HarmonyOS 7 InsightBoard 多形态适配实录 05:WindowStage × Navigation:多窗口恢复、页面栈与业务状态持久化【鸿蒙心迹】
前四期把 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
更多推荐



所有评论(0)