首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >用仓颉语言 cj-tauri 框架,五分钟给娃做了个鸿蒙形近字游戏

用仓颉语言 cj-tauri 框架,五分钟给娃做了个鸿蒙形近字游戏

原创
作者头像
csdn猫哥
发布于 2026-11-15 01:40:12
发布于 2026-11-15 01:40:12
190
举报

> 女儿十岁,五年级了,经检查有多动症ADHD 加阅读障碍,注意力很难长时间挂在一件事上。医生说要多做辨字训练,我就想做个小游戏:把小学一到五年级那些长得像的汉字凑成一组,让她在一堆"长得很像"的字里找出不一样的那个。用一个周末,给十岁的女儿做了个形近字游戏(使用仓颉语言的 cj-tauri 跨段端开发框架)。

方案是我自己先写了一份文档:五个玩法、字库分级、奖励机制都列好了。真正难的从来不是设计,是**把它变成能跑的东西**——尤其当它得先在电脑上跑,之后还要挪到鸿蒙平板上。这就是cj-tauri赞的地方:同一套源码,换个构建方式就可以支持鸿蒙PC啦。开发起来先在电脑上跑,再跑到设备上一样的效果。

这篇就记这一路:选了什么框架、栽在哪三个坑里、最后怎么连cj-tauri上游框架的 bug 一起修了并顺带给cj-tauri提了个pr。

---

一、选型:为什么是 cj-tauri

方案文档里原本写的是 Vue 3 + Phaser + localStorage,纯 H5。但我想让它在平板上是个**真应用**,不是浏览器里的一个页面——孩子会自己切走,家长还得开"引导式访问"锁屏,太折腾。

cj-tauri 的形态正好:**仓颉后端 + 系统 WebView 前端**,仓颉静态编译出一个 exe,前端还是我熟悉的 HTML/CSS/JS。更关键的是它一份业务代码可以同时出桌面和鸿蒙两个产物——现在先跑windows电脑桌面,等鸿蒙平板接上,重新编一次就行在平板上了。

代码语言:txt
复制
hanzi-game/
代码语言:txt
复制
├── cj-tauri.toml          # 唯一真相:应用 id / 窗口 / 页面入口 / 能力目录
代码语言:txt
复制
├── src/                   # 仓颉后端(存档命令)
代码语言:txt
复制
├── assets/ui/             # 前端:index.src.html + css/ + js/
代码语言:txt
复制
└── tools/build-ui.js      # 前端内联器(后面会讲为什么必须有它)

---

二、第一版就栽了:页面起来了,但零样式零脚本

按 skill 的说法,"页面超过一屏就独立成 `assets/ui/index.html`",我就老老实实拆成了 `index.html` + `css/style.css` + `js/app.js` 七八个文件,页面里正常写相对引用:

代码语言:txt
复制
<link rel="stylesheet" href="css/style.css">
代码语言:txt
复制
<script src="js/app.js"></script>

编译通过,窗口也起来了。然后我看到的是这个:

字都在,但完全没有样式——原本用 `display:none` 藏起来的五个页面(首页、游戏页、结算页、图鉴、家长中心)**全部摊在一屏上**。而且桥日志里**一行错误都没有**:

代码语言:txt
复制
[cj-bridge] controller ready, loading page
代码语言:txt
复制
[cj-bridge] NavigateToString -> hr=0x00000000      ← 看着完全正常
代码语言:txt
复制
[cj-bridge] html navigation requested

我一开始以为是自己 CSS 写错了,改了两轮才反应过来:**JS 也没执行**——首页的年级列表、玩法卡片应该是由 JS 渲染的,全是空的。也就是说整个 `js/` 目录一个文件都没加载。

翻框架源码才明白:`PageSource.Asset` 是"把 HTML 文件读成字符串",Windows 侧再走 `NavigateToString`——文档没有真实来源,相对路径的 `<link href>` / `<script src>` 一律解析不到。

判定方法我后来写进了踩坑记录:**`NavigateToString -> hr=0x0` 是"成功"行,别被骗;真正的判据是页面自检行出不来。**

代码语言:txt
复制
// 页面启动成功后回投一行到仓颉 stderr
代码语言:txt
复制
bridge.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`。

代码语言:txt
复制
// tools/build-ui.js 的核心就两条正则
代码语言:txt
复制
html = html.replace(/[ \t]*<link\b[^>]*href="([^"]+)"[^>]*>/gi,
代码语言:txt
复制
  (m, href) => '<style>\n' + readAsset(href) + '\n</style>');
代码语言:txt
复制
html = html.replace(/[ \t]*<script\b[^>]*src="([^"]+)"[^>]*><\/script>/gi,
代码语言:txt
复制
  (m, src) => '<script>\n' + readAsset(src) + '\n</script>');
代码语言:txt
复制
// 内联完还有相对引用就当场失败,别让下一次运行再当侦探
代码语言:txt
复制
const leftover = html.match(/(?:src|href)="(?!https?:|data:|#)[^"]+"/g);
代码语言:txt
复制
if (leftover) process.exit(1);

生成的是 110 KB 的单文件页面。同一份代码,跑起来是这样:

这一坑后来提成上游 issue [#9](https://atomgit.com/qq8864/cj-tauri/issues/9):框架内部其实知道这个限制(`AGENTS.md` 提过一句),但**面向使用者的 skill 和模板都没说**,照做就必然踩。我在 issue 里建议了三步——`check` 扫到相对引用就告警、skill 补一句、将来用 `SetVirtualHostNameToFolderMapping` 真支持。

---

三、游戏本体:一局 5 题,五种玩法

字库我按方案文档整理成 102 组,覆盖一到五年级(13/14/22/31/22 组),每组带拼音、词组、偏旁和一句记忆口诀:

代码语言:txt
复制
['点画系列', '大下加点是太,大右加点是犬',
代码语言:txt
复制
  [['大', '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` 里:

代码语言:txt
复制
progress:load   读存档(返回 JSON 文本,空存档回空串)
代码语言:txt
复制
progress:save   覆盖写(入参 { data: "<JSON 文本>" })
代码语言:txt
复制
progress:path   回存档路径(家长备份用)
代码语言:txt
复制
app:about       版本与数据目录

cj-tauri 有个"三处联动"的铁律,**漏一处前端就用不了**:

代码语言:txt
复制
// ① 实现 + 注册
代码语言:txt
复制
public func build(): AppSpec {
代码语言:txt
复制
    return AppSpec()
代码语言:txt
复制
        .window(WindowConfig("形近字辨别训练", 1024, 720))
代码语言:txt
复制
        .wire({ app =>
代码语言:txt
复制
            app.register("progress:load", ProgressLoadCommand())
代码语言:txt
复制
            app.register("progress:save", ProgressSaveCommand())
代码语言:txt
复制
            // ...
代码语言:txt
复制
        })
代码语言:txt
复制
}
代码语言:txt
复制
// ② assets/capabilities/default.json 里放行(否则前端拿到 command not allowed)
代码语言:txt
复制
{ "commands": ["progress:load", "progress:save", "progress:path", "app:about", "report"] }
代码语言:txt
复制
// ③ 前端 invoke
代码语言:txt
复制
bridge.invoke('progress:save', { data: JSON.stringify(state) })

只做 ①③ 会得到 `command not allowed`,只做 ①② 会得到 `command not registered`。我第一次就漏了清单,报错信息倒是很清楚。

存档落在 `%LOCALAPPDATA%\cj-hanzi-game\progress.json`——**应用自己的目录,不是项目目录**,这样换工作目录启动也不丢进度。写之前先校验一遍 JSON 合不合法,不合法就拒写:"宁可丢一次保存,也不要把存档写坏"。

前端还留了兜底:后端不可用时退回 localStorage,并且每次保存都同时写一份本地缓存。真出问题时至少不是全丢。

---

五、两个把我卡了半小时的环境坑

这两个都不算框架的锅,但都**不给任何提示**,值得记下来。

坑 A:`cjpm` 在完整 PATH 下静默退出 127

代码语言:txt
复制
$ cjpm -v
代码语言:txt
复制
$ echo $?
代码语言:txt
复制
127

一个字符都不打。查了半天才发现是 **PATH 顺序**:把仓颉的 `bin`、`tools/bin`、`runtime/lib/windows_x86_64_cjnative` 放到 PATH **最前面**就好了。同样的二进制,同样的参数,顺序不一样结果就不一样——这种问题没有任何报错,只能靠对照实验。

坑 B:少了 `WebView2Loader.dll`,窗口一闪而过

编译一切正常,双击产物,桥只打一行就销毁窗口:

代码语言:txt
复制
[cj-bridge] host thread started
代码语言:txt
复制
[cj-bridge] WebView2Loader.dll not found: put it next to the exe or on PATH
代码语言:txt
复制
[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` 当项目名**建出一个目录**。

---

六、顺手把上游的一个 bug 修了

做这个游戏的过程中我跑了 `cj-tauri check`,它说:

代码语言:txt
复制
[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://` 永远比不相等。

修法也就三行思路:还原转义、两处共用一个取根入口、比对时两侧都归一化。

代码语言:txt
复制
// 唯一入口:sync 与 check 都必须走它,否则就等着"sync 完立刻报漂移"
代码语言:txt
复制
func resolveSkeletonFrameworkRoot(verbose: Bool): Option<String> {
代码语言:txt
复制
    match (readBizFrameworkPath()) {
代码语言:txt
复制
        case Some(p) =>
代码语言:txt
复制
            if (verbose) { eprintln("[cj-tauri] 框架路径取自业务包声明:${p}") }
代码语言:txt
复制
            return Some(p)
代码语言:txt
复制
        case None => return resolveFrameworkRoot()
代码语言:txt
复制
    }
代码语言:txt
复制
}
代码语言:txt
复制
// 归一化时保留开头的 UNC `//`:那是 Windows 共享路径,不能合并掉
代码语言:txt
复制
func toTomlPath(p: String): String {
代码语言:txt
复制
    let s = p.replace("\\", "/")
代码语言:txt
复制
    // ... 合并重复分隔符 ...
代码语言:txt
复制
}

我还补了 5 条回归测试,守两条不变量:TOML 转义必须还原(未知转义不许被吃掉)、分隔符必须合并且幂等。

验证时我特意做了个 A/B

`cd cli && cjpm test` 跑出来 10 条里 2 条红。我第一反应是"我改坏了",但没敢直接改测试——先把我的改动 stash 掉,在**基提交**上重跑:

代码语言:txt
复制
基提交(无我的改动):TOTAL 5,PASSED 3,FAILED 2   ← 同样这两条
代码语言:txt
复制
带上我的改动:       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。

我一开始先跑离线体检:

代码语言:txt
复制
[sign] 包名: com.example.hanzi_game
代码语言:txt
复制
[sign] 设备 UDID: (取不到)
代码语言:txt
复制
[sign] 原因: 拿不到设备 UDID(设备没连?跑 `hdc shell bm get --udid` 看看)

设备明明连着。真相是 **`hdc` 不在 PATH 里**(它躺在 DevEco 的 toolchains 目录下)。把这个目录加进 PATH,同一条命令立刻改口:

代码语言:txt
复制
[sign] 设备 UDID: 667BA4F7…DCE1443
代码语言:txt
复制
[sign] 原因: 本机没有「含本机 UDID 且包名为 com.example.hanzi_game」的材料
代码语言:txt
复制
[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` 变回空数组**。接着打包:

代码语言:txt
复制
> hvigor Finished :entry:default@SignHap... after 2 ms          ← 快得可疑
代码语言:txt
复制
[ohos] HAP = …/entry-default-unsigned.hap
代码语言:txt
复制
[ohos] 注意:这是未签名包,不能装进设备。

再跑一次 `sign --auto`(不带 `--newKeys`,想复用已有材料)时,它到第 ⑥ 步就结束了,**没打印第 ⑦ 步「已写回 signingConfigs」、也没报错**——因为那份 p12 的口令是上一次随机生成、只在当时存在过,写回需要的密文它拼不出来,于是静默跳过。于是你会拿着一个「签名成功过」的工程,编出未签名的包。

落法很简单,但得知道:**顺序是 `sync` → `sign` → `build`,签完不要再 sync**;真要重来就 `sign --auto --newKeys`,它会把口令重新生成一遍并写回。

装上去之后,一次就成了:

代码语言:txt
复制
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
代码语言:txt
复制
hdc shell aa start -a EntryAbility -b com.example.hanzi_game

鸿蒙真机上的样子

同一份 `assets/ui/`,在 1840×2800 的平板竖屏上(SLG-W10):

第一次装上去(上图是调过之后的)我看着别扭:`.big-btn { margin-top: auto }` 在 2800px 高的屏上把「开始练习」顶到了屏幕最底,中间空出一大片,像布局没写完。于是加了一段针对高窗口的媒体查询——内容成组居中、字号整体放大、字卡和选项卡也跟着放大(孩子不用凑近屏幕):

代码语言:txt
复制
@media (min-height: 900px) {
代码语言:txt
复制
  #view-home { justify-content: center; }
代码语言:txt
复制
  #view-home .big-btn { margin-top: 22px; }
代码语言:txt
复制
  .home-title { font-size: 40px; }
代码语言:txt
复制
  .target-char { font-size: 176px; }
代码语言:txt
复制
  .opt-card .opt-char { font-size: 104px; }
代码语言:txt
复制
}

游戏页在鸿蒙平板上:

到这里,**一份业务代码,桌面和鸿蒙两端都跑起来了**——`src/` 里那 200 行仓颉、`assets/ui/` 里那 10 个前端文件,两端共用的就是同一份,差别只在 `cj-tauri.toml` 派生出两个骨架。

八、现在它能干什么,以及下一步

已验证的(都有实机截图和桥日志对应):

  • 五种玩法都能玩,作答判定、进度点、连击、鼓励气泡都对;
  • 一局 5 题走完会进结算页,星星计入存档、徽章解锁;
  • 家长中心的密码门、统计、折线图、重点复习区都正常;
  • 存档经仓颉命令落盘,文件内容我核对过;
  • **鸿蒙平板实机跑通**:签名、装机、启动、1840×2800 竖屏布局都验过。

**没验证的也得说清楚**:

  • 音效和语音朗读——Web Audio 合成 + SpeechSynthesis,桌面和平板都只验证了不抛错、不阻塞玩法,好不好听得孩子耳朵说了算;
  • 平板上的长时间使用(半小时以上会不会内存涨、ArkWeb 会不会掉帧)没测。

下一步就是等她放学试玩,然后接平板:

代码语言:txt
复制
export CANGJIE_STDX_OHOS=<鸿蒙版 stdx>/linux_ohos_aarch64_cjnative/dynamic/stdx
代码语言:txt
复制
cd targets/ohos && npx cj-tauri build
代码语言:txt
复制
cd targets/ohos && npx cj-tauri run --install     # 先 cj-tauri sign 检查签名材料

对她来说,这个游戏是不是用仓颉写的、有没有踩坑、上游修了几个 bug,全都不重要。她只关心一件事:**自己选的宠物今天长大了没有**。

---

链接汇总

  • 游戏工程:`E:/test/harmonyos/test/cj-game/hanzi-game`
  • cj-tauri 框架:<https://atomgit.com/qq8864/cj-tauri>
  • 提的 issue:[#9 相对资源静默失效](https://atomgit.com/qq8864/cj-tauri/issues/9) · [#10 sync/check 恒报漂移](https://atomgit.com/qq8864/cj-tauri/issues/10) · [#11 诊断静默失败族](https://atomgit.com/qq8864/cj-tauri/issues/11)
  • 提的 PR:[#24 文档沉淀](https://atomgit.com/qq8864/cj-tauri/merge_requests/24) · [#25 drift 修复](https://atomgit.com/qq8864/cj-tauri/merge_requests/25)

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、选型:为什么是 cj-tauri
  • 二、第一版就栽了:页面起来了,但零样式零脚本
    • 解法:源码分文件,构建时内联成一个自包含页面
  • 三、游戏本体:一局 5 题,五种玩法
    • 五种玩法
    • 奖励与家长端
  • 四、仓颉后端只干一件事:把进度存下来
  • 五、两个把我卡了半小时的环境坑
    • 坑 A:`cjpm` 在完整 PATH 下静默退出 127
    • 坑 B:少了 `WebView2Loader.dll`,窗口一闪而过
  • 六、顺手把上游的一个 bug 修了
    • 验证时我特意做了个 A/B
  • 七、上鸿蒙平板:装包三十秒
    • 然后我踩了个顺序坑
    • 鸿蒙真机上的样子
  • 八、现在它能干什么,以及下一步
    • 链接汇总
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档