在 SwiftUI Text 中控制孤行:挖掘未公开的 avoidsOrphans

SwiftUI 的 Text 有一项默认行为:当段落末行只剩一个孤词或孤字时,它会主动把上一行的完整词组一并推到末行,避免“落单”。这份体贴多数时候是对的,但代价是上一行可能留下一大片空白——在窄容器或中英混排的场景中,反而破坏了段落的均衡。

麻烦在于,UIKit 允许开发者通过 NSParagraphStyle 关掉这项策略,SwiftUI 却没有提供任何公开接口。本文将挖掘 SwiftUI 中一个早已存在、却始终未曾公开的 API —— avoidsOrphans,让开发者重新拿回这项控制权。

本文使用了未公开的 SwiftUI ABI。它适合研究、验证与内部工具,不应被视为 Apple 承诺兼容的正式 API。

孤行是什么,SwiftUI 又做了什么

孤行(orphan)指段落末尾独占一行的孤词或孤字。例如下图中,最后一个中文字符“者”便孤零零地占据了一整行。

image-20260806092351393

需要说明的是,排版学中的 orphan 通常指段落首行孤悬于页面或栏的底部,widow 指段落末行被推至次页顶部,而末行只剩一两个词的情况更常被称作 runt。Apple 在命名上选择了 orphan 一词,本文沿用其口径。

在 UIKit 中,开发者可以通过 NSParagraphStyle.LineBreakStrategy.pushOut 调整换行策略(line break strategy)。移除 pushOut 后,断行结果如下:

image-20260806092639654

SwiftUI 默认采用 .standard 策略,其中就包含 .pushOut。当末行可能只剩一个孤词时,文本系统会把前面的完整词组一并移到末行。孤行是避免了,上一行却因此空出一大片:

image-20260806100914231

在窄容器中,这种留白往往比孤行本身更刺眼。而 SwiftUI 并未提供任何关闭它的手段。

Text + NSAttributedString:此路不通

既然 NSAttributedString 支持段落样式,能否先从 .standard 中移除 .pushOut,再转换成 Swift AttributedString 交给 Text

Swift
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineBreakStrategy =
    .standard.subtracting(.pushOut)

let source = NSAttributedString(
    string: content,
    attributes: [
        .font: UIFont.preferredFont(forTextStyle: .body),
        .paragraphStyle: paragraphStyle
    ]
)

let attributed = try AttributedString(
    source,
    including: \.uiKit
)

Text(attributed)

很遗憾,Text 并不会遵守我们设置的 lineBreakStrategy。它会依据当前的 EnvironmentValues 重新构造并写入段落样式,覆盖掉外部传入的配置:

Swift
attributes[.kitParagraphStyle] = properties.paragraph.style(
    environment: environment
)

结论是:在 SwiftUI 中若想自定义 lineBreakStrategy,通常只能退回到包装 UILabel 或其他 TextKit 视图。为了一行断行规则付出这样的代价,显然并不经济。

SwiftUI 有这个能力,只是没有公开

在和 OpenSwiftUI 开发者 Kyle Ye 的交流中,他告诉我,SwiftUI 其实几年前就已具备这项能力,只是从未开放。他在 OpenSwiftUI 中将其公开了出来。

我循着他提供的线索做了检查,并得到验证:

  1. SwiftUICore 的导出符号表中存在 View.avoidsOrphans(_:)EnvironmentValues.avoidsOrphans
  2. 从 iOS 16.x 开始,系统二进制中已出现上述符号。这说明它至少从 iOS 16 时代便已存在,而非近期 SDK 才加入的空壳声明。

那么,我们能否在自己的项目中用上它?

在项目中调用 avoidsOrphans

Apple 随 SDK 分发的模块接口已经删去了这项声明,因此下面这种方式行不通:

Swift
@_spi(Private) import SwiftUI

我们真正需要做的,是向编译器补上一份声明,并让这份声明生成与系统 SwiftUI 完全相同的 ABI 符号

构建一份 ABI 模块视图

在项目根目录创建 Modules 文件夹,并加入 SwiftUI_SPI.swiftinterface

Swift
// swift-interface-format-version: 1.0
// swift-module-flags: -enable-objc-interop -enable-library-evolution -swift-version 5 -module-name SwiftUI_SPI -module-abi-name SwiftUI
import Swift
@_exported import SwiftUI

@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *)
extension SwiftUI.View {
    public func avoidsOrphans(_ flag: Swift.Bool) -> some SwiftUI.View
}

其中最关键的是两个模块名称:

  • -module-name SwiftUI_SPI:这份接口在源码中的导入名称,因此使用时写 import SwiftUI_SPI
  • -module-abi-name SwiftUI:要求编译器按照 SwiftUI 模块的身份生成符号引用,使链接器能够在系统 SwiftUI / SwiftUICore 中找到真正的实现。

@_exported import SwiftUI 则让导入 SwiftUI_SPI 的源文件同时看见正常的 SwiftUI API。

这份写法参考了 OpenSwiftUI 示例中用于调用系统实现的 SwiftUI_SPI.swiftinterface。它利用的是 Swift 编译器与现有系统 ABI,既没有修改 Xcode SDK 中的原始文件,也没有改变签名、沙盒或系统权限。

顺带一提,符号表中同时存在 EnvironmentValues.avoidsOrphans,理论上也可以照此补写声明。但环境值涉及 getter / setter 与内部 key 类型,声明成本更高、也更脆弱;而 View 修饰器已足以覆盖绝大多数场景,因此本文只声明后者。

在 Xcode 中配置

  1. 在 Xcode 工程所在目录创建实体文件夹 Modules,将 SwiftUI_SPI.swiftinterface 放入其中。
  2. 该文件既不应进入 Compile Sources,也不应被复制到 App Bundle。
  3. 在 Build Settings 中找到 Swift Compiler - Search Paths → Module Import Paths(构建设置名为 SWIFT_INCLUDE_PATHS)。
  4. 在 Path 中添加包含 SwiftUI_SPI.swiftinterface 的目录,并保留 $(inherited)
  5. 将 Deployment Target 设为 iOS 16 或更高版本。

配置完成后即可使用:

Swift
import SwiftUI_SPI

struct ContentView: View {
    var body: some View {
        Text("SwiftUITEXT 开发者")
            .frame(width: 150)
            .avoidsOrphans(false) // SwiftUI 默认为 true;false 会移除 pushOut 策略
    }
}

image-20260806092351393

如果 Xcode 仍然报告找不到模块,可先执行一次 Clean Build Folder,必要时清理该项目的 Derived Data,让编译器重新生成模块缓存。

项目需要做其他调整吗

不需要,上述改动不会产生全局影响。

.swiftinterface 是供编译器读取的文本模块接口。构建时,Xcode 会据此在 Derived Data 或模块缓存中生成编译器可用的 .swiftmodule;它不会生成或嵌入 SwiftUI_SPI.framework,App Bundle 中也不会因此多出一个动态库。

其他源文件仍可正常使用:

Swift
import SwiftUI

只在需要隐藏接口的少数文件中改为:

Swift
import SwiftUI_SPI

比较稳妥的组织方式,是把 import SwiftUI_SPI 与所有相关调用收敛到一个兼容层文件中,其余业务代码继续只依赖公开 SwiftUI。

能否用于真实项目

从技术角度看,可以:符号自 iOS 16 起已经存在(我在本地验证到 16.4),当前系统仍保留实现,演示项目也能够编译、链接并运行。

但“能够运行”与“适合发布”是两件事。

由于 avoidsOrphans 未出现在 Apple 公开 SDK 接口中,因此它落在审核指南 2.5.1(仅使用公开 API)的约束范围内。

但它与传统意义上的“私有 API 调用”在技术形态上并不相同:这里既没有使用 dlsym,也没有构造 NSSelectorFromString,链接的是一个真实存在于系统二进制中的 Swift mangled 符号。常见的自动化扫描以 Objective-C 私有 selector 为主要目标,未必会命中这种形态。

不过“未必被扫描到”不等于“合规”。2.5.1 的判定权始终在 Apple 手中。对于面向 App Store 的普通产品,更稳妥的选择仍是接受 SwiftUI 当前的默认行为,或在确有需求的局部位置使用公开的 UIKit / TextKit 能力,等待 SwiftUI 正式开放对应接口。

延伸:自己动手挖掘

用如下方式,可以自行探索 SwiftUICore.tbd 中的导出符号:

Shell
SDK_PATH=$(xcrun --sdk iphoneos --show-sdk-path)
SWIFTUI_CORE_TBD="$SDK_PATH/System/Library/Frameworks/SwiftUICore.framework/SwiftUICore.tbd"

rg -o '\$s[[:alnum:]_]+' "$SWIFTUI_CORE_TBD" \
    | sort -u \
    | xcrun swift-demangle

你会发现 avoidsOrphans 远非孤例。

总结

avoidsOrphans 是 SwiftUI 中颇具代表性的一类情况:底层能力早已成熟,框架内部也在使用,却始终没有进入公开 API。借助 -module-abi-name,我们可以为编译器补上一份缺失的声明,从而重新触达这项能力——但也必须清醒地认识到,这条路径没有任何兼容性承诺,最坏的结果是启动崩溃。

本文是一次技术探索,而非发布建议。真正的解法不在开发者这边:期待 Apple 能尽早开放这些早已存在且成熟的接口。

致谢

感谢 Kyle Ye 提供的关键线索,以及 OpenSwiftUI 项目在 SwiftUI 内部实现上的持续挖掘工作。

订阅 Fatbobman 周报

每周精选 Swift 与 SwiftUI 开发技巧,加入众多开发者的行列。

立即订阅