一份代码,多端产物:仓颉语言的cj-tauri 框架开发桌面和鸿蒙应用指南
没重构之前,用 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] page | PageSource.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 的硬约束逼出来的:
package名不能和业务包重名。 模块名在依赖树内必须唯一,骨架模块名默认取应用名,撞上了就加_app后缀——所以这里叫music_next_app。- 可执行产物名恒为
main。 不管你exeName写什么,cjpm 的可执行工程一律输出target/release/bin/main。music-next 实测产出main7.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。
更多推荐



所有评论(0)