WWDC Quick Look 💓 By SwiftGGTeam
Port your Mac app to Apple silicon

Port your Mac app to Apple silicon

观看原视频

Highlight

Xcode 12 支持把 Mac app 编译成同时包含 arm64 与 x86_64 的 Universal app,并要求开发者检查二进制依赖、低层计时代码、同步原语和插件架构匹配。

核心内容

Mac 迁移到 Apple Silicon 以后,很多团队最担心的是重写。一个已经运行多年的 Mac app,里面可能有 AppKit UI、C 代码、二进制 framework、命令行构建脚本、插件系统和性能优化代码。过去从 PowerPC 到 Intel 的经验会让人想到字节序、架构分支和大量条件编译。

这场 session 给出的第一步很直接:打开 Xcode 12,选择 My Mac,按 Run。演讲中的 Solar System 示例没有改项目设置,就编译并以 Apple CPU 架构运行。原因是 Xcode 12、SDK 和工具链已经能在任意 Mac 上交叉编译 arm64 与 x86_64。对高层 AppKit 代码来说,迁移常常先从一次普通构建开始。

麻烦出现在旧假设里。代码可能用 __x86_64__ 表示“这是 Mac”,也可能把 mach_absolute_time() 当成纳秒,或者依赖一个只有 Intel slice 的 .framework。这些代码在 Intel Mac 上跑了很多年,到了 arm64 构建、原生运行或插件加载时才会暴露。

Apple 的路线是把迁移拆成可检查的步骤:先确认 Intel 构建仍然通过,再切换到原生架构修编译错误,然后处理 link-time 的二进制依赖,最后在 Apple Silicon Mac 上跑测试、调试、性能分析和插件验证。Universal app 不是一次开关,它是一份检查清单。

详细内容

从 Xcode 12 构建 Universal app

02:19)Xcode 12 的运行目的地可以选择原生 Apple Silicon、Rosetta,或者构建同时支持 Apple Silicon 与 Intel 的 Universal app。演讲强调,工具链能在 Intel Mac 上交叉编译,所以构建 Universal app 不要求开发机已经是 Apple Silicon Mac。

关键点:

  • Mach-O 文件可以只包含一个 CPU 架构,也可以是同时包含 arm64 与 x86_64 的 Universal binary。
  • lipo -info 是演讲中用来检查 .a.dylib.framework.xcframework 架构信息的命令。
  • 命令行构建时先用 xcodebuild -showdestinations 查看目的地,再用 -destination 指定测试运行在哪个架构。
  • arch=arm64 对应 Apple Silicon 原生测试路径,arch=x86_64 对应在 Apple Silicon Mac 上通过 Rosetta 跑 Intel 代码的测试路径。

09:04)第一个构建坑是页面大小。演讲提到 Apple Silicon 系统的 native page size 与 Intel 机器不同,PAGE_SIZE 不再适合作为编译期常量。

关键点:

  • PAGE_MAX_SIZE 表示编译期可用的最大页大小上界。
  • vm_page_size 在运行期返回当前进程看到的页大小。
  • Rosetta 会匹配 Intel 环境,translated process 仍支持 4 KB pages,因此这个问题要在原生路径测试。

用语义条件替代 CPU 条件

10:09)演讲中的示例函数 GetDefaultTimerClass__x86_64__ 表示 macOS 和 simulator。到了 Apple Silicon Mac,这个条件失效。CPU 架构描述的是指令集,平台条件描述的是系统环境,两者不能混用。

关键点:

  • TARGET_OS_OSX 和 Swift 的 #if os(macOS) 表达“这段代码跑在 macOS”。
  • TARGET_CPU_X86_64 和 Swift 的 #if arch(x86_64) 只在代码确实依赖 Intel 指令或 ABI 时使用。
  • 如果只有 Intel 汇编或 SSE/AVX 实现,需要给 arm64 提供第二套实现,或改用 Accelerate、Compression 等系统框架。

修复非 Universal 二进制依赖

12:54)演讲中的 demo app 在 arm64 链接阶段失败,错误来自一个预编译 binary framework。linker 报出 ignoring file when building for macOS-arm64,说明项目正在链接一个只含 x86_64 的文件。

关键点:

  • link-time 的 Undefined symbols for architecture arm64 可能是下游 binary 没有 arm64 slice。
  • .framework 目录本身不是 Mach-O 文件,要检查 framework 里的实际可执行文件。
  • 迁移时要扫描 .a.dylib.framework.xcframework,再用 lipo -info 确认每个预编译依赖是否 Universal。
  • 第三方 binary 需要供应商发布 Universal build;短期排查时可以临时移除依赖,但发布前要换回 Universal 版本。

不要把 mach_absolute_time 当成纳秒

21:19)演讲的 runtime bug 出现在进度条刷新频率上。旧代码把 mach_absolute_time() 的 tick 当成纳秒,然后除以 1_000_000_000 得到秒。这个假设在不同 CPU 架构上不成立。

// Don’t assume the time is returned in nanoseconds

func monotonicTimestampInSeconds() -> Double {
  let ticks = mach_absolute_time()
  let seconds = Double(ticks) / 1_000_000_000
  return seconds
}

关键点:

  • mach_absolute_time() 返回 tick units,不保证 tick 等于 1 纳秒。
  • 代码把 ticks 当纳秒,会让定时器在某些架构上变慢,演讲提到常见现象是大约慢 40 倍。
  • 这类 bug 通常不会在编译期暴露,必须在 Apple Silicon Mac 上跑原生路径。

21:40)如果确实需要单调时间戳,演讲建议改用返回纳秒的 clock_gettime_nsec_np(CLOCK_UPTIME_RAW),或者使用 Foundation 与 GCD 的 timer。

// Use clock_gettime_nsec_np to read timestamp in nanoseconds

func monotonicTimestampInSeconds() -> Double {
  let nanoseconds = clock_gettime_nsec_np(CLOCK_UPTIME_RAW)
  let seconds = Double(nanoseconds) / 1_000_000_000
  return seconds
}

关键点:

  • clock_gettime_nsec_np(CLOCK_UPTIME_RAW) 直接返回纳秒,后续除法才有明确单位。
  • 函数名保留 seconds 语义,调用方不用知道底层时间 API 已替换。
  • 搜索代码库里的 mach_absolute_time 是迁移检查中的低成本动作。

用阻塞同步替代 spinlock 和 busy-wait

26:40)Apple Silicon Mac 有性能核心与能效核心。系统会根据性能和电量动态调度。演讲特别提醒,spinlock 和 busy-wait 可能白白占住性能核心,拖慢整体工作完成时间。

func performWorkUnderSpinlock() {
  spinlock_lock()
  performWork()
  spinlock_unlock()
}

func retrieveNextWorkTask() -> WorkTask {
  while true {
    let task = queue.sync { taskQueue.pop() }
    if let task = task { return task }
    else { continue }
  }
}

关键点:

  • spinlock_lock() 会在拿不到锁时继续消耗 CPU。
  • while true 循环反复检查队列是否有任务,这是 busy-wait。
  • 在非对称 CPU 系统上,这类等待可能占用更适合执行真实任务的 P cores。

27:03)替代方案是使用阻塞锁和条件变量。线程无法继续时应让出 CPU,等条件满足再恢复。

func performWorkUnderSpinlock() {
  os_unfair_lock_lock()
  performWork()
  os_unfair_lock_unlock()
}

func retrieveNextWorkTask() -> WorkTask {
  condition.lock()
  while !taskQueue.hasAnyWork {
    condition.wait()
  }
  let task = taskQueue.pop()
  condition.unlock()
  return task
}

关键点:

  • os_unfair_lock_lock() 是阻塞型同步原语,适合替代自旋等待。
  • condition.wait() 在没有任务时阻塞线程,不反复占用 CPU。
  • 演讲还列出 NSLock、pthread mutex、NSCondition、pthread condition variables,并建议能用 GCD 时优先用 GCD。

插件必须匹配进程架构

30:16)插件迁移的核心规则很简单:同一个进程里的所有代码必须使用同一种 CPU 架构。in-process 插件用 dlopenBundle.load 加载,如果 host 是原生 arm64,插件也必须有 arm64 slice;如果 host 在 Rosetta 下运行,插件必须能作为 Intel 代码加载。

void *plugin_module = dlopen("./path/to/plugin.dylib", RTLD_NOW);
if (plugin_module == NULL) {
  fprintf(stderr, "loading module failed:\n");
  fprintf(stderr, "%s\n", dlerror());
  return 0;
}

关键点:

  • dlopen 返回 NULL 时必须调用 dlerror(),错误信息会指出磁盘上的文件架构不匹配。
  • 第一方插件应删除硬编码的 x86_64 Architectures 设置,让 Xcode 使用 Standard Architectures。
  • 第三方 in-process 插件如果仍是 Intel-only,用户可能只能强制 app 通过 Rosetta 打开。
  • out-of-process 插件可通过 XPC 隔离成独立进程,一份 app 最多维护按架构区分的插件进程。

发布前看三类 crash log

36:39)Xcode Organizer 会显示 archive 中包含的 CPU 架构,也会在 crash log 里显示崩溃来自哪个架构、进程是否处于 translated 状态。Universal app 发布后,开发者会看到 Intel Mac 上的 x86 crash、Apple Silicon Mac 上的 native arm64 crash,以及 Apple Silicon Mac 上 Rosetta 路径的 translated x86 crash。

关键点:

  • Debug 构建通过不代表 Archive 构建也通过,因为 Archive 可能第一次真正构建 Universal 产物。
  • 发布前要确认 Archive 产物包含 arm64 与 x86_64,并分别跑原生 arm64 与 Rosetta x86_64 测试。
  • 插件和二进制依赖仍要用 lipo -info 检查,避免发布后才发现某个路径只能在 Intel 下工作。
  • Thread Sanitizer 适合排查迁移后暴露的数据竞争;演讲指出 Intel 与 Apple Silicon 的 memory ordering model 不同。
  • kernel extension 和 DriverKit 相关软件要额外阅读 macOS porting 文档,因为部分内核扩展在 Apple Silicon Mac 上被弃用或不允许使用。

核心启发

1. 给现有 Mac app 做一页 Universal 迁移仪表盘

  • 做什么:在团队内部做一个脚本,扫描 .a.dylib.framework.xcframeworkmach_absolute_time__x86_64__PAGE_SIZE 和硬编码 Architectures。
  • 为什么值得做:这场 session 的迁移问题大多能被静态扫描提前发现,尤其是二进制依赖和架构条件。
  • 怎么开始:用 shell 或 SwiftPM command plugin 包装 findlipo -inforg,输出每个目标是否有 arm64 slice,以及需要人工确认的源代码位置。

2. 在 CI 中把 Mac 测试拆成 arm64 与 x86_64 两条路径

  • 做什么:同一个 test scheme 分别用 arch=arm64arch=x86_64 跑,记录哪条路径失败。
  • 为什么值得做:演讲明确要求原生和 Rosetta 都测试;只跑一种架构会漏掉非 portable code。
  • 怎么开始:在 Apple Silicon runner 上为 xcodebuild test 增加两个 destination,并把性能测试结果按架构上传到 dashboard。

3. 给插件型 app 增加架构诊断面板

  • 做什么:当插件加载失败时,把 dlerror()、插件路径、host 当前架构和 lipo -info 结果展示给用户或写入诊断日志。
  • 为什么值得做:in-process 插件的失败常来自架构不匹配,普通用户只看到功能失效,很难知道该更新插件还是切换 Rosetta。
  • 怎么开始:在 dlopenBundle.load 失败分支收集错误,附带“插件供应商需要提供 Universal build”的提示,并给用户打开支持文档的入口。

4. 重写低层性能代码的等待策略

  • 做什么:找出自旋锁、空循环取队列、按 CPU 数切分任务的旧实现,改成 GCD、阻塞锁或条件变量。
  • 为什么值得做:Apple Silicon Mac 的 P cores 和 E cores 会动态调度,busy-wait 会浪费性能核心。
  • 怎么开始:先替换最热路径里的 spinlock,再用 Instruments 对比原生 arm64 下 CPU 占用和任务完成时间。

5. 给时间相关代码建立架构回归测试

  • 做什么:对刷新频率、超时、节流、防抖、benchmark 计时建立单元测试或 UI 测试。
  • 为什么值得做mach_absolute_time() tick 假设错误会表现成“慢几十倍”的运行期 bug,编译器不会提醒。
  • 怎么开始:搜索 mach_absolute_time,优先改用 clock_gettime_nsec_np(CLOCK_UPTIME_RAW) 或 Foundation/GCD timer,再在 arm64 和 x86_64 destination 下跑同一组测试。

关联 Session

评论

GitHub Issues · utterances