flutterでgraphQLを使用してみた（graphql_codegenとgraphql_flutter）

株式会社Neverのkousoです。

株式会社Neverは「NEVER STOP CREATE 作りつづけること」をビジョンに掲げ、理想を実現するためにプロダクトを作り続ける組織です。モバイルアプリケーションの受託開発、技術支援、コンサルティングを行っております。アプリ開発のご依頼や開発面でのお困りの際はお気楽にお問合せください。
https://neverjp.com/contact/

## 概要
最近、graphQLを使用しているので、勉強のためにタイトル記載のパッケージを使用してみました。
色々なパッケージがある中、mutationやqueryをコード生成してくれるgraphql_codegen、キャッシュ機能があるgraphql_flutterが実際に活かせそうだと思ったので使用してみました。

## GraphQLのAPI

https://rickandmortyapi.com/graphql

今回はこちらを使用しました

## デモアプリ
<p float="left">
  <img src="https://i.gyazo.com/31e7252ac98e39736390e63971b669b5.gif" width="48%">
  <img src="https://i.gyazo.com/6aa97ad432a11a3caafd164be0d5bd7e.gif" width="48%">
</p>
20件取得して、次のページでさらに20件取得してます

## graphql_codegen

https://pub.dev/packages/graphql_codegen

- graphql_codegen
- build_runner（コード生成に必要）

のパッケージをpubspec.yamlに追加します

### schema.graphqlを追加

IDEのGraphiQLやApollo Studioもありますが、今回はPostmanを使用してスキーマを取得しました。

schema.graphqlファイルは、Dartのコード生成に必須です。
query、mutation、subscriptionなどの構造を定義して、そのスキーマに基づいて、queryやmutationを実装し、適切なデータ型でレスポンスできます。
つまり、このファイルに基づいて.graphqlで定義されたqueryなどを解釈してコード生成するので、ないとコード生成されないです。
私自身、少しはまりました。。。

``` lib/graphql/schema.graphql
type Query {
    characters(page: Int!, filter: FilterCharacter): Characters
    locations(page: Int!): Locations
    episodes(page: Int!): Episodes
}

type Character {
    id: ID!
    name: String
    image: String
}

type Location {
    id: ID!
    name: String
    type: String
}

type Episode {
    id: ID!
    name: String
    episode: String
}

type Characters {
    results: [Character]
}

type Locations {
    results: [Location]
}

type Episodes {
    results: [Episode]
}

input FilterCharacter {
    name: String
}
```

### queries.graphqlを追加

データ取得に必要な情報を記載していきます

``` lib/graphql/queries.graphql
query GetCharacters($page: Int!, $name: String!) {
    characters(page: $page, filter: { name: $name }) {
        results {
            id
            name
            image
        }
    }
}

query GetLocations {
    locations(page: 1) {
        results {
            id
            name
            type
        }
    }
}

query GetEpisodes {
    episodes(page: 1) {
        results {
            id
            name
            episode
        }
    }
}
```

### 自動生成の設定
graphql_codegenでコードを自動生成するので、その設定をします

build.yamlに下記を追加
```
targets:
  $default:
    builders:
      graphql_codegen:
        options:
          scopes:
            - lib/graphql/**
          clients:
            - graphql
            - graphql_flutter
```
graphql_flutterを使用するので追加してます

```
lib/graphql/**
lib/graphql/   　←コード生成されず
```
コード生成されずにハマったので、**は必要です！！

## build_runnerでコード生成
```
dart run build_runner build
```
上記コマンドでコード生成します

![代替テキスト](https://i.gyazo.com/0fde9b80c80c3ff4844371cf1c6c7715.png)

上記のように、ファイルが生成されます

## 比較するとわかりやすいので、codegenの有無

### codegenを使用しない場合
```dart
const String getCharacters = r'''
query GetCharacters($page: Int!, $name: String!) {
  characters(page: $page, filter: { name: $name }) {
    results {
      id
      name
      image
    }
  }
}
''';

const String getLocations = r'''
query {
  locations(page: 1) {
    results {
      id
      name
      type
    }
  }
}
''';

const String getEpisodes = r'''
query {
  episodes(page: 1) {
    results {
      id
      name
      episode
    }
  }
}
''';
```

このように、ハードコードで書いていくことになります
実際、タイプミスでエラーになって悩んだ経験もあるので、そういうリスクは減らしたいです。

### codegenを使用する場合
```graphql
query GetCharacters($page: Int!, $name: String!) {
    characters(page: $page, filter: { name: $name }) {
        results {
            id
            name
            image
        }
    }
}

query GetLocations {
    locations(page: 1) {
        results {
            id
            name
            type
        }
    }
}

query GetEpisodes {
    episodes(page: 1) {
        results {
            id
            name
            episode
        }
    }
}
```
このように記載できます
コード整形もできますし、コード生成の段階でエラーがでます。

## graphql_flutter

https://pub.dev/packages/graphql_flutter

- graphql_codegen
- flutter_hooks

のパッケージをpubspec.yamlに追加します
状態管理にflutter_hooksを使用します

```　lib/main.dart
class MyApp extends HookWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    final HttpLink httpLink = HttpLink('https://rickandmortyapi.com/graphql');

    final client = useState<GraphQLClient>(
      GraphQLClient(
        link: httpLink,
        cache: GraphQLCache(),
      ),
    );

    return GraphQLProvider(
      client: client,
      child: const MaterialApp(
        home: CharacterList(),
      ),
    );
  }
}
```
キャッシュのところでは、[Hive](https://pub.dev/packages/hive)を使用してローカルDBに保存するのもよさそうです

### 一覧表示
```dart
Query$GetCharacters$Widget(
  options: Options$Query$GetCharacters(
    variables: Variables$Query$GetCharacters(
      page: index.value,
      name: searchText.value,
    ),
  ),
  builder: (
    QueryResult result, {
    VoidCallback? refetch,
    FetchMore? fetchMore,
  }) {
    if (result.isLoading) {
      return const Center(child: CircularProgressIndicator());
    }

    if (result.hasException) {
      return Text(result.exception.toString());
    }
    final data = result.data;
    if (data == null) {
      return const Text('データがありません');
    }

    final charactersResponse =
        CharactersResponse.fromJson(data['characters']);
    final characters = charactersResponse.results;

    return ListView.builder(
      itemCount: characters.length,
      itemBuilder: (context, index) {
        final character = characters[index];

        return ListTile(
          title: Text(character.name ?? ''),
          leading: Image.network(character.image ?? ''),
        );
      },
    );
  },
),
```

codegenで生成したコードは上記のように記載できます。

```Query$GetCharacters$Widget```：codegenで生成されたウィジェットで、この場合はGetCharactersのqueryの結果を表示するWidgetです。

```
options: Options$Query$GetCharacters(
    variables: Variables$Query$GetCharacters(
      page: index.value,
      name: searchText.value,
    ),
),
```
queryを実行するオプションを設定します。
```Variables$Query$GetCharacters``` ：クエリに必要な変数を指定しています（この場合は、pageの数字と、キャラクターの名前です）。

mutationの場合は、mutaions.graphqlでmutationを生成して、QueryのところをMutationに変更します。

### コード全文
``` lib/character_list.dart
class CharacterList extends HookWidget {
  const CharacterList({super.key});

  @override
  Widget build(BuildContext context) {
    final searchText = useState<String>('');
    final index = useState<int>(1);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Rick and Morty Characters'),
      ),
      body: Column(
        children: [
          Row(
            children: [
              Expanded(
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: TextFormField(
                    decoration: const InputDecoration(
                      labelText: 'Search',
                      border: OutlineInputBorder(),
                    ),
                    onChanged: (value) {
                      searchText.value = value;
                    },
                  ),
                ),
              ),
              IconButton(
                icon: const Icon(Icons.arrow_back),
                onPressed: () {
                  if (index.value > 1) {
                    index.value--;
                  }
                },
              ),
              IconButton(
                icon: const Icon(Icons.arrow_forward),
                onPressed: () => index.value++,
              ),
            ],
          ),
          Expanded(
            child: Query$GetCharacters$Widget(
              options: Options$Query$GetCharacters(
                variables: Variables$Query$GetCharacters(
                  page: index.value,
                  name: searchText.value,
                ),
              ),
              builder: (
                QueryResult result, {
                VoidCallback? refetch,
                FetchMore? fetchMore,
              }) {
                if (result.isLoading) {
                  return const Center(child: CircularProgressIndicator());
                }

                if (result.hasException) {
                  return Text(result.exception.toString());
                }
                final data = result.data;
                if (data == null) {
                  return const Text('データがありません');
                }

                final charactersResponse =
                    CharactersResponse.fromJson(data['characters']);
                final characters = charactersResponse.results;

                return ListView.builder(
                  itemCount: characters.length,
                  itemBuilder: (context, index) {
                    final character = characters[index];

                    return ListTile(
                      title: Text(character.name ?? ''),
                      leading: Image.network(character.image ?? ''),
                    );
                  },
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}
```

## 終わりに
graphql_codegen、graphql_flutterともに、いいパッケージだと思いました。

query、mutationはgraphql_codegenで自動生成し、Widget内で状態が完結する場合は、hooksとgraphql_flutter、他の画面でも状態管理が必要な場合はRiverpodを使用するなど、自分の中でそれぞれの役割が明確になった気がします。
他にもgraphQLのパッケージがあるので、使用、比較してみたいと思いました。

ありがとうございました！