[英]SwiftUI and MVVM - Communication between model and view model
我一直在试验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?
我希望我的意思很清楚。 很难解释,因为它令人困惑
谢谢!
我花了最后几个小时玩代码,我想我已经想出了一个很好的方法来做到这一点。 我不知道这是否是预期的方式,或者它是否是正确的 MVVM,但它似乎有效,而且实际上非常方便。
我将在下面发布一个完整的工作示例供任何人尝试。 它应该是开箱即用的。
这里有一些想法(可能完全是垃圾,我对这些东西一无所知。如果我错了,请纠正我:))
我认为view models
可能不应该包含或保存模型中的任何实际数据。 这样做将有效地创建已保存在model layer
的内容的副本。 将数据存储在多个位置会导致您在更改任何内容时必须考虑的各种同步和更新问题。 我尝试过的所有东西最终都变成了一大块不可读的丑陋代码。
将类用于模型内的数据结构并不能很好地工作,因为它会使检测更改更加麻烦(更改属性不会更改对象)。 因此,我将Character
类改为struct
。
我花了几个小时试图弄清楚如何在model layer
和view model
之间传达更改。 我尝试设置自定义发布者,自定义订阅者跟踪任何更改并相应地更新视图模型,我考虑让model
订阅view model
以及建立双向通信等。没有任何结果。 感觉很不自然。 但事情是这样的:模型不必与视图模型进行通信。 其实我觉得完全不应该。 这可能就是 MVVM 的意义所在。 raywenderlich.com上的 MVVM 教程中显示的可视化也显示了这一点:
(来源: https : //www.raywenderlich.com/4161005-mvvm-with-combine-tutorial-for-ios )
那是一种单向连接。 视图模型从模型中读取并可能对数据进行更改,但仅此而已。
因此,我没有让model
告诉view model
任何更改,而是通过将模型设置为ObservableObject
来让view
检测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]
}
}
感谢 Quantm 发布上面的示例代码。 我跟着你的例子,但简化了一点。 我所做的改变:
通过这些更改,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()
}
}
简短的回答是使用@State,每当状态属性更改时,都会重建视图。
长答案是根据 SwiftUI 更新 MVVM 范例。
通常,要成为“视图模型”,某些绑定机制需要与其相关联。 在您的情况下,它没有什么特别之处,它只是另一个对象。
SwiftUI 提供的绑定来自于符合 View 协议的值类型。 这使它与没有值类型的 Android 区分开来。
MVVM 不是关于拥有一个称为视图模型的对象。 这是关于具有模型视图绑定。
因此,现在不是模型 -> 视图模型 -> 视图层次结构,而是struct Model: View with @State inside。
多合一,而不是嵌套的 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”。
要提醒View
的@Observed
变量,请将objectWillChange
更改为
PassthroughSubject<Void, Never>()
另外,打电话
objectWillChange.send()
在您的changeCharacter()
函数中。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.