yii.
← Writing
Flutter 技術 · · 11 min read · Read in English

你的 App 今年秋天可能開不起來: Flutter 3.47 與 Dart 3.13

Material 被搬出 SDK、iOS 27 強制 UIScene、Impeller 上桌機。這一版不是加功能,是搬地基,而且有兩個死線。

來源:What’s new in Flutter 3.47(Emma Twersky,2026–08–12)
Announcing Dart 3.13(Connie Ooi,2026–08–12)


前言:這次的 release note 要用不同的方式讀#

大部分 Flutter 版本更新,你掃一遍標題、記兩個新 widget、關掉分頁,日子照過。

這一版不行。

因為裡面有兩件事帶死線,而訂死線的不是 Flutter 團隊,是 Apple 和 11 月的下一個 stable。錯過的後果也不只是少個新功能:你的 app 用新版 Xcode build 出來會直接啟動失敗。

我把兩篇官方公告讀完,最後的結論很簡單:這一版真正需要你排進行事曆的只有兩件事,其他全部是背景音。

  • Material 和 Cupertino 被搬出 SDK 了,11 月的 Fall stable 正式 deprecate 內建版本
  • iOS 27 強制 UIScene lifecycle,沒採用的 app 用 Xcode 27 build 會啟動失敗

剩下的 Impeller 上桌機、Wasm 進展、Dart 的 primary constructors,都很好,但都可以慢慢來。

這篇文章按「該不該現在做」的順序講,不按官方公告的順序。


1. Material 搬家了#

一個 Chip 的 padding 差 2px,Flutter 團隊修好之後,你要等三個月才拿得到。

原因是 package:flutter/material.dart 住在 SDK 裡面,跟 rendering、gestures、scheduler 這些真正的核心綁在同一個 repo、同一個發版節奏。改一行 Material,也只能排隊等下一次季度 stable,跟著整包 engine 一起出貨。

反過來也一樣:你只是想要新版的 NavigationBar,卻得把整個 SDK 版本往上跳,順便承擔所有 engine 變更的風險。

Flutter 團隊在 3.47 做的事就是把這條繩子剪斷。

關鍵洞察:material_ui 和 cupertino_ui 已經在 pub.dev 上發布 1.0,改成每週發版,不再綁季度 SDK release。

這是把 design system 從「框架的一部分」降級成「框架的一個使用者」。降級聽起來像壞事,其實是解放。Material 從此可以用自己的節奏演化,Flutter core 也不用再假裝自己懂設計。

官方講得更直白:這是為了鋪路一個 style-neutral 的 core widget catalog。翻譯成人話:未來的 Flutter core 不預設任何設計語言,你要 Material 就裝 Material,要自己那套就自己接。對長期維護自家 design system 的團隊,這是門檻大幅下降。

順帶一提,Material 和 Cupertino 的社群貢獻從今年 4 月起就凍結了,就是為了讓這次搬家能乾淨完成。現在解凍,而且官方明確說了:開放社群 PR,計畫每週發版。搬資料夾只是表面,真正的變化是一個關了四個月的區域被重新打開。

節奏的變化在 pub.dev 上看得很直接:

0.0.1 到 0.0.2 中間隔了約四個月,之後發版間隔一路縮短:13 天、8 天、1 天。發布者掛的是通過驗證的 flutter.dev,不是社群 fork。

死線在哪#

本版核心 SDK 還是內建這兩套,遷移是 opt-in,所以你這個月不動也不會壞。

問題在下一版:

核心 SDK 內建的 Material / Cupertino,預計在今年 11 月的 Fall stable release 正式 deprecate。

也就是說,從現在(8 月中)到 11 月,你有大約三個月的緩衝期。三個月對一個中型 app 來說不算太趕,但如果你的專案依賴一堆第三方套件,而它們也都要跟著遷,那就不一定夠了。

好消息是官方想到了這件事。


2. 怎麼遷,還有那座橋#

遷移本身是一行指令:

dart fix --apply --code=migrate_design_widgets

它會自動把你的 import 從 package:flutter/material.dart / package:flutter/cupertino.dart 改成新的獨立套件。

pub.dev 上的 readme 也把遷移步驟寫進去了:

官方有標一個已知 bug:如果這個工具改不動你的 pubspec.yaml,手動補一下再跑一次就好。

flutter pub add material_ui       # 有用到 Cupertino 就再加 cupertino_ui
dart fix --apply --code=migrate_design_widgets

這裡有一件官方公告沒提、但 pub.dev 上查得到的事:material_ui 1.0.0 的 Min Dart SDK 是 3.12,不是 3.13。

換句話說,你不必先升到 Flutter 3.47 才能開始遷移。Flutter 3.44(Dart 3.12)就吃得下這個套件。這讓「遷 UI 套件」和「升 SDK」可以拆成兩件事分開排,風險小很多。

真正的關鍵是這座橋#

但 import 改完只解決了你自己的 code。你依賴的那 30 個第三方套件呢?它們還在 import 'package:flutter/material.dart'。

以往這種情況只有一個結局:卡死,等生態系對齊。你什麼都不能做,只能發 issue 然後刷 GitHub。

3.47 給了一個叫 MaterialUiCompatibilityBridge 的東西,直接把這個死結解開:

import 'package:material_ui/material_ui.dart';
 
class MyApp extends StatelessWidget {
  const MyApp({super.key});
 
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4)),
      ),
      // 這一行讓還在用舊 import 的依賴繼續正常運作
      builder: (BuildContext context, Widget? child) {
        return MaterialUiCompatibilityBridge(child: child!);
      },
      home: const HomeScreen(),
    );
  }
}

一個 builder 包起來,你的 app 可以立刻遷到獨立套件,依賴慢慢等。

這個設計值得多看一眼。它把「大規模遷移」從一場需要全生態系同步的協調行動,降級成一件各自可以獨立完成的事。

當年 null safety 之所以推得動,靠的也是同一招:mixed-mode 讓還沒遷的依賴繼續跑,你不必等最後一個套件作者醒來。沒有這種過渡機制的遷移,通常會拖到大家忘記為止。

我自己會先開一個分支跑一次遷移,純粹為了看兩個數字:多少檔案被改到、多少依賴還吃舊 import。這兩個數字決定你是「這週順手做完」還是「要跟 PM 排一個 sprint」。

Localization 也一起拆了#

flutter_localizations 同樣被拆散:Material 和 Cupertino 的 delegate 與翻譯字串,現在分別住進 material_ui 和 cupertino_ui。

官方這張分層圖把「被抽走的是哪一層」講得很清楚。Material_UI、Cupertino_UI 和 flutter_localizations 一起被拉出 Framework 層,底下的 Widgets / Rendering / Foundation 原封不動:

改法反而變簡單了。以前你要列三個 delegate:

import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/material.dart';
 
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
  GlobalCupertinoLocalizations.delegate,
  GlobalMaterialLocalizations.delegate,
  GlobalWidgetsLocalizations.delegate,
],

現在一行:

import 'package:material_ui/material_ui.dart';
 
localizationsDelegates: GlobalMaterialLocalizations.delegates,

注意是 delegates(複數)不是 delegate。這個 getter 已經把 Cupertino 和 Widgets 的 delegate 都包進去了。

如果你是套件作者#

官方給了一句很重的話:這次遷移應該視為 major release。

不是 patch,不是 minor。你的套件 API surface 上的型別來源整個換了地方,下游必須知道。


3. iOS 27 的最後通牒#

換一個更急的。

Xcode 27、iOS 27、macOS 27 今年秋天到。而 iOS 27 SDK 帶了一個強制要求:

所有 UIKit-based app 必須採用 UIScene lifecycle。用 Xcode 27 build 但沒採用的 app,會在啟動時直接失敗。

請注意這句話的性質。不是 deprecation warning,不是 pub.dev 扣分,是 crash on launch。

好消息:大部分 app 不用做任何事,Flutter CLI 在 build 的時候會自動處理這個遷移。

壞消息:有兩種情況它處理不了,需要你手動遷移:

  1. 你的 AppDelegate 有自訂的 native code
  2. 你用到的 plugin 還在吃舊的 application lifecycle

為什麼這件事值得現在做#

因為這兩個判斷條件,CI 測不出來。

你的 CI 現在跑 Xcode 26,一路綠燈。等到秋天 Xcode 27 GA、CI image 一換,你才會發現問題,而那時候你可能正在趕一個上架期限。

更麻煩的是第二個條件(plugin 還在吃舊 lifecycle)根本不在你的控制範圍內。你得一個一個去看。

手動遷移的步驟在 Apple 的 TN3187 技術文件裡。

所以官方的建議是對的,而且我認為應該加重語氣:現在就拿 Apple 的 beta 版跑一次你的 app。這是這一版唯一一件我會建議「這週就做」的事。

最低版本也被拉高了#

為了支援 Xcode 27,最低支援版本往上調了:

  • iOS — 13 → 15
  • macOS — 10.15 → 12

如果你的用戶群裡還有 iOS 13/14,這是需要跟 PM 談的事,不是工程單方面可以決定的。


4. Apple 平台的其他清算#

同一個秋天,Apple 生態還有兩件事在收尾。

Intel Mac 開始退場#

Flutter 跟著 Apple 一起往 Apple Silicon 走:

  • 已經停掉 Intel 硬體上的自動化測試
  • CLI 在 Intel 主機上 build、或 build 雙架構時,開始印警告
  • 這些警告未來會變成 error

想提前切乾淨的話:

flutter config --enable-macos-arm64-only

CocoaPods 進入 maintenance mode#

Swift Package Manager 的遷移進度比我想像的好:

前 100 大 iOS plugin 裡,已經有 92 個完成 SwiftPM 遷移。

官方的措辭已經從「建議」變成「警告」了:CocoaPods 現在是 maintenance mode,沒遷移的 plugin 最終會停止運作,而且在 pub.dev 上的分數會被扣。

之前試過覺得不穩而關掉的人,現在值得再試一次:

flutter config --enable-swift-package-manager

build 時間也順便快了一點:社群貢獻者 @lukemmtt 讓 build pipeline 提早過濾掉不必要的 SwiftPM package scheme。


5. Impeller 上桌機,Skia 開始倒數#

講完死線,來講這一版最漂亮的東西。

先講那個你一定遇過的卡頓#

你的動畫第一次播放時會頓一下,第二次以後就順了。你可能以為是「還沒熱起來」,或是「debug mode 就這樣」。

不是。那叫 shader compilation jank。

Skia 的做法是在 runtime 動態編譯 shader。第一次需要某個 shader 時才去編譯,編譯要時間,那一幀就掉了。所以你看到的是:第一次頓,之後順,因為 shader 被 cache 住了。

Impeller 的做法是把這件事整個搬到 build time:

Impeller 在編譯期就把一組固定的 shader 全部編好,runtime 不再動態編譯。第一幀就是順的。

這不是「優化」,是把問題的類別消掉。動態編譯的抖動不會被調校到消失,只會被搬到別的時機出現;預編譯則是根本沒有那個步驟。

3.47 的變化#

Impeller 現在是 macOS、Windows、Linux 的預設 renderer。它走的是各平台的現代圖形 API:macOS 用 Metal,Windows 和 Linux 用 Vulkan。

還可以 opt-out,但官方講得很硬:

macOS   → Info.plist 裡 FLTEnableImpeller = false
Windows → main.cpp 裡 project.set_impeller_switch(flutter::ImpellerSwitch::Disabled)
Linux   → my_application.cc 裡 fl_dart_project_set_enable_impeller(project, FALSE)

「Fallback 選項會在未來版本移除。如果你非得退回 Skia,請開 issue。」

翻譯:這些開關是給你回報問題用的,不是給你長期依賴的。真的踩到問題請去開 issue,不要默默關掉。

桌機同時拿到的兩個視覺升級#

  • macOS 預設開啟 Wide Gamut Color:支援的硬體上色彩更飽和精確
  • 桌機文字改用 SDF(Signed Distance Function)渲染:桌機螢幕的 pixel density 通常比手機低,但 GPU 算力更充裕,SDF 正好拿算力換清晰度,文字和向量曲線都更銳利

三件事加起來,訊號很清楚:Flutter 開始把桌機當作一級的高品質圖形目標。

桌機還拿到了什麼#

順帶一提幾個桌機的實用進展:

  • Windows 和 Linux 支援 flavors 了,assets 可以依 flavor 分流:
flutter:
  assets:
    - path: assets/flavor_a/images
      flavors:
        - flavor_a
flutter build windows --flavor flavor_a
flutter build linux --flavor flavor_a
  • 多視窗(與 Canonical 合作,仍是 experimental):Linux 和 Windows 支援 popup window,可以做原生 context menu 和工具面板;也可以查詢 windowHandle 拿到底層原生視窗指標(HWND / NSWindow / GtkWindow),做像 Windows dockable pane 這種進階整合

多視窗我會保守看。官方這張示範圖右上角還掛著 DEBUG 橫幅,跑的是 reference app 而不是產品,這大致就是這條線目前的成熟度。要出貨桌機產品,通常需要的正好就是多視窗,而它還掛著 experimental 標籤。還沒到可以下注的時候。


6. Wasm 補上了最後一塊拼圖#

Flutter Web 的長期方向已經很明確了:Wasm by default。

現在還是 opt-in:

flutter build web --release --wasm

前提是硬性的,沒有商量空間:

dart:html 和 package:js 在 dart2wasm 完全不支援。必須遷到 package:web + dart:js_interop。

這兩個 legacy library 早在 Dart 3.7 就 deprecated 了,而且官方說未來連 dart2js 也會停止支援。多數情況下把套件依賴升級就自動解掉了。真正麻煩的是你自己直接呼叫這些 API 的地方。

為什麼 deferred loading 是關鍵#

以前 Wasm 有個尷尬:小型 app 用起來很好,但大型 app 的初始 bundle 太大,初次載入反而輸給 dart2js。

3.47 / 3.13 補上的就是這塊:deferred loading。

Dart 端(實驗性 flag):

dart compile wasm -O2 --enable-deferred-loading

Flutter 端(main channel,實驗性):

flutter build web --release --wasm --enable-wasm-deferred-loading

官方的說法是:在使用 deferred loading 的大型應用中,dart2wasm 的初次載入時間(IPL)相較 dart2js 有顯著改善,DOM-based 的 web app 和 Flutter web app 都受惠。

換句話說,Wasm 從「小 app 才划算」翻轉成「大 app 更划算」。這正是預設化的前置條件。你不會把一個「專案越大越吃虧」的東西設成預設值。

(一個實作細節:Dart 端的 deferred loading 需要你的 embedder 提供一個載入 wasm module bytes 的 callback,細節在產生的 <app>.mjs 裡的 CompiledApp.instantiate 文件。)


7. Dart 3.13:四個層次一起減肥#

Dart 3.13 的官方標題是「conciseness and simplicity」。讀完會發現這不是行銷詞:語言、格式、編譯、連結四個層次真的都在減量。

語言層:primary constructors 正式 stable#

在 3.12 experimental 之後,這個語法轉正了:

class Point(final int x, final int y);

一行。欄位、型別、建構子,全部在裡面。以前要寫的那一整段樣板碼消失了。

搭配 new 和 factory 關鍵字的簡潔建構子語法,空的宣告 body 可以直接用 ; 結尾。

我覺得更值得注意的是配套。官方沒有丟個語法就走人,而是同時給了六個 lint(都有 auto-fix):

  • empty_container_bodies — 用 ; 取代空的 {}
  • initialize_in_field_declaration — 把欄位初始化從 constructor 搬到宣告處
  • unnecessary_const_in_enum_constructor — 拿掉 enum constructor 的 const
  • unnecessary_primary_constructor_body — 拿掉不需要的 primary constructor body
  • unnecessary_type_name_in_constructor — 用 new 取代重複的型別名
  • use_declaring_parameters — 能用 declaring parameter 的地方就用

以及四個 IDE refactoring:Convert to primary constructor、Convert to in-body constructor、Convert to declaring parameter、Move initialization to the field declaration。

語法 + lint + auto-fix + IDE refactoring 一次到位,這是一套完整的遷移工具鏈,不是一個孤立的語法糖。

格式層:dart format 會自動分區你的 import#

這一項我認為是全篇對日常影響最大的東西,因為它每個檔案都會碰到。

formatter 現在會依 Effective Dart 的規則,在不同「區塊」的 import 之間插入空行。注意它只分區、不排序:

// Before:
import 'dart:io';
import 'dart:math';
import 'package:args/args.dart';
import 'package:test/test.dart';
import 'my_library.dart';
 
// After:
import 'dart:io';
import 'dart:math';
 
import 'package:args/args.dart';
import 'package:test/test.dart';
 
import 'my_library.dart';

另外兩個看得見的改動:

一是修掉了一個 optimization bug,method call 裡包大型 collection literal 時會排版錯誤:

// Before:
await MethodChannelContainer()
    .onMethodChannelInvoke('reportCrash', <String, Object?>{
      'time': nowTime,
      'errorValue': errorName,
    });
 
// After:
await MethodChannelContainer().onMethodChannelInvoke(
  'reportCrash',
  <String, Object?>{
    'time': nowTime,
    'errorValue': errorName,
  },
);

二是 method chain 的斷行啟發式改了。當 chain 的 target 是只有單一元素/單一參數的 collection literal 或 function call 時,formatter 現在偏好斷 chain 而不是斷 target:

// Before,斷 target:
function(
  argument,
).method().another();
 
// After,斷 chain:
function(argument)
    .method()
    .another();

這裡有個實務上的坑:除了那個 bug fix 之外,這些格式改動都是 language-versioned 的,只有當你把程式碼升到 Dart 3.13 之後才會生效。

意思是升版後你的第一個 commit 大概會是整包 reformat。建議把它單獨開一個純格式化的 commit,不要跟功能改動混在一起,否則 code review 會變成災難。

官方自己也承認格式變動會造成 churn,所以他們一向保守。但公告裡有一句話我覺得說得很好:在這個大家因為 generative AI 而審閱的 code 比以往任何時候都多的時代,可讀性的改善是有實質價值的。

連結層:原生函式庫終於能 tree-shake 了#

這是這次最結構性的改動。

長期以來的狀況是這樣:你用 dart:ffi 和 Code Assets 接了一個 native library(SQLite、加密、影像解碼、音訊引擎),Dart compiler 會把沒用到的 Dart wrapper function tree-shake 掉,但底層那包 native binary 原封不動全部打進去。

打個比方:SQLite 暴露的 function 數以百計,你的 app 可能只碰到其中幾個,其餘的機器碼還是原封不動躺在 bundle 裡。

@RecordUse()(在 package:meta)加上 package:record_use 把這件事解決了:

第一步,標記 FFI binding(ffigen 產生的 binding 會自動標,你通常不用手動寫):

import 'dart:ffi';
import 'package:meta/meta.dart';
 
@RecordUse()
@Native<Int32 Function(Int32, Int32)>()
external int sqlite3_open(
  Pointer<Utf8> filename,
  Pointer<Pointer<sqlite3>> ppDb,
);

第二步,compiler 追蹤可達性。AOT build 做 whole-program compilation 和 tree-shaking 的時候,會記錄哪些被標記的 binding 真的在可執行的 code 裡被碰到。躺在已經被 tree-shake 掉的 Dart code 裡的呼叫,自動排除。

第三步,link hook 裁剪 native symbol。在你的 hook/link.dart 裡讀 input.recordedUses,叫 native toolchain 只保留 Dart 真的會呼叫的 symbol:

void main(List<String> arguments) async {
  await link(arguments, (input, output) async {
    // 取出可達 Dart code 裡實際呼叫到的 symbol
    final symbolsToKeep = input.recordedUses?.calls.keys
        .cast<Method>()
        .map((method) => recordUseMapping[method.name]!);
 
    await cLibrary.link(
      input: input,
      output: output,
      linkerOptions: LinkerOptions.treeshake(
        symbolsToKeep: symbolsToKeep,
      ),
    );
  });
}

最漂亮的是這個 case:如果你的 app 從頭到尾沒呼叫過某個套件的 native binding,link hook 會把整包 native binary 從最終 bundle 裡直接拿掉。

要講清楚的是:這主要是給套件作者的功能,不是給 app 開發者的。你要加註解、要在 link hook 裡寫查詢邏輯。app 端只會被動享受到成果。

但這件事的真正意義是:「引入一個重量級 native 依賴」的成本,第一次跟「你實際用到多少」掛鉤了。

引擎內部#

官方公告最後列了三個月來檯面下的工程進展,其中兩個值得記一下:

  • 修掉一個 type promotion 的 unsoundness(涉及 nested function 的罕見情況)。這是型別系統的正確性問題,不是效能優化。
  • 在 Dart heap 外圍加了一層 memory cage,強化 native runtime 的記憶體安全。

另外還有兩個方向性的訊號:DDC 正在統一模組系統(清掉 legacy),以及開始實驗 dynamic modules(動態程式碼連結),官方講的應用場景是「團隊內快速分享原型」這類開發流程改善。這個值得往後盯。


8. 接下來三個月怎麼排#

把上面全部壓成一個行動順序:

這週就做(風險最高、CI 測不出來)

  1. 拿 Xcode 27 / iOS 27 beta 跑一次你的 app,確認 UIScene 沒問題
  2. 檢查 AppDelegate 有沒有自訂 native code(要手動遷的話照 Apple TN3187 走)
  3. 盤點你的 plugin,哪些還在吃舊的 application lifecycle
  4. 跟 PM 確認 iOS 15 / macOS 12 的最低版本影響到多少用戶

這個月做(有 11 月死線)

  1. 開一個分支跑 dart fix --apply --code=migrate_design_widgets,看災情規模
  2. 決定要不要用 MaterialUiCompatibilityBridge 先行遷移(記得:Min Dart SDK 只要 3.12,不必等升到 3.47)
  3. 如果你維護套件,排一個 major release
  4. 順手把 localization 改成 GlobalMaterialLocalizations.delegates

這一季做(沒有硬死線,但方向明確)

  1. 重新試一次 SwiftPM(flutter config --enable-swift-package-manager)
  2. 用 --wasm build 一次 web,看還有多少 legacy interop 要清
  3. 升 Dart 3.13,並且把 dart format 造成的整包 reformat 開成獨立的 commit
  4. Intel Mac 開發機:開始規劃汰換

知道就好

  1. Impeller 桌機預設、Wide Gamut、SDF 文字 — 免費升級,不用做事
  2. Widget Previews 進 stable — 有本地快取(.widget_preview/)、PreviewThemeData 多主題矩陣測試、預覽 web widget 時自動同步 web/ assets
  3. Android 依賴矩陣:Java 17、KGP 2.4.0、AGP 9.1.0、Gradle 9.3.1;compileSdk / targetSdk = 36、minSdk = 24
  4. 多視窗仍是 experimental — 觀望

9. 總結#

Flutter 3.47 表面上是「Modular by design」,實際上是一次地基搬遷:Material 從框架的一部分變成框架的一個使用者,Skia 從預設渲染引擎變成過渡選項,Web 從 JS 往 Wasm 挪。搭配 Dart 3.13 在語言、格式、編譯、連結四層同步減量,整個平台在往「核心更小、周邊更靈活」的方向收斂。

但對你這個月的工作來說,重點只有兩句話:

iOS 27 的 UIScene 是 hard failure,而且 CI 測不出來。現在就拿 beta 驗。

Material 遷移有 11 月死線,但 MaterialUiCompatibilityBridge 讓你不必等生態系,可以現在就開始。

其他的,慢慢來。

升級前值得先掃一遍官方的 Breaking Changes,以及兩篇原始公告:What’s new in Flutter 3.47、Announcing Dart 3.13。


Resources#

本文原刊登於 Medium。