OGP 画像

ジャンプTOON アプリチームの吉田航己(@koki8442)です。
5 月にサービスを開始した 「ジャンプTOON」 では、モバイルアプリを Flutter で開発し、通信には GraphQL を用いています。
本記事では GraphQL の解説、Flutter アプリで GraphQL を活用する際の工夫点や開発の知見を紹介していきます。

目次

  1. GraphQL の概要
  2. ジャンプTOON アプリでの GraphQL の使用
  3. Flutter で GraphQL を使うメリット
  4. Flutter × GraphQL 開発の工夫
  5. おわりに
  6. 参考文献


——

GraphQL の概要

GraphQL は Meta 社が REST の問題点を解決するために開発した API クエリ言語およびランタイムです。その概念は特定の言語やデータベースなどに依存するものではなく、ビジネスドメインのデータを Node と Edge のグラフで構造化し、必要に応じてそのグラフの一部を利用するという API 仕様そのものを指します。
GraphQL Schema にアプリケーションで使用するデータの構造を型付きで定義し、クライアントは都度グラフの中から必要なデータだけを柔軟にリクエストし取得できます。これにより REST の課題であったオーバーフェッチやアンダーフェッチの問題を解決できます。
また、REST では取得するデータごとにエンドポイントを分ける必要がありますが、GraphQLではリクエストボディに Query を記述することでエンドポイントを統一できます。

ジャンプTOON アプリでの GraphQL の使用

ジャンプTOON のアプリでは、GraphQL クライアントに graphql_flutter (graphql)、GraphQL 関連のコードの自動生成に graphql_codegen を使用しています。
graphql は Apollo Client をモデルとした GraphQL クライアントを dart で使用するためのパッケージです。これを Flutter から使いやすくするための API や Widget を提供するラッパーが graphql_flutter です。
graphql_flutter は Query, Mutation などの基本的な Operation 用のフックを提供しており、それらのカスタムフックを利用して通信を行なっています。

class SettingScreen extends HookConsumerWidget {
  const SettingScreen({super.key});
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final isGuestUser = useIsGuestUser();
    final query = useQuery$Setting(options);
    return Scaffold(
      // ...
      body: GraphQLQueryContainer(
        query: query,
        onLoadingWidget: SkeletonSettingScreen(isGuestUser: isGuestUser),
        onErrorWidget: (error, stackTrace) => ErrorContainer(
          error: error,
          stackTrace: stackTrace,
          onAction: query.refetch,
        ),
        child: (data) {
          return SingleChildScrollView(
            // ...
          );
        },
      ),
    );
  }
}

これらのフックはレスポンスデータの取得やキャッシュの更新によって自動的にリビルドを引き起こします。私たちのチームでは Query の状態に合わせて UI を更新するための GraphQLQueryContainer というユーティリティ Widget を用意しています。

/// GraphQL useQuery 用のコンテナウィジェット
class GraphQLQueryContainer extends HookWidget {
  const GraphQLQueryContainer({
    required this.query,
    required this.child,
    this.onLoadingWidget,
    this.onEmptyWidget,
    this.onErrorWidget,
    super.key,
  });


  final QueryHookResult query;
  final Widget Function(TParsed data) child;
  final Widget? onLoadingWidget;
  final Widget? onEmptyWidget;
  final Widget Function(GraphQLException error, StackTrace? stackTrace)?
      onErrorWidget;

  @override
  Widget build(BuildContext context) {
    if (result.hasException && onErrorWidget != null) {
      // エラーが発生した場合
      return onErrorWidget!(
        GraphQLException.fromOperationException(result.exception),
        StackTrace.current,
      );
    } else if (result.data == null && result.isNotLoading) {
      // データがない場合
      return onEmptyWidget ?? const SizedBox();
    } else if (result.data == null && result.isLoading) {
      // ローディング状態
      return onLoadingWidget ?? const SizedBox();
    } else {
      // データが存在する場合
      return child(result.parsedData as TParsed);
    }
  }
}

graphql_codegen を使用して Query や Fragment を .graphql ファイルに定義し、これらのファイルから dart コードを自動生成しています。Schema から dart の class を自動で作成してくれるため、型安全に開発を進めることが可能です。

setting.query.graphql
query Setting {
  me {
    id
  }
}
setting_query.graphql から自動生成される setting_query.graphql.dart
class Query$Setting {
  Query$Setting({
    required this.me,
    this.$__typename = 'Query',
  });

  factory Query$Setting.fromJson(Map<String, dynamic> json) {
    final l$me = json['me'];
    final l$$__typename = json['__typename'];
    return Query$Setting(
      me: Query$Setting$me.fromJson((l$me as Map<String, dynamic>)),
      $__typename: (l$$__typename as String),
    );
  }

  final Query$Setting$me me;

  final String $__typename;

  Map<String, dynamic> toJson() {
    final _resultData = <String, dynamic>{};
    final l$me = me;
    _resultData['me'] = l$me.toJson();
    final l$$__typename = $__typename;
    _resultData['__typename'] = l$$__typename;
    return _resultData;
  }

  @override
  int get hashCode {
    // ...
  }

  @override
  bool operator ==(Object other) {
    // ...
  }
}

class Query$Setting$me {
  Query$Setting$me({
    required this.id,
    this.$__typename = 'User',
  });

  factory Query$Setting$me.fromJson(Map<String, dynamic> json) {
    final l$id = json['id'];
    final l$$__typename = json['__typename'];
    return Query$Setting$me(
      id: (l$id as String),
      $__typename: (l$$__typename as String),
    );
  }

  final String id;

  final String $__typename;

  Map<String, dynamic> toJson() {
    final _resultData = <String, dynamic>{};
    final l$id = id;
    _resultData['id'] = l$id;
    final l$$__typename = $__typename;
    _resultData['__typename'] = l$$__typename;
    return _resultData;
  }

  @override
  int get hashCode {
    // ...
  }

  @override
  bool operator ==(Object other) {
    // ...
  }
}

// ...

graphql_flutter.QueryHookResult<Query$Setting> useQuery$Setting(
        [Options$Query$Setting? options]) =>
    graphql_flutter.useQuery(options ?? Options$Query$Setting());

// ... 

 

また、Fragment Colocation を導入しコンポーネントファイルとそこで使用するデータ群を取りまとめた Fragment を近くに配置しています。これによってコンポーネントと Fragment を 1 : 1 で対応させ、どのコンポーネントでどんなデータを使用するかが明確になります。

画面がコンポーネントの組み合わせで作られるように、その画面に必要なデータを定義する Query も Fragment の組み合わせで作成することができます。

ui/
├── component/
│   └── text/
│       ├── user_name_text_fragment.graphql
│       ├── user_name_text_fragment.graphql.dart
│       └── user_name_text.dart
└──screen/
    └── root/
        └── my_page/
            ├── my_page_query.graphql
            ├── my_page_query.graphql.dart
            └── my_page_screen.dart

Flutter で GraphQL を使うメリット

GraphQL を使用して感じたメリットは、スキーマファースト開発、クライアントキャッシュ、宣言的 UI との親和性の 3 点です。

スキーマファースト開発

スキーマファーストで開発することで、Schema を元にクライアント・サーバー間でのコミュニケーションコストを削減でき、API が完成前でも並行開発が可能になります。

Schema が仕様書の役割を果たし、API を俯瞰して理解することができます。実際にジャンプTOON チームでは Schema 定義後に、実装者が Schema 共有会を実施してクラアント・サーバー間での認識の齟齬を減らす取り組みを行っています。

加えて、クライアントは API の実装完了前でも Schema からデータをモックすることで API と同時並行で開発を進めることができます。

私たちのチームではアプリ・ウェブ・サーバーでリポジトリを分けて開発しているため、files-sync-action を使用し、サーバーで Schema が更新されると自動でクラアント側のリポジトリに PR が作成されるようにしています。

スキーマの同期

クライアントキャッシュ

個人的に Flutter で GraphQL を使用する最大のメリットは GraphQL クライアントに備わっているキャッシュ機構を使える点だと感じています。

多くの GraphQL クライアントでは、Query, Mutation などの Operation の結果を正規化してキャッシュします。正規化は以下の 3 ステップで行われます。

  1. Operation 結果を分割して個別のオブジェクトにする
  2. 分割したオブジェクトに一意の key をつける
  3. それぞれのオブジェクトをフラットに保存する

例えば、以下のような漫画のタイトルやエピソードの Query とそのレスポンスがあるとします。

query {
  comics {
    id
    title
    episodes {
      id
      title
  }
}
[   
  Comics: [
    {
      id: 1,
      __typename: “Commics”,
      title: “WebToon Title”,
      episodes: [
        {
          id: 1,
          __typename: “Episodes”,
          title: “Revenge”,
        },
        {
          id: 2,
          __typename: “Episodes”,
          title: “Imagination”,
        },
      ],
    },     
  ]
]

GraphQL クライアントは上記のような階層構造のレスポンスデータをそのまま保存するのではなく、それぞれのオブジェクトに分割します。

そしてそのオブジェクトに対して、デフォルトでは __typename:id を key としてフラットにキャッシュを保存しています。この一連の処理のことを正規化(Normalization)と呼びます。

キャッシュの正規化

実際に GraphQL クライアントのキャッシュを視覚化した画面が下の図です。

Query, Comics:1, Episodes:1, Episodes:2 … などの階層化されていたオブジェクトがキャッシュ内ではフラットに保存されていることがわかります。__ref として key を保持することで、オブジェクトが参照を持ち階層構造を表現しています。

キャッシュ画面

このように Query 結果を正規化し保持するキャッシュは Mutation のレスポンスでも自動で更新され、SSOT(Single Source of Truth) として機能します。

新しく Query を実行した場合、すでに他の箇所の Query でキャッシュが存在していれば、通信することなくそのキャッシュデータを返します。正規化されている恩恵でキャッシュが更新されるとそれを参照している箇所も自動で変更されます。

宣言的 UI との親和性

データを元に UI を構築する宣言的 UI は、先ほど説明した GraphQL クライアントのキャッシュ機構との親和性が非常に高いです。ある Operation で正規化されたキャッシュデータが更新されると、それを参照しているすべての UI が更新されるためです。

GraphQL クライアント技術は、宣言的 UI におけるアーキテクチャの一つといえます。Fragment Colocation のアプローチを用いることで、UI に基づいて必要なデータを Fragment に定義し、dartの class を自動生成してそのまま使用することができます。さらに、データ取得には UI から直接 Query のフックを呼び出し、そのレスポンスデータをそのまま利用することができます。

この GraphQL クライアント技術の利点は REST にも応用可能です。実際に Redux の公式ドキュメントでも Store データの正規化について言及されています。

ただしその正規化の作業が自動化されていません。これは REST がリソース指向であり、複数のエンドポイントから取得されるデータの関係性をクライアント側で判断する必要があるためです。GraphQL ではデータの関係性がグラフ構造に反映されているため、GraphQL クライアントが自動的に正規化を行うことが可能となっています。

自動的な正規化はできませんが、API レスポンスをキャッシュするアプローチを採用した TanStack Query (旧 React Query) や SWR などの技術が React には存在しています。

Flutter にも TacStack Query にインスパイアされた fQuery があります。こちらはまだ β 版ですが、今後の発展に期待しています。

Flutter × GraphQL 開発の工夫

いいね問題

GraphQL クライアントのキャッシュ更新によるリビルドでカバーしきれないケースも存在します。例えば、Query の結果がリスト形式で、その一覧を表示しているような場合です。ジャンプTOON アプリではお気に入り一覧画面がこのケースに該当します。この画面を例にキャッシュ自動更新で対応しきれない場合の対策について紹介します。

ジャンプTOON アプリではお気に入り画面から作品詳細画面へと遷移することができます。お気に入り画面ではお気に入り登録をした作品が一覧表示され、その並び順が変更可能です。作品詳細では、作品をお気に入り登録/解除することができます。

ここで問題になってくるのがユーザーが以下のような行動をとった場合です。

  1. お気に入り画面から作品詳細に画面遷移
  2. 作品詳細でお気に入り登録を解除する
  3. お気に入り画面に戻る

3 の時点で、お気に入り画面から登録解除された作品は消えている状態が理想ですが、特に何もしなければ作品が残り続けてしまいます。変更を反映させるために画面を Refresh すると、スクロール位置がトップに戻ってしまったり、再度ローディング状態になるなどユーザー体験が悪化する問題があります。

いいね問題

このように、お気に入りの状態が以下の要件を同時に満たすことが難しいという問題を、「いいね問題」と呼んでいます。

  • 整合性 : お気に入りリストが現在のソート順に従って適切に更新され、過不足がないこと
  • 即時性 : お気に入りの更新が即時に反映され、ユーザーにシームレスな体験を提供すること
  • スクロール位置 : 前回のスクロール位置を保持し、再度スクロールし直す必要がないこと
  • サーバー負荷 : サーバーに過度な負荷をかけないこと

このいいね問題への対策として、私たちのチームではいくつかの方法を検討しました。以下は検討した対策の一部です。

キャッシュの手動更新

作品詳細でお気に入り登録/解除の Mutation 直後に update フックを使用して、お気に入り一覧のキャッシュを手動で更新する方法です。

項目 評価 説明
整合性 サーバー側でのお気に入り一覧更新と同様のロジックをアプリでも持つ必要がある
即時性 即時反映される
スクロール位置 キャッシュの追加/削除のためスクロール位置は保持される
サーバー負荷 なし キャッシュの更新のためサーバーに負荷はない
実装難易度 それぞれのソート順のキャッシュの扱いを考慮する必要がある

Refresh (お気に入り画面で)

お気に入り画面への遷移を監視して戻ってきた際に Refresh する方法です。

項目 評価 説明
整合性 再度リクエストするため整合性は担保される
即時性 × ローディング状態になる
スクロール位置 × Refresh 時に一度ローディング画面になるため、Refresh 完了後にスクロールが初期位置に戻る
サーバー負荷 お気に入り画面に遷移した際に 1 回 Fetch するだけ
実装難易度 実装が容易

Refresh (Mutation 直後に)

作品詳細でお気に入り登録/解除の Mutation 直後にお気に入り一覧の状態を Refresh する方法です。

項目 評価 説明
整合性 再度リクエストするため整合性は担保される
即時性 Mutation 直後に Refresh し、お気に入り画面では通信が完了している可能性高い
スクロール位置 × スクロールが初期位置に戻る
サーバー負荷 お気に入りの登録/解除を連続で行うと負荷が高くなる恐れがある
実装難易度 実装が容易

Bulk Fetch (お気に入り画面で)

お気に入り画面への遷移を監視して戻ってきた際にお気に入り一覧を Bulk Fetch する方法です。

ここでの Bulk Fetch とは Fetch 済みのお気に入りの件数を保持しておき、そのデータまで再度 Fetch することを指します。Refresh との違いは、Refresh は 1 回だけ Fetch するのに対し、Bulk Fetch は必要であれば取得済みデータまで複数回に渡り Fetch するという点です。

項目 評価 説明
整合性 整合性は担保される
即時性 × ローディング状態になる
スクロール位置 スクロール位置を明示的に保持し、Fetch 完了後に移動させる必要あり
サーバー負荷 複数回の Fetch を行う可能性があるがそこまで多くならない
実装難易度 ・Fetch 後にスクロール位置を移動させるロジックが必要
・Fetch 済みの件数を保持しておく必要がある

Bulk Fetch (Mutation 直後に)

作品詳細でお気に入り登録/解除の Mutation 直後にお気に入り一覧を Bulk Fetch する方法です。

項目 評価 説明
整合性 整合性は担保される
即時性 Mutation 直後に Refresh し、お気に入り画面では通信が完了している可能性高い
スクロール位置 スクロール位置を明示的に保持し、Fetch 完了後に移動させる必要あり
サーバー負荷 お気に入りの登録/解除を連続で行うと負荷が高くなる恐れがある
実装難易度 ・Fetch 後にスクロール位置を移動させるロジックが必要
・Fetch 済みの件数を保持しておく必要がある

これらの対策をの中から、私たちのチームでは「キャッシュの手動更新」のアプローチを採用しました。

整合性 即時性 スクロール位置 サーバ負荷 実装難易度
キャッシュの手動更新 なし
Refresh(お気に入り画面で) × ×
Refresh(Mutation 直後に) ×
Bulk Fetch(お気に入り画面で) ×
Bulk Fetch(Mutation 直後に)

 

前述したように、お気に入りの登録/解除の Mutation の update フックでキャッシュを更新しています。更新ロジック自体はそこまで複雑なものではなく、例えばお気に入り解除の場合は保持しているお気に入り作品のリストから該当の id の作品を削除するだけです。

お気に入り登録の場合、現在のソート順に基づいて追加された作品の挿入位置を決定します。例えばソート順が「お気に入り登録順」の場合、リストの先頭に新しく更新された作品データを挿入します。

キャッシュ手動更新のコード例
// お気に入り登録/解除を行うカスタムフック
  Favorite useFavorite({
    RetryGraphQLExceptionCallback? onError,
    List<Object?> keys = const [],
  }) {
    // ...
   
    // Query の variables
    final variables = ref.read(favoriteSeriesListQueryVariablesProvider());
    // お気に入り画面のソート順
    final sortOrder = ref.read(favoriteSeriesSortOrderStateProvider);
  
    final mutation = useMutation$UpdateFavoriteSeries(
      WidgetOptions$Mutation$UpdateFavoriteSeries(
        // Mutation の update 時にキャッシュ手動更新のメソッドを呼び出す
        update: (_, result) => _insertFavoriteSeriesCache(
          client: client,
          sortOrder: sortOrder,
          variables: variables,
          result: result,
        ),
      ),
    );
    
    // ...
  }
  
  // お気に入り登録をした場合
  void _insertFavoriteSeriesCache({
    required GraphQLClient client,
    required FavoriteSeriesSortOrder sortOrder,
    required Variables$Query$FavoriteSeriesList variables,
    required QueryResult<Mutation$UpdateFavoriteSeries>? result,
  }) {
    // 現在のソート順のキャッシュを読み込む
    final data = client.readQuery$FavoriteSeriesList(variables: variables);
    
    // ...
    
    final connection = data.userSeriesFavorite;
    final hasNextPage = connection.pageInfo.hasNextPage;
    final items = connection.edges.map((e) => e.node).toList();
    
    // ...
    
    // キャッシュのリストの中から新たにお気に入りされた作品データを挿入する index を探す
    final index = response.findFavoriteInsertionIndex(items, sortOrder);
    items.insert(index, response);
  
    client.writeQuery$FavoriteSeriesList(
      variables: variables,
      data: data.copyWith(
        userSeriesFavorite: data.userSeriesFavorite.copyWith(
          edges: items
              .map(
                (e) => Query$FavoriteSeriesList$userSeriesFavorite$edges(
                  node: e,
                ),
              )
              .toList(),
        ),
      ),
    );
  }

このようにお気に入り作品のキャッシュを手動更新することで、整合性、即時性、スクロール位置を担保したユーザー体験が可能になります。

ページネーション

useQuery を使用してデータを取得したレスポンスデータを基に、ページネーションを実現するため、私たちのチームでは GraphQLPagingListView という Widget を自作しています。infinite_scroll_pagination を利用すれば手軽に無限スクロールを実現可能ですが、GraphQL との組み合わせでは以下のような問題点がありました。

  • PagingController に GraphQL のレスポンスデータを渡しデータ管理を行うため、データソースが GraphQL のキャッシュと PagingController の二重になってしまう
  • PagingController 内で fetch や fetchMore を制御するため、データ取得に useQuery が使用できず、キャッシュ更新後の自動リビルドが行えない

そこで GraphQL のレスポンスデータを基に、ページネーションを実現する GraphQLPagingListView Widget を作成しています。入力に対してどのような見た目にするのか決めるための PagingState を別途定義し、その値によって Widget を切り替えるシンプルな Stateless Widget となっています。

infinite_scroll_pagination を使用した例
class FavoriteSeriesScreen extends HookConsumerWidget {
  const FavoriteSeriesScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final client = useGraphQLClient();
    // fetch 関数の定義
    final fetch = // ...

    // fetch 後の cache 更新の関数
    final updateCache = // ...

    // PagingController の定義
    final pagingController = PagingController();
    final pagingStatus = useState(pagingController.value.status);

    useEffectOnce(() {
      Future fetchPage(PageKeyType pageKey) async {
        try {
          final (newItems, cacheItem, endCursor, hasNextPage) =
              await fetch(pageKey);
          if (hasNextPage) {
            pagingController.appendPage(newItems, endCursor);
          } else {
            pagingController.appendLastPage(newItems);
          }

          updateCache?.call(
            pageKey: pageKey,
            cacheItems: cacheItem,
            endCursor: endCursor,
            hasNextPage: hasNextPage,
          );
        } catch (error) {
          pagingController.error = error;
        }
      }
  
      pagingController
        ..addPageRequestListener(fetchPage)
        ..addStatusListener((status) => pagingStatus.value = status);
  
      return pagingController.dispose;
    });

    // 他の箇所からキャッシュが更新された時用のキャッシュの監視
    final queryCache = useQuery(
      Options$Query$FavoriteSeriesList(
        fetchPolicy: FetchPolicy.cacheOnly,
      ),
    );

    return PagedListView(
        pagingController: pagingController,
        // ...
    );
  }
}
  
GraphQLPagingListView を使用した例
class FavoriteSeriesScreen extends HookConsumerWidget {
  const FavoriteSeriesScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final query = useQuery$FavoriteSeriesList(firstQueryOptions);

    final result = query.result;
    final pagingData = PagingData(
      itemCount: result.parsedData?.userSeriesFavorite.edges.length,
      hasNext:
          result.parsedData?.userSeriesFavorite.pageInfo.hasNextPage ?? false,
      result: result,
    );

    return GraphQLPagingListView<Query$FavoriteSeriesList>.builder(
      data: pagingData,
      onFetchMore: () => query.fetchMore(
        FetchMoreOptions$Query$FavoriteSeriesList(
          // ...
        ),
      ),
      // ...
    );
  }
}

@immutable
class PagingData {
  const PagingData({
    required this.itemCount,
    required this.hasNext,
    required this.result,
    this.refreshing = false,
  });

  final int? itemCount;
  final bool hasNext;
  final QueryResult result;
  final bool refreshing;

  PagingState get state => PagingState.fromPagingData(this);
}

/// [PagingData] からページングの状態を定義する。
enum PagingState {
  /// ネットワーク経由で取得したデータが空
  empty,

  /// キャッシュ経由で取得したデータが空
  /// データが空にも関わらず、ローディング用の UI を表示したいケースがあるため、[empty] とは別に定義している
  cacheEmpty,

  /// 初回読み込み中
  initialLoading,

  /// 初回読み込み時にエラーが発生
  initialError,

  /// 追加読み込み中
  nextLoading,

  /// 追加読み込み時にエラーが発生
  nextError,

  /// 初回読み込み完了後 or 追加読み込み完了後
  stable,
  ;
}
  

HttpClient

HttpClient に dio を使用しています。下の図のようにアップデートやメンテナンス情報などの静的なアセットの取得は GraphQL を介さないため、Asset エンドポイント向けの HttpClient と GraphQL エンドポイント向けの HttpClient に分けています。Asset エンドポイント向け、GraphQL エンドポイント向けの HttpClient に dio を使用することで、これらの通信に共通の Logger を使用することができます。

GraphQL クライアントから GraphQL サーバーまでの通信のフローを制御するための Link は、Http/Https 通信を担う HttpLink(DioLink), 認証用の idToken を headerKey に追加するための AuthLink, ユーザーアカウントの状態を監視して適切なエラーを出し分けるための ErrorLink で構成されています。

HttpClient

アプリの起動時間やネットワークリクエストのパフォーマンス計測用に Firebase Performance Monitoring(FPM) を導入しています。FPM は HttpClient.enableTimelineLogging を使用しているネットワークリクエストの指標を自動で集計します。しかし、dio が内部で生成する HttpClient は HttpClient.enableTimelineLogging を設定していないため、Interceptor をカスタムで作成し、ネットワークをトレースする必要があります。

class DioFirebasePerformanceInterceptor extends Interceptor {
  const DioFirebasePerformanceInterceptor();

  @override
  Future onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final metric = await newCommonHttpMetric(
      options.uri.toString(),
      options.method.asHttpMethod()!,
    );
    options.extra[extraKey] = metric;

    // GraphQL によるリクエストの場合は Query をカスタム属性に追加する
    if (options.baseUrl == const String.fromEnvironment('apiEndpoint') &&
        options.data != null &&
        options.path == '/query') {
      final queryData = (options.data as Map<String, dynamic>)['query']
          .toString()
          .split(' ');
      final queryType = queryData[0];
      final queryName = queryData[1].split('(')[0];

      final attributeValue = '$queryType $queryName'
          // カスタム属性の値は 100 文字以下にする必要がある
          .characters
          .take(100)
          .toString();

      metric.putAttribute('Query', attributeValue);
    }

    final requestContentLength = options.data.toString().toByteSize();
    await metric.start();
    if (requestContentLength != null) {
      metric.requestPayloadSize = requestContentLength;
    }
    return super.onRequest(options, handler);
  }

  @override
  Future onResponse(
    Response response,
    ResponseInterceptorHandler handler,
  ) async {
    await _stopMetric(response, response.requestOptions);
    return super.onResponse(response, handler);
  }

  @override
  Future onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    await _stopMetric(err.response, err.requestOptions);
    return super.onError(err, handler);
  }

  Future _stopMetric(
    Response? response,
    RequestOptions options,
  ) async {
    final metric = options.extra[extraKey];
    if (metric is HttpMetric) {
      options.extra.remove(extraKey);
      metric.setResponse(response);
      await metric.stop();
    }
  }
}

おわりに

最後までお読みいただきありがとうございました。GraphQL の利便性が少しでも伝わり、Flutter で GraphQL を使用する事例が増えれば嬉しいです。

後日別メンバーの記事も公開されますのでぜひご期待ください。

参考文献

Avatar photo
2023年入社のFlutterエンジニアの吉田航己です。現在は「タテマンガ」アプリのジャンプTOONの開発に従事しています。個人開発で野球のデータ管理アプリ「野球ログ」も開発しています。