Flutter FFI 实战:将 C++ 象棋引擎嵌入移动端 App

发布时间:2026/10/12 15:43:44
Flutter FFI 实战:将 C++ 象棋引擎嵌入移动端 App 1. 为什么要把一个象棋引擎塞进手机 App做独立开发到第四年我越来越觉得真正有意思的项目往往不是从我要做一个平台开始的而是从我想让某个东西跑起来开始的。象棋引擎就是这样一个东西。它本身是一个纯粹的算法体输入一个局面输出一步棋逻辑闭环不依赖网络不需要服务器理论上可以跑在任何有计算能力的地方。但问题在于绝大多数象棋引擎都是为桌面环境写的命令行交互、标准输入输出、UCI 协议这些东西和移动端的交互模型之间隔着一道不小的鸿沟。我最初的想法很简单做一个能在手机上随时摆棋、随时让引擎分析局面的工具。不是那种在线对战平台也不是社交产品就是一个纯粹的、离线的、打开就能用的象棋分析工具。这个需求听起来不复杂但真正动手之后才发现从引擎的编译、通信协议的适配、到 UI 层的状态同步每一步都有坑。关键词里提到的 Godot 和 Flutter恰好代表了我在这件事上走过的两条路。Godot 是我最早尝试的方案因为它的 GDScript 写起来快场景系统也适合做棋盘这种网格布局。但做到一半我发现Godot 在移动端的生态和工具链对于我要调用一个 C 编译的引擎这件事来说摩擦力比我想象的大。后来我转向 Flutter用 Dart 做 UI用 FFI 直接调 native 库整个链路反而清晰了很多。这篇文章不是教程也不是框架对比。它更像是一份项目笔记记录我在这个过程中做的每一个关键决策、踩过的每一个坑、以及最后跑通的那套方案。如果你也在做类似的事情——把某个桌面端的计算密集型程序搬到移动端——那这些经验大概率对你有用。2. Godot 方案为什么走到一半就停了2.1 GDScript 的便利性与 native 调用的摩擦Godot 最大的优势在于它的场景系统和 GDScript 的即时反馈。我用 TileMap 做棋盘用 Area2D 做点击检测不到两天就把棋盘的渲染和交互搭出来了。棋子用 Sprite 加动画走子逻辑用 GDScript 写了一个简单的规则校验整个原型跑起来非常顺畅。但问题出在引擎这两个字上。真正的象棋引擎不是几十行 GDScript 能搞定的它涉及到搜索树、剪枝、局面评估、置换表这些都需要 C 级别的性能。Godot 确实支持 GDExtension可以加载 native 库但整个编译流程、ABI 兼容性、以及不同平台下的构建配置对于一个独立开发者来说维护成本太高了。我试过用 GDNative 加载一个编译好的引擎动态库在桌面端跑通了但一到 Android 就各种符号找不到、架构不匹配。Godot 的文档在这块比较薄社区里相关的案例也少大部分讨论都集中在游戏逻辑层面很少有人拿它去做调用外部计算引擎这种事。2.2 移动端导出时的工具链问题另一个让我决定放弃 Godot 的原因是导出流程。Godot 的 Android 导出需要配置 Android SDK、NDK、JDK还要处理 Gradle 版本兼容。这些本身不是大问题但当你需要额外引入一个 native 库的时候整个构建链路就变得非常脆弱。我遇到过 Gradle 版本和 Godot 导出模板不匹配导致构建失败也遇到过 NDK 版本不对导致 native 库链接错误。更关键的是Godot 的移动端调试体验不如 Flutter。Flutter 有热重载有 DevTools有完整的日志系统。Godot 在 Android 上的日志输出经常被截断调试 native 层的问题基本靠猜。对于一个需要频繁调整引擎参数、测试不同局面的项目来说这种调试体验是不可接受的。所以做到第三周的时候我决定换方案。不是 Godot 不好而是它不适合这个场景。Godot 适合做游戏适合做交互密集但计算逻辑不复杂的东西。但当你需要把一整个 C 引擎塞进去的时候Flutter 的 FFI 机制明显更直接。3. Flutter FFI把引擎编译成手机能直接调的库3.1 引擎的编译目标选择静态库还是动态库转到 Flutter 之后第一个要解决的问题是引擎到底编译成什么形式。Flutter 通过 dart:ffi 调用 native 代码支持动态库.so / .dylib / .dll和静态库.a。我一开始想用静态库因为觉得这样打包进 APK 更干净不需要额外处理库文件的加载路径。但实际试下来静态库在 Flutter 里的集成反而更麻烦。你需要把静态库链接到一个动态库壳里或者用 CMake 把它编进 Flutter 的 native 构建流程。而动态库就简单很多编译出一个 .so放到 android/app/src/main/jniLibs/ 下面对应的架构目录里Dart 侧用 DynamicLibrary.open 直接加载就行。所以我最终选择了动态库方案。具体来说引擎编译成 libchessengine.so分别针对 arm64-v8a、armeabi-v7a、x86_64 三个架构各编一份。Android 打包的时候会自动把对应架构的库打进 APK运行时系统会根据设备架构自动选择。这里有一个细节需要注意Flutter 的 release 构建默认只打包 arm64-v8a 和 armeabi-v7a如果你在模拟器上调试需要额外加上 x86_64。我一开始在模拟器上跑的时候一直报library not found后来才发现是架构没对上。3.2 Dart FFI 的绑定写法与内存管理引擎编译好之后下一步是在 Dart 侧写 FFI 绑定。这部分是整个项目里最需要小心的地方因为涉及到跨语言的内存管理。C 侧分配的内存Dart 的 GC 是不管的你必须手动释放否则就会泄漏。我的做法是在 C 侧暴露一组纯 C 接口避免直接暴露 C 类。比如// chess_engine.h typedef void* EngineHandle; EngineHandle engine_create(); void engine_destroy(EngineHandle handle); const char* engine_best_move(EngineHandle handle, const char* fen, int depth); void engine_free_string(const char* str);然后在 Dart 侧这样绑定import dart:ffi; import dart:convert; typedef EngineCreateC PointerVoid Function(); typedef EngineCreateDart PointerVoid Function(); typedef EngineBestMoveC PointerUtf8 Function( PointerVoid, PointerUtf8, Int32); typedef EngineBestMoveDart PointerUtf8 Function( PointerVoid, PointerUtf8, int); class ChessEngine { late final DynamicLibrary _lib; late final EngineCreateDart _create; late final EngineBestMoveDart _bestMove; late final PointerVoid _handle; ChessEngine() { _lib DynamicLibrary.open(libchessengine.so); _create _lib .lookupFunctionEngineCreateC, EngineCreateDart(engine_create); _bestMove _lib.lookupFunctionEngineBestMoveC, EngineBestMoveDart( engine_best_move); _handle _create(); } String bestMove(String fen, int depth) { final fenPtr fen.toNativeUtf8(); final resultPtr _bestMove(_handle, fenPtr, depth); final result resultPtr.toDartString(); calloc.free(fenPtr); // 注意resultPtr 指向的内存在 C 侧需要调用 engine_free_string 释放 return result; } }这里有几个关键点。第一Dart 的 String 和 C 的 char* 之间需要转换toNativeUtf8 会分配内存用完必须 free。第二C 侧返回的字符串指针如果是在 C 侧 malloc 的Dart 侧不能直接 free必须通过 C 侧暴露的释放函数来处理。第三引擎句柄是一个不透明的指针Dart 侧只负责传递不关心它指向什么。内存管理这块我踩过一个坑一开始我在 Dart 侧用 calloc.free 去释放 C 侧返回的字符串结果程序直接崩溃。后来才明白C 侧用的是 mallocDart 的 calloc.free 底层实现不一样混用会导致堆损坏。正确的做法是让 C 侧提供一个 engine_free_string 函数Dart 侧调它来释放。3.3 引擎搜索深度与移动端性能的平衡引擎跑起来之后下一个问题是搜索深度。桌面端引擎可以轻松跑到深度 20 以上但在手机上深度 12 可能就要好几秒。对于一个交互式 App 来说用户不可能等五秒才看到一步棋。我的策略是分级快速分析用深度 8大概 200 毫秒出结果标准分析用深度 12大概 1 到 2 秒深度分析用深度 16但会放到后台 isolate 里跑不阻塞 UI。这样用户可以根据场景选择摆棋的时候用快速分析复盘的时候用深度分析。这里还有一个优化点引擎的置换表大小。桌面端可以开几百 MB手机上我限制在 16 MB 到 64 MB 之间。太小了搜索效率低太大了内存占用高而且 Android 对单个 App 的内存限制比较严格尤其是低端机。4. 棋盘 UI 与引擎通信的状态同步4.1 用 CustomPainter 画棋盘而不是堆 WidgetFlutter 做棋盘最直接的想法是用 GridView 或者 Stack 加 Positioned 来摆棋子。我一开始也是这么做的但很快发现性能有问题。每次走子都要重建整个 Widget 树64 个格子的布局计算加上棋子的动画在低端机上会有明显的卡顿。后来我改用 CustomPainter整个棋盘在一个 Canvas 上画出来。格子线、棋子、高亮、提示箭头全部在 paint 方法里绘制。这样只有一个 Widget重建成本极低而且可以精确控制每一帧的绘制内容。棋子的渲染我用的是文字符号加自定义字体而不是图片。这样做的好处是缩放不失真而且包体积小。每个棋子就是一个 Unicode 字符用 TextPainter 画到 Canvas 上位置根据棋盘坐标计算。4.2 引擎调用放到 Isolate 里避免卡 UIDart 是单线程的虽然它有事件循环但一个耗时的同步调用会阻塞整个 UI 线程。引擎搜索是典型的 CPU 密集型任务如果直接在 UI isolate 里调 FFI界面会直接冻住。解决方案是用 Isolate。Flutter 提供了 compute 函数可以把一个函数放到新的 isolate 里执行。但 FFI 调用在 isolate 之间传递有一些限制指针不能直接跨 isolate 传递。我的做法是在每个 isolate 里独立创建引擎实例主 isolate 负责 UI后台 isolate 负责搜索通过 SendPort 和 ReceivePort 通信。具体流程是用户走一步棋UI 更新然后主 isolate 发送一个消息给后台 isolate带上当前局面的 FEN 字符串和搜索深度。后台 isolate 收到后调用引擎拿到最佳走法再通过 SendPort 发回主 isolate。主 isolate 收到结果后更新 UI显示引擎的建议。这里有一个细节引擎实例的创建和销毁是有成本的不能每次搜索都新建一个。我的做法是后台 isolate 启动时创建一个引擎实例之后一直复用直到 isolate 销毁。4.3 FEN 字符串作为局面交换的标准格式引擎和 UI 之间的通信我用的是 FEN 字符串。FEN 是象棋和国象通用的局面描述格式一行字符串就能完整表示棋盘上所有棋子的位置、轮到谁走、王车易位状态、吃过路兵状态等。选择 FEN 而不是自定义的二进制格式原因是它可读、可调试、可序列化。我在开发过程中经常需要把当前局面复制出来放到桌面端的引擎里对比分析FEN 让这件事变得非常方便。而且 FEN 是标准格式意味着我的 App 可以和其他象棋工具互操作用户可以把局面导出到其他软件里继续分析。解析 FEN 的代码不复杂但要注意边界情况。比如 FEN 里的数字表示连续的空格解析的时候要展开。还有王车易位字段如果为空要用 - 表示。这些细节如果处理不好引擎会直接报错或者给出错误的走法。5. 打包、体积与跨平台的那些坑5.1 多架构 so 文件的打包与裁剪Android 的 APK 支持多种 CPU 架构每种架构需要一份对应的 .so 文件。如果你的引擎比较大三份 .so 加起来可能会让 APK 体积膨胀很多。我的引擎编译出来每份大概 2 MB三份就是 6 MB加上 Flutter 本身的运行时整个 APK 大概 20 MB 左右。如果觉得体积太大可以用 Android App Bundle 格式发布Google Play 会根据用户设备架构只下发对应的 .so。但如果你是自己分发 APK那就只能全量打包。另一个优化是开启编译器的 strip 选项去掉符号表能减小不少体积。还有一个坑是Flutter 的 release 构建默认会做代码混淆和资源压缩但 jniLibs 里的 .so 不会被自动 strip。你需要在 build.gradle 里手动配置android { packagingOptions { jniLibs { useLegacyPackaging false } } }5.2 iOS 上的静态库链接与 BitcodeiOS 这边比 Android 麻烦一些。iOS 不允许动态加载外部库所以引擎必须编译成静态库然后链接到 Runner 目标里。这意味着你不能用 DynamicLibrary.open而是要用 DynamicLibrary.process() 来查找符号。具体做法是在 Xcode 里把 .a 文件加到 Link Binary With Libraries 里然后在 Dart 侧用 DynamicLibrary.process() 来获取符号。这里要注意符号的可见性C 函数需要加 extern C 并且确保没有被 strip 掉。Bitcode 是另一个坑。Apple 现在默认要求 Bitcode但很多第三方库不支持。如果你的引擎不支持 Bitcode需要在 Xcode 里把 Enable Bitcode 设为 NO。不过从 Xcode 14 开始Bitcode 已经默认关闭了所以这个问题在新项目里可能不会遇到。5.3 引擎线程与 UI 线程的隔离策略引擎内部通常有自己的线程池用于并行搜索。但在移动端线程数量不能太多否则会跟 UI 线程抢 CPU导致界面卡顿。我的做法是在引擎初始化的时候限制线程数为 2 到 4具体取决于设备的核心数。Android 上可以通过 Runtime.getRuntime().availableProcessors() 获取核心数iOS 上可以用 sysctl。但更简单的做法是直接在 Dart 侧传一个参数给引擎让引擎自己决定。我一般默认给 2 个线程因为大多数中低端手机只有 4 到 6 个核心留给 UI 和系统至少需要 2 个。还有一个问题是引擎的思考时间。如果用户连续快速走子引擎可能会堆积多个搜索请求。我的处理方式是加一个防抖如果上一个搜索还没完成新的请求就取消掉旧的只保留最新的。这样可以避免引擎被无意义的搜索占满。6. 实测下来的性能数据与优化心得6.1 不同深度下的响应时间实测我在几台不同档次的设备上做了测试引擎搜索深度和响应时间的关系大致如下搜索深度高端机旗舰中端机低端机深度 880ms150ms300ms深度 12400ms900ms2.1s深度 161.8s4.5s10s从数据可以看出深度 12 是一个比较平衡的点。中端机不到 1 秒低端机 2 秒左右用户基本可以接受。深度 16 在低端机上就太慢了只适合放到后台慢慢跑。基于这个数据我把 App 的默认分析深度设为 12同时提供一个深度分析按钮点了之后会切到深度 16 并在后台运行完成后通知用户。6.2 置换表大小对搜索效率的影响置换表是引擎里用来缓存已搜索局面的数据结构大小直接影响搜索效率。我测试了不同置换表大小下的搜索节点数和耗时置换表大小搜索节点数耗时深度 121 MB120万1.2s4 MB85万0.9s16 MB62万0.7s64 MB55万0.65s可以看到从 1 MB 到 16 MB 提升明显但从 16 MB 到 64 MB 收益就很小了。考虑到移动端的内存限制我最终选择了 16 MB 作为默认值。低端机上可以降到 4 MB避免内存不足被系统杀掉。6.3 低端机上的降级策略低端机的性能瓶颈主要在 CPU 和内存。除了降低搜索深度和置换表大小我还做了几个降级处理。第一关闭引擎的多线程只用单线程搜索减少 CPU 竞争。第二降低 UI 的刷新率棋子的动画从 60fps 降到 30fps。第三延迟加载引擎App 启动时不初始化等用户第一次点击分析的时候再加载。这些降级策略通过一个设备性能检测模块来自动判断。检测的指标包括 CPU 核心数、内存大小、以及一个简单的跑分测试。根据结果把设备分为高、中、低三档分别应用不同的配置。7. 如果你也想做类似的事这几条经验可以省你几天时间第一不要一开始就追求完美的架构。我最开始的 Godot 版本虽然最后废弃了但它让我快速验证了棋盘交互和引擎通信的基本逻辑。如果一开始就纠结用哪个框架可能到现在还没跑起来。第二FFI 的内存管理一定要在早期就处理好。我因为内存泄漏问题调试了整整两天最后发现是 Dart 侧 free 了 C 侧 malloc 的内存。后来我定了一条规矩谁分配谁释放跨语言传递的指针必须附带释放函数。第三引擎的搜索深度不要设太高。移动端用户对响应时间的容忍度很低超过 2 秒的等待就会让人觉得卡。宁可深度低一点、响应快一点也不要让用户等。第四测试一定要在真机上做。模拟器的性能和真机差别很大尤其是 native 代码的执行效率。我在模拟器上跑深度 16 只要 1 秒到真机上直接变成 5 秒完全不是一个量级。第五打包体积要提前关注。我一开始没注意三份 .so 加上 Flutter 运行时APK 直接到了 35 MB。后来 strip 了符号表、去掉了不必要的架构才降到 20 MB 左右。如果你要上应用商店体积是一个很重要的指标。这个项目到现在还在迭代最近在考虑加一个开局库的功能让引擎在开局阶段直接查表而不是搜索这样能进一步降低响应时间。另外也在研究怎么把引擎的分析结果可视化比如用热力图显示棋盘上每个格子的控制力。这些等做出来之后再另开一篇笔记来写。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询