鸿蒙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

相关推荐
马剑威(威哥爱编程)35 分钟前
【共创稿事节】HarmonyOS 7 Agent 开发实战 05:流式输出做打字机对话
华为·harmonyos
穆梓兰煊2 小时前
Flutter端云协同推理架构解析
flutter·github
AI备忘录3 小时前
(二十五)华为华三锐捷迈普思科 ERPS 环网保护配置命令(G.8032 五厂商对照)
运维·服务器·网络·网络协议·华为
OH_TPC4 小时前
【鸿蒙优选三方库】@ohos/coap:让鸿蒙设备说上 IoT 的母语
物联网·华为·harmonyos·鸿蒙
FungLeo5 小时前
成为全栈·Flutter App 篇·内置 WebView:网页历史、App 返回与外链安全
flutter·网络安全·移动开发·webview·路由管理·成为全栈
程序猿追5 小时前
渐变中间那一格怎么算?用 colormath 做个鸿蒙调色器
华为·harmonyos
程序猿追6 小时前
在 HarmonyOS 6 上做了张 3D 翻转卡片,正面背面来回翻
3d·华为·harmonyos
小π军6 小时前
安卓、iOS、鸿蒙三端的理解
android·ios·harmonyos
m0_738185827 小时前
Flutter 鸿蒙化实战:volume_controller 适配 OpenHarmony,系统音量控制
flutter·华为·harmonyos·鸿蒙
用户117910488337 小时前
每个会话一个子进程:多会话真并行的架构取舍
开源·harmonyos