Flutter for OpenHarmony:从零搭建今日资讯App(十二)收藏功能与数据持久化
本文介绍了Flutter中实现收藏功能的三种数据持久化方案,并详细解析了基于SharedPreferences的实现方法。文章首先对比了SharedPreferences、SQLite和Hive/ObjectBox三种方案的优缺点,根据收藏功能数据量小、结构简单的特点选择了SharedPreferences。然后通过Provider三层架构(UI层、状态层、持久化层)实现状态管理,最后给出了Fav

收藏功能是内容类应用的标配。用户看到喜欢的内容,点一下收藏,下次打开应用还能找到。这个看似简单的功能,背后涉及状态管理、数据持久化、列表操作等多个技术点。本文将从数据持久化的角度,深入讲解如何实现一个完整的收藏功能。
数据持久化的三种方案
在Flutter中,数据持久化有三种常见方案:
方案一:SharedPreferences - 轻量级键值存储
适用场景:
- 简单的配置信息(主题、语言等)
- 少量的用户数据(收藏列表、搜索历史等)
- 不超过几MB的数据
优点:
- 使用简单,API友好
- 跨平台支持好
- 读写速度快
缺点:
- 只能存储基本类型
- 不支持复杂查询
- 数据量大时性能下降
方案二:SQLite - 关系型数据库
适用场景:
- 大量结构化数据
- 需要复杂查询
- 数据关系复杂
优点:
- 支持SQL查询
- 性能好
- 功能强大
缺点:
- 使用复杂
- 需要写SQL
- 迁移麻烦
方案三:Hive/ObjectBox - NoSQL数据库
适用场景:
- 大量对象存储
- 需要高性能
- 不需要复杂查询
优点:
- 性能极好
- 使用简单
- 类型安全
缺点:
- 需要额外依赖
- 学习成本
- 生态不如前两者
我们的选择:SharedPreferences
收藏功能的特点:
- 数据量不大(一般不超过几百条)
- 数据结构简单(新闻列表)
- 不需要复杂查询
- 只需要增删改查
SharedPreferences完全满足需求,而且使用最简单。
Provider状态管理架构
收藏功能涉及多个页面:
- 首页:显示收藏按钮
- 详情页:显示收藏按钮
- 收藏页:显示收藏列表
这三个页面需要共享收藏状态,所以我们使用Provider进行状态管理。
Provider的三层架构:
收藏功能分为三个层次。UI层包括FavoritesScreen、NewsDetailScreen、NewsCard等页面和组件,只负责显示,不关心数据从哪来。状态层是FavoritesProvider,管理_favorites列表和toggleFavorite、clearFavorites等方法,负责数据和业务逻辑。持久化层使用SharedPreferences,负责存储收藏数据,key是’favorites’,value是JSON字符串列表。
这个架构的优点是层次清晰,职责分明。UI层通过Consumer或context.read访问状态层,状态层通过SharedPreferences访问持久化层。
FavoritesProvider的完整实现
让我们从Provider开始,这是收藏功能的核心:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/news_article.dart';
class FavoritesProvider extends ChangeNotifier {
List<NewsArticle> _favorites = [];
List<NewsArticle> get favorites => _favorites;
FavoritesProvider() {
_loadFavorites();
}
Future<void> _loadFavorites() async {
final prefs = await SharedPreferences.getInstance();
final favoritesJson = prefs.getStringList('favorites') ?? [];
_favorites = favoritesJson
.map((json) => NewsArticle.fromJson(jsonDecode(json)))
.toList();
notifyListeners();
}
Future<void> _saveFavorites() async {
final prefs = await SharedPreferences.getInstance();
final favoritesJson = _favorites
.map((article) => jsonEncode(article.toJson()))
.toList();
await prefs.setStringList('favorites', favoritesJson);
}
bool isFavorite(String articleId) {
return _favorites.any((article) => article.id == articleId);
}
Future<void> toggleFavorite(NewsArticle article) async {
if (isFavorite(article.id)) {
_favorites.removeWhere((a) => a.id == article.id);
} else {
_favorites.insert(0, article);
}
await _saveFavorites();
notifyListeners();
}
Future<void> clearFavorites() async {
_favorites.clear();
await _saveFavorites();
notifyListeners();
}
}
代码解析:
1. ChangeNotifier是什么?
class FavoritesProvider extends ChangeNotifier {
ChangeNotifier是Flutter提供的状态管理基类:
- 继承它的类可以通知监听者
- 调用
notifyListeners()时,所有监听的Widget会重建 - 这是Provider模式的核心机制
为什么不用StatefulWidget?
StatefulWidget的状态是局部的:
- 只能在一个Widget中使用
- 无法跨页面共享
- 数据传递麻烦
ChangeNotifier的状态是全局的:
- 可以在任何地方访问
- 自动同步到所有监听者
- 数据传递简单
2. 私有变量与公开getter
List<NewsArticle> _favorites = [];
List<NewsArticle> get favorites => _favorites;
这是一个重要的设计模式:
_favorites是私有的,外部不能直接修改favorites是公开的getter,外部只能读取- 所有修改必须通过Provider的方法
为什么这样设计?
防止外部直接修改数据:
// 错误:外部直接修改
provider._favorites.add(article); // 编译错误,_favorites是私有的
// 正确:通过方法修改
provider.toggleFavorite(article); // 会调用notifyListeners
如果允许外部直接修改:
- 可能忘记调用notifyListeners
- UI不会更新
- 数据不会保存
3. 构造函数中加载数据
FavoritesProvider() {
_loadFavorites();
}
Provider创建时自动加载收藏数据:
- 应用启动时创建Provider
- 立即从本地加载数据
- 用户打开收藏页时数据已经准备好
为什么不在initState中加载?
Provider不是Widget,没有initState:
- Provider在main.dart中创建
- 只创建一次
- 构造函数就是初始化的地方
数据加载的实现
_loadFavorites方法从本地加载收藏数据:
Future<void> _loadFavorites() async {
final prefs = await SharedPreferences.getInstance();
final favoritesJson = prefs.getStringList('favorites') ?? [];
_favorites = favoritesJson
.map((json) => NewsArticle.fromJson(jsonDecode(json)))
.toList();
notifyListeners();
}
代码解析:
1. SharedPreferences.getInstance()
final prefs = await SharedPreferences.getInstance();
获取SharedPreferences实例:
- 这是一个异步操作,需要await
- 返回的是单例,多次调用返回同一个实例
- 第一次调用会初始化,后续调用很快
2. getStringList方法
final favoritesJson = prefs.getStringList('favorites') ?? [];
从本地读取字符串列表:
- key是’favorites’
- 返回
List<String>?,可能为null - 使用
??提供默认值空列表
为什么存储List而不是List?
SharedPreferences只支持基本类型:
- bool
- int
- double
- String
- List
不支持自定义对象,所以需要序列化:
- NewsArticle → JSON字符串 → 存储
- 读取 → JSON字符串 → NewsArticle
3. map转换
_favorites = favoritesJson
.map((json) => NewsArticle.fromJson(jsonDecode(json)))
.toList();
将JSON字符串列表转换为对象列表:
步骤分解:
// 1. favoritesJson是List<String>
['{"id":"1","title":"新闻1"}', '{"id":"2","title":"新闻2"}']
// 2. map遍历每个字符串
.map((json) => ...)
// 3. jsonDecode将字符串解析为Map
jsonDecode(json) // Map<String, dynamic>
// 4. fromJson将Map转换为对象
NewsArticle.fromJson(...) // NewsArticle
// 5. toList转换为列表
.toList() // List<NewsArticle>
4. notifyListeners()
notifyListeners();
通知所有监听者数据已更新:
- Consumer会重建
- UI会显示最新数据
为什么加载后要通知?
虽然这时可能还没有监听者,但:
- 确保数据一致性
- 如果有监听者会立即更新
- 养成好习惯,所有数据变化都通知
数据保存的实现
_saveFavorites方法将收藏数据保存到本地:
Future<void> _saveFavorites() async {
final prefs = await SharedPreferences.getInstance();
final favoritesJson = _favorites
.map((article) => jsonEncode(article.toJson()))
.toList();
await prefs.setStringList('favorites', favoritesJson);
}
代码解析:
1. 序列化过程
final favoritesJson = _favorites
.map((article) => jsonEncode(article.toJson()))
.toList();
将对象列表转换为JSON字符串列表:
步骤分解:
// 1. _favorites是List<NewsArticle>
[NewsArticle(...), NewsArticle(...)]
// 2. map遍历每个对象
.map((article) => ...)
// 3. toJson将对象转换为Map
article.toJson() // Map<String, dynamic>
// 4. jsonEncode将Map转换为字符串
jsonEncode(...) // String
// 5. toList转换为列表
.toList() // List<String>
2. setStringList保存
await prefs.setStringList('favorites', favoritesJson);
保存到本地:
- key是’favorites’
- value是字符串列表
- 异步操作,需要await
为什么要await?
确保数据保存成功:
- 如果不await,可能还没保存完就返回了
- 应用崩溃或关闭时数据会丢失
- await确保写入完成
3. 保存时机
注意_saveFavorites是私有方法:
- 不能从外部调用
- 只在Provider内部调用
- 每次修改数据后自动保存
这样设计的好处:
- 外部不需要关心保存逻辑
- 不会忘记保存
- 数据一致性有保证
判断是否收藏
isFavorite方法判断某篇新闻是否已收藏:
bool isFavorite(String articleId) {
return _favorites.any((article) => article.id == articleId);
}
代码解析:
1. any方法
_favorites.any((article) => article.id == articleId)
any是List的方法,判断是否存在满足条件的元素:
- 遍历列表
- 对每个元素执行判断函数
- 只要有一个返回true,就返回true
- 全部返回false,才返回false
为什么不用contains?
// 错误:contains比较对象引用
_favorites.contains(article) // 总是false
// 正确:any比较id
_favorites.any((a) => a.id == articleId) // 正确
contains比较的是对象引用:
- 即使id相同,对象不同
- 会返回false
any比较的是id:
- 只要id相同就返回true
- 这才是我们想要的
2. 性能考虑
any的时间复杂度是O(n):
- 最坏情况遍历整个列表
- 收藏数量不多时没问题
- 如果收藏很多(几千条),可以优化
优化方案:使用Set
class FavoritesProvider extends ChangeNotifier {
List<NewsArticle> _favorites = [];
Set<String> _favoriteIds = {}; // 新增
bool isFavorite(String articleId) {
return _favoriteIds.contains(articleId); // O(1)
}
Future<void> toggleFavorite(NewsArticle article) async {
if (isFavorite(article.id)) {
_favorites.removeWhere((a) => a.id == article.id);
_favoriteIds.remove(article.id); // 同步更新
} else {
_favorites.insert(0, article);
_favoriteIds.add(article.id); // 同步更新
}
await _saveFavorites();
notifyListeners();
}
}
Set的contains是O(1),比List快很多。
切换收藏状态
toggleFavorite是最核心的方法:
Future<void> toggleFavorite(NewsArticle article) async {
if (isFavorite(article.id)) {
_favorites.removeWhere((a) => a.id == article.id);
} else {
_favorites.insert(0, article);
}
await _saveFavorites();
notifyListeners();
}
代码解析:
1. toggle的含义
toggle是"切换"的意思:
- 已收藏 → 取消收藏
- 未收藏 → 添加收藏
一个方法处理两种情况,简化调用:
// 不需要判断,直接调用
provider.toggleFavorite(article);
2. removeWhere删除
_favorites.removeWhere((a) => a.id == article.id);
removeWhere删除满足条件的所有元素:
- 遍历列表
- 对每个元素执行判断函数
- 返回true的元素会被删除
为什么不用remove?
// 错误:remove比较对象引用
_favorites.remove(article); // 可能删除失败
// 正确:removeWhere比较id
_favorites.removeWhere((a) => a.id == article.id); // 一定成功
和contains一样,remove也是比较对象引用。
3. insert(0, article)的妙用
_favorites.insert(0, article);
在列表开头插入:
- index 0是第一个位置
- 新收藏的排在最前面
- 符合用户习惯
为什么不用add?
_favorites.add(article); // 添加到末尾
add添加到末尾:
- 新收藏的在最后
- 用户要滚动到底部才能看到
- 体验不好
insert(0)添加到开头:
- 新收藏的在最前面
- 用户立即看到
- 体验好
4. 保存和通知
await _saveFavorites();
notifyListeners();
顺序很重要:
- 先保存到本地
- 再通知UI更新
如果顺序反了:
- UI先更新,显示新状态
- 保存失败,数据丢失
- 下次打开应用,状态不对
先保存再通知:
- 保存成功,数据安全
- 再更新UI
- 即使UI更新失败,数据也不会丢
清空收藏
clearFavorites方法清空所有收藏:
Future<void> clearFavorites() async {
_favorites.clear();
await _saveFavorites();
notifyListeners();
}
代码解析:
1. clear方法
_favorites.clear();
清空列表:
- 删除所有元素
- 列表变为空
- 简单直接
2. 为什么要保存?
await _saveFavorites();
清空后必须保存:
- 否则只是内存中清空
- 下次打开应用,数据还在
- 用户会困惑
3. 为什么要通知?
notifyListeners();
通知UI更新:
- 收藏页显示空状态
- 收藏按钮变为未收藏
- 收藏数量更新
收藏页面的实现
有了Provider,页面实现就很简单了:
class FavoritesScreen extends StatelessWidget {
const FavoritesScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('我的收藏'),
actions: [
Consumer<FavoritesProvider>(
builder: (context, favProvider, child) {
if (favProvider.favorites.isEmpty) return const SizedBox();
return IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () {
_showClearDialog(context, favProvider);
},
);
},
),
],
),
body: Consumer<FavoritesProvider>(
builder: (context, favProvider, child) {
if (favProvider.favorites.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.favorite_outline,
size: 80,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'还没有收藏任何内容',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'快去发现喜欢的新闻吧!',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: favProvider.favorites.length,
itemBuilder: (context, index) {
return NewsCard(article: favProvider.favorites[index]);
},
);
},
),
);
}
}
代码解析:
1. 为什么用StatelessWidget?
class FavoritesScreen extends StatelessWidget {
收藏页面不需要管理自己的状态:
- 数据由Provider管理
- 使用Consumer监听变化
- 页面本身无状态
这是Provider模式的标准用法。
2. AppBar中的清空按钮
actions: [
Consumer<FavoritesProvider>(
builder: (context, favProvider, child) {
if (favProvider.favorites.isEmpty) return const SizedBox();
return IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () {
_showClearDialog(context, favProvider);
},
);
},
),
],
为什么用Consumer?
需要根据收藏数量决定是否显示按钮:
- 有收藏:显示清空按钮
- 无收藏:不显示按钮
Consumer监听Provider变化:
- 收藏数量变化时自动重建
- 按钮显示/隐藏自动切换
为什么返回SizedBox?
if (favProvider.favorites.isEmpty) return const SizedBox();
不能返回null:
- actions要求返回Widget
- null会报错
返回SizedBox:
- 空Widget,不占空间
- 不显示任何内容
- 相当于不显示按钮
为什么用delete_outline图标?
delete_outline是空心删除图标- 比实心的
delete更柔和 - 不会让用户觉得太激进
3. body中的Consumer
body: Consumer<FavoritesProvider>(
builder: (context, favProvider, child) {
// ...
},
),
整个body用Consumer包裹:
- 收藏列表变化时重建
- 空状态和列表自动切换
Consumer的三个参数:
builder: (context, favProvider, child) {
context- BuildContext,用于导航等favProvider- Provider实例,访问数据和方法child- 不变的子Widget,优化性能(这里没用到)
4. 空状态的设计
if (favProvider.favorites.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.favorite_outline,
size: 80,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'还没有收藏任何内容',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'快去发现喜欢的新闻吧!',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
);
}
空状态设计的要点:
大图标:
- size: 80,视觉冲击力
- favorite_outline,和收藏主题一致
- 灰色,表示空状态
两行文字:
- 第一行:说明当前状态
- 第二行:引导用户操作
颜色层次:
- 图标:Colors.grey[400]
- 主文字:Colors.grey[600],更深
- 副文字:Colors.grey[500],居中
文案设计:
- “还没有收藏任何内容” - 说明状态
- “快去发现喜欢的新闻吧!” - 引导操作
不要用"暂无数据"这种冷冰冰的文案。
5. 列表的实现
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: favProvider.favorites.length,
itemBuilder: (context, index) {
return NewsCard(article: favProvider.favorites[index]);
},
);
复用NewsCard组件:
- 保持视觉一致性
- 减少代码重复
- 便于维护
直接访问favProvider.favorites:
- Consumer已经监听了变化
- 数据更新时自动重建
- 不需要额外处理
清空确认对话框
清空是危险操作,需要二次确认:
void _showClearDialog(BuildContext context, FavoritesProvider provider) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('清空收藏'),
content: const Text('确定要清空所有收藏吗?此操作不可恢复。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () {
provider.clearFavorites();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已清空收藏')),
);
},
child: const Text('确定'),
),
],
),
);
}
代码解析:
1. showDialog方法
showDialog(
context: context,
builder: (context) => AlertDialog(...),
)
显示对话框:
- 返回Future,可以await等待结果
- 点击外部或返回键会关闭
- builder返回对话框Widget
2. AlertDialog结构
AlertDialog(
title: const Text('清空收藏'),
content: const Text('确定要清空所有收藏吗?此操作不可恢复。'),
actions: [...],
)
标准的对话框结构:
title- 标题content- 内容actions- 按钮列表
3. 文案设计
title: const Text('清空收藏'),
content: const Text('确定要清空所有收藏吗?此操作不可恢复。'),
文案要点:
- 标题简洁明了
- 内容说明后果:“此操作不可恢复”
- 让用户知道这是危险操作
4. 按钮顺序
actions: [
TextButton(..., child: const Text('取消')),
TextButton(..., child: const Text('确定')),
],
按钮顺序有讲究:
- 取消在左,确定在右
- 符合用户习惯
- 减少误操作
为什么不用ElevatedButton?
对话框中通常用TextButton:
- 更轻量
- 不抢眼
- 符合Material Design规范
5. 取消按钮
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
只关闭对话框,不做其他操作。
6. 确定按钮
TextButton(
onPressed: () {
provider.clearFavorites();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已清空收藏')),
);
},
child: const Text('确定'),
),
三步操作:
- 清空收藏
- 关闭对话框
- 显示提示
为什么要显示SnackBar?
给用户反馈:
- 操作成功
- 不会让用户困惑
- 提升体验
在其他页面使用收藏功能
收藏功能不只在收藏页使用,详情页也需要:
// 在详情页的AppBar中
Consumer<FavoritesProvider>(
builder: (context, favProvider, child) {
final isFavorite = favProvider.isFavorite(article.id);
return IconButton(
icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_outline,
color: isFavorite ? Colors.red : null,
),
onPressed: () {
favProvider.toggleFavorite(article);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isFavorite ? '已取消收藏' : '已添加到收藏'),
duration: const Duration(seconds: 1),
),
);
},
);
},
)
代码解析:
1. 使用Consumer
只在按钮处使用Consumer:
- 收藏状态变化时只重建按钮
- 不重建整个页面
- 性能更好
2. 判断收藏状态
final isFavorite = favProvider.isFavorite(article.id);
根据id判断是否已收藏:
- 已收藏:显示实心红心
- 未收藏:显示空心灰心
3. 图标和颜色
icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_outline,
color: isFavorite ? Colors.red : null,
),
视觉反馈很重要:
- 实心 vs 空心
- 红色 vs 默认色
- 一眼就能看出状态
4. 点击切换
onPressed: () {
favProvider.toggleFavorite(article);
// ...
},
直接调用toggleFavorite:
- 不需要判断当前状态
- Provider内部会处理
- 简化调用代码
5. 提示文案
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isFavorite ? '已取消收藏' : '已添加到收藏'),
duration: const Duration(seconds: 1),
),
);
根据操作显示不同文案:
- 取消收藏:“已取消收藏”
- 添加收藏:“已添加到收藏”
让用户知道发生了什么。
SharedPreferences的深入理解
SharedPreferences是收藏功能的基础,我们深入了解一下:
1. 存储位置
不同平台存储位置不同:
Android:
/data/data/<package_name>/shared_prefs/<package_name>_preferences.xml
iOS:
~/Library/Preferences/<bundle_id>.plist
Web:
LocalStorage
2. 存储格式
Android的XML格式:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="favorites">["json1","json2"]</string>
</map>
3. 存储限制
SharedPreferences有大小限制:
- Android:没有明确限制,但建议不超过1MB
- iOS:没有明确限制,但建议不超过1MB
- Web:LocalStorage限制5-10MB
4. 性能特点
读取性能:
- 第一次读取:从磁盘加载,较慢
- 后续读取:从内存读取,很快
写入性能:
- 异步写入,不阻塞UI
- 批量写入比单个写入快
5. 线程安全
SharedPreferences是线程安全的:
- 可以在任何线程读写
- 内部有锁机制
- 不会出现数据竞争
6. 数据同步
写入是异步的:
setXxx方法立即返回- 实际写入在后台进行
- 可能有延迟
如果需要立即写入:
await prefs.setStringList('favorites', favoritesJson);
使用await确保写入完成。
数据迁移与版本管理
随着应用更新,数据结构可能变化,需要考虑迁移:
场景1:添加新字段
旧版本:
class NewsArticle {
final String id;
final String title;
}
新版本:
class NewsArticle {
final String id;
final String title;
final String? category; // 新增
}
fromJson需要兼容:
factory NewsArticle.fromJson(Map<String, dynamic> json) {
return NewsArticle(
id: json['id'],
title: json['title'],
category: json['category'], // 可能为null
);
}
场景2:修改字段类型
旧版本:
final String publishedAt; // 字符串
新版本:
final DateTime publishedAt; // 日期对象
需要迁移:
Future<void> _migrateData() async {
final prefs = await SharedPreferences.getInstance();
final version = prefs.getInt('data_version') ?? 1;
if (version < 2) {
// 迁移到版本2
final oldData = prefs.getStringList('favorites') ?? [];
final newData = oldData.map((json) {
final map = jsonDecode(json);
// 转换publishedAt格式
map['publishedAt'] = DateTime.parse(map['publishedAt']).toIso8601String();
return jsonEncode(map);
}).toList();
await prefs.setStringList('favorites', newData);
await prefs.setInt('data_version', 2);
}
}
场景3:更换存储方案
从SharedPreferences迁移到SQLite:
Future<void> _migrateToSQLite() async {
final prefs = await SharedPreferences.getInstance();
final migrated = prefs.getBool('migrated_to_sqlite') ?? false;
if (!migrated) {
// 读取旧数据
final oldData = prefs.getStringList('favorites') ?? [];
final articles = oldData
.map((json) => NewsArticle.fromJson(jsonDecode(json)))
.toList();
// 写入SQLite
final db = await DatabaseHelper.instance.database;
for (final article in articles) {
await db.insert('favorites', article.toJson());
}
// 标记已迁移
await prefs.setBool('migrated_to_sqlite', true);
// 删除旧数据
await prefs.remove('favorites');
}
}
性能优化
收藏功能涉及频繁的读写操作,需要优化性能:
1. 延迟加载
不要在构造函数中加载:
class FavoritesProvider extends ChangeNotifier {
List<NewsArticle> _favorites = [];
bool _isLoaded = false;
Future<void> ensureLoaded() async {
if (_isLoaded) return;
await _loadFavorites();
_isLoaded = true;
}
}
在需要时才加载:
// 在收藏页的initState中
void initState() {
super.initState();
context.read<FavoritesProvider>().ensureLoaded();
}
2. 批量操作
如果需要批量添加收藏:
Future<void> addMultipleFavorites(List<NewsArticle> articles) async {
_favorites.addAll(articles);
await _saveFavorites(); // 只保存一次
notifyListeners(); // 只通知一次
}
不要循环调用toggleFavorite:
// 错误:每次都保存和通知
for (final article in articles) {
await toggleFavorite(article); // 慢
}
// 正确:批量操作
await addMultipleFavorites(articles); // 快
3. 防抖动
如果用户快速点击收藏按钮:
Timer? _saveTimer;
Future<void> toggleFavorite(NewsArticle article) async {
// 立即更新UI
if (isFavorite(article.id)) {
_favorites.removeWhere((a) => a.id == article.id);
} else {
_favorites.insert(0, article);
}
notifyListeners();
// 延迟保存
_saveTimer?.cancel();
_saveTimer = Timer(const Duration(milliseconds: 500), () {
_saveFavorites();
});
}
UI立即响应,保存延迟执行:
- 用户体验好
- 减少写入次数
- 提升性能
4. 分页加载
如果收藏很多,可以分页加载:
class FavoritesProvider extends ChangeNotifier {
List<NewsArticle> _allFavorites = [];
List<NewsArticle> _displayedFavorites = [];
int _page = 0;
final int _pageSize = 20;
void loadMore() {
final start = _page * _pageSize;
final end = start + _pageSize;
if (start < _allFavorites.length) {
_displayedFavorites.addAll(
_allFavorites.sublist(start, min(end, _allFavorites.length))
);
_page++;
notifyListeners();
}
}
}
错误处理
数据持久化可能失败,需要处理错误:
1. 读取失败
Future<void> _loadFavorites() async {
try {
final prefs = await SharedPreferences.getInstance();
final favoritesJson = prefs.getStringList('favorites') ?? [];
_favorites = favoritesJson
.map((json) => NewsArticle.fromJson(jsonDecode(json)))
.toList();
} catch (e) {
print('加载收藏失败: $e');
_favorites = []; // 使用空列表
}
notifyListeners();
}
2. 保存失败
Future<void> _saveFavorites() async {
try {
final prefs = await SharedPreferences.getInstance();
final favoritesJson = _favorites
.map((article) => jsonEncode(article.toJson()))
.toList();
await prefs.setStringList('favorites', favoritesJson);
} catch (e) {
print('保存收藏失败: $e');
// 可以显示错误提示
// 或者重试
}
}
3. JSON解析失败
Future<void> _loadFavorites() async {
final prefs = await SharedPreferences.getInstance();
final favoritesJson = prefs.getStringList('favorites') ?? [];
_favorites = favoritesJson
.map((json) {
try {
return NewsArticle.fromJson(jsonDecode(json));
} catch (e) {
print('解析收藏失败: $json, $e');
return null;
}
})
.whereType<NewsArticle>() // 过滤掉null
.toList();
notifyListeners();
}
测试收藏功能
收藏功能需要测试:
1. 单元测试Provider
void main() {
test('toggleFavorite adds article', () async {
final provider = FavoritesProvider();
final article = NewsArticle(id: '1', title: 'Test');
await provider.toggleFavorite(article);
expect(provider.favorites.length, 1);
expect(provider.isFavorite('1'), true);
});
test('toggleFavorite removes article', () async {
final provider = FavoritesProvider();
final article = NewsArticle(id: '1', title: 'Test');
await provider.toggleFavorite(article);
await provider.toggleFavorite(article);
expect(provider.favorites.length, 0);
expect(provider.isFavorite('1'), false);
});
}
2. Widget测试
void main() {
testWidgets('shows empty state', (tester) async {
await tester.pumpWidget(
ChangeNotifierProvider(
create: (_) => FavoritesProvider(),
child: MaterialApp(home: FavoritesScreen()),
),
);
expect(find.text('还没有收藏任何内容'), findsOneWidget);
});
}
3. 集成测试
void main() {
testWidgets('favorite flow', (tester) async {
// 1. 打开详情页
await tester.tap(find.byType(NewsCard).first);
await tester.pumpAndSettle();
// 2. 点击收藏
await tester.tap(find.byIcon(Icons.favorite_outline));
await tester.pumpAndSettle();
// 3. 打开收藏页
await tester.tap(find.byIcon(Icons.favorite));
await tester.pumpAndSettle();
// 4. 验证收藏列表
expect(find.byType(NewsCard), findsOneWidget);
});
}
常见问题
1. 收藏状态不同步
可能原因:
- 没有使用Consumer
- Provider没有notifyListeners
解决方案:
- 在需要更新的地方使用Consumer
- 确保每次修改都调用notifyListeners
2. 数据丢失
可能原因:
- 没有await保存操作
- 应用崩溃时数据未保存
解决方案:
- 使用await确保保存完成
- 考虑使用防抖动延迟保存
3. 性能问题
可能原因:
- 收藏数量太多
- 频繁读写
解决方案:
- 使用分页加载
- 使用防抖动减少写入
- 考虑迁移到SQLite
4. JSON解析失败
可能原因:
- 数据格式变化
- 数据损坏
解决方案:
- 添加try-catch
- 过滤解析失败的数据
- 做好数据迁移
扩展功能
1. 收藏分组
按分类分组收藏:
class FavoritesProvider extends ChangeNotifier {
Map<String, List<NewsArticle>> _favoritesByCategory = {};
List<NewsArticle> getFavoritesByCategory(String category) {
return _favoritesByCategory[category] ?? [];
}
List<String> get categories {
return _favoritesByCategory.keys.toList();
}
}
2. 收藏排序
支持多种排序方式:
enum SortType { time, title, source }
class FavoritesProvider extends ChangeNotifier {
SortType _sortType = SortType.time;
List<NewsArticle> get favorites {
final list = List<NewsArticle>.from(_favorites);
switch (_sortType) {
case SortType.time:
list.sort((a, b) => b.publishedAt.compareTo(a.publishedAt));
break;
case SortType.title:
list.sort((a, b) => a.title.compareTo(b.title));
break;
case SortType.source:
list.sort((a, b) => a.source.compareTo(b.source));
break;
}
return list;
}
void setSortType(SortType type) {
_sortType = type;
notifyListeners();
}
}
3. 收藏导出
导出收藏为文件:
Future<void> exportFavorites() async {
final json = jsonEncode(_favorites.map((a) => a.toJson()).toList());
final file = File('${directory.path}/favorites.json');
await file.writeAsString(json);
}
4. 收藏同步
同步到云端:
Future<void> syncFavorites() async {
// 上传到服务器
await api.uploadFavorites(_favorites);
// 下载服务器数据
final serverFavorites = await api.downloadFavorites();
// 合并数据
_mergeFavorites(serverFavorites);
}
最佳实践总结
通过这篇文章,我们学到了实现收藏功能的最佳实践:
数据持久化:
- 选择合适的存储方案
- SharedPreferences适合轻量级数据
- 做好序列化和反序列化
- 处理读写错误
状态管理:
- 使用Provider管理全局状态
- 私有变量+公开getter保护数据
- 每次修改都通知监听者
- 自动保存数据
UI设计:
- 友好的空状态
- 清晰的视觉反馈
- 危险操作二次确认
- 及时的操作提示
性能优化:
- 延迟加载
- 批量操作
- 防抖动
- 分页加载
这些实践不仅适用于收藏功能,也适用于所有需要数据持久化的场景。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
在这里你可以找到更多Flutter开发资源,与其他开发者交流经验,共同进步。
更多推荐

所有评论(0)