ym88659208ym87991671
11 минут на чтение
27 мая 2024

Асинхронная загрузка элементов списка из xml-ресурсов

Денис Дружинин
Senior Android разработчик

Всем привет, меня зовут Денис Дружинин, я занимаюсь Андроид-разработкой на протяжении многих лет. В ходе работы над продуктами SberDevices нашим инженерам часто приходится находить нестандартные решения. Вот уже полгода как я работаю в команде СберЧата, где,в том числе, решаю задачи оптимизации.

В Андроид-приложениях такие долгие операции, как подгрузка данных из БД или по сети, выносятся в отдельные потоки. Однако загрузка view из xml-ресурсов (inflating) в большинстве приложений по‑прежнему происходит в главном потоке, что может негативно сказаться на времени открытия экрана. Эта проблема усугубляется, когда экран содержит RecyclerView, отображающий список элементов со сложными лэйаутами.

Так, мы обнаружили, что при открытии экрана со списком чатов в СберЧате сначала тратится 300500 мс на загрузку данных, а потом ещё 150180 мс на загрузку view из xml-ресурсов (10 элементов по 1518 мс), и это не считая заполнения элементов списка подгруженными ранее данными. В результате, суммарное время инициализации экрана приблизилось к одной секунде, что воспринимается пользователем как существенная задержка. В результате проведённого анализа стало понятно, что элементы списка имеет смысл загружать из xml-ресурсов заранее и асинхронно (в идеале, несколько элементов списка одновременно).

Почему иерархия view, а не интерфейс на Compose?

Сразу отвечу на вопрос, который, вероятно, возникнет у читателя  почему в статье речь идёт о сравнительно старом подходе с иерархией view и их загрузкой из xml-лэйаутов, а не о более новом подходе к созданию интерфейса с помощью фреймворка Compose. Существенная кодовая база, по‑прежнему, написана с использованием view, а полный переход на Compose при наличии сложных интерфейсов требует значительных временных затрат. По этой причине хочется иметь возможность ускорить инициализацию экрана, реализованного посредством иерархии view, с минимальными изменениями в коде.

Аналоги

AsyncLayoutInflater

Класс AsyncLayoutInflater из библиотеки androidx.asynclayoutinflater:asynclayoutinflater предназначен для асинхронной загрузки view из xml-ресурсов. К сожалению, этот класс имеет следующие недостатки:

  1. Загрузка осуществляется всегда в одном рабочем потоке, то есть запустить загрузку нескольких элементов списка одновременно не получится.
  2. Отмена загрузки невозможна, также компонент не отслеживает события жизненного цикла, и поэтому не может отменить загрузку автоматически, когда уничтожается Activity или Fragment.
  3. В примерах, которые можно найти в интернете, предлагается начинать асинхронную загрузку в момент вызова метода адаптера onCreateViewHolder(). Проблема в том, что этот метод вызывается, когда уже произошла загрузка данных для отображения. При таком подходе придётся также отслеживать, загружен ли лэйаут элемента списка к моменту вызова метода onBindViewHolder(), что усложняет логику работы адаптера. Напомню, что мы ищем способ подгружать данные для отображения и view из xml-ресурсов одновременно.

OkLayoutInflater

Класс OkLayoutInflater стал развитием концепции AsyncLayoutInflater. OkLayoutInflater может загружать несколько элементов списка из xml-ресурсов одновременно, чем устраняет недостаток (1) класса AsyncLayoutInflater. OkLayoutInflater также отслеживает события жизненного цикла и отменяет загрузку в случае события Lifecycle.Event.ON_DESTROY, что устраняет недостаток (2). Однако недостаток (3) класса AsyncLayoutInflater присущ и классу OkLayoutInflater.

PreinflatedItemViewPool

Интерфейс и способ использования

В результате, был разработан класс PreinflatedItemViewPool, который позволяет заранее подгрузить элементы списка, распараллелив эту задачу для выполнения в нескольких рабочих потоках. Для начала, рассмотрим интерфейс этого класса.

class PreinflatedItemViewPool private constructor(private val context: Context,
private val config: Map<String, PreinflatedItemViewPool.ConfigItem>,
private val itemParent: ViewGroup?,
errorListener: ErrorListener) {
fun getCompositeView(@LayoutRes mainLayoutResId: Int, @LayoutRes internalLayoutResId: Int, @IdRes containerId: Int): View

fun getView(@LayoutRes layoutResId: Int): View

companion object {
fun fromContext(context: Context,
config: Map<String, PreinflatedItemViewPool.ConfigItem>,
itemParent: ViewGroup? = null,
errorListener: ErrorListener = LogcatErrorListener()): PreinflatedItemViewPool
fun fromFragment(fragment: Fragment,
config: Map<String, PreinflatedItemViewPool.ConfigItem>,

Экземпляр класса PreinflatedItemViewPool создаётся с помощью фабричных методов fromContext() и fromFragment(). Переданный при этом Context или Fragment рассматривается как LifecycleOwner, освобождение ресурсов PreinflatedItemViewPool выполняется автоматически при возникновении события Lifecycle.Event.ON_DESTROY. Поскольку при создании экземпляра PreinflatedItemViewPool требуется передать в качестве параметра config: Map<String, PreinflatedItemViewPool.ConfigItem>, в СберЧате мы инкапсулировали логику инициализации PreinflatedItemViewPool для разных экранов в object PreinflatedItemViewPoolFactory. Вот пример такого класса:

object PreinflatedItemViewPoolFactory {
private const val MAIN_SCREEN_INITIAL_VIEW_COUNT = 12

private val standardCountMaps = HashMap<ItemType, HashMap<String, PreinflatedItemViewPool.ConfigItem>&gt;().apply {
this[ItemType.MainScreen] = HashMap<String, PreinflatedItemViewPool.ConfigItem>(2).apply {
addItem(PreinflatedItemViewPool.ConfigItem(R.layout.list_item,
MAIN_SCREEN_INITIAL_VIEW_COUNT&nbsp;/ 2,), this)
addItem(PreinflatedItemViewPool.ConfigItem(R.layout.list_item_extended,
MAIN_SCREEN_INITIAL_VIEW_COUNT&nbsp;/ 2), this)
}
}

private fun addItem(newItem: PreinflatedItemViewPool.ConfigItem,
items: HashMap<String, PreinflatedItemViewPool.ConfigItem>) {
items[newItem.getId()] = newItem

Параметр config задаёт количество элементов с разным viewType, которые требуется загрузить из xml-ресурсов (параметр regularCount конструктора класса ConfigItem будет разобран ниже). Использование PreinflatedItemViewPool не усложняет логику адаптера, так как создание (или получение уже готовой view) просто делегируется экземпляру PreinflatedItemViewPool.

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
val itemView = preinflatedViewPool.getView(if (viewType == 0) R.layout.list_item else R.layout.list_item_extended)
return ItemViewHolder(itemView)
}

В листинге, приведённом выше, ItemViewHolder подкласс класса RecyclerView.ViewHolder. PreinflatedItemViewPool позволяет загружать как простые view с помощью метода getView(), так и составные view с помощью метода getCompositeView(). Во втором случае, метод загружает из xml-ресурса основной лэйаут (параметр mainLayoutResId), ищет в нём view-контейнер по идентификатору (параметр containerId) и, наконец, выполняет подгрузку из xml внутреннего лэйаута (параметр internalLayoutResId) с найденным контейнером в качестве родительского (parent). Звучит довольно сложно, но в реальности, это один из способов сформировать лэйауты для разных viewType. Такой подход позволяет избежать дублирования лэйаутов. Так, лэйауты для нескольких viewType имеют один и тот же mainLayoutResId и containerId, но разные internalLayoutResId. При вызове методов getView() и getCompositeView(), view требуемого типа будет гарантированно возвращён, либо из «запаса» ранее созданных асинхронно, либо создан синхронно в вызывающем потоке.

Детали реализации

Полный листинг класса PreinflatedItemViewPool

class PreinflatedItemViewPool private constructor(private val context: Context,
private val config: Map<String, PreinflatedItemViewPool.ConfigItem>,
private val itemParent: ViewGroup?,
errorListener: ErrorListener) {
var isDebugLogsEnabled: Boolean = false

private val pool = HashMap<String, ArrayList<View>&gt;()
private val asyncInflater = AsyncInflater(context, errorListener)

init {
for (configItem in&nbsp;config.values) {
val count = configItem.initialCount
for (i&nbsp;in&nbsp;0&nbsp;until count) {
inflateViewAsync(configItem)
}

pool, который является членом класса PreinflatedItemViewPool и имеет тип HashMap<String, ArrayList<View>&gt; пополняется в результате асинхронной загрузки view, в то время как методы getView() и getCompositeView() удаляют возвращаемый элемент из pool.

Ещё один член класса asyncInflater имеет тип AsyncInflater, который является упрощённой версией рассмотренного выше класса OkLayoutInflater, разве что содержит метод inflateCompositeView() для загрузки составных view из xml-ресурсов. Код AsyncInflater приведён ниже.

Полный листинг класса AsyncInflater

class AsyncInflater(private val context: Context,
private val errorListener: ErrorListener) {

private val coroutineContext = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.Default + coroutineContext)
private val basicInflater = BasicInflater(context)

/**
* Tries to&nbsp;inflate the given layout asynchronously. If&nbsp;fails to&nbsp;do&nbsp;this, falls back to&nbsp;inflation in&nbsp;the Main thread.
* The result view is&nbsp;not attached to&nbsp;parent.
*/
fun inflate(@LayoutRes resId: Int,
parent: ViewGroup?,
callback: suspend (view: View) -&gt; Unit,) {
scope.launch {

В качестве одного из параметров конструктора PreinflatedItemViewPool передаётся errorListener: ErrorListener, имеющий по умолчанию значение LogcatErrorListener(). errorListener оповещается о возникших ошибках и ворнингах, реализация LogcatErrorListener просто выводит эту информацию в Logcat.

Листинг интерфейса ErrorListener и класса LogcatErrorListener

interface ErrorListener {
fun warning(tag: String, throwable: Throwable?, message: String)
fun error(tag: String, throwable: Throwable, message: String)
}

class LogcatErrorListener: ErrorListener {

override fun warning(tag: String, throwable: Throwable?, message: String) {
Log.w(tag, message, throwable)
}

override fun error(tag: String, throwable: Throwable, message: String) {
Log.e(tag, message, throwable)
}
}

В ходе дебага бывает полезно отследить, как часто в ходе инициализации экрана и позже, в ходе прокрутки списка запрашиваемые view берутся из предзагруженного набора, а как часто подгружаются в главном потоке в момент вызова метода getView() или getCompositeView(). Для этих целей в PreinflatedItemViewPool был добавлен изменямый булевский флаг isDebugLogsEnabled, который позволяет включить логирование соответствующих событий.

RecycledViewPoolWithPreloading — неудачная попытка

Стоит признать, что в результате использования PreinflatedItemViewPool возникает ситуация, когда предзагруженные элементы хранятся в одном месте, а уже использованные элементы совсем в другом месте  RecyclerView.RecycledViewPool. Фактически, при этом нарушается принцип single responsibility.

Поэтому в качестве развития PreinflatedItemViewPool была идея создать класс RecycledViewPoolWithPreloading, унаследованный от RecyclerView.RecycledViewPool, чтобы он инкапсулировал всю необходимую логику асинхронной загрузки, а адаптер бы тогда вообще не знал о существовании PreinflatedItemViewPool. К сожалению, добиться правильного функционирования класса RecycledViewPoolWithPreloading не удалось, так как RecyclerView.Adapter заполняет важное package-private поле ViewHolder.mItemViewType после вызова метода RecyclerView.Adapter.onCreateViewHolder(). Если это поле не заполнено, то RecycledViewPool считает созданный ViewHolder невалидным, и вся логика работы RecycledViewPool нарушается. Можно было, конечно, заполнить это поле с применением инструментария рефлексии, но хотелось получить концептуальное решение, которое не перестало бы внезапно работать после очередного обновления библиотеки, содержащей RecyclerView.

Как компенсировать отсутствие RecycledViewPoolWithPreloading?

В PreinflatedItemViewPool.ConfigItem было добавлено поле regularCount, которое позволяет задать количество предзагруженных в дальнейшем элементов списка, отличное от начального, задаваемого параметром initialCount. Логичным способом вычисления regularCount будет (initialCount&nbsp;&mdash; RecycledViewPool.DEFAULT_MAX_SCRAP). При этом DEFAULT_MAX_SCRAP это приватная константа, равная 5, поэтому стоит перенести его себе в PreinflatedItemViewPoolFactory. При прокрутке списка стандартный RecycledViewPool закэширует до DEFAULT_MAX_SCRAP элементов определённого viewType, соответственно PreinflatedItemViewPool может уменьшить на это значение количество предзагруженных элементов этого viewType.

Если задан параметр regularCount, меньший чем initialCount, то первые (initialCount&nbsp;&mdash; regularCount) вызовов getView() и getCompositeView() для определённого viewType не будут приводить к старту асинхронной загрузки новой view взамен возвращаемой.

object PreinflatedItemViewPoolFactory {
// Value is&nbsp;taken from RecycledView.RecycledViewPool.DEFAULT_MAX_SCRAP.
private const val RECYCLER_VIEW_POOL_DEFAULT_MAX_SCRAP = 5
private const val COMPOUND_ITEMS_SCREEN_INITIAL_VIEW_COUNT = 10

private val standardCountMaps = HashMap<ItemType, HashMap<String, PreinflatedItemViewPool.ConfigItem>&gt;().apply {
this[ItemType.CompoundItemsScreen] = HashMap<String, PreinflatedItemViewPool.ConfigItem>(1).apply {
addItem(PreinflatedItemViewPool.CompositeViewConfigItem(layoutResId = R.layout.list_item_compound,
internalLayoutResId = R.layout.list_item_compound_content,
containerId = R.id.contentContainerView,
initialCount = COMPOUND_ITEMS_SCREEN_INITIAL_VIEW_COUNT,
regularCount = COMPOUND_ITEMS_SCREEN_INITIAL_VIEW_COUNT&nbsp;&mdash; RECYCLER_VIEW_POOL_DEFAULT_MAX_SCRAP), this)
}
}

Результаты тестирования

Использование PreinflatedItemViewPool позволило ускорить инициализацию экрана со списком чатов в СберЧате на 130150 мс, что означает получение предзагруженного элемента списка в 9 из 10 случаев. Такой результат означает для нас достаточно хороший баланс между затратами памяти на предзагруженные элементы и полученным за счёт этого ускорением инициализации экрана. Прокрутка также стала более плавной, но здесь нам ещё есть над чем работать в части заполнения элементов списка загруженными данными.

Итоги

Перечислим преимущества разработанного PreinflatedItemViewPool по сравнению со стандартным классом AsyncLayoutInflater:

 Одновременная загрузка нескольких view в разных рабочих потоках (их количество зависит от количества ядер процессора).  Автоматическая отмена загрузки и освобождение ресурсов в случае уничтожения связанного Activity или Fragment.  Простота использования в подклассах класса RecyclerView.Adapter. Нет необходимости усложнять логику подкласса, и в момент биндинга данных проверять состояние загрузки view для элемента списка.  Возможность гибкой настройки количества предзагружаемых view для каждого viewType.

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

Поскольку SberDevices, как и Сбер в целом, находится на стадии бурного развития как технологическая компания, у нас много различных проектов и интересных задач. А ещё мы всегда рады видеть в команде новых экспертов, которые готовы решать нестандартные задачи и создавать инновационные продукты 😉.

ПАО Сбербанк использует cookie для персонализации сервисов и удобства пользователей.
Вы можете запретить сохранение cookie в настройках своего браузера.