LoginSignup
4
4

More than 3 years have passed since last update.

GraphQLでタスク管理アプリを作る -フロントエンド編- [iOS+Apollo-iOS]

Posted at

前書き

この記事はApollo-iOSを用いて、GraphQLを使用したTODOアプリのiOSの実装について紹介していくものです。
@ebkn さんの記事と @Climber22 さんの記事に触発されて作ったのでバックエンドの方はそちらを参考にしてもらえると

Qiita初投稿なので色々とガバガバだと思われますが何卒よろしくお願いします。

コードはこちら

Image from Gyazo
Image from Gyazo

主な技術要素

  • Apollo-iOS
  • Carthage
  • ReactorKit
  • RxSwift
  • RxDataSource
  • Swinject

Apollo

フロントエンドなので @Climber22 さんと書くことはそこまで変わりませんが
GraphQLのスキーマを元に通信部分のコードを型付きで自動生成してくれる便利なものです。
iOSだけではなくAndroid用のもあります。

開発体験

パースミスなどの簡単なミスから解放されるのとともに型があることで
コミュニケーションを綿密に取らなくても齟齬が生まれにくいのが良いところです。
またMoya等で書いていた部分が圧倒的に少なくなるのは工数がかけられない状況でも良いと感じました。

ディレクトリ構成

ディレクトリ構成は以下のようになっています。
多そうに見えますがそんなに大したことはないのと肝心なのは/Networking なので
そこだけ見てもらえば大丈夫です。

 $ tree -I "Carthage|GraphQLToDo.xcodeproj|Assets.xcassets"
.
├── Cartfile
├── Cartfile.resolved
├── CarthageInputFileList.xcfilelist
├── CarthageOutputFileList.xcfilelist
├── GraphQLToDo
│   ├── AppDelegate.swift
│   ├── Base.lproj
│   │   └── LaunchScreen.storyboard
│   ├── CellModel
│   │   ├── CellItem.swift
│   │   └── SectionModel.swift
│   ├── Component
│   │   ├── TaskCell.swift
│   │   ├── TaskCell.xib
│   │   └── TaskCellRector.swift
│   ├── Coordinator
│   │   ├── CoordinatorProtocol.swift
│   │   └── TaskCoordinator.swift
│   ├── DI
│   │   └── AppAssembly.swift
│   ├── Entities
│   │   └── Pagination.swift
│   ├── Extension
│   │   ├── Apollo+Rx.swift
│   │   ├── TaskFields+Equatable.swift
│   │   ├── UIScrolView+ReachBottom.swift
│   │   ├── UIScrolView+TouchBegan.swift
│   │   ├── UIViewController+instantiate.swift
│   │   └── UIViewControllerLifecycle+Rx.swift
│   ├── Foundation
│   │   └── DataFormatters.swift
│   ├── Info.plist
│   ├── Networking
│   │   ├── API.swift
│   │   ├── Repository
│   │   │   ├── ApiType.swift
│   │   │   ├── GraphQLApiType.swift
│   │   │   ├── GraphQLProviderType.swift
│   │   │   ├── GraphQLRepositoryType.swift
│   │   │   └── TaskRepository.swift
│   │   └── Schema
│   │       ├── Common.graphql
│   │       └── Task.graphql
│   ├── Presentable
│   │   └── TaskPresentable.swift
│   ├── Reactor
│   │   ├── CreateTaskViewReactor.swift
│   │   ├── TaskViewReactor.swift
│   │   └── TasksViewReactor.swift
│   ├── SceneDelegate.swift
│   ├── Service
│   │   └── TaskService.swift
│   ├── Store
│   │   └── AccessibleApolloStore.swift
│   ├── Storyboard
│   │   ├── CreateTask.storyboard
│   │   ├── Task.storyboard
│   │   └── Tasks.storyboard
│   ├── ViewController
│   │   ├── CreateTaskViewController.swift
│   │   ├── TaskViewController.swift
│   │   └── TasksViewController.swift
│   └── schema.json
├── GraphQLToDoTests
│   ├── GraphQLToDoTests.swift
│   └── Info.plist
├── GraphQLToDoUITests
│   ├── GraphQLToDoUITests.swift
│   └── Info.plist
├── Makefile
└── README.md

大まかな流れ

  • Apollo等のインストール
  • スキーマを書く
  • jsonの生成
  • ビルドでSwiftのコードの生成
  • 通信部分の実装
  • ViewとかViewModelとか という流れになります。 それでは最初のインストールからやってきます。

1. install

まずはインストールから
今回はライブラリの管理にCarthageを使用しているのでそれを使ってインストールします。
Apollo-iOSはCarthage/Cocoapods/Swift Package Managerの3つに対応しているので
どれを使ってインストールしても大丈夫ですが、最近盛り上がっているのとビルド速度でCarthageにしました
今回はMakefileを作ってあるので、makeしてもらえれば特に問題ないと思います。

2. スキーマを書く

次にバックエンドのスキーマを元にクライアント側のスキーマも書きます。
まずはバックエンドに対応しているQueryとMutationを叩くのを作ります
fragmentでレスポンスをまとめるのに関してはテストを考えるとできる限り書いた方がいいと思います。
可読性的にも向上すると思いますし。
また、コード生成をした時にstructでちゃんと精製してくれるので扱いやすいです。
ファイルの分け方としては意味単位で分けるのが良さげな気がします。
なので今回はTaskに関するものはTask.graphqlにPaginationなどの普遍的なものはCommon.graphqlに分離しています。

Task.graphql
fragment TaskFields on Task{
    id
    title
    notes
    completed
    due
}

query Tasks($input: TasksInput!, $orderBy: TaskOrderFields!, $page: PaginationInput!) {
    tasks(input: $input, orderBy: $orderBy, page: $page) {
        pageInfo {
            ...PageInfoFields
        }
        edges {
            cursor
            node {
                ...TaskFields
            }
        }
    }
}

mutation CreateTask($input: CreateTaskInput!) {
    createTask(input: $input) {
        ...TaskFields
    }
}

mutation UpdateTask($input: UpdateTaskInput!) {
    updateTask(input: $input) {
        ...TaskFields
    }
} 
Common.graphql
fragment PageInfoFields on PageInfo {
    endCursor
    hasNextPage
}

3. JSONの生成

Apollo-iOSだけではなくApolloClientにおいてはコード生成のためにapollo-toolingを使用しています。
これは先ほど作った.graphqlファイルからいろんな言語のコードを精製してくれたり、
サーバーからスキーマをダウンロードできたりするツールです。
しかしながらコード生成には先ほど書いたようなクライアントのスキーマとサーバーからダウンロードしてきたJSONの2つが必要なので
実際にapollo-toolingを使ってダウンロードしていきたいと思います。
まずはインストールからということで

$ npm install apollo

でインストールしてください。 -gはお好みで。
次は @ebkn さんが用意してくれた こちらをmakeして起動します。
そしたら/Networking下で次のコマンドを実行します。

$ apollo schema:download schema.json --endpoint http://localhost:3000/graphql

これでコード生成に必要なJSONがダウンロードされました。
このJSONには型定義がまとめられています
今回は認証がないのでこれでダウンロードできますが、認証がある場合は

$ apollo schema:download schema.json --endpoint http://localhost:3000/graphql --header "Authorization: Bearer <token>"

という形になります

4. ビルド設定

クライアント側のスキーマとコード生成に必要なJSONが揃ったので、Swiftのコードが生成されるようにビルドの設定をしていきます。
これはapolloのドキュメントのをコピペすれば大丈夫です、
ただ生成されるAPI.swiftのパスと、参照するスキーマとJSONのパスは自由に変えられるので
今回は以下のようにしています。

"${SCRIPT_PATH}"/run-bundled-codegen.sh codegen:generate --target=swift --includes=./Networking/Schema/*.graphql --localSchemaFile="./schema.json" ./Networking/API.swift

内部的にはコンパイルされる前にapokko-toolingSwiftのコードを生成するスクリプトを動かしています。
これで生成されるAPI.swiftは一番最初はXcodeに認識されてないのでAdd fileで追加しておいてください。

5. 通信部分の実装

5.1 ApolloClientの生成

ここから実際にswiftのコードを書いていきます。
まずは通信に必要なApolloClientのインスタンスを生成するところから
URLの文字列はBundleから呼び出すようにしています。

GraphQLProviderType.swift
    var baseURL: URL {
        guard
            let baseApiString = Bundle.main.object(forInfoDictionaryKey: "BaseAPI") as? String,
            let apiURL = URL(string: baseApiString)
            else {
                fatalError("BaseAPI is unavailable")
        }
        return apiURL
    }

    lazy var client: ApolloClient = {
        let networkTransport = HTTPNetworkTransport(url: baseURL)
        let client = ApolloClient(networkTransport: networkTransport)
        client.cacheKeyForObject = { $0["id"] }
        return client
    }()

クライアントの生成はコードを見てもらってもわかるように非常に簡単でヘッダーやURL、使用する通信プロトコル等を指定する
NetworkTransportをClientに渡すだけです。今回はHTTPNetworkTransportを使用していますが
他にもWebSocketを利用するWebSocketTransport
一部分にのみWebSocketを使うSplitNetworkTransportの2種類があります。
headerでtokenを渡す際には別のinitでこんな感じになります 

Example.swift
        let configuration = URLSessionConfiguration.default
        configuration.httpAdditionalHeaders = token
        let networkTransport = HTTPNetworkTransport(url: baseUrl,
                                                    session: URLSession(configuration: configuration),
                                                    sendOperationIdentifiers: false,
                                                    useGETForQueries: false,
                                                    enableAutoPersistedQueries: false,
                                                    useGETForPersistedQueryRetry: false,
                                                    delegate: nil,
                                                    requestCreator: ApolloRequestCreator())

さて、今説明したApolloClientを使って実際にQueryとMutationの部分を作っていきます。
まず基本的には以下のような形でfetch/mutationを実行します。

Example.swift
//Query
client.fetch(
    query: query,
    cachePolicy: cachePolicy,
    queue: queue
    )
{ result in
    switch result {
    case let .failure(error):
        // do something
    case let .success(response):
        if let errors = response.errors {
            // do something
        }
        else if let data = response.data {
            // do something
        }
    }
}
//Mutation
client.perform(
    mutation: mutation,
    queue: queue
    )
{ result in
    switch result {
    case let .failure(error):
        // do something
    case let .success(response):
        if let errors = response.errors {
            // do something
        }
        else if let data = response.data {
            // do something
        }
    }
}

レスポンスはCompletationHundlerにResult型で返ってきます。
.successの方にもエラーのチェックをしている部分がありますが、これは仕様としてQuery/Mutationでのインプットがおかしい場合はステータスコード200でエラーが返ってくるために必要になります。
引数にあるcachePolicyqueueについては前者はどのようにキャッシュを使いながらQueryを実行するかの指定で、後者はどのスレッドで実行するかを指定するものとなっています。
デフォルトではメインスレッドでの実行かつ、
CachePolicyは.returnCacheDataElseFetchというキャッシュがあるなら使うけど無いならサーバーからfetchするというものになっています。
今回は通信部分ということでバックグラウンドでの処理にしたいので.global()Queueを指定しています。
更に使いやすいように以下のようにRxで流せるように拡張しています。

Apollo+Rx.swift
    func fetch<Query: GraphQLQuery>(
        query: Query,
        cachePolicy: CachePolicy = .returnCacheDataElseFetch,
        queue: DispatchQueue = DispatchQueue.main)
        -> Single<Query.Data>
    {
        return Single.create { single in
            let cancellable = self.provider.client.fetch(
                query: query,
                cachePolicy: cachePolicy,
                queue: queue)
            { result in
                switch result {
                case let .failure(error):
                    single(.error(error))
                case let .success(response):
                    if let errors = response.errors {
                        single(.error(RxGraphQLError.graphQLErrors(errors)))
                    }
                    else if let data = response.data {
                        single(.success(data))
                    }
                }
            }
            return Disposables.create {
                cancellable.cancel()
            }
        }
    }

これによって実際にタスクをfetchしている部分はこのように簡潔に書くことができます。
今回はTaskServiceでレスポンスを扱いやすい形に直しています。

TaskRepository.swift
    func fetchTasks(
        input: TasksInput,
        orderBy: TaskOrderFields,
        page: PaginationInput,
        refetch: Bool
    )
        -> Single<TasksQuery.Data> {
            let cachePolicy: CachePolicy = refetch ? .fetchIgnoringCacheData : .returnCacheDataElseFetch
            return provider.rx.fetch(query: TasksQuery(input: input,
                                                       orderBy: orderBy,
                                                       page: page),
                                     cachePolicy: cachePolicy,
                                     queue: .global())
    }

あとはService層などで使いやすい形に整形すれば通信部分の実装は完了です。

6. Viewとか

ここからは実際にViewに表示したり画面遷移したりの部分ですが、
メインはApolloを使った通信部分なので詳しくは解説しません。
使っている技術と簡単な説明としては

  • Coordinator: 画面遷移をViewの外に分離
  • RxDatasources: TableViewとPickerViewのDatasouceとして使用
  • ReactorKit: ViewのStateの保持と通信処理の呼び出し
  • Swinject: これを使って先ほど説明したApolloClientなどをDI

というようになっています。
3つとも簡単なレイアウトなのでStoryboardで作成しています。
ReactorKitを使用しているのでViewは入力をreactorに対してbindしてるだけなので
主に実際に処理をしているreactorの説明になります。

6.1 タスク一覧の作成

レイアウトはタスク一覧を表示するためのTableViewとタスク作成画面を表示するためのボタン、
並べ替え用のPickerを表示するためのボタンとなっています。
Simulator Screen Shot - iPhone 11 Pro Max - 2020-02-24 at 20.45.28.png

この画面で必要な機能は

  • タスクの表示
  • スクロールでのページネーション
  • リロード
  • フィルタリング

の4つなのでこれを実装していきます。

6.1.1 タスク一覧の読み込み

ということでまずはタスク一覧の表示から、
これはreactorでTaskServiceにあるfetchTaskを呼び出してMutationに変換してStateにいれるだけです。

TasksViewReactor.swift
        case let .loadTask(reload):
            let startLoading: Observable<Mutation> = .just(.setIsLoading(true))
            let tasks = taskService.fetchTasks(completed: state.isCompleted,
                                               order: state.taskOrder,
                                               endCursor: "",
                                               hasNext: false,
                                               refetch: reload)
                .map { page in
                    let cellItem = page.pageElements.map { CellItem.task(
                        TaskCellReactor(taskService: self.taskService, task: $0)
                        )
                    }

                    return Pagination(pageElements: cellItem,
                                      hasNextPage: page.hasNextPage,
                                      endCursor: page.endCursor)
            }
            .map(Mutation.setTasks)
            let endLoading: Observable<Mutation> = .just(.setIsLoading(false))
            return .concat([startLoading, tasks, endLoading])

6.1.2 ページネーション

次にスクロールでのページネーションですが
ページネーション自体は、カーソルの指定と次のページが有るかないかを見るだけなので
以下のようにタスク一覧の表示とやってることは変わらないです。

TasksViewReactor.swift
            let nextPage = taskService.fetchTasks(completed: state.isCompleted,
                                                  order: state.taskOrder,
                                                  endCursor: state.tasks.endCursor,
                                                  hasNext: state.tasks.hasNextPage,
                                                  refetch: false)
                .map { page in
                    let cellItem = page.pageElements.map { CellItem.task(
                        TaskCellReactor(taskService: self.taskService, task: $0)
                        )
                    }

                    return Pagination(pageElements: cellItem,
                                      hasNextPage: page.hasNextPage,
                                      endCursor: page.endCursor)
            }

ページネーションの呼び出しはTableViewを以下のような形でExtensionさせて呼び出すようにしています。

UIScrolView+ReachBottom.swift
    var isReachedBottom: Observable<Void> {
        return contentOffset
            .filter { [weak base = self.base] _ in
                guard let base = base else { return false }
                return base.isReachedBottom(tolerance: base.bounds.height / 2)
            }
            .map { _ in }
    }

6.1.3 リロード

リロードについてなんですがApollo-iOSではrefetchの指定ができなく、
かつ基本的にキャッシュを参照してレスポンスを返してくるので
今回は以下のようにQueryに渡すcachePolicyをキャッシュを参照しないものに変更してfetchし直すことで対応しています。

let cachePolicy: CachePolicy = refetch ? .fetchIgnoringCacheData : .returnCacheDataElseFetch

6.1.4 フィルタリング

フィルタリングは以下のようにPickerで内容をbindしつつ、
リロードしている感じです。

TasksViewReactor.swift
        case let .selectCompletedAndOrder(row, component):
            switch state.menuOrderOptions[component][row] {
            case TaskOrderFields.latest.rawValue:
                return .just(.setTaskOrder(.latest))
            case TaskOrderFields.due.rawValue:
                return .just(.setTaskOrder(.due))
            case CompletedSelect.all.rawValue:
                return .just(.setIsCompleted(nil))
            case CompletedSelect.completed.rawValue:
                return .just(.setIsCompleted(true))
            case CompletedSelect.notCompleted.rawValue:
                return .just(.setIsCompleted(false))
            default:
                return .empty()
            }
TasksViewController.swift
        reactor.state.map { $0.taskOrder }
            .distinctUntilChanged()
            .map { _ in Reactor.Action.load }
            .bind(to: reactor.action)
            .disposed(by: disposeBag)

6.2 タスクページの作成

Simulator Screen Shot - iPhone 11 Pro Max - 2020-02-24 at 20.45.39.png

この画面で必要な機能は

  • タスクの表示
  • タスクの編集/アップデート

の3つなのでこれを実装していきます。

6.2.1 タスクの読み込み

GraphQLはその名前の由来として自動的にルートクエリからキャッシュを作る機構があります。
これはApollo-iOSでも利用でき、ApolloStoreというクラスからキャッシュにアクセスすることができます。
しかしながらデフォルトではfetchしてきたオブジェクトが何のキーにも紐づいていないので、
明示的にアクセスしたい場合にはキーを設定する必要があります。
ということで以下のようにcacheKeyForObjectにidを指定します。

GraphQLProviderType.swift
    lazy var client: ApolloClient = {
        let networkTransport = HTTPNetworkTransport(url: baseURL)
        let client = ApolloClient(networkTransport: networkTransport)
        client.cacheKeyForObject = { $0["id"] }
        return client
    }()

これによってfragmentに含まれているidをもとにApolloStoreから読み出せるようになったので、
実際に以下のようにStoreからの読み出しを実装していきます。

AccessibleApolloStore.swift
    func load <T: GraphQLSelectionSet>(
        identifier: String,
        fragmentType: T.Type
    )
        -> Observable<T>
    {
        return .create { observable in
            self.provider.client.store.withinReadTransaction(
                { transaction -> T in
                    let data = try transaction.readObject(ofType: fragmentType,
                                                          withKey: identifier)
                    return data
            },
                callbackQueue: .global(),
                completion: { result in
                    switch result {
                    case let .success(data):
                        observable.onNext(data)
                    case let .failure(error):
                        observable.onError(error)
                    }
            })
            return Disposables.create()
        }
    }

これでTask画面で表示するのは単純にStoreを呼び出すだけになります。

TaskViewReactor.swift
        case .load:
            let task = accessibleApolloStore.load(identifier: taskIdentifier,
                                                  fragmentType: TaskFields.self)
                .map(Mutation.setTask)
            return task

6.2.2 タスクのアップデート

次にアップデートの実装です
最初にキャッシュから読み出したTaskと同じかどうかを見て画面が非表示になる際に更新しています。
とても単純
このアップデート実行時に同時にApolloStoreにあるキャッシュもアップデートしています。

TaskViewReactor.swift
        case .updateTask:
            guard cachedTask != state.task else { return .empty() }
            taskService.updateTask(taskIdentifier: taskIdentifier,
                                   title: state.task.title,
                                   notes: state.task.notes,
                                   completed: state.task.completed,
                                   due: state.task.due)

                .do(onNext: {[weak self] task in
                    let _ = self?.accessibleApolloStore.update(identifier: task.id,
                                                               fragmentType: TaskFields.self,
                                                               newItem: task)
                    self?.taskService.onNextUpdateTaskStream(task: task)
                })
                .subscribe()
            return .empty()

6.3 タスク作成ページの作成

Simulator Screen Shot - iPhone 11 Pro Max - 2020-02-24 at 20.45.49.png

この画面で必要な機能は

  • タスクの作成

の1つだけなんで実装していきます。
各UIコンポーネントからreactorに対して値をbindするだけで、そこまで難しいものではありません。
タイトルはOptionalではないのでguardでタイトルがちゃんと入力されているかだけを見てcreateTaskを呼び出しています。

CreateTaskViewReactor.swift
case .createTask:
            guard state.isCreatable else { return .empty() }
            let due = state.due == nil ? nil : ISO8601DateFormatter().string(from: state.due!)
            let isCreated = taskService.createTask(title: state.title,
                                                   notes: state.notes,
                                                   completed: state.isCompleted,
                                                   due: due)
                .do(onNext: {[weak self] task in
                    self?.taskService.onNextCreateTaskStream(task: task)
                })
                .map { _ in true }
                .map(Mutation.setIsCreated)
            return isCreated

タスク作成時にはシングルトンのTaskServiceを通してタスク一覧画面に対して、リロードするようにしています。


今回はReactorKitとApollo-iOSを使った実装を紹介しました。
勝手に型付きで通信部分を自動生成してくれるのは負担も減るし開発体験として素晴らしいものがあります。
Apollo-iOSはどんどん変わっていくので追うのは大変ですがどんどん便利な機能も増えていくし、開発体験はとにかく最高なので皆さんもぜひやってみてください。

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