Getting Started With RxSwift and RxCocoa: Networking

Giới thiệu

Tiếp theo các phần trước Phần 1 - Getting Started With RxSwift and RxCocoa, Phần 2 - Getting Started With RxSwift and RxCocoa : Observable and the Bind hôm nay chúng ta sẽ tiếp tục tìm hiểu về RxSwift, cụ thể là tìm hiểu về networking, cách lấy data và kết nối dữ liệu đó với View. Rx có rất nhiều networking extentions bao gồm RxAlamofire, Moya và ở trong bài viết này chúng ta sẽ tập trung ở Moya.

Moya

Moya là một abstract layer giúp cho chúng ta thực hiện các tác vụ về networking, về cơ bản bằng cách sử dụng thư viện này chúng ta có thể tạo kết nối tới API một cách đơn giản, với phần mở rộng bao gồm RxSwift vào ModelMapper chúng ta đã có đầy đủ vũ khí cho việc thực hiện kết nối mạng một cách ngon lành.

Setup

Để setup Moya chúng ta cần Provider thiết lập stubbing, endpoint closure ... Cho trường hợp của chúng ta chugns ta chỉ cần thiết lập đơn giản Provider với RxSwift. Việc thứ 2 chúng ta cần làm là thiết lập cấu hình Endpoint - 1 enum định nghĩa ra các API cần gọi. Việc này rất đơn giản, chúng ta chỉ cần tạo ra enum conform TargetType là xong, nó là 1 protocol gồm url, method, task, parameter và parameterEncoding. Param cuối cùng chúng ta cần specify là sampleData sử dụng cho việc fake data trả về và test.

Example

Trong example này chúng ta sẽ lấy ra các issues của 1 reposity xác định sử dụng GitHub API. Trước tiên chúng ta lấy về các reposity, kiểm tra xem nó tồn tại hay ko, sau đó gửi request lấy về các issuses cho repository này. Chúng ta sẽ map kết quả trả về từ JSON sang objects, xử lý lỗi, xử lý dupplicating requests, spamming API ... Ví dụ của chúng ta sẽ chạy như sau:

Chúng ta sẽ tạo project với nội dung file pod như sau:

platform :ios, '8.0'
use_frameworks!
 
target 'RxMoyaExample' do
 
pod 'RxCocoa', '~> 3.0.0'
pod 'Moya-ModelMapper/RxSwift', '~> 4.1.0'
pod 'RxOptional'
 
end
 
post_install do |installer|
    installer.pods_project.targets.each do |target|
        target.build_configurations.each do |config|
              config.build_settings['ENABLE_TESTABILITY'] = 'YES'
              config.build_settings['SWIFT_VERSION'] = '3.0'
        end
    end
end

Step 1 – Controller and Moya setup.

Chúng ta sẽ bắt đầu với UI, ở đây UI chỉ đơn giản gồm 1 UITableview và 1 UISearchbar. Tiếp theo chúng ta cần1 viewcontroller để quản lý, nó sẽ lấy data từ search bar, pass cho model, model sẽ lấy dữ liệu từ server và đẩy nó cho tableview. Chúng ta sẽ tạo file IssueListViewController.swift nội dung như sau:

import Moya
import Moya_ModelMapper
import UIKit
import RxCocoa
import RxSwift
 
class IssueListViewController: UIViewController {
    
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var searchBar: UISearchBar!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupRx()
    }
    
    func setupRx() {
    }
}

Tiếp theo chúng ta sẽ tạo file GithubEndpoint.swift và tạo enum với 1 số target:

import Foundation
import Moya
 
enum GitHub {
    case userProfile(username: String)
    case repos(username: String)
    case repo(fullName: String)
    case issues(repositoryFullName: String)
}

enum Github như mình đã nói nó cần conform TargetType, cái này cũng chỉ đơn giản là 1 enum. Chúng ta sẽ tạo extension cho GitHub enum, nó sẽ gồm tất cả các thuộc tính cần thiết:

import Foundation
import Moya
 
private extension String {
    var URLEscapedString: String {
        return self.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlHostAllowed)!
    }
}
 
enum GitHub {
    case userProfile(username: String)
    case repos(username: String)
    case repo(fullName: String)
    case issues(repositoryFullName: String)
}
 
extension GitHub: TargetType {
    var baseURL: URL { return URL(string: "https://api.github.com")! }
    var path: String {
        switch self {
        case .repos(let name):
            return "/users/\(name.URLEscapedString)/repos"
        case .userProfile(let name):
            return "/users/\(name.URLEscapedString)"
        case .repo(let name):
            return "/repos/\(name)"
        case .issues(let repositoryName):
            return "/repos/\(repositoryName)/issues"
        }
    }
    var method: Moya.Method {
        return .get
    }
    var parameters: [String: Any]? {
        return nil
    }
    var sampleData: Data {
        switch self {
        case .repos(_):
            return "{{\"id\": \"1\", \"language\": \"Swift\", \"url\": \"https://api.github.com/repos/mjacko/Router\", \"name\": \"Router\"}}}".data(using: .utf8)!
        case .userProfile(let name):
            return "{\"login\": \"\(name)\", \"id\": 100}".data(using: .utf8)!
        case .repo(_):
            return "{\"id\": \"1\", \"language\": \"Swift\", \"url\": \"https://api.github.com/repos/mjacko/Router\", \"name\": \"Router\"}".data(using: .utf8)!
        case .issues(_):
            return "{\"id\": 132942471, \"number\": 405, \"title\": \"Updates example with fix to String extension by changing to Optional\", \"body\": \"Fix it pls.\"}".data(using: .utf8)!
        }
    }
    var task: Task {
        return .request
    }
    var parameterEncoding: ParameterEncoding {
        return JSONEncoding.default
    }
}

Chúng ta ko cần truyền bất kỳ param nào ở đây nên return nil, method luôn luôn là .get, baseURL cũng ko thay đổi, sampleData thì có trả về các dữ liệu khác nhau trong các trường hợp khác nhau do đó chúng ta để trong switch. Chúng ta đã thực thi xong Moya Provider. Chúng ta cũng cần thực hiện việc ẩn bàn phím khi click cell, việc này đương nhiên cũng được thực hiện với RxSwift, chúng ta sẽ cần 1 DisposeBag, thêm nữa chúng ta sẽ tạo ra 1 Observable mới để lấy dữ liệu text từ search bar, filter, ...

class IssueListViewController: UIViewController {
    ...
    let disposeBag = DisposeBag()
    var provider: RxMoyaProvider<GitHub>!    
    var latestRepositoryName: Observable<String> {
        return searchBar
            .rx.text
            .orEmpty
            .debounce(0.5, scheduler: MainScheduler.instance)
            .distinctUntilChanged()
    }
    ...
    func setupRx() {
        // First part of the puzzle, create our Provider
        provider = RxMoyaProvider<GitHub>()
   
        // Here we tell table view that if user clicks on a cell,
        // and the keyboard is still visible, hide it
        tableView
            .rx.itemSelected
            .subscribe(onNext: { indexPath in
                if self.searchBar.isFirstResponder() == true {
                    self.view.endEditing(true)
                }
            })
            .addDisposableTo(disposeBag)
    }
    ...
}

Step 2 – Network model and mapping objects

Chúng ta cần model để lấy dữ liệu từ text tương ứng lấy ở search bar, nhưng đầu tiên chúng ta cần parse object trước khi chúng ta truyền bất kì dữ liệu nào vào nó, việc này được thực hiện dễ dàng nhờ ModelMapper, chúng ta sẽ cần 2 entities, 1 cho Repository và 1 cho Issuse.

import Mapper
 
struct Repository: Mappable {
    
    let identifier: Int
    let language: String
    let name: String
    let fullName: String
    
    init(map: Mapper) throws {
        try identifier = map.from("id")
        try language = map.from("language")
        try name = map.from("name")
        try fullName = map.from("full_name")
    }
}
import Mapper
 
struct Issue: Mappable {
    
    let identifier: Int
    let number: Int
    let title: String
    let body: String
    
    init(map: Mapper) throws {
        try identifier = map.from("id")
        try number = map.from("number")
        try title = map.from("title")
        try body = map.from("body")
    }
}

Chúng ta ko cần quá nhiều thuộc tính, nếu bạn muốn có thể tự thêm thuộc tính dựa theo tài liệu GitHub API Tiếp theo chungs ta sẽ chuyển tới phần thú vị nhất của tutorial này, IssueTrackerModel - core of our Networking. Trước tiên, model của chúng ta cần có Provider property để pass nó vào hàm init. Sau đó chúng ta cần có property để observable text để lấy tên reposityNames

Let’s create the IssueTrackerModel.swift:

import Foundation
import Moya
import Mapper
import Moya_ModelMapper
import RxOptional
import RxSwift
 
struct IssueTrackerModel {
    
    let provider: RxMoyaProvider<GitHub>
    let repositoryName: Observable<String>
    
    func trackIssues() -> Observable<[Issue]> {
        
    }
    
    internal func findIssues(repository: Repository) -> Observable<[Issue]?> {
 
    }
    
    internal func findRepository(name: String) -> Observable<Repository?> {
 
    }
}

Chúng ta thêm 2 functions:

  • findRepository(😃 sẽ trả về optional repository
  • findIssues(😃 sẽ trả về list các issuses với repository tương ứng.
internal func findIssues(repository: Repository) -> Observable<[Issue]?> {
    return self.provider
        .request(GitHub.issues(repositoryFullName: repository.fullName))
        .debug()
        .mapArrayOptional(Issue.self)
}
 
internal func findRepository(name: String) -> Observable<Repository?> {
    return self.provider
        .request(GitHub.repo(fullName: name))
        .debug()
        .mapObjectOptional(Repository.self)
}

Step by step:

  1. Chúng ta có provider, thực hiện request với các enum đã được định nghĩa
  2. Sau đó chúng ta sẽ pass GitHub.repo hoặc GitHub.issuese => request done.
  3. Chúng ta sử dụng debugs() operator, sẽ giúp in các giá trị của request rất hữu ích cho việc phát triển, debugs cũng như testing .
  4. Pare, map giá trị JSON trả về .

Chúng ta có 2 methods trả về dữ liệu dựa vào đầu vào, làm sao để kết nối chúng? cho tác vụ này chúng ta sẽ tìm hiểu operator mới: flatMap() và flatMapLatest(), các operator này sẽ tạo ra sequence từ 1 sequence khác. Chúng ta có 1 sequence string, chúng ta muốn convert nó sang 1 sequence của các repositories, từ sequence repositories -> sequence issuses, ...

Our trackIssues method should look like the one below:

func trackIssues() -> Observable<[Issue]> {
    return repositoryName
        .observeOn(MainScheduler.instance)
        .flatMapLatest { name -> Observable<Repository?> in
            print("Name: \(name)")
            return self
                .findRepository(name)
        }
        .flatMapLatest { repository -> Observable<[Issue]?> in
            guard let repository = repository else { return Observable.just(nil) }
            
            print("Repository: \(repository.fullName)")
            return self.findIssues(repository)
        }
        .replaceNilWith([])
}

Step by step:

  1. Chúng ta muốn chắc chắn rằng việc observed được thực hiện trên MainScheduler bởi vì mục đích của model để bind dữ liệu sang UI, ở đây là tableview.
  2. transform text (repository name) thành observable repository sequence, có thể nhận giá trị nil trong trường hợp map object không đúng.
  3. kiểm tra repository mapped có nil hay không, nếu nil ta trả về observable nil sequence, nếu không nil ta transofrom thành mảng các issues
  4. replaceNilWith([]) là RxOptional extension giúp chúng ta trong trường hợp transform nil thành mảng rỗng và clear tableview

Step 3 – Bind issues to table view

Phần cuối cùng chúng ta sẽ kết nối dữ liệu từ model vào table view. Nói cách khác là bind từ observable vào table view. Thông thường chúng ta cần viết viewcontroller sẽ thực thi UITableViewDataSource, nó sẽ thực hiện 1 vài hàm như numberOfRows, cellForRow, ... tuy nhiên với RxSwift chúng ta chỉ cần thực hiện UITalbeViewDatasource trong 1 closure. RxCocoa cung cấp chúng ta hàm rx.itemsWithCellFactory rất tuyệt vời.

class IssueListViewController: UIViewController {
    ...
    var issueTrackerModel: IssueTrackerModel!
    ...    
    func setupRx() {
        // First part of the puzzle, create our Provider
        provider = RxMoyaProvider<GitHub>()
        
        // Now we will setup our model
        issueTrackerModel = IssueTrackerModel(provider: provider, repositoryName: latestRepositoryName)
        
        // And bind issues to table view
        // Here is where the magic happens, with only one binding
        // we have filled up about 3 table view data source methods
        issueTrackerModel
            .trackIssues()
            .bindTo(tableView.rx.items) { tableView, row, item in
                let cell = tableView.dequeueReusableCell(withIdentifier: "issueCell", for: IndexPath(row: row, section: 0))
                cell.textLabel?.text = item.title
                
                return cell
            }
            .addDisposableTo(disposeBag)
        
        // Here we tell table view that if user clicks on a cell,
        // and the keyboard is still visible, hide it
        tableView
            .rx.itemSelected
            .subscribe(onNext: { indexPath in
                if self.searchBar.isFirstResponder == true {
                    self.view.endEditing(true)
                }
            })
            .addDisposableTo(disposeBag)
    }
    ...
}

And that’s it! Everything we wanted to implement is implemented! Run the project and be happy with the results!

Tham khảo

http://www.thedroidsonroids.com/blog/ios/rxswift-examples-3-networking