ZOZOマッチアプリのメッセージ機能を支えるFlutter × GraphQLの実装

ZOZOマッチアプリのメッセージ機能を支えるFlutter × GraphQLの実装

はじめに

こんにちは、新規事業部フロントエンドブロックの池田です。普段はZOZOマッチのアプリ開発を担当しています。2025年6月にマッチングアプリ「ZOZOマッチ」をリリースしました。ZOZOマッチにはメッセージ機能があり、この機能を実現するためにGraphQLを用いています。本記事ではFlutterアプリでGraphQLを用いたリアルタイムメッセージ機能の開発の知見と工夫した点をご紹介します。

なお、ZOZOマッチアプリ全体のアーキテクチャや技術構成については、別記事「ZOZOマッチアプリのアーキテクチャと技術構成」で詳しく紹介しています。

目次

GraphQLとは

GraphQLは、Meta社が公開したAPIのクエリ言語およびランタイムです。REST APIの課題を解決するために開発され、現在では多くの企業で採用されています。

GraphQLの主な特徴

1. 単一エンドポイント

REST APIでは複数のエンドポイント(/users/messagesなど)が存在しますが、GraphQLではすべてのリクエストが単一のエンドポイント(通常は/graphql)に送信されます。

2. 必要なデータのみ取得

クライアントが必要なフィールドを明示的に指定できるため、オーバーフェッチング(不要なデータの取得)やアンダーフェッチング(追加リクエストが必要)を防げます。

# 必要なフィールドだけを指定
query {
  user(id: "123") {
    name
    avatar  # この2つのフィールドのみ取得
  }
}

3. 強力な型システム

スキーマによって厳密な型定義が行われ、開発時の型安全性が向上します。また、GraphQL Playgroundなどのツールで自動的にドキュメントが生成されます。

4. リアルタイム通信のサポート

Subscription機能によりWebSocket1経由でリアルタイムデータ配信が可能です。これがZOZOマッチのメッセージ機能で重要な役割を果たしています。

GraphQLの3つの操作タイプ

GraphQLには主に3つの操作タイプがあります。

操作タイプ 用途 REST APIでの相当
Query データの取得 GET
Mutation データの作成・更新・削除 POST/PUT/DELETE
Subscription リアルタイムデータの購読 WebSocket/SSE

ZOZOマッチでのGraphQLの利用背景

ZOZOマッチではユーザー同士がリアルタイムでメッセージをやり取りする機能があります。マッチングアプリにおいて、メッセージ機能は最も重要な機能の1つであり、以下の要件を満たす必要がありました。

  • メッセージの送受信がリアルタイムで反映される
  • オフライン時のメッセージもオンライン復帰時に受信できる
  • 既読の管理ができる

従来のREST APIでこれらを実現しようとすると、以下の課題がありました。

  1. リアルタイム性の実装が複雑: ポーリングでは遅延が発生し、WebSocketの実装は複雑
  2. 複数回のAPIコール: メッセージ一覧、ユーザー情報、既読状態などを別々に取得する必要がある
  3. オーバーフェッチング: 不要なデータも含めて取得してしまい、通信量が増加
  4. 型安全性の確保が困難: APIレスポンスの型定義を手動で管理する必要がある

これらの課題を解決するため、GraphQLを採用しました。GraphQLでは以下の機能により各課題に対応できます。

  1. Subscription: WebSocketベースのリアルタイム通信を標準機能として提供し、リアルタイムでのメッセージの反映を実現
  2. 単一エンドポイント: 1回のリクエストで必要なデータ(メッセージ、ユーザー情報、既読状態など)をまとめて取得可能
  3. 柔軟なクエリ: クライアント側で必要なフィールドのみを指定して取得でき、通信量を最適化
  4. 強力な型システム: スキーマから型安全なコードを自動生成し、開発時の型チェックを実現
メッセージ一覧画面 メッセージ画面
メッセージ一覧のスクリーンショット メッセージ画面のスクリーンショット

Flutter×GraphQLの実装

Flutterへの導入

FlutterでGraphQLを利用するために、以下のパッケージを導入しました。graphql_flutterはGraphQLクエリの実行とウィジェットの提供、graphql_codegenはGraphQLスキーマから型安全なDartコードの自動生成を担当します。

主要パッケージ

dependencies:
  graphql_flutter: ^5.2.1

dev_dependencies:
  graphql_codegen: ^2.0.0

GraphQL Clientの設定

まず、アプリ全体でGraphQL Clientを利用できるように設定します。WebSocketによるSubscriptionをサポートするため、HTTPとWebSocketの両方のリンクを設定しています。また、AuthLinkを使用してBearerトークンによる認証を管理し、すべてのGraphQLリクエストに認証情報を自動的に付与しています。WebSocketの実装に関しては後述で詳細に記載しています。

  static Future<GraphQLClient> _initializeGraphQLClient(Ref ref) async {
    final dio = ref.watch(dioProvider);
    final buildConfig = ref.watch(buildConfigProvider);

    final authLink = AuthLink(
      getToken: () async {
        final token = await _getAccessToken(ref);
        return 'Bearer $token';
      },
    );

    final dioLink = Link.from([DioLink(buildConfig.graphQlEndpoint, client: dio)]);

    final webSocketLink = _initializeWebSocketLink(ref);

    final link = Link.split(
      (request) => request.isSubscription, // Subscriptionの場合はWebSocketLink、それ以外(Query/Mutation)はHTTP Linkを使用
      await webSocketLink,
      authLink.concat(dioLink),
    );

    final client = GraphQLClient(
      cache: GraphQLCache(store: InMemoryStore()),
      link: link,
    );

    return client;
  }

アプリのエントリーポイントでProviderとして設定します。

void main() {
  runApp(
    GraphQLProvider(
      client: _initializeGraphQLClient(),
      child: MyApp(),
    ),
  );
}

型安全なコード生成

graphql_codegenを用いて、GraphQLのスキーマから自動的にDart型を生成します。

  1. GraphQLスキーマファイルの配置
   lib/
   ├── graphql/
   │   ├── schema.graphql     # サーバーのスキーマ
   │   └── queries/
   │       └── messages.graphql # クエリ定義
  1. クエリの定義(messages.graphql)
   query ListMessages($channelId: String!, $limit: Int, $nextToken: String) {
     listMessages(channelId: $channelId, limit: $limit, nextToken: $nextToken) {
       items {
         ...MessageFields
       }
       nextToken
     }
   }

   mutation CreateMessage($channelId: String!, $kind: String!, $body: String!) {
     createMessage(channelId: $channelId, kind: $kind, body: $body) {
       channelId
       action
       message {
         ...MessageFields
       }
     }
   }

   subscription OnMessageModified($channelId: String!) {
     onMessageModified(channelId: $channelId) {
       action
       channelId
       message {
         ...MessageFields
       }
     }
   }
  1. コード生成の設定(build.yaml)
   targets:
     $default:
       builders:
         graphql_codegen:
           options:
             schema: lib/graphql/schema.graphql
             queries_glob: lib/graphql/queries/**.graphql
             output_directory: lib/graphql/generated
  1. コード生成の実行
   flutter pub run build_runner build --delete-conflicting-outputs

これにより、型安全なクエリ実行用のクラスが自動生成されます。自動生成によってタイプミスのリスクを減らし、フォーマットを効かせることができます。また、Schemaが適切でなかった場合は自動生成の際にエラーが発生するため、問題を早期に検知できるメリットがあります。

GraphQLでのデータ取得

ZOZOマッチではマッチングしたお相手が表示されるメッセージ一覧画面とそこから遷移できるメッセージ画面でQueryを使ってデータを取得しています。

以下のコードは、メッセージ一覧を取得する際の実装例です。Query$ListMessages$Widgetは前述のコード生成により作成された型安全なウィジェットで、GraphQLのQueryを簡潔に実行できます。

                Query$ListMessages$Widget(
                  options: Options$Query$ListMessages(
                    fetchPolicy: FetchPolicy.networkOnly,
                    variables: Variables$Query$ListMessages(
                      channelId: channelId,
                    ),
                  ),
                  builder: (result, {fetchMore, refetch}) {
                    if (result.data == null && result.isLoading) {
                      return const CommonLoadingView();
                    }
                    if (result.hasException) {
                      return CommonErrorView(
                        onRetry: () async {
                          await refetch?.call();
                        },
                      );
                    }
                    MessageListWidget();
                  }
                );

このコードでは、Options$Query$ListMessagesでクエリを設定し、fetchPolicynetworkOnlyを指定して常に最新のデータをサーバーから取得します。また、Variables$Query$ListMessagesで型安全にGraphQL変数(channelId)を渡しています。

builder内では、クエリの実行状態に応じて3種類のUIを表示しています。result.isLoadingがtrueの場合はローディング画面を表示し、result.hasExceptionがtrueの場合はエラー画面を表示します。エラー画面ではrefetchを呼び出すことでリトライ機能も提供しています。データ取得が成功した場合は、メッセージリストウィジェットを表示します。

graphql_flutterパッケージが提供するWidgetベースのAPIを利用することで、GraphQLのクエリ実行とFlutterのUI更新が自然に統合されています。また、fetchMorerefetchなどの機能も標準で提供されるため、ページネーションやデータの再取得も簡単に実装できます。

GraphQLでの送信処理

次にMutationを使ったメッセージ送信処理を紹介します。GraphQLのMutationは、データの作成・更新・削除といった副作用を伴う操作に使用します。以下はメッセージ送信時の実装例です。

            Mutation$CreateMessage$Widget(
              options: WidgetOptions$Mutation$CreateMessage(
                onCompleted: (_, _) async {
                  // メッセージ送信完了時の処理
                },
                onError: (error) async {
                  if (error == null) {
                    return;
                  }
                  // エラー時の処理
                  logger.error('Error creating message: $error');
                },
              ),
              builder: (runMutation, result) {
                MessageBarWidget(
                  onSubmit: (text) async {
                    runMutation(
                      Variables$Mutation$CreateMessage(
                        channelId: channelId,
                        body: text,
                      ),
                    );
                  }
                );
              }
            );

このコードでは、Mutation$CreateMessage$Widgetでメッセージ送信を実装しています。onCompletedで送信成功時、onErrorでエラー時の処理を定義します。

builderから提供されるrunMutation関数を呼び出すことでMutationを実行します。MessageBarWidgetのテキスト送信時に、Variables$Mutation$CreateMessageを使って型安全に必要なパラメータ(channelIdbody)を渡しています。この実装により、ユーザーがメッセージを入力して送信ボタンを押すと、GraphQL Mutationが実行されサーバーにメッセージが送信されます。

Mutationの実行は非同期で行われ、送信中の状態はresultオブジェクトから取得できます。これにより、送信中のローディング表示や、送信失敗時のリトライ機能なども簡単に実装できます。

Subscriptionでのリアルタイム反映

メッセージ一覧へのマッチングの反映やお相手からメッセージの受信をリアルタイムで反映する際にはSubscriptionを用います。GraphQL Subscriptionは、WebSocketを使用してサーバーからクライアントへリアルタイムでデータをプッシュする仕組みです。前述したようにWebSocketLinkの分岐の追加が必要となります。

ZOZOマッチでは、QueryとSubscriptionを組み合わせて使用しています。Queryで初期データを取得し、その後Subscriptionでリアルタイムの変更を受信するという役割分担です。この設計には以下の理由があります。

  1. 初期データの確実な取得: Queryで画面表示時に必要な全データを一度に取得できる
  2. エラーハンドリングの明確化: 初期データ取得とリアルタイム更新でエラー処理を分離できる
  3. ページネーション対応: 過去のメッセージ取得などにはQueryが適している

以下はメッセージ一覧の変更(新規マッチングやメッセージ受信)を購読する実装例です。

              useEffect(() {
                WidgetsBinding.instance.addPostFrameCallback((_) {
                  // メッセージ一覧を取得した後に、Subscriptionを購読する
                  subscription = graphQLClient
                      .subscribe(
                        Options$Subscription$OnChannelModified(
                          variables: Variables$Subscription$OnChannelModified(
                            userId: userId,
                          ),
                        ),
                      )
                      .listen((event) {
                        // 取得したデータをUIに反映させる
                      });
                });
                return () async {
                  await subscription?.cancel();
                };
              }, []);

このコードでは、useEffectフックを使用してウィジェットのライフサイクルに合わせたSubscriptionを管理しています。graphQLClient.subscribeメソッドでOptions$Subscription$OnChannelModifiedを購読し、ユーザーIDに関連するチャンネルの変更を監視します。

listenメソッドのコールバック内で、サーバーから送信されたイベントを受信し、UIに反映させます。これにより、新しいマッチングが成立したり、お相手からメッセージを受信したりした際に、リアルタイムでメッセージ一覧が更新されます。

重要な点として、useEffectのクリーンアップ関数でsubscription?.cancel()を呼び出すことで、ウィジェットが破棄される際に適切にSubscriptionを解除しています。これにより、メモリリークを防ぎ、不要なWebSocket接続を維持しないようにしています。

開発で得た知見と工夫点

Optimistic Update(楽観的更新)によるUX改善

メッセージ送信時のユーザー体験を向上させるため、Optimistic Update(楽観的更新)を実装しました。これは、サーバーからの応答を待たずユーザーの操作を即座にUIへ反映させる手法です。ユーザーがメッセージを送信した際にリクエストの結果を待ってからUIに反映するとUIに反映されるまでの時間が長くなってしまうため不安に感じてしまうことがあります。

実装の流れ

  1. 即座のUI更新: ユーザーがメッセージ送信ボタンをタップすると、Riverpodで管理しているメッセージリストに一時的なメッセージオブジェクトをローカルで追加し、即座にUIへ表示
  2. バックグラウンドでの送信: 並行してGraphQL Mutationでサーバーへメッセージを送信
  3. データの同期: Subscription経由でサーバーから正式なメッセージデータを受信後、一時的なメッセージを置き換え

この実装により、ネットワーク遅延と関係なく、ユーザーは自分の送信したメッセージが即座に表示されるため、操作感が向上します。送信失敗時は、エラー状態を表示し、失敗したメッセージ文をメッセージバーへ戻す動作にしています。

楽観的更新に対応済みのメッセージ送信 楽観的更新なしのメッセージ送信
楽観的更新に対応済みのメッセージ送信 楽観的更新なしのメッセージ送信

アプリバックグラウンド時のSubscription管理

モバイルアプリ特有の課題として、アプリがバックグラウンドに移行した際のWebSocket接続の扱いがあります。iOSやAndroidは、バッテリー消費を抑えるため、バックグラウンドアプリのネットワーク接続を制限します。これにより、GraphQL Subscriptionで使用しているWebSocket接続が自動的に切断されてしまいます。

発生する問題

  1. アプリをバックグラウンドにすると、WebSocket接続が切断される
  2. この間に送信されたメッセージは、Subscriptionでは受信できない
  3. ユーザーがプッシュ通知でメッセージを確認してアプリに戻っても、UIが更新されていない

解決策

この問題に対して、次の2つのアプローチで対応しました。

  1. 自動再接続の実装: autoReconnect: trueの設定により、アプリがフォアグラウンドへ復帰した際、WebSocket接続を自動的に再開し、Subscriptionの購読を復帰
  2. データの再取得: アプリのライフサイクルイベントを監視し、フォアグラウンド復帰時に最新のメッセージリストをQueryで再取得
class MessageListScreen extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    useOnAppLifecycleStateChange((previous, current) {
      if (current == AppLifecycleState.resumed) {
        // フォアグラウンド復帰時に最新データを取得
        refetch?.call();
      }
    });

    // 以下、通常のWidget実装
  }
}

この実装により、ユーザーがアプリに戻った際には常に最新の状態が表示されるようになりました。

AWS AppSyncとの統合における課題と解決策

バックエンドでAWS AppSyncを利用している場合、標準のWebSocketLinkではAWS AppSync独自の認証形式に対応できないという課題2があります。AWS AppSyncはWebSocket接続時に特別な形式の認証ヘッダーを要求するため、カスタムのWebSocketLink実装が必要になりました。

AWS AppSyncは認証情報をBase64エンコードしてURLパラメータとして渡し、リクエストボディも独自の形式で送信します。これに対応するため、CustomWebSocketLinkを実装しました。

/// [WebSocketLink]をベースにした、カスタムのWebSocketLink
class CustomWebSocketLink extends Link {
    CustomWebSocketLink({
    required this.getToken,
    required this.realTimeEndpoint,
    required this.host,
    this.subProtocol = GraphQLProtocol.graphqlWs,
  });

  final String subProtocol;
  final Future<String?> Function() getToken;
  final String realTimeEndpoint;
  final String host;

  Future<void> connectOrReconnect() async {
    final token = await getToken();
    final authHeader = {'Authorization': token, 'host': host};
    final encodedHeader = base64.encode(utf8.encode(jsonEncode(authHeader)));
    final url = '$realTimeEndpoint:443/graphql/realtime?header=$encodedHeader&payload=e30=';
    await _socketClient?.dispose();
    _socketClient = SocketClient(
      url,
      config: SocketClientConfig(
        serializer: _AppSyncRequest(authHeader: authHeader),
        inactivityTimeout: const Duration(minutes: 2),
        queryAndMutationTimeout: const Duration(milliseconds: 5000),
      ),
      onMessage: (message) {
        logger.info('GraphQL Subscription message: $message');
      },
    );
  }
}

/// AWS AppSync固有の認証形式に対応するためのリクエストシリアライザー
/// 参考: https://github.com/zino-hofmann/graphql-flutter/issues/682#issuecomment-759078492
class _AppSyncRequest extends RequestSerializer {
  const _AppSyncRequest({required this.authHeader});
  final Map<String, dynamic> authHeader;

  @override
  Map<String, dynamic> serializeRequest(Request request) => {
    'data': jsonEncode({
      'query': printNode(request.operation.document),
      'variables': request.variables,
    }),
    'extensions': {'authorization': authHeader},
  };
}

SocketClientの設定では、inactivityTimeoutで非アクティブ時のタイムアウトを設定します。onMessageコールバックでSubscriptionのメッセージ受信状況をログ確認でき、デバッグが容易になります。

まとめ

本記事ではFlutterアプリにおけるGraphQLの実装を紹介しました。GraphQLの導入によってリアルタイムのメッセージ機能の実現ができました。GraphQLの利用を検討している方がいれば、ぜひ参考にしてみてください。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

hrmos.co

corp.zozo.com


  1. WebSocketは、クライアントとサーバー間で双方向通信を可能にするプロトコルです。HTTPと異なり、一度接続を確立すると、サーバーからクライアントへ任意のタイミングでデータを送信できます。
  2. graphql_flutterのGitHubにissueが上がっています。https://github.com/zino-hofmann/graphql-flutter/issues/682
カテゴリー