OpenHarmony上Flutter数独App:本地数据持久化实战

发布时间:2026/10/2 18:33:52
OpenHarmony上Flutter数独App:本地数据持久化实战 1. 项目盘点在OpenHarmony上跑Flutter先把账算清楚1.1 这个App到底要做什么先把这个项目的边界说清楚这不是一个纯ArkUI的鸿蒙原生应用也不是一个跑在Android套壳环境里的普通Flutter应用而是直接让Flutter引擎跑在OpenHarmony系统上用同一套Dart代码完成数独的生成、游戏、存档和恢复。标题里三个关键词其实对应三层问题Flutter解决的是跨端UI和业务逻辑的复用OpenHarmony解决的是目标运行环境的适配本地数据持久化解决的是“玩家关掉App再打开游戏进度不能丢”。选数独作为实战载体是因为它的逻辑闭环特别适合展示一个完整App的方方面面数独终盘生成涉及算法设计挖洞和唯一解校验涉及递归回溯和剪枝游戏界面涉及手势和自绘棋盘存档读档涉及序列化和存储策略。这些正好把Flutter在OpenHarmony上从UI到能力再到平台桥接的链路全部串起来了。比起那种只写一个“你好世界”或者一个列表页的Demo数独的复杂度能逼你把工程结构、状态管理和持久化真正做扎实。对想入门Flutter鸿蒙开发的人来说这个项目也是比较友好的切入点。你不需要一上来就碰复杂的原生插件数独的多数逻辑都在纯Dart层完成只有存档和系统交互会涉及平台能力调用踩坑范围可控。1.2 环境与工具链准备OpenHarmony上的Flutter开发工具链和Android/iOS有一些区别。当前比较稳妥的路线是OpenHarmony SDK Flutter官方维护的鸿蒙适配分支或社区维护的ohos版本 DevEco Studio作为IDE。纯命令行也能做但DevEco的向导能省掉不少工程配置的麻烦。实际操作的时候工程初始化有两种方式一种是从DevEco Studio里新建工程后手动加入Flutter模块另一种是用Flutter命令行直接生成带ohos平台的工程骨架。我建议优先用命令行生成这样目录结构和Gradle同步都比较干净。初始化完成之后工程里会有ohos目录这是Flutter适配层为OpenHarmony生成的宿主工程和android、ios目录是同一层级的兄弟关系。这一步最容易踩的坑是SDK版本和编译链版本不匹配。OpenHarmony的SDK版本、Flutter适配分支的版本、以及DevEco的版本三者的对应关系如果不一致编译的时候会出现各种诡异报错比如“找不到OpenHarmony platform”或者NDK版本冲突。我的建议是装项目模板里默认锁定的版本不要盲目升级等整个项目跑通之后再考虑升版本。1.3 为什么选Flutter而不直接上ArkUI这个问题不是抬杠而是真正做技术选型时绕不开的。ArkUI作为鸿蒙的原生声明式框架配合方舟编译器性能和对系统能力的调用深度上百没有任何问题。但Flutter在这个场景下的核心价值是一套UI代码同时覆盖OpenHarmony、Android、iOS三个平台。如果你团队里已经有现成的Flutter数独实现或者未来有计划做多端发布Flutter的长期维护成本优势会非常明显。另一个实际优势是Flutter的渲染层自成一派。Flutter使用自研引擎Skia/Impeller直接绘制UI不依赖系统组件树。这意味着在OpenHarmony上Flutter页面的观感和交互表现和Android上几乎是完全一致的你不需要针对鸿蒙的组件规范重新调整UI细节。代价是包体积会比ArkUI原生应用大一些以及部分系统级能力需要通过平台通道Platform Channel手动桥接。数独这类游戏App对这种工程复杂度的容忍度很高选Flutter的收益是大于成本的。2. 先把数独核心逻辑做扎实2.1 终盘生成一次Fisher-Yates加三组行变换一个数独游戏的起点是要有一个合法的终盘即每一行、每一列、每个九宫格都包含1到9且不重复。生成终盘最省事的方式是先写好一个标准的、用数字1到9逐行排列的合法终盘基盘然后对基盘做有限范围内的行列置换因为置换不会破坏合法性。实际写代码的时候我会分三步走。第一步用Sattolo算法Fisher-Yates的一种变体要求生成的排列是完整循环排列对1到9这组数字做一个随机排列作为基盘的第一行。第二步基于第一行用“三行组内循环位移”的方法生成后续八行。所谓三行组就是第1~3行、第4~6行、第7~9行这三个组每个组内的行之间错位3格循环平移组与组之间错位1格或2格循环平移。比如第一行是[3,1,4,2,6,5,8,7,9]第二行就是它左移3位后的结果[2,6,5,8,7,9,3,1,4]第三行再左移3位。第四行则是第一行左移1位第五行左移4位依次类推。第三步再随机交换整个三行组的顺序就得到了一个随机性足够强的终盘。Listint _shift(Listint row, int offset) { final result Listint.filled(9, 0); for (int i 0; i 9; i) { result[i] row[(i offset) % 9]; } return result; } ListListint generateFullBoard() { var firstRow List.generate(9, (i) i 1); // Sattolo打乱确保不是平凡排列 for (int i 8; i 0; i--) { final j Random().nextInt(i); // 注意j取0到i-1 final tmp firstRow[i]; firstRow[i] firstRow[j]; firstRow[j] tmp; } final rows Listint[]; rows.add(List.of(firstRow)); rows.add(_shift(firstRow, 3)); rows.add(_shift(firstRow, 6)); rows.add(_shift(firstRow, 1)); rows.add(_shift(firstRow, 4)); rows.add(_shift(firstRow, 7)); rows.add(_shift(firstRow, 2)); rows.add(_shift(firstRow, 5)); rows.add(_shift(firstRow, 8)); return rows; }这里有一个特别容易翻车的地方换行时使用位移动量不能乱来。组内位移必须是3的倍数否则会破坏每个宫格内的数字分布导致虽然行和列不重复但宫格内重复。组间位移则避开3的倍数否则整个棋盘会出现列方向上的规律虽然合法但很无趣。我最初写的时候就是没注意这个细节生成出来的终盘有三分之一每次列内重复排查了很久才发现是位移量的锅。2.2 挖洞造题与唯一解校验终盘生成之后题目造不出来等于白做。数独题目的核心矛盾是洞挖得越多越难但洞太多解不唯一玩家填到一半发现两种答案都有理整个游戏就崩了。所以挖洞之后必须做唯一解校验。挖洞通常用一个贪心过程随机挑一个格子把它挖掉然后用求解器检查当前局面是否仍然有唯一解如果有就继续挖下一个如果没有就把这个格子恢复原状换一个格子试。这个过程迭代到挖洞数量达到目标为止。唯一解校验的求解器用回溯加剪枝就够了。数独棋盘只有81格用人类策略去优化求解器完全没有必要。回溯的写法是找一个候选数最少的空格MRV启发式依次尝试填入候选数递归求解记录解的数量。只要解数超过1就立刻剪掉这样通常能在几十毫秒内判定“是否唯一”。int solveCount(ListListint board, int limit) { // 找到候选数最少的空格 var bestRow -1, bestCol -1, bestCandidates 10; for (int r 0; r 9; r) { for (int c 0; c 9; c) { if (board[r][c] ! 0) continue; final candidates _candidates(board, r, c); if (candidates.length bestCandidates) { bestCandidates candidates.length; bestRow r; bestCol c; if (bestCandidates 1) break; } } } if (bestRow -1) return 1; // 所有格子已填满找到1个解 var count 0; for (final value in _candidates(board, bestRow, bestCol)) { board[bestRow][bestCol] value; count solveCount(board, limit - count); board[bestRow][bestCol] 0; if (count limit) return count; } return count; }核心点只有一个一旦解的数量超过你设定的上限这里传1就行立即返回不要再继续递归。不少新人会把整个解空间都搜完再判断虽然正确但白白跑了几百倍的无效计算生成一道困难题可能要等好几秒体验非常差。加了提前剪枝之后生成一道中等难度题通常能在300毫秒内完成足够应对运行时动态出题的需求。2.3 难度的三种控制方式难度控制没有银弹实际工程里通常是三个参数联动挖洞数量、挖洞方式和对称性。简单题一般挖35~40个洞中等题40~45个困难题45~60个。但挖洞数量40的题也可能比挖洞数量45的题更难因为剩余格子的分布和候选数的宽度不一样。我建议在挖洞循环里控制成一个“螺旋下降”的策略每一轮先随机挑一个候选数比较少的格子去挖因为这种格子是玩家最容易依靠逻辑推理确定的挖掉它能降低难度如果某格挖完之后唯一解校验通过但解的唯一性依赖于大量回溯推理也就是该格在求解器里被尝试了很多次那就意味着这个洞对玩家来说很难这类洞要控制比例。为了简单我通常不精确控制每个格子的推理强度而是用一个粗略指标动态调整挖洞目标值挖洞过程中一旦发现某次唯一解校验时间明显偏长比如超过200毫秒就撤销这次挖洞并降低目标值。对称性方面绝大多数商业数独App都会做旋转对称挖洞你挖掉(r, c)同时挖掉(8-r, 8-c)。这纯粹是为了视觉美观和操作手感从数学上讲不影响难度但从产品角度看很重要玩家对不对称的题目会有一种“这是随机乱挖的”不信任感。2.4 游戏状态机的数据模型设计数独游戏的状态比普通小游戏稍微多一点当前棋盘谜题本身也就是初始给出的数字玩家的输入历史用于撤销和重做计时器记录的错误次数以及游戏是否完成。这些状态如果在页面上随手定义后面做持久化的时候就会变成一场灾难。我建议一开始就定义一套干净的数据模型。棋盘用一个ListListint表示题目和当前状态分开谜题格子在UI里是不可编辑的需要记录每格是“题目格”还是“玩家格”输入历史用两个栈一个撤销栈一个重做栈栈里的元素是(row, col, 旧值)这样的轻量结构。游戏回合用枚举表示PLAYING、PAUSED、FINISHED。class SudokuGame { final ListListint puzzle; ListListint current; final Setint givenCells; // 编码为 r * 9 c final ListMove undoStack []; final ListMove redoStack []; int mistakes 0; int elapsedSeconds 0; GameStatus status GameStatus.playing; bool get isFinished _isSolved(current); }这套模型之所以重要是因为它后面直接映射到JSON序列化。你持久化的是什么恢复出来的就是什么。如果状态散落在各个Widget里冷启动恢复时还得靠“把UI重新操作一遍”来还原那持久化就白做了。3. 本地数据持久化把存档写进OpenHarmony的存储层3.1 选型shared_preferences还是SQLite本地数据持久化是这个项目标题里的重头戏所以选型一定得讲明白。Flutter生态里最常用的本地存储工具是shared_preferences插件它在Android上基于SharedPreferences在iOS上基于NSUserDefaults在OpenHarmony上基于系统提供的轻量级键值存储。数独游戏需要保存的数据量非常小一份棋盘加上统计信息序列化成JSON通常不超过几KB用键值存储完全够用而且读写都是异步的比SQLite这种重方案简单太多。但这里有个底线不要用shared_preferences保存游戏过程的每次落子。如果你每填一个数字就调用一次setString某些版本上会遇到写入次数频繁导致的性能问题。正确做法是游戏过程中把状态放在内存里只在特定时机切后台、游戏完成、暂停、退出做整块写入。这样一次游戏下来存盘次数通常只有几次到十几次既满足恢复需求又不给存储层徒增压力。SQLite对应Flutter里的sqflite鸿蒙上也有适配版本在这个项目里属于“杀鸡用牛刀”。如果你未来想做联网同步、多局游戏历史列表、复杂度高的数据查询SQLite才是正确选择。数独App里对查询的需求几乎为零加载存档就是“读一个key”没有必要引入数据库代价。3.2 数据模型与JSON版本管理持久化最忌讳的事情是直接在代码里散落一堆prefs.setString(time_elapsed, ...)之类的调用。字段多了之后每次改动要么漏掉迁移要么版本兼容性爆雷。我把存档拆成三层UserSettings设置项难度偏好、音效开关、主题、GameProgress进行中的一局棋盘、撤销栈、计时器、错误数、GameStat历史统计总完成局数、最佳成绩、累计用时。序列化格式我用JSON字段命名用snake_case还是camelCase无所谓关键是加一个schemaVersion字段。schemaVersion解决的是未来版本升级问题假设你从V1升到V2新增了一个“提示次数”字段旧存档没有这个字段如果恢复时直接按新模型解析老用户加载就会崩。有了版本号就能写一个迁移函数逐版本升级数据格式而不是直接解析到最后版本。class GameProgress { static const int schemaVersion 1; final int version; final String difficulty; final ListListint puzzle; final ListListint current; final Listint givenCells; final ListMove undoStack; final ListMove redoStack; final int mistakes; final int elapsedSeconds; final String savedAt; MapString, dynamic toJson() { version: schemaVersion, difficulty: difficulty, puzzle: puzzle, current: current, givenCells: givenCells, undo: undoStack.map((m) m.toJson()).toList(), redo: redoStack.map((m) m.toJson()).toList(), mistakes: mistakes, elapsedSeconds: elapsedSeconds, savedAt: savedAt, }; factory GameProgress.fromJson(MapString, dynamic json) { // 先检查version再决定解析路径 final v json[version] as int? ?? 1; if (v schemaVersion) { throw FormatException(unsupported schema version: $v); } // 从V1开始逐级升级 return _migrateV1(json); } }有经验的同学可能注意到我不直接用fromJson内部做迁移而是抽了_migrateV1这样一个函数入口。这个设计就是为以后V2、V3留的楼梯口每次新增版本时新增一个迁移函数旧数据逐级往上搬永远不允许跳到未来版本。3.3 自定义UserRepository封装与异步事务写入数据访问我习惯封装成一个UserRepository对外暴露的接口只和业务语义打交道比如FutureGameProgress loadProgress()Futurevoid saveProgress(GameProgress progress)内部才接触SharedPreferences或者文件读写。这样做的直接好处是以后想从shared_preferences切到文件存储或数据库业务代码一行不用改。SharedPreferences在鸿蒙适配版本上有一个使用细节启动后建议先调用一次getInstance()再操作数据避免插件通道未就绪时的竞态。另外官方建议不要在主Isolate频繁同步调用我实测在鸿蒙真机上偶发卡顿所以统一走async接口。写入策略上我做了三件事。第一内存快照游戏过程中任何状态变更只改内存对象不自动落盘。第二写盘节流如果距离上次写盘不到500毫秒则等到下一帧或AppLifecycleState.paused时再写。第三事务保证所有键的写入放在一个Dart Future序列里依次执行而不是并发调用。class UserRepository { static const _progressKey game_progress_v1; SharedPreferences? _prefs; bool _dirty false; Timer? _debounce; Futurevoid init() async { _prefs await SharedPreferences.getInstance(); } Futurevoid saveProgress(GameProgress progress) async { _dirty true; _debounce?.cancel(); _debounce Timer(const Duration(milliseconds: 500), () async { if (_dirty _prefs ! null) { await _prefs!.setString(_progressKey, jsonEncode(progress.toJson())); _dirty false; } }); } }为什么整块写入而不是只改几个字段因为棋盘和撤销栈的数据结构决定了它们之间强耦合如果你只更新current棋盘而不更新撤销栈恢复后玩家一点撤销棋盘和栈就对不上了。与其维护字段级的一致性不如整个GameProgress作为一个不可变单元整体写入简单而且不可能出现半新版半旧版的脏状态。3.4 多局记录与排行榜数据的写入细节数独App一般还要保留历史对局记录和排行榜Stats页显示“历史最佳时间”“累计完成局数”“按难度分的胜率”。这些数据量也不大一个GameStat模型就能装下。我建议在UserRepository里单独开一个key存储统计信息不要混进游戏进度里因为统计信息需要每次完成游戏后更新一次而游戏进度在每局中途也会写入两者混在一起会导致无谓的写放大。统计更新的逻辑里要处理一个易错点玩家可能会重玩同一道题每完成一局肯定要更新累计局数但要区分“这个难度下是否打破个人最佳”。我在updateStats函数里分两步走先读旧统计再根据新成绩决定是否替换最佳成绩最后整体写回。这里如果省掉“先读旧值”的步骤直接覆盖排行榜就变成只纪录最近一局了。注意OpenHarmony端如果将来要支持多身份或多端云同步SharedPreferences这套方案就不够用了需要迁移到关系型数据库或分布式数据服务。但作为单机版数独保持简单依然是最高优先级。4. 页面重建与状态恢复Navigator和数据的配合4.1 切页面状态丢失的前因后果Flutter里Navigator.push之后上一个页面的State其实还在Widget树里只是被新页面盖住了。正常情况下返回之后输入框的文字、滚动位置都还在。但我在不少项目里看到“切换页面后状态丢失”的抱怨原因五花八门有的把页面内容放在FutureBuilder里每次setState都会重新执行Future导致数据重新加载有的在页面build里直接调SharedPreferences.getInstance()通道没缓存每次重建都要重新初始化还有的是用了全局GlobalKey之后页面被Navigator和TabBarView两条路径重复管理State生命周期被打乱了。在数独这个项目里这个问题具体表现为玩家切到设置页再返回如果中途因为系统内存压力导致当前页面被销毁重建棋盘进度可能丢失。Flutter框架不会自动帮你保存局部State它默认认为页面还在内存里。所以双保险很重要内存缓存一份磁盘持久化一份。恢复时优先用内存缓存内存没有才走磁盘恢复。4.2 冷启动恢复与“继续游戏”冷启动恢复是持久化价值的直接体现。App启动后首页会有“新游戏”和“继续游戏”两个入口。“继续游戏”的按钮是否可用取决于UserRepository.loadProgress()是否返回了有效的GameProgress。这一步不建议在启动页的initState里同步等待IO因为启动性能会受影响。我建议在启动页先渲染框架用FutureBuilder或一个Future在后台加载加载完再回调更新按钮状态。恢复流程里有一个细节加载到的存档需要校验合法性包括棋盘尺寸、数字是否都在1到9范围内、解锁栈元素坐标是否越界、givenCells是否和puzzle中的非零位置一致。这种校验看起来多此一举但实际能挡住非常多的崩溃。比如旧版本写入的存档字段名变了解析时字段缺失直接调用toJson对应的反向解析就会在某个空值上抛异常。异常处理我建议分级加载失败时不弹出错误框而是静默清除存档并让用户开新局记录一条日志方便开发阶段定位。生产环境里弹一个“存档损坏”的框对用户来说毫无价值除非你打算做数据修复功能。4.3 关闭应用重启后恢复问题的处理“继续游戏”还有一个隐藏场景玩家点了关闭但系统并没有真正杀死进程下次点击应用图标时其实是恢复了之前进程Flutter引擎可能都没有重启。这个时候如果只依赖磁盘存档会出现一个很尴尬的现象——内存里还是旧进度磁盘上也是旧进度全局都一致看起来没什么问题但假如系统在玩家关闭应用前的最后一刻没有触发paused生命周期内存里的最新进度没落盘那恢复出来的反而是更早一版的棋盘玩家会感觉自己填的数字丢了。解决这个问题的关键是尽量保证“状态变更即落盘兜底”在每次游戏暂停、完成、切后台时都主动保存同时利用WidgetsBindingObserver监听AppLifecycleState.paused在生命周期回调里再保存一次。这两个保存时机相互重叠不可怕因为写入是幂等的读出来永远是同一个数据。真正可怕的是连一个保存时机都没有触发那就只能在每次落子时做了代价是频繁写盘我不推荐在纯单机游戏里这么干。5. 和OpenHarmony平台能力对接Channel与PlatformView的实战经验5.1 EventChannel传外部事件到Flutter侧纯粹的数独游戏其实不太需要原生能力但如果要接入系统级的生命周期事件、外部输入事件比如手柄按键、实体键盘的数字键就需要和OpenHarmony侧做通信。Flutter在这套体系里提供三种通道MethodChannel用于Flutter调用原生方法EventChannel用于原生向Flutter侧推事件流BasicMessageChannel用于双向消息传递。数独场景里实体键盘输入是个不错的例子鸿蒙原生层捕获了按键事件通过EventChannel推给Flutter侧Flutter侧再把数字填到选中的格子里。EventChannel _keyEventChannel const EventChannel(com.example.sudoku/key_event); void _listenNativeKeys() { _keyEventChannel.receiveBroadcastStream().listen((event) { if (event is int _selectedCell ! null) { _handleDigitInput(event); } }, onError: (Object e) { debugPrint(key event channel error: $e); }); }EventChannel有一个特点它是“有状态”的流原生侧如果没有事件就会一直挂起这块不会自动重连。页面销毁时一定要记得取消订阅或者自然断开否则会留下一个半开的事件流导致页面重建后收到重复事件。这个现象在调试时不容易发现但在真机长时间使用后会越来越明显。5.2 PlatformView嵌入原生组件数独用不用得上PlatformView大多数情况用不上但如果要做成绩分享图片的“原生保存相册”联动或者某些鸿蒙系统特性组件比如系统级字体选择器需要嵌入到Flutter页面里就得靠PlatformViewLink或UiKitView/AndroidView对应的鸿蒙实现。我实际试过在OpenHarmony上用PlatformView嵌入一个原生封装的画笔组件。那些想把这个功能接进数独App做“标注笔记”的场景我建议优先考虑用Flutter自绘解决而不是原生组件。原因很简单Flutter的PlatformView在鸿蒙适配版的性能还没到成熟的Android水平嵌入原生组件后会导致Flutter侧的触碰事件和原生侧的坐标转换出现偏差尤其在快速拖动时非常明显。除非原生组件提供的功能Flutter侧确实无法实现否则别给自己找麻烦。如果说要做的是把成品棋盘截图保存到相册那根本不需要PlatformView用Flutter的RepaintBoundary把Widget渲染成图片再通过MethodChannel调用原生相册接口即可。这样既绕开了组件嵌入的坑又保留了原生能力。6. 常见问题与打包血泪排查6.1 打包报错Gradle插件与版本地狱打包阶段最经典的报错就是这个You are applying Flutters main Gradle plugin imperatively using the apply script...这个报错的根源是老版Flutter的Gradle插件是通过apply script方式引入的而新版本AGPAndroid Gradle Plugin 8.0改成了声明式插件plugins {}块两者混用就冲突。鸿蒙适配分支同样受这个影响。解决办法很直接打开ohos或android目录下的settings.gradle和根build.gradle把apply改为plugins {}或者确认仓库里是否有适配分支提供的迁移脚本不要硬着头皮改版本号因为Flutter对Gradle和AGP的版本组合相当挑剔。另一个高频打包问题是构建缓存损坏报类似could not close input stream的AssertionError。这种问题绝大多数不是代码原因而是Gradle缓存或中间产物状态异常直接flutter clean加删除~/.gradle/caches里的相关任务再重新构建基本都能解决。如果还不行检查一下签名配置和仓库地址能否被当前网络访问。6.2 渲染和性能相关的坑Impeller是Flutter 3.7以后默认开启的新渲染引擎它替代Skia的部分管线目的是解决Skia在低端机上的帧漂移问题。但在OpenHarmony适配分支上Impeller可能和鸿蒙侧的渲染环境存在兼容性问题表现是页面闪烁或某些形状绘制异常。遇到这种情况在AndroidManifest或鸿蒙对应的配置里关闭Impellermeta-data android:nameio.flutter.embedding.android.Impeller android:valuefalse /鸿蒙侧的对应配置在Flutter适配文档里也有说明。数独这种棋盘类界面线条多如果Impeller对特定GPU驱动下的绘制指令处理有问题棋盘线就会发虚。我实测某些设备上关闭Impeller后反而更稳定帧数也够用。性能这块数独的主要瓶颈不在渲染而在逻辑所以关闭Impeller对游戏体验几乎没有负面影响。平台版本兼容上也要注意很多第三方Flutter插件声明的最低SDK版本比当前鸿蒙适配分支高报xcode27很多flutter包报版本低类似的错误在鸿蒙里就是NDK/SDK版本过低。这种情况不要纠结于报错本身去升级对应插件的适配版本或者锁定Flutter适配分支的版本保持整体工具链同质量版本。6.3 其他高频坑速查表现原因处理启动时白屏很久Flutter引擎初始化慢或首次加载JIT产物使用release包测试检查是否有日志阻塞主线程棋盘自绘后出现锯齿画笔宽度过细或未做设备像素比适配用MediaQuery.devicePixelRatio计算实际像素宽度Future里的setState报错异步回调时页面已销毁回调前检查mounted保存后重启存档丢失写入时机太晚进程被杀前未落盘增加paused生命周期保存兜底真机调试时事件通道收不到端口或权限未配置检查ohos工程里的权限声明与项目配置还有一个容易被忽略的实践OpenHarmony的HDIHardware Device Interface是硬件驱动接口层的概念应用开发者一般不需要直接接触它。如果你的数独App将来要做“重力感应摇一摇排新题”这类传感器功能也是通过系统传感器服务或Flutter插件间接访问永远不会直接写HDI代码。理解系统的分层关系有助于定位问题源头但不代表要深入驱动层。最后补几句实操心得这个项目做下来我最想强调的一点是本地数据持久化的难点永远不在写代码而在定义“哪些数据需要存、什么时机存、存了之后怎么保证能原样恢复”。数独把这个问题暴露得很清楚因为你既要保存一整个棋盘又要保存玩家的操作历史还得分清哪些格子是题目本身、哪些是玩家填的。等到你做完这个项目再回头去看其他App的存档设计会有一种“万变不离其宗”的感觉。对我自己来说这套工程里最佳的性价比投资是那个UserRepository的封装。它把SharedPreferences的细节全部隔离在业务之外之后做多端同步、云存档、数据库迁移都不用动游戏逻辑。很多项目不是一开始就打算做云同步后来需求一来发现数据访问代码散落各处改起来想死。所以在项目第一天就把持久化边界划清楚后面会一直感谢自己。