Думаю, каждый из нас периодически сталкивается с непонятными микрофризами при взаимодействии с, казалось бы, простым UI…
Просто скролишь список, и тут — бац! Лагнуло! Сегодня я бы хотел разобрать одну из множества причин такого поведения — работу с ресурсами. Мы разберёмся, в каких случаях работа с ресурсами может стать проблемой. Почему это происходит и как лучше всего от этого избавится.
По всем правилам приличия представлюсь — меня зовут Перевалов Данил, а теперь давайте перейдём к теме.
Нереальный пример
Для воспроизведения поведения с фризами сделаем простой пример. Пусть у нас на экране будет один ImageView и две кнопки. При нажатии на каждую из них в ImageView вставляется картинка. Картинка одна и та же, но для каждой из кнопок свой экземпляр в ресурсах.
val image = findViewById<ImageView>(R.id.image)
val button1 = findViewById<Button>(R.id.button1)
val button2 = findViewById<Button>(R.id.button2)
button1.setOnClickListener { image.setImageResource(R.drawable.test_image1) }
button2.setOnClickListener { image.setImageResource(R.drawable.test_image2) }
Картинка достаточно большого размера — 3096 на 4680 пикселей.
Попробуем потыкаться. Если мы будем многократно нажимать на первую кнопку, то мы увидим пусть не идеальное, но относительно приемлемое количество кадров.
Да, далеко не 60 кадров, но картинка очень большая, и рендерить её телефону непросто. Но жить можно.
А вот если мы начнём попеременно нажимать то первую, то вторую кнопку, картина резко изменится.
Появляются «пики точёные» высотой почти до Status Bar. Чтобы понять, в чём проблема, собираем дебажную версию, убеждаемся, что на ней те же проблемы, и записываем trace момента нажатия на кнопку.
Из 476 миллисекунд, которые занимает View.performClick, на getDrawable у нас уходит 473 миллисекунды. Проблема, очевидно, где-то в работе с ресурсами. Хотя, я думаю, вы уже и по названию статьи об этом догадались.
Но это пример в вакууме, где ситуация не очень реалистична. В реальном проекте никто не будет вставлять гигантскую картинку через setImageResource. По крайней мере, я на это надеюсь. Давайте взглянем на более реальный пример.
Реальный пример – списки
Так как микрофризы ярче всего видны при использовании списков, за которые в Android обычно отвечает RecyclerView, то и рассматривать будем на его примере.
Допустим, у нас есть список звонков. Список может быть очень длинным. Тысячи записей. Сама вёрстка экрана очень простая, но периодически при скролле этого экрана появляются непонятные фризы.
При этом у каждого элемента списка есть иконка с состоянием звонка: принятый, сброшенный, пропущенный. Какая иконка выставится, решается на основе данных в RecyclerView.Adapter.onBindViewHolder(...).
Часто разработчики делегируют вызов onBindViewHolder куда-либо ещё: в сам ViewHolder, View или отдельный делегат. Так проще работать с несколькими типами и переиспользовать их между адаптерами. Поэтому в примере я сделаю так же и делегирую onBindViewHolder в сам ViewHolder. А внутри него на основе данных получаю идентификатор ресурса и делаю getDrawable с этим идентификатором.
class CallAdapter(context: Context) : RecyclerView.Adapter<ViewHolder>() {
..........................................
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.bind(item)
}
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
..........................................
fun bind(item: Data) {
val iconRes = when (item.state) {
Data.State.INCOMING -> R.drawable.ic_incoming
Data.State.REJECTED -> R.drawable.ic_rejected
Data.State.SKIPPED -> R.drawable.ic_skipped
}
val drawable = ResourcesCompat.getDrawable(resources, iconRes, theme)
icon.setImageDrawable(drawable)
}
}
И вы никак не могли ожидать, но есть проблема опять в getDrawable. Фризы стабильно появлялись именно при вызове этого метода.
Чтобы понять в чём тут проблема, надо понять, как Android работает с ресурсами типа Drawable.
Работа с Drawable в Android
При старте приложение в C++ слое индексируются все ресурсы из установленных для приложения apk. В результате этого получается словарь, где в качестве ключа выступает идентификатор ресурса, а в качестве значения… Всё на самом деле зависит от типа ресурса и версии Android. Для нас важно, что в случае с Drawable неважно, какого картинка типа: xml, png, jpg, webp или что-то ещё — это просто путь к файлу.
Как следует из сказанного выше сама итоговая картинка не хранится в оперативной памяти при старте приложения, в отличии от некоторых других типов ресурсов. Так сделано, чтобы не забивать оперативную память тяжёловесными картинками. К тому же в случае с векторной картинкой неизвестно, какого она должна быть размера.
Само создание Drawable, чтобы мы могли использовать её в нашем UI, происходит при непосредственном вызове getDrawable у экземпляра Resources. Для этого ResourcesImpl обращается к C++ классу AssetManager2, который и обратится к таблице с идентификаторами. В итоге он по id ресурса узнает конкретный файл и вернёт в ResourcesImpl соответствующий ему InputStream. Далее с помощью InputStream уже будет создан необходимый нам Drawable. Там, конечно, есть ещё куча логики, связанной с плотностью экранов и обработка пограничных кейсов, но мы их опустим.
Получается такая длинная цепочка методов:
Полученный результат кладётся в кэш. Для этого даже есть отдельный класс — ThemedResourceCache. В целом логика простая: если есть в кэше, берём из него, если нет, то забираем из файловой системы (постоянной памяти) и кладём в кэш (оперативная память).
При этом в кэше хранятся ссылки не на Drawable, а на Drawable.ConstantState. Этот state, по сути, представляет из себя набор данных, необходимых, чтобы создать новый Drawable. Дважды вызвав метод getDrawable, мы получим два разных экземпляра Drawable, но с одним и тем же экземпляром Drawable.ConstantState. Если изменить в state какой-либо параметр у одной Drawable, то он изменится и у другой Drawable, так как ссылка на один и тот же экземпляр. Правда, изменения эти станут видны только после перерисовки.
Также важной для текущего повествования особенностью кэша является использование WeakReference для хранения Drawable.ConstantState. Почему так сделали в Android, понятно. Они не знали жизненный цикл конкретного Drawable, поэтому хранят столько, сколько могут, пока не забьётся память.
Как следствие при вызове Garbage Collector этот кеш очищается. В итоге в реальном использовании возникает проблема…
Проблема
Если Drawable всё время находится на экране, то всё хорошо. Ссылка на Drawable хранится во View, а сам Drawable хранит ссылку на Drawable.State. Поэтому Garbage Collector (GC) видит, что на Drawable.State есть также и сильная ссылка, и не удаляет её из кэша.
Но что случится, если Drawable не постоянно присутствует на экране? В нашем случае это Drawable состояния звонка. Вполне может быть так, что 10 элементов подряд – все звонки принятые, а далее 10 элементов подряд – пропущенные. Значит, иконки отклонённых звонков на экране сейчас нет. Как следствие, сильной ссылки на state иконки отклонённого звонка тоже нет, и GC почистит слабую ссылку в кэше при следующем своём срабатывании. В результате, когда на экране всё-таки понадобится иконка отклонённого звонка, то придётся заново грузить её из постоянной памяти.
Утрируя: время жизни Drawable, появляющейся на экране лишь периодически, по сути, стремится ко времени между срабатываниями GC. Его срабатывания никак не привязаны к жизненному циклу экрана. Пока открыт экран, GC может сработать многократно, особенно если это (почти) бесконечный список. Ведь с каждой новой загруженной страницей мы добавляем данных в оперативную память. При этом Drawable, которые не отображаются на экране в данный момент, после каждого срабатывания GC вынуждены заново загружаться из постоянной памяти.
Проблема тут в том, что постоянная память сильно медленнее оперативной. Ведь мало того, что мы грузим файл из постоянной памяти, что не быстро, так ещё и делаем это в достаточно тяжёлом методе RecyclerView.Adapter.OnBindViewHolder. К тому же недостаточно просто загрузить картинку, надо превратить её в bitmap, чтобы отображать на UI. Вишенкой на торте является то, что делаем мы это на главном потоке.
В общем, дело это непростое. При каждой загрузке Drawable, которой нет в кэше, возникает заметный фриз.
На экране со списком звонков у элемента списка всего один маленький Drawable, но на других экранах их может быть больше десятка в каждом из элементов списка. На таких экранах дела с загрузкой Drawable будут обстоять гораздо хуже.
Усугублённая проблема
На самом деле проблема даже чуть более неприятная, чем может показаться на первый взгляд. Дело в том, что производители телефонов любят экономить в первую очередь именно на скорости постоянной памяти.
Простые пользователи привыкли мерить производительность телефона по двум параметрам: процессор и количество памяти. Поэтому многие производители, решая, на чём бы им сэкономить, часто делают выбор в пользу скорости постоянной памяти, потому что это менее заметный показатель для обычного потребителя на момент покупки устройства. В итоге и памяти много, и процессор крутой. Вот только он простаивает, дожидаясь информации из постоянной памяти, а она медленная. Вот и получается, что характеристики крутые, а телефон периодически фризит.
Часто этим и отличаются флагманы, в которых стоит дорогая и быстрая память, от «народных» смартфонов, у которых тот же процессор и количество оперативной памяти, но постоянная память дешёвая и медленная. При этом разработчики обычно выбирают именно флагманы, а потом и приложения на них тестируют. Из-за этого получается, что они даже не видят боли «простого народа».
Решение
Жизненный цикл Drawable должен быть примерно равен жизненному циклу экрана, чтобы Drawable не загружались многократно и в то же время не хранились слишком долго.
Решение в лоб — делегат
Способ, который первым приходит в голову — сохранить ссылку на Drawable в самом делегате onBindViewHolder, в нашем случае — во ViewHolder. Можно даже обернуть в lazy, чтобы не создавать Drawable лишний раз.
RecyclerView.ViewHolder(itemView) {
..........................................
private val incomingDrawable by lazy {
getDrawable(R.drawable.ic_incoming)
}
private val rejectedDrawable by lazy {
getDrawable(R.drawable.ic_rejected)
}
private val skippedDrawable by lazy {
getDrawable(R.drawable.ic_skipped)
}
fun bind(item: Data) {
val iconDrawable = when (item.state) {
Data.State.INCOMING -> incomingDrawable
Data.State.REJECTED -> rejectedDrawable
Data.State.SKIPPED -> skippedDrawable
}
icon.setImageDrawable(iconDrawable)
}
}
Вообще, решение неплохое. Самый первый ViewHolder при вызове bind загрузит Drawable с постоянной памяти, а остальные при запросе получат Drawable с тем же state из кэша.
Но есть и проблемы…
Часто разработчики, чтобы уменьшить размер apk, кладут в него Drawable только одного цвета, например, чёрного, а уже в нужном месте красят в нужный цвет, например, в жёлтый. Представим, что у нас иконки пропущеного звонка и принятого звонка совпадают, но имеют разные цвета — красный и зеленый соответственно. Покрасим их с помощью setTint.
private val incomingDrawable by lazy {
val drawable = getDrawable(R.drawable.ic_incoming)
drawable.setTint(greenColor)
return@lazy drawable
}
private val skippedDrawable by lazy {
val drawable = getDrawable(R.drawable.ic_incoming)
drawable.setTint(redColor)
return@lazy drawable
}
private val rejectedDrawable by lazy {
getDrawable(R.drawable.ic_rejected)
}
Вот только… Вы же помните про кэш? setTint изменяет не конкретный экземпляр Drawable, а его state, который хранится в кеше. Если просто применить setTint, то у нас покрасятся все Drawable с этим state, а значит, всё с таким же идентификатором. В итоге все иконки будут одного цвета. Это явно не тот результат, на который мы рассчитывали. Чтобы этого избежать, придётся использовать метод mutate. Он возьмёт оригинальный state и сделает на его основе новый специально для этого Drawable. Таким образом, можно отвязаться от общего state. Правда, придётся пожертвовать кэшом, так как mutate state хранится только внутри самой Drawable.
У самой Drawable достаточно вызвать mutate(), и вся магия случится.
private val incomingDrawable by lazy {
val drawable = getDrawable(R.drawable.ic_incoming).mutate()
drawable.setTint(greenColor)
return@lazy drawable
}
private val skippedDrawable by lazy {
val drawable = getDrawable(R.drawable.ic_incoming).mutate()
drawable.setTint(redColor)
return@lazy drawable
}
private val rejectedDrawable by lazy {
getDrawable(R.drawable.ic_rejected)
}
Теперь можно красить, не боясь покрасить все связанные через state экземпляры.
Правда, есть один нюанс…
Теперь у нас в каждом из ViewHolder имеется собственный экземпляр state. По сути, у нас теперь n экземпляров окрашенной картинки. В плане потребления памяти всё стало сильно хуже. В общем, делегат — не лучшее место, если вы пользуетесь tint. Да и проблема с тем, что мы загружаем Drawable с постоянной памяти в главном потоке, никуда не ушла.
А где же лучшее место?
Лучшее место
Можно попробовать сохранить в Adapter, но:
списков может быть несколько, значит, может получится несколько окрашенных копий Drawable.State;
пробрасывать Drawable из Adapter в какой-то делегат неудобно;
мы всё ещё загружаем Drawable с постоянной памяти в главном потоке;
такая ситуация может повторится не только с RecyclerView, но и с обычными View.
Можно в Activity/Fragment, но:
пробрасывать Drawable из Activity/Fragment в какой-то делегат неудобно;
мы всё ещё загружаем Drawable с постоянной памяти в главном потоке.
В общем, думал я, думал. В целом, нам подойдет любой объект который имеет жизненный цикл равный жизненному циклу экрана. Но по моему нескромному субъективному мнению, лучшее место — UiMapper. Его задачей является превращение моделей бизнес-логики в модели UI. Поэтому:
можно и нужно вызывать на побочном потоке;
все модели одинакового типа и с одинаковым набором Drawable пройдут через один экземпляр UiMapper, а значит, можно хранить в нём окрашенные и не очень Drawable;
жизненный цикл равняется жизненному циклу экрана, а значит, не будет лишней загрузки Drawable из-за GC;
хуже код от него точно не будет.
Получится что-то подобное:
class UiMapper(
val resourcesWrapper: ResourcesWrapper
) {
private val redColor by lazy {
resourcesWrapper.getColor(R.color.red)
}
private val greenColor by lazy {
resourcesWrapper.getColor(R.color.green)
}
private val incomingDrawable by lazy {
resourcesWrapper.getDrawable(R.drawable.ic_incoming).apply {
mutate()
setTint(greenColor)
}
}
private val skippedDrawable by lazy {
resourcesWrapper.getDrawable(R.drawable.ic_incoming).apply {
mutate()
setTint(redColor)
}
}
private val rejectedDrawable by lazy {
resourcesWrapper.getDrawable(R.drawable.ic_rejected)
}
@WorkerThread
fun map(data: Data): UiData {
val stateDrawable = when (data.state) {
Data.State.INCOMING -> incomingDrawable
Data.State.REJECTED -> rejectedDrawable
Data.State.SKIPPED -> skippedDrawable
}
return UiData(
stateDrawable = stateDrawable
)
}
}
Сам ViewHolder при этом сильно упростился.
class UiMapperViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val icon: ImageView = itemView.findViewById(R.id.clicked)
fun bind(item: UiData) {
icon.setImageDrawable(item.stateDrawable)
}
}
Общая схема получается примерно следующей:
Не обязательно хранить Drawable в самом UiMapper. Это может быть отдельный класс для хранения и/или получения самих Drawable, жизненый цикл которого равен жизненному циклу UiMapper или самого экрана. В любом случае инициатором получения Drawable выступит UiMapper.
Правда, как по мне, это уже оверхед. Но всё, конечно, зависит от количества самих Drawable. Если их очень много, то, наверное, стоит.
Из минусов: придётся сделать обёртку над Resources, чтобы нормально писать Unit-тесты. Также я слышал, что некоторые против использования идентификаторов ресурсов за пределами Android-классов. Тогда придётся ещё сделать обёртку и для идентификаторов.
В остальном же — самое лучшее место.
Итог
При вынесении получения Drawable в UiMapper непонятные фризы на экране со списком звонков ушли. Мы и раньше писали UiMapper’ы для большинства экранов, но вот чёткого правила: «Получать Drawable в UiMapper», не было. Кажется, что скоро появится. Если у вас тоже случаются непонятные фризы, проверьте, возможно, причина в загрузке Drawable. Конечно, совет: «Получайте Drawable в UiMapper», вообще ни разу не универсален, но! Главное, что стоит вынести из статьи:
ресурсы лучше не получать на MainThread;
лучше хранить сильную ссылку на Drawable, пока живёт экран, иначе GC будет подбрасывать вам сюрпризы.