SwiftUI 和 MVVM - 模型和 View 模型之间的通信

标签 swift swiftui combine

我一直在试验 SwiftUI 中使用的 MVVM 模型有些事情我还不太明白。

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

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

当模型改变时, View 模型应该如何知道?我考虑过手动使用新的 Combine在 View 模型可以订阅的模型中创建发布者的框架。

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

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

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

// 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 模型 可能不应该包含或保存模型中的任何实际数据。这样做会有效地创建已保存在 模型层 中的内容的副本。将数据存储在多个位置会导致各种同步和更新问题,您在更改任何内容时都必须考虑这些问题。我尝试的一切最终都变成了一大堆难以阅读的丑陋代码。

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

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

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

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

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

  • 考虑到这一点,让示例正常工作并不难。


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

两个 View 在进行更改时同步。

enter image description here

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]
    }
}

关于SwiftUI 和 MVVM - 模型和 View 模型之间的通信,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/57826430/

相关文章:

ios - 使用 SwiftUI 绑定(bind) ViewModel 和 TextFields

SwiftUI @FetchRequest 对关系的核心数据更改不刷新

ios - UIKit 中的 Swift 组合。某些用户的 URLSession dataTaskPublisher NSURLErrorDomain -1

swift - 我们可以从 LinkPresentation 框架中的 LPLinkView 中提取图像吗?

ios - SwiftUI navigationBarBackButtonHidden 没有按预期工作

ios - 禁用 SwiftUI 应用程序的 iTunesStore 访问 - 摆脱 "Error retrieving iTunesStore accounts"警告

ios - UIViewControllerRepresentable 运行时错误 : Nib is loaded but view outlet wasn't set. 为什么?

ios - 如何将镜像添加到 2d Box 角

ios - UIButton: 当isHighlighted = true时,我只能通过滑动手指来调用一个函数

ios - 将滤镜应用于 UIImage 时,结果是颠倒的