平行视界里最让我意外的不是双栏布局,而是返回键。左侧文档列表选中 DOC-042,右侧网页点进四层链接;此时按返回,到底该退网页历史、关闭详情,还是把整个页面路由弹掉?早期实现把三件事都绑在 NavPathStack.pop() 上,结果网页内部链接根本退不回去,切换条目后旧 Controller 还会把标题回调写到新详情。

我把这个 Demo 命名为 ParallelWebDesk,页面是 ArticleHubPage,任务号 WEB-1408。最终验收状态为 DUAL_PANE:选中 DOC-042,活动 Controller 2 个、缓存 1 个、池上限 3,当前 Web 历史深度 4,页面路由深度 2,回退决策 WEB_BACK,丢弃迟到回调 1 次,状态 CONTROLLER_STABLE。

一、一个 Controller 看起来能复用,实际会把归属关系弄丢

第一版只有一个 WebviewController。用户点击左侧条目时,右侧 Web 改 src;为了加快切换,我还把 Controller 放进全局变量。短时间测试没问题,可连续打开两个详情后,网页标题、进度和历史栈开始串页。Controller 的历史属于具体 Web 实例,路由选中项则属于详情会话,两者生命周期不一致。

后来改成“一个详情会话一个 Controller”。池的 key 不是 URL,而是 detailSessionId;同一详情里的站内跳转继续使用原 Controller,切换文档则获取另一个槽位。列表选中 DOC-042 只说明当前业务项,不等于网页历史根节点。把这两层身份拆开后,迟到回调才有可以比较的 ownerEpoch。

这也不同于此前 ArkWeb WebGL 上下文恢复或 WebMessagePort 销毁。前者处理图形资源重建,后者处理 H5 通道代次;这次页面本身不崩,问题是多个详情的 Controller 归属、系统返回键仲裁和缓存上限。症状都发生在 ArkWeb,修复点却不在同一层。

二、Controller 池先定义归属,再谈复用

第一段代码解决 Controller 被两个详情同时借用的问题。acquireController() 为 session 创建条目,重复获取只更新访问时间;缓存达到 3 个时,只淘汰非活动且最久未使用的条目。活动详情的 Controller 绝不能被挪给另一个 Web 组件。

interface ControllerSlot {
  sessionId: string
  docId: string
  controller: webview.WebviewController
  ownerEpoch: number
  active: boolean
  lastUsedAt: number
}

class WebControllerPool {
  private readonly limit: number = 3
  private slots: Map<string, ControllerSlot> = new Map()

  acquireController(sessionId: string, docId: string, epoch: number): ControllerSlot {
    let slot = this.slots.get(sessionId)
    if (!slot) {
      this.evictInactiveIfNeeded()
      slot = { sessionId, docId, ownerEpoch: epoch, active: true,
        lastUsedAt: Date.now(), controller: new webview.WebviewController() }
      this.slots.set(sessionId, slot)
    }
    slot.active = true
    slot.lastUsedAt = Date.now()
    return slot
  }
}

池上限 3 是 Demo 的内存预算,不是 ArkWeb 的平台限制。折叠屏内双栏同时保留列表预览与详情,所以验收时活动 Controller 为 2、非活动缓存为 1。若页面结构只有一个 Web,池完全可以更小。这里最重要的不是数字,而是“活动项不淘汰、不同 session 不共享实例”。

创建 Controller 不代表 Web 已经可调用。页面通过 onControllerAttached 标记 attachState,只有绑定后才执行加载、回退或脚本调用。组件重建时旧回调可能晚到,回调携带 ownerEpoch;和当前槽位不一致就丢弃。本轮记录的 lateCallbackDropped=1 正是一次切换后的旧标题回调。

三、返回动作必须先问 Web,再问详情路由

第二段代码是这次改造的核心。decideBackTarget() 先查看当前详情是否存在、Controller 是否已绑定,再调用 accessBackward() 判断网页历史。如果 Web 能后退,决策为 WEB_BACK 并调用 backward();否则双栏模式清空详情选中,单栏模式再交给 NavPathStack。

type BackDecision = 'WEB_BACK' | 'CLEAR_DETAIL' | 'ROUTE_POP' | 'NOOP'

function decideBackTarget(mode: 'SINGLE_PANE' | 'DUAL_PANE',
  slot: ControllerSlot | undefined, pathStack: NavPathStack): BackDecision {
  if (slot?.active && slot.controller.accessBackward()) {
    slot.controller.backward()
    return 'WEB_BACK'
  }
  if (mode === 'DUAL_PANE' && slot) {
    detailState.clearSelection()
    return 'CLEAR_DETAIL'
  }
  if (pathStack.size() > 1) {
    pathStack.pop()
    return 'ROUTE_POP'
  }
  return 'NOOP'
}

调用顺序不能反过来。若先 pop 页面,Web 的历史还没消费,用户会直接离开工作台;若无条件 backward,Controller 尚未绑定或已经换 owner,就可能操作错误详情。回退决策也只执行一次,手势返回和顶部按钮共用同一个仲裁器,通过 backEpoch 防止一次手势触发两条路径。

当前数据里 Web 历史深度为 4、路由深度为 2,因此回退结果明确是 WEB_BACK。等 Web 历史回到根页,再按返回,双栏才清空 DOC-042;在单栏设备上则可能 pop 详情路由。这里的“平行视界适配”不是把页面宽度分成两块,而是让同一个返回动作在布局形态变化后仍有稳定语义。

四、缓存回收要先断回调,再移除引用

第三段代码处理离场与内存收口。缓存淘汰不能只从 Map 删除;先把 slot 设为 inactive、递增 ownerEpoch,让迟到回调失效,再清理业务监听和页面持有的引用。Controller 的底层资源由 Web 组件生命周期管理,池只管理应用层所有权,不伪造“已物理释放”的状态。

releaseInactiveControllers(keepSessionId: string): void {
  const candidates = [...this.slots.values()]
    .filter((slot) => !slot.active && slot.sessionId !== keepSessionId)
    .sort((a, b) => a.lastUsedAt - b.lastUsedAt)

  while (this.slots.size > this.limit - 1 && candidates.length > 0) {
    const victim = candidates.shift()!
    victim.ownerEpoch++
    webEventHub.offOwner(victim.sessionId)
    this.slots.delete(victim.sessionId)
  }
}

limit - 1 为即将加入的新槽位预留位置,调用发生在新详情 attach 前。重复调用不会继续删除活动项;候选为空时保持现状并记录压力日志,而不是错误回收正在显示的详情。页面退后台时暂停进度订阅,重新前台后按 sessionId 恢复,不新建重复 Controller。

如果系统发出内存压力,策略会更积极:先清空非活动缓存,再让看不见的预览 Web 释放。当前文章没有把内存等级 API 混进主线,避免把 Controller 归属和系统回收写成同一个问题;产品实现可以在池外追加策略,仍需遵守 active 不被抢占的约束。

五、调试页要让三层状态同时可见

工程目录包含 WebControllerPool.ets、BackArbiter.ets、DetailSessionStore.ets、ArticleHubPage.ets 和 ParallelDetailView.ets。HiLog 固定输出:task=WEB-1408 mode=DUAL_PANE selected=DOC-042、activeControllers=2 cachedControllers=1 poolLimit=3、webHistory=4 routeDepth=2 backDecision=WEB_BACK、lateCallbackDropped=1 state=CONTROLLER_STABLE。

IDE 中间停在 decideBackTarget(),右侧模拟器展示双栏文档工作台,底部日志把业务选中、Controller 池和回退仲裁三层状态放在一起。这样排查“返回错了”时,不会只看到路由栈,也不会只盯 Web 历史。红色标注只指向 accessBackward() 的优先分支。

六、旋转和折叠发生在回退之前怎么办

我用一条固定路径回归:双栏选中 DOC-042,详情内连续跳转四层;窗口收窄切成单栏,再立刻触发返回。形态变化只更新 mode,不改 Controller 的 sessionId 和 Web 历史;因此第一次返回仍执行 WEB_BACK。只有历史回到根页,下一次返回才根据当时的 SINGLE_PANE 决定 ROUTE_POP。

最终运行页时间 14:08、电量 82%,显示 mode DUAL_PANE、选中 DOC-042、活动 2、缓存 1、上限 3、Web 历史 4、路由深度 2、决策 WEB_BACK、迟到回调丢弃 1,状态 CONTROLLER_STABLE。按钮“返回上一页”走仲裁器,“清理非活动页”调用受限缓存回收。

七、三个看起来像优化、其实会破坏语义的做法

第一种是按 URL 复用 Controller。两个业务文档可能跳到同一登录页或帮助页,URL 相同不代表详情会话相同。第二种是切换选中项时直接清空全部 Web 历史,这会让用户在当前详情里失去正常回退。第三种是把 Controller 放进全局单例却不记录 owner,页面重建后没人能判断回调属于谁。

我更愿意接受三个受控槽位,而不是一个“万能 Controller”。缓存数量稍多,却换来了可解释的归属。真要压内存,也应淘汰完整的非活动 session,而不是让两个可见组件共享一个历史对象。优化必须服从页面语义,否则省下的初始化时间会变成无法复现的串页问题。

八、平行视界适配最后落在状态边界上

做完以后,双栏布局本身反而成了最简单的一部分。真正需要设计的是:列表选中属于业务状态,NavPathStack 属于页面路由,WebviewController 属于 Web 会话,三者谁先消费返回、谁在形态切换后保留、谁在缓存淘汰时失效。

这套实现没有覆盖网页下载、权限弹窗或多窗口共享 Cookie,它只解决当前 Demo 的 Controller 归属、回退仲裁和缓存回收。边界写清楚后,状态页上的 CONTROLLER_STABLE 才不是一句装饰文字,而是可以由活动数、缓存数、历史深度和迟到回调共同证明的工程结论。

Logo

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

更多推荐