HarmonyOS 应用开发

ArkTS · ArkUI · Stage Model · ArkTS 语言核心、ArkUI 声明式布局、路由、状态管理、网络与数据,附 office-flow 审批示例工程(11
← 返回教程馆
4阶段
28章节
28源文档
12 周计划配套生态

📊 我的学习进度 本地自动保存

加载中…
🧭 导览0/0
code0/0
docs 1-80/0
docs 9-160/0
docs 17-240/0
💡 每章卡片右上角「✓ 标记已学」;教程间互链已自动映射到本页章节;代码示例请配合 Gitee 仓库 使用。

📖 HarmonyOS 企业级开发实战教程 README.md

面向有基础编程能力、希望进阶到企业级开发的工程师。以 HarmonyOS NEXT / HarmonyOS 7.0 为基线,用 ArkTS + ArkUI 声明式范式 + Stage 模型,从环境搭建一路写到上架发布与持续集成,后半程用一个完整的企业办公类项目 OfficeFlow 贯穿到底。


一、版本基线(阅读前必读)

项 值 说明
编写基线 HarmonyOS 开发套件 26.0.0 对应 API 26.0.0 / HarmonyOS 7.0
IDE DevEco Studio 26.0.0 Release(26.0.0.821,2026-08-28) SDK 已随 IDE 预置,无需单独下载
targetSdkVersion 26.0.0 自 API 26 起版本号改用语义化 X.Y.Z
compatibleSdkVersion 6.0.0(20) 必须写成旧格式,20 仍在 X.Y.Z(N) 体系内
语言 ArkTS TypeScript 的静态化子集,非"TS 随便写"
UI 范式 ArkUI 声明式 UI = f(state),禁用命令式 UI 拼接
应用模型 Stage 模型 FA 模型已废弃,本教程不涉及
导入风格 统一 @kit.* 禁止 @ohos.* 分命名空间导入
状态管理 全量 V2 @ComponentV2 / @ObservedV2 / @Trace … V1 仅在附录 A 做迁移对照
路由 Navigation + NavPathStack + 系统路由表 @ohos.router 已不推荐(官方自 API 12 起不再维护)
网络 @kit.RemoteCommunicationKit 的 rcp @ohos.net.http 不再作为新项目主力

为什么要锁死版本:HarmonyOS 迭代极快,社区资料跨版本混杂,一半以上的"诡异问题"其实是把旧版本写法用在了新版本上。本教程所有代码都对齐上表;出现版本差异时,正文会用 > 版本门槛 明确标注。

版本演进速查

开发套件版本 API 系统版本 时间 备注
26.0.0 26.0.0 HarmonyOS 7.0 2026-08 本教程基线
6.1.1(24) 24 HarmonyOS 6.1 2026-05
6.1.0(23) 23 HarmonyOS 6.1 2026-04
6.0.2(22) / 6.0.1(21) 22 / 21 HarmonyOS 6.0 2026-01 / 2025-11
6.0.0(20) 20 HarmonyOS 6.0 2025-09 本教程兼容下限
5.1.x(18/19) 18 / 19 HarmonyOS 5.1 2025-06 状态管理 V2 已可用
5.0.0(12) 12 HarmonyOS 5.0 2024-10 Navigation / V2 / rcp 起点
4.x 及更早 ≤ 10 HarmonyOS 4 及更早 ≤ 2023 FA 模型时代,本教程不涉及

二、适用读者与前置能力

适合你,如果:已掌握任一主流语言的语法(Java / Kotlin / Dart / TypeScript / Swift),写过至少一个完整 App 或在团队里做过功能模块,想补齐"能上生产"的那一层——架构分层、错误处理、多端适配、性能定位、CI 与上架。

不适合你,如果:完全没写过代码。本教程不从"什么是变量"讲起,直接进入工程视角。

前置环境:Windows 10/11 64 位或 macOS 12+,16 GB 内存(推荐 32 GB)、100 GB 可用磁盘。


三、阅读路径

路径 顺序 适合谁
系统通关(推荐) 00 → 01 → … → 10 → 11 第一次系统学 HarmonyOS,想建立完整心智模型
有基础直接实战 00 → 05 → 06 → 11 已写过 ArkUI 页面,只想补齐状态管理、网络与架构
查漏补缺 直接看 appendix/ 已有项目在跑,需要迁移对照、版本门槛、术语速查
带项目来查 按 appendix/B 的表定位问题 → 回到对应章节 手上有存量工程要升级或重构

四、章节路线图

章节 核心问题 关键产出 难度
00 学习准备 版本怎么对齐?术语怎么统一? 环境自检清单 ★
01 环境搭建与工具链 工程里每个文件是干什么的?怎么用命令行构建? 可命令行构建的 HelloOffice ★
02 ArkTS 语言核心 ArkTS 和 TypeScript 差在哪?为什么? 类型安全的领域模型 + 解析器 ★★
03 ArkUI 组件与布局 容器怎么选?组件怎么复用?长列表怎么不卡? 千条数据的审批列表页 ★★
04 页面路由与生命周期 页面栈归谁管?生命周期在哪一步做什么? Navigation 路由 + 登录拦截 ★★
05 状态管理 V2 装饰器怎么配?为什么会刷新失败? 全 V2 重构的列表页 ★★★
06 数据持久化与网络请求 存哪?取哪?网络怎么收口? Repository + rcp 封装 ★★★
07 分布式能力 跨设备能做什么?边界在哪? 跨设备草稿协同闭环 ★★★
08 性能与内存优化 怎么量化问题而不是靠猜? 四段式性能报告 ★★★
09 工程化与模块化架构 模块怎么拆?依赖怎么管? 无环的多模块工程 ★★★
10 测试与上架发布 测什么?怎么发?怎么过审? 三层测试 + 上架清单 ★★
11 企业级实战 OfficeFlow 一个真实项目怎么落地? 可编译的多模块工程 ★★★★

第 11 章目录

文件 内容
00 项目总览 定位、功能范围、技术选型决策记录、运行与验收
01 架构与模块划分 模块职责、依赖方向、目录约定、分层数据流
02 网络层与统一错误处理 rcp 封装、拦截器链、Result / AppError、401 单飞
03 日志与埋点 三级分层、无损采集、脱敏
04 登录鉴权 Token 生命周期、会话状态、路由拦截
05 审批流业务闭环 列表 / 详情 / 提交 / 撤回、状态机、幂等
06 消息与通知 通知通道、权限、跳转、已读同步
07 多端适配与分布式协同 断点响应式、折叠屏 / 平板 / PC、跨设备草稿
08 构建与持续集成 签名不入库、hvigorw 流水线、静态门禁

五、代码仓库与章节对应关系

第 11 章的工程位于 code/office-flow/,是一个可直接用 DevEco Studio 26.0.0 打开的多模块工程:

模块 形态 对应章节
entry HAP 04、11/01、11/08
feature_login HAR 11/04
feature_approval HAR 11/05
feature_message HAR 11/06
feature_collab HAR 11/07
lib_auth HAR 11/04
lib_network HAR 06、11/02
lib_storage HAR 06
lib_log HAR 11/03
lib_router HAR 04、11/01
lib_distributed HAR 07、11/07
lib_ui HSP 03、11/07
lib_base HAR 02、11/02

工程默认开启 Mock 模式,无需后端即可跑通全部业务链路;具体见 附录 D。 另外提供两种验证手段:

  • 静态门禁(不需要 DevEco Studio,几秒出结果):code/office-flow/scripts/ 下的依赖图校验、禁用 API 扫描、路由表校验,可用 bash scripts/ci.sh 一次跑完;
  • 本地假后端:code/office-flow/server/mock-server.js(零依赖 Node 服务), 支持 ?simulate=timeout|500|401|429|malformed 故障注入与 __edge__ 边界数据, 用于验证拦截器级 Mock 覆盖不到的真实 HTTP 行为与两端契约一致性。

工程实测状态

以下检查已在交付前实际运行通过:

检查 命令 结果
依赖图(无环 / 无 feature 横向依赖 / lib_base 零依赖) node scripts/check-deps.mjs 通过(13 个模块,6 条同层 lib 依赖为既定设计)
禁用 API 扫描(@ohos.* 分命名空间、@ohos.router、V1 装饰器…) bash scripts/check-banned-api.sh 通过(扫描 99 个文件,命中 0)
路由表与实际文件/命名一致性 node scripts/check-route-map.mjs 通过(8 条路由全部有效)
本地假后端接口与故障注入 node server/mock-server.js 通过(已用 curl 验证登录、分页、边界数据、500/401 注入)
rcp 适配点收敛 grep -rl "@kit.RemoteCommunicationKit" --include=*.ets . 只有 lib_network/src/main/ets/rcp/RcpClient.ets 一处

尚未验证的部分(本机没有 DevEco Studio):ArkTS 编译、模拟器/真机运行、UiTest。 工程中所有涉及 SDK 具体签名处都已标注「待 DevEco 编译验证」,并刻意把 rcp 的调用收敛到一个类,便于按实际 SDK 快速对齐。


六、写作规范(章节统一约定)

本教程刻意保持一致的三种标注,读到第三章你就会形成条件反射:

  • > 术语 — 关键概念的一句话解释,不展开。
  • > 版本门槛 — 该能力在哪一版可用;写 API 26 新增 就意味着在兼容下限 20 上必须降级。
  • > 误区 — 真实会踩的坑,给「错误写法 → 正确写法」对照。

每章固定结构:

N.0 本章目标      —— 3~5 条,用可检验的动词写(能搭建 / 能写出 / 能定位 / 能量化)
N.x 小节          —— 设计思路 → 关键代码 → 版本差异提示
N.9 常见误区      —— 至少 3 条,全部来自实践
N.10 实践任务     —— 任务描述 + 验收 checklist
N.11 延伸阅读     —— 官方文档锚点

代码风格:4 空格缩进、显式类型标注、const 优先、禁止 any 出现在公开签名、资源引用一律走 $r()。


七、附录

附录 内容
A 状态管理 V1 → V2 迁移速查 完整映射表与混用禁忌(全书唯一出现 V1 的地方)
B 弃用 API 与版本门槛总表 已废弃 / 已不推荐清单 + API 20 vs 26 门槛
C 术语字典 HAP / HAR / HSP / Stage / NavPathStack / 断点 …
D 本地 Mock 方案 无后端跑通全书的方法与契约文件

八、更新日志

日期 变更
2026-09 首版。基线 HarmonyOS 开发套件 26.0.0(API 26.0.0 / HarmonyOS 7.0)。

后续 HarmonyOS 版本发布时,请优先更新本文档的「版本基线」与 附录 B。

📖 OfficeFlow README.md

一个 HarmonyOS 企业办公示例工程:覆盖登录鉴权 / 审批流 / 消息通知 / 多端协同四条主链路, 用一套真实可跑的多模块骨架,把"模块怎么分、状态怎么管、错怎么传、跨设备怎么同步"讲清楚。

这个工程不是 Hello World 集合,而是一份带取舍说明的参考实现: 每个关键设计都写了"为什么这样做"和"不这样做会踩什么坑",注释里出现的反例都是真实踩过的。


1. 技术基线

项 值 说明
运行时 HarmonyOS 7.0
API 版本 API 26.0.0 compileSdkVersion / targetSdkVersion
兼容下限 6.0.0(20) compatibleSdkVersion,即最低可安装系统版本
模型 Stage 模型 全工程无 FA 模型
IDE DevEco Studio 26.0.0
状态管理 仅 V2 @ComponentV2 / @ObservedV2 / @Trace / @Local / @Param / @Computed / @Monitor
导入风格 仅 @kit.* 禁止 @ohos.* 分命名空间导入
构建 hvigor(hvigorw)

兼容下限与能力门禁

compatibleSdkVersion 是 6.0.0(20),而 compileSdkVersion 是 26.0.0 —— 这个组合意味着 可以调用 API 21~26 的新接口,但必须在运行期确认设备支持。

本工程的纪律是:业务代码里不允许裸写 canIUse,一律经 lib_base 的 CapabilityGate 收口。 原因写在 lib_base/src/main/ets/gate/CapabilityGate.ets 的注释里,简单说三条:

  1. 判断分散在各处时,升级 SDK 后无法一次性找出哪些是"新能力";
  2. 裸写 canIUse 容易只在支持分支写逻辑,忘了降级分支,在旧设备上静默失败;
  3. 需要统一捕获 canIUse 自身可能抛出的异常。

已登记的能力字符串见 Capability 常量类(通知、RDB、分布式数据同步、分布式设备管理、网络栈、Web)。


2. 模块依赖图

                          ┌────────────────────────────────────────────┐
                          │   entry(HAP,唯一可安装入口)              │
                          │   持有唯一 NavPathStack + 路由表分发        │
                          └───────────────┬────────────────────────────┘
                                          │ 依赖所有 feature 与部分 lib
             ┌────────────────────────────┼────────────────────────────┐
             ▼                            ▼                            ▼
   ┌──────────────────┐        ┌──────────────────┐        ┌──────────────────┐
   │ feature_login    │        │ feature_approval │        │ feature_message  │
   │ (登录鉴权)      │        │ (审批流)        │        │ (消息与通知)    │
   └────────┬─────────┘        └────────┬─────────┘        └────────┬─────────┘
            │                           │                           │
            │                  ┌────────┴─────────┐                 │
            │                  ▼                  │                 │
            │        ┌──────────────────┐         │                 │
            └───────►│ feature_collab   │◄────────┘                 │
                     │(多端协同)       │                           │
                     └────────┬─────────┘                           │
                              │                                     │
   ┌──────────────────────────┴─────────────────────────────────────┴──────┐
   │                        能力库层(lib_*,不感知业务)                    │
   │                                                                       │
   │  lib_ui(HSP)   lib_router   lib_storage   lib_distributed   lib_network│
   │  lib_auth                                     lib_log                 │
   └──────────────────────────────────┬────────────────────────────────────┘
                                      ▼
                     ┌────────────────────────────────┐
                     │ lib_base(契约与工具,零依赖)   │
                     │ Result / AppError / JsonReader │
                     │ CapabilityGate / UiState / Paged│
                     └────────────────────────────────┘

规则(由 scripts/check-deps.mjs 自动校验,违规直接 CI 失败):

规则 允许 违反后的症状
feature → lib ✅ —
lib → feature ❌ 禁止 改一个 feature 导致多个 lib 重编译,且 lib 无法单测
feature ↔ feature(横向) ❌ 禁止 两个 feature 互相 import,任一模块无法被删除或复用
lib_base → 任何模块 ❌ 禁止 底层契约被业务污染,"公共库"再也不公共
依赖环 ❌ 禁止 模块无法独立编译与单测
依赖未在 build-profile.json5 注册的模块 ❌ 禁止 构建期报错位置离原因很远
同层 lib ↔ lib ⚠️ 慎用 仅允许指向更稳定的 lib,脚本给警告

feature 之间要跳转怎么办:只依赖 lib_router 的路由名常量,不 import 对方的页面。 例如 feature_message 跳到审批详情:

import { RouteNames, Router } from 'lib_router';
import type { ApprovalDetailParam } from 'lib_router';

// 参数键名必须逐字匹配 ApprovalDetailParam(id + from,两者都必填)
const param: ApprovalDetailParam = { id: message.relatedId, from: RouteNames.MESSAGE_LIST };
Router.push(RouteNames.APPROVAL_DETAIL, param);

这把"跳转"从代码依赖降级为契约依赖(只依赖路由名),运行时由路由表解析到具体页面。


3. 目录结构

office-flow/
├── AppScope/                     # 应用级配置(bundleName / versionCode / versionName)
│   ├── app.json5                 #   ← scripts/inject-version.mjs 会改写版本号
│   └── resources/
├── hvigor/                       # 构建工具链配置
├── build-profile.json5           # ★ 工程级构建配置:modules 注册 + product + 签名(刻意留空)
├── oh-package.json5              # ★ 工程级依赖(本工程的脚本无需任何 npm 依赖,见第 7 节)
├── hvigorfile.ts                 # 工程级构建入口(appTasks)
│
├── entry/                        # HAP:唯一入口 + 唯一 NavPathStack
│   └── src/main/
│       ├── module.json5
│       ├── ets/
│       │   ├── entryability/EntryAbility.ets     # 生命周期 + 窗口尺寸监听 + onNewWant 深链
│       │   ├── init/AppInitializer.ets           # 应用级初始化编排(同步段 / 延迟段)
│       │   ├── pages/Index.ets                   # Navigation 根容器 + route_map 构建函数
│       │   └── state/WindowState.ets             # 断点状态(应用级唯一来源)
│       └── resources/base/profile/route_map.json  # ★ 系统路由表(纯字符串,靠脚本兜住)
│
├── feature_login/                # HAR:登录鉴权
├── feature_approval/             # HAR:审批流(列表 / 详情 / 发起)
├── feature_message/              # HAR:消息与通知  ← 本工程新增
├── feature_collab/               # HAR:多端协同    ← 本工程新增
│
├── lib_ui/                       # HSP:公共组件与主题(AppButton/AppEmpty/AppDialog/Theme/断点)
├── lib_base/                     # HAR:契约与工具(Result/AppError/JsonReader/CapabilityGate)★最底层
├── lib_auth/                     # HAR:会话与令牌
├── lib_network/                  # HAR:rcp 封装 + 拦截器链 + Mock 基建
├── lib_storage/                  # HAR:Preferences / RDB / 文件
├── lib_log/                      # HAR:日志 / 打点 / 埋点 / 脱敏
├── lib_router/                   # HAR:路由名常量(RouteNames)+ Router 门面
├── lib_distributed/              # HAR:设备管理 / 草稿同步通道 / 合并算法 / 权限
│
├── scripts/                      # ★ 构建与 CI 脚本(详见第 6 节)
│   ├── check-deps.mjs            #   模块依赖图校验
│   ├── check-banned-api.sh       #   禁用 API 扫描
│   ├── banned-api.list           #   禁用 API 白名单(当前为空)
│   ├── check-route-map.mjs       #   路由表校验
│   ├── inject-version.mjs        #   版本号 / IS_RELEASE 注入
│   ├── setup-signing.sh          #   生成本地签名配置片段
│   └── ci.sh                     #   一条命令跑完整流水线
│
├── signature/                    # 签名材料目录(内容全部被 .gitignore 忽略)
│   └── README.md                 #   ★ 必须读:证书从哪来、怎么配、怎么排错
│
├── server/                       # 本地假后端(可选,默认走 Mock 不需要它)
│   ├── mock-server.js            #   零依赖 Node 假后端,支持 ?simulate= 故障注入
│   └── README.md                 #   启动方式、接口清单、与拦截器级 Mock 的分工
└── build-artifacts/              # CI 归档产物(.gitignore 忽略)

4. 如何运行

4.1 默认路径:Mock 模式,无需后端

工程默认环境是 MOCK(lib_base/src/main/ets/const/AppConst.ets 的 EnvConfig.CURRENT)。 此时所有 HTTP 请求都被 lib_network 的 MockInterceptor 拦下, 用各模块注册的规则返回数据 —— 不出网、不需要后端。

# 1) 用 DevEco Studio 26.0.0 打开工程根目录
# 2) 等待首次 Sync 完成(hvigor 会下载依赖,需要联网)
# 3) 选择 entry 模块 → 运行到模拟器或真机

首次 Sync 会下载 hvigor 与 ohpm 依赖,需联网。之后可离线构建。

4.2 切到真实后端

改 lib_base/src/main/ets/const/AppConst.ets 里的 EnvConfig.CURRENT:

static readonly CURRENT: Env = Env.PROD;   // 或 DEV / STAGING

同时起本地假后端(可选):

PORT=3000 node server/mock-server.js    # 端口被占用时用 PORT 换一个
# 然后访问 http://127.0.0.1:3000/approval/list 验证服务已起

mock-server.js 支持 故障注入:任意接口追加 ?simulate=timeout|500|401|403|404|429|slow|malformed|raw-array, 可在真实链路上演练超时、鉴权失效、非法 JSON 等分支。接口清单与演示数据见 server/README.md。

注意:切到非 Mock 环境后,各模块的 *MockRules 不会被注册 (AppInitializer.initMock() 里有 if (!EnvConfig.isMock()) return;)—— 这条判断是刻意保留的,"生产包里带了一份能返回假数据的规则"是最难排查的一类事故。 为防止这条依赖人工记忆的规则失效,scripts/inject-version.mjs 增加了 发布环境门禁:打 release 包时若 EnvConfig.CURRENT 仍是 MOCK/DEV/STAGING, 直接 exit 1 中断(scripts/ci.sh 的第 6 阶段会调用它)。确实要出演示包时显式加 --allow-mock-in-release。

4.3 常用命令

# ---- 静态检查(不需要 DevEco,几秒钟出结果,提 PR 前先跑这个)----
node scripts/check-deps.mjs                 # 模块依赖图
bash scripts/check-banned-api.sh            # 禁用 API
node scripts/check-route-map.mjs            # 路由表
SKIP_BUILD=1 bash scripts/ci.sh             # 上面三个一起跑

# ---- 构建(需要 hvigorw,即 DevEco 环境)----
./hvigorw clean --no-daemon                 # 清理增量缓存
./hvigorw --sync --no-daemon                # 解析模块图与依赖
./hvigorw assembleHap --mode module -p product=default -p buildMode=debug
./hvigorw assembleApp -p product=default -p buildMode=release -p signingConfig=default
bash scripts/ci.sh                          # 完整流水线(检查 + debug 包 + 归档)
bash scripts/ci.sh --release                # 额外产出 release 包(需签名环境变量)

# ---- 辅助 ----
bash scripts/check-banned-api.sh --list-patterns   # 查看启用中的规则
node scripts/inject-version.mjs --help             # 版本注入用法
bash scripts/setup-signing.sh --help               # 签名配置用法

产物位置:entry/build/.../outputs/default/*.hap(debug)、 build/outputs/default/*.app(release);scripts/ci.sh 会把它们统一归档到 build-artifacts/。


5. 签名说明

签名材料(.cer / .p7b / .p12)必须由开发者自行申请, 且已被 .gitignore 忽略。口令(keyPwd / storePassword)只应存在于两个地方: 开发者的 DevEco 配置、CI 的 Secret 存储。

  • 完整说明:signature/README.md —— 三种配置方式、签名失败的排查顺序;
  • 构建流水线与环境变量:docs/11/08-build-ci.md;
  • 生成不含口令的配置片段:bash scripts/setup-signing.sh。

工程的 build-profile.json5 刻意留了 "signingConfigs": [], 就是为了让"谁在本地填了签名配置"这件事显式地体现为一次文件改动,从而不会误提交。


6. 构建脚本与 CI 门禁

scripts/ 下的六个脚本按"守什么"分类:

脚本 守住的规则 为什么需要它
check-deps.mjs 模块依赖方向、无依赖环、lib_base 零依赖、依赖已注册 模块化规则写在文档里没有约束力;一次赶进度的横向 import 就能把两个 feature 焊死,而编译、打包、运行都不报错
check-banned-api.sh 无 @ohos.* 分命名空间导入、无 @ohos.router/@ohos.net.http、无 PersistentStorage、无 V1 状态装饰器 混用 V1/V2 状态机制是"莫名其妙不刷新"的最大来源;V1 装饰器与 V2 只差一个字符,人眼在 Review 里抓不住
check-route-map.mjs pageSourceFile 文件真实存在、buildFunction 以 RouteBuilder 结尾、路由名不重复 路由表全是字符串,没有任何编译期保护;拼错的现象是白屏(无异常、无日志、无堆栈)
inject-version.mjs 版本号与 EnvConfig.IS_RELEASE 由构建流程写入 IS_RELEASE 是编译期常量,控制日志级别与 Mock 的编译期剪裁。靠"开发者记得改"必然出现 release 包残留 debug 分支
setup-signing.sh 签名配置生成,口令绝不落盘 手改 build-profile.json5 补签名,是"密钥被提交"的最短路径
ci.sh 把上面五件事按固定顺序串起来 顺序本身有价值,见下文

ci.sh 的阶段顺序(顺序本身就是设计)

[1] hvigorw clean        ← 清掉增量缓存,让"构建成功"只反映当前源码
[2] hvigorw sync         ← 解析模块图与依赖,配置错误在这一步先暴露
[3] check-deps.mjs       ┐
[4] check-banned-api.sh  ├ 三项静态检查,几秒钟,全部在编译之前
[5] check-route-map.mjs  ┘
[6] assembleHap (debug)  ← 不需要签名,QA 与自测的日常产物
[7] inject-version.mjs   ← 仅 release:注入版本号与 IS_RELEASE
[8] assembleApp (release)← 仅 release:签名从环境变量注入,缺任一直接失败
[9] 归档到 build-artifacts/

两条关键理由:

  1. 静态检查全部前置。它们加起来几秒钟,而一次 assembleHap 要几十秒到几分钟。 把便宜且"必然确定"的检查放前面,反馈时间从分钟级压到秒级, 而且失败原因更精确("依赖违规"和"编译错误"的排查路径完全不同)。
  2. debug 包在前、release 包在后。debug 不需要签名、更快、失败率更低; release 需要签名材料与版本注入,证书匹配、Profile 有效期都可能出问题。 把高失败率的步骤放后面,让大部分错误在便宜阶段被拦掉。

ci.sh 用 set -e + ERR trap,任一阶段失败即中断并打印阶段名与耗时 —— set -e 只会静默退出,日志里看到的是"最后一条正常输出之后什么都没了", 那是最难用的失败信息。


7. 关于 rcp 适配点(重要)

整个工程只有 lib_network/src/main/ets/rcp/RcpClient.ets 中的 RcpTransport 直接调用 rcp API。

这一点可以用一行命令验证:

grep -rln "@kit.RemoteCommunicationKit" --include=*.ets .
# → ./lib_network/src/main/ets/rcp/RcpClient.ets

所以:如果 SDK 版本导致 rcp 的签名差异,只需要改这一个类。

设计上是刻意这么做的:

  • rcp 的接口在不同 SDK 版本之间有过微调(方法名、参数形状、异常类型);
  • 业务层与上层拦截器只面对 lib_network/src/main/ets/model/HttpTypes.ets 里定义的 自有契约(RequestOptions / NetResponse / RetryPolicy / CancelToken), 完全看不到 rcp 的类型;
  • 于是升级 SDK 时,改动面被限制在一个文件里,而不是散落在所有发请求的地方。

RcpTransport 里还写明了两个最容易踩的坑:

  1. headers 必须每次请求传入,不能写进 Session 配置。 Session 复用时 SessionConfiguration 的 headers 会被固化, token 刷新之后仍会发送旧头 —— 这是 rcp 最隐蔽的坑;
  2. 异常归一化。rcp 抛出的错误要在这里一次性翻译成 AppError, 否则 ErrorCode 体系会被击穿,UI 层拿不到稳定的错误码。

8. 与教程章节的对照

教程章节 对应工程内容 关键文件
00-before-you-start.md 环境准备与工程定位 本 README、scripts/check-deps.mjs 的前置说明
01-environment-toolchain.md 工具链、build-profile.json5、oh-package.json5、依赖形态(HAR/HSP) 根 build-profile.json5、各模块五件套
02-arkts-core.md 严格模式、类型系统、错误模型、显式契约 lib_base:Result / AppError / JsonReader
03-arkui-layout.md 布局、组件封装、主题与断点 lib_ui;feature_message/view/MessageListPage.ets
04-routing-lifecycle.md Navigation、NavPathStack、route_map.json、深链、onNewWant entry/pages/Index.ets、lib_router、feature_*/router/*.ets
05-state-management.md V2 状态管理:@ObservedV2/@Trace/@Computed/@Monitor、LazyForEach 数据源 feature_message/viewmodel/、feature_collab/viewmodel/、entry/state/WindowState.ets
06-persistence-network.md 存储选型(Preferences/RDB/KVStore/文件)、rcp、拦截器链、重试与取消、Mock lib_storage、lib_network、feature_message/repo/、feature_collab/repo/
07-distributed.md 设备发现、分布式同步、逐字段合并与冲突处理、应用级订阅 lib_distributed、feature_collab/(尤其 service/DraftSyncService.ets 的类注释)
08-performance.md 列表性能、冷启动分段、打点、埋点缓冲 lib_log:Trace / Analytics;entry/init/AppInitializer.ets 的同步段/延迟段切分
09-modularization.md 模块划分、依赖规则、稳定依赖原则、依赖门禁 scripts/check-deps.mjs、lib_base/Index.ets 的导出纪律
10-testing-release.md 单元测试、签名、打包 signature/README.md、scripts/setup-signing.sh、scripts/inject-version.mjs
11/08-build-ci.md CI 流水线与产物流转 scripts/ci.sh
appendix/A-v1-v2-cheatsheet.md V1 → V2 装饰器对照 scripts/check-banned-api.sh 的 v1-state-decorator 规则
appendix/B-deprecated-and-gates.md 废弃 API 与版本门槛 lib_base/gate/CapabilityGate.ets、scripts/check-banned-api.sh
appendix/C-glossary.md 术语表(HAR/HSP/HAP、软总线、KVStore、rcp…) —
appendix/D-mock-server.md Mock 的三种方案对比与规则表设计 lib_network/mock/MockRegistry.ets、feature_*/mock/*MockRules.ets

四个 feature 模块的实现位置

模块 内容 入口文件
feature_login 登录、会话建立、令牌刷新触发 Index.ets → src/main/ets/view/
feature_approval 审批列表 / 详情 / 发起提交(幂等键) Index.ets → src/main/ets/view/
feature_message 消息列表、已读状态、本地轻量缓存、通知发布与点击跳转 src/main/ets/view/MessageListPage.ets
feature_collab 设备发现、草稿同步、冲突裁决 src/main/ets/view/CollabDevicePage.ets

9. 工程约定(提交前自查)

必须

  • 4 空格缩进;公开签名显式类型标注;公开 API 禁止 any;
  • 资源走 $r(),尺寸用 vp / fp;
  • 状态管理一律 V2;跨模块跳转一律走 lib_router 的路由名常量;
  • 模块五件套与 lib_base / lib_network 保持一致: harTasks / module.json5 里 "type": "har" / 无 abilities 段;
  • 需要权限的模块在 requestPermissions 里声明并给 reason(资源引用,不是裸字符串);
  • Index.ets 禁止 export * —— 只导出稳定契约(页面 / 路由构建函数 / Mock 规则)。

禁止

  • @ohos.* 分命名空间导入(一律 @kit.*);
  • @ohos.router(用 lib_router)、@ohos.net.http(用 lib_network 的 rcp 封装);
  • FA 模型;
  • V1 状态装饰器(@State / @Prop / @Link / @Observed / @ObjectLink / @Provide / @Consume / @Watch / @StorageLink / @StorageProp / @LocalStorageLink / @LocalStorageProp / @Track);
  • PersistentStorage(用 lib_storage);
  • 手改 AppScope/app.json5 的版本号或 AppConst.ets 的 IS_RELEASE(用 scripts/inject-version.mjs);
  • 提交 signature/ 下的任何签名材料或口令。

前四项、以及最后两项都有脚本兜底(见第 6 节)。

关于根 oh-package.json5 的 devDependencies

根 oh-package.json5 只声明了 @ohos/hypium 与 @ohos/hamock(单元测试用),本工程刻意未改动它:

scripts/ 下的全部脚本零 npm 依赖,只使用 Node 内置模块 (node:fs / node:path / node:process / node:url)与 bash 内置能力。

为什么坚持零依赖:

  1. 这些脚本要在 CI 的最小镜像里跑。每多一个依赖,就要多一步 npm install, 而"构建前的检查"卡在装依赖上是很难接受的成本;
  2. 校验脚本本身如果依赖了一个被校验的工程才能提供的包,就会形成循环依赖;
  3. 读 build-profile.json5 / oh-package.json5 需要处理注释与尾逗号(JSON5), 为此引入 json5 包是最常见的诱惑 —— 但代价是脚本生命周期绑定了第三方包的版本。 本工程自己实现了位置保持的注释剥离(见 check-deps.mjs 的 stripComments), 注释与尾逗号处理在 30 行内完成,且能顺便保护字符串里的 //(如 https://)。

因此:不需要给根 oh-package.json5 添加任何 devDependencies。


10. 已知限制与后续方向

限制 现状 后续
单元测试 工程已声明 hypium,示例测试待补 见 docs/10-testing-release.md
埋点上报 ConsoleUploader 只打印日志 换成真实通道实现 EventUploader 接口即可,调用点不动
草稿远端同步 本工程走服务端中转(见 feature_collab/repo/DraftRepository.ets 的说明) 纯软总线方案(KVStore + DataObject)见 docs/07-distributed.md
协同的设备筛选 DeviceHub 返回同账号全部在线设备 可加"按能力筛选"(是否有输入法、屏幕尺寸)
服务端 server/ 是可选假后端 仅用于脱离 Mock 时联调

11. 快速上手(TL;DR)

# 只想跑静态检查(不需要 DevEco)
SKIP_BUILD=1 bash scripts/ci.sh

# 想看依赖图长什么样
node scripts/check-deps.mjs

# 想配签名
cat signature/README.md

用 DevEco Studio 打开工程 → Sync → 运行 entry。默认 Mock 模式,无需后端。

📖 signature/ —— 签名材料目录 README.md

本目录下的 .cer / .p7b / .p12(含 .p12.bak)一律不进版本库。 根目录的 .gitignore 已经忽略它们,请不要用 git add -f 强行加入。

为什么这些文件必须"开发者各自申请"

HarmonyOS 应用要装到真机(或用 release 模式出包)就必须签名。签名的三个输入 不是可以随代码一起分发的东西:

文件 是什么 从哪来 泄漏的后果
*.cer 应用证书 AppGallery Connect → 证书、App ID 和 Profile → 申请证书 配合私钥可伪造应用签名
*.p7b 签名 Profile(含包名、证书指纹、ACL 权限) AppGallery Connect → Profile 管理 → 申请调试/发布 Profile 可被用来给同名包签名并安装
*.p12 密钥库(含私钥) DevEco Studio 首次申请证书时生成并保存 私钥一旦泄漏等同于应用身份被接管
*.p12.bak 密钥库备份 自行备份 同上

私钥丢失不可找回。 丢了只能重新申请证书,并存量用户的覆盖升级会断链。 所以密钥库一定要在安全的地方单独备份(本目录里的 .bak 只是为了方便本地开发, 它同样被 gitignore 忽略)。

本地开发怎么配签名(三种方式)

A. 用 DevEco Studio(推荐)

File > Project Structure > Signing Configs → 勾选自动签名或手工填写 → IDE 会把配置直接写进根 build-profile.json5。

注意:写完之后 build-profile.json5 会变成"已修改"状态。 不要提交这份改动 —— 提交上去等于把密钥库路径与加密口令一起入库。 (本工程的 build-profile.json5 刻意留了 "signingConfigs": [],就是为了让这一点显式。)

B. 用脚本生成片段(不含口令)

export APP_CERT_FILE=/abs/path/to/officeflow.cer
export PROFILE_FILE=/abs/path/to/officeflow.p7b
export KEYSTORE_FILE=/abs/path/to/officeflow.p12
export KEY_ALIAS=officeflow
bash scripts/setup-signing.sh

生成 build-profile.signing.local.json5(该文件也已被忽略)。 把它里面的 signingConfigs 数组合并进根 build-profile.json5, 口令字段需要你自己补 —— 脚本刻意不写口令,因为"写进去就会被提交"是个只差一次的赌注。

C. CI(口令完全不落盘)

口令从 CI 的 Secret 存储以环境变量注入,通过 hvigor 命令行参数传入:

./hvigorw assembleApp -p buildMode=release -p signingConfig=default \
  -p appCertFile="$APP_CERT_FILE" \
  -p profileFile="$PROFILE_FILE" \
  -p keystoreFile="$KEYSTORE_FILE" \
  -p keyAlias="$KEY_ALIAS" \
  -p keyPwd="$KEY_PWD" \
  -p keystorePwd="$KEYSTORE_PWD"

scripts/ci.sh --release 已经封装了这条命令,并在缺少任一环境变量时直接失败—— "构建成功但没有签名"的包比构建失败更危险,它会被误当成可用产物发出去。

排查签名失败

签名报错通常很含糊,按这个顺序查最快:

  1. 路径:三个文件路径是不是相对路径 / 写错了。scripts/setup-signing.sh 会先帮你校验存在性;
  2. Profile 有效期:调试 Profile 有有效期(通常 90 天),过期后必须重新申请;
  3. 包名一致性:AppScope/app.json5 的 bundleName 必须与 Profile 里登记的包名一致;
  4. 证书指纹:Profile 绑定的证书指纹必须与 .cer 匹配 —— 换了证书就要重新申请 Profile;
  5. 签名算法:本工程使用 ECC 密钥(SHA256withECDSA)。若你用的是 RSA 证书,改成 SHA256withRSA。

更完整的环境搭建与流水线说明见 docs/11/08-build-ci.md。

📖 server — 本地假后端(可选) README.md

它解决什么问题

工程默认走拦截器级 Mock(lib_network 的 MockInterceptor + 各模块的 *MockRules), 优点是快、不出网、不需要后端。但它有两件事验证不了:

  1. 真实 HTTP 行为 —— 状态码、重定向、超时、大响应体分片、连接复用;
  2. 两端契约一致性 —— 拦截器 Mock 的响应也是我们自己写的,写错了两边一起错。

mock-server.js 就是补这一课:它是一个零依赖的 Node HTTP 服务, 用与客户端同一份契约(统一响应包装 code/message/data/traceId)返回数据, 但走的是真实网络栈。

启动

node server/mock-server.js                  # 默认端口 3000
PORT=8080 node server/mock-server.js        # 端口被占用时换一个

3000 是很多本地服务(含 VS Code 插件、其它示例工程)的默认端口, 起不来时先换端口,而不是怀疑脚本。

怎么让应用连上它

  1. 把 lib_base/src/main/ets/const/AppConst.ets 的 EnvConfig.CURRENT 改为 Env.DEV;
  2. 把 EnvConfig.baseUrl() 中 Env.DEV 的地址改成 http://<你的电脑局域网IP>:3000;
  3. 不要写 localhost / 127.0.0.1 —— 那是设备自己的回环地址,不是你的电脑。

如果确实想用回环地址(例如通过端口转发):

hdc rport tcp:3000 tcp:3000
# 之后应用侧可以用 127.0.0.1:3000

演示数据

项 值
登录账号 demo
登录密码 demo1234
审批单总数 120 条(另含 5 条边界数据)
消息总数 57 条(未读混排)
待审批数 13

接口清单

方法 路径 说明
POST /auth/login 密码错返回 HTTP 200 + 业务码 40000
POST /auth/refresh refreshToken 需以 rt_ 开头,否则 HTTP 401
POST /auth/logout 总是成功
GET /user/profile 用户信息
GET /approval/list 分页 + status / keyword 过滤
GET /approval/list/pending 更长前缀,用于演示「最长前缀优先」
GET /approval/detail?id= 支持 __401__ / __500__ / __notfound__ / __drift__
POST /approval/submit 读 Idempotency-Key 头;标题含「重复」或键含 dup 时返回 40901
POST /approval/withdraw 回显撤回后快照
POST /approval/decide id=conflict 时返回 40901
GET /approval/count 角标统计
GET /message/list 分页
GET /message/list/empty 空列表
GET /message/unreadCount 未读数
POST /message/read / /message/readAll 标记已读
GET /collab/devices 2 台设备(1 在线 1 离线)
GET /collab/devices/none 零设备(未组网/不同账号)
POST /collab/draft/push 回显服务端归一化后的记录
GET /collab/draft/pull 返回与本地时钟相同的字段 → 可触发冲突裁决

魔法 keyword(GET /approval/list)

keyword 效果
__empty__ 空列表(total=0)
__overflow__ 分页越界(items=[]、hasMore=false)
__edge__ 仅返回 5 条边界数据

__edge__ 的 5 条数据是刻意构造的,用于验证客户端解析的健壮性:

id 构造的问题 期望的客户端行为
AP90001 缺 applicantDept / amount 用默认值渲染,不崩溃,记一条解析 WARN
AP90002 amount 是字符串 "1280.50"、urgent 是 1、时间是 ISO 字符串 JsonReader 兼容转换
AP90003 标题与正文超长 列表项截断;长文本不参与局部刷新
AP90004 status 是客户端未支持的枚举值 降级显示为「未知状态」而非空白
AP90005(id 为空) 主键缺失 整条丢弃,不渲染幽灵行

故障注入

任意接口追加 ?simulate=:

值 效果 用于验证
timeout 不响应,让客户端超时 NET_TIMEOUT、重试与退避
500 HTTP 500 服务端错误归一化、可重试判定
401 HTTP 401 单飞刷新 + 重放 + 强制登出
403 HTTP 403 无权限提示
404 HTTP 404 资源不存在
429 HTTP 429 限流 + 重试
slow 延迟 3 秒 骨架屏、加载态、慢请求告警
malformed 返回非法 JSON PARSE_FAILED 分支
raw-array data 段直接是数组 非统一包装响应的兼容

示例:

curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:3000/approval/list?simulate=500"   # 500
curl -s "http://127.0.0.1:3000/approval/list?keyword=__edge__" | head -c 200
curl -s -X POST http://127.0.0.1:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"account":"demo","password":"demo1234"}'

一个刻意的设计:业务失败用 HTTP 200

/auth/login 密码错误时返回的是 HTTP 200 + 业务码 40000,而不是 HTTP 400。 原因是客户端的 HttpStatusMapper 会先把非 2xx 状态归一化成通用错误, HTTP 400 会让业务码信息丢失,UI 只能给出「请求失败」这种无用提示。 所以业务失败走业务码,传输/鉴权失败才用 HTTP 状态码 —— 这条约定必须在前后端之间说清。

边界说明

  • 数据全部在内存中,重启即复位(没有持久化,也没有并发保护);
  • 不校验 Authorization 头(真后端要校验,这里只用于跑通链路);
  • 不模拟网络抖动与连接中断,需要这些场景请用 ?simulate=timeout。

相关文档

  • 拦截器级 Mock 的规则表设计 → ../../docs/appendix/D-mock-server.md
  • 网络层与错误处理 → ../../docs/11/02-network-result-error.md
  • 审批链路的数据流与缓存策略 → ../../docs/11/05-approval-flow.md

📄 第 0 章 学习准备 00-before-you-start.md

这一章不写代码,只解决三件事:把版本名词对齐、把全书术语统一、确认你的环境能跑起来。跳过它的人,通常会在第 1 章或第 6 章被版本问题绊住。


0.0 本章目标

  1. 能准确说清 HarmonyOS 系统版本 / 开发套件版本 / API 版本 / SDK 版本 四者的关系,不再被 6.0.0(20) 和 26.0.0 两种写法绕晕。
  2. 能独立判断"我手上的教程/博客内容能不能用",依据是其编写时的 API 版本。
  3. 能完成本机环境自检,产出一份可归档的版本清单。
  4. 能说清本教程的三种标注(术语 / 版本门槛 / 误区)与统一的源码组织方式。
  5. 能定位官方文档中真正权威的入口,而不是靠搜索博客。

0.1 版本体系:四个容易混淆的概念

0.1.1 它们分别是什么

概念 含义 谁决定 例子
系统版本 用户手机上跑的操作系统版本 华为发布节奏 HarmonyOS 7.0
开发套件版本 官方打包发布的一整套(IDE + SDK + 命令行工具) 与系统版本同期 26.0.0
API 版本 开发者可调用的接口集合版本 与开发套件同步 26.0.0
SDK 实现上述 API 的库与编译工具链,随 DevEco Studio 预置 与开发套件同步 HarmonyOS SDK 26.0.0

术语:API 版本 ≠ 系统版本。一个 API 版本对应一代系统能力,但同一代系统在不同机型上可能推送节奏不同。判断"某个 API 能不能用",看 API 版本与设备实际系统,不看营销名称。

术语:开发套件(Developer Suite)是官方对"IDE + SDK + 命令行工具"这个整体的称呼。教程里说"升级到 26.0.0",指的是把这三样一起升到 26.0.0,而不是只换 IDE。

0.1.2 版本号格式的断代(关键)

从 API 26.0.0 起,版本号格式发生了一次断代式变更:

时代 格式 例子 括号里的数字
API 26 之前 X.Y.Z(N) 6.0.0(20) N 是 OpenHarmony 底座的 API Level
API 26 起 X.Y.Z 26.0.0 不再有括号

新格式的语义:

  • X(主版本):大量新功能与重要变更,可能包含 API 修改,需要适配。26.0.0 就是一次主版本。
  • Y(次版本):新增功能,原则上向后兼容。
  • Z(修订版本):修问题与小幅改进,向后兼容。

0.1.3 本教程的版本策略

采用主版本 = 26、兼容下限 = 20 的双轨策略:

编写基线(targetSdkVersion): 26.0.0   ← 用得上新能力,且能对上官方最新文档
兼容下限(compatibleSdkVersion): 6.0.0(20)  ← 覆盖 HarmonyOS 6.0 及以上主力设备

这意味着:全书主线写法都必须在 API 20 设备上能跑。凡是用到 API 21~26 新增能力的知识点,都会标 > 版本门槛:API 26 新增,并给出降级方案。

术语:兼容下限(compatibleSdkVersion)是应用声明"最低支持到哪个 SDK"。低于此版本的设备无法安装。它不决定编译期能否引用较新 API——引用超出下限的 API 是否报错,由 compileSdkVersion 与 IDE 检查决定;真正的失败发生在低版本设备的运行期。

0.1.4 如何判断一份外部资料是否可用

看三样,缺一不可:

  1. API 版本(不是系统营销名,而是 API 号)。低于 API 12 的资料大概率是 FA 模型或过渡期写法,默认放弃。
  2. 是否使用 @ohos.* 分命名空间导入。这是最有效的"年代指纹"——import http from '@ohos.net.http' 说明是老写法。
  3. 是否出现 @State / @Prop / @Link。出现即为 V1 状态管理,可作参考但不要照抄到新项目。

0.2 全书术语统一

以下术语全书含义固定,不再重复解释(完整版见 附录 C):

术语 一句话定义
ArkTS HarmonyOS 应用开发语言,TypeScript 的静态化子集,去掉了动态特性以获得确定性与性能
ArkUI 声明式 UI 框架,UI 是状态的函数(UI = f(state))
Stage 模型 当前应用模型。核心是 UIAbility + WindowStage,替代已废弃的 FA 模型
HAP 可安装的应用包,一个应用可有多个 HAP
HAR 静态共享库,随调用方一起打包,可独立发布到 ohpm
HSP 动态共享库,多 HAP 运行时共享一份代码与资源,可减小包体
Ability 应用对外能力的抽象单元;UIAbility 是带界面的那类
Context 应用/模块的上下文,是访问资源、沙箱、系统能力的入口;生命周期长于页面,慎持有
断点 窗口宽度的档位(xs/sm/md/lg/xl/xxl 六档),多端适配的判断依据
拦截器 在请求发出前/响应返回后插入的处理环节,用于注入鉴权、日志、错误映射、重试
Result 本教程的错误处理模型:用 Success / Failure 类判别替代异常穿越分层
Mock 模式 用本地假数据替换真实后端,使工程无后端也能跑通

0.3 环境自检清单

在进入第 1 章前,请确认:

  • [ ] DevEco Studio 版本为 26.0.0 Release(Help > About DevEco Studio 可查)
  • [ ] HarmonyOS SDK 版本与 IDE 匹配(Help > About HarmonyOS SDK)
  • [ ] SDK Platforms 中已勾选 HarmonyOS SDK(API 26) 与至少一个设备类型(Phone)
  • [ ] SDK Tools 中已安装 Command Line Tools、Toolchains、Emulator
  • [ ] 本机 Node.js 版本满足 IDE 要求(DevEco 26 自带受管 Node,不要用系统 Node 覆盖)
  • [ ] ohpm / hvigorw 可在终端执行(路径由 IDE 注入,或用 IDE 内置终端)
  • [ ] 已注册并登录华为开发者账号(后续签名与上架必须,建议现在办)
  • [ ] 本地具备可用的模拟器镜像或真机(hdc list targets 能看到设备)
  • [ ] 工程所在路径不含中文与空格(这是新手最常踩的构建失败原因)

把自检结果记下来,后续章节遇到"本地能跑、别人跑不了"时,第一件事就是对比这份清单。

自检脚本

# 建议在 DevEco Studio 的内置终端执行,确保环境变量已注入
node -v
ohpm -v
hvigorw -v
hdc list targets

把输出贴到你的项目 README 里。CI 上跑出不一致结果时,这份记录能省下几个小时。


0.4 本教程的三种标注

> 术语

给出概念的一句话定义。只在首次出现时标注,完整定义收在附录 C。

> 版本门槛

形如:

版本门槛:API 26 新增。在兼容下限 20 上不可用,须用 deviceInfo.sdkApiVersion 分档或 CapabilityGate(1.5.3)判定后降级;canIUse 仅适用于 SystemCapability 维度的能力缺失场景。

看到这个标注,就意味着这段代码不能无条件写进业务逻辑。

> 误区

形如:

误区:错误写法 → 正确写法,外加一句"为什么错"。

每章至少 3 条,全部来自真实踩坑,不写凑数的常识。


0.5 源码组织约定

第 11 章工程 code/office-flow/ 内的模块命名与依赖方向,全书统一:

entry(HAP,唯一可安装入口)
  └── feature_*(HAR,业务特性)
        └── lib_*(HAR,能力库,不感知业务)
              └── lib_base(HAR,最底层契约与工具)
lib_ui(HSP,公共组件与主题,只依赖 lib_base)

命名约定:

对象 约定 例子
页面文件 XxxPage.ets ApprovalListPage.ets
组件文件 名词,无后缀 ApprovalCard.ets
ViewModel XxxViewModel ApprovalListViewModel
仓储 XxxRepository ApprovalRepository
数据源 XxxRemoteDs / XxxLocalDs ApprovalRemoteDs
路由名常量 XxxRoute ApprovalRoute.LIST
导出面 每个 HAR 只有根 Index.ets feature_approval/Index.ets

代码风格:4 空格缩进、显式类型标注、const 优先、公开签名禁止 any、资源一律 $r() 引用。


0.6 权威文档入口

优先顺序如下,遇到分歧以第一项为准:

  1. 官方文档(developer.huawei.com/consumer/cn/doc/) — 指南 + API 参考
  2. 版本说明与升级适配指导 — "向 26.0.0 升级"这类文档,是判断行为变更的唯一权威
  3. DevEco Studio 变更说明 — 工具链行为变化(构建、预览、检查项)
  4. 社区文章 — 只用来找思路与踩坑经验,代码一律回官方文档核对

0.7 本章小结

记住三句话就够用:

  1. API 版本决定能力边界,compatibleSdkVersion 决定你能不能编译通过,targetSdkVersion 决定系统按哪套行为对待你。
  2. 代码特征比发布时间更可靠地指示新旧:@ohos.* 与 @State 是两个最灵敏的年代指纹。
  3. 主线写法必须在 API 20 上能跑,新能力一律走标注 + 降级。

0.8 延伸阅读

  • 官方文档 → 版本说明 → HarmonyOS 开发套件版本列表(确认当前最新版与配套 IDE)
  • 官方文档 → 版本说明 → 应用升级适配指导(向 26.0.0 升级)(行为变更与适配清单)
  • 官方文档 → 应用开发导读 → HarmonyOS 应用开发基础知识(Stage 模型整体视图)
  • 本教程 README 的「版本基线」与「章节路线图」

0.9 常见误区

误区 1:以为版本号格式变更只是"改个写法"

错误写法

"compatibleSdkVersion": "26.0.0"   // 或手敲 "20",追求"简洁"

正确写法

"compatibleSdkVersion": "6.0.0(20)"   // 让 DevEco Studio 生成后再改数值,不手敲字符串

为什么错:配置里同时存在两种格式的版本号,且它们不能互转。compatibleSdkVersion 指向 API 20 时就必须写 6.0.0(20)——"20" 或 "26.0.0" 都无法表达底座号,会导致安装下限解析失败。

误区 2:用"文章发布时间"判断资料新旧

错误写法

只看"发布于 2026 年"就照抄示例代码。

正确写法

看代码特征:@ohos.* 分命名空间导入、@State / @Prop / @Link 出现即为旧写法(见 0.1.4)。

为什么错:发布时间近但内容是旧代码转载的博客非常多。看代码特征,不看发布时间。

误区 3:用系统安装的 Node.js 去跑 hvigorw

错误写法

在系统终端直接执行 hvigorw / ohpm 命令做构建。

正确写法

项目命令一律在 IDE 内置终端执行;CI 里显式使用 Command Line Tools 附带的 Node。

为什么错:DevEco Studio 自带受管 Node,版本与构建工具链严格绑定;用系统 Node 常导致 EBADENGINE 或构建工具版本不匹配。

误区 4:用搜索引擎解决"API 是否存在"

错误写法

搜"xxx API 能不能用"并直接采信搜索结果。

正确写法

只查官方 API 参考与版本说明(见 0.6 的优先顺序)。

为什么错:搜索结果的时效性不可控,且常混合多个版本。判断 API 是否可用、在哪个版本可用、参数是什么,只看官方 API 参考。


0.10 实践任务

任务:完成环境自检并产出可归档的版本清单

任务描述

  1. 按 0.3 的环境自检清单逐项检查本机环境(IDE / SDK / 设备类型 / 命令行工具 / Node / 开发者账号 / 模拟器或真机 / 工程路径)。
  2. 在 DevEco Studio 内置终端执行 0.3 的自检脚本,完整留存四条命令的输出。
  3. 把检查结果整理成一份环境版本清单,归档到你的项目 README(或团队知识库)。

验收 checklist

  • [ ] 0.3 清单每一项都已勾选,或写明不满足的原因与补救计划
  • [ ] node -v / ohpm -v / hvigorw -v / hdc list targets 的输出已原样留存
  • [ ] 清单含 IDE / SDK / Node / ohpm / hvigor / 设备六项版本
  • [ ] 工程所在路径不含中文与空格(截图或路径记录为证)
  • [ ] 清单已提交到项目 README 或团队知识库

交付物清单

交付物 形式 验收点
环境版本清单 Markdown 六项版本齐全,与 0.3 清单一一对应
自检脚本输出 文本留存 四条命令原样输出,他人可复现比对

下一章:第 1 章 环境搭建与工具链

📄 第 11 章 项目总览 00-overview.md

前 10 章讲的是"每一块怎么做对",这一章开始讲"把它们拼起来之后还会出什么问题"。工程 code/office-flow/ 是一个可直接打开、可直接跑的多模块项目,覆盖登录鉴权 / 审批流 / 消息通知 / 多端协同四条链路。本章先给出它的定位、范围与取舍记录,再给出可执行的验收方法——读完你应当能自己判断"这个工程能给我什么、不能给我什么"。


0.0 本章目标

  1. 能说清 OfficeFlow 的功能范围边界:哪些是完整实现、哪些是骨架、哪些刻意留空,并能解释每一处"留空"的原因。
  2. 能复述全部 14 条关键选型决策的「决策 / 备选 / 理由 / 代价」,知道每条决策换来的是什么、付出的是什么。
  3. 能跑通工程:DevEco Studio 26.0.0 打开 → sync → 运行 entry,默认 Mock 模式下完成一次登录、看一次列表、触发一次错误分支。
  4. 能切换运行环境:从拦截器级 Mock 切到 server/mock-server.js 提供的本地真实 HTTP 后端,并说清两种模式各自能验证什么。
  5. 能验收:用本章的清单逐项打勾,把"看起来在跑"变成"有证据地能跑"。

0.1 项目定位与功能范围

0.1.1 它是什么

OfficeFlow 是一个带取舍说明的参考实现,不是一组 Hello World。它的价值在于三件事:

它提供 具体形态
一套无环的 13 模块骨架 12 个 HAR/HSP + 1 个 HAP,依赖方向由脚本强制(见 scripts/check-deps.mjs)
一套可复制的分层与错误模型 View → ViewModel → Repository → DataSource,全链路返回 Result<T>,异常不出数据层
一套可复现的错误与边界演练入口 Mock 规则支持按输入触发 401 / 40100 / 40901 / 503 / 字段缺失,无需改代码即可演练分支

术语:拦截器级 Mock ── 本工程默认的假数据方案。它挂在 lib_network 的拦截器链第 3 位,命中后连网络都不出,直接构造响应。与"起一个假后端"是两件不同的事,见 0.2 第 7 条。

0.1.2 四条主链路:诚实的实现程度标注

工程 README 声称覆盖四条链路,但覆盖度并不相同。下表是逐项核对代码后的结论,请以它为准:

链路 实现程度 依据(真实文件) 缺什么
登录鉴权 ✅ 完整 lib_auth/repo/AuthRepository.ets(真实经 HttpApi 发请求)、token/TokenStore.ets(Preferences 落盘)、token/TokenRefresher.ets(单飞刷新)、session/SessionStore.ets + session/LogoutBus.ets、lib_router/interceptor/RouteInterceptor.ets(登录拦截) 无。仅账号体系是单一的"账号密码",无验证码 / 三方登录
审批流 ✅ 完整 feature_approval:列表(SWR + LazyForEach)、详情、发起(幂等键)、撤回、审批(通过 / 驳回);datasource/ApprovalLocalDs.ets 走 RDB 缓存 无流程编排引擎(不支持多级会签 / 条件分支),状态机只有 5 个态
消息通知 ⚠️ 链路完整,通道需真机确认 feature_message/view/MessageListPage.ets、repo/MessageRepository.ets、service/NotificationService.ets;已读状态本地维护 通知授权与横幅展示必须在真机/模拟器上人工确认;推送(远程推送)不在范围内
多端协同 ⚠️ 完整但需两台真机 lib_distributed:device/DeviceHub.ets(设备发现)、sync/DraftSyncChannel.ets、merge/DraftMerge.ets(逐字段合并 + 冲突裁决);feature_collab 的草稿协同 UI 模拟器上分布式能力不可靠;DraftRepository 的草稿远端同步走服务端中转,不是纯软总线(README「已知限制」已声明)

0.1.3 刻意留空的部分(不要误以为是漏写)

项 现状 为什么刻意这样
签名配置 根 build-profile.json5 里 "signingConfigs": [] 让"谁在本地填了签名配置"显式体现为一次文件改动,避免误提交(见 signature/README.md)
单元测试 声明了 @ohos/hypium / @ohos/hamock,示例测试待补 测试的分层与写法在第 10 章;本工程聚焦架构,不重复堆测试样例
埋点上报通道 lib_log 的 Analytics 默认用 ConsoleUploader(只打印) 上报通道是业务相关实现;换掉 EventUploader 的实现即可,调用点一行不动
真实后端 server/mock-server.js 是零依赖假后端 本章要验证的是"契约与错误处理",不是真实业务数据
混淆 根 build-profile.json5 中 obfuscation.enable = false,各模块 buildOptionSet.release 中为 true 两处口径不一致,属待收敛项:以模块级配置为准,见 0.6 误区 3
多语言 仅 base + en_US 两份 string.json 多语言产物会放大包体,示例不需要

误区:把"示例工程"当成"可直接上线的业务底座"。它解决的是结构问题(分层、依赖、错误、可观测),不解决业务完备性(流程引擎、权限模型、风控)。照搬结构可以,照搬功能范围会漏需求。

0.1.4 关键文件速查

排障时最常"想不起来在哪"的十几个位置,先抄在这里:

你想要的 去哪
环境与域名切换 lib_base/src/main/ets/const/AppConst.ets(EnvConfig.CURRENT / baseUrl() / IS_RELEASE)
错误码与用户文案 lib_base/src/main/ets/error/AppError.ets(ErrorCode / AppErrorKind / AppError)、AppConst.ets 的 UserMessages
Result 与组合子 lib_base/src/main/ets/result/Result.ets、result/ResultExt.ets
请求契约(不含 rcp 类型) lib_network/src/main/ets/model/HttpTypes.ets(RequestOptions / NetResponse / RetryPolicy / CancelToken / BizCode)
唯一 rcp 适配点 lib_network/src/main/ets/rcp/RcpClient.ets(RcpTransport + RcpClient.execute)
拦截器与顺序 lib_network/src/main/ets/rcp/NetInterceptor.ets、rcp/interceptors/*.ets
Mock 规则表 lib_network/src/main/ets/mock/MockRegistry.ets + 各模块 mock/*MockRules.ets
日志 / 埋点 / 打点 / 脱敏 lib_log/src/main/ets/log/XLogger.ets、analytics/Analytics.ets、analytics/EventModel.ets、trace/Trace.ets、mask/Masker.ets
登录 / 令牌 / 会话 / 登出广播 lib_auth/src/main/ets/{repo, token, session}/(AuthRepository / TokenRefresher / TokenStore / SessionStore / LogoutBus)
路由名与参数契约 lib_router/src/main/ets/const/RouteNames.ets
唯一允许碰 NavPathStack 的地方 lib_router/src/main/ets/facade/Router.ets
断点状态(应用级唯一来源) entry/src/main/ets/state/WindowState.ets
启动编排(同步段 / 延迟段) entry/src/main/ets/init/AppInitializer.ets
路由表与分发 entry/src/main/resources/base/profile/route_map.json + entry/src/main/ets/pages/Index.ets 的 destBuilder
门禁脚本 scripts/check-deps.mjs、scripts/check-banned-api.sh、scripts/check-route-map.mjs、scripts/ci.sh
本地假后端 server/mock-server.js

0.2 技术选型决策记录(ADR)

格式:决策 / 备选 / 选择理由 / 代价。"代价"一栏是这份记录最重要的部分——没有代价的决策通常意味着还没想清楚。

# 决策 备选 选择理由 代价
1 状态管理全量 V2(@ComponentV2/@ObservedV2/@Trace/@Local/@Param/@Computed/@Monitor) V1(@State/@Prop/@Link/@Watch) V1 的 @Watch 只能挂在被装饰变量上、拿不到变化前后值;V2 的 @Monitor 能拿到 monitor.value()?.before/now,本工程的状态变迁埋点直接依赖它(ApprovalListViewModel.onStateChanged)。V2 还支持 @Computed 缓存派生值,列表页 pendingCount 靠它避免渲染期遍历 社区资料一半是 V1,必须靠 scripts/check-banned-api.sh 的 v1-state-decorator 规则兜住;团队成员要重新建立直觉
2 网络用 rcp(@kit.RemoteCommunicationKit) @ohos.net.http rcp 有 Session 概念(连接池与配置复用)、分阶段超时(connectMs/transferMs)、原生拦截器与 toJSON()/toString() 便捷方法;@ohos.net.http 每次请求独立、无会话与拦截器,鉴权注入与重试只能散落在调用处 rcp 在不同 SDK 版本间有过签名微调,必须设唯一适配点(本工程收敛到 RcpTransport,见第 2 条 ADR 与 2.1 节)
3 路由用 Navigation + NavPathStack + 系统路由表 @ohos.router @ohos.router 已废弃;Navigation 支持系统路由表(route_map.json)、NavDestination 生命周期(onShown/onHidden,曝光与停留埋点的正确口径就挂在这里)、以及"页面栈由根容器唯一持有"的确定性 路由表是纯字符串配置,无编译期保护,必须补 scripts/check-route-map.mjs 门禁;页面必须显式承载 NavDestination
4 lib_ui 用 HSP,其余库用 HAR 全部 HAR;或全部 HSP 公共组件与主题存在跨 HAP 共享的全局单例语义(主题状态、AppStorageV2 实例)。HAR 在多 HAP 场景下会被复制成多份实例,"单例"会退化成"每份一个",表现为状态时好时坏;HSP 在设备上只有一份 HSP 与依赖方必须同签名同 bundle,不能跨应用复用,也不便调试(改 HSP 要重装宿主);首启有一次加载开销。因此只在"被 ≥2 个 HAP 引用"时才值得
5 全局态用 AppStorageV2 承载(SessionStore / LogoutBus / WindowState) 静态单例字段;V1 AppStorage;@Provider/@Consumer 断点、会话与登出广播是跨页面、随窗口的状态,而页面经路由动态挂载(NavDestination 不在同一棵组件树上),@Provider 覆盖不到。AppStorageV2.connect(类, key, creator) 按"类型 + key"返回同一实例,且只观测 @Trace 字段,正好是这种场景 必须把状态建模成类(原始值无法被观测);key 手写会静默创建第二个实例,因此 key 一律走常量(SESSION_STORE_KEY / LOGOUT_BUS_KEY);connect 的 creator 只在首次执行,构造函数里不能放"必须执行一次"的副作用
6 错误用 Result 类判别,不用异常穿越分层 抛异常 + 各层 try/catch;{ ok: boolean, data?, error? } 联合类型 异常在签名上看不出来,调用方不 catch 也能编过;Result<T> 把"可能失败"变成编译期类型信息。类继承判别让 instanceof Success 分支内 value 必然存在,避免布尔判别下"访问了不存在的 data" 每个调用点都要显式处理两个分支(本工程用 Results.fold/valueOrUndefined/errorOrUndefined 收敛写法);异常只允许存在于 DataSource 内部并由拦截器翻译成 AppError
7 Mock 走拦截器链,不放假后端函数桩 直接替换 DataSource 实现;起一个本地 HTTP 假服务 拦截器级 Mock 在链的第 3 位(Auth → Logging → Mock → ErrorMap → Retry),它前面的鉴权注入与日志脱敏、它后面的错误映射与重试全部照常执行——即"开了 Mock 不会绕过整条链路"。直接替换 DataSource 会让拦截器逻辑完全不被覆盖 验证不了真实 HTTP 行为(状态码、重定向、超时、分片),也验证不了两端契约一致性。所以 server/mock-server.js 不是冗余,而是补这一课(见 0.3.5)
8 关闭 HAP 产品拆分,改用运行时断点 为手机 / 平板 / PC 各建一个 product(或各建一个 HAP) 形态差异(列数、分栏、字号)在本工程里都是能用运行时判定的东西:WindowState 监听窗口尺寸 → Breakpoints.ofWidth → bpOf() 取档位值。用 product 拆分会产出多份需要分别测试与上架的包,产物数量直接乘上测试成本 必须保证断点只有一个来源(EntryAbility.onWindowStageCreate 唯一注册 windowSizeChange),页面一律只读 WindowState;一旦有页面自己注册监听,就会出现"A 页分栏、B 页单栏"
9 登出广播用计数器,不用布尔 isLoggedOut: boolean 布尔是"电平信号",同一状态重复置位不产生变化:登出 → 置 true → 用户重新登录(若某条路径忘了复位)→ 新令牌再失效时置 true 不触发,表现为"卡在主页、请求全 401、界面毫无反应"。计数器是"边沿信号",每次登出都 +1,连续两次登出也一定触发,且不需要复位(复位本身就是漏 bug 的地方) 消费方必须监听具体属性路径(@Monitor('logoutBus.logoutEpoch')),V2 下路径上每一层都要可观测
10 签名材料绝不入库 把 .p12/.cer/.p7b 与口令一起提交,方便"开箱即用" 三者合起来就是完整的应用签名权:仓库可见者能签出与正式包签名一致的包用于分发或提审,且这类泄漏无法通过改密码挽回 每个开发者要自己申请证书(走 signature/README.md 与 scripts/setup-signing.sh);CI 必须注入 6 个环境变量,缺任一直接失败
11 幂等键在"用户点击提交"时生成一次,重试复用 每次发请求时生成;只靠服务端防重 若每次请求都生成,则"提交超时(服务端其实已成功)→ 用户点重试 → 带新键 → 服务端认为是第二次提交 → 两张审批单"。在点击时生成、重试复用同一个键,才真正覆盖"网络层重发"与"用户重复点击"两种场景;且客户端生成能保证键在网络重试前后不变 页面必须自行管理键的生命周期:成功了要清、内容改了要重置(内容变了就是另一个业务动作,复用旧键会被服务端按 40901 直接拒掉)
12 rcp 适配点收敛到单一类 在业务与各拦截器里直接调用 rcp API rcp 的接口在不同 SDK 版本间有过微调。收敛后,升级 SDK 的改动面是一个文件里的三个方法(见 2.1),而不是散落在所有发请求的地方;业务与拦截器只面对 lib_network/model/HttpTypes.ets 里的自有契约(RequestOptions/NetResponse/RetryPolicy/CancelToken),完全看不到 rcp 的类型 多一层间接(RcpTransport),排查"这个参数到底传给谁了"要多跳一次
13 重试策略分级下放,且默认不重试 全局统一重试;按 HTTP 方法判定 幂等性由业务语义决定而非 HTTP 方法:ApprovalRemoteDs 里 list/detail/count 显式声明 idempotent: true + defaultIdempotentRetry(),submit/withdraw/decide 显式声明 idempotent: false + noRetry()。RequestOptions.idempotent 默认 false,默认安全 每个新接口都要"选择"一次策略,忘记选等于不重试(安全但可能少一次自愈机会)
14 Index.ets 只导出稳定契约,禁止 export * export * 图省事 export * 会把内部实现一次性变成公共契约,之后任何内部重构都是破坏性变更,且无法回答"这个模块对外提供了什么"。本工程还刻意不导出 ApprovalModule(DI 装配点)与 viewmodel/repo/datasource,防止使用方绕过页面直接调仓储 新增一个对外能力要手改 Index.ets;有时需要"再导出一层类型"(如 lib_network/Index.ets 里的 export type { ... })

术语:ADR(Architecture Decision Record) ── 把"我们选了什么、为什么不选别的、为此付出什么"记成一条可检索的记录。它的价值在半年后:当时的代价开始显现时,你能分清"这是预期内的代价"还是"选错了"。

0.2.1 三条贯穿全工程的取舍主线

14 条决策不是并列的清单,它们收敛在三条主线上。读代码时如果只记住三件事,记这三条:

主线一:把"可能出错的地方"变成"类型上必须处理的地方"。 Result<T> 替代异常(第 6 条)、UiState 判别式替代 loading/error 两个布尔、RequestOptions.idempotent 显式声明代替默认重试(第 13 条)、Parser<T> 强制每个接口提供具名解析器。共同点是:不依赖开发者记得处理,而是让"忘记处理"在编译期就过不去。

主线二:把"变化的东西"从"固定的东西"里剥出来。 变量头(Authorization)从 Session 配置剥到每次 Request 的 headers;可变参数从 URL 常量剥到 RequestOptions;跨 feature 跳转从代码依赖剥成 RouteNames 字符串契约(第 14 条与第 2 章 1.6);rcp 的版本差异剥到单一适配点(第 12 条)。共同点是:变化被限制在可枚举的少数位置。

主线三:把"约束"从文档搬进脚本。 依赖方向 → scripts/check-deps.mjs;范式(禁 V1 / 禁 @ohos.*) → scripts/check-banned-api.sh;路由表字符串 → scripts/check-route-map.mjs;版本号与 IS_RELEASE → scripts/inject-version.mjs。共同点是:写在文档里的规则没有约束力,一次赶进度的 import 就能把它破掉,而编译、打包、运行都不会报错。

误区:只抄第 1~14 条的具体选择,不抄这三条主线。具体选择会随 SDK 版本过时(比如 rcp 换成别的网络库),主线不会。


0.3 运行与验收

0.3.1 用 DevEco Studio 打开并运行(默认路径)

# 1) 用 DevEco Studio 26.0.0 打开工程根目录(不是某个模块目录)
#    D:/Documents/tencent/workbuddy/learning/HarmonyOS-doc/code/office-flow
# 2) 等待首次 Sync 完成(hvigor 与 ohpm 依赖需要联网下载)
# 3) 选择 entry 模块 → 选择模拟器或真机 → Run

工程的默认环境是 MOCK(lib_base/src/main/ets/const/AppConst.ets 的 EnvConfig.CURRENT 默认值为 Env.MOCK),此时所有请求都被 MockInterceptor 拦下,不出网、不需要后端。

0.3.2 默认 Mock 的演示账号(以 lib_auth 的 Mock 规则为准)

以 lib_auth/src/main/ets/mock/AuthMockRules.ets 为唯一依据(server/mock-server.js 的口径与之一致):

/** 登录成功的固定账号(见 docs/appendix/D-mock-server.md 的演示账号表) */
const MOCK_ACCOUNT: string = 'demo@officeflow.com';
const MOCK_ACCOUNT_ALIASES: string[] = ['demo', 'demo@officeflow.com', '13800000000'];
const MOCK_PASSWORD: string = 'demo1234';
输入 结果
账号 demo / demo@officeflow.com / 13800000000,密码 demo1234 登录成功(HTTP 200 + code: 0)
密码随便填错 HTTP 200 + 业务码 40000 → ErrorCode.BIZ_FAILED,页面显示"账号或密码错误"
账号不在别名表里 同上(40000)

关键细节:密码错误的 Mock 返回的是 HTTP 200 + 业务码非 0,而不是 HTTP 400。原因写在规则注释里:ErrorMapInterceptor 先判 HTTP 状态码,HTTP 400 会被 HttpStatusMapper 归一化成 UNKNOWN,业务码 40000 就丢了,UI 也就无法区分"账号不存在"与"网关抽风"。

0.3.3 真机验证协同的条件

多端协同(lib_distributed + feature_collab)有硬性前置条件,缺一条就只能看到空设备列表:

  • [ ] 两台真实设备,登录同一个华为账号,且在同一局域网 / 已建立可信连接
  • [ ] 两台设备都已授予 ohos.permission.DISTRIBUTED_DATASYNC(entry/src/main/module.json5 已声明 reason 与 usedScene,运行时仍需用户同意)
  • [ ] 两台设备都已开启蓝牙 / WLAN(软总线依赖其中之一)
  • [ ] DeviceHub.start() 已被协同页面触发(本工程刻意不在启动时拉起设备发现,避免所有用户为不用的功能付出启动开销)

模拟器上分布式能力不可靠(见第 7 章误区 1),因此本章的协同验收一律以真机为准。

0.3.4 命令行构建与静态检查

# ---- 静态检查:不需要 DevEco,几秒钟出结果,提 PR 前先跑 ----
node scripts/check-deps.mjs                 # 模块依赖图(架构门禁)
bash scripts/check-banned-api.sh            # 禁用 API(范式门禁)
node scripts/check-route-map.mjs            # 路由表(字符串契约门禁)
SKIP_BUILD=1 bash scripts/ci.sh             # 上面三项一起跑

# ---- 构建:需要 hvigorw(即 DevEco 环境)----
./hvigorw clean --no-daemon                 # 清增量缓存,让"构建成功"只反映当前源码
./hvigorw --sync --no-daemon                # 解析模块图与依赖,配置错误在此先暴露
./hvigorw assembleHap --mode module -p product=default -p buildMode=debug
./hvigorw assembleApp -p product=default -p buildMode=release -p signingConfig=default
bash scripts/ci.sh                          # 完整流水线(检查 + debug 包 + 归档)
bash scripts/ci.sh --release                # 额外产出 release 包(需签名环境变量)

产物位置:entry/build/.../outputs/default/*.hap(debug)、build/outputs/default/*.app(release);scripts/ci.sh 会把它们统一归档到 build-artifacts/。

版本门槛:API 26.0.0 起 compatibleSdkVersion 必须写成旧格式 6.0.0(20),而 targetSdkVersion 写成新格式 26.0.0,两者格式不同且不可互转。手敲容易出错,先用 IDE 生成再改数值。

0.3.5 切到真实后端(本地假后端)

两步:改环境常量 + 起后端。

// lib_base/src/main/ets/const/AppConst.ets
static readonly CURRENT: Env = Env.DEV;   // 由 Env.MOCK 改为 Env.DEV / Env.STAGING / Env.PROD

static baseUrl(): string {
    switch (EnvConfig.CURRENT) {
        case Env.MOCK:
            return 'https://mock.officeflow.local';
        case Env.DEV:
            return 'https://dev-api.officeflow.example.com';   // ← 改成 http://<你的电脑IP>:3000
        // ...
    }
}
node server/mock-server.js              # 默认端口 3000
PORT=8080 node server/mock-server.js    # 3000 被占用时换端口(脚本读 process.env.PORT)

三个必须注意的点:

  1. 不要用 localhost。模拟器/真机访问的是"设备自己的回环地址",必须用宿主机的局域网 IP;或先做端口转发:hdc rport tcp:3000 tcp:3000,再用 127.0.0.1:3000。
  2. 切到非 Mock 环境后,各模块的 *MockRules 一条都不会注册。AppInitializer.initMock() 里有 if (!EnvConfig.isMock()) { ... return; },这条判断是刻意保留的——"生产包里带了一份能返回假数据的规则"是最难排查的一类事故。
  3. EnvConfig.IS_RELEASE 由 scripts/inject-version.mjs 在构建前改写,不要手改(见第 4 章)。

0.3.6 错误分支与冲突的演练入口

这块是本章验收清单能打勾的前提。全部不需要改代码:

要演练的分支 触发方式 期望观测
密码错误(业务码 40000) 密码随便填错 页面提示"账号或密码错误",日志里 biz error 40000
令牌过期(业务码 40100 → 单飞刷新 + 重放) 把 /user/profile 请求写成带 ?simulate=expired(Mock 规则认这个 query;也可把 PROFILE_TOKEN_EXPIRED_VARIANT 改为 true) AuthInterceptor 识别 40100 → 置 retryPlan.requireRefresh → TokenRefresher.refreshToken() 只发一次 /auth/refresh → 重放成功
刷新彻底失败(HTTP 401 → 清会话 + 登出) 让 refreshToken 不是 rt_ 开头(Mock 规则据此返回 401) 清 TokenStore → SessionStore.reset() → LogoutBus.broadcast() → 根组件清栈并跳登录页
服务端错误(HTTP 5xx) MockRegistry.setFailureRate(0.3)(或 Mock 规则注入 503) 幂等请求按 defaultIdempotentRetry() 重试最多 3 次;RetryInterceptor.backoff 有抖动
字段缺失(契约漂移) 把 LOGIN_MISSING_FIELD_VARIANT 改为 true 日志出现 parse issues WARN;AuthRepository.login 返回 PARSE_FAILED 而不是"密码错误"
重复提交(业务码 40901) 提交流程中重试(复用同一幂等键) BizCode.DUPLICATE_SUBMIT → ErrorCode.BIZ_CONFLICT → 页面提示"该单据此前已提交成功,请到列表查看"
断网可用 关闭网络后进入审批列表 有本地缓存时正常展示并打降级 warn;无缓存才走错误页
窗口形态变化(断点) 在 PC / 折叠屏上拖动窗口 工作台卡片列数 1→2→3 变化;MainPage 底部断点行同步变化

0.3.7 第一次运行失败时的定位顺序

按这个顺序查,绝大多数问题在前三步就被拦住:

顺序 现象 先看哪里 判据
1 Sync 报红 oh-package.json5 的 file:../xxx 路径 路径必须指向真实存在的模块目录;node scripts/check-deps.mjs 能直接报出"依赖了未注册的模块"
2 编译过但"点了没反应" route_map.json 的 name / buildFunction / pageSourceFile 三者与 Index.ets 的 destBuilder 分支、各模块 RouteBuilder 函数名必须逐字一致;node scripts/check-route-map.mjs 能报出
3 白屏且无日志 AppInitializer.runSync() 是否被调用、EnvConfig.CURRENT 首屏落地依赖 AppInitializer.sessionReady();若 Promise reject,应用会永远停在启动占位页
4 所有请求 401 / 请求发出时没有 Authorization AuthProviderInstaller.install() 是否在首个请求前执行 AuthInterceptor 用 AuthTokenRegistry.require(),未安装会抛异常(这是刻意的启动期配置错误)
5 Mock 不生效 EnvConfig.CURRENT 是否为 Env.MOCK、MockRegistry.enable(true) 是否被调用 MockInterceptor 的条件是 EnvConfig.isMock() && MockRegistry.isEnabled(),两个都真才命中
6 埋点/日志里没有断点档位 EntryAbility.attachWindowSizeListener 是否执行、WindowState.update 是否收到回调 PC 上拖动窗口时 MainPage 的断点行必须跟着变;不变说明监听没注册或没写进 WindowState
7 改了 HSP 里的组件但界面没变 是否重装了宿主 HAP HSP 是随宿主安装的独立产物,产物更新后必须重装宿主;这是 HSP 相对 HAR 最容易被忽略的调试成本

0.4 验收清单

  • [ ] node scripts/check-deps.mjs 退出码为 0,输出 13 个模块且无违规、无依赖环
  • [ ] bash scripts/check-banned-api.sh 退出码为 0,输出中"白名单豁免次数"为 0(当前豁免清单为空)
  • [ ] node scripts/check-route-map.mjs --require-file 通过,route_map.json 的 8 条路由与 Index.ets 的 destBuilder 分支一一对应
  • [ ] ./hvigorw assembleHap --mode module -p product=default -p buildMode=debug 能产出 .hap
  • [ ] DevEco Studio 26.0.0 打开工程后 Sync 无红字,entry 可运行到设备
  • [ ] 用 demo / demo1234 能登录成功并落到主框架(RouteNames.MAIN)
  • [ ] 用错误密码登录,页面显示"账号或密码错误",且不是"网络异常"
  • [ ] 登录后能进入审批列表并看到数据;下拉刷新后列表状态从 REFRESHING 回到 SUCCESS
  • [ ] 触底加载更多可用;加载更多失败时保留已有数据且只在 footer 报错
  • [ ] 断开网络后重进审批列表:有缓存时正常展示并出现降级提示,日志有 warn 级别的降级记录
  • [ ] 断开网络后提交审批:界面明确失败,且本地数据未被标记为已提交(不做乐观更新)
  • [ ] 在提交页连续两次点击提交:日志里 Idempotency-Key 相同,服务端只建一张单
  • [ ] 触发 40100 分支:日志中 /auth/refresh 只出现 1 次(单飞生效),随后原请求被重放成功
  • [ ] 触发 HTTP 401 刷新失败分支:应用清栈并回到登录页,重新登录后仍可正常使用
  • [ ] 日志中搜索不到 Bearer、完整手机号 13800000000、password= 明文(脱敏的两个出口都生效)
  • [ ] 在 PC / 折叠屏上拖动窗口:工作台卡片列数随断点变化,且只有一处注册了 windowSizeChange
  • [ ] 两台同账号真机:协同设备页能看到对端设备(模拟器不作为验收环境)
  • [ ] 冷启动时不闪登录页:已登录用户的 SessionStore 由磁盘快照回填(AppInitializer.sessionReady())
  • [ ] 退出登录后再用另一个账号登录:列表里不出现上一个账号的数据(clearCache() 被调用)

0.5 与教程前面章节的对应关系

教程章节 在本工程里的落地物 关键文件
00-before-you-start.md 环境准备与版本基线 根 build-profile.json5、工程 README.md
01-environment-toolchain.md 工具链、模块五件套、HAR/HSP 形态 各模块 oh-package.json5 / hvigorfile.ts / module.json5
02-arkts-core.md 严格模式、显式类型、错误模型 lib_base:Result / ResultExt / AppError / JsonReader
03-arkui-layout.md 组件封装、主题与断点 lib_ui(AppButton / AppTag / Theme / Breakpoints)
04-routing-lifecycle.md Navigation、NavPathStack、系统路由表、深链 entry/pages/Index.ets、lib_router、各 feature 的 router/*.ets
05-state-management.md V2 装饰器、@Computed、@Monitor、LazyForEach 数据源 feature_approval/viewmodel/、entry/state/WindowState.ets
06-persistence-network.md 存储选型、rcp、拦截器链、重试与取消、Mock lib_storage、lib_network
07-distributed.md 设备发现、分布式同步、逐字段合并与冲突 lib_distributed、feature_collab
08-performance.md 冷启动分段、打点、埋点缓冲 lib_log 的 Trace / Analytics、entry/init/AppInitializer.ets
09-modularization.md 模块划分、依赖方向、门禁脚本 scripts/check-deps.mjs、各模块根 Index.ets
10-testing-release.md 签名、打包、上架 signature/README.md、scripts/setup-signing.sh、scripts/inject-version.mjs
本章(11/00~11/08) 工程整体落地与验收 全部

0.6 常见误区

误区 1:以为开了 Mock 就绕过了整条网络链路

错误认知:Mock 模式验证不了鉴权、日志、错误映射,所以错误分支要等接上真后端才能测。 实际情况:本工程的 Mock 挂在链的第 3 位(Auth → Logging → Mock → ErrorMap → Retry),Mock 只短路"最靠近网络的传输"这一层。因此鉴权头一样会注入、日志一样会脱敏输出、Mock 返回的假错误照样会被 ErrorMapInterceptor 归一化、RetryInterceptor 照样会按 retryable 决定是否重看重试。 为什么重要:这决定了你可以在纯本地把 401、5xx、40901、字段缺失这些分支全部走一遍(见 0.3.6)。反过来,如果你的 Mock 是"直接替换 DataSource",那确实绕过了一切——那是另一种方案,不是本工程的。

误区 2:把 EnvConfig.IS_RELEASE 当运行时开关手改

错误写法:为了看详细日志,手动把 EnvConfig.IS_RELEASE 改成 false 然后打 release 包。 正确写法:用 node scripts/inject-version.mjs --release,它同时改写 AppScope/app.json5 的版本号与该字面量。 为什么错:IS_RELEASE 是编译期常量,控制日志级别的编译期剪裁。它一旦变成"开发者记得改"的字面量,release 包里残留 debug 分支只是时间问题;而且手改没有任何审计痕迹,出问题只能翻提交历史。

误区 3:混淆配置两处不一致时不知道以谁为准

现象:根 build-profile.json5 的 products[0].buildOption.arkOptions.obfuscation.enable 是 false,而各模块 build-profile.json5 的 buildOptionSet.release.arkOptions.obfuscation.ruleOptions.enable 是 true。 正确做法:以模块级配置为准(release 构建逐个模块读自己的 buildOptionSet),并尽快把根级那一处删掉或对齐,避免下一个人照着根配置推断出错误结论。 为什么错:配置冲突本身不会报错,只会让人做出错误判断。同一份配置语义在两个层级都存在时,一定要指定唯一权威来源。

误区 4:以为默认 Mock 就是"假数据能跑通功能"

错误认知:Mock 能返回数据,所以功能就算通了。 实际情况:Mock 验证的是分支可达性,不是契约一致性——Mock 响应也是我们自己写的,写错了两边一起错。真实 HTTP 行为(状态码、重定向、超时、大响应体分片)只有 server/mock-server.js 或真后端能验证。 正确做法:日常开发用拦截器级 Mock(快、不出网);联调自测与发布前,切到 server/mock-server.js 再跑一遍主链路。

误区 5:把"能构建"当成"能交付"

错误认知:assembleHap 成功 = 工程健康。 实际情况:构建成功只证明语法与依赖解析没问题。本工程的三项静态检查(依赖图、禁用 API、路由表)守的是编译期看不见的三类问题:模块间横向依赖、V1/V2 混用、路由名拼错导致的白屏。它们加起来几秒钟,比一次 assembleHap 便宜两个数量级。 正确做法:把 SKIP_BUILD=1 bash scripts/ci.sh 当成提交前的固定动作。

误区 6:在页面或 ViewModel 里自己 new 依赖

错误写法:页面里 private readonly repo = new ApprovalRepositoryImpl(new ApprovalRemoteDs(...), new ApprovalLocalDs())。 正确写法:仓库通过装配点获取——ApprovalModule.repository();ViewModel 一律由 ApprovalModule.listViewModel() / detailViewModel() 工厂产出。 为什么错(原文就在 feature_approval/src/main/ets/di/ApprovalModule.ets 的类注释里):依赖图会被复制 N 份、单测无法替换实现、冷启动被拖慢。更隐蔽的是 ViewModel 必须是"每次新实例":它持有页面级取消令牌,若被复用,第二次进入页面时令牌已是 cancelled,所有请求结果都会被丢弃——表现为"第二次进列表永远空白",且极难定位。

误区 7:以为启动初始化"顺序无所谓"

错误认知:初始化步骤反正在 onCreate 里都会执行,先后没差别。 实际情况:AppInitializer.runSync() 的顺序有明确依据(理由写在每个 initXxx 上):

AppInitializer.initLogging();           // 1. 日志要最早可用
AppInitializer.initAnalytics();         // 2. 埋点要在网络之前:登录/刷新请求也要被观测
AppInitializer.initAuthProvider();      // 3. 令牌提供者必须先于任何请求安装
AppInitializer.initRouteInterceptor();  // 4. 登录拦截器必须先于第一次跳转注册
AppInitializer.initMock();              // 5. Mock 必须在首个请求之前注册完
AppInitializer.initSession(context);    // 6. 会话恢复:发起即返回(不 await)

为什么错:顺序错了不会报错,只会"偶发"。把 initMock() 放到某个请求之后,那个请求就真的出网了;把 initRouteInterceptor() 放到首次跳转之后,就会出现"未登录也能进业务页"。


0.7 实践任务

任务:把 OfficeFlow 跑起来并完成一次完整的验收

阶段一:跑起来

  1. 用 DevEco Studio 26.0.0 打开工程根目录,等 Sync 完成。
  2. 运行 entry 到模拟器或真机,确认首屏是登录页(未登录状态)。
  3. 用 demo / demo1234 登录,确认落到主框架。
  4. 从工作台进入审批列表 → 点进详情 → 发起一次审批 → 返回列表确认新单据在列表里。

阶段二:把错误分支走一遍

  1. 用错误密码登录一次,记录页面提示与日志(应当是业务码 40000,不是"网络异常")。
  2. 触发 /user/profile?simulate=expired,在日志里数 /auth/refresh 的出现次数(应当是 1)。
  3. 断网后重进审批列表,确认有缓存时正常展示并出现降级提示。
  4. 断网后提交审批,确认本地数据未被标记为已提交。

阶段三:切换环境

  1. 起 node server/mock-server.js(端口被占用时用 PORT=8080)。
  2. 把 EnvConfig.CURRENT 改为 Env.DEV,并把 Env.DEV 的 baseUrl 改成 http://<你的电脑IP>:端口。
  3. 重新运行,确认请求真的出网(服务端控制台有日志),并复现一次 ?simulate=500。

阶段四:收口

  1. 跑完 0.4 的验收清单,把未通过项记成 issue;跑 SKIP_BUILD=1 bash scripts/ci.sh 确认三项门禁全绿。

验收 checklist

  • [ ] 四条主链路各自走到过"成功"与"失败"两条分支
  • [ ] 单飞刷新被证明过:并发 401 场景下 /auth/refresh 只出现 1 次
  • [ ] 幂等键被证明过:连续两次提交的 Idempotency-Key 相同
  • [ ] 断网可用被证明过:有缓存时列表可展示,且日志有降级 warn
  • [ ] 冲突被证明过:40901 分支能复现并给出可读提示
  • [ ] 环境切换被证明过:Mock 与本地真实后端两种模式都跑通过
  • [ ] 静态检查三项全绿,且能说出每一项守住的是什么问题

交付物清单

交付物 形式 说明
验收记录 Markdown 逐条对照 0.4,附日志片段作为证据
环境清单 Markdown DevEco / SDK / 设备 / 是否真机,以及 hdc list targets 输出
错误分支证据 日志片段 40100 单飞、40901 冲突、断网降级三条
门禁输出 终端输出 ci.sh 三项静态检查的完整输出
问题清单 Markdown 未通过项 + 初步定位(哪一层、哪个文件)

0.8 延伸阅读

  • 官方文档 → 应用开发 → 应用框架 → 应用程序包结构(HAP / HAR / HSP 的产物与安装分发模型)
  • 官方文档 → 应用开发 → 应用框架 → ArkUI → Navigation 组件与 NavPathStack
  • 官方文档 → 应用开发 → 应用框架 → Remote Communication Kit → 使用 rcp 发起网络请求 与 拦截器
  • 官方文档 → 应用开发 → 应用框架 → ArkData → Preferences / 关系型数据库(RDB)开发指导
  • 官方文档 → 应用开发 → 应用框架 → ArkUI → 状态管理 V2(@ObservedV2 / @Trace / @Local / @Param / @Computed / @Monitor)
  • 官方文档 → 工具 → 命令行工具(hvigorw / hdc / ohpm)
  • 本教程 README 的「版本基线」与「第 11 章目录」
  • 本工程 code/office-flow/README.md(模块地图、脚本说明、已知限制)
  • 本教程 附录 D 本地 Mock 方案(三种 Mock 方案对比与规则表设计)

上一章:第 10 章 测试与上架发布 | 下一章:11/01 架构与模块划分

📄 第 11 章 架构与模块划分 01-architecture-modules.md

第 9 章讲了"模块化应该怎么想",这一章讲"OfficeFlow 实际怎么做的、脚本怎么把它钉住"。区别在于:第 9 章给的是原则与可配置的示例脚本,这一章给的是已落地的事实——真实的 13 个模块、真实的依赖输出、真实的目录,以及三处绕开循环依赖的具体手法。


1.0 本章目标

  1. 能读懂 node scripts/check-deps.mjs 的输出,并能解释每一行的依赖为什么是这条而不是另一条。
  2. 能说出 13 个模块各自的形态(HAP / HAR / HSP)、职责与"为什么是这个形态"。
  3. 能背出依赖四条铁律,并指出每条铁律由哪个脚本、以什么规则拦住。
  4. 能按目录约定在一个新 feature 里摆放代码:哪个文件进 model/、哪个进 datasource/、为什么 view/ 里不能 new 依赖。
  5. 能画清 View → ViewModel → Repository → DataSource 的返回类型契约,并说明"哪一层的异常可以逃逸"。
  6. 能复述三个跨模块解耦手法的真实代码,并说明它们各自切断了哪条依赖边。

1.1 依赖图(脚本的真实输出)

不要从文档里抄依赖图——从脚本里读。以下输出来自 node scripts/check-deps.mjs(13 个模块,无违规):

lib_base          (无依赖)
lib_auth           --> lib_base, lib_log, lib_network
lib_distributed    --> lib_base, lib_log
lib_log            --> lib_base
lib_network        --> lib_base, lib_log
lib_router         --> lib_base, lib_log
lib_storage        --> lib_base, lib_log
lib_ui             --> lib_base
feature_approval   --> lib_base, lib_log, lib_network, lib_router, lib_storage, lib_ui
feature_collab     --> lib_base, lib_distributed, lib_log, lib_network, lib_router, lib_storage, lib_ui
feature_login      --> lib_auth, lib_base, lib_log, lib_network, lib_router, lib_ui
feature_message    --> lib_base, lib_log, lib_network, lib_router, lib_storage, lib_ui
entry              --> 全部 12 个模块

用 Mermaid 表达同一张图(方向 = 依赖方向,箭头指向被依赖方):

graph TD
    entry --> feature_login
    entry --> feature_approval
    entry --> feature_message
    entry --> feature_collab
    entry --> lib_ui
    entry --> lib_auth
    entry --> lib_network
    entry --> lib_storage
    entry --> lib_log
    entry --> lib_router
    entry --> lib_distributed
    entry --> lib_base

    feature_login --> lib_auth
    feature_login --> lib_network
    feature_login --> lib_router
    feature_login --> lib_ui
    feature_login --> lib_log
    feature_login --> lib_base

    feature_approval --> lib_network
    feature_approval --> lib_storage
    feature_approval --> lib_router
    feature_approval --> lib_ui
    feature_approval --> lib_log
    feature_approval --> lib_base

    feature_message --> lib_network
    feature_message --> lib_storage
    feature_message --> lib_router
    feature_message --> lib_ui
    feature_message --> lib_log
    feature_message --> lib_base

    feature_collab --> lib_distributed
    feature_collab --> lib_network
    feature_collab --> lib_storage
    feature_collab --> lib_router
    feature_collab --> lib_ui
    feature_collab --> lib_log
    feature_collab --> lib_base

    lib_auth --> lib_network
    lib_auth --> lib_log
    lib_auth --> lib_base

    lib_network --> lib_log
    lib_network --> lib_base
    lib_storage --> lib_log
    lib_storage --> lib_base
    lib_router --> lib_log
    lib_router --> lib_base
    lib_log --> lib_base
    lib_distributed --> lib_log
    lib_distributed --> lib_base
    lib_ui --> lib_base

读这张图有三个要点:

  1. 它是星形的,不是网状的。所有跨 feature 的"跳转"都走 lib_router,因此 feature_* 之间一条边都没有。
  2. lib_base 是唯一的汇点(零出边)。它的 oh-package.json5 里 "dependencies": {} 是空的——这不是省事,而是"最底层契约"的定义。
  3. lib_ui 是 HSP,在依赖图上与 HAR 看不出差别,差别在运行期(见 1.2 与 1.4)。

术语:稳定依赖原则(SDP) ── 模块只能依赖比自己更稳定的模块。"稳定"= 被更多模块依赖 + 变化频率更低。反过来(稳定的依赖不稳定的)会让稳定的那个被迫跟着变,稳定性被摧毁。这就是 lib_base 绝不能 import 任何 feature_* 的原因。


1.2 模块清单表(13 个)

# 模块 形态 职责 为什么是这个形态 对应章节
1 entry HAP 唯一可安装入口:UIAbility、窗口尺寸监听(唯一一处)、根 NavPathStack、系统路由表分发、启动编排 一个应用必须有且只有一个可安装入口;Ability 与 @Entry 页面只能存在于 HAP 04、11/01、11/08
2 feature_login HAR 登录页、本地校验、记住账号、登录成功后的回跳白名单 业务特性:变化频率最高,只被 entry 引用,HAR 约束最少、调试最简单 11/04
3 feature_approval HAR 审批列表 / 详情 / 发起 / 撤回 / 审批;本工程的目录约定范例 同上 11/05
4 feature_message HAR 消息列表、已读状态、本地轻量缓存、通知发布与点击跳转 同上 11/06
5 feature_collab HAR 设备发现面板、草稿协同 UI、冲突展示 同上 07、11/07
6 lib_auth HAR 令牌生命周期(TokenStore)、单飞刷新(TokenRefresher)、会话状态(SessionStore)、登出广播(LogoutBus)、与网络层的接驳(AuthProviderInstaller) 能力库:不感知业务,但也不是"最底层"——它依赖 lib_network,因此不能是 HSP 的候选(HSP 只允许依赖 lib_base) 11/04
7 lib_network HAR rcp 封装、拦截器链、Result/AppError 映射、重试与取消、Mock 基建 能力库;被 5 个模块引用,但没有多 HAP 场景,因此不需要 HSP 06、11/02
8 lib_storage HAR Preferences / RDB / 文件 / 分布式 KV 封装 能力库;刻意不依赖 lib_network(存储层不知道"网络"这件事存在) 06
9 lib_log HAR 三级日志(XLogger)、打点(Trace)、埋点(Analytics)、脱敏(Masker) 能力库;被几乎所有模块引用,是最"公共"的 lib,但形态仍是 HAR(见下方说明) 11/03
10 lib_router HAR 路由名常量(RouteNames)、参数契约、Router 门面(唯一允许碰 NavPathStack 的地方)、拦截器与路由埋点 能力库;不得依赖任何 feature,否则星形图退化成网状 04、11/01
11 lib_distributed HAR 设备发现(DeviceHub)、同步通道(DraftSyncChannel)、逐字段合并与冲突裁决(DraftMerge)、权限(Permission) 能力库;刻意不依赖 lib_network——协同是"设备到设备"的本地能力,拖进网络栈会让协同逻辑在断网时无法单测 07、11/07
12 lib_ui HSP 公共组件、设计 token、断点工具 唯一被建成 HSP 的库:它承载跨 HAP 共享的单例语义(主题状态、AppStorageV2 实例)。HAR 在多 HAP 下会被复制成多份实例,"单例"退化成"每份一个" 03、11/07
13 lib_base HAR 契约(Result/AppError/Contracts)、工具(JsonReader)、能力门禁(CapabilityGate)、常量(EnvConfig/AppConst/UserMessages) 最底层;零依赖,被所有人依赖 02、11/02

1.2.1 三个"为什么"的补充说明

为什么 lib_log 不建 HSP? 它被 12 个模块引用,看起来很"公共"。但本工程只有一个 HAP(entry),HSP 的收益只出现在"同一份库被 ≥2 个 HAP 引用"的场景。单 HAP 下 HSP 反而白搭上首启加载开销与"同签名同 bundle"约束。引用广度不是选择 HSP 的依据,多 HAP 才是。

为什么 HSP 只允许依赖 lib_base? HSP 与引用它的 HAP 必须同签名同 bundle。若 lib_ui 依赖了 lib_network,而 lib_network 又依赖 lib_log……共享包会把整条依赖链拖进"必须同签名同 bundle"的约束里,任何一环被别的 HAP 用不同版本引用就会冲突。因此 lib_ui/oh-package.json5 里只有 lib_base,而 lib_ui/Index.ets 的注释写明了这条纪律。

为什么 lib_ui 组件"只认字符串"? 见 1.6 手法②。这是"UI 库不反向依赖数据层"的落地方式;一旦 lib_ui import 了业务枚举,依赖方向就变成"UI 依赖数据层",lib_ui 再也不可能被别的业务复用。


1.3 依赖四条铁律 + 脚本如何强制

四条铁律来自工程 README,落地形态在 scripts/check-deps.mjs(架构门禁)与 scripts/check-banned-api.sh(范式门禁)。

铁律 允许 / 禁止 违反后的症状 谁拦住它
① 单向:feature → lib → lib_base lib → feature ❌ 改一个 feature 导致多个 lib 重编译,且该 lib 无法单测 check-deps.mjs 规则 5「依赖方向倒置(低层依赖了高层)」——用 layerOf() 给模块分层(lib_base=0 / lib_*=1 / feature_*=2 / entry=3),toLayer > fromLayer 即违规
② 无横向:feature ↔ feature 禁止 横向 import ❌ 两个 feature 互相 import,任一模块无法被删除或复用,规模一大必然成环 check-deps.mjs 规则 1「feature 之间禁止横向依赖」——mod.name.startsWith('feature_') && dep.startsWith('feature_')。这是脚本里第一条被拦下来的规则
③ 零依赖:lib_base 不依赖任何本地模块 ❌ 底层契约被业务污染,"公共库"再也不公共 check-deps.mjs 规则 3——只要 lib_base 的 oh-package.json5 里出现任何 file: 依赖即违规
④ 可解析且无环:依赖必须指向 build-profile.json5 已注册的模块,且图中无环 ❌ 构建期报错位置离原因很远;有环则无法独立编译与单测 check-deps.mjs 规则 4「依赖了未注册的模块」+ 规则 2「依赖环」(DFS 三色标记,打印完整环路径如 lib_a → lib_b → lib_a)

另外两条"软"约束:

  • 同层 lib ↔ lib 只给警告。lib_network → lib_log、lib_auth → lib_network 都是合理的;但 a → b → a 是环的雏形。需要人判断的事不该做成硬门禁,否则门禁会被绕过。
  • 依赖未注册但目录不存在 → 只警告(并行开发中"目录还没建"不等于"依赖错了");用 --strict 可以让它变成错误(CI 的最终分支上应开这个开关)。

范式的门禁交给 scripts/check-banned-api.sh,它守的是四类"看起来能编过、但属于上一代范式"的写法:

规则 id 拦什么 为什么
ohos-namespace-import from '@ohos.xxx' 分命名空间导入 API 26 起统一走 @kit.*;混用会让"这个能力归哪个 Kit"不可知,升级时无法一次性盘点改动面
ohos-router @ohos.router 已由 Navigation + 路由名常量取代
ohos-net-http @ohos.net.http 已由 lib_network 的 rcp 封装取代
persistent-storage PersistentStorage 应由 lib_storage 收口
v1-state-decorator @State/@Prop/@Link/@Observed/@ObjectLink/@Provide/@Consume/@Watch/@StorageLink/@StorageProp/@LocalStorageLink/@LocalStorageProp/@Track V1 与 V2 观察机制不能混用,是"莫名其妙不刷新"的最大来源;两者只差一个字符,人眼在 Review 里抓不住

版本门槛:check-banned-api.sh 用一次 awk 完成"剥注释 + 全规则匹配"。这不是写法偏好——若"每个文件 × 每条规则"各起一次进程,60 个文件就是 900 次进程创建,在 Windows/Git Bash 上会把一条 3 秒的检查变成 90 秒,门禁一慢,人就会想办法跳过它。

1.3.1 怎么验证门禁真的在工作

# 1) 三项静态检查一起跑(不需要 DevEco,几秒钟)
SKIP_BUILD=1 bash scripts/ci.sh

# 2) 故意造一条横向依赖,确认脚本拦下
#    在 feature_message/oh-package.json5 的 dependencies 里加:
#    "feature_approval": "file:../feature_approval"
node scripts/check-deps.mjs        # 应退出码 1,并打印 [feature 横向依赖]

# 3) 故意写一个 V1 装饰器,确认脚本拦下
#    在任意 .ets 里写 @State count: number = 0;
bash scripts/check-banned-api.sh   # 应退出码 1,并打印 v1-state-decorator

把这两次"故意违规"做完,你才会相信门禁真的在跑——没有验证过的门禁等于没有门禁。


1.4 目录约定(以 feature_approval 为范例)

feature_approval/
├── Index.ets                          ← 唯一导出面:页面 / RouteBuilder / Mock 规则
├── oh-package.json5                   ← 依赖声明(feature → lib_*,无 feature 横向)
├── build-profile.json5                ← 模块构建配置(release 开混淆)
├── hvigorfile.ts                      ← harTasks
└── src/main/
    ├── module.json5                   ← "type": "har",不声明任何权限
    └── ets/
        ├── model/                     ← 领域模型 + 状态机 + DTO 解析
        ├── datasource/                ← IO 边界:远端 / 本地 / 缓存策略
        ├── repo/                      ← 仓储:接口 + 实现 + 单例
        ├── viewmodel/                 ← 页面状态与命令(@ObservedV2 + @Trace)
        ├── view/                      ← 页面(XxxPage.ets)
        │   └── components/            ← 该 feature 私有组件
        ├── router/                    ← RouteBuilder + 本模块路由名清单
        ├── mock/                      ← Mock 规则表
        └── di/                        ← 装配点(唯一的 factory)

逐目录说明「放什么 / 不放什么」:

目录 放什么 不放什么 依据
model/ 领域模型(Approval)、状态机与流转规则(ApprovalStatus + ApprovalStatusRules)、命令对象(ApprovalQuery 里的 SubmitApprovalCmd/WithdrawCmd/DecideApprovalCmd)、DTO 与手写解析(ApprovalDto 里的 ApprovalMapper) 任何 IO、任何 UI 依赖、任何文案资源 model/ 是最内层,能被 UI(决定按钮显隐)、仓储(决定能否撤回)、测试(穷举状态组合)三处共用,因此必须是纯函数
datasource/ 远端 IO(ApprovalRemoteDs:拼路径/参数/body、具名 parser、声明幂等性与重试策略)、本地 IO(ApprovalLocalDs:RDB 读写)、缓存新鲜度策略(ApprovalCachePolicy) 缓存降级决策、状态管理、面向用户的文案 ApprovalRemoteDs 的类注释明确列出"只做四件事",并声明"不做:缓存、降级、状态管理、文案"
repo/ ApprovalRepository(接口,定义契约与返回类型)、ApprovalRepositoryImpl(编排远端/本地/内存三层与降级)、ApprovalRepoSingleton(进程级单例) UI 决策(跳转/弹窗)、直接构造 ViewModel ApprovalRepository 注释:"全部分层约定:只返回 Result,不抛异常"
viewmodel/ @ObservedV2 类,@Trace 字段承载 UI 状态,@Computed 派生值,@Monitor 状态变迁埋点,命令方法(loadFirstPage/refresh/loadMore/submit/withdraw) new 依赖(走 DI)、拼 UI 资源引用、写 SQL/发请求的细节 ApprovalListViewModel 类注释的"状态更新两条铁律"
view/ XxxPage.ets(承载 NavDestination 与可见性回调)、components/ 下的私有组件(ApprovalCard、ApprovalDataSource) new 仓储/数据源、await、JSON 解析、定时器 见下方 1.4.1
router/ @Builder export function XxxRouteBuilder、本模块路由名清单(APPROVAL_ROUTE_NAMES) 裸字符串路由名、参数断言、NavDestination 包装 ApprovalRoute.ets 注释:页面自己承载 NavDestination,这样 onShown/onHidden 才能直接驱动 ViewModel
mock/ 规则表(ApprovalMockRules),规则是纯函数:入参 MockRequest、出参 MockResponse 依赖外部可变状态、随机数触发分支 AuthMockRules 注释:"Mock 里故意不做令牌黑名单这类状态,否则测试之间会互相污染"
di/ 唯一的装配点(ApprovalModule):仓储单例、ViewModel 工厂、跨页面脏标记、Mock 安装、清缓存 业务逻辑 ApprovalModule 注释:依赖图只应存在于本文件与 ApprovalRepoSingleton

1.4.1 为什么 View 里不允许 new 依赖

ApprovalModule.ets 的类注释把理由写全了,这里提炼成三条可检验的后果:

  1. 依赖图被复制 N 份。每个页面各 new 一次数据源,就多一份 HTTP 客户端配置、多一套缓存句柄。想统一改超时或换实现时,你得先在全局搜 new ApprovalRemoteDs。
  2. 单测无法替换实现。ApprovalRepository 之所以是接口,就是为了让假实现能塞进 ViewModel("单测可以把假实现塞进 ViewModel,不需要起网络与 RDB")。页面直接 new 实现类,这个能力立刻消失。
  3. 冷启动被拖慢。new 的时机变成了"页面被构建时",而页面是运行时按需挂载的;依赖构造里只要有一点同步开销,就会落在用户可见的路径上。

与之配套的第二条纪律:仓储是单例,ViewModel 每次新建。这个区别不是随意定的——它决定了"第二次进入页面还能不能用":

// feature_approval/src/main/ets/di/ApprovalModule.ets
static repository(): ApprovalRepository {
    return ApprovalRepoSingleton.get();      // 单例:无状态服务对象,持有连接/句柄
}

/** 列表页 ViewModel(**每次新实例**,理由见类注释) */
static listViewModel(): ApprovalListViewModel {
    return new ApprovalListViewModel(ApprovalModule.repository());
}

ViewModel 一旦被复用,它持有的 cancelToken 已经是 cancelled,第二次进页面时所有请求结果都会被 isStale() 丢弃——表现为"第二次进列表永远空白"。

术语:装配点(composite root) ── 应用里唯一允许把具体实现拼接起来的位置。依赖注入的"注入"通常发生在两个地方:一处是进程级的组合根(AppInitializer),一处是模块级的装配点(ApprovalModule)。除此之外,任何地方 new 实现类都是违规。


1.5 分层数据流与返回类型契约

1.5.1 完整流转

        用户点击
           │
           ▼
┌─────────────────────┐   绑定 @Trace 字段(只读)
│  View(XxxPage)     │◄──────────────────────────────┐
│  NavDestination      │                               │
│  onShown / onHidden  │  调用命令方法(不 await 结果) │
└──────────┬──────────┘                               │
           │ vm.loadFirstPage() / vm.submit(cmd)      │
           ▼                                          │
┌─────────────────────┐   写 @Trace 字段(唯一写状态入口)│
│ ViewModel            │───────────────────────────────┘
│ @ObservedV2 + @Trace │
│ @Computed(派生)     │
│ @Monitor(变迁埋点)  │
└──────────┬──────────┘
           │ 返回 Result<ApprovalListResult>
           ▼
┌─────────────────────┐   编排三层数据来源 + 降级 + 缓存策略
│ Repository(接口)    │   返回 Result<T>,绝不抛异常
│ ApprovalRepositoryImpl│
└──────────┬──────────┘
           │ 返回 Result<Paged<Approval>>
           ▼
┌─────────────────────┐   拼路径/参数/body、具名 parser、
│ DataSource           │   声明幂等性与重试策略
│ RemoteDs / LocalDs   │   内部可 try/catch,出口仍是 Result
└──────────┬──────────┘
           │
           ▼
   HttpApi → RcpClient → 拦截器链 → RcpTransport → rcp
   (lib_network)

1.5.2 每层的返回类型契约

层 返回类型 硬约束 依据
View 无返回(void) 只做"发起命令 + 绑定字段"。没有 await、没有 JSON、没有定时器、没有 try/catch ApprovalListViewModel 类注释铁律 1
ViewModel 命令方法返回 Promise<void> 或 Promise<Result<T>>;状态通过写 @Trace 字段表达 只能在某个方法内部 await 仓储后写 @Trace 字段;数组整体替换而不是逐项就地改 同上铁律 1、2
Repository Result<T>(Promise<Result<T>>) 绝不抛异常。远端失败且有本地数据时降级为成功,而不是把错误甩给 UI ApprovalRepository 接口注释
DataSource Result<T>(远端);本地层返回 Result<T> 或裸值 + 由 repo 包装 内部可以 try/catch,但出口必须是 Result。远端的 catch 里 e as AppError 之所以成立,是因为拦截器链保证了"抛出的都是 AppError" ApprovalRemoteDs 的 HttpApi 调用 + 第 2 章的拦截器顺序论证
HttpApi Result<T> 解包 data 段 + 调 parser;解析失败归一为 PARSE_FAILED;issues 只记 WARN 不上报错误 lib_network/api/HttpApi.ets 的 unwrap

四类"谁负责什么"的边界(一句话版):

  • View 不知道 HTTP、SQL、缓存策略;
  • ViewModel 不知道 数据从哪来(远端还是缓存),只知道"要点亮哪个状态";
  • Repository 不知道 UI 结构、路由、组件;
  • DataSource 不知道 业务规则(能不能审批由服务端定),只负责数据搬运。

1.5.3 为什么"编排信息"要跟着数据一起上来

ApprovalRepository.list() 返回的不是 Paged<Approval>,而是 ApprovalListResult:

export class ApprovalListResult {
    readonly page: Paged<Approval>;
    /** 数据来自本地缓存(可能是新鲜的,也可能是降级兜底) */
    readonly fromCache: boolean;
    /** 远端请求失败,用陈旧缓存兜底 —— UI 应给出"重试"入口 */
    readonly degraded: boolean;
    /** 缓存命中且正在后台刷新 —— UI 保留旧数据,不要清屏 */
    readonly refreshing: boolean;
    // ...
}

理由写在 ApprovalRepository.ets 的注释里:fromCache/degraded/refreshing 是 UI 必须知道的信息。把它们藏在 repo 内部只返回数据,UI 就只能靠猜,最后演变成"错误页和内容页互相覆盖"的闪烁。让信息随数据一起上来,是这一层存在的理由。

误区:让 Repository 返回裸数据,把"是否降级"做成 Repository 的内部日志。日志只有开发者看得到,UI 看不到——用户会以为自己看到的待审批列表是最新的。


1.6 跨模块协作的三个解耦手法

这三处都是"本来会产生一条不该有的依赖边,用别的方式切断了它"。每处都对应真实文件。

手法① AuthTokenProvider 接口 + AuthTokenRegistry 反向注入:切断 lib_network ↔ lib_auth 的环

问题:lib_auth 需要 lib_network 发登录/刷新请求;而 lib_network 的 AuthInterceptor 需要 lib_auth 提供 token。直接互相 import 就是环。

解法:lib_network 只定义接口,不认识实现;lib_auth 在启动时把实现注册进来。依赖方向仍然是 lib_auth → lib_network → lib_base,单向无环。

// lib_network/src/main/ets/rcp/interceptors/AuthInterceptor.ets
/**
 * 存在的意义是**切断 lib_network 与 lib_auth 的循环依赖**:
 *   lib_auth 需要 lib_network 发登录/刷新请求;
 *   lib_network 需要 lib_auth 提供 token。
 * 解法:lib_network 只依赖这个接口,lib_auth 在应用启动时注册实现。
 */
export interface AuthTokenProvider {
    currentToken(): string;
    shouldRefresh(): boolean;
    refreshToken(): Promise<boolean>;
    onUnauthorized(reason: string): void;
}

export class AuthTokenRegistry {
    private static provider: AuthTokenProvider | undefined = undefined;

    static install(provider: AuthTokenProvider): void {
        AuthTokenRegistry.provider = provider;
    }

    /** 获取实现;未安装时抛错(属配置错误,应在启动阶段暴露而非静默降级) */
    static require(): AuthTokenProvider {
        const p: AuthTokenProvider | undefined = AuthTokenRegistry.provider;
        if (p === undefined) {
            throw new Error('AuthTokenProvider 未安装:请在应用启动时调用 AuthTokenRegistry.install()');
        }
        return p;
    }
}

实现侧则是 TokenRefresher(lib_auth,同时是单飞刷新的实现),安装动作被收敛到 AuthProviderInstaller.install(clientVersion),由 entry 在 AppInitializer.initAuthProvider() 调一次。

三个刻意的设计决定:

  1. require() 抛异常而不是返回 undefined。缺鉴权配置属于启动期配置错误,必须在第一个请求就炸出来,而不是静默地把所有请求发成"匿名请求",几天后表现为"线上 30% 的用户看不到数据"。
  2. 安装动作显式化,不靠模块副作用。HAR 的模块级代码在"谁最先被 import"上是不确定的,靠模块副作用做初始化会踩坑。
  3. entry 只说"安装鉴权",不知道装的是什么。AppInitializer.initAuthProvider() 调 AuthProviderInstaller.install(version),而不是手写 AuthTokenRegistry.install(TokenRefresher.shared())——后者会把"装什么"的知识复制到 entry,lib_auth 换实现时 entry 得跟着改。

手法② lib_ui 的 ApprovalStatusView 只认字符串:切断"UI 库 → 数据层"的反向依赖

问题:审批状态标签放在 lib_ui 里最合理(列表页、详情页、消息卡片都要用)。但 lib_ui 一旦 import 业务枚举(feature_approval 的 ApprovalStatus),依赖方向就变成"UI 依赖业务",lib_ui 再也不可能被别的业务复用。

解法:把契约放在字符串上(后端本来就是字符串),由状态枚举保证它只可能是那几个值之一。

// lib_ui/src/main/ets/components/AppTag.ets
/**
 * 为什么这里只认**字符串**、不 import 业务枚举:
 * lib_ui 是公共 UI 层,一旦 import 了数据层的 ApprovalStatus,
 * 依赖方向就变成"UI 依赖数据层",lib_ui 再也不可能被别的业务复用。
 * 契约放在字符串上(后端本来就是字符串),由状态枚举保证它只可能是这四个值之一。
 */
export class ApprovalStatusView {
    static readonly PENDING: string = 'pending';
    static readonly APPROVED: string = 'approved';
    static readonly REJECTED: string = 'rejected';
    static readonly WITHDRAWN: string = 'withdrawn';

    static toneOf(status: string): AppTagTone {
        switch (status) {
            case ApprovalStatusView.PENDING:
                // 待审批用 warning 而不是 info:它表示"需要有人做事",是可以推动的状态
                return AppTagTone.WARNING;
            case ApprovalStatusView.APPROVED:
                return AppTagTone.SUCCESS;
            // ...
            default:
                // 未知状态落到中性色但文案明确写"未知状态",
                // 不做静默兜底 —— 静默兜底会让契约漂移永远不被发现
                return AppTagTone.NEUTRAL;
        }
    }
}

两层语义隔离也是这条手法的一部分:

  • 标签的"语义 tone"(AppTagTone.INFO/SUCCESS/WARNING/DANGER/NEUTRAL)与"色值"分离。语义到色值的映射只在资源文件(app.color.tag_*)里发生,换主题/换品牌色时组件代码一行不改。反例:一旦出现 AppTagTone.BLUE,就等于把色值决策泄漏进了业务代码。
  • 业务侧只负责"状态 → tone 名"的映射(feature_approval/src/main/ets/view/components/ApprovalCard.ets 的 toneNameOf),lib_ui 只负责"tone → 外观"。

版本门槛:这套写法在 API 20 上即可用,不涉及新 API。

手法③ feature 之间只走 lib_router 的 RouteNames 常量

问题:feature_message 想跳到审批详情。最直觉的写法是 import { ApprovalDetailPage } from 'feature_approval'——这一行立刻产生横向依赖,两个模块从此刻起无法独立删除、独立编译、独立测试。

解法:只依赖 lib_router 的路由名常量与参数契约,运行时由路由表解析到具体页面。

// feature_message/src/main/ets/view/MessageListPage.ets
// 跨 feature 跳转只依赖 lib_router 的路由名常量,**不 import feature_approval**。
// 直接 import 目标页面会立刻产生 feature 之间的横向依赖,两个模块从此无法独立删除。
Router.push(RouteNames.APPROVAL_DETAIL, param);

RouteNames 同时是全局路由表——"谁跳向谁"在一处就能看全:

// lib_router/src/main/ets/const/RouteNames.ets
export class RouteNames {
    static readonly LOGIN: string = 'Login';
    static readonly MAIN: string = 'Main';
    static readonly APPROVAL_LIST: string = 'ApprovalList';
    static readonly APPROVAL_DETAIL: string = 'ApprovalDetail';
    static readonly APPROVAL_CREATE: string = 'ApprovalCreate';
    static readonly MESSAGE_LIST: string = 'MessageList';
    static readonly COLLAB_DEVICES: string = 'CollabDevices';
    static readonly SETTINGS: string = 'Settings';

    static readonly ALL: string[] = [ /* ... */ ];
    static readonly PUBLIC: string[] = [ RouteNames.LOGIN ];
}

这套方案自身也有代价,必须知道:

  1. 路由名是字符串,没有编译期保护。拼错的现象是"点进去白屏"(无异常、无日志、无堆栈)。因此需要 scripts/check-route-map.mjs 守住 pageSourceFile 存在、buildFunction 命名、路由名不重复。
  2. 参数类型也无法强校验。RouteNames.ets 的注释里写明了原因:static readonly LOGIN: string = 'Login' 在 ArkTS 里的类型是 string 而不是字面量 'Login',因此写不出 RouteParamsMap[RouteNames.LOGIN] 这种索引访问。补偿手段是"参数接口 + 调用处对象字面量的类型标注":
const param: ApprovalDetailParam = { id: id, from: 'approval_list' };
Router.push(RouteNames.APPROVAL_DETAIL, param);
  1. 参数必须可序列化,且只放"标识 + 来源"。把整个对象塞进参数会造成"详情页显示的是旧数据"(用户返回列表再进来时尤其明显)。

误区:以为"用了 RouteNames 常量就万事大吉"。常量只解决了名字的一致性,解决不了参数的一致性。真实反例见 1.8 误区 4。


1.7 常见误区

误区 1:把所有公共代码塞进一个 common HAR

错误做法:新建 common 模块,把工具函数、DTO、常量、UI 组件、甚至一部分业务逻辑全放进去,所有模块都依赖它。 正确做法:按"变化频率 + 复用度 + 团队边界"拆成 lib_base(契约与工具,最稳定)/ lib_network / lib_storage / lib_log / lib_router / lib_ui,各自只被真正需要它的模块依赖。 为什么错:common 会变成"人人都依赖、人人不敢改"的上帝模块——改一行工具函数就触发全量重编,且它必然违反对稳定依赖原则(common 依赖了业务,就再也稳不了)。本工程把这条约束做成硬门禁:lib_base 出现任何 file: 依赖即 check-deps.mjs 违规。

误区 2:feature 互相 import 形成环

错误写法:

// feature_message 里
import { ApprovalDetailPage } from 'feature_approval';

正确写法:

import { RouteNames, Router } from 'lib_router';
const param: ApprovalDetailParam = { id: message.relatedId, from: RouteNames.MESSAGE_LIST };
Router.push(RouteNames.APPROVAL_DETAIL, param);

为什么错:横向依赖让两个模块绑死——无法单独删除、无法单独编译、无法单独测试,而且规模一大必然成环。更隐蔽的是双向依赖:feature_approval 也想跳 feature_message 时,环就形成了,编译期可能能过,运行期可能拿到未初始化的模块。本条是 check-deps.mjs 里第一个被拦下来的规则。

误区 3:HAR 用 export *

错误写法:Index.ets 里写 export * from './src/main/ets/index-all'。 正确写法:逐个 export { 具体符号 },类型用 export type { ... }。 为什么错:export * 把内部实现一次性暴露为公共契约,之后任何内部重构都成为破坏性变更,下游随时可能编译失败;同时你无法回答"这个模块对外提供了什么"。本工程连"该导出什么"都做了取舍——feature_approval/Index.ets 刻意不导出 model/datasource/repo/viewmodel,也不导出 ApprovalModule;lib_network/Index.ets 甚至为此写了注释:"内部实现(RcpTransport、各拦截器)虽然导出,但调用方应把它们视为'可替换实现',而不是稳定契约"。

误区 4:以为"路由名对了"就够了,忽略参数契约

现象(曾是本工程的真实缺陷,已修复):feature_message/src/main/ets/view/MessageListPage.ets 曾在跳转时传 { 'approvalId': message.relatedId },而 ApprovalDetailParam 的契约是 { id: string; from: string },详情页读的是 param.id。结果是:从消息列表点进详情,缺 id,详情页会立刻 pop 回上一页——用户看到的是"点了没反应"。 收敛后的写法(本工程的落地范例):

// feature_message/src/main/ets/view/MessageListPage.ets:319-323
// 参数键名必须逐字匹配 ApprovalDetailParam(id + from,两者都必填):
const param: ApprovalDetailParam = {
    id: message.relatedId,
    from: RouteNames.MESSAGE_LIST
};
Router.push(RouteNames.APPROVAL_DETAIL, param);

为什么必须这样:Router.push<P extends object>(name: string, param?: P) 的形参类型不携带路由名信息(见 1.6 手法③ 的成因),所以编译期不会拦。补偿手段只有一个:调用处显式标注参数类型(const param: ApprovalDetailParam = {...}),让编译器替你检查字段名。不写这行标注,就等于放弃了唯一的保护。

误区 5:把 UIAbility 和路由注册放进 HSP

错误做法:为了"统一管理",把 Ability、@Entry 页面、路由表注册都放进一个 HSP。 正确做法:UIAbility 与 @Entry 页面只能存在于 HAP(entry);HSP 只放公共组件与主题(lib_ui)。 为什么错:HSP 是随宿主 HAP 一起安装的共享库,自身不是可安装单元,不能承载应用的拉起入口;同时 HSP 受"同签名同 bundle"约束,把它做成"应用骨架"会让任何想复用的模块都被拖进这个约束。形态选择的正解是:可安装/需要独立分发 → HAP;被 ≥2 个 HAP 引用且体积大 → HSP;其余 → HAR(默认选择)。

误区 6:依赖图不校验,靠人肉 review

错误做法:把依赖规则写进团队文档,靠 CodeReview 抓。 正确做法:把规则编码进 scripts/check-deps.mjs 与 scripts/check-banned-api.sh,接进 CI 的静态检查阶段,失败即阻断。 为什么错:一次为了赶进度写的横向 import 就能把两个 feature 焊死,而编译、打包、运行都不报错——它只会在"某天想删掉一个模块"时集中爆发。规则写在文档里没有约束力;能被绕过的门禁等于没有门禁。同理,V1 装饰器与 V2 装饰器只差一个字符,人眼在 Review 里抓不住。

误区 7:同层 lib 互相依赖不当回事

现象:lib_auth → lib_network、lib_network → lib_log 都是真实存在的同层依赖,脚本只给警告。 正确做法:逐个判断方向是否合理——lib_auth 依赖 lib_network 合理(它要发请求),lib_network 依赖 lib_log 合理(它要打日志);但一旦出现 lib_a → lib_b → lib_a,就是环的雏形,必须立刻拆掉。 为什么错:同层依赖本身不是错,成环才是。脚本把它做成警告而不是硬门禁,是因为"需要人判断的事不该做成硬门禁"——否则大家会习惯性地加白名单,门禁整体失效。


1.8 实践任务

任务:给 OfficeFlow 加一个新 feature 模块,并让门禁保持全绿

阶段一:建图

  1. 跑 node scripts/check-deps.mjs,把当前依赖图抄进自己的笔记,并在图上标出你打算挂新模块的位置。
  2. 决定新模块的形态(HAR 还是 HSP),写出理由(被打几个 HAP 引用?有没有跨 HAP 的全局单例语义?)。
  3. 在 build-profile.json5 的 modules 里注册新模块,并同步更新 1.2 的模块清单表。

阶段二:落地

  1. 建目录骨架,按 1.4 的九个目录(model/datasource/repo/viewmodel/view/components/router/mock/di)逐个填空,没有内容的目录不要建。
  2. 写 Index.ets,逐个 export 稳定契约,禁止 export *;刻意想清楚"哪些不导出"。
  3. 在新模块里实现一条最小链路:View → ViewModel → Repository → DataSource → HttpApi,全程返回 Result<T>,View 里不出现 try/catch 与 await。
  4. 在 di/ 里写装配点(仓储单例 + ViewModel 工厂),页面只从装配点取依赖。

阶段三:接路由

  1. 在 lib_router/const/RouteNames.ets 里加路由名与参数接口(改的是 lib_router,不是别的 feature)。
  2. 在 entry/pages/Index.ets 里加一个 @Builder export function xxxRouteBuilder,并同步 route_map.json 与 destBuilder 分支(三处必须一次改完)。
  3. 从另一个 feature 用 Router.push(RouteNames.XXX, param) 跳过来,调用处显式标注参数类型。

阶段四:让门禁说话

  1. 跑 SKIP_BUILD=1 bash scripts/ci.sh,三项检查必须全绿。
  2. 故意在新模块的 oh-package.json5 里加一条对别的 feature 的依赖,确认 check-deps.mjs 拦下;确认后删掉。
  3. 故意在某处写 @State,确认 check-banned-api.sh 拦下;确认后删掉。

验收 checklist

  • [ ] 新模块已注册进 build-profile.json5 的 modules,check-deps.mjs 输出中出现它且无违规
  • [ ] 新模块的 oh-package.json5 里没有任何 file:../feature_*
  • [ ] Index.ets 里逐个 export,全文件搜不到 export *
  • [ ] di/ 之外的地方搜不到 new XxxRepository / new XxxRemoteDs
  • [ ] view/ 目录里搜不到 try、catch、await、JSON.parse
  • [ ] 全链路返回 Result<T>:DataSource 与 Repository 的公开方法签名都能看到 Result
  • [ ] route_map.json 的 name/buildFunction/pageSourceFile 三处与 Index.ets 的 destBuilder 一一对应,check-route-map.mjs 通过
  • [ ] 跨模块跳转走 RouteNames 常量,且调用处有显式参数类型标注
  • [ ] 故意制造的横向依赖与 V1 装饰器都被脚本拦下(有终端输出为证)
  • [ ] 更新了 1.2 的模块清单表与 README.md 的模块地图

交付物清单

交付物 形式 说明
新 feature 模块 源码 九个目录中真正用到的那些,加 Index.ets
模块清单表更新 Markdown 模块 / 形态 / 职责 / 为什么是这个形态 / 对应章节
依赖图截图 终端输出或 Mermaid 来自 node scripts/check-deps.mjs 的真实输出
门禁输出 终端输出 三项静态检查全绿的完整输出
形态选择说明 Markdown 半页 为什么这个模块用 HAR / HSP,附量化依据(被几个 HAP 引用)
违规验证记录 终端输出 两次"故意违规被拦下"的证据

1.9 常见问题排查表

现象 优先怀疑 验证方式
改了 lib_ui 里的组件,界面没变 HSP 产物未随宿主重装 重新运行 entry(HSP 是独立产物,必须重装宿主)
某个页面"点了没反应" 路由名拼错 / buildFunction 名字不一致 / destBuilder 少一个分支 跑 node scripts/check-route-map.mjs;再逐个比对三处
两个页面看到的断点档位不一致 有页面自己注册了 windowSizeChange 全工程搜索 'windowSizeChange',应当只有 EntryAbility 一处
第二次进列表永远空白 ViewModel 被复用,cancelToken 已是 cancelled 检查取 VM 的地方是不是工厂(ApprovalModule.listViewModel())而不是缓存字段
单测里无法替换数据源 页面/VM 里直接 new 了实现类 检查是否通过 ApprovalModule.repository() 取接口
check-deps.mjs 报了"依赖了未注册的模块" 新模块忘了写进 build-profile.json5 的 modules 打开根 build-profile.json5 对照 modules 数组
新增 lib_* 后脚本报"未登记模块" RULES 白名单没有覆盖它 本工程的 check-deps.mjs 用层级判定(layerOf)而非名字白名单,因此正常不会出现;若出现说明模块命名不符合 lib_ / feature_ 前缀约定

1.10 已知缺陷与待收敛项(对表记录)

以下几处是文档与代码对表的产物:前两条曾是真实缺陷,现已按工单收敛(改写为"收敛手法已落地的范例"留档);ApprovalStatus 的双定义经核实为有意设计;只有最后一条(配置口径)仍是待收敛项:

  1. 跨 feature 跳转的参数名不一致(已修复,收敛范例):feature_message/view/MessageListPage.ets 曾传 { 'approvalId': ... },而契约 ApprovalDetailParam 与 ApprovalDetailPage 用的是 id,现象(详情页立刻 pop)很隐蔽。现已按契约收敛:MessageListPage.ets:319-323 构造 ApprovalDetailParam { id, from: RouteNames.MESSAGE_LIST } 并显式标注参数类型——见 1.7 误区 4,该写法本身就是防复发的手段。
  2. 契约重复定义:
    • ApprovalListParam / ApprovalCreateParam(已修复,收敛范例):曾在 lib_router/src/main/ets/const/RouteNames.ets 与 feature_approval/src/main/ets/router/ApprovalRoute.ets 各定义一份且字段不同。现已收敛到 lib_router 单一权威;ApprovalRoute.ets:31-42 刻意只 re-export、不再定义,并把"两份同名但形状不同的接口"的危害写成注释——这是"参数契约唯一来源只能是 lib_router"这条纪律落地的范例。
    • ApprovalStatus 与 APPROVAL_STATUS_VALUES 同时出现在 feature_approval/src/main/ets/model/ApprovalStatus.ets(5 个状态,含 Draft,成员名 PascalCase)与 lib_storage/src/main/ets/rdb/ApprovalDao.ets(4 个状态,成员名 SCREAMING_CASE)——这是有意设计,不是待收敛项:业务枚举与持久化枚举刻意分离,两处均有警示注释,且两者之间的换算必须显式完成(见 ApprovalDao.ets:19-27 的文件头注释)。改一处漏一处时不会静默出错,换算层会在编译期逼你表态。
  3. 配置两处口径不一致:根 build-profile.json5 的 obfuscation.enable = false 与各模块 buildOptionSet.release 的 enable = true 并存。建议以模块级为准并删除根级那一处(见 0.6 误区 3)。

1.11 延伸阅读

  • 官方文档 → 应用开发 → 应用框架 → 应用程序包结构(HAP / HAR / HSP 的产物结构与安装分发模型)
  • 官方文档 → 应用开发 → 应用框架 → 共享库开发(HAR)(打包方式、导出面、Index.ets 约定)
  • 官方文档 → 应用开发 → 应用框架 → 动态共享库开发(HSP)(同签名同 bundle 约束、多 HAP 共享机制)
  • 官方文档 → 应用开发 → 应用框架 → 多模块工程与模块间依赖(oh-package.json5、file: 依赖、overrides)
  • 官方文档 → 应用开发 → 应用框架 → ArkUI → Navigation 组件与系统路由表(routerMap)
  • 官方文档 → 工具 → 构建配置参考(build-profile.json5)(modules、products、buildOptionSet)
  • 本教程 第 9 章 工程化与模块化架构(原则与演进路径)
  • 本教程 11/02 网络层与统一错误处理(Result 与拦截器链的真实实现)
  • 本工程 code/office-flow/README.md 的「模块依赖图」与「工程约定」

上一章:11/00 项目总览 | 下一章:11/02 网络层与统一错误处理

📄 第 1 章 环境搭建与工具链 01-environment-toolchain.md

环境问题吃掉新手进度的一半以上:版本号该写哪种格式、SDK 到底要不要单独装、工程里每个文件负责什么、命令行怎么出一份能装到设备上的包。这一章把「装 IDE → 建工程 → 命令行构建 → 装到设备」一次走完,并给出可归档、可在 CI 复现的环境基线。


1.0 本章目标

  1. 能解释 26.0.0 与 6.0.0(20) 两种格式的断代原因,并在工程里写对每一个版本字段。
  2. 能完成 DevEco Studio 26.0.0 首次配置(SDK 勾选、受管 Node、模拟器含 Car、代理与 ohpm 源),产出可归档的环境清单。
  3. 能逐字段说明 app.json5 / module.json5 / build-profile.json5 / oh-package.json5 / hvigorfile.ts 的职责,回答「改配置该改哪个文件」。
  4. 能用 hvigorw 命令行完成 clean → sync → assembleHap → assembleApp,区分 signed 与 unsigned 产物,并用 hdc 完成安装与启动。
  5. 能用「编译期 → 运行期 → 降级」三段式写出兼容代码,并用 CapabilityGate 把 canIUse 判定收口到一处。

1.1 版本名词与格式断代

1.1.1 三类版本号各管一件事

类别 典型值 出现在哪 变更后果
SDK / API 版本 26.0.0 build-profile.json5 的三个字段 决定能调用哪些 API、系统按哪套行为对待你
应用版本 versionCode: 1000000 / versionName: "1.0.0" AppScope/app.json5 决定商店能否覆盖安装、用户看到什么
依赖版本 "@scope/pkg": "1.2.3" oh-package.json5 决定三方库行为,与 SDK 版本无关

术语:编译期 SDK(compileSdkVersion)——编译器按哪个版本的 API 声明做类型检查,只影响「能否编译通过」,不影响运行行为。 术语:运行目标(targetSdkVersion)——系统按哪个版本的默认行为约束应用。它是行为开关:权限提示、动效、字体缩放、返回键语义都可能随之改变。 术语:兼容下限(compatibleSdkVersion)——低于此版本的设备无法安装。

1.1.2 格式断代与语义

时代 格式 例子 括号里的 N
API 26 之前 X.Y.Z(N) 6.0.0(20) OpenHarmony 底座的 API Level
API 26 起 X.Y.Z 26.0.0 不再有括号

换格式的原因是旧写法把「底座 Level」硬编码在括号里,导致 HarmonyOS 与底座两条版本线纠缠——同一个 N 可能对应不同能力集合。新格式把语义固定下来:X 主版本可能含 API 修改与行为变更,必须适配;Y 次版本新增功能,向后兼容;Z 修订版本修问题,向后兼容。

但断代不等于替换:compatibleSdkVersion 指向 API 20,而 20 仍在旧体系内,必须写旧格式。三种格式在同一文件里共存不是笔误:

// build-profile.json5(工程根,节选)
{
  "app": {
    "products": [
      {
        "name": "default",
        "signingConfig": "default",
        "runtimeOS": "HarmonyOS",
        // 编译期用 26 的 API 声明 → 可引用 API 26 新增能力
        "compileSdkVersion": "26.0.0",
        // 系统按 26 的行为约束应用 → 需按「向 26.0.0 升级」清单自查
        "targetSdkVersion": "26.0.0",
        // 兼容下限仍是 20 → 必须留在旧格式
        "compatibleSdkVersion": "6.0.0(20)"
      }
    ]
  }
}

1.1.3 应用版本与 SDK 版本的隔离

// AppScope/app.json5
{
  "app": {
    "bundleName": "com.example.officeflow",
    "vendor": "example",
    "versionCode": 1000000,        // 正整数,仅用于商店比较大小,每次提审递增
    "versionName": "1.0.0",        // 字符串,仅用于展示
    "icon": "$media:app_icon",     // 必须是分层图标
    "label": "$string:app_name"    // 走资源引用才能多语言
  }
}

bundleName 上架后不可更改,换包名等于换一个应用。versionCode 与 compatibleSdkVersion 没有任何换算关系,写成 "26.0.0" 会被构建工具直接拒绝。

版本差异提示:全书 compileSdkVersion / targetSdkVersion 恒为 26.0.0,compatibleSdkVersion 恒为 6.0.0(20)。凡偏离处正文会显式说明。

误区:以为「兼容下限写旧格式」是工具还没跟上。真实原因是格式与语义绑定:6.0.0(20) 表达「版本 6.0.0,底座 Level 20」,而 6.0.0 无法表达底座号。稳妥做法是让 IDE 生成后再改数值,不要手敲字符串。


1.2 DevEco Studio 26.0.0 的安装与首次配置

1.2.1 SDK 从哪来(本节最关键)

版本门槛:自 DevEco Studio 5.0(5.0.0(12))起,SDK 已随 IDE 预置;到 26.0.0 时代,单独下载安装 SDK 已成历史。

这条改动的实际影响:

  • 不需要「先装 IDE、再去官网下 SDK、再指定路径」这套旧流程。
  • SDK 与 IDE 版本严格绑定,不能跨版本混搭——用 26.0.0 的 IDE 配 24 的 SDK 会直接构建失败。
  • IDE 升级 = SDK 升级。升级 IDE 后必须重新执行一次 sync,让工程重新解析 SDK。
  • SDK 路径不要手动搬移或改名。IDE 记录了原路径,搬走后报的是「找不到 SDK」而不是「路径不对」,排查方向容易跑偏。

安装后先确认三处一致:

查看位置 应看到 不一致时的动作
Help > About DevEco Studio 26.0.0 Release 跨大版本升级先读版本说明、完整备份工程,升级后必须重新 sync
Help > About HarmonyOS SDK HarmonyOS SDK 26.0.0 检查是否装了旧 SDK 目录并被优先选中
Settings > SDK 的安装路径 位于 IDE 安装目录下 恢复默认路径后重新 sync

1.2.2 SDK 勾选

分组 勾选项 用途 漏勾的后果
SDK Platforms HarmonyOS SDK(API 26) API 声明与运行时库 无法编译
SDK Platforms Phone / Tablet / 2in1 / Wearable / Car 目标设备类型资源 对应形态无法构建
SDK Tools Command Line Tools 提供 hvigorw / ohpm / codelinter 命令行构建与 CI 不可用
SDK Tools Toolchains 编译工具链与 hdc 无法安装到设备
SDK Tools Emulator 模拟器运行时 无设备可调试

版本门槛:API 26 起 SDK Platforms 新增 Car 设备类型。 需要车机形态的工程必须勾选,并在模块的 deviceTypes 中声明 car,否则构建期报设备类型不匹配。

1.2.3 受管 Node / ohpm / hvigor 的版本绑定

三者是一条链,不要拆开:

DevEco Studio 26.0.0
  ├── 受管 Node     ← IDE 内置,不读系统 PATH
  ├── ohpm          ← 包管理:装依赖、发库
  └── hvigor        ← 构建引擎,版本由「工程内配置」决定,随工程走

关键机制:hvigor 的版本由工程声明,不由 IDE 决定。所以同一 IDE 打开不同工程可能使用不同 hvigor——这是特性,它保证老工程升级 IDE 后仍能用原构建引擎跑通。

// hvigor/hvigor-config.json5
{
  "hvigorVersion": "26.0.0",                    // 与开发套件版本对齐
  "dependencies": {
    "@ohos/hvigor-ohos-plugin": "26.0.0"        // 提供 appTasks / hapTasks / harTasks / hspTasks
  },
  "execution": {
    "daemon": true,        // 常驻进程,重复构建更快
    "incremental": true,   // 增量构建
    "parallel": true,      // 多模块并行
    "typeCheck": false     // 单模块快速构建可关;CI 上建议开
  },
  "nodeOptions": {
    "maxOldSpaceSize": 8192                     // 大型多模块工程易 OOM,8 GB 是常见起点
  }
}

验证版本:

# 在 DevEco Studio 内置终端执行(环境变量已注入)
node -v && ohpm -v && hvigorw -v

误区:用系统安装的 Node.js 跑 hvigorw。受管 Node 与构建工具链严格绑定,用系统 Node 常见后果是 EBADENGINE,或更隐蔽的「构建成功但产物异常」。项目命令一律在 IDE 内置终端执行,CI 上显式使用 Command Line Tools 附带的 Node。

1.2.4 模拟器(含 Car)

在 Device Manager 中:选择模板(Phone / Tablet / 2in1 / Wearable / Car)→ 下载系统镜像 → 启动并确认 hdc list targets 能看到设备。镜像体积大,先确认磁盘余量(建议 ≥ 30 GB)。

Windows 上模拟器依赖虚拟化,启动即失败时优先查:

现象 常见原因 处理
启动卡在 0% Hyper-V / WHPX 未启用 在「启用或关闭 Windows 功能」中开启虚拟机平台
能看到设备但装不上 hdc 版本与设备不匹配 hdc kill -r 重启服务,或换用 IDE 附带的 hdc

1.2.5 代理与 ohpm 源

企业网络下这两处不配好,sync 必然失败:

# 1) 代理:Settings > HTTP Proxy 填地址与端口;内网直连选 No proxy
# 2) ohpm 源:
ohpm config get registry
ohpm config set registry https://your-company-mirror.example.com/ohpm/
配置项 位置 作用
HTTP Proxy Settings > HTTP Proxy IDE 与 hvigor 的下载流量
ohpm registry ohpm config oh-package.json5 中三方库依赖的解析源

误区:只想改 ohpm 源,却顺手改了系统 npm 源。hvigor 的依赖解析走 ohpm 与工程级配置,改系统 npm 源对构建无影响,反而污染其它项目。改动范围最小化,并写进团队文档。


1.3 工程解剖

1.3.1 顶层结构

OfficeFlow/
├── AppScope/                        # 应用级全局配置与资源(整个 App 唯一一份)
│   ├── app.json5                    # bundleName / versionCode / icon / label
│   └── resources/base/              # 应用级图标、名称字符串
├── entry/                           # 默认生成的 HAP 模块(唯一可安装入口)
│   ├── src/main/
│   │   ├── ets/
│   │   │   ├── entryability/EntryAbility.ets   # UIAbility 入口
│   │   │   ├── pages/Index.ets                 # 首页
│   │   │   └── common/                         # 模块内公共代码
│   │   ├── resources/                          # 模块级资源(限定词目录)
│   │   └── module.json5                        # 模块声明(能力 / 权限 / 页面)
│   ├── build-profile.json5          # 模块级构建配置
│   ├── hvigorfile.ts                # 模块级构建任务声明
│   ├── oh-package.json5             # 模块级依赖声明
│   └── obfuscation-rules.txt        # 混淆规则
├── hvigor/hvigor-config.json5       # 构建引擎版本与参数
├── hvigorfile.ts                    # 工程级构建任务声明
├── build-profile.json5              # 工程级构建配置(products / signingConfigs / modules)
├── oh-package.json5                 # 工程级依赖声明
├── oh-package-lock.json5            # 依赖锁(必须入库)
└── oh_modules/                      # 依赖实体(不入库,由 sync 生成)

一句话记住分层:AppScope 管「这个 App 是谁」,模块管「这个模块能做什么」,两个 build-profile 管「怎么构建」。

1.3.2 entry 与 module.json5

// entry/src/main/module.json5
{
  "module": {
    "name": "entry",                            // 模块名,工程内唯一
    "type": "entry",                            // entry / feature / shared
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",              // 启动时优先进这个 Ability
    "deviceTypes": ["phone", "tablet", "2in1"], // 声明支持的设备形态
    "deliveryWithInstall": true,                // 随应用一起安装
    "installationFree": false,                  // 是否支持免安装(元服务用)
    "pages": "$profile:main_pages",             // 页面路由表
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,                       // 是否允许外部应用拉起
        "skills": [
          // 桌面入口必须声明这两项,否则桌面无图标
          { "entities": ["entity.system.home"], "actions": ["action.system.home"] }
        ]
      }
    ],
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET",
        "reason": "$string:reason_internet",
        "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
      }
    ]
  }
}

字段按「四个问题」归类:

问题 相关字段 说明
我是谁 name / type / description type 决定产物形态:entry/feature 出 HAP,shared 出 HSP
装到哪些设备 deviceTypes 与 SDK 已勾选设备类型求交;交集为空则无法安装
入口在哪 mainElement / abilities[].srcEntry / skills 桌面点击 → 匹配 action.system.home → 拉起 mainElement
能用什么权限 requestPermissions 只声明,运行时仍需申请

术语:main_pages——resources/base/profile/main_pages.json 维护的页面路径清单,只服务传统页面路由。本教程主线路由走 Navigation + route_map.json 系统路由表(第 4 章),两者职责不同,不要混用。

1.3.3 resources 限定词目录

entry/src/main/resources/
├── base/                        # 默认资源,任何限定词目录缺失时回落到这里
│   ├── element/
│   │   ├── string.json          # 字符串:$r('app.string.xxx')
│   │   ├── color.json           # 颜色:  $r('app.color.xxx')
│   │   ├── float.json           # 尺寸:  $r('app.float.xxx'),值写 "16vp"
│   │   └── boolean.json         # 布尔开关
│   ├── media/                   # 图片、图标
│   └── profile/
│       ├── main_pages.json      # 页面清单
│       └── route_map.json       # Navigation 系统路由表
├── zh_CN/element/string.json    # 中文文案覆盖
├── en_US/element/string.json    # 英文文案覆盖
├── dark/element/color.json      # 深色模式颜色覆盖
└── rawfile/                     # 原样打包的资产(HTML / 字体 / 契约 JSON)

匹配规则是逐级回落:设备为中文深色模式时,zh_CN 命中文案、dark 命中颜色、其余回落到 base。因此 base 必须是完整可用的那一套,限定词目录只放差异项。

误区:在 base/element/color.json 里只放浅色值,dark/element/color.json 里只放一部分覆盖。深色模式下少数控件仍是浅色——因为缺失项回落到了 base。正确做法是两套 key 集合完全一致,用脚本或 Code Linter 校验。

1.3.4 oh-package.json5 / build-profile.json5 / hvigorfile.ts

// lib_ui/oh-package.json5
{
  "name": "lib_ui",
  "version": "1.0.0",
  "main": "Index.ets",                // 对外导出面只走这一个文件
  "dependencies": {
    "lib_base": "file:../lib_base",   // 本地模块依赖走相对路径,不写版本号
    "@scope/pkg": "1.2.3"             // 三方库写精确版本,禁用 ^ / ~
  }
}

HSP 的动态依赖在使用方模块声明,不写在 HSP 自己的 oh-package.json5 里。例如 entry 要动态加载 lib_ui 时,在 entry/oh-package.json5 中写:

// entry/oh-package.json5(节选)—— 使用方声明动态依赖
{
  "dynamicDependencies": {
    "lib_ui": "file:../lib_ui"
  }
}

工程级 build-profile.json5 的四个顶层块:

顶层块 职责 常见改动
app.signingConfigs 签名材料(证书 / Profile / 密钥库) 首次打开工程时由 IDE 生成
app.products 产品形态(版本三件套、runtimeOS) 改 targetSdkVersion、加渠道
app.buildModeSet 构建模式(debug / release)的编译参数 是否混淆、是否带符号表
modules 参与构建的模块清单与目标 新增模块后必须在此登记

模块级 build-profile.json5 只管本模块怎么编译。注意 apiType 只有 stageMode 一个有效值:

// entry/build-profile.json5
{
  "apiType": "stageMode",
  "buildOptionSet": [
    {
      "name": "release",
      "arkOptions": {
        "obfuscation": {
          "ruleOptions": { "enable": true, "files": ["./obfuscation-rules.txt"] }
        }
      }
    }
  ],
  "targets": [{ "name": "default" }, { "name": "ohosTest" }]
}

hvigorfile.ts 只做一件事:声明本层提供哪些内置构建任务。

// 工程根目录 hvigorfile.ts —— 应用级任务
export { appTasks } from '@ohos/hvigor-ohos-plugin';

// entry/hvigorfile.ts —— HAP 模块任务
export { hapTasks } from '@ohos/hvigor-ohos-plugin';

// lib_base/hvigorfile.ts —— HAR 模块任务
export { harTasks } from '@ohos/hvigor-ohos-plugin';

// lib_ui/hvigorfile.ts —— HSP 模块任务
export { hspTasks } from '@ohos/hvigor-ohos-plugin';

版本差异提示:如果配置里出现 faMode、或工程根目录存在 config.json,那是 FA 模型时代的工程。本教程不涉及,也不要把那套写法带进新工程。


1.4 构建链路

1.4.1 三个阶段

sync   →   build   →   package
依赖解析     编译 ArkTS     打包 + 签名
生成 oh_modules   生成中间产物    产出 HAP / APP
阶段 触发方式 产出 失败时的典型症状
sync 打开工程、改 oh-package.json5、hvigorw --sync oh_modules/、资源索引 「找不到模块」
build hvigorw assembleHap build/ 下中间产物 类型报错、ArkTS 限制报错
package 含在 assemble 内 .hap / .app 签名失败、资源冲突

1.4.2 hvigorw 命令

命令一律在工程根目录、IDE 内置终端执行。

命令 作用 常用参数
hvigorw clean 清空构建产物 --no-daemon
hvigorw --sync 依赖同步与工程解析 --no-daemon
hvigorw assembleHap 构建 HAP --mode module -p module=entry@default -p product=default -p buildMode=debug
hvigorw assembleHar / assembleHsp 构建 HAR / HSP --mode module -p module=lib_base@default
hvigorw assembleApp 构建可上架 .app --mode project -p product=default -p buildMode=release
hvigorw --stop-daemon 停掉常驻构建进程 排查「改了代码没生效」时先执行它
参数 取值 说明
--mode module / project 单模块组装 vs 整包组装
-p module entry@default 模块名@目标名,对应模块 build-profile 的 targets
-p product default 对应工程 build-profile 的 products[].name
-p buildMode debug / release 决定是否混淆、是否带调试符号
--no-daemon 无值 单次执行,不复用常驻进程;CI 上建议加
# 推荐的命令行出包序列(debug 包)
hvigorw --stop-daemon
hvigorw clean --no-daemon
hvigorw --sync --no-daemon
hvigorw assembleHap --mode module -p module=entry@default -p product=default -p buildMode=debug --no-daemon

1.4.3 产物目录与 signed / unsigned

entry/build/default/outputs/default/
├── entry-default-signed.hap      # 已签名 —— 可直接 hdc install
└── entry-default-unsigned.hap    # 未签名 —— 不能直接安装

build/outputs/default/
├── officeflow-default-signed.app
└── officeflow-default-unsigned.app
产物 能否直接装到设备 用途
*-signed.hap 能 本地调试、内部测试分发
*-unsigned.hap 不能 交给流水线统一签名,或上架前由 AGC 再签名
*-signed.app 能(走 AGC 通道) 上架提交
*-unsigned.app 不能 交由 AGC 完成最终签名

误区:拿到 unsigned.hap 直接安装报错后,去反复改 module.json5。unsigned 的定义就是不可安装,症状与配置无关。先确认产物名带不带 signed,再看签名材料。

1.4.4 hdc 安装与调试

命令 作用
hdc list targets 列出已连接设备 / 模拟器
hdc install <path>.hap / hdc uninstall <bundleName> 安装 / 卸载
hdc shell aa start -a EntryAbility -b com.example.officeflow 拉起指定 Ability
hdc shell aa force-stop com.example.officeflow 强杀应用进程
hdc shell bm dump -n com.example.officeflow 查看已安装包信息(版本、权限、模块)
hdc file recv <设备路径> <本地路径> 从设备取文件(导出日志、dump)
hdc hilog 查看设备日志,建议配合 grep
hdc kill -r 重启 hdc 服务,解决「设备突然不可见」
hdc list targets
hdc install entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.example.officeflow
hdc hilog | grep OfficeFlow

误区:设备不可见就先怀疑代码。hdc 是有状态的:USB 重插、模拟器重启后服务端常处于半失效状态。标准顺序是 hdc kill -r → hdc list targets → 重启设备,比翻构建日志快得多。


1.5 兼容策略三段式

compatibleSdkVersion = 6.0.0(20) 意味着编译期是 26、运行环境可能是 20。这个错配不会在编译期暴露,只在运行时崩。所以任何 API 21+ 的能力都走三段式。

第一段 编译期:声明目标与下限     → 决定「能不能编译过」
第二段 运行期:canIUse 判定能力    → 决定「这台上有没有这个能力」
第三段 降级  :提供等价的旧实现     → 决定「没有能力时用户看到什么」

1.5.1 第一段:编译期声明

字段 值 作用
compileSdkVersion 26.0.0 能用 26 的 API 声明,不保证设备支持
targetSdkVersion 26.0.0 系统按 26 的行为约束应用
compatibleSdkVersion 6.0.0(20) 覆盖 API 20 及以上设备

误区:以为把 compatibleSdkVersion 调高到 26.0.0 就能规避兼容问题。结果是能安装的设备大幅减少,兼容问题从「运行时偶发」变成「装不上」,覆盖量损失远比 bug 严重。正确做法是保留下限,用第二、三段兜住。

1.5.2 第二段:运行期能力判定

canIUse 是 ArkTS 的全局内置函数,接收 SystemCapability 字符串、返回布尔值。

SystemCapability 覆盖能力
SystemCapability.Communication.NetStack 网络栈(HTTP / Socket)
SystemCapability.Multimedia.Camera.Core 相机核心能力
SystemCapability.DistributedHardware.DeviceManager 分布式设备管理
SystemCapability.Ability.AbilityRuntime.Core 应用模型运行时核心

术语:SystemCapability——系统能力的字符串标识,是「设备上有没有这个能力」的唯一权威判断依据。完整清单以官方「SystemCapability 清单」为准,不要凭字符串猜。

canIUse 只回答「能力是否存在」,不回答「版本是否够新」。需要按 API 版本精确分档时配合 deviceInfo.sdkApiVersion:

import { deviceInfo } from '@kit.BasicServicesKit';

const isApi26OrAbove: boolean = deviceInfo.sdkApiVersion >= 26;

1.5.3 第三段:降级实现 + 统一收口

真实工程里 canIUse 最怕散落在各处:今天业务里判一遍、明天工具类里再判一遍,判定口径迟早不一致。把它收口成一个类。

// lib_base/src/main/ets/gate/CapabilityGate.ets
import { hilog } from '@kit.PerformanceAnalysisKit';
import { deviceInfo } from '@kit.BasicServicesKit';

const DOMAIN: number = 0x0000;
const TAG: string = 'CapabilityGate';

/**
 * 运行期兼容策略的唯一下沉点。
 * 约定:全工程只有这里出现 canIUse,业务代码只调用 supported / pick。
 */
export class CapabilityGate {
    // 能力在进程生命周期内不会变化,判定结果缓存,避免重复探测
    private static readonly probeCache: Map<string, boolean> = new Map<string, boolean>();

    /** 判定设备是否具备某 SystemCapability。 */
    static supported(sysCap: string): boolean {
        const cached: boolean | undefined = CapabilityGate.probeCache.get(sysCap);
        if (cached !== undefined) {
            return cached;
        }
        let ok: boolean = false;
        try {
            ok = canIUse(sysCap);
        } catch (err) {
            // 防御性兜底:即便实现变化也不向上传播
            ok = false;
        }
        CapabilityGate.probeCache.set(sysCap, ok);
        hilog.info(DOMAIN, TAG, 'probe %{public}s -> %{public}s, sdkApi=%{public}d',
            sysCap, String(ok), deviceInfo.sdkApiVersion);
        return ok;
    }

    /** 有增强链路走增强,没有走降级;调用方不必关心判定细节。 */
    static pick<T>(sysCap: string, enhanced: () => T, fallback: () => T): T {
        return CapabilityGate.supported(sysCap) ? enhanced() : fallback();
    }
}

调用侧只关心两条链路,判定与日志都被吃掉(CapabilityGate 由 lib_base 根 Index.ets 统一导出):

import { CapabilityGate } from 'lib_base';

const DISTRIBUTED: string = 'SystemCapability.DistributedHardware.DeviceManager';

export function listCollaborators(): Promise<string[]> {
    return CapabilityGate.pick<Promise<string[]>>(
        DISTRIBUTED,
        (): Promise<string[]> => discoverRemoteDevices(),   // 增强链路
        (): Promise<string[]> => Promise.resolve(['local']) // 降级链路:保证功能可用且不报错
    );
}

function discoverRemoteDevices(): Promise<string[]> {
    return Promise.resolve([]);
}
段位 判定依据 出现在哪 缺失时的失败表现
编译期 build-profile.json5 三个版本字段 配置,不在代码里 编译失败(明显、易发现)
运行期 canIUse + deviceInfo.sdkApiVersion 只在 CapabilityGate 内 崩溃(隐蔽)
降级 业务回调 业务调用点 功能空白但可用

版本门槛:本节代码不依赖 API 26 新增能力,在兼容下限 20 上可直接运行。后续章节凡出现 > 版本门槛:API 26 新增 的写法,都必须落在 CapabilityGate.pick 的增强分支里,不允许直接写在主流程上。


1.6 26.0.0 工具链增量

先建立一条认知:以下四项全部是 IDE(工具链)能力,不是运行时 API。它们不改变设备行为,因此不需要 canIUse 判定,也不需要写进业务代码的兼容分支。

能力 属于哪一层 解决什么问题 代码是否需要改动
8 档断点 UI 预览 IDE 预览器 一次看清多宽度档位下的布局 不需要;但布局本身要按断点写(第 3 章)
Code Scanner 资源泄漏检查 IDE 静态检查 静态发现未释放的监听、Context、媒体资源 不需要改 API,但需按检查结果修代码
dump 文件解析 IDE 分析器 直接打开堆 / 线程 dump 定位内存与卡顿 不需要,用于定位阶段
Car 模拟器 IDE 运行环境 车机形态的本地调试 需要:deviceTypes 加 car 并做适配

1.6.1 8 档断点 UI 预览

预览器支持一次性渲染 8 个宽度档位(在 xs / sm / md / lg / xl / xxl 六档基础上新增细分档位,合计 8 档;具体档位划分以 DevEco 26.0.0 实际为准),用于对照同一份布局在不同窗口宽度下的表现。

要点:断点在布局代码里定义,预览器只负责按宽度渲染。所以 GridRow 的断点阈值与各档位列数必须自己写全,预览才有意义(见 3.7)。键盘 / 鼠标与触摸的差异不在预览器覆盖范围内,2in1 / car 的交互仍需真机或模拟器验证。

// 用于预览对照的最小骨架:断点由代码声明,预览器按宽度渲染(ApprovalCard 组件见第 3 章)
GridRow({
    columns: { xs: 4, sm: 4, md: 8, lg: 12, xl: 12 },
    gutter: { x: 12, y: 12 }
}) {
    GridCol({ span: { xs: 4, sm: 2, md: 4, lg: 6, xl: 6 } }) {
        ApprovalCard()
    }
}

术语:断点——窗口宽度的档位,是「同一份代码适配多端」的判断依据。断点是布局层概念,与 SDK 版本无关;26.0.0 变的只是预览器能一次显示几档。

1.6.2 Code Scanner 资源泄漏检查

Code Scanner 在静态分析阶段扫描代码,重点覆盖资源泄漏模式:注册监听未注销、持有长生命周期 Context、媒体 / 文件句柄未关闭、定时器未清理。

时机 动作
提交前 对本次改动的目录跑一次扫描,数量应归零
合并前 对模块全量扫描,新增告警必须说明原因
发布前 全工程扫描作为门禁(第 10 章接进 CI)

它能识别的典型泄漏模式:

aboutToAppear(): void {
    this.ctx.on('change', this.onChanged);
}
aboutToDisappear(): void {
    // 错误:注册了监听却从不注销
}

1.6.3 dump 文件解析

应用异常退出或被判定卡顿后会产生 dump 文件(堆快照、线程栈)。26.0.0 的 IDE 可直接解析,用于找到内存占用最高的对象与引用链、找到主线程阻塞时的调用栈、并对照业务代码定位到具体组件。

# 从设备取出 dump 文件
hdc file recv /data/log/faultlog/faultlogger/ ./faultlog/
# 然后在 DevEco Studio 中直接打开该文件,用内置解析视图查看引用链与调用栈

误区:设备一崩就先翻业务日志。dump 回答的是「谁还持有这个对象」「主线程当时卡在哪个栈」,这是日志答不了的。先看 dump,再看日志。

1.6.4 Car 模拟器

Car 模拟器提供车机形态的运行环境,差异不只是屏幕尺寸:

维度 手机 车机
窗口宽度 常见 xs / sm 常见 lg / xl
输入方式 触摸 触摸 + 旋钮 / 方向盘按键 + 语音
代码侧动作 无 deviceTypes 声明 car;布局按大档位设计
// entry/src/main/module.json5(节选)—— 未声明 car 时包无法安装到车机
{ "module": { "name": "entry", "type": "entry", "deviceTypes": ["phone", "tablet", "2in1", "car"] } }

版本门槛:Car 设备类型在 API 26 起随 SDK 提供。下行兼容到 API 20 时,deviceTypes 多写 car 是安全的(手机设备会忽略不匹配形态),但不要把车机专属交互无条件写进主流程。


1.7 签名与设备调试闭环

类型 材料来源 用途 有效期
调试签名 IDE 自动生成(需登录华为开发者账号) 装到已注册的调试设备 有期限,过期需重新生成
发布签名 AGC 申请 .cer + .p7b 上架与正式分发 按 AGC 规则

签名材料共三件用于构建签名:.p12(密钥库,存私钥)、.cer(公钥证书)、.p7b(Profile,描述「哪个 bundle、哪台设备、能用哪些权限」);申请期另需一件 .csr(证书请求,用于换发 .cer,用完归档)。

// build-profile.json5(节选)—— 密码严禁入库,走 CI 密钥注入
{
  "app": {
    "signingConfigs": [
      {
        "name": "default",
        "type": "HarmonyOS",
        "material": {
          "storeFile": "./signature/officeflow.p12",
          "storePassword": "<从环境变量注入>",
          "keyAlias": "debugKey",
          "keyPassword": "<从环境变量注入>",
          "certpath": "./signature/officeflow.cer",
          "profile": "./signature/officeflow.p7b",
          "signAlg": "SHA256withECDSA"
        }
      }
    ]
  }
}

误区:把签名材料与明文密码一起提交到仓库。后果不只是安全问题——Profile 过期后所有人构建同时失败,修复需要重新申请材料并回滚代码。签名目录必须在 .gitignore 中。

真机调试时设备需在 AGC 登记(UDID 白名单),否则 Profile 不覆盖该设备、安装必失败;模拟器通常不需要。推荐用 IDE 自动签名功能一键生成调试材料。

安装失败排查顺序:

报错方向 先查什么 再看什么
签名相关 产物是否带 signed 材料是否过期、Profile 是否覆盖该设备
设备类型不匹配 module.json5 的 deviceTypes SDK 是否勾选对应设备类型
版本不兼容 设备系统版本 vs compatibleSdkVersion 是否需降低下限
安装被拒(已存在) 是否装了不同签名的同包名应用 先 hdc uninstall 再装
完全连不上设备 hdc list targets hdc kill -r → 重插 → 重启设备

1.8 构建可复现性与 CI 前置

「我本地能构建,CI 不能」几乎总是三样东西没锁住。第 11 章展开流水线,这里先建立基线。

对象 文件 为什么
依赖树 oh-package-lock.json5 锁定每个依赖的实际解析版本,避免不同人装到不同版本
构建引擎 hvigor/hvigor-config.json5 锁定 hvigor 与插件版本,避免构建行为漂移
依赖声明 全部 oh-package.json5 声明依赖来源与版本
# .gitignore(节选)—— 这五类必须忽略
build/                 # 构建产物
oh_modules/            # 依赖实体,由 sync 生成
.hvigor/               # 构建缓存
signature/             # 签名材料,含私钥
local.properties       # 本机路径与代理,因人而异

命令行构建依赖 SDK 路径环境变量,CI 上显式设置,不要依赖 shell 隐式继承:DEVECO_SDK_HOME 指向 Command Line Tools 附带的 SDK;PATH 追加 <SDK>/toolchains 与 IDE 的 Node 目录。

# CI 最小流水线:固定序列 clean → sync → assemble,不要跳步
set -e
export DEVECO_SDK_HOME="$PWD/tools/sdk"

node -v && ohpm -v && hvigorw -v          # 打印版本,出问题时一眼定位
hvigorw --sync --no-daemon
hvigorw assembleHap --mode module \
  -p module=entry@default -p product=default -p buildMode=debug --no-daemon
hvigorw assembleApp --mode project \
  -p product=default -p buildMode=release --no-daemon

误区:CI 上跑完 clean 不跑 sync。clean 会删除构建目录,而 oh_modules 是否被清取决于清理范围;一旦被清掉,后续 assembleHap 会报「找不到模块」。


1.9 常见误区

误区 1:把 compatibleSdkVersion 写成新格式

错误写法

"compatibleSdkVersion": "26.0.0"

正确写法

"compileSdkVersion": "26.0.0",
"targetSdkVersion": "26.0.0",
"compatibleSdkVersion": "6.0.0(20)"

为什么错:compatibleSdkVersion 指向 API 20,而 20 仍在 X.Y.Z(N) 体系内。写成 26.0.0 会让工具按 API 26 解析安装下限,结果是绝大多数设备装不上。三个字段语义不同,不能一起改成新格式。

误区 2:以为要单独下载安装 SDK

错误写法

# 到官网单独下载 SDK 压缩包 → 解压到自定义目录 → 在 IDE 里指定路径

正确写法

# 只安装 DevEco Studio 26.0.0,SDK 已随 IDE 预置
# 在 Settings > SDK 确认路径位于 IDE 安装目录下即可

为什么错:自 DevEco Studio 5.0(5.0.0(12))起 SDK 已随 IDE 预置,手动指定外部 SDK 目录会造成版本错配——典型症状是编译报找不到某 API 声明,而该 API 明明存在。

误区 3:把 IDE 能力当成 API 去做 canIUse 判定

错误写法

if (CapabilityGate.supported('SystemCapability.IDE.Previewer')) {
  this.enableMultiBreakpointPreview();
}

正确写法

// IDE 能力不需要运行期判定,只有设备能力才需要
const ok: boolean = CapabilityGate.supported('SystemCapability.Communication.NetStack');

为什么错:8 档断点预览、Code Scanner、dump 解析都是开发工具的能力,运行在 IDE 里而非设备上。canIUse 查询的是设备 SystemCapability,判定 IDE 能力必然恒为 false,写出永远走不到的分支。

误区 4:改了 module.json5 却不跑 sync

错误写法

hvigorw assembleHap --no-daemon

正确写法

hvigorw --sync --no-daemon && hvigorw assembleHap --no-daemon

为什么错:module.json5 的变更需要重新解析并生成中间产物(权限、页面清单、路由表索引)。跳过 sync 时会复用陈旧索引,表现为「权限声明了但运行时申请不到」「新页面找不到」这类与配置无关的诡异问题。

误区 5:新增模块只在 oh-package.json5 声明依赖

错误写法

// 顶层 oh-package.json5 加了依赖,但工程 build-profile.json5 的 modules 没登记
{ "dependencies": { "lib_ui": "file:../lib_ui" } }

正确写法

// 工程 build-profile.json5 必须同步登记,否则模块不参与构建
{ "modules": [{ "name": "lib_ui", "srcPath": "./lib_ui", "targets": [{ "name": "default" }] }] }

为什么错:oh-package.json5 描述的是「依赖了谁」,build-profile.json5 的 modules 描述的是「谁参与构建」。漏登记时依赖能解析、代码能 import,但产物里没有对应模块,表现为运行时报「模块不存在」或资源缺失。

误区 6:忽略设备形态与 Profile 的匹配

错误写法

"deviceTypes": ["phone"]      // 却想在平板上验证折叠屏布局

正确写法

"deviceTypes": ["phone", "tablet", "2in1", "car"]

为什么错:deviceTypes 与 SDK 已勾选设备类型求交,交集为空则无法安装;只声明 phone 的包装不上平板,于是「多端适配」这件事在验证环节就被跳过了,直到提审才发现问题。


1.10 实践任务

任务:搭出可命令行构建的 HelloOffice

任务描述

  1. 安装并配置 DevEco Studio 26.0.0,按 1.2.2 勾选 SDK,创建 Phone 模拟器(Car 可后补)。
  2. 新建 Stage 模型空工程,bundleName 设为 com.example.hellooffice,工程路径不含中文与空格。
  3. 修正 build-profile.json5 三个版本字段为 26.0.0 / 26.0.0 / 6.0.0(20)。
  4. 补全 AppScope 与 entry 的 string.json / color.json,新增 en_US 与 dark 限定词目录(key 集合与 base 完全一致)。
  5. 把 deviceTypes 扩为 ["phone", "tablet", "2in1", "car"],声明 ohos.permission.INTERNET。
  6. 新建 lib_base/src/main/ets/gate/CapabilityGate.ets,实现 1.5.3 的完整代码,并从 lib_base 根 Index.ets 统一导出。
  7. 用命令行完成 clean → sync → assembleHap,装到模拟器并拉起,用 hdc hilog 抓到自己打印的一行日志。

验收 checklist

  • [ ] Help > About DevEco Studio 显示 26.0.0 Release,Help > About HarmonyOS SDK 显示 HarmonyOS SDK 26.0.0
  • [ ] node -v / ohpm -v / hvigorw -v 在 IDE 内置终端均可执行且版本匹配
  • [ ] build-profile.json5 中 compatibleSdkVersion 为字符串 "6.0.0(20)"(带括号)
  • [ ] resources/base 与 resources/dark 的 color key 集合一致(脚本 diff 校验)
  • [ ] resources/base 与 zh_CN / en_US 的 string key 集合一致
  • [ ] hvigorw assembleHap 产出 entry-default-signed.hap,hdc install 成功
  • [ ] hdc shell aa start 能拉起应用,hdc hilog 能看到 CapabilityGate 的探测日志
  • [ ] 故意安装一次 unsigned 产物,记录报错原文用于后续对照
  • [ ] .gitignore 已含 build/、oh_modules/、.hvigor/、signature/、local.properties
  • [ ] oh-package-lock.json5 已入库

交付物清单

交付物 形式 验收点
环境清单 Markdown(贴到工程 README) 含 IDE / SDK / Node / ohpm / hvigor / 设备六项版本
HelloOffice 工程 可编译目录 命令行可出 signed HAP
CapabilityGate.ets 源码 只有此类出现 canIUse;supported / pick 齐备
资源一致性校验脚本 bash 能 diff 两个限定词目录的 key 集合
构建命令记录 Markdown 记录完整命令与产物路径,可被他人复现

1.11 延伸阅读

  • 官方文档 → 版本说明 → HarmonyOS 开发套件版本列表(确认当前最新版与配套 IDE、SDK)
  • 官方文档 → 版本说明 → 应用升级适配指导(向 26.0.0 升级)(行为变更清单,改 targetSdkVersion 前必读)
  • 官方文档 → 应用开发导读 → DevEco Studio 使用指南 → 工程结构(AppScope / 模块 / 资源目录的权威说明)
  • 官方文档 → 应用开发导读 → DevEco Studio 使用指南 → 编译构建(hvigor)(hvigorw 命令与参数全集)
  • 官方文档 → API 参考 → SystemCapability 清单(canIUse 判定的权威依据)
  • 官方文档 → 应用开发 → 应用签名与 Profile 申请(调试签名与发布签名的完整材料说明)

上一章:第 0 章 学习准备 | 下一章:第 2 章 ArkTS 语言核心

📄 附录 A 状态管理 V1 → V2 迁移速查 A-v1-v2-cheatsheet.md

本附录是全书唯一允许出现 V1 写法的地方,且全部以"迁移对照"的立场书写。V1 装饰器未被官方废弃、仍可编译运行,但新项目一律采用 V2;这里的内容只服务于两件事:读懂存量老代码,把在跑的老工程迁到 V2。


A.0 使用说明与立场

问题 结论
V1 还能用吗 能。官方未废弃 @State / @Link 等,编译与运行不受影响
要不要迁移 新项目不必考虑;存量工程按 A.4 清单渐进迁移,不建议大爆炸式重写
迁移的最大收益 观测粒度更细(深层属性级)、刷新可预测、@Computed / @Monitor 让派生逻辑与副作用显式化
迁移的最大成本 双向同步从 @Link 改为"@Param + @Event"回调模型,父子交互写法要改
能否局部迁移 可以,但同一组件内不能混用(见 A.3 第 1 条)。以"组件为最小迁移单元"逐层推进

误区:把 V2 理解成"V1 换了个名字"。V2 不是命名替换,而是观测模型的替换:V1 靠装饰器类型推断观测范围(数组/对象只观测一层),V2 靠 @ObservedV2 + @Trace 显式声明观测点,观测范围由数据类决定而非由使用方的装饰器决定。

版本门槛:状态管理 V2(@ComponentV2 / @ObservedV2 / @Trace / @Local / @Param / @Monitor / @Computed)自 API 12 起可用,其余装饰器随版本补齐。在兼容下限 API 20 上无门槛,可无条件使用。


A.1 装饰器与 API 映射总表

能力 V1 写法 V2 写法 迁移注意点
组件内可变状态 @State value: number = 0 @Local value: number = 0 @Local 必须在组件内初始化,不可由外部传入
父传子参数 @Prop title: string @Param title: string = '' @Param 在子组件内只读;父级更新会同步下来
父传子(一次性) @Prop 语义模糊 @Once @Param title: string = '' 需要"只取初值、之后本地自管"时加 @Once
参数必填约束 无对应能力 @Require @Param id: string 忘记传参在编译期报错,替代了 V1 的运行时防御
子传父 / 双向 @Link value: number @Param value: number + @Event onValueChange: (v: number) => void 双向同步改为父持有状态、子触发事件;见 A.2.3
跨层级下传 @Provide value: number @Provider() value: number = 0 @Provider 由后代最近的 @Consumer 消费
跨层级上取 @Consume value: number @Consumer() value: number = 0 必须提供默认值;找不到 @Provider 时用默认值
属性变更监听 @Watch('onChange') @Monitor('count') onCountChange(m: IMonitor) {} @Monitor 可一次监听多个成员,并给出 before / now
可观测类 @Observed class M {} @ObservedV2 class M {} V2 的可观测类必须用 @ObservedV2 声明
类内可观测属性 @Track prop: string @Trace prop: string = '' V2 是白名单:只有 @Trace 属性参与观测,未标者改了不刷新
对象引用观测 @ObjectLink item: M 直接把 M 实例作为 @Param 传入 V2 无需 @ObjectLink,观测能力内建在 @ObservedV2 类上
组件声明 @Component struct C {} @ComponentV2 struct C {} V2 组件内不能出现 V1 装饰器,反之亦然
派生值(计算属性) 无(写在 get 或 build 里) @Computed get total(): number { … } @Computed 会缓存,依赖变化才重算,替代 build 里的即时计算
应用级存储 AppStorage.setOrCreate('k', v) / @StorageLink AppStorageV2.connect(T, 'k', () => new T()) V2 不能存原始值,必须由类承载,见 A.3 第 5 条
持久化存储 PersistentStorage.persistProp('k', v) PersistenceV2.connect(T, 'k', () => new T()) 嵌套类属性必须标 @Type,否则静默丢失,见 A.3 第 6 条
页面级存储 LocalStorage / @LocalStorageLink AppStorageV2 或 @ObservedV2 单例 V2 无 LocalStorage 对应物,按作用域选型
环境变量 @Environment @Consumer + 自定义 @Provider 由应用自行注入。官方 V1→V2 迁移指导同此口径:V2 也可直接调用 Ability 接口获取环境变量

术语:观测粒度指"数据变化到第几层才触发 UI 刷新"。V1 的 @State 观测对象的引用替换与数组增删,但不观测数组元素内部属性;V2 通过 @Trace 把观测点下沉到属性级,故称"更细"。


A.2 三组对照:V1 与 V2 的行为差异

A.2.1 深层嵌套对象属性更新是否触发刷新

// ── V1:@Observed + @ObjectLink 只观测一层,深层属性改了不刷新 ──
@Observed
class Address {
    city: string = ''
}

@Observed
class User {
    name: string = ''
    address: Address = new Address()
}

@Component
struct UserViewV1 {
    @ObjectLink user: User

    build() {
        Column() {
            Text(this.user.name)        // 改 name 会刷新(一层)
            Text(this.user.address.city) // 改 city 不刷新(两层)
        }
    }
}
// ── V2:@Trace 逐属性声明,跨层与嵌套均刷新 ──
@ObservedV2
class AddressV2 {
    @Trace city: string = ''
}

@ObservedV2
class UserV2 {
    @Trace name: string = ''
    @Trace address: AddressV2 = new AddressV2()
}

@ComponentV2
struct UserViewV2 {
    @Param user: UserV2 = new UserV2()

    build() {
        Column() {
            Text(this.user.name)         // 刷新
            Text(this.user.address.city) // 也刷新:address 与 city 都被 @Trace
        }
    }
}
维度 V1 V2
观测深度 一层(@ObjectLink 绑定的对象属性) 按 @Trace 标记,可达任意层
深层刷新条件 每层都要 @Observed + 每层都要 @ObjectLink 中转 链路属性全部标 @Trace 即可
未标属性的行为 改了不刷新 改了不刷新(同样静默)

误区:V2 不是"自动全量观测"。@ObservedV2 类里没标 @Trace 的属性依然是不可观测的,this.user.remark = 'x' 不触发刷新且不报错。迁移时最容易漏标。

A.2.2 数组元素修改是否触发刷新

// ── V1:整体替换数组会刷新,改元素属性不会 ──
@Observed
class Task {
    title: string = ''
    done: boolean = false
}

@Component
struct TaskListV1 {
    @State tasks: Task[] = []

    private toggleV1(index: number): void {
        this.tasks[index].done = !this.tasks[index].done // 不刷新
        this.tasks = [...this.tasks]                     // 手动换引用才刷新
    }
}
// ── V2:元素属性被 @Trace,直接改即刷新,无需换引用 ──
@ObservedV2
class TaskV2 {
    @Trace title: string = ''
    @Trace done: boolean = false
}

@ComponentV2
struct TaskListV2 {
    @Local tasks: TaskV2[] = []

    private toggleV2(index: number): void {
        this.tasks[index].done = !this.tasks[index].done // 直接刷新
    }
}
操作 V1(@State 数组) V2(@Local 数组 + @Trace 元素类)
push / splice / 赋值新数组 刷新 刷新
arr[i].prop = x 不刷新(需 @Observed + 子组件 @ObjectLink,或整体换引用) 刷新
arr[i] = newObj 刷新 刷新
迁移要点 删掉 [...this.arr] 这类"手动换引用"技巧,避免无意义重渲染 —

误区:迁移时把 this.tasks = [...this.tasks] 一并保留,以为"保险"。这在 V2 里会造成一次多余的数组引用变更,触发不必要的 diff,长列表上放大成掉帧。

A.2.3 父子组件间双向同步的写法差异

// ── V1:@Link 直接双向绑定,子组件可改父状态 ──
@Component
struct StepperV1 {
    @Link value: number

    build() {
        Row() {
            Button('-').onClick(() => { this.value-- }) // 直接改父状态
            Text(`${this.value}`)
            Button('+').onClick(() => { this.value++ })
        }
    }
}
// ── V2:父持有状态,子用 @Param 读 + @Event 通知 ──
@ComponentV2
struct StepperV2 {
    @Param value: number = 0
    @Event onValueChange: (v: number) => void = (_v: number) => {}

    build() {
        Row() {
            Button('-').onClick(() => { this.onValueChange(this.value - 1) })
            Text(`${this.value}`)
            Button('+').onClick(() => { this.onValueChange(this.value + 1) })
        }
    }
}

@ComponentV2
struct CounterHost {
    @Local count: number = 0

    build() {
        StepperV2({
            value: this.count,
            onValueChange: (v: number) => { this.count = v } // 单向数据流闭环
        })
    }
}
维度 V1 @Link V2 @Param + @Event
数据流向 双向,子可写 单向:父 → 子传值、子 → 父传事件
子组件能否改值 能 不能(@Param 只读)
数据归属 模糊,谁都能改 明确:状态归父组件所有
调试难度 难(改值点分散) 易(唯一写入点在 @Event 回调)
迁移要点 把"子改父"的每处赋值,改成一次 onXxx 事件调用 —

A.3 混用禁忌清单

# 禁忌 现象 正确做法
1 同一组件内混用 V1 与 V2 装饰器 编译报错,构建直接失败 以组件为单位整体切换:@Component ↔ @ComponentV2 二选一
2 把 @ObservedV2 实例传给 V1 组件观测 经 @ObjectLink 传入时可能直接编译报错;经 @Prop / @State 传入时编译通过但观测失效,UI 不刷新 V2 数据只交给 V2 组件;跨代传参需在边界处做快照或改造接收方
3 @Local 依赖外部初始化 编译报错:@Local 必须本地初始化 外部传入的值用 @Param;本地派生用 @Local + @Computed
4 在子组件里写 this.xxx = v 改 @Param 编译报错 / 运行期不生效 通过 @Event 回调把意图交回父组件,由父改 @Local
5 往 AppStorageV2 里存原始值(number/string) 类型不匹配,编译或运行期失败 用类承载:AppStorageV2.connect(SessionStore, 'session', () => new SessionStore())
6 PersistenceV2 嵌套类属性漏标 @Type 静默丢失:反序列化后该属性为默认值,不报错 嵌套的类属性一律标 @Type,见下方示例
7 用 @Trace 标在普通 class 上 不生效(@Trace 仅在 @ObservedV2 类内有效) 类必须先声明为 @ObservedV2
8 在 @Computed 里做副作用(写日志、发请求) 重算时机不可控,副作用重复触发 @Computed 只做纯计算;副作用放 @Monitor 或生命周期
// 禁忌 6 的正反例
@ObservedV2
class DraftMeta {
    @Trace title: string = ''
}

@ObservedV2
class Draft {
    @Trace id: string = ''
    @Type(DraftMeta)              // 必须:否则 meta 反序列化后丢失
    @Trace meta: DraftMeta = new DraftMeta()
    @Trace tags: string[] = []
}

误区:以为 PersistenceV2 丢了数据会报错。它是静默的——读出来是默认值,直到用户发现"上次填的没了"。凡涉及嵌套结构,迁移后必须做一次"写入 → 杀进程 → 重启 → 读回"的验证。


A.4 迁移操作清单

按组件为最小单元,逐项执行:

  • [ ] 把所有 @Component 改成 @ComponentV2,@Observed 改成 @ObservedV2
  • [ ] 数据类里把 @Track 改成 @Trace;逐属性检查是否需要观测,避免漏标
  • [ ] @State → @Local;确认每个 @Local 都在组件内初始化
  • [ ] @Prop → @Param;需要"只取初值"的补 @Once,必填的补 @Require
  • [ ] @Link → @Param + @Event;把子组件的写操作改写成事件回调
  • [ ] @Provide / @Consume → @Provider() / @Consumer();确认 @Consumer 有默认值
  • [ ] @Watch → @Monitor;把监听多个字段的 @Watch 合并为一个 @Monitor
  • [ ] build() 里的即时计算抽成 @Computed getter
  • [ ] AppStorage / @StorageLink → AppStorageV2,状态由类承载
  • [ ] PersistentStorage → PersistenceV2,嵌套结构补 @Type
  • [ ] 删除 this.arr = [...this.arr] 这类手动换引用技巧
  • [ ] 全局搜索 @ObjectLink,全部移除并改为直接传 @ObservedV2 实例
  • [ ] 跑一遍"数据写入 / 杀进程 / 重启 / 读回"的持久化验证
  • [ ] 全量编译一次,确认无"V1/V2 混用"报错

验收:迁移完成的标志不是"能编译",而是长列表滚动与详情页联动处不出现多余重渲染。用第 8 章的 Profiler 抓一次帧数据对比迁移前后。


A.5 延伸阅读

  • 本教程 第 5 章 状态管理(V2 主线写法与刷新失败排查)
  • 本教程 附录 B(V1 装饰器在弃用总表中的定位)
  • 本教程 附录 C(状态管理 V1/V2、观测粒度、重渲染等术语)
  • 官方文档 → ArkUI → 状态管理(含 V1 → V2 迁移指导与 @Type / @Once 的完整参数说明)
  • 官方文档 → API 参考 → ArkTS 装饰器(每个装饰器的可用版本与约束)

📄 第 2 章 ArkTS 语言核心 02-arkts-core.md

ArkTS 不是「随便写的 TypeScript」。它砍掉了 TS 的动态特性,换来 AOT 确定性、可静态分析的状态追踪与跨线程安全。这一章讲清砍了什么、为什么砍、被砍掉的需求该怎么在限制内表达——并把领域模型 → DTO → 解析器这条数据入口链路写完整。


2.0 本章目标

  1. 能解释 ArkTS 作为「TS 静态化子集」的三条设计动机(AOT 确定性 / 性能 / 可静态分析),据此判断一段 TS 代码能否直接使用,并写出通过 ArkTS 静态检查的代码:显式类型字面量、无动态增删属性、无重载。
  2. 能划清领域模型 / DTO / UI 状态的边界,并写出手写 fromJson 解析器,正确处理字段缺失、类型不符与默认值。
  3. 能用不可变更新替代深层原地修改,并解释它与 ArkUI V2 刷新机制的因果关系。
  4. 能判断一段计算该留在主线程、下沉 taskpool 还是交给 worker,并说清 Sendable 与序列化的成本差异。
  5. 能定位循环导入,并把模块导出面收敛到根 Index.ets。

2.1 心智模型:为什么要有这门「受限的 TS」

2.1.1 三条设计动机

动机 具体机制 被砍掉的语言特性
AOT 确定性 编译期必须能确定对象布局与分派目标,才能提前生成机器码 运行期动态增删属性、with、eval、改 prototype
性能 静态分派可内联、可去虚化;线程间共享对象需固定内存布局 any / unknown、结构化类型、apply / call / bind
可静态分析 状态管理 V2 的依赖追踪要精确知道「读了哪个字段」,动态属性会让追踪失效 任意索引签名、动态键访问、声明合并

术语:静态化子集——ArkTS 的语法是 TypeScript 的子集,类型规则更严。TS 里「能编译」不等于 ArkTS 里「能编译」;反过来,ArkTS 代码基本都能被 TS 理解。

2.1.2 限制不是能力缺失,而是换建模方式

遇到限制时,正确反应不是绕过编译器,而是把「动态需求」翻译成「显式建模」:

你想要的动态能力 ArkTS 里的替代建模
任意键值对 Map<string, string> 或 Record<string, string>
一个字段可能是多种类型 判别联合(枚举判别键 + 类层次)
参数组合多 静态工厂方法 + 可选参数
运行期决定对象形状 定义若干固定形状的类,用 switch 分派

判别联合是本教程出现频率最高的替代方案,先建立印象:

// 用「枚举判别键 + 类层次」替代 TS 的联合字面量类型
export enum FieldKind {
    TEXT = 'text',
    SELECT = 'select'
}

export class FieldBase {
    readonly key: string;
    readonly kind: FieldKind;
    readonly label: string;

    constructor(key: string, kind: FieldKind, label: string) {
        this.key = key;
        this.kind = kind;
        this.label = label;
    }
}

export class TextField extends FieldBase {
    readonly maxLength: number;

    constructor(key: string, label: string, maxLength: number) {
        super(key, FieldKind.TEXT, label);
        this.maxLength = maxLength;
    }
}

export class SelectField extends FieldBase {
    readonly options: string[];

    constructor(key: string, label: string, options: string[]) {
        super(key, FieldKind.SELECT, label);
        this.options = options;
    }
}

版本差异提示:本节不涉及版本差异。ArkTS 的类型规则稳定,与 API 20 / API 26 无关——语言限制不随 SDK 版本放宽,不要期望升级 SDK 后可以写 any。


2.2 高频语法限制

2.2.1 限制总表

# 限制项 允许的写法 不允许的写法 为什么要限
1 对象字面量类型 const p: Point = { x: 1, y: 2 } const p = { x: 1, y: 2 } 无类型无法在编译期确定对象布局
2 属性增删 Map 或预声明可选字段 obj.newKey = 1、delete obj.key 布局必须编译期固定,才能按偏移访问
3 结构化类型 class A implements P 显式实现 「字段一样」就直接赋值 ArkTS 用名义类型,避免隐式耦合
4 with / eval 直接写逻辑 任何形式 破坏静态分析,无法 AOT
5 any / unknown 明确类型或判别联合 任何位置出现 等于关掉类型检查与优化
6 函数 / 构造函数重载 静态工厂 + 可选参数 同名多签名 运行期按参数分派,无法静态绑定
7 参数默认值 字面量常量 f(n: number = compute())(TS 语义允许,默认值运行期求值) 非语言限制;教程约定只用字面量常量以保持可预测
8 可选参数位置 可选参数写在末尾 f(a?: number, b: number) TS 共有规则,非 ArkTS 差异,列出仅为提醒
9 动态调用 显式参数、箭头函数 arguments、fn.apply/call/bind this 与参数个数不可静态分析
10 原型操作 类继承 __proto__、改 prototype 布局必须固定、可预生成
11 索引签名 Record<K, V>、Map 自定义任意索引签名 保证键空间可控、可分析
12 声明合并 显式 export 同名接口 / 命名空间合并 保证「一个名字一个定义」
13 类型断言 as 到明确的类 / 接口 as any 断言必须可验证

2.2.2 字面量必须有显式类型、属性不能增删

interface Point {
    x: number;
    y: number;
}

// 错误写法:裸字面量,编译器无法确定布局
// const p = { x: 1, y: 2 };

const p: Point = { x: 1, y: 2 };          // 正确一:先有类型

function moveTo(target: Point): void { }
moveTo({ x: 3, y: 4 });                    // 正确二:由形参类型推导

export class ApprovalRow {
    readonly id: string;
    // 需要「可能不存在」的字段就显式声明为可空,而不是事后动态挂
    readonly remark: string | undefined;

    constructor(id: string, remark?: string) {
        this.id = id;
        this.remark = remark;
    }
}

// row.extraFlag = true;    // 属性未声明,无法增加
// delete row.remark;       // 禁止删除

// 真正需要动态键空间时用 Map
const tags: Map<string, string> = new Map<string, string>();
tags.set('source', 'mobile');
tags.delete('source');

注意第一种:只有目标类型明确时字面量才合法。function f(v: Object): void 后面跟 f({ a: 1 }) 依然报错,因为 Object 无法确定布局。

2.2.3 用静态工厂替代重载

ArkTS 不支持函数与构造函数的重载签名。按「参数含义是否相同」二选一:

export class Money {
    readonly cents: number;

    private constructor(cents: number) {
        this.cents = cents;
    }

    /** 入口语义不同 → 静态工厂,方法名自带说明 */
    static fromCents(cents: number): Money {
        return new Money(Math.round(cents));
    }

    static fromYuanText(text: string): Money {
        const parsed: number = parseFloat(text);
        return new Money(isNaN(parsed) ? 0 : Math.round(parsed * 100));
    }

    /** 语义相同、只有可选性不同 → 一个方法 + 可选参数 */
    add(other: Money, scale: number = 1): Money {
        return new Money(this.cents + Math.round(other.cents * scale));
    }

    yuanText(): string {
        return (this.cents / 100).toFixed(2);
    }
}

2.2.4 null 安全、可选链与空值合并

严格空检查始终开启,无法关闭。

export class Department {
    readonly name: string;

    constructor(name: string) {
        this.name = name;
    }
}

export class Employee {
    readonly name: string;
    readonly department: Department | undefined;   // 显式可空

    constructor(name: string, department?: Department) {
        this.name = name;
        this.department = department;
    }

    /** 可选链:链上任意一环为空就短路为 undefined */
    departmentName(): string | undefined {
        return this.department?.name;
    }

    /** 空值合并:只处理 null / undefined,不误伤 0 与空串 */
    departmentNameOrDefault(): string {
        return this.department?.name ?? '未分配';
    }
}
场景 写法 不要用
读可能不存在的字段 obj?.field ?? fallback obj.field || fallback(0 / '' 会被误判)
取默认值 value ?? fallback value ? value : fallback
可选参数后有必填参数 拆成两个方法 可选参数写在前(编译不过)
有默认值的参数 用 =,不要同时标 ? f(a?: number = 1)

注:上表后两行(可选参数位置、默认值参数)是 TypeScript 共有规则,并非 ArkTS 差异,列出仅为提醒。

术语:空值合并 vs 逻辑或——?? 只在左侧为 null / undefined 时取右侧;|| 在左侧是任何假值(含 0、''、NaN)时都取右侧。处理金额、计数时用错会丢数据。

版本差异提示:?. / ?? 在兼容下限 API 20 上完全可用。不要用非空断言 ! 绕过空检查——它不做运行期校验,只是让编译器闭嘴,是 undefined 崩溃的常见来源。


2.3 三层建模:领域模型 / DTO / UI 状态

2.3.1 为什么要分三层

不分层的典型后果是「一个类既当接口契约、又当业务对象、又是渲染状态」:后端改字段名要动 UI 代码,UI 加临时字段污染了领域模型,序列化细节泄漏到展示逻辑。

层 唯一职责 允许依赖 明确禁止
DTO 与线上 JSON 字段一一对应,字段名原样保留 无 业务逻辑、格式化、UI 文案
领域模型 承载业务不变量与规则,构造成功即合法 自身与基础类型 反序列化细节、网络、UI 类型
UI 状态 可观察、驱动渲染的最小集合 领域模型 直接持有 DTO

三条边界规则贯穿全书:

  1. DTO 不允许离开数据层。进入业务逻辑前必须转成领域模型——转换点唯一,改动成本可控。
  2. 领域模型的构造必须能失败。失败用返回值表达(本节 ParseOutcome,2.8 的 Result),不允许产出「半成品对象」。
  3. UI 状态只存渲染需要的最小集合,派生值交给 @Computed(第 3、5 章)。
网络 JSON ──解析──> DTO ──映射+校验──> 领域模型 ──投影──> UI 状态 ──> 渲染
              ↑ 2.3.3   ↑ 2.3.4       ↑ 2.8 Result   ↑ 第 5 章 V2 装饰器

2.3.2 DTO 与领域模型

// entry/src/main/ets/data/dto/ApprovalDto.ets

/** DTO:字段名与线上 JSON 完全一致,不重命名、不校验、不含任何逻辑。 */
export interface ApprovalDto {
    id: string;
    title: string;
    amount: number;
    status: string;
    created_at: number;
    tags: string[];
}
// entry/src/main/ets/domain/Approval.ets

export enum ApprovalStatus {
    DRAFT = 'draft',
    PENDING = 'pending',
    APPROVED = 'approved',
    REJECTED = 'rejected'
}

/** 领域模型:构造完成即满足全部不变量,字段不可能缺失。 */
export class Approval {
    readonly id: string;
    readonly title: string;
    readonly amountCents: number;
    readonly status: ApprovalStatus;
    readonly createdAt: Date;
    readonly tags: string[];

    constructor(id: string, title: string, amountCents: number,
                status: ApprovalStatus, createdAt: Date, tags: string[]) {
        this.id = id;
        this.title = title;
        this.amountCents = amountCents;
        this.status = status;
        this.createdAt = createdAt;
        this.tags = tags;
    }

    /** 业务规则住在领域模型里,UI 不再自己判 */
    get isActionable(): boolean {
        return this.status === ApprovalStatus.PENDING;
    }

    get amountText(): string {
        return (this.amountCents / 100).toFixed(2);
    }

    /** 不可变更新:返回新实例而不是就地改(见 2.4) */
    withStatus(next: ApprovalStatus): Approval {
        return new Approval(this.id, this.title, this.amountCents, next, this.createdAt, this.tags);
    }
}

2.3.3 手写 JSON 读取器

不直接 JSON.parse(...) as ApprovalDto 的原因:类型断言不做任何运行期校验。后端少一个字段、多一个 null,断言全部照过,问题会在离现场很远的地方炸开。手写读取器把「字段缺失 / 类型不符 / 数组元素异常」变成可收集的问题列表 + 安全默认值。

// entry/src/main/ets/core/json/JsonReader.ets

export class ParseIssue {
    readonly path: string;
    readonly detail: string;

    constructor(path: string, detail: string) {
        this.path = path;
        this.detail = detail;
    }

    toString(): string {
        return `${this.path}: ${this.detail}`;
    }
}

type JsonObject = Record<string, Object>;

/**
 * 字段缺失与类型不符都记成 ParseIssue 并返回安全默认值;
 * 只有「整体不可解析」才返回 undefined。
 * 嵌套对象复用同一套方法:用 obj(key) 拿到子 reader 后继续按字段读。
 */
export class JsonReader {
    private readonly root: JsonObject;
    private readonly issues: ParseIssue[] = [];

    private constructor(root: JsonObject) {
        this.root = root;
    }

    /** 入口:非法 JSON 或根节点非对象时返回 undefined */
    static tryOpen(text: string): JsonReader | undefined {
        let parsed: Object;
        try {
            parsed = JSON.parse(text) as Object;
        } catch (err) {
            return undefined;
        }
        if (parsed === null || parsed === undefined || typeof parsed !== 'object') {
            return undefined;
        }
        return new JsonReader(parsed as JsonObject);
    }

    /** 子对象:缺失或类型不符 → undefined,并记问题 */
    obj(key: string): JsonReader | undefined {
        const v: Object | undefined = this.root[key];
        if (v === undefined || v === null || typeof v !== 'object' || Array.isArray(v)) {
            this.issues.push(new ParseIssue(key, 'not an object'));
            return undefined;
        }
        return new JsonReader(v as JsonObject);
    }

    /** 字符串:缺失或非字符串 → fallback 并记问题 */
    str(key: string, fallback: string): string {
        const v: Object | undefined = this.root[key];
        if (typeof v === 'string') {
            return v as string;
        }
        this.issues.push(new ParseIssue(key,
            v === undefined ? 'missing, fallback used' : 'not a string, fallback used'));
        return fallback;
    }

    /** 整数(分值、时间戳用):缺失、非数字、非整数 → fallback 并记问题 */
    int(key: string, fallback: number): number {
        const v: Object | undefined = this.root[key];
        const n: number = v as number;
        if (typeof v === 'number' && isFinite(n) && Math.floor(n) === n) {
            return n;
        }
        this.issues.push(new ParseIssue(key,
            v === undefined ? 'missing, fallback used' : 'not an integer, fallback used'));
        return fallback;
    }

    /** 字符串数组:非数组 → 空数组;非字符串元素逐个丢弃并记录下标 */
    strArray(key: string): string[] {
        const v: Object | undefined = this.root[key];
        const out: string[] = [];
        if (v === undefined || !Array.isArray(v)) {
            this.issues.push(new ParseIssue(key, 'not an array, [] used'));
            return out;
        }
        const arr: Object[] = v as Object[];
        for (let i = 0; i < arr.length; i++) {
            const item: Object = arr[i];
            if (typeof item === 'string') {
                out.push(item as string);
            } else {
                this.issues.push(new ParseIssue(`${key}[${i}]`, 'not a string, dropped'));
            }
        }
        return out;
    }

    /** 取走累积的问题(拷贝,避免调用方二次修改内部状态) */
    drain(): ParseIssue[] {
        return this.issues.slice();
    }
}

2.3.4 映射器:DTO → 领域模型

映射器是唯一允许同时认识 DTO 与领域模型的地方:把字符串状态转枚举、把分值转整数、把时间戳转 Date,并决定问题的严重级别。

// entry/src/main/ets/data/ApprovalMapper.ets
import { JsonReader, ParseIssue } from '../core/json/JsonReader';
import { Approval, ApprovalStatus } from '../domain/Approval';

/** 解析结果:永远有一个可用对象,另附非致命问题清单 */
export class ParseOutcome {
    readonly value: Approval;
    readonly issues: ParseIssue[];

    constructor(value: Approval, issues: ParseIssue[]) {
        this.value = value;
        this.issues = issues;
    }

    get clean(): boolean {
        return this.issues.length === 0;
    }
}

function toStatus(raw: string): ApprovalStatus {
    switch (raw) {
        case 'pending':
            return ApprovalStatus.PENDING;
        case 'approved':
            return ApprovalStatus.APPROVED;
        case 'rejected':
            return ApprovalStatus.REJECTED;
        default:
            return ApprovalStatus.DRAFT;   // 未知状态一律兜底为草稿,绝不抛错
    }
}

export class ApprovalMapper {
    /** undefined = 整条数据不可用;返回 ParseOutcome = 可用(可能带问题) */
    static fromJson(text: string): ParseOutcome | undefined {
        const reader: JsonReader | undefined = JsonReader.tryOpen(text);
        if (reader === undefined) {
            return undefined;
        }

        // 逐字段读取:缺失 / 类型不符都在 reader 内记问题并给出安全默认值
        const id: string = reader.str('id', '');
        const title: string = reader.str('title', '(未命名审批)');
        const amountCents: number = reader.int('amount', 0);
        const rawStatus: string = reader.str('status', 'draft');
        const createdMs: number = reader.int('created_at', 0);
        const tags: string[] = reader.strArray('tags');

        // 关键字段缺失时视为不可用,避免污染上层
        if (id.length === 0) {
            return undefined;
        }

        const approval: Approval = new Approval(
            id, title, amountCents, toStatus(rawStatus), new Date(createdMs), tags
        );
        return new ParseOutcome(approval, reader.drain());
    }

    /** 列表解析:逐条处理,坏数据被丢弃而不是让整页失败 */
    static fromJsonList(text: string): ParseOutcome[] {
        let parsed: Object;
        try {
            parsed = JSON.parse(text) as Object;
        } catch (err) {
            return [];
        }
        if (!Array.isArray(parsed)) {
            return [];
        }
        const rows: Object[] = parsed as Object[];
        const out: ParseOutcome[] = [];
        for (let i = 0; i < rows.length; i++) {
            // 教学简化:经 stringify 往返逐条复用 fromJson;生产应避免序列化往返,给 JsonReader 增加对象入口
            const one: ParseOutcome | undefined = ApprovalMapper.fromJson(JSON.stringify(rows[i]));
            if (one !== undefined) {
                out.push(one);
            }
        }
        return out;
    }
}

调用侧的模式固定为「先看有没有值、再看有没有问题」:

// 组件方法内片段,省略导入
const outcome: ParseOutcome | undefined = ApprovalMapper.fromJson(rawText);
if (outcome === undefined) {
    // 整条不可用 → 走错误态 UI(第 3 章)
} else {
    if (!outcome.clean) {
        // 非致命问题:上报埋点(第 11 章),但不打断用户
        hilog.warn(0x0000, 'Mapper', 'issues=%{public}d', outcome.issues.length);
    }
    const row: Approval = outcome.value;
}

版本差异提示:Record、Array.isArray、JSON.parse、模板字符串在兼容下限 API 20 上均可用,无需门槛标注。解析与校验在手写读取器里就能完成,不要为此引入额外 JSON 工具库。


2.4 不可变更新

2.4.1 先讲清观察边界

不可变更新的必要性来自观察边界。V2 的规则是:

装饰器 能观察到什么
@Local / @Param 仅变量自身的赋值(@Param 为父组件单向同步);对象属性变化必须整链 @ObservedV2 + @Trace 才可观察,V2 没有隐式第一层观察(与 V1 @State 的关键差异,见附录 A)
@Trace 被装饰字段的变化;沿途每层都是 @ObservedV2 + @Trace 时才穿透到深层
@ObservedV2 类中被 @Trace 的 Array / Map / Set / Date 其 API 调用(push / splice / set / delete…)触发刷新

两条硬结论:

  1. 未被 @Trace 装饰的属性——包括第一层——改了不会刷新——最典型的静默失败。
  2. 数组用 push / splice 是合法且可观察的,不必为了刷新而刻意写不可变。

2.4.2 不可变更新的三条工程理由

理由 说明
通知范围确定 引用变化一定通知,不依赖「深层是否都被 @Trace 装饰」这一脆弱前提
支持比较与回滚 引用相等即可做脏检查、撤销栈、@Computed 缓存命中判断
消除共享可变状态 同一对象被多处持有时,隐蔽耦合被消灭

2.4.3 反面案例:深层原地修改不刷新

// 错误:Attachment 没有 @ObservedV2,size 也没有 @Trace
export class Attachment {
    size: number = 0;

    withSize(next: number): Attachment {
        const copy: Attachment = new Attachment();
        copy.size = next;
        return copy;
    }
}

@ObservedV2
export class ApprovalRow {
    readonly id: string;
    @Trace attachment: Attachment = new Attachment();
    @Trace status: ApprovalStatus = ApprovalStatus.DRAFT;

    constructor(id: string) {
        this.id = id;
    }

    // 错误示范:改成功了,但 UI 不会刷新
    // this.attachment.size = 1024;

    // 正确:整体替换引用,一定触发刷新
    withAttachmentSize(next: number): void {
        this.attachment = this.attachment.withSize(next);
    }

    withStatus(next: ApprovalStatus): void {
        this.status = next;
    }
}

attachment 的引用没变、size 又不在观察范围内,于是「数据是新的、界面是旧的」。另一条路是把整链都标成 @ObservedV2 + @Trace,但要求沿途每一层都记得加,漏一层就退化成同样的 bug——所以推荐整体替换。

2.4.4 数组的不可变工具

export class RowStore {
    /** 按 id 局部更新:找不到就原样返回(引用不变,避免无谓刷新) */
    static replaceById(rows: Approval[], id: string, next: Approval): Approval[] {
        let hit: boolean = false;
        const mapped: Approval[] = rows.map((r: Approval): Approval => {
            if (r.id === id) {
                hit = true;
                return next;
            }
            return r;
        });
        return hit ? mapped : rows;
    }

    /** 按 id 删除 */
    static removeById(rows: Approval[], id: string): Approval[] {
        const kept: Approval[] = rows.filter((r: Approval): boolean => r.id !== id);
        return kept.length === rows.length ? rows : kept;
    }

    /** 排序:先拷贝再排,绝不对入参调用 sort */
    static sortedByCreatedDesc(rows: Approval[]): Approval[] {
        const copy: Approval[] = rows.slice();
        copy.sort((a: Approval, b: Approval): number => b.createdAt.getTime() - a.createdAt.getTime());
        return copy;
    }
}

误区:直接对 @Trace 的数组调用 sort()。sort 会就地重排,虽然 V2 能观察到这次调用从而刷新,但同一个数组对象可能被多处引用,其它视图拿到的是被打乱后的顺序,问题极难定位。先 slice() 再 sort(),成本只有一次浅拷贝。

版本差异提示:@ObservedV2 / @Trace 自状态管理 V2(API 12 起)提供,在兼容下限 API 20 上完全可用,无需门槛标注。本教程全程使用 V2 装饰器,V1 迁移对照收在附录 A。


2.5 并发:主线程职责边界

2.5.1 边界怎么划

工作类型 归属 例子
UI 渲染 主线程 声明式 build、布局、绘制
UI 状态更新 只能主线程 给 @Local / @Trace 赋值
轻量逻辑 主线程 格式化、简单校验、状态机跳转
重计算 taskpool 大 JSON 解析、加解密、图片处理、批量统计
长生命周期独立上下文 worker 需要常驻并保留自身状态的任务

判定标准很直接:这段代码会不会阻塞主线程超过一帧(约 16 ms)?会,就下沉。

2.5.2 taskpool

// entry/src/main/ets/workers/ApprovalParseTask.ets
import { taskpool } from '@kit.ArkTS';

/**
 * @Concurrent 函数必须是顶层函数:
 * 不能是类的实例方法,也不能是捕获了外部变量的闭包。
 */
@Concurrent
function countRows(payload: string): number {
    // 纯计算:不访问 UI、不访问 Context、不做 I/O
    const rows: Object[] = JSON.parse(payload) as Object[];
    return rows.length;
}

export async function countRowsInPool(text: string): Promise<number> {
    const task: taskpool.Task = new taskpool.Task(countRows, text);
    try {
        const result: Object = await taskpool.execute(task);
        return result as number;
    } catch (err) {
        return 0;
    }
}
能力 用法 说明
单任务 new taskpool.Task(fn, args) + execute 参数建议用单个对象承载,便于后续扩展
任务组 taskpool.TaskGroup 一组任务统一等待,适合并行切片
延迟执行 taskpool.executeDelayed(delay, task) API 18+;task 须为 GenericsTask。定时 / 防抖型计算
优先级 taskpool.execute(task, taskpool.Priority.HIGH) 影响排队顺序,不抢占正在运行的任务
取消 taskpool.cancel(task) 任务未开始才能可靠取消

2.5.3 worker

// 片段示意:宿主为组件实例,this 指向当前组件(回调在主线程执行)
import { worker } from '@kit.ArkTS';

const parseWorker: worker.ThreadWorker =
    new worker.ThreadWorker('entry/ets/workers/ParseWorker.ets');

// 回调在主线程执行,因此可以在这里安全地改 UI 状态
parseWorker.onmessage = (e: worker.MessageEvents): void => {
    this.rowCount = e.data as number;
};

parseWorker.onerror = (e: Object): void => {
    // e 是错误事件对象,字段以所用 SDK 为准;此处取 message 仅为示意
    const msg: string = (e as Error).message;
    hilog.error(0x0000, 'Worker', 'failed: %{public}s', msg);
};

parseWorker.postMessage(largePayload);
parseWorker.terminate();   // 页面退出必须终止,否则线程常驻(Code Scanner 会报泄漏)
维度 taskpool worker
线程模型 系统管理的线程池 每个 worker 一个常驻线程
生命周期 任务结束即释放 自己 terminate,否则常驻
状态保持 无(函数式) 有(可维护内部状态)
适用 一次性并行计算 长期保活、频繁交互
数量约束 池化,可放心提交多个 数量有限,不要为每次任务新建 worker

2.5.4 Sendable 与序列化成本

路径 条件 成本
共享传递 对象标注 @Sendable 无序列化,线程间引用同一份内存
结构化克隆 普通可序列化对象 深拷贝,成本随体积与嵌套深度线性增长
// 共享传递:跨线程不拷贝
@Sendable
export class ParseStat {
    total: number = 0;
    dropped: number = 0;
    costMs: number = 0;
}

@Sendable 的约束决定了它什么时候不能用:@Sendable 类的成员类型必须本身可共享,不要在其中持有 UI 类型、Context 或普通类实例;「不能捕获闭包」是 @Concurrent 函数的约束——@Concurrent 函数必须是顶层函数,不能是捕获了外部变量的闭包(见 2.5.2)。

误区:把几 MB 的列表直接 postMessage 给 worker「让它处理」。结构化克隆会深拷贝整棵树,序列化耗时往往超过处理耗时本身。要么只传必要字段(如 id 列表),要么改用 @Sendable 共享对象。

2.5.5 UI 状态只能在主线程改

// 错误:在 @Concurrent 函数或 worker 文件里直接改 UI 状态
// @Concurrent
// function bad(): void { this.loading = false; }   // 线程错误 + 作用域错误

// 正确:工作线程算结果,主线程负责赋值(组件方法内片段,省略类声明与导入)
async refresh(): Promise<void> {
    this.loading = true;                                        // 主线程
    const count: number = await countRowsInPool(this.rawText);  // 工作线程
    this.rowCount = count;                                      // 回到主线程
    this.loading = false;
}

版本差异提示:taskpool、worker、@Concurrent、@Sendable 在兼容下限 API 20 上均可使用。注意 @Concurrent 与 @Sendable 必须写在 .ets 文件中,放到 .ts 文件里会编译失败。


2.6 模块与导入

2.6.1 @kit.* 是唯一入口

ArkTS 能力按「能力域」分组成 Kit,每个 Kit 是统一入口,一条 import 可同时取到该域多个对象。全工程只允许从 Kit 导入系统能力。

能力域 Kit 典型导入
应用模型与上下文 @kit.AbilityKit import { UIAbility, Want, common, abilityAccessCtrl } from '@kit.AbilityKit';
ArkUI 能力 @kit.ArkUI import { window, promptAction, mediaquery } from '@kit.ArkUI';
日志与性能 @kit.PerformanceAnalysisKit import { hilog } from '@kit.PerformanceAnalysisKit';
数据存储 @kit.ArkData import { preferences, relationalStore } from '@kit.ArkData';
网络 @kit.RemoteCommunicationKit import { rcp } from '@kit.RemoteCommunicationKit';
基础服务 @kit.BasicServicesKit import { BusinessError, deviceInfo } from '@kit.BasicServicesKit';
文件 @kit.CoreFileKit import { fileIo } from '@kit.CoreFileKit';
并发与工具 @kit.ArkTS import { taskpool, worker, util } from '@kit.ArkTS';
分布式 @kit.DistributedServiceKit import { distributedDeviceManager } from '@kit.DistributedServiceKit';
媒体与图像 @kit.MediaKit / @kit.ImageKit import { media } from '@kit.MediaKit';

SDK 抛出的错误统一按 BusinessError 处理,它携带 code 与 message:

import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

try {
    // 调用系统能力
} catch (err) {
    const be: BusinessError = err as BusinessError;
    hilog.error(0x0000, 'Net', 'failed: code=%{public}d msg=%{public}s', be.code, be.message);
}

版本差异提示:Kit 化入口自 API 12 起成为统一规范,在兼容下限 API 20 上可用。看到「按子系统拆分的分命名空间导入」就是老写法——它是判断外部资料年代的指纹之一,不要照抄进新工程。

2.6.2 导出面收敛

每个 HAR 只有根 Index.ets 是对外导出面,内部文件路径不允许被跨模块导入。

// lib_base/Index.ets —— 唯一导出面
export { Result, Success, Failure } from './src/core/Result';
export { AppError, ErrorCode } from './src/core/AppError';
export { CapabilityGate } from './src/common/CapabilityGate';
export { ParseIssue, JsonReader } from './src/core/json/JsonReader';

// 错误写法:深路径导入,等于把内部实现变成公共契约
// import { parseMoney } from 'lib_base/src/internal/moneyHelper';

// 正确写法:只从导出面取
import { AppError, ErrorCode } from 'lib_base';

术语:导出面(public surface)——一个模块对外承诺的 API 集合。收敛到一个文件的价值在于:重命名内部文件、重构内部结构时只有 Index.ets 会变,调用方不受影响。

2.6.3 循环导入的识别与消除

循环导入(A 依赖 B、B 依赖 A)在 ArkTS 里通常不报编译错误,而是表现为运行期诡异现象:

现象 说明
导入的对象是 undefined 初始化顺序被打乱,取到尚未完成初始化的绑定
instanceof 判定恒为 false 两侧拿到的是不同初始化阶段的同一个类
静态常量读到默认值 模块体执行顺序不同
# hvigor 构建时若检测到循环依赖,会在日志里给出依赖环路径
hvigorw assembleHap --no-daemon 2>&1 | grep -i "circular\|cycle"
手法 做法 适用
下沉契约 把共用的接口 / 常量 / 枚举移到 lib_base 首选,最彻底
依赖倒置 上层定义接口,下层实现,下层只依赖接口文件 业务层互相调用时
提取第三方模块 共同依赖抽成新的小模块 环较长、节点较多时
延迟引用 用回调或工厂函数代替直接导入类型 无法下沉时的兜底

依赖方向必须单向:entry → feature_* → lib_* → lib_base。出现反向依赖即为环的起点。

误区:用「合并两个模块」来消环。合并会让模块职责膨胀并破坏分层,短期消除、长期更痛。先尝试把契约下沉到 lib_base,只有契约本身确实不属于底座时才考虑其它手法。


2.7 编码规范与静态检查基线

项 规定 理由
缩进 4 空格 与官方示例、IDE 默认一致
引号 / 分号 单引号、语句末尾分号 统一 diff,减少无意义冲突
变量 const 优先,需要重赋值才用 let 缩小可变范围
类型标注 变量、参数、返回值显式标注 ArkTS 的静态优势依赖显式类型
any 公开签名中禁止 语言层面已全面禁止(见 2.2.1 第 5 条),此为工程纪律再强调
资源引用 一律 $r('app.string.xxx') 多语言与深色模式的前提
尺寸单位 vp / fp px 不随密度与字体缩放
对象 命名约定 例子
类 / 接口 / 枚举 PascalCase ApprovalMapper
方法 / 变量 camelCase withStatus
常量 UPPER_SNAKE_CASE STATE_KEY
页面文件 XxxPage.ets ApprovalListPage.ets
组件文件 名词,无后缀 ApprovalCard.ets
ViewModel / 仓储 / 数据源 XxxViewModel / XxxRepository / XxxRemoteDs ApprovalRemoteDs
布尔字段 is / has / can 前缀 isActionable

ArkTS 的原生规则集(arkts-* 系列)在编译期强制,报错无法通过配置绕过——这是好事,意味着类型与语法类问题不会流到运行期。Code Linter 负责其上的风格与质量规则:

// .codelinter.json
{
  "files": ["**/*.ets", "**/*.ts"],
  "ignore": ["**/oh_modules/**", "**/build/**", "**/.hvigor/**"],
  "rules": {
    "arkts-no-any-unknown": "error",
    "arkts-no-untyped-obj-literals": "error",
    "@typescript-eslint/no-unused-vars": "warn"
  }
}
# 本地门禁:改动目录跑一次,数量归零再提交
codelinter -c .codelinter.json .

版本差异提示:规则集随工具链版本演进,具体规则名以官方「Code Linter 规则」文档为准。不要为了让告警消失而关闭规则;要么改代码,要么在配置里写清豁免理由与负责人。


2.8 错误处理:Result 模型

2.8.1 为什么不让异常穿越分层

做法 问题
底层抛、顶层 catch 中间层无法在类型上表达「可能失败」,调用方只能靠文档知道要 catch
每层 catch 再重抛 上下文丢失、栈被打断、日志重复
catch 里只打日志继续执行 吞掉错误,最难排查的一类 bug

本教程统一用判别式 Success / Failure:失败是类型的一部分,编译器帮你检查有没有处理。

// lib_base/src/core/AppError.ets
export enum ErrorCode {
    UNKNOWN = 9999,
    NETWORK_OFFLINE = 1001,
    NETWORK_TIMEOUT = 1002,
    AUTH_EXPIRED = 2001,
    PERMISSION_DENIED = 2002,
    PARSE_FAILED = 9001,
    NOT_FOUND = 4004
}

export class AppError {
    readonly code: ErrorCode;
    readonly message: string;
    readonly cause: string | undefined;

    constructor(code: ErrorCode, message: string, cause?: string) {
        this.code = code;
        this.message = message;
        this.cause = cause;
    }
}
// lib_base/src/core/Result.ets
import { AppError } from './AppError';

export class Success<T> {
    readonly value: T;

    constructor(value: T) {
        this.value = value;
    }
}

export class Failure {
    readonly error: AppError;

    constructor(error: AppError) {
        this.error = error;
    }
}

/** 失败分支不带类型参数:失败没有值,这能省掉大量无意义的类型噪音 */
export type Result<T> = Success<T> | Failure;

2.8.2 失败在哪一层产生、哪一层消费

层 对错误的态度 手段
系统边界(网络 / 文件 / SDK) 必须捕获 try/catch → BusinessError → AppError → Failure
解析层(2.3) 不抛错 收集问题 + 安全默认值,整体不可用才返回 undefined
领域层 用 Failure 表达业务规则失败 只返回 Result,不抛异常
UI 层 只消费,不判定 拿到 Failure 展示文案,不做 code 分支(除 401)

分工要分清:ParseOutcome 处理「数据不完整但仍可用」,Result<T> 处理「要么成功要么失败」。 字段缺一个用默认值就够了,不要升级成 Failure;网络断了则必然是 Failure。

// 数据层:把系统异常立即转成 Result,异常不再向上传播
import { rcp } from '@kit.RemoteCommunicationKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { AppError, ErrorCode, Result, Success, Failure } from 'lib_base';

export async function fetchApprovalText(id: string): Promise<Result<string>> {
    try {
        const response: rcp.Response = await rcp.get(`https://api.example.com/approvals/${id}`);
        if (response.statusCode !== 200) {
            return new Failure(new AppError(ErrorCode.UNKNOWN, `HTTP ${response.statusCode}`));
        }
        return new Success(response.toString());
    } catch (err) {
        const be: BusinessError = err as BusinessError;
        return new Failure(new AppError(ErrorCode.NETWORK_OFFLINE, '网络不可用', `code=${be.code}`));
    }
}
// UI 层:用 instanceof 做类型收窄,失败分支由编译器强制处理(组件方法内,省略导入)
const result: Result<string> = await fetchApprovalText(id);
if (result instanceof Failure) {
    this.banner = result.error.message;      // 自动收窄到 Failure
    return;
}
const outcome: ParseOutcome | undefined = ApprovalMapper.fromJson(result.value);
if (outcome === undefined) {
    this.banner = getContext().resourceManager.getStringSync($r('app.string.parse_failed'));
    return;
}
this.detail = outcome.value;

误区:用 $r('app.string.parse_failed').toString() 取文案。$r() 返回的是资源对象,toString() 拿到的不是文案本身;取解析后的字符串要用 getContext().resourceManager.getStringSync($r(...))。

版本差异提示:Result / Success / Failure / AppError 是本教程在 lib_base 中的自定义类型,不随 SDK 版本变化。不要把它们与 SDK 的 BusinessError 混用:BusinessError 只允许出现在系统边界,一旦越过边界就必须转成 AppError。


2.9 常见误区

误区 1:对象字面量省略显式类型

错误写法

const config = { retry: 3, timeout: 5000 };

正确写法

interface RetryConfig {
    retry: number;
    timeout: number;
}
const config: RetryConfig = { retry: 3, timeout: 5000 };

为什么错:ArkTS 无法为无类型字面量确定对象布局,编译期直接报错。这不是风格偏好,而是 AOT 的前提。只有目标类型明确时字面量才合法,所以正确做法是先声明接口或让形参类型提供目标类型。

误区 2:用动态属性代替显式字段

错误写法

const row = new ApprovalRow('a1');
row[`extra_${index}`] = value;      // 运行期造键
delete row.remark;

正确写法

const bag: Map<string, string> = new Map<string, string>();
bag.set(`extra_${index}`, value);
bag.delete('remark');

为什么错:对象布局必须编译期固定才能按偏移访问。需要真正动态的键空间时,用 Map 表达「这是一个键值集合」这一事实,而不是让本应固定的对象长出随机字段。

误区 3:用重载表达不同入口

错误写法

class Money {
    constructor(cents: number);
    constructor(yuan: string);
    constructor(arg: number | string) { /* ... */ }
}

正确写法

class Money {
    readonly cents: number;
    private constructor(cents: number) { this.cents = cents; }
    static fromCents(cents: number): Money { return new Money(cents); }
    static fromYuanText(text: string): Money { return new Money(Math.round(parseFloat(text) * 100)); }
}

为什么错:ArkTS 不支持重载签名,运行期按参数分派无法静态绑定。静态工厂还有额外好处:方法名自带语义,Money.fromYuanText('12.5') 比 new Money(12.5) 难错得多。

误区 4:公开签名里写 any 图省事

错误写法

export function handle(payload: any): any {
    return payload.data;
}

正确写法

export interface PromptPayload {
    data: string;
    requestId: string;
}
export function handle(payload: PromptPayload): string {
    return payload.data;
}

为什么错:any 等于关掉类型检查,同时关闭了内联与去虚化等优化机会。如果一段数据「结构未知」,正确做法是先用 JsonReader(2.3.3)把它变成有类型的值,再进入业务代码。

误区 5:依赖「结构相同即可赋值」

错误写法

class PointA { x: number = 0; y: number = 0; }
class PointB { x: number = 0; y: number = 0; }
const a: PointA = new PointB();     // 期望靠字段一致混过去

正确写法

interface HasPosition { x: number; y: number; }
class PointB implements HasPosition { x: number = 0; y: number = 0; }
const a: HasPosition = new PointB();     // 显式实现接口

为什么错:ArkTS 使用名义类型,两个字段相同的类依然互不相容。这看起来更麻烦,实际是把隐式耦合变成显式契约——重命名字段时会立刻编译失败,而不是在运行期悄悄错位。

误区 6:跨模块深路径导入

错误写法

import { parseMoney } from 'lib_base/src/internal/moneyHelper';

正确写法

// 先在 lib_base/Index.ets 中导出,再从模块名导入
import { AppError, ErrorCode } from 'lib_base';

为什么错:深路径导入把内部实现变成了公共契约,内部重构会连锁破坏所有调用方;它还极易造成循环导入——模块内部文件被外部直接引用后,依赖图不再是单向的。

误区 7:把大对象直接丢给工作线程

错误写法

parseWorker.postMessage(this.allRows);      // 几 MB 的数组

正确写法

// 只传必要数据;需要共享大对象时用 @Sendable
parseWorker.postMessage(idsForSync);

为什么错:跨线程序列化是深拷贝,几 MB 的结构在传输上耗掉的时间常超过处理本身。更糟的是这个开销不在业务代码里,性能分析时极易被误判成「渲染慢」。要么只传必要字段,要么用 @Sendable 共享内存。


2.10 实践任务

任务:搭出类型安全的领域模型与数据入口

任务描述

  1. 新建 HAR 模块 lib_base,根 Index.ets 导出 Result / Success / Failure / AppError / ErrorCode / ParseIssue / JsonReader,内部实现文件一律不导出。
  2. 实现 Approval 领域模型(含 ApprovalStatus、amountText / isActionable / withStatus),构造函数完成后不存在「字段缺失」状态。
  3. 实现 JsonReader 与 ApprovalMapper.fromJson / fromJsonList,处理四类输入:正常数据、字段缺失、类型不符(amount 是字符串)、tags 内含非字符串元素。
  4. 用不可变更新方法(replaceById / removeById / sortedByCreatedDesc)实现列表操作,禁止对入参调用 sort。
  5. 写一个 taskpool 任务,把「大批量 JSON 计数」下沉到工作线程,主线程只负责赋值。
  6. 配置 .codelinter.json 并跑全量检查,告警数降到 0 或逐条写明豁免理由。

验收 checklist

  • [ ] lib_base/Index.ets 是唯一导出面,全工程无深路径导入
  • [ ] 全工程搜索 any / unknown,公开签名中为 0 处
  • [ ] 全工程搜索 delete 与「给未声明字段赋值」,为 0 处
  • [ ] ApprovalMapper.fromJson 对四类输入均有确定行为,且不抛异常
  • [ ] 类型不符与字段缺失都会产生 ParseIssue,且 value 仍可用
  • [ ] 非法 JSON 时 fromJson 返回 undefined(而不是抛错或返回半成品)
  • [ ] RowStore.sortedByCreatedDesc 调用后原数组顺序不变
  • [ ] @Concurrent 函数为顶层函数,且所在文件为 .ets
  • [ ] 给 UI 状态赋值的代码全部位于主线程侧
  • [ ] codelinter -c .codelinter.json . 无 error 级告警

交付物清单

交付物 形式 验收点
lib_base HAR 模块目录 Index.ets 导出面完整,含 Result / AppError
领域模型三件套 源码 Approval / ApprovalStatus / 金额字段
JsonReader.ets + ApprovalMapper.ets 源码 四类异常输入有确定行为
边界用例集 测试代码或用例表 每类输入一条,含期望输出
.codelinter.json 配置 检查可复现执行
检查结果记录 Markdown 告警数 + 每条豁免的理由与负责人

2.11 延伸阅读

  • 官方文档 → 应用开发 → ArkTS 语言 → 从 TypeScript 到 ArkTS 的适配规则(逐条限制的权威说明)
  • 官方文档 → 应用开发 → ArkTS 语言 → ArkTS 语法约束(禁止项总览,遇到编译报错先查这里)
  • 官方文档 → 应用开发 → ArkTS 语言 → 并发编程(taskpool / worker / Sendable)
  • 官方文档 → 应用开发 → ArkTS 语言 → 模块化与 Kit 化导入说明(各能力域与 Kit 的对应关系)
  • 官方文档 → 应用开发 → ArkUI → 状态管理 V2(@ObservedV2 / @Trace 的观察边界)
  • 官方文档 → 工具 → Code Linter 静态检查(规则集与配置文件格式)

上一章:第 1 章 环境搭建与工具链 | 下一章:第 3 章 ArkUI 组件与布局

📄 第 11 章 网络层与统一错误处理 02-network-result-error.md

术语:适配点(adaptation point)—— 一处刻意收窄的边界,把所有易变的第三方 API 调用关在同一个文件里,让外部只面对稳定的自有契约。

前置:本章默认你已经读过 docs/06-persistence-network.md 的 6.3~6.6 节,那里讲清了 rcp 的基础用法、拦截器的概念模型、Result / AppError 的设计动机。本篇不再重复概念,只做一件事:把这套模型在真实工程里的落地逐行对上。

版本门槛:网络能力基于 HarmonyOS 6.0.0(20) 起可用的 @kit.RemoteCommunicationKit。本工程开发套件基线为 26.0.0,compatibleSdkVersion 下限 6.0.0(20)。


2.0 本章目标

读完本篇,你应当能回答:

  1. 为什么整个工程里 @kit.RemoteCommunicationKit 只出现在一个文件里,以及要换网络库时该改哪几个方法;
  2. 为什么 Session 必须全局单例,而鉴权头绝对不能写进 SessionConfiguration.headers;
  3. Auth → Logging → Mock → ErrorMap → Retry 这条链为什么是这个顺序,Mock 为什么必须夹在正中间;
  4. 重试的三条原则在代码里分别由哪一行保证,抖动(jitter)解决的是什么问题;
  5. Result<T> / AppError 的每个字段是谁在写、谁在读;
  6. 401 为什么要"单飞刷新",pending 的清理时机为什么不能用 finally;
  7. 幂等键为什么必须"在用户点击提交时生成一次",以及"改了内容"为什么反而要重置它;
  8. 响应字段缺失为什么不算错误,但必须上报。

2.1 目录结构与唯一适配点

2.1.1 真实目录树

lib_network/src/main/ets/
├── model/
│   └── HttpTypes.ets          # 对外数据契约:RequestOptions / NetResponse /
│                              #   CancelToken / RetryPolicy / ApiEnvelope / BizCode / NetErrors
├── api/
│   └── HttpApi.ets            # 门面:解包 data 段 + 调用具名 parser + IdempotencyKeys
├── rcp/
│   ├── RcpClient.ets          # ★ 唯一适配点(RcpTransport)+ 执行循环(RcpClient)
│   ├── NetInterceptor.ets     # NetContext / RetryPlan / LoggingSession / 基类 / InterceptorChain
│   └── interceptors/
│       ├── AuthInterceptor.ets        # + AuthTokenProvider / AuthTokenRegistry
│       ├── LoggingInterceptor.ets     # + LoggingInterceptorSession
│       ├── MockInterceptor.ets
│       ├── ErrorMapInterceptor.ets    # + HttpStatusMapper / BizCodeMapper
│       └── RetryInterceptor.ets
└── mock/
    └── MockRegistry.ets       # 规则注册表 + MockBody 构造器

对照 lib_network/Index.ets:业务模块只应从这里 import,内部实现虽然导出,但注释明确写了"应视为可替换实现,而不是稳定契约"。这条纪律是后面所有"改一处"讨论的前提。

2.1.2 为什么 rcp 只能出现在一个类里

lib_network/src/main/ets/rcp/RcpClient.ets:12-22 的文件头注释就是答案:

本文件中的 RcpTransport 是整个工程唯一直接调用 rcp API 的地方。这样设计的原因很实际:rcp 的接口在不同 SDK 版本间有过微调,如果把它散落在业务与各拦截器里,一次 SDK 升级就要改十几处。

可以用一条命令验证这个约束确实成立:

cd code/office-flow
grep -rl "@kit.RemoteCommunicationKit" --include=*.ets .
# 期望输出只有一行:lib_network/src/main/ets/rcp/RcpClient.ets

这是"约束靠检查而不是靠自觉"的一个正面例子。如果没有这条 grep,RcpTransport 会在半年内被后来者绕开——因为直接 rcp.createSession() 看起来总是"更快一点"。

2.1.3 RcpTransport 的五个方法

方法 真实签名 职责
ensureSession private ensureSession(): rcp.Session 懒创建进程级 Session;只配置进程级不变的策略
send async send(ctx: NetContext): Promise<NetResponse> 发一次请求,把 rcp 的响应规整成 NetResponse。不做重试、不做错误映射
toAppError toAppError(e: Error, ctx: NetContext): AppError 把 rcp 抛出的原生异常归一化为 AppError
close close(): void 进程退出前释放 Session
headersOf / bodyTextOf private static 把 rcp 的响应头 / 响应体结构规整成稳定的 Record<string, string> / string

headersOf 与 bodyTextOf 是 private static,因为它们纯粹是"把 SDK 的形状差异吃掉",不暴露给上层。bodyTextOf 里那段 typeof body === 'string' 的分支,就是为不同版本 rcp 取值方式略有差异准备的。

对表记录(已修复):本篇早期版本曾记录 RcpClient.ets:20 的注释写作"只需要改 RcpTransport 的 3 个方法(createSession / send / mapError)",与实际方法名不一致。现已改为 ensureSession / send / toAppError,与类中实际签名一致。

2.1.4 要换网络库时改哪里

因为 RequestOptions(model/HttpTypes.ets:169)刻意不暴露任何 rcp 类型,替换网络库的改动面被限制在 RcpClient.ets 一个文件:

  1. 重写 RcpTransport 的 ensureSession / send / toAppError / close;
  2. 保持 send 的入参 NetContext 与出参 NetResponse 不变;
  3. 保持 toAppError 的三层判断顺序(取消 → 超时 → 其他)不变。

HttpApi、五个拦截器、所有 feature 的数据源与仓储一行都不用改。这就是收敛适配点的全部收益。


2.2 Session 生命周期

2.2.1 为什么必须全局单例

RcpClient.get(baseUrl) 是进程级单例(RcpClient.ets:161),内部持有一个 RcpTransport,RcpTransport 持有一个 rcp.Session。

Session 的价值在于连接池与 TLS 会话复用。如果每个页面各建一个 Session:

  • 每个 Session 一份连接池,连接数随页面数线性增长;
  • HTTPS 的 TLS 握手(含证书校验)会被重复执行,冷启动首个请求的耗时会成倍增加;
  • 超时、重定向等策略会在多处配置,autoRedirect 在 A 处是 true、B 处忘了设,表现为"某些接口不跟随跳转"。

因此:Session 是进程级资源,不是请求级资源。它随进程创建、随进程退出释放,绝不随页面创建销毁。

// RcpClient.ets:161-166
static get(baseUrl: string): RcpClient {
    if (RcpClient.instance === undefined) {
        RcpClient.instance = new RcpClient(baseUrl);
    }
    return RcpClient.instance;
}

RcpClient.reset() 存在,但只有一个用途:测试(更换 baseUrl 或清空拦截器状态)。业务代码不应调用它。

2.2.2 绝不能把 token 写进 SessionConfiguration.headers

这是 rcp 最容易被踩、后果最严重的一个坑。RcpClient.ets:30-39 的注释写得很直接:

不要在这里配置任何"会变化"的头(token、用户标识、租户)。Session 复用会把 SessionConfiguration 的头固化下来,令牌刷新后旧值仍会被发送。所有变化参数一律走每次请求的 Request。

故障表现是这样的:

  1. 用户登录,token = T1;
  2. 你把它写进 SessionConfiguration.headers['Authorization'],Session 创建完成;
  3. 半小时后 token 过期,单飞刷新拿到 T2,TokenStore 已更新;
  4. 下一个请求复用同一个 Session —— 发出的仍然是 Authorization: Bearer T1;
  5. 服务端返回 401 → 触发刷新 → 拿到 T3 → 再发仍是 T1 → 无限 401 循环,最终被判为"刷新失败"而登出。

排查时最迷惑的地方是:TokenStore 里明明是 T2,日志里却没打出 T2。因为你打印的是存储值,而网络上跑的是 Session 里被固化住的那个旧值。

正确的做法:Session 配置里只放进程级不变的策略。

// RcpClient.ets:40-57
private ensureSession(): rcp.Session {
    if (this.session === undefined) {
        this.session = rcp.createSession({
            baseAddress: this.baseUrl,
            requestConfiguration: {
                transfer: {
                    autoRedirect: true,
                    timeout: {
                        connectMs: 10000,   // 连接超时:进程级不变
                        transferMs: 15000   // 传输超时:进程级不变
                    }
                }
            }
        });
        XLogger.i(this.tag, () => `session created, baseUrl=${this.baseUrl}`);
    }
    return this.session;
}

baseAddress 也是进程级不变的——换环境靠切换 EnvConfig.CURRENT 后重启,而不是运行时改 Session。

2.2.3 正确做法:每次请求从零构造 headers

AuthInterceptor.before 注入 Authorization,但注入了不代表发得出去。真正保证"这次发的是这次的 token"的,是 RcpClient.buildHeaders 每次从零构造一个对象:

// RcpClient.ets:306-328
/**
 * 每次请求都构造新的 headers 对象。
 * 这是"可变头必须走 Request 而不是 Session 配置"的落地方式:
 * 从零构造,杜绝上个请求的残留头被带过来。
 */
private static buildHeaders(options: RequestOptions): Record<string, string> {
    const headers: Record<string, string> = {
        'Content-Type': 'application/json; charset=utf-8',
        'Accept': 'application/json'
    };
    // 幂等键:非幂等写操作由客户端生成,服务端据此去重
    if (options.idempotencyKey !== undefined && options.idempotencyKey.length > 0) {
        headers['Idempotency-Key'] = options.idempotencyKey;
    }
    const custom: Record<string, string> | undefined = options.headers;
    if (custom !== undefined) {
        const keys: string[] = Object.keys(custom);
        for (let i = 0; i < keys.length; i++) {
            headers[keys[i]] = custom[keys[i]];
        }
    }
    return headers;
}

三个细节值得单独指出:

  1. 从零构造,而不是"复制一份默认头再改"。复制会留下"上一轮的键"这种隐蔽残留。
  2. Idempotency-Key 在这里落地,而不是在 AuthInterceptor 里——因为它是 RequestOptions 的一等字段,属于请求契约,不属于鉴权。
  3. options.headers 最后合并,让业务传入的同名头可以覆盖默认值(例如某接口需要 Content-Type: text/plain)。

误区:以为"我在 AuthInterceptor 里 ctx.headers['Authorization'] = ... 就够了"。够,但前提是 ctx.headers 是全新的。一旦有人图省事把 NetContext 的 headers 改成一个模块级常量对象,这条链立刻退化成 2.2.2 描述的故障。

2.2.4 关闭时机

RcpTransport.close 的注释写明「Session 是进程级的,不在每次请求后关闭」。调用链是:

app 退出 / 测试清理 → RcpClient.close() → RcpTransport.close() → session.close()

业务代码不需要关心关闭。唯一会碰到它的是 RcpClient.reset()(测试用)。


2.3 拦截器链

2.3.1 三个挂载点与统一返回约定

NetInterceptor.ets:109-123 定义了基类:

export abstract class NetInterceptor {
    abstract readonly name: string;

    before(ctx: NetContext): Promise<AppError | undefined> { return Promise.resolve(undefined); }
    after(ctx: NetContext): Promise<AppError | undefined> { return Promise.resolve(undefined); }
    onError(ctx: NetContext, error: AppError): AppError { return error; }
}
挂载点 时机 返回值语义
before 请求发出前 undefined = 继续;AppError = 短路,不发请求
after 拿到响应后(含 Mock 响应) undefined = 继续;AppError = 短路进入错误处理
onError 发生错误时 必须返回 AppError(可以是原对象),链上每一环都可替换它

三个设计决策:

  1. 用返回值而不是抛异常。注释写明:让控制流在 ArkTS 下保持可静态分析,也避免"异常穿越拦截器链"带来的语义歧义。ArkTS 的类型收窄对 try/catch 的支持不如对返回值判别友好。
  2. onError 不短路,而是链式替换。InterceptorChain.runOnError 的注释:采用"链式替换"而非"首个生效",让错误能层层补充上下文(如先归一化为 HTTP_5XX,再由 Retry 环节追加"已重试 2 次")。这一点在 2.3.7 的"已达上限"分支里被真实用到。
  3. LoggingSession 接口定义在 NetInterceptor.ets 而不是 LoggingInterceptor.ets。注释说明:避免两个文件互相 import 形成类型环——方向始终是拦截器 → 上下文。这是一个小但真实的工程决策:ArkTS 的循环 import 在某些场景下会静默产生 undefined。

对表记录(已修复):本篇早期版本曾记录 NetInterceptor.ets:2 有一行自引用导入(import { NetContext, RetryPlan, NetInterceptor } from '../NetInterceptor';,三个符号全部定义在本文件内)。该行现已删除,文件头只保留对 lib_base 与 ../model/HttpTypes 的合法导入。

2.3.2 链上顺序与理由

注册顺序(RcpClient.ets:151-156):

this.chain = new InterceptorChain()
    .add(new AuthInterceptor())
    .add(new LoggingInterceptor())
    .add(new MockInterceptor())
    .add(new ErrorMapInterceptor())
    .add(new RetryInterceptor());

InterceptorChain 的注释(NetInterceptor.ets:125-137)给出了逐条理由:

序 环节 为什么在这个位置
1 Auth 后续所有环节都应在"已带鉴权头"的前提下工作
2 Logging 能记录到最终发出的头(含鉴权、已脱敏),且能覆盖 Mock 命中的请求
3 Mock 命中即短路不走真实网络,但依旧被 Logging 记录
4 ErrorMap 把 HTTP 状态码与业务码统一成 AppError
5 Retry 只有拿到归一化错误后才能判断"是否值得重试"

一句话记法:先给身份 → 再留证据 → 再决定真假 → 再翻译错误 → 最后决定要不要再来一次。

注册顺序不等于执行顺序。三个挂载点的执行语义(NetInterceptor.ets:146-191):

  • before 按注册正序执行(Auth 最先注入头,Logging 第二个记录);
  • after 按注册逆序执行(洋葱模型,runAfter 从链尾往回走):ErrorMap 先把状态码/业务码归一成 AppError,Logging 最后收尾。因此 Logging.after 只会在"确认成功"时执行 finish(true);失败请求在 ErrorMap 处被短路,改由 onError 阶段的 finish(false, code) 记账——失败请求不会被埋点记成成功(若 after 按正序执行,Logging 会在 ErrorMap 之前 finish(true),而 finish 的幂等标志会吃掉 onError 里的失败记账);
  • onError 按注册正序、链式全员执行,每环都可替换错误对象,不短路。

2.3.3 AuthInterceptor:注入 + 401 判定

before 做四件事(AuthInterceptor.ets:79-121):

  1. 注入公共头:X-Trace-Id / X-Client / X-Client-Version / X-Platform。注释说明用途是"便于服务端做灰度与问题定位"。X-Client-Version 由 setClientVersion 在启动时注入,避免在 lib_network 里读 UI 层信息。
  2. needAuth === false 时提前 return,公开接口(登录、刷新)到此为止。
  3. 预刷新:仅当 provider.shouldRefresh() && ctx.options.idempotent === true 时先刷一次。为什么只对幂等请求做?因为预刷新会引入一次额外往返;对非幂等请求(提交),它与"401 后刷新再重放"的收益相同但风险更高(重放语义更敏感),所以索性只让 401 分支处理。
  4. 取 token,为空则直接返回 401 错误(userMessage: '登录已过期,请重新登录'),并打上 Authorization: Bearer ${token}。

onError 是 401 单飞刷新的触发点,细节见 2.7。

AuthInterceptor 没有 after,用基类的 no-op——鉴权不需要处理"响应回来后"的任何事。

2.3.4 LoggingInterceptor:三种产出,两条纪律

三种产出(LoggingInterceptor.ets:8-11):

  1. 请求/响应摘要日志(排障);
  2. 耗时打点 Trace.count('net:duration', elapsed)(性能分析);
  3. 网络埋点 Analytics.network(tag, elapsed, success, errorCode)(服务端观测成功率与耗时分布)。

两条纪律:

  • 请求体默认不打印。BODY_LOG_WHITELIST 当前是空数组,也就是全工程默认一个 body 都不打。要打必须显式把路径加进白名单,且只打白名单内的接口。
  • 响应体只打长度与前 200 字符(BODY_PREVIEW_LIMIT = 200),且先过 Masker.text。

LoggingInterceptorSession 的存在理由很值得抄:注释说"把'开始 → 结束'的状态收在一个对象里,避免拦截器持有可变静态字段(并发请求下会被互相覆盖,这是很隐蔽的 bug 来源)"。finish() 里还有 finished 幂等标志,保证重复调用不会重复埋点。配合 2.3.2 说的 after 逆序执行:Logging.after 只在"确认成功"(ErrorMap 未报错)时才 finish(true),失败请求走 onError 的 finish(false, code)——两处只会有其一生效,埋点的 success 字段因此可信。

onError 里第一件事是判断 error.isCancellation——取消不打错误日志。注释:否则用户每次返回上一页都会刷一条红色日志。

2.3.5 MockInterceptor:为什么必须夹在正中间

MockInterceptor.ets:7-20 的注释把这个位置讲透了:

  • 在 Logging 之后 → Mock 命中也会输出完整日志与耗时埋点,测试时看到的链路与真实请求一致,不会因为"换成了假数据"而丢失可观测性。
  • 在 ErrorMap 之前 → Mock 返回的假错误(401/500/业务码)照样会被归一化,因此错误处理分支可以被真实地演练。

第二条是这条链最有价值的设计:Mock 不是"绕过系统的后门",而是"注入到系统中间的一段可编程响应"。你可以用 MockRegistry.setFailureRate(0.3) 让 30% 的请求返回 503,从而在真机上验证重试、慢请求告警、降级返回缓存这些分支——这些分支用真实后端极难稳定复现。

实现方式(MockInterceptor.ets:17-19):命中后不直接返回成功,而是把响应写进 ctx.mockedResponse,由 RcpClient 跳过真实网络、直接进入 after 阶段。这样"响应处理"这条路径只有一份实现。

// MockInterceptor.ets:68-75
const netResponse: NetResponse = new NetResponse(
    mockResponse.statusCode,
    { 'content-type': 'application/json' },
    mockResponse.body
);
ctx.mocked = true;
ctx.mockedResponse = netResponse;
ctx.response = netResponse;

注意 ctx.mocked = true 这个标记:它让 LoggingInterceptorSession.finish 在日志末尾打上 | MOCKED,让你在几百行日志里一眼分辨"这次是真请求还是假数据"。

还有一个细节:Mock 会在 before 阶段 await sleep(delay)(MockInterceptor.ets:63-66)。这是刻意的——让 Mock 也消耗真实的时间,从而让骨架屏、加载态、慢请求告警的表现在 Mock 模式下与真实一致。

2.3.6 ErrorMapInterceptor:三层归一化的第 1、3 层

after 的逻辑(ErrorMapInterceptor.ets:138-159):

async after(ctx: NetContext): Promise<AppError | undefined> {
    const response: NetResponse | undefined = ctx.response;
    if (response === undefined) { return undefined; }
    // 第一层:HTTP 状态码
    if (!response.isHttpSuccess) {
        return HttpStatusMapper.toError(response, ctx.tag);
    }
    // 第三层:业务码(HTTP 200 但 code != 0)
    const envelope: ApiEnvelope | undefined = ErrorMapInterceptor.tryEnvelope(response.rawBody, ctx.tag);
    if (envelope === undefined) {
        // 响应体不是统一包装格式:不视为错误,交给上层 parser 处理
        return undefined;
    }
    if (envelope.traceId.length > 0) {
        response.traceId = envelope.traceId;   // 回填 traceId
    }
    return BizCodeMapper.toError(envelope, ctx.tag);
}

tryEnvelope 采用"宽松解析"(ErrorMapInterceptor.ets:161-187):只要文本能解析为对象、且含数字型 code 字段,就认为是包装格式。注释说明不用严格校验是为了兼容"部分接口直接返回数组"的历史情况。

BizCodeMapper 的映射表:

业务码 BizCode 常量 归一化后的 ErrorCode 用户文案 retryable
40100 TOKEN_EXPIRED BIZ_TOKEN_EXPIRED 登录已过期,请重新登录 false
40300 NO_PERMISSION BIZ_NO_PERMISSION 你没有执行该操作的权限 false
40901 DUPLICATE_SUBMIT BIZ_CONFLICT 该单据已提交,请勿重复操作 false
50000 SERVER_ERROR HTTP_SERVER_ERROR 服务暂时不可用,请稍后重试 true
其他 — BIZ_FAILED 优先用服务端 message false

40901 这条映射是幂等键设计的另一半:服务端识别出"这是同一业务动作的重复请求",返回 40901,客户端把它变成"该单据已提交,请勿重复操作"。用户看到的是一条明确提示,而不是"提交失败"这种含糊文案——因为对用户来说,"已经提交过了"和"提交失败"是完全不同的两件事。

2.3.7 RetryInterceptor:只做判定,不做重发

注释(RetryInterceptor.ets:17-19)说明了实现方式:本拦截器不做真正的重发,而是给 ctx 打上 retryPlan 标记,由 RcpClient 的执行循环读取该标记后决定是否再跑一轮。理由是避免拦截器内部递归调用 Session 带来栈与取消语义混乱。详见 2.4.3。

还有一个容易漏掉的细节:达到 maxAttempts 上限时,必须显式清空 ctx.retryPlan(RetryInterceptor.ets:43-48)。onError 链是"链式替换、全员过一遍",在本环节之前 Auth 可能已经为 401 写入了 needRetry=true 的刷新计划——如果这里只给错误追加"(retried N times, giving up)"的 message 而不清计划,执行循环会看到 needRetry=true 继续重放,与本环节"已达上限"的判定互相矛盾,次数上限对 401 刷新路径形同虚设。

2.3.8 一个请求的完整时序

HttpApi.get / post / ...                     ← 合并默认值(idempotent / needAuth / tag)
  └─ RcpClient.execute(options)
       ├─ traceId = nextTraceId();  attempt = 1
       ├─ refreshedOnce = false          ← 循环级状态:本次请求是否已"刷新并重放"过一次
       └─ while (true)
            ├─ ① 取消检查(cancelToken.cancelled → 直接 Failure)
            ├─ ② 构造 NetContext(url / headers / body 全部从零构造)
            │     若 refreshedOnce → 把 AuthInterceptor.MARK_REFRESHED 继承进新 ctx.marks
            │     (ctx 每轮重建,不继承的话 AuthInterceptor 的"已刷仍 401"闸门永不触发)
            ├─ ③ runBefore(ctx)(按注册正序)
            │     Auth.before      → 公共头 + (必要时预刷新)+ Authorization
            │     Logging.before   → session.begin():打请求日志 + Trace.count
            │     Mock.before      → 命中则写 ctx.mockedResponse(含延迟)
            │     ErrorMap.before  → no-op
            │     Retry.before     → no-op
            ├─ ④ if (无错 且 无 Mock 响应) → transport.send(ctx)
            │     catch → transport.toAppError(e, ctx)   ← 取消 / 超时 / 不可达
            ├─ ⑤ if (无错) runAfter(ctx)(按注册逆序 = 洋葱)
            │     Retry.after      → no-op
            │     ErrorMap.after   → HTTP 状态 / 业务码 → AppError(短路点)
            │     Mock.after       → no-op
            │     Logging.after    → session.finish(true):只有"确认成功"才走到这里
            │     Auth.after       → no-op
            ├─ ⑥ if (无错 且 有响应) → return Success<NetResponse>
            ├─ ⑦ runOnError(ctx, error)   ← 链式替换,不短路(按注册正序)
            │     Auth.onError     → 401 → retryPlan{needRetry, requireRefresh};
            │                        已带 MARK_REFRESHED → 直接 onUnauthorized 强制登出
            │     Logging.onError  → session.finish(false, code) + FAIL 日志
            │     Mock.onError     → 原样返回
            │     ErrorMap.onError → 原样返回
            │     Retry.onError    → 三条原则判定;已达上限 → 清空 retryPlan 后放弃
            ├─ ⑧ if (retryPlan 为空 / needRetry=false / 已取消) → return Failure
            ├─ ⑨ if (requireRefresh) → 单飞刷新后重放
            │     refreshedOnce 已为 true → 双保险闸门:onUnauthorized → return Failure
            │     provider.refreshToken() 失败 → provider.onUnauthorized(...) → return Failure
            │     成功 → refreshedOnce = true(一次请求最多刷新重放一次)
            ├─ ⑩ sleep(plan.delayMs)
            └─ ⑪ attempt++  ── 回到 ①
  └─ DefaultHttpApi.unwrap(response, options, parser)
       ├─ extractDataText(rawBody)  → 取 data 段文本(裸数组根原样透传,见 2.9.2)
       ├─ parser(dataText, issues)  → DTO → 领域模型(可能抛错 → PARSE_FAILED)
       └─ issues.length > 0 → XLogger.w(留痕,但不报错)

这张图建议对照 RcpClient.ets:188-275 逐行读一遍。八成的"网络层疑难杂症"都能在这张图上定位到是哪一步——例如"错误文案不对"一定在 ⑤/⑦,"请求头不对"一定在 ③/②,"重复提交"一定在 ⑨/⑪ 之外(即不该走到重试)。


2.4 重试设计

2.4.1 三条原则

三条原则被写进类型与代码,而不是写在文档里靠自觉(HttpTypes.ets:75-90、RetryInterceptor.ets:11-15):

原则 代码落点
仅幂等请求重试 RetryInterceptor.onError 第 2 步:if (ctx.options.idempotent !== true) { return error; }
仅可恢复错误重试 第 5 步:if (!policy.retryOn(error)) { return error; },默认实现是 e.retryable
有上限与退避 RetryPolicy.maxAttempts / baseBackoffMs 是必填字段;第 4 步检查 ctx.attempt >= policy.maxAttempts,命中上限时显式 ctx.retryPlan = undefined——否则此前 Auth 为 401 写入的刷新计划会让上限失效(见 2.3.7)

注意第 2 步的位置在第 5 步之前——注释写了"非幂等请求绝不重试 —— 这是安全底线,优先于一切"。即使错误是 5xx(retryable: true),只要请求非幂等就不重试。这个顺序不是随手写的。

RequestOptions.idempotent 的注释(HttpTypes.ets:185-189)值得抄下来:

是否幂等。决定能否重试 —— POST 提交审批必须为 false,重试会造成重复单据。幂等性由业务语义决定,不由 HTTP 方法决定。

DefaultHttpApi 的默认值体现了后半句(HttpApi.ets:57-103):GET 默认 idempotent: true;POST / PUT / DELETE 默认 idempotent: false。要让写操作重试,必须显式传 idempotent: true,而这个显式声明本身就会在 code review 里被看见。

2.4.2 指数退避 + 抖动

// RetryInterceptor.ets:60-70
/**
 * 指数退避 + 抖动。
 * 抖动(jitter)很关键:没有抖动时,一批同时失败的客户端会在同一时刻集体重试,
 * 把刚恢复的服务再次打挂。
 */
static backoff(baseMs: number, attempt: number): number {
    const exponential: number = baseMs * Math.pow(2, attempt - 1);
    // 0.5~1.0 的随机因子,等价于 +/- 25% 抖动
    const jitter: number = 0.5 + Math.random() * 0.5;
    return Math.round(exponential * jitter);
}

以 baseBackoffMs = 300 为例:

attempt 指数部分 实际等待区间
1 300ms 150 ~ 300ms
2 600ms 300 ~ 600ms
3 1200ms 600 ~ 1200ms

抖动解决的是惊群问题。想象一次后端重启:1 万个客户端在同一秒失败、baseMs 相同、退避函数确定,那么它们会在同一毫秒一起重试,正好在服务刚起来、最脆弱的时候再打一次。加了抖动后,重试请求被摊平到半个退避窗口内。

不需要抖动的场景只有一种:你希望重试的时刻可预测以便测试。那时应该用 Mock 注入确定的延迟,而不是去掉抖动。

2.4.3 为什么重试循环在 RcpClient 而不在拦截器

RcpClient.ets:181-187 的注释:

重试循环放在这里(而不是拦截器内部)的原因:重发涉及"取消检查、尝试计数、退避等待、401 刷新"四件事,把它们集中在一处,语义清晰且只有一份实现。

反面设计是把 while 写在 RetryInterceptor.onError 里。会立刻带来三个问题:

  1. onError 的返回类型是 AppError,不是 Promise<Result<T>>,重发必须打破这个签名;
  2. 取消语义需要重新实现一遍(ctx.cancelled 在每一轮开头检查),极容易漏掉某一轮;
  3. 拦截器内部再调 chain.runBefore 会造成递归执行链,LoggingInterceptor 的 session.begin() 被重复调用,日志里出现重复的 --> 行。

集中在一处的代价是 RcpClient.execute 那个 while (true) 有点长(约 60 行)。用一个有点长的循环换掉三处重复实现,是划算的。

2.4.4 各接口的重试声明

真实工程的声明情况(feature_approval/.../ApprovalRemoteDs.ets):

接口 方法 idempotent retry 理由
/approval/list GET true defaultIdempotentRetry() 纯读,天然幂等
/approval/detail GET true defaultIdempotentRetry() 纯读
/approval/count GET true defaultIdempotentRetry() 纯读
/approval/submit POST false noRetry() 写操作,重复单据
/approval/withdraw POST false noRetry() 写操作
/approval/decide POST false noRetry() 写操作
/auth/refresh POST true 默认 刷新是幂等的,且它是"救命路径",必须允许重试

最后一行是唯一一个"POST 但 idempotent: true"的例子,理由写在 TokenRefresher.ets:167-169:

needAuth: false —— 刷新请求本身不能带(可能已过期的)访问令牌,否则服务端会先因过期令牌返回 401,导致刷新永远进不去。 idempotent: true —— 刷新是幂等的,允许网络层重试;这是它能作为"救命路径"的前提。


2.5 Result 模型

2.5.1 定义

// lib_base/src/main/ets/result/Result.ets
export abstract class Result<T> {
}

export class Success<T> extends Result<T> {
    readonly value: T;
    constructor(value: T) { super(); this.value = value; }
}

export class Failure<T> extends Result<T> {
    readonly error: AppError;
    constructor(error: AppError) { super(); this.error = error; }
}

两个设计决策的理由都写在文件头(Result.ets:3-12):

为什么不用 { ok: boolean, data?: T, error?: AppError }:布尔判别下 data 与 error 都是可选的,调用方总能写出"访问了不存在的 data"的代码;类继承 + ArkTS 的类型收窄,能让编译器在 instanceof Success 分支内保证 value 存在。

为什么 Failure 不带泛型:失败分支不持有 T,带上泛型参数只会让类型推导变啰嗦,运行时也不存在那个 T。这就是为什么 Failure<T> 里没有 value 字段、Failure 的构造只接收 AppError。

实践中常见的写法分两类,注意区分:

// 从函数返回:用偏函数形式,T 由返回类型推导
async function load(): Promise<Result<Approval>> {
    return err<Approval>(e);          // 显式 T
}

// 判断:必须先 instanceof 再取字段
if (result instanceof Success) {
    const value: Approval = (result as Success<Approval>).value;
}

(result as Success<Approval>) 这个 cast 看起来啰嗦,但它是代价最低的写法:result instanceof Success 已经把分支收窄到了 Success,cast 只是补上泛型参数。若省略 cast,ArkTS 在某些嵌套场景下会推导出 Success<unknown> 而导致后续字段访问报错。

2.5.2 工厂函数

// Result.ets:34-51
export function ok<T>(value: T): Result<T> { ... }
export function err<T>(error: AppError): Result<T> { ... }

/** 把可能抛异常的同步块包成 Result,避免异常逃逸出数据访问层 */
export function attempt<T>(block: () => T, onError: (e: Error) => AppError): Result<T> {
    try {
        return new Success<T>(block());
    } catch (e) {
        return new Failure<T>(onError(e as Error));
    }
}

attempt 的用途是包裹同步的 IO(RDB 查询、文件读)。它的价值在于把"可能抛异常的第三方 API"转成 Result,从而让仓储层的 Result<T> 返回契约不会被一个意外的 throw 打破。

2.5.3 Results 组合子

方法 签名要点 典型用途
orElse (Result<T>, fallback: T) => T "有默认值就行"的读场景
valueOrUndefined (Result<T>) => T | undefined UI 层可选渲染
errorOrUndefined (Result<T>) => AppError | undefined 只想拿错误做日志/判断
map (Result<T>, (T) => R) => Result<R> 变换成功值,失败原样透传
flatMap (Result<T>, (T) => Result<R>) => Result<R> 串联两次可能失败的调用
fold (Result<T>, onSuccess, onFailure) => R 一并收敛两个分支,保证两种结局都被显式处理
recover (Result<T>, (AppError) => Result<T>) => Result<T> 失败时尝试恢复
onSuccess 副作用,返回原 result 只有成功才做的事(写缓存、埋点)
onFailure 副作用,返回原 result 失败才做的事(注意过滤取消类错误)

两个使用纪律:

  1. fold 优于 if (r instanceof Success)。if 会让"忘了处理失败分支"变成一件可能的事;fold 强制两个回调都传。
  2. onFailure 里必须先判 error.isCancellation。注释写了:"取消类错误应在这里被过滤掉,不要触发用户提示"。这条在真实工程里被反复使用,例如 ApprovalRepositoryImpl.ets:152 / :191 / :211 三处判断都是 error !== undefined && !error.isCancellation。

Results 存在的动机写在文件头:让"取值 → 变换 → 分支"这套高频动作有统一写法,避免每个 ViewModel 里都出现 if (r instanceof Success) 的复制粘贴。

2.5.4 sequence:批量提交的语义

// ResultExt.ets:75-90
/**
 * 把 Result 列表收敛为"全成功才成功"的 Result 列表。
 * 用于批量提交这类语义:要么整体成功,要么明确告知失败项。
 */
export function sequence<T>(results: Result<T>[]): Result<T[]> { ... }

注意它的语义是短路:遇到第一个 Failure 就返回,且返回的 AppError 是那个失败项的错误。这适合"批量删除"这类操作——用户关心的是"哪个失败了、为什么",而不是"一共成功了几个"。要后者就得换一个返回结构(例如 { succeeded: T[], failed: Array<{index, error}> }),sequence 故意不承担这个职责。

2.5.5 与第 6 章的关系

docs/06-persistence-network.md 的 6.6 节用大量篇幅论证了"为什么用 Result 而不是异常"。本篇不重复论证,只做一件事:把 Result 在真实工程中的使用位置列出来,让你知道该在哪些层看到它。

层 返回类型 说明
RcpClient.execute Promise<Result<NetResponse>> 原始响应,未解包
HttpApi.request / get / post … Promise<Result<T>> 已解包 + 已解析
ApprovalRemoteDs.list / detail / … Promise<Result<Paged<Approval>>> 等 已转领域模型
ApprovalRepositoryImpl.list / submit / … Promise<Result<ApprovalListResult>> 等 已合并 SWR / 写穿透策略
ApprovalListViewModel.load 内部持有 UiState,方法返回 Promise<void> 视图模型不再向上返回 Result

最后一行是有意的:ViewModel 是 Result 的终点。它把 Result 收敛成 @Local 状态(UiState / 列表数据 / 错误文案),View 只读状态,不碰 Result。这条边界让 View 层完全不需要 import lib_base 的错误类型。


2.6 AppError 与三层归一化

2.6.1 字段表

lib_base/src/main/ets/error/AppError.ets:65-92:

字段 类型 谁写 谁读 说明
code string 归一化方 UI 分支、埋点聚合 稳定错误码,见 2.6.3
kind AppErrorKind 归一化方 UI 粗粒度分支 大类,比逐条 code 稳定
message string 归一化方 日志、上报 开发者可见,可含技术细节
userMessage string 归一化方 UI Toast / 错误页 面向用户,建议由资源文件提供
httpStatus number HTTP 层 日志、上报 非 HTTP 来源为 0
bizCode string 业务码层 日志、上报 非业务错误为空串
retryable boolean 错误的产生方判定 RetryPolicy.retryOn 消费方不猜
traceId string 上下文 / 响应体 用户反馈反查 与网络层链路标识一致
cause Error | undefined 传输层 深度排障 保留原始异常

两条设计纪律(文件头 AppError.ets:4-9):

  1. 用"稳定字符串 code"而不是裸 Error 穿越分层——UI 只认 code,文案走 userMessage。
  2. retryable 由错误的产生方判定,消费方不猜。这条把"这个错误该不该重试"的知识留在了最懂它的地方:HttpStatusMapper 知道 429/5xx 可重试,BizCodeMapper 知道 50000 可重试,NetErrors.cancelled 知道取消不可重试。RetryPolicy.retryOn 只是读这个字段。

retryable 的默认值来自 ErrorCode.isRetryableCode(AppError.ets:57-62),只认四种:NET_UNREACHABLE / NET_TIMEOUT / HTTP_429 / HTTP_5XX。注意它的注释写了"仅表达网络层语义,不代表请求可重放"——可不可重放是幂等性的事,由 RequestOptions.idempotent 决定。这两件事必须分开,否则会出现"5xx 所以重试了提交审批"的事故。

2.6.2 AppErrorKind 九类

export enum AppErrorKind {
    NETWORK = 'network',        // 连接失败、DNS 失败、无网络
    TIMEOUT = 'timeout',        // 超时
    CANCELLED = 'cancelled',    // 主动取消(页面销毁),不应提示用户
    HTTP = 'http',              // HTTP 状态码非 2xx
    BUSINESS = 'business',      // HTTP 200 但业务 code 表示失败
    PARSE = 'parse',            // 响应解析失败
    STORAGE = 'storage',        // 本地存储失败
    UNSUPPORTED = 'unsupported',// 能力不可用(设备不支持 / 权限缺失)
    UNKNOWN = 'unknown'         // 兜底
}

UI 层做分支时优先用 kind。原因:kind 的取值集合是封闭的(9 个),而 code 会随业务增长。一个只想要"网络类错误显示重试按钮"的页面,写 error.kind === AppErrorKind.NETWORK 比列出所有 NET_* code 更稳。

2.6.3 ErrorCode 前缀约定

约定:NET_ / HTTP_ / BIZ_ / PARSE_ / STORE_ / CAP_。前缀的价值在日志检索与埋点聚合:在 Kibana 里搜 HTTP_5XX 只命中一类,搜 5XX 会命中一堆无关字符串。

export class ErrorCode {
    static readonly NET_UNREACHABLE: string = 'NET_UNREACHABLE';
    static readonly NET_TIMEOUT: string = 'NET_TIMEOUT';
    static readonly NET_CANCELLED: string = 'NET_CANCELLED';
    static readonly NET_TLS: string = 'NET_TLS';
    static readonly HTTP_UNAUTHORIZED: string = 'HTTP_401';
    static readonly HTTP_FORBIDDEN: string = 'HTTP_403';
    static readonly HTTP_NOT_FOUND: string = 'HTTP_404';
    static readonly HTTP_TOO_MANY_REQUESTS: string = 'HTTP_429';
    static readonly HTTP_SERVER_ERROR: string = 'HTTP_5XX';
    static readonly BIZ_FAILED: string = 'BIZ_FAILED';
    static readonly BIZ_TOKEN_EXPIRED: string = 'BIZ_TOKEN_EXPIRED';
    static readonly BIZ_NO_PERMISSION: string = 'BIZ_NO_PERMISSION';
    static readonly BIZ_CONFLICT: string = 'BIZ_CONFLICT';
    static readonly PARSE_FAILED: string = 'PARSE_FAILED';
    static readonly STORE_FAILED: string = 'STORE_FAILED';
    static readonly CAP_UNSUPPORTED: string = 'CAP_UNSUPPORTED';
    static readonly UNKNOWN: string = 'UNKNOWN';
}

注意 HTTP_SERVER_ERROR 的值是 'HTTP_5XX' 而不是 'HTTP_500'。这是刻意的:它覆盖整个 5xx 区间,而 httpStatus 字段里保留了精确的状态码(502 / 503 / 504)。只看 code 知道类别,要看具体值读 httpStatus。

2.6.4 四个便捷成员

// AppError.ets:98-127
/** 用户取消是"正常路径",UI 不应对它弹错误 */
get isCancellation(): boolean {
    return this.kind === AppErrorKind.CANCELLED || this.code === ErrorCode.NET_CANCELLED;
}

/** 鉴权失效需要走统一的"清会话 + 跳登录"链路 */
get isUnauthorized(): boolean {
    return this.code === ErrorCode.HTTP_UNAUTHORIZED || this.code === ErrorCode.BIZ_TOKEN_EXPIRED;
}

/** 供日志与上报使用,避免各处自行拼字符串 */
toLogLine(): string {
    return `[${this.code}] http=${this.httpStatus} biz=${this.bizCode} msg=${this.message}` +
        (this.traceId.length > 0 ? ` trace=${this.traceId}` : '');
}

/** 派生一个新错误,用于在某一层补充上下文(如补 traceId) */
with(overrides: AppErrorParams): AppError { ... }

isUnauthorized 同时认 HTTP_401 与 BIZ_TOKEN_EXPIRED,这一点很关键:真实后端可能返回 HTTP 401,也可能返回 HTTP 200 + code: 40100。若只认前者,那么"业务码形式令牌过期"就不会触发单飞刷新,表现为"某些接口莫名跳登录、某些接口不跳"——一个极难排查的不一致。

toLogLine() 是唯一的错误日志格式化方式。它的存在消除了"每个地方拼出来的错误日志格式都不一样,grep 不出来"的问题。

with() 在真实工程里被用于两个地方:RetryInterceptor.ets:45-47 追加"已重试 N 次"上下文;未来需要在某一层补 traceId 时也可以用它。

2.6.5 三层归一化

              ┌─────────────────────────────────────────────┐
   第①层      │ 传输层:rcp 抛出的原生异常                    │
              │   RcpTransport.toAppError()                  │
              │   取消 → NET_CANCELLED                       │
              │   超时 → NET_TIMEOUT                         │
              │   其他 → NET_UNREACHABLE(带 cause)          │
              └────────────────────┬────────────────────────┘
                                   ▼
              ┌─────────────────────────────────────────────┐
   第②层      │ HTTP 层:状态码非 2xx                        │
              │   HttpStatusMapper.toError()                 │
              │   401 → HTTP_401(retryable=false)          │
              │   403 → HTTP_403  404 → HTTP_404             │
              │   429 → HTTP_429(retryable=true)           │
              │   5xx → HTTP_5XX(retryable=true)           │
              │   其他 → UNKNOWN(retryable=false)          │
              └────────────────────┬────────────────────────┘
                                   ▼
              ┌─────────────────────────────────────────────┐
   第③层      │ 业务层:HTTP 200 但 code != 0                │
              │   BizCodeMapper.toError()                    │
              │   40100 → BIZ_TOKEN_EXPIRED                  │
              │   40300 → BIZ_NO_PERMISSION                  │
              │   40901 → BIZ_CONFLICT                       │
              │   50000 → HTTP_SERVER_ERROR(retryable=true)│
              │   其他 → BIZ_FAILED                          │
              └────────────────────┬────────────────────────┘
                                   ▼
                      统一出口:AppError(UI 只认它)

第③层是最容易被漏掉的一层。BizCodeMapper 的文件头注释写得很直接:

很多团队会漏掉这层,导致业务失败被当成成功,最终在 UI 上表现为"提交后没反应"。

漏掉它的典型症状就是"提交后没反应"——因为 HTTP 200 被当成成功,UI 跳回列表,但服务端其实什么都没做。用户过一会儿发现单据不存在,再提交一次,得到两张单(如果幂等键也做错了的话)。

2.6.6 一个完整例子的流转

设:提交审批时后端返回 HTTP 200,体为 {"code":40901,"message":"duplicate","data":null,"traceId":"abc123"}。

步骤 结果
RcpTransport.send 返回 NetResponse(200, {...}, '{"code":40901,...}')
runAfter(逆序) Retry.after no-op → ErrorMap.after:isHttpSuccess 为真 → tryEnvelope 解析出 code=40901 → 回填 response.traceId='abc123' → BizCodeMapper 返回 AppError{code:'BIZ_CONFLICT', bizCode:'40901', traceId:'abc123', retryable:false} → 短路,Mock/Logging/Auth 的 after 不再执行
runOnError(正序) Auth:isUnauthorized 为 false → 原样返回;Logging:session.finish(false, 'BIZ_CONFLICT') + FAIL 日志(成功埋点没有打过,失败在此处唯一记账);Mock/ErrorMap:原样;Retry:noRetry() 上限为 1 → 无 retryPlan
RcpClient plan 为空 → 返回 Failure<NetResponse>(BIZ_CONFLICT)
HttpApi.request Failure → 直接透传 Failure<T>
ApprovalRepositoryImpl.commitWrite 原样透传(保留 code / bizCode / traceId)
ViewModel @Local 写入 error.userMessage = '该单据已提交,请勿重复操作'
View 显示提示

注意这个例子里的埋点视角:HTTP 200 但业务失败时,Logging.after 根本不会执行(它在逆序 after 链里排在 ErrorMap 之后,ErrorMap 已短路),不存在"先打 success=true 再打 success=false"的重复记账——失败请求由 onError 的 finish(false, code) 唯一记账。这正是 2.3.2 说的"after 逆序(洋葱)"换来的语义:埋点的 success 字段与请求的真实结局一一对应。


2.7 401 单飞刷新

2.7.1 为什么必须单飞

TokenRefresher.ets:13-19 的注释是这个问题最好的答案:

一次冷启动或一次返回前台,很可能有 5~10 个并发请求同时收到 401。若各自触发刷新:

  • 产生 N 次无谓的刷新请求;
  • 更致命的是服务端 refreshToken 是轮换的:第 1 次刷新成功后旧 refreshToken 立即失效,后 4 次拿旧值去刷必然失败 → 触发 onUnauthorized → 用户"莫名其妙被登出"。

第二点是关键。如果 refreshToken 不轮换,那么多刷几次只是浪费请求,用户无感。但轮换是安全实践的主流做法(refreshToken 一次性使用,用后作废并发新的),此时并发刷新会必然失败。

故障的用户视角:用户打开 App 看到列表在转圈,然后突然被踢到登录页。日志里有一堆 refresh failed。开发者第一反应是"刷新接口有问题",去查服务端发现刷新接口每次都返回成功——因为第一次确实成功了。

2.7.2 pending 的实现与清理时机

// TokenRefresher.ets:106-124
refreshToken(): Promise<boolean> {
    const inflight: Promise<boolean> | undefined = this.pending;
    if (inflight !== undefined) {
        XLogger.i(this.tag, () => 'refresh already in flight, joining the same promise');
        return inflight;
    }
    const started: Promise<boolean> = this.runRefresh();
    this.pending = started;
    // 清理排在"结果已交付"之后;身份校验避免误清新一轮
    started.then((okFlag: boolean) => {
        this.release(started, okFlag);
    }, (e: Error) => {
        XLogger.e(this.tag, () => `refresh promise rejected unexpectedly: ${e.message}`);
        this.release(started, false);
    });
    return started;
}

单飞的实现极简:一个 pending 字段缓存"正在跑的那一个 Promise"。所有并发调用 await 的是同一个对象,于是"等同一个结果"这个语义不需要任何锁、队列或状态机。

清理时机的坑:注释(TokenRefresher.ets:25-29)明确写了不能用 finally:

如果在 finally 里清,那么清理发生在 Promise 交付给调用方之前,极端时序下(微任务被插队)会出现"上一个刷新还没交付、下一个已经开始"的重叠刷新。因此这里用 then 把清理排在交付之后,并用 this.pending === started 做身份校验,保证不会把"新一轮的 pending"误清掉。

release 里的身份校验:

// TokenRefresher.ets:231-239
private release(promise: Promise<boolean>, success: boolean): void {
    if (this.pending === promise) {
        this.pending = undefined;
    }
    ...
}

如果不用 this.pending === promise 判断,就会出现这样的时序问题:A 轮刷新慢,B 轮刷新已经开始并把 pending 设成了 B 的 Promise;此时 A 轮完成,无脑 this.pending = undefined,于是 B 轮失去了单飞保护,C 轮的调用者会看到 pending === undefined 从而又发起一次刷新。

这个 bug 的复现概率很低(需要精确的微任务交错),但一旦出现就是"偶尔莫名被登出"。而这类问题的排查成本极高——因为它在你的机器上永远复现不了。

2.7.3 三条防线

防线 位置 触发条件 行为
① 预刷新 AuthInterceptor.before shouldRefresh() && idempotent === true 先刷新再发请求,省掉一次 401 往返
② 401 触发 AuthInterceptor.onError 收到 401 且 needAuth !== false 写 retryPlan{needRetry, delayMs:0, requireRefresh:true},由 RcpClient 刷新后重放
③ 已刷仍 401 AuthInterceptor.onError ctx.marks[MARK_REFRESHED] === true 说明 refreshToken 也失效 → 直接 onUnauthorized('refresh_retry_unauthorized'),不再重放

第三道防线是必须的,否则就会出现"刷新成功但服务端仍然 401"时的无限循环:刷新 → 重放 → 401 → 刷新 → …。这条防线上有两道闸门,缺一不可:

  1. 循环级上限 + 标记继承(RcpClient.ets:191-216)。ctx 每一轮都会重建,任何"只写在 ctx 上"的标记都活不过一次重放——这正是无界循环的根源。因此 execute 用循环级局部变量 refreshedOnce 记录"本次请求是否已刷新并重放过",并在每轮构造新 ctx 时把 AuthInterceptor.MARK_REFRESHED 继承进去;重放轮再次 401 时,AuthInterceptor.onError 见到该标记即强制登出(refresh_retry_unauthorized)。
  2. 双保险闸门(RcpClient.ets:254-260)。即便标记的继承逻辑将来被改坏,requireRefresh 分支里还有一道不依赖任何 ctx 标记的判断:refreshedOnce 已为 true 就直接 onUnauthorized 并放弃,绝不发生二次刷新。

一次请求的生命周期里"刷新并重放"最多发生一次——这是写进执行循环结构的硬约束,不是靠调用方自觉。

NetContext.marks 的类型是 Record<string, boolean>,注释写明"键为拦截器私有常量"。AuthInterceptor.MARK_REFRESHED = '__auth_refreshed__' 用双下划线包裹,是一种约定俗成的"这是内部键"标记。

为什么预刷新只对幂等请求做?因为预刷新会额外增加一次网络往返。对 GET 列表这类高频调用,如果 token 快过期就先刷一次,等于每次到临界点都会多一次请求;而且多个并发 GET 会各自判断 shouldRefresh() 都为真——不过它们会被单飞收敛成一次刷新,所以成本可控。对 POST 提交,重放语义更敏感,宁可让服务端 401 再走标准流程。

2.7.4 unauthorizedHandled 一次性闸门

// TokenRefresher.ets:43-49
/**
 * onUnauthorized 的一次性闸门。
 * 契约要求"只应触发一次":一次 401 风暴里 AuthInterceptor 与 RcpClient
 * 都会调它,若不去重会清 5 次存储、广播 5 次登出、跳 5 次登录页。
 * 刷新成功后复位,使下一段会话仍能被正常登出。
 */
private unauthorizedHandled: boolean = false;

实现在 onUnauthorized(TokenRefresher.ets:131-144):

onUnauthorized(reason: string): void {
    if (this.unauthorizedHandled) {
        XLogger.d(this.tag, () => `onUnauthorized ignored (already handled): ${reason}`);
        return;
    }
    this.unauthorizedHandled = true;
    XLogger.must(this.tag, () => `unauthorized, purging session. reason=${reason}`);
    this.purge(reason).then(() => {
        // 先清数据、再广播
    }).catch((e: Error) => {
        XLogger.e(this.tag, () => `purge session failed: ${e.message}`);
    });
}

三个细节:

  1. 用 XLogger.must 而不是 XLogger.e。must 是不受日志级别过滤的强制输出(见 03 篇)。被登出是"必须能在日志里看到"的事件。
  2. purge 是异步的,但 onUnauthorized 是同步签名。因为接口 AuthTokenProvider.onUnauthorized(reason: string): void 是同步的,注释说明"这样即使存储清理较慢,UI 的登出跳转也不必等它"。
  3. 先清数据、再广播。注释:根组件收到信号时状态已经干净,否则登录页可能先渲染出旧用户名再被清空(一闪而过)。

purge 的三步(TokenRefresher.ets:222-229):

private async purge(reason: string): Promise<void> {
    const store: TokenStore | undefined = TokenStore.get();
    if (store !== undefined) { await store.clear(); }   // 1. 清 token 存储
    SessionStore.connect().reset();                     // 2. 清会话状态
    LogoutBus.connect().broadcast(reason);              // 3. 广播登出信号
}

顺序不能换:broadcast 会让根组件(entry/pages/Index.ets 的 @Monitor('logoutBus.logoutEpoch'))跳登录页,而登录页会读 SessionStore 里的用户名。如果先广播再清,登录页就会瞬间渲染出上一个用户的昵称。

2.7.5 刷新请求自身的参数

// TokenRefresher.ets:167-178
const result: Result<TokenPair> = await this.http.post<TokenPair>(
    TokenRefresher.REFRESH_PATH,          // '/auth/refresh'
    TokenRefresher.tokenPairParser,
    { refreshToken: refresh },
    {
        needAuth: false,      // 不带(可能已过期的)访问令牌
        idempotent: true,     // 刷新是幂等的,允许网络层重试
        tag: 'auth:refresh'
    });

needAuth: false 这一点极易漏。如果刷新请求也带 Authorization 头,那么服务端在鉴权中间件里就会先因为过期令牌返回 401,刷新请求永远进不到刷新逻辑。表现为"刷新永远失败 → 永远登出"。

idempotent: true 让刷新成为"救命路径":弱网下刷新请求本身被重试,而不是一次失败就登出。

2.7.6 轮换兼容

// TokenRefresher.ets:197-202
/**
 * 轮换兼容:服务端可能不返回新的 refreshToken(未开启轮换),
 * 此时必须**保留旧的**。直接覆盖会让下一次刷新必然失败 —— 这是很常见的坑。
 */
const effectiveRefresh: string = pair.refreshToken.length > 0 ? pair.refreshToken : refresh;
const merged: TokenPair = new TokenPair(pair.accessToken, effectiveRefresh, pair.expiresIn);

同时注意 store.save(merged, store.currentUser()) 传的是 store.currentUser() 而不是空对象:

用 store.currentUser() 而不是空 UserInfo:/auth/refresh 只返回令牌,把用户信息一并覆盖会清掉昵称头像与 tenantId。

这是"部分更新"场景的通病:刷新接口只返回令牌,但保存逻辑是"整体覆盖",于是一次刷新把用户昵称清空了。解法是在调用处显式传入应保留的部分,而不是让存储层去猜。


2.8 幂等键

2.8.1 为什么由客户端生成

HttpApi.ets:187-195 的注释:

客户端生成、随请求发送、服务端据此去重。这比"服务端自己防重"更可靠:能在网络层重发与用户重复点击两种场景下都生效。

服务端自己防重的常见做法是"同一用户 5 秒内相同内容视为重复"。它有两个漏洞:一是窗口外的重试(用户 6 秒后点重试)防不住;二是"相同内容"的判定不精确(用户确实想提交两张一模一样的报销单)。

客户端生成的键则精确地对齐业务语义:同一个用户动作 = 同一个键。服务端只需按键去重,不需要猜业务。

IdempotencyKeys.next 的实现(HttpApi.ets:196-201):

export class IdempotencyKeys {
    static next(prefix: string): string {
        const rand: number = Math.floor(Math.random() * 0xFFFFFF);
        return `${prefix}-${Date.now().toString(36)}-${rand.toString(16)}`;
    }
}

时间戳 + 随机数,不带用户信息(避免键本身泄漏隐私)。prefix 用于服务端侧聚合与排查(approval / approval-withdraw / approval-approve)。

2.8.2 生成时机:点击提交时一次

ApprovalCreatePage.ets:20-27 的注释是本篇最值得抄进团队规范的一段:

✗ 错误做法:在发请求的地方生成(submit() 里每次 new 一个键) 用户点提交 → 请求超时(但服务端其实已处理成功,只是响应丢了)→ 用户点"重试" → 带一个新键 → 服务端认为这是第二次提交 → 两张审批单。也就是说"每次生成"等于没有幂等。

✓ 正确做法:在用户点击提交的那一刻生成一次,记在 this.idempotencyKey 上;失败后用户可以无限次重试,每次都复用同一个键,服务端据此去重;只有在"这次业务动作真的结束了"(提交成功、或用户改了内容重新提交)时才重置。

对应的代码(ApprovalCreatePage.ets:236-251):

if (this.idempotencyKey.length === 0) {
    this.idempotencyKey = IdempotencyKeys.next('approval');
}
// ...
const cmd: SubmitApprovalCmd = new SubmitApprovalCmd({ /* ... */, idempotencyKey: this.idempotencyKey });

注意 if (this.idempotencyKey.length === 0) 这个守卫:它同时覆盖了两种情况——首次提交(空 → 生成)与重试(非空 → 复用)。这就是"生成一次、复用多次"的全部实现。

2.8.3 保留与重置规则

事件 键的处理 位置 理由
用户首次点击提交 生成 :237 新业务动作
提交成功 重置为空 :279 动作已结束,键作废
用户改了表单内容 重置为空 :310 内容变了就是另一个业务动作
提交失败(网络/服务端错误) 保留 :318 重试必须复用,否则重复单据

第三条最反直觉,但注释解释了(ApprovalCreatePage.ets:29-30):

为什么"改了内容"要重置键:内容变了就是另一个业务动作,复用旧键会被服务端当成重复提交直接拒掉(40901),用户会卡死在"改了也提交不了"。

这是一个很典型的坑:只知道"要复用键"的开发者,会把键留在页面上直到成功为止。用户提交失败后改了标题再提交,服务端看到旧键 → 返回 40901 → 提示"该单据已提交,请勿重复操作" → 用户困惑("我明明改了内容,而且它根本没提交成功")。

2.8.4 幂等键的传输与响应

传输:RcpClient.buildHeaders 把 options.idempotencyKey 写成 Idempotency-Key 头(RcpClient.ets:316-319)。用 HTTP 头而不是请求体字段,原因是头是传输层关注点,与业务字段解耦——同一个键也能被网关、WAF 等中间层读取。

响应:服务端识别出重复后返回 code: 40901,经 BizCodeMapper 归一为 BIZ_CONFLICT,用户看到"该单据已提交,请勿重复操作"。

一个完整的幂等闭环(ApprovalRepositoryImpl.ets:254-266 的注释):

  • 网络层已由 RequestOptions.idempotent=false 禁止自动重试;
  • 这一层也不做应用层重试;
  • 用户点"重试"时复用同一个 idempotencyKey(幂等安全);
  • 服务端 40901(重复提交)会被归一为 BIZ_CONFLICT,UI 提示"该单据已提交"。 一句话:幂等键是允许重试的前提,而重试的决定权交给用户。

2.9 契约漂移的可观测性

2.9.1 三种归宿

响应解析过程中发现的"字段缺失 / 类型不符"有两种处理方式,取决于它是否影响可用性:

情况 处理 理由
载荷为空('' / 'null' / '[]' / '{}') 返回空页 / 空值,不是错误 可能是分页越界或后端确实没数据
有内容但结构不符 抛出 → HttpApi 归一为 PARSE_FAILED 契约变了
部分字段缺失但主体可用 降级填默认值 + 记录 issue,返回成功 静默数据丢失的早期信号

第三种是最需要可观测性的。ApprovalRemoteDs.pageParser 的注释(ApprovalRemoteDs.ets:195-203):

  • 空载荷:可能是分页越界或后端没数据 → 空页,不是错误;
  • 有内容但结构不符:说明契约变了 → 抛出,由 HttpApi 归一为 PARSE_FAILED。若这里也静默返回空页,表现是"用户列表忽然空了",而日志里什么都没有,是典型的"静默数据丢失"事故。

2.9.2 HttpApi.unwrap 的处理

// HttpApi.ets:111-131
private static unwrap<T>(response: NetResponse, options: RequestOptions, parser: Parser<T>): Result<T> {
    const tag: string = options.tag === undefined ? options.path : options.tag;
    const dataText: string = DefaultHttpApi.extractDataText(response.rawBody);

    const issues: string[] = [];
    let value: T;
    try {
        value = parser(dataText, issues);
    } catch (e) {
        const detail: string = (e as Error).message;
        XLogger.e('net:HttpApi', () => `parser threw for ${tag}: ${detail}`);
        return new Failure<T>(NetErrors.parse(tag, detail));
    }

    // 解析过程中记录到的问题不上报为错误,但要留痕:
    // 它意味着后端契约与客户端预期已经漂移,是"下一次线上事故"的早期信号。
    if (issues.length > 0) {
        XLogger.w('net:HttpApi', () => `parse issues (${issues.length}) for ${tag}: ${issues.join('; ')}`);
    }
    return new Success<T>(value);
}

三层设计:

  1. issues 是出参(Parser<T> = (dataText: string, issues: string[]) => T)。为什么用出参而不是返回值包装:保持 Result<T> 这一条统一契约,调用方不需要先解包再判断。
  2. parse 抛错 → PARSE_FAILED。这是"结构不符"的归宿。
  3. issues 非空 → WARN 日志,但仍返回 Success。这是"降级但可观测"的归宿。

unwrap 上游的 extractDataText(HttpApi.ets:133-159)也有两条值得知道的契约:

  • 裸数组根原样透传(:141-143):JsonReader.tryParse 只收对象根(根节点是数组/标量时返回 undefined,见 JsonReader.ets:46-68 的注释——不拦住的话,后续所有键读取都会静默 miss)。而"裸数组响应"是一种需要交给 parser 检测的契约漂移形态(Mock 服务器的 simulate=raw-array 就是演练它),必须在 extractDataText 里显式放行,不能被折叠成 'null'。
  • 无 data 段或 data 为标量时同样有明确约定:非统一包装 → 整个响应体交给 parser;标量 data → JSON.stringify 成合法 JSON 文本,保持"parser 只吃 JSON 文本"的契约。

2.9.3 数据源的 drain:把 issues 递出去

HttpApi 创建的 issues 数组是局部变量,请求返回后就没了。ApprovalRemoteDs 用 collector 出参把它复制出来(ApprovalRemoteDs.ets:242-255):

/**
 * 把 HttpApi 收集到的 issues 复制到调用方的收集器。
 * 为什么是"复制":HttpApi 每次请求都会新建 issues 数组(局部变量),
 * 不复制出去,请求返回后这些信息就只能靠日志捞,无法驱动埋点。
 */
private static drain(from: string[], to?: string[]): void {
    if (to === undefined) { return; }
    for (let i = 0; i < from.length; i++) { to.push(from[i]); }
}

drain 在三个 parser 工厂里各调一次(:209 / :223 / :234)。注释也诚实指出了这个设计的缺点:"出参容易被遗忘。所以只有 repo 一个调用方,且它每次都传。"——这是"为了不打破 Result<T> 统一契约"付出的代价,工程师接受了它。

2.9.4 仓库层上报

// ApprovalRepositoryImpl.ets:26-32
/**
 * 契约漂移事件名。
 * 说明:lib_log 的 EventNames 是**跨模块共享契约**,新增事件应先在那里注册。
 * 该事件尚在评审中,这里先用模块内常量落地,避免为了一个实验性指标
 * 去改动公共契约(改公共契约会让所有模块重新走一遍评审)。
 */
private static readonly EVENT_CONTRACT_DRIFT: string = 'contract_drift';

reportDrift 的实现(ApprovalRepositoryImpl.ets:400-412):

private reportDrift(tag: string, issues: string[]): void {
    if (issues.length === 0) { return; }
    XLogger.w(ApprovalRepositoryImpl.LOG_TAG,
        () => `contract drift on ${tag} (${issues.length}): ${issues.join(' | ')}`);
    Analytics.emit(EventBuilder.of(ApprovalRepositoryImpl.EVENT_CONTRACT_DRIFT)
        .str(EventProps.ROUTE, tag)
        .num(EventProps.COUNT, issues.length)
        .str(ApprovalRepositoryImpl.PROP_SAMPLE, issues[0])
        .result(false, 'PARSE_DRIFT')
        .build());
}

埋点的三个属性(route / count / sample)对应三种看板用法:

  • 按 route 聚合 → "哪个接口开始漂移了";
  • count 的分布 → "漂移严重程度";
  • sample → "具体是哪个字段少了"(样本字符串会经 Analytics 的 Masker 脱敏)。

reportDrift 被调用的位置(:107 / :138 / :182 / :203 / :272 / :303)覆盖了全部六个接口。注意它在 commitWrite 里也调了——写操作的响应同样可能字段缺失,而且写操作的静默降级更危险(用户以为提交成功,但拿到的 Approval 里没有 id)。

2.9.5 为什么不上报为错误

代码注释给了完整的答案(ApprovalRepositoryImpl.ets:388-399):解析问题不是错误(HttpApi 已经把降级做完了,用户看到的是可用数据),但它是"下一次线上事故的早期信号":

后端改了字段名/类型,客户端静默降级成默认值,用户看到的是"金额都是 0"、"状态都是待审批"。等到有人投诉时,已经过去很久了。所以这里把它变成可聚合的指标。

这句话值得单独记住,它是这一节的全部价值:可观测性的目标不是"记录错误",而是"让还没变成投诉的问题可见"。


2.10 常见误区

1. 把 token 写进 SessionConfiguration.headers

见 2.2.2。症状是"刷新成功但请求仍带旧 token → 无限 401 → 被登出",且 TokenStore 里的值是正确的,极易误判为服务端问题。

2. 在拦截器里自己写 while 重试

见 2.4.3。三重后果:取消语义要重实现、日志重复、onError 的签名被破坏。

3. 用 HTTP 方法推断幂等性

POST 不一定非幂等(/auth/refresh 就是幂等的),PUT 也不一定幂等(取决于服务端实现)。RequestOptions.idempotent 的注释写得很清楚:幂等性由业务语义决定,不由 HTTP 方法决定。

4. 在"发请求的地方"生成幂等键

见 2.8.2。等于没有幂等。这是本篇最容易在 code review 里被抓到的错误,因为它的代码形态很自然(submit() 里 const key = ...)。

5. RetryPolicy.retryOn 里判断"是否幂等"

常见写法:retryOn: (e) => e.retryable && isIdempotentPath(...)。这是错的——幂等性判断已经在 RetryInterceptor.onError 的第 2 步做过了,而且 retryOn 拿不到 RequestOptions。正确职责划分:retryOn 只回答"这个错误本身可恢复吗"。

6. pending 用 finally 清理

见 2.7.2。会退化成重叠刷新,且概率极低、极难复现。

7. 只处理 HTTP 401,不处理 code: 40100

isUnauthorized 同时认两者。只认前者会表现为"某些接口跳登录、某些接口不跳"——取决于后端用哪种形式返回令牌过期。

8. 在 onFailure 里直接弹 Toast

取消类错误(用户返回上一页触发的 cancelToken.cancel())会走进 onFailure。不看 error.isCancellation 就直接弹提示,症状是"用户每返回一次就闪一个错误提示"。

9. 跳过 ErrorMap,让业务自己判断 statusCode

后果是"有的地方 401 弹登录、有的地方 401 弹通用错误"(ErrorMapInterceptor.ets:10-12 的注释)。这是"错误处理分散"的经典症状:每个调用方都以为自己在做正确的事。

10. 把 issues 当错误处理

issues 非空仍然返回 Success。如果调用方看到 issues 非空就返回失败,会把"一个可选字段缺失"升级成"列表加载失败"——比静默降级更糟。

11. 让 MockInterceptor 直接 return new Success(...)

before 的返回类型是 AppError | undefined,它不能返回成功结果。Mock 必须把响应写进 ctx.mockedResponse,否则会绕过 after 阶段——错误映射与日志全部失效,Mock 与真实的链路出现分歧(见 2.3.5)。


2.11 实践任务

任务 1:注入一个可复现的失败,验证重试与告警

  1. 打开 code/office-flow,在 entry 的启动流程里找到 AppInitializer.initMock();
  2. 添加 MockRegistry.setFailureRate(0.5)(50% 请求返回 503);
  3. 运行 entry,反复进入审批列表,观察日志中的:
    • HIT ... status=503(Mock 命中)
    • injected failure for ...(注入日志)
    • retry approval.list attempt=2 after XXXms(重试日志,注意 delay 的抖动)
    • SLOW REQUEST(若退避叠加到 3s 以上)
    • FAIL ... [HTTP_5XX](重试耗尽后的最终失败)
  4. 观察 Trace.count('net:retry', 1) 是否在 hiTraceMeter 里出现。

验收

  • [ ] 能观察到至少一次 attempt=2 的重试日志
  • [ ] 两次重试的 delay 值不相同(抖动生效)
  • [ ] 关闭 Mock(MockRegistry.enable(false))后,同样操作不再出现 MOCKED 标记

交付物:一段不超过 30 行的日志摘录(含时间戳与 traceId),标注出上述 5 类行。

任务 2:为新接口写一个完整的声明

为"批量审批"设计一个 POST /approval/batch-decide,要求:

  1. 正确声明 idempotent / retry,并说明理由;
  2. 提供一个具名 parser,明确"空载荷"与"结构不符"两种情况的处理;
  3. 决定要不要 idempotencyKey;
  4. 写出该接口在 BizCodeMapper 里需要新增(或不必新增)的业务码映射。

验收

  • [ ] idempotent: false 且 retry: noRetry(),理由写明"写操作、重复审批有外部后果"
  • [ ] 载荷为空返回空数组(不是错误);结构不符 throw
  • [ ] 有 idempotencyKey,且生成时机写在注释里(用户点击"批量通过"时)
  • [ ] 说明服务端返回 40901 时的用户文案

交付物:一段 ApprovalRemoteDs 风格的方法实现(20~40 行,含注释)。

任务 3:复现"token 写进 Session"的事故

  1. 在 RcpTransport.ensureSession 里临时加上 headers: { 'Authorization': 'Bearer FIXED_TOKEN' };
  2. 登录后手动把 TokenStore 里的 token 改掉(或用 AuthMockRules 的变体开关触发刷新);
  3. 观察请求头是否仍带旧值;
  4. 恢复代码,改用 AuthInterceptor 注入,确认请求头跟着变。

验收

  • [ ] 能复现出"存储里是新 token、网络上跑的是旧 token"
  • [ ] 能解释为什么 LoggingInterceptor 打印的头是"最终发出的头"(它在链的第 2 位,Auth 之后)
  • [ ] 恢复后 Authorization 每次都等于 TokenStore.accessToken()

交付物:两次请求的日志对照(改前 / 改后),各不超过 10 行。


2.12 对表记录:发现的实现缺陷

编写本篇时逐行对照过真实工程,早期版本曾记录三处代码层面的不一致。三处均已修复,留作"文档对表 → 工程收敛"的记录:

# 位置 当时的问题 现状
1 lib_network/src/main/ets/rcp/NetInterceptor.ets:2 一行自引用导入(三个符号全部定义在本文件内,'../NetInterceptor' 指向不存在的路径),依赖编译器对"未解析导入 + 同名本地定义"的宽容 已删除。文件头只保留 lib_base 与 ../model/HttpTypes 的合法导入
2 lib_network/src/main/ets/rcp/RcpClient.ets:20 文件头注释写作"只需改 RcpTransport 的 3 个方法(createSession / send / mapError)",与实际方法名不符 已改为 ensureSession / send / toAppError,与类中实际签名一致(见 2.1.3)
3 lib_network/src/main/ets/model/HttpTypes.ets:7 注释提到的 RcpInterceptorBridge 在工程中不存在,与"RcpTransport 是唯一适配点"的表述冲突 已统一为 RcpTransport(lib_network/src/main/ets/rcp/RcpClient.ets)

另外,本篇早期版本描述的部分行为也随工程演进而更新:NetResponse 从未使用过的 elapsedMs 字段已删除(耗时统一走 NetContext.elapsedMs);NetInterceptor 的死导出 NET_TRACE_ENABLED、HttpApi 的死导出 parseFailure / isCancelled、RetryInterceptor 的死常量 NEED_RETRY / NEXT_DELAY_MS 均已清理;runAfter 改为逆序(洋葱模型,见 2.3.2);401 刷新-重放加上循环级 refreshedOnce 上限与双保险闸门(见 2.7.3);RetryInterceptor 达上限时显式清空 retryPlan(见 2.3.7)。正文均已按当前代码改写。


2.13 延伸阅读

  • 官方文档 → 网络管理 → Remote Communication Kit(rcp)→ 会话管理与请求配置 —— 理解 createSession / fetch 的官方语义,对照本篇 2.1.3 的五个方法
  • 官方文档 → 网络管理 → Remote Communication Kit(rcp)→ 拦截器与重试策略 —— 对照本篇 2.3 / 2.4,看清哪些是本工程自建、哪些是 SDK 提供
  • 官方文档 → 应用框架 → ArkTS → 类型系统与 instanceof 收窄 —— 理解 Result<T> 为什么必须配 (result as Success<T>) 的 cast
  • 官方文档 → 应用框架 → ArkTS → 模块化与循环依赖 —— 理解 2.3.1 里 LoggingSession 接口为什么要单独放在 NetInterceptor.ets
  • 教程 docs/06-persistence-network.md 6.3~6.6 节 —— 概念模型部分
  • 教程 docs/11/01-architecture-modules.md 1.6 节 —— AuthTokenProvider 与前一篇的"跨模块解耦三手法"呼应
  • 教程 docs/11/03-logging-tracking.md —— Masker 脱敏、Trace 打点、Analytics.network 的完整实现
  • 教程 docs/11/04-login-auth.md —— TokenRefresher 与 TokenStore 的完整登录链路
  • 教程附录 D —— 本地 Mock 方案的 MockRegistry 规则写法与 server/mock-server.js

导航

上一章:11/01 架构与模块划分 | 下一章:11/03 日志与埋点

📄 第 3 章 ArkUI 组件与布局 03-arkui-layout.md

「容器该选谁」「同一段 UI 怎么复用」「一千条数据怎么不卡」是 ArkUI 日常开发中最费时间的三个问题。这一章不给 API 字典,只给选型依据、观察边界与可量化的性能约束,最终落成一个千条数据的审批列表页。


3.0 本章目标

  1. 能解释声明式三要素(UI = f(state) / build 幂等 / 无副作用),并指出 build() 里哪些写法会被编译期拦下、哪些不会但同样有害。
  2. 能依据「滚动方向 / 是否虚拟滚动 / 是否按宽度变列数」在 11 种布局容器中做出选型,并能解释为什么;能用 GridRow + GridCol 写出小屏 1 列 / 手机 2 列 / 大屏 3 列的响应式栅格,并用 8 档断点预览验收。
  3. 能对比 @Builder / @BuilderParam / @Styles / @Extend / AttributeModifier 五者的复用边界,并针对场景选对工具。
  4. 能独立写出完整的 IDataSource 实现,说清 listeners 注册与增删改通知为什么要成对调用,并把 keyGenerator 写成业务 id。
  5. 能把颜色、文案、尺寸全部收敛到 $r() 资源,补齐 dark 与 en_US 限定词目录,并能量化浅色 / 深色下的表现一致性。

3.1 声明式三要素

3.1.1 UI = f(state)

界面是状态的函数。你只声明「状态长这样时界面长这样」,不描述「先删哪个节点、再插哪个节点」。因此:

你写的 框架负责的
状态变量 + build 里的组件树 差异计算、节点增删、属性更新
状态与界面的映射关系 什么时候重渲染、局部更新哪棵子树

推论:只要状态正确,界面一定正确。反过来,界面不对时先查状态,而不是去查布局代码。

3.1.2 build 的两类问题

build() 有硬性禁止项(编译期拦下)和软性问题(编译期放行但同样有害)。这两类要分开记,否则会误以为「能编译就没问题」。

类别 具体写法 是否编译期报错
硬性 build() 内声明本地变量(let / const) 是
硬性 build() 内创建本地作用域(如包装一层函数体) 是
硬性 调用非 @Builder 装饰的成员方法 是
硬性 使用 console 打印 是
硬性 switch 语句(可用 if 替代) 是
硬性 return 语句 是
软性 在属性参数位置写耗时表达式(如 .width(this.computeWidth())) 否
软性 在属性参数位置 new 大对象、写正则、做 JSON.parse 否
软性 在属性参数位置读写 AppStorage、打日志 否

软性才是真正的耗损点:属性参数位置的表达式每次重渲染都会求值,而它不会报错、不会被 Code Linter 提示。

3.1.3 正确分工:派生算 @Computed,副作用进生命周期

注:本节示例省略导入(hilog 来自 @kit.PerformanceAnalysisKit,IMonitor 由 ArkUI 内置);ApprovalMock 见第 2 章。示例中的字面量尺寸(如 width(48))均为示例从简,工程实现一律走 $r('app.float.*'),见 3.5。

@Entry
@ComponentV2
struct ApprovalListPage {
    @Local rows: Approval[] = [];
    @Local keyword: string = '';
    @Local loading: boolean = true;

    // 派生数据:只在依赖(rows / keyword)变化时重算,build 里只读不写
    @Computed
    get visibleRows(): Approval[] {
        if (this.keyword.length === 0) {
            return this.rows;
        }
        return this.rows.filter((r: Approval): boolean => r.title.includes(this.keyword));
    }

    @Computed
    get actionableCount(): number {
        return this.rows.filter((r: Approval): boolean => r.isActionable).length;
    }

    // 副作用:放在生命周期,而不是 build
    aboutToAppear(): void {
        this.loadRows();
    }

    // 响应式副作用:只在 keyword 变化时执行
    @Monitor('keyword')
    onKeywordChange(monitor: IMonitor): void {
        hilog.info(0x0000, 'Page', 'keyword -> %{public}s', this.keyword);
    }

    private async loadRows(): Promise<void> {
        this.loading = true;
        // 数据获取见第 6 章
        this.rows = ApprovalMock.list();
        this.loading = false;
    }

    build() {
        Column({ space: 12 }) {
            // 只读 @Computed,不在参数位置做计算
            Text($r('app.string.pending_count', this.actionableCount))
                .fontSize($r('app.float.body_fs'))

            if (this.loading) {
                LoadingProgress().width(48).height(48)
            } else if (this.visibleRows.length === 0) {
                Text($r('app.string.empty_state'))
                    .fontColor($r('app.color.text_secondary'))
            } else {
                List({ space: 8 }) {
                    ForEach(this.visibleRows, (row: Approval) => {
                        ListItem() {
                            ApprovalCard({ row: row })
                        }
                    }, (row: Approval): string => row.id)
                }
            }
        }
        .padding($r('app.float.page_padding'))
    }
}

术语:幂等——同样的状态连续执行两次 build(),得到的组件树完全一致、且不产生额外副作用。幂等是框架能安全地重复执行 build()(重排、断点切换、热重载)的前提。

版本门槛:本教程基线为 API 26;@Computed / @Monitor 属状态管理 V2,自 API 12 起可用,兼容下限 20 设备不受影响。@Monitor 的回调参数类型是 ArkUI 内置的 IMonitor,无需导入。


3.2 布局容器选型决策树

3.2.1 先问三个问题

① 内容是「一维排列」还是「二维排列」?
② 数据量大到需要虚拟滚动(屏幕外不创建节点)吗?
③ 需要按窗口宽度改变每行数量吗?

3.2.2 决策树

内容需要滚动吗?
├─ 需要
│   ├─ 二维规则网格、要虚拟滚动 ─────────────→ Grid
│   ├─ 不等高多列(瀑布流)────────────────→ WaterFlow
│   ├─ 一维长列表(含吸顶、分组、侧滑)──────→ List
│   ├─ 左右滑动翻页 ───────────────────────→ Swiper
│   └─ 单块长内容(表单、富文本、长页面)────→ Scroll
└─ 不需要
    ├─ 需要按窗口宽度切换每行列数 ──────────→ GridRow + GridCol
    ├─ 复杂对齐、想减少嵌套层数 ────────────→ RelativeContainer
    ├─ 需要层叠(角标、遮罩、浮动按钮)─────→ Stack
    ├─ 需要换行 / 比例 / 反向排列 ──────────→ Flex
    └─ 其余(一维一行或一列)──────────────→ Row / Column

3.2.3 「什么场景选谁」对照表

容器 选它的场景 不要用它做 关键属性
Row / Column 子项数量固定且少的一维排列 承载几百条数据 space、justifyContent、alignItems
Stack 角标、遮罩、浮动操作按钮 逐项对齐(对齐关系复杂时用 RelativeContainer) alignContent
Flex 需要换行 wrap、比例 flexGrow、反向排列 替代简单的 Row/Column(测量开销更大) wrap、justifyContent、alignItems
RelativeContainer 复杂锚点对齐、把 3~4 层嵌套拉平成 1 层 需要虚拟滚动的列表 alignRules
Grid 二维规则网格 + 虚拟滚动(如相册、看板) 不等高的多列内容 columnsTemplate、rowsGap
GridRow / GridCol 多端共用的响应式栅格,按断点变列数 长列表(无虚拟滚动) columns、span、breakpoints
List 一维长列表:聊天、审批流、设置项 二维网格布局 cachedCount、sticky、divider
WaterFlow 不等高卡片流(如内容社区) 等高的规则网格(用 Grid 更省) columnsTemplate、itemConstraintSize
Swiper 轮播、引导页、卡片翻页 承载长列表(数据量大时改为 List) loop、autoPlay、indicator
Scroll 内容超出屏幕的单块区域 内部再套同向滚动容器 scrollBar、edgeEffect

3.2.4 三条通用约束

  1. 嵌套层数控制在 4 层以内。每多一层,测量与布局各多一轮;超过后优先用 RelativeContainer 拉平。
  2. 滚动容器不嵌套同向滚动。Scroll 内再套 List(同为纵向)会导致滚动手势归属不确定、出现白屏或滚动中断。纵向列表要加的头部内容,用 ListItemGroup 的 header 能力承载。
  3. 数据量超过一两百条就必须虚拟滚动。Row / Column / Flex 里的 ForEach 会全量创建节点,1000 条就是 1000 棵子树。

版本门槛:以上容器与属性在兼容下限 API 20 上均可用。GridRow 的断点能力属布局层特性,与 SDK 版本无关——真正随版本变化的是预览器能一次渲染几档(见 1.6.1)。


3.3 组件复用四件套对比

3.3.1 对比总表

维度 @Builder @BuilderParam @Styles / @Extend AttributeModifier
复用的东西 UI 片段 容器的 UI 插槽 属性集合 属性集合(带状态与逻辑)
支持参数 支持(按值 / 按引用) 由父组件注入 @Styles 不支持;@Extend 支持 支持,可随状态变化
跨文件复用 全局 Builder 可共享;跨模块复用建议改用自定义组件 本身就是跨组件传递 @Extend 仅限当前文件 类可导出,跨模块可复用
响应状态变化 按引用传参才响应 是 是(编译期展开,随状态重算) 是(对象需可观察)
典型场景 局部重复的 UI 结构 对话框 / 卡片容器的内容缺口 简单的样式收敛 组件库中的动态属性策略
主要陷阱 基本类型按值传参不刷新 父组件未传时无内容 @Styles 只能写通用属性 每次应用会全量重算属性

术语:按值传递 vs 按引用传递——调用 @Builder 时传入单个基本类型是按值(内部拿的是快照,不刷新);传入对象字面量是按引用(内部拿到的是同一引用,变化会刷新)。这是 @Builder 最容易被忽略的行为差异。

3.3.2 @Builder:轻量 UI 片段

// 全局 @Builder:写在共享的 .ets 文件里,供同模块内复用
@Builder
function sectionTitle(text: ResourceStr) {
    Row({ space: 8 }) {
        Divider().vertical(true).height(16).color($r('app.color.brand'))
        Text(text)
            .fontSize($r('app.float.title_fs'))
            .fontColor($r('app.color.text_primary'))
    }
    .width('100%')
}

@ComponentV2
struct StatusChip {
    // 组件内 @Builder:可以访问 this 上的状态
    @Builder
    chip(label: ResourceStr, tone: ResourceColor) {
        Text(label)
            .fontSize($r('app.float.caption_fs'))
            .fontColor(tone)
            .padding({ left: 8, right: 8, top: 2, bottom: 2 })
            .backgroundColor($r('app.color.chip_bg'))
            .borderRadius(4)
    }

    build() {
        Column({ space: 8 }) {
            sectionTitle($r('app.string.approval_detail'))
            this.chip($r('app.string.status_pending'), $r('app.color.warning'))
        }
    }
}

参数传递规则对照:

调用写法 传递方式 内部状态变化时是否刷新
this.chip(label, color) 传基本类型 按值 不刷新
this.card({ title: this.title }) 传对象字面量 按引用 刷新
传入变量(含 this 上的对象成员) 按值(快照) 不保证刷新——需要刷新必须包成对象字面量

3.3.3 @BuilderParam:给容器留 UI 缺口

@ComponentV2
export struct DialogShell {
    @Param title: ResourceStr = '';
    @Event onConfirm: () => void = (): void => {};

    // 插槽:父组件不传时用默认内容,避免出现空容器
    @BuilderParam body: () => void = this.defaultBody;

    @Builder
    defaultBody() {
        Text($r('app.string.empty_content'))
            .fontColor($r('app.color.text_secondary'))
    }

    build() {
        Column({ space: 16 }) {
            Text(this.title).fontSize($r('app.float.title_fs'))
            this.body()
            Button($r('app.string.confirm'))
                .onClick(() => this.onConfirm())
        }
        .padding($r('app.float.card_padding'))
        .backgroundColor($r('app.color.card_bg'))
        .borderRadius(12)
    }
}

父组件用尾随闭包注入内容:

DialogShell({
    title: $r('app.string.approve_title'),
    onConfirm: (): void => { this.submit(); }
}) {
    // 这段 UI 会赋给 @BuilderParam body
    Text(this.draft.remark)
        .fontSize($r('app.float.body_fs'))
}

3.3.4 @Styles 与 @Extend:样式收敛

// @Styles:只收敛「通用属性」,不能带参数
@Styles
function cardBox() {
    .width('100%')
    .padding($r('app.float.card_padding'))
    .backgroundColor($r('app.color.card_bg'))
    .borderRadius(12)
}

// @Extend:扩展「特定内置组件」,可带参数,但仅限当前文件
@Extend(Text)
function titleText(size: number, color: ResourceColor) {
    .fontSize(size)
    .fontColor(color)
    .fontWeight(FontWeight.Medium)
    .maxLines(1)
    .textOverflow({ overflow: TextOverflow.Ellipsis })
}

@ComponentV2
struct DemoCell {
    build() {
        Column() {
            Text($r('app.string.row_title')).titleText(18, $r('app.color.text_primary'))
        }
        .cardBox()
    }
}

两者的边界必须记牢:

工具 能写什么 不能写什么
@Styles 通用属性:width / padding / backgroundColor / borderRadius 组件私有属性(如 fontSize);参数
@Extend(Text) 该内置组件的全部属性 自定义组件;跨文件复用

3.3.5 AttributeModifier:类型安全的属性注入

// lib_ui/src/modifier/CardModifier.ets
export class CardModifier implements AttributeModifier<ColumnAttribute> {
    private elevated: boolean = false;
    private compact: boolean = false;

    setElevated(v: boolean): CardModifier {
        this.elevated = v;
        return this;
    }

    setCompact(v: boolean): CardModifier {
        this.compact = v;
        return this;
    }

    applyNormalAttribute(instance: ColumnAttribute): void {
        instance.width('100%');
        instance.borderRadius(12);
        instance.padding(this.compact ? 8 : 16);
        instance.backgroundColor($r('app.color.card_bg'));
        if (this.elevated) {
            instance.shadow({ radius: 12, color: $r('app.color.shadow') });
        }
    }
}
// 使用:同一个 modifier 实例可以被多个组件复用
const cardModifier: CardModifier = new CardModifier().setElevated(true).setCompact(false);

Column() { /* ... */ }
    .attributeModifier(cardModifier)

AttributeModifier 的独特价值在于类型安全 + 可组合 + 可跨模块导出:applyNormalAttribute 的形参类型是 ColumnAttribute,属性名写错会编译失败,而字符串式的动态属性方案做不到这一点。

版本门槛:AttributeModifier 与 .attributeModifier() 在 API 20 上可用。当 modifier 对象内部状态需要驱动界面更新时,对象本身要可观察(用 @ObservedV2 + @Trace 标注字段,见第 5 章);否则改了 elevated 不会触发重算。

选型口诀:局部重复结构 → @Builder;容器留缺口 → @BuilderParam;只收敛样式 → @Styles / @Extend;要类型安全、要跨模块、要带状态 → AttributeModifier。


3.4 长列表:LazyForEach + IDataSource + cachedCount

3.4.1 为什么必须用 LazyForEach

方式 节点创建 1000 条数据的后果
ForEach 全量创建 首屏创建 1000 棵子树,卡顿明显、内存高
LazyForEach 只创建可视区 + 预加载区 常驻约「可视条数 + cachedCount」棵子树

LazyForEach 需要一个数据源:实现 IDataSource 接口的对象。它不是简单的数组,而是「数据容器 + 变更通知通道」。

3.4.2 完整 IDataSource 实现

// entry/src/main/ets/viewmodel/BasicDataSource.ets

/**
 * LazyForEach 的数据源。
 * 契约:数据变更必须「先改数据、再通知」,两者成对出现,缺一不可。
 */
export class BasicDataSource<T> implements IDataSource {
    private listeners: DataChangeListener[] = [];
    private items: T[] = [];

    // ---- IDataSource 必须实现的四个方法 ----

    totalCount(): number {
        return this.items.length;
    }

    getData(index: number): T {
        return this.items[index];
    }

    registerDataChangeListener(listener: DataChangeListener): void {
        if (this.listeners.indexOf(listener) < 0) {
            this.listeners.push(listener);
        }
    }

    unregisterDataChangeListener(listener: DataChangeListener): void {
        const pos: number = this.listeners.indexOf(listener);
        if (pos >= 0) {
            this.listeners.splice(pos, 1);
        }
    }

    // ---- 内部:通知全部监听者 ----

    private notify(handler: (l: DataChangeListener) => void): void {
        this.listeners.forEach((l: DataChangeListener): void => handler(l));
    }

    // ---- 数据变更:每一对「改数据 + 通知」都必须成对出现 ----

    /** 整体替换:无法精确描述差异时使用,代价最高(整段重建) */
    reload(next: T[]): void {
        this.items = next;
        this.notify((l: DataChangeListener): void => l.onDataReloaded());
    }

    /** 追加一项:通知新增的下标 */
    append(item: T): void {
        this.items.push(item);
        const index: number = this.items.length - 1;
        this.notify((l: DataChangeListener): void => l.onDataAdd(index));
    }

    /** 头部插入(如收到新消息) */
    prepend(item: T): void {
        this.items.unshift(item);
        this.notify((l: DataChangeListener): void => l.onDataAdd(0));
    }

    /** 原位更新一项:数据与通知的下标必须一致 */
    update(index: number, item: T): void {
        this.items[index] = item;
        this.notify((l: DataChangeListener): void => l.onDataChange(index));
    }

    /** 删除一项:先 splice 再通知删除后的下标 */
    removeAt(index: number): void {
        this.items.splice(index, 1);
        this.notify((l: DataChangeListener): void => l.onDataDelete(index));
    }

    /** 移动一项:先真正移动,再通知 from → to */
    move(from: number, to: number): void {
        const moved: T[] = this.items.splice(from, 1);
        this.items.splice(to, 0, moved[0]);
        this.notify((l: DataChangeListener): void => l.onDataMove(from, to));
    }

    /** 批量变更:一次通知多个操作,适合「接口返回新列表后做差异更新」。
     *  注意:本方法只负责通知,不修改数据——调用方必须先把 items 修改到位
     *  (与 ops 描述一致),再调用本方法;否则框架按旧的 getData 结果重建,出现错位。 */
    applyBatch(ops: DataOperation[]): void {
        this.notify((l: DataChangeListener): void => l.onDatasetChange(ops));
    }

    /** 只读快照,供业务侧做统计 */
    snapshot(): T[] {
        return this.items.slice();
    }
}

为什么必须成对调用(最常见的三类 bug):

写法 后果
改了数据、没通知 界面不更新(数据是新的、界面是旧的)
通知了、没改数据 界面按错误的 getData 结果重建,出现错位或重复项
改了下标 5、通知了下标 3 只有下标 3 的节点被刷新,5 的节点显示旧数据

一个容易忽略的细节:registerDataChangeListener 必须支持被重复调用而不重复添加(indexOf < 0 判断),否则列表滚动时会收到多份通知,出现重复渲染。

3.4.3 页面侧:LazyForEach + cachedCount

注:BasicDataSource 见 3.4.2,ApprovalCard 见 3.4.6,此处省略导入。

@Entry
@ComponentV2
struct ApprovalListPage {
    // 数据源本身不需要装饰器:刷新走 listeners 通道,而不是走状态变量
    private ds: BasicDataSource<Approval> = new BasicDataSource<Approval>();
    @Local loading: boolean = true;

    aboutToAppear(): void {
        this.ds.reload(ApprovalMock.list());
        this.loading = false;
    }

    build() {
        Column() {
            if (this.loading) {
                LoadingProgress().width(48).height(48)
            } else {
                List({ space: 8 }) {
                    LazyForEach(this.ds, (row: Approval) => {
                        ListItem() {
                            ApprovalCard({ row: row })
                        }
                    }, (row: Approval): string => row.id)   // keyGenerator 用业务 id
                }
                .cachedCount(3)          // 预加载可视区外 3 条
                .divider({ strokeWidth: 1, color: $r('app.color.divider') })
                .width('100%')
                .height('100%')
                .padding({ left: 16, right: 16 })
            }
        }
    }
}

3.4.4 keyGenerator:必须用业务 id

写法 结果
(row) => row.id 正确:增删后节点与内部状态被正确复用
(row) => index.toString() 错误:任意插入/删除都会让后续所有 key 变化,整段重建且状态错位
不传 keyGenerator 框架按位置生成 key,等价于用 index

用 index 作 key 的典型事故:列表第 3 项被展开,删除第 1 项后,第 3 项「看起来」被展开了——因为展开状态跟随的是位置 3 的节点,而位置上现在是原来的第 4 条数据。

3.4.5 cachedCount:预加载多少条

取值 效果
0 可视区外不创建节点,快速滚动时可能白屏
1 ~ 3 推荐区间:滚动平滑,内存可控
远大于可视条数(如 20) 首屏变慢、内存上升,收益递减

cachedCount 只对 LazyForEach 生效;ForEach 是全量创建,该属性对它没有意义。同理,它需要挂在滚动容器(List / Grid / Swiper / WaterFlow)上,而不是挂在 LazyForEach 上。

版本门槛:部分版本提供 cachedCount(count, isShown) 双参重载,用于让缓存项参与预布局。兼容下限不确定时不要使用双参重载,单参版本在所有目标版本上行为一致。

3.4.6 组件复用:@Reusable

LazyForEach 让「屏幕外的节点不创建」,@Reusable 让「滑出屏幕的节点不被销毁、而是被复用」。两者配合才能把滚动开销压到最低。

@Reusable
@ComponentV2
export struct ApprovalCard {
    @Require @Param row: Approval;
    @Local expanded: boolean = false;

    // 复用钩子:只需重置「非 props 驱动」的残留状态
    aboutToReuse(): void {
        this.expanded = false;
    }

    build() {
        Column({ space: 8 }) {
            Text(this.row.title)
                .fontSize($r('app.float.body_fs'))
                .fontColor($r('app.color.text_primary'))
            Text(this.row.amountText)
                .fontSize($r('app.float.caption_fs'))
                .fontColor(this.row.isActionable
                    ? $r('app.color.warning')
                    : $r('app.color.text_secondary'))
        }
        .padding($r('app.float.card_padding'))
        .backgroundColor($r('app.color.card_bg'))
        .borderRadius(12)
        .onClick(() => { this.expanded = !this.expanded; })
    }
}

复用的正确心态:组件实例会被交给另一条数据。所以 aboutToReuse 里只清理那些不由 @Param 驱动的状态;凡是能从 @Param 推导的,都不要另存一份。

术语:复用池——框架维护的、滑出缓存区后未被销毁而被回收暂存的节点缓存。@Reusable 的组件在滑出缓存区后进入复用池,再次需要时直接取出并更新 @Param,省下创建与销毁两笔开销。


3.5 资源与主题

3.5.1 $r() 的五种类型

资源类型 目录 引用写法 适用
string element/string.json $r('app.string.xxx') 文案;模板用 $r('app.string.xxx', 3)
color element/color.json $r('app.color.xxx') 前景色、背景色、分割线
float element/float.json(值写 "16vp") $r('app.float.xxx') 间距、圆角、字号
media media/*.png $r('app.media.xxx') 图片、图标
plural element/plural.json $r('app.plural.xxx', 3) 单复数(英文必需)

配置清单类资源走 $profile: 形式,例如 "pages": "$profile:main_pages"。

3.5.2 限定词目录与回落规则

目录 命中条件 放什么
base/ 兜底,永远命中 完整的一套资源
zh_CN/ 中文语言 中文文案覆盖
en_US/ 英文语言 英文文案覆盖
dark/ 深色模式 深色配色覆盖
xxhdpi/ 高密度屏幕 高分辨率图片
horizontal/ 横屏 横屏专属图片 / 布局资源

回落是按键逐个进行的:设备是「中文 + 深色」时,app.string.title 走 zh_CN、app.color.card_bg 走 dark,其它键回落到 base。所以 base 必须是完整的一套,限定词目录只放差异项。

3.5.3 深浅色适配

// entry/src/main/resources/base/element/color.json
{
  "color": [
    { "name": "page_bg", "value": "#F1F3F5" },
    { "name": "card_bg", "value": "#FFFFFF" },
    { "name": "text_primary", "value": "#182431" },
    { "name": "text_secondary", "value": "#666666" },
    { "name": "divider", "value": "#E5E5E5" }
  ]
}
// entry/src/main/resources/dark/element/color.json
// 注意:key 集合必须与 base 完全一致,只换值
{
  "color": [
    { "name": "page_bg", "value": "#000000" },
    { "name": "card_bg", "value": "#1A1A1A" },
    { "name": "text_primary", "value": "#E5E5E5" },
    { "name": "text_secondary", "value": "#999999" },
    { "name": "divider", "value": "#2A2A2A" }
  ]
}

代码侧零改动:$r('app.color.card_bg') 会随系统深浅色自动切换。这是「不硬编码色值」最直接的收益。

误区:在代码里判断模式再手动换色,例如写 const bg: ResourceColor = isDark ? '#1A1A1A' : '#FFFFFF'。这会导致三件事:新增限定词目录时必须回来改代码;同一语义色在多处重复定义、迟早不一致;系统切换深浅色时若判断值更新不及时,界面出现半深半浅。

3.5.4 尺寸与字号

// entry/src/main/resources/base/element/float.json
{
  "float": [
    { "name": "page_padding", "value": "16vp" },
    { "name": "card_padding", "value": "12vp" },
    { "name": "title_fs", "value": "18fp" },
    { "name": "body_fs", "value": "16fp" },
    { "name": "caption_fs", "value": "12fp" }
  ]
}

单位选择规则:

单位 用途 说明
vp 间距、宽高、圆角 随屏幕密度换算,与像素密度解耦
fp 字号 随系统字体大小缩放,可访问性依赖它
lpx 需要与设计稿 1:1 对齐时 少见,非必要不用
px 基本不用 不随密度缩放,多端必然错位

3.5.5 多语言

// entry/src/main/resources/zh_CN/element/string.json
{
  "string": [
    { "name": "pending_count", "value": "待处理 %d 条" },
    { "name": "empty_state", "value": "暂无审批" }
  ]
}
// entry/src/main/resources/en_US/element/string.json
{
  "string": [
    { "name": "pending_count", "value": "%d pending" },
    { "name": "empty_state", "value": "No approvals" }
  ]
}

带参引用:$r('app.string.pending_count', this.actionableCount)。英文的「1 items」这类问题由 plural.json 解决。

版本门槛:$r()、限定词目录与 plural 资源在兼容下限 API 20 上均可用。校验手段:写一个 bash 脚本 diff base/element/color.json 与 dark/element/color.json 的 name 集合,把「key 不一致」变成 CI 可拦截的错误,而不是等到深色模式截图才发现。


3.6 26.0.0 的 ArkUI 增强

版本门槛:API 26 新增。本节涉及的材质效果与新增组件在兼容下限 API 20 上不可用,必须给出降级实现。

3.6.1 两类增强

增强类别 是什么 对代码的影响
材质效果 更完整的层次材质体系,用于表达界面的层级与深度 属性级增量:降级为「实色背景 + 基础模糊」
基于 V2 的新增组件 原生按状态管理 V2 设计的组件封装 接入方式变化:直接支持 @Param / @Event / @BuilderParam

术语:材质(material)——不只是一个模糊值,而是「背景色 + 模糊 + 高光 + 层级」的一组协同表现。它的价值在于让浮层与背后的内容产生可感知的层次关系。具体属性名以官方 API 参考 → ArkUI → 通用属性为准,本节只给接入骨架与门槛判定。

3.6.2 门槛判定要用对工具

第 1 章的 CapabilityGate 回答的是「设备有没有这个能力」;本节还需要回答「API 版本够不够」。两者不能互相替代:

// lib_base/src/common/VersionGate.ets
import { deviceInfo } from '@kit.BasicServicesKit';

/** 按 API 版本分档:回答「这个写法值不值得走」 */
export class VersionGate {
    private static readonly apiLevel: number = deviceInfo.sdkApiVersion;

    static atLeast(minApi: number): boolean {
        return VersionGate.apiLevel >= minApi;
    }
}
问题 用谁 依据
这台设备有没有相机能力? CapabilityGate.supported(...) SystemCapability
这台设备的 API 够不够 26? VersionGate.atLeast(26) deviceInfo.sdkApiVersion
这个 IDE 能不能一次预览 8 档断点? 都不需要 工具链能力,不在设备上(1.6)

3.6.3 用 AttributeModifier 承载降级

把「增强 / 降级」两条链路封在 modifier 里,业务代码无感:

// lib_ui/src/theme/MaterialBackground.ets
import { VersionGate } from 'lib_base';

export class MaterialBackground implements AttributeModifier<ColumnAttribute> {
    applyNormalAttribute(instance: ColumnAttribute): void {
        // 基座:所有版本都能用的实色背景
        instance.backgroundColor($r('app.color.card_bg'));
        instance.borderRadius(12);

        if (VersionGate.atLeast(26)) {
            // 增强链路:26.0.0 的层次材质。此处以通用模糊能力**示意分层结构**
            // (BlurStyle 取值本身 API 12 起即可用,不代表 API 26 独有);
            // 真实增强属性名与取值以官方 API 参考 → ArkUI → 通用属性为准
            instance.backgroundBlurStyle(BlurStyle.COMPONENT_ULTRA_THICK);
        } else {
            // 降级链路:基础模糊(API 12 起即可用),视觉近似但层级感弱
            instance.backgroundBlurStyle(BlurStyle.COMPONENT_REGULAR);
        }
    }
}

3.6.4 新增组件的接入方式

基于 V2 的新增组件直接用 V2 装饰器接入,不需要适配层:

@ComponentV2
export struct MaterialPanel {
    @Require @Param title: ResourceStr;
    @Event onDismiss: () => void = (): void => {};
    @BuilderParam body: () => void = this.emptyBody;

    @Builder
    emptyBody() {
        Text($r('app.string.empty_content'))
            .fontColor($r('app.color.text_secondary'))
    }

    build() {
        Column({ space: 12 }) {
            Text(this.title).fontSize($r('app.float.title_fs'))
            this.body()
        }
        .padding($r('app.float.card_padding'))
        .attributeModifier(new MaterialBackground())
    }
}

降级策略的三条纪律:

  1. 增强属性只出现在 AttributeModifier 或专用组件封装里,不散落到页面代码——散落之后,降级要改几十处。
  2. 降级链路必须是完整可用的视觉方案,而不是「凑合能跑」。基础模糊 + 实色底在 API 20 上是合格的,不做任何模糊就退化成纯色也是可接受的,但不能出现透明导致文字看不清。
  3. 不要在业务条件里判版本来决定渲染什么内容,只用来决定「用哪套属性」。

3.7 响应式栅格:GridRow + GridCol

3.7.1 概念:总列数 vs 跨列数

GridRow 定义「每个断点下总共几列」        GridCol 定义「这个元素跨几列」
    xs: 4  sm: 4  md: 8  lg: 12  xl: 12      卡片: xs 4 / sm 2 / md 4 / lg 4
    └──────────── 12 列制在大屏 ────────┘        结果: 1 列 / 2 列 / 2 列 / 3 列

一句口诀:GridRow 给格子总数,GridCol 给每个元素占几格,span 要按断点逐档写。

3.7.2 断点阈值与总列数

断点 典型窗口宽度 常见设备 建议总列数
xs < 320vp 小屏手机 4
sm 320 ~ 600vp 手机、折叠屏外屏 4
md 600 ~ 840vp 折叠屏展开、小平板 8
lg 840 ~ 1080vp 平板、PC 窗口 12
xl ≥ 1080vp 大屏、车机 12

3.7.3 完整示例:小屏 1 列 / 手机 2 列 / 大屏 3 列

@Entry
@ComponentV2
struct ApprovalBoardPage {
    @Local rows: Approval[] = [];

    aboutToAppear(): void {
        this.rows = ApprovalMock.list();
    }

    build() {
        GridRow({
            // 各断点下的总列数
            columns: { xs: 4, sm: 4, md: 8, lg: 12, xl: 12 },
            gutter: { x: 12, y: 12 },
            // 4 个阈值切出 5 档断点
            breakpoints: {
                value: ['320vp', '600vp', '840vp', '1080vp'],
                reference: BreakpointsReference.WindowSize
            },
            direction: GridRowDirection.Row
        }) {
            // 标题:所有断点都占满整行
            GridCol({ span: { xs: 4, sm: 4, md: 8, lg: 12, xl: 12 } }) {
                Text($r('app.string.board_title'))
                    .fontSize($r('app.float.title_fs'))
                    .fontColor($r('app.color.text_primary'))
            }

            // 卡片:xs 满行(1 列) → sm 半行(2 列) → md 半行(2 列) → lg/xl 1/3 行(3 列)
            ForEach(this.rows, (row: Approval) => {
                GridCol({ span: { xs: 4, sm: 2, md: 4, lg: 4, xl: 4 } }) {
                    ApprovalCard({ row: row })
                }
            }, (row: Approval): string => row.id)
        }
        .width('100%')
        .padding($r('app.float.page_padding'))
    }
}

span 的推算过程必须自己算清楚——这是这套栅格最容易错的地方:

断点 总列数 卡片 span 每行卡片数 换算
xs 4 4 1 4 ÷ 4
sm 4 2 2 4 ÷ 2
md 8 4 2 8 ÷ 4
lg 12 4 3 12 ÷ 4
xl 12 4 3 12 ÷ 4

3.7.4 代码侧需要知道当前断点时

布局用声明式栅格解决;但业务逻辑(例如「大屏才显示主从两栏」)需要在代码里读断点,用 mediaquery:

import { mediaquery } from '@kit.ArkUI';

export class BreakpointWatcher {
    private listener: mediaquery.MediaQueryListener | undefined = undefined;

    /** 监听是否进入 lg 及以上(≥ 840vp)。
     *  注意:`on('change', ...)` 只在档位**变化**时回调。若注册时窗口已经 ≥ 840vp,
     *  初始命中不会触发回调——需要主动查询一次当前匹配结果并调用 onChange,
     *  具体查询接口以所用 SDK 的 MediaQueryListener 为准(见官方 API 参考)。 */
    start(onChange: (isLarge: boolean) => void): void {
        this.listener = mediaquery.matchMediaSync('(width >= 840vp)');
        this.listener.on('change', (result: mediaquery.MediaQueryResult): void => {
            onChange(result.matches);
        });
    }

    /** 必须在 aboutToDisappear 调用,否则监听常驻(Code Scanner 会报资源泄漏) */
    stop(): void {
        this.listener?.off('change');
        this.listener = undefined;
    }
}

版本门槛:GridRow / GridCol 与 mediaquery 在兼容下限 API 20 上均可用,无需门槛标注。新增的断点细分档位只影响 IDE 预览器(1.6.1),不影响 breakpoints.value 的写法。


3.8 布局性能与调试

3.8.1 八类高频问题

# 问题 症状 处理
1 嵌套过深(> 4 层) 布局耗时随层数增长 用 RelativeContainer 拉平
2 属性参数位置做计算 / new 对象 每次重渲染都分配 移到 @Computed 或成员初始化
3 长列表用 ForEach 首屏卡、内存高 LazyForEach + cachedCount
4 keyGenerator 用 index 增删后状态错位 用业务 id
5 组件不复用 滚动时频繁创建 / 销毁 @Reusable + 只重置非 props 状态
6 同向滚动容器嵌套 滚动手势冲突、白屏 用 ListItemGroup 的 header 承载头部内容
7 百分比层层嵌套 反复测量 用 layoutWeight 或固定尺寸
8 滥用 onAreaChange 回调风暴 只在必要时监听并做防抖

3.8.2 排查顺序

界面卡顿时,按「先定位是布局还是数据」的顺序走,比到处加日志有效:

步骤 动作 期望产出
1 用 ArkUI Inspector 看组件树层数 找出 > 4 层的分支
2 看列表是否用了 LazyForEach 与 cachedCount 排除「全量创建」
3 检查 keyGenerator 与 @Reusable 排除「重建 + 状态错位」
4 检查属性参数位置有无表达式 排除「每次重渲染都重算」
5 多档位预览对照 确认不是特定宽度才卡
6 导出 dump 分析主线程栈(1.6.3) 定位到具体方法

3.8.3 可量化的验收线

指标 目标 怎么量
首屏可见时间 1000 条数据下不明显长于 100 条 同页面替换数据量对比
列表滚动 无白屏、无明显掉帧 快速滑动 + 8 档断点预览对照
组件树深度 ≤ 4 层(列表项内 ≤ 3 层) ArkUI Inspector
切主题重排 切换深浅色无错位、无闪烁 系统切换 + 截图对比

版本门槛:Inspector、dump 解析、8 档断点预览都是 IDE 能力(1.6),不需要在代码里做任何兼容处理。性能优化不改语义——如果为了快而丢失了业务状态,那是 bug 而不是优化。


3.9 常见误区

误区 1:在 build 的属性参数位置做计算

错误写法

build() {
    Column() {
        Text(this.summary())
            .fontSize(this.computeFontSize())
    }
    .width(this.calcWidth())
}

正确写法

@Computed
get summaryText(): string {
    return `${this.actionableCount}/${this.rows.length}`;
}

build() {
    Column() {
        Text(this.summaryText).fontSize($r('app.float.body_fs'))
    }
    .width('100%')
}

为什么错:build() 里不能声明本地变量、不能调用普通方法(编译期会拦),但属性参数位置的表达式不会被拦。它每次重渲染都会求值——calcWidth() 这种带数学运算的方法在滚动列表里会被调用成千上万次。

误区 2:长列表用 ForEach,且 keyGenerator 用 index

错误写法

List() {
    ForEach(this.rows, (row: Approval) => {
        ListItem() { ApprovalCard({ row: row }) }
    }, (row: Approval, index: number): string => index.toString())
}

正确写法

List() {
    LazyForEach(this.ds, (row: Approval) => {
        ListItem() { ApprovalCard({ row: row }) }
    }, (row: Approval): string => row.id)
}
.cachedCount(3)

为什么错:ForEach 全量创建节点,1000 条就是 1000 棵子树;index 作 key 意味着「身份」绑定在位置上,插入或删除后所有后续 key 变化,节点被整段重建且内部状态(展开、输入中)错位到别的数据上。

误区 3:IDataSource 改了数据却不通知,或通知与数据不同步

错误写法

removeAt(index: number): void {
    this.items.splice(index, 1);
    // 忘了通知
}

update(index: number, item: T): void {
    this.notify((l: DataChangeListener): void => l.onDataChange(index));   // 先通知
    this.items[index] = item;                                              // 后改数据
}

正确写法

removeAt(index: number): void {
    this.items.splice(index, 1);
    this.notify((l: DataChangeListener): void => l.onDataDelete(index));
}

update(index: number, item: T): void {
    this.items[index] = item;
    this.notify((l: DataChangeListener): void => l.onDataChange(index));
}

为什么错:LazyForEach 收到通知后会立刻回调 getData() 取数据。通知时不改数据,框架取到的是旧值(或 undefined);先通知后改数据同样取到旧值。必须严格「先改数据、再通知」,且下标要指向同一个元素。

误区 4:@Builder 传基本类型却期望刷新

错误写法

// 传一个基本类型 → 按值传递 → 内部拿到的是快照
this.statusChip(this.statusLabel, this.statusColor)

正确写法

// 传对象字面量 → 按引用传递 → 状态变化时刷新
this.statusChip({ label: this.statusLabel, color: this.statusColor })

为什么错:@Builder 的参数传递分按值(基本类型)与按引用(对象字面量)两种。按值时参数是调用瞬间的副本,之后状态变了也不会重算片段,表现为「数据变了、芯片文字不变」。这是最隐蔽的一类不刷新问题——代码看起来毫无问题。

误区 5:@Styles 里写组件私有属性,或 @Extend 用在自定义组件上

错误写法

@Styles
function titleStyle() {
    .fontSize(18)          // fontSize 不是通用属性
    .fontColor('#182431')
}

// @Extend 不能作用于自定义组件
// @Extend(ApprovalCard)
// function cardStyle() { .width('100%') }

正确写法

@Extend(Text)
function titleStyle(size: number) {
    .fontSize(size)
    .fontColor($r('app.color.text_primary'))
}

为什么错:@Styles 只能收敛通用属性(width / padding / backgroundColor 等);fontSize 属 Text 私有属性,写在这里编译失败。@Extend 只能扩展内置组件,自定义组件要收敛样式请用 AttributeModifier。

误区 6:硬编码颜色与文案,尺寸用 px

错误写法

Text('待处理 3 条')
    .fontSize(16)
    .fontColor('#182431')
    .backgroundColor('#FFFFFF')
    .padding(12)

正确写法

Text($r('app.string.pending_count', this.actionableCount))
    .fontSize($r('app.float.body_fs'))
    .fontColor($r('app.color.text_primary'))
    .backgroundColor($r('app.color.card_bg'))
    .padding($r('app.float.card_padding'))

为什么错:硬编码色值在深色模式下必然穿帮(要么回到代码里逐个改,要么在多个文件里重复定义);硬编码文案无法多语言,英文环境下会直接显示中文;无单位数值按属性的默认单位处理(width / padding 等按 vp,fontSize 按 fp),但把 px 写进尺寸会在不同密度设备上错位,且字号不用 fp 会让用户放大字体的设置失效——这是可访问性问题。

误区 7:GridCol 的 span 不按断点写全

错误写法

// 只给一个固定 span,平板上会一直是一列
GridCol({ span: 4 }) {
    ApprovalCard({ row: row })
}

正确写法

GridCol({ span: { xs: 4, sm: 2, md: 4, lg: 4, xl: 4 } }) {
    ApprovalCard({ row: row })
}

为什么错:span 只给单一数值时,它在所有断点下都取该值;若该值等于 xs 的总列数,结果就是所有宽度下都退化成单列,白白浪费平板与大屏的横向空间。每一档都要显式写,并自己核算「总列数 ÷ span = 每行个数」。


3.10 实践任务

任务:千条数据的审批列表页

任务描述

  1. 新建 ApprovalListPage.ets,用 Mock 生成 1000 条审批数据(字段见第 2 章 Approval)。
  2. 实现 BasicDataSource<T>(第 3.4.2 的完整版),页面用 LazyForEach + cachedCount(3),keyGenerator 使用 row.id。
  3. ApprovalCard 标注 @Reusable,aboutToReuse 只重置非 props 驱动的状态。
  4. 用 GridRow + GridCol 实现响应式布局:xs/sm 每行 1~2 列、md 每行 2 列、lg/xl 每行 3 列,并在注释里写出 span 推算过程。
  5. 所有颜色、文案、尺寸改用 $r();补齐 base / dark 的 color.json 与 base / zh_CN / en_US 的 string.json,key 集合完全一致。
  6. 实现空态、加载态、错误态三个分支,且三种状态都有对应资源文案。
  7. 用 8 档断点预览对照 5 个档位的实际列数,截图存档。
  8. 写一个 deleteRow(id) 交互(侧滑或长按),验证删除后展开状态不串位。

验收 checklist

  • [ ] 1000 条数据下滚动无白屏,快速滑动不掉帧
  • [ ] 全局搜索 ForEach,长列表处均已替换为 LazyForEach
  • [ ] 所有 keyGenerator 均返回业务 id,无 index.toString()
  • [ ] BasicDataSource 的每个变更方法都是「先改数据、再通知」,且下标一致
  • [ ] registerDataChangeListener 有去重判断(indexOf < 0)
  • [ ] GridCol 的 span 五档全写,注释含「总列数 ÷ span」的推算
  • [ ] 深浅色切换无错位、无半深半浅的控件
  • [ ] 切换英文后无中文残留
  • [ ] base 与 dark 的 color key 集合一致(脚本校验通过)
  • [ ] 组件树最深分支 ≤ 4 层(ArkUI Inspector 确认)
  • [ ] 删除第 1 条后,原本展开的第 3 条不出现「状态错位」
  • [ ] 切页面再返回,无监听泄漏(Code Scanner 无新增告警)

交付物清单

交付物 形式 验收点
ApprovalListPage.ets 源码 LazyForEach + cachedCount + 三态分支
BasicDataSource.ets 源码 四个接口方法 + 六类变更通知齐备
ApprovalCard.ets 源码 @Reusable + aboutToReuse 职责正确
响应式栅格实现 源码 + 注释 span 推算过程可核对
资源文件 base / dark / zh_CN / en_US key 集合一致
断点预览截图 图片(5 档) 列数与预期一致
资源一致性校验脚本 bash 可 diff color / string 的 key 集合

3.11 延伸阅读

  • 官方文档 → 应用开发 → ArkUI → 布局与约束(各容器能力与嵌套约束的权威说明)
  • 官方文档 → 应用开发 → ArkUI → 响应式布局(断点与栅格)(GridRow / GridCol / 断点阈值的定义)
  • 官方文档 → 应用开发 → ArkUI → 长列表与懒加载(LazyForEach / IDataSource)(数据源接口与通知语义)
  • 官方文档 → 应用开发 → ArkUI → 组件复用(@Reusable)(复用生命周期与状态清理约定)
  • 官方文档 → 应用开发 → ArkUI → 资源与限定词目录($r 类型与回落规则)
  • 官方文档 → 应用开发 → ArkUI → AttributeModifier 与属性复用(动态属性注入与类型安全约束)

上一章:第 2 章 ArkTS 语言核心 | 下一章:第 4 章 页面路由与生命周期

📄 第 11 章 日志与埋点 03-logging-tracking.md

术语:可观测性(observability)—— 不新增代码就能从系统外部推断内部状态的能力。本篇讲的是它成本最低的三种形态:日志、打点、埋点。

前置:docs/06-persistence-network.md 与本篇的 02 篇分别给出了 Masker 与 Analytics 的被调用视角(谁在什么时机调)。本篇讲的是它们自身的实现,以及三级分层该如何选择。

版本门槛:日志基于 @kit.PerformanceAnalysisKit 的 hilog;性能打点基于同一 kit 的 hiTraceMeter。两者在 HarmonyOS 6.0.0(20) 起可用,本工程开发套件基线 26.0.0。


3.0 本章目标

读完本篇,你应当能回答:

  1. "记录发生了什么"和"统计成功率"该用哪一层——三级分层的选择判据是什么;
  2. XLogger.d(tag, () => ...) 为什么必须收闭包而不是 string,它到底省掉了什么;
  3. release 包里日志级别、Mock 开关、断点属性各有什么差异,这些差异是怎么被"注入"出来的;
  4. Masker 的六个正则分别覆盖什么,为什么它的出口只有两个;
  5. Analytics 为什么绝不落盘,缓冲满了为什么丢最旧的、为什么不无限重试;
  6. 事件名与属性名为什么必须是常量,属性值为什么只允许基础类型;
  7. page_view 为什么挂在 onShown / onHidden 而不是 aboutToAppear;
  8. 怎么用 Trace 把"启动慢"从猜测变成一条分段数据。

3.1 三级可观测性分层

3.1.1 分工表

层 门面 底层 回答的问题 单次开销 数据落点 主要消费方
日志 XLogger hilog 发生了什么 低(字符串构造在关闭时常为 0) 设备日志缓冲 / IDE Log 窗口 开发、售后排障
打点 Trace / LaunchTrace hiTraceMeter 花了多久、慢在哪一段 极低(无字符串) 设备 trace 文件(需主动抓取) 性能分析
埋点 Analytics EventUploader(可替换) 用户做了什么、成功率多少 低(内存缓冲 + 批量上报) 上报服务端 → 数据看板 产品、运营、运维告警

三者的实现分别落在 lib_log/src/main/ets/ 下的 log/、trace/、analytics/ 三个目录,脱敏工具在 mask/。

3.1.2 选择判据:三个问题

选哪一层,只需要依次问三个问题:

① 这个信息要用来算比例 / 画趋势吗?
      是 → 埋点(Analytics)
      否 → ②
② 这个问题是关于「时间」的吗?(多久 / 慢在哪段)
      是 → 打点(Trace)
      否 → ③
③ 这个信息只在「出问题时」才有人看吗?
      是 → 日志(XLogger)
      否 → 你其实还没想清楚要它干什么

三条常见误判:

  • "想知道网络失败率" → 不要用日志统计。日志在设备上,你捞不到。用 Analytics.network(...),它在 LoggingInterceptor 里已经自动打好了。
  • "想知道列表渲染多少毫秒" → 不要用日志打时间戳再相减。用 Trace.sync('list:render', () => ...),它直接进泳道图。
  • "想知道某个用户为什么点了没反应" → 用日志。这种情况下的关键词是"某一个用户、某一台设备",与埋点的聚合视角正好相反。

3.1.3 三级的边界

三条纪律,分别是三层的"不该做什么":

  1. 日志不该被用来做数据统计。它是非结构化的(人类可读的句子),聚合成本极高;而且 release 包默认只留 WARN 及以上(见 3.2.4),信息天然不完整。
  2. 打点不该用在循环里。Trace 的文件头注释写明:"只在关键链路上打点(启动分段、网络耗时、列表首屏、DB 查询),不要在循环里逐条打点 —— 打点本身有成本,且会让泳道图不可读。"
  3. 埋点不该阻塞业务。Analytics.emit 的注释写明:"调用点不应关心成功与否,更不应 await 它 —— 埋点不该阻塞业务。" 它返回 void,在类型层面就断掉了 await 的可能。

3.2 日志:XLogger

3.2.1 文件与导出面

lib_log/src/main/ets/log/XLogger.ets   → LogLevel / LogSink / HilogSink / MemorySink / XLogger
lib_log/Index.ets                      → 对外导出 XLogger / LogLevel / HilogSink / MemorySink / LogSink

Index.ets 的注释解释了为什么连 HilogSink / MemorySink 这种"内部实现"都导出:

内部实现也导出,因为它们是合法的替换实现(测试与本地排障要用)。

MemorySink 的作用是单测:它把日志行存进数组,于是可以断言"某个错误确实被记录了"。这是"出口可替换"这个设计最直接的一个收益。

3.2.2 无损采集的三个关键设计

① 闭包延迟构造(最重要)

// XLogger.ets:142-154
private static emit(level: LogLevel, tag: string, build: () => string): void {
    // 注意:先比较再调用 build(),这是"延迟构造"生效的地方
    if (level < XLogger.effectiveLevel()) {
        return;
    }
    let message: string = '';
    try {
        message = build();
    } catch (e) {
        message = `<log message build failed: ${(e as Error).message}>`;
    }
    XLogger.sink.write(level, tag, message);
}

所有日志方法的签名都是 (tag: string, build: () => string),而不是 (tag: string, message: string)。文件头注释给出了理由:

日志级别被关闭时,闭包根本不执行 —— 字符串拼接、JSON.stringify、数组遍历这些开销全部为零。这是"日志不影响性能"的核心手段,而不是"少打日志"。

"少打日志"这条路是走不通的:开发者在写代码时无法预知线上需要什么日志,于是只会在出事之后补日志、再发版、再等下一次出事。闭包方案让写日志的成本从"运行时开销"变成"代码行数",这才是敢在 release 包里保留 XLogger.i(...) 调用点的前提。

对比一个真实的反例。下面这样写,即使日志被过滤掉,JSON.stringify 也已经执行完了:

// ✗ 错误:无论是否输出,stringify 都会执行
XLogger.d(tag, JSON.stringify(heavyObject));

// ✓ 正确:被过滤时闭包根本不执行
XLogger.d(tag, () => JSON.stringify(heavyObject));

这套写法的代价是每个调用点都要写 () =>。真实工程里 248 处 XLogger.* 调用全部带闭包——这个一致性说明它已经变成了肌肉记忆,而不是需要每次思考的决策。

emit 里还有一层 try/catch:闭包内部抛异常时会写成 <log message build failed: ...>。这条兜底很重要——日志构造代码本身有 bug(例如遍历一个为 undefined 的数组)时,不能让日志把业务打崩。这是"日志系统永远不能影响业务"原则的最小实现。

② 出口抽象 LogSink

// XLogger.ets:16-23
/**
 * 日志写入出口。抽象成接口的目的是:
 * - 便于单测替换为内存实现(断言"某个错误确实被记录了");
 * - 后续接远端日志通道时不用改业务代码。
 */
export interface LogSink {
    write(level: LogLevel, tag: string, message: string): void;
}

三个实现:

实现 用途 关键细节
HilogSink 默认出口 构造可传 domain(默认 0x0F0F);显式写 %{public}s
MemorySink 测试 / 本地排障 存成 Level|tag|message 的字符串数组,提供 clear()

HilogSink 里那行 hilog.info(this.domain, tag, '%{public}s', message) 的 %{public}s 是刻意显式写出来的,注释说明了原因:"不要把隐私标记交给默认值决定"。

这条纪律值得展开:hilog 的格式串若省略隐私标记,输出行为由系统默认规则决定,可能表现为日志出现 <private>(你什么都看不到),也可能在某些配置下原样输出(敏感信息泄漏)。两种结果都不可接受——你需要的是"确定性地看到我已脱敏的内容"。显式 %{public}s 保证输出一定是你传进去的那串(而它已经过 3.3 节的 Masker 处理)。

③ 统一 tag 格式

// XLogger.ets:191-197
/**
 * 统一 tag 格式:`[模块:类]`。
 * 便于用 hilog 过滤,也便于在日志平台按模块聚合。
 */
static tagOf(module: string, clazz: string): string {
    return `${module}:${clazz}`;
}

约定形如 net:HttpApi / feature:ApprovalRemoteDs / entry:AppInitializer。典型用法是把它固化成类里的一个常量:

// lib_network/src/main/ets/api/HttpApi.ets:42
private readonly tag: string = XLogger.tagOf('net', 'HttpApi');

这样做的收益是可以按模块过滤。出网络问题时在 hdc 或 IDE 里只筛 net:*,就能把 feature:* 的噪音全部排除;反过来排查业务问题时也只筛 feature:*。

若直接在调用点写 XLogger.i('net:HttpApi', ...),字符串虽然长得一样,但改名时无法全局检索、拼错时编译器也不报错。真实工程里 248 处 XLogger.* 调用中的裸字符串 tag 已收敛为 0 处——本篇早期版本记录的 13 处存量(含 Analytics 里不符合 模块:类 形状的 'analytics')已全部改走 tagOf(如 Analytics.ets:17 的 XLogger.tagOf('log', 'ConsoleUploader'))。

3.2.3 级别与开关

// XLogger.ets:7-14
export enum LogLevel {
    DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3, FATAL = 4, NONE = 5
}

与 hilog 的级别对齐,注释说明理由是"避免二次映射带来的理解成本"。数值递增的好处是可以直接比较:level < effectiveLevel() 就是"该过滤掉"。

// XLogger.ets:103-140
private static sink: LogSink = new HilogSink();
private static minLevel: LogLevel = EnvConfig.IS_RELEASE ? LogLevel.WARN : LogLevel.DEBUG;
/** 运行期可远程下调,用于线上排障临时开启详细日志 */
private static overriddenLevel: LogLevel | undefined = undefined;
/** 生产环境开关:由灰度配置下发,最多开启 30 分钟后自动回落 */
private static verboseUntil: number = 0;

private static effectiveLevel(): LogLevel {
    if (XLogger.overriddenLevel !== undefined) {
        if (XLogger.verboseUntil > 0 && Date.now() > XLogger.verboseUntil) {
            XLogger.overriddenLevel = undefined;
            XLogger.verboseUntil = 0;
        } else {
            return XLogger.overriddenLevel;
        }
    }
    return XLogger.minLevel;
}

四个开关,各有一个明确用途:

开关 谁改 生效范围 用途
minLevel EnvConfig.IS_RELEASE(构建期) 进程级默认 release 只留 WARN 及以上
overriddenLevel setLevel / enableVerboseFor 覆盖 minLevel 线上排障临时开详细日志
verboseUntil enableVerboseFor(minutes) 到期自动回落 临时开启,防止忘记关

enableVerboseFor 的"到期自动回落"是关键:线上排障最常见的后续事故是"为了排查开了 verbose 日志,然后忘了关,日志把用户存储撑满"。用一个到期时间戳把这个操作变成默认安全的。

effectiveLevel() 里的判断顺序也值得注意:先检查 overriddenLevel,再在其中检查过期。过期时顺手清掉两个字段,而不是只返回 minLevel 而留着脏值——这样下一次调用不必再做一次过期判断。

3.2.4 release / debug 差异

项 debug 构建 release 构建 由谁控制
日志最低级别 DEBUG WARN EnvConfig.IS_RELEASE
XLogger.must 输出 仍然输出 不受级别影响(见 3.2.5)
Mock 规则注册 按环境(默认注册) — EnvConfig.CURRENT === Env.MOCK
埋点上报器 ConsoleUploader(打完日志就完) 应替换为真实网络通道 Analytics.setUploader
埋点公共属性 is_release false true AppInitializer.commonProps()
代码混淆 关闭 模块级 obfuscation 为 true 各模块 build-profile.json5

EnvConfig.IS_RELEASE 是 static readonly 的布尔字面量(AppConst.ets:28),它的值由 scripts/inject-version.mjs --release 在构建前就地改写:

// AppConst.ets:22-28
/**
 * 是否为 release 构建。
 * 由 scripts/inject-version.sh 在构建前改写这一个字面量,业务代码只读不改。
 * 不做成运行时探测,是因为日志级别这类开关必须在编译期就确定,
 * 否则 release 包会残留 debug 分支代码。
 */
static readonly IS_RELEASE: boolean = false;

inject-version.mjs 的注释把"为什么必须由脚本改"讲得很清楚:

构建模式是编译期常量,不是运行时开关。EnvConfig.IS_RELEASE 控制日志级别、调试入口、Mock 开关的编译期剪裁。如果它是"开发者记得改"的一个字面量,release 包里残留 debug 分支只是时间问题。所以它必须由 CI 在构建前用脚本改写 —— 人对一个布尔字面量是不可靠的。

脚本只改两处,且拒绝静默跳过(inject-version.mjs:29-30):AppScope/app.json5 的 versionCode / versionName,以及 AppConst.ets 的 IS_RELEASE 字面量。匹配不到就 exit 1,注释说明理由:"静默跳过 = 版本号没更新但构建成功"。

AppInitializer.initLogging 里还有第二次设置:

// AppInitializer.ets:124-127
private static initLogging(): void {
    XLogger.setLevel(EnvConfig.isDebugBuild() ? LogLevel.DEBUG : LogLevel.WARN);
    XLogger.i(AppInitializer.TAG, () => `logger ready, env=${EnvConfig.CURRENT} baseUrl=${EnvConfig.baseUrl()}`);
}

看起来它与 XLogger.minLevel 的初始化重复,但两者职责不同:minLevel 是字段初始值,setLevel 写的是 overriddenLevel(运行期覆盖层)。这样设计的好处是——如果某天需要"启动时按远端配置决定级别",只要改 initLogging 一处,XLogger 本身不用动。

对表记录(已修复):本篇早期版本曾记录 AppConst.ets:24 的注释写作"由 scripts/inject-version.sh 在构建前改写",而实际脚本文件是 scripts/inject-version.mjs。该注释现已改为 .mjs,按注释去找脚本不会再扑空。

3.2.5 must() 的定位

// XLogger.ets:177-189
/**
 * 关键链路日志:无论级别如何都会输出,用于"必须留痕"的场景
 * (登录态变更、支付类操作、数据迁移)。用时克制。
 */
static must(tag: string, build: () => string): void {
    let message: string = '';
    try { message = build(); } catch (e) { message = '<build failed>'; }
    XLogger.sink.write(LogLevel.WARN, tag, message);
}

must 绕过了 effectiveLevel() 检查,在任何构建下都会输出,但写入时用的级别是 WARN(而不是 FATAL)。这个选择是对的:must 表达的是"必须留痕",而不是"这是致命错误"。把它写成 FATAL 会让真正的 FATAL 日志失去信号价值。

真实工程里的使用位置(全部在关键链路上):

位置 内容
AppInitializer.runSync sync init done: <分段摘要>
AppInitializer.runDeferred 后 / shutdown shutdown done
AppInitializer.initMock mock enabled: <规则清单>
TokenRefresher.onUnauthorized unauthorized, purging session. reason=...
LogoutBus.broadcast logout broadcast #<epoch> reason=...
SessionStore.reset session reset

共同特征:这些事件在一台真实设备的日志里必须能找到。用户反馈"我被登出了",你需要能看到两次 unauthorized, purging session。若它被 release 的 WARN 阈值挡掉(或者你顺手写成了 d),这个排查手段就没了。

3.2.6 三条使用纪律

  1. 必须收闭包。见 3.2.2 ①。这一条没有例外,唯一"违规"的写法是把一个不可变字面量直接传进去(例如 () => 'session closed')——所以干脆统一写闭包,不做例外。
  2. 错误日志必须已脱敏。XLogger.e 的注释(XLogger.ets:168)写明:"传入的文案必须已脱敏(网络层由 LoggingInterceptor 保证)"。这解释了为什么脱敏是调用方的责任而不是 XLogger 的——XLogger 不知道自己收到的是用户数据还是固定文案,统一脱敏会带来不必要的正则开销。
  3. 不要用日志做统计分析。见 3.1.3。

3.3 脱敏:Masker

3.3.1 六个正则

// Masker.ets:11-16
private static readonly PHONE: RegExp = /1[3-9]\d{9}/g;
private static readonly ID_CARD: RegExp = /\d{17}[\dXx]/g;
private static readonly BANK_CARD: RegExp = /\d{16,19}/g;
private static readonly EMAIL: RegExp = /[\w.+-]+@[\w-]+\.[\w.]+/g;
private static readonly BEARER: RegExp = /(Bearer\s+)[A-Za-z0-9\-._~+/]+=*/g;
private static readonly JWT: RegExp = /eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/g;
正则 覆盖 保留策略
PHONE 中国大陆手机号 前 3 后 4
ID_CARD 18 位身份证(末位可为 X) 前 4 后 2
BANK_CARD 16~19 位数字 前 4 后 4
EMAIL 邮箱 首字符 + 域名
BEARER Bearer <token> 形式的鉴权头 保留 Bearer 前缀
JWT 三段式 JWT 整体替换为 ***

三个顺序细节值得注意(text() 里的 .replace 链顺序,Masker.ets:30-36):

  1. BEARER 在 JWT 之前。因为 Bearer eyJ... 里同时命中两个正则,先处理 BEARER 能保留前缀信息(看到 Bearer *** 就知道这里原本是个鉴权头),比整段变成 *** 更有排障价值。
  2. JWT 在手机号等之前。JWT 的 payload 段是 base64,可能恰好包含 11 位数字被手机号正则误伤。先整体消掉 JWT 避免这种局部替换留下半截 token。
  3. BANK_CARD 在 ID_CARD 之后。身份证是 18 位、银行卡是 16~19 位,两者区间重叠。先匹配 18 位的身份证(更精确),剩下的 16/17/19 位才交给银行卡规则。

3.3.2 字段级脱敏:SENSITIVE_KEYS

// Masker.ets:18-23
/** 需要整体隐藏的字段名(大小写不敏感,含子串匹配) */
private static readonly SENSITIVE_KEYS: string[] = [
    'password', 'passwd', 'pwd', 'token', 'access_token', 'refresh_token',
    'authorization', 'cookie', 'secret', 'signature', 'idcard', 'id_card',
    'sessionkey', 'session_key', 'privatekey', 'apikey', 'api_key'
];

匹配方式是 lower.indexOf(key) >= 0——子串匹配,不是等值匹配。这个选择是对的:

  • access_token 与 token 都列了,但子串匹配让 my_custom_token 也能被覆盖到;
  • 代价是可能误伤(tokenCount 会被当作敏感字段打码)。

为什么宁可误伤:误伤的后果是"某个统计字段在日志里变成 ***"(不影响功能),漏判的后果是"用户密码进了日志平台"(安全事故)。这两者的不对称性决定了必须选宽松的那一侧。

3.3.3 效果对照

输入 输出 规则
13800001234 138****1234 PHONE + keepEnds(3,4)
11010119900307123X 1101****23X ID_CARD + keepEnds(4,2)
6222020200112233445 6222****3445 BANK_CARD + keepEnds(4,4)
zhangsan@officeflow.com z***@officeflow.com maskEmail
Bearer eyJhbGc... Bearer *** BEARER
eyJhbGciOi.eyJzdWIiOi.SflKxwRJ *** JWT
key = password, value = x password=*** headers() + isSensitiveKey
key = X-Trace-Id, value = 13800001234 X-Trace-Id=138****1234 headers() + text()

最后一行体现了 headers() 的两级处理:先判字段名,再对值做文本脱敏。

// Masker.ets:74-84
static headers(raw: Record<string, string>): string {
    const parts: string[] = [];
    const keys: string[] = Object.keys(raw);
    for (let i = 0; i < keys.length; i++) {
        const key: string = keys[i];
        const value: string = raw[key];
        parts.push(`${key}=${Masker.isSensitiveKey(key) ? '***' : Masker.text(value)}`);
    }
    return parts.join(', ');
}

这个"两级"设计很关键:字段名判断挡不住"手机号被放进了一个叫 X-Device-Info 的头里",文本脱敏则是最后一道网。

fields() 与 headers() 都是返回新对象:

// Masker.ets:59-62
/**
 * 字段级脱敏:对已解析的对象按 key 递归处理。
 * 返回新对象,不修改入参(避免把脱敏结果写回真实缓存)。
 */

"不修改入参"这条注释指向一个很具体的坑:如果 Masker 就地修改了传入的对象,而那个对象恰好是真实缓存里的 Approval 实例,那么脱敏结果(***)会被写进缓存,用户回来就看到一堆星号。返回新对象是唯一安全的做法。

3.3.4 两个唯一出口

// Masker.ets:1-9
/**
 * 脱敏。
 *
 * 唯一的两个执行出口:
 *   1. LoggingInterceptor(网络请求/响应日志)
 *   2. Analytics.emit(埋点属性)
 *
 * 业务代码**不允许自己拼日志**,因此也不需要在业务里记得脱敏 ——
 * 这是设计上刻意制造的"忘记机会为零"。
 */

这是一条结构性的安全保障,而不是纪律。它的逻辑链是:

  1. 日志只能通过 XLogger 写(hilog 在工程里只被 XLogger 使用,全仓 grep 可验证);
  2. 请求/响应日志只能由 LoggingInterceptor 写,而它调用了 Masker.headers / Masker.text(02 篇 2.3.4);
  3. 埋点属性只能通过 Analytics.emit 进缓冲,而 enrich 对所有字符串值调用了 Masker.text(3.5.4);
  4. 于是业务代码没有任何"忘记脱敏"的入口——因为业务代码根本不直接拼日志。

验证第 1 条:

cd code/office-flow
grep -rn "hilog\." --include=*.ets . | grep -v "lib_log/src/main/ets/log/XLogger.ets"
# 期望:无输出
grep -rn "console\.log" --include=*.ets .
# 期望:无输出

两条命令都在写"日志出口唯一"这个约束。注意它们是 grep 检查而不是 CI 规则——scripts/banned-api.list 里没有 hilog / console.log 规则,所以这条约束目前靠约定与 review 维持。要真正防住,应加一条 direct-hilog 规则。

3.3.5 tokenFingerprint:只输出指纹

// Masker.ets:44-57
/** token 只输出指纹,永不输出原文 —— 指纹足够用于"确认是不是同一个 token" */
static tokenFingerprint(token: string): string {
    if (token.length === 0) { return 'empty'; }
    return `fnv1a32:${Masker.hash32(token)}(${token.length})`;
}

这个方法的用途很具体:排障时你经常需要回答"这次请求用的是新 token 还是旧 token?"。直接打 token 是禁止的,但"打一个稳定的短指纹"完全够用——两个请求的指纹相同,就说明用的是同一个 token。

TokenRefresher 在三个地方用了它(TokenRefresher.ets:166 / :216):

XLogger.i(this.tag, () => `refreshing token with ${Masker.tokenFingerprint(refresh)}`);
XLogger.i(this.tag, () => `refresh ok, new access=${Masker.tokenFingerprint(merged.accessToken)}`);

两行日志的指纹不同 ⇒ 刷新确实换了新令牌;相同 ⇒ 服务端返回了同一个 accessToken(可能意味着刷新逻辑没生效)。

对表记录(已修复):本篇早期版本曾记录 tokenFingerprint 输出前缀写作 sha256:,而实际算法是 FNV-1a 32 位散列(Masker.ets:111-119 的 hash32),前缀名有误导性。现已改为诚实的前缀 fnv1a32:(Masker.ets:56),且注释明确警告:它是非加盐、可碰撞的 32 位散列,用途仅限"日志里对同一个 token 做等值比对",任何安全场景都必须换成加盐的密码学摘要。


3.4 性能打点:Trace

3.4.1 与 XLogger 的分工

// Trace.ets:3-15
/**
 * 关键链路耗时打点(hiTraceMeter)。
 *
 * 定位:性能问题的**量化手段**,不是日志。
 * 与 XLogger 的分工:
 *   - XLogger 回答"发生了什么";
 *   - Trace 回答"花了多久、慢在哪一段"。
 *
 * 约束:
 *   - 只在关键链路上打点(启动分段、网络耗时、列表首屏、DB 查询),
 *     不要在循环里逐条打点 —— 打点本身有成本,且会让泳道图不可读。
 *   - begin/end 必须严格配对。用 finish() 返回值的方式包装,避免忘记 end。
 */

Trace 写的是 hiTraceMeter,不含任何字符串构造,因此开销比日志更低——它记录的是"在某个名字上打了一个时间点/计数"。它的消费方式是抓取 trace 文件后在 DevEco 的泳道图里看。

3.4.2 API 表

API 签名 用途
begin (name) => TraceSession 开始一个同步段,返回会话句柄
sync <T>(name, block: () => T) => T 推荐:包住同步块,抛异常也保证 end
async <T>(name, block: () => Promise<T>) => Promise<T> 包住异步块,耗时含等待时间
count (name, value = 1) => void 计数型打点(发生了多少次)
TraceSession.end () => number 结束并返回耗时毫秒

sync 的注释写明了它为什么是推荐用法:

// Trace.ets:24-35
/**
 * 包住一个同步块,自动 begin/end,即使抛异常也会 end。
 * 这是推荐用法 —— 手写 begin/end 在早退分支上极易漏配对。
 */
static sync<T>(name: string, block: () => T): T {
    hiTraceMeter.startSyncTrace(hiTraceMeter.LevelInfo, name, '');
    try {
        return block();
    } finally {
        hiTraceMeter.finishSyncTrace(hiTraceMeter.LevelInfo);
    }
}

"早退分支上极易漏配对"是真实经验:手写 begin / end 时,if (cached) return; 这样的早退分支会让 end 永远不被调用,泳道图上出现一条永不闭合的段——而这条段会污染整个泳道图的可读性,让后续所有分析都建立在错误的时间轴上。try/finally 是唯一不需要每次都想一遍的写法。

TraceSession 也做了防御:

// Trace.ets:65-72
end(): number {
    if (this.finished) { return 0; }
    this.finished = true;
    hiTraceMeter.finishSyncTrace(hiTraceMeter.LevelInfo);
    return Date.now() - this.started;
}

重复 end() 返回 0 而不是重复调 finishSyncTrace——避免"结束了一个不属于自己的段"这种更难排查的问题。

3.4.3 LaunchTrace:启动分段器

// Trace.ets:79-84
/**
 * 启动耗时分段器。
 *
 * 启动优化最容易犯的错是"整体测一个数,然后猜哪段慢"。
 * 用单调时钟分段记录,才能把优化落到具体阶段。
 */

用法(AppInitializer.runSync,AppInitializer.ets:89-117):

const trace: LaunchTrace = AppInitializer.launchTrace();
AppInitializer.initLogging();          trace.mark('initLoggingDone');
AppInitializer.initAnalytics();        trace.mark('initAnalyticsDone');
AppInitializer.initAuthProvider();     trace.mark('initAuthProviderDone');
AppInitializer.initRouteInterceptor(); trace.mark('initRouteInterceptorDone');
AppInitializer.initMock();             trace.mark('initMockDone');
AppInitializer.initSession(context);   trace.mark('initSessionStarted');
XLogger.must(AppInitializer.TAG, () => `sync init done: ${trace.summary()}`);

三个设计点:

  1. mark() 返回距起点的累计毫秒。要"阶段耗时"用 segment(from, to) 两个点相减。这样同一份数据既能看累计也能看差值。
  2. mark() 内部同时调 hiTraceMeter.traceByValue,所以每个分段在泳道图上也是一条独立数据点,不需要额外的打点代码。
  3. summary() 输出一行,配合 XLogger.must 让"每次冷启动都有一行启动分段"——这让启动耗时成为一条可以长期观察的序列,而不是需要专门跑一次 profiling 才能拿到的数字。

report() 在首帧后调用(AppInitializer.runDeferred,AppInitializer.ets:332-349,trace.report() 在 :347),打出 launch:total。这个位置是对的:首帧前的 total 没有意义。

3.4.4 命名约定

真实工程里出现的打点名:

打点名 位置 类型
net:request LoggingInterceptorSession.begin count
net:duration LoggingInterceptorSession.finish count(值为耗时)
net:retry RetryInterceptor.onError count
launch:<阶段名> LaunchTrace.mark(如 launch:initMockDone) count(值为累计耗时)
launch:total LaunchTrace.report count

命名规范:<域>:<语义>,冒号前是域(net / launch / 未来的 db / list),冒号后是语义。与日志 tag 的 模块:类 形状一致,便于在泳道图里按域折叠。

注意 net:duration 用 count(name, elapsed) 传耗时值,而不是用 sync 段。这是刻意的:网络请求是异步的且带重试,用段会导致"重试时段的嵌套",而用值计数能直接得到耗时分布。这是一个"打点形式要匹配数据形状"的例子。


3.5 埋点:Analytics

3.5.1 三步性能设计

// Analytics.ets:29-42
/**
 * 埋点门面。
 *
 * 性能设计(这三条决定了埋点会不会成为卡顿来源):
 *
 * 1. **写入内存环形缓冲,绝不落盘**。埋点写文件是移动端卡顿的经典元凶,
 *    尤其在滚动与列表复用场景下。缓冲满了丢最旧的,而不是阻塞。
 *
 * 2. **批量 + 异步上报**。满足「条数阈值 / 时间阈值 / 切后台」任一条件才发车,
 *    单次网络开销摊薄到 N 条事件上。
 *
 * 3. **失败有上限的重入队**。永远重试会导致内存无限增长,
 *    因此每条事件带 retryCount,超过上限直接丢弃并计数(丢弃本身要可观测)。
 */

① 内存缓冲,满则丢旧

// Analytics.ets:97-102
Analytics.buffer.push(Analytics.enrich(event));
if (Analytics.buffer.length >= AppConst.EVENT_BUFFER_SIZE) {
    Analytics.droppedCount += Analytics.buffer.length - AppConst.EVENT_BUFFER_SIZE;
    // 丢最旧的,保留最新的:新事件对排障更有价值
    Analytics.buffer.splice(0, Analytics.buffer.length - AppConst.EVENT_BUFFER_SIZE);
}

"丢最旧的"这个方向是刻意的。缓冲区满通常意味着"上报通道坏了"或"用户离线很久"。此时:

  • 保留最新的事件 ⇒ 用户刚出问题前的那段操作序列还在,能定位;
  • 保留最旧的事件 ⇒ 你拿到的是半小时前的开机流程,对当前问题毫无帮助。

丢弃会被 droppedCount 记账。"丢弃本身要可观测"——否则看板上"埋点总量下降"会被误读成"用户活跃度下降",而真实原因是客户端在丢数据。

② 批量 + 三条件触发

// Analytics.ets:103-105
if (Analytics.buffer.length >= Analytics.FLUSH_BATCH_SIZE
    || Date.now() - Analytics.lastFlushAt >= Analytics.FLUSH_INTERVAL_MS) {
    Analytics.flush();
}

flush 的三个触发条件:

条件 位置 说明
条数 ≥ 20(FLUSH_BATCH_SIZE) emit 内(判定在 Analytics.ets:103) 高频场景下快速发车
距上次 ≥ 30s(FLUSH_INTERVAL_MS) emit 内 低频场景下的兜底
切后台 / 退出 AppInitializer.shutdown() → Analytics.flush() 保证缓冲不丢

第三条在 AppInitializer.shutdown 里(AppInitializer.ets:385,Analytics.flush() 在 :390),注释写明:"埋点缓冲最后冲一次,是丢数据与不丢数据的差别。"

flush 的实现是先清缓冲、再异步上传:

// Analytics.ets:139-153
static flush(): void {
    if (Analytics.buffer.length === 0) { return; }
    const batch: TrackEvent[] = Analytics.buffer.slice();
    Analytics.buffer.length = 0;
    Analytics.lastFlushAt = Date.now();
    Analytics.uploader.upload(batch).then((ok: boolean) => {
        if (!ok) { Analytics.requeue(batch); }
    }).catch(() => { Analytics.requeue(batch); });
}

顺序很重要:slice() 取出快照 → 清空缓冲 → 再上传。若反过来(上传完成后再清),上传期间新产生的事件会被一并清掉,造成丢失。这个"先取快照再清"的模式在"批量队列"的实现里是标准做法。

③ 有上限的重入队

// Analytics.ets:155-178
private static requeue(batch: TrackEvent[]): void {
    const keep: TrackEvent[] = [];
    for (let i = 0; i < batch.length; i++) {
        const e: TrackEvent = batch[i];
        const key: string = `${e.name}#${e.timestamp}`;
        const retried: number = (Analytics.retryCounts.get(key) ?? 0) + 1;
        if (retried > Analytics.MAX_RETRY) {
            Analytics.droppedCount++;
            Analytics.retryCounts.delete(key);
            continue;
        }
        Analytics.retryCounts.set(key, retried);
        keep.push(e);
    }
    if (keep.length > 0) {
        // 重入队放在队首,保证失败批次优先补发
        Analytics.buffer = keep.concat(Analytics.buffer);
        ...
    }
}

三个细节:

  1. 用 name#timestamp 作为重试计数键。同一批次里的两条同名事件靠 timestamp 区分。TrackEvent 的构造里 timestamp = Date.now(),同毫秒同名的概率极低,够用。
  2. 重入队放在队首(keep.concat(buffer))。理由写在注释里:"保证失败批次优先补发"。放在队尾的话,失败批次会一直被新事件挤到后面,最终被 splice 丢掉。
  3. 重入队后再截断一次。keep 可能比缓冲上限还大,所以要再判一次 EVENT_BUFFER_SIZE 并 splice(EVENT_BUFFER_SIZE, overflow)(注意这里是从尾部删,与 emit 的从头部删不同——因为这个位置的意图是"保住刚补发的失败批次")。这一处的不对称是刻意的,也值得在 review 时留意。

3.5.2 参数表

参数 值 位置 说明
AppConst.EVENT_BUFFER_SIZE 200 AppConst.ets:71 缓冲上限,满则丢最旧
FLUSH_INTERVAL_MS 30000 Analytics.ets:59 时间阈值 30s
MAX_RETRY 3 Analytics.ets:67 单条事件最大重试次数
条数阈值(FLUSH_BATCH_SIZE) 20 Analytics.ets:65 与时间阈值一样是具名常量(本篇早期版本记录的"硬编码在 emit 里"已修复)
AppConst.LOG_BUFFER_SIZE 500 AppConst.ets:69 MemorySink 环形缓冲上限(本篇早期版本记录的"无任何引用"已修复,见 3.9)

EVENT_BUFFER_SIZE 与 LOG_BUFFER_SIZE 都定义在 lib_base 的 AppConst 里,而不是 lib_log 内部。这有一个实际好处:常量在依赖图的最底层,任何模块都能读到同一个值,不必为了拿一个数字而依赖 lib_log。

3.5.3 事件模型的三条纪律

// EventModel.ets:1-8
/**
 * 埋点事件模型。
 *
 * 三条纪律:
 * 1. **事件名与属性名是契约**,用常量集中定义,禁止在业务里写裸字符串 —— 否则改名时无法全局检索。
 * 2. **属性值只允许基础类型**。对象会迫使上报端做序列化,序列化会带来体积与性能不可控。
 * 3. **公共属性自动注入**,业务只填私有属性 —— 避免每个埋点都手抄 appVersion、断点档位。
 */

纪律 2 在类型层面被强制:

// EventModel.ets:63-72
export class TrackEvent {
    readonly name: string;
    readonly timestamp: number;
    readonly properties: Record<string, string | number | boolean>;
    ...
}

Record<string, string | number | boolean> 让"塞个对象进去"直接编译不过。这条限制的理由很实在:一条事件里放一个嵌套对象,上报体积就不是你能预估的了(一次误传 Approval 全量对象可能让单条事件从 200 字节变成 4KB),而埋点体积失控的代价要等到账单或性能问题出现才会发现。

EventNames 的完整清单(EventModel.ets:9-40),按域分组:

域 事件名
页面 page_view、page_leave
路由 route_push、route_pop
鉴权 login_submit、login_success、login_fail、logout
审批 approval_list_load、approval_detail_view、approval_submit、approval_submit_result、approval_withdraw
协同 device_list_view、draft_sync_push、draft_sync_conflict
性能 perf_startup、perf_network、perf_list_first_frame

注意 approval_submit 与 approval_submit_result 是两个事件:前者是"用户点了提交"(意图),后者是"提交有结果了"(结果)。分开的理由是这两者的差值就是"提交动作的完成率"——一个直接反映体验质量的指标。如果合成一个事件,这个指标就算不出来了。

事件名用 snake_case(approval_submit_result)而属性名也是 snake_case(error_code)。这是埋点系统的通行约定,好处是看板上不用做名字转换。

3.5.4 EventBuilder

// EventModel.ets:90-135
/** 事件构造器:链式补充属性,避免层层嵌套的字面量 */
export class EventBuilder {
    private readonly name: string;
    private readonly props: Record<string, string | number | boolean> = {};

    static of(name: string): EventBuilder { return new EventBuilder(name); }

    str(key: string, value: string): EventBuilder { this.props[key] = value; return this; }
    num(key: string, value: number): EventBuilder { this.props[key] = value; return this; }
    bool(key: string, value: boolean): EventBuilder { this.props[key] = value; return this; }

    /** 结果与错误码:所有"操作类"埋点都带上这两个,便于算成功率 */
    result(success: boolean, errorCode: string = ''): EventBuilder {
        this.props[EventProps.RESULT] = success ? 'success' : 'fail';
        if (errorCode.length > 0) { this.props[EventProps.ERROR_CODE] = errorCode; }
        return this;
    }

    duration(ms: number): EventBuilder {
        this.props[EventProps.DURATION_MS] = Math.round(ms);
        return this;
    }

    build(): TrackEvent { return TrackEvent.of(this.name, this.props); }
}

两个"语义化方法"值得单独说:

  • result(success, errorCode):把布尔值写成 'success' / 'fail' 字符串。为什么不用布尔?因为看板按字符串分组更自然,而且可以未来扩展成 'timeout' / 'partial' 等多值。同时它保证了 result 与 error_code 总是成对出现——result(false, 'HTTP_5XX') 这样的组合不会因为漏写而缺失。
  • duration(ms):内部 Math.round(ms)。理由是把浮点毫秒变成整数,避免埋点里出现 128.99999999999999 这种值——它在看板上做区间分组时会落到错误的桶里。

EventProps 的常量清单(EventModel.ets:43-57):page / route / duration_ms / result / error_code / trace_id / breakpoint / item_id / count / status / from / retry / device_count。注意 retry 与 trace_id 已经预留——它们是"以后要把埋点和日志关联起来"这一需求的接口位。

3.5.5 属性设计

一个埋点的属性应当能回答"在什么条件下、结果如何",而不是"把我知道的都塞进去"。三种属性各有明确职责:

类别 例子 谁填 回答什么
公共属性 app_version / os_version / device_model / device_type / env / is_release / breakpoint / session_id Analytics.enrich 自动注入 环境切片
上下文属性 page / route / from 调用点 在哪个场景下发生
结果属性 result / error_code / duration_ms / count 调用点 结果如何

这条划分的意义是避免在每个调用点重复填公共属性。AppInitializer.commonProps() 的注释说明了代价:

公共属性只算一次、注入到每条事件:appVersion / osVersion / 设备型号 / 断点档位 / 环境。这么做是为了让业务侧埋点不必手抄这些字段 —— 手抄的下场一定是某个页面漏了,而漏掉的字段事后无法补,看板只能长期缺失。

"事后无法补"是这句话的关键。app_version 若某个页面漏了,那么那一整个版本的这个页面的数据都是残缺的——而且你不可能回到过去补上。

commonProps() 里还有一句纪律提醒(AppInitializer.ets:146):"新增字段前先想清楚:它是否真的每条事件都需要?属性膨胀与埋点体积是线性关系。"

3.5.6 enrich:注入 + 脱敏

// Analytics.ets:180-196
/** 注入公共属性并脱敏 */
private static enrich(event: TrackEvent): TrackEvent {
    const merged: Record<string, string | number | boolean> = {};
    const commonKeys: string[] = Object.keys(Analytics.common);
    for (let i = 0; i < commonKeys.length; i++) {
        merged[commonKeys[i]] = Analytics.common[commonKeys[i]];
    }
    merged['session_id'] = Analytics.sessionId;
    const ownKeys: string[] = Object.keys(event.properties);
    for (let i = 0; i < ownKeys.length; i++) {
        const key: string = ownKeys[i];
        const value: string | number | boolean = event.properties[key];
        // 字符串值统一过脱敏:埋点是最容易泄漏用户信息的地方
        merged[key] = typeof value === 'string' ? Masker.text(value) : value;
    }
    return TrackEvent.of(event.name, merged);
}

三个要点:

  1. 事件私有属性覆盖公共属性(merged[key] = ... 在公共属性之后执行)。这样某个事件若真的需要覆盖 env,可以做到。默认情况下不会冲突,因为公共属性名(app_version 等)与业务属性名(page、result)没有交集。
  2. session_id 单独注入,不在 common 里。因为 common 只包含"环境类"属性(不随会话变化),而 session_id 每次冷启动都会变。把它放在 common 里会导致 refreshAnalyticsContextIfChanged 重注入时需要一个额外开关。
  3. 只有字符串值过 Masker.text,数字与布尔原样保留。这是一个性能决策:脱敏要跑 6 个正则,对 duration_ms 这种数字跑正则是纯浪费。

注意第 3 点的含义:放一个对象进属性是不可能的(类型不允许),但放一个"包含手机号的字符串"会被自动脱敏。这就是"忘记机会为零"(3.3.4)在埋点侧的落地。

3.5.7 公共属性与断点重注入

Analytics.init(sessionId, common) 是唯一的公共属性入口——没有单独的 setter。这带来一个真实问题:UIAbility.onCreate 早于 onWindowStageCreate,此时窗口尺寸还不知道,断点档位只能取默认值。

解法是一次"重注入"(AppInitializer.ets:351-370):

/**
 * 拿到真实窗口尺寸后重注入埋点公共属性。
 *
 * 为什么需要它:`Analytics` 只在 init 时接收公共属性,没有单独的 setter。
 * 而 onCreate 早于窗口就绪,此时断点只能给默认值。若不做这一步,
 * 全量事件的 breakpoint 字段会永远是默认档,看板上「大屏用户的行为」这一维度直接失效。
 * 代价是重注会重置一次 flush 计时器;因此这里用 injectedBpTag 做去重,只在档位真的变化时重注。
 */
static refreshAnalyticsContextIfChanged(breakpointTag: string): void {
    if (breakpointTag === AppInitializer.injectedBpTag) { return; }
    if (AppInitializer.sessionId.length === 0) { return; }
    AppInitializer.injectedBpTag = breakpointTag;
    Analytics.init(AppInitializer.sessionId, AppInitializer.commonProps());
    XLogger.d(AppInitializer.TAG, () => `analytics context refreshed, breakpoint=${breakpointTag}`);
}

两个细节值得学:

  1. 用 injectedBpTag 去重。窗口尺寸回调会频繁触发(折叠屏展开、分屏、旋转),若每次都 init 就会反复重置 lastFlushAt,导致时间阈值永远达不到——表现为"埋点迟迟不上报"。用"档位是否真的变了"作为判据,把调用次数从"每次尺寸变化"降到"每次跨档"。
  2. sessionId.length === 0 时直接返回。这说明 initAnalytics 还没跑(即 refreshAnalyticsContextIfChanged 被过早调用),此时什么都不做,等 initAnalytics 自己带上正确值。

3.5.8 可观测性自己的可观测性

// Analytics.ets:198-201
/** 本地排障用:当前缓冲与丢弃统计 */
static stats(): string {
    return `buffered=${Analytics.buffer.length} dropped=${Analytics.droppedCount}`;
}

droppedCount 是在 emit(缓冲溢出)、requeue(重试超限)两处累加的。这个数字本身也是可观测性系统必须能自证健康的体现:如果你不知道丢了多少,你就不知道看板上的下降是业务下降还是采集故障。

Analytics.stats() 目前没有自动上报(ConsoleUploader 只把事件打到日志)。接入真实上报通道时,建议把 droppedCount 作为一个更高优先级的事件单独上报——因为"上报通道坏了"这件事如果也走同一个坏掉的通道,就永远没人知道。


3.6 三个接入点示例

① 启动编排分段(entry)

// entry/src/main/ets/init/AppInitializer.ets:89-117(节选)
AppInitializer.syncDone = true;
const trace: LaunchTrace = AppInitializer.launchTrace();

AppInitializer.initLogging();
trace.mark('initLoggingDone');

// 埋点要在网络之前:鉴权链路的首个请求(登录/刷新)也需要能被观测到
AppInitializer.initAnalytics();
trace.mark('initAnalyticsDone');

// 鉴权提供者必须先于任何请求安装,否则 AuthInterceptor 会抛「未安装」的配置错误
AppInitializer.initAuthProvider();
trace.mark('initAuthProviderDone');

// 登录拦截器必须先于第一次跳转注册,否则「未登录也能进业务页」
AppInitializer.initRouteInterceptor();
trace.mark('initRouteInterceptorDone');

// Mock 必须在首个请求之前注册完:注册晚一步,那个请求就真的出网了
AppInitializer.initMock();
trace.mark('initMockDone');

AppInitializer.initSession(context);
trace.mark('initSessionStarted');

XLogger.must(AppInitializer.TAG, () => `sync init done: ${trace.summary()}`);

这个例子的价值在于它同时用到了三层:

  • Trace(LaunchTrace.mark)→ 量化每一步耗时;
  • XLogger.must → 保证这行摘要在任何构建里都能看到;
  • 「埋点要在网络之前」这条顺序约束 → 说明初始化顺序本身就是可观测性设计的一部分。

② 页面曝光 / 停留(LoginPage)

// feature_login/src/main/ets/view/LoginPage.ets:62-63, 240-248
/** 进入可视的时刻,与 onHidden 成对使用算停留时长 */
private enteredAt: number = 0;

// ... NavDestination 上:
.onShown(() => {
    this.enteredAt = Date.now();
    Analytics.pageView('LoginPage', RouteNames.LOGIN);
})
.onHidden(() => {
    const duration: number = this.enteredAt > 0 ? Date.now() - this.enteredAt : 0;
    Analytics.pageLeave('LoginPage', duration);
    this.enteredAt = 0;
})

页面注释解释了为什么不用 aboutToAppear(LoginPage.ets:46-50):

aboutToAppear 在首帧提交前触发,此时页面还不一定真的可见(可能立刻被上层覆盖)。曝光与停留时长必须以「真的可见」为口径,否则这两个指标都不可用。

这个坑很值得记住:aboutToAppear 是构造时机,onShown 才是可见时机。用前者统计曝光时长,会得到"包含被覆盖时间的时长",而且被覆盖的页面也会被记为一次曝光——两个指标同时失效。

enteredAt > 0 这个守卫处理"onHidden 先于 onShown 触发"的异常时序(在 NavDestination 被直接销毁时可能发生),此时 duration 记为 0 而不是 Date.now() - 0(一个天文数字)。

另外注释里明确划分了职责(LoginPage.ets:50):"路由级埋点(route_push / route_pop)由 lib_router 的 RouteTelemetry 负责,页面不重复上报。"这是一个很好的边界示例——页面只报自己的曝光,路由变化由路由库统一报。

③ 业务动作 + 结果(ApprovalCreatePage)

// feature_approval/src/main/ets/view/ApprovalCreatePage.ets:254-261
this.submitting = true;
this.errorText = '';
Analytics.emit(EventBuilder.of(EventNames.APPROVAL_SUBMIT)
    .str(EventProps.PAGE, ApprovalCreatePage.PAGE)
    .bool('urgent', this.urgent)
    .str(EventProps.STATUS, this.category)
    .result(true, '')
    .build());
// 成功分支(:284-288)
Analytics.emit(EventBuilder.of(EventNames.APPROVAL_SUBMIT_RESULT)
    .str(EventProps.ITEM_ID, created.id)
    .str(EventProps.STATUS, created.status)
    .result(true, '')
    .build());

// 失败分支(:300-303)
Analytics.emit(EventBuilder.of(EventNames.APPROVAL_SUBMIT_RESULT)
    .str(EventProps.ERROR_CODE, error.code)
    .result(false, error.code)
    .build());

四个设计点:

  1. "意图"与"结果"分成两个事件。见 3.5.3。两者的计数差 = 提交动作完成率。
  2. result(true, '') 里的空字符串:EventBuilder.result 只在 errorCode.length > 0 时写入 error_code。成功事件因此不带 error_code 属性——而不是带一个 ''。这让看板上的 error_code 分布干净(不会出现一个空字符串桶)。
  3. 失败分支先过滤取消:
// ApprovalCreatePage.ets:296-299
const error: AppError | undefined = Results.errorOrUndefined(result);
if (error === undefined || error.isCancellation) {
    return;
}

用户返回上一页触发的 CANCEL 不进埋点。若不过滤,"提交失败率"里会混进大量用户主动取消,这个指标就废了。这条纪律在 02 篇 2.5.3 讨论 onFailure 时也出现过——同一个原则在埋点与日志两处都必须执行。

  1. item_id 只在成功时才有,因为失败时服务端没返回 id。

3.7 常见误区

1. 把日志写成 XLogger.d(tag, JSON.stringify(x))

绕过了闭包延迟构造,stringify 无论级别如何都会执行。在 release 构建里日志被过滤掉了,但 CPU 开销一点没省。热路径上(列表滚动、@Computed 内)这样写会直接体现为掉帧。

2. 用日志统计成功率

日志是非结构化的、且 release 下不完整(见 3.1.3)。成功率必须走埋点。LoggingInterceptor 已经自动记录了 Analytics.network(...),不需要你再打一遍。

3. 把 Trace.begin() / end() 手写在带早退的逻辑里

begin 之后任何 return 都会让 end 永不执行,泳道图上留下一条永不闭合的段(3.4.2)。用 Trace.sync / Trace.async,它们的 try/finally 保证配对。

4. 在循环里逐条 Trace 打点

Trace 的文件头注释已经明确禁止(3.4.1)。泳道图会变得不可读,且打点成本会被乘上循环次数。正确做法是在循环外用 Trace.sync 包一次,需要项数就用 Trace.count(name, items.length)。

5. 埋点 await Analytics.flush()

emit 返回 void 是有意的(3.1.3)。flush 虽然是 void,但如果手写 await 一个上传 Promise,就等于让网络往返阻塞 UI 逻辑。正确做法是把 flush 留给"切后台 / 退出"这类生命周期节点(见 AppInitializer.shutdown)。

6. 在埋点属性里塞对象或数组

类型上不允许(Record<string, string | number | boolean>),但有人会 JSON.stringify 成字符串绕过去。后果是单条事件体积不可控(3.5.3),且这个字符串值会进入 Masker.text 的正则处理,白白付出开销。要传"多个值"就用 count 这类聚合属性。

7. page_view 挂在 aboutToAppear

见 3.6 ②。曝光与停留时长两个指标会同时失效。正确位置是 NavDestination 的 onShown / onHidden。

8. 把 page_leave 的时长算成 Date.now()(忘了减起点)

enteredAt 忘了减会得到"从 1970 年至今的毫秒数"。真实工程里 LoginPage 用 this.enteredAt > 0 ? Date.now() - this.enteredAt : 0 显式兜住了异常时序(见 3.6 ②)。写自己的页面时也要有这个守卫。

9. XLogger.must 滥用

must 绕过级别过滤,在任何构建下都输出(3.2.5)。注释写了"用时克制"。把它当普通 i 用,release 包的日志量会失控——而日志写入本身是有成本的,且会挤占 hilog 的缓冲区(导致更早的关键日志被冲掉)。

10. 在 Masker 返回的对象上做二次修改

fields() / headers() 返回新对象是刻意的(3.3.3),防止脱敏结果被写回真实缓存。如果你拿到返回的对象又去改它并期望影响原对象,行为是"静默不生效"——容易误判为 bug。

11. 新埋点直接写裸字符串事件名

EventNames 是跨模块共享契约(EventModel.ets:5)。写裸字符串的后果是"改名时无法全局检索"。工程里 ApprovalRepositoryImpl 因为一个实验性指标尚未进评审,用了模块内常量 EVENT_CONTRACT_DRIFT = 'contract_drift' 并在注释里写明了原因与后续动作(02 篇 2.9.4)——这才是写裸字符串的正确姿势:可以是暂时的,但必须留下"为什么暂时、怎么收敛"的说明。

12. 认为 EnvConfig.IS_RELEASE 是运行时开关

它是被 inject-version.mjs 在构建前就地改写的一个字面量(3.2.4)。不要在运行时改它(static readonly 也改不了),也不要写"根据某个配置决定是否 release"的逻辑——构建模式必须在编译期确定,否则 release 包会残留 debug 分支。


3.8 实践任务

任务 1:验证闭包延迟构造真的省掉了开销

  1. 在 lib_log/src/main/ets/log/XLogger.ets 的 emit 里,于 if (level < XLogger.effectiveLevel()) 之前插入一行计数(例如 XLogger.buildCalls++,用一个 static 字段);
  2. 在 emit 的 build() 调用处再插一行计数(例如 XLogger.buildInvoked++);
  3. 在审批列表上快速滚动 10 秒(这会大量触发 LoggingInterceptor / ViewModel 的 d 级日志);
  4. 用 XLogger.must 打出两个计数值,观察它们是否相差数百倍;
  5. 把 XLogger.setLevel(LogLevel.NONE) 后再滚 10 秒,确认 buildInvoked 完全没有增长。

验收

  • [ ] 默认(DEBUG 级别)下 buildCalls ≈ buildInvoked
  • [ ] 设为 NONE 后 buildInvoked 不再变化,而 buildCalls 仍在增长
  • [ ] 能指出若把某处日志从闭包写法改成直接传字符串,哪个计数会变化

交付物:两张计数对照表(默认级别 / NONE 级别),以及一句结论。

任务 2:给一个新页面接全三级可观测性

为一个新的"报表页"接入:

  1. Trace:用 Trace.async('report:firstPaint', ...) 包住首次数据加载;用 Trace.count('report:rowCount', n) 记录渲染行数;
  2. XLogger:在数据加载成功/失败处各写一条日志,tag 用 tagOf;
  3. Analytics:pageView / pageLeave 挂 onShown / onHidden;在 EventNames 里新增一个 report_load 事件,属性包含 page / duration_ms / count / result / error_code。

验收

  • [ ] pageLeave 的时长与你在页面上实际停留的时间一致(不是天文数字)
  • [ ] onHidden 里做了 enteredAt > 0 的守卫
  • [ ] 失败分支过滤了 error.isCancellation
  • [ ] 新增的事件名与属性名都是 EventNames / EventProps 里的常量
  • [ ] 属性值全部是基础类型

交付物:页面代码片段(约 60 行)+ 一段说明"为什么这条 Trace 用的是 async 而不是 sync"。

任务 3:压出一次真实的缓冲丢弃

  1. 在 AppInitializer.initAnalytics 之后调用 Analytics.setUploader({ upload: async () => false })(让上传永远失败);
  2. 循环调用 Analytics.track(EventNames.PAGE_VIEW, (b) => b.str(EventProps.PAGE, p${i})) 3000 次;
  3. 用 XLogger.must 打出 Analytics.stats();
  4. 观察 buffered 是否停在 200、dropped 是否在增长;
  5. 把上传器改回 ConsoleUploader,调一次 Analytics.flush(),确认缓冲被清空。

验收

  • [ ] buffered 上限为 AppConst.EVENT_BUFFER_SIZE(200)
  • [ ] 能解释为什么重试超限后 droppedCount 增长但 buffered 不再增长
  • [ ] 能指出重入队时"重入队放队首"这个选择避免了什么问题

交付物:三组 Analytics.stats() 输出(丢弃前 / 丢弃后 / 恢复上传后)+ 一段分析。


3.9 对表记录:发现的实现缺陷

编写本篇时逐行核对过 lib_log 的全部实现与 lib_base 的相关常量,早期版本曾记录 8 处不一致。8 处均已修复,留作记录(现状行号以当前代码实测为准):

# 位置 当时的问题 现状
1 lib_base/.../AppConst.ets:65 LOG_BUFFER_SIZE = 500 全仓无任何引用,MemorySink 实为无上限数组 已实现环形截断:MemorySink.write 超限丢最旧(XLogger.ets:71-72),常量移至 AppConst.ets:69 并补了用途注释
2 lib_log/.../Masker.ets:49 tokenFingerprint 前缀写作 sha256:,实际是 FNV-1a 32 位散列 前缀已改为 fnv1a32:(Masker.ets:56),注释明确警告非密码学用途(见 3.3.5)
3 lib_base/.../AppConst.ets:24 注释写 scripts/inject-version.sh,实际是 .mjs 注释已改为 .mjs
4 lib_log/.../XLogger.ets:80-81 注释宣称"编译期常量短路、连函数调用都省掉",与实际行为不符 注释已修正(XLogger.ets:91-100):明确说明这不是编译期短路,函数调用与级别判断始终发生,被省掉的只有闭包构造成本,并给出调用点自行包编译期开关的写法
5 scripts/ci.sh、scripts/inject-version.mjs 没有任何环节校验 EnvConfig.CURRENT,release 包可能带着全套 Mock 规则发布 门禁已存在:inject-version.mjs 的 gateReleaseEnv()(读 AppConst 的 CURRENT_ENV_RE 精确匹配环境字面量,非 PROD 一律 exit 1,逃生开关 --allow-mock-in-release,另有 --check-release-env 单独运行入口);ci.sh 第 6 阶段(共 10 阶段)在 release 构建前调用它——环境不对,任何构建都不会发生
6 lib_log/.../Analytics.ets:95 条数阈值 20 硬编码,同类阈值都是具名常量 已提为 private static readonly FLUSH_BATCH_SIZE = 20(Analytics.ets:65),与 FLUSH_INTERVAL_MS 并排声明
7 全仓 XLogger.* 调用 13 处裸字符串 tag(含不符合 模块:类 形状的 'analytics') 已全部收敛:重新统计为 248 处调用、0 处裸字符串;ConsoleUploader 已用 XLogger.tagOf('log', 'ConsoleUploader')(Analytics.ets:17)
8 feature_approval/.../ApprovalListViewModel.ets @Monitor('state') 监听所有状态迁移,中间态也产生 result=success、duration_ms≈0 的事件,抬高事件数、拉低平均耗时 已只上报终态:onStateChanged 先判 isTerminal(SUCCESS / EMPTY / FAILURE)再打点(ApprovalListViewModel.ets:157-183),事件里还补了 from(迁移前状态)字段

当年第 5 条(发布安全)与第 8 条(数据可信度)是最优先的两项,如今都已是工程里的真实防线——第 5 条的细节见第 8 章的发布环境门禁一节。


3.10 延伸阅读

  • 官方文档 → 性能分析 → @ohos.hilog 日志打印 —— 理解 %{public}s 等隐私标记与 domain 的官方语义,对照 3.2.2 ②
  • 官方文档 → 性能分析 → @ohos.hiTraceMeter 打点 —— startSyncTrace / finishSyncTrace / traceByValue 的语义与抓取方式,对照 3.4
  • 官方文档 → 性能分析 → 应用性能分析工具(DevEco Profiler)—— 理解 trace 文件与泳道图的消费方式
  • 官方文档 → 应用框架 → ArkUI → NavDestination → onShown / onHidden 生命周期 —— 对照 3.6 ②
  • 官方文档 → 应用框架 → ArkUI → 状态管理 V2 → @Monitor 与 IMonitor —— 理解 @Monitor('state') 的触发时机与 3.9 第 8 条"只上报终态"的过滤写法
  • 教程 docs/11/02-network-result-error.md 2.3.4 / 2.9 —— Masker 与 Analytics 在网络层的被调用视角
  • 教程 docs/11/01-architecture-modules.md 1.5 —— lib_log 在依赖图中的位置与"不许反向依赖"的约束
  • 教程 docs/11/04-login-auth.md —— LogoutBus.broadcast 的 XLogger.must 与 Analytics.track(EventNames.LOGOUT) 的成对使用
  • 教程 docs/10-testing-release.md —— MemorySink 在单测中断言日志的用法
  • 教程附录 C —— 可观测性 / 埋点 / 打点 等术语的统一表述

导航

上一章:11/02 网络层与统一错误处理 | 下一章:11/04 登录鉴权

📄 第 11 章 登录鉴权与会话管理 04-login-auth.md

涉及模块:lib_auth(HAR)、feature_login(HAR)、lib_router(HAR)、entry(HAP)。 前置章节:04 页面路由与生命周期、06 数据持久化与网络请求、11/02 网络层与统一错误处理。

登录看起来是"一个表单 + 一次请求",实际它是全工程唯一一处同时动到持久化、内存状态、网络拦截器、路由栈、Ability 生命周期的功能。任何一处漏掉,症状都不是"登录失败",而是:

  • 冷启动闪一下登录页(路由栈被写坏);
  • 请求偶发 401 后"莫名其妙被登出"(并发刷新把轮换过的 refreshToken 用废了);
  • 退出登录后另一位用户看到了上一个人的消息(通知栏没清);
  • 用户点了两次登录,栈里留下两个主框架(返回键穿透)。

本章把这条链路拆成 8 段,每段给出"为什么是这个写法"以及"换成直觉写法会怎样"。


4.0 本章目标

读完并完成实践任务后,你应当能够:

  1. 画出 Token 从登录到失效再到清理的完整生命周期,并指出每一跳的落点(内存 / 磁盘 / 服务端);
  2. 解释 TokenStore 为什么必须同时维护内存镜像与 Preferences,以及不这么做会具体坏在哪一行;
  3. 实现 一个"并发 401 只刷一次"的单飞刷新器,并说明为什么清理 pending 不能写在 finally 里;
  4. 接上 路由拦截:让未登录用户进不了业务页,且登录页自身不会被拦成死循环;
  5. 定位 "冷启动闪登录页"的根因,并用"发起不 await + 首帧后消费 Promise"的方式修掉;
  6. 诚实说明 登录成功回跳的当前降级路径与其边界(白名单 + 落 MAIN),而不是假装它已经完美回跳。

4.1 一条链路上的五个落点

先把全貌铺开。下面这张表是本章的骨架,后面每一节都在展开其中一行。

# 阶段 触发者 落点 关键约束
1 登录 LoginPage.onSubmit → AuthRepository.login 服务端换取 TokenPair needAuth: false:此时可能还残留上一个账号的过期令牌
2 落令牌 TokenStore.save() 内存镜像(同步可见)+ Preferences(异步落盘) 先写内存再落盘
3 落会话 SessionStore.applyLogin() AppStorageV2 全局单例 必须在令牌之后,isLoggedIn 的语义依赖"令牌可续期"
4 携带 AuthInterceptor.before 请求公共头 同步读内存镜像,绝不 await 读盘
5 续期 TokenRefresher.refreshToken() 单飞刷新 + 轮换兼容 清理 pending 排在交付之后
6 失效 TokenRefresher.onUnauthorized store.clear() → SessionStore.reset() → LogoutBus.broadcast() 一次性闸门,一次 401 风暴只清一次
7 广播 根组件 @Monitor('logoutBus.logoutEpoch') Router.clear() → replace(LOGIN) 幂等,两条登出路径可能同时到达
8 冷启动恢复 AppInitializer.initSession → Index.onPageShow 磁盘快照 → 内存 发起不 await,消费在首帧之后

术语 单飞(single flight):多个并发调用共享同一个在途 Promise,得到一个结果。工程里用在令牌刷新(TokenRefresher)与配置加载上,避免"同一件事做 N 次"。

版本门槛 AppStorageV2.connect() 是状态管理 V2 的应用级共享机制,API 11 起可用;@ObservedV2 / @Trace / @Monitor 在 API 12 起完整可用。本工程兼容下限为 6.0.0(20),全部满足。


4.2 TokenStore:为什么必须存两份

TokenStore 这个类如果只做一件事,那就是给同步接口提供数据源。看它的使用方:

// lib_network/src/main/ets/interceptor/AuthInterceptor.ets —— 契约形状(节选)
export interface AuthTokenProvider {
    /** 同步读取当前访问令牌。注意:没有 Promise,也不允许有 */
    currentToken(): string;
    /** 是否值得"先刷再用" */
    shouldRefresh(): boolean;
    refreshToken(): Promise<boolean>;
    onUnauthorized(reason: string): void;
}

currentToken() 是同步的,这是刻意的设计:拦截器 before() 是纯函数式的注入动作,如果它变成 async,那么每个请求的发起都会多一次微任务调度,且"注入头"与"发起请求"之间会出现可观测的空窗 —— 期间若用户点了登出,头里带的还是旧令牌。

但 HarmonyOS 的 preferences 只有异步 API。两者只能靠内存镜像弥合:

// lib_auth/src/main/ets/token/TokenStore.ets(节选,含注释原文)
export class TokenStore {
    private static readonly STORE_NAME: string = 'officeflow_auth';
    /** 提前刷新窗口:到期前 60 秒就算"需要刷新" */
    static readonly REFRESH_AHEAD_MS: number = 60 * 1000;
    /** 服务端未下发 expiresIn 时的兜底有效期(秒)。宁短勿长,避免用到已被轮换的令牌 */
    private static readonly FALLBACK_TTL_SECONDS: number = 2 * 60 * 60;

    // ---- 内存镜像 ----
    private access: string = '';
    private refresh: string = '';
    private expires: number = 0;
    private user: UserInfo = UserInfo.empty();
}

三条硬约束

约束一:写入顺序是"先内存、后磁盘",不可交换。

async save(tokens: TokenPair, user: UserInfo): Promise<Result<void>> {
    const expiresAt: number = Date.now() + ttlSeconds * 1000;
    // 1. 内存镜像(同步可见)
    this.access = tokens.accessToken;
    this.refresh = tokens.refreshToken;
    this.expires = expiresAt;
    this.user = user;
    // 2. 落盘
    await store.put(TokenStore.K_ACCESS, this.access);
    // ...
    await store.flush();
}

反过来的话,await store.flush() 期间发起的请求会读到旧令牌(或空令牌),拦截器据此判定"未登录"并直接返回 401 —— 一次登录成功后立刻触发的首页数据请求会偶发失败。这个 bug 在真机上极难复现(取决于磁盘速度),但在低端机上会稳定出现。

约束二:扁平 key 而不是整块 JSON。

private static readonly K_ACCESS: string = 'accessToken';
private static readonly K_REFRESH: string = 'refreshToken';
private static readonly K_EXPIRES_AT: string = 'expiresAt';
private static readonly K_USER_ID: string = 'userId';
private static readonly K_USER_NAME: string = 'userName';
private static readonly K_AVATAR: string = 'avatarUrl';
private static readonly K_TENANT: string = 'tenantId';

如果存成一个 TokenSnapshot 的 JSON 字符串,updateUser() 就必须"读出全部 → 改一个字段 → 写回全部",任何一次读改写之间进程被杀,都会丢掉别的字段。扁平 key 让"改资料"与"换令牌"能各自只动自己那几个 key —— 这也是为什么 save() 与 updateUser() 是两个入口,而不是一个带可选参数的 save()。

约束三:日志只出指纹。

XLogger.i(this.tag, () => `saved tokens access=${Masker.tokenFingerprint(tokens.accessToken)}` +
    ` refresh=${Masker.tokenFingerprint(tokens.refreshToken)} ttl=${ttlSeconds}s userId=${user.id}`);

Masker.tokenFingerprint() 输出形如 sha256:xxxx(len) 的指纹。它能回答排障时真正需要的问题("这两次请求带的是不是同一个 token"),而泄漏风险为零。注意:令牌不能只在 Masker 里脱敏,因为 Masker 是"调用方主动调用"的工具,不是自动过滤器 —— 所以纪律必须落在 TokenStore 自身:这个文件里不允许出现任何直接打印令牌的语句。

幂等安装

static async install(context: common.Context): Promise<Result<TokenStore>> {
    const existing: TokenStore | undefined = TokenStore.shared;
    if (existing !== undefined && existing.isReady()) {
        return new Success<TokenStore>(existing);
    }
    const instance: TokenStore = existing === undefined ? new TokenStore() : existing;
    return TokenStore.awaitInstall(instance, context);
}

install() 会在 Ability.onCreate 里被调用,而 Ability 可能被系统重建(多窗口、回收后恢复)。如果不做幂等,就会出现两个 TokenStore 实例各自持有内存镜像:A 实例写入了令牌,B 实例读到的还是空 —— 表现为"登录成功但首页仍然 401",且两边日志都正常。

误区 UIAbilityContext 存成静态字段。TokenStore 明确不这么做 —— Context 活着就等于整个 UIAbility 活着,页面销毁后不释放,是典型的内存泄漏。它只在 install() 时取一次 Preferences 句柄,句柄本身不持有 UI 上下文。


4.3 SessionStore:全应用唯一的登录态来源

TokenStore 解决"令牌在哪",SessionStore 解决"UI 怎么知道登录态变了"。

// lib_auth/src/main/ets/session/SessionStore.ets(结构)
export const SESSION_STORE_KEY: string = 'session';

@ObservedV2
export class SessionStore {
    @Trace isLoggedIn: boolean = false;
    @Trace userId: string = '';
    @Trace userName: string = '';
    @Trace avatarUrl: string = '';
    @Trace tenantId: string = '';

    static connect(): SessionStore {
        // AppStorageV2.connect(SessionStore, SESSION_STORE_KEY, () => new SessionStore())
    }

    applyLogin(user: UserInfo): void { /* ... */ }
    applySnapshot(snapshot: TokenSnapshot): void { /* ... */ }
    applyProfile(user: UserInfo): void { /* ... */ }
    reset(): void { /* ... */ }
    hasAvatar(): boolean { /* ... */ }
    describe(): string { /* ... */ }
}

要点有三条:

1. 它只有一份。 AppStorageV2.connect() 按 key 返回同一实例,因此任何模块 SessionStore.connect() 拿到的都是同一个对象。这解决了"设置页认为已登录、工作台认为未登录"这类因实例分裂导致的幻觉。

2. 它用 @Trace 而不是普通字段。 因为根组件的登出兜底依赖它:

// entry/src/main/ets/pages/Index.ets(节选)
@Monitor('session.isLoggedIn')
onLoginStateChanged(monitor: IMonitor): void {
    if (this.session.isLoggedIn) {
        // 重新登录成功后解锁,允许下一次登出再次复位
        this.redirectingToLogin = false;
        return;
    }
    XLogger.must(TAG, () => `session cleared, dirty=${monitor.dirty.join(',')}`);
    this.goLogin('session_cleared');
}

术语 @Monitor('a.b') 的属性路径观察:路径上每一层都必须是可观测对象。session 是 @Local,isLoggedIn 在 @ObservedV2 类里标了 @Trace,链路才成立。写成 @Monitor('isLoggedIn') 不会报错,但永远不会触发 —— 这是 V2 里最难查的一类"没反应"。

3. 它是"运行期状态",不是"真相"。 磁盘上的真相在 TokenStore 的 Preferences 里。冷启动时由 AuthRepository.restore() 从磁盘回填:

// lib_auth/src/main/ets/repo/AuthRepository.ets(节选)
async restore(): Promise<Result<boolean>> {
    const store: TokenStore | undefined = TokenStore.get();
    if (store === undefined) {
        XLogger.w(this.tag, () => 'restore skipped: TokenStore not installed');
        return new Success<boolean>(false);
    }
    const loaded: Result<TokenSnapshot> = await store.load();
    if (loaded instanceof Failure) {
        return new Failure<boolean>((loaded as Failure<TokenSnapshot>).error);
    }
    const snapshot: TokenSnapshot = (loaded as Success<TokenSnapshot>).value;
    SessionStore.connect().applySnapshot(snapshot);
    return new Success<boolean>(snapshot.accessToken.length > 0 || snapshot.refreshToken.length > 0);
}

注意 restore() 刻意不刷新令牌。启动路径上的任何网络调用都会直接拉长首帧;令牌续期由拦截器在"真正要发请求时"按需完成(见 4.5)。


4.4 LogoutBus:为什么是计数器而不是布尔

登出需要"广播一次",直觉写法是一个布尔 isLoggedOut,根组件 @Monitor 它。这个写法有一个致命缺陷:第二次登出不会触发变化。

// lib_auth/src/main/ets/session/LogoutBus.ets(结构)
export const LOGOUT_BUS_KEY: string = 'logoutBus';

@ObservedV2
export class LogoutBus {
    @Trace logoutEpoch: number = 0;      // 单调递增的计数器
    @Trace lastReason: string = '';
    @Trace lastLogoutAt: number = 0;

    static connect(): LogoutBus { /* AppStorageV2.connect(LogoutBus, LOGOUT_BUS_KEY, ...) */ }

    broadcast(reason: string): void {
        this.logoutEpoch = this.logoutEpoch + 1;
        this.lastReason = reason;
        this.lastLogoutAt = Date.now();
        // Analytics.track(EventNames.LOGOUT, ...) —— 全应用只在这里上报一次
    }

    get epoch(): number { return this.logoutEpoch; }
}

用计数器的三个理由:

  1. 每次登出都是"新事件"。 用户登录 → 登出 → 再登录 → 再登出,第二次登出时 logoutEpoch 从 1 变成 2,@Monitor 照样触发。布尔值第二次不会发生变化,根组件不会复位导航 —— 用户停在业务页,而会话已经清了。
  2. 能区分"两条路径同时到达"。 显式登出与 401 被动失效都走 broadcast(),两者可能是同一件事的两面。计数器让"发生了两次"这件事可观测,而布尔会把它们折叠成一次。
  3. @Monitor 的语义就是"值变了"。 计数器天然符合这个语义。

误区 在根组件里重复上报 LOGOUT 事件。LogoutBus.broadcast() 内部已经上报过一次,Index.onLogoutBroadcast 里刻意不再上报。埋点重复上报是最隐蔽的数据污染源:看板上的登出次数会翻倍,而两边代码看起来都"应该上报",很难看出是同一个动作。


4.5 401 单飞:并发风暴下的唯一正确解法

问题是什么

一次冷启动或一次返回前台,很可能有 5~10 个并发请求同时收到 401。如果每个请求各自触发刷新:

  • 产生 N 次无谓的刷新请求;
  • 更致命的是服务端 refreshToken 是轮换的:第 1 次刷新成功后旧 refreshToken 立即失效,后 4 次拿着旧值去刷必然失败,于是触发 onUnauthorized —— 用户"莫名其妙被登出"。

单飞怎么实现

// lib_auth/src/main/ets/token/TokenRefresher.ets(节选)
refreshToken(): Promise<boolean> {
    const inflight: Promise<boolean> | undefined = this.pending;
    if (inflight !== undefined) {
        XLogger.i(this.tag, () => 'refresh already in flight, joining the same promise');
        return inflight;
    }
    const started: Promise<boolean> = this.runRefresh();
    this.pending = started;
    // 清理排在"结果已交付"之后;身份校验避免误清新一轮
    started.then((okFlag: boolean) => {
        this.release(started, okFlag);
    }, (e: Error) => {
        XLogger.e(this.tag, () => `refresh promise rejected unexpectedly: ${e.message}`);
        this.release(started, false);
    });
    return started;
}

private release(promise: Promise<boolean>, success: boolean): void {
    if (this.pending === promise) {
        this.pending = undefined;
    }
    // ...
}

两个容易写错的细节

细节一:清理 pending 不能写在 finally 里。

如果写成:

// ❌ 错误写法
const started = this.runRefresh().finally(() => { this.pending = undefined; });

finally 的回调发生在 Promise 交付给调用方之前。极端时序下(微任务被插队),会出现"上一个刷新还没交付、下一个已经开始",重新引入并发刷新。正确做法是用 then 把清理排在交付之后,并用 this.pending === started 做身份校验,保证不会把"新一轮的 pending"误清掉。

细节二:防御性地处理 Promise reject。

runRefresh 内部已经 catch 了所有异常,但 then 的第二个参数还是写了。理由写在注释里:任何漏网异常都不能让 pending 永久卡住 —— 一旦卡住,后续所有刷新都会"加入一个已经死掉的 Promise"而永久返回 false,症状是"登录过期后无论怎么重试都恢复不了,只能杀进程"。

一次性闸门

onUnauthorized(reason: string): void {
    if (this.unauthorizedHandled) {
        XLogger.d(this.tag, () => `onUnauthorized ignored (already handled): ${reason}`);
        return;
    }
    this.unauthorizedHandled = true;
    XLogger.must(this.tag, () => `unauthorized, purging session. reason=${reason}`);
    this.purge(reason).then(() => {
        // 先清数据、再广播:根组件收到信号时状态已经干净
    }).catch((e: Error) => {
        XLogger.e(this.tag, () => `purge session failed: ${e.message}`);
    });
}

契约要求 onUnauthorized "只应触发一次":一次 401 风暴里 AuthInterceptor 与 RcpClient 都会调它,若不去重会清 5 次存储、广播 5 次登出、跳 5 次登录页。刷新成功后复位(this.unauthorizedHandled = false),使下一段会话仍能被正常登出。

轮换兼容

/**
 * 轮换兼容:服务端可能不返回新的 refreshToken(未开启轮换),
 * 此时必须**保留旧的**。直接覆盖会让下一次刷新必然失败 —— 这是很常见的坑。
 */
const effectiveRefresh: string = pair.refreshToken.length > 0 ? pair.refreshToken : refresh;
const merged: TokenPair = new TokenPair(pair.accessToken, effectiveRefresh, pair.expiresIn);

还有一个容易被忽略的点:刷新时用 store.currentUser() 传用户信息,而不是空 UserInfo。/auth/refresh 只返回令牌,如果顺手传个空的用户对象进去,就会把昵称、头像、tenantId 全部清掉。

拦截器侧怎么配合

// lib_network/src/main/ets/interceptor/AuthInterceptor.ets(行为契约)
// before():  注入公共头;needAuth === false 时跳过;幂等请求做 preflight 刷新
// onError(): 识别 401 → 标记 MARK_REFRESHED → retryPlan.requireRefresh

分工是清晰的:AuthInterceptor 决定"这次要不要刷、刷完要不要重放",TokenRefresher 决定"怎么刷、失败后怎么办"。AuthInterceptor 绝不自己拼刷新请求 —— 否则单飞就白写了。

版本门槛 rcp(@kit.RemoteCommunicationKit)的拦截器机制(interceptors 数组 + before/onError 钩子)是 API 12 起的常规能力。旧写法 @ohos.net.http 没有等价的拦截器链,迁移时这部分需要重写而非平移。


4.6 路由拦截:把判断放在入口,而不是每个页面

为什么不写在页面里

最常见的写法是在每个受保护页面的 aboutToAppear 里判断"没登录就跳登录页"。三个问题:

  1. 页面会先渲染一帧再被跳走,用户看到内容闪现(未授权的数据可能一闪而过);
  2. 每加一个受保护页面就要记得写一遍,漏一个就是越权入口;
  3. "未登录"的判定条件(令牌存在 / 过期 / 被顶下线)散落各处,规则升级要全仓改。

放到跳转的入口上统一处理,三件事一次解决:

// lib_router/src/main/ets/interceptor/RouteInterceptor.ets(节选)
export interface RouteInterceptor {
    readonly name: string;
    canNavigate(to: string, param?: object): boolean;
    onBlocked(to: string): void;
}

canNavigate 只回答"能不能跳",onBlocked 才做"怎么补救"。这个拆分是刻意的:拦截与补救是两件事,混在一起会让"拦下来之后跳哪里"的决策逻辑无法被单独测试。

白名单:避免死循环

canNavigate(to: string, param?: object): boolean {
    if (this.provider.isLoggedIn()) {
        return true;
    }
    // 白名单:登录页自身也不能被拦,否则 onBlocked 里跳登录会再次触发拦截 → 死循环
    if (RouteNames.isPublic(to)) {
        return true;
    }
    this.pendingRoute = to;
    this.pendingParam = param;
    XLogger.i(LoginInterceptor.TAG, () => `blocked '${to}': not logged in`);
    return false;
}

RouteNames.PUBLIC 目前只有 ['Login'] 一条。漏掉这一条的后果是栈溢出式死循环:跳登录页 → 被拦 → 再跳登录页 → …… 现象是应用卡死或疯狂刷新,且日志里全是同一行。

lib_router 不认识 lib_auth

这是一个依赖方向问题。lib_router 只依赖 lib_base / lib_log,如果让它直接调 SessionStore.connect().isLoggedIn,路由库就绑死了某一种会话实现,也把"契约层"拖进了业务层。解法是在 entry 里放一个 5 行的适配器:

// entry/src/main/ets/init/AppInitializer.ets(节选)
class SessionLoginStateProvider implements LoginStateProvider {
    isLoggedIn(): boolean {
        return SessionStore.connect().isLoggedIn;
    }
}

entry 是唯一同时能看到 lib_router 与 lib_auth 的地方,所以"把会话状态接到路由拦截"这件事必须发生在这里。

导航执行器:打断循环依赖

export interface RouteNavigator {
    push(name: string, param?: object): void;
}

Router 需要依赖 InterceptorRegistry(跳转前查拦截器),如果拦截器又反向依赖 Router,两个文件之间就形成模块级循环依赖。循环依赖在 ArkTS 里不一定立刻报错,但会让"先加载谁"变得不确定,是极难排查的偶发问题。注入一个只有 push 的窄接口,依赖方向就永远是单向的。


4.7 冷启动不闪登录页

症状与根因

症状:已登录用户冷启动时,先看到登录页,然后(有时)被弹回主框架,有时就停在登录页。

根因有两层:

  1. 时序:SessionStore 是空的,而回填磁盘快照是 IO。如果在 aboutToAppear 阶段就读 isLoggedIn,读到的必然是 false。
  2. 不可自愈:登录页不会自己发现"其实已登录"(它的 aboutToAppear 只做同步准备,不发请求)。所以这一次错误跳转是永久性的。

三步解法

第一步:会话恢复发起即返回(不 await)。

// entry/src/main/ets/init/AppInitializer.ets(节选)
private static initSession(context: common.Context): void {
    AppInitializer.sessionReadyPromise = AppInitializer.restoreSession(context);
}

private static async restoreSession(context: common.Context): Promise<boolean> {
    try {
        await TokenStore.install(context);
        await AuthRepository.shared().restore();
    } catch (e) {
        // 恢复失败一律降级为"未登录":用户可以重新登录,
        // 但绝不能让这个 Promise 以 reject 结束 —— 根组件的落地逻辑在等它,
        // 一旦 reject,应用会永远停在启动占位页(比闪一下登录页严重得多)。
        XLogger.w(AppInitializer.TAG, () => `session restore failed: ${(e as Error).message}`);
    }
    const loggedIn: boolean = SessionStore.connect().isLoggedIn;
    XLogger.i(AppInitializer.TAG, () => `session restore finished, loggedIn=${loggedIn}`);
    return loggedIn;
}

static sessionReady(): Promise<boolean> {
    const pending: Promise<boolean> | undefined = AppInitializer.sessionReadyPromise;
    return pending === undefined ? Promise.resolve(SessionStore.connect().isLoggedIn) : pending;
}

第二步:落地决策放在首帧之后。

// entry/src/main/ets/pages/Index.ets(节选)
onPageShow(): void {
    this.enteredAt = Date.now();
    Analytics.pageView('Index', 'root');
    if (this.landing) {
        return;
    }
    this.landing = true;
    AppInitializer.sessionReady().then((loggedIn: boolean) => {
        if (loggedIn) {
            // 已登录:直接落主框架,且用 replace —— 根路径不该留在栈里
            Router.replace(RouteNames.MAIN);
            return;
        }
        this.goLogin('cold_start');
    });
}

两个细节:

  • 为什么用 onPageShow 而不是 aboutToAppear:aboutToAppear 阶段 Navigation 尚未挂载,此时 push 存在时序竞争;onPageShow 一定在首帧之后触发,栈已可用。
  • 为什么用 replace 而不是 push:push 会把 MAIN 压在"无路由的根占位页"之上,用户按返回键会回到启动占位页(一个 LoadingProgress),而不是退出应用。

第三步:landing 标记保证只落地一次。 onPageShow 会被多次触发(从后台返回、页面被覆盖后恢复),不做标记会反复 replace —— 每次 replace 都会重建页面,用户正在填的表单会被清空。

复位到登录页要幂等

private goLogin(reason: string): void {
    if (this.redirectingToLogin) {
        XLogger.d(TAG, () => `already redirecting to login, skip (${reason})`);
        return;
    }
    this.redirectingToLogin = true;
    const from: string = Router.attached ? Router.currentName : '';
    XLogger.must(TAG, () => `reset to login, reason=${reason}, from=${from}`);
    Router.clear();
    const param: LoginParam = { from: from };
    Router.replace(RouteNames.LOGIN, param);
}

clear() 与 replace() 必须成对:

  • 只 clear 不 push 会留下一个空的导航容器(白屏);
  • 只 push 不 clear 则栈里残留的业务页仍持有旧会话的数据,用户按返回就能看到。

两条登出路径(显式登出广播 / 会话被清空兜底)可能几乎同时到达,所以还需要 redirectingToLogin 把跳转做成幂等。


4.8 登录成功后的回跳:当前的降级路径与它的边界

这是本章必须诚实说明的一段,因为它是并行开发中真实存在的接口缺口,而不是"设计上如此优雅"。

设计意图

LoginInterceptor 保存了被拦下时的原始参数:

/** 待回跳的目标路由名;空串表示没有待回跳目标 */
private pendingRoute: string = '';
/** 待回跳的原始参数,登录后必须原样带上,否则详情页会因为缺 id 打不开 */
private pendingParam: object | undefined = undefined;

resumeAfterLogin(): boolean {
    const target: string = this.pendingRoute;
    if (target.length === 0) {
        return false;
    }
    const param: object | undefined = this.pendingParam;
    this.clearPending();
    XLogger.i(LoginInterceptor.TAG, () => `resume to '${target}' after login`);
    // navigator.push(target) / navigator.push(target, param)
    return true;
}

AppInitializer.initRouteInterceptor() 的注释也写着"登录成功后由 LoginInterceptor.resumeAfterLogin() 回跳"。

现实

LoginPage 并没有调用 resumeAfterLogin()。 它走的是另一条路:

// feature_login/src/main/ets/view/LoginPage.ets(节选)
private resolveRedirect(): string {
    const param: LoginParam | undefined = Router.getParam<LoginParam>();
    if (param === undefined || param.from === undefined) {
        return '';
    }
    const target: string = param.from;
    return REDIRECT_WHITELIST.indexOf(target) >= 0 ? target : '';
}

private async onSubmit(): Promise<void> {
    const succeeded: boolean = await this.vm.submit(context);
    if (!succeeded) {
        return;
    }
    const redirect: string = this.resolveRedirect();
    if (redirect.length > 0) {
        Router.replace(redirect);
        return;
    }
    Router.replace(RouteNames.MAIN);
}

白名单只有 5 条:

const REDIRECT_WHITELIST: string[] = [
    RouteNames.MAIN,
    RouteNames.APPROVAL_LIST,
    RouteNames.MESSAGE_LIST,
    RouteNames.COLLAB_DEVICES,
    RouteNames.SETTINGS
];

为什么是这样(不是懒,是依赖方向限制)

LoginInterceptor 实例由 entry 私有持有:

  • lib_auth 不依赖 lib_router(oh-package.json5 里没有它);
  • feature_login 是 feature 层,不能反向依赖 entry。

因此登录页只能按 LoginParam.from 的路由名回跳,拿不到拦截器里那份 pending 参数。工程文件把这个限制写在明处:

/**
 * ## 已知限制(联调项)
 * 拦截器内部保存了被拦下时的**原始参数**(如详情页的 id),但那份 pending 状态
 * 只存在于 LoginInterceptor 实例里,而实例由 entry 私有持有 ...
 * 正解是让 lib_router 暴露一个全局的 pending 恢复入口(或在 lib_router 内完成拦截器注册),
 * 属于 lib_router 的接口演进,不在本次任务范围内 —— 这里先用白名单 + 降级到 MAIN 兜住。
 */

为什么白名单只放"无必填参数"的路由

  • ApprovalDetail 需要 id、ApprovalCreate 可选 draftId。回跳到这类路由必须带上原始参数,而原始参数拿不到。回跳过去只会得到一个空参数的详情页,比不回跳更糟。
  • 拿不到原始参数时落到 MAIN 是安全且可用的降级:用户从工作台再点进去,比停在一个缺 id 的详情页要好得多。

白名单的第二个理由:开放重定向

LoginParam.from 来自路由参数,而路由参数在深链 / 外部拉起场景下是不可信输入。不做校验就等于把"跳到哪里"交给了外部。所以这里不是"可选优化",而是安全边界。

与 Ability 级深链的关系:两条独立的落地链路

上面讲的是"登录拦截 → 登录成功后的页内回跳"。还有一条独立的链路:点击通知 / 外部深链在 Ability 层拉起应用,由 entry/src/main/ets/entryability/EntryAbility.ets 的 resolveDeepLink(target)(EntryAbility.ets:220-241)统一落地:

  • 未登录 → 一律 Router.replace(RouteNames.LOGIN),不允许"未登录先看到业务页"的越权展示;
  • 白名单内(lib_router 的 DEEP_LINK_ALLOWED_ROUTES,当前为 MAIN / MESSAGE_LIST)→ 栈中已有则 popToName 回收,否则 push 直达;
  • 白名单外(含带 id 的详情类目标)→ 回落 MAIN。

冷启动与热启动共用这一套决策:冷启动时 want 在 onCreate 到达,但窗口与路由栈都不存在,只能先解析暂存到 pendingDeepLinkRoute,等 loadContent 回调(内容已提交)后再消费;热启动(onNewWant)则直接消费。深链参数键 DEEP_LINK_ROUTE_KEY('of.route')由 lib_router 统一收口,与通知的 wantAgent 写入端共用同一份常量。这条链路与登录回跳互不替代:深链把用户带到的是白名单页面本身,而登录回跳解决的是"被拦下的业务页如何恢复"。


4.9 常见误区

误区 1 把 currentToken() 写成 async。 理由通常是"要读 Preferences,所以得异步"。但 AuthTokenProvider 的契约要求同步返回,AuthInterceptor.before() 是同步注入。正确做法是维护内存镜像(4.2),而不是改接口签名。

误区 2 在 save() 里先 await store.flush() 再改内存字段。 反了。落盘期间发起的请求会读到旧令牌,判定为未登录。正确顺序:先写内存镜像(同步可见),再异步落盘。

误区 3 @Monitor('isLoggedIn') 不生效。 V2 监听的是属性路径,路径上每一层都要可观测。必须写 @Monitor('session.isLoggedIn')(session 是 @Local,isLoggedIn 是 @Trace)。这个错误不报错、不告警,只是永远不触发。

误区 4 登出用布尔广播。 第二次登出不会产生变化,根组件不复位导航,用户停在业务页而会话已清。用单调递增的 logoutEpoch 计数器。

误区 5 pending 在 finally 里清理。 清理发生在 Promise 交付之前,极端时序下会出现重叠刷新。用 then 把清理排在交付之后,并做 this.pending === started 的身份校验。

误区 6 刷新成功后无条件覆盖 refreshToken。 服务端可能不开轮换、不返回新 refreshToken,此时必须保留旧的。直接覆盖会让下一次刷新必然失败。

误区 7 在根组件的 onLogoutBroadcast 里再上报一次 LOGOUT。 LogoutBus.broadcast() 内部已经上报。重复上报会让看板上的登出次数翻倍,且两边代码看起来都"应该上报"。

误区 8 Router.clear() 与 Router.replace() 只做其中一个。 只 clear 会白屏;只 replace 则残留业务页仍可被返回看到(旧会话数据泄漏)。必须成对。

误区 9 登录成功后用 push 落 MAIN。 会把 MAIN 压在一个占位页之上,用户按返回回到 LoadingProgress 而非退出应用。用 replace,让登录页与占位页都不留在栈里。

误区 10 在 aboutToAppear 里读 SessionStore.isLoggedIn 做落地决策。 此时快照还没回填,必然读到 false,且这次跳转不可自愈。必须等 AppInitializer.sessionReady(),且在 onPageShow 里消费。

误区 11 restoreSession() 让异常冒出去。 根组件的落地逻辑在等这个 Promise。一旦它 reject,应用会永远停在启动占位页 —— 比闪一下登录页严重得多。必须 catch 并降级为"未登录"。

误区 12 把 UIAbilityContext 存成静态字段。 Context 活着就等于整个 UIAbility 活着。TokenStore / NotificationService / Permission 都是"install 时取一次句柄或只在方法内临时使用",页面绝不允许持有它。


4.10 实践任务

任务 A:把链路跑通并观测每一步(必做)

  1. 以 Mock 环境启动工程(EnvConfig.CURRENT = MOCK)。
  2. 用演示账号登录:demo@officeflow.com / demo1234。
  3. 在日志中找出并按顺序标出这 5 条:
    • auth:login 请求(注意它 needAuth: false);
    • TokenStore 的 saved tokens access=<fingerprint>;
    • SessionStore 状态变为已登录(dirty 里应出现 isLoggedIn);
    • 根组件的 landing 决策日志(应出现 replace 到 Main);
    • AuthInterceptor 注入头的日志。
  4. 然后杀进程重启,确认:不再出现登录页,且日志里有 session restore finished, loggedIn=true。

验收 checklist

  • [ ] 能在日志中按时间顺序指出 8 个落点各自的证据行;
  • [ ] 冷启动时没有任何一帧渲染出登录页(可用录屏逐帧确认);
  • [ ] 重启后的首个业务请求头里带着有效令牌(不是"先 401 再重放")。

任务 B:制造一次 401 风暴(必做)

  1. 用调试手段把 TokenStore 的内存 expires 改到"已过期但尚未清理"的状态(或直接调用 shouldRefreshSoon() 相关的测试入口)。
  2. 在首页同时触发 5 个以上并发请求(例如快速切换 Tab 触发多个列表加载)。
  3. 观察日志,应当满足:
    • 只有 1 条 refreshing token with <fingerprint>;
    • 有 N 条 refresh already in flight, joining the same promise;
    • 没有出现 unauthorized, purging session(即没有被误判为登出)。

验收 checklist

  • [ ] 刷新请求数 == 1(不是 N);
  • [ ] 用户全程没有被踢到登录页;
  • [ ] TokenRefresher.stats() 输出形如 refresh success=1 fail=0 inflight=false。

任务 C:验证"登出两次都能复位"(必做)

  1. 登录 → 在设置页登出 → 确认回到登录页;
  2. 再登录 → 再登出。
  3. 第二次登出必须同样回到登录页(这正是在验证 logoutEpoch 计数器而不是布尔)。

验收 checklist

  • [ ] 两次登出都有 logout broadcast, dirty=..., epoch=1/2 的日志;
  • [ ] 两次都执行了 Router.clear() + replace(Login);
  • [ ] 没有任何一次留下可返回的业务页。

任务 D:给登录成功补一条真回跳(进阶)

当前 LoginPage 只能用 LoginParam.from + 白名单回跳,且丢参数。请你设计并实现一个不破坏依赖方向的改进方案,至少满足:

  • lib_router 暴露一个全局的 pending 恢复入口(例如 RouteTelemetry 之外的一个 PendingTarget 存储);
  • LoginInterceptor 在拦截时把 {route, param} 写进这个全局存储;
  • LoginPage 登录成功后优先尝试恢复 pending(含参数),恢复不到再降级到白名单 / MAIN;
  • LoginInterceptor.clearPending() 在用户放弃登录(按返回键离开登录页)时被调用,避免下次登录"莫名跳到一个旧页面"。

验收 checklist

  • [ ] 未登录状态下直接深链到 ApprovalDetail{id='ap-1001'},登录成功后带 id 落到详情页;
  • [ ] 用户放弃登录后再登录,不会跳到上次被拦的路由;
  • [ ] lib_router 的 oh-package.json5 里没有新增对 lib_auth / 任何 feature 的依赖(node scripts/check-deps.mjs 必须仍然通过)。

任务 E:安全自查(必做)

  • [ ] 在 lib_auth 全目录搜索令牌原文的输出点:只允许出现 Masker.tokenFingerprint(...) 或长度;
  • [ ] 确认 LoggingInterceptor 的 body 白名单为空,因此 /auth/login 的请求体天然不被打印;
  • [ ] 确认 LoginPage 的 password 字段没有走任何持久化(TokenStore 里只有令牌与用户资料);
  • [ ] 确认退出登录时调用了 NotificationService.getInstance().cancelAll()(见 11/06)。

4.11 延伸阅读

  • 官方文档 → 应用框架 → Ability Kit → UIAbility 组件 → 生命周期(onCreate / onNewWant / onDestroy)
  • 官方文档 → 应用框架 → Ability Kit → 访问应用上下文(UIAbilityContext 与 Context 的区别与持有纪律)
  • 官方文档 → 应用框架 → ArkUI → 状态管理 V2 → @ObservedV2 与 @Trace、AppStorageV2
  • 官方文档 → 应用框架 → ArkUI → 状态管理 V2 → @Monitor 与 IMonitor(属性路径观察)
  • 官方文档 → 应用框架 → ArkUI → Navigation → NavPathStack 与系统路由表(routerMap)
  • 官方文档 → 应用框架 → ArkData → Preferences(getPreferences / put / flush 的语义与容量边界)
  • 官方文档 → 应用框架 → Network Kit → Remote Communication Kit(rcp)→ 拦截器与请求/响应拦截
  • 教程内:04 页面路由与生命周期、11/02 网络层与统一错误处理、11/03 日志与埋点、11/06 消息与通知

← 上一节:11/03 日志与埋点 | 教程目录 | 下一节:11/05 审批流业务闭环 →

📄 第 4 章 页面路由与生命周期 04-routing-lifecycle.md

路由不是"跳页面",生命周期也不是一串回调清单。两者的真正价值是给页面划定责任边界:谁负责创建、谁负责销毁、谁负责把结果带回来。边界划错,就会长出内存泄漏、状态错乱与返回栈失控。


4.0 本章目标

  1. 能画出 Stage 模型从 AbilityStage 到叶子组件的完整生命周期时序,并指出每一步可以安全访问哪些 Context。
  2. 能写出 UIAbility 的全部生命周期回调,并对每个回调给出"该做什么 / 不该做什么"的判断标准。
  3. 能搭建 基于 Navigation + NavPathStack + NavDestination + 系统路由表 的路由骨架,支持跨 HAR 跳转与类型化参数契约。
  4. 能实现 用 setInterception 完成登录拦截与页面埋点,并说清 willShow 与 didShow 的语义差异。
  5. 能定位 至少 5 类路由/生命周期缺陷:监听未注销、参数丢失、返回栈错误、拦截死循环、Context 泄漏。

4.1 Stage 模型全景与生命周期时序

4.1.1 对象层级:从进程到组件

Stage 模型把一次应用运行拆成五层对象,每层都有独立的生命周期:

层级 对象 数量 职责
应用 ApplicationContext 全局 1 个 进程级上下文,用于监听应用前后台、内存等级
模块 AbilityStage 每个 HAP/模块 1 个 模块级初始化、HapModuleInfo 相关逻辑
能力 UIAbility 由 launchType 决定 承载一个窗口与一个任务栈入口
窗口 WindowStage → Window 每个 UIAbility 至少 1 个主窗口 窗口尺寸/状态事件、loadContent 加载页面根
页面/组件 @Entry 组件 / NavDestination / 子组件 任意 渲染与交互

术语:WindowStage ── 窗口舞台,UIAbility 与具体窗口之间的中间层。一个 WindowStage 管理一个 UIAbility 的全部窗口,loadContent 与 windowStageEvent 都挂在它上面。

术语:WindowStageEvent ── 窗口舞台的显示/隐藏/前后台事件流,用于感知"整个窗口"的状态,而不是"某个页面"的状态。

4.1.2 冷启动完整时序

sequenceDiagram
    autonumber
    participant SYS as 系统
    participant AS as AbilityStage
    participant AB as UIAbility
    participant WS as WindowStage
    participant PG as 页面根
    participant CP as 子组件

    SYS->>AS: onCreate()
    SYS->>AB: onCreate(want, launchParam)
    SYS->>AB: onWindowStageCreate(windowStage)
    AB->>WS: loadContent('pages/Index')
    Note over WS,PG: loadContent 完成后,框架构建页面树并回调页面自身生命周期(简化示意)
    PG->>PG: aboutToAppear()
    PG->>CP: 子组件 aboutToAppear()(父先于子)
    PG->>PG: onPageShow()
    SYS->>AB: onForeground()
    Note over AB,PG: 前台运行态
    SYS->>AB: onBackground()
    PG->>PG: onPageHide()
    SYS->>AB: onWindowStageDestroy()
    PG->>PG: aboutToDisappear()
    SYS->>AB: onDestroy()

等价的无图版时序(便于打印进排查清单):

  1. AbilityStage.onCreate()
  2. UIAbility.onCreate(want, launchParam)
  3. UIAbility.onWindowStageCreate(windowStage)
  4. windowStage.loadContent('pages/Index')
  5. 页面根 aboutToAppear()
  6. 子组件 aboutToAppear()(父先于子)
  7. 页面根 onPageShow()
  8. UIAbility.onForeground()
  9. —— 前台运行 ——
  10. UIAbility.onBackground() → 页面 onPageHide()
  11. UIAbility.onWindowStageDestroy() → 页面 aboutToDisappear() → UIAbility.onDestroy()

版本门槛:API 26 起,onForeground 与页面 onPageShow 的先后顺序在不同设备形态(手机 / PC / 折叠屏)上不保证一致。只依赖两条稳定约束:aboutToAppear 一定早于 onPageShow;aboutToDisappear 一定晚于 onPageHide。不要把业务逻辑写在"靠顺序猜"的位置。

4.1.3 Context 层级与作用域

Context 类型 获取方式 生命周期 典型用途 持有风险
ApplicationContext abilityContext.getApplicationContext() 与进程同寿命 全局资源、监听应用前后台 低。可安全放进单例
AbilityStageContext AbilityStage.context 与模块同寿命 模块级沙箱目录 低
UIAbilityContext UIAbility.context / getUIContext().getHostContext() 与 UIAbility 同寿命 拉起其他 Ability、申请权限、startAbilityForResult 高。被单例持有即泄漏整个 Ability
组件内 Context this.getUIContext().getHostContext() 取决于宿主 取 filesDir、databaseDir 中。仅应在方法内临时使用

术语:Context ── 应用或模块的上下文对象,是访问资源、沙箱目录与系统能力的统一入口。它不是一个轻量句柄,持有它等于持有一整条对象链。

在组件中取上下文的推荐写法:

import { common } from '@kit.AbilityKit';

@ComponentV2
struct ExportButton {
    private get abilityContext(): common.UIAbilityContext {
        // getHostContext() 可能返回 undefined(组件尚未挂载到窗口树时),
        // 返回前必须判空;此处断言仅作教学从简——生产代码应显式判空并给出兜底
        const ctx = this.getUIContext().getHostContext();
        if (ctx === undefined) {
            throw new Error('getHostContext() returned undefined: component not attached');
        }
        return ctx as common.UIAbilityContext;
    }

    build() {
        Button($r('app.string.export'))
            .onClick(() => {
                const dir: string = this.abilityContext.filesDir;
                // 使用 dir ...
            })
    }
}

三条硬规则:

  1. 禁止把 UIAbilityContext 存进 static 字段、单例或 AppStorageV2。
  2. 跨页面长时间存活的逻辑,只允许持有 ApplicationContext 与沙箱路径字符串(filesDir / cacheDir)。
  3. 需要"页面销毁后仍要跑"的任务(上传、下载、导出),把上下文在发起时取出并作为参数传入,任务内部立刻转存路径。

误区:把 context 理解成"随便拿的全局变量"。它是有生命周期的对象,UIAbility 销毁后旧引用仍能读属性,但所有系统调用都会失败——这类问题只在长时间挂后台或反复进出时暴露。


4.2 UIAbility 生命周期详解

UIAbility 是应用与系统交互的最小带界面单元。它的回调数量不多,但每个都有明确的"能做什么"边界。

4.2.1 回调职责表

回调 触发时机 该做什么 不该做什么
onCreate(want, launchParam) Ability 实例创建,页面尚未加载 解析 want 参数、初始化无 UI 依赖的服务(日志、配置、数据库句柄) 访问窗口对象、做耗时 IO、同步网络请求
onWindowStageCreate(windowStage) 窗口舞台就绪 调用 loadContent 加载首页、注册窗口级事件(尺寸/状态) 把 windowStage 存成长期全局引用
onForeground() 切到前台 恢复轮询、重连 WebSocket、刷新时间敏感的展示 重新加载整个页面数据(页面有自己的 onPageShow)
onBackground() 切到后台 暂停轮询/动画/定位,落盘未提交的草稿 在此处执行异步长任务且不注册后台任务
onWindowStageDestroy() 窗口舞台销毁 注销所有窗口监听、置空引用 只写日志不做清理(这是最常见的泄漏源)
onDestroy() Ability 实例销毁 释放模块级资源、持久化最后的会话状态 做新的异步请求
onNewWant(want, launchParam) 单实例模式下被再次拉起 解析新 want,把参数投递到路由或事件总线 重建窗口或重新 loadContent

4.2.2 标准实现骨架

// entry/src/main/ets/entryability/EntryAbility.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';

const DOMAIN: number = 0x0F01;
const TAG: string = 'OfficeFlow';

export default class EntryAbility extends UIAbility {
    private mainWindow: window.Window | undefined = undefined;
    private sizeListener: ((size: window.Size) => void) | undefined = undefined;

    onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
        hilog.info(DOMAIN, TAG, 'onCreate, bundle=%{public}s', this.context.abilityInfo.bundleName);
        // 只做与 UI 无关、且必须最早执行的初始化
        // 例如:注册全局崩溃采集、预热只读配置
    }

    onWindowStageCreate(windowStage: window.WindowStage): void {
        windowStage.loadContent('pages/Index', (err: BusinessError) => {
            if (err.code) {
                hilog.error(DOMAIN, TAG, 'loadContent failed: %{public}d', err.code);
                return;
            }
            // 页面加载完成后再取窗口,规避时序竞争
            this.mainWindow = windowStage.getMainWindowSync();
            this.sizeListener = (size: window.Size) => {
                hilog.info(DOMAIN, TAG, 'windowSizeChange: %{public}d x %{public}d', size.width, size.height);
                // 只做"窗口级"决策:断点归档、分栏切换的判定输入
            };
            this.mainWindow.on('windowSizeChange', this.sizeListener);
        });
    }

    onForeground(): void {
        hilog.info(DOMAIN, TAG, 'onForeground');
    }

    onBackground(): void {
        hilog.info(DOMAIN, TAG, 'onBackground');
    }

    onWindowStageDestroy(): void {
        // 关键:先注销再置空,顺序不能反
        if (this.mainWindow !== undefined && this.sizeListener !== undefined) {
            this.mainWindow.off('windowSizeChange', this.sizeListener);
        }
        this.mainWindow = undefined;
        this.sizeListener = undefined;
    }

    onDestroy(): void {
        hilog.info(DOMAIN, TAG, 'onDestroy');
    }

    onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
        // 单实例被再次拉起:把 want 转成路由意图,交给页面消费
        const target: string | undefined = want.parameters?.['targetRoute'] as string | undefined;
        if (target !== undefined) {
            hilog.info(DOMAIN, TAG, 'onNewWant -> %{public}s', target);
        }
    }
}

注意:windowSizeChange 的回调参数是 window.Size,width / height 以 px 为单位。做布局判定时统一转 vp 再比较,别把 px 当 vp 用。

4.2.3 回调里最容易踩的三件事

  1. onCreate 里做网络请求:首帧被阻塞,冷启动时间直接翻倍。改成"页面 + ViewModel 拉取"。
  2. onBackground 里启动长任务:系统会在数秒内冻结应用,任务被杀死且无回调。需要后台执行必须走后台任务机制。
  3. onWindowStageDestroy 只打日志:window 的 on 注册不会自动清理,反复进出页面会累积监听器,最终导致重复回调与内存增长。

4.3 页面与组件生命周期

4.3.1 @Entry 页面 vs NavDestination 页面

使用系统路由表后,业务页面不再标 @Entry,它的"页面级"回调由 NavDestination 承担。两类页面的回调对应关系:

语义 @Entry 页面回调 NavDestination 回调 说明
即将出现 aboutToAppear() aboutToAppear() 组件树构建前,只做同步初始化
已进入/即将进入可视 onPageShow() onShown() 适合刷新数据、启动动画
离开可视 onPageHide() onHidden() 适合暂停、上报停留时长
即将销毁 aboutToDisappear() aboutToDisappear() 注销监听、停掉定时器
返回键拦截 onBackPress() onBackPressed() 返回 true 表示已自行处理
路由上下文就绪 无 onReady(context) 拿到 NavDestinationContext,可读写 pathStack
弹窗形态 无 NavDestinationMode.DIALOG 半透明弹窗式目的地

术语:NavDestinationContext ── NavDestination 的运行时上下文,包含 pathInfo(路由名与参数)、pathStack(页面栈引用)与 getConfigInRouteMap()(路由表配置)。这是页面里唯一应该用来操作路由的入口。

4.3.2 生命周期时序(页面进出)

首次进入 A
  A.aboutToAppear → A.onPageShow / A.onShown
A 上推入 B
  B.aboutToAppear → B.onReady → B.onShown → A.onHidden(A 未销毁,仍在栈中)
B 返回 A
  B.onHidden → B.aboutToDisappear → B 出栈 → A.onShown
A 被销毁
  A.onHidden → A.aboutToDisappear

关键结论:onHidden 不等于销毁。被覆盖的页面仍在栈中,@Local 状态保留,只是不再渲染可视区域。想"回到页面时刷新",用 onShown;想"页面彻底释放",用 aboutToDisappear。

4.3.3 标准页面骨架

// feature_approval/src/main/ets/view/ApprovalDetailPage.ets
// 业务页面放在所在 feature 模块(见 4.4.2 的页面归属说明)
import { hilog } from '@kit.PerformanceAnalysisKit';
import { ApprovalDetailParam } from 'lib_router';

@ComponentV2
export struct ApprovalDetailPage {
    @Param param: ApprovalDetailParam = { approvalId: '', from: 'list', readonly: false };
    @Local vm: ApprovalDetailViewModel = new ApprovalDetailViewModel();

    aboutToAppear(): void {
        // 只做同步准备:绑定监听、初始化
        this.vm.attach(this.param.approvalId);
    }

    aboutToDisappear(): void {
        // 必须对称:注销监听、取消在途请求
        this.vm.detach();
    }

    build() {
        NavDestination() {
            Column() {
                Text($r('app.string.approval_detail_title'))
                    .fontSize($r('app.float.title_fs'))
                    .fontWeight(FontWeight.Medium)
                // 正文 ...
            }
            .width('100%')
            .padding({ left: 16, right: 16 })
        }
        .title($r('app.string.approval_detail_title'))
        .onReady((context: NavDestinationContext) => {
            // onReady 是拿 pathStack 的标准位置
            this.vm.bindStack(context.pathStack);
        })
        .onShown(() => {
            this.vm.onVisible();
        })
        .onHidden(() => {
            this.vm.onInvisible();
        })
        .onBackPressed(() => {
            // 返回 false:交还给系统执行默认 pop
            return this.vm.hasUnsavedDraft();
        })
    }
}

误区:在 aboutToAppear 里发起网络请求。此时首帧尚未提交,请求回来之前页面是空白的;且请求可能在页面已销毁后返回,触发对已释放对象的写入。数据获取放在 ViewModel,由 onShown 或 attach 触发。


4.4 Navigation 路由实现

4.4.1 由谁持有页面栈

NavPathStack 必须由一个生命周期足够长、且只有一份的对象持有。推荐放在应用根页面的 @Local 里:

// entry/src/main/ets/pages/Index.ets
import { MainTabs } from '../views/MainTabs';

@Entry
@ComponentV2
struct Index {
    @Local pathStack: NavPathStack = new NavPathStack();

    build() {
        Navigation(this.pathStack) {
            MainTabs()
        }
        .hideTitleBar(true)
        .mode(NavigationMode.Stack)
    }
}

三条纪律:

  1. 不要在多个页面各建一个 NavPathStack。栈只有一个,页面通过 NavDestinationContext.pathStack 拿到它。
  2. 不要在 NavDestination 内部 new NavPathStack()——这会让新页面永远无法返回。
  3. 栈的持有者只做"持有与拦截",不做业务判断。

4.4.2 系统路由表:三处配置

先说清页面归属:业务页面组件一律放在各 feature_* 模块(如 feature_approval/src/main/ets/view/),entry 不放业务页面;而路由表集中配置在 entry 一处——所有路由的 pageSourceFile 都指向 entry 的根页面文件 Index.ets,由它里面的一组集中构建函数转调各 feature 模块导出的模块级 RouteBuilder。这与「路由表随页面所在模块分散配置」的方案都能工作,本工程选集中式的理由是:路由名集合、构建函数分支、RouteNames.ALL 常量三处必须一一对应,集中在一处最容易核对漏配。

第一处:entry/src/main/module.json5

{
  "module": {
    "name": "entry",
    "type": "entry",
    "routerMap": "$profile:route_map",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "launchType": "singleton"
      }
    ]
  }
}

第二处:entry/src/main/resources/base/profile/route_map.json(节选,业务路由全部集中在这张表里)

{
  "routerMap": [
    {
      "name": "ApprovalList",
      "pageSourceFile": "src/main/ets/pages/Index.ets",
      "buildFunction": "approvalListRouteBuilder",
      "data": {
        "description": "审批列表 | 参数:ApprovalListParam | 需要登录:是",
        "needLogin": true
      }
    },
    {
      "name": "ApprovalDetail",
      "pageSourceFile": "src/main/ets/pages/Index.ets",
      "buildFunction": "approvalDetailRouteBuilder",
      "data": {
        "description": "审批详情 | 参数:ApprovalDetailParam | 需要登录:是",
        "needLogin": true
      }
    }
  ]
}

三个字段的含义与约束:

字段 含义 约束
name 路由名,跨模块跳转的唯一标识 全局唯一;建议来自常量类,不要散落字符串
pageSourceFile 构建函数所在源文件路径 相对模块根目录(含 src/main/...),不是相对 profile 目录;集中式方案下所有路由都指向 entry 的 Index.ets
buildFunction 页面的构建函数名 必须是该文件中 @Builder 导出的全局函数,签名 (name: string, param: object)
data(可选) 自定义元数据 只读,运行时经 getConfigInRouteMap() 取,适合存 needLogin 这类静态标记

第三处:两级构建函数——entry 集中登记,feature 导出模块级 RouteBuilder,页面组件留在 feature 内。

// entry/src/main/ets/pages/Index.ets(节选)
import { ApprovalDetailRouteBuilder } from 'feature_approval';

/** route_map.json 的 buildFunction 指向这里:名字逐字符一致,且必须 export */
@Builder
export function approvalDetailRouteBuilder(name: string, param: object): void {
    // entry 只回答「路由名 → 哪个构建函数」,不做任何参数断言;
    // 参数收敛与 NavDestination 归属由 feature 的 RouteBuilder 负责
    ApprovalDetailRouteBuilder(name, param);
}
// feature_approval/src/main/ets/router/ApprovalRoute.ets(节选)
// 页面组件在本模块 view/ 目录下;这里只做「名字 → 页面」的映射,没有任何逻辑
@Builder
export function ApprovalDetailRouteBuilder(name: string, param: object): void {
    ApprovalDetailPage()
    // 参数由页面自己经 Router.getParam<ApprovalDetailParam>() 读取
}

版本门槛:本教程基线为 API 26;系统路由表(routerMap)的跨 HAR / HSP 编译期合并自 API 12 起基本可用,兼容下限 20 设备不受影响。这意味着跨模块跳转不强制在入口模块登记路由,但仍需保证 name 全局唯一。本工程选择集中注册式(而非把 routerMap 分散到各 HAR),是因为「三处一一对应」的核对成本在此方案下最低。

4.4.3 类型化参数契约

参数字符串在编译期无法校验,所以契约必须显式落地。把"路由名 + 参数类型 + 校验函数"三者放同一个文件:

// lib_router/src/main/ets/contract/RouteParams.ets
export type ApprovalFrom = 'list' | 'message' | 'search';

export interface ApprovalDetailParam {
    readonly approvalId: string;
    readonly from: ApprovalFrom;
    readonly readonly: boolean;
}

export interface ApprovalSubmitParam {
    readonly templateId: string;
    readonly draftId?: string;
}

/** 运行期校验:路由参数可能在跨模块/深链场景下不可信 */
export function parseApprovalDetailParam(raw: object): ApprovalDetailParam | undefined {
    const id: unknown = (raw as Record<string, Object>)['approvalId'];
    const from: unknown = (raw as Record<string, Object>)['from'];
    if (typeof id !== 'string' || id.length === 0) {
        return undefined;
    }
    if (from !== 'list' && from !== 'message' && from !== 'search') {
        return undefined;
    }
    return {
        approvalId: id,
        from: from,
        readonly: (raw as Record<string, Object>)['readonly'] === true
    };
}

术语:路由契约 ── 对"某个路由名接收什么参数"的强类型描述。它把原本靠文档约定的隐式接口变成编译期可检查的显式类型,是跨模块路由不失控的前提。

4.4.4 路由名常量集中管理

// lib_router/src/main/ets/contract/RouteName.ets
export class RouteName {
    static readonly APPROVAL_DETAIL: string = 'ApprovalDetail';
    static readonly APPROVAL_SUBMIT: string = 'ApprovalSubmit';
    static readonly LOGIN: string = 'Login';
    static readonly MESSAGE_CENTER: string = 'MessageCenter';
}

/** 需登录路由表(命中即拦截):这些路由在未登录时会被拦截器拦下并改跳登录页 */
export const LOGIN_REQUIRED: string[] = [
    RouteName.APPROVAL_DETAIL,
    RouteName.APPROVAL_SUBMIT
];

术语:路由名常量 ── 用类静态只读字段承载路由名,取代魔法字符串。跨模块跳转只依赖常量类,不依赖对方的页面路径,这是模块解耦的关键。

4.4.5 完整跳转示例

// 列表页:跳详情并等待回传结果
import { ApprovalDetailParam, RouteName, Router } from 'lib_router';

@ComponentV2
export struct ApprovalListPage {
    @Local vm: ApprovalListViewModel = new ApprovalListViewModel();
    private stack: NavPathStack | undefined = undefined;

    build() {
        NavDestination() {
            List() {
                // 此处用 ForEach 仅因示例列表短;长列表按第 3 章改 LazyForEach
                ForEach(this.vm.items, (item: ApprovalItem) => {
                    ListItem() {
                        ApprovalCard({ item: item })
                    }
                    .onClick(() => {
                        const param: ApprovalDetailParam = {
                            approvalId: item.id,
                            from: 'list',
                            readonly: false
                        };
                        // 带结果回调的推入
                        Router.pushWithResult<ApprovalDetailParam, ApprovalActionResult>(
                            this.stack, RouteName.APPROVAL_DETAIL, param,
                            (result: ApprovalActionResult) => {
                                if (result.changed) {
                                    this.vm.refreshOne(result.approvalId);
                                }
                            }
                        );
                    })
                }, (item: ApprovalItem) => item.id)
            }
        }
        .onReady((context: NavDestinationContext) => {
            this.stack = context.pathStack;
        })
    }
}

4.5 进阶:拦截、栈操作与结果回传

4.5.1 setInterception 做登录拦截与埋点

拦截器挂在页面栈上,在路由发生前/后统一处理,避免每个页面各写一遍。

// entry/src/main/ets/pages/Index.ets
import { RouteName, LOGIN_REQUIRED, LoginParam } from 'lib_router';
import { AuthStore } from 'lib_auth';
import { Tracker } from 'lib_log';
import { MainTabs } from '../views/MainTabs';   // Navigation 的首页内容(见 4.4.1)

@Entry
@ComponentV2
struct Index {
    @Local pathStack: NavPathStack = new NavPathStack();

    aboutToAppear(): void {
        this.pathStack.setInterception({
            willShow: (from, to, operation, isAnimated) => {
                const target: string = (to as NavDestinationContext).pathInfo?.name ?? '';
                if (operation === NavigationOperation.PUSH && LOGIN_REQUIRED.includes(target)) {
                    if (!AuthStore.shared.isLoggedIn) {
                        // 拦截本次进入,改为推入登录页,并把原目标作为回跳参数
                        const loginParam: LoginParam = { redirect: target };
                        this.pathStack.pushPathByName(RouteName.LOGIN, loginParam, false);
                        return false;
                    }
                }
                return true;
            },
            didShow: (from, to, operation, isAnimated) => {
                const ctx: NavDestinationContext = to as NavDestinationContext;
                Tracker.pageView(ctx.pathInfo?.name ?? 'unknown', operation);
            }
        });
    }

    build() {
        Navigation(this.pathStack) {
            MainTabs()
        }
        .hideTitleBar(true)
        .mode(NavigationMode.Stack)
    }
}

语义差异必须分清:

回调 时机 返回值 典型用途
willShow 路由生效前 true 放行 / false 取消本次路由 登录拦截、权限校验、参数改写检查
didShow 路由生效后 无 页面埋点、面包屑更新、滚动位置记录

误区:在 willShow 里既 pushPathByName 又返回 true。这会让登录页和原目标页同时入栈,返回时穿透到受保护页面。拦截必须"替换"而非"并存":push 新页面后返回 false。

4.5.2 栈操作 API 速查

API 作用 易错点
pushPathByName(name, param, animated?) 推入并传参 参数必须是 object,不能传原始值
pushDestinationByName(name, param) 异步推入,路由不存在时 reject 跨模块跳转首选,能暴露拼错的路由名
pop(result?, animated?) 出栈,可带回结果 结果只会交给入栈时注册的 onPop
popToName(name, result?, animated?) 回退到指定名字的最近一个实例 名字不存在时无任何效果,需自行判断
popToIndex(index, result?, animated?) 回退到指定下标 下标会随栈变化,不要缓存旧下标
removeByName(name) 删除所有同名页面 会连带销毁其中的状态
moveToTop(name, animated?) 把已有页面移到栈顶(复用实例) 复用的 @Local 状态不会重置
getAllPathName(): string[] 取全部路由名 只用于判断,不要用它重建栈
getParamByName(name): unknown 取参数 返回值是 unknown,必须校验后再用
getIndexByName(name): number 取下标 不存在返回 -1
clear(animated?) 清空栈 根页面会被清掉,慎用

4.5.3 页面栈结果回传的标准做法

结果回传只有一条可靠路径:入栈时注册 onPop,子页面 pop(result)。

// lib_router/src/main/ets/Router.ets
import { ApprovalActionResult } from './contract/RouteResults';

export class Router {
    /** 无返回值推入 */
    static push<T extends object>(stack: NavPathStack | undefined, name: string, param: T): void {
        if (stack === undefined) {
            return;
        }
        stack.pushPathByName(name, param, false);
    }

    /** 带结果回调推入 */
    static pushWithResult<T extends object, R>(
        stack: NavPathStack | undefined,
        name: string,
        param: T,
        onResult: (result: R) => void
    ): void {
        if (stack === undefined) {
            return;
        }
        stack.pushPathByName(name, param, (info: PopInfo) => {
            const result: R | undefined = info.result as R | undefined;
            if (result !== undefined) {
                onResult(result);
            }
        }, false);
    }
}
// 详情页:回传结果(ApprovalDetailPage 组件方法内片段,省略导入)
import { ApprovalActionResult } from 'lib_router';

private finish(changed: boolean, approvalId: string): void {
    const result: ApprovalActionResult = { changed: changed, approvalId: approvalId };
    this.stack?.pop(result, true);
}

术语:PopInfo ── pop 时携带的信息对象,包含 info: NavPathInfo 与 result: Object。它是页面栈"返回值"的载体,等价于函数调用的 return。

三条替代方案的取舍:

方案 何时用 代价
onPop 回调 一对一、单次回传 生命周期短,只能在入栈时注册
共享 ViewModel / Store 多页面共享同一份状态 需要严格的归属管理,否则状态残留
事件总线 广播式通知(如"退出登录") 弱类型,难追踪,慎用于业务数据

4.6 多窗口与多实例

4.6.1 launchType 决定实例模型

在 module.json5 的 abilities 中配置:

launchType 行为 适用场景
singleton 全局唯一实例,重复拉起触发 onNewWant 绝大多数应用主入口
multiton 每次拉起创建新实例与新任务栈 多任务并行,如文档编辑器同时开两份
specified 由应用自行决定复用还是新建 需要按设备/账号维度区分实例

版本门槛:API 26 起,multiton 与 specified 在多窗口设备(PC / 折叠屏)上的窗口分配策略由系统按形态决策,不要硬编码"一个实例一个窗口"。

4.6.2 窗口事件:注册与注销必须成对

// 窗口事件:注册与注销必须成对
// (EntryAbility 类内片段;this.mainWindow / this.sizeListener 为类成员,省略 import)
onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index', (err: BusinessError) => {
        if (err.code) {
            return;
        }
        this.mainWindow = windowStage.getMainWindowSync();
        this.sizeListener = (size: window.Size) => {
            // 只做窗口级决策:断点归档
            const widthVp: number = px2vp(size.width);
            this.currentBreakpoint = Breakpoint.resolve(widthVp);
        };
        this.mainWindow.on('windowSizeChange', this.sizeListener);
    });
}

// 在 UIAbility 中注销
onWindowStageDestroy(): void {
    if (this.mainWindow !== undefined && this.sizeListener !== undefined) {
        this.mainWindow.off('windowSizeChange', this.sizeListener);
    }
    this.mainWindow = undefined;
    this.sizeListener = undefined;
}

误区:用 on('windowSizeChange', () => {...}) 传匿名函数。匿名函数无法在 off 时被引用到,监听永远注销不掉。回调必须提升为具名成员,注册与注销使用同一个引用。

4.6.3 窗口级状态 vs 页面级状态

维度 窗口级状态 页面级状态
例子 断点档位、分栏开关、窗口尺寸、是否全屏 列表数据、表单草稿、滚动位置、加载态
载体 BreakpointStore(AppStorageV2 单例) 页面自己的 ViewModel(@Local)
生命周期 与窗口同寿命,跨页面共享 与页面同寿命,出栈即释放
错误放置的后果 放进页面 → 换页面后断点判断失效 放进全局 → 页面反复进出后状态污染

判别口诀:"这个值在离开当前页面后还有意义吗?" 有 → 窗口级/全局;没有 → 页面级。


4.7 lib_router 收口

跨模块路由的失控通常源于三件事:路由名散落、参数类型不共享、跳转逻辑复制。把三者收进一个 HAR 就能根治:

lib_router/src/main/ets/
  ├── Index.ets                  # 唯一导出面
  ├── Router.ets                 # push / pushWithResult / pop 封装
  ├── RouterGuard.ets            # 登录/权限判定,供 setInterception 调用
  └── contract/
        ├── RouteName.ets        # 路由名常量
        ├── RouteParams.ets      # 每个路由的参数类型 + 校验函数
        └── RouteResults.ets     # 每个路由的结果类型
// lib_router/src/main/ets/Index.ets
export { RouteName, LOGIN_REQUIRED } from './contract/RouteName';
export { parseApprovalDetailParam } from './contract/RouteParams';
export type { ApprovalDetailParam, ApprovalSubmitParam, ApprovalFrom } from './contract/RouteParams';
export type { ApprovalActionResult } from './contract/RouteResults';
export { Router } from './Router';

依赖方向约束(与第 9 章一致):

feature_approval ──► lib_router ──► lib_base
feature_login    ──► lib_router
entry            ──► lib_router

lib_router 不允许依赖任何 feature_*。这样任何模块都能跳转到任何路由,而模块之间零耦合。


4.8 生命周期与路由的调试手段

4.8.1 用统一埋点把时序"打出来"

// lib_log/src/main/ets/LifecycleTrace.ets
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN: number = 0x0F02;
const TAG: string = 'Lifecycle';

export class LifecycleTrace {
    static enter(scope: string, name: string): void {
        hilog.info(DOMAIN, TAG, '[enter] %{public}s :: %{public}s', scope, name);
    }

    static exit(scope: string, name: string): void {
        hilog.info(DOMAIN, TAG, '[exit ] %{public}s :: %{public}s', scope, name);
    }
}

排查"页面没刷新""返回后数据是旧的"这类问题时,第一步永远是把 enter/exit 日志按时间排序读一遍,而不是猜。

4.8.2 常见问题的定位路径

现象 优先怀疑 验证方式
返回键无反应 onBackPress/onBackPressed 返回了 true 却没处理 临时改回 false 观察
页面栈越跳越深 willShow 返回 true 同时 push 了新页面 打印 getAllPathName()
重复回调 on() 注册了匿名函数,off 失效 打印注册次数
返回后数据不刷新 数据获取写在 aboutToAppear(只执行一次) 移到 onShown
偶发白屏后恢复 在生命周期里做了异步且未处理销毁 打印对象是否已释放
内存持续增长 单例持有 UIAbilityContext 或页面对象 DevEco Studio 内存快照对比

4.9 常见误区

1. 路由仍用 @ohos.router

// 错误写法
import { router } from '@kit.ArkUI';
router.pushUrl({ url: 'pages/ApprovalDetail', params: { id: 'A-1' } });
// 正确写法
import { RouteName, ApprovalDetailParam } from 'lib_router';
const param: ApprovalDetailParam = { approvalId: 'A-1', from: 'list', readonly: false };
this.pathStack.pushPathByName(RouteName.APPROVAL_DETAIL, param, false);

为什么错:@ohos.router 已不推荐(官方自 API 12 起不再维护,见附录 B 的 B.1.1),其全局单页栈不支持分栏、不支持系统路由表、参数无类型约束、无法跨 HAR 注册。继续用它会导致多端适配(PC 分栏)无法实现,且迁移成本随时间递增。

2. 在 NavDestination 内 new NavPathStack()

// 错误写法
@ComponentV2
struct ApprovalDetailPage {
    private stack: NavPathStack = new NavPathStack();  // 新建了一个独立的空栈
}
// 正确写法
.onReady((context: NavDestinationContext) => {
    this.stack = context.pathStack;   // 复用根页面持有的同一个栈
})

为什么错:NavPathStack 代表的是"当前这个导航容器的页面栈",它由 Navigation 创建并持有。在子页面里新建一个空栈,相当于拿到了一个没有历史记录的新容器,pop() 会直接失败或造成栈状态错乱。

3. 在 aboutToAppear 里发网络请求

// 错误写法
aboutToAppear(): void {
    this.vm.load();   // 异步拉起网络请求
}
// 正确写法
aboutToAppear(): void {
    this.vm.attach(this.param.approvalId);   // 只做同步准备
}
onShown(() => {
    this.vm.refreshIfStale(30_000);                 // 进入可视后,只在数据超过 30s 未更新时拉取
})

为什么错:aboutToAppear 在首帧提交前执行,此时发起 IO 只会拉长白屏时间;更严重的是请求返回时页面可能已经 aboutToDisappear,写入已释放的 @Local 会引发难以复现的状态异常。数据获取属于"进入可视"语义,应放 onShown。

4. 用 static 变量在页面间传参

// 错误写法
export class ParamsHolder {
    static currentApprovalId: string = '';
}
// 跳转页:ParamsHolder.currentApprovalId = item.id; pushUrl(...)
// 正确写法
const param: ApprovalDetailParam = { approvalId: item.id, from: 'list', readonly: false };
Router.push<ApprovalDetailParam>(this.stack, RouteName.APPROVAL_DETAIL, param);

为什么错:静态变量是全局单例,两个页面同时打开时后写的值会覆盖前一个;页面恢复(进程被系统回收后重建)时静态值已丢失,参数变成空。路由参数必须随路由一起走,才能被系统保存与恢复。

5. route_map.json 的 buildFunction 忘了 export 或对不上名字

// 错误写法:函数名与配置不一致,且未导出
@Builder
function detailBuilder(name: string, param: object) {
    ApprovalDetailPage({ param: param as ApprovalDetailParam });
}
// 正确写法:名字与 route_map.json 的 buildFunction 完全一致,且 export
@Builder
export function approvalDetailRouteBuilder(name: string, param: object) {
    ApprovalDetailRouteBuilder(name, param);
}

为什么错:路由表在编译期做静态扫描,靠函数名找到入口。名字不匹配或没有 export,跳转时会报"路由未找到",而这类错误在 pushPathByName 的调用点完全看不出来——错误点与原因相差一个编译阶段。

6. onWindowStageDestroy 不注销窗口监听

// 错误写法
onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.getMainWindowSync().on('windowSizeChange', (size: window.Size) => {
        this.handle(size);
    });
}
onWindowStageDestroy(): void {
    hilog.info(DOMAIN, TAG, 'destroy');   // 只打日志
}
// 正确写法
onWindowStageCreate(windowStage: window.WindowStage): void {
    this.mainWindow = windowStage.getMainWindowSync();
    this.sizeListener = (size: window.Size) => { this.handle(size); };
    this.mainWindow.on('windowSizeChange', this.sizeListener);
}
onWindowStageDestroy(): void {
    this.mainWindow?.off('windowSizeChange', this.sizeListener);
    this.mainWindow = undefined;
    this.sizeListener = undefined;
}

为什么错:on 的注册不会随对象销毁自动清理。反复进出(如深链拉起、多实例切换)会累积监听器,表现为一次窗口尺寸变化触发 N 次回调,以及 UIAbility 无法被回收。

7. willShow 里既 push 又返回 true

// 错误写法
willShow: (from, to, operation, isAnimated) => {
    if (needLogin) {
        this.pathStack.pushPathByName(RouteName.LOGIN, loginParam, false);
        return true;   // 放行 → 登录页与目标页同时入栈
    }
    return true;
}
// 正确写法
willShow: (from, to, operation, isAnimated) => {
    if (needLogin) {
        this.pathStack.pushPathByName(RouteName.LOGIN, loginParam, false);
        return false;  // 取消本次进入,只保留登录页
    }
    return true;
}

为什么错:willShow 的返回值决定"本次路由是否生效"。返回 true 会让原目标页照常入栈,用户点返回就能绕过登录直接看到受保护内容;同时栈里出现"登录页 + 目标页"两条记录,返回链路完全错乱。

8. 在 onShown 里做重资源初始化

// 错误写法
onShown(() => {
    this.vm.load();               // 每次进入可视都重新拉取全量数据
})
// 正确写法
onShown(() => {
    this.vm.refreshIfStale(30_000);   // 只在数据超过 30s 未更新时才拉取
})

为什么错:onShown 会在"从子页面返回"时再次触发。每次返回都全量重拉,会造成请求放大、列表闪烁与滚动位置丢失。进入可视只应触发"是否需要刷新"的判定,而不是无条件刷新。


4.10 实践任务

任务描述

为 OfficeFlow 搭建路由骨架,并完成登录拦截与结果回传闭环:

  1. 在 entry 中建立 Index 页面,持有唯一 NavPathStack,使用 Navigation + NavigationMode.Stack。
  2. 在 lib_router 中建立 RouteName、RouteParams、RouteResults、Router 四个导出项。
  3. 在 feature_approval 中创建 ApprovalListPage 与 ApprovalDetailPage(页面组件放 view/,模块级 RouteBuilder 放 router/),并在 entry 的 route_map.json 中为两个页面各登记一条记录、各写一个转调构建函数。
  4. 用 setInterception 实现:未登录时进入详情页被拦截并跳登录页;登录成功后自动回跳到原目标。
  5. 详情页支持"撤回审批",撤回成功后把结果回传给列表页,列表页只刷新该条数据。
  6. 在 EntryAbility 中监听 windowSizeChange,输出当前断点档位,并在 onWindowStageDestroy 中正确注销。

验收 checklist

  • [ ] module.json5 中存在 "routerMap": "$profile:route_map"
  • [ ] 全工程搜索不到 router.pushUrl / router.replaceUrl / @ohos.router
  • [ ] 每个 route_map.json 条目的 name 都能在 RouteName 常量类中找到同名常量
  • [ ] 每个 buildFunction 均为 @Builder export function,且与配置字符串逐字符一致
  • [ ] 未登录点进详情页:栈内只有登录页,返回后回到列表页
  • [ ] 登录成功后自动进入详情页,且 getAllPathName() 中不出现重复的目标路由
  • [ ] 详情页撤回成功后,列表页仅该条数据变化(打印其余条目的刷新次数为 0)
  • [ ] NavPathStack 全工程只有一处 new NavPathStack()
  • [ ] onWindowStageDestroy 中存在 off('windowSizeChange', ...),且回调为具名成员
  • [ ] 反复进出详情页 20 次后,每次窗口尺寸变化仍只触发 1 次回调
  • [ ] 页面参数中出现非法值时(如 from: 'xxx'),走的是校验失败分支而不是崩溃

交付物清单

交付物 位置
路由契约与常量 lib_router/src/main/ets/contract/{RouteName,RouteParams,RouteResults}.ets
跳转封装 lib_router/src/main/ets/Router.ets
根导航页 entry/src/main/ets/pages/Index.ets(含集中构建函数)
路由表配置 entry/src/main/resources/base/profile/route_map.json(全部路由集中登记于此)
审批列表/详情页 feature_approval/src/main/ets/view/{ApprovalListPage,ApprovalDetailPage}.ets
审批模块级 RouteBuilder feature_approval/src/main/ets/router/ApprovalRoute.ets
Ability 骨架 entry/src/main/ets/entryability/EntryAbility.ets
时序验证记录 docs/lifecycle-trace.md(贴入生命周期日志与窗口事件日志)

4.11 延伸阅读

  • 官方文档 → 应用开发 → 应用框架 → Ability Kit → UIAbility 组件生命周期(7 个回调的官方定义)
  • 官方文档 → 应用开发 → 应用框架 → Ability Kit → Stage 模型开发指导(AbilityStage / WindowStage 层级关系)
  • 官方文档 → 应用开发 → 应用框架 → ArkUI(方舟开发框架)→ Navigation 组件(NavPathStack 全量 API 与示例)
  • 官方文档 → 应用开发 → 应用框架 → ArkUI → 系统路由表(routerMap 配置与跨模块路由)
  • 官方文档 → 应用开发 → 应用框架 → ArkUI → 页面路由与参数传递(pushDestinationByName、PopInfo 与拦截器)
  • 官方文档 → 应用开发 → 应用框架 → ArkUI → 页面与自定义组件的生命周期(aboutToAppear / onPageShow / onShown 对照)
  • 本教程 附录 B 的 B.1.1(@ohos.router 弃用清单与迁移口径)

上一章:第 3 章 ArkUI 组件与布局 下一章:第 5 章 状态管理

📄 第 11 章 审批流业务闭环 05-approval-flow.md

涉及模块:feature_approval(HAR)、lib_network(HAR)、lib_base(HAR)、lib_ui(HSP)。 前置章节:11/02 网络层与统一错误处理、05 状态管理、06 数据持久化与网络请求。

审批是 OfficeFlow 里唯一一条"读 → 写 → 状态迁移 → 跨页同步"全都要走的业务线。它同时是一道放大器:任何一处偷懒的写法,都会在这个业务上放大成用户可见的错误 ——

  • 列表点进去看到"待审批",详情页却显示"已通过"(缓存不一致);
  • 提交按钮连点两次,服务端产生两张单据(缺幂等键);
  • 弱网下刷新失败,整屏内容消失(把错误当成了清屏信号);
  • LazyForEach 用了 index 做 key,标记已读后"点 A 结果 B 变了"。

本章按"数据流从上到下"的顺序拆解这条链路,并在最后给出一套可复现错误分支的调试方法 —— 这是本工程相对多数示例工程最大的差异点。

说明 本章涉及 SDK 运行链路的部分未经本机编译验证;文中所有 文件:行号 引用与签名均对照当前工程源码实测,SDK 侧行为以官方 API 参考为准。


5.0 本章目标

  1. 画出 一次"提交审批"从页面点击到列表/详情/缓存/埋点全部收敛的完整路径;
  2. 写出 审批状态机,并用它(而不是散落的 if)决定按钮显隐与动作合法性;
  3. 实现 stale-while-revalidate:先给缓存、再给网络、失败保留旧值,且能区分"陈旧"与"错误";
  4. 按状态分档 设计缓存 TTL,并解释空列表为什么永远不算新鲜;
  5. 正确使用 幂等键:什么时候生成、什么时候清除、什么错误必须保留它;
  6. 用 LazyForEach + 自写 IDataSource 渲染长列表,并说明 keyGenerator 为什么必须是业务 id;
  7. 构造并复现 至少 6 条错误分支(空、超长、契约漂移、401、业务 401、500、非数组),不靠改代码。

5.1 分层落地:一条链路穿过的 6 层

先看全景。这张表是本章的索引,后面的小节按需展开。

层 文件 职责(只做这些)
视图 view/ApprovalListPage.ets、view/ApprovalDetailPage.ets、view/ApprovalCreatePage.ets 渲染 + 把用户动作转成 ViewModel 调用;不碰网络、不碰存储
视图模型 viewmodel/ApprovalListViewModel.ets、viewmodel/ApprovalDetailViewModel.ets 状态机(UI 状态 + 业务动作)、分页游标、幂等键持有、埋点
仓储 repo/ApprovalRepositoryImpl.ets 缓存策略(SWR / TTL)、写穿透、结果归一化
数据源 datasource/ApprovalRemoteDs.ets、datasource/ApprovalLocalDs.ets 远端:HTTP + 解析;本地:RDB 快照(lib_storage 的 ApprovalDao / t_approval 表,entry 启动期 RdbHelper.init(context) 打开)
模型 model/Approval.ets、model/ApprovalStatus.ets、model/ApprovalDto.ets、model/ApprovalQuery.ets 领域模型、状态机规则、DTO → 模型映射
装配 di/ApprovalModule.ets、repo/ApprovalRepoSingleton.ets 依赖注入 + 脏标记(跨页失效)

两条硬边界:

  1. 视图不认识 DTO。 ApprovalListPage 里没有一处 JSON.parse、没有一处字段名猜测 —— 字段名只出现在 ApprovalDto.ets 的映射器里。后端把 applicantName 改名成 applicant,改动面被限制在一个文件。
  2. ViewModel 不认识 HTTP。 它调 repository().list(query),拿到的是 Result<ApprovalListResult>。它不知道这是 Mock 还是真实网关,也不知道底层是 rcp 还是别的实现。

术语 DTO(Data Transfer Object):与后端接口一一对应的原始数据结构。本工程要求 DTO 与领域模型是两个类型,中间必有映射器 —— 因为"后端契约会变"和"UI 需要稳定形状"是两件事。

说明 本地缓存是完整字段快照(schema v1 起)。t_approval 的 v0 基线只有 8 列,回读时曾对缺失字段补默认值(加急标记丢失、类别/部门为空串),是早期文档明确披露的"有损快照"取舍。工程随后通过 RdbHelper.MIGRATIONS 的 v0→v1 版本迁移补齐 category / applicant_dept / urgent / created_at 四列(RdbHelper.ets:86-105 的 SCHEMA_VERSION = 1 与迁移步骤表,ApprovalDao.ets:54-74 的 ApprovalRecord 已扩到 12 字段),ApprovalLocalDs 现在完整读写全部字段。剩下两处差异是刻意的命名映射而不是丢失:applicant ← applicantName、remark ← summary(ApprovalLocalDs.ets:184-234 的 toRecord / fromRecord,映射只在这两个方法各出现一次)。这个"有损 → 版本迁移扩列 → 完整快照"的演进过程本身就是一个 schema 迁移的正例(新库走 v0 DDL + 全部 ALTER、老库逐版本升级,两条路径结构一致),可对照第 6 章数据持久化与 9.x 工程化章节阅读。

装配与脏标记

// feature_approval/src/main/ets/di/ApprovalModule.ets(对外形状)
export class ApprovalModule {
    static repository(): ApprovalRepository { /* 走 ApprovalRepoSingleton.get() */ }
    static listViewModel(): ApprovalListViewModel { /* ... */ }
    static detailViewModel(): ApprovalDetailViewModel { /* ... */ }

    /** 列表脏标记。提交/撤回/审批成功后置位,列表下次可见时强制重拉 */
    static markListDirty(): void { /* ... */ }
    static consumeListDirty(): boolean { /* ... */ }
    /** 详情脏标记。无参的全局布尔:当前只有一条详情页路径,id 维度没有收益 */
    static markDetailDirty(): void { /* ... */ }
    static consumeDetailDirty(): boolean { /* ... */ }

    static installMock(): void { /* ... */ }
    static async clearCache(): Promise<void> { /* await repo.clearCache() 后同时置位两个脏标记 */ }
}

为什么需要脏标记(dirty flag):用户在详情页审批通过,然后返回列表。列表页是从栈里恢复的(aboutToAppear 不会重跑),它手上的数据是旧的。如果没有脏标记,用户会看到"我刚审批通过的单据还是待审批" —— 而这条记录其实已经被服务端改了。

脏标记是"跨页失效"的最小实现:置位方不需要知道谁在读,读取方自己消费一次就清掉。相比事件总线,它天然幂等(重复置位只算一次),也没有"订阅者泄漏"的风险。


5.2 状态机:把 if 收成一个类型

状态与转移规则

// feature_approval/src/main/ets/model/ApprovalStatus.ets(结构)
export enum ApprovalStatus {
    Draft = 'draft',
    Pending = 'pending',
    Approved = 'approved',
    Rejected = 'rejected',
    Withdrawn = 'withdrawn'
}

export const APPROVAL_STATUS_VALUES: string[] = [
    ApprovalStatus.Draft,
    ApprovalStatus.Pending,
    ApprovalStatus.Approved,
    ApprovalStatus.Rejected,
    ApprovalStatus.Withdrawn
];

export class ApprovalStatusRules {
    /** 能否审批(通过/驳回):只有"进行中"可以 */
    static canApprove(s: ApprovalStatus): boolean { /* Pending */ }
    static canReject(s: ApprovalStatus): boolean { /* Pending */ }
    /** 能否撤回:只有申请人自己是提交人且处于进行中才可以(后者由状态表达) */
    static canWithdraw(s: ApprovalStatus): boolean { /* Pending */ }
    /** 能否编辑:草稿与被撤回的可以改 */
    static canEdit(s: ApprovalStatus): boolean { /* Draft | Withdrawn */ }
    /** 是否终态:终态不再参与轮询与重试 */
    static isTerminal(s: ApprovalStatus): boolean { /* Approved | Rejected | Withdrawn */ }
    /** 是否"在途":会影响缓存 TTL 的档位(见 5.3) */
    static isInFlight(s: ApprovalStatus): boolean { /* Pending */ }
    // 注意:isInFlight 只含 Pending(ApprovalStatus.ets:92-94)。Draft 不算在途 ——
    // 它只有本机在改,走 ApprovalCachePolicy.TTL_DRAFT_MS 的独立分档(ApprovalCachePolicy.ets:31/:68-70)。
    /** 相关动作执行后,应变成什么状态;空数组表示非法动作 */
    static nextStates(s: ApprovalStatus): ApprovalStatus[] { /* ... */ }
}

配套的展示层映射也集中在这里,而不是散在页面的 switch 里:

export class ApprovalStatusTone { /* 语义色:neutral / warning / success / danger / muted */ }

export class ApprovalStatusStyle {
    static toneOf(s: ApprovalStatus): ApprovalStatusTone { /* ... */ }
    /** 返回语义 token 名(如 `approval_status_pending`),不是资源引用;view 层再 switch 到 $r(...) */
    static colorTokenOf(s: ApprovalStatus): string { /* ... */ }
    /** 中文展示文案(资源缺失时的兜底,与 lib_base 的 UserMessages 同一策略) */
    static labelOf(s: ApprovalStatus): string { /* ... */ }
    /** 白名单解析;未知值落到调用方给的 fallback,不返回 undefined */
    static parse(raw: string, fallback: ApprovalStatus): ApprovalStatus { /* ... */ }
}

为什么必须收成一个类

理由一:按钮显隐不该由页面猜。

// 详情页动作栏(结构示意)
if (this.vm.canDecide) { /* 通过 / 驳回 */ }
if (this.vm.canWithdraw) { /* 撤回 */ }
if (this.vm.canEdit) { /* 编辑 */ }

这三个 @Computed 全部来自状态机:

@Computed get canDecide(): boolean { /* ApprovalStatusRules.canApprove(status) */ }
@Computed get canWithdraw(): boolean { /* ApprovalStatusRules.canWithdraw(status) */ }
@Computed get canEdit(): boolean { /* ApprovalStatusRules.canEdit(status) */ }

如果页面自己写 if (status === 'pending'),那么当业务加一条"代理审批"规则时,你需要在每个页面的判断点各改一遍,而且不会有人提醒你漏了一个。

理由二:非法动作必须有唯一定义。

服务端也会校验(返回 40901 业务冲突),但客户端不能"反正服务端会拦"。用户点了不该出现的按钮 → 请求 → 被拒 → 报错,这个循环里每一次往返都是一次体验损耗。状态机让不该出现的按钮根本不渲染。

理由三:parse() 必须有兜底。 后端新增一个状态值(比如 archived)时,客户端不认识它。返回 undefined 会让所有 switch 掉进 default 分支得不到处理;返回一个默认档(本工程的签名是 parse(raw, fallback),由调用方显式指定兜底值,如 ApprovalLocalDs.fromRecord 传 ApprovalStatus.Pending)则是显式降级。

版本门槛 enum 与 Record<K, V> 在 ArkTS 中均受支持,但不支持 enum 的 const enum 变体与反向映射的非常规用法。本工程所有枚举都以"字符串字面量 + 白名单数组"的形式落地(APPROVAL_STATUS_VALUES),这样解析器、日志、埋点可以共用同一份取值清单。

解析时用白名单

// feature_approval/src/main/ets/model/ApprovalDto.ets(节选)
export class ApprovalMapper {
    static readonly MAX_TITLE_LEN: number = 200;
    static readonly MAX_SUMMARY_LEN: number = 4000;

    static fromJson(reader: JsonReader, issues: string[]): Approval { /* ... */ }
    static fromJsonList(text: string, issues: string[]): Approval[] { /* ... */ }
    static fromJsonPaged(text: string, issues: string[]): Paged<Approval> { /* ... */ }
    static fromJsonOne(text: string, issues: string[]): Approval { /* ... */ }
    static fromJsonCount(text: string, issues: string[]): StatusCount { /* ... */ }

    static isKnownStatus(raw: string): boolean { /* APPROVAL_STATUS_VALUES.includes(raw) */ }
    static clampText(text: string, max: number): string { /* 截断 + 记 issue */ }
    static issuesOf(reader: JsonReader, scope: string): string[] { /* 把嵌套 reader 的 issues 搬出来 */ }
}

三个要点:

  1. 不用 JSON.parse(text) as Approval。 as 是编译期断言,运行时零校验。后端少给一个 title 会一路穿透到 UI,表现为白屏或"无标题空行"。
  2. 超长字段要截断并记 issue。 这不是"防御性编程"的洁癖:超长文本会撑爆列表项布局;本地缓存落在 RDB 里,虽然 TEXT 列没有 8KB 一类的单值上限,但把几千字的摘要整页整页地写进快照会白白放大 IO 与查询成本,而 issue 让"契约漂移"变成一条日志而不是一次崩溃。
  3. issuesOf 必须手动搬运。 JsonReader 把 issues 存在每个实例上,嵌套读取(objArray 内部的 reader)的问题不会自动冒泡到外层 —— 这是必须手动搬运的原因,漏了它等于解析告警静默丢失。

5.3 缓存:stale-while-revalidate 与 TTL 分档

SWR 三段式

ApprovalRepositoryImpl.list() 是全章最值得逐行读的方法。它的形状是:

① 读本地快照 → 有则立刻返回 { fromCache: true, refreshing: true }
② 同时发起远端请求
③ 远端成功 → 覆盖本地 + 返回 { fromCache: false, degraded: false }
   远端失败 → 若本地有数据,返回缓存 + { degraded: true };否则返回 Failure

三条分支在 ApprovalRepositoryImpl 里都有对应实现:①/② 在 list()(ApprovalRepositoryImpl.ets:71-99),③ 的降级在 fetchList()(ApprovalRepositoryImpl.ets:120-128:远端失败且带入了 staleFallback 且非取消 → 返回旧数据并 degraded=true)。列表路径的 SWR 降级不是规划中的承诺,而是已落地的代码 —— 对照工程源码阅读时可以逐行验证。

对应到返回类型(本工程是 class 而不是 interface:字段 readonly + 构造函数,配合 instanceof 判别更直接):

// feature_approval/src/main/ets/repo/ApprovalRepository.ets:22-51(节选)
export class ApprovalListResult {
    readonly page: Paged<Approval>;
    readonly fromCache: boolean;
    readonly degraded: boolean;
    readonly refreshing: boolean;
    constructor(page: Paged<Approval>, fromCache: boolean, degraded: boolean, refreshing: boolean) { /* ... */ }
}
export class ApprovalDetailResult {
    readonly approval: Approval;
    readonly fromCache: boolean;
    readonly degraded: boolean;
    readonly refreshing: boolean;
}

三个布尔各管一件事,不能合并:

字段 含义 UI 该做什么
fromCache 这份数据来自本地快照,不是刚拉到的 可以显示"上次同步:xx"这类提示
refreshing 后台正在刷新 显示细进度条,不清空当前内容
degraded 网络失败,展示的是陈旧数据 出一行"离线/网络异常"提示 + 重试入口

把它们合成一个"是否可用"的布尔,UI 就无法区分"数据旧但网络正常"与"网络断了",只能统一给一个含糊的提示。

TTL 分档:为什么不能一刀切

// feature_approval/src/main/ets/datasource/ApprovalCachePolicy.ets:27-117(节选)
export class ApprovalCachePolicy {
    /** 进行中(待审批):状态随时会变,30 秒后就不再新鲜 */
    static readonly TTL_IN_FLIGHT_MS: number = 30 * 1000;
    /** 草稿:只有本机在改,2 分钟 */
    static readonly TTL_DRAFT_MS: number = 2 * 60 * 1000;
    /** 已撤回:5 分钟 */
    static readonly TTL_WITHDRAWN_MS: number = 5 * 60 * 1000;
    /** 终态(通过/驳回):不会再变,10 分钟 */
    static readonly TTL_TERMINAL_MS: number = 10 * 60 * 1000;

    /** 详情页比列表更"重",TTL 按比例缩短;但保底 10 秒,避免每次都打网络 */
    private static readonly DETAIL_FACTOR: number = 0.5;
    private static readonly DETAIL_MIN_MS: number = 10 * 1000;

    static isFresh(updatedAt: number, ttlMs: number): boolean { /* age = Date.now() - updatedAt,时钟异常一律不新鲜 */ }
    static ttlOf(status: ApprovalStatus): number { /* 按档位返回;未知状态走最短档 */ }
    static isItemFresh(item: Approval): boolean { /* isFresh(item.updatedAt, ttlOf(item.status)) */ }
    static detailTtlOf(status: ApprovalStatus): number { /* ttlOf * DETAIL_FACTOR,不低于 DETAIL_MIN_MS */ }
    static isListFresh(items: Approval[]): boolean { /* 每条都新鲜才算新鲜;空列表永远不新鲜 */ }
    static isDetailFresh(item: Approval): boolean { /* ... */ }
}

注意两点实现细节:isFresh 族没有 now 参数(内部取 Date.now(),调用方不必传时间),DETAIL_FACTOR / DETAIL_MIN_MS 是 private(调参只发生在策略类内部)。

分档的判据是"这条数据的状态有多大概率在变":

  • 待审批的单据:同事可能正在审批它,30 秒后本地看到的很可能已经不是最新;
  • 终态(通过/驳回):除非被管理员改,否则不会再变,缓存久一点完全安全;
  • 草稿:只有本机在改(别人看不到你的草稿),所以可以缓存久一点。

空列表永远不算新鲜,这是全文最反直觉、也最实用的一条:

// 语义:空列表不新鲜 —— 因为"缓存了空"经常是"上次请求失败/没数据"的副作用。
// 如果空列表算新鲜,用户会一直看到"暂无数据"而不触发重拉。

一个真实场景:用户第一次进入列表时断网,远端失败、本地也无数据,于是写入一个空快照。如果空列表算新鲜,用户恢复网络后重新进入列表,仍然看到"暂无数据",而且不会自动重拉 —— 必须手动下拉才能恢复。

详情 TTL 比列表短,因为详情是"用户主动点进来的一个具体对象",他对准确性的期待更高("我点开就是想看这个单据现在的状态")。同时保底 10 秒,避免用户反复进出详情页时每次都打网络。

失败时保留旧值

这是"两段式刷新"的第三段,也是最容易做错的一段:

  • 有旧内容时刷新失败:保留旧内容 + 记录错误,不清屏。清屏才是真的惹恼用户 —— 他只是网络抖动了一下,凭什么让整屏数据消失?
  • 没内容时失败:进入 FAILURE 态,给重试入口。

在列表 ViewModel 里的对应实现(结构):

if (result instanceof Success) {
    // 覆盖 items
} else {
    const error: AppError | undefined = Results.errorOrUndefined(result);
    // 取消是正常路径(页面切走/下拉被打断),不能弹错误也不能改状态
    if (error !== undefined && error.isCancellation) {
        return;
    }
    this.errorText = /* UserMessages.forError(error) */;
    if (this.items.length > 0) {
        // 已有内容时刷新失败:保留旧内容 + 记录错误
        this.state = UiState.SUCCESS;
    } else {
        this.state = UiState.FAILURE;
    }
}

误区 把"取消"当成失败。CancelToken 在页面切走时会取消在途请求,此时返回的是 isCancellation === true。如果把它当失败处理,用户会看到"切页面回来弹了一个网络错误弹窗"。


5.4 写操作:写穿透、幂等键、脏标记

读走缓存,写必须穿透

// feature_approval/src/main/ets/repo/ApprovalRepositoryImpl.ets(结构)
submit(cmd: SubmitApprovalCmd): Promise<Result<Approval>> { return this.commitWrite(/* ... */); }
withdraw(cmd: WithdrawCmd): Promise<Result<Approval>> { return this.commitWrite(/* ... */); }
decide(cmd: DecideApprovalCmd): Promise<Result<Approval>> { return this.commitWrite(/* ... */); }

commitWrite 做三件事:

  1. 发请求(带幂等键);
  2. 成功后写回本地缓存(writeBackList / mergeIntoListCache);
  3. 置脏标记(ApprovalModule.markListDirty() / markDetailDirty())。

为什么写一定要穿透缓存:读可以陈旧(用户容忍"刚点进来的数据差 30 秒"),但写必须落到底。如果"审批通过"只改了本地缓存就返回成功,用户会立刻看到状态变了;而一旦他杀掉进程重进,服务端还是"待审批",本地的乐观结果被拉回 —— 这类"我明明审批过了"的投诉极难定位,因为它在本地看起来完全正确。

读写幂等性:语义决定,不由 HTTP 方法决定

// feature_approval/src/main/ets/datasource/ApprovalRemoteDs.ets(约定)
// 读:idempotent: true  + defaultIdempotentRetry    (超时后重试是安全的)
// 写:idempotent: false + noRetry + idempotencyKey  (不能重试,必须显式带幂等键)

这一条很容易被"GET 幂等、POST 非幂等"的直觉带偏。反例在同一个工程里就有两处:

接口 HTTP 方法 幂等性 原因
/auth/login POST 幂等 重复登录只会多发一个令牌,不产生业务单据
/auth/refresh POST 幂等 重复刷新只是换一个令牌
/approval/submit POST 非幂等 重复提交会产生两张单据
/collab/draft/push POST 幂等 整体覆盖语义,重复提交同一 version 无副作用
/approval/decide POST 非幂等 重复审批会产生两条审批意见

所以判断依据只有一句话:重复执行会不会产生额外的、用户可见的副作用。

幂等键的生命周期

这是本工程最容易写错、也最能体现工程功底的一处。看创建页:

// feature_approval/src/main/ets/view/ApprovalCreatePage.ets(结构)
/** 幂等键:私有字段,**刻意不加 @Trace** —— 它不参与渲染,加 @Trace 只会带来无意义的刷新 */
private idempotencyKey: string = '';

private handleSubmitResult(result: Result<Approval>): void {
    const created: Approval | undefined = Results.valueOrUndefined(result);
    if (created !== undefined) {
        this.idempotencyKey = '';
        this.resetForm();
        ApprovalModule.markListDirty();
        // ...埋点 + toast("提交成功") + Router.pop()
        return;
    }
    const error: AppError | undefined = Results.errorOrUndefined(result);
    if (error === undefined || error.isCancellation) {
        return;
    }
    if (error.code === ErrorCode.BIZ_CONFLICT) {
        // 40901:幂等键已被服务端消费过("重复了")。说明上一次请求其实成功了,
        // 只是响应丢失 —— 重试只会再次 40901。清键 + 重置表单 + 置脏 + 提示后 pop 回列表:
        this.idempotencyKey = '';
        this.resetForm();
        ApprovalModule.markListDirty();
        promptAction.showToast({ message: '该单据此前已提交成功,请到列表查看', duration: 2500 });
        Router.pop();
        return;
    }
    // 其他失败(网络超时、500)**保留键**:用户重试时复用同一个键,
    // 服务端才能把"其实成功但响应丢了"的重复请求识别出来,而不是新建一张单。
    this.errorText = UserMessages.forError(error);
}

核心规则:只有"确定不会再重试"时才清键。

结果 键的处理 理由
成功 清 这一次业务已闭环
40901 业务冲突 清 + 置脏 键已被消费,重试无意义;且单据很可能已创建
网络超时 / 500 / 取消 保留 请求可能已到达服务端,重试必须复用同一个键
用户主动改了表单内容 重新生成 这是"另一张单据",不该复用旧键

误区 在 build() 里生成幂等键。build() 会被重复执行,每次都会生成新键 —— 于是"重试"实际上变成了"新建"。幂等键必须由用户动作驱动生成,且生命周期挂在页面实例上(本工程用私有字段 + 页面级 @Local)。

误区 给幂等键加 @Trace。它不参与渲染,加了只会让每次赋值都触发一次无意义的刷新扫描。私有字段默默持有即可。

详情页的动作幂等键:一个 Map

详情页要处理三个动作(通过、驳回、撤回),每个动作要有独立的键:

// feature_approval/src/main/ets/viewmodel/ApprovalDetailViewModel.ets(结构)
/** 动作名 → 幂等键。按动作分别持有,避免"点了通过又点驳回"共用同一个键 */
private pendingKeys: Map<string, string> = new Map<string, string>();

private keyForAction(action: string): string {
    let key: string = this.pendingKeys.get(action) ?? '';
    if (key.length === 0) {
        // 键前缀带动作名:日志与后端凭据能直接看出"这是 approve 还是 withdraw 的键"
        key = IdempotencyKeys.next(`approval-${action}`);
        this.pendingKeys.set(action, key);
    }
    return key;
}

private commitAction(action: string, result: Result<Approval>, successText: string): void {
    const changed: Approval | undefined = Results.valueOrUndefined(result);
    if (changed !== undefined) {
        this.approval = changed;              // 用服务端权威快照刷新详情
        this.actionMessage = successText;
        // 动作已完成 → 丢弃幂等键:否则对同一条单据的下一次操作会被判重复提交
        this.pendingKeys.delete(action);
        return;
    }
    const error: AppError | undefined = Results.errorOrUndefined(result);
    if (error === undefined || error.isCancellation) {
        return;
    }
    // 幂等键**保留**(冲突分支也不清):用户重试时复用同一个键,服务端不会重复建单
    if (error.code === ErrorCode.BIZ_CONFLICT || error.httpStatus === 409) {
        // 冲突:单据已被他人处理。toast 提示 + 自动刷新把新状态拿回来,
        // 不给"重试"按钮 —— 重试一个已经被人处理掉的动作只会再撞一次
        this.conflictText = '该单据已被处理,正在为你刷新最新状态';
        this.refresh();
        return;
    }
    if (error.code === ErrorCode.HTTP_FORBIDDEN || error.code === ErrorCode.BIZ_NO_PERMISSION) {
        // 403:没有权限,不是网络问题。给出"无权限"提示而不是"重试"
        this.noPermission = true;
        this.errorText = UserMessages.NO_PERMISSION;
        return;
    }
    if (error.code === ErrorCode.HTTP_UNAUTHORIZED || error.code === ErrorCode.BIZ_TOKEN_EXPIRED) {
        // 401:交给 lib_auth 的登出链路(清会话 + 广播),这里不重复跳登录页
        this.errorText = UserMessages.SESSION_EXPIRED;
        return;
    }
    this.errorText = UserMessages.forError(error);
}

为什么 403 与 401 要分开处理:

  • HTTP_FORBIDDEN(403):当前用户没有权限。重试一万次也没用,UI 应显示"无权限"并不提供重试按钮;
  • HTTP_UNAUTHORIZED(401):会话失效。这不是本页该处理的问题 —— 交给 lib_auth 的 401 单飞链路(见 11/04)统一清会话并跳登录页。本页若也跳一次,就会和根组件的跳转打架。

详情页没有乐观更新

ApprovalDetailPage.executePendingAction() 的注释值得抄一遍:不做乐观更新。

列表页的"标记已读"可以做乐观更新(低风险、错了也无所谓);但"审批通过"不行。原因:

  • 如果乐观更新后请求失败,界面显示"已通过"而服务端仍是"待审批",用户会以为已经处理完而离开;
  • 审批是有法律/流程意义的动作,界面必须只在服务端确认后才改状态。

代价是一次等待(按钮显示 loading),收益是"看到即真实"。这个取舍对审批是划算的。


5.5 长列表:LazyForEach + 自写 IDataSource

为什么必须自己写

LazyForEach 只认 IDataSource,它不认识 @Trace。V2 的状态观察能让"读到 items 的组件"重绘,但 LazyForEach 是长列表的复用容器,它需要知道"第 i 项变了"才能做最小重建。所以必须显式实现四个接口方法:

// feature_approval/src/main/ets/view/components/ApprovalDataSource.ets(结构)
export class ApprovalDataSource implements IDataSource {
    private listeners: DataChangeListener[] = [];
    /** 越界占位:返回一个稳定的空对象,避免 getData 越界返回 undefined 让 itemBuilder 崩 */
    private static readonly PLACEHOLDER: Approval = /* 空 Approval */;

    totalCount(): number { /* ... */ }
    getData(index: number): Approval { /* 越界返回 PLACEHOLDER */ }
    registerDataChangeListener(listener: DataChangeListener): void { /* ... */ }
    unregisterDataChangeListener(listener: DataChangeListener): void { /* ... */ }

    reload(items: Approval[]): void { /* 整体替换 + notifyReload */ }
    append(items: Approval[]): void { /* 追加 + notifyAdd */ }
    removeById(id: string): void { /* 定位 + notifyDelete */ }
    changeById(fresh: Approval): void { /* 按 fresh.id 定位 + notifyChange */ }
    move(from: number, to: number): void { /* notifyMove */ }
    contains(id: string): boolean { /* ... */ }
    snapshot(): Approval[] { /* 供单测与埋点 */ }

    private notifyReload(): void { /* onDataReloaded */ }
    private notifyAdd(index: number): void { /* onDataAdd */ }
    private notifyChange(index: number): void { /* onDataChange */ }
    private notifyDelete(index: number): void { /* onDataDelete */ }
    private notifyMove(from: number, to: number): void { /* onDataMove */ }
}

三个必须做对的地方

第一:PLACEHOLDER 越界占位。

LazyForEach 在快速滚动、cachedCount 预加载、以及 totalCount 与数据源不同步的瞬间,都可能请求一个"当前不存在的 index"。返回 undefined 会让 itemBuilder 拿到空引用(ArkTS 下可能直接崩)。返回一个稳定的空对象则表现为"闪一个空白项",比崩溃好得多。

第二:变更要"最小化",不要一律 notifyReload。

场景 该用 代价
分页追加 notifyAdd 不重建已有项
标记已读 / 状态变化 notifyChange 只重建一项
撤回后从列表移除 notifyDelete 只移除一项
下拉刷新 / 筛选变化 notifyReload 整表重建(此时本来就要重建)

一律 notifyReload 的代价在千条列表上是可见的卡顿:整表重建意味着所有可见项都要重新绑定数据并重新布局。

第三:数据源与 ViewModel 是同一个数组,不是两份。

// view/ApprovalListPage.ets(节选)
@Local ds: ApprovalDataSource = new ApprovalDataSource();

// ...
@Monitor('vm.items')
onItemsChanged(_monitor: IMonitor): void {
    // 用 provider 闭包指向 vm.items(同一份真相),而不是拷贝一份数组
    this.ds.reload(this.vm.items);
}

拷贝一份数组意味着"两份真相",一定会出现"已读了但列表没变"或反之。闭包指向同一个数组,从结构上消灭这类不一致。

keyGenerator 必须用业务 id

LazyForEach(this.ds, (item: Approval) => {
    ListItem() {
        ApprovalCard({ approval: item, onTap: (a: Approval) => { /* ... */ } })
    }
}, (item: Approval) => item.id)

用 index 做 key 是 LazyForEach 最经典的坑:

  • 筛选条件变化后,第 3 项从"ap-1003"变成"ap-1050",框架认为"第 3 项没变"(因为 key 还是 3),于是复用了旧的组件实例;
  • 表现为"点 A 结果 B 变了"、"列表项内容与详情页不一致"、"已读标记错位"。

用业务 id 之后,"这一项是不是同一项"的判断与业务一致,框架才能做正确的复用与重建。

术语 cachedCount:LazyForEach 在可见区域外预先构建的项数。本工程用 AppConst.LIST_CACHED_COUNT(值为 4)。太小会在快速滚动时露出空白,太大则拖慢首帧(预构建本身有成本)。

说明 AppConst.LIST_CACHED_COUNT = 4 在千条数据列表上是偏保守的取值。它的依据是"屏幕上通常同时可见 5~8 项,预构建 4 屏左右足够覆盖一次快速滑动"。如果你的机型列表项更复杂(含图片),建议先按 2 起步再往上调,用帧率数据而不是感觉来决定。

筛选栏与防抖

// 实际实现(ApprovalListPage.ets:196):
GridRow({ columns: 12, gutter: 8 }) {
    ForEach(FILTER_ITEMS, (filter: FilterItem) => {
        GridCol({ span: 3 }) {
            // 筛选项 chips
        }
    })
}

列表区不用 GridRow,而用 lanes 做响应式分列(ApprovalListPage.ets:342):this.bp === Breakpoint.SM ? 1 : 2 —— 手机 1 列、平板/桌面 2 列。断点档位由 onAreaChange 回填的 this.bp(Breakpoints.ofWidth(...))驱动。

搜索输入必须防抖(本工程 300ms):每敲一个字符就发一次请求,会在用户输入"差旅报销"这 4 个字时发出 4 次请求,而前 3 次的结果毫无价值。防抖的代价是"松手后 300ms 才响应",用户完全感知不到。

筛选条件变化后必须回到第 1 页。 ApprovalQuery.next() / sameFilters() 就是为这件事服务的:sameFilters() 判断"这次查询与上次是不是同一组筛选条件",不同则清空游标。

// feature_approval/src/main/ets/model/ApprovalQuery.ets(结构)
export class ApprovalQuery {
    // 不可变:每个 withXxx 都返回新实例
    withStatus(status: ApprovalStatus | undefined): ApprovalQuery { /* ... */ }
    withKeyword(keyword: string): ApprovalQuery { /* ... */ }
    next(): ApprovalQuery { /* page + 1 */ }
    sameFilters(other: ApprovalQuery): boolean { /* 只比筛选条件,不比 page */ }
    /** 是否为首页(只有首页参与本地缓存,见 ApprovalRepository 的注释;getter 而非方法) */
    get isFirstPage(): boolean { /* this.page === 1 */ }
    cacheKey(): string { /* 筛选条件 → 缓存键(本工程只缓存第一页) */ }
    toQueryMap(): Record<string, string> { /* ... */ }
    toPageQuery(): PageQuery { /* ... */ }
}

为什么 Query 要不可变:分页加载与筛选变化可能并发发生(用户一边滚动一边点筛选)。如果 ApprovalQuery 是可变对象,next() 会原地改 page,而正在飞的那个请求持有的也是同一个对象 —— 它回来时读到的 page 已经不是发起时的值,于是数据被写到错误的页。不可变对象让"发起时的查询条件"被冻结在闭包或局部变量里。


5.6 观测粒度:埋点应该打在哪一层

本工程的一条纪律:状态迁移点唯一,埋点就打在迁移点上,而不是在每个 setState 的地方各写一行。

// viewmodel/ApprovalListViewModel.ets:163-177(节选,事件名走 lib_log 的 EventNames)
@Monitor('state')
onStateChanged(monitor: IMonitor): void {
    // 只在终态(SUCCESS / EMPTY / FAILURE)上报:LOADING / REFRESHING 是中间态,
    // 上报它们会让一次加载产出 2 条 result=success 的事件、耗时被算得偏乐观
    if (!ApprovalListViewModel.isTerminal(this.state)) {
        return;
    }
    Analytics.emit(EventBuilder.of(EventNames.APPROVAL_LIST_LOAD)
        .str(EventProps.PAGE, 'approval_list')
        .str(EventProps.STATUS, `${this.state}`)
        .num(EventProps.COUNT, this.items.length)
        .num(EventProps.DURATION_MS, this.loadStartedAt > 0 ? Date.now() - this.loadStartedAt : 0)
        .result(this.state !== UiState.FAILURE, this.errorText.length > 0 ? 'LIST_FAILED' : '')
        .build());
}

@Monitor('state') 的唯一合法用途就是这类副作用。绝不在 @Monitor 里改状态 —— 那会形成"改状态 → 触发 Monitor → 又改状态"的回环,且极难排查。

契约漂移要单独上报

// repo/ApprovalRepositoryImpl.ets:400-412(节选)
private static readonly EVENT_CONTRACT_DRIFT: string = 'contract_drift';

private reportDrift(tag: string, issues: string[]): void {
    if (issues.length === 0) {
        return;
    }
    XLogger.w(ApprovalRepositoryImpl.LOG_TAG,
        () => `contract drift on ${tag} (${issues.length}): ${issues.join(' | ')}`);
    Analytics.emit(EventBuilder.of(ApprovalRepositoryImpl.EVENT_CONTRACT_DRIFT)
        .str(EventProps.ROUTE, tag)
        .num(EventProps.COUNT, issues.length)
        .str('sample', issues[0])
        .result(false, 'PARSE_DRIFT')
        .build());
}

为什么值得单独一个事件名:解析器的 issue 是"后端契约与客户端预期不符"的唯一证据。如果不单独上报,它只会混在普通日志里,而"某个字段悄悄改名导致某个页面少显示了一行"这类问题,在线上往往只能靠用户反馈才发现。有了这个事件,看板上能直接看到"今天 approval/detail 的契约漂移有 37 次"。

新鲜度提示是可选能力

ViewModel 提供 freshnessHint()(结构),由页面决定是否展示。它把 fromCache / degraded 翻译成一句人话("离线数据,正在重试")。它不是错误提示 —— 用户看到陈旧数据 + 一行说明,比看到一个"加载失败"的弹窗要好。

页面级埋点的正确挂载点(来自 feature_login 的实践)

page_view / page_leave 要成对,才能算出停留时长。挂载点有两个选择:

  • aboutToAppear / aboutToDisappear:不要用。aboutToAppear 在首帧提交前触发,此时页面还不一定真的可见(可能立刻被上层覆盖)。
  • NavDestination 的 onShown / onHidden:用这个。曝光与停留时长必须以"真的可见"为口径。
// 参考实现:feature_login/src/main/ets/view/LoginPage.ets
.onShown(() => {
    this.enteredAt = Date.now();
    Analytics.pageView('LoginPage', RouteNames.LOGIN);
})
.onHidden(() => {
    const duration: number = this.enteredAt > 0 ? Date.now() - this.enteredAt : 0;
    Analytics.pageLeave('LoginPage', duration);
    this.enteredAt = 0;
})

还有一条:路由级埋点(route_push / route_pop)由 lib_router 的 RouteTelemetry 负责,页面不要重复上报。 路由层是唯一必经之路,在那里上报天然完整且一致。

误区 事件名写成裸字符串散落各处。接入上报前必须上移到 lib_log 的 EventNames,否则同一个业务动作会出现两套事件名,看板上算不出完整漏斗。本工程的 feature_message 里有一处本地常量类(MessageEvents),并在注释里明确写了这条约束与原因(避免给并行开发中的 lib_log 制造合并冲突)。feature_approval 已直接使用 EventNames。


5.7 错误分支必须"可复现"

这是本工程与多数示例工程最大的差异点:每一条错误分支都有一个不用改代码就能触发的入口。

两条触发方式

方式一:魔法关键词(走真实请求,由 Mock 层拦截)。

// feature_approval/src/main/ets/mock/ApprovalMockRules.ets(关键词清单)
// 列表标题里包含以下关键词(或筛选条件命中)时,返回对应的场景数据
//   __empty__     → 空列表
//   __long__      → 超长文本数据集(验证截断与布局)
//   __drift__     → 契约漂移数据集(字段缺失/类型不符 → 触发 contract_drift)
//   __overflow__  → 超出单页数量(验证分页边界)
//   __401__       → HTTP 401(验证 HttpStatusMapper 与"清会话跳登录"链路)
//   __biz401__    → 与 __401__ 同映射到 TOKEN_EXPIRED:HTTP 401 + 业务码 40100 双保险
//                  (HttpStatusMapper 与 BizCodeMapper 两条链路都被演练)
//   __500__       → HTTP 500(验证服务端错误映射)
//   __array__     → data 段是数组而非对象(验证解析器不崩)

方式二:特殊 id / 内容(走业务语义)。

// 详情 id
//   __notfound__  → 404,验证"单据不存在"分支
//   __drift__     → 详情契约漂移
// 提交
//   标题包含"重复" 或 幂等键包含 "dup" → 返回 40901(业务冲突)

场景与数据集

// feature_approval/src/main/ets/mock/ApprovalMockRules.ets:45-62
export enum MockScenario {
    NORMAL = 'normal',
    EMPTY = 'empty',
    LONG_TEXT = 'long_text',
    DRIFT = 'drift',
    PAGE_OVERFLOW = 'page_overflow',
    TOKEN_EXPIRED = 'token_expired',
    SERVER_ERROR = 'server_error',
    RAW_ARRAY = 'raw_array'
}
// 注意:枚举里没有"业务 401"成员 —— 业务 401 走 __biz401__ 魔法关键词,
// 与 __401__ 一样映射到 TOKEN_EXPIRED 场景(unauthorized() 返回 HTTP 401 + 业务码 40100 双保险,
// 让 HttpStatusMapper 与 BizCodeMapper 两条映射链路都被演练到)。

// normalDataset:ap-1001 ~ ap-1006,覆盖 5 个状态 + 1 条紧急单
// longTextDataset:标题 200+ 字、摘要 4000+ 字(越界后应被 clampText 截断)
// driftDataset:缺字段、字段类型不符(应产出 issues 并上报 contract_drift)

为什么"可复现"是硬要求

只在代码里写 if (unauthorized) { ... } 而没有一个可复现的触发方式,等于这条分支从没被验证过。它的风险不是"可能有问题",而是"你连它有没有问题都不知道":

  • 401 分支里"清会话 + 跳登录"的顺序写反了?只有真的 401 才知道;
  • 契约漂移的兜底是"显示空字符串"还是"崩溃"?只有真的缺字段才知道;
  • 超长文本截断后布局是否还正常?只有真的喂 2000 字才知道。

describeIssues() 提供了一条命令看全当前命中的规则,配合 MockRegistry.describe() 是排障的第一步。

术语 Mock 前缀匹配:MockRegistry 按"最长路径前缀优先"匹配规则,与注册顺序无关。因此 /approval/list/empty 与 /approval/list 可以共存而不会互相遮蔽。这也解释了为什么本工程不用正则匹配 —— 正则的优先级靠人脑记,前缀长度靠机器算。

说明 ApprovalMockRules 的文件头注释里提到过一个 __emptycode__ 关键词,但在 MagicKeyword 类与 scenarioOf() 里并没有实现它。文档按实现(而非注释)描述能力清单;遇到这类注释与实现的偏差时,以可执行代码为准 —— 这也是"把规则写成可执行文件"的价值之一。


5.8 详情页:状态机驱动 UI 的完整例子

详情页是全章唯一一处"状态机、错误分流、脏标记、幂等键、埋点"全都在场的地方,值得单独看一遍。

// viewmodel/ApprovalDetailViewModel.ets(状态字段)
@Trace approval: Approval | undefined = undefined;
@Trace state: UiState = UiState.IDLE;
@Trace errorText: string = '';
@Trace fromCache: boolean = false;
@Trace degraded: boolean = false;
/** 有动作在飞:期间所有动作按钮 disabled */
@Trace actionInFlight: boolean = false;
/** 40901 的业务冲突文案(与 errorText 分开:它不是"出错",而是"状态变了") */
@Trace conflictText: string = '';
/** 403:无权限。与 errorText 分开是为了 UI 不给"重试"按钮 */
@Trace noPermission: boolean = false;
/** 一次性提示(动作成功后的 toast) */
@Trace actionMessage: string = '';

四个"错误类"字段为什么要分开:

字段 语义 UI 表现
errorText 网络/解析等可重试的失败 错误块 + 重试按钮
conflictText 单据状态已变化(40901) toast 提示 + VM 自动 refresh 拉回最新状态,不给"重试"按钮
noPermission 无权限(403) 提示块,没有按钮
actionMessage 动作成功的一次性提示 toast,消费后清空

如果把它们合并成一个 errorText,UI 就只能给所有人一个"重试"按钮 —— 包括那些重试一万次也不会成功的情况(403)。

actionMessage 必须"消费后清空":

// viewmodel/ApprovalDetailViewModel.ets:211-215
/** 用户已看到提示 → 清空,避免返回详情时提示又冒出来 */
consumeMessage(): void {
    this.actionMessage = '';
    this.conflictText = '';
}

consumeMessage() 返回 void(不回传文案),且同时清掉冲突提示 —— 提示的"只出现一次"由页面保证:页面先 toast 再消费。

页面用 @Monitor 把状态翻译成 UI 副作用:

// view/ApprovalDetailPage.ets:78-99(实际实现)
@Monitor('vm.errorText')
onErrorTextChanged(): void {
    if (this.vm.errorText.length > 0) {
        promptAction.showToast({ message: this.vm.errorText, duration: 2500 });
    }
}

@Monitor('vm.conflictText')
onConflictChanged(): void {
    // 冲突也是 toast(不是顶部条):VM 在置位 conflictText 的同时已经自动 refresh
    if (this.vm.conflictText.length > 0) {
        promptAction.showToast({ message: this.vm.conflictText, duration: 2500 });
    }
}

@Monitor('vm.actionMessage')
onActionMessageChanged(): void {
    if (this.vm.actionMessage.length > 0) {
        promptAction.showToast({ message: this.vm.actionMessage, duration: 2000 });
        // 提示只消费一次:先 toast 再消费,不清理会让"从后台切回来"再次弹出同一个提示
        this.vm.consumeMessage();
    }
}

askConfirm() 负责"危险动作的二次确认"。本工程用 lib_ui 的 ConfirmDialog(纯声明式,不是 @CustomDialog)—— 因为自定义弹窗的创建/销毁与 ArkUI 的生命周期耦合,而声明式弹窗只是"按条件渲染一个组件",没有额外状态。

动作栏显隐完全来自状态机

// 结构示意:ActionBar 的分支条件全部来自 @Computed
if (this.vm.canDecide) { /* 通过 / 驳回 */ }
if (this.vm.canWithdraw) { /* 撤回 */ }
if (this.vm.canEdit) { /* 编辑 */ }

这使得"按钮的可见性"与"服务端的合法性判断"共用同一份规则(ApprovalStatusRules),不会出现"按钮点了但服务端拒绝"的尴尬。


5.9 常见误区

误区 1 把 JSON.parse(text) as Approval 当解析。 as 是编译期断言,运行时零校验。后端少给一个字段会一路穿透到 UI。必须逐字段读 + 白名单 + issue 收集。

误区 2 刷新失败时清空列表。 用户只是网络抖了一下,凭什么让整屏数据消失?有旧内容时保留旧内容 + 记录错误;只有"本来就没内容"时才进 FAILURE。

误区 3 把取消(isCancellation)当失败。 页面切走会取消在途请求。当失败处理会让用户"切页面回来弹了个网络错误"。

误区 4 缓存 TTL 一刀切。 待审批的单据与终态单据的变化概率差一个数量级。一律 10 分钟会让用户看到过期的待办;一律 30 秒会让终态数据被无意义地重拉。

误区 5 空列表算"新鲜"。 用户首次进入时断网 → 写入空快照 → 恢复网络后重新进入仍看到"暂无数据"且不重拉。空列表必须永远不新鲜。

误区 6 幂等键在 build() 或页面构造时生成。 build() 会被重复执行,每次生成新键,"重试"变成了"新建"。键必须由用户动作驱动生成,且挂在页面实例上。

误区 7 所有失败都清幂等键。 网络超时/500 必须保留键 —— 请求可能已到达服务端,重试时复用同一个键,服务端才能识别出重复请求而不是新建一张单。

误区 8 给幂等键加 @Trace。 它不参与渲染,加了只会带来无意义的刷新。私有字段默默持有即可。

误区 9 详情页做乐观更新。 列表的"标记已读"可以,审批不行。乐观更新后请求失败会让用户以为"已经处理完了",而服务端仍是待审批。

误区 10 LazyForEach 的 key 用 index。 筛选/排序变化后会"点 A 结果 B 变了"。必须用业务 id。

误区 11 一律用 notifyReload。 千条列表上整表重建是可见卡顿。追加用 notifyAdd,单条变化用 notifyChange,删除用 notifyDelete。

误区 12 IDataSource.getData() 越界返回 undefined。 cachedCount 预加载与快速滚动都会请求越界 index。必须返回稳定的占位对象。

误区 13 页面直接 @State items: Approval[],然后又拷贝一份给 IDataSource。 两份真相必然不同步。数据源应通过 provider 闭包指向 ViewModel 的同一个数组。

误区 14 在 @Monitor 里改状态。 形成"改状态 → 触发 Monitor → 又改状态"的回环,且极难排查。@Monitor 只用于埋点/日志这类副作用。

误区 15 把 403 和 401 当成同一种错误处理。 403 是无权限(重试无用,不该给重试按钮);401 是会话失效(交给 lib_auth 的单飞链路,本页不要重复跳登录页)。

误区 16 筛选条件变化后不清分页游标。 用户在第 3 页把筛选从"全部"改成"待审批",会请求"待审批的第 4 页" —— 结果为空且不再有下一页,用户以为"待审批一条都没有"。

误区 17 事件名写成裸字符串。 同一动作出现两套事件名,看板算不出漏斗。接入上报前必须上移到 lib_log 的 EventNames。

误区 18 只在代码里写错误分支,不给可复现的触发方式。 等于这条分支从没被验证过。本工程的解法是魔法关键词 + 特殊 id,两条路都不需要改代码。


5.10 实践任务

任务 A:把一条提交链路完整追一遍(必做)

  1. 进入审批列表 → 点"发起审批" → 填写内容 → 提交。
  2. 在日志中按顺序定位:
    • ApprovalCreatePage 生成幂等键的日志(键前缀应为 approval);
    • approval/submit 请求(注意它 不带重试、带幂等键);
    • commitWrite 写回本地缓存的日志;
    • ApprovalModule.markListDirty() 的置位;
    • 返回列表后 consumeListDirty() 返回 true → 强制重拉。

验收 checklist

  • [ ] 返回列表后,新单据出现在列表中(而不是要手动下拉才出现);
  • [ ] 日志中 consumeListDirty 第二次返回 false(脏标记被消费一次就清掉);
  • [ ] 若把网络断开再提交,失败后再提交一次,两次请求携带的幂等键相同。

任务 B:复现全部错误分支(必做)

按 5.7 的清单,逐条触发并记录实际表现。每条都要填三列:触发方式 / 期望 UI / 实际 UI。

场景 触发方式 期望
空列表 关键词 __empty__ AppEmpty,不是 FAILURE
超长文本 关键词 __long__ 标题/摘要被截断,布局不破
契约漂移 关键词 __drift__ 页面正常 + 上报 contract_drift
分页边界 关键词 __overflow__ 最后一页少于 size,hasMore=false
HTTP 401 关键词 __401__ 清会话 → 跳登录页
业务 401 关键词 __biz401__ HTTP 401 + 业务码 40100 双保险,同一收敛路径(两个 Mapper 各走一条)
HTTP 500 关键词 __500__ 有旧内容则保留 + 错误条;无内容则 FAILURE + 重试
非数组 data 关键词 __array__ 解析失败被归一化为 PARSE_FAILED,不崩
单据不存在 详情 id __notfound__ "单据不存在"提示,不是"网络错误"
业务冲突 提交标题含"重复" 或 键含 dup 40901 → 冲突提示 + 清键 + 置脏

验收 checklist

  • [ ] 10 条全部不改一行代码就能复现;
  • [ ] 每条的日志里都能找到对应的错误码(不是笼统的"失败");
  • [ ] __401__ 与 __biz401__ 走到的是同一条收敛路径(清会话 + 跳登录),只是映射器不同。

任务 C:验证 SWR 与"不清屏"(必做)

  1. 正常加载列表(数据入缓存);
  2. 断网;
  3. 杀掉进程重进列表。

验收 checklist

  • [ ] 首屏立刻显示缓存内容(不是骨架屏);
  • [ ] 顶部/底部出现"离线/网络异常"提示(degraded === true);
  • [ ] 内容没有被清空;
  • [ ] 恢复网络后下拉刷新,提示消失且数据更新。

任务 D:验证 TTL 分档(进阶)

  1. 在 ApprovalCachePolicy 里临时把 TTL_TERMINAL_MS 改成 3 * 1000(3 秒);
  2. 打开一条"已通过"的单据详情,退出,3 秒内再次进入 —— 应命中缓存(无请求日志);
  3. 等 4 秒再进入 —— 应触发重拉。

验收 checklist

  • [ ] 能观察到 isDetailFresh() 的返回值随 TTL 变化而翻转;
  • [ ] 恢复 TTL 后,连续进出详情页不会每次都打网络(说明 DETAIL_MIN_MS 生效);
  • [ ] 把 TTL_* 全部改成 0,确认空列表仍然不新鲜(这条不受 TTL 影响)。

任务 E:给列表加"批量撤回"(进阶)

需求:列表页支持多选 + 批量撤回。要求:

  • 每个待撤回项各自一个幂等键(不能共用一个);
  • 部分成功、部分失败要能分别报告(不能"有一个失败就整体回滚");
  • 批量操作结束后只置一次脏标记;
  • 长按进入多选模式,退出多选时清空选择。

验收 checklist

  • [ ] 5 条里第 3 条触发"重复"关键词(40901),其余 4 条成功,UI 能分别说明;
  • [ ] 失败项的幂等键被保留,成功项的键被清除;
  • [ ] markListDirty() 只被调用一次(用日志计数验证);
  • [ ] node scripts/check-deps.mjs 仍通过(不要为了省事去 import 别的 feature)。

5.11 延伸阅读

  • 官方文档 → 应用框架 → ArkUI → 渲染控制 → ForEach / LazyForEach / IDataSource / DataChangeListener
  • 官方文档 → 应用框架 → ArkUI → 状态管理 V2 → @Computed / @Monitor / @Local / @Param
  • 官方文档 → 应用框架 → ArkUI → 布局 → GridRow / GridCol 栅格与断点响应
  • 官方文档 → 应用框架 → Network Kit → Remote Communication Kit(rcp)→ 请求重试与超时策略
  • 官方文档 → 应用框架 → ArkData → 关系型数据库(RDB)(relationalStore 事务与谓词查询)
  • 教程内:06 数据持久化与网络请求、11/02 网络层与统一错误处理、11/04 登录鉴权与会话管理、11/07 多端适配与分布式协同

← 上一节:11/04 登录鉴权与会话管理 | 教程目录 | 下一节:11/06 消息与通知 →

📄 第 5 章 状态管理 05-state-management.md

状态管理是 ArkUI 里唯一"写错不报错、跑起来才诡异"的领域。本章只讲 V2——因为 V2 不是 V1 的语法糖,而是把观测粒度从"对象引用"下沉到"属性字段"的一次重构。理解这一点,后面所有"为什么刷新失败"都能自己推出来。


5.0 本章目标

  1. 能解释 V1 与 V2 在观测粒度上的本质差异,并说出 V2 为什么能用更少的刷新完成同样的更新。
  2. 能选出正确的装饰器组合完成:组件内部状态、父子单向/双向、跨层共享四类通信。
  3. 能排查 "数据变了但界面不刷新"这一问题,并定位到具体缺失的 @Trace、断裂的观测链或越界的观测对象。
  4. 能写出 一个完整的 ViewModel:@ObservedV2 + @Trace + @Computed + @Monitor,并控制其刷新范围。
  5. 能完成 把一段 V1 代码迁移为 V2,且知道迁移中的三类硬性禁忌。

5.1 为什么选 V2

5.1.1 观测粒度:对象引用级 vs 属性级

V1 的观测单元是被 @Observed 标记的对象的引用赋值。V2 的观测单元是被 @Trace 标记的字段的赋值。

维度 V1 V2
观测单元 类的引用(@Observed) 类内的字段(@Trace)
深层对象修改 不触发(除非整体替换或逐层套 @ObjectLink) 逐层都有 @Trace 即触发
数组修改 第一层(长度/整体替换)可观测;元素属性变化依赖 @Observed 类 + @ObjectLink 链,易断链 @Trace 标注的数组,元素与长度变化均可观测
刷新粒度 组件级——整个组件重渲染 组件级为最小单位,但只有依赖该字段的组件被标记为脏
类外使用 必须配合装饰器 @ObservedV2 类可独立于组件使用(适合 ViewModel)
派生值 无内建能力,需手写 @Computed 带缓存
变化监听 @Watch 只监听顶层属性 @Monitor 支持属性路径

术语:观测粒度 ── 框架能感知到"最细小的变化单位"。粒度越细,一次修改需要重新渲染的组件越少,性能越稳。

V1 的典型困境:user.address.city 变了,Text(this.user.address.city) 不刷新,只能靠 this.user.address = new Address(...) 整体替换。这个"整体替换"的代价在大对象上是 O(对象体积),且要求每个引用链上的类都被 @Observed 标记,漏一个就断链。

V2 直接删掉了这层要求:只要 User、Address 都是 @ObservedV2,且 address 与 city 都标了 @Trace,city 的赋值就能精确触达绑定了 city 的组件。

术语:必要渲染范围 ── 一次状态变更真正需要重渲染的组件集合。V2 的目标是把这个集合压到最小;V1 因为粒度粗,常常放大成"父组件 + 全部子组件"。

5.1.2 V1 → V2 完整映射表

V1 装饰器 / API V2 对应 迁移要点
@Component @ComponentV2 入口装饰器。与 V1 不可混用,@ComponentV2 内出现 @State 直接编译报错
@State @Local @Local 是私有状态,不能被父组件初始化,且必须初始化
@Prop @Param 父 → 子单向,子侧只读。V2 不做深拷贝,传的是引用
@Link @Param + @Event 双向拆成"只读输入 + 回调输出",数据流更显式
@Provide @Provider 跨层提供。按变量名匹配,可起别名
@Consume @Consumer 跨层消费,可给默认值(找不到 Provider 时使用)
@Observed @ObservedV2 类级标记;V2 必须叠加 @Trace 才有观测能力
@Track(V1 类属性) @Trace 属性级标记,可作用于嵌套类
@Watch @Monitor V2 支持 'a.b.c' 路径监听,回调参数为 IMonitor
@ObjectLink 无对应(由 @Param 替代) V2 中深观测由 @ObservedV2 + @Trace 保证,不需要专门的"子组件代理"
@State + 局部派生 @Computed V1 无内建派生值;V2 的 @Computed 带缓存
PersistentStorage PersistenceV2 键值式 → "类型 + key"式,底层仍是持久化 KV
AppStorage AppStorageV2 只能存类实例,不能存原始值
@StorageLink / @StorageProp AppStorageV2.connect(...) + @Trace 连接得到的是可观测对象的引用
LocalStorage 无直接对应 用 @Provider / @Consumer 或 AppStorageV2 替代
@Builder / @Styles / @Extend 不变 不属于状态管理范畴
@Once / @Event / @Type V2 新增 V1 无对应能力(@Require 例外:V1 即有,配 @Prop/@State 使用;V2 改为修饰 @Param/@Event)

版本门槛:本教程基线为 API 26;状态管理 V2 装饰器自 API 12 起可用,兼容下限 20 设备不受影响。V2 是唯一推荐写法,V1 装饰器在 API 26 上仍可用(向下兼容),但不再接收新特性,且官方迁移指南的终点是 V2。

5.1.3 三类硬性禁忌

禁忌一:V1 与 V2 装饰器在同一组件内混用。

// 错误写法:@ComponentV2 里出现 @State
@ComponentV2
struct MixedBad {
    @State count: number = 0;      // ❌ 编译报错
    @Local name: string = '';
}
// 正确写法:整组件切到 V2
@ComponentV2
struct AllV2 {
    @Local count: number = 0;
    @Local name: string = '';
}

为什么错:@Component 与 @ComponentV2 是两套不同的观测引擎。V1 引擎在类初始化阶段注入属性代理,V2 引擎在编译期生成观测元数据。同一个组件里两套元数据会互相破坏,所以官方选择在编译期直接拒绝。这也是"不能渐进式混用"的根本原因——迁移的最小单位是一个组件,而不是一个属性。

禁忌二:把 V2 对象传给 V1 组件,观测不到。

// 错误写法:V2 的数据源 + V1 的消费者
@ObservedV2
class Order {
    @Trace status: string = 'pending';
}

@Component                    // V1 组件
struct OrderRowBad {
    @Prop order: Order;       // ❌ 这里的 @Prop 不会观测 Order 的 @Trace
    build() {
        Text(this.order.status)   // status 变了不刷新
    }
}
// 正确写法:消费侧也必须是 V2
@ObservedV2
class Order {
    @Trace status: string = 'pending';
}

@ComponentV2
struct OrderRowGood {
    @Param order: Order = new Order();
    build() {
        Text(this.order.status)   // 精确刷新
    }
}

为什么错:V2 的 @Trace 生成的是 V2 引擎认识的观测元数据;V1 的 @Prop / @ObjectLink 走的是 V1 引擎的代理链。跨引擎边界时,V1 侧只把对象当成普通对象,属性变化对它不可见。结论:一个组件树要么全 V1 要么全 V2,迁移时按"子树"整体切换。

禁忌三:在 V2 组件里使用 PersistentStorage。

// 错误写法
PersistentStorage.persistProp('token', '');   // ❌ V1 时代的持久化 API
// 正确写法
import { PersistenceV2 } from '@kit.ArkUI';
const session: SessionStore = PersistenceV2.connect(SessionStore, 'session', () => new SessionStore())!;

为什么错:PersistentStorage 依赖 V1 的 AppStorage 与 @StorageLink 才能把变化推回 UI,在纯 V2 组件树里它既没有被观测的出口,也会与新持久化方案形成两套数据源。V2 项目里持久化只有 PersistenceV2 一条路。


5.2 组件内与父子通信

5.2.1 装饰器选型表

装饰器 方向 能否被父初始化 可写性 适用场景
@Local 组件内部 否 可写 纯内部状态:展开态、输入框内容、加载中标志
@Param 父 → 子 是(可带默认值) 只读 子组件接收父数据
@Once 父 → 子 是 只读 只在初始化时同步一次,之后父变化不再影响子
@Event 子 → 父 是 可调用 子组件把用户动作回报给父
@Provider 祖先 → 后代 是(本地初始化) 可写 跨多层提供共享状态
@Consumer 后代读取 可本地默认值 可写 跨多层消费共享状态
@Require 修饰 @Param/@Event — — 强制父组件必须传入

术语:@Local(V2) ── 组件私有状态。与 V1 的 @State 最关键的区别是:它不能从父组件初始化。想让外部能设置,就必须显式声明为 @Param——这是 V2 有意为之的"接口显式化"。

5.2.2 @Local:私有且必须初始化

@ComponentV2
struct SearchBar {
    // 必须给初始值,否则编译报错
    @Local keyword: string = '';
    @Local expanded: boolean = false;

    build() {
        Row() {
            TextInput({ text: this.keyword })
                .onChange((value: string) => {
                    this.keyword = value;   // 组件内可写
                })
        }
    }
}

为什么要"必须初始化":@Local 的语义是"这个状态由组件自己负责出生"。没有初始值意味着它的首次取值来源不明——框架无法在父组件未提供时给出确定状态。设计上它把"外部可注入"和"内部自持有"强制分开,避免 V1 里 @State 既可内部初始化又可被父覆盖造成的语义模糊。

5.2.3 @Param:只读单向

@ComponentV2
struct ApprovalCard {
    @Require @Param item: ApprovalItem = new ApprovalItem();
    @Param showBadge: boolean = false;      // 有默认值 → 该参数可选

    build() {
        Row() {
            Text(this.item.title).fontSize($r('app.float.body_fs'))
            if (this.showBadge) {
                Badge({ count: this.item.pendingCount })
            }
        }
    }
}

三条约束:

  1. 子组件只能读,写 this.item = ... 编译报错。
  2. 传对象时不做深拷贝,父子持有同一实例。
  3. 想在子组件内修改,必须走 @Event 回调,或把子组件内的可变副本声明为 @Local。

误区:以为 @Param 像 V1 的 @Prop 一样会复制一份。V2 传的是引用。好处是没有拷贝开销,代价是"子组件改了对象内部字段,父组件也会看到"。需要快照语义时必须手动做一次浅/深拷贝。

显式例外(MVVM 惯例):上面约束 3 针对的是领域数据对象(如 ApprovalItem)。经 @Param 传入的 @ObservedV2 ViewModel 上直接改 @Trace 字段(如 this.vm.keyword = value)是本书 MVVM 分层的标准写法(见 5.6)——ViewModel 本就是"可变状态的宿主",修改经 @Trace 精确通知,不违反单向数据流。约束只禁止 View 越过 VM 修改领域数据。

5.2.4 @Once:只同步一次

@ComponentV2
struct EditableTitle {
    // 只在初始化时接收父组件的 title,之后父组件再改也不影响本组件
    @Once @Param title: string = '';
    @Local editing: string = '';

    aboutToAppear(): void {
        this.editing = this.title;   // 建立一次本地草稿
    }

    build() {
        TextInput({ text: this.editing })
            .onChange((v: string) => { this.editing = v; })
    }
}

适用场景:"以父值为初始值,之后独立演进" 的编辑态。典型是详情页的可编辑表单:进入时拷贝服务端数据,之后用户改动不应被上层轮询刷新覆盖。若去掉 @Once,父组件每次刷新都会把用户的输入抹掉。

5.2.5 @Event:子到父的回调

@ComponentV2
struct ApprovalCard {
    @Require @Param item: ApprovalItem = new ApprovalItem();
    @Event onApprove: (id: string) => void = (_: string) => {};
    @Event onReject: (id: string, reason: string) => void = (_id: string, _reason: string) => {};

    build() {
        Row({ space: 8 }) {
            Button($r('app.string.approve'))
                .onClick(() => { this.onApprove(this.item.id); })
            Button($r('app.string.reject'))
                .onClick(() => { this.onReject(this.item.id, ''); })
        }
    }
}
// 父组件
@ComponentV2
struct ApprovalList {
    @Local vm: ApprovalListViewModel = new ApprovalListViewModel();

    build() {
        List() {
            ForEach(this.vm.items, (item: ApprovalItem) => {
                ListItem() {
                    ApprovalCard({
                        item: item,
                        onApprove: (id: string) => { this.vm.approve(id); },
                        onReject: (id: string, reason: string) => { this.vm.reject(id, reason); }
                    })
                }
            }, (item: ApprovalItem) => item.id)
        }
    }
}

为什么"双向"要拆成 @Param + @Event:V1 的 @Link 让子组件能直接改写父状态,调用方无法知道谁改了数据。V2 把写操作显式化为一次回调,父组件始终是唯一写入者——这在多人协作与问题定位上的价值远大于少写几行代码。

5.2.6 @Provider / @Consumer:跨层共享

@ObservedV2
export class ThemeState {
    @Trace mode: string = 'light';
    @Trace compact: boolean = false;
}

@ComponentV2
struct AppShell {
    // 按变量名 theme 提供
    @Provider() theme: ThemeState = new ThemeState();

    build() {
        Column() {
            DeepChild()
        }
    }
}

@ComponentV2
struct DeepChild {
    // 多层之下的后代直接消费,无需逐层传参
    @Consumer() theme: ThemeState = new ThemeState();

    build() {
        // emoji 与裸字符串仅为教学示意;工程实现文案走 $r() 资源、模式值走枚举
        Text(this.theme.mode === 'dark' ? '🌙' : '☀️')
    }
}

用别名避免同名冲突:

@Provider('appTheme') theme: ThemeState = new ThemeState();
@Consumer('appTheme') theme: ThemeState = new ThemeState();

取舍:

方案 层数 推荐度
逐层 @Param 传递 2~3 层 推荐,依赖关系可见
@Provider / @Consumer 4 层以上 推荐,但需限定为"主题/语言/断点"这类低频变更数据
AppStorageV2 全局单例 跨页面 推荐,见 5.4

误区:把业务数据(如当前审批单)塞进 @Provider。跨层共享的隐式性会让"这个数据从哪来"变得不可追踪。跨层只传"环境类"数据:主题、语言、断点、权限位。


5.3 观测模型

5.3.1 @ObservedV2 + @Trace 必须成对

// 正确:类标 @ObservedV2,需要观测的字段标 @Trace
@ObservedV2
export class ApprovalItem {
    @Trace id: string = '';
    @Trace title: string = '';
    @Trace status: string = 'pending';
    @Trace assignee: User = new User();   // 嵌套对象,类本身也需 @ObservedV2

    // 未标 @Trace:改它不会触发刷新
    loadedAt: number = 0;
}

@ObservedV2
export class User {
    @Trace name: string = '';
    @Trace avatar: string = '';
}
写法 结果
只有 @ObservedV2,字段无 @Trace 不观测。赋值不刷新,且没有任何报错
只有 @Trace,类无 @ObservedV2 编译报错(@Trace 必须位于 @ObservedV2 类中)
两者都有 正常观测,属性级刷新
嵌套对象的类没有标 @ObservedV2 该层是观测链的断点,更深层的变化不可见

术语:观测链 ── 从 UI 绑定的表达式出发,逐层到实际赋值字段的路径。链上任意一层的类缺 @ObservedV2、或字段缺 @Trace,整条链失效,且是静默失效。

误区:以为"字段没变化所以不刷新"是逻辑问题。真实情况常常是"字段变了,但链在上一层就断了"。排查时从绑定表达式往赋值点倒着走,逐个检查每层的两个标注。

5.3.2 @Computed:带缓存的派生值

@ObservedV2
export class ApprovalListViewModel {
    @Trace items: ApprovalItem[] = [];

    /** 依赖 items 中每个元素的 status(@Trace 字段) */
    @Computed
    get pendingCount(): number {
        return this.items.filter((item: ApprovalItem) => item.status === 'pending').length;
    }

    @Computed
    get hasPending(): boolean {
        return this.pendingCount > 0;   // 可以依赖另一个 @Computed
    }
}

缓存规则:

规则 说明
首次读取时计算 惰性求值,未被使用的 @Computed 不消耗算力
依赖变化才失效 依赖 = 计算过程中实际读取过的被观测字段
失效后下次读取才重算 不是"一变就算",避免连续变更时重复计算
必须是 getter 不能有参数,不能是方法
不能有副作用 内部禁止赋值其他状态,否则会造成刷新风暴

四条禁止(否则缓存语义不可靠):

  1. 不能返回值不确定的内容(Date.now()、Math.random())。
  2. 不能访问未标 @Trace 的字段——那部分变化不会让它失效,会返回过期结果且不报错。
  3. 不能在 @Computed 内部调用异步方法。
  4. 不要在 @Computed 内部创建新对象并把它们塞回被观测状态。

什么时候不要用 @Computed:计算成本低于缓存管理成本时(如 a + b)。@Computed 的价值在于"依赖多个字段的聚合"(计数、求和、过滤、格式化);单一字段的简单变换直接用表达式即可。

5.3.3 @Monitor:路径级副作用

本节所有代码块的宿主都是 @ObservedV2 类(ViewModel),不是 @ComponentV2 组件——因此类内只能用 @Trace / 普通字段,不能出现 @Local 等组件装饰器。

@ObservedV2
export class FormViewModel {
    @Trace title: string = '';
    @Trace amount: number = 0;
    @Trace attachment: AttachmentInfo = new AttachmentInfo();

    /** 支持属性路径,依赖链上的每一层都必须有 @Trace */
    @Monitor('attachment.fileSize')
    onFileSizeChange(monitor: IMonitor): void {
        monitor.dirty.forEach((path: string) => {
            const now: IMonitorValue<number> | undefined = monitor.value.find(
                (v: IMonitorValue<number>) => v.path === path
            );
            if (now !== undefined && now.now > 10 * 1024 * 1024) {
                hilog.warn(DOMAIN, TAG, 'attachment too large: %{public}d', now.now);
            }
        });
    }

    /** 同时监听多个路径 */
    @Monitor('title', 'amount')
    onDraftDirty(monitor: IMonitor): void {
        this.draftRevision = this.draftRevision + 1;   // draftRevision 不得标 @Trace
    }

    private draftRevision: number = 0;
}

IMonitor 的字段:

字段 类型 含义
dirty string[] 本次变化涉及的属性路径列表
value IMonitorValue[] 每个路径的详情,含 path、before、now

核心约束:@Monitor 的回调里不要修改被监听的字段。

// 错误写法:造成循环刷新
@Trace count: number = 0;

@Monitor('count')
onCountChange(monitor: IMonitor): void {
    this.count = Math.min(this.count, 100);   // ❌ count → 回调 → 改 count → 回调 ...
}
// 正确写法一:把约束放在写入侧
setCount(next: number): void {
    const clamped: number = Math.max(0, Math.min(next, 100));
    this.count = clamped;
}
// 正确写法二:写到一个明确"不改回源字段"的派生状态
@Trace count: number = 0;
@Trace normalizedCount: number = 0;   // 独立的被观测字段:观察侧不再是"被监听字段"本身

@Monitor('count')
onCountChange(monitor: IMonitor): void {
    this.normalizedCount = Math.max(0, Math.min(this.count, 100));
}

即便框架能检测到直接的自赋值循环并终止,间接循环(A 改 B、B 改 A)仍会导致刷新风暴。原则:@Monitor 只做"读起来像事件"的副作用——上报、校验、写日志、联动另一个独立状态;真正的数据约束写在 setter 里。

5.3.4 @Monitor 与 @Computed 的职责边界

需求 用什么 原因
"值变了就执行某个动作" @Monitor 副作用语义,按时序触发
"我想要一个由多个字段算出来的值" @Computed 声明式依赖,带缓存
"值变了要通知服务端" @Monitor 一次性动作
"值变了要显示不同的文案" @Computed 纯计算,无需副作用

5.4 全局状态

5.4.1 AppStorageV2.connect:按"类型 + key"取单例

// lib_auth/src/main/ets/SessionStore.ets
import { AppStorageV2 } from '@kit.ArkUI';

@ObservedV2
export class SessionStore {
    /** 存储 key 用常量:拼错内联字符串会静默创建新实例(见 5.4.5) */
    private static readonly KEY: string = 'session';

    @Trace token: string = '';
    @Trace userId: string = '';
    @Trace userName: string = '';
    @Trace roles: string[] = [];

    @Computed
    get isLoggedIn(): boolean {
        return this.token.length > 0;
    }

    /** 统一入口:任何模块都通过它拿到同一个实例 */
    static shared(): SessionStore {
        const store: SessionStore | undefined =
            AppStorageV2.connect(SessionStore, SessionStore.KEY, () => new SessionStore());
        if (store === undefined) {
            // 同 key 不同类型会返回 undefined:静默 new 会造出孤儿实例,两份"单例"并存
            throw new Error('SessionStore.connect failed: key/type conflict');
        }
        return store;
    }

    clear(): void {
        this.token = '';
        this.userId = '';
        this.userName = '';
        this.roles = [];
    }
}

connect 的行为语义:

行为 说明
首次调用 用 creator 创建实例,以 key 注册
后续同 key 同类型 返回同一个实例引用
同 key 不同类型 返回 undefined(不抛错,静默失败,必须防)
实例是 @ObservedV2 类 其中的 @Trace 字段变化会通知所有使用方

术语:AppStorageV2 ── V2 的全局状态容器。它按"构造类型 + 字符串 key"作为唯一标识;与 V1 的 AppStorage 不同,它只接受类实例,不能存原始值。

版本门槛:本教程基线为 API 26;AppStorageV2 / PersistenceV2 自 API 12 起可用,兼容下限 20 设备不受影响。二者是全局状态与持久化的唯一推荐入口,V1 的 AppStorage / LocalStorage / PersistentStorage 在新项目中不再使用。

5.4.2 为什么不能存原始值

// 错误写法:直接存原始值
const count: number | undefined = AppStorageV2.connect(Number, 'count', () => 0);   // ❌
// 正确写法:用类承载
@ObservedV2
class CounterState {
    @Trace value: number = 0;
}

const counter: CounterState =
    AppStorageV2.connect(CounterState, 'counter', () => new CounterState())!;
counter.value = counter.value + 1;   // 有 @Trace,UI 能刷新

为什么错:AppStorageV2 的观测能力来源于 @ObservedV2 + @Trace。原始值(number / string / boolean)没有类结构,也就没有 @Trace 元数据——写进去能读出来,但任何变化都不会触发刷新。这类问题在排查时极具迷惑性:数据确实写进去了,控制台打印也正确,只有 UI 不动。

统一写法:所有全局状态都定义一个 @ObservedV2 类,字段标 @Trace,通过 static shared() 暴露。哪怕只有一个字段,也包一层类。

5.4.3 PersistenceV2:持久化全局状态

// lib_storage/src/main/ets/SettingsStore.ets
import { PersistenceV2, Type } from '@kit.ArkUI';

/** 存储 key 用常量:拼错内联字符串会静默创建新实例(见 5.4.5) */
const SETTINGS_KEY: string = 'settings';

@ObservedV2
export class NotificationConfig {
    @Trace enabled: boolean = true;
    @Trace quietStartHour: number = 22;
    @Trace quietEndHour: number = 8;
}

@ObservedV2
export class SettingsStore {
    @Trace theme: string = 'light';
    @Trace language: string = 'zh-Hans';
    @Trace fontSizeScale: number = 1.0;

    // 嵌套类属性:必须标 @Type,否则序列化/反序列化会出错(见下文)
    @Trace @Type(NotificationConfig) notification: NotificationConfig = new NotificationConfig();

    // 类数组:@Type 指定元素类型
    @Trace @Type(RecentsEntry) recents: RecentsEntry[] = [];

    // 日期:@Type(Date) 才能被正确还原为 Date 实例
    @Trace @Type(Date) lastSyncAt: Date = new Date(0);

    static shared(): SettingsStore {
        const store: SettingsStore | undefined =
            PersistenceV2.connect(SettingsStore, SETTINGS_KEY, () => new SettingsStore());
        if (store === undefined) {
            // 同 key 不同类型会返回 undefined:静默 new 会造出孤儿实例,两份"单例"并存
            throw new Error('SettingsStore.connect failed: key/type conflict');
        }
        return store;
    }

    /** 关键数据(如登录态、草稿)落盘前手动触发一次 */
    flush(): void {
        PersistenceV2.save(SETTINGS_KEY);
    }
}

@ObservedV2
export class RecentsEntry {
    @Trace key: string = '';
    @Trace visitedAt: number = 0;
}

PersistenceV2 与 AppStorageV2 的差别:

维度 AppStorageV2 PersistenceV2
生效范围 进程内存 进程内存 + 磁盘
落盘时机 不落盘 应用退出时 + 定期 + 手动 save()
适用数据 会话、当前用户 用户偏好、草稿、最近记录
性能 纯内存 写入有序列化与 IO 成本
容量 无硬限制 只适合小数据,量级 KB

5.4.4 嵌套类属性必须标 @Type(静默失败的坑)

这是 V2 里最隐蔽的一类问题,值得单独讲透。

不标 @Type 会发生什么:PersistenceV2 把对象序列化成 JSON 存盘;重启后读回来的是普通对象字面量,而不是 NotificationConfig 的实例。

// 错误写法:漏了 @Type
@ObservedV2
export class SettingsStoreBad {
    @Trace notification: NotificationConfig = new NotificationConfig();
}

重启后:

const s: SettingsStoreBad = PersistenceV2.connect(SettingsStoreBad, 'settings', () => new SettingsStoreBad())!;
// s.notification 的类型标注是 NotificationConfig,但运行时只是个普通 object:
// - 原型方法丢失:s.notification.reset() 会报 not a function
// - @Trace 观测失效:s.notification.enabled = false 不刷新 UI
// - 类型断言检查(instanceof)失败
// 正确写法
@Trace @Type(NotificationConfig) notification: NotificationConfig = new NotificationConfig();

需要 @Type 的三种情况:

属性类型 写法
自定义类 @Trace @Type(SomeClass) field: SomeClass = new SomeClass();
自定义类数组 @Trace @Type(SomeClass) list: SomeClass[] = [];
Date / Set / Map @Trace @Type(Date) at: Date = new Date(); 等

不需要 @Type 的情况:string / number / boolean、这些原始类型的数组、以及字面量对象(Record<string, string> 之类,但此类属性内部字段变化不可观测,仅整体引用替换可观测)。

误区:只在"新建数据"时测试持久化。新建对象是内存里的真实例,一切正常;只有"杀掉进程再启动"这一步才能暴露 @Type 缺失。所以持久化的验收用例必须包含"冷启动后读取"。

误区:把带方法的类直接交给 PersistenceV2。函数无法被 JSON.stringify 序列化,存盘时被丢弃;即使标了 @Type,方法也不会随数据恢复。持久化的类只放数据字段,行为放在外部 service。

5.4.5 全局状态的治理纪律

纪律 理由
每个全局状态只有一个 static shared() 入口 避免各模块自己 connect 时写错 key 或类型
key 用常量,不用内联字符串 拼错 key 会静默创建一个新实例,两个"单例"并存
全局状态只放"跨页面存活"的数据 放多了会变成隐式全局变量,测试与复现都会变难
退出登录时显式 clear() 全局状态是内存泄漏的温床,也是越权展示的常见来源
持久化的类不含函数、不含大对象 序列化成本与失败风险

5.5 性能边界

5.5.1 观测粒度就是字段

一次 @Trace 字段赋值,框架会把依赖该字段的 UI 节点标记为脏。因此性能的关键不是"减少赋值次数",而是减少一次赋值所波及的绑定数量。

// 差:一个组件绑定了 20 个字段,任一字段变化都让整个组件重渲染
@ComponentV2
struct HeavyBad {
    @Param item: ApprovalItem = new ApprovalItem();
    build() {
        Column() {
            Text(this.item.title)
            Text(this.item.status)
            // ... 另外 18 个绑定
        }
    }
}
// 好:拆成小组件,每个只绑定自己关心的字段
@ComponentV2
struct ApprovalTitle {
    @Param item: ApprovalItem = new ApprovalItem();
    build() { Text(this.item.title) }
}

@ComponentV2
struct ApprovalStatus {
    @Param item: ApprovalItem = new ApprovalItem();
    build() { Text(this.item.status) }
}

拆分之后,item.status 变化只重建 ApprovalStatus,ApprovalTitle 不受影响。

5.5.2 大列表:整体替换优于逐项观测

对一个 1000 条的列表,逐项标 @Trace 并在循环里逐条改,会让框架在短时间内产生 1000 次脏标记;而一次整体替换只产生 1 次。

// 差:逐项修改,N 次观测开销
for (let i = 0; i < this.items.length; i++) {
    const target: ApprovalItem | undefined = this.items.find((x: ApprovalItem) => x.id === ids[i]);
    if (target !== undefined) {
        target.status = 'approved';   // 每次都是一次脏标记
    }
}
// 好:先在普通对象上攒好,再一次性替换
const next: ApprovalItem[] = this.items.map((item: ApprovalItem) => {
    const copy: ApprovalItem = ApprovalItem.copyOf(item);
    if (ids.includes(item.id)) {
        copy.status = 'approved';
    }
    return copy;
});
this.items = next;   // 一次观测

配合 ForEach 的 key 函数(业务 id,纪律见第 3 章),框架按 key 复用节点并更新传入的 @Param 引用,绑定变化字段的行随之精确重渲染:

ForEach(this.items, (item: ApprovalItem) => {
    ListItem() { ApprovalRow({ item: item }) }
}, (item: ApprovalItem) => item.id)   // key 统一用业务 id,不掺版本号

为什么 key 里不要带 revision 之类的版本号:key 变化等价于「这一项换了」,框架会销毁并重建该行节点,行内状态(展开、输入中)全部丢失;而 @Param 引用更新 + @Trace 观测本来就能驱动行内刷新,版本号是多余的。

版本门槛:本教程基线为 API 26;LazyForEach / IDataSource 自 API 9/10 起可用,兼容下限 20 设备不受影响。长列表优先使用 LazyForEach 配合 IDataSource,此时"整体替换"指的是替换数据源,而不是替换整个组件树。

5.5.3 不要把大对象塞进 @Trace

不要标 @Trace 的数据 原因 替代方案
超长文本(几万字的正文) 每次赋值都做一次脏传播,且比较成本高 存 id,正文放 RDB,用 @Local 承接当前页展示
base64 图片字符串 单条几百 KB,参与观测毫无收益 存文件,@Trace 只存路径
整页 HTML / 富文本 DOM 结构 内部结构不参与 UI 绑定 用 Web 组件加载 URL 或本地文件
一次查询返回的 1000 条原始 JSON 观测整个数组没有意义 映射为轻量领域模型后再观测
// 差
@ObservedV2
class DraftBad {
    @Trace contentHtml: string = '';   // 可能是 200KB
}

// 好
@ObservedV2
class DraftGood {
    @Trace draftId: string = '';
    @Trace revision: number = 0;
    @Trace summary: string = '';       // 只观测列表要展示的短摘要
    // 正文存在 RDB,展示时按 draftId 读取到 @Local
}

5.5.4 @Param 的拷贝成本模型

V2 的 @Param 不做深拷贝,所以没有 V1 @Prop 那种"传对象即 O(对象体积)"的开销。代价是父子共享可变对象:

风险 表现 规避
子组件间接修改"父的数据" 通过 @Param 拿到的对象内部字段被改,父侧也变了 子组件只能读;需要改动时走 @Event
多个列表项共享同一实例 改一项,多处同时变 每项使用独立实例,或替换时统一重建
想保存快照却存了引用 历史版本被后续修改污染 显式写不可变副本方法

误区:以为 V2 的性能优势来自"更快的赋值"。真正原因是更小的必要渲染范围——同样的赋值,V1 可能重建一整棵子树,V2 只重建绑定该字段的叶子组件。


5.6 与架构的关系

5.6.1 ViewModel 即"被观测的类"

V2 让状态管理可以退出 UI 层。@ObservedV2 类与组件完全解耦,可以独立于任何组件存在、可以被单测直接构造:这正是 ViewModel 需要的形态。

分层约定:

View(@ComponentV2 组件)
  ├── 只做三件事:绑定字段、转发事件、组织布局
  └── 不含业务判断、不发网络请求、不拼 SQL

ViewModel(@ObservedV2 类)
  ├── 持有 @Trace 可展示状态
  ├── 暴露 @Computed 派生值
  ├── 用 @Monitor 处理联动与上报
  └── 依赖 Repository(不含 UI 类型)

Repository(普通类,见第 6 章)
  └── 负责数据来源编排与缓存

判断 View 是否"干净"的标准:组件里是否只剩字段绑定与事件转发——不含业务判断、不含数据获取、不含状态换算。是,则分层正确。

5.6.2 完整 ViewModel 示例

// feature_approval/src/main/ets/viewmodel/ApprovalListViewModel.ets
import { hilog } from '@kit.PerformanceAnalysisKit';
import { AppStorageV2 } from '@kit.ArkUI';
import { ApprovalItem, ApprovalFilter } from 'lib_base';
import { Result, AppError } from 'lib_base';
import { ApprovalRepository } from 'lib_storage';
import { SessionStore } from 'lib_auth';
import { Tracker } from 'lib_log';

const DOMAIN: number = 0x0F03;
const TAG: string = 'ApprovalListVM';

@ObservedV2
export class ApprovalListViewModel {
    // ---------- 被观测的状态 ----------
    @Trace items: ApprovalItem[] = [];
    @Trace filter: ApprovalFilter = ApprovalFilter.ALL;
    @Trace keyword: string = '';
    @Trace loading: boolean = false;
    @Trace errorMessage: string = '';
    @Trace loadedAt: number = 0;

    // 不分页元信息:用普通字段即可,界面不直接绑定
    private pageSize: number = 20;
    private repo: ApprovalRepository = ApprovalRepository.shared();
    // 显式例外:VM 持有 NavPathStack 属于"导航句柄"而非业务状态——
    // 分层约定禁止的是业务判断与数据获取进 View;VM 只在动作完成后用它做纯跳转
    private stack: NavPathStack | undefined = undefined;

    // ---------- 派生值:带缓存 ----------
    @Computed
    get filtered(): ApprovalItem[] {
        const kw: string = this.keyword.trim().toLowerCase();
        const byFilter: ApprovalItem[] = this.filter === ApprovalFilter.ALL
            ? this.items
            : this.items.filter((item: ApprovalItem) => item.status === this.filter);
        if (kw.length === 0) {
            return byFilter;
        }
        return byFilter.filter((item: ApprovalItem) => item.title.toLowerCase().includes(kw));
    }

    @Computed
    get isEmpty(): boolean {
        return !this.loading && this.filtered.length === 0;
    }

    @Computed
    get pendingCount(): number {
        // 依赖 items 中每个元素的 status(@Trace 字段)
        return this.items.filter((item: ApprovalItem) => item.status === 'pending').length;
    }

    @Computed
    get title(): ResourceStr {
        return this.filter === ApprovalFilter.ALL
            ? $r('app.string.approval_all')
            : $r('app.string.approval_pending');
    }

    // ---------- 副作用:路径级监听 ----------
    @Monitor('filter', 'keyword')
    onQueryChanged(monitor: IMonitor): void {
        monitor.dirty.forEach((path: string): void => {
            const payload: Record<string, string> = { field: path };
            Tracker.event('approval_list_query', payload);
        });
        // 只做上报与状态联动,不改回被监听字段
        this.errorMessage = '';
    }

    /** 首次进入:只在数据过期时拉取 */
    async refreshIfStale(maxAgeMs: number): Promise<void> {
        const age: number = Date.now() - this.loadedAt;
        if (this.items.length > 0 && age < maxAgeMs) {
            return;
        }
        await this.load();
    }

    async load(): Promise<void> {
        if (this.loading) {
            return;
        }
        this.loading = true;
        this.errorMessage = '';
        const result: Result<ApprovalItem[]> = await this.repo.list(this.filter);
        result.fold(
            (items: ApprovalItem[]) => {
                // 一次性整体替换:只产生一次观测开销
                this.items = items;
                this.loadedAt = Date.now();
                hilog.info(DOMAIN, TAG, 'loaded %{public}d items', items.length);
            },
            (err: AppError) => {
                this.errorMessage = err.userMessage;
                hilog.error(DOMAIN, TAG, 'load failed: %{public}s', err.code);
            }
        );
        this.loading = false;
    }

    /** 只刷新单条:避免整表刷新 */
    applyUpdated(id: string, status: string, revision: number): void {
        const next: ApprovalItem[] = this.items.map((item: ApprovalItem) => {
            if (item.id !== id) {
                return item;
            }
            const copy: ApprovalItem = ApprovalItem.copyOf(item);
            copy.status = status;
            copy.revision = revision;
            return copy;
        });
        this.items = next;
    }

    async approve(id: string): Promise<void> {
        const result: Result<void> = await this.repo.approve(id, SessionStore.shared().userId);
        result.fold(
            (): void => { this.applyUpdated(id, 'approved', Date.now()); },
            (err: AppError): void => { this.errorMessage = err.userMessage; }
        );
    }

    bindStack(stack: NavPathStack): void {
        // 显式例外注见类内 stack 字段声明处:导航句柄可由 View 注入 VM
        this.stack = stack;
    }
}
// View:只有绑定与转发
@ComponentV2
export struct ApprovalListView {
    // 默认值仅为编译占位;真实页面必须显式传入同一个 VM 实例
    @Param vm: ApprovalListViewModel = new ApprovalListViewModel();

    build() {
        NavDestination() {
            Column() {
                Search({ value: this.vm.keyword })
                    .onChange((value: string) => { this.vm.keyword = value; })

                if (this.vm.loading) {
                    LoadingProgress().width(32).height(32)
                } else if (this.vm.isEmpty) {
                    Text($r('app.string.empty_approval'))
                } else {
                    List() {
                        ForEach(this.vm.filtered, (item: ApprovalItem) => {
                            ListItem() {
                                ApprovalCard({
                                    item: item,
                                    onApprove: (id: string) => { this.vm.approve(id); }
                                })
                            }
                        }, (item: ApprovalItem) => item.id)   // key 用业务 id
                    }
                }

                if (this.vm.errorMessage.length > 0) {
                    Text(this.vm.errorMessage).fontColor($r('app.color.error'))
                }
            }
        }
        .title(this.vm.title)
    }
}

这个结构里,View 里没有任何 if (status === 'pending') 之类的业务判断——过滤在 @Computed,状态判断在 ViewModel,数据获取在 Repository。


5.7 深层对象更新:V1 写法 vs V2 写法

这是最能体现两代差异的一组对照。目标:修改 user.address.city 并让界面刷新。

V1 写法(仅作对照,新代码禁止使用)

V1 达成同一目标的形态(完整代码见附录 A):

// ⚠️ V1 代码,仅用于说明观测粒度差异,请勿在新项目中使用
@Observed class AddressV1 { city: string = ''; }
@Observed class UserV1 { name: string = ''; address: AddressV1 = new AddressV1(); }

// 每个消费深层字段的组件都要拆出来、用 @ObjectLink 接住:
@Component struct AddressRowV1 { @ObjectLink address: AddressV1; build() { Text(this.address.city) } }
@Component struct ProfileV1 { @ObjectLink user: UserV1; build() { Column() { AddressRowV1({ address: this.user.address }) } } }

this.user.address.city = 'Shanghai' 本身能生效,但只有 AddressRowV1 被通知,ProfileV1 无从感知;想在这一层展示 city,只能整体替换 this.user.address = new AddressV1()——重建整个对象。

V1 的三重负担:

  1. 每一层都要 @Observed,漏一层即断链。
  2. 每一个消费该层的组件都要 @ObjectLink,层级越深组件拆分越碎。
  3. 跨层展示必须整体替换(user.address = new Address(...)),大对象上成本高,且会丢失未复制字段。

V2 写法

// ✅ V2:全链路 @ObservedV2 + @Trace 一次到位
@ObservedV2
export class Address {
    @Trace city: string = '';
    @Trace detail: string = '';
}

@ObservedV2
export class User {
    @Trace name: string = '';
    @Trace address: Address = new Address();
}

@ComponentV2
struct Profile {
    @Param user: User = new User();

    build() {
        Column() {
            // 顶层字段:直接绑定
            Text(this.user.name)
            // 深层字段:直接绑定,无需子组件、无需 @ObjectLink
            Text(this.user.address.city)
            Text(this.user.address.detail)
        }
    }
}

// 更新 city:一次赋值,只有绑定 city 的节点重渲染
// this.vm.user.address.city = 'Shanghai';

差异归纳

对比项 V1 V2
需要标注的位置 每个类的 @Observed + 每个消费组件的 @ObjectLink 每个类的 @ObservedV2 + 每个字段的 @Trace
深层字段直接绑定 不支持,需拆分组件 支持
更新方式 深层整体替换,或拆分组件 直接赋值字段
更新成本 O(被替换对象体积) O(1) 脏标记
断链风险 高(漏标即静默失效) 中(漏 @Trace 仍静默,但检查点集中在类定义处)
组件拆分动机 被迫拆分(为了观测) 主动拆分(为了性能)

最后一行值得强调:**V1 拆分组件常常是"被观测机制逼的",V2 拆分组件才是"为了性能主动做的"。**两者动机不同,拆出来的粒度也不该相同——迁移时不要照搬 V1 的组件树。


5.8 迁移与落地清单

5.8.1 迁移顺序(自底向上)

  1. 先改数据模型:把 lib_base 里的领域模型全部换成 @ObservedV2 + @Trace。
  2. 再改全局状态:AppStorage → AppStorageV2,PersistentStorage → PersistenceV2,并给嵌套类补 @Type。
  3. 再改 ViewModel:接入 @Computed 与 @Monitor。
  4. 最后改组件:整棵子树从 @Component 切到 @ComponentV2,@State → @Local、@Prop → @Param、@Link → @Param + @Event。
  5. 删掉 V1 遗留物:@Observed、@ObjectLink、@Watch、@StorageLink。

5.8.2 迁移检查清单

  • [ ] 全工程搜索 @Observed(、@ObjectLink、@Watch、@StorageLink、PersistentStorage 均无残留
  • [ ] 不存在 @Component 与 @ComponentV2 混用的文件(同一 .ets 里两种都出现即需复核)
  • [ ] 每个 @ObservedV2 类中,所有会被修改且需要驱动 UI 的字段都标了 @Trace
  • [ ] 每个 @Computed 的依赖字段全部标了 @Trace(否则缓存不会失效)
  • [ ] 每个 @Monitor 的回调不会写回被监听字段(直接或间接都不会)
  • [ ] 全局状态均为类实例,不存在 AppStorageV2.connect(Number, ...) 之类写法
  • [ ] 所有持久化类的嵌套类属性、类数组、Date 均标了 @Type
  • [ ] 持久化的类中不含函数成员
  • [ ] 验证过"杀进程重启后读取持久化数据"的完整路径
  • [ ] @Local 全部有初始值
  • [ ] View 组件内不含业务判断逻辑(抽查 3 个页面)
  • [ ] 大列表使用整体替换或 LazyForEach,未在循环内逐项 @Trace 赋值

5.9 常见误区

1. 只标 @ObservedV2 不标 @Trace

// 错误写法
@ObservedV2
export class Order {
    status: string = 'pending';   // 没有 @Trace
}
// 正确写法
@ObservedV2
export class Order {
    @Trace status: string = 'pending';
}

为什么错:@ObservedV2 只是声明"这个类可以参与观测",真正建立观测的是 @Trace。缺 @Trace 时,赋值既不会报错也不会刷新——这是 V2 里最高频的静默失败。排查口诀:界面不刷新,先数 @Trace。

2. @Local 想从父组件初始化

// 错误写法
@ComponentV2
struct Child {
    @Local value: string = '';
}
// 父组件:Child({ value: 'abc' })   ❌ @Local 不能被父初始化
// 正确写法
@ComponentV2
struct Child {
    @Param value: string = '';
}
// 父组件:Child({ value: 'abc' })

为什么错:@Local 的语义是"组件私有的、自持有的状态",框架明确禁止外部注入以保证数据流单向。需要外部可设置的属性就必须声明为 @Param;两者混用会让"这个值从哪来"变得无法追踪。

3. 在 @Computed 里读取未标 @Trace 的字段

// 错误写法
@ObservedV2
class CartVM {
    @Trace items: CartItem[] = [];
    couponDiscount: number = 0;   // 未标 @Trace

    @Computed
    get total(): number {
        const sub: number = this.items.reduce((s: number, i: CartItem) => s + i.price, 0);
        return sub - this.couponDiscount;   // 依赖了不可观测字段
    }
}
// 正确写法
@ObservedV2
class CartVM {
    @Trace items: CartItem[] = [];
    @Trace couponDiscount: number = 0;

    @Computed
    get total(): number {
        const sub: number = this.items.reduce((s: number, i: CartItem) => s + i.price, 0);
        return sub - this.couponDiscount;
    }
}

为什么错:@Computed 的缓存失效条件是"计算过程中读取过的 @Trace 字段发生了变化"。读取未标 @Trace 的字段,既不会建立依赖,也不会让缓存失效——couponDiscount 改了之后,total 仍然返回上一次的缓存值,且永远不会报错。

4. 在 @Monitor 里写回被监听字段

// 错误写法
@Trace amount: number = 0;

@Monitor('amount')
onAmountChange(monitor: IMonitor): void {
    this.amount = Math.max(0, this.amount);   // ❌ 触发自身回调
}
// 正确写法:约束写在写入侧
setAmount(next: number): void {
    const clamped: number = Math.max(0, Math.min(next, 1000000));
    if (clamped !== this.amount) {
        this.amount = clamped;
    }
}

为什么错:@Monitor 是"变化之后"的通知;在通知里再改同一个字段,会再次触发通知。即使框架能拦截直接自赋值,A→B→A 这类间接环依然会造成刷新风暴,表现为界面卡顿、日志刷屏、CPU 持续高位。

5. AppStorageV2 存原始值

// 错误写法
const flag: boolean | undefined = AppStorageV2.connect(Boolean, 'flag', () => false);   // ❌
flag = true;   // 界面不动
// 正确写法
@ObservedV2
export class FeatureFlags {
    @Trace newApprovalFlow: boolean = false;
}

const flags: FeatureFlags =
    AppStorageV2.connect(FeatureFlags, 'flags', () => new FeatureFlags())!;
flags.newApprovalFlow = true;   // ✅ 正常刷新

为什么错:观测能力来自 @ObservedV2 + @Trace,原始值没有任何可观测元数据。数据"存进去了、读出来也对",只是没人通知 UI 重渲染——排查难度极高。所有全局状态一律用类包裹。

6. 持久化类的嵌套属性漏标 @Type

// 错误写法
@ObservedV2
export class UserSettings {
    @Trace profile: Profile = new Profile();   // 漏了 @Type
}
// 正确写法
@ObservedV2
export class UserSettings {
    @Trace @Type(Profile) profile: Profile = new Profile();
}

为什么错:PersistenceV2 用 JSON 序列化落盘,读回来只能得到普通对象;@Type 是框架还原原型(并恢复 @Trace 观测)的唯一依据。缺它会同时造成三个后果:原型方法丢失、嵌套字段不可观测、instanceof 判定失败。而且只在冷启动后才暴露——日常开发中几乎不会被发现。

7. 用 @Computed 代替状态

// 错误写法:把 @Computed 当作可写字段
@ObservedV2
class VM {
    @Computed
    get fullName(): string {
        return `${this.first} ${this.last}`;
    }
    // 某处:this.fullName = 'A B';   ❌ 编译报错
}
// 正确写法:写源字段
this.first = 'A';
this.last = 'B';
// fullName 自动重算

为什么错:@Computed 只能是 getter。派生状态的正确来源永远是源字段,直接写派生值会导致"派生值与源值不一致",而这正是派生值要消除的问题。

8. V2 数据模型传给 V1 组件

// 错误写法
@Component
struct LegacyRow {
    @Prop item: ApprovalItem = new ApprovalItem();   // ApprovalItem 是 @ObservedV2 类
    build() { Text(this.item.status) }               // 不刷新
}
// 正确写法:按子树整体切换
@ComponentV2
struct ModernRow {
    @Param item: ApprovalItem = new ApprovalItem();
    build() { Text(this.item.status) }
}

为什么错:V1 与 V2 是两套观测引擎,@Prop 不认识 @Trace 生成的元数据。跨引擎传递时,接收方只看到一个普通对象。迁移必须按子树整体切换,不存在"新老共存"的中间态。

9. 大列表逐项改字段

// 错误写法
this.items.forEach((item: ApprovalItem) => {
    item.status = 'expired';   // 1000 次脏传播
});
// 正确写法
const next: ApprovalItem[] = this.items.map((item: ApprovalItem) => {
    const copy: ApprovalItem = ApprovalItem.copyOf(item);
    copy.status = 'expired';
    return copy;
});
this.items = next;   // 1 次

为什么错:V2 的观测粒度细,并不意味着"逐项修改没有成本"。每次 @Trace 赋值都会走一遍脏标记与依赖收集,N 次赋值在长列表上是可测量的卡顿。批量变更先攒后替。


5.10 实践任务

任务描述

把 OfficeFlow 的审批列表页从 V1 完全重构为 V2,并补齐全局状态与持久化:

  1. 将 lib_base 中的 ApprovalItem、User、AttachmentInfo 改造为 @ObservedV2 + @Trace。
  2. 把登录态从 AppStorage 迁移到 AppStorageV2 承载的 SessionStore 类。
  3. 把用户偏好(主题、语言、通知静默时段)迁移到 PersistenceV2 承载的 SettingsStore,其中通知配置为嵌套类,必须正确处理 @Type。
  4. 编写 ApprovalListViewModel,包含至少 3 个 @Computed 与 2 个 @Monitor。
  5. 列表页与卡片组件全部切到 @ComponentV2,父传子用 @Param,子传父用 @Event。
  6. 实现搜索与筛选:keyword 与 filter 变化后列表实时变化,且不重新请求网络。
  7. 实现 applyUpdated,使单条更新只触发该行的重渲染。
  8. 提供一份"刷新范围验证"记录:开启日志后修改一条数据,列出实际重渲染的组件清单。

验收 checklist

  • [ ] 全工程无 @Observed(、@ObjectLink、@Watch、@StorageLink、@StorageProp、PersistentStorage
  • [ ] 全工程无 @State / @Prop / @Link / @Provide / @Consume
  • [ ] 同一 .ets 文件中不同时出现 @Component 与 @ComponentV2
  • [ ] ApprovalItem 的每个参与 UI 展示的字段都有 @Trace
  • [ ] SettingsStore.notification 标了 @Type(NotificationConfig)
  • [ ] 杀进程重启后,主题与语言设置仍生效,且重启后修改 notification.enabled 能立即刷新 UI
  • [ ] ApprovalListViewModel.filtered 在 keyword 变化后立即变化,且未触发 repo.list() 调用(打印调用次数为 0)
  • [ ] @Monitor 的回调中不存在对被监听字段的赋值(人工复核 + 单测覆盖)
  • [ ] 修改单条审批状态后,只有该行对应的组件打印了重渲染日志
  • [ ] @Local 均有初始值,编译无 "must be initialized" 报错
  • [ ] View 组件中不含 if (status === 'pending') 这类业务判断(抽查 ApprovalListView、ApprovalCard)

交付物清单

交付物 位置
V2 领域模型 lib_base/src/main/ets/model/{ApprovalItem,User,AttachmentInfo}.ets
会话全局状态 lib_auth/src/main/ets/SessionStore.ets
偏好持久化 lib_storage/src/main/ets/SettingsStore.ets
列表 ViewModel feature_approval/src/main/ets/viewmodel/ApprovalListViewModel.ets
列表与卡片视图 feature_approval/src/main/ets/view/ApprovalListPage.ets、feature_approval/src/main/ets/view/components/ApprovalCard.ets
迁移记录 code/office-flow/docs/state-migration.md(逐文件的 V1→V2 对照表)
刷新范围验证 code/office-flow/docs/render-scope.md(含重渲染组件清单与截图)

5.11 延伸阅读

  • 官方文档 → 应用开发 → 应用框架 → ArkUI → 状态管理 V2 概览(V2 装饰器全清单与设计目标)
  • 官方文档 → 应用开发 → 应用框架 → ArkUI → @ObservedV2 与 @Trace(观测模型与嵌套类约束)
  • 官方文档 → 应用开发 → 应用框架 → ArkUI → @Local / @Param / @Once / @Event(组件内与父子通信)
  • 官方文档 → 应用开发 → 应用框架 → ArkUI → @Computed 与 @Monitor(缓存失效条件、IMonitor 结构、路径监听)
  • 官方文档 → 应用开发 → 应用框架 → ArkUI → AppStorageV2 与 PersistenceV2(全局状态、@Type 与序列化规则)
  • 官方文档 → 应用开发 → 应用框架 → ArkUI → 状态管理 V1 到 V2 迁移指导(整章对照与混用限制)
  • 本教程 附录 A(V1 → V2 完整映射速查,全书唯一集中出现 V1 的地方)

上一章:第 4 章 页面路由与生命周期 下一章:第 6 章 数据持久化与网络请求

📄 第 11 章 消息与通知 06-notification.md

涉及模块:feature_message(HAR)、lib_ui(HSP)、lib_network(HAR)、entry(HAP)。 前置章节:11/02 网络层与统一错误处理、06 数据持久化与网络请求、11/04 登录鉴权与会话管理。

消息列表是"看起来很简单的页面"的典型代表:一个 List、一个角标、一个点击跳转。但它同时踩到了本工程里最难的三块:

  1. 已读状态必须与通知栏一致 —— 列表点了已读,通知栏还留着提醒,是用户最直接的割裂感来源;
  2. 点击通知要能拉起正确页面 —— 冷启动 / 热启动两条路径、wantAgent 的 requestCode、路由参数编码,每一项漏掉都表现为"点了没反应";
  3. 长列表的复用与不可变性 —— LazyForEach 不认识 @Trace,Message 必须不可变,key 必须是业务 id。

本章按"模型 → 仓储 → 列表 → 通知 → 角标 → 真机差异"的顺序写。最后一段专门讲模拟器与真机的行为差异,因为通知这条链路的验收只能在真机上做。

说明 本章涉及 SDK 运行链路的部分(通知、wantAgent、授权弹窗)未经本机编译验证;文中 文件:行号 引用与签名均对照当前工程源码实测,SDK 侧行为以官方 API 参考为准。


6.0 本章目标

  1. 写出 一个不可变的消息领域模型,并解释为什么"原地改 read 字段"会导致列表不刷新;
  2. 设计 消息的缓存策略(只缓存第一页)并说明未读数为什么必须是独立接口而不是本地统计;
  3. 实现 两段式渲染(先缓存后网络),并在刷新失败时保留旧内容;
  4. 封装 通知能力:能力门禁、通道惰性创建、授权区分"用户拒绝"与"系统故障";
  5. 接通 点击通知拉起页面的完整链路,并说清"冷启动走 onCreate、热启动走 onNewWant";
  6. 对齐 列表已读状态与通知栏状态(单条 cancel / 全部 cancelAll);
  7. 诚实说明 通知链路在模拟器上不可验收的部分,并给出真机验收方法。

6.1 消息模型:不可变 + 白名单 + 手写解析

// feature_message/src/main/ets/model/Message.ets(节选,注释为原文)
export enum MessageType {
    /** 审批类:可跳转到审批详情 */
    APPROVAL = 'approval',
    /** 系统类:公告、版本、运维通知,不可跳转 */
    SYSTEM = 'system',
    /** 协同类:草稿被其他设备更新、冲突提醒 */
    COLLAB = 'collab'
}

/** 解析白名单。新增类型必须先加到这里,否则会被降级成 SYSTEM。 */
export const MESSAGE_TYPE_VALUES: string[] = [
    MessageType.APPROVAL,
    MessageType.SYSTEM,
    MessageType.COLLAB
];

export class Message {
    readonly id: string;
    readonly type: MessageType;
    readonly title: string;
    readonly body: string;
    readonly relatedId: string;
    readonly read: boolean;
    readonly createdAt: number;
    // ... constructor
}

为什么"所有字段 readonly"

V2 的 @Trace 只在引用变化时通知 UI。如果允许 msg.read = true 原地改字段,列表不会重绘 —— 这是 V2 项目里最常见的"点了没反应"。

因此"已读"这类变更走 withRead() 返回新实例,由 ViewModel 替换数组元素:

/** 返回"已读状态被改写"的新实例,而不是改 this.read */
withRead(read: boolean): Message {
    if (this.read === read) {
        return this;
    }
    return new Message(this.id, this.type, this.title, this.body, this.relatedId, read, this.createdAt);
}

注意 read === read 时返回 this(同一个引用)。这不是微优化:它让"重复标记已读"成为一个无变化操作,避免无意义的重绘。调用方 applyRead() 会据此产出"引用不变"的数组元素,框架的 diff 就不会认为它变了。

不可变性还有第二个理由(在 IDataSource 那一节会看到):getData() 返回的是元素引用,LazyForEach 会把它交给 itemBuilder。原地改字段后 notifyItemChanged(i) 拿到的是同一个引用,框架做 diff 时可能判定"没变"而不重建。

两个语义 getter

/** 是否可用于渲染。无 id 的脏数据应在进入列表前被过滤掉。 */
get usable(): boolean {
    return this.id.length > 0;
}

/** 是否可跳转:只有审批类且带上了关联单号才有详情页可去 */
get navigable(): boolean {
    return this.type === MessageType.APPROVAL && this.relatedId.length > 0;
}

usable 与 navigable 把"能不能渲染"与"能不能跳转"两件事分开表达。它们的不同组合对应四种真实情况:

usable navigable 场景 处理
✓ ✓ 审批消息带单号 可渲染、点击跳详情
✓ ✗ 系统公告、协同提醒 可渲染、点击给即时反馈(toast)
✗ — 后端缺 id 在进入列表前就丢弃
— — 审批消息缺 relatedId 走"不可跳转"分支

为什么必须手写 fromJson

static fromJson(reader: JsonReader, issues: string[]): Message {
    const id: string = reader.str('id', '');
    const type: MessageType = reader.enumOf<MessageType>('type', MESSAGE_TYPE_VALUES, MessageType.SYSTEM);
    const title: string = reader.str('title', '');
    const body: string = reader.str('body', '');
    const relatedId: string = reader.str('relatedId', '');
    const read: boolean = reader.bool('read', false);
    const createdAt: number = reader.timestamp('createdAt', 0);

    Message.collect(reader, issues, id.length > 0 ? `message#${id}` : 'message#?');

    // 标题缺失时用正文兜底:列表里出现一条"无标题空行"比显示正文更糟
    const safeTitle: string = title.length > 0 ? title : body;
    return new Message(id, type, safeTitle, body, relatedId, read, createdAt);
}

四个要点:

  1. reader.enumOf<MessageType>(..., MESSAGE_TYPE_VALUES, MessageType.SYSTEM):后端新增消息类型时,客户端不认识 → 降级为 SYSTEM 而不是得到 undefined 枚举。降级为 SYSTEM 是安全的(它不可跳转),而 undefined 会让所有 switch 掉进 default。
  2. reader.timestamp('createdAt', 0):后端可能给秒级时间戳或 ISO 串,由 JsonReader.timestamp 统一收口成毫秒。在业务代码里自己判断"是秒还是毫秒"是错的开端 —— 它总要靠"值大于 10^12 就是毫秒"这种脆弱的启发式。
  3. 标题兜底用正文:一条"无标题空行"比显示正文更糟。这是"降级要降得可用"的典型应用。
  4. Message.collect 手动搬运 issues:JsonReader 把 issues 存在每个实例上,嵌套读取(objArray 内部的 reader)的问题不会自动冒泡到外层。这个搬运动作漏掉,等于解析告警静默丢失。
static collect(reader: JsonReader, issues: string[], scope: string): void {
    const own: ParseIssue[] = reader.issues;
    for (let i = 0; i < own.length; i++) {
        issues.push(`${scope}.${own[i].toString()}`);
    }
}

日志不打正文

/** 便于日志与排障:不打印正文,避免把用户内容写进日志 */
toString(): string {
    return `Message(id=${this.id}, type=${this.type}, read=${this.read}, at=${this.createdAt})`;
}

消息正文可能包含业务敏感信息(金额、客户名、审批意见)。toString() 只输出 id、类型、已读、时间 —— 这四样足以回答排障需要的全部问题。


6.2 仓储:缓存策略与"未读数必须独立"

为什么用 Preferences 而不是 RDB

/**
 * ## 为什么消息用 Preferences 而不是 RDB
 *
 * 消息在这里是**轻量列表 + 高频读写 + 无查询需求**:
 *   - 列表只做"按时间倒序全量展示",不做按状态/类型/时间范围的过滤排序;
 *   - 单条就是一个 JSON 对象,几十字节到几百字节;整页缓存量级在几十 KB。
 */
private static readonly CACHE_KEY: string = 'feature_message.list.v1';
private static readonly CACHE_LIMIT: number = 20;

选型判据(对应第 6 章的选型表):需要"按状态筛选、按时间排序、分页查询"才轮到 RDB 的索引与查询下推。 消息在这里三者都不需要,上 RDB 只会多出建库、建表、升级迁移、schema 版本管理四份成本,收益为零。

必须提前想清楚"什么时候换成 RDB"

代码注释里列了四条换 RDB 的触发条件,这是本工程的一条写作纪律:把"什么时候该改"写在代码里,让后来者能判断,而不是靠口口相传。

条件 原因
消息要支持"未读/已读/审批/系统"多条件组合筛选 + 排序 需要索引
缓存条数要上千,且需要按时间窗口分页查询 Preferences 全量加载进内存会顶
需要保留历史 N 千条并做统计聚合 需要聚合查询
单条消息正文较大(> 8KB) 撞上 Preferences 单值上限

只缓存第一页

/**
 * ## 缓存策略(带取舍)
 *
 * - 只缓存**第一页**。翻页数据缓存价值低(用户下次进来大概率还是看最新几条),
 *   而缓存全部页会让 Preferences 单值迅速逼近 8KB 上限。
 * - 缓存**只写不读回业务流**:cachedFirstPage() 是给 ViewModel "先渲染再刷新"用的,
 *   不作为 list() 的失败兜底 —— 静默展示过期数据比报错更糟。
 */
async list(query: PageQuery): Promise<Result<Paged<Message>>> {
    const result: Result<Paged<Message>> = await this.api.get<Paged<Message>>(
        '/message/list',
        MessageRepository.parsePage,
        { 'page': query.page, 'size': query.size }
    );
    if (result instanceof Success) {
        const page: Paged<Message> = (result as Success<Paged<Message>>).value;
        if (query.page === 1) {
            // 写缓存失败不影响本次结果:缓存是"加分项",不是主干
            await this.saveFirstPage(page.items);
        }
    }
    return result;
}

第二条取舍值得单独强调:缓存不作为失败兜底。这与审批列表的 SWR 策略看似矛盾,实际不是:

  • 审批列表:有明确的"陈旧但可用"语义(fromCache / degraded 三个布尔),UI 会给出"离线数据"提示 —— 用户知道自己在看旧数据;
  • 消息列表:只做"先渲染再刷新"。如果 list() 失败了就直接把缓存当结果返回,UI 会显示"最新的消息"而其实可能是两天前的 —— 没有提示,用户不知情。

区别不在"要不要用缓存",而在用缓存时有没有告诉用户。

未读数必须是独立接口

/**
 * 未读数。
 *
 * 这是一个**独立接口**而不是本地遍历 items 得到的结果,原因:
 * 列表只缓存了第一页,本地遍历得到的是"第一页里的未读数",不是全站未读数。
 * 角标必须用服务端口径。
 */
async unreadCount(): Promise<Result<number>> {
    return this.api.get<number>('/message/unreadCount', MessageRepository.parseCount);
}

这是本章最容易写错、后果最明显的一处。假设总共有 57 条消息、其中 38 条未读,而列表只加载了第一页 20 条(其中 13 条未读):

  • 本地统计 → 角标显示 13;
  • 服务端口径 → 角标显示 38。

用户会非常确信地告诉你"角标数字不对"。而这个 bug 的表现还会随翻页变化 —— 越往下翻角标越大,看起来像"数字在乱跳"。

解析器:纯函数,便于单测

static parsePage(dataText: string, issues: string[]): Paged<Message> {
    const reader: JsonReader | undefined = JsonReader.tryParse(dataText);
    if (reader === undefined) {
        throw new Error(`message page is not valid JSON: ${MessageRepository.head(dataText)}`);
    }
    const items: Message[] = reader.objArray<Message>('items',
        (item: JsonReader) => Message.fromJson(item, issues));
    // ...

    // 过滤掉没有 id 的脏数据:留着会在 LazyForEach 里造成 key 重复 → 渲染错位
    const usable: Message[] = [];
    for (let i = 0; i < items.length; i++) {
        if (items[i].usable) {
            usable.push(items[i]);
        } else {
            issues.push(`page.items[${i}] dropped: missing id`);
        }
    }
    return { items: usable, page: page, size: size, total: total, hasMore: hasMore };
}

JSON 非法时抛异常是刻意的:这是唯一"整体失败"的情况,由 HttpApi 的 unwrap 统一转成 PARSE_FAILED。字段缺失则是"局部问题",用 issue 记录 + 默认值继续。

无 id 的脏数据必须在进入列表前丢弃。 留着它会在 LazyForEach 里造成 key 重复(keyGenerator 返回 item.id,空串会重复),表现为渲染错位。

head(text) 只留 120 字符,避免把整个响应体写进日志。

缓存写失败只留痕

// feature_message/src/main/ets/repo/MessageRepository.ets:267-299
private async write(page: Paged<Message>): Promise<void> {
    const text: string = JSON.stringify(page);   // 失败在 try 外层处理,略
    // PreferenceStore 的真实写入口是 put(key, value)(同步返回 Result<void>,**只写内存**)
    // + 独立的 await flush()(真正落盘)。没有 putString 这种带类型后缀的方法。
    const written: Result<void> = this.store.put(MessageRepository.CACHE_KEY, text);
    if (written instanceof Failure) {
        XLogger.w(this.tag, () => `cache write failed, degrade to no-cache: ...`);
        return;
    }
    const flushed: Result<void> = await this.store.flush();
    if (flushed instanceof Failure) {
        XLogger.w(this.tag, () => `cache flush failed, degrade to no-cache: ...`);
    }
}

两个返回值都要判:只判 put 会让"没落盘"表现成"这次看着写成功了、重启后缓存回退"——这是比直接失败更隐蔽的缓存 bug。任何一侧失败都只记 WARN,不上升为业务错误 —— 缓存是加分项,不是主干。

误区 在 cachedFirstPage() 解析失败时把异常抛出去。缓存解析失败说明本地数据格式已过期(比如模型加了字段),此时直接丢掉重来才对 —— 让它变成一次用户可见的错误是错的处理方式。工程里的做法是返回空数组 + 一条 WARN。

说明 本工程刻意不引入自动序列化库。领域模型字段全是 readonly 基础类型,直接 JSON.stringify 即可;自动序列化库会把字段名写进反射元数据,与"手写 fromJson"的策略重复,且混淆后极易翻车。


6.3 列表:两段式渲染与 LazyForEach

ViewModel 的字段设计

// feature_message/src/main/ets/viewmodel/MessageListViewModel.ets(节选)
@ObservedV2
export class MessageListViewModel {
    /** 列表数据。整体替换(而不是 push)来保证 @Trace 收到引用变化 */
    @Trace items: Message[] = [];
    @Trace state: UiState = UiState.IDLE;
    /** 面向用户的失败文案。技术细节进日志,不进 UI */
    @Trace errorMessage: string = '';
    @Trace hasMore: boolean = false;
    /**
     * 服务端口径的未读数,用于角标。
     * 与 @Computed unreadCount 的区别:后者只统计"已加载的前 N 页",
     * 用它做角标会在翻页前少报。两个数各有用途,不要合并。
     */
    @Trace badgeCount: number = 0;

    /** 供 LazyForEach 使用的数据源 */
    readonly dataSource: MessageListDataSource;

    /** 并发闸门:下拉刷新与滚动加载同时触发时,只允许一个在飞 */
    private inFlight: boolean = false;
}

两个计数字段并存是刻意的:

字段 来源 用途 特点
badgeCount(@Trace) /message/unreadCount 角标 服务端口径,准确
unreadCount(@Computed) 遍历 items 判断"一键已读"按钮是否可用 只覆盖已加载页,可能少报

绝不允许手动维护一个"未读计数器"。 手动维护的计数器与列表不同步只是时间问题(漏一次 -- 就永远偏 1)。@Computed 让它成为派生值。

@Monitor 只用于埋点:

@Monitor('state')
onStateChanged(monitor: IMonitor): void {
    const dirty: string[] = monitor.dirty.slice();
    XLogger.d(this.tag, () => `state=${this.state} dirty=[${dirty.join(',')}]`);
    Analytics.track(MessageEvents.STATE, (b: EventBuilder) => b
        .str(EventProps.PAGE, 'message_list')
        .str(EventProps.ROUTE, RouteNames.MESSAGE_LIST)
        .str(EventProps.STATUS, this.state)
        .num(EventProps.COUNT, this.items.length) as EventBuilder);
}

状态迁移点唯一,埋点既不会漏也不会重复。不要在 @Monitor 里改状态 —— 会形成回环。

两段式渲染

/**
 * 加载第一页。
 *
 * 两段式渲染(**先缓存后网络**):
 *   1. 先用本地缓存快照把列表画出来(state=SUCCESS),用户立刻看到内容;
 *   2. 再打网络刷新,用服务端数据替换。
 * 这样冷启动不会有整屏骨架屏闪烁 —— 骨架屏只在"真的没有缓存"时出现。
 */
async load(useCache: boolean = true): Promise<void> {
    if (this.inFlight) {
        return;
    }
    this.inFlight = true;
    try {
        if (useCache && this.items.length === 0) {
            const cached: Message[] = await this.repo.cachedFirstPage();
            if (cached.length > 0) {
                this.items = cached;
                this.state = UiState.SUCCESS;
                this.dataSource.notifyReload();
            }
        }
        this.state = this.items.length === 0 ? UiState.LOADING : UiState.REFRESHING;
        // ...发起 list({ page: 1, size: AppConst.DEFAULT_PAGE_SIZE })
    } finally {
        this.inFlight = false;
    }
}

三个细节:

细节一:useCache 参数的意义。 下拉刷新时传 false —— 屏幕上已经有内容了,再读一次缓存纯属浪费。

细节二:状态从 LOADING 与 REFRESHING 分流。 this.items.length === 0 ? LOADING : REFRESHING:有内容时用 REFRESHING,这样页面不会把列表替换成骨架屏。

细节三:inFlight 闸门。 下拉刷新与滚动加载可能同时触发,闸门保证只有一个在飞。

失败保留旧内容

} else {
    const error: AppError | undefined = Results.errorOrUndefined(result);
    // 取消是正常路径(页面切走/下拉被打断),不能弹错误也不能改状态
    if (error !== undefined && error.isCancellation) {
        XLogger.d(this.tag, () => 'load cancelled');
        return;
    }
    const message: string = error === undefined ? UserMessages.UNKNOWN : UserMessages.forError(error);
    this.errorMessage = message;
    if (this.items.length > 0) {
        // 已有内容时刷新失败:保留旧内容 + 记录错误,不要清空屏幕(那才是真的惹恼用户)
        this.state = UiState.SUCCESS;
        XLogger.w(this.tag, () => `refresh failed, kept stale list: ${message}`);
    } else {
        this.state = UiState.FAILURE;
    }
}

分页:追加用 notifyItemsAdded

async loadMore(): Promise<void> {
    if (this.inFlight || !this.hasMore) {
        return;
    }
    this.inFlight = true;
    try {
        const next: number = this.page + 1;
        const result: Result<Paged<Message>> = await this.repo.list({ page: next, size: AppConst.DEFAULT_PAGE_SIZE });
        if (result instanceof Success) {
            const appended: Message[] = (result as Success<Paged<Message>>).value.items;
            const start: number = this.items.length;
            this.items = this.items.concat(appended);
            this.page = next;
            this.hasMore = (result as Success<Paged<Message>>).value.hasMore;
            this.state = UiState.SUCCESS;
            this.dataSource.notifyItemsAdded(start, appended.length);
        }
        // ...
    } finally {
        this.inFlight = false;
    }
}

notifyItemsAdded(start, count) 比 notifyReload() 便宜得多 —— 不重建已有项。在千条消息的列表上,这个差别是"流畅"与"滚动卡顿"的差别。

已读:乐观更新且不回滚

/**
 * 标记单条已读。
 *
 * **乐观更新**:先改本地 UI(用户点击要有即时反馈),再发请求。
 * 失败时**不回滚** —— 已读是低风险状态,回滚会让"我明明点了却还是未读"更困惑;
 * 正确做法是下次刷新时以服务端为准自动纠正。
 */
async markRead(message: Message): Promise<Result<boolean>> {
    const index: number = MessageListViewModel.indexOf(this.items, message.id);
    if (index >= 0 && !message.read) {
        this.items = MessageListViewModel.replaceRead(this.items, message.id, true);
        this.dataSource.notifyItemChanged(index);
    }
    // ...
    const result: Result<boolean> = await this.repo.markRead(message.id);
    const error: AppError | undefined = Results.errorOrUndefined(result);
    if (error !== undefined && !error.isCancellation) {
        XLogger.w(this.tag, () => `markRead failed, will reconcile on next refresh: ${error.toLogLine()}`);
    }
    return result;
}

为什么已读可以乐观更新,而审批不能(对照 11/05 的详情页):已读是低风险、可自愈的状态。即使这次请求失败,用户下次刷新时服务端会给正确值。审批是流程动作,界面必须只在服务端确认后才改。

"不回滚"也是刻意的:回滚会让用户看到"我明明点了已读,怎么又变未读了" —— 这个困惑比"其实服务端没记上(下次刷新才发现)"更严重。

notifyItemChanged(index) 而不是 notifyReload():只影响一行,不重建整表。

一键已读

async markAllRead(): Promise<number> {
    if (this.unreadCount === 0) {
        return 0;
    }
    // 同样乐观更新:整表重建比逐条 notifyItemChanged 更简单,用户预期也是"整屏都变了"
    const out: Message[] = [];
    for (let i = 0; i < this.items.length; i++) {
        out.push(this.items[i].withRead(true));
    }
    this.items = out;
    this.dataSource.notifyReload();
    // ...发起 /message/readAll
}

这里用 notifyReload() 是正确的:用户预期就是"整屏都变了",逐条通知反而更贵(N 次 listener 调用)。

角标刷新失败时的降级

/** 刷新角标。失败时退回本地计数:角标过期总好过角标消失 */
async refreshBadge(): Promise<void> {
    const result: Result<number> = await this.repo.unreadCount();
    if (result instanceof Success) {
        this.badgeCount = (result as Success<number>).value;
    } else {
        this.badgeCount = this.unreadCount;
    }
}

这个降级值得注意:角标过期好过角标消失。用户看到"未读 13"(其实是 38)会觉得数字不对;但看到"没有角标"会以为没有新消息——后者是更严重的错误。


6.4 数据源:LazyForEach 只认 IDataSource

/**
 * LazyForEach 的数据源。
 *
 * ## 为什么必须自己写
 *
 * LazyForEach 只认 IDataSource,它**不认识 @Trace**。
 * V2 的状态观察能让"读到 items 的组件"重绘,但 LazyForEach 是长列表的复用容器,
 * 它需要知道"第 i 项变了"才能做最小重建。
 */
export class MessageListDataSource implements IDataSource {
    private readonly listeners: DataChangeListener[] = [];
    private readonly provider: () => Message[];

    constructor(provider: () => Message[]) {
        this.provider = provider;
    }

    totalCount(): number { return this.provider().length; }
    getData(index: number): Message { return this.provider()[index]; }
    registerDataChangeListener(listener: DataChangeListener): void { /* ... */ }
    unregisterDataChangeListener(listener: DataChangeListener): void { /* ... */ }

    /** 单条变更:标记已读这类"只影响一行"的操作走这里,避免整表重建 */
    notifyItemChanged(index: number): void { /* onDataChange(index) */ }
    /** 追加:分页加载更多后调用。比 notifyReload 便宜得多(不重建已有项) */
    notifyItemsAdded(start: number, count: number): void { /* onDataAdd */ }
    /** 全量重载:刷新、批量已读、筛选条件变化时用 */
    notifyReload(): void { /* onDataReloaded */ }
}

为什么用 provider 闭包而不是复制数组

constructor(repo: MessageRepository) {
    this.repo = repo;
    // 闭包捕获 this:DataSource 只持有"怎么取数",不持有数据本身
    this.dataSource = new MessageListDataSource((): Message[] => this.items);
}

复制一份数组意味着"两份真相",一定会出现"已读了但列表没变"或反之。闭包指向 ViewModel 的同一个数组,从结构上消灭这类不一致。

为什么 key 必须是 id

LazyForEach(this.vm.dataSource, (item: Message) => {
    ListItem() {
        this.MessageRow(item)
    }
}, (item: Message) => item.id)

注释写得很直白:用 index 做 key 会在"标记已读后重排/插入"时错位,表现为"点 A 结果 B 变已读",这是 LazyForEach 最经典的坑。

页面骨架

@ComponentV2
export struct MessageListPage {
    /** 路由参数(由 MessageListRouteBuilder 传入)。目前只接受可选的定位参数 */
    @Param routeParam: Record<string, string> = {};
    @Local vm: MessageListViewModel = new MessageListViewModel(MessageRepository.createDefault());
    @Local refreshing: boolean = false;
    /** 用于断点布局,由 onAreaChange 回填 */
    @Local areaWidth: number = 360;
    // ...
}

几个渲染细节:

// feature_message/src/main/ets/view/MessageListPage.ets:177-199(节选)
Refresh({ refreshing: $$this.refreshing }) {
    List({ space: 8 }) {
        LazyForEach(this.vm.dataSource, /* ... */)
    }
    .cachedCount(AppConst.LIST_CACHED_COUNT)      // 预构建 4 项
    .onReachEnd(() => {
        this.vm.loadMore();
    })
}
.layoutWeight(1)
.onRefreshing(() => {
    this.onRefresh();
})
// MessageListPage.ets:293-303
private async onRefresh(): Promise<void> {
    this.refreshing = true;
    try {
        await this.vm.load(false);   // 下拉刷新不带缓存
    } finally {
        // $$ 双向绑定 + 手动复位的"双保险":无论成功/失败/取消,转圈都会收起
        this.refreshing = false;
    }
}

Refresh 的 refreshing 用 $$ 双向绑定:组件在用户手势结束时会把状态写回 this.refreshing。工程仍保留 finally 里的手动复位作为双保险 —— 双向绑定覆盖"手势收起"这一条路径,但回调内部异常、取消等边缘时序下仍可能卡在转圈,手动复位保证无论哪条路径退出都不会卡住。

未读视觉用"独立元素 + 字重",不靠颜色:

// 未读圆点:用独立元素而不是"整行变色",是为了在深浅主题下都还能分辨
Circle({ width: 8, height: 8 })
    .fill(message.read ? Color.Transparent : $r('app.color.msg_unread_dot'));

Text(message.title)
    // 未读加粗:小字号下颜色区分度不够,字重才是可靠信号
    .fontWeight(message.read ? FontWeight.Normal : FontWeight.Medium)

点击:先标记已读,再跳转,并取消通知

/**
 * 点击一条消息:先标记已读,再按类型跳转。
 *
 * 顺序不能反。如果先跳走,页面可能已开始销毁,标记已读的异步回调会写到"已经没人听"的
 * ViewModel 上,表现为"返回列表还是未读"。这里先触发(不 await)标记已读再跳,两者互不阻塞。
 */
private onOpen(message: Message): void {
    this.vm.markRead(message);
    // 用户已经看到了,通知栏就不该再留着 —— 否则会有"明明读过还在提醒"的割裂感
    NotificationService.getInstance().cancel(noticeIdOf(message));

    if (message.type === MessageType.APPROVAL && message.navigable) {
        // 跨 feature 跳转只依赖 lib_router 的路由名常量,**不 import feature_approval**。
        // 直接 import 目标页面会立刻产生 feature 之间的横向依赖,两个模块从此无法独立删除。
        // 参数键名必须逐字匹配 ApprovalDetailParam(id + from,两者都必填):
        // 详情页按 id 重新拉取,from 用于返回行为与埋点归因。
        const param: ApprovalDetailParam = {
            id: message.relatedId,
            from: RouteNames.MESSAGE_LIST
        };
        Router.push(RouteNames.APPROVAL_DETAIL, param);
        return;
    }
    if (message.type === MessageType.COLLAB) {
        Router.push(RouteNames.COLLAB_DEVICES, {});
        return;
    }
    // 系统类消息没有详情页:给出即时反馈,避免"点了没反应"
    promptAction.showToast({ message: message.body, duration: 2000 });
}

这一段是本章信息密度最高的代码块,四个决策都值得记:

  1. 先标记已读再跳(不 await):反了会导致"返回列表还是未读";
  2. cancel(noticeIdOf(message)):已读与通知栏必须同步;
  3. 跨 feature 跳转不 import 目标模块:只依赖 RouteNames 常量。这是 check-deps.mjs 会强制检查的规则;
  4. 系统消息给 toast:避免"点了没反应"。

说明 跳详情的参数键与 lib_router 的 ApprovalDetailParam 契约一致:{ id, from } 两个必填字段(RouteNames.ets:124-129),调用处用类型标注 const param: ApprovalDetailParam = {...} 让编译期参与校验(MessageListPage.ets:321-325)。早期版本曾传过 { 'approvalId': ... } 裸键,已随契约收敛修正。

一键已读要清空通知栏

private async onMarkAllRead(): Promise<void> {
    const updated: number = await this.vm.markAllRead();
    if (updated > 0) {
        // 一键已读后清空通知栏:列表与通知栏的状态必须一致
        await NotificationService.getInstance().cancelAll();
    }
}

if (updated > 0) 这个条件很重要:updated === 0 是合法值(本来就没有未读),此时不应调用 cancelAll() —— 否则会把用户还没看的其他类型通知(比如协同冲突提醒)一起清掉。


6.5 NotificationService:为什么要包一层

/**
 * 通知能力封装(@kit.NotificationKit)。
 *
 * ## 为什么要包一层
 *
 * 裸调 notificationManager 有四个坑,散在业务里迟早出事:
 *   1. **能力不在所有设备上**:部分设备/裁剪系统没有通知能力,必须先用能力门禁判断;
 *   2. **通道需要先创建**:不建通道就 publish,在部分版本上静默失败(没有异常、没有通知);
 *   3. **授权是用户的决定**:用户拒绝后所有 publish 都会失败,必须区分"权限没开"与"发布失败";
 *   4. **wantAgent 的生命周期**:它是系统持有的对象,签名随 SDK 版本有微调,
 *      收口到一个文件里,升级时改动面可控。
 */
export class NotificationService {
    /** 本工程统一的通知通道 id。同一个通道重复创建是幂等的 */
    private static readonly CHANNEL_ID: string = 'officeflow_message';
    /**
     * 深链参数键。**从 lib_router 引入**(DEEP_LINK_ROUTE_KEY / DEEP_LINK_PARAMS_KEY):
     * 键名由写入方(本类 wantAgent)与读取方(entry 的 EntryAbility)共用,
     * 契约收口在 lib_router —— 各写一份字面量迟早改一处漏一处,
     * 表现为"点击通知永远跳不到目标页"且无编译期报错(NotificationService.ets:63-70)。
     */
    static readonly PARAM_ROUTE: string = DEEP_LINK_ROUTE_KEY;      // 'of.route'
    static readonly PARAM_PARAMS: string = DEEP_LINK_PARAMS_KEY;    // 'of.params'
}

lib_router 侧的契约还包括一份深链白名单(RouteNames.ets:85-88):DEEP_LINK_ALLOWED_ROUTES = [MAIN, MESSAGE_LIST] —— 只收"无参数依赖"的路由,带 id 的详情类目标一律回落 MAIN,避免"进程回收后恢复出空参数页面"。

术语 通知通道(slot):系统按通道维度给用户提供开关粒度。不建通道直接 publish,在部分版本上会静默失败(没有异常、没有通知)—— 这是通知"发了但看不到"最常见的原因。

版本门槛 通知发布(publish)与通道管理(addSlot)等能力自 API 9 之前就以 @ohos.notificationManager 形态提供;@kit.NotificationKit 的 Kit 聚合形态自 API 12 起提供(此前 import 的是 @ohos.notificationManager)。SlotType / ContentType 等枚举在后续版本有调整(如 contentType → notificationContentType)。本工程按要求统一使用 notificationContentType(见下方 publish 的注释)。@kit.AbilityKit 的 wantAgent 同理为 Kit 聚合形态(API 12 起)。

应用级初始化

// NotificationService.ets:93-104
/**
 * 应用级初始化:注入 UIAbilityContext,由**消息页首次进入**时调用(幂等,重复调用只更新 context)。
 *
 * 为什么不是 EntryAbility.onCreate:NotificationService 刻意不导出到模块外
 * (见 Index.ets),entry 无法 import 它——依赖方向必须保持 feature 内闭环。
 * 页面持有 UIAbilityContext(getContext),由它在触发通知链路前完成注入即可;
 * 建通道在首次 publish 时惰性执行,页面注入不影响首帧。
 */
init(context: common.UIAbilityContext): void {
    this.context = context;
    XLogger.i(this.tag, () => 'NotificationService initialized');
}

接线点在 MessageListPage.aboutToAppear(MessageListPage.ets:91-95):页面在 vm.start() 之前调用 NotificationService.getInstance().init(getContext(this) as common.UIAbilityContext),保证后续任何通知链路调用时 context 已就绪。这里有一个与 TokenStore 不同的取舍:TokenStore 不持有 Context(只取 Preferences 句柄),而 NotificationService 持有 UIAbilityContext。原因写在注释里:requestEnableNotification 需要一个"活的" context,而调用点拿不到它。这是一个有意接受的代价 —— 但注意它持有的是 Ability 级 context(一个进程一个 Ability,launchType: singleton),不是页面级对象,且只在页面首次进入时注入一次。

摘要通知:链路已经接通

"新消息 → 通知栏提醒"这条链路在工程里已经接线,触发点在 MessageListViewModel(MessageListViewModel.ets:129-139、:219-226、:269-283):

  • VM 持有一个 lastUnreadCount 基线(-1 表示尚未建立);load() 成功分支先取旧基线、替换数据、刷新角标,然后记录新基线并触发 maybePublishDigest(previousUnread, currentUnread);
  • maybePublishDigest 的判据是两次刷新之间未读数增长了才发一条摘要通知("您有 N 条新消息")。首次加载(previousUnread < 0)不发 —— 人正在看列表,再弹通知是打扰;
  • 摘要用固定 id(DIGEST_NOTICE_ID = 1):连续多条新消息只更新同一条摘要,不在通知栏堆积;跳转目标 route = MESSAGE_LIST,正好落在深链白名单内;
  • 发布失败只记日志:通知是增强体验,不值得为它打断任何主流程。

工程没有推送通道,这个"两次刷新间的增量"是对真实推送的近似演示 —— 但 publish → wantAgent → EntryAbility 深链落地 的链路本身与真实推送一致。

能力门禁

/** 供页面做"是否显示通知开关"的判断 */
isSupported(): boolean {
    return CapabilityGate.isSupported(Capability.NOTIFICATION);
}

CapabilityGate 把所有 canIUse 判断收口到一处。业务侧不直接调 canIUse,因为"能力名写错"这件事在运行期表现为"永远 false",且没有编译期保护 —— 收口之后只需要审查一个文件。

通道:惰性 + 幂等 + 权限不足不算失败

/**
 * 创建通知通道。
 *
 * 幂等且**惰性**:第一次真正要发通知时才建。
 * 原因:应用启动阶段做 IPC 会拖慢首帧,而通道在没发通知前毫无用处。
 *
 * 注意:addSlot 在普通三方应用上需要系统级权限(见 module.json5 的说明)。
 * 申请不到时**不算失败** —— 系统会按 SlotType 自动建通道,
 * 所以这里把权限类异常降级为 warning,让发布流程继续走。
 */
async ensureChannel(): Promise<Result<string>> {
    if (this.channelReady) {
        return new Success<string>(NotificationService.CHANNEL_ID);
    }
    if (!this.isSupported()) {
        return NotificationService.unsupported();
    }
    try {
        await notificationManager.addSlot(notificationManager.SlotType.SOCIAL_COMMUNICATION);
        this.channelReady = true;
        return new Success<string>(NotificationService.CHANNEL_ID);
    } catch (e) {
        const error: BusinessError = e as BusinessError;
        // 权限不足(201/12100001 一类)走系统自动建通道的路径;其它错误才是真问题
        XLogger.w(this.tag, () => `addSlot skipped: code=${error.code} msg=${error.message}`);
        this.channelReady = true;
        return new Success<string>(NotificationService.CHANNEL_ID);
    }
}

三个设计决策:

  1. 惰性:应用启动阶段做 IPC 会拖慢首帧,而通道在没发通知前毫无用处;
  2. 幂等:channelReady 标记保证重复调用不重复建;
  3. 权限不足降级为成功:系统会按 SlotType 自动建通道。把它当失败会让"发通知"整条链路无谓地中断。

说明 module.json5 里刻意不声明 ohos.permission.NOTIFICATION_CONTROLLER。它是 system_basic 级权限,三方应用声明了也不会生效,反而让最终权限清单失真(HAR 的 requestPermissions 会合并进宿主 HAP,上架审核会暴露一个无法兑现的权限项)。普通应用的通知权限路径是运行时请求(requestEnableNotification)。feature_message 与 entry 的 module.json5 注释都明确写了这一口径;同理,DISTRIBUTED_DATASYNC 也只在 entry 声明一次,各 HAR(feature_collab / lib_distributed / lib_storage)不重复声明。

授权:区分"用户拒绝"与"系统故障"

/**
 * 申请通知授权。
 *
 * 这是普通应用真正要走的"通知权限"路径:requestEnableNotification 会弹出系统授权框
 * (`NOTIFICATION_CONTROLLER` 是系统权限,三方应用拿不到)。
 *
 * 返回值语义:true = 当前已授权;false = 用户拒绝或能力不可用。
 * 调用方应当把 false 当作**可继续但不保证送达**的状态,而不是硬阻断主流程 ——
 * 消息列表本身不依赖通知,被拒只是收不到提醒。
 */
async requestPermission(): Promise<Result<boolean>> {
    if (!this.isSupported()) {
        return NotificationService.unsupported();
    }
    if (this.context === undefined) {
        return NotificationService.notInitialized();
    }
    try {
        const enabled: boolean = await notificationManager.isNotificationEnabled();
        if (enabled) {
            return new Success<boolean>(true);
        }
        await notificationManager.requestEnableNotification(this.context);
        return new Success<boolean>(true);
    } catch (e) {
        const error: BusinessError = e as BusinessError;
        XLogger.w(this.tag, () => `requestEnableNotification failed: code=${error.code} msg=${error.message}`);
        // 用户拒绝不等于系统故障,用户文案要给出可行动指引而不是"操作失败"
        return new Failure<boolean>(new AppError({
            code: ErrorCode.BIZ_NO_PERMISSION,
            kind: AppErrorKind.UNSUPPORTED,
            message: `notification permission denied: code=${error.code}`,
            userMessage: '未开启通知权限,将无法收到新消息提醒',
            retryable: false,
            cause: e as Error
        }));
    }
}

三个要点:

  1. 先 isNotificationEnabled() 再请求。已授权时直接返回,不走系统流程 —— 否则每次进设置页都会弹一次(用户会觉得很烦)。
  2. 被拒不是"失败",而是"可继续但不保证送达"。消息列表本身不依赖通知能力,被拒只是收不到提醒。硬阻断主流程是错的。
  3. 用户文案要"可行动"。说"操作失败"没用,要说清楚影响("将无法收到新消息提醒")以及下一步(去设置里开)。这条纪律与 11/07 的 Permission 完全一致。

说明 NotificationKit 还提供 requestEnableNotificationNoUi 变体:不弹系统授权框,直接以当前授权状态返回。适用场景是"设置页里用户主动点击通知开关"——点击本身就是意图表达,再弹一次系统框是多余打断。工程注释已把两者并列为候选(NotificationService.ets:145),弹窗路径用 requestEnableNotification(context),主动开启路径可用 NoUi 变体。

发布

async publish(notice: NoticeRequest): Promise<Result<number>> {
    if (!this.isSupported()) {
        return NotificationService.unsupported();
    }
    if (this.context === undefined) {
        return NotificationService.notInitialized();
    }
    const channel: Result<string> = await this.ensureChannel();
    if (channel instanceof Failure) {
        return new Failure<number>((channel as Failure<string>).error);
    }

    let agent: WantAgent;
    try {
        agent = await NotificationService.buildWantAgent(this.context, notice);
    } catch (e) {
        /* wantAgent 构建失败 → UNKNOWN,retryable: false */
    }

    // 用 notificationContentType(API 11 起的新字段名)而不是已废弃的 contentType。
    // 若 SDK 升级后此处签名变化,只需改这一个对象字面量。
    const request: notificationManager.NotificationRequest = {
        id: notice.id,
        slotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
        content: {
            notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
            normal: { title: notice.title, text: notice.body, additionalText: '' }
        },
        wantAgent: agent
    };

    try {
        await notificationManager.publish(request);
        return new Success<number>(notice.id);
    } catch (e) {
        const error: BusinessError = e as BusinessError;
        // 1600001 = 通知未授权
        return new Failure<number>(new AppError({
            code: error.code === 1600001 ? ErrorCode.BIZ_NO_PERMISSION : ErrorCode.UNKNOWN,
            kind: AppErrorKind.UNSUPPORTED,
            message: `notification publish failed: code=${error.code} msg=${error.message}`,
            userMessage: '消息提醒发送失败,请检查通知权限',
            retryable: false,
            cause: e as Error
        }));
    }
}

通知 id 用消息 id 的数值化结果,保证"同一条消息只留一条通知":

/** 消息 id → 通知 id 的稳定映射。同一条消息反复提醒时复用同一个通知 id,避免通知栏堆积 */
function noticeIdOf(message: Message): number {
    let hash: number = 7;
    for (let i = 0; i < message.id.length; i++) {
        // 31 是常用的小质数乘子,短字符串上冲突率足够低
        hash = (hash * 31 + message.id.charCodeAt(i)) % 0x7FFFFFFF;
    }
    return hash;
}

取消

/** 取消单条通知(用户读完消息后调用) */
async cancel(id: number): Promise<Result<boolean>> { /* notificationManager.cancel(id) */ }

/** 清空本应用的全部通知。退出登录时必须调用 —— 否则下一位登录者会看到上一个人的消息 */
async cancelAll(): Promise<Result<boolean>> { /* notificationManager.cancelAll() */ }

cancelAll() 在退出登录时必须调用。 这是本章最容易被漏掉、后果最严重的一处:通知栏是系统级的,它不属于任何页面或用户会话。如果退出登录时不清,下一位登录者会在通知栏看到上一位用户的审批金额与客户名称 —— 这是真实的信息泄漏,且用户完全没有预期。


6.6 wantAgent:点击通知怎么跳对页面

/**
 * 构造点击通知的 wantAgent。
 *
 * 关键三点(都踩过):
 *   1. **requestCode 用 notice.id**:相同 requestCode 的 wantAgent 会被系统当成同一个,
 *      点第二条通知会跳到第一条的目标页面;
 *   2. **actionFlags 用 UPDATE_PRESENT_FLAG**:允许同 id 通知被更新而不新建;
 *   3. **parameters 里放路由**:通知体不参与路由决策,跳转完全由这里决定。
 */
private static async buildWantAgent(context: common.UIAbilityContext, notice: NoticeRequest): Promise<WantAgent> {
    const abilityName: string = context.abilityInfo.name;
    const bundleName: string = context.abilityInfo.bundleName;

    const want: Want = {
        bundleName: bundleName,
        abilityName: abilityName,
        parameters: {
            [NotificationService.PARAM_ROUTE]: notice.route,
            [NotificationService.PARAM_PARAMS]: notice.params
        }
    };

    const info: wantAgent.WantAgentInfo = {
        wants: [want],
        actionType: wantAgent.OperationType.START_ABILITY,
        // 区分每一条通知,否则系统只认最后一个
        requestCode: notice.id,
        actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
    };
    return wantAgent.getWantAgent(info);
}

通知不携带路由,它携带的是一个 intent

理解这一点的意义:通知体里的标题与正文不参与跳转决策。你想让"有 3 条新消息"这条摘要通知跳到 MessageList,唯一的方式是把路由写进 want.parameters:

want.parameters = { 'of.route': 'MessageList', 'of.params': {} }

参数名统一加 of. 前缀,避免与系统保留键冲突(如 ohos.aafwk.param.*)。这个前缀看起来是小事,但一旦和系统键撞名,症状是"路由参数读出来是系统值",且完全无从猜起。注意目标路由应选白名单内的"无参数依赖"路由(MAIN / MESSAGE_LIST)——带 id 的详情类目标会被 resolveDeepLink 回落到 MAIN(见下文"应用侧怎么消费")。

应用侧怎么消费:这条链路已经落地

读取端是 entry 的 EntryAbility,深链参数键统一引用 lib_router 的 DEEP_LINK_ROUTE_KEY / DEEP_LINK_PARAMS_KEY 常量(与 NotificationService 的写入端共用一份契约)。冷启动与热启动都已实现落地(EntryAbility.ets:50-92、:196-241):

  • 冷启动:onCreate(want) 里只解析不路由(窗口与路由栈还不存在),把目标路由暂存到 pendingDeepLinkRoute;loadContent 回调(内容已提交、路由栈可用)之后再消费 —— 这就是"杀进程后点通知仍能落到目标页"的保证;
  • 热启动:onNewWant(want) 里直接解析并落地(EntryAbility.ets:196-203);
  • 统一入口 resolveDeepLink(target)(EntryAbility.ets:220-241)的决策顺序:未登录 → 一律 replace(LOGIN)(不允许"未登录先看到业务页"的越权展示);命中 DEEP_LINK_ALLOWED_ROUTES 白名单(MAIN / MESSAGE_LIST)且非 MAIN → 栈中已有则 popToName 回收,否则 push 直达;白名单外 → 回落 MAIN。

三条要点:

要点一:冷启动走 onCreate(want),热启动走 onNewWant(want)。

只处理 onNewWant 会导致"应用没在后台时点通知打不开页面" —— 这是最常见的漏测点。因为应用被杀死后重新拉起时,系统把 want 传给 onCreate,而不是 onNewWant。

本工程的 launchType 是 singleton,onNewWant 是重复拉起唯一的入口,所以两条路径必须都处理:

// entry/src/main/module.json5(节选)
/*
 * launchType: singleton —— 全局唯一实例。
 * 选它的原因:本应用只有一份会话(SessionStore)与一份页面栈(Index 持有),
 * 多实例会让「退出登录清栈」只作用于其中一个实例,出现「另一个窗口还停在业务页」。
 * 代价是重复拉起只走 onNewWant,深链参数必须在 onNewWant 里自己消费。
 */
"launchType": "singleton"

要点二:requestCode 必须区分。

相同 requestCode 的 wantAgent 会被系统判为同一个。于是"点第二条通知会跳到第一条的目标页面"。用 notice.id(也就是消息 id 的哈希)做 requestCode,每条通知都是独立的。

要点三:actionFlags 用 UPDATE_PRESENT_FLAG。

允许同 id 的通知被更新而不新建。语义上这对应"同一条消息再次提醒时刷新通知栏里的那一条",而不是堆积两条。

路由参数要"清洗"一遍

want 的 parameters 在跨进程传递中会经历序列化,值可能是数字、对象甚至 null。MessageRoute.ets 里的处理值得抄:

/**
 * 把路由参数收敛成 Record<string, string>。
 *
 * 为什么不直接 `param as Record<string, string>`:
 * 路由参数的运行时类型不受编译期约束(可能来自 pushPathByName、wantAgent 的 parameters、
 * 甚至 deeplink 的 query)。逐键 toString() 能保证页面永远拿到字符串;
 * 而 `as` 只是编译期断言,运行时传进来一个对象就会让页面读到 "[object Object]"。
 */
export function messageRouteParamsOf(raw: object | undefined): Record<string, string> {
    const out: Record<string, string> = {};
    if (raw === undefined || raw === null) {
        return out;
    }
    const source: Record<string, Object> = raw as Record<string, Object>;
    const keys: string[] = Object.keys(source);
    for (let i = 0; i < keys.length; i++) {
        const key: string = keys[i];
        const value: Object = source[key];
        out[key] = (value === null || value === undefined) ? '' : value.toString();
    }
    return out;
}

这是"输入边界处必须做真实类型校验"的典型应用:路由参数是外部输入(可能来自 deeplink),as 只是让编译器闭嘴,不是校验。


6.7 角标:三个数字的取舍

工程里同时存在三个"未读数",它们各有用途,绝不能合并:

名字 来源 用途 准确性
badgeCount /message/unreadCount 页面顶部角标 服务端口径,准确
unreadCount(@Computed) 遍历 items 判断"一键已读"是否可用 只覆盖已加载页
noticeId 消息 id 哈希 通知栏去重 与消息一一对应

角标为什么不能用 items 统计:列表只加载了第一页。用户会非常确信地说"角标少了 25 条"。而这个 bug 的表现会随翻页变化,看起来像"数字在乱跳"。

unreadCount(@Computed)为什么还是需要的:"一键已读"按钮的 enabled 需要它:

if (this.vm.unreadCount > 0) {
    AppButton({ text: $r('app.string.msg_action_read_all'), /* ... */ });
}

如果这里也用 badgeCount,就会出现"角标显示 38 未读,但按钮是 disabled(因为当前页都读过了)"的矛盾 —— 用户点了没反应。

refreshBadge() 失败时退回本地计数:

/** 刷新角标。失败时退回本地计数:角标过期总好过角标消失 */

这是一个明确的取舍:角标过期 > 角标消失。数字略小是"不准确",没有角标是"没有新消息"——后者是更严重的误导。

角标与通知栏的一致性

一致性靠三条规则维护:

触发 动作
点击单条消息 markRead(id) + cancel(noticeId)
一键已读(updated > 0) markAllRead() + cancelAll()
退出登录 cancelAll()(见 6.5)

漏掉任何一条,用户都会看到"列表已读、通知栏还在提醒"的割裂感。

误区 一键已读时无条件调用 cancelAll()。updated === 0 是合法值(本来就没有未读),此时清空会把用户还没看过的其他通知(比如协同冲突提醒)一起清掉。必须判断 updated > 0。


6.8 模拟器与真机的差异(重要)

这是本章必须放在正文而不是脚注里的一段。NotificationService 的类注释原文:

/**
 * ## 关于模拟器的行为差异(重要)
 *
 * 模拟器上的通知链路与真机**不一致**:
 *   - 部分模拟器镜像完全没有通知服务,publish 成功但通知栏什么都不出现;
 *   - 通知渠道(addSlot)在模拟器上常被跳过,导致真机才暴露"没建通道"的问题;
 *   - 授权弹窗(requestEnableNotification)在模拟器上可能直接返回成功而不弹窗;
 *   - 点击通知拉起 wantAgent 在模拟器上对 abilityName 校验更宽松。
 *
 * 结论:**通知相关的验收一律以真机为准**,模拟器只用来验证"代码路径能跑通、没有异常"。
 */

逐条解释为什么

模拟器现象 真机上的后果
publish 成功但通知栏空白 你以为功能完成了,真机上才发现通道没建
addSlot 被跳过 真机上"发了但看不到",且没有任何异常
授权直接返回成功(不弹窗) 真机上一弹窗用户点了拒绝,你没处理拒绝分支
abilityName 校验更宽松 真机上 getWantAgent 直接抛异常

怎么安排验收

项目 模拟器 真机
代码路径能跑通(无异常/无崩溃) ✓ ✓
通知栏真的出现通知 ✗ ✓
通道创建与 SlotType 语义正确 ✗ ✓
授权拒绝分支 ✗ ✓
点击通知拉起页面(冷启动 + 热启动) 部分 ✓
从已读消息点开后通知消失 ✗ ✓
退出登录后通知栏被清空 ✗ ✓

真机验收的最小步骤清单:

  1. 准备一台真机(或一台真机 + 一台已组网设备);
  2. 确认系统通知总开关已开;
  3. 触发一条消息(用 Mock 规则也可:先让 Mock 返回一条新消息,再由业务侧触发一次 publish);
  4. 确认通知栏出现;
  5. 杀掉应用,在通知栏点这条通知 → 应拉起应用并落到目标页(验证 onCreate 路径);
  6. 应用在后台时,再点一条通知 → 应落到目标页而不是"新开一个实例"(验证 onNewWant + launchType: singleton);
  7. 在列表里点开这条消息 → 通知应从通知栏消失;
  8. 退出登录 → 通知栏应被清空。

术语 冷启动 / 热启动:冷启动指进程不存在,系统创建新进程并调 onCreate(want);热启动指进程还在(前台或后台),系统只调 onNewWant(want)。通知点击可能触发其中任意一种,两条路径都必须处理。

Mock 边界

MessageMockRules 覆盖的是网络侧的七条规则:

规则 用途
GET /message/list/empty 空列表(验证 AppEmpty 与"接口成功但没数据"的区分)
GET /message/list/unauthorized HTTP 401(验证 HttpStatusMapper 与"清会话跳登录"链路)
GET /message/list/expired 业务码 40100 令牌过期(HTTP 200,验证 BizCodeMapper 分支)
GET /message/list 分页 + 未读混排
GET /message/unreadCount 服务端口径未读数
POST /message/readAll 全部已读,返回改写条数
POST /message/read 标记单条已读;缺 id 时返回 INVALID_PARAM

固定数据集有三条刻意的设计(原文摘要):

  • 时间倒序:真实接口就是按时间倒序,列表页的排序假设必须在这里被验证;
  • 未读混排:不是"前 N 条未读"而是交错分布(i % 3 === 1 视为已读)。连续未读会让"未读高亮"看起来对,交错才验证得了"第 7 条已读、第 8 条未读";
  • 总条数 57:刻意不是 20 的整数倍,用来验证"最后一页少于 size"的分页边界。

注意 Mock 不覆盖通知本身 —— 通知是系统能力,没有 Mock 层。这意味着"通知链路的真机验收"是不可替代的。

契约对齐记录(原"已知不一致")

本章早期版本曾如实标注三处"文档/代码未完全对齐"。这三处均已在本轮工程合流中修复,此处保留为历史记录:

  1. MessageListPage 对 lib_ui 的调用签名 —— 已对齐。页面文件头现在显式声明 AppButton({ text, variant, loading?, disabled?, onTap })(MessageListPage.ets:13-25),与 lib_ui 的实际实现一致。
  2. ApprovalDetail 的路由键名 —— 已对齐。onOpen 构造 { id, from } 的 ApprovalDetailParam 对象(MessageListPage.ets:321-325),与 lib_router 契约逐字一致(见 6.4 的说明)。
  3. ApprovalListParam / ApprovalCreateParam 双定义 —— 已对齐。参数接口收敛到 lib_router/src/main/ets/const/RouteNames.ets 单一权威定义(RouteNames.ets:112-136),feature_approval 的 ApprovalRoute.ets 只 re-export、不再各自定义。

6.9 常见误区

误区 1 原地改 message.read = true。 V2 的 @Trace 只看引用变化,列表不会重绘 —— 这是"点了没反应"的最常见来源。必须 withRead() 返回新实例并替换数组元素。

误区 2 用 index 做 LazyForEach 的 key。 标记已读、插入、排序变化后会"点 A 结果 B 变了"。必须用 item.id。

误区 3 把 IDataSource 的数据复制一份。 两份真相必然不同步("已读了但列表没变")。用 provider 闭包指向 ViewModel 的同一个数组。

误区 4 角标用本地遍历 items 统计。 列表只加载了第一页,角标会少报,且数字随翻页变化。角标必须用 /message/unreadCount 的服务端口径。

误区 5 一键已读无条件 cancelAll()。 updated === 0 时会把用户还没看的其他通知一起清掉。必须判断 updated > 0。

误区 6 退出登录不清通知栏。 通知栏是系统级的,不属于任何会话。下一位登录者会看到上一位用户的审批金额与客户名 —— 真实的信息泄漏。

误区 7 只处理 onNewWant,不处理 onCreate 的 want。 应用被杀死时点通知打不开页面。冷启动与热启动两条路径都必须处理。

误区 8 wantAgent 的 requestCode 用固定值或 0。 相同 requestCode 会被系统判为同一个 wantAgent,点第二条通知跳到第一条的目标页。必须用每条通知唯一的 id。

误区 9 路由参数键名不加前缀。 会与系统保留键(ohos.aafwk.param.*)冲突,症状是"读出来是系统值"且完全无从猜起。统一加 of. 前缀。

误区 10 param as Record<string, string> 当参数清洗。 as 是编译期断言,运行时零校验。路由参数可能来自 deeplink,必须逐键 toString()。

误区 11 不建通知通道就 publish。 在部分版本上静默失败(没有异常、没有通知)。通道要惰性创建,但确实要创建。

误区 12 把"用户拒绝通知权限"当成系统故障。 用户拒绝是正常分支。应当把它当"可继续但不保证送达",并给出可行动指引(去哪里开),而不是硬阻断主流程或显示"操作失败"。

误区 13 在 module.json5 里声明 ohos.permission.NOTIFICATION_CONTROLLER。 它是系统级权限,三方应用声明了不生效,只会让权限清单失真。普通应用走 requestEnableNotification 运行时请求。

误区 14 在模拟器上验收通知链路。 模拟器上"publish 成功但通知栏空白""addSlot 被跳过""授权不弹窗"都是常态。通知的验收只能在真机上做。

误区 15 把缓存当 list() 失败时的兜底。 静默展示过期数据比报错更糟。缓存只用于"先渲染再刷新",且必须在 UI 上告诉用户这是缓存(对照审批列表的 fromCache / degraded)。

误区 16 在 Message 的日志里打印正文。 正文可能含金额、客户名、审批意见。toString() 只出 id / 类型 / 已读 / 时间。


6.10 实践任务

任务 A:把已读链路追一遍(必做)

  1. 打开消息列表(Mock 环境,共 57 条,第 1 页 20 条);
  2. 点开一条未读的审批消息;
  3. 观察并记录:
    • markRead 是否先于跳转发生(日志顺序);
    • notifyItemChanged(index) 的 index 是否等于该消息在列表中的位置;
    • 返回列表后该条是否仍为已读(验证"先标记再跳"的顺序);
    • cancel(noticeId) 是否被调用(真机上还要确认通知真的消失)。

验收 checklist

  • [ ] 返回列表后该条是已读(不是"点了又变回未读");
  • [ ] 列表里只有一行被重建(不是整表);
  • [ ] noticeId 与该消息 id 稳定对应(同一条消息两次点击得到同一个 hash)。

任务 B:验证角标三个数字的差异(必做)

  1. 记录页面顶部角标(badgeCount)的值;
  2. 手动翻到第 3 页,再记录 unreadCount(可以在"一键已读"按钮的 disabled 状态上观察);
  3. 断网后下拉刷新,观察 refreshBadge() 的降级行为。

验收 checklist

  • [ ] 角标始终等于 /message/unreadCount 的返回值(服务端口径);
  • [ ] 翻页过程中角标不变;
  • [ ] 断网后角标不消失(退回本地计数),而不是变成 0 或空。

任务 C:复现 7 条 Mock 分支(必做)

逐条触发 MessageMockRules 的规则,填表:触发路径 / 期望 / 实际。

路径 期望
/message/list/empty AppEmpty,不是 FAILURE
/message/list/unauthorized HTTP 401 → 清会话 → 跳登录页
/message/list/expired 业务码 40100 → 同一收敛路径(BizCodeMapper)
/message/list?page=3&size=20 最后一页 17 条,hasMore=false
/message/unreadCount 38(i % 3 !== 1 的条数)
POST /message/readAll updated = 38
POST /message/read(缺 id) INVALID_PARAM

验收 checklist

  • [ ] 7 条都不需要改代码;
  • [ ] unauthorized 与 expired 走到同一条收敛路径(只是映射器不同);
  • [ ] 空列表与"接口失败"在 UI 上是两种不同表现。

任务 D:真机验收通知全链路(必做,需要真机)

按 6.8 的 8 步清单执行,每一步留证据(截图或日志)。

验收 checklist

  • [ ] 通知栏真的出现通知(不是"publish 返回成功"就算过);
  • [ ] 冷启动(杀进程后点通知)能落到目标页;
  • [ ] 热启动(应用在后台时点通知)落到同一个页面实例,不新开实例;
  • [ ] 列表点开后通知消失;
  • [ ] 退出登录后通知栏被清空。

任务 E:给消息列表加"筛选"(进阶)

需求:顶部加 Tab(全部 / 审批 / 系统 / 协同),切换时按 MessageType 过滤。

要求:

  • 筛选条件变化时回到第 1 页(不能请求"第 4 页的待审批");
  • 筛选是本地过滤还是服务端过滤,你要给出选择并说明理由;
  • 如果选本地过滤,说明为什么不能复用 LazyForEach 的同一个 dataSource(提示:totalCount() 的语义);
  • 角标不受筛选影响。

验收 checklist

  • [ ] 从第 3 页切到某个筛选条件,不会发出 page=4 的请求;
  • [ ] 角标在切筛选前后不变;
  • [ ] bash scripts/check-banned-api.sh 仍通过(扫描文件数会从 99 变为 100)。

任务 F:把"通知权限引导"补完整(进阶)

NotificationService.requestPermission() 现在只返回 Failure。请补齐用户路径:

  • 在设置页展示"通知开关"当前状态(isNotificationEnabled());
  • 用户点击打开时调用 requestPermission();
  • 若用户已拒绝(系统不再弹窗),引导到应用通知设置页(参考 11/07 里 Permission.openAppPermissionSettings() 的写法,注意目标是"通知设置"而不是"权限设置");
  • 全程不阻断消息列表的使用。

验收 checklist

  • [ ] 已授权时不弹窗(直接进设置页不产生系统弹窗);
  • [ ] 用户拒绝后给出"去哪里开"的可行动指引;
  • [ ] 消息列表在未授权状态下功能完整(只是收不到提醒)。

6.11 延伸阅读

  • 官方文档 → 应用框架 → Notification Kit → 通知发布(publish / cancel / cancelAll)
  • 官方文档 → 应用框架 → Notification Kit → 通知通道(addSlot 与 SlotType 语义)
  • 官方文档 → 应用框架 → Notification Kit → 请求通知授权(isNotificationEnabled / requestEnableNotification)
  • 官方文档 → 应用框架 → Ability Kit → WantAgent(OperationType / WantAgentFlags / getWantAgent)
  • 官方文档 → 应用框架 → Ability Kit → Want 与隐式匹配(parameters 的传递与保留键)
  • 官方文档 → 应用框架 → Ability Kit → UIAbility 启动模式(singleton / multiton 与 onNewWant)
  • 官方文档 → 应用框架 → ArkUI → 渲染控制 → LazyForEach 与 IDataSource
  • 教程内:06 数据持久化与网络请求、11/02 网络层与统一错误处理、11/05 审批流业务闭环、11/07 多端适配与分布式协同

← 上一节:11/05 审批流业务闭环 | 教程目录 | 下一节:11/07 多端适配与分布式协同 →

📄 第 6 章 数据持久化与网络请求 06-persistence-network.md

数据层的核心问题只有两个:存量数据放在哪、流量数据怎么收口。前者决定了应用能不能离线可用,后者决定了错误能不能被统一治理。这一章把两者都收敛到 Repository 这一层,业务代码从此不再直接碰数据库和网络。


6.0 本章目标

  1. 能选择正确的存储方案:面对一段数据,能说出用 Preferences、RDB、KVStore 还是文件,并给出理由。
  2. 能设计一张可用的 RDB 表:建表语句、索引、事务边界,并把 ResultSet 正确映射为领域模型。
  3. 能写出基于 rcp 的网络层:Session 复用、请求构造、拦截器链、错误映射,并说清 Session 与 Request 各自的职责。
  4. 能实现 Result<T> + AppError 的错误模型,使错误在分层间传递而不依赖异常穿透。
  5. 能交付一个 ApprovalRepository:支持本地优先 + 远端刷新、写穿透与失败降级。

6.1 存储选型矩阵

6.1.1 五种存储形态

方案 提供方 数据模型 适用数据特征 不适用
Preferences @kit.ArkData 键值(string / number / boolean / 数组) 单条小、读写频繁、无需查询:主题、语言、上次登录账号、开关位 需要排序/过滤/聚合的数据
关系型数据库(RDB) @kit.ArkData 表 / 行 / 列,支持 SQL 结构化、需要条件查询、排序、分页、事务:审批列表、草稿、消息记录 大二进制、超高频单点写入
分布式 KVStore @kit.ArkData 键值(可跨设备同步) 需要跨设备一致的轻量状态:跨设备草稿标记、设备间接力进度 大量结构化数据、需要查询
文件 @kit.CoreFileKit 字节流 大对象与二进制:导出文件、图片缓存、离线 HTML、日志文件 需要随机读写某一行
内存(不落盘) V2 状态 对象 页面态、会话态、缓存派生值 需要跨进程重启保留的数据

术语:沙箱 ── 应用自己的私有目录空间(filesDir / cacheDir / databaseDir 等)。其他应用无法访问,卸载即清空;备份策略由系统按目录角色决定。

6.1.2 决策路径

数据特征 选谁 为什么
一条 < 8KB 的键值,读写极频繁 Preferences 全量加载进内存,读取是内存操作,微秒级
需要按状态筛选、按时间排序、分页 RDB 只有 SQL 能提供索引与查询下推;Preferences 只能全量遍历
有多条记录必须"要么都成功要么都失败" RDB 事务能力,其他方案均不具备
单条 > 8KB 的字符串 RDB 或文件 Preferences 单值上限约 8KB,超出会写入失败
图片 / 音视频 / 压缩包 文件 塞进 KV/RDB 会显著放大读写成本与库体积
需要跨设备一致的少量状态 分布式 KVStore 系统负责设备间同步,不必自建同步协议
数据量大且只追加不修改(日志) 文件 顺序写性能最好,可按天切分
只是当前页面的展示数据 不落盘 落到磁盘只会带来一致性负担

术语:分布式 KVStore ── 由系统负责在可信设备间同步的键值存储。它的价值是"同步",不是"存储";把它当成跨设备数据库用会撞上容量与查询能力的双重上限。

6.1.3 沙箱路径与容量特性

路径 获取方式 用途 系统是否清理
filesDir context.filesDir 应用私有持久文件 否(卸载清空)
cacheDir context.cacheDir 可重建的缓存 可能被系统清理
tempDir context.tempDir 临时文件 是
preferencesDir context.preferencesDir Preferences 落盘位置 否
databaseDir context.databaseDir RDB 落盘位置 否
distributedFilesDir context.distributedFilesDir 分布式文件共享 否

容量与性能的量级参考:

方案 单条上限 总量建议 读写量级
Preferences key ≤ 80 字节,value ≤ 8192 字节 建议控制在数百条以内 内存读,微秒级
RDB 单行受存储限制 单表 < 10 万行时性能稳定 索引查询毫秒级
KVStore 单条为 MB 级上限 只适合轻量状态 本地毫秒级,同步有网络延迟
文件 受文件系统限制 视需求 顺序写最快

误区:把缓存放进 filesDir。cacheDir 会被系统在空间紧张时清理,而 filesDir 不会——放错方向的后果是"永不清理,体积无限增长",或"数据莫名消失"。可重建的放 cacheDir,不可重建的放 filesDir。

版本门槛:本教程基线为 API 26;@kit.ArkData 的 Kit 聚合形态自 API 12 起可用(此前为 @ohos.data.* 分命名空间导入),兼容下限 20 设备不受影响。新项目统一使用 Kit 导入。


6.2 RDB 建模与使用

6.2.1 建表与索引

import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN: number = 0x0F04;
const TAG: string = 'ApprovalStore';

const SQL_CREATE_APPROVAL: string = `
CREATE TABLE IF NOT EXISTS t_approval (
    id             TEXT PRIMARY KEY,
    title          TEXT NOT NULL,
    status         TEXT NOT NULL,
    applicant      TEXT NOT NULL,
    amount         REAL DEFAULT 0,
    remark         TEXT DEFAULT '',
    updated_at     INTEGER NOT NULL,
    synced         INTEGER NOT NULL DEFAULT 0,
    category       TEXT DEFAULT '',
    applicant_dept TEXT DEFAULT '',
    urgent         INTEGER NOT NULL DEFAULT 0,
    created_at     INTEGER NOT NULL
)`;

const SQL_CREATE_INDEXES: string[] = [
    'CREATE INDEX IF NOT EXISTS idx_approval_status_created ON t_approval (status, created_at DESC)',
    'CREATE INDEX IF NOT EXISTS idx_approval_synced ON t_approval (synced)'
];

export class ApprovalStore {
    private store: relationalStore.RdbStore | undefined = undefined;

    async init(context: common.UIAbilityContext): Promise<void> {
        const config: relationalStore.StoreConfig = {
            name: 'officeflow.db',
            securityLevel: relationalStore.SecurityLevel.S1
        };
        this.store = await relationalStore.getRdbStore(context, config);
        await this.store.executeSql(SQL_CREATE_APPROVAL);
        for (const sql of SQL_CREATE_INDEXES) {
            await this.store.executeSql(sql);
        }
        hilog.info(DOMAIN, TAG, 'rdb initialized');
    }
}

版本演进:表结构不要靠"改 DDL"演进。工程中 RdbHelper 持有 SCHEMA_VERSION 与迁移脚本表(v0→v1 为 t_approval 补 category / applicant_dept / urgent / created_at 四列),open() 时按版本差执行迁移——上面的 DDL 即 v1 基线形态,完整实现见第 11 章。

建模要点:

项 做法 理由
主键 用业务 id(TEXT PRIMARY KEY),不用自增 与后端主键一致,避免"本地 id ↔ 远端 id"映射表
时间 存 INTEGER 毫秒时间戳 便于排序与区间查询;格式化属于展示层职责
状态 存 TEXT 枚举值 可读性优先于体积;改枚举值时无需迁移旧数据
金额 存 REAL 但限定两位小数,或存"分"的 INTEGER 浮点误差在处理财务数据时不可接受,涉及结算建议存整数分
索引 覆盖"筛选 + 排序"的组合列 让 WHERE status = ? ORDER BY created_at 走索引而非全表扫
软删除 加 deleted_at INTEGER DEFAULT 0(可选扩展:上表 DDL 与工程 v1 结构均未含此列,需要增量同步/回收站时再以版本迁移引入) 避免物理删除后无法做增量同步
同步标记 加 synced / dirty 位 支持离线写入后的补偿推送

术语:ValuesBucket ── RDB 的写入载体,本质是"列名 → 值"的字典。它没有类型约束,列名写错不会在编译期报错,因此列名必须来自常量表。

// 列名常量:杜绝手写字符串导致的静默写入失败
export class ApprovalCol {
    static readonly TABLE: string = 't_approval';
    static readonly ID: string = 'id';
    static readonly TITLE: string = 'title';
    static readonly STATUS: string = 'status';
    static readonly APPLICANT: string = 'applicant';
    static readonly AMOUNT: string = 'amount';
    static readonly REMARK: string = 'remark';
    static readonly UPDATED_AT: string = 'updated_at';
    static readonly SYNCED: string = 'synced';
    static readonly CATEGORY: string = 'category';
    static readonly APPLICANT_DEPT: string = 'applicant_dept';
    static readonly URGENT: string = 'urgent';
    static readonly CREATED_AT: string = 'created_at';
}

6.2.2 事务:批量写入必须包起来

async upsertBatch(items: ApprovalItem[]): Promise<number> {
    const store: relationalStore.RdbStore | undefined = this.store;
    if (store === undefined) {
        // 未初始化是调用时序错误:显式失败,不要静默返回 0(那会把"没写入"伪装成"写入 0 条")
        throw new Error('ApprovalStore not initialized');
    }
    // 关键:批量写入用事务,一次提交代替 N 次提交
    store.beginTransaction();
    try {
        let count: number = 0;
        for (const item of items) {
            const bucket: relationalStore.ValuesBucket = {
                [ApprovalCol.ID]: item.id,
                [ApprovalCol.TITLE]: item.title,
                [ApprovalCol.STATUS]: item.status,
                [ApprovalCol.APPLICANT]: item.applicant,
                [ApprovalCol.AMOUNT]: item.amount,
                [ApprovalCol.REMARK]: item.remark,
                [ApprovalCol.UPDATED_AT]: item.updatedAt,
                [ApprovalCol.SYNCED]: 1,
                [ApprovalCol.CATEGORY]: item.category,
                [ApprovalCol.APPLICANT_DEPT]: item.applicantDept,
                [ApprovalCol.URGENT]: item.urgent ? 1 : 0,
                [ApprovalCol.CREATED_AT]: item.createdAt
            };
            // CONFLICT_REPLACE:主键冲突时替换,等价于 upsert
            // 注意:insert 的返回值是 rowId(写入行的行号),不是"受影响行数";
            // 这里只累计调用次数作为写入条数,不把返回值当行数用
            await store.insert(
                ApprovalCol.TABLE, bucket, relationalStore.ConflictResolution.ON_CONFLICT_REPLACE
            );
            count++;
        }
        store.commit();
        return count;
    } catch (e) {
        store.rollBack();
        hilog.error(DOMAIN, TAG, 'batch upsert failed: %{public}s', JSON.stringify(e));
        throw e as Error;
    }
}

事务规则:

  1. beginTransaction() 与 commit() / rollBack() 必须严格配对,rollBack 放在 catch 中。
  2. 事务期间不要做网络请求或长时间等待——会长时间占用数据库锁。
  3. 单次事务的写入行数控制在合理范围(数百到数千行),超大事务中途失败的回滚代价很高。
  4. 批量插入优先使用 batchInsert:一次调用完成整批写入,减少 N 次单独 insert 调用的跨层开销。

6.2.3 查询与结果集映射

async listByStatus(status: string, limit: number, offset: number): Promise<ApprovalItem[]> {
    const store: relationalStore.RdbStore | undefined = this.store;
    if (store === undefined) {
        return [];
    }
    const sql: string = `
        SELECT id, title, status, applicant, amount, remark, category, applicant_dept, urgent,
               created_at, updated_at, synced
        FROM ${ApprovalCol.TABLE}
        WHERE status = ?
        ORDER BY created_at DESC
        LIMIT ? OFFSET ?`;
    const rs: relationalStore.ResultSet = await store.querySql(sql, [status, limit, offset]);
    const result: ApprovalItem[] = [];
    try {
        // 必须先判定列下标,避免列顺序变化导致错位
        const idxId: number = rs.getColumnIndex(ApprovalCol.ID);
        const idxTitle: number = rs.getColumnIndex(ApprovalCol.TITLE);
        const idxStatus: number = rs.getColumnIndex(ApprovalCol.STATUS);
        const idxApplicant: number = rs.getColumnIndex(ApprovalCol.APPLICANT);
        const idxAmount: number = rs.getColumnIndex(ApprovalCol.AMOUNT);
        const idxRemark: number = rs.getColumnIndex(ApprovalCol.REMARK);
        const idxCategory: number = rs.getColumnIndex(ApprovalCol.CATEGORY);
        const idxDept: number = rs.getColumnIndex(ApprovalCol.APPLICANT_DEPT);
        const idxUrgent: number = rs.getColumnIndex(ApprovalCol.URGENT);
        const idxCreated: number = rs.getColumnIndex(ApprovalCol.CREATED_AT);
        const idxUpdated: number = rs.getColumnIndex(ApprovalCol.UPDATED_AT);
        const idxSynced: number = rs.getColumnIndex(ApprovalCol.SYNCED);

        while (rs.goToNextRow()) {
            const item: ApprovalItem = new ApprovalItem();
            item.id = rs.getString(idxId);
            item.title = rs.getString(idxTitle);
            item.status = parseStatus(rs.getString(idxStatus));
            item.applicant = rs.getString(idxApplicant);
            item.amount = rs.getDouble(idxAmount);
            // 可空文本列先判空,兜底空串
            item.remark = rs.isColumnNull(idxRemark) ? '' : rs.getString(idxRemark);
            item.category = rs.isColumnNull(idxCategory) ? '' : rs.getString(idxCategory);
            item.applicantDept = rs.isColumnNull(idxDept) ? '' : rs.getString(idxDept);
            item.urgent = rs.getLong(idxUrgent) === 1;
            item.createdAt = rs.getLong(idxCreated);
            item.updatedAt = rs.getLong(idxUpdated);
            item.synced = rs.getLong(idxSynced) === 1;
            result.push(item);
        }
    } finally {
        // ResultSet 持有底层游标,必须显式关闭
        rs.close();
    }
    return result;
}

ResultSet 使用规则:

规则 说明
goToNextRow() 推进游标 初始游标位于第一行之前,必须先调用再取值
用 getColumnIndex(列名) 取值 不要硬编码下标,SELECT * 的列顺序不是契约
可空列先判 isColumnNull 直接 getString 空值会得到不可预期结果
必须 close() 放在 finally,否则连接泄漏,表现为"查询越来越慢直至失败"
大结果集分批读 一次查 10 万行既占内存也占主线程时间

6.2.4 主线程禁令

RDB 的同步 API(insert / querySql / executeSql 的同步形态)执行在主线程会阻塞渲染。规则:

  1. 主线程只使用异步形态的 RDB API;同步形态一律不要在主线程调用。
  2. 大批量导入(如首次同步全量数据):解析、校验等纯计算放 taskpool,写库在主线程分批 batchInsert(见下例)。
  3. 网络请求 + 数据库写入的组合不要在同一个主线程任务里串行等待。
import { taskpool } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit';

/**
 * 工作线程只做纯计算:解析、校验、行拆分。
 * 不能在 taskpool 里打开数据库——RdbStore 与创建它的 Context 绑定,
 * Context 无法序列化传给工作线程。
 */
@Concurrent
function parseAndSplit(raw: string): string[][] {
    // 把报文拆成行数组(每行是一个列值数组)后返回,写库由主线程完成
    return [];
}

async function parseInBackground(raw: string): Promise<string[][]> {
    const task: taskpool.Task = new taskpool.Task(parseAndSplit, raw);
    return taskpool.execute(task) as Promise<string[][]>;
}

// 主线程:读回计算结果,分批 batchInsert 写入
async function importAll(store: relationalStore.RdbStore, raw: string): Promise<void> {
    const rows: string[][] = await parseInBackground(raw);
    const pageSize: number = 500;
    for (let i: number = 0; i < rows.length; i += pageSize) {
        const buckets: relationalStore.ValuesBucket[] = toBuckets(rows.slice(i, i + pageSize));
        await store.batchInsert(ApprovalCol.TABLE, buckets);
    }
}

误区:把 RdbStore 实例跨线程共享,或以为能在 taskpool 任务里"按数据库文件路径重新打开"。RdbStore 与创建它的上下文绑定,Context 不可跨线程序列化传递,工作线程里也拿不到可靠的打开路径。正确分工:主线程负责所有数据库读写(分批 batchInsert),taskpool 只做纯计算(解析、校验、转换)。

版本门槛:本教程基线为 API 26;taskpool 自 API 9 起可用,兼容下限 20 设备不受影响。Worker 仍可用但启动成本更高,适合长驻线程。


6.3 网络选型与 rcp 基础

6.3.1 为什么是 rcp

维度 @kit.RemoteCommunicationKit 的 rcp 旧式 @ohos.net.http
会话模型 有 Session,内置连接池与配置复用 每次请求独立,无会话概念
HTTP/2 支持,可通过配置协商 支持有限
拦截器 原生支持 Interceptor 链 无
请求取消 支持 支持
超时配置 分阶段(连接 / 传输) 单一超时
响应解析 提供 toJSON() / toString() 等便捷方法 手动处理 ArrayBuffer
与 ArkTS 类型系统契合度 高,类型定义完整 一般

版本门槛:本教程基线为 API 26;rcp 自 API 12 起可用,兼容下限 20 设备不受影响。新项目网络层统一使用 rcp,@ohos.net.http 不再作为主力方案。

术语:Session ── rcp 的会话对象,承载连接池、默认 headers、超时策略与拦截器链。一个应用通常只需要 1~2 个 Session(业务 Session + 文件传输 Session)。

6.3.2 基础示例

工程铁律:全工程只允许一处直接调用 rcp API。它收在 RcpTransport(唯一适配点)里,上层只面对 RcpClient 门面。原因是 rcp 的接口在不同 SDK 版本间有过微调——散落在业务里,一次升级就要改十几处;收在一处,只改适配点的三个方法(ensureSession / send / toAppError)。完整实现见第 11 章,此处给出教学骨架:

import { rcp } from '@kit.RemoteCommunicationKit';

export class RcpTransport {
    private session: rcp.Session | undefined = undefined;

    private ensureSession(): rcp.Session {
        if (this.session === undefined) {
            this.session = rcp.createSession({
                baseAddress: 'https://api.officeflow.example.com',
                requestConfiguration: {
                    transfer: {
                        timeout: { connectMs: 5000, transferMs: 15000 }
                    }
                }
            });
        }
        return this.session;
    }

    /** 发送一次请求。不做重试、不做错误映射——那是拦截器链的职责 */
    async send(ctx: NetContext): Promise<NetResponse> {
        const session: rcp.Session = this.ensureSession();
        // 可变参数必须放在每次新建的 Request 上,不能依赖 Session 默认 headers
        const request: rcp.Request = new rcp.Request(ctx.url, ctx.method, ctx.headers, ctx.body);
        const response: rcp.Response = await session.fetch(request);
        return new NetResponse(response.statusCode, headersOf(response), await bodyTextOf(response));
    }

    /** 进程退出前释放。Session 是进程级的,不在每次请求后关闭 */
    close(): void {
        if (this.session !== undefined) {
            this.session.close();
            this.session = undefined;
        }
    }
}

业务侧不接触 rcp,全部经 RcpClient 门面发起。一次典型的 GET:

const result: Result<NetResponse> = await RcpClient.get(baseUrl).execute({
    path: `/v1/approvals/${id}`,
    method: 'GET',
    headers: { 'X-Trace-Id': traceId },   // 单请求定制的头;公共头由拦截器统一注入
    idempotent: true                      // GET 天然幂等,允许重试
});

一次典型的 POST:

const result: Result<NetResponse> = await RcpClient.get(baseUrl).execute({
    path: '/v1/approvals',
    method: 'POST',
    body: payload.toJson(),
    idempotent: false,                    // 写操作默认不自动重试
    idempotencyKey: payload.requestId     // 客户端生成,服务端据此去重
});

注:本节演示的是「Request 级传头」机制——可变头随每次请求构造。工程实践中 Authorization 这类公共可变头一律由 6.4 的 AuthInterceptor 在 before 阶段统一注入,两种方式取其一,不要叠加(叠加后头的最终值取决于写入顺序,排障困难)。

6.3.3 Session 复用会固化 SessionConfiguration(重点)

这是 rcp 最容易踩的坑,必须单独记住。

rcp.createSession(config) 中的 headers 属于 SessionConfiguration,在创建时被固化。之后通过该 Session 发出的每个请求都会带上它。因此:

// 错误写法:把会变化的 token 写进 Session 默认 headers
const session: rcp.Session = rcp.createSession({
    baseAddress: 'https://api.officeflow.example.com',
    headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${tokenSnapshot}`   // ❌ 登录/刷新 token 后不生效
    }
});
// 用户重新登录 → token 变了 → 但 Session 里仍是旧值 → 全部请求 401
// 正确写法:Session 只放不变的公共配置(超时、重定向等进程级策略)
const session: rcp.Session = rcp.createSession({
    baseAddress: 'https://api.officeflow.example.com',
    requestConfiguration: {
        transfer: { timeout: { connectMs: 5000, transferMs: 15000 } }
    }
});

// token 由 AuthInterceptor 在每次请求的 before 阶段实时读取注入(见 6.4)。
// 注意:本工程不使用 rcp 原生的 requestConfiguration.interceptors 字段,
// 拦截器链统一收在自定义的 InterceptorChain(三挂载点模型),
// 依赖面更小,也不随 rcp 接口在 SDK 版本间的微调而变(见第 11 章)。
const request: rcp.Request = new rcp.Request('/v1/approvals', 'GET');
const response: rcp.Response = await session.fetch(request);
配置项 放 Session 放 Request
Content-Type / Accept ✅ 全局不变 也可覆盖
baseAddress ✅ 不适用
Authorization ❌ 禁止 由 Auth 拦截器按请求实时注入(6.4)
X-Trace-Id / 请求 ID ❌ ✅ 每个请求唯一
Idempotency-Key ❌ ✅ 与业务动作一一对应
超时(连接/传输) ✅ 默认值 ✅ 单请求覆盖(如上传)
拦截器链 ❌ 不放进 Session 配置(由 RcpClient 的 InterceptorChain 统一管理) 不适用

术语:SessionConfiguration ── 创建 Session 时传入的配置快照。它定义了连接池大小、默认头、超时与拦截器;它不会随外部变量变化而更新。

推论:如果确实需要"换一套配置",正确做法是新建一个 Session(session.close() 后重建),而不是修改外部变量期待它生效。多数场景下,正确方案只有一个——把可变参数下沉到 Request。

6.3.4 取消与超时

// 超时:在 Request 上覆盖 Session 默认值(长上传需要更长的传输超时)
const uploadRequest: rcp.Request = new rcp.Request(
    '/v1/files',
    {
        method: 'POST',
        content: fileBuffer,
        transfer: {
            timeout: { connectMs: 5000, transferMs: 120000 }
        }
    }
);

页面离开时取消在途请求:把 Session 与 Request 的引用一并交给 ViewModel,在 aboutToDisappear 中调用取消。

aboutToDisappear(): void {
    this.vm.cancelInFlight();
}

取消的正确性依赖两点:请求对象被保存下来(不是匿名发起)、取消后不再写入已销毁的页面状态。


6.4 拦截器链

工程不使用 rcp 原生的 Interceptor 接口,而是自定义 NetInterceptor 基类,提供三个挂载点:

挂载点 时机 典型职责
before(ctx) 请求发出前 注入头、校验参数、短路(如 Mock 命中)
after(ctx) 拿到响应后 状态码归一化、错误码映射、成功收尾
onError(ctx, error) 发生错误时 错误归一化、判定是否重试、失败记账

三者都返回 AppError | undefined:返回 undefined 表示继续,返回 AppError 表示短路。用返回值而不是抛异常,是为了让控制流保持可静态分析,也避免"异常穿越拦截器链"的语义歧义。拦截器共享的上下文 NetContext 持有 url / method / headers / body / traceId / attempt 等字段,并承载 retryPlan(重试计划)、mockedResponse(Mock 短路响应)、session(日志会话)等传递槽。

6.4.1 顺序语义

注册顺序与本工程一致(完整实现见第 11 章):

Auth → Logging → Mock → ErrorMap → Retry

执行语义有三条,与注册顺序共同构成完整契约:

  • before 按注册序依次执行,任一返回错误即短路;
  • after 按注册逆序执行(洋葱模型):ErrorMap 先把状态码归一成 AppError,Logging 最后收尾;
  • onError 链式全员过一遍:每环都可替换错误对象,错误层层补充上下文(如先归一化,再由 Retry 追加"已重试 N 次")。
注册序:  Auth  →  Logging  →  Mock  →  ErrorMap  →  Retry
before:  Auth  →  Logging  →  Mock  →  ErrorMap  →  Retry  →  网络
after :  Auth  ←  Logging  ←  Mock  ←  ErrorMap  ←  Retry  ←  网络(逆序,洋葱)
onError:Auth  →  Logging  →  Mock  →  ErrorMap  →  Retry(链式,全员)
注册序 拦截器 before after onError
1 Auth 注入 Authorization / traceId 等公共头 — 识别 401 → 写入「刷新 + 重放」计划
2 Logging 开日志会话,记录脱敏后的请求摘要 确认成功才 finish(true) finish(false, code),失败记账
3 Mock 命中规则则写入 mockedResponse,短路真实网络 — —
4 ErrorMap — HTTP 状态码 + 业务码归一成 AppError,非 2xx 返回错误短路 —
5 Retry — — 基于已归一化错误的 retryable 写 retryPlan(仅幂等请求)

顺序不是随意的:

  • Auth 最先:后续环节都面对"已带鉴权头"的请求。
  • Logging 第二:能记录到最终发出的头(含鉴权,已脱敏),且覆盖 Mock 命中。
  • Mock 第三:命中即短路真实网络,但 Logging 的日志与 ErrorMap 的错误归一化照样执行——假错误也能被真实演练。
  • ErrorMap 第四:在 after 链中最先做归一化,Mock 返回的假 401/500 同样被翻译成 AppError。
  • Retry 最后:onError 链式执行保证它拿到的是已归一化的错误(retryable 已就位),重试基于结构化错误决策,而不是解析字符串。

失败请求的可观测性:after 逆序(洋葱模型)意味着 Logging 的 after 只在"确认成功"时执行(finish(true));失败请求走不到这里,由 onError 阶段记账 finish(false, code)——失败请求不会被埋点记成成功。

6.4.2 鉴权拦截器

import { AppError } from 'lib_base';
import { NetContext, NetInterceptor } from './NetInterceptor';
import { AuthTokenProvider, AuthTokenRegistry } from './AuthTokenRegistry';

// AuthTokenProvider 是 lib_network 内定义的接口(currentToken / refreshToken /
// onUnauthorized),lib_auth 在应用启动时注册实现——以此切断两个模块的循环依赖。
export class AuthInterceptor extends NetInterceptor {
    readonly name: string = 'auth';

    /** 上下文标记键:本次请求是否已尝试过刷新(跨重放轮继承,防无界刷新) */
    static readonly MARK_REFRESHED: string = '__auth_refreshed__';

    async before(ctx: NetContext): Promise<AppError | undefined> {
        // 请求方向:实时读取 token(而不是创建 Session 时的快照)
        const token: string = AuthTokenRegistry.require().currentToken();
        if (token.length > 0) {
            ctx.headers['Authorization'] = `Bearer ${token}`;
        }
        return undefined;
    }

    onError(ctx: NetContext, error: AppError): AppError {
        if (!error.isUnauthorized) {
            return error;
        }
        // 已刷新过仍 401:refreshToken 也失效,走强制登出,绝不二次刷新
        if (ctx.marks[AuthInterceptor.MARK_REFRESHED] === true) {
            AuthTokenRegistry.require().onUnauthorized('refresh_retry_unauthorized');
            return error;
        }
        ctx.marks[AuthInterceptor.MARK_REFRESHED] = true;
        // 不自己重发:写入「刷新 + 重放」计划,由执行循环统一刷新令牌后重放
        ctx.retryPlan = { needRetry: true, delayMs: 0, requireRefresh: true };
        return error;
    }
}

关键点:ctx.headers 是可以被就地修改的,这是"每次请求都注入最新 token"的落点。刷新 token 必须做单飞(single-flight)——多个并发请求同时收到 401 时,只应发起一次刷新,其余等待同一个 Promise(由 AuthTokenProvider 实现方保证)。

为什么 401 不在拦截器里直接重发:重发涉及"取消检查、尝试计数、退避等待、401 刷新"四件事,集中在执行循环(RcpClient.execute)一处,语义清晰且只有一份实现(见第 11 章)。

术语:单飞(single-flight) ── 把多个并发的相同操作合并为一次执行,其余调用共享同一个结果。401 并行刷新、重复的数据拉取都用它。

6.4.3 日志脱敏拦截器

import { AppError } from 'lib_base';
import { NetContext, NetInterceptor } from './NetInterceptor';

const SENSITIVE_KEYS: string[] = ['password', 'token', 'idCard', 'mobile', 'bankCard'];

export class LoggingInterceptor extends NetInterceptor {
    readonly name: string = 'logging';

    async before(ctx: NetContext): Promise<AppError | undefined> {
        // 开启本次请求的日志会话:记录方法、脱敏后的 URL、attempt 与 body 摘要
        ctx.session = new LogSession(ctx);
        ctx.session.begin();
        return undefined;
    }

    async after(ctx: NetContext): Promise<AppError | undefined> {
        // after 按注册逆序执行:Logging 排在 ErrorMap 之后——
        // 走到这里说明 ErrorMap 未报错,即"确认成功",才允许 finish(true)
        const session: LogSession | undefined = ctx.session;
        if (session !== undefined) {
            session.finish(true, '');
        }
        return undefined;
    }

    onError(ctx: NetContext, error: AppError): AppError {
        // 失败请求不进 after,在这里记账(finish 幂等,两处只会有其一生效)
        const session: LogSession | undefined = ctx.session;
        if (session !== undefined) {
            session.finish(false, error.code);
        }
        return error;
    }

    /** URL 中的 query 参数同样可能含敏感值,必须逐项替换 */
    private static maskUrl(url: string): string {
        const parts: string[] = url.split('?');
        if (parts.length < 2) {
            return url;
        }
        const masked: string[] = parts[1].split('&').map((pair: string) => {
            const kv: string[] = pair.split('=');
            if (kv.length === 2 && SENSITIVE_KEYS.includes(kv[0])) {
                return `${kv[0]}=***`;
            }
            return pair;
        });
        return `${parts[0]}?${masked.join('&')}`;
    }
}

// LogSession:一次请求的日志会话(begin 记请求摘要,finish 记状态码、耗时与埋点,
// 内部用 finished 标志保证幂等),完整实现见第 11 章。

脱敏纪律:

  1. 不要打印整个 body。业务 body 常含手机号、身份证、金额明细。
  2. 不要打印 header 全量。Authorization / Cookie / X-Token 必须屏蔽。
  3. 需要排查时打印结构而非值:{ approvalId: 'A-1', reason: '<len=42>' }。
  4. 日志分级:debug 只在开发构建开启,Release 构建关闭。

6.4.4 错误码映射拦截器

import { AppError } from 'lib_base';
import { NetContext, NetInterceptor } from './NetInterceptor';

export class ErrorMapInterceptor extends NetInterceptor {
    readonly name: string = 'errorMap';

    /** after 挂载点:HTTP 状态码 + 业务码 → AppError。用返回值短路,不走异常 */
    async after(ctx: NetContext): Promise<AppError | undefined> {
        const response: NetResponse | undefined = ctx.response;
        if (response === undefined) {
            return undefined;
        }
        // 第一层:HTTP 状态码
        if (response.statusCode < 200 || response.statusCode >= 300) {
            return AppError.fromHttp(response.statusCode, '', '', ctx.url);
        }
        // 第二层:业务码(HTTP 200 但 code != 0)
        return BizCodeMapper.toError(response.rawBody, ctx.url);
    }
}

// BizCodeMapper:解析统一包装中的业务 code/message,
// 非零业务码翻译成对应的 AppError(示意,完整实现见第 11 章)。

术语:错误码映射 ── 把传输层与业务层的多维错误(HTTP 状态码、业务 code、异常类型)统一翻译成应用内的单一错误类型,使上层不必了解 HTTP 细节。

传输层异常去哪了:DNS、连接超时、TLS、断网这类异常不经过 after——RcpTransport.send 抛出的原生异常被执行循环捕获,由适配点的 toAppError 归一化后进入 onError 链(见 6.3.2 与第 11 章)。

为什么 Retry 注册在链尾:after 阶段 ErrorMap 返回 AppError 即短路(after 链剩余环节不再执行),短路结果进入 onError 链;onError 链式全员执行,保证 Retry 拿到的是已归一化的错误(retryable 已就位)。也就是说,"重试基于结构化错误决策"不是靠把 Retry 排在 ErrorMap 前面,而是靠"ErrorMap 先归一化 + onError 链式传递"这两条语义共同保证的。

6.4.5 重试拦截器

import { AppError } from 'lib_base';
import { NetContext, NetInterceptor } from './NetInterceptor';

const MAX_ATTEMPTS: number = 3;
const BASE_DELAY_MS: number = 300;
const IDEMPOTENT_METHODS: string[] = ['GET', 'HEAD', 'PUT', 'DELETE'];

export class RetryInterceptor extends NetInterceptor {
    readonly name: string = 'retry';

    /**
     * onError 挂载点:只判定"要不要重试",真正重发放到执行循环。
     * 这样取消语义、超时统计、耗时日志都只有一处实现。
     */
    onError(ctx: NetContext, error: AppError): AppError {
        const method: string = ctx.method;
        if (!IDEMPOTENT_METHODS.includes(method)) {
            return error;                    // 非幂等请求绝不自动重试
        }
        if (!error.retryable) {
            return error;                    // 归一化错误已给出结构化判定
        }
        if (ctx.attempt >= MAX_ATTEMPTS) {
            // 关键:达上限必须显式清空重试计划再放弃。
            // onError 链是"链式全员过一遍",本环节之前 Auth 可能已为 401 写入了
            // needRetry=true 的计划;只改错误不清计划的话,执行循环会继续重放,
            // 次数上限对 401 刷新路径形同虚设
            ctx.retryPlan = undefined;
            return error;
        }
        // 指数退避 + 抖动,避免重试风暴
        const delay: number = BASE_DELAY_MS * Math.pow(2, ctx.attempt - 1)
            + Math.floor(Math.random() * 100);
        ctx.retryPlan = { needRetry: true, delayMs: delay };
        return error;
    }
}

幂等性是重试的前提:GET / PUT / DELETE 天然幂等;POST 只有在服务端支持幂等键(如 Idempotency-Key)时才可重试。无条件重试 POST 会造成重复提交——审批单被创建两次是真实事故。

6.4.6 Mock 拦截器

import { AppError } from 'lib_base';
import { NetContext, NetInterceptor } from './NetInterceptor';
import { EnvConfig } from 'lib_base';
import { MockRegistry } from '../mock/MockRegistry';

export class MockInterceptor extends NetInterceptor {
    readonly name: string = 'mock';

    async before(ctx: NetContext): Promise<AppError | undefined> {
        // 用环境配置(EnvConfig)而非运行时开关,保证 Release 包内不存在 Mock 分支
        if (!EnvConfig.isMock()) {
            return undefined;
        }
        const rule = MockRegistry.match(ctx.method, ctx.options.path);
        if (rule === undefined) {
            return undefined;   // 未命中则继续走真实网络
        }
        // 命中:不发起真实网络。把构造响应写进 ctx,
        // 执行循环见到 mockedResponse 就跳过 transport.send,直接进入 after 阶段——
        // "响应处理"这条路径 Mock 与真实请求共用一份实现
        ctx.mocked = true;
        ctx.mockedResponse = MockRegistry.buildResponse(rule);
        return undefined;
    }
}

Mock 放在 Logging 之后、ErrorMap 之前,是刻意设计:命中后日志与耗时埋点照样产出(测试时看到的链路与真实请求一致);返回的假错误(401/500/业务码)照样被 ErrorMap 归一化,错误处理与重试分支可以被真实演练——而不是"开了 Mock 就绕过了整条链路"。


6.5 序列化与类型安全

6.5.1 禁止 JSON.parse 后直接 as

// 错误写法
const raw: string = response.toString();
const dto: ApprovalDetailDto = JSON.parse(raw) as ApprovalDetailDto;   // ❌ 零校验
console.info(dto.creator.name);   // 后端少返回 creator → 运行时崩溃
// 正确写法:手写 fromJson,逐字段校验与兜底
export class ApprovalDetailDto {
    readonly id: string;
    readonly title: string;
    readonly status: ApprovalStatus;
    readonly creatorName: string;
    readonly createdAt: number;

    private constructor(id: string, title: string, status: ApprovalStatus,
                        creatorName: string, createdAt: number) {
        this.id = id;
        this.title = title;
        this.status = status;
        this.creatorName = creatorName;
        this.createdAt = createdAt;
    }

    static fromJson(json: Record<string, Object>): ApprovalDetailDto {
        return new ApprovalDetailDto(
            Json.readString(json, 'id', ''),
            Json.readString(json, 'title', ''),
            parseApprovalStatus(Json.readString(json, 'status', 'unknown')),
            // 兜底值用普通常量:数据层不应依赖 UI 资源。
            // $r('app.string.xxx') 返回资源对象,直接 toString() 拿不到解析后的文案;
            // "未知用户"这类文案由展示层用 resourceManager.getStringSync 解析后再显示
            Json.readString(json, 'creator_name', ''),
            Json.readNumber(json, 'created_at', 0)
        );
    }
}

为什么手写而不是自动映射:

原因 说明
字段名与语言习惯不同 后端 snake_case → 前端 camelCase 的映射必须有人负责
缺失字段需要默认值 不是所有字段都必返回;as 会把缺失变成 undefined 传播
字段类型不稳定 后端可能把 id 从数值改成字符串,需要一个统一转换点
白名单 后端多返回的字段不应进入领域模型(避免字段变更导致的内存放大)
可测试 fromJson 是纯函数,能对异常报文写单测
// lib_base/src/main/ets/util/Json.ets
export class Json {
    /** 入口:文本 → 对象。非法 JSON 或根节点不是对象时返回 undefined(调用方判空) */
    static parseObject(text: string): Record<string, Object> | undefined {
        try {
            const v: Object = JSON.parse(text) as Object;
            return typeof v === 'object' && v !== null ? v as Record<string, Object> : undefined;
        } catch (e) {
            return undefined;
        }
    }

    static readString(json: Record<string, Object>, key: string, fallback: string): string {
        const v: Object | undefined = json[key];
        return typeof v === 'string' ? v : fallback;
    }

    static readNumber(json: Record<string, Object>, key: string, fallback: number): number {
        const v: Object | undefined = json[key];
        if (typeof v === 'number') {
            return v;
        }
        // 容忍后端返回字符串数字
        if (typeof v === 'string') {
            const parsed: number = Number(v);
            return Number.isFinite(parsed) ? parsed : fallback;
        }
        return fallback;
    }

    static readBool(json: Record<string, Object>, key: string, fallback: boolean): boolean {
        const v: Object | undefined = json[key];
        return typeof v === 'boolean' ? v : fallback;
    }

    static readArray<T>(json: Record<string, Object>, key: string): T[] {
        const v: Object | undefined = json[key];
        return Array.isArray(v) ? (v as T[]) : [];
    }
}

6.5.2 字段白名单与向后兼容

规则一:fromJson 只读白名单内的字段。不把整个 json 存进对象,避免后端新增字段时本地内存跟着膨胀。

规则二:新增字段必须有默认值。向后兼容只在一个方向成立:客户端能读旧服务端(缺字段),服务端也能读旧客户端(多字段被忽略)。

// 服务端 v2 新增 priority 字段;客户端 v1 代码不会崩
static fromJson(json: Record<string, Object>): ApprovalItem {
    const item: ApprovalItem = new ApprovalItem();          // 无参构造 + 字段赋值,与 6.2.3 的映射写法一致
    item.id = Json.readString(json, 'id', '');
    item.priority = Json.readString(json, 'priority', 'normal');   // 缺失时兜底
    return item;
}

规则三:不要为"未来可能用到"的字段预留位置。领域模型只包含当前真正使用的字段;后端字段变化时改 fromJson 一处。

6.5.3 四类易错类型的处理

类型 后端常见形态 正确处理
大整数(如订单号、雪花 ID) JSON number,超过 2^53 后精度丢失 在 DTO 中按 string 接收,需要展示时直接当字符串用,不要转 number
金额 number(浮点)或 string(字符串) 统一转成"分的整数"在内部流转,展示时再格式化
日期 毫秒时间戳 / "2026-08-28T10:00:00Z" / "2026-08-28" 统一在 fromJson 中归一化为毫秒时间戳;格式化只在展示层
枚举 字符串 / 数字 / 大小写不一致 用映射函数容错,未识别值落到 UNKNOWN 而不是抛错
export function parseApprovalStatus(raw: string): ApprovalStatus {
    switch (raw.toLowerCase()) {
        case 'pending': return ApprovalStatus.PENDING;
        case 'approved': return ApprovalStatus.APPROVED;
        case 'rejected': return ApprovalStatus.REJECTED;
        default:
            // 未知枚举不抛错:老客户端遇到新状态时仍能展示
            return ApprovalStatus.UNKNOWN;
    }
}
// 日期归一化
export function parseTimestamp(value: Object | undefined): number {
    if (typeof value === 'number') {
        // 容忍后端返回秒级时间戳
        return value < 1_000_000_000_000 ? value * 1000 : value;
    }
    if (typeof value === 'string') {
        const parsed: number = Date.parse(value);
        return Number.isNaN(parsed) ? 0 : parsed;
    }
    return 0;
}

误区:用 JSON.parse 的 reviver 做类型转换。这会把"反序列化"和"领域校验"耦合在一起,且 reviver 无法拿到路径信息,出错时定位困难。分离关注点:JSON.parse 只负责结构,fromJson 负责语义。

误区:用 @Type 或框架能力反序列化网络数据。@Type 服务于 PersistenceV2 的本地持久化,网络报文的字段名、可空性、枚举容错都要单独处理。两条路径的 DTO 不要共用同一个类。

6.5.4 toJson 的方向性

export class ApprovalSubmitDto {
    readonly requestId: string;   // 幂等键,与服务端约定
    readonly templateId: string;
    readonly formData: Record<string, string>;
    readonly remark: string;

    constructor(requestId: string, templateId: string,
                formData: Record<string, string>, remark: string) {
        this.requestId = requestId;
        this.templateId = templateId;
        this.formData = formData;
        this.remark = remark;
    }

    /** 上行:字段名按服务端约定(snake_case),显式拼装 */
    toJson(): Record<string, Object> {
        return {
            'request_id': this.requestId,
            'template_id': this.templateId,
            'form_data': this.formData,
            'remark': this.remark
        };
    }
}

上行同样要白名单:显式列出要提交的字段,而不是把 ViewModel 序列化后整个发出去——后者会把界面态(如 loading)泄漏到网络。


6.6 错误模型:Result<T> 与 AppError

6.6.1 为什么用 Result 而不是异常

维度 异常穿透 Result<T>
调用方是否知道可能失败 不确定(签名上看不出来) 明确(返回类型写了 Result)
是否强制处理 否,容易漏 catch 是,必须 fold 或判 ok
跨层语义 各层 rethrow,易丢失上下文 错误对象自带完整上下文
与 UI 的对接 需要 try/catch 包裹 直接 fold 出 UI 状态
可测试性 需要断言抛出 直接断言返回值

结论:业务分层之间用 Result 传递结果,异常只用于真正的异常(传输层、编程错误) —— 而传输层异常会在最外层被 ErrorMapInterceptor(与适配点的 toAppError)翻译成 AppError,因此上层永远不需要写 try/catch。

6.6.2 AppError

// lib_base/src/main/ets/error/AppError.ets
export enum ErrorCode {
    NETWORK = 'NETWORK',             // 断网、DNS 失败、连接被拒
    TIMEOUT = 'TIMEOUT',             // 连接或传输超时
    UNAUTHORIZED = 'UNAUTHORIZED',   // 401,需要登录
    FORBIDDEN = 'FORBIDDEN',         // 403,无权限
    NOT_FOUND = 'NOT_FOUND',         // 404
    CONFLICT = 'CONFLICT',           // 409,版本冲突
    BIZ_REJECTED = 'BIZ_REJECTED',   // 业务拒绝(校验不通过、状态不允许)
    SERVER = 'SERVER',               // 5xx
    PARSE = 'PARSE',                 // 报文解析失败
    CANCELLED = 'CANCELLED',         // 主动取消
    UNKNOWN = 'UNKNOWN'
}

export interface AppErrorInit {
    code: ErrorCode;
    httpStatus?: number;
    bizCode?: string;
    message: string;        // 开发者可见的完整信息
    userMessage: string;    // 用户可见的文案(本地化后)
    retryable: boolean;     // 是否值得重试
    cause?: Error;          // 原始错误,用于日志
    traceId?: string;       // 链路 id,用于与后端对账
}

export class AppError extends Error {
    readonly code: ErrorCode;
    readonly httpStatus: number;
    readonly bizCode: string;
    readonly userMessage: string;
    readonly retryable: boolean;
    readonly cause: Error | undefined;
    readonly traceId: string;

    constructor(init: AppErrorInit) {
        super(init.message);
        this.name = 'AppError';
        this.code = init.code;
        this.httpStatus = init.httpStatus ?? 0;
        this.bizCode = init.bizCode ?? '';
        this.userMessage = init.userMessage;
        this.retryable = init.retryable;
        this.cause = init.cause;
        this.traceId = init.traceId ?? '';
    }

    static of(code: ErrorCode, message: string, retryable: boolean, cause?: Error): AppError {
        return new AppError({
            code: code,
            message: message,
            userMessage: AppError.defaultUserMessage(code),
            retryable: retryable,
            cause: cause
        });
    }

    /** HTTP 状态码 + 业务码 → 统一错误 */
    static fromHttp(status: number, bizCode: string, serverMessage: string, url: string): AppError {
        switch (status) {
            case 401:
                return new AppError({
                    code: ErrorCode.UNAUTHORIZED, httpStatus: status, bizCode: bizCode,
                    message: `401 ${url}`, userMessage: '登录已过期,请重新登录', retryable: false
                });
            case 403:
                return new AppError({
                    code: ErrorCode.FORBIDDEN, httpStatus: status, bizCode: bizCode,
                    message: `403 ${url}`, userMessage: '没有操作权限', retryable: false
                });
            case 404:
                return new AppError({
                    code: ErrorCode.NOT_FOUND, httpStatus: status, bizCode: bizCode,
                    message: `404 ${url}`, userMessage: '请求的内容不存在', retryable: false
                });
            case 409:
                return new AppError({
                    code: ErrorCode.CONFLICT, httpStatus: status, bizCode: bizCode,
                    message: `409 ${url}`, userMessage: '数据已被他人修改,请刷新后重试', retryable: false
                });
            default:
                if (status >= 500) {
                    return new AppError({
                        code: ErrorCode.SERVER, httpStatus: status, bizCode: bizCode,
                        message: `${status} ${url}`,
                        userMessage: '服务暂时不可用,请稍后重试', retryable: true
                    });
                }
                // 4xx 中的业务拒绝:直接展示服务端给出的可读消息
                return new AppError({
                    code: ErrorCode.BIZ_REJECTED, httpStatus: status, bizCode: bizCode,
                    message: `${status} ${bizCode} ${serverMessage}`,
                    userMessage: serverMessage.length > 0 ? serverMessage : '操作未成功',
                    retryable: false
                });
        }
    }

    private static defaultUserMessage(code: ErrorCode): string {
        switch (code) {
            case ErrorCode.NETWORK: return '网络不可用,请检查网络后重试';
            case ErrorCode.TIMEOUT: return '请求超时,请稍后重试';
            case ErrorCode.PARSE: return '数据格式异常,请升级应用后重试';
            case ErrorCode.CANCELLED: return '';
            default: return '操作失败,请稍后重试';
        }
    }
}

字段设计意图:

字段 谁用 为什么必须存在
code 程序分支 稳定枚举,用于 switch 决策(如 401 触发跳登录)
httpStatus 日志与排查 与后端对账时唯一无歧义的标识
bizCode 后端契约 同一 HTTP 状态下的细分业务原因
message 日志 开发者视角的完整信息,不上屏
userMessage UI 已本地化、可直接展示;与 message 分离避免技术细节泄漏
retryable 重试策略 用结构而非字符串判断是否需要重试
cause 日志 保留原始异常,避免"错误被翻译后原始信息丢失"
traceId 端到端排查 用户报障时可凭它直接定位服务端日志

6.6.3 Result<T> 的类继承判别设计

// lib_base/src/main/ets/error/Result.ets
import { AppError } from './AppError';

export abstract class Result<T> {
    readonly ok: boolean;

    protected constructor(ok: boolean) {
        this.ok = ok;
    }

    abstract map<U>(f: (value: T) => U): Result<U>;
    abstract flatMap<U>(f: (value: T) => Result<U>): Result<U>;
    abstract fold<U>(onOk: (value: T) => U, onErr: (error: AppError) => U): U;

    static ok<T>(value: T): Result<T> {
        return new Success<T>(value);
    }

    static fail<T>(error: AppError): Result<T> {
        return new Failure<T>(error);
    }
}

export class Success<T> extends Result<T> {
    readonly value: T;

    constructor(value: T) {
        super(true);
        this.value = value;
    }

    map<U>(f: (value: T) => U): Result<U> {
        return new Success<U>(f(this.value));
    }

    flatMap<U>(f: (value: T) => Result<U>): Result<U> {
        return f(this.value);
    }

    fold<U>(onOk: (value: T) => U, onErr: (error: AppError) => U): U {
        return onOk(this.value);
    }
}

export class Failure<T> extends Result<T> {
    readonly error: AppError;

    constructor(error: AppError) {
        super(false);
        this.error = error;
    }

    // 失败分支:map/flatMap 直接透传,不执行 f(避免对无效值做二次加工)
    map<U>(f: (value: T) => U): Result<U> {
        return new Failure<U>(this.error);
    }

    flatMap<U>(f: (value: T) => Result<U>): Result<U> {
        return new Failure<U>(this.error);
    }

    fold<U>(onOk: (value: T) => U, onErr: (error: AppError) => U): U {
        return onErr(this.error);
    }
}

三个算子的语义:

算子 签名 用途 失败时的行为
map (T) => U 对成功值做纯变换(如 DTO → 领域模型) 透传原错误,不调用变换
flatMap (T) => Result<U> 串联可能失败的下一步(如 DTO → 领域模型 → 落库) 透传原错误,短路
fold (T) => U, (AppError) => U 把两种情况收敛成一个值(通常是 UI 状态) 走 onErr 分支
// 组合用法:先 await 拿到 Result,再一路 map/flatMap,最后 fold 出 UI 状态
// 注意:map/flatMap 是 Result 上的算子,不能直接链在 Promise 上
const r: Result<ApprovalDetail> = await repo.detail(id);
const result: Result<ApprovalDetailVM> = r
    .map((detail: ApprovalDetail) => ApprovalDetailVM.from(detail))
    .flatMap((vm: ApprovalDetailVM) => validatePermission(vm));

result.fold(
    (vm: ApprovalDetailVM) => { this.detail = vm; this.errorMessage = ''; },
    (err: AppError) => {
        this.errorMessage = err.userMessage;
        if (err.code === ErrorCode.UNAUTHORIZED) {
            this.stack?.pushPathByName(RouteName.LOGIN, new LoginParam(), false);
        }
    }
);

术语:Result ── 用类型表达"成功有值 / 失败有错误"的容器。它把"可能失败"从运行时的意外变成编译期的类型信息,是本书错误处理模型的基石。

为什么用继承 + instanceof 而不是 { ok, value?, error? } 联合类型:

  1. 在 ArkTS 的静态类型约束下,可选字段的收窄需要额外断言;继承 + ok 判别能自然分流。
  2. Success / Failure 各自只持有自己需要的字段,不存在"成功对象上有个 error: undefined"这种伪字段。
  3. 新增算子(如 recover、mapError)只需在两个子类各实现一次,无需改动类型定义。
  4. 与 instanceof 的配合天然支持"只处理一种情况"的写法(见下方 recover)。
/** 失败时提供降级值,转换为成功 */
export function recover<T>(result: Result<T>, fallback: T): Result<T> {
    if (result instanceof Failure) {
        return new Success<T>(fallback);
    }
    return result;
}

6.7 Repository 收口

6.7.1 职责与边界

Repository 是数据来源的唯一出口。它解决三个具体问题:

  1. 数据从哪来:内存 → 本地库 → 远端,按优先级编排。
  2. 什么时候刷:本地优先 + 远端刷新(stale-while-revalidate)。
  3. 失败怎么办:远端失败时降级到本地,而不是把错误直接甩给 UI。
ViewModel ──► Repository ──┬──► RemoteDs(rcp)
                          ├──► LocalDs(RDB / Preferences)
                          └──► MemoryCache
层 知道什么 不知道什么
ViewModel 界面需要哪些字段、如何组织 UI 状态 HTTP、SQL、缓存策略
Repository 数据来源优先级、缓存新鲜度、一致性策略 UI 结构、路由、组件
RemoteDs URL、DTO、序列化 业务规则
LocalDs 表结构、SQL 网络

6.7.2 策略:本地优先 + 远端刷新 + 写穿透

策略 语义 适用
缓存优先(SWR) 立即返回本地数据,同时后台拉远端;远端回来后更新 列表、详情等容忍短暂陈旧的数据
网络优先 先请求远端,失败才读本地 强一致性要求(如权限、余额)
仅本地 只读本地 离线模式、草稿
写穿透 先写远端,成功后写本地;失败则标记待同步 提交审批、撤回等关键动作
写回退 先写本地并标记脏位,稍后补偿 弱网下的评论、点赞
export enum Freshness {
    FRESH = 'FRESH',           // 可直接用
    STALE = 'STALE',           // 可用但需后台刷新
    EXPIRED = 'EXPIRED'        // 必须等待远端
}

6.7.3 完整 ApprovalRepository

// lib_storage/src/main/ets/repository/ApprovalRepository.ets
import { hilog } from '@kit.PerformanceAnalysisKit';
import { AppError, ErrorCode, Result } from 'lib_base';
import { ApprovalItem, ApprovalFilter, ApprovalDetail } from 'lib_base';
import { ApprovalRemoteDs } from '../datasource/ApprovalRemoteDs';
import { ApprovalLocalDs } from '../datasource/ApprovalLocalDs';

const DOMAIN: number = 0x0F06;
const TAG: string = 'ApprovalRepo';

/** 本地数据的新鲜度阈值:60 秒内视为新鲜 */
const FRESH_TTL_MS: number = 60_000;
/** 超过该时长视为过期,必须等远端 */
const EXPIRED_TTL_MS: number = 24 * 60 * 60 * 1000;

export class ApprovalRepository {
    private static instance: ApprovalRepository | undefined = undefined;

    private remote: ApprovalRemoteDs = new ApprovalRemoteDs();
    private local: ApprovalLocalDs = new ApprovalLocalDs();
    /** 内存缓存:进程内最快的一层 */
    private memory: Map<string, ApprovalDetail> = new Map();
    /** 单飞:同一 id 的并发刷新合并为一次 */
    private inflightDetail: Map<string, Promise<Result<ApprovalDetail>>> = new Map();

    static shared(): ApprovalRepository {
        if (ApprovalRepository.instance === undefined) {
            ApprovalRepository.instance = new ApprovalRepository();
        }
        return ApprovalRepository.instance;
    }

    /**
     * 列表查询:本地优先 + 远端刷新
     * 语义:只要能拿到本地数据就先返回,避免白屏;随后用远端数据覆盖。
     */
    async list(filter: ApprovalFilter, forceRefresh: boolean = false): Promise<Result<ApprovalItem[]>> {
        const cached: ApprovalItem[] = await this.local.listByStatus(filter);
        const stale: boolean = this.local.isListStale(FRESH_TTL_MS);

        if (cached.length > 0 && !stale && !forceRefresh) {
            // 命中内存级新鲜度:直接返回,不发网络请求
            return Result.ok<ApprovalItem[]>(cached);
        }

        const remoteResult: Result<ApprovalItem[]> = await this.remote.list(filter, cached);
        if (remoteResult instanceof Success) {
            // 写回本地,供下次冷启动使用
            await this.local.upsertBatch(remoteResult.value);
            this.local.markListFresh();
            return remoteResult;
        }

        // 远端失败且本地有数据 → 降级到本地,不把错误抛给 UI
        if (cached.length > 0) {
            hilog.warn(DOMAIN, TAG, 'remote failed, fallback to local cache');
            return Result.ok<ApprovalItem[]>(cached);
        }
        // 本地也没有 → 只能把错误交给上层
        return remoteResult;
    }

    /**
     * 详情查询:内存 → 本地 → 远端,带 single-flight
     */
    async detail(id: string, forceRefresh: boolean = false): Promise<Result<ApprovalDetail>> {
        if (!forceRefresh) {
            const inMemory: ApprovalDetail | undefined = this.memory.get(id);
            if (inMemory !== undefined) {
                return Result.ok<ApprovalDetail>(inMemory);
            }

            const localDetail: ApprovalDetail | undefined = await this.local.findDetail(id);
            const freshness: Freshness = this.local.detailFreshness(id, FRESH_TTL_MS, EXPIRED_TTL_MS);

            if (localDetail !== undefined && freshness === Freshness.FRESH) {
                this.memory.set(id, localDetail);
                return Result.ok<ApprovalDetail>(localDetail);
            }
            if (localDetail !== undefined && freshness === Freshness.STALE) {
                // 先返回本地,同时后台刷新(不阻塞调用方)
                this.refreshDetailInBackground(id);
                this.memory.set(id, localDetail);
                return Result.ok<ApprovalDetail>(localDetail);
            }
            // EXPIRED:必须等远端;若远端失败则用本地兜底
        }

        const remoteResult: Result<ApprovalDetail> = await this.fetchDetailOnce(id);
        if (remoteResult instanceof Success) {
            this.memory.set(id, remoteResult.value);
            await this.local.upsertDetail(remoteResult.value);
            return remoteResult;
        }

        const fallback: ApprovalDetail | undefined = await this.local.findDetail(id);
        if (fallback !== undefined) {
            hilog.warn(DOMAIN, TAG, 'detail remote failed, fallback to local');
            return Result.ok<ApprovalDetail>(fallback);
        }
        return remoteResult;
    }

    /**
     * 写穿透:先写远端,成功后更新本地与内存
     * 失败时不做本地"乐观更新",避免界面显示成功但服务端无记录
     */
    async approve(id: string, operatorId: string): Promise<Result<void>> {
        const result: Result<void> = await this.remote.approve(id, operatorId);
        if (result instanceof Success) {
            await this.local.updateStatus(id, 'approved', Date.now());
            this.memory.delete(id);           // 让下次读取重新拉取完整详情
            this.local.markListDirty();
            return Result.ok<void>(undefined);
        }
        const err: AppError = (result as Failure<void>).error;
        hilog.error(DOMAIN, TAG, 'approve failed: %{public}s', err.code);
        return result;
    }

    /**
     * 弱网可用的写回退:先落本地并标记脏位,由同步器补偿
     */
    async saveDraft(draft: ApprovalDetail): Promise<Result<void>> {
        await this.local.upsertDraft(draft, true);   // true = dirty
        this.memory.set(draft.id, draft);
        return Result.ok<void>(undefined);
    }

    /** 登出时清空所有层次,避免跨账号数据泄漏 */
    async clearAll(): Promise<void> {
        this.memory.clear();
        this.inflightDetail.clear();
        await this.local.clearAll();
    }

    // ---------- 私有 ----------

    /** single-flight:同一 id 的并发请求只发一次网络 */
    private async fetchDetailOnce(id: string): Promise<Result<ApprovalDetail>> {
        const existing: Promise<Result<ApprovalDetail>> | undefined = this.inflightDetail.get(id);
        if (existing !== undefined) {
            return existing;
        }
        const task: Promise<Result<ApprovalDetail>> = this.remote.detail(id)
            .finally(() => { this.inflightDetail.delete(id); });
        this.inflightDetail.set(id, task);
        return task;
    }

    private refreshDetailInBackground(id: string): void {
        this.fetchDetailOnce(id)
            .then(async (result: Result<ApprovalDetail>) => {
                if (result instanceof Success) {
                    this.memory.set(id, result.value);
                    await this.local.upsertDetail(result.value);
                }
            })
            .catch((e: Error) => {
                hilog.warn(DOMAIN, TAG, 'background refresh failed: %{public}s', e.message);
            });
    }
}

要点提炼:

要点 实现方式
不白屏 有本地数据就先返回,远端在后台补
不重复请求 内存缓存 + single-flight
不把错误甩给 UI 本地有数据时远端失败即降级
不产生"假成功" 关键写操作先远端后本地,不做乐观更新
不跨账号串数据 登出时 clearAll()
可观测 每个降级分支都打 warn 日志

6.7.4 DataSource 层

// lib_storage/src/main/ets/datasource/ApprovalRemoteDs.ets
import { Failure, Json, Result, Success, AppError, ErrorCode } from 'lib_base';
import { NetResponse } from 'lib_network';
import { RcpClient } from 'lib_network';
import { ApprovalItem, ApprovalFilter, ApprovalDetail } from 'lib_base';
import { ApprovalListDto, ApprovalDetailDto } from '../dto/ApprovalDto';

export class ApprovalRemoteDs {
    private client: RcpClient = RcpClient.get(BASE_URL);

    async list(filter: ApprovalFilter, cached: ApprovalItem[]): Promise<Result<ApprovalItem[]>> {
        const raw: Result<NetResponse> = await this.client.execute({
            path: '/v1/approvals',
            query: { 'status': filter.status },   // query 由门面统一编码,不手拼进路径字符串
            method: 'GET',
            headers: { 'If-None-Match': EtagStore.get('approvals') },
            idempotent: true
        });
        if (raw instanceof Failure) {
            return Result.fail<ApprovalItem[]>((raw as Failure<NetResponse>).error);
        }
        const response: NetResponse = (raw as Success<NetResponse>).value;
        if (response.statusCode === 304) {
            // 服务端未变更:复用本地数据,省一次传输
            return Result.ok<ApprovalItem[]>(cached);
        }
        const body: Record<string, Object> | undefined = Json.parseObject(response.rawBody);
        if (body === undefined) {
            return Result.fail<ApprovalItem[]>(
                AppError.of(ErrorCode.PARSE, 'approval list: body is not an object', false));
        }
        EtagStore.put('approvals', response.headers['etag'] ?? '');
        const items: ApprovalItem[] = ApprovalListDto.fromJson(body).items;
        return Result.ok<ApprovalItem[]>(items);
    }

    async detail(id: string): Promise<Result<ApprovalDetail>> {
        const raw: Result<NetResponse> = await this.client.execute({
            path: `/v1/approvals/${id}`, method: 'GET', idempotent: true
        });
        if (raw instanceof Failure) {
            return Result.fail<ApprovalDetail>((raw as Failure<NetResponse>).error);
        }
        const response: NetResponse = (raw as Success<NetResponse>).value;
        const body: Record<string, Object> | undefined = Json.parseObject(response.rawBody);
        if (body === undefined) {
            return Result.fail<ApprovalDetail>(
                AppError.of(ErrorCode.PARSE, `approval ${id}: body is not an object`, false));
        }
        return Result.ok<ApprovalDetail>(ApprovalDetailDto.fromJson(body).toDomain());
    }

    async approve(id: string, operatorId: string): Promise<Result<void>> {
        const raw: Result<NetResponse> = await this.client.execute({
            path: `/v1/approvals/${id}/approve`,
            method: 'POST',
            body: { 'operator_id': operatorId },
            idempotent: false,                                 // 写操作默认不自动重试
            idempotencyKey: `${id}:approve:${operatorId}`      // 服务端据此去重
        });
        if (raw instanceof Failure) {
            return Result.fail<void>((raw as Failure<NetResponse>).error);
        }
        return Result.ok<void>(undefined);
    }
}

注意这里不再有 catch (e) → e as AppError:传输层异常由 RcpTransport 的 toAppError 归一化,HTTP 状态码与业务码由 ErrorMapInterceptor 归一化,DataSource 拿到的失败已经包装在 Result.fail 里——error 一定是 AppError。这个"一定"由 6.4 的拦截器链结构保证,而不是靠运气。


6.8 落地与调试

6.8.1 Mock 与真实后端切换

模式 开关 行为
进程内 Mock EnvConfig.isMock()(环境配置,非运行时开关) MockInterceptor 命中规则时短路返回本地契约内容
本地假后端 node server/mock-server.js 独立 Node 进程模拟服务端契约(支持 ?simulate 故障注入与 __edge__ 系边界数据),App 走完整真实链路,baseUrl 指向本机(见附录 D / 第 11 章)
真实 关闭 Mock、baseUrl 指向真实环境 走完整网络链路

三档各司其职:进程内 Mock 测"链路语义"(拦截器、重试、错误分支),本地假后端测"真实 HTTP 语义"(连接、超时、真实报文),两者都不依赖后端排期。Mock 规则与样本应与 DTO 单测共用同一份样本数据(工程中分布在各业务模块的 mock/ 目录),避免"Mock 数据能跑、真实数据解析失败"。

6.8.2 排查路径

现象 优先怀疑 验证方式
所有请求 401 Session 头部固化了旧 token 打印 Session 创建时间与当前 token 变更时间
偶发"数据没更新" ETag 未随数据一起失效 清空 ETag 后重试
界面白屏后出现内容 走了"必须等远端"的分支,本地无缓存 打印 freshness 判定结果
列表数据比详情旧 写操作后未 markListDirty() 检查写穿透路径
数据库查询越来越慢 ResultSet 未 close() 统计打开/关闭次数
提交重复创建 POST 被重试拦截器重发 打印 Idempotency-Key 是否稳定
冷启动读到旧数据 缓存未按用户隔离 检查 clearAll() 是否在登出时调用

6.8.3 三条不可让步的纪律

  1. ViewModel 不直接调用 rcp,也不直接写 SQL。 只调 Repository。
  2. Repository 返回 Result,不抛异常。 异常只存在于 DataSource 内部。
  3. 任何写操作都要考虑"服务端成功、本地失败"与"本地成功、服务端失败"两个方向。 前者靠重放本地写入解决,后者靠不做乐观更新规避。

6.9 常见误区

1. 把 token 写进 Session 的默认 headers

// 错误写法
const session: rcp.Session = rcp.createSession({
    baseAddress: 'https://api.officeflow.example.com',
    headers: { 'Authorization': `Bearer ${token}` }   // 创建时固化
});
// 正确写法
const session: rcp.Session = rcp.createSession({
    baseAddress: 'https://api.officeflow.example.com',
    headers: { 'Content-Type': 'application/json' }     // 只放不变的头
});
// token 由 AuthInterceptor 在每次请求的 before 阶段注入 ctx.headers

为什么错:SessionConfiguration 是创建时的快照,不会随外部变量变化更新。用户重新登录、token 刷新后,所有请求仍然带着旧 token,表现为"刚登录就 401"。排查时容易误判为后端问题——因为重新打开应用(重建 Session)后一切正常。

2. JSON.parse 后直接 as

// 错误写法
const item: ApprovalItem = JSON.parse(body) as ApprovalItem;
// 正确写法
const dto: ApprovalDetailDto = ApprovalDetailDto.fromJson(JSON.parse(body) as Record<string, Object>);
const item: ApprovalItem = dto.toDomain();

为什么错:as 是编译期断言,运行时零校验。后端少返回一个嵌套对象、把 id 从数字改成字符串、返回 null 而不是 [],都会在界面渲染时才崩溃,且崩溃点离原因很远。边界处必须显式解析。

3. 用浮点数存金额

// 错误写法
const amount: number = 0.1 + 0.2;   // 0.30000000000000004
store.insert(TABLE, { 'amount': amount });
// 正确写法:内部统一用"分"的整数
const amountCents: number = 10 + 20;   // 30
// 展示层再格式化:`${(amountCents / 100).toFixed(2)}`

为什么错:IEEE 754 双精度无法精确表示大多数十进制小数,累加与比较会产生偏差。审批金额这类数据一旦出现 0.30000000000000004,对账时无法解释。内部用整数分,只在展示层转元。

4. 大整数用 number 接收

// 错误写法
const orderId: number = json['order_id'] as number;   // 19 位雪花 id 已被截断
// 正确写法
const orderId: string = Json.readString(json, 'order_id', '');

为什么错:JSON number 解析为双精度浮点,超过 2^53 后低位数字丢失。1234567890123456789 会变成 1234567890123456800,且不报错。这类问题在测试环境(id 短)不出现,生产环境(id 长)才爆发。

5. 无条件重试 POST

// 错误写法:onError 里对一切请求写重试计划
onError(ctx: NetContext, error: AppError): AppError {
    ctx.retryPlan = { needRetry: true, delayMs: 300 };   // 全都重试
    return error;
}
// 正确写法:只重试幂等方法;需要重试 POST 时依赖服务端幂等键
onError(ctx: NetContext, error: AppError): AppError {
    if (!IDEMPOTENT_METHODS.includes(ctx.method)) {
        return error;   // 非幂等请求绝不写重试计划
    }
    ctx.retryPlan = { needRetry: true, delayMs: backoff(ctx.attempt) };
    return error;
}
// POST 提交时带上稳定的 Idempotency-Key,由服务端去重

为什么错:POST /approvals 重试两次会创建两张审批单,用户侧表现为"我只点了一次却提交了两遍"。客户端无法自行保证 POST 幂等,只能靠"不自动重试 + 幂等键"组合解决。

6. 在拦截器里吞掉错误并返回伪造响应

// 错误写法
async after(ctx: NetContext): Promise<AppError | undefined> {
    // 把失败"洗"成 undefined,上层拿到一个空壳响应
    return undefined;
}
// 正确写法:要么返回真实归一化的 AppError,要么只在明确的降级场景(Mock 命中)返回构造好的响应
async before(ctx: NetContext): Promise<AppError | undefined> {
    // 仅在 Mock 模式且命中规则时短路(写 mockedResponse,走统一响应路径)
    if (EnvConfig.isMock()) {
        const rule = MockRegistry.match(ctx.method, ctx.options.path);
        if (rule !== undefined) {
            ctx.mocked = true;
            ctx.mockedResponse = MockRegistry.buildResponse(rule);
            return undefined;
        }
    }
    return undefined;
}

为什么错:伪造响应会让上层拿到 statusCode === undefined 的对象,错误映射拦截器无法识别,最终在解析处抛出一个与根因无关的异常。降级必须在 Repository 层做,且降级用的是真实存在的本地数据。

7. ResultSet 忘记 close()

// 错误写法
const rs: relationalStore.ResultSet = await store.querySql(sql, args);
while (rs.goToNextRow()) { /* ... */ }
return result;   // ❌ 未关闭
// 正确写法
const rs: relationalStore.ResultSet = await store.querySql(sql, args);
try {
    while (rs.goToNextRow()) { /* ... */ }
} finally {
    rs.close();
}

为什么错:ResultSet 持有底层查询游标与连接资源。未关闭会导致连接池逐渐耗尽,表现为"应用运行一段时间后所有数据库操作变慢直至失败",而单次操作的日志看起来完全正常。

8. 在主线程做大批量导入

// 错误写法
async function syncAll(): Promise<void> {
    const all: ApprovalItem[] = await remote.listAll();       // 5000 条
    await local.upsertBatch(all);                             // 主线程写 5000 行
}
// 正确写法:分页 + 分批写入 + 进度上报
async function syncAll(total: number,
                       onProgress: (done: number, total: number) => void): Promise<Result<void>> {
    // total 由调用方在启动同步前取好(如列表页已有的分页总数),本函数不重复统计
    let offset: number = 0;
    const pageSize: number = 200;
    while (true) {
        const page: Result<ApprovalItem[]> = await repo.listPage(offset, pageSize);
        if (page instanceof Failure) {
            return page;
        }
        await local.upsertBatch(page.value);                  // 主线程分批写;重计算已在 taskpool 完成
        onProgress(offset + page.value.length, total);
        if (page.value.length < pageSize) {
            break;
        }
        offset += pageSize;
    }
    return Result.ok<void>(undefined);
}

为什么错:主线程同时负责渲染与事件分发。5000 条的解析与写入会占用数百毫秒到数秒,期间界面完全冻结,用户看到的是"点了没反应";严重时会触发系统的无响应检测。重计算(解析、校验)移到 taskpool,写库用分批 batchInsert。

9. Repository 里做 UI 决策

// 错误写法
async list(): Promise<Result<ApprovalItem[]>> {
    const result = await this.remote.list();
    if (result instanceof Failure && result.error.code === ErrorCode.UNAUTHORIZED) {
        router.pushByName('Login');   // ❌ 数据层触发路由
    }
    return result;
}
// 正确写法:数据层只返回错误,由 ViewModel 决定跳转
// Repository
return result;

// ViewModel
result.fold(
    (items: ApprovalItem[]) => { this.items = items; },
    (err: AppError) => {
        if (err.code === ErrorCode.UNAUTHORIZED) {
            this.stack?.pushPathByName(RouteName.LOGIN, new LoginParam(), false);
        }
        this.errorMessage = err.userMessage;
    }
);

为什么错:Repository 会被后台任务、预加载、多端协同等非 UI 场景复用。一旦它触发跳转,这些场景就会弹出莫名其妙的登录页;同时数据层依赖了路由,单元测试无法独立运行。跳转永远是 UI 层的决定。

10. 缓存未按用户隔离

// 错误写法
async clearAll(): Promise<void> {
    // 为空 —— 登出时什么都不做
}
// 正确写法
async clearAll(): Promise<void> {
    this.memory.clear();
    this.inflightDetail.clear();
    await this.local.clearAll();
    await SettingsStore.shared().resetUserScoped();
}

为什么错:本地缓存与内存缓存往往包含上一个账号的审批数据。切换账号后不清理,A 用户的列表数据会在 B 用户登录后被当作本地缓存返回——这既是数据错误,也是安全事件。登出路径必须显式清理所有层级的用户态数据。


6.10 实践任务

任务描述

为 OfficeFlow 交付完整的数据层:

  1. 用 PersistenceV2 存"主题 / 语言 / 上次登录账号"这三项轻量配置(与交付物清单一致;个人偏好类小状态若选 preferences.getPreferences 亦可,但需写明取舍,二者不得混用于同一类数据)。
  2. 建立 RDB 库 officeflow.db,包含 t_approval 与 t_draft 两张表,为 t_approval 建立"状态 + 创建时间"组合索引。
  3. 实现 ApprovalLocalDs:upsertBatch 走事务、listByStatus 用索引查询并正确关闭 ResultSet。
  4. 实现网络门面 RcpClient:内部 RcpTransport 是全工程唯一直接调用 rcp 的适配点,Session 只配置不变的超时与重定向策略,可变头全部走每次请求。
  5. 实现拦截器链(注册序):AuthInterceptor(401 单飞刷新)→ LoggingInterceptor(URL 与 body 脱敏)→ MockInterceptor → ErrorMapInterceptor → RetryInterceptor(仅幂等)。
  6. 实现 AppError 与 Result<T>,包含 map / flatMap / fold / recover。
  7. 实现 ApprovalRepository:列表 SWR、详情内存 + single-flight、审批写穿透、草稿写回退。
  8. 提供一条端到端链路:列表页 → 详情页 → 审批 → 返回列表并局部刷新,全程无 try/catch 出现在 ViewModel 中。

验收 checklist

  • [ ] Preferences 中不存在超过 8KB 的值;缓存类数据存放于 cacheDir 而非 filesDir
  • [ ] 全工程无 @ohos.data.* / @ohos.net.http / @ohos.file.* 导入
  • [ ] t_approval 表有 (status, created_at) 组合索引,EXPLAIN QUERY PLAN 显示走索引
  • [ ] upsertBatch 使用 beginTransaction / commit / rollBack,异常路径有回滚
  • [ ] 所有 ResultSet 均在 finally 中 close()
  • [ ] 大批量导入的解析、校验等重计算在 taskpool 中执行,写库为分批 batchInsert
  • [ ] rcp.createSession 全工程只在适配点 RcpTransport 中调用一次,且配置中不含 Authorization
  • [ ] 刷新 token 后,无需重启应用,后续请求自动携带新 token
  • [ ] 并发 5 个 401 请求只触发 1 次 token 刷新(打印刷新次数)
  • [ ] LoggingInterceptor 输出中搜索不到 Bearer、完整手机号、password
  • [ ] POST 请求不会自动重试;Idempotency-Key 在多次点击下保持一致
  • [ ] 金额字段内部以整数分存储,t_approval.amount 无浮点误差
  • [ ] 订单号 / 审批 id 等长数字字段在 DTO 中为 string
  • [ ] 断开网络后,已有缓存的列表仍能正常展示,且能看到降级 warn 日志
  • [ ] 主动断开网络后执行审批,界面提示失败且本地数据未被标记为已审批
  • [ ] 登出后再次以另一账号登录,列表中不出现上一账号的数据
  • [ ] ViewModel 中不存在 try / catch 与 await rcp / store.query 调用

交付物清单

交付物 位置
错误模型 lib_base/src/main/ets/error/{AppError,Result}.ets
JSON 读取工具 lib_base/src/main/ets/util/Json.ets
轻量配置 lib_storage/src/main/ets/SettingsStore.ets(PersistenceV2)
RDB 封装 lib_storage/src/main/ets/db/{ApprovalSql,ApprovalStore}.ets
数据源 lib_storage/src/main/ets/datasource/{ApprovalRemoteDs,ApprovalLocalDs}.ets
DTO 与解析 lib_storage/src/main/ets/dto/ApprovalDto.ets
网络层 lib_network/src/main/ets/rcp/RcpClient.ets(含唯一适配点 RcpTransport)、lib_network/src/main/ets/mock/MockRegistry.ets
拦截器链 lib_network/src/main/ets/rcp/interceptors/{Auth,Logging,Mock,ErrorMap,Retry}Interceptor.ets
Repository lib_storage/src/main/ets/repository/ApprovalRepository.ets
Mock 规则 各业务模块 mock/ 目录(如 feature_approval/mock/ApprovalMockRules.ets)
数据层说明 docs/data-layer.md(分层图 + 每个降级分支的触发条件)

6.11 延伸阅读

  • 官方文档 → 应用开发 → 应用框架 → ArkData(方舟数据管理)→ Preferences 开发指导(读写、flush 与容量限制)
  • 官方文档 → 应用开发 → 应用框架 → ArkData → 关系型数据库(RDB)开发指导(建表、事务、ValuesBucket、ResultSet)
  • 官方文档 → 应用开发 → 应用框架 → ArkData → 分布式键值数据库开发指导(KVStore 类型与设备间同步)
  • 官方文档 → 应用开发 → 应用框架 → Remote Communication Kit → 使用 rcp 发起网络请求(Session、Request、Response 与超时配置)
  • 官方文档 → 应用开发 → 应用框架 → Remote Communication Kit → 拦截器(Interceptor、RequestContext 与执行顺序)
  • 官方文档 → 应用开发 → ArkTS → TaskPool 简介与使用(并发任务与线程间通信约束)
  • 官方文档 → 应用开发 → 应用框架 → Core File Kit → 应用文件访问(沙箱路径、fileIo 与文件读写)

上一章:第 5 章 状态管理 下一章:第 7 章 分布式能力

📄 第 7 章 分布式能力 07-distributed.md

分布式不是"把云端的活搬到手机上",而是让同一账号下的多台设备像一台设备一样工作。这一章的重点不是 API 有多少,而是哪些能做、哪些做不到、边界在哪。


7.0 本章目标

  1. 能判断一个需求该落在分布式五类能力中的哪一类(发现 / 数据 / 启动 / 接续 / 文件)。
  2. 能列出分布式调试的硬性前置条件,并在两台真机上完成一次设备发现与推送。
  3. 能在分布式数据对象(DataObject)与分布式 KVStore之间做出有依据的选型,并设计"状态与重资源分离"的同步策略、指定同步粒度。
  4. 能设计"每条记录都带版本号"的数据模型,写出带版本号的合并函数,并说清"最后写入胜出"在什么场景下会丢数据。
  5. 能把同步订阅注册到应用级生命周期,保证 off 与 release 成对出现。

7.1 能力地图与前置条件

7.1.1 五类能力

能力 解决什么 承载 Kit 状态特征 典型场景
设备发现与管理 知道周围有谁、谁上线下线 @kit.DistributedServiceKit 只读,事件驱动 选设备弹窗、参与者列表
分布式数据对象 DataObject 一个内存对象在多设备上"看起来是同一个" @kit.ArkData 有会话(sessionId),弱持久 协同光标、开关联动、播放进度
分布式 KVStore 结构化键值记录的同步与落盘 @kit.ArkData 有持久化,按 key 操作 草稿正文、用户偏好、业务记录
跨设备启动 在另一台设备上拉起 Ability @kit.AbilityKit 单向指令 "在平板上打开本文档"
应用接续 换设备后无缝接着用 @kit.AbilityKit(onContinue) 需声明 continuable 手机 → 平板继续编辑
分布式文件 大文件/图片/附件跨设备读写 @kit.CoreFileKit 大对象,不走 KV 附件、导出包、原图

术语:分布式软总线 ── 系统在多设备间建立的统一通信底座,向上提供发现、组网、传输能力。应用不直接使用软总线 API,只使用建在它之上的设备管理、数据对象、KVStore 等高层接口。

7.1.2 硬性前置条件(先满足,再写代码)

缺一条,代码写得再对也跑不通。调分布式第一步不是看日志,是对着这张表打勾。

条件 要求 如何验证
真机 至少两台物理设备。模拟器不提供分布式软总线能力,跨设备同步在模拟器上不可用 hdc list targets
同华为账号 两台设备登录同一账号(数据同步的鉴权基础) 设置 → 账号中心
已组网 / 同局域网 同 WLAN 网段,或经超级终端完成组网 设置 → 超级终端
无线通道开启 WLAN 与蓝牙均开启(软总线的发现与传输通道) 快捷开关
已授权 首次调用弹授权确认,用户拒绝则持续失败直到手动重开 设置 → 应用 → 权限
动态权限 ohos.permission.DISTRIBUTED_DATASYNC(user_grant),必须运行时申请 见 7.1.3

版本门槛:ohos.permission.DISTRIBUTED_DATASYNC 为 user_grant 权限。只在 module.json5 静态声明不够,必须用 abilityAccessCtrl 运行时申请;两者缺一,调用时返回授权错误。

7.1.3 权限:静态声明 + 动态申请

// entry/src/main/module.json5
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.DISTRIBUTED_DATASYNC",
        "reason": "$string:reason_distributed_sync",
        "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
      }
    ]
  }
}
// lib_distributed/src/main/ets/permission/DistributedPermission.ets
import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const SYNC_PERMISSIONS: Permissions[] = ['ohos.permission.DISTRIBUTED_DATASYNC'];

/** 幂等:已授权时不会弹窗,直接返回成功 */
export async function ensureSyncPermission(context: common.UIAbilityContext): Promise<boolean> {
    try {
        const atManager = abilityAccessCtrl.createAtManager();
        const result = await atManager.requestPermissionsFromUser(context, SYNC_PERMISSIONS);
        const granted: boolean = result.authResults.every((code: number) => code === 0);
        if (!granted) {
            hilog.warn(0x0001, 'DistPerm', 'user denied distributed sync');
        }
        return granted;
    } catch (err) {
        hilog.error(0x0001, 'DistPerm', 'request failed: %{public}s', JSON.stringify(err));
        return false;
    }
}

reason 必须指向字符串资源($string:),文案要说清用途——"用于在多设备间同步你的草稿内容"能过审,"需要权限"会被驳回。


7.2 设备管理

7.2.1 设计思路

  1. createDeviceManager 是应用级单例。重复创建会拿到多个句柄,回调被重复注册。
  2. 回调必须 off。on('deviceStateChange') 在进程内是全局的,只注册不注销会导致页面销毁后仍在回调里操作已释放对象。
  3. release() 必须成对,顺序为先 off 再 release。
  4. getAvailableDeviceListSync 是同步 IPC,不要放进高频刷新路径或由每个页面各自调用。应用级维护缓存、变化时广播,页面只订阅。

术语:deviceId ── 设备唯一标识,跨设备启动与 KVStore 指定设备同步都用它。networkId 是网络层标识,两者不是一回事,API 返回哪个就用哪个,不要互相强转。

7.2.2 关键代码:设备管理器封装

// lib_distributed/src/main/ets/device/DeviceHub.ets
import { distributedDeviceManager } from '@kit.DistributedServiceKit';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

export type DeviceListener = (devices: distributedDeviceManager.DeviceBasicInfo[]) => void;

export class DeviceHub {
    private static instance: DeviceHub | undefined = undefined;
    private dm: distributedDeviceManager.DeviceManager | undefined = undefined;
    private listeners: Set<DeviceListener> = new Set();   // Set 天然去重,避免重复注册
    private cache: distributedDeviceManager.DeviceBasicInfo[] = [];

    static getInstance(): DeviceHub {
        if (DeviceHub.instance === undefined) { DeviceHub.instance = new DeviceHub(); }
        return DeviceHub.instance;
    }

    /** 在 EntryAbility.onCreate 调用一次,内部幂等 */
    init(context: common.UIAbilityContext): void {
        if (this.dm !== undefined) { return; }
        const bundleName: string = context.applicationInfo.bundleName;   // 包名;name 是应用显示名,别用错(字段以所用 SDK 的 ApplicationInfo 为准)
        this.dm = distributedDeviceManager.createDeviceManager(bundleName);
        this.dm.on('deviceStateChange', (data: distributedDeviceManager.DeviceStateChange) => {
            hilog.info(0x0001, 'DeviceHub', 'state change: %{public}d', data.action);
            this.refresh();
        });
        this.refresh();
    }

    getOnlineDevices(): distributedDeviceManager.DeviceBasicInfo[] { return this.cache; }

    addListener(listener: DeviceListener): void {
        this.listeners.add(listener);
        listener(this.cache);   // 立即回灌当前值,页面无需自己再拉一次
    }

    removeListener(listener: DeviceListener): void { this.listeners.delete(listener); }

    /** 在 EntryAbility.onDestroy 调用 */
    destroy(): void {
        if (this.dm !== undefined) {
            this.dm.off('deviceStateChange');   // 顺序不能反
            this.dm.release();
            this.dm = undefined;
        }
        this.listeners.clear();
        this.cache = [];
    }

    private refresh(): void {
        if (this.dm === undefined) { return; }
        try {
            this.cache = this.dm.getAvailableDeviceListSync();
        } catch (err) {
            hilog.error(0x0001, 'DeviceHub', 'list failed: %{public}s', JSON.stringify(err));
            this.cache = [];
        }
        this.listeners.forEach((listener: DeviceListener) => listener(this.cache));
    }
}

页面侧只订阅、不持句柄(V2):

@ComponentV2
export struct CollabDeviceSheet {
    @Local devices: distributedDeviceManager.DeviceBasicInfo[] = [];
    private hub: DeviceHub = DeviceHub.getInstance();
    // 用字段保存箭头函数,保证 add/remove 拿到的是同一个函数对象
    private onDevices: DeviceListener = (devices: distributedDeviceManager.DeviceBasicInfo[]): void => {
        this.devices = devices;
    };

    aboutToAppear(): void { this.hub.addListener(this.onDevices); }
    aboutToDisappear(): void { this.hub.removeListener(this.onDevices); }   // 只摘自己,不销毁句柄

    build() {
        List() {
            ForEach(this.devices,
                (d: distributedDeviceManager.DeviceBasicInfo) => { ListItem() { Text(d.deviceName).fontSize(16) } },
                (d: distributedDeviceManager.DeviceBasicInfo) => d.deviceId)
        }
    }
}

版本门槛:getAvailableDeviceListSync 为同步接口。若在兼容下限 20 的设备上遇到"未就绪返回空列表",改用异步的 getAvailableDeviceList 并在 then 中刷新缓存,其余逻辑不变。


7.3 选型:DataObject 还是 KVStore

7.3.1 对比表

维度 DataObject KVStore
抽象层级 内存对象,属性变更即同步 结构化键值记录
会话概念 有:sessionId,同值设备共享同一对象 无会话,按设备列表同步
同步粒度 属性级(改 title 只推 title) 条目级(按 key 或整库)
数据规模 轻量,建议单对象 KB 级 条数多、单条可较大
持久化 默认内存态,进程结束即失 有,落本地库
离线行为 会话内设备离线则不同步 可先写本地,待上线后 sync
同步控制 弱(不能挑设备) 强(SyncMode + 指定设备)
心智负担 低(像用普通对象) 中(要管 sync 时机)

7.3.2 决策依据

按序问三个问题,第一个"是"即答案:

  1. 需要落盘、冷启动后还在吗? → KVStore。
  2. 它是一份"共享状态"而不是"一批记录"吗?(开关、进度、光标位置)→ DataObject。
  3. 需要指定同步给谁吗? → KVStore(sync(deviceIds, mode))。

第 11 章用组合模式:DataObject 管"看的",KVStore 管"存的"——前者负责 UI 实时联动,后者负责节流落盘与冷启动回灌。

术语:sessionId ── DataObject 的会话标识,参与同一 sessionId 的设备共享同一份对象数据。它不是服务端"房间号",必须由业务自己生成并保证期望协同的设备算出同一个值(常用"业务前缀 + 主键",如 draft-${docId})。

7.3.3 关键代码

// lib_distributed/src/main/ets/session/DraftDataObject.ets
// DataObject:属性级联动
import { distributedDataObject } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';

interface DraftState {
    title: string;
    caret: number;
}

export class DraftDataObject {
    private obj: distributedDataObject.DataObject | undefined = undefined;

    start(docId: string, initial: DraftState): void {
        // 官方惯用式:create 时带初始对象;此后对已有属性的每次赋值都会触发增量同步。
        // 同步的属性集在 create 时确定,事后新增属性不会自动纳入同步(以官方 API 为准)
        this.obj = distributedDataObject.create({
            sessionId: `draft-${docId}`,
            title: initial.title,
            caret: initial.caret
        });
        // change 只告知"哪些字段变了",不给新旧值;需要值就自己读 obj 上的属性
        this.obj.on('change', (sessionId: string, fields: Array<string>) => {
            hilog.info(0x0001, 'DraftDO', 'fields: %{public}s', fields.join(','));
        });
        this.obj.on('status', (sessionId: string, networkId: string, status: string) => {
            hilog.info(0x0001, 'DraftDO', 'peer %{public}s -> %{public}s', networkId, status);
        });
    }

    /** 局部更新:只给需要变的属性赋值,未触到的属性不产生同步流量 */
    patch(fields: Partial<DraftState>): void {
        const obj = this.obj;
        if (obj === undefined) { return; }
        if (fields.title !== undefined) { obj.title = fields.title; }
        if (fields.caret !== undefined) { obj.caret = fields.caret; }
    }

    stop(): void {
        this.obj?.off('change');
        this.obj?.off('status');
        this.obj = undefined;
    }
}
// lib_distributed/src/main/ets/kv/DraftKvStore.ets
// KVStore:结构化记录 + 可控同步
import { distributedKVStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

export class DraftKvStore {
    private manager: distributedKVStore.KVManager | undefined = undefined;
    private kv: distributedKVStore.SingleKVStore | undefined = undefined;

    async open(context: common.Context): Promise<void> {
        this.manager = distributedKVStore.createKVManager({
            context: context, bundleName: 'com.example.officeflow'
        });
        this.kv = await this.manager.getKVStore<distributedKVStore.SingleKVStore>('officeflow_draft', {
            createIfMissing: true, encrypt: false, backup: false,
            // DEVICE_COLLABORATION:多设备类型,支持多设备自动合并;
            // SINGLE_VERSION:各端独立副本、以最新写入为准,同样支持 sync
            kvStoreType: distributedKVStore.KVStoreType.DEVICE_COLLABORATION,
            securityLevel: distributedKVStore.SecurityLevel.S1
        });
    }

    /** 真实签名:put(key: string, value: Uint8Array | string | number | boolean): Promise<void> */
    async put(key: string, value: string | number | boolean | Uint8Array): Promise<void> {
        await this.kv?.put(key, value);
    }

    /**
     * 手动同步。真实签名:sync(deviceIdList: string[], mode: SyncMode): Promise<void>
     * deviceIdList 必填;对全部在线设备同步就传入全部在线设备的 id 列表
     * ("空数组=全部在线设备"的语义以官方文档为准)。
     */
    async sync(deviceIds: string[], mode: distributedKVStore.SyncMode): Promise<void> {
        await this.kv?.sync(deviceIds, mode);
    }

    async enableAutoSync(): Promise<void> { await this.kv?.setAutoSync(true); }

    /** 会话结束归还句柄(由 SyncSession.stop 调用,见 7.6.2) */
    async close(): Promise<void> {
        const manager = this.manager;
        const kv = this.kv;
        if (manager === undefined || kv === undefined) { return; }
        await manager.closeKVStore('officeflow_draft', kv);
        this.kv = undefined;
    }
}

何时用 autoSync,何时手动 sync:

场景 选择 理由
少量关键字段变更后要立刻可见 setAutoSync(true) 省心,变更即推
高频写(逐字编辑、滚动位置) 手动 sync + 防抖 autoSync 会产生同步风暴
新设备加入 / 重连 sync(allOnlineDevices, PULL_ONLY) 拉全量对齐基线(传入当前全部在线设备)
单向广播 sync(deviceIds, PUSH_ONLY) 控制方向,避免回环
精细控制参与方 sync(deviceIds, PUSH_PULL) 指定设备

sync 的 deviceIdList 是必填的 string[];"传空数组是否等价于全部在线设备"以官方文档为准,工程中显式传入在线设备列表。


7.4 同步粒度与策略

7.4.1 状态与重资源分离

跨设备传输的带宽、时延、稳定性都远低于本地内存。一条铁律:

能在 1 KB 内表达的东西走数据通道(DataObject / KVStore),超过 1 KB 的走文件通道,数据通道只传 URI。

内容 通道 传什么 不传什么
文本字段 DataObject / KVStore 字符串本身 —
图片、附件、导出包 分布式文件系统 URI + size + digest base64、字节数组
大列表 KVStore,按条目 key 单条记录 整个列表对象

7.4.2 按条目 / 按块同步

把"一整个列表"当同步单位,是分布式性能问题的头号来源。

  • 按条目:一条记录一个 key(draft:${docId}),变更只写变更的条目。
  • 按块:长正文切块(如每 4 KB),key 形如 doc:${id}:chunk:${n},只同步变更的块。
  • 元数据单独一条:doc:${id}:meta 记录总块数、版本号、摘要,供对端校验完整性。

术语:同步风暴 ── 因粒度过粗或未节流产生的大量跨设备传输。识别特征:日志中同一会话的传输次数与用户操作次数呈非线性放大。

7.4.3 每条记录都要带版本信息

export interface SyncEnvelope<T> {
    id: string;              // 业务主键
    version: number;         // 单调递增的本地版本号
    updatedAt: number;       // 业务时间戳,用于跨设备比较
    originDevice: string;    // 产生方,便于回溯冲突来源
    payload: T;
}

version 与 updatedAt 不能互相替代:version 是单设备内单调递增,用于判断"是否落后于自己上次写的";updatedAt 是跨设备时间戳,用于决定谁更新,但设备间时钟可能不同步,只能兜底。


7.5 冲突与一致性

7.5.1 平台只负责通知,合并是业务责任

分布式数据对象与 KVStore 提供的是最终一致的数据通道,不提供业务语义的冲突解决。两台设备同时改同一字段,平台能保证"双方最终看到同一个值",但不保证这个值是你想要的那个。

7.5.2 最后写入胜出(LWW)的适用边界

LWW 成立的前提:① 并发写同一字段概率极低;② 字段互相独立,丢一个不影响另一个;③ 业务可容忍丢失。

不成立时的后果:

场景 LWW 后果
两人同时编辑同一段正文 后到者整段覆盖,前者输入彻底消失且无感知
同一字段被脚本与人工同时改 时间戳更晚者获胜,另一方无感知
A 设备时钟快 5 分钟 A 的历史写都"看起来更新",永远获胜

结论:整对象 LWW 只能用于"单写者"或"字段天然独立"的数据(主题色、字号、开关)。协同编辑必须逐字段/逐段落合并,或退化为占用锁。

7.5.3 带版本号的合并函数

策略:逐字段合并 + 字段级时钟,把冲突面积缩到最小。

// lib_distributed/src/main/ets/merge/DraftMerge.ets
export interface DraftRecord {
    id: string;
    version: number;                        // 整体版本,任何字段变更 +1
    updatedAt: number;
    fieldClock: Record<string, number>;     // 逐字段最后修改时间,冲突判定用它
    fields: Record<string, string>;
}

export interface MergeResult {
    record: DraftRecord;
    conflicts: string[];                    // 无法自动裁决、必须交用户决策的字段
}

export function mergeDraft(local: DraftRecord, remote: DraftRecord): MergeResult {
    if (local.id !== remote.id) {
        throw new Error(`mergeDraft: id mismatch ${local.id} vs ${remote.id}`);
    }
    if (local.version === remote.version && local.updatedAt === remote.updatedAt) {
        return { record: local, conflicts: [] };   // 快速通道:无变更
    }
    const merged: DraftRecord = {
        id: local.id,
        version: Math.max(local.version, remote.version) + 1,
        updatedAt: Math.max(local.updatedAt, remote.updatedAt),
        fieldClock: {}, fields: {}
    };
    const conflicts: string[] = [];
    const keys: Set<string> = new Set([...Object.keys(local.fields), ...Object.keys(remote.fields)]);

    keys.forEach((key: string) => {
        const lc: number = local.fieldClock[key] ?? -1;
        const rc: number = remote.fieldClock[key] ?? -1;
        const hasLocal: boolean = local.fields[key] !== undefined;
        const hasRemote: boolean = remote.fields[key] !== undefined;

        if (!hasLocal || !hasRemote) {                      // 单边存在,直接采纳
            merged.fields[key] = hasLocal ? local.fields[key] : remote.fields[key];
            merged.fieldClock[key] = hasLocal ? lc : rc;
            return;
        }
        if (local.fields[key] === remote.fields[key]) {      // 内容相同,取较大时钟
            merged.fields[key] = local.fields[key];
            merged.fieldClock[key] = Math.max(lc, rc);
            return;
        }
        if (lc === rc) {                                     // 时钟相同 => 无法证明顺序 => 报冲突
            merged.fields[key] = local.fields[key];
            merged.fieldClock[key] = lc;
            conflicts.push(key);
            return;
        }
        const useRemote: boolean = rc > lc;                  // 唯一的"自动丢弃"分支,风险明确
        merged.fields[key] = useRemote ? remote.fields[key] : local.fields[key];
        merged.fieldClock[key] = useRemote ? rc : lc;
    });

    return { record: merged, conflicts: conflicts };
}

设计取舍:

  • 逐字段而非整对象:把"整段正文被覆盖"降级为"某一段被覆盖"。
  • 冲突上报而非静默丢弃:conflicts 非空时 UI 必须提示并保留双方内容。
  • 时钟相等即判冲突:宁可多报不可漏报——时钟相同意味着我们无法证明顺序,"猜一个"就是数据丢失。
  • 合并结果必须写回并广播,否则其他设备仍拿旧版本继续产生冲突。

配套的一致性保障:定期全量对齐(如每 60s 或重连时)修正漂移;关键写操作以服务端为准;冲突事件埋点上报,才能量化"LWW 到底丢了多少数据"。


7.6 注册时机与生命周期

7.6.1 原则:订阅是应用级的

页面级注册(aboutToAppear)有三个问题:页面销毁即断订阅,其他设备的变更静默丢失;多页面同时订阅同一数据会重复处理;页面重建会重复注册,回调次数叠加。

正确位置:EntryAbility.onCreate(进程级)或 AbilityStage.onCreate(模块级,适合模块内多 Ability 共享)。

7.6.2 关键代码:应用级会话 + 在 EntryAbility 挂载

// lib_distributed/src/main/ets/session/SyncSession.ets
import { common } from '@kit.AbilityKit';
import { AppStorageV2 } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { DeviceHub } from '../device/DeviceHub';
import { DraftKvStore } from '../kv/DraftKvStore';
import { ensureSyncPermission } from '../permission/DistributedPermission';

@ObservedV2
export class SyncFlag {
    @Trace distributedEnabled: boolean = false;
}

export class SyncSession {
    private static instance: SyncSession | undefined = undefined;
    private started: boolean = false;
    readonly devices: DeviceHub = DeviceHub.getInstance();
    readonly store: DraftKvStore = new DraftKvStore();

    static getInstance(): SyncSession {
        if (SyncSession.instance === undefined) { SyncSession.instance = new SyncSession(); }
        return SyncSession.instance;
    }

    /** 注意:调用方不要 await,避免阻塞首帧 */
    async start(context: common.UIAbilityContext): Promise<void> {
        if (this.started) { return; }
        const granted: boolean = await ensureSyncPermission(context);
        const flag = AppStorageV2.connect(SyncFlag, () => new SyncFlag());
        if (!granted) {
            if (flag !== undefined) { flag.distributedEnabled = false; }   // 降级为纯本地模式
            return;
        }
        this.devices.init(context);
        await this.store.open(context);
        this.started = true;
        if (flag !== undefined) { flag.distributedEnabled = true; }
    }

    stop(): void {
        if (!this.started) { return; }
        this.devices.destroy();
        // 会话级收尾:KVStore 句柄也要归还(closeKVStore),避免进程内句柄泄漏
        this.store.close().catch((err: Object) => {
            hilog.error(0x0001, 'SyncSession', 'close kv store failed: %{public}s', JSON.stringify(err));
        });
        this.started = false;
    }
}
// entry/src/main/ets/entryability/EntryAbility.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { SyncSession } from 'lib_distributed';

export default class EntryAbility extends UIAbility {
    onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
        // 应用级注册;不 await,UI 通过 SyncFlag.distributedEnabled 显示"协同连接中"
        SyncSession.getInstance().start(this.context);
    }

    onDestroy(): void { SyncSession.getInstance().stop(); }

    onWindowStageCreate(windowStage: window.WindowStage): void {
        windowStage.loadContent('pages/Index');
    }
}

为什么不能 await start():里面包含权限弹窗(首次必然等用户点击)与异步 KVStore 打开,await 后首帧被拖到秒级。正确做法是 loadContent 先行,UI 订阅 SyncFlag 显示中间态——这就是第 8 章 8.3「启动优化」(8.3.2 懒加载与延迟初始化)在分布式场景的落地。


7.7 跨设备启动与应用接续

7.7.1 跨设备启动

在 want 上带 deviceId,即可把 Ability 拉到指定设备:

import { common, Want } from '@kit.AbilityKit';

export async function openDocOnDevice(
    context: common.UIAbilityContext, deviceId: string, docId: string
): Promise<void> {
    const want: Want = {
        deviceId: deviceId,
        bundleName: 'com.example.officeflow',
        abilityName: 'EntryAbility',
        parameters: { 'docId': docId } as Record<string, Object>
    };
    await context.startAbility(want);
}

要点:目标设备必须已安装该应用、登录同账号、已授权;只传参数不传数据(want.parameters 有大小限制且序列化成本高),目标设备拿 docId 后自行从数据通道取内容;失败必须给明确提示与重试入口。

7.7.2 应用接续

接续的本质是把"用户当前在哪"传过去,不是搬内存镜像。

// module.json5 中声明可接续
{ "module": { "abilities": [
    { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets",
      "continuable": true }
] } }
// 源设备:只放恢复现场所需的最小信息
onContinue(wantParam: Record<string, Object>): AbilityConstant.OnContinueResult {
    wantParam['docId'] = this.currentDocId;
    wantParam['caretOffset'] = this.caretOffset;
    wantParam['scrollY'] = this.scrollY;
    return AbilityConstant.OnContinueResult.AGREE;
}

// 目标设备:识别接续启动并恢复
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    if (launchParam.launchReason === AbilityConstant.LaunchReason.CONTINUATION) {
        this.restoreDraft(want.parameters?.['docId'] as string,
                          want.parameters?.['caretOffset'] as number);
    }
}
该传 不该传
业务主键(docId) 整个列表数据
光标 / 滚动位置 图片二进制
播放进度、选中项 Token、密码等敏感信息

版本门槛:接续依赖账号、组网与目标设备上应用版本一致。跨版本接续(源 26.0.0、目标 20)会话建立可能失败,务必实现"接续失败则本地正常启动"的降级分支。


7.8 分布式文件与重资源通道

7.8.1 何时必须走文件通道

判断项 走数据通道 必须走文件通道
体积 < 1 KB > 100 KB
类型 文本、布尔、数值 图片、音视频、导出文档
需要随机读写 否 是
可重复生成 是 否(原始素材)

1 KB ~ 100 KB 之间的灰区判定规则(与 7.4.1 对齐):可拆的拆小走数据通道——长正文按 7.4.2 切块、按条目建 key;不可拆的整体走文件通道——图片、附件等二进制不要试图塞进 KV。判据是内容类型,不是"尽量塞":KVStore 单条 MB 级上限只代表"能存",不代表"该传"。

数据通道只传引用:

export interface AttachmentRef {
    uri: string;          // 分布式文件 URI,对端据此读取
    size: number;         // 字节数,供对端预估传输与检查空间
    digest: string;       // 内容摘要,用于校验完整性
    displayName: string;  // 展示与另存用
}

export interface DraftWithAttachment {
    docId: string;
    title: string;
    attachments: AttachmentRef[];   // 重资源只存引用
    version: number;
    updatedAt: number;
}

7.8.2 重资源的五步闭环

步骤 做什么 缺失时的表现
1. 归档 附件写入分布式文件系统,拿到 URI 下游全断
2. 登记 把 AttachmentRef 写进 DataObject / KVStore 对端不知道有附件
3. 传输 文件内容跨设备同步(可异步、可断点) 对端拿到 URI 但读不到内容
4. 校验 用 digest 与 size 校验 图片半张、文档损坏
5. 落地 对端存入本地沙箱并记录映射 重启后失效

术语:引用先到、内容后到 ── 数据通道(小、快)先到,文件通道(大、慢)后到。因此 UI 必须处理"URI 已存在但内容未就绪"的中间态:显示占位符 + 进度,而不是直接报错。

设计要点:以 digest 判重实现幂等重试;大文件限制并发,否则挤占软总线带宽导致协作消息延迟;列表先显示缩略图,详情页再拉原图(按需升级)。

7.8.3 把分布式收在一个模块里

第 11 章把这套能力全收在 lib_distributed(HAR),对外只暴露三个门面:

// lib_distributed/Index.ets —— 唯一导出面(详见 9.3)
export { DeviceHub } from './src/main/ets/device/DeviceHub';
export type { DeviceListener } from './src/main/ets/device/DeviceHub';
export { SyncSession, SyncFlag } from './src/main/ets/session/SyncSession';
export { mergeDraft } from './src/main/ets/merge/DraftMerge';
export type { DraftRecord, MergeResult } from './src/main/ets/merge/DraftMerge';
// SyncEnvelope(7.4.3)与 AttachmentRef(7.8.1)定义在 lib_distributed/src/main/ets/model/Types.ets
export type { SyncEnvelope, AttachmentRef } from './src/main/ets/model/Types';

为什么强绑成一个模块:分布式能力的复杂度不在单个 API,而在句柄生命周期、权限、降级分支、冲突处理四件事交织。分散到各业务模块,必然出现"有的模块 off 了有的没 off"。收口成模块,才能在 7.2 / 7.6 两处集中保证生命周期正确。


7.9 常见误区

误区 1:在模拟器上调试分布式能力

错误写法:模拟器 + 一台真机,期望在模拟器上看到设备发现与同步。 正确写法:两台真机,同华为账号,同 WLAN 或完成组网后再调试。 为什么错:模拟器不提供分布式软总线能力,getAvailableDeviceListSync() 必然返回空列表。继续排查代码是浪费时间——很多团队在这上面花过整整一天。

误区 2:只静态声明权限,不做动态申请

错误写法:只在 module.json5 写 "requestPermissions": [{ "name": "ohos.permission.DISTRIBUTED_DATASYNC" }]。 正确写法:静态声明 + requestPermissionsFromUser 运行时申请(7.1.3),并处理拒绝后的降级为纯本地模式。 为什么错:该权限是 user_grant,只声明不申请系统不会自动授权,调用同步接口时返回授权错误而非弹框;用户也永远没机会授权,表现为"功能静默失效"。

误区 3:在页面里注册同步订阅与设备监听

错误写法:aboutToAppear 里 DeviceHub.getInstance().addListener(...) 或 draftObj.on('change', ...)。 正确写法:应用级(EntryAbility.onCreate)注册会话与句柄,页面只 addListener / removeListener 订阅视图数据(7.6.2)。 为什么错:页面销毁即断订阅,其他设备的变更无人接收、静默丢失;用户切走再回来看到的是旧数据。这是分布式"数据丢失"投诉的最大来源,且极难复现。

误区 4:注册了 on('deviceStateChange') 却不 off、不 release

错误写法:只 on,没有 off 与 release。 正确写法:this.dm.off('deviceStateChange'); this.dm.release(); this.dm = undefined;。 为什么错:设备监听在进程内是全局的,只注册不注销会让回调次数随注册次数叠加、回调里访问已释放对象引发崩溃、系统侧句柄泄漏。顺序也重要:先 off 再 release,反了会在释放后仍收到一次回调。

误区 5:把大对象或图片 base64 塞进 DataObject / KVStore

错误写法:this.draftObj.coverBase64 = base64OfImage;(DataObject 上的一次属性赋值就是一次同步)。 正确写法:图片写入分布式文件,数据通道只传 AttachmentRef(URI + size + digest,7.8.1)。 为什么错:属性级同步会把整段 base64 当作一次字段变更推给所有设备。3 MB 的图 base64 后约 4 MB,一次同步就是 4 MB × N 台,弱网必然超时或 OOM,且无法流式与断点续传。

误区 6:用"最后写入胜出"实现协同编辑

错误写法:以整体 updatedAt 较大者覆盖另一个。 正确写法:逐字段合并 + 字段级时钟(7.5.3),无法裁决的字段进 conflicts 交用户确认;或把产品明确定义为"同一时刻只允许一台设备编辑"(占用锁)。 为什么错:整对象 LWW 会让一方输入彻底消失且无感知。用户看到刚写的一段被抹掉,是数据可信度问题,不是体验问题。且设备时钟不同步会让某台设备"永远获胜"。

误区 7:用 @ohos.* 分命名空间导入分布式能力

错误写法:import distributedKVStore from '@ohos.data.distributedKVStore'; 正确写法:import { distributedKVStore, distributedDataObject } from '@kit.ArkData';、import { distributedDeviceManager } from '@kit.DistributedServiceKit'; 为什么错:@ohos.* 是旧版分命名空间导入。与 Kit 导入混用会让代码风格不统一、可维护性变差,个别工具链配置下还存在同一模块被加载两份的风险(表现为 instanceof 判定失败、共享单例失效)。

误区 8:用 V1 装饰器承接同步数据

错误写法:@Observed class SyncModel + @State model: SyncModel。 正确写法:@ObservedV2 / @Trace,组件用 @ComponentV2 + @Local / @Param(7.2.2)。 为什么错:V1 的观测粒度依赖 @Track 与对象层级,跨设备写入常来自非 UI 线程路径,容易刷新丢失;V2 的 @Trace 属性级精确观测。本教程禁止新代码使用 V1 装饰器与 PersistentStorage(迁移见附录 A)。


7.10 实践任务

任务:跨设备草稿协同闭环

在 feature_collab(HAR)中实现一份文档草稿的跨设备协同:

  1. 设备列表:展示在线设备(名称 + 类型),实时响应上下线。
  2. 跨设备打开:选中设备后在其上打开同一份草稿(want.deviceId)。
  3. 状态同步:标题与光标位置走 DataObject;正文走 KVStore 手动同步(防抖 400ms)。
  4. 冲突处理:两设备同时改标题时按字段级时钟裁决,conflicts 非空时弹确认框并保留双方内容。
  5. 降级:无权限或组网失败时界面明确显示"协同不可用(原因)",本地功能完全可用。

约束:代码放 lib_distributed(HAR),feature_collab 只经 lib_distributed/Index.ets 导入;系统句柄只在 SyncSession 中创建与释放;页面用 @ComponentV2 + @Local / @Param,禁止 V1 装饰器与 PersistentStorage;数据模型必须含 version 与 updatedAt。

验收 checklist

  • [ ] 在两台真机(同华为账号、同 WLAN)完成全部功能验证,模拟器只用于 UI 走查
  • [ ] 首次进入弹出 DISTRIBUTED_DATASYNC 授权框;拒绝后应用不崩溃且显示降级提示
  • [ ] A 改标题,B 在 1 秒内可见,且 B 日志中 change 回调的 fields 只含 title
  • [ ] A 连续输入 50 字符,B 收到同步次数 ≤ 3(防抖 400ms 下的预期值;若调整防抖参数,此阈值须按"输入时长 ÷ 防抖间隔"重新核定后同步修改)
  • [ ] 同时改标题为不同内容,两侧都出现冲突确认入口,未被静默覆盖
  • [ ] 冷启动后草稿与版本号从 KVStore 恢复,版本号单调递增不回退
  • [ ] 退出协同页面但应用存活时,B 的修改仍被接收(证明订阅是应用级的)
  • [ ] 杀掉应用后日志可见 off 与 release 已执行,无残留句柄
  • [ ] 传一张 2 MB 图片作附件,数据通道只传 AttachmentRef,日志中无 base64
  • [ ] 一方断网重连后 60 秒内完成全量对齐,两侧版本一致
  • [ ] 10 分钟协同会话中,DevEco Profiler 观察内存无持续增长

交付物清单

交付物 形式 说明
lib_distributed 模块 HAR 源码 DeviceHub / SyncSession / DraftMerge / 权限封装
feature_collab 页面 HAR 源码 设备列表、协同面板、冲突确认框
协同时序图 图或 Mermaid 标注"引用先到、内容后到"的中间态
冲突策略说明 Markdown 一页 LWW 边界与 conflicts 的用户交互
双机验证记录 截图 + 日志片段 覆盖 checklist 每一项,标注型号与系统版本
同步次数统计 日志摘录 防抖前后同步次数对比

7.11 延伸阅读

  • 官方文档 → 应用开发 → 应用框架 → 分布式设备管理(createDeviceManager / on / off / release 与 DeviceBasicInfo 字段含义)
  • 官方文档 → 应用开发 → 应用框架 → 分布式数据对象(sessionId 语义、change / status 回调、数据大小约束)
  • 官方文档 → 应用开发 → 应用框架 → 分布式键值数据库(KVStoreType、SyncMode、autoSync 与 sync 的行为差异)
  • 官方文档 → 应用开发 → 应用框架 → 应用接续(continuable 声明、onContinue 生命周期、跨端参数约定)
  • 官方文档 → 应用开发 → 应用框架 → 分布式文件系统(跨设备文件 URI 与访问方式)
  • 官方文档 → 应用开发 → 应用框架 → 权限管理(user_grant 权限的动态申请流程与 authResults 语义)

上一章:第 6 章 数据持久化与网络请求 | 下一章:第 8 章 性能与内存优化

📄 第 11 章 多端适配与分布式协同 07-multi-device-collab.md

涉及模块:lib_ui(HSP,断点与设计 token)、lib_distributed(HAR)、feature_collab(HAR)、entry(HAP)。 前置章节:03 ArkUI 组件与布局、07 分布式能力、11/05 审批流业务闭环。

说明 本章涉及的分布式运行链路(DTDO / 设备管理 / 真机组网)未经本机编译与真机验证,文中签名与行为均以工程源码及文件内注释为准。

本章是全书"两个最容易自我感觉良好、实际最容易做错"的主题合体:

  • 多端适配:同一个 HAP 要跑在手机、平板、PC 上。看起来只是"宽了换两列",实际要处理的是断点来源唯一、px/vp 换算、窗口尺寸变化时的监听注册与注销;
  • 分布式协同:跨设备草稿同步。看起来只是"发一份数据过去",实际要处理的是DTDO 只支持扁平结构、自己发的不该回调给自己、冲突不能自动裁决、订阅必须挂在应用级。

最后一段专门讲 Mock 的边界 —— 因为分布式功能最大的风险不是"写不出来",而是"Mock 跑通了就以为完成了"。


7.0 本章目标

  1. 保证 全应用只有一个断点来源,并解释为什么每个页面各自 onAreaChange 算断点会出错;
  2. 正确 用 UIContext 做 px → vp 换算,并说清为什么不能用"设计稿比例"硬算;
  3. 写出 三层布局结构(token / 断点 / 页面),并说明哪些做法明确不采用、为什么;
  4. 接上 DeviceHub 的设备发现:先注册监听再拉列表、用 deviceId 去重、离线设备置灰保留;
  5. 实现 跨设备草稿同步:DTDO 只放扁平结构 + 映射器、过滤自身回环、offline 后重开;
  6. 写出 一个纯函数的冲突合并器,并解释为什么"版本与时间戳都相同但内容不同"不能自动裁决;
  7. 把订阅挂在应用级,分清 attach/detach(页面级)与 stop()(应用级);
  8. 说清 Mock 的边界:它能验证什么业务逻辑、不能验证什么系统能力。

7.1 断点:来源必须唯一

五档断点

// lib_ui/src/main/ets/layout/Breakpoints.ets(结构)
export enum Breakpoint {
    XS = 'xs',
    SM = 'sm',
    MD = 'md',
    LG = 'lg',
    XL = 'xl'
}

/** 断点值容器:给每条规则提供"按档取值"的能力 */
export interface BpValues<T> { xs: T; sm: T; md: T; lg: T; xl: T; }

export class Breakpoints {
    static readonly SM_MIN: number = 320;
    static readonly MD_MIN: number = 600;
    static readonly LG_MIN: number = 840;
    static readonly XL_MIN: number = 1280;

    /** 按宽度(vp)判档 */
    static ofWidth(vp: number): Breakpoint { /* ... */ }
    /** 是否达到某档及以上 */
    static atLeast(vp: number, bp: Breakpoint): boolean { /* ... */ }
    /** 档位序号,用于"至少是 lg"这类比较 */
    static rank(bp: Breakpoint): number { /* ... */ }
    /** px → vp(必须传 UIContext) */
    static vpOfPx(uiContext: UIContext, px: number): number { /* ... */ }
}

/** 判档 + 按档取值一步完成。values 必须给全 5 档,页面里最常用 */
export function bpOf<T>(widthVp: number, values: BpValues<T>): T { /* ... */ }
/** 只取档位名('xs' | 'sm' | ...),供日志与埋点用 */
export function bpName(widthVp: number): string { /* ... */ }

版本门槛 断点本身是纯计算,任何版本都可用。但 px → vp 换算依赖 UIContext 的像素密度接口,这是 API 11 起的推荐路径(替代早期通过 display 全局对象猜测密度)。本工程兼容下限 6.0.0(20) 满足。

说明 工程里只有 5 档。DevEco Studio 预览器提供的 8 档预览是 IDE 能力(预览面板的尺寸预设),不是运行时的断点枚举。写文档和写代码时不要把它们混为一谈 —— 这会导致"代码里找不到第 6 档"的困惑。

为什么断点必须"来源唯一"

看 entry 的 WindowState:

// entry/src/main/ets/state/WindowState.ets(结构)
export const KEY: string = 'windowState';

@ObservedV2
export class WindowState {
    @Trace widthBp: Breakpoint = /* 默认档 */;
    @Trace heightBp: Breakpoint = /* 默认档 */;
    @Trace windowWidthVp: number = 0;
    @Trace windowHeightVp: number = 0;

    static shared(): WindowState { /* AppStorageV2.connect(WindowState, KEY, ...) */ }

    /** 用 0.5vp 容差过滤抖动 */
    update(widthVp: number, heightVp: number): void { /* 变化不足 0.5vp 则不更新 */ }

    @Computed get breakpointTag(): string { /* 'sm' | 'md' | ... 供埋点公共属性用 */ }
    @Computed get ready(): boolean { /* 尺寸是否已就绪 */ }
}

然后在 EntryAbility 里注册窗口尺寸监听:

// entry/src/main/ets/entryability/EntryAbility.ets(结构)
onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index', (err) => {
        // 回调里再挂监听:此时 UI 上下文已就绪,能拿到 UIContext 做 px → vp
        this.attachWindowSizeListener(windowStage);
    });
}

private applyWindowSizePx(windowStage: window.WindowStage, px: number /* ... */): void {
    const uiContext: UIContext = /* windowStage 对应窗口的 UIContext */;
    // 必须用 UIContext 换算,不能自己按 3.0 之类的系数硬算
    const vp: number = Breakpoints.vpOfPx(uiContext, px);
    WindowState.shared().update(vp, /* ... */);
}

onWindowStageDestroy(): void {
    // 先 off 监听,再置空引用 —— 反了会留下"已失效的回调仍会被调用"
    this.offWindowSizeListener();
    this.windowStage = undefined;
}

为什么必须唯一:

  1. 口径一致:如果每个页面各自 onAreaChange 算断点,那么"列表页认为自己是 md、详情页认为是 lg"这种不一致只是时间问题 —— 症状是"某些页面的布局像是另一台设备";
  2. 可上报:断点要作为埋点的公共属性注入每条事件。公共属性只能在应用级算一次,页面级算不出来;
  3. 可测试:唯一的 update(vp, vp) 入口让"给定宽度 → 期望断点"成为可穷举的单测。

埋点里的断点:先默认值,后重注

AppInitializer 有一条容易被忽略的注释:

/**
 * 注意此处窗口尺寸还没就绪(onCreate 早于 onWindowStageCreate),
 * 断点档位先用默认值,拿到真实尺寸后由 refreshAnalyticsContextIfChanged 重注入。
 */

因为 Analytics 只在 init 时接收公共属性、没有单独的 setter,所以必须"拿到真实尺寸后重注一次"。代价是重注会重置一次 flush 计时器,因此用 injectedBpTag 做去重,只在档位真的变化时才重注。

不做这一步的后果:全量事件的 breakpoint 字段永远是默认档,看板上"大屏用户的行为"这一维度直接失效 —— 而看板上看不出这是"真的没有大屏用户"还是"埋点没注入"。

px → vp:为什么不能用固定系数

/** px → vp(必须传 UIContext) */
static vpOfPx(uiContext: UIContext, px: number): number { /* ... */ }

因为 px 与 vp 的比例由设备的像素密度决定,而这个值在不同设备上不同、在同一个设备上也可能因"显示大小"设置而变化。用"设计稿 360px / 1080px 屏 → 除以 3"这类硬编码系数:

  • 在像素密度不是 3 的设备上直接错;
  • 用户改了系统"显示大小"(字体/界面缩放)后错;
  • 折叠屏展开/折叠时错。

而 UIContext 提供的换算接口读的是当前这一帧生效的密度,是唯一正确的来源。

术语 vp(virtual pixel):与像素密度无关的逻辑长度单位。ArkUI 的所有长度参数默认按 vp 解释。px 是物理像素,只在绑定系统回调(如窗口尺寸变化、系统手势坐标)时出现。两者的边界就是转换发生的地方 —— 本工程把这条边界收在 Breakpoints.vpOfPx 一个函数里。

误区 用 display.getDefaultDisplaySync() 取全局密度自己除。多窗口/分屏场景下"当前窗口所在屏幕的密度"与"默认屏幕的密度"可能不同,而且这个 API 在新版本上不鼓励使用。正确做法是从 UIContext 取。

0.5vp 容差:为什么需要

窗口尺寸变化回调在拖动窗口边缘(PC 分屏、平板自由窗口)时会以极高频率触发。每次触发都更新 @Trace 字段,意味着每次都会驱动所有依赖断点的组件重算。

0.5vp 的容差把"亚像素级抖动"过滤掉:

update(widthVp: number, heightVp: number): void {
    // 变化不足 0.5vp 则不更新(否则拖动窗口边缘会触发海量无意义刷新)
}

选 0.5 而不是 1:因为断点本身的档位间隔远大于 1vp(最小间隔 320),所以 0.5 与 1 在"是否跨档"上没有区别;但 0.5 能更快响应真实的尺寸变化(比如用户拖得很快,恰好落在整数边界附近时不会因为舍入而漏掉一次跨档)。


7.2 布局三层:token / 断点 / 页面

工程里的响应式布局严格分三层,每层只做一件事。

第一层:设计 token(lib_ui/theme/Theme.ets)

// lib_ui 导出:Theme, ThemeTokens, Spacing, Radius, FontSizes, Duration

这一层提供语义化的数值:Spacing.SM / Spacing.LG、Radius.MD、FontSizes.BODY、Duration.FAST。

页面里不写裸数字。 理由不是审美:

  • 裸数字散落在几十个文件里,改一次视觉规范要全仓搜;
  • "这里用 12 还是 16"的决策会被复制到每个页面,且每个人记的规则不同;
  • 深色主题、大字体模式下的差异无法集中处理。

第二层:断点(lib_ui/layout/Breakpoints.ets)

这一层提供"档位判断"与"按档取值"。页面通过 bpOf(widthVp, values) 一步完成判档与按档取值 —— 注意 values 必须给全 5 档(BpValues<T>),漏档会被类型系统直接拦下。

第三层:页面(布局决策)

页面只做决策,不发明数字:

// entry/src/main/ets/pages/MainPage.ets(节选)
/**
 * 工作台卡片列数:手机 1 列 / 平板 2 列 / 大屏 3 列。
 * 用 lib_ui 的 bpOf 而不是在页面里写 if-else 链:
 * 档位阈值只在 lib_ui 定义一次,这里只声明"每档取什么值"。
 */
private columns(): number {
    return bpOf<number>(this.windowState.windowWidthVp, { xs: 1, sm: 1, md: 2, lg: 2, xl: 3 });
}

/** 卡片宽度:按列数换算成百分比,配合 Flex 换行 */
private cardWidth(): string {
    const columns: number = this.columns();
    // 减去间距带来的误差:用 96% 而不是 100%,避免浮点误差把第 N 列挤到下一行
    return `${Math.floor(96 / columns)}%`;
}

feature_approval 的列表页用同一套机制决定"一屏几列":

// feature_approval/src/main/ets/view/ApprovalListPage.ets(节选)
// 栅格列数固定 12,断点差异交给 lanes / GridCol 的 span 承担,
// 不用"断点 → 列数"对象做 GridRow 的 columns(ApprovalListPage.ets:196)
GridRow({ columns: 12, gutter: 8 }) {
    // 每张审批卡片 GridCol 固定 span: 3
    // ...
}
// lanes(this.bp === Breakpoint.SM ? 1 : 2):窄屏单列、宽屏双列的流式排布(:342)

feature_message 用断点决定的是边距而不是列数:

// feature_message/src/main/ets/view/MessageListPage.ets(节选,:355-364)
/**
 * 平板/2in1 上留更宽的边距;窄屏用 16vp 保证内容区不被挤扁。
 *
 * 用 Breakpoints.atLeast 判定"至少 LG 档":断点阈值只在 lib_ui 定义一次,
 * 这里不写 840 这种魔数。不用 bpOf 是因为它要求给全 5 档取值,
 * 而本处只要一个布尔结论。
 */
private horizontalPadding(): number {
    return Breakpoints.atLeast(this.areaWidth, Breakpoint.LG) ? 24 : 16;
}

同一个断点,不同页面用在不同维度(列数 / 边距 / 字号),这正是"三层分离"的价值:页面各自决定"这一屏该怎么排",但"什么宽度算 md"由公共层决定。

与宿主尺寸的对接

页面级布局需要当前页面自身的可用宽度,而不是整个窗口的宽度(分屏、悬浮窗、Shell 内嵌时二者不同):

// feature_message/src/main/ets/view/MessageListPage.ets(节选)
@Local areaWidth: number = 360;

// build() 里:
.onAreaChange((_oldValue: Area, newValue: Area) => {
    this.areaWidth = Number(newValue.width);
})

这里有一个容易混淆的点:页面用 onAreaChange 拿自己的宽度,是为了布局("我这个容器有多宽");而 WindowState 用窗口回调拿的是为了全局口径(埋点公共属性、全局档位判断)。

两者不冲突,但用途必须分清:

来源 用途 是否驱动全局
WindowState(窗口回调) 埋点公共属性、全局档位 ✓
onAreaChange(页面自身) 页面内部布局决策 ✗

如果页面用 WindowState 的宽度做布局,在分屏场景下会用到错误的宽度(窗口宽度 ≠ 页面可用宽度);如果埋点用页面的 onAreaChange,则"同一时刻不同页面报的断点不同"。


7.3 明确不采用的做法(以及为什么)

这一节列出的每一条,都是"看起来能解决问题、实际会埋雷"的做法。

不采用一:用 product 拆分不同形态的包

// entry/src/main/module.json5(节选,原文注释)
// 多端形态:手机/平板/PC 同一个 HAP(形态差异用运行时断点判定,不用 product 拆分)
"deviceTypes": [
  "default",
  "tablet",
  "2in1"
],

为什么:拆 product 意味着多个产物、多套签名、多条发布通道、多个崩溃上报来源。而实际上 90% 的形态差异只是"同样的内容怎么排",用断点在运行时判定就够了。

只有以下情况才值得拆 product:功能集合不同(平板不支持某功能)、权限清单不同、依赖的 SDK 能力不同。本工程三者都不满足。

不采用二:用 @ohos.router 或按设备类型分支跳转

形态差异体现在布局上,不体现在路由上。路由表(route_map.json)是形态无关的 —— 8 条路由在手机、平板、PC 上完全一致。

如果出现"平板上详情页要显示成左右两栏",正确做法是同一个路由、页面内部按断点切换布局,而不是"平板上跳另一个路由"。后者会让深链(deeplink)的语义随设备变化,是一类极难维护的分叉。

不采用三:用 display 全局对象取屏幕信息做布局

见 7.1 的 px/vp 讨论。display 描述的是"屏幕",而布局需要的是"窗口/容器"。多窗口与分屏是可预期的常态。

不采用四:用 PersistentStorage 记住断点

断点是由当前窗口尺寸派生的瞬时值,不是用户偏好。把它持久化意味着"上次在平板上用的 lg 档,这次在手机上启动也用 lg" —— 布局直接错乱。

(本工程的 check-banned-api.sh 会把 PersistentStorage 直接判为违规,见 11/08。)

不采用五:在页面里写 if (deviceInfo.deviceType === 'tablet')

deviceInfo.deviceType 表达的是设备形态,不是"当前可用空间"。以下几种情况它都会给出错误的结论:

  • PC 上的窄窗口(设备是 PC,空间却像手机);
  • 平板分屏(半屏宽度);
  • 折叠屏折叠态(设备形态是折叠屏,展开/折叠的空间完全不同)。

唯一正确的判据是"当前有多少可用空间",也就是 vp 宽度。

误区 用设备类型代替断点。这是多端适配里最常见、也最难在开发机上发现的错误 —— 因为开发时通常全屏使用一台设备,deviceType 与断点恰好一致。


7.4 DeviceHub:设备发现

生命周期:应用级单例,只启动一次

/**
 * 分布式设备中心。
 *
 * ## 生命周期(最容易做错的一点)
 * 这是**应用级单例**,监听在**应用启动时注册一次**,不随页面销毁。
 * 反面做法是在页面 aboutToAppear 里 on / aboutToDisappear 里 off:
 *   - 设备上下线事件会在页面不可见期间丢失(回到页面时列表是过期的);
 *   - 页面重建时会重复注册监听,导致同一次上线收到 N 次回调。
 * 因此:`start()` 只应在 Ability.onCreate(或 Application 启动流程)里调用一次,
 * 由 `started` 标记做幂等;页面只读 `devices`,绝不调 start/stop。
 */
@ObservedV2
export class DeviceHub {
    /** UI 直接绑定这个数组。只读语义:外部不得修改它 */
    @Trace devices: DeviceInfo[] = [];
    @Trace state: DeviceHubState = DeviceHubState.IDLE;
    /** 失败原因(开发者可读),用于故障面板与日志 */
    @Trace lastError: string = '';

    /** 唯一性容器:deviceId → 设备。数组由它派生 */
    private dedup: Map<string, DeviceInfo> = new Map<string, DeviceInfo>();
    private manager: distributedDeviceManager.DeviceManager | undefined = undefined;
    private listener: Callback<distributedDeviceManager.DeviceStateChange> | undefined = undefined;
    private started: boolean = false;

    /** 全局唯一实例,通过 AppStorageV2 共享(与 SessionStore 同一套机制) */
    static shared(): DeviceHub { /* AppStorageV2.connect(DeviceHub, DEVICE_HUB_KEY, ...) */ }

    start(bundleName: string): void { /* ... */ }
    stop(): void { /* ... */ }
    refresh(): void { /* ... */ }
}

设备中心的状态机只有四个值,UI 不需要自己拼判断:

export enum DeviceHubState {
    /** 尚未启动(未安装监听) */
    IDLE = 'idle',
    /** 已就绪,列表可用 */
    READY = 'ready',
    /** 设备/系统不具备分布式设备管理能力 */
    UNSUPPORTED = 'unsupported',
    /** 启动失败(权限被拒、服务异常等) */
    FAILED = 'failed'
}

先注册监听,再拉列表

const manager: distributedDeviceManager.DeviceManager =
    distributedDeviceManager.createDeviceManager(bundleName);
this.manager = manager;
// 先注册监听再拉列表:反过来的话,两次调用之间发生的上下线事件会被永久丢失
// (列表能拿到结果,但后续事件没人接)。
this.listener = (data: distributedDeviceManager.DeviceStateChange) => {
    this.onDeviceStateChange(data);
};
manager.on('deviceStateChange', this.listener);
this.state = DeviceHubState.READY;
this.refresh();

这个顺序差一行就会造成"设备上线但列表不更新"的偶发 bug,而且只在设备恰好在两次调用之间上下线时才复现。

去重必须用 deviceId

/**
 * ## 去重
 * 系统回调可能对同一台设备多次上报(例如网络切换导致 networkId 变化)。
 * 去重的键必须是 `deviceId`,**不能用 networkId** —— 同一设备在不同网络下
 * networkId 会变,用它会得到"同一台设备出现多次"的经典 bug。
 * 这里用 Map 做唯一性容器,每次变更后**重建数组并整体赋值**,
 * 因为 V2 的 @Trace 对"整体赋值"的观测最可靠(对数组原地 push 的观测依赖版本行为)。
 */
private upsert(device: DeviceInfo): void {
    this.dedup.set(device.deviceId, device);
    this.publishFromMap();
}
/**
 * 从 Map 重建数组并整体赋值。
 * 先排序(在线优先、时间新的优先)再赋值,保证 UI 列表顺序稳定 ——
 * 每次刷新都换顺序会让用户觉得"列表在跳"。
 */
private publishFromMap(): void {
    const list: DeviceInfo[] = Array.from(this.dedup.values());
    list.sort((a: DeviceInfo, b: DeviceInfo) => {
        if (a.online !== b.online) {
            return a.online ? -1 : 1;
        }
        return b.lastSeenAt - a.lastSeenAt;
    });
    this.devices = list;
}

离线设备置灰保留,不删除

// 把"上次在线、这次不在列表里"的设备标记为离线,而不是直接删掉:
// 直接删会让用户看到设备"凭空消失",置灰才符合协作场景的预期。

这条取舍在协作场景里尤其重要:用户正在等另一台设备上线时,看到"设备还在,只是离线"比"设备不见了"要好得多 —— 后者会让用户怀疑"是不是我配对配错了"。

stop() 的三件事

/**
 * 停止并完整释放。
 *
 * 三件事缺一不可,只做 off 不做 release 会泄漏系统服务连接:
 *   1. off 撤销监听(否则释放后回调仍会进来,访问已失效的 manager);
 *   2. release 释放管理器;
 *   3. 清空本地状态,避免 UI 停留在过期列表上。
 * 一般只在退出登录 / 应用退出时调用。
 */

工程的 AppInitializer.shutdown()(由 onDestroy 调用)里做了这件事:

static shutdown(): void {
    Analytics.flush();
    try {
        DeviceHub.shared().stop();
    } catch (e) {
        XLogger.w(AppInitializer.TAG, () => `device hub stop failed: ${(e as Error).message}`);
    }
    RcpClient.reset();
    XLogger.must(AppInitializer.TAG, () => 'shutdown done');
}

注意 AppInitializer 的 runDeferredAfterFirstFrame() 里有一条重要注释:

/**
 * 注意这里**不启动分布式设备发现**:DeviceHub.start() 会拉起系统设备管理服务与事件监听,
 * 属于「协同」这条支线的成本,应该由协同页面按需启动(lib_distributed 自行负责),
 * 而不是让所有用户为不用到的功能付出启动开销。
 */

设备发现的成本要"按需付":拉起系统设备管理服务会建立通道与心跳。让所有用户(包括从不使用协同的人)在冷启动时就承担这份成本是不划算的。

能力门禁与降级

// 能力门禁:不支持时优雅降级,不抛异常
if (!CapabilityGate.isSupported(Capability.DISTRIBUTED_DEVICE_MANAGER)) {
    this.state = DeviceHubState.UNSUPPORTED;
    this.lastError = 'SystemCapability.DistributedHardware.DeviceManager not supported';
    this.devices = [];
    XLogger.w(this.tag, () => `${this.lastError}; distributed features disabled on this device`);
    return;
}

策略是"返回空列表 + 记 WARN + 状态置 UNSUPPORTED,绝不崩溃"。 模拟器与部分设备不具备分布式软总线能力,此时 createDeviceManager 可能抛异常。用户看到的是"当前设备不支持协同",而不是崩溃。

真机验收前提(必须留在代码里)

/**
 * ## 真机验收前提(这句话必须留在代码里)
 * 「模拟器上无分布式软总线能力」—— 要看到设备列表,必须满足:
 *   1. **真机**(至少两台,或一台真机 + 一台已组网设备);
 *   2. 两台设备登录**同一个华为账号**;
 *   3. 已**组网**(在"超级终端"/蓝牙+WiFi 同网段,或同一局域网内已配对);
 *   4. 已授予 `ohos.permission.DISTRIBUTED_DATASYNC`(见 Permission.ets)。
 * 缺任何一条,设备列表都会是空的 —— 这不是 bug,不要在这上面浪费时间排查代码。
 */

这张表值得抄下来贴在工位上。分布式功能 90% 的"排查时间"都花在了环境上 —— 而这四条的缺失全都表现为"列表空白",和代码 bug 的表象完全一样。

DeviceInfo:系统类型不越过适配层

/**
 * 设备信息模型(纯数据,不依赖任何 Kit)。
 *
 * 为什么不直接复用系统的 `distributedDeviceManager.DeviceBasicInfo`:
 *   1. 它是系统类型,字段随 SDK 演进;直接把它透传到 UI 与日志层,
 *      一次 SDK 升级就可能引发大范围的编译错误;
 *   2. 它**没有"在线状态"字段** —— 可用设备列表只包含当前在线的设备,
 *      而下线事件需要我们保留一条"已离线"的记录用于 UI 提示;
 *   3. 纯模型可以被单测直接构造,不需要模拟系统设备管理能力。
 */
export class DeviceInfo {
    readonly deviceId: string;
    readonly deviceName: string;
    readonly networkId: string;
    readonly deviceType: number;
    readonly deviceTypeName: string;
    readonly online: boolean;
    readonly lastSeenAt: number;

    static of(deviceId: string, deviceName: string, networkId: string, deviceType: number,
        deviceTypeName: string, online: boolean): DeviceInfo { /* 用具名工厂避免长参数写错顺序 */ }

    /** 展示名兜底:系统可能返回空串,直接把空串丢给 UI 会出现空白列表项 */
    get displayName(): string { /* deviceName || '未命名设备' */ }

    /** 状态变化(上线/下线)时保留原设备信息,只改 online 与时间 */
    withOnline(online: boolean): DeviceInfo { /* ... */ }

    /** 去重与日志用的稳定键。注意不能用 networkId */
    key(): string { return this.deviceId; }
}

系统的设备类型枚举要映射成本工程的文案:

/**
 * 设备类型 → 展示文案。
 *
 * ⚠️ 唯一适配点:这里引用了 `distributedDeviceManager.DeviceType` 的枚举成员。
 * 若你的 SDK 版本里某个成员名不同,**删掉对应的 case 即可** ——
 * 它只影响展示文案,不影响设备发现、去重与在线状态逻辑。
 * 刻意不用硬编码数字:枚举值的含义由系统定义,写死会在升级后悄悄错位。
 */
private static typeName(deviceType: number): string {
    if (deviceType === distributedDeviceManager.DeviceType.PHONE) { return DeviceTypeNames.PHONE; }
    if (deviceType === distributedDeviceManager.DeviceType.TABLET) { return DeviceTypeNames.TABLET; }
    if (deviceType === distributedDeviceManager.DeviceType.PC) { return DeviceTypeNames.PC; }
    return DeviceTypeNames.UNKNOWN;
}

动态权限:声明与申请缺一不可

/**
 * 为什么必须把「静态声明 + 运行期申请」成对处理:
 *   `ohos.permission.DISTRIBUTED_DATASYNC` 是 **user_grant**(用户授权)权限:
 *   - 只写进 module.json5 只能让应用"可申请",不申请就是被拒状态;
 *   - 只在代码里申请而没在 module.json5 声明,`requestPermissionsFromUser` 会直接失败(且原因不明显)。
 */
export class Permission {
    static readonly DISTRIBUTED_DATASYNC: Permissions = 'ohos.permission.DISTRIBUTED_DATASYNC';

    static install(context: common.UIAbilityContext): void { /* 保存 context + 读资源文案 */ }
    static isGranted(permission: Permissions): boolean { /* checkAccessTokenSync,不弹窗 */ }
    static ensure(permission: Permissions): Promise<Result<boolean>> { /* 已授权直接返回,否则申请 */ }
    static ensureDistributedDatasync(): Promise<Result<boolean>> { /* ... */ }
    static hasDistributedDatasync(): boolean { /* ... */ }
    static openAppPermissionSettings(): Promise<Result<void>> { /* 跳到本应用权限设置页 */ }
}

工程在 module.json5 里的声明与它必须逐字符一致。注意声明收敛在 entry 一处(entry/src/main/module.json5:72)—— lib_distributed / feature_collab 的 module.json5 里只有注释说明这一点,不再重复声明(权限清单以安装包入口 HAP 为准,多处声明只会让"到底在哪申请"变得含糊):

{
  /*
   * 跨设备协同:user_grant(敏感)权限,必须给 reason(资源引用)与 usedScene。
   * reason 必须是 $string 资源而不是字面量 —— 系统要用它生成授权弹窗的多语言文案。
   * usedScene 声明「哪个 Ability、什么时候用」,越精确越容易过审。
   */
  "name": "ohos.permission.DISTRIBUTED_DATASYNC",
  "reason": "$string:entry_perm_datasync_reason",
  "usedScene": {
    "abilities": ["EntryAbility"],
    "when": "inuse"
  }
}

两个细节:

细节一:查询权限用 checkAccessTokenSync,不要用 requestPermissionsFromUser。 后者在已授权时也会走一遍系统流程,且无法在"只想判断"的场景使用。tokenId 必须从自身 bundleInfo 取,写死或猜都是错的。

细节二:拒绝后必须能引导到设置页。

/**
 * 为什么必须有这一步:用户点"拒绝"之后,系统**不会再弹**申请框,
 * 这时 `requestPermissionsFromUser` 直接返回拒绝,应用侧没有任何办法再次弹窗。
 * 唯一可行动路径就是把用户送到设置页 —— 否则就成了"功能永久不可用且无解释"。
 */

openAppPermissionSettings() 通过 startAbility 打开系统设置页,并用 settingsParamBundleName 参数直接定位到本应用详情页,而不是让用户自己找。


7.5 DraftSyncChannel:DTDO 的唯一适配点

为什么需要"两套结构 + 映射器"

分布式数据对象(DTDO)只支持基本类型与基本类型数组,嵌套对象与自定义类实例不被支持。把 version: DraftVersion 这样的嵌套对象直接塞进去,SDK 不会报错,但那个字段不会被同步 —— 对端收到的永远是默认值。而业务上的草稿天然是"一份对象 + 版本元信息 + 附件列表":

// lib_distributed/src/main/ets/sync/DraftPayload.ets(结构)
/** 业务模型:嵌套结构(可读、可判等) */
export class DraftPayload {
    readonly draftId: string;
    readonly text: string;
    readonly version: DraftVersion;   // ← 嵌套对象,DTDO 同步不了
    readonly attachments: string[];   // ← 基本类型数组,DTDO 支持;只放路径/key,禁止 base64

    static of(draftId, text, version, attachments): DraftPayload { /* ... */ }
    static empty(draftId, editorId): DraftPayload { /* ... */ }
    editedBy(editorId: string, text: string): DraftPayload { /* ... */ }
    withVersion(version: DraftVersion): DraftPayload { /* ... */ }
    static toSide(payload: DraftPayload): DraftSide { /* 转成合并器认识的"版本 + 指纹" */ }
    static contentHash(text: string, attachments: string[]): string { /* 正文与附件各取指纹再拼接 */ }
    copy(): DraftPayload { /* ... */ }
    describe(): string { /* 不含正文的排障摘要 */ }
    private static fingerprintOf(input: string): string { /* FNV-1a */ }
}

/** 同步记录:**扁平字段契约**,version 三件套被拍平成三个标量 */
export interface DraftSyncRecord {
    draftId: string;
    text: string;
    version: number;      // ← DraftVersion.version 拍平
    updatedAt: number;    // ← DraftVersion.updatedAt 拍平
    editorId: string;     // ← DraftVersion.editorId 拍平
    attachments: string[]; // ← 基本类型数组可直接进 DTDO;字段名一旦发布就是跨设备契约
}

/** 映射器:业务模型 ⇄ 同步记录。还原时缺失字段用默认值兜底(对端可能是旧版本客户端) */
export class DraftRecordMapper {
    static toRecord(payload: DraftPayload): DraftSyncRecord { /* ... */ }
    static fromRecord(record: DraftSyncRecord): DraftPayload { /* ... */ }
}

这是"系统类型不越过适配层"在数据侧的同一条纪律:DTDO 的限制(扁平)被收口在 DraftSyncRecord + DraftRecordMapper 里,业务侧只认识 DraftPayload。

如果直接把业务对象所有字段平铺进 DTDO,未来加一个"附件列表"就要改 DTDO 结构,而 DTDO 的结构变化意味着两台设备的版本不兼容 —— 旧版本设备读到新结构会字段缺失,合并逻辑会误判为冲突。

术语 DTDO(Distributed Data Object):分布式数据对象。系统提供的跨设备内存/持久化数据结构,字段变更会同步到同一分布式组网内的其他设备。它对字段类型有限制(基础类型、扁平),也是本工程必须写映射器的根本原因。

通道:单一适配点

/**
 * DraftSyncSource —— DTDO 唯一适配点。
 * 所有对分布式数据对象 API 的调用都收口在这里,其余代码只认识 DraftSyncChannel 的语义接口。
 */
export class DraftSyncChannel {
    private ownerId: string;                         // 本机标识(用于过滤自身回环),构造时传入
    private context: common.Context | undefined = undefined;
    private sessionId: string = '';
    private opened: boolean = false;
    private dataObject: distributedDataObject.DataObject | undefined = undefined;
    private changeCallback: /* ... */ | undefined = undefined;
    private statusCallback: /* ... */ | undefined = undefined;
    private remoteListener: ((draft: DraftPayload) => void) | undefined = undefined;
    /** 回调注册前收到的远端变更:单槽位,缓冲最后一次,等 onRemoteChange 时补投 */
    private pendingRemote: DraftPayload | undefined = undefined;
    /** 最后一次发布的版本,用于过滤"自己发出去又绕回来"的变更 */
    private lastPublishedVersion: number = 0;
    private receivedCount: number = 0;

    constructor(ownerId: string) { /* ... */ }

    /** 注入上下文(创建 DTDO 需要)。与 Permission/TokenStore 一致:显式安装,不做静态隐式持有 */
    install(context: common.Context): void { /* ... */ }

    get isOpen(): boolean { /* ... */ }
    get currentSession(): string { /* ... */ }
    get remoteChangeCount(): number { /* 已收到的远端变更次数,UI 可用于调试面板 */ }

    /**
     * 重复 open 同一个 sessionId 是幂等的;换 sessionId 会先 close 再重建
     * (DTDO 的 sessionId 不可变,换会话只能重建对象)。
     * 流程:Mock 短路 → 能力门禁 → 权限(open 阶段是唯一能弹系统框的时机)→ 建 DTDO + 挂两个监听。
     */
    async open(sessionId: string): Promise<Result<void>> { /* ... */ }
    /**
     * 同步关闭:setSessionId('') 退组 + off 监听 + 清状态。
     * 退组是标准做法 —— 只 off 不退组,对端仍会认为"本设备还在会话里"。
     */
    close(): void { /* ... */ }
    /**
     * 同步发布:DTDO 的写入是内存操作,save() 返回布尔即"是否成功交给同步框架"。
     * 包成 Promise 只会让调用方以为在等网络,从而错误地阻塞 UI。
     */
    publish(draft: DraftPayload): Result<void> { /* 写 DTDO(走映射器) */ }
    /** 注册远端变更回调(只保留一个),并把缓冲的最后一次变更补投给迟到者 */
    onRemoteChange(callback: (draft: DraftPayload) => void): void { /* ... */ }
    /** 只撤销回调、保留组网(页面销毁时用;连组网也要退用 close()) */
    stopObserve(): void { /* ... */ }
}

为什么所有 DTDO 调用都要收口:DTDO 的 API 签名在版本间有调整(genSessionId / on('change') / on('status') 的形态)。收口到一个文件后,SDK 升级时改动面可控;不收口就会散落到多个模块,升级时漏改一处就是一个静默失效。

过滤自身回环

private onRemoteChangeEvent(/* ... */): void {
    // 过滤自身回环:本设备发出的变更不应该再回调给自己。
    // DTDO 在部分版本上确实会回调本机,不过滤会造成"合并 → 广播 → 再合并"的无限循环。
    if (record.editorId === this.ownerId && record.version <= this.lastPublishedVersion) {
        return;
    }
    // ...
}

这是本章最隐蔽的一个坑。 DTDO 在部分版本上会把本机写入的变更也回调给本机。过滤条件需要两个:editorId 与 version。

  • 只用 editorId:会误杀"本机在另一台设备上编辑后同步回来的变更"(同一账号、同一个 editorId);
  • 只用 version:会误杀"对端编辑了但版本号恰好等于本机最后发布版本"的罕见情况。

两个条件同时成立才判定为回环。

offline 状态:对象已释放,必须重开

private onStatusEvent(/* ... */): void {
    // offline 不能当成"暂时不可用":它意味着分布式数据对象已经被系统释放,
    // 继续 publish 会写到已失效的对象上(静默失败)。正确做法是标记为需重开。
    // ...
}

这类"事件语义在不同系统版本上略有差异"的边界,收口在一个文件里之后,就变成了"改一个 switch"而不是"全仓排查"。

远端变更缓冲:单槽位覆盖式

/** 统一的"投递给业务"入口,负责缓冲与异常隔离(DraftSyncChannel.ets:394-410) */
private deliver(draft: DraftPayload): void {
    this.receivedCount += 1;
    const listener = this.remoteListener;
    if (listener === undefined) {
        // 回调还没注册:缓冲最后一次,等 onRemoteChange 时补投
        this.pendingRemote = draft;
        return;
    }
    try {
        listener(draft);
    } catch (e) {
        // 业务回调异常不能污染同步通道,否则后续变更全部收不到
    }
}

为什么需要缓冲:时序问题真实存在 —— open() 之后、UI 的 onRemoteChange 注册之前,对端的变更可能已经到达。丢掉它会表现为"对端改了但本地没反应,直到下一次改动才追上"。缓冲把它变成"延迟送达"。

为什么是单槽位而不是数组队列:协同语义里,业务关心的只有"对端最新改成什么样"。草稿是整份覆盖式同步(不是增量补丁),最后一条天然包含之前的全部信息;中间态依次重放只会让合并器多做几次无用功,结果仍然收敛到最后一条。用一个字段换掉一个数组加循环投递,复杂度花在刀刃上 —— onRemoteChange 注册时补投一次即可(DraftSyncChannel.ets:306-319)。


7.6 DraftMerge:纯函数的冲突合并

三条设计原则

/**
 * 草稿冲突合并。
 *
 * 设计原则(这三条决定了它能不能被放心地用在实时协同里):
 *
 * 1. **纯函数**:`merge()` 不读写任何全局状态、不发埋点、不打日志、不改入参。
 *    同样的入参永远得到同样的出参,因此可以脱离设备与网络做穷举单测。
 *    副作用(弹窗、上报 DRAFT_SYNC_CONFLICT)由**调用方**执行 ——
 *    合并算法一旦掺了副作用,就没法在"单元测试里跑一万组组合"了。
 *
 * 2. **只搬运版本元信息,不搬运正文**:入参用内容**指纹**而不是正文本身。
 *    理由:合并结果只关心"谁胜出",正文由调用方从自己的缓存里取。
 *    把正文塞进合并函数会让每次比较都复制一遍大字符串(草稿可能几十 KB),
 *    而合并又发生在收到远端变更的高频回调里 —— 这是自找的卡顿。
 *
 * 3. **冲突不自动解决**:两边的版本与时间戳完全相同、内容却不同,
 *    说明后端或时钟给出了无法判断的信息。此时**必须**交给用户选择,
 *    算法不能猜 —— 猜错的代价是用户的手写内容被静默覆盖,比弹窗烦人得多。
 */

结构:用类继承做判别

/** 版本元信息。三件套缺一不可:谁改的、什么版本、什么时候改的 */
export interface DraftVersion {
    /** 单调递增的版本号。<= 0 视为"从未同步过" */
    version: number;
    /** 最后修改时间(毫秒时间戳) */
    updatedAt: number;
    /** 最后修改者标识。冲突时用来告诉用户"是同事 A 改的" */
    editorId: string;
}

/** 参与合并的一侧:版本元信息 + 内容指纹 */
export interface DraftSide {
    version: DraftVersion;
    /** 内容指纹(由 fingerprint() 生成)。只比较,不搬运正文 */
    contentHash: string;
}

export enum MergeKind { MERGED = 'merged', CONFLICT = 'conflict', NO_CHANGE = 'no_change' }
export enum DraftWinner { LOCAL = 'local', REMOTE = 'remote', NONE = 'none' }

/**
 * 合并结局基类。
 * 与工程里的 Result 模型保持同一套哲学:**用类继承做判别,而不是 flag 组合**。
 * 这样 `outcome instanceof Conflict` 之后,编译器能保证 local/remote 一定存在。
 */
export abstract class MergeOutcome {
    readonly kind: MergeKind;
    abstract describe(): string;
}
export class Merged extends MergeOutcome { readonly winner: DraftWinner; readonly version: DraftVersion; readonly reason: string; get requiresApply(): boolean; }
export class Conflict extends MergeOutcome { readonly local: DraftSide; readonly remote: DraftSide; get summary(): string; }
export class NoChange extends MergeOutcome { readonly reason: string; }

为什么不用 { kind, winner?, conflicts? } 这样的 flag 组合:那样每次使用都要先判 kind,再断言可选字段存在。类继承让"Conflict 一定有两个 side""Merged 一定有 winner"成为类型系统保证的事实。

指纹:必须是业务契约,不能借用日志工具

/**
 * 冲突判定用的内容指纹。
 *
 * 为什么不用 lib_log 的 Masker.tokenFingerprint:
 * 那是**日志脱敏**工具,它的输出格式(当前是 fnv1a32:xxxx(len))会随排障需求演进。
 * 合并指纹是**业务契约**,它必须稳定 —— 两端算出的指纹不同就会被误判为冲突。
 * 契约不能依赖工具的展示格式,所以在这里独立实现(FNV-1a,32 位,够用且极快)。
 */
static fingerprint(text: string): string {
    let hash: number = 2166136261;
    for (let i = 0; i < text.length; i++) {
        hash ^= text.charCodeAt(i);
        hash = (hash * 16777619) >>> 0;
    }
    return hash.toString(16).padStart(8, '0');
}

这一段值得反复读。它示范了一个通用原则:业务契约不能依赖展示工具的格式。日志指纹的格式可以为了排障而改变(加长度、换算法);如果合并逻辑复用了它,那么日志工具的一次升级就会导致所有跨版本协同都判成冲突,而故障现象与日志工具毫无关联。

术语 FNV-1a:一种极快的非加密哈希算法。32 位输出在"比较两份草稿是否相同"这个用途上冲突率足够低,且计算成本可以忽略(O(n) 单遍、无分配)。

合并规则:顺序本身就是规格

/**
 * 合并。
 *
 * 规则顺序(顺序本身就是规格,从上到下短路):
 *   0. 内容指纹相同 → NoChange(连版本都不用管:内容一样就没有要同步的东西)
 *   1. 一侧版本非法(<=0,即"从未同步过的新草稿")→ 采用另一侧
 *   2. 版本高者胜
 *   3. 版本相同 → updatedAt 大者胜
 *   4. 两者都相同但内容不同 → Conflict(交给用户)
 */
static merge(local: DraftSide, remote: DraftSide): MergeOutcome {
    // 规则 0:先比内容。放在最前面能兜住"两侧版本不同但内容其实一样"的情况,
    // 避免为了一份相同的内容让用户看到一个毫无意义的冲突弹窗。
    if (local.contentHash === remote.contentHash) {
        return new NoChange('content identical');
    }

    // 规则 1:新草稿(version = 0)不应该战胜任何已有版本,
    // 否则"本地新建的空草稿"会覆盖掉远端的真实内容。
    const localValid: boolean = DraftMerge.isVersionValid(local.version);
    const remoteValid: boolean = DraftMerge.isVersionValid(remote.version);
    if (localValid !== remoteValid) {
        return remoteValid
            ? new Merged(DraftWinner.REMOTE, remote.version, 'local has no valid version')
            : new Merged(DraftWinner.LOCAL, local.version, 'remote has no valid version');
    }

    // 规则 2:版本高者胜
    if (remote.version.version > local.version.version) {
        return new Merged(DraftWinner.REMOTE, remote.version, 'remote version is newer');
    }
    if (local.version.version > remote.version.version) {
        return new Merged(DraftWinner.LOCAL, local.version, 'local version is newer');
    }

    // 规则 3:版本相同看时间戳。
    // 用严格不相等判断,是为了让"时间戳完全相同"落到规则 4,
    // 而不是被某个分支悄悄吞掉 —— 时间戳相同意味着判定依据已耗尽。
    if (remote.version.updatedAt > local.version.updatedAt) {
        return new Merged(DraftWinner.REMOTE, remote.version, 'same version, remote updated later');
    }
    if (local.version.updatedAt > remote.version.updatedAt) {
        return new Merged(DraftWinner.LOCAL, local.version, 'same version, local updated later');
    }

    // 规则 4:版本与时间戳都相同、内容却不同 —— 信息不足以判定,交给用户
    return new Conflict(local, remote);
}

规则 0 为什么要放最前面:它兜住"两侧版本不同但内容其实一样"的情况。如果不先比内容,用户会因为"版本 5 vs 版本 6、内容完全一样"而看到一个毫无意义的冲突弹窗 —— 而实际上没有任何需要用户决策的东西。

规则 1 为什么必要:version <= 0 表示"从未同步过的新草稿"。如果让新草稿参与"版本高者胜",那么本地新建的空草稿(version 0)会输给任何已有版本 —— 这是对的;但如果反过来实现成"版本低者胜"或用 Math.max 之类的直觉写法,本地新建的空草稿会覆盖远端的真实内容。这是一条破坏性的错误,必须在算法层挡住。

isVersionValid 的完整判据:

private static isVersionValid(version: DraftVersion): boolean {
    if (!Number.isFinite(version.version) || !Number.isFinite(version.updatedAt)) {
        return false;
    }
    if (version.version <= 0 || version.version > DraftMerge.MAX_VERSION) {
        return false;
    }
    // updatedAt <= 0 也视为脏数据(如后端漏字段被解析成 0)
    return version.updatedAt > 0;
}

MAX_VERSION = Number.MAX_SAFE_INTEGER:防止脏数据(如 2^53)参与比较时产生精度问题。这不是过度防御 —— 后端字段类型从 int 变成 long 或 string 时,"解析成一个大数"是常见的漂移形态。

说明 DraftMerge.merge() 接收的是 DraftSide(版本 + 指纹),不是整份草稿(版本 + 正文)。这个签名是刻意的:合并发生在收到远端变更的高频回调里,把几十 KB 的正文塞进去会让每次比较都复制一遍大字符串。


7.7 应用级订阅:DraftSyncService

观察者模式 + 立即回灌

// feature_collab/src/main/ets/service/DraftSyncService.ets(节选)
export type SyncObserver = (service: DraftSyncService) => void;

addObserver(observer: SyncObserver): void {
    if (this.observers.indexOf(observer) < 0) {
        this.observers.push(observer);
    }
    // 立即回灌一次当前状态:页面不必自己再读一遍,也避免"注册到第一次变更之间"的空窗
    observer(this);
}

removeObserver(observer: SyncObserver): void {
    const pos: number = this.observers.indexOf(observer);
    if (pos >= 0) {
        this.observers.splice(pos, 1);
    }
}

private notify(): void {
    for (let i = 0; i < this.observers.length; i++) {
        try {
            this.observers[i](this);
        } catch (e) {
            // 单个观察者抛异常不能影响其它观察者,更不能让服务进入半死状态
            XLogger.e(this.tag, () => `observer threw: ${(e as Error).message}`);
        }
    }
}

三个设计点:

  1. addObserver 立即回灌:避免"注册到第一次变更之间"的空窗。否则页面在 attach() 之后、第一次远端变更之前,看到的还是自己 @Local 的初始值(空);
  2. 观察者必须是箭头函数字段,不能是内联 lambda:
/**
 * 观察者必须是**字段**(箭头函数),不能是内联 lambda:
 * removeObserver 靠引用相等来摘除,内联 lambda 每次都是新对象,会摘不掉。
 */
private readonly observer: SyncObserver = (service: DraftSyncService): void => {
    this.syncState = service.syncState;
    this.lastSyncAt = service.lastSyncAt;
    this.pendingConflict = service.pendingConflict;
};

内联 lambda 每次都是新对象,removeObserver 摘不掉 → 观察者泄漏 + 幽灵回调(页面销毁后回调仍然执行,写入一个已销毁的 ViewModel);

  1. notify 里逐个体 try/catch:单个观察者抛异常不能让服务进入半死状态。

⚠️ 订阅必须注册在应用级

/**
 * ## ⚠️ 订阅必须注册在应用级,不能随页面销毁
 *
 * 这是第 7 章最重要的一条纪律,也是协同类功能最容易写错的地方。
 *
 * 错误做法:在页面的 aboutToAppear 里 startSync、aboutToDisappear 里 stop。
 * 后果有三个,且都是静默的:
 *   1. 页面一销毁订阅就断了 —— 此时对端推送的变更**永久丢失**(不是延迟,是丢),
 *      下次进入页面看到的还是旧内容,而"为什么没同步上"完全无从查起;
 *   2. 多个页面同时订阅同一份数据 → 同一次变更被处理多次,
 *      version 被无意义地推高,最后谁是最新反而说不清;
 *   3. 页面在返回栈里反复重建 → 重复注册,回调次数叠加(第一次 1 次、第二次 2 次……)。
 *
 * 正确做法:
 *   - **通道与订阅归本服务(应用级单例)所有**,生命周期与进程一致;
 *   - 页面只做 `addObserver` / `removeObserver`,**只摘自己**,绝不 stop 服务;
 *   - stop() 的调用点只有两个:EntryAbility.onDestroy,以及用户显式点击"停止同步"。
 *     (onDestroy 不保证一定被调用,所以 stop() 必须写成幂等的、可重复进入的。)
 */

页面侧对应的是:

// feature_collab/src/main/ets/view/CollabDevicePage.ets(节选)
aboutToAppear(): void {
    Analytics.pageView('collab_devices', RouteNames.COLLAB_DEVICES);
    this.vm.attach();
    this.enterAt = Date.now();
    this.vm.refreshDevices(this.hostContext());
}

aboutToDisappear(): void {
    // 只摘自己。这里若改成 DraftSyncService.getInstance().stop(),
    // 页面一离开协同就断了,对端后续的变更会永久丢失。
    this.vm.detach();
    Analytics.pageLeave('collab_devices', Date.now() - this.enterAt);
}

CollabDeviceViewModel 的注释把两者的区别说得很清楚:

/**
 * ## attach / detach 与 service.stop() 的区别(必须分清)
 *
 *   - attach / detach:**页面级**。页面走了就 detach,避免观察者泄漏。
 *   - service.stop():**应用级**。只在 EntryAbility.onDestroy 调用。
 *     页面销毁时**绝不**调它 —— 那会把订阅断掉,对端推送的变更静默丢失。
 */

ViewModel 是"镜像",不是"真相"

/**
 * ## 它是"镜像",不是"真相"
 *
 * 真相在 DraftSyncService(应用级单例)里。本 VM 的作用是把服务状态**镜像**成
 * 组件可直接观察的 @Trace 字段,并在页面销毁时**只摘自己**。
 *
 * 为什么需要镜像:V2 的 @Trace 不会跨对象自动传播。页面直接读服务当然也可以,
 * 但页面就会依赖服务的完整生命周期语义;镜像一层之后,页面只依赖
 * attach/detach 两个动作,误用成本最低。
 */

"V2 的 @Trace 不会跨对象自动传播" 是一句需要记住的话。DraftSyncService 上的 @Trace syncState 变化不会自动让页面里 @Local vm.syncState 变化 —— 必须有一个观察者把它搬过来。这就是 attach/detach + observer 存在的理由。

服务内部的状态与幂等

async startSync(draftId: string): Promise<Result<boolean>> {
    if (this.channel !== undefined) {
        if (this.draftId === draftId) {
            return new Success<boolean>(true);
        }
        return new Failure<boolean>(DraftSyncService.busy(this.draftId, draftId));
    }
    // ...
}

/**
 * 幂等规则:已在协同**同一份草稿**时直接返回成功(页面重建会重复调用);
 * 已在协同**另一份草稿**时返回失败,要求先 stop。
 * 不做"自动切换"是因为自动切换会在用户无感知的情况下丢掉上一份草稿的未提交改动。
 */

"不做自动切换"的取舍值得注意:看起来"智能"的自动切换实际上会静默丢弃上一份草稿的未提交改动。宁可让用户看到"已有草稿在协同中,请先停止同步再切换"。

冲突处理:不写本地

/**
 * 远端变更入口。**箭头函数字段**:注册与注销必须是同一个引用(见 startSync 的说明)。
 *
 * 合并策略(对应 lib_distributed 的**整篇草稿模型**,不是逐字段):
 *   - Conflict:进入 CONFLICT 状态,交给用户裁决。
 *     此刻**不写本地**,避免"用户还没选,本机版本已经被悄悄改掉"。
 *   - NoChange:内容与本地一致,什么都不做。
 *   - Merged:采纳胜出方,写回本地并**广播回去**。
 *     广播这一步最容易被省掉 —— 不广播,对端会一直拿着落败的版本继续产生冲突。
 */
private handleRemoteChange = (remote: DraftPayload): void => {
    const local: DraftPayload | undefined = this.local;
    if (local === undefined) { return; }
    // 通道按 sessionId 组网,组网内可能有多份草稿在跑:draftId 不一致的直接忽略
    if (remote.draftId !== local.draftId) { return; }

    let outcome: MergeOutcome;
    try {
        outcome = DraftMerge.merge(
            DraftMerge.side(local.version, local.text),
            DraftMerge.side(remote.version, remote.text));
    } catch (e) { return; }

    if (outcome instanceof Conflict) {
        this.pendingConflict = new PendingConflict(local.draftId, local, remote, outcome.summary);
        this.syncState = SyncState.CONFLICT;
        Analytics.track(EventNames.DRAFT_SYNC_CONFLICT, /* ... */);
        this.notify();
        return;   // ← 注意:这里直接返回,不改 this.local
    }
    if (outcome instanceof NoChange) { return; }

    const merged: Merged = outcome as Merged;
    // 胜出方是远端才搬内容;本地胜出时内容不动,但版本要跟合并结果对齐
    const next: DraftPayload = merged.winner === DraftWinner.REMOTE
        ? remote.withVersion(merged.version)
        : local.withVersion(merged.version);
    this.local = next;
    this.syncState = SyncState.SYNCED;
    this.lastSyncAt = Date.now();
    this.notify();
    this.commitAndBroadcast(next);
};

"此刻不写本地"是一条数据安全底线。 如果先写本地再弹窗,用户就会看到"我的改动怎么变了"—— 而在他做出选择之前,本机内容已经被改掉了。用户可能直接点"返回"离开,那份被覆盖的内容就永久丢了。

裁决冲突:两件事缺一不可

/**
 * 裁决冲突。
 *
 * 裁决后的草稿做了两件事,缺一不可(DraftSyncService.ets:319-341):
 *   1. **version 前进到 max(local, remote) + 1**:保证对端一定认为这是新变更;
 *   2. **整份草稿的 updatedAt 刷新为 now**:合并是整篇粒度,判据只有"版本 + 时间戳 + 内容指纹"。
 *      否则"版本相同、时钟也相同"这个组合会原样保留下来,对端下一次合并又判成冲突,
 *      用户永远在被同一个冲突打扰。
 */

第 2 点尤其容易被漏掉:只改 version 不改 updatedAt,对端会拿着"时钟相等"的旧记录再判一次冲突 —— 用户会陷入"同一个冲突反复弹窗"的循环。这里没有"字段时钟"可刷新 —— 整份草稿模型里根本没有逐字段的时钟,这也是它比逐字段模型简单的地方:裁决只需要把整份草稿的版本抬高一格。

停止:顺序不能反

/**
 * 停止协同并释放通道(DraftSyncService.ets:359-374)。
 *
 * 调用时机只有两个:EntryAbility.onDestroy,以及用户显式点击"停止同步"。
 * 写成幂等,是因为 onDestroy 既不保证被调用,也可能在异常退出路径上被调用多次。
 *
 * 顺序不能反:先 stopObserve 再 close。
 * 反过来的话,close 过程中通道还可能投递最后一个远端变更,回调进来时
 * this.channel 已被置空 / 会话已释放,合并就会落在一个"半关闭"的对象上。
 */
async stop(): Promise<void> {
    const channel: DraftSyncChannel | undefined = this.channel;
    if (channel === undefined) {
        return;
    }
    // 先摘监听,再放会话 —— 让"还在飞的远端回调"落在一个仍然合法的对象上
    channel.stopObserve();
    this.channel = undefined;
    // close() 是同步的:只做退组与资源释放,没有需要等待的异步收尾(通道注释里有取舍说明)
    channel.close();
    this.local = undefined;
    this.pendingConflict = undefined;
    this.draftId = '';
    this.syncState = SyncState.IDLE;
    this.notify();
}

注意这里没有 try/catch:close() 是同步方法、内部已经把"退组失败"降级成日志(通道里唯一可能抛异常的 setSessionId('') 自带兜底),服务层不需要再包一层 —— 给一个不抛异常的同步调用套 try/catch,只会让读者误以为这里存在需要恢复的失败路径。


7.8 Mock 边界:DistributedMock 能做什么、不能做什么

这一节的存在本身就是一条工程纪律。

/**
 * 分布式能力的本地模拟器。
 *
 * ## 它能做什么、不能做什么(这句话必须写在最前面)
 *
 * **能做**:在没有第二台设备、没有组网、甚至在模拟器上,验证**业务逻辑**——
 * 草稿发布 → 远端变更回调 → 冲突合并 → 弹窗选择 → 落盘 这条链路是否闭环,
 * 以及"状态字段与附件分离""自己发的不回调给自己"这类规则是否写对了。
 *
 * **不能做**:验证真实的分布式软总线能力。它不覆盖:
 *   - 设备发现/认证/组网是否成功(需要真机 + 同一华为账号 + 同一局域网或已组网);
 *   - 真实网络下的时序(乱序、乱延迟、丢包后重传);
 *   - DTDO 的数据量上限与字段类型限制(本模拟器不做这些校验,因此**用它跑通不代表真机能跑通**);
 *   - 权限被拒、设备被移除、账号不一致等系统级失败路径。
 * 因此上线前的验收必须回到真机双设备自测。把这句话放在代码里,
 * 是为了防止"Mock 跑通就以为功能完成了"这种最常见的误判。
 */

它能验证的四类业务逻辑

能力 对应规则
草稿发布 → 远端变更回调 → 合并 → 落盘 整条链路的闭环
自己发的不回调给自己 publish() 跳过 ownerId 相同的观察者
异步回调(不是同步) setTimeout(..., ECHO_DELAY_MS)
离线设备保留并置灰 removeDevice() 只 withOnline(false),不删除

异步回环是刻意的:

/**
 * 发布草稿并**异步**回环给其他观察者。
 *
 * 两个细节都是刻意的:
 *   1. 异步(setTimeout)而不是同步回调 —— 真实网络一定不是同步的。
 *      同步回调会让"在回调里改状态"的代码侥幸工作,到真机上因为时序不同而崩。
 *   2. 跳过 ownerId 相同的观察者 —— 真实 DTDO 中本设备不会收到自己的变更事件,
 *      若 Mock 把自己发的也回环回来,会掩盖"重复触发合并"这类问题。
 */

ECHO_DELAY_MS = 16:给一个极短但非零的延迟,用来暴露"重入"类 bug。同步回调会让"在回调里改状态"的代码侥幸工作(因为在同一个调用栈里,状态还没变),到真机上因为时序不同才崩。

为什么 seed 两台设备时都是离线态:

/**
 * 预置两台"假设备"。
 * 只预置离线态:**在线设备必须由业务主动 addDevice 才会出现**,
 * 这样"设备上线"这条路径同样被演练到,而不是一进来就有一个看似真实的设备列表。
 */

如果预置成在线,"设备上线"这条路径就永远不会在 Mock 下被走到。

开关由编译期常量决定

/**
 * ## 开关
 * 由 `EnvConfig.isMock()` 驱动:MOCK 环境走本模拟器,其他环境走真实 DTDO。
 * 不做成"运行时随机降级"—— 真假通道必须由编译期常量决定,
 * 否则线上出现"部分用户静默走了 Mock 通道"这种灾难级问题。
 */
static isActive(): boolean {
    return EnvConfig.isMock();
}

"部分用户静默走了 Mock 通道"是灾难级问题:它会让线上问题在本地完全复现不出来(因为本地走真通道),而现象又只出现在一小部分用户身上。

Mock 边界必须在 UI 上显式提示

/** Mock 模式提示。**必须显式提示**:否则用桩设备调通的流程会被当成"功能已完成" */
@Builder
MockBanner() {
    Text($r('app.string.collab_banner_mock'))
        // ...
}

页面顶部常驻两条提示:

@Builder
PrerequisiteBanner() {
    Row() {
        AppTag({ text: $r('app.string.collab_devices_title') })
        Text($r('app.string.collab_banner_prereq'))   // "真机 + 同账号 + 已组网"
            // ...
    }
}
/**
 * ## 页面上必须把"前置条件"讲清楚
 *
 * 分布式功能的调试成本几乎全在环境上:真机、同账号、已组网、蓝牙/WLAN 开着。
 * 少任何一条,代码全对也是零设备 —— 而现象是"列表空白",看起来和 bug 一模一样。
 * 因此页面顶部**常驻**一条前置条件提示,而不是藏在文档里。
 *
 * 模拟器上更要留意:**模拟器不提供分布式软总线**,零设备是必然结果而非故障。
 */

Permission 的错误文案也要"可行动"

/**
 * ## 为什么必须把「静态声明 + 运行期申请」成对处理
 * ...
 * 申请失败的出口要"可行动":仅告诉用户"权限不足"是没用的,
 * 必须说清楚**去哪里开**,并提供一键跳转到应用权限页的能力。
 * 用户第一次拒绝后系统不会再弹窗,此时唯一能做的就是把用户送到设置页。
 */

资源文案(lib_distributed/src/main/resources/base/element/string.json)写的是完整路径:"请前往 设置 > 应用和服务 > 应用管理 > OfficeFlow > 权限,开启后重试。"

协同页的三个状态分支

// feature_collab/src/main/ets/view/CollabDevicePage.ets(结构)
if (this.vm.loading) {
    AppLoading({ text: $r('app.string.collab_loading') })
} else if (!this.vm.permissionGranted) {
    // 未授权不是"错误",而是一个需要用户动作的状态
    this.PermissionGuide()
} else if (this.vm.state === UiState.EMPTY) {
    AppEmpty({ title: $r('app.string.collab_empty_devices'), hint: $r('app.string.collab_empty_hint') })
} else if (this.vm.state === UiState.FAILURE) {
    AppEmpty({ title: $r('app.string.collab_state_failed'), hint: this.vm.errorMessage })
}

"未授权"被单独成一个分支,不是 FAILURE。 它是一个"需要用户动作"的状态,UI 应该给"去开启"的按钮,而不是"重试"。

设备发现三步的顺序是刻意的

/**
 * 刷新设备列表。
 *
 * 三步顺序是刻意的:能力 → 权限 → 列表。
 * 反过来的话,在"能力不可用"的设备上会先弹一次权限申请(用户莫名其妙被要权限),
 * 再发现"这台设备根本不支持" —— 体验上很糟。
 */
async refreshDevices(context: common.UIAbilityContext): Promise<void> {
    this.errorMessage = '';
    this.supported = CapabilityGate.isSupported(Capability.DISTRIBUTED_DEVICE_MANAGER);
    if (!this.supported) {
        this.state = UiState.FAILURE;
        this.errorMessage = '当前设备不支持分布式设备管理';
        return;
    }
    this.permissionGranted = await DistributedPermission.ensure(context);
    if (!this.permissionGranted) {
        // 未授权不是"失败",而是一个需要用户动作的状态:页面据此展示引导弹窗
        this.state = UiState.FAILURE;
        this.errorMessage = '';
        return;
    }
    // ...拉设备列表
}

冲突是异步到达的,用 @Monitor 驱动弹窗

/**
 * 冲突是**异步到达**的(对端随时可能推来变更),不能用点击事件驱动弹窗。
 * 用 @Monitor 监听 @ObservedV2 对象的属性路径,是 V2 里做"状态 → UI 副作用"的正规写法。
 */
@Monitor('vm.pendingConflict')
onConflictArrived(_monitor: IMonitor): void {
    if (this.vm.pendingConflict !== undefined) {
        this.conflictDialogVisible = true;
    }
}

冲突弹窗的内容必须给出合并器的事实摘要、两侧版本号与各自的裁决后果(整篇草稿粒度没有"冲突字段"可列):

// feature_collab/src/main/ets/view/CollabDevicePage.ets(节选,:370-379)
private conflictSummary(): string {
    const conflict = this.vm.pendingConflict;
    if (conflict === undefined) {
        return '';
    }
    // 必须写清"两边分别是什么版本",只说"有冲突"等于把决策成本推给用户却又不给依据。
    // lib_distributed 的合并是**整篇草稿粒度**,没有"冲突字段名"可列,
    // 因此这里用合并器给出的事实摘要(summary,含两侧版本与编辑者)+ 两个版本号 + 两个选项各自的后果。
    return `${conflict.summary}\n` +
        `本机版本 v${conflict.localVersion},对端版本 v${conflict.remoteVersion}。`;
}

PendingConflict(DraftSyncService.ets:67-88)持有的就是"两份完整草稿 + 一段摘要":local / remote 是 DraftPayload 全文(用户要看到"两边分别是什么"才能判断,只给 reason 等于让人盲选),summary 来自 Conflict.summary,localVersion / remoteVersion 是便捷 getter —— 没有 fields / fieldCount 这样的逐字段属性,那是逐字段模型的残留概念。

"取消"不做任何决定:

onCancel: (): void => {
    // "取消"不做任何决定,只收起弹窗,下次再问。
    // 把取消映射成某个裁决,会让用户在一次误触中永久丢掉本机改动。
    this.conflictDialogVisible = false;
}

设备列表用 ForEach 而不是 LazyForEach

// 设备通常只有两三台,用 ForEach 而不是 LazyForEach:
// LazyForEach 的复用收益在小列表上为零,却要多维护一个 IDataSource。
ForEach(this.vm.devices, (device: CollabDevice) => {
    this.DeviceRow(device)
}, (device: CollabDevice) => device.deviceId)

LazyForEach 不是"更好的 ForEach"。 它的价值是长列表的复用(避免同时构建上千个组件)。在 2~3 项的列表上,它只会多出一个 IDataSource 的维护成本。

协同页的 CollabDevice 是本地视图模型

/**
 * 协同页自己用的设备视图模型。
 *
 * 为什么不直接把 DeviceInfo 交给 UI:
 *   1. DeviceInfo 是 lib_distributed 的契约,字段随 SDK/业务变化;UI 应该面对稳定形状;
 *   2. 页面需要"是否被选中"这类本地状态,它不属于设备本体。
 */
@ObservedV2
export class CollabDevice {
    @Trace deviceId: string;
    @Trace name: string;
    @Trace online: boolean;

    /** 在线状态决定了能不能对它发起协同 —— 离线设备只展示、不可选 */
    get selectable(): boolean {
        return this.deviceId.length > 0 && this.online;
    }
}

并且只有一个函数读取 DeviceInfo 的字段:

/** 唯一读取 DeviceInfo 字段的地方(见文件头的假设契约说明) */
private static mapDevice(info: DeviceInfo): CollabDevice {
    return new CollabDevice(info.deviceId, info.deviceName, info.online);
}

这是"把外部契约收口到一处"的实际收益:lib_distributed 把 online 改名成 isAvailable,只需要改这一个函数。

契约对齐记录(原"已知不一致")

feature_collab 与 lib_distributed 曾是并行开发的,feature_collab 的文件头显式声明了自己依赖的"假设契约"。早期这些假设与 lib_distributed 的实际实现存在六处偏差,现已在同一分支上合流对齐,逐条留档:

位置 曾经的偏差 现状
CollabDeviceViewModel 假设 DeviceHub.prewarm() / release() 已对齐:DeviceHub.start(bundleName) / stop() / refresh()(prewarm / release 已从代码与注释中移除)
CollabDeviceViewModel 假设 DistributedPermission.ensure(context) 已对齐:类名 Permission,服务内走 ensureDistributedDatasync()(通道 open() 内部自带权限申请,ViewModel 不再重复申请)
DraftSyncService 假设 open(draftId),通道形参是 open(sessionId) 仍是现状:服务刻意"一份草稿 = 一次协作会话",sessionId 直接传 draftId(DraftSyncService.ets:200-201/:245)。形参名与实参语义的这点错位是约定而非缺陷;若将来"多份草稿共用一条会话",再单独引入 sessionId
DraftSyncService 假设 publish(record: DraftRecord) 已对齐:publish(draft: DraftPayload),映射在通道内走 DraftRecordMapper
DraftSyncService 假设 merge() 返回 { record, conflicts } 已对齐:DraftMerge.merge(local: DraftSide, remote: DraftSide): MergeOutcome(Merged / Conflict / NoChange 三个类,instanceof 判别)
DraftSyncService 假设 lib_distributed 导出 DraftRecord 类型 已对齐:服务直接使用 DraftPayload,不再自定义本地 DraftRecord

feature_collab 文件头的"假设契约"注释仍然保留(DraftSyncService.ets:11-16),它现在的角色不再是"防偏差的免责声明",而是通道依赖的最小接口说明 —— 契约已对齐,注释描述的就是实际签名。这一条仍列入 11/08 的交付前检查清单:后续任何一侧改动签名,先改契约注释再改实现。


7.9 常见误区

误区 1 每个页面自己 onAreaChange 算断点。 会出现"列表页认为自己是 md、详情页认为是 lg"的不一致,且断点无法作为埋点公共属性。全局断点来源必须唯一(WindowState)。

误区 2 用固定系数做 px → vp。 像素密度因设备而异、因"显示大小"设置而异、折叠屏展开/折叠时也会变。必须用 UIContext 取当前生效的密度。

误区 3 用 deviceInfo.deviceType === 'tablet' 判断布局。 PC 的窄窗口、平板分屏、折叠屏折叠态都会给出错误结论。唯一正确的判据是当前可用空间的 vp 宽度。

误区 4 页面用窗口宽度做内部布局 / 埋点用页面宽度。 分屏场景下两者不同。页面布局用自身 onAreaChange,全局口径用 WindowState。

误区 5 用 PersistentStorage 记住断点。 断点是瞬时派生值,不是用户偏好。持久化会导致"上次在平板上用的 lg,这次在手机上启动也用 lg"。

误区 6 在页面 aboutToAppear 里 DeviceHub.start()。 页面重建会重复注册监听(同一次上线收到 N 次回调);页面不可见期间的上下线事件丢失。

误区 7 DeviceHub.stop() 只做 off 不做 release。 会持续泄漏系统服务连接与心跳。三件事缺一不可:off / release / 清状态。

误区 8 设备去重用 networkId。 同一设备在不同网络下 networkId 会变,结果是"同一台设备出现多次"。必须用 deviceId。

误区 9 先拉设备列表,再注册监听。 两次调用之间发生的上下线事件会被永久丢失(列表拿到了结果,但后续事件没人接)。

误区 10 把 DTDO 当成能存任意结构。 它只支持扁平的基础类型字段。必须"业务模型 + 扁平同步记录 + 映射器"三件套。

误区 11 不过滤自身回环。 DTDO 在部分版本上会把本机变更也回调给本机,造成"合并 → 广播 → 再合并"的无限循环。过滤条件需要 editorId 与 version 两个同时成立。

误区 12 把 offline 状态当成"暂时不可用"。 它意味着分布式数据对象已被系统释放,继续 publish 会静默失败。正确做法是标记为需重开。

误区 13 在收到远端变更时立刻写本地。 若存在冲突,用户还没裁决,本机内容已经被悄悄改掉 —— 用户选择"返回"就永久丢了那份内容。

误区 14 裁决冲突后只改 version,不把整份草稿的 updatedAt 抬高到 now。 对端会拿着"时钟相等"的旧记录再判一次冲突,用户陷入"同一个冲突反复弹窗"。

误区 15 在页面的 aboutToDisappear 里 DraftSyncService.stop()。 页面一离开协同就断了,对端后续的变更永久丢失。页面只能 detach() 摘自己。

误区 16 观察者用内联 lambda。 removeObserver 靠引用相等摘除,内联 lambda 每次都是新对象 → 摘不掉 → 观察者泄漏 + 幽灵回调。

误区 17 stop() 里先 close() 再 stopObserve()。 close() 过程中通道可能投递最后一个远端变更,回调进来时对象已半关闭。必须先摘监听。

误区 18 Mock 跑通就当功能完成。 DistributedMock 不覆盖设备发现/组网、真实网络时序、DTDO 的数据量与类型限制、权限被拒等系统级路径。上线前必须真机双设备验收。

误区 19 把设备列表的空白当成 bug 去排查代码。 真机 + 同一账号 + 已组网 + 已授权,缺任何一条都是零设备。先查环境再查代码。

误区 20 协同页用 LazyForEach 渲染 2~3 台设备。 复用收益为零,却要多维护一个 IDataSource。小列表用 ForEach。


7.10 实践任务

任务 A:验证断点来源唯一(必做)

  1. 在 PC(或平板)上打开应用,把窗口从窄拖到宽再拖回窄;
  2. 观察日志中 WindowState.update() 的调用频率与断点变化;
  3. 同时打开消息列表与审批列表,确认两者的断点在同一时刻一致。

验收 checklist

  • [ ] 拖动窗口时不会出现"海量更新"(0.5vp 容差生效);
  • [ ] 跨档时日志里能看到档位变化(widthBp 有变化,且只有一个来源);
  • [ ] 埋点事件里的 breakpoint 字段与当前档位一致(不是永远默认档)。

任务 B:验证 px → vp 换算(必做)

  1. 在系统设置里把"显示大小"改成"大";
  2. 重启应用,记录首屏的断点与埋点里的 breakpoint 字段;
  3. 改回"标准",再对比一次。

验收 checklist

  • [ ] 两次的断点都正确(不是同一个值);
  • [ ] 如果自己实现了一份"除以 3"的换算,能说出在哪些设备/设置下它会错;
  • [ ] 工程里搜索不到任何硬编码的像素密度系数。

任务 C:真机双设备跑通协同(必做,需要两台设备)

按下列顺序,每一步记录现象:

  1. 两台真机、同一个华为账号、已组网、蓝牙/WLAN 开启;
  2. 在协同页确认设备列表非空;
  3. 若为空,逐条核对四要素,并记录"是环境问题还是代码问题";
  4. 选一台在线设备 → 点"发起协同";
  5. 观察日志:open(draftId) → publish → 对端 onRemoteChange;
  6. 在两端先后编辑草稿并发布,观察"版本高者胜"的自动合并(版本号在日志里递增,无弹窗);
  7. 冲突弹窗在真机上难以自然复现 —— 两端各自取 Date.now(),几乎不可能凑出"版本与时间戳都相等"; 要演练裁决,可临时把两端的版本与 updatedAt 改成同一对常量(做法见任务 E),裁决后确认不会反复弹同一个冲突。

验收 checklist

  • [ ] 设备列表能出现另一台设备(不是 Mock 桩设备);
  • [ ] 对端能收到变更(日志里有远端变更回调);
  • [ ] 两端先后编辑时自动合并、版本号单调递增(没有为"谁新谁旧"弹窗);
  • [ ] 冲突弹窗(若已按第 7 步构造)给出两侧版本号与编辑者,没有"冲突字段列表";
  • [ ] 裁决后同一冲突不再重复弹出(验证整份草稿的 version / updatedAt 已被抬高一格);
  • [ ] 点"取消"后本机内容没有被修改。

任务 D:验证"页面销毁不丢同步"(必做)

  1. 发起协同;
  2. 从协同页返回到上一页(页面销毁);
  3. 在对端修改草稿并发布;
  4. 再次进入协同页,检查是否收到了第 3 步的变更。

验收 checklist

  • [ ] 再次进入时状态是 SYNCED 且 lastSyncAt 更新过(说明变更被收到);
  • [ ] 观察者数量没有增长(用 addObserver/removeObserver 的日志核对);
  • [ ] 如果把 aboutToDisappear 改成 stop(),能复现"变更永久丢失"(这是验证这条纪律的最好方式)。

任务 E:Mock 模式下验证冲突闭环(必做)

合并是整篇草稿粒度:内容不同 → 版本高者胜 → 时间戳大者胜 → 两者都相同才判冲突。所以"冲突"只有一种形态:版本相同、时间戳相同、正文不同 —— 不存在"某几个字段冲突、其余字段自动合并"的中间态。

CollabMockRules(CollabMockRules.ets:20-33/:44-52)据此构造了必然冲突的 fixture:本地桩草稿(seedLocalDraft,:204-214)与 /collab/draft/pull 的对端草稿(:120-141)严格共享同一对常量,只有正文不同:

export class CollabMockRules {
    /** 本机草稿的版本基线 */
    private static readonly LOCAL_VERSION: number = 6;
    /**
     * 本地桩草稿与对端 fixture 共用的"最后修改时间"。
     * 必须是一个常量:冲突的判定依据正是"版本与时间戳都相等、内容却不同",
     * 两边各自取一次 Date.now() 就永远凑不出这个条件。
     */
    private static readonly SHARED_UPDATED_AT: number = 1735689600000;
    // seedLocalDraft() 与 /collab/draft/pull 的 handler 都引用上面这对常量:
    // 本机正文"营收增长 9%……"(editorId: local-phone-01),
    // 对端正文"营收增长 12%……"(editorId: peer-pad-01)。
}

改常量就能开关冲突,这比"改字段时钟"直觉得多:任何一侧的 LOCAL_VERSION 或 SHARED_UPDATED_AT 变了,合并器立刻能分出胜负,冲突随之消失。

验收 checklist

  • [ ] Mock 模式下(无需第二台设备)能触发冲突弹窗;
  • [ ] 弹窗内容是事实摘要 + 两侧版本号与编辑者(本机 v6 / 对端 v6,local-phone-01 与 peer-pad-01),没有"冲突字段列表";
  • [ ] 把 fixture 的 version 改成比 6 大的值 → 对端版本高者胜、自动合并、不再弹窗(验证"版本高者胜"分支);
  • [ ] 把 SHARED_UPDATED_AT 任意一侧改掉(版本保持相等)→ 时间戳大者胜,冲突同样消失(验证"时钟大者胜"分支);
  • [ ] 把两侧常量改回完全相等,冲突必然复现(验证 fixture 与合并规则一致,且每次改动两侧常量都要一起改)。

任务 F:给 DraftMerge 写穷举单测(进阶)

DraftMerge.merge() 是纯函数,因此可以脱离设备与网络做穷举测试。请覆盖:

  • 内容相同(任意版本组合)→ NoChange;
  • 一侧 version = 0 → 采用另一侧;
  • 一侧 version = -1 / NaN / 2^53 → 视为非法;
  • 版本不同 → 高者胜;
  • 版本相同、时间戳不同 → 晚者胜;
  • 版本与时间戳都相同、内容不同 → Conflict;
  • 版本相同、时间戳相同、内容相同 → NoChange(先命中规则 0)。

验收 checklist

  • [ ] 7 组全部通过;
  • [ ] 断言的是 outcome instanceof Merged / Conflict / NoChange,而不是 kind 字符串(验证"用类继承做判别"的价值);
  • [ ] 测试里没有任何 XLogger / Analytics 调用(验证合并器是纯函数)。

任务 G:核对 feature_collab 与 lib_distributed 的契约对齐(进阶)

7.8 末尾的"契约对齐记录"表显示两模块的六处历史偏差已经合流。本任务是把"对齐"从一句结论变成一次可核对的验收:逐条对照源码确认现状,并确认防线(最小接口注释、依赖检查)仍然在位。

验收 checklist

  • [ ] CollabDeviceViewModel 中搜索不到 prewarm / release / DistributedPermission;
  • [ ] DraftSyncService 使用 DraftPayload + DraftSyncChannel.onRemoteChange + DraftMerge.merge(DraftSide, DraftSide),且用 instanceof Conflict / NoChange / Merged 判别结局;
  • [ ] PendingConflict 上搜索不到 fields / fieldCount(整篇草稿粒度没有这些概念);
  • [ ] DraftSyncService.startSync 传给 channel.open() 的实参是 draftId("一份草稿 = 一次会话"的刻意约定);
  • [ ] node scripts/check-deps.mjs 仍通过(feature_collab 依赖 lib_distributed 是允许的);
  • [ ] DraftSyncService.ets 文件头的"假设契约"注释描述的就是当前实际签名(它的角色已从"防偏差的免责声明"变成"通道依赖的最小接口说明")。

7.11 延伸阅读

  • 官方文档 → 应用框架 → ArkUI → 布局 → 响应式布局与断点(GridRow / GridCol 栅格)
  • 官方文档 → 应用框架 → ArkUI → UIContext 与像素单位(vp / px / fp 的换算)
  • 官方文档 → 应用框架 → ArkUI → 窗口管理(WindowStage 尺寸变化监听与注销)
  • 官方文档 → 应用框架 → Distributed Service Kit → 设备管理(createDeviceManager / deviceStateChange)
  • 官方文档 → 应用框架 → Distributed Service Kit → 分布式数据对象(DTDO 的字段类型限制与状态回调)
  • 官方文档 → 应用框架 → Ability Kit → 动态权限申请(requestPermissionsFromUser 与 checkAccessTokenSync)
  • 官方文档 → 应用模型 → 应用配置 → deviceTypes 与多端形态
  • 教程内:03 ArkUI 组件与布局、07 分布式能力、11/05 审批流业务闭环、11/06 消息与通知、11/08 构建与持续集成

← 上一节:11/06 消息与通知 | 教程目录 | 下一节:11/08 构建与持续集成 →

📄 第 11 章 构建与持续集成 08-build-ci.md

涉及目录:scripts/、signature/、hvigor/、build-profile.json5、oh-package.json5、.gitignore。 前置章节:01 环境搭建与工具链、09 工程化与模块化架构、10 测试与上架发布。

在这一章之前,"能不能跑"取决于你打开的这台机器:装了 DevEco、签名配置是对的、缓存是干净的。这一章要把它变成任何一台干净的机器、任何一次提交都能得到一个可复现的产物。

三件事决定了这件事能不能做到:

  1. 构建必须能命令行跑通 —— 只有 IDE 能构建的工程,等于没有 CI;
  2. 签名与口令绝不入库 —— 私钥一旦进了 git 历史,删掉也没用,只能走吊销流程;
  3. 架构与范式必须由脚本守住 —— 写在文档里的规则没有约束力,写在 CI 里的才有。

本章的每一项都附实测输出。凡是没有贴输出的结论,都只是"应该如此"。

说明 签名与 release 构建(8/10、9/10 两个阶段)未在本机实际执行——签名材料只存在于 CI Secret,本地只验证过 SKIP_BUILD=1 路径与 --check-release-env 门禁。相关结论以 ci.sh / inject-version.mjs 源码与注释为准。


8.0 本章目标

  1. 用命令行 完成一次 clean → sync → assembleHap 的完整构建,不依赖 IDE 点按钮;
  2. 说清 签名三种材料的来源、为什么绝不能入库、本地与 CI 的两条注入路径;
  3. 跑通 三项静态门禁,并能读懂它们的输出(依赖图、禁用 API 命中、路由表校验);
  4. 画出 十阶段 CI 流水线,并解释"为什么必须是这个顺序";
  5. 实现 版本的单一来源:版本号与构建模式由脚本注入,禁止手改;
  6. 执行 一份 ≥12 项的交付前检查清单,把"能不能发"从感觉变成清单;
  7. 知道 哪些检查在本地没装 DevEco 时也能跑(三项静态门禁不需要 DevEco)。

8.1 命令行构建:hvigorw 与四种任务

hvigorw 是什么

hvigorw 是 DevEco Studio 工程模板提供的构建启动脚本(Windows 下是 hvigorw.bat,Unix 下是 hvigorw shell 脚本)。它不是随 SDK 安装的全局命令,而是每个工程自带一个。

第一次运行它会去下载 hvigor 依赖(走 ohpm 仓库),所以必须在联网环境执行。ci.sh 在找不到它时给出的提示原文:

hvigorw 由 DevEco Studio 工程模板提供(一个 shell 启动脚本)。
它首次运行会去下载 hvigor 依赖(走 ohpm 仓库),因此需要在联网环境执行。

本地没有 DevEco 时的做法(本工程的静态检查**不需要** DevEco):
  SKIP_BUILD=1 bash scripts/ci.sh

在 CI 镜像里:确保 hvigorw 可执行,或用 HVIGOR=/opt/deveco/hvigorw 指定路径。

HVIGOR 是脚本暴露的环境变量,用于固定版本的 CI 镜像:

# scripts/ci.sh(节选)
# hvigorw 由 DevEco 工程模板提供。允许用环境变量覆盖,便于固定版本的 CI 镜像
HVIGOR="${HVIGOR:-./hvigorw}"

术语 HAP / HAR / HSP:HAP 是可安装的部署包(本工程只有 entry 一个 HAP);HAR 是编译期静态共享库(lib_* / feature_*);HSP 是运行期动态共享包(本工程的 lib_ui)。assembleHap 产出 HAP,assembleApp 把 HAP 与资源打包成可安装的 App 包。

四个常用任务

任务 作用 是否要签名
clean 清理增量缓存 否
--sync 解析模块图与 oh-package 依赖 否
assembleHap 产出 debug 包(单个模块的 HAP) 否
assembleApp 产出 release 包(可安装的 App) 是

为什么先 clean 再 sync

# scripts/ci.sh(节选,注释为原文)
# 1. **先 clean 再 sync**:clean 保证"上一轮的增量产物"不会掩盖问题 ——
#    最典型的症状是删掉一个源文件后构建仍然成功(旧产物还在),
#    或者改了 build-profile.json5 但增量模式没重新解析模块图。
#    sync 放在 build 前,是因为它会把"模块五件套与 build-profile 不一致"
#    这类配置错误在这一步就暴露出来,而不是等编译到一半才报。
"$HVIGOR" clean --no-daemon
# ...
"$HVIGOR" --sync --no-daemon

"删掉一个源文件后构建仍然成功"是 CI 里最危险的一类假阳性:它让你的"绿色"毫无意义。CI 上的构建必须是冷构建 —— 本地为了快而用增量,CI 为了可信必须不用。

--no-daemon 同样重要:hvigor 的守护进程会复用状态,在 CI 容器里它会带来"上一次构建的配置残留"。CI 每次都是新容器,守护进程收益为零。

debug 包与 release 包的区别

# debug 包(不需要签名)
"$HVIGOR" assembleHap --mode module -p product=default -p buildMode=debug --no-daemon
# release 包(签名从环境变量注入)
"$HVIGOR" assembleApp -p product=default -p buildMode=release -p signingConfig=default \
    -p appCertFile="$APP_CERT_FILE" \
    -p profileFile="$PROFILE_FILE" \
    -p keystoreFile="$KEYSTORE_FILE" \
    -p keyAlias="$KEY_ALIAS" \
    -p keyPwd="$KEY_PWD" \
    -p keystorePwd="$KEYSTORE_PWD" \
    --no-daemon

ci.sh 里对顺序的解释值得抄一遍:

# 3. **debug 包在前,release 包在后**。debug 包不需要签名、构建更快,
#    是 QA 与自测的日常产物;release 包需要签名材料与版本注入,
#    失败率更高(签名配置、Profile 有效期、证书匹配都可能出问题)。
#    把高失败率的步骤放后面,可以让大部分错误在便宜的阶段就被拦掉。

这是一条通用的流水线原则:把便宜且确定失败的检查前置,把昂贵且不确定的步骤后置。 一次 assembleHap 要几十秒到几分钟,而三项静态检查加起来几秒钟 —— 把静态检查放在编译前面,反馈时间就从分钟级压到秒级。

构建配置里的两个关键字段

// build-profile.json5(节选)
{
  "app": {
    "products": [
      {
        "name": "default",
        "signingConfig": "default",
        // 编译期使用的 SDK:决定能引用到哪些 API
        "compileSdkVersion": "26.0.0",
        // 兼容下限:写成旧格式 "X.Y.Z(N)",指向 API 20(HarmonyOS 6.0)
        // 它同时是系统按哪套行为对待应用的依据之一,也是"最低可安装版本"
        "compatibleSdkVersion": "6.0.0(20)",
        // 面向的 SDK:决定系统按哪一代行为运行(行为变更隔离靠它)
        "targetSdkVersion": "26.0.0",
        "runtimeOS": "HarmonyOS",
        "buildOption": {
          "strictMode": {
            // ArkTS 严格检查:任何越界写法都在编译期暴露,是质量门禁的第一道
            "caseSensitiveCheck": true,
            "useNormalizedOHMUrl": true
          },
          "arkOptions": {
            "obfuscation": {
              "ruleOptions": {
                // release 构建启用混淆;keep 规则见各模块 obfuscation-rules.txt
                "enable": false,
                "files": ["./obfuscation-rules.txt"]
              }
            }
          }
        }
      }
    ],
    "buildModeSet": [
      { "name": "debug" },
      { "name": "release" }
    ]
  }
}

三个字段各有明确职责,不要混:

字段 决定什么 本工程取值
compileSdkVersion 能引用到哪些 API(编译期) 26.0.0
compatibleSdkVersion 最低可安装版本 + 系统按哪套行为对待应用 6.0.0(20)
targetSdkVersion 系统按哪一代行为运行(行为变更隔离靠它) 26.0.0

版本门槛 compatibleSdkVersion 必须写成旧格式 "X.Y.Z(N)"(如 6.0.0(20)),因为 20 仍在 X.Y.Z(N) 体系内;而 compileSdkVersion / targetSdkVersion 自 API 26 起改用语义化 X.Y.Z。混用会导致构建配置校验失败。

说明 本工程 obfuscation.ruleOptions.enable 当前为 false —— 因为工程要保持"调试时类名可读"。release 发版前应改为 true,并确认各模块的 obfuscation-rules.txt 保留了必要的 keep 规则(反射、序列化、路由构建函数、@Builder 名称等)。

顶层依赖与 oh-package-lock.json5

// oh-package.json5
{
  "modelVersion": "26.0.0",
  "description": "OfficeFlow — HarmonyOS 企业级实战工程(HarmonyOS 7.0 / API 26.0.0)",
  "dependencies": {},
  "devDependencies": {
    "@ohos/hypium": "1.0.24",
    "@ohos/hamock": "1.0.1"
  }
}

工程内部模块(lib_* / feature_* / entry)之间的依赖不在这里声明,而是在各模块自己的 oh-package.json5 里用 file:../xxx 的形式声明。这样做的意义:依赖关系贴在被依赖方附近,check-deps.mjs 才能逐模块扫描。

oh-package-lock.json5 必须入库。 .gitignore 里明确写了这一条:

# 注意:oh-package-lock.json5 **要**入库 —— 它锁定依赖版本,
# 是"同一份代码在不同机器上装出同样的依赖"的唯一保证。
!oh-package-lock.json5

8.2 签名:三种材料与三层方案

三种材料是什么、从哪来、泄漏的后果

文件 是什么 从哪来 泄漏的后果
*.cer 应用证书 AppGallery Connect → 证书、App ID 和 Profile → 申请证书 配合私钥可伪造应用签名
*.p7b 签名 Profile(含包名、证书指纹、ACL 权限) AppGallery Connect → Profile 管理 → 申请调试/发布 Profile 可被用来给同名包签名并安装
*.p12 密钥库(含私钥) DevEco Studio 首次申请证书时生成并保存 私钥一旦泄漏等同于应用身份被接管
*.p12.bak 密钥库备份 自行备份 同上

私钥丢失不可找回。 丢了只能重新申请证书,而存量用户的覆盖升级会断链 —— 也就是老用户装不上新版本。所以密钥库一定要在安全的地方单独备份。

第一层:.gitignore(唯一的强制手段)

# ---------------------------------------------------------------------------
# OfficeFlow / .gitignore
#
# 三类东西必须被忽略,理由各不相同:
#
#   1. 构建产物(build/、*.hap、*.app、build-artifacts/)
#      它们是**可再生的**。入库只会让仓库体积线性膨胀,而且会掩盖"源码没改但产物变了"
#      这类真实问题(有人手工塞了个包进去,谁也不知道它是哪次构建的)。
#
#   2. 工具链缓存与 IDE 配置(oh_modules/、.hvigor/、.idea/、.cxx/)
#      缓存因机器与版本而异;IDE 配置因个人偏好而异。
#      这两类东西入库之后,每次拉代码都会产生"我没动过但显示已修改"的噪音 diff。
#
#   3. **签名材料与本地配置**(signature/*.cer|*.p7b|*.p12、local.properties、
#      build-profile.signing.local.json5)
#      这是唯一一类"忽略它是为了安全,而不是为了整洁"的条目。
#      私钥与证书一旦进了 git 历史,即使后续删除也仍然留在历史里 ——
#      必须走密钥吊销流程,代价远高于一开始就不加进去。
# ---------------------------------------------------------------------------

# ---- 构建产物 ----
build/
build-artifacts/
*.hap
*.app
*.har
*.hsp
*.abc
*.so.map

# ---- 工具链缓存 ----
oh_modules/
node_modules/
.hvigor/
.cxx/
.ohpm/
# 注意:oh-package-lock.json5 **要**入库 —— 它锁定依赖版本,
# 是"同一份代码在不同机器上装出同样的依赖"的唯一保证。
!oh-package-lock.json5

# ---- IDE ----
.idea/
.vscode/
*.iml
.DS_Store
Thumbs.db

# ---- 签名材料(安全相关,务必忽略)----
# 证书 / Profile / 密钥库全部由开发者自行申请,绝不入库。
# 详见 signature/README.md 与 docs/11/08-build-ci.md
signature/*.cer
signature/*.p7b
signature/*.p12
signature/*.p12.bak

# 由 scripts/setup-signing.sh 生成的本地签名配置片段(可能含绝对路径与本地口令)
build-profile.signing.local.json5

# ---- 本地环境 ----
local.properties
local.env
.env
.env.local

# ---- 日志与临时文件 ----
*.log
*.tmp
*.bak
*.orig
*.rej

注意 .gitignore 里最重要的那句话:"私钥与证书一旦进了 git 历史,即使后续删除也仍然留在历史里 —— 必须走密钥吊销流程"。这意味着"我提交错了,再删掉"这个补救动作是无效的。.gitignore 是唯一的强制手段。

误区 用 git add -f 强行把证书加进仓库(比如为了让 CI 拿到它)。这等于永久公开私钥。CI 拿到签名材料的方式只有一种:从 CI Secret 以环境变量注入(见第三层)。

第二层:本地开发

根 build-profile.json5 刻意留空:

{
  "app": {
    /*
     * 签名配置刻意留空。
     * 原因:.cer / .p7b / .p12 属私密材料,绝不入库。
     * - 本地:用 DevEco Studio 的 File > Project Structure > Signing Configs 自动生成(会写入本文件,注意不要提交该改动)
     * - CI:用命令行参数注入(appCertFile / profileFile / keystoreFile / keyAlias / keyPwd / keystorePwd)
     * 详见 docs/11/08-build-ci.md
     */
    "signingConfigs": [],

空数组是显式的提醒:任何人想跑 release 构建,都会立刻遇到"没有签名配置",从而被引导到文档。

本地有两条路:

路 A:用 DevEco Studio(推荐)

File > Project Structure > Signing Configs → 勾选自动签名或手工填写 → IDE 会把配置直接写进根 build-profile.json5。

注意:写完之后 build-profile.json5 会变成"已修改"状态。不要提交这份改动 —— 提交上去等于把密钥库路径与加密口令一起入库。

路 B:用脚本生成片段(不含口令)

export APP_CERT_FILE=/abs/path/to/office_flow.cer
export PROFILE_FILE=/abs/path/to/office_flow.p7b
export KEYSTORE_FILE=/abs/path/to/office_flow.p12
export KEY_ALIAS=officeflow
export KEY_PWD='...'          # 可选:本脚本只检查是否已设置,不会写进文件
export KEYSTORE_PWD='...'     # 可选:同上
bash scripts/setup-signing.sh

setup-signing.sh 的四步:

  1. 校验必填环境变量(只要"路径 + 别名"这类非秘密信息;口令是可选的,因为脚本根本不写口令):
[ -n "$APP_CERT_FILE" ] || problem "缺少环境变量 APP_CERT_FILE(应用证书 .cer)"
[ -n "$PROFILE_FILE" ] || problem "缺少环境变量 PROFILE_FILE(签名 Profile .p7b)"
[ -n "$KEYSTORE_FILE" ] || problem "缺少环境变量 KEYSTORE_FILE(密钥库 .p12)"
[ -n "$KEY_ALIAS" ] || problem "缺少环境变量 KEY_ALIAS(密钥别名)"
  1. 校验引用的文件真实存在(把"签名失败"从 hvigor 的含糊报错前移到配置阶段,并把后缀不对的情况列为警告):
case "$APP_CERT_FILE" in
    *.cer) ;;
    *) warn "APP_CERT_FILE 的后缀不是 .cer,确认一下是不是选错了文件" ;;
esac
  1. 写出片段,口令字段留空:
{
  "app": {
    "signingConfigs": [
      {
        "name": "default",
        "type": "HarmonyOS",
        "material": {
          "certpath": "${APP_CERT_FILE}",
          // 留空:见上方说明。DevEco 会在本地自动填充(密文)
          "storePassword": "",
          "keyAlias": "${KEY_ALIAS}",
          // 留空:见上方说明
          "keyPassword": "",
          "profile": "${PROFILE_FILE}",
          // 本工程使用 ECC 密钥。若你的证书是 RSA 申请的,改成 SHA256withRSA
          "signAlg": "SHA256withECDSA",
          "storeFile": "${KEYSTORE_FILE}"
        }
      }
    ]
  }
}
  1. 提示后续步骤(并把三条路径都写出来:DevEco / 手工合并 / CI)。

生成的文件是 build-profile.signing.local.json5,它同样被 .gitignore 忽略。

说明 脚本支持 --check 模式(只校验不写文件)与 --force(覆盖已存在文件)。工程默认不覆盖:已存在的输出文件需要显式 --force,避免把已经手工补好口令的配置误覆盖掉。

第三层:CI(口令完全不落盘)

口令只从 CI Secret 以环境变量注入,通过 hvigor 命令行参数传给构建:

./hvigorw assembleApp -p buildMode=release -p signingConfig=default \
  -p appCertFile="$APP_CERT_FILE" \
  -p profileFile="$PROFILE_FILE" \
  -p keystoreFile="$KEYSTORE_FILE" \
  -p keyAlias="$KEY_ALIAS" \
  -p keyPwd="$KEY_PWD" \
  -p keystorePwd="$KEYSTORE_PWD"

ci.sh 已经封装了这条命令,并在缺少任一环境变量时直接失败:

MISSING=""
for v in APP_CERT_FILE PROFILE_FILE KEYSTORE_FILE KEY_ALIAS KEY_PWD KEYSTORE_PWD; do
    eval "value=\${$v:-}"
    [ -z "$value" ] && MISSING="${MISSING} ${v}"
done
if [ -n "$MISSING" ]; then
    echo "${RED}缺少签名环境变量:${MISSING}${RST}" >&2
    echo "本地请用 scripts/setup-signing.sh 生成配置片段并交给 DevEco 补口令;" >&2
    echo "CI 请把这些值放进 Secret 并以环境变量注入(详见 docs/11/08-build-ci.md)。" >&2
    exit 1
fi
# 签名材料与口令**都不落盘**:口令只从 CI Secret 以环境变量进入构建进程。
# 缺少任何一项时不再继续 —— release 包没有签名是无法安装的,
# 让它"构建成功但没有签名"比直接失败更危险(会被误当成可用产物)。

"构建成功但没有签名"的包比构建失败更危险 —— 因为它会被归档、被下载、被当成可用产物发出去,直到有人装的时候才发现。

三层方案合起来看

层 手段 谁用 口令在哪
强制 .gitignore + 空的 signingConfigs 全员 ——
本地 DevEco Signing Configs,或 setup-signing.sh 生成片段 开发者 DevEco 的加密存储
CI 环境变量 + hvigor 命令行参数 流水线 CI Secret 存储

口令只存在于两个地方:开发者的 DevEco 配置、CI 的 Secret 存储。不在版本库、不在任何被提交的文件里、不在终端历史里(setup-signing.sh 明确不打印口令内容)。

排查签名失败

签名报错通常很含糊,按这个顺序查最快:

# 检查项 说明
1 路径 三个文件路径是不是相对路径 / 写错了。setup-signing.sh 会先帮你校验存在性
2 Profile 有效期 调试 Profile 有有效期(通常 90 天),过期后必须重新申请
3 包名一致性 AppScope/app.json5 的 bundleName 必须与 Profile 里登记的包名一致
4 证书指纹 Profile 绑定的证书指纹必须与 .cer 匹配 —— 换了证书就要重新申请 Profile
5 签名算法 本工程使用 ECC 密钥(SHA256withECDSA)。若你用的是 RSA 证书,改成 SHA256withRSA

8.3 三项静态门禁

这三个脚本是本章最重要的部分 —— 它们把"写在文档里的规则"变成"存在硬约束的检查"。它们不需要 DevEco,只需要 node 与 bash。

门禁一:模块依赖图(check-deps.mjs)

它守住的规则(对应第 9 章的依赖规则表):

# 规则 处理
1 feature ↔ feature 横向依赖 违规
2 依赖环 违规
3 lib_base 依赖任何其它模块 违规
4 依赖指向未在 build-profile.json5 注册的模块 违规
5 方向倒置(低层依赖高层) 违规
6 同层 lib ↔ lib 警告(可能是合理的,也可能是一个环的雏形)

为什么第 6 条只给警告:lib_network 依赖 lib_log 是合理的(网络层要打日志);但也可能是环的雏形。需要人判断的事不该做成硬门禁,否则门禁会被绕过 —— 这是脚本设计里最重要的取舍。

实测输出(node scripts/check-deps.mjs):

========================================================================
OfficeFlow 模块依赖图校验
工程根:...\code\office-flow
模块数:13(已扫描 13)
========================================================================

依赖图(A --> B 表示 A 依赖 B):
  lib_base          (无依赖)
  lib_auth           --> lib_base, lib_log, lib_network
  lib_distributed    --> lib_base, lib_log
  lib_log            --> lib_base
  lib_network        --> lib_base, lib_log
  lib_router         --> lib_base, lib_log
  lib_storage        --> lib_base, lib_log
  lib_ui             --> lib_base
  feature_approval   --> lib_base, lib_log, lib_network, lib_router, lib_storage, lib_ui
  feature_collab     --> lib_base, lib_distributed, lib_log, lib_network, lib_router, lib_storage, lib_ui
  feature_login      --> lib_auth, lib_base, lib_log, lib_network, lib_router, lib_ui
  feature_message    --> lib_base, lib_log, lib_network, lib_router, lib_storage, lib_ui
  entry              --> feature_approval, feature_collab, feature_login, feature_message, lib_auth, lib_base, lib_distributed, lib_log, lib_network, lib_router, lib_storage, lib_ui

警告(6):
  ⚠ 同层 lib 互相依赖(需人工确认方向合理):lib_auth --> lib_log
  ⚠ 同层 lib 互相依赖(需人工确认方向合理):lib_auth --> lib_network
  ⚠ 同层 lib 互相依赖(需人工确认方向合理):lib_network --> lib_log
  ⚠ 同层 lib 互相依赖(需人工确认方向合理):lib_storage --> lib_log
  ⚠ 同层 lib 互相依赖(需人工确认方向合理):lib_router --> lib_log
  ⚠ 同层 lib 互相依赖(需人工确认方向合理):lib_distributed --> lib_log

========================================================================
OK — 依赖方向、模块注册、依赖环、lib_base 零依赖 全部通过
========================================================================

依赖图里有三件事值得读出来:

  1. lib_base 是唯一的零依赖节点 —— 这正是"最底层契约"应有的样子;
  2. feature_* 之间没有任何横线 —— 跨模块跳转走路由名常量,不走 import(这条由脚本强制);
  3. 6 条警告全是"→ lib_log" —— 日志是横切关注点,几乎每个模块都要用。这是一个健康的同层依赖(单向、无环)。

容错设计:模块目录不存在时只警告、按"依赖为空"参与图构建、不影响退出码(并行开发中"目录还没建"不等于"依赖错了")。用 --strict 可以让缺失目录变成错误(CI 的最终分支上应该开这个开关)。

说明 脚本自己实现了 JSON5 注释与尾逗号剥离,没有引入 json5 依赖。理由是"不该为了一个校验脚本去动根 oh-package.json5"。实现里有一个值得学习的细节:剥离注释必须逐字符扫描并跟踪字符串状态,否则 "https://api.example.com" 里的 // 会被当成注释,把后面的内容整段吃掉 —— 这是自己实现注释剥离最经典的 bug。

门禁二:禁用 API 扫描(check-banned-api.sh)

它守住的五类写法:

rule-id 禁的是什么 替代
ohos-namespace-import from '@ohos.xxx' 分命名空间导入 统一 @kit.*
ohos-router @ohos.router Navigation + 路由名常量
ohos-net-http @ohos.net.http lib_network 的 rcp 封装
persistent-storage PersistentStorage lib_storage 收口
v1-state-decorator @State / @Prop / @Link / @Observed / @ObjectLink / @Provide / @Consume / @Watch / @StorageLink / @StorageProp / @LocalStorageLink / @LocalStorageProp / @Track V2(@ObservedV2 + @Trace / @Local / @Param)

为什么 V1/V2 混用必须硬禁:同一个组件里出现 @State 和 @Trace,更新时机不一致 —— 这是"莫名其妙不刷新"的最常见来源。人眼在 CodeReview 里抓这类问题非常不可靠(尤其 V1 装饰器只差一个字符)。

实测输出(bash scripts/check-banned-api.sh):

================================================================
禁用 API 扫描(先剥注释,再匹配)
工程根:/d/Documents/tencent/workbuddy/learning/HarmonyOS-doc/code/office-flow
白名单:/d/.../scripts/banned-api.list(0 条豁免)
================================================================
----------------------------------------------------------------
扫描文件:99
白名单豁免次数:0
命中:0
OK —— 未发现禁用 API

扫描文件:99 / 命中:0 / 白名单豁免:0 这三个数字合起来说明:整个工程 99 个 .ets 源文件全部在统一范式内,且没有任何豁免。

这个脚本最值得学的是"先剥注释,再匹配":

# ## 关键实现细节一:先剥注释,再匹配
#
# 本工程的注释里**大量出现**被禁 API 的名字(例如解释"为什么不用 @Observed 而用 @ObservedV2")。
# 直接 grep 原始文件会把注释当命中,产生一堆假阳性 —— 假阳性是脚本类门禁最致命的问题:
# 被误报几次之后,所有人都会开始无脑加白名单,门禁就废了。
#
# 因此这里先做一次**位置保持的注释剥离**:
#   - /* */ 与 // 的内容替换为空格,**字符数与行数都不变**;
#   - 字符串字面量内部不动(否则字符串里的 "*/" 会提前结束块注释)。
# 剥完再匹配,行号仍然对应原始文件,报告时回读原文件的那一行展示给开发者。

"位置保持"是精髓:剥完注释后字符数与行数都不变,所以命中行的行号仍然对应原始文件,报告时直接 sed -n "${lineno}p" 回读原文件就能给开发者看到真实代码。

第二个关键实现细节:

# ## 关键实现细节二:整个扫描只用一次 awk + 少量 sed
#
# 这条约束是**性能与健壮性**双重考虑,不是写法偏好:
#
#   - 如果"每个文件 × 每条规则"各起一次 awk/grep,60 个文件就是 900 次进程创建。
#     在 Windows/Git Bash 上进程创建极慢(每次几十毫秒),整条 CI 会从 3 秒变成 90 秒 ——
#     门禁一慢,人就会开始想办法跳过它。

"门禁一慢,人就会开始想办法跳过它" —— 这是所有工程纪律的共同宿命。所以性能不是可选项。

还有两个正则可读性要点:

#   \042 = 双引号,\047 = 单引号。用八进制是为了避免在 shell 单引号字符串里
#   写引号导致 shell 引用被提前终止 —— 这类脚本最容易出问题的就是嵌套引号。
#   \> 是 gawk 的「词尾」锚点:@State\> 不会命中 @StateXxx,
#   @Observed\> 也不会命中 @ObservedV2(V 是词字符,词尾不成立)。

@Observed\> 不命中 @ObservedV2 这一条尤其关键 —— 如果用了 @Observed 的子串匹配,正确写法反而会被判违规。

白名单的设计纪律:

# 白名单的原则:
#   - 每条豁免都必须能回答"为什么这里必须这样写";
#   - 优先选择**单规则豁免**而不是整文件豁免 —— 整文件豁免会连未来的新违规一起放过;
#   - 豁免是技术债的记账,不是免责条款。能改掉的尽早去掉。

本工程的白名单当前为空(0 条豁免),并保留两条被注释掉的示例来说明"写得好的豁免"长什么样。

门禁三:路由表校验(check-route-map.mjs)

它守住的是纯字符串契约:

// 系统路由表(route_map.json)是**纯字符串配置**:
//   { "name": "message/list",
//     "pageSourceFile": "src/main/ets/pages/Index.ets",
//     "buildFunction": "messageListRouteBuilder" }
//
// 这三行里没有任何一处有编译期保护:
//   - 路径写错 → 构建期不报错,运行时 push 到该路由是**白屏**;
//   - buildFunction 拼错或改名后忘了同步 → 同上,白屏;
//   - 路由名与 lib_router 的 RouteNames 常量不一致 → 表现为"某个入口点进去没反应"。

白屏是移动端最难定位的现象之一(没有异常、没有日志、没有堆栈),所以这类字符串契约必须由脚本兜住。

实测输出(node scripts/check-route-map.mjs --require-file):

========================================================================
路由表校验(route_map.json)
模块:entry
路由表:entry\src\main\resources\base\profile\route_map.json
========================================================================

共 8 条路由:
  ✓ Login           loginRouteBuilder           src/main/ets/pages/Index.ets
  ✓ Main            mainRouteBuilder            src/main/ets/pages/Index.ets
  ✓ ApprovalList    approvalListRouteBuilder    src/main/ets/pages/Index.ets
  ✓ ApprovalDetail  approvalDetailRouteBuilder  src/main/ets/pages/Index.ets
  ✓ ApprovalCreate  approvalCreateRouteBuilder  src/main/ets/pages/Index.ets
  ✓ MessageList     messageListRouteBuilder     src/main/ets/pages/Index.ets
  ✓ CollabDevices   collabDeviceRouteBuilder    src/main/ets/pages/Index.ets
  ✓ Settings        settingsRouteBuilder        src/main/ets/pages/Index.ets

========================================================================
OK —— 8 条路由的文件与命名全部有效
========================================================================

8 条路由与 lib_router 的 RouteNames 常量、与 Index.ets 的 destBuilder 分支集合必须是同一份清单三处一致:

// 三者必须保持一一对应:route_map.json 的 name 集合 == destBuilder 的分支集合
// == lib_router 中 RouteNames.ALL 的集合(含 entry 自己的 Main / Settings)。
// 任何一处多出来/少一个,现象都是「某个入口点进去没反应」或「路由未找到」,
// 而编译期不会报错。改动路由时请三处一起改。

buildFunction 必须以 RouteBuilder 结尾:

// ## 命名约定:必须以 RouteBuilder 结尾
//
// 不是审美问题:route_map.json 的 buildFunction 是字符串,混淆/重构都不会提醒你改名。
// 统一后缀让"哪些函数被路由表引用"可以用 grep 一次性找全,
// scripts/check-route-map.mjs 也据此做规范校验(注意大小写敏感:
// `messageListRouteBuilder` 与 `MessageListRouteBuilder` 都满足这条约定)。

工程里两种写法都出现过:MessageListRouteBuilder(模块内构建函数)与 messageListRouteBuilder(entry 的薄包装)。不强制统一首字母,因为它们承担不同角色,强行改名只会制造一次无收益的重构。

容错设计:路由表不存在且未加 --require-file 时只警告(并行开发中模块可能还没建);加了 --require-file 则直接失败。CI 里用的是带 --require-file 的形式,因为最终分支上路由表必须存在。

pageSourceFile 接受两种路径风格:

// 同一份路径在不同版本的模板里有两种写法:
//   - 模块根相对:"src/main/ets/pages/Index.ets"(当前写法)
//   - ets 根相对:"pages/Index.ets"
// 两种都接受:**路由能不能用**才是重点,路径风格之争没有价值。

这是一条值得学习的脚本设计观:校验脚本的目的是"能不能用",不是"风格是否统一"。把风格偏好写进硬门禁只会制造对抗。


8.4 CI 流水线:十个阶段与顺序理由

阶段表

# 阶段 命令 跳过条件
1 清理 hvigorw clean --no-daemon SKIP_BUILD=1
2 同步 hvigorw --sync --no-daemon SKIP_BUILD=1
3 模块依赖图 node scripts/check-deps.mjs 从不跳过
4 禁用 API bash scripts/check-banned-api.sh 从不跳过
5 路由表 node scripts/check-route-map.mjs --require-file 从不跳过
6 发布环境门禁 node scripts/inject-version.mjs --check-release-env 未请求 release(不受 SKIP_BUILD 影响)
7 debug 包 hvigorw assembleHap --mode module -p product=default -p buildMode=debug --no-daemon SKIP_BUILD=1
8 版本注入 node scripts/inject-version.mjs --release 未请求 release,或 SKIP_BUILD=1,或 VERSION_CODE/VERSION_NAME 未设(缺省时只提示并跳过注入,不中断)
9 release 包 hvigorw assembleApp -p ... -p signingConfig=default ... --no-daemon 未请求 release,或 SKIP_BUILD=1
10 归档 复制 *.hap / *.app 到 build-artifacts/ SKIP_BUILD=1

阶段编号与 ci.sh 里的 stage 标签(1/10 ~ 10/10)逐一对应。两个关键口径:

  • 第 6 阶段在 SKIP_BUILD=1 时照常执行:它是纯静态校验(读一个文件、匹配一个正则),毫秒级完成,本地"只跑检查"的路径同样要能拦下"release 带着 Mock 上线"。ci.sh 里的原话:"不依赖 SKIP_BUILD:它是纯静态校验,本地用 SKIP_BUILD=1 也要能被拦下。"
  • 第 8 阶段版本号缺省不中断:VERSION_CODE / VERSION_NAME 未设置时打印警告并沿用当前版本号继续(脚本注释:"release 包将继续使用当前版本号")——版本注入是便利设施,发布环境门禁才是安全设施,两者失败策略刻意不同。

四种运行方式

bash scripts/ci.sh                 # 常规:检查 + debug 包
bash scripts/ci.sh --release       # 额外产出 release app(需要签名环境变量)
SKIP_BUILD=1 bash scripts/ci.sh    # 只跑静态检查(本地提 PR 前用)
SKIP_BUILD=1 bash scripts/ci.sh --release   # 只跑静态检查 + 发布环境门禁(不打包)

--release 也可以由 RELEASE=1 环境变量触发。第四种方式的价值:不打包、不签名,也能完整验证"当前工程状态能否通过发布环境门禁"—— 提 PR 时怀疑环境被人改过,跑这一条就知道。

顺序的四条理由

# 四条顺序理由(ci.sh 注释为原文,ci.sh:23-46):
#
# 1. **先 clean 再 sync**:clean 保证"上一轮的增量产物"不会掩盖问题 ...
#    sync 放在 build 前,是因为它会把"模块五件套与 build-profile 不一致"
#    这类配置错误在这一步就暴露出来,而不是等编译到一半才报。
#
# 2. **三项静态检查全部在编译之前**。它们加起来几秒钟,而一次 assembleHap
#    要几十秒到几分钟。把便宜且"必然确定"的检查前置,是把反馈时间从分钟级压到秒级 ——
#    而且失败原因也更精确(依赖违规 vs 编译错误,两者排查路径完全不同)。
#    这三个门禁分别守住:架构不会腐化、范式不会退化、路由不会白屏。
#
# 3. **debug 包在前,release 包在后**。debug 包不需要签名、构建更快,
#    是 QA 与自测的日常产物;release 包需要签名材料与版本注入,
#    失败率更高(签名配置、Profile 有效期、证书匹配都可能出问题)。
#    把高失败率的步骤放后面,可以让大部分错误在便宜的阶段就被拦掉。
#
# 4. **发布环境门禁放在所有构建之前**。`EnvConfig.CURRENT` 的默认值是 `Env.MOCK`,
#    而 Mock 开关是"环境判断"—— 这意味着"release 包带着整套 Mock 规则上线"
#    只需要一次"忘了把 CURRENT 改成 prod"。这类包不会报错、不会崩溃,
#    只会让线上返回假数据,而且线上问题在本地完全复现不出来。
#    门禁把这件事变成构建期硬失败:它不写文件、不需要签名与版本号,
#    毫秒级就能跑完,所以排在 debug 构建之前 —— 环境不对就不该浪费任何一次构建。

失败时必须能定位

# 失败时把「哪个阶段、哪一行」打出来。
# set -e 只会静默退出,CI 日志里看到的就是"最后一条正常输出之后什么都没了"——
# 那是最难用的失败信息。ERR trap 把阶段名补上,定位成本从"翻日志"降到"看最后一行"。
on_error() {
    local exit_code=$?
    local elapsed=$((SECONDS - STAGE_STARTED_AT))
    # ...
    printf '%sFAILED at stage: %s(耗时 %ss,退出码 %s)%s\n' "$RED" "$STAGE" "$elapsed" "$exit_code" "$RST" >&2
    exit "$exit_code"
}
trap on_error ERR

"最后一条正常输出之后什么都没了"是最难用的失败信息。 加了 trap 之后,CI 日志的最后一行就是失败阶段名与耗时。

set -eo pipefail 保证流水线里的任一条命令失败都会被捕获(没有 pipefail 的话 a | b 只看 b 的退出码)。

阶段 10 的归档细节(多模块工程特有)

archive_one() {
    local src="$1"
    local rel
    # 用相对路径把模块名带进文件名:多模块工程里全是 output/default/xxx.hap,
    # 只留 basename 会在归档目录里互相覆盖。
    rel="${src#"${ROOT_DIR}/"}"
    rel="${rel//\//_}"
    cp -f "$src" "${ARTIFACT_DIR}/${rel}"
    # ...
}

多模块工程里每个模块都产出 output/default/xxx.hap,如果只取 basename,归档目录里会互相覆盖 —— 最后只剩一个包,而没人知道丢了哪些。

退出码

值 含义
0 全部通过(CI PASSED,并打印产物个数与归档目录)
1 任一阶段失败,且会打印失败阶段名

8.5 版本注入与可复现性

为什么版本号必须由脚本注入

/**
 * ## 为什么要有它(而不是手改)
 *
 * 版本号与构建模式**必须由构建流程决定**,不能由人手改,原因有三个:
 *
 * 1. **手改必漏**。一次发版要同时改 `versionCode`、`versionName`,
 *    有时还有散落在代码里的版本常量。漏一个,就会出现
 *    "包名对、版本号错"的包,而这类问题在安装时才暴露。
 *
 * 2. **构建模式是编译期常量,不是运行时开关**。
 *    `EnvConfig.IS_RELEASE` 控制日志级别、调试入口、Mock 开关的编译期剪裁。
 *    如果它是"开发者记得改"的一个字面量,release 包里残留 debug 分支只是时间问题。
 *    所以它必须由 CI 在构建前用脚本改写 —— 人对一个布尔字面量是不可靠的。
 *
 * 3. **可审计**。CI 日志里会有"从 X 改成 Y"的记录;
 *    手改的版本号没有任何痕迹,出问题只能靠翻提交历史猜。
 */

它只改两个地方

文件 字段
AppScope/app.json5 versionCode / versionName
lib_base/src/main/ets/const/AppConst.ets EnvConfig.IS_RELEASE: boolean = <bool>

刻意改的是字面量,而不是引入配置文件:

// 刻意改的是**字面量**而不是引入配置文件:
// 读配置文件意味着运行时 IO 与失败分支,而这两个值在编译期就必须确定。

因为 IS_RELEASE 是编译期常量,它决定日志级别与 Mock 开关的编译期剪裁。改成"运行时读配置",就等于失去了剪裁能力(release 包里仍然带着 debug 分支的代码)。

实测输出(--dry-run)

node scripts/inject-version.mjs --version-code=1000001 --version-name=1.0.1 --dry-run
========================================================================
版本号与构建模式注入
工程根:...\code\office-flow
模式:debug(dry-run,不写盘)
========================================================================

[1/2] AppScope/app.json5
  versionCode: 1000000  →  1000001
  versionName: "1.0.0"  →  "1.0.1"

[2/2] lib_base/src/main/ets/const/AppConst.ets
  EnvConfig.IS_RELEASE: false  →  false
  无需改动(值已一致)

========================================================================
DRY-RUN —— 以上为将要发生的改动,未写入任何文件
========================================================================

安全设计:匹配不到就失败

/**
 * ## 安全设计
 *
 *   - 改写前先校验:目标字段必须能匹配到,版本号格式必须合法;
 *   - 匹配不到就 **exit 1**,绝不"静默跳过"(静默跳过 = 版本号没更新但构建成功);
 *   - `--dry-run` 只打印将要发生的改动,不写盘;
 *   - 就地替换而不是重写整个文件:保住注释与格式,diff 才可读。
 */

"静默跳过 = 版本号没更新但构建成功" —— 这是所有脚本类工具最危险的行为模式。宁可失败,不可静默通过。

IS_RELEASE 的匹配用了一个非常具体的正则:

/*
 * 匹配 `static readonly IS_RELEASE: boolean = false;`
 *
 * 为什么用这么具体的形状而不是宽松的 /IS_RELEASE\s*=\s*(true|false)/:
 * 这个字面量会被"就地改写",如果匹配范围过宽(例如把类型标注也吃进去),
 * 改完之后代码就不再可编译,而报错会出现在离这里很远的地方。
 * 精确匹配 + 找不到就报错,是这里唯一可接受的行为。
 */
const IS_RELEASE_RE = /static\s+readonly\s+IS_RELEASE\s*:\s*boolean\s*=\s*(true|false)\s*;/;

发布环境门禁:release 不得带着 Mock 上线

同一个脚本里还住着另一条性质完全不同的检查 —— 它不注入任何东西,只拒绝放行:

// scripts/inject-version.mjs(节选,:117-162)
/**
 * `static readonly CURRENT: Env = Env.<NAME>;`
 * 形状必须精确:这个字面量既被读取校验(本脚本),也可能被人手改;
 * 宽松匹配会把类型标注也吃进去,报错位置会离原因很远。
 */
const CURRENT_ENV_RE = /static\s+readonly\s+CURRENT\s*:\s*Env\s*=\s*Env\.([A-Z]+)\s*;/;
/** release 唯一允许的环境 */
const RELEASE_ALLOWED_ENV = 'PROD';

function gateReleaseEnv() {
    const current = readCurrentEnv();
    if (current === RELEASE_ALLOWED_ENV) {
        info(`发布环境校验通过:EnvConfig.CURRENT = Env.${current}`);
        return;
    }
    if (allowMockInRelease) {
        console.warn(`[WARN] release 目标使用非生产环境 Env.${current}(由 --allow-mock-in-release 放行)。` +
            '仅限联调演示包,禁止用于正式发布。');
        return;
    }
    fail(`拒绝打 release 包:EnvConfig.CURRENT = Env.${current},不是 Env.${RELEASE_ALLOWED_ENV}。...`);
}

它要拦的事故形态(inject-version.mjs 文件头注释原文):EnvConfig.CURRENT 的默认值是 Env.MOCK,而 entry 的 initMock() 是"环境判断"开关 —— 打 release 包时忘了改这一行,整个包会带着全套 Mock 规则上线:线上返回假数据、线上问题在本地完全复现不出来,而且不会有任何报错。

四个设计点:

设计点 实现 为什么
精确形状匹配 CURRENT_ENV_RE 匹配 static readonly CURRENT: Env = Env.<NAME>; 匹配不到(字段改名、格式变了)直接 exit 1,绝不静默跳过 —— 静默跳过等于门禁不存在
唯一放行环境 RELEASE_ALLOWED_ENV = 'PROD' mock / dev / staging 一律拒绝,白名单只有一个值,没有"接近生产"的灰色地带
逃生开关 --allow-mock-in-release 联调/演示包确实需要带 Mock。显式传参并在日志打 WARN —— 逃生必须留下痕迹,而不是默认静默
独立运行入口 --check-release-env 只跑门禁:不注入版本号、不写任何文件,也不需要 VERSION_CODE / VERSION_NAME,跑完直接 exit 0

CI 接入点是 ci.sh 的第 6 阶段(stage "6/10 发布环境门禁"):仅 --release(或 RELEASE=1)时执行,且不受 SKIP_BUILD 影响 —— 本地"只跑静态检查"的路径同样要能拦下环境错误。脚本里的原话:

# 门禁内容:EnvConfig.CURRENT 必须是 Env.PROD。读到 mock(默认值!)/ dev / staging
# 一律中断 —— 否则这个 release 包会把 entry 里注册的整套 Mock 规则带上线。
# 不依赖 SKIP_BUILD:它是纯静态校验,本地用 SKIP_BUILD=1 也要能被拦下。
node "${SCRIPT_DIR}/inject-version.mjs" --check-release-env

本地单独验证这条门禁(不需要 DevEco,不需要签名材料):

node scripts/inject-version.mjs --check-release-env
# 环境为 PROD 时:OK —— 发布环境门禁通过(--check-release-env,未改动任何文件)
# 环境为 MOCK 时:[FATAL] 拒绝打 release 包:EnvConfig.CURRENT = Env.MOCK,不是 Env.PROD(exit 1)

版本号也有严格校验:

  • versionCode 必须是十进制整数,且在 1 ~ 2147483647 之间(HarmonyOS 该字段是 int32);
  • versionName 必须匹配 /^\d+(\.\d+){1,3}([-+][0-9A-Za-z.-]+)?$/(形如 1.0.0 或 1.0.0-beta.1)。

可复现性:三个必要条件

"同一份提交 + 同一套环境 → 同一个产物",需要:

# 条件 工程里的对应措施
1 依赖版本锁定 oh-package-lock.json5 入库(.gitignore 里显式 !oh-package-lock.json5)
2 构建工具版本固定 HVIGOR 环境变量允许 CI 镜像固定 hvigor 路径;compileSdkVersion 写死为 26.0.0
3 构建是冷构建 CI 里 clean --no-daemon + --sync --no-daemon

再加一条:版本号与构建模式由脚本注入(而不是由人改),否则"同一份提交"的产物会因为某人本地改了一个字面量而不同。

误区 期望"同一份代码得到字节完全相同的产物"。签名时间戳、绝对路径、构建时间戳都会进入产物。可复现性的现实目标是"行为等价"(同样的功能、同样的配置、同样的依赖版本),而不是二进制相同。


8.6 交付前检查清单

下面 14 项按"从代码到发布"的顺序排列。每一项都是可验证的动作,不是"应该注意"。

代码与架构(1-4)

  • [ ] 1. 三项静态门禁全绿
SKIP_BUILD=1 bash scripts/ci.sh

预期:check-deps OK(可能带同层 lib 警告)、check-banned-api 命中 0、check-route-map --require-file OK。

  • [ ] 2. 依赖图最终分支用 --strict 复检
node scripts/check-deps.mjs --strict

预期:没有"模块目录不存在"的警告(所有模块都已创建)。并行开发期的"目录还没建"在最终分支上不允许存在。

  • [ ] 3. 路由三处一致

route_map.json 的 name 集合 == Index.ets 的 destBuilder 分支集合 == lib_router 的 RouteNames.ALL。

预期:check-route-map.mjs 输出的路由条数与 RouteNames.ALL 的长度相等(本工程为 8)。

  • [ ] 4. 历史契约不一致已全部合流(留档 + 复核)

历史上 feature_collab / feature_message 与公共库之间曾有四处契约偏差(详见 11/06 与 11/07 的对齐记录),现已全部在同一分支上合流:

历史不一致 位置 现状
DeviceHub.prewarm() / release() / DistributedPermission 在 lib_distributed 中不存在 CollabDeviceViewModel ✅ 已对齐:start() / stop() / Permission.ensureDistributedDatasync()(旧名已从代码中移除)
AppButton 调用签名(enabled / onClick)与 lib_ui 实际不符 MessageListPage ✅ 已对齐:AppButton({ text, variant, disabled, onTap })
跳审批详情传 approvalId 与 ApprovalDetailParam 定义不符 MessageListPage ✅ 已对齐:统一为 { id, from }(lib_router 单一权威)
DraftSyncChannel.open(draftId) / publish(DraftRecord) / merge(DraftRecord, DraftRecord) 与公共库签名不符 DraftSyncService ✅ 已对齐:publish(DraftPayload)、merge(DraftSide, DraftSide): MergeOutcome;唯余 open(draftId) 一处 —— 服务刻意"一份草稿 = 一次协作会话",把 draftId 直接当 sessionId 传入(DraftSyncService.ets:200-201),形参名与实参语义的错位是约定而非缺陷,保留现状

合流之外,本轮修复另有两项链路结论随之落定,交付时一并复核:

  • 列表 SWR 降级已实现:fetchList 网络失败时回退 stale 缓存(ApprovalRepositoryImpl.ets:120-128),正文不再有"degraded 恒 false"的旧叙事;

  • 通知链路已接线:NotificationService.init 由 MessageListPage.aboutToAppear 注入 context,摘要通知经 MessageListViewModel.maybePublishDigest 发布,深链冷/热启动均落地(详见 11/06)。

  • [ ] 5. 无 TODO / FIXME 进入发布分支

bash -c "grep -rn 'TODO\|FIXME' --include=*.ets . | wc -l"

预期:0(或在清单里逐条登记为已知技术债)。

构建与签名(6-9)

  • [ ] 6. build-profile.json5 的 signingConfigs 是空数组
"signingConfigs": [],

预期:仓库里的版本是空数组(本地被 DevEco 写过之后不要提交那份改动)。

  • [ ] 7. 没有任何签名材料进版本库
git ls-files signature/

预期:只输出 signature/README.md(.cer / .p7b / .p12 / .p12.bak 全部被忽略)。

  • [ ] 8. 命令行 debug 包能构建
bash scripts/ci.sh

预期:CI PASSED —— 产物 N 个,归档目录:.../build-artifacts。

  • [ ] 9. release 包能构建且签名正确
export APP_CERT_FILE=... PROFILE_FILE=... KEYSTORE_FILE=... KEY_ALIAS=... KEY_PWD=... KEYSTORE_PWD=...
bash scripts/ci.sh --release

预期:CI PASSED;产物能用 hdc install 装到真机;versionName 是本次发版的版本号。

版本与配置(10-11)

  • [ ] 10. 版本号与构建模式由脚本注入,无手改痕迹
git diff -- AppScope/app.json5 lib_base/src/main/ets/const/AppConst.ets

预期:这两个文件在发布分支上没有未提交的手改;版本号由 CI 在构建前注入。

  • [ ] 11. release 构建开启混淆并确认 keep 规则

检查 build-profile.json5 的 arkOptions.obfuscation.ruleOptions.enable(本工程当前为 false),并确认各模块的 obfuscation-rules.txt 保留了:

  • 路由 @Builder 导出函数名(route_map.json 按字符串引用);
  • 解析器 / 序列化涉及的类型名(若有);
  • 对外暴露的 HAP/HAR 接口。

运行时行为(12-14)

  • [ ] 12. 退出登录清空通知栏

动作:登录 → 触发一条通知 → 退出登录 → 检查通知栏。

预期:通知栏被清空(NotificationService.cancelAll() 被调用)。

  • [ ] 13. 冷启动不闪登录页

动作:登录 → 杀进程 → 重启。

预期:全程没有一帧渲染出登录页;日志里有 session restore finished, loggedIn=true。

  • [ ] 14. 真机双设备跑通协同(含冲突裁决)

动作:两台真机 + 同一账号 + 已组网 → 发起协同 → 两端先后编辑草稿(验证"版本高者胜"的自动合并)→ 需要演练裁决时,临时把两端草稿的版本与 updatedAt 改成同一对常量构造冲突(做法见 11/07 任务 E)→ 裁决冲突。

预期:设备列表非空;冲突弹窗给出两侧版本号与编辑者(整篇草稿粒度没有"字段名列表");裁决后同一冲突不重复弹出;点"取消"不修改本机内容。

补充:如果时间只够做一半

按这个顺序砍,保底到第 9 项:

1 → 2 → 3 → 6 → 7 → 8 → 9   (必做)
4 → 5 → 10 → 11             (强烈建议)
12 → 13 → 14                (涉及系统能力,必须在真机上做)

第 6、7 两项是安全项,任何情况下都不能省。第 14 项是分布式功能的唯一有效验收,也只能在真机上做。


8.7 常见误区

误区 1 只在 IDE 里点按钮构建。 只有 IDE 能构建的工程等于没有 CI。构建必须能命令行跑通 —— hvigorw 是每个工程自带的启动脚本,允许用 HVIGOR 环境变量固定版本。

误区 2 CI 里用增量构建以求快。 "删掉一个源文件后构建仍然成功"是 CI 里最危险的假阳性 —— 它让你的绿色毫无意义。CI 必须是冷构建(clean + --no-daemon + --sync)。

误区 3 把证书 / 密钥库提交进仓库,或提交被 DevEco 改过的 build-profile.json5。 私钥一旦进了 git 历史,删掉也没用,只能走吊销流程。工程用"空的 signingConfigs: [] + .gitignore"双重手段提醒这一点。

误区 4 用 git add -f 强行把签名材料加进仓库,好让 CI 拿到。 等于永久公开私钥。CI 只能从 Secret 以环境变量注入。

误区 5 release 构建时缺少签名却不失败。 "构建成功但没有签名"的包比构建失败更危险 —— 它会被归档、被打包、被当成可用产物发出去。ci.sh 在缺少任一签名环境变量时直接 exit 1。

误区 6 把依赖规则只写在文档里。 写在文档里的规则没有约束力。一次"为了赶进度"的跨 feature import 就能把两个模块焊死,而编译、打包、运行全都不报错 —— 直到某天要单独删掉一个模块。规则必须变成 CI 门禁。

误区 7 给门禁加整文件白名单来"让它闭嘴"。 整文件豁免会连未来的新违规一起放过。优先单规则豁免,且每条豁免必须能回答"为什么这里必须这样写"。豁免是技术债的记账,不是免责条款。

误区 8 让门禁脚本变慢。 "每个文件 × 每条规则"各起一次进程,60 个文件就是 900 次进程创建;在 Windows/Git Bash 上整条 CI 会从 3 秒变成 90 秒。门禁一慢,人就会开始想办法跳过它。

误区 9 脚本匹配不到目标字段时静默跳过。 "版本号没更新但构建成功"是脚本类工具最危险的行为模式。inject-version.mjs 在匹配不到时 exit 1,绝不静默通过。

误区 10 把 IS_RELEASE 改成运行时读配置。 它必须在编译期确定,这样 release 构建才能剪裁掉 debug 分支与 Mock 代码。改成运行时读取,等于 release 包里仍然带着调试能力。

误区 11 忘了把 oh-package-lock.json5 入库。 它是"同一份代码在不同机器上装出同样的依赖"的唯一保证。.gitignore 里用 !oh-package-lock.json5 显式反向放行。

误区 12 多模块归档时只取 basename。 每个模块都产出 output/default/xxx.hap,只取文件名会互相覆盖,最后只剩一个包。必须把模块路径带进归档文件名。

误区 13 期望可复现 = 字节完全相同。 签名时间戳、绝对路径、构建时间戳都会进入产物。现实目标是"行为等价"(同样的功能、配置、依赖版本)。

误区 14 在模拟器上验收通知与分布式。 通知在模拟器上"publish 成功但通知栏空白""addSlot 被跳过""授权不弹窗"是常态;分布式在模拟器上零设备是必然结果。这两类功能的验收只能在真机上做。

误区 15 把"设备列表空白"当成代码 bug 排查。 真机 + 同一华为账号 + 已组网 + 已授权,缺任何一条都是零设备。先查环境。

误区 16 忽略 --strict 与 --require-file 的"最终分支收紧"作用。 并行开发期它们被放宽(给警告而不是报错)是合理的;但最终分支上必须收紧,否则"模块目录不存在""路由表不存在"这类问题会一路带进发布。


8.8 实践任务

任务 A:本地零依赖跑通三项门禁(必做)

目标:证明三项静态门禁不需要 DevEco。

cd code/office-flow
SKIP_BUILD=1 bash scripts/ci.sh

验收 checklist

  • [ ] 三项门禁全部通过,CI PASSED;
  • [ ] 输出里有 "SKIP_BUILD=1:跳过 clean/sync/assemble,只跑静态检查";
  • [ ] 记录三项的实际数字(模块数 / 扫描文件数 / 路由条数),与本章给出的实测输出对比;
  • [ ] 故意在任一 .ets 文件里加一行 @State count: number = 0;,确认门禁失败并指出文件与行号,然后删掉。

任务 B:故意打破依赖规则(必做)

  1. 在 feature_message 的 oh-package.json5 里加一条依赖:"feature_approval": "file:../feature_approval";
  2. 运行 node scripts/check-deps.mjs;
  3. 记录它给出的规则名、detail、hint;
  4. 撤销改动。

验收 checklist

  • [ ] 输出里有「feature 之间禁止横向依赖」;
  • [ ] detail 形如 feature_message --> feature_approval;
  • [ ] hint 指向正确解法(走 lib_router 的路由名常量);
  • [ ] 退出码为 1。

任务 C:故意写错路由表(必做)

  1. 把 route_map.json 里某条路由的 buildFunction 改成一个不存在的名字;
  2. 运行 node scripts/check-route-map.mjs --require-file;
  3. 观察它对"命名规范"与"文件存在性"分别报了什么;
  4. 再改回正确名字,但把 pageSourceFile 改成一个不存在的路径,再跑一次;
  5. 全部还原。

验收 checklist

  • [ ] 命名不规范(不以 RouteBuilder 结尾)被报为违规;
  • [ ] pageSourceFile 不存在被报为违规,且 hint 里列出了它尝试过的查找起点;
  • [ ] 还原后再次运行输出 OK —— N 条路由的文件与命名全部有效。

任务 D:走一遍签名配置的三层方案(必做,不需要真证书)

  1. 第一层:git ls-files signature/ —— 记录输出(应只有 README.md);
  2. 第二层:用假路径跑 setup-signing.sh:
export APP_CERT_FILE=/tmp/not-exist.cer
export PROFILE_FILE=/tmp/not-exist.p7b
export KEYSTORE_FILE=/tmp/not-exist.p12
export KEY_ALIAS=officeflow
bash scripts/setup-signing.sh
  1. 记录它的报错(应该报"文件不存在"并不写任何文件);
  2. 换成真实存在的文件(可以用 touch 造三个空文件)再跑一次,检查生成的 build-profile.signing.local.json5;
  3. 确认 git status 里看不到这个文件(被忽略)。

验收 checklist

  • [ ] 假路径时脚本退出码为 1,且没有写出任何文件;
  • [ ] 三个空文件时脚本成功生成片段,且两个口令字段为空字符串;
  • [ ] git status --porcelain 里不出现 build-profile.signing.local.json5;
  • [ ] 脚本末尾打印的三种方式(A 本地 DevEco / B 手工合并 / C CI)你都读懂了。

任务 E:验证版本注入的"拒绝静默跳过"(必做)

  1. 正常运行 --dry-run,记录 versionCode / versionName 的"before → after";
  2. 把 AppConst.ets 里的 static readonly IS_RELEASE: boolean = false; 改成 static readonly isRelease: boolean = false;;
  3. 再跑 node scripts/inject-version.mjs --version-code=1000001 --version-name=1.0.1 --dry-run;
  4. 记录报错信息;然后改回。

验收 checklist

  • [ ] 改坏字段名后脚本 exit 1,且提示里说清了"若字段名或类型改了,请同步修改本脚本的匹配规则(本脚本拒绝静默跳过)";
  • [ ] 改坏后没有写入任何文件;
  • [ ] 还原后再次运行正常。

任务 F:把门禁接进真实的 CI(进阶)

在 GitHub Actions(或你们的 CI 平台)里配一条流水线,要求:

  • 触发:push 到 main 与所有 PR;
  • 阶段 1:SKIP_BUILD=1 bash scripts/ci.sh(不装 DevEco 的轻量 runner 上就能跑);
  • 阶段 2:在装了 DevEco 的 runner 上跑 bash scripts/ci.sh;
  • 阶段 3:release 分支(如 release/*)上跑 bash scripts/ci.sh --release,签名材料从 Secret 注入;
  • 产物:把 build-artifacts/ 作为构建产物上传。

验收 checklist

  • [ ] PR 上能看到三项门禁的独立输出;
  • [ ] 故意打破一条依赖规则,PR 变红;
  • [ ] 阶段 1 的耗时显著短于阶段 2(说明"便宜检查前置"确实起作用);
  • [ ] Secret 里没有任何明文口令进入日志(检查 CI 日志,口令应被平台自动打码或根本不打印)。

任务 G:让 feature_collab 与 lib_distributed 合流(进阶)

这是交付前检查清单第 4 项的具体执行。按 11/07 的"已知不一致"表逐项对齐,并在完成后:

  • 删除两个文件头的"假设契约"注释(契约已对齐,注释成了误导);
  • 真机双设备重新验收冲突闭环;
  • 确认 check-deps.mjs 仍通过。

验收 checklist

  • [ ] grep -rn "prewarm\|release()\|DistributedPermission" feature_collab/ 无结果;
  • [ ] DraftSyncService 使用 DraftPayload + DraftMerge.merge(DraftSide, DraftSide);
  • [ ] 真机冲突闭环可复现;
  • [ ] 两个文件头的"假设契约"注释已删除。

8.9 延伸阅读

  • 官方文档 → 应用开发 → DevEco Studio → 工程构建 → hvigorw 命令行构建与任务列表
  • 官方文档 → 应用开发 → DevEco Studio → 应用签名 → Signing Configs 与自动签名
  • 官方文档 → 应用开发 → AppGallery Connect → 证书、App ID 和 Profile(申请流程)
  • 官方文档 → 应用模型 → 应用配置 → build-profile.json5 字段参考(compileSdkVersion / compatibleSdkVersion / targetSdkVersion)
  • 官方文档 → 应用模型 → 应用配置 → module.json5 字段参考(routerMap / deviceTypes / requestPermissions)
  • 官方文档 → 应用开发 → 应用测试 → 混淆规则(obfuscation-rules.txt 与 keep 规则)
  • 官方文档 → 应用开发 → 命令行工具 → hdc(安装与调试)
  • 教程内:01 环境搭建与工具链、09 工程化与模块化架构、10 测试与上架发布、11/02 网络层与统一错误处理、11/07 多端适配与分布式协同

← 上一节:11/07 多端适配与分布式协同 | 回到教程目录

📄 第 8 章 性能与内存优化 08-performance.md

性能优化的唯一门槛不是技术,而是证据。没有数据的"优化"是赌博:你可能只是把一段代码从 A 挪到 B,同时把另一处拖慢,最终谁也说不清。这一章给出的是一套可复制的取证流程,而不是一堆零散的技巧。


8.0 本章目标

  1. 能用「问题 → 证据 → 修复 → 复测」四段式描述一个性能问题,每一段都能被他人复核。
  2. 能对常见渲染症状(白屏、掉帧、输入延迟)说出可能根因,选择正确的定位手段,并区分 IDE 分析能力(DevEco Profiler、dump 解析、Code Scanner)与 运行时 API(hiTraceMeter、hidebug)。
  3. 能对启动过程分段打点,量化各阶段耗时,并指出哪一段该延后到首帧之后。
  4. 能识别四类内存泄漏(监听器、定时器、静态缓存、单例持 Context),并说出各自的证明方法。
  5. 能为工程建立三项性能基线(冷启动时长、列表滚动帧率、峰值内存),并给出回归判定阈值。

8.1 性能工作方法:四段式

8.1.1 为什么必须是四段式

性能问题的特点是"归因难"。没有固定流程,团队很容易陷入两种失败模式:凭直觉改(改完更快就说优化成功)与一次改多处(变快了但不知道是哪一处起的作用,下次出问题无从下手)。

四段式的价值在于每一段都留下可复核的痕迹:

段 要写什么 不合格的样子 合格的样子
问题 可复现的现象 + 复现步骤 + 判定为问题的量化标准 "页面有点卡" "审批列表 800 条,快速滑动时帧率从 60 掉到 32,掉帧集中在第 200~400 条"
证据 采集工具 + 原始数据 + 数据指向的根因 "我看了下感觉是布局问题" "Frame 泳道显示单帧布局耗时 22ms,其中 Flex 嵌套 7 层;CPU 泳道主线程在同帧执行了 3 次全量 diff"
修复 单一变更 + 为什么这个变更能消除根因 "顺手也把网络请求 timeout 调了" "把列表项内 7 层 Flex 改为 1 层 RelativeContainer + 固定尺寸,消除重复测量"
复测 同设备、同数据量、同操作路径下的前后对比数据 "明显快了" "帧率 32 → 58,单帧布局耗时 22ms → 4ms,其余指标无回退"

**铁律:一次只改一处。**如果你改了 A 和 B,结果是"变快了 30%",那你既不知道 A 是否有效,也不知道 B 是否掩盖了 A 的负面作用。

8.1.2 先建基线,再谈优化

优化前必须先有"没优化时的数字"。基线要固化三件事,否则复测不可比:

  1. 设备:具体型号 + 系统版本(同一台,不要换机对比)。
  2. 数据量:列表条数、图片张数、缓存命中情况(用固定测试数据集,不要用真实随机数据)。
  3. 操作路径:从哪个页面进入、滑动几次、停留多久,写成可重放步骤。

术语:基线(baseline) ── 优化前在固定条件下测得的指标值。没有基线的"优化"无法证明有效,也无法在 CI 上做回归门禁。

8.1.3 症状 → 根因 → 手段速查表

症状 可能根因 定位手段
冷启动白屏时间长 启动期同步 IO、重初始化、await 阻塞首帧 分段打点(8.3.5)+ Profiler 冷启动泳道
列表滑动掉帧、卡顿 布局嵌套过深、单帧构建节点过多、未复用时创建新对象 Profiler Frame 泳道 + LazyForEach 复用核查
输入延迟、点击无响应 主线程被长任务(同步 IO、大循环、复杂计算)占用 CPU 泳道主线程火焰图,找 > 16ms 的长任务
内存持续增长不回落 监听器/定时器未注销、静态缓存无上限、单例持 Context 反复进出页面比 PSS(8.6)+ 内存快照引用链
退出页面后仍有日志/回调 注册未注销 日志时间戳 + 快照中查找回调持有者
图片列表卡顿、内存尖峰 按原图尺寸解码、缓存无上限 解码尺寸 vs 显示尺寸对比 + Profiler 内存曲线
动画卡顿 每帧触发状态刷新、在动画回调里做重计算 Frame 泳道 + 刷新边界(8.2.2)核查
页面切换整体变慢 路由目标页在 aboutToAppear 做重活 路由耗时打点 + CPU 泳道

8.2 渲染性能

8.2.1 布局嵌套与测量成本

设计思路:布局的代价来自"测量"次数,而不是节点数本身。每多一层容器,父容器为了算出自己的尺寸,要多一轮子节点测量。嵌套 Flex 尤其贵——Flex 需要按主轴/交叉轴分配剩余空间,会触发多轮测量。

做法 代价
7 层嵌套 Flex/Column 实现"左中右" 每帧多轮测量,长列表里被放大 N 倍
一层 RelativeContainer + 固定尺寸 一次测量,锚点直接定位
在 build() 里做 filter()/sort()/字符串拼接 每次刷新重算,且与状态规模成正比

关键实践:

  • 能用固定尺寸就不用自适应尺寸;给了确定的 width/height 的容器,框架可以跳过对子节点的尺寸探测。
  • 长列表项内不要嵌套滚动容器(List 套 List),内层滚动会导致外层复用失效。
  • build() 里只放"取值与拼装",所有计算提前到状态变更时(@Computed)完成。
@ComponentV2
struct ApprovalCard {
    @Param item: ApprovalItem = new ApprovalItem();

    // 计算放在 @Computed:只在依赖变化时重算,而不是每帧重算
    @Computed
    get statusText(): string {
        return ApprovalStatus.label(this.item.status);
    }

    @Computed
    get relativeTime(): string {
        // 注意:Date.now() 不是响应式的——这个值只在 item.updatedAt 变化时重算。
        // 要"随时间流逝刷新",需由页面级定时器更新一个 @Local now 并传入参与计算
        return TimeFormat.relative(this.item.updatedAt, Date.now());
    }

    build() {
        // 单层 RelativeContainer 取代 3 层 Column/Row 嵌套
        RelativeContainer() {
            Text(this.item.title).id('title').fontSize(16).alignRules({ left: { anchor: '__container__', align: HorizontalAlign.Start } })
            Text(this.statusText).id('status').fontSize(14).alignRules({ right: { anchor: '__container__', align: HorizontalAlign.End } })
            Text(this.relativeTime).fontSize(12).alignRules({ left: { anchor: 'title', align: HorizontalAlign.Start }, top: { anchor: 'title', align: VerticalAlign.Bottom } })
        }
        .height(72)
    }
}

版本门槛:本教程基线为 API 26;@Computed 属于状态管理 V2 能力,自 API 12 起可用,兼容下限 20 设备可直接使用。

8.2.2 刷新边界与观测粒度

设计思路:ArkUI 的刷新以"使用该状态的组件"为边界。观测粒度决定刷新范围:

观测方式 粒度 后果
@Trace 单个字段 属性级 只有读该字段的组件刷新
@ObservedV2 类里只标 @Trace 到需要的字段 字段级 未标记的字段变化不触发刷新(也可能导致"该刷不刷")
一个巨型对象包住整个页面状态 组件级 任一字段变化 → 整页重建
在父组件读状态后以 @Param 传给子组件 传递级 父刷新 → 所有子刷新

关键实践:

  • 状态按"谁用谁读"切分:列表项的状态不要放在页面级大对象里,每个 item 自己持有 @ObservedV2 实例。
  • 组件切小不是为了好看,是为了收窄刷新边界。一个组件刷新代表它整棵子树重建。
  • @Param 是单向同步:父到子的更新会同步下来,子组件内部的修改不回传给父;深嵌套结构容易让父级刷新放大到全部子级。

8.2.3 LazyForEach 复用与 cachedCount

长列表必须用 LazyForEach + IDataSource。三个易错点:

// 标准 IDataSource 实现(V2 下用 @ObservedV2 管理数据本身)
class ApprovalDataSource implements IDataSource {
    private items: ApprovalItem[] = [];
    private listeners: DataChangeListener[] = [];

    totalCount(): number { return this.items.length; }
    getData(index: number): ApprovalItem { return this.items[index]; }

    registerDataChangeListener(listener: DataChangeListener): void {
        if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener); }
    }
    unregisterDataChangeListener(listener: DataChangeListener): void {
        const pos: number = this.listeners.indexOf(listener);
        if (pos >= 0) { this.listeners.splice(pos, 1); }
    }

    /** 增量更新:只通知变更区间,避免 notifyDataReload 引发全量重建 */
    appendBatch(newItems: ApprovalItem[]): void {
        const start: number = this.items.length;
        this.items = this.items.concat(newItems);
        this.listeners.forEach((l: DataChangeListener) => {
            // 每条新增都必须通知一次:只调一次 onDataAdd 会让框架认为只加了一条,
            // 其余条目不渲染。是否提供按区间的批量通知 API 以官方 API 参考为准
            for (let i: number = 0; i < newItems.length; i++) {
                l.onDataAdd(start + i);
            }
        });
    }
}
List() {
    LazyForEach(this.dataSource, (item: ApprovalItem) => {
        ListItem() { ApprovalCard({ item: item }) }
    }, (item: ApprovalItem) => `${item.id}:${item.version}`)   // key 必须稳定且随内容变化
}
.cachedCount(4)          // 屏幕外预建 4 条,滑动时不再等待构建
.height('100%')

三件事必须做对:

  1. key 不能用 index。index 会让"删除第 3 条"变成"第 3 条及之后全部内容变更",复用彻底失效,同时引发图片错位。
  2. key 要包含会变的内容标识(如 id:version)。只用 id 时,内容更新不会触发该项重建。
  3. cachedCount 不是越大越好。它决定屏幕外预建条数:太小则滑动时被动等待构建(掉帧),太大则首屏构建压力与内存上升。通常取"一屏可见条数"量级(如 4~8),并在真机上滑动实测调整。

术语:复用(recycle) ── 列表滑动时把移出屏幕的组件对象回收,用于下一个要进入屏幕的条目,避免反复创建/销毁。复用生效的前提是 key 稳定,否则框架无法判断"这是同一条数据"。

8.2.4 图片尺寸与解码

图片导致的性能问题几乎都源于解码尺寸大于显示尺寸。

做法 后果
3 MB 原图直接交给 80×80 的缩略图位 解码出 12 MP 位图,单张占内存数十 MB,列表里叠加直接 OOM
按显示尺寸请求/解码 内存与解码耗时都按面积比例下降

实践:列表用缩略图(服务端或本地生成的 thumbUri),详情页才取原图;占位图与目标图使用相同宽高比,避免加载完成后的布局跳变;超大图启用渐进式加载。

8.2.5 动画与帧率

  • 用声明式属性动画/animateTo 驱动,不要在动画回调里逐帧改状态。逐帧写状态会触发逐帧布局与重绘,等于把 60 次/秒的重活交给主线程。
  • 动画只改"能触发合成"的属性(位置、透明度、缩放),尽量不改会引起重新测量的属性(宽高、字体大小、约束)。
  • 动画结束后清理引用,避免动画对象被长生命周期对象持有。

8.3 启动优化

8.3.1 启动阶段拆分

先把启动切成可度量的段,才谈得上优化:

阶段 发生了什么 该做什么 不该做什么
进程创建 → AbilityStage.onCreate 运行时与模块环境初始化 只注册模块级能力(如路由表) 读配置、建数据库连接
UIAbility.onCreate 应用级初始化入口 注册应用级会话、日志、埋点骨架 同步 IO、大循环、await 权限弹窗
onWindowStageCreate → loadContent 窗口与首帧内容加载 加载首屏页面 预加载非首屏页面
首帧渲染完成 用户第一次看到内容 骨架屏已可见 首帧内做网络往返
首屏可交互 数据填充完成 填充真实数据 —

判据:onCreate 里每一项都应该能回答"不做它,首帧会错吗?"不能,就延后。

8.3.2 懒加载与延迟初始化

// 反例:在 onCreate 里同步做完所有初始化
onCreate(): void {
    this.db = openDatabaseSync();      // 同步 IO
    this.config = readConfigSync();    // 同步 IO
    this.prefetchAll();                // 大循环 + 网络
}

// 正例:分级初始化
onCreate(): void {
    Logger.init();                     // 极轻,后续日志都依赖它
    SyncSession.getInstance().start(this.context);   // 不 await(见 7.6.2)
    // 其余推到首帧之后
    setTimeout(() => { this.warmUpAsync(); }, 0);
}

延迟初始化的三种手段及取舍:

手段 适用 风险
首帧后 setTimeout(..., 0) 不依赖首帧结果的预热 与用户首次操作争抢主线程
首次使用时初始化(lazy getter) 能力可能不被用到 首次使用处出现卡顿,需配骨架/loading
空闲回调 / 分批任务 大批量预热(如资源预解码) 实现复杂度高

注意:延迟初始化不等于"扔给后台线程"。ArkTS 的主线程是 UI 线程,把重活从首帧前移到首帧后,收益是"用户先看到内容",而不是"重活消失了"。真正的耗时削减要靠减少工作量本身。

8.3.3 首屏数据预取与骨架屏

  • 预取:在 onWindowStageCreate 之后、首帧之前发起首屏数据的网络请求(异步,不阻塞),首帧用骨架屏占位。这样网络往返与首帧渲染并行。
  • 骨架屏:结构与真实内容一致(行数、高度、圆角),避免数据到达后的大幅布局跳变(跳变会让用户感觉"闪")。
  • 不要为了"看起来快"而隐藏 loading 后立刻显示空白。空白帧比骨架屏更容易被感知为卡顿。

8.3.4 避免启动期同步 IO

同步 IO 在启动期是杀伤力最大的一类代码:它直接占用主线程,且耗时随文件大小/设备存储性能波动。

反例 正例
fs.openSync / 同步读配置 异步读,或把配置编译期内置进代码
同步查询数据库拿首屏数据 异步查询 + 骨架屏;或改为内存缓存优先
启动时同步解析大 JSON 分包加载 / 只解析首屏需要的字段

8.3.5 关键代码:启动耗时分段打点

// lib_base/src/main/ets/perf/LaunchTrace.ets
import { systemDateTime } from '@kit.BasicServicesKit';
import { hiTraceMeter } from '@kit.PerformanceAnalysisKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

/**
 * 用系统开机时长做单调时钟,避免用户改系统时间导致数据异常。
 * 每个阶段只打两次数:进入与离开,差值即该段耗时。
 */
export class LaunchTrace {
    private static readonly marks: Map<string, number> = new Map();
    private static readonly result: Map<string, number> = new Map();

    static begin(stage: string): void {
        LaunchTrace.marks.set(stage, LaunchTrace.now());
        hiTraceMeter.startTrace(stage, 1);          // 同步泳道打点,可在 Profiler 中看到
    }

    static end(stage: string): void {
        hiTraceMeter.finishTrace(stage, 1);
        const start: number | undefined = LaunchTrace.marks.get(stage);
        if (start === undefined) { return; }
        LaunchTrace.result.set(stage, LaunchTrace.now() - start);
    }

    /** 包住一段可能抛异常的初始化,保证 end 一定被调用 */
    static async wrap<T>(stage: string, task: () => Promise<T>): Promise<T> {
        LaunchTrace.begin(stage);
        try {
            return await task();
        } finally {
            LaunchTrace.end(stage);
        }
    }

    /** 首屏可交互时调用,输出完整分段表供归档 */
    static report(): string {
        const lines: string[] = ['--- launch trace ---'];
        LaunchTrace.result.forEach((cost: number, stage: string) => {
            lines.push(`${stage}: ${cost}ms`);
        });
        const report: string = lines.join('\n');
        hilog.info(0x0001, 'LaunchTrace', '%{public}s', report);
        return report;
    }

    private static now(): number {
        // 毫秒级单调时间
        return systemDateTime.getUptime(systemDateTime.TimeType.STARTUP, false);
    }
}

在 UIAbility 上挂分段:

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    LaunchTrace.begin('ability.onCreate');
    Logger.init();
    SyncSession.getInstance().start(this.context);   // 故意不 await
    LaunchTrace.end('ability.onCreate');
}

onWindowStageCreate(windowStage: window.WindowStage): void {
    LaunchTrace.begin('windowStage.loadContent');
    windowStage.loadContent('pages/Index', (err) => {
        LaunchTrace.end('windowStage.loadContent');
    });
}

首屏页在数据填充完成后:

aboutToAppear(): void {
    LaunchTrace.begin('firstScreen.data');
}
// 数据回调中
LaunchTrace.end('firstScreen.data');
LaunchTrace.report();      // 归档进日志,CI 上可提取

版本门槛:hiTraceMeter 打点需要在 DevEco Profiler 中录制才有泳道显示;线上环境不连接 Profiler 时打点开销极小,可保留。若需完全关闭,用自建布尔开关(如编译期常量 const TRACE_ENABLED: boolean = true,在 begin / end 首行 if (!TRACE_ENABLED) { return; } 短路),不要在每个调用点写 if。


8.4 内存问题

8.4.1 泄漏四件套

内存泄漏在受管语言里不是"内存丢了",而是"本该死掉的对象被长生命周期的东西引用着"。ArkTS 场景下,四类占绝大多数:

类型 典型代码 为什么泄漏 证明方法
监听器未注销 hub.addListener(this.onDevices) 但无 removeListener 单例持有页面实例的闭包,页面永远不被回收 反复进出页面,快照中该页面存在 N 份
定时器未清理 setInterval(...) 未 clearInterval 定时器持有回调,回调持有组件状态 退出页面后日志仍在按间隔打印
静态缓存无上限 static cache: Map<string, X> 只写不删 静态成员生命周期 = 进程生命周期 缓存 size 只增不减;PSS 随操作单调上升
闭包/单例持有 Context 单例字段 private ctx: UIAbilityContext UIAbilityContext 持有整个 Ability,进而持有整棵页面树 快照中从 GC Root 到 UIAbilityContext 的引用链

术语:PSS(Proportional Set Size) ── 进程实际占用的物理内存分摊值,是移动端衡量"这个应用真占了多少内存"最常用的指标。观察泄漏看的是多次操作后是否回落到基线,而不是绝对值大小。

8.4.2 为什么"退出页面内存不降"就足以立案

内存有 GC 延迟,单次观察不可信。可复现的泄漏判定是:

  1. 记录进入前的 PSS(P0);
  2. 反复"进入页面 → 操作 → 返回" 20 次;
  3. 手动触发一次 GC(DevEco Profiler 提供),等待 3 秒,记录 P1;
  4. 若 P1 - P0 与次数近似线性增长,且远超单次页面正常占用,则为泄漏。

线性增长是泄漏的指纹:真正的缓存增长会收敛(命中后趋于稳定),泄漏不会。

8.4.3 大对象与图片缓存策略

对象 不该怎么做 该怎么做
图片位图 全量原图常驻内存 Map LRU + 内存上限(如按可用内存 1/8),超限逐出;磁盘缓存兜底
长列表数据 一次性把 10000 条放进状态 分页 + 窗口化,只保留可见区 ± cachedCount
大 JSON 整个解析后常驻 只保留需要的字段;大字段按需解析
日志 无上限数组堆积 环形缓冲(固定容量)+ 定期落盘
// 有上限的 LRU:容量是硬约束,不是"希望"
export class LruCache<V> {
    private readonly capacity: number;
    private readonly map: Map<string, V> = new Map();

    constructor(capacity: number) { this.capacity = capacity; }

    get(key: string): V | undefined {
        const value: V | undefined = this.map.get(key);
        if (value !== undefined) {          // 命中:移到最近使用端
            this.map.delete(key);
            this.map.set(key, value);
        }
        return value;
    }

    put(key: string, value: V): void {
        if (this.map.has(key)) { this.map.delete(key); }
        this.map.set(key, value);
        while (this.map.size > this.capacity) {     // Map 保持插入顺序,首个即最久未用
            const oldest: string | undefined = this.map.keys().next().value;
            if (oldest === undefined) { break; }
            this.map.delete(oldest);
        }
    }

    get size(): number { return this.map.size; }
    clear(): void { this.map.clear(); }
}

8.4.4 组件销毁时的资源释放清单

把这个清单做成代码评审的固定检查项:每个 aboutToAppear 里出现的注册,都必须在 aboutToDisappear 有对应的注销。

资源 注册处 释放处 易漏点
事件监听 on('xxx') / addListener off('xxx') / removeListener 用内联箭头函数导致 remove 失败
定时器 setInterval clearInterval 只 clear 了一处,分支里还有一处
动画 animateTo / 属性动画 停止动画或置空引用 动画回调里继续读写已销毁组件状态
网络请求 rcp 请求发出 页面销毁时取消未完成请求 回调里操作已销毁组件状态
订阅 数据源 registerDataChangeListener unregisterDataChangeListener LazyForEach 的 DataSource 忘记注销
大对象 页面内缓存 clear() 交给静态缓存后忘记设上限
@ComponentV2
struct ApprovalDetail {
    @Local detail: ApprovalDetailModel = new ApprovalDetailModel();
    private timerId: number = -1;
    private onChanged = (payload: ApprovalPayload): void => { this.detail.apply(payload); };

    aboutToAppear(): void {
        ApprovalBus.getInstance().addListener(this.onChanged);
        this.timerId = setInterval(() => { this.pollOnce(); }, 5000);
    }

    aboutToDisappear(): void {
        ApprovalBus.getInstance().removeListener(this.onChanged);
        if (this.timerId !== -1) {
            clearInterval(this.timerId);
            this.timerId = -1;
        }
        this.detail.release();     // 释放页面内持有的图片/大字段
    }
}

8.5 工具链

8.5.1 工具能回答什么问题

工具 类型 能回答 不能回答
DevEco Profiler — Frame IDE 能力 每帧耗时、掉帧位置、布局/绘制/构建各占多少 为什么这段代码慢(需要看 CPU)
DevEco Profiler — CPU IDE 能力 主线程火焰图、长任务、函数级耗时占比 函数内部的业务语义
DevEco Profiler — Memory IDE 能力 内存曲线、快照对比、对象引用链、GC 触发点 泄漏的"业务原因"
DevEco Profiler — Energy IDE 能力 耗电归因(网络、CPU、定位、唤醒) 具体代码位置
dump 文件解析 IDE 能力(DevEco Studio 26 新增) 崩溃/卡死现场的对象与线程快照,可离线分析 运行时的实时数据
Code Scanner 资源泄漏检查 IDE 能力(DevEco Studio 26 新增) 静态扫描出"注册未注销""未释放句柄"等模式 运行时真实泄漏;它只能提示可疑点
hiTraceMeter 运行时 API 自定义阶段的耗时打点,进 Profiler 泳道 未打点区域的耗时
hidebug 运行时 API 进程 PSS、原生堆大小等数值,可写入日志/上报 引用链与对象明细

误区:把 DevEco Studio 26 的 dump 文件解析与 Code Scanner 资源泄漏检查当成运行时 API。它们是 IDE 的静态/离线分析能力:Code Scanner 在编码与构建阶段扫描源码模式,dump 解析在 IDE 里打开已有 dump 文件。二者都不在设备运行时提供接口,因此不能写进应用代码,也不能用来做线上监控。

8.5.2 运行时 API:hiTraceMeter

除 8.3.5 的同步打点外,异步区间(跨 await、跨回调)用异步打点:

import { hiTraceMeter } from '@kit.PerformanceAnalysisKit';

const TASK_ID: number = 1001;

async function syncDraft(): Promise<void> {
    hiTraceMeter.startAsyncTrace('OfficeFlow:Sync', TASK_ID, 1);
    try {
        await uploadChunks();
        await pullRemoteChanges();
    } finally {
        hiTraceMeter.finishAsyncTrace('OfficeFlow:Sync', TASK_ID);   // 必须成对,否则泳道不闭合
    }
}

// 计数打点:适合"发生了多少次"的量化,例如每帧刷新次数
hiTraceMeter.traceByValue('OfficeFlow:RebuildCount', rebuildCount);

8.5.3 运行时 API:hidebug

import { hidebug } from '@kit.PerformanceAnalysisKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

/** 采一次内存三件套;单位 KB。建议在固定场景(如返回列表页后)采集 */
export function sampleMemory(tag: string): void {
    const pss: number = hidebug.getPss();                    // 进程 PSS
    const nativeHeap: number = hidebug.getNativeHeapSize();  // 原生堆
    const privateDirty: number = hidebug.getPrivateDirty();  // 私有脏页
    hilog.info(0x0001, 'MemProbe', '%{public}s pss=%{public}dKB nativeHeap=%{public}dKB privateDirty=%{public}dKB',
        tag, pss, nativeHeap, privateDirty);
}

用法要点:hidebug 给的是数值,价值在于"按固定场景打点 + 横向对比"。把它接进 8.7 的基线采集,就能在 CI 或灰度包上做自动回归。

版本门槛:hidebug 的部分接口在 API 20 与 26 之间存在子集差异。使用前先在官方 API 参考确认目标接口的"起始版本",不要在兼容下限设备上无条件调用——建议用 canIUse 兜底或包一层 try/catch。

8.5.4 推荐的分析顺序

分析性能问题有一条省时间的顺序,从外到内,从粗到细:

  1. 先看 Frame 泳道:确认"是不是渲染问题"。如果帧率正常,掉帧不是问题所在。
  2. 再看 CPU 泳道主线程:找出 > 16ms 的长任务,看它落在哪个函数。
  3. 怀疑内存时看 Memory 曲线 + 快照:曲线看趋势,快照看引用链。
  4. 怀疑耗电时看 Energy:确认是 CPU、网络还是唤醒导致。
  5. 最后用自定义打点(hiTraceMeter)把可疑区间切细,验证假设。

8.6 完整案例:单例持有 UIAbilityContext

下面是一个真实发生过的场景,按四段式完整走一遍。

8.6.1 问题

现象:审批列表 → 详情 → 返回,反复操作后应用内存持续上升;进入第 20 轮左右出现明显卡顿,第 30 轮附近被系统回收(后台被杀)。

复现步骤:列表页(800 条数据)→ 点击第 1 条 → 详情页停留 2 秒 → 返回 → 重复。每次循环后停留 1 秒。

判定为问题的量化标准:20 次循环后 PSS 相对基线增长超过 50 MB,且第 20 次的总量高于第 10 次的 1.5 倍(线性增长)。

8.6.2 证据

采集一:数值趋势(hidebug)

在返回列表页后、GC 后采样,得到:

循环次数 PSS (MB) 详情页对象实例数(快照统计)
0(基线) 88 0
5 142 5
10 196 10
20 305 20

两次采样:线性增长(每轮约 +10.8 MB),详情页对象实例数恰好等于循环次数。这已经能判定为泄漏,而不是缓存。

采集二:引用链(DevEco Profiler — Memory 快照对比)

在快照中查找 ApprovalDetailModel 实例,展开引用链,得到:

GC Root
  └── 静态字段 ApprovalCache.instance           (lib_base/ApprovalCache.ets)
        └── Map<string, ApprovalDetailModel> models
              └── ApprovalDetailModel #17
                    └── UIAbilityContext (持有 detailPage 引用)
                          └── 整棵详情页组件树

根因:ApprovalCache 是静态单例,缓存了 ApprovalDetailModel;而该 Model 在构造时被传入了 UIAbilityContext(为调用 resourceManager 读字符串)。UIAbilityContext 生命周期与 Ability 一致,且反向持有页面树,于是每次进入详情页就多一份页面树被静态 Map 钉住。

采集三:修复前代码

// 问题代码
export class ApprovalCache {
    private static instance: ApprovalCache | undefined = undefined;
    private models: Map<string, ApprovalDetailModel> = new Map();   // 无上限

    static getInstance(): ApprovalCache {
        if (ApprovalCache.instance === undefined) { ApprovalCache.instance = new ApprovalCache(); }
        return ApprovalCache.instance;
    }

    put(id: string, model: ApprovalDetailModel): void {
        this.models.set(id, model);       // 只写不删
    }
}

export class ApprovalDetailModel {
    // 反例:把 UIAbilityContext 作为构造参数长期持有
    constructor(private readonly ctx: common.UIAbilityContext) {}
}

8.6.3 修复

单点变更:让 ApprovalDetailModel 不再持有 UIAbilityContext,改为只持有 resourceManager(或改为传入 Context 的 applicationContext),同时给缓存加上限。

为什么这个变更能消除根因:引用链的最后一环是 UIAbilityContext——resourceManager 与 applicationContext 的生命周期是应用级,不反向持有页面树;即使 Model 被缓存住,也不会有页面树被钉死;加上限后即使仍有残留,也只保留最近 N 条。

// 修复后
import { common } from '@kit.AbilityKit';
import { resourceManager } from '@kit.LocalizationKit';

export class ApprovalDetailModel {
    // 只依赖读取资源所需的最小能力,不持有 UIAbilityContext
    constructor(private readonly resMgr: resourceManager.ResourceManager) {}

    static from(ctx: common.UIAbilityContext): ApprovalDetailModel {
        // 取应用级 Context 派生的 resourceManager,避免持有 Ability 级 Context
        return new ApprovalDetailModel(ctx.getApplicationContext().resourceManager);
    }
}

export class ApprovalCache {
    private static instance: ApprovalCache | undefined = undefined;
    private readonly models: LruCache<ApprovalDetailModel> = new LruCache<ApprovalDetailModel>(8);  // 硬上限

    static getInstance(): ApprovalCache {
        if (ApprovalCache.instance === undefined) { ApprovalCache.instance = new ApprovalCache(); }
        return ApprovalCache.instance;
    }

    put(id: string, model: ApprovalDetailModel): void { this.models.put(id, model); }
    get(id: string): ApprovalDetailModel | undefined { return this.models.get(id); }
    clear(): void { this.models.clear(); }
}

若确实需要持有 Context,把长生命周期的 Context 统一从"Ability 级"降到"应用级",或在必须持有临时 Context 时使用弱引用:

export class PageScopedBridge {
    // WeakRef 不阻止对象被回收;取用时必须判空
    private ctxRef: WeakRef<common.UIAbilityContext> | undefined = undefined;

    attach(ctx: common.UIAbilityContext): void { this.ctxRef = new WeakRef(ctx); }

    doSomething(): void {
        const ctx: common.UIAbilityContext | undefined = this.ctxRef?.deref();
        if (ctx === undefined) {
            return;      // Context 已被回收,静默降级,不要抛异常打断业务流程
        }
        // 使用 ctx
    }
}

原则:能不持有就不持有;必须持有就持应用级;只能持临时对象时用弱引用 + 判空。

8.6.4 复测

同设备、同数据量、同操作路径(列表 800 条,循环 20 次,返回后 GC 采样):

指标 修复前 修复后 判定
20 次循环后 PSS 305 MB 93 MB 回落至基线 88 MB 的 6% 以内 ✅
第 10 次 / 第 5 次 PSS 比值 1.38(线性增长) 1.00(无增长) 增长趋势消失 ✅
详情页 Model 实例数(快照) 20 ≤ 8(LRU 上限) 与循环次数解耦 ✅
返回列表页后首帧耗时 118 ms 41 ms 未出现回退 ✅
30 次循环后是否被回收 是(后台被杀) 否 问题消除 ✅

结论归档:问题(线性增长 + 每轮 +10.8 MB)→ 证据(PSS 趋势 + 引用链到静态 Map)→ 修复(Model 不再持 UIAbilityContext + LRU 上限)→ 复测(PSS 回落、增长消失、其他指标无回退)。四段齐全,他人可按同样步骤复核。

误区:看到"内存增长"就直接加 clear() 或定时清理。清理掩盖的是"为什么会涨"——本例中即使每次返回都 clear(),只要引用链存在,在详情页停留期间的内存尖峰依然会触发系统回收风险,且 clear() 之后重新进入会重新分配,表现为"内存锯齿 + 反复卡顿"。


8.7 度量与回归基线

8.7.1 三项基线

只建三个指标,够用且可持续:

指标 定义 采集方式 建议目标(示例)
冷启动时长 进程创建 → 首屏可交互 LaunchTrace 分段 + 首屏数据完成回调 ≤ 1.5s(中端机)
列表滚动帧率 800 条数据快速滑动 10 秒 Profiler Frame 泳道 + displaySync 统计 平均 ≥ 55 FPS,1% Low ≥ 40
峰值内存 上述操作过程中的 PSS 最大值 hidebug.getPss 定时采样 ≤ 250 MB(中端机)

版本门槛:hidebug 与 hiTraceMeter 在兼容下限 20 上均可用,但具体接口集合需逐个确认起始版本;displaySync 用于帧统计时注意其在后台窗口下的行为,避免把"应用在后台"的低帧率算进基线。

8.7.2 debug 基线与 release 复核

必须两套数据,但不能混用:

项 debug release
用途 日常开发对比、定位问题 对外指标、回归门禁的最终口径
特点 有调试信息、无混淆、部分检查开启,通常偏慢 与用户实际一致
注意 只用于"改前 vs 改后"的相对比较 用于"是否达标"的绝对判定

错误做法:用 debug 包测出的 2.1s 说"我们的冷启动是 2.1s"。正确做法:debug 里看趋势,release 里下结论。

术语:玄学优化 ── 没有基线、没有证据、无法复现的"优化"。典型特征是结论只能由改动者本人复述,他人按同样步骤测不出差异。

8.7.3 回归门禁

把基线里最容易自动化的指标接进 CI(第 10 章 10.5 讲执行方式):

  • 冷启动时长:用 hdc 启动应用并采集 LaunchTrace 日志,解析出分段耗时;超过上浮阈值(如 15%)则告警。
  • 峰值内存:跑一遍固定用例(打开列表 → 滚动 → 详情 → 返回 ×20),采集 PSS 序列;出现线性增长趋势即失败。
  • 帧率:需要设备侧稳定环境,通常放在每日回归而非每次提交。

门禁的意义不是"卡住提交",而是"让性能回退在当天被发现"。性能回退的修复成本随时间指数上升。


8.8 低端设备与多端降级

同一份代码要在手机上流畅、在低端机上可用,靠的是运行时能力判定,不是型号硬编码。

维度 高配策略 降级策略 判定方式
动效 完整转场与共享元素 缩短时长或改为淡入 运行时读取设备等级/内存档位
列表预建 cachedCount(8) cachedCount(2) 同上
图片解码 2× 显示尺寸 1× 显示尺寸 固定策略,按设备等级切换
首屏预取 全量预取 只预取首屏 网络类型 + 设备等级
日志 全量采集 只采错误 构建模式(release 降采样)

原则:降级开关的默认值必须是最安全的(不知道设备能力时用降级档),且降级路径要每天至少跑一次——否则它会在真出问题时才第一次被执行。


8.9 常见误区

误区 1:用 Date.now() 差值或 console 计时当性能数据

错误写法:const t = Date.now(); doWork(); console.log(Date.now() - t); 正确写法:阶段耗时用 hiTraceMeter 打点 + Profiler 泳道;需要代码内数值时用单调时钟(systemDateTime.getUptime),不用挂钟时间。 为什么错:Date.now() 受系统时间调整影响(用户改时间、时区同步)会出现负数或巨大值;console 输出本身有开销,且高频打印会干扰被测代码的耗时。

误区 2:一次改多处"顺手优化"

错误写法:把"布局展开、图片改成懒加载、网络超时改成 5s"放在同一次提交里,测出"快了 30%"。 正确写法:一次只改一处,每处单独复测;一次提交里只包含一个可归因的变更。 为什么错:多处变更让结果无法归因。如果 A 其实起了反作用、被 B 的收益掩盖,下次在别的场景 A 就会暴露成新的性能问题,而你已经"验证过 A 没问题"。

误区 3:用 debug 包的数据对外声称性能指标

错误写法:"我们的冷启动是 2.1 秒"(数据来自 debug 包)。 正确写法:debug 数据只用于改前改后的相对对比;对外指标一律用 release 包在同型号设备上复核。 为什么错:debug 包通常未混淆、含调试信息、开启额外检查,耗时显著高于 release。用错口径会导致达标判断失真、CI 门禁阈值失效。

误区 4:用 setInterval 做"定时刷新 UI"

错误写法:setInterval(() => { this.refresh(); }, 1000) 每秒重建一次列表。 正确写法:数据变化时用事件/回调驱动刷新;确需轮询时用最长间隔 + 页面不可见时暂停,并在 aboutToDisappear 清理。 为什么错:定时刷新在主线程上周期性制造长任务,与滑动、动画争抢帧预算,表现为"滑动时一卡一卡";同时让设备无法进入低功耗状态,耗电明显。

误区 5:注册了监听器/定时器却不注销

错误写法:aboutToAppear 里 Bus.addListener(this.onChanged) 且没有对应的 removeListener。 正确写法:每个注册都配一个释放,且函数引用必须是同一个对象(用字段保存箭头函数,不要内联)。 为什么错:单例持有监听器 → 监听器持有组件闭包 → 组件永远不被回收。反复进出页面时,快照中会出现 N 份页面实例,PSS 随次数线性增长(见 8.6)。

误区 6:静态缓存没有容量上限

错误写法:private static cache: Map<string, X> = new Map(); 只 set 不 delete。 正确写法:静态缓存一律用带容量的 LRU(8.4.3),并提供 clear() 用于登出等场景。 为什么错:静态成员的生命周期等于进程生命周期,无上限缓存等于"用一次就永久占一份"。这类泄漏最隐蔽——它在单次使用中完全无害,只在长时间使用后才表现为内存告急。

误区 7:单例持有 UIAbilityContext

错误写法:class Repo { constructor(private ctx: common.UIAbilityContext) {} },并把 Repo 存进静态单例。 正确写法:只依赖最小能力(resourceManager、applicationContext);必须持临时 Context 时用 WeakRef + deref() 判空(8.6.3)。 为什么错:UIAbilityContext 反向持有整个 Ability 与页面树。静态单例持有它,就相当于把整棵页面树钉在进程里,是最典型、最高频的泄漏形态。

误区 8:LazyForEach 的 key 用 index

错误写法:(item, index) => index.toString()。 正确写法:``(item) => ${item.id}:${item.version}```。 **为什么错**:用 index` 作 key 时,"删除第 3 条"会被框架理解为"第 3 条及其后全部内容变更",复用完全失效、图片与状态错位;同时因为没有稳定身份,列表滚动会反复创建/销毁组件。

误区 9:把 Code Scanner 与 dump 解析当成运行时监控手段

错误写法:指望在代码里调用这些能力做线上泄漏检测。 正确写法:二者是 DevEco Studio 26 的 IDE 能力(静态扫描 / 离线 dump 分析);线上监控要靠 hidebug 采样 + 自定义埋点上报。 为什么错:它们不在设备运行时暴露接口,写进应用代码会直接编译不过;且静态扫描只能提示"可疑模式",无法证明真实泄漏,也不能替代运行时数据。


8.10 实践任务

任务:产出一份四段式性能报告

在 OfficeFlow 工程中完成以下工作:

  1. 选定一个真实症状:从"列表滑动掉帧"或"内存持续增长"中选一个,必须能在你手上的真机上稳定复现。
  2. 建立基线:固定设备、数据量(≥ 500 条)、操作路径,采集 8.7.1 的三项指标,记录为基线表。
  3. 取证并定位:用 Profiler + hiTraceMeter + hidebug 采集证据,写出根因,并给出引用链或火焰图片段。
  4. 单点修复:只改一处,说明为什么这个变更能消除根因。
  5. 复测:同条件重测三项指标,给出前后对比表与"无回退"的证据。
  6. 接入门禁:把其中至少一项指标做成 CI 可执行的脚本(可以是本地脚本),输出通过/失败。

约束:报告中的每个数字必须标注采集工具、设备型号、系统版本、debug 或 release;不得出现"明显改善""感觉流畅"这类无可复核性的表述。

验收 checklist

  • [ ] 报告中「问题」段包含可复现步骤与量化判定标准,他人能照做复现
  • [ ] 「证据」段包含至少两种工具的原始数据(数值 + 泳道/快照/火焰图任一)
  • [ ] 「证据」段给出了指向根因的引用链或函数级耗时占比,而不是"怀疑是 XX"
  • [ ] 「修复」段只有一处变更,且说明了它如何消除根因
  • [ ] 「复测」段给出了同设备、同数据量、同路径的前后对比表
  • [ ] 三项基线都有值,且标注了 debug / release 口径
  • [ ] 内存问题的复测中出现"多次操作后回落到基线"的证据
  • [ ] 帧率问题给出了 1% Low 或掉帧位置,而不是只给平均值
  • [ ] 至少有一项指标接入了可自动执行的检查脚本,并跑通一次"故意超标 → 失败"
  • [ ] 报告中不存在无法复核的形容词结论
  • [ ] 修复提交中未夹带与该问题无关的其他改动

交付物清单

交付物 形式 说明
性能报告 Markdown 四段式结构,含全部数据表
基线表 Markdown / CSV 三项指标 + 采集条件
证据附件 截图 / dump / 日志 Frame 泳道、内存快照引用链、LaunchTrace 日志
修复补丁 代码 diff 单一变更
复测对比表 Markdown / CSV 前后数值与判定
门禁脚本 Bash / Node.js 可执行,输出通过/失败
释放清单核对结果 Markdown 8.4.4 清单逐项核对

8.11 延伸阅读

  • 官方文档 → 应用性能优化 → 性能优化概览(优化的分层视角与官方推荐顺序)
  • 官方文档 → 应用性能优化 → ArkUI 渲染性能优化(布局、刷新、长列表的官方建议)
  • 官方文档 → 应用性能优化 → 应用启动性能优化(启动阶段划分与可延后事项)
  • 官方文档 → 应用性能优化 → 内存优化(常见泄漏形态与排查方法)
  • 官方文档 → 调试与优化 → DevEco Profiler 使用指南(Frame / CPU / Memory / Energy 泳道解读)
  • 官方文档 → API 参考 → @kit.PerformanceAnalysisKit(hiTraceMeter 与 hidebug 的接口与起始版本)
  • 本教程 第 10 章 测试与上架发布 中「测试接入 CI」一节

上一章:第 7 章 分布式能力 | 下一章:第 9 章 工程化与模块化架构

📄 第 9 章 工程化与模块化架构 09-modularization.md

模块化的目的不是"目录好看",而是把变更关进笼子:改一个功能时,受影响的范围应该是可预测的、可枚举的。这一章讲的是怎么用 HAP / HAR / HSP 三种产物、依赖方向约束和导出面治理,把"改一处、炸一片"变成"改一处、只影响一处"。


9.0 本章目标

  1. 能说清 HAP / HAR / HSP 在打包方式、可否独立安装、能否被多 HAP 共享、签名约束、包体影响五个维度上的差异,并为一个给定库选出正确形态。
  2. 能按「变化频率 + 复用度 + 团队边界」把一个单体工程拆成无环的模块图,并说清为什么依赖只能单向 feature → lib → base。
  3. 能为一个 HAR 写出只有稳定 API 的根 Index.ets,并说清 export * 与"内部目录直接导出"会带来什么后果。
  4. 能配置 oh-package.json5 的版本、file: 本地依赖、overrides 与锁文件,并跑通依赖图校验脚本自动检测 feature 横向依赖与依赖环。
  5. 能在 build-profile.json5 中配置多 product、buildMode、混淆 keep 规则与签名绑定。

9.1 三种产物形态对比

9.1.1 完整对比表

维度 HAP HAR HSP
本质 应用安装包 静态共享库 动态共享库
打包方式 编译为 .hap,多个 HAP 一起打成 App Pack 上架 编译为 .har;被引用时以源码/字节码形式参与宿主编译,最终打进宿主 HAP 编译为 .hsp,作为独立产物随宿主 HAP 一起安装
可否独立安装 ✅ 可(hdc install,市场也按 HAP 分发) ❌ 不可,自身不是可安装单元 ❌ 不可,必须随宿主 HAP 一起安装
能否被多 HAP 共享 每个 HAP 是独立单元 编译期可被多 HAP 引用,运行期每个 HAP 各持一份代码与资源 运行期多 HAP 共享一份代码与资源
签名 / bundle 约束 同一应用内所有 HAP 必须同 bundleName,各 HAP 用同一证书与 Profile 签名 无独立签名要求,最终随宿主签名 应用内 HSP 与宿主必须同一签名证书、同一 bundleName;跨应用分发的集成态 HSP 由工具链在集成时自动替换包名并重签
包体影响 基础单元,决定最低包体 被 N 个 HAP 引用 → 包体增加约 N 份 多 HAP 共用一份,多 HAP 场景下显著减小包体
调试复杂度 低,可直接安装运行 低,与宿主一同编译,可正常断点 中,需随宿主安装,产物更新需重新安装宿主
加载性能 — 无额外加载开销(已编译进宿主) 首启需加载共享库,存在一次加载开销
典型适用 应用入口(entry)、需按需分发的独立特性 HAP 业务特性模块、纯逻辑库、编译期复用的工具库 跨 HAP 共享的公共 UI 组件库、主题、大型基础库

9.1.2 选型决策

按顺序问:

  1. 它是应用的可安装入口,或需要独立按需分发吗? → HAP。
  2. 它会被多个 HAP 引用,且体积较大(UI 组件库、主题、大基础库)吗? → HSP。
  3. 其余情况 → HAR(默认选择,约束最少、调试最简单)。

版本门槛:集成态 HSP 自 API 12 起可用,仅 Stage 模型支持。在兼容下限 20 上无门槛。

术语:应用内 HSP 与集成态 HSP ── 两者是 HSP 的两种使用方式:

  • 应用内 HSP:与宿主 bundleName 强耦合,随本应用一起构建安装,不能独立发布,也不能给别的应用用;
  • 集成态 HSP:可发布到 OHPM 私仓,供同一组织内的其他应用集成复用。启用条件:工程级 build-profile.json5 配置 useNormalizedOHMUrl: true,HSP 模块级配置 integratedHsp: true;集成方引用时,工具链会自动替换包名并重签,使其与集成方应用一致。

跨应用共享的选型结论:同组织内可用集成态 HSP(走 OHPM 私仓);公开的三方库仍用 HAR(发布到 OHPM 中心仓,任何应用都可引用)。

典型误判:为了"减小包体"把一切塞进 HSP。后果是——单 HAP 应用(只有 entry)用 HSP 反而增加加载开销与调试复杂度,包体几乎不变。HSP 的收益只出现在"多 HAP 引用同一个库"的场景。

工程形态 推荐做法
单 HAP(只有 entry) 全部用 HAR;不要引入 HSP
多 HAP(entry + 若干 feature HAP) 被 ≥ 2 个 HAP 引用的大库用 HSP,其余用 HAR
同组织内多应用共享内部库 集成态 HSP(integratedHsp: true + OHPM 私仓)
需发布给公开第三方 HAR(发布到 OHPM 中心仓)

9.1.3 目录与产物的对应关系

office-flow/
├── entry/                      HAP —— 唯一可安装入口
├── feature_login/              HAR
├── feature_approval/           HAR
├── feature_message/            HAR
├── feature_collab/             HAR
├── lib_auth/                   HAR
├── lib_network/                HAR
├── lib_storage/                HAR
├── lib_log/                    HAR
├── lib_router/                 HAR
├── lib_distributed/            HAR
├── lib_base/                   HAR
├── lib_ui/                     HSP —— 被多个 feature HAP 共享
├── build-profile.json5         工程级构建配置(9.5)
├── oh-package.json5            工程级依赖声明(9.4)
└── scripts/check-deps.mjs      依赖图校验脚本(9.7)

9.2 模块拆分原则

9.2.1 三条拆分依据

依据 判断问题 拆的信号
变化频率 这个模块会多久改一次?和邻居同步改吗? 一个模块里有两块代码总是"一起改"或"从不一起改"
复用度 有多少地方会用它? 被 ≥ 3 个模块引用,却还挂在某个 feature 里
团队边界 谁负责维护? 两个团队在同一目录里改不同文件,评审经常互相阻塞

为什么这三条而不是"按层拆"或"按页面拆":按技术层(pages/、components/、utils/)拆,会让"改一个功能"横跨所有目录;按页面拆,会让共享逻辑无处安放。按变化频率与复用度拆,才能让"一次业务变更"落到最少的模块里。

9.2.2 依赖只能单向:feature → lib → base

entry(HAP,可安装入口)
  └── feature_*(HAR,业务特性)      ← 最不稳定,变化频率最高
        └── lib_*(HAR,能力库,不感知业务)
              └── lib_base(HAR,契约与工具)   ← 最稳定
lib_ui(HSP,公共组件与主题,只依赖 lib_base)

核心规则:

规则 允许 / 禁止 违反后的典型症状
feature → lib ✅ —
lib → feature ❌ 禁止 改一个 feature 导致 5 个 lib 重新编译,且 lib 无法单测
feature ↔ feature(横向) ❌ 禁止 两个 feature 互相 import,任一模块无法被删除或复用
base → 任何业务模块 ❌ 禁止 底层契约被业务污染,"公共库"再也不公共
同层 lib ↔ lib ⚠️ 慎用,仅允许指向更稳定的 lib 依赖环、初始化顺序问题

feature 之间禁止横向依赖,跨模块跳转走路由名常量:

// lib_router/src/main/ets/RouteNames.ets —— 唯一的路由名来源,所有模块都依赖它
export class ApprovalRoute {
    static readonly LIST: string = 'approval/list';
    static readonly DETAIL: string = 'approval/detail';
}

export class LoginRoute {
    static readonly INDEX: string = 'login/index';
}
// feature_message 想跳到审批详情:只依赖 lib_router,不 import feature_approval
import { ApprovalRoute } from 'lib_router';

this.pathStack.pushPathByName(ApprovalRoute.DETAIL, { approvalId: id });

为什么用路由名常量而不是直接 import 目标页面:直接 import 会立刻产生 feature_message → feature_approval 的横向依赖,两个模块从此刻起无法独立删除、无法独立编译、无法独立测试。路由名常量把"跳转"这件事降级为依赖契约(只依赖 lib_router),运行时由路由表解析到具体页面。

术语:稳定依赖原则(SDP, Stable Dependencies Principle) ── 一个模块应该依赖比自己更稳定的模块。稳定 = 被更多模块依赖、变化频率更低。反过来(稳定的模块依赖不稳定的模块)会让"稳定的那个"被迫跟着变,稳定性被摧毁——这也是为什么 lib_base 绝不能 import 任何 feature_*。

9.2.3 拆分时机:不早拆,也不晚拆

阶段 做法
早期(1~2 人、< 20 个页面) 单 HAP,只把 lib_base 的纯逻辑抽出来
中期(3~6 人、多业务线) 抽 lib_*(网络、存储、日志、路由),entry 内按 feature 目录组织
后期(> 6 人、多 HAP 分发) 把 feature 提为独立 HAR,公共 UI 提为 HSP,引入依赖校验门禁

过早拆分的代价:模块边界错一次,改边界的成本远高于当初不拆。过晚拆分的代价:所有代码都在 entry 里,任何改动都要全量编译,测试无法隔离。判断标准是"编译一次要多久 + 一次改动会命中几个人"。


9.3 导出面治理

9.3.1 根 Index.ets 是唯一导出面

每个 HAR / HSP 有且只有一个对外入口:模块根目录的 Index.ets。其它文件默认都是内部实现。

// lib_network/Index.ets —— 该模块唯一的对外契约
export { RcpClient } from './src/main/ets/client/RcpClient';
export { RequestInterceptor } from './src/main/ets/interceptor/RequestInterceptor';
export { ApiResult } from './src/main/ets/model/ApiResult';
export { AppError, ErrorCode } from './src/main/ets/error/AppError';
export type { ApiResultShape, PageQuery } from './src/main/ets/model/Types';

三条硬规则:

规则 反例 后果
禁止 export * export * from './src/main/ets/index-all' 一次性把内部实现全部暴露,之后任何内部改动都成了"破坏性变更"
内部实现目录不导出 导出 internal/TokenStore 调用方绕过门面直接依赖内部类,门面形同虚设
只导出稳定 API 导出为调试方便加的 dumpInternalState() 调试接口被业务依赖,无法删除

9.3.2 什么该导出、什么不该导出

该导出 不该导出
门面类(RcpClient) 具体的拦截器实现(只导出接口/基类)
跨模块传递的数据结构(ApiResult、AppError) 内部 DTO 与数据库实体
类型定义(export type) 内部常量表
需要被外部实现的接口(RequestInterceptor) 默认实现的具体类
稳定的枚举与错误码 实验性/未定型的 API

命名约定:内部实现放 internal/ 子目录,且不写入 Index.ets。这样"哪些是内部"在目录结构上一眼可见,评审时不必逐个查导出列表。

9.3.3 破坏性变更管理

变更类型 处理方式
新增导出符号 次版本号 +1(1.2.0 → 1.3.0),无需通知
新增可选参数 / 新增枚举成员 次版本号 +1,在 CHANGELOG 注明
删除导出 / 改签名 / 改语义 主版本号 +1,且必须走废弃周期

废弃周期(两版过渡):

/**
 * @deprecated 自 1.3.0 起废弃,将在 2.0.0 移除。请改用 {@link RcpClient.requestWithResult}。
 */
export function request(url: string): Promise<string> {
    // 保留一个版本的旧实现,内部转调新 API,保证暂无编译错误
    return RcpClient.getInstance().requestWithResult(url).then((r) => r.data);
}

为什么必须留废弃周期:HAR 在编译期依赖,一旦删除导出,所有引用方立刻编译失败。多模块工程里这意味着"上游一个提交,下游全部红"。留一个版本,让下游能独立排期升级。

版本门槛:ArkTS 支持 @deprecated 标注并会在 IDE 中给出删除线提示。但标注不是门禁——真正拦住破坏性变更的手段是 9.7 的依赖图校验脚本配合模块版本策略,以及"下游升级后编译"的 CI 任务。


9.4 依赖管理

9.4.1 ohpm 的版本语义

{
  "name": "feature_approval",
  "version": "1.0.0",
  "dependencies": {
    "lib_network": "file:../lib_network",
    "lib_base": "file:../lib_base",
    "lib_router": "file:../lib_router"
  },
  "devDependencies": {
    "@ohos/hypium": "1.0.21"
  }
}
写法 语义 建议
"1.2.3" 精确版本 生产依赖首选,可复现性最好
"^1.2.3" 允许 >=1.2.3 <2.0.0 对外部库可接受,但必须配锁文件
"~1.2.3" 允许 >=1.2.3 <1.3.0 谨慎使用
"file:../lib_x" 本地路径依赖 工程内模块间依赖的唯一方式
"file:../libs/xxx.har" 本地 HAR 产物 用于接入第三方未上架的 HAR

9.4.2 overrides 强制统一版本

当多个模块间接依赖同一个库的不同版本时,用 overrides 在工程根统一:

// 工程根 oh-package.json5
{
  "name": "office-flow",
  "version": "1.0.0",
  "overrides": {
    "lib_network": "file:lib_network",
    "@thirdparty/analytics": "2.4.1"
  }
}

使用原则:

  • overrides 只写在工程根,不要写在子模块里(分散会导致"局部生效",更难排查)。
  • 只在"确实需要强制统一"时用,不要当默认手段。它掩盖的是依赖版本冲突的根因(某个模块声明了过时的版本范围)。
  • 每次改动 overrides 都要在 CHANGELOG 记录,因为它会静默改变多个模块的实际依赖版本。

误区:把 overrides 写进子模块的 oh-package.json5 来"就地修版本"。overrides 只在工程根生效一处;散落到子模块既不生效也难排查(见 9.9 误区 6)。

9.4.3 锁文件必须入库

ohpm-lock.json5(或对应的锁文件)必须提交到版本库。

做法 后果
锁文件入库 本地与 CI 装到完全相同的版本,问题可复现
锁文件不入库 / 被 .gitignore 忽略 "本地好的、CI 坏了"的经典事故;某天上游发了新版,CI 静默装到新版本
手动编辑锁文件 与 oh-package.json5 不一致,ohpm install 时被覆盖,白改

规范:ohpm install 后如果锁文件有变更,必须与代码一起提交;CI 上使用 ohpm install --frozen-lockfile 之类的严格模式(若当前工具链不支持,则改为提交后校验锁文件未被修改)。

9.4.4 私有源与认证

# 配置私有仓库(示例域名,实际地址由团队运维提供)
ohpm config set registry https://ohpm.example.internal/repo/ohpm
ohpm config set @myorg:registry https://ohpm.example.internal/repo/ohpm

# 认证信息放用户级配置,绝不入库
ohpm config set //ohpm.example.internal/repo/ohpm/:_authToken ${OHPM_TOKEN}
# CI 侧(.deveco/ci-tasks.yaml 示例)
install:
  script:
    - ohpm config set registry $OHPM_REGISTRY
    - ohpm config set //ohpm.example.internal/repo/ohpm/:_authToken $OHPM_TOKEN
    - ohpm install --all

铁律:token 与签名材料只走环境变量或 CI secret,不允许出现在 oh-package.json5、.ohpmrc、.npmrc 等任何入库文件里。仓库里的 .ohpmrc 只能放 registry 地址,不能放凭据。

9.4.5 依赖分层白名单

模块 允许依赖 禁止依赖
lib_base 仅系统 Kit(@kit.*) 任何 lib_* / feature_*
lib_log lib_base + 系统 Kit feature_*、其它业务 lib
lib_*(通用) lib_base、lib_log + 系统 Kit feature_*、其它业务 lib(除下方显式例外)
lib_ui(HSP) lib_base + 系统 Kit 任何 feature_*、任何业务 lib
feature_* lib_*、lib_base、系统 Kit 其它 feature_*
entry 所有 feature_* 与 lib_* —

显式例外(须在 9.6.3 清单与脚本 RULES 中登记):lib_auth 允许依赖 lib_network(Token 注入与会话请求需要)。除显式登记者外,lib_* 之间不得互相依赖。

这张表可以直接编码进 9.7 的脚本,成为可执行的门禁,而不是靠口头约定。


9.5 构建定制

9.5.1 build-profile.json5 的整体结构

// 工程根 build-profile.json5
{
  "app": {
    "signingConfigs": [
      {
        "name": "default",
        "type": "HarmonyOS",
        // 路径指向本机文件;这些文件绝不入库(见 10.6)
        "material": {
          "certpath": "D:/keys/officeflow.cer",
          "storePassword": "****",
          "keyAlias": "officeflow",
          "keyPassword": "****",
          "profile": "D:/keys/officeflow.p7b",
          "signAlg": "SHA256withECDSA",
          "storeFile": "D:/keys/officeflow.p12"
        }
      }
    ],
    "products": [
      {
        "name": "default",
        "signingConfig": "default",
        "compatibleSdkVersion": "6.0.0(20)",
        "targetSdkVersion": "26.0.0",
        "runtimeOS": "HarmonyOS",
        "buildOption": {
          "strictMode": { "caseSensitiveCheck": true, "useNormalizedOHMUrl": true }
        }
      },
      {
        "name": "tablet",
        "signingConfig": "default",
        "compatibleSdkVersion": "6.0.0(20)",
        "targetSdkVersion": "26.0.0",
        "runtimeOS": "HarmonyOS"
      }
    ],
    "buildModeSet": [
      { "name": "debug" },
      { "name": "release" }
    ]
  },
  "modules": [
    { "name": "entry", "srcPath": "./entry", "targets": [{ "name": "default", "applyToProducts": ["default", "tablet"] }] },
    { "name": "feature_approval", "srcPath": "./feature_approval" }
  ]
}

版本门槛:compatibleSdkVersion 必须写成旧格式 6.0.0(20),targetSdkVersion 写成新格式 26.0.0——两者格式不同且不可互转(见第 0 章 0.1.2)。手敲容易出错,先用 IDE 生成再改数值。

9.5.2 多 product:解决什么

场景 用 product 的做法
手机 / 平板 / PC 需要不同资源或不同入口配置 每个形态一个 product,applyToProducts 指定哪些 module 参与
内测包与正式包共存(不同图标等差异) 两个 product,配不同 signingConfig。注意:包名(bundleName)由 AppScope/app.json5 统一决定,product 改不了包名——要不同包名必须独立工程
渠道定制(预置资源不同) product + 渠道资源目录,构建时选择

关键点:product 是构建目标,不是"运行时开关"。运行时能力判定(8.8 的设备降级)与 product 是两件事——能用运行时判定的,不要用 product 拆分,否则会产出多份需要分别测试的包。

9.5.3 buildMode 与混淆

// 模块级 build-profile.json5(如 feature_approval)
{
  "apiType": "stageMode",
  "buildOption": {},
  "buildOptionSet": [
    {
      "name": "release",
      "arkOptions": {
        "obfuscation": {
          "ruleOptions": {
            "enable": true,
            "files": ["./obfuscation-rules.txt"]
          }
        }
      }
    }
  ],
  "targets": [{ "name": "default" }]
}

obfuscation-rules.txt 的 keep 规则(宁少不多,每条都要有理由)。注意:属性名混淆的总开关是 -enable-property-obfuscation(写在规则文件里,默认关闭),不存在"在 build-profile 里关掉某几个属性名混淆"的配置项:

# 属性名混淆总开关(默认关闭;开启后 JSON 序列化/按名匹配的属性必须 keep)
-enable-property-obfuscation

# 保留入口与生命周期方法名(框架按名字反射调用)
-keep-global-name
EntryAbility
AbilityStage

# 保留 JSON 序列化用到的数据类属性名(与服务端字段一一对应)
-keep-property-name
approvalId
approverId
createdAt

# @Provider/@Consumer 按字符串名配对,属性名混淆会破坏配对
-keep-property-name
currentSessionProvider

注意:装饰器本身(@Trace / @Local / @Param 等)由编译器处理,不需要写进 keep 规则;需要 keep 的是"按名字查找/配对"的属性(如 @Provider/@Consumer 的名字匹配、序列化字段名)。

keep 规则的取舍:keep 越多,混淆收益越小;keep 越少,运行时崩溃风险越高。判断方法只有一条——每一版 release 包都要跑一遍全链路回归(第 10 章)。不要用"上一版没问题"推断"这一版也没问题",因为混淆结果随代码变化而变。

术语:属性名混淆 ── 把 approvalId 改写成 a 之类短名以减小包体。它对本应用内部代码无害,但如果属性名参与跨进程/跨网络的数据契约(JSON 字段名、反射查表),改名会导致解析失败。因此这类字段必须在 keep 规则里显式保留。

9.5.4 资源裁剪

手段 做法 收益
只打入需要的语言资源 resources/ 下按语言目录组织,product 只引用目标语言 多语言包体下降
剔除未使用的图片 定期用 IDE 的资源分析能力核查引用;删除确认无引用的资源 直接减小包体
同图多倍图按需 只为目标设备密度提供必要倍图 减小包体
product 差异资源走各自目录 渠道定制资源不放在公共 base 目录 避免每个 product 都带上全部资源

原则:资源裁剪在 release 构建中做,且每次裁剪后必须跑一遍视觉走查——被误删的资源在编译期不报错,只在运行时表现为空白或占位图。

9.5.5 签名绑定:buildModeBinder

Debug 与 Release 用不同签名材料是常见需求(debug 用调试证书、release 用发布证书)。用 buildModeBinder 把构建模式绑定到签名配置:

{
  "app": {
    "signingConfigs": [
      { "name": "debugConfig",  "type": "HarmonyOS", "material": { "certpath": "D:/keys/debug.cer", "storeFile": "D:/keys/debug.p12", "profile": "D:/keys/debug.p7b", "keyAlias": "debug", "storePassword": "****", "keyPassword": "****", "signAlg": "SHA256withECDSA" } },
      { "name": "releaseConfig","type": "HarmonyOS", "material": { "certpath": "D:/keys/release.cer", "storeFile": "D:/keys/release.p12", "profile": "D:/keys/release.p7b", "keyAlias": "release", "storePassword": "****", "keyPassword": "****", "signAlg": "SHA256withECDSA" } }
    ],
    // 把 buildMode 与签名配置绑定,避免手工切换时签错包
    "buildModeBinder": [
      { "buildModeName": "debug",   "mapping": [{ "productName": "default", "signingConfigName": "debugConfig" }] },
      { "buildModeName": "release", "mapping": [{ "productName": "default", "signingConfigName": "releaseConfig" }] }
    ]
  }
}

为什么必须绑定:手工在 IDE 里切换签名配置,是"打出一个 debug 签名的 release 包"这类事故的根源。绑定后,构建命令里只需指定 buildMode,签名自动跟随。

配套的路径与凭据处理:

# 签名材料路径走环境变量,避免把本机绝对路径提交进 build-profile.json5
# 实际做法:build-profile.json5 内写相对路径或由 CI 注入的变量,凭据由 CI secret 提供
hvigorw assembleHap --mode module -p product=default -p buildMode=release --no-daemon

签名材料绝不入库:.p12(私钥)、.cer(证书)、.p7b(Profile)三者一起入库等于把应用签名权交给了整个仓库的可见者。正确做法见 10.6。


9.6 团队规范

9.6.1 目录约定

<module>/
├── Index.ets                            ← 唯一导出面(HAR/HSP 必需)
├── oh-package.json5                     ← 依赖声明
├── build-profile.json5                  ← 模块构建配置
├── obfuscation-rules.txt                ← keep 规则(release 需要)
└── src/
    ├── main/
    │   ├── module.json5                 ← 模块声明(各形态模块均有,type 取 entry/feature/har/shared)
    │   ├── ets/
    │   │   ├── view/                    ← 页面与组件(XxxPage.ets / 名词.ets)
    │   │   ├── viewmodel/               ← XxxViewModel(@ObservedV2 + @Trace)
    │   │   ├── repository/              ← XxxRepository(唯一数据入口)
    │   │   ├── datasource/              ← XxxRemoteDs / XxxLocalDs
    │   │   ├── model/                   ← 数据结构(export type / interface)
    │   │   └── internal/                ← 内部实现,禁止写入 Index.ets
    │   └── resources/                   ← base / 语言 / 密度目录
    ├── ohosTest/                        ← Instrument Test(设备侧,见 10.1)
    └── test/                            ← Local Test(纯逻辑,见 10.1)

9.6.2 命名规范

对象 约定 例子
页面 XxxPage.ets ApprovalListPage.ets
组件 名词,无后缀 ApprovalCard.ets
ViewModel XxxViewModel ApprovalListViewModel
仓储 XxxRepository ApprovalRepository
数据源 XxxRemoteDs / XxxLocalDs ApprovalRemoteDs
路由名常量 XxxRoute ApprovalRoute.LIST
模块 feature_xxx / lib_xxx feature_approval / lib_network
模块根导出 固定 Index.ets —

9.6.3 完整模块清单表

模块 形态 职责 允许依赖 对应章节
entry HAP 应用入口、Ability、路由表注册、首屏 全部 feature_*、lib_* 04、11/01、11/08
feature_login HAR 登录、注册、会话状态 lib_auth、lib_network、lib_base、lib_router、lib_ui、lib_log 11/04
feature_approval HAR 审批列表 / 详情 / 提交 / 撤回 lib_network、lib_storage、lib_base、lib_router、lib_ui、lib_log 11/05
feature_message HAR 消息列表、通知落地页、已读同步 lib_network、lib_storage、lib_base、lib_router、lib_ui、lib_log 11/06
feature_collab HAR 跨设备协同面板与草稿协同 UI lib_distributed、lib_network、lib_storage、lib_base、lib_router、lib_ui、lib_log 07、11/07
lib_auth HAR Token 生命周期、单飞刷新、鉴权拦截 lib_network、lib_base、lib_log 11/04
lib_network HAR rcp 封装、拦截器链、错误映射 lib_base、lib_log 06、11/02
lib_storage HAR KVStore / 关系型存储封装、迁移 lib_base、lib_log 06
lib_log HAR 三级日志、无损采集、脱敏 lib_base 11/03
lib_router HAR 路由名常量、路由表注册、拦截钩子 lib_base、lib_log 04、11/01
lib_distributed HAR 设备管理、同步会话、冲突合并 lib_base、lib_log 07、11/07
lib_ui HSP 公共组件、主题、断点工具 lib_base 03、11/07
lib_base HAR 契约(Result / AppError)、工具、常量 仅系统 Kit 02、11/02

这张表就是评审依据:新增一个模块时,必须先在这张表里登记"允许依赖",否则 9.7 的脚本会直接拦下来。

9.6.4 评审检查项

  • [ ] 新增的跨模块 import 是否在允许依赖清单内?
  • [ ] 是否出现了 feature 之间的横向 import?(跳转应走路由名常量)
  • [ ] 是否有 export *?
  • [ ] 导出的符号是否都是稳定 API?内部实现是否在 internal/ 下?
  • [ ] 依赖是否有环?(跑 9.7 的脚本)
  • [ ] 是否有硬编码的绝对路径或凭据?
  • [ ] 破坏性变更是否走废弃周期,且更新了 CHANGELOG 与版本号?

9.7 依赖图校验脚本

9.7.1 目标

用一份可直接运行的 Node.js 脚本,扫描各模块的 oh-package.json5 与 module.json5,构建依赖图,并检测四类违规:

  1. feature 横向依赖:feature_* → feature_*
  2. 依赖环:任意环
  3. 方向倒置:lib_* → feature_*、lib_base → 任何本地模块
  4. 未登记的本地依赖:依赖了不在模块清单里的本地路径

9.7.2 脚本

// scripts/check-deps.mjs
// 用法: node scripts/check-deps.mjs [工程根目录]
// 退出码: 0 = 通过, 1 = 存在违规
// 注:本节脚本为教学简化版;工程版(JSON5 注释剥离、--strict 严格模式、违规分级)
//     见工程 scripts/check-deps.mjs,日常门禁以工程版为准。
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, resolve, basename, dirname } from 'node:path';

const ROOT = resolve(process.argv[2] ?? '.');
/** 允许依赖白名单:key 为模块名,value 为允许依赖的模块名前缀规则 */
const RULES = {
    // lib_base 只允许系统 Kit,不允许任何本地模块
    lib_base: [],
    // lib_* 只允许 lib_base 与其它 lib_(由下面的通用规则处理)
    lib_: ['lib_base', 'lib_'],
    // lib_ui (HSP) 只允许 lib_base
    lib_ui: ['lib_base'],
    // feature_* 只允许 lib_*
    feature_: ['lib_', 'lib_base'],
    entry: ['feature_', 'lib_']
};

function readJson5Like(path) {
    // 工程内的 oh-package.json5 / module.json5 含注释,**不是**无注释的合法 JSON;
    // 解析前必须先剥离注释行(工程版脚本还会处理块注释与尾逗号)
    const text = readFileSync(path, 'utf8').replace(/^[ \t]*\/\/.*$/gm, '');
    return JSON.parse(text);
}

function discoverModules() {
    const modules = [];
    for (const entry of readdirSync(ROOT, { withFileTypes: true })) {
        if (!entry.isDirectory()) { continue; }
        const dir = join(ROOT, entry.name);
        const pkgPath = join(dir, 'oh-package.json5');
        const modPath = join(dir, 'src', 'main', 'module.json5');
        if (!existsSync(pkgPath)) { continue; }
        const pkg = readJson5Like(pkgPath);
        let type = 'lib';
        if (existsSync(modPath)) {
            const mod = readJson5Like(modPath).module;
            type = mod.type ?? 'entry';   // entry / feature / shared
        }
        modules.push({ name: entry.name, dir, pkgName: pkg.name ?? entry.name, type, deps: pkg.dependencies ?? {} });
    }
    return modules;
}

/** 从依赖声明中解析出本地模块名:file:../lib_x → lib_x(相对模块目录解析,而非工程根) */
function resolveLocalDep(key, value, mod, modules) {
    if (typeof value !== 'string' || !value.startsWith('file:')) { return null; }
    const rel = value.slice('file:'.length);
    const abs = resolve(mod.dir, rel);   // file: 路径相对于声明它的模块目录
    if (rel.endsWith('.har') || rel.endsWith('.hsp')) { return null; }  // 二进制产物不算模块依赖
    const name = basename(abs);
    return modules.some((m) => m.name === name) ? name : `UNKNOWN:${name}`;
}

function ruleFor(moduleName) {
    if (moduleName in RULES) { return RULES[moduleName]; }
    for (const prefix of Object.keys(RULES)) {
        if (prefix.endsWith('_') && moduleName.startsWith(prefix)) { return RULES[prefix]; }
    }
    return null;   // 未登记模块 → 视为违规
}

const modules = discoverModules();
const graph = new Map();
const problems = [];

for (const mod of modules) {
    const edges = [];
    for (const [key, value] of Object.entries(mod.deps)) {
        const dep = resolveLocalDep(key, value, mod, modules);
        if (dep === null) { continue; }
        if (dep.startsWith('UNKNOWN:')) {
            problems.push(`[未登记依赖] ${mod.name} → ${dep.slice('UNKNOWN:'.length)}(不在模块清单中)`);
            continue;
        }
        edges.push(dep);

        // 规则一:feature 横向依赖
        if (mod.name.startsWith('feature_') && dep.startsWith('feature_')) {
            problems.push(`[feature 横向依赖] ${mod.name} → ${dep}:跨 feature 跳转应改用 lib_router 中的路由名常量`);
        }
        // 规则二:方向倒置
        const allowed = ruleFor(mod.name);
        if (allowed === null) {
            problems.push(`[未登记模块] ${mod.name}:请在 9.6.3 模块清单与脚本 RULES 中登记`);
        } else if (!allowed.some((p) => dep.startsWith(p))) {
            problems.push(`[依赖方向倒置] ${mod.name} → ${dep}:不在允许清单 [${allowed.join(', ')}] 内`);
        }
    }
    graph.set(mod.name, edges);
}
// 同一问题只报一次(未登记模块的每条依赖都会触发,去重避免刷屏)
const uniqueProblems = [...new Set(problems)];

// 规则三:环检测(DFS 三色标记)
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map([...graph.keys()].map((n) => [n, WHITE]));
const stack = [];

function dfs(node) {
    color.set(node, GRAY);
    stack.push(node);
    for (const next of graph.get(node) ?? []) {
        if (!graph.has(next)) { continue; }
        const c = color.get(next);
        if (c === GRAY) {
            const start = stack.indexOf(next);
            problems.push(`[依赖环] ${stack.slice(start).concat(next).join(' → ')}`);
        } else if (c === WHITE) {
            dfs(next);
        }
    }
    stack.pop();
    color.set(node, BLACK);
}
for (const node of graph.keys()) { if (color.get(node) === WHITE) { dfs(node); } }

// 输出报告
console.log(`扫描 ${modules.length} 个模块,检测到 ${uniqueProblems.length} 处问题`);
console.log('依赖图:');
for (const [name, edges] of graph) {
    console.log(`  ${name} → ${edges.length > 0 ? edges.join(', ') : '(无本地依赖)'}`);
}
if (uniqueProblems.length > 0) {
    console.error('\n违规明细:');
    uniqueProblems.forEach((p) => console.error(`  ${p}`));
    process.exit(1);
}
console.log('\n依赖图校验通过');

9.7.3 接入构建流程

# 本地手动执行
node scripts/check-deps.mjs .

# CI 中作为独立门禁(失败即阻断)
node scripts/check-deps.mjs . || { echo "依赖图校验失败,请先修复依赖方向"; exit 1; }
触发时机 说明
提交前手动跑一次 最快反馈
CI 的静态检查阶段 与 lint / Code Scanner 一起,失败即阻断后续构建
模块清单变更时 新增模块必须同时更新脚本的 RULES 与 9.6.3 的表

版本门槛:脚本依赖 DevEco 内置的受管 Node(见第 0 章 0.3),不要用系统 Node 执行。未使用 node: 前缀的 import 在旧版本 Node 上也可用;若团队 Node 版本较老,把 node:fs 改回 fs 即可。


9.8 演进路径

从单体到多模块,按阶段推进,每阶段都要"可编译 + 可回归":

阶段 目标 动作 验收
0 单体可跑 单 HAP,entry 内按 feature 建目录 功能与重构前完全一致
1 抽出最底层 建 lib_base(Result / AppError / 工具 / 常量),业务代码改为 import 依赖方向单向,lib_base 不 import 任何业务
2 抽出能力层 建 lib_network / lib_storage / lib_log / lib_router 各 lib 可独立跑 Local Test
3 抽出公共 UI lib_ui 建为 HSP(若存在多 HAP 计划)或 HAR 主题与组件在多页面复用一致
4 抽出业务特性 feature_* 提为独立 HAR,entry 只保留 Ability 与路由表 跨 feature 跳转全部改走路由名常量
5 固化门禁 依赖图校验脚本 + CI 阻断 + 模块清单表 故意造一条横向依赖,脚本必须拦下

迁移中的兼容策略:每阶段结束都保持"对外行为不变",先搬代码、后调整(不要在搬家的同时改逻辑)。搬迁与重构混在一起,出问题时无法判断是"搬错了"还是"改错了"。


9.9 常见误区

误区 1:把一切都塞进 HSP

错误写法:单 HAP 工程里把 lib_ui、lib_network 全部建成 HSP,理由是"减小包体"。 正确写法:单 HAP 场景全部用 HAR;HSP 只在"同一份库被 ≥ 2 个 HAP 引用"时才值得。 为什么错:HSP 的收益来自"多 HAP 共享一份"。单 HAP 场景下没有第二个人来分摊,反而白搭上首启加载开销与同签名同 bundle 约束,调试链路也更复杂。

误区 2:feature 之间互相 import

错误写法:feature_message 里 import { ApprovalDetailPage } from 'feature_approval'。 正确写法:只依赖 lib_router,用 this.pathStack.pushPathByName(ApprovalRoute.DETAIL, param) 跳转。 为什么错:横向依赖让两个模块从此绑死——无法单独删除、无法单独编译、无法单独测试,且规模一大必然形成依赖环。本条是脚本里第一个被拦下来的规则。

误区 3:用 export * 图省事

错误写法:Index.ets 里 export * from './src/main/ets/index-all'。 正确写法:逐个 export { 具体符号 },并区分 export type。 为什么错:export * 把内部实现一次性暴露为公共契约。之后任何内部重构都成为破坏性变更,下游随时可能编译失败;同时你无法回答"这个模块对外提供了什么"这个最基本的问题。

误区 4:让 lib_* 反向依赖 feature_*

错误写法:lib_network 里 import { ApprovalDto } from 'feature_approval',为了复用那个 DTO。 正确写法:把 ApprovalDto 下沉到 lib_base(或新建 lib_contract),两侧都依赖它。 为什么错:违反稳定依赖原则。lib_network 被 5 个模块依赖,一旦它依赖了 feature_approval,改审批模块就要重编网络库,且网络库再也无法独立测试与发布。

误区 5:绕过门面直接 import 内部文件

错误写法:import { TokenStore } from 'lib_auth/src/main/ets/internal/TokenStore'。 正确写法:只从 lib_auth(即根 Index.ets)导入;需要的能力如果没导出,说明它不该被外部使用,或应该补一个门面方法。 为什么错:绕过 Index.ets 等于绕过契约。内部类的签名可以随时变,调用方会在某次无关的升级后突然编译失败,且这种失败无法通过"看导出列表"预判。

误区 6:overrides 当默认手段用

错误写法:只要出现版本不一致就在根 oh-package.json5 加一条 overrides。 正确写法:先查为什么两个模块声明了不同版本——通常是某个模块的依赖范围过时,改那一个即可。overrides 只在"外部库确实存在不兼容的多版本"时使用,并记录原因。 为什么错:overrides 会静默改变多个模块的实际依赖版本,掩盖根因。等它被移除时,问题会一次性集中爆发。

误区 7:锁文件不入库

错误写法:.gitignore 里包含锁文件,理由是"避免冲突"。 正确写法:锁文件必须入库,且与 oh-package.json5 变更一起提交。 为什么错:这直接导致"本地能跑、CI 装到新版本就挂"。上游发新版的时间点不可控,事故会出现在与代码变更完全无关的某次流水线上,排查成本极高。

误区 8:签名材料与凭据入库

错误写法:把 .p12 / .cer / .p7b 放在工程的 signature/ 目录并提交,密码写在 build-profile.json5。 正确写法:材料放本机或 CI secret,路径与凭据由环境变量注入;.gitignore 显式排除 *.p12 / *.cer / *.p7b(见 10.6)。 为什么错:三者合起来就是完整的应用签名权。仓库可见者可以签出与你正式包签名一致的包,用于分发或提审。这类泄漏无法通过"改密码"挽回,只能换证书并通知所有用户。

误区 9:用一个 product 打天下,或反过来为每个渠道建 product

错误写法(两个极端):所有差异都靠 product 拆分,产出 6 个需要分别测试的包;或者所有差异都塞进运行时 if,导致包体膨胀、逻辑分支爆炸。 正确写法:能用运行时判定的用运行时判定(设备形态、内存档位、系统版本),只有影响到包名、图标、签名、要上架的产物 才用 product。 为什么错:product 是构建目标,每个 product 都要独立走一遍完整测试与上架流程。product 数量直接乘上测试成本。


9.10 实践任务

任务:把 OfficeFlow 重构为无环多模块工程

阶段一:建图

  1. 用 9.7 的脚本扫描现有工程,输出当前依赖图与违规清单(如果工程尚未拆分,先手工填写"期望的依赖图")。
  2. 按 9.6.3 的清单表,写出每个模块的职责与允许依赖。

阶段二:落地

  1. 抽出 lib_base,把 Result / AppError / 通用工具迁入,验证它不 import 任何本地模块。
  2. 抽出 lib_network / lib_storage / lib_log / lib_router,每个模块提供根 Index.ets,禁止 export *。
  3. 把 lib_ui 建成 HSP(若有 ≥ 2 个 HAP 计划)或 HAR,并记录选择理由。
  4. 把业务特性提为 feature_* HAR,跨 feature 跳转全部改为路由名常量。

阶段三:固化

  1. 把校验脚本接入构建流程,并制造一次违规验证它能拦下。
  2. 补齐模块清单表与评审检查项,写入团队文档。

约束:每阶段结束都要保证工程可编译、主链路可运行;搬迁与逻辑修改必须分开提交;不得引入 export *。

验收 checklist

  • [ ] 依赖图校验脚本返回 0,输出中无任何违规项
  • [ ] 人为添加一条 feature_message → feature_approval 的依赖,脚本返回 1 并明确指出违规类型
  • [ ] 人为制造一个 lib_a → lib_b → lib_a 的环,脚本能打印完整环路径
  • [ ] 全工程 grep 不到 export *(Index.ets 中)
  • [ ] 每个 HAR / HSP 都有根 Index.ets,且导出项逐个列出
  • [ ] 全工程搜索不到 from 'feature_xxx/...' 这类跨 feature 深层路径 import
  • [ ] lib_base 的 oh-package.json5 中不含任何 file:../lib_* 或 file:../feature_*
  • [ ] 锁文件已入库,且与 oh-package.json5 一致(重新 ohpm install 无变更)
  • [ ] .gitignore 中包含 *.p12、*.cer、*.p7b,且仓库历史中无这些文件
  • [ ] buildModeBinder 已配置,buildMode=release 构建出的包为 release 签名(用 hdc 或 IDE 核对)
  • [ ] obfuscation-rules.txt 的每条 keep 规则旁注了理由
  • [ ] 模块清单表(9.6.3 格式)与实际目录一致,无未登记模块
  • [ ] 单模块编译耗时相较重构前下降(记录数据)

交付物清单

交付物 形式 说明
多模块工程 源码 无环依赖图,HAP + HAR + HSP 齐备
依赖图校验脚本 scripts/check-deps.mjs 可直接运行,退出码语义正确
模块清单表 Markdown 模块 / 形态 / 职责 / 允许依赖 / 对应章节
依赖图 Mermaid 或图片 标注方向与层
HSP 选型说明 Markdown 一页 为什么该库用 HSP,收益量化(包体前后对比)
混淆规则 obfuscation-rules.txt 每条带理由注释
构建配置 build-profile.json5 多 product + buildModeBinder
评审检查项 Markdown 纳入团队评审模板

9.11 延伸阅读

  • 官方文档 → 应用框架 → 应用程序包结构(HAP / HAR / HSP 的产物结构与安装分发模型)
  • 官方文档 → 应用框架 → 共享库开发(HAR)(打包方式、导出面、Index.ets 约定)
  • 官方文档 → 应用框架 → 动态共享库开发(HSP)(同签名同 bundle 约束、多 HAP 共享机制)
  • 官方文档 → 应用框架 → 多模块工程与模块间依赖(oh-package.json5、file: 依赖、overrides)
  • 官方文档 → 工具 → 构建配置参考(build-profile.json5)(products、buildModeSet、buildModeBinder、obfuscation)
  • 官方文档 → 工具 → ohpm 包管理与私有仓库配置
  • 本教程 第 11 章 架构与模块划分

上一章:第 8 章 性能与内存优化 | 下一章:第 10 章 测试与上架发布

📄 第 10 章 测试与上架发布 10-testing-release.md

测试的成本不在"写用例",而在"用例是否可信":跑得慢、偶尔红、失败信息说不清是哪里错——这样的用例会被团队集体无视。上架的成本也不在"点提交",而在签名材料的管理与灰度出问题时的回滚能力。


10.0 本章目标

  1. 能判断一段代码该放在 src/test(Local Test)、src/ohosTest(Instrument Test)还是 UiTest 层,并说出依据。
  2. 能用 Hypium 写出含前置/后置、异步、数据驱动的单元用例,并正确使用 MockKit 的 clear 避免用例互相污染。
  3. 能用 UiTest 写一条端到端主链路,且不依赖绝对坐标与硬编码文案,失败时产出可归档的截图证据。
  4. 能把测试接入 CI:命令行执行、报告归档、失败即阻断、覆盖率趋势。
  5. 能说清 .cer / .p7b / .p12 三者的对应关系与调试 / 发布 Profile 的差别,并写出一套可执行的发布流程:材料规范、权限最小化、灰度放量与回滚预案。

10.1 测试分层

10.1.1 三层各自的运行环境与能力边界

层 目录 运行环境 能访问什么 速度 稳定性
Local Test(本地单元测试) src/test 不依赖设备,轻量运行时 纯 ArkTS 逻辑;不能访问设备与 Ability 秒级 高
Instrument Test(设备侧测试) src/ohosTest 真机 / 模拟器 Context、UIAbility、@kit.* 系统能力 分钟级 中
UiTest(端到端) src/ohosTest 中的 UI 用例 真机 / 模拟器 真实 UI 树、真实用户操作 分钟到十分钟级 低(受环境与动画影响)

术语:Local Test ── 在开发机上直接运行的单元测试,不拉起应用进程。它的价值是"反馈快":改一行逻辑 3 秒内知道对错。Instrument Test ── 需要安装到设备上运行的测试,能触达真实系统能力,代价是每次都要部署与等待。二者目录固定,不要混放——放错目录的用例要么跑不起来,要么被误当成设备测试拖慢 CI。

10.1.2 「什么代码放哪一层」决策表

被测对象 放哪层 理由
纯函数、算法(如 7.5.3 的 mergeDraft) Local Test 无外部依赖,秒级反馈
数据映射 / 序列化 / 反序列化 Local Test 纯逻辑,用固定样例覆盖边界
状态机、校验规则、金额与时间格式化 Local Test 用例数量多,必须快
Repository 的业务编排(依赖注入的假数据源) Local Test 依赖注入后即为纯逻辑
KVStore / 关系型数据库读写 Instrument Test 需要真实 Context 与存储
权限申请、设备信息、分布式会话建立 Instrument Test 必须真机(见 7.1.2)
UIAbility 生命周期行为 Instrument Test 需要 Ability 环境
登录 → 列表 → 详情 → 提交 主链路 UiTest 需要真实交互与路由
跨设备协同、应用接续 真机手工 + UiTest 辅助 模拟器不支持分布式;自动化成本高

比例建议:Local Test : Instrument Test : UiTest ≈ 7 : 2 : 1。理由不是"金字塔好看",而是执行成本与不稳定度从左到右递增——越多用例落在这三层的最左边,CI 越快、越少假红。

10.1.3 一条判断准则

"这段代码能不能在没有设备的机器上跑出确定的结果?"

  • 能,且不依赖真实时间与网络 → Local Test
  • 不能,但不需要用户交互 → Instrument Test
  • 依赖用户点击、滚动、页面跳转 → UiTest

误区:把"读一个常量""格式化一个日期"这类纯逻辑写成 UiTest,理由是"顺手能验证界面"。结果是 CI 里每条用例都要装应用、等动画,一次回归 40 分钟,团队三天后就把它从流水线里删了。


10.2 Hypium 单元测试

10.2.1 基础结构

Hypium 通过 ohpm 安装,测试文件中从 @ohos/hypium 导入。

说明:@ohos/hypium 是 ohpm 包名(第三方测试框架依赖),与"系统能力的 @ohos.* 分命名空间导入"不是一回事。系统能力一律仍走 @kit.*。

// 模块级 oh-package.json5
{
  "name": "feature_collab",
  "version": "1.0.0",
  "devDependencies": {
    "@ohos/hypium": "1.0.21"
  }
}
// feature_collab/src/test/DraftMerge.test.ets
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@ohos/hypium';
import { mergeDraft, DraftRecord } from '../main/ets/merge/DraftMerge';

function makeRecord(overrides: Partial<DraftRecord>): DraftRecord {
    return {
        id: 'doc-1',
        version: 1,
        updatedAt: 1000,
        fieldClock: {},
        fields: {},
        ...overrides
    } as DraftRecord;
}

export default function draftMergeTest(): void {
    describe('DraftMerge', () => {
        let base: DraftRecord;

        beforeAll(() => {
            // 只做一次的重活(如加载大样例数据)
        });

        beforeEach(() => {
            // 每个用例独立起点,避免用例间互相影响
            base = makeRecord({ version: 3, updatedAt: 2000, fieldClock: { title: 100 }, fields: { title: 'A' } });
        });

        afterAll(() => {
            // 释放只在前置里创建的资源
        });

        it('remote 时钟更新时采纳远端字段', 0, () => {
            const remote: DraftRecord = makeRecord({
                version: 4, updatedAt: 2100, fieldClock: { title: 200 }, fields: { title: 'B' }
            });
            const result = mergeDraft(base, remote);
            expect(result.record.fields['title']).assertEqual('B');
            expect(result.conflicts.length).assertEqual(0);
        });

        it('时钟相同且内容不同时上报冲突而不是静默覆盖', 0, () => {
            const remote: DraftRecord = makeRecord({
                version: 3, updatedAt: 2000, fieldClock: { title: 100 }, fields: { title: 'C' }
            });
            const result = mergeDraft(base, remote);
            expect(result.conflicts).assertContain('title');
        });

        it('id 不一致时抛出可识别错误', 0, () => {
            const remote: DraftRecord = makeRecord({ id: 'doc-2' });
            expect(() => mergeDraft(base, remote)).assertThrowError('id mismatch');
        });

        it('合并后版本号单调递增', 0, () => {
            const remote: DraftRecord = makeRecord({ version: 9, updatedAt: 3000 });
            const result = mergeDraft(base, remote);
            expect(result.record.version).assertEqual(10);
        });
    });
}

常用断言(挑常用的记住即可,其余查 API 参考):

断言 用途
assertEqual(expected) 值相等(注意类型严格性)
assertTrue() / assertFalse() 布尔判定
assertContain(item) 数组/字符串包含
assertThrowError(msg) 抛异常,且消息包含指定片段
assertClose(expected, tolerance) 浮点比较(不要用 assertEqual 比浮点)
assertInstanceOf(type) 类型判定
assertNull() / assertUndefined() 空值判定

10.2.2 异步用例

it('异步加载完成后返回成功结果', 0, async () => {
    const repo = new ApprovalRepository(new FakeRemoteDs());
    const result = await repo.loadDetail('a-1');
    expect(result.isSuccess()).assertTrue();
});

// 回调风格:用 done 显式结束,Hypium 会等待
it('回调风格用例', 0, (done: Function) => {
    ApprovalBus.getInstance().once('loaded', (payload) => {
        expect(payload.id).assertEqual('a-1');
        done();
    });
    ApprovalBus.getInstance().emit('loaded', { id: 'a-1' });
});

异步用例的两条纪律:

  • 必须设置超时意识:不做网络与真实等待的异步用例应当毫秒级完成;如果一个异步用例偶尔"卡住很久",大概率是 done() 没被调用,此时它在测的是超时而不是业务。
  • 不要依赖真实时间。需要"等待 300ms 防抖生效"时,把时钟与时序抽象出来(注入 scheduler),用假时钟推进,而不是 setTimeout(..., 300) 真等。
// 可测的时间抽象:生产代码用真实实现,测试注入假时钟
export interface Scheduler {
    now(): number;
    schedule(delayMs: number, task: () => void): number;
}

export class Debouncer {
    constructor(private readonly scheduler: Scheduler, private readonly delayMs: number) {}

    debounce(task: () => void): void {
        // 省略:用 scheduler 记录与触发,测试中可立即推进时间
    }
}

10.2.3 数据驱动

同一逻辑、多组数据时,用数据驱动而不是复制粘贴用例:

describe('TimeFormat.relative', () => {
    // 最后一个参数为数据数组,用例函数按序接收每一组数据
    it('相对时间格式化', 0, (name: string, input: number, expected: string) => {
        expect(TimeFormat.relative(input, 1_700_000_000_000)).assertEqual(expected);
    }, [
        ['刚刚', 1_700_000_000_000, '刚刚'],
        ['5 分钟前', 1_700_000_000_000 - 5 * 60_000, '5 分钟前'],
        ['1 小时前', 1_700_000_000_000 - 60 * 60_000, '1 小时前'],
        ['跨天', 1_700_000_000_000 - 26 * 60 * 60_000, '昨天']
    ]);
});

收益:新增一个边界(如"跨月")只是往数组里加一行,评审时能一眼看出覆盖了哪些情况,而复制粘贴的用例会掩盖"漏了哪个边界"。

10.2.4 MockKit

MockKit 用于替换对象方法,让被测代码不依赖真实实现(网络、存储、系统能力):

import { describe, it, expect, beforeEach, afterEach, MockKit, when } from '@ohos/hypium';
import { RcpClient } from 'lib_network';
import { ApprovalRepository } from '../main/ets/repository/ApprovalRepository';

export default function approvalRepoTest(): void {
    describe('ApprovalRepository', () => {
        let mocker: MockKit;

        beforeEach(() => {
            mocker = new MockKit();
        });

        afterEach(() => {
            // 关键:恢复所有被 mock 的方法,否则会污染后续用例
            mocker.clear(RcpClient.prototype);
        });

        it('网络返回业务错误时映射为 Failure', 0, async () => {
            const mockRequest = mocker.mockFunc(RcpClient.prototype, RcpClient.prototype.requestWithResult);
            when(mockRequest).afterReturn(Promise.resolve({ code: 40001, message: '审批已撤回', data: null }));

            const repo = new ApprovalRepository(new FakeRemoteDs());
            const result = await repo.submit('a-1');
            expect(result.isFailure()).assertTrue();
        });

        it('网络抛异常时不向上抛,而是转为 Failure', 0, async () => {
            const mockRequest = mocker.mockFunc(RcpClient.prototype, RcpClient.prototype.requestWithResult);
            when(mockRequest).afterThrowError('network unreachable');

            const repo = new ApprovalRepository(new FakeRemoteDs());
            const result = await repo.submit('a-1');
            expect(result.isFailure()).assertTrue();
        });
    });
}

为什么 clear 是必须的:mockFunc 修改的是原型上的方法,这个修改在测试进程内是全局的。不 clear 会导致后面的用例(甚至别的 describe 块)仍然拿到被 mock 的实现,出现"单跑通过、全跑失败"这种最难排查的现象。

正确做法:afterEach 里 clear,并且只 clear 自己改过的对象。不要用"重置所有"的粗暴方式掩盖问题——那会让"谁改了原型"这件事永远查不出来。

误区:只在用例末尾(it 内部最后一行)调用 clear。一旦用例中途断言失败抛异常,clear 就不会执行,污染留给下一个用例。clear 必须放在 afterEach 这类一定会执行的位置。


10.3 UiTest

10.3.1 基本结构

// feature_approval/src/ohosTest/ets/test/ApprovalFlow.test.ets
import { describe, it, expect, beforeAll, afterAll } from '@ohos/hypium';
import { Driver, ON, Component } from '@kit.TestKit';

const TIMEOUT_MS: number = 30_000;

export default function approvalFlowTest(): void {
    describe('ApprovalFlow', () => {
        let driver: Driver;

        beforeAll(async () => {
            // Driver 会自动绑定被测应用,无需手动 startAbility
            driver = Driver.create();
            await driver.delayMs(500);
        });

        afterAll(async () => {
            // 释放驱动,避免测试进程残留
            driver = undefined as unknown as Driver;
        });

        it('登录后可进入审批列表并打开详情', 0, async () => {
            // 用资源 id 定位,而不是坐标也不是硬编码文案
            const loginBtn: Component = await driver.findComponent(ON.id('login_submit'));
            expect(loginBtn !== null).assertTrue();
            await loginBtn.click();
            await driver.waitForIdle(TIMEOUT_MS);

            const list: Component = await driver.findComponent(ON.id('approval_list'));
            expect(list !== null).assertTrue();

            // 滚动到目标项再点击,避免依赖"第几个"这种脆弱定位
            // 坐标按窗口尺寸比例计算,不写死像素(10.3.2 纪律一)
            const size = await driver.getDisplaySize();
            await driver.swipe(size.width * 0.5, size.height * 0.6, size.width * 0.5, size.height * 0.3, 800);
            await driver.waitForIdle(TIMEOUT_MS);

            const card: Component = await driver.findComponent(ON.id('approval_card_0'));
            await card.click();
            await driver.waitForIdle(TIMEOUT_MS);

            const detailTitle: Component = await driver.findComponent(ON.id('approval_detail_title'));
            expect(detailTitle !== null).assertTrue();
        });
    });
}

关键 API 的用法要点:

API 用途 注意
Driver.create() 创建 UI 驱动 整个用例集复用一个,不要每条用例都创建
driver.findComponent(ON.id(x)) 定位组件 首选 id;text 次之(文案会变);避免 ON.type 单独使用
driver.assertComponentExist(ON.id(x)) 断言存在 用于"应出现"的判定,比 findComponent 后判空更直接
driver.waitForIdle() 等待 UI 空闲 每次交互后都要调,否则会读到中间态
component.inputText(text) 输入 先 click() 聚焦再输入
component.click() / driver.swipe(...) 交互 滑动用相对坐标(基于当前屏幕尺寸计算),不要写死像素
driver.delayMs(ms) 强制等待 尽量少用;只在动画/异步无法被 waitForIdle 覆盖时使用

10.3.2 三条硬纪律

纪律一:不依赖绝对坐标。 不同设备分辨率、折叠屏展开/折叠状态、状态栏高度都会改变坐标。

纪律二:不硬编码文案。 文案会因多语言、产品改词而变。定位用资源 id;必须断言文案时,从资源读取期望值:

// 反例:ON.text('提交') —— 改文案或多语言环境下必挂
// 正例:从资源取期望文案,再比对
const ctx = abilityDelegatorRegistry.getAbilityDelegator().getAppContext();
const expectedSubmit: string = ctx.resourceManager.getStringByNameSync('submit_button');
await driver.assertComponentExist(ON.text(expectedSubmit));

纪律三:失败必须有证据链。 失败时只告诉"找不到组件"没有可操作性。在 afterEach 里做"失败即截图":

it('主链路', 0, async () => {
    try {
        // ... 测试步骤
    } catch (err) {
        // 失败即取证。截图用 uitest 命令行(在 CI 宿主机上对设备执行),ArkTS 侧
        // Driver 的截图接口以官方 API 参考为准,此处不直接依赖:
        //   hdc shell uitest screenCap -p /data/local/tmp/failure.png
        //   hdc file recv /data/local/tmp/failure.png ./failure-<时间戳>.png
        // 再把可见文本 dump 与日志时间戳一并归档,构成证据链
        await TestEvidence.saveText(`failure-${Date.now()}.txt`, collectVisibleTexts());
        throw err;   // 必须重新抛出,否则用例会被记为通过
    }
});

证据链三件套:截图(看到什么)+ 可见文本/组件树(停在哪一步)+ 日志时间戳(什么时候)。缺任何一个,失败用例都要靠"再跑一次看看"来定位——而 UI 用例经常不复现。

版本门槛:UiTest 的部分能力(如截图、组件树遍历)在不同 API 版本上的接口形态有差异。使用前核对官方 API 参考中该接口的起始版本,不要在兼容下限设备上无条件调用。

10.3.3 UiTest 该覆盖什么

该覆盖 不该覆盖
登录 → 列表 → 详情 → 提交 主链路 每种表单校验错误的提示文案
关键页面能打开且首屏元素齐备 每个按钮的点击效果
路由拦截(未登录跳登录) 样式、间距、颜色
一次"冷启动后走通核心链路" 与逻辑分支相关的边界(放 Local Test)

原则:UiTest 是防"整体跑不通"的兜底网,不是功能覆盖工具。它数量少、跑得慢、不稳定,所以要挑最不能坏的那几条。


10.4 DevEco Testing 专项

DevEco Testing 是独立于单元测试的体检工具,关注的是"应用在真实设备上的表现",而不是"某个函数对不对"。它与 Hypium 的关系是互补:Hypium 回答"逻辑对不对",DevEco Testing 回答"跑起来稳不稳、快不快"。

场景 检测项 输出
性能 启动时长、滑动帧率、响应时延、内存峰值 指标报告 + 问题项
稳定性 长时间随机/遍历操作下的崩溃、卡死、应用无响应 崩溃堆栈 + 复现路径
兼容性 多设备/多分辨率/多系统版本下的界面与功能异常 异常截图 + 设备清单
功耗 场景耗电、后台唤醒、网络占用 耗电归因报告
UX 走查 布局截断、控件重叠、无响应区域 可视化问题标注

与 8.7 的关系:DevEco Testing 适合跑批量场景并出报告,而 8.7 的三项基线适合做精确对比与回归门禁。日常开发用 hiTraceMeter / Profiler 做精确定位;发版前用 DevEco Testing 做一轮广度体检。两者不要互相替代:体检报告给不出"某函数占了多少毫秒",精确打点也给不出"20 台设备上有没有布局截断"。

说明:DevEco Testing 的具体场景名与报告字段随版本演进,以 DevEco Studio 26.0.0 内置工具的实际界面为准;本节的表格用于说明"它回答哪类问题",字段细节请以工具内为准。


10.5 测试接入 CI

10.5.1 流水线阶段

阶段 内容 耗时量级 失败是否阻断
静态检查 依赖图校验(9.7)、lint、Code Scanner 秒~分钟 阻断
Local Test 全部 src/test 用例 秒~分钟 阻断
构建 assembleHap(debug) 分钟 阻断
Instrument Test 设备侧用例(需可用设备/模拟器) 分钟 阻断
UiTest 主链路端到端 分钟 阻断(可配置为仅主干分支阻断)
性能回归 冷启动 / 峰值内存(8.7.3) 分钟 告警优先,逐步升级为阻断
签名与归档 release 签名、产物归档 分钟 阻断

排序依据:越快、越稳定、越能定位的放前面。静态检查与 Local Test 秒级出结果,先跑能让开发者在 1 分钟内知道"提交有问题",而不是等 20 分钟的设备测试。

10.5.2 命令行执行

# 1) 依赖图校验(见 9.7)
node scripts/check-deps.mjs .

# 2) 查看当前工程可用的任务名(不同工程的任务名可能不同,先确认再写进流水线)
hvigorw tasks --no-daemon

# 3) 构建 debug 包
hvigorw assembleHap --mode module -p product=default -p buildMode=debug --no-daemon

# 4) 构建 release 包(签名由 buildModeBinder 绑定,见 9.5.5)
hvigorw assembleHap --mode module -p product=default -p buildMode=release --no-daemon

版本门槛:测试任务的具体名称与参数随 DevEco 版本演进(例如是否需要 -p test=...、是否按 module 粒度执行)。不要凭记忆写死,先用 hvigorw tasks 列出任务名再落到流水线脚本里;任务名变化时 CI 会直接失败,比静默跳过测试好。

10.5.3 报告归档

# .ci/pipeline.yaml(示例,字段按团队 CI 平台调整)
stages:
  - static-check
  - unit-test
  - build
  - device-test
  - sign-archive

static-check:
  stage: static-check
  script:
    - node scripts/check-deps.mjs .
    - bash scripts/check-banned-api.sh
  artifacts:
    when: always
    paths:
      - build/reports/static-check/

unit-test:
  stage: unit-test
  script:
    - hvigorw test --no-daemon
  artifacts:
    when: always                     # 失败也要归档,否则查不到现场
    paths:
      - build/reports/test/
      - build/outputs/logs/
    reports:
      junit: build/reports/test/**/*.xml
  retry: 0                           # 单测不稳定是真问题,不要自动重试掩盖

device-test:
  stage: device-test
  script:
    - hdc list targets               # 无设备时应当显式失败,而不是静默跳过
    - hvigorw ohosTest --no-daemon
  artifacts:
    when: always
    paths:
      - build/reports/ohosTest/
      - build/outputs/screenshots/   # UiTest 失败截图(10.3.2)

两条纪律:

  • when: always:失败时的报告与截图才是最需要的。默认只在成功时归档,等于把现场丢了。
  • 无设备时显式失败:hdc list targets 为空时应该让流水线红,而不是"跳过设备测试 → 绿"。静默跳过会让团队在发版当天才发现设备测试三个月没跑过。

10.5.4 覆盖率趋势

覆盖率不作为目标,作为趋势:

做法 结果
设"覆盖率必须 ≥ 80%" 团队写无意义断言刷数字(如只断言"结果不为 null")
记录每主干分支覆盖率并画趋势 覆盖率突然下降时能立刻发现"这次提交没写测试"
对关键模块(lib_base 的算法、状态机)设硬门槛 高价值代码有保障,其余模块不折腾

具体做法:每次主干构建把覆盖率数字写入一个持久化记录(如 CI 的指标存储),生成趋势图;当较上一主干分支下降超过 2 个百分点时在评审里提示。重点看"新增代码的覆盖率",而不是"全量覆盖率"——全量数字会被历史代码稀释,看不出新提交的质量。


10.6 签名与上架

10.6.1 三种材料的对应关系

文件 是什么 里面有什么 存放位置
.p12 密钥库(KeyStore) 私钥 + 别名 + 两次密码(库密码、私钥密码) 本机安全目录 / CI secret,绝不入库
.cer 数字证书 公钥 + 由 AGC 签发的信息(证明"这个公钥属于这个开发者") 本机安全目录 / CI secret,不入库(与 .p12 / .p7b 同口径,见 10.6.6)
.p7b Profile(授权文件) 应用标识(bundleName)、允许的权限、证书指纹、调试类型的还包含设备 UDID 列表 同上,绝不入库

三者的关系一句话说清:

用 .p12 里的私钥 + .cer 里的公钥构成"我是谁";.p7b 是 AGC 出具的"我授权这个应用标识在这些设备上用这些权限"。三者齐备才能签出一个能被设备接受的包。缺 .p7b 会签出无法安装的包;.p7b 与 .cer 不匹配会在安装时报签名校验失败。

10.6.2 调试 Profile vs 发布 Profile

维度 调试 Profile 发布 Profile
用途 本地调试、内测分发 正式上架
设备绑定 绑定 UDID 列表——只有列表内的设备能装 不绑设备,任何设备可安装
数量限制 有设备数量上限,需在 AGC 维护 —
典型报错 "设备未在 Profile 中" —
与签名配置 配到 debug 的 signingConfig 配到 release 的 signingConfig

常见事故:用调试 Profile 打出包去提审,审核侧设备不在 UDID 列表里 → 安装失败 → 审核驳回。所以 9.5.5 的 buildModeBinder 不只是方便,它是防错机制。

10.6.3 AGC 创建应用的流程

步骤 做什么 关键点
1 注册并完成开发者实名认证 个人 / 企业类型决定可申请的能力范围
2 在 AGC 创建应用 bundleName 一旦确定不要改,改了等于换了一个应用,用户升级路径断裂
3 创建密钥与证书 可让 AGC 生成 .p12/.cer,也可上传自有 CSR;生成的 .p12 只可下载一次,务必立即妥善保存
4 申请 Profile 选择证书、应用、类型(调试/发布);调试类型需先录入设备 UDID
5 下载材料并配置到 build-profile.json5 路径与密码走环境变量(9.5.5)
6 构建并验证 用 buildMode=release 出包,装到非调试列表的设备上验证能装能跑
7 提交上架 上传 App Pack、填写材料、提交审核

密钥丢失的后果:.p12 丢失即无法再用同一证书签名,只能重新申请证书并重新上架(用户需要重新安装)。因此密钥要有离线备份,且备份介质不入库、不上公共云盘。

10.6.4 包体与材料规范

项 规范 检查方式
包体大小 控制在团队设定上限内(如 ≤ 100 MB);超限先做资源与依赖审计 构建产物统计 + 9.5.4 资源裁剪
图标 各尺寸齐备,无透明通道问题、无拉伸 逐尺寸目视 + 工具核查
截图 覆盖主要界面,真实内容(不要占位图),与当前版本一致 逐张核对版本号
应用描述 说清核心功能,不夸大、不堆关键词 与产品定位对齐
更新说明 面向用户,写"改了什么",不写"修复若干问题" 逐版本核对
内容分级 按实际内容如实填写 与实际功能一致
隐私政策 有可访问的 URL,内容与实际收集行为一致 逐条对照代码中的采集点

10.6.5 隐私政策与权限最小化

权限最小化三问(每个权限都要能回答):

  1. 不做这个功能,能不能不给这个权限? 能,就不要申请。
  2. 能不能用更弱的权限替代?(如用 pickPhoto 选图替代读相册权限)
  3. reason 文案是否说清了具体用途?
// 每个 user_grant 权限都要有可读、具体的 reason
{
  "name": "ohos.permission.DISTRIBUTED_DATASYNC",
  "reason": "$string:reason_distributed_sync",
  "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}

资源文件里写具体用途:

// entry/src/main/resources/base/element/string.json
{
  "string": [
    { "name": "reason_distributed_sync", "value": "用于在同一账号下的手机与平板之间同步你的审批草稿" },
    { "name": "reason_notification",    "value": "用于在你收到新审批任务时推送提醒" }
  ]
}

"需要权限"、"为了更好的体验"会被驳回——因为它们没有告诉用户具体用在哪。写清楚场景、数据种类、后果。

敏感权限审批:涉及位置、通讯录、相机、麦克风等敏感权限时,除 reason 外通常还需在 AGC 侧单独申请并说明业务必要性。申请被拒时不要试图用替代权限绕过——正确的做法是检查该功能是否可以用系统提供的"用户主动授权单次访问"能力替代(如选图器、单次定位)。

隐私政策必须与代码一致:政策里说"不收集位置信息",代码里却有定位调用,属于严重合规问题。上线前用一次"代码中的采集点 vs 政策条目"对照表逐条核验。

10.6.6 签名密钥绝不入库

这是本章最重要的一条硬规定:

# .gitignore 必须包含
*.p12
*.cer
*.p7b
*.keystore
signature/
.ohpmrc.local
# 检查是否已经误提交(同时检查历史)
git log --all --pretty=format:%H -- '*.p12' '*.cer' '*.p7b' | head

# 若已入库,注意:从当前提交删除是不够的,历史里仍可取到
# 必须视为"密钥已泄漏":重新申请证书与 Profile,并强制所有用户升级

正确的凭据流转:

环节 做法
开发本机 材料放本机安全目录(如用户目录下的受限目录),路径写进本机 .dev 配置或环境变量
CI 材料以 CI secret(文件型 secret)形式注入,构建时落地到临时目录,构建后清理
仓库 只保留 build-profile.json5 中的占位与结构,敏感值走变量替换

已泄漏的判定与处置:只要 .p12 曾进入过任何共享仓库(含私有仓库的完整历史),就应视为泄漏。处置顺序是"换证书 → 重新申请 Profile → 出新包 → 推动用户升级",而不是"删掉那个 commit 就没事了"。


10.7 发布策略与灰度回滚

10.7.1 灰度放量

阶段 放量 观察窗口 进入下一阶段的条件
内部验证 团队内安装 1 天 主链路无阻断问题
小流量 5% 1~2 天 崩溃率、卡死率不高于上一版本;核心转化不下降
中流量 20%~50% 2~3 天 同上,且无新增差评主题
全量 100% 持续 —

每阶段必须观测的指标(缺一不可):

指标 为什么看它
崩溃率 / 卡死率(按版本、机型、系统版本分组) 最直接的质量信号;分组才能看出"只在某机型崩"
冷启动时长(P50 / P90) 8.7.1 的基线在真实流量上的验证
峰值内存(P90) 内存回退会在低内存设备上表现为被系统回收
核心链路成功率(登录 / 提交) 才代表"业务能不能用"
卸载率 / 差评新增 滞后指标,但能兜住"功能没错但用户不接受"

10.7.2 回滚:先想清楚"回滚是什么"

必须面对的现实:已升级的用户无法被降级。应用市场不支持"把用户拉回旧版本"。所以真正的回滚手段只有两类:

手段 生效范围 生效时间 适用
停止放量 尚未升级的用户 分钟级 阻断新用户进入问题版本
服务端开关(kill switch) 已升级且未升级的所有用户 分钟级(下次拉配置时生效) 唯一能救"已升级用户"的手段
发布修复版 所有用户 小时~天级(取决于审核) 中长期修复

结论:核心功能必须有服务端开关,且开关的默认值必须是"安全的那一侧"。

// 远端配置示例(默认值与远端值语义必须明确)
{
  "features": {
    "collaboration_v2": { "enabled": false, "rollout": 0.05 },
    "new_editor":       { "enabled": true,  "rollout": 1.0 }
  }
}
开关设计要点 说明
默认值 = 旧行为 首次启动、拉配置失败时,走的是已验证过的路径
开关粒度按功能,不按代码分支 一个功能一个开关,不要"总开关 + 10 个子开关"
开关有下线时间 长期存在的开关会累积成"永久双路径",必须排期删除
开关切换不需发版 一旦需要发版才能关,它就失去了应急价值

术语:kill switch(应急开关) ── 服务端可远程改变功能可用性的配置项。它的核心价值是"不发版就能止损",因此必须在功能开发时就一并设计,而不是出事后再加——出事时再加,往往要先发一版包含开关的包,黄花菜都凉了。

10.7.3 出问题时的处置顺序

  1. 确认影响面:哪些版本、哪些机型、多少比例用户、能否复现。
  2. 止损优先:关开关 / 停止放量。不要先想着定位根因——用户还在受影响。
  3. 保留现场:收集崩溃堆栈、用户 ID、操作路径;不要急着发新版覆盖。
  4. 定位根因:回到日志与堆栈,按 8.1 的四段式给出证据。
  5. 修复并验证:修复版先走内测与小流量,不要因为"着急"直接全量。
  6. 复盘:补测试用例(把这次漏掉的场景变成用例),补开关,更新检查清单。

"先止损再看日志"的顺序不是拍脑袋:根因定位可能花几小时,而关开关只需要一分钟。把顺序搞反的代价是几小时的额外影响面。


10.8 上架材料与合规自检

10.8.1 提交前自检清单

  • [ ] bundleName 与 AGC 上创建的应用一致,且本次未变更
  • [ ] 用的是发布 Profile(不绑设备),且设备签名校验通过(在非调试设备上装过)
  • [ ] compatibleSdkVersion 为 6.0.0(20)、targetSdkVersion 为 26.0.0,与 AGC 上声明的最低/目标版本一致
  • [ ] 所有 user_grant 权限都有具体、可读的 reason 文案
  • [ ] 代码中的实际采集行为与隐私政策条目逐条一致
  • [ ] 提供了账号注销入口(法规要求,且要能在应用内完成,不能只留邮箱)
  • [ ] 图标、截图、描述、更新说明与当前版本一致
  • [ ] 内容分级如实填写
  • [ ] 应用内无第三方 SDK 未声明的数据采集
  • [ ] release 包已跑通完整主链路与三层测试
  • [ ] 崩溃率与卡死率在上架前的内测流量中达标
  • [ ] 有可用的 kill switch 与回滚预案,并演练过一次
  • [ ] .gitignore 已排除签名材料,仓库历史中确认无签名材料

10.8.2 审核驳回的高频原因

驳回原因 典型表现 预防
权限用途说明不清 reason 写"需要权限" 按 10.6.5 写具体场景
隐私政策与实际不符 政策未提及账号信息收集,但应用有登录 上线前逐条对照
缺少账号注销入口 只有注册没有注销 产品设计阶段就纳入
截图与实际不符 截图为旧版界面或含占位数据 每次提审重新截
功能不可用(审核侧环境) 依赖内网、依赖特定设备、依赖调试 Profile 审核侧要能跑通主链路(可用 Mock 数据兜底)
应用崩溃/白屏 未在冷启动与弱网下验证 提交前跑弱网与冷启动用例

"审核侧环境"这条最容易被忽略:审核人员的设备、网络、账号状态与你不同。如果主链路强依赖某个内部服务或某台设备,务必准备可独立运行的展示路径(这就是本教程工程默认开启 Mock 模式的直接原因)。


10.9 常见误区

误区 1:只有 UiTest,没有单元测试

错误写法:所有验证都写在 src/ohosTest 的 UI 用例里,"反正最终是界面要能用"。 正确写法:逻辑与边界放 src/test,设备能力放 src/ohosTest,只把主链路放 UiTest(10.1.2)。 为什么错:UiTest 每次都要部署应用、等动画、真实点击,单条用例秒级变分钟级,且受环境影响频繁假红。一条 40 分钟、偶尔红的主链路回归,最后一定会被从流水线里删掉。

误区 2:MockKit 用后不 clear

错误写法:it 内部第一行 mockFunc,最后一行 clear。 正确写法:beforeEach 里创建 mocker,afterEach 里 mocker.clear(被改的对象)。 为什么错:mockFunc 改的是原型方法,作用范围是整个测试进程。用例中途断言失败会跳过末尾的 clear,把 mock 留给下一个用例,表现为最难查的"单跑通过、全跑失败"。clear 必须放在一定会执行的位置。

误区 3:单元测试里等真实时间

错误写法:await new Promise((r) => setTimeout(r, 300)) 来等防抖生效。 正确写法:把时间抽象成 Scheduler 注入,测试中用假时钟推进;确需真实等待时,用例应当被判定为不该放在 Local Test。 为什么错:真实等待让用例变慢、变不稳定(CI 机器负载高时会偶发失败),而且它测的是"能不能等 300ms",不是"防抖逻辑对不对"。

误区 4:UiTest 依赖绝对坐标与硬编码文案

错误写法:driver.click(540, 1620);driver.assertComponentExist(ON.text('提交'))。 正确写法:ON.id('approval_submit') 定位;必须断言文案时,从 resourceManager 读期望值再比对(10.3.2)。 为什么错:坐标随分辨率、折叠状态、状态栏高度变化而失效——用例在开发者机器上绿、在测试机上红;硬编码文案会在多语言或产品改词后集体挂掉,且失败信息无法区分"功能坏了"还是"文案改了"。

误区 5:用调试 Profile 打包提审

错误写法:buildMode=release 但 signingConfig 指向调试材料。 正确写法:用 buildModeBinder 把 release 绑定到发布 Profile,并在非调试设备列表里的设备上验证能安装运行(9.5.5 / 10.6.2)。 为什么错:调试 Profile 绑定 UDID 列表,审核侧设备不在列表里 → 安装失败 → 直接驳回。而且这类错误在开发机上完全看不出来(开发机就在列表里)。

误区 6:签名材料入库

错误写法:把 .p12 / .cer / .p7b 放在工程 signature/ 目录并提交,密码明文写在 build-profile.json5。 正确写法:材料走本机安全目录或 CI secret,.gitignore 排除,仓库只保留配置结构(10.6.6)。 为什么错:三者合起来就是完整的应用签名权。任何能看到仓库的人都能签出与你正式包签名一致的包用于分发或提审。这不是"改个密码"能挽回的,只能重新申请证书并推动用户升级。

误区 7:把覆盖率当 KPI

错误写法:门禁设"覆盖率 ≥ 80%",团队为达标写 expect(result).assertTrue() 这类无意义断言。 正确写法:覆盖率作为趋势观测,重点看新增代码覆盖率是否下降;硬门槛只设在少数关键模块(10.5.4)。 为什么错:覆盖率衡量的是"这行代码被执行过",不是"结果被验证过"。以数字为目标会直接催生"执行但不校验"的用例,既拖慢 CI 又提供虚假安全感。

误区 8:灰度一次全量

错误写法:审核通过后直接把放量拉到 100%,理由是"小流量看不出什么"。 正确写法:5% → 20% → 50% → 100%,每阶段设观察窗口与进入条件(10.7.1)。 为什么错:全量后才发现的问题是全量影响。小流量确实可能看不出问题,但那正说明"该看的指标没看全"(要按机型、系统版本分组看崩溃率),而不是"应该跳过灰度"。

误区 9:没有 kill switch,回滚只能发版

错误写法:所有功能逻辑都编译在包里,出问题只能发修复版。 正确写法:核心功能在开发时就配服务端开关,默认值为旧行为(10.7.2)。 为什么错:发版要走审核(小时到天级),期间所有用户持续受影响。kill switch 是唯一能救"已升级用户"的手段,而"已升级用户不能降级"这条现实无法绕过。

误区 10:权限 reason 写"需要权限"

错误写法:"reason": "应用需要此权限以正常运行"。 正确写法:写具体场景与数据用途,如"用于在同一账号下的手机与平板之间同步你的审批草稿"(10.6.5)。 为什么错:审核会检查 reason 是否向用户说明了具体用途;笼统表述既给不出用户做出判断所需的信息,也是驳回的高频原因。同时它会让用户在授权弹窗上直接点"拒绝",功能静默失效。


10.10 实践任务

任务:三层测试 + 一次完整的上架演练

第一部分:测试

  1. 为 lib_distributed 的 mergeDraft(7.5.3)写 Local Test:覆盖"远端更新获胜""时钟相同报冲突""id 不一致抛错""版本号单调递增"四类,至少含一组数据驱动用例。
  2. 为 ApprovalRepository 写 Instrument Test:覆盖存储读写失败、权限缺失、真实 Context 下的正常路径。
  3. 用 UiTest 写一条主链路:冷启动 → 登录 → 列表 → 详情 → 提交,并在失败时产出截图 + 可见文本证据。
  4. 把三层测试接入构建流程:命令行可执行、报告归档、失败即阻断、无设备时显式失败。

第二部分:上架演练

  1. 在 AGC 完成一次完整流程:创建应用 → 生成密钥与证书 → 申请发布 Profile → 下载并配置。
  2. 用 buildMode=release 出包,在不在调试设备列表的设备上验证安装与主链路。
  3. 列出全部权限,逐条回答"最小化三问",并写出 reason 文案。
  4. 写出本次发布所需的全部材料清单与截图清单。
  5. 设计一个 kill switch 方案:开关名、默认值、生效范围、关闭后的用户可见行为。
  6. 写一页回滚预案:触发条件、止损动作、保留现场、修复与验证、复盘。

约束:签名材料与凭据不得出现在任何入库文件中;UiTest 不得使用绝对坐标与硬编码文案;Local Test 不得依赖真实时间与网络。

验收 checklist

  • [ ] src/test 中存在 mergeDraft 的四类用例,且包含至少一组数据驱动
  • [ ] Local Test 全量执行时间在秒级(记录实际耗时)
  • [ ] MockKit 的 clear 位于 afterEach,且故意让一个用例失败后,其后的用例仍然通过
  • [ ] src/ohosTest 中的 Instrument Test 能访问真实 Context 并断言存储结果
  • [ ] UiTest 主链路全绿;全工程 grep 不到 driver.click( 形式的绝对坐标
  • [ ] UiTest 断言文案来自 resourceManager,全工程 grep 不到 ON.text('中文') 形式
  • [ ] UiTest 失败时在归档目录中能同时找到截图与可见文本
  • [ ] CI 中静态检查 → Local Test → 构建 → 设备测试 → 签名归档按序执行,任一失败即阻断
  • [ ] 报告与截图在失败时也被归档(when: always 生效)
  • [ ] 无设备时流水线显式失败(用一次 hdc list targets 为空的环境验证)
  • [ ] 覆盖率记录已接入,能看到主干分支的趋势数据
  • [ ] release 包在非调试设备列表的设备上安装成功并跑通主链路
  • [ ] .gitignore 已排除 *.p12、*.cer、*.p7b,且 git log --all 查不到这些文件
  • [ ] 每个 user_grant 权限都有具体的 reason 文案(不是"需要权限")
  • [ ] 隐私政策条目与代码中的采集点逐条对照过,结果记录在案
  • [ ] kill switch 方案已文档化:开关名、默认值、关闭后的行为
  • [ ] 回滚预案一页纸写完,且包含"演练过一次"的说明
  • [ ] 应用内存在可用的账号注销入口

交付物清单

交付物 形式 说明
Local Test 用例 src/test/**/*.test.ets 含数据驱动用例
Instrument Test 用例 src/ohosTest/** 触达真实 Context
UiTest 主链路 src/ohosTest/** 含失败截图证据产出
CI 流水线定义 .ci/pipeline.yaml 五阶段 + 归档 + 阻断
覆盖率记录 指标存储 / Markdown 主干分支趋势
签名配置 build-profile.json5 buildModeBinder 绑定,无明文凭据
权限清单 Markdown 权限 / 用途 / 最小化三问结论 / reason 文案
上架材料清单 Markdown 图标 / 截图 / 描述 / 更新说明 / 分级
kill switch 方案 Markdown 一页 开关名、默认值、生效范围
回滚预案 Markdown 一页 触发条件与处置顺序
演练记录 截图 + 日志 非调试设备安装成功、主链路跑通

10.11 延伸阅读

  • 官方文档 → 应用测试 → 测试框架(Hypium)(describe / it / 断言 / 数据驱动 / MockKit 用法)
  • 官方文档 → 应用测试 → 本地单元测试与设备侧测试(src/test 与 src/ohosTest 的目录约定与执行方式)
  • 官方文档 → 应用测试 → UiTest 框架(Driver / ON / 组件定位与交互 API)
  • 官方文档 → 工具 → DevEco Testing(性能、稳定性、兼容性、功耗场景与报告解读)
  • 官方文档 → 工具 → hvigor 构建与命令行任务(hvigorw 任务名与参数)
  • 官方文档 → 上架与分发 → 应用签名与 Profile(.cer / .p7b / .p12 的生成、下载与配置)
  • 官方文档 → 上架与分发 → AppGallery Connect 应用创建与版本提交
  • 官方文档 → 应用框架 → 权限管理(权限级别、reason 规范、敏感权限申请)

上一章:第 9 章 工程化与模块化架构 | 下一章:第 11 章 企业级实战 OfficeFlow

📄 附录 B 弃用 API 与版本门槛总表 B-deprecated-and-gates.md

本附录解决两类问题:"这段老代码还能不能用?" 与 "这个新能力在兼容下限 20 上能不能用?"。第一部分是"淘汰名单",第二部分是"版本门槛",第三部分澄清"工具链给了 ≠ 运行时能用",第四部分给出统一的 canIUse 兜底模板。

版本门槛:本附录所有"可用版本"以官方 API 参考中的 @since / 起始版本为准。凡是本表与官方文档冲突的,以官方文档为准。


B.1 第一部分:已废弃 / 已不推荐

B.1.1 路由:@ohos.router → Navigation

项目 现状 替代方案 影响面
router.pushUrl() 已不推荐 NavPathStack.pushPath() / pushPathByName() 全部页面跳转
router.replaceUrl() 已不推荐 NavPathStack.replacePath() 登录后替换栈顶等场景
router.pushUrl({ params }) 已不推荐 pushPath({ name, param }),param 类型化 页面间传参
router.getParams() 已不推荐 NavDestination 的 onReady 回调取 param 目标页取参
router.back() / back(url) 已不推荐 NavPathStack.pop() / popToName() / popToIndex() 返回与多级返回
router.clear() 已不推荐 NavPathStack.clear() 退出登录清栈
router.getLength() 已不推荐 NavPathStack.size() 栈深度判断
页面 @Entry 自动入栈 已不推荐 系统路由表 route_map.json + NavDestination 路由注册方式

采用 Navigation 的可量化收益:路由栈可编程(可读、可回滚、可拦截)、支持分栏与一多适配、天然对接 route_map.json 动态路由。本教程主线全部使用 Navigation,见 第 4 章。

B.1.2 导入风格:@ohos.* → @kit.*

旧写法(@ohos.*) 新写法(@kit.*) 说明
@ohos.app.ability.UIAbility @kit.AbilityKit UIAbility / AbilityStage / Want / Context
@ohos.app.ability.common @kit.AbilityKit 通用类型(Context 族)
@ohos.app.ability.wantAgent @kit.AbilityKit WantAgent
@ohos.app.form.* @kit.FormKit 卡片
@ohos.file.fs @kit.CoreFileKit 文件读写
@ohos.file.picker @kit.CoreFileKit 文件选择
@ohos.file.photoAccessHelper @kit.MediaLibraryKit 相册读写
@ohos.net.http @kit.NetworkKit(优先 rcp) 见 B.1.4
@ohos.net.socket / @ohos.net.webSocket @kit.NetworkKit Socket / WebSocket
@ohos.request @kit.BasicServicesKit 上传下载任务
@ohos.data.preferences @kit.ArkData KV 偏好
@ohos.data.relationalStore @kit.ArkData RDB
@ohos.data.distributedKVStore @kit.ArkData 分布式 KV
@ohos.data.uniformTypeDescriptor @kit.ArkData UTD
@ohos.hilog @kit.PerformanceAnalysisKit 日志
@ohos.hiTraceMeter @kit.PerformanceAnalysisKit 打点
@ohos.hidebug @kit.PerformanceAnalysisKit 内存/句柄调试
@ohos.notificationManager @kit.NotificationKit 通知管理
@ohos.notificationSubscribe @kit.NotificationKit 通知订阅
@ohos.distributedDeviceManager @kit.DistributedServiceKit 设备管理
@ohos.distributedHardware.deviceManager @kit.DistributedServiceKit 旧设备管理
@ohos.util @kit.ArkTS 集合、编解码、字符串
@ohos.buffer @kit.ArkTS Buffer
@ohos.taskpool @kit.ArkTS TaskPool
@ohos.worker @kit.ArkTS Worker
@ohos.multimedia.media @kit.MediaKit 媒体播放
@ohos.multimedia.camera @kit.CameraKit 相机
@ohos.multimedia.audio @kit.AudioKit 音频
@ohos.multimedia.image @kit.ImageKit 图像
@ohos.geoLocationManager @kit.LocationKit 定位
@ohos.abilityAccessCtrl @kit.AbilityKit 权限校验
@ohos.promptAction @kit.ArkUI 弹窗
@ohos.window @kit.ArkUI 窗口
@ohos.mediaquery @kit.ArkUI 见 B.1.9
@ohos.router 无(改用 NavPathStack) 见 B.1.1
@ohos.arkui.componentUtils @kit.ArkUI 组件查询
@ohos.animator @kit.ArkUI 动画
@ohos.web.webview @kit.ArkWeb Web 组件
@ohos.security.cryptoFramework @kit.CryptoArchitectureKit 加解密
@ohos.zlib @kit.BasicServicesKit 压缩

为什么废弃:@kit.* 是聚合后的对外导入面,一个 Kit 内聚同领域能力,避免"一个能力散在多个 @ohos.* 模块"的碎片化;工具链也会对 @ohos.* 的继续使用给出提示。统一 @kit.* 是判断代码年代最灵敏的指纹之一。

B.1.3 应用模型:FA → Stage

项目 现状 替代方案 影响面
config.json 已废弃 app.json5 + module.json5 工程配置
featureAbility 已废弃 UIAbility + WindowStage 页面承载
Page Ability 已废弃 UIAbility(一个 Ability 多个 @Entry 页面) 页面组织
@ohos.ace / @ohos.ability.featureAbility 已废弃 @kit.AbilityKit 启动与上下文
getContext() 取 FA 上下文 已废弃 this.getUIContext().getHostContext() Context 获取

为什么废弃:FA 模型把"页面"提升为可安装单元,跨设备迁移与多窗口能力受限;Stage 模型把 Ability 与窗口解耦(UIAbility + WindowStage),才支撑起后来的多窗口、一多与分布式调度。

B.1.4 网络:@ohos.net.http → rcp

项目 现状 替代方案 影响面
http.createHttp() 不再作为新项目主力 rcp.createSession() 全部 HTTP 调用
无内置拦截器链 能力缺失 session.interceptors 注册 Interceptor 鉴权注入、日志、重试、错误映射
连接复用配置繁琐 不推荐 SessionConfiguration(连接池、超时、代理) 性能
手动拼错误码 不推荐 rcp 统一错误模型 + 拦截器映射 错误处理

为什么废弃:@ohos.net.http 缺少拦截器、会话配置与统一的错误/响应模型,业务侧被迫重复造轮子。本教程网络层主力为 @kit.RemoteCommunicationKit 的 rcp,见 第 6 章 与 第 11 章 11/02。

B.1.5 持久化:PersistentStorage 的定位

项目 现状 替代方案 影响面
PersistentStorage V1 配套,不推荐新用 状态用 PersistenceV2;配置用 Preferences;结构化数据用 relationalStore 状态持久化
AppStorage V1 配套 AppStorageV2 应用级内存状态
LocalStorage V1 配套 AppStorageV2 或 @ObservedV2 单例 页面级状态

为什么废弃:V1 的持久化只支持简单 KV 且观测模型与 V2 不兼容。按数据形态选型:应用级可观测状态 → PersistenceV2;用户偏好 KV → Preferences;列表/关系数据 → RDB。

B.1.6 状态装饰器:V1 → V2

项目 现状 替代方案 影响面
@State / @Prop / @Link 未废弃,不推荐新用 @Local / @Param / @Param+@Event 组件状态
@Observed / @ObjectLink / @Track 未废弃,不推荐新用 @ObservedV2 / @Trace 数据类观测
@Watch 未废弃,不推荐新用 @Monitor 属性监听
@Provide / @Consume 未废弃,不推荐新用 @Provider / @Consumer 跨层级传值
@Component 未废弃,不推荐新用 @ComponentV2 组件声明

完整映射与混用禁忌见 附录 A。注意:V1 与 V2 不能在同一组件内混用,混用直接编译报错。

B.1.7 Context 传递:globalThis.getContext()

项目 现状 替代方案 影响面
globalThis.getContext() 不推荐 显式注入:组件用 this.getUIContext().getHostContext(),非 UI 层由入口透传 AbilityContext 资源、沙箱、系统能力访问

为什么废弃:全局取 Context 会让依赖关系隐式化,多实例场景(多窗口/多 Ability)下取到的可能不是你以为的那个;同时它让单测难以注入假 Context。

B.1.8 设备形态判断:mediaquery device-type → 断点

项目 现状 替代方案 影响面
mediaquery.matchMediaSync('(device-type: phone)') 不推荐 window.getWindowWidthBreakpoint() 布局分支
deviceInfo.deviceType 判断形态 不推荐 同上,基于窗口宽度断点 一多适配
按"手机/平板/PC"硬编码三套布局 不推荐 按 xs/sm/md/lg/xl 五档断点做响应式 全量布局

为什么废弃:设备形态是设备属性,而窗口宽度是运行时事实。折叠屏展开、平板分栏、PC 窗口自由缩放都让"设备形态"与"可用宽度"脱钩。断点才是稳定判据,见 第 11 章 11/07。

B.1.9 其他不推荐写法

项目 现状 替代方案 为什么
LazyForEach 的 keyGenerator 用 index 不推荐 用业务唯一 id 列表增删后 index 复用,导致复用错位、局部刷新错乱、动效异常
页面级注册/注销分布式同步监听 不推荐 应用级注册(Ability 生命周期) 页面销毁即断连,跨页/后台场景漏事件
在 build() 里做耗时计算 / new 对象 / 起定时器 不推荐 @Computed / ViewModel / 生命周期方法 build() 会被高频调用,副作用会重复触发、耗时计算拖垮帧率
用 px 写尺寸 不推荐 vp(尺寸)/ fp(字号) px 不随屏幕密度与用户字号缩放,一多适配必然错位
主线程同步阻塞式 IO 不推荐 异步 API 或 TaskPool 主线程阻塞直接表现为掉帧与 ANR
公开签名出现 any 不推荐 显式类型 / 泛型 / Object 破坏 ArkTS 静态约束,且掩盖真实契约
资源引用硬编码字符串路径 不推荐 $r('app.string.xxx') 无法多语言与主题化,且编译期无法校验

B.2 第二部分:版本门槛表(API 20 → 26)

B.2.1 已 stable、在 20 上无门槛的能力

能力 最低可用 API 在 20 上是否可用 降级方案
Navigation + NavPathStack + 系统路由表 API 12 可用 无需降级
状态管理 V2(@ComponentV2 / @ObservedV2 / @Trace / @Local / @Param / @Monitor / @Computed) API 12 可用 无需降级
rcp(@kit.RemoteCommunicationKit,含 Interceptor) API 12 可用 无需降级
HAP / HAR / HSP 三种包结构 API 12 可用 无需降级
@kit.* 聚合导入 API 12 可用 无需降级

B.2.2 API 26 新增能力(在 20 上必须降级)

本清单为教程基线设定,具体能力清单与起始版本以 26.0.0 官方 Release Notes 为准。

能力 归属 Kit 最低可用 API 在 20 上是否可用 降级方案
ArkUI 强化材质效果 ArkUI API 26 不可用 用基础背景色 / 模糊 / 阴影组合近似,或直接省略材质层
基于 V2 的新增组件 ArkUI API 26 不可用 用 @ComponentV2 自行封装等价组件;V2 状态模型 API 12 起可用,无需回退 V1
ModularObjectExtensionAbility Ability Kit API 26 不可用 用既有 ExtensionAbility 类型(如 ServiceExtension)承载同等逻辑
Core File Kit 沙箱目录共享为系统级可见 Core File Kit API 26 不可用 走文件选择器 Picker 由用户显式授权,或经"导出到下载目录"路径
Graphics Accelerate Kit 预启动 Graphics Accelerate Kit API 26 不可用 依赖系统默认渲染路径,不主动预启动
Notification Kit 半模态拉起通知设置 Notification Kit API 26 不可用 跳转系统设置页由用户手动开启通知,或引导至应用内设置说明页
ArkWeb Chromium 内核 132 → 144 ArkWeb 运行期能力 不可依赖 见下方专项说明,必须双代设备各测一遍

版本门槛:上表除 ArkWeb 外的前六项均为 API 26 新增(以 26.0.0 官方 Release Notes 为准)。写进 build() 或业务主链路前,必须用 canIUse 包裹(模板见 B.4),或在编译期通过 compatibleSdkVersion 与条件分支隔离。

B.2.3 ArkWeb 内核升级(专项)

维度 说明
变更 ArkWeb 内置 Chromium 内核由 132 升级到 144
性质 运行期能力,不是编译期 API。工程里没有任何"版本号"可写,行为差异在设备上才暴露
风险 Web 侧 JS/CSS 的兼容性差异、废弃特性移除、渲染与 API 行为微调
测试要求 必须在 API 20 设备与 API 26 设备上各跑一遍全部 Web 页面用例
兜底 关键 Web 页面保留本地兜底页(降级提示 + 原生等价入口)

误区:以为"我 targetSdkVersion 是 26,内核就是 144"。内核版本跟设备实际系统走,同一个 HAP 装在 20 和 26 的设备上,内核可能不同。跨代设备各测一遍是唯一可靠做法。


B.3 第三部分:工具链能力 ≠ 运行时能力

DevEco Studio 26 提供了若干"预览/检查"侧能力,它们只存在于 IDE 里,业务代码不能依赖、打包后不存在(8 档预览器等工具链能力的具体构成以 26.0.0 官方 Release Notes 与 IDE 实际为准):

工具链能力 归属 说明 为什么业务不能依赖
8 档断点 UI 预览 IDE 预览器 预览器可切 8 档宽度看布局 运行期横向断点枚举仍为 5 档(xs/sm/md/lg/xl);代码里只能按 5 档分支
Car 模拟器 IDE 模拟器 可用于车机形态预览 模拟器是开发环境,真机能力与车机 SDK 需另配;不是运行时保证
dump 文件解析 IDE 分析器 解析内存/线程 dump 需 IDE 打开解析,应用内无法调用;只能作为开发期定位手段
Code Scanner 资源泄漏检查 IDE 静态检查 静态扫描句柄/资源泄漏风险 编译期静态检查,不改变运行期行为;不能替代运行时监控
维度 工具链(IDE 侧) 运行时(业务代码可依赖)
断点档位 预览器 8 档 枚举 5 档:xs / sm / md / lg / xl
可编程性 不可编程 通过 getWindowWidthBreakpoint() 取当前档位
打包后是否存在 否 是
正确用法 开发期验证布局 业务分支只写 5 档逻辑

误区:在业务代码里按 8 档写 if (breakpoint === 'xxl')。运行期根本没有 xxl,这个分支永远为假。预览器给的 8 档是"看"的,代码里能判的是 5 档。


B.4 第四部分:canIUse 兜底写法模板

canIUse 是 ArkTS 提供的运行期能力探测入口,用于在旧设备上安全地探测新 API 是否存在,避免直接调用导致崩溃。

// lib_base/src/main/ets/utils/CanIUseGate.ets

/**
 * 能力探测登记的键名常量。
 * 键名为 canIUse 接受的系统能力字符串,避免散落在业务里。
 * 注意:下面各键值为**示例键名**,真实值以官方 SystemCapability 列表为准。
 */
export class AbilityKeys {
    /** API 26 新增:ArkUI 强化材质效果 */
    static readonly ARKUI_MATERIAL: string = 'SystemCapability.ArkUI.Material';
    /** API 26 新增:图形加速预启动 */
    static readonly GRAPHICS_PRELAUNCH: string = 'SystemCapability.Graphics.GraphicsAccelerate';
    /** API 12 起可用:rcp */
    static readonly RCP: string = 'SystemCapability.Communication.RemoteCommunicationKit';
}

export class CanIUseGate {
    /**
     * 探测单个能力。
     * @param key 系统能力字符串
     * @returns 当前设备是否具备该能力
     */
    static has(key: string): boolean {
        const ok: boolean = canIUse(key);
        return ok;
    }

    /**
     * 在具备能力时执行主逻辑,否则执行降级逻辑。
     * 用泛型返回,保证两条分支的类型一致。
     */
    static withFallback<T>(key: string, primary: () => T, fallback: () => T): T {
        if (CanIUseGate.has(key)) {
            return primary();
        }
        return fallback();
    }
}
// 使用示例(示意伪代码:applyEnhancedMaterial 的真实签名与宿主以官方 API 参考为准)
function applyCardMaterial(ctx: UIContext): void {
    const result: string = CanIUseGate.withFallback<string>(
        AbilityKeys.ARKUI_MATERIAL,
        () => {
            // API 26:使用强化材质效果
            ctx.applyEnhancedMaterial();
            return 'enhanced';
        },
        () => {
            // 降级:基础背景 + 阴影近似
            return 'basic';
        }
    );
    hilog.info(0x0000, 'Material', 'material mode: %{public}s', result);
}
使用要点 说明
只在"新能力"处包裹 stable 能力(Navigation / V2 / rcp)无需 canIUse
两条分支都必须可用 降级分支不是"抛异常",而是可用但效果弱的实现
键名集中管理 用 AbilityKeys 常量类收敛,避免字符串散落
单测覆盖两分支 用 Mock 让 canIUse 返回两种结果,两条路径都要有用例
不等于编译期隔离 canIUse 是运行期判断;编译期仍受 compatibleSdkVersion 约束

误区:以为 canIUse 返回 true 就万事大吉。它只回答"当前设备有没有这个能力",不回答"参数对不对、权限够不够、调用时机对不对"。探测通过 ≠ 调用成功,仍要处理调用的失败返回。


B.5 延伸阅读

📄 附录 C 术语字典 C-glossary.md

全书术语的完整定义。正文首次出现时用 > 术语 给一句话解释,完整定义收在这里。含义以本附录为准;与社区口语冲突时,以官方文档中的定义为准。

版本门槛:术语中标注的"API 12 起"等版本,均指该能力/装饰器在官方 API 参考中的起始版本,与 附录 B 的门槛表一致。


C.0 怎么用这本字典

场景 用法
读正文遇到 > 术语 回到本附录对应分组看完整定义与"常见误解"
看到一个不确定英文缩写 用编辑器的全文搜索按英文列检索,如搜 HSP
与同事/社区对不上口径 以本附录定义为准;若与官方文档冲突,以官方文档为准并反馈修订本附录
面试/评审前速览 重点看各组"常见误解"列——它列的是最容易答错的点

分组索引:C.1 应用模型与包结构 · C.2 语言与框架 · C.3 状态与渲染 · C.4 路由与生命周期 · C.5 数据与网络 · C.6 工程与构建 · C.7 测试与发布 · C.8 性能。


C.1 应用模型与包结构

术语 英文 一句话定义 常见误解 / 补充
Stage 模型 Stage Model 当前应用模型。以 UIAbility + WindowStage 为核心,Ability 与窗口解耦 常见误解:以为只是"FA 改个名字"。实际是模型重构,支撑多窗口、一多与分布式
FA 模型 Feature Ability Model 上一代应用模型,页面即 Ability,用 config.json 描述 已废弃。判断资料是否可用:出现 featureAbility / config.json 即旧时代
UIAbility UIAbility 带界面的 Ability,应用启动入口,持有 WindowStage 补充:一个 UIAbility 可挂多个 @Entry 页面;它比页面长寿
EntryAbility Entry Ability 工程默认生成的 UIAbility 实现,承载首屏 WindowStage 补充:名字可改,"入口"由 module.json5 中 abilities 的 skills 决定
WindowStage Window Stage UIAbility 内的窗口舞台,负责窗口生命周期与页面加载 常见误解:以为可直接操作窗口内容。内容由 loadContent 挂载的页面负责
AbilityStage AbilityStage 模块级的 Ability 运行时容器,模块首次加载时创建 补充:适合做模块级初始化(日志、全局配置);随模块而非随页面存活
Context Context 应用/模块上下文,访问资源、沙箱与系统能力的入口 常见误解:以为能随处 getContext()。它生命周期长于页面,慎持有,优先显式传递
Want Want 启动目标描述(要拉起哪个 Ability、带什么参数) 补充:显式 Want 指定 bundleName + abilityName,隐式 Want 靠 action/entities 匹配
ExtensionAbility ExtensionAbility 无界面或特殊形态的能力扩展,如卡片、服务、输入法、蓝牙 补充:是一族基类而非单个类型;选型看用途(Form/Service/InputMethod…)
HAP HarmonyOS Ability Package 可安装的应用包,一个应用可含多个 HAP 常见误解:以为一个应用只能一个 HAP。多 HAP 是常态(主包 + 特性包/免安装)
HAR Harmony Archive 静态共享库,随调用方一起打包进 HAP 常见误解:HAR 能减小包体。它是编译期合并,不共享运行时,减包用 HSP
HSP Harmony Shared Package 动态共享库,多 HAP 运行时共享一份代码与资源 补充:用于"多包共用且体积大"的场景(如公共 UI);单 HAP 场景用 HAR 更简单
AppScope AppScope 应用级资源与配置目录,跨模块共用 补充:app.json5 与全局资源在此;$r('app.string.xxx') 的解析范围与之相关
module.json5 module config 模块级配置:Ability 声明、权限、路由、依赖 补充:与 app.json5 分工——模块级 vs 应用级
app.json5 app config 应用级配置:包名、版本、图标、签名相关 补充:versionCode / versionName 在此维护
bundleName bundle name 应用唯一标识(包名),反写域名风格 常见误解:以为可随意改。上架后不可变,改了就是另一个应用;签名与推送都绑定它
依赖方向 Dependency Direction 模块间允许的引用方向,本教程约定单向向下 补充:entry → feature_* → lib_* → lib_base,禁止反向依赖与环

C.2 语言与框架

术语 英文 一句话定义 常见误解 / 补充
ArkTS ArkTS HarmonyOS 应用开发语言,TypeScript 的静态化子集,去掉了动态特性以获得确定性与性能 常见误解:"ArkTS 就是 TS"。不是——禁 any、禁运行时动态属性、禁结构性改型,编译期约束更强
ArkUI ArkUI 声明式 UI 框架,UI 是状态的函数(UI = f(state)) 补充:分声明式(ArkTS)与旧命令式范式;本教程只用声明式
声明式 Declarative UI 描述"UI 应该长什么样",由框架负责重建,而非手动操作控件 常见误解:以为声明式不能精细控制。用状态收敛 + @Computed 可达同等精度
UI = f(state) UI is a function of state 声明式的核心等式:给定同一份状态,渲染结果确定 补充:推论是"想让 UI 变,就改状态",禁止直接操作控件
struct struct ArkUI 的组件类型声明单元,内部用 build() 描述 UI 常见误解:以为等价于 TS class。不支持继承、不能自定义构造函数
@Entry Entry 标记页面入口组件,一个页面文件一个 补充:页面级 LocalStorage 为 V1 概念,见 附录 A
@Component / @ComponentV2 Component 组件装饰器,V1 / V2 两代 补充:二者不可在同一组件内混用,见 附录 A
@Builder Builder 声明式 UI 复用的函数式构件,可传参、可局部重建 常见误解:把它当普通函数调用来"取返回值"。它描述 UI 片段,不返回组件对象
@Styles / @Extend Styles / Extend 样式复用:@Styles 抽通用属性,@Extend 扩展指定组件的属性 补充:@Extend 只能扩展内置组件,自定义组件用它无效
装饰器 Decorator 以 @ 前缀的元信息标注,驱动框架行为(观测、生命周期、路由等) 补充:装饰器是编译期语义,不是运行期函数调用,写错位置直接编译失败
Sendable Sendable 可跨并发(Worker / TaskPool)安全传递的类型约束 常见误解:以为加 @Sendable 就能传。它要求类型内部全部成员也满足约束
TaskPool Task Pool 多线程任务池,用于把耗时任务移出主线程 补充:与 Worker 的取舍——TaskPool 适合短任务与自动调度,Worker 适合长驻线程
Worker Worker 独立线程,可长驻并持有状态,与主线程通过消息通信 补充:适合持续计算或需保留上下文的场景
UUID Universally Unique Identifier 通用唯一标识,用于生成不依赖后端的稳定 id 常见误解:以为 UUID 可作排序键。它无序,排序需另配时间戳或自增序
属性方法 Attribute Method 链式配置组件属性/事件的调用,如 .width(100) 补充:属性方法必须在组件声明块内调用,写在 build() 外无效

C.3 状态与渲染

术语 英文 一句话定义 常见误解 / 补充
状态管理 V1 State Management V1 第一代状态观测模型:@State / @Prop / @Link / @Observed / @ObjectLink 等 未废弃但不推荐新用;观测粒度粗(对象/数组只观测一层)。见 附录 A
状态管理 V2 State Management V2 第二代状态观测模型:@ComponentV2 / @ObservedV2 / @Trace / @Local / @Param / @Monitor / @Computed API 12 起可用,无门槛。V1/V2 不可在同一组件内混用
观测粒度 Observation Granularity 数据变化传播到第几层才触发 UI 刷新 常见误解:以为 V2 等于"全自动全量观测"。只有被 @Trace 标记的属性才被观测
@Local Local 组件内部可变状态,必须本地初始化 常见误解:用它接收外部参数。外部传入用 @Param
@Param Param 父组件传入的参数,子组件内只读 常见误解:在子组件里赋值。会编译报错,须经 @Event 回传
@Once Once 与 @Param 搭配,表示只取父级初值、之后本地自管 补充:用于"默认值可被覆盖但后续不受父级影响"的场景
@Event Event 子组件向父组件回传意图的回调声明 补充:V2 中替代 @Link 的双向能力,是单向数据流的闭环
@Provider / @Consumer Provider / Consumer 跨层级下传 / 上取,替代 V1 的 @Provide / @Consume 补充:@Consumer 必须有默认值,找不到 @Provider 时用默认值
@Trace Trace 在 @ObservedV2 类内标记"该属性参与观测"的白名单装饰器 常见误解:以为能标在普通 class 上。它只在 @ObservedV2 类内有效
@ObservedV2 Observed V2 可观测类装饰器,与 @Trace 配合提供属性级观测 常见误解:以为标了类就全属性可观测。未标 @Trace 的属性依然不可观测
@Computed Computed 派生状态:依赖变化才重算,并缓存结果 常见误解:在里面写副作用。它应保持纯计算,副作用交给 @Monitor
@Monitor Monitor 属性变更监听,可一次监听多个成员并拿到 before / now 补充:替代 V1 的 @Watch;触发时机是属性值变化后
UI 刷新 UI Refresh 状态变化后框架重建受影响的 UI 部分 常见误解:以为改了数据就一定刷新。未观测的数据改了不刷新,且不报错
重渲染 Re-render 组件重新执行 build() 构建其 UI 子树 常见误解:以为重渲染一定有性能问题。关键是范围——范围越小越安全
diff Diff 框架比对新旧 UI 描述,只更新变化节点的过程 补充:key 选择直接影响 diff 质量,见 LazyForEach 的 keyGenerator
AppStorageV2 App Storage V2 应用级内存状态容器,跨页面共享 常见误解:以为能直接存 number。V2 必须由类承载,不能存原始值
PersistenceV2 Persistence V2 应用级持久化状态容器,进程重启后仍保留 常见误解:以为数据不会丢。嵌套类属性漏标 @Type 会静默丢失
@Type Type 标注可持久化类中的嵌套类型,供反序列化还原 补充:漏标是 PersistenceV2 最隐蔽的坑,且不报错

C.4 路由与生命周期

术语 英文 一句话定义 常见误解 / 补充
Navigation Navigation 声明式路由容器组件,页面栈的可编程载体 补充:替代已不推荐(官方自 API 12 起不再维护)的 @ohos.router;支持分栏一多适配
NavPathStack Nav Path Stack 路由栈对象,提供 pushPath / pop / replace / clear 等操作 补充:路由栈可读可拦截,是路由守卫的载体
NavDestination Nav Destination Navigation 内的目标页容器组件,承载被路由到的页面 常见误解:以为它就是 @Entry 页面。它不是入口页面,由栈驱动显示
route_map.json Route Map 系统路由表,声明路由名与目标模块,支持动态路由 补充:让路由名与模块解耦,特性模块可自主注册;配合 NavPathStack.pushPathByName
路由名常量 Route Name Constant 以 XxxRoute.LIST 形式收敛的路由名,避免字符串散落 补充:本教程约定 lib_router 统一定义
aboutToAppear about To Appear 组件创建后、首次渲染前触发一次 补充:适合做一次性初始化;比"页面显示"更早,只执行一次
onPageShow on Page Show 页面级生命周期:页面显示时触发 补充:与 aboutToAppear(创建时一次)区分——切前后台会再次 onPageShow
onPageHide on Page Hide 页面级生命周期:页面隐藏时触发 补充:适合暂停轮询/释放动画;不等同于销毁
aboutToDisappear about To Disappear 组件即将销毁时触发 补充:注销监听、取消定时器、断开订阅的唯一正确位置
拦截器 Interceptor (routing) 路由跳转前的拦截钩子,用于登录校验、权限判断、重定向 常见误解:与网络拦截器混淆。二者同名但分属路由层与网络层,作用对象不同
路由守卫 Route Guard 拦截器的策略化封装,统一处理"未登录跳登录页"等规则 补充:本教程在 lib_router 收敛路由守卫规则
页面栈 Page Stack 当前导航层级的有序集合,决定返回行为 补充:Navigation 的页面栈由 NavPathStack 显式持有,可读可编程

C.5 数据与网络

术语 英文 一句话定义 常见误解 / 补充
Preferences Preferences 轻量 KV 偏好存储,适合配置项与开关 常见误解:拿来存列表。它适合小规模标量,列表/关系数据用 RDB
RDB Relational Database 关系型数据库(SQLite 形态),支持表结构、查询与事务 补充:适合结构化与需查询聚合的数据;注意迁移脚本与索引
KVStore Distributed KV Store 分布式键值存储,支持跨设备同步 补充:同步前需设备组网与权限;不是"自动复制",同步范围需显式设计
DataObject Distributed Data Object 分布式数据对象,多设备共享同一对象的状态容器 常见误解:以为和普通对象一样用。跨设备变更与会话管理有额外约束
Repository Repository 仓储层,聚合数据源并向 ViewModel 提供领域化接口 补充:本教程约定 XxxRepository,屏蔽"从哪取数"的细节
数据源 Data Source 具体取数实现,分远程 XxxRemoteDs 与本地 XxxLocalDs 补充:Mock 方案 B 就是替换数据源实现,见 附录 D
rcp Remote Communication Kit 本教程主力网络库(@kit.RemoteCommunicationKit),提供 Session / Interceptor / 统一错误模型 API 12 起可用。替代 @ohos.net.http,见 附录 B
Session Session rcp 的请求会话,持有连接池、超时、代理与拦截器配置 常见误解:每个请求 createSession()。应复用会话以复用连接
Interceptor Interceptor (network) 请求前/响应后插入的处理环节,用于注入鉴权、日志、错误映射、重试 补充:本教程 Mock 模式即在拦截器层实现,见 附录 D
统一包装 Uniform Envelope 所有接口共用的 code/message/data/traceId 响应外壳 补充:把 HTTP 状态码与业务是否成功解耦,客户端只需一套解析
traceId Trace ID 链路追踪标识,贯穿客户端日志与服务端日志 常见误解:以为只是调试用。排障时它是把两端日志串起来的唯一线索
幂等 Idempotency 同一操作重复执行的结果与执行一次一致的性质 常见误解:以为 GET 天然幂等就够了。提交/撤回类写操作要用幂等键保证
幂等键 Idempotency Key 请求携带的唯一标识,服务端据此去重,防重复提交 补充:通常由客户端生成并随请求头/体上传,服务端短期留存
401 单飞 Single Flight on 401 并发请求同时遇 401 时,只发起一次 Token 刷新,其余等待其结果 补充:避免刷新风暴与重复登录跳转,见 第 11 章 11/02
缓存 TTL Time To Live 缓存条目的存活时长,超时视为失效 补充:需配合"主动失效"策略,否则脏数据会持续到 TTL 到期

C.6 工程与构建

术语 英文 一句话定义 常见误解 / 补充
ohpm OpenHarmony Package Manager 三方库包管理器,管理依赖的下载与版本 补充:与 npm 生态不同;ohpm install 在 IDE 内置终端执行
oh-package.json5 ohpm package manifest 包描述文件,声明依赖、版本与入口 补充:HAR/HSP 的对外导出面由其 Index.ets 决定
Index.ets Export Surface 模块唯一对外导出文件,控制"外部能看见什么" 常见误解:以为模块内所有文件都能被外部 import。未在 Index.ets 导出的一律不可见
hvigor hvigor HarmonyOS 构建工具(构建编排引擎) 常见误解:与 hvigorw 混用。hvigor 是工具本体,hvigorw 是仓库内的包装脚本
hvigorw hvigor wrapper 工程内的构建入口脚本,锁定构建工具链版本 补充:CI 上用它而非全局 hvigor,保证版本一致;见 第 11 章 11/08
build-profile.json5 build profile 构建配置:产品、构建模式、签名、编译选项 补充:targetSdkVersion / compatibleSdkVersion 在此维护
buildMode Build Mode 构建模式,如 debug / release 常见误解:以为只是优化开关。它同时影响混淆、调试能力与包体
product Product 产品维度配置,同一工程可出多个定制包 补充:product 与 buildMode 是正交维度,组合出具体构建变体
混淆 Obfuscation 重命名与压缩符号,减小包体并增加逆向难度 常见误解:以为开了就不会崩。反射/序列化涉及的符号必须配保留规则
签名 Signing 用证书对 HAP 签名,设备据此校验来源与完整性 常见误解:把签名文件提交到仓库。签名文件与密码一律不入库,走 CI 密钥注入
signingConfig Signing Configuration build-profile.json5 中的签名配置段 补充:本地调试用自动签名,发布用正式证书
资源限定词 Resource Qualifier 资源目录后缀,按语言/主题/分辨率匹配不同资源 补充:如 zh_CN / dark;配合 $r() 由框架自动选择
静态门禁 Static Gate CI 中强制执行的检查项(编译、Lint、单测) 补充:见 第 11 章 11/08

C.7 测试与发布

术语 英文 一句话定义 常见误解 / 补充
测试金字塔 Testing Pyramid 自下而上分单元 / 集成 / UI 三层,数量递减 补充:本教程三层测试的指导思想,见 第 10 章
Hypium Hypium 官方测试框架,提供单元测试与用例组织 补充:本教程三层测试(单元 / 集成 / UI)的第一层载体
MockKit Mock Kit 测试替身能力,用于 mock 依赖与返回值 补充:配合 canIUse 双分支用例,覆盖降级路径
UiTest Ui Test UI 自动化测试能力,驱动真实界面做端到端验证 常见误解:以为能替代单元测试。它慢且脆,只覆盖主链路
覆盖率 Coverage 被测试执行到的代码/分支占比 常见误解:追求 100%。应优先覆盖业务分支与错误路径,而非无脑堆数值
三方库 Third-Party Library 通过 ohpm 引入的外部依赖 补充:引入前评估维护活跃度、包体与合规;不建议引入多套同类库
AGC AppGallery Connect 华为应用市场的开发者服务控制台 补充:签名证书、应用信息、上架位与分发都在此管理
AppGallery AppGallery 华为应用市场(上架目标与审核入口) 补充:审核重点是权限用途、隐私合规与内容规范
隐私声明 Privacy Statement 应用对数据收集与使用的告知文件 常见误解:以为写个页面就行。需与实际权限/采集行为一致,且上架前须可访问
首次启动同意 First-Launch Consent 首次启动时获取隐私政策同意的流程 补充:未同意前不得采集任何个人信息,否则直接审核驳回
权限申请 Permission Request 运行时向用户申请敏感权限的流程 补充:module.json5 声明 + 运行时 requestPermissionsFromUser,缺一不可
审核驳回 Review Rejection 上架审核未通过,附整改理由 补充:常见原因集中在权限用途不匹配、隐私声明缺失、内容违规

C.8 性能

术语 英文 一句话定义 常见误解 / 补充
Profiler Profiler 性能分析工具,采集 CPU、内存、帧率、启动等指标 补充:定位性能问题用"数据说话",见 第 8 章
hiTraceMeter hiTraceMeter 打点 API,标注关键代码段耗时,供分析工具可视化 常见误解:与日志等价。日志是文本,打点是有时间轴的区间数据
hidebug HiDebug 运行期调试接口,读取内存、句柄等诊断信息 补充:用于定位泄漏与内存增长,是 IDE 分析器的数据源之一
帧率 Frame Rate 每秒渲染帧数(FPS),低于 60 通常可感知卡顿 常见误解:只看平均值。卡顿要看掉帧分布与长帧峰值
长帧 Long Frame 单帧耗时显著超过帧预算(约 16.7ms)的帧 补充:长帧是卡顿的直接来源,定位时优先找最长帧而非平均帧
冷启动 Cold Start 进程不存在时的首次启动,含进程创建与首帧渲染 补充:与热启动区分;冷启动时长是首屏体感的核心指标
内存泄漏 Memory Leak 对象已无用途却被持续引用,内存占用只增不减 补充:页面销毁未注销监听/定时器是最高频成因
Code Scanner Code Scanner IDE 静态检查能力,扫描资源泄漏与代码风险 常见误解:以为它能在运行时兜底。它是编译期静态检查,打包后不生效
四段式性能报告 Four-Segment Report 本教程的性能描述结构:现场 → 数据 → 定位 → 修复与回归 补充:避免"体感很卡"这类无法验证的描述

C.9 易混词对照

评审与排障时最常被问错的五组,单独列出:

A B 区别一句话 判据
HAR HSP HAR 编译期合并进包,HSP 运行期共享一份 问"是否有独立运行时实体":无 → HAR,有 → HSP
Preferences RDB Preferences 是 KV 标量,RDB 是表结构可查询 问"要不要按条件查/聚合":不要 → Preferences
路由拦截器 网络拦截器 一个拦跳转,一个拦请求 问"拦的对象是页面还是 HTTP"
冷启动 热启动 进程是否已存在 看进程:不存在 → 冷启动
@Local @Param @Local 自己拥有并可变,@Param 由父传入且只读 问"值归谁所有":归自己 → @Local
@Computed @Monitor @Computed 产出派生值(纯计算),@Monitor 响应变化(可副作用) 问"要结果还是要反应"

C.10 延伸阅读

  • 本教程 第 0 章 学习准备(术语统一与版本体系)
  • 本教程 附录 A(状态管理 V1/V2 的完整映射)
  • 本教程 附录 B(弃用清单与版本门槛)
  • 本教程 附录 D(Mock 模式相关术语的落地)
  • 官方文档 → 应用开发导读 → HarmonyOS 应用开发基础知识(应用模型、包结构)
  • 官方文档 → ArkTS / ArkUI → 状态管理、性能优化(观测模型与性能指标)

📄 附录 D 本地 Mock 方案 D-mock-server.md

目标:没有后端,也能把全书与第 11 章 OfficeFlow 工程完整跑通。三种方案按"侵入度从低到高"排列,拦截器方案(A)是推荐默认;只读接口可用方案 B 快速铺数据;需要进程外联调或与后端"换开关即连真实服务"时用方案 C。

版本门槛:拦截器依赖 rcp 的 Interceptor,API 12 起可用,在兼容下限 20 上无门槛。三套方案都在 compatibleSdkVersion=6.0.0(20) 下可运行。


D.0 三套方案对比

维度 方案 A 拦截器 Mock 方案 B rawfile 静态资源 方案 C 本地 Node 服务
侵入度 只改网络层装配 只改数据源实现 不动客户端代码
覆盖能力 动态(分页/延迟/失败率/写操作) 只读 全量(含写操作与状态机)
是否需后端 否 否 本地跑一个进程
跨语言联调 不能 不能 能(真 HTTP)
真机 / 模拟器 可用 可用 需配置地址(模拟器与真机均连宿主机局域网 IP,见 D.4.1)
适用章节 全书、11/02、11/05、11/06 06、11/05 列表展示 11/02、11/05、11/06 全链路
推荐度 ★★★ 默认 ★★ 辅助 ★★ 联调补充

D.1 统一响应包装与 Result / AppError 的对应

所有方案的响应体统一为同一层包装,客户端只需一套解析逻辑:

{
  "code": 0,
  "message": "ok",
  "data": {},
  "traceId": "b7f1c2e4-1a2b-4c3d-9e8f-001122334455"
}
字段 类型 说明 缺失时的处理
code number 业务码。0 表示成功,非 0 为业务错误 缺失视为解析失败(Parse 失败)
message string 面向开发者/用户的提示文案 缺失时用本地兜底文案
data object | array | null 业务数据。成功时才有意义 成功但为 null 时按空处理,不报错
traceId string 链路追踪 id,用于日志串联与排障 缺失时客户端自生成一个并记日志

业务码约定(与第 11 章 AppErrorKind 映射):

code 含义 映射到 AppError.kind 客户端动作
0 成功 — (返回 Success<T>) 正常渲染
40000 参数错误 / 校验失败 Business 提示并阻止提交
40100 未登录 / Token 过期 Unauthorized 触发 401 单飞刷新,失败则跳登录
40400 资源不存在 Business 提示"内容已删除"并返回
40901 重复提交(幂等拦截) Business 按成功处理或提示"请勿重复提交"
50000 服务端错误 Server 提示重试;保留 traceId 供排障

口径:业务失败用 HTTP 200 + 业务码——若用 HTTP 4xx/5xx,客户端的状态码归一化会先把错误归为 UNKNOWN,业务码信息就丢了。唯一例外是鉴权失效(刷新失败、Token 过期演示):用 HTTP 401 + 40100,客户端据此清会话并触发单飞刷新/登出(mock-server.js 即按此实现)。

// lib_base/src/main/ets/result/Result.ets(AppError 在 lib_base/src/main/ets/error/AppError.ets;
// 与第 11 章 11/02 一致的判别模型)
export enum AppErrorKind {
    Network = 'Network',        // 连不上、超时
    Server = 'Server',          // 5xx / code=50000
    Business = 'Business',      // 业务码非 0
    Unauthorized = 'Unauthorized',
    Parse = 'Parse'             // 结构不符 / 字段类型错误
}

export class AppError {
    readonly kind: AppErrorKind;
    readonly code: number;
    readonly message: string;
    readonly traceId: string;

    constructor(kind: AppErrorKind, code: number, message: string, traceId: string) {
        this.kind = kind;
        this.code = code;
        this.message = message;
        this.traceId = traceId;
    }
}

export type Result<T> = Success<T> | Failure;

export class Success<T> {
    readonly value: T;
    constructor(value: T) {
        this.value = value;
    }
}

export class Failure {
    readonly error: AppError;
    constructor(error: AppError) {
        this.error = error;
    }
}

术语:统一包装指所有接口共用一层 code/message/data/traceId,把"HTTP 状态码"与"业务是否成功"解耦。好处是客户端只需要一个解析函数、一个错误映射表。Mock 阶段就按真实契约造数据,切到真实后端时零改动。


D.2 方案 A:拦截器级 Mock(推荐)

D.2.1 原理与装配

在 rcp 会话上注册一个 MockInterceptor。请求出栈前先经过它:命中 Mock 规则则本地构造响应返回、不发网络;未命中则透传给下一环,走真实链路。

Result.fetchData
   └── rcp.Session (拦截器链)
         ├── MockInterceptor   ← 开关打开时生效
         ├── AuthInterceptor   ← 注入 Token(未被 Mock 命中时)
         └── LogInterceptor
               └── 真实网络
// lib_network/src/main/ets/rcp/interceptors/MockInterceptor.ets(教学示意,结构与工程一致)
import { rcp } from '@kit.RemoteCommunicationKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { MockBody, MockRegistry } from '../mock/MockRegistry';
import type { MockRequest, MockResponse } from '../mock/MockRegistry';

const DOMAIN: number = 0x0000;
const TAG: string = 'MockInterceptor';

export class MockInterceptor implements rcp.Interceptor {
    private readonly enabled: () => boolean;

    constructor(enabled: () => boolean) {
        this.enabled = enabled;
    }

    async intercept(context: rcp.RequestContext): Promise<rcp.Response> {
        if (!this.enabled()) {
            // 开关关闭:直接透传,业务代码零感知
            return context.proceed(context.request);
        }

        const request: rcp.Request = context.request;
        const method: string = request.method.toString();
        const url: string = request.url.toString();
        const path: string = MockInterceptor.pathOf(url);

        const rule = MockRegistry.match(method, path);
        if (rule === undefined) {
            hilog.info(DOMAIN, TAG, 'mock miss, passthrough: %{public}s %{public}s', method, path);
            return context.proceed(request);
        }

        // 全局延迟(模拟网络耗时)
        const wait: number = MockRegistry.delays();
        if (wait > 0) {
            await MockInterceptor.sleep(wait);
        }

        // 全局失败率注入(验证错误处理与重试;业务失败仍是 HTTP 200 + 业务码)
        if (MockRegistry.shouldFail()) {
            hilog.warn(DOMAIN, TAG, 'mock injected failure: %{public}s %{public}s', method, path);
            return MockInterceptor.respond(500, MockBody.fail(50000, '服务端开小差了,请稍后重试'));
        }

        // 命中规则:handler 是纯函数,出参 statusCode + 响应体文本
        const req: MockRequest = MockInterceptor.snapshot(method, path, url, request);
        const result: MockResponse = rule.handler(req);
        hilog.info(DOMAIN, TAG, 'mock hit: %{public}s %{public}s (%{public}s)', method, path, rule.description);
        return MockInterceptor.respond(result.statusCode, result.body);
    }

    private static pathOf(url: string): string {
        const schemeEnd: number = url.indexOf('://');
        const start: number = schemeEnd >= 0 ? url.indexOf('/', schemeEnd + 3) : 0;
        const queryAt: number = url.indexOf('?', start >= 0 ? start : 0);
        if (start < 0) {
            return '/';
        }
        return queryAt < 0 ? url.substring(start) : url.substring(start, queryAt);
    }

    /** 从 rcp.Request 抽取请求快照(解析 query 与 body 文本,工程实现同理) */
    private static snapshot(method: string, path: string, url: string, raw: rcp.Request): MockRequest {
        // 省略:query 解析与 body 文本读取,产出 { method, path, query, body }
        return { method, path, query: {}, body: '' } as MockRequest;
    }

    private static sleep(ms: number): Promise<void> {
        return new Promise<void>((resolve: () => void) => {
            setTimeout(resolve, ms);
        });
    }

    private static respond(statusCode: number, body: string): rcp.Response {
        const headers: rcp.RequestHeaders = {
            'content-type': 'application/json; charset=utf-8'
        };
        return new rcp.Response(statusCode, headers, body);
    }
}

版本门槛:rcp.RequestContext 的具体成员名(request / proceed())以官方 API 26 参考为准。若你的 SDK 版本签名不同,只需调整这一处适配,MockRegistry 与规则表不受影响。

D.2.2 MockRule 注册表(与工程一致)

工程中的 MockRegistry 是静态类(lib_network/src/main/ets/mock/MockRegistry.ets):规则表、开关、全局失败率与全局延迟都挂在类静态字段上;handler 是纯函数——入参请求快照、出参 statusCode + 响应体文本。

// lib_network/src/main/ets/mock/MockRegistry.ets(节选)
export interface MockRequest {
    method: string;
    path: string;                     // 不含 query 的路径
    query: Record<string, string>;
    body: string;                     // 原始请求体文本
}

export interface MockResponse {
    statusCode: number;               // 业务失败也是 200;只有鉴权失效才回 401
    body: string;                     // 完整响应体文本(统一包装 code/message/data/traceId)
    delayMs?: number;                 // 规则级延迟,验证骨架屏与加载态
}

export interface MockRule {
    method: HttpMethod | '*';
    pathPrefix: string;               // 路径前缀,如 /approval/
    description: string;              // 命中时打进日志,便于确认"到底生效了哪条"
    handler: (req: MockRequest) => MockResponse;
}

export class MockRegistry {
    private static rules: MockRule[] = [];
    private static enabled: boolean = false;
    /** 全局失败率(0~1):验证错误处理与重试逻辑 */
    private static failureRate: number = 0;
    /** 全局延迟,叠加在规则自身 delayMs 之上 */
    private static globalDelayMs: number = 0;

    static enable(enabled: boolean): void { /* … */ }
    static setFailureRate(rate: number): void { /* … */ }
    static setGlobalDelay(ms: number): void { /* … */ }
    static register(rule: MockRule): void { /* … */ }
    static registerAll(rules: MockRule[]): void { /* … */ }

    /**
     * 按「方法 + 路径前缀」匹配,命中多条取**最长前缀**——
     * 这样 /approval/list/pending 优先于 /approval/list,与注册顺序无关。
     */
    static match(method: string, path: string): MockRule | undefined { /* … */ }
}

配套的 MockBody 工具类负责构造统一包装文本(ok / fail / page),规则内不必手写 JSON 拼接。

规则集按业务归属分布在各模块,各自在自己的 mock/ 目录注册,lib_network 不感知业务:

模块 规则文件 覆盖路径
lib_auth lib_auth/src/main/ets/mock/AuthMockRules.ets /auth/login、/auth/refresh、/auth/logout、/user/profile
feature_login feature_login/src/main/ets/mock/LoginMockRules.ets 验证码 / 短信登录等登录页演练
feature_approval feature_approval/src/main/ets/mock/ApprovalMockRules.ets /approval/list、/approval/detail、/approval/submit 等
feature_message feature_message/src/main/ets/mock/MessageMockRules.ets /message/list、/message/unreadCount、/message/read*
feature_collab feature_collab/src/main/ets/mock/CollabMockRules.ets /collab/devices、`/collab/draft/push
lib_distributed lib_distributed/src/main/ets/mock/DistributedMock.ets 跨设备草稿同步演练

误区:把全部业务 Mock 规则集中塞进 lib_network。网络库会因此反向依赖业务契约,破坏 lib → feature 的方向约束(9.2.2)。规则跟着业务走,网络库只提供注册与匹配机制。

D.2.3 响应体工具(MockBody)

规则只需要产出 statusCode + 文本,统一包装由 MockBody 收敛(与 D.1 契约一字不差):

// lib_network/src/main/ets/mock/MockRegistry.ets(节选)
export class MockBody {
    /** 成功包装:{ code: 0, message: 'ok', data, traceId } */
    static ok(data: string, traceId?: string): string;
    /** 业务失败包装:data 恒为 null */
    static fail(code: number, message: string, traceId?: string): string;
    /** 分页数据包装:{ items, page, size, total, hasMore },与真实接口契约一致 */
    static page(items: string, page: number, size: number, total: number): string;
}

D.2.4 边界场景与魔法关键词(ApprovalMockRules 摘录)

工程审批模块的规则文件(feature_approval/src/main/ets/mock/ApprovalMockRules.ets)内置了全部边界场景。场景切换有两种方式:改一行全局场景(ApprovalMockRules.scenario = MockScenario.DRIFT),或不改代码、在搜索框输入魔法关键词(只影响当次请求):

// feature_approval/src/main/ets/mock/ApprovalMockRules.ets(节选)
export enum MockScenario {
    NORMAL = 'normal',          // 正常数据
    EMPTY = 'empty',            // 空列表
    LONG_TEXT = 'long_text',    // 超长文本
    DRIFT = 'drift',            // 契约漂移(缺字段 / 类型不符)
    PAGE_OVERFLOW = 'page_overflow',
    TOKEN_EXPIRED = 'token_expired',
    SERVER_ERROR = 'server_error',
    RAW_ARRAY = 'raw_array'     // data 段直接是数组
}

// 业务 401 演练不走枚举,用 __biz401__ 关键词触发(HTTP 200 + 业务码 40100)

export class ApprovalMockRules {
    /** ★ 全局场景开关:改这一行即可让整个审批模块进入某个场景 */
    static scenario: MockScenario = MockScenario.NORMAL;

    /** 注册全部规则。由 entry 在启动时调用一次 */
    static install(): void {
        MockRegistry.registerAll(ApprovalMockRules.rules());
    }
}

魔法关键词全集(与本地假后端 server/mock-server.js 的约定打通,见 D.5):

关键词 效果
__empty__ 空列表(total=0、hasMore=false)
__long__ 超长标题 / 超长说明(验证截断与省略号)
__drift__ 缺字段 / 类型不符的记录(验证解析降级:默认值 + WARN)
__overflow__ 分页越界(items 空、hasMore=false)
__401__ HTTP 401(走状态码分支,触发单飞刷新)
__biz401__ HTTP 200 + 业务码 40100(走业务码分支)
__500__ HTTP 500(服务端错误)
__array__ data 段直接返回数组(验证容器归一化)

详情页 id 传 __401__ / __500__ / __notfound__ 可复现对应错误;提交页标题含"重复"或幂等键含 dup 返回 40901。

D.2.5 开关控制(EnvConfig.CURRENT)

工程中不存在独立的 MockSwitch。环境开关收敛在 lib_base/src/main/ets/const/AppConst.ets 的 EnvConfig.CURRENT——一行改环境,业务代码只读判定结果:

// lib_base/src/main/ets/const/AppConst.ets(节选)
export enum Env {
    MOCK = 'mock',        // 本地 Mock:不出网,全部走 lib_network 的 MockInterceptor
    DEV = 'dev',          // 开发环境(本地假后端 server/mock-server.js 也配在这档)
    STAGING = 'staging',  // 预发环境
    PROD = 'prod'         // 生产环境
}

export class EnvConfig {
    /** 当前环境。发版前改为 PROD。 */
    static readonly CURRENT: Env = Env.MOCK;

    static baseUrl(): string {
        switch (EnvConfig.CURRENT) {
            case Env.MOCK:    return 'https://mock.officeflow.local';
            case Env.DEV:     return 'https://dev-api.officeflow.example.com';
            case Env.STAGING: return 'https://stg-api.officeflow.example.com';
            case Env.PROD:    return 'https://api.officeflow.example.com';
            default:          return 'https://api.officeflow.example.com';
        }
    }

    static isMock(): boolean {
        return EnvConfig.CURRENT === Env.MOCK;
    }
}
// 会话装配:MockInterceptor 的启用判定只读 EnvConfig.isMock()
session.interceptors = [
    new MockInterceptor(() => EnvConfig.isMock()),
    new AuthInterceptor(),
    new LoggingInterceptor()
];

发布门禁:CURRENT 停在非 PROD 时,release 构建会被 CI 拦下——scripts/inject-version.mjs 的 gateReleaseEnv() 读取 AppConst 中的环境字面量做精确匹配,非 PROD 直接 exit 1(逃生开关 --allow-mock-in-release);该门禁作为 scripts/ci.sh 的第 6 阶段执行(仅 --release 时运行,且不受 SKIP_BUILD 影响)。也就是说"带着 Mock 环境出包"在流程上不可达,而不是靠人记得改。

误区:把 if (EnvConfig.isMock()) 散落到各个 Repository。这样切环境要改十几处,且极易漏改。正确做法:开关只在拦截器/会话装配这一层判定一次,Repository 与 ViewModel 完全不知情。


D.3 方案 B:rawfile 静态资源 Mock

适合只读接口:把响应 JSON 直接放进 rawfile,用资源管理器读取。无需网络层改动,适合先铺数据、快速出页面。

目录约定:

lib_network/src/main/resources/rawfile/mock/
  ├── approval_list_page1.json
  ├── approval_detail_AP-1001.json
  ├── approval_detail_not-exist.json     ← 40400 样本
  ├── approval_list_empty.json           ← 空列表样本
  └── message_unread_count.json
// lib_network/src/main/ets/mock/RawfileMockLoader.ets
import { resourceManager } from '@kit.LocalizationKit';
import { util } from '@kit.ArkTS';
import { AppError, AppErrorKind, Failure, Result, Success } from 'lib_base';
// WrappedResponse 为统一包装类型(工程内 mock/MockTypes 定义),解析 rawfile 中的 JSON 用

export class RawfileMockLoader {
    private readonly resMgr: resourceManager.ResourceManager;

    constructor(resMgr: resourceManager.ResourceManager) {
        this.resMgr = resMgr;
    }

    /**
     * 读取 rawfile 中的 JSON 并解析为 T。
     * 读取失败 → Network 错误;解析失败 → Parse 错误。
     */
    async load<T>(name: string): Promise<Result<T>> {
        const traceId: string = `raw-${Date.now().toString(16)}`;
        try {
            const bytes: Uint8Array = await this.resMgr.getRawFileContent(`mock/${name}`);
            const text: string = util.TextDecoder.create('utf-8').decodeToString(bytes);
            const wrapped: WrappedResponse<T> = JSON.parse(text) as WrappedResponse<T>;
            if (wrapped.code !== 0) {
                return new Failure(new AppError(AppErrorKind.Business, wrapped.code,
                    wrapped.message, wrapped.traceId ?? traceId));
            }
            return new Success<T>(wrapped.data);
        } catch (e) {
            const err = e as BusinessError;
            if (err.code === 9001005 || err.code === 9001007) {
                return new Failure(new AppError(AppErrorKind.Network, err.code,
                    '读取本地 Mock 资源失败', traceId));
            }
            return new Failure(new AppError(AppErrorKind.Parse, err.code ?? -1,
                'Mock 资源解析失败', traceId));
        }
    }
}
约定 说明
文件命名 <业务>_<谓词>_<参数>.json,如 approval_detail_AP-1001.json
内容格式 与真实响应完全一致的包装体,含 code/message/data/traceId
边界样本 每个接口至少留一份异常样本(空列表、40400、字段缺失),供第 10 章测试用
切换 数据源实现二选一注入,ViewModel 无感
局限 只能读,不能覆盖提交/撤回等写操作;分页需预生成多份文件

D.4 方案 C:轻量本地服务

工程内置了一个零依赖的 Node 假后端:code/office-flow/server/mock-server.js(Node 16+ 直接运行,无任何三方依赖)。它存在的意义是两件拦截器 Mock 做不到的事:验证真实 HTTP 行为(状态码、超时、大响应体分片),以及校验客户端与后端的契约一致性——拦截器 Mock 的响应也是我们自己写的,写错了两边一起错。

D.4.1 启动方式与设备连接

# 在工程 code/office-flow 目录下执行;默认端口 3000,可用 PORT 环境变量改
node server/mock-server.js

# 自定义端口
PORT=8080 node server/mock-server.js

# 预期输出(启动横幅)
# OfficeFlow mock server listening on http://0.0.0.0:3000
#
# 可用接口:
#   POST /auth/login          账号 demo / 密码 demo1234
#   POST /auth/refresh        refreshToken 需以 rt_ 开头
#   POST /auth/logout
#   GET  /user/profile
#   GET  /approval/list?page=&size=&status=&keyword=
#   GET  /approval/list/pending
#   GET  /approval/detail?id=
#   POST /approval/submit     带 Idempotency-Key 头
#   POST /approval/withdraw
#   POST /approval/decide
#   GET  /approval/count
#   GET  /message/list | /message/unreadCount | /message/list/empty
#   POST /message/read | /message/readAll
#   GET  /collab/devices | /collab/devices/none
#   POST /collab/draft/push | GET /collab/draft/pull
#
# 魔法 keyword(GET /approval/list):
#   __empty__    空列表          __overflow__ 分页越界
#   __edge__     仅边界数据(含缺字段/类型错误/超长/未知枚举/空主键)
#
# 故障演练(任意接口追加):?simulate=timeout|500|401|403|404|429|slow|malformed|raw-array

⚠️ 不要用 127.0.0.1/localhost 连设备:模拟器与真机上的回环地址指向设备自身,不是你的开发机。设备侧接入一律用宿主机的局域网 IP(开发机与设备须在同一网段;服务监听在 0.0.0.0,局域网可达)。

场景 baseUrl
模拟器 / 真机(推荐) http://<开发机局域网IP>:3000
临时端口转发(备选) 先 hdc rport tcp:3000 tcp:3000,之后可用 http://127.0.0.1:3000(rport 把设备的 3000 转发到宿主机)
浏览器 / curl 自测 http://127.0.0.1:3000(本机直接访问)

工程侧接入:把 EnvConfig.CURRENT 改为 Env.DEV,并把 EnvConfig.baseUrl() 的 DEV 分支指向 http://<局域网IP>:3000(见 D.6.1)。

D.4.2 实现骨架(与 server/mock-server.js 一致)

下面是节选骨架,完整实现(约 580 行,含全部端点与边界数据构造)见 code/office-flow/server/mock-server.js。

// code/office-flow/server/mock-server.js(Node.js 运行,非 ArkTS;节选)
'use strict';
const http = require('http');
const url = require('url');

const PORT = Number(process.env.PORT || 3000);

// ── 统一响应包装:业务失败用 HTTP 200 + 业务码;鉴权失效用 HTTP 401 + 40100 ──
function ok(res, data) {
  sendJson(res, 200, { code: 0, message: 'ok', data, traceId: traceId() });
}
function bizFail(res, code, message) {
  sendJson(res, 200, { code, message, data: null, traceId: traceId() });
}

// ── 故障注入:优先于业务逻辑,任意接口可用 ──
function applySimulation(res, simulate) {
  switch (simulate) {
    case 'timeout':   /* 60 秒后销毁连接,让客户端先超时 */ return true;
    case '500':       sendJson(res, 500, { code: 50000, message: 'simulated server error', ... }); return true;
    case '401':       sendJson(res, 401, { code: 40100, message: 'token expired', ... }); return true;
    case '403':       sendJson(res, 403, { code: 40300, message: 'forbidden', ... }); return true;
    case '404':       sendJson(res, 404, { code: 40400, message: 'not found', ... }); return true;
    case '429':       sendJson(res, 429, { code: 42900, message: 'too many requests', ... }); return true;
    case 'malformed': /* HTTP 200 + 截断 JSON,验证客户端 Parse 分支 */ return true;
    case 'raw-array': /* HTTP 200 + 裸数组响应(非统一包装),验证容器归一化 */ return true;
    default: return false;   // simulate=slow 在业务逻辑前 sleep 3000ms,见 delay()
  }
}

// ── 请求分派:auth / approval / message / collab 四组 handler,均无 /api 前缀 ──
const server = http.createServer(async (req, res) => {
  const { pathname, query } = parseQuery(req);
  setCORS(res);                                    // CORS 全开放,便于浏览器调试
  if (applySimulation(res, query.simulate)) return; // 故障注入优先
  await delayIfSlow(req, res);                      // simulate=slow → 延迟 3 秒

  let handled = await handleAuth(req, res, pathname);
  if (!handled) handled = await handleApproval(req, res, pathname, query);
  if (!handled) handled = await handleMessage(req, res, pathname, query);
  if (!handled) handled = await handleCollab(req, res, pathname);
  if (!handled) {
    sendJson(res, 404, { code: 40400, message: `no route for ${req.method} ${pathname}`, ... });
  }
});

server.listen(PORT, () => {
  console.log(`OfficeFlow mock server listening on http://0.0.0.0:${PORT}`);
  // …打印可用接口、魔法 keyword、simulate 全集(见 D.4.1 预期输出)
});

要点:

设计点 说明
监听 0.0.0.0 局域网设备可直连;PORT 环境变量可改端口
统一包装 { code, message, data, traceId } 成功 code=0;traceId 同时写入 X-Trace-Id 响应头,与客户端日志串联
业务失败 = HTTP 200 + 业务码 参数/校验错误统一 40000;重复提交/冲突 40901
鉴权失效 = HTTP 401 + 40100 出现在 /auth/refresh 失败与 /approval/detail?id=__401__,客户端据此清会话
keyword 数据开关 __empty__ / __overflow__ / __edge__ 切换列表数据集(见 D.5)
id 魔法值 __500__ / __401__ / __notfound__ / __drift__(契约漂移:title 被后端改名为 subject)
边界数据内置 默认列表 = 120 条常规 + 5 条边界样本(缺字段、类型错误、超长文本、未知枚举、空主键)

D.4.3 接口契约表(20 个端点,无 /api 前缀)

以下契约与 mock-server.js 逐字对齐(客户端解析层必须按此实现):

认证与用户

方法 路径 请求 响应 data 错误分支
POST /auth/login { account, password } { accessToken, refreshToken, expiresIn: 7200, user{ id, name, avatarUrl, phone, email, tenantId, roles[] } } 40000(缺参 / 密码 ≠ demo1234,账号非空即可)
POST /auth/refresh { refreshToken }(须 rt_ 开头) { accessToken, refreshToken: "rt_rotated_…", expiresIn: 7200 } HTTP 401 + 40100(token 非 rt_ 开头;客户端清会话并登出)
POST /auth/logout 无 { loggedOut: true } —
GET /user/profile 无 user 对象(同登录返回的 user) —

审批

方法 路径 请求 响应 data 错误分支
GET /approval/list query:page、size、status?、keyword? { items[], page, size, total, hasMore } keyword 魔法值见 D.5;默认 125 条(120 常规 + 5 边界)
GET /approval/list/pending query:page、size { items[](13 条 pending), page, size, total: 13, hasMore } —
GET /approval/detail query:id 审批单对象(含 id/title/applicantName/applicantDept/category/status/summary/amount/urgent/createdAt/updatedAt) HTTP 500+50000(id=__500__)、HTTP 401+40100(__401__)、HTTP 404+40400(__notfound__)、契约漂移样本(__drift__:title 改名 subject,applicantName 改名 applicant)
POST /approval/submit body:表单 + 请求头 Idempotency-Key { id, status: 'pending', idempotencyKey, acceptedAt } 40901(标题含"重复"或幂等键含 dup)
POST /approval/withdraw { id } { id, status: 'withdrawn', updatedAt } —
POST /approval/decide { id, decision }(approve / reject) { id, status: 'approved' | 'rejected', updatedAt } 40901(id=conflict,已被他人处理)、40000(decision 非法)
GET /approval/count 无 { pending: 13, urgent: 2, draft: 4 } —

消息

方法 路径 请求 响应 data 错误分支
GET /message/list query:page、size { items[]{ id, type, title, body, relatedId, read, createdAt }, page, size, total: 57, hasMore } —
GET /message/list/empty 无 { items: [], page: 1, size: 20, total: 0, hasMore: false } —
GET /message/unreadCount 无 { count }(真实计数:按 read 字段统计,初始 15,随已读操作变化) —
POST /message/read { id } { updated: 1 }(命中)或 { updated: 0 }(未找到) 40000(缺 id)
POST /message/readAll 无 { updated }(本次置为已读的条数) —

协同

方法 路径 请求 响应 data 错误分支
GET /collab/devices 无 { devices[]{ deviceId, deviceName, online, deviceType } }(1 台在线平板 + 1 台离线手机) —
GET /collab/devices/none 无 { devices: [] } —
POST /collab/draft/push { draftId, version, … } { draftId, version(传入 +1), updatedAt, accepted: true } —
GET /collab/draft/pull 无 { draftId: 'demo', version: 3, updatedAt, title, body, fieldClock: { title: 2, body: 3 } }(用于冲突裁决演练) —

未知路由统一返回 HTTP 404 + 40400,message 形如 no route for GET /xxx。CORS 全开放,可用浏览器直接调试;所有响应都带 X-Trace-Id 头。


D.5 故障演练与魔法参数

D.5.1 ?simulate= 故障注入全集

任意端点追加 ?simulate=<值> 即可演练对应故障(优先于业务逻辑执行,mock-server.js 与工程内 Mock 场景都支持):

simulate 值 行为 验证目标
timeout 服务端挂起 60 秒后销毁连接(客户端超时先行触发) 超时错误码与重试
500 HTTP 500 + code: 50000 "simulated server error" 服务端错误提示与重试
401 HTTP 401 + code: 40100 401 单飞刷新与跳登录
403 HTTP 403 + code: 40300 无权限提示
404 HTTP 404 + code: 40400 资源不存在处理
429 HTTP 429 + code: 42900 限流提示与退避
slow 延迟 3 秒后正常返回 骨架屏 / 加载态 / 取消请求
malformed HTTP 200 + 截断的非法 JSON({"code":0,"data":{oops) 客户端 Parse 分支
raw-array HTTP 200 + 裸数组响应(非统一包装) 容器归一化兼容

D.5.2 魔法关键词与魔法 id

列表 keyword(GET /approval/list,本地假后端与拦截器 Mock 同一套约定):

keyword 数据集 验证目标
__empty__ 空列表(total=0、hasMore=false) 列表空态
__overflow__ 分页越界(items 空、hasMore=false) 客户端不无限加载,不能按"本页条数 < size"误判
__edge__ 仅 5 条边界样本 一次看全解析降级路径
其它非 __ 开头 按标题包含过滤 常规搜索

详情 id(GET /approval/detail):

id 行为
__500__ HTTP 500 + 50000
__401__ HTTP 401 + 40100(触发单飞刷新)
__notfound__ HTTP 404 + 40400
__drift__ 契约漂移:正常 200,但 title 被后端改名为 subject、applicantName 改名为 applicant——客户端应降级渲染并记 WARN

写操作触发器:

位置 触发条件 结果
POST /approval/submit 标题含"重复"或 Idempotency-Key 含 dup 40901 请勿重复提交
POST /approval/decide id === 'conflict' 40901 该单据已被他人处理
POST /message/read 缺 id 40000
POST /auth/refresh refreshToken 非 rt_ 开头 HTTP 401 + 40100(清会话登出)

D.5.3 内置边界数据(__edge__ 的 5 条样本)

keyword=__edge__ 返回的边界样本,每条对应一类线上事故:

id 缺陷类型 验证目标
AP90001 缺 applicantDept 与 amount 字段 解析层用默认值降级,不崩溃
AP90002 类型错误:amount 是字符串 "1280.50"、urgent 是数字 1 宽松解析与归一化
AP90003 超长文本:标题 repeat(60)、说明 repeat(200) 截断、maxLines 与省略号
AP90004 未知状态枚举 escalated_to_legal 降级到 Unknown,不渲染空白
(空 id) 主键为空的脏数据 客户端整条丢弃,不出现"幽灵行"

D.5.4 Mock 数据设计原则

Mock 数据的目的不是"让页面有东西显示",而是提前暴露真实世界的脏数据。第 6 章(解析健壮性)与第 10 章(错误处理)都依赖这些边界样本。

边界类型 造法 验证目标 用在哪
空列表 keyword=__empty__ 或 /message/list/empty 列表空态与分页到底提示 06、11/05
分页越界 keyword=__overflow__ 或超大 page hasMore=false,客户端停止加载 06、11/05
超长文本 keyword=__long__ 或边界样本 AP90003 单行/多行截断、maxLines 与省略号 03、11/05
缺字段 边界样本 AP90001 / __drift__ 解析层默认值降级 02、06
类型错误 边界样本 AP90002(金额为字符串) 宽松解析与归一化 02、06
未知枚举 边界样本 AP90004 降级 Unknown 而非白屏 02、11/05
裸数组响应 ?simulate=raw-array / __array__ 容器归一化 06、11/02
契约漂移 id=__drift__ 字段改名后的降级渲染 + WARN 11/02
401 过期 ?simulate=401、id=__401__、__biz401__、refresh 失败 401 单飞刷新与跳登录(HTTP 码与业务码两条路径) 11/04
500 错误 ?simulate=500 / id=__500__ 服务端错误的提示与重试 10、11/02
重复提交 submit 标题含"重复"或幂等键含 dup 写操作幂等(40901) 11/05
慢响应 ?simulate=slow(3 秒) 骨架屏 / 加载态 / 取消请求 03、08

误区:只造"好看的数据"。只造正常数据的 Mock 会让人误以为解析层不需要防御——等到接真实后端时,一个 null 就能让详情页白屏。Mock 阶段就要把脏数据喂给解析层。


D.6 切换与验收

D.6.1 一行改环境(EnvConfig.CURRENT)

环境切换只改 lib_base/src/main/ets/const/AppConst.ets 的 EnvConfig.CURRENT 一个字面量(见 D.2.5):

目标 操作 涉及代码
切到本地假后端(方案 C,server/mock-server.js) EnvConfig.CURRENT = Env.DEV,并把 EnvConfig.baseUrl() 的 Env.DEV 分支改为 http://<开发机局域网IP>:3000 AppConst.ets 1 处
切回拦截器 Mock(方案 A) 改回 Env.MOCK(拦截器仅在 EnvConfig.isMock() 时生效) AppConst.ets 1 处
切到预发 / 生产 Env.STAGING / Env.PROD(baseUrl() 自动跟随) AppConst.ets 1 处
只读接口走 rawfile(方案 B) 数据源实现改注入 RawfileMockLoader 数据源装配 1 处

发布门禁:CURRENT 停在非 PROD 时无法打出正式包——scripts/inject-version.mjs 的 gateReleaseEnv() 对 AppConst 中的环境字面量做精确形状匹配(匹配不到即 exit 1,绝不静默跳过),仅 PROD 放行(逃生开关 --allow-mock-in-release);该检查作为 scripts/ci.sh 的**第 6 阶段「发布环境门禁」**执行(仅 --release 时运行,不受 SKIP_BUILD 影响,详见第 11 章 11/08)。

不污染业务代码的判定标准:全局搜索 mock / isMock / EnvConfig,命中范围不得超出 lib_base 的 AppConst.ets、lib_network 的装配层与各模块 mock/ 目录。若 Repository、ViewModel、页面文件里出现环境判断,即为设计错误。

D.6.2 Mock 模式验收 checklist

  • [ ] EnvConfig.CURRENT = Env.MOCK(飞行模式即可,不出网)时,登录、审批列表、详情、提交、撤回、消息全链路可跑通
  • [ ] 方案 C 接入:Env.DEV + 局域网 IP 指向 server/mock-server.js 后,同一套业务代码全链路可跑通(业务层零改动)
  • [ ] 列表分页:首页 / 中间页 / 末页 / __overflow__ 越界页行为正确,hasMore 与实际一致
  • [ ] __empty__ 空列表、__edge__ 五条边界样本(缺字段 / 类型错误 / 超长 / 未知枚举 / 空主键)渲染无异常,无布局溢出、不崩溃
  • [ ] id=__drift__ 契约漂移样本走解析降级(默认值渲染 + WARN 日志),页面不白屏
  • [ ] ?simulate=malformed 与 raw-array 走 Parse / 归一化分支,页面不白屏
  • [ ] ?simulate=401、id=__401__、__biz401__ 三条路径都能触发 401 单飞:并发请求只刷新一次 Token,/auth/refresh 失败(非 rt_ 开头)后统一跳登录
  • [ ] ?simulate=500 与 MockRegistry 全局失败率命中的请求,错误提示文案与 traceId 正确展示
  • [ ] submit 标题含"重复"或幂等键含 dup 返回 40901,UI 不产生重复单;decide id=conflict 返回 40901
  • [ ] ?simulate=slow(3 秒)下加载态 / 骨架屏 / 取消请求均正常
  • [ ] /message/unreadCount 返回真实计数(初始 15),read / readAll 后计数随之变化
  • [ ] 发布门禁验证:CURRENT 非 PROD 时执行 node scripts/inject-version.mjs --check-release-env 应 exit 1;改回 PROD 后通过
  • [ ] 全局搜索确认业务层无环境 / Mock 相关判断

D.7 延伸阅读

📁 配套文件目录

HarmonyOS-doc/
  README.md
  code/
    office-flow/
      .gitignore
      README.md
      build-profile.json5
      hvigorfile.ts
      obfuscation-rules.txt
      oh-package.json5
  docs/
    00-before-you-start.md
    01-environment-toolchain.md
    02-arkts-core.md
    03-arkui-layout.md
    04-routing-lifecycle.md
    05-state-management.md
    06-persistence-network.md
    07-distributed.md
    08-performance.md
    09-modularization.md
    10-testing-release.md
    11/
      00-overview.md
      01-architecture-modules.md
      02-network-result-error.md
      03-logging-tracking.md
      04-login-auth.md
      05-approval-flow.md
      06-notification.md
      07-multi-device-collab.md
      08-build-ci.md
    appendix/
      A-v1-v2-cheatsheet.md
      B-deprecated-and-gates.md
      C-glossary.md
      D-mock-server.md

完整源码 Gitee 仓库浏览 →