折叠屏展开后页面状态丢了?HarmonyOS 形态切换下的状态保持实践【鸿蒙心迹】

👋 你好,欢迎来到我的博客!我是【菜鸟学鸿蒙】
我是一名在路上的移动端开发者,正从传统“小码农”转向鸿蒙原生开发的进阶之旅。为了把学习过的知识沉淀下来,也为了和更多同路人互相启发,我决定把探索 HarmonyOS 的过程都记录在这里。
🛠️ 主要方向:ArkTS 语言基础、HarmonyOS 原生应用(Stage 模型、UIAbility/ServiceAbility)、分布式能力与软总线、元服务/卡片、应用签名与上架、性能与内存优化、项目实战,以及 Android → 鸿蒙的迁移踩坑与复盘。
🧭 内容节奏:从基础到实战——小示例拆解框架认知、专项优化手记、实战项目拆包、面试题思考与复盘,让每篇都有可落地的代码与方法论。
💡 我相信:写作是把知识内化的过程,分享是让生态更繁荣的方式。
如果你也想拥抱鸿蒙、热爱成长,欢迎关注我,一起交流进步!🚀
前言
折叠屏展开,Navigation 自动切成了双栏。布局适配了,但列表里之前选中的那一项没了,搜索框里输入的关键词不见了,列表也滚回了顶部。这类问题从表现上看像布局适配没做好,实际原因往往是状态放错了地方。
一、UI 能重新布局,不代表用户体验连续
Navigation 在 NavigationMode.Auto 模式下,当窗口宽度达到 600vp 时切换为双栏;低于 600vp 时切回单栏。折叠屏展开就触发了这个宽度跳变。
官方文档明确说明:模式切换时,NavPathStack(路由栈)本身不会被清空。但路由栈里的页面组件有可能被重新创建,@State 声明的组件内状态会被重置为初始值。
路由还在,选中项、搜索词、滚动位置这些业务临时状态没了。
这不是 Bug,这是 @State 的正常行为——它的生命周期绑定在组件实例上,组件重建,@State 就从初始值重新开始。
二、哪些数据属于页面临时状态
先把状态分个层:
| 状态类型 | 示例 | 形态切换后是否需要保持 |
|---|---|---|
| 纯布局参数 | 当前断点、组件宽度 | 不需要,应根据新窗口重新计算 |
| 业务临时状态 | 搜索词、选中项 ID、列表 firstIndex、Tab 激活项 | 需要保持 |
| 持久化业务数据 | 收藏、用户设置 | 有自己的持久化机制,另行处理 |
判断标准:如果用户在折叠态做了操作 X,展开后 X 应该还在,X 就必须被保持。
三、哪些状态应该提升到更高层管理
@State 适合做纯粹的组件内 UI 状态,不适合承载需要跨形态保持的业务数据。
有两条路:
路径 A:提升到父组件,用 @Link 或 @Provide / @Consume 传递
适合同一 Navigation 下多个子组件共享的状态。
路径 B:存入 AppStorage,用 @StorageLink 绑定
适合跨页面、跨组件层级保持的状态。AppStorage 与应用进程绑定,生命周期与应用一致,组件重建不影响它里面的数据。
官方文档中,AppStorage 的初始化方式是 AppStorage.setOrCreate(key, defaultValue),在组件内用 @StorageLink('key') 双向绑定。对"列表选中项"和"滚动位置"这类业务临时状态,路径 B 更稳。
四、搭一个"列表选中详情"的测试页面
用这个场景验证状态保持:
- 列表页展示书单,可以点击选中某一项,列表支持滚动
- 双栏时右侧显示详情,单栏时跳转到详情页
- 需要保持:当前选中的
itemId,列表的firstIndex
整体用 Navigation + NavPathStack + NavDestination 组织。
在 UIAbility 的 onCreate 里提前初始化 AppStorage:
// EntryAbility.ets
import UIAbility from '@ohos.app.ability.UIAbility';
import window from '@ohos.window';
export default class EntryAbility extends UIAbility {
onCreate(): void {
// 提前初始化需要跨形态保持的业务状态,设置默认值
AppStorage.setOrCreate<number>('selectedItemId', -1);
AppStorage.setOrCreate<number>('listFirstIndex', 0);
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index');
}
}
AppStorage 是 ArkUI 框架提供的全局对象,不需要单独 import。
五、折叠前选中数据的保存
关键原则:用户操作时实时写入 AppStorage,不要等到折叠事件触发再保存。
折叠事件的时机难以预判,在回调里抢救状态容易产生竞态,也容易遗漏。用户每次操作(点击、滚动)时同步写入,才是最稳的方式。
// Index.ets - 列表页核心片段
@Entry
@Component
struct IndexPage {
// 通过 @StorageLink 双向绑定 AppStorage,组件重建后自动从 AppStorage 恢复
@StorageLink('selectedItemId') selectedItemId: number = -1;
@StorageLink('listFirstIndex') listFirstIndex: number = 0;
private pathStack: NavPathStack = new NavPathStack();
private listScroller: Scroller = new Scroller();
@Builder
PageMap(name: string, param: Object) {
if (name === 'DetailPage') {
DetailPageComponent()
}
}
build() {
Navigation(this.pathStack) {
List({ scroller: this.listScroller }) {
ForEach(bookList, (item: BookItem) => {
ListItem() {
BookRow({ item: item, isSelected: item.id === this.selectedItemId })
.onClick(() => {
// 用户点击时立即写入,@StorageLink 赋值会同步更新 AppStorage
this.selectedItemId = item.id;
this.pathStack.pushPath({ name: 'DetailPage', param: item.id });
})
}
})
}
.onScrollIndex((firstIndex: number) => {
// 滚动时同步记录当前可见的第一个 index
this.listFirstIndex = firstIndex;
})
.onAppear(() => {
// 组件出现(包括重建后)时恢复滚动位置
if (this.listFirstIndex > 0) {
this.listScroller.scrollToIndex(this.listFirstIndex);
}
})
}
.mode(NavigationMode.Auto)
.navDestination(this.PageMap)
}
}
需要重点关注的两处:
@StorageLink 而非 @State:this.selectedItemId = item.id 这一行,不只是更新组件内的值,也同时更新了 AppStorage。形态切换导致组件重建时,新实例从 AppStorage 读取的仍然是最后一次写入的选中项。
滚动位置的"写入-恢复"链路:onScrollIndex 负责实时记录,onAppear 负责恢复。Scroller.scrollToIndex() 把列表定位回保存的 index,避免用户展开后要重新滚动找位置。
六、展开后重新布局而不是重新初始化业务
这是最容易写错的地方。
"重新布局"和"重新初始化业务"是两件不同的事,但在 onAppear 里很容易写成同一件事:把数据请求、状态清零、搜索词重置都放进去。这样写,每次组件出现都会把之前保存好的状态覆盖掉。
正确的处理思路:
- 数据初始化(网络请求、数据库加载)只在真正需要首次加载时执行,用标志位控制,避免重复触发
- UI 状态恢复(高亮、滚动位置)从 AppStorage 读取,不主动清零
- 布局相关计算(断点值、列宽)在窗口尺寸变化时重新算,与业务状态无关
详情页示例:
// DetailPageComponent.ets - NavDestination 页面核心片段
@Component
struct DetailPageComponent {
@StorageLink('selectedItemId') selectedItemId: number = -1;
@State detailData: BookDetail | null = null;
private dataLoaded: boolean = false;
build() {
NavDestination() {
if (this.detailData !== null) {
// 展示详情内容
Text(this.detailData.title).fontSize(20)
}
}
.onAppear(() => {
// 组件出现时:selectedItemId 已经从 AppStorage 恢复,直接用
// dataLoaded 标志位保证不会因为组件重建而重复请求
if (!this.dataLoaded && this.selectedItemId !== -1) {
this.loadDetail(this.selectedItemId);
this.dataLoaded = true;
}
})
}
private loadDetail(id: number): void {
// 根据 id 加载详情数据
}
}
dataLoaded 用 boolean 而不是 @State,是因为它只是一个逻辑控制位,不需要驱动 UI 刷新,也不需要跨形态保持——重建后重新加载一次数据是合理的。
七、Navigation 与状态管理之间的配合
Navigation 和 AppStorage 的职责边界很清晰,两者各管各的:
| 职责 | 由谁负责 |
|---|---|
| 根据窗口宽度切换单栏 / 双栏 | Navigation(NavigationMode.Auto) |
| 维护路由历史,页面进出 | NavPathStack |
| 保持业务临时状态,跨生命周期 | AppStorage + @StorageLink |
| 感知折叠状态变化(有需要时) | display.on('foldStatusChange') |
如果应用有针对折叠状态的专项业务逻辑(比如折叠后暂停视频播放),可以引入 display 模块的监听:
// 在需要感知折叠状态的位置注册,例如 UIAbility 的 onWindowStageCreate
import display from '@ohos.display';
import window from '@ohos.window';
// isFoldable() 是 API 9+ 的接口,先判断设备是否支持折叠
if (display.isFoldable()) {
// on('foldStatusChange') 是 API 10+ 的接口
display.on('foldStatusChange', (foldStatus: display.FoldStatus) => {
// FoldStatus.FOLD_STATUS_EXPANDED = 1(展开)
// FoldStatus.FOLD_STATUS_FOLDED = 2(折叠)
// FoldStatus.FOLD_STATUS_HALF_FOLDED = 3(半折叠)
AppStorage.setOrCreate<display.FoldStatus>('currentFoldStatus', foldStatus);
});
}
在 UIAbility 的 onWindowStageDestroy 里记得取消监听,避免内存泄漏:
onWindowStageDestroy(): void {
if (display.isFoldable()) {
display.off('foldStatusChange');
}
}
这里有个地方比较容易理解错:如果只是想让 Navigation 在展开后变双栏,不需要手动处理 foldStatusChange,NavigationMode.Auto 已经内置了这个逻辑——它监听的是窗口宽度,不是折叠状态枚举值。display.on('foldStatusChange') 是给那些需要在业务层感知折叠态的场景用的。
八、形态切换测试用例怎么设计
只测"展开后能显示"覆盖不了多少问题。更有效的做法是把操作序列和状态期望一起设计:
| 操作序列 | 期望状态 | 容易出问题的地方 |
|---|---|---|
| 折叠态选中第 N 项 → 展开 | 双栏右侧显示第 N 项详情,左侧高亮第 N 项 | selectedItemId 丢失导致右栏空白或显示错误项 |
| 折叠态滚动到第 M 条 → 展开 → 折回 | 折回后列表仍在第 M 条附近 | listFirstIndex 未保存,或 onAppear 里的恢复逻辑被覆盖 |
| 展开态双栏选中第 N 项 → 折叠 | 折叠后单栏仍显示第 N 项详情页 | 双栏操作没有同步写入 AppStorage,单栏读到的是旧值 |
| 连续多次折叠 → 展开 → 折叠 | 每次折回后状态与上次离开时一致 | 多次切换下写入时序问题,状态互相覆盖 |
DevEco Studio 模拟器支持折叠屏形态切换,可以用来触发窗口宽度变化,观察 onScrollIndex 和 @StorageLink 的赋值是否按预期更新。如果想更精准地追踪状态变化,在 onScrollIndex 和点击回调里加 console.log 输出 AppStorage 的值,再对照模拟器行为核查。
有一条容易遗漏的测试边界:AppStorage 与应用生命周期绑定,应用退出后其中的数据会丢失。如果产品需要用户下次打开应用时仍然恢复上次的选中项,需要使用 PersistentStorage 而不是 AppStorage。这两个接口目的不同,搞混了状态保持会失效。
开发经验总结
@State生命周期绑定组件实例,Navigation 模式切换导致组件重建时会被重置。跨形态需要保持的业务临时状态,改用@StorageLink绑定AppStorage。- 状态要"用户操作时实时写入",不要依赖折叠事件触发时再保存——时机晚,也容易有竞态。
NavigationMode.Auto根据窗口宽度(600vp 阈值)自动切换单双栏,NavPathStack在切换时不会被清空,但页面组件可能重建。- 滚动位置用
Scroller.scrollToIndex()在onAppear里恢复,onScrollIndex回调负责实时记录firstIndex。 display.on('foldStatusChange')是 API 10+ 接口,只在有针对折叠状态的专项业务逻辑时才需要引入,基础布局适配不需要它。使用前必须先调用display.isFoldable()确认设备支持(API 9+)。AppStorage的生命周期与应用一致,应用退出后数据丢失。需要持久化到下次启动的状态,应使用PersistentStorage。
如果你正在做折叠屏适配,可以先检查项目中哪些用了 @State 却承载了搜索词、选中项这类业务临时状态——通常这就是状态丢失问题的起点。
📝 写在最后
如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!
我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!
感谢你的阅读,我们下篇文章再见~👋
✍️ 作者:菜鸟不学编程
🧵 本文原创,转载请注明出处。
更多推荐




所有评论(0)