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

前言
底部导航栏切换 Tab 是移动应用最常见的交互模式之一。但 Flutter 初学者容易踩一个坑:切换 Tab 后切回来,之前页面的滚动位置、输入内容、选中状态全丢了——页面被重建了。
这是因为 Flutter 的 widget 是声明式的:当 BottomNavigationBar 切换索引时,如果使用 _pages[_currentIndex] 或 if/switch 切换子 widget,旧的 widget 会被 dispose,新的被创建。
解决方案是 IndexedStack——它同时持有所有子 widget,但只渲染当前索引的那一个。本文拆解 IndexedStack 的工作原理、性能影响和使用场景。
项目仓库:todo_flutter_harmony
问题重现:错误写法
// 错误写法——每次切换都会重建页面
class HomePage extends StatefulWidget {
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _currentIndex = 0;
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:正确的写法
class HomePage extends StatefulWidget {
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _currentIndex = 0;
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 方案:
class MemoListPage extends StatefulWidget {
State<MemoListPage> createState() => _MemoListPageState();
}
class _MemoListPageState extends State<MemoListPage>
with AutomaticKeepAliveClientMixin {
bool get wantKeepAlive => true; // 关键
Widget build(BuildContext context) {
super.build(context); // 必须调用
return ...;
}
}
配合 PageView 使用:
PageView(
controller: _pageController,
children: const [
MemoListPage(),
TodoListPage(),
DiaryListPage(),
StatsPage(),
],
)
这个方案的优点是页面可以懒加载(切到该页才初始化),但代码更复杂。
对于只有 4 个 Tab 的备忘录应用,IndexedStack 的简洁性胜出。
内存分析
4 个页面同时存活会占多少内存?
MemoListPage:一个 ListView + 若干 Provider Consumer,约 2-3MBTodoListPage:同上,约 2-3MBDiaryListPage:同���,约 2-3MBStatsPage:一个 Grid 布局 + 热力图,约 3-5MB
总计约 10-15MB。对于现代手机(通常 4GB+ RAM),这个内存开销完全可以接受。
数据加载时机
使用 IndexedStack 时,所有 4 个页面在首次创建时都会执行 initState。这意味着 4 个 Provider 的数据加载会同时触发:
// 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 个:
class StatsPage extends StatelessWidget {
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 切换保持页面状态的最简方案:
- 所有子页面同时创建并存活,切换时不销毁不重建
- 只有当前 index 的子页面被渲染,其他页面保持存活但不可见
- 内存开销 ~10-15MB,对现代设备可接受
AutomaticKeepAliveClientMixin是更灵活但更复杂的替代方案
4 行代码(IndexedStack + 4 个 children)解决了一个常见且恼人的用户体验问题。
完整项目代码见:todo_flutter_harmony
更多推荐




所有评论(0)