Разработка группового чата в мобильном приложении
Групповой чат сложнее приватного не в разы, а на порядок. В приватном чате два участника — все события синхронизируются через одно WebSocket-соединение к одной conversation. В групповом чате на 200 участников сервер должен fanout каждого сообщения в 199 соединений, правильно вычислять непрочитанные для каждого, не давить Redis под нагрузкой и корректно работать когда часть участников офлайн. Это уже проблема архитектуры, а не просто UI.
Серверная архитектура: fanout и presences
Распределение сообщений
Самая болезненная часть — доставка сообщения всем участникам группы. Синхронный fanout («отправили → прогнали по всем соединениям → ответили клиенту») не масштабируется: при группе на 500 человек итерация по активным соединениям занимает десятки миллисекунд, а если WS-серверов несколько — соединения участников распределены по разным нодам.
Правильная схема: клиент → WebSocket-сервер → очередь (Redis Pub/Sub или Kafka topic per group) → каждый WS-сервер читает из своей очереди и доставляет онлайн-участникам → для офлайн-участников — очередь push-уведомлений.
Для групп до 100 участников Redis Pub/Sub с channel-per-group работает хорошо. Для больших — Kafka или NATS JetStream с consumer groups.
Непрочитанные сообщения (unread counts)
Классическая ошибка — хранить last_read_message_id в таблице group_members и при каждом запросе считать SELECT COUNT(*) WHERE id > last_read_message_id. На группе с тысячами сообщений и сотнями участников это убивает базу.
Рабочий подход: Redis Hash unread:{user_id}:{group_id} → инкремент на каждое новое сообщение в группе, reset при открытии чата. Суммарный бейдж — HVALS unread:{user_id} и суммирование на клиенте. При перезапуске Redis — пересчёт из PostgreSQL как fallback.
Роли и права
Схема: owner, admin, member. Права гранулярно: can_send_messages, can_add_members, can_remove_members, can_edit_group_info. Хранится в group_members.role + JSON-поле permissions для кастомных переопределений. Проверка на уровне API middleware до выполнения action.
Мобильный UI: что технически сложно
Список участников и упоминания
При вводе @ — popup с фильтрацией участников. На iOS: UITextView + кастомный UIView-overlay позиционированный над клавиатурой через KeyboardLayoutGuide. При выборе участника — вставка атрибутированной строки с NSAttributedString и кастомным NSTextAttachment или просто цветной range.
В Jetpack Compose: BasicTextField с кастомным VisualTransformation для окраски упоминаний + Popup с LazyColumn для выпадающего списка. Тригер @ — через TextFieldValue.text.lastIndexOf('@') с debounce 200ms.
На бэке при сохранении сообщения — парсинг упоминаний регуляркой, создание message_mentions[] записей, отдельный push-уведомление упомянутым участникам даже если они отключили уведомления группы.
Медиа и файлы в группе
Фото, видео, документы — загрузка через presigned S3 URL как в приватном чате, но с дополнительной проверкой квот (лимит хранилища на группу или на пользователя). Медиагалерея группы — отдельный экран с UICollectionView/LazyVerticalGrid, выборка из таблицы messages по type IN ('image','video') AND group_id = ? с пагинацией.
Превью ссылок (link preview): на сервере при получении сообщения с URL — асинхронный job (Sidekiq/Celery) парсит Open Graph метаданные, кэширует в Redis на 24h, клиент получает данные превью в событии message.updated.
Индикатор набора текста
WS-event typing.start / typing.stop от клиента → сервер рассылает в группу с user_id печатающего → клиенты показывают «Иван набирает...». Проблема: при 20 одновременно набирающих участниках UX ломается. Ограничение: показываем максимум 3 имени, далее «и ещё N человек набирают». Таймаут: если typing.stop не пришёл — автоматически скрываем через 5 секунд.
Офлайн и синхронизация
Групповой чат требует локальной БД. SQLite через SQLCipher (шифрование) — схема: groups, messages, group_members. При старте приложения — sync с сервером: запрос всех групп с last_synced_at, затем для каждой группы — сообщения после последнего message_id. Конфликты при одновременном редактировании — Last Write Wins по updated_at.
На iOS — GRDB.swift поверх SQLite, на Android — Room с Flow-подпиской для реактивного обновления UI.
Типичные ошибки при самостоятельной реализации
-
N+1 при загрузке списка групп: для каждой группы отдельный запрос
last_message. Решение: JOIN с подзапросом или денормализованное полеlast_message_preview. -
Push на все сообщения без учёта mute: участник заглушил группу, но получает пуш. Проверка
group_members.notifications_mutedна сервере перед отправкой FCM/APNs. - Удаление участника без cleanup: после кика пользователь технически получает WS-события если соединение не закрыто. Нужен принудительный disconnect через сигнал на WS-сервере.
- Отсутствие optimistic updates: сообщение появляется в UI только после ответа сервера. Правильно — показать сразу со статусом «sending», обновить/откатить при ответе.
Сроки и состав работ
Базовый групповой чат (создание групп, роли admin/member, сообщения с пагинацией, пуши) — 3-4 недели. Полная функциональность (медиа, упоминания, link preview, офлайн-синхронизация, квоты хранилища, галерея группы) — 2-3 месяца. Для Flutter-проекта — примерно на 30% быстрее за счёт единого UI-слоя.
Стоимость рассчитывается после детального ТЗ: состав платформ, объём групп (лимит участников), требования к шифрованию и офлайн-режиму существенно влияют на архитектурные решения.







