Flutter AOT 算错:与 JIT 结果不一致的排查

掘金热帖《Flutter 下 AOT 一个你怎么都想不出来的 Bug,JIT 正常的计算,在 AOT 居然算错了》记录了同一段计算在 JIT 下正常、在 AOT 下却得到错误结果的现象。 这一现象可以作为排查起点,但不能仅凭标题倒推出根因。

更稳妥的判断是:只有当相同源码、相同输入、相同依赖和相同业务状态,在多次干净构建中都能稳定复现差异,并且纯 Dart 最小样例也受到影响时,才有充分理由继续向 AOT 编译器缺陷方向排查。否则,差异更可能来自状态残留、输入不一致、类型转换、平台插件或构建配置。

排查可按以下路径推进:先在纯 Dart 中复现,再在同一设备上做 Flutter Debug/Release 对照,锁定输入并比较中间值,最后把问题压缩成可提交的最小样例。

JIT 正常而 AOT 算错,如何判断是否为编译器 Bug?

仅凭这些现象,不能直接将其定性为编译器缺陷。热帖标题可以作为现象线索,但引用材料没有提供一套足以核验的统一复现条件,也没有足够信息证明标题背后存在某一种特定根因。 因此,看到 Debug 正常、Release 错误时,首先应把问题拆成四类:

观察到的现象 优先排查方向 暂时不能得出的结论
Debug 正常,Release 错误 断言、热重载状态、构建配置、依赖版本 AOT 一定错误
纯 Dart 脚本中稳定出现差异 类型、常量求值、数值运算、代码生成 Flutter UI 或插件一定有问题
只在一个设备或平台出现 架构、系统版本、原生插件、Web 编译路径 整个 AOT 编译器都有问题
偶发且受执行顺序影响 时间、随机数、并发、异步调度、缓存 编译器存在确定性缺陷

其中,“干净、稳定、可缩小的差异”比“Debug 与 Release 的表面差异”更有诊断价值。如果两个构建使用了不同输入、保留了不同状态,或者一个来自模拟器、另一个来自真机,那么它们首先不是一组有效的对照实验。

怎样把同一输入的差异稳定复现出来?

先用纯 Dart 脚本隔离 Flutter 相关因素

先把原始计算提取成独立函数,保留最初的表达式结构、输入类型和中间步骤。不要一开始就重写算法,也不要急着把 const 改成运行时变量,因为重写本身可能消除问题。

下面的代码只用于固定比较方式,calculate 中的表达式应替换为实际触发问题的最小版本:

double calculate(double x, double y) {
  // 在这里原样保留触发问题的表达式
  return x + y;
}

void dump(String name, num value) {
  print('$name type=${value.runtimeType} value=$value');

  if (value is double) {
    print(
      '$name precision=${value.toStringAsPrecision(17)} '
      'isNaN=${value.isNaN} '
      'isInfinite=${value.isInfinite} '
      'isNegative=${value.isNegative}',
    );
  }
}

void main() {
  const x = 1.0;
  const y = 3.0;

  dump('x', x);
  dump('y', y);

  final result = calculate(x, y);
  dump('result', result);
}

在同一套 Dart SDK 下,以 macOS/Linux shell 为例执行:

dart --version
dart run main.dart > jit.txt
dart compile exe main.dart -o main_aot
./main_aot > aot.txt
diff -u jit.txt aot.txt

这里比较的是 dart run 的运行时 JIT 路径与 dart compile exe 生成的 AOT 可执行文件。两者必须使用同一个 SDK、同一份源码和同一组输入。

如果结果不同,不要立即提交缺陷。继续做三件事:

  • 删除 UI、插件、网络、存储和异步代码,只保留触发表达式。
  • 恢复原始的 const、静态字段、默认参数和运算顺序,避免“优化代码”时改变了问题条件。
  • 输出每一个中间值,确定差异从哪一步开始,而不是只比较最终结果。

纯 Dart 样例没有复现,也不能直接证明 Flutter 没问题。桌面 AOT、Flutter Android AOT 和 Flutter iOS AOT 的目标环境并不完全相同,纯 Dart 结果只是一个重要信号,不是完整证明。

再回到 Flutter 做双构建对照

选择同一台真机,通过设备列表确认设备 ID:

flutter devices
flutter --version
git status --short
git rev-parse HEAD
flutter clean
flutter pub get
flutter run -d <device-id> --debug --dart-define=CASE_ID=case_001

Debug 运行结束后,停止应用并把应用数据恢复到同一基线,再执行 Release 构建:

flutter clean
flutter pub get
flutter run -d <device-id> --release --dart-define=CASE_ID=case_001

对照时有几个容易被忽略的条件:

  • 不要拿开发机、模拟器和真机互相比较。
  • 不要把热重载后的 Debug 进程当作 Release 对照。
  • Debug 与 Release 安装前应清理旧数据,避免前一次运行修改了持久化状态。
  • 输入应来自两个构建都包含的固定夹具,或者通过相同的 --dart-define 注入。
  • flutter test 不能替代真机 Release 构建,它没有覆盖发布包的原生代码生成和实际运行路径。
  • 如果 Release 日志与 Debug 日志格式不同,应比较结构化数值,而不是比较界面上的默认格式化文本。

把影响结果的变量全部锁住

每次复现至少记录以下信息:

类别 需要固定或记录的内容
源码 Git 提交、工作区状态、触发表达式
依赖 pubspec.lock、插件及原生库版本
SDK Flutter 版本、Dart 版本、发布渠道
设备 操作系统、设备型号、CPU 架构
输入 固定输入值、输入来源、初始化顺序
输出 最终结果、关键中间值、运行时类型
构建 flavor、构建参数、完整命令、安装产物

浮点数不要只保留默认字符串。可以同时输出 runtimeType、toStringAsPrecision(17) 的结果,以及 NaN、无穷和负零等状态。高有效位数只是诊断手段,不代表业务已经获得了十进制精度。

JIT 与 AOT 到底在哪里分叉?

JIT 在应用运行时动态编译代码,AOT 则在应用发布前把 Dart 代码编译为机器码;开发阶段依赖 JIT 热重载,发布阶段使用 AOT。 在 iOS 的常见生产产物中,AOT 会生成由 CPU 直接执行的原生指令,并打包进 App.framework,随后经过签名并由系统加载;Release 构建还会禁用断言。

目标平台本身也需要分开考虑。Flutter 既可以编译为 ARM 或 Intel 机器码,也可以编译为 JavaScript,因此 Web 上的结果不能直接作为移动端 AOT 存在缺陷的证据。

排查时可以按四层组织:

层次 需要回答的问题
源码与常量求值 类型是否明确?是否包含 const 表达式、静态字段或默认参数?
编译器优化与代码生成 差异是否只在 AOT 出现?纯 Dart 样例是否稳定复现?
运行时状态 热重载、缓存、单例、静态初始化或异步顺序是否不同?
平台与原生依赖 是否经过插件、时间、区域设置、文件、加密或硬件接口?

源码语义和运行状态通常比编译器缺陷更容易验证。因此,应在其他三层基本排除后,再把编译器问题纳入候选范围。

为什么数值会差一点,或恰好少 1?

类型和运算过程究竟发生了什么?

先同时记录变量的静态类型和运行时类型。声明为 num 的值实际可能是整数或浮点数,声明为 dynamic 的位置也不应被假定始终走同一条执行路径。关键位置应显式写出预期类型和转换规则。

除法、截断和舍入应分别检查,例如:

final quotient = 7 / 2;
final truncated = 7 ~/ 2;
final rounded = (7 / 2).round();

不要只看最终变量的声明。还要确认:

  • 实际执行的是普通除法、整数截断除法,还是显式 toInt()、round()。
  • 中间值是否超过目标环境能够表达的整数范围,溢出策略是什么。
  • 是否存在从整数到浮点数、再从浮点数转回整数的往返转换。
  • 0、负数、负零、极大值和极小值是否被纳入测试。
  • 接口、插件或 JSON 反序列化是否悄悄改变了数值类型。

结果恰好相差 1,通常比一般的浮点误差更值得先检查除法、截断、边界条件和中间类型。

浮点差异为什么不一定来自 Flutter?

“看起来只差一点”应拆成几个独立问题:输入在编译期还是运行时确定,表达式是否属于常量求值,运算顺序是否发生变化,是否存在表达式合并,以及目标架构和运行时采用了怎样的浮点执行路径。

这些都是需要验证的假设,不是看到 Debug/Release 差异后就可以直接套上的结论。

同时记录 NaN、正负无穷和负零。不要只用默认输出、== 判断或普通序列化结果间接观察这些值,因为不同表现形式可能掩盖真实状态。还要区分两种情况:

  • 差异超出业务允许误差,属于精度或正确性问题。
  • 差异只存在于最后几位,但已经影响金额、税额或累计结果,同样需要处理。

对于金额、税费等要求十进制精度的业务,不宜接受“误差很小”作为结案依据。更稳妥的方案是使用整数最小单位、定点数,或经过验证的十进制实现。

怎样把最终结果拆成中间值?

把计算拆成若干有名字的中间变量,并在每一步输出:

final a = normalize(input);
dump('a', a);

final b = transform(a);
dump('b', b);

final c = combine(b, factor);
dump('c', c);

dump('result', c);

找到第一个分叉点后,再判断它属于哪一类:

  • 分叉前是常量表达式:检查编译期求值。
  • 分叉发生在类型转换:检查源码语义和接口契约。
  • 分叉发生在普通运行时运算:再考虑优化、代码生成和目标环境。
  • 只有最终值不同:可能存在误差累积,需要继续向前拆分。

为什么只在 Release、某台设备或某次构建中出错?

Release 和热重载改变了哪些条件?

Release 会禁用断言,因此所有只在 assert 中执行的校验、辅助计算或状态修改都必须单独检查。 如果 Debug 路径实际执行了某段逻辑,而 Release 路径没有执行,那么两者不能直接比较。

热重载还可能保留旧状态。资料指出,热重载用于在不丢失状态的情况下预览改动,但全局变量初始化、静态字段和 main() 入口等部分不能按普通函数代码的方式依赖热重载更新。 因此,Debug 中看到正常结果时,应至少再做一次热重启或完整重启对照:

flutter run -d <device-id> --debug

完成热重启验证后,再进行完整进程重启。不要把一次热重载成功视为新代码已经覆盖所有状态。

平台和原生插件是否绕过了 Dart 计算?

如果计算涉及时间、区域设置、文件格式、加密、坐标或硬件能力,应列出两条路径:一条是 Dart 实现,另一条是原生插件实现。重点核对:

  • 两条路径的版本是否一致。
  • 返回值和参数类型是否一致。
  • 精度、舍入和单位换算规则是否一致。
  • Release 配置是否启用了不同的原生实现。
  • 模拟器与真机的系统服务、区域设置或硬件能力是否不同。

Android、iOS、桌面端和 Web 应分别测试。Flutter 在 Web 目标上可以编译为 JavaScript,把它与移动端原生 AOT 混为一谈,会直接破坏对照条件。

构建环境和依赖是否真正相同?

本机正常而 CI 异常时,优先比较:

  • Flutter 与 Dart SDK 版本。
  • pubspec.lock 是否完全一致。
  • flavor、构建参数、资源文件和代码生成文件。
  • Android、iOS 或桌面端原生依赖版本。
  • 目标架构、发布渠道和签名配置。
  • 缓存是否被清理,构建是否真正重新生成。

Flutter 官方提供发布渠道切换以及稳定版和历史版本管理能力,可以用这些能力建立版本矩阵。 每轮只改变一个变量,例如先固定设备和依赖,只切换相邻 SDK 版本;再固定 SDK,只切换目标架构。这样才能知道问题是版本相关、平台相关,还是与状态和缓存相关。

结果为什么会偶发或受执行顺序影响?

如果同一构建重复运行的结果并不一致,应暂时搁置“编译器确定性错误”的判断,检查:

  • 时间值是否随运行变化。
  • 随机数是否缺少固定种子。
  • 多个 await 的执行顺序是否影响写入顺序。
  • 并发任务、Isolate 或回调是否共享可变状态。
  • 缓存是否在 Debug 与 Release 之间保留了不同内容。
  • 集合遍历顺序是否被误认为稳定顺序。
  • 测试用例或文件是否被前一次运行修改。

时间、随机数和外部平台服务应尽量改为显式依赖并注入测试数据。否则,一次“复现成功”可能只是捕获到了某次执行顺序。

怎样把问题压缩成一个可提交的最小样例?

最小样例应同时满足四个条件:

  1. 不依赖 Flutter UI。
  2. 不依赖插件、网络、存储或原生能力。
  3. 不使用随机输入或未固定的时间。
  4. 能在同一 SDK 下稳定比较 JIT 与 AOT 结果。

样例中应保留原始触发表达式、原始输入类型和预期输出,而不是只留下一个看起来等价的简化公式。随后至少完成以下验证:

  • 在两个干净的 Release 构建中重复测试。
  • 在不同原生架构上重复测试。
  • 在相邻 Flutter 版本上重复测试。
  • 比较最终结果以及所有关键中间值。
  • 记录实际命令,确保其他人可以按顺序执行。

如果差异只在 Flutter 应用中出现,就继续剥离插件、业务初始化和资源文件。如果纯 Dart 样例也能稳定触发 AOT 差异,则可以按 Dart/Flutter 缺陷格式提交。

一份可用的报告至少包含五部分:

  1. 问题描述:说明预期行为和实际行为,不使用“编译器有 Bug”作为唯一结论。
  2. 复现步骤:列出源码版本、输入、构建命令和运行方式。
  3. 实际结果:保留原始输出和关键中间值。
  4. 预期结果:说明预期值及其依据。
  5. 环境信息:包含 Flutter/Dart 版本、目标平台、设备、系统版本、CPU 架构和完整依赖版本。

纯 Dart 样例只应在 AOT 出现时,优先提交给 Dart SDK 团队;如果只在 Flutter 应用中出现,则应继续检查插件、构建配置和业务代码,而不是直接归因于通用 AOT 编译器。

确认是编译器问题后,应如何修复?

如果问题属于源码语义,应明确每个数值的类型和转换规则,统一除法、截断和舍入方式,并让接口返回类型与文档保持一致。不要让 num、double 和 int 在多层调用中隐式混用。

如果问题属于精度不足,金额等业务应改用整数最小单位、定点数或经过验证的十进制方案。通过调整表达式顺序、插入无意义运算或关闭优化来“碰对一次”,只会把问题推迟到下一次输入或目标环境中。

如果问题涉及常量求值,排障时可以先把常量改成运行时输入,观察差异是否消失。这个步骤用于定位,正式方案则应结合业务对确定性、精度和性能的要求决定,而不是机械地把所有 const 都移除。

如果问题来自状态或环境,应把时间、随机数、平台服务和外部数据改成显式依赖,通过参数注入让测试环境可控。缓存、全局状态和异步顺序也应进入测试夹具,而不是依赖执行时机。

如果最终确认是编译器缺陷,优先升级到包含修复的 Flutter/Dart 版本。若暂时必须使用旧版本,应固定 SDK 版本、缩小触发代码,并准备语义等价的重写方案,同时提交完整复现材料。关闭优化或增加延时不应作为正式修复。

如何避免这类问题进入线上环境?

为关键计算增加固定输入、固定精度的单元测试和黄金用例,至少覆盖零值、负数、边界值、舍入点、特殊浮点值以及 JIT/AOT 对照结果。测试不仅要断言最终值,也要在关键中间步骤设置断言。

发布流程应加入真机 Release 冒烟测试。Debug 模式、热重载和 flutter test 都有价值,但它们不能代替实际发布包在同一设备上的运行结果。

固定 Flutter SDK 与依赖版本,并保存构建产物对应的源码提交、SDK 信息、锁文件和原生依赖版本。出现差异时,能够先确认“比较的是不是同一份产物”,再讨论代码本身的问题。

排查按以下顺序推进:

  1. 干净复现。
  2. 锁定输入。
  3. 比较中间值。
  4. 剥离插件与状态。
  5. 建立版本矩阵。
  6. 压缩最小样例。
  7. 决定修代码还是修版本。

如果前六步都能稳定完成,并且纯 Dart 最小样例仍然只在 AOT 下复现,再向 Dart/Flutter 团队提交缺陷,通常比直接提交一句“Flutter AOT 算错了”更容易得到有效处理。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容