flutter 编译相关

在 Flutter (Dart) 开发中,理解编译期 (Compile-time)运行期 (Runtime) 的区别至关重要。这不仅关乎代码怎么写,还直接影响 App 的性能和稳定性。

我们可以用**“拍电影”**来做一个生动的比喻:

  • 编译期 = 写剧本与彩排阶段(还没给观众看,检查逻辑、修正错别字、确定道具)。

  • 运行期 = 正式上映阶段(观众坐在电影院看了,根据观众反应互动,播放画面)。 image.png


1. 编译期 (Compile-time) —— “静态检查”

这是代码被“翻译”成机器语言的阶段。此时 App 还没有运行,编译器(Compiler)正在通过静态分析来检查你的代码。

  • 发生时间:你点击 “Run” 按钮之后,App 启动之前(或者在写代码时 IDE 报红线的时刻)。

  • 主要任务

    1. 语法检查:有没有少写分号?括号匹配吗?

    2. 类型检查:把 String 赋值给 int 了吗?(Dart 的空安全检查主要就在这里工作)。

    3. 常量计算:对于标记为 const 的变量,编译器直接计算出结果,把它“冻结”在内存里。

    4. Tree Shaking:把没用到的代码删掉,减小包体积。

  • 典型报错

    • SyntaxError: 语法错误。

    • TypeError (静态): 比如 int a = "hello".

  • 核心关键字

    • const这是编译期的王牌const Widget 意味着这个组件在编译时就确定了,运行时不需要重复创建,极大提升 Flutter 性能。

形象比喻:

编剧(编译器)在看剧本:“这行台词写错了(语法错误)”,“这个角色设定是男的,你这里写他怀孕了(类型错误)”。改好后,剧本定稿打印(生成机器码)。


2. 运行期 (Runtime) —— “动态执行”

这是 App 已经在手机上跑起来的阶段。CPU 正在一行行执行机器码,内存正在不断分配和回收。

  • 发生时间:App 启动后,用户点点滑滑的时候。

  • 主要任务

    1. 用户交互:响应点击、滚动。

    2. 网络请求:去服务器拿数据(这是编译期无法预知的)。

    3. UI 渲染build() 方法被调用,绘制像素到屏幕。

    4. 状态管理setState() 改变数据。

  • 典型报错

    • Exceptions:网络超时、文件未找到。

    • RangeError:数组越界(试图访问 list[10] 但只有 5 个元素)。

    • LateInitializationError:承诺了 late 却没赋值。

  • 核心关键字

    • final:虽然不可变,但可以在运行时才确定值(比如 final time = DateTime.now())。

    • dynamic:放弃编译期检查,全靠运行时运气。

形象比喻:

电影上映了(App 运行)。

突然放映机坏了(Crash),或者观众觉得剧情不合理扔鸡蛋(逻辑 Bug),或者去拿胶卷的时候路断了(网络请求失败)。这些都是写剧本时(编译期)无法完全预测的突发状况。


核心对比表:一目了然

特性编译期 (Compile-time)运行期 (Runtime)
状态代码是静止的文本代码是流动的指令
执行者Dart 编译器 (Compiler)Dart 虚拟机 (VM) 或 CPU
知道什么知道 const pi = 3.14知道 now 是几点几分
不知道什么不知道用户输入了什么,不知道网络数据不知道这行代码当初是谁写的
关键操作语法分析、类型推断、常量优化内存分配、IO 操作、UI 绘制
代表关键字constfinal, var, late
空安全检查 String? 是否赋给了 String检查 ! 操作是否遇到了 null

Flutter 中的经典面试题:const vs final

理解了编译期和运行期,就能完美回答这个问题:

  1. const (编译期常量)

    • 值必须在写代码时就确定。

    • const list = [1, 2]

    • 优势:在内存中只有一份,极度节省资源。Flutter 推荐尽量多用 const 组件。

  2. final (运行期常量)

    • 值可以在程序跑起来后才确定,但一旦确定就不能改。

    • final list = jsonDecode(response); (网络请求回来才知道内容)。

    • 优势:保证数据不可变性,安全。

总结

  • 编译期越严格(如 Dart 的强类型和空安全),运行期就越安全,Bug 越少。

  • 我们努力把更多的错误在编译期解决掉,就是为了让用户在运行期拥有丝般顺滑的体验。

Flutter 打包&编译

  1. 编译期:指你执行 flutter build ios 或在 Xcode 中点击 Archive 打包发布时的过程。

  2. 存储位置是的,Dart 代码会被编译成二进制机器码(ARM64 指令),并存储在一个 Mach-O 格式的文件中。

    • 但注意:它通常不是合并在宿主 App(主程序)的那个 Mach-O 可执行文件中,而是作为一个独立的 动态库(Dynamic Framework) 存在,名字通常叫 App.framework

下面为你详细拆解这个过程,因为 Debug 模式Release 模式 的表现完全不同。


1. Release 模式(打包发布时)—— AOT 编译

当你打正式包(Release Mode)准备上架 App Store 时,Flutter 的编译期发生了什么?

编译过程 (AOT - Ahead Of Time)

在构建阶段,Xcode 会调用 Flutter 的构建脚本(通常在 Build Phases 里的 Run Script)。

  1. Dart 编译器 (dart2aot) 介入。

  2. 它利用 AOT(提前编译) 技术,将你的 Dart 代码(业务逻辑、UI 构建等)直接编译成 原生的 ARM64 汇编代码/机器码

  3. 这不仅仅是转译,而是真正的编译,和 C++ 或 Swift 编译成机器码是一样的。它不再需要 Dart 虚拟机在运行时去解释。

产物形态 (Mach-O)

生成的机器码会被封装在一个名为 App.framework 的文件夹中。

  • 打开 App.framework,你会看到一个同名的二进制文件 App

  • 这个 App 文件就是一个 Mach-O 格式的动态库 (Dynamic Library)

  • 你的所有 Dart 逻辑代码(Dart 业务三棵树、算法等)都变成了二进制指令躺在这里面。

这里的 “Mach-O” 也是 Mach-O

你提到的“打包成二进制存储在 Mach-O”,理解是完全正确的。只是 iOS 的 App 结构通常是:

  • 主 Mach-O (Runner): 你的原生宿主代码(Swift/ObjC)。

  • 依赖 Mach-O (App.framework): 你的 Flutter/Dart 代码编译后的产物。

  • 引擎 Mach-O (Flutter.framework): Google 写的 C++ 渲染引擎。

总结:Release 模式下,Dart 变成了真正的二进制机器码,存放在 App.framework 里的 Mach-O 文件中。


2. Debug 模式(日常开发调试时)—— JIT 编译

当你连着手机写代码,使用热重载(Hot Reload)时,情况完全不同。

编译过程 (JIT - Just In Time)

为了支持极速的热重载,Flutter 不会把代码编译成机器码。

  1. Dart 编译器将代码编译成 Kernel Snapshot(一种中间字节码,类似 Java 的 bytecode)。

  2. Dart 虚拟机(Dart VM)在手机上启动。

  3. VM 在运行时加载这些字节码,并逐行解释执行(或即时编译)。

产物形态 (Assets)

在 Debug 模式生成的 App.framework 中,那个 Mach-O 文件其实是个“空壳”(或者说只包含极少的引导代码)。

  • 真正的 Dart 代码变成了 数据文件(Assets),通常叫 kernel_blob.binisolate_snapshot_data

  • 它们被放在 flutter_assets 目录下,作为资源文件随包分发。

总结:Debug 模式下,Dart 代码是字节码资源,不是 Mach-O 指令。


3. 图解结构

为了让你一眼看懂,我画了一个简单的架构图:

📦 你的 iOS App (.ipa / .app)
 ┣ 📂 Frameworks
 ┃ ┣ 📦 Flutter.framework
 ┃ ┃ ┗ 📜 Flutter (Mach-O)  <-- C++ 渲染引擎 (Google编译好的)
 ┃ ┃
 ┃ ┗ 📦 App.framework       <-- 你的 Dart 代码在这里!
 ┃   ┗ 📜 App (Mach-O)      <-- Release下:全是二进制机器码
 ┃                          <-- Debug下:只是个壳,代码在 assets 里
 ┃
 ┣ 📂 assets (Debug模式下重要)
 ┃ ┗ 📜 kernel_blob.bin     <-- Debug下的 Dart 字节码
 ┃
 ┗ 🚀 Runner (Mach-O)       <-- 你的原生 Swift/OC 代码 (宿主)
     (它会链接并加载上面的 Frameworks)

真实IPA 中的结构: image.png

核心结论

回到你的问题:

  1. 编译期是指什么? 在 iOS 嵌入场景下,编译期指 Xcode 构建过程中执行 xcode_backend.sh 脚本的时刻。这个脚本会调用 Dart 工具链,把 .dart 文件转化为 iOS 能运行的产物。

  2. 会打包成二进制存储在 Mach-O 吗?

    • (在 Release 模式下)。

    • 但是,它通常是存储在 独立的动态库 Mach-O (App.framework) 中,而不是直接混入主程序的 Mach-O 中。

    • iOS 系统在启动 App 时,动态链接器 (dyld) 会自动加载主程序,并顺带加载 App.framework,此时 Dart 的二进制指令就被加载进内存执行了。

所以,Dart 在 Release 模式下的性能之所以接近原生,就是因为它最后确实变成了纯粹的 CPU 指令(Mach-O 二进制)

如果没有单独打包 flutter 产物 而是在构建 iOSapp 的时候 通过分支链接打包的 会有不同吗?

结论先行:即使你直接通过源码依赖(分支链接/CocoaPods本地路径)进行构建,最终的产物结构通常也是一样的。

你的 Dart 代码(Flutter 业务逻辑)在 Release 模式下,依然会被编译成一个独立的 Mach-O 文件,并包裹在 App.framework 中,而不是直接合并(静态链接)到主 App 的 Runner 可执行文件中。

让我们深入分析一下为什么会这样,以及这背后的构建逻辑。

1. 构建流程的本质:Xcode 只是个“包工头”

当你没有单独打成 .xcframework,而是直接在 Xcode 里引入 Flutter 模块构建时(比如通过 standard CocoaPods 集成):

  1. 触发构建:你在 Xcode 点击 Build。

  2. 执行脚本:Xcode 的 Build Phases 中有一步叫做 Run Script,它会执行 xcode_backend.sh

  3. 委托编译:这个脚本会“偷偷”去调用 Flutter 的命令行工具(Dart 编译器)。

  4. 生成产物:Flutter 工具链在幕后把 Dart 代码编译成机器码,生成 App.framework

  5. 嵌入:Xcode 把这个刚刚生成好的 App.framework 拷贝到最终 App 的 Frameworks 目录下。

所以,无论你是提前打好包拖进去,还是现场编译,最终在这个 App 的肚子里,它们都是以动态库(Dynamic Library)的形式存在的。

2. 为什么不合并进主程序的 Mach-O?(静态链接 vs 动态链接)

你可能在想:“既然我都一起编译了,为什么不能像 C++/OC 代码一样,直接把 Dart 代码编译出的二进制指令塞进主程序的 Runner 二进制文件里呢?”

这就涉及到了 Flutter 在 iOS 上的架构设计:

A. 默认行为:动态库 (Dynamic Library)

Flutter 官方目前的构建脚本,强制将 Dart 代码构建为动态库 (App.framework)。

  • 结构清晰:宿主代码 (Runner)、Flutter 引擎 (Flutter.framework)、Dart 业务代码 (App.framework) 三足鼎立。

  • 加载机制:iOS 启动时,dyld(动态链接器)会负责加载这三个独立的文件。

B. 静态链接 (Static Linking) —— 极其困难且非默认

要想把 Dart 代码直接打入主程序 Mach-O(即变成静态库 .a 链接进去),在目前的 Flutter 稳定版中是不支持或者是极难实现的,原因包括:

  1. Dart Snapshot 结构:Dart 的 AOT 产物不仅仅是指令,还有特殊的数据段结构,独立封装更容易管理内存。

  2. 符号冲突:如果合并在一起,可能会和宿主工程产生复杂的符号冲突。

  3. 构建脚本限制:官方的 xcode_backend.sh 就是写死了生成 Framework 的逻辑。

3. 唯一的“不同”点:构建时间与缓存

虽然产物结构(Mach-O 位置)没有不同,但构建体验会有不同:

特性单独打包产物 (Pre-built)源码/分支直接构建 (Source Integration)
构建速度 (Xcode 只需要拷贝文件) (Xcode 每次都要等 Dart 编译完成)
调试便利性差 (改了 Dart 代码要重新打包导出) (改了 Dart 代码,Xcode 再跑一下自动更新)
最终结构Runner + App.frameworkRunner + App.framework (一模一样)

总结

哪怕你是把 Flutter 代码作为 Git Submodule 或者是本地路径引入 Xcode 进行混合编译:

  1. 你的 Dart 代码 依然不会 混入主程序的 Mach-O 文件。

  2. 依然会 生成一个独立的 App.framework (包含独立的 Mach-O)。

  3. iOS 系统 依然是 在运行时通过动态加载的方式把这块代码跑起来的。

这种设计保证了 Flutter 模块的独立性,也最大化了与原生环境的解耦。

AOT (预先编译) 与 JIT (即时编译) 的工作原理

模式一:AOT 编译 (Ahead-of-Time)AOT

编译在程序运行之前发生,是 Flutter 发布正式版本(Release Mode)的核心技术。它的目标是提供最快的启动速度和最稳定的运行性能。

工作原理与产物

编译流程:Dart 代码在构建阶段被 Dart 编译器(dart2aot)直接编译成 原生机器码(如 ARM64 或 x64 指令集)。 •产物特点:生成的二进制文件(如 iOS 的 App.framework 中的 Mach-O)不依赖 Dart 虚拟机 (VM) 来解释执行,可以直接在 CPU 上运行。 •优化特点:由于缺乏运行时信息,AOT 编译器会做出相对保守的优化(例如,在虚方法调用上必须采用保守策略)。 使用场景:Release Mode(正式发布包)、Profile Mode。

模式二:JIT 编译 (Just-in-Time)

JIT 编译发生在程序执行期间,是 Flutter 调试模式(Debug Mode)的关键,旨在实现快速的开发周期和亚秒级的热重载

工作原理与机制

编译流程:Dart 代码首先被编译成 Kernel Snapshot(一种中间字节码)。在运行时,Dart VM 加载并解释执行这些字节码。当某段代码(热点 ‘Hot Spot’)被频繁执行时,JIT 编译器会将其即时编译成高度优化的机器码并缓存起来。 •核心优势:JIT 编译器在运行时可以访问实时性能数据和类型信息。这使得它能够进行更激进的优化,例如更精确的内联(Inlining)和推测性反虚拟化(Speculative Devirtualization)。

使用场景:Debug Mode(日常开发调试)、支持 Hot Reload。

性能的细微差别:为何 JIT 有时更快?

虽然 AOT 因其预编译和无运行时开销而具有更快的启动速度和更稳定的帧率,但在某些特定场景下,JIT 理论上可以达到更高的峰值性能: •运行时洞察:JIT 利用运行时分析数据(如某个接口方法的实际调用类型)来动态调整和优化代码,打破了 AOT 编译器的保守假设。 •UI 领域选择:尽管 JIT 可能在长时间运行的计算任务中占优,但 Flutter 仍选择 AOT 用于发布包,因为用户界面对启动速度和执行时间的可预测性要求极高,而 JIT 模式的启动较慢且需要“热身”时间。 image.png