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 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.
Để 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) và 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:VIPER
Tới nay chúng ta đã tìm hiểu các mô hình MVC, MVP và MVVM. 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.
Đặ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:- Ứ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.
- User nhấn vào nút tăng số trên giao diện (View)
- View gởi yêu cầu tăng số đến Presenter (có thể là protocol Presenter).
- 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).
- 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.
- Interactor gởi kết quả về InteractorOutput (chính là Presenter đang adopt).
- Presenter thực hiện các logic animation nếu cần sau đó format data trả về cho View (thông qua protocol).
Á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 NumberPresenterProtocol và NumberInteractorOutputimport 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 NumberInteractorInputimport 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 NumberViewclass 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: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 presenter và interactor 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
Post a Comment