演员隔离 @Observable 类型

6
在WWDC 2021的Discover concurrency in SwiftUI中,他们建议将ObservableObject对象隔离到主要的actor中。例如:
struct ContentView: View {
    @StateObject var viewModel = ViewModel()

    var body: some View {
        Text("\(viewModel.count)")
        .task {
            try? await viewModel.start()
        }
    }
}

@MainActor 
class ViewModel: ObservableObject {
    var count = 0

    func start() async throws {
        while count < 10 {
            count += 1
            try await Task.sleep(for: .seconds(1))
        }
    }
}

但在iOS 17的Observation框架中(正如WWDC 2023的在SwiftUI中发现观察所介绍的),似乎不再需要将主要操作员隔离以防止UI更新在后台线程上触发。例如,以下代码可以正常工作,而不会出现关于从后台启动UI更新的警告:
struct ContentView: View {
    var viewModel = ViewModel()             // was `@StateObject var viewModel = ViewModel()`

    var body: some View {
        Text("\(viewModel.count)")
        .task {
            try? await viewModel.start()
        }
    }
}

@Observable class ViewModel {               // was `@MainActor class ViewModel: ObservableObject {…}`
    var count = 0                           // was `@Published`

    func start() async throws {
        while count < 10 {
            count += 1
            try await Task.sleep(for: .seconds(1))
        }
    }
}

对于避免主要角色隔离的根本机制并不立即明显,但它有效。

但是,如果你希望除了不从后台更新 UI 之外的其他原因,ViewModel 也被隔离为角色,该怎么办呢? 例如,我只想在这个 @Observable 对象中避免竞争?SE-0395 表明它(当前)不支持可观察的 actor 类型:

未来增强的另一个重点领域是支持可观察的 actor 类型。这将需要针对目前角色尚不存在的关键路径进行特定处理。

那么,对于某些全局角色(如主要角色)隔离的 class 呢?看起来我可以将视图模型隔离到主要角色,但是在 View 中会出现错误:

在同步非隔离上下文中调用主要角色隔离的初始化程序 'init()'

我可以通过将View隔离到主要的角色中来规避该错误。例如,以下似乎有效:

@MainActor
struct ContentView: View {
    var viewModel = ViewModel()

    var body: some View {
        Text("\(viewModel.count)")
        .task {
            try? await viewModel.start()
        }
    }
}

@MainActor
@Observable
class ViewModel {
    var count = 0

    func start() async throws {
        while count < 10 {
            count += 1
            try await Task.sleep(for: .seconds(1))
        }
    }
}

但是,将整个View隔离到主要角色似乎不太合适,尤其是当苹果明显选择不这样做时(原因我无法理解)。所以,简而言之,如何将@Observable类型隔离到全局角色(比如主要角色)?

使用@Observable宏单独并不会以任何方式显式地使类的成员进行同步或隔离。实际上,突变是同步的,并且发生在与当前调度队列执行器相关联的任何线程上。因此,start()只有在任务使用相同的底层线程或同步调度队列时才可能是线程安全的。然而,SwiftUI视图只能从主线程读取值(与设置器异步)。我怀疑访问模型属性的视图是否是线程安全的。 - undefined
对于访问属性的视图,可以通过其他方式确保线程安全性。如果我们展开宏,就可以看到如何实现这一点的简要情况。我的猜测是:通过阴影存储和突变函数的副本(由设置器注册,并在主线程上异步调用)来实现。 - undefined
是的,这正是我的整个观点,即@Observable可能会防止主线程UI更新警告,但它不是线程安全的。(顺便说一下,start的非隔离async版本在“通用执行器”上运行,利用了协作线程池。)恢复线程安全性是我希望将class隔离到全局actor的整个原因。就像你在下面所说的那样,使用@MainActor类型和nonisolated init是另一种解决这个问题的方法。 - undefined
1
就我所知,从Xcode 15 Beta 5开始,将View隔离到@MainActor不再影响#Preview,因此我已经删除了我问题中的那部分内容。 - undefined
1个回答

2
我只有一个解决办法来应对这个问题,而且它可能并不适用于所有情况。
但首先,让我们来看一下问题:
给定一个使用并初始化模型的SwiftUI视图:
struct ContentView: View {
    @State var viewModel = ViewModel()

    var body: some View {
        ...
    }
}

并且相应的模型使用@MainActor来同步其成员:
@MainActor
@Observable
class ViewModel {
    var count: Int = 0
    
    func foo() async throws {
        // asynchronously mutates member `count` which 
        // needs to be synchronised. Here, through 
        // using `@MainActor`. That way, it's guaranteed 
        // that mutations on `count` happen solely on 
        // the main thread.
        ...
    }
}

尝试编译时,我们在结构体ContentenView中遇到了一个错误。
    @State var viewModel = ViewModel() <== Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context

那就是,编译器希望确保Model的初始化程序将在主线程上调用。虽然我们直觉上认为这种情况肯定会发生,毕竟它是一个视图,但编译器需要明确的事实。
这个要求的原因并不那么明显。通常,在其他语言中,我们可以通过其他方式来确保成员的安全访问,而构造函数可以在任何线程上调用。
对于Swift,我们可以阅读更多关于此的信息On Actors and Initialization, SE-0327,具体来说:overly-restrictive-non-async-initializers 将SwiftUI视图与主actor关联可能是一种解决方案,但目前可能会引起其他问题。
另一个解决方案可能是将初始化器声明为非隔离 - 但要小心,这可能会破坏同步。在这种情况下,可以通过显式地将初始化器声明为空的方式来解决:
@MainActor
@Observable
class ViewModel {
    var count: Int = 0
    
    nonisolated init() {}
    
    func start() async throws {
        while count < 10 {
            count += 1
            try await Task.sleep(for: .seconds(1))
        }
    }
}

注意:
为了使用一个空的非隔离初始化器,在声明时必须初始化所有成员。例如:
class ViewModel {
    var count: Int = 0
    ...

非隔离的初始化器无法初始化/设置成员。如果我们尝试,会出现以下错误:

主角独立属性'count'无法从非隔离的上下文中进行变异

注意

声明为非隔离的更复杂的初始化器可能容易出现数据竞争!请仔细阅读上述链接。

这是当前问题的临时解决方法。希望在未来能更完善这些事情。


是的,非隔离的init确实是解决这个问题的一种方法。谢谢! - undefined

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接