墨匣学术写作编辑器上架鸿蒙PC:从 200MB Electron 到 6MB 原生壳的架构收敛

欢迎加入开源鸿蒙PC社区: https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

墨匣的第一版走的是最「顺路」的技术路线:把一个成熟的 Electron Markdown 编辑器整体搬上鸿蒙 PC。功能全对,但包体接近 200MB,还踩中了运行时底座在新系统上的兼容深坑。第二版做了架构级收敛:抛弃重型运行时,用 ArkTS 原生壳 + ArkWeb 组件直接承载编辑器前端资产——包体降到 6MB,权限表清零,进程稳定。本文按上架全流程复盘这次「做减法」的工程:需求怎么收敛、两条路线怎么比选、桥接层怎么设计、文件链路怎么走系统选择器、跨语言边界上「返回值不可靠」的坑怎么绕。

项目上架信息 / 工程事实
应用名墨匣学术写作编辑器
包名com.zettlr.moxia
版本1.0.0 (1000000)
设备形态2in1 / tablet(HarmonyOS PC)
上游项目Zettlr(开源 Markdown 学术写作编辑器,GPL-3.0,编辑内核 CodeMirror 系)
架构ArkTS 原生壳 + ArkWeb 加载 rawfile 编辑器资产(index.html + bundle + 65 chunks)
文件链路DocumentViewPicker + file.fs,用户授权制
权限零(module.json5 无 requestPermissions 表)
包体约 6MB(Electron 路线约 200MB)
上架状态已在鸿蒙应用市场正式上架

一、先看效果图

图 1 · 应用主视觉

墨匣应用主视觉

图 1:应用简介宣传图。「学术写作」是全部设计决策的锚点:为长文、为 Markdown、为本地。

图 2 · 为专注写作而设计

为专注写作而设计

图 2:编辑区所见即所得,语法高亮、表格渲染、标题层级一目了然。界面没有为移动端妥协的巨大按钮,键鼠快捷键完整保留。

图 3 · 应用运行实拍

应用运行实拍

图 3:2in1 真机实拍。打开、编辑、保存全链路在系统文件选择器授权下完成,文稿始终留在设备内。


在这里插入图片描述

二、需求设计:学术写作要什么,不要什么

需求收敛从场景倒推。目标用户是写课程论文、毕业论文、科研投稿的人,他们的写作会话有三个特征:时间长(一次三小时起步)、结构化(标题、引用、表格、公式)、怕丢稿(文件必须可靠落盘)。

由此得出能力清单与舍弃清单:

能力清单(做)舍弃清单(不做)
Markdown 所见即所得 + 语法高亮账号体系与云同步
表格、列表、引用块的完整渲染团队协作与评论批注
打开/编辑/保存 .md .markdown .txt文献管理数据库(后期路线图)
大文件流畅编辑(万行级)插件市场
本地离线,零权限内置 AI 辅助

「不做清单」比「做清单」更值钱:它直接决定了零权限、离线、6MB 包体这三个上架竞争点。学术写作工具的用户对隐私与稳定极度敏感——文稿不联网、不进云端本身就是需求,不是限制。


三、技术选型:两条路线的生死对比

第一版选了 Electron 整机搬运:上游编辑器本来就是 Electron 应用,把 Node 主进程、渲染进程、打包产物整体移植,理论上「改最少的东西上架最快」。

实际结果是两条路线的全面对比:

维度路线 A:Electron 整机搬运路线 B:ArkTS 原生壳 + ArkWeb
包体~200MB~6MB
运行时自带 Chromium/Node 底座(随包)系统级 ArkWeb,零额外运行时
兼容风险高——底座与系统版本强耦合,新系统上曾出现启动即 SIGTRAP 的闪退,定位深达 NAPI 注册层低——Web 组件是系统能力,随系统更新
权限需要运行时必需的 ACL 受限权限零权限
改造量中(底座嫁接、权限申请、包体治理)中偏大(重写壳 + 桥接层),但边界清晰
启动速度冷启动加载完整运行时原生壳秒开,Web 资产本地加载

路线 A 的 SIGTRAP 是压垮决策的最后一根稻草:闪退发生在启动后 0.15 秒,栈指向随包底座的 libadapter.so 在新系统上的 NAPI 注册失败——这类问题的修复权不在应用层,而在底座与系统的匹配上。即便找到可用的新底座,「随包带一整套浏览器运行时」的 200MB 包体与兼容面,对上架应用仍是长期负债。

路线 B 的核心洞察是:编辑器真正需要跨平台复用的只有前端资产(HTML/CSS/JS),而不是运行时。CodeMirror 编辑内核就是标准 Web 资产,让系统的 ArkWeb 来承载它们,让 ArkTS 原生壳来提供文件能力——运行时问题整个消失。

最终选 B,工程结构收敛为三层:

ArkUI 外壳层   ArkTS 页面、窗口、生命周期(launchType: singleton)
     │
编辑内核层     ArkWeb 加载 rawfile/editor/(index.html + bundle + 65 chunks)
     │            ↕ registerJavaScriptProxy(window.__moxiaBridge)
系统能力层     DocumentViewPicker 文件选择器 + file.fs 读写 + 应用沙箱

四、功能开发:一个只有四个方法的桥

原生壳与编辑器之间只桥接四个文件能力:

openFile() / readFile(path) / writeFile(path, content) / saveFileAs()

桥的注册用 ArkWeb 的 registerJavaScriptProxy,把 EditorBridge 实例挂为页面里的 window.__moxiaBridge。这里有个关键设计:不改编辑器 bundle,用页面适配层做契约转换。上游前端期望的接口是 window.zettlrAPI,且打开文件时它吃的是「路径字符串」契约;而鸿蒙侧文件选择器返回的是 uri。适配层(index.html 里一段小代码)把桥返回的 {path, name, content} 收敛成 bundle 期望的 path 字符串,再喂给上游代码。

这个「适配层收敛契约」的手法价值巨大:编辑器 bundle 有 65 个 chunk,一个字节都不用改——上游代码的不可控变更被完全隔离在适配层里。

文件链路的实现要点:

// 打开:系统文件选择器,md/markdown/txt 三类后缀过滤,单选
const dp = new picker.DocumentViewPicker(this.ctx);
const opt = new picker.DocumentSelectOptions();
opt.fileSuffixFilters = ['.md', '.markdown', '.txt'];
opt.maxSelectNumber = 1;
const uris: Array<string> = await dp.select(opt);

// 读:openSync 拿到的 File 对象要传 .fd
file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
const st = fs.statSync(file.fd);
const buf = new ArrayBuffer(st.size);
fs.readSync(file.fd, buf, { offset: 0 });
const text = new util.TextDecoder().decode(new Uint8Array(buf));

// 写:覆盖写 + fsync 落盘
file = fs.openSync(uri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
fs.writeSync(file.fd, content);
fs.fsyncSync(file.fd);

另存为走 DocumentViewPicker.save():newFileNames 给出默认名「未命名.md」,fileSuffixChoices 提供后缀选择,autoCreateEmptyFile 让对话框预创建空文件并直接返回新文件的 uri——返回后直接 writeFile(uri, content) 即可,无需二次确认文件存在性。

权限模型由此天然成立:所有文件访问都发生在用户亲手选择的那一刻,选择即授权,uri 即凭据,应用全程不持有任何存储权限。这就是 module.json5 里 requestPermissions 整个缺席的底气。


五、兼容性适配:ArkTS 严格模式下的四类坑

前端转 ArkTS 的适配坑集中在语言层与 API 层,四类最典型:

1. 严格模式的类型收敛。ArkTS 的 arkts-no-any-unknown 规则禁止 any/unknown 断言,桥接层里所有可能为空的句柄必须显式可空:

private closeQuietly(file: fs.File | null): void {
  if (file === null) return;
  try { fs.closeSync(file); } catch (e) { /* 关闭失败不阻断主流程 */ }
}

File | null 配合 closeQuietly,把「finally 里关 fd 再抛异常」的经典错误整段消除。

2. fs API 的对象语义。fs.openSync 返回的是 File 对象而非裸 fd,后续 readSync/writeSync/statSync 都要传 file.fd——照抄 Node 的 fd 直传写法会静默失败。

3. 解码器的输入类型。util.TextDecoder().decode() 只吃 Uint8Array,ArrayBuffer 直接传入不报错但返回空串——这个坑在日志里表现为「读到的文件是空的」,极难排查。

4. Web 回调的事件对象。ArkWeb 的 onPageEnd / onErrorReceive 回调是单参数事件对象(event.url / event.error.getErrorCode()),不是 Web 版多参数签名——按多参数写法注册的回调永远不触发。

四类坑的共性是「静默失败」:不崩溃、不报错、只是不工作。对应的工程纪律是桥接层每一步都打 hilog(tag 统一 moxia),打开、选择、读取、写入、推送,每步的输入输出落进日志,真机上一条 hilog | grep moxia 就能把链路断点定位出来。


六、跨语言边界最深的一坑:桥返回值不可靠

写文件后的结果回传,是整个桥接层最深的坑。

最初的设计是「写完返回 boolean」:JS 侧 await bridge.writeFile(path, content) 拿返回值更新保存状态。真机上表现为保存时灵时不灵。最终定位到:javaScriptProxy 的 Promise 返回值不可靠——失败对象会被转成 null,空串也会被转成 null,页面侧无法区分「写入失败」和「返回了空值」。

绕法是反转通信方向:写入完成后,ArkTS 侧主动用 runJavaScript 把结果推回页面:

async writeFile(path: string, content: string): Promise<void> {
  const ok: boolean = this.writeUri(path, content);
  if (this.ctrl !== null) {
    this.ctrl.runJavaScript(
      'window.__moxiaOnWrite && window.__moxiaOnWrite(' + JSON.stringify(ok) + ')'
    ).catch(() => { /* 页面未就绪时忽略 */ });
  }
}

页面侧注册 window.__moxiaOnWrite(ok) 回调更新 UI。经验规则由此确立:ArkWeb 桥上,「请求-响应」用注册回调推送,「命令下发」用 javaScriptProxy 调用——不要依赖代理方法的返回值承载语义。


七、调试与真机实测

真机验收按完整写作会话设计,覆盖四个链路:

  1. 冷启动:安装后首启,编辑器资产(2MB 级 index.html + bundle)本地加载,无网络等待;
  2. 打开 → 编辑 → 保存:DocumentViewPicker 选中真机上的 md 文件,编辑后覆盖保存,用文件管理器核对字节落盘;保存对话框取消路径也要走查(应用不得因用户取消而进入异常态);
  3. 另存为:save 对话框预创建空文件 → 写入 → 新文件在文件管理器可见;
  4. 异常输入:打开超大文件、非 UTF-8 编码文件、空文件,确认编辑器与桥接层都不崩。

调试方法论上,这条链路的排障几乎全靠分层日志:hilog(tag=moxia)覆盖 ArkTS 侧每一步,Web 内核侧的 console 经 ArkWeb 的回调也汇入 hilog。跨语言边界的排障,日志必须两侧都有——只有一侧有日志,断点永远在「对方没说话还是我没听见」之间猜。

分布式特性方面,本应用刻意未接入:写作场景的单机属性使跨设备流转收益有限,而权限与复杂度成本是实打实的。上架应用「每个特性都要能回答为什么不需要」与「每个入口都经得起点击」同样重要。


八、上架资质与审核避坑

墨匣的资质准备围绕「零权限」展开,反而最省:

材料工程事实
身份与版本com.zettlr.moxia,1.0.0 (1000000),签名 profile 绑定包名
权限表整个缺席——无 requestPermissions,审核无权限问询点
隐私面无账号、无遥测、无网络;文稿经用户授权访问且留在沙箱
预览图主视觉、编辑界面、真机运行三张实拍
开源合规GPL-3.0 上游,应用内关于页与仓库 LICENSE 声明一致

审核避坑的三条实战经验:

  1. 零权限不是「没写权限表」,而是「功能设计就不需要权限」。审核会核对权限表与功能的对应关系;如果功能上必须读写任意路径却没申请权限,反而构成隐瞒。墨匣的选择器授权制让两者天然对齐。
  2. 开源合规要在应用内可核对。关于页写明上游项目与许可,仓库里 LICENSE 与应用内声明一致,避免「用了 GPL 代码但闭源上架」的争议。
  3. 包体也是资质的一部分。6MB 的包体在「应用体积」这类隐性审核指标上是加分项;更重要的是它消除了「随包运行时与系统版本失配」这类审核阶段根本测不出来的定时炸弹。

九、上架后迭代

架构收敛带来的最大红利是迭代成本低——壳只有三个 ArkTS 文件,改动即改即验。已规划的演进路线按优先级:

  1. 多标签与工程管理:论文写作天然是「一个文件夹一篇论文」,工作区树 + 多标签是第一优先级;
  2. 引用与参考文献辅助:与写作场景强相关的增值能力,保持本地实现;
  3. 云同步与协作写作(可选、按需授权):唯一可能引入网络的特性,原则是「默认关闭、用户显式开启、权限逐项申请」——把它放进路线图而不是首版,正是需求设计阶段「不做清单」的兑现。

十、踩坑总结

  1. 能复用前端资产,不要复用运行时。Electron 类应用移植的第一问:去掉 Node/Chromium 后,剩下的前端资产在 ArkWeb 里能不能跑?能跑,就值得重写壳。
  2. 适配层收敛契约,bundle 一字节不改。跨平台复用的资产越「原装」,升级成本越低;所有平台差异收敛到一层薄适配代码里。
  3. javaScriptProxy 的返回值不要承载语义,关键结果用 runJavaScript 主动推送。
  4. ArkTS 严格模式下,可空句柄全部显式 | null,关闭资源封装成 closeQuietly 一类的兜底函数。
  5. fs 的 File 对象要传 .fd、TextDecoder 要喂 Uint8Array——两个静默失败点,靠全链路 hilog 定位。
  6. 跨语言排障,日志两侧都要有;桥接层每步打点,不是可选项。
  7. 「不做清单」要写成文档:零权限、零网络、零账号的架构,是用需求阶段的克制换来的。

十一、结语

墨匣的版本迭代史,是一次教科书式的「架构减法」:从 200MB 到 6MB,从自带运行时到系统能力,从 ACL 权限申请到权限表清空。减掉的都是间接层——而每一层间接层,都是一次故障机会。

上架应用的竞争力清单上,「稳定」「轻」「隐私干净」这三个词,往往不是加法加出来的,而是减法减出来的。对移植型应用,先问「上游哪些东西其实不用搬」,再问「怎么搬」——顺序反了,就会在最重的路上撞墙。

源码与构建说明见开源仓库;本文代码摘录均来自工程实际提交。

Logo

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

更多推荐