[読書会]WebSocket で通信する

(当頁はdocs.flutter.dev(https://docs.flutter.dev/cookbook/networking/web-sockets)のリファレンスブログです。)

 (通常のHTTPリクエストに加えて) WebSocketを使用してサーバと接続することができます。
WebSocket は、ポーリングを必要とせずにサーバとの双方向通信を可能にします

 ここでは、Lob.com が提供するテスト用のWebSocketサーバに接続します
このサーバは、送信したメッセージをそのまま返す動作をします。)

1.WebSocket サーバに接続する

 WebSocket通信を行うために、web_socket_channel パッケージを使用します。
このパッケージは、WebSocketサーバへの接続を簡単に行うためのツールを提供します。

 WebSocketChannel を使うことで、サーバからのメッセージを受信したり、サーバにメッセージを送信したりすることができます。

WebSocketサーバ接続のコード(例):
//WebSocketChannel.connect:
//( 指定した WebSocket サーバへの接続を確立するためのメソッドです。
//  引数には接続先のサーバの URL を指定します。)
final channel = WebSocketChannel.connect(
  //Uri.parse():
  //( 文字列として指定された URL を Uri オブジェクトに変換します。
  //  これにより、WebSocketChannel.connect() メソッドで
  //  適切に URL を渡せるようになります。)
  Uri.parse(
    'wss://echo.websocket.events'
    //↑ これは、テスト用の WebSocket サーバで、
    //  送信したメッセージをそのまま返してくれる「エコーサーバ」です。
    //  wss は、WebSocket Secure(暗号化された通信)の略で、
    //  ws(非暗号化)より安全に通信できます。
  ),
);

2.サーバからのメッセージをリッスンする

 WebSocket サーバとの接続が確立されたら、サーバからのメッセージをリッスンして処理します

(テスト用サーバの場合、送信したメッセージがそのまま返されます(エコーサーバの動作)。
この例では、StreamBuilder ウィジェットを使用して新しいメッセージリッスンし、受信したデータを Text ウィジェットで画面に表示します。

StreamBuilder ウィジェットを使用して新しいメッセージをリッスンし、受信したデータを Text ウィジェットで画面に表示(例):
//StreamBuilder:
//( Stream と連携するための Flutter ウィジェット。
//  ストリーム内の新しいイベントを検知するたびに、UI を再構築します。)
StreamBuilder(
  //stream:
  //( WebSocketChannel のストリームを指定 )
  stream: channel.stream,  //←上記設定サーバからのメッセージストリーム。
  //( ↑サーバーからの新しいメッセージが channel.stream に届くたびに、
  //  StreamBuilder が反応します。)
  
  //builder:
  //( ストリームで新イベント(メッセージ)発生時に呼出されるコールバック関数。)
  builder: (context, snapshot) {
    // スナップショットにデータがある場合は表示、なければ空文字列
    return Text(snapshot.hasData ? '${snapshot.data}' : '');
  },
)

3.サーバにデータを送信する

 サーバにデータを送信するには、WebSocketChannel が提供する sink を使います。
この sink を通じて、サーバにメッセージを送ることができます。

WebSocketChannel が提供する sink を使い、この sink を通じて、サーバにメッセージを送る(例):
//サーバーとの接続
final channel = WebSocketChannel.connect(
  Uri.parse('wss://echo.websocket.events'),
);

//channel.sink:
//( WebSocketChannel が提供する sink は、サーバへのデータ送信をサポートします。
//  add() メソッド を使用して、サーバにデータ(メッセージ)を送信します。
//  メッセージを sink に追加することで、サーバーにデータを送信します。)

channel.sink.add('Hello!');

//サーバからのレスポンスをリッスン
channel.stream.listen((message) {
  print('Received: $message'); //サーバーからのメッセージを表示
});

4.WebSocket 接続を閉じる

 WebSocket を使用し終わったら、接続を閉じる必要があります。
これにより、サーバとの通信を終了し、リソースが解放されます。

WebSocket との接続を閉じる(例):
import 'package:web_socket_channel/web_socket_channel.dart';

void main() {
  //WebSocket サーバーに接続
  final channel = WebSocketChannel.connect(
    Uri.parse('wss://echo.websocket.events'),
  );

  //サーバーにデータを送信
  channel.sink.add('Hello, server!');

  //サーバーからのメッセージをリッスン
  channel.stream.listen((message) {
    print('Received: $message');
    
    //メッセージを受信後に接続を閉じる
    channel.sink.close();
  });
}

5.サンプルコード

WebSocketサーバに接続して、文字列の送受信および画面表示する(例):
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    const title = 'WebSocket Demo';
    return const MaterialApp(
      title: title,
      home: MyHomePage(
        title: title,
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({
    super.key,
    required this.title, //表示するタイトルを受け取る
  });

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  //ユーザが入力したメッセージを管理するテキストコントローラ
  final TextEditingController _controller = TextEditingController();

  //WebSocket サーバに接続
  final _channel = WebSocketChannel.connect(
    //Uri.parse('wss://echo.websocket.events'), //エコーサーバの URL
    Uri.parse('wss://ws.postman-echo.com/raw'), //エコーサーバの URL
  );

  //追加1
  late final Stream _broadcastStream;

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

    // WebSocketからのメッセージをリッスンしてログに出力
    //_channel.stream.listen((message) {
    _broadcastStream = _channel.stream.asBroadcastStream();
    if (kDebugMode) {
      //print('Received message: $message');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title), //アプリバーにタイトルを表示
      ),
      body: Padding(
        padding: const EdgeInsets.all(20), //画面全体のパディング
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start, //左揃え
          children: [
            //ユーザがメッセージを入力するフォーム
            Form(
              child: TextFormField(
                controller: _controller, //入力されたテキストを管理
                decoration: const InputDecoration(labelText: 'Send a message'), //入力フォームのラベル
              ),
            ),
            const SizedBox(height: 24), //フォームとメッセージ表示の間にスペースを追加
            //サーバからのメッセージを表示する StreamBuilder
            StreamBuilder(
              //stream: _channel.stream, //WebSocket サーバからのストリームを設定
              stream: _broadcastStream, //WebSocket サーバからのストリームを設定
              builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.active) {
                  //スナップショットにデータがあれば表示、なければ'No data received yet'表示
                  return Text(snapshot.hasData
                    ? '${snapshot.data}'
                    : 'No data received yet');
                  
                } else if (snapshot.connectionState == ConnectionState.waiting) {
                  return const Text('Waiting for data...');
                  
                } else {
                  return const Text('Connection closed');
                  
                }

              },
            )
          ],
        ),
      ),
      //送信ボタン
      floatingActionButton: FloatingActionButton(
        onPressed: _sendMessage, //メッセージを送信するメソッドを呼び出す
        tooltip: 'Send message', //ボタンのツールチップ
        child: const Icon(Icons.send), //ボタンのアイコン
      ),
    );
  }

  //メッセージをサーバに送信するメソッド
  void _sendMessage() {
    if (_controller.text.isNotEmpty) {
      _channel.sink.add(_controller.text); //ユーザが入力したメッセージをサーバに送信
    }
  }

  //ウィジェットが破棄される際にリソースを解放
  @override
  void dispose() {
    _channel.sink.close(); //WebSocket の接続を閉じる
    _controller.dispose(); //テキストコントローラのリソースを解放
    super.dispose();
  }
}

 docs.flutter.dev(https://docs.flutter.dev/cookbook/networking/web-sockets)中のサンプルだと、以下の様なエラーが発生しましたので、2か所ほど大きく変更してあります。

(変更点)

・WebSocket応答テスト用サーバ:
  ’wss://ws.postman-echo.com/raw’ に変更

・Stream:
  Stream(シングルリスナー用)を、asBroadcastStream() (複数リスナーが可能な Broadcast Stream に変換可能)に変更

(エラー)

Dart
════════ Exception caught by widgets library ═══════════════════════════════════
The following StateError was thrown building KeyedSubtree-[GlobalKey#80518]:
Bad state: Stream has already been listened to.

The relevant error-causing widget was:
    Scaffold Scaffold:file:///C:/app/flutter/test02/websockets01/lib/main.dart:58:12

When the exception was thrown, this was the stack:
#3      _StreamBuilderBaseState._subscribe (package:flutter/src/widgets/async.dart:130:38)
async.dart:130
#4      _StreamBuilderBaseState.initState (package:flutter/src/widgets/async.dart:104:5)
async.dart:104
#5      StatefulElement._firstBuild (package:flutter/src/widgets/framework.dart:5748:55)
framework.dart:5748
#6      ComponentElement.mount (package:flutter/src/widgets/framework.dart:5593:5)
framework.dart:5593
#7      Element.inflateWidget (package:flutter/src/widgets/framework.dart:4468:16)
framework.dart:4468
#8      MultiChildRenderObjectElement.inflateWidget (package:flutter/src/widgets/framework.dart:7035:36)
framework.dart:7035
#9      MultiChildRenderObjectElement.mount (package:flutter/src/widgets/framework.dart:7047:32)
framework.dart:7047
...     Normal element mounting (28 frames)
#37     Element.inflateWidget (package:flutter/src/widgets/framework.dart:4468:16)
framework.dart:4468
#38     MultiChildRenderObjectElement.inflateWidget (package:flutter/src/widgets/framework.dart:7035:36)
framework.dart:7035
#39     MultiChildRenderObjectElement.mount (package:flutter/src/widgets/framework.dart:7047:32)
framework.dart:7047
...     Normal element mounting (340 frames)
#379    Element.inflateWidget (package:flutter/src/widgets/framework.dart:4468:16)
framework.dart:4468
#380    MultiChildRenderObjectElement.inflateWidget (package:flutter/src/widgets/framework.dart:7035:36)
framework.dart:7035
#381    MultiChildRenderObjectElement.mount (package:flutter/src/widgets/framework.dart:7047:32)
framework.dart:7047
...     Normal element mounting (525 frames)
#906    Element.inflateWidget (package:flutter/src/widgets/framework.dart:4468:16)
framework.dart:4468
#907    Element.updateChild (package:flutter/src/widgets/framework.dart:3963:18)
framework.dart:3963
#908    _RawViewElement._updateChild (package:flutter/src/widgets/view.dart:441:16)
view.dart:441
#909    _RawViewElement.mount (package:flutter/src/widgets/view.dart:464:5)
view.dart:464
...     Normal element mounting (15 frames)
#924    Element.inflateWidget (package:flutter/src/widgets/framework.dart:4468:16)
framework.dart:4468
#925    Element.updateChild (package:flutter/src/widgets/framework.dart:3963:18)
framework.dart:3963
#926    RootElement._rebuild (package:flutter/src/widgets/binding.dart:1605:16)
binding.dart:1605
#927    RootElement.mount (package:flutter/src/widgets/binding.dart:1574:5)
binding.dart:1574
#928    RootWidget.attach.<anonymous closure> (package:flutter/src/widgets/binding.dart:1527:18)
binding.dart:1527
#929    BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:3038:19)
framework.dart:3038
#930    RootWidget.attach (package:flutter/src/widgets/binding.dart:1526:13)
binding.dart:1526
#931    WidgetsBinding.attachToBuildOwner (package:flutter/src/widgets/binding.dart:1265:27)
binding.dart:1265
#932    WidgetsBinding.attachRootWidget (package:flutter/src/widgets/binding.dart:1247:5)
binding.dart:1247
#933    WidgetsBinding.scheduleAttachRootWidget.<anonymous closure> (package:flutter/src/widgets/binding.dart:1233:7)
binding.dart:1233
#937    _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:184:12)
isolate_patch.dart:184
(elided 6 frames from class _Timer, dart:async, and dart:async-patch)

コメントを残す