鸿蒙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 从树中被移除,其 State 被 dispose。当切回 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

相关推荐
SoaringHeart1 小时前
Flutter 查缺补漏:@JsonKey 属性全解,让模型层不再被后端字段牵着走
flutter
贾伟康1 小时前
HarmonyOS API 26 UKey PIN 授权门禁:状态查询、失败归因与资源闭环
华为·harmonyos
Dream-Y.ocean7 小时前
鸿蒙PC平台星图遥感适配实战:基于 Electron 壳方案的纯 JavaScript 遥感图像处理引擎开发
javascript·electron·harmonyos
七月稻草人12 小时前
鸿蒙平板能当电脑第二块屏幕吗?ToDesk、向日葵镜像/副屏实测
华为·电脑·harmonyos
贾伟康12 小时前
【共创稿事节】HarmonyOS 7.0 Want隐式跳转:从接入到生产验证
性能优化·实战·harmonyos·arkts·arkui
NJPJI_Yang12 小时前
鸿蒙 HarmonyOS 7 应用内存优化:appMemoryOptimizer 释放 so 页缓存实战
pytorch·深度学习·harmonyos
恋猫de小郭14 小时前
Flutter 可以更直接地调用原生 API 了,Dart Direct Native Interop
android·前端·flutter
翰德恩咨询14 小时前
IPD咨询洞察:华为如何以项目为中心,激活组织
华为
熊猫钓鱼>_>14 小时前
三方库 NDK 原生模块跨平台适配与调试【鸿蒙心迹】
开发语言·华为·ai编程·harmonyos·鸿蒙·openharmony·鸿蒙心迹
码点14 小时前
二.Flutter 速成第二课:核心概念与第一个自定义页面
flutter