AtomGit Flutter鸿蒙客户端:Tab导航架构
AtomGit Flutter 客户端最初使用单路由架构——所有页面通过 Navigator 推入推出,没有底部 Tab 栏。这种方案在功能较少的应用初期是可行的,但随着功能增长,几个问题逐渐凸显

设计目标与背景
AtomGit Flutter 客户端最初使用单路由架构——所有页面通过 Navigator 推入推出,没有底部 Tab 栏。这种方案在功能较少的应用初期是可行的,但随着功能增长,几个问题逐渐凸显:
- 缺乏全局导航:用户从"首页"进入仓库详情后,想切换到"个人中心"需要先返回首页再跳转
- 状态丢失:每次离开页面再返回,之前的数据需要重新加载
- 不符合移动端习惯:几乎所有主流应用都使用底部 Tab 作为一级导航
- 发现性差:新功能藏在路由中,用户不清楚应用有哪些功能
架构设计
Tab 结构定义
项目设计了 4 个 Tab,覆盖应用的四个核心功能区域:
static const _tabs = <Widget>[
HomeTab(), // 首页:热门仓库 + 我的仓库
ExploreTab(), // 发现:搜索 + 推荐
NotificationsTab(), // 通知:消息和动态
ProfileTab(), // 我的:用户信息和设置
];
每个 Tab 是一个完整的独立页面(拥有自己的 Scaffold、AppBar 和状态管理),而不是共享同一个父 Scaffold 的局部组件。这种设计保证每个 Tab 的独立性——它们可以有不同的 AppBar 样式、不同的路由栈、不同的状态管理策略。
导航层级总览
MaterialApp (root Navigator)
├── MainShell (IndexedStack) ← '/' 路由
│ ├── HomeTab (Scaffold + 独立 AppBar)
│ ├── ExploreTab (Scaffold + 独立 AppBar)
│ ├── NotificationsTab (Scaffold + 独立 AppBar)
│ └── ProfileTab (Scaffold + 独立 AppBar)
│
├── RepoDetailScreen ← '/repo'
├── FileTreeScreen ← '/repo/code'
├── CodeViewScreen ← '/repo/blob'
├── IssueListScreen ← '/repo/issues'
├── IssueDetailScreen ← '/repo/issues/detail'
├── ProfileScreen ← '/user'
├── SearchScreen ← '/search'
├── StarredReposScreen ← '/starred'
├── SettingsScreen ← '/settings'
└── LoginScreen ← '/login'
核心规则只有一条:Tab 切换在 IndexedStack 内部完成,不涉及路由变化;所有详情页通过 root Navigator 全屏覆盖 Tab 栏。
MainShell 的实现
class MainShell extends StatefulWidget {
const MainShell({super.key});
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _currentIndex = 0;
static const _tabs = <Widget>[
HomeTab(),
ExploreTab(),
NotificationsTab(),
ProfileTab(),
];
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _tabs,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
setState(() => _currentIndex = index);
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: '发现',
),
NavigationDestination(
icon: Icon(Icons.notifications_outlined),
selectedIcon: Icon(Icons.notifications),
label: '通知',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的',
),
],
),
);
}
}
为什么使用 StatefulWidget 而非 StatelessWidget + Provider
_currentIndex 是 Tab 切换的本地 UI 状态。它只在 MainShell 内部使用,不需要被其他 Widget 感知或响应。将这种状态放入 Provider 是过度设计——Provider 适合跨组件共享的状态(如登录状态),而 Tab 选中索引只是局部交互状态。
NavigationBar 的图标变体
Material 3 的 NavigationBar 支持 icon 和 selectedIcon 分离:
icon:未选中状态的图标(outlined 风格)selectedIcon:选中状态的图标(filled 风格)
这种设计让选中 Tab 在视觉上更突出——不仅是颜色变化,还有图标填充状态的变化。Icons.home_outlined 和 Icons.home 对应 outline 和 filled 两种图标字体变体。
IndexedStack:Tab 状态保持的核心
IndexedStack 是 Flutter 中少数能同时持有多个子 Widget 的组件之一。它的工作机制:
IndexedStack(
index: 2, // 只显示索引 2 的子 Widget
children: [A, B, C, D],
)
虽然只有 C 可见,但 A、B、C、D 的 State 对象都存在于 Widget 树中。切换到 D 时,C 的 State 不销毁,D 的 State 直接复用(如果之前创建过)。
这意味着:
- 滚动位置保持:在"发现" Tab 滚动到第 50 条,切换到"首页"再回来,滚动位置不变
- 数据缓存保持:Tab 中通过 Provider 加载的数据不丢失
- 输入状态保持:TextField 的内容不丢失
其他方案的对比分析
| 方案 | 状态保持 | 初始性能 | 内存占用 | 适用场景 |
|---|---|---|---|---|
IndexedStack |
自动保持所有 | 一次创建所有 | 较高(4 个 Tab 都在内存) | Tab 数 ≤ 5 |
PageView + AutomaticKeepAliveClientMixin |
手动管理 | 按需创建 | 较低(可释放未使用页) | Tab 数 > 5 |
Offstage |
保持但全部渲染 | 一次创建所有 | 高(全部渲染) | 不推荐 |
Visibility |
保持 | 一次创建所有 | 高(全部在布局中) | 不推荐 |
条件渲染 if (index == i) |
切换即销毁 | 仅当前 Tab | 最低 | 无状态 Tab |
选择 IndexedStack 的理由:
- 4 个 Tab 数量较少,全部持有的内存开销可接受
- 不需要手写
AutomaticKeepAliveClientMixin - 切换即显示,无重建延迟
IndexedStack 的代价:
- 启动时同时创建 4 个 Tab,可能增加首帧渲染时间
- 如果某个 Tab 的数据加载很重,会在后台消耗网络和 CPU
项目通过延迟加载策略缓解这个问题:Tab 内部在 build 中通过 addPostFrameCallback 触发加载(而非在 initState 中),利用 IndexedStack 不会立即 build 非可见 Widget 的特性,实际上只有首次切换到该 Tab 时才真正触发数据请求。
IndexedStack 的 build 时机
重要但不明显的细节:IndexedStack 的不可见子 Widget 也会经历 initState 和初始 build 吗?
答案是会的。IndexedStack 在首次构建时会遍历所有子 Widget,调用它们的 initState 和首次 build。但之后的 setState 只会重建可见的那个子 Widget。
所以项目中 Tab 在 initState 中的操作应该尽量轻量,重的数据加载放在 addPostFrameCallback 或首次可见时才触发。
Material 3 NavigationBar 详解
NavigationBar 是 Material 3 引入的底部导航组件,替代 Material 2 的 BottomNavigationBar。主要改进:
1. 自适应高度。根据系统字体大小和设备类型自动调整高度,适配折叠屏和大屏设备。
2. 内置动画。选中切换自带过渡动画,不需要手写 AnimatedContainer 或 TweenAnimationBuilder。
3. 可访问性。每个 NavigationDestination 自动获取语义标签,支持 TalkBack。
4. 状态栏适配。自动处理底部安全区域(手势导航条),避免被系统 UI 遮挡。
NavigationBar(
// 可选的样式配置
height: 65, // 自定义高度
backgroundColor: Colors.white, // 背景色
indicatorColor: Colors.blue, // 选中指示器颜色
animationDuration: Duration(milliseconds: 300), // 动画时长
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
setState(() => _currentIndex = index);
},
destinations: [...],
)
详情页的全屏覆盖机制
从 Tab 进入详情页时,导航是在 root Navigator 上进行的:
// 在任何 Tab 内部,push 详情页
Navigator.pushNamed(context, '/repo',
arguments: {'owner': 'flutter', 'name': 'flutter'});
因为 MainShell 的 Scaffold 使用的是默认 Navigator(root),所以 Navigator.pushNamed 推入的页面会覆盖整个 MainShell(包括底部 Tab 栏)。
路由层级示意:
Navigator 栈
├── RepoDetailScreen ← 当前可见(全屏)
├── MainShell ← 被覆盖(状态保留)
│ ├── HomeTab ← 被覆盖(状态保留)
│ ├── ExploreTab ← 被覆盖(状态保留)
│ ├── NotificationsTab ← 被覆盖(状态保留)
│ └── ProfileTab ← 被覆盖(状态保留)
当用户从详情页返回(Navigator.pop),MainShell 连同 4 个 Tab 的完整状态恢复显示。
Auth-Aware UI 在 Tab 中的应用
两个 Tab(通知、我的)根据登录状态展示完全不同的界面:
class NotificationsTab extends StatelessWidget {
Widget build(BuildContext context) {
final isLoggedIn = context.watch<AuthProvider>().isLoggedIn;
return Scaffold(
appBar: AppBar(title: const Text('通知')),
body: isLoggedIn
? _buildNotifications(context)
: _buildLoginPrompt(context),
);
}
}
context.watch<AuthProvider>() 替代了手动的 addListener / removeListener。当 AuthProvider 调用 notifyListeners() 时,所有使用了 watch 的 Widget 会自动重建。
为什么用 watch 而不是 read
// read:一次性读取,不建立订阅
final isLoggedIn = context.read<AuthProvider>().isLoggedIn;
// watch:建立订阅,Provider 变化时自动重建
final isLoggedIn = context.watch<AuthProvider>().isLoggedIn;
在 build 方法中必须使用 watch。如果在 build 中使用 read,登录后 UI 不会更新,用户需要手动刷新才会看到变化。
未登录引导 UI
Widget _buildLoginPrompt(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.notifications_outlined,
size: 80, color: Colors.grey[400]),
const SizedBox(height: 16),
Text('登录后可查看通知',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () =>
Navigator.pushNamed(context, '/login'),
icon: const Icon(Icons.login),
label: const Text('立即登录'),
),
],
),
);
}
}
引导 UI 由三个层次组成:
- 大图标(灰度)——建立视觉焦点
- 说明文案——解释当前功能和价值
- 登录按钮——引导用户执行核心操作
Tab 的独立性原则
每个 Tab 应该是自包含的,它们之间不应有直接的依赖关系:
// 正确:Tab 通过 Navigator 和 Provider 进行间接交互
Navigator.pushNamed(context, '/repo', arguments: {...});
// 错误:Tab 之间直接通信
context.read<HomeTabState>().refresh(); // 不应该这样做
如果需要在 Tab 之间共享状态(如登录后更新多个 Tab),通过全局 Provider(如 AuthProvider)实现。Provider 的 notifyListeners 广播机制天然支持这种一对多的状态传播。
性能考量
首帧渲染。4 个 Tab 在 IndexedStack 中同时创建,可能导致首帧渲染时间较长。优化方法:
- Tab 的 initState 避免重操作(文件 I/O、API 请求)
- 图片和资源延迟加载
- 首帧只渲染可见 Tab 的关键部分
内存。所有 Tab 保持在内存中。对于内存受限的 HarmonyOS 设备,监控内存使用:
- ProfileTab 的 UserProvider 在登出时 dispose(手动管理生命周期)
- 大型列表注意 item 回收(
addAutomaticKeepAlives的使用)
嵌套 Navigator。当前设计使用单个 root Navigator。如果某个 Tab 需要自己的路由栈(例如"我的"Tab 内有子页面但不覆盖底部栏),可以给该 Tab 包裹一个独立的 Navigator。项目当前没有这个需求,所有详情页统一走全屏覆盖模式。
路由注册方式
项目使用 onGenerateRoute 集中注册路由:
static Route<dynamic> generateRoute(RouteSettings settings) {
switch (settings.name) {
case '/':
return MaterialPageRoute(
builder: (_) => const MainShell());
case '/repo':
return MaterialPageRoute(
builder: (_) => const RepoDetailScreen());
case '/repo/code':
return MaterialPageRoute(
builder: (_) => const FileTreeScreen());
case '/settings':
return MaterialPageRoute(
builder: (_) => const SettingsScreen());
case '/login':
return MaterialPageRoute(
builder: (_) => const LoginScreen());
// ...
default:
return MaterialPageRoute(
builder: (_) => const _NotFoundScreen());
}
}
集中在 generateRoute 中的好处:
- 所有路由定义在一个地方,便于查找和修改
- 支持路由守卫(检查登录状态后决定跳转)
- 添加 404 处理非常简单
更多推荐


所有评论(0)