如何传递环境对象到视图模型?

我希望创建一个可以被视图模型(而不仅仅是视图)访问的 Environment 对象。

Environment 对象跟踪应用程序会话数据,例如 loggedIn、访问令牌等,这些数据将被传递到视图模型(或需要的服务类)中,以允许调用 API 来传递来自这个 Environment 对象的数据。

我尝试从视图中将会话对象传递给视图模型类的初始化器,但是得到了一个错误。

如何使用 SwiftUI 访问/传递 Environment 对象到视图模型中?

25906 次浏览

I choose to not have a ViewModel. (Maybe time for a new pattern?)

I have setup my project with a RootView and some child views. I setup my RootView with a App object as the EnvironmentObject. Instead of the ViewModel accessing Models, all my views access classes on App. Instead of the ViewModel determining the layout, the view hierarchy determine the layout. From doing this in practice for a few apps, I've found my views are staying small and specific. As an over simplification:

class App: ObservableObject {
@Published var user = User()


let networkManager: NetworkManagerProtocol
lazy var userService = UserService(networkManager: networkManager)


init(networkManager: NetworkManagerProtocol) {
self.networkManager = networkManager
}


convenience init() {
self.init(networkManager: NetworkManager())
}
}
struct RootView: View {
@EnvironmentObject var app: App
    

var body: some View {
if !app.user.isLoggedIn {
LoginView()
} else {
HomeView()
}
}
}
struct HomeView: View {
@EnvironmentObject var app: App


var body: some View {
VStack {
Text("User name: \(app.user.name)")
Button(action: { app.userService.logout() }) {
Text("Logout")
}
}
}
}

In my previews, I initialize a MockApp which is a subclass of App. The MockApp initializes the designated initializers with the Mocked object. Here the UserService doesn't need to be mocked, but the datasource (i.e. NetworkManagerProtocol) does.

struct HomeView_Previews: PreviewProvider {
static var previews: some View {
Group {
HomeView()
.environmentObject(MockApp() as App) // <- This is needed for EnvironmentObject to treat the MockApp as an App Type
}
}


}

Below provided approach that works for me. Tested with many solutions started with Xcode 11.1.

The problem originated from the way EnvironmentObject is injected in view, general schema

SomeView().environmentObject(SomeEO())

ie, at first - created view, at second created environment object, at third environment object injected into view

Thus if I need to create/setup view model in view constructor the environment object is not present there yet.

Solution: break everything apart and use explicit dependency injection

Here is how it looks in code (generic schema)

// somewhere, say, in SceneDelegate


let someEO = SomeEO()                            // create environment object
let someVM = SomeVM(eo: someEO)                  // create view model
let someView = SomeView(vm: someVM)              // create view
.environmentObject(someEO)

There is no any trade-off here, because ViewModel and EnvironmentObject are, by design, reference-types (actually, ObservableObject), so I pass here and there only references (aka pointers).

class SomeEO: ObservableObject {
}


class BaseVM: ObservableObject {
let eo: SomeEO
init(eo: SomeEO) {
self.eo = eo
}
}


class SomeVM: BaseVM {
}


class ChildVM: BaseVM {
}


struct SomeView: View {
@EnvironmentObject var eo: SomeEO
@ObservedObject var vm: SomeVM


init(vm: SomeVM) {
self.vm = vm
}


var body: some View {
// environment object will be injected automatically if declared inside ChildView
ChildView(vm: ChildVM(eo: self.eo))
}
}


struct ChildView: View {
@EnvironmentObject var eo: SomeEO
@ObservedObject var vm: ChildVM


init(vm: ChildVM) {
self.vm = vm
}


var body: some View {
Text("Just demo stub")
}
}

You shouldn't. It's a common misconception that SwiftUI works best with MVVM. MVVM has no place in SwiftUI. You are asking that if you can shove a rectangle to fit a triangle shape. It wouldn't fit.

Let's start with some facts and work step by step:

  1. ViewModel is a model in MVVM.

  2. MVVM does not take value types (e.g.; no such thing in Java) into consideration.

  3. A value type model (model without state) is considered safer than reference type model (model with state) in the sense of immutability.

Now, MVVM requires you to set up a model in such way that whenever it changes, it updates the view in some pre-determined way. This is known as binding.

Without binding, you won't have nice separation of concerns, e.g.; refactoring out model and associated states and keeping them separate from view.

These are the two things most iOS MVVM developers fail:

  1. iOS has no "binding" mechanism in traditional Java sense. Some would just ignore binding, and think calling an object ViewModel automagically solves everything; some would introduce KVO-based Rx, and complicate everything when MVVM is supposed to make things simpler.

  2. Model with state is just too dangerous because MVVM put too much emphasis on ViewModel, too little on state management and general disciplines in managing control; most of the developers end up thinking a model with state that is used to update view is reusable and testable. This is why Swift introduces value type in the first place; a model without state.

Now to your question: you ask if your ViewModel can have access to EnvironmentObject (EO)?

You shouldn't. Because in SwiftUI a model that conforms to View automatically has reference to EO. E.g.;

struct Model: View {
@EnvironmentObject state: State
// automatic binding in body
var body: some View {...}
}

I hope people can appreciate how compact SDK is designed.

In SwiftUI, MVVM is automatic. There's no need for a separate ViewModel object that manually binds to view which requires an EO reference passed to it.

The above code is MVVM. E.g.; a model with binding to view. But because model is value type, so instead of refactoring out model and state as view model, you refactor out control (in protocol extension, for example).

This is official SDK adapting design pattern to language feature, rather than just enforcing it. Substance over form. Look at your solution, you have to use singleton which is basically global. You should know how dangerous it is to access global anywhere without protection of immutability, which you don't have because you have to use reference type model!

TL;DR

You don't do MVVM in java way in SwiftUI. And the Swift-y way to do it is no need to do it, it's already built-in.

Hope more developer see this since this seemed like a popular question.

The Resolver library does a nice job to get dependency injection for model classes. It provides a property wrapper @Injected which is very similar in spirit to @EnvironmentObject but works everywhere. So in a model, I would inject a ExampleService like this:

class ExampleModel: ObservableObject {


@Injected var service: ExampleService


// ...
}

This can also be used to resolve dependencies for Views:

struct ExampleView: View {


@ObservedObject var exampleModel: ExampleModel = Resolver.resolve()


var body: some View {
// ...
 }
}

An alternative for Views is to use @EnvironmentObject in the SwiftUI view hierarchy, but this gets a little bit cumbersome because you'll have two dependency-injection containers, Resolver/@Injected for everything that's app-wide/service-like and SwiftUI/@EnvironmentObject in the view hierarchy for everything that relates to views/for view models.

You can do it like this:

struct YourView: View {
@EnvironmentObject var settings: UserSettings


@ObservedObject var viewModel = YourViewModel()


var body: some View {
VStack {
Text("Hello")
}
.onAppear {
self.viewModel.setup(self.settings)
}
}
}

For the ViewModel:

class YourViewModel: ObservableObject {
  

var settings: UserSettings?
  

func setup(_ settings: UserSettings) {
self.settings = settings
}
}

This is the simplest way I have found to access and update an @EnvironmentObject property within a viewModel:

// ContentView.swift


import SwiftUI


struct ContentView: View {
@EnvironmentObject var store: Store


var body: some View {
Child(viewModel: ChildViewModel(store))
}
}


// Child.swift


import SwiftUI


struct Child: View {
// only added here to verify that the actual
// @EnvironmentObject store was updated
// not needed to run
@EnvironmentObject var store: Store


@StateObject var viewModel: ViewModel


var body: some View {
Text("Hello, World!").onAppear {
viewModel.update()
print(store.canUpdateStore)
// prints true
}
}
}


extension Child {
final class ViewModel: ObservableObject {
let store: StoreProtocol


init(store: StoreProtocol) {
self.store = store
}


public func update() {
store.updateStore()
}
}
}




// myApp.swift


import SwiftUI


protocol StoreProtocol {
var canUpdateStore: Bool { get }
func updateStore() -> Void
}


class Store: ObservableObject, StoreProtocol {
@Published private(set) var canUpdateStore: Bool = false


func updateStore() {
canUpdateStore = true
}
}


@main
struct myApp: App {
@StateObject private var store = Store()


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

This approach also allows you to mock the store via dependency injection when unit testing ChildViewModel or within the canvas previews.

There's no optionals unlike other hacky approaches that use onAppear, can run code before the onAppear is triggered and the view model is scoped only to the view that it serves.

You can also directly mutate the store within the viewModel, that works just fine too.

Maybe this is more or less about viewpoints:

// ViewModel
struct ProfileViewModel {
@EnvironmentObject state: State
private func businessLogic() {}
}


// The "separate" UI part of the view model
extension ProfileViewModel: View {
var body: some View {
ProfileView(model: self)
}
}


// The "real" view
struct ProfileView: View {
@ObservedObject var model
@Environment(\.accessibilityEnabled) var accessibilityEnabled
var body: some View {
// real view
}
}

Solution for: iOS 14/15+

Here's how you might interact with an Environment Object from a View Model, without having to inject it on instantiation:

  1. Define the Environment Object:
import Combine


final class MyAuthService: ObservableObject {
@Published private(set) var isSignedIn = false
    

func signIn() {
isSignedIn = true
}
}
  1. Create a View to own and pass around the Environment Object:
import SwiftUI


struct MyEntryPointView: View {
@StateObject var auth = MyAuthService()
    

var body: some View {
content
.environmentObject(auth)
}
    

@ViewBuilder private var content: some View {
if auth.isSignedIn {
Text("Yay, you're all signed in now!")
} else {
MyAuthView()
}
}
}
  1. Define the View Model with methods that take the Environment Object as an argument:
extension MyAuthView {
@MainActor final class ViewModel: ObservableObject {
func signIn(with auth: MyAuthService) {
auth.signIn()
}
}
}
  1. Create a View that owns the View Model, receives the Environment Object, and calls the appropriate method:
struct MyAuthView: View {
@EnvironmentObject var auth: MyAuthService
@StateObject var viewModel = ViewModel()
    

var body: some View {
Button {
viewModel.signIn(with: auth)
} label: {
Text("Sign In")
}
}
}
  1. Preview it for completeness:
struct MyEntryPointView_Previews: PreviewProvider {
static var previews: some View {
MyEntryPointView()
}
}