Flutter × Deno(WebSocket)でチャットアプリをつくる


成果物

準備

下記をインストール。
依存関係の関係で他にも勝手にいろいろインストールされるはず。

Flutter 3.16.9
 - flutter_chat_ui: ^1.6.10
 - settings_ui: ^2.0.2
 - uuid: ^4.3.3
 - web_socket_channel: ^2.4.0
 - flutter_riverpod: ^2.4.10
deno 1.40.5
 - deployctl 1.10.5

※riverpod に関してはいろいろバージョンアップがあったらしく、まだ検索上位に古い情報が多いので注意。

今回はannotationとgeneratorを使用するので、下記公式のゲッティングスタートのインストールコマンドを使用した。https://riverpod.dev/ja/docs/introduction/getting_started

flutter pub add flutter_riverpod
flutter pub add riverpod_annotation
flutter pub add dev:riverpod_generator
flutter pub add dev:build_runner
flutter pub add dev:custom_lint
flutter pub add dev:riverpod_lint

サーバースクリプト

// main.js
// deno run --allow-net=0.0.0.0:80 --allow-read=./index.html main.js
let sockets = new Map();

Deno.serve({
    port: 80,
    handler: async (request) => {

      if (request.headers.get("upgrade") === "websocket") {
        const { socket, response } = Deno.upgradeWebSocket(request);

        socket.onopen = () => {
          console.log("CONNECTED");
          const uuid = crypto.randomUUID();
          sockets.set(uuid, socket);
          socket.send(JSON.stringify({type:"getId", uuid: uuid}));
        };
        socket.onmessage = (event) => {
          console.log(`RECEIVED: ${JSON.stringify(event)}`);
          for(const [key, value] of sockets){
            try{
              value.send(event.data);// eventは文字列になっているのでそのままポストする
            }catch(e){
              sockets.delete(key);
              console.error("ERROR:", e);

            }
            
          };
          
        };
        socket.onclose = () => {
          console.log("DISCONNECTED");
          console.log(sockets);
        };
        socket.onerror = (error) => console.error("ERROR:", error);
  
        return response;
      } else {
        const file = await Deno.open("./index.html", { read: true });
        return new Response(file.readable);
      }
    },
});
  

解説

const { socket, response } = Deno.upgradeWebSocket(request);

Denoでwebsocketを使用する場合、Deno.upgradeWebSocket()でrequestを受け取って、socketとresponceオブジェクトを受け取る。
そして、socketオブジェクトにイベントを設定して、respenceを返してあげる。

Deno.serve({port:80, handler: async (request) => { return Responce });

よく理解していないが、上記サーバーオブジェクトを定義することで、サーバー処理の記述が可能。

ローカルで実行する場合は
deno run --allow-net=0.0.0.0:80 --allow-read=./index.html main.js
のようにip,ポートの許可とファイル読み取り許可の設定を引数で行う必要がある。

クライアントスクリプト

// main.dart
import 'dart:convert';
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';

import 'package:flutter_vector_icons/flutter_vector_icons.dart';
import 'package:settings_ui/settings_ui.dart';

import 'package:web_socket_channel/status.dart' as status;
import 'package:web_socket_channel/web_socket_channel.dart';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import 'dart:convert' as convert;
import 'package:uuid/uuid.dart';

// riverpod_generator用
part 'main.g.dart';

// flutter pub run build_runner watch でriverpod_generator実行
@riverpod
class SetteingData extends _$SetteingData {
  @override
  Map<String, dynamic> build() => {"userName": "username"};

  void setUserName(String name) {
    state.update("userName", (value) => name);
  }

}

// エントリポイント
void main() {
  runApp(const ProviderScope(child: MyApp()));
}

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

  @override
  Widget build(BuildContext context) => const MaterialApp(
        home: SettingPage(),
      );
}

class ChatPage extends ConsumerStatefulWidget {
  const ChatPage({super.key});

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

class ChatPageState extends ConsumerState<ChatPage> {
  final uuid = const Uuid();
  final List<types.Message> _messages = [];
  late WebSocketChannel channel;
  late String _uuid = "";
  late types.User _user = const types.User(id: 'temporaryId');

  // 初期化、イベント処理
  @override
  void initState(){
    debugPrint("初期化開始");
    super.initState();
    // _loadMessages();

    channel = WebSocketChannel.connect(
      Uri.parse('ws://プロジェクト名.deno.dev:80'),
    );

    channel.stream.listen((response) {
      final res = convert.json.decode(response) as Map<String, dynamic>;
      if(res['type'] == "post"){
        setState(() {
          _addMessage(types.Message.fromJson(res['user'] as Map<String, dynamic>));
        });
      }

      if(res['type'] == "getId"){
        setState(() {
          _uuid = res['uuid'];
        });
      }
      
    });

  }

  @override
  void dispose() {
    super.dispose();
    channel.sink.close(status.goingAway);
  }

  @override
  Widget build(BuildContext context) {
    final setteingData = ref.watch(setteingDataProvider);
    _user = types.User(
        id: _uuid,
        firstName: setteingData['userName'],
        lastName: "@1.0"
      );
    return Scaffold(
        body: Chat(
          messages: _messages,
          onSendPressed: _handleSendPressed,
          user: _user,
          showUserNames: true,
        ),
      );
  }

  void _addMessage(types.Message message) {
    setState(() {
      _messages.insert(0, message);
    });
  }

  void _handleSendPressed(types.PartialText message) {
    if(_user.id == 'temporaryId') return;

    final textMessage = types.TextMessage(
      author: _user,
      createdAt: DateTime.now().millisecondsSinceEpoch,
      id: uuid.v4(),
      text: message.text,
    );

    channel.sink.add(convert.json.encode({"type": "post", "user": textMessage}));
  }
}

class SettingPage extends ConsumerWidget {
  const SettingPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final settingData = ref.watch(setteingDataProvider); // 値の変更感知に必要っぽい
    return Scaffold(
      backgroundColor: const Color(0xFFF2F2F7),
      appBar: AppBar(),
      body: SettingsList(
        platform: DevicePlatform.iOS,
        lightTheme: const SettingsThemeData(
          settingsListBackground: Color(0xFFF2F2F7),
          settingsSectionBackground: Colors.white,
        ),
        sections: [
          SettingsSection(
            title: const Text("セクション"),
            tiles: [
              SettingsTile.navigation(
                leading: const Icon(
                  Icons.star,
                  color: Colors.yellow,
                ),
                title: const Text('チャットを始める'),
                trailing: const Text(""),
                onPressed: (context) {
                  Navigator.push(
                    context,
                    MaterialPageRoute(builder: (context) => const ChatPage()),
                  );
                },
              ),
              SettingsTile(
                leading: const Text("名前:"),
                title: TextField(onChanged: (value) {
                  ref.read(setteingDataProvider.notifier).setUserName(value);
                })
              ),
            ],
          ),
        ],
      ),
    );
  }
}

解説

part 'main.g.dart';

@riverpod
class SetteingData extends _$SetteingData {
  @override
  Map<String, dynamic> build() => {"userName": "username"};

  void setUserName(String name) {
    state.update("userName", (value) => name);
  }

}

@riverpodというアノテーションをつけておき、flutter pub run build_runnerを実行するとmain.g.dartに必要なコードが自動生成される。基本はこのNotifierProviderを使用すればいいみたい。

riverpod_annotation、生成ファイルをインポート
@riverpod のアノテーションを付ける
クラス名:自由(記法はローワーキャメルケース)
build メソッド:必ず記述します
状態値:状態値とその初期値を定義します。状態値を持たない場合は返り値を void にします
クラスメソッド:自由に記述してください。state で状態値にアクセス出来ます
生成されるプロバイダ名:ファイル名のローワーキャメルケース+Providerで生成されます。

https://zenn.dev/flutteruniv_dev/articles/riverpod_generator_in_action#notifierprovider
class SettingPage extends ConsumerWidget {
@override
  Widget build(BuildContext context, WidgetRef ref) {
    final settingData = ref.watch(setteingDataProvider); // 監視に必要
...
ref.read(setteingDataProvider.notifier).setUserName(value);

ConsumerWidgetを継承したクラスでは、buildの引数WidgetRef refを付け足す。
final settingData = ref.watch(setteingDataProvider); 
としたとき、settingDataの値がリアルタイムに更新される(値更新時に画面更新が入る)
値の更新は
ref.read(setteingDataProvider.notifier).setUserName(value);
のようにする

ConsumerStatefulWidget、ConsumerStateはそのstateful版。

Chat(
     messages: _messages,
     onSendPressed: _handleSendPressed,
     user: _user,
     showUserNames: true,
)
types.User(
        id: _uuid,
        firstName: setteingData['userName'],
        lastName: "@1.0"
      );

Chat、types.Userに関してはflutter_chat_uiで提供されるウィジェット・クラス。

SettingsListXXX

SettingsListXXXに関してはsettings_uiで提供されるウィジェット・クラス。

// 接続
channel = WebSocketChannel.connect(
      Uri.parse('ws://プロジェクト名.deno.dev:80'),
    );

    // 受信
    channel.stream.listen((response) {
      final res = convert.json.decode(response) as Map<String, dynamic>;
      if(res['type'] == "post"){
        setState(() {
          _addMessage(types.Message.fromJson(res['user'] as Map<String, dynamic>));
        });
      }

      if(res['type'] == "getId"){
        setState(() {
          _uuid = res['uuid'];
        });
      }
      
    });
// 切断
@override
  void dispose() {
    super.dispose();
    channel.sink.close(status.goingAway);
  }
// 送信
channel.sink.add(convert.json.encode({"type": "post", "user": textMessage}));

WebSocketChannelはweb_socket_channelによって提供され、websocketの処理を記述できる。

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