Урок 18 из 25 · Месяц 6. Чат и публикация приложений
Чат на Firebase Realtime Database, Stream и сокеты, часть 1
Содержание урока
Чат — одна из самых популярных задач на собеседованиях и в реальных проектах: в нём сразу видно, умеет ли разработчик работать с данными, которые меняются «вживую». В этой части разберёмся, что такое Stream и как его показывать через StreamBuilder, подключим Firebase Realtime Database, спроектируем структуру данных чата и научимся отправлять и читать сообщения.
Что такое Stream
Вы уже знаете Future — это одно значение, которое придёт в будущем: ответ сервера, прочитанный файл. Stream — это поток значений, которые приходят по очереди, сколько угодно раз.
Бытовая аналогия: Future — это посылка, которую вы ждёте один раз. Stream — это подписка на журнал: номера приходят каждую неделю, пока вы не отпишетесь.
Future<T> |
Stream<T> |
|
|---|---|---|
| Сколько значений | Одно (или ошибка) | Много, по очереди |
| Как получить | await future |
await for или .listen() |
| Виджет | FutureBuilder |
StreamBuilder |
Создаём свой Stream
Самый простой способ — функция с async*. Звёздочка означает «эта функция возвращает поток», а yield — «отдай очередное значение»:
Stream<int> countdown(int from) async* {
for (var i = from; i >= 0; i--) {
await Future.delayed(const Duration(seconds: 1));
yield i; // отдаём значение подписчику
}
}
Future<void> main() async {
await for (final value in countdown(3)) {
print(value); // 3, 2, 1, 0 — по одному в секунду
}
print('Готово');
}
async*— функция-генератор потока.yield i— отправляет число в поток и продолжает работу.await for— цикл, который ждёт каждое новое значение. Закончится, когда поток закроется.
StreamController и подписка
Когда значения приходят «снаружи» (нажатия, сообщения из сети), используют StreamController — это «труба»: в один конец кладём данные (sink/add), из другого читаем (stream).
import 'dart:async';
void main() {
final controller = StreamController<String>();
final StreamSubscription<String> sub = controller.stream.listen(
(text) => print('Новое сообщение: $text'),
onError: (Object e) => print('Ошибка: $e'),
);
controller.add('Привет');
controller.addError(Exception('Нет сети'));
sub.cancel();
controller.close();
}
listen возвращает StreamSubscription — «квитанцию о подписке». Через неё можно поставить поток на паузу (pause), продолжить (resume) и, главное, отписаться (cancel).
Обычный поток можно слушать только одному подписчику; если слушателей несколько, создают StreamController.broadcast(). Потоки Firebase можно слушать сколько угодно раз — каждый listen создаёт свою подписку.
StreamBuilder: показываем поток на экране
StreamBuilder подписывается на поток, перерисовывает виджет при каждом новом значении и сам отписывается, когда виджет удаляется из дерева.
StreamBuilder<int>(
stream: countdownStream, // поток создаём заранее, не в build!
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text('Ошибка: ${snapshot.error}');
}
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
return Text('Осталось: ${snapshot.data}');
},
)
snapshot — это «снимок» потока в текущий момент:
| Поле | Что значит |
|---|---|
connectionState |
waiting — ждём первое значение, active — данные идут, done — поток закрыт |
hasData / data |
Есть ли последнее значение и само значение |
hasError / error |
Пришла ли ошибка |
Подключаем Firebase Realtime Database
Realtime Database (RTDB) — это облачная база данных Firebase, которая хранит всё как одно большое JSON-дерево и мгновенно присылает изменения всем подключённым клиентам. Свой сервер не нужен.
Firebase-проект у вас уже подключён после урока Firebase: подключение и авторизация. Осталось:
- В консоли Firebase откройте Build → Realtime Database → Create Database, выберите регион и режим locked mode (правила напишем сами во второй части).
- Добавьте пакет и обновите конфигурацию, чтобы в
firebase_options.dartпоявилсяdatabaseURL:
flutter pub add firebase_database
flutterfire configure
Инициализация Firebase.initializeApp(...) в main остаётся прежней.
Структура данных чата
В RTDB всё — одно JSON-дерево. Главное правило: держите дерево плоским. Когда вы читаете узел, Firebase скачивает его целиком со всеми вложенными данными. Если положить сообщения внутрь чата, то для списка чатов придётся скачать все сообщения всех чатов.
Поэтому сообщения не кладут внутрь чата, а выносят в отдельную ветку:
{
"users": {
"uid_aibek": { "name": "Айбек", "online": true, "lastSeen": 1760000000000 }
},
"chats": {
"chat_1": {
"members": { "uid_aibek": true, "uid_mira": true },
"lastMessage": "Привет",
"updatedAt": 1760000000000
}
},
"messages": {
"chat_1": {
"-NxA1b2": {
"senderId": "uid_aibek",
"text": "Привет",
"createdAt": 1760000000000
}
}
},
"userChats": {
"uid_aibek": { "chat_1": true },
"uid_mira": { "chat_1": true }
}
}
users— профили и онлайн-статус.chats— «шапка» чата: участники и последнее сообщение для списка.messages/{chatId}— сами сообщения, читаем только когда открыли чат.userChats/{uid}— какие чаты есть у пользователя. Это денормализация — сознательное дублирование данных, чтобы быстро читать. В NoSQL это нормально.
Для личного чата удобно делать предсказуемый chatId из двух uid — тогда двое пользователей всегда попадут в один и тот же чат:
String buildChatId(String uid1, String uid2) {
final ids = [uid1, uid2]..sort(); // сортируем, чтобы порядок не влиял
return '${ids[0]}_${ids[1]}';
}
Модель сообщения
Данные из RTDB приходят как Map<Object?, Object?>, поэтому модель создаётся из такой Map:
import 'package:equatable/equatable.dart';
class Message extends Equatable {
const Message({
required this.id,
required this.senderId,
required this.text,
required this.createdAt,
});
final String id;
final String senderId;
final String text;
final DateTime createdAt;
factory Message.fromMap(String id, Map<Object?, Object?> map) {
return Message(
id: id,
senderId: map['senderId'] as String? ?? '',
text: map['text'] as String? ?? '',
createdAt: DateTime.fromMillisecondsSinceEpoch(
(map['createdAt'] as num?)?.toInt() ?? 0,
),
);
}
bool isMine(String myUid) => senderId == myUid;
@override
List<Object?> get props => [id, senderId, text, createdAt];
}
Разбор:
idне хранится внутри сообщения — это ключ узла (-NxA1b2), поэтому передаём его отдельно.as String? ?? ''— защита от отсутствующих полей.createdAtхранится как число миллисекунд (так Firebase хранит время), а в модели — удобныйDateTime.Equatable— чтобы Bloc иStreamBuilderмогли сравнивать сообщения (вы знаете его по уроку про модели и JSON).
Отправка сообщения
Ссылка на узел базы — DatabaseReference. Метод push() создаёт дочерний узел с уникальным ключом, который ещё и сортируется по времени создания:
import 'package:firebase_database/firebase_database.dart';
final _db = FirebaseDatabase.instance;
Future<void> sendMessage({
required String chatId,
required String senderId,
required String text,
}) async {
final trimmed = text.trim();
if (trimmed.isEmpty) return; // пустые сообщения не шлём
final newRef = _db.ref('messages/$chatId').push();
await newRef.set({
'senderId': senderId,
'text': trimmed,
'createdAt': ServerValue.timestamp,
});
}
_db.ref('messages/$chatId')— путь в дереве, как адрес папки.push()— генерирует ключ локально,set(...)— записывает данные в узел.ServerValue.timestamp— специальный маркер: сервер сам подставит своё текущее время. Часы на телефонах пользователей могут отличаться, и если братьDateTime.now(), сообщения перемешаются.
Чтение сообщений в реальном времени
У DatabaseReference и запросов (Query) есть потоки. Главные: onValue — сразу и при любом изменении присылает весь узел; onChildAdded, onChildChanged, onChildRemoved — сообщают о каждом добавленном, изменённом или удалённом дочернем узле. Для начала возьмём onValue — он проще: всегда отдаёт актуальный список целиком.
Stream<List<Message>> watchMessages(String chatId) {
final query = FirebaseDatabase.instance
.ref('messages/$chatId')
.orderByChild('createdAt')
.limitToLast(50);
return query.onValue.map((DatabaseEvent event) {
return event.snapshot.children.map((child) {
return Message.fromMap(
child.key!,
child.value as Map<Object?, Object?>,
);
}).toList();
});
}
orderByChild('createdAt')— сортируем по времени (по возрастанию).limitToLast(50)— берём только 50 последних, а не всю историю.onValue.map(...)— превращаем «сырое» событие базы вList<Message>.snapshot.children— дочерние узлы в порядке запроса. Не используйте(snapshot.value as Map).values— уMapпорядок не гарантирован.
Собираем экран чата
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key, required this.chatId, required this.myUid});
final String chatId;
final String myUid;
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
late final Stream<List<Message>> _messages;
final _controller = TextEditingController();
@override
void initState() {
super.initState();
_messages = watchMessages(widget.chatId); // поток создаём один раз
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _send() async {
final text = _controller.text;
_controller.clear();
await sendMessage(chatId: widget.chatId, senderId: widget.myUid, text: text);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Чат')),
body: Column(
children: [
Expanded(
child: StreamBuilder<List<Message>>(
stream: _messages,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(child: Text('Ошибка: ${snapshot.error}'));
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final messages = snapshot.data!.reversed.toList();
return ListView.builder(
reverse: true, // новые сообщения снизу
itemCount: messages.length,
itemBuilder: (context, i) {
final m = messages[i];
final mine = m.isMine(widget.myUid);
return Align( // свои — справа, чужие — слева
alignment: mine ? Alignment.centerRight : Alignment.centerLeft,
child: Card(
color: mine ? Colors.blue.shade100 : null,
child: Padding(
padding: const EdgeInsets.all(10),
child: Text(m.text),
),
),
);
},
);
},
),
),
SafeArea(
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(hintText: 'Сообщение'),
onSubmitted: (_) => _send(),
),
),
IconButton(icon: const Icon(Icons.send), onPressed: _send),
],
),
),
],
),
);
}
}
Ключевые моменты:
- Поток создаётся в
initState, а не вbuild. ListViewсreverse: true«прилипает» к низу, как в любом мессенджере, поэтому список тоже переворачиваем: самое новое сообщение — первым элементом.- Поле очищаем до отправки: Firebase сначала показывает запись локально, поэтому сообщение появится мгновенно даже при медленной сети.
Типичные ошибки
- Permission denied при чтении/записи — база создана в locked mode, а правила ещё не настроены. Для первой проверки можно временно разрешить доступ только авторизованным (
"auth != null"), подробно — во второй части. databaseURLне найден — забыли заново выполнитьflutterfire configureпосле создания базы.- Экран мигает загрузкой — поток создаётся внутри
build. - Вложенная структура — сообщения внутри
chats, и список чатов грузится долго.
Практика
- Напишите функцию
Stream<String> clock()наasync*, которая каждую секунду отдаёт текущее время в форматеHH:mm:ss, и покажите её черезStreamBuilder. Ожидаемый результат: часы на экране тикают. - Создайте в консоли Firebase базу, вручную добавьте узел
messages/test_chatс двумя сообщениями и выведите их на экран черезwatchMessages. Ожидаемый результат: измените текст в консоли — он сразу поменяется в приложении. - Соберите
ChatScreenи откройте его на двух эмуляторах (или эмуляторе и телефоне) с разными пользователями. Ожидаемый результат: сообщение с одного устройства появляется на другом за секунду. - Под каждым сообщением выведите время отправки в формате
14:05. Ожидаемый результат: время берётся изcreatedAt, а не изDateTime.now().
Итоги
Future— одно значение,Stream— поток значений; для чата нуженStream.- От потока важно отписываться;
StreamBuilderделает это сам, но поток для него создают один раз. - Realtime Database — JSON-дерево, которое мгновенно синхронизируется между клиентами.
- Структура чата должна быть плоской:
users,chats,messages/{chatId},userChats/{uid}. push()+set()отправляют сообщение,ServerValue.timestampставит единое серверное время.orderByChild+limitToLast+onValueдают живой список последних сообщений.