OpenHarmony上Flutter本地存储方案:从选型到避坑实践
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字符串写到应用私有目录的某个文件里。下次冷启动或者断网时先读文件作为占位数据同时后台刷新网络数据等网络数据回来再覆盖写一遍文件。Futurevoid saveArticleListSnapshot(String channelId, ListArticleSummary list) async { final dir await getApplicationCacheDirectory(); final file File(${dir.path}/channel_${channelId}.json); await file.writeAsString(jsonEncode(list.map((e) e.toJson()).toList())); } FutureListArticleSummary? 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如果插件直接支持就直接用sqfliteSQL写法和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之前先想清楚要存哪些字段。收藏表这么设计字段类型说明idINTEGER 主键自增本地主键article_idTEXT 唯一服务端文章ID用于去重titleTEXT文章标题收藏列表直接展示sourceTEXT来源媒体名publish_timeINTEGER发布时间时间戳collect_timeINTEGER收藏时间排序靠这个summaryTEXT摘要内容cover_urlTEXT封面图URL阅读历史表设计基本类似只是多了一个read_progress字段记录读到百分之多少方便做“继续阅读”功能。这里有一个需要提前做索引的字段article_id必须加唯一约束用于收藏时去重collect_time要加索引因为收藏列表要按时间倒序分页拉取。建表语句我在后面一节详细展开这里先提醒一句SQLite的字段类型约束比较宽松不要在表设计上偷懒该设置的类型还是要设置否则数据乱起来之后排查问题极其痛苦。3.3 统一的本地数据入口LocalDataManager的职责边界LocalDataManager作为对外的门面暴露的方法都是面向业务的比如Futurebool isFavorite(String articleId); Futurevoid addFavorite(ArticleDetail detail); Futurevoid removeFavorite(String articleId); FutureListFavoriteItem getFavoriteList({int page, int pageSize}); Futurevoid saveReadHistory(ArticleDetail detail, double progress); FutureListReadHistoryItem getReadHistory({int page, int pageSize}); Futurevoid 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; FutureDatabase get database async { _db ?? await _open(); return _db!; } FutureDatabase _open() async { final dir await getApplicationDocumentsDirectory(); final path p.join(dir.path, _dbName); return openDatabase(path, version: _dbVersion, onCreate: _onCreate, onConfigure: _onConfigure); } Futurevoid _onConfigure(Database db) async { await db.execute(PRAGMA foreign_keys ON); } Futurevoid _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); Futurebool isFavorite(String articleId) async { final result await db.query( favorite, where: article_id ?, whereArgs: [articleId], limit: 1, ); return result.isNotEmpty; } Futurevoid 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 ?? , }); } Futurevoid deleteFavorite(String articleId) async { await db.delete(favorite, where: article_id ?, whereArgs: [articleId]); } FutureListFavoriteItem 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小时即失效”但失效不等于删除只是返回数据时要提示上层“这是旧数据”。过期判断示例FutureArticleCacheData? 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 MapString, 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整体状态管理框架再梳理一遍。有兴趣的可以持续关注这个系列。