OpenPencil 如何用 Rust 一套代码跑通 Windows / macOS / Linux / HarmonyOS / iOS / Android

16 阅读26分钟

OpenPencil 如何用 Rust 一套代码跑通 Windows / macOS / Linux / HarmonyOS / iOS / Android

摘要:OpenPencil 是一个开源的 AI 原生矢量设计工具。这篇文章完整拆解它如何用 Rust 实现「一次编写、七类设备投递」——桌面(Windows / macOS / Linux)、Web、iOS / Android,以及 HarmonyOS(手机 / 平板 / 2in1 PC)。内容包括:为什么选 Rust、四宿主 + 三绑定 + 三薄壳的分层架构、编辑核心与渲染管线、每个平台的壳与绑定细节、CI 验证矩阵,以及一份毫不掩饰的「已验证 / 未验证」边界清单。

现状声明(重要):本文记录的是跨平台接入**第一阶段(第一步)**的现状,不是最终架构。模块分层、命名与共享逻辑的归属都会在后续整理重构;HarmonyOS 的 GPU 渲染还有「最后一公里」待接通。文中的目录结构随时可能变化,请始终以仓库最新源码为准。

0. 先看结论

OpenPencil 曾经是 TypeScript(Electron + Web)应用,如今产品本体是 Rust。跨平台的秘诀不是「每个平台写一遍」,而是把平台差异压缩到两处最薄的接缝里:窗口 / 表面(surface) 与 系统能力(文件选择器、登录、IME),其余全部共享一份代码。

平台形态宿主 / 绑定渲染后端壳状态
Windows x86_64 / ARM64op-host-desktop(winit)Skia GL原生二进制✅ CI 构建(ARM64 为 check)
macOS Apple Silicon / Intelop-host-desktop(winit fork)Skia GL / Metal原生二进制✅ CI 构建(Intel 为 check)
Linux x86_64 / aarch64op-host-desktopSkia GL(EGL/Mesa)原生二进制✅ CI 构建 + 测试
Web(PC 浏览器)op-host-web(wasm32)CanvasKit(Skia WASM)浏览器 JS✅ 5 MiB 级产物,0 个 env 导入
iOS 手机 / iPadop-engine-ffi(staticlib)Metal(CAMetalLayer)SwiftUI / UIKit✅ App Store 发布车道
Android 手机 / 平板op-engine-jni(cdylib)EGL/GLES(SurfaceView)Kotlin(Gradle)✅ release 发布车道
HarmonyOS 5 手机 / 平板 / 2in1(PC)op-engine-napi(libopenpencil.so)EGL/GLES(XComponent,规划中)ArkTS(DevEco / hvigor)⚠️ 编译 + 契约测试全绿;GPU 表面后端仍待接通,真机未验证(见 §7.4)

「PC」在 OpenPencil 里有两个落点:桌面原生二进制(Windows / macOS / Linux),以及 HarmonyOS 的 2in1 设备形态——同一个 ArkTS 包通过 deviceTypes 声明 [phone, tablet, 2in1] 安装到 PC 类 HarmonyOS 设备,引擎在 2in1 上自动切换完整桌面布局(见 §7.3)。

本文怎么读:§1–§3 讲「为什么」与「怎么分」——技术选型与分层原则;§4–§5 讲桌面与 Web;§6–§7 讲移动三端(iOS / Android / HarmonyOS),其中 §7 有最完整的绑定层细节;§8 讲 CI;§9 是当前阶段定位,§10 是可迁移的工程原则。想快速理解架构,看 §2 与 §3 即可。

1. 为什么是 Rust

1.1 一种语言,三种编译形态

跨端方案大体有三条路线:Web 技术套壳(Electron / Tauri)、各端原生重写(Swift + Kotlin + C++ 各写一遍)、以及「原生核心 + 薄壳」混合路线。OpenPencil 选的是第三条,而让它成立的前提是 Rust 的编译目标足够全:

编译形态目标对应平台
主机原生 AOT 二进制x86_64 / aarch64 + Windows / macOS / Linux桌面三平台
wasm32wasm32-unknown-unknown浏览器(PC Web)
C ABI 动态库 / 静态库cdylib / staticlibiOS、Android、HarmonyOS(都能链接 C ABI)

这意味着同一份编辑核心可以同时变成:桌面 App 的可执行文件、浏览器的 wasm 模块、iOS 的静态库、Android 的 .so、HarmonyOS 的 .so。平台差异只发生在「最后一层」,而不是每一行业务代码。

1.2 具体收益

  • 一个语言栈替代三个语言栈。旧 TypeScript 版的桌面(Electron)、Web、移动三份实现各自漂移,版本对齐是长期债务;现在编辑器只有一份实现,桌面、Web、移动共用同一套 widgets 与状态机(§2)。
  • 无 GC、帧率稳定。编辑器高频操作是命中测试、布局与重绘,移动端 JIT/GC 的抖动直接体现为掉帧;Rust 的所有权模型没有这个包袱,且可以静态链接进任何壳。
  • 内存安全面对不可信输入。OpenPencil 打开的是 .op 文件、AI 生成的结构、Figma 导入的 JSON——这些输入都不可信。Rust 在编译期消灭整类内存错误,这对「打开任意文件」的产品尤其重要。
  • 同一个渲染栈。原生侧用 skia-safe(Skia 的 Rust 绑定),浏览器侧用官方 CanvasKit(Skia 的 wasm 构建)——同一个渲染器的两种交付方式,移动与桌面共用同一个画布绘制器(§3)。
  • 生态刚好补齐了每一块拼图:skia-bindings 对 OpenHarmony 有一等平台支持(§3.4)、社区有 napi-ohos 绑定 crate(§7)、Android 有 cargo-ndk、iOS 有 XcodeGen,跨平台构建链是成熟而不是冒险。

1.3 工程底座

整个产品是一个 Cargo workspace:核心、宿主、绑定、壳各就其位;工具链固定(1.94),clippy / fmt 全绿是 CI 门槛,测试在 Windows 上用 nextest 每进程隔离(§4.3)。仓库还有一条硬规矩——每个 .rs 文件不超过 800 行,超了就拆文件,且当前 workspace 零违规。这保证了跨平台代码量膨胀时,可维护性不塌。

历史注脚:TypeScript 版已在 v0.7.5 退役,Rust 实现不是「对齐移植」,它就是产品本体——今天仓库里的所有平台能力都以 Rust 为核心。

2. 总架构:四宿主、三绑定、三薄壳

flowchart TB
    subgraph shells["壳层 · 每平台最薄的一层(不含编辑逻辑)"]
        IOS[&#34;iOS · SwiftUI / UIKit<br/>CAMetalLayer&#34;]
        AND[&#34;Android · Kotlin<br/>SurfaceView&#34;]
        OHOS[&#34;HarmonyOS · ArkTS<br/>XComponent&#34;]
        BR[&#34;Web · 浏览器 JS<br/>CanvasKit&#34;]
    end

    IOS -->|&#34;C ABI&#34;| FFI[&#34;op-engine-ffi<br/>稳定 C ABI(iOS / Android 共用)&#34;]
    AND -->|&#34;JNI&#34;| JNI[&#34;op-engine-jni<br/>JNI 编组层<br/>(引擎线程 + 句柄表)&#34;]
    OHOS -->|&#34;Node-API (NAPI)&#34;| NAPI[&#34;op-engine-napi<br/>OHOS 绑定<br/>(复用 jni 线程 / 句柄表)&#34;]
    BR -->|&#34;wasm 导出&#34;| WEB[&#34;op-host-web<br/>wasm32 cdylib&#34;]

    JNI --> FFI
    NAPI --> FFI

    subgraph hosts[&#34;宿主层 · Rust&#34;]
        DESK[&#34;op-host-desktop<br/>桌面二进制 openpencil-desktop<br/>(winit + Skia GL)&#34;]
        NAT[&#34;op-host-native<br/>WidgetHost + Skia GL<br/>(桌面 / 移动共用)&#34;]
        CLI[&#34;op-cli · 命令行工具 op&#34;]
    end

    FFI --> NAT
    WEB --> NAT
    DESK --> NAT

    subgraph core[&#34;平台无关编辑核心 · 一份代码&#34;]
        UI[&#34;op-editor-ui<br/>平台无关 widgets + RenderBackend 接缝&#34;]
        EC[&#34;op-editor-core<br/>PenDocument / EditorState&#34;]
        HC[&#34;op-editor-host-core<br/>无传输层宿主状态机&#34;]
        COL[&#34;op-collab<br/>无传输层协作协议&#34;]
    end

    NAT --> UI
    UI --> EC
    NAT --> HC
    NAT --> COL
    CLI --> UI

    subgraph prim[&#34;渲染原语 · 依赖的 Rust 框架&#34;]
        JIAN[&#34;jian<br/>jian-skia · jian-scene · jian-core&#34;]
        CAS[&#34;casement · winit fork<br/>(含 macOS 修复)&#34;]
    end

    UI --> JIAN
    DESK --> CAS

这张图从上到下是「越往下越平台无关」:

第一层·壳。每个平台一份,只做四件事:持有渲染表面(CAMetalLayer / SurfaceView / XComponent / CanvasKit 画布)、转发原始输入(指针 / 键盘 / IME)、提供系统能力(文件选择器、登录)、跟随系统生命周期。壳里没有任何编辑逻辑。

第二层·绑定。把「引擎线程上的调用」翻译成平台的 FFI 方言:iOS 直接走 C ABI(Objective-C/Swift 原生支持)、Android 走 JNI、HarmonyOS 走 Node-API(NAPI)、Web 走 wasm 导出。绑定层只做编组(marshalling),不做业务——这是它能保持几百行规模的原因。

第三层·宿主。op-host-desktop 是桌面二进制的入口(winit 窗口 + Skia GL),op-host-native 是真正的原生宿主库(WidgetHost + 渲染后端,桌面与移动共用),op-host-web 是浏览器入口,op-cli 是命令行。

第四层·编辑核心。平台无关,wasm32-clean 强制——任何平台 API 都进不来(§2.3)。

第五层·渲染原语。依赖的 Rust 框架:jian(Skia 适配、渲染场景、布局与事件)与 casement(winit fork),见 §3 与 §4。

2.1 crate 地图

层级crate职责
编辑核心op-editor-core规范的 .op(PenDocument)编辑器状态、EditorCommand、设计变量解析
编辑核心op-editor-ui平台无关 widgets + RenderBackend 门面(wasm32-clean)
编辑核心op-editor-host-core无传输层的宿主状态机,所有宿主共用
编辑核心op-collab无传输层协作协议、规范哈希、精确文档应用(wasm32-clean)
宿主op-host-nativeWidgetHostNative + skia-safe GL 后端(桌面 + 移动)
宿主op-host-desktop桌面二进制 openpencil-desktop;兼作 --serve-web 守护进程
宿主op-host-web浏览器 wasm32 cdylib,CanvasKit 渲染
宿主op-cli命令行工具 op
绑定op-engine-ffi稳定 C ABI:把引擎嵌进 iOS / Android 壳(metal / gl / raster 表面)
绑定op-engine-jniAndroid JNI 编组层(引擎线程、句柄表、回调)
绑定op-engine-napiOpenHarmony Node-API 层 → libopenpencil.so(复用 JNI 的线程/句柄表)
协作op-collab-host与宿主无关的协作会话运行时(CollabRuntime + CollabHost),桌面与 serve-web 守护进程共同驱动
SDKop-web-sdk第三方嵌入用的 Web 查看器 SDK
工具op-util无依赖叶子 crate:协作 id 语法、hex 颜色、JSON / XML 转义

旁边还有 AI 相关的 op-ai / op-orchestrator / op-codegen / op-ai-skills(§5 会看到它们也进了 wasm 束)、导入相关的 op-figma / op-pen-loader、以及 op-mcp、op-git、op-i18n 等。共同点是:能进编辑核心的都不允许碰平台。

2.2 编辑核心内部:一份 Document 状态机

所有平台的编辑器状态收敛在一个 Document 结构里(对应旧 TS 版散落在多个 store 里的状态):

  • 页面树:pages、active_page_index;
  • 选择与工具:selected、tool——工具集是 Select / Rect / Ellipse / Polygon / Line / Pen / Text / Frame / Hand 九种;
  • 视口:viewport(平移、缩放与缩放锚点);
  • 聊天与 UI:chat 状态、侧栏与面板宽度、属性输入焦点、设置、颜色选择器、主题与语言、形状选择器等。

对它的所有修改都走命名明确的 mutator:提交属性编辑(解析 f32 写位置/尺寸/旋转/描边宽)、选中颜色写入、手柄拖拽改边界、删除/复制/排序、图层上下移、多锚点钢笔路径(第一个锚点前就打历史快照,撤销能还原到落笔前)、分组/解组、页面增删改、HSV 取色器锚定等。命中测试 node_at_doc_point 支持按节点旋转;复制时的 id 分配器会越过当前最大 id 防碰撞。

这套状态机一次写成,四端复用——桌面点击、手机单指、2in1 的鼠标、浏览器里的点击,最终都变成对同一组 mutator 的调用,行为天然一致。属性面板的节标题(位置 / 弹性布局 / 尺寸 / 图层 / 填充 / 描边 / 效果 / 导出)与「设计 / 代码」页签也由它按当前语言解析,15 种语言的 i18n 由 op-i18n 统一提供。

2.3 唯一接缝:RenderBackend trait

widget 代码与平台之间只有一个 trait:

fill_rect / stroke_rect / draw_text / clip_rect
save / restore / translate
stroke_line / fill_round_rect / stroke_round_rect / stroke_svg_path
resize / dpi_scale

两条强制不变量:

  • op-editor-ui 必须保持 wasm32-clean:widget 代码一次编译到桌面、移动与浏览器三种目标,任何平台 API 都进不来。
  • 只有宿主可以调用 widgets:native 与 web 宿主的 widget_host 是仅有的入口,仓库有边界检查脚本(check-widget-boundary.sh)守住这条线。

2.4 单源化:宁可共享,绝不复制

跨平台项目最大的坑是「同一个逻辑长出两棵孪生树」——改了这棵忘那棵。OpenPencil 的硬规矩:

  • 原生与 Web 的 widget_host 曾经是 fork,已收敛为单源(属性面板的派发 / 提交 / 布局写操作是宿主共享的)。
  • HarmonyOS 绑定直接复用 Android 绑定里的引擎线程队列与句柄表——这两块是纯 std、不含 JNI,刻意不复制:一份「字节相同的 850 行 teardown 顺序逻辑」正是团队被烧过的孪生复制。文档里写明:若出现第三个移动绑定,应把它们提升为独立 crate。
  • 工具函数收拢进 op-util:曾有九份 hex_color 实现(其中一份在非 ASCII 输入上 panic)、一份有损的 JSON 转义、一份漏掉引号实体的 XML 转义(属性注入漏洞)——现在只有一个无依赖、wasm32-clean 的实现。

2.5 资产策略:原生内嵌,wasm 按需拉取

产品资产(图标、模板预览图)在原生端编译期内嵌,在 wasm 端通过全局「路由 → 字节」注册表按帧经 XHR 拉取,拉取期间先画占位;注册表有单飞(single-flight)与 Absent / Pending / Ready / Failed 状态机,两端行为一致。这一项把 Web 束从 8 MiB 压到实测 5 MiB 级——约 11.3 MiB 的模板预览图、场景模板文档与图标目录被移出按需加载。

2.6 几个工程不变量

  • 800 行上限:每个 .rs 文件不超过 800 行,当前 workspace 零违规;拆分是纯代码搬迁,不改行为。
  • 类型化错误:失败路径一律带类型化错误枚举(workspace 有 80+ 个枚举;Result<_, String> 只在两处文档化的边界存活),新代码禁止再引入裸字符串错误。
  • 同步宿主代码里阻塞 future 只有一条合法入口(block_on_anywhere),它按调用上下文选择安全策略——在 tokio worker 上直接 block_on 会「runtime within runtime」直接 abort。
  • 原生宿主测试要带 --features gl-host:默认 feature 下只跑 55 个测试,gl-host 才解锁完整 676 个——这是移动构建不背 Skia GL 的代价。

3. 渲染层:一个 Skia,五个后端

3.1 jian:渲染原语框架

渲染原语来自 jian(依赖的 Rust 框架),四件套:

  • jian-skia:skia-safe 适配层,把画布操作落到 Skia;
  • jian-scene:规范渲染场景——LayoutScene + 命中测试 + 路径几何,编辑器和任何 jian 应用共用;
  • jian-core:事件类型与 taffy 布局;
  • jian-host-desktop:桌面 GL 管线。

上层把文档变成平台无关的渲染场景,下层适配 Skia。同一场景描述,五个后端:

后端平台载体
GL桌面 Windows / macOS / Linuxwinit 窗口
MetaliOS / macOS壳持有的 CAMetalLayer
EGL/GLESAndroidANativeWindow(SurfaceView)
CanvasKit(Skia WASM)Web浏览器画布
EGL/GLES(规划)HarmonyOSOH_NativeXComponent

像素一致性是卖点:iOS / Android / HarmonyOS 壳的文档都写明——「引擎用桌面编辑器画布完全相同的绘制器画当前页」。移动端不是「移动版渲染器」,而是同一个渲染器接到一块手机大小的表面上。

3.2 一致性的根:手势竞技场在引擎里

跨端一致性不只靠渲染。jian 内置一个手势竞技场(gesture arena):Tap / LongPress / Swipe / Scroll 以及多指 Pinch & Rotate,带冒泡式命中分发。移动三端的壳不做手势识别——手势由引擎解释,三端语义逐字相同:单指点击选中手指下最上层节点、单指拖动平移画布、双指捏合绕捏合中点缩放(§6.4)。手势、命中测试、光标逻辑只存在于一份 Rust 代码里。

3.3 图标、文本与启动性能

  • 图标:Lucide 的 d-strings 经 Skia 的 SVG path 解析绘制,圆头笔触 + 圆角连接,与 lucide 原版风格一致;
  • 文本:Skia 栅格渲染保证逐像素稳定,可按需启用完整 Paragraph 整形(textlayout);
  • 冷启动:三阶段启动驱动(DataPath 预窗口 → Visual 首帧 → Background 后台),每平台有预算测试与回归门禁,还可预烘焙初始布局(AOT)换取更快首帧。

3.4 Skia 的 OpenHarmony 一等支持

skia-bindings 内置 OHOS 平台分支:读取 OHOS NDK 路径,传 --sysroot / --target / musl 定义(OHOS 用 musl libc),设 GN 参数 skia_use_egl=true、GLES 标准、关闭 fontconfig/X11,并链接 c++ 静态运行时与 EGL / GLESv3。不需要手写 GN 参数;构建脚本只需保证 C/C++ 编译器命令携带 --target 三元组、并统一设置各编译器环境变量,防止捡到宿主机编译器(§7.4 详述)。

4. 桌面:op-host-desktop + casement(winit fork)

4.1 组成

桌面产物是二进制 openpencil-desktop:winit 负责窗口与事件,skia-safe GL 负责渲染;同一入口还提供 --serve-web 守护进程(把 Web 版跑成局域网服务)。命令行工具 op 来自同一 workspace。桌面与移动共用 op-host-native 的 WidgetHost,所以「编辑器本体」在桌面与手机上真的是同一份。

4.2 为什么 fork winit 成 casement

窗口层是 winit 的 fork(casement)。fork 的动机不是改架构,而是补齐桌面级 macOS 细节:

  • 保留无 marked text 的变换后 IME commit(中文输入法关键路径);
  • 窗口成为 key 窗口时应用红绿灯按钮 inset,跨 resize / 全屏退出幂等重定位;
  • 系统「打开方式」拖入的文件 URL 排空(drain_opened_file_urls)。

这些是「产品级 macOS 应用」与「能跑的窗口」之间的差距;上游合并节奏与产品节奏不同步,所以用 fork 承载,代价与收益都在明面上。

4.3 CI 矩阵与平台特有坑

目标runner形态
aarch64-apple-darwinmacos-latest构建 + 测试
x86_64-apple-darwinmacos-latest交叉 check(Intel runner 已退役)
x86_64-unknown-linux-gnuubuntu-latest构建 + 测试
aarch64-unknown-linux-gnuubuntu-24.04-arm原生 ARM 构建 + 测试
x86_64-pc-windows-msvcwindows-latest构建 + 测试(nextest)
aarch64-pc-windows-msvcwindows-latest交叉 check(暂无 Win11 ARM 托管 runner)

每个平台都有自己特有的坑,解决方案写在 CI 里:

  • Linux ARM64 用原生 ARM runner,因为常见交叉镜像的 Ubuntu/GCC/FreeType 版本太老,链不上现代 Skia(缺可变字体符号 + libstdc++ ABI 不匹配);Linux 还需预装 xkbcommon / Wayland / xcb / Mesa EGL-GLES / freetype / 中文字体 / xvfb 全家桶。
  • Windows:Skia 的字体管理器(DirectWrite)在主线程与工作线程并发排版时会段错误,进程内串行测试也拦不住跨线程竞争,因此用 nextest 每进程隔离跑测试,doctests 单独跑。
  • Linux GPU 冒烟测试已解锁:Skia 经 eglGetProcAddress 拿到 GL 接口;没有可用 EGL/Mesa 的 runner 上软跳过(INCONCLUSIVE),显式要求 GPU 的机器上可以失败硬停。

5. Web:op-host-web(wasm32 + CanvasKit)

Rust 编译成 wasm32 的 cdylib:Rust 部分纯逻辑,渲染委托给浏览器加载的官方 CanvasKit。两条硬性门槛:6 MiB gzip 上限、0 个 env. 导入*——后者意味着无需 libc shim,浏览器里零环境依赖。

这个束里装的是完整应用:代码生成 AI 流水线、Figma 解析器、AI / 实时同步、协作协议,以及内嵌的 AI 技能语料(约 1.1 MiB markdown,include_dir! 打进二进制)。实测 op-host-web 5.17 MiB、第三方嵌入用的 op-web-sdk 5.20 MiB——6 MiB 上限是失控回归的绊线,不是预算。

细节上:

  • 资产(§2.5)按帧拉取,占位绘制;路径在 /pkg/assets/ 下由脚本分阶段注入束;
  • 图标目录可选择性外置:op-host-web 有守护进程按需拉取,op-web-sdk 无守护进程则保持内嵌——对查看器来说「还没拉到」等于「永远没有」;
  • 双轨编译守卫:默认 web feature 是不上屏的 stub 挂载,只证明公开面 wasm32-clean;生产渲染走 canvaskit feature,由专门的 wasm-bundle 工作流构建并过束检查。

Web 与桌面共享同一份 widget 与编辑器状态(§2.2),差异只剩 RenderBackend 的实现与资产拉取路径——这是整篇文章最想证明的事:浏览器版不是另一个产品,是同一个产品的另一种投递。

6. 移动:一个 C ABI,两种绑定,三个壳

6.1 op-engine-ffi —— 稳定 C ABI

嵌入层的 crate 类型按平台切换:cdylib 给 Android,staticlib 给 iOS,rlib 给宿主机侧测试。ABI 设计上处处为跨语言稳定考虑:返回 i32 状态码(0 = Ok,1 = InvalidArg,…,10 = NotReady;-1 = STATUS_CLOSING 表示句柄已销毁,不会派发);坐标统一为表面逻辑像素、左上原点,dpr 换算物理像素;文本偏移按 UTF-16 码元。

feature 开关对应平台能力:

feature内容
metaliOS / macOS Metal 渲染(经壳持有的 CAMetalLayer)
glAndroid EGL/GLES 渲染(经壳持有的 ANativeWindow)
editor完整编辑器模式:顶栏、图层面板、工具栏、属性面板、聊天、设置——桌面全套 chrome 跑在手机上
pinned-skia-binaries生产发布车道:使用摘要校验钉住的预编译 Skia 归档

6.2 iOS:SwiftUI 壳 + Metal

iOS 侧是纯源码 SwiftUI/UIKit 壳,用 XcodeGen 从 project.yml 生成工程,Rust 静态库是外部构建输入:

cargo build -p op-engine-ffi --release --target aarch64-apple-ios-sim --features metal,editor
cargo build -p op-engine-ffi --release --target aarch64-apple-ios     --features metal,editor

壳的纪律相当严格:

  • 视图持有自己的 CAMetalLayer;传给引擎的指针是借用,有效期到 suspend / destroy 返回;
  • 单线程模型:UIKit、CADisplayLink(帧泵)、全部引擎调用与回调反应都在主线程;回调载荷同步拷贝、异步派发——回调永远不重入 ABI;
  • 系统 Files 选择器打开 .op / .pen;导出 PNG / JPEG / SVG / PDF(WebP 隐藏——移动 Skia 归档不含 WebP 编码器);
  • 登录走平台原生能力(如 Apple 原生登录表单),注册与找回密码是原生同级页面,第三方登录在应用内表单完成——壳负责「系统长相」,引擎只关心配对结果。

6.3 Android:Kotlin 壳 + EGL/GLES

Gradle Kotlin 壳(Gradle 8.14.3 + JDK 17+),引擎经 JNI 渲染到 SurfaceView(EGL/GLES),壳负责生命周期、insets、触摸与帧泵:

cargo ndk -t arm64-v8a -t x86_64 -o packaging/android/app/src/debug/jniLibs build -p op-engine-jni --features gl,editor

JNI 编组层提供引擎线程队列(投递 / 阻塞调用 / 关闭排空 / 延迟销毁)、单调递增的句柄表(销毁后的句柄不会被误用)与回调上抛(alog 日志)。Debug 与 Release 分属不同的 jniLibs source set,发布产物走独立车道。这两块纯 std 组件后来被 HarmonyOS 绑定直接复用(§7.2)。

6.4 三端一致的手势语义

再强调一次:壳不做手势识别。单指点击选中、单指拖动平移、双指捏合缩放,三端由同一份 Rust 代码解释(§3.2)。iOS 的 UIKit 手势、Android 的 GestureDetector、HarmonyOS 的 XComponent 事件,在这里全部退化为「原始指针事件转发器」。

6.5 editor 模式:手机上的完整编辑器

移动端有两种形态:mode 0 查看器、mode 1 完整编辑器。editor feature 打开的是后者——不是「移动简化版」,而是把桌面那套 chrome(顶栏、图层、工具栏、属性面板、聊天、设置)原样搬到手机上。代价是壳要多转一批桌面语义的输入(第二指按下中点、捏合增量、无按键悬停等),收获是功能集合与桌面完全对齐。

7. HarmonyOS:ArkTS 壳 + Node-API,一个包覆盖 phone / tablet / 2in1

HarmonyOS 侧定位 HarmonyOS 5(API 12+),包名 tech.zseven.openpencil,一个 App 同时覆盖手机、平板与 PC 形态。引擎把整个 UI 绘制进单个 XComponent 表面;ArkTS 只转发生命周期、原始指针、按键、IME 与文件选择器,别的什么都不做。壳源文件按职责拆开:EngineHost(引擎宿主)、PointerRouter(指针路由)、ImeConduit / ImeProxyBridge(输入法桥)、DocumentShell(文件选择与导出)、WindowBridge(窗口)、AuthRuntime(登录)等。

7.1 NAPI 契约:库名即 ABI

ArkTS 的导入语句就是库文件名,写死在 ABI 契约里:

import native from 'libopenpencil.so';

导出的 ArkTS API 是 Kotlin 契约的 camelCase 翻译(nativeEditorPress → editorPress),外加 OHOS 独有补充。契约由单测强制执行:导出名缺失或多余会直接构建失败。

生命周期核心面:

函数职责
create(doc, w, h, dpr, callbacks, storageRoot, mode)创建引擎,返回句柄(0 = 失败);mode 0 查看器 / 1 编辑器;编辑器模式下 doc 可空(开空白起始文档),查看器必须有字节;storageRoot 必须是应用私有绝对路径
attachSurface / suspend / resumeXComponent 表面收养;suspend 是阻塞屏障,返回时 GPU 表面已释放
resize / resizeWithSafeArea / setSafeArea / setKeyboard逻辑尺寸、刘海/安全区、软键盘高度
frame / pointer帧泵(返回真实帧状态)与原始指针
destroy / lastError销毁与错误读取(传 0 可读 create 失败原因)

回调(都在 ArkUI 事件循环上执行,绝不在引擎调用栈内):onNeedsRedraw(hasNextWake, nextWakeMs)(帧调度)、onRuntimeError、onInputFocusChanged(含返回键提示)、onRemoteImageRequest(图片按需拉取)。

文本编辑 + IME 面完整覆盖 textBegin / textInsert / textBackspace / imeSetComposingText / imeCommitComposition / textCaretRect 等 12 个函数——偏移按 UTF-16 码元(编辑器 IME 预编辑文本例外按字节,契约里逐条写明)。远程图片、注册字体、页面切换各有对应函数。

7.2 绑定层结构与三个决策

flowchart TB
    ARK[&#34;ArkTS 壳(packaging/harmony)<br/>import native from 'libopenpencil.so'&#34;]
    ARK --> NAPI[&#34;op-engine-napi · OHOS 绑定&#34;]

    NAPI --> MOD[&#34;module.rs<br/>Node-API 模块面<br/>+ 加载时 XComponent 收养&#34;]
    NAPI --> BIND[&#34;bindings*.rs<br/>导出函数<br/>(与 JNI natives 同构拆分)&#34;]
    NAPI --> CB[&#34;callbacks.rs<br/>引擎 → ArkTS 上抛<br/>(threadsafe functions)&#34;]
    NAPI --> WIN[&#34;window.rs<br/>按 XComponent id 的表面借用簿记&#34;]
    NAPI --> XC[&#34;xcomponent.rs<br/>手写 OH_NativeXComponent NDK 声明&#34;]
    NAPI --> HIL[&#34;hilog.rs<br/>HiLog 写入<br/>(Android 日志的孪生)&#34;]
    NAPI --> ACT[&#34;action.rs<br/>平台无关常量 + 事件映射<br/>(宿主机可测)&#34;]

    NAPI --> FFI[&#34;op-engine-ffi · C ABI&#34;]
    FFI --> CORE[&#34;op-host-native / op-editor-* / jian-skia&#34;]

三个工程决策:

  1. 复用而非重写:引擎线程与句柄表直接从 Android 绑定 import(§2.4)。
  2. 一切 OHOS 相关代码 target 门控:除平台无关常量模块外,每个模块都带 cfg(target_os = "linux", target_env = "ohos") 门控,因此在没有 NDK 的开发机上,该 crate 是惰性 stub——全 workspace 的 check / test 依旧全绿。
  3. 手写 NDK 声明、常量转录自官方头:XComponent 事件类型与回调表、键码、HiLog 级别全部转录自 OpenHarmony 官方头文件,不猜常量。

依赖的社区 crate 是 napi-ohos 1.2.0 系列(MIT),已验证可为 aarch64-unknown-linux-ohos 编译。

7.3 2in1 / PC 形态:同一引擎,桌面 chrome

ArkTS 壳在 2in1 设备上收到鼠标与硬件键盘事件,引擎相应切换形态:

  • editorSetTouchChrome(enabled):false = 完整桌面布局(PC / 2in1),true = 移动触控 chrome;
  • editorHover(无按键光标移动)、editorWheel(面板感知滚轮、Ctrl+滚轮缩放)、keyModifiers()(硬件修饰键位掩码)——这是 OHOS 绑定相对 Android 的独有补充;
  • 壳动作码覆盖完整系统交互:打开文档、登录 WebView 开关、导出、账户中心、语言选择器,以及窗口关闭 / 最小化 / 缩放——后三者来自顶栏绘制的红绿灯圆点,只会到达隐藏了平台标题栏的桌面级壳;触控 chrome 不画圆点;
  • 编辑器键码与桌面一致:Backspace / Delete / Enter / Escape / 复制 / 撤销 / 重做 / 方向键,硬件键盘直接映射。

一句话:2in1 上的 HarmonyOS 包接上键盘鼠标,就退化成「桌面版」的行为——同一份引擎,两种 chrome。

7.4 构建链路与诚实状态

export OHOS_NDK_HOME="$HOME/command-line-tools/sdk/default/openharmony"
scripts/build-ohos.sh --release
# -> packaging/harmony/entry/libs/arm64-v8a/libopenpencil.so

构建脚本走 clang 包装器(注入 --target + --sysroot),目标三元组 aarch64-unknown-linux-ohos,另有 x86_64 模拟器车道(OHOS_TARGET)。几个容易踩的坑都已处理:

  • CC / CXX 必须把 --target= 写进编译器命令本身(skia-bindings 从 CC 里解析目标三元组,优先于 cargo 三元组);
  • CLANGCC / CLANGCXX 优先级高于 CC / CXX,脚本四者同设,杜绝宿主机编译器混入;
  • 链接器走 AR_ 环境变量而不是 cargo config 的 ar 键(cargo 忽略那个键);
  • OHOS_NDK_HOME 指向包含 native/ 的目录(skia-bindings 自己会追加)。

ArkTS 工程不编译任何原生代码,只消费预构建 .so(无 CMake 目标),通过 oh-package 把 libopenpencil.so 的类型包接进工程;签名需要华为开发者账号,未签名什么都装不上真机/模拟器。

状态必须诚实——当前开发机没有安装 OHOS NDK,官方文档里有一份明确的「已验证 / 未验证」清单:

已验证 ✅:开发机上 check / test / fmt / clippy 全绿;全部 OHOS 门控模块针对 aarch64-unknown-linux-ohos 通过类型检查与 lint(以 stub 链接,含 / 不含 editor feature);napi-ohos 系列 crate 存在且可编译;NDK 常量转录自上游头文件。

未验证 ⚠️:真实交叉构建从未运行过(Skia 必须为 OHOS 从源码编译);attachSurface 目前无法成功——引擎的 EGL 表面后端仍是 android 门控,OHOS 上调用返回 NotReady,修复点是渲染依赖处一处改动 + 引擎侧两行门控放宽,文档已写明到「三处编辑」的粒度;链接名(libace_ndk.z.so / libhilog_ndk.z.so)未经真实链接验证;模块注册假设未经 ArkTS 运行时执行;模拟器车道从未构建;HiLog domain 是选定的而非规定值。

一句话:OHOS 模块构建、加载、驱动一切,除了上屏——GPU 表面后端的最后接通,是明确的下一步。

8. CI 矩阵:跨平台承诺的可执行形式

主矩阵(rust-multiplatform.yml)一次跑完:

组目标runner备注
桌面6 个三元组macOS / Ubuntu / Windows见 §4.3 表;ARM64 系 check-only 条目诚实标注
Webwasm32-unknown-unknownUbuntu默认 feature 编译守卫(stub 挂载,不上屏);生产束由 wasm-bundle 工作流单独构建并过 6 MiB + 0 env 检查
移动ios-aarch64 / ios-aarch64-sim(features metal,editor);android-aarch64 / android-x86_64(features gl,editor)macOS / Ubuntucargo check 级:保证 Rust 侧为每个目标保持可编译;iOS 需要 Xcode SDK 跑 macOS runner,Android 走 NDK

发布侧:ios-app-store.yml 与 android-release.yml 是商店车道;生产发布一律走 pinned-skia-binaries——摘要校验的预编译 Skia 归档由评审过的流程分阶段放入,杜绝 CI 与本地构建的 Skia 差异。工具链 1.94,rust-cache 加速,fail-fast: false 让矩阵并行跑完再报。

9. 现状与演进:这是第一步,不是最终架构

必须坦白地说清楚当前所处的位置:

  • 已完成的(第一步):Rust 编辑核心就位;桌面三平台与 Web 全绿;iOS / Android 绑定与壳跑通发布车道;HarmonyOS 的 NAPI 绑定、ArkTS 壳与 2in1 桌面形态按同一架构就位,编译与契约测试全绿。
  • 明确不是最终态的:整体架构设计还会重新整理——模块分层与命名、共享逻辑的归属(例如引擎线程 / 句柄表提升为独立 crate)、绑定层与壳层的边界,都会在后续阶段继续演进。文中出现的目录结构只代表接入第一阶段的形态。
  • 待接通的:HarmonyOS 的 Skia EGL 表面后端(修复点已文档化到具体位置)、OHOS 真机验证、以及各平台的体验打磨。

一句话总结:跨平台这条路已经用最小的接缝走通了桌面、Web 与移动的第一程;HarmonyOS 的绑定已按同一架构搭好骨架,差的最后一步是 GPU 表面接通。所有结构都可能在下一阶段整理重构——本文是「第一阶段快照」,不是「最终设计蓝图」。

10. 可复用的工程原则

  1. 薄壳原则:壳只做系统能力(表面、文件选择器、登录、IME);手势、命中测试、渲染、编辑逻辑全部留在引擎。壳代码量小到可以按平台重写而不心疼。
  2. 唯一接缝:RenderBackend trait + C ABI + 三个 FFI 层;接缝越少,平台越多越便宜。
  3. 门控而非分叉:OHOS 代码用 target_env 门控、移动渲染用 metal / gl feature 门控、GL 宿主能力不进移动构建——一份源码树编译出所有目标,未就绪平台的代码还能在无 NDK 机器上保持全绿。
  4. 契约测试当编译器用:NAPI 导出名缺失 / 多余直接构建失败;Kotlin 契约与 ArkTS 契约同构翻译。
  5. 不复制 850 行:跨绑定共享逻辑显式 import,并预留「提升为独立 crate」的演进路线。
  6. 矩阵即承诺:每个目标三元组在 CI 里有名字、有 runner、有 feature 组合;check-only 的条目诚实标注。
  7. 诚实文档:「已验证 / 未验证」清单写进 README,未验证的部分逐条列明原因与修复点——这是跨平台项目最重要的资产:让下一个接手的人不用猜哪部分是传说。
  8. 薄壳 + 厚核的边界脚本化:widget 调用边界、wasm 束上限、800 行上限全部有检查脚本,规则不靠自觉。

本文基于 OpenPencil(github.com/ZSeven-W/openpencil)及其依赖 jian / casement 的公开源码与 CI 配置整理;平台状态以仓库最新代码为准。