把 JupyterLab开源版搬上鸿蒙应用市场的上架全记录

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_jupyter

一款 500MB 的"重装"应用是怎么通过华为应用市场审核、成功上架鸿蒙 PC 的?本文从架构设计讲到打包签名,再讲到上架审核的全流程踩坑,技术为主,上架为纲。

JupyterLab Launcher

一、为什么做这个应用

鸿蒙 PC 的应用生态正在快速补位,开发者工具类是缺口最明显的一块。JupyterLab 是数据科学与教学领域事实上的标准交互式计算环境——Notebook、控制台、文件浏览器、多标签工作区一应俱全,但在鸿蒙上长期没有一个原生可安装的版本。

在这里插入图片描述

在动手之前,我把目标用户和场景想得很具体,这直接决定了后面的技术取舍:

  • 教学场景:老师带学生上 Python 入门课,需要"人手一个环境、零配置开课"。传统方案是每人装 Anaconda(几个 GB)或者连教师机的 JupyterHub,鸿蒙 PC 上的教室里这两条路都不通。一个从应用市场一键安装、离线可用的 Notebook 是刚需。
  • 数据分析:业务同学拿到的 .ipynb 分析报告,在鸿蒙 PC 上应该能直接打开复现,而不是只能看导出的 HTML。
  • 脚本实验:开发者想随手验证一段 Python 逻辑、跑个正则、算个日期,打开一个 Notebook 比开终端顺手得多。

这三个场景有一个共同的隐含约束:不能假设有网。教室可能屏蔽外网,办公网可能限制端口。所以这个项目的验收标准从第一天起就是"全程离线可用",而不是"联网后可用"。

这个项目要做的就是一件事:让用户从应用市场点一下"安装",就能在鸿蒙 PC 上得到一个开箱即用的 JupyterLab,新建 Notebook、写代码、运行单元格、保存 .ipynb,全程离线可用。

上架后的样子(应用市场搜索 “python” 即可命中):

应用市场上架页

应用名称最终定为「Python 笔算方舟实验台」——上架应用需要考虑应用市场搜索分发,“JupyterLab” 对普通用户几乎没有搜索量,而 “Python” 是这个应用最核心的关键词。这也是本文想传达的第一个上架经验:技术身份和产品身份可以分离,包名 org.jupyter.lab 保持开源社区血统,应用名面向真实用户。

二、总体架构:为什么是 Electron 壳

2.1 路线选型

JupyterLab 上游是一个 Web 技术栈的桌面应用(TypeScript 前端 + Tornado 后端 + Python 内核),有三条可选的适配路线:

路线做法结论
ArkWeb 重写用系统 Web 组件加载 Lab 前端,ArkTS 重写后端Lab 后端重度依赖 Python 生态,ArkTS 无法复刻,工作量不可控
纯 ArkTS 原生重写用 ArkUI 重画一套 Notebook UI丢掉上游全部前端能力(插件、快捷键、调试器),维护即噩梦
OpenHarmony Electron上游桌面壳原样移植,只做平台适配复用上游 99% 代码,适配点收敛到主进程,选定

OpenHarmony Electron 是鸿蒙 PC 上已被验证的桌面应用运行时:libelectron.so 承载 Chromium + Node.js,ArkTS 侧只需要一个极薄的壳。鸿蒙 PC 上的多款桌面工具都走了这条路,成熟度足够支撑上架级质量。

2.2 分层结构

整体分层如下:

架构图

┌─────────────────────────────────────────────┐
│  ohos_hap/  (DevEco HAP 壳)                 │
│  EntryAbility → OpenHarmony Electron        │
│  BrowserWindow.loadURL → http://127.0.0.1   │
└──────────────────▲──────────────────────────┘
                   │
┌──────────────────┴──────────────────────────┐
│  pkg/ohos/runtime  Electron 主进程          │
│  · 平台探测:JUPYTERLAB_OPENHARMONY=1       │
│  · 默认关 GPU 合成(OHOS 上不稳定)          │
│  · 三种服务模式(下文详述)                  │
└─────────────────────────────────────────────┘
  • HAP 壳只做一件事:拉起 Electron 运行时(libelectron.so)。ArkTS 侧几乎没有业务代码,这大幅压缩了需要维护的原生代码量。
  • Electron 主进程负责起本地服务、建 BrowserWindow、加载 http://127.0.0.1:<port>/lab。
  • JupyterLab 前端资产整体打进 HAP 的 resfile,随包分发,离线可用。

一次完整的启动序列是这样的:

  1. 系统拉起 EntryAbility,EntryAbility 启动 Electron 运行时;
  2. 主进程探测运行环境(JUPYTERLAB_OPENHARMONY=1 强制声明 OHOS 平台),决定服务模式;
  3. node-static 模式下,主进程在本机回环地址起一个静态文件服务,挂载 Lab 前端资产;
  4. 创建 BrowserWindow,loadURL(http://127.0.0.1:<port>/lab);
  5. 前端加载完成后,内核在浏览器内启动(node-static 模式),或由主进程 spawn 设备端 Python(python 模式)。

整条链路没有一步需要联网,回环地址的 HTTP 只发生在进程内部。

2.3 环境变量:配置的唯一入口

主进程的所有可调参数都收敛到 JUPYTERLAB_* 前缀的环境变量(同时兼容姊妹工程的 NOTEBOOK_* 前缀,便于 CI 共享):

变量作用
JUPYTERLAB_OPENHARMONY=1强制 OHOS 平台探测
JUPYTERLAB_DISABLE_GPU关闭硬件加速(OHOS 上默认开启)
JUPYTERLAB_OHOS_SERVER_MODEnode-static / python / external
JUPYTERLAB_PORT / JUPYTERLAB_TOKEN本地服务监听端口 / 访问令牌
JUPYTERLAB_ROOT_DIRNotebook 工作区根目录
JUPYTERLAB_PYTHON_PATH指定 Python 解释器路径
JUPYTERLAB_EXTERNAL_SERVER_URL外部 Jupyter Server 地址

用环境变量而不是配置文件,好处是排障时可以直接在日志里看到完整的生效配置,也方便高级用户不改包就改变行为——这对一个上架应用来说是很实用的"逃生舱"。

2.4 三种服务模式

主进程按环境变量决定服务模式,这是整个适配里最关键的弹性设计:

模式环境变量场景
node-static(默认)JUPYTERLAB_OHOS_SERVER_MODE=node-static本地静态 Lab UI + 浏览器内内核,开箱即用
pythonJUPYTERLAB_OHOS_FORCE_PYTHON=1设备端 CPython python -m jupyterlab,完整生态
externalJUPYTERLAB_EXTERNAL_SERVER_URL=http://…连接局域网/远程已有的 Jupyter Server

上架版默认走 node-static:用户装完就能用,不依赖任何网络和后端。python 模式作为进阶能力保留——随包打了一个 HNP 格式的 aarch64 CPython(下文详述)。external 模式则面向有远程算力需求的用户:笔记本前端本地渲染,内核跑在实验室服务器上。

三、关键技术点拆解

3.1 HNP:把 CPython 塞进 HAP

鸿蒙的原生可执行程序分发走 HNP(HarmonyOS Native Package) 机制。Python 解释器交叉编译为 aarch64 后打成 jupyterlab_python.hnp,在 module.json5 里声明:

"hnpPackages": [
  { "package": "jupyterlab_python.hnp", "type": "private" }
]

这里有个踩过的坑:姊妹工程 Notebook 用的是同一个 HNP 文件但声明为 "type": "public"(设备全局注册)。如果先装了 Notebook,再装声明 public 的本应用,安装时会报 9568407 Failed to install the native package——同一个 public HNP 包名不允许被第二个应用重复注册。解决方案就是本应用改成 private,两个应用即可共存,各自持有独立的 Python 运行时副本。

public 与 private 的语义差异值得展开说:public 意味着安装时把 HNP 注册到设备全局目录(/data/service/hnp),任何应用都能引用这一份;private 则是每个应用在自己的沙箱内持有一份私有副本。对 JupyterLab 来说后者反而是更合理的语义——Python 环境和前端资产版本强耦合,共享一份全局解释器只会带来"谁升级谁背锅"的版本漂移问题。

3.2 Python 环境的探测与回退链

python 模式下解释器怎么找,是主进程里最讲究的一段逻辑。直接写死路径会在不同设备、不同安装状态下翻车,所以实现了一条多级回退链:

  1. 用户显式指定:JUPYTERLAB_PYTHON_PATH 存在则直接使用;
  2. HNP 安装目录:探测 /data/service/hnp/bin/jupyterlab-python3(HNP public 链接)及沙箱内的 HNP 安装路径;
  3. userData 镜像:随包只读 site-packages 首启镜像出的可写副本;
  4. 应用自带目录:包内 pylibs/ 的兜底路径。

每一级候选不是找到了就用,而是先用 probeJupyterLabImport 做一次导入探活——让候选解释器真的 import 一次 JupyterLab 核心模块,确认能出结果才算数。探活失败的候选静默跳过,全链失败时给出明确的错误页而不是白屏。

更进一步的细节:如果所有候选都无法导入 JupyterLab(比如设备 Python 是裸的),主进程还有一条首启自举路径——利用 spawn 上下文能访问设备 Python 的前提,在可写目录里执行 pip 安装 JupyterLab 再启动。三层保险,覆盖了从"全新设备"到"装过姊妹工程"的各种状态。

另一个不起眼但关键的钩子是 native extension finder:Python 的 C 扩展(_ssl、zlib 这类)在鸿蒙上分布在 HAP 的 native 库目录里,标准 import 机制找不到。运行时往 sys.meta_path 注入了一个自定义 Finder,把 import 请求重定向到 JUPYTERLAB_OHOS_NATIVE_LIB_DIR 下查找对应的 .so。没有这个钩子,解释器能启动但 ssl 模块加载失败,pip 会当场罢工。

3.3 GPU 合成:默认关掉

OpenHarmony Electron 的 GPU 合成在部分设备上不稳定(白屏、花屏),主进程启动时无条件降级:

if (shouldDisableGpu()) {
  app.commandLine.appendSwitch('disable-gpu');
  app.commandLine.appendSwitch('disable-gpu-compositing');
  app.disableHardwareAcceleration();
}

代价是动画性能略有损失,但对以表单和文本渲染为主的 Notebook 界面几乎无感。上架应用的稳定性永远优先于跑分——审核员和用户的第一印象是"能不能正常打开",而不是渲染快 20%。

同级别的稳定性措施还有两处:

  • 单实例锁:app.requestSingleInstanceLock() 保证同一时刻只有一个主进程,二次启动唤起已有窗口。Notebook 场景下用户很可能从文件管理器重复触发打开,没有锁就会起来两个实例抢同一个端口。
  • 日志落盘:所有主进程关键事件(平台探测结果、服务模式、Python 候选链、探活输出)通过 writeLog 追加写到 userData/jupyterlab-runtime.log。用户报问题时让他把这个文件发过来,比口头描述"打不开了"有用一百倍。

3.4 窗口标题:产品身份的最后一公里

JupyterLab 前端把标题写死为 xxx - JupyterLab。上架后用户在任务栏看到的必须和市场登记的应用名一致,否则会被认为"货不对板"。主进程用 page-title-updated 事件做拦截改写:

const productName = 'Python笔算方舟实验台';
mainWindow.webContents.on('page-title-updated', (event, title) => {
  let rewritten = title;
  if (/- JupyterLab$/.test(title)) {
    rewritten = title.replace(/ - JupyterLab$/, ` - ${productName}`);
  } else if (/^JupyterLab$/.test(title)) {
    rewritten = productName;
  }
  // ...
});

注意这里要处理的不只是完整标题:Lab 前端在 Launcher 页发的标题就是裸的 JupyterLab,而在编辑页发的是 <文件名> - JupyterLab,两种都要覆盖。事件是持续触发的——每次切换标签、重命名文件都会重发——所以拦截必须在窗口生命周期内始终生效,而不是只在启动时改一次。

这类"品牌一致性"细节在审核中很容易被忽视,但恰恰是上架应用与野生移植版的分水岭。

3.5 数据落盘:写到哪里都是确定的

沙箱应用的文件读写必须收敛到用户可见、可备份的位置。运行时把日志、Jupyter 配置、Python 镜像统一放到 userData 目录(jupyterlab-runtime.log、jupyter-config/、bundled-python/),Notebook 工作区落在用户文档目录。首次保存时的重命名对话框走标准交互:

重命名保存

数据目录的设计有两条原则:

  • 只读区与可写区严格分离。随包的 site-packages、前端资产永远是只读的,任何写操作都发生在 userData 的镜像区。这样升级应用时直接覆盖安装包内容即可,不会弄脏用户数据,回滚也干净。
  • 镜像操作带指纹校验。把随包环境镜像到可写目录时,先算源目录的 fingerprint 写进标记文件(如 .jupyterlab-ohos-python.json),下次启动先比对指纹,一致就直接复用,不一致才重建。2 万多个文件的全量拷贝只在版本变化时发生一次。

四、运行效果

新建一个 Notebook,写一段带打字机效果的 Python 脚本直接运行——内核调度、输出流式刷新、单元格状态流转全部正常:

Notebook 运行效果

从截图可以看到几个值得说的细节:

  • 单元格执行序号([1]、[2])由内核按真实执行顺序分配,[*] 的执行中状态、In [n]: 的空闲状态切换正常,说明前端与内核的 message 协议(Jupyter messaging over ZeroMQ/WebSocket)在鸿蒙上完整工作;
  • 流式输出(flush=True 的逐行打印、time.sleep 的打字机效果)逐帧刷新而非一次性吐出,验证了 stdout 通道的实时性;
  • 多行字符串、转义字符、f-string 等 Python 语法全部正确渲染,代码高亮由上游 CodeMirror 原样继承。

Launcher 页新建 Notebook / Text File / Markdown / Python File 一应俱全,多标签工作区(VS Code 式布局)完整保留:

Launcher

左侧文件浏览器支持新建、重命名、移动、删除;顶栏的 File / Edit / View / Run / Kernel / Tabs / Settings / Help 菜单全部可用;快捷键体系(Shift+Enter 运行并下移、A/B 上下插单元格、DD 删除)与桌面版一致——老 Jupyter 用户零学习成本。

五、上架全流程与踩坑

这是本文的重点。整个上架链路可以拆成签名、包体、材料、审核四关。

5.1 签名:Debug 证书过不了市场这一关

上架包必须用发布证书 + 发布 Profile 签名,这与本地调试签名是完全独立的一套材料:

材料来源用途
.cer / .csr / .p12AGC 证书中心发布证书链
release .p7bAGC 发布 Profile(绑定 org.jupyter.lab)只能给上架包签名
debug 自动签名 .p7bDevEco 自动生成仅真机调试,市场拒收

签名材料的生成顺序是:先在本地用 keytool 生成密钥对并导出 .csr,上传到 AGC 证书中心换取 .cer 发布证书;再在 AGC 上创建绑定 org.jupyter.lab 的发布 Profile,把 .cer 与 Profile 打包成 release .p7b。这套材料(本工程里的 jupyter.cer / jupyter.csr / jupyter.p12 / jupyterRelease.p7b)只对上架包生效,日常真机调试继续用 DevEco 的自动签名,两条线互不干扰。

调试阶段踩过的两个经典报错值得记下:

  • 00303074:自动签名 Profile 是按包名绑定的。给 org.jupyter.notebook.ohos 生成的 Profile 签不了 org.jupyter.lab,多应用并行的团队务必为每个 bundle 单独生成。
  • 自动签名时 DevEco 可能把 compatibleSdkVersion 改写成 6.1.0(23)+beta1,导致编译期 import lazy 报 10705000。上架版锁死 6.0.1(21),且不写 compatibleSdkVersionStage 字段。

第二条尤其隐蔽:DevEco 的 Project Structure 界面在每次点 Sync 或签名 Fix 之后都可能悄悄改写 build-profile.json5,报错信息又只说 es2abc 拒绝 lazy import,不提示根因是 SDK 版本配置。每次动过签名配置之后,都应回头检查一遍 product 段的 SDK 字段。

5.2 包体:505MB 的 HAP 怎么处理

最终签名的 HAP 约 505MB——里面装着 Electron 运行时、JupyterLab 前端资产、HNP CPython 解释器和标准库。几个减负和打包要点:

  1. 资源放 resfile:HAP 对 rawfile/resfile 的处理方式不同,Electron 应用树整体放 resfile/resources/app,安装后按路径直读,不做解压拷贝,首启快且不占双份空间。路径必须精确到 ohos_hap/web_engine/src/main/resources/resfile/resources/app——Electron 自身模块目录下的 resfile 并不是打包进 HAP 的应用树,这是踩过弯路才确认的。
  2. Python 环境做指纹镜像:首次启动把随包的只读 site-packages 镜像到 userData 可写目录,用 fingerprint 标记版本,避免每次启动重复拷贝 2 万多个文件。
  3. 剥离无用组件:torch、vtk 这类可选依赖一律不进包。JupyterLab 全库扫描后确认仅 3D 视图用 vtk、部分可选后端用 torch,核心 Notebook 体验完全不依赖,剔除后包体降了数百 MB。

构建同步这一步也值得单独说明:pkg/ohos/build-package.mjs 负责把 pkg/ohos/runtime 下的主进程源码、HTML 资产镜像同步到 web_engine 与 entry 两份 resfile——改完 runtime 源码必须跑一次同步再重编,直接改 resfile 里的文件会在下次构建时被镜像覆盖,这是典型的"改了没生效"陷阱。

应用市场上架对包体没有硬性上限,但 500MB 级别的应用必须保证描述与体积匹配——市场详情页要明确说明"内置完整 Python 3.12 运行环境,无需联网",让用户对下载体积有预期,这是降低差评和卸载率的关键。

5.3 上架材料清单

AGC(AppGallery Connect)上创建应用后需要准备的材料,逐项列一下:

材料要点
应用信息名称、简介、分类(工具)、年龄分级 3+
应用图标遵循鸿蒙设计规范,不能直接用上游 favicon
截图/预览图至少覆盖核心场景:Launcher、Notebook 编辑运行、多标签工作区
权限声明本应用几乎零敏感权限,最小化声明是过审的通行证
隐私政策说明数据全部本地处理、不联网收集,与实际实现一致
版权与开源JupyterLab 上游 BSD-3-Clause,需在应用内与描述中保留版权声明
签名 release 包上述发布证书签名的 HAP

几项材料的实操经验展开说说:

  • 应用简介要按"是什么、给谁用、凭什么装"三段写。上架版的简介开头直接点明"交互式编程与数据分析工作台",中段罗列一键新建 Notebook、文件浏览器统一管理工程、多标签并行作业这些可感知的功能,末段强调"内置原生 Python 3.12、离线可用"——每句话都是用户决策的依据,不写空话。
  • 截图宁多勿少。Launcher(第一印象)、Notebook 运行(核心能力)、多标签工作区(效率卖点)三张是底线,市场详情页的截图就是你的应用商店首页,审核员也会拿截图与实际界面比对。
  • 权限声明是这个项目的天然优势:本地回环 HTTP、本地文件读写都在沙箱默认权限内,不申请位置、通讯录、麦克风任何一个敏感权限,隐私页干净得没有任何可挑之处。
  • 年龄分级问卷如实填写"无社交、无付费、无用户生成内容共享",得到 3+。Notebook 虽然能执行任意代码,但执行的是用户自己的代码,不构成 UGC 分享,不影响分级。

其中开源合规值得一提:BSD-3-Clause 允许商用分发,但要求保留版权声明与许可文本。应用描述里明确"基于开源 JupyterLab 构建",既满足协议要求,也是给应用背书。

5.4 审核:描述与实现严格一致

审核阶段的反馈通常集中在"功能与描述不符"。上架描述里的每一句话都要能在真机上复现:

“应用内置原生 Python 3.12 运行环境,设备侧直接启动 Python 内核,不依赖网络与云端,安装后开箱即用,支持单元格重启、中断与多会话管理,标准 .ipynb 格式与桌面端完全兼容。”

这句话对应的就是 python 服务模式 + HNP 内置解释器的实现。如果描述里写了"完整 Python 生态",但实际装上去 pip install 不可用,那就是打回重改。宁可描述保守一点,也不要让审核员找出实现不上的承诺。

提交前建议做一轮"对着描述逐句验收":描述里提到单元格重启,就在真机上重启一次内核;提到多会话,就开两个 Notebook 并行跑;提到 .ipynb 兼容,就把桌面端导出的 Notebook 拷进来打开跑一遍。每句承诺对应一个真机动作,全绿再提审。

最终,应用通过审核并上架应用市场,分类"工具",面向鸿蒙 PC 用户分发。

5.5 版本与后续升级

上架只是 1.0.0(versionCode: 1000000)的起点。鸿蒙应用的版本递增规则要求每次上架 versionCode 严格递增,所以工程里把这个字段单独当作发版管理项对待,而不是随手改。后续的升级节奏规划为:

  • 解释器升级(3.12 → 3.13)走 HNP 重新打包,private 语义保证了不会影响设备上其他应用的同名包;
  • 前端资产升级(跟随上游 JupyterLab 4.x)只动 resfile,HNP 和壳不动,包体增量小;
  • 镜像指纹机制天然兼容升级:新包的 site-packages 指纹变了,首启自动重建可写副本,用户无感。

六、构建复现

完整构建链路两条命令:

node pkg/ohos/build-package.mjs   # runtime 同步到 web_engine resfile
node pkg/ohos/build-hap.mjs       # hvigor assembleHap 出 HAP

前置环境与产物:

项要求
DevEco Studio需自带 hvigor 与 SDK(DEVECO_SDK_HOME 指向 SDK 目录)
JavaDevEco 自带 JBR 的 bin 目录前置到 PATH(PackageHap 打包依赖 java,缺失报 00308018)
Python 镜像源完整 HAP 需 JUPYTERLAB_OHOS_PYTHON_DIST 指向 aarch64 prefix
SDK 版本锁 6.0.1(21),不写 compatibleSdkVersionStage

签名后的产物:

entry/build/default/outputs/default/entry-default-signed.hap  (~505MB)

构建过程的高频报错速查:

报错码根因处置
00308018PackageHap 找不到 javaDevEco jbr/bin 前置 PATH
10705000SDK 被改写为 beta1,lazy import 被拒改回 6.0.1(21),去掉 Stage 字段
00303074签名 Profile 与 bundle 不匹配为 org.jupyter.lab 单独生成材料
9568407(安装期)HNP public 包名冲突本应用 HNP 声明为 private

七、总结

维度做法
架构HAP 壳 + OpenHarmony Electron + 本地静态服务,三种服务模式弹性切换
运行时HNP 机制分发 aarch64 CPython,private 类型规避包名冲突
稳定性默认关 GPU 合成,单实例锁,日志落 userData
数据只读/可写严格分离,指纹镜像增量同步
上架发布证书签名、产品身份统一改写、开源合规、描述可复现

这个项目最大的心得是:移植只是上半场,上架是下半场。签名材料与包名严格绑定、产品身份全局统一、审核描述逐句可验证——这些工程外的功课,决定了开源成果能不能真正走到用户手里。

如果说还有什么经验值得复制到下一个项目,那就是这三条:

  1. 回退链思维:解释器找得到就用,找不到就自举,再不行给明确报错——上架应用面对的是无法控制的设备状态,任何"假设环境正常"的代码都会变成用户手里的崩溃;
  2. 身份一致性:包名、窗口标题、市场名称、图标、简介,五处必须讲同一个故事,审核员和用户都在核对这个故事;
  3. 描述即验收清单:把上架描述当成测试用例集来写,每句承诺都在真机上验过再提交。

鸿蒙 PC 的开发者工具生态还很年轻,应用市场里每多一个"能跑真 Python"的工具,生态就厚一分。欢迎在应用市场搜索「Python 笔算方舟实验台」体验,也欢迎基于 BSD-3-Clause 协议参与共建。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐