web-dev-qa-db-fra.com

SwiftUI et MVVM - Communication entre le modèle et le modèle de vue

J'ai expérimenté le modèle MVVM utilisé dans SwiftUI et il y a certaines choses que je ne comprends pas encore.

SwiftUI utilise @ObservableObject/@ObservedObject pour détecter les changements dans un modèle de vue qui déclenchent un recalcul de la propriété body pour mettre à jour la vue.

Dans le modèle MVVM, c'est la communication entre la vue et le modèle de vue. Ce que je ne comprends pas vraiment, c'est comment le modèle et le modèle de vue communiquent.

Lorsque le modèle change, comment le modèle de vue est-il censé le savoir? J'ai pensé à utiliser manuellement le nouveau framework Combine pour créer des éditeurs à l'intérieur du modèle auxquels le modèle de vue peut s'abonner.

Cependant, j'ai créé un exemple simple qui rend cette approche assez fastidieuse, je pense. Il existe un modèle appelé Game qui contient un tableau de Game.Character objets. Un personnage a une propriété strength qui peut changer.

Que se passe-t-il si un modèle de vue modifie la propriété strength d'un caractère? Pour détecter ce changement, le modèle devrait s'abonner à chaque personnage du jeu (parmi bien d'autres choses). N'est-ce pas un peu trop? Ou est-ce normal d'avoir de nombreux éditeurs et abonnés?

Ou mon exemple ne suit-il pas correctement MVVM? Mon modèle de vue ne doit-il pas avoir le modèle réel game comme propriété? Si oui, quelle serait la meilleure façon?

// 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?

J'espère que ce que je veux dire est clair. C'est difficile à expliquer car c'est déroutant

Merci!

7
Quantm

Merci Quantm d'avoir publié un exemple de code ci-dessus. J'ai suivi votre exemple, mais j'ai simplifié un peu. Les modifications que j'ai apportées:

  • Pas besoin d'utiliser Combine
  • La seule connexion entre le modèle de vue et la vue est la liaison fournie par SwiftUI. par exemple: utilisez la paire @Published (dans le modèle de vue) et @ObservedObject (dans la vue). Nous pourrions également utiliser la paire @Published et @EnvironmentObject si nous voulons créer des liaisons sur plusieurs vues avec le modèle de vue.

Avec ces changements, la configuration MVVM est assez simple et la communication bidirectionnelle entre le modèle de vue et la vue est fournie par le framework SwiftUI, il n'est pas nécessaire d'ajouter des appels supplémentaires pour déclencher une mise à jour, tout se fait automatiquement. J'espère que cela vous aidera également à répondre à votre question d'origine.

Voici le code de travail qui fait à peu près la même chose que votre exemple de code ci-dessus:

// 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()
      }
   }

0
lq_msu

Pour alerter la variable @Observed Dans votre View, remplacez objectWillChange par

PassthroughSubject<Void, Never>()

Appelez aussi

objectWillChange.send()

dans votre fonction changeCharacter().

0
Ken Mueller

La réponse courte est d'utiliser @State, chaque fois que la propriété d'état change, la vue est reconstruite.

La réponse longue est de mettre à jour le paradigme MVVM par SwiftUI.

Généralement, pour que quelque chose soit un "modèle de vue", un mécanisme de liaison doit lui être associé. Dans votre cas, il n'y a rien de spécial, c'est juste un autre objet.

La liaison fournie par SwiftUI provient d'un type de valeur conforme au protocole View. Cela le distingue de Android où il n'y a pas de type de valeur.

MVVM ne consiste pas à avoir un objet appelé modèle de vue. Il s'agit d'avoir une liaison de vue de modèle.

Donc, au lieu de modèle -> voir le modèle -> voir la hiérarchie, c'est maintenant struct Model: View avec @State à l'intérieur.

Tout en un au lieu d'une hiérarchie à 3 niveaux imbriquée. Cela peut aller à l'encontre de tout ce que vous pensiez savoir sur MVVM. En fait, je dirais que c'est une architecture MVC améliorée.

Mais la liaison est là. Quels que soient les avantages que vous pouvez obtenir de la liaison MVVM, SwiftUI le propose immédiatement. Il présente juste sous une forme unique.

Comme vous l'avez indiqué, il serait fastidieux de faire une liaison manuelle autour du modèle de vue même avec Combine, car le SDK estime qu'il n'est pas nécessaire de fournir une telle liaison pour le moment. (Je doute que ce sera le cas, car il s'agit d'une amélioration majeure par rapport à la MVVM traditionnelle dans sa forme actuelle)

Code semi-pseudo pour illustrer les points ci-dessus:

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

Notez à quel point c'est simple. Rien ne vaut la simplicité. Pas même "MVVM".

0
Jim lai