在这里插入图片描述

最近在做一个跨平台商城项目,需要同时支持 Android、iOS 和 OpenHarmony。首页作为用户进入App后的第一个界面,承载了很多功能:导航入口、搜索、分类筛选、用户状态展示等。今天把实现过程分享出来,希望对大家有帮助。

从项目入口说起

每个 Flutter 项目都从 main.dart 开始,我们的也不例外:

void main() {
  runApp(const MyApp());
}

runApp 是 Flutter 应用的启动函数,它接收一个 Widget 作为参数,这个 Widget 就是整个应用的根节点。我们传入的是 MyApp,一个自定义的 StatefulWidget。

为什么用 StatefulWidget 而不是 StatelessWidget?因为我们需要在应用层面管理一些状态,比如深色模式开关、用户登录信息等。这些状态变化时,整个应用的主题都要跟着变。

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  
  State<MyApp> createState() => _MyAppState();
}

这里的 super.key 是 Dart 3 的新语法,等价于以前的 Key? key 然后 super(key: key),写起来更简洁。

状态管理方案的选择

状态管理是 Flutter 开发绑不开的话题。Provider、Riverpod、GetX、Bloc… 选择太多反而让人纠结。这个项目我选了一个比较轻量的方案:InheritedNotifier

它是 Flutter 内置的,不需要额外依赖,而且原理简单:当 Notifier 调用 notifyListeners() 时,所有依赖它的 Widget 都会重建。

先定义一个 AppStateScope 来包装状态:

class AppStateScope extends InheritedNotifier<AppState> {
  const AppStateScope({
    super.key,
    required AppState state,
    required super.child,
  }) : super(notifier: state);

命名用 Scope 是参考了 Provider 的风格,表示"作用域"的意思,这个 Widget 下面的所有子 Widget 都能访问到这个状态。

接下来是获取状态的静态方法:

  static AppState of(BuildContext context) {
    final scope = context.dependOnInheritedWidgetOfExactType<AppStateScope>();
    if (scope == null) {
      throw FlutterError(
        'AppStateScope.of() called without AppStateScope in tree.'
      );
    }
    return scope.notifier!;
  }
}

dependOnInheritedWidgetOfExactType 这个方法名字很长,但做的事情很简单:沿着 Widget 树往上找,找到第一个类型匹配的 InheritedWidget。如果找不到就返回 null,所以我们要做个空检查,找不到就抛异常,方便调试。

使用的时候一行代码搞定:

final appState = AppStateScope.of(context);

如果你不确定 context 上面有没有 AppStateScope,可以用 maybeOf 方法,它返回的是可空类型,不会抛异常。

把状态注入到应用中

有了 AppStateScope,接下来要把它放到 Widget 树的顶层:

class _MyAppState extends State<MyApp> {
  final AppState _appState = AppState();

  
  void dispose() {
    _appState.dispose();
    super.dispose();
  }

dispose 这里很重要。AppState 继承自 ChangeNotifier,内部可能有一些监听器。如果不调用 dispose,这些监听器不会被清理,可能造成内存泄漏。虽然 MyApp 作为根 Widget 几乎不会被销毁,但养成好习惯总没错。

build 方法里把 AppStateScope 包在最外层:

  
  Widget build(BuildContext context) {
    return AppStateScope(
      state: _appState,
      child: AnimatedBuilder(
        animation: _appState,
        builder: (context, _) {
          return MaterialApp(
            title: '爱淘淘',
            debugShowCheckedModeBanner: false,

你可能会问,InheritedNotifier 不是已经能监听变化了吗,为什么还要 AnimatedBuilder?原因是 MaterialApp 本身不在 AppStateScope 的子树里(它是 AppStateScope 的 child 的一部分),所以需要 AnimatedBuilder 来触发重建。

主题配置用了 Material 3 的新 API:

            theme: ThemeData(
              colorScheme: ColorScheme.fromSeed(
                seedColor: Colors.deepPurple,
                brightness: _appState.darkMode 
                  ? Brightness.dark 
                  : Brightness.light,
              ),
              useMaterial3: true,
            ),

ColorScheme.fromSeed 是 Material 3 引入的,只需要一个种子颜色,它就能自动生成一整套配色方案,包括 primary、secondary、surface、background 等等。比以前手动配色方便太多了。

深色模式的切换也很简单:

            themeMode: _appState.darkMode ? ThemeMode.dark : ThemeMode.light,
            initialRoute: AppRoutes.home,
            routes: AppRoutes.builders(),
          );
        },
      ),
    );
  }
}

themeMode 有三个值:lightdarksystem。我们这里根据用户设置来切换,如果想跟随系统,用 ThemeMode.system 就行。

路由的集中管理

页面一多,路由管理就成了问题。我的做法是把所有路由都放到一个类里:

class AppRoutes {
  static const String home = '/';
  static const String shop = '/shop';
  static const String login = '/auth/login';
  static const String register = '/auth/register';
  static const String profile = '/me/profile';
  static const String settings = '/me/settings';
  // ... 更多路由
}

命名上我用了分层的路径风格,比如 /auth/login/me/profile/order/checkout。这样做的好处是一眼就能看出页面属于哪个模块,后期维护也方便。

路由和页面的映射关系:

static Map<String, WidgetBuilder> builders() {
  return <String, WidgetBuilder>{
    home: (_) => const AppHomePage(),
    shop: (_) => const ShopShellPage(),
    login: (_) => const LoginPage(),
    register: (_) => const RegisterPage(),
    profile: (_) => const ProfilePage(),
    // ... 更多映射
  };
}

WidgetBuilder 是一个函数类型:Widget Function(BuildContext)。这里用下划线 _ 表示我们不需要用到 context 参数。

还有一个方法用来获取所有页面的列表,首页会用到:

static List<AppRouteEntry> allEntries() {
  return const <AppRouteEntry>[
    AppRouteEntry(title: '商店', route: shop),
    AppRouteEntry(title: '登录', route: login),
    AppRouteEntry(title: '注册', route: register),
    // ... 更多条目
  ];
}

AppRouteEntry 是一个简单的数据类,包含 titleroute 两个字段。用 const 构造可以让这些对象在编译期就创建好,运行时不需要额外分配内存。

首页的整体结构

终于到首页了。首页用 StatefulWidget,因为有两个本地状态需要管理:

class _AppHomePageState extends State<AppHomePage> {
  String _query = '';
  int _selectedCategory = 0;

_query 是搜索关键词,_selectedCategory 是当前选中的分类索引。这两个状态只在首页内部使用,不需要放到全局状态里。

分类数据我直接写死了:

  final _categories = [
    {'name': '全部', 'icon': Icons.apps},
    {'name': '商店', 'icon': Icons.storefront},
    {'name': '账户', 'icon': Icons.person},
    {'name': '订单', 'icon': Icons.receipt_long},
    {'name': '其他', 'icon': Icons.settings},
  ];

实际项目中这些数据可能从后端获取,或者放到配置文件里。但对于演示项目,写死是最简单的方式。

页面筛选逻辑

筛选逻辑我单独抽成了一个方法,这样 build 方法会更清晰:

List<AppRouteEntry> _getFilteredEntries() {
  final entries = AppRoutes.allEntries();
  var filtered = entries;

这里用 var 而不是 final,因为 filtered 后面会被重新赋值。如果用 final,编译器会报错。

根据选中的分类过滤:

  if (_selectedCategory == 1) {
    filtered = entries.where((e) => 
      e.route.contains('/shop') || e.route.contains('/catalog')
    ).toList();
  } else if (_selectedCategory == 2) {
    filtered = entries.where((e) => 
      e.route.contains('/me') || e.route.contains('/auth')
    ).toList();
  } else if (_selectedCategory == 3) {
    filtered = entries.where((e) => 
      e.route.contains('/order') || e.route.contains('/address')
    ).toList();
  }

这里用路由路径来判断分类,是一个取巧的做法。因为我们的路由命名是有规律的,所以可以通过路径包含的关键词来判断页面属于哪个分类。比如路径包含 /shop 的就是商店相关页面。

再根据搜索关键词过滤:

  if (_query.trim().isNotEmpty) {
    filtered = filtered.where((e) => 
      e.title.toLowerCase().contains(_query.trim().toLowerCase())
    ).toList();
  }

  return filtered;
}

搜索时把标题和关键词都转成小写再比较,这样大小写不敏感。trim() 去掉首尾空格,避免用户不小心输入空格导致搜不到结果。

顶部导航栏

AppBar 上放了两个按钮:消息通知和用户头像。

消息按钮带未读数量徽章:

AnimatedBuilder(
  animation: appState,
  builder: (context, _) {
    final unread = appState.unreadCount;
    return IconButton(
      icon: Badge(
        isLabelVisible: unread > 0,
        label: Text('$unread'),
        child: const Icon(Icons.notifications_outlined),
      ),
      onPressed: () => Navigator.of(context).pushNamed(AppRoutes.messages),
    );
  },
),

Badge 是 Material 3 新增的组件,用来显示小红点或数字徽章。isLabelVisible 控制是否显示,我们只在未读数大于 0 时才显示。用 AnimatedBuilder 是因为 unreadCount 可能会变化(比如收到新消息),需要监听 appState 的变化来更新 UI。

用户头像按钮稍微复杂一点:

AnimatedBuilder(
  animation: appState,
  builder: (context, _) {
    final user = appState.currentUser;
    return IconButton(
      icon: CircleAvatar(
        radius: 14,
        backgroundColor: Theme.of(context).colorScheme.primaryContainer,
        child: Text(
          user?.nickname.isNotEmpty == true 
            ? user!.nickname[0].toUpperCase() 
            : '游',
        ),
      ),
      onPressed: () => Navigator.of(context).pushNamed(
        appState.isLoggedIn ? AppRoutes.profile : AppRoutes.login,
      ),
    );
  },
),

显示逻辑是这样的:如果用户已登录且有昵称,显示昵称的首字母(大写);否则显示"游"字,表示游客身份。点击时根据登录状态跳转,已登录跳个人资料页,未登录跳登录页。

搜索框的实现

搜索框放在页面顶部的卡片里:

TextField(
  decoration: InputDecoration(
    border: const OutlineInputBorder(),
    labelText: '搜索页面',
    prefixIcon: const Icon(Icons.search),
    suffixIcon: _query.isNotEmpty
        ? IconButton(
            icon: const Icon(Icons.clear),
            onPressed: () => setState(() => _query = ''),
          )
        : null,
  ),
  onChanged: (v) => setState(() => _query = v),
),

OutlineInputBorder 给输入框加一个边框,看起来更清晰。Material 3 默认的输入框样式是下划线,我个人更喜欢边框样式。

清空按钮只在有内容时显示,用三元表达式根据 _query 是否为空来决定。suffixIcon 可以是 null,这时候就不显示任何东西。

onChanged 在每次输入变化时触发,实现实时搜索。如果想在用户按回车时才搜索,可以用 onSubmitted。

分类标签栏

分类用横向滚动的 FilterChip 列表:

SizedBox(
  height: 50,
  child: ListView.builder(
    scrollDirection: Axis.horizontal,
    padding: const EdgeInsets.symmetric(horizontal: 12),
    itemCount: _categories.length,

给 SizedBox 一个固定高度,这样 ListView 才知道自己有多高。如果不设置,ListView 会尝试占满所有可用空间,可能导致布局问题。scrollDirection: Axis.horizontal 让列表横向滚动,适合标签栏这种场景。

每个标签的构建:

    itemBuilder: (context, index) {
      final cat = _categories[index];
      final isSelected = _selectedCategory == index;
      return Padding(
        padding: const EdgeInsets.only(right: 8),
        child: FilterChip(
          selected: isSelected,
          label: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              Icon(cat['icon'] as IconData, size: 16),
              const SizedBox(width: 4),
              Text(cat['name'] as String),
            ],
          ),
          onSelected: (_) => setState(() => _selectedCategory = index),
        ),
      );
    },
  ),
),

FilterChip 是 Material 组件,自带选中/未选中的样式切换,不需要我们手动处理。selected 属性控制当前状态,onSelected 回调在点击时触发。

mainAxisSize: MainAxisSize.min 让 Row 只占用必要的宽度,而不是撑满整个空间。这对于 Chip 这种紧凑型组件很重要。

页面列表

列表部分要处理两种情况:有数据和无数据。

Expanded(
  child: filtered.isEmpty
      ? Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Icon(Icons.search_off, size: 60, color: Colors.grey.shade400),
              const SizedBox(height: 16),
              const Text('未找到页面'),
            ],
          ),
        )

当搜索没有结果时,显示一个友好的提示,而不是空白页面。这是用户体验的基本要求。Expanded 让列表占满剩余空间,如果不用 Expanded,ListView 会报错,因为它不知道自己应该有多高。

有数据时用 ListView.builder:

      : ListView.builder(
          padding: const EdgeInsets.all(12),
          itemCount: filtered.length,
          itemBuilder: (context, index) {
            final entry = filtered[index];
            return Padding(
              padding: const EdgeInsets.only(bottom: 8),
              child: ShopCard(
                child: ListTile(
                  contentPadding: EdgeInsets.zero,

ListView.builder 是懒加载的,只有即将显示的 item 才会被创建。对于长列表来说性能更好。虽然我们这里只有 30 个页面,用哪个都行,但养成用 builder 的习惯没坏处。

contentPadding: EdgeInsets.zero 是因为 ListTile 默认有内边距,但我们的 ShopCard 已经有了,所以把 ListTile 的内边距去掉,避免重复。

列表项的图标部分:

                  leading: Container(
                    width: 40,
                    height: 40,
                    decoration: BoxDecoration(
                      color: Theme.of(context).colorScheme.primaryContainer,
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: Icon(
                      _getIconForRoute(entry.route),
                      color: Theme.of(context).colorScheme.onPrimaryContainer,
                      size: 20,
                    ),
                  ),

primaryContainer 和 onPrimaryContainer 是 Material 3 的配色规范。Container 是背景色,onContainer 是前景色(文字/图标),它们是配套的,保证对比度足够。

标题和副标题:

                  title: Text(entry.title),
                  subtitle: Text(
                    entry.route, 
                    style: Theme.of(context).textTheme.bodySmall
                  ),
                  trailing: const Icon(Icons.chevron_right),
                  onTap: () => Navigator.of(context).pushNamed(entry.route),
                ),
              ),
            );
          },
        ),
),

副标题显示路由路径,这样开发时可以快速知道每个页面对应的路由是什么,方便调试。正式上线可以去掉。chevron_right 是右箭头图标,暗示用户这是可以点击进入的。

图标匹配逻辑

根据路由路径自动匹配图标:

IconData _getIconForRoute(String route) {
  if (route.contains('shop')) return Icons.storefront;
  if (route.contains('login')) return Icons.login;
  if (route.contains('register')) return Icons.person_add;
  if (route.contains('profile')) return Icons.person;
  if (route.contains('settings')) return Icons.settings;
  if (route.contains('order')) return Icons.receipt_long;
  if (route.contains('cart')) return Icons.shopping_cart;
  if (route.contains('favorite')) return Icons.favorite;
  if (route.contains('message')) return Icons.message;
  return Icons.article;
}

contains 做模糊匹配,这样 /order/orders/order/order-detail 都能匹配到订单图标。最后返回一个默认图标 Icons.article,确保任何页面都有图标显示,不会出现空白。

底部导航栏

底部导航是商城 App 的标配:

bottomNavigationBar: BottomNavigationBar(
  type: BottomNavigationBarType.fixed,
  currentIndex: 0,
  items: const [
    BottomNavigationBarItem(icon: Icon(Icons.home), label: '首页'),
    BottomNavigationBarItem(icon: Icon(Icons.storefront), label: '商店'),
    BottomNavigationBarItem(icon: Icon(Icons.category), label: '分类'),
    BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'),
  ],

type 设为 fixed 是因为当 item 超过 3 个时,BottomNavigationBar 默认会切换到 shifting 模式,只显示选中项的文字。设置 fixed 可以让所有 item 都显示文字。

点击处理:

  onTap: (index) {
    switch (index) {
      case 1:
        Navigator.of(context).pushNamed(AppRoutes.shop);
        break;
      case 2:
        Navigator.of(context).pushNamed(AppRoutes.categories);
        break;
      case 3:
        Navigator.of(context).pushNamed(
          appState.isLoggedIn ? AppRoutes.profile : AppRoutes.login,
        );
        break;
    }
  },
),

点击其他 tab 时,我们是 push 一个新页面,而不是在当前页面切换内容。这样用户按返回键可以回到首页,符合 Android 的导航习惯。case 0 没有处理,因为点击首页 tab 时我们已经在首页了,不需要做任何事情。

一些踩过的坑

开发过程中遇到了几个问题,分享一下:

1. InheritedWidget 的更新时机

一开始我把 MaterialApp 直接放在 AppStateScope 的 child 里,结果发现切换深色模式时主题不更新。后来才明白,InheritedWidget 只会通知它的子 Widget 重建,而 MaterialApp 是在 AppStateScope 构建时就创建好的,不会被重建。解决方案就是加一层 AnimatedBuilder。

2. ListView 在 Column 里报错

如果你把 ListView 直接放在 Column 里,会报 “Vertical viewport was given unbounded height” 的错误。原因是 ListView 需要知道自己的高度,但 Column 给它的是无限高度。解决方案是用 Expanded 包裹 ListView。

3. FilterChip 的 onSelected 参数

FilterChip 的 onSelected 回调参数是 bool,表示新的选中状态。但我们这里不需要用到这个值,因为我们是单选,点击就选中。所以用 (_) 忽略这个参数。

总结

这篇文章实现了一个功能完整的商城首页,包括:

  • 使用 InheritedNotifier 做轻量级状态管理
  • 路由集中管理,命名分层清晰
  • 搜索和分类筛选组合使用
  • 根据登录状态动态调整 UI
  • 底部导航栏的实现

代码都是从实际项目中提取的,可以直接运行。下一篇我们来实现登录页面,会涉及到表单验证、异步请求等内容。

有问题欢迎留言讨论。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐