请添加图片描述

前言

底部导航栏切换 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 从树中被移除,其 Statedispose。当切回 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-3MB
  • TodoListPage:同上,约 2-3MB
  • DiaryListPage:同���,约 2-3MB
  • StatsPage:一个 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 切换保持页面状态的最简方案:

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

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

完整项目代码见:todo_flutter_harmony

Logo

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

更多推荐