RxSwift với MVVM (Phần 1)

1. MVVM là gì?

MVVM (Model- View - View Model) được sáng tạo bởi hai kỹ sư của Microsoft là Ken Cooper và Ted Peters với mục đích làm đơn giản việc lập trình sự kiện của giao diện người dùng dựa trên các tính năng đặc biệt của WPF và Silverlight.

View: Tương tự như trong mô hình MVC, View là phần giao diện của ứng dụng để hiển thị dữ liệu và nhận tương tác của người dùng. Một điểm khác biệt so với các ứng dụng truyền thống là View trong mô hình này tích cực hơn. Nó có khả năng thực hiện các hành vi và phản hồi lại người dùng thông qua tính năng binding, command.

Model: Cũng tương tự như trong mô hình MVC. Model là các đối tượng giúp truy xuất và thao tác trên dữ liệu thực sự.

View Model: Lớp trung gian giữa View và Model. ViewModel có thể được xem là thành phần thay thế cho Controller trong mô hình MVC. Nó chứa các mã lệnh cần thiết để thực hiện data binding, command.

MVVM

Các ưu điểm của MVVM:

  • Tăng tính lỏng của code (Loosely Coupled):

    • View biết về View Model nhưng View Model không biết về View
    • Có thể dễ dàng thay thế View mà không ảnh hưởng tới View Model
  • Code có thể dễ dàng viết Unit Tests

  • Code dễ dàng bảo trì

Binding

Trong mô hình MVVM, View và Model không ràng buộc chặt với nhau mà được "bind" với nhau, nghĩa là thay đổi ở Model sẽ được cập nhật ở View và ngược lại.

Do iOS không hỗ trợ binding nên chúng ta phải chọn một trong các phương án sau:

  • Dùng một trong các thư viện binding dựa trên KVO như RZDataBinding hay SwiftBond
  • Sử dụng các FRF (functional reactive programming) framework như ReactiveCocoa, RxSwift hay PromiseKit

Trong khuôn khổ bài viết này, chúng ta sẽ sử dụng RxSwift.

2. RxSwift là gì?

RxSwift: ReactiveX for Swift

RxSwift là 1 phần của ReactiveX (thường gọi là “Rx”) được sử dụng ở rất nhiều ngôn ngữ và platform khác nhau. RxSwift là framework sử dụng cho ngôn ngữ Swift theo kỹ thuật reactive.

Các bạn có thể tham khảo thông tin về RxSwift qua 1 số bài viết trên Viblo:

Giới thiệu về thư viện RXSwift RxSwift các khái niệm cơ bản P.1

3. Demo:

Chúng ta sẽ tạo demo 1 ứng dụng cho phép lấy danh sách các repository từ github và hiển thị danh sách event tương ứng với từng repository. Trong dự án sẽ có 3 target tương ứng với MVC, MVVM và macOS. Trong đó MVVM của iOS và macOS sẽ dùng chung Model và ViewModel, chỉ khác về View.

iOS

macOS

3.1. Tạo project:

Tạo project MGMVVMRxSwiftDemo, có kiểu là Single View Application, ngôn ngữ Swift.

3.2. Model:

Căn cứ vào yêu cầu của bài toán, chúng ta cần 2 model là RepoEvent, việc map với json trả về từ server sẽ được thực hiện thông qua ObjectMapper.

struct Repo: Mappable {
    var id = 0
    var name: String?
    var fullname: String?
    var urlString: String?
    var starCount = 0
    var folkCount = 0
    var avatarURLString: String?
    
    init() {
        
    }
    
    init(name: String) {
        self.name = name
    }
    
    init?(map: Map) {
        
    }
    
    mutating func mapping(map: Map) {
        id <- map["id"]
        name <- map["name"]
        fullname <- map["full_name"]
        urlString <- map["html_url"]
        starCount <- map["stargazers_count"]
        folkCount <- map["forks"]
        avatarURLString <- map["owner.avatar_url"]
    }
}
struct Event: Mappable {
    var id: String?
    var type: String?
    var avatarURLString: String?
    
    init?(map: Map) {
        
    }
    
    mutating func mapping(map: Map) {
        id <- map["id"]
        type <- map["type"]
        avatarURLString <- map["actor.avatar_url"]
    }
}

3.3. Service:

Việc lấy danh sách repo và event từ server sẽ được thực hiện thông qua RepoService, chúng ta sẽ gửi request lên qua 2 url:

struct URLs {
    static let repoList = "https://api.github.com/search/repositories?q=language:swift&per_page=10"
    static let eventList = "https://api.github.com/repos/%@/events?per_page=15"
}
protocol RepoServiceProtocol {
    func repoList(input: RepoListInput) -> Observable<RepoListOutput>
    func eventList(input: EventListInput) -> Observable<EventListOutput>
}

class RepoService: APIService, RepoServiceProtocol {
    func repoList(input: RepoListInput) -> Observable<RepoListOutput> {
        return self.request(input)
            .observeOn(MainScheduler.instance)
            .shareReplay(1)
    }
    
    func eventList(input: EventListInput) -> Observable<EventListOutput> {
        return self.requestArray(input)
            .observeOn(MainScheduler.instance)
            .map { events -> EventListOutput in
                return EventListOutput(events: events)
            }
            .shareReplay(1)
    }
}

Trong đó APIService là 1 base class cung cấp hàm generic request để xử lý chung các request tới server, gồm có request trả về 1 object và trả về 1 mảng object. Dữ liệu trả về sẽ được map tự động qua ObjectMapper.

class APIService {
    
    private func _request(_ input: APIInputBase) -> Observable<Any> {
        let manager = Alamofire.SessionManager.default
        
        return manager.rx
            .request(input.requestType,
                     input.urlString,
                     parameters: input.parameters,
                     encoding: input.encoding,
                     headers: input.headers)
            .flatMap { dataRequest -> Observable<DataResponse<Any>> in
                dataRequest
                    .rx.responseJSON()
            }
            .map { (dataResponse) -> Any in
                return try self.process(dataResponse)
            }
    }
    
    private func process(_ response: DataResponse<Any>) throws -> Any {
        ...
    }
    
    func request<T: Mappable>(_ input: APIInputBase) -> Observable<T> {
        return _request(input)
            .map { data -> T in
                if let json = data as? [String:Any],
                    let item = T(JSON: json) {
                    return item
                } else {
                    throw APIError.invalidResponseData(data: data)
                }
            }
    }
    
    func requestArray<T: Mappable>(_ input: APIInputBase) -> Observable<[T]> {
        return _request(input)
            .map { data -> [T] in
                if let jsonArray = data as? [[String:Any]] {
                    return Mapper<T>().mapArray(JSONArray: jsonArray)
                } else {
                    throw APIError.invalidResponseData(data: data)
                }
        }
    }
}

3.4. ViewModel:

RepoListViewModel - ViewModel tương ứng với việc hiển thị danh sách các repo. View model này có 2 output (2 state) là repoListisLoadingData.

Có 1 action là loadDataAction, thực hiện nhiệm vụ lấy danh sách repo, dữ liệu trả về sẽ được cập nhật vào repoList, trạng thái

Thông qua constructor injection, chúng ta sẽ inject 1 instance của RepoService. Việc sử dụng protocol RepoServiceProtocol thay cho 1 class cụ thể RepoService sẽ giúp RepoListViewModel không bị phụ thuộc vào RepoService. Khi test chúng ta có thể dễ dàng thay thế bằng 1 class mock các tính năng của RepoService.

class RepoListViewModel {
    
    let repoService: RepoServiceProtocol
    let bag = DisposeBag()
    
    // MARK: - Input
    
    // MARK: - Output
    private(set) var repoList: Variable<[Repo]>
    private(set) var isLoadingData = Variable(false)
    
    private(set) var loadDataAction: Action<String, [Repo]>!
    
    init(repoService: RepoServiceProtocol) {
        self.repoService = repoService
        
        self.repoList = Variable<[Repo]>([])
        
        bindOutput()
    }
    
    private func bindOutput() {
        loadDataAction = Action { [weak self] sender in
            print(sender)
            self?.isLoadingData.value = true
            guard let this = self else { return Observable.never() }
            return this.repoService.repoList(input: RepoListInput())
                .map({ (output) -> [Repo] in
                    return output.repositories ?? []
                })
        }
        
        loadDataAction
            .elements
            .subscribe(onNext: { [weak self] (repoList) in
                self?.repoList.value = repoList
                self?.isLoadingData.value = false
            })
            .disposed(by: bag)
        
        loadDataAction
            .errors
            .subscribe(onError: { [weak self](error) in
                self?.isLoadingData.value = false
                print(error)
            })
            .disposed(by: bag)
    }
}

EventListViewModel - ViewModel tương ứng với việc hiển thị danh sách các event của repo. ViewModel này có input là repo và output là eventList. Chúng ta cũng inject RepoService thông qua constructor injection.

class EventListViewModel {
    let repoService: RepoServiceProtocol
    let bag = DisposeBag()
    
    // MARK: - Input
    private(set) var repo: Variable<Repo>
    
    // MARK: - Output
    private(set) var eventList: Variable<[Event]>
    
    init(repoService: RepoServiceProtocol, repo: Variable<Repo>) {
        self.repoService = repoService
        self.repo = repo
        
        self.eventList = Variable<[Event]>([])
        
        bindOutput()
    }
    
    private func bindOutput() {
        repo
            .asObservable()
            .filter { $0.fullname != nil && !$0.fullname!.isEmpty }
            .map { $0.fullname! }
            .flatMap({ repoFullName -> Observable<EventListOutput> in
                return self.repoService.eventList(input: EventListInput(repoFullName: repoFullName))
            })
            .subscribe(onNext: { [weak self] (output) in
                self?.eventList.value = output.events
            }, onError: { (error) in
                print(error)
            })
            .disposed(by: bag)
    }
}

(Hết phần 1) Link dự án demo Github