Разработка ленты подписок (Following Feed) в мобильном приложении
Лента подписок — технически самый сложный компонент социального приложения. Не потому что сложно написать SELECT posts WHERE author_id IN (following_ids) ORDER BY created_at DESC — это работает до ~10K пользователей. Проблема начинается когда нужно держать ленту актуальной в реальном времени, обрабатывать «знаменитостей» с миллионом подписчиков в feed, и отдавать первый экран быстро.
Fan-out vs Fan-in: выбор архитектуры
Два классических подхода к формированию ленты:
| Fan-out on write (push) | Fan-in on read (pull) | |
|---|---|---|
| Принцип | При публикации пишем в ленты всех подписчиков | При запросе ленты собираем посты из подписок |
| Плюсы | Чтение быстрое (готовая лента в Redis) | Нет дублирования данных, проще для «звёзд» |
| Минусы | Запись дорогая для популярных авторов | Чтение медленнее, сложнее ранжирование |
| Когда | До ~100K подписчиков у автора | Авторы с миллионами фолловеров |
Большинство приложений используют гибрид: fan-out для обычных пользователей, fan-in для «звёзд» (>50K подписчиков). Порог настраивается.
Для MVP — fan-in достаточно:
SELECT p.*, u.name, u.avatar_url
FROM posts p
JOIN follows f ON p.author_id = f.followee_id
JOIN users u ON p.author_id = u.id
WHERE f.follower_id = :user_id
AND p.created_at < :cursor
ORDER BY p.created_at DESC
LIMIT 20;
Индексы: follows(follower_id), posts(author_id, created_at DESC).
Realtime-обновления
Три варианта:
Pull to refresh — пользователь тянет вниз, запрашиваем посты новее firstPost.created_at. Самый простой вариант, работает везде.
WebSocket/SSE — сервер пушит новые посты клиенту. При получении показываем баннер «N новых постов» вверху ленты (как Twitter). Клиент не вставляет их автоматически — только по тапу на баннер, иначе лента прыгает под пальцем.
Long polling — компромисс без WebSocket.
На iOS WebSocket — URLSessionWebSocketTask. На Android — OkHttp WebSocket. На Flutter — web_socket_channel.
Пагинация и cursor
Обязательно cursor-based, не OFFSET:
-
cursor=created_atпоследнего поста на текущей странице (ISO 8601 string) - Запрос:
GET /feed?cursor=2024-11-15T10:30:00Z&limit=20 - Ответ:
{ items: [...], next_cursor: "...", has_more: true }
При OFFSET на 100-й странице база вычитывает 2000 строк только чтобы пропустить. При большом числе подписок и постов — секунды ожидания.
Кэширование на клиенте
iOS — сохраняем первые 50-100 постов ленты в CoreData или Realm. При открытии приложения — мгновенно показываем кэш, одновременно запрашиваем новые посты. Когда новые посты пришли — тихо вставляем их в начало (или показываем баннер). NSFetchedResultsController + NSDiffableDataSourceSnapshot для плавного обновления без мерцания.
Android — Room + Paging 3 с RemoteMediator. Локальная база — источник истины, RemoteMediator подгружает данные из сети в Room, Paging 3 рендерит из Room.
Flutter — Hive или Isar для локального кэша, flutter_bloc для управления состоянием страниц.
Алгоритмическая лента
Хронологическая лента — базис. Если нужна алгоритмическая (ранжирование по engagment): хранить score за каждый пост, пересчитывать через воркер (BullMQ/Celery) при добавлении лайков/комментариев. Клиент запрашивает ленту с параметром sort=ranked. Для первого запуска — хронологическая, после набора данных — переключение на алгоритмическую. Обе ленты как отдельные вкладки (Reels vs Following у Instagram).
Скролл и производительность
UICollectionView с UICollectionViewCompositionalLayout и DiffableDataSource — золотой стандарт на iOS. Prefetch данных через UICollectionViewDataSourcePrefetching. Изображения — Kingfisher с кэшированием в памяти и на диске.
На Android LazyColumn (Compose) или RecyclerView с ConcatAdapter. Изображения — Coil с rememberAsyncImagePainter.
Главная причина дёрганой прокрутки — декодировка изображений на main thread. Kingfisher и Coil делают это в background по умолчанию. При кастомной загрузке изображений — DispatchQueue.global(qos: .userInitiated).async (iOS) или Dispatchers.IO (Android).
Этапы работы
Выбор архитектуры ленты (fan-in/fan-out/гибрид) под ожидаемую нагрузку → API с cursor-пагинацией → UI ленты с кэшем → realtime-обновления → нагрузочное тестирование (k6) на сценарий «1000 запросов ленты одновременно».
Сроки
Базовая лента с pull-to-refresh и пагинацией — 2-3 дня. С realtime WebSocket, кэшем, алгоритмическим ранжированием — 7-10 дней. Стоимость рассчитывается индивидуально.







