在 SwiftUI 中使用参数初始化@StateObject

Initialize @StateObject with a parameter in SwiftUI

我想知道目前(在询问时,第一个 Xcode 12.0 Beta)是否有一种方法可以使用来自初始化程序的参数来初始化 @StateObject

更具体地说,这段代码工作正常:

struct MyView: View {
  @StateObject var myObject = MyObject(id: 1)
}

但这不是:

struct MyView: View {
  @StateObject var myObject: MyObject

  init(id: Int) {
    self.myObject = MyObject(id: id)
  }
}

据我了解@StateObject的作用是使视图成为对象的所有者。 我使用的当前解决方法是像这样传递已经初始化的 MyObject 实例:

struct MyView: View {
  @ObservedObject var myObject: MyObject

  init(myObject: MyObject) {
    self.myObject = myObject
  }
}

但是现在,据我了解,创建对象的视图拥有它,而这个视图没有。

谢谢。

这是一个解决方案演示。使用 Xcode 12b.

进行测试
class MyObject: ObservableObject {
    @Published var id: Int
    init(id: Int) {
        self.id = id
    }
}

struct MyView: View {
    @StateObject private var object: MyObject
    init(id: Int = 1) {
        _object = StateObject(wrappedValue: MyObject(id: id))
    }

    var body: some View {
        Text("Test: \(object.id)")
    }
}

backup

应该避免@Asperi 给出的答案 Apple 在他们的 documentation for StateObject.

中是这样说的

You don’t call this initializer directly. Instead, declare a property with the @StateObject attribute in a View, App, or Scene, and provide an initial value.

Apple 尝试在引擎盖下进行大量优化,不要与系统作斗争。

只需创建一个 ObservableObject,并为您首先要使用的参数设置 Published 值。然后使用 .onAppear() 设置它的值,SwiftUI 将完成剩下的工作。

代码:

class SampleObject: ObservableObject {
    @Published var id: Int = 0
}

struct MainView: View {
    @StateObject private var sampleObject = SampleObject()
    
    var body: some View {
        Text("Identifier: \(sampleObject.id)")
            .onAppear() {
                sampleObject.id = 9000
            }
    }
}

目前我真的没有@StateObjects 的好的解决方案,但我试图在@main App 中使用它们作为@EnvironmentObjects 的初始化点。我的解决方案是不使用它们。我把这个答案放在这里是为了那些想和我做同样事情的人。

在提出以下建议之前,我为此苦苦挣扎了一段时间:

这两个let声明在文件级别

private let keychainManager = KeychainManager(service: "com.serious.Auth0Playground")
private let authenticatedUser = AuthenticatedUser(keychainManager: keychainManager)

@main
struct Auth0PlaygroundApp: App {

    var body: some Scene {
    
        WindowGroup {
            ContentView()
                .environmentObject(authenticatedUser)
        }
    }
}

这是我发现的使用参数初始化环境对象的唯一方法。我无法在没有 keychainManager 的情况下创建 authenticatedUser 对象,并且我不打算更改我的整个应用程序的体系结构以使我的所有注入对象不采用参数。

我想我找到了一种解决方法,可以控制用@StateObject 包装的视图模型的实例化。如果您不在视图上将视图模型设为私有,则可以使用合成的成员初始化,在那里您可以毫无问题地控制它的实例化。如果您需要 public 方法来实例化您的视图,您可以创建一个工厂方法来接收您的视图模型依赖项并使用内部合成的 init。

import SwiftUI

class MyViewModel: ObservableObject {
    @Published var message: String

    init(message: String) {
        self.message = message
    }
}

struct MyView: View {
    @StateObject var viewModel: MyViewModel

    var body: some View {
        Text(viewModel.message)
    }
}

public func myViewFactory(message: String) -> some View {
    MyView(viewModel: .init(message: message))
}

正如@Mark 指出的那样,您不应该在初始化期间的任何地方处理 @StateObject。那是因为 @StateObject 在 View.init() 之后被初始化并且稍微 before/after 主体被调用。

关于如何将数据从一个视图传递到另一个视图,我尝试了很多不同的方法,并提出了一个适用于简单和复杂视图/视图模型的解决方案。

版本

Apple Swift version 5.3.1 (swiftlang-1200.0.41 clang-1200.0.32.8)

此解决方案适用于 iOS 14.0 以上版本,因为您需要 .onChange() 视图修饰符。该示例是在 Swift Playgrounds 中编写的。如果你需要一个 onChange like modifier for lower versions,你应该写你自己的修饰符。

主视图

主视图有一个 @StateObject viewModel 处理所有视图逻辑,例如按钮点击和“数据”(testingID: String) -> 检查 ViewModel

struct TestMainView: View {
    
    @StateObject var viewModel: ViewModel = .init()
    
    var body: some View {
        VStack {
            Button(action: { self.viewModel.didTapButton() }) {
                Text("TAP")
            }
            Spacer()
            SubView(text: $viewModel.testingID)
        }.frame(width: 300, height: 400)
    }
    
}

主视图模型 (ViewModel)

viewModel 发布了 testID: String?。这个 testID 可以是任何类型的对象(例如配置对象 a.s.o,随便你怎么说),对于这个例子,它只是子视图中也需要的一个字符串。

final class ViewModel: ObservableObject {
    
    @Published var testingID: String?
    
    func didTapButton() {
        self.testingID = UUID().uuidString
    }
    
}

因此,通过点击按钮,我们的 ViewModel 将更新 testID。我们还希望 SubView 中有此 testID,如果它发生变化,我们也希望我们的 SubView 能够识别并处理这些变化。通过 ViewModel @Published var testingID 我们能够 发布 对我们视图的更改。现在让我们看看我们的 SubViewSubViewModel.

子视图

所以 SubView 有自己的 @StateObject 来处理自己的逻辑。它与其他视图和 ViewModel 完全分开。在此示例中,SubView 仅显示其 MainView 中的 testID。但请记住,它可以是任何类型的对象,例如数据库请求的预设和配置。

struct SubView: View {
    
    @StateObject var viewModel: SubviewModel = .init()
    
    @Binding var test: String?
    init(text: Binding<String?>) {
        self._test = text
    }
    
    var body: some View {
        Text(self.viewModel.subViewText ?? "no text")
            .onChange(of: self.test) { (text) in
                self.viewModel.updateText(text: text)
            }
            .onAppear(perform: { self.viewModel.updateText(text: test) })
    }
}

为了“连接”我们的 testingID 由我们的 MainViewModel 发布,我们用 @Binding 初始化我们的 SubView。所以现在我们的 SubView 中有相同的 testingID。但是我们不想直接在视图中使用它,而是需要将数据传递到我们的 SubViewModel,记住我们的 SubViewModel 是一个 @StateObject 来处理所有逻辑。而且我们不能在视图初始化期间将值传递到我们的 @StateObject 中,就像我在开头写的那样。此外,如果我们的 MainViewModel 中的数据 (testingID: String) 发生变化,我们的 SubViewModel 应该识别并处理这些变化。

因此我们使用两个 ViewModifiers

onChange

.onChange(of: self.test) { (text) in
                self.viewModel.updateText(text: text)
            }

onChange修饰符订阅我们@Binding属性中的变化。因此,如果它 更改 ,这些更改将传递给我们的 SubViewModel。请注意,您的 属性 需要 Equatable。如果您传递一个更复杂的对象,例如 Struct,请确保在您的 Struct.

实施 此协议

onAppear

我们需要 onAppear 来处理“第一个初始数据”,因为 onChange 不会在您的视图第一次初始化时触发。它仅用于 更改 .

.onAppear(perform: { self.viewModel.updateText(text: test) })

好的,这是 SubViewModel,我想没有更多的解释了。

class SubviewModel: ObservableObject {
    
    @Published var subViewText: String?
    
    func updateText(text: String?) {
        self.subViewText = text
    }
}

现在您的数据在您的 MainViewModelSubViewModel 之间同步,这种方法适用于具有许多子视图和这些子视图的大视图子视图等。它还使您的视图和相应的视图模型保持高度可重用性。

工作示例

游乐场 GitHub: https://github.com/luca251117/PassingDataBetweenViewModels

补充说明

为什么我使用 onAppearonChange 而不是仅使用 onReceive:似乎将这两个修饰符替换为 onReceive 会导致连续数据流多次触发 SubViewModel updateText。如果您需要流式传输数据以进行演示,这可能没问题,但是如果您想要处理网络调用等,这可能会导致问题。这就是为什么我更喜欢“双修饰符方法”。

个人注意:请不要修改相应视图范围之外的stateObject。即使它在某种程度上是可能的,也不是它的意思。

@cicerocamargo 的回答是一个很好的建议。我在我的应用程序中遇到了同样的困难,试图弄清楚如何在我的@StateObject 视图模型中注入依赖项,并在多次测试后得出了相同的答案。这样,视图模型在所有情况下都只会被实例化一次。

class MyViewModel: ObservableObject {
   @Published var dependency: Any

   init(injectedDependency: Any) {
       self.dependency = injectedDependency
   }
}

struct  MyView: View {
    @StateObject var viewModel: MyViewModel
    
    var body: some View {
       // ...
    } 
}

struct MyCallingView: View {
    var body: some View {
        NavigationLink("Go to MyView",
            destination: MyView(viewModel: MyViewModel(injectedDependency: dependencyValue)))
    }
}

对此唯一要记住的是,视图模型的实例化应该与视图的实例化一致。如果我们将调用视图代码更改为:

struct MyCallingView: View {
    var body: some View {
        let viewModel = MyViewModel(injectedDependency: dependencyValue)
        NavigationLink("Go to MyView",
            destination: MyView(viewModel: viewModel))
    }
}

那么编译器将无法优化此代码,并且每次 MyCallingView 失效并需要重绘时都会实例化 viewModel。从好的方面来说,即使每次都实例化,也只会使用原始实例。

每当我的视图不正常时,我经常访问此页面。我意识到我需要调整我对 UIKit 的想法,在 UIKit 中我会自由地使用 ViewModels 来封装视图状态。我对这些对象的初始化和拆卸以及我的观点更有信心。将 StateObject 用于具有注入状态的 ViewModel 有点像黑盒子,而且令人困惑。我认为 post 上的答案证明了这一点。

我现在正在努力的是这里提出的模型https://nalexn.github.io/clean-architecture-swiftui/

我仍将严格使用 StateObject 来查看属性,但每当我发现自己想要将状态注入对象时,我会认为它可能是一种代码味道。例如,具有 UserViewModel(id: 1) 的 UserView。我已经尝试使用 _state = ... 方法在视图中注入 viewModel 和 init,虽然它们一开始可能会起作用,但我遇到了错误。

上面链接的干净架构促进了一个单独的 AppState,它将通过绑定将其数据传递到视图。好像有点Manager/Singleton不过至少我状态的管理比较明显

Asperi 的回答很好,但是从文档的角度来看似乎不是很完美。我发现了下面的方法,但我不知道它是否有效。

class Object: ObservableObject {
    let id: String
    init(id: String) {
        self.id = id
    }
}

struct ParentView: View {
    @State var obj: Object?
    var body: some View {
        if let obj = obj {
            ChildView().environmentObject(obj)
        } else {
            Button("Tap") {
                self.obj = Object(id: "id")
            }
        }
    }
}

struct ChildView: View {
    @EnvironmentObject var customObject: Object
    var body: some View {
        Text(customObject.id)
    }
}

简答

StateObject 有下一个初始化:init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)。这意味着 StateObject 将在正确的时间创建对象的实例 - 在 运行 body 第一次之前。但这并不意味着您必须在视图中的一行中声明该实例,例如 @StateObject var viewModel = ContentViewModel().

我找到的解决方案是也传递闭包并允许 StateObject 在对象上创建实例。该解决方案效果很好。有关详细信息,请阅读下面的 长答案

class ContentViewModel: ObservableObject {}

struct ContentView: View {
    @StateObject private var viewModel: ContentViewModel
    
    init(viewModel: @autoclosure @escaping () -> ContentViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel())
    }
}

struct RootView: View {
    var body: some View {
        ContentView(viewModel: ContentViewModel())
    }
}

无论RootView创建多少次bodyContentViewModel的实例都只有一个。

通过这种方式,您可以初始化 @StateObject 具有参数的视图模型。

长答案

@StateObject

@StateObject 第一次在 运行 body 之前创建一个值实例 (Data Essentials in SwiftUI)。并且它在所有视图生命周期内保留该值的一个实例。您可以在 body 之外的某处创建一个视图实例,您将看到 ContentViewModelinit 不会被调用。请参阅以下示例中的 onAppear

struct ContentView: View {
    @StateObject private var viewModel = ContentViewModel()
}

struct RootView: View {
    var body: some View {
        VStack(spacing: 20) {
        //...
        }
        .onAppear {
            // Instances of ContentViewModel will not be initialized
            _ = ContentView()
            _ = ContentView()
            _ = ContentView()

            // The next line of code
            // will create an instance of ContentViewModel.
            // Buy don't call body on your own in projects :)
            _ = ContentView().view
        }
    }
}

因此,将创建实例委托给 StateObject 很重要。

为什么不应该将 StateObject(wrappedValue:) 与实例一起使用

让我们考虑一个例子,当我们通过传递一个 viewModel 实例来创建一个带有 _viewModel = StateObject(wrappedValue: viewModel)StateObject 实例时。当根视图将触发对 body 的额外调用时,将创建 viewModel 上的新实例。如果您的视图是整个屏幕视图,那可能会正常工作。尽管如此,最好不要使用此解决方案。因为您永远不确定父视图何时以及如何重绘其子视图。

final class ContentViewModel: ObservableObject {
    @Published var text = "Hello @StateObject"
    
    init() { print("ViewModel init") }
    deinit { print("ViewModel deinit") }
}

struct ContentView: View {
    @StateObject private var viewModel: ContentViewModel
    
    init(viewModel: ContentViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel)
        print("ContentView init")
    }

    var body: some View { Text(viewModel.text) }
}

struct RootView: View {
    @State var isOn = false
    
    var body: some View {
        VStack(spacing: 20) {
            ContentView(viewModel: ContentViewModel())
            
            // This code is change the hierarchy of the root view.
            // Therefore all child views are created completely,
            // including 'ContentView'
            if isOn { Text("is on") }
            
            Button("Trigger") { isOn.toggle() }
        }
    }
}

我点击了“触发”按钮 3 次,这是 Xcode 控制台中的输出:

ViewModel init
ContentView init
ViewModel init
ContentView init
ViewModel init
ContentView init
ViewModel deinit
ViewModel init
ContentView init
ViewModel deinit

如您所见,ContentViewModel 的实例被创建了很多次。这是因为当根视图层次结构发生变化时,其 body 中的所有内容都会从头开始创建,包括 ContentViewModel。无论您在子视图中将其设置为 @StateObject 。您在根视图中调用 init 的次数与根视图更新 body.

的次数相同

使用闭包

至于 StateObject 在 init 中使用闭包 - init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType) 我们也可以使用它并传递闭包。代码与上一节(ContentViewModelRootView)完全相同,但唯一的区别是使用闭包作为 ContentView:

的初始参数
struct ContentView: View {
    @StateObject private var viewModel: ContentViewModel
    
    init(viewModel: @autoclosure @escaping () -> ContentViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel())
        print("ContentView init")
    }

    var body: some View { Text(viewModel.text) }
}

点击“触发”按钮 3 次后 - 下一个输出:

ContentView init
ViewModel init
ContentView init
ContentView init
ContentView init

可以看到只创建了一个ContentViewModel实例。 ContentViewModel 也是在 ContentView.

之后创建的

顺便说一句,最简单的方法是将 属性 设为 internal/public 并删除 init:

struct ContentView: View {
    @StateObject var viewModel: ContentViewModel
}

结果是一样的。但是在这种情况下 viewModel 不能是私有的 属性。

非常好的答案。

现在,我发现在某些情况下,获得正确的 @StateObject 可能很棘手,例如在用户导航 UI.[=20= 时处理延迟检索信息所需的网络请求]

这是我喜欢使用的一种模式,尤其是当一个屏幕(或屏幕的层次结构)由于其相关的检索成本而应该延迟呈现数据时。

它是这样的:

  • 主屏幕包含子屏幕的模型。
  • 每个模型都会跟踪其显示状态以及是否已加载信息。这有助于避免重复昂贵的操作,例如网络调用。
  • 子屏幕依赖于模型并检查显示状态以显示加载视图或呈现最终的 information/error。

屏幕细分如下:

赶时间?这是项目:

https://github.com/tciuro/StateObjectDemo

主屏幕(ContentView):

import SwiftUI

struct ContentView: View {
    @StateObject private var aboutModel = AboutModel()
    
    var body: some View {
        NavigationView {
            List {
                Section {
                    NavigationLink(destination: AboutView(aboutModel: aboutModel)) {
                        Text("About...")
                    }
                } footer: {
                    Text("The 'About' info should be loaded once, no matter how many times it's visited.")
                }
                
                Section  {
                    Button {
                        aboutModel.displayMode = .loading
                    } label: {
                        Text("Reset Model")
                    }
                } footer: {
                    Text("Reset the model as if it had never been loaded before.")
                }
            }
            .listStyle(InsetGroupedListStyle())
        }
    }
}

支持的数据类型:

enum ViewDisplayState {
    case loading
    case readyToLoad
    case error
}

enum MyError: Error, CustomStringConvertible {
    case loadingError
    
    var description: String {
        switch self {
            case .loadingError:
                return "about info failed to load... don't ask."
        }
    }
}

关于屏幕(AboutView):

import SwiftUI

struct AboutView: View {
    @ObservedObject var aboutModel: AboutModel
    
    var body: some View {
        Group {
            switch aboutModel.displayMode {
                case .loading:
                    VStack {
                        Text("Loading about info...")
                    }
                case .readyToLoad:
                    Text("About: \(aboutModel.info ?? "<about info should not be nil!>")")
                case .error:
                    Text("Error: \(aboutModel.error?.description ?? "<error hould not be nil!>")")
            }
        }
        .onAppear() {
            aboutModel.loadAboutInfo()
        }
    }
}

AboutView 模型:

import SwiftUI

final class AboutModel: ObservableObject {
    private(set) var info: String?
    private(set) var error: MyError?
    
    @Published var displayMode: ViewDisplayState = .loading
    
    func loadAboutInfo() {
        /**
        If we have loaded the about info already, we're set.
        */
        
        if displayMode == .readyToLoad {
            return
        }
        
        /**
        Load the info (e.g. network call)
        */
        
        loadAbout() { result in
            /**
            Make sure we assign the 'displayMode' in the main queue
            (otherwise you'll see an Xcode warning about this.)
            */
            
            DispatchQueue.main.async {
                switch result {
                    case let .success(someAboutInfo):
                        self.info = someAboutInfo
                        self.displayMode = .readyToLoad
                    case let .failure(someError):
                        self.info = nil
                        self.error = someError
                        self.displayMode = .error
                }
            }
        }
    }
    
    /**
    Dummy function; for illustration purposes. It's just a placeholder function
    that demonstrates what the real app would do.
    */
    
    private func loadAbout(completion: @escaping (Result<String, MyError>) -> Void) {
        /**
        Gather the info somehow and return it.
        Wait a couple secs to make it feel a bit more 'real'...
        */
        
        DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
            if Bool.random() {
                completion(.success("the info is ready"))
            } else {
                completion(.failure(MyError.loadingError))
            }
        }
    }
}

简而言之,我发现对于这种延迟加载模式,将 @StateObject 放在主屏幕而不是子屏幕中可以避免潜在不必要的代码 re-executions.

此外,使用 ViewDisplayState 允许我控制是否应显示加载视图,解决了当数据已经在本地缓存时发生的常见 UI 闪烁问题,使 UI 加载视图不值得展示。

当然,这不是灵丹妙药。但根据您的工作流程,它可能会有用。

如果您想看到这个项目的实际效果并尝试使用它,请随时下载它 here。 干杯!