RXSWIFT BY EXAMPLES #4 – MULTITHREADING - PART II
Bài đăng này đã không được cập nhật trong 6 năm
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:
All rights reserved