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!
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:
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()
}
}
Pour alerter la variable @Observed
Dans votre View
, remplacez objectWillChange
par
PassthroughSubject<Void, Never>()
Appelez aussi
objectWillChange.send()
dans votre fonction changeCharacter()
.
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".