Интеграция Firebase Firestore в мобильное приложение
Firestore — документно-ориентированная БД с real-time слушателями, офлайн-кэшем и гибкими запросами. В отличие от RTDB, поддерживает составные индексы, where() по нескольким полям, orderBy() с пагинацией. Но у неё свои подводные камни: onSnapshot без limit() на растущей коллекции постепенно увеличивает объём данных на каждый тик, пока не замедлит рендеринг.
Структура данных: коллекции и подколлекции
Базовая структура для социального приложения:
users/{userId}
├── displayName: string
├── photoURL: string
└── posts/{postId} ← подколлекция
├── text: string
├── createdAt: Timestamp
└── likes: number
Подколлекции — правильно для данных, количество которых не ограничено. Вложенные массивы в документ — неправильно для коллекций > 50 элементов: Firestore ограничивает размер документа 1 МБ, и весь документ читается даже если нужно одно поле.
Подписки и пагинация
import firestore from '@react-native-firebase/firestore';
// Реальное время + пагинация
const [posts, setPosts] = useState<Post[]>([]);
const [lastDoc, setLastDoc] = useState<FirebaseFirestoreTypes.DocumentSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const fetchPage = useCallback(async () => {
if (loading) return;
setLoading(true);
let query = firestore()
.collection(`users/${userId}/posts`)
.orderBy('createdAt', 'desc')
.limit(20);
if (lastDoc) query = query.startAfter(lastDoc);
const snapshot = await query.get();
const newPosts = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as Post));
setPosts(prev => [...prev, ...newPosts]);
setLastDoc(snapshot.docs[snapshot.docs.length - 1] ?? null);
setLoading(false);
}, [lastDoc, loading, userId]);
// Real-time: только для верхней части ленты (без пагинации)
useEffect(() => {
const unsubscribe = firestore()
.collection(`users/${userId}/posts`)
.orderBy('createdAt', 'desc')
.limit(10)
.onSnapshot(snapshot => {
const fresh = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as Post));
setPosts(prev => {
// Объединяем новые real-time данные с пагинированными
const ids = new Set(fresh.map(p => p.id));
return [...fresh, ...prev.filter(p => !ids.has(p.id))];
});
});
return () => unsubscribe();
}, [userId]);
Разделяем real-time (первая страница) и load more (пагинация через get()). onSnapshot на весь список с пагинацией — антипаттерн: каждое изменение в коллекции вернёт полный новый снапшот первых N документов.
Офлайн-кэш
// Включить до любого обращения к Firestore
await firestore().settings({
cacheSizeBytes: firestore.CACHE_SIZE_UNLIMITED, // или конкретный размер в байтах
persistence: true, // включено по умолчанию на мобильных
});
При офлайне onSnapshot продолжает работать — возвращает данные из кэша с snapshot.metadata.fromCache === true. Записи буферизуются и отправляются при восстановлении сети.
Для явного чтения из кэша без сетевого запроса:
const snapshot = await firestore()
.collection('posts')
.doc(postId)
.get({ source: 'cache' }); // 'cache' | 'server' | 'default'
Составные индексы
Запрос с where + orderBy по разным полям требует составного индекса. Firestore автоматически предлагает его создать при первой ошибке в development (линк в консоль). В продакшне — настраивайте индексы в firestore.indexes.json заранее:
{
"indexes": [
{
"collectionGroup": "posts",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "userId", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
}
]
}
Без индекса Firestore вернёт FAILED_PRECONDITION ошибку. Время создания индекса — от нескольких минут до часов на больших коллекциях.
Правила безопасности Firestore
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId}/posts/{postId} {
allow read: if request.auth != null;
allow write: if request.auth.uid == userId
&& request.resource.data.keys().hasAll(['text', 'createdAt'])
&& request.resource.data.text is string
&& request.resource.data.text.size() <= 2000;
}
}
}
Валидируйте типы и размеры в правилах — не только в клиентском коде.
Транзакции и batch writes
// Транзакция: атомарный перевод лайка
await firestore().runTransaction(async transaction => {
const postRef = firestore().doc(`posts/${postId}`);
const userLikeRef = firestore().doc(`userLikes/${userId}_${postId}`);
const [postSnap, likeSnap] = await Promise.all([
transaction.get(postRef),
transaction.get(userLikeRef),
]);
if (likeSnap.exists()) {
transaction.delete(userLikeRef);
transaction.update(postRef, { likes: firestore.FieldValue.increment(-1) });
} else {
transaction.set(userLikeRef, { userId, postId, createdAt: firestore.FieldValue.serverTimestamp() });
transaction.update(postRef, { likes: firestore.FieldValue.increment(1) });
}
});
FieldValue.increment() — атомарный инкремент без read-modify-write гонки. Без транзакции при одновременных лайках счётчик будет некорректным.
Оценка
Firestore с офлайн-персистентностью, real-time подписками, пагинацией и правилами безопасности: 2–4 недели в зависимости от сложности структуры данных.







