SwiftUI 没有字体回退栈:用 NSFontDescriptor.cascadeList 复刻 CSS font-family
把一个 Web 应用移植到原生 macOS SwiftUI 时,我遇到了一个让人抓狂的问题:在设置面板里切换「中文 UI 字体」,界面上的中文字体纹丝不动,只有英文字形跟着变。乍一看以为是 @Published 没传播,或者 FontStore 的 binding 写错了——查了半天,发现是 SwiftUI 本身的根本性限制在作祟。
现象
Font.mono(size)工具方法只读了uiLatinFamily(英文等宽字体),完全忽略了uiCJKFamily。- 换了中文字体后,侧边栏、标题栏、状态栏里的中文依然用 PingFang SC 渲染,没有任何变化。
- 加了一个朴素修复后又出现次生症状:切换字体/主题时整个窗口卡顿抖动;或者只有部分视图更新,其余视图保留旧字体/旧颜色。
根因
Web 的 font-family: 'JetBrains Mono', 'PingFang SC', monospace 是一个字形级回退链——浏览器逐字符查找:当前字体有这个字形就用,没有就去下一个。
SwiftUI 的 Text 没有这个机制。.font(.custom("JetBrains Mono", size: 14)) 只用这一个字体族,遇到 CJK 字形就静默回退到系统字体(通常是 PingFang SC),完全不看你传了什么 CJK 字体名。所以:
- 用户设置了中文字体 →
FontStore.uiCJKFamily变了 →Font.mono()只传英文族 → SwiftUI 渲染中文时仍走系统回退 → 用户设置无效。 - 朴素修复(每次调用都现造
NSFontDescriptor)→ 字体/主题切换时整棵视图树重渲染,NSFont被构建数百次 → 卡顿。 - 无参数的容器视图(
SidebarView()、HeaderView()等)没有输入参数,SwiftUI diff 认为它们不需要重算,FontStore @Published变更只触发了根节点,子树被跳过 → 部分视图停留在旧字体。
.font(.custom("LatinFontFamily", size: 14)) 在遇到 CJK 字符时会静默回退到系统字体,与你传入的字体族无关。任何「切换中文字体」的用户设置都会在这里失效,且不报任何错误。
正解
用 NSFontDescriptor.cascadeList 手工构造带回退链的 NSFont,再通过 toll-free bridge 转成 SwiftUI Font,并缓存避免重复构建。
import AppKit
extension AppFont {
// (1) 缓存:避免每次调用都重建描述符
nonisolated(unsafe) private static var cache: [String: NSFont] = [:]
static func cascaded(
latinFamily: String?,
cjkFamily: String,
size: CGFloat,
weight: NSFont.Weight
) -> NSFont {
let key = "\(latinFamily ?? "_sys_")|\(cjkFamily)|\(Int(size * 100))|\(weight.rawValue)"
if let c = cache[key] { return c }
// (2) 基础字体:英文族
let base: NSFont = latinFamily.flatMap {
NSFont(descriptor: NSFontDescriptor(fontAttributes: [
.family: $0,
.traits: [NSFontDescriptor.TraitKey.weight: weight.rawValue]
]), size: size)
} ?? .monospacedSystemFont(ofSize: size, weight: weight)
// (3) 追加 cascadeList:遇到 Latin 字体缺字时,回退到 CJK 字体族
let cjk = NSFontDescriptor(fontAttributes: [.family: cjkFamily])
let desc = base.fontDescriptor.addingAttributes([.cascadeList: [cjk]])
let f = NSFont(descriptor: desc, size: size) ?? base
cache[key] = f
return f
}
}
extension Font {
static func mono(_ size: CGFloat, _ w: Font.Weight = .regular) -> Font {
// NSFont is toll-free bridged to CTFont
Font(AppFont.cascaded(
latinFamily: FontStore.shared.uiLatinFamily,
cjkFamily: FontStore.shared.uiCJKFamily,
size: size,
weight: ns(w)
) as CTFont)
}
}另外一定要修无参数容器视图的传播问题:
// 在每个顶层容器(SidebarView、HeaderView、StatusBarView 等)里加上:
@ObservedObject private var fonts = FontStore.shared
@ObservedObject private var theme = ThemeStore.shared这样 FontStore @Published 变化时,SwiftUI 会把这些视图标为 dirty 并重算其子树。NSViewRepresentable 的终端组件只走 updateNSView,不会被销毁重建,会话状态不受影响。
传错字体族名会让 NSFontDescriptor 静默回退,症状和「没加 cascadeList」一模一样。用 mdls -name com_apple_ats_name_family your.ttf 确认 TTF 的注册名(name table ID 1/16),再对应传入字符串。
三件事缺一不可:NSFontDescriptor.cascadeList 实现 CJK 回退、NSFont 按 (latin, cjk, size, weight) 缓存消除卡顿、顶层容器加 @ObservedObject 保证全局重渲。
排查路径
如果你也遇到「切换字体设置只有英文字形变化」:
- 确认
Font.xxx(size)工具方法有没有读 CJK 字体变量——漏读是最高频原因。 - 加 cascadeList 后若仍无效,用
mdls验证字体族名是否正确。 - 卡顿 → 一定是在热路径上重建了
NSFont,加缓存。 - 部分视图不更新 → 找出所有无参数顶层容器,补
@ObservedObject。
一句话外卖
SwiftUI
Text没有 CSSfont-family的字形级回退,移植 Web 字体栈必须用NSFontDescriptor.cascadeList手工构建,并缓存避免卡顿。