Flutter x Socket.IOで行うリアルタイム通信

# はじめに

社内勉強会で発表した内容をもう少し詳しく説明するために記事にしました。スライドに関しては以下より確認できます。

https://speakerdeck.com/takashimelon/flutter-x-socket-dot-iodexing-uriarutaimutong-xin

また、今回作成したアプリのコードは以下より参照できます。

- クライアント: https://github.com/takashi0602/socket_io_demo
- サーバー: https://github.com/takashi0602/socket-io-server

動作に関しては以下の通りです。こちらは[応用](#応用)で解説しています。

![アプリのデモの様子を写したgif画像。シミュレーターが2台並べられ、トップページのRoom1へ遷移後、チャットを開始している。チャットではお互いが挨拶を交わしている。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192833/0ff51fab-e03c-c98e-3868-298a23e81c4b.gif)

# 本題

## Socket.IOとは

[Socket.IO](https://socket.io/)とは、あらゆるプラットフォームに対応する双方向・低遅延通信を実現するライブラリです。特徴としては以下が挙げられます。

- ほとんどの場合はWebSocketで通信を行う
- WebSocketで接続できない場合、HTTPロングポーリングにフォールバック
- 接続が切れたら、自動で再接続を行う
- イベントの送受信が簡単に実装できる

### 対応プラットフォーム

対応プラットフォームは以下になります。

#### サーバー

| 言語 | リンク |
| --- | --- |
| JavaScript (Node.js) | - [Installation steps](https://socket.io/docs/v4/server-installation/)<br>- [API](https://socket.io/docs/v4/server-api/)<br>- [Source code](https://github.com/socketio/socket.io) |
| JavaScript (Deno) | [https://github.com/socketio/socket.io-deno](https://github.com/socketio/socket.io-deno) |
| Java | [https://github.com/mrniko/netty-socketio](https://github.com/mrniko/netty-socketio) |
| Java | [https://github.com/trinopoty/socket.io-server-java](https://github.com/trinopoty/socket.io-server-java) |
| Python | [https://github.com/miguelgrinberg/python-socketio](https://github.com/miguelgrinberg/python-socketio) |
| Golang | [https://github.com/googollee/go-socket.io](https://github.com/googollee/go-socket.io) |
| Rust | [https://github.com/Totodore/socketioxide](https://github.com/Totodore/socketioxide)　|

参照: https://socket.io/docs/v4/#server-implementations

#### クライアント

| 言語 | リンク |
| --- | --- |
| JavaScript (browser, Node.js or React Native) | - [Installation steps](https://socket.io/docs/v4/client-installation/)<br>- [API](https://socket.io/docs/v4/client-api/)<br>- [Source code](https://github.com/socketio/socket.io-client) |
| JavaScript (for WeChat Mini-Programs) | [https://github.com/weapp-socketio/weapp.socket.io](https://github.com/weapp-socketio/weapp.socket.io) |
| Java | [https://github.com/socketio/socket.io-client-java](https://github.com/socketio/socket.io-client-java) |
| C++ | [https://github.com/socketio/socket.io-client-cpp](https://github.com/socketio/socket.io-client-cpp) |
| Swift | [https://github.com/socketio/socket.io-client-swift](https://github.com/socketio/socket.io-client-swift) |
| Dart | [https://github.com/rikulo/socket.io-client-dart](https://github.com/rikulo/socket.io-client-dart) |
| Python | [https://github.com/miguelgrinberg/python-socketio](https://github.com/miguelgrinberg/python-socketio)　|
| .Net | [https://github.com/doghappy/socket.io-client-csharp](https://github.com/doghappy/socket.io-client-csharp)　|
| Rust | [https://github.com/1c3t3a/rust-socketio](https://github.com/1c3t3a/rust-socketio)　|
| Kotlin | [https://github.com/icerockdev/moko-socket-io](https://github.com/icerockdev/moko-socket-io)　|

参照: https://socket.io/docs/v4/#client-implementations

## WebSocketとは

WebSocketは、単一のTCPコネクション上に双方向通信のチャンネルを提供する、コンピュータの通信プロトコルの1つです。WebSocketのハンドシェイクはHTTP/1.1 Upgradeヘッダーを使用し、HTTPプロトコルをWebSocketプロトコルに変更するように実現されています。

参照: https://ja.wikipedia.org/wiki/WebSocket

※ 専門外のため、ここでは簡単な説明に留めます。

## 基礎

Flutterでは上記で記載した、[Dartのパッケージ](https://github.com/rikulo/socket.io-client-dart)を使用します。
※ サーバーサイドDartとして[https://github.com/rikulo/socket.io-dart](https://github.com/rikulo/socket.io-dart)もありますが、公式での記載はなく、またメンテナンスもされていないため、今回はNode.js(Express)を使用しています。

### 設定

FlutterでSocket.IOを利用するために、まずサーバー側の実装を行う必要があります。以下が最低限の設定になります。

```index.ts
import express from "express";
import { createServer } from "http";
import { Server } from "socket.io";

const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer);
const port = 3000;

io.on("connection", (socket) => {
  console.log("connected");
});

httpServer.listen(port, () => {
  console.log(`server running at http://localhost:${port}`);
});
```

次にFlutter側の設定を行います。
まず、`socket_io_client`をインポートし、`io`メソッドを使用して接続するURLやオプションを設定します。オプションに関しては、`OptionBuilder`を使用して設定します。以下ではWebSocketで通信を行い、自動接続をオフにしています。
※ ネイティブアプリではWebSocketのみ対応しています。

```main.dart
import 'package:flutter/material.dart';
import 'package:socket_io_client/socket_io_client.dart' as IO;
...
class MyHomePage extends StatefulWidget {...}

class _MyHomePageState extends State<MyHomePage> {
  late final IO.Socket _socket;

  @override
  void initState() {
    super.initState();

    _socket = IO.io(
      "http://localhost:3000",
      IO.OptionBuilder()
          .setTransports(['websocket'])
          .disableAutoConnect()
          .build(),
    );
  }

  ...
}
```

### 接続

`connect`メソッドを使用して、サーバーと接続します。接続した際に`onConnect`メソッド内で定義した処理が実行されます。

```main.dart
class _MyHomePageState extends State<MyHomePage> {
  late final IO.Socket _socket;

  @override
  void initState() {
    super.initState();

    ...
    _socket.onConnect((_) {
      debugPrint('connect');
    });
    _socket.connect();
  }

  ...
}
```

### 切断

`dispose`メソッドを使用して、サーバーとの接続を切ります。切断した際に`onDisconnect`メソッド内で定義した処理が実行されます。
※ `disconnect`メソッドでも切断可能ですが、`dispose`メソッドの場合は登録したイベントも含めて切断します。用途に合わせて使い分けてください。

```main.dart
class _MyHomePageState extends State<MyHomePage> {
  late final IO.Socket _socket;

  @override
  void initState() {
    ...
    _socket.onDisconnect((_) {
      debugPrint('disconnect');
    });
    ...
  }

  @override
  void dispose() {
    _socket.dispose();
    super.dispose();
  }

  ...
}
```

### 受信

`on`メソッドを使用してサーバーからのイベントを受信します。

```main.dart
class _MyHomePageState extends State<MyHomePage> {
  late final IO.Socket _socket;

  @override
  void initState() {
    super.initState();

    ...
    _socket.on('receive-message', (res) {
      debugPrint('$res');
    });
    ...
  }

  ...
}
```

### 送信

`emit`メソッドを使用してサーバーにイベントを送信します。

```main.dart
class _MyHomePageState extends State<MyHomePage> {
  ...

  void _sendMessage() {
    _socket.emit('send-message', 'こんにちは！');
  }
}
```

## 応用

上記で説明した`on`メソッドや`emit`メソッドを使用してチャットアプリを作成します。改めてにはなりますが、以下が動作になります。

![アプリのデモの様子を写したgif画像。シミュレーターが2台並べられ、トップページのRoom1へ遷移後、チャットを開始している。チャットではお互いが挨拶を交わしている。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192833/0ff51fab-e03c-c98e-3868-298a23e81c4b.gif)

また、アプリの概要は以下になります。

- トップページでチャットを行うルームの一覧を取得する
- ルームに入室すると、以前までのメッセージを取得し表示する
- 各ルームでメッセージを送受信できる
- 自分の投稿と自分以外の投稿を区別できるようにする

※ サーバー側の説明は割愛します。

こちらも改めてにはなりますが、以下よりコードを参照できます。

- クライアント: https://github.com/takashi0602/socket_io_demo
- サーバー: https://github.com/takashi0602/socket-io-server

### ルーム一覧の取得

まず、モデルを作成します。モデルの作成には[freezed](https://pub.dev/packages/freezed)を使用します。

```lib/model/message.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'message.freezed.dart';
part 'message.g.dart';

@freezed
class Message with _$Message {
  const factory Message({
    required int id,
    required int userId,
    required String body,
  }) = _Message;

  factory Message.fromJson(Map<String, dynamic> json) =>
      _$MessageFromJson(json);
}
```

```lib/model/room.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:socket_io_demo/model/message.dart';

part 'room.freezed.dart';
part 'room.g.dart';

@freezed
class Room with _$Room {
  const factory Room({
    required int id,
    required List<Message> messages,
  }) = _Room;

  factory Room.fromJson(Map<String, dynamic> json) => _$RoomFromJson(json);
}
```

次にルーム一覧を取得します。その際に、[http](https://pub.dev/packages/http)と[riverpod(flutter_riverpod)](https://pub.dev/packages/flutter_riverpod)を使用しています。
※ AsyncNotifierを使用していますが、今回特にルームの追加や削除などの機能はないため、AsyncNotifierでなくても構いません。

```lib/features/room/api/rooms.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:socket_io_demo/model/room.dart';

part 'rooms.g.dart';

@riverpod
class RoomsAsyncNotifier extends _$RoomsAsyncNotifier {
  Future<List<Room>> _fetchRooms() async {
    try {
      final res = await http.get(Uri.parse('http://localhost:3000/rooms'));
      if (res.statusCode == 200) {
        final List<dynamic> body = json.decode(res.body);
        return body.map((room) => Room.fromJson(room)).toList();
      } else {
        throw Exception('Failed to load rooms');
      }
    } catch (_) {
      throw Exception('Failed to load rooms');
    }
  }

  @override
  FutureOr<List<Room>> build() async {
    return await _fetchRooms();
  }
}
```

上記で作成したroomsAsyncNotifierProviderを使用して、トップページでルーム一覧を取得し表示します。（ルーティングには[go_router](https://pub.dev/packages/go_router)を使用しています。）
ここでは対応はしていませんが、LINEのように最新のメッセージを表示したりしても良いかもしれません。

```lib/pages/top_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:socket_io_demo/features/room/api/rooms.dart';
import 'package:socket_io_demo/pages/room_page.dart';

// _Body以外は割愛

class _Body extends ConsumerWidget {
  const _Body();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final roomsAsyncNotifier = ref.watch(roomsAsyncNotifierProvider);
    return roomsAsyncNotifier.when(
      data: (rooms) {
        // 以下で一覧表示を行う
        return ListView.builder(
          itemCount: rooms.length,
          itemBuilder: (context, index) {
            final room = rooms[index];

            return ListTile(
              title: Text('Room ${room.id}'),
              trailing: const Icon(Icons.navigate_next),
              onTap: () {
                // ルームIDを渡して遷移
                RoomPageRoute(id: room.id).push(context);
              },
            );
          },
        );
      },
      error: (_, __) {
        // エラー時のUIは割愛
      },
      loading: () {
        // ローディング時のUIは割愛
      },
    );
  }
}
```

### メッセージの取得

ルームごとのメッセージの履歴を取得します。メッセージの送信を行うため、API通信以外に`addMessage`メソッドを定義し、メッセージの追加処理を行っています。

```lib/features/room/api/room.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:socket_io_demo/model/message.dart';
import 'package:socket_io_demo/model/room.dart';

part 'room.g.dart';

@riverpod
class RoomAsyncNotifier extends _$RoomAsyncNotifier {
  Future<Room> _fetchRoom(int id) async {
    try {
      final res = await http.get(Uri.parse('http://localhost:3000/room/$id'));
      if (res.statusCode == 200) {
        final body = json.decode(res.body);
        return Room.fromJson(body);
      } else {
        throw Exception('Failed to load room');
      }
    } catch (_) {
      throw Exception('Failed to load room');
    }
  }

  @override
  FutureOr<Room> build(int id) async {
    return await _fetchRoom(id);
  }

  void addMessage(Message message) {
    final stateValue = state.value;
    if (stateValue == null) return;

    state = AsyncValue.data(Room(
      id: stateValue.id,
      messages: [...stateValue.messages, message],
    ));
  }
}
```

上記で作成したroomAsyncNotifierProviderを使用して、メッセージを表示します。また、自分が投稿したメッセージは右側に、自分以外のユーザーが投稿したメッセージは左側に表示します。

```lib/features/room/components/message_content.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:socket_io_client/socket_io_client.dart' as IO;
import 'package:socket_io_demo/features/room/api/room.dart';
import 'package:socket_io_demo/features/room/components/message_form.dart';
import 'package:socket_io_demo/model/message.dart';

class MessageContent extends ConsumerStatefulWidget {...}

class _MessageContentState extends ConsumerState<MessageContent> {
  // Socket.IOの部分は後ほど説明するため割愛

  @override
  Widget build(BuildContext context) {
    // 本来はAPIからユーザーIDを取得するのが好ましいが、このアプリでは起動時に任意のユーザーIDをセットし、それを使用する
    // ex: flutter run --dart-define=USER_ID=1
    final userId =
        int.parse(const String.fromEnvironment('USER_ID', defaultValue: '1'));
    final roomAsyncNotifier =
        ref.watch(roomAsyncNotifierProvider(widget.roomId));

    return roomAsyncNotifier.when(data: (room) {
      final messages = room.messages;

      return Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(
            child: ListView.builder(
              itemCount: messages.length,
              itemBuilder: (context, index) {
                final message = messages[index];

                return Padding(
                  padding: const EdgeInsets.symmetric(
                    vertical: 16,
                    horizontal: 16,
                  ),
                  child: Row(
                    // メッセージの表示位置を制御
                    mainAxisAlignment: message.userId == userId
                        ? MainAxisAlignment.end
                        : MainAxisAlignment.start,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      // 自分以外のユーザーのアイコンを表示
                      if (message.userId != userId) ...[
                        const Icon(
                          Icons.account_circle_outlined,
                          size: 24,
                          color: Colors.black,
                        ),
                        const SizedBox(width: 8),
                      ],
                      Flexible(
                        child: Text(
                          message.body,
                          style: const TextStyle(
                            fontSize: 16,
                            height: 1.5,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ),
                      // 自分のユーザーアイコンを表示
                      if (message.userId == userId) ...[
                        const SizedBox(width: 8),
                        const Icon(
                          Icons.account_circle_outlined,
                          size: 24,
                          color: Colors.black,
                        ),
                      ],
                    ],
                  ),
                );
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(20),
            // MessageFormは後ほど解説
            child: MessageForm(
              socket: _socket,
              roomId: widget.roomId,
              userId: userId,
            ),
          ),
          const SizedBox(height: 24),
        ],
      );
    }, error: (_, __) {
      // エラー時のUIは割愛
    }, loading: () {
      // ローディング時のUIは割愛
    });
  }
}
```

### メッセージの送受信

Socket.IOを使用してメッセージの送受信を行います。メッセージの送信以外のイベントは`initState`内で定義します。

```lib/features/room/components/message_content.dart
class _MessageContentState extends ConsumerState<MessageContent> {
  late final IO.Socket _socket;

  @override
  void initState() {
    super.initState();

    _socket = IO.io(
      "http://localhost:3000",
      IO.OptionBuilder()
          .setTransports(['websocket'])
          .disableAutoConnect()
          .build(),
    );

    // 接続時の処理
    _socket.onConnect((_) {
      debugPrint('connect');
      // どのルームに参加するのかをサーバーに伝える
      _socket.emit('join-room', widget.roomId);
    });

    // ルームに参加した時の処理
    _socket.on('joined-room', (_) {
      debugPrint('joined-room');
    });

    // メッセージを受け取った時の処理
    _socket.on('receive-message', (res) {
      // `addMessage`メソッドを使用してプロバイダーを更新する
      // 更新することによってUIが反映される
      final message = Message.fromJson(res);
      ref
          .read(roomAsyncNotifierProvider(widget.roomId).notifier)
          .addMessage(message);
    });

    // 切断時の処理
    _socket.onDisconnect((_) => debugPrint('disconnect'));
    _socket.connect();
  }

  @override
  void dispose() {
    _socket.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {...}
}
```

メッセージの送信ではTextEditingControllerを用いて、ユーザーが入力した文字列を送信します。
※ [flutter_hooks](https://pub.dev/packages/flutter_hooks)を用いて実装していただいても構いません。

```lib/features/room/components/message_form.dart
import 'package:flutter/material.dart';
import 'package:socket_io_client/socket_io_client.dart' as IO;

class MessageForm extends StatefulWidget {
  const MessageForm({
    super.key,
    required this.socket,
    required this.roomId,
    required this.userId,
  });

  final IO.Socket socket;
  final int roomId;
  final int userId;

  @override
  State<MessageForm> createState() => _MessageFormState();
}

class _MessageFormState extends State<MessageForm> {
  final TextEditingController _controller = TextEditingController();
  final FocusNode _focusNode = FocusNode();
  bool canSend = false;

  // このリスナーによってボタンの活性・非活性を切り替えている
  void textEditingListener() {
    setState(() {
      canSend = _controller.text.isNotEmpty;
    });
  }

  void _sendMessage() {
    if (_controller.text.isNotEmpty) {
      // メッセージを送信する
      widget.socket.emit('send-message', {
        'roomId': widget.roomId,
        'userId': widget.userId,
        'body': _controller.text,
      });
      // 送信後は入力欄をクリアし、フォーカスを外す
      _controller.clear();
      _focusNode.unfocus();
    }
  }

  @override
  void initState() {
    super.initState();
    _controller.addListener(textEditingListener);
  }

  @override
  void dispose() {
    _controller.removeListener(textEditingListener);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: [
          Expanded(
            // 入力欄の実装
            child: TextFormField(
              controller: _controller,
              focusNode: _focusNode,
              keyboardType: TextInputType.multiline,
              maxLines: null,
              decoration: const InputDecoration(
                contentPadding: EdgeInsets.symmetric(
                  vertical: 4,
                  horizontal: 8,
                ),
                border: OutlineInputBorder(
                  borderSide: BorderSide(
                    color: Colors.grey,
                    width: 1,
                  ),
                ),
                enabledBorder: OutlineInputBorder(
                  borderSide: BorderSide(
                    color: Colors.grey,
                    width: 1,
                  ),
                ),
                focusedBorder: OutlineInputBorder(
                  borderSide: BorderSide(
                    color: Colors.grey,
                    width: 1,
                  ),
                ),
              ),
            ),
          ),
          const SizedBox(width: 8),
          // 送信ボタン
          // canSendステートを用いてボタンを制御
          IconButton(
            iconSize: 32,
            onPressed: canSend ? _sendMessage : null,
            icon: Icon(
              Icons.send,
              color: canSend ? Colors.blue : Colors.grey,
            ),
          ),
        ],
      ),
    );
  }
}
```

これらの実装でアプリの概要を実装することができました。お疲れ様です。

# おわりに

Socket.IOを使用することで簡単にリアルタイム通信を実現することが可能となり、開発の幅が広がりました。また、簡単に実装することができるのでハッカソン等で使用することもおすすめです。
[Flutter Casual Games Toolkit](https://flutter.dev/games)と合わせてゲームの開発なども行えそうなので、また試してみたいです。