惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
大猫的无限游戏
大猫的无限游戏
博客园 - 聂微东
Jina AI
Jina AI
The Cloudflare Blog
V
Visual Studio Blog
博客园_首页
量子位
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 【当耐特】
爱范儿
爱范儿
博客园 - 三生石上(FineUI控件)
小众软件
小众软件
博客园 - 司徒正美
阮一峰的网络日志
阮一峰的网络日志
Last Week in AI
Last Week in AI
V
V2EX
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园 - 叶小钗
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
WordPress大学
WordPress大学
宝玉的分享
宝玉的分享
T
Tailwind CSS Blog
博客园 - Franky

肘子的 Swift 记事本 | Fatbobman's Blog

iPhone Duo 带来的机遇与挑战 -- 肘子的 Swift 周报 #153 当 Mac mini 的价格不再 mini -- 肘子的 Swift 周报 #152 SwiftData:优化从建模开始 举手之劳 -- 肘子的 Swift 周报 #151 从使用 AI 到委托 AI(下):我想要的可委托性 AI 想放弃了,人没有 -- 肘子的 Swift 周报 #150 从使用 AI 到委托 AI:我的一些思考 越来越慢的 App Review,拦住了谁? -- 肘子的 Swift 周报 #149 ContentBuilder 解析:SwiftUI 类型检查性能提升的秘密 Apple Intelligence 已通过中国政府审核,即将在中国提供服务 -- 肘子的 Swift 周报 #148 热茶还是冰咖啡 -- 肘子的 Swift 周报 #147 Liquid Glass:UIKit 适配踩坑实录 不是模型变慢了,是任务变大了 -- 肘子的 Swift 周报 #146 当灵感跑在了结果前面 -- 肘子的 Swift 周报 #145 当每一次写入都有了新价格 -- 肘子的 Swift 周报 #144 当 Linux 成为“空气”:容器、Agent 与不再重要的“桌面之争” -- 肘子的 Swift 周报 #143 两个 SwiftUI 动画 Bug 排查小记 SPI 加入 Apple,Swift 迈向自举 -- 肘子的 Swift 周报 #142 Swift 还让你 Excited 吗? -- 肘子的 Swift 周报 #141 从 Size Class 到可用空间,horizontalSizeClass 还可靠吗? WWDC 26:AI 帮你看完了,然后呢? -- 肘子的 Swift 周报 #140 WWDC 2026 初印象:符合预期,但更务实 -- 肘子的 Swift 周报 #139 Core Data + Observation:从属性级响应到心智解放 稳定 > 新功能 -- 肘子的 Swift 周报 #138 用自定义 Layout 化解 SwiftUI List 的行高与间距跳变 从社区路标到生态基石:Dave Verwer 的新篇章 -- 肘子的 Swift 周报 #137 消失的 WWDC 愿望单 -- 肘子的 Swift 周报 #136 CocoaPods 正在退场,SwiftPM 才刚到第二章 -- 肘子的 Swift 周报 #135 让 AI 从称手到称心 -- 肘子的 Swift 周报 #134 Swift 并发正被更广泛地接纳 -- 肘子的 Swift 周报 #133
在 SwiftUI Text 中控制孤行:挖掘未公开的 avoidsOrphans
东坡肘子 · 2026-08-06 · via 肘子的 Swift 记事本 | Fatbobman's Blog

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

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 中将其公开了出来。

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

  1. SwiftUICore 的导出符号表中存在 View.avoidsOrphans(_:)EnvironmentValues.avoidsOrphans
  2. 从 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 中配置

  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 或更高版本。

配置完成后即可使用:

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 中也不会因此多出一个动态库。

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

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 内部实现上的持续挖掘工作。