三国杀攻略App做到第三个版本,我开始认真对待本地存储这件事。当时项目用的是Flutter for OpenHarmony开发,功能侧基本稳定了,但数据侧的问题一个接一个冒出来:攻略列表刷新一次就回到顶部、用户收藏的武将方案一重启就没影、离线模式下卡牌效果查不了。这些问题本质都一样——数据没有落盘,全在内存里裸奔。这篇内容就是我从这个实战项目里一步步磨出来的数据持久化与本地存储经验,包括选型思路、核心代码、以及几次让我熬夜排查的坑,希望对正在OpenHarmony上做Flutter应用的朋友有参考价值。
1. 为什么拿“三国杀攻略”做本地存储的落地点
1.1 攻略类App的数据画像
做攻略App和做聊天软件、记账软件不一样,它的数据特点是“读得频繁、写得松散、但绝对不能丢”。我先把三国杀攻略场景里需要持久化的数据拉了一张清单,你感受一下:
| 数据类别 | 具体内容 | 存储要求 |
|---|---|---|
| 武将攻略 | 武将名、势力、技能描述、入手难度、强度评级、推荐出装 | 只读多、数据量大、支持搜索 |
| 卡牌信息 | 牌名、类型、花色点数、结算逻辑、FAQ | 只读多、结构固定、需要快速单查 |
| 用户收藏 | 用户收藏的武将/牌组/套路 | 写入频率低,但绝不能丢 |
| 游戏记录 | 最近几局的出牌过程、胜负结果 | 追加写入、量不大 |
| 个人设置 | 字体大小、夜间模式、目标势力筛选 | 高频读写、单值为主 |
这张表拉出来就清楚了一个事:这个App的持久化不是“存几个文件”那么简单,它更像一个本地知识库。武将和卡牌之间有关联,攻略和收藏之间有归属关系,如果全部用文本文件去存,后期做搜索、排序、过滤会非常痛苦。所以我从第一版就决定:核心数据走关系型数据库,轻量设置走首选项,两者分工。
1.2 OpenHarmony适配版Flutter的现状
先说清楚环境问题。OpenHarmony上的Flutter应用,跑的是Flutter框架 + OpenHarmony平台的适配层,大部分Dart层代码和标准Flutter一致,但插件生态和Android/iOS侧并不完全打通。你熟悉的很多pub包,在OpenHarmony上不一定有对应实现,或者说需要用适配分支版本的插件才能跑起来。这意味着“选型”这件事比在安卓上要多花一倍精力。
不过好消息是,去年到今年的适配进展很快,常用的几个持久化方向都有可用的方案:首选项有现成的适配、文件路径可以通过路径库拿到、SQLite方向也有适配版的关系型数据库插件能用。我在下面的选型环节会结合自己的实测给建议——不是说哪个包名最好,而是告诉你每种方案在OpenHarmony适配环境里的行为差异和取舍逻辑。
2. 本地存储选型:文件、首选项还是关系数据库
2.1 四种方案在OpenHarmony上的实测对比
我在做选型时列了四个候选:纯文件存储、首选项、关系型数据库、内存缓存。先各自说结论,再给对比表。
- 纯文件存储:把JSON/String写入应用私有目录。优点是最简单,不依赖插件,适配层再烂也能用文件API。缺点是数据一多,加载、反序列化、更新都很难受,尤其是武将列表这种几千条记录,全量读入内存会有明显卡顿。我只用它存日志、导出文件这类和核心业务无关的内容。
- 首选项:OpenHarmony适配环境下有可直接用的首选项插件。适合存“用户设置”和“极少量状态值”,比如当前选的势力筛选、夜间模式开关。它底层的实现是XML或类似的键值存储,读写快,但存复杂对象要自己序列化成JSON字符串,不适合放大量数据。
- 关系型数据库:创建
hero_guide、card_info这样的表,用SQL查询。开发成本和体积都高一些,但换来的是搜索、索引、关联查询的能力。三国杀攻略的核心数据天然适合表结构,这个方向跑不掉。OpenHarmony适配版使用方式和平常的SQLite封装基本一样,最大的差异点在文件路径获取上,这一点后面踩坑章节会展开。 - 内存缓存:严格说它不是持久化,但读多写少的场景里必须有它。把热门攻略和卡牌放进一个带过期时间的Map里,数据库查询前先走缓存,可以少跑很多磁盘IO。
四选一不现实,实际是组合拳。我的最终方案是:首选项存设置,SQLite存攻略和收藏,内存缓存做查询加速,文件只存用户主动导出的内容。这四层各司其职,谁也不抢谁的活。
2.2 为什么把核心数据放进SQLite而不是JSON文件
很多做小工具的朋友会想:“我就一个攻略App,数据几百条,用JSON文件多简单,读进来塞进List不就行了?”我第一版也这么干过,然后被两个问题教育了。
第一个问题是写入原子性。你写JSON文件,如果App在写入中途被系统杀掉或断电,文件可能只剩一半,下次启动直接解析失败。SQLite这种数据库有事务机制,写了一半也能回滚,保证数据要么没写、要么写完整。攻略App的用户收藏量不大,但每一条都是用户花时间挑出来的,丢一条都是事故。
第二个问题是查询能力。JSON文件读进来之后要做搜索、过滤、按势力分组、按强度排序,所有这些逻辑都得自己写代码去遍历List。数据量小还好,等攻略库膨胀到几千条记录,每查一次都全量过滤,性能肉眼可见地掉。SQLite建好索引之后,一个WHERE camp = ? AND rank = ?就是毫秒级的事。这个差别在小数据量下不明显,等你真的往App里塞了全武将全卡牌的攻略,就会感谢当初选了数据库。
2.3 首选项适配版注意点
首选项这边我只提醒一点:OpenHarmony适配版的首选项,键值存储是基于平台层的本地能力实现的,和Dart的SharedPreferences包不一定同名,初始化方式也可能有差异。你导入插件后,记得先打印一下读取结果,确认数据是否能跨App重启保留。我最初遇到过一个情况:写入返回true,但重启后读出来是null,找了半天发现是插件适配版没有正确初始化底层存储,换一个适配分支就好了。
还有一种常见做法是“用首选项存一个JSON字符串作为数据库的‘开关状态’”,比如记录“数据库初始化版本号”。这个做法没问题,但不要直接把整个攻略库JSON塞进首选项——那玩意不是干这个的,存大了读写都慢。
3. 核心实现:从建表到DAO层的完整落地
3.1 数据库初始化和建表SQL
确定了SQLite方向,第一步是初始化数据库。在OpenHarmony适配版环境里,获取数据库文件的存储路径建议用路径插件拿到应用支持目录,而不是自己拼一个固定路径。代码如下:
import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart' as sqflite; // 适配版插件 Future<Database> _openDatabase() async { // 这里用到路径插件,OpenHarmony适配环境下会返回应用私有目录 final dbDir = await getApplicationSupportDirectory(); final dbPath = p.join(dbDir.path, 'sgs_guide.db'); return sqflite.openDatabase( dbPath, version: 1, onCreate: (db, version) async { await db.execute(''' CREATE TABLE hero_guide ( id INTEGER PRIMARY KEY AUTOINCREMENT, hero_name TEXT NOT NULL, camp TEXT NOT NULL, skill_desc TEXT, play_style TEXT, difficulty TEXT, power_rank INTEGER, is_favorite INTEGER DEFAULT 0 ) '''); await db.execute(''' CREATE TABLE card_info ( id INTEGER PRIMARY KEY AUTOINCREMENT, card_name TEXT NOT NULL, card_type TEXT NOT NULL, suit TEXT, number TEXT, logic_desc TEXT, faq_json TEXT ) '''); await db.execute(''' CREATE TABLE battle_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, record_time INTEGER NOT NULL, result TEXT, detail_json TEXT ) '''); }, ); }有几个点说明一下。getApplicationSupportDirectory()返回的是应用沙箱内的支持目录,这是拿路径插件问系统要的,而不是'/data/...'这种写死的路径。OpenHarmony的沙箱目录规则和Android不完全一样,写死路径基本必挂。另外我建了三张表,每张表都带一个自增主键id,虽然业务上用武将名做唯一标识更直观,但主键用自增整型对索引和关联查询更友好,武将名可以做普通索引加唯一约束。
faq_json字段存的是卡牌FAQ的JSON字符串。这种接口稳定、结构相对独立的小数据,我不愿意为它单独建表,就存JSON进去,需要时反序列化。经验是:强关联、需要JOIN的数据拆表,弱关联、一次取完的数据存JSON,这个度把握好,数据库设计既不会过度范式化,也不会变成一个大宽表。
3.2 DAO层封装:别让业务代码直接碰SQL
表建好之后,如果业务页面里直接写SQL,后期维护会很痛苦。我做了一个很薄的DAO层,把查武将、查卡牌、写收藏这类操作封装成方法。示例:
class HeroGuideDao { final Database db; HeroGuideDao(this.db); Future<List<HeroGuide>> queryByCamp(String camp, {int page = 0, int pageSize = 20}) async { final rows = await db.query( 'hero_guide', where: 'camp = ?', whereArgs: [camp], orderBy: 'power_rank DESC', limit: pageSize, offset: page * pageSize, ); return rows.map((row) => HeroGuide.fromMap(row)).toList(); } Future<HeroGuide?> queryByName(String heroName) async { final rows = await db.query( 'hero_guide', where: 'hero_name = ?', whereArgs: [heroName], limit: 1, ); if (rows.isEmpty) return null; return HeroGuide.fromMap(rows.first); } Future<void> updateFavorite(int id, bool isFavorite) async { await db.update( 'hero_guide', {'is_favorite': isFavorite ? 1 : 0}, where: 'id = ?', whereArgs: [id], ); } }这里用到了db.query()而不是手写SQL字符串,好处是参数自动转义,避免拼接SQL时的注入和引号问题。你可能觉得个人App不用防注入,但三国杀攻略数据里偶尔会有带引号的技能描述,比如“回合结束后,若你的手牌数为0,则视为使用一张【杀】”,这类文案直接拼SQL会把语句弄坏。用whereArgs传参是最省心的解法。
封装的另一个好处是方便统一加缓存。比如queryByCamp这个方法内部可以先查内存缓存,缓存没命中再走数据库,对业务层完全透明。这个我放到下一节讲。
3.3 模型类与Json的互相转换
DAO层返回的是Map列表,但业务层最好操作强类型模型。我建了HeroGuide这个模型类,核心代码如下:
class HeroGuide { final int? id; final String heroName; final String camp; final String skillDesc; final String playStyle; final String difficulty; final int powerRank; final bool isFavorite; HeroGuide({ this.id, required this.heroName, required this.camp, required this.skillDesc, required this.playStyle, required this.difficulty, required this.powerRank, required this.isFavorite, }); factory HeroGuide.fromMap(Map<String, Object?> map) { return HeroGuide( id: map['id'] as int?, heroName: map['hero_name'] as String, camp: map['camp'] as String, skillDesc: map['skill_desc'] as String? ?? '', playStyle: map['play_style'] as String? ?? '', difficulty: map['difficulty'] as String? ?? '', powerRank: map['power_rank'] as int? ?? 0, isFavorite: (map['is_favorite'] as int? ?? 0) == 1, ); } Map<String, Object?> toMap() { return { 'id': id, 'hero_name': heroName, 'camp': camp, 'skill_desc': skillDesc, 'play_style': playStyle, 'difficulty': difficulty, 'power_rank': powerRank, 'is_favorite': isFavorite ? 1 : 0, }; } }字段映射这里有几个坑。第一是SQLite的布尔值没有真正的BOOL类型,只能用 INTEGER 0/1 存,所以isFavorite必须做转换。第二是数据库查询出来的Map,字段名是下划线风格,Dart侧是驼峰,映射关系必须写对,少一个字段就是运行时错误。第三是skill_desc这类可能为空的字段,要给它一个默认值,否则后续页面渲染可能因为null崩溃。这些细节看起来小,但对我这种粗线条的开发者来说,十次bug有八次出在字段映射上。
4. 读多写少场景的缓存与预加载设计
4.1 两层缓存:内存优先,数据库兜底
三国杀攻略App的典型操作是:用户进入武将列表页、点进详情页、返回、再进另一个详情页。同样一批武将数据会被反复查询。如果每次点击都走SQLite,虽然数据库查询很快,但频繁的IO和ResultSet构建依然会带来肉眼可见的延迟,尤其在低端设备上。
我的做法是在DAO层外面套一个“Repository层”,里面维护一个简单的内存缓存:
class HeroGuideRepository { final HeroGuideDao _dao; final Map<String, List<HeroGuide>> _campCache = {}; HeroGuideRepository(this._dao); Future<List<HeroGuide>> queryByCamp(String camp, {bool forceRefresh = false}) async { if (!forceRefresh && _campCache.containsKey(camp)) { return _campCache[camp]!; } final result = await _dao.queryByCamp(camp); _campCache[camp] = result; return result; } Future<HeroGuide?> queryByName(String heroName) async { final cached = _searchInCache(heroName); if (cached != null) return cached; return _dao.queryByName(heroName); } }这套缓存逻辑很简单,但有几个细节值得说。第一,缓存只针对“全量结果集”这类稳定数据,用户收藏状态这种随时可能变化的数据不要缓存太久,或者每次写入后主动清掉对应缓存。第二,内存缓存一定会占用RAM,攻略数据量在可接受范围内才这么干,如果以后做到几千条甚至上万条,就要加LRU淘汰策略,只缓存最近访问的条目。第三,forceRefresh这个参数一定要留,因为后台数据更新后,你需要一个入口绕过缓存强制拉新。
4.2 首屏预加载:让用户不等白屏
这个App的首屏是武将攻略列表。以前的做法是等页面加载时再去异步查数据库,结果就是每次冷启动都要白屏个一秒多。后来我改成了“启动时预加载”策略:
void main() async { WidgetsFlutterBinding.ensureInitialized(); final db = await _openDatabase(); final dao = HeroGuideDao(db); final repository = HeroGuideRepository(dao); // 启动时预先加载热门势力武将,后续页面直接命中缓存 await repository.queryByCamp('魏'); await repository.queryByCamp('蜀'); await repository.queryByCamp('吴'); runApp(MyApp(repository: repository)); }启动阶段拿几百毫秒预热三张列表,换来的是用户进入页面时数据已经在内存里,列表几乎瞬开。代价是启动时间增加了一点点,但这个权衡在攻略场景非常划算。你可以根据自己App的数据分层来决定预热哪些内容——冷启动白屏的等待感受,和一进去列表瞬间刷出来的感受,用户体感差距非常大,这个优化值得做。
4.3 避免频繁写库:批量操作与延迟落盘
收藏、浏览记录这类操作,如果每次点击都立刻写SQLite,会有两个问题:一是频繁的数据库事务开销,二是用户快速操作时可能出现“写入竞争”。我最终做的是批量延迟落盘:
final _pendingFavorites = <int, bool>{}; // 待写入的收藏状态 Timer? _writeTimer; void scheduleFavoriteUpdate(int id, bool isFavorite) { _pendingFavorites[id] = isFavorite; _writeTimer ??= Timer(const Duration(seconds: 2), () async { await _dao.batchUpdateFavorites(_pendingFavorites); _pendingFavorites.clear(); _writeTimer = null; }); }用户在两秒内点了五个收藏,就只触发一次批量写库。这个方案要考虑一个边界:App退到后台或进程被杀时,未落盘的待写状态可能丢失。所以我在AppLifecycleListener里加了一个回调,进程进入非活跃状态时立即flush待写内容。原理想通之后做起来不复杂,核心是你得意识到“延迟写”和“及时落盘”之间的矛盾,并根据数据重要性给它一个合理的折中点。
5. 真机踩坑:数据库“丢失”与异步冲突排查实录
5.1 现象:升级版本后老用户的攻略数据凭空消失
这个Bug是我在测试机上最崩溃的一次。现象非常稳定:新安装App,写入一批收藏和几条攻略备注,然后杀掉App重启,数据还在;但只要升级版本安装,再打开App,数据库就像被重置了一样,所有用户数据都没了。听起来像经典的“升级后数据丢失”,但诡异的是日志里没有任何异常,数据库文件也确实存在。
5.2 排查链路:先定位文件路径,再看初始化逻辑
我当时排查了整整一个晚上,链路是这样的,你以后遇到类似问题可以直接抄:
- 打印数据库实际路径。通过路径插件打印出
dbPath,发现升级前后路径居然不一样。旧版本拿到的是/xxx/com.example.sgs/files/support/sgs_guide.db,新版本因为包配置调整变成了另一个路径。同一个App,因为版本升级导致沙箱目录结构变化,数据库文件实际落在了两个不同的位置。 - 确认数据库文件是否存在。去文件目录里一查,旧路径下的
sgs_guide.db还在,新路径下只有新建的空库文件。所以不是数据被清,是App在找另一个文件。 - 回溯沙箱目录变化原因。对比两个版本的路径发现,问题出在初始化代码里我混淆了“应用支持目录”和“应用文档目录”。旧版本用了支持目录,新版本某次重构后改用了文档目录。OpenHarmony的沙箱中这两个目录是不同的位置,路径一变,数据库自然读不到旧数据。
这事的根因并不复杂,但它暴露了一个很重要的原则:数据库路径策略一旦定下来,就不要轻易改,而且必须做版本迁移兼容。我当时居然把路径改了却没写迁移逻辑,属实不应该。
5.3 修复方案:路径常量化 + 首次启动迁移检查
修复分两步走。第一步,把数据库路径收口到一个常量方法里,确保所有调用方拿到的都是同一路径,禁止各处散落路径拼接逻辑:
Future<String> resolveDbPath() async { final dir = await getApplicationSupportDirectory(); return p.join(dir.path, 'sgs_guide.db'); }第二步,写一个migrateIfNeeded()方法,在旧路径存在而新路径不存在时,把旧文件复制过去:
Future<void> migrateIfNeeded() async { final oldPath = await _resolveLegacyDbPath(); // 旧版本路径 final newPath = await resolveDbPath(); if (await File(oldPath).exists() && !await File(newPath).exists()) { await File(oldPath).copy(newPath); } }迁移逻辑虽然不复杂,但它给我上了一课:任何改动到持久化路径、数据库文件位置的代码,都必须连同“历史数据兼容”一起考虑。用户手机上已经有数据了,你改了路径,对用户来说就是数据丢失,这是产品事故级别的问题。
5.4 另一个坑:异步未等待导致写入覆盖读
还有一次问题出在异步编程上。我在收藏页面写了个逻辑:进页面先异步读收藏状态,用户点按钮后同步改状态并写库。但某次我把读库操作改成了“不等待”,直接返回初始值,结果用户点收藏时,页面显示的其实还是旧状态,连续点了两次,第二次的写入把第一次覆盖了。表面上看起来是“数据丢失”,实际上是一个经典的异步竞态。
排查手段是加日志记录每个操作的发起时间和完成时间,发现读操作返回在写操作之后,状态被回拨了。修复方式是在setState前强制等待读库完成,并且对收藏按钮加防抖,避免短时间内多次触发。这个坑在数据持久化项目中非常常见,尤其在你开始优化异步、加缓存之后,更容易踩。任何依赖查询结果的UI操作,都要确保数据版本是新的,否则宁可先加载中,也不要让用户基于旧数据做决定。
6. 项目结构取舍:数据库、状态管理和后续扩展
6.1 为什么DAO和Repository不放进页面State
这个App早期版本,我在页面里直接new了一个DAO对象,数据查出来放在State里。页面少的时候问题不大,页面一多,你会发现同一个武将详情页可能被列表页、搜索页、收藏页同时打开,每个页面各自初始化一个DAO,各自建一次数据库连接,既浪费资源,又容易出现数据不一致。
我最终的做法是在App启动时创建单例的Repository,通过构造参数传给需要数据的页面。这样所有页面共享同一个Repository,内存缓存和数据连接都是同一套。状态管理方面,我保留了简单的全局ValueNotifier,没有引入重型状态管理库——因为攻略类App的数据流并不复杂,多数场景是“读一次、填进页面”,用不上复杂的reactive状态同步。
这里给个建议:不要因为业界流行什么状态管理方案就往项目里塞,先看你的数据流复杂度。三国杀攻略这种内容型App,数据更新频率很低,页面需要同步的状态也不多,用最笨但最稳的方法,反而出Bug最少。
6.2 数据库版本升级策略:预留migration通道
数据库不可能一次设计到位,后期加字段、加表是必然的。SQLite的openDatabase支持onUpgrade回调,我在建库时就预留了这个通道:
return sqflite.openDatabase( dbPath, version: 2, onCreate: _onCreate, onUpgrade: (db, oldVersion, newVersion) async { if (oldVersion < 2) { await db.execute('ALTER TABLE hero_guide ADD COLUMN is_favorite INTEGER DEFAULT 0'); } // 后续版本继续在这里加分支 }, );版本号从1升到2时,只执行增量迁移,不动旧表结构。这样老用户的数据库文件可以平滑升级,不用删库重建。攻略类App最忌讳的就是升级一次让用户重新收藏一遍,那等于把自己积累的用户信任清零。这个migration通道从第一版就留着,后面加任何字段都有地方安放,强烈建议你也这么做。
6.3 扩展方向:导出导入与云同步
数据持久化打底之后,我下个版本计划做两个扩展:本地数据导出成JSON/CSV文件,方便用户在社区分享攻略;另一个是云同步收藏状态,跨设备保持一致。前者是“文件存储”的典型场景,后者会引入网络层和本地数据库的合并策略,比纯本地存储复杂很多。到时候可能需要一个本地操作队列,把离线期间的更改记下来,等联网后统一提交。这些功能都建立在当前这套本地存储结构之上,所以把地基打好,后面房子怎么盖都稳。
最后说点实在的。我在这个项目里最大的感受是:数据持久化听上去是个老生常谈的话题,但在OpenHarmony适配版Flutter环境下,它比在标准Flutter环境里更容易出幺蛾子——路径变化、插件适配差异、沙箱机制不同,每一个都能让你排查到深夜。如果你也在做类似项目,我建议从第一行代码起就定死“数据库路径策略”,任何涉及迁移的操作都要先把旧数据处理干净再上新逻辑。我把这套思路原原本本整理在这里,踩过的坑能帮你避掉几个,就算没白写。