你的 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 的時候會自動處理這個遷移。
壞消息:有兩種情況它處理不了,需要你手動遷移:
- 你的
AppDelegate有自訂的 native code - 你用到的 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 的constunnecessary_primary_constructor_body— 拿掉不需要的 primary constructor bodyunnecessary_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 測不出來)
- 拿 Xcode 27 / iOS 27 beta 跑一次你的 app,確認 UIScene 沒問題
- 檢查
AppDelegate有沒有自訂 native code(要手動遷的話照 Apple TN3187 走) - 盤點你的 plugin,哪些還在吃舊的 application lifecycle
- 跟 PM 確認 iOS 15 / macOS 12 的最低版本影響到多少用戶
這個月做(有 11 月死線)
- 開一個分支跑
dart fix --apply --code=migrate_design_widgets,看災情規模 - 決定要不要用
MaterialUiCompatibilityBridge先行遷移(記得:Min Dart SDK 只要 3.12,不必等升到 3.47) - 如果你維護套件,排一個 major release
- 順手把 localization 改成
GlobalMaterialLocalizations.delegates
這一季做(沒有硬死線,但方向明確)
- 重新試一次 SwiftPM(
flutter config --enable-swift-package-manager) - 用
--wasmbuild 一次 web,看還有多少 legacy interop 要清 - 升 Dart 3.13,並且把
dart format造成的整包 reformat 開成獨立的 commit - Intel Mac 開發機:開始規劃汰換
知道就好
- Impeller 桌機預設、Wide Gamut、SDF 文字 — 免費升級,不用做事
- Widget Previews 進 stable — 有本地快取(
.widget_preview/)、PreviewThemeData多主題矩陣測試、預覽 web widget 時自動同步web/assets - Android 依賴矩陣:Java 17、KGP 2.4.0、AGP 9.1.0、Gradle 9.3.1;
compileSdk/targetSdk= 36、minSdk= 24 - 多視窗仍是 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#
- What’s new in Flutter 3.47 — Flutter 官方公告
- Announcing Dart 3.13 — Dart 官方公告
- material_ui / cupertino_ui — pub.dev 上的獨立設計套件
- Flutter Breaking Changes — 升級前值得掃一遍
- Apple TN3187: Migrating to the UIKit scene-based life cycle — UIScene 手動遷移的官方技術文件
本文原刊登於 Medium。