supabaseとFlutterでリアルタイムチャットを作る方法

こんにちは、株式会社Pentagonでアプリ開発をしている石渡港です。
https://pentagon.tokyo

今回はsupabaseとFlutterでリアルタイムチャットを作ってみました。
その結果、チャットを実装できることがわかりました。

【この記事を読むメリット】

  • supabaseとFlutterでリアルタイムチャットを実装する方法がわかります。
    【こんな方に参考にしていただきたい】
  • supabaseとFlutterでリアルタイムチャットを実装したい人。

【調査の動機】
今後開発するプロジェクトで、supabaseとFlutterでリアルタイムチャットを利用する予定があったため調査しました。

【調査結果】

  • リアルタイム受信を実装するためにsupabase側で設定を加える必要があります。
  • リアルタイム反映を表現するため、streamで取得しました。その際に、クエリで内容を絞れませんでした。そのため、Listのメソッドのwhereなどで絞る必要があります。
  • APIの代わりにクエリでデータの呼び出しができるので便利です。
目次

【結論】

1. supabaseの準備

SQLタブへ移動します。

新規SQLを入力できるように準備します。

SQLを記入して実行します。

CREATE TABLE public.rooms (
  id serial NOT NULL PRIMARY KEY,
  name text NOT NULL,
  created_at timestamp without time zone NOT NULL DEFAULT now()
);

CREATE TABLE public.chat_messages (
  id serial NOT NULL PRIMARY KEY,
  room_id integer NOT NULL REFERENCES public.rooms(id),
  user_id uuid NOT NULL,
  message text NOT NULL,
  created_at timestamp without time zone NOT NULL DEFAULT now()
);

実行後、テーブルにchat_messagesroomsが生成されます。

chat_messagesidがAuto Incrementになっているか確認します。
Edit Columを選択します。

Is Identityがついていることを確認します。もし、ついていない場合はブラウザ上でidを削除、新規追加した際につけるようにしてください。

リアルタイム受信の設定を行います。
Databaseタブへ移動し、Replicationタブを選択します。

リアルタイムを検知したいスイッチトグルを有効にしてください。
一番右の * tablesを選択してください。

今回有効にするテーブルを選択してください。

supabaseの設定は以上です。

Flutter側の設定

supabaseに関する環境などはこちらを参考にしてください。

今回は下記のライブラリを追加で使用しました。

  flutter_hooks: ^0.18.1
  hooks_riverpod: ^0.14.0+5
  freezed_annotation: ^0.14.2
  freezed: ^0.14.2
  json_serializable: ^4.1.4
  build_runner: ^2.1.2
  get: ^4.3.8

supabseの操作は下記のクラスにまとめました。
レスポンスをfreezedクラスでマッピングしています。

import 'dart:developer';

import 'package:supabase/supabase.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:supabaseflutterquickstart/models/chat_message.dart';
import 'package:supabaseflutterquickstart/models/room.dart';

class SupabaseService {
  // Singleton化
  SupabaseService._();

  static SupabaseService? _instance;

  factory SupabaseService() {
    SupabaseService? instance = _instance;
    if (instance == null) {
      _instance = new SupabaseService._();
      return _instance!;
    }
    return instance;
  }

  Future<void> initialize() async {
    _supabase = await Supabase.initialize(
      url: 'your-url',
      anonKey:
          'your-anonKey',
    );
  }

  late Supabase _supabase;
  SupabaseClient get client => _supabase.client;

  // ルームの取得
  Future<List<Room>> getRooms() async {
    final response = await client
        .from('rooms')
        .select()
        .order('created_at', ascending: true)
        .execute();
    if (response.status! < 300 && response.status! >= 200) {
      final map = response.data as List<dynamic>;
      return map.map((e) => Room.fromJson(e)).toList();
    } else {
      throw response.error!;
    }
  }

  /// チャットのメッセージをリアルタイム取得
  Stream<List<ChatMessage>> getChatMessagesStream({required int roomId}) {
    return client
        .from('chat_messages')
        .stream()
        .order('created_at', ascending: true)
        .execute()
        /// 絞り込みのクエリが使えないのでListのwhereで絞り込み
        .map((event) => event
            .map((e) => ChatMessage.fromJson(e))
            .where((element) => element.roomId == roomId)
            .toList());
  }

  /// チャットの投稿
  Future<void> postChatMessages({required ChatMessage chatMessage}) async {
    final updateChatMessage = chatMessage.copyWith(
      userId: client.auth.currentUser?.id ?? '',
      createdAt: DateTime.now().toString(),
    );
    final json = updateChatMessage.toJson();
    json.remove('id');
    final response = await client.from('chat_messages').insert(json).execute();
    if (response.status! < 300 && response.status! >= 200) {
      log(response.toString());
    } else {
      throw response.error!;
    }
  }
}

チャットメッセージをリアルタイム取得するために、StreamProviderを利用しています。

import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:supabaseflutterquickstart/models/chat_message.dart';
import 'package:supabaseflutterquickstart/services/supabase_service.dart';

final chatMessagesSteamProvider =
    StreamProvider.autoDispose.family<List<ChatMessage>, int>((ref, id) {
  final stream = SupabaseService().getChatMessagesStream(roomId: id);
  ref.onDispose(() {
    stream.listen((event) {}).cancel();
  });
  return stream;
});

下記のクラスでwatchを利用して、リアルタイムにメッセージを待ち構えます。

class MessageList extends ConsumerWidget {
  const MessageList({required this.chatRoomId, Key? key}) : super(key: key);
  final int chatRoomId;

  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final data = watch(chatMessagesSteamProvider(chatRoomId));
    return data.when(
      data: (response) {
        return ListView.separated(
          itemBuilder: (context, index) {
            final message = response[index];
            return Container(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: [
                      Text(
                        message.userId,
                        style: textStyle(
                          const [
                            fontSize8,
                            white,
                          ],
                        ),
                      ),
                      Spacer(),
                      Text(
                        message.createdAt,
                        style: textStyle(
                          const [
                            fontSize8,
                            white,
                          ],
                        ),
                      ),
                    ],
                  ),
                  spaceH8,
                  Text(message.message),
                ],
              ),
            );
          },
          separatorBuilder: (context, index) => const Divider(),
          itemCount: response.length,
        );
      },
      loading: () => const CircularProgressIndicator(),
      error: (error, stack) => Text('Error: $error'),
    );
  }
}

正しく実装できていると、下記gifのような動きを確認できます。

【まとめ】

supabaseとFlutterでリアルタイムチャットを実装する方法がわかりました。
クエリで絞れないなどできないこともありましたが、他の手法がありそうなので調査したいと思います。
また、APIを開発せずにDBに直接読み書きできるので開発コストを減らせると思いました。

参考

Godot から Supabase を使う

採用情報はこちら
目次