Skip to main content

MVVM và VIPER: Con đường trở thành Senior


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à:
  1. ViewModel không hề biết gì về View, một ViewModel có thể được sử dụng cho nhiều View (one-to-many).
  2. ViewModel sử dụng Observer design pattern để liên lạc với View (thường được gọi là binding data, có thể là 1 chiều hoặc 2 chiều tùy nhu cầu ứng dụng). Chính đặc điểm này MVVM thường được phối hợp với các thư viện hỗ trợ Reactive Programming hay Event/Data Stream, đây là triết lý lập trình hiện đại và hiệu quả phát triển rất mạnh trong những năm gần đây.
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:
Folder-structure-viper
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

  • Trong VIPER ta phải phân chia được trong 1 màn hình sẽ cần bao nhiêu module VIPER cũng như các logic cho chúng. Mặt khác việc xác định được view trong module sẽ chỉ cần những gì cũng là một vấn đề mà các bạn cần luyện tập.
  • Mối quan hệ giữa các thành phần trong VIPER khá chằng chịt và dễ bị retain cycle giữa các class. Vì thế nên lưu ý đặt từ khóa weak trong khai báo các biến trỏ tới các thành phần.
  • Khi đã là kiểu biến weak, wireframe có nhiệm phải giữ lại tham chiếu của presenterinteractor nếu không chúng sẽ bị nil khi chạy ứng dụng.

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.

Comments

Popular posts from this blog

Alamofire vs URLSession

Alamofire vs URLSession: a comparison for networking in Swift Alamofire and URLSession both help you to make network requests in Swift. The URLSession API is part of the foundation framework, whereas Alamofire needs to be added as an external dependency. Many  developers  doubt  whether it’s needed to include an extra dependency on something basic like networking in Swift. In the end, it’s perfectly doable to implement a networking layer with the great URLSession API’s which are available nowadays. This blog post is here to compare both frameworks and to find out when to add Alamofire as an external dependency. Build better iOS apps faster Looking for a great mobile CI/CD solution that has tons of iOS-specific tools, smooth code signing, and even real device testing? Learn more about Bitrise’s iOS specific solutions! This shows the real power of Alamofire as the framework makes a lot of things easier. What is Alamofire? Where URLSession can be found within the s

Fileprivate vs private: Giải thích sự khác biệt

Fileprivate vs private in Swift: The differences explained Fileprivate and private are part of the access control modifiers in Swift. These keywords, together with internal, public, and open, make it possible to restrict access to parts of your code from code in other source files and modules. The private access level is the lowest and most restrictive level whereas open access is the highest and least restrictive. The documentation of Swift will explain all access levels in detail to you, but in this blog post, I’m going to explain the differences between two close friends: fileprivate and private. Build better iOS apps faster Looking for a great mobile CI/CD solution that has tons of iOS-specific tools, smooth code signing, and even real device testing? Learn more about Bitrise’s iOS specific solutions! Open access is the highest (least restrictive) access level and private access is the lowest (most restrictive) access level. This will improve readability and mak