设计稿里的标题使用了定制字体,本地调试也完全正常,但自动构建出的 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、租用周期与部署节点,将开发、构建或实验任务放到一台独享物理机上。