Flutter for OpenHarmony 微动漫App实战:历史记录实现
本文介绍了Flutter应用中历史记录功能的完整实现方案。该功能自动保存用户浏览的动漫记录,支持查看、删除单条记录和清空全部记录,并实现本地数据持久化。 核心实现包含: 存储服务:采用单例模式封装数据存取,支持字符串和列表的读写操作 状态管理:使用Provider管理历史记录状态,自动加载本地数据 业务逻辑:实现记录添加(含去重和50条限制)、删除和清空功能 数据持久化:通过JSON序列化存储动漫
通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97
用户浏览过的动漫,下次想再看的时候怎么找?靠记忆?那太难了。历史记录功能就是为了解决这个问题,自动记录用户浏览过的内容,方便随时回顾。
这篇文章会完整实现历史记录功能,包括自动记录、列表展示、单条删除、清空全部,以及数据的本地持久化。涉及到 Provider 状态管理、本地存储、JSON 序列化等知识点,代码都是项目里实际跑着的。
历史记录的设计思路
历史记录功能看起来简单,但要考虑的细节不少:
什么时候记录:用户进入详情页就记录,还是停留一段时间才记录?我们选择延迟 500 毫秒记录,避免误点进去马上退出的情况。
记录多少条:不能无限记录,会占用太多存储空间。我们限制最多 50 条,超出的自动删除最早的记录。
重复怎么处理:同一部动漫浏览多次,应该只保留一条记录,并且移到最前面。
数据怎么存储:用本地存储持久化,App 重启后历史记录还在。
想清楚这些,代码写起来就有方向了。
存储服务的实现
先看最底层的存储服务,它负责数据的读写:
class StorageService {
static final StorageService _instance = StorageService._();
static StorageService get instance => _instance;
StorageService._();
final Map<String, dynamic> _data = {};
用 单例模式 实现,整个 App 只有一个 StorageService 实例。_instance 是私有静态变量,instance 是公开的 getter,StorageService._() 是私有构造函数,外部无法直接 new。
_data 是一个 Map,用来存储所有数据。这是内存存储,App 关闭后数据会丢失。实际项目中可以换成 SharedPreferences 或其他持久化方案。
初始化方法
Future<void> init() async {
print('✅ StorageService: Initialized (memory mode)');
}
初始化方法目前只是打印一条日志。如果用 SharedPreferences,这里需要调用 SharedPreferences.getInstance()。保留这个方法是为了接口统一,方便以后切换存储方案。
字符串存取
Future<bool> setString(String key, String value) async {
_data[key] = value;
print('✅ Storage: Set "$key" = "$value"');
return true;
}
String? getString(String key) => _data[key] as String?;
setString 存储字符串,返回 Future 表示是否成功。虽然内存存储不会失败,但保持这个返回类型是为了和 SharedPreferences 接口一致。
getString 读取字符串,返回可空类型,key 不存在时返回 null。
字符串列表存取
历史记录是一个列表,需要用字符串列表来存储:
Future<bool> setStringList(String key, List<String> value) async {
_data[key] = value;
print('✅ Storage: Set "$key" with ${value.length} items');
return true;
}
List<String>? getStringList(String key) {
final value = _data[key];
if (value is List) {
return value.map((e) => e.toString()).toList();
}
return null;
}
setStringList 存储字符串列表,日志里打印了列表长度,方便调试。
getStringList 读取字符串列表,做了类型检查,如果存储的不是 List 就返回 null。map((e) => e.toString()) 确保每个元素都是字符串。
删除和清空
Future<bool> remove(String key) async {
_data.remove(key);
return true;
}
Future<bool> clear() async {
_data.clear();
return true;
}
remove 删除指定 key 的数据,clear 清空所有数据。这两个方法在清空历史记录时会用到。
HistoryProvider 的实现
Provider 负责管理历史记录的状态和业务逻辑:
import 'dart:convert';
import 'package:flutter/material.dart';
import '../models/anime.dart';
import '../services/storage_service.dart';
class HistoryProvider extends ChangeNotifier {
List<Anime> _history = [];
List<Anime> get history => _history;
继承 ChangeNotifier 让这个类具备通知监听者的能力。_history 是私有变量,通过 getter 暴露给外部,外部只能读不能直接修改。
构造函数中加载数据
HistoryProvider() {
_loadHistory();
}
构造函数里调用 _loadHistory,Provider 创建时自动从本地存储加载历史记录。这样 App 启动后,用户立即就能看到之前的浏览记录。
从本地存储加载历史
Future<void> _loadHistory() async {
try {
await StorageService.instance.init();
final data = StorageService.instance.getStringList('history') ?? [];
_history = data.map((e) => Anime.fromJson(json.decode(e))).toList();
notifyListeners();
} catch (e) {
print('Error loading history: $e');
}
}
先确保存储服务初始化完成,然后读取 history 这个 key 对应的字符串列表。
每个字符串是一个 JSON,用 json.decode 解析成 Map,再用 Anime.fromJson 转成 Anime 对象。
?? [] 处理 key 不存在的情况,返回空列表而不是 null。
加载完成后调用 notifyListeners 通知 UI 更新。
添加历史记录
这是最核心的方法,用户浏览动漫详情时调用:
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 条记录。sublist(0, 50) 取前 50 个元素,超出的自动丢弃。
这个数量可以根据实际需求调整,太多会占用存储空间,太少又不够用。50 条是个比较平衡的数字。
await StorageService.instance.setStringList(
'history',
_history.map((e) => json.encode(e.toJson())).toList(),
);
notifyListeners();
} catch (e) {
print('Error adding to history: $e');
}
}
把 Anime 对象列表转成 JSON 字符串列表存储。toJson 把对象转成 Map,json.encode 把 Map 转成字符串。
每次修改都同步到本地存储,保证数据不丢失。最后调用 notifyListeners 通知 UI 更新。
清空历史记录
Future<void> clearHistory() async {
try {
_history.clear();
await StorageService.instance.remove('history');
notifyListeners();
} catch (e) {
print('Error clearing history: $e');
}
}
清空内存中的列表,同时从本地存储删除 history 这个 key。用 remove 而不是存一个空列表,更干净。
删除单条记录
Future<void> removeFromHistory(int malId) async {
try {
_history.removeWhere((e) => e.malId == malId);
await StorageService.instance.setStringList(
'history',
_history.map((e) => json.encode(e.toJson())).toList(),
);
notifyListeners();
} catch (e) {
print('Error removing from history: $e');
}
}
根据动漫 ID 删除指定记录。removeWhere 会删除所有满足条件的元素,虽然理论上同一个 ID 只有一条记录,但用 removeWhere 比 remove 更安全。
删除后重新保存整个列表到本地存储。
历史记录页面的实现
页面负责展示历史记录列表:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/history_provider.dart';
import '../widgets/anime_list_tile.dart';
class HistoryScreen extends StatelessWidget {
const HistoryScreen({super.key});
用 StatelessWidget 是因为页面本身不管理状态,所有状态都在 Provider 里。页面只是"读取"Provider 的数据并显示出来。
AppBar 上的清空按钮
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('观看历史'),
actions: [
Consumer<HistoryProvider>(
builder: (context, provider, _) => provider.history.isNotEmpty
? IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () {
清空按钮用 Consumer 包裹,这样可以根据历史记录是否为空来决定是否显示按钮。provider.history.isNotEmpty 为 true 时显示按钮,为 false 时显示空的 SizedBox。
清空确认对话框
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('清空历史'),
content: const Text('确定要清空所有观看历史吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () {
provider.clearHistory();
Navigator.pop(context);
},
child: const Text('确定'),
),
],
),
);
清空是个危险操作,需要二次确认。AlertDialog 包含标题、内容和两个按钮。
点取消只是关闭对话框,点确定会调用 provider.clearHistory() 清空数据,然后关闭对话框。
注意这里可以直接调用 provider.clearHistory(),因为 provider 是从 Consumer 的 builder 参数拿到的,在这个作用域内可用。
页面主体内容
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),
),
],
),
);
}
用 Consumer 监听 HistoryProvider,历史记录变化时 UI 自动更新。
空状态显示一个大图标和提示文字,让用户知道这里会显示什么内容。
历史记录列表
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 展示。每个列表项是一个 AnimeListTile,传入 onDelete 回调支持滑动删除。
滑动删除时调用 provider.removeFromHistory,传入动漫 ID。删除后 Provider 会调用 notifyListeners,Consumer 会自动重建,列表会更新。
在详情页添加历史记录
历史记录是在详情页自动添加的,来看看怎么实现:
class _AnimeDetailScreenState extends State<AnimeDetailScreen> {
void initState() {
super.initState();
_anime = widget.anime;
_loadDetails();
// 添加到历史记录
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
context.read<HistoryProvider>().addToHistory(_anime);
}
});
}
在 initState 里延迟 500 毫秒添加历史记录。为什么要延迟?
避免误点:用户可能只是误点进来,马上就退出了,这种情况不应该算作"浏览过"。延迟一下,只有真正停留的才会记录。
mounted 检查:如果用户在 500 毫秒内退出了页面,这时候 context 已经不可用了,直接调用会报错。mounted 检查确保页面还在。
context.read<HistoryProvider>() 获取 Provider 实例,然后调用 addToHistory 添加记录。
JSON 序列化
历史记录存储需要把 Anime 对象转成 JSON 字符串,来看看 Anime 的 toJson 方法:
Map<String, dynamic> toJson() => {
'mal_id': malId,
'title': title,
'title_japanese': titleJapanese,
'images': {'jpg': {'large_image_url': imageUrl}},
'trailer': {'url': trailerUrl},
'synopsis': synopsis,
'score': score,
'episodes': episodes,
'status': status,
'rating': rating,
'rank': rank,
'popularity': popularity,
'genres': genres.map((g) => {'name': g}).toList(),
'season': season,
'year': year,
'type': type,
'source': source,
'duration': duration,
'studios': studios.map((s) => {'name': s}).toList(),
'aired': {'from': airedFrom, 'to': airedTo},
};
toJson 返回一个 Map,结构和 API 返回的 JSON 保持一致。这样 fromJson 和 toJson 可以互相转换,数据不会丢失。
注意 images 和 genres 这些嵌套结构,要按照 API 的格式来构造,否则 fromJson 解析时会出错。
关于数据持久化的思考
目前的 StorageService 是内存存储,App 关闭后数据会丢失。实际项目中需要用持久化方案:
SharedPreferences:最常用的方案,适合存储简单的键值对。Flutter 有官方插件,使用方便。
Hive:高性能的 NoSQL 数据库,适合存储复杂数据。比 SharedPreferences 快,但需要额外学习。
SQLite:关系型数据库,适合需要复杂查询的场景。历史记录这种简单列表用 SQLite 有点大材小用。
对于历史记录这种场景,SharedPreferences 就够用了。把 StorageService 里的内存存储换成 SharedPreferences,其他代码不用改。
性能优化
当历史记录很多时,每次修改都要序列化整个列表,可能会有性能问题。可以考虑以下优化:
增量更新:只存储变化的部分,而不是整个列表。但这样实现会复杂很多。
异步存储:把存储操作放到后台执行,不阻塞 UI。目前的实现已经是异步的,但 await 会等待存储完成。可以改成"fire and forget"模式,不等待结果。
批量操作:如果短时间内有多次修改,可以合并成一次存储。用防抖(debounce)技术实现。
对于 50 条记录的规模,这些优化其实不太必要。但如果要存储更多数据,就需要考虑了。
扩展功能:搜索历史记录
如果历史记录很多,用户可能需要搜索功能:
class HistoryProvider extends ChangeNotifier {
List<Anime> _history = [];
String _searchQuery = '';
List<Anime> get history => _searchQuery.isEmpty
? _history
: _history.where((anime) =>
anime.title.toLowerCase().contains(_searchQuery.toLowerCase())
).toList();
void setSearchQuery(String query) {
_searchQuery = query;
notifyListeners();
}
}
添加一个 _searchQuery 变量,history getter 根据搜索词过滤结果。toLowerCase 让搜索不区分大小写。
页面上加一个搜索框:
AppBar(
title: TextField(
decoration: const InputDecoration(
hintText: '搜索历史记录...',
border: InputBorder.none,
),
onChanged: (value) {
context.read<HistoryProvider>().setSearchQuery(value);
},
),
)
用户输入时实时过滤列表,体验很流畅。
扩展功能:按日期分组
历史记录可以按日期分组显示,比如"今天"、“昨天”、“更早”:
class Anime {
// ... 其他字段
final DateTime? viewedAt; // 添加浏览时间字段
}
Future<void> addToHistory(Anime anime) async {
final animeWithTime = Anime(
malId: anime.malId,
title: anime.title,
// ... 其他字段
viewedAt: DateTime.now(), // 记录浏览时间
);
// ... 其他逻辑
}
在 Anime 模型里添加 viewedAt 字段,添加历史记录时记录当前时间。
页面上可以用 grouped_list 包来实现分组显示,或者自己实现分组逻辑。
扩展功能:历史记录统计
可以在个人中心显示一些统计信息:
class HistoryProvider extends ChangeNotifier {
// ... 其他代码
int get totalCount => _history.length;
int get todayCount {
final today = DateTime.now();
return _history.where((anime) {
final viewedAt = anime.viewedAt;
if (viewedAt == null) return false;
return viewedAt.year == today.year &&
viewedAt.month == today.month &&
viewedAt.day == today.day;
}).length;
}
int get thisWeekCount {
final now = DateTime.now();
final weekAgo = now.subtract(const Duration(days: 7));
return _history.where((anime) {
final viewedAt = anime.viewedAt;
if (viewedAt == null) return false;
return viewedAt.isAfter(weekAgo);
}).length;
}
}
添加几个统计属性:总数、今天浏览数、本周浏览数。这些数据可以在个人中心的统计卡片里展示,让用户了解自己的使用情况。
小结
历史记录功能涉及的知识点:单例模式、本地存储、JSON 序列化、Provider 状态管理、Consumer 监听数据变化、AlertDialog 确认对话框、滑动删除、延迟执行和 mounted 检查。
这些技术点组合在一起,实现了一个完整的历史记录系统:自动记录、去重置顶、限制数量、持久化存储、单条删除、清空全部。
历史记录是提升用户体验的重要功能,让用户可以方便地回顾之前浏览过的内容。后续还可以扩展搜索、分组、统计等功能,让历史记录更加实用。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)