把 Jupyter Notebook 搬到鸿蒙 PC:同栈双胞胎的 Notebook-first 移植实战
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_jupyterNotebook
写在前面
这是最有意思的一篇——因为它不是从零开始移植一个应用,而是从已经跑通的姊妹工程里分叉出另一个产品形态。
Jupyter 生态里有个特殊现象:Notebook 7 的前端就是 JupyterLab 组件构建的。上游jupyter/notebook从 7.0 开始放弃了老nbclassic的 jQuery 代码库,改为复用 Lab 的 React/Phosphor 前端。这意味着:如果你已经把 JupyterLab 移植到了鸿蒙 PC,你就已经顺手完成了 Jupyter Notebook 90% 的移植工作——剩下的是产品身份分叉和 UI 形态收敛。
任务清单要求两个产品分列交付、独立安装、独立评分,所以这次的核心问题不是「能不能跑」(Lab 已经验证),而是「怎么把一套已验证的技术栈,切成两个能共存、可独立演进的交付物」。
一、为什么 Notebook 7 是「最容易的硬骨头」
先看清楚上游关系:
jupyter/notebook (Notebook 7) │ 前端复用 ▼ jupyterlab/jupyterlab (Lab 组件库) │ OHOS 适配已验证(姊妹工程 ohos_JupyterLab) ▼ OpenHarmony Electron (libelectron.so) + node-static + 浏览器内 PythonNotebook 7 = Lab 前端 + single-document 外壳。上游用pageConfig控制产品形态:
| 配置 | JupyterLab 产品 | Jupyter Notebook 产品 |
|---|---|---|
pageConfig.mode | multiple-document | single-document |
| 启动 URL | /lab(Launcher 多文档入口) | /lab/tree/Untitled.ipynb(直接打开笔记本) |
| Launcher 卡片页 | 有 | 禁用 |
| 左侧文件浏览器 | 有 | 禁用 |
| TOC 侧栏 | 有 | 禁用 |
| bundleName | org.jupyter.lab.ohos | org.jupyter.notebook.ohos |
| 环境变量前缀 | JUPYTERLAB_* | NOTEBOOK_*(兼容JUPYTERLAB_*) |
所以移植策略非常清晰:fork 姊妹工程 → 改产品身份配置 → 保持技术栈完全复用。
二、从 Lab 分叉:bootstrap 脚本做了什么
两个仓库之间用一个 bootstrap 脚本同步 runtime(约 2000 行 Electron 主进程代码不重复维护):
nodescripts/bootstrap-from-notebook.mjs# Notebook → 姊妹方向nodescripts/bootstrap-from-lab.mjs# 反向(Lab 仓库持有源)脚本做的字符串/配置级重写:
org.jupyter.lab.ohos → org.jupyter.notebook.ohos productUi: 'lab' → productUi: 'notebook' /lab(Launcher 入口) → /lab/tree/Untitled.ipynb JUPYTERLAB_*(优先) → NOTEBOOK_*(优先,JUPYTERLAB_* 兼容) hnpPackages type: private → type: public ← 关键差异,见 §四工程判断:这种「同栈双产品」的架构下,bootstrap 脚本就是唯一的真相源。所有产品身份相关的东西都集中在这一个脚本里,避免两个仓库手工 drift。
三、架构:和 Lab 完全同栈,只有入口不同
三种服务模式与 Lab 完全一致:
| 模式 | 环境变量 | 场景 |
|---|---|---|
| node-static(默认) | NOTEBOOK_OHOS_SERVER_MODE=node-static | 静态前端 + 浏览器内内核,零原生依赖 |
| python | NOTEBOOK_OHOS_FORCE_PYTHON=1 | 设备上跑python -m jupyterlab(需 aarch64 发行版) |
| external | NOTEBOOK_EXTERNAL_SERVER_URL=… | 连接已有 Jupyter Server |
为什么默认 node-static 而不是 python 模式?pyzmq/libsodium 在设备侧仍然脆弱(官方文档原话),交叉编译一个能跑 Jupyter Server 的 Python 环境的工程成本,远超「静态前端 + 浏览器内解释器」的方案。这是整个 Jupyter 双胞胎移植里最重要的一个工程决策。
四、HNP 双胞胎共存机制(本次最大坑)
两个产品都打包了一个 HNP 原生包(jupyterlab_python.hnp,用于可选的原生 Python 能力)。第一次装 Notebook 时直接翻车:
Install Failed: code:9568407 Failed to install the HAP because installing the native package failed.hilog 关键线索:
ProcessBundleInstallNative … hnp install: electron [HNP API] native package install! … package name=org.jupyter.notebook.ohos already exist cfg ignore … MSG_ERR_NATIVE_INSTALL_FAILED根因:HNP 包有public/private两种作用域:
| 类型 | 作用域 | 冲突规则 |
|---|---|---|
public | 设备全局唯一 | 同名 public 包只能装一个 |
private | 单应用沙箱 | 每个应用各持一份,互不干扰 |
两个产品声明了同名jupyterlab_python.hnp,如果都是 public,先装的占坑,后装的报 9568407。
修复方案(不对称设计):
// Notebook(先发布,占 public 坑) "hnpPackages": [ { "package": "jupyterlab_python.hnp", "type": "public" } ] // Lab(后发布,用 private 共存) "hnpPackages": [ { "package": "jupyterlab_python.hnp", "type": "private" } ]改完必须重新assembleHap——模块元数据是烘焙进 HAP 的,热改无效。
方法论:任何「同栈双胞胎」交付,都要在 HNP 这一层显式设计共存策略,且不对称(一个 public 一个 private)是最稳的——对称 public 会互相踢,对称 private 浪费设备空间且语义不明。
五、真机验收:六个功能点逐一过
设备:HUAWEI MateBook Pro,HarmonyOS 7.0.0。
5.1 启动直达笔记本(single-document 的核心体验)
这就是 Notebook-first 与 Lab-first 的用户可感知差异:打开就是笔记本,光标在 cell 里,可以直接打代码。Click to add a cell.提示、Mode: Command/Edit状态切换、Ln x, Col y行列号全部工作。
5.2 Cell 执行与返回值显示
注意[3]: None这一行——这是 Jupyter 对表达式返回值的自动回显(print()返回 None)。这个细节存在,说明 Skulpt 内核的执行协议和前端的消息通道是完整实现的,不是简单地把 stdout 打到页面上。
5.3 菜单完整度
20+ 菜单项、快捷键提示、二级菜单全部渲染正确。右下角还能看到系统任务栏(BOSS 直聘、邮件、抖音)——这是真机全屏截图,不是模拟器。
5.4 键盘快捷键帮助
弹窗、滚动、关闭交互全部正常——Lab 前端的 Modal 组件在 OHOS Electron 上没有降级。
5.5 ASCII 艺术字输出(多行字符串渲染)
值得记录的一个细节:这些 box 字符(╔═╗║╚╝)在r"""..."""raw 三引号字符串里会触发 Skulpt 词法器 bug(SyntaxError: bad input),改用普通"""..."""三引号或分行print()就完全正常。截图里的输出就是修复后的结果——这是 Skulpt 内核的边界,不是平台问题。
5.6 无 matplotlib 环境下的数据可视化(本文最有价值的一节)
鸿蒙 PC 的 Jupyter 双胞胎默认内核是 Skulpt,没有 matplotlib / numpy。但数据可视化需求是真实的——教学场景画个函数曲线、演示趋势,不能没有。解法是纯标准库 ASCII 折线图:
完整代码(约 40 行,纯math+ 列表推导,Skulpt 100% 兼容):
importmath# 1. 数据xs=[i*0.4foriinrange(24)]ys=[math.sin(x)*3+x*0.25forxinxs]# 2. 画布W,H=64,18min_y,max_y=min(ys),max(ys)min_x,max_x=min(xs),max(xs)defto_col(x):returnint((x-min_x)/(max_x-min_x)*(W-2))+1defto_row(y):returnint((y-min_y)/(max_y-min_y)*(H-2))# 3. 画布初始化 + 坐标轴canvas=[[' 'for_inrange(W)]for_inrange(H)]forrinrange(H):canvas[r][0]='│'forcinrange(W):canvas[H-1][c]='─'canvas[H-1][0]='└'# 4. 描点 + 插值连线foriinrange(len(xs)):col=to_col(xs[i])row=H-1-to_row(ys[i])canvas[row][col]='●'ifi>0:prev_col=to_col(xs[i-1])span=col-prev_colifspan>1:forcinrange(prev_col+1,col):t=(c-prev_col)*1.0/span y=ys[i-1]+(ys[i]-ys[i-1])*t canvas[H-1-to_row(y)][c]='·'# 5. 峰谷标注peak_i=ys.index(max(ys))low_i=ys.index(min(ys))canvas[H-1-to_row(ys[peak_i])][to_col(xs[peak_i])]='▲'canvas[H-1-to_row(ys[low_i])][to_col(xs[low_i])]='▼'# 6. 输出print("y_max = {:.2f}".format(max_y))forrowincanvas:print(''.join(row))print("y_min = {:.2f} x: {:.1f} -> {:.1f}".format(min_y,min_x,max_x))几个工程要点:
- 刻意不用 f-string(部分 Skulpt 版本支持不稳),全部
.format() - 刻意不用 raw string(
r"""是词法坑),box 字符只出现在普通短字符串里 - 插值连线拆成
span/t/y三个中间变量,避免长链表达式触发解析器边界 [i]: None返回值回显、错误行号定位(bad input on line N)都正常工作
这套「降级可视化」思路适用于任何没有图形栈的嵌入式 Python 环境——Skulpt、MicroPython、受限容器都是同一个问题域。
六、双胞胎工程的坑位对照
这次 Notebook 分叉把 Lab 踩过的坑原样再踩一遍,但每个坑的解法都能直接复用,成本大幅下降:
| 坑 | Lab 首次解决成本 | Notebook 复用成本 |
|---|---|---|
CompileArkTS 10705000(import lazy) | 排查半天,定位到compatibleSdkVersionStage: beta1 | 0(bootstrap 直接钉住6.0.1(21)) |
| SignHap 00303074(profile 绑 bundle) | 摸清 debug profile 的 bundle 绑定机制 | 5 分钟(DevEco Fix 一次) |
Install 9568320(signingConfig: "") | 半小时(发现 DevEco 不回填 products 引用) | 5 分钟(知道看产物文件名) |
| Install 9568407(HNP 冲突) | 理解 public/private 作用域 | 0(bootstrap 写好public) |
| resfile 放错模块 | 白屏半天 | 0(脚本生成正确路径) |
这就是「同栈双胞胎」模式的最大红利:第一个产品的踩坑成本是沉没成本,第二个产品的边际成本趋近于零。反过来,如果两个产品各自独立移植,这些坑要踩两遍。
七、复现命令
cdohos_JupyterNotebook# 1. 从 Lab 工程同步 runtime(首次必做)nodescripts/bootstrap-from-lab.mjs# 或按仓库实际脚本名# 2. 打包 runtime 到 web_engine resfilenodepkg/ohos/build-package.mjs# 3. 构建 HAPnodepkg/ohos/build-hap.mjs# 跳过 hvigor 仅打包资源:# NOTEBOOK_OHOS_SKIP_HVIGOR=1 node pkg/ohos/build-hap.mjs# 4. DevEco 自动签名 → 检查 products[].signingConfig = "default"# 5. 安装 + 启动hdcinstall-relectron/build/default/outputs/default/electron-default-signed.hap hdc shell aa start-aEntryAbility-borg.jupyter.notebook.ohos环境变量速查:NOTEBOOK_PORT=8888、NOTEBOOK_TOKEN(默认随机)、NOTEBOOK_ROOT_DIR=<userData>/notebooks、NOTEBOOK_DISABLE_GPU=1。
八、给「同栈多产品」交付的方法论
这套模式适用于所有「一个技术栈要交付多个产品形态」的场景(IDE 的社区版/专业版、浏览器的稳定版/测试版、办公套件的多个组件):
- 先跑通一个,再分叉第二个——第一个产品的踩坑日记就是第二个产品的施工图
- bootstrap 脚本是唯一真相源——产品身份的所有差异集中在一个可 review 的 diff 里,禁止手工双仓库漂移
- HNP/权限/签名按 bundle 隔离——debug profile 绑 bundleName,HNP 设计不对称共存(public/private),一个都别复用
- UI 形态差异交给上游配置——
pageConfig.mode这种官方开关比魔改前端代码稳定一个数量级 - 降级路径先于完整路径——node-static + 浏览器内内核先落地,原生 Server 后补;能跑的 60 分比跑不起来的 100 分有价值
- 受限环境的可视化用 ASCII——没有图形栈时,40 行纯标准库代码就能覆盖教学场景 80% 的画图需求
回头看这个项目最大的价值不是「又移植了一个应用」,而是验证了同栈双胞胎的工程模式:Notebook 7 的分叉只花了 Lab 首次移植约 20% 的时间,而这 80% 的节省全部来自 Lab 留下的踩坑记录和 bootstrap 脚本。如果你手上也有类似「一个底座多个产品」的移植任务,强烈建议按这个模式组织仓库——第一个产品慢一点没关系,它是在为后面的所有产品铺路。
常见问题 FAQ
Q1:和 JupyterLab 版有什么区别?能同时装吗?
能共存。两者技术栈完全相同,差异只在产品形态:Notebook 打开就是单个笔记本(single-document,无 Launcher / 文件浏览器),Lab 是多文档 + Launcher。HNP 层做了不对称设计(Notebook 用public、Lab 用private),互不冲突。
Q2:为什么打开后不是经典的/tree老界面?
上游 Notebook 7 已经放弃老nbclassic的 jQuery 前端,改为复用 Lab 组件。本工程走的是官方路线——Lab 静态资源 +pageConfig.mode = single-document,视觉上是「单文档笔记本」,但底层不是老/tree。
Q3:能装 numpy / matplotlib 吗?
默认不能。内核是 Skulpt(浏览器内 Python 子集),没有 C 扩展生态。两个出路:切NOTEBOOK_EXTERNAL_SERVER_URL连远程 Jupyter Server 用真内核;或者用纯标准库 ASCII 图表顶住教学场景(见 §5.6 的 40 行折线图代码)。
Q4:为什么r"""..."""三引号字符串报SyntaxError: bad input?
Skulpt 词法器对 raw 三引号 + box-drawing 多字节字符有边界 bug。去掉r前缀用普通"""...""",或把内容拆成多行print()就正常。真 Python 解释器跑同样代码没问题——这是内核限制,不是平台 bug。
Q5:签名成功了还是报9568320 no signature file?
看产物文件名。unsigned说明build-profile.json5里products[].signingConfig还是空串——DevEco 自动签名只写材料不回填这个引用。手动改成"default"重新构建。
Q6:装了 Lab 再装 Notebook,报9568407原生包失败?
HNP 同名冲突。两个产品的hnpPackages里声明了同名jupyterlab_python.hnp,public类型设备全局唯一,后装的会被拒。用新版 HAP(Notebookpublic/ Labprivate的不对称组合)即可共存;不想重编就先卸载另一个再装。