В предыдущей статье я упоминал кейс приложения «Бланк». Этот материал — логическое и более глубокое продолжение.
Наш клиент — «Бланк», банк для предпринимателей. В его официальном приложении на Android мы работали над пользовательским интерфейсом чата поддержки. Конечная цель — сделать удобный и отзывчивый UI.
Стек технологий:
Android Studio,
Kotlin,
Jetpack Compose,
Chatwoot.
Функционал чата:
отправка и получение сообщений;
отправка и получение файлов любых форматов, но не более 40 мб – ограничение Chatwoot;
*оценка работы поддержки от 1 до 5 (со стороны клиента);
*просмотр статуса задачи (со стороны сотрудника).
Зона ответственности в рамках проекта – первые два пункта – сообщения и файлы. Задача – интегрировать Chatwoot с приложением «Бланка» с помощью Jetpack Compose.
Chatwoot – это пакет поддержки клиентов с открытым исходным кодом. Он работает через WebSocket и REST API. Благодаря Chatwoot саппорт-менеджеры могут просматривать чаты из разных каналов на одной панели.
Jetpack Compose – мощная библиотека с современным и интуитивно понятным подходом к созданию UI. С ее помощью мы создали бесшовный и удобный сервис поддержки для клиентов «Бланка» на Android. Подробнее про Jetpack Compose читайте в нашем обзоре.
Нюансы разработки UI чата техподдержки
MVI-паттерн
В разработке UI чата мы использовали MVI-паттерн.
MVI-паттерн (Model-View-Intent) – это архитектурный подход к разработке мобильных приложений, который упорядочивает процесс взаимодействия между пользовательским интерфейсом (View), бизнес-логикой (Model) и пользовательскими действиями (Intent). Он отличается от других архитектурных подходов тем, что фокусируется на пользовательском взаимодействии, а не на состоянии приложения.
internal interface ViewNews
internal interface ViewState
internal interface ViewWish
internal abstract class BaseViewModel<Wish : ViewWish, State : ViewState, News : ViewNews>() : ViewModel() {
private val _wish = MutableSharedFlow<Wish>()
val wish: SharedFlow<Wish> = _wish
protected fun subscribeToWish() {
_wish
.onEach(this::handleWish)
.launchIn(viewModelScope)
}
init {
subscribeToWish()
}
private val _state: MutableStateFlow<State> by lazy {
MutableStateFlow(initialState())
}
val state: StateFlow<State> by lazy { _state }
private val _news = MutableSharedFlow<News>()
val news = _news.asSharedFlow()
protected abstract fun initialState(): State
protected abstract fun handleWish(wish: Wish)
protected fun onStateChanged(state: State) {
}
protected fun acceptState(reducer: State.() -> State) {
_state.value = state.value.reducer()
}
protected fun acceptNews(builder: () -> News) {
viewModelScope.launch {
val news = builder()
_news.emit(news)
}
}
fun acceptWish(wish: Wish) {
viewModelScope.launch {
_wish.emit(wish)
}
}
}
В данном коде определены три интерфейса: ViewNews, ViewState и ViewWish.
Класс BaseViewModel<Wish: ViewWish, State: ViewState, News: ViewNews>() наследует класс ViewModel и определяет общий шаблон (т. е. абстрагирует) для всех ViewModel, которые используют MVI‑паттерн.
Внутри этого класса есть защищенный метод subscribeToWish(). Он подписывается на MutableSharedFlow событий типа ViewWish, т. е. на поток событий, которые UI хочет получить или выполнить. Как только такое событие поступает в поток, оно обрабатывается методом handleWish(), определяющимся в классе‑наследнике BaseViewModel.
Объявление val wish: SharedFlow<Wish> = _wish позволяет получать доступ к MutableSharedFlow извне в качестве Immutable SharedFlow, т. е. только для чтения.
Такой подход к обработке интентов пользователя, позволяет создавать отзывчивый и масштабируемый интерфейс, который быстро реагирует на действия пользователя и обеспечивает согласованность состояния приложения.
Повторная анимация сообщений
В Jetpack Compose нет прямой поддержки анимации при первом показе в листах, например, LazyColumn, LazyRow.
Например, у нас есть следующий код для анимации:
AnimatedVisibility(
visibleState = MutableTransitionState(false).apply {
targetState = true
},
enter = enterTransition,
exit = exitTransition
) {
MyItemContent(it)
}
Он создает виджет AnimatedVisibility, который принимает несколько параметров:
visibleState — это объект состояния перехода, который отвечает за то, видим ли элемент. В этом примере мы используем MutableTransitionState, чтобы создать состояние, которое изначально устанавливается как невидимый (false), а затем изменяется на видимый (true) с помощью метода apply.
enter — это анимация, которая происходит, когда элемент появляется на экране. Здесь можно использовать различные анимации, такие как fadeIn() или slideInHorizontally().
exit — это анимация, происходящая, когда элемент исчезает с экрана. Здесь тоже можно использовать разные анимации, например, fadeOut() или slideOutVertically().
MyItemContent(it) — это функция, которая определяет содержимое элемента пользовательского интерфейса.
В конечном итоге AnimatedVisibility обеспечивает плавный переход между состояниями видимости элемента с помощью анимаций, заданных в параметрах enter и exit. В нашем примере AnimatedVisibility показывает содержимое элемента UI, когда visibleState устанавливается в true (при запуске анимации).
Мы столкнулись с такой проблемой: ранее просмотренные (и, следовательно, проанимированные) сообщения, повторно анимируются при возврате к ним. Это смотрится некорректно.
Решение: применили AnimatedHelper.
AnimatedHelper – вспомогательный класс, который определяет, какие элементы списка уже анимированы, а какие – нет. Это подходит для нашей ситуации с возвратом к просмотренным сообщениям.
internal class AnimatedHelper {
private val map = HashMap<Any?, Boolean>()
fun isAnimated(key: Any?): Boolean = map[key] ?: false
fun setAnimatedValue(key: Any?, value: Boolean) {
map[key] = value
}
}
AnimatedHelper содержит HashMap, хранящий информацию о видимости и анимации для каждого сообщения. Ключом в HashMap выступает Any? – объект, который можно использовать для идентификации каждого сообщения. Значение Boolean указывает, должно ли сообщение быть видимым и проанимированным.
Метод isAnimated принимает ключ key и возвращает Boolean, указывающий, было ли сообщение уже проанимировано. Если ключа нет в HashMap, метод вернет значение false.
Метод setAnimatedValue устанавливает значение value для ключа key в HashMap. Это позволяет изменить значение видимости и анимации для каждого сообщения.
Итак, при возврате к ранее видимым сообщениям AnimatedHelper используется для проверки, должно ли сообщение быть проанимировано или нет. Если значение isAnimated для ключа равно false, значит, сообщение еще не было проанимировано и нужно запустить анимацию. Если значение isAnimated равно true, значит сообщение уже было проанимировано и анимация не нужна.
Применение AnimatedHelper – хорошее временное, но неоптимальное решение. Разработчики Jetpack Compose уже знают о проблеме и работают над ее исправлением. Ожидаем, что в будущих версиях Jetpack Compose появится функционал для управления анимациями в листах при первом и последующих показах. Сейчас у них есть только анимация при смене мест элементов.
Альтернативное решение, которое мы не использовали, – это Android Views и RecyclerView. С их помощью тоже можно решить вопрос с анимациями в листах при первом показе. Но тогда утрачивается концепция применения чистого Jetpack Compose. К тому же технология стара. Compose предлагает новый и более современный подход к созданию UI, повышает производительность и оптимизацию для приложения.
Повторная отработка кода при рекомпозиции
В Jetpack Compose не рекомендуется применять StateFlow для одноразовых событий (News) при MVI-паттерне. Для них в архитектуре MVI используется поток данных SharedFlow, который может иметь несколько «слушателей».
LaunchedEffect(key1 = Unit)
onEach(block).flowOn(context).launchIn(this)
В нашем случае использование collectAsState() для SharedFlow не подходит для передачи News, потому что он предназначен для использования со StateFlow и не уведомляет о новых значениях, если значение потока не изменилось. Вместо этого мы использовали простой collect для SharedFlow в коробке LaunchedEffect, который уведомляет о новых значениях в потоке, когда они появляются.
Если мы используем SharedFlow или любой другой Flow, при каждой рекомпозиции будет создаваться новый Flow и заново запускаться код, который был передан в функцию onEach(). Это может привести к дополнительным нагрузкам на систему.
LaunchedEffect позволяет запустить асинхронную операцию один раз и сохранить ее состояние с ключом между перерисовками. В итоге при каждой рекомпозиции не будет запускаться новая операция, а будет использоваться сохраненное состояние. Это позволит избежать нежелательных эффектов и улучшить производительность приложения.
Оптимизация Jetpack Compose
В результате тестирования обнаружили медленную работу приложения. Чтобы понять, что именно замедляет процессы, обратились к Layout Inspector: проанализировали структуру и свойства View. Если функция передается без адреса (не как ссылка), то при рекомпозиции создается новая лямбда, что может тормозить работу чата.
Решили вопрос с помощью функции remember, которая сохраняет первоначальное лямбда-выражение.
contentClicked = remember {
{
// Do something
}
}
Использование функции remember помогло сохранить состояние между рекомпозициями и оптимизировать работу приложения. При использовании remember создается кэш-значение, которое сохраняется между вызовами функции. При повторных вызовах не происходит повторной рекомпозиции. Это повышает производительность и уменьшает нагрузку на систему. Например, влияет на скорость скролла чата.
Remember также можно применять для кэширования результатов дорогостоящих вычислений, чтобы избежать повторного вычисления при рекомпозиции. Это особенно полезно при работе с большими данными или сложными расчетами.
Remember – не универсальное решение для всех случаев оптимизации в Jetpack Compose. Иногда может потребоваться использовать другие методы оптимизации, например, Memoization внутри Composable функций. Каждый случай требует индивидуального подхода.
Подводим итоги
Работа над интерфейсом чата для «Бланка» заняла у нас полтора месяца. Несмотря на описанные нюансы, использовать Jetpack Compose было удачным решением. Тем более мы давно пишем на Kotlin. Для нашей команды Compose явно проще, чем XML-макеты. Композитные функции удобно переиспользовать в разных частях программного продукта. Компактный код, доступность сочетания фреймворка с другими библиотеками, легкость внесения правок без изменения структуры – причины, по которым мы рекомендуем разработчикам познакомиться с Jetpack Compose.
Комментарии (2)
Virtyoz
20.04.2023 11:05В статье упоминаются классы
ViewWish
иViewNews
, что очень напоминает MviCore от Баду. Верное ли это наблюдение? Почему решили использовать свой подход вместо готового фреймворка?
Rusrst
Вариант с мапой конечно интересен, надо на него поближе взглянуть, но я до сих пор удивляюсь как можно было сделать списки такими убогими имея recyclerview. Но даже так стандартная анимация расхлопывания будет так себе. Я так понимаю вы априори верите что бек ключи не задвоит?) Смело)
Ну и вообще, чем больше я в compose погружаюсь, тем больше огорчаюсь тому, что кучу всего сломали.
Кстати, если использовать RV вместо lazylist, и в холдеры прокидывать composeview, то тоже могут быть сложности. Так что со списками прям все очень печально...