MVVM và VIPER: Con đường trở thành Senior - Phần cuối

MVVM và VIPER: Con đường trở thành Senior - Phần cuối

- 21 mins

Đây là bài thứ 4 trong loạt bài “Kiến trúc ứng dụng: Nên bắt đầu như thế nào”:

Trong bài viết trước chúng ta đã tìm hiểu về MVCMVP để ứng dụng cho một iOS App đơn giản. Bài này chúng ta sẽ tiếp tục ứng dụng 2 mô hình MVVMVIPER. Nhắc lại là ứng dụng của chúng ta cụ thể khi chạy sẽ như sau:

Demo Number Counter app

Source code đầy đủ cho tất cả mô hình MVC, MVP, MVVM và VIPER các bạn có thể download tại đây.

MVVM

MVVM có thể nói là mô hình kiến trúc được rất nhiều các cư dân trong cộng đồng ưa chuộng. Điểm tinh hoa của kiến trúc này là ở ViewModel, mặc dù rất giống với Presenter trong MVP tuy nhiên có 2 điều làm nên tên tuổi của kiến trúc này đó là:

Binding Data trong MVVM là điều không bắt buộc, một số implement chỉ đơn giản làm ViewModel như một lớp trung gian giữa Model-View, lớp này giữ nhiệm vụ format data hoặc mapping trạng thái của View. Tuy nhiên cách này theo mình khiến cho ViewModel trở thành Presenter và đưa kiến trúc này về MVP.

MVVM

Để giữ cho bài viết này đơn giản cho các bạn, mình sẽ viết một class DataBinding để thực hiện nhiệm vụ của ViewModel. Vì để áp dụng cho tất cả các kiểu dữ liệu nên ta sẽ dùng generic type, ngoài ra ta dùng thêm didSet trên value để tự động gọi method fire().

import Foundation

class DataBinding<T> {
  typealias Handler = (T) -> Void
  private var handlers:[Handler] = []
  
  var value: T {
    didSet {
      self.fire()
    }
  }
  
  init(value: T) {
    self.value = value
  }
  
  func bind(hdl:@escaping Handler) {
    self.handlers.append(hdl)
  }
  
  func bindAndFire(hdl:@escaping Handler) {
    self.bind(hdl: hdl)
    self.fire()
  }
  
  private func fire() {
    for hdl in self.handlers {
      hdl(value)
    }
  }
}

Với Presenter ta sẽ đổi lại thành ViewModel có sử dụng DataBinding với 2 properties quan trọng numberString(String)decreaseEnabled(Bool):

import Foundation

class NumberViewModel {
  private var numberModel:NumberModel?
  var numberString:DataBinding<String>?
  var decreaseEnabled:DataBinding<Bool>?
  
  init(number:Int) {
    self.numberModel = NumberModel(value: number)
    
    self.numberString = DataBinding(value: formatNumber(number: number))
    self.decreaseEnabled = DataBinding(value: number > 0)
  }
  
  func increaseNumber() {
    guard let numberModel = self.numberModel else { return }
    numberModel.setValue(value: numberModel.getValue() + 1)
    
    self.updateViewWithFireEvents()
  }
  
  func decreaseNumber() {
    guard let numberModel = self.numberModel else { return }
    
    let currentValue = numberModel.getValue()
    if currentValue <= 0 { return }
    
    numberModel.setValue(value: currentValue - 1)
    
    self.updateViewWithFireEvents()
  }
  
  private func formatNumber(number:Int) -> String {
    return String(format: "%02d", arguments: [number])
  }
  
  private func updateViewWithFireEvents() {
    guard   let numberModel = self.numberModel else { return }
    
    let currentValue = numberModel.getValue()
    let text = formatNumber(number: currentValue)
    
    // Fire event
    self.numberString?.value = text
    self.decreaseEnabled?.value = currentValue > 0
  }
}

Với class NumberVC ta update lại như sau:

import UIKit

class NumberVC: UIViewController {

  @IBOutlet weak var numberLabel: UILabel!
  @IBOutlet weak var decreaseButton: UIButton!
  
  private let numberViewModel = NumberViewModel(number: 3)
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    // Listen data stream from View Model
    numberViewModel.numberString?.bindAndFire(hdl: { [weak self] (text) in
      guard let `self` = self else { return }
      self.numberLabel.text = text
    })
    
    numberViewModel.decreaseEnabled?.bindAndFire(hdl: { [weak self] (enabled) in
      guard let `self` = self else { return }
      self.decreaseButton.isEnabled = enabled
    })
  }

  override func didReceiveMemoryWarning() {
    super.didReceiveMemoryWarning()
  }
  
  @IBAction func decreaseAction(_ sender: UIButton) {
    self.numberViewModel.decreaseNumber()
  }
  
  @IBAction func increaseAction(_ sender: UIButton) {
    self.numberViewModel.increaseNumber()
  }
}

Về phần test ViewModel của MVVM cũng tương đối đơn giản:

import XCTest
@testable import NumberCounterMVVM

class NumberViewMock {
  var textValue = ""
  var descreaseEnabled = true
  
  init(viewModel:NumberViewModel) {
    viewModel.numberString?.bindAndFire(hdl: { [unowned self] (text) in
      self.textValue = text
    })
    
    viewModel.decreaseEnabled?.bindAndFire(hdl: { [unowned self] (enabled) in
      self.descreaseEnabled = enabled
    })
  }
}

class NumberCounterViewModelTests: XCTestCase {
    
  var numberViewModel:NumberViewModel!
  var numberViewMock:NumberViewMock!
  
  override func setUp() {
    super.setUp()
    
    self.numberViewModel = NumberViewModel(number: 10)
    self.numberViewMock = NumberViewMock(viewModel: self.numberViewModel)
  }
  
  override func tearDown() {
    super.tearDown()
  }
  
  func testInitValueMustBeTen() {
    XCTAssert(numberViewMock.textValue == "10", "Init number is not \"10\".")
  }
  
  func testIncreaseNumber() {
    self.numberViewModel.increaseNumber()
    
    XCTAssert(numberViewMock.textValue == "11", "Number is not \"11\" after increased.")
  }
  
  func testDecreaseNumber() {
    self.numberViewModel.decreaseNumber()
    
    XCTAssert(numberViewMock.textValue == "09", "Number is not \"09\" after decreased.")
  }
  
  func testDecreaseDisableWhenNumberIsZero() {
    for _ in 1...10 {
      self.numberViewModel.decreaseNumber()
    }
    
    XCTAssert(numberViewMock.descreaseEnabled == false, "Decrease control still enabled when number is 0.")
  }
}

Kết qủa test:

MVVM

VIPER

Tới nay chúng ta đã tìm hiểu các mô hình MVC, MVPMVVM. Các mô hình trên có đặc điểm chung là có một “lớp trung gian” để phân tác Model-View. Tuy nhiên gánh nặng trên các lớp trung gian này cũng khá lớn, vì trong ứng dụng mobile các logic phục vụ UX là rất lớn: animation, transition, navigation,… Chúng thường được ta gọi là “logic cho view” và “business logic”.

Việc quản lý được các module có tính liên kết giữa nhiều màn hình (push tới đâu, truyền qua những gì) hay các logic cho View cũng là một thách thức lớn. Lúc này chúng ta sẽ phải cần tìm hiểu mô hình VIPER.

VIPER cũng là một ứng dụng của Clean Architecture rất nổi tiếng. Đây là mô hình mình rất yêu thích, và cũng là mô hình khó nhất trong loạt bài này.

MVVM

Đặc điểm của VIPER

Đầu tiên là VIPER sẽ là mô hình hướng use case hoặc module, điều này có nghĩa là mỗi module VIPER sẽ chỉ là một tính năng, một nhiệm vụ cụ thể chứ không làm hết tất cả những tính năng trên màn hình. Ví dụ thường thấy đó là ở màn hình “Product List”, user có thể “Like Product”, “Add Product To Cart”, “List Product” và “Go to Product Details” như vậy là ta có 4 module VIPER chứ không phải 1.

Các module VIPER sẽ kết nối với nhau thông qua Router/Wireframe, đây là nơi giữ tất cả các tham chiếu của các thành phần trong VIPER và chịu trách nhiệm “dẫn đường” (VD: Push tới màn hình sản phẩm chi tiết là dùng module nào).

View trong VIPER tương tự với View trong MVP, sử dụng protocol để trừu tượng hóa lớp này.

Presenter chỉ làm chuyên nhiệm vụ xử lý logic View: điều khiển các UI, Animation,…

Interactor chính là nơi giải quyết các business logic.

Entity chính là Model như bình thường.

Như vậy mô hình này chia rất nhỏ nhiệm vụ cho từng class cụ thể, tương ứng với các nguyên lý thiết kế hướng đối tượng. Mặt khác, mối quan hệ giữa View-Presenter-Interactor sẽ dựa trên các lớp trừu tượng vì vậy chúng lệ thuộc rất thấp và có thể dễ dàng thay thế và mở rộng nếu cần.

Flow trong VIPER

Do VIPER có khá nhiều các thành phần nên việc hiểu được flow của nó là điều rất cần thiết trước khi ta bắt tay vào code:

  1. Ứng dụng khởi động và gọi tới Router để khởi tạo toàn bộ các thành phần.
  2. User nhấn vào nút tăng số trên giao diện (View)
  3. View gởi yêu cầu tăng số đến Presenter (có thể là protocol Presenter).
  4. Presenter sẽ thực hiện các logic view nếu có, sau đó truyền yêu cầu tăng số đến InteractorInput (chính là Interactor đang adopt).
  5. Interactor thực hiện business logic tăng số dựa vào giá trị của Entity (Model). Interactor có thể giao tiếp với API nếu cần.
  6. Interactor gởi kết quả về InteractorOutput (chính là Presenter đang adopt).
  7. Presenter thực hiện các logic animation nếu cần sau đó format data trả về cho View (thông qua protocol).

Trong flow trên ta thấy có 1 luồng input và 1 luồng output rất rõ ràng. Trên thực tế flow này có thể ngắn hơn như user yêu cần chuyển tới module khác thì Presenter sẽ gọi Router để chuyển. Hoặc nếu 1 tính năng chỉ cần animation thôi thì Presenter có thể thực hiện và không cần phải gọi qua Interactor nữa.

Áp dụng mô hình VIPER vào ứng dụng

Chúng ta sẽ bắt đầu với protocol NumberView:

import Foundation

protocol NumberView:class {
  func setTextNumber(text:String)
  func updateDecreaseControl(enabled:Bool)
}

Tiếp theo là protocol PresenterProtocol:

import Foundation

protocol NumberPresenterProtocol:class {
  func getNumber()
  func increase()
  func decrease()
}

Protocol input và output:

import Foundation

protocol NumberInteractorInput:class {
  func increase()
  func decrease()
  func getCurrentValue()
}

protocol NumberInteractorOutput:class {
  func setNumber(number:Int)
}

Presenter sẽ adopt 2 protocol NumberPresenterProtocolNumberInteractorOutput

import Foundation

class NumberPresenter: NumberPresenterProtocol, NumberInteractorOutput {
    
  weak var numberView:NumberView?
  weak var numberInteractor:NumberInteractorInput?
  var numberWireframe:NumberWireframe? // not use in this project
  
  // Adopt NumberPresenterProtocol
  func increase() {
    self.numberInteractor?.increase()
  }
  
  func decrease() {
    self.numberInteractor?.decrease()
  }
  
  func getNumber() {
    self.numberInteractor?.getCurrentValue()
  }
  
  private func format(number:Int) -> String {
    return String(format: "%02d", arguments: [number])
  }
  
  // Adopt NumberInteractorOutput
  func setNumber(number: Int) {
    let text = format(number: number)
    
    self.numberView?.setTextNumber(text: text)
    self.numberView?.updateDecreaseControl(enabled: number > 0)
  }
}

Interactor sẽ cần adopt protocol NumberInteractorInput

import Foundation

class NumberInteractor: NumberInteractorInput {
    
  var numberEntity:NumberEntity?
  weak var numberPresenter:NumberInteractorOutput?
  
  
  init(entity:NumberEntity) {
    self.numberEntity = entity
  }
  
  // Adopt NumberInteractorInput
  func getCurrentValue() {
    let currentValue = self.numberEntity?.getValue() ?? 0
    
    self.numberPresenter?.setNumber(number: currentValue)
  }
  
  func increase() {
    let currentValue = self.numberEntity?.getValue() ?? 0
    let newValue = currentValue + 1
    self.numberEntity?.setValue(value: newValue)
    
    self.numberPresenter?.setNumber(number: newValue)
  }
  
  func decrease() {
    let currentValue = self.numberEntity?.getValue() ?? 0
    let newValue = currentValue - 1
    
    self.numberEntity?.setValue(value: newValue)
    
    self.numberPresenter?.setNumber(number: newValue)
  }
}

NumberVC sẽ cần phải adopt protocol NumberView

class NumberVC: UIViewController, NumberView {

  static let identifier = "numberVC"
  
  @IBOutlet weak var numberLabel: UILabel!
  @IBOutlet weak var decreaseButton: UIButton!
  
  var numberPresenter:NumberPresenterProtocol?
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    self.numberPresenter?.getNumber()
  }

  override func didReceiveMemoryWarning() {
    super.didReceiveMemoryWarning()
  }
  
  @IBAction func decreaseAction(_ sender: UIButton) {
    self.numberPresenter?.decrease()
  }
  
  @IBAction func increaseAction(_ sender: UIButton) {
    self.numberPresenter?.increase()
  }
  
  // Adopt NumberView
  func setTextNumber(text: String) {
    self.numberLabel.text = text
  }
  
  func updateDecreaseControl(enabled: Bool) {
    self.decreaseButton.isEnabled = enabled
  }
}

NumberWireframe sẽ chịu trách nhiệm khởi tạo tất cả mối quan hệ lằng nhằng trên:

import UIKit

class NumberWireframe {
    
  var interactor:NumberInteractor!
  var presenter:NumberPresenter!
  
  func getModule(initNumber numb:Int) -> UIViewController {
      
    let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main)
    let view = storyboard.instantiateViewController(withIdentifier: NumberVC.identifier) as! NumberVC
    let entity = NumberEntity(value: 3)
    let presenter = NumberPresenter()
    let interactor = NumberInteractor(entity: entity)
    
    view.numberPresenter = presenter
    presenter.numberView = view
    interactor.numberPresenter = presenter
    presenter.numberInteractor = interactor
    presenter.numberWireframe = self
    
    self.interactor = interactor
    self.presenter = presenter
    
    return view
  }
}

Khi đã dùng VIPER, chúng ta sẽ không dùng chính năng init ViewController của Storyboard mà sẽ code tay để Wireframe khởi tạo ViewController. Vì thế trong AppDelegate sẽ cần thay đổi lại như sau:

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?


  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.
    
    let numberModule = NumberWireframe()
    let numberVC = numberModule.getModule(initNumber: 3)
    
    self.window = UIWindow(frame: UIScreen.main.bounds)
    self.window?.rootViewController = numberVC
    self.window?.makeKeyAndVisible()
    
    return true
  }
}

Cấu trúc thư mục VIPER sẽ có dạng như sau:

MVVM

Source code đầy đủ các bạn có thể download tại đây.

Một số điều lưu ý trong mô hình VIPER


Lời kết

Như vậy là chúng ta đã đi qua một lượt các mô hình phổ biến nhất trong kiến trúc ứng dụng. Mặc dù là một ví dụ đơn giản và quá overskill, tuy nhiên mình hy vọng sẽ giúp được cho các bạn phần nào. Trên thực tế, việc lựa chọn mô hình nào cho dự án còn tùy thuộc vào khả năng của team, điều này rất quan trọng bởi vì nó sẽ định hình cho team hiện tại và cả những người mới sẽ gia nhập trong tương lai.

Nếu bạn vẫn chưa thể hiểu được ngay, không sao hết vì mình bản thân mình ngày xưa cũng mất khá nhiều thời gian để có thể hiểu và ứng dụng được chúng. Cái quan trọng là ta vẫn cứ làm thật nhiều app, khi đụng trúng vấn đề mà mô hình giải quyết thì tự động những kiến thức trước đó sẽ phát huy tác dụng. Khoảnh khắc đó mình hay gọi là “giác ngộ”.

Việc hiểu biết về kiến trúc ứng dụng sẽ là một cột mốc quan trọng trên con đường tiến lên Senior, Expert về sau. Chúc các bạn sẽ ngày càng thăng tiến hơn với ngành và ngày càng viết được nhiều app chất lượng hơn.

Viet Tran

Viet Tran

A Man who is developing apps with Red Bull

comments powered by Disqus
rss facebook twitter github youtube mail spotify lastfm instagram linkedin google google-plus pinterest medium vimeo stackoverflow reddit quora