はじめに
社内勉強会で発表した内容をもう少し詳しく説明するために記事にしました。スライドに関しては以下より確認できます。
また、今回作成したアプリのコードは以下より参照できます。
- クライアント: https://github.com/takashi0602/socket_io_demo
- サーバー: https://github.com/takashi0602/socket-io-server
動作に関しては以下の通りです。こちらは応用で解説しています。
本題
Socket.IOとは
Socket.IOとは、あらゆるプラットフォームに対応する双方向・低遅延通信を実現するライブラリです。特徴としては以下が挙げられます。
- ほとんどの場合はWebSocketで通信を行う
- WebSocketで接続できない場合、HTTPロングポーリングにフォールバック
- 接続が切れたら、自動で再接続を行う
- イベントの送受信が簡単に実装できる
対応プラットフォーム
対応プラットフォームは以下になります。
サーバー
言語 | リンク |
---|---|
JavaScript (Node.js) | - Installation steps - API - Source code |
JavaScript (Deno) | https://github.com/socketio/socket.io-deno |
Java | https://github.com/mrniko/netty-socketio |
Java | https://github.com/trinopoty/socket.io-server-java |
Python | https://github.com/miguelgrinberg/python-socketio |
Golang | https://github.com/googollee/go-socket.io |
Rust | https://github.com/Totodore/socketioxide |
参照: https://socket.io/docs/v4/#server-implementations
クライアント
言語 | リンク |
---|---|
JavaScript (browser, Node.js or React Native) | - Installation steps - API - Source code |
JavaScript (for WeChat Mini-Programs) | https://github.com/weapp-socketio/weapp.socket.io |
Java | https://github.com/socketio/socket.io-client-java |
C++ | https://github.com/socketio/socket.io-client-cpp |
Swift | https://github.com/socketio/socket.io-client-swift |
Dart | https://github.com/rikulo/socket.io-client-dart |
Python | https://github.com/miguelgrinberg/python-socketio |
.Net | https://github.com/doghappy/socket.io-client-csharp |
Rust | https://github.com/1c3t3a/rust-socketio |
Kotlin | 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のパッケージを使用します。
※ サーバーサイドDartとしてhttps://github.com/rikulo/socket.io-dartもありますが、公式での記載はなく、またメンテナンスもされていないため、今回はNode.js(Express)を使用しています。
設定
FlutterでSocket.IOを利用するために、まずサーバー側の実装を行う必要があります。以下が最低限の設定になります。
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のみ対応しています。
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
メソッド内で定義した処理が実行されます。
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
メソッドの場合は登録したイベントも含めて切断します。用途に合わせて使い分けてください。
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
メソッドを使用してサーバーからのイベントを受信します。
class _MyHomePageState extends State<MyHomePage> {
late final IO.Socket _socket;
@override
void initState() {
super.initState();
...
_socket.on('receive-message', (res) {
debugPrint('$res');
});
...
}
...
}
送信
emit
メソッドを使用してサーバーにイベントを送信します。
class _MyHomePageState extends State<MyHomePage> {
...
void _sendMessage() {
_socket.emit('send-message', 'こんにちは!');
}
}
応用
上記で説明したon
メソッドやemit
メソッドを使用してチャットアプリを作成します。改めてにはなりますが、以下が動作になります。
また、アプリの概要は以下になります。
- トップページでチャットを行うルームの一覧を取得する
- ルームに入室すると、以前までのメッセージを取得し表示する
- 各ルームでメッセージを送受信できる
- 自分の投稿と自分以外の投稿を区別できるようにする
※ サーバー側の説明は割愛します。
こちらも改めてにはなりますが、以下よりコードを参照できます。
- クライアント: https://github.com/takashi0602/socket_io_demo
- サーバー: https://github.com/takashi0602/socket-io-server
ルーム一覧の取得
まず、モデルを作成します。モデルの作成にはfreezedを使用します。
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);
}
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とriverpod(flutter_riverpod)を使用しています。
※ AsyncNotifierを使用していますが、今回特にルームの追加や削除などの機能はないため、AsyncNotifierでなくても構いません。
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を使用しています。)
ここでは対応はしていませんが、LINEのように最新のメッセージを表示したりしても良いかもしれません。
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
メソッドを定義し、メッセージの追加処理を行っています。
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を使用して、メッセージを表示します。また、自分が投稿したメッセージは右側に、自分以外のユーザーが投稿したメッセージは左側に表示します。
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
内で定義します。
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を用いて実装していただいても構いません。
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と合わせてゲームの開発なども行えそうなので、また試してみたいです。