
> 女儿十岁,五年级了,经检查有多动症ADHD 加阅读障碍,注意力很难长时间挂在一件事上。医生说要多做辨字训练,我就想做个小游戏:把小学一到五年级那些长得像的汉字凑成一组,让她在一堆"长得很像"的字里找出不一样的那个。用一个周末,给十岁的女儿做了个形近字游戏(使用仓颉语言的 cj-tauri 跨段端开发框架)。
方案是我自己先写了一份文档:五个玩法、字库分级、奖励机制都列好了。真正难的从来不是设计,是**把它变成能跑的东西**——尤其当它得先在电脑上跑,之后还要挪到鸿蒙平板上。这就是cj-tauri赞的地方:同一套源码,换个构建方式就可以支持鸿蒙PC啦。开发起来先在电脑上跑,再跑到设备上一样的效果。
这篇就记这一路:选了什么框架、栽在哪三个坑里、最后怎么连cj-tauri上游框架的 bug 一起修了并顺带给cj-tauri提了个pr。
---
方案文档里原本写的是 Vue 3 + Phaser + localStorage,纯 H5。但我想让它在平板上是个**真应用**,不是浏览器里的一个页面——孩子会自己切走,家长还得开"引导式访问"锁屏,太折腾。
cj-tauri 的形态正好:**仓颉后端 + 系统 WebView 前端**,仓颉静态编译出一个 exe,前端还是我熟悉的 HTML/CSS/JS。更关键的是它一份业务代码可以同时出桌面和鸿蒙两个产物——现在先跑windows电脑桌面,等鸿蒙平板接上,重新编一次就行在平板上了。
hanzi-game/├── cj-tauri.toml # 唯一真相:应用 id / 窗口 / 页面入口 / 能力目录├── src/ # 仓颉后端(存档命令)├── assets/ui/ # 前端:index.src.html + css/ + js/└── tools/build-ui.js # 前端内联器(后面会讲为什么必须有它)---
按 skill 的说法,"页面超过一屏就独立成 `assets/ui/index.html`",我就老老实实拆成了 `index.html` + `css/style.css` + `js/app.js` 七八个文件,页面里正常写相对引用:
<link rel="stylesheet" href="css/style.css"><script src="js/app.js"></script>编译通过,窗口也起来了。然后我看到的是这个:
字都在,但完全没有样式——原本用 `display:none` 藏起来的五个页面(首页、游戏页、结算页、图鉴、家长中心)**全部摊在一屏上**。而且桥日志里**一行错误都没有**:
[cj-bridge] controller ready, loading page[cj-bridge] NavigateToString -> hr=0x00000000 ← 看着完全正常[cj-bridge] html navigation requested我一开始以为是自己 CSS 写错了,改了两轮才反应过来:**JS 也没执行**——首页的年级列表、玩法卡片应该是由 JS 渲染的,全是空的。也就是说整个 `js/` 目录一个文件都没加载。
翻框架源码才明白:`PageSource.Asset` 是"把 HTML 文件读成字符串",Windows 侧再走 `NavigateToString`——文档没有真实来源,相对路径的 `<link href>` / `<script src>` 一律解析不到。
判定方法我后来写进了踩坑记录:**`NavigateToString -> hr=0x0` 是"成功"行,别被骗;真正的判据是页面自检行出不来。**
// 页面启动成功后回投一行到仓颉 stderrbridge.invoke('report', { line: 'boot ok · bridged=' + bridged + ' · groups=' + groups });日志里出现 `[frontend] boot ok · bridged=true · groups=102`,才算页面真的活着。
我不愿意为了这个把 CSS/JS 全塞进一个一万行的 HTML 里——那没法维护。所以加了一个 30 行的内联器:源码仍然是分文件的,跑一下 `node tools/build-ui.js` 生成自包含的 `index.html`。
// tools/build-ui.js 的核心就两条正则html = html.replace(/[ \t]*<link\b[^>]*href="([^"]+)"[^>]*>/gi, (m, href) => '<style>\n' + readAsset(href) + '\n</style>');html = html.replace(/[ \t]*<script\b[^>]*src="([^"]+)"[^>]*><\/script>/gi, (m, src) => '<script>\n' + readAsset(src) + '\n</script>');// 内联完还有相对引用就当场失败,别让下一次运行再当侦探const leftover = html.match(/(?:src|href)="(?!https?:|data:|#)[^"]+"/g);if (leftover) process.exit(1);生成的是 110 KB 的单文件页面。同一份代码,跑起来是这样:
这一坑后来提成上游 issue [#9](https://atomgit.com/qq8864/cj-tauri/issues/9):框架内部其实知道这个限制(`AGENTS.md` 提过一句),但**面向使用者的 skill 和模板都没说**,照做就必然踩。我在 issue 里建议了三步——`check` 扫到相对引用就告警、skill 补一句、将来用 `SetVirtualHostNameToFolderMapping` 真支持。
---
字库我按方案文档整理成 102 组,覆盖一到五年级(13/14/22/31/22 组),每组带拼音、词组、偏旁和一句记忆口诀:
['点画系列', '大下加点是太,大右加点是犬', [['大', 'dà', '大小', '大'], ['太', 'tài', '太阳', '大'], ['犬', 'quǎn', '警犬', '犬']]],数据结构是紧凑的一行一组,改字库不用碰仓颉代码,改完 `node tools/build-ui.js` 就生效(我写了个校验脚本,确认 102 组里每个词组都真的含那个字)。
| 玩法 | 练什么 | 怎么玩 |
|---|---|---|
| ?? 火眼金睛 | 整体字形辨识 | 一堆相同的字里找出不一样的那个(或者找出和样例一样的) |
| ?? 汉字配对 | 字 ? 词义 | 左栏字、右栏词语,点着连起来 |
| ??? 偏旁侦探 | 偏旁归因 | 给一个字,从偏旁里挑出它的偏旁 |
| ?? 听音辨字 | 听觉 → 视觉 | 听读音选字,读不出来就退化成看拼音选 |
| ?? 组词挑战 | 语义理解 | 词语挖个空,选能填进去的字 |
**难度是自适应的**:连对就多一个干扰项(最多 6 个选项),答错就退一格。答错也不惩罚——不给星,但也不扣星;同一题错到第 3 次,自动把记忆口诀显示出来:
> 口诀:日里一横是太阳,目里两横是眼睛
星星一颗一颗攒,攒够就喂宠物升级(6 个形态,从蛋宝宝到汉字神龙)。徽章 7 枚,最早解锁的那个是"初出茅庐——完成第 1 局",答对 0 题也给你:
注意那句鼓励语——"今天这几个字有点难,你都坚持做完了,明天再来一次就会更熟"。**夸过程不夸结果**是方案的硬要求,代码里所有文案都按这条写,答错时说"再看看这两个字差在哪",绝不出现"错了""笨"。
家长中心单独设了密码(默认 `1234`),进去能看到今日时长、正确率趋势,以及**反复答错的字组自动进"重点复习区"**:
折线图是我自己用 Canvas 画的,没引第三方库——一个 chart.js 加进来就要考虑离线包体积,不值得。
---
前端用 localStorage 也能存,但那东西在 WebView 里不保险(那些页面没有真实来源,origin 是 opaque,某些情况下直接抛 `SecurityError`)。孩子的星星和徽章不能丢,所以我让仓颉侧写文件。
四条命令,都在 `src/storage.cj` 里:
progress:load 读存档(返回 JSON 文本,空存档回空串)progress:save 覆盖写(入参 { data: "<JSON 文本>" })progress:path 回存档路径(家长备份用)app:about 版本与数据目录cj-tauri 有个"三处联动"的铁律,**漏一处前端就用不了**:
// ① 实现 + 注册public func build(): AppSpec { return AppSpec() .window(WindowConfig("形近字辨别训练", 1024, 720)) .wire({ app => app.register("progress:load", ProgressLoadCommand()) app.register("progress:save", ProgressSaveCommand()) // ... })}// ② assets/capabilities/default.json 里放行(否则前端拿到 command not allowed){ "commands": ["progress:load", "progress:save", "progress:path", "app:about", "report"] }// ③ 前端 invokebridge.invoke('progress:save', { data: JSON.stringify(state) })只做 ①③ 会得到 `command not allowed`,只做 ①② 会得到 `command not registered`。我第一次就漏了清单,报错信息倒是很清楚。
存档落在 `%LOCALAPPDATA%\cj-hanzi-game\progress.json`——**应用自己的目录,不是项目目录**,这样换工作目录启动也不丢进度。写之前先校验一遍 JSON 合不合法,不合法就拒写:"宁可丢一次保存,也不要把存档写坏"。
前端还留了兜底:后端不可用时退回 localStorage,并且每次保存都同时写一份本地缓存。真出问题时至少不是全丢。
---
这两个都不算框架的锅,但都**不给任何提示**,值得记下来。
$ cjpm -v$ echo $?127一个字符都不打。查了半天才发现是 **PATH 顺序**:把仓颉的 `bin`、`tools/bin`、`runtime/lib/windows_x86_64_cjnative` 放到 PATH **最前面**就好了。同样的二进制,同样的参数,顺序不一样结果就不一样——这种问题没有任何报错,只能靠对照实验。
编译一切正常,双击产物,桥只打一行就销毁窗口:
[cj-bridge] host thread started[cj-bridge] WebView2Loader.dll not found: put it next to the exe or on PATH[cj-bridge] host destroyed: 000001575fcd8780而 `cj-tauri info` 会告诉你 C 桥已构建、cjc/stdx/cjpm 全部解析正常——**info 全绿不代表能跑**。把框架 `native/webview2/WebView2Loader.dll` 拷到 exe 旁边就完事。
这两条我都提进了上游 issue [#11](https://atomgit.com/qq8864/cj-tauri/issues/11),顺带还报了两个:`cj-tauri check` 的三方比对失败时只回十进制 `code=3221225781`(我是自己换算成 `0xC0000135` 才认出是 DLL 未找到),以及 `cj-tauri create --help` 会把 `--help` 当项目名**建出一个目录**。
---
做这个游戏的过程中我跑了 `cj-tauri check`,它说:
[cj-tauri] 骨架漂移:targets/desktop/cjpm.toml 与生成器输出不一致(运行 cj-tauri sync desktop 重新生成)行,我按提示重跑 `sync`,再 check——**还是这条**。再 sync,再 check,还是。这就不是"我改坏了",是它自相矛盾。
翻了 `cli/src/sync.cj` 才看明白,是三个小问题叠在一起:
1. 读业务包 `cjpm.toml` 里 `path = "E:\\test\\a"` 时**没做 TOML 反转义**,`\\` 原样带下去,最后渲染成 `E://test//a` 写进我的 `targets/`;
2. `sync` 用业务包声明渲染,`check` 却用 `resolveFrameworkRoot()` 重新渲染再比字符串——**两条来源形式不同,比对永远不成立**;
3. `toTomlPath` 只把 `\` 换成 `/`,**不合并重复分隔符**,所以老骨架里那个 `E://` 永远比不相等。
修法也就三行思路:还原转义、两处共用一个取根入口、比对时两侧都归一化。
// 唯一入口:sync 与 check 都必须走它,否则就等着"sync 完立刻报漂移"func resolveSkeletonFrameworkRoot(verbose: Bool): Option<String> { match (readBizFrameworkPath()) { case Some(p) => if (verbose) { eprintln("[cj-tauri] 框架路径取自业务包声明:${p}") } return Some(p) case None => return resolveFrameworkRoot() }}// 归一化时保留开头的 UNC `//`:那是 Windows 共享路径,不能合并掉func toTomlPath(p: String): String { let s = p.replace("\\", "/") // ... 合并重复分隔符 ...}我还补了 5 条回归测试,守两条不变量:TOML 转义必须还原(未知转义不许被吃掉)、分隔符必须合并且幂等。
`cd cli && cjpm test` 跑出来 10 条里 2 条红。我第一反应是"我改坏了",但没敢直接改测试——先把我的改动 stash 掉,在**基提交**上重跑:
基提交(无我的改动):TOTAL 5,PASSED 3,FAILED 2 ← 同样这两条带上我的改动: TOTAL 10,PASSED 8,FAILED 2 ← 新增 5 条全过,红的还是那两条结论清楚了:那两条 `[links]` 用例在基提交上就是红的,不是我引入的。**测试红了先做对照实验,别急着改测试让它变绿**——那是掩盖问题,不是修问题。
修复提成了 PR [#25](https://atomgit.com/qq8864/cj-tauri/merge_requests/25),文档沉淀(速查表 4 条 + `AGENTS.md` §4 三条)提成了 PR [#24](https://atomgit.com/qq8864/cj-tauri/merge_requests/24)。
顺带还有个**没修**的:`scripts/test.sh` 在我这机器上链接期失败,`libstdx.crypto.x509.a` 缺 `CertOpenSystemStoreA` 一类的符号(mingw 的 link-option 少了 `-lcrypt32`,基提交就这样)。这条我如实写进了 PR 说明,没顺手夹带——**一个 PR 只做一件事**,不然 review 的人会很难受。
---
桌面跑通只是半场。平板接上那天,真正的门槛是**签名**——鸿蒙真机不认自签,它只信「包名 + 设备 UDID」双匹配的 profile。
我一开始先跑离线体检:
[sign] 包名: com.example.hanzi_game[sign] 设备 UDID: (取不到)[sign] 原因: 拿不到设备 UDID(设备没连?跑 `hdc shell bm get --udid` 看看)设备明明连着。真相是 **`hdc` 不在 PATH 里**(它躺在 DevEco 的 toolchains 目录下)。把这个目录加进 PATH,同一条命令立刻改口:
[sign] 设备 UDID: 667BA4F7…DCE1443[sign] 原因: 本机没有「含本机 UDID 且包名为 com.example.hanzi_game」的材料[sign] 下一步: 跑 `cj-tauri sign --login` + `sign --auto` 为这个包名现签一份这条我也拿去提了上游(见文末),因为它给出的第一句结论会把人往「是不是线坏了」的方向带。
按提示走自签名:`sign --login` 弹一次浏览器、登录 AGC(令牌加密落盘、约 4 小时有效),`sign --auto --newKeys` 本地现生成密钥对——**私钥永不上云**,只把 CSR 交上去换证书,再把 profile 和密文口令写回 `build-profile.json5`。整套一分钟,cj-tauri的自签名,比在 DevEco 里点自动化流程还快。
改完样式重新生成页面后,我按习惯跑了 `cj-tauri sync ohos` —— 它把 `build-profile.json5` 按模板重写了一遍,**`signingConfigs` 变回空数组**。接着打包:
> hvigor Finished :entry:default@SignHap... after 2 ms ← 快得可疑[ohos] HAP = …/entry-default-unsigned.hap[ohos] 注意:这是未签名包,不能装进设备。再跑一次 `sign --auto`(不带 `--newKeys`,想复用已有材料)时,它到第 ⑥ 步就结束了,**没打印第 ⑦ 步「已写回 signingConfigs」、也没报错**——因为那份 p12 的口令是上一次随机生成、只在当时存在过,写回需要的密文它拼不出来,于是静默跳过。于是你会拿着一个「签名成功过」的工程,编出未签名的包。
落法很简单,但得知道:**顺序是 `sync` → `sign` → `build`,签完不要再 sync**;真要重来就 `sign --auto --newKeys`,它会把口令重新生成一遍并写回。
装上去之后,一次就成了:
hdc install -r entry/build/default/outputs/default/entry-default-signed.haphdc shell aa start -a EntryAbility -b com.example.hanzi_game同一份 `assets/ui/`,在 1840×2800 的平板竖屏上(SLG-W10):
第一次装上去(上图是调过之后的)我看着别扭:`.big-btn { margin-top: auto }` 在 2800px 高的屏上把「开始练习」顶到了屏幕最底,中间空出一大片,像布局没写完。于是加了一段针对高窗口的媒体查询——内容成组居中、字号整体放大、字卡和选项卡也跟着放大(孩子不用凑近屏幕):
@media (min-height: 900px) { #view-home { justify-content: center; } #view-home .big-btn { margin-top: 22px; } .home-title { font-size: 40px; } .target-char { font-size: 176px; } .opt-card .opt-char { font-size: 104px; }}游戏页在鸿蒙平板上:
到这里,**一份业务代码,桌面和鸿蒙两端都跑起来了**——`src/` 里那 200 行仓颉、`assets/ui/` 里那 10 个前端文件,两端共用的就是同一份,差别只在 `cj-tauri.toml` 派生出两个骨架。
已验证的(都有实机截图和桥日志对应):
**没验证的也得说清楚**:
下一步就是等她放学试玩,然后接平板:
export CANGJIE_STDX_OHOS=<鸿蒙版 stdx>/linux_ohos_aarch64_cjnative/dynamic/stdxcd targets/ohos && npx cj-tauri buildcd targets/ohos && npx cj-tauri run --install # 先 cj-tauri sign 检查签名材料对她来说,这个游戏是不是用仓颉写的、有没有踩坑、上游修了几个 bug,全都不重要。她只关心一件事:**自己选的宠物今天长大了没有**。
---
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。