鸿蒙Flutter实战:IndexedStack保持Tab页面状态

前言

底部导航栏切换 Tab 是移动应用最常见的交互模式之一。但 Flutter 初学者容易踩一个坑:切换 Tab 后切回来,之前页面的滚动位置、输入内容、选中状态全丢了------页面被重建了。

这是因为 Flutter 的 widget 是声明式的:当 BottomNavigationBar 切换索引时,如果使用 _pages[_currentIndex]if/switch 切换子 widget,旧的 widget 会被 dispose,新的被创建。

解决方案是 IndexedStack------它同时持有所有子 widget,但只渲染当前索引的那一个。本文拆解 IndexedStack 的工作原理、性能影响和使用场景。

项目仓库:todo_flutter_harmony

问题重现:错误写法

dart 复制代码
// 错误写法------每次切换都会重建页面
class HomePage extends StatefulWidget {
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _buildPage(_currentIndex),  // 直接调用方法
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (index) {
          setState(() => _currentIndex = index);
        },
        items: const [...],
      ),
    );
  }

  Widget _buildPage(int index) {
    switch (index) {
      case 0: return const MemoListPage();
      case 1: return const TodoListPage();
      case 2: return const DiaryListPage();
      case 3: return const StatsPage();
      default: return const SizedBox();
    }
  }
}

问题:当 _currentIndex 从 0 变为 1 时,_buildPage(0) 返回的 MemoListPage widget 从树中被移除,其 Statedispose。当切回 0 时,一个新的 MemoListPage 被创建,所有状态丢失。

IndexedStack:正确的写法

dart 复制代码
class HomePage extends StatefulWidget {
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(
        index: _currentIndex,
        children: const [
          MemoListPage(),
          TodoListPage(),
          DiaryListPage(),
          StatsPage(),
        ],
      ),
      bottomNavigationBar: NavigationBar(
        selectedIndex: _currentIndex,
        onDestinationSelected: (index) {
          setState(() => _currentIndex = index);
        },
        destinations: const [
          NavigationDestination(icon: Icon(Icons.note_alt_outlined), label: '备忘录'),
          NavigationDestination(icon: Icon(Icons.checklist_outlined), label: '待办'),
          NavigationDestination(icon: Icon(Icons.book_outlined), label: '日记'),
          NavigationDestination(icon: Icon(Icons.bar_chart), label: '统计'),
        ],
      ),
    );
  }
}

IndexedStack 的工作原理:

  • 所有 children 都被创建并保持在 widget 树中
  • 只有 children[index] 被渲染(可见)
  • 未渲染的 children 仍然存活,其 State 不会被 dispose
  • 切换到另一个 index 时,之前被隐藏的 child 变成可见,但其 State 原封不动

IndexedStack vs 其他方案

方案 状态保持 内存占用 首次加载
IndexedStack ✅ 全部保持 高(所有页面常驻) 所有页面同时初始化
PageView + AutomaticKeepAliveClientMixin ✅ 按需保持 懒加载
Offstage ✅ 保持(但仍在树中) 所有页面同时初始化
Visibility ❌ 不保持 每次重建
if/switch ❌ 不保持 每次重建

AutomaticKeepAliveClientMixin 方案:

dart 复制代码
class MemoListPage extends StatefulWidget {
  @override
  State<MemoListPage> createState() => _MemoListPageState();
}

class _MemoListPageState extends State<MemoListPage>
    with AutomaticKeepAliveClientMixin {
  @override
  bool get wantKeepAlive => true;  // 关键

  @override
  Widget build(BuildContext context) {
    super.build(context);  // 必须调用
    return ...;
  }
}

配合 PageView 使用:

dart 复制代码
PageView(
  controller: _pageController,
  children: const [
    MemoListPage(),
    TodoListPage(),
    DiaryListPage(),
    StatsPage(),
  ],
)

这个方案的优点是页面可以懒加载(切到该页才初始化),但代码更复杂。

对于只有 4 个 Tab 的备忘录应用,IndexedStack 的简洁性胜出。

内存分析

4 个页面同时存活会占多少内存?

  • MemoListPage:一个 ListView + 若干 Provider Consumer,约 2-3MB
  • TodoListPage:同上,约 2-3MB
  • DiaryListPage:同���,约 2-3MB
  • StatsPage:一个 Grid 布局 + 热力图,约 3-5MB

总计约 10-15MB。对于现代手机(通常 4GB+ RAM),这个内存开销完全可以接受。

数据加载时机

使用 IndexedStack 时,所有 4 个页面在首次创建时都会执行 initState。这意味着 4 个 Provider 的数据加载会同时触发:

dart 复制代码
// MemoListPage.initState
WidgetsBinding.instance.addPostFrameCallback((_) {
  context.read<MemoProvider>().loadMemos();
});

// TodoListPage.initState
WidgetsBinding.instance.addPostFrameCallback((_) {
  context.read<TodoProvider>().loadTodos();
});

// DiaryListPage.initState
WidgetsBinding.instance.addPostFrameCallback((_) {
  context.read<DiaryProvider>().loadDiaries();
});

// StatsPage.initState  ------ 不需要额外加载,数据来自其他 3 个 Provider

4 个 addPostFrameCallback 都在同一帧中注册,在下一帧一起触发。由于每个 Provider 独立加载自己的数据(读 JSON 文件、解析、notifyListeners),它们之间是串行但在 event loop 上快速连续执行。对于几百 KB 的 JSON 文件,总加载时间 < 50ms。

统计页的特殊处理

统计页的数据来自另外 3 个 Provider------它不需要自己加载数据,而是 watch 另外 3 个:

dart 复制代码
class StatsPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final memoProvider = context.watch<MemoProvider>();
    final todoProvider = context.watch<TodoProvider>();
    final diaryProvider = context.watch<DiaryProvider>();

    return SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          _buildStatsGrid(memoProvider, todoProvider, diaryProvider),
          const SizedBox(height: 20),
          _buildCompletionProgress(todoProvider),
          const SizedBox(height: 20),
          _buildMoodDistribution(diaryProvider),
          const SizedBox(height: 20),
          _buildDiaryHeatmap(diaryProvider),
        ],
      ),
    );
  }
}

当用户在备忘录 Tab 新增了一条备忘录,切换到统计 Tab 时,统计页的 context.watch<MemoProvider>() 已经持有了最新数据------因为 MemoProvider 的状态在切换前就更新了。

鸿蒙兼容性

IndexedStack 是 Flutter 框架层的基础组件,完全在 Dart/渲染引擎层实现。不涉及任何平台 API,与鸿蒙 OHOS 零冲突。

总结

IndexedStack 是 Tab 切换保持页面状态的最简方案:

  1. 所有子页面同时创建并存活,切换时不销毁不重建
  2. 只有当前 index 的子页面被渲染,其他页面保持存活但不可见
  3. 内存开销 ~10-15MB,对现代设备可接受
  4. AutomaticKeepAliveClientMixin 是更灵活但更复杂的替代方案

4 行代码(IndexedStack + 4 个 children)解决了一个常见且恼人的用户体验问题。

完整项目代码见:todo_flutter_harmony

相关推荐
年小个大13 小时前
受 go-zero 启发,我给 Flutter 写了一套 CLI 脚手架
android·flutter·架构
智塑未来17 小时前
鸿蒙游戏体验手册:四种能力从性能到玩法逐一解锁
游戏·华为·harmonyos
math_hongfan18 小时前
鸿蒙离线数据缓存高级架构:弱网预加载/离线数据优先级/同步冲突解决/上线后数据合并策略
学习·缓存·华为·架构·harmonyos·鸿蒙
math_hongfan1 天前
鸿蒙企业级数据存储高级架构:从读写分离到冷热数据分层/归档策略/数据生命周期管理最佳实践
人工智能·学习·华为·架构·harmonyos·鸿蒙
GitLqr1 天前
Flutter 居然能直接跑在 Apple Watch 上了?玩转 flutter-watchos
flutter·全栈·apple watch
math_hongfan1 天前
鸿蒙存储异常高级排查:文件损坏检测/数据恢复/读写失败重试/磁盘空间预警系统性根治方案
学习·华为·harmonyos·鸿蒙
lilian2331 天前
Harmony os 技术实战|拼豆制图10:把取消、解析失败和保存失败写成可恢复状态机
java·javascript·华为·harmonyos
2501_919749031 天前
华为鸿蒙美缝剂实用APP—小羊美缝
华为·harmonyos
2501_919749031 天前
华为鸿蒙积攒年度高光APP—小羊高光
华为·harmonyos
智塑未来1 天前
鸿蒙7碰一碰智感交互——碰哪儿传哪儿
华为·harmonyos