工程實踐

雲端 Mac 的 iOS 字型資源與註冊一致性檢查

雲端 Mac 的 iOS 字型資源與註冊一致性檢查

設計稿中的標題使用了自訂字型,本機除錯時也完全正常,但自動建置產生的 App 安裝到測試裝置後,卻悄悄改用了系統字型。這類問題通常不會造成編譯失敗:字型可能未加入目標、Info.plist 中的相對路徑有誤,或程式碼使用了檔名,而不是字型內部的 PostScript 名稱。最可靠的做法不是繼續檢查專案目錄,而是在雲端 Mac 完成建置後,直接稽核最終的 App 套件。

先定義字型門禁要檢查什麼

字型門禁至少要回答四個問題:宣告的檔案是否存在、檔案能否由 Core Text 解析、內部名稱是否符合程式碼約定,以及多個檔案是否產生重複名稱。檢查對象應是 .app 目錄,而不是原始碼中的 Resources 資料夾,因為目標成員資格與 Copy Bundle Resources 階段都可能改變最終結果。

檢查項目 失敗代表的意義 建議處理方式
UIAppFonts 項目 建置設定未宣告字型 修正目標的 Info 設定
App 套件內檔案 字型未複製到建置產物 檢查目標成員資格與資源階段
PostScript 名稱 程式碼使用的名稱可能有誤 從字型描述元讀取真實名稱
名稱唯一性 字型家族或版本發生衝突 刪除重複檔案或明確定義取代關係

字型在介面上「看起來差不多」不能作為驗收依據。發生回退時,較短的英文文字可能不易察覺,但字寬變化會影響換行、按鈕尺寸與螢幕截圖比對。

建議在儲存庫中維護一份 ci/expected-fonts.txt,每行記錄一個允許使用的 PostScript 名稱。它是程式碼呼叫與資源檔案之間的穩定契約,不應記錄開發者電腦上安裝的所有字型。

從最終 App 套件擷取實際資訊

先透過現有建置流程產生 App 套件,再將路徑傳給檢查指令碼。Debug 與 Release 可能使用不同的資源設定,因此發布流程必須檢查實際準備交付的設定。指令碼不能先向系統註冊字型再進行驗證,否則機器上既有的同名字型可能掩蓋 App 套件中的缺漏。

以下 Swift 指令碼會讀取 Info.plist 中的 UIAppFonts,逐項確認檔案存在,並透過 Core Text 擷取內部名稱。將它儲存為 ci/check_fonts.swift

import Foundation
import CoreText

let arguments = CommandLine.arguments
guard arguments.count == 2 else {
    FileHandle.standardError.write(Data("usage: check_fonts.swift /path/App.app
".utf8))
    exit(64)
}

let appURL = URL(fileURLWithPath: arguments[1], isDirectory: true)
let plistURL = appURL.appendingPathComponent("Info.plist")
guard
    let data = try? Data(contentsOf: plistURL),
    let plist = try? PropertyListSerialization.propertyList(from: data) as? [String: Any],
    let declared = plist["UIAppFonts"] as? [String],
    !declared.isEmpty
else {
    FileHandle.standardError.write(Data("UIAppFonts is missing or empty
".utf8))
    exit(1)
}

var failed = false
var seen = Set<String>()

for relativePath in declared {
    let url = appURL.appendingPathComponent(relativePath)
    guard FileManager.default.fileExists(atPath: url.path) else {
        FileHandle.standardError.write(Data("missing: \(relativePath)
".utf8))
        failed = true
        continue
    }

    let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] ?? []
    if descriptors.isEmpty {
        FileHandle.standardError.write(Data("unreadable: \(relativePath)
".utf8))
        failed = true
    }

    for descriptor in descriptors {
        let value = CTFontDescriptorCopyAttribute(descriptor, kCTFontNameAttribute)
        guard let name = value as? String, !name.isEmpty else {
            FileHandle.standardError.write(Data("unnamed: \(relativePath)
".utf8))
            failed = true
            continue
        }
        if !seen.insert(name).inserted {
            FileHandle.standardError.write(Data("duplicate: \(name)
".utf8))
            failed = true
        }
        print("\(name)	\(relativePath)")
    }
}

exit(failed ? 1 : 0)

執行時請使用建置產物的實際路徑:

xcrun swift ci/check_fonts.swift "$APP_PATH" | sort > build/actual-fonts.txt
diff -u ci/expected-fonts.txt build/actual-fonts.txt

如果清單只儲存名稱,可以在輸出後加上 cut -f1。關鍵是讓差異回傳非零結束碼,使流程立即停止。

固定 PostScript 名稱,而不是檔名

Headline-Bold.otf 只是磁碟上的檔名,字型內部名稱可能是 HeadlinePro-Bold。SwiftUI 的 Font.custom、UIKit 的字型初始化以及測試程式碼,都應使用內部名稱。重新命名字型檔案不會自動改變這個名稱,但供應商更新字型版本時,內部名稱卻可能發生變化。

為名稱變更設定明確的審查流程

actual-fonts.txt 與允許清單不一致時,不要直接在流程中自動接受新值。應先確認這項變更是否來自計畫內的升級,再搜尋專案中的字型呼叫。可以將字型名稱集中放在一個原始碼常數檔案中,避免散落於檢視程式碼與測試夾具。

同一個字型檔案也可能包含多個描述元。指令碼應保留所有名稱,而不是只取第一個。如果團隊確實需要可變字型,還應針對常用字重補上一組介面層級的冒煙測試,確認軸設定與設計基準一致。

補上執行階段與介面驗收

靜態門禁可以證明資源結構正確,但無法證明每個頁面都使用了正確的字重。建議新增一個僅供測試目標使用的字型清單頁面,涵蓋標題、內文、數字、中文標點與較長的英文單字,並在固定尺寸的模擬器上執行螢幕截圖驗收。

執行階段斷言應檢查字型實例的 fontName,不要只比較 point size。對於動態字型,測試還應至少涵蓋兩個內容尺寸類別,觀察放大後是否發生截斷。如果介面允許在字型缺失時回退,回退邏輯必須明確記錄在程式碼中;核心品牌字型則應在測試環境中直接觸發失敗。

進行平行測試時,每個工作都應使用獨立的建置目錄與結果目錄,避免某個分支產生的允許清單覆蓋另一個分支。字型檔案屬於輸入資源,不應由建置指令碼直接在原位置修改。

將檢查整合至可重現的流程

完整順序應為:清理隔離的建置目錄、執行目標設定的建置、定位唯一的 App 套件、執行字型結構檢查、比對允許清單,最後再執行介面測試。不要使用模糊的 find | head -1 選擇產物;同一目錄中存在多個 App 時,它可能會檢查到測試宿主或舊建置。

門禁失敗後,應保留 actual-fonts.txt、App 套件內的字型檔案清單與建置記錄片段,但不要上傳無關的憑證。排查時先查看缺失檔案,再檢查 UIAppFonts,最後核對內部名稱;依照這個順序,可以快速區分「未打包」與「呼叫名稱錯誤」。

字型升級獲得核准後,應在同一次變更中提交字型檔案、允許清單、集中管理的名稱常數與螢幕截圖基準。這樣回復任一提交時,都能還原一套完整狀態,也不會讓雲端 Mac 的建置結果依賴某台開發機預先安裝的字型。

常見問題

為什麼不能只確認專案目錄裡有字型檔?

專案檔案不代表已進入最終產物。應檢查建置或封存後的 App 套件,確認檔案已複製,而且 UIAppFonts 的每個項目都能解析。

SwiftUI 的 custom 字型名稱可以直接使用檔名嗎?

不應直接假設檔名可用。custom 通常需要字型內部的 PostScript 名稱,應從描述符讀取並與專案允許清單比對。

字型檢查應放在流水線哪個階段?

放在可安裝 App 套件生成之後、上傳或分發之前,確保檢查的是最終資源複製結果。

獨享實體節點

在雲端持續執行你的 Mac mini 工作流程

選擇 RunAMac M4、租用週期與部署節點,將開發、建置或實驗工作放到一台獨享實體主機上。

選擇租用方案