墨匣学术写作编辑器上架鸿蒙PC:从 200MB Electron 到 6MB 原生壳的架构收敛
墨匣学术写作编辑器上架鸿蒙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 调用——不要依赖代理方法的返回值承载语义。
七、调试与真机实测
真机验收按完整写作会话设计,覆盖四个链路:
- 冷启动:安装后首启,编辑器资产(2MB 级 index.html + bundle)本地加载,无网络等待;
- 打开 → 编辑 → 保存:DocumentViewPicker 选中真机上的 md 文件,编辑后覆盖保存,用文件管理器核对字节落盘;保存对话框取消路径也要走查(应用不得因用户取消而进入异常态);
- 另存为:save 对话框预创建空文件 → 写入 → 新文件在文件管理器可见;
- 异常输入:打开超大文件、非 UTF-8 编码文件、空文件,确认编辑器与桥接层都不崩。
调试方法论上,这条链路的排障几乎全靠分层日志:hilog(tag=moxia)覆盖 ArkTS 侧每一步,Web 内核侧的 console 经 ArkWeb 的回调也汇入 hilog。跨语言边界的排障,日志必须两侧都有——只有一侧有日志,断点永远在「对方没说话还是我没听见」之间猜。
分布式特性方面,本应用刻意未接入:写作场景的单机属性使跨设备流转收益有限,而权限与复杂度成本是实打实的。上架应用「每个特性都要能回答为什么不需要」与「每个入口都经得起点击」同样重要。
八、上架资质与审核避坑
墨匣的资质准备围绕「零权限」展开,反而最省:
| 材料 | 工程事实 |
|---|---|
| 身份与版本 | com.zettlr.moxia,1.0.0 (1000000),签名 profile 绑定包名 |
| 权限表 | 整个缺席——无 requestPermissions,审核无权限问询点 |
| 隐私面 | 无账号、无遥测、无网络;文稿经用户授权访问且留在沙箱 |
| 预览图 | 主视觉、编辑界面、真机运行三张实拍 |
| 开源合规 | GPL-3.0 上游,应用内关于页与仓库 LICENSE 声明一致 |
审核避坑的三条实战经验:
- 零权限不是「没写权限表」,而是「功能设计就不需要权限」。审核会核对权限表与功能的对应关系;如果功能上必须读写任意路径却没申请权限,反而构成隐瞒。墨匣的选择器授权制让两者天然对齐。
- 开源合规要在应用内可核对。关于页写明上游项目与许可,仓库里 LICENSE 与应用内声明一致,避免「用了 GPL 代码但闭源上架」的争议。
- 包体也是资质的一部分。6MB 的包体在「应用体积」这类隐性审核指标上是加分项;更重要的是它消除了「随包运行时与系统版本失配」这类审核阶段根本测不出来的定时炸弹。
九、上架后迭代
架构收敛带来的最大红利是迭代成本低——壳只有三个 ArkTS 文件,改动即改即验。已规划的演进路线按优先级:
- 多标签与工程管理:论文写作天然是「一个文件夹一篇论文」,工作区树 + 多标签是第一优先级;
- 引用与参考文献辅助:与写作场景强相关的增值能力,保持本地实现;
- 云同步与协作写作(可选、按需授权):唯一可能引入网络的特性,原则是「默认关闭、用户显式开启、权限逐项申请」——把它放进路线图而不是首版,正是需求设计阶段「不做清单」的兑现。
十、踩坑总结
- 能复用前端资产,不要复用运行时。Electron 类应用移植的第一问:去掉 Node/Chromium 后,剩下的前端资产在 ArkWeb 里能不能跑?能跑,就值得重写壳。
- 适配层收敛契约,bundle 一字节不改。跨平台复用的资产越「原装」,升级成本越低;所有平台差异收敛到一层薄适配代码里。
- javaScriptProxy 的返回值不要承载语义,关键结果用 runJavaScript 主动推送。
- ArkTS 严格模式下,可空句柄全部显式
| null,关闭资源封装成 closeQuietly 一类的兜底函数。 - fs 的 File 对象要传
.fd、TextDecoder 要喂Uint8Array——两个静默失败点,靠全链路 hilog 定位。 - 跨语言排障,日志两侧都要有;桥接层每步打点,不是可选项。
- 「不做清单」要写成文档:零权限、零网络、零账号的架构,是用需求阶段的克制换来的。
十一、结语
墨匣的版本迭代史,是一次教科书式的「架构减法」:从 200MB 到 6MB,从自带运行时到系统能力,从 ACL 权限申请到权限表清空。减掉的都是间接层——而每一层间接层,都是一次故障机会。
上架应用的竞争力清单上,「稳定」「轻」「隐私干净」这三个词,往往不是加法加出来的,而是减法减出来的。对移植型应用,先问「上游哪些东西其实不用搬」,再问「怎么搬」——顺序反了,就会在最重的路上撞墙。
源码与构建说明见开源仓库;本文代码摘录均来自工程实际提交。
更多推荐





所有评论(0)