把 Zettlr 搬上鸿蒙 PC:墨匣学术写作编辑器移植与上架全记录

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

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

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

从 Electron 启动 0.15 秒闪退,到 6MB 的移植版成功上架华为应用市场——这是一次跨越两个阶段的桌面应用移植实战,也是这篇文章要完整拆解的全部内容。

一、作品是什么:墨匣学术写作编辑器

墨匣学术写作编辑器(包名 com.zettlr.moxia,v1.0.0)是一款运行于 HarmonyOS PC(2in1 设备)的本地化 Markdown 学术写作应用。它移植自国际知名开源写作软件 Zettlr 的核心编辑引擎——基于 CodeMirror 6 的所见即所得 Markdown 编辑器,为论文、笔记、书稿等长文写作提供沉浸、高效的创作环境。

名字取自「墨」与「匣」:墨,是文房笔墨,是学术写作的象征;匣,是卡片之盒——正是 Zettlr 所源自的卢曼 Zettel(卡片)与卡片盒笔记法(Zettelkasten)的本义。一匣一墨,文库精髓。

应用简介宣传图

核心能力一览:

  • 所见即所得编辑:CodeMirror 6 内核,编辑响应迅速、滚动顺滑,上万字长文也能从容应对;
  • 完整 Markdown 语法高亮:标题、强调、代码、引用、列表、链接各有专属配色,语法符号弱化显示,正文更突出;
  • 一键排版预览:自研 Markdown→HTML 渲染器,所见即所得,写作与审阅无缝衔接;
  • 真实文件读写:通过系统文件选择器(DocumentViewPicker)打开/保存 .md / .markdown / .txt,本地优先;
  • 零敏感权限:不申请任何文件全局读写权限,一切文件访问由用户在系统选择器中当场授权。

为专注而设计

下面进入正题:这个应用是怎么一步步移植出来的。

在这里插入图片描述

二、为什么是 Zettlr,为什么是鸿蒙 PC

鸿蒙 PC 生态刚起步时,桌面端最缺的不是计算器、不是时钟,而是生产力工具——尤其是写作类。学术写作者对编辑器的要求非常苛刻:

  1. 长文档(几万字)下输入不能卡顿;
  2. 所见即所得,而不是"左边写源码、右边看效果"的割裂体验;
  3. 纯本地、纯文本、格式开放(Markdown),十年后文件还能打开。

Zettlr 恰好是这个领域的标杆开源项目:CodeMirror 6 内核、纯 Web 技术栈构建、Electron 桌面发行。这意味着——它的编辑器核心理论上可以在任何有现代 WebView 的平台上活下来。

还有一个朴素的判断支撑着整个决策:Markdown 是纯文本。它不需要专有二进制格式、不需要云同步服务、不需要账号体系,一个能渲染文本框和读写文件的运行时就足以承载它的一切。这决定了移植的复杂度上限远低于那些依赖专有运行时或闭源服务的应用,是"值得一试"的底气所在。

所以移植的初始思路非常自然:Zettlr 是 Electron 应用,鸿蒙 PC 有 Electron for OHOS 的移植底座,那把 Electron 搬过去,事情不就成了吗?

事实证明:这条路能走,但走到一半你会发现另一条路更对。

三、移植方案的推演:三条路摆在面前

动手前评估过三条路线:

路线思路预估成本风险
A. Electron 整体直移用 Electron for OHOS 底座整体打包 Zettlr低(改造成本)原生模块兼容性、引擎稳定性、包体巨大
B. 纯 ArkUI 从零实现用 ArkUI 从零实现全部编辑器能力极高(CodeMirror 6 的编辑域、语法树、插件生态全部再造不现实)功能性缺口大
C. ArkUI + ArkWeb 混合ArkUI 做壳与系统能力,ArkWeb 加载原版 CodeMirror 编辑器资产中需要设计一层可靠的 JS↔Native 桥

路线 A 是当时社区的"标准答案",于是先走了 A;走通之后因为两个硬伤,把壳换成了 C。B 从一开始就被排除——CodeMirror 6 的编辑域模型(事务、位置映射、视野渲染)绝不是"再造一个文本框"那么简单,放弃原内核等于放弃 Zettlr 的灵魂,那就不叫移植了。

下面按时间线完整复盘。

四、第一阶段:Electron 直移打通链路(踩坑全记录)

在展开之前,先把这一章的基调说清楚:Electron 阶段虽然最终让位于更轻的方案,但它绝不是弯路——正是这一阶段的完整实践,摸清了底座行为、系统对话框、签名规则和崩溃排查方法,后续每一步都在吃这一阶段攒下的老本。前一条路贡献了移植中最重要的侦察成果。

4.1 冒烟阶段:先让窗口立起来

Electron for OHOS 的基本形态是:一个 OHOS 工程里带 entry(应用壳)与 web_engine(Electron 运行时承载)两个模块,运行时由内置的 Node/Electron 加载 resfile/resources/app 下的应用代码。移植 Zettlr 的第一步不是上真前端,而是用一个最小化 demo 验证整条链路:

  1. 构建出 unsigned HAP;
  2. DevEco 自动签名(注意必须用本应用专属的 p7b,不可借用其他 bundle 的签名材料,否则 hdc install 直接报错);
  3. 安装到真机,看主窗口能否起来。

早期冒烟阶段的复现指南

这个阶段的典型故障是"冒烟表":窗口起来了,但渲染进程里疯狂报错。比如最小 demo 里写了一个 markdown 表格渲染逻辑,页面直接抛出:

Could not render table. window.getCitationCallback is not a function

这个错误非常典型:Zettlr 前端 bundle 在启动时会探测 window.getCitationCallback(citeproc 引用管理后端注入的钩子),在纯浏览器环境里它不存在。解法是在 bundle 加载前补一个降级桩,让引用渲染回退为原始文本 [@key]——这个桩在后面的最终形态里依然沿用,是整个移植里"以最小侵入驯服 bundle"思路的第一次实践。

顺带把这一阶段的构建口径固定下来,因为它贯穿了 Electron 阶段的所有迭代:

# 构建 unsigned HAP(未签名)
hvigorw --mode module -p product=default -p buildMode=release assembleHap

# 产物位置(包体约 166MB,大头是随包运行时)
# electron/build/default/outputs/default/electron-default-unsigned.hap

# DevEco 打开工程自动签名(File → Project Structure → Signing Configs)
# 注意:务必勾选 Automatically generate signature,
# 且签名材料必须是本应用 bundle 专属的 p7b——借用其他工程的签名材料,
# p7b 里绑定的包名对不上,hdc install 会直接报 9568322(not trusted app source)
hdc install electron-default-signed.hap

这套"构建 → 签名 → 安装 → 看日志"的循环,在这一阶段每天要跑上十几轮。它看起来枯燥,但价值在于把变量锁定:每一轮只改一个东西(底座版本、资源内容、manifest 配置),如果窗口立不住,能立刻知道是哪一次改动引入的。后面排查 SIGTRAP 时,正是靠这套纪律才能用对比法快速收网。

到这里,链路层面的结论已经得出:底座能跑、页面能渲染、系统能力能接。接下来才是硬仗——把真实 Zettlr 前端塞进去。

4.2 文件能力:主进程 API 全部要换血

Electron 的文件能力全靠主进程 fs 模块和原生对话框,这些在 OHOS 沙箱里一律不可用。demo 阶段把 openFile/saveFile 换成了 OHOS 的 DocumentViewPicker,效果立竿见影——系统级文件选择器正常弹出,能选、能存:

Electron 版成功调起系统文件对话框

到这里,文件能力的移植方向也清晰了:无论壳最终是什么,文件链路都走 picker + 系统文件接口。这个结论直接被终版沿用。

4.3 真实 Zettlr 前端的嫁接

真实 Zettlr 的前端不是几个 HTML 文件,而是一整套构建产物:

  • yarn build 产出的 CodeMirror 编辑器 UI 资源;
  • 一堆依赖原生模块的 Node 侧能力(文件树、引用管理、导出等);
  • main 进程的 IPC 通道(renderer 通过 window.zettlrAPI 调用主进程)。

嫁接动作概括为五步:

  1. yarn 构建真实前端:在 x86 机器上 yarn build(会拉原生模块,macOS/Linux 需编译工具链),把产出的渲染层资源整体拷出;
  2. 复制产物到 web_engine:把构建产物放进 resfile/resources/app,覆盖占位的 index.html 及资源;
  3. 确认 main.cjs 的加载路径:Electron 主进程从 file:// 协议加载真实入口 HTML;
  4. 重跑构建:镜像同步到 entry 与 web_engine 两份 resfile,重新出包;
  5. 换第 1 步构建 → 第 2 步安装 → 用真实 Zettlr 功能(新建笔记、双链、标签、导出)做冒烟。

4.4 最深的一道坑:启动 0.15 秒 SIGTRAP

真实前端嫁接进去后,应用在真机上启动约 0.15 秒即崩溃,日志里是刺眼的 SIGTRAP,且进程直接死亡、没有任何 JS 报错。

排查过程值得一提,因为这类问题日志几乎不给你线索:

  • 排除 JS 层:渲染进程来不及执行任何脚本就死了,说明崩在更底层;
  • 对比法定位:用一个空的 Electron demo 工程打包装到同一台真机——同样闪退。说明问题不在 Zettlr 前端,而在 Electron 底座本身与新系统的兼容性;
  • 根因:随包分发的旧版底座(面向旧 API 等级构建)里的 libadapter.so,在新的系统版本上 getNativeContext 的 NAPI 注册失败,引擎初始化阶段直接 abort。也就是说这是底座与目标设备系统版本的错配,不是应用代码的问题。

修复路径是换新底座:找一份面向新版 OHOS 系统(Electron 37 / Node 22,targetSdk 更高)的自包含底座工程,配套的 electron-ohos-runtime npm 包里带三个关键 so(libelectron / libadapter / libffmpeg,逐一做 SHA256 校验后替换)。嫁接时要特别小心几件事:

  • 只替换 ets 与 cpp/types 两块运行时结构,resfile/resources/app 里的 Zettlr 产物要原样保留;
  • module.json5 按新底座的结构重排,entry 模块补 KEEP_BACKGROUND_RUNNING 与 backgroundModes: taskKeeping(保活);
  • 最关键的 ACL 权限:新引擎开 JIT 必须申请 kernel.ALLOW_WRITABLE_CODE_MEMORY,这是一个受限权限——删掉必闪退;调试签名也要在 DevEco 里重新走自动签名并在对话框里明确批准该受限权限(它会写进 p7b 的 acls.allowed-acls),否则安装时报 9568289(profile 缺 ACL)。

走完这一整套,Electron 版终于在新系统上稳定运行:主进程、GPU 进程、双渲染进程各就各位,Zettlr 的编辑、双链、标签、导出功能都能用。

Electron 版稳定运行后的界面

4.5 移植的分叉点:保住内核,换掉壳

技术上通了,但两个硬伤摆在"上架"这个目标面前:

  1. 包体:完整 Electron 运行时(含 V8、Chromium、Node)打进 HAP,包体约 200MB。对一个 Markdown 编辑器来说,这是用户下载时无法回避的负担,也是审核与分发上的减分项;
  2. 引擎链路的不可控性:SIGTRAP 的教训说明,这条链路的稳定性取决于第三方底座与目标系统版本的持续对齐,每一次系统升级都是一次重新适配的风险。

而 Zettlr 真正有价值的部分是什么?是 CodeMirror 6 编辑器内核那套前端资产——它本来就不需要 Electron。Electron 在这里只干了三件事:提供 WebView、提供文件对话框、提供本地文件读写。前两件事 ArkWeb + ArkUI 原生就有,第三件事 file.fs 模块原生也有。

于是移植进入第二阶段:内核原封不动,壳换成系统原生的。这不是推倒重来,而是把第一阶段验证过的每一样东西(bundle 兼容性、picker 链路、签名流程、调试方法)平移到更轻的载体上。

五、第二阶段:ArkUI 单模块 + ArkWeb 承载原生内核

5.1 架构总览

最终形态非常克制,整个应用只有一个 entry 模块:

┌────────────────────────────────────────────────────┐
│  ArkUI 层(Index.ets)                             │
│   └─ Web 组件:resource://rawfile/editor/index.html│
│       │  registerJavaScriptProxy(EditorBridge)     │
│       ▼                                            │
│  页面适配层(index.html 内联脚本,不改 bundle)      │
│   └─ window.__moxiaBridge → 包装为 window.zettlrAPI│
│       ▼                                            │
│  zettlr-editor.bundle.js(原版 CodeMirror 6 内核)  │
│                                                    │
│  EditorBridge.ets(ArkTS 侧文件能力)               │
│   └─ DocumentViewPicker + @ohos.file.fs + hilog    │
└────────────────────────────────────────────────────┘

四个关键决策:

  1. 编辑器资产原样复用:从 Electron 工程里把编辑器内核(index.html + bundle + 65 个 chunk)整体拷入 rawfile/editor/,由 ArkWeb 以 resource://rawfile/ 协议加载。bundle 一行不改——它原是给 Electron renderer 用的,ArkWeb 同样是 Chromium 内核,兼容性无忧;
  2. 桥只做四件事:openFile() / readFile(path) / writeFile(path, content) / saveFileAs()。这是 bundle 对外部文件能力的全部依赖面(原来由 window.zettlrAPI 提供),收敛到 4 个方法,桥的面积就锁死了;
  3. 不改 bundle 的适配层:在 index.html 里加一段内联脚本,把 ArkTS 侧注册的 window.__moxiaBridge 包装回 bundle 期望的 window.zettlrAPI 契约,同时吸收两端的所有"脏活"(授权失效、桥返回值不可靠等,后文详述);
  4. 零敏感权限:文件读写全部走 DocumentViewPicker(用户授权制)+ @ohos.file.fs,module.json5 里一个敏感权限都没有。

5.2 ArkTS 侧:EditorBridge 全实现

桥的实现是整个原生侧的核心,直接看代码(有删节的注释保留原貌):

// EditorBridge.ets —— Web 编辑器内核 ↔ ArkTS 文件系统桥
import { common } from '@kit.AbilityKit';
import { webview } from '@kit.ArkWeb';
import { picker } from '@kit.CoreFileKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { util } from '@kit.ArkTS';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG: string = 'moxia';

export interface OpenResult {
  path: string;    // picker 返回的 uri(虚拟路径)
  name: string;    // 显示文件名
  content: string; // 全文内容
}

export class EditorBridge {
  private readonly ctx: common.UIAbilityContext;
  /** 写文件完成后用控制器把结果主动推回页面(桥返回值不可靠) */
  private readonly ctrl: webview.WebviewController | null;
  private static readonly SUFFIX_FILTERS: Array<string> = ['.md', '.markdown', '.txt'];
  private static readonly DEFAULT_FILE_NAME: string = '未命名.md';

  // ... 省略构造器与工具方法 ...

  /** 以只读方式读取 uri 全文;失败返回空串 */
  private readUri(uri: string): string {
    let file: fs.File | null = null;
    try {
      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));
      return text;
    } catch (e) {
      hilog.error(DOMAIN, TAG, 'readUri FAIL uri=%{public}s err=%{public}s',
        uri, (e as Error).message);
      return '';
    } finally {
      this.closeQuietly(file);
    }
  }

  /** 以读写方式把内容写入 uri(不存在则创建,存在则覆盖) */
  private writeUri(uri: string, content: string): boolean {
    let file: fs.File | null = null;
    try {
      file = fs.openSync(uri,
        fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
      fs.writeSync(file.fd, content);
      fs.fsyncSync(file.fd);   // 落盘才算保存成功
      return true;
    } catch (e) {
      return false;
    } finally {
      this.closeQuietly(file);
    }
  }

  /** 打开系统文件选择器,返回用户选中的文档 */
  async openFile(): Promise<OpenResult | null> {
    const dp = new picker.DocumentViewPicker(this.ctx);
    const opt = new picker.DocumentSelectOptions();
    opt.fileSuffixFilters = EditorBridge.SUFFIX_FILTERS;
    opt.maxSelectNumber = 1;
    const uris: Array<string> = await dp.select(opt);
    if (uris.length === 0) { return null; }
    const uri = uris[0];
    const content = this.readUri(uri);   // 当场读全文(授权是一次性的!)
    return { path: uri, name: this.displayNameOf(uri), content: content };
  }

  /** 另存为:DocumentViewPicker.save 预创建空文件并返回新 uri */
  async saveFileAs(): Promise<string | null> {
    const dp = new picker.DocumentViewPicker(this.ctx);
    const opt = new picker.DocumentSaveOptions();
    opt.newFileNames = [EditorBridge.DEFAULT_FILE_NAME];
    opt.fileSuffixChoices = EditorBridge.SUFFIX_FILTERS;
    const uris: Array<string> = await dp.save(opt);
    if (uris.length === 0) { return null; }
    return uris[0];
  }

  /** 按 uri 写回,并用 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(() => { /* 页面未就绪时忽略 */ });
    }
  }
}

注意几个刻意为之的细节:

  • 每一步都打 hilog(tag=moxia),真机上出问题第一时间能在 hilog 里看到断在哪一环;
  • fs.fsyncSync:只 writeSync 不落盘,系统回收 fd 时可能丢数据,保存操作必须 fsync 才算数;
  • closeQuietly 放在 finally:桥被高频调用,fd 泄漏是不可接受的。

5.3 宿主侧:注册桥的时机

Index.ets 只有 80 余行,核心是把 Web 组件的生命周期与桥的注册时机对齐:

@Entry
@Component
struct Index {
  private controller: webview.WebviewController = new webview.WebviewController();
  private bridge: EditorBridge = new EditorBridge(
    getContext(this) as common.UIAbilityContext, this.controller);
  private bridgeRegistered: boolean = false;

  aboutToAppear(): void {
    // 开启 Web 调试(devtools),便于真机诊断页面内部状态
    webview.WebviewController.setWebDebuggingAccess(true);
  }

  private registerBridge(): void {
    if (this.bridgeRegistered) { return; }
    this.controller.registerJavaScriptProxy(
      this.bridge, '__moxiaBridge',
      ['openFile', 'readFile', 'writeFile', 'saveFileAs']);
    this.bridgeRegistered = true;
  }

  build() {
    Stack({ alignContent: Alignment.TopStart }) {
      Web({ src: 'resource://rawfile/editor/index.html', controller: this.controller })
        .width('100%').height('100%')
        .javaScriptAccess(true)
        .domStorageAccess(true)
        .fileAccess(true)
        // controller 与 Web 组件绑定成功后才注册桥,确保注入进页面
        .onControllerAttached(() => { this.registerBridge(); })
        .onPageEnd((event: PageEndEvent) => {
          hilog.info(0x0000, 'moxia', 'onPageEnd url=%{public}s', event.url);
        })
        .onErrorReceive((event: ErrorReceiveEvent) => {
          hilog.error(0x0000, 'moxia', 'onErrorReceive code=%{public}d info=%{public}s',
            event.error.getErrorCode(), event.error.getErrorInfo());
        })
    }
    .backgroundColor('#1e1e1e')
  }
}

要点:桥的注册挂在 onControllerAttached(controller 与 Web 组件刚建立绑定时)并做了幂等保护——太早注册会抛异常,太晚注册 bundle 可能已经启动并探测不到桥。

5.4 页面适配层:不改 bundle 的"翻译官"

适配层是这套架构里最精巧的部分,它把 ArkTS 桥的原语翻译成 bundle 期望的 window.zettlrAPI 契约,并且吸收了所有跨端脏活。节选核心逻辑:

window.zettlrAPI = {
  openFile: function () {
    if (current) { current.content = liveContent(); }
    return raw().openFile().then(function (r) {
      if (r === null || r === undefined) { return null; }
      current = { path: r.path, name: r.name, content: r.content };
      pendingContent = r.content;   // 标记待注入
      injectPending(0);             // 直接塞进编辑器,不依赖 bundle 二次读取
      return r.path;                // bundle 只用返回值记录文档路径
    });
  },
  readFile: function (p) {
    // 永远命中缓存,绝不碰 fs(picker 一次性授权已失效)
    if (current && current.path === p) {
      return Promise.resolve(current.content);
    }
    return Promise.resolve(current ? current.content : '');
  },
  writeFile: function (p, c) {
    // 桥的 Promise 返回值不可靠 → 挂回调等 ArkTS 侧 runJavaScript 推送
    var my = ++writeToken;
    return new Promise(function (resolve) {
      var timer = setTimeout(function () {
        setStatus('保存超时,请重试或另存为');
        resolve(false);
      }, 10000);
      window.__moxiaOnWrite = function (ok) {
        if (my !== writeToken) { return; }   // 过期回调忽略
        clearTimeout(timer);
        if (ok && current && current.path === p) { current.content = c; }
        setStatus(ok ? '已保存' : '保存失败,请用另存为');
        resolve(ok);
      };
      raw().writeFile(p, c);
    });
  },
  saveFileAs: function () {
    var content = liveContent();  // 先抓真实内容,弹窗期间不被影响
    return raw().saveFileAs().then(function (uri) {
      if (!uri) { return null; }
      current = { path: uri, name: uri, content: content };
      return raw().writeFile(uri, content).then(function () { return uri; });
    });
  }
};

把内容塞进编辑器用 CodeMirror 6 原生的 dispatch 全量替换文档:

function writeInto(ed, text) {
  var view = ed && ed._instance;   // 真正的 CM6 EditorView 在包装实例的 _instance 上
  if (view && typeof view.dispatch === 'function' && view.state) {
    view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
    return true;
  }
  return false;
}

由于 CodeMirror 实例可能被 bundle 重建,注入逻辑带一个最多 30 次、每次 100ms 的重试循环,直到实例就绪、注入成功为止。

5.5 壳层 UI 的移植设计:工具栏、关于弹层与预览窗格

Electron 版的 UI 是 Zettlr 完整工作台(侧边栏、文件树、统计面板……),而移植版只有一个编辑器内核,于是壳层的 UI 全部围绕内核重新设计。设计原则只有一条:工具为写作让路。

  • 顶部工具栏只有六个动作:新建、打开、保存、另存为、预览、关于,外加当前文件名显示。主操作"保存"用主题色(青绿 #1abc9c)高亮,其他按钮一律深灰弱化;
  • 关于弹层是深色居中模态卡片:品牌字标、六个功能卖点、版本信息,点击遮罩、按 Esc 或点"开始写作"都能关闭,开合带 0.18 秒透明度与位移过渡;
  • 预览窗格与编辑区互斥显示(CSS class 切换),排版上限宽 760px 居中,行高 1.75,模拟纸面阅读密度;
  • 状态栏元素保留但隐藏——bundle 的状态管理会写它的 textContent,物理删除元素会抛错,于是用 display:none 让它"活着但不可见"。

这些细节的共同点是:都绕着 bundle 的既有行为设计,而不是要求 bundle 配合。这是适配层思路从 JS 层延伸到了 UI 层。

5.6 自研预览渲染器:一百行的 Markdown→HTML

"一键排版预览"没有引入 marked/markdown-it 等第三方库,而是手写了一个约一百行的渲染器。原因很简单:bundle 里原本的预览能力依赖 Electron 主进程协作,砍掉主进程后预览链路断了,而引入新依赖意味着重新走一遍资产构建与体积审计。

渲染器分两层:

  1. 行级解析:逐行识别围栏代码块、标题、分隔线、引用块、表格、嵌套列表(含有序/无序混合与续行)、段落,用栈维护列表嵌套深度;
  2. 行内解析:先处理行内代码(避免代码内容被后续规则二次加工),再依次处理图片、链接、加粗、删除线、斜体,所有文本先经 HTML 转义防注入。

预览渲染失败时降级为状态提示"预览渲染失败,可继续编辑",绝不阻断写作主流程——编辑与保存是主干,预览是分支,分支塌了主干不能跟着塌。

六、踩坑实录:十个真实坑与解法

这一节是全文最"值钱"的部分,全部来自真机实调,每一条都有对应的代码落点。

坑 1:API 24 没有 DocumentSavePicker

网上(包括部分旧文档)的写法是 new picker.DocumentSavePicker(),在新 SDK 上这个类根本不存在。正确的保存对话框是:

const dp = new picker.DocumentViewPicker(ctx);
const opt = new picker.DocumentSaveOptions();
opt.newFileNames = ['未命名.md'];       // 预填文件名
opt.fileSuffixChoices = ['.md', '.markdown', '.txt'];
const uris = await dp.save(opt);        // 返回预创建文件的 uri

autoCreateEmptyFile 默认为 true:对话框会预创建空文件并直接返回 uri,内容由调用方随后 writeFile 写入。

坑 2:fs.openSync 返回的是 File 对象,不是 fd

fs.openSync(uri, ...) 返回 File 对象,后续 readSync/writeSync/fsyncSync 传的都是它的 .fd 字段。混用会得到类型错误或静默失败。

坑 3:util.TextDecoder.decode 只吃 Uint8Array

ArrayBuffer 不能直接喂给 decode,必须包一层 new Uint8Array(buf)。报错信息不明显,容易在错误的层排查半天。

坑 4:Web 回调是单参事件对象

ArkWeb 的 onPageEnd/onErrorReceive 回调签名是单参事件对象,事件属性要用 event.url、event.error.getErrorCode() 的方式取,而不是多参数回调。TypeScript 的习惯在这里会直接编译不过。

坑 5:arkts-no-any-unknown 严格模式

ArkTS 默认开启 arkts-no-any-unknown,禁止 unknown 类型断言。桥的方法签名里所有可能失败的位置一律用可空类型(fs.File | null)显式表达,而不是 any/unknown 兜底。这其实倒逼出了一份更干净的错误处理。

坑 6:picker 授权是一次性的(最重要的坑)

这是整个移植里最深的一个坑,症状极其诡异:打开文件一切正常,保存时却写不进去(或 bundle 内部二次读取时报 Operation not permitted)。

原因:DocumentViewPicker.select() 返回的 uri 授权是一次性的。openFile 当场能读;但 bundle 随后按它自己的流程再调 readFile(path) 读同一个 uri 时,授权已失效,系统直接拒绝。

解法在适配层(见 5.4):彻底绕开 bundle 的二次读取机制——

  • openFile:桥当场读出全文 → 适配层直接用 CM6 dispatch 把内容注入编辑器 → 返回值只给 bundle 用于记录路径,内容不再依赖 readFile;
  • readFile:永远命中适配层内存缓存,绝不碰 fs。

一句话总结这条铁律:picker 给的 uri,只在授权新鲜的那一刻用一次。

坑 7:桥的 Promise 返回值不可靠

registerJavaScriptProxy 注册的方法,从 JS 侧调用时 Promise 返回值不稳定:失败对象、空串都会被转成 null,无法区分"写入失败"与"写入成功但没返回值"。

解法:写入结果不走返回值,走主动推送。ArkTS 侧写完文件后用 controller.runJavaScript() 调用页面上的 window.__moxiaOnWrite(true/false);JS 侧挂回调等推送,配 10 秒超时兜底和递增 token 防并发写串扰。

坑 8:citeproc 引用后端不可用

citeproc(学术引用渲染)依赖原生模块,OHOS 沙箱里跑不起来。硬塞会在每次触发引用渲染时抛 TypeError。解法是在 bundle 之前定义降级桩:

window.getCitationCallback = function () {
  return function () { return undefined; };
};

编辑器会回退显示原始引用文本 [@key]——功能降级但绝不崩溃。

这里有个时间窗口的细节:这个桩必须定义在 bundle 加载之前。Zettlr 前端在初始化时会同步探测这个钩子,晚一步就探测不到。所以它被放在 index.html 里一个独立的 <script> 标签中,位置严格先于末尾加载 zettlr-editor.bundle.js 的那个标签。“在 bundle 之前铺好它期望的全局环境”,和"不改 bundle"是同一件事的两面:前者是手段,后者是纪律。

坑 9:bundle 默认浅色主题,白底灰字没法看

编辑器 bundle 默认 darkMode: false(berlin 浅色主题),正文色约 #aaa 配白底,对比度只有 2:1 左右。而应用整体是深色 UI。解法是在 index.html 里用 !important 强制覆盖一整套深色主题——并且不用 CSS invert 滤镜(那会把所有 token 染成统一黄绿色、丢掉语法区分度),而是逐类映射仿 VS Code Dark+ 的语义色:标题蓝、链接青、代码橙、引用绿、格式符号灰。

坑 10:另存为保存出 0KB 文件

复现路径:另存为对话框弹出期间,用户继续输入 → 对话框返回 uri → 此时才去抓编辑器内容。如果抓取时机或内容源不对,写进新文件的就是空串。

解法:saveFileAs 的第一步就是 liveContent()——先抓编辑器当前真实内容,再弹对话框;对话框返回 uri 后立刻用抓好的内容写入。另外给保存对话框加了 saveDialogBusy 互斥,防止重复弹出导致状态互相覆盖。

顺带一提"实时内容"的取法也有讲究:bundle 的包装实例只暴露了只读 value,直接读它可能拿到的是注入时刻的快照而非用户最新输入。所以 liveContent() 优先从包装实例背后的 CM6 EditorView 取实时文档,取不到才回退缓存——保存动作的每一环都必须以"用户此刻看到的内容"为准。

调试方法论:把 DevTools 接上 ArkWeb

十个坑里有六个是靠同一条调试通道定位的:ArkWeb 支持 DevTools 远程调试。

aboutToAppear(): void {
  webview.WebviewController.setWebDebuggingAccess(true);
}

真机上应用跑起来后,用 Chrome(或任何 Chromium 内核浏览器)访问调试入口,即可 attach 到 ArkWeb 里的页面:断点、Console、Network、Elements 一应俱全。处理坑 6 时,正是在 Console 里手动调用 window.__moxiaBridge.readFile(uri) 复现出 Operation not permitted,才确认了"授权一次性"的推断;处理坑 10 时,则是在 Sources 面板断在 writeFile 入口,单步看到内容源在弹窗前后发生了变化。

给后来者一条建议:移植类项目的第一行代码就应该是打开调试开关。Web 内核里的故障,日志只能告诉你"死了",DevTools 才能告诉你"怎么死的"。

七、成色如何:体积、启动与真机表现

移植前后放在一起对比,差距是数量级的:

维度Electron 壳阶段系统原生壳(终版)
包体~200MB(含完整 Electron 运行时)~6MB
模块结构entry + web_engine 双模块entry 单模块
引擎随包 Chromium/Node,需持续对齐系统版本系统 ArkWeb,随系统演进
文件能力Electron 主进程改造 + picker原生 picker + file.fs,零权限
启动崩溃风险SIGTRAP(底座错配史)无(无第三方运行时)

真机实测:安装即用、进程稳定,编辑上万字长文输入顺滑,打开/保存/另存为全链路可用。开发期还开了 setWebDebuggingAccess(true),DevTools 可以直接 attach 到 ArkWeb 排查页面内部状态——这条调试通道在处理坑 6、坑 10 时立了大功。

几个体感维度的对比也值得记录:

冷启动。Electron 版要拉起主进程、GPU 进程、渲染进程的完整编排,冷启动明显长于终版;终版的启动路径极短——ArkUI 渲染一个 Web 组件,Web 组件加载本地 rawfile 里的静态 HTML,编辑器内核初始化完成后即可输入。启动链条上少一层"运行时拉运行时"的间接性,快是结构性的快,不是调优出来的快。

内存占用。Electron 版常驻一整套 Chromium + Node 运行时,内存水位天然高一个台阶;终版复用系统 ArkWeb 的共享内核,应用自身只需承载编辑器页面的开销。对 2in1 设备上同时开着一堆生产力工具的用户来说,这个差距在多应用并行时会被放大。

更新面。Electron 版的系统适配风险藏在随包运行时里——每次系统升级都可能需要重新对齐底座版本;终版没有随包运行时,ArkWeb 由系统统一演进,应用侧要跟的只剩编辑器内核自身的版本。移植工作从"持续负债"变成了"一次结清"。

应用运行实拍

八、安全设计:零敏感权限是怎么做到的

很多移植作品为了省事会申请全局文件读写权限,这是一条既能跑通、又会丢分的路。本应用的安全模型从第一行代码起就是用户授权制:

  1. 不申请任何敏感权限:module.json5 的权限表干干净净,没有任何 ohos.permission 层面的文件域权限;
  2. 一切文件访问经 picker:打开、保存、另存为全部通过 DocumentViewPicker 的系统级界面,用户看得见、选得准、可取消;
  3. 授权最小化:每次 picker 授权只覆盖当次选中的那一个文件(坑 6 的"一次性授权"在安全视角下反而是特性——天然杜绝了越权访问);
  4. 全链路日志可审计:桥的每个动作(begin/ok/FAIL)都打 hilog,出问题可回溯,但日志中的 uri 等内容不涉及用户数据实体;
  5. 数据不出设备:无网络请求、无遥测,文档永远只存在于用户指定的位置。

这套模型也让审核沟通变得简单:权限表为空,没有需要解释的敏感行为。

九、上架:从 HAP 到应用市场

9.1 上架材料

上架准备的核心工作量在素材与合规,技术侧反而轻松(HAP 构建签名流程在第一阶段已经全部跑通):

  • 应用信息:名称、简介、分类(工具)、年龄分级;
  • 应用截图与宣传图:首图、功能宣传图若干,突出"所见即所得"“语法高亮”“表格编辑”“实时预览”“学术引用”"本地优先·隐私安全"六个卖点;
  • 隐私政策与权限说明:零敏感权限让这一项几乎零成本;
  • 签名 release 包:AppGallery release profile 签名的正式 HAP。

9.2 审核与上架结果

提交审核后,应用顺利过审并上架华为应用市场,商店分类为工具类,面向 2in1/PC 设备分发:

在这里插入图片描述

上架页的应用介绍里,我特意写明了技术底细,而不是含糊带过:

墨匣学术写作编辑器是一款专为2in1设备打造的 Markdown 学术写作工具,移植自国际知名开源写作软件 Zettlr 的核心编辑引擎……【流畅的写作体验】基于 CodeMirror 6 编辑器内核,支持 Markdown 语法高亮与所见即所得实时渲染——输入标题即刻呈现,加粗即时生效……

对开发者坦诚技术栈,对用户讲清楚能得到什么,这是上架文案的基本功。

9.3 素材制作的三点心得

上架素材的投入产出比远比想象中高,分享三点实测心得:

首图决定第一印象。 首图采用"左文右图"的经典布局:左侧品牌字标加一句话定位,右侧放应用真实界面截图。切忌用纯渲染的效果图——用户下载后看到的界面如果和首图差距过大,第一波评价就会失分。

宣传图讲"卖点",不讲"参数"。 第二张宣传图的主题是"为专注而设计",副标题"把繁琐交给工具,把沉浸留给写作",下面排六个图标加短语:所见即所得、语法高亮、表格编辑、实时预览、学术引用、本地优先·隐私安全。每个短语都对应一个用户可验证的真实功能,没有一句形容词堆砌。

隐私卖点值得单独强调。 "本地优先 · 隐私安全"在六卖点里排最后但分量不轻——对学术写作者来说,未发表的论文手稿是核心资产,"数据不出设备"是比任何编辑功能都硬的购买理由。而这一点恰好是零敏感权限架构的天然副产品:安全架构做得好,宣传素材都不用编。

十、复盘:这次移植教会我的五件事

1. 先问"内核在哪",再问"壳用什么"。
Zettlr 的灵魂是 CodeMirror 6 内核,不是 Electron。识别出资产的真实边界后,移植方案自然从"整体搬运"收敛为"内核原样复用 + 壳交给系统原生组件"。200MB 到 6MB 的差距,本质是认清边界的差距。

2. 桥要窄,适配要厚。
原生桥只暴露 4 个方法,把所有跨端的不确定性(授权失效、返回值不可靠、实例重建)全部吸收在页面适配层里。bundle 一行未改,意味着将来换内核版本,适配层几乎可以原样沿用。

3. 授权是一次性的,把它当事实来设计。
坑 6 的教训值得所有做文件类应用的人记住:不要假设"打开时能读 = 之后能读"。在授权新鲜的那一刻把数据拿到手,之后的流程全部基于内存态。

4. 桥返回值不可靠,就用主动推送。
跨语言边界的异步结果传递,靠返回值是脆弱的;runJavaScript 主动回调 + token + 超时兜底,才是一个能在真机上活下来的设计。

5. 日志密度决定真机排障效率。
桥的每一步、Web 的每个生命周期事件都有 hilog,真机上问题定位从"盲猜"变成"顺着日志走"。移动/嵌入式开发里,这条纪律的性价比无与伦比。

附:给后来者的移植检查清单

如果你也想把一个 Web 技术栈的桌面应用搬上鸿蒙 PC,下面这份清单是从本项目里提炼的最低限度检查项:

架构决策阶段:

  • 盘点原应用对 Electron/Node 的真实依赖面:文件 IO、对话框、原生模块、托盘、自动更新……列成清单,逐项找 OHOS 系统能力的对应物;
  • 找出"应用真正的内核资产"(本项目里是 CodeMirror bundle),确认它是否可以在纯 WebView 环境运行——如果可以,Electron 大概率只是壳;
  • 估算随包运行时的体积代价,并与"系统组件替代方案"对比,这个数字会直接影响上架后的转化率。

桥设计阶段:

  • 桥的方法数量压到最小集,每一个都是"系统能力映射",不掺业务逻辑;
  • 约定好"授权一次性"前提下的数据流:谁在授权新鲜时读数据、谁持有内存态、bundle 如何被喂内容;
  • 写结果一律用 runJavaScript 主动推送,不依赖桥返回值;推送配 token 与超时兜底。

实现阶段:

  • 第一行代码打开 setWebDebuggingAccess(true);
  • 桥的每个动作打 hilog,Web 生命周期事件(onPageEnd/onErrorReceive)全部接日志;
  • 涉及 picker 的每一条路径都问一遍:“这个 uri 被第二次使用了吗?”

上架阶段:

  • 签名材料与 bundleName 严格对应,正式包用应用市场通道的 release profile;
  • 零敏感权限优先于"申请了再说"——它同时降低审核风险、抬高隐私卖点;
  • 首图与实机界面保持一致,宣传图只写用户可验证的功能。

这份清单不能保证你的移植一帆风顺,但能保证你踩的坑里,没有一个是别人已经踩过并且写下来的。

十一、后续路线

上架只是第一站。回看整个移植过程,功能上做了一次刻意取舍:首版只保留"单文档编辑"这条主干(新建、打开、编辑、保存、另存为、预览),把 Zettlr 完整版里的工作区管理、引用库、导出引擎全部推迟。取舍的依据是一条朴素的主干优先原则——一个能可靠打开和保存文档的编辑器,胜过一堆 demo 级的半成品功能。上架首版验证的是架构与体验的成色,接下来按重要性逐步把枝叶接回主干,路线图上还有几站:

  • 工作区模式:目录级项目管理,支持侧边文件树与多文档切换,向 Zettlr 完整形态靠拢;
  • 引用管理增强:在降级桩的基础上,把 [@key] 引用的补全与 CSL 渲染做成 JS 侧实现;
  • 导出链路:Pandoc 类导出能力的纯 JS 替代(HTML/PDF 导出优先);
  • 双链与卡片盒:[[链接]] 语法支持与标签面板,把 Zettelkasten 的方法论完整带过来。

结语

从 Electron 0.15 秒闪退的至暗时刻,到应用市场里那个可以"打开"的墨匣图标,这次移植最直观的成果是包体从 200MB 到 6MB、崩溃从"随机出现"到"归零";但比数字更重要的是方法论:在移植里,最大的杠杆不是复用更多的代码,而是认清哪些代码根本不需要被复用。

墨已研好,匣已开启。愿每一位在鸿蒙 PC 上写作的人,都能让思想先于格式抵达。


本文所有代码片段均取自墨匣学术写作编辑器 v1.0.0 实际工程(ArkTS / TypeScript / JavaScript),配图为开发与上架过程真实截图。

Logo

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

更多推荐