仓颉语言的cj-tauri 框架已经支持鸿蒙应用开发了。本文依据 npm 包
cj-tauri的说明与仓库里的真实落地整理,目标读者是「会写 HTML/CSS/JS、想用仓颉写后端」的鸿蒙开发者。文中每条行为都对应到具体文件或命令,可以照着做一遍;跑不通的地方也如实写了「它会怎么报错、真因在哪」。
设备环境(本文的实测基准):鸿蒙PC 华为 MateBook Pro(HAD-W32)、HarmonyOS7.0.0.107、API 26、aarch64、屏幕 3120×2080 px、密度 190%;工具链是 DevEco Studio 6.1 自带的仓颉插件。
cj-tauri是什么
cj-tauri · 仓颉版 Tauri。用华为仓颉语言实现的桌面应用混合开发框架 ------ 仓颉静态编译后端 + 系统 WebView 前端,产物不带 Chromium、几 MB 级。cj-tauri 是「仓颉语言的 Tauri 2」:一个仓颉原生的脚手架 CLI,把 Tauri 的三件套带到仓颉语言上------
| 三件套 | 在 cj-tauri 里是什么 |
|---|---|
| WebView 宿主 | Windows 用 WebView2、Linux 用 WebKitGTK、鸿蒙用 ArkWeb |
| IPC 双向桥 | invoke / resolve / event,页面前端统一走 window.__CJ_TAURI__ |
| 能力安全模型 | capability 白名单,命令与事件都必须在清单里声明才放行 |
也就是说:界面继续用 Web 技术写,后端换成静态编译的仓颉,中间那层桥与权限校验由框架提供。
它不是「把网页塞进黑框」那种壳。一条命令从页面到仓颉后端要过四关:桥脚本注入 → IPC 报文 →能力清单校验 → 命令分发(在 worker 线程上执行,不钉住宿主 UI 线程);回投同样走判据可查的通道。
换句话说,页面上按一个按钮,真的会跑到你写的那段仓颉代码里。
为什么要专门为鸿蒙写一篇?因为同一套框架现在有了第三个宿主 。桌面那两个(WebView2 / WebKitGTK)已经跑了很久,鸿蒙 PC(ArkWeb)这一路是后来拿真机一轮轮打通并留了证据的:页面里的 system:version能往返、回投报文里的 platform 是 ohos、多窗口 label 路由逐条恒等。对使用者的意义很直接。
cj-tauri npm仓地址 :www.npmjs.com/package/cj-...
bash
npx cj-tauri create myohos --template ohos
一条命令生成一个标准 DevEco 工程 ,里面 ArkWeb 宿主、双向桥、能力清单、构建脚本都已就位,你只要动两个文件:rawfile/index.html(页面)和 cangjie/app_host.cj(命令层)。

一、开发优势
1. 一套业务代码,三个宿主
这是最能省事的一条,也是它区别于「某平台定制方案」的地方:换宿主,不换业务。
仓库里的音乐示例 examples/music(Windows/Linux 三栏播放器)搬上鸿蒙时,鸿蒙工程examples/ohos/ohos-music 里没有另抄一份业务代码 ------examples/music/src/*.cj 在构建时被复制进entry/src/main/cangjie/musiclib/(只把文件头的 package 改成应用模块的包名),页面同理内嵌编译。
所以「谁是源」很清楚:
bash
改业务逻辑 → 改桌面工程 examples/music
改鸿蒙宿主 → 改鸿蒙工程的 main_ability / index.cj / 构建脚本
代价与收益都很清楚:你维护的是一份业务,平台差异只在宿主那一层。如果每次上鸿蒙都要维护两份代码,那这个「跨平台」就是假的------这条是作者给自己定的验收标准。
2. 后端是真原生,前端是真 Web
- 后端是静态编译的仓颉:命令实现里可以开线程、读文件、发 http、操作 SQLite(插件)、调 FFI; 它运行在独立 worker 线程上(命令分发是异步的),慢命令不会冻住界面。
- 前端是系统 WebView 里的真页面:HTML/CSS/JS 全都能用,做桌面级排版、动画、图表没有额外成本。
- 桥是双向 的:页面
invoke调后端;后端emit推事件给页面(页面listen订阅)。
对鸿蒙开发者还有一个现实好处:ArkWeb 就是系统浏览器内核,你在 Chrome 里调好的页面,搬到鸿蒙上基本不用重排(窗口尺寸口径见下文第 4 节,那一处要注意)。
3. 能力安全模型是「默认拒绝」
所有命令与事件都必须在能力清单里声明,没声明的调用会被后端直接拒掉,页面看到的是command not allowed 而不是「神秘地没反应」。鸿蒙工程里这份清单是 entry/src/main/resources/rawfile/capabilities.json:
json
{
"identifier": "default",
"windows": ["main"],
"commands": ["greet", "timer", "system:version", "system:ping", "system:echo"],
"events": ["tick"]
}
设计约束是「声明 ≠ 放行,清单不引用就不生效」:插件可以声明它提供什么,但放不放行由清单说话。对一个要装进用户设备的应用,这比「全开」省心------至少你知道哪些面被暴露了。
4. 鸿蒙侧真的很薄:框架不碰 ArkUI
这是踩过坑之后定下的架构,也是它能在鸿蒙上稳定跑的前提:框架包绝不引用 ArkUI。
@Component / Web / javaScriptProxy 这类声明式 UI 的东西全部住在应用模块(entry/src/main/cangjie/index.cj),框架侧只导出不碰 ArkUI 的桥接函数。原因是硬性的:框架包一旦 import kit.ArkUI.*,合并进应用动态库后包初始化链会跳到坏地址,应用启动即 SIGSEGV (表现是白屏或秒退,而 hilog 里连一行 AbilityStage.onCreate 都没有)。
好处落在你这边:
- 升级框架不需要动你的宿主代码;
- 你的鸿蒙工程就是标准 DevEco 工程 (
entry/+AppScope/+build-profile.json5+hvigorfile.ts), IDE 里直接点 Build 也能编过; - 页面与能力清单是 hap 里的 rawfile 真文件,不是「构建期生成的字符串」,改完重编即可。
5. 一条命令覆盖三个平台
Windows / Linux / 鸿蒙都是同一条 cj-tauri 命令。子命令对鸿蒙会自动转交工程自带的构建脚本:
| 子命令 | 在鸿蒙工程上的行为 |
|---|---|
cj-tauri dev |
转交 build-hap.sh(编 hap) |
cj-tauri build |
转交 build-hap.sh(编 hap) |
cj-tauri run |
转交 build-hap.sh --install(编 + 装机 + 拉起) |
cj-tauri info |
环境自检(排障先跑它) |
你不需要记「鸿蒙用哪条命令、桌面用哪条」。
6. 边界如实,不假装成功
这一点容易被当成缺点,其实是长期可用的关键:平台没有的能力,框架就如实报没有。
- 鸿蒙侧的宿主能力位(菜单 / 托盘 / 全局快捷键 / 拖放 / 原生对话框)目前如实返回 0,调用会被拒,当前还在陆续实现中,敬请期待;
- 没有 C 桥,所以
launchHidden(后台静默子进程)/ 串口 / 托盘这类宿主能力在鸿蒙上暂时不提供; - 运行时装不了 TLS(设备上没有 OpenSSL 3),要访问 https 就走系统网络栈
ohos.net.http;
对做产品的人来说,这些「明确的没有」比「时好时坏」好排期得多。
二、环境准备
需要的东西不多,但缺哪一样都只会得到一句误导性的报错,所以先把清单摆出来:
| # | 需要什么 | 版本 / 说明 | 怎么确认它有 |
|---|---|---|---|
| 1 | 仓颉 SDK | 1.2.0 ,含 stdx | cjc --version |
| 2 | Node.js | 能跑 npx 即可(跑 CLI 用) |
node -v |
| 3 | DevEco Studio | 6.1,含仓颉插件(自带 hvigor / node / hdc) | 目录存在即可 |
| 4 | 鸿蒙版 stdx | 构建期要链它 | 目录存在即可 |
| 5 | 鸿蒙 PC 真机 | 开发者模式 + USB 调试 + 已签名 | hdc list targets |
2.1 仓颉 SDK(应用后端就是它)
这个 npm 包不替代仓颉 SDK :应用后端是仓颉代码,cj-tauri dev / build 要调用 SDK 里的cjc / cjpm 把你的应用编出来。装好后让 CLI 能找到它:
bash
# Linux
export CANGJIE_HOME=/opt/cangjie/cangjie
# Windows cmd
set CANGJIE_HOME=D:\Program Files (x86)\Cangjie
不设也能跑------CLI 会探测常见安装位置(Windows D:\Program Files (x86)\Cangjie、Linux /opt/cangjie/cangjie)。装完先自检:
bash
npx cj-tauri info # 打印 框架 / 项目 / stdx / SDK / cjpm / C 桥 的解析结果
npx cj-tauri --version # 这条不需要 SDK,用于确认 npx 拿到的是哪个版本
排障时第一条命令就该是 info :它把每个组件的解析路径都打出来,比逐条试错快得多。 没装 SDK 时 CLI 会给安装提示,而不是甩一句 cjpm: command not found。
2.2 拿到 CLI
bash
npx cj-tauri --version # 一次性使用,推荐
npm i -g cj-tauri # 或者全局安装,之后直接 cj-tauri <子命令>
包内优先使用预编译二进制(prebuilt/<平台-架构>/),没有则把 CLI 源码拷到缓存目录用本机cjpm build 编一次(Linux/macOS ~/.cache/cj-tauri、Windows %LOCALAPPDATA%\cj-tauri),不会往全局安装目录里写产物。
一条已知限制要提前知道:首次 dev / build 会把 C 桥(桌面平台的)构建到框架目录的 native/ 下,所以那份安装目录需要可写。鸿蒙工程不走这条路(鸿蒙侧没有 C 桥),但如果你同时做桌面版,记着这条。
2.3 DevEco Studio 6.1 与仓颉插件
鸿蒙侧构建链全在 DevEco 里:hvigor(构建)、node、hdc(设备)、仓颉构建插件(编 .cj 进 .so)。工程自带的 build-hap.sh 会按顺序探测安装根(DEVECO_WIN 优先,其次D:\oh\DevEcoStudio、C:\Program Files\Huawei\DevEco Studio 等),探测不到就让你显式给:
bash
export DEVECO_WIN='D:\Program Files\Huawei\DevEco Studio'
从命令行(headless)构建时有两个必须给的开关,否则报的错会指向「工程配置写错」:
bash
export DEVECO_CANGJIE_PLUGIN_ENABLED=true
export NODE_PATH="$(cygpath -w "$(pwd)/node_modules")"
真因是:仓颉构建插件被判定为未启用 时,它会主动把 cangjie 的 schema 删掉 且不报错,于是终端只剩一行 00303038,而工程里明明写着 cangjieOptions 却被告知「property name must be valid」。(build-hap.sh 已经把这一组变量都设好了,你手工调 hvigorw 时才需要自己给。)
2.4 鸿蒙版 stdx
构建期要链鸿蒙版 stdx,指向它的动态库目录:
bash
export CANGJIE_STDX_OHOS=<stdx>/linux_ohos_aarch64_cjnative/dynamic/stdx
注意两个易错点:
- 指的是
dynamic/stdx这个目录,不是 stdx 的包根; entry/cjpm.toml里不要 写${CANGJIE_STDX_OHOS}之类的变量:hvigor 只替换它自己的模板变量(${AARCH64_LIBS}那一组),写别的会被替换成空串,而空串会让整条path-option失效------报错却是imports package 'stdx.encoding.json' ... not added as a dependency,看着像漏配依赖。模板里写的是相对路径../stdx,指向工程根上那个stdx联接,由build-hap.sh按CANGJIE_STDX_OHOS自动创建(Windows 用mklink /J)。
2.5 真机与签名
- 真机开开发者模式 + USB 调试 ,
hdc list targets能看到串号; - 签名 :模板不带证书 (调试证书与华为账号绑定,不该入库)。首次打开工程在 DevEco 里 File → Project Structure → Signing Configs,勾上「Automatically generate signature」。不签名也能编,产物是
*-unsigned.hap,但装不进设备 (hdc install会报签名类错误); aa start需要屏幕已解锁:开发者模式下没法自动解锁,锁屏时表现为「启动失败」或者抓到一张锁屏截图;- 同一份调试签名下,两个工程共用一个
bundleName会互相顶掉安装位 ------一台设备上只能留一个。换bundleName要在 DevEco 里重新自动签名一次,命令行做不了。这个期待官方开放侧载,就不用这么麻烦了,且可以拜托对 DevEco这个IDE的依赖,更方便AI。
2.6 三条「报错指向的地方不是真因」
这三条是这套工具链上最容易白走弯路的,先记下来能省一晚:
| 你看到的报错 | 真因 | 怎么办 |
|---|---|---|
Invalid value of 'DEVECO_SDK_HOME' |
环境里预置了指向不存在安装的旧路径 | build-hap.sh 会校验后回退;手工构建就自己 export 有效值 |
property name must be valid: cangjieOptions |
仓颉构建插件没被加载(schema 被它自己删了) | 给上 DEVECO_CANGJIE_PLUGIN_ENABLED=true + NODE_PATH |
opt.exe: EnumReflection format: operand numbers error! |
cjc 与 opt 来自不同代 SDK(cjc 按 CANGJIE_HOME 找 opt) |
用同一套 SDK 的 build-tools,别让桌面 SDK 在 PATH 上抢先 |
最后一条尤其值得强调:崩溃点落在 opt 不代表原因在 opt,它只是第一个发现 IR 不合法的关卡; 同时 hvigor 的常驻 daemon 用的是它自己启动时 的环境,所以「手跑 cjpm build 成功、build-hap.sh 一跑就崩」这种分裂现象,是 daemon 复用了旧环境------build-hap.sh 因此固定带 --no-daemon。
三、开发过程
3.1 生成工程
bash
npx cj-tauri create myohos --template ohos
cd myohos
两个可选参数,一般只在前几次需要:
bash
npx cj-tauri create myohos --template ohos \
--bundle com.example.myapp \
--label "我的应用"
--bundle:hap 的bundleName。缺省由项目名推导成com.example.<名字>(-、.折成_,数字开头补app前缀,因为鸿蒙要求每段以字母开头、只含字母数字下划线、至少三段);--label:桌面显示的应用名,缺省等于项目名。
生成的是一个标准 DevEco 工程,鸿蒙相关的那层很薄:
bash
myohos/
├─ AppScope/app.json5 应用级配置(bundleName)
├─ build-profile.json5 工程级构建配置(签名留空)
├─ build-hap.sh 唯一构建入口(编 hap / 装机)
├─ entry/
│ ├─ cjpm.toml cjTauri 依赖 + aarch64-linux-ohos 目标段
│ ├─ build-profile.json5 cangjieOptions.checkDeviceTypes=false(放行 2in1)
│ └─ src/main/
│ ├─ module.json5 deviceTypes: 2in1 / tablet / phone
│ ├─ cangjie/
│ │ ├─ ability_stage.cj AbilityStage
│ │ ├─ main_ability.cj UIAbility:沙箱、rawfile 读取、窗口尺寸、loadContent
│ │ ├─ index.cj ArkWeb 宿主组件(javaScriptProxy 双向桥)
│ │ └─ app_host.cj 框架装配 + 示例命令(greet / timer)
│ └─ resources/rawfile/
│ ├─ index.html 页面(你主要改这里)
│ └─ capabilities.json 能力清单(你主要改这里)
├─ hvigorfile.ts / oh-package.json5 hvigor 工程描述
└─ README.md 本工程的说明(含改哪里、坑在哪)
你日常只动两个文件 :rawfile/index.html 和 cangjie/app_host.cj。其余四段装配代码是「跑起来」的必需品,通常不用碰。
3.2 为什么鸿蒙上要四段装配
桌面上程序的入口是 main(),鸿蒙上没有「自己起个进程跑 main()」这回事:窗口属于 ArkUI,页面由 ArkWeb 组件承载 。所以模板把桌面的 main() 拆成三段,顺序不能错:
ts
// ① 沙箱目录必须在 run() 之前给:run(html) 要把页面落盘再以 file:// 加载
// (ArkWeb 的仓颉绑定没有 loadData,只能走文件)
let filesDir = "${this.context.filesDir}"
ArkWebRuntime.configure(filesDir)
// ② 页面与能力清单来自 hap 的 rawfile,读成字符串传进去
let pageHtml = this.readRawText("index.html")
let capsJson = this.readRawText("capabilities.json")
// ③ run() 会阻塞到 quit(),只能在非 UI 线程跑------UI 线程要留给 ArkUI 建树
let _ = spawn { startTauriApp("我的应用", pageHtml, capsJson) }
第四段落在 onWindowStageCreate:等框架把默认窗口的宿主登记好,再 loadContent:
ts
this.logDefaultWindowRect(windowStage) // 只读一行窗口矩形当凭证,不改尺寸(见 3.7)
this.waitAndLogHost(DEFAULT_WINDOW_LABEL) // 3 秒有界等待:宿主的页面地址要非空
windowStage.loadContent("EntryView")
这两步各自都有个「不这么写就白屏」的原因,值得知道:
- 为什么等宿主 :组件的
build()是一次性求值 。首帧如果宿主还没登记、页面地址是空串,就永远不建Web组件,onPageBegin也不会来,页面纯白且没有任何报错------死锁。 - 为什么在
aboutToAppear()里同步地址 :宿主把页面落盘后才把file://地址放进自己的字段,而build()已经求值完了。所以index.cj在aboutToAppear()(首次build()之前跑一次)里读一次宿主地址写进@State,触发重绘。
明白这两点,你就知道以后加窗口/改宿主时该在哪一步塞代码。
3.3 写第一条命令(三处联动,漏一处就用不了)
加命令永远是三件事,缺哪个就得到对应的错:
- 实现一个
CommandHandler; - 在装配处
.register("名字", XxxCommand()); - 在
rawfile/capabilities.json的commands里声明"名字"。
| 只做了 | 运行时报什么 |
|---|---|
| 1 + 3(忘记注册) | command not registered |
| 1 + 2(忘记清单) | command not allowed |
完整的最小例子(app_host.cj 里自带的 greet):
ts
import cjTauri.*
import stdx.encoding.json.*
public class GreetCommand <: CommandHandler {
public init() {}
public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
var name = "world"
if (let Some(n) <- args.get("name")) {
match (n.kind()) {
case JsonKind.JsString => name = n.asString().getValue()
case _ => ()
}
}
return JsonString("Hello, ${name}! 来自仓颉后端(鸿蒙 ArkWeb 宿主)")
}
}
装配:
ts
let app = TauriApp()
.window(WindowConfig(windowTitle, 1280, 820))
.register("greet", GreetCommand())
.register("timer", TimerCommand())
// 显式挂载清单即不自动扫描目录------hap 里本来也没有 capabilities/ 这个目录
if (capsJson.size > 0) {
app.addCapabilityJson(JsonValue.fromStr(capsJson))
}
app.run(pageHtml) // 阻塞到 quit()
注意 app.addCapabilityJson 这一句:鸿蒙工程里清单是构建期打进 hap 的 rawfile ,运行期没有capabilities/ 目录可扫,所以必须显式挂。忘了挂的后果是「一条命令都不放行(含 system:*)」,页面看到的全是 command not allowed------这比静默放行安全,也比「假装成功」好排查。
3.4 改页面:等桥出现,然后 invoke
页面就是普通 HTML/CSS/JS,唯一的框架约定是只经 window.__CJ_TAURI__:
js
// 鸿蒙的 ArkWeb 没有 document-start 注入,桥是在 onPageBegin 里注入的,
// 所以页面必须「等桥出现」,不能在模块作用域直接读。
function withBridge(cb) {
if (window.__CJ_TAURI__) { cb(window.__CJ_TAURI__); return; }
setTimeout(function () { withBridge(cb); }, 50);
}
withBridge(function (tauri) {
// 页面 → 仓颉
tauri.invoke('greet', { name: 'world' })
.then(function (data) { console.log('invoke OK:', data); })
.catch(function (err) { console.error('invoke FAIL:', err.message); });
// 仓颉 → 页面(事件订阅,事件名要在清单的 events 里声明)
tauri.listen('tick', function (payload) {
console.log('tick #' + payload.n);
});
});
这段 withBridge 不是可选项。桌面平台的桥是 document-start 注入,鸿蒙侧拿不到那个时机(onPageBegin 已经是最早的可用点),所以任何「模块加载时立刻 invoke」的写法在鸿蒙上都会拿到undefined。模板的 index.html 已经这么写了,照抄即可。
3.5 能力清单:把「放行」写清楚
清单只有四个字段,但每个都有实际后果:
json
{
"identifier": "default",
"windows": ["main"],
"commands": ["greet", "timer", "system:version", "system:ping", "system:echo"],
"events": ["tick"]
}
commands:命令白名单。除system:version/system:ping/system:echo这几个内置命令外,全部要自己写进去;events:事件也要声明 。后端emit一个没声明的事件名,页面那边收不到;windows:这份清单覆盖哪些窗口 label。单窗口就是["main"];identifier:清单标识,多份清单时用于区分。
再往上一层还有个「命令放行 ≠ 参数放行 」的机制:桌面侧可以给插件的路径参数配 scope($HOME/notes/** 这类 allow/deny 规则),由共享层统一判定,插件里不另写一份逻辑。鸿蒙工程目前不用到插件的那几个路径类命令,但如果你以后加了,判定逻辑仍只有一份。
3.6 构建 → 装机 → 拉起
跑之前先把两个环境变量备好(2.3 / 2.4 的结论):
bash
export CANGJIE_STDX_OHOS=<stdx>/linux_ohos_aarch64_cjnative/dynamic/stdx
export DEVECO_WIN='D:\Program Files\Huawei\DevEco Studio' # 探测不到时才需要
然后:
bash
bash build-hap.sh # 只编 hap,产物在 entry/build/default/outputs/default/*.hap
bash build-hap.sh --install # 编完装机并拉起(= npx cj-tauri run)
bash build-hap.sh --clean # 先清缓存再编(改了仓颉代码却没生效时用)
脚本做了几件容易被忽略、但少了就跳进坑里的事:
- 探测并校验
DEVECO_SDK_HOME/NODE_HOME(环境里预置了不存在的路径时回退并说明忽略了谁); - 把 SDK 的
runtime/lib/windows_x86_64_cjnative与build-tools/bin排进PATH,并让cjc与opt来自同一套 SDK; - 建工程根的
stdx联接(../stdx那条相对路径靠它成立); - 用
--no-daemon起 hvigor,避免 daemon 复用旧环境; - 装机的
hdc用 DevEco 自带那份(sdk/default/openharmony/toolchains/hdc.exe),并且给路径前一律MSYS_NO_PATHCONV=1------Git Bash 会把 Windows 路径改写成/d/...,hdc拿到就找不到文件。
装机的两条命令(脚本已封装,手工排障时用得上):
bash
MSYS_NO_PATHCONV=1 hdc install -r <hap 的 Windows 路径>
hdc shell aa start -a EntryAbility -b com.example.myapp
⚠️ -a 后面是 module.json5 里的 ability 名(EntryAbility) ,不是仓颉类名 MainAbility------写成类名会得到 10104001。
另外:改代码没生效先怀疑产物,别改代码 。hvigor 的 CompileCangjie 与框架侧 cjpm 各有一份 增量缓存,只清一边时旧 .so 会被喂回来,对照实验当场失真。判据看产物(.so 尺寸 / llvm-nm 里的符号数),不看 build success 这一行。
3.7 窗口尺寸:默认就别动它
这是最反直觉的一条,也是踩过两版弯路之后定下的口径:鸿蒙 PC 上系统给的那扇窗就是合适的,不要自己 resize。
实测系统按桌面形态给的初始矩形是 [515 281 2090 1394],即 2090×1394 px ≈ 1100×734 vp ,约屏宽 2/3、居中留白合理。而你在 onWindowStageCreate 里紧接着 resize() 会发生什么?系统先按默认尺寸建窗、你的代码再改它------屏上看到的是「先弹一个尺寸、随即跳成另一个」,用户的原话是「刚启动时弹出来的窗口大小合适,为何后来又变了」。
所以模板只做一件事:读一行矩形打进 hilog 当凭证,不 resize、不 move。
sql
main window keeps system default 2090x1394 px at density 190% on screen 3120x2080, rect=[515 281 2090 1394]
真要固定尺寸才自己调,两个数字上的坑先记着:
resize()收的是像素,不是 vp 。写resize(1440, 900)在 3120×2080 的屏上只占 46% 宽,日志照打「resized」、也不抛异常,只有hidumper读到的矩形能揭穿它;densityPixels是Float32(.cj.d里看着像Float64),跟Float64直接相乘会报invalid binary operator,甚至把同一段的Hilog.info报成「参数列表不匹配」------先化成整数百分比再算。顺带一提:本机密度是 190%,别按「2 倍屏」想当然。
还有一个前提:上面「系统给的就是桌面窗」成立的条件是 module.json5 的 deviceTypes 写 2in1,并且 entry/build-profile.json5 里放行了它。写成 phone / tablet 时,系统按手机应用塞进一扇约 600 宽的竖屏兼容窗------那种情况才需要自己定尺寸。
3.8 真机自证:hilog 里应该有这条链
「窗口起来了」不等于「通了」。跑通之后该有的是一条可对账的日志链 (CjTauri tag),缺哪行就知道断在哪:
less
MainAbility OnCreated.EntryAbility
host registered after 0 polls label=main
host pageUrl label=main url="file:///data/storage/el2/base/haps/entry/files/cj-tauri-main.html"
page begin label=main
runJavaScript ok
main window keeps system default 2090x1394 px at density 190% on screen 3120x2080
取证手法有两条是硬要求:
- 必须流式抓 。
hdc shell hilog > f先起流 ,再aa start启动应用,跑完再 grep 文件。 一次性hilog -x会把启动瞬间那几行挤出环形缓冲(中间系统日志能刷三千多行),判据会很怪:ps里进程活着,但Cangjietag 计数是 0; eprintln在设备上完全不可见 。桌面平台的 stderr 是主要诊断通道,在鸿蒙上等于没有------ 诊断一律写Hilog.info。
页面侧自证:hdc shell snapshot_display 抓一张图,看得到业务内容(不是白屏、不是错误提示),再点一下 system:version 看它返回版本号而不是 -。
3.9 把已有的桌面工程搬上鸿蒙
如果你的应用已经在桌面跑起来了(就像仓库里的 music / movie),迁移要改的只有「谁在跑页面」,业务代码可以一行不动。仓库里两个鸿蒙工程的做法值得照抄:
bash
examples/music/src/*.cj(去掉 main.cj)
└─ 复制到 entry/src/main/cangjie/<业务包>/
└─ 只改第一行:package music_app → package <应用模块包名>.<业务包>
examples/music/ui/index.html + capabilities/default.json
└─ base64 编码 → 编译进包的字符串常量(<业务包>/xxx_assets.cj)
于是「谁是源」很清楚:改业务逻辑去改桌面工程,改完重跑 build-hap.sh 。不把桌面工程当 path 依赖写进 entry/cjpm.toml,是因为桌面的业务模块通常是output-type = "executable"、自带 main(),把它当依赖编进动态模块属于没跑通过的形态。
顺带一个便宜的好处:页面走 base64 内嵌 ,绕开了仓颉三引号字符串会把 JS 里的反斜杠转义先吃一遍的坑(内联 JS 里写 \n 会变成真换行,把字符串或单行注释拆断,整段 <script> 语法错误、页面一行都不执行,而 stderr 毫无提示)。
搬完之后命令契约保持不变 是成败判据。观影示例的封面在桌面版走「宿主带 Referer 取图」的命令movie:image,鸿蒙侧要换成系统网络栈(见第 4 节),但命令名、入参、返回值、白名单一行没改------前端代码因此零改动。如果为了上某个平台改了命令契约,那「同一套业务」就不成立了。
四、常见坑速查
按「症状 → 真因 → 处理」排,都是真机上遇到过的:
| 症状 | 真因 | 处理 |
|---|---|---|
| 窗口是竖屏窄窗(约 600 宽),三栏挤成一列 | deviceTypes 只写了 phone / tablet |
写 2in1;若被判 01103012,在 entry/build-profile.json5 里设 cangjieOptions.checkDeviceTypes = false(插件默认只放行 phone/tablet) |
| 所有请求都失败,页面说「连不上后台」 | 没声明 ohos.permission.INTERNET,实际报 SocketException: Failed to create socket: Operation not permitted. |
在 module.json5 的 requestPermissions 里加上;normal 级,装了即生效 |
应用启动即退 / 白屏,hilog 里连 onCreate 都没有 |
模块里有解析不了的符号(例如无 C 桥平台的 FFI) | `llvm-nm -D --undefined-only <libentry.so> |
页面纯白,但 BindWebTagToWebInstance 有、page begin 没有 |
组件 build() 一次性求值,页面地址为空就不建 Web(死锁) |
在 aboutToAppear() 里同步宿主地址进 @State;@State 不能带 private |
组件传参报 missing arguments for parameter list |
成员写成普通 var,或调用点挂了链式修饰 |
成员用 @Prop,调用点保持纯调用,尺寸交给外层容器 |
invoke 的 promise 既不 resolve 也不 reject |
回投通道没走通:javaScriptProxy 的返回值传不回页面 (实测恒 null) |
回投由原生推(页面心跳 → 宿主在 proxy 回调里 runJavaScript),模板已做好,业务不用管 |
页面里 window.__CJ_TAURI__ 是 undefined |
没等桥出现(鸿蒙没有 document-start 注入) | 用 withBridge 轮询等待,别在模块作用域直接读 |
命令明明注册了却报 command not registered / command not allowed |
三处联动漏了一处 | 注册 / register() / 清单 commands 三者齐 |
| 只有 https 资源全灭,http 接口一切正常 | 鸿蒙侧仓颉运行时装不了 TLS(设备上没有 OpenSSL 3,MUSL-LDSO: load libcrypto_openssl.z.so failed) |
换系统网络栈 ohos.net.http(createHttp() + HttpRequest.request),命令契约不变 |
| 改了仓颉代码,装上去还是旧行为 | hvigor 与框架 cjpm 各有一份增量缓存 | bash build-hap.sh --clean;判据看 .so 尺寸,不看 build success |
aa start 起不来 |
屏幕锁着,或 -a 写了仓颉类名 MainAbility |
解锁后重试;-a 用 EntryAbility |
| 装第二个应用把第一个顶掉了 | 两个工程共用同一份调试签名与 bundleName |
一台设备只留一个;换 bundleName 要在 DevEco 里重新自动签名 |
五、总结
把这件事拆开看,cj-tauri 在鸿蒙上提供的是一个很薄的宿主适配层 + 一套平台无关的应用骨架:
- 你写的东西 :一份 HTML/CSS/JS 页面、若干仓颉
CommandHandler、一份能力清单; - 框架提供的东西:ArkWeb 宿主装配、双向桥、能力校验、命令分发(异步 worker)、事件回投;
- 平台替你兜的东西:窗口、TLS、证书链、权限------用系统自己的实现,框架不越界。
从零到装进设备,实际只要四步:
bash
npx cj-tauri create myohos --template ohos # 1. 生成标准 DevEco 工程
# 2. 改 rawfile/index.html(页面)+ cangjie/app_host.cj(命令)+ capabilities.json(放行)
bash build-hap.sh --install # 3. 编 hap + 装机 + 拉起(= npx cj-tauri run)
hdc shell hilog > log.txt # 4. 流式取证:看那五、六行链是否齐全
什么时候适合用它
- 界面重、逻辑也重:图表、编辑器、复杂排版用 Web 做得快,重活交给仓颉后端;
- 已经在桌面(Windows / Linux)跑着一个 cj-tauri 应用 :想上鸿蒙时业务代码基本不动,只需要加一个宿主工程(
examples/ohos/*就是这条路); - 要一份能讲清楚安全边界的应用:能力清单是显式的,哪些命令、哪些事件被暴露一目了然;
- 团队是「前端 + 后端」结构 :前端照常写页面,后端写仓颉命令,接口就是
invoke契约。
什么时候先别用它
- 需要完全原生的鸿蒙控件与系统集成(菜单栏、托盘、全局快捷键、原生对话框):鸿蒙侧这些能力位目前如实返回 0,目前还在完善中;
- 需要多窗口:桌面色已有 label 路由与取证,鸿蒙工程模板目前是单窗口(多窗口要按 label 各挂一个宿主);
- 需要在鸿蒙上跑 C 桥那类宿主能力(静默子进程、串口):鸿蒙侧没有 C 桥产物,暂不提供。
三条经验,比 API 更值钱
- 失败可能发生在「诊断能力本身失效」的地方 。鸿蒙上
eprintln不进 hilog、hilog 缓冲会滚掉启动瞬间------这种时候先取一条不问运行期的证据(符号表llvm-nm、产物尺寸、窗口矩形),比任何日志都快。 - 「调用返回成功」不等于「现象成立」 。
resize()会照打日志也不抛异常,但单位是 px 不是 vp;SetMenu那类宿主 API 更会静默返回 FALSE。凡是宿主调用,都按「调用前读数 + 调用后复核 + 把返回值打进同一行」收口。 - 别为了让检查变绿去改事实。取不到的图源就让它失败(页面回退成色块),不支持的能力就如实拒绝------把边界藏起来,下次它会在用户设备上以更贵的样子出现。
参考
- npm 包:www.npmjs.com/package/cj-...
- 仓库与完整文档:atomgit.com/qq8864/cj-t...
- 鸿蒙示例:
examples/ohos/ohos-music(音乐)、examples/ohos/ohos-movie(观影)、examples/ohos-probe(宿主探针) - 鸿蒙接入的完整踩坑过程:仓库
docs/鸿蒙ArkWeb接入踩坑.md; - 四篇实战记在
docs/鸿蒙PC实战-01~04