Привет всем! Хочу поделится одной интересной проблемой (и ее решением), с которой пришлось столкнуться при использовании jetpack compose.

Я пишу с нуля программу аренды велосипедов. Соответственно могу использовать современные frameworks и стараться сделать все по феншую :)

Для UI я выбрал jetpack compose и использую MVI для взаимодействия между UI и ViewModel (точнее упрощенную версию MVI, которую @D7ILeucoH более правильно назвал "MVVM со стейтами").

Во ViewModel, отвечающую за взаимодействие с Yandex Map, приходят разные события: если клиент двигает карту, с сервера запрашивается информация о находящихся в этом месте велосипедах, 2-х видах парковок и медленных зонах. Кроме того периодически с сервера запрашивается информация об активной аренде клиентом велосипеда. Все эти данные асинхронно передаются на UI.

// это идет c ViewModel на UI
sealed class MapUiState {
    data class Bikes(val bikes: List<Bike>) : MapUiState()
    data class Parkings(val stations: List<Parking>, val parkings: List<Parking>) : MapUiState()
    data class SlowZones(val slowZones: List<SlowZoneObject>, val showMarkers: Boolean) : MapUiState()
    data class ActiveRent(val rent: Rent?, val show: Boolean) : MapUiState()
    …
}

// а это c UI в ViewModel 
sealed class MapIntent {
    data class ChangeMapPosition(val mapRect: MapRect, val zoom: Float) : MapIntent()
    …
}

Вроде все хорошо работает, не лагает, jetpack compose достаточно шустрый.

Но я заметил, что иногда некоторая информация не отображается. Например, я вижу по логу, что все необходимые данные получены, но велосипеды почему-то не отобразились. Потом какое-то время все нормально, потом опять что-то не отобразилось. При этом в логе все нормально, никаких ошибок нет.

Достаточно долго пришлось чесать репу и перечитывать документацию чтобы в конце концов наткнутся на то, что было написано на самом видном месте:

“Recomposition is optimistic, which means Compose expects to finish recomposition before the parameters change again. If a parameter does change before recomposition finishes, Compose might cancel the recomposition and restart it with the new parameter.”

Итак если Compose не успел отрисовать всё до следующего изменения, он «с оптимизмом» отбрасывает старое и берется за новое. Признаться, я немного обалдел от такого оптимизма.

Отрисовка в Yandex Map не быстрая, объектов может быть очень много, если есть проблемы на относительно шустром телефоне, понятно что на старом тормозном ведре их будет гораздо больше.

Вроде как решение очевидно, надо не посылать новые события пока не отрисовались старые. Но это значит, что надо добавить новые intents от UI в ViewModel о том, что отрисовка завершена, в ViewModel хранить полученные но не отправленные на UI данные, в общем много лишнего геморроя и система становится менее понятной и более нагруженной. От этого варианта я отказался.

Следующее что приходит в голову – объединить получаемые данные и посылать их одним событием на отрисовку. Для начала я объединил отрисовку велосипедов, парковок и медленных зон. Т.е. только когда собраны все необходимые данные посылается одно событие на отрисовку.

sealed class MapUiState {
    data class MapContent(val bikes: List<Bike>?, val stations: List<Parking>?, val parkings: List<Parking>?, val slowZones: List<SlowZoneObject>?, val showMarkers: Boolean) : MapUiState()
    …
}

Но дальше это надо было еще объединить с данными аренды велосипеда и получилось что-то монстроидальное. И кроме того если пользователь шустро скролит экран, то тяжелая отрисовка все равно не успевает.

Решение пришло неожиданно: надо просто отправлять новые данные от ViewModel на UI с небольшой задержкой, гарантирующей, что предыдущие данные уже отрисовались. Для этого решил использовать SharedFlow.

В ViewModel добавил следующее:

private val mapUiStates: MutableSharedFlow<MapUiState> = MutableSharedFlow(replay = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private lateinit var mapUiStatesJob: Job
private val _mapUiState: MutableStateFlow<MapUiState> = MutableStateFlow(MapUiState.Normal)
val mapUiState: StateFlow<MapUiState> = _mapUiState.asStateFlow()

private fun initStates() {
    mapUiStatesJob = mapUiStates.onEach {
        delay(CHANGE_STATE_DELAY)
        _mapUiState.value = it
    }.launchIn(viewModelScope)
}

private fun closeStates() {
    mapUiStatesJob.cancel()
}

Дальше кидаем события в SharedFlow, которая с задержкой тригерит StateFlow, которая в свою очередь “обзервится” на UI:

ViewModel:

private fun changeState(uiState: MapUiState …) {
	…
    mapUiStates.tryEmit(uiState)
}

UI:

val mapUiState by mapViewModel.mapUiState.collectAsStateWithLifecycle()

Поигрался с задержкой – на моем телефоне все стабильно отрисовывается при задержке в 50 мс., что для данной программы вполне допустимо. Очередь тоже большая не набирается, так что решение для меня подошло.

Надеюсь еще кому-то это сможет пригодится или наведет на мысли о причинах возможных проблем с отрисовкой в jetpack compose и путях их решения.

Комментарии (18)


  1. princeparadoxes
    13.06.2024 11:19
    +3

    А не лучше ли будет использовать debounce вместо delay? С debounce отбросятся ненужные события и сразу будет нарисован нужный (последний) вариант. Это уменьшит общее количество отрисовок.

    Более того, debounce можно повесить на события с UI на ViewModel, которые сообщают о смене позиции камеры, что уберёт часть запросов на сервер.

    P.S. Compose рассматривает List (а они основа вашего MapUiState), как Unstable. Это приводит к лишним рекомпозициям, так как Compose не может понять, изменилось ли что-то в списке или нет и принудительно рекомпозирует. Попробуйте использовать неизменяемые коллекции.


    1. yvertkin Автор
      13.06.2024 11:19
      +1

      debounce вместо delay я как раз не могу использовать, т.к. в очереди стоят разные MapUiState с данными по велосипедам, парковкам, станциям, медленным зонам, арендам и т.д. С debounce я потеряю всё :)

      Вешать debounce на события с UI на ViewModel мне тоже не нужно, т.к. в Yandex Map SDK CameraListener уже имеет параметр “finished” и событие посылаются на ViewModel не на каждое движение пальца пользователя, а когда он немного притормаживает “неистовый” скролинг.

      И да, одинаковые события (например несколько данных по велосипедам) в очереди не появляются, для этого есть другой механизм.

      Большое спасибо за неизменяемые коллекции, проапдейтился на них.


  1. Neikist
    13.06.2024 11:19
    +2

    В чем проблема несколько flow с данными разных типов собрать через combine в одну модель для UI? Передавать данные во вью для отрисовки кусками разных типов - оно в целом не очень как то идее декларативного UI фреймворка соответствует.

    Ну и да, для событий перемещения как выше написали debounce неплохо иметь.


    1. yvertkin Автор
      13.06.2024 11:19

      Для обновления UI я запрашиваю с сервера разные данные (велосипеды, парковки, станции, медленные зоны). Кроме этого раз в 10 сек. я запрашиваю данные об аренде. Все ответы приходят в разное время (а иногда приходит timeout exception). Если ждать получения всех данных и объединять их, пользователь может увидеть обновление экрана через n секунд, что недопустимо.

      Про debounce выше ответил.


      1. Neikist
        13.06.2024 11:19

        И это ничего не меняет. Не надо ждать получения всех данных. Никто не мешает по умолчанию пока реальные данные не пришли пустые отправлять, или из кэша какого нибудь. А после перемещения камеры - ранее полученные для другого положения (если надо отсеченные, без лишних), либо опять же пустые.


  1. kirich1409
    13.06.2024 11:19
    +2

    Решение на основе задержек - очень нестабильное и без гарантий для устройства. Сегодня она быстрая, а завтра там пользователь еще что запустит в фоне и все, уже 50 мс не хватит. Из-за таких задержек флагманы не могут показать своих преимуществ и потом говорят что устрйоства плохие, а не приложения


    1. yvertkin Автор
      13.06.2024 11:19

      Согласен, что в общем плане задержка не самое лучшее решение. Но как я написал в статье, проблема неотрисовки каких-то данных даже без задержки встречается довольно редко, поэтому я очень надеюсь, что флагманы не будут на меня в обиде за такое решение ;)


  1. Nek_12
    13.06.2024 11:19
    +1

    И проблема не в оптимистичной рекомпозиции. Композиция отменит предыдущую отрисовку и запустит новую. Кейса когда что-то "не отрисовалось" тут быть не может, если состояние не используется неправильно. А судя по начальному силду где каждый кусок карты это отдельный Стейт - оно так и есть.


    1. yvertkin Автор
      13.06.2024 11:19

      Прошу прощения, м.б. не совсем понял комментарий по поводу неправильного использования состояния. Выше уже ответил почему не могу отрисовывать все вместе за раз.


  1. D7ILeucoH
    13.06.2024 11:19
    +2

    Так это не MVI, это MVVM) Где тут Actor, Producer, Reducer? Я не люблю этот подход как раз из-за лишней сложности, и использую похожий на твой подход. У нас его называют "MVVM со стейтами". Можешь тоже так написать, чтобы нести идею в массы.

    Насчёт debounce правильно подсказали, но есть ещё более крутой метод, который, судя по доке, считается не очень стабильным, но, на моей практике, он отлично работает. Это sample. Метод не имеет лишней задержки перед каждым отправлением события. Он как бы сохраняет последнее значение и возвращает его, когда внутренний duration удовлетворён. https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/sample.html

    Ещё требуется понимать, что время от отправки событий через flow до получения слушателем - не мгновенно, а примерно 50мс. Поэтому отправлять события чаще чем эти 50мс попросту нельзя: даже умный диспетчер не переварит такую нагрузку, из-за чего вызовет подвисание UI.

    И да, дважды подумай, нужно ли использовать delay внутри лямбды: лучше использовать расширения.


    1. yvertkin Автор
      13.06.2024 11:19

      Спасибо за "MVVM со стейтами" внёс уточнение. Про debounce ответил выше.

      Про delay внутри лямбды и расширения не очень понял, вроде как это стандартное решение.


  1. rukhi7
    13.06.2024 11:19

    Соответственно могу использовать современные frameworks и стараться сделать все по феншую

    а у них там не реализована возможность отрисовки по слоям, это когда есть фоновый слой который меняется относительно редко и слой на котором всякие движущиеся объекты можно отдельно перерисовывать (машины, автобусы, ...) относительно часто?

    Интересно на сколько современные и на сколько по феншую просто.


    1. yvertkin Автор
      13.06.2024 11:19

      Отрисовки по слоям в Yandex Map SDK для Android я не нашел. Про современные решения я имел в виду архитектуру Android приложения


      1. rukhi7
        13.06.2024 11:19

        на самом деле я считаю что любая система отрисовки вполне современная, если идею или алгоритм ее работы можно прочитать и в конце концов понять и следовать ей на практике, как раз как в вашем случае. Наличие слоев это дополнительная идея которая могла бы добавить какие-то возможности, но это не точно.


  1. molotkv
    13.06.2024 11:19

    Я с яндекс картами не работал особо, но там же классический артефакт View классов.

    Скорей всего неправильная проброска данных для рекомпозиции.

    Возможно там стоит использовать лаунчэффекты или прямую подписку на Flow

    И да хорошая идея другого комментатора про UnStable классы, можно сделать аннотацию Stable для ваших данных


    1. yvertkin Автор
      13.06.2024 11:19

      Хм.. “лаунчэффекты или прямую подписку на Flow” не пробовал, но вроде и без этого все неплохо работает.. С UnStable идея действительно хорошая, но решил использовать “неизменяемые коллекции”, а не аннотацию Stable, т.к. дока рекомендует: “If it is possible to make your class stable without an annotation, you should strive to achieve stability that way.”


  1. romeat
    13.06.2024 11:19

    Хотелось бы увидеть, как у вас UI реализован. А то в статье про компоуз, но ни одной Composable-функции нет)


    1. yvertkin Автор
      13.06.2024 11:19

      Ну почему одна есть:

      val mapUiState by mapViewModel.mapUiState.collectAsStateWithLifecycle() :)

      А если серьезно, то работа с Yandex Map SDK для Android достаточно специфична и это не совсем компоуз. Рассказать хотел именно про взаимодействие с ним.