##前書き
この記事はApollo-iOSを用いて、GraphQLを使用したTODOアプリのiOSの実装について紹介していくものです。
@ebkn さんの記事と @Climber22 さんの記事に触発されて作ったのでバックエンドの方はそちらを参考にしてもらえると
Qiita初投稿なので色々とガバガバだと思われますが何卒よろしくお願いします。
コードはこちら
- 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
に分離しています。
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
}
}
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-tooling
でSwift
のコードを生成するスクリプトを動かしています。
これで生成されるAPI.swift
は一番最初はXcodeに認識されてないのでAdd fileで追加しておいてください。
##5. 通信部分の実装
5.1 ApolloClientの生成
ここから実際にswiftのコードを書いていきます。
まずは通信に必要なApolloClientのインスタンスを生成するところから
URLの文字列はBundleから呼び出すようにしています。
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でこんな感じになります
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を実行します。
//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でエラーが返ってくるために必要になります。
引数にあるcachePolicy
とqueue
については前者はどのようにキャッシュを使いながらQueryを実行するかの指定で、後者はどのスレッドで実行するかを指定するものとなっています。
デフォルトではメインスレッドでの実行かつ、
CachePolicyは.returnCacheDataElseFetch
というキャッシュがあるなら使うけど無いならサーバーからfetchするというものになっています。
今回は通信部分ということでバックグラウンドでの処理にしたいので.global()
Queueを指定しています。
更に使いやすいように以下のようにRxで流せるように拡張しています。
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でレスポンスを扱いやすい形に直しています。
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を表示するためのボタンとなっています。
この画面で必要な機能は
- タスクの表示
- スクロールでのページネーション
- リロード
- フィルタリング
の4つなのでこれを実装していきます。
####6.1.1 タスク一覧の読み込み
ということでまずはタスク一覧の表示から、
これはreactorでTaskServiceにあるfetchTaskを呼び出してMutationに変換してStateにいれるだけです。
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 ページネーション
次にスクロールでのページネーションですが
ページネーション自体は、カーソルの指定と次のページが有るかないかを見るだけなので
以下のようにタスク一覧の表示とやってることは変わらないです。
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させて呼び出すようにしています。
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しつつ、
リロードしている感じです。
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()
}
reactor.state.map { $0.taskOrder }
.distinctUntilChanged()
.map { _ in Reactor.Action.load }
.bind(to: reactor.action)
.disposed(by: disposeBag)
この画面で必要な機能は
- タスクの表示
- タスクの編集/アップデート
の3つなのでこれを実装していきます。
####6.2.1 タスクの読み込み
GraphQLはその名前の由来として自動的にルートクエリからキャッシュを作る機構があります。
これはApollo-iOSでも利用でき、ApolloStoreというクラスからキャッシュにアクセスすることができます。
しかしながらデフォルトではfetchしてきたオブジェクトが何のキーにも紐づいていないので、
明示的にアクセスしたい場合にはキーを設定する必要があります。
ということで以下のようにcacheKeyForObjectにidを指定します。
lazy var client: ApolloClient = {
let networkTransport = HTTPNetworkTransport(url: baseURL)
let client = ApolloClient(networkTransport: networkTransport)
client.cacheKeyForObject = { $0["id"] }
return client
}()
これによってfragmentに含まれているidをもとにApolloStoreから読み出せるようになったので、
実際に以下のようにStoreからの読み出しを実装していきます。
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を呼び出すだけになります。
case .load:
let task = accessibleApolloStore.load(identifier: taskIdentifier,
fragmentType: TaskFields.self)
.map(Mutation.setTask)
return task
####6.2.2 タスクのアップデート
次にアップデートの実装です
最初にキャッシュから読み出したTaskと同じかどうかを見て画面が非表示になる際に更新しています。
とても単純
このアップデート実行時に同時にApolloStoreにあるキャッシュもアップデートしています。
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()
この画面で必要な機能は
- タスクの作成
の1つだけなんで実装していきます。
各UIコンポーネントからreactorに対して値をbindするだけで、そこまで難しいものではありません。
タイトルはOptionalではないのでguardでタイトルがちゃんと入力されているかだけを見てcreateTaskを呼び出しています。
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はどんどん変わっていくので追うのは大変ですがどんどん便利な機能も増えていくし、開発体験はとにかく最高なので皆さんもぜひやってみてください。