在 macOS 的文本编辑或设计工具中,字体选择状态通常需要被保存。例如用户选了 Helvetica Neue Bold 24号,下次启动要恢复。但 Core Text 的 CTFontDescriptorRef 是 CF 对象,不能直接写入 UserDefaults 或文件。如果直接存 CTFontRef 或描述符对象,得到的是内存地址引用,进程结束后就失效。因此必须把字体描述符转成可持久化的数据格式,再从数据重建字体对象。

先理解字体描述符的属性结构
CTFontDescriptorRef 在 Core Text 中承担的是字体匹配依据的角色。它内部保存的并不是某个字体文件的二进制内容,而是一组描述字体特征的键值对,比如家族名、字重、斜体属性、PostScript 名称、可变字体轴参数等。调用 CTFontDescriptorCopyAttributes 可以拿到一个 CFDictionary,里面就是这些键值。这个 API 的名称里带 Copy,说明返回的对象归调用方所有,Swift 桥接后需要手工管理或用 ARC 处理。
拿到字典之后不要急着直接写入文件。虽然大多数 Core Text 属性值都是字符串、数字、布尔值、Data 或嵌套字典,但部分属性可能包含 CFURL 或其他非 Property List 类型。PropertyListSerialization 对根对象和所有递归值有严格限制,只接受 NSString、NSNumber、NSData、NSDate、NSArray 和 NSDictionary。如果字典里混入 CFURL,序列化会直接抛异常。所以第一步是递归检查并清洗属性字典,把 URL 转成字符串,把不支持的类型丢弃或替换。
下面这段代码可以获取描述符的原始属性,并打印几个常见的键,方便观察结构。这里的 kCTFontFamilyNameAttribute、kCTFontTraitsAttribute 都是 CFString 常量,桥接到 Swift 后需要转为 String 才能作为字典键使用。
import CoreText
import Foundation
func inspectDescriptor(_ descriptor: CTFontDescriptor) {
let attributes = CTFontDescriptorCopyAttributes(descriptor) as? [String: Any] ?? [:]
let family = attributes[kCTFontFamilyNameAttribute as String] ?? "nil"
let traits = attributes[kCTFontTraitsAttribute as String] ?? "nil"
let postscriptName = attributes[kCTFontNameAttribute as String] ?? "nil"
print("family: \(family)")
print("traits: \(traits)")
print("postscriptName: \(postscriptName)")
}
将描述符序列化为 Property List 数据
本地保存最常见、也最稳妥的方式是二进制 Property List。二进制 plist 体积小,读取速度快,而且 Core Text 属性字典天然接近 plist 结构。清洗属性字典的递归函数需要覆盖 String、NSNumber、Data、Date、Array、Dictionary、URL 和 NSNull。NSNumber 已经覆盖了布尔值、整数和浮点数,CFBoolean 桥接后也会成为 NSNumber,因此不用单独处理布尔类型。
清洗时要注意 Dictionary 的键必须是 String。Core Text 返回的 CFDictionary 键本身就是 CFString,桥接为 [String: Any] 后满足要求。如果从其他地方拼装描述符时键不是字符串,PropertyListSerialization 也会拒绝。对于 URL,比较稳妥的做法是保存 absoluteString,恢复时再根据需要重新构造 URL。
import Foundation
func plistSafeValue(_ value: Any) -> Any? {
switch value {
case let string as String:
return string
case let number as NSNumber:
return number
case let data as Data:
return data
case let date as Date:
return date
case let url as URL:
return url.absoluteString
case is NSNull:
return NSNull()
case let array as [Any]:
return array.compactMap { plistSafeValue($0) }
case let dictionary as [String: Any]:
var result: [String: Any] = [:]
for (key, value) in dictionary {
if let safeValue = plistSafeValue(value) {
result[key] = safeValue
}
}
return result
default:
return nil
}
}
有了这个清洗函数,再把结果交给 PropertyListSerialization 就能得到 Data。这里选择二进制格式,便于直接写入文件。如果希望文件可读,也可以把 format 参数改成 .xml。写入时使用 atomic 选项,可以避免进程中途崩溃产生半个文件。
import Foundation
import CoreText
enum FontSerializationError: Error {
case unsafeAttributes
}
func saveDescriptor(_ descriptor: CTFontDescriptor, to url: URL) throws {
let rawAttributes = CTFontDescriptorCopyAttributes(descriptor) as? [String: Any] ?? [:]
guard let safeAttributes = plistSafeValue(rawAttributes) as? [String: Any] else {
throw FontSerializationError.unsafeAttributes
}
let plistData = try PropertyListSerialization.data(fromPropertyList: safeAttributes,
format: .binary,
options: 0)
try plistData.write(to: url, options: .atomic)
}
网络传输与 JSON 的取舍
如果要把字体描述符发给另一台 Mac,或者通过服务端中转,可以直接传输二进制 plist 数据,但通常需要 Base64 编码后放进 JSON 或文本协议。Base64 会带来大约三分之一体积膨胀,不过字体描述符本身通常只有几百字节到几 KB,这个开销可以接受。客户端收到后先 Base64 解码,再按 plist 方式反序列化即可。
另一种做法是直接输出 JSON 对象,这样服务端和前端更容易解析。但 JSON 对 Data 类型的支持不好,必须把 Data 转成 Base64 字符串,而且 JSON 对象的所有键也必须是字符串。字体描述符里有些属性值可能是 Date,也要转成时间戳或 ISO 字符串。相比之下,本地文件用 plist 更省事,网络传输则推荐保留 plist 二进制再加 Base64,避免 JSON 转换过程中因类型变化丢失字体特征。
import Foundation
func encodeDescriptorForTransport(_ descriptor: CTFontDescriptor) throws -> String {
let rawAttributes = CTFontDescriptorCopyAttributes(descriptor) as? [String: Any] ?? [:]
guard let safeAttributes = plistSafeValue(rawAttributes) as? [String: Any] else {
throw NSError(domain: "FontSerialization", code: 1, userInfo: nil)
}
let plistData = try PropertyListSerialization.data(fromPropertyList: safeAttributes,
format: .binary,
options: 0)
return plistData.base64EncodedString()
}
func decodeDescriptorFromTransport(_ base64String: String) -> CTFontDescriptor? {
guard let plistData = Data(base64Encoded: base64String, options: []),
let rawAttributes = try? PropertyListSerialization.propertyList(from: plistData,
options: [],
format: nil),
let attributes = rawAttributes as? [String: Any] else {
return nil
}
return CTFontDescriptorCreateWithAttributes(attributes as CFDictionary)
}
需要注意,如果接收的数据来自不可信来源,不要直接传给 Core Text。攻击者可能构造超大嵌套字典或异常数值,反序列化时消耗大量内存。可以在解析前限制数据大小,并在拿到字典后校验必要字段,过滤掉不属于字体描述符的键。对于跨平台传输,还要考虑目标机器可能没有原字体,这时序列化数据只能作为近似匹配的输入,不能保证像素级一致。
从数据恢复字体描述符和字体对象
反序列化的核心是 PropertyListSerialization.propertyList(from:options:format:)。它把 Data 还原成属性字典,但返回类型是 Any,需要手动检查是否为 [String: Any]。确认类型后再调用 CTFontDescriptorCreateWithAttributes,传入桥接后的 CFDictionary。这个函数会根据属性重建描述符,即使某些属性在目标系统上不存在,也不会返回空值,而是尽量保留可用部分。
得到描述符后,创建 CTFont 需要指定字号。同一个描述符可以生成不同字号的字体对象,因此序列化时不要把字号写进描述符,除非你确实需要一个固定大小的字体。通常的保存逻辑是:描述符保存字体家族、样式、特征等;字号作为布局参数单独保存。创建字体使用 CTFontCreateWithFontDescriptor,如果描述符不足以匹配到字体,Core Text 会执行回退。
import Foundation
import CoreText
func loadFont(from url: URL, size: CGFloat) -> CTFont? {
guard let data = try? Data(contentsOf: url),
let rawAttributes = try? PropertyListSerialization.propertyList(from: data,
options: [],
format: nil),
let attributes = rawAttributes as? [String: Any] else {
return nil
}
let descriptor = CTFontDescriptorCreateWithAttributes(attributes as CFDictionary)
return CTFontCreateWithFontDescriptor(descriptor, size, nil)
}
如果你希望得到更接近原始描述的字体,可以先调用 CTFontDescriptorCreateMatchingFontDescriptor 来查找最匹配的字体描述符,再用返回的描述符创建 CTFont。这个匹配过程会考虑 PostScript 名称、家族、字重等属性,也能在所有字体中筛选。尤其在用户选择的是可变字体或激活字体可能暂时不可用时,匹配步骤比直接创建更稳定。
import Foundation
import CoreText
func loadBestFont(from url: URL, size: CGFloat) -> CTFont? {
guard let data = try? Data(contentsOf: url),
let rawAttributes = try? PropertyListSerialization.propertyList(from: data,
options: [],
format: nil),
let attributes = rawAttributes as? [String: Any] else {
return nil
}
let savedDescriptor = CTFontDescriptorCreateWithAttributes(attributes as CFDictionary)
guard let matchedDescriptor = CTFontDescriptorCreateMatchingFontDescriptor(savedDescriptor, nil) else {
return nil
}
return CTFontCreateWithFontDescriptor(matchedDescriptor, size, nil)
}
反序列化完成后,建议调用 CTFontCopyPostScriptName 或 CTFontCopyFullName 检查实际得到的字体。如果返回的名称和保存时不一致,说明目标机器使用了回退字体,这时可以给用户提示,或启用字体下载逻辑。对于需要跨设备同步字体偏好的应用,最好在序列化数据中同时保留 family 名和 PostScript 名,PostScript 名更精确,但迁移到不同系统时可能失效;family 名加 traits 的组合则更容易匹配到近似字体。
综合来看,Core Text 字体描述符的序列化链路并不复杂,重点在于属性字典的清洗和类型检查。本地文件用二进制 plist,网络传输用 Base64 包装 plist,恢复时先还原字典再创建描述符,最后通过匹配 API 生产 CTFont。遵循这条链路,就能把用户字体状态稳定地保存下来,并在下次启动或另一台设备上尽可能一致地恢复。