没重构之前,用 cj-tauri 写过示例的人多半踩过同一个坑:想给应用加一个端,得把 src/ 复制一份,再用 sed 改包名、改入口、改资源路径。仓颉的 cjpm 有条硬规矩——cjpm.toml 的模块名必须等于源文件里的 package 名,所以「复制一份源码改个名」几乎是唯一的手工路径,两端代码从此开始各自漂移。

cj-tauri 按 RFC-002 设计方案重构之后换了个思路:业务代码只有一份,且必须是不能再编译成可执行文件的 static 包;每个端的骨架由清单派生出来,各编各的产物。,这样就实现了一份代码,同时支持linux,windows和鸿蒙PC的理想情况,后续只需维护一套代码即可。

重构后的新架构图:

在这里插入图片描述
这样有个好处:同一份项目代码,可以构建出不同平台的产物,再也不用维护多个平台的源码啦。跨端框架本该如此。只是前期由于暂未支持鸿蒙,导致鸿蒙平台得使用npx cj-tauri create myohos --template ohos 单独生成鸿蒙平台的项目,非常繁琐。

这篇不讲架构图,只讲怎么用——拿仓库里按新形态迁移的 examples/music-next(仓颉爱音乐)当例子,从目录长什么样一直走到两种产物落地。

在这里插入图片描述

一、先看清换掉了什么

同一个播放器,旧形态和新形态的差别不在业务逻辑,而在「谁负责跟平台打交道」。

examples/music(旧形态)examples/music-next(新形态)
工程本身自己就是可执行 cjpm 工程,cjpm.toml 里挂 webkitgtk 的 link-option业务 static 包 + cj-tauri.toml 清单,不链接任何平台库
入口业务 src/main.cj 里写 main()骨架 launcher 调业务的 build()
页面怎么来业务自己 PageSource.Inline(...) 或读盘清单 [assets] page,launcher 从资产根读盘
能力清单项目根 capabilities/assets/capabilities/,清单 [capabilities] dir 指定
加一个端复制源码 + sed 改包名(旧的 build-hap.sh)cj-tauri sync ohos
单文件打包支持(PACKED_* 双路)未迁移

一句话概括这个转向:平台知识从业务里搬进清单和骨架,业务只留「我这个应用要注册哪些命令」。

二、工程长什么样

examples/music-next 的目录:

music-next/
├── cj-tauri.toml            # 应用清单:整个工程的单一真相
├── cjpm.toml                # 业务包(output-type = "static")
├── src/                     # 业务代码,1324 行,唯一真相
│   ├── main.cj              #   build(): AppSpec —— 装配点,零平台知识
│   ├── api.cj               #   后台音乐接口(stdx.net.http)
│   ├── commands.cj          #   命令层 music:* / fav:* / report(662 行)
│   ├── cover.cj             #   封面
│   └── lrc.cj               #   歌词
├── assets/                  # 资产根
│   ├── ui/index.html        #   前端,3657 行,与旧工程逐字节一致
│   └── capabilities/        #   能力白名单 default.json
└── targets/                 # ← sync 生成,勿手改,不进版本库
    ├── desktop/             #   cjpm.toml + src/main.cj
    └── ohos/                #   AppScope / entry / build-hap.sh ...

值得单独说的是 cjpm.toml 里那行 output-type = "static"。这就是新形态的物理约束:业务包编不出可执行文件,只能被引用。旧工程里那段 -lwebkit2gtk-4.1 -lgtk-3 ... 的链接参数,现在写在 sync 生成的骨架里——业务层不该知道自己跑在什么窗口上。

三、清单:五个字段说清一个应用

cj-tauri.toml 全文只有二十来行,因为能派生的都不让人手写:

[app]
  name = "music-next"
  version = "0.1.0"
  # 桌面是安装 ID;鸿蒙直接当 bundleName(每段须字母开头,只含字母数字下划线)
  id = "com.example.music_next"
  # 业务包名,与 src/ 里的 package 必须一字不差
  package = "music_next"

[[windows]]
  label = "main"
  title = "仓颉爱音乐 · cj-tauri"
  width = 1280
  height = 820

[assets]
  page = "ui/index.html"

[capabilities]
  dir = "assets/capabilities"

各段的去向:

清单字段桌面端鸿蒙端
[app] id应用标识AppScope/app.json5 的 bundleName
[app] name / version产物元信息vendor / versionName
[app] package骨架 import 的业务包同上,两端一致
[[windows]]launcher 里的 WindowConfig窗口尺寸与标题同样下发
[assets] pagePageSource.Asset("ui/index.html")资产注入 hap 的 rawfile
[capabilities] dir.capabilities("assets/capabilities")权限集展开进鸿蒙侧装配

id 那条注释是我在鸿蒙端吃过亏才补上去的:它同时是 bundleName,任一段不合规 sync 就直接拒绝受理;而且改了它,鸿蒙首次构建必须去 DevEco 里对该包名重新「自动签名」,命令行做不了这件事。

四、业务侧只写一个 build()

新形态给业务留下的装配入口只有一个函数,签名固定 build(): AppSpec。music-next 的实现(节选,src/main.cj):

package music_next

import cjTauri.*
import cjTauri.plugins.store.*   // 插件子包不随 import cjTauri.* 传递,必须显式引

public func build(): AppSpec {
    let store = StoreBackend(MUSIC_LIBRARY_PATH)   // 收藏库:原子写 + 并发锁
    let spec = AppSpec()
        .window(WindowConfig("仓颉爱音乐 · cj-tauri", 1280, 820))
        .wire({ app =>
            app.register("music:hot", MusicHotCommand())
            app.register("music:search", MusicSearchCommand())
            app.register("fav:get", FavGetCommand(store))
            app.register("fav:set", FavSetCommand(store))
            app.register("music:quit", MusicQuitCommand(app))
            app.register("report", ReportCommand())
            // ... 共 11 条
        })
    spec.name = "仓颉爱音乐 · cj-tauri"
    return spec
}

跟旧版的 build() 比,它少了整整两块东西,而且这两块都不是搬到别处写,是直接消失:

  • 旧版要 PageSource.Inline(loadMusicUi()) 把前端塞进二进制;现在页面由 launcher 按清单从 assets/ui/index.html 读盘。
  • 旧版要内嵌 PACKED_CAPS_B64 能力清单;现在能力目录由 launcher 按 [capabilities] dir 挂载。

窗口那句在业务里其实也重复了一次(清单 [[windows]] 已经是真相),保留它纯粹是给人看默认值——launcher 会按清单再覆盖一遍,两处不一致时以清单为准。

五、一条命令派生,另一条命令构建

CLI 的子命令一共八个(create / dev / build / run / info / doctor / check / sync),真正跟多端相关的是 sync 和 check 这对:

cj-tauri sync [desktop|ohos]   按清单派生 targets 骨架(缺省pc端和鸿蒙两端都派生)
cj-tauri check                 校验 cj-tauri.toml + targets 骨架漂移
cj-tauri build                 构建 C 桥 + cjpm build(鸿蒙工程 = 编 hap)
cj-tauri run                   运行已构建产物(鸿蒙工程 = 装到设备并拉起)

桌面端从零到跑起来是三步:

export CANGJIE_STDX=<stdx>/dynamic/stdx
cd examples/music-next

cj-tauri sync        # 派生 targets/desktop 和 targets/ohos
cj-tauri build       # 编 targets/desktop 骨架,连带编业务 static 包
cj-tauri run         # 需要 X 显示环境

sync 生成的桌面骨架短到可以整段贴出来(targets/desktop/src/main.cj,带生成头注释):

// generated by cj-tauri sync (v1.1.2) — 不要手改;改动会被 check 判为漂移
// 桌面 launcher:页面/窗口/资产/能力目录由清单派生;业务装配点 music_next.build()(RFC §4.1/§4.2)
package music_next_app

import cjTauri.*
import cjTauri.platform.*
import music_next.*

main(): Int64 {
    let spec = build()
        .page(PageSource.Asset("ui/index.html"))
        .window(WindowConfig("仓颉爱音乐 · cj-tauri", 1280, 820))
        .assets(DirectoryAssetResolver("assets"))
        .capabilities("assets/capabilities")
    launch(spec)
    return 0
}

骨架的 cjpm.toml 也是派生的,两处依赖路径说明了引用方向:

[package]
  name = "music_next_app"
  output-type = "executable"

[dependencies]
  cjTauri = { path = "/root/test/cj/cj-tauri" }   # 框架,绝对路径
  music_next = { path = "../.." }                 # 业务 static 包,指回工程根

[target.x86_64-unknown-linux-gnu]
  link-option = "-L<框架>/native -lcjtbridge -lwebkit2gtk-4.1 -lgtk-3 ..."

两个细节值得记住,都是 cjpm 的硬约束逼出来的:

  1. package 名不能和业务包重名。 模块名在依赖树内必须唯一,骨架模块名默认取应用名,撞上了就加 _app 后缀——所以这里叫 music_next_app。
  2. 可执行产物名恒为 main。 不管你 exeName 写什么,cjpm 的可执行工程一律输出 target/release/bin/main。music-next 实测产出 main 7.8 MB 加一个 music_next_app.cjo。

check 是这套流程里最省钱的一条命令,它做三件事:校验清单本身(id 格式、窗口、资产文件是否存在)、比对 targets/ 生成物是否漂移、以及在桌面骨架构建过之后做一次三方比对——能力声明 × 插件静态自描述 × 无头导出的真实装配结果。跑通就一行:

[cj-tauri] check 通过:com.example.music_next@0.1.0(music-next)

六、鸿蒙端:同一条 sync,另一套产物

鸿蒙端不需要另起工程,也不需要复制源码:

cj-tauri sync ohos     # 派生 targets/ohos
cd targets/ohos
bash build-hap.sh      # 编 hap;--install 装到设备并拉起,--clean 先删产物

sync ohos 干的事分三层:

  • 把 cli/templates/ohos-skeleton/ 整套拷过来(AppScope、entry、hvigor、build-hap.sh 等静态物);
  • 用清单重写 4 个派生文件:AppScope/app.json5(bundleName/vendor/versionName)、entry/cjpm.toml、entry/src/main/cangjie/app_host.cj、main_ability.cj;
  • 把 assets/ 整体注入 entry/src/main/resources/rawfile/(页面 + 能力清单)。

派生出来的 entry/cjpm.toml 依赖段最能说明「一份代码」到底怎么落到两端:

[dependencies]
  cjTauri = { path = "/root/test/cj/cj-tauri" }
  # 业务包:唯一真相在工程根(骨架永不复制业务代码、不改包名)
  music_next = { path = "../../.." }

业务包仍然指回工程根的同一份 src/,两端编的是同一批 .cj 文件。差别只在资产:桌面用 DirectoryAssetResolver("assets") 直接读盘,hap 里没有工程目录,所以鸿蒙侧的 app_host.cj 自带一个 RawfileAssetResolver,从 ResourceManager.getRawFileContent 取页面——两个解析器实现同一个 AssetResolver 接口,业务一行都不用改。

鸿蒙端只有两处需要人介入,都在 README 里标了出来:

  • 首次构建要去 DevEco 里对 com.example.music_next 重新「自动签名」。这个 id 与 examples/ohos 那两个工程共用的调试包名 com.yang.testcj 不同,模板不随包分发证书,命令行签不了。
  • 能力位在鸿蒙端的现实:dialog:message 是原生弹窗,clipboard / notification 报 0 如实失败(端不支持的插件是桩,不是编译期剔除)。

七、以后加 macOS / Android 要改什么

这是这次重构我最想解决的事,所以设计时就把口子留成了一个固定的清单:业务代码 0 改动,框架层改三处。

层加一端的改动现状
清单规则新增目标名(targets/macos、targets/android)与各自的派生字段映射未实现,RFC §7.1 已定义
骨架模板加 cli/templates/macos-skeleton/、android-skeleton/,配对应渲染器只有 ohos-skeleton 一份
宿主实现实现 WebViewHost(WKWebView / Android WebView)+ 原生 C 桥已有 webkit2gtk、WebView2、ArkWeb 三个
平台知识只在 platform_discover.cj 一处补分支(大小写、环境探测、目录约定)7 个 platform_*.cj 里,6 个是纯契约,只有它带平台知识
业务代码不需要动—

代价写在明处:每一端仍要手写一个 WebViewHost 与原生桥,这是唯一没法派生的部分。另外 cjc 1.2.0 目前只是「接受」x86_64-apple-darwin 和 aarch64-linux-android 这两个目标名,能进编译阶段,但生态没验证过;iOS 更进一步——禁 JIT、动态链接形态受限,*-apple-ios 都没测过。所以准确说法是「接得进来」,不是「可用」。

平台差异也不是随便写 if os == ...。框架里的约定是:业务与骨架都只申请能力位,真相在宿主实现里;真要分平台的代码,用 @When 门禁在编译期切。music-next 的业务层一条 @When 都没有——这正是「零平台知识」的可验证形式。

八、六条硬约束,撞上就是构建失败

这一节是实操里最容易浪费时间的部分,按踩到的顺序排。

1. targets/ 千万别入库。 生成的 cjpm.toml 里写的是本机绝对路径(cjTauri = { path = "/root/test/cj/cj-tauri" }),提交上去换台机器就失效。.gitignore 一律整体忽略 targets/,克隆后用 cj-tauri sync 重建,再用 cj-tauri check 确认两边一致。仓库里只留 11 个源文件。

2. 模块名 == 包名。 cjpm.toml 的 [package] name 必须与源文件 package 一字不差,这是 cjpm 的规矩,也是旧形态必须复制源码改名的根因。新形态里业务包名由清单 package 钉死,骨架名由应用名派生。

3. 依赖树内模块名唯一。 骨架与业务撞名时,骨架自动加 _app 后缀(music_next → music_next_app)。别手动改成别的名字,check 会判漂移。

4. 可执行产物恒名 main。 想靠 exeName 改二进制名是徒劳,target/release/bin/main 是 cjpm 的固定输出。要分发就自己改名或走打包。

5. 插件子包要显式 import。 import cjTauri.* 只给核心包,cjTauri.plugins.store.* 这类子包不会跟着进来。少写一行,StoreBackend 就被判成未声明类型。

6. 目录即子包,父子不能互引。 仓颉里一个目录就是一个独立子包,父包与子包互相引用等于循环依赖,直接构建失败。框架为此把 src/platform/ 限制成「只收不引用父包符号的文件」,platform_host_provider.cj 因为要用 WebViewHost 和三个内置宿主构造器,必须留在根包。

九、这次迁移的实测账

数字都是在本机(Linux x86_64,仓颉 1.2.0)跑出来的,不是估算:

项目结果
sync 双端成功,骨架模块 music_next_app,rawfile 注入页面与能力清单
桌面构建首次 270 秒以上(连带编框架层),产物 main 7.8 MB + music_next_app.cjo
运行时冒烟xvfb-run + dbus-run-session 跑满 25 秒无异常退出(exit 124 = 超时正常终止)
check通过;同时抓出一处真实问题,见下
业务代码量1324 行 .cj + 3657 行前端,与旧工程逐字节一致

check 抓出的那处问题值得单独讲:旧 examples/music 的 build() 里挂了 StorePlugin,新形态三方比对时发现它注册的 5 条 store:* 通用命令既没写进能力清单、业务也从未调用过——属于冗余装配。摘掉它之后增量重编 + check 通过。这类问题靠读代码很难发现,靠运行也发现不了(多注册几条命令不影响功能),只有把「声明、自描述、真实装配」三方摆在一起比对才暴露出来。

十、还没做到的,照实说

  • 鸿蒙端只验到生成,没在 music-next 上真机跑过。 sync ohos 生成物正确、build-hap.sh 前置检查齐备,但签名与装机是这轮没走完的路;能跑通的鸿蒙证据来自 examples/ohos 下的 ohos-music 等工程。
  • 单文件打包没迁移。 旧工程的 PACKED_UI_B64 / PACKED_CAPS_B64 双路内嵌机制在新形态里没有对应物,属于批次 4 资产模型任务。
  • macOS / Android 只有设计,没有骨架。 见第七节表格:模板、渲染器、宿主实现、原生桥都还是空的。
  • 唯一的新形态示例就是 music-next。 旧 examples/music 保留为行为对照基线,两者目前并存。

速查

# 新工程
cj-tauri create myapp                  # 生成 cj-tauri.toml + src/ + assets/
cj-tauri sync                          # 派生 targets/desktop + targets/ohos

# 桌面
cj-tauri build && cj-tauri run

# 鸿蒙
cj-tauri sync ohos
cd targets/ohos && bash build-hap.sh --install

# 每次改完清单/骨架相关的东西
cj-tauri check

重构之后,「多端」从一件要手工复制源码的事,变成了清单里多一个目标、流水线上多一条 sync。业务层唯一需要关心的是 build() 里那 11 条命令注册——它在桌面和鸿蒙上编的是同一份文件,页面也是同一份 HTML。

Logo

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

更多推荐