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を使用すればいいみたい。
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の処理を記述できる。