實際上 Flutter 無法適應 Android 主題字型是一個大家普遍都知道的問題,但是為什麼會這樣卻很少人講,**如果說是 Skia 自繪導致的,那 Compose 為什麼又支援**?今天就讓我們用「古法寫文章」來解釋這個問題。

更重要的是,四年了, Flutter 中文字重問題終於修復了。
首先我們要知道,系統字型一般是位於 /system/etc/ 下的靜態設定,並且在不同 Android 版本下具有很強的碎片化情形:
版本 路徑 特點
Android 4.0 - 4.4 /system/etc/system_fonts.xml 簡單的定義
Android 5.0 - 11.0 /system/etc/fonts.xml 加了版本號機制,支援透過語言標籤(lang)進行複雜的字型回退邏輯
Android 10.0+ /product/etc/fonts_customization.xml 廠商可以在 product 分割區進行非侵入式擴充
Android 12.0+ /data/fonts/files/ (動態) 引入 FontManagerService,支援透過資料分割區動態更新系統字型
Android 15.0+ 變數字型的新配置要放進 font_fallback.xml,fonts.xml 正在被棄用
所以在不同版本上它的行為並不一致,但可以確定的是,/system 分割區在執行時通常是唯讀的,這就導致設備製造商(例如小米、華為、Oppo)在實作主題商店中的字型一鍵切換時,不會直接修改 fonts.xml,過去普遍採用「執行時記憶體替換」或「反射注入」等方式。
簡單來說,過去這就是一個 Hook 操作,而 Hook 的核心通常是 android.graphics.Typeface,因為在原生 Android 裡,所有的字型請求最終都會轉成對 Typeface 物件的引用,例如:
sSystemFontMap 反射替換:廠商在系統啟動或主題切換時,透過 Java 反射機制修改 Typeface 類中的靜態成員變數 sSystemFontMap,這個 Map 存了從 family 名稱到具體 Typeface 實例的對應,當原生 TextView 呼叫 setTypeface 時,系統會從這個被 Hook 的 Map 中回傳指向主題字型檔案的實例。
LayoutInflater 攔截:在 XML 佈局渲染過程中,LayoutInflater 會處理 android:fontFamily 屬性,廠商也可以在 LayoutInflater.Factory2 中注入邏輯,在 View 建立階段就強制套用特定的 Typeface 實例。
Typeface 擴充:例如某些系統中會對 Typeface 做擴充,這些擴充會改寫字重(Weight)和風格(Style)的匹配邏輯,從而支援「無級字重調節」等場景。
而大家都知道,Flutter 之前走的是獨立的 Skia,現在是獨立的 Impeller(字型部分仍是 Skia libtxt/SkParagraph 的邏輯),但對於 Flutter 來說,Android 上的文字渲染主要走 engine/native 字型堆疊,不直接依賴 Java 層 android.graphics.Typeface 的解析結果,因此並不會被 Hook,簡單對比就是:
原生 TextView 的渲染使用 TextPaint,最終會呼叫 Canvas.drawText,而這個過程需要傳入一個 Typeface 物件,這個 Typeface 物件正是 Hook 生效的地方。
Flutter 的渲染流程是: Dart (TextStyle) -> C++ Engine (Libfont) -> Skia (SkFontMgr) -> 直接讀取 .ttf 檔案 -> GPU 繪製
所以 Flutter 大部分時候會忽略系統的主題字型,只會使用預設字型,而這個問題在 Flutter Web 更複雜,因為現在 Flutter Web 預設都是 CanvasKit 模式,是透過編譯成 WebAssembly 在 Web 上執行,為了保證渲染一致性,CanvasKit 往往會直接載入開發者提供的字型檔案,或者使用一套預定義的預設字型:
也就是 Flutter Web 與系統(Android 或 iOS)的字型系統幾乎完全隔離,所以更不用說了。
那問題來了,為什麼使用 Skia 的 Compose 可以呢?
這是因為雖然 Compose 在 Android 的繪製階段,確實透過 Skia(透過 RenderNode 或 HardwareCanvas 提供支援)繪製,但它在字型解析階段並沒有像 Flutter 那樣完全隔離,例如:
Compose 的 Text 元件使用 FontFamily.Resolver 來處理字型請求。
在 Android 平台上,Compose 的 FontFamily.Resolver 實作會呼叫 androidx.compose.ui.text.platform 中的邏輯,Compose Android 的文字棧會解析到 Android 的 Typeface / TextPaint / Layout 實作上,而不是像 Flutter 那樣完全走一套獨立的引擎字型尋找鏈路。
所以 Compose 實際上是在 Java 層向系統請求 Typeface 物件,它自然會觸發設備製造商在 Typeface 類中注入的 Hook。
Compose 拿到系統回傳的 Typeface 後,會把它包含的資訊轉化為底層渲染引擎可識別的參數,因為它使用的是 Android 系統自帶的 Skia 環境,而 Android 系統的 Paint 類原生就支援將 Typeface 物件傳給底層的繪製指令。
當然,如果主題字型的實作是透過 Hook TextView 的方案,那對 Compose 也是不支援的,Compose 只對 Typeface 的 Hook 方案生效。
額外提一點: Flutter 3.41 新增
FontWeight直接控制變數字型wght軸的能力,FontWeight現在支援1-1000任意值,同時FontWeight.index被廢除,改為FontWeight.lerp連續插值,但這與 Android 系統的可變字型(variable font)並不是同一回事。
同時,Flutter 引擎的字型管理仍然主要依賴 Skia,或者說在 Impeller 中嵌入的 HarfBuzz/FreeType 等,並沒有透過 Android NDK 的 AFontMatcher 來匹配系統字型,所以 Flutter 並不會使用 Android 系統目前主題的字型映射。
最後,我們透過文字渲染的三個核心階段來理解 Flutter 裡的字型渲染,將文字從一個 String 變成螢幕上的像素,需要經過三個階段:
它們的關係就像流水線一樣:

所以,Skia 的 SkFontMgr 負責的就是我們所說的匹配字型部分:
而 HarfBuzz 文本整形引擎(Text Shaper)解決的是一個字串不能簡單逐字元渲染的問題:
fi 組合可能變成一個連字(ligature)👨👩👧👦(一家人)其實是多個 code point + ZWJ(零寬連接符)組合HarfBuzz 讀取字型檔中的 OpenType 排版表(GSUB/GPOS 等),決定:
而最後 FreeType 字型解析與光柵化引擎,主要負責:
回到匹配字型問題,在 Android 上,Flutter 使用 Skia 的 Android 字型管理器,或詳見 flutter/engine/third_party/txt/src/txt/platform_android.cc:

這裡的 SkFontMgr_New_Android(nullptr) 就是 Skia 提供的 Android 字型管理器,傳入 nullptr 意味著使用預設設定,也就是去解析 /system/etc/fonts.xml 來發現系統字型,SkFontMgr_New_Android 內部就使用了 FreeType 來解析字型檔案。
同時,透過 flutter/engine/impeller/typographer/backends/skia/text_frame_skia.cc 可以看到,即使是 Impeller 的 Typographer 後端,也叫 TypographerContextSkia,它從 SkTextBlob(Skia 的文字物件)中擷取 Glyph 資訊,然後用 Impeller 自己的渲染管線去繪製:

所以 Impeller 替換的是「最終的 GPU 繪製管線」,而不是文本整形和字型管理;字型發現(
SkFontMgr)、文本整形(HarfBuzz)、字型解析(FreeType)這些工作目前仍然是在 Skia 生態系內完成。
而設備廠商的主題字型(例如小米主題商店下載的字型)通常不會寫在 fonts.xml 裡面,即便在高版本 Android 是透過 FontManagerService 動態替換,但 SkFontMgr_New_Android 不會呼叫 FontManagerService,它只是靜態地讀檔,所以 Flutter 的 SkFontMgr 根本看不到 OEM 動態替換的主題字型。
這就是為什麼 Flutter 至今無法自動渲染手機廠商自訂主題字型的原因。問題不在 Impeller 還是 Skia,問題在「字型發現」這一層用的是檔案解析而非系統 API。
而 Compose 在 Android 上的系統字型解析,仍然會接入 Android 平台的 Typeface / 字型解析能力,因此如果 OEM 的主題字型替換落在這條鏈路上,通常情況下 Compose 還是會生效。
不過在其他平台,例如 iOS 上 Compose 就和 Flutter 差不多,例如在 Compose Multiplatform 的 1.6.0 中就提供了
SystemFont來支援開發者直接呼叫作業系統已安裝的字型,所以 Compose 在 Android 平台相對 Flutter 有歷史性的字型優勢。
最後,不得不提一下,我在 2022 年提出的一個中文字重渲染不正確的問題,最近終於被解決了,感動萬分:

主要原因是因為 CTFontCreateForString() 的 API 不會保留輸入字型的 fontWeight,一般來說,Flutter 的文字渲染鏈路是:

而當渲染的字元(中文)不在當前主字型(SF Pro)中時,Skia 需要透過 CoreText 的 CTFontCreateForString() 進行 fallback 字型查找,問題是 CTFontCreateForString() 在查找 fallback 字型時,會忽略傳入的 fontWeight,這就導致永遠回傳 weight=400 (Regular) 的字型。
而英文在 SF Pro 內,所以不會受影響,只有 CJK 字元才會觸發 fallback 導致出現問題,這也是為什麼明確指定 PingFang SC 作為主字型或 fallback 可以解決問題的原因;CoreText 在查找 PingFang SC 的字重時走的是 matchFamilyStyle(按字族名匹配字重),而不是 CTFontCreateForString(),因此字重會被保留。
最終修復是在 Skia 上做了處理,其實就是直接修改 SkFontMgr_mac_ct.cpp 中的 fallback 邏輯,讓 CoreText 在字元 fallback 時正確保留字重即可:

那這個問題為什麼拖這麼久?因為要等 Skia 有時間去修復和支援啊,雖然上層可以做一個 Wrapper 來暫時處理,但 Flutter 團隊還是覺得補丁不應該在上層堆砌。
其實這也是為什麼 Flutter 要做 Impeller 的原因,Skia 的問題推進速度太慢了,如果真的等 Skia 的 Graphite 去做著色器預熱,還真不知道要等到何年;畢竟 Skia 團隊要考慮的事情很多,而現在 Impeller 反而在回饋 Skia 的 Graphite,甚至 Impeller 都開始被 Avalonia 投資移植到 .NET 平台,只能說當時做 Impeller 真的是 Flutter 最正確的決定。
所以這個坑多年來終於還是等到 Skia 團隊的補丁,也算是解決了一個陳年小刺。