逆向分析CoreText中的字體級聯(lián)/Font Fallback機制

一、引言

本文基于Xcode 16.4,iOS 18.5模擬器分析,不同系統(tǒng)版本可能有區(qū)別。

前面我們介紹了自定義文字排版引擎的原理,其中有一個復雜部分是字體Fallback,本文將通過逆向手段分析CoreText中CTFontCopyDefaultCascadeListForLanguages的實現(xiàn),通過了解系統(tǒng)的字體回退實現(xiàn),可以幫助我們實現(xiàn)更好的生產(chǎn)級別的文字排版引擎。

在開始之前,先介紹下CTFontCopyDefaultCascadeListForLanguages API,其完整的函數(shù)簽名如下:

官方文檔:https://developer.apple.com/documentation/coretext/ctfontcopydefaultcascadelistforlanguages(::)

func CTFontCopyDefaultCascadeListForLanguages(
    _ font: CTFont,
    _ languagePrefList: CFArray?
) -> CFArray?

一個字體不可能支持所有的Unicode,比如Helvetica不支持中文,PingFang不支持韓文,在實際渲染時,往往是多個字體共同參與完成的,另外不同字體支持的Unicode有交集,那最終選擇哪個字體也是有優(yōu)先級的;CTFontCopyDefaultCascadeListForLanguages的作用就是:給定一個字體和語言列表,返回系統(tǒng)默認的Fallback列表(也叫級聯(lián)列表,CascadeList),簡單理解就是系統(tǒng)會按這個Fallabck列表進行優(yōu)先級選擇Fallback字體。

在macOS/iOS中,我們也可以通過kCTFontCascadeListAttribute顯示指定Fallback鏈(如下),這樣就能自定義Fallback,當然,如果不指定的話會系統(tǒng)也會啟用默認Fallback,來盡量保證文本渲染正確。

func makeAttributedStringWithFallback(
    text: String,
    baseFontName: String = "Helvetica",
    size: CGFloat = 16,
    languages: [String] = ["zh-Hans", "ja", "ko"]
) -> NSAttributedString {
    let baseFont = CTFontCreateWithName(baseFontName as CFString, size, nil)
    let fallbacks = CTFontCopyDefaultCascadeListForLanguages(baseFont, languages as CFArray)
        as? [CTFontDescriptor] ?? []
    var attributes: [CFString: Any] = [
        kCTFontNameAttribute: baseFontName,
        kCTFontSizeAttribute: size
    ]
    // 可以在這里修改fallbacks,來自定義回退
    if !fallbacks.isEmpty {
        attributes[kCTFontCascadeListAttribute] = fallbacks
    }
    let newDescriptor = CTFontDescriptorCreateWithAttributes(attributes as CFDictionary)
    let finalFont = CTFontCreateWithFontDescriptor(newDescriptor, size, nil)
    let attributesDict: [NSAttributedString.Key: Any] = [
        .font: finalFont
    ]
    return NSAttributedString(string: text, attributes: attributesDict)
}

下面,我們按如下調(diào)用Demo來實際研究下:

let ctFont = UIFont.systemFont(ofSize: 16)
let languages: [String] = ["zh-Hans"]
let cascadeList = CTFontCopyDefaultCascadeListForLanguages(ctFont, languages as CFArray)

二、調(diào)用鏈路

CTFontCopyDefaultCascadeListForLanguages.png

如上是CTFontCopyDefaultCascadeListForLanguages的調(diào)用鏈路,可以看出大致分為兩條處理鏈路:

  • Preset Fallbacks:系統(tǒng)預設Fallback,這是一個“快速通道”,系統(tǒng)內(nèi)部維護了一個針對特定字體(如系統(tǒng)UI字體)的硬編碼Fallback列表,如果請求的主字體在這個預設列表中,系統(tǒng)會直接使用這個列表,速度非??臁?/li>
  • System Default Fallbacks:系統(tǒng)默認Fallback,這是一個“通用通道”,如果預設列表沒有命中,系統(tǒng)會啟動默認Fallback流程,該流程會加載一個全局的、定義了完整回退規(guī)則的配置文件,根據(jù)用戶的語言偏好設置,動態(tài)地為請求的字體生成一個Fallback列表,并進行緩存以提高后續(xù)調(diào)用效率。

后文我們也將按這兩個流程分開分析。

完整的反匯編邏輯和注釋可以參考:https://github.com/HusterYP/FontFallback

三、TBaseFont::CreateFallbacks

/**
* 核心分發(fā)函數(shù),決定是使用預設Fallback還是系統(tǒng)默認Fallback。
*
* @param result@<X0> (TBaseFont*) TBaseFont 實例。
* @param a2@<X1>     (int) 標志位,可能表示是否為系統(tǒng)UI字體。
* @param a3@<X2>     (int) 字體屬性。
* @param a4@<X3>     (_QWORD*) 未知參數(shù),可能是字符集。
* @param a5@<X4>     (CFArrayRef) 語言列表。
* @param a6@<X8>     (_QWORD*) 用于接收結(jié)果的輸出指針。
*
* @return __int64 無實際意義。
 */
__int64 __usercall TBaseFont::CreateFallbacks@<X0>(__int64 result@<X0>, __int64 a2@<X1>, __int64 a3@<X2>, __int64 a4@<X3>, __int64 a5@<X4>, _QWORD *a6@<X8>)
{
    ...
  // 保存參數(shù)
  v6 = a3;  // 字體特性標志
  v7 = a5;  // 語言數(shù)組指針
  v8 = a2;  // 系統(tǒng)UI字體標志
  v9 = (TBaseFont *)result;  // 基礎字體對象
  ...
  // 如果系統(tǒng)UI字體標志不為 0,嘗試創(chuàng)建預設字體回退
  if ( (_DWORD)a2 )
  {
    v11 = (_QWORD *)a4;
    // 從字體對象中獲取字體名,如.SFUI-Regular
    v12 = (*(__int64 (**)(void))(*(_QWORD *)result + 560LL))();
    if ( v12 )
    {
      v13 = v12;
      // 初始化字體描述符源對象
      TDescriptorSource::TDescriptorSource((TDescriptorSource *)&v33);
      _X26 = &v34;
      // 創(chuàng)建預設字體回退列表
      _X0 = TDescriptorSource::CreatePresetFallbacks(v13, v11, v7, v6, &v34);
      ...
    }
  }
  // 檢查預設字體回退是否成功創(chuàng)建
  v24 = objc_retain(_X0);
  if ( v24 )
  {
    v25 = v24;
    v26 = CFArrayGetCount(v24);
    result = objc_release(v25);
    // 如果預設字體回退不為空,直接返回
    if ( v26 )
      return result;
  }
  ...
  // 如果預設字體回退為空,創(chuàng)建系統(tǒng)默認字體回退
  v27 = TBaseFont::GetCSSFamily(v9);
  _X23 = &v34;

  // 創(chuàng)建系統(tǒng)默認字體回退列表
  _X0 = TBaseFont::CreateSystemDefaultFallbacks((__int64)v9, v27, v7, v8, &v34);
  ...
  return result;
}

這是處理預設Fallback和默認Fallback的入口函數(shù)。

1)result@<X0>參數(shù)是什么

首先我們主要關注的是第一個入?yún)?code>result@<X0>,我們先嘗試反匯編x0,發(fā)現(xiàn)它其實指向的是類 TTenuousComponentFont (CoreText 內(nèi)部的一個私有類,繼承自 TBaseFont)的虛函數(shù)表,如下,下面的udf 其實是因為LLDB嘗試將數(shù)據(jù)當代碼解讀,但其實它是一個指針表,所以識別成了未定義。

CreateFallbacks-1.png

CoreText 是由 C++ 和 Objective-C 混合實現(xiàn)的,C++類對象的方法調(diào)用是通過虛函數(shù)表(vtable)實現(xiàn)的,C++ 虛表是一個函數(shù)指針數(shù)組,對象里保存著一個 vptr(虛表指針),指向它所屬類的 vtable。

下面我們嘗試將result@<X0>按虛表指針解析,主要是dis -c 5 -s xxx,可以通過這種方式索引各方法。

CreateFallbacks-2.png

繼續(xù)往上追溯,result@<X0>其實來自原始入?yún)TFont中的一個屬性。

2)什么情況下會觸發(fā)Preset Fallbacks

提取主要控制邏輯如下:

// 如果系統(tǒng)UI字體標志不為 0,嘗試創(chuàng)建預設字體回退
if ( (_DWORD)a2 )
{
  v11 = (_QWORD *)a4;
  // 從字體對象中獲取字體名,如.SFUI-Regular
  v12 = (*(__int64 (**)(void))(*(_QWORD *)result + 560LL))();
  if ( v12 )
  {
    ...
  }
}

可以發(fā)現(xiàn)當a2非0時會觸發(fā)Preset Fallbacks,繼續(xù)往上追溯a2來自于TFont::IsSystemUIFontAndForShaping((TFont *)v5, &v14)IsSystemUIFontAndForShaping不在本文重點,簡單理解就是如果是系統(tǒng)UI字體且用于文本塑形的字體則返回true,比如典型的UIFont.systemFont.SFUI-Regular:San Francisco (SF)字體家族中的字體)判定為true。

Q:為什么只有系統(tǒng)UI字體才有預設Fallback

簡單理解就是只有系統(tǒng)UI字體是系統(tǒng)完全可控可感知的,所以可以提前構建Fallback列表

3)什么情況下會觸發(fā)System Default Fallbacks

從上面反匯編邏輯比較容易看出,當Preset Fallbacks的結(jié)果為空時,會繼續(xù)走System Default Fallbacks兜底。

四、Preset Fallbacks

4.1 獲取全局預設Fallback列表CTPresetFallbacks

在分析系統(tǒng)是如何為特定字體構建預設Fallback(字體的級聯(lián)列表)之前,我們需要先知道預設列表是從哪里讀取的。

系統(tǒng)是通過GetCTPresetFallbacksDictionary獲取預設列表的,繼續(xù)往下追溯預設列表最終來自GSFontCacheGetData

/*
 * 函數(shù): GSFontCacheGetData
 * -------------------------
 * @brief  從圖形服務(GraphicsServices)的字體緩存中根據(jù)鍵名獲取數(shù)據(jù)。
 * @param  a1 (void*)      String入?yún)ⅲ瑢嶋H是對應plist名稱,比如預設列表的plist名稱CTPresetFallbacks.plist
 * @param  a2 (const char*) 在此反匯編中未使用,可能是寄存器傳參的殘留。
 * @return (void*)         返回一個指向緩存數(shù)據(jù)的指針,如果找不到則可能返回NULL。
 */
void *__fastcall GSFontCacheGetData(void *a1, const char *a2)
{
  // =================================================================
  // 快速通道 1: 檢查是否請求 "DefaultFontFallbacks.plist"
  // =================================================================
  // 調(diào)用 a1 的 isEqualToString: 方法,與字符串 "DefaultFontFallbacks.plist"(stru_6BEB8)比較
  if ( (unsigned int)objc_msgSend_isEqualToString_(a1, a2, &stru_6BEB8) )
  {
    // 如果是,直接返回全局變量 kDefaultFontFallbacks 的值。
    // 這是一個非常高效的硬編碼路徑,用于獲取默認的后備字體規(guī)則。
    v4 = &kDefaultFontFallbacks;
    return (void *)*v4;
  }

  // =================================================================
  // 快速通道 2: 檢查是否請求 "CTPresetFallbacks.plist"
  // =================================================================
  // 調(diào)用 a1 的 isEqualToString: 方法,與字符串 "CTPresetFallbacks.plist"(stru_6BED8)比較
  if ( (unsigned int)objc_msgSend_isEqualToString_(v2, v3, &stru_6BED8) )
  {
    // 如果是,直接返回全局變量 CTPresetFallbacks 的值。
    // 這正是我們之前分析的、包含了所有預設后備規(guī)則的那個.plist文件的內(nèi)容。
    // 系統(tǒng)通過這個鍵來加載整個預設后備字典。
    v4 = &CTPresetFallbacks;
    return (void *)*v4;
  }

  // =================================================================
  // 快速通道 3: 檢查是否請求某個特殊字典
  // =================================================================
  // 調(diào)用 a1 的 isEqualToString: 方法,與字符串 "CTFontInfo.plist"(stru_6BEF8)比較
  if ( !((unsigned __int64)objc_msgSend_isEqualToString_(v2, v5, &stru_6BEF8) & 1) )
  {
    // 如果鍵不是 stru_6BEF8,則進入下面的常規(guī)查詢邏輯
    // =================================================================
    // 常規(guī)查詢路徑: 在一個全局字典 (unk_1EB8F0) 中查找
    // =================================================================
    // 檢查鍵是否為 "CTCharacterSets.plist" (stru_6BF18)
    if ( (unsigned int)objc_msgSend_isEqualToString_(v2, v7, &stru_6BF18) )
    {
      // **鍵名轉(zhuǎn)換/別名**: 如果是,則將要查詢的鍵替換為另一個字符串 "CTCharacterSets" (stru_6BF38)
      v9 = &stru_6BF38;
    }
    // 檢查鍵是否為 "GSFontCache.plist" (stru_6BF58)
    else if ( (unsigned int)objc_msgSend_isEqualToString_(v2, v8, &stru_6BF58) )
    {
      // **鍵名轉(zhuǎn)換/別名**: 如果是,則將要查詢的鍵替換為另一個字符串 "GSFontCache" (stru_6BF78)
      v9 = &stru_6BF78;
    }
    else
    {
      // 檢查鍵是否為 "CoreTextConfig.plist" (stru_6BF98)
      if ( !(unsigned int)objc_msgSend_isEqualToString_(v2, v8, &stru_6BF98) )
        // 如果鍵不匹配上面任何一個需要轉(zhuǎn)換的鍵,則使用原始的鍵 v2 在全局字典中查找
        return objc_msgSend_objectForKey_(&unk_1EB8F0, v8, v2);
      
      // **鍵名轉(zhuǎn)換/別名**: 如果鍵是 stru_6BF98,則將其替換為 "CoreTextConfig" (stru_6BFB8)
      v9 = &stru_6BFB8;
    }
    
    // 對于所有經(jīng)過“鍵名轉(zhuǎn)換”的情況,使用轉(zhuǎn)換后的新鍵 v9 在全局字典中查找
    // objectForKeyedSubscript: 是 OC 中字典下標語法 (dictionary[key]) 的底層實現(xiàn)
    return objc_msgSend_objectForKeyedSubscript_(&unk_1EB8F0, v8, v9);
  }

  // 如果快速通道3的檢查為真 (鍵等于 stru_6BEF8),則直接返回整個全局字典 unk_1EB8F0
  return &unk_1EB8F0;
}

從反匯編邏輯不太容易看,可以結(jié)合LLDB Debug一起分析:

CTPresetFallbacks-plist.png

在查詢預設列表時,入?yún)⑹?code>CTPresetFallbacks.plist,系統(tǒng)會從全局變量CTPresetFallbacks中讀取預設列表,CTPresetFallbacks是全局共享的,是在CoreText服務啟動時構建的一個全局常量,內(nèi)容如下:

完整列表見:https://github.com/HusterYP/FontFallback/blob/main/CTPresetFallbacks.plist

{
  ...
  ".SFUI-Regular" =     (
        ".AppleSystemFallback-Regular",
        ".AppleColorEmojiUI",
        ".SFGeorgian-Regular",
        HelveticaNeue,
        ".AppleSymbolsFB",
                {
            ar = ".AppleArabicFont-Regular"; // 如果系統(tǒng)語言是阿拉伯語(ar),則使用此字體
            ur = ".AppleUrduFont-Regular"; // 如果是烏爾都語(ur),則使用此字體
        },
                {
            ja = ".AppleJapaneseFont-Regular"; // 如果是日語(ja)
            ko = ".AppleKoreanFont-Regular"; // 如果是韓語(ko)
            my = "NotoSansMyanmar-Regular";
            "my-Qaag" = "NotoSansZawgyi-Regular";
            "zh-HK" = ".AppleHongKongChineseFont-Regular"; // 香港繁體中文
            "zh-Hans" = ".AppleSimplifiedChineseFont-Regular"; // 簡體中文
            "zh-Hant" = ".AppleTraditionalChineseFont-Regular"; // 臺灣繁體中文
            "zh-MO" = ".AppleMacaoChineseFont-Regular";
        },
        ".ThonburiUI-Regular",
        ".SFHebrew-Regular",
        ".SFArmenian-Regular",
        ".AppleIndicFont-Regular",
        "KohinoorDevanagari-Regular",
        Kailasa,
        "KohinoorBangla-Regular",
        "KohinoorGujarati-Regular",
        "MuktaMahee-Regular",
        "NotoSansKannada-Regular",
        KhmerSangamMN,
        LaoSangamMN,
        MalayalamSangamMN,
        NotoSansOriya,
        SinhalaSangamMN,
        TamilSangamMN,
        "KohinoorTelugu-Regular",
        "NotoSansArmenian-Regular",
        EuphemiaUCAS,
        "Menlo-Regular",
        AppleSymbols,
        ArialMT,
        "STIXTwoMath-Regular",
        ".HiraKakuInterface-W4",
        HelveticaNeue,
        "Kefa-Regular",
        Galvji,
        ".PhoneFallback"
    );
    SystemWideFallbacks =     (
                (
            128,
            887,
            "Charter-Roman"
        ),
                (
            895,
            895,
            "DINCondensed-Bold"
        ),
                (
            975,
            1315,
            "Charter-Roman"
        ),
                (
            1316,
            1319,
            ".SFUI-Regular"
        ),
                ...
    )
}

CTPresetFallbacks.plist中主要定義了兩組內(nèi)容:

1)為特定字體定義Fallback列表/級聯(lián)列表

比如我們這里要查詢.SFUI-Regular的Fallback列表,就用.SFUI-Regular作為key去CTPresetFallbacks.plist中找到一組字典進行解析,解析邏輯后面會講。

2)SystemWideFallbacks

SystemWideFallbacks定義了一個全局級別的 Fallback 映射,和字體無關,按 Unicode code point 范圍定義;每個元素是一個三元組,包括:起始 Unicode 碼點 + 結(jié)束 Unicode 碼點 + 指定 Fallback 字體。

比如128~887范圍優(yōu)先用Charter-Roman。

4.2 預設列表解析流程

獲取到全局預設列表之后,我們再來看系統(tǒng)是如何針對特定字體(系統(tǒng)的UI字體)構建級聯(lián)列表的,主要邏輯在CreatePresetFallbacks中,如下:

/*
* 實現(xiàn)“快速通道”,從一個全局的、硬編碼的字典中查找并創(chuàng)建預設列表。
*
* @param a1@<X1> (CFStringRef) 字體名稱或標識符。
* @param a2@<X2> (_QWORD*)     輸出參數(shù),可能用于字符集。
* @param a3@<X3> (CFArrayRef)  語言列表。
* @param a4@<X4> (int)         標志位。
* @param a5@<X8> (_QWORD*)     用于接收結(jié)果的輸出指針。
*
* @return __int64 返回創(chuàng)建的預設列表 (CFArrayRef)。
*/
__int64 __usercall TDescriptorSource::CreatePresetFallbacks@<X0>(__int64 a1@<X1>, _QWORD *a2@<X2>, __int64 a3@<X3>, __int64 a4@<X4>, _QWORD *a5@<X8>)
{
  ...
  _X19 = a5;
  // 1. 獲取全局預設字典
  result = GetCTPresetFallbacksDictionary();
  v11 = result;
  // 2. 創(chuàng)建有序的語言列表
  v12 = CreateOrderedLanguages(v6);
  // 3. 使用字體名 a1 在預設字典中查找
  v13 = CFDictionaryGetValue(v11, v8);
  // 4. 如果找到匹配項,并且它是一個數(shù)組,則開始處理
  if ( v13 && (v15 = v13, v16 = CFGetTypeID(v13), v16 == CFArrayGetTypeID()) )
  {
    // 創(chuàng)建一個可變數(shù)組用于存放結(jié)果
    v37 = CFArrayCreateMutable(*(_QWORD *)kCFAllocatorDefault_ptr, 0LL, kCFTypeArrayCallBacks_ptr);
    v17 = CFArrayGetCount(v15);
    if ( v17 )
    {
      // 5. 遍歷預設數(shù)組中的每一項
      do
      {
        v20 = (__CFString *)CFArrayGetValueAtIndex(v15, v19);
        v21 = CFGetTypeID(v20);
                // 5a. 如果是字典類型,說明是按語言區(qū)分的后備字體
        if ( v21 == CFDictionaryGetTypeID() )
        {
          // 遍歷上面構建的語言列表,在字典中查找匹配的后備字體
          do
          {
            v25 = CFArrayGetValueAtIndex(v12, v24);
            if ( v20 )
            {
              v26 = CFDictionaryGetValue(v20, v25);
              if ( v26 )
                TDescriptorSource::AppendFontDescriptorFromName(&v37, v26, 1024LL);
            }
          }
          while ( v23 != v24 );
        }
        // 5b. 如果是字符串類型,直接作為后備字體名
        else
        {
          // ... 對Emoji等特殊字體進行處理 ...
          TDescriptorSource::AppendFontDescriptorFromName(&v37, v20, 1024LL);
        }
        ++v19;
      }
      while ( v19 != v18 );
    }
  }
  // 將最終結(jié)果寫入輸出指針并返回
  ...
}

代碼注釋已經(jīng)比較清晰,總結(jié)下來解析流程是:

1)通過字體名從全局預設列表中查詢Fallback數(shù)組

比如我們通過.SFUI-Regular查詢到的原始Fallback數(shù)組如下:

".SFUI-Regular" =     (
        ".AppleSystemFallback-Regular",
        ".AppleColorEmojiUI",
        ".SFGeorgian-Regular",
        HelveticaNeue,
        ".AppleSymbolsFB",
                {
            ar = ".AppleArabicFont-Regular"; // 如果系統(tǒng)語言是阿拉伯語(ar),則使用此字體
            ur = ".AppleUrduFont-Regular"; // 如果是烏爾都語(ur),則使用此字體
        },
                {
            ja = ".AppleJapaneseFont-Regular"; // 如果是日語(ja)
            ko = ".AppleKoreanFont-Regular"; // 如果是韓語(ko)
            my = "NotoSansMyanmar-Regular";
            "my-Qaag" = "NotoSansZawgyi-Regular";
            "zh-HK" = ".AppleHongKongChineseFont-Regular"; // 香港繁體中文
            "zh-Hans" = ".AppleSimplifiedChineseFont-Regular"; // 簡體中文
            "zh-Hant" = ".AppleTraditionalChineseFont-Regular"; // 臺灣繁體中文
            "zh-MO" = ".AppleMacaoChineseFont-Regular";
        },
        ...
)

2)遍歷Fallback數(shù)組,如果是字典類型,需要按語言區(qū)分Fallback字體

還記得最初CTFontCopyDefaultCascadeListForLanguages的函數(shù)簽名中,第二個參數(shù)支持傳語言列表:

func CTFontCopyDefaultCascadeListForLanguages(
    _ font: CTFont,
    _ languagePrefList: CFArray?
) -> CFArray?

系統(tǒng)會通過CreateOrderedLanguages創(chuàng)建一個有序的語言數(shù)組,具體做法是將調(diào)用者想要的語言(languagePrefList)、App自身想要的語言、以及用戶在整個系統(tǒng)中設置的語言偏好合并成一個有序的語言數(shù)組。

然后遍歷語言數(shù)組,從字典中篩選出對應語言的Fallback字體添加到結(jié)果中。

從這里可以看出,同一字體的Fallback列表,還會受語言影響,比如:

zh-Hans zh-HK
zh-Hans.png
zh-HK.png

Q:為什么Fallback字體還跟語言設置相關?

參考自定義文字排版引擎的原理一文中針對「相同Script的字符如果使用了不同的Font,會有什么問題」的回答

3)遍歷Fallback數(shù)組,如果是字符串類型,「直接」作為Fallback字體

「直接」加引號,因為還會處理Emoji字體等特殊情況。

4)Fallback數(shù)組遍歷完成之后,構建完成該字體最終的預設Fallabck列表/級聯(lián)列表

4.2 Preset Fallbacks小結(jié)

總結(jié)下Preset Fallbacks流程:

1)系統(tǒng)從全局常量CTPresetFallbacks中讀取預設列表

2)根據(jù)用戶指定主字體名從全局預設列表中查詢Fallback數(shù)組

3)遍歷Fallback數(shù)組,如果為字典類型,根據(jù)用戶指定語言、App偏好語言、系統(tǒng)設置偏好語言來選擇Fallback字體

4)遍歷Fallback數(shù)組,如果為字符串類型,「直接」作為Fallback字體

5)Fallback數(shù)組遍歷完后,對應字體的級聯(lián)列表構建完成

五、System Default Fallbacks

如果系統(tǒng)預設Fallback沒有查到結(jié)果,則會兜底到系統(tǒng)默認Fallback邏輯,為字體動態(tài)構建級聯(lián)列表。

5.1 CSSFamily分類

__int64 __usercall TBaseFont::CreateFallbacks@<X0>(__int64 result@<X0>, __int64 a2@<X1>, __int64 a3@<X2>, __int64 a4@<X3>, __int64 a5@<X4>, _QWORD *a6@<X8>)
{
  ...
  // 如果預設字體回退為空,創(chuàng)建系統(tǒng)默認字體回退
  v27 = TBaseFont::GetCSSFamily(v9);
  _X23 = &v34;

  // 創(chuàng)建系統(tǒng)默認字體回退列表
  _X0 = TBaseFont::CreateSystemDefaultFallbacks((__int64)v9, v27, v7, v8, &v34);
  ...
  return result;
}

系統(tǒng)默認Fallback,會先通過TBaseFont::GetCSSFamily將用戶指定主字體分類,這是后續(xù)查表的關鍵;GetCSSFamily會讀取字體特征進行分類,主要分為:

  • sans-serif (無襯線體):字體筆畫的末端沒有額外的裝飾性“腳”,如Helvetica、Arial、San Francisco (SF Pro)、PingFang SC (蘋方)
  • serif (襯線體):字體筆畫的末端有裝飾性的“腳”(襯線),如Times New Roman、Georgia、New York、宋體
  • monospace (等寬體):所有字符占據(jù)相同的寬度,如Menlo、Courier、Monaco、SF Mono
  • cursive (手寫體):如Snell Roundhand
  • fantasy (裝飾體):如Papyrus

除此外,蘋果在UI上下文中,還有幾個擴展的CSSFamily分類:

  • ui-serif:用于 UI 的襯線字體,主要指 New York 家族

  • ui-sans-serif:用于 UI 的無襯線字體,即 San Francisco 家族

  • ui-monospace:用于 UI 的等寬字體,即 SF Mono

  • ui-rounded:用于 UI 的圓體字體。如 SF Pro RoundedSF Compact Rounded

5.2 獲取系統(tǒng)默認Fallback列表kDefaultFontFallbacks

和全局預設列表一樣,系統(tǒng)默認Fallback列表也是通過GSFontCacheGetData讀取配置文件。

調(diào)用鏈路是:CreateSystemDefaultFallbacks -> CopyDefaultSubstitutionListForLanguages -> CopyFontFallbacksForLanguages -> CopyFontFallbacks -> CopyDefaultFontFallbacks -> GSFontCacheGetData;通過GSFontCacheGetData讀取系統(tǒng)默認Fallback列表時,入?yún)⑹?code>DefaultFontFallbacks.plist

DefaultFontFallbacks.plist.png

也是從一個全局常量kDefaultFontFallbacks中獲取的,內(nèi)容如下:

{
    common =     (
        ...
    );
    cursive =     (
        ...
    );
    default =     (
        ...
    );
    fantasy =     (
        ...
    );
    monospace =     (
        ...
    );
    "sans-serif" =     (
        Helvetica,
        AppleColorEmoji,
        ".AppleSymbolsFB",
                {
            ar = GeezaPro;
            ja = "HiraginoSans-W3";
            ko = "AppleSDGothicNeo-Regular";
            my = "NotoSansMyanmar-Regular";
            "my-Qaag" = "NotoSansZawgyi-Regular";
            ur = NotoNastaliqUrdu;
            "zh-HK" = "PingFangHK-Regular";
            "zh-Hans" = "PingFangSC-Regular";
            "zh-Hant" = "PingFangTC-Regular";
            "zh-MO" = "PingFangMO-Regular";
        },
        Thonburi,
        ArialHebrew
    );
    serif =     (
        ...
    );
    "ui-monospace" =     (
        ...
    );
    "ui-rounded" =     (
        ...
    );
    "ui-serif" =     (
        ...
    );
}

DefaultFontFallbacks.plist的格式基本和CTPresetFallbacks.plist類似,也是KV結(jié)構,Value部分也分為字符串和字典類型,字典類型也會根據(jù)用戶指定語言來擇優(yōu)選取。

5.3 解析并緩存系統(tǒng)默認Fallback列表

解析和緩存邏輯主要由CopyFontFallbacks處理,主邏輯如下:

/**
 * CoreText 字體回退 - 復制字體回退列表函數(shù)
 * 功能: 根據(jù)字體描述符和語言信息復制相應的字體回退列表
 * 
 * 參數(shù):
 *   a1 (_QWORD *): 輸出參數(shù)指針,用于接收生成的字體回退數(shù)組
 *   a2 (__int64): 字體描述符對象指針
 *   a3 (__CFString *): 主要語言代碼字符串
 *   a4 (__CFString *): 次要語言代碼字符串(可選)
 *   a5 (__int64): 語言數(shù)組指針(可選)
 * 
 * 返回值:
 *   __int64: 操作結(jié)果
 */
__int64 __fastcall TFontFallbacks::CopyFontFallbacks(_QWORD *a1, __int64 a2, __CFString *a3, __CFString *a4, __int64 a5)
{
    ...
  // 保存參數(shù)到局部變量和寄存器
  _X22 = a5;  // 語言數(shù)組指針
  v6 = a4;    // 次要語言代碼
  v7 = a3;    // 主要語言代碼
  v8 = a2;    // 字體描述符對象
  v9 = a1;    // 輸出參數(shù)指針
  // 先在Font實例成員變量字典中查找Fallback緩存
  v16 = CFDictionaryGetValue(_X0, a3);
  ...
  // 如果沒有找到緩存,則動態(tài)構建
  if ( !_X9 )
  {
    ...
    // 獲取系統(tǒng)默認Fallback列表
    CopyDefaultFontFallbacks();
    v22 = objc_retain(_X0);
    if ( v22 )
    {
      // 用cssfamliy從系統(tǒng)默認Fallback列表中查找映射
      v24 = CFDictionaryGetValue(v22, v6);      
      // 檢查是否找到了有效的字體列表
      if ( v24 && CFArrayGetCount(v24) >= 1 )
      {
        ...
        // 解析列表
        // 根據(jù)用戶指定語言、App偏好語言、系統(tǒng)設置偏好語言創(chuàng)建有序語言數(shù)組
        v29 = CreateOrderedLanguages(_X22);
        // 處理字體回退列表
        TDescriptorSource::ProcessFallbackList(v24, (__int64)&v59, v31, v29);

        // 解析通用(common)字體回退列表
        v34 = CFDictionaryGetValue(_X25, &stru_1F69C8);
        TDescriptorSource::ProcessFallbackList(v36, (__int64)&v59, v31, v29);

                // 緩存結(jié)果到Font實例
        v44 = objc_retain(_X0);
        if ( v44 )
        {
            ...
            CFDictionarySetValue(_X0, v7, _X2);
        }
      }
    }
  // 處理特定語言的回退邏輯
  ...
  return objc_release(v57);
}

注意CopyFontFallbacks中一共調(diào)了兩次ProcessFallbackList,邏輯是先取對應CSSFamily的(比如sans-serif)Fallback列表,再取common的Fallback列表,最終將二者合并起來作為對應字體的Fallback結(jié)果。

ProcessFallbackList解析字體列表的邏輯和預設Fallback類似,也是根據(jù)Value是字符串類型還是字典類型來區(qū)分解析,此處不再贅述。

最后,CopyFontFallbacks還會將Fallback結(jié)果緩存到Font實例的字典變量中,key是cssfamily + languages(逗號分隔開),比如:sans-serif,zh-HK

CopyFontFallbacks.png

CopyFontFallbacks邏輯比較清晰,總結(jié)下來是:

1)先從Font實例中獲取Fallback緩存,如果已經(jīng)構建過則直接使用

2)緩存獲取失敗,走動態(tài)構建,將對應CSSFamily的Fallback列表和common的Fallback列表合并成最終Fallback結(jié)果

3)緩存Fallback結(jié)果到Font實例,key是cssfamily + languages

5.4 語言處理與線程安全

CopyFontFallbacksForLanguages在調(diào)用CopyFontFallbacks之前,會對用戶指定的語言(即CTFontCopyDefaultCascadeListForLanguageslanguagePrefList參數(shù))進行處理:

__int64 __usercall TFontFallbacks::CopyFontFallbacksForLanguages@<X0>(__int64 a1@<X0>, __int64 a2@<X1>, __int64 a3@<X2>, __int64 a4@<X8>)
{
  // 如果沒有提供語言數(shù)組,直接調(diào)用單語言版本
  if ( !a3 )
    return TFontFallbacks::CopyFontFallbacks((_QWORD *)a4, a1, (__CFString *)a2, 0LL, 0LL);
    ...
  // 獲取系統(tǒng)有序語言數(shù)組
  v7 = GetOrderedLanguages;
  // 遍歷輸入的語言代碼數(shù)組
  do
  {
    // 檢查規(guī)范化后的語言代碼是否在系統(tǒng)支持的語言列表中
    __asm { LDAPR           X3, [X22], [X22] }
    if ( (unsigned int)CFArrayContainsValue(v7, 0LL, v8, _X3) )
    {
      // 如果支持,添加到有效語言數(shù)組中
      CFArrayAppendValue(v6, v21);

    }
    ++v12;
  }
  while ( v11 != v12 );
  ...
  // 如果找到了有效的語言代碼
  if ( CFArrayGetCount(v6) )
  {
      TFontFallbacks::CopyFontFallbacks(v24, v25, _X2, v4, v6);
  }
  else
  {
    // 如果沒有找到有效語言,使用單語言版本
    TFontFallbacks::CopyFontFallbacks(v24, v25, v4, 0LL, 0LL);
  }
  ...
}

大致邏輯是:

  • 如果languagePrefList傳nil(注意空數(shù)組不算nil),則直接用cssfamily查詢CopyFontFallbacks

  • 如果languagePrefList不為nil,會將用戶指定的languages通過GetOrderedLanguages過濾一遍,去除系統(tǒng)不支持的language,然后使用cssfamily + languages查詢CopyFontFallbacks

另外,CopyFontFallbacks會有對字典的讀寫操作,為了線程安全,CopyDefaultSubstitutionListForLanguages會對整個流程加一把大鎖:

__int64 __usercall TDescriptorSource::CopyDefaultSubstitutionListForLanguages@<X0>(__int64 a1@<X0>, __int64 a2@<X1>, __int64 a3@<X8>)
{
  TDescriptorSource *v6; // 鎖對象指針
  // 這個鎖確保字體回退緩存的線程安全訪問
  v6 = (TDescriptorSource *)os_unfair_lock_lock_with_options(&TDescriptorSource::sFontFallbacksLock, 327680LL);
  ...
  TFontFallbacks::CopyFontFallbacksForLanguages(TDescriptorSource::sFontFallbacksCache, v4, v3, v5);
  // 釋放字體回退緩存鎖并返回
  return os_unfair_lock_unlock(&TDescriptorSource::sFontFallbacksLock);
}

5.5 結(jié)果處理與返回

最后CreateSystemDefaultFallbacks會對CopyDefaultSubstitutionListForLanguages中獲取到的字體描述符進行處理,即排除用戶指定字體,防止自己Fallback自己。

六、總結(jié)

至此,我們通過逆向的手段梳理完了CTFontCopyDefaultCascadeListForLanguages的完整流程,最后整理下結(jié)論如下:

整體分為兩個大流程:

1、Preset Fallbacks:預設Fallback

1.1 系統(tǒng)從全局常量CTPresetFallbacks中讀取預設列表

1.2 根據(jù)用戶指定主字體名從全局預設列表中查詢Fallback數(shù)組

1.3 遍歷Fallback數(shù)組,如果為字典類型,根據(jù)用戶指定語言、App偏好語言、系統(tǒng)設置偏好語言來選擇Fallback字體

1.4 遍歷Fallback數(shù)組,如果為字符串類型,「直接」作為Fallback字體

1.5 Fallback數(shù)組遍歷完后,對應字體的級聯(lián)列表構建完成

2、System Default Fallbacks:系統(tǒng)默認Fallback

1.1 獲取主字體的CSSFamily分類

1.2 從全局常量kDefaultFontFallbacks中讀取默認Fallback列表

1.3 用cssfamily + languages從字體實例中獲取Fallback緩存,如果已經(jīng)構建則直接使用

1.4 緩存缺失則動態(tài)構建,根據(jù)CSSFamily獲取對應字體的Fallback列表并解析,獲取common類型的Fallback列表并解析,合并二者結(jié)果作為最終Fallback結(jié)果

1.5 用cssfamily + languages將Fallback結(jié)果緩存到Font實例

1.6 處理并返回Fallback結(jié)果

更多精彩內(nèi)容,歡迎關注??公眾號:非專業(yè)程序員Ping

?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容