終於,Flutter 修復 Android 中文字體異常,但也很草台,不知該怎麼吐槽

半年了,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,也就是目前希望匹配的目標族名,例如 Robotoserif,或者空字串
  • 另一個是字型配置裡的 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 的搜尋不是一個單階段流程,它有多種匹配模式:

  • 先精確找 named font
  • 再找匹配 fFallbackFor 的 fallback font
  • 再做 named font 的兜底
  • 最後做 fallback font 的兜底

而第一次修復主要處理的是 NameType::Fallback 路徑,也就是「找 fFallbackFor 匹配目前 familyName 的 fallback 字型」,但是 NameType::FallbackNot 這條最後兜底路徑,還是會把「專用 fallback」 和「通用 fallback」 混在一起,以 familyName = 'Roboto' 為例:

  • 階段 1 找不到叫 Roboto 的 CJK 字型
  • 階段 2 找不到 fFallbackFor = 'Roboto' 的 CJK fallback
  • 階段 3 在非回退字型裡也找不到漢字
  • 最後進入階段 4 FallbackNot,那麼排在前面的 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 出現的原因和修復過程也是很草台,同時也不得不感慨,字型問題永遠是中文的痛處。


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


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

共有 0 則留言


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