外观
记一次 macOS SwiftUI 应用 CI 构建后崩溃排查全记录
约 1544 字大约 5 分钟
SwiftCI/CD踩坑记录
次阅读
2026-05-14
从 EXC_BREAKPOINT 到 SIGTRAP,历经 13 次修复提交,最终定位到 Swift 6 跨版本运行时不兼容问题。
背景
制作了一款 macOS 菜单栏应用,用 SwiftUI + AppKit 构建,提供定时肩颈活动提醒。本地 Xcode 编译运行完全正常,但 GitHub Actions CI 构建的 DMG 安装后打开就崩溃。
应用用到的关键技术栈:
- SwiftUI + AppKit(菜单栏应用)
UNUserNotificationCenter(系统通知)SMAppService(开机自启动)@MainActor+ Swift Concurrency- GitHub Actions CI(构建 + 自动发布)
排查过程
第一阶段:本地调试 — UNUserNotificationCenter 委托崩溃
最初的崩溃发生在本地,和 CI 无关。现象是 UNUserNotificationCenterDelegate 回调中触发 EXC_BREAKPOINT。
fix: resolve EXC_BREAKPOINT crash in UserNotifications delegate
fix: add @preconcurrency to UNUserNotificationCenterDelegate conformance
fix: remove @MainActor from ReminderEngine to fix delegate crash
fix: revert to @MainActor + @preconcurrency approach
fix: remove class-level @MainActor, use @unchecked Sendable
fix: add @preconcurrency to UNUserNotificationCenterDelegate
fix: use nonisolated logger and [weak self] to prevent SIGTRAP in UN callbacks根因:ReminderEngine 是 @MainActor 类,同时遵循 UNUserNotificationCenterDelegate。UN 的回调在后台线程执行,但 @MainActor 隔离的方法要求在主线程调用。Swift 6 严格并发模式下,这个冲突直接导致运行时 crash。
最终解决方案:
- 委托方法标记
nonisolated,在方法内通过Task { @MainActor in ... }切换到主线程 - 日志属性使用
nonisolated static let避免跨线程访问 - 回调中全部使用
[weak self]防止生命周期问题
@MainActor
final class ReminderEngine: NSObject, ObservableObject, @preconcurrency UNUserNotificationCenterDelegate {
nonisolated static let logger = Logger(...)
nonisolated func userNotificationCenter(_ center: ..., didReceive: ...) {
Task { @MainActor in
self.snooze(minutes: 10)
}
completionHandler()
}
}这段经历让我对 Swift Concurrency 的理解加深了不少,但真正的大坑还在后面。
第二阶段:CI 构建后崩溃 — 签名与权限假说
本地问题修好后,发现 CI 构建的 app 打开直接崩溃。第一个猜测是代码签名问题。
fix: add Hardened Runtime and entitlements for CI build做了三件事:
- 新建
NeckEase.entitlements,声明 JIT 和 unsigned executable memory 权限 project.yml中添加CODE_SIGN_ENTITLEMENTS- CI 的
codesign命令加上--options runtime启用 Hardened Runtime
结果:构建成功,依然崩溃。方向错了。
第三阶段:参照正常项目 — 全面对比
拉取了能正常运行的姊妹项目 [EaseNeck Lite](https://github.com/AkaChou/easeL neck-lite) 进行逐项对比:
| 维度 | EaseNeck(正常) | NeckEase(崩溃) |
|---|---|---|
| 编译方式 | swiftc 直接编译 | XcodeGen + xcodebuild |
| Swift 版本 | 5.9 | 6.0 |
| 部署目标 | macOS 13.0 | macOS 15.0 |
@MainActor | 不使用 | 大量使用 |
| Hardened Runtime | 无 | 有 |
| CI runner | macos-14 | macos-latest |
差异巨大。决定彻底放弃 xcodebuild,改用 swiftc 直接编译。
fix: switch CI from xcodebuild to swiftc to match working EaseNeck build结果:macos-14 + Swift 5.9 下编译失败 — @preconcurrency 用在协议遵循上是 Swift 6 专属语法。
第四阶段:Swift 版本拉锯战
fix: remove @preconcurrency from conformance for Swift 5.9 compatibility → 编译失败(Task 中捕获 self 错误)
fix: use macos-15 runner with swift-version 6 for CI build → 构建成功,但依然 SIGTRAPmacos-15 的 Xcode 16.4 有 Swift 6.0+,代码可以编译了。但产出的二进制在我的 macOS 26 上仍然 SIGTRAP。
第五阶段:关键突破 — 本地复现与对比
在本地用 swiftc 编译完全相同的代码,发现:
本地 swiftc(Swift 6.3.2)编译 → 运行正常 ✓
CI swiftc(Swift 6.0)编译 → SIGTRAP ✗对比二进制的动态库依赖:
CI 构建:SwiftUI compatibility 6.5.4 (macOS 15 SDK)
本地构建:SwiftUI compatibility 7.5.3 (macOS 26 SDK)真相大白:Swift 6.0 编译器生成的 @MainActor 运行时检查代码,与 macOS 26 的 Swift 6.3 运行时存在兼容性问题。本地用 Swift 6.3.2 编译就没问题。
最终修复:Swift 5 模式
fix: use Swift 5 mode + fix version bump script去掉 -swift-version 6,用 Swift 5 模式编译。Swift 5 模式对 @MainActor 的运行时检查更宽松,二进制在 macOS 26 上运行正常。
验证:
# CI Swift 5 模式构建的 DMG
/tmp/Swift5DMG/NeckEase.app/Contents/MacOS/NeckEase
# → 运行正常,菜单栏图标出现,通知正常弹出同时修复了一个一直存在的版本号递增 bug:bash 算术展开 ((expr)) 应该写成 $((expr))。
最终 CI 配置
runs-on: macos-15
swiftc \
-Osize \ # 优化体积
-o "$APP_BUNDLE/Contents/MacOS/$APP_NAME" \
-target arm64-apple-macosx13.0 \
-framework SwiftUI -framework Foundation \
-framework UserNotifications -framework AppKit \
-framework ServiceManagement -framework Combine \
NeckEase/*.swift
codesign --force --deep --sign - "$APP_BUNDLE" # 简单 ad-hoc 签名关键决策:
- 不用
-swift-version 6:避免 Swift 6 strict mode 的运行时兼容问题 - 用
swiftc而非 xcodebuild:更简单、更可控、更容易对齐参考项目 - 不用 Hardened Runtime:简单的 ad-hoc 签名就够用
-Osize:优化体积,也避免了某些优化级别的运行时问题
经验教训
1. Swift 版本兼容性是隐形的坑
Swift 虽然宣称 ABI 稳定(从 Swift 5.0 起),但 @MainActor 等 Concurrency 特性的运行时检查行为在不同版本间有差异。用 Swift 6.0 编译的二进制在 Swift 6.3 运行时上可能出问题,这不是 ABI 层面的兼容,而是编译器插入的运行时检查逻辑不同。
2. CI 环境永远落后于你的本机
GitHub Actions 的 macOS runner 版本固定,你本地的 macOS 和 Xcode 版本永远更新。编译器版本不一致时,@MainActor 等运行时特性可能产生兼容问题。
3. 对比法是最高效的排查手段
有一个同样功能、能正常工作的参照项目(EaseNeck Lite)是关键。逐项对比两个项目的编译方式、Swift 版本、代码风格,让问题迅速收敛。
4. Swift 6 的 @MainActor + UNUserNotificationCenterDelegate 陷阱
这是 Swift 6 Concurrency 中最常见的坑之一:
- UN 的回调在后台线程
@MainActor隔离要求主线程- 即使加了
@preconcurrency,运行时仍然可能 trap
最稳妥的做法是让委托方法 nonisolated,内部通过 Task { @MainActor in } 切换。
5. 能用 Swift 5 模式就别用 Swift 6
至少在 CI 编译分发场景下,Swift 5 模式的兼容性远好于 Swift 6 strict mode。代码里保留 @MainActor 等注解,但编译时走 Swift 5 模式,既保证了代码质量,又避免了运行时兼容问题。
从 v1.0.0 到 v1.0.8,13 次修复提交,最终一行 -swift-version 6 的删除解决了所有问题。