Flutter for OpenHarmony 微动漫App实战:个人中心实现
通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97
个人中心这个页面,说实话一开始我觉得挺简单的,不就是放几个按钮嘛。但真正做起来才发现,要把它做好还真得花点心思。用户信息怎么展示、统计数据怎么实时更新、菜单列表怎么组织、子页面怎么跳转,每个细节都值得琢磨。
这篇文章会把个人中心模块拆开来讲,从主页面到各个子页面,代码都是项目里实际跑着的。

先聊聊个人中心要做什么
打开任何一个App的"我的"页面,你会发现它们的结构都差不多:顶部是用户头像和昵称,中间是一些统计数据,下面是功能入口列表。这种设计已经被验证过无数次了,用户一看就懂,不需要学习成本。
我们的个人中心也采用这个经典布局,但会加入一些自己的特色,比如统计卡片会实时显示收藏和历史记录的数量,主题切换会立即生效等等。
依赖引入
先看看主页面需要引入哪些东西:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
这两个是基础依赖,material.dart 提供 UI 组件,provider 用来做状态管理。
import '../providers/theme_provider.dart';
import '../providers/history_provider.dart';
import '../providers/favorites_provider.dart';
这三个 Provider 分别管理主题设置、观看历史、收藏列表。个人中心需要从它们那里读取数据来显示统计数字。
import 'history_screen.dart';
import 'settings_screen.dart';
import 'about_screen.dart';
import 'help_screen.dart';
这些是子页面的导入,每个 import 对应一个功能入口。实际项目里还有更多,比如排行榜、分类、时间表等,这里就不全列出来了。
页面用 StatelessWidget 还是 StatefulWidget
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
个人中心主页面用的是 StatelessWidget。你可能会问,页面上不是有统计数据吗,那不是状态吗?
确实是状态,但这些状态不是由这个页面管理的,而是由 Provider 管理的。页面只是"读取"这些数据并显示出来,当 Provider 里的数据变化时,Consumer 会自动触发重建。所以页面本身不需要维护任何状态,用 StatelessWidget 就够了。
这个设计思路很重要:把状态放到该放的地方,页面只负责展示。
整体布局结构
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('我的')),
body: SingleChildScrollView(
child: Column(
children: [
Scaffold 是 Flutter 页面的标准骨架,提供了 AppBar、body、floatingActionButton 等插槽。
SingleChildScrollView 包裹整个内容区域,这样当内容超出屏幕高度时可以滚动。有些同学喜欢用 ListView,但 ListView 更适合列表场景,这里用 Column 组织不同类型的内容块更合适。
用户头像区域
头像用一个渐变色的圆形容器来实现:
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
Theme.of(context).primaryColor,
Theme.of(context).colorScheme.secondary,
],
),
),
BoxDecoration 的 shape: BoxShape.circle 让容器变成圆形。渐变色用 LinearGradient,从主题的主色渐变到次色。
为什么要用主题色而不是写死颜色值?因为这样头像颜色会跟随主题变化。用户切换到深色模式时,头像的渐变色也会自动适配,不需要额外处理。
child: const Icon(
Icons.person,
size: 40,
color: Colors.white,
),
),
头像里放一个人形图标,白色的,在渐变背景上很显眼。如果后续要接入用户系统,可以把这个 Icon 换成 Image.network 加载用户真实头像。
用户昵称和欢迎语
const SizedBox(height: 16),
const Text(
'动漫迷',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
昵称用 20号字体加粗,在页面上比较突出。SizedBox(height: 16) 是头像和昵称之间的间距,比用 Padding 更简洁。
const SizedBox(height: 8),
const Text(
'欢迎来到微动漫',
style: TextStyle(color: Colors.grey),
),
欢迎语用灰色小字,作为昵称的补充说明。这种"大字+小字"的组合在 UI 设计里很常见,能形成视觉层次。
统计卡片的实现
统计卡片是个人中心的亮点,能让用户一眼看到自己的数据:
Widget _buildStatCard(BuildContext context) {
return Consumer2<FavoritesProvider, HistoryProvider>(
builder: (context, favProvider, histProvider, _) {
这里用了 Consumer2,它可以同时监听两个 Provider。为什么不用两个嵌套的 Consumer?因为那样代码会很丑,Consumer2 就是为这种场景设计的。
当收藏列表或历史记录有变化时,这个 builder 函数会被重新调用,卡片上的数字会自动更新。用户在详情页点了收藏,回到个人中心,数字已经变了,不需要手动刷新。
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStat('收藏', favProvider.favorites.length.toString()),
_buildStat('历史', histProvider.history.length.toString()),
_buildStat('追番', '0'),
],
),
),
);
},
);
}
Card 组件自带圆角和阴影,不需要额外设置。Row 配合 MainAxisAlignment.spaceAround 让三个统计项均匀分布,视觉效果很平衡。
favorites.length 和 history.length 直接从 Provider 读取,转成字符串显示。"追番"功能还没做,先写个 0 占位。
单个统计项的构建
Widget _buildStat(String label, String value) {
return Column(
children: [
Text(
value,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
数字用 大号粗体,这是最重要的信息,要最显眼。
const SizedBox(height: 4),
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
);
}
标签用 小号灰色字体,作为数字的说明。这种"数字在上、标签在下"的布局是统计卡片的标准设计,用户一眼就能看到关键数据。
功能菜单列表
菜单列表是个人中心的核心,提供各种功能的入口:
Widget _buildMenuSection(BuildContext context) {
return Column(
children: [
_buildMenuItem(
context,
icon: Icons.trending_up,
title: '趋势',
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const TrendingScreen()),
),
),
每个菜单项都是一个 _buildMenuItem 调用,传入三个参数:图标、标题、点击回调。
点击后用 Navigator.push 跳转到对应的子页面。这是 Flutter 最基本的页面跳转方式,简单直接。
_buildMenuItem(
context,
icon: Icons.history,
title: '观看历史',
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const HistoryScreen()),
),
),
观看历史是用户常用的功能,放在比较靠前的位置。
_buildMenuItem(
context,
icon: Icons.settings,
title: '设置',
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
),
设置页面提供主题切换等功能,是个人中心的标配。
_buildMenuItem(
context,
icon: Icons.help,
title: '帮助',
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const HelpScreen()),
),
),
_buildMenuItem(
context,
icon: Icons.info,
title: '关于',
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AboutScreen()),
),
),
],
);
}
帮助和关于放在最后,这两个功能使用频率较低,但不能没有。
菜单项组件的实现
Widget _buildMenuItem(
BuildContext context, {
required IconData icon,
required String title,
required VoidCallback onTap,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
每个菜单项用 Card 包裹,自带圆角和阴影效果。margin: const EdgeInsets.only(bottom: 8) 让卡片之间有间距,不会挤在一起。
child: ListTile(
leading: Icon(icon, color: Theme.of(context).primaryColor),
title: Text(title),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
ListTile 是 Material Design 的标准列表项组件,自带 leading、title、trailing 三个插槽,正好对应我们需要的"图标-标题-箭头"布局。
左侧图标用主题色,保持视觉统一。右侧箭头提示用户这是可点击的,点击后会跳转到新页面。
设置页面的实现
设置页面主要提供主题切换功能,需要管理选中状态:
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
State<SettingsScreen> createState() => _SettingsScreenState();
}
这里用 StatefulWidget,因为需要维护当前选中的主题选项。
class _SettingsScreenState extends State<SettingsScreen> {
String _selectedTheme = 'system';
_selectedTheme 存储当前选中的主题,默认是"跟随系统"。这个变量用来控制 RadioListTile 的选中状态。
初始化时同步主题状态
页面打开时,需要从 Provider 获取当前主题,保证显示的选中状态和实际主题一致:
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final themeProvider = context.read<ThemeProvider>();
为什么要用 addPostFrameCallback?因为 initState 里不能直接访问 context,会报错。这个回调会在第一帧渲染完成后执行,那时候 context 已经可用了。
setState(() {
switch (themeProvider.themeMode) {
case ThemeMode.light:
_selectedTheme = 'light';
break;
case ThemeMode.dark:
_selectedTheme = 'dark';
break;
default:
_selectedTheme = 'system';
}
});
});
}
根据 ThemeMode 枚举值设置对应的字符串。这样 RadioListTile 才能正确显示选中状态,用户一进页面就能看到当前用的是哪个主题。
主题切换的处理
void _changeTheme(String? theme) {
if (theme == null) return;
setState(() {
_selectedTheme = theme;
});
context.read<ThemeProvider>().setTheme(theme);
}
这个方法做两件事:
- 更新本地状态,让 RadioListTile 立即显示新的选中状态
- 调用 Provider 的 setTheme 方法,真正切换主题并持久化
先 setState 再调 Provider,这样用户点击后能立即看到选中状态变化,体验更流畅。
主题选项的 UI
RadioListTile<String>(
title: const Text('浅色主题'),
value: 'light',
groupValue: _selectedTheme,
onChanged: _changeTheme,
),
RadioListTile 是带单选按钮的列表项,非常适合这种互斥选择的场景。
value 是这个选项代表的值,groupValue 是当前选中的值。当 value == groupValue 时,这个选项显示为选中状态。
RadioListTile<String>(
title: const Text('深色主题'),
value: 'dark',
groupValue: _selectedTheme,
onChanged: _changeTheme,
),
RadioListTile<String>(
title: const Text('跟随系统'),
value: 'system',
groupValue: _selectedTheme,
onChanged: _changeTheme,
),
三个选项覆盖了用户的主要需求:强制浅色、强制深色、跟随系统。大多数用户会选择跟随系统,这样白天自动浅色、晚上自动深色。
ThemeProvider 的实现
主题切换的核心是 ThemeProvider:
class ThemeProvider extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system;
ThemeMode get themeMode => _themeMode;
继承 ChangeNotifier 让这个类具备通知监听者的能力。_themeMode 存储当前主题模式,对外只暴露 getter,不允许直接修改。
ThemeProvider() {
_loadTheme();
}
构造函数里调用 _loadTheme,从本地存储加载之前保存的主题设置。这样 App 重启后主题不会丢失。
加载保存的主题
Future<void> _loadTheme() async {
try {
await StorageService.instance.init();
final theme = StorageService.instance.getString('theme') ?? 'system';
_themeMode = _getThemeMode(theme);
notifyListeners();
} catch (e) {
print('加载主题错误: $e');
}
}
从本地存储读取 theme 这个 key,如果没有就默认 system。然后转换成 ThemeMode 枚举,调用 notifyListeners 通知监听者。
保存主题设置
void setTheme(String theme) {
_themeMode = _getThemeMode(theme);
StorageService.instance.setString('theme', theme);
notifyListeners();
}
这个方法同时做三件事:更新内存状态、写入本地存储、通知监听者。调用后整个 App 的主题会立即切换。
ThemeMode _getThemeMode(String theme) {
switch (theme) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
这个辅助方法把字符串转换成 ThemeMode 枚举,在加载和保存时都会用到。
观看历史页面
历史记录页面展示用户浏览过的动漫:
class HistoryScreen extends StatelessWidget {
const HistoryScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('观看历史'),
actions: [
AppBar 右侧要放一个清空按钮,但只在有历史记录时才显示。
Consumer<HistoryProvider>(
builder: (context, provider, _) => provider.history.isNotEmpty
? IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () {
用 Consumer 监听 HistoryProvider,根据 history.isNotEmpty 决定是否显示按钮。这样当历史记录被清空后,按钮会自动消失。
清空确认对话框
清空是个危险操作,需要二次确认:
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('清空历史'),
content: const Text('确定要清空所有观看历史吗?'),
AlertDialog 是 Material Design 的标准对话框组件,包含标题和内容。
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () {
provider.clearHistory();
Navigator.pop(context);
},
child: const Text('确定'),
),
],
),
);
两个按钮:取消和确定。点取消只是关闭对话框,点确定会调用 clearHistory 清空数据,然后关闭对话框。
历史记录列表
body: Consumer<HistoryProvider>(
builder: (context, provider, _) {
if (provider.history.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.history, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'还没有观看历史',
style: TextStyle(color: Colors.grey[600], fontSize: 16),
),
],
),
);
}
空状态要有友好的提示,让用户知道这里会显示什么内容。一个大图标加一行文字,简洁明了。
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: provider.history.length,
itemBuilder: (_, i) => AnimeListTile(
anime: provider.history[i],
onDelete: () => provider.removeFromHistory(provider.history[i].malId),
),
);
},
),
有数据时用 ListView.builder 展示。每个列表项传入 onDelete 回调,支持滑动删除单条记录。
HistoryProvider 的添加逻辑
Future<void> addToHistory(Anime anime) async {
try {
_history.removeWhere((e) => e.malId == anime.malId);
_history.insert(0, anime);
先移除已存在的相同记录,再插入到列表开头。这样做有两个好处:避免重复,最近浏览的排最前。
if (_history.length > 50) {
_history = _history.sublist(0, 50);
}
限制最多保存 50条,超出的自动删除。这个数量可以根据实际需求调整,太多会占用存储空间,太少又不够用。
await StorageService.instance.setStringList(
'history',
_history.map((e) => json.encode(e.toJson())).toList(),
);
notifyListeners();
} catch (e) {
print('添加历史记录错误: $e');
}
}
每次修改都同步到本地存储,保证数据不丢失。用 json.encode 把 Anime 对象转成 JSON 字符串存储。
帮助页面的实现
帮助页面用 FAQ 形式展示常见问题:
class HelpScreen extends StatelessWidget {
const HelpScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('帮助')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildFaqItem(
'如何收藏动漫?',
'在动漫详情页面点击❤️图标即可收藏。收藏的动漫会保存在"我的收藏"中。',
),
每个问题用 _buildFaqItem 方法构建,传入问题和答案。这种方式让代码结构清晰,添加新问题也很方便。
_buildFaqItem(
'如何切换主题?',
'进入"我的" > "设置",选择浅色、深色或跟随系统主题。',
),
_buildFaqItem(
'应用支持离线使用吗?',
'不支持。应用需要网络连接才能获取动漫数据。',
),
],
),
);
}
FAQ 要覆盖用户最常问的问题,写的时候要站在用户角度思考。
可折叠的问答项
Widget _buildFaqItem(String question, String answer) {
return ExpansionTile(
title: Text(
question,
style: const TextStyle(fontWeight: FontWeight.w600),
),
ExpansionTile 是 Flutter 提供的可折叠列表项组件,默认只显示标题,点击后展开显示内容。
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
answer,
style: const TextStyle(height: 1.6),
),
),
],
);
}
答案放在 children 里,展开后才显示。height: 1.6 设置行高,让文字更易读。
这种设计让页面不会太长,用户可以快速浏览所有问题,找到自己关心的再展开查看。
小结
个人中心看起来简单,实际涉及的知识点不少:页面布局设计、Provider 数据监听、Consumer 和 Consumer2 的使用、主题切换和持久化、对话框交互、可折叠列表等等。
代码结构上,主页面负责展示和导航,子页面负责具体功能,Provider 负责数据管理。这种职责分离的架构便于后期扩展,比如要加新功能,只需要新建一个子页面,然后在主页面加个入口就行。
个人中心是用户使用频率很高的页面,值得花时间把它做好。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)