Flutter×Socket.IO チャットアプリをつくる

サーバーサイド

環境構築

わけわからない状態でローカル環境を汚すのは避けたいので、
CodeSandboxを使用してサーバーを構築する。
Socket.IOはNode.js上で動くのでテンプレートはNode HTTP Serverを選択。

立ち上がったら、npm でexpressとsocket.ioをインストールしておく。

serve.js を作成

const crypto = require("crypto");
const app = require("express")();
const http = require("http").createServer(app);
const io = require("socket.io")(http);

// HTMLやJSなどを配置するディレクトリ
const DOCUMENT_ROOT = __dirname + "/public";

/**
 * "/"にアクセスがあったらindex.htmlを返却
 */
app.get("/", (req, res) => {
  res.sendFile(DOCUMENT_ROOT + "/index.html");
});
app.get("/:file", (req, res) => {
  res.sendFile(DOCUMENT_ROOT + "/" + req.params.file);
});

/**
 * [イベント] ユーザーが接続
 */
io.on("connection", (socket) => {
  console.log("ユーザーが接続しました");

  //---------------------------------
  // ログイン
  //---------------------------------
  (() => {
    // トークンを作成
    const token = makeToken(socket.id);

    // 本人にトークンを送付
    io.to(socket.id).emit("token", { token: token });
  })();

  //---------------------------------
  // 発言を全員に送信
  //---------------------------------
  socket.on("post", (msg) => {
    io.emit("member-post", msg);
  });
});

/**
 * 3000番でサーバを起動する
 */
http.listen(3000, () => {
  console.log("listening on *:3000");
});

/**
 * トークンを作成する
 *
 * @param  {string} id - socket.id
 * @return {string}
 */
function makeToken(id) {
  const str = "aqwsedrftgyhujiko" + id;
  return crypto.createHash("sha1").update(str).digest("hex");
}

引用:はじめてのSocket.io #2 チャット編「自分がemitした通信内容か判定する」 (katsubemakito.net)

返すindex.htmlがないのでコメントアウトしておく。

// app.get("/", (req, res) => {
//   res.sendFile(DOCUMENT_ROOT + "/index.html");
// });

設定からpackage.jsonを編集する。
startの項目を node serve.js にしておけば勝手に立ち上がる。

server.jsの解説

ほとんど引用元の転載になってしまうが書く。

クライアントが最初に接続したときに呼ばれるイベント
 io.on("connection", (socket) => { // 処理 });

クライアントに送信
 io.emit('イベント名', { data:data });

特定のユーザーにだけemitする
 io.to(socket.id).emit("イベント名", {data:data});
※Socket.ioではsocket.idから各ユーザーのセッションIDを知ることができる。

クライアントサイド

main.dart

import 'package:flutter/material.dart';
import 'chat.dart';

void main(){
  runApp(MyApp());
}

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

class MyHomePage extends StatefulWidget {
  const MyHomePage({
    Key? key,
    required this.title,
  }) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

//ここからメイン
class _MyHomePageState extends State<MyHomePage> {
  final TextEditingController _controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Form(
              child: TextFormField(
                controller: _controller,
                decoration: const InputDecoration(labelText: '名前'),
              ),
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              child: const Text('次へ'),
              onPressed: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(builder: (context) => Chat(name: _controller.text)),
                );
              },
            ),
          ],
        ),
      ),
    );
  }

}

main.dartの解説

次へボタンを押したとき、Chatに名前を渡して遷移させる。

ElevatedButton(
              child: const Text('次へ'),
              onPressed: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(builder: (context) => Chat(name: _controller.text)),
                );
              },
            ),

chat.dart

import 'package:flutter/material.dart';
import 'package:socket_io_client/socket_io_client.dart';

class Chat extends StatefulWidget {
  const Chat({
    Key? key,
    required this.name,
  }) : super(key: key);

  final String name;

  @override
  // ignore: library_private_types_in_public_api
  _ChatState createState() => _ChatState();
}

//ここからメイン
class _ChatState extends State<Chat> {
  final TextEditingController _controller = TextEditingController();
  late final Socket _socket;
  late EmitData emitData = EmitData();
  late List<EmitData> listEmitData = [];
  late Color color_text = Colors.black;

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

    _socket = io(
      "https://xxxxxx.xxx.codesandbox.io",
      OptionBuilder()
        .setTransports(['websocket'])
        .disableAutoConnect()
        .build()
    );

    _socket.onConnect((data) {
      _socket.on("token", (data) {
        emitData = EmitData.fromJson(data);
      });
    });

    _socket.on("member-post", (msg) => {
      if(mounted){
        setState(() {
          listEmitData.add(EmitData.fromJson(msg));
        })
      }
    });

    // WebSocketに接続
    _socket.connect();
  }

  @override
  Widget build(BuildContext context) {
    
    return Scaffold(
      appBar: AppBar(
        title: const Text("chat"),
      ),
      body: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Form(
              child: TextFormField(
                controller: _controller,
                decoration: const InputDecoration(labelText: 'メッセージを送る'),
              ),
            ),
            const SizedBox(height: 24),
            ListView.builder(
              shrinkWrap: true, //column内のため
              itemCount: listEmitData.length,
              itemBuilder: (context, index) {
                if(listEmitData[index].token == emitData.token){
                  color_text = Colors.blue;
                }else{
                  color_text = Colors.black;
                }
                return Text(
                  "${listEmitData[index].name}> ${listEmitData[index].text}",
                  style: TextStyle(color: color_text)
                );
              },
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _sendMessage,
        tooltip: 'Send message',
        child: const Icon(Icons.send),
      ),
    );
  }

  void _sendMessage() {
    emitData.text = _controller.text;
    _controller.clear();
    _socket.emit("post", {
      "text": emitData.text,
      "token": emitData.token,
      "name": widget.name
    });
  }
}

class EmitData {
  String? text = "";
  String token = "token";
  String? name = "name";

  EmitData();

  EmitData.fromJson(Map<String, dynamic> json)
      : text = json['text'],
        token = json['token'],
        name = json['name'];
}

chat.dartの解説

名前(name)を渡すことを要求。requiredつけると渡していないときエラーを返してくれるはず。

const Chat({
    Key? key,
    required this.name,
  }) : super(key: key);

第一引数にサーバーのURL, 第二引数にオプション設定。
今回は自動接続をOFFにしている。(なぜか推奨らしい。)

_socket = io(
      "https://xxxxxx.xxx.codesandbox.io",
      OptionBuilder()
        .setTransports(['websocket'])
        .disableAutoConnect()
        .build()
    );

初回接続時、サーバーからtokenが発行されるので保存する。
これはListView.builder ウィジェットで表示させる。

    _socket.onConnect((data) {
      _socket.on("token", (data) {
        emitData = EmitData.fromJson(data);
      });
    });

メッセージを受け取ったらリストに追加する。

    _socket.on("member-post", (msg) => {
      if(mounted){
        setState(() {
          listEmitData.add(EmitData.fromJson(msg));
        })
      }
    });

socket.ioなので当然だがemitで送信。node.jsと若干書き方が違う点に注意。Dart慣れない…
main.dartから渡された値はwidget.変数名で参照できる。

    _socket.emit("post", {
      "text": emitData.text,
      "token": emitData.token,
      "name": widget.name
    });

実行結果

リリースビルドしたものを2個立ち上げて実験。

備考

Flutter 3.5.0-12.0.pre.145 • channel master • https://github.com/flutter/flutter.git
Framework • revision a1289a4135 (4 hours ago) • 2022-11-07 06:46:25 -0500
Engine • revision 891d4a3577
Tools • Dart 2.19.0 (build 2.19.0-374.0.dev) • DevTools 2.19.0

いいなと思ったら応援しよう!