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

Posted

技术标签:

【中文标题】SwiftUI 和 MVVM - 模型和视图模型之间的通信【英文标题】:SwiftUI and MVVM - Communication between model and view model 【发布时间】:2020-01-09 14:13:21 【问题描述】:

我一直在试验SwiftUI 中使用的 MVVM 模型,但有些东西我还不太明白。

SwiftUI 使用@ObservableObject/@ObservedObject 来检测视图模型中触发重新计算body 属性以更新视图的更改。

在 MVVM 模型中,这是视图和视图模型之间的通信。我不太明白的是模型和视图模型是如何通信的。

当模型发生变化时,视图模型应该如何知道呢?我考虑过手动使用新的Combine 框架在模型内部创建视图模型可以订阅的发布者。

但是,我创建了一个简单的示例,这使得这种方法非常乏味,我认为。有一个名为Game 的模型包含Game.Character 对象的数组。一个角色有一个可以改变的strength 属性。

如果视图模型改变了角色的strength 属性怎么办?为了检测这种变化,模型必须订阅游戏中的每一个角色(可能还有很多其他的东西)。是不是有点过分了?还是有很多发布者和订阅者是正常的?

或者我的示例没有正确遵循 MVVM?我的视图模型是否应该没有实际模型game 作为属性?如果是这样,有什么更好的方法?

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

我希望我的意思很清楚。很难解释,因为它令人困惑

谢谢!

【问题讨论】:

还没有经历过这个,但是这些教程通常都很好。如果没有,它应该可以帮助您使您的问题更加简洁:raywenderlich.com/4161005-mvvm-with-combine-tutorial-for-ios 很棒的文章。在他们的应用示例中,模型层被设计为“被动”的。视图模型可以请求刷新(加载新的天气数据),但模型不保存任何持久数据(如我示例中的字符)。因此,要么他们的示例未涵盖此用例,要么 MVVM 实际上意味着具有这些类型的模型。但是,我不确定如何调整我的示例以使其符合 MVVM。 【参考方案1】:

要提醒View 中的@Observed 变量,请将objectWillChange 更改为

PassthroughSubject<Void, Never>()

另外,打电话

objectWillChange.send()

在您的 changeCharacter() 函数中。

【讨论】:

谢谢。但这只会导致此特定视图重绘。那么所有其他显示该特定字符的视图呢?他们也必须更新。 如果您的所有视图都引用了一个ViewModel,那么当您调用.send() 时,所有视图都会更新。 但他们不一定会这样做,这就是问题所在。触发器必须来自模型或其他东西【参考方案2】:

最后几个小时我一直在研究代码,我想我已经想出了一个很好的方法。我不知道这是否是预期的方式,或者它是否是正确的 MVVM,但它似乎工作并且实际上非常方便。

我将在下面发布一个完整的工作示例供任何人试用。它应该开箱即用。

这里有一些想法(可能完全是垃圾,我对那些东西一无所知。如果我错了,请纠正我:))

我认为view models 可能不应该包含或保存模型中的任何实际数据。这样做将有效地创建已保存在model layer 中的内容的副本。将数据存储在多个位置会导致您在更改任何内容时必须考虑的各种同步和更新问题。我尝试的所有东西最终都变成了一大段难以理解的丑陋代码。

对模型内部的数据结构使用类并不能很好地工作,因为它会使检测更改变得更加麻烦(更改属性不会更改对象)。因此,我将Character 类改为struct

我花了几个小时试图弄清楚如何在 model layerview model 之间传达更改。我尝试设置自定义发布者、跟踪任何更改并相应更新视图模型的自定义订阅者,我考虑让model 订阅view model 以及建立双向通信等。没有任何结果。感觉很不自然。 但事情是这样的:模型不必与视图模型通信。事实上,我认为根本不应该。这可能就是 MVVM 的意义所在。 raywenderlich.com 上的 MVVM 教程中显示的可视化也显示了这一点:

(来源:https://www.raywenderlich.com/4161005-mvvm-with-combine-tutorial-for-ios)

这是一种单向连接。视图模型从模型中读取数据并可能对数据进行更改,仅此而已。

因此,我没有让model 告诉view model 任何更改,而是让view 通过将模型设为ObservableObject 来检测对model 的更改。每次更改时,都会重新计算视图,该视图调用view model 上的方法和属性。然而,view model 只是从模型中获取当前数据(因为它只访问而不保存它们)并将其提供给视图。 视图模型根本不需要知道模型是否已更新。没关系

考虑到这一点,让示例运行起来并不难。


这是演示所有内容的示例应用程序。它只是显示所有字符的列表,同时显示显示单个字符的第二个视图。

进行更改时,两个视图会同步。

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]
    


【讨论】:

我不同意你的做法。视图模型的目的是将视图与模型分离,封装业务逻辑和数据格式。通过通知视图有关模型的更改,您基本上会破坏这种设计模式。我也相信这不是单向连接,因为 MVVM 主要是关于绑定,并且对视图模型的更改应该导致视图被通知它们。在我看来这篇文章有一个更准确的类似于 MVVM 的图表:medium.com/ios-os-x-development/… 我一直认为,SwiftUI 需要保留旧模型快照的“副本”。只有有了这些信息,它才能与当前模型进行比较,并进行高效的 UI 更新。这就是为什么struct 用于模型而不是class 的原因吗?这是写在官方文档的某个地方吗? 另外,我看到你遵守协议Identifiable,即使它在大部分教程中都没有提到 - hackingwithswift.com/books/ios-swiftui/… 我知道,是因为这个原因吗? ***.com/questions/63487142/… 我非常喜欢这个解决方案,因为它使视图可重用。我可以将每个视图及其 viewModel 视为仅依赖于与之交互的模型的组件。但是,我希望有一种方法可以将 charList 和 charDetail viewModel 存储在包装视图中,这样就不会在每次模型更改时都重新创建它们。我试过了,但是这两个视图不再保持同步。想法?【参考方案3】:

感谢 Quantm 在上面发布示例代码。我按照你的例子,但简化了一点。我所做的更改:

无需使用组合 视图模型和视图之间的唯一联系是 SwiftUI 提供的绑定。例如:使用@Published(在视图模型中)和@ObservedObject(在视图中)对。如果我们想使用视图模型跨多个视图构建绑定,我们还可以使用 @Published 和 @EnvironmentObject 对。

通过这些更改,MVVM 设置非常简单,视图模型和视图之间的双向通信全部由 SwiftUI 框架提供,无需添加任何额外调用来触发任何更新,一切都会发生自动地。希望这也有助于回答您最初的问题。

这是与您上面的示例代码大致相同的工作代码:

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

【讨论】:

【参考方案4】:

简短的回答是使用@State,只要状态属性发生变化,就会重建视图。

长答案是根据 SwiftUI 更新 MVVM 范例。

通常,要成为“视图模型”,某些绑定机制需要与之关联。在您的情况下,它没有什么特别之处,它只是另一个对象。

SwiftUI 提供的绑定来自符合 View 协议的值类型。这使它与没有值类型的 android 区分开来。

MVVM 不是关于拥有一个称为视图模型的对象。这是关于模型-视图绑定。

所以现在不是模型 -> 视图模型 -> 视图层次结构,而是 struct Model: View 内部带有 @State。

多合一而不是嵌套的 3 级层次结构。它可能与您认为自己了解的有关 MVVM 的一切背道而驰。事实上,我会说它是一种增强的 MVC 架构。

但是绑定是存在的。无论您可以从 MVVM 绑定中获得什么好处,SwiftUI 都提供开箱即用的功能。它只是以独特的形式呈现。

正如您所说,即使使用 Combine,手动绑定视图模型也会很乏味,因为 SDK 认为目前还没有必要提供这样的绑定。 (我怀疑它永远不会,因为它是对当前形式的传统 MVVM 的重大改进)

半伪代码说明以上几点:

struct GameModel 
     // build your model

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

请注意这是多么简单。没有什么比简单更重要的了。甚至不是“MVVM”。

【讨论】:

没有什么比简单更重要的了,同意。但这仍然给我留下了一些问题。由于 m 是一个结构,它是按值复制的。当您对其进行变异时,它会在本地发生变异。您如何更新“真实”模型?例如,如果另一个视图以其他形式显示模型的相同元素,它如何被警告更新自身,因为它有自己的 - 另一个副本 - 自己的模型? 对于共享状态,您需要引用类型绑定。 本地状态是独立的。他们背后没有“真正的”模型可以更新。您所描述的是共享状态,这将需要 EnvironmentObject 或 ObservableObject 绑定中的引用类型模型。我会说将整个模型转换为可以共享的一些属性的引用类型通常是不值得的。将它们重构为共享状态,并将其余部分保留为值类型和本地状态。例如,MVVM 的一个常见问题是它们倾向于将网络与模型混合在一起,这不可避免地必须是引用类型。为什么不重构网络? 不同意。 @State 应该始终是私有的。这是关于视图的内部状态,由 SwiftUI 管理。 不确定您指的是什么。 @State 被编译器保护为“私有”,即;无法从视图外部访问。我所说的“共享状态”指的是视图模型实际上是具有共享状态的模型,而不是共享@State

以上是关于SwiftUI 和 MVVM - 模型和视图模型之间的通信的主要内容,如果未能解决你的问题,请参考以下文章

SwiftUI MVVM:父视图更新时重新初始化子视图模型

在 SwiftUI MVVM 中的子视图和父视图之间共享值

SwiftUI 在哪里加载 MVVM 中的数据

是否所有 @Published 变量都需要在 SwiftUI 的视图模型中具有初始值?

如何在 SwiftUI 中通过 ViewModel 传播模型更改?

SwiftUI之深入解析如何处理特定的数据和如何在视图中适配数据模型对象