在这里插入图片描述

收藏功能是内容类应用的标配。用户看到喜欢的内容,点一下收藏,下次打开应用还能找到。这个看似简单的功能,背后涉及状态管理、数据持久化、列表操作等多个技术点。本文将从数据持久化的角度,深入讲解如何实现一个完整的收藏功能。

数据持久化的三种方案

在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();

顺序很重要:

  1. 先保存到本地
  2. 再通知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('确定'),
),

三步操作:

  1. 清空收藏
  2. 关闭对话框
  3. 显示提示

为什么要显示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开发资源,与其他开发者交流经验,共同进步。

Logo

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

更多推荐