茶器艺科智造HarmonyOS应用实战-02-冷启动深链要覆盖旧草稿,onNewWant又可能抢跑:用先恢复后消费固定Want优先级
茶器艺科智造HarmonyOS应用实战-02-冷启动深链要覆盖旧草稿,onNewWant又可能抢跑:用先恢复后消费固定Want优先级
用户在系统外点击 chaqi://entry?tab=2,期望应用打开指定页签;应用本地又保存着上次退出时的茶杯草稿。此时默认值、Preferences 草稿和 Want 深链同时拥有修改页面状态的机会。如果没有固定顺序,页面可能先跳到目标位置,几百毫秒后又被旧草稿盖回去。
从调用顺序看,当前源码表达了“草稿后消费 Want”的优先级意图:EntryAbility.onCreate 先调用 HAR 写入 pending,HSP 页面恢复 Preferences 后再调用 HAR 取出它。但这还不能证明普通冷启动已经成功。工程没有开启 deduplicateHar,libraryhar 会按缺省规则分别打进 HAP 与 HSP;两侧调用同名函数,不足以证明它们读写同一个模块级变量。即使运行验证证明当前工具链下恰好能交接,草稿恢复期间的 onNewWant 仍存在另一层竞态:事件可能让页面提前消费路由,随后完成的旧草稿又覆盖新状态。
本篇同时收口“状态到底由哪个模块唯一持有”和“何时允许消费”两个问题。推荐把带 id 的 RouteInbox 放在唯一的 HSP 宿主中,由 entry 调用 HSP 窄端口投递,页面在同一 HSP 内读取;另一条路线是完整启用 HAR 去重后,再用产物与双侧实例日志证明 HAR 单例身份。两条路线都把 EventHub 限定为唤醒信号,并按“先恢复、再合并、成功后 ACK”固定优先级。

本文区分三类内容:
- 源码事实:当前生命周期、草稿恢复、pending 写入与消费的调用位置;跨 HAP/HSP 是否命中同一 pending 尚未验证。
- 本文设计:HSP 唯一宿主、带 id 的 latest-wins inbox、字段级合并和 ACK。
- 待验证事项:构建、端到端 URI 拉起、后台复用、进程重建和真机持久化。
一、先写下状态优先级,避免回调各自决定结果
页面启动时可能同时出现三份状态:
- 代码中的安全默认值。
- Preferences 中上次保存的草稿。
- 本次系统 Want 携带的显式导航或生成参数。
对这个应用,合理优先级是:
默认值 < Preferences 草稿 < 最新有效 Want
“Want 优先”不是把整个草稿丢掉,而是字段级覆盖。只有 tab 时只改页签;generate=true 时进入生成页;Want 提供了高度、直径等杯体参数才覆盖对应字段,缺失字段继续沿用草稿。
可以把规则写成决策表:
| 输入组合 | 页面基线 | 最终覆盖 |
|---|---|---|
| 无草稿、无 Want | 默认值 | 无 |
| 有草稿、无 Want | Preferences 草稿 | 无 |
| 草稿 + tab Want | Preferences 草稿 | selectedTabIndex |
| 草稿 + generate Want | Preferences 草稿 | 进入生成页及 Want 提供的杯体字段 |
| 草稿读取失败 + Want | 默认值 | Want 提供的字段 |
优先级必须由一个协调点执行。若 onCreate、onNewWant、Preferences 回调和 EventHub 监听器都能独立修改页面,代码阅读顺序无法保证运行顺序。
二、module.json5 决定哪些 URI 能进入 EntryAbility
entry/src/main/module.json5 为 EntryAbility 配置了 browsable 实体、viewData 动作,以及 scheme 为 chaqi、host 为 entry 的 URI 入口。关键结构可以简化成:
{
"abilities": [
{
"name": "EntryAbility",
"exported": true,
"skills": [
{
"entities": ["entity.system.browsable"],
"actions": ["ohos.want.action.viewData"],
"uris": [
{
"scheme": "chaqi",
"host": "entry"
}
]
}
]
}
]
}
华为应用 URI 配置说明了 skills、actions、entities 和 uris 的匹配关系。清单负责决定请求能否进入应用;进入之后仍要在业务层校验 tab、generate 和数值范围,不能因为系统完成 URI 匹配就信任所有查询参数。
当前配置没有显式 launchType。根据华为UIAbility 启动模式,singleton 是默认模式:第一次创建实例进入 onCreate,已有实例再次收到请求时进入 onNewWant。两条路径必须共享同一份路由协议,却不能假定页面处于同一启动阶段。
三、onCreate 负责存入,onNewWant 还会发出唤醒
当前 EntryAbility 的关键逻辑是:
onCreate(want: Want): void {
setChaqiRouteFromWant(want)
}
onNewWant(want: Want): void {
setChaqiRouteFromWant(want)
this.context.eventHub.emit('chaqiWantRoute')
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index')
}
onCreate 没有 emit 是合理的。这个阶段 HSP 页面尚未出现,也没有订阅者;把 Want 存入 HAR 后,页面稍后主动读取即可。
onNewWant 面对的是一个已经存在的 Ability 实例,所以它在更新 pending 后通知页面。问题不在 emit 本身,而在监听器收到通知时,草稿可能还处于异步恢复阶段。
华为UIAbility 与 UI 的数据同步介绍了 EventHub 的 on、off 和 emit。事件能否被接收取决于订阅时机,因此 EventHub 适合当唤醒信号,不适合成为唯一的数据载体。
四、当前 pending 是 HAR 模块实例内的单槽邮箱
libraryhar/src/main/ets/routes/ChaqiRouteIntent.ets 在模块作用域保存一个可空 payload。对每一个实际加载的 HAR 模块实例,当前语义可以抽象成:
let pending: ChaqiRoutePayload | null = null;
export function setChaqiRouteFromWant(want: Want): void {
const route: ChaqiRoutePayload = parseWant(want)
if (route.tab < 0 && !route.generateCup) {
pending = null
return
}
pending = route
}
export function takeAndClearChaqiRoute(): ChaqiRoutePayload | null {
const route: ChaqiRoutePayload | null = pending
pending = null
return route
}
它有四个重要特征:
| 特征 | 当前行为 | 影响 |
|---|---|---|
| 容量 | 只有一个槽位 | 后一次写入覆盖前一次 |
| 生命周期 | 当前 HAR 模块实例的内存 | 进程终止后消失;跨包是否同一实例不能从 import 名称推断 |
| 消费 | 读取即清空 | 应用失败也无法重试 |
| 无效输入 | 清空 pending | 无效 Want 可能抹掉先前有效请求 |
单槽本身不一定错误。导航通常只关心最新目标,latest-wins 很合适;问题有两层:当前单槽没有 id 和“应用成功后再确认”的动作,同时写入发生在 entry HAP、读取发生在 libraryhsp HSP,而默认打包会产生两份 HAR。第一层要用 envelope 与 ACK 解决,第二层必须先确定唯一状态宿主。
五、源码安排为先恢复后消费,跨包交接仍待证明
HSP 页面用 draftHydrated 和 draftRestoring 标记恢复状态。当前 restoreDraftState 的主干顺序是:
private restoreDraftState(): void {
this.draftRestoring = true
chaqiDraftStore.load(this.abilityCtx())
.then((draft: ChaqiDraftState) => {
this.applyDraftState(draft)
this.draftHydrated = true
this.draftRestoring = false
this.applyPendingRoute()
})
.catch((_e: object) => {
this.draftHydrated = true
this.draftRestoring = false
this.applyPendingRoute()
})
}
aboutToAppear 会先注册 chaqiWantRoute 监听器,然后启动草稿恢复。首次冷启动时,onCreate 先调用写入函数且不 emit;Preferences 完成后,applyPendingRoute 再调用读取函数。这个先后安排体现了“草稿为基线、Want 最后覆盖”的预期设计意图。
但写入函数运行在 entry HAP,读取函数由 HSP 页面调用。当前 deduplicateHar 缺省为 false,官方构建规则是 HAR 分别进入每个 HAP/HSP,因此这里必须把结论写成“源码安排如此,实际交接未验证”。至少要在写入端和读取端打印同一启动期诊断 token 与 envelope id,并完成冷启动拉起;该 token 只用于关联日志,不能用于安全认证。如果两侧 token 不同,页面读不到 entry 写入的 pending,时序安排再完整也没有意义。
ChaqiDraftStore 通过 @kit.ArkData 导入 preferences,存储名为 chaqi_draft_state,load 会读取并约束草稿字段,save 会逐项 put 后 flush。Preferences 适合保存这种轻量用户状态,接口能力可参考华为@ohos.data.preferences 用户首选项。
六、在同一 inbox 已成立的前提下,恢复期间的 onNewWant 仍会抢跑
下面的竞态有一个前提:EntryAbility 写入与页面读取已经接到同一个 inbox。满足这个前提后,仍要按时间线观察恢复门禁:
T0 aboutToAppear 启动 restoreDraftState
T1 draftRestoring = true,Preferences 正在读取
T2 已存在的 UIAbility 收到 onNewWant
T3 setChaqiRouteFromWant 写入 pending
T4 EventHub 立即唤醒页面
T5 onChaqiWantRoute 调用 applyPendingRoute
T6 takeAndClearChaqiRoute 提前清空 pending
T7 页面应用 Want;generate 路径的 persistDraftNow 或 tab 路径的 scheduleDraftPersist 因恢复中而返回
T8 Preferences 读取完成
T9 applyDraftState 用旧草稿覆盖页面
T10 恢复尾部再次消费,但 pending 已为空
在这个分支中,最终现象不是“深链从来没到”,而是“深链短暂生效后被旧草稿盖回去”。如果跨包 inbox 本身没有接通,现象则会更早变成“页面一直取不到路由”。两类故障必须用实例标识、envelope id 与阶段日志分开判断。
当前 persistDraftNow 和 scheduleDraftPersist 都会在尚未 hydrated 或正在 restoring 时返回,这是避免半恢复状态写回的合理保护。修复不应删除这层保护;应让恢复期间的新 Want 留在 inbox,等草稿成为基线后再统一合并。
七、RouteInbox 要有唯一宿主,也要把 offer、peek、ACK 全部接通
applyPendingRoute 当前先调用 takeAndClearChaqiRoute,再根据 generate 或 tab 修改页面。如果之后的参数同步、3D 更新或持久化失败,消息已经不存在。仅把它改成一个导出的 HAR 单例还不够:在当前重复 HAR 配置下,entry 与 HSP 仍可能各拿到一份实例。
推荐方案是让 libraryhsp 成为 RouteInbox 的唯一宿主。HAR 只保留纯解析函数 parseChaqiRouteFromWant() 与 payload 类型;HSP 创建唯一 inbox 并导出窄函数;EntryAbility 只投递,不持有也不读取 inbox;页面在同一 HSP 内 peek 和 ACK。
// libraryhar/routes/ChaqiRouteIntent.ets:纯解析,不保存 pending
export function parseChaqiRouteFromWant(want: Want): ChaqiRoutePayload | null {
const route: ChaqiRoutePayload = parseWant(want)
if (route.tab < 0 && !route.generateCup) { return null }
return route
}
// libraryhar/Index.ets:让 HSP 只能从公共入口使用纯解析函数
export { parseChaqiRouteFromWant }
from './src/main/ets/routes/ChaqiRouteIntent'
export type { ChaqiRoutePayload }
from './src/main/ets/routes/ChaqiRouteIntent'
// libraryhsp/services/ChaqiRouteInbox.ets:唯一状态宿主
import { Want } from '@kit.AbilityKit'
import { ChaqiRoutePayload, parseChaqiRouteFromWant } from 'libraryhar'
export enum ChaqiRouteSource { ON_CREATE, ON_NEW_WANT }
export interface ChaqiRouteEnvelope {
id: number;
receivedAt: number;
source: ChaqiRouteSource;
payload: ChaqiRoutePayload;
}
class ChaqiRouteInbox {
private nextId: number = 1
private latest: ChaqiRouteEnvelope | null = null
offer(payload: ChaqiRoutePayload, source: ChaqiRouteSource): number {
const id: number = this.nextId++
this.latest = { id, receivedAt: Date.now(), source, payload }
return id
}
peekLatest(): ChaqiRouteEnvelope | null { return this.latest }
ackApplied(id: number): boolean {
if (this.latest === null || this.latest.id !== id) { return false }
this.latest = null
return true
}
}
const routeInbox = new ChaqiRouteInbox()
const instanceToken: string = `${Date.now()}-${Math.random()}`
export function offerChaqiRouteFromWant(
want: Want, source: ChaqiRouteSource
): number | null {
const payload = parseChaqiRouteFromWant(want)
if (payload === null) { return null } // 不清除已有 envelope
return routeInbox.offer(payload, source)
}
export function peekLatestChaqiRoute(): ChaqiRouteEnvelope | null {
return routeInbox.peekLatest()
}
export function ackAppliedChaqiRoute(id: number): boolean {
return routeInbox.ackApplied(id)
}
export function chaqiRouteInboxInstanceToken(): string { return instanceToken }
HSP 的公共入口要把 Ability 所需的投递端口导出,页面则直接引用同一 HSP 内的 service。这样两端不会再各自构造 inbox:
// libraryhsp/Index.ets
export {
ChaqiRouteSource,
chaqiRouteInboxInstanceToken,
offerChaqiRouteFromWant
}
from './src/main/ets/services/ChaqiRouteInbox'
// EntryAbility.ets:只负责投递
import { ChaqiRouteSource, offerChaqiRouteFromWant } from 'libraryhsp'
onCreate(want: Want): void {
offerChaqiRouteFromWant(want, ChaqiRouteSource.ON_CREATE)
}
onNewWant(want: Want): void {
const id = offerChaqiRouteFromWant(
want, ChaqiRouteSource.ON_NEW_WANT
)
if (id !== null) {
this.context.eventHub.emit('chaqiWantRoute')
}
}
// ChaqiExperiencePage.ets:与 inbox 同属 libraryhsp
import {
ChaqiRouteEnvelope,
ackAppliedChaqiRoute,
peekLatestChaqiRoute
}
from '../services/ChaqiRouteInbox'
这才是完整接线:Ability 的 offer、页面的 peek/ACK 指向 HSP 中同一个私有对象。ackAppliedChaqiRoute() 只清除 id 相同的 envelope;若处理旧请求期间出现更新 id,旧 ACK 返回 false,新请求仍保留。receivedAt 只用于日志,业务顺序以递增 id 为准。诊断 token 也只能作为运行证据,不能参与业务判断。
另一条可选路线是让 RouteInbox 继续留在 HAR,但必须先配置 packOptions.deduplicateHar: true、idDefinedFilePath、useNormalizedOHMUrl: true 和 module.json5.libIsolation: false,并确认工具链、系统版本与运行场景满足限制,再通过 APP 解包和 HAP/HSP 双侧 token 日志证明只使用同一份运行时身份。相关缺省值和约束见华为工程级 build-profile.json5 文档。当前工程没有开启去重,所以本文推荐 HSP 唯一宿主,不把“导出 HAR 单例”当成可靠修复。
如果以后 generate 从“设置页面参数”升级成“必须执行一次的业务命令”,latest-wins 就不够了,应把命令放进有界队列。当前文章只针对导航和状态覆盖语义,不额外引入队列复杂度。
八、EventHub 只唤醒,RouteInbox 才保存事实
事件监听器不应在 DRAFT_LOADING 阶段消费路由:
export enum StartupPhase {
DRAFT_LOADING,
READY
}
private startupPhase: StartupPhase = StartupPhase.DRAFT_LOADING
private onChaqiWantRoute = (): void => {
if (this.startupPhase !== StartupPhase.READY) {
return
}
this.drainLatestRoute()
}
这里的 return 不会丢请求,因为 Want payload 已经先写入 RouteInbox。EventHub 只是提醒页面“邮箱可能更新了”。
冷启动即使没有收到事件,恢复结束也会主动 drain;页面 READY 后收到新事件,则立即 drain。这样订阅早晚只影响处理时机,不影响请求是否存在。

入口顺序应固定为:接收 Want、恢复草稿、字段合并、应用状态、成功 ACK。页面合并或应用异常发生在 ACK 之前,inbox 因而保留可重试状态;Preferences 写入是 ACK 之后单独观察的持久化步骤。
九、协调器要串行 drain,避免两个事件同时确认
先把草稿恢复收敛为一个完成入口。无论 Preferences 成功还是失败,都要先得到完整基线,再把阶段切到 READY:
private completeDraftRestore(draft: ChaqiDraftState): void {
this.applyDraftState(draft)
this.draftHydrated = true
this.draftRestoring = false
this.startupPhase = StartupPhase.READY
this.drainLatestRoute()
}
private restoreDraftThenDrain(): void {
this.startupPhase = StartupPhase.DRAFT_LOADING
this.draftRestoring = true
chaqiDraftStore.load(this.abilityCtx())
.then((draft: ChaqiDraftState) => {
this.completeDraftRestore(draft)
})
.catch((_e: object) => {
this.completeDraftRestore(DEFAULT_CHAQI_DRAFT_STATE)
})
}
消费函数保持同步,ACK 只确认“这一条 envelope 已被页面状态成功接纳”。Preferences 保存仍走现有延迟持久化,不把一次磁盘写入失败变成重复导航:
private draining: boolean = false
private drainLatestRoute(): void {
if (this.startupPhase !== StartupPhase.READY || this.draining) {
return
}
this.draining = true
try {
while (true) {
const envelope: ChaqiRouteEnvelope | null =
peekLatestChaqiRoute()
if (envelope === null) {
return
}
const merged: ChaqiDraftState =
mergeDraftAndRoute(this.buildDraftState(), envelope.payload)
try {
this.applyDraftState(merged)
} catch (_e) {
return
}
if (ackAppliedChaqiRoute(envelope.id)) {
this.scheduleDraftPersist()
}
}
} finally {
this.draining = false
}
}
若应用期间发生同步重入并写入了更新 id,旧 ACK 不会删除新 envelope,while 会继续处理最新值。常规 ArkTS 事件在同一线程依次执行,这个同步临界区也避免了 await 期间出现“事件已唤醒,但 draining 仍为 true,随后无人再消费”的空窗。
当前 ChaqiDraftStore.save 把 getStore() 放在内部 try 之外:获取 Preferences 失败会 reject 到页面,但 persistDraftNow() 的空 catch 随后吞掉它;put 或 flush 失败则被 save 内部的空 catch 吞掉。因此 scheduleDraftPersist 只能表达尽力保存,不能生成耐久性回执。若产品要求“只有落盘成功才算消费”,需要先让 save 返回明确结果,再为失败重试增加去重与退避;这属于比页面导航更强的协议,不应塞进最小修复。

结构图中 Preferences 和 RouteInbox 都进入协调器,EventHub 只指向协调器;READY 后形成最终页面状态,成功结果再回到 RouteInbox 做 ACK。
十、preset 要先铺模板,再让显式数值覆盖
字段级合并不能只处理数值。当前 applyXiaoyiCupParams() 会先根据 presetKey 调用 applyPreset(),一次写入 presetIndex、heightMm、diameterMm、mouthMm、bottomMm、waistPct,再用 Want 中显式提供的数值覆盖。若只修改 presetIndex,界面可能显示“悟空杯”,尺寸却仍是旧草稿,状态已经自相矛盾。
建议把六组预设数据移成 HAR 中唯一的纯数据源,页面按钮和路由合并共同使用。合并顺序固定为:复制草稿、应用有效 preset、覆盖显式数值。
interface RoutePresetPatch {
key: string;
presetIndex: number;
heightMm: number;
diameterMm: number;
mouthMm: number;
bottomMm: number;
waistPct: number;
}
const ROUTE_PRESETS: RoutePresetPatch[] = [
{ key: 'luohan', presetIndex: 0, heightMm: 70, diameterMm: 85, mouthMm: 85, bottomMm: 41, waistPct: 45 },
{ key: 'wukong', presetIndex: 1, heightMm: 88, diameterMm: 76, mouthMm: 68, bottomMm: 34, waistPct: 58 },
{ key: 'fang', presetIndex: 2, heightMm: 62, diameterMm: 92, mouthMm: 92, bottomMm: 48, waistPct: 38 },
{ key: 'generic', presetIndex: 3, heightMm: 88, diameterMm: 72, mouthMm: 64, bottomMm: 36, waistPct: 52 },
{ key: 'spiral', presetIndex: 4, heightMm: 95, diameterMm: 68, mouthMm: 58, bottomMm: 34, waistPct: 55 },
{ key: 'zhujie', presetIndex: 5, heightMm: 78, diameterMm: 76, mouthMm: 82, bottomMm: 38, waistPct: 48 }
]
function presetForKey(key: string): RoutePresetPatch | null {
const normalized: string = key.trim()
for (let i = 0; i < ROUTE_PRESETS.length; i++) {
if (ROUTE_PRESETS[i].key === normalized) { return ROUTE_PRESETS[i] }
}
return null
}
function applyPresetToDraft(
draft: ChaqiDraftState, preset: RoutePresetPatch
): void {
draft.presetIndex = preset.presetIndex
draft.heightMm = preset.heightMm
draft.diameterMm = preset.diameterMm
draft.mouthMm = preset.mouthMm
draft.bottomMm = preset.bottomMm
draft.waistPct = preset.waistPct
}
function overlayNumber(
value: number, min: number, max: number, current: number
): number {
if (!Number.isFinite(value) || value < 0) { return current }
return Math.max(min, Math.min(max, value))
}
export function mergeDraftAndRoute(
base: ChaqiDraftState, route: ChaqiRoutePayload
): ChaqiDraftState {
const merged: ChaqiDraftState = { ...base, orders: base.orders.slice() }
if (!route.generateCup) {
if (route.tab >= 0 && route.tab <= 3) {
merged.selectedTabIndex = route.tab
}
return merged
}
merged.selectedTabIndex = 0
const preset = presetForKey(route.cup.presetKey)
if (preset !== null) { applyPresetToDraft(merged, preset) }
const cup = route.cup
merged.heightMm = overlayNumber(cup.heightMm, 50, 100, merged.heightMm)
merged.diameterMm = overlayNumber(cup.diameterMm, 60, 110, merged.diameterMm)
merged.mouthMm = overlayNumber(cup.mouthMm, 55, 110, merged.mouthMm)
merged.bottomMm = overlayNumber(cup.bottomMm, 25, 60, merged.bottomMm)
merged.thicknessMm = overlayNumber(cup.thicknessMm, 3, 15, merged.thicknessMm)
merged.waistPct = overlayNumber(cup.waistPct, 30, 70, merged.waistPct)
merged.waveLevel = overlayNumber(cup.waveLevel, 0, 10, merged.waveLevel)
return merged
}
两个最小单元用例专门锁住覆盖顺序:
function generateRoute(presetKey: string, heightMm: number): ChaqiRoutePayload {
return {
tab: 0, generateCup: true,
cup: { presetKey, heightMm, diameterMm: -1, mouthMm: -1,
bottomMm: -1, thicknessMm: -1, waistPct: -1, waveLevel: -1 }
}
}
const presetOnly = mergeDraftAndRoute(baseDraft, generateRoute('wukong', -1))
assertEqual(presetOnly.presetIndex, 1)
assertEqual(presetOnly.heightMm, 88)
assertEqual(presetOnly.diameterMm, 76)
assertEqual(presetOnly.mouthMm, 68)
const presetAndHeight = mergeDraftAndRoute(baseDraft, generateRoute('wukong', 96))
assertEqual(presetAndHeight.presetIndex, 1)
assertEqual(presetAndHeight.heightMm, 96) // 显式值最后覆盖
assertEqual(presetAndHeight.diameterMm, 76) // 其余字段沿用悟空模板
这里的 baseDraft 应使用与页面一致的完整 fixture,assertEqual 则替换成项目测试框架的断言 API。本文没有运行这些测试,所以它们是落地验收项,不是通过记录。页面最终只应用 merged 一次,applyDraftState() 会同步杯体轮廓;如果产品仍要求生成后重置视角或显示 Toast,还要在 ACK 前显式执行对应副作用。
十一、URI 解析必须按参数边界匹配
当前路由代码通过寻找 key= 的位置手工读取查询值。如果没有约束问号和 & 边界,notab=3 可能被当成 tab=3。重复 key、百分号编码和 fragment 也需要明确行为。
可以先把查询段拆成键值对,再按完整键名匹配:
export function parseExactQuery(uri: string): Map<string, string> {
const values: Map<string, string> = new Map<string, string>()
const question: number = uri.indexOf('?')
if (question < 0) {
return values
}
const fragment: number = uri.indexOf('#', question + 1)
const end: number = fragment < 0 ? uri.length : fragment
const query: string = uri.substring(question + 1, end)
const pairs: string[] = query.split('&')
for (const pair of pairs) {
if (pair.length === 0) {
continue
}
const separator: number = pair.indexOf('=')
const rawName: string =
separator < 0 ? pair : pair.substring(0, separator)
const rawValue: string =
separator < 0 ? '' : pair.substring(separator + 1)
const name: string = decodeURIComponent(rawName)
const value: string = decodeURIComponent(rawValue)
if (values.has(name)) {
throw new Error('DUPLICATE_QUERY_PARAMETER')
}
values.set(name, value)
}
return values
}
这份建议选择“重复参数视为无效”,避免同一 URI 有两种解释。若业务决定采用最后一个值,也要写进协议和测试。当前代码还规定 URI 查询值优先于 want.parameters,这一现状可以保留,但必须用用例固定。
解析异常应把本次 Want 归类为无效输入,而不是顺手清空先前的有效 envelope。只有显式的取消协议才能删除 pending。
十二、最小测试矩阵和排查顺序
| 编号 | 场景 | 期望结果 | 关键断言 |
|---|---|---|---|
| B02-00 | entry 向 HSP 窄端口 offer,HSP 页面 peek | 命中同一启动期 token 和 envelope id | 唯一 HSP inbox |
| B02-01 | onCreate + 已保存草稿 + tab Want | 草稿先恢复,tab 最终以 Want 为准 | 页面只出现最终值 |
| B02-02 | 无 preset 的 generate Want 只带部分杯体字段 | 进入页签 0,缺失字段沿用草稿 | 字段级合并 |
| B02-03 | Preferences 读取失败 + 有效 Want | 默认值为 base,Want 仍生效 | 不依赖旧草稿 |
| B02-04 | DRAFT_LOADING 时 onNewWant | 不提前消费,READY 后应用 | pending 保留 |
| B02-05 | 恢复期间连续两个有效 Want | 最新 id 胜出 | latest-wins |
| B02-06 | 旧请求应用时新请求到达 | 旧 ACK 不删除新 envelope | id 比较 |
| B02-07 | 有效 pending 后收到无效 Want | 有效请求仍在 | 无效输入不取消 |
| B02-08 | 页面状态应用失败 | 不 ACK,可重试 | envelope id 不变 |
| B02-09 | emit 发生在页面订阅前 | 恢复结束仍主动 drain | EventHub 非事实来源 |
| B02-10 | READY 后收到 onNewWant | 立即应用且只应用一次 | 幂等 |
| B02-11 | URI 含 notab=3 | 不解析为 tab=3 | 完整键名 |
| B02-12 | URI 有重复 tab | 按协议拒绝 | 无歧义 |
| B02-13 | URI 与 parameters 同时给 tab | URI 值优先 | 保持现状 |
| B02-14 | 深链合并后重启 | Preferences 恢复最终状态 | 持久化回读 |
| B02-15 | Preferences 写入失败 | 当前页面不回滚,失败可观察 | 保存回执或日志 |
| B02-16 | 进程终止且系统不重投 Want | inbox 不伪装成持久队列 | 明确内存边界 |
| B02-17 | generate 只带 preset=wukong | 应用悟空模板的索引与整组尺寸 | preset-only |
| B02-18 | preset=wukong 且 heightMm=96 | 先用悟空模板,再只覆盖高度 | preset + 显式字段 |
常见问题可以按下面顺序排查:
| 现象 | 先看什么 | 常见原因 | 修复 |
|---|---|---|---|
| 页面先跳转又回到旧页签 | restoring 期间的事件时间线 | Want 被提前消费 | DRAFT_LOADING 只入箱 |
| 冷启动完全没有跳转 | onCreate 是否 offer、恢复尾部是否 drain | 把 EventHub 当唯一通道 | 恢复结束主动读取 inbox |
| 第二个 Want 偶尔消失 | ACK 的 id | 旧消费者清空新请求 | 只确认相同 id |
| 无效链接导致有效跳转消失 | invalid 分支 | 无效 Want 清空单槽 | 无效输入不修改 inbox |
| notab 被识别成 tab | 查询参数拆分 | 子串匹配没有边界 | 按完整键名解析 |
| 页面正确但重启又变旧 | save 的真实结果 | 异常被内部吞掉 | 返回明确保存状态 |
| 重试后重复执行动作 | 合并逻辑是否幂等 | 把导航当命令追加 | 状态覆盖与命令队列分开 |
当前源码能够确认的是调用位置与预期顺序:onCreate 调用写入,草稿恢复尾部调用读取。由于默认重复 HAR,跨 HAP/HSP 是否命中同一 pending 仍未验证;只有在唯一 inbox 已接通的前提下,才能继续从调用链推导恢复期间 onNewWant 的覆盖竞态。这些都不是一次真机复现记录。HSP 宿主 RouteInbox、id、ACK、严格 URI 解析和协调器代码属于本文设计,尚未写入茶器工程。
本文没有修改 chaqi 源码,没有运行 hvigorw assembleHap --no-daemon,没有发起真实 URI、生成 HAP/APP,也没有在模拟器或真机验证后台复用、进程重建和 Preferences 写回。落地后应按矩阵先做可注入的时序单元测试,再用目标 API 版本完成端到端拉起与生命周期回读。
更多推荐

所有评论(0)