1. 项目背景与核心思路拆解
不是我矫情,手上这个“Flutter for OpenHarmony数独游戏App”的活儿,从一开始就注定不能照搬普通Android/iOS那一套。数独的核心玩法大家都不陌生:9x9宫格、行列唯一约束、难度选择、计时、记录历史成绩,这些功能本身不算复杂,真正让人纠结的,是“跑在OpenHarmony上”这件事。
OpenHarmony不是安卓,虽然它兼容Android应用框架的移植思路,但Flutter官方并没有直接把它列为一级支持平台,你要跑起来,得借助OpenHarmony的Flutter适配层(也就是社区常说的“flutter_flutter”和配套的引擎仓)。这就意味着,我们不止要写Dart代码,还要理解鸿蒙侧的平台通道怎么接、持久化插件能否复用、文件路径怎么取,这些坑不提前踩一遍,后面联调时心态容易崩。
这个项目的目标用户实际分两类。一类是给鸿蒙设备做离线单机游戏,玩家打开即玩,历史记录、用户偏好、关卡进度都必须存在本地;另一类是像我这种想验证“Flutter跨端能力在鸿蒙上到底靠不靠谱”的开发者,拿数独这种中小型应用练手再合适不过。为什么选数独?因为它的数据结构清晰(网格、线索、记录),天然适合验证持久化层的读写效率、异常恢复可靠性,又不会因为业务逻辑复杂而干扰对框架本身的观察。
我最终确定的整体思路是这样:Flutter负责UI和游戏逻辑,OpenHarmony负责提供运行环境和系统能力接口,两者通过平台通道(MethodChannel)和插件机制通信。数据持久化不做花哨的“分布式数据管理”,先老老实实把本地需求做扎实——用户偏好(主题、音效开关、棋盘难度设置)、对局状态(进行中的局面、计时、候选数)、历史成绩(用时、步数、日期、难度)三块数据分开存储。选型上,简单配置用SharedPreferences,结构化对局和历史记录用SQLite,大块不常见的备份导出再用文件IO,三层互补,刚好覆盖数独的所有场景,也方便后面扩展数据迁移和清理逻辑。
可能有人会说,OpenHarmony自带的Preferences和关系型数据库(RDB)也很好用,为什么不直接用?我的回答是:项目主体是Flutter,我们希望尽量把业务代码停留在Dart层,保持未来的可移植性。只要插件适配层封装得当,将来换回Android或iOS时,游戏逻辑和DAO层一行不用改,这个收益在跨端项目里比什么都值。
2. 环境准备与工程搭建避坑指南
2.1 工具链版本选择,别追新也别太旧
先说环境。OpenHarmony的Flutter适配目前有两条主路线:一条是使用OpenHarmony官方维护的“flutter_flutter”分支,配合对应的“flutter_engine”和“flutter_plugins”;另一条是直接用第三方厂商提供的Gitee镜像仓库集成。我给的建议是,不要直接拿flutter官方稳定版去build鸿蒙工程,因为官方版本没有集成鸿蒙的platform embedder,编译阶段就会报找不到OpenHarmony平台实现。
我当前实测可用的组合是这样:
- 操作系统:Windows 10/11 或者 Ubuntu 20.04+,Windows下建议配好git bash,很多脚本在PowerShell下会抗议。
- DevEco Studio:5.0.0 Release及以上,OpenHarmony SDK API 12。
- Flutter SDK:采用openharmony官方仓库
https://gitee.com/openharmony-sig/flutter_flutter的master分支,建议固定到一个已发布的tag上,不要天天flutter upgrade。 - Dart SDK:随Flutter SDK一起构建,不需要单独装。
- 三方插件:flutter_plugins仓库中提供的shared_preferences、sqflite、path_provider的OpenHarmony版本。
这里有一个重要的理解:OpenHarmony适配层本质上是把Flutter的embedder换成了OpenHarmony的Native接口(NAPI),中途会用到系统提供的napi模块去注册平台通道。所以你配置环境时,一定要确认NDK和Native编译工具链已经装好,否则编译flutter_engine时会在最底层报C++链接错误。
2.2 创建Flutter工程并集成鸿蒙宿主
我习惯的创建流程分三步,每一步都有坑位。
第一步,使用flutter create --org com.example sudoku_app生成标准的Dart工程。注意,工程名不能用中文,包名保持小写加下划线。
第二步,在Flutter工程根目录下创建一个ohos目录,这个目录不是flutter create自动生成的,需要自己用DevEco Studio新建一个Empty Ability工程,或者从社区的模板拷贝。我在实践中发现,直接在Flutter工程里用DevEco打开ohos目录,会识别为独立工程——实际上它的构建确实依赖独立工程配置。然后拷贝Flutter的engine产物和插件编译产物,具体配置由flutter_module对外提供的Gradle依赖完成。如果你用的DevEco版本较新,它会自动识别项目里的ohos文件夹并加载build-profile.json5。
第三步,把Flutter的PlatformView绑定给鸿蒙的Ability。打开MainAbility.kt(或者ets文件),创建一个FlutterAbility作为MainAbility的父类,并在onCreate中调用configureFlutterEngine和super.onCreate。核心代码大致如下:
class MainAbility : FlutterAbility() { override fun onConfigureFlutterEngine(flutterEngine: FlutterEngine) { super.onConfigureFlutterEngine(flutterEngine) // 可以在这里注册自定义的MethodChannel } }如果你是首次跑通,我建议先跑一遍官方demo的HelloWorld,不要直接加载数独工程。为什么?因为一旦数独工程编译失败,你根本分不清是业务代码问题、插件问题还是引擎问题。先把最简链路跑通,再逐步追加代码,排查效率会高很多。
2.3 常见初始化失败问题速查
我把这一路可能遇到的典型问题整理成表,大家直接对照处理:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 编译报找不到OpenHarmony的flutter_engine | 未配置flutter_engine依赖或使用了官方SDK | 在工程中配置openHarmony的flutter引擎仓库,确认flutter_flutter处于openHarmony分支 |
| 运行后白屏 | Ability没有继承FlutterAbility,或缺少FlutterActivity继承 | 检查MainAbility的基类,确保注册了FlutterEngine |
| MethodChannel调用无响应 | 通道名不一致,或没有在鸿蒙侧注册Handler | 统一通道名字符串,并确认“Dart端-平台端”一一对应 |
| 插件找不到实现 | 插件包没有添加鸿蒙平台的plugin实现 | 在ohos工程中添加harmonyos_plugin支持,并引入flutter_plugins中对应插件的鸿蒙版本 |
| 内存编译时报 “please use the main Gradle plugin ... apply script” | 鸿蒙构建脚本和Flutter插件版本冲突 | 按报错提示调整build.gradle,移除重复apply,或升级DevEco到新版 |
3. 本地数据持久化方案选型与数据建模
3.1 为什么不用“一把梭”的单一存储方案
很多新手拿到需求后,会图省事把所有数据都塞进SharedPreferences,或者反过来全部进SQLite。这两种做法在这个项目里都会让你别扭。SharedPreferences本质是key-value文件,写入是整体替换,复杂对象一变序列化解析就头疼,数据量稍微大一点,读写性能就很差;SQLite则偏重,你为一个音效开关、一串棋盘主题偏好而去建表、写事务、维护字段迁移,完全是杀鸡用牛刀,而且维护成本奇高。
所以我的分层策略很朴素:“配置走Preferences,实体走SQLite,归档走文件”。听起来像废话,但实际剪裁下来你会发现每种技术都刚好发挥自己擅长的那一块。
具体拆解如下:
用户偏好:包括当前难度(简单/中等/困难)、主题色、音效开关、震动开关、是否显示候选数。这些是离散的、更新频繁、单条很小的数据。用
SharedPreferences一个键值搞定,启动时异步读取,修改时同步写入,零学习成本。对局状态与历史成绩:这是结构化、关系型数据,且存在查询、排序、分页需求(比如“最近20条对局记录按用时升序”)。用SQLite数据库管理,表结构清晰,SQL表达力强,也能在版本升级时平滑增加字段。
备份与导出:玩家可能想把个人最好成绩或神秘数独题面导出成JSON文件,或者从文件导入题面。这本质是“序列化快照”,用标准文件IO配合Dart的
File写法刚刚好。
3.2 数独领域模型与表设计
在设计表之前,要先把数独的领域模型理清楚。这个游戏的核心对象有三个:网格(Grid)、线索(Hint)、对局(GameSession)和成绩记录(ScoreRecord)。为了不让存储模块变成大杂烩,我把它们拆成四张表:
| 表名 | 主要字段 | 用途 |
|---|---|---|
grid_templates | id, difficulty, puzzle_data, solution_data, generated_at | 存放生成好的题面和解法,方便难度选择时快速检索 |
game_sessions | id, difficulty, grid_id, current_board_data, notes_data, elapsed_seconds, status, updated_at | 记录进行中的对局,防止误杀进程后全部丢失 |
score_records | id, game_type, difficulty, duration_seconds, steps_count, completed_at | 保存每局完成后的成绩,用于历史列表和统计 |
user_preferences | key, value | 通用偏好存储,但实际我直接用SharedPreferences,不建表 |
为了照顾不熟悉数据库建模的读者,我额外说明一个设计决策:current_board_data和notes_data为什么不拆成行列子表?因为数独每一步操作都是全量重绘棋盘,保存对局时必须把整个19x19的数据快照(9行9列加候选数矩阵)整体序列化,拆成子表反而要多表事务、多表关联,代价大于收益。所以这里采用“单行存快照”的简单模型,一次写入,一次读取,数据量大约只有几百字节,性能完全不是瓶颈。
Dart侧的模型类也按这个结构定义:
class GameSession { final int? id; final int difficulty; final String currentBoardData; // 序列化为81字符或JSON字符串 final String notesData; // 候选数数据,JSON final int elapsedSeconds; final int status; // 0=进行中, 1=完成, 2=超时放弃 final int updatedAt; }持久化层不关心业务逻辑,只负责把模型塞进数据库,所以这里不需要什么高大上的ORM,直接用sqflite的RawQuery和insert方法就行,同时保持DAO接口抽象,方便后续替换成Drift或其它ORM。
3.3 插件选型的鸿蒙适配注意点
扛把子的是sqflite_common_ffi还是原生sqflite?我得提醒一下,在OpenHarmony上直接用默认的sqflite插件是行不通的,因为它的Android和iOS实现只响应各自平台的方法调用,在OpenHarmony上找不到原生侧的实现类。正确做法是使用社区为OpenHarmony移植的sqflite版本,或者走sqflite_common_ffi通道,后者通过FFI在Dart层直接用SQLite的C库,完全绕开平台通道,理论上在任何支持FFI的系统上都能跑,包括OpenHarmony。
这是我在项目里踩过的大坑之一。一开始图省事装了官方sqflite,编译通了,一运行就报“MissingPluginException(No implementation found for method getDatabasesPath)”。后来换成sQFlite_common_ffi,果然顺畅。所以这个项目中我的真实选型是:
- shared_preferences:使用鸿蒙适配版
shared_preferences_open_harmony(包名前缀可能不同,以实际仓库为准)。 - sqflite:使用
sqflite_common_ffi并初始化sqfliteFfiInit(),数据库路径用path_provider的鸿蒙实现获取。 - path_provider:也有鸿蒙移植版,用于获取应用文档目录
getApplicationDocumentsDirectory()。 - 文件导出导入:直接使用Dart的
dart:io,无需原生参与,最省事。
需要补充的是,sqflite_common_ffi在桌面端和OpenHarmony端都要引入sqlite3_flutter_libs或者鸿蒙对应的动态库打包方式,否则运行时会报找不到libsqlite3.so。在鸿蒙工程中需要把sqlite3的动态库手动放进libs/arm64-v8a或libs/armeabi-v7a,这一步很容易漏。
4. 数独游戏核心逻辑实现与持久化对接
4.1 棋盘数据编码与序列化
数独棋盘有标准的81格表示法,按行优先排列,每格填入1~9或者0(表示空)。项目里我直接用String保存题目盘面,比如一个简单题面看起来是这样:
530070000600195000098000060800060003400803001700020006060000280000419005000080079这个字符串既是输入给生成算法的格式,也是持久化到grid_templates.puzzle_data字段的最轻量表示。81个字符就是81格,索引i对应行i ~/ 9、列i % 9,读取时按索引切分即可。
第二种序列化格式是JSON,用于保存候选数和复杂状态。比如notes_data如果直接存二维数组太啰嗦,我会压缩成Map,键是格子索引字符串,值是候选数数组,这样只保存有候选的格子,平均下来每个对局不过几十个键,JSON体积也很小。
Map<String, List<int>> notes = {}; // 例: '4' => [1,2,3] 表示第4格可填1,2,3保存session时,直接把整个MapjsonEncode成字符串存进notes_data字段。加载时jsonDecode恢复。这里不追求极致的压缩率,数独的临时数据结构并不大,重要的是稳定性。
4.2 规则校验算法与自动填充体验
核心规则校验是数独游戏的基础:一个数字如果在其所在行、列、3x3小宫格内已存在,就不能再填入。我封装了一个SudokuValidator,输入是当前盘面(List<List >),输出是某个指定位置可填入的数字集合。
List<int> getAvailableNumbers(List<List<int>> board, int row, int col) { if (board[row][col] != 0) return []; Set<int> used = {}; // 检查行 for (int c = 0; c < 9; c++) { if (board[row][c] != 0) used.add(board[row][c]); } // 检查列 for (int r = 0; r < 9; r++) { if (board[r][col] != 0) used.add(board[r][col]); } // 检查3x3宫格 int startRow = (row ~/ 3) * 3; int startCol = (col ~/ 3) * 3; for (int r = startRow; r < startRow + 3; r++) { for (int c = startCol; c < startCol + 3; c++) { if (board[r][c] != 0) used.add(board[r][c]); } } return [1,2,3,4,5,6,7,8,9].where((n) => !used.contains(n)).toList(); }玩法上我加了一个“智能提示”功能:玩家点击某个空单元格,如果该格候选数只有一个,就直接自动填入。这个功能看似简单,却是提升新手体验的关键,同时它也能用它来验证持久化恢复的完整性:每次载入对局时重新计算一遍候选数,如果和保存的一致,就说明数据没有被破坏。这也是实践中的一个额外收益。
4.3 自动保存与恢复机制实现
不是所有玩家玩完一整盘棋都会主动点保存。如果用户切到后台、杀进程、或者突然来电接了个电话,回来发现重来一遍,那这种产品体验基本可以判死刑了。所以我实现了“自动保存+启动恢复”两条链路。
自动保存触发时机有四个:每完成一次有效填入、每切换一次难度、应用进入后台(通过AppLifecycleState.inactive监听)、计时器每满30秒。保存方法如下:
Future<void> saveCurrentSession(GameSession session) async { final db = await getDatabase(); await db.insert( 'game_sessions', session.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); }注意conflictAlgorithm: ConflictAlgorithm.replace这句。由于每一局只有一个进行中的session,我们可以在启动新局时把旧session的status标记为“中断”,然后插入新的一行,这样做可以保留用户多局尝试的历史,而不是粗暴覆盖。
启动恢复的逻辑放在main.dart的initState里,先读数据库,再判断是否存在status为“进行中”的session,如果有就恢复到棋盘。这个流程必须做成异步,并且要在UI首帧渲染完成后再执行,否则用户会看到一个闪现的空白棋盘,很不雅观。我用Future.delayed(Duration(milliseconds: 200))来延迟恢复,或者更优雅的做法是让启动页先显示Logo,数据加载完成后再跳转游戏页。
4.4 本地持久化的线程与异步处理
刚接触Flutter的朋友容易有一个误区:数据库操作是不是必须在子线程?其实sqflite内部已经通过Isolate把数据库操作放到后台了,我们在Dart层写的db.insert和db.query都是Future,不会卡UI线程。但在OpenHarmony上因为FFI的关系,数据库原生部分跑在C侧,Dart侧异步机制依然有效,所以开发者不需要自己再new一个Isolate。
但是要注意一个纪律:不要在UI构建过程中直接同步访问数据库。build方法里只允许依赖内存中的模型状态,所有数据库读写都要放在事件回调里。我见过很多新手把getDatabasePath写在build里,结果就是界面反复重建时反复打开数据库,轻则卡顿,重则数据库文件句柄泄漏。正确做法是使用一个单例数据库管理器:
class AppDatabase { AppDatabase._(); static final AppDatabase instance = AppDatabase._(); Database? _db; Future<Database> get database async { if (_db != null) return _db!; final dir = await getApplicationDocumentsDirectory(); final path = p.join(dir.path, 'sudoku.db'); _db = await openDatabase(path, version: 1, onCreate: _onCreate); return _db!; } }p是path包,导入package:path/path.dart为p。如果你不想额外加path包,就用字符串拼接路径,但你会在跨平台时陷入分隔符的烦恼,还是建议加上path。
5. 用户偏好与历史成绩的实操落地
5.1 SharedPreferences读写的最佳实践
在Flutter中,SharedPreferences的鸿蒙适配版本和Android用法几乎一样。先初始化再读取,可以在main中提前执行一次WidgetsFlutterBinding.ensureInitialized(),然后await SharedPreferences.getInstance()。我在项目中封装了一个PrefsManager,把所有key以常量形式放一个类里,避免魔法字符串写得到处都是。
class PrefKeys { static const String difficulty = 'pref_difficulty'; static const String themeIndex = 'pref_theme_index'; static const String soundEnabled = 'pref_sound_enabled'; static const String hapticEnabled = 'pref_haptic_enabled'; static const String showNotes = 'pref_show_notes'; }写入时直接prefs.setInt(PrefKeys.difficulty, 2),读取时注意要给默认值:难度默认简单(0),主题默认0(浅色),音效默认true。存布尔值要注意,老版本鸿蒙适配层对setBool支持可能不够稳定,如果遇到类型转换异常,就改用setInt(1,0),我在一个低版本设备上就遇到过这个问题,所以这里特别提醒。
SharedPreferences在API 12鸿蒙上底层实际上是轻量级偏好数据库,它的写入是异步落盘的。官方文档说它不保证强一致,所以我们不要在桌面保存后立刻强杀进程来验证数据,真要验证一致性,应该通过正常界面重启。
5.2 SQLite建表与成绩查询排序
数据库版本管理也是容易被忽略的点。openDatabase的version参数和onCreate回调是配套的,未来如果给sessions表加一个play_count字段,就要把version改成2,并实现onUpgrade。目前项目版本设为1,建表语句如下:
Future<void> _onCreate(Database db, int version) async { await db.execute(''' CREATE TABLE grid_templates( id INTEGER PRIMARY KEY AUTOINCREMENT, difficulty INTEGER NOT NULL, puzzle_data TEXT NOT NULL, solution_data TEXT NOT NULL, generated_at INTEGER NOT NULL ) '''); await db.execute(''' CREATE TABLE game_sessions( id INTEGER PRIMARY KEY AUTOINCREMENT, difficulty INTEGER NOT NULL, grid_id INTEGER, current_board_data TEXT NOT NULL, notes_data TEXT NOT NULL, elapsed_seconds INTEGER NOT NULL, status INTEGER NOT NULL, updated_at INTEGER NOT NULL ) '''); await db.execute(''' CREATE TABLE score_records( id INTEGER PRIMARY KEY AUTOINCREMENT, game_type INTEGER NOT NULL, difficulty INTEGER NOT NULL, duration_seconds INTEGER NOT NULL, steps_count INTEGER NOT NULL, completed_at INTEGER NOT NULL, is_best INTEGER NOT NULL DEFAULT 0 ) '''); }历史页面的查询我做得比较讲究。玩家希望看到的不只是按时间倒序的一条条记录,而是一个“个人最佳”的展示。我写了一条聚合SQL:
SELECT difficulty, MIN(duration_seconds) AS best_duration, COUNT(*) AS total_games FROM score_records GROUP BY difficulty;这条查询会返回每个难度下的最快用时和总局数,用于在首页展示三个难度徽章。如果性能敏感,可以为difficulty字段建索引,不过数独App的数据量短时间内撑不到需要索引的级别,这里就不过度设计了。
5.3 用Repository统一数据入口
为了让UI层不直接和数据库、Preferences打架,我在中间加了一层Repository,提供高层API给页面调用:
class GameRepository { final AppDatabase db = AppDatabase.instance; final PrefsManager prefs = PrefsManager.instance; Future<void> saveGameSession(GameSession session) async { final database = await db.database; await database.insert('game_sessions', session.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); prefs.lastSessionId = session.id; } Future<GameSession?> loadResumeSession() async { final database = await db.database; final list = await database.rawQuery('SELECT * FROM game_sessions WHERE status=0 ORDER BY updated_at DESC LIMIT 1'); if (list.isEmpty) return null; return GameSession.fromMap(list.first); } Future<List<ScoreRecord>> getRecentScores({int limit = 20}) async { final database = await db.database; final rows = await database.query('score_records', orderBy: 'completed_at DESC', limit: limit); return rows.map(ScoreRecord.fromMap).toList(); } }这样,游戏页只依赖future或stream,不需要关心数据库细节。UI和存储解耦后,我想要把底层换成Drift或ObjectBox,只需要改Repository内部实现即可,这是工程上非常划算的一笔投资。
6. 数独生成与唯一解判定要点
6.1 随机挖洞法的实现思路
数独题目怎么来?最简单可靠的方案是“随机填充法生成完整终盘 + 按难度挖洞”,而不是直接随机生成一个残缺盘面再校验唯一解。这里我直接复用了经典算法,步骤为:
- 从空棋盘开始,通过回溯算法生成一个随机完整解。
- 对完整解进行随机行交换、列交换、宫交换,够换几次后,盘面看起来就是一张“新题”。
- 初始完全解中的部分格子按难度比例挖成0,每次挖洞后调用唯一解判定函数,保证只剩一个解。
- 将挖洞后的题面和原始解分别存入grid_templates,完成题目存储。
很多人会问,为什么不直接用现成题库呢?因为题库数据是固定的,玩家反复玩会背下答案。而动态生成可以无限供应新题,配合本地数据库复用最近生成的题,也能降低计算频率。这里的成本是算法代码量稍大,但核心也不超过200行。
唯一解判定我用的是“计数解法”——对某个挖洞后的盘面进行DFS求解,但一旦找到第二个解就立即返回。由于数独盘面约束强,很少出现迷之状态,所以速度可以接受。对于极其复杂的盘面,额外加了一个解数上限参数:只数到2,遇到第二个解就剪枝,时间复杂度可控。
6.2 题面入库与随机出题的效率平衡
生成好的题面不能每次都生成,否则玩家等待时间会有明显波动。我的策略是:第一次启动时生成约50题分难度缓存,之后每次玩完一局,后台异步再补一题。这样每次进入新游戏时,直接读本地库取一题没玩过的,体验非常流畅。
对应的入库操作如下:
Future<void> insertGridTemplate(GridTemplate tpl) async { final database = await db.database; await database.insert('grid_templates', tpl.toMap()); } Future<GridTemplate?> fetchUnusedGrid(int difficulty) async { final database = await db.database; final rows = await database.rawQuery( 'SELECT * FROM grid_templates WHERE difficulty=? AND id NOT IN (SELECT grid_id FROM game_sessions WHERE status=1) ORDER BY RANDOM() LIMIT 1', [difficulty], ); if (rows.isEmpty) return null; return GridTemplate.fromMap(rows.first); }ORDER BY RANDOM()在数据量小于几千行时性能极佳,数独App用到这个量级完全足够。等以后题库大起来,再换成按权重轮询策略也不迟,现在就不要过度优化了。
7. 常见问题与排查技巧实录
7.1 数独App特有的存储问题
问题一:恢复对局时棋盘闪现初始状态,然后才跳到保存的局面。这个现象看起来像数据没保存成功,其实是异步恢复太慢导致的。解决办法有两种,一是启动页期间的Future.wait里先加载session再跳转;二是恢复期间给一个半透明的loading遮罩,数据完成后切换。如果追求视觉上的无缝恢复,建议把数据读取提前到main()中初始化完成,再运行App。不过这样启动时间会略微增加,权衡之后我选择用遮罩方案。
问题二:候选数数据在持久化后丢失。排查后发现是JSON编码时把Map<int, List<int>>的key转成了字符串,恢复时没有解析回int,结果按字符串key读格子索引时全部失配。解决办法是在fromMap中统一int.parse(key),并且写入之前把索引手动转换为字符串。这个坑很隐蔽,但只坑一次就记住了。
问题三:在OpenHarmony设备上数据库文件路径找不到。我建议直接调用getApplicationDocumentsDirectory()打印一下路径,而不是靠猜。打印出来后会发现路径在/data/app/el2/100/base/com.example.sudoku/haps/main/files/documents/之类的位置,不同设备或API版本可能不同,所以永远不要硬编码路径。
7.2 插件与鸿蒙通道的排查通用技巧
方法通道没反应时,先打开日志过滤关键字“MethodChannel”和“Plugin”。在鸿蒙侧,系统日志用hilog,Dart侧的print默认打到flutter进程的stdout,不一定会出现在DevEco控制台,建议用FlutterUtils.dPrint之类的封装统一打点。我遇到的另一个诡异的问题是通道方法名大小写不一致,Dart侧写“getDBPath”,鸿蒙侧写成“getDbPath”,结果MethodChannel的Miss结果异常非常难查。所以通道名称和参数格式建议集中定义在一个常量文件里,Dart侧和Kotlin/TS侧都用同一个常量,不要各写各的。
7.3 性能调优实测记录
实测中我记录了OpenHarmony设备(API 12,ARM64)上关键操作耗时:
| 操作 | 平均耗时 |
|---|---|
| 应用启动到棋盘显示 | 约1.6秒 |
| 自动保存一局进度(序列化+SQLite写入) | 约12ms |
| 加载历史记录20条 | 约35ms |
| 读取偏好设置 | 约5ms |
这里可以看到,真正消耗时间的是引擎初始化和界面渲染,本地持久化的耗时完全控制在可接受范围。如果启动时间太长,可以考虑在正式布局前先用简单的Splash图撑住首帧,数据读取过程与首帧开始并行,等数据ready再更新状态。
7.4 数据迁移与清理策略
随着玩家跨越多个版本,数据库表结构可能要变更。我在AppDatabase中增加了onUpgrade的处理,预留了ALTER TABLE的迁移空间。有一个小细节:sqflite_common_ffi在Windows和OpenHarmony上的事务行为略有差异,OpenHarmony的SQLite版本较新,默认WAL模式,查询时如果在写入事务中,读到的可能是旧快照。这时候不要慌,可以用PRAGMA journal_mode=WAL或直接保证写入和读取都走同一个Database实例而不是重新打开,就能规避大部分并发问题。实际项目中,单用户数独App并发访问压力极小,这一条了解即可。
8. 最终实操复盘与后续扩展经验
说了一大堆,其实这个项目最核心的收获不是“做完了游戏”,而是摸清了Flutter在OpenHarmony上的家庭边界。OpenHarmony的Flutter适配已经可以支撑中小型工具类应用,但Plugin生态还不像Android那么完善,遇到缺实现的插件怎么办?无非三条路:找官方移植版、用FFI绕过平台通道、自己写鸿蒙侧插件。其中自己写插件需要熟悉OpenHarmony的NAPI和Ability生命周期,工作量不低,但这也是跨端开发的进阶必修课。
回头看我踩过最大的一个坑,还是数据库路径的那一步。如果验收时发现错误,第一反应往往不是路径问题,而是去怀疑SQL语句或构造函数,其实先打印路径能少走一小时弯路。其次是不要在真机上用debug模式直接测存储,因为热重载重置Dart isolate,可能导致数据库句柄状态不可控,测试时应使用flutter run --release模式。
项目还可以继续扩展的方向也有不少。比如把数独的题库定期从服务端拉取,更新到本地数据库;或者增加一个“每日挑战”功能,每天固定一个题面ID,答题成绩上传到后端。这些都需要在数据模型层提前预留字段,比如给grid_templates增加source_type和daily_date字段。不过这里要克制,不要一上来就把所有功能都堆进去,先把核心持久化链路做稳,比什么都重要。
最后再说一个操作细节:当你准备在鸿蒙设备上卸载重装应用时,旧的数据库目录不会被彻底清空,因为OpenHarmony的默认卸载策略可能保留数据恢复目录。如果开发中遇到“装完后还能看到旧成绩”,不要震惊,去系统设置里的应用管理,清除数据后再验证。我因为这个问题曾一度怀疑SQLite的持久化失效,后来才发现是鸿蒙系统的数据保留机制在起作用。
希望这篇内容能帮同样在踩鸿蒙Flutter坑的同学省点时间。如果你也正在做类似的本地数据持久化方案,建议先把分层思路理清楚,再去动手写代码,整体会顺畅很多。