NCMBとFlutterでチャットアプリを作る（その3：チャット処理）

NCMBとMonacaを使ってチャットアプリを作ります。以前、○○を使ったチャットアプリを公開したのですが、現在○○は無料での利用枠がなくなっています。そこで今回は[goofmint/ruby-websockets-chat-demo](https://github.com/goofmint/ruby-websockets-chat-demo)をベースにチャットアプリを作ります。これはごく単純なWebScoketサーバーで、Herokuにデプロイできるものです。Herokuの無料枠内での利用が可能です。

[前回の記事では認証周りを解説しました](https://qiita.com/goofmint/items/389746953078397f886e)。今回はチャット処理について解説します。

## コードについて

今回のコードは[NCMBMania/flutter\_chat: Flutter SDKを使ったチャットアプリです](https://github.com/NCMBMania/flutter_chat)にアップロードしてあります。実装時の参考にしてください。

## WebSocketサーバーのデプロイ

チャットサーバーをデプロイします。Herokuに対応していますので、クリック1つでデプロイできます。

[goofmint/ruby-websockets-chat-demo](https://github.com/goofmint/ruby-websockets-chat-demo)

デプロイした際のURLが `https://CHAR-SERVER.herokuapp.com` だとしたら、`https` を `wss` に変更して `.env` にて `WEBSOCKET_URL` キーで設定します。 `wss` はWebSocketのSSL/TLS版です。

```
NCMB_PUBLIC_FILE_PATH=https://mbaas.api.nifcloud.com/2013-09-01/applications/YOUR_APP_ID/publicFiles/
WEBSOCKET_URL=wss://YOUR_APP_NAME.herokuapp.com
APPLICATION_KEY=YOUR_APPLICATION_KEY
CLIENT_KEY=YOUR_CLIENT_KEY

```

## WebSocket接続の設定

ChatPageにてWebSocketへの接続を行います。

```dart
// チャット画面用State
class _ChatPageState extends State<ChatPage> {
  @override
  void initState() {
    super.initState();
    _initWebSocket(); // WebSocket接続
    _listenWebSocket(); // WebSocketリスナー
    _loadMessages(); // メッセージ取得
    _setUser(); // ユーザー情報セット
  }

	  // WebSocketを初期化する
  void _initWebSocket() {
    _channel =
        WebSocketChannel.connect(Uri.parse(dotenv.env['WEBSOCKET_URL']!));
  }
}
```

## チャット画面について

チャット画面はflutter_chat_uiを使うので、コードはとてもシンプルです。

```dart
@override
Widget build(BuildContext context) {
	return Scaffold(
			appBar: AppBar(title: const Text('Chat')),
			body: Chat(
				messages: _messages,
				onPreviewDataFetched:
						(types.TextMessage message, types.PreviewData previewData) {},
				onSendPressed: _onSendPressed,
				onAttachmentPressed: _handleAtachmentPressed,
				showUserAvatars: true,
				showUserNames: true,
				user: _user,
			));
}
```

### 変数の準備

ChatPageで共有する変数は次の通りです。

```dart
// チャットメッセージを格納するリスト
List<types.Message> _messages = [];
// チャットユーザー情報
late types.User _user;
// WebSocket用オブジェクト
late WebSocketChannel _channel;
// HTMLのアンエスケープ処理用
final _unescape = HtmlUnescape();
// ファイル名用
final _uuid = const Uuid();
```

### 初期化処理

initStateではWebSocketへの接続以外にも多くの関数があります。

```dart
void initState() {
	super.initState();
	_initWebSocket(); // WebSocket接続
	_listenWebSocket(); // WebSocketリスナー
	_loadMessages(); // メッセージ取得
	_setUser(); // ユーザー情報セット
}
```

#### WebSocketのリスナー設定

WebSocketを使ってメッセージを受け取った場合の処理です。WebSocketでは文字列しか送れませんので、その文字列からNCMBObjectとNCMBUserを再現して、チャットメッセージを作成します。作成したメッセージはリスト _messages に追加します。

```dart
// WebSocketリスニングイベントを設定する
void _listenWebSocket() {
	_channel.stream.listen((data) async {
		// メッセージ情報からNCMBObjectを復元する
		final map = json.decode(data) as Map<String, dynamic>;
		final object = _decodeNCMBObject(map['message'], 'Message') as NCMBObject;
		final user = _decodeNCMBObject(map['user'], 'user') as NCMBUser;
		// ユーザー情報を追加
		object.set('user', user);
		// NCMBObjectからチャットメッセージへ変換する
		final message = _createMessage(object);
		// メッセージを追加
		setState(() {
			_messages.insert(0, message);
		});
	});
}
```

#### 既存メッセージの読み込み

データストアに保存されているメッセージを読み込みます。これはMessageクラス（クラスは一般的なDBでいうテーブル名相当）に保存されています。

このメッセージを受け取った後、各メッセージをチャットメッセージに変換しています。

```dart
// 既存のメッセージをNCMBから読み込む処理
void _loadMessages() async {
	// メッセージを取得
	var query = NCMBQuery('Message');
	query.include('user');
	query.limit(100);
	query.order('createDate');
	final messages = await query.fetchAll();
	// NCMBのメッセージからチャット用メッセージに変換
	var chatMessages = messages.map((m) => _createMessage(m)).toList();
	// 表示を更新
	setState(() {
		_messages = chatMessages;
	});
}
```

![IMG_2615.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/e59c2c2a-823f-7811-b7c4-1ca744b30168.png)


#### ユーザーデータの作成

NCMBUser（ログインユーザー）からチャットユーザーの形に変換しています。

```dart
// ログインユーザー情報をチャットユーザー情報に変換して設定する
void _setUser() {
	_user = types.User(
			id: widget.user.objectId!,
			lastName: widget.user.getString('displayName'));
}
```

### メッセージを送信する

メッセージの送信は2種類あります。なお、1と2を同時には行えません。

1. テキストメッセージの送信
2. 写真の送信


#### テキストメッセージの送信

テキストメッセージは送信ボタンを押した際に実行されます。 `_createNCMBObject` 関数でNCMBObjectを作成します。保存したら、そのデータをWebSocketで送信します。

```dart
// メッセージ送信時のイベント処理
void _onSendPressed(types.PartialText message) async {
	// NCMBObjectを作成して保存
	var object = await _createNCMBObject(message: message.text);
	await object.save();
	// WebSocketで送信
	_sendMessage(object);
}
```

#### 写真の送信

写真を送信する場合には、まず選択された写真をファイルストアにアップロードします。後はそのバイト配列（画像のサイズなどに使います）と、ファイル名を `_createNCMBObject` に送ります。それ以外の流れはテキストメッセージと同じです。

```dart
// 画像選択時の処理
// 画像をNCMBFileでアップロード後、ファイル名などをNCMBObjectに登録する
void _handleImageSelection() async {
	// 画像の選択ダイアログ表示
	final result = await ImagePicker().pickImage(
		imageQuality: 70,
		maxWidth: 1440,
		source: ImageSource.gallery,
	);
	// 選択された画像がない場合は終了
	if (result == null) return;
	// 選択されたファイルをUnit8Listに変換
	final bytes = await result.readAsBytes();
	// ファイル名の設定
	final fileName = result.mimeType == 'image/jpeg'
			? '${_uuid.v4()}.jpg'
			: '${_uuid.v4()}.png';
	// ファイルアップロード
	await NCMBFile.upload(fileName, bytes);
	// ファイルと関連付けたNCMBObjectを作成
	var object = await _createNCMBObject(fileName: fileName, bytes: bytes);
	// NCMBObjectを保存
	await object.save();
	// WebSocketで送信
	_sendMessage(object);
}
```

#### NCMBObjectの作成

NCMBObjectはテキストメッセージと写真アップロードで変わりませんが、写真の場合はファイル名や写真のサイズなどの情報を保存しています。

また、ACL（アクセス権限）として誰でも読み込み可能、保存したユーザー自身だけが書き込み（編集）可能としています。

```dart
// NCMBObjectを作成して返す
Future<NCMBObject> _createNCMBObject(
		{String? message, String? fileName, Uint8List? bytes}) async {
	var object = NCMBObject('Message');
	// テキストメッセージの場合
	if (message != null) {
		object.set('message', message);
	}
	// 画像メッセージの場合
	if (fileName != null) {
		final image = await decodeImageFromList(bytes!);
		object.set('fileName', fileName);
		// 画像表示用に必要な情報を設定
		object.set('height', image.height.toDouble());
		object.set('size', bytes.length);
		object.set('width', image.width.toDouble());
	}
	object.set('user', widget.user);
	// ACL（アクセス権限）の設定
	var acl = NCMBAcl();
	acl
		..setPublicReadAccess(true) // 誰でも読み込み可能
		..setUserWriteAccess(widget.user, true); // 編集は自分だけ
	object.set('acl', acl);
	return object;
}
```

#### メッセージの送信

NCMBObjectをWebSocketで送信する処理です。前述の通り、WebSocketでは文字列しか送信できませんので、NCMBObjectを文字列にしてから送信しています。

```dart
  // WebSocketでメッセージ送信
  void _sendMessage(NCMBObject object) {
    _channel.sink.add(jsonEncode(
        {'message': object.toString(), 'user': widget.user.toString()}));
  }
```

逆に文字列で送られてきたデータをNCMBObject、またはNCMBUserに戻す処理は次のようになります。

```dart
// WebSocketのメッセージからNCMBObjectやNCMBUserを復元する
Object _decodeNCMBObject(String str, String className) {
	final messageJson =
			json.decode(_unescape.convert(str)) as Map<String, dynamic>;
	// NCMBObjectとNCMBUserの出し分け
	final object = className == 'user' ? NCMBUser() : NCMBObject(className);
	object.sets(messageJson);
	return object;
}
```

## まとめ

今回はチャット画面周りを解説しました。WebSocketサーバーを自分で立てれば、簡単なチャットアプリを作成できます。WebSocketサーバーだけではメッセージが保存されませんが、NCMBのデータストアを組み合わせることで、チャットメッセージを残しておけます。

flutter_chat_uiを使えばUI周りを深く考えずとも、簡単にチャット機能が実装できます。ぜひNCMBと組み合わせてチャット機能を実装してください。