工程实践

云端 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 包,确认字体文件确实被复制,并且 Info.plist 中的 UIAppFonts 条目能逐项解析。

SwiftUI 的 custom 字体名称应该使用文件名吗?

不应该默认使用文件名。custom 通常需要字体内部的 PostScript 名称,应从字体描述符读取并维护允许清单,否则可能在设备上静默回退到系统字体。

字体门禁应该放在流水线的哪个阶段?

放在生成可安装 App 包之后、上传或分发之前。这样检查的是最终资源复制结果,同时不会让错误产物进入后续发布步骤。

独享物理节点

在云端持续运行你的 Mac mini 工作流

选择 RunAMac M4、租用周期与部署节点,将开发、构建或实验任务放到一台独享物理机上。

选择租用方案