做 Flutter 适配 OpenHarmony 的这段时间,模拟项目X已经连载到了第十二篇,这一期的主题是收藏功能与数据持久化。前几篇把资讯列表、详情页、网络请求全链路打通之后,整个项目其实已经具备完整的阅读闭环,但一直有个体验漏洞:用户读到一篇有价值的资讯,想存下来回头再看,却发现没有任何入口能留下痕迹。对一个资讯类App来说,收藏缺失属于硬伤。
所以这一篇,我会完整记录在模拟项目X上实现收藏功能的全过程。重点不只是把按钮做出来,而是把"持久化"这三个字真正落地:状态跨页面同步、数据落盘、重启后恢复,每一步都有不少讲究。我会按真实的开发顺序来写,先说需求拆解和方案选型,再讲UI交互与状态联动,最后是OpenHarmony平台下的持久化实现与踩坑复盘。正在做Flutter应用迁移,或者自己项目里需要收藏功能的同学,这份实操记录可以直接照做。
1. 收藏功能的设计定位与数据选型
收藏按钮看起来只是"点一下变红",但把它放在一个完整的资讯App里,牵扯到的东西远比想象中多。如果不在动手前把需求和边界想清楚,百分之百会在开发中期开始打补丁。
1.1 核心需求拆解:持久化是底线不是加分项
我梳理了模拟项目X在收藏功能上必须满足的四件事。第一,详情页有收藏入口,点击后有即时视觉反馈。第二,资讯列表页的每条数据能体现收藏状态,让用户不用进详情页就知道这篇是不是存过。第三,提供一个独立的收藏列表页,聚合展示所有已收藏文章,支持移除。第四,也是最重要的,收藏数据必须持久化,杀掉进程重启之后状态还能完整恢复。
第四点最容易被新手忽略,但它才是收藏功能的灵魂。很多人把收藏做成内存里的一个HashSet,点一下亮一下,App一重启全没了。用户隔天打开App发现收藏不翼而飞,这个功能还不如不做。所以我把持久化定义为底线需求,而不是加分项。
在设计阶段我还做了一个明确的取舍:收藏列表展示的是本地缓存的完整资讯数据,而不是只存文章ID、进入页面时再通过网络重新拉取。两个理由。第一,用户收藏的核心诉求是"回头还能看",如果资讯源已经下架、或者网络处于不稳定状态,只存ID的收藏等于一条死链。第二,直接从本地渲染数据,收藏列表页的打开速度远快于重新请求网络,对用户体感的影响是决定性的。
这个决策直接决定了存储层必须保存两类信息:已收藏文章的ID集合,以及文章的快照数据(标题、摘要、来源、发布时间、图片地址等)。想清楚了这一点,方案选型就有的放矢了。
1.2 持久化方案横评:为什么我最终选择JSON落盘
Flutter生态里做本地持久化的方案不少,我在这次开发前把主流选项都过了一遍,并且逐个在OpenHarmony的适配环境下做了验证。这里直接给大家一份横评表。
| 方案 | 存储形态 | 适合场景 | OpenHarmony适配成本 | 我的评估 |
|---|---|---|---|---|
| shared_preferences | 键值对,本质是配置文件 | 小体量偏好设置、状态标记 | 适配环境已内置支持,成本低 | 首选 |
| 文件存储(配合路径API) | 自定义格式文件 | 有特定格式要求的落盘 | 路径API需要验证,成本中等 | 备选 |
| SQLite数据库(扩展包) | 关系型表结构 | 数据量大、查询条件复杂 | 需要引入原生实现,成本较高 | 阶段二升级 |
| 内存缓存方案 | 仅运行期存活 | 临时状态 | 无成本 | 只做辅助 |
先说结论。我在这一期选择的是用shared_preferences做键值存储,把收藏列表整体序列化成JSON字符串后写入。对于模拟项目X当前阶段的收藏规模——单个用户收藏几十到几百条资讯——这个方案完全够用,而且shared_preferences在OpenHarmony的Flutter适配环境里支持成熟,不需要额外处理原生桥接。
为什么不直接上SQLite?很多人会觉得数据库更"正规"。但项目开发的铁律是"在合适的规模选合适的方案"。SQLite的引入意味着要处理建表、升级、迁移、事务,以及OpenHarmony环境下原生插件的编译适配,对一个收藏量可能在几百条以内的功能来说,这个成本是超配的。开发效率、可维护性、稳定性三者综合下来,JSON落盘是当前阶段的最优解。
当然,如果你的应用面向的是重度用户,收藏量可能到几千上万条,那SQLite的升级路径是必须提前考虑的。我在文末单独留了一节讲怎么平滑迁移,这里不展开。
1.3 数据模型设计:收藏记录背后的字段取舍
持久化方案的载体是数据模型。我沿用模拟项目X之前定义的文章数据模型,额外为收藏场景设计了一个收藏记录模型。字段上保留了文章模型的核心信息,同时增加了收藏时间戳。
收藏时间戳这个字段是我特别坚持的。很多实现只存文章原始数据,忽略了收藏时间,导致收藏列表页只能按文章发布时间排序。用户收藏的是"当时觉得有用的东西",按收藏时间倒序展示才符合心智模型。实测下来,加了这一个字段,收藏列表的用户体验提升非常明显。
模型的序列化方法要写成稳定的形式。字段的增加是必然的,但序列化时不能因为模型扩展了就破坏老数据的可读性。我一般会写一个兼容旧字段的fromJson方法,对缺失字段给默认值,避免老版本数据在升级后直接崩溃。这一点在持久化功能里尤其重要,因为落盘的数据生命周期比App版本长得多。
2. 收藏按钮与跨页面状态同步
存储方案定下来之后,真正的开发从UI交互开始。这一章讲清楚详情页按钮、列表页标识和跨页面状态同步三条线的实现。
2.1 详情页收藏按钮的交互实现
详情页的收藏入口我放在了AppBar的右上角,使用图标按钮。实现上有两个核心点:状态的即时切换,以及切换过程的可感知性。
状态切换用图标切换来实现。未收藏时显示空心图标,收藏后变为实心图标并填充主题色。在Flutter里我用AnimatedSwitcher包了一层,切换时加一个缩小放大动画。动画时长控制在200毫秒左右,太快了用户感知不到反馈,太慢了又显得卡顿,这个区间是实测下来最舒服的。
按钮的点击处理需要防抖。收藏操作本身是异步写盘,连续快速点击会导致写入竞态。我在点击回调里加了一个基于标志位的互斥判断,写入未完成前忽略新的点击。这个小细节如果不处理,快速连点两次会出现收藏状态与图标显示不一致的诡异问题。
代码上核心逻辑是这样组织的:
Future<void> _toggleFavorite() async { if (_busy) return; _busy = true; try { final shouldFavorite = !FavoritesStorage.instance.isFavorite(widget.article.id); if (shouldFavorite) { await FavoritesStorage.instance.add(widget.article); } else { await FavoritesStorage.instance.remove(widget.article.id); } } finally { _busy = false; } }这里我特意让UI状态的更新依赖收藏服务的通知机制,而不是点击瞬间就改UI。原因是如果先改UI、写盘失败,用户界面上显示已收藏,但重启后收藏丢失,这种隐性不一致是最难排查的。
2.2 列表页收藏标识:让用户一眼识别
资讯列表页我加了一个很小的收藏标识。设计上不想喧宾夺主,所以用了竖排的图标加一个轻量底色,放在卡片右下角。这种低调但可识别的方案,比单独显示一段"已收藏"文字要清爽得多。
列表页渲染时,需要判断当前文章是否在收藏集合中。这里有个性能细节:不要在每个列表项build的时候都查一次存储。我在内存里维护了一份已经加载的收藏ID集合,列表页只查这个集合。得益于内存HashSet的O(1)查询,列表滚动时几乎无额外开销。
集合的更新时机要严格把控。详情页收藏成功后,会同步通知列表页刷新;收藏列表页移除一条数据后,也要同步回列表页。这两条通路如果漏了任何一条,用户就会看到列表页和详情页状态各说各话。我采用的方案是项目之前就引入的Provider做状态管理,在收藏服务里暴露一个ChangeNotifier,所有需要感知收藏状态变化的页面都监听它。
2.3 状态同步机制:避免"这里收藏了那里还是灰的"
跨页面状态同步是我在这次开发中花时间最多的地方。资讯列表页、详情页、收藏列表页三者的收藏状态必须保持实时一致,任何一处出现滞后,用户的信任感都会打折扣。
我没有采用传统的页面跳转回调传参方式,因为收藏状态可能由详情页触发修改,而列表页在导航栈的更底层。回调链路太长且容易漏。更好的方式是全局的收藏状态单例配合监听通知。收藏服务内部维护收藏ID集合和文章列表,任何增删操作完成后,调用notifyListeners通知所有监听方重建相关UI。
这样设计的核心收益是单向数据流。所有收藏的增删都收敛到FavoritesStorage这一个类里,UI只负责展示和触发。排查问题时只需要看这一个类的逻辑,不需要在页面之间反查引用关系。
实测中的经验是,监听回调里不要做重量级操作。列表页收到通知后重建的是收藏标识相关的组件子树,而不是整个ListView。我特意把收藏标识拆成了独立的Widget,并且用收藏ID的集合作为它是否重建的判断依据,这样即使收藏列表页同时开着,也不会产生多余的整页刷新。
3. 持久化层核心实现
UI链路通了,接下来是真正决定收藏功能质量的部分:持久化层。这一章给完整的存储服务封装、序列化细节和OpenHarmony平台适配注意点。
3.1 存储服务封装:读写接口与异常处理
我实现了一个FavoritesStorage单例,对外暴露五个核心方法:查询全部收藏、判断是否已收藏、添加收藏、取消收藏、初始化加载。所有方法都是异步的,因为底层的SharedPreferences读写虽然快,但在极端情况下也可能有IO延迟,同步阻塞UI线程是绝对不建议的。
判断是否已收藏这个操作,我在方法内部做了两层优化。第一层是内存缓存,服务启动时一次性把持久化数据加载进内存,之后的读操作都是纯内存操作;第二层才是真正的磁盘读取,只在服务初始化和数据被外部修改时发生。
写入侧的设计更加谨慎。SharedPreferences的写入本质是整文件覆盖,如果两条写操作并发交错,可能出现数据互相覆盖的问题。我在写入口做了串行化处理,用一个简单的Future链保证同一时间只有一个写入任务在执行。
完整的核心读写在代码上长这样:
class FavoritesStorage extends ChangeNotifier { FavoritesStorage._(); static final FavoritesStorage instance = FavoritesStorage._(); static const _prefsKey = 'favorite_articles'; final Set<String> _ids = {}; final List<Article> _articles = []; Future<void>? _pendingWrite; Future<void> load() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_prefsKey); if (raw == null || raw.isEmpty) return; final list = articleListFromJson(raw); _articles ..clear() ..addAll(list); _ids ..clear() ..addAll(list.map((e) => e.id)); notifyListeners(); } bool isFavorite(String id) => _ids.contains(id); List<Article> get all => List.unmodifiable(_articles); Future<void> add(Article article) async { if (!_ids.add(article.id)) return; _articles.insert(0, article); notifyListeners(); await _scheduleWrite(); } Future<void> remove(String id) async { if (!_ids.remove(id)) return; _articles.removeWhere((e) => e.id == id); notifyListeners(); await _scheduleWrite(); } Future<void> _scheduleWrite() { final previous = _pendingWrite; final completer = Completer<void>(); _pendingWrite = completer.future; previous?.whenComplete(() async { try { final prefs = await SharedPreferences.getInstance(); final ok = await prefs.setString(_prefsKey, articleListToJson(_articles)); if (!ok) { debugPrint('收藏数据写入失败'); } } catch (e) { debugPrint('收藏数据写入异常: $e'); } finally { completer.complete(); } }); return _pendingWrite!; } }写操作的串行化实现很关键:每次写操作把上一个写操作的Future接在后面,形成一个链式的异步队列。这样即使短时间内连续触发十次收藏操作,也不会出现磁盘文件里只剩最后一条数据的情况。这个设计经过反复重启测试验证,数据完整性是可靠的。
3.2 序列化避坑:中文编码与模型映射
持久化功能绕不开序列化。模拟项目X的文章数据包含标题、摘要、来源名、发布时间等,其中标题和摘要都是中文。在JSON序列化这一点上,Flutter的jsonEncode对中文默认不做转义处理,直接输出UTF-8编码的中文字符,这本身没有问题。
真正的坑在别处。一是HTML标签问题。资讯内容从网络接口拿回来时,摘要字段可能带着HTML标签或转义字符,收藏时如果原样写入,收藏列表页显示的就像一行原始代码。我统一在入库时做了一次清洗,把常见标签剥离,只保留纯文本。
二是图片地址的完整性。收藏的文章快照里包含图片URL,这个URL可能是相对路径。如果资讯源的服务端配置不当,收藏了过一段时间图片域名变更,旧URL就会失效。我在收藏写入时对URL做了一次规范化,把相对路径补全为绝对地址。这个细节平时看不出来,但用户几个月后打开收藏发现图片裂了,体验就很糟。
三是模型字段的兼容。前面提到过,Article模型后续大概率会加字段。我在fromJson里对每个字段都做了为空判断和类型兜底。例如发布时间解析失败时回退到0,图片URL缺失时使用占位地址。这样即使收藏了旧版本的数据,新版本App打开时依然能稳定渲染。
3.3 OpenHarmony平台适配要点
这一节写给准备实际跑在OpenHarmony设备上的同学。Flutter在OpenHarmony上运行依赖社区适配的SDK,大部分纯Dart层的包都能正常工作,但涉及平台通道的插件就要逐个验证。
shared_preferences这个存储组件在适配环境里是有对应实现的,我实测下来读写正常、数据能留存。这里说的留存是重点。OpenHarmony的进程模型与常见移动平台有差异,App在后台被系统回收后,如果存储实现没有正确落盘,重启后数据就会丢。我在测试时特意做了"杀进程重启"的验证流程,而不是简单地按Home键切后台。
路径相关API在适配环境里行为可能和标准Flutter不同。如果后续要切换到文件存储或者SQLite,需要优先验证PathProvider在OpenHarmony上的返回路径是否真实可写,建议打印出路径后在文件管理器里肉眼确认一下。这一步看起来多余,实际能帮你避开大量权限问题。
还有一个工程上的注意点:不要在调试热重载状态下验证持久化逻辑。热重载会重建Dart侧状态,但可能不会正确触发平台侧的写入清理,容易造成"调试时正常、正式运行丢失数据"的假象。我每次改完持久化代码,都会做一次完整的冷启动验证。
4. 调试过程与常见问题排查
开发过程中踩了一些比较典型的坑,这里按问题类型整理出来,附上排查思路和解决办法。这些坑在常规文档里大概率找不到,但实际项目中几乎都会遇到。
4.1 收藏状态不同步的三个典型场景
场景一:从详情页返回列表页,列表页的收藏标识还是旧状态。排查发现列表页虽然监听了ChangeNotifier,但刷新逻辑写在了页面的build方法里,而被返回时列表页的build没有触发。解决方法是把收藏标识拆成独立Widget,并让它自身订阅状态变化,而不是依赖整页重建。
场景二:从收藏列表页移除一条收藏,返回到资讯列表页,这一条还显示着"已收藏"。这个问题出在移除操作只更新了磁盘数据,没有调用notifyListeners。我在FavoritesStorage的remove方法里补上了通知,问题立刻消失。这类问题最好的预防手段,是把所有状态变更收敛到存储服务,并在服务内部强制统一走通知出口。
场景三:详情页快速切换多篇文章,收藏图标错乱。原因是详情页复用了同一个页面实例,切换文章时状态没有重置。我在文章ID变化时主动重置收藏状态,并且让收藏状态的读取变成响应式的,而不是页面里保存的布尔值。
4.2 数据丢失问题:写操作竞态与崩溃恢复
我最严重的一次bug是连续快速收藏多篇文章后重启,发现磁盘上只保留了最后一条。定位下来是连续async写入彼此交错,后写的把先写的覆盖了。这直接促使我在存储服务里加入了写操作串行化,也就是前面代码里的Future链方案。
数据损坏的另一个来源是写入中断。极端情况下App在写盘过程中被系统强杀,可能留下半截JSON。我在读取侧做了防御性解析:解析失败时不会让App崩溃,而是把损坏数据备份到一个备份键,然后清空主键重新开始。虽然这种情况非常罕见,但处理了之后整个存储服务的健壮性上升了一个台阶。
还有一个和恢复有关的细节。如果收藏数据解析失败就清空,用户之前存的内容全没了,体验很糟。我现在的策略是尝试逐条解析:一条坏数据不影响其他正常数据的恢复。这需要序列化方法对单条数据的错误包一层try-catch,牺牲一点代码整洁度换来数据的最大保全,值得。
4.3 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 收藏后重启数据丢失 | 平台存储未真正落盘,或未做冷启动验证 | 用"杀进程重启"代替切后台验证 |
| 列表页与详情页状态不一致 | 未订阅统一状态源,各自存了副本 | 所有页面只读FavoritesStorage单例 |
| 快速连点收藏出现状态错乱 | 写操作竞态 | 点击防抖加写操作串行化 |
| 老版本收藏数据打开崩溃 | 模型新增字段未做兼容 | fromJson对所有字段空值兜底 |
| 收藏列表出现HTML标签 | 入库前未清洗文本 | 存储前统一剥离标签 |
| 图片无法显示 | URL相对路径或域名过期 | 写入时规范化URL |
表格里每一行都是真实踩过的坑。其中"杀进程重启验证"那条我特别想强调,很多持久化的bug只有在这一步才会暴露,而这一步又特别容易被开发者在自测时跳过。
5. 性能优化与后续扩展
收藏功能当前版本已经完整可用,但在开发过程中我也把后续的优化路径和扩展方向理清楚了,分享出来供大家参考。
5.1 大批量收藏数据的读写优化
如果用户收藏量达到几百条甚至上千条,每次增删都整体序列化全量数据写入,会产生不必要的IO开销。优化手段有两种,按量级选择。
第一种是在写入时做去重和增量观察。收藏操作大多是零星的,一次只加一条,可以在写入前对比新数据和内存缓存,没有变化就直接跳过写盘。第二种是分片存储,把数据按时间或ID拆成多个键,每次只写变化的那一片。分片方案的缺点是读取时要合并,复杂度会上升。
对于模拟项目X这个体量,我选择了第一种优化,实现成本低且收益立竿见影。几千条以内的收藏数据,实测写盘耗时都在可接受范围内。
5.2 从JSON到SQLite的升级路径
如果哪一天产品要求做收藏的分类管理、标签体系,或者收藏量预期会大幅增长,SQLite是自然的演进方向。我在当前代码里已经把数据访问层收敛到FavoritesStorage这一个类,切换实现时UI侧不需要改动。
升级SQLite时要注意数据迁移。老用户磁盘上存着JSON格式的收藏数据,升级后第一次启动要把这些JSON解析后写入SQLite表,并且要保证迁移过程幂等:迁移失败不能导致数据丢失,重复迁移不能插入重复数据。我建议在迁移完成前保留旧的JSON键,等确认新库数据完整后再清理,给用户数据留一条退路。
5.3 后续可以扩展的方向
收藏功能做扎实之后,可以继续扩展的方向其实不少。比如收藏数据的云同步,跨设备的收藏保持一致,这里需要设计冲突合并策略,核心原则是"同一篇文章以最近的操作为准"。再比如收藏的分类打标,让用户给收藏文章加标签,这需要把存储模型从单层列表升级为多层结构,SQLite的升级路线就派上用场了。
还有一个我近期打算做的,是收藏列表的搜索与筛选。数据都在本地,全文搜索的实现成本不高,却能把收藏列表从"按时间翻找"提升为"直接检索",对用户价值非常直接。
另外,如果你在做的是资讯类产品的海外或本地化版本,收藏数据建议增加导出能力,给用户一个"把我的收藏导出成文件"的入口。这个功能技术上只需要把JSON序列化后共享出去,但带来的信任感和留存效果会超出预期。
最后说点个人体会。这次把收藏功能完整做完,最大的感受是:一个看起来只是"点一下变红"的功能,背后居然串联了状态管理、序列化、IO竞态、数据恢复这么多知识点。模拟项目X开发到这一期,最值的不是功能本身,而是把"数据生命周期"这条线彻底捋清楚了。顺着这条路,后续云同步、多端数据互通都有清晰的实现基础,这也是我会继续往下做的重要原因。