07/09/2018, 15:34

Reactive Programming với RxSwift

Bài này mình làm theo The introduction to Reactive Programming you've been missing với ví dụ convert sang RxSwift. Bạn có thể tìm thấy code chạy được trên Github. Nếu bạn đang gặp khó khăn khi bắt đầu học Reactive Programming với RxSwift , đừng vội lo lắng, không phải chỉ có mình bạn thế ...

alt text

Bài này mình làm theo The introduction to Reactive Programming you've been missing với ví dụ convert sang RxSwift. Bạn có thể tìm thấy code chạy được trên Github.

Nếu bạn đang gặp khó khăn khi bắt đầu học Reactive Programming với RxSwift, đừng vội lo lắng, không phải chỉ có mình bạn thế đâu :sweat_smile:

Học RxSwift quả thật là khó, nhất là khi không có những tài liệu tham khảo tốt. Tất cả các tút đều hoặc quá khái quát hoặc quá cụ tỉ. Document chính thức của ReactiveX thì như đấm vào tai

Rx.Observable.prototype.flatMapLatest(selector, [thisArg])

Projects each element of an observable sequence into a new sequence of observable sequences by incorporating the element's index and then transforms an observable sequence of observable sequences into an observable sequence producing values only from the most recent observable sequence.

Mình cũng đã có những quãng thời gian khó khăn, cuối cùng phải học theo cách vọc thẳng vào sample trong repo của RxSwift hoặc là một vài open source app có sẵn. Document phát đầu của RxSwift thì báng ngay Binding với Retry, chả hiểu cái của khỉ gì. Đọc code cũng không khá khẩm hơn mấy khi mở ra đã thấy RxDataSources với Moya/RxSwift méo hiểu ở đâu ra.

Thật may là có một bài tút duy nhất rất dễ hiểu của anh Andre (@andrestaltz) nhưng code ví dụ lại là RxJS, vì vậy mình đã quyết định làm một app mô phỏng "Who to Follow" trong tút đó và chia sẻ lại, hi vọng sẽ giúp các bạn học sau học dễ dàng hơn.

Bắt đầu nhé :tada:

Ấn vào một nút, gõ một ký tự vào text field, v.v... tất cả các thao tác của người dùng trên màn hình một app có thể coi là một asynchronous event (sự kiện bất đồng bộ). Tuy vậy khi người dùng thực hiện các thao tác liên tục, ví dụ như ấn liên tiếp vào 1 nút hay gõ liên tiếp vào một text field, chúng ta sẽ có một asynchronous event streams (dòng sự kiện bất đồng bộ)

--a---b-c---d---X---|->

a, b, c, d are events
X is an error event
| is the 'completed' signal
---> is the timeline

Hình vẽ bên trên là mô phỏng cho một stream. Ngoài stream cho các thao tác ấn, thao tác gõ như trên, bạn có thể tạo stream cho tất cả các biến, hằng, các kiểu dữ liệu, mọi thứ đều có thể biến thành stream! Ví dụ Facebook feed có thể coi là một stream dữ liệu.

Cùng với đó bạn sẽ có một bộ tool rất bá đạo, dùng để tạo mới, kết hợp, lược bỏ hay biến đổi giữa các stream với nhau. Một stream có thể chuyển hóa thành một stream khác, hay một vài stream có thể nhập lại rồi bung ra một stream mới. Các method hay dùng trong bộ tool này phải kể đến map, flatMap, combine, merge, scan, filter....

buttonTapStream: ---t----t--t----t------t-->
                 vvvvv map(t becomes 1) vvvv
                 ---1----1--1----1------1-->
                 vvvvvvvvv scan(+) vvvvvvvvv
counterStream:   ---1----2--3----4------5-->

Trong thế giới của Reactive Programming, stream nói trên được gọi là Observables, thể hiện bằng một đường timeline như trục thời gian, với các sự kiện diễn theo theo thứ tự. Điều cần nhớ đầu tiên là, Observables là immutable, có nghĩa là với mỗi method trong bộ toolbox dùng xong sẽ tạo ra một Observables mới, chứ không biến đổi Observables cũ. Đây là tư tưởng cốt lõi của functional programming.

Reactive Programming mang đến một phong cách lập trình mới khi làm việc với những app đòi hỏi tính tương tác cao. Mobile app hiện đại ngày nay đã phát triển vượt bậc: khi người dùng gõ vào khung search, hay kéo xuống để refresh, không có màn hình mới nào được chuyển đến cả, tất cả mọi UI event đều gọi đến data ở phía sau và phản ánh lại màn hình ngay lập tức.

Từ đây mình sẽ hướng dẫn ứng dụng RxSwift cho một ví dụ rất thực tế. Như bạn đã biết thì Twitter có một khung UI "Who to follow" trên web như dưới đây:

Để code được khung này cần những tính năng chính:

  • Khi mới load lần đầu, cần lấy được thông tin của 3 user và hiển thị vào 3 dòng
  • Khi ấn nút "Refresh", cần load thông tin của 3 user khác và hiển thị lại
  • Khi ấn nút "x" ở mỗi dòng, clear thông tin trên dòng đó và load vào thông tin 1 user mới
  • Mỗi dòng cần có tên và ảnh đại diện của user

Twiter vốn không cung cấp public API, vì thế mình sẽ dùng Github APi thay thế để tránh mấy đoạn Oauth loằng ngoằng. API bên trên của Github có thể chỉ định một GET parameter since để lấy offset. Trong trường hợp bạn muốn xem cụ thể thì có thể clone repo xem trực tiếp.

Chúng ta sẽ bắt đầu với yêu cầu đơn giản nhất: "Khi load lần đầu lấy thông tin 3 user và hiển thị 3 dòng". Nghĩ một cách trực quan thì

  1. Tạo 1 request
  2. Bắn request và lấy về response
  3. Hiển thị dữ liệu từ response vào UITableView

Để tạo request trong Swift thì có những thư viện rất hay và có sẵn như Alamofire chẳng hạn. Tuy nhiên chúng ta sẽ thử suy nghĩ vấn đề theo hướng Reactive Programming trước. Coi như URL là một string, trong trường hợp này là https://api.github.com/users, mình sẽ tạo một Observable đầu tiên với kiểu Observable<String>

let requestStream: Observable<String> = Observable.just("https://api.github.com/users")

Đây là một stream của một biến string là URL. Stream này chỉ emit (nhả ra) event một lần duy nhất

--a------|->

Where a is the string "https://api.github.com/users"

requestStream chưa làm việc gì khác ngoài việc chứa URL của Github cả, mình sẽ gọi request đến URL và lấy data về, bằng cách dùng hàm subscribeNext

requestStream.subscribeNext { url in 
  // Do the real request to Github API, get back a `User` model
  let responseStream: Observable<[User]> = UserModel().findUsers(url)
}

Hãy để ý kiểu của responseStream bên trên, cũng là một Observable khác và chứa list các User. Bạn có thể xem cụ thể implement của UserModel().findUsers(url) trên Github, còn ở đây hãy tạm coi rằng đó chỉ là một method trả về kiểu [User] được gói trong Observable.

Vậy bước tiếp theo sẽ là hiển thị dữ liệu thu về vào UITableView, bằng cách lại subcribeNext vào cái responseStream bên trên

requestStream.subscribeNext { url in 
  let responseStream: Observable<[User]> = UserModel().findUsers(url)
  responseStream.subscribeNext { users in
    // ...
  }
}

Chúng ta có gì ở đây ? Một subcribeNext lồng bên trong subcribeNext khác - mùi của callback hell. Mình sẽ dùng đến vũ khí đầu tiên của Reactive: map(f)

let responseStream = requestStream.map { url in 
  return UserModel().findUsers(url)
}

Kiểu của responseStream ở đây là Observable<Observable<[User]>>: một metastream! Con quái này thực tế là một stream của stream, có nghĩa là mỗi sự kiện mà nó nhả ra lại là một stream mới, mà stream đó đến lượt mình mới nhả ra [User]. Bạn có thể tưởng tượng metastream là stream of pointers vậy, mỗi pointer trỏ đến một stream khác.

Nhưng chúng ta không cần một con metastream thế này! Cách tốt hơn là dùng flatMap(f), một phiên bản của map(f) nhưng "flatten" kiểu của kết quả trở lại thành một stream đơn giản

let responseStream = requestStream.flatMap { url in 
  return UserModel().findUsers(url)
}

Ngon rồi, nếu chúng ta có nhiều event diễn ra trong requestStream, chúng ta có thể có nhiều kết quả [User] tương ứng

requestStream:  --url-------url----------url------------|->
responseStream: -----[User]-----[User]-----[User]-------|->

Đến đây đoạn code của chúng ta như sau

let requestStream: Observable<String> = Observable.just("https://api.github.com/users")
let responseStream = requestStream.flatMap { url in 
  return UserModel().findUsers(url)
}
responseStream.subscribeNext { users in
  // users is a normal [User] list, here comes the UI Rendering part
}

Chức năng của nút Refresh khá đơn giản: mỗi khi người dùng ấn nút "Refresh", chúng ta bắn request lấy 3 user mới và trám lại vào màn hình.

Ở đây chúng ta có 2 stream: 1 stream của động tác ấn nút "Refresh" (người dùng có thể ấn nhiều lần!) và 1 stream của API URL được biến đổi từ stream nói trên. Trong RxSwift, event ấn nút có thể nhận bằng hàm rx_tap

let refreshStream = refresh.rx_tap
let requestStream: Observable<String> = refreshStream.map { _ in
  let random = Array(1...1000).random()
  return "https://api.github.com/users" + String(random)
}

Ở đây "refresh" là outlet cho nút Refresh, và random() là một hàm extension

Vì mình code lởm và ko có unit test, mình vừa làm hỏng một chức năng ở phần trước: với code như giờ thì sẽ không có request nào xảy ra load màn hình load lần đầu tiên. Mọi request chỉ phát sinh khi người dùng thực sự ấn nút.

Để sửa chữa, mình sẽ tạo thêm một stream mới cho lần load đầu

let beginningStream: Observable<String> = Observable.just("https://api.github.com/users")

và, bằng một cách vi diệu nào đó, "ghép" với stream bên trên. Cách vi diệu lần này có tên là merge()

stream A: ---a--------e-----o----->
stream B: -----B---C-----D-------->
          vvvvvvvvv merge vvvvvvvvv
          ---a-B---C--e--D--o----->
let requestStream = Observable.of(refreshStream, beginningStream).merge()

Tuy vậy, vì một trong 2 thành phần merge lần này chỉ là một stream đơn giản, nên có thể viết ngắn gọn lại và ghép trực tiếp vào requestStream như thế này

let refreshStream = refresh.rx_tap.startWith(()) // Here
let requestStream: Observable<String> = refreshStream.map { _ in
  let random = Array(1...1000).random()
  return "https://api.github.com/users" + String(random)
}

Mỗi khi nhận được data từ responseStream, chúng ta sẽ muốn hiển thị ngay lập tức vào 3 cell trên UITableViewCell. Hãy nghĩ về "thần chú" Rx:

Chúng ta có thể tạo ra userStream, trả lại 1 user duy nhất cho mỗi cell:

// Inside func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath)
let userStream: Observable<User?> = responseStream.map { users in
  guard users.count > 0 else {return nil}
  return users.random()
}

Khi người dùng nhấn nút "Refresh", một request sẽ được bắn đi, qua thời gian khi response trở lại chúng ta mới có thể lấy ra userStream ở trên và áp ngược vào màn hình. Như vậy ngay khi vừa ấn nút thì màn hình vẫn giữ nguyên thông tin cũ, chỉ đến khi nhận được thông tin mới mới thay đổi.

Để làm UI nuột hơn, chúng ta cần một phương pháp "xóa trắng" màn hình khi ấn nút "Refresh", và trám lại thông tin khi có response về. Mình sẽ map mỗi lần ấn nút Refresh vào một stream nil, và merge vào userStream ở trên

let nilOnRefreshTapStream: Observable<User?> = refresh.rx_tap
  .map {_ in return nil}
let suggestionStream = Observable.of(userStream, nilOnRefreshTapStream)
  .merge()

Khi cần hiển thị, mình có thể check data có phải nil hay không, để ẩn hoặc hiện cả cell

suggestionStream.subscribeNext{ op in
  guard let u = op else { return self.clearCell(cell) }
  return self.setCell(cell, user: u )
}.addDisposableTo(cell.disposeBagCell)

Đây là bức tranh toàn cảnh tại thời điểm hiện tại:

           refreshStream: ----------o--------o---->
           requestStream: -r--------r--------r---->
          responseStream: ----R---------R------R-->   
suggestionStream(Cell 1): ----s-----N---s----N-s-->
suggestionStream(Cell 2): ----q-----N---q----N-q-->
suggestionStream(Cell 3): ----t-----N---t----N-t-->

N ở đây nghĩa là nil

Hoàn thiện thêm 1 chút, khi load lần đầu tiên mình sẽ tạo một suggestionStream "trống" bằng cách dùng startWith(.None)

let nilOnRefreshTapStream: Observable<User?> = refresh.rx_tap
  .map {_ in return nil}
let suggestionStream = Observable.of(userStream, nilOnRefreshTapStream)
  .merge()
  .startWith(.None)

và ascii diagram của chúng ta sẽ trở thành:

           refreshStream: ----------o--------o---->
           requestStream: -r--------r--------r---->
          responseStream: ----R---------R------R-->   
suggestionStream(Cell 1): -N--s-----N---s----N-s-->
suggestionStream(Cell 2): -N--q-----N---q----N-q-->
suggestionStream(Cell 3): -N--t-----N---t----N-t-->

Vậy là còn một tính năng cuối phải implement: mỗi dòng có một nút "x", khi ấn vào sẽ clear thông tin trên dòng hiện tại, load một user mới và trám vào thay thế. Quá dễ, giờ lại map event của nút "x" vào một stream mới và bắn 1 request mỗi khi người dùng ấn nút "x"

let closeStream = cell.cancel.rx_tap // "cancel" is outlet for cancel button
let requestStream = Observable.of(refreshStream, closeStream)
  .merge()
  .map { _ in
  let random = Array(1...1000).random()
  return "https://api.github.com/users" + String(random)
}

Nếu chạy thử đoạn code này, bạn sẽ thấy tất cả 3 dòng đều bị load lại, chứ không phải chỉ có 1 dòng của nút "x", Điều này là dễ hiểu, vì mỗi requestStream sẽ trigger một responseStream, và đến lượt mình trigger tiếp cả 3 suggestionStream trên cả 3 cell.

Có một vài cách để giải quyết bài toán, và để cho thú vị mình sẽ giải bằng cách dùng lại dữ liệu từ responseStream. API của Github thực tế trả về 100 kết quả, và ngoài trừ 3 kết quả dùng khi refresh, vẫn còn 97 kết quả chưa động đến!

Hãy thử tư duy lại theo stream. Khi 1 event "ấn vào nút x" xảy ra, mình sẽ làm thế nào để suggestionStreamlấy 1 user trong số user chưa được dùng đến từ lần nhả gần nhất của responseStream, như vầy nè

   requestStream: --r--------------->
  responseStream: ------R----------->
closeClickStream: ------------c----->
suggestionStream: ------s-----s----->

Trong RxSwift có 1 hàm gọi là combineLatest(f) có thể làm những gì chúng ta muốn ở trên. combineLatest(f) lấy 2 stream đầu vào A và B, mỗi khi 1 stream nhả ra 1 giá trị, nó sẽ tự động lấy thêm giá trị gần nhất của stream còn lại và thực hiện hàm f để nhả ra giá trị kết quả mới.

stream A: --a-----------e--------i-------->
stream B: -----b----c--------d-------q---->
          vvvvvvvv combineLatest(f) vvvvvvv
          ----AB---AC--EC---ED--ID--IQ---->

where f is the uppercase function

Chúng ta có thể dùng combineLatest với closeStream và responseStream, để mỗi khi nút "x" được nhấn, ta có thể lấy giá trị gần nhất trong responseStream để nhả ra một user mới

let closeStream = cell.cancel.rx_tap
let userStream: Observable<User?> = Observable.combineLatest(closeStream, responseStream)
{ (_, users) in
  guard users.count > 0 else {return nil}
  return users.random()
}

let nilOnRefreshTapStream: Observable<User?> = refresh.rx_tap.map {_ in return nil}
let suggestionStream = Observable.of(userStream, nilOnRefreshTapStream)
  .merge()
  .startWith(.None)

Một mảnh cuối cùng còn thiếu trong lời giải này: hàm combineLatest(f) sẽ không chạy được khi 1 trong 2 đầu vào chưa nhả ra giá trị nào cả. Nhìn lại vào ascii diagram bạn sẽ thấy stream kết quả không có phản ứng nào khi stream A lần đầu nhả ra giá trị a, và vì thế code sẽ lại chết ở thời điểm đó.

Cách giải quyết là "giả lập" một lần ấn nút "x" tại thời điểm ban đầu

let closeStream = cell.cancel.rx_tap.startWith(())

Bài toán đã giải xong, đây là tổng kết đáp án

let refreshStream = refresh.rx_tap.startWith(())
let requestStream: Observable<String> = refreshStream.map { _ in
  let random = Array(1...1000).random()
  return "https://api.github.com/users" + String(random)
}
let responseStream = requestStream.flatMap { url in 
  return UserModel().findUsers(url)
}

// Inside func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath)
let closeStream = cell.cancel.rx_tap.startWith(())
let userStream: Observable<User?> = Observable.combineLatest(closeStream, responseStream)
{ (_, users) in
  guard users.count > 0 else {return nil}
  return users.random()
}
let nilOnRefreshTapStream: Observable<User?> = refresh.rx_tap.map {_ in return nil}
let suggestionStream = Observable.of(userStream, nilOnRefreshTapStream)
  .merge()
  .startWith(.None)

suggestionStream.subscribeNext{ op in
  guard let u = op else { return self.clearCell(cell) }
  return self.setCell(cell, user: u )
}.addDisposableTo(cell.disposeBagCell)

Bạn có thể xem bản chạy đầy đủ ở Github repo.

Ví dụ này tuy nhỏ nhưng lại vừa thể hiện được rất nhiều tư tưởng của Reactive Programming. Chúng ta thao tác với nhiều sự kiện xảy ra trên UI, bắn request và thậm chí cache cả response. Viết code theo phong cách functional giúp tư tưởng trong sáng hơn và code dễ đọc (= đẹp và ít bug) hơn. Chúng ta không còn viết ra một tập các lệnh cụ thể cho máy tính làm theo từng bước một nữa, chúng ta chỉ nói cho máy tính cách làm, rằng "hãy hành xử thế này nếu thằng kia/con kia phản ứng thế kia" v.v...

Code trong Reactive Programming không còn sự xuất hiện của các câu điều khiển kiểu như if, else, for, while hay phong cách callback nữa. Nếu thực sự bạn muốn phân nhánh, bạn có thể dùng filter v.v... Reactive Programming = More power in less code :smile:

Nếu bạn đã cảm thấy hứng thú với RxSwift và có ý định tiến xa hơn để dùng trong nghiệp IOS của mình, hãy đọc sang RxSwift API - API cụ thể của bộ toolbox để biến đổi hay kết hợp Observables. Nếu bạn muốn hiểu các method hoạt động cụ thể trên dòng sự kiện ra sao, hãy ngó sang Marble diagrams. của RxJava. Khi nào bí thì vẽ lại diagram, nghĩ kỹ xem mình muốn làm gì từ đây =) , đọc lại danh sách các method và chọn trong số có sẵn. Với kinh nghiệm của mình thì cách này khá hiệu quả.

Khi bạn đã dần quen với RxSwift, bạn sẽ (và chắc chắn) cần tìm hiểu các bộ thư viện hay được "dùng kèm" như RxCocoa, Moya/RxSwift hay RxDataSources, rồi cả Driver. Cuối cùng, hãy dành thời gian học một ngôn ngữ functional "thực thụ" và làm quen với cách suy nghĩ về immutable hay side effect và hay gặp trong Reactive.

  • Sample app
  • Observables là 1 monad
  • The introduction to Reactive Programming you've been missing by @andrestaltz
  • Code mẫu bằng RxJS (a Promise is an Observable! !)
0