MVC, MVP, MVVM, VIPER nên xài cái nào - Phần 3

MVC, MVP, MVVM, VIPER nên xài cái nào - Phần 3

- 16 mins

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

Xin chào các công dân mới toanh vừa gia nhập thế giới kiến trúc ứng dụng. Tính bài trước, các bạn đã là bước chân vào thế giới này rồi. Hôm nay với vai trò là một công dân “già”, mình sẽ giới thiệu các bạn đến một số “địa điểm” nổi tiếng nhất của thế giới này:

Trong các mô hình trên mình cố tình ghi các thành phần theo đúng với mối liên hệ của chúng để các bạn dễ hình dung. Nhưng các bạn có thấy điểm chung nào giữa chúng không ?

Điểm chung đó chính là ViewModel luôn ở đầu và cuối và có xu hướng rời xa nhau.

Mối liên kết giữa View - Model trong kiến trúc ứng dụng

Ta đang nói về kiến trúc ứng dụng, nghĩa là thiết kế kiến trúc cho ứng dụng. Ứng dụng thì sẽ luôn có giao diện người dùng (graphic user interface - GUI) và phần code cũng như dữ liệu (data - model) để phục vụ cho giao diện trên (hay còn gọi là View). Thông thường View không chỉ hiển thị dữ liệu mà còn nhận vào tác tương tác của người dùng để thao tác trên dữ liệu, còn dữ liệu thì cũng không đơn giản là muốn show là show được, đôi khi phải thông qua các formatter, converter cũng như các service cung cấp bên ngoài như các Restful API.

Sự tồn tại của View - Model là bắt buộc. Chúng ta đã biết, một class không thể có quá nhiều logic trong nó và khi có 2 class trở lên có quan hệ với nhau thì ta phải tìm cách để chúng giảm lệ thuộc vào nhau. Đây là vấn đề đã làm đau đầu các kiến trúc sư trong nửa thế kỷ qua. Theo tốc độ phát triển của nền công nghiệp lập trình ứng dụng, có rất nhiều các mô hình ra đời để giải quyết bài toán trên. Và khi cơn đau đầu của họ đã qua thì tới cơn đau đầu của chúng ta, nên chọn mô hình nào để áp ụng bây giờ ?!

Khi mới học làm ứng dụng (trong bài này mình viết app cho iOS), chúng ta thường chỉ muốn tìm hiểu các thành phần cần thiết để làm được app và tập trung vào lập trình tính năng. Những app ban đầu ta làm vì quá đơn giản nên phần kiến trúc app sẽ thường bị loại bỏ để ta có thể focus đúng vào cái ta cần. Đến khi ta đã biết lập trình app rồi thì bước tiếp theo đó là làm thế nào để có thể làm được những app đạt chất lượng tốt về cả hiệu năng lẫn khả năng dễ bảo trì, dễ test và phát triển. Mình tin đó là lý do tại sao các bạn đã đọc đến bài này.

Trong phần còn lại của bài viết, mình sẽ làm một app cực kỳ đơn giản và áp dụng lần lượt tất cả các mô hình MVC, MVP, MVVMVIPER vào để các bạn dễ hiểu và dễ so sách từ đó sẽ dễ dàng lựa chọn mô hình phù hợp cho ứng dụng và team. Qua đó mình hy vọng sẽ giúp các bạn “đỡ đau” phần nào.

Ứng dụng ta cần viết sẽ như sau:

Demo Number Counter app

MVC

Source code phiên bản cho người mới học chỉ có nhiu đây thôi (phần giao diện mình làm với Storyboard):

import UIKit

class NumberVC: UIViewController {

  @IBOutlet weak var numberLabel: UILabel!
  @IBOutlet weak var decreaseButton: UIButton!
    
  var number:Int = 0
    
  override func viewDidLoad() {
    super.viewDidLoad()
    self.initDataAndShow()
  }

  override func didReceiveMemoryWarning() {
    super.didReceiveMemoryWarning()
  }
  
  func initDataAndShow() {
    number = 3
    self.numberLabel.text = "\(number)"
  }

  @IBAction func decreaseAction(_ sender: UIButton) {
      
    number -= 1
    self.numberLabel.text = "\(number)"
    
    if number == 0 {
        self.decreaseButton.isEnabled = false
    }
  }
  
  @IBAction func increaseAction(_ sender: UIButton) {
    number += 1
    self.decreaseButton.isEnabled = true
    self.numberLabel.text = "\(number)"
  }
}

Trong ví dụ trên nếu ta không dùng Storyboard để xây dựng giao diện mà đặt cả code layout vào thì ta sẽ có file duy nhất bao gồm cả View, Controller, Model. Do một phần cách viết app mà framework của Apple quy định nên ta cũng tránh được phần nào.

Tuy nhiên trong đoạn code trên không hề có Model mà chỉ dùng một biến số nguyên number. Mặt khác ta cũng thấy rằng có khá nhiều dòng code lặp lại self.numberLabel.text = "\(number)". Để làm đúng và tốt hơn mình sẽ đổi lại như sau:

// File NumberModel.swift
import Foundation

class NumberModel {
  private var value:Int = 0
  
  init(value:Int) {
      self.value = value
  }
  
  func getValue() -> Int {
      return self.value
  }
  
  func setValue(value:Int) {
      self.value = value
  }
}

// File NumberVC.swift
import UIKit
class NumberVC: UIViewController {

  @IBOutlet weak var numberLabel: UILabel!
  @IBOutlet weak var decreaseButton: UIButton!
  
  var number:NumberModel!
  
  override func viewDidLoad() {
      super.viewDidLoad()
      
      self.initDataWith(val: 3)
      self.updateUI()
  }

  override func didReceiveMemoryWarning() {
      super.didReceiveMemoryWarning()
  }
  
  func initDataWith(val:Int) {
      self.number = NumberModel(value: val)
  }
  
  func updateUI() {
      self.numberLabel.text = "\(number.getValue())"
      self.decreaseButton.isEnabled = number.getValue() > 0
  }

  @IBAction func decreaseAction(_ sender: UIButton) {
      self.number.setValue(value: self.number.getValue() - 1)
      self.updateUI()
  }
  
  @IBAction func increaseAction(_ sender: UIButton) {
      self.number.setValue(value: self.number.getValue() + 1)
      self.updateUI()
  }
}

Bây giờ chúng ta chính thức sẽ có View: Storyboard/Xib, Controller: class NumberVCModel: class NumberModel. Logic của Controller mình cũng viết lại để gom nhóm lại các method cho hợp lý hơn. Nếu bây giờ ta thay đổi format kết quả phải là số có 2 chữ số trở lên thì cần thay đổi method updateUI mà không cần phải edit ở cả 3 chỗ như trước:

func updateUI() {
  self.numberLabel.text = String(format: "%02d", arguments: [number.getValue()])
  self.decreaseButton.isEnabled = number.getValue() > 0
}

Nhìn chung mô hình MVC sẽ giúp cho sự liên lạc giữ ModelView phải thông qua Controller. Thông thường Controller sẽ đóng vai trò điều khiển như tên của nó, dựa vào Model cụ thể nó sẽ quyết định View sẽ cần show cái gì và thậm chí là thay đổi View nếu cần. Mô hình này cũng thường thấy trong cuộc sống như: Tivi, DVD Player và đĩa DVD.

Mô hình MVC dù đã hoàn thành nhiệm vụ phân tách được View - Model tuy nhiên cũng còn những điểm yếu như:

MVP

MVP

Trong mô hình MVP sẽ có một số đặc điểm sau:

Từ đó ta sẽ thay đổi mô hình ứng dụng trên từ MVC sang MVP như sau:

Đầu tiên là protocol của phần trừu tượng của View. Trên thực tế View này chỉ cần nhận về 2 thứ: giá trị của number hiện tại dưới dạng String (đã format)nút giảm số có được enabled hay không.

import Foundation

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

Lập class Presenter:

Presenter sẽ giữ một kết nối yếu (weak) về View chể chống retain cycle, đây là protocol chứ không phải là instance cụ thể. Sau khi thao tác tăng giảm number trên Model, presenter sẽ gởi kết quả về View.

import Foundation

class NumberPresenter {
  private var numberModel:NumberModel?
  private weak var numberView:NumberView?
  
  init(model:NumberModel) {
    self.numberModel = model
  }
  
  func attach(view:NumberView) {
    self.numberView = view
    
    updateView()
  }
  
  func increaseNumber() {
    guard let numberModel = self.numberModel else { return }
    numberModel.setValue(value: numberModel.getValue() + 1)
    
    self.updateView()
  }
  
  func decreaseNumber() {
    guard let numberModel = self.numberModel else { return }
    
    let currentValue = numberModel.getValue()
    if currentValue <= 0 { return }
    
    numberModel.setValue(value: currentValue - 1)
    
    self.updateView()
  }
  
  private func updateView() {
    guard   let numberModel = self.numberModel,
            let numberView = self.numberView
    else { return }
    
    let text = String(format: "%02d", arguments: [numberModel.getValue()])
    numberView.setTextNumber(text: text)
    
    numberView.updateDecreaseControl(enabled: numberModel.getValue() > 0)
  }
}

Thay đổi class NumberVC như sau:

NumberVC đóng vai trò là View vì thế sẽ phải adopt protocol NumberView. Trong lifecycle method viewDidLoad ta cho attach View vào Presenter để View bắt đầu nhận kết quả khởi tạo từ Presenter (trên thực tế có thể là những Service lấy data từ các API).

import UIKit

class NumberVC: UIViewController, NumberView {

  @IBOutlet weak var numberLabel: UILabel!
  @IBOutlet weak var decreaseButton: UIButton!
    
  private let numberPresenter = NumberPresenter(model: NumberModel(value: 3))
    
  override func viewDidLoad() {
    super.viewDidLoad()
    self.numberPresenter.attach(view: self)
  }

  override func didReceiveMemoryWarning() {
    super.didReceiveMemoryWarning()
  }
    
  @IBAction func decreaseAction(_ sender: UIButton) {
    self.numberPresenter.decreaseNumber()
  }
  
  @IBAction func increaseAction(_ sender: UIButton) {
    self.numberPresenter.increaseNumber()
  }
  
  // Implement methods from NumberView
  func setTextNumber(text: String) {
    self.numberLabel.text = text
  }
  
  func updateDecreaseControl(enabled: Bool) {
    self.decreaseButton.isEnabled = enabled
  }
}

Bây giờ chúng ta chạy lại ứng dụng sẽ được kết quả như cũ.

Ưu điểm của mô hình MVP là View bây giờ chỉ còn là 1 phần nhỏ của ViewController. Presenter sẽ không cần biết View là class nào, miễn là có adopt protocol NumberView là được. Việc này giúp ta có thể dễ dàng test được Presenter như sau:

import XCTest
@testable import NumberCounterMVP

class NumberViewMock:NumberView {
  var textValue = ""
  var descreaseEnabled = true
  
  func setTextNumber(text: String) {
      self.textValue = text
  }
  
  func updateDecreaseControl(enabled: Bool) {
      self.descreaseEnabled = enabled
  }
}

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

Kết quả khi run test:

MVP Test Result


Vì bài viết đã khá dài nên mình sẽ viết tiếp MVVM và VIPER trong phần sau. Source code đầy đủ cho mô hình MVC và MVP các bạn có thể download tại đây.

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