---
url: /dev/ur1wlsgo/index.md
---
# macOS SwiftUI 应用 CI 构建后崩溃排查全记录

> 从 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]` 防止生命周期问题

```swift
@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
```

做了三件事：

1. 新建 `NeckEase.entitlements`，声明 JIT 和 unsigned executable memory 权限
2. `project.yml` 中添加 `CODE_SIGN_ENTITLEMENTS`
3. 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                 → 构建成功，但依然 SIGTRAP
```

macos-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 上运行正常。

验证：

```bash
# CI Swift 5 模式构建的 DMG
/tmp/Swift5DMG/NeckEase.app/Contents/MacOS/NeckEase
# → 运行正常，菜单栏图标出现，通知正常弹出
```

同时修复了一个一直存在的版本号递增 bug：bash 算术展开 `((expr))` 应该写成 `$((expr))`。

## 最终 CI 配置

```yaml
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` 的删除解决了所有问题。*
