简体   繁体   中英

SwiftUI and MVVM - Communication between model and view model

I've been experimenting with the MVVM model that's used in SwiftUI and there are some things I don't quite get yet.

SwiftUI uses @ObservableObject / @ObservedObject to detect changes in a view model that trigger a recalculation of the body property to update the view.

In the MVVM model, that's the communication between the view and the view model. What I don't quite understand is how the model and the view model communicate.

When the model changes, how is the view model supposed to know that? I thought about manually using the new Combine framework to create publishers inside the model that the view model can subscribe to.

However, I created a simple example that makes this approach pretty tedious, I think. There's a model called Game that holds an array of Game.Character objects. A character has a strength property that can change.

So what if a view model changes that strength property of a character? To detect that change, the model would have to subscribe to every single character that the game has (among possibly many other things). Isn't that a little too much? Or is it normal to have many publishers and subscribers?

Or is my example not properly following MVVM? Should my view model not have the actual model game as property? If so, what would be a better way?

// My Model
class Game {

  class Character {
    let name: String
    var strength: Int
    init(name: String, strength: Int) {
      self.name = name
      self.strength = strength
    }
  }

  var characters: [Character]

  init(characters: [Character]) {
    self.characters = characters
  }
}

// ...

// My view model
class ViewModel: ObservableObject {
  let objectWillChange = PassthroughSubject<ViewModel, Never>()
  let game: Game

  init(game: Game) {
    self.game = game
  }

  public func changeCharacter() {
     self.game.characters[0].strength += 20
  }
}

// Now I create a demo instance of the model Game.
let bob = Game.Character(name: "Bob", strength: 10)
let alice = Game.Character(name: "Alice", strength: 42)
let game = Game(characters: [bob, alice])

// ..

// Then for one of my views, I initialize its view model like this:
MyView(viewModel: ViewModel(game: game))

// When I now make changes to a character, e.g. by calling the ViewModel's method "changeCharacter()", how do I trigger the view (and every other active view that displays the character) to redraw?

I hope it's clear what I mean. It's difficult to explain because it is confusing

Thanks!

I've spent the few last hours playing around with the code and I think I've come up with a pretty good way of doing this. I don't know if that's the intended way or if it's proper MVVM but it seems to work and it's actually quite convenient.

I will post an entire working example below for anyone to try out. It should work out of the box.

Here are some thoughts (which might be complete garbage, I don't know anything about that stuff yet. Please correct me if I'm wrong :))

  • I think that view models probably shouldn't contain or save any actual data from the model . Doing this would effectively create a copy of what's already saved in the model layer . Having data stored in multiple places causes all kinds of synchronization and update problems you have to consider when changing anything. Everything I tried ended up being a huge, unreadable chunk of ugly code.

  • Using classes for the data structures inside the model doesn't really work well because it makes detecting changes more cumbersome (changing a property doesn't change the object). Thus, I made the Character class a struct instead.

  • I spent hours trying to figure out how to communicate changes between the model layer and the view model . I tried setting up custom publishers, custom subscribers that track any changes and update the view model accordingly, I considered having the model subscribe to the view model as well to establish two-way communication, etc. Nothing worked out. It felt unnatural. But here's the thing : The model doesn't have to communicate with the view model. In fact, I think it shouldn't at all. That's probably what MVVM is about. The visualisation shown in an MVVM tutorial on raywenderlich.com shows this as well:

在此处输入图片说明 (Source: https://www.raywenderlich.com/4161005-mvvm-with-combine-tutorial-for-ios )

  • That's a one-way connection. The view model reads from the model and maybe makes changes to the data but that's it.

    So instead of having the model tell the view model about any changes, I simply let the view detect changes to the model by making the model an ObservableObject . Every time it changes, the view is being recalculated which calls methods and properties on the view model . The view model , however, simply grabs the current data from the model (as it only accesses and never saves them) and provides it to the view. The view model simply doesn't have to know whether or not the model has been updated. It doesn't matter .

  • With that in mind, it wasn't hard to make the example work.


Here's the example app to demonstrate everything. It simply shows a list of all characters while simultaneously displaying a second view that shows a single character.

Both views are synched when making changes.

在此处输入图片说明

import SwiftUI
import Combine

/// The model layer.
/// It's also an Observable object so that swiftUI can easily detect changes to it that trigger any active views to redraw.
class MyGame: ObservableObject {
    
    /// A data object. It should be a struct so that changes can be detected very easily.
    struct Character: Equatable, Identifiable {
        var id: String { return name }
        
        let name: String
        var strength: Int
        
        static func ==(lhs: Character, rhs: Character) -> Bool {
            lhs.name == rhs.name && lhs.strength == rhs.strength
        }
        
        /// Placeholder character used when some data is not available for some reason.
        public static var placeholder: Character {
            return Character(name: "Placeholder", strength: 301)
        }
    }
    
    /// Array containing all the game's characters.
    /// Private setter to prevent uncontrolled changes from outside.
    @Published public private(set) var characters: [Character]
    
    init(characters: [Character]) {
        self.characters = characters
    }
    
    public func update(_ character: Character) {
        characters = characters.map { $0.name == character.name ? character : $0 }
    }
    
}

/// A View that lists all characters in the game.
struct CharacterList: View {
    
    /// The view model for CharacterList.
    class ViewModel: ObservableObject {
        
        /// The Publisher that SwiftUI uses to track changes to the view model.
        /// In this example app, you don't need that but in general, you probably have stuff in the view model that can change.
        let objectWillChange = PassthroughSubject<Void, Never>()
        
        /// Reference to the game (the model).
        private var game: MyGame
        
        /// The characters that the CharacterList view should display.
        /// Important is that the view model should not save any actual data. The model is the "source of truth" and the view model
        /// simply accesses the data and prepares it for the view if necessary.
        public var characters: [MyGame.Character] {
            return game.characters
        }
        
        init(game: MyGame) {
            self.game = game
        }
    }
    
    @ObservedObject var viewModel: ViewModel
    
    // Tracks what character has been selected by the user. Not important,
    // just a mechanism to demonstrate updating the model via tapping on a button
    @Binding var selectedCharacter: MyGame.Character?

    var body: some View {
        List {
            ForEach(viewModel.characters) { character in
                Button(action: {
                    self.selectedCharacter = character
                }) {
                    HStack {
                        ZStack(alignment: .center) {
                            Circle()
                                .frame(width: 60, height: 40)
                                .foregroundColor(Color(UIColor.secondarySystemBackground))
                            Text("\(character.strength)")
                        }
                        
                        VStack(alignment: .leading) {
                            Text("Character").font(.caption)
                            Text(character.name).bold()
                        }
                        
                        Spacer()
                    }
                }
                .foregroundColor(Color.primary)
            }
        }
    }
    
}

/// Detail view.
struct CharacterDetail: View {

    /// The view model for CharacterDetail.
    /// This is intentionally only slightly different to the view model of CharacterList to justify a separate view model class.
    class ViewModel: ObservableObject {
        
        /// The Publisher that SwiftUI uses to track changes to the view model.
        /// In this example app, you don't need that but in general, you probably have stuff in the view model that can change.
        let objectWillChange = PassthroughSubject<Void, Never>()
        
        /// Reference to the game (the model).
        private var game: MyGame
        
        /// The id of a character (the name, in this case)
        private var characterId: String
        
        /// The characters that the CharacterList view should display.
        /// This does not have a `didSet { objectWillChange.send() }` observer.
        public var character: MyGame.Character {
            game.characters.first(where: { $0.name == characterId }) ?? MyGame.Character.placeholder
        }
        
        init(game: MyGame, characterId: String) {
            self.game = game
            self.characterId = characterId
        }
        
        /// Increases the character's strength by one and updates the game accordingly.
        /// - **Important**: If the view model saved its own copy of the model's data, this would be the point
        /// where everything goes out of sync. Thus, we're using the methods provided by the model to let it modify its own data.
        public func increaseCharacterStrength() {
            
            // Grab current character and change it
            var character = self.character
            character.strength += 1
            
            // Tell the model to update the character
            game.update(character)
        }
    }
    
    @ObservedObject var viewModel: ViewModel
    
    var body: some View {
        ZStack(alignment: .center) {
            
            RoundedRectangle(cornerRadius: 25, style: .continuous)
                .padding()
                .foregroundColor(Color(UIColor.secondarySystemBackground))
            
            VStack {
                Text(viewModel.character.name)
                    .font(.headline)
                
                Button(action: {
                    self.viewModel.increaseCharacterStrength()
                }) {
                    ZStack(alignment: .center) {
                        Circle()
                            .frame(width: 80, height: 80)
                            .foregroundColor(Color(UIColor.tertiarySystemBackground))
                        Text("\(viewModel.character.strength)").font(.largeTitle).bold()
                    }.padding()
                }
                
                Text("Tap on circle\nto increase number")
                .font(.caption)
                .lineLimit(2)
                .multilineTextAlignment(.center)
            }
        }
    }
    
}


struct WrapperView: View {
    
    /// Treat the model layer as an observable object and inject it into the view.
    /// In this case, I used @EnvironmentObject but you can also use @ObservedObject. Doesn't really matter.
    /// I just wanted to separate this model layer from everything else, so why not have it be an environment object?
    @EnvironmentObject var game: MyGame
    
    /// The character that the detail view should display. Is nil if no character is selected.
    @State var showDetailCharacter: MyGame.Character? = nil

    var body: some View {
        NavigationView {
            VStack(alignment: .leading) {
                
                Text("Tap on a character to increase its number")
                    .padding(.horizontal, nil)
                    .font(.caption)
                    .lineLimit(2)
                
                CharacterList(viewModel: CharacterList.ViewModel(game: game), selectedCharacter: $showDetailCharacter)
                
                if showDetailCharacter != nil {
                    CharacterDetail(viewModel: CharacterDetail.ViewModel(game: game, characterId: showDetailCharacter!.name))
                        .frame(height: 300)
                }
                
            }
            .navigationBarTitle("Testing MVVM")
        }
    }
}

struct WrapperView_Previews: PreviewProvider {
    static var previews: some View {
        WrapperView()
        .environmentObject(MyGame(characters: previewCharacters()))
        .previewDevice(PreviewDevice(rawValue: "iPhone XS"))
    }
    
    static func previewCharacters() -> [MyGame.Character] {
        let character1 = MyGame.Character(name: "Bob", strength: 1)
        let character2 = MyGame.Character(name: "Alice", strength: 42)
        let character3 = MyGame.Character(name: "Leonie", strength: 58)
        let character4 = MyGame.Character(name: "Jeff", strength: 95)
        return [character1, character2, character3, character4]
    }
}

Thanks Quantm for posting an example code above. I followed your example, but simplified a bit. The changes I made:

  • No need to use Combine
  • The only connection between view model and view is the binding SwiftUI provides. eg: use @Published (in view model) and @ObservedObject (in view) pair. We could also use @Published and @EnvironmentObject pair if we want to build bindings across multiple views with the view model.

With these changes, the MVVM setup is pretty straightforward and the two-way communication between the view model and view is all provided by the SwiftUI framework, there is no need to add any additional calls to trigger any update, it all happens automatically. Hope this also helps answer your original question.

Here is the working code that does about the same as your sample code above:

// Character.swift
import Foundation

class Character: Decodable, Identifiable{
   let id: Int
   let name: String
   var strength: Int

   init(id: Int, name: String, strength: Int) {
      self.id = id
      self.name = name
      self.strength = strength
   }
}

// GameModel.swift 
import Foundation

struct GameModel {
   var characters: [Character]

   init() {
      // Now let's add some characters to the game model
      // Note we could change the GameModel to add/create characters dymanically,
      // but we want to focus on the communication between view and viewmodel by updating the strength.
      let bob = Character(id: 1000, name: "Bob", strength: 10)
      let alice = Character(id: 1001, name: "Alice", strength: 42)
      let leonie = Character(id: 1002, name: "Leonie", strength: 58)
      let jeff = Character(id: 1003, name: "Jeff", strength: 95)
      self.characters = [bob, alice, leonie, jeff]
   }

   func increaseCharacterStrength(id: Int) {
      let character = characters.first(where: { $0.id == id })!
      character.strength += 10
   }

   func selectedCharacter(id: Int) -> Character {
      return characters.first(where: { $0.id == id })!
   }
}

// GameViewModel
import Foundation

class GameViewModel: ObservableObject {
   @Published var gameModel: GameModel
   @Published var selectedCharacterId: Int

   init() {
      self.gameModel = GameModel()
      self.selectedCharacterId = 1000
   }

   func increaseCharacterStrength() {
      self.gameModel.increaseCharacterStrength(id: self.selectedCharacterId)
   }

   func selectedCharacter() -> Character {
      return self.gameModel.selectedCharacter(id: self.selectedCharacterId)
   }
}

// GameView.swift
import SwiftUI

struct GameView: View {
   @ObservedObject var gameViewModel: GameViewModel

   var body: some View {
      NavigationView {
         VStack {

            Text("Tap on a character to increase its number")
               .padding(.horizontal, nil)
               .font(.caption)
               .lineLimit(2)

            CharacterList(gameViewModel: self.gameViewModel)

            CharacterDetail(gameViewModel: self.gameViewModel)
               .frame(height: 300)

         }
         .navigationBarTitle("Testing MVVM")
      }
   }
}

struct GameView_Previews: PreviewProvider {
    static var previews: some View {
      GameView(gameViewModel: GameViewModel())
      .previewDevice(PreviewDevice(rawValue: "iPhone XS"))
    }
}

//CharacterDetail.swift
import SwiftUI

struct CharacterDetail: View {
   @ObservedObject var gameViewModel: GameViewModel

   var body: some View {
      ZStack(alignment: .center) {

         RoundedRectangle(cornerRadius: 25, style: .continuous)
             .padding()
             .foregroundColor(Color(UIColor.secondarySystemBackground))

         VStack {
            Text(self.gameViewModel.selectedCharacter().name)
               .font(.headline)

            Button(action: {
               self.gameViewModel.increaseCharacterStrength()
               self.gameViewModel.objectWillChange.send()
            }) {
               ZStack(alignment: .center) {
                  Circle()
                      .frame(width: 80, height: 80)
                      .foregroundColor(Color(UIColor.tertiarySystemBackground))
                  Text("\(self.gameViewModel.selectedCharacter().strength)").font(.largeTitle).bold()
              }.padding()
            }

            Text("Tap on circle\nto increase number")
            .font(.caption)
            .lineLimit(2)
            .multilineTextAlignment(.center)
         }
      }
   }
}

struct CharacterDetail_Previews: PreviewProvider {
   static var previews: some View {
      CharacterDetail(gameViewModel: GameViewModel())
   }
}

// CharacterList.swift
import SwiftUI

struct CharacterList: View {
   @ObservedObject var gameViewModel: GameViewModel

   var body: some View {
      List {
         ForEach(gameViewModel.gameModel.characters) { character in
             Button(action: {
               self.gameViewModel.selectedCharacterId = character.id
             }) {
                 HStack {
                     ZStack(alignment: .center) {
                         Circle()
                             .frame(width: 60, height: 40)
                             .foregroundColor(Color(UIColor.secondarySystemBackground))
                         Text("\(character.strength)")
                     }

                     VStack(alignment: .leading) {
                         Text("Character").font(.caption)
                         Text(character.name).bold()
                     }

                     Spacer()
                 }
             }
             .foregroundColor(Color.primary)
         }
      }
   }
}

struct CharacterList_Previews: PreviewProvider {
   static var previews: some View {
      CharacterList(gameViewModel: GameViewModel())
   }
}

// SceneDelegate.swift (only scene func is provided)

   func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
      // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
      // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
      // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

      // Use a UIHostingController as window root view controller.
      if let windowScene = scene as? UIWindowScene {
         let window = UIWindow(windowScene: windowScene)
         let gameViewModel = GameViewModel()
         window.rootViewController = UIHostingController(rootView: GameView(gameViewModel: gameViewModel))
         self.window = window
         window.makeKeyAndVisible()
      }
   }

Short answer is to use @State, whenever state property changes, view is rebuilt.

Long answer is to update MVVM paradigm per SwiftUI.

Typically for something to be a "view model", some binding mechanism needs to be associated with it. In your case there's nothing special about it, it is just another object.

The binding provided by SwiftUI comes from value type conforming to View protocol. This set it apart from Android where there's no value type.

MVVM is not about having an object called view model. It's about having model-view binding.

So instead of model -> view model -> view hierarchy, it's now struct Model: View with @State inside.

All in one instead of nested 3 level hierarchy. It may go against everything you thought you knew about MVVM. In fact I'd say it's an enhanced MVC architecture.

But binding is there. Whatever benefit you can get from MVVM binding, SwiftUI has it out-of-box. It just presents in an unique form.

As you stated, it would be tedious to do manual binding around view model even with Combine, because SDK deems it not necessary to provide such binding as of yet. (I doubt it ever will, since it's a major improvement over traditional MVVM in its current form)

Semi-pseudo code to illustrate above points:

struct GameModel {
     // build your model
}
struct Game: View {
     @State var m = GameModel()
     var body: some View {
         // access m
     }
     // actions
     func changeCharacter() { // mutate m }
}

Note how simple this is. Nothing beats simplicity. Not even "MVVM".

To alert the @Observed variable in your View , change objectWillChange to

PassthroughSubject<Void, Never>()

Also, call

objectWillChange.send()

in your changeCharacter() function.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM