.entry .entry-content ul > li > ul { display: none; } .entry-content td { text-align: left; } はじめに こんにちは、新規事業部フロントエンドブロックの 池田 です。普段はZOZOマッチのアプリ開発を担当しています。2025年6月にマッチングアプリ「 ZOZOマッチ 」をリリースしました。ZOZOマッチにはメッセージ機能があり、この機能を実現するためにGraphQLを用いています。本記事ではFlutterアプリでGraphQLを用いたリアルタイムメッセージ機能の開発の知見と工夫した点をご紹介します。 なお、ZOZOマッチアプリ全体のアーキテクチャや技術構成については、別記事「 ZOZOマッチアプリのアーキテクチャと技術構成 」で詳しく紹介しています。 目次 はじめに 目次 GraphQLとは GraphQLの主な特徴 1. 単一エンドポイント 2. 必要なデータのみ取得 3. 強力な型システム 4. リアルタイム通信のサポート GraphQLの3つの操作タイプ ZOZOマッチでのGraphQLの利用背景 Flutter×GraphQLの実装 Flutterへの導入 主要パッケージ GraphQL Clientの設定 型安全なコード生成 GraphQLでのデータ取得 GraphQLでの送信処理 Subscriptionでのリアルタイム反映 開発で得た知見と工夫点 Optimistic Update(楽観的更新)によるUX改善 実装の流れ アプリバックグラウンド時のSubscription管理 発生する問題 解決策 AWS AppSyncとの統合における課題と解決策 まとめ 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機能によりWebSocket 1 経由でリアルタイムデータ配信が可能です。これがZOZOマッチのメッセージ機能で重要な役割を果たしています。 GraphQLの3つの操作タイプ GraphQLには主に3つの操作タイプがあります。 操作タイプ 用途 REST APIでの相当 Query データの取得 GET Mutation データの作成・更新・削除 POST/PUT/DELETE Subscription リアルタイムデータの購読 WebSocket/SSE ZOZOマッチでのGraphQLの利用背景 ZOZOマッチではユーザー同士がリアルタイムでメッセージをやり取りする機能があります。マッチングアプリにおいて、メッセージ機能は最も重要な機能の1つであり、以下の要件を満たす必要がありました。 メッセージの送受信がリアルタイムで反映される オフライン時のメッセージもオンライン復帰時に受信できる 既読の管理ができる 従来のREST APIでこれらを実現しようとすると、以下の課題がありました。 リアルタイム性の実装が複雑 : ポーリングでは遅延が発生し、WebSocketの実装は複雑 複数回のAPIコール : メッセージ一覧、ユーザー情報、既読状態などを別々に取得する必要がある オーバーフェッチング : 不要なデータも含めて取得してしまい、通信量が増加 型安全性の確保が困難 : APIレスポンスの型定義を手動で管理する必要がある これらの課題を解決するため、GraphQLを採用しました。GraphQLでは以下の機能により各課題に対応できます。 Subscription : WebSocketベースのリアルタイム通信を標準機能として提供し、リアルタイムでのメッセージの反映を実現 単一エンドポイント : 1回のリクエストで必要なデータ(メッセージ、ユーザー情報、既読状態など)をまとめて取得可能 柔軟なクエリ : クライアント側で必要なフィールドのみを指定して取得でき、通信量を最適化 強力な型システム : スキーマから型安全なコードを自動生成し、開発時の型チェックを実現 メッセージ一覧画面 メッセージ画面 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型を生成します。 GraphQLスキーマファイルの配置 lib/ ├── graphql/ │ ├── schema.graphql # サーバーのスキーマ │ └── queries/ │ └── messages.graphql # クエリ定義 クエリの定義 (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 } } } コード生成の設定 (build.yaml) targets: $default: builders: graphql_codegen: options: schema: lib/graphql/schema.graphql queries_glob: lib/graphql/queries/**.graphql output_directory: lib/graphql/generated コード生成の実行 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 でクエリを設定し、 fetchPolicy に networkOnly を指定して常に最新のデータをサーバーから取得します。また、 Variables$Query$ListMessages で型安全にGraphQL変数( channelId )を渡しています。 builder 内では、クエリの実行状態に応じて3種類のUIを表示しています。 result.isLoading がtrueの場合はローディング画面を表示し、 result.hasException がtrueの場合はエラー画面を表示します。エラー画面では refetch を呼び出すことでリトライ機能も提供しています。データ取得が成功した場合は、メッセージリストウィジェットを表示します。 graphql_flutterパッケージが提供するWidgetベースのAPIを利用することで、GraphQLのクエリ実行とFlutterのUI更新が自然に統合されています。また、 fetchMore や refetch などの機能も標準で提供されるため、ページネーションやデータの再取得も簡単に実装できます。 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 を使って型安全に必要なパラメータ( channelId 、 body )を渡しています。この実装により、ユーザーがメッセージを入力して送信ボタンを押すと、GraphQL Mutationが実行されサーバーにメッセージが送信されます。 Mutationの実行は非同期で行われ、送信中の状態は result オブジェクトから取得できます。これにより、送信中のローディング表示や、送信失敗時のリトライ機能なども簡単に実装できます。 Subscriptionでのリアルタイム反映 メッセージ一覧へのマッチングの反映やお相手からメッセージの受信をリアルタイムで反映する際にはSubscriptionを用います。GraphQL Subscriptionは、WebSocketを使用してサーバーからクライアントへリアルタイムでデータをプッシュする仕組みです。前述したようにWebSocketLinkの分岐の追加が必要となります。 ZOZOマッチでは、QueryとSubscriptionを組み合わせて使用しています。Queryで初期データを取得し、その後Subscriptionでリアルタイムの変更を受信するという役割分担です。この設計には以下の理由があります。 初期データの確実な取得 : Queryで画面表示時に必要な全データを一度に取得できる エラーハンドリングの明確化 : 初期データ取得とリアルタイム更新でエラー処理を分離できる ページネーション対応 : 過去のメッセージ取得などには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に反映されるまでの時間が長くなってしまうため不安に感じてしまうことがあります。 実装の流れ 即座のUI更新 : ユーザーがメッセージ送信ボタンをタップすると、Riverpodで管理しているメッセージリストに一時的なメッセージオブジェクトをローカルで追加し、即座にUIへ表示 バックグラウンドでの送信 : 並行してGraphQL Mutationでサーバーへメッセージを送信 データの同期 : Subscription経由でサーバーから正式なメッセージデータを受信後、一時的なメッセージを置き換え この実装により、ネットワーク遅延と関係なく、ユーザーは自分の送信したメッセージが即座に表示されるため、操作感が向上します。送信失敗時は、エラー状態を表示し、失敗したメッセージ文をメッセージバーへ戻す動作にしています。 楽観的更新に対応済みのメッセージ送信 楽観的更新なしのメッセージ送信 アプリバックグラウンド時のSubscription管理 モバイルアプリ特有の課題として、アプリがバックグラウンドに移行した際のWebSocket接続の扱いがあります。iOSやAndroidは、バッテリー消費を抑えるため、バックグラウンドアプリのネットワーク接続を制限します。これにより、GraphQL Subscriptionで使用しているWebSocket接続が自動的に切断されてしまいます。 発生する問題 アプリをバックグラウンドにすると、WebSocket接続が切断される この間に送信されたメッセージは、Subscriptionでは受信できない ユーザーがプッシュ通知でメッセージを確認してアプリに戻っても、UIが更新されていない 解決策 この問題に対して、次の2つのアプローチで対応しました。 自動再接続の実装 : autoReconnect: true の設定により、アプリがフォアグラウンドへ復帰した際、WebSocket接続を自動的に再開し、Subscriptionの購読を復帰 データの再取得 : アプリのライフサイクルイベントを監視し、フォアグラウンド復帰時に最新のメッセージリストを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 WebSocketは、クライアントとサーバー間で双方向通信を可能にするプロトコルです。HTTPと異なり、一度接続を確立すると、サーバーからクライアントへ任意のタイミングでデータを送信できます。 ↩ graphql_flutterのGitHubにissueが上がっています。 https://github.com/zino-hofmann/graphql-flutter/issues/682 ↩