Upgrade to Pro — share decks privately, control downloads, hide ads and more …

RxSwift + API request + MVVM

Tomohiro Moro
February 01, 2017

RxSwift + API request + MVVM

Tomohiro Moro

February 01, 2017
Tweet

More Decks by Tomohiro Moro

Other Decks in Programming

Transcript

  1. RxSwift + API request + MVVM Rxษڧձ@Cookpad 2017.2.1 @slightair

  2. API Request

  3. APIKit + Himotoki IUUQTHJUIVCDPNJTILBXB"1*,JU IUUQTHJUIVCDPNJLFTZP)JNPUPLJ

  4. APIKit ͱ Himotoki Ͱ Reddit ͷ API ͷϦΫΤετ Λఆٛ͢Δ

  5. protocol RedditAPIRequest: Request {} extension RedditAPIRequest { var baseURL: URL

    { return URL(string: "https://oauth.reddit.com")! } } extension RedditAPIRequest where Response: Decodable { func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response { return try Response.decodeValue(object) } }
  6. extension RedditAPI { struct UserRequest: RedditAPIRequest { typealias Response =

    RedditUser let method: HTTPMethod = .get let path: String = "/api/v1/me" } } struct RedditUser { let name: String } extension RedditUser: Decodable { static func decode(_ e: Extractor) throws -> RedditUser { return try RedditUser(name: e <| "name") } }
  7. let request = RedditAPI.UserRequest() let task = Session.send(request) { result

    in switch result { case .success(let response): print(response) case .failure(let error): print(error) } } ϦΫΤετૹ৴
  8. APIKit + RxSwift IUUQTHJUIVCDPN3FBDUJWF93Y4XJGU

  9. APIKit Ͱૹ৴ͨ͠ϦΫΤετ ͷϨεϙϯεΛ Observable Ͱฦ͢Α͏ͳ extension Λ ௥Ճ͢Δ

  10. extension Session: ReactiveCompatible {} extension Reactive where Base: Session {

    func response<T: Request>(_ request: T) -> Observable<T.Response> { return Observable.create { [weak base] observer in let task = base?.send(request) { result in switch result { case .success(let response): observer.onNext(response) observer.onCompleted() case .failure(let error): observer.onError(error) } } return Disposables.create { task?.cancel() } } } }
  11. let request = RedditAPI.UserRequest() Session.shared.rx.response(request) // -> Observable<RedditUser> .subscribe(onNext: {

    user in }, onError: { error in }, onCompleted: { }) .addDisposableTo(disposeBag)
  12. Request with AccessToken

  13. ผ్औಘͨ͠ AccessToken Λϔομʹ ෇༩ͯ͠ ϦΫΤετΛૹ৴͢Δ

  14. extension RedditAPIRequest { var baseURL: URL { return URL(string: "https://oauth.reddit.com")!

    } var headerFields: [String : String] { var fields = [ "User-Agent": RedditAPI.userAgent, ] if let accessToken = RedditDefaultService.shared.accessToken { fields["Authorization"] = "Bearer \(accessToken)" } return fields } }
  15. AccessToken ͕ࣦޮ͢Δͱ…

  16. SessionTaskError.responseError(ResponseError.unacceptableStatusCode(401)) 401 Unauthorized

  17. AccessToken ͕ ࣦޮ͍ͯͨ͠Β ৽͍͠τʔΫϯΛऔಘͯ͠ લճͷϦΫΤετΛ ૹ৴͠௚ͯ͠ཉ͍͠

  18. retry / retryWhen IUUQSFBDUJWFYJPEPDVNFOUBUJPOPQFSBUPSTSFUSZIUNM

  19. let request = RedditAPI.UserRequest() Session.shared.rx .response(request) .retryWhen { (errors: Observable<Error>)

    in return errors.flatMapWithIndex { error, retryCount -> Observable<RedditCredential> in if case SessionTaskError.responseError(ResponseError .unacceptableStatusCode(401)) = error, retryCount < 1 { return service.refreshAccessToken() // -> Observable<RedditCredential> } return Observable.error(error) } } // -> Observable<RedditUser>
  20. ͦΕͧΕͷϦΫΤετΛ ૹ৴͍ͯ͠ΔՕॴͰ͸ͳ͘ extension Ͱ௥Ճͨ͠ ϝιουʹ࠶ൃߦͷॲཧΛ ଍ͯ͠͠·͏

  21. extension Reactive where Base: Session { func response<T: Request>(_ request:

    T, refreshAccessTokenWhenExpired: Bool = true, service: RedditService = RedditDefaultService.shared) -> Observable<T.Response> { return Observable.create { [weak base] observer in let task = base?.send(request) { result in switch result { case .success(let response): observer.onNext(response) observer.onCompleted() case .failure(let error): observer.onError(error) } } return Disposables.create { task?.cancel() } } .retryWhen { (errors: Observable<Error>) in return errors.flatMapWithIndex { error, retryCount -> Observable<RedditCredential> in if refreshAccessTokenWhenExpired, case SessionTaskError.responseError(ResponseError.unacceptableStatusCode(401)) = error, retryCount < 1 { return service.refreshAccessToken() } return Observable.error(error) } } } } let request = RedditAPI.UserRequest() Session.shared.rx.response(request) // -> Observable<RedditUser>
  22. AccessToken ͷࣦޮΛ ؾʹ͠ͳͯ͘ྑ͘ͳͬͨ

  23. MVVM

  24. Reddit ͷ৽ணҰཡΛ ද͍ࣔͨ͠

  25. None
  26. { "kind": "Listing", "data": { "children": [ { "kind": "t3",

    "data": { "name": "t3_5qfzn8", "title": "Cat is a great goal keeper" ... } }, { "kind": "t3", "data": { "name": "t3_5qg9f9", "title": "Halo Girl, Graphite, A6" ... } }, ... IUUQTXXXSFEEJUDPNEFWBQJ
  27. Pull to refresh Ͱ৽ண෼Λ Ұ൪Լʹ͍ͭͨΒաڈ෼Λ औಘ͍ͨ͠

  28. None
  29. before / after ΫΤϦύϥϝʔλͰߋ৽෼͕ औಘͰ͖ΔͷͰͦΕΛ࢖͏

  30. هࣄҰཡΛऔಘ͢Δ Listing Request Λఆٛ͢Δ

  31. enum ListingRequestKind { case refresh // GET /new case before(String)

    // GET /new?before=name case after(String) // GET /new?after=name } protocol ListingRequest: Request { var requestKind: ListingRequestKind { get } } extension ListingRequest { func response(from object: Any, urlResponse: HTTPURLResponse) throws -> ListingResponse { let elements = try decodeArray(object, rootKeyPath: ["data", "children"]) as [Article] return ListingResponse(elements: elements, requestKind: requestKind) } } struct ListingResponse { let elements: [Article] let requestKind: ListingRequestKind }
  32. ৽ணهࣄΛऔಘ͢Δ NewArticleListRequest Λఆٛ͢Δ

  33. extension RedditAPI { struct NewArticleListRequest: RedditAPIRequest, ListingRequest { typealias Response

    = ListingResponse let method: HTTPMethod = .get let path: String = "/new" let requestKind: ListingRequestKind var queryParameters: [String: Any]? { var parameters: [String: Any] = [ "limit": 25, ] switch requestKind { case .before(let name): parameters["before"] = name case .after(let name): parameters["after"] = name default: break } return parameters } } }
  34. ViewModel

  35. input & output • input • refresh • loadBefore •

    loadAfter • output • articles
  36. ͦΕͧΕͷૢ࡞ͷೖྗΛ ͖͔͚ͬͱͯ͠هࣄҰཡΛ ྲྀ͢ Observable Λ ૊ΈཱͯΔ

  37. final class NewArticleListViewModel { enum TriggerType { case refresh, before,

    after } var articles: Observable<[Article]> = Observable.empty() var firstArticleName: String? var lastArticleName: String? init( input: ( refreshTrigger: Observable<Void>, loadBeforeTrigger: Observable<Void>, loadAfterTrigger: Observable<Void> ), client: ListingClient) { let requestTrigger: Observable<TriggerType> = Observable .of( input.refreshTrigger.map { .refresh }, input.loadBeforeTrigger.map { .before }, input.loadAfterTrigger.map { .after } ) .merge()
  38. articles = requestTrigger .flatMapLatest { type -> Observable<ListingResponse> in let

    requestKind: ListingRequestKind if type == .before, let firstArticleName = self.firstArticleName { requestKind = .before(firstArticleName) } else if type == .after, let lastArticleName = self.lastArticleName { requestKind = .after(lastArticleName) } else { requestKind = .refresh } return client.loadArticles(requestKind: requestKind) } .scan([]) { articles, response in switch response.requestKind { case .refresh: return response.elements case .before: return response.elements + articles case .after: return articles + response.elements } } .do(onNext: { articles in self.firstArticleName = articles.first?.name self.lastArticleName = articles.last?.name }) .startWith([]) .shareReplay(1) } }
  39. Dependency

  40. ϓϩτίϧΛఆٛͯ͠ ґଘ஫ೖ Ͱ͖ΔΑ͏ʹ͓ͯ͘͠

  41. protocol ListingClient { func loadArticles(requestKind: ListingRequestKind) -> Observable<ListingResponse> } struct

    NewArticleListClient: ListingClient { func loadArticles(requestKind: ListingRequestKind) -> Observable<ListingResponse> { let request = RedditAPI.NewArticleListRequest(requestKind: requestKind) return Session.shared.rx.response(request) } }
  42. ViewController

  43. UIίϯϙʔωϯτͷઃఆͱ ViewModel ΁ͷ ೖྗɾग़ྗͷ઀ଓΛߦ͏

  44. class NewArticleListViewController: UIViewController { @IBOutlet weak var tableView: UITableView! let

    disposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() let refreshControl = UIRefreshControl() tableView.refreshControl = refreshControl let refreshTrigger = rx.sentMessage(#selector(viewWillAppear)).map { _ in } let loadBeforeTrigger = refreshControl.rx.controlEvent(.valueChanged) let loadAfterTrigger = tableView.rx.reachedBottom let viewModel = NewArticleListViewModel( input: ( refreshTrigger: refreshTrigger.asObservable(), loadBeforeTrigger: loadBeforeTrigger.asObservable(), loadAfterTrigger: loadAfterTrigger.asObservable() ), client: NewArticleListClient() ) viewModel.articles .bindTo(tableView.rx.items(cellIdentifier: "ArticleListCell")) { _, article, cell in cell.textLabel?.text = article.title } .addDisposableTo(disposeBag) viewModel.articles .map { _ in false } .bindTo(refreshControl.rx.isRefreshing) .addDisposableTo(disposeBag) } }
  45. RxTest

  46. ࡞ͬͨ ViewModel Λ ςετ͍ͨ͠

  47. TestScheduler

  48. ͍ͭ(Ծ૝࣌ؒ) ͳʹ͕ ετϦʔϜʹྲྀΕͨͷ͔ ఆٛͰ͖Δ

  49. let scheduler = TestScheduler(initialClock: 0) let refreshTrigger = scheduler.createHotObservable([ next(200,

    ()), next(500, ()), ]) ... let expectedArticlesEvents = [ next(0, EquatableArray([])), next(200, EquatableArray([ Article(title: "0", name: "0"), Article(title: "1", name: "1"), Article(title: "2", name: "2"), ])), ... ] let recordedArticles = scheduler.record(source: viewModel.articles.map { EquatableArray($0) }) scheduler.start() XCTAssertEqual(recordedArticles.events, expectedArticlesEvents)
  50. Mock dependency

  51. ςετͷͨΊʹ ࣮૷ΛϞοΫʹࠩ͠ସ͑Δ

  52. API ͷϦΫΤετͱ ϨεϙϯεΛࣗ༝ʹ ઃఆͰ͖ΔΑ͏ͳ MockClient Λఆٛ͢Δ

  53. protocol ListingClient { func loadArticles(requestKind: ListingRequestKind) -> Observable<ListingResponse> } struct

    MockListingClient: ListingClient { let responses: [ListingRequestKind: ListingResponse] func loadArticles(requestKind: ListingRequestKind) -> Observable<ListingResponse> { guard let response = responses[requestKind] else { return Observable.empty() } return Observable.just(response) } }
  54. let mockClient = MockListingClient(responses: [ .refresh: ListingResponse(elements: [ Article(title: "0",

    name: "0"), Article(title: "1", name: "1"), Article(title: "2", name: "2"), ], requestKind: .refresh), .before("0"): ListingResponse(elements: [ Article(title: "-3", name: "-3"), Article(title: "-2", name: "-2"), Article(title: "-1", name: "-1"), ], requestKind: .before("0")), .after("2"): ListingResponse(elements: [ Article(title: "3", name: "3"), Article(title: "4", name: "4"), Article(title: "5", name: "5"), ], requestKind: .after("2")), ]) let viewModel = NewArticleListViewModel( input: ( refreshTrigger: refreshTrigger.asObservable(), loadBeforeTrigger: loadBeforeTrigger.asObservable(), loadAfterTrigger: loadAfterTrigger.asObservable() ), client: mockClient )
  55. Test ViewModel

  56. ࡐྉ͕ἧͬͨͷͰ૊ΈཱͯΔ

  57. class NewArticleListViewModelTest: XCTestCase { func testInitialViewModel() { let scheduler =

    TestScheduler(initialClock: 0) let refreshTrigger = scheduler.createHotObservable([ next(200, ()), next(500, ()), ]) let loadBeforeTrigger = scheduler.createHotObservable([ next(300, ()), ]) let loadAfterTrigger = scheduler.createHotObservable([ next(400, ()), ])
  58. let expectedArticlesEvents = [ next(0, EquatableArray([])), next(200, EquatableArray([ Article(title: "0",

    name: "0"), Article(title: "1", name: "1"), Article(title: "2", name: "2"), ])), next(300, EquatableArray([ Article(title: "-3", name: "-3"), Article(title: "-2", name: "-2"), Article(title: "-1", name: "-1"), Article(title: "0", name: "0"), Article(title: "1", name: "1"), Article(title: "2", name: "2"), ])), next(400, EquatableArray([ Article(title: "-3", name: "-3"), Article(title: "-2", name: "-2"), Article(title: "-1", name: "-1"), Article(title: "0", name: "0"), Article(title: "1", name: "1"), Article(title: "2", name: "2"), Article(title: "3", name: "3"), Article(title: "4", name: "4"), Article(title: "5", name: "5"), ])), next(500, EquatableArray([ Article(title: "0", name: "0"), Article(title: "1", name: "1"), Article(title: "2", name: "2"), ])), ]
  59. let mockClient = MockListingClient(responses: [ .refresh: ListingResponse(elements: [ Article(title: "0",

    name: "0"), Article(title: "1", name: "1"), Article(title: "2", name: "2"), ], requestKind: .refresh), .before("0"): ListingResponse(elements: [ Article(title: "-3", name: "-3"), Article(title: "-2", name: "-2"), Article(title: "-1", name: "-1"), ], requestKind: .before("0")), .after("2"): ListingResponse(elements: [ Article(title: "3", name: "3"), Article(title: "4", name: "4"), Article(title: "5", name: "5"), ], requestKind: .after("2")), ]) let viewModel = NewArticleListViewModel( input: ( refreshTrigger: refreshTrigger.asObservable(), loadBeforeTrigger: loadBeforeTrigger.asObservable(), loadAfterTrigger: loadAfterTrigger.asObservable() ), client: mockClient )
  60. let recordedArticles = scheduler.record(source: viewModel.articles.map { EquatableArray($0) }) scheduler.start() XCTAssertEqual(recordedArticles.events,

    expectedArticlesEvents) } }
  61. Tips

  62. TestScheduler#record extension TestScheduler { func record<O: ObservableConvertibleType>(source: O) -> TestableObserver<O.E>

    { let observer = self.createObserver(O.E.self) let disposable = source.asObservable().bindTo(observer) self.scheduleAt(100000) { disposable.dispose() } return observer } } IUUQTHJUIVCDPN3FBDUJWF93Y4XJGUCMPC CGCBBDCBBECGBECD3Y&YBNQMF 3Y&YBNQMFJ045FTUT5FTU4DIFEVMFS#.BSCMF5FTUTTXJGU--
  63. EquatableArray struct EquatableArray<Element: Equatable> : Equatable { let elements: [Element]

    init(_ elements: [Element]) { self.elements = elements } } func == <E: Equatable>(lhs: EquatableArray<E>, rhs: EquatableArray<E>) -> Bool { return lhs.elements == rhs.elements } IUUQTHJUIVCDPN3FBDUJWF93Y4XJGUQVMM JTTVFDPNNFOU IUUQTHJUIVCDPN3FBDUJWF93Y4XJGUCMPC CGCBBDCBBECGBECD5FTUT3Y4XJGU5FTUT 5FTU*NQMFNFOUBUJPOT&RVBUBCMF"SSBZTXJGU
  64. More samples • https://github.com/slightair/akane