通过网盘分享的文件: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 只有一条记录,但用 removeWhereremove 更安全。

删除后重新保存整个列表到本地存储。


历史记录页面的实现

页面负责展示历史记录列表:

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 保持一致。这样 fromJsontoJson 可以互相转换,数据不会丢失。

注意 imagesgenres 这些嵌套结构,要按照 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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐