12/08/2018, 16:29

RXSWIFT BY EXAMPLES #4 – MULTITHREADING - PART II

Tiếp theo từ Phần I, và tài liệu: Droids Ở phần trước chúng ta đã nói về 1 chút lý thuyết Schedulers, về 2 methods observeOn() & subscribeOn(), về cấu trúc của ứng dụng mà chúng ta sẽ code - đó là 1 app mà cho phép chúng ta tìm kiếm repositories trên github thông qua username. Step 1 – ...

Tiếp theo từ Phần I, và tài liệu: Droids Ở phần trước chúng ta đã nói về 1 chút lý thuyết Schedulers, về 2 methods observeOn() & subscribeOn(), về cấu trúc của ứng dụng mà chúng ta sẽ code - đó là 1 app mà cho phép chúng ta tìm kiếm repositories trên github thông qua username.

Step 1 – Controller and UI.

Chúng ta sẽ bắt đầu với UI, và giống như example #3 của series này, UI chỉ bao gồm 1 UITableView và 1 UISearchBar. Tiếp theo, chúng ta cần 1 controller để quản lý mọi thứ: bắt đầu từ việc observing textField và kết thúc ở việc truyền những repositories (nhận được sau khi tìm kiếm bằng text nhập vào ở textField) vào trong tableView. Tạo 1 file mới tên là RepositoriesViewController.swift với code như sau:

import UIKit
import ObjectMapper
import RxAlamofire
import RxCocoa
import RxSwift

class RepositoriesViewController: UIViewController {
   
   @IBOutlet weak var tableView: UITableView!
   @IBOutlet weak var searchBar: UISearchBar!

   override func viewDidLoad() {
       super.viewDidLoad()
       setupRx()
   }
   
   func setupRx() {
   }
}

Tương tự như những lần trước, chúng ta đều chuẩn bị sẵn method setupRx() =)) bởi vì đơn giản chúng ta phụ thuộc vào Rx. Tiếp theo, hãy tạo observable của chúng ta từ property rx_text của searchBar giống như ở Example #3, nhưgn lần này chúng ta có add thêm filter cho nó: vì chúng ta không muốn giá trị empty. Chúng ta sẽ chỉ để lại request cuối cùng trong tableView khi xuất hiện, vì vậy code cuối cùng sẽ như sau:

class RepositoriesViewController: UIViewController {
   ...
   var rx_searchBarText: Observable<String> {
       return searchBar
           .rx_text
           .filter { $0.characters.count > 0 } // notice the filter new line
           .throttle(0.5, scheduler: MainScheduler.instance)
           .distinctUntilChanged()
   }
   ...
}

Và chúng ta add rx_searchBarText như một variable trong RepositoriesViewController. Bây giờ chúng ta cần add connections giữa observable đó transformed vào Observable<[Repository]> và trả lại giá trị cho UITableView

Step 2 – Network model and mapping objects

Đầu tiên, chúng ta cần phải setup cho việc mapping các object. Tạo 1 file mới với tên Repository.swift và implement như sau:

import ObjectMapper

class Repository: Mappable {
   var identifier: Int!
   var language: String!
   var url: String!
   var name: String!
   
   required init?(_ map: Map) { }
   
   func mapping(map: Map) {
       identifier <- map["id"]
       language <- map["language"]
       url <- map["url"]
       name <- map["name"]
   }
}

Như vậy chúng ta đã có controler, cũng đã có Repository object, và bây giờ là lúc cho network model. Chúng ta sẽ khởi tạo model với kiểu Observable<String> và implement method mà trả về Observable<[Repository]>. Rồi chúng ta connect model với view ở RepositoriesViewController. File RepositoryNetworkModel.swift sẽ trông như sau:

import ObjectMapper
import RxAlamofire
import RxCocoa
import RxSwift

struct RepositoryNetworkModel {

   private var repositoryName: Observable<String>
   
   private func fetchRepositories() -> Driver<[Repository]> {
       ...
   }
   
}

Code ở phía trên thoáng nhìn qua thì rất là bình thường, nhưng nếu nhìn kỹ hơn bạn sẽ thấy: chúng ta không hề return Observable<Repository> mà là Driver<Repository>. Vậy thực sự Driver là cái gì? Chúng ta đã nói về Scheduler và chúng ta biết rằng nếu chúng ta muốn truyền data vào UI thì chúng ta luôn muốn sử dụng MainScheduler, và về cơ bản nó chính là vai trò của một Driver: Driver là 1 Variabe mà nó sẽ nói rằng: "Oke 5, Tao sẽ hoạt động ở trên main thread nên đừng có lo lắng gì cả mà hãy bind tao đi!" Theo cách này, chúng ta sẽ chắc chắn rằng việc binding của chúng ta sẽ không bị error-prone và chúng ta có thể kết nối 1 cách an toàn. Vậy còn implement thực tế ntn? hãy bắt đầu với flatMapLatest() mà chúng ta đã dùng trước đó và transform Observable<String> thành Observable<[Repository]>:

struct RepositoryNetworkModel {
   ...
   private func fetchRepositories() -> Driver<[Repository]> {
       return repositoryName
           .flatMapLatest { text in
               return RxAlamofire
                   .requestJSON(.GET, "https://api.github.com/users/(text)/repos")
                   .debug()
                   .catchError { error in
                       return Observable.never()
                   }
           }
           .map { (response, json) -> [Repository] in
               if let repos = Mapper<Repository>().mapArray(json) {
                   return repos
               } else {
                   return []
               }
           }
   }
   ...
}

Đầu tiên, bạn sẽ thấy code ở trên có vẻ khác khác: tự nhiên lại có thêm 1 method map(). Nhưng thực tế nó ko có gì là khó hiểu cả: trong method flatMapLatest() chúng ta thực hiện việc request network như thông thường, và nếu như có error chúng ta sẽ dừng lại với Observable.never(). Sau đó chúng ta map response nhận được từ Alamofire tới Observable<[Repository]>. Chúng ta có thể nối hàm map() trong flatMapLatest() (có thể nối ngay sau catchError()), nhưng chúng ta cần nó ở bên ngoài flatMapLatest() để cho 1 số việc sau này, nên nó chỉ là vấn đề ưu tiên. ok 5, đoạn code trên vẫn chưa thể compile được (vì chúng ta return Observable trong khi chúng ta muốn return Driver) nên chúng ta vẫn còn đào sâu hơn: làm sao để transform Observable<[Repository]> thành Driver<[Repository]>? Đơn giản vl luôn: mọi Observable có thể transform thành Driver với việc sử dụng asDriver(). Trong trường hợp cụ thể này chúng ta sẽ dùng .asDriver(onErrorJustReturn: []) mà có nghĩa là: Nếu có error nào trong chain , trả về 1 empty array.

struct RepositoryNetworkModel {
   ...
   private func fetchRepositories() -> Driver<[Repository]> {
       return repositoryName
           .flatMapLatest { text in
               return RxAlamofire
                   .requestJSON(.GET, "https://api.github.com/users/(text)/repos")
                   .debug()
                   .catchError { error in
                       return Observable.never()
                   }
           }
           .map { (response, json) -> [Repository] in
               if let repos = Mapper<Repository>().mapArray(json) {
                   return repos
               } else {
                   return []
               }
           }
           .asDriver(onErrorJustReturn: []) // This also makes sure that we are on MainScheduler
   }
   ...
}

Như vậy là chúng ta chưa hề động đến observeOn() hoặc subscribeOn() nhưng chúng ta đã chuyển schedulers đến 2 lần. Đầu tiên là với throttle() và bây giờ là với asDriver(). Code bây giờ đã có thể chạy, điều cuối cùng chúng ta cần phải làm là connect repositories trong RepositoryNetworkModel tới viewController. Nhưng trước khi làm đièu đó, chúng ta cần phải replace method phía trên, vì với cách ấy chúng ta tạo ra 1 pipeline mới mỗi lần dùng. Thay vào đó, tôi thích 1 property hơn, nhưng không phải một computed property vì kết quả sẽ giống như 1 method. Thay vào đó chúng ta sẽ tạo 1 lazy var mà sẽ rằng buộc với method của chúng ta khi fetches repositories. Với điều này chúng ta sẽ tránh khỏi việc multiple creation of the sequence. Đồng thời, chúng ta cũng cần ẩn tất cả những thứ ko phải property, để chắc chắn rằng bất cứ ai dùng model này cũng sẽ get được đúng Driver property. Nhược điểm của cách làm này là chúng ta phải chỉ rõ type init của truct, mà tôi nghĩ rằng đó là một sự trao đổi công bằng.

struct RepositoryNetworkModel {
   
   lazy var rx_repositories: Driver<[Repository]> = self.fetchRepositories()
   private var repositoryName: Observable<String>
   
   init(withNameObservable nameObservable: Observable<String>) {
       self.repositoryName = nameObservable
   }
   
   private func fetchRepositories() -> Driver<[Repository]> {
       return repositoryName
           .flatMapLatest { text in
               return RxAlamofire
                   .requestJSON(.GET, "https://api.github.com/users/(text)/repos")
                   .debug()
                   .catchError { error in
                       return Observable.never()
                   }
           }
           .map { (response, json) -> [Repository] in
               if let repos = Mapper<Repository>().mapArray(json) {
                   return repos
               } else {
                   return []
               }
           }
           .asDriver(onErrorJustReturn: []) // This also makes sure that we are on MainScheduler
   }
}

Bây giờ chúng ta sẽ connect data vào viewController. Khi chúng ta muốn bind Driver vào tableView, thay vì dùng bindTo chúng ta sẽ dùng drive() nhưng syntax và mọi thứ đều tươgn tự như bindTo. Ngoài việc binding data vào tableView, chugns ta cũng sẽ tạo ra 1 subscription và mỗi khi count của repositories là 0, chúng ta cũng show ra 1 alert. File RepositoriesViewController cuối cùng sẽ như sau:

class RepositoriesViewController: UIViewController {
   
   @IBOutlet weak var tableViewBottomConstraint: NSLayoutConstraint!
   @IBOutlet weak var tableView: UITableView!
   @IBOutlet weak var searchBar: UISearchBar!
   let disposeBag = DisposeBag()
   var repositoryNetworkModel: RepositoryNetworkModel!
   
   var rx_searchBarText: Observable<String> {
       return searchBar
           .rx_text
           .filter { $0.characters.count > 0 }
           .throttle(0.5, scheduler: MainScheduler.instance)
           .distinctUntilChanged()
   }
   
   override func viewDidLoad() {
       super.viewDidLoad()
       setupRx()
   }
   
   func setupRx() {
       repositoryNetworkModel = RepositoryNetworkModel(withNameObservable: rx_searchBarText)
       
       repositoryNetworkModel
           .rx_repositories
           .drive(tableView.rx_itemsWithCellFactory) { (tv, i, repository) in
               let cell = tv.dequeueReusableCellWithIdentifier("repositoryCell", forIndexPath: NSIndexPath(forRow: i, inSection: 0))
               cell.textLabel?.text = repository.name
               
               return cell
           }
           .addDisposableTo(disposeBag)
       
       repositoryNetworkModel
           .rx_repositories
           .driveNext { repositories in
               if repositories.count == 0 {
                   let alert = UIAlertController(title: ":(", message: "No repositories for this user.", preferredStyle: .Alert)
                   alert.addAction(UIAlertAction(title: "OK", style: .Default, handler: nil))
                   if self.navigationController?.visibleViewController?.isMemberOfClass(UIAlertController.self) != true {
                       self.presentViewController(alert, animated: true, completion: nil)
                   }
               }
           }
           .addDisposableTo(disposeBag)
   }
}

Có thể bạn sẽ thấy lạ lẫm với driveNext() nhưng bạn cũng thừa sức đoán được: nó như kiểu subsribeNext dành cho Driver vậy.

Step 3 – Multithreading optimization

Bạn có thể thấy được, trên thực tế, mọi thứ chúng ta làm đều đc thực hiện trên MainScheduler. Vì sao? Bở vì chain của chúng ta bắt đầu từ searchBar.rx_text và cái này chắc chắn là đc thực hiện trên MainScheduler. Và bởi vì mọi thứ khác đều default trên scheduler hiện tại -> có thể UI thread của chúng ta bị quá tải. Vậy làm cách nào để ngăn chặn nó? Switch sang background thread trước khi request và trước khi mapping object, vì thế chúng ta sẽ chỉ update UI ở main thread:

struct RepositoryNetworkModel {
  ...
  private func fetchRepositories() -> Driver<[Repository]> {
      return repositoryName
          .observeOn(ConcurrentDispatchQueueScheduler(globalConcurrentQueueQOS: .Background))
          .flatMapLatest { text in // .Background thread, network request
              return RxAlamofire
                  .requestJSON(.GET, "https://api.github.com/users/(text)/repos")
                  .debug()
                  .catchError { error in
                      return Observable.never()
                  }
          }
          .observeOn(ConcurrentDispatchQueueScheduler(globalConcurrentQueueQOS: .Background))
          .map { (response, json) -> [Repository] in // again back to .Background, map objects
              if let repos = Mapper<Repository>().mapArray(json) {
                  return repos
              } else {
                  return []
              }
          }
          .asDriver(onErrorJustReturn: []) // This also makes sure that we are on MainScheduler
  }
  ...
}

Với đoạn code trên bạn sẽ có thể tự hỏi: "Tại sao phải dùng observeOn() tới 2 lần giống hệt nhau?" Bởi vì chúng ta không thể biết chắc được rằng requestJSON sẽ trả về data ở cùng thread nó bắt đầu hay không. Vì thế chúng ta phải chắc chắn nó vẫn ở background thread cho việc mapping vì nó khá là nặng. Như vậy, bây giờ chúng ta đã thực hiện việc mapping trên background threads, kết quả của mapping được truyền lên UI thread, chúng ta còn có thể làm gì tốt hơn nữa không? Dĩ nhiên là có, chúng ta muốn user biết được rằng một network request đang đc thực hiện. Để làm đièu đó, ta sẽ sử dụng thuộc tính UIApplication.sharedApplication().networkActivityIndicatorVisiable, mà thường đc gọi là spinner. Tuy nhiên, bây giờ chúng ta cần phải cẩn thận với threads, kể từ lúc chúng ta muốn update UI ở ngay trong khi request/mapping đang thực hiện. Chúng ta cũng sẽ sử dụng 1 method tên là doOn() mà có thể làm bất kể gì bạn muốn trên 1 events cụ thể (như .Next, .Error etc.) Giả sử chúng ta muốn show spinner ngay trước khi flatMapLatest() thực hiện, doOn sẽ là thứ bạn cần. Chúng ta chỉ cần switch MainScheduler trước khi action đó đc thực hiện. Cuối cùng code cho việc fetching repositories sẽ như sau:

struct RepositoryNetworkModel {
  
  lazy var rx_repositories: Driver<[Repository]> = self.fetchRepositories()
  private var repositoryName: Observable<String>
  
  init(withNameObservable nameObservable: Observable<String>) {
      self.repositoryName = nameObservable
  }
  
  private func fetchRepositories() -> Driver<[Repository]> {
      return repositoryName
          .subscribeOn(MainScheduler.instance) // Make sure we are on MainScheduler
          .doOn(onNext: { response in
              UIApplication.sharedApplication().networkActivityIndicatorVisible = true
          })
          .observeOn(ConcurrentDispatchQueueScheduler(globalConcurrentQueueQOS: .Background))
          .flatMapLatest { text in // .Background thread, network request
              return RxAlamofire
                  .requestJSON(.GET, "https://api.github.com/users/(text)/repos")
                  .debug()
                  .catchError { error in
                      return Observable.never()
                  }
          }
          .observeOn(ConcurrentDispatchQueueScheduler(globalConcurrentQueueQOS: .Background))
          .map { (response, json) -> [Repository] in // again back to .Background, map objects
              if let repos = Mapper<Repository>().mapArray(json) {
                  return repos
              } else {
                  return []
              }
          }
          .observeOn(MainScheduler.instance) // switch to MainScheduler, UI updates
          .doOn(onNext: { response in
              UIApplication.sharedApplication().networkActivityIndicatorVisible = false
          })
          .asDriver(onErrorJustReturn: []) // This also makes sure that we are on MainScheduler
  }
}

Full app sẽ nhìn như sau:

0