开源软件适配系列之—把 2048 搬上鸿蒙 PC:游戏适配与键盘事件桥实战

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_2048

本文记录将经典开源益智游戏 2048 适配到 HarmonyOS PC 的完整过程:从复用 OpenHarmony 官方定制 Electron 底座、跨过签名证书绑定不匹配的构建失败,到定位并修复"方向键完全无响应"这一致命问题——根因是 OHOS Electron 注入的键盘事件只能到达 DOM 事件流的 capture 阶段、永远不会冒泡。文中给出了一个可复用的 capture→bubble 输入转发桥,以及实现它时必须避开的两个坑(其中一个会让渲染进程陷入无限递归直接卡死)。所有结论均为鸿蒙 PC 2in1 真机实测,附修复前后的日志与截图。

真机全景:2048 运行在鸿蒙 PC 桌面上

图 0:2048 运行在鸿蒙 PC(2in1 形态)桌面上,窗口、任务栏图标、最高分持久化全部正常

一、缘起:为什么是 2048

鸿蒙 PC 生态起步阶段,桌面软件适配的注意力大多集中在"生产力刚需"上——IDE、办公套件、终端、笔记应用,这些我此前都做过。但一个桌面系统是否"能日常用、愿意用",休闲娱乐类应用同样是绕不开的一块。一个打开后只有工作工具的桌面,很难说服普通用户把它当作主力机。
在这里插入图片描述

而在所有休闲品类中,我最终选定了 2048 作为下一个适配对象。理由有四条,每一条都对应着实际工程考量:

第一,零构建。 上游仓库 gabrielecirulli/2048 的产物就是源码本体:一个 index.html、一个 style/ 目录、一个 js/ 目录。没有 vite、没有 webpack、没有 grunt,不存在"构建产物与源码不一致"的问题,也不存在"构建环境在 CI 上跑不通"的风险。git clone 下来直接就是可以塞进 resfile 的完整应用。

第二,零原生依赖。 没有 Canvas、没有 WebGL、没有 WebAudio、没有图片 sprite 图集,全部交互界面由纯 DOM + CSS3 动画渲染。这一点在鸿蒙 PC 上意义重大——OHOS Electron 底座默认必须走软件渲染(后文会讲为什么),一个对 GPU 没有任何要求的纯 DOM 应用,是这条渲染路径上风险最低的负载。

第三,零远程内容。 没有 CDN 外链、没有字体外链、没有统计脚本,纯离线单页面。webSecurity: false 不会引入任何攻击面,也不存在"断网环境白屏"的尴尬。

第四,也是最重要的一条:它是输入敏感型应用。 2048 的全部交互就是四个方向键。如果键盘链路在鸿蒙上有任何问题,这个适配就是零分——它没有鼠标点击可以蒙混过关的退路。这恰好能暴露 Web 承载路线最隐蔽、也最致命的一类问题:输入事件链路。事后证明,这个判断完全正确,2048 也确实挖出了这条路上最深的一个坑。
在这里插入图片描述
上游由意大利开发者 Gabriele Cirulli 开发,MIT 协议,GitHub 13.4k star,是这个星球上最有名的开源小游戏之一。MIT 协议意味着可以任意修改、再分发,合规压力为零;上游最后提交在 2024 年 10 月,功能早已是完整形态,停更不影响使用。

适配目标从第一天起就很明确:在鸿蒙 PC 上跑"真正的 2048",上游代码一行不改。这句话成了整个项目所有技术决策的锚点——每当犹豫"要不要干脆改上游几行代码绕过去"时,都回到这条原则上来。后文会看到,方向键问题最终的解法也遵守了这条纪律:所有适配代码都放在宿主层(main.cjs),上游 js/ 目录保持零改动。

二、技术选型:三条路的取舍

纯前端应用上鸿蒙,摆在面前的路有三条。

路线 A:ArkTS 全面重写。 2048 的核心逻辑不过几百行 JS,重写的工作量看似不大。但重写完它就不再是 2048 了——上游的 CSS 动画手感、localStorage 存档结构、未来可能的更新,全部无法跟进,等于造了一个一次性分叉。更本质的问题是:这么做对"验证鸿蒙 PC 的 Web 承载能力"这个目标毫无贡献。否决。

路线 B:远程 Web 化。 把上游页面挂到服务器上,鸿蒙端用浏览器访问。实现最快,但做出来的东西依赖网络、没有应用形态(无窗口图标、无独立进程、无本地存档隔离),验收上过不了"鸿蒙应用"这一关。否决。

路线 C:复用 OpenHarmony 官方定制的 Electron 运行时(libelectron.so)。 官方 SIG 把整个 Electron 打成了一个 libelectron.so(约 177MB),主进程、渲染进程、IPC、BrowserWindow 全部保留真实语义。应用的全部静态产物放进 HAR 模块的 resfile/resources/app/,主进程 loadFile 加载本地 index.html。代价是包体——签名后的 HAP 约 206MB——换来的是上游代码零改动 + 真实 Chromium 渲染

选 C。这也是我在 Zettlr、Clumsy Bird、Standard Notes 上反复验证过的路线。

2.1 双模块 HAP 架构

在这里插入图片描述

两个模块的分工非常清晰:electron/ 是宿主,来自官方移植,不绑定任何具体应用,塞的是 libelectron.so 和宿主 Ability;web_engine/ 是 HAR 包,Electron 适配框架和应用的一切都在它的 resfile/resources/app/ 下。做新应用适配时,electron/ 原样保留,只动 web_engine/ 里的东西——这正是"底座派生"能做到半小时级的原因。
在这里插入图片描述

2.2 主进程的三项防坑契约

main.cjs 有三项设计,全部对齐官方 demo 的最佳实践。这三条是前面多个项目用崩溃换来的经验:

1. GPU 彻底禁用必须最前置。 app.disableHardwareAcceleration() 加上 --disable-gpu--disable-gpu-compositing 等系列开关,必须放在任何窗口创建之前。鸿蒙壳工程默认走 EGL 的 GPU 子进程,在部分系统版本上会反复崩溃导致白屏。2048 是纯 DOM/CSS 渲染,软件渲染的性能开销可以忽略——事实上真机上动画帧率和浏览器里没有可感知差异。

2. 渲染进程隔离。 contextIsolation: truenodeIntegration: false。2048 对原生能力零需求,preload 脚本只留了一个输入链路诊断探针(后文验收时它立了功)。

3. 全局错误落盘。 鸿蒙端没有 DevTools,渲染进程里的一切错误如果只 console.error 就等于消失。uncaughtExceptionunhandledRejection 必须写进 userData/2048-runtime.log,渲染进程的 console-message 事件也统一转发落盘。后面排查方向键问题时,正是这份日志提供了全部线索。

另外 webSecurity: false 是有意为之:loadFilefile:// 协议,纯离线单页面、零远程内容,关闭后不引入任何攻击面,却省掉了 file:// 下各种资源加载的 CORS 麻烦。

2.3 为什么不是 ArkWeb:一段值得展开的对比

做 Web 承载,鸿蒙上其实还有第四条路:ArkWeb(Web 组件)+ Stage 模型,用 onInterceptRequest 拦截资源请求、把静态产物映射进应用沙箱。这条路的包体小得多(不带 177MB 的 libelectron.so),启动也更快。我在别的项目上评估过,最终对 2048 这类"完整 Web 应用"仍然选了 Electron,理由有三个:

第一,事件注入行为可控性不同。 ArkWeb 的键盘事件走的是系统输入管道,行为随系统版本演进;而 OHOS Electron 的行为(包括"capture 可达、bubble 断链"这个特性)在我适配过的多个应用上表现一致、可预测、可写桥。对一个输入敏感型的游戏,可预测性比包体重要。

第二,localStorage 等存储语义。 Electron 的 localStorageIndexedDB 落在标准 Chromium profile 里,跨启动持久化行为与桌面 Chrome 完全一致。ArkWeb 的存储隔离与持久化策略要按文档逐项核对,遇到边界情况(如 force-stop 后的恢复)缺乏参照系。

第三,工程一致性。 我的多条适配线(Zettlr、Clumsy Bird、Standard Notes、2048)共用同一套底座、同一份 main.cjs 防坑契约、同一套排错经验。多维护一条 ArkWeb 技术栈,意味着排错经验无法复用——对单人适配者来说,这是最贵的成本。

当然这个结论不是普适的:如果是简单页面 + 系统输入为主的应用,ArkWeb 的小包体和快启动很有吸引力。选型跟着"输入复杂度"和"既有工程资产"走,不跟"哪个更先进"走。

三、底座派生:从 Clumsy Bird 到 2048

工程不是从零搭的,而是从已真机验证的 Clumsy Bird 底座整体复制:

cp -r ohos_ClumsyBird ohos_2048

这一步有个不起眼但必须遵守的约束:工程路径保持全英文。hvigor 构建系统对路径中的非 ASCII 字符是硬限制,中文路径会在某些阶段报出非常隐晦的错误。

复制完成后是身份替换,清单很短但一项都不能漏:

位置改动
AppScope/app.json5bundleNameorg.game2048.ohosvendorgame2048
AppScope/.../string.jsonapp_name2048
图标 ×5(AppScope 3 个 + electron 模块 2 个)替换为 2048 金色图标
resfile/resources/app/删除 Clumsy Bird 产物,放入上游 2048 静态产物
app/package.jsonname 2048-ohos / license MIT
main.cjs窗口尺寸、日志文件名、输入桥(后文详述)

图标是脚本生成的:512×512 画布,背景色 #ECC400 直接取自上游 meta/apple-touch-icon.png,居中绘制白色粗体 2048,文字宽度占画布 69.7%——这个比例是与上游原图标的文字占比对齐的,保证视觉密度一致。五处图标(应用、任务栏、快捷方式等)必须全部替换,漏掉任何一处,桌面上就会出现新旧图标混杂的诡异效果。

身份替换里最容易翻车的是 bundleName——它不只是个名字,它和签名证书是绑死的。这就引出了第一关。

四、第一关:签名证书绑定不匹配

构建命令用的是 DevEco 安装目录下的 hvigor(工程内没有 hvigorw 脚本):

cd ohos_2048/ohos_hap
/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw --stop-daemon
/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw \
  assembleHap --mode module -p product=default -p buildMode=debug

(先 --stop-daemon 是个防御性习惯:如果之前删过 .hvigor 目录,残留的守护进程会持有失效的工作目录句柄,报 ENOENT: uv_cwd,停掉守护进程即解。)

构建走到了 SignHap 阶段,然后失败:

SignHap ... ERROR: 00303074 Configuration Error

4.1 根因:p7b profile 与 bundleName 是绑死的

从 Clumsy Bird 底座继承下来的签名配置里,引用的是旧的签名 profile(p7b 文件),而这个 profile 在华为签发时绑定的是 org.clumsybird.ohos。现在工程的 bundleName 改成了 org.game2048.ohos,签名校验直接拒绝。

这里有一个必须理解的机制:HarmonyOS 的调试 profile(p7b)由华为服务器签发,内含 bundleName、设备 UDID、证书指纹的绑定关系,本地无法自行生成。SDK 自带的 OpenHarmonyProfileDebug.pem 只适用于 OpenHarmony 开源系统镜像,HarmonyOS 商用设备不认。命令行也没有签名入口。所以唯一解法是:

  1. DevEco Studio 打开 ohos_hap/ 工程;
  2. File → Project Structure → Signing Configs → 勾选 Automatically generate signature(需登录华为账号并联网);
  3. DevEco 会为 org.game2048.ohos 生成一套全新的 .p12 / .cer / .p7b,并自动写回 build-profile.json5signingConfigs
  4. 确认 products[].signingConfig 指向新生成的 signingConfigs[].name

DevEco 中重新自动签名后,构建、安装、启动一次通过

图 1:DevEco Studio 中完成自动签名后的 build-profile.json5;下方 Run 面板显示完整的构建 → 推包 → 安装 → 拉起流程,org.game2048.ohos successfully launched within 13s 825ms

顺带记一个反面结论:如果不签名直接装,报错是 code:9568320 error: no signature file——看到这个码不用怀疑别的,就是没签名。

经验:只要改了 bundleName,就必须重新自动签名。签名 profile 与 bundleName 的绑定关系,决定了"一套证书走天下"在鸿蒙上不存在。这条经验后来在新工程派生时帮我省掉了至少两次盲目排查。

五、真正的拦路虎:方向键完全无响应

HAP 装上了,aa start 成功,棋盘正确渲染,两个初始方块安静地躺在角落里。然后无论怎么按方向键,棋盘纹丝不动

这是本次适配唯一称得上"硬骨头"的问题,也是本文最值得看的部分。

5.1 排除法:把问题圈进键盘链路

先排除显而易见的可能性:

  • 渲染没问题——棋盘、色块、字体全部正确,说明 loadFile 与静态资源路径正确,CSS 加载完整;
  • 不是 JS 报错——2048-runtime.log 里没有任何 uncaughtException,上游逻辑正常启动;
  • 不是窗口焦点问题——鼠标点击 “New Game” 按钮有效,新局正常开出,说明窗口有焦点、渲染进程活着、按钮的 click/touchend 都能命中。

问题被精确圈定在键盘事件上:窗口收得到鼠标,收不到键盘。

翻上游源码 js/keyboard_input_manager.js,监听是这样注册的:

document.addEventListener("keydown", function (event) {
  var modifiers = event.altKey || event.ctrlKey || event.metaKey ||
                  event.shiftKey;
  var mapped    = map[event.which];
  if (!modifiers && mapped !== undefined) {
    event.preventDefault();
    self.emit("move", mapped);
  }
});

两个关键事实浮出水面:

  1. 监听注册在 document 上,addEventListener 第三参缺省为 false,即默认 bubble 阶段
  2. 方向的判定靠 event.which——一个早已废弃的 legacy 属性。

第一条就是病根。这里需要展开讲一下 DOM 事件模型。

5.2 背景:DOM 事件流的三个阶段

DOM 事件派发时沿三条路径传播:

        capture(捕获)          target(目标)        bubble(冒泡)
window ──────────────────▶ document ──▶ 元素 ──▶ document ──────────▶ window
        parent → child                          child → parent

同一次按键,window → document → 目标元素 是 capture 方向,目标元素 → document → window 是 bubble 方向。addEventListener(type, fn, true) 把监听器挂在 capture 阶段,false(缺省)挂在 bubble 阶段。

正常浏览器里,一次真实的按键会完整走完三个阶段,所以挂在哪一段都能收到。但 OHOS Electron 底座向页面注入键盘事件的方式比较特殊——注入的事件在 capture 阶段可见,之后不会继续沿 bubble 方向传播

这不是我猜的,是 Clumsy Bird 适配时用探针真机实证过的结论(当时的解法是把上游监听器改挂 capture,但那等于改了上游代码——在 2048 这里我不想再改上游)。

于是链条清晰了:底座注入的事件 → capture 阶段可达(无人监听)→ bubble 阶段永远不触发 → 2048 的 keyboard_input_manager 等的是一个 bubble 阶段的 keydown → 等到天荒地老。

方向键"完全无响应",一分不多、一分不少地复现了这个模型预言的行为。

5.3 方案:capture→bubble 转发桥

约束是"上游代码零改动",那么修只能修在宿主层。思路很直白:

did-finish-load 后用 executeJavaScript 注入一段脚本,在 capture 阶段截获底座注入的按键,合成一个等价的、bubbles: true 的新事件派发到 document,让上游的 bubble 监听器能收到。

完整实现如下(可直接复用):

mainWindow.webContents.on('did-finish-load', () => {
  writeLog('did-finish-load');
  mainWindow.webContents.executeJavaScript(`
    (function () {
      try {
        if (window.__ohos2048Bridge) return 'already';
        window.__ohos2048Bridge = true;

        var PASS_KEYS = {
          37: true, 38: true, 39: true, 40: true,  // 方向键 Left/Up/Right/Down
          72: true, 74: true, 75: true, 76: true,  // Vim hjkl
          65: true, 83: true, 68: true, 87: true,  // WASD
          82: true                                  // R 重新开始
        };

        document.addEventListener('keydown', function (e) {
          if (e.__ohos_bridge) return;              // 坑 1:忽略自身合成事件
          var kc = e.which || e.keyCode;
          if (!PASS_KEYS[kc]) return;

          var synth = new KeyboardEvent('keydown', {
            bubbles: true,
            cancelable: true,
            altKey:   e.altKey,
            ctrlKey:  e.ctrlKey,
            metaKey:  e.metaKey,
            shiftKey: e.shiftKey
          });
          // 坑 2:补回 legacy 键码,2048 只读 event.which
          Object.defineProperty(synth, 'which',   { get: function () { return kc; } });
          Object.defineProperty(synth, 'keyCode', { get: function () { return kc; } });
          Object.defineProperty(synth, '__ohos_bridge', { value: true });

          document.dispatchEvent(synth);
          console.info('[2048-BRIDGE] forwarded keyCode=' + kc);
        }, true /* capture */);

        console.info('[2048-BRIDGE] input bridge installed');
        return 'ok';
      } catch (e) {
        console.info('[2048-BRIDGE] install err ' + e);
        return 'err';
      }
    })();
  `).then((r) => writeLog(`bridge inject result=${r}`))
    .catch((e) => writeLog(`bridge inject failed: ${e}`));
});

5.4 坑 1(致命):合成事件会被自己的监听器再次捕获

初版代码没有做任何标记,注入后真机直接整机卡死——不是"方向键没反应",而是整个窗口僵住。

原因回过头看非常清晰,但身处其中时极具迷惑性:

  1. capture 监听器收到底座注入的原始事件 →
  2. 处理器合成一个 bubbles: true 的新事件并 dispatchEvent
  3. 合成事件同样从 capture 阶段开始传播 → 再次命中同一个 capture 监听器
  4. 处理器合成新事件 → 回到第 2 步 →
  5. 无限自我派发,渲染进程的事件循环被打满,整机假死。

这是个纯逻辑层面的无限递归,任何 try/catch 都拦不住——因为每一层都在"正常执行"。修复方式是给合成事件打标记,处理器首行检查:

if (e.__ohos_bridge) return;   // 自己合成的事件,直接放行

标记的注入也有讲究:KeyboardEventInit 不支持自定义属性,所以用 Object.defineProperty(synth, '__ohos_bridge', { value: true }) 在实例上定义。这是整个桥里一行都不能省的防御——它把一次"看起来随机"的整机卡死变成了不可能事件。

教训:在事件监听器里 dispatchEvent 同类型事件,必须假设"这个事件会回到我自己身上"。凡是递归结构,先想清楚终止条件再动手。

5.5 坑 2:legacy 的 which / keyCode 会静默丢失

第二个坑更隐蔽。2048 判定方向靠 map[event.which],但 KeyboardEvent 构造函数的入参字典(KeyboardEventInit不包含 whichkeyCode——这两个属性已从规范中废弃。即使你在 init 对象里塞了 which: 37,构造器也会静默丢弃,不报错、不告警。

合成出来的事件 bubbles 正确、key: 'ArrowLeft' 正确,唯独 whichundefined——对现代代码毫无影响,对 2048 恰好是致命的:map[undefined] 取不到方向,事件收到了,逻辑上等于没收到。

修复是构造完成后在实例上重新定义这两个只读属性:

Object.defineProperty(synth, 'which',   { get: function () { return kc; } });
Object.defineProperty(synth, 'keyCode', { get: function () { return kc; } });

补充一个诚实的注脚:headless Chrome 实测表明,Chromium 的 KeyboardEvent 构造其实会保留 keyCode/which(非标准扩展),所以在桌面 Chrome 里这段加固可能"看不出作用"。但在鸿蒙这种没有 DevTools、渲染内核版本不确定的环境里,我倾向于把一切依赖非标准行为的假设全部锁死——调试手段越少的环境,越要对显式依赖买单

5.6 桥的验证:先在 headless Chrome 里回放

注入真机之前,我先把桥的核心逻辑(document capture → 合成 → bubble 送达)在 headless Chrome 里完整回放了一遍,断言三件事:

  • 合成事件的 which 在 bubble 监听器里取值正确;
  • 桥不会触发自身(无递归,计数器恒为 1);
  • PASS_KEYS 四方向 + WASD + hjkl + R 全部映射完整。

三断言全过后才上真机。真机日志里 bridge inject result=ok[2048-BRIDGE] input bridge installed 依次出现,随后每次按键都有一条:

[2048-BRIDGE] forwarded keyCode=37

方向键复活了。

5.7 一个留给真机的风险点

桥是无条件合成事件的。推演一下就知道存在一个理论隐患:如果某台设备上底座注入的按键本来就能正常冒泡,那么一次按键会先触发上游逻辑一次(原始事件),再被桥捕获合成、又触发一次(合成事件)——走两格

这一点在写桥时就意识到了,但无法在本地验证——本机 Chrome 里根本没有"底座注入"这个环节。所以验收清单里专门加了一条:观察"按一次方向键是否只走一格"。若走两格,桥需改为条件合成:capture 阶段记录事件后延迟一个微任务,确认 bubble 阶段未收到原始事件再补发。

真机结果:一次按键走一格。说明在本设备的底座上,"capture 可达、bubble 断链"的模型成立,无条件合成是正确选择。

六、真机验收

设备:HarmonyOS PC 2in1,SN 3QC0124C20000733,系统 6.0.26。

安装与启动:

hdc install electron/build/default/outputs/default/electron-default-signed.hap
hdc shell aa start -a EntryAbility -b org.game2048.ohos

启动后桌面与任务栏均出现 2048 金色图标,窗口打开即渲染完整棋盘,无黑屏、无白屏。真机 HiLog 里能看到 org.game2048.ohos 下的 chrome、WebEngine、DMS 等组件依次完成初始化,多进程架构全部存活:

真机 HiLog:org.game2048.ohos 各组件启动日志

图 2:DevEco HiLog 面板——WebEngine、chrome、DMS、WMSEvent 等组件的启动日志,Electron 多进程在真机上完整存活

6.1 方向键注入与计分链路

验收环境是键鼠形态的 2in1,方向键通过 uinput 从系统层注入(等价于真实物理按键,从根上绕开"测试代码自己注入自己收"的循环论证)。

开局状态——SCORE 0,BEST 76(上一局的存档),两个初始方块:

开局:SCORE 0,两个初始方块

图 3:启动后的初始棋盘。注意右上角 BEST 76——这是 localStorage 持久化的历史最高分,后面还会回来验证它

注入 5 次方向键,运行日志中输入桥 5 次全部转发:

[renderer:info] [2048-BRIDGE] forwarded keyCode=40 (file:///...)
[renderer:info] [2048-BRIDGE] forwarded keyCode=37 (file:///...)
[renderer:info] [2048-BRIDGE] forwarded keyCode=38 (file:///...)
[renderer:info] [2048-BRIDGE] forwarded keyCode=39 (file:///...)
[renderer:info] [2048-BRIDGE] forwarded keyCode=40 (file:///...)

棋盘同步变化,方块开始随方向移动、相同数字合并:

方向键操作中:方块移动与合并

图 4:方向键注入后的棋盘——4、8、2、2、2 各就各位,合并动画正常

继续操作,SCORE 累计到 16,合并出新的方块:

SCORE 16:计分与合并同步正确

图 5:SCORE 16 / BEST 76——每一步合并的得分增量与 SCORE 显示完全一致

最终 5 次注入后 SCORE 停在 76,并合成出 16:

验收终点:SCORE 60→76,合成出 16

图 6:验收终态——SCORE 60 / BEST 76,棋盘上已合成出 16。从"按键注入"到"分数变化"的整条链路端到端可用

至此,输入(uinput 注入)→ 底座事件注入 → capture 桥 → bubble 派发 → 上游逻辑 → DOM 渲染,整条链路每一环都有日志或截图佐证。

6.2 T0/T1/T2 能力分级实测

按三档口径如实标注,未验证的项一律不写"通过"。

T0 基础可用

能力项实测结果状态
HAP 安装到 2in1 真机(arm64-v8a)install bundle successfully✅ 通过
启动进入 4×4 棋盘并生成初始方块无黑屏/白屏,多进程全部存活✅ 通过
方向键移动方块、相同数字合并翻倍uinput 注入 5 次,SCORE 8 → 76,合成出 16✅ 通过
计分正确累加合并增量与 SCORE 显示一致✅ 通过
重新开始(New Game)点击后正常开新局✅ 通过

T1 完整可用

能力项实测结果状态
最高分持久化force-stop 后重启,BEST 仍为 76✅ 通过
鼠标点击按钮日志捕获多次 click 命中✅ 通过
键盘多方案(WASD / hjkl / R)输入桥已放行对应 keyCode⚠️ 已实现,未逐项实测
触摸滑动(touchend 方向判定)2in1 键鼠形态未测⚠️ 未测

T2 平台体验

能力项实测结果状态
窗口尺寸适配560×700 → 560×830(见 6.3)✅ 已适配
窗口拉伸自适应上游固定 500px 宽布局,拉伸不重排⚠️ 上游限制
单实例锁已实现 requestSingleInstanceLock⚠️ 已实现,未实测
触控屏滑动平板形态未测⚠️ 未测

未覆盖项:Game Over / You Win 两个弹窗(未玩到棋盘填满或合成 2048 的终局状态);音效不涉及(上游无音频资源)。

关于 BEST 76 的持久化值得多说一句:这是上游 local_storage_manager.jslocalStorage 存的。Electron 的 localStorage 落在 userData 目录下的 Chromium profile 里,force-stop 杀进程后重开仍在——说明鸿蒙的 Electron 运行时把 Chromium profile 的持久化也带过来了,"本地优先存档"这条 Web 应用的默认行为在鸿蒙上是完整的。

6.3 一个小但真实的坑:窗口高度

初版窗口沿用了底座的 560×700,结果棋盘第 4 行被硬生生裁掉。上游布局宽度固定 500px,纵向内容(标题 + 计分 + 棋盘 + 说明文字)总高度超出 700。

这类问题在浏览器里永远不会出现——浏览器有滚动条兜底,内容超了就滚。但 Electron 窗口是固定视口,裁切就是硬裁切,而且用户不会想到去滚动一个"游戏窗口"。改为 560×830 后完整显示。

经验:凡是固定视口承载 Web 产物,第一件事就是量一遍内容的实际高度(DevTools 里一眼的事),不要沿用上一个应用的尺寸。不同应用的"内容高度"差几十像素很正常,而几十像素就是一整行棋盘。

6.4 鼠标侧:意外的顺利

2048 的 “New Game / Try again / Keep going” 按钮同时绑定了 clicktouchend。鸿蒙把鼠标左键合成为 touch 事件序列,正好命中 touchend 分支,无需任何桥接

这是本次适配唯一没有费功夫的输入通道。也印证了一个规律:上游写得越" web 风"(同时监听 mouse 和 touch),在鸿蒙上的兼容性反而越好——两条通道总有一条能被合成事件命中。

七、沉淀:一个可复用的输入桥模板

这次适配最大的产出不是 2048 本身——它只是个几百行 JS 的小游戏——而是这段桥代码可以原样复用到任何"靠键盘输入的纯前端应用"上

复用清单:

  1. PASS_KEYS 键位表:俄罗斯方块是方向键 + P(80) 暂停;贪吃蛇是方向键;Pong 是 W/S
  2. window.__ohos2048Bridge 去重标记的命名,避免多桥冲突;
  3. 其余全部照抄——尤其是回环防护(__ohos_bridge 标记)和 which/keyCode 加固这两段。它们是最容易被省略、也最容易出事的部分:省掉前者,整机卡死;省掉后者,方向键"收到了但没反应"。

更广义地,这次适配沉淀的是一条派生流水线

复制底座 → 改 bundleName/图标/名称 → 重新自动签名 → 放入静态产物
       → 改 main.cjs(窗口尺寸/日志名/键位表)→ 构建 → 真机

前四步是机械劳动,一小时内可以完成;真正需要动脑的只有"这个应用的输入方式是什么、键位表放哪些码"这一件事。这就是"先做一个最简样本把链路全部打通"的价值:第二个、第三个应用的边际成本趋近于零

八、已知限制

限制说明
软件渲染按 OHOS Electron 防坑约定全局禁用 GPU;2048 为纯 DOM/CSS 渲染,负载极低,无可感知差异。对 WebGL 类应用此约定不适用,需单独评估
非响应式布局上游固定 500px 宽,窗口拉伸时不重排。属上游设计而非适配缺陷,上游多年未改
触摸/平板形态未测2in1 键鼠形态已验收;触控滑动、平板单实例行为留待后续
终局弹窗未实测Game Over / You Win 未玩到,逻辑上无风险(与 New Game 同一弹窗体系),但按"未验证不写通过"的口径如实标注
非国产上游上游作者为意大利开发者。MIT 协议合规无障碍,但若渠道要求"国产开源软件"口径,需提前确认归属认定规则

九、结语

2048 是我在鸿蒙 PC 上做过体量最小的适配,前后真正花时间的只有两处:签名重签半小时,方向键桥半天。但它把 Web 承载路线里最隐蔽的一个坑完整地挖了出来——事件冒泡链断在 capture 阶段

这个坑有三个讨厌的特征:不报错(事件只是"到不了")、不崩溃(应用看起来一切正常)、浏览器里不复现(本机 Chrome 永远是好的)。如果没有日志落盘制度和 Clumsy Bird 时期留下的先验结论,排查成本会高出一个数量级。

给后来者的三条建议:

  1. 先做最简样本,再批量派生。 第一个应用把底座、签名、输入、持久化四条链路全部打通,后面每个新应用都是填表题。选第一个样本时,优先选"输入敏感型"的——它会把输入链路的坑逼出来;选"纯展示型"的,坑会留到第二个应用才爆。
  2. 日志落盘先于一切功能。 鸿蒙端没有 DevTools,主进程 uncaughtException、渲染进程 console-message 统一落盘,是唯一的眼睛。本次排查方向键问题,全部证据链都来自那份 2048-runtime.log
  3. 在事件监听器里派发同类型事件,先想终止条件。 capture 桥的回环防护只有一行,但缺了它就是整机卡死。凡是"自己产生自己消费"的递归结构,标记 + 首行拦截应当成为肌肉记忆。

欢迎在评论区交流,也欢迎一起参与鸿蒙 PC 生态建设。

十、Q&A:几个高频问题

Q1:输入桥会不会导致"一次按键走两格"?
理论上会——若某台设备上底座注入的事件本来就能冒泡,原始事件和合成事件会各触发一次。本设备实测走一格(见 5.7)。换机型验收时把"按一次是否只走一格"列为必检项;若走两格,改条件合成即可。

Q2:换个游戏复用这套工程,要动哪些地方?
只有三处:resfile 换静态产物;main.cjs 改窗口尺寸、日志名、PASS_KEYS 键位表;AppScope 改 bundleName / 图标 / 名称并重新自动签名。桥代码照抄,一行不改。

Q3:为什么不用 ArkWeb?包体能小 177MB。
见 2.3 节。核心是可预测性与工程一致性——ArkWeb 的键盘注入行为缺少可跨应用复用的先验,而 Electron 这几条适配线能共用同一套防坑契约。

Q4:HAP 206MB 能不能瘦?
大头是 libelectron.so(177MB),2048 本体只有几百 KB。除非改走 ArkWeb 或自行裁剪 Electron 运行时,否则降不下来——这是"跑真应用"的固定成本。

Q5:上游是国外作者,合规有没有问题?
MIT 协议,可自由修改、再分发,无障碍。但若申报渠道对"国产开源软件"有归属认定要求,需提前确认口径;这一条与 2048 无关,对任何国外上游都适用。

Q6:终局弹窗(Game Over / You Win)没测到,算不算验收不完整?
T0/T1/T2 三档均未要求终局弹窗,它属核心玩法闭环之外的分支。我在 6.2 表中按"未验证不写通过"如实标注,不做掩盖。

附录 A:构建与产物速查

# 构建(工程内无 hvigorw,用 DevEco 自带的)
/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw --stop-daemon
/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw \
  assembleHap --mode module -p product=default -p buildMode=debug

# 产物位置
electron/build/default/outputs/default/electron-default-unsigned.hap   # 未签名
electron/build/default/outputs/default/electron-default-signed.hap     # 签名后

# 安装与启动
hdc install electron/build/default/outputs/default/electron-default-signed.hap
hdc shell aa start -a EntryAbility -b org.game2048.ohos

# 残留旧应用时先卸载
hdc shell bm uninstall -b org.game2048.ohos

附录 B:排错速查表

本次适配(含前期项目)遇到的全部错误码与现象,按排查顺序整理:

现象 / 错误码含义解法
00303074 Configuration Error(SignHap 阶段)签名 profile 绑定的 bundleName 与工程不一致DevEco → Project Structure → Signing Configs 重新自动签名
code:9568320 no signature file(安装时)HAP 未签名完成自动签名后重新构建
install entry already exist (code 9568267)设备残留旧应用(entry 模块名不同)hdc shell bm uninstall -b <bundleName> 后重装
ENOENT: uv_cwd(hvigor 启动时)hvigor 守护进程持有已删除目录的句柄hvigorw --stop-daemon 后重试
启动白屏 / 反复崩溃GPU 子进程在 EGL 路径上崩溃disableHardwareAcceleration() + --disable-gpu* 最前置
方向键无响应、鼠标正常键盘事件断在 capture 阶段,bubble 监听器收不到本文第五章的 capture→bubble 转发桥
窗口僵死、CPU 打满输入桥无回环防护,合成事件自我派发合成事件打 __ohos_bridge 标记,处理器首行拦截
方向键"收到但没反应"合成事件丢失 legacy which/keyCodeObject.defineProperty 在实例上补回
棋盘/页面底部被裁切固定视口高度小于内容实际高度按内容实际高度调 BrowserWindow 尺寸(本例 700→830)
运行期异常无处可看鸿蒙端无 DevTools主进程 uncaughtException + 渲染进程 console-message 统一落盘 userData/2048-runtime.log

参考资料

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐