Как эффективно показывать в Android-приложении десятки тысяч изображений из разных источников? Изучаем кейс компании Revolut.
8К открытий9К показов
Евгений Зубков
Lead Android Engineer в Revolut
В приложениях мы показываем десятки тысяч картинок из разных источников — загружаем из сети, локально, генерируем. Плодить разные сущности кода для их отображения трудоёмко и неэффективно, логичнее делать обобщения. Расскажу, как мы построили систему отображения картинок из любых источников и снизили уровень собственной боли от этого процесса.
Приложение Revolut
В нашем приложении много типов картинок для отображения — есть списки транзакций с разными иконками, списки карточек, Lottie-анимации, гифки. Покажу, как мы работаем с картинками на примере списка транзакций.
У нашего списка транзакций насчитывается несколько десятков типов ячеек. Для примера мы возьмем пять:
В каждом случае картинка взята из отдельного источника или сгенерирована.
Как работает стандартный способ отображения картинок
Создадим адаптер для такого списка.
class TransactionsAdapter : RecyclerView.Adapter() {
private var items = mutableListOf()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.view_transaction, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHandler, position: Int) = Unit
override fun getItemCount() = items.size
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val imageView: ImageView = itemView.findViewById(R.id.image)
}
}
Так будет выглядеть стандартный шаблон адаптера для RecyclerView. Реализуем биндинг значений:
override fun onBindViewHolder(holder: ViewHandler, position: Int) {
val transaction = items[position]
when {
transaction.isContactWithAvatar() -> {
// Загрузка и отображение аватара
}
!transaction.isContactWithAvatar() -> {
// Отображение аватара
}
transaction.isMerchantWithAvatar() -> {
// Загрузка и отображение аватара
}
!transaction.isMerchantWithAvatar() -> {
// Загрузка изображения из источника
}
}
}
Появляется портянка условий, потому что внутри адаптера для каждого вида транзакции мы строим отдельную логику. Можно усложнить и использовать свой ViewType под каждый источник. Тем более к этому подталкивает контракт адаптера:
override fun getItemViewType(position: Int): Int {
val transaction = items[position]
return when {
transaction.isContactWithAvatar() -> VIEW_TYPE_CONTACT_WITH_AVATAR
!transaction.isContactWithAvatar() -> VIEW_TYPE_CONTACT_WITHOUT_AVATAR
transaction.isMerchantWithAvatar() -> VIEW_TYPE_MERCHANT_WITH_AVATAR
!transaction.isMerchantWithAvatar() -> VIEW_TYPE_MERCHANT_WITHOUT_AVATAR
else -> VIEW_TYPE_UNKNOWN
}
}
Учитывая, что в нашем случае может быть несколько десятков видов транзакций, стандартный способ реализации адаптера не подходит.
Как улучшить адаптер
Можем выделить два основных подхода к расширению — ViewType или делегаты. Остальные не упоминаю специально: по своей сути они будут похожи на второй подход.
Первый вариант — ViewType — можно использовать, когда приложение простое, то есть содержит один простой список и, например, пару экранов. Нам этот способ не подходит, потому что такие адаптеры нельзя переиспользовать. Если мы будем расширять адаптер, добавляя новые ViewType, адаптер будет неконтролируемо расти. Кроме того, под каждый экран нам придётся создавать свои адаптеры.
Второй подход — с делегатами — выглядит привлекательнее. Он позволяет не создавать разные адаптеры под каждый экран, а использовать делегаты. Четыре года назад об этом писал Ханс Дорфман, и на GitHub можно найти много библиотек с реализацией такого подхода. Мы будем использовать реализацию самого Дорфмана.
Смотрим на пример простого делегата, который отображает ProgressBar.
class LoadingDelegate :
AbsListItemAdapterDelegate<LoadingDelegate.Model, ListItem, LoadingDelegate.ViewHandler>() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHandler =
ViewHandler(LayoutInflater.from(parent.context).inflate(R.layout.view_loading, parent, false))
override fun isForViewType(item: ListItem, items: MutableList<ListItem>, position: Int): Boolean = item is Model
override fun onBindViewHolder(item: Model, holder: ViewHandler, payloads: MutableList<Any>) = Unit
data class Model(override val listId: String) : ListItem
class ViewHandler(itemView: View) : RecyclerView.ViewHolder(itemView)
}
interface ListItem {
val listId: String
fun calculatePayload(oldItem: ListItem): Any? = null
}
Внутри делегата, как и в стандартном адаптере, создаем ViewHolder. Происходит биндинг. Главное отличие от стандартного адаптера в том, что у каждого делегата есть своя модель. Она будет использоваться, чтобы отобразить нужный тип ячейки. В свою очередь, у каждой модели есть интерфейс ListItem с полем listId и методом calculatePayloads внутри.
Перейдём к реализации адаптера, который умеет отображать делегаты.
В этой реализации видно, зачем нужен интерфейс ListItem — его удобно использовать для ListDiffCallback, чтобы DiffUtil не обновлял ячейки, которые не изменились, и не запускал лишние анимации. Кроме того, так как для моделей используется Data class, нам из коробки доступен equals. Вся работа с DiffUtil сводится к правильному созданию модели делегата.
Под каждый экран адаптер создаётся так: мы в конструкторе передаём список делегатов, который экран должен поддерживать.
private val adapter by lazy {
DiffAdapter(
listOf(
EmptyDelegate(),
ErrorDelegate(),
LoadingDelegate(),
LoadMoreDelegate(),
CardDelegate()
)
)
}
Благодаря делегатам создание адаптера под каждый экран упрощается.
Как отображать картинки
Теперь уберём логику загрузки и отображения картинок из адаптера, разгрузим onBindViewHolder. Мы должны реализовать две сущности — модель картинки и делегат, который будет уметь загружать и отображать её. Рассмотрим пример модели, где загружаем картинку из ресурсов.
interface Image : Parcelable
@Parcelize
data class ResourceImage(
@DrawableRes val drawableRes: Int,
@ColorRes val colorRes: Int? = null
) : Image
Сначала сделаем интерфейс Image. Затем опишем набор параметров для ResourceImage, по которым хотим настраивать отображение. В данном случае — id ресурса картинки и цвета, если хотим её закрасить.
Теперь перейдём к делегату загрузки и определим его интерфейс. Отсюда понятно, зачем нам интерфейс Image.
interface ImageDisplayDelegate {
fun suitsFor(image: Image): Boolean
fun displayTo(image: Image, to: ImageView)
}
Каждый делегат должен уметь делать две вещи:
определять, умеет ли он отображать переданную картинку или нет;
отображать картинку в ImageView.
Так будет выглядеть делегат загрузки картинки из ресурсов.
class ResourceImagesDisplayDelegate : ImageDisplayDelegate {
override fun suitsFor(image: Image) = image is ResourceImage
override fun displayTo(image: Image, to: ImageView) {
Glide.with(to.context).clear(to)
with(image as ResourceImage) {
val drawable = ContextCompat.getDrawable(to.context, drawableRes)
colorRes?.let { drawable?.setTint(ContextCompat.getColor(to.context, it)) }
to.setImageDrawable(drawable)
}
}
}
Здесь:
метод suitsFor() проверяет, что image — ResourceImage;
внутри метода displayTo() мы устанавливаем картинку в ImageView и, если colorRes не null, то выставляем tint.
Это самый простой из возможных делегатов.
Как объединить делегаты
Объединим все поддерживаемые делегаты в одном месте и сократим интерфейс взаимодействия до метода displayTo().
class ImagesDisplayeDelegates : ImageDisplayer {
protected val delegates = listOf(
ResourceImagesDisplayDelegate(),
UriImageDisplayDelegate(),
LottieImageDelegate(),
CountryImageLoader(),
CurrencyImageDisplayDelegate(),
BitmapImageDelegate(),
GifResourseImageDisplayDelegate(),
CardImagesDisplayDelegate(),
GrayedOutImageDecoratorDisplayDelegate()
)
override fun displayTo(image: Image?, to: ImageView) {
if (image != null) {
// Begin
delegates.first { delegate -> delegate.suitsFor(image) }
.displayTo(image, to)
// End
} else {
to.setImageDrawable(null)
}
}
}
Обращаю внимание на строку 18. При помощи метода first() мы находим первый подходящий делегат для отображения картинки. Если нужный делегат не найден, возможен краш, и это не ошибка проектирования. Мы намеренно придерживаемся принципа fail-fast, чтобы быстро избавиться от неочевидного поведения. Например, когда картинка не отобразилась, а мы не знаем причину.
Как запустить трансформации
Разберёмся, зачем в отображении транзакции могут понадобиться трансформации. Предположим, у нас есть аватарка контакта или продавца, которую мы получаем из сети. Она может иметь любую форму и размер, но в приложении Revolut мы должны отобразить её круглой и определённого размера — 40х40 dp.
Настроим модель и добьёмся такого поведения.
Возьмём UrlImage. Любая картинка, которой нужна поддержка трансформаций, должна иметь соответствующие настройки. Можно ввести интерфейс TransformableImage со свойством transformations:
@Parcelize
data class UrlImage(
val url: String,
@DrawableRes val placeholder: Int? = null,
@DrawableRes val errorIcon: Int? = null,
override val transformations: ImageTransformations? = null
) : Image, TransformableImage
class UrlImagesDisplayDelegate() : ImageDisplayDelegate {
override fun suitsFor(image: Image) = image is UrlImage
override fun displayTo(image: Image, to: ImageView) {
if (image !is UrlImage) throw IllegalStateException("UrlImagesDisplayDelegate displays only UrlImages")
Glide.with(to.context).clear(to)
Glide.with(to.context)
.load(image.url)
.apply(
RequestOptions()
.error(image.errorIcon)
.placeholder(image.placeholder)
.applyImageTransformations(to.context.resources, image)
)
.into(to)
}
}
Класс настроек может выглядеть так:
@Parcelize
data class ImageTransformations(
val rotation: Int? = null,
val circle: Boolean = false,
val square: Boolean = false,
val centerCrop: Boolean = false,
@Dimension(unit = Dimension.DP) val radiusDp: Int? = null,
@Dimension(unit = Dimension.DP) val widthDp: Int? = null,
@Dimension(unit = Dimension.DP) val heightDp: Int? = null
) : Parcelable
Для отображения картинок используем Glide. Соответственно, трансформации ориентированы под эту библиотеку.
Так создаётся массив преобразований. Отмечу два момента, которые помогут избежать лишней работы.
Во-первых, поля помечены как nullable, и это позволяет задавать только нужные трансформации. Во-вторых, не очевидно, но критично, в каком порядке трансформации будут вызваны.
Представим, что на входе — очень широкая картинка, которую надо повернуть, отмасштабировать и скруглить. Сравним два сценария развития событий.
// Первый
add(RotationTransformer(degrees = 90))
add(CircleCrop())
add(GlideScaleTransformation(width = 100))
// Второй
add(GlideScaleTransformation(width = 100))
add(RotationTransformer(degrees = 90))
add(CircleCrop())
В первом случае сначала мы поворачиваем картинку на 90 градусов, затем закругляем и только после этого меняем ширину. Во втором случае мы сначала меняем ширину.
Второй сценарий эффективнее, так как поворот и скругление «дешевле» делать на меньших изображениях.
Вернёмся к реализации. Ранее мы создали массив, который теперь должны передать в Glide, когда он будет отображать картинку по URL. Создаём объект RequestOptions и передаём ему массив. Помним, что нельзя передавать пустой массив — Glide упадёт. Поэтому обязательно добавляем проверку.
val options = RequestOptions().apply {
val transformations = image.getGlideTransformsArray(resources)
if (transformations.isNotEmpty()) {
transforms(*transformations)
}
}
Glide.with(context)
.load(url)
.apply(options)
.into(imageView)
Так как будем переиспользовать трансформации в разных делегатах, будет удобно вынести их в экстеншн applyImageTransformations.
internal fun RequestOptions.applyImageTransformations(resources: Resources, image: TransformableImage): RequestOptions =
apply {
val transformations = image.getGlideTransformsArray(resources)
if (transformations.isNotEmpty()) {
transforms(*transformations)
}
}
fun getGlideRequestOptions(resources: Resources): RequestOptions =
RequestOptions().applyImageTransformations(resources, this)
Также добавляем метод в интерфейс TransformableImage — getGlideTransformsArray(). Сам интерфейс и экстеншен applyImageTransformations помечены как internal. Так мы избегаем утечки абстракции, и конечный пользователь моделей и делегатов не знает, что используется внутри — в публичных интерфейсах Glide не виден. Удобно, если захотим заменить Glide на другую библиотеку.
Посмотрим снова на наш список транзакций. Мы уже знаем, как работает адаптер делегатов. Теперь создадим делегат для отображения транзакции.
Базовая реализация адаптера выглядит так:
class ImageDelegate : BaseRecyclerViewDelegate<ImageDelegate.Model, ImageDelegate.Holder>(
viewType = R.layout.delegate_image,
rule = DelegateRule { _, data -> data is Model }
) {
override fun onCreateViewHolder(parent: ViewGroup) = Holder(parent.inflate(viewType))
override fun onBindViewHolder(holder: Holder, data: Model, pos: Int, payloads: List<Any>?) = Unit
data class Model(
override val listId: String
) : ListItem
class Holder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val imageView: ImageView = itemView.image
}
}
Отображение текста убрано для упрощения. Мы научим этот делегат отображать транзакции с картинками из сети, из ресурсов, показывать аватар контакта, который создаётся из инициалов.
Сначала модифицируем модель.
data class Model(
override val listId: String,
val resourceId: Int,
val url: String? = null,
val doubleLettersImage: String? = null
) : ListItem
В каждом случае передаём свои параметры, все в одном месте. Примерно так будет отображаться картинка:
override fun onBindViewHolder(holder: Holder, data: Model, pos: Int, payloads: List<Any>?) {
if (data.url != null) {
// Скачивание по URL и отображение
} else if (data.doubleLettersImage != null) {
// Создание битмапа из строки и отображение
} else {
holder.imageView.setImageResource(data.resourceId)
}
}
Сразу видим минусы:
такое решение тяжело расширять;
важен порядок, который в свою очередь может быть неочевидным;
бизнес-логика находится внутри адаптера (делегата).
Начнём сначала и попробуем использовать делегаты. Сделаем несколько доработок.
В модели вместо всех параметров оставляем только картинку на отображение:
class ImageDelegate(
//
private val imageDisplayer: ImageDisplayer
//
) : BaseRecyclerViewDelegate<ImageDelegate.Model, ImageDelegate.Holder>(
viewType = R.layout.delegate_image,
rule = DelegateRule { _, data -> data is Model }
) {
override fun onCreateViewHolder(parent: ViewGroup) = Holder(parent.inflate(viewType))
override fun onBindViewHolder(holder: Holder, data: Model, pos: Int, payloads: List<Any>?) {
//
imageDisplayer.displayTo(data.image, holder.imageView)
//
}
data class Model(
override val listId: String,
//
val image: Image
//
) : ListItem
class Holder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val imageView: ImageView = itemView.image
}
}
В итоге список транзакций примет такой вид:
listOf(
ImageDelegate.Model(image = ResourceImage(R.drawable.ic_no_avatar)),
ImageDelegate.Model(image = UrlImage("url to merchant")),
ImageDelegate.Model(image = DoubleLettersImage("EZ")),
ImageDelegate.Model(image = UrlImage("url to user avatar")),
)
Поведение становится более явным, и мы вынесли логику из адаптера.
Как создать делегат для генерируемой картинки
Теперь рассмотрим частный случай и создадим делегат, который будет генерировать изображение из двух символов. В первую очередь, определим требования к этому делегату. Очевидно, он должен уметь отображать буквы и настраивать отображение.
Модель будет выглядеть следующим образом:
@Parcelize
data class DoubleLettersImage(
val letters: String,
@ColorRes val textColor: Int = Color.GRAY,
@ColorRes val backgroundColor: Int = Color.TRANSPARENT,
val sizeInDp: Int = 40,
val textSizeInDp: Int = 40,
override val transformations: ImageTransformations? = null
) : Image, TransformableImage
Для настройки фона используем ImageTransformations.
Перейдём к генерации битмапы. Можем использовать обертку TextDrawable, где внутри отрисовка идёт при помощи Canvas. Далее эту битмапу нужно обработать и установить в ImageView.
За счёт использования экстеншена реализация делегата занимает пару строк. Покажу, как он работает.
Первый вариант, где заданы базовые настройки:
Во втором варианте добавляем трансформацию скругления:
И в третьем — поворачиваем картинку. Нам ничего не стоит отображать иконку аватара в том виде, в котором это требуется согласно дизайну:
Как создать кастомную трансформацию
Представим, что нам надо сделать флип по горизонтали. Сначала создадим каркас класса трансформации:
class FlipTransformation private constructor(
private val horizontal: Boolean
) : BitmapTransformation() {
override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap = TODO()
override fun updateDiskCacheKey(messageDigest: MessageDigest) = Unit
}
В случае с Glide базовый класс должен быть BitmapTransformation. Glide снова упрощает жизнь, так как содержит TransformationUtils с нужными методами. Остаётся лишь добавить эту трансформацию к остальным.
override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
val exifOrientation = if (horizontal) {
ExifInterface.ORIENTATION_FLIP_HORIZONTAL
} else {
ExifInterface.ORIENTATION_FLIP_VERTICAL
}
return TransformationUtils.rotateImageExif(pool, toTransform, exifOrientation)
}
if (transformations?.centerCrop == true) {
add(CenterCrop())
}
if (transformations?.flipHorizontal != null) {
add(FlipTransformation(transformations?.flipHorizontal))
}
transformations?.radiusDp?.let { radius ->
add(RoundedCorners(UiUtils.dpToPx(resources, radius.toFloat()).toInt()))
}
Как тестировать
Одна из главных причин, почему стоит использовать именно этот способ работы с картинками — тестирование. Нарисуем примерную схему архитектуры (clean) и покажем, как данные доходят до слоя UI. В качестве данных будем рассматривать список транзакций.
Получилась довольно стандартная схема. База данных возвращает список моделей, на уровне репозитория мы мапим их в модели доменного уровня. Тот, в свою очередь, передаст их на уровень выше — до UI. Каждый этап маппинга моделей покрывается тестами.
Рассмотрим, как может выглядеть доменная модель транзакции:
data class Transaction(
val id: String,
val amount: Money,
val date: DateTime,
val type: TransactionType
)
У неё есть доступ к id транзакции, сумме и дате. Как понять, что показывать — это денежный перевод или покупка в магазине? Откуда брать название, URL? Нам помогут sealed class.
sealed class TransactionType {
data class Transfer(
val contactName: String,
val contactAvatarUrl: String? = null
) : TransactionType()
data class CardPayment(
val merchantName: String,
val merchantRating: Double = 0.0,
val merchantLogoUrl: String? = null
) : TransactionType()
}
Здесь мы видим два типа транзакций — перевод и покупка. Каждый имеет уникальный набор параметров.
Далее разберёмся, что является моделью для слоя UI, и для этого вспомним, как выглядел наш делегат для адаптера RecyclerView.
class ImageDelegate(
private val imageDisplayer: ImageDisplayer
) : BaseRecyclerViewDelegate<ImageDelegate.Model, ImageDelegate.Holder>(
viewType = R.layout.delegate_image,
rule = DelegateRule { _, data -> data is Model }
) {
override fun onCreateViewHolder(parent: ViewGroup) = Holder(parent.inflate(viewType))
override fun onBindViewHolder(holder: Holder, data: Model, pos: Int, payloads: List<Any>?) {
imageDisplayer.displayTo(data.image, holder.imageView)
}
//
data class Model(
override val listId: String,
val image: Image
) : ListItem
//
class Holder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val imageView: ImageView = itemView.image
}
}
Модель делегата отлично подходит в качестве UI-модели.
Рассмотрим несколько сценариев, которые можем протестировать только за счёт использования делегатов для адаптера и картинок.
Кейс 1 — Перевод контакту без аватарки
Transaction(
id = "some_id",
amount = Money(100, Currency.GBP),
date = DateTime.parse("some_date"),
type = TransactionType.Transfer(
contactName = "Some Name"
)
)
// Should be mapped to:
ImageDelegate.Model(
listId = "some_id",
image = DoubleLettersImage(
letters = "SN",
transformations = ImageTransformations(
circle = true
)
)
)
Проверяем, создаётся ли модель картинки для отображения инициалов, если отсутствует URL аватара .
Кейс 2 — Перевод контакту с аватаркой
Transaction(
id = "some_id",
amount = Money(100, Currency.GBP),
date = DateTime.parse("some_date"),
type = TransactionType.Transfer(
contactName = "Some Name",
contactAvatarUrl = "some_url"
)
)
// Should be mapped to:
ImageDelegate.Model(
listId = "some_id",
image = UrlImage(
url = "some_url",
transformations = ImageTransformations(
circle = true
)
)
)
Ожидаем, что будет создана UrlImage с одной трансформацией.
Кейс 3 — Покупка в магазине, у которого в системе есть аватар
Transaction(
id = "some_id",
amount = Money(100, Currency.GBP),
date = DateTime.parse("some_date"),
type = TransactionType.CardPayment(
merchantName = "Netflix",
merchantLogoUrl = "some_url"
)
)
// Should be mapped to:
ImageDelegate.Model(
listId = "some_id",
image = UrlImage(
url = "some_url",
transformations = ImageTransformations(
circle = true
)
)
)
Идентично кейсу 2: ожидаем, что будет создана UrlImage с одной трансформацией.
Кейс 4 — Покупка в магазине без аватарки
Transaction(
id = "some_id",
amount = Money(100, Currency.GBP),
date = DateTime.parse("some_date"),
type = TransactionType.CardPayment(
merchantName = "Netflix"
)
)
// Should be mapped to:
ImageDelegate.Model(
listId = "some_id",
image = ResourceImage(
drawableRes = R.drawable.ic_no_avatar
)
)
В данном случае можем сделать дополнительную проверку: каждая покупка может относиться к разным категориям, и иконки будут отличаться. Можем также проверить, мапим ли каждую категорию в нужную иконку.
Выводы
Отображение картинок с помощью делегатов даёт несколько преимуществ.
Во-первых, мы освобождаем адаптер от логики, которой в нём быть не должно. Он не должен отвечать за выбор источника картинки в зависимости от набора параметров.
Во-вторых, мы абстрагировались от способа загрузки и обработки изображений. Теперь в любой момент времени мы почти без боли можем заменить Glide на что-то другое.
В-третьих, как следствие, мы можем тестировать отображение нужного типа картинки. То есть фактически тестировать отображение данных на экране.
Компания Proton открыла исходный код всех версий своего облачного хранилища Proton Drive, включая приложения для Windows, macOS, iOS и Android. Теперь пользователи и эксперты могут самостоятельно проверить безопасность приложений
Миниатюрный инструмент для пентестеров Flipper Zero получил крупное обновление до версии 1.0. В числе нововведений: увеличенная автономность, более быстрая передача данных через Bluetooth, ускоренное чтение NFC-карт и поддержка JavaScript для разработчиков