🧑‍🍳

Apollo iOS v1.x系の変更でインパクトがある点をおさらいする

2023/12/26に公開

1. はじめに

皆様お疲れ様です。「iOS Advent Calendar 2023」の25日目を担当させて頂きます、fumiyasac(Fumiya Sakai)と申します。何卒よろしくお願い致します。

現在、業務や個人開発では、サーバーサイド側をGraphQLを積極的に利用して開発する方針としているため、Apolloでの処理がアプリの根幹を担っています。また、iOS側のGraphQLクライアントは「apollo-ios」を利用しています。

apollo-iosの導入から必要なコード生成に関する手順においてv0.x系から破壊的な変更があったので、v1.x系にバージョンアップを図る際に気をつけた方が良さそうな点や相違点がある部分を中心にまとめたものを、この機会に改めて整理しておきたいと思います。

※こちらの内容に関しては、2023年10月17日に開催された「Mobile勉強会 Wantedly × チームラボ #11」でも登壇しました。登壇資料内では、業務内で得られた知見や簡単なサンプル開発を通じて得られた学びの中で「基本のき」の部分も合わせてまとめています。

2. 0.x系でのコード自動生成手順の確認

まず前段として、0.x系での導入手順とBuildまでの流れを軽く触れておきます。

過去バージョンでは、XcodeをBuildするタイミングでGraphQLのコード生成処理を実行する様な形を取っていました。

v0.x系でApolloを導入する際のポイント図解

3. 最新バージョン(1.x系)での重要ポイント

Apolloのメジャーバージョンアップが実施された事に伴い、CLI関連をはじめ内部機能についても破壊的な変更がされました。

その中でも、以前にApolloのv0.x系を過去に導入していた場合からv1.x系にバージョンアップをする際は下記のマイグレーションガイドに沿って実施していく事になります。特にCLI関連やコード自動生成処理についても抜本的な変更がされている点に注意が必要です。

※ Apolloマイグレーションガイドについては「v1.0→v1.2」 & 「v1.2→v1.3」に関するドキュメントも用意されています。

【CLIに関する変更点】

v1.x系では、Swift Package Manager経由でApolloを導入してApolloを導入した場合にはXcodeからCLIツールをインストールする事ができます。これまでのnode.js経由でのインストール方式ではなくXcodeを経由したインストールとなったので、設定も随分としやすくなっています。

v1.x系でのCLIインストール手順

【コード自動生成に関するコマンド】

CLIツールをインストールしたら、次は./apollo-ios-cliを利用して、Swiftコードを自動生成する準備をします。基本的にはapollo-codegen-config.jsonに必要な設定を記述した後にSwiftコードを自動生成コマンドを実行する形となります。

# ①`apollo-codegen-config.json`の雛形を作成する
$ ./apollo-ios-cli init --schema-namespace CountriesSchema --module-type embeddedInTarget --target-name SimpleGraphQLPractice (--overwrite)

# ② apollo-codegen-config.jsonの内容をProjectに合わせて記載する

# ③ schema定義ファイル(schema.json)をapollo-ios-cli経由でダウンロード or 直接ダウンロードしProject内に配置
# ※ schema定義ファイルが正しく設定されている場合は下記コマンドを実行してダウンロードができます
$ ./apollo-ios-cli fetch-schema

# ④ Project内にQuery or Mutation処理用の`(ファイル名).graphql`ファイルを追加する

# ⑤ 定義した`apollo-codegen-config.json`からSwiftコードを自動生成する
$ ./apollo-ios-cli generate

下図は今回のサンプルプロジェクトで記載しているapollo-codegen-config.jsonの一例になりますが、内容には現在お使いのProject構成に合わせて設定することになります。

v1.x系でのコード自動生成処理に関する設定

【コード自動生成処理後の内容例】

前述した手順や設定が正しく実行できた場合は、下記の様な形となります。こちらは今回紹介するサンプル(自作したGraphQLサーバーと通信するサンプル)での事例になりますが、Project内に格納したQueryファイルやMutationファイルに対応して、この様な形でSwiftコードが自動生成されるイメージになります。
(0.x系の時と比べて自動生成物のGroup構成が少し異なる点に注意が必要です)

v1.x系でのコード自動生成された関する項目

4. 実装サンプル紹介

ここからは、具体的なサンプル実装例を紹介します。以降で紹介する2つの実装サンプルについては、UI実装としてはシンプルにGraphQLから取得した内容を表示したり、簡単なフォームで内容を送信したりする事ができるものになります。

【収録サンプルで利用しているアーキテクチャ】

構成自体はModel-ViewModel-Viewの構成をベースとしており、View要素と関係する処理については、ViewModelクラス内部で@Published private(set) var (変数名)で定義したプロパティと双方向Bindingの様な形で処理を組み立てています。

収録サンプルで利用しているアーキテクチャ

【GraphQLでの処理をasync/awaitで取り扱う部分の処理例】

こちらはGraphQLでの通信処理部分のコード例になります。ApolloClientのExtension部分で、Apollo内で用意されているQuery & Mutationの実行処理をasync/awaitでラッピングしている点がポイントになります。

final class GraphQLClient {

    // MARK: - Singleton Instance

    static let shared = GraphQLClient()

    private init() {}

    // MARK: - Property

    let apollo = {
        // MEMO: Client作成部分に関する詳細な設定については公式ドキュメントを参考にすると良さそうです。
        // ドキュメント: https://www.apollographql.com/docs/ios/networking/client-creation
        let cache = InMemoryNormalizedCache()
        let store = ApolloStore(cache: cache)
        let provider = DefaultInterceptorProvider(
            client: URLSessionClient(),
            store: store
        )
        // MEMO: エンドポイントはすでにサンプルとして公開されているものを利用する形としています。
        // 動作コード: http://localhost:4000/graphql
        let url = URL(string: "http://localhost:4000/graphql")!
        let transport = RequestChainNetworkTransport(
            interceptorProvider: provider,
            endpointURL: url,
            // MEMO: もしHeader用のAccessTokenを付与する場合はこちらを活用することになります。
            additionalHeaders: ["Authorization": "Bearer "]
        )
        return ApolloClient(networkTransport: transport, store: store)
    }()
}

// 参考:
// 「apollo-ios」内のIssue内のディスカッションで紹介されていたコードを参考にしています。
// https://github.com/apollographql/apollo-ios/issues/2216

// MARK: - ApolloClient Extension

extension ApolloClient {

    // GraphQLのQueryをする処理をasync/awaitの処理内で実行する
    @discardableResult
    func fetchAsync<Query: GraphQLQuery>(
        query: Query,
        cachePolicy: CachePolicy = .default,
        contextIdentifier: UUID? = nil,
        queue: DispatchQueue = .main
    ) async throws -> GraphQLResult<Query.Data> {

        // MEMO: withCheckedThrowingContinuationでErrorをthrowする形にしています。
        return try await withCheckedThrowingContinuation { continuation in
            fetch(
                query: query,
                cachePolicy: cachePolicy,
                contextIdentifier: contextIdentifier,
                queue: queue
            ) { result in
                switch result {
                case .success(let value):
                    continuation.resume(returning: value)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }

    // GraphQLのMutationをする処理をasync/awaitの処理内で実行する
    @discardableResult
    func performAsync<Mutation: GraphQLMutation>(
        mutation: Mutation,
        publishResultToStore: Bool = true,
        queue: DispatchQueue = .main
    ) async throws -> GraphQLResult<Mutation.Data> {

        // MEMO: withCheckedThrowingContinuationでErrorをthrowする形にしています。
        return try await withCheckedThrowingContinuation { continuation in
            perform(
                mutation: mutation,
                publishResultToStore: publishResultToStore,
                queue: queue
            ) { result in
                switch result {
                case .success(let value):
                    continuation.resume(returning: value)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

【UnitTest時のMock作成時にDataDict型を利用する際の注意点】

こちらは、収録サンプル内のUnitTestで利用しているMockデータを定義するクラスを抜粋したものになります。このクラスではUnitTest等の用途で、通信処理成功時のレスポンスを組み立てる際においては、DataDict(data: [String: AnyHashable], fulfilledFragments: Set<ObjectIdentifier>)を利用する必要があります。故に、格納データが[String: AnyHashable]の入れ子構造で格納されているため、レスポンスで想定している構造が複雑な場合には注意が必要になるかと思います。

final class CountryListRequestSuccessMock: CountryListRequest {

    // MARK: - Function

    func getResult() async throws -> GraphQLResult<CountriesSchema.GetAllCountriesQuery.Data> {
        // MEMO: Apollo1.x系からはGraphQLで返却されるデータをUnitTest用にマッピングする際には注意が必要(構造が複雑になりがち)
        // 一覧データのMock生成時の流れ
        // (1) まずDataDict型(第1引数は[String: AnyHashable]型、第2引数はから配列)のデータを作成してレスポンスデータを想定してマッピングをする
        // (2) 次にCountriesSchema.GetAllCountriesQuery.Data型のデータを作成してGraphQLResultに入れて返却する
        let dataDict = DataDict(
            data: [
                "countries": [
                    DataDict(data: ["code": "MY", "name": "Malaysia", "emoji": "🇲🇾"], fulfilledFragments: []),
                    DataDict(data: ["code": "TH", "name": "Thailand", "emoji": "🇹🇭"], fulfilledFragments: []),
                    DataDict(data: ["code": "MX", "name": "Mexico", "emoji": "🇲🇽"], fulfilledFragments: []),
                    DataDict(data: ["code": "JP", "name": "Japan", "emoji": "🇯🇵"], fulfilledFragments: []),
                    DataDict(data: ["code": "IN", "name": "India", "emoji": "🇮🇳"], fulfilledFragments: [])
                ]
            ],
            fulfilledFragments: []
        )
        let data = CountriesSchema.GetAllCountriesQuery.Data.init(_dataDict: dataDict)
        return GraphQLResult(data: data, extensions: [:], errors: nil, source: .server, dependentKeys: nil)
    }
}

final class CountryListRequestFailureMock: CountryListRequest {

    // MARK: - Function

    func getResult() async throws -> GraphQLResult<CountriesSchema.GetAllCountriesQuery.Data> {
        throw MockError.with()
    }
}

4-1. 1つ目の簡単な実装サンプルの解説

こちらは、国情報一覧と詳細画面を表示するだけのシンプルな画面を実装したものになります。国情報を取得するためのエンドポイントは、下記で公開されているものを利用しています。

国情報を取得するEndpoint

【サンプル画面構成】

国一覧画面 国詳細画面
国一覧画面 国詳細画面

【実装コード例】

🌾 本稿では、国一覧情報を取得する部分を抜粋しています。

GetAllCountries.graphql
query GetAllCountries {
  countries {
    code
    name
    emoji
  }
}
CountryListRequest.swift
import Apollo
import Foundation

protocol CountryListRequest {
    func getResult() async throws -> GraphQLResult<CountriesSchema.GetAllCountriesQuery.Data>
}

final class CountryListRequestImpl: CountryListRequest {

    // MARK: - Property

    private let apolloClient: ApolloClient

    // MARK: - Initializer

    init(apolloClient: ApolloClient = GraphQLClient.shared.apollo) {
        self.apolloClient = apolloClient
    }

    // MARK: - Function

    func getResult() async throws -> GraphQLResult<CountriesSchema.GetAllCountriesQuery.Data> {
        let query = CountriesSchema.GetAllCountriesQuery()
        return try await apolloClient.fetchAsync(query: query)
    }
}
CountriesRepository.swift
import Apollo
import Foundation

// MARK: - Protocol

protocol CountryListRepository {
    func getAllCountries() async throws -> [CountryListEntity]
}

final class CountryListRepositoryImpl: CountryListRepository {

    // MARK: - Property

    private let countryListRequest: CountryListRequest

    // MARK: - Initializer

    init(countryListRequest: CountryListRequest = CountryListRequestImpl()) {
        self.countryListRequest = countryListRequest
    }

    // MARK: - Function

    func getAllCountries() async throws -> [CountryListEntity] {
        let result = try await countryListRequest.getResult()
        return convertToEntities(result: result)
    }

    // MARK: - Private Function

    private func convertToEntities(result: GraphQLResult<CountriesSchema.GetAllCountriesQuery.Data>) -> [CountryListEntity] {
        result.data?.countries.compactMap {
            CountryListEntity(
                code: $0.code,
                name: $0.name,
                emoji: $0.emoji
            )
        } ?? []
    }
}
CountryListViewModel.swift
import Foundation

final class CountryListViewModel: ObservableObject {
    
    // MARK: - Property
    
    private let countryListRepository: CountryListRepository

    @Published private(set) var countryListEntities: [CountryListEntity] = []
    @Published private(set) var requestStatus: RequestStatus = .none

    // MARK: - Initializer

    init(countryListRepository: CountryListRepository = CountryListRepositoryImpl()) {
        self.countryListRepository = countryListRepository
    }

    // MARK: - Function

    func fetchCountryList() {
        Task { @MainActor in
            self.requestStatus = .requesting
            do {
                // MEMO: async/awaitベースの処理で必要な値を取得し、その後`@Published`で定義した値を更新する
                self.countryListEntities = try await self.countryListRepository.getAllCountries()
                self.requestStatus = .success
            } catch let error {
                // MEMO: 本来ならばエラーハンドリング処理等を入れる必要がある
                print("Fetch Country List Error: " + error.localizedDescription)
                self.requestStatus = .failure
            }
        }
    }
}
CountryListView.swift
import SwiftUI

struct CountryListView: View {

    // MARK: - Property

    @ObservedObject private var viewModel: CountryListViewModel

    // MARK: - Initializer

    init(viewModel: CountryListViewModel = CountryListViewModel()) {
        self.viewModel = viewModel
    }

    // MARK: - Body

    var body: some View {
        NavigationStack {
            List {
                switch viewModel.requestStatus {
                case .success:
                    // 国の一覧を取得して表示する
                    ForEach(viewModel.countryListEntities, id: \.code) { countryListEntity in
                        // セルをタップすると詳細画面へ遷移する
                        NavigationLink(
                            destination: CountryDetailView(code: countryListEntity.code),
                            label: {
                                CountryListRow(countryListEntity: countryListEntity)
                            }
                        )
                    }
                case .failure:
                    // リクエストを再実行をするためのErrorView表示する
                    ConnectionErrorView(tapButtonAction: viewModel.fetchCountryList)
                default:
                    // 空のView要素を表示する
                    EmptyView()
                }
            }
            .navigationBarTitle("Country List", displayMode: .large)
            .listStyle(.inset)
            .onFirstAppear(onceExecuteAction: viewModel.fetchCountryList)
        }
    }
}

4-2. 2つ目の簡単な実装サンプルの解説

こちらも、学生食堂にありそうな?メニューを一覧表示し、カテゴリー項目をクリックしてフィルタリングをする機能を実装したものになります。こちらは、Apollo + Express を利用した擬似的なGraphQLサーバーを利用した形としています。

※ 収録サンプルのGraphQLサーバーを動作させる際は、下記の様な手順で実行する事ができます。こちらは、TypeScriptで作成しています。

# Backendディレクトリ内に疑似的なサーバーに関するコードを作成する
$ cd Backend

# プロジェクトの新規作成
$ yarn init -y

# Apollo Serverパッケージを追加する(Apollo + Express構成)
$ yarn add apollo-server apollo-server-express express graphql cors

# Typescriptパッケージを追加する
$ yarn add -D typescript @types/node

# tsconfig.jsonを新規作成する
$ npx tsc --init

# ts-node&ts-node-devパッケージを新規作成する(TypeScriptコード変更を検知し再起動するため)
$ yarn add -D ts-node ts-node-dev

# 参考1: Apollo+Expressで始めるGraphQL超入門 ~ GraphQLをざっくり理解する
# https://qiita.com/Zonoma/items/5de4b14dcd839db5f148
# 参考2: TypeScriptでApollo-Serverを構築する
# https://qiita.com/omukaik/items/4b31b771c674fcea9118
# 参考3: 【GraphQL】Apollo ServerやTypeScriptを使ってGraphQLのAPIを開発する
# https://isub.co.jp/graphql/getting-started-with-apollo-server/
# package.jsonに記載している内容をインストールする
$ yarn

# GraphQL Serverを起動する
$ yarn dev

① (Query) メニュー表示画面

【サンプル画面構成:Query利用部分】

メニュー検索画面 お知らせ一覧画面
メニュー検索画面 お知らせ一覧画面

🌾 本稿では、メニュー表示取得 & Filter処理をする部分を抜粋しています。

MenuRequest.swift
import Apollo
import Foundation

protocol MenuRequest {
    func getResult(
        dishType: String?,
        categorySlug: String?
    ) async throws -> GraphQLResult<MenuExhibitionSchema.GetAllMenusQuery.Data>
}

final class MenuRequestImpl: MenuRequest {

    // MARK: - Property

    private let apolloClient: ApolloClient

    // MARK: - Initializer

    init(apolloClient: ApolloClient = GraphQLClient.shared.apollo) {
        self.apolloClient = apolloClient
    }

    // MARK: - Function
    
    func getResult(
        dishType: String?,
        categorySlug: String?
    ) async throws -> GraphQLResult<MenuExhibitionSchema.GetAllMenusQuery.Data> {
        let query = MenuExhibitionSchema.GetAllMenusQuery(
            dishType: dishType ?? .null,
            categorySlug: categorySlug ?? .null
        )
        return try await apolloClient.fetchAsync(query: query)
    }
}
MenuRepository.swift
import Apollo
import Foundation

protocol MenuRepository {
    func getFilteredMenus(        
        dishType: String?,
        categorySlug: String?
    ) async throws -> [MenuEntity]
}

final class MenuRepositoryImpl: MenuRepository {

    // MARK: - Property

    private let menuRequest: MenuRequest

    // MARK: - Initializer

    init(menuRequest: MenuRequest = MenuRequestImpl()) {
        self.menuRequest = menuRequest
    }

    // MARK: - Function

    func getFilteredMenus(
        dishType: String?,
        categorySlug: String?
    ) async throws -> [MenuEntity] {
        let result = try await menuRequest.getResult(
            dishType: dishType,
            categorySlug: categorySlug
        )
        return convertToEntities(result: result)
    }

    // MARK: - Private Function

    private func convertToEntities(result: GraphQLResult<MenuExhibitionSchema.GetAllMenusQuery.Data>) -> [MenuEntity] {
        result.data?.getMenus?.compactMap {
            MenuEntity(
                id: $0.id,
                name: $0.name,
                dishType: $0.dishType,
                categorySlug: $0.categorySlug,
                price: $0.price,
                kcal: $0.kcal,
                thumbnail: $0.thumbnail
            )
        } ?? []
    }
}
MenuViewModel.swift
import Foundation

final class MenuViewModel: ObservableObject {

    // MARK: - Property

    private let menuRepository: MenuRepository

    @Published private(set) var dishType: String? = nil
    @Published private(set) var categorySlug: String? = nil
    @Published private(set) var menuEntities: [MenuEntity] = []
    @Published private(set) var requestStatus: RequestStatus = .none

    // MARK: - Initializer

    init(menuRepository: MenuRepository = MenuRepositoryImpl()) {
        self.menuRepository = menuRepository
    }

    // MARK: - Function

    func fetchMenu(
        dishType: String?,
        categorySlug: String?
    ) {
        Task { @MainActor in
            self.dishType = dishType
            self.categorySlug = categorySlug
            self.requestStatus = .requesting
            do {
                // MEMO: async/awaitベースの処理で必要な値を取得し、その後`@Published`で定義した値を更新する
                self.menuEntities = try await self.menuRepository.getFilteredMenus(
                    dishType: dishType,
                    categorySlug: categorySlug
                )
                self.requestStatus = .success
            } catch let error {
                // MEMO: 本来ならばエラーハンドリング処理等を入れる必要がある
                print("Fetch Country List Error: " + error.localizedDescription)
                self.requestStatus = .failure
            }
        }
    }
}
MenuScreenView.swift
import SwiftUI

struct MenuScreenView: View {

    // MARK: - Property

    @ObservedObject private var viewModel: MenuViewModel

    // MARK: - Initializer

    init(viewModel: MenuViewModel = MenuViewModel()) {
        self.viewModel = viewModel
    }

    // MARK: - Body

    var body: some View {
        NavigationStack {
            // カテゴリーでの絞り込み検索バー表示
            SelectCategorySlugView(
                selectedCategorySlug: viewModel.categorySlug ?? "",
                tapCategorySlugChipAction: { selectedCategorySlug in
                    viewModel.fetchMenu(dishType: viewModel.dishType, categorySlug: selectedCategorySlug)
                }
            )
            // 食事バランスガイドでの絞り込み検索バー表示
            SelectDishTypeView(
                selectedDishType: viewModel.dishType ?? "",
                tapDishTypeChipAction: { selectedDishType in
                    viewModel.fetchMenu(dishType: selectedDishType, categorySlug: viewModel.categorySlug)
                }
            )
            List {
                switch viewModel.requestStatus {
                case .success:
                    // お知らせ一覧を取得して表示する
                    ForEach(viewModel.menuEntities, id: \.id) { menuEntity in
                        MenuListRowView(menuEntity: menuEntity)
                    }
                case .failure:
                    // リクエストを再実行をするためのErrorView表示する
                    ConnectionErrorView(
                        tapButtonAction: {
                            viewModel.fetchMenu(dishType: viewModel.dishType, categorySlug: viewModel.categorySlug)
                        }
                    )
                default:
                    // 空のView要素を表示する
                    EmptyView()
                }
            }
            .navigationBarTitle("メニュー一覧表示", displayMode: .inline)
            .navigationBarItems(
                trailing: Button(
                    action: {}, 
                    label: {
                        //
                        NavigationLink(
                            destination: InquireScreenView(),
                            label: {
                                Image(systemName: "envelope.fill")
                                    .foregroundColor(.white)
                            }
                        )
                    }
                )
            )
            .listStyle(.inset)
            .onFirstAppear(
                onceExecuteAction: {
                    viewModel.fetchMenu(dishType: viewModel.dishType, categorySlug: viewModel.categorySlug)
                }
            )
        }
    }
}

② (Mutation) お問い合わせ送信画面

【サンプル画面構成:Mutation利用部分】

お問い合わせ画面 お問い合わせ入力状態
お問い合わせ画面 お問い合わせ入力状態

【実装コード例】

InquireRequest.swift
import Apollo
import Foundation

protocol InquireRequest {
    func addResult(
        title: String,
        text: String
    ) async throws -> GraphQLResult<MenuExhibitionSchema.AddInquireMutation.Data>
}

final class InquireRequestImpl: InquireRequest {

    // MARK: - Property

    private let apolloClient: ApolloClient

    // MARK: - Initializer

    init(apolloClient: ApolloClient = GraphQLClient.shared.apollo) {
        self.apolloClient = apolloClient
    }

    // MARK: - Function

    func addResult(
        title: String,
        text: String
    ) async throws -> GraphQLResult<MenuExhibitionSchema.AddInquireMutation.Data> {
        let mutation = MenuExhibitionSchema.AddInquireMutation(
            title: title,
            text: text
        )
        return try await apolloClient.performAsync(mutation: mutation)
    }
}
InquireRepository.swift
import Apollo
import Foundation

protocol InquireRepository {
    func addInquire(
        title: String,
        text: String
    ) async throws -> InquireEntity?
}

final class InquireRepositoryImpl: InquireRepository {

    // MARK: - Property

    private let inquireRequest: InquireRequest

    // MARK: - Initializer

    init(inquireRequest: InquireRequest = InquireRequestImpl()) {
        self.inquireRequest = inquireRequest
    }

    // MARK: - Function

    func addInquire(
        title: String,
        text: String
    ) async throws -> InquireEntity? {
        let result = try await inquireRequest.addResult(
            title: title,
            text: text
        )
        return convertToEntities(result: result)
    }

    // MARK: - Private Function

    private func convertToEntities(result: GraphQLResult<MenuExhibitionSchema.AddInquireMutation.Data>) -> InquireEntity? {
        if let addInquire = result.data?.addInquire {
            return InquireEntity(
                id: addInquire.id,
                title: addInquire.title,
                text: addInquire.text
            )
        } else {
            return nil
        }
    }
}
InquireViewModel.swift
import Foundation

final class InquireViewModel: ObservableObject {

    // MARK: - Property

    private let inquireRepository: InquireRepository

    // MEMO: テキスト入力内容と表示を連動させるため、`private(set)`にはしていない点に注意
    @Published var inputTitle = ""
    @Published var inputText = ""

    @Published private(set) var requestStatus: RequestStatus = .none

    var shouldBeActiveSubmitButton: Bool {
        let existsBlankField = (inputTitle.count == 0 || inputText.count == 0)
        let shouldRequesting = requestStatus == .requesting
        return existsBlankField || shouldRequesting
    }

    // MARK: - Initializer

    init(inquireRepository: InquireRepository = InquireRepositoryImpl()) {
        self.inquireRepository = inquireRepository
    }

    // MARK: - Function

    func addInquire() {
        Task { @MainActor in
            self.requestStatus = .requesting
            do {
                let inquireEntity = try await self.inquireRepository.addInquire(title: inputTitle, text: inputText)
                print("Add Inquire Success! → Entity: ", inquireEntity as Any)
                self.requestStatus = .success
                self.resetFields()
            } catch let error {
                // MEMO: 本来ならばエラーハンドリング処理等を入れる必要がある
                print("Add Inquire Error: " + error.localizedDescription)
                self.requestStatus = .failure
            }
        }
    }

    // MARK: - Private Function

    private func resetFields() {
        inputTitle.removeAll()
        inputText.removeAll()
    }
}
InquireScreenView.swift
import SwiftUI

struct InquireScreenView: View {
    
    // MARK: - Property
    
    private var inputTextFont: Font {
        return Font.custom("AvenirNext-Regular", size: 13)
    }
    
    private var resultTextFont: Font {
        return Font.custom("AvenirNext-Regular", size: 13)
    }
    
    private var resultTextColor: Color {
        return Color.red
    }
    
    private var submitButtonFont: Font {
        return Font.custom("AvenirNext-Bold", size: 16)
    }
    
    private var submitButtonColor: Color {
        return Color.orange
    }
    
    // 遷移先から遷移元へ戻るために必要なPropertyWrapper
    @Environment(\.dismiss) private var popToPrevious
    
    // テキスト入力エリア等のフォーカス制御用のPropertyWrapper
    @FocusState private var isKeyboardActive: Bool
    
    @ObservedObject private var viewModel: InquireViewModel
    
    // MARK: - Initializer
    
    init(viewModel: InquireViewModel = InquireViewModel()) {
        self.viewModel = viewModel
    }
    
    // MARK: - Body
    
    var body: some View {
        NavigationStack {
            List {
                // 1. タイトル入力用Section
                Section(header: Text("タイトル:")) {
                    // MEMO: viewModelに準備した`@Published var inputTitle`と双方向のBindingを実施する
                    TextField("", text: $viewModel.inputTitle)
                        .font(inputTextFont)
                        .listRowSeparator(.hidden)
                        // フォーカス状態と連動させるためのModifier定義
                        .focused($isKeyboardActive)
                }
                // 2. 本文入力用Section
                Section(header: Text("本文:")) {
                    // MEMO: viewModelに準備した`@Published var inputTitle`と双方向のBindingを実施する
                    TextEditor(text: $viewModel.inputText)
                        .font(inputTextFont)
                        .listRowSeparator(.hidden)
                        // フォーカス状態と連動させるためのModifier定義
                        .focused($isKeyboardActive)
                }
                // 3. 送信メッセージ処理結果表示
                displayResultSectionIfNeeded()
                // 4. 送信ボタン表示
                HStack {
                    Spacer()
                    // MEMO: 送信ボタン部分のAction定義とViewModelでの処理を連動する
                    Button(
                        action: {
                            viewModel.addInquire()
                        },
                        label: {
                            Text("送信する")
                                .font(submitButtonFont)
                                .foregroundColor(submitButtonColor)
                                .background(.white)
                                .frame(width: 240.0, height: 48.0)
                                .cornerRadius(24.0)
                                .overlay(
                                    RoundedRectangle(cornerRadius: 24.0)
                                        .stroke(submitButtonColor, lineWidth: 1.0)
                                )
                        }
                    )
                    // MEMO: ボタン押下可否状態をViewModel側の状態に合わせて切り替える
                    .disabled(viewModel.shouldBeActiveSubmitButton)
                    Spacer()
                }
                .listRowSeparator(.hidden)
            }
            .toolbar {
                // キーボード入力時に表示するToolBarに関する定義
                // ※ TextEditorは「return」ボタン押下時にキーボードが閉じないので注意
                ToolbarItemGroup(placement: .keyboard) {
                    Spacer()
                    Button(
                        action: {
                            // キーボードのフォーカスを取り消す(UIKitのResignFirstResponderに相当するもの)
                            isKeyboardActive = false
                        },
                        label: {
                            Text("閉じる")
                        }
                    )
                }
            }
            .navigationBarTitle("お問い合わせ", displayMode: .inline)
            .navigationBarBackButtonHidden(true)
            .navigationBarItems(
                leading: Button(
                    action: {
                        // 前に表示している画面へ戻る処理をこのタイミングで実行する
                        popToPrevious()
                    },
                    label: {
                        // MEMO: 戻るボタンを自前で作成する
                        // ※iOS15までサポートしていたり、UIKitとの混合であればUINavigationControllerの方がいいかも...
                        Image(systemName: "chevron.backward")
                            .foregroundColor(.white)
                    }
                )
            )
            .listStyle(.grouped)
            .scrollContentBackground(.hidden)
        }
    }
    
    // MARK: - Private Function
    
    @ViewBuilder
    private func displayResultSectionIfNeeded() -> some View {
        let resultStatus = displayResultStatus()
        if resultStatus.shouldDisplayResultSection {
            HStack {
                Spacer()
                Text(resultStatus.message)
                    .font(resultTextFont)
                    .foregroundColor(resultTextColor)
                Spacer()
            }
            .listRowSeparator(.hidden)
        } else {
            EmptyView()
        }
    }

    private func displayResultStatus() -> (message: String, shouldDisplayResultSection: Bool) {
        if viewModel.requestStatus == .success {
            (message: "お問い合わせの送信に成功しました😋", shouldDisplayResultSection: true)
        } else if viewModel.requestStatus == .failure {
            (message: "お問い合わせの送信に失敗しました😢", shouldDisplayResultSection: true)
        } else {
            (message: "", shouldDisplayResultSection: false)
        }
    }
}

③ (GraphQLサーバー) ApolloServerを利用した簡単なBackend

本稿で紹介しているGraphQLを利用した実装は、下記の様な形で実装しています。

index.ts
// WebアプリケーションフレームワークでExpressを利用する
import express from 'express';
// Origin間リソース共有(CORS)用のMiddleWareの利用許可をする
import cors from 'cors';
// GraphQLサーバー構築に必要なものを利用する
import { ApolloServer } from 'apollo-server-express';

// GraphQLのSchema定義
import { typeDefs } from './typeDef/schema';
// GraphQLのResolver定義
import { resolvers } from './resolvers';

// 利用データの呼び出し
// ① Queryでは表示対象データを呼び出しています
// ② Mutationでは空配列を定義しています
import { news } from './dataDef/news';
import { menu } from './dataDef/menu';
import { inquire } from './dataDef/inquire';

// ExpressでCORSを利用する
const app = express().use(cors());

// Apolloサーバー実行処理定義
async function startServer() {
  // ApolloServerのインスタンスを定義する
  const apolloServer = new ApolloServer({
    typeDefs: typeDefs,
    resolvers,
    context: () => {
      return {
        news: news,
        menu: menu,
        inquire: inquire
      };
    }
  });
  // ApolloServerを実行する
  await apolloServer.start();
  // Middlewareを適用する
  apolloServer.applyMiddleware({ app, path: '/graphql' });
}

// Apolloサーバーを起動する
startServer();

// Port番号の指定
const port = 4000;

// Expressサーバーを起動する
app.listen({ port: port }, () => {
  console.log(`🚀 Server is running at http://localhost:${port}/graphql`);
});
resolvers.ts
// Query処理
import { query } from './resolverDef/query';
// Mutation処理
import { mutation } from './resolverDef/mutation';

// Resolver定義一覧
export const resolvers = {
  Query: query,
  // MEMO: Mutationに関する処理を定義する部分
  Mutation: mutation
};
schema.ts
// GraphQLサーバー構築に必要なものを利用する
import { gql } from 'apollo-server-express';

// Query&Mutationに関する定義を記載する
export const typeDefs = gql`
  type Query {
    getNews: [News!]
    getMenus(menuFilter: MenuFilter): [Menu!]
  }

  type Mutation {
    addInquire(inquireItem: InquireItem): Inquire!
  }

  type News {
    id: ID!
    title: String!
    date: String!
    genre: String!
  }

  input MenuFilter {
    dishType: String
    categorySlug: String
  }

  type Menu {
    id: ID!
    name: String!
    dishType: String!
    categorySlug: String!
    price: Int!
    kcal: Int!
    thumbnail: String!
  }

  input InquireItem {
    title: String!
    text: String!
  }

  type Inquire {
    id: ID!
    title: String!
    text: String!
  }
`;
query.ts
// Query定義一覧を記載する
export const query = {

  // ① News一覧を取得する
  getNews: (parent: any, args: any, context: any) => {
    // Contextから渡されたNews一覧データをGraphQLで返却するための処理
    const result = context.news;
    return result;
  },

  // ② Menu一覧を取得する
  getMenus: (parent: any, args: any, context: any) => {
    // Contextから渡されたMenu一覧データを条件でフィルタリングをしGraphQLで返却するための処理
    const { menuFilter } = args;
    // inputで送られた`dishType`の値に該当するものを絞り込む(※ない場合は全件表示)
    const resultOfDishTypeFilter = context.menu.filter((menu: any) => {
      if (menuFilter.dishType === null || menuFilter.dishType === undefined) {
        return true
      } else {
        return menu.dishType === menuFilter.dishType
      }
    });
    // inputで送られた`categorySlug`の値に該当するものを絞り込む(※ない場合は全件表示)
    const resultOfCategorySlugFilter = context.menu.filter((menu: any) => {
      if (menuFilter.categorySlug === null || menuFilter.categorySlug === undefined) {
        return true
      } else {
        return menu.categorySlug === menuFilter.categorySlug
      }
    });
    // 2つの絞り込み結果から共通要素を取得する
    const duplicatedResult = [...resultOfDishTypeFilter, ...resultOfCategorySlugFilter].filter((menu: any) => {
        return resultOfDishTypeFilter.includes(menu) && resultOfCategorySlugFilter.includes(menu)
    });
    const result = [...new Set(duplicatedResult)];
    return result;
  },
};
mutation.ts
// Mutation定義一覧を記載する
export const mutation = {
  addInquire: (parent: any, args: any, context: any) => {
    // Contextから渡されたInquireItemデータをGraphQLで追加するための処理
    // ※こちらは仮の送信処理をする形になります。
    const { inquireItem } = args;
    const newInquire = {
      id: context.inquire.length + 1,
      title: inquireItem.title,
      text: inquireItem.text
    }
    context.inquire.push(newInquire);
    return newInquire;
  }
};

5. GraphQLやApolloの雰囲気を掴むための参考資料

【登壇資料】

【解説ブログ】

6. まとめ

v0.x系と比べて、CLIの準備やコード自動生成に関連する処理で大きな変更があった点や、DataDict型の取り扱いの部分は、大規模なProject等で移行作業が必要な場面では大変な事もありますが、GraphQLから取得したデータをEntity等へ変換する処理自体には、さほど大きな変更は無かった様に思います。

業務内でもサーバーサイドエンジニア側とのコミュニケーションを図る場面や機能開発を進める場合においても、スピードアップが見込める実感があるので、以前サーバーサイドエンジニアをした経験も活かしながら良い活路を模索したいです。

2023年12月時点での最新版はv1.7.1であることや、2023年6月中にv0.x系からバージョンアップされた際の変更内容がかなり破壊的であったので、今後の動向や変更内容に関しては引き続きキャッチアップを継続していきます。

Discussion