![](https://habrastorage.org/getpro/habr/upload_files/f46/1f6/e87/f461f6e879959b226b86ccbb96619c7b.jpg)
Думаю, каждый из нас периодически сталкивается с непонятными микрофризами при взаимодействии с, казалось бы, простым 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 пикселей.
Попробуем потыкаться. Если мы будем многократно нажимать на первую кнопку, то мы увидим пусть не идеальное, но относительно приемлемое количество кадров.
![](https://habrastorage.org/getpro/habr/upload_files/bb9/998/574/bb99985742fc6c5b068296bb2528bc26.png)
Да, далеко не 60 кадров, но картинка очень большая, и рендерить её телефону непросто. Но жить можно.
А вот если мы начнём попеременно нажимать то первую, то вторую кнопку, картина резко изменится.
![](https://habrastorage.org/getpro/habr/upload_files/31c/528/a12/31c528a12176f4fb0168b18106b74ed6.png)
Появляются «пики точёные» высотой почти до Status Bar. Чтобы понять, в чём проблема, собираем дебажную версию, убеждаемся, что на ней те же проблемы, и записываем trace момента нажатия на кнопку.
![](https://habrastorage.org/getpro/habr/upload_files/d9c/1b4/e01/d9c1b4e01f114bbffdf22f738a6ce10c.png)
Из 476 миллисекунд, которые занимает View.performClick, на getDrawable у нас уходит 473 миллисекунды. Проблема, очевидно, где-то в работе с ресурсами. Хотя, я думаю, вы уже и по названию статьи об этом догадались.
Но это пример в вакууме, где ситуация не очень реалистична. В реальном проекте никто не будет вставлять гигантскую картинку через setImageResource. По крайней мере, я на это надеюсь. Давайте взглянем на более реальный пример.
Реальный пример – списки
Так как микрофризы ярче всего видны при использовании списков, за которые в Android обычно отвечает RecyclerView, то и рассматривать будем на его примере.
Допустим, у нас есть список звонков. Список может быть очень длинным. Тысячи записей. Сама вёрстка экрана очень простая, но периодически при скролле этого экрана появляются непонятные фризы.
![](https://habrastorage.org/getpro/habr/upload_files/1e3/47b/633/1e347b63328e5200864030819cf34ae8.png)
При этом у каждого элемента списка есть иконка с состоянием звонка: принятый, сброшенный, пропущенный. Какая иконка выставится, решается на основе данных в 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 или что-то ещё — это просто путь к файлу.
![](https://habrastorage.org/getpro/habr/upload_files/c5f/4c8/bb6/c5f4c8bb6d758ad7373815e95f5d9aab.png)
Как следует из сказанного выше сама итоговая картинка не хранится в оперативной памяти при старте приложения, в отличии от некоторых других типов ресурсов. Так сделано, чтобы не забивать оперативную память тяжёловесными картинками. К тому же в случае с векторной картинкой неизвестно, какого она должна быть размера.
Само создание Drawable, чтобы мы могли использовать её в нашем UI, происходит при непосредственном вызове getDrawable у экземпляра Resources. Для этого ResourcesImpl обращается к C++ классу AssetManager2, который и обратится к таблице с идентификаторами. В итоге он по id ресурса узнает конкретный файл и вернёт в ResourcesImpl соответствующий ему InputStream. Далее с помощью InputStream уже будет создан необходимый нам Drawable. Там, конечно, есть ещё куча логики, связанной с плотностью экранов и обработка пограничных кейсов, но мы их опустим.
Получается такая длинная цепочка методов:
![](https://habrastorage.org/getpro/habr/upload_files/8db/646/4a9/8db6464a9bbe9a51aaf3c124a2d6808a.png)
Полученный результат кладётся в кэш. Для этого даже есть отдельный класс — ThemedResourceCache. В целом логика простая: если есть в кэше, берём из него, если нет, то забираем из файловой системы (постоянной памяти) и кладём в кэш (оперативная память).
![](https://habrastorage.org/getpro/habr/upload_files/2f7/532/9df/2f75329dfc9ee4ec0ee7d6e6c9b4142e.png)
При этом в кэше хранятся ссылки не на Drawable, а на Drawable.ConstantState. Этот state, по сути, представляет из себя набор данных, необходимых, чтобы создать новый Drawable. Дважды вызвав метод getDrawable, мы получим два разных экземпляра Drawable, но с одним и тем же экземпляром Drawable.ConstantState. Если изменить в state какой-либо параметр у одной Drawable, то он изменится и у другой Drawable, так как ссылка на один и тот же экземпляр. Правда, изменения эти станут видны только после перерисовки.
![](https://habrastorage.org/getpro/habr/upload_files/60a/981/1ef/60a9811ef37947b18359212d385c653c.png)
Также важной для текущего повествования особенностью кэша является использование WeakReference для хранения Drawable.ConstantState. Почему так сделали в Android, понятно. Они не знали жизненный цикл конкретного Drawable, поэтому хранят столько, сколько могут, пока не забьётся память.
![](https://habrastorage.org/getpro/habr/upload_files/2e2/9ea/849/2e29ea8498cf2324c5243a3d26f39887.png)
Как следствие при вызове Garbage Collector этот кеш очищается. В итоге в реальном использовании возникает проблема…
Проблема
Если Drawable всё время находится на экране, то всё хорошо. Ссылка на Drawable хранится во View, а сам Drawable хранит ссылку на Drawable.State. Поэтому Garbage Collector (GC) видит, что на Drawable.State есть также и сильная ссылка, и не удаляет её из кэша.
Но что случится, если Drawable не постоянно присутствует на экране? В нашем случае это Drawable состояния звонка. Вполне может быть так, что 10 элементов подряд – все звонки принятые, а далее 10 элементов подряд – пропущенные. Значит, иконки отклонённых звонков на экране сейчас нет. Как следствие, сильной ссылки на state иконки отклонённого звонка тоже нет, и GC почистит слабую ссылку в кэше при следующем своём срабатывании. В результате, когда на экране всё-таки понадобится иконка отклонённого звонка, то придётся заново грузить её из постоянной памяти.
![](https://habrastorage.org/getpro/habr/upload_files/0f2/e17/286/0f2e172863db84d406b4b7051af03ece.png)
Утрируя: время жизни 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.
![](https://habrastorage.org/getpro/habr/upload_files/513/b9c/57a/513b9c57a1ed8961bb170b63dace95be.png)
У самой 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)
}
}
Общая схема получается примерно следующей:
![](https://habrastorage.org/getpro/habr/upload_files/ff6/9e3/a48/ff69e3a4805f48a3f094f3ebdd295f88.png)
Не обязательно хранить Drawable в самом UiMapper. Это может быть отдельный класс для хранения и/или получения самих Drawable, жизненый цикл которого равен жизненному циклу UiMapper или самого экрана. В любом случае инициатором получения Drawable выступит UiMapper.
![](https://habrastorage.org/getpro/habr/upload_files/a2f/855/4be/a2f8554be930db7e7c0a2d8a7fefe20e.png)
Правда, как по мне, это уже оверхед. Но всё, конечно, зависит от количества самих Drawable. Если их очень много, то, наверное, стоит.
Из минусов: придётся сделать обёртку над Resources, чтобы нормально писать Unit-тесты. Также я слышал, что некоторые против использования идентификаторов ресурсов за пределами Android-классов. Тогда придётся ещё сделать обёртку и для идентификаторов.
В остальном же — самое лучшее место.
Итог
При вынесении получения Drawable в UiMapper непонятные фризы на экране со списком звонков ушли. Мы и раньше писали UiMapper’ы для большинства экранов, но вот чёткого правила: «Получать Drawable в UiMapper», не было. Кажется, что скоро появится. Если у вас тоже случаются непонятные фризы, проверьте, возможно, причина в загрузке Drawable. Конечно, совет: «Получайте Drawable в UiMapper», вообще ни разу не универсален, но! Главное, что стоит вынести из статьи:
ресурсы лучше не получать на MainThread;
лучше хранить сильную ссылку на Drawable, пока живёт экран, иначе GC будет подбрасывать вам сюрпризы.
Neikist
Статья годная, но как обычно в таких статьях — мало рейтинга набирает и комментариев))