尽管 Swift 6 已发布一段时间,不少苹果第一方框架仍未完成适配,导致部分依赖这些框架的开发者在迁移过程中遇到阻碍。Megabits 在开发 SLIT_STUDIO 相机 App 时也面临类似挑战,但他选择迎难而上。
本文将介绍他如何通过引入 actor
、GlobalActor
以及职责清晰的组件(如 Recorder
和 CaptureManageObject
),应对 Swift 6 的线程安全变更,解决 AVFoundation 与 Swift Concurrency 的兼容性问题,同时提升代码结构与安全性,避免依赖 @preconcurrency
和 nonisolated
等临时方案。
在参加 Let’s Vision 2025 期间,我遇到了 SLIT_STUDIO 的开发者 Megabits,大家聊得很愉快。作为一个学习工业设计的开发者,Megabits 在他的开发作品中总会融入一些独特的设计元素。后来有机会查看了 SLIT_STUDIO 的源代码,发现他在 Swift 6 适配方面做得不错。因此邀请他撰写了这篇文章。
SLIT_STUDIO
大家好,我是 Megabits。最近我完成了一个叫 SLIT_STUDIO 的相机 App 作为我大学设计专业的毕设。整个开发过程花费了一整年,是我 iOS 开发技术的集大成。这是一款利用 Slit-Scan 技术拍摄特效照片的 App。
整个 App 中最为复杂的部分即是图像渲染和相机逻辑的部分,而在相机逻辑部分中,最为复杂的即是针对 Swift 6 的重构。
Swift 6 是在我 App 开发开始前发布的,但我当时主要把精力放在实现功能本身上,并没有把版本拉上来。而且由于 Swift 6 早期变动很大,各个 Beta 之间报错不一致根本不知道该信任哪个,所以也是到了比较后期才开始做这方面的工作。
对于一个相机 App 来说,最大的难点就在于,AVFoundation 依赖于 GCD,并不能直接使用 Swift Concurrency。比如,在创建 DataOutput 的时候,你需要指定 callback 发生在什么队列上。
let output = AVCaptureVideoDataOutput()
output.setSampleBufferDelegate(self, queue: captureQueue)
所以我们需要通过一些没那么直观的方法,把这些使用不同线程管理方式的代码衔接起来,而且还要保证它们是安全的。
重构前
其实在完全没有重构的时候,我 App 中 Swift6 检测到的错误并没有那么吓人,即使打开了完整检查,也只有大概七八十个左右。(虽然说当时看起来还是很吓人的,但 Swift6 重构搞的多了,这点错误就不算多了。)这并不是因为我的代码问题很少,只是因为大的问题还没有暴露出来。之后改得多了,错误可能会暂时性的越来越多,这也都是很正常的。
此时我的代码中所有相机逻辑全部都在一个巨大的 CapturePipeline.swift 和它的 Extension 里面,代码非常臃肿。
class CapturePipeline: NSObject, ObservableObject {
... 此处省略约 300 行和五个 Extension
}
CapturePipeline 的功能现在主要有以下几个部分,对应其关联的主要技术点:
- 更新预览 - ObservableObject
- 相机拍摄 - AVCaptureSession
- 滤镜处理 - Metal
- 影片录制 - AVAssetWriter
所有这些东西现在都在一个 class 里面,而且这个 class 竟然还是个 ObservableObject。明明下面三个没有一样是在 MainActor 顺序执行的,还和 MainActor 混在一起。这本身就没安全到哪去。现在他们能在一起相安无事简直就是靠运气。更别提会有一堆 Sendable 的错误了。所以我们的主要目标就是把本来应该在不同线程执行的代码放到各自的 Actor 中,这样编译器就可以帮我们规避线程不安全的风险,也可以让代码的结构变得更好。
从重构开始
在修复 Swift6 相关错误的时候,不能只把注意力放在错误本身上。因为所有的这些报错,都表示你的 App 有线程不安全的环节。所以我们应该把重点放在梳理 App 的结构,将可以不属于同一线程,可以并行执行的逻辑拆分。并且完成隔离,尽可能将 class 改为 actor 防止同时被多个对象访问。这样自然就可以解决大部分错误了。否则就只会陷入越修警告越多,越来越搞不清楚状况的窘境。
此外目前在 Xcode 中,默认线程安全的检查会在 Minimal 这个级别,这个时候的警告很少,而且都是比较好处理的。这时候大家可能就会想要现在就把这些小问题先修了,但我其实比较建议大家时不时多开到 Complete 看一看,而不是单纯的一级一级修。有时候在较低的检测级别,会存在一些情况,就是你以为自己把问题处理掉了,但其实只是制造了一个在 Minimal 不会显示的更高级别的问题而已。
拆出和 @MainActor 交互的部分
这里我首先从 View 的角度出发。因为不管怎么样,View 永远都是在 MainActor 的,我创建了 CaptureManageObject 来连接 View 和 CapturePipeline,然后给这个绝对安全的 class 贴满了标签。这样一来,虽然 CapturePipeline 本身还不安全,但至少可以梳理出有什么东西之后会和 MainActor 交互的,什么东西是不需要的。
@MainActor
final class CaptureManageObject: NSObject, ObservableObject, Sendable {
let pipeline: CapturePipeline
init(pipeline: CapturePipeline) {
super.init()
Task {
pipeline = try await CapturePipeline(delegate: self, isPad: isPad)
}
}
@Published var scanConfig = ScanConfig() {
didSet(oldValue) {
if oldValue == scanConfig { return }
scanConfigWhileRecording = scanConfig
Task {
await pipeline?.localScanConfig = scanConfig
}
}
}
...
func startCapture(preview: Bool) async {
await pipeline?.startCapture(preview: preview || streamingMode, enableRecording: enableRecording, enableMicrophone: enableMicrophone)
}
func stopCapture() async {
await pipeline?.stopCapture()
}
}
在这之前,下面这种错误随处可见。resultImageSize 和 capturePreviewImage 都是会被 View 读取的,虽然我在这里用 DispatchQueue.main.async 强制他写的时候一定要在主线程,但是因为这两个变量就直接定义在 CapturePipeline 之中,编译器也不会知道会不会有其他不在主线程的代码偷偷直接访问了这个变量。结果就是这样一个 self 不 Sendable 的错误。当然你也可以给这些东西都加上 @MainActor,但总归是不如拿到一个单独的 class 里面清晰。
此外,我还将影片录制的部分先拆了出来,因为这部分的逻辑和其他部分可以完全独立,只要在每一帧都能拿到 buffer 就好了。所以给他直接分出一个 actor。
actor Recorder {
public init(url: URL, videoOrientation: Int32 = 0, vFormatDescription: CMFormatDescription, videoSettings: [String : Any], audioSettings: [String : Any]?) throws {
...
}
func appendVideoSampleBuffer(_ sampleBuffer: CMSampleBuffer) async throws {
...
}
...
}
于是现在 CapturePipeline 还剩下:
- 相机拍摄 - AVCaptureSession
- 滤镜处理 - Metal
拆出和相机交互的部分
拆完了和 View 互动的部分,接下来就拆另一端,和相机交互的部分。这部分就不像 View 那么好拆了。因为前面说的,相机相关的东西都停留在 GCD 时代。即便你给他写成 actor,里面也有大量用 GCD 来管理的 delegate。所以你又不得不把这些相机的 delegate 变成 nonisolated,事实上没什么意义,也就只是套了一层而已。而且只要相机返回了一帧,滤镜就会处理一帧,这之间没在做什么什么并行处理,也不会存在多个相机的实例。那给他们放在一个 actor 里似乎可以省不少事。所以我这里使用了较少有人在使用的 GlobalActor。
@globalActor actor CameraActor: GlobalActor {
static let shared = CameraActor()
}
@CameraActor class Camera: NSObject {
let captureQueue = DispatchQueue(label: "com.linearCCD.capture")
var captureSession = AVCaptureSession()
var currentDevice: AVCaptureDevice?
var videoInput: AVCaptureDeviceInput?
var audioInput: AVCaptureDeviceInput?
var videoOutput: AVCaptureVideoDataOutput?
var audioOutput: AVCaptureAudioDataOutput?
...
}
@CameraActor class CapturePipeline: NSObject, ObservableObject {
let renderContext: MTIContext
var renderPoolForFilter = RenderPool()
var renderPoolForRecordingImage = RenderPool()
var cameraFeedRenderTask: Task<(), Never>?
...
}
这样既将逻辑拆分开,同时又让这两个 class 在同一个线程内顺序执行,节省了很多的麻烦。长期来讲,等苹果把 AVFoundation 的部分改好,或者代码的结构允许再次重构的时候,把这两部分拆分开依然是较好的选择。而且以后增加需要分开线程执行的新功能也会较为方便。所以我其实比较是将 GlobalActor 作为一种临时的解决方案看待。
最后,相机部分的逻辑大概就变成了现在这个样子。
无视错误要谨慎
在 Swfit6 中给我们提供了很多无视错误的方法。比如 @preconcurrency,nonisolated(unsafe) 之类的都可以让我们暂时性的无视一些错误。甚至 Swift6 还会建议你去用 @preconcurrency。但在这样做之前,一定要先思考,这些问题用常规手段真的无法解决吗?
比如说同样的一段代码,我们两种做法都能通过编译,但很显然前者是更加安全也更加直观的,后者如何运行则没有太多提示:
extension Camera: AVCaptureVideoDataOutputSampleBufferDelegate {
nonisolated func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
await ...
}
}
extension Camera: @preconcurrency AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
...
}
}
所以在条件允许的情况下,我们应该尽可能使用安全的解决方案,而不是轻易的无视错误,除非你只是暂时这么做。
对 Swift6 的感想
虽然说从当年 Optional 的概念一开始也让人摸不到头脑,但总体来说还是很简单直接好理解的。而 Swift6 要考虑的范围要大得多,很难做到随手就能解决。也许我们永远没有办法像习惯 Optional 一样习惯 Swift6,不过未来已经是这个样子了,总有一天 Swift6 会变成默认版本。还是得早点做,赶早不赶晚。
最后如果你想要尝试一下使用 SLIT_STUDIO,可以从这里下载体验:App Store(需要非国区ID)。也欢迎你联系我提供你拍摄的有趣作品。
"加入我们的 Discord 社区,与超过 2000 名苹果生态的中文开发者一起交流!"