Flutter で同一 Wi-Fi 内リアルタイム通信 — mDNS + WebSocket でチャットアプリを作ってみた
はじめに
Flutter で同一 Wi-Fi 内にいる端末同士がサーバーを介さずに直接通信するデモアプリを作ってみました。
nsd(mDNS サービス発見・登録)+ shelf_web_socket(WebSocket サーバー)+ web_socket_channel(WebSocket クライアント)を使い、以下の機能を iOS / Android の実機2台で動作確認しています。
- サービス発見: 同一 Wi-Fi 内のサーバーを mDNS で自動検出して一覧表示
- WebSocket 接続: 発見したサーバーに接続
- チャット: 接続した端末間で双方向にテキストメッセージを送受信
最終的に作ったチャットのデモです。片方の端末でメッセージを送ると、もう片方にリアルタイムで表示されます。

どんな場面で使えるか
Mac や iPhone からプリンターが設定なしで見つかったことはないでしょうか。あれは AirPrint が mDNS という仕組みを使って、同一ネットワーク内のプリンターを自動発見しています。
本記事で扱うのは、この「同一ネットワーク内のデバイス同士が直接通信する仕組み」を Flutter で実装する方法です。外部サーバーを経由せず、同一 Wi-Fi 内で完結します。
Wi-Fi リアルタイム通信の仕組み
mDNS / DNS-SD(サービスの発見と登録)
同一 Wi-Fi 内で「このネットワークに○○がいますよ」と名乗り出たり(サービス登録)、「○○はいませんか?」と探したり(サービス発見)できる仕組みです。前述の AirPrint では、プリンターが自分の存在を名乗り出て、Mac がそれを探しに行くことで、設定なしで印刷できるようになっています。
| 操作 | 説明 | 例 |
|---|---|---|
| サービス登録 | 「自分はここにいます」とネットワークに名乗り出る | プリンターが「印刷できます」と宣言 |
| サービス発見 | 名乗り出ているサービスを探す | Mac が「印刷できる人いませんか?」と問い合わせ |
WebSocket(双方向リアルタイム通信)
通常の HTTP 通信はリクエストとレスポンスの一往復で終わりますが、WebSocket は一度接続すると、どちらからでも好きなタイミングでメッセージを送れます。電話のようなイメージです。チャットアプリやリアルタイム通知でよく使われます。
通信の全体像
パッケージ選定
pub.dev で候補を検索し、要件に合うものを選定しました。
mDNS パッケージ
今回はサーバー端末が「自分を見つけてもらう」ためにサービス登録が必要です。この要件で候補を絞りました。
| パッケージ | publisher | pub pt | DL数 | 発見 | 登録 | 備考 |
|---|---|---|---|---|---|---|
| multicast_dns | flutter.dev(公式) | 160 | 327万 | OK | 非対応 | |
| nsd | haberey.com(検証済み) | 160 | 3.6万 | OK | OK | Android NSD / iOS Bonjour をラップ |
| flutter_nsd | nimroddayan.com | 150 | 844 | OK | OK | Android NSD / iOS Bonjour をラップ |
| mdns_dart | 未検証 | 160 | 24万 | OK | OK | Dart のみで実装(ネイティブ API 不使用) |
Flutter 公式の multicast_dns が最初に目に入りますが、サービスの発見(クエリ)しかできず、登録には対応していません。multicast_dns の DL 数が圧倒的に多いのは、一般的なユースケースでは Flutter アプリは「発見する側」で、登録は IoT デバイスやプリンターなど Flutter 以外が担うことが多いからではないかと思います。今回のように Flutter アプリ同士で通信する場合は、アプリ側でサービス登録も必要になります。
登録に対応している nsd・flutter_nsd・mdns_dart の中から nsd を採用しました。mdns_dart は pure Dart 実装でネイティブ API を使わない点が気になり、flutter_nsd は DL 数が少なく実績が限られていたため、ネイティブ API(Android の NSD / iOS の Bonjour)をラップしており実績もある nsd が最も安心感がありました。
WebSocket パッケージ
クライアントとサーバーそれぞれで候補を検索しました。
クライアント:
| パッケージ | publisher | pub pt | DL数 | 備考 |
|---|---|---|---|---|
| web_socket_channel | tools.dart.dev(Dart 公式) | 150 | 600万 | StreamChannel ベースの API |
| web_socket | dart.dev(Dart 公式) | 160 | 495万 | Dart 標準の WebSocket API |
| web_socket_client | felangel.dev | 160 | 3.1万 | 自動再接続機能あり |
サーバー:
| パッケージ | publisher | pub pt | DL数 | 備考 |
|---|---|---|---|---|
| shelf_web_socket | tools.dart.dev(Dart 公式) | 160 | 683万 | shelf フレームワーク向け |
| dart_frog_web_socket | dart-frog.dev | 160 | 2,184 | Dart Frog フレームワーク向け |
サーバー側は shelf_web_socket を採用しました。shelf は Dart 公式の HTTP サーバーフレームワークで、WebSocket 対応もこのパッケージで完結します。dart_frog_web_socket は Dart Frog というフレームワーク向けのため、今回の用途には shelf の方がシンプルです。
クライアント側は web_socket_channel を採用しました。shelf_web_socket が内部で web_socket_channel を使っているため、サーバー・クライアント間で同じエコシステムに統一できます。
最終的なパッケージ構成
dependencies:
nsd: ^5.0.1 # mDNS サービス発見・登録
web_socket_channel: ^3.0.3 # WebSocket クライアント
shelf: ... # HTTP サーバー(shelf_web_socket の依存)
shelf_web_socket: ^3.0.0 # WebSocket サーバー
実装
サーバー側(mDNS 登録 + WebSocket サーバー)
サーバー端末は2つのことをします。
shelf_web_socketで WebSocket サーバーを起動nsdのregister()でサービスを mDNS に登録
import 'package:nsd/nsd.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_web_socket/shelf_web_socket.dart';
// WebSocket サーバーを起動
final handler = webSocketHandler((webSocket, _) {
webSocket.stream.listen((message) {
// クライアントからのメッセージを受信
print('受信: $message');
});
});
final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, 0);
// ポート 0 で OS に空きポートを自動割り当て
// mDNS にサービスを登録
final registration = await register(
Service(name: 'MyApp', type: '_playground._tcp', port: server.port),
);
InternetAddress.anyIPv4 で全インターフェースをリッスンし、ポート 0 で OS に空きポートを自動割り当てさせます。割り当てられたポートを mDNS に登録することで、クライアントはポート番号を知らなくても接続先を発見できます。
クライアント側(mDNS 発見 + WebSocket 接続)
クライアント端末は2つのことをします。
nsdのstartDiscovery()でサービスを検索- 発見したホスト・ポートに
WebSocketChannel.connect()で接続
import 'package:nsd/nsd.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
// mDNS でサービスを検索
final discovery = await startDiscovery('_playground._tcp');
discovery.addServiceListener((service, status) {
if (status == ServiceStatus.found) {
final host = service.host;
final port = service.port;
print('発見: ${service.name} ($host:$port)');
}
});
// 発見したサービスに WebSocket 接続
final channel = WebSocketChannel.connect(Uri.parse('ws://$host:$port'));
await channel.ready;
// メッセージ送信
channel.sink.add('こんにちは');
// メッセージ受信
channel.stream.listen((message) {
print('受信: $message');
});
プラットフォーム設定
Android(AndroidManifest.xml):
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
iOS(Info.plist):
<key>NSLocalNetworkUsageDescription</key>
<string>同一Wi-Fi内のデバイスと通信するために使用します</string>
<key>NSBonjourServices</key>
<array>
<string>_playground._tcp</string>
</array>
パーミッション設定はこれだけです。Android はマニフェストに宣言するだけでランタイムの権限リクエストは不要、iOS はアプリ初回起動時にローカルネットワークアクセスの許可ダイアログが1回表示されるのみです。permission_handler のようなパッケージも必要ありません。
iOS では NSBonjourServices にサービスタイプを登録しないと、ローカルネットワークアクセスの許可ダイアログが表示されず、サービスが発見できない点には注意してください。
ソースコード
本記事では実装のエッセンスを抜粋して紹介しました。全体の実装はリポジトリで公開しているので、よければ参考にしてみてください。
まとめ
同一 Wi-Fi 内の端末間通信は、mDNS でサービスを発見し WebSocket で接続するというシンプルな構成で実現できました。
パーミッション設定も最小限で、想像していたよりも躓くポイントが少なく実装できた印象です。






