Всем привет, меня зовут Денис Дружинин, я занимаюсь Андроид-разработкой на протяжении многих лет. В ходе работы над продуктами SberDevices нашим инженерам часто приходится находить нестандартные решения. Вот уже полгода как я работаю в команде СберЧата, где,в том числе, решаю задачи оптимизации.
В Андроид-приложениях такие долгие операции, как подгрузка данных из БД или по сети, выносятся в отдельные потоки. Однако загрузка view из xml-ресурсов (inflating) в большинстве приложений по‑прежнему происходит в главном потоке, что может негативно сказаться на времени открытия экрана. Эта проблема усугубляется, когда экран содержит RecyclerView
, отображающий список элементов со сложными лэйаутами.
Так, мы обнаружили, что при открытии экрана со списком чатов в СберЧате сначала тратится 300–500 мс на загрузку данных, а потом ещё 150–180 мс на загрузку view из xml-ресурсов (10 элементов по 15–18 мс), и это не считая заполнения элементов списка подгруженными ранее данными. В результате, суммарное время инициализации экрана приблизилось к одной секунде, что воспринимается пользователем как существенная задержка. В результате проведённого анализа стало понятно, что элементы списка имеет смысл загружать из xml-ресурсов заранее и асинхронно (в идеале, несколько элементов списка одновременно).
Сразу отвечу на вопрос, который, вероятно, возникнет у читателя — почему в статье речь идёт о сравнительно старом подходе с иерархией view и их загрузкой из xml-лэйаутов, а не о более новом подходе к созданию интерфейса с помощью фреймворка Compose. Существенная кодовая база, по‑прежнему, написана с использованием view, а полный переход на Compose при наличии сложных интерфейсов требует значительных временных затрат. По этой причине хочется иметь возможность ускорить инициализацию экрана, реализованного посредством иерархии view, с минимальными изменениями в коде.
Класс AsyncLayoutInflater из библиотеки androidx.asynclayoutinflater:asynclayoutinflater
предназначен для асинхронной загрузки view из xml-ресурсов. К сожалению, этот класс имеет следующие недостатки:
Activity
или Fragment
.onCreateViewHolder()
. Проблема в том, что этот метод вызывается, когда уже произошла загрузка данных для отображения. При таком подходе придётся также отслеживать, загружен ли лэйаут элемента списка к моменту вызова метода onBindViewHolder()
, что усложняет логику работы адаптера. Напомню, что мы ищем способ подгружать данные для отображения и view из xml-ресурсов одновременно.Класс OkLayoutInflater стал развитием концепции AsyncLayoutInflater
. OkLayoutInflater
может загружать несколько элементов списка из xml-ресурсов одновременно, чем устраняет недостаток (1) класса AsyncLayoutInflater
. OkLayoutInflater
также отслеживает события жизненного цикла и отменяет загрузку в случае события Lifecycle.Event.ON_DESTROY
, что устраняет недостаток (2). Однако недостаток (3) класса AsyncLayoutInflater
присущ и классу OkLayoutInflater
.
В результате, был разработан класс 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>>().apply {
this[ItemType.MainScreen] = HashMap<String, PreinflatedItemViewPool.ConfigItem>(2).apply {
addItem(PreinflatedItemViewPool.ConfigItem(R.layout.list_item,
MAIN_SCREEN_INITIAL_VIEW_COUNT / 2,), this)
addItem(PreinflatedItemViewPool.ConfigItem(R.layout.list_item_extended,
MAIN_SCREEN_INITIAL_VIEW_COUNT / 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 требуемого типа будет гарантированно возвращён, либо из «запаса» ранее созданных асинхронно, либо создан синхронно в вызывающем потоке.
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>>()
private val asyncInflater = AsyncInflater(context, errorListener)
init {
for (configItem in config.values) {
val count = configItem.initialCount
for (i in 0 until count) {
inflateViewAsync(configItem)
}
pool
, который является членом класса PreinflatedItemViewPool
и имеет тип HashMap<String, ArrayList<View>>
пополняется в результате асинхронной загрузки view, в то время как методы getView()
и getCompositeView()
удаляют возвращаемый элемент из pool
.
Ещё один член класса asyncInflater
имеет тип AsyncInflater
, который является упрощённой версией рассмотренного выше класса OkLayoutInflater
, разве что содержит метод inflateCompositeView()
для загрузки составных view из xml-ресурсов. Код 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 inflate the given layout asynchronously. If fails to do this, falls back to inflation in the Main thread.
* The result view is not attached to parent.
*/
fun inflate(@LayoutRes resId: Int,
parent: ViewGroup?,
callback: suspend (view: View) -> Unit,) {
scope.launch {
В качестве одного из параметров конструктора PreinflatedItemViewPool
передаётся errorListener: ErrorListener
, имеющий по умолчанию значение LogcatErrorListener()
. errorListener
оповещается о возникших ошибках и ворнингах, реализация LogcatErrorListener
просто выводит эту информацию в Logcat.
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
, который позволяет включить логирование соответствующих событий.
Стоит признать, что в результате использования PreinflatedItemViewPool
возникает ситуация, когда предзагруженные элементы хранятся в одном месте, а уже использованные элементы совсем в другом месте — RecyclerView.RecycledViewPool
. Фактически, при этом нарушается принцип single responsibility.
Поэтому в качестве развития PreinflatedItemViewPool
была идея создать класс RecycledViewPoolWithPreloading
, унаследованный от RecyclerView.RecycledViewPool
, чтобы он инкапсулировал всю необходимую логику асинхронной загрузки, а адаптер бы тогда вообще не знал о существовании PreinflatedItemViewPool
. К сожалению, добиться правильного функционирования класса RecycledViewPoolWithPreloading
не удалось, так как RecyclerView.Adapter
заполняет важное package-private поле ViewHolder.mItemViewType
после вызова метода RecyclerView.Adapter.onCreateViewHolder()
. Если это поле не заполнено, то RecycledViewPool
считает созданный ViewHolder
невалидным, и вся логика работы RecycledViewPool
нарушается. Можно было, конечно, заполнить это поле с применением инструментария рефлексии, но хотелось получить концептуальное решение, которое не перестало бы внезапно работать после очередного обновления библиотеки, содержащей RecyclerView
.
В PreinflatedItemViewPool.ConfigItem
было добавлено поле regularCount
, которое позволяет задать количество предзагруженных в дальнейшем элементов списка, отличное от начального, задаваемого параметром initialCount
. Логичным способом вычисления regularCount
будет (initialCount — RecycledViewPool.DEFAULT_MAX_SCRAP)
. При этом DEFAULT_MAX_SCRAP
— это приватная константа, равная 5, поэтому стоит перенести его себе в PreinflatedItemViewPoolFactory
. При прокрутке списка стандартный RecycledViewPool
закэширует до DEFAULT_MAX_SCRAP
элементов определённого viewType, соответственно PreinflatedItemViewPool
может уменьшить на это значение количество предзагруженных элементов этого viewType.
Если задан параметр regularCount
, меньший чем initialCount
, то первые (initialCount — regularCount)
вызовов getView()
и getCompositeView()
для определённого viewType
не будут приводить к старту асинхронной загрузки новой view взамен возвращаемой.
object PreinflatedItemViewPoolFactory {
// Value is 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>>().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 — RECYCLER_VIEW_POOL_DEFAULT_MAX_SCRAP), this)
}
}
Использование PreinflatedItemViewPool
позволило ускорить инициализацию экрана со списком чатов в СберЧате на 130–150 мс, что означает получение предзагруженного элемента списка в 9 из 10 случаев. Такой результат означает для нас достаточно хороший баланс между затратами памяти на предзагруженные элементы и полученным за счёт этого ускорением инициализации экрана. Прокрутка также стала более плавной, но здесь нам ещё есть над чем работать в части заполнения элементов списка загруженными данными.
Перечислим преимущества разработанного PreinflatedItemViewPool
по сравнению со стандартным классом AsyncLayoutInflater
:
— Одновременная загрузка нескольких view в разных рабочих потоках (их количество зависит от количества ядер процессора).
— Автоматическая отмена загрузки и освобождение ресурсов в случае уничтожения связанного Activity
или Fragment
.
— Простота использования в подклассах класса RecyclerView.Adapter
. Нет необходимости усложнять логику подкласса, и в момент биндинга данных проверять состояние загрузки view для элемента списка.
— Возможность гибкой настройки количества предзагружаемых view для каждого viewType.
В этой статье я рассказал о своей реализации асинхронной загрузки элементов списка из xml-ресурсов. Надеюсь, не слишком утомил вас подробным описанием соответствующих классов. Делитесь в комментариях мнениями, а также своими способами ускорения загрузки экрана и повышения плавности промотки списка.
Поскольку SberDevices, как и Сбер в целом, находится на стадии бурного развития как технологическая компания, у нас много различных проектов и интересных задач. А ещё мы всегда рады видеть в команде новых экспертов, которые готовы решать нестандартные задачи и создавать инновационные продукты 😉.