茶器艺科智造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”固定优先级。

Want优先级与先恢复后消费

本文区分三类内容:

  • 源码事实:当前生命周期、草稿恢复、pending 写入与消费的调用位置;跨 HAP/HSP 是否命中同一 pending 尚未验证。
  • 本文设计:HSP 唯一宿主、带 id 的 latest-wins inbox、字段级合并和 ACK。
  • 待验证事项:构建、端到端 URI 拉起、后台复用、进程重建和真机持久化。

一、先写下状态优先级,避免回调各自决定结果

页面启动时可能同时出现三份状态:

  1. 代码中的安全默认值。
  2. Preferences 中上次保存的草稿。
  3. 本次系统 Want 携带的显式导航或生成参数。

对这个应用,合理优先级是:

默认值 < Preferences 草稿 < 最新有效 Want

“Want 优先”不是把整个草稿丢掉,而是字段级覆盖。只有 tab 时只改页签;generate=true 时进入生成页;Want 提供了高度、直径等杯体参数才覆盖对应字段,缺失字段继续沿用草稿。

可以把规则写成决策表:

输入组合页面基线最终覆盖
无草稿、无 Want默认值
有草稿、无 WantPreferences 草稿
草稿 + tab WantPreferences 草稿selectedTabIndex
草稿 + generate WantPreferences 草稿进入生成页及 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: trueidDefinedFilePathuseNormalizedOHMUrl: truemodule.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

入口顺序应固定为:接收 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与启动协调器

结构图中 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-00entry 向 HSP 窄端口 offer,HSP 页面 peek命中同一启动期 token 和 envelope id唯一 HSP inbox
B02-01onCreate + 已保存草稿 + tab Want草稿先恢复,tab 最终以 Want 为准页面只出现最终值
B02-02无 preset 的 generate Want 只带部分杯体字段进入页签 0,缺失字段沿用草稿字段级合并
B02-03Preferences 读取失败 + 有效 Want默认值为 base,Want 仍生效不依赖旧草稿
B02-04DRAFT_LOADING 时 onNewWant不提前消费,READY 后应用pending 保留
B02-05恢复期间连续两个有效 Want最新 id 胜出latest-wins
B02-06旧请求应用时新请求到达旧 ACK 不删除新 envelopeid 比较
B02-07有效 pending 后收到无效 Want有效请求仍在无效输入不取消
B02-08页面状态应用失败不 ACK,可重试envelope id 不变
B02-09emit 发生在页面订阅前恢复结束仍主动 drainEventHub 非事实来源
B02-10READY 后收到 onNewWant立即应用且只应用一次幂等
B02-11URI 含 notab=3不解析为 tab=3完整键名
B02-12URI 有重复 tab按协议拒绝无歧义
B02-13URI 与 parameters 同时给 tabURI 值优先保持现状
B02-14深链合并后重启Preferences 恢复最终状态持久化回读
B02-15Preferences 写入失败当前页面不回滚,失败可观察保存回执或日志
B02-16进程终止且系统不重投 Wantinbox 不伪装成持久队列明确内存边界
B02-17generate 只带 preset=wukong应用悟空模板的索引与整组尺寸preset-only
B02-18preset=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 版本完成端到端拉起与生命周期回读。

Logo

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

更多推荐