Разработка системы подписок на пользователей в мобильном приложении
Подписки — основа социального графа приложения. Технически это таблица follows (follower_id, followee_id, created_at) с UNIQUE-ограничением. Но детали в UI, производительности запросов и уведомлениях определяют, насколько система будет работать при росте аудитории.
Схема данных и запросы
Таблица follows:
CREATE TABLE follows (
follower_id BIGINT NOT NULL,
followee_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (follower_id, followee_id)
);
CREATE INDEX idx_follows_followee ON follows (followee_id);
Индекс на followee_id нужен для быстрого подсчёта подписчиков: SELECT COUNT(*) FROM follows WHERE followee_id = ?. Без него при 1M записях — full scan.
Денормализация: users.followers_count и users.following_count — обновляется триггером или через очередь. Счётчик в профиле берётся из денормализованного поля, не из COUNT.
Взаимная подписка (mutual follow): SELECT 1 FROM follows WHERE follower_id = $1 AND followee_id = $2 — проверяем оба направления. Можно кэшировать в Redis: SISMEMBER user:{id}:following {target_id}.
Кнопка Follow/Unfollow
Оптимистичное обновление обязательно — кнопка переключается мгновенно:
// iOS
func toggleFollow(userId: String, currentlyFollowing: Bool) {
let optimisticState = !currentlyFollowing
updateFollowButton(isFollowing: optimisticState)
let request = optimisticState ? apiService.follow(userId) : apiService.unfollow(userId)
request.sink(
receiveCompletion: { [weak self] completion in
if case .failure = completion {
self?.updateFollowButton(isFollowing: currentlyFollowing) // откат
}
},
receiveValue: { _ in }
).store(in: &cancellables)
}
На Compose — followState: Boolean в ViewModel, изменяется до запроса, откатывается при ошибке.
Три состояния кнопки: «Подписаться», «Подписан», «Отписаться» (последнее показывается при лонг-тапе или hover). Не показывайте кнопку «Отписаться» как основной текст — пользователи принимают за подтверждение подписки.
Закрытые аккаунты (Private accounts)
Если приложение поддерживает закрытые профили: follow_request вместо немедленной подписки. Новая таблица follow_requests (requester_id, target_id, status, created_at). Целевой пользователь видит входящие заявки, принимает/отклоняет. При принятии — запись перемещается в follows. Push-уведомление при новой заявке и при принятии.
Список подписчиков/подписок
Пагинация cursor-based по created_at DESC или по follower_id (для стабильного порядка). Для каждого пользователя в списке показываем isFollowedByMe — требует JOIN или batch-проверки. Batch-вариант: SELECT followee_id FROM follows WHERE follower_id = ? AND followee_id IN (?) — один запрос для всей видимой страницы.
На iOS — UITableView с UITableViewDiffableDataSource, каждая ячейка содержит аватар, имя, кнопку Follow с состоянием. Prefetch: UITableViewDataSourcePrefetching запрашивает следующую страницу за 3 ячейки до конца.
На Android — LazyColumn с Paging 3, RemoteMediator для network + local cache.
Уведомления при подписке
При новой подписке — push-уведомление автору профиля: «Иван подписался на вас». FCM/APNs через бэкенд. Deeplink — на профиль подписавшегося. Батчинг: если за минуту подписалось 5 человек — одно уведомление «5 новых подписчиков», не пять отдельных.
Рекомендации «Кого подписаться»
Простая эвристика: пользователи, на которых подписаны мои подписки, но на которых не подписан я («friends of friends»). SQL:
SELECT DISTINCT f2.followee_id
FROM follows f1
JOIN follows f2 ON f1.followee_id = f2.follower_id
WHERE f1.follower_id = :me
AND f2.followee_id != :me
AND NOT EXISTS (SELECT 1 FROM follows WHERE follower_id = :me AND followee_id = f2.followee_id)
LIMIT 20;
Для больших графов — предрасчёт через воркер, результат в Redis.
Сроки
Базовая система (follow/unfollow, счётчики, список) — 1-2 дня. С закрытыми аккаунтами, уведомлениями, рекомендациями — 3-5 дней. Стоимость рассчитывается индивидуально.







