Flutter for OpenHarmony 商城App实战 - 首页实现
本文分享了Flutter跨平台商城项目首页的实现过程。主要内容包括: 项目入口从main.dart开始,使用StatefulWidget作为根组件以管理全局状态 采用轻量级的InheritedNotifier进行状态管理,通过AppStateScope提供全局状态访问 使用Material 3的ColorScheme.fromSeed自动生成配色方案,支持深色模式切换 集中管理路由配置,采用分层路

最近在做一个跨平台商城项目,需要同时支持 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 有三个值:light、dark 和 system。我们这里根据用户设置来切换,如果想跟随系统,用 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 是一个简单的数据类,包含 title 和 route 两个字段。用 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
更多推荐


所有评论(0)