Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Speaker Deck
PRO
Sign in
Sign up for free
RxSwift + API request + MVVM
Tomohiro Moro
February 01, 2017
Programming
9
2.3k
RxSwift + API request + MVVM
Tomohiro Moro
February 01, 2017
Tweet
Share
More Decks by Tomohiro Moro
See All by Tomohiro Moro
たのしいクックパッドでのモバイルアプリエンジニア生活 / newgrads_event2020
slightair
0
460
CI/CD for mobile apps at Cookpad / Bitrise & Cookpad Developer Meetup
slightair
2
3k
コンセプトは「機械に人間が合わせる」クックパッドが実践する新しいリリースフローとは / @IT seminar 2018 12 14
slightair
0
510
動作確認のための社内アプリ配信サービスを新たに作った話 / iOSDC 2018
slightair
2
3.4k
クックパッドのiOS更新との付き合い方 / CAMPFIRE iOS #2
slightair
0
1.6k
よくわかんないけど最近つくってるゲームで RxJava つかってみてる
slightair
3
1.9k
ゲームのプレイ動画を気軽にシェアしてもらう
slightair
3
2.8k
Other Decks in Programming
See All in Programming
Step Functions Distributed Map を使ってみた
codemountains
0
110
社会人 20 年目エンジニア、発信で技術学びなおしてる話
e99h2121
1
140
Swift Concurrency in GoodNotes
inamiy
4
1.4k
良質な技術記事を量産する秘訣 / #MeetsPro
jnchito
12
4k
Hatena Engineer Seminar #23「新卒研修で気軽に『ありがとう』を伝え合える Slack アプリを開発した話」
slashnephy
0
350
Micro Frontends with Module Federation @MicroFrontend Summit 2023
manfredsteyer
PRO
0
580
僕が考えた超最強のKMMアプリの作り方
spbaya0141
0
180
Listかもしれない
irof
1
280
domain層のモジュール化 / MoT TechTalk #15
mot_techtalk
0
120
What's new in Shopware 6.5
shyim
0
110
Remix + Cloudflare Pages + D1 で ポケモン SV のレンタルチームを検索できるアプリを作ってみた
kuroppe1819
4
1.4k
Makuakeの認証基盤とRe-Architectureチーム
bmf_san
0
610
Featured
See All Featured
Done Done
chrislema
178
14k
The Pragmatic Product Professional
lauravandoore
21
3.4k
Keith and Marios Guide to Fast Websites
keithpitt
407
21k
Debugging Ruby Performance
tmm1
67
11k
What the flash - Photography Introduction
edds
64
10k
How GitHub Uses GitHub to Build GitHub
holman
465
280k
I Don’t Have Time: Getting Over the Fear to Launch Your Podcast
jcasabona
13
1.1k
Intergalactic Javascript Robots from Outer Space
tanoku
261
26k
A designer walks into a library…
pauljervisheath
199
16k
Web development in the modern age
philhawksworth
197
9.6k
The Brand Is Dead. Long Live the Brand.
mthomps
48
2.9k
GitHub's CSS Performance
jonrohan
1020
430k
Transcript
RxSwift + API request + MVVM Rxษڧձ@Cookpad 2017.2.1 @slightair
API Request
APIKit + Himotoki IUUQTHJUIVCDPNJTILBXB"1*,JU IUUQTHJUIVCDPNJLFTZP)JNPUPLJ
APIKit ͱ Himotoki Ͱ Reddit ͷ API ͷϦΫΤετ Λఆٛ͢Δ
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) } }
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") } }
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) } } ϦΫΤετૹ৴
APIKit + RxSwift IUUQTHJUIVCDPN3FBDUJWF93Y4XJGU
APIKit Ͱૹ৴ͨ͠ϦΫΤετ ͷϨεϙϯεΛ Observable Ͱฦ͢Α͏ͳ extension Λ Ճ͢Δ
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() } } } }
let request = RedditAPI.UserRequest() Session.shared.rx.response(request) // -> Observable<RedditUser> .subscribe(onNext: {
user in }, onError: { error in }, onCompleted: { }) .addDisposableTo(disposeBag)
Request with AccessToken
ผ్औಘͨ͠ AccessToken Λϔομʹ ༩ͯ͠ ϦΫΤετΛૹ৴͢Δ
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 } }
AccessToken ͕ࣦޮ͢Δͱ…
SessionTaskError.responseError(ResponseError.unacceptableStatusCode(401)) 401 Unauthorized
AccessToken ͕ ࣦޮ͍ͯͨ͠Β ৽͍͠τʔΫϯΛऔಘͯ͠ લճͷϦΫΤετΛ ૹ৴ͯ͠͠ཉ͍͠
retry / retryWhen IUUQSFBDUJWFYJPEPDVNFOUBUJPOPQFSBUPSTSFUSZIUNM
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>
ͦΕͧΕͷϦΫΤετΛ ૹ৴͍ͯ͠ΔՕॴͰͳ͘ extension ͰՃͨ͠ ϝιουʹ࠶ൃߦͷॲཧΛ ͯ͠͠·͏
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>
AccessToken ͷࣦޮΛ ؾʹ͠ͳͯ͘ྑ͘ͳͬͨ
MVVM
Reddit ͷ৽ணҰཡΛ ද͍ࣔͨ͠
None
{ "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
Pull to refresh Ͱ৽ணΛ Ұ൪Լʹ͍ͭͨΒաڈΛ औಘ͍ͨ͠
None
before / after ΫΤϦύϥϝʔλͰߋ৽͕ औಘͰ͖ΔͷͰͦΕΛ͏
هࣄҰཡΛऔಘ͢Δ Listing Request Λఆٛ͢Δ
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 }
৽ணهࣄΛऔಘ͢Δ NewArticleListRequest Λఆٛ͢Δ
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 } } }
ViewModel
input & output • input • refresh • loadBefore •
loadAfter • output • articles
ͦΕͧΕͷૢ࡞ͷೖྗΛ ͖͔͚ͬͱͯ͠هࣄҰཡΛ ྲྀ͢ Observable Λ ΈཱͯΔ
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()
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) } }
Dependency
ϓϩτίϧΛఆٛͯ͠ ґଘೖ Ͱ͖ΔΑ͏ʹ͓ͯ͘͠
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) } }
ViewController
UIίϯϙʔωϯτͷઃఆͱ ViewModel ͷ ೖྗɾग़ྗͷଓΛߦ͏
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) } }
RxTest
࡞ͬͨ ViewModel Λ ςετ͍ͨ͠
TestScheduler
͍ͭ(Ծ࣌ؒ) ͳʹ͕ ετϦʔϜʹྲྀΕͨͷ͔ ఆٛͰ͖Δ
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)
Mock dependency
ςετͷͨΊʹ ࣮ΛϞοΫʹࠩ͠ସ͑Δ
API ͷϦΫΤετͱ ϨεϙϯεΛࣗ༝ʹ ઃఆͰ͖ΔΑ͏ͳ MockClient Λఆٛ͢Δ
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) } }
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 )
Test ViewModel
ࡐྉ͕ἧͬͨͷͰΈཱͯΔ
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, ()), ])
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"), ])), ]
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 )
let recordedArticles = scheduler.record(source: viewModel.articles.map { EquatableArray($0) }) scheduler.start() XCTAssertEqual(recordedArticles.events,
expectedArticlesEvents) } }
✌
Tips
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--
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
More samples • https://github.com/slightair/akane