SwiftUI 的 Text 有一项默认行为:当段落末行只剩一个孤词或孤字时,它会主动把上一行的完整词组一并推到末行,避免“落单”。这份体贴多数时候是对的,但代价是上一行可能留下一大片空白——在窄容器或中英混排的场景中,反而破坏了段落的均衡。
麻烦在于,UIKit 允许开发者通过 NSParagraphStyle 关掉这项策略,SwiftUI 却没有提供任何公开接口。本文将挖掘 SwiftUI 中一个早已存在、却始终未曾公开的 API —— avoidsOrphans,让开发者重新拿回这项控制权。
本文使用了未公开的 SwiftUI ABI。它适合研究、验证与内部工具,不应被视为 Apple 承诺兼容的正式 API。
孤行是什么,SwiftUI 又做了什么
孤行(orphan)指段落末尾独占一行的孤词或孤字。例如下图中,最后一个中文字符“者”便孤零零地占据了一整行。
需要说明的是,排版学中的 orphan 通常指段落首行孤悬于页面或栏的底部,widow 指段落末行被推至次页顶部,而末行只剩一两个词的情况更常被称作 runt。Apple 在命名上选择了 orphan 一词,本文沿用其口径。
在 UIKit 中,开发者可以通过 NSParagraphStyle.LineBreakStrategy.pushOut 调整换行策略(line break strategy)。移除 pushOut 后,断行结果如下:
SwiftUI 默认采用 .standard 策略,其中就包含 .pushOut。当末行可能只剩一个孤词时,文本系统会把前面的完整词组一并移到末行。孤行是避免了,上一行却因此空出一大片:
在窄容器中,这种留白往往比孤行本身更刺眼。而 SwiftUI 并未提供任何关闭它的手段。
Text + NSAttributedString:此路不通
既然 NSAttributedString 支持段落样式,能否先从 .standard 中移除 .pushOut,再转换成 Swift AttributedString 交给 Text?
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 重新构造并写入段落样式,覆盖掉外部传入的配置:
attributes[.kitParagraphStyle] = properties.paragraph.style(
environment: environment
)
结论是:在 SwiftUI 中若想自定义 lineBreakStrategy,通常只能退回到包装 UILabel 或其他 TextKit 视图。为了一行断行规则付出这样的代价,显然并不经济。
SwiftUI 有这个能力,只是没有公开
在和 OpenSwiftUI 开发者 Kyle Ye 的交流中,他告诉我,SwiftUI 其实几年前就已具备这项能力,只是从未开放。他在 OpenSwiftUI 中将其公开了出来。
我循着他提供的线索做了检查,并得到验证:
- SwiftUICore 的导出符号表中存在
View.avoidsOrphans(_:)与EnvironmentValues.avoidsOrphans; - 从 iOS 16.x 开始,系统二进制中已出现上述符号。这说明它至少从 iOS 16 时代便已存在,而非近期 SDK 才加入的空壳声明。
那么,我们能否在自己的项目中用上它?
在项目中调用 avoidsOrphans
Apple 随 SDK 分发的模块接口已经删去了这项声明,因此下面这种方式行不通:
@_spi(Private) import SwiftUI
我们真正需要做的,是向编译器补上一份声明,并让这份声明生成与系统 SwiftUI 完全相同的 ABI 符号。
构建一份 ABI 模块视图
在项目根目录创建 Modules 文件夹,并加入 SwiftUI_SPI.swiftinterface:
// 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 中配置
- 在 Xcode 工程所在目录创建实体文件夹
Modules,将SwiftUI_SPI.swiftinterface放入其中。 - 该文件既不应进入 Compile Sources,也不应被复制到 App Bundle。
- 在 Build Settings 中找到 Swift Compiler - Search Paths → Module Import Paths(构建设置名为
SWIFT_INCLUDE_PATHS)。 - 在 Path 中添加包含
SwiftUI_SPI.swiftinterface的目录,并保留$(inherited)。 - 将 Deployment Target 设为 iOS 16 或更高版本。
配置完成后即可使用:
import SwiftUI_SPI
struct ContentView: View {
var body: some View {
Text("SwiftUITEXT 开发者")
.frame(width: 150)
.avoidsOrphans(false) // SwiftUI 默认为 true;false 会移除 pushOut 策略
}
}
如果 Xcode 仍然报告找不到模块,可先执行一次 Clean Build Folder,必要时清理该项目的 Derived Data,让编译器重新生成模块缓存。
项目需要做其他调整吗
不需要,上述改动不会产生全局影响。
.swiftinterface 是供编译器读取的文本模块接口。构建时,Xcode 会据此在 Derived Data 或模块缓存中生成编译器可用的 .swiftmodule;它不会生成或嵌入 SwiftUI_SPI.framework,App Bundle 中也不会因此多出一个动态库。
其他源文件仍可正常使用:
import SwiftUI
只在需要隐藏接口的少数文件中改为:
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 中的导出符号:
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 内部实现上的持续挖掘工作。