Реализация чата на Flutter при помощи вебсокетов

В этой статья мы хотели бы рассмотреть реализацию простого чата на языке Dart, используя протокол websocket.

Читать на Habr

Для чего нужны вебсокеты?

Вебсокеты могут использоваться в клиент-серверных приложениях, где необходимо создать двухстороннюю связь, чтобы серверная сторона могла инициировать процессы в клиентском приложении. Например, отображать уведомление в клиентском приложении при возникновении событии на сервере, или в случае, когда необходимо в режиме реального времени обновлять данные в клиентском приложении. Это можно использовать, чтобы отображать на карте местоположение и статус водителя. В качестве еще одного применения вебсокета - можно привести настройку из одного клиентского приложения (используемого родителем) через связь по сокету с сервером другого приложения (используемого ребенком).

В этих приложениях, реализованных нашей компанией, успешность бизнес-процесса выстраивалась на последовательной корректной работе двух сокетов (клиент (родительское приложение) - сервер и клиент (используемый ребенком) - сервер).

Пример использования вебсокета

Рассмотрим пример использования вебсокета при реализации простого чата с помощью библиотеки web_socket_channel.

Напишем класс для управления сокетом нашего чата. Он будет содержать объект сокета из библиотеки, подписку на события из этого сокета и комплитер для идентификации состояния процесса подключения. При старте приложения создадим объект этого класса.

class SocketApi {
  SocketApi();

  IOWebSocketChannel? _socket;
  StreamSubscription? _socketSubscription;
  Completer<bool> _connecting = Completer<bool>();
}
Если приложение требует авторизации, то соединение по сокету устанавливаем только в авторизованном состояниии. Для этого делаем в классе геттер и сеттер id профиля пользователя и при инициализации соединения передаем в запросе токен пользователя.
int? _profileId;

Future<void> setProfile({required final int profileId}) async {
    _profileId = profileId;
    await _connect();
  }

  void removeProfile() {
    _profileId = null;
    _close();
  }

Future<void> _connect() async {
    await _close();
    final prefs = await SharedPreferences.getInstance();
    final token = prefs.getString('TOKEN');
    assert(_profileId != null);
    final wsUrl = Uri.parse('ws://my-base-url/ws/chat/$_profileId/?token=$token');
      try {
        _socket = IOWebSocketChannel.connect(
          wsUrl,
          pingInterval: const Duration(seconds: 5),
        );
        _socketSubscription = _socket!.stream.listen(
          _onMessage,
          onDone: _onDone,
          onError: (Object err, StackTrace stackTrace) {
            print('Listen err $err, $stackTrace');
          },
        );
        _connecting.complete(true);
        debugPrint('connected');
      } catch (_) {
        debugPrint('cant connected');
      }
  }
Как видно из кода выше, мы создаем подписку на стрим и вешаем обработчики на все события и ошибки.
Напишем метод, который будет закрывать сокет при выходе пользователя из профиля.
Future<void> _close() async {
    _connecting = Completer<bool>();
    await _socketSubscription?.cancel();
    _socketSubscription = null;
    try {
      await _socket?.sink.close(status.normalClosure);
    } catch (_) {
      debugPrint('Socket already closed.');
    }

    if (_socket != null) {
      print(['disconnected', _socket?.closeCode, _socket?.closeReason]);
    }
    _socket = null;
  }
Реализуем обработчики событий в сокете.
Future<void> _onDone() async {
    await _close();
    await Future<void>.delayed(const Duration(seconds: 1), () async {
      await _connect();
    });
}

void _onMessage(dynamic message) {
    debugPrint('✅ RECEIVED: $message');
    final msg = jsonDecode(message.toString()) as Map<String, dynamic>;
    final chatJson = msg['chat'] as Map<String, dynamic>;
    final event = msg['type'] as String;
    if (event == 'write_message') {
      // Реализуем обработчики различных типов событий чата
    } else if (event == 'read_messages') {
      // то же
    }
}
Реализуем метод для отправки сообщений в сокет:
Future<void> _send(
    String cmd,
    int chatId,
    String? message, {
    Map<String, dynamic>? data,
  }) async {
    if (_socket == null) {
      await _connect();
    }
    final connectionResult = await _connecting.future;
    if (connectionResult != true) {
      return;
    }
    final d = <String, dynamic>{
      'type': cmd,
      'chat_id': chatId,
      'message': message,
    };
    d.removeWhere((key, value) => value == null);
    final json = jsonEncode(d);
    debugPrint('? SEND: $d');
    _socket!.sink.add(json);
  }
Поле "cmd" в этом методе отвечает за тип отправляемого сообщения.
Реализуем примеры методов для отправки событий различного типа в сокет.
Future<void> sendTextMessage({
  required final String text,
  required final int chatId,
}) async {
  await _send('write_message', chatId, text);
}

Future<void> setMessagesRead({required final int chatId}) async {
  await _send('read_message', chatId, null);
}
В итоге мы получили класс, который позволит реализовать работу простого чата с помощью вебсокета.