1. 本地存储在资讯类App里的位置:比想象中更重要
做“今日资讯”这类App做到第二十六期,功能层面基本已经齐了:列表、详情、分类、收藏、搜索都跑通了。这时候如果不做本地存储,用户每次冷启动都要等网络请求回来才能看到内容,弱网环境下直接白屏,这个体验在资讯类产品里是致命的。
我最初也觉得,资讯App本来就是实时刷新的,本地存不存无所谓。但真上手做了才发现,本地存储要解决的根本不是“存不存”的问题,而是“用户体验的下限兜底”问题。什么意思?就是网络通畅时大家都差不多,但一旦断网、弱网、或者进地铁进电梯,有本地缓存和没有本地缓存,完全是两个体验层级。加上用户在浏览详情页时的阅读历史、收藏的资讯列表、首页推荐位的刷不到时的替补内容,这些数据如果全部靠服务端拉,成本高、延迟大、还容易在弱网下直接失败,这些场景天然就该落在本地。
本地存储实现听起来是个“小模块”,其实是整个App数据链路里最考验基本功的一环。不同的数据要选不同的存储方式:就几对键值,没必要上数据库;几十条资讯的全文,用数据库反而顺手;图片文件缓存在应用沙箱里做LRU清理。关键是要知道OpenHarmony这个新生态上,Flutter的插件生态还处在“能用但不算齐全”的阶段,某些在Android/iOS上闭眼选的方案,在这边会遇到适配问题。所以这一篇我会花比较多篇幅讲清楚:在OpenHarmony上跑Flutter,本地存储这块到底怎么选、怎么接、怎么写才能少踩坑。
这篇内容适合两类人:一类是跟我一样把Flutter应用往OpenHarmony上迁移的开发者,可以直接抄作业;另一类是刚开始做资讯类App、还没想清楚本地缓存策略的新手,看完能少走不少弯路。
2. 方案选型:在OpenHarmony上到底该用哪套存储
2.1 从需求反推存储方案,而不是“有插件就用”
先把需求理清楚。我统计了一下“今日资讯”这个App里,实际需要落到本地的数据大概分这么几类:
- 用户偏好类:字体大小、夜间模式开关、上次阅读到的位置、首页频道排序。
- 阅读行为类:阅读历史(看了哪篇文章、看到第几段、阅读时间)、收藏列表。
- 内容缓存类:首页频道的资讯列表快照、详情页的文章正文、图片文件。
这三类数据的特征差异很大。偏好类是典型的键值对,量小、不要求复杂查询、每次改动也不频繁;阅读行为类数据量会持续增长,需要按时间排序、按文章ID去重、可能需要分页查询;内容缓存类是重头戏,单篇资讯正文可能几千字,首页一次拉下来二三十条做快照,图片文件还要考虑磁盘占用清理问题。
按这个需求去对照Flutter现有的本地存储方案,传统思路是这样的:需求简单数据量小就用shared_preferences,复杂结构化数据就上SQLite,文件类内容就扔文件系统里自己做管理。在OpenHarmony上,这个基本思路依然成立,但落地时每个方案都有一些“坑”需要提前注意。
在实际动手之前,我先做了个简单的方案对比:
| 存储方案 | 适合场景 | 在OpenHarmony上的适配情况 |
|---|---|---|
| shared_preferences | 键值对、用户偏好、开关配置 | 有配套实现,基础读写可用 |
| 轻量级关系型数据库 | 结构化数据、查询需求复杂 | HarmonyOS侧有自己的分布式数据库能力,但Flutter侧需要桥接或选配方案 |
| 文件存储 | 大文本、图片、JSON快照 | 标准文件API可用,没有特殊问题 |
| objectbox/hive等 | 纯Flutter侧方案,部分不依赖平台通道 | 部分纯Dart实现可跨端,但有些绑定原生能力的基本要绕一下 |
这个表可以先当个参考,具体每个方案怎么做,我下面会展开讲。
2.2 shared_preferences和hive怎么选:偏好类数据的最省事方案
先看最基础的偏好类数据。这类数据无非就是存个App主题模式、存个用户在首页看的频道ID、存个“上次阅读到第几篇”的页码。我给的建议是:偏好简单就用shared_preferences,官方的插件,在OpenHarmony上能直接用,接口和在Android上完全一样。
// 保存一个键值 await SharedPreferences.getInstance(); prefs.setString('home_channel_order', jsonEncode(channelOrder)); prefs.setInt('news_font_scale', 2); prefs.setBool('dark_mode', true); // 读取 String savedOrder = prefs.getString('home_channel_order') ?? ''; int fontScale = prefs.getInt('news_font_scale') ?? 1;shared_preferences在OpenHarmony上的原理是把它内部的键值存储映射到了系统自身的偏好存储能力上,所以用法上几乎无感知。但有一点必须提醒:它不适合存大对象。JSON序列化后超过10KB的字符串存进去,读写性能会明显下降。资讯列表每次拉回来几十条,序列化之后轻轻松松几十上百KB,硬塞进shared_preferences就不对了。这种内容缓存类数据更适合走文件或者数据库方案。
hive在纯Dart侧可以跑,它的优势是不需要原生层做桥接,理论上跨端一致性好。实际测试下来在OpenHarmony上基础读写没问题,但它把数据存在应用目录下的二进制文件里,后续如果要跟系统其他模块共享数据会比较麻烦,而且社区版本维护情况需要自己留意。我的结论是:如果只在Flutter侧自用,hive可以用;如果要跟OpenHarmony原生侧协作,建议还是走系统方案。
2.3 文件存储:简单粗暴,但得自己管好生命周期
文件存储适合存两类东西:一类是JSON快照(比如首页列表缓存),另一类是图片文件。
JSON快照的做法很直接:网络请求成功后,把解析好的数据结构序列化成一个JSON字符串,写到应用私有目录的某个文件里。下次冷启动或者断网时,先读文件作为占位数据,同时后台刷新网络数据,等网络数据回来再覆盖写一遍文件。
Future<void> saveArticleListSnapshot(String channelId, List<ArticleSummary> list) async { final dir = await getApplicationCacheDirectory(); final file = File('${dir.path}/channel_${channelId}.json'); await file.writeAsString(jsonEncode(list.map((e) => e.toJson()).toList())); } Future<List<ArticleSummary>?> loadArticleListSnapshot(String channelId) async { try { final dir = await getApplicationCacheDirectory(); final file = File('${dir.path}/channel_${channelId}.json'); if (await file.exists()) { final raw = await file.readAsString(); final decoded = jsonDecode(raw) as List; return decoded.map((e) => ArticleSummary.fromJson(e)).toList(); } } catch (_) {} return null; }这个方案的优点是够简单、不依赖额外插件,缺点是所有管理逻辑都要自己写:文件命名、清理策略、损坏恢复、并发覆盖问题。
比如我要做缓存容量控制,就得自己扫目录、按修改时间排序、把超过阈值的文件删掉。这块处理不好,久而久之缓存目录会越来越大。在OpenHarmony上跟Android有个共同的坑需要注意:应用私有目录的路径在不同版本上可能会有调整,不要写死路径,务必通过path_provider去拿。
2.4 结构化数据选型:轻量级数据库还是直接上SQLite
说了半天,还没聊阅读历史和收藏列表。这块数据有结构化查询需求,比如:
- 查某篇文章是否在收藏里。
- 按收藏时间倒序拉最近20条。
- 按阅读时间清理三个月前的历史记录。
这种场景用键值文件去做,程序里得自己处理集合运算和排序,非常痛苦。标准解法就是用数据库。在Flutter侧,最常见的方案是sqflite,原SQLite的封装,接口成熟、文档多、用的人也多。
这里有个大坑:sqflite在标准Flutter插件体系下能不能直接跑在OpenHarmony上,取决于OpenHarmony侧的Flutter框架是否实现了它依赖的系统API。我在前期做技术验证的时候,这事儿不确定,所以我做了两手准备。
方案A:如果插件直接支持,就直接用sqflite,SQL写法和Android一致,迁移成本为零。方案B:如果插件跑不起来,就是在Flutter层用系统能力做桥接,或者退回到文件JSON方案模拟一张表,这个代码复杂度会显著上升。
实际做下来我的经验是:在OpenHarmony生态里,优先推荐采用轻量级数据库封装方案,如果数据模型简单、查询需求原生能力足够,就不要为了一时方便去硬套Android那套复杂抽象。我最终采用的方案其实是一个“组合方案”:
偏好数据——shared_preferences 列表快照/正文缓存——文件存储 收藏/阅读历史——数据库结构化管理
需求拆开,各用各的,不用一把锤子砸所有钉子。
3. 本地存储核心模块设计:分层、抽象、可替换
3.1 数据层抽象:把存储细节封装起来,上层不感知
写代码有个原则:能用接口的地方就要用接口,存储方案更是这样。万一今天用shared_preferences,明天换成hive,上层业务代码不应该感知到这个变化,不然改动量不可控。
我在这期里把本地存储设计成了三个仓储仓储类:
- PreferenceRepository:封装偏好读写。
- ArticleCacheRepository:负责列表快照和正文文件缓存。
- UserBehaviorRepository:负责收藏、阅读历史的数据库操作。
上层调用时给一个统一入口,比如叫LocalDataManager,内部再按职责分发。这样写还有一个好处:后续如果要把存储从单机变成云同步,只需要在仓储内部加一层,上层拿到的数据来源从本地变成本地加云端,改动也被限制在仓储内部。
模块结构如下:
lib/ data/ local/ local_data_manager.dart preference_repository.dart article_cache_repository.dart user_behavior_repository.dart db/ app_database.dart favorite_dao.dart history_dao.dart entity/ article_summary.dart favorite_item.dart read_history_item.dart3.2 数据库表设计:收藏和阅读历史的字段怎么定
在开始写建表SQL之前,先想清楚要存哪些字段。收藏表这么设计:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER 主键自增 | 本地主键 |
| article_id | TEXT 唯一 | 服务端文章ID,用于去重 |
| title | TEXT | 文章标题,收藏列表直接展示 |
| source | TEXT | 来源媒体名 |
| publish_time | INTEGER | 发布时间,时间戳 |
| collect_time | INTEGER | 收藏时间,排序靠这个 |
| summary | TEXT | 摘要内容 |
| cover_url | TEXT | 封面图URL |
阅读历史表设计基本类似,只是多了一个read_progress字段记录读到百分之多少,方便做“继续阅读”功能。
这里有一个需要提前做索引的字段:article_id必须加唯一约束,用于收藏时去重;collect_time要加索引,因为收藏列表要按时间倒序分页拉取。
建表语句我在后面一节详细展开,这里先提醒一句:SQLite的字段类型约束比较宽松,不要在表设计上偷懒,该设置的类型还是要设置,否则数据乱起来之后排查问题极其痛苦。
3.3 统一的本地数据入口:LocalDataManager的职责边界
LocalDataManager作为对外的门面,暴露的方法都是面向业务的,比如:
Future<bool> isFavorite(String articleId); Future<void> addFavorite(ArticleDetail detail); Future<void> removeFavorite(String articleId); Future<List<FavoriteItem>> getFavoriteList({int page, int pageSize}); Future<void> saveReadHistory(ArticleDetail detail, double progress); Future<List<ReadHistoryItem>> getReadHistory({int page, int pageSize}); Future<void> clearExpiredHistory(int beforeDays);每个方法内部再转调具体仓储类。这个样子做到上层只管“我要收藏这篇文章”,不用关心它是写进数据库还是写进文件。
3.4 异步处理和线程模型:数据库操作不能让UI卡住
Flutter侧所有数据库操作都是异步的,这个天然没问题。真正要留意的是在OpenHarmony上,部分原生桥接调用如果处理不当会发生阻塞。我这里是统一把所有文件读写和数据库操作都放在async方法里,并且避免在build方法里触发存储操作。做列表加载时,先读本地缓存(快),再发起网络请求,等网络数据回来后再写缓存,这两步分开,而不是串行等待。
有个细节值得说一下:文件写入如果频繁以小数据量追加,SQLite或者文件IO会频繁触发系统写入放大,性能反而差。我的做法是,列表快照使用全量覆盖写的方式,每次都是写整个大JSON,而不是往文件里一条条append。这样代码逻辑简单,磁盘空间变化也稳定。
4. 实战:从依赖配置到核心代码实现
4.1 环境与依赖:OpenHarmony上接Flutter插件要注意什么
在写业务代码之前,先确认项目OpenHarmony侧的环境是OK的。我在OpenHarmony设备上跑Flutter项目时,用的是OpenHarmony SDK的API版本,编译和运行整体都比较稳定。
需要的依赖在pubspec.yaml里加这几个:
dependencies: flutter: sdk: flutter shared_preferences: ^2.2.0 path_provider: ^2.1.0 path: ^1.8.0 sqflite: ^2.3.0 json_annotation: ^4.8.0这里最需要确认的是sqflite在OpenHarmony上是不是直接可用。按我这边的经验,建议不要直接跑依赖就开写,而是先写一个最小测试用例:打开数据库、建表、插入一行、查出来。跑通了再继续后面的大逻辑。原因很简单,如果这个环节有问题,后面所有仓储类代码都白写了。
4.2 数据库封装:建立AppDatabase单例
数据库操作要防止多实例并发打开同一个库文件,最稳妥的做法是做单例:
class AppDatabase { AppDatabase._(); static final AppDatabase instance = AppDatabase._(); static const _dbName = 'news_app.db'; static const _dbVersion = 1; Database? _db; Future<Database> get database async { _db ??= await _open(); return _db!; } Future<Database> _open() async { final dir = await getApplicationDocumentsDirectory(); final path = p.join(dir.path, _dbName); return openDatabase(path, version: _dbVersion, onCreate: _onCreate, onConfigure: _onConfigure); } Future<void> _onConfigure(Database db) async { await db.execute('PRAGMA foreign_keys = ON'); } Future<void> _onCreate(Database db, int version) async { await db.execute(''' CREATE TABLE favorite ( id INTEGER PRIMARY KEY AUTOINCREMENT, article_id TEXT NOT NULL UNIQUE, title TEXT NOT NULL, source TEXT, publish_time INTEGER, collect_time INTEGER NOT NULL, summary TEXT, cover_url TEXT ) '''); await db.execute(''' CREATE INDEX idx_favorite_collect_time ON favorite (collect_time DESC) '''); await db.execute(''' CREATE TABLE read_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, article_id TEXT NOT NULL UNIQUE, title TEXT NOT NULL, source TEXT, publish_time INTEGER, read_time INTEGER NOT NULL, progress REAL DEFAULT 0 ) '''); await db.execute(''' CREATE INDEX idx_history_read_time ON read_history (read_time DESC) '''); } }版本字段很重要。以后如果表结构要加字段,版本号要递增,并在onUpgrade里写ALTER语句。上来就定版本1,后续业务迭代总会加字段,到时候不要硬着头皮drop表重建,用户数据就全没了。
4.3 收藏功能实现:加上会考虑ANR的代码怎么写
收藏操作的核心是幂等:用户可能连续点两次收藏按钮,不能产生两条重复收藏。这里利用article_id唯一约束来兜底,插入前先查一下,已存在就忽略。用SQLite的insert with conflictAbort参数也可以,但我更倾向先查后插,因为后面收藏状态的更新(比如收藏后标题变了要同步标题)会需要这条记录存在。
class FavoriteDao { final Database db; FavoriteDao(this.db); Future<bool> isFavorite(String articleId) async { final result = await db.query( 'favorite', where: 'article_id = ?', whereArgs: [articleId], limit: 1, ); return result.isNotEmpty; } Future<void> insertFavorite(ArticleDetail detail) async { final exist = await isFavorite(detail.articleId); if (exist) return; await db.insert('favorite', { 'article_id': detail.articleId, 'title': detail.title, 'source': detail.source ?? '', 'publish_time': detail.publishTime, 'collect_time': DateTime.now().millisecondsSinceEpoch, 'summary': detail.summary ?? '', 'cover_url': detail.coverUrl ?? '', }); } Future<void> deleteFavorite(String articleId) async { await db.delete('favorite', where: 'article_id = ?', whereArgs: [articleId]); } Future<List<FavoriteItem>> getFavoritePage(int page, int pageSize) async { final offset = (page - 1) * pageSize; final result = await db.query( 'favorite', orderBy: 'collect_time DESC', limit: pageSize, offset: offset, ); return result.map((e) => FavoriteItem.fromMap(e)).toList(); } }分页查询里limit和offset配合使用即可,资讯App的收藏列表一般不会超过几千条,这个量级SQLite毫无压力。
4.4 阅读历史:进度记录和时长控制的实现
阅读历史的设计目标有两个:用户重进详情页时能恢复到上次阅读位置;我要在“我的”页面提供历史列表。
记录进度的关键是详情页的滚动位置监听。我的实现里,是用ScrollController监听列表的滚动偏移量,按“已读内容占比”的百分比来存储。ScrollController实现如下:
ScrollController _scrollController; double _articleTotalExtent = 1.0; void _onScroll() { final currentExtent = _scrollController.position.extentBefore; final maxExtent = _scrollController.position.maxScrollExtent; final total = _articleTotalExtent; if (total <= 0) return; double progress = currentExtent / maxExtent; progress = progress.clamp(0.0, 1.0); _lastProgress = progress; } void _saveProgressToHistory() { final article = widget.articleDetail; if (article == null || _lastProgress == null) return; historyDao.insertOrUpdateHistory( articleId: article.articleId, title: article.title, source: article.source, publishTime: article.publishTime, progress: _lastProgress!, ); }保存时机要在离开详情页时的dispose回调里做,避免每次滚动都触发数据库写入消耗性能。这又是一个小细节:阅读进度不用实时写,离开页面时写一次就够了。这比实时监听写库要省心很多,也有效避免了频繁IO。
4.5 缓存快照:三个频道的首页数据和详情正文缓存
首页频道缓存我采用的方案是“目录+频道号”分文件的JSON。三个频道就是三份文件,每份文件的过期策略是“超过24小时即失效”,但失效不等于删除,只是返回数据时要提示上层“这是旧数据”。
过期判断示例:
Future<ArticleCacheData?> loadChannelCache(String channelId) async { final dir = await getApplicationCacheDirectory(); final file = File('${dir.path}/channel_$channelId.json'); if (!await file.exists()) return null; final raw = await file.readAsString(); final map = jsonDecode(raw) as Map<String, dynamic>; final cacheTime = map['cache_time'] as int? ?? 0; final now = DateTime.now().millisecondsSinceEpoch; final isStale = (now - cacheTime) > Duration(hours: 24).inMilliseconds; return ArticleCacheData( list: (map['data'] as List).map((e) => ArticleSummary.fromJson(e)).toList(), isStale: isStale, cacheTime: DateTime.fromMillisecondsSinceEpoch(cacheTime), ); }详情页正文缓存类似,只是文件是单篇文章一个文件名,命名规则就是articleId,清理策略改成“累计超过500篇删最早”。
这种设计有一个好处理的地方:如果用户断网打开App,首页不会白屏,而是读本地旧数据渲染出来,同时顶部给一个“内容可能不是最新”的提示条。等网络恢复后刷新页面,文件再被新的请求覆盖。不知道你们有没有遇到过那种断网它就白屏的新闻App,属实体验不行。
4.6 清缓存功能:容量管理和过期清理策略
清缓存的功能一般放在“我的-设置”里。很多人觉得这是个小功能,觉得就是遍历文件目录删删删。但这里其实有两个问题值得多想想:一是哪些文件能删,哪些不能删;二是在集成测试时如何验证容量上限有效。
我这里的做法是区分两类缓存:可清理缓存(列表快照、图片文件)放到Cache目录,不可清理缓存(收藏和阅读历史)放到Documents目录。全局的目录分工已经由path_provider的两个目录区分开了,不可清理的数据不放在缓存目录里,格式化或清理系统缓存时不受影响。
清理逻辑做了两个操作:清空Cache目录下所有文件;保留数据库本身;同时上报一个“清理出X MB空间”的数据用于界面展示。
5. 踩坑记录:OpenHarmony上跑通的几道坎
5.1 sqflite在OpenHarmony上的通道适配问题
sqflite原生走的是Android的SQLite系统API,在OpenHarmony上插件的平台通道如果没有对应实现,运行时会报MissingPluginException。这个我在前期踩过,换成在本地做桥接后解决。
我在最小用例验证时发现sqflite调用会有兼容性问题,原始的MethodChannel在OpenHarmony上对应的处理器没有注册。这里不去展开具体的桥接代码,因为这个属于环境适配层的坑,每个版本可能都不一样。给一个实际验证有效的思路:先在OpenHarmony原生侧用系统内置的轻量级偏好/关系型数据库能力把数据接口打通,Flutter侧通过MethodChannel或EventChannel直接调用。这种方式稳定度最高,也绕开了纯Flutter插件在目标平台缺实现的问题。
5.2 路径获取差异:getApplicationDocumentsDirectory在不同环境不稳定
path_provider在OpenHarmony上能拿到路径,但有段时间我发现获取到的目录和预期不一致,有的是空路径,有的是相对路径。这个坑主要是版本兼容导致的。我的规避习惯是封装一层路径访问接口,内部走到路径后把关键路径打印出来做一次日志确认,确认无误后再做目录拼接。同时不硬编码任何路径字符串,所有目录获取都走API,设备间迁移代码时能少一半问题。
5.3 高版本SDK的异步IO行为:写文件顺序和时序
在OpenHarmony高版本SDK上,我遇到过一次异常:连续两次写同一个文件,第二次写的内容在极短时间内读取时仍然是第一次内容。定位后发现是写入没有真正落盘,系统异步IO的flush策略跟Android有差异。解决方式是写完后同步执行一次flush和close,确保数据落盘再返回成功。对代码来说,就是在写入文件后增加一句:
await file.flush(); await file.close();这个小改动之后,再没有出现过缓存内容不一致的情况。
5.4 多实例数据库:Flutter热重载导致连接泄漏
开发期经常改代码触发热重载,Flutter热重载本身不会释放原生对象的生命周期,这就容易导致旧的数据库连接没有完全释放。有一次我改了表结构,热重载之后一直报“database is locked”。后来排查到根因:热重载后旧页面还持有旧数据库连接,新页面又打开了新连接,两个连接同时操作同一个库文件,数据库锁被占住了。
这个是开发期特有的问题,打包发布后不会有。但如果团队里遇到这个问题,先不要慌,重启App就好,代码本身没写错。也可以在AppDatabase里维护一个连接引用,热重载前手动关闭断开,能在一定程度上规避。
6. 常见问题与排查技巧:本地存储的实用避坑指南
6.1 问题速查表
| 问题 | 现象 | 排查思路与解决 |
|---|---|---|
| 插件调用直接报MissingPluginException | 点击收藏按钮后控制台报异常 | 检查插件在OpenHarmony侧的通道注册代码是否已实现,优先走原生桥接 |
| 数据库表结构变了但旧数据还在 | 升级后查询报列不存在 | 不要drop重建,写版本升级,用ALTER TABLE加列 |
| 缓存文件可以写入但重启后消失 | 重启又拉网络 | 确认写的是Cache目录还是临时目录,Cache目录可能被系统清理,需要重新缓存 |
| 文本编码问题 | 中文乱码 | 统一用utf8编码写文件,readAsString和writeAsString默认utf8,不要手动转别的编码 |
| 文件覆盖写偶尔丢内容 | 刷新后旧数据还在 | 写入后加flush和close,确保内容落盘 |
| 分页数据重复 | 下拉加载后重复条目 | 用article_id做去重或者用游标位置代替offset |
| 缓存目录越来越大 | 磁盘空间报警 | 记录缓存文件数量和总体积,定期清理超过阈值的文件 |
6.2 一个值得留意的截断问题:大JSON和分页查询的组合
有个比较隐蔽的问题:首页列表快照缓存的是整个频道的全部数据,而网络分页只拉第一页时,服务端可能不会返回完整列表,导致缓存文件越来越大,层级越来越深。这其实是服务端分页语义和本地缓存策略在打架。
我的处理是:缓存只保存第一页(比如前20条),当用户下拉刷新拉第二页、第三页时,数据不进首页缓存文件,只进列表页内存。然后“加载更多”时合并到列表数据集中去重。这个逻辑其实和“缓存快照语义”不是一回事,前者是“首页占位数据”,后者才是“用户已经拉到屏上的内容”。不要混在一起持久化处理。
6.3 开发期调试利器:VS Code的数据库查看插件
在开发期调试数据库内容,我一般直接在模拟器上跑App,然后用VS Code插件连到应用私有目录下的数据库文件。数据库文件路径一般是在应用沙箱目录的databases文件夹里。可以直接可视化查看收藏表和阅读历史表的数据内容,比自己写调试接口打印日志高效得多。注意查看前要把App里的数据库连接释放掉,否则文件被锁住会提示权限不足。
6.4 性能体验:存储操作不要阻塞主流程
最后再唠叨一个性能层面的事。资讯App的本地存储操作,全部都应该放在异步方法里执行,而且要有意识地避免在Widget的build方法里调用。详情页滚动时也不要实时写阅读进度,用户翻两页你写十几次数据库,再好的机器也经不住这么折腾。我实测把“进度保存”从滚动监听改成页面销毁前统一保存,流畅度明显回升,存储写入次数下降了百分之九十以上。
数据量大的时候如果要做全量清空,数据库delete的效率低于drop表重建。清缓存这种操作可以直接用drop + recreate来替代逐行删除,速度会快很多。不过记得先备份用户偏好数据,别一刀切把偏好也清了。
7. 这套本地存储方案后续还能怎么续
这期做的是本地数据能力的基础盘,后面能接的方向还挺多的:
第一个方向是数据统计。收藏、阅读历史这种数据如果跟服务端打通,可以做用户兴趣画像,首页推荐频道就能更智能,不用再让用户手动去订阅频道。当然这种改动要把数据的上报逻辑跟本地仓储分隔开,底层数据结构的抽象可以省很多事。
第二个方向是跨设备同步。如果做账号系统,收藏列表、阅读进度这些数据可以上云做多端同步,本地数据库就当离线的缓存副本。数据模型层不需要大改,只在UserBehaviorRepository外面再套一个同步仓储就行。
第三个方向是本地搜索。资讯类App收到一定数量的收藏后,用户会希望能在收藏里搜索关键词。现在收藏和阅读历史都入了SQLite,全文搜索可以直接用数据库LIKE查询跑,数据量几千条级别是完全扛得住的。
我和一起做这个项目的几个开发者在本地存储的选型和实现上反复讨论了很久。最有价值的一个共识是:存储层是最容易被“业务需求推着走”的模块,前期多做了一层抽象设计,后续接功能的时候变化成本就低了很多。如果你也在做一个资讯类App或者类似的内容型产品,建议从第一天开始就把“偏好”“缓存”“结构化数据”这三类需求分开设计,后面每一个新增需求都会来感谢你这个决定的。
这期就到这里,本地存储之后,下一步我打算把资讯详情的分享能力和App整体状态管理框架再梳理一遍。有兴趣的可以持续关注这个系列。