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

时间:2019-09-06 17:36:30

标签: swift swiftui combine

我一直在试验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?

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

谢谢!

4 个答案:

答案 0 :(得分:1)

我花了最后几个小时来处理代码,我想我已经找到了一种非常好的方法。我不知道这是否是预期的方式,或者它是否是正确的MVVM,但它似乎可以正常工作并且很方便。

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

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

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

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

  • 我花了数小时试图弄清楚如何在model layerview model之间传达变化。我尝试设置自定义发布者,跟踪任何更改并相应更新视图模型的自定义订阅者,我考虑让model订阅view model来建立双向通信,等等。出来。感觉不自然。 但这就是问题:模型不必与视图模型进行通信。实际上,我认为完全不应该。可能就是MVVM。 raywenderlich.com的MVVM教程中显示的可视化效果也显示了这一点:

enter image description here (来源:https://www.raywenderlich.com/4161005-mvvm-with-combine-tutorial-for-ios

  • 这是单向连接。视图模型从模型中读取数据,也许可以对数据进行更改,仅此而已。

    因此,我并没有让model告诉view model任何更改,而是通过使模型成为{{1 }}。每次更改时,都会重新计算视图,该视图调用view上的方法和属性。但是,model仅从模型中获取当前数据(因为它仅访问而从未保存它们)并将其提供给视图。 视图模型完全不必知道模型是否已更新。没关系

  • 考虑到这一点,使示例变得很容易。


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

进行更改时,两个视图都是同步的。

enter image description here

ObservableObject

答案 1 :(得分:0)

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

PassthroughSubject<Void, Never>()

也打电话

objectWillChange.send()

在您的changeCharacter()函数中。

答案 2 :(得分:0)

感谢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()
      }
   }

答案 3 :(得分:0)

简短的答案是使用@State,每当状态属性更改时,都会重建视图。

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

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

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

MVVM与拥有称为视图模型的对象无关。这是关于模型视图绑定的。

因此,它现在是结构模型:视图,其中带有@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”。