前四期把 InsightBoard 的布局做到了四个明显不同的场景:手机 COMPACT、折叠屏展开 MEDIUM、半折叠 HOVER,以及平板 / PC 的 EXPANDED。

真正把应用放到自由窗口里反复开关以后,我发现前面那些“状态连续”还不够。

窗口从 1180vp 的三栏模式关闭,再重新打开成 780vp 的自由窗口,页面确实还能启动,但详情回到了第一条、筛选条件丢了,Navigation 也从详情退回首页。更麻烦的是,这些问题不是每次都出现,因为有时只是窗口尺寸变化,有时 WindowStage 本身已经重新创建。

所以 05 不再继续改布局,而是处理多窗口恢复链路:

窗口仍在
→ windowSizeChange
→ 只重排布局

WindowStage 被销毁
→ 保存业务快照
→ 释放窗口监听

WindowStage 重新创建
→ 读取业务快照
→ 根据新 viewport 决定布局
→ 重建 NavigationPathStack
→ 恢复 selected / filter / scroll

本轮固定数据:

taskId: adapt_20261002_05
restoreCase: FREE_WINDOW_REOPEN

before:
1180vp / EXPANDED / RAIL

after:
780vp / MEDIUM / SPLIT

routeStack:
Dashboard → Detail

selectedItem:
insight_042

filter:
全部

scrollOffset:
1280vp

windowStageGeneration:
2

restoreCost:
64ms

status:
RESTORED

一、先区分“窗口变小”和“窗口重建”

04 已经证明 PC / 2in1 不能直接等于 EXPANDED,自由窗口缩窄以后仍然要按 viewport 重新选择布局。

但第五期出现的是另一类问题。

当应用只是从 1180vp 拖到 780vp:

Window 还在
Navigation 状态还在
Store 也还在

此时只需要执行布局重排。

而如果 WindowStage 已经销毁,下一次进入:

新的 WindowStage
新的主窗口
新的 ArkUI 页面树

虽然 Stage 模型下多个应用组件可以共享 ArkTS 引擎和状态,但工程不能把“对象现在还活着”当成恢复策略。真正希望在 WindowStage 重建、异常恢复甚至进程重启后继续使用的数据,仍然要有持久化边界。

我最后把状态分成三层:

WindowState
仅当前 Window 有效

SessionState
WindowStage 重建后要恢复

PersistentBusinessState
进程重启后仍有价值

windowRect 属于第一层,Navigation 页面栈属于第二层,用户的筛选和当前选中洞察属于第三层。

二、保存的不是组件实例,而是“可重建业务快照”

以前我总想保留页面本身。

做完这一期以后,我反而把恢复对象压得很小:

export interface InsightRestoreSnapshot {
  version: number
  taskId: string

  routeNames: string[]
  selectedItemId: string
  filter: string
  scrollOffsetVp: number

  lastWidthVp: number
  lastLayoutMode: string
  savedAt: number
}

当前快照:

version=1
taskId=adapt_20261002_05
routeNames=[Dashboard, Detail]
selectedItemId=insight_042
filter=全部
scrollOffsetVp=1280
lastWidthVp=1180
lastLayoutMode=EXPANDED

这里没有保存 NavigationPathStack 对象、ListScroller 对象、Window 对象,也没有保存页面 Component。

这些都属于运行期对象。

恢复真正需要的是“重新创建页面以后,怎样回到用户刚才的位置”。

三、Preferences 只保存业务快照,不保存窗口尺寸结论

这一段代码解决的是“窗口重开以后仍然强行恢复成旧布局”的问题:

import { preferences } from '@kit.ArkData'
import { common } from '@kit.AbilityKit'

export class WindowSessionStore {
  private readonly name: string =
    'insightboard_window_session'

  async save(
    context: common.UIAbilityContext,
    snapshot: InsightRestoreSnapshot
  ): Promise<void> {
    const store =
      await preferences.getPreferences(
        context,
        { name: this.name }
      )

    await store.put(
      'restore_snapshot',
      JSON.stringify(snapshot)
    )

    await store.flush()
  }

  async restore(
    context: common.UIAbilityContext
  ): Promise<InsightRestoreSnapshot | null> {
    const store =
      await preferences.getPreferences(
        context,
        { name: this.name }
      )

    const raw = await store.get(
      'restore_snapshot',
      ''
    ) as string

    if (raw.length === 0) {
      return null
    }

    return JSON.parse(raw)
      as InsightRestoreSnapshot
  }
}

lastWidthVp=1180 只用于记录“保存时是什么场景”,不会在恢复时直接决定布局。

新的窗口只有 780vp,恢复以后必须进入 MEDIUM / SPLIT。否则就会出现“状态恢复成功,但页面按旧窗口尺寸继续画三栏”的假连续。

四、WindowStage 创建后,先恢复业务,再用新 Window 重新算布局

EntryAbility 里我没有把恢复动作塞进 DashboardPage。

这一段代码解决的是“页面已经渲染一轮以后才突然跳到旧详情”的问题:

export default class EntryAbility
  extends UIAbility {

  private generation: number = 0

  async onWindowStageCreate(
    windowStage: window.WindowStage
  ): Promise<void> {
    this.generation++

    const restore =
      await WindowSessionStore.shared()
        .restore(this.context)

    if (restore !== null) {
      AppStorage.setOrCreate(
        'selectedItemId',
        restore.selectedItemId
      )
      AppStorage.setOrCreate(
        'filter',
        restore.filter
      )
      AppStorage.setOrCreate(
        'scrollOffsetVp',
        restore.scrollOffsetVp
      )
      AppStorage.setOrCreate(
        'restoreRoutes',
        restore.routeNames
      )
    }

    const mainWindow =
      windowStage.getMainWindowSync()

    ViewportManager.shared()
      .bind(mainWindow)

    windowStage.loadContent(
      'pages/Index'
    )
  }

  onWindowStageDestroy(): void {
    ViewportManager.shared().dispose()
  }
}

真正项目里 save() 会放在更合适的生命周期或关键业务动作里,不能等 WindowStage 已经销毁后再临时去读 UI。

我当前做法是:

onBackground / 关键业务变化
→ 更新最新 Snapshot

onWindowStageDestroy
→ 只释放 Window 相关资源

这样销毁阶段不会因为 I/O 又拉长生命周期。

五、Navigation 恢复只还原“路线”,不还原旧布局结构

这次保存的是:

Dashboard
Detail

而不是:

RAIL
ListPane
DetailPane

原因很简单。

RAIL / SPLIT / STACK 是布局结构,不是用户的业务路线。

恢复到 780vp 后,AdaptiveNavigationRouter 得到 SPLIT,再由 RouteRestorer 把 Detail 放进这个结构:

export class RouteRestorer {
  restore(
    stack: NavigationPathStack,
    routes: string[],
    selectedItemId: string
  ): void {
    stack.clear()

    routes.forEach((name: string) => {
      if (name === 'Dashboard') {
        stack.pushPathByName(
          'Dashboard',
          null
        )
      }

      if (name === 'Detail') {
        stack.pushPathByName(
          'Detail',
          { id: selectedItemId }
        )
      }
    })
  }
}

这段代码解决的是“把三栏页面树当成 Navigation 历史”的问题。

用户记住的是“我正在看 insight_042 的详情”,而不是“我当时处在三栏容器的右边那一列”。

六、滚动位置恢复要等列表数据准备好

这次最容易出现时序问题的是 scrollOffsetVp=1280。

如果页面一创建就调用 scrollToOffset(1280),列表数据还没完成绑定,滚动可能不生效。

所以恢复顺序变成:

读取 Snapshot
→ 载入业务数据
→ 恢复 filter
→ 恢复 selectedItem
→ 恢复 route
→ 列表首轮布局完成
→ 恢复 scrollOffset

我把最后一步收进 ListRestoreCoordinator,不让 EntryAbility 直接碰 Scroller。

页面里只接收一次可恢复事件:

aboutToAppear(): void {
  this.restoreCoordinator
    .whenListReady(
      this.listScroller,
      (offset: number) => {
        this.listScroller.scrollTo({
          yOffset: offset,
          animation: false
        })
      }
    )
}

这一步虽然比保存 ID 麻烦,却是“回到原位置”和“只是打开原详情”的区别。

七、AppStorageV2 / PersistenceV2 能用,但不要把所有状态一股脑持久化

ArkUI V2 状态管理已经提供 AppStorageV2 和 PersistenceV2,用于全局 UI 状态和持久化 UI 状态。

InsightBoard 没有为了“用了新 API”把所有对象都塞进 PersistenceV2。

我仍然保留两条边界:

高频运行状态
→ Store / AppStorage

真正需要跨 WindowStage 或进程恢复的数据
→ Persistence / Preferences

例如当前 hover 动画进度、某个临时测量值、某个组件是否已经 mount,都没有持久化价值。

而 selectedItem、filter、route、scrollOffset 才值得恢复。

八、多窗口恢复还要处理“旧窗口监听没有释放”

第五期做压力测试时,我连续关闭 / 打开自由窗口 10 次,最初 windowSizeChange 的日志会越来越多。

第 10 次拖动一次窗口,居然打出 4 条相同日志。

不是系统重复回调,而是旧的 ViewportManager listener 没释放干净。

所以现在 Window 监听的拥有者必须实现:

bind
→ active=true

dispose
→ off(windowSizeChange)
→ active=false

所有布局 Manager 都禁止自己偷偷重新注册 Window listener。

这一条到了 06 会作为最终验收项继续测试。

九、DevEco 里这次只看一条“关闭再打开”

开发图固定演示:

before:
1180vp / EXPANDED / RAIL

close

reopen:
780vp / MEDIUM / SPLIT

selectedItem:
insight_042

filter:
全部

scrollOffset:
1280vp

HiLog:

08:54:12.228 save snapshot
route=[Dashboard,Detail]
selected=insight_042
filter=全部
scroll=1280vp

08:54:12.243 onWindowStageDestroy generation=1
08:54:12.271 onWindowStageCreate generation=2
08:54:12.286 new viewport=780vp mode=MEDIUM navigation=SPLIT
08:54:12.292 restore complete cost=64ms status=RESTORED

这组日志能明确区分“恢复了旧业务状态”和“重新使用旧布局”。

十、最终运行结果:恢复的是上下文,不是旧窗口

运行图:

最终统一数据:

taskId: adapt_20261002_05
restoreCase: FREE_WINDOW_REOPEN
before: 1180vp / EXPANDED / RAIL
after: 780vp / MEDIUM / SPLIT
route: Dashboard → Detail
selected: insight_042
filter: 全部
scrollOffset: 1280vp
generation: 2
restoreCost: 64ms
status: RESTORED

这一篇完成以后,InsightBoard 的“状态连续”终于跨过了组件树和 WindowStage。

前四期解决的是“布局变化时不要丢”,第五期解决的是“窗口没了再回来还能接上”。

下一篇就是系列最后一篇。

我会把手机、折叠屏展开、半折叠 Hover、平板、PC / 自由窗口全部放进同一套回归矩阵,专门检查重复布局 Apply、重复系统监听、状态丢失和多轮切换后的资源趋势。

到 06 不再添加新功能,只做工程验收。

十一、恢复快照必须有版本,不然以后字段一改就会变成“旧数据炸新页面”

做恢复功能时,我最开始只保存 JSON,没有版本号。

这个问题在当天当然看不出来,因为保存和读取使用的是同一份结构。真正把 detailTab、sortMode 加进去以后,旧快照里没有这些字段,页面就开始出现默认值和空值混在一起的情况。

所以现在 Snapshot 明确带:

version=1

恢复时先检查版本:

export class RestoreSnapshotMigrator {
  normalize(
    raw: InsightRestoreSnapshot
  ): InsightRestoreSnapshot {
    if (raw.version === 1) {
      return raw
    }

    throw new Error(
      `UNSUPPORTED_RESTORE_VERSION:${raw.version}`
    )
  }
}

当前版本只有 v1,看起来这个 Migrator 很多余。

但它给以后留了一个非常清楚的入口:结构变了,先迁移,再恢复;不要让每个页面自己判断“这个字段有没有”。

我还会在恢复失败时直接降级到:

Dashboard
+
默认筛选
+
不恢复 scrollOffset

而不是因为一份旧快照让应用无法进入。

恢复能力的目标是提高连续性,不应该反过来成为启动失败的新来源。

十二、保存频率不能跟 windowSizeChange 一样高

窗口拖动时 windowSizeChange 可能连续触发。

如果每一次都把 Preferences 写一遍,等于把高频 UI 事件变成高频磁盘写入。

所以第五期把保存触发点收紧到:

selectedItem 改变
filter 改变
route 改变
页面进入后台
窗口准备关闭

纯窗口尺寸变化只更新内存中的 SessionState,不立刻写盘。

我加了一个 300ms 的轻量合并:

export class SnapshotCommitScheduler {
  private timer: number = -1

  schedule(
    commit: () => Promise<void>
  ): void {
    if (this.timer >= 0) {
      clearTimeout(this.timer)
    }

    this.timer = setTimeout(
      async () => {
        this.timer = -1
        await commit()
      },
      300
    )
  }

  cancel(): void {
    if (this.timer >= 0) {
      clearTimeout(this.timer)
      this.timer = -1
    }
  }
}

这里的 300ms 仍然是 InsightBoard 当前工程值,不是系统推荐常量。

它只是避免用户连续点筛选、马上切详情时产生三次无意义写盘。

真正进入 onBackground 前,我会主动 flush 一次最新快照,确保最终状态落盘。

十三、appRecovery 和业务恢复不是同一层能力

HarmonyOS 还提供 appRecovery,可以在异常场景保存 Ability 状态、重启应用并恢复。这个能力非常有价值,但我没有用它替代第五期的 Window Session 恢复。

原因是两者解决的问题不同。

第五期主要处理:

自由窗口关闭 / 重开
WindowStage 重建
普通前后台切换
用户主动继续上一次浏览位置

而 appRecovery 更偏向:

故障场景
异常退出
应用恢复

所以我把它们看成两条线:

正常生命周期连续性
→ WindowSessionStore

异常恢复
→ appRecovery

如果把所有连续性都押在 appRecovery 上,普通窗口重建反而没有必要的业务快照;如果只做 Preferences,又覆盖不了真正故障后的 Ability 恢复。

工程边界清楚以后,后面接 APMS 或异常恢复也不会和窗口恢复混在一起。

十四、恢复前必须校验 selectedItem 还存在

这一点是我在改测试数据时发现的。

假设上一次保存:

selectedItem=insight_042

但第二天服务端数据已经下线了 insight_042。

如果恢复代码无条件 push Detail,详情页就会拿到一个已经不存在的 ID。

现在恢复前先走:

Snapshot
→ 数据加载
→ selectedItem 存在性校验
→ 再恢复 Route

如果不存在:

selectedItem = 第一条可用数据
routeTop = Dashboard
scrollOffset = 0
restoreState = DEGRADED

也就是说恢复也有降级状态。

当前本轮数据里 insight_042 仍然存在,所以最终是 RESTORED;但这条边界必须提前写好,不能假设持久化 ID 永远有效。

十五、我最后用五组反向测试验证恢复不是“偶然成功”

第五期最终做了五组反向测试。

第一组,关闭 1180vp 自由窗口,再以 780vp 重开,确认布局从 RAIL 变 SPLIT,而 insight_042 不变。

第二组,恢复前修改筛选条件为“全部”,确认新窗口里筛选不会回默认。

第三组,恢复前把列表滚到 1280vp,确认列表数据加载完成以后再恢复滚动,而不是页面刚创建就调用无效滚动。

第四组,手动破坏一份 Snapshot JSON,恢复必须降级到 Dashboard,不能卡死启动。

第五组,连续重开窗口 10 次,windowSizeChange listener 数量始终只有 1,WindowStage 销毁后为 0。

这些测试做完以后,我才认为 64ms 的“恢复成功”是可信的。

否则一次截图里刚好恢复到旧详情,并不能证明生命周期处理真的完整。

参考资料

  • Stage 模型开发概述:https://developer.huawei.com/consumer/cn/arkui/arkui-stage
  • 多窗口布局适配:https://developer.huawei.com/consumer/en/doc/harmonyos-guides-V14/multi-window-layout-adapt-V14
  • ArkUI V2 状态管理:https://developer.huawei.com/consumer/en/doc/harmonyos-guides-V14/arkts-mvvm-v2-V14
  • appRecovery API:https://developer.huawei.com/consumer/en/doc/harmonyos-references-V14/js-apis-app-ability-apprecovery-V14
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐