Refreshable API を TCA で使う

8.4K Views

November 13, 21

スライド概要

profile-image

iOS エンジニアをやっています。

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

関連スライド

各ページのテキスト
1.

を Refreshable API TCA iOSアプリ開発のためのFunctional Architecture情報共有会5 で使う

2.

とは Refreshable API iOS 15 から利用できるようになった View Modifier SwiftUI なら簡単に Pull to Refresh を実現できる `refreshable` を利用するだけ これが取る closure は async な処理を要求する List(mailbox.conversations) { ConversationCell($0) } .refreshable { await mailbox.fetch() } 2

3.

ではどのように を利用できるか TCA refreshable TCA では v0.23.0 から `ViewStore.send(_:while:)` というものが導入されている(現在時点では Beta ) これを refreshable 内で利用すると TCA で refreshable が上手く扱える 「Async Refreshable: Composable Architecture」という Point-Free 内のエピソードをもとに、 TCA でどのように refreshable が扱えるか見ていこうと思います 3

4.

Refreshable with TCA を理解するために利用する例 4

5.
[beta]
API Client

的部分

struct FactClient {
var fetch: (Int) -> Effect<String, Error>
struct Error: Swift.Error, Equatable {}
}
extension FactClient {
static let live = Self(
fetch: { number in
URLSession.shared.dataTaskPublisher(
for: URL(string: "http://numbersapi.com/\(number)/trivia")!
)
.map { data, _ in String(decoding: data, as: UTF8.self) }
.catch { _ in
Just("\(number) is a good number Brent")
.delay(for: 1, scheduler: DispatchQueue.main)
}
.setFailureType(to: Error.self)
.eraseToEffect()
}
)
}

5

6.
[beta]
State, Action, Environment
struct PullToRefreshState: Equtable {
var count = 0
var fact: String?
}
enum PullToRefreshAction: Equatable {
case cancelButtonTapped
case decrementButtonTapped
case incrementButtonTapped
case refresh
case factResponse(Result<String, FactClient.Error>)
}
struct PullToRefreshEnvironment {
var fact: FactClient
var mainQueue: AnySchedulerOf<DispatchQueue>
}

6

7.
[beta]
Reducer
refreshReducer = Reducer<PullToRefreshState, PullToRefreshAction, PullToRefreshEnvironment>
{ state, action, environment in
struct CancelId: Hashable {}
switch action {
case .decrementButtonTapped:
state.count -= 1
return .none
case .incrementButtonTapped:
state.count += 1
return .none
case let .factResponse(.success(fact)):
state.fact = fact
return .none
case .factResponse(.failure):
return .none // TODO:
case .refresh:
return environment.fact.fetch(state.count)
.receive(on: environment.mainQueue)
.catchToEffect(PullToRefreshAction.factResponse)
.cancellable(id: CancelId())
case .cancelButtonTapped:
return .cancel(id: CancelId())
}
}

エラーハンドリング

7

8.
[beta]
View(store

宣言部分)

struct PullToRefreshView: View {
let store: Store<PullToRefreshState, PullToRefreshAction>

}

var body: some View {
// ...
}

8

9.

View(body) var body: some View { WithViewStore(self.store) { viewStore in List { HStack { Button("-") { viewStore.send(.decrementButtonTapped) } Text("\(viewStore.count)") Button("+") { viewStore.send(.incrementButtonTapped) } } .buttonStyle(.plain) if let fact = viewStore.fact { Text(fact) } if viewStore.isLoading { Button("Cancel") { viewStore.send(.cancelButtonTapped) } } } .refreshable { viewStore.send(.refresh) } 9

10.

Preview struct PullToRefreshView_Previews: PreviewProvider { static var previews: some View { PullToRefreshView( store: .init( initialState: .init(), reducer: pullToRefreshReducer, environment: .init( fact: .live, mainQueue: .main ) ) ) } } 10

11.

実行してみる 11

12.

動作的には問題なさそうに見える?

13.

コードを少し変更してみる case .refresh: return environment.fact.fetch(state.count) .delay(for: 2, scheduler: environment.mainQueue) .catchToEffect(PullToRefreshAction.factResponse) .cancellable(id: CancelId()) 13

14.

通信が完了してないのに indicator が消えてしまう 14

15.

何が問題なのか は closure に async な処理を要求する 提供された非同期な処理が実行されている限り loading indicator が留まるというものになっている 現在実装している `viewStore.send(.refresh)` は async ではない同期的な処理 TCA でこの問題を解決するためには少し工夫する必要がある `refreshable` View Modifier 15

16.

State に isLoading を導入 struct PullToRefreshState: Equatable { var count = 0 var fact: String? var isLoading = false } 16

17.

isLoading を reducer で操作 switch action { case let .factResponse(.success(fact)): state.fact = fact state.isLoading = false return .none case .factResponse(.failure): state.isLoading = false return .none case .refresh: state.isLoading = true // ... case .cancelButtonTapped: state.isLoading = false return .cancel(id: CancelId()) } 17

18.

あとは async 的に利用できる send があると良さそう こんな感じ // .refreshable { await viewStore.send(.refresh, while: \.isLoading) } 18

19.

async な send の signature はこのような形 extension ViewStore { func send( _ action: Action, `while`: (State) -> Bool ) async { // } } 実装 19

20.
[beta]
実装を考えてみる
func send(
_ action: Action,
`while`: (State) -> Bool
) async {
//
Action
self.send(action)
// ViewStore
state
self.publisher
.filter { !`while`($0) } // `while`
}

まずは何よりも
を発火させる必要がある
には全ての
の変化が流れてくる publisher があるため、それを監視する
は escaping でないためエラーが発生する

20

21.
[beta]
実装を考えてみる2
func send(
_ action: Action,
`while` isInFlight: @escaping (State) -> Bool // escaping + internal argument
) async {
self.send(action)
self.publisher
.filter { !isInFlight($0) }
.prefix(1) // isLoading
.sink { _ in
//
}
}

の変化の監視は最初のものだけ判別できれば良い

実装

ここで生じる問題点
`sink` は `cancellable` を返すがどうする?
最終的には async な task を構築する必要があるがどうする?

21

22.
[beta]
にするための

publisher -> async
Bridge
Swift はそのための Bridge となる function を用意してくれている
`withUnsafeContinuation`
non-async/await

なコードを async/await なコードに変えられる

// signature
withUnsafeContinuation(<#(UnsafeContinuation<T, Never>) -> Void#>)

使い方

//
let number = await WithUnsafeContinutation { continuation in
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
continuation.resume(returning: 42)
}
}

22

23.
[beta]
`withUnsafeContinuation`

を `send` で利用する

func send(
_ action: Action,
`while` isInFlight: @escaping (State) -> Bool
) async {
self.send(action)
await withUnsafeContinuation { continuation in
self.publisher
.filter { !isInFlight($0) }
.prefix(1)
.sink { _ in
continuation.resume()
}
}
}

23

24.
[beta]
`cancellable`

の取り扱い方

func send(
_ action: Action,
`while` isInFlight: @escaping (State) -> Bool
) async {
self.send(action)

}

var cancellable: Cancellable?
await withUnsafeContinuation { (continuation: UnsafeContinuation<Void, Never>) in //
cancellable = self.publisher
.filter { !isInFlight($0) }
.prefix(1)
.sink { _ in
continuation.resume()
_ = cancellable // strongly capture
}
}

型推論ができなくなるため型を明示

24

25.
[beta]
現在時点の Beta ディレクトリにある実装方法
func send(
_ action: Action,
while predicate: @escaping (State) -> Bool
) async {
self.send(action)
await self.suspend(while: predicate)
}
func suspend(while predicate: @escaping (State) -> Bool) async {
_ = await self.publisher
.values // AsyncPublisher<Self>
.first(where: { !predicate($0) }) // AnyCancellable
}

を返却しないため、そのための対処が必要ない

25

26.

コンパイルが通るようになる .refreshable { await viewStore.send(.refresh, while: \.isLoading) } 26

27.

cancel 時の animation がない問題がある 27

28.
[beta]
combine-schedulers

の animation 機能を使って解決

case .refresh:
state.isLoading = true
state.fact = nil
return environment.fact.fetch(state.count)
.delay(for: 2, scheduler: environment.mainQueue.animation())
// ...
// ...
Button("Cancel") {
viewStore.send(.cancelButtonTapped, animation: .default)
}

28

29.

無事 cancel 時の animation が行われるようになる 29

30.

まとめ の `refreshable` View Modifier は簡単に Pull To Refresh を表現できる `refreshable` は async な処理を要求するため、TCA で利用するためには工夫が必要 現在時点では Beta だが、TCA にはそのための `viewStore.send(_:while:)` が用意されている 発表では紹介しなかったが、TCA を利用すると非常に網羅的なテストが可能となる 網羅的なテストができることが TCA の売り 例えば State を追加したりしたら、その State の変化を検証しないとテストは失敗する 発生しうる Action も `receive` 等によって網羅する必要がある 素の SwiftUI だと以下のような部分でテストが厳しくなると述べられていた 詳しくは Point-Free の「Async Refreshable: SwiftUI」を参照して頂ければと思います🙏 API リクエストをキャンセルする際のフローがテストできない(する方法がわからない) Xcode Beta 版のバグか、Swift の custom executors を使う必要があるのかはっきりしていないらし い async な処理中の `isLoading` の変化をテストするために、テスト内で Sleep を行う必要がある SwiftUI 30