Flutter 的 build_runner 已經今非昔比,看看 build_runner 2.13 有什麼特別?

相信寫過 Flutter 的人對 build_runner 印象不太好,主要是過去它的效能較差、失敗率偏高,所以很多人能不使用就不使用。

但這些年過去後,build_runner 已經改變不少。特別是從 v2.7.0 到 v2.13.0,透過加入 AOT 編譯、大幅減少 I/O 與序列化開銷、以及優化程式碼分析流程等改進,現在版本的效能已有明顯提升。

特別是 2.13.0 版本,在官方的測試中,針對大型增量構建實現了最高 4 倍的加速。具體測試環境與指標為:

  • 測試環境:在單 CPU 的 Linux 機器上執行,所有程式碼均經過 AOT 編譯(使用 --force-aot),消除 JIT 預熱時間的影響
  • 專案規模:以 library(庫)的數量(1000、5000、10000)來衡量專案複雜度
  • init(初始構建):從零開始的全量構建
  • incr(增量構建):在已有構建結果的基礎上,修改少量程式碼後的再次構建
  • web:同時應用 built_value 的產生與 DDC(Dart Dev Compiler)編譯的場景,更接近真實的 Web 開發流程

從測試結果可以看到:專案越大、提升越明顯。速度提升倍數如下:

  • 在 1000 個庫的小型專案中,初始構建提升約為 1.4x
  • 在 10000 個庫的大型專案中,初始構建提升達到 3.0x,而增量構建達到 3.9x

另外,在所有規模下,增量構建(incr)的提升倍數普遍高於初始構建(init):

  • 10000 庫的 incr 平均耗時,從 v2.12 的 45.72 秒,降到 v2.13.0 的 11.80 秒

另外,當 build_runner 搭配下一代 Dart 分析器(analyzer)優化後的表現:

  • 在 10000 個庫的增量構建中,速度提升可達 4.8x
  • 原本 v2.12 需 45.72 秒的任務,在雙重優化下只需 9.46 秒即可完成

以 10000 庫為基準,差異如下:

版本階段 初始構建 (init) 增量構建 (incr) Web 增量構建
v2.12 (舊版) 48.75s 45.72s 55.24s
v2.13.0 (新版) 16.12s (3.0x) 11.80s (3.9x) 18.11s (3.1x)
v2.13 + 新版分析器 12.48s (3.9x) 9.46s (4.8x) 15.63s (3.5x)

而且這還只是 2.13 對比 2.12,實際上從 2.10 開始就已經有不少改善。

v2.10.0 - v2.12.0 的一些重要性能改善包括:

  • AOT 編譯建構器 (v2.10.0):
    • 開始加入 --force-aot 標誌;過去 Builder 在 JIT 模式下冷啟動較慢,而 AOT 模式下 Builder 啟動更快且吞吐量提高
  • findAssets 擴展性優化 (v2.10.1):
    • 大幅提高在擁有數千個檔案的套件中進行前綴匹配的速度
    • 使用 source_gen 的專案(例如 built_value、json_serializable)可受益
  • 庫循環處理優化 (v2.10.3):
    • 優化分析驅動程序處理庫循環的邏輯,顯著加快大型程式碼庫的構建速度

因此直到 2.13,這些修改帶來的提升主要涉及以下幾點:

  • 不需要序列化的資源圖複製

    在 watch 或 serve 模式下,每次構建之間需要重置資源圖(Asset Graph)。舊版本需要將圖序列化到磁碟再讀回,造成大量 I/O 開銷。而 2.13 在 graph.dart 中引入了 copyForNextBuild 方法,直接在記憶體中複製節點樹,在頻繁儲存程式碼觸發的增量構建場景非常實用,例如:

    AssetGraph copyForNextBuild(BuildPhases buildPhases) {
    return AssetGraph._with(
      nodes: _nodes.clone(), // 直接複製記憶體中的節點,避免序列化
      // ... 複製其他元資料
      previousInBuildPhasesOptionsDigests: inBuildPhasesOptionsDigests,
      inBuildPhasesOptionsDigests: buildPhases.inBuildPhasesOptionsDigests,
      // ...
    );
    }
  • 復用語法錯誤計算結果

    Resolver 在處理 library 時需要檢查語法錯誤。透過復用 analyzer 已有的解析結果,可以避免重複計算,例如:

    Future<List<AnalysisResultWithDiagnostics>> _syntacticErrorsFor(
    LibraryElement element,
    ) async {
    final parsedLibrary = _driver.currentSession.getParsedLibraryByElement(element);
    if (parsedLibrary is! ParsedLibraryResult) return const [];
    
    final relevantResults = <AnalysisResultWithDiagnostics>[];
    for (final unit in parsedLibrary.units) {
      if (unit.diagnostics.any((error) => error.diagnosticCode.type == DiagnosticType.SYNTACTIC_ERROR)) {
        relevantResults.add(unit);
      }
    }
    return relevantResults; // 利用 Analyzer 會話中的快取結果
    }
  • 復用 Trigger 配置摘要

    Builder 可以配置 triggers(觸發器),只有滿足特定條件(例如存在特定註解或 import)時才會執行。v2.13.0 開始快取這些配置的摘要(digest),避免每次都重複解析:

    /// 只有當配置發生變化時,摘要才會改變
    late final Digest digest = md5.convert(utf8.encode(triggers.toString()));

triggers 並不是 2.13 新增的機制,但到了 2.13 它的普及度與作用已經相當顯著。

Trigger 可以展開來說明:在過去的 Builder 通常會掃描專案中的所有 .dart 檔案,而透過觸發器(triggers),Builder 可以宣告:「只有當檔案包含特定的 import 或特定的 annotation(註解)時才會執行。」換句話說:

  • 若不符合觸發條件:build_runner 只會做基礎的字串配對(不進行完整的符號解析),這比完整的 type resolve 快很多
  • 若滿足觸發條件:才會啟動 Builder 並進入後續的解析流程

例如你開發或適配一個 my_generator 套件,並提供 my_builder 生成器,你可以修改 build.yaml,在 builders 定義中開啟 run_only_if_triggered,並在頂層加入 triggers 配置塊:

# 1. 在生成器定義中開啟觸發模式
builders:
  my_builder:
    import: "package:my_generator/builder.dart"
    builder_factories: ["myBuilderFactory"]
    build_extensions: {".dart": [".g.dart"]}
    auto_apply: dependents
    # 核心配置:宣告 Builder 僅在被觸發時執行
    defaults:
      options:
        run_only_if_triggered: true

# 2. 定義具體的觸發規則(頂層設定)
triggers:
  # 格式為 "套件名:生成器名"
  my_generator:my_builder:
    # 觸發條件1:檔案匯入了特定的庫(只需寫庫路徑,系統會自動加上 package: 前綴)
    - "import my_generator/annotations.dart"
    # 觸發條件2:檔案使用了特定的註解名稱
    - "annotation MyCustomAnnotation"

目前支援以下兩種觸發器:

  • import 觸發器:
    • "import 路徑/檔名.dart":檢查原始程式碼的指令中是否包含該庫的 import 語句。當功能必須由匯入特定註解庫啟用時,這種方式非常高效。
  • annotation 觸發器:
    • "annotation 註解類名":檢查原始程式碼的所有宣告上是否附帶該註解名稱。即使沒有明確匯入(例如透過 export 間接匯入),只要檢測到匹配名稱就會觸發。

例如 built_value 就採用了類似的適配:只有在檢測到 import 'package:built_value/built_value.dart'@SerializersFor 註解時才會運作。對於無關的原始檔案,build_runner 會在日誌中顯示為 "not triggered" 或 "skipped":

總結到 2.13 版本,主要透過三大方向達成跳躍式效能提升:

  • 記憶體化操作:資產圖(Asset Graph)不再進行大量的磁碟 I/O 序列化
  • 計算復用:解析器(Resolver)復用分析器(analyzer)已存在的分析結果,避免重複計算語法錯誤
  • 流程精簡:透過 AOT 編譯與更聰明的觸發器摘要管理,減少 Builder 的預熱與無效啟動

不過,這並不代表你什麼都不用做就能享受所有改善,例如:

  • 第三方套件需要在其 build.yaml 中將 run_only_if_triggered 設為 true,並定義具體的 triggers,系統才能根據觸發器快速跳過不相關的原始檔案,避免進入耗時的解析階段
  • 開啟 AOT 後,第三方 Builder 的程式碼中不能使用 dart:mirrors;若 Builder 使用反射,就無法進行 AOT 編譯
  • analyzer 相關相依需升級到相對應的新版本;v2.13.0 透過 BuildResolver 深度復用了 analyzer 的語法錯誤計算結果

那麼,你覺得 build_runner 對你有用嗎?

連結

https://github.com/dart-lang/build/blob/master/build_runner/CHANGELOG.md


原文出處:https://juejin.cn/post/7619990292558348340


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝12   💬10   ❤️2
361
🥈
我愛JS
📝2   💬9   ❤️2
93
🥉
💬1  
4
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登