👋 你好,欢迎来到我的博客!我是【菜鸟学鸿蒙】
   我是一名在路上的移动端开发者,正从传统“小码农”转向鸿蒙原生开发的进阶之旅。为了把学习过的知识沉淀下来,也为了和更多同路人互相启发,我决定把探索 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 而非 @Statethis.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 加载详情数据
  }
}

dataLoadedboolean 而不是 @State,是因为它只是一个逻辑控制位,不需要驱动 UI 刷新,也不需要跨形态保持——重建后重新加载一次数据是合理的。

七、Navigation 与状态管理之间的配合

Navigation 和 AppStorage 的职责边界很清晰,两者各管各的:

职责由谁负责
根据窗口宽度切换单栏 / 双栏NavigationNavigationMode.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 在展开后变双栏,不需要手动处理 foldStatusChangeNavigationMode.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 却承载了搜索词、选中项这类业务临时状态——通常这就是状态丢失问题的起点。

📝 写在最后

如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!

我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!

感谢你的阅读,我们下篇文章再见~👋

✍️ 作者:菜鸟不学编程
🧵 本文原创,转载请注明出处。

Logo

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

更多推荐