2026-09-24 深夜,我们把 zjchina 的定位从「内容站架构方法论」升级为「协作认知架构方法论」。 24 小时后,鸿蒙版工程骨架落地——这是定位升维后的第一场实战。 而在本文上线前夜,一次「APP 没有刷新键」的小修复,意外做成了未来多机协同「事件去重」的第一个原型。 本文记录这场实战的技术细节、踩坑过程与决策逻辑,作为 ADR 0004 的注脚。
一、为什么鸿蒙是「协作认知架构」的第一块试金石
ADR 0004 把 zjchina 的定位从「内容站」升维到「协作认知架构」——简单说,就是从「做好一个网站」变成「做好一套能连接物理世界的系统」。
鸿蒙适配恰好踩中三个维度:
| 维度 | 鸿蒙场景 | 我们的机制 |
|---|---|---|
| 空间可达 | 工厂大屏、导购机器人触屏、无人店收银台 | ArkWeb 薄壳 + 服务端渲染(零 JS 首屏) |
| 时间可验证 | 交易记录可回溯、决策过程可审计 | 事件日志(类似飞行记录仪)+ 内容指纹链 |
| 边界生长 | 传感器(RFID/重量)、防截屏、全屏展示 | 平台能力隔离(鸿蒙特有代码单独存放) |
鸿蒙不是「又一个平台」,而是协作认知架构向物理世界延伸的第一个触点。
二、技术方案:ArkWeb 薄壳,不改现有架构
2.1 三轨道不变,鸿蒙只是多一个「容器」
我们的网站有三条渲染轨道(类似三条生产线):
┌─────────────────────────────────────────────────────────────┐
│ 内容轨道(首页、文章页、决策日志) │
│ 服务端直接生成 HTML,无需下载 JS,打开即读 │
│ → 鸿蒙浏览器直接打开,零改动 │
├─────────────────────────────────────────────────────────────┤
│ 应用轨道(后台管理) │
│ 浏览器下载 wasm(编译后的代码),交互复杂 │
│ → 鸿蒙浏览器可运行,但非最优 │
├─────────────────────────────────────────────────────────────┤
│ 壳轨道(桌面/手机 App) │
│ 原生窗口包裹网页内容 │
│ → 鸿蒙 H1 = ArkWeb 薄壳(第一个原生容器) │
│ → 鸿蒙 H3 = 原生交易页(未来) │
└─────────────────────────────────────────────────────────────┘
关键决策:鸿蒙不改变三条轨道,而是给「壳轨道」增加一种交付形态。内容页面(服务端渲染)在鸿蒙浏览器里零改动运行——这是「中立契约层」的第二次兑现(第一次是服务端渲染/客户端渲染双端同源)。
2.2 窗口级能力:防截屏与全屏
防截屏(鸿蒙 API 26 的 setWindowPrivacyMode):
- 截屏/录屏时窗口自动变黑,无需修改网页代码(系统在窗口层拦截)
- 坑位:API 26 需要特殊权限(
ohos.permission.PRIVACY_WINDOW,系统级授权)- 设计文档假设「无需权限」已过时
- 工厂场景(企业证书)可用;公开版若拿不到授权则通过配置关闭
- 降级策略:如果权限不足,catch 错误继续加载(不阻断使用,但日志留痕)
全屏沉浸(setWindowLayoutFullScreen):
- 内容延伸至状态栏/导航栏,工厂大屏场景必需
- 待实测:安全区边距(
env(safe-area-inset-*))在鸿蒙浏览器是否正确传递
2.3 工程骨架:Stage 模型,零依赖
harmony/
├── AppScope/app.json5 # 应用标识:net.zjchina.shell
├── build-profile.json5 # API 26.0.0, 签名配置
├── entry/src/main/
│ ├── module.json5 # 防截屏权限声明
│ ├── ets/entryability/EntryAbility.ets # 防截屏+全屏逻辑
│ ├── ets/pages/Index.ets # ArkWeb 壳(加载网页)
│ ├── ets/model/AppConfig.ets # 配置中心(开关集中管理)
│ └── resources/ # 字符串/颜色/页面配置
└── scripts/harmony-build.sh # 一键构建(环境变量注入)
关键设计:
- AppConfig.ets:所有窗口能力开关集中管理(防截屏/全屏/网址),避免魔法数字散落各处
- harmony-build.sh:构建时注入环境变量(SDK 路径等),不污染全局 shell 配置
- 零依赖:
oh-package.json5无第三方依赖,纯鸿蒙原生代码 + 系统浏览器
三、踩坑记录:API 26 vs 设计文档
| 坑位 | 设计文档假设 | 实际情况(API 26) | 解决方案 |
|---|---|---|---|
| 防截屏权限 | 无需权限 | 需系统级权限(PRIVACY_WINDOW) | 声明权限,catch 错误降级 |
| 错误回调签名 | isMainFrame 直接参数 | isMainFrame 在 event.request 对象上 | 按官方类型定义修正 |
| 版本号格式 | "26"(数字) | "26.0.0"(字符串) | 按构建工具错误提示修正 |
| API 26 运行环境 | 下载本地镜像即可调试 | 当时 DevEco Studio 的本地模拟器列表中没有 API 26 镜像(镜像清单随 IDE 版本更新,以实际列表为准);在线模拟器虽有 API 26,但命令行 hdc install 装本地签名包被拒 | DevEco Run 直连 Pura X 在线模拟器,云端签名部署 |
这个坑的关键不在「下载」,而在「安装通道」:在线模拟器运行在云端,本地命令行工具因签名体系不一致无法把应用装进去;而 DevEco Run 走云端通道,自动完成构建、签名、部署一条龙,直接把应用送上 Pura X 在线模拟器(API 26)。所以验收全部跑在 API 26 在线模拟器上——防截屏是全功能生效而非降级;唯一未闭环项是云端环境拿不到 hilog(即上方清单第 8 项)。
四、跨语言边界:让「契约」当裁判
鸿蒙适配是「跨语言交互问题」的第三次出现(前两次:C++ 后端 ↔ Rust、Rust ↔ 浏览器 wasm)。
纪律不变:
- 边界只传契约,不传调用:鸿蒙代码(ArkTS)与 Rust 核心之间只传数据类型(JSON 格式),与浏览器→后端、服务端渲染→后端是同一批类型
- 漂移锁从三向扩四向:现在数据格式校验是「Rust 计算 + 文档定义 + C++」三向锁定;鸿蒙加入后,ArkTS 侧类型从统一的数据规范自动生成——校验机制不变,只是多一个使用方
- 平台能力仍走通道隔离:碰鸿蒙系统功能(传感器/窗口/防截屏)的代码永远住在鸿蒙特有目录,核心逻辑保持纯净可编译到鸿蒙,不出现平台判断代码污染
H2 验证(后续):只暴露核心逻辑的一个纯函数(如 Markdown 渲染),鸿蒙代码调用,同一测试用例双端断言——不做 UI。
五、对网页编排的影响:零影响
内容页面(首页、文章页、决策日志)服务端渲染,鸿蒙浏览器直接打开,无需任何改动。
唯一需要实测的假设,验收也给出了结论:
- 鸿蒙浏览器是否支持 Service Worker?(设计文档「默认支持」此前只是假设)
- 若支持:离线缓存加速 + 断网兜底(显示友好离线页)
- 若不支持:服务端渲染仍可用(只是没缓存加速),后台管理页不开放(wasm 依赖缓存)
验收清单与结果:
- 部署方式:
命令行 hdc install→ DevEco Run 部署(在线模拟器绕过本地安装限制)✅ - 防截屏(截屏时窗口黑屏)✅ API 26 全功能生效
- 全屏(内容延伸至安全区)✅
- 离线缓存(在线首访/二访/断网)✅(网络连接活跃)
- 内页跳转(文章详情页)✅(鸿蒙浏览器加载网站)
- 动画性能(wasm 粒子效果)✅(渲染进程独立,内存占用合理)
- 服务端渲染首屏(零 JS 可读)✅(鸿蒙浏览器直接消费 HTML)
- 稳定性(无错误日志)⚠️(在线模拟器日志权限限制)
合计 7/8 通过(Pura X 在线模拟器,API 26):防截屏全功能生效;唯一未闭环项是云端环境拿不到 hilog 日志,留待真机或本地模拟器补验。
六、上线前夜:APP 没有刷新键,事件驱动替代轮询
准备发布本文时发现一个真实问题:Service Worker 为了让二次访问秒开,会先展示本地缓存、再在后台静默更新。浏览器里用户还能手动刷新,可页面装进鸿蒙壳之后——APP 没有刷新键,新文章可能一直不出现。
6.1 第一反应是轮询,但它被否决了
直觉方案很简单:页面每隔几十秒请求一次「版本号」,发现变化就弹提示。否决理由同样简单:博客的发布间隔可能长达数周,定时请求里 99.99% 都是「没有变化」——为了一次发布,让每个访客的设备持续空转耗电,这笔账不划算。APP 在后台时连接还会被系统冻结,定时器并不可靠。注意被否决的是定时器轮询;后来补上的内容探针只在用户行为事件里触发一次请求,不是同一回事。
6.2 「代码发布」本来就是可以监听的事件
重新审视机制后发现,浏览器早就准备好了答案:
- 每次发版,构建脚本都会重新生成 Service Worker 脚本
sw.js——版本号、资源清单、完整性指纹全部写进它的字节,且这个文件本身被设置为永不缓存; - 浏览器在每次打开页面时自动比对它:字节变了,就安装新 SW,页面随之收到一个
updatefound(发现更新)事件; - 于是判定收敛成一个极简状态机:首次安装静默接管(新访客看到的本就是最新内容,不该被打扰);页面已被旧版本控制时再收到更新事件,才弹出横幅,并且同一次更新只弹一次。
对于没有导航的场景——APP 切到后台再切回来、网络重新连通、从系统页面缓存恢复——则在对应的用户行为事件里主动检查一次。全程没有任何定时器:发布间隔再长,通知成本都是零。
6.3 上线当天就被实测打脸:还有一种发布叫「内容发布」
第一版上线后,我用 Safari 打开站点准备验收——没有横幅,首页文章列表停在四天前。排查发现两个此前完全没考虑到的机制真相:
真相一:提示代码提示不了它自己(自举)。 我打开页面时,设备上跑的是旧 SW 吐出的旧页面,旧页面加载的是旧版注册脚本,里面根本没有横幅代码。即便浏览器此刻已在后台装好了新 SW,当前页面也没有任何代码在监听更新事件。所有 SW 更新提示方案都有这个自举特性:提示代码必须比发布早一次访问存在于访客设备上——它从下一次发布起才开始工作。本地测试之所以能看到横幅,是因为测试环境手动构造了「设备上已有新代码」的状态,这个前提在真实首次发布时并不存在。
真相二:发博客根本不换 sw.js。 updatefound 只在 sw.js 字节变化时触发,而发布一篇文章是纯数据库动作——不碰任何静态文件。这次恰好是代码和文章一起部署才碰巧有信号;以后单独发博客,SW 永远不知道世界更新了。我在本文初稿里写的「发布本来就是可监听事件」,只对代码发布成立,对内容发布不成立。
6.4 内容探针:让本来就要发的那次请求顺便当裁判
补丁没有复活轮询,而是顺着 SWR 的工作方式借力:
- 后端在每个页面响应上带一个极轻的版本头:已发布文章数 + 最新发布时间(数据在渲染时现成,零额外查询);
- SW 后台回源更新页面缓存时顺手比对新旧版本头——这是一次本来就会发的请求,新增成本为零。版本变了,就通知当前还在看旧页面的标签页弹同一个横幅;
- 用户停在页面不导航的场景(APP 切后台再回来),SW 才在该事件里发一次
no-store探针请求,且回填前要过与导航完全相同的文档完整性校验。
于是信号源从一个变成两个,通知内核和横幅一行没改——「代码发布」走 SW 生命周期事件,「内容发布」走 SWR 内容探针,两者进同一个状态机、共用同一条去重纪律:同一访客同一会话,无论信号从哪条路来,只打扰一次。
设计对了,实现照样会咬人。真栈端到端测试(真起后端、真发一篇文章、真浏览器跑 SW)连抓三只虫子,每一只都是纯单元测试看不到的:
其一,后台更新是个「没人等的 Promise」。 SWR 的写法是「先吐缓存响应、同时后台拉新版」——最初后台回填只是一个游离的 Promise,没有用 event.waitUntil() 替它续命。规范给 SW 的生命周期是:响应一旦交付、事件处理结束,SW 随时可以被终止。本地全缓存命中、页面毫秒级渲染完时,后台那次回源还在路上,SW 连请求带回填带通知一起被掐死——版本头明明翻代了,缓存不更新、横幅不出现;而手动触发的探针因为跑在另一个事件里反而一切正常,极具迷惑性。修法就是 SWR 的标准两只手:respondWith(旧页面) 交付体验,waitUntil(后台更新链) 显式保活,连回填完成都要 await 进链里才算数。
其二,消息可能比收件人出生得早。 后台回源在本地只要几十毫秒,完全可能在新页面的注册脚本挂好消息监听之前就把「内容已更新」发了出来——消息发了,没人接。这和此前完整性告警遇到的是同一个时序黑洞,修法也是同一个:SW 先把信号记进待认领集合再发消息,页面启动时主动来认领一次(drain)。实时通道 + 启动补发,两条路合起来才覆盖全部时序。
其三,测试 URL 长得不像真实访客,测的就是假路径。 测试为了防缓存给首页加了 ?probe=时间戳,而 SW 的导航缓存键是精确匹配——带查询串的 URL 匹配不到 / 的缓存,直接走了冷缓存网络直出(这条路径用户直达新内容,按设计本就不弹横幅)。换成真实访客的无查询串 /,老访客路径才真正被覆盖。测试构造的世界稍有失真,结论就会骗你——这也是坚持真栈 E2E 而不是只测纯核的原因。
这两个真相也各留了一条诚实的边界:第一版功能上线当次的老访客(包括我自己)需要手动刷新一次完成「代码自举」,无法穿越回过去补救;内容探针依赖新版本头,在它自己上线前已被缓存的旧页面里同样不存在——机制只能向前兼容,不能向过去兼容。
6.5 这是未来多机协同「事件去重」的第一个原型
这个功能看似只是个更新提示,架构上却特意做了一件事:通知入口与触发源解耦。横幅和更新动作并不关心「是谁发现了新版本」——Service Worker 更新事件、SWR 内容探针只是前两个信号源;将来股票行情、无人店多机协作的实时事件,经过 SSE/Push 这样的长连接通道,可以直接触发同一个通知入口,UI 与去重逻辑一行都不用改。
核心判定只有两个状态——「是否已被旧版本接管」「本次会话是否已提示」——保证同一事件只打扰一次。这与多机协同里「同一事件只处理一次」的幂等去重是同一个问题:去重必须做在事件入口,而不是等消息堆到界面上再去过滤。两个纯核状态机被抽离成零依赖函数用测试向量锁死(「首次访问不提示」「同一更新不重复提示」「事件乱序到达也不误报」「两个信号源同会话各至只弹一次」「版本头缺失宁可漏报」),再加一个真栈端到端用例:真起后端、真发一篇临时文章、验证版本头翻代→横幅只弹一次→点刷新后缓存确实换成新页面,全程无 JS 错误,临时文用完即删。
也诚实标注边界:这个机制保证「下次打开页面或 APP 回到前台时」立即通知,而不是挂机不动时秒级弹窗。真正的秒级推送(长连接/系统推送)是下一阶段的能力——接缝已经留好。
这篇文章的上线过程,就是它自己的测试用例:第一版被真实设备打脸、定位到两个机制漏洞、补上内容探针,接着真栈 E2E 又连抓三个实现层的虫子——你现在读到的这段文字,就是这次修复的交付物之一。
七、下一步:从「能跑」到「好用」
H2(后续):鸿蒙代码调用 Rust 核心逻辑(如 Markdown 渲染),验证数据格式四向锁定。
H3(未来):鸿蒙原生交易页(结算/支付/订单确认),核心逻辑在 Rust,UI 双份(鸿蒙原生 + 网页版)。
实时通道(接缝已留):新版本横幅的通知入口与触发源解耦,下阶段接 SSE/Push 时直接复用——行情跳动、多机协作事件与「内容更新」走同一个入口、同一套幂等去重。
5.1 无人店:鸿蒙 H1 是「导购机器人触屏」的技术预演——鸿蒙浏览器承载商品讲解(内容轨道),鸿蒙原生页承载交易(壳轨道),传感器数据经通道入事件日志。
八、结语:协作认知架构的第一块砖
鸿蒙 H1 不是「又一个平台适配」,而是协作认知架构向物理世界延伸的第一块砖。
它验证了:
- 三轨道不变:内容/应用/壳的路由表、渲染模式、降级链
- 中立契约层:内容页面零改动跑在鸿蒙浏览器
- 通道隔离:平台能力(防截屏/全屏/传感器)住在鸿蒙特有目录,核心逻辑保持纯净
- 可观测降级:低版本或权限不足的设备上,防截屏按设计走 catch 降级(不阻断加载);API 26 在线模拟器全功能生效(低版本降级路径尚未在真机实测)
上线前夜补的事件驱动更新提醒,则验证了另一件更面向未来的事:事件可以从任何通道来,但入口只有一个、同一事件只处理一次——这条纪律先在最不起眼的「更新提示」上跑通,等它的是行情事件流和多机协作。
下一块砖:股票模拟盘的「乐观执行 + 资金事件流」,然后是无人店的「多机协作」。
参考文献:
- ADR 0004:协作认知架构定位升维(docs/decisions/0004-collaborative-cognition-architecture.md)
- 鸿蒙策略预研(docs/superpowers/specs/2026-09-25-harmony-support-strategy.md)
- H1 落地状态(docs/superpowers/specs/2026-09-25-harmony-h1-status.md)
- 页面编排要求(docs/superpowers/specs/2026-09-25-harmony-page-layout-requirements.md)