在iOS中使用MVVM模式的用法

38
我是一名iOS开发者,我承认我的项目中存在着Massive View Controllers,所以我一直在寻找更好的项目结构方式,并了解了MVVM(Model-View-ViewModel)架构。我一直在阅读有关iOS中MVVM的相关内容,现在我有几个问题。我将用一个示例来解释我的问题。
我有一个名为LoginViewController的视图控制器。 LoginViewController.swift
import UIKit

class LoginViewController: UIViewController {

    @IBOutlet private var usernameTextField: UITextField!
    @IBOutlet private var passwordTextField: UITextField!

    private let loginViewModel = LoginViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

    }

    @IBAction func loginButtonPressed(sender: UIButton) {
        loginViewModel.login()
    }
}

它没有一个 Model 类。但我创建了一个叫做 LoginViewModel 的视图模型,用于放置验证逻辑和网络调用。

LoginViewModel.swift

import Foundation

class LoginViewModel {

    var username: String?
    var password: String?

    init(username: String? = nil, password: String? = nil) {
        self.username = username
        self.password = password
    }

    func validate() {
        if username == nil || password == nil {
            // Show the user an alert with the error
        }
    }

    func login() {
        // Call the login() method in ApiHandler
        let api = ApiHandler()
        api.login(username!, password: password!, success: { (data) -> Void in
            // Go to the next view controller
        }) { (error) -> Void in
            // Show the user an alert with the error
        }
    }
}
  1. 我的第一个问题是,我的MVVM实现是否正确?我对此有疑问的原因是,例如我将登录按钮的点击事件(loginButtonPressed)放在了控制器中。我没有为登录屏幕创建单独的视图,因为它只有几个文本字段和一个按钮。控制器是否可以拥有与UI元素绑定的事件方法是可接受的?

  2. 我的下一个问题也与登录按钮有关。当用户点击该按钮时,应该将用户名和密码值传递到LoginViewModel进行验证,如果成功,则传递到API调用。我的问题是如何将这些值传递给视图模型。我应该向login()方法添加两个参数,并在从视图控制器调用该方法时传递它们吗?还是应该在视图模型中声明属性,并从视图控制器设置它们的值?哪个在MVVM中是可以接受的?

  3. 接下来看一下视图模型中的validate()方法。如果它们中的任何一个为空,用户应该得到通知。这意味着在检查之后,应将结果返回到视图控制器以采取必要的操作(显示警报)。login()方法也是同样的情况。如果请求失败,请向用户发出警报,如果成功,则转到下一个视图控制器。如何从视图模型向控制器通知这些事件?在这种情况下是否可以使用类似KVO的绑定机制?

  4. 在iOS中使用MVVM时,还有哪些绑定机制?KVO是其中之一。但我读到它并不适用于较大的项目,因为它需要大量重复代码(注册/注销观察者等)。还有哪些其他选项?我知道ReactiveCocoa是用于此的框架,但我想看看是否有其他本地选项。

我在互联网上找到的所有关于MVVM的材料几乎没有提供我想要澄清的这些部分的信息,因此我非常感谢您的回答。


1
只有我一个人不喜欢从视图模型发出网络请求吗? - SoftDesigner
@SoftDesigner 我同意,最佳实践是不要在视图模型中进行网络调用,但在提供的示例中,ApiHandler类已经成功地抽象出了执行登录操作的具体细节。目前只是最好的猜测,确实存在网络调用。应用程序可能处于离线状态,通过本地数据库登录。我们不知道,视图模型也不知道(这就是应该的方式)。如果api变量的数据类型是由ApiHandler实现的协议,那么就更好了。 - Jaja Harris
3个回答

38

嘿伙计!

1a- 你正在朝着正确的方向前进。你把loginButtonPressed放在了视图控制器中,这正是它应该所在的位置。控件的事件处理程序应该始终放在视图控制器中 - 所以这是正确的。

1b- 在你的视图模型中,有注释说“向用户显示带有错误的警报”。你不想在validate函数中显示该错误。相反,创建一个枚举,它具有一个关联值(其中值是您要向用户显示的错误消息)。更改您的验证方法,使其返回该枚举。然后在您的视图控制器中,您可以评估该返回值,从那里您将显示警报对话框。请记住,您只想在视图控制器中使用UIKit相关类 - 绝不能从视图模型中使用。视图模型应仅包含业务逻辑。

enum StatusCodes : Equatable
{
    case PassedValidation
    case FailedValidation(String)

    func getFailedMessage() -> String
    {
        switch self
        {
        case StatusCodes.FailedValidation(let msg):
            return msg

        case StatusCodes.OperationFailed(let msg):
            return msg

        default:
            return ""
        }
    }
}

func ==(lhs : StatusCodes, rhs : StatusCodes) -> Bool
{
    switch (lhs, rhs)
    {           
    case (.PassedValidation, .PassedValidation):
        return true

    case (.FailedValidation, .FailedValidation):
        return true

    default:
        return false
    }
}

func !=(lhs : StatusCodes, rhs : StatusCodes) -> Bool
{
    return !(lhs == rhs)
}

func validate(username : String, password : String) -> StatusCodes
{
     if username.isEmpty || password.isEmpty
     {
          return StatusCodes.FailedValidation("Username and password are required")
     }

     return StatusCodes.PassedValidation
}

2 - 这是一个偏好问题,最终取决于您的应用程序要求。在我的应用中,我通过login()方法传递这些值,例如:login(username, password)。

3 - 创建一个名为LoginEventsDelegate的协议,然后在其中创建一个如下的方法:

func loginViewModel_LoginCallFinished(successful : Bool, errMsg : String)

然而,此方法仅应用于通知视图控制器尝试在远程服务器上登录的实际结果。它与验证部分无关。您的验证例程将按照上面讨论的#1处理。请让您的视图控制器实现LoginEventsDelegate,并在您的视图模型上创建一个公共属性,即

class LoginViewModel {
    var delegate : LoginEventsDelegate?  
}

然后在您的API调用的完成块中,您可以通过委托通知视图控制器,即

func login() {
        // Call the login() method in ApiHandler
        let api = ApiHandler()

        let successBlock =
        {
           [weak self](data) -> Void in

           if let this = self { 
               this.delegate?.loginViewModel_LoginCallFinished(true, "")
           }
        }

        let errorBlock = 
        {
            [weak self] (error) -> Void in

            if let this = self {
                var errMsg = (error != nil) ? error.description : ""
                this.delegate?.loginViewModel_LoginCallFinished(error == nil, errMsg)
            }
        }

        api.login(username!, password: password!, success: successBlock, error: errorBlock)
    }

而你的视图控制器将会是这样:

class loginViewController : LoginEventsDelegate {

    func viewDidLoad() {
        viewModel.delegate = self
    }

    func loginViewModel_LoginCallFinished(successful : Bool, errMsg : String) {
         if successful {
             //segue to another view controller here
         } else {
             MsgBox(errMsg) 
         }
    }
}

有人会说,您只需将闭包传递给登录方法,并跳过整个协议即可。我认为这是一个糟糕的想法,原因有几个。

从用户界面层(UIL)向业务逻辑层(BLL)传递闭包将破坏关注点分离(SOC)。登录()方法驻留在BLL中,因此您基本上会说“嘿BLL代表我执行这个UIL逻辑”。这是一个违反SOC的行为!

BLL只应通过委托通知与UIL通信。这样,BLL基本上是在说:“嘿,UIL,我已经执行完我的逻辑了,这是一些数据参数,你可以用它来操作UI控件。”

因此,UIL不应要求BLL代表他执行UI控制逻辑。只应要求BLL通知他。

4-我见过ReactiveCocoa并听说它很好,但从个人经验而言,我从未使用过。 因此,无法从个人经验中发表意见。我建议您在您的情况下尝试使用简单的委托通知(如#3所述)。如果满足需求,则很好;如果您正在寻找更复杂的东西,则可以研究ReactiveCocoa。

顺便说一句,这实际上也不是MVVM方法,因为没有使用绑定和命令,但这只是IMO的“西红柿”|“西红柿”吹毛求疵。无论您使用哪种MV *方法,SOC原则都是相同的。


1
非常棒的答案!您能再详细解释一下关于问题3的回答吗?特别是您所提到的:“坚持使用委托方法而不是将闭包传递给登录方法并跳过协议”这部分。或者,您可以提供一些相关链接来说明这个问题吗?非常感谢! - Tony Lin
1
但是,如果你的闭包只包含一个语句,比如self.refresh(),那么它与调用相同的self.refresh()方法的委托方法有什么不同呢?最终,无论选择哪种通信方式,控制权都会传递到你的视图控制器的更新方法中。 - dieworld
嗨@JajaHarris,我有点困惑关于UIL和BLL通信的问题。在很多教程中,在viewmodel类中,我看到了像这样的var闭包声明,var updateLoadingStatus: (()->())?。这会破坏SOC吗?最好避免使用它吗? - Giorgio
@Giorgio(续)现在在dataRetrievalStarting中,控制器可以显示加载旋转器。在dataWasRetrieved中,控制器可以隐藏加载旋转器。现在,当新的开发人员加入并获得新的故事以显示/隐藏附加控件时,他知道该逻辑应该存在哪里。他不会将其塞入updateLoadingStatus闭包中,而是只需创建自己的showMyControls()方法,并从dataWasRetrieved中调用它。视图模型不知道其数据事件触发时发生了什么UI任务-这与他无关。希望对你有所帮助! - Jaja Harris
闭包可以被定义为var onUpdateLoadingStatus: (()->())?。BLL 不需要知道 UIL 的实现细节,UIL 将提供实现细节给它,而 BLL 只给出闭包的定义,因此不会破坏 SOC。 - Alex Bin Zhao
显示剩余5条评论

11

iOS中的MVVM指的是创建一个填满数据用来呈现在屏幕上的对象,与您的模型类分开。它通常映射UI中消耗或产生数据的所有项目,如标签、文本框、数据源或动态图像。它通常使用验证器对输入进行轻量级验证(空字段、有效电子邮件或不、正数、开关是否打开等),这些验证器通常是单独的类而不是内联逻辑。

您的视图层知道此VM类,并观察其更改以反映它们,并在用户输入数据时也更新VM类。VM中的所有属性都与UI中的项目相关联。例如,当用户进入用户注册屏幕时,此屏幕获取了一个VM,其中没有任何属性被填充,除了状态属性具有"不完整"状态。视图知道只有一个完整的表单才能被提交,因此现在将提交按钮设置为非活动状态。

然后,用户开始填写详细信息并在电子邮件地址格式上犯了一个错误。 VM中该字段的验证器现在设置了一个错误状态,视图设置了错误状态(例如红色边框)和在UI中的VM验证器中的错误消息。

最后,当VM内的所有必填字段都获得状态“完整”时,VM就是完整的了,视图观察到这一点,现在将提交按钮设置为活动状态,以便用户可以提交它。提交按钮操作被连接到VC上,VC确保VM链接到正确的模型,并保存它们。有时直接使用模型作为VM可能会很有用,例如在具有简单CRUD屏幕的情况下。

我在WPF中使用过这个模式,效果非常好。听起来需要在View中设置所有这些观察器并将许多字段放入Model类以及ViewModel类,但是一个好的MVVM框架将帮助您完成这项工作。您只需要将UI元素链接到正确类型的VM元素、分配正确的验证器,很多这样的代码都会自动完成而不需要添加所有这些样板代码。

此模式的一些优点:

  • 仅公开所需数据
  • 更好的可测试性
  • 连接UI元素到数据的样板代码更少

缺点:

  • 现在您需要维护M和VM两者
  • 您仍然无法完全避免使用VC iOS

4

iOS中的MVVM架构可以轻松地实现,无需使用第三方依赖。对于数据绑定,我们可以使用Closure和didSet的简单组合来避免使用第三方依赖。

public final class Observable<Value> {

    private var closure: ((Value) -> ())?

    public var value: Value {
        didSet { closure?(value) }
    }

    public init(_ value: Value) {
        self.value = value
    }

    public func observe(_ closure: @escaping (Value) -> Void) {
        self.closure = closure
        closure(value)
    }
}

从ViewController进行数据绑定的示例:

final class ExampleViewController: UIViewController {

    private func bind(to viewModel: ViewModel) {
        viewModel.items.observe(on: self) { [weak self] items in
            self?.tableViewController?.items = items
            // self?.tableViewController?.items = viewModel.items.value // This would be Momory leak. You can access viewModel only with self?.viewModel
        }
        // Or in one line:
        viewModel.items.observe(on: self) { [weak self] in self?.tableViewController?.items = $0 }
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        bind(to: viewModel)
        viewModel.viewDidLoad()
    }
}

protocol ViewModelInput {
    func viewDidLoad()
}

protocol ViewModelOutput {
    var items: Observable<[ItemViewModel]> { get }
}

protocol ViewModel: ViewModelInput, ViewModelOutput {}
final class DefaultViewModel: ViewModel {  
  let items: Observable<[ItemViewModel]> = Observable([])

  // Implmentation details...
}

当你的应用程序最低需要iOS版本为13时,可以使用SwiftUI和Combine替换它。

本文介绍了MVVM更详细的描述。 https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3


没错,一个简单的包装器(如Observable)就足以实现MVVM绑定。这是我所有MVVM项目的默认附加组件。 - byJeevan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接