简体   繁体   English

SwiftUI 和 MVVM - 模型和视图模型之间的通信

[英]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使用的 MVVM 模型,但有些东西我还不太明白。

SwiftUI uses @ObservableObject / @ObservedObject to detect changes in a view model that trigger a recalculation of the body property to update the view. SwiftUI使用@ObservableObject / @ObservedObject来检测视图模型中的更改,这些更改触发了body属性的重新计算以更新视图。

In the MVVM model, that's the communication between the view and the view model.在 MVVM 模型中,这是视图和视图模型之间的通信。 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.我想手动使用新的Combine框架在模型内部创建视图模型可以订阅的发布者。

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.有一个名为Game的模型,它Game.Character对象。 A character has a strength property that can change.角色具有可以改变的strength属性。

So what if a view model changes that strength property of a character?那么如果一个视图模型改变了一个角色的strength属性呢? 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?还是我的示例没有正确遵循 MVVM? Should my view model not have the actual model game as property?我的视图模型不应该将实际模型game作为属性吗? 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.我不知道这是否是预期的方式,或者它是否是正确的 MVVM,但它似乎有效,而且实际上非常方便。

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 .我认为view models可能不应该包含或保存模型中的任何实际数据 Doing this would effectively create a copy of what's already saved in the model layer .这样做将有效地创建已保存在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.因此,我将Character类改为struct

  • I spent hours trying to figure out how to communicate changes between the model layer and the view model .我花了几个小时试图弄清楚如何在model layerview 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.我尝试设置自定义发布者,自定义订阅者跟踪任何更改并相应地更新视图模型,我考虑让model订阅view model以及建立双向通信等。没有任何结果。 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.这可能就是 MVVM 的意义所在。 The visualisation shown in an MVVM tutorial on raywenderlich.com shows this as well: raywenderlich.com上的 MVVM 教程中显示的可视化显示了这一点:

在此处输入图片说明 (Source: https://www.raywenderlich.com/4161005-mvvm-with-combine-tutorial-for-ios ) (来源: 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 .因此,我没有让model告诉view model任何更改,而是通过将模型设置为ObservableObject来让view检测model的更改。 Every time it changes, the view is being recalculated which calls methods and properties on the view model .每次更改时,都会重新计算视图,该视图会调用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.然而, view model只是从模型中获取当前数据(因为它只访问而不保存它们)并将其提供给视图。 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.感谢 Quantm 发布上面的示例代码。 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.视图模型和视图之间的唯一联系是 SwiftUI 提供的绑定。 eg: use @Published (in view model) and @ObservedObject (in view) pair.例如:使用@Published(在视图模型中)和@ObservedObject(在视图中)对。 We could also use @Published and @EnvironmentObject pair if we want to build bindings across multiple views with the view model.如果我们想使用视图模型跨多个视图构建绑定,我们也可以使用 @Published 和 @EnvironmentObject 对。

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.通过这些更改,MVVM 设置非常简单,视图模型和视图之间的双向通信全部由 SwiftUI 框架提供,无需添加任何额外的调用来触发任何更新,这一切都是自动发生的。 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.简短的回答是使用@State,每当状态属性更改时,都会重建视图。

Long answer is to update MVVM paradigm per SwiftUI.长答案是根据 SwiftUI 更新 MVVM 范例。

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. SwiftUI 提供的绑定来自于符合 View 协议的值类型。 This set it apart from Android where there's no value type.这使它与没有值类型的 Android 区分开来。

MVVM is not about having an object called view model. MVVM 不是关于拥有一个称为视图模型的对象。 It's about having model-view binding.这是关于具有模型视图绑定。

So instead of model -> view model -> view hierarchy, it's now struct Model: View with @State inside.因此,现在不是模型 -> 视图模型 -> 视图层次结构,而是struct Model: View with @State inside。

All in one instead of nested 3 level hierarchy.多合一,而不是嵌套的 3 级层次结构。 It may go against everything you thought you knew about MVVM.它可能与您认为对 MVVM 了解的一切背道而驰。 In fact I'd say it's an enhanced MVC architecture.事实上,我会说它是一种增强的 MVC 架构。

But binding is there.但是绑定是存在的。 Whatever benefit you can get from MVVM binding, SwiftUI has it out-of-box.无论您可以从 MVVM 绑定中获得什么好处,SwiftUI 都具有开箱即用的功能。 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.正如您所说,即使使用Combine 在视图模型周围进行手动绑定也会很乏味,因为SDK 认为目前还没有必要提供这种绑定。 (I doubt it ever will, since it's a major improvement over traditional MVVM in its current form) (我怀疑它永远不会,因为它是对当前形式的传统 MVVM 的重大改进)

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".甚至不是“MVVM”。

To alert the @Observed variable in your View , change objectWillChange to要提醒View@Observed变量,请将objectWillChange更改为

PassthroughSubject<Void, Never>()

Also, call另外,打电话

objectWillChange.send()

in your changeCharacter() function.在您的changeCharacter()函数中。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

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