如果你也玩三国杀,应该能理解那种打完一局精彩对局后,想赶紧把战绩记下来的冲动。武将、身份、胜负、击杀数——这些数据攒得越多,越能看出自己在哪个身份、哪个武将上容易出彩。问题在于,我用的是OpenHarmony系统的设备,应用商店里翻来覆去就那么几个工具,能打的攻略类应用几乎没有,更别提带战绩管理的了。后来我想,既然Flutter社区一直在做OpenHarmony适配,干脆自己动手写一个,把三国杀攻略App做成,顺便把战绩记录功能从数据层到界面完整趟一遍。
这篇文章就围绕我在Flutter for OpenHarmony上做三国杀攻略App的开源实践展开,核心是里面的战绩记录模块。我会把数据建模、数据库选型、页面实现、真机调试这几个关键环节遇到的问题和取舍都摆出来,适合两类人看:一类是想在OpenHarmony上做Flutter应用,但不确定插件生态靠不靠谱的开发者;另一类是三国杀玩家,想给自己做一个纯粹的本地战绩工具。代码思路不复杂,但每一步踩过的坑和选型的理由很值得聊一聊。
1. 立项背景:OpenHarmony上为什么缺一款这样的App
1.1 三国杀玩家的战绩记录需求
三国杀这个游戏的胜负信息质量其实很高。一场身份局玩下来,至少包含了这几个关键维度:游戏模式(标准、军争、国战)、身份(主公、忠臣、反贼、内奸)、你使用的武将、最终是否胜利、击杀数、造成伤害量,以及这局游戏的时长。这些数据如果只是打完就忘,等于白玩——因为你没法复盘自己到底是哪个武将胜率高,哪个身份胜率惨淡。
市面上虽然有一些三国杀助手类的应用,但大多绑定在Android和iOS生态里,在OpenHarmony设备上很难找到原生可用的版本。而且这些App往往做得过于臃肿,各种推送和活动弹窗反而干扰了核心的那件事:记录和复盘自己的战绩。我自己是那种打完一局好局必须马上记下来的人,所以一个干净、离线优先、可以随时翻阅统计数据的本地战绩工具,对我来说就是刚需。
1.2 Flutter for OpenHarmony的适配现状
说到跨平台方案,Flutter在OpenHarmony上的适配,一直是社区在推进的事。目前OpenHarmony SIG组维护了ohos分支的Flutter SDK,核心的Dart运行时和渲染引擎已经能跑起来,基础的Widget也没问题。但相比Android和iOS,插件生态还在补课。
我实际用下来,状态管理(Provider、Riverpod)、图表库(fl_chart)、网络库(dio)这些纯Dart插件可以直接用,因为不涉及原生代码。但需要调用平台能力的插件就麻烦了,比如数据库、文件路径、包信息,这些必须有对应的OHOS实现。社区里已经有sqflite_ohos、path_provider_ohos、shared_preferences_ohos这类适配包,覆盖了日常开发的大部分场景,但版本维护和文档完整度还是比成熟平台差一些。
所以在项目启动之前,我先给自己定了一条铁律:能选纯Dart实现就选纯Dart实现,必须走原生能力的,优先找ohos适配过的社区包,找不到就先绕过,比如用临时方案顶替。这条规则在后面让我少踩了很多坑。
1.3 为什么攻略App是练手OHOS的最佳项目
很多人听到OpenHarmony上跑Flutter,第一反应是做一个Hello World或者简单的Todo List,但我觉得这种项目信息量太少,跑通了也不能证明什么。三国杀攻略App不一样:它有数据结构设计、有本地持久化、有列表展示、有统计图表、有状态管理,几乎把日常App开发会遇到的核心问题都覆盖了一遍。
更关键的是,它的功能边界很清晰。我不需要做游戏本身的实时对战逻辑,也不涉及太多复杂的网络同步,核心就是"录入一条战绩、存起来、统计出来"这个闭环。这个闭环在Android上做一遍和在OpenHarmony上做一遍,差异点全在平台适配层,非常适合验证Flutter跨端能力在OHOS上到底可用到什么程度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 战绩系统的数据设计:先从一局游戏说起
2.1 一局三国杀战绩到底要记录哪些字段
设计数据表之前,我习惯先想"一条完整的战绩记录,用户在录入时看到什么、看完之后想查什么"。完整模拟用户的使用场景:晚上打了一局军争身份局,我选了孙权做主公,最后赢了,杀了两个反贼,打了三回合结束。这样一条记录,至少要能回答这些问题:什么时候打的、打的什么模式、我是什么身份、用的什么武将、赢了没有、我做了什么贡献。
按照这个思路,我定义了一张battle_records表,字段如下表所示:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER 自增主键 | 记录唯一标识 |
| general | TEXT | 使用的武将名 |
| identity | INTEGER | 身份编码,0主、1忠、2反、3内 |
| mode | TEXT | 游戏模式,标准/军争/国战/斗地主等 |
| is_win | INTEGER | 是否胜利,0失败、1胜利 |
| kill_count | INTEGER | 击杀数,默认0 |
| damage | INTEGER | 造成伤害量,默认0 |
| duration_seconds | INTEGER | 对局时长,单位秒 |
| battle_time | INTEGER | 对局发生的Unix时间戳(毫秒) |
| note | TEXT | 备注,可记录队友ID、精彩瞬间等 |
| create_time | INTEGER | 记录创建时间,Unix时间戳(毫秒) |
有两个在设计时容易忽略的点,我想提醒一下。
第一,identity字段我用整数编码而不是直接存字符串。原因不是省空间,而是后面的统计查询会轻松很多。按身份分组统计胜率时,GROUP BY identity比GROUP BY identity_text要直观,而且如果以后支持多语言,编码不会跟着界面文字走。
第二,battle_time和create_time分开存。battle_time是玩家手动选择的实际对局时间,create_time是这条记录真正写入数据库的时间。这两个时间在用户补录历史战绩时会明显不一致,如果只存一个字段,后续按时间排序和统计就会出现偏差。
2.2 数据实体与SQL建表语句
在Flutter端,我建了一个对应的实体类BattleRecord,字段和表结构一一对应。用Dart写出来大概是这样:
dart复制class BattleRecord {
final int? id;
final String general;
final int identity;
final String mode;
final bool isWin;
final int killCount;
final int damage;
final int durationSeconds;
final int battleTime;
final String note;
final int createTime;
const BattleRecord({
this.id,
required this.general,
required this.identity,
required this.mode,
required this.isWin,
required this.killCount,
required this.damage,
required this.durationSeconds,
required this.battleTime,
required this.note,
required this.createTime,
});
BattleRecord copyWith({int? id, bool? isWin, String? note}) {
return BattleRecord(
id: id ?? this.id,
general: general,
identity: identity,
mode: mode,
isWin: isWin ?? this.isWin,
killCount: killCount,
damage: damage,
durationSeconds: durationSeconds,
battleTime: battleTime,
note: note ?? this.note,
createTime: createTime,
);
}
Map<String, Object?> toMap() {
return {
'id': id,
'general': general,
'identity': identity,
'mode': mode,
'is_win': isWin ? 1 : 0,
'kill_count': killCount,
'damage': damage,
'duration_seconds': durationSeconds,
'battle_time': battleTime,
'note': note,
'create_time': createTime,
};
}
factory BattleRecord.fromMap(Map<String, Object?> map) {
return BattleRecord(
id: map['id'] as int?,
general: map['general'] as String,
identity: map['identity'] as int,
mode: map['mode'] as String,
isWin: (map['is_win'] as int) == 1,
killCount: map['kill_count'] as int,
damage: map['damage'] as int,
durationSeconds: map['duration_seconds'] as int,
battleTime: map['battle_time'] as int,
note: map['note'] as String,
createTime: map['create_time'] as int,
);
}
}
建表SQL如下:
sql复制CREATE TABLE battle_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
general TEXT NOT NULL,
identity INTEGER NOT NULL,
mode TEXT NOT NULL,
is_win INTEGER NOT NULL,
kill_count INTEGER NOT NULL DEFAULT 0,
damage INTEGER NOT NULL DEFAULT 0,
duration_seconds INTEGER NOT NULL DEFAULT 0,
battle_time INTEGER NOT NULL,
note TEXT,
create_time INTEGER NOT NULL
);
CREATE INDEX idx_battle_time ON battle_records (battle_time DESC);
CREATE INDEX idx_identity ON battle_records (identity);
CREATE INDEX idx_general ON battle_records (general);
索引不是随便建的。我实际上每天录入的成百上千条记录,查询最频繁的两种场景是:按时间倒序展示最近记录、按身份或武将分组统计。所以对battle_time、identity、general这三个字段加了索引,实测在本地数据量达到几千条时,统计查询的耗时基本都在毫秒级。
2.3 为什么选sqflite_ohos而不是drift或Hive
Flutter生态里的本地数据库方案不少,我既然要上OpenHarmony,每个方案都认真权衡了一遍。drift是sqflite之上的一层类型安全ORM,开发体验很好,但它依赖sqlite3原生库,需要额外的原生编译适配,在OpenHarmony上要自己处理sqlite3的ohos构建,风险偏高。
Hive和Isar这类NoSQL方案,读写速度确实快,但对聚合查询的支持很弱。战绩统计这种场景,要按身份、武将、模式做分组聚合,用NoSQL就只能把全部数据捞出来在内存里算,效率低、代码也不优雅。Isar虽然支持FilterGroup,但在OHOS上同样面临原生库适配问题。
sqflite_ohos是直接把Android上大家最熟悉的sqflite移植到OHOS上的方案,API几乎一致,底层走的是OHOS的SQLite能力,不需要额外引入sqlite3原生库。对我来说,迁移成本最低、风险最可控。下面是三个方案的综合对比:
| 方案 | SQL支持 | OHOS适配成本 | 适合场景 |
|---|---|---|---|
| sqflite_ohos | 完整SQL | 低,直接依赖OHOS系统SQLite | 结构化数据、报表统计 |
| drift | 完整SQL | 高,需自行适配sqlite3原生库 | 追求类型安全的桌面级应用 |
| Hive/Isar | 弱 | 中 | 简单键值、缓存场景 |
选型结论很明确:在OpenHarmony生态还没有完全成熟的阶段,优先选择与上游API一致的sqflite_ohos,可以最大程度减少自己维护的成本。实际上后来的开发也确实证明了这个选择是对的,整个数据库层几乎没因为平台差异而卡过壳。
3. 数据库接入与核心代码落地
3.1 依赖配置与数据库初始化
数据库方案定了sqflite_ohos,接下来就是把它接进项目。pubspec.yaml里的依赖配置大概是这样的:
yaml复制dependencies:
flutter:
sdk: flutter
sqflite_ohos: ^1.0.0
path_provider_ohos: ^1.0.0
provider: ^6.1.1
fl_chart: ^0.66.2
intl: ^0.18.1
这里要特别提醒一句:如果某些包的OHOS适配版本在pub.dev上找不到,可以去OpenHarmony SIG的代码仓库检查,有的包通过git依赖方式引用反而更稳定。我在初始化阶段就试过一个版本不匹配的问题——README示例里写的是sqflite_ohos: ^0.2.0,但实际配套的Flutter SDK版本已经不支持了,最后锁定到了backup的版本才跑通。
数据库初始化我放在了单独的database_helper.dart里:
dart复制import 'package:sqflite_ohos/sqflite_ohos.dart';
import 'package:path/path.dart';
class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._();
static Database? _database;
DatabaseHelper._();
Future<Database> get database async {
_database ??= await _initDatabase();
return _database!;
}
Future<Database> _initDatabase() async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, 'sanguosha_strategy.db');
return openDatabase(
path,
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE battle_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
general TEXT NOT NULL,
identity INTEGER NOT NULL,
mode TEXT NOT NULL,
is_win INTEGER NOT NULL,
kill_count INTEGER NOT NULL DEFAULT 0,
damage INTEGER NOT NULL DEFAULT 0,
duration_seconds INTEGER NOT NULL DEFAULT 0,
battle_time INTEGER NOT NULL,
note TEXT,
create_time INTEGER NOT NULL
)
''');
await db.execute(
'CREATE INDEX idx_battle_time ON battle_records (battle_time DESC)');
await db.execute('CREATE INDEX idx_identity ON battle_records (identity)');
await db.execute('CREATE INDEX idx_general ON battle_records (general)');
},
);
}
}
这个文件的设计上没有必要做成一堆静态方法,单例模式就够用。后面不管是战绩页面还是统计页面,都通过DatabaseHelper.instance.database拿到数据库实例,避免每次调用都重新打开数据库,减少句柄泄露风险。这段代码在RK3568开发板上实际跑下来,冷启动首次初始化大概两千条数据耗时不到一百毫秒,体验是可以接受的。
3.2 战绩CRUD的封装实现
直接在各页面里写SQL不是一个好习惯,尤其是项目要持续迭代的时候。我建了一个BattleRecordRepository来统一管理战绩的增删改查,把数据映射和SQL细节都藏在后面,页面只管调用repository的方法。
dart复制class BattleRecordRepository {
final Database db;
BattleRecordRepository(this.db);
Future<int> insert(BattleRecord record) async {
return db.insert('battle_records', record.toMap());
}
Future<int> update(BattleRecord record) async {
return db.update(
'battle_records',
record.toMap(),
where: 'id = ?',
whereArgs: [record.id],
);
}
Future<int> delete(int id) async {
return db.delete(
'battle_records',
where: 'id = ?',
whereArgs: [id],
);
}
Future<List<BattleRecord>> getAllOrderByTime({int limit = 100}) async {
final rows = await db.query(
'battle_records',
orderBy: 'battle_time DESC',
limit: limit,
);
return rows.map(BattleRecord.fromMap).toList();
}
Future<BattleRecord?> getById(int id) async {
final rows = await db.query(
'battle_records',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return null;
return BattleRecord.fromMap(rows.first);
}
}
insert和update的细节处理需要注意。insert时不要在record里放id字段,让SQLite自增主键自动处理;update时一定要记得带where条件,否则会更新整张表。如果修改了Entity后再调用update,建议先用copyWith生成新对象再赋给repository,避免页面上的旧引用影响后续操作。
3.3 胜率统计的SQL写法
战绩记录的核心价值在统计。我从需求里拆出四个必做的统计维度:总胜率、按身份胜率、按武将出场次数和胜率、最近N场胜负趋势。前三个直接交给SQL聚合函数处理。
总胜率:
sql复制SELECT
COUNT(*) AS total_count,
COALESCE(SUM(CASE WHEN is_win = 1 THEN 1 ELSE 0 END), 0) AS win_count,
ROUND(
COALESCE(SUM(CASE WHEN is_win = 1 THEN 1 ELSE 0 END), 0) * 100.0 / COUNT(*),
1
) AS win_rate
FROM battle_records;
按身份统计:
sql复制SELECT
identity,
COUNT(*) AS total_count,
COALESCE(SUM(CASE WHEN is_win = 1 THEN 1 ELSE 0 END), 0) AS win_count,
ROUND(
COALESCE(SUM(CASE WHEN is_win = 1 THEN 1 ELSE 0 END), 0) * 100.0 / COUNT(*),
1
) AS win_rate
FROM battle_records
GROUP BY identity
ORDER BY total_count DESC;
按武将统计Top10:
sql复制SELECT
general,
COUNT(*) AS total_count,
COALESCE(SUM(CASE WHEN is_win = 1 THEN 1 ELSE 0 END), 0) AS win_count
FROM battle_records
GROUP BY general
ORDER BY total_count DESC
LIMIT 10;
统计结果需要映射成一个单独的结果模型。我用一个Map或专门写一个StatisticResult类来接。这里有一个细节:CASE WHEN is_win = 1 THEN 1 ELSE 0 END在SQLite里支持,很直观。但读回来时要注意,sqflite的sum结果可能是整数也可能是浮点数,写的时候统一转成double稳一点。
最近N场的胜负趋势不一定要用SQL。更简单的办法是把最近20场按时间倒序取回来,然后在Dart里遍历算出一个胜率的滚动序列,画折线图时直接喂给fl_chart的lineBarChart。因为数据量不大,我选择了这种代码更可控的方式。
4. 战绩列表与统计图表页面实战
4.1 战绩录入表单的实现要点
录入表单是整个App使用频率最高的页面,设计的核心是减少录入摩擦。我用了一个全屏页面,从上到下依次是:对局时间选择、游戏模式下拉、身份单选、武将输入、胜负切换、击杀数和伤害量数字输入、备注框。
身份选择这里,我用了一个SegmentedButton来展示主公、忠臣、反贼、内奸四个选项。如果用DropdownButton,操作层级多,手感很重。胜负切换用Switch,切换时有明显的动效反馈,并且整个表单的提交按钮会实时显示当前记录的二个关键统计项,给用户一点即时的确认。
表单校验上,只有一个硬性规则:武将名不能为空。其他字段都有默认值,比如击杀数默认0、对局时间默认当前时间,玩家不想填的都能直接跳过。这个设计原则是"能默认就别让用户填",表单越短,录入意愿越高。
提交时的时间戳转换很关键。对局时间我用showDatePicker和showTimePicker组合,拼成DateTime后转毫秒时间戳存储。注意intl包的时区处理,在OpenHarmony上时区跟随系统,存储时使用绝对时间戳,展示时再在本地格式化,这样后续无论设备时区怎么变,历史战绩的时间不会乱。
4.2 战绩列表的时间线设计与滑动交互
列表页是整个App的门面,我用了一个按日期分组的ListView。基本思路是:先取全部战绩按battle_time倒序,然后在Dart里按日期归组,渲染时每个组首部显示日期标题,组内用Card展示记录。
每条Card的布局如下:
- 第一行:武将名(加粗) + 胜负标签(胜利绿色/失败红色)
- 第二行:模式与身份的格式化文本,例如"军争 · 主公"
- 第三行:击杀x个 · 造成伤害y点 · 对局时长mm分钟
- 右上角:对局时间,格式如"12/20 21:34"
删除交互用的Dismissible组件,左滑或者右滑删除。在实际体验里,这个交互虽然方便,但容易误触,我在确认删除的机制上做了增强:滑动结束后不是直接删,而是弹一个AlertDialog确认,确认后才调repository.delete。
列表加载性能上,因为战绩数据量一般不会特别大,直接一次性加载全部记录再分组,数据量到几千条时内存和滑动帧率都还撑得住。但如果你的用户是重度玩家,数据涨到几万条,那就要考虑分页加载或者结合分页查询了。这里有一个普通的ListView.builder懒加载原理,性能和可维护性最佳,所以我默认就用它,只在数据量确实大的时候再考虑分页。
4.3 基于fl_chart的胜率图表展示
统计页是整个App里最能体现"攻略价值"的地方。我用了fl_chart这个纯Dart库,在OpenHarmony上不需要额外的原生适配,这是它最大的优势。
页面顶部是一张概览卡片,展示总场次、总胜场、总胜率三个核心数字。下方用三个图表分别呈现:身份胜率对比用饼图,武将出场Top10用柱状图,最近20场胜率趋势用折线图。
以饼图为例,fl_chart的使用方式大概是这样:
dart复制PieChart(
PieChartData(
sections: identitySections,
centerSpaceRadius: 40,
sectionsSpace: 2,
startDegreeOffset: -90,
),
)
identitySections需要根据统计API返回的数据生成。每个身份一个PieChartSectionData,颜色加上title。如果某个身份一场没打过,不会出现在饼图里,同时要在标题下方补充一句"未记录XX身份对局",让用户知道自己哪些身份的数据是空白的。
柱状图的关键点是处理武将名过长的情况。三国杀的武将有"SP孙尚香"、"界徐盛"这种四个字以上的名字,直接当bottomTitles显示会重叠。我的解决方式是限制展示前8个武将,超长名称在柱状图下方的Title里截断加省略号。更详细的武将列表放在图表下方的一个表格里,那种情况反而不怕文字长。
折线图的数据源就是前面提到的最近20场滚动胜率。我在这里做了一个平滑处理:把原始胜率做了一次windows大小为5的移动平均,让曲线的趋势更明显,不至于因为单场胜负剧烈跳动。这个处理我个人很建议做,不然折线图看起来就是一条锯齿状的线,几乎看不出趋势。
5. 真机运行调试:我在RK3568开发板上踩过的坑
5.1 设备树与系统镜像的选择
我用的设备是RK3568开发板。这个板子配套的OpenHarmony系统镜像在网上一搜一大把,但不同的开发板品牌和硬件配置,对应的设备树文件不一样。最开始我也是被"openharmony的rk3566有许多设备树到底咋选"这种问题卡住,后来发现解决办法很简单:看系统镜像发布说明里列出的板型列表,对照自己板子的具体型号,选对应vendor的配置文件解锁进去。千万不要随便选一个龙芯或树莓派之类的配置,会直接起不来系统。
如果你的板子没有对应的正式支持,另一个方案是用OpenHarmony提供的模拟器,在DevEco Studio里创建Phone设备模拟器跑Flutter应用。模拟器部署速度快得多,调试UI交互足够。我实际调试UI都是在模拟器上完成的,只有验证数据库、文件存储这些涉及平台的特性时才切换到真机RK3568上跑。
5.2 权限与文件路径的差异
OpenHarmony的权限模型跟Android有相似之处,但又不完全一样。我的App读写数据库、写导出JSON文件,这些在Android上需要申请存储权限,在OpenHarmony里,应用私有目录下的读写是不需要额外权限的。一开始我踩的坑是想把导出文件写到公共Download目录,结果在真机上直接抛权限异常。
解决方式很简单:文件一律写到应用私有目录,用path_provider_ohos提供的getApplicationDocumentsDirectory()拿路径。数据库文件路径也是同理,getDatabasesPath()拿到的就是应用专属的数据库目录。原则是"能用私有目录就用私有目录",既安全又省心,真的需要用户导出文件到可见位置,再考虑结合文件管理能力让用户自己选择导出路径。
热词里提到的"flutter如何调用鸿蒙的图库",我也顺便研究了一下。如果以后想给战绩记录加头像图片或者截图,需要走OHOS的媒体库权限申请,在Flutter侧要写MethodChannel去调原生接口,或者等社区的photo_manager_ohos这类插件成熟。这个功能我暂时没有做,因为优先级不高,核心战绩记录功能用不上图库。
5.3 热重载失效与状态恢复问题
Flutter的热重载在OpenHarmony设备上能用,但效果和Android不完全一样。我遇到最多的情况是:改完UI代码按r热重载,页面确实刷出来了,但Provider里的状态还停留在热重载前,列表明明加了新数据却不刷新。
这个问题排查了很久,后来确认是Provider的状态没有跟着热重载重置。解决方法是热重载之后手动触发一次状态刷新,或者在main.dart里不保留Provider的初始状态,让重启时从数据库重新拉取。平时开发时更干脆的办法是直接冷重启(Shift+R),确保状态从0开始,虽然慢一点但不容易被脏状态误导。
另外一个坑是修改完数据库相关代码后,热重载不会有任何效果。sqflite_ohos属于原生插件,涨了插件代码就需要完全重启编译,否则原生层的变更根本不会进到进程里。我建议所有数据库schema变更都走版本号递增和onUpgrade回调,而不是删掉App重装。
5.4 构建配置的几个注意点
OpenHarmony上的Flutter项目构建,跟Android的Gradle体系有区别,OHOS侧使用的是hvigor构建工具。如果你之前只配过Android的Gradle,第一次看hvigorfile.ts会有点不习惯apply插件的语法风格。网上那句"you are applying flutter's main gradle plugin imperatively using the apply s..."的报错在OHOS上不会完全一样,但大意类似——插件通过命令式方式应用出了问题。
解决思路是严格按照OpenHarmony SIG提供的Flutter SDK文档来配置hvigorfile.ts,不要照搬Android项目的gradle配置。另外SDK版本这一块也要盯紧:Flutter SDK的ohos分支版本、DevEco Studio的SDK版本、以及各ohos插件的版本,三者如果对不上,编译期会出现各种奇怪的报错。我的经验是尽量锁版本,升级前先看release note。实测一个稳定组合是ok的,但你要以自己环境实际能编译通过为准。
版本匹配问题在热词里也有印证,像"flutter各个版本不对导致依赖包下不下来"这类问题,其实就是依赖的依赖里夹带了不兼容的传递依赖。排查时打开pubspec.lock看一遍,找到不兼容的包版本,手动用dependency_overrides指定一个兼容版本,就能解决。
6. 战绩数据的本地JSON导出与后续同步思路
6.1 导出完整备份为JSON文件
战绩数据是用户的心血,本地数据库万一损坏或者换设备,全没了可就亏大了。所以我在设置页加了一个"导出备份"功能:把所有战绩记录序列化成一个JSON数组,写入应用文档目录下的backup_20250101.json文件。
导出的核心逻辑其实很直接:
dart复制Future<String> exportAllToJson() async {
final rows = await db.query('battle_records', orderBy: 'battle_time ASC');
final list = rows.map(BattleRecord.fromMap).map((r) => r.toMap()).toList();
final jsonStr = jsonEncode(list);
final docsDir = await getApplicationDocumentsDirectory();
final file = File(
'${docsDir.path}/backup_${DateTime.now().millisecondsSinceEpoch}.json',
);
await file.writeAsString(jsonStr, flush: true);
return file.path;
}
导入逻辑思路完全一致,读取JSON数组后逐条insert,插入之前先判断是否已有相同battle_time和general的记录,避免重复导入。这个去重判断基于业务规则,因为实战中一场对局的时间加武将组合基本上不会完全一样。导入完成后清空缓存列表,让UI重新从数据库刷新。
备份文件放在应用私有目录虽然安全,但用户要拿到这个文件需要Root或者特殊工具,操作门槛有点高。后续可以考虑接OHOS的分享能力,让用户把备份文件直接分享到网盘或者聊天工具。这个功能留到后面做,作为计划中排名靠前的一项。
6.2 Repository模式为云同步预留伸展空间
虽然当前版本是纯离线应用,但战绩记录这种数据,用户换手机或者重新安装App的时候肯定希望能恢复,所以云同步是迟早的事。我在写代码的时候就用Repository模式把数据访问层和UI层彻底分离了。
现在的BattleRecordRepository只有本地实现,但接口的抽象已经把方法定义成insert、update、delete、query这一层。以后要接入云同步,只需要做三件事:
- 拉一个新的
RemoteBattleRecordRepository,实现同一套接口,内部走网络请求。 - 在数据库里新增一张sync_log表,记录每一条本地记录的改动时间戳和同步状态。
- 同步时从本地和服务器各拉一遍增量数据,用battle_time加设备ID生成全局唯一ID做去重合并,冲突策略暂时用last-write-wins,也就是以最后修改时间为准。
这套设计没有过度设计,因为Repository接口本身就很薄,同步的复杂度全部封装在具体实现里,页面层感知不到差异。以后就算要做多端同步,UI代码基本不用动,只要把Repository替换为带同步逻辑的装饰器版本就行。
最后补一句我个人的体会
这套战绩记录功能从头到尾在Flutter for OpenHarmony上跑通之后,我最大的感受是:OpenHarmony上的Flutter生态已经不再是"能不能跑"的问题,而是"怎么选型、怎么绕坑"的问题。数据库选sqflite_ohos,图表用纯Dart的fl_chart,状态管理用Provider,界面物理体验完全跟Android一致,剩下需要留心的就是各插件版本兼容性和设备真机调试的差异。
另外分享一个开发期的实用小技巧:我用了一个多星期的时间,每天把自己真实打的三国杀记录下来喂给App,一边用一边修细节。你会发现很多问题只有真实数据量进来之后才暴露,比如武将名长度、单手操作录入速度、统计页数据的可读性。自己当小白鼠是打磨工具型App最有效的方式。如果你也打算做类似的Flutter for OpenHarmony实践项目,建议先把自己的实际需求列成清单,再逐项实现,不要一上来就铺得太开。这样无论最后做到什么程度,拿到的成果都是自己真正用得上的东西。
