请添加图片描述

设计目标与背景

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

  1. 缺乏全局导航:用户从"首页"进入仓库详情后,想切换到"个人中心"需要先返回首页再跳转
  2. 状态丢失:每次离开页面再返回,之前的数据需要重新加载
  3. 不符合移动端习惯:几乎所有主流应用都使用底部 Tab 作为一级导航
  4. 发现性差:新功能藏在路由中,用户不清楚应用有哪些功能

架构设计

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 支持 iconselectedIcon 分离:

  • icon:未选中状态的图标(outlined 风格)
  • selectedIcon:选中状态的图标(filled 风格)

这种设计让选中 Tab 在视觉上更突出——不仅是颜色变化,还有图标填充状态的变化。Icons.home_outlinedIcons.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 由三个层次组成:

  1. 大图标(灰度)——建立视觉焦点
  2. 说明文案——解释当前功能和价值
  3. 登录按钮——引导用户执行核心操作

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 处理非常简单
Logo

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

更多推荐