Android 上為什麼主題字型對 Flutter 不生效,對 Compose 生效?Flutter 中文字型問題修復

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

image-20260309161336079

更重要的是,四年了, 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.xmlfonts.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(透過 RenderNodeHardwareCanvas 提供支援)繪製,但它在字型解析階段並沒有像 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 變成螢幕上的像素,需要經過三個階段:

  • 字型載入與匹配:例如 "sans-serif 粗體對應哪個 .ttf 檔案?",透過 SkFontMgr 實作(Skia 的字型管理器)
  • 文本整形(Shaping):例如 "這段阿拉伯文/印地語應該怎麼連字、換序?每個字元用字型裡的哪個 Glyph?",透過 HarfBuzz 實作
  • 光柵化(Rasterization):例如 "把這個 Glyph 輪廓變成像素位圖",透過 FreeType 實作

它們的關係就像流水線一樣:

所以,Skia 的 SkFontMgr 負責的就是我們所說的匹配字型部分:

  • 掃描系統字型目錄
  • 解析字型設定檔
  • 按字型名 + 字重 + 風格匹配到具體的 .ttf 檔案
  • 提供回退鏈(找不到字元時自動換字型)

而 HarfBuzz 文本整形引擎(Text Shaper)解決的是一個字串不能簡單逐字元渲染的問題:

  • 阿拉伯文:字母在詞首、詞中、詞尾形態不同,還要從右到左排列
  • 印地語/泰語:元音符號要疊在輔音上方或下方
  • 英文:fi 組合可能變成一個連字(ligature)
  • Emoji:👨‍👩‍👧‍👦(一家人)其實是多個 code point + ZWJ(零寬連接符)組合

HarfBuzz 讀取字型檔中的 OpenType 排版表(GSUB/GPOS 等),決定:

  • 每個字元對應字型中的哪個 Glyph ID
  • 每個 Glyph 的精確位置偏移
  • 哪些字元需要合併、替換、重新排序

而最後 FreeType 字型解析與光柵化引擎,主要負責:

  • 解析字型檔:讀取 .ttf/.otf 檔案,擷取 Glyph 輪廓(貝茲曲線)
  • 光柵化:把向量輪廓轉成像素位圖(bitmap),考慮次像素抗鋸齒(subpixel anti-aliasing)、hinting 等

回到匹配字型問題,在 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 團隊的補丁,也算是解決了一個陳年小刺。


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


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

共有 0 則留言


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