Дельта android-live-albums: android-client (+3 требования: живое обновление списка, догон при переподключении, потеря доступа) и realtime (порядок списка теперь правило отображения клиента). Дельта fix-album-cover-readiness: realtime (+1 требование: album.updated при готовности вариантов фото-обложки).
11 KiB
Design
Context
См. proposal.md — мотивация. Состояние и ограничения, определяющие подход:
- Android подписан на SSE только пер-альбомно:
AlbumSseClientвызывается изAlbumViewModelи живёт ровно столько, сколько живёт экран альбома. ПарсерAlbumEventParserсворачиваетalbum.updatedиalbum.deletedв одинAlbumChanged, а неизвестные типы возвращает какnull(тихо игнорирует). - Список альбомов — offline-first: UI наблюдает Room, а
AlbumRepository.refresh()заменяет кэш серверным снимком черезreplaceAll. AlbumDtoуже несёт форму элементаGET /api/albums(cover_thumb_url,cover_medium_url,photos_count,members_count), иAlbumEntityхранит те же поля, поэтому payload события ложится в кэш без сборки объекта вручную.- В Room между
photosиalbumsнетForeignKey/CASCADE: удаление строки альбома не уносит его фото. TokenStoreне наблюдаем: «залогинен» проверяется императивно,logout()чистит токен и запускает навигацию. Наблюдаемого сигнала сессии, на который можно подписать стрим, нет.AppContainerживёт с процессом и запускает фоновые вещи вstart()(прецедент —DeviceMediaObserver), но у него нет крючка «сессия началась/закончилась».- SSE-клиент Android авторизуется заголовком
Authorizationчерез интерцептор, поэтому query-параметрtoken, нужный вебу, здесь не требуется. - Бэкенд уже всё публикует: удаление альбома владельцем уходит и в канал альбома, и в
личные потоки; исключение участника доходит до исключённого только личным потоком
(в канал альбома уходит
member.left, который клиент не использует).
Goals / Non-Goals
Goals:
- Обновлять список альбомов из персонального потока без запроса списка на каждое событие.
- Закрыть потерю доступа одним путём, покрывающим и удаление владельцем, и исключение.
- Не терять изменения, пропущенные за время разрыва соединения.
Non-Goals:
- Центр уведомлений и приглашения (в Android их нет;
notification— игнорируем). - Серверный обрыв пер-альбомного SSE при исключении участника — существующее ограничение.
- Изменение пер-альбомного контракта событий и REST.
- Работа подписки в фоне и обход Doze.
- Сортировка списка — отдельный change (
android-album-sort).
Decisions
1. Дом подписки — ViewModel главного экрана, клиент — в AppContainer.
AlbumsViewModel создаётся на стартовой записи навигации после входа, переживает уход на
экран альбома/профиля (запись остаётся в back stack) и уничтожается на выходе из аккаунта
(popUpTo ... inclusive). Это функциональный аналог вебовой подписки на уровне layout'а:
жизненный цикл сессии даёт навигация, а не процессный scope.
Альтернативы: (а) AppContainer.scope — как веб, но нет наблюдаемой сессии и подписка
живёт в фоне; (б) подписка на каждом экране — соединения дублируются, список всё равно
остаётся мёртвым. Сам клиент кладём в AppContainer рядом с albumSseClient — как
зависимость, чтобы ViewModel оставался тонким.
2. События применяются в Room, а не в UI-состояние.
Обработчик вызывает AlbumRepository (upsert/удаление), поэтому и список, и открытый
экран альбома получают изменение сами, без дополнительных запросов.
Альтернатива — править UiState списка напрямую: тогда открытый экран альбома не узнаёт
ничего, и логика расходится с offline-first моделью.
3. Потеря доступа — через переход состояния кэша, один путь на два триггера.
album.deleted (удаление владельцем или исключение) удаляет альбом из Room; экран альбома
реагирует на переход «альбом был ненулевым и стал null». Флаг «уже видел» отличает это
от честного холодного старта без кэша и без сети.
Альтернатива — явные события (AlbumDeleted из пер-альбомного парсера и album.deleted
из личного) с прямым onBack(): два пути, дублирование и потребность знать собственный
user_id для разбора member.left (сейчас экран знает только роль).
4. Удаление альбома уносит и его фото.
Так как FK с каскадом нет, удаление в репозитории должно чистить и photos этого альбома.
Иначе сетка удалённого альбома остаётся живой из кэша.
5. Отдельный клиент и парсер для личного потока.
Личный поток несёт album.created, album.updated, album.deleted и notification.
notification игнорируется явно: у него собственный type внутри payload'а
(member.joined, like.added), который нельзя путать с типом SSE-события.
Альтернатива — обобщить AlbumSseClient параметром запроса и парсера: пока переиспользования
мало, обобщение усложняет существующий рабочий путь.
6. Догон при реконнекте — существующий AlbumRepository.refresh().
refresh() делает replaceAll серверным снимком, поэтому заодно уносит альбомы,
удалённые за время разрыва. Отдельный механизм догона не нужен.
7. Пер-альбомную обработку не трогаем.
Открытый экран получает album.updated/album.deleted и из своего канала, и косвенно из
личного. Обработка идемпотентна (upsert/удаление), дублирующая работа невелика, а
трогать работающий путь ради экономии одного запроса не стоит.
8. Unit-тесты гоняются только на debug-варианте.
Compose-тестовая активность (androidx.activity.ComponentActivity) объявляется
ui-test-manifest, который подключён как debug-зависимость, поэтому release-манифест её
не содержит и Robolectric не может запустить createComposeRule. Источники тестов
вариант-агностичны, поэтому прогон один раз на debug покрывает то же самое, а тестовая
активность не попадает в production-манифест. Альтернатива — подключить
ui-test-manifest как implementation — отвергнута: она добавляет тестовую активность
в release-APK.
Risks / Trade-offs
- Отмена коллектора может не отменить
EventSource(closed.await()бросает доsource.cancel()) → утечка соединения и цикл реконнектов на удалённом альбоме → покрыть тестом и, если подтвердится, завершать соединение вfinally/invokeOnClose. sseClientсобран безTokenAuthenticator→ на 401 посреди длинной сессии клиент не обновит токен, а переоткроется с прежним → покрыть сценарием истечения токена.- Порядок списка меняется от живых событий (альбом упорядочены по времени изменения)
→ принято осознанно; средство управления порядком приходит в
android-album-sort. - Исключённый участник продолжает получать пер-альбомный SSE до разрыва соединения → существующее ограничение, не закрывается здесь.
- Фон и Doze → подписка не переживает вытеснение процесса; реконнект и догон восстанавливают состояние при возвращении.
Migration Plan
Изменение аддитивное: схемы Room, эндпоинтов и контрактов не меняем. Выкатывается обычным обновлением приложения; откат — предыдущая версия APK, при которой список снова обновляется только по REST-снимку. Данные на сервере и в кэше не мигрируются.
Open Questions
- Стоит ли позже перевести пер-альбомный
album.updatedна применение payload'а в Room вместо REST-перезагрузки, чтобы убрать дублирующий запрос? На поведение и разбивку задач не влияет.