半年了,Flutter 終於修復了 3.38 帶來的一個奇葩問題,**在某些 Android 裝置上,如果對比會發現,中文字體和之前版本不一樣了**,當然不是說完全渲染不對,而是字型變了:

這個問題其實是因為中文字體從系統「預設的無襯線體」,變成了明顯偏「宋體風格的襯線體」,問題主要集中在部分三星、OPPO、OnePlus 等裝置。
所以它不是必現。
而這個問題噁心在於,其實 Flutter 在這裡什麼都沒改,問題其實是 Skia 引起的,但又不是完全 Skia 的問題,因為 Flutter 在 3.38 更新了 Skia 的版本,而 Skia 在這個新版本裡,對 Android 字型 fallback 邏輯做了一次重構,而這次重構改變了 find_family_style_character 在某些場景下選擇 fallback 字型的順序:

你說 Flutter 不是 Impeller 了?為什麼還是 Skia?這個問題其實我們說過很多次了,在字型、字形選擇和字體排版等場景,Flutter 仍然依賴 Skia 實作,只是最終渲染的是 Impeller。
簡單來說就是,Flutter 3.38 升級了 Skia,而 Skia 的 a918c0e 重構了 Android 字型搜尋邏輯:當搜尋過程走到 familyName 為空的路徑時,新邏輯跳過了 fFallbackFor 的匹配檢查,然後直接使用了「回退列表」裡第一個中文字型。

而在一些裝置上,這個第一個被命中的字型剛好是 NotoSerifCJK-Regular.ttc,它是襯線體,所以這時候中文就被渲染成了宋體風格。
聽起來太抽象了?沒事,後面聊聊就懂了,因為懂了之後你會覺得更抽象。
舉個例子,在 Flutter 這段程式碼,雖然你寫了 Roboto ,但是 Roboto 本身不包含中文字型,所以當 Flutter 要渲染「重」這個字時,底層會進入 Android 字型 fallback 機制:
less 體驗AI代碼助手 代碼解讀複製代碼Text(
'重置密碼',
style: TextStyle(fontFamily: 'Roboto', fontSize: 45),
)
目前 Roboto 字型沒有這個字形,所以需要去系統設定裡找一個可以顯示該字元的「後備字型」,而在 Android 上,這部分邏輯由 Skia 的 SkFontMgr_android.cpp 負責,其中關鍵函式就是 find_family_style_character:
在字型列表裡找一個既符合目前字族(Group)約束、又包含目標字元的字型。
這裡有兩個關鍵概念:
familyName,也就是目前希望匹配的目標族名,例如 Roboto、serif,或者空字串fFallbackFor,它表示「這個字型是給哪個字族做後備的」這裡以三星裝置上的典型配置為例,系統裡可能同時存在兩個 CJK 字型:
SECCJK-Regular.ttc,它的 fFallbackFor 為空,表示它是通用後備NotoSerifCJK-Regular.ttc,它宣告 fFallbackFor = 'serif',表示它原則上應該只給 serif 字族做後備
xml 体验AI代碼助手 代碼解讀複製代碼<!-- 普通無襯線字型,通用後備,fFallbackFor 為空 -->
<family lang="zh-Hans">
<font>SECCJK-Regular.ttc</font>
<!-- fFallbackFor = "" ← 空,意思是「我是所有字族的通用後備」 -->
</family>
<!-- 專門給 serif 字族用的後備,fFallbackFor = "serif" -->
<family lang="zh-Hans">
<font>NotoSerifCJK-Regular.ttc</font>
<!-- fFallbackFor = "serif" ← 意思是「我只給 serif 字族做後備」 -->
</family>
這裡的關鍵就是,SECCJK-Regular.ttc 是通用 fallback,適合 Roboto 這種預設無襯線場景下兜底中文,而 NotoSerifCJK-Regular.ttc 是 serif 專用 fallback,只有明確要求 serif 風格時才應該使用。
但是問題就在這裡:Skia 這個 Bug 版本,在優化匹配邏輯的時候,直接忽略了
fFallbackFor,所以每一個重構的初衷都是好的,自己力不能及,總會帶來坑坑窪窪。
而在以前沒問題的版本裡,即使 familyName 為空,也會繼續檢查 fFallbackFor ,在 Flutter 3.35 對應的 Skia 舊邏輯裡,當 familyName = '' 時,Skia 會遍歷回退列表做二次匹配。

比如舊版本裡,它先看到 NotoSerifCJK-Regular.ttc,發現它的 fFallbackFor = 'serif',並不等於空字串,於是跳過。
然後繼續往後找,看到 SECCJK-Regular.ttc,它的 fFallbackFor = '',匹配空 familyName,而且包含「重」這個漢字,於是回傳正確字型。
但是 3.38 開始,Flutter 對應使用的新 Skia 版本裡,當 familyName 為空時,它因為忽略 fFallbackFor ,所以直接就回傳了 fallback 的第一個。
回到 Skia commit a918c0e 這個修改,它起初的目的是重構字型搜尋路徑,讓搜尋更完整,但在重構過程中,不知不覺地引入了這個其他 bug:當 familyName 為空時,就跳過 fFallbackFor 匹配檢查。

這在一些極端兜底場景裡看起來合理,因為空 familyName 可以被理解成「找任何能顯示這個字元的字型」 ,但是它在 Android CJK 字型配置就產生了副作用:
NotoSerifCJK-Regular.ttc雖然只是 serif 專用後備,但它確實包含中文,如果它在某個無語言約束或空familyName路徑裡排在前面,就會被直接回傳,這也是為什麼只有某些機器才會發生,這個 Bug 取決於廠商 fallback 自己排列的順序。

關鍵差異只有一個:當 familyName = '' 時,到底還要不要繼續檢查 fFallbackFor,在舊邏輯會繼續檢查,所以會跳過 fFallbackFor = 'serif' 的專用字型,而在 Bug 邏輯裡會跳過檢查,所以 NotoSerifCJK 被直接回傳。
當然,這裡還有一個為什麼會空字串?因為前幾輪搜尋沒找到時,Skia 內部會用空 familyName 再試一次「無差別全域搜尋」。
而一開始大家發現,只要在 MaterialApp 裡面顯式配置中文 locale , Flutter 就能正確識別語言環境,從而正常渲染:
less 體驗AI代碼助手 代碼解讀複製代碼MaterialApp(
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('zh', 'CN'), // 加上這個,字型渲染恢復正常
Locale('en', 'US'),
],
locale: Locale('zh', 'CN'), // 或者依賴系統語言
// ...
)
但是後來又發現,如果系統語言為英文,出現中文時還是會 fallback 到錯誤字型上,因為根本原因還是在 Skia 層。
比如 Flutter 渲染文字時,最終會呼叫 Skia 的 onMatchFamilyStyleCharacter:
perl 体验AI代码助手 代码解读复制代碼onMatchFamilyStyleCharacter(
familyName, // 字型名,如 "Roboto"
style, // 字重等
bcp47[], // !!!!!注意這個,語言標籤陣列,如 ["zh-Hans"] 或 ["en-US"] !!!!!
bcp47Count,
character // 要渲染的字元,如 '重'
)
而這裡的 bcp47[] 這個語言標籤陣列,就是 locale 直接影響的東西,正常來說,如下程式碼所以,帶語言標籤的搜尋(①)優先級高於空語言標籤的兜底搜尋(②),所以當 lang.getTag() 存在時,是可以匹配到正確的字型:
scss 体验AI代码助手 代码解读复制代碼for (const SkString& currentFamilyName : {familyName, ""}) {
for (bool elegant : {true, false}) {
// ① 先用 bcp47 裡的每個語言標籤搜尋
for (int i = bcp47Count; i --> 0;) {
SkLanguage lang(bcp47[i]);
while (!lang.getTag().isEmpty()) {
matchingTypeface = find(currentFamilyName, lang.getTag(), elegant);
if (matchingTypeface) return matchingTypeface; // 找到就立刻回傳
lang = lang.getParent(); // zh-Hans → zh → ""
}
}
// ② bcp47 全部用完還沒找到,再用空語言標籤兜底搜尋
matchingTypeface = find(currentFamilyName, SkString(""), elegant);
if (matchingTypeface) return matchingTypeface;
}
}
但是 bcp47[] 裡放什麼,還取決於系統語言和 App 配置的 locale 的組合,而 bcp47[] 在組裝時,程式碼路徑會參考系統 locale ,某些系統下即使配置了 zh-CN,bcp47 陣列裡也不一定能正確傳入 zh-Hans 標籤 。
所以這個 Bug 不單單是 Flutter 版本帶的 Skia 導致,而是由 Flutter 版本、Skia 版本、Android 系統字型配置、系統語言、應用 locale 等多個因素共同觸發。

然後更抽象的來了,在第一次 Skia 端做修復時(Fix SkFontMgr_Android fallback bb69b5b) ,只是把語意調整為「best match」 ,讓空 family name 時通用 fallback fonts 優先於 family-specific fallback fonts ,但是它忽略了 find_family_style_character 的搜尋不是一個單階段流程,它有多種匹配模式:
fFallbackFor 的 fallback font
而第一次修復主要處理的是 NameType::Fallback 路徑,也就是「找 fFallbackFor 匹配目前 familyName 的 fallback 字型」,但是 NameType::FallbackNot 這條最後兜底路徑,還是會把「專用 fallback」 和「通用 fallback」 混在一起,以 familyName = 'Roboto' 為例:
fFallbackFor = 'Roboto' 的 CJK fallbackFallbackNot,那麼排在前面的 NotoSerifCJK 還是會被回傳所以 Skia 又又又進行了第二次修改(Adjust SkFontMgr_Android fallback order 2636871),這次修復後的邏輯核心是:
fFallbackFor = '' 的通用後備字型,就應該優先回傳通用後備fFallbackFor = 'serif' 這類專用後備當成最後手段
所以這也是這個問題為什麼修了這麼久的原因,因為它是 Skia 帶來的問題,你就只能等 Skia 團隊修復,但是誰能想到,它還要修兩次才修得好。

但是,嚴格來說這個 Bug 修得算快的,因為它不只是在 Flutter 上會有問題,如果是某個 Android 版本也引用了這個 Skia 提交,其實也會有類似問題,所以根據 Flutter 其他問題的時間週期來看,這個 Bug 修得算快了,因為它同時也影響了 Android 平台本身。
而目前這個修復的 Skia 版本已經合併到 3.43.0-0.3.pre ,所以下個版本 3.44 穩定版就可以直接修正問題了,不得不說這個問題真的很抽象又噁心,最重要這個 Bug 出現的原因和修復過程也是很草台,同時也不得不感慨,字型問題永遠是中文的痛處。