ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter 迁移实战:将 Godot 象棋引擎解耦并塞进移动 App

Flutter 迁移实战:将 Godot 象棋引擎解耦并塞进移动 App 1. 为什么要把一个象棋引擎塞进手机 App做独立开发的人大概都有过这种执念手里有一个自己写得挺满意的核心逻辑就总想把它包装成一个能拿得出手的产品。我这个象棋引擎最早是在 Godot 里跑起来的当时图的是它做棋盘渲染和交互特别顺手节点系统摆棋子、拖拽走子、动画反馈几乎不用写太多底层代码。但问题很快就来了——Godot 导出的移动端包体偏大冷启动慢而且我想加一些偏工具属性的功能比如棋谱管理、云端同步、分享卡片用 Godot 的 UI 体系做起来非常别扭。于是就有了这次迁移把纯逻辑的象棋引擎从 Godot 里剥离出来用 Flutter 重做整个 App 外壳。核心诉求其实就三条引擎逻辑一行不改地复用、移动端体验要跟原生一样跟手、后续迭代能快速加功能。这篇文章就把我踩过的坑、做过的取舍、以及最终跑通的方案完整讲一遍适合正在做跨端迁移、或者想把某个核心算法模块塞进 App 的独立开发者参考。先说结论性的判断引擎和 UI 必须彻底解耦中间用一层薄薄的桥接层通信。这个原则听起来像废话但真正做的时候90% 的返工都来自一开始没把边界划清楚。我第一版就是偷懒把引擎的走子生成和 Godot 的节点状态混在一起结果迁移时几乎重写。第二版痛定思痛把引擎做成一个纯函数式的模块输入是棋盘状态输出是合法走法列表不依赖任何渲染框架这才让后面的 Flutter 接入变得顺理成章。2. 引擎与 UI 解耦的整体设计思路2.1 核心分层引擎层、桥接层、表现层我把整个系统切成三层这个划分是后面所有工作的基础。引擎层纯逻辑负责棋盘表示、走法生成、局面评估、搜索算法。它不知道什么叫“屏幕”也不知道什么叫“点击”只认数据。桥接层负责在引擎和 UI 之间翻译数据。引擎用的是紧凑的整数数组表示棋盘UI 需要的是带坐标、带棋子类型、带选中状态的对象列表这中间的转换全在桥接层做。表现层Flutter 的 Widget 树负责渲染棋盘、处理手势、播放动画、管理页面路由。这么分的好处是引擎层可以单独写单元测试甚至可以在命令行里跑自我对弈来验证正确性完全不依赖任何图形环境。我实测下来引擎层的测试覆盖率能做到 95% 以上这在混合了 UI 的项目里几乎不可能。2.2 为什么选 Flutter 而不是继续用 Godot 或换原生这个决策我纠结了挺久列个表对比一下当时的考量。方案优势劣势我的判断继续用 Godot引擎代码零迁移成本包体大、UI 生态弱、工具类功能难做放弃长期维护成本高原生双端性能最好、体验最跟手两套代码、开发周期翻倍放弃独立开发扛不住Flutter一套代码、UI 表达力强、热重载爽与原生逻辑通信需要桥接采用桥接成本可控关键点是 Flutter 的Platform Channel和FFI两条路。如果引擎是 Dart 写的直接同进程调用就行连桥接都省了如果引擎是 C 写的就得走 FFI。我的引擎最早是 GDScript迁移时我把它重写成了 Dart因为 GDScript 的语法和 Dart 挺接近重写成本比想象中低而且省掉了跨语言调用的序列化开销。提示如果你的引擎是 C 或 Rust 写的别急着用 Platform Channel 传 JSON那个序列化开销在搜索深度大的时候会非常明显。优先考虑 dart:ffi把引擎编译成动态库直接调用。2.3 数据结构的统一约定迁移中最容易出问题的就是数据结构对不上。我在 Godot 里用的是Array[Array]的二维数组表示棋盘每个格子存一个整数表示棋子。到了 Dart 里如果还用嵌套 List性能会差一些因为 Dart 的 List 是对象嵌套访问有额外开销。最后我改成了一维的Int8List长度 90象棋是 9 列 10 行索引i row * 9 col。这个改动看起来小但搜索算法里要频繁访问棋盘一维数组的缓存友好性明显更好。实测在同样搜索深度下一维数组比嵌套 List 快了大概 15% 到 20%。棋子的编码我也重新设计了一套用正负号区分红黑绝对值表示兵种// 棋子编码约定 // 0 表示空 // 正数表示红方负数表示黑方 // 1/2 兵/卒3/4 炮5/6 车7/8 马9/10 相/象11/12 仕/士13/14 帅/将 const int empty 0; const int redPawn 1; const int blackPawn -1; const int redCannon 3; const int blackCannon -3; // ... 以此类推这套编码的好处是判断敌我只需要看符号判断兵种只需要看绝对值搜索里的分支判断能省不少代码。3. 引擎核心逻辑的迁移与重写要点3.1 走法生成从 GDScript 到 Dart 的逐行翻译走法生成是整个引擎最核心也最容易出错的部分。象棋的走法规则比国际象棋复杂尤其是炮的隔子吃、马的蹩腿、象的塞眼这些规则在迁移时一个都不能漏。我在 GDScript 里的写法是每个兵种一个函数函数里遍历所有可能的方向逐个判断目标格是否合法。翻译到 Dart 时基本是逐行对应但有几个地方需要特别注意。第一是边界判断。GDScript 里数组越界会返回 nullDart 里会直接抛异常。所以我在 Dart 版本里所有访问棋盘的地方都先做边界检查或者用一维数组的索引计算来天然规避。比如马走日从(r, c)到(r2, c1)我会先判断r2 10 c1 9再访问数组。第二是蹩腿和塞眼的判断。马的蹩腿是看马腿位置有没有棋子象的塞眼是看象眼位置。这两个判断在 GDScript 里我写得很随意因为 GDScript 对空值容忍度高。Dart 里我把它们抽成了独立的辅助函数bool isHorseLegBlocked(Int8List board, int fromRow, int fromCol, int dr, int dc) { // 马腿位置是起点向目标方向走一格的位置 int legRow fromRow (dr 0 ? 1 : -1) * (dr.abs() 2 ? 1 : 0); int legCol fromCol (dc 0 ? 1 : -1) * (dc.abs() 2 ? 1 : 0); return board[legRow * 9 legCol] ! empty; }这个函数我单独写了测试用例把马在棋盘各个位置的蹩腿情况都覆盖了一遍确保迁移没有引入回归。3.2 局面评估分值表的重新标定评估函数决定了引擎的“棋风”。我在 Godot 版本里用的是一套比较粗糙的分值兵 100、炮 450、车 900、马 400、相 200、仕 200、帅 10000。这套分值在简单对弈里够用但迁移时我顺手做了一次调优。调优的方法是自己跟自己下棋跑几千局统计每个兵种在实际对局中的“存活价值”。具体做法是记录每个兵种从开局到被吃掉的平均步数步数越长的兵种说明它在实战中越难被处理分值应该适当调高。跑下来发现炮的分值偏低了因为炮在中局阶段的威胁很大但我的原分值只给了 450。调整后炮给到 480马给到 420整体棋力有可感知的提升。这个调优过程不需要什么高深的理论就是多跑对局、多看数据、小幅调整避免一次调太多导致棋风突变。注意分值调整后一定要重新跑回归测试。我有一次把车的分值从 900 调到 950结果引擎开始出现“用炮换车”的亏子行为因为搜索深度不够时高分值棋子被过度保护反而影响了正常交换判断。3.3 搜索算法Alpha-Beta 剪枝的 Dart 实现搜索用的是经典的 Alpha-Beta 剪枝配合迭代加深和置换表。这部分逻辑在 GDScript 和 Dart 之间差异不大主要是语法层面的翻译。但 Dart 的递归性能比 GDScript 好不少同样的搜索深度Dart 版本耗时大概只有 GDScript 的 60%。置换表我用的是Mapint, TranspositionEntrykey 是局面的 Zobrist 哈希值。Zobrist 哈希在迁移时要注意随机数的生成GDScript 的randi()和 Dart 的Random()生成的序列不一样所以哈希值会变但置换表的逻辑不受影响只要保证同一局面在同一局对弈中哈希一致就行。class TranspositionEntry { final int depth; final int score; final int flag; // 0 精确值1 下界2 上界 final int bestMove; TranspositionEntry(this.depth, this.score, this.flag, this.bestMove); }搜索深度我默认设的是 6 层在手机上大概 200 到 500 毫秒能出结果这个延迟对用户体验来说是可以接受的。如果设到 8 层耗时会到 2 秒以上除非做后台线程否则会卡 UI。4. Flutter 端的棋盘渲染与交互实现4.1 用 CustomPaint 画棋盘而不是堆 Widget一开始我想用 GridView 加一堆 Container 来拼棋盘每个格子一个 Widget。试了一下90 个格子加上棋子Widget 树非常深滑动和重绘都有明显卡顿。后来改用CustomPaint整个棋盘和棋子都在一个 Canvas 上画性能立刻上来了。核心思路是重写paint方法先画棋盘线再画九宫格和河界文字最后遍历棋盘数组画棋子。class ChessBoardPainter extends CustomPainter { final Int8List board; final int selectedIndex; ChessBoardPainter(this.board, this.selectedIndex); override void paint(Canvas canvas, Size size) { double cellW size.width / 9; double cellH size.height / 10; // 画横线、竖线、九宫斜线 // 遍历 board非空则画棋子圆和文字 } override bool shouldRepaint(covariant ChessBoardPainter old) { return old.board ! board || old.selectedIndex ! selectedIndex; } }shouldRepaint这里要小心如果直接返回 true每次父组件重建都会重绘浪费性能。我改成比较 board 引用和 selectedIndex只有真正变化时才重绘。4.2 手势处理点击选中与拖拽走子交互我做了两套点击选中再点击目标格走子以及直接拖拽棋子到目标格。点击这套逻辑简单用GestureDetector的onTapUp拿到坐标换算成行列索引就行。拖拽稍微麻烦一点需要维护一个“正在拖拽的棋子”状态在onPanUpdate里更新拖拽位置在onPanEnd里判断落点是否合法。这里有个体验细节拖拽时棋子要跟着手指走并且原位置的棋子要隐藏或者半透明否则会有重影。Offset? dragPosition; int? draggingIndex; void onPanStart(DragStartDetails d) { final idx _hitTest(d.localPosition); if (idx ! null board[idx] ! empty) { draggingIndex idx; dragPosition d.localPosition; } } void onPanEnd(DragEndDetails d) { if (draggingIndex ! null dragPosition ! null) { final target _hitTest(dragPosition!); if (target ! null _isLegalMove(draggingIndex!, target)) { _makeMove(draggingIndex!, target); } } draggingIndex null; dragPosition null; }_hitTest就是把屏幕坐标换算成棋盘索引注意要处理棋盘边距和格子大小的关系手指点在格子边缘时要有一定的容错范围我给了大概 20% 的额外判定区域实测下来误触率明显降低。4.3 动画与反馈让走子有“手感”纯逻辑迁移完之后体验上的差距主要靠动画补。我加了三个动画棋子移动的平移动画、吃子时的缩放消失动画、将军时的棋盘闪烁提示。平移动画用AnimationController配合TweenOffset时长设 150 毫秒。这个时长是试出来的太短感觉生硬太长感觉拖沓150 毫秒在手机上最跟手。吃子动画是让被吃的棋子在 200 毫秒内缩放到 0 并淡出。将军提示是让帅/将所在的格子边框闪三下红色用AnimatedBuilder驱动。实操心得动画不要阻塞引擎计算。我的做法是走子动画一开始播放就立刻在后台线程启动引擎搜索等动画播完引擎结果大概率已经算好了用户感觉不到等待。如果引擎算得慢就先显示“思考中”的指示器。5. 跨层通信的性能优化与踩坑记录5.1 避免每帧序列化棋盘这是我最开始犯的错。桥接层每次 UI 重绘都把整个棋盘序列化成 JSON 传给引擎引擎算完再序列化回来。结果帧率直接掉到 20 以下。后来改成共享内存式的传递引擎和 UI 共用同一个Int8List棋盘对象UI 只读引擎在搜索时复制一份副本来改。这样避免了序列化帧率恢复到 60。// UI 层持有 board引擎搜索时传入副本 Int8List searchBoard Int8List.fromList(board); final bestMove engine.search(searchBoard, depth);复制一个 90 字节的数组开销可以忽略但省掉的序列化开销非常可观。5.2 搜索放到 Isolate 里避免卡 UIDart 是单线程事件循环如果搜索直接在 UI 线程跑深度大的时候会明显卡顿。我用Isolate.run把搜索放到独立 isolate 里。final bestMove await Isolate.run(() { return engine.search(Int8List.fromList(board), depth); });这里有个坑Isolate.run每次调用都会创建新 isolate频繁调用有开销。如果搜索很频繁建议用长驻 isolate 加SendPort通信。我的场景是用户走一步才搜一次用Isolate.run足够了。5.3 常见问题速查表问题现象可能原因排查方向解决方法走子后棋盘不刷新shouldRepaint 返回 false检查 board 引用是否变化用新数组替换旧数组或加版本号搜索卡顿明显搜索在 UI 线程看是否用了 Isolate移到 Isolate.run 里马走法异常蹩腿判断漏了边界单测覆盖所有位置补边界检查拖拽落点不准坐标换算没算边距打印 hitTest 结果加容错区域引擎棋力突然变差分值调整过头回滚最近一次调整小步调整并跑回归6. 迁移后的效果与后续可扩展方向迁移完成后包体从原来的 80 多兆降到了 20 兆出头冷启动从 3 秒多降到 1 秒以内这些是实打实的收益。引擎逻辑因为做了彻底解耦后面加功能非常快比如我后来加的“悔棋”功能只需要在桥接层维护一个历史棋盘栈引擎层完全不用动。后续我打算把引擎的搜索深度做成可配置的难度等级简单模式 4 层、普通 6 层、困难 8 层。8 层在手机上大概要 2 到 3 秒配合“思考中”的动画体验上可以接受。另外还想加一个“复盘分析”功能把整局棋的每一步都跑一遍引擎评估标出哪几步是失误这个功能对想提升棋力的用户会很有价值。如果你也在做类似的迁移我的建议是先把引擎的测试写扎实再动 UI。引擎正确性是地基地基不稳UI 做得再漂亮也是白搭。我迁移过程中大概花了 40% 的时间在写引擎测试上当时觉得慢但后面每次改 UI 都不担心把逻辑改坏这个投入非常值。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进