Skip to main content

MVC, MVP, MVVM, VIPER nên xài cái nào (P1)

View and Model
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:
  • MVC: View - Controller - Model
  • MVP: View - Presenter - Model
  • MVVM: View - View Model - Model
  • VIPER: View - Presenter (with Router/Wireframe) - Interactor - Entity (Model)
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ư:
  • Logic của Controller dễ phình to (trở thành massive controller) do tương lai phải quản lý cả logic của View như: animation, show các popup nếu có, bặt tắt các control tương ứng, ... và logic để thao tác với data => khó bảo trì và mở rộng.
  • Vì các phương thức của controller phải làm việc với cả View và Model nên sẽ gây khó khăn khi tạo các lớp mock cho Unit Test => Khó test

MVP

MVP
Trong mô hình MVP sẽ có một số đặc điểm sau:
  • Model: cũng giống như mô hình MVC.
  • View: View cũng tương tự MVC tuy nhiên ta sẽ sử dụng trừu tượng để giảm lệ thuộc giữa View và Presenter. View do đặc điểm sẽ nhận kết quả từ Presenter một cách bị động, nên thường được gọi là Passive View hay Dumb View.
  • Presenter: đây là lớp thay thế cho Controller, nhiệm vụ của nó là xử lý các thao tác của user trên view rồi làm việc với Model rồi gởi kết quả về View.
  • User sẽ thao thác trên View thay vì là Controller. Do class ViewController (View + Controller) khá đặc biệt nên ta không thấy rõ điều này. Nhưng nếu là trên lập trình Winform thì sẽ rõ ràng hơn.
  • Mối liên kết giữa View và Presenter là 1 - 1
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.")
  }
}

Comments

Popular posts from this blog

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

Trong bài viết trước chúng ta đã tìm hiểu về MVC và MVP để ứ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 MVVM và VIPER . Nhắc lại là ứng dụng của chúng ta cụ thể khi chạy sẽ như sau: 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à: 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). 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

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