Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

API Request

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

APIKit + RxSwift IUUQTHJUIVCDPN3FBDUJWF93Y4XJGU

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

Request with AccessToken

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

AccessToken ͕ࣦޮ͢Δͱ…

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

retry / retryWhen IUUQSFBDUJWFYJPEPDVNFOUBUJPOPQFSBUPSTSFUSZIUNM

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

MVVM

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

No content

Slide 26

Slide 26 text

{ "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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

No content

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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 }

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

ViewModel

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

Dependency

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

ViewController

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

RxTest

Slide 46

Slide 46 text

࡞ͬͨ ViewModel Λ ςετ͍ͨ͠

Slide 47

Slide 47 text

TestScheduler

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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)

Slide 50

Slide 50 text

Mock dependency

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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 )

Slide 55

Slide 55 text

Test ViewModel

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

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 )

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

Slide 62

Slide 62 text

Tips

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

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

Slide 65

Slide 65 text

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