9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NewsPicksAdvent Calendar 2023

Day 11

TCAでGithubリポジトリ検索アプリを作ってみよう②

Last updated at Posted at 2023-12-10

本記事およびサンプルコードはTCA 1.5.0を使用しています

はじめに

TCAでGithubリポジトリ検索アプリを作ってみよう①の続きです。

前回はプロジェクト構成やAPIクライアントの設計の話まででTCAについては特に触れてませんでしたが、今回はいよいよTCAで作った画面の実装の解説をします。


ちなみに、この記事は NewsPicks アドベントカレンダー 2023 の11日目の記事です。
NewsPicksの他の技術記事も面白いので是非読んでみてください!

リポジトリリストの表示

最初に、リポジトリリストの表示機能について見ていきます。

リポジトリリスト表示機能は、リスト表示に関するロジックを持つSearchRepositoriesReducerと、リストの各行(アイテム)に関するロジックを持つSearchRepositoriesItemReducerで構成されています。
親であるSearchRepositoriesReducerに対して、子であるSearchRepositoriesItemReducerが複数存在するという関係です。

これをStateで表現すると以下のようになります。

@Reducer
public struct SearchRepositoriesReducer: Reducer, Sendable {
    public struct State: Equatable {
        var items = IdentifiedArrayOf<RepositoryItemReducer.State>()
    }

    // ...
}

IdentifiedArrayはArrayよりも効率的かつ安全にアイテムのコレクションを扱えるように設計されたAPIです。
TCAにおいてリストのアイテムをモデリングするときは基本的にIdentifiedArrayを使うことになるでしょう。
IdentifiedArrayOf<Element>IdentifiedArray<Element.ID, Element>のタイプエイリアスです。

子Reducerを親Reducerに統合するため、親Reducerに以下の実装を加えます。

@Reducer
public struct SearchRepositoriesReducer: Reducer, Sendable {
    // ...

    public enum Action: Sendable {
        case items(IdentifiedActionOf<RepositoryItemReducer>)
    }

    public var body: some ReducerOf<Self> {
        Reduce { state, action in
            // ...
        }
        .forEach(\.items, action: \.items) {
            RepositoryItemReducer()
        }
    }
}

次に、ビューの実装を見ていきます。
SwiftUI.Listを使ってリストを表示します。

ForEachStoreはTCAが提供するSwiftUI.ForEachのラッパーAPIで、アイテムのドメインにスコープしたStore(StoreOf<RepositoryItemReducer>)を生成してくれます。
生成したStoreをリストアイテムのビューであるRepositoryItemViewに渡します。

public struct SearchRepositoriesView: View {
    WithViewStore(store, observe: { $0 }) { viewStore in
        List {
            ForEachStore(store.scope(
                state: \.items,
                action: \.items
            )) { itemStore in
                RepositoryItemView(store: itemStore)
            }
        }
    }
}

リスト表示部分だけを抜き出すとこんな感じです。

検索クエリを入力してGithubAPIにリクエストする

本サンプルアプリは任意の検索クエリを入力してGuthubリポジトリを検索できる機能を持っています。

検索バーに文字を入力すると、SwiftUIのBindingを通じてアクションが送信され、Reducerは入力されたクエリを使ってGithub APIにリクエストを行います。

検索バーはSwiftUIのsearchable(text:placement:prompt:)モディファイアを使って表示します。
NavigationStackStoreについてはいずれ触れますが、SwiftUIのNavigationStackを内部で実装している、TCAが提供するビューです。
NavigationStack内のビューにsearchable(text:placement:prompt:)モディファイアを追加することで、検索バーが自動で追加されます。

NavigationStackStore(store.scope(state: \.path, action: \.path)) {
    WithViewStore(store, observe: { $0 }) { viewStore in
        List {
            // ...
        }
        .searchable(text: viewStore.$query)
    }
}

searchable(text:placement:prompt:)モディファイアにはBindingを渡しますが、ViewStoreからBindingを派生する方法には2種類あります。

  1. ViewStoreのbinding(get:send:)メソッドを使う
  2. @BindingStateを使う

1のbinding(get:send:)メソッドを使う方法は、ボイラープレートが多く冗長になりがちです。
具体的なコードで示しましょう。
まず、Stateに検索クエリ文字列を管理するqueryプロパティを定義します。

@Reducer
public struct SearchRepositoriesReducer: Reducer, Sendable {
    public struct State: Equatable {
        // ...
        var query = ""  // 追加
    }
}

TCAにおいては、Stateはアクションを通じてしか更新することができません。
Bindingでもそれは同じで、queryプロパティを更新するためのアクションが必要です。
ここではqueryChanged(String)というアクションを実装しましょう。

ReducerはqueryChanged(String)アクションを受け取ったらStateを更新します。

@Reducer
public struct SearchRepositoriesReducer: Reducer, Sendable {
    // ...
    public enum Action: Sendable {
        // ...
        case queryChanged(String)  // 追加
    }

    public var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case let .queryChanged(newValue):
                state.query = newValue  // queryを更新
                return .none
            }
        }
    }
}

ビューでは、binding(get:send:)メソッドを使ってBindingを作成します。

.searchable(text: viewStore.binding(get: \.query, send: { .queryChanged($0) }))

queryプロパティを更新するためだけに、ActionとReducerの単純なコードの追加が必要です。
プロパティが一つだけだと気になりませんが、数が増えてくるとボイラープレートが増え、Reducerのコードが冗長になります。

そこで2の@BindingStateの出番です。
@BindingStateはまさにこのボイラープレートを無くすために登場しました。

まず、queryプロパティに@BindingStateを追加します。

@Reducer
public struct SearchRepositoriesReducer: Reducer, Sendable {
    public struct State: Equatable {
        // ...
        @BindingState var query = ""  // @BindingStateを追加
    }
}

次に、ActionをBindableActionプロトコルに準拠させます。
このプロトコルに準拠するためにはbinding(BindingAction<State>)というケースの追加が必要です。

そして、bodyにBindingReducerを追加します。
BindingReducerはBindingを通じて送信されたアクションを受信し、@BindingStateがついたプロパティの値を更新してくれます。
これによってプロパティ更新のコードを自分で書く必要がなくなります。

@Reducer
public struct SearchRepositoriesReducer: Reducer, Sendable {
    // ...
    public enum Action: BindableAction, Sendable {  // BindableActionに準拠
        // ...
        case binding(BindingAction<State>)  // 追加
    }

    public var body: some ReducerOf<Self> {
        BindingReducer()  // 追加
        Reduce { state, action in
            switch action {
            // ...
            }
        }
    }
}

ビューでは以下のように$付きでプロパティを参照することで、ビューにBindingを渡すことが出来ます。

.searchable(text: viewStore.$query)

ここまでで、検索バーに検索クエリを入力することでStateを更新できるようになりました。
次は、検索クエリの入力を検知してGithub APIにリクエストを送信する部分を見ていきます。

以下のように実装すると、検索クエリが入力された直後に何か処理を行うことが出来ます。

@Reducer
public struct SearchRepositoriesReducer: Reducer, Sendable {
    // ...
    public var body: some ReducerOf<Self> {
        BindingReducer()
        Reduce { state, action in
            switch action {
            case .binding(\.$query):
                // ...検索クエリが入力されたときに実行されるロジック
            }
        }
    }
}

検索バーに文字を入力するごとにGithub APIにリクエストを送信するコードは以下のようになります。

@Reducer
public struct SearchRepositoriesReducer: Reducer, Sendable {
    // ...
    public var body: some ReducerOf<Self> {
        BindingReducer()
        Reduce { state, action in
            switch action {
            case .binding(\.$query):
                // 検索クエリが入力されるたびにここが実行される
                return .run { [query = state.query, page = state.currentPage] send in
                    await send(.searchReposResponse(Result {
                        try await githubClient.searchRepos(query: query, page: page)
                    }))
                }
            }
        }
    }
}

Debounce

検索バーに1文字追加するごとに検索処理が行われるようなUIはかえって鬱陶しいです。
ユーザが検索したい文言を入力し終わるのを一定時間待つことができると、使いやすいUIになります。
このような振る舞いをDebounceと言います。

Debounceを実装するのは非常に簡単です。
まず、ReducerにmainQueueをDIします。

@Reducer
public struct SearchRepositoriesReducer: Reducer, Sendable {
    // ...
    @Dependency(\.mainQueue) var mainQueue
}

mainQueueの実体はAnySchedulerOf<DispatchQueue>.mainで、簡単に言うとメインスレッド上で処理の実行タイミングを制御できるAPIです。
Debounceだったり、一定間隔で何か処理をするようなタイマー的な使い方をすることもできます。

Debounceの実装は、以下のようにEffectのdebounce(id:for:scheduler:options)メソッドを呼び出してあげるだけです。

@Reducer
public struct SearchRepositoriesReducer: Reducer, Sendable {
    // ...

    // 追加
    enum CancelId { case searchRepos }
    
    public var body: some ReducerOf<Self> {
        BindingReducer()
        Reduce { state, action in
            switch action {
            case .binding(\.$query):
                return .run { [query = state.query, page = state.currentPage] send in
                    await send(.searchReposResponse(Result {
                        try await githubClient.searchRepos(query: query, page: page)
                    }))
                }
                // 追加
                .debounce(id: CancelId.searchRepos, for: 0.3, scheduler: mainQueue)
            }
        }
    }
}

0.3を指定していますが、これは「検索バーに文字が入力されてから0.3秒間次の文字の入力がなかったらGithub APIにリクエストを送信する」という意味になります。
なお、すでに実行中のEffect(APIリクエスト)があったらそれをキャンセルし、新しいEffectを実行します。

CancelId.searchReposは、Effectをキャンセルするときに対象のEffectを特定するために使用されるIDです。
IDはHashableに準拠している必要があり、上記のようにenumで実装する方法がTCAのドキュメントで示されています。

ビューの無駄な再描画を防ぐ

リスト表示部分のコードを改めて見てみましょう。

public struct SearchRepositoriesView: View {
    WithViewStore(store, observe: { $0 }) { viewStore in
        List {
            ForEachStore(store.scope(
                state: \.items,
                action: \.items
            )) { itemStore in
                RepositoryItemView(store: itemStore)
            }
        }
    }
}

WithViewStoreが以下のように実装されています。

WithViewStore(store, observe: { $0 }) { viewStore in
    // ...
}

引数observe{ $0 }が渡されていますが、これはSearchRepositories.Stateの全てのプロパティの変更を検知してビューを再描画するという指定になります。
SwiftUIにおいてビューの再描画はパフォーマンス悪化につながるため、なるべく避けなければなりません。

例えば本サンプルアプリでは、リストを下にスクロールしていくと、リストの最後のアイテムが表示されたタイミングでページング処理が実行されます。
具体的には、アイテムが表示されたというアクションが送信され、それがリストの最後のアイテムかつ次のページが存在するという条件を満たすと、Github APIに次のページをリクエストします。
このとき、ページング処理の状態を表すStateのプロパティが更新されます。

ビューでStateの全てのプロパティを監視していると、ページング処理が実行されるときにStateが更新されるためビュー全体が再描画されます。
スクロール操作中にビュー全体が再描画されることになるので、スクロール操作がカクついてしまいます。

そこで、ビューに必要なプロパティのみを保持するViewStateという構造体を定義します。

public struct SearchRepositoriesView: View {
    // ...

    // 追加
    struct ViewState: Equatable {
        @BindingViewState var query: String
    }

    // 追加
    init(store: BindingViewStore<SearchRepositoriesReducer.State>) {
        self._query = store.$query
    }

    // 監視対象をStateからViewStateに変換
    WithViewStore(store, observe: ViewState.init) { viewStore in
        // ...
    }
}   

上記の例では、SearchRepositoriesViewは検索クエリが更新されるときに限って再描画を行います。
ページング処理でStateが更新されてもビューの再描画は行われないので、スクロールパフォーマンスに影響することがありません。

このテクニックはTCAの公式ドキュメントでも紹介されています。

上記の例では、@BindingViewStateBindingViewStoreという見慣れない型が使われています。
これらは@BindingStateがついたStateのプロパティをViewStateで扱うときに必要なります。

SearchRepositoriesReducer.Stateのqueryプロパティには@BindingStateをつけていましたね。
ViewStateでこのプロパティを扱いたいときに上記のような書き方をする必要があります。

@Reducer
public struct SearchRepositoriesReducer: Reducer, Sendable {
    public struct State: Equatable {
        // ...
        @BindingState var query = ""
    }
}

続く...

検索バーに検索クエリを入力し、リポジトリのリストを表示する部分の実装について解説しました。

次の記事ではXcode Previewsについて解説する予定です。
お楽しみに!

9
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?