$30 off During Our Annual Pro Sale. View Details »

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

    View Slide

  2. API Request

    View Slide

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

    View Slide

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

    View Slide

  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)
    }
    }

    View Slide

  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")
    }
    }

    View Slide

  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)
    }
    }
    ϦΫΤετૹ৴

    View Slide

  8. APIKit + RxSwift
    IUUQTHJUIVCDPN3FBDUJWF93Y4XJGU

    View Slide

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

    View Slide

  10. extension Session: ReactiveCompatible {}
    extension Reactive where Base: Session {
    func response(_ request: T) ->
    Observable {
    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()
    }
    }
    }
    }

    View Slide

  11. let request = RedditAPI.UserRequest()
    Session.shared.rx.response(request) // -> Observable
    .subscribe(onNext: { user in
    }, onError: { error in
    }, onCompleted: {
    })
    .addDisposableTo(disposeBag)

    View Slide

  12. Request with AccessToken

    View Slide

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

    View Slide

  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
    }
    }

    View Slide

  15. AccessToken ͕ࣦޮ͢Δͱ…

    View Slide

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

    View Slide

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

    View Slide

  18. retry / retryWhen
    IUUQSFBDUJWFYJPEPDVNFOUBUJPOPQFSBUPSTSFUSZIUNM

    View Slide

  19. let request = RedditAPI.UserRequest()
    Session.shared.rx
    .response(request)
    .retryWhen { (errors: Observable) in
    return errors.flatMapWithIndex { error, retryCount ->
    Observable in
    if case SessionTaskError.responseError(ResponseError
    .unacceptableStatusCode(401)) = error,
    retryCount < 1 {
    return service.refreshAccessToken()
    // -> Observable
    }
    return Observable.error(error)
    }
    } // -> Observable

    View Slide

  20. ͦΕͧΕͷϦΫΤετΛ
    ૹ৴͍ͯ͠ΔՕॴͰ͸ͳ͘
    extension Ͱ௥Ճͨ͠
    ϝιουʹ࠶ൃߦͷॲཧΛ
    ଍ͯ͠͠·͏

    View Slide

  21. extension Reactive where Base: Session {
    func response(_ request: T,
    refreshAccessTokenWhenExpired: Bool = true,
    service: RedditService = RedditDefaultService.shared)
    -> Observable {
    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) in
    return errors.flatMapWithIndex { error, retryCount -> Observable 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

    View Slide

  22. AccessToken ͷࣦޮΛ
    ؾʹ͠ͳͯ͘ྑ͘ͳͬͨ

    View Slide

  23. MVVM

    View Slide

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

    View Slide

  25. View Slide

  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

    View Slide

  27. Pull to refresh Ͱ৽ண෼Λ
    Ұ൪Լʹ͍ͭͨΒաڈ෼Λ
    औಘ͍ͨ͠

    View Slide

  28. View Slide

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

    View Slide

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

    View Slide

  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
    }

    View Slide

  32. ৽ணهࣄΛऔಘ͢Δ
    NewArticleListRequest
    Λఆٛ͢Δ

    View Slide

  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
    }
    }
    }

    View Slide

  34. ViewModel

    View Slide

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

    View Slide

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

    View Slide

  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,
    loadBeforeTrigger: Observable,
    loadAfterTrigger: Observable
    ),
    client: ListingClient) {
    let requestTrigger: Observable = Observable
    .of(
    input.refreshTrigger.map { .refresh },
    input.loadBeforeTrigger.map { .before },
    input.loadAfterTrigger.map { .after }
    )
    .merge()

    View Slide

  38. articles = requestTrigger
    .flatMapLatest { type -> Observable 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)
    }
    }

    View Slide

  39. Dependency

    View Slide

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

    View Slide

  41. protocol ListingClient {
    func loadArticles(requestKind: ListingRequestKind)
    -> Observable
    }
    struct NewArticleListClient: ListingClient {
    func loadArticles(requestKind: ListingRequestKind)
    -> Observable {
    let request = RedditAPI.NewArticleListRequest(requestKind: requestKind)
    return Session.shared.rx.response(request)
    }
    }

    View Slide

  42. ViewController

    View Slide

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

    View Slide

  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)
    }
    }

    View Slide

  45. RxTest

    View Slide

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

    View Slide

  47. TestScheduler

    View Slide

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

    View Slide

  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)

    View Slide

  50. Mock dependency

    View Slide

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

    View Slide

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

    View Slide

  53. protocol ListingClient {
    func loadArticles(requestKind: ListingRequestKind) -> Observable
    }
    struct MockListingClient: ListingClient {
    let responses: [ListingRequestKind: ListingResponse]
    func loadArticles(requestKind: ListingRequestKind) -> Observable {
    guard let response = responses[requestKind] else {
    return Observable.empty()
    }
    return Observable.just(response)
    }
    }

    View Slide

  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
    )

    View Slide

  55. Test ViewModel

    View Slide

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

    View Slide

  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, ()),
    ])

    View Slide

  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"),
    ])),
    ]

    View Slide

  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
    )

    View Slide

  60. let recordedArticles = scheduler.record(source: viewModel.articles.map {
    EquatableArray($0)
    })
    scheduler.start()
    XCTAssertEqual(recordedArticles.events, expectedArticlesEvents)
    }
    }

    View Slide


  61. View Slide

  62. Tips

    View Slide

  63. TestScheduler#record
    extension TestScheduler {
    func record(source: O) -> TestableObserver {
    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--

    View Slide

  64. EquatableArray
    struct EquatableArray : Equatable {
    let elements: [Element]
    init(_ elements: [Element]) {
    self.elements = elements
    }
    }
    func == (lhs: EquatableArray, rhs: EquatableArray) -> Bool {
    return lhs.elements == rhs.elements
    }
    IUUQTHJUIVCDPN3FBDUJWF93Y4XJGUQVMM
    JTTVFDPNNFOU
    IUUQTHJUIVCDPN3FBDUJWF93Y4XJGUCMPC
    CGCBBDCBBECGBECD5FTUT3Y4XJGU5FTUT
    5FTU*NQMFNFOUBUJPOT&RVBUBCMF"SSBZTXJGU

    View Slide

  65. More samples
    • https://github.com/slightair/akane

    View Slide