返回博客

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 字体名。所以:

  1. 用户设置了中文字体 → FontStore.uiCJKFamily 变了 → Font.mono() 只传英文族 → SwiftUI 渲染中文时仍走系统回退 → 用户设置无效
  2. 朴素修复(每次调用都现造 NSFontDescriptor)→ 字体/主题切换时整棵视图树重渲染,NSFont 被构建数百次 → 卡顿
  3. 无参数的容器视图(SidebarView()HeaderView() 等)没有输入参数,SwiftUI diff 认为它们不需要重算,FontStore @Published 变更只触发了根节点,子树被跳过 → 部分视图停留在旧字体
坑:SwiftUI .custom() 对 CJK 字形没有 fallback

.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),再对应传入字符串。

正解:cascadeList + 缓存 + 容器观察

三件事缺一不可:NSFontDescriptor.cascadeList 实现 CJK 回退、NSFont 按 (latin, cjk, size, weight) 缓存消除卡顿、顶层容器加 @ObservedObject 保证全局重渲。

排查路径

如果你也遇到「切换字体设置只有英文字形变化」:

  1. 确认 Font.xxx(size) 工具方法有没有读 CJK 字体变量——漏读是最高频原因。
  2. 加 cascadeList 后若仍无效,用 mdls 验证字体族名是否正确。
  3. 卡顿 → 一定是在热路径上重建了 NSFont,加缓存。
  4. 部分视图不更新 → 找出所有无参数顶层容器,补 @ObservedObject

一句话外卖

SwiftUI Text 没有 CSS font-family 的字形级回退,移植 Web 字体栈必须用 NSFontDescriptor.cascadeList 手工构建,并缓存避免卡顿。