Илья Некрасов, Mahtalitet, android-разработчик KODE
За два с половиной года в андроид-разработке я успел поработать на совершенно разных проектах: от социальной сети для автомобилистов и латвийского банка до федеральной бонусной системы и третьей по перевозкам авиакомпании. Так или иначе в каждом из этих проектов я сталкивался с задачами, которые требовали поиска неклассических решений при реализации списков с помощью класса RecyclerView.
Эта статья — плод подготовки к выступлению на DevFest Kaliningrad’18, а также общения с коллегами — особенно будет полезна начинающим разработчикам и тем, кто использовал лишь одну из существующих библиотек.
Для начала копнем немного в суть вопроса и источника боли, а именно — разрастание функционала при разработке приложения и усложнения используемых списков.
Глава первая, в которой заказчик мечтает о приложении, а мы — о четких требованиях
Давайте представим ситуацию, когда в студию обращается заказчик, который хочет мобильное приложение магазина по продаже резиновых уточек.
Проект быстро развивается, новые идеи возникают регулярно и не оформлены в долгосрочный roadmap.
Сперва заказчик просит нас показывать список существующих товаров и при клике оформлять заявку на доставку. За решением ходить далеко не нужно: пользуемся классическим набором из RecyclerView, простым самописным адаптером для него и Activity.
Для адаптера мы используем однородные данные, один ViewHolder и простую логику биндинга.
class DucksClassicAdapter(
private val data: List<Duck>,
private val onDuckClickAction: (Duck) -> Unit
) : RecyclerView.Adapter<DucksClassicAdapter.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val rubberDuckView = LayoutInflater.from(parent.context).inflate(R.layout.item_rubber_duck, parent, false)
return ViewHolder(rubberDuckView)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val duck = data[position]
holder.divider.isVisible = position != 0
holder.rubberDuckImage.apply {
Picasso.get()
.load(duck.icon)
.config(Bitmap.Config.ARGB_4444)
.fit()
.centerCrop()
.noFade()
.placeholder(R.drawable.duck_stub)
.into(this)
}
holder.clicksHolder.setOnClickListener { onDuckClickAction.invoke(duck) }
}
override fun getItemCount() = data.count()
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val rubberDuckImage: ImageView = view.findViewById(R.id.rubberDuckImage)
val clicksHolder: View = view.findViewById(R.id.clicksHolder)
val divider: View = view.findViewById(R.id.divider)
}
}
Со временем у заказчика появляется идея добавить к резиновым уточкам другую категорию товаров, а значит, придется добавить новую модель данных и новый макет. Но самое главное, что в адаптере появится еще один ViewType, с помощью которого можно определить, какой ViewHolder использовать для конкретного элемента списка.
После этого к категориям добавляются заголовки, по которым каждую категорию можно свернуть и развернуть для упрощения ориентации пользователей в магазине. Это плюс ещё один ViewType и ViewHolder для заголовков. Кроме того, придется усложнить адаптер, так как надо хранить список открытых групп и с помощью него проверять необходимость скрытия и отображения того или иного элемента по нажатию на заголовок.
class DucksClassicAdapter(
private val onDuckClickAction: (Pair<Duck, Int>) -> Unit,
private val onSlipperClickAction: (Duck) -> Unit,
private val onAdvertClickAction: (Advert) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var data: List<Duck> = emptyList()
set(value) {
field = value
internalData = data.groupBy { it.javaClass.kotlin }
.flatMap { groupedDucks ->
val titleRes = when (groupedDucks.key) {
DuckSlipper::class -> R.string.slippers
RubberDuck::class -> R.string.rubber_ducks
else -> R.string.mixed_ducks
}
groupedDucks.value.let { listOf(FakeDuck(titleRes, it)).plus(it) }
}
.toMutableList()
duckCountsAdapters = internalData.map { duck ->
val rubberDuck = (duck as? RubberDuck)
DucksCountAdapter(
data = (1..(rubberDuck?.count ?: 1)).map { count -> duck to count },
onCountClickAction = { onDuckClickAction.invoke(it) }
)
}
}
private val advert = DuckMockData.adverts.orEmpty().shuffled().first()
private var internalData: MutableList<Duck> = mutableListOf()
private var duckCountsAdapters: List<DucksCountAdapter> = emptyList()
private var collapsedHeaders: MutableSet<Duck> = hashSetOf()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_RUBBER_DUCK -> {
val view = parent.context.inflate(R.layout.item_rubber_duck, parent)
DuckViewHolder(view)
}
VIEW_TYPE_SLIPPER_DUCK -> {
val view = parent.context.inflate(R.layout.item_duck_slipper, parent)
SlipperViewHolder(view)
}
VIEW_TYPE_HEADER -> {
val view = parent.context.inflate(R.layout.item_header, parent)
HeaderViewHolder(view)
}
VIEW_TYPE_ADVERT -> {
val view = parent.context.inflate(R.layout.item_advert, parent)
AdvertViewHolder(view)
}
else -> throw UnsupportedOperationException("view type $viewType without ViewHolder")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderViewHolder -> bindHeaderViewHolder(holder, position)
is DuckViewHolder -> bindDuckViewHolder(holder, position)
is SlipperViewHolder -> bindSlipperViewHolder(holder, position)
is AdvertViewHolder -> bindAdvertViewHolder(holder)
}
}
private fun bindAdvertViewHolder(holder: AdvertViewHolder) {
holder.advertImage.showIcon(advert.icon)
holder.advertTagline.text = advert.tagline
holder.itemView.setOnClickListener { onAdvertClickAction.invoke(advert) }
}
private fun bindHeaderViewHolder(holder: HeaderViewHolder, position: Int) {
val item = getItem(position) as FakeDuck
holder.clicksHolder.setOnClickListener { changeCollapseState(item, position) }
val arrowRes = if (collapsedHeaders.contains(item))
R.drawable.ic_keyboard_arrow_up_black_24dp
else
R.drawable.ic_keyboard_arrow_down_black_24dp
holder.arrow.setImageResource(arrowRes)
holder.title.setText(item.titleRes)
}
private fun changeCollapseState(item: FakeDuck, position: Int) {
val isCollapsed = collapsedHeaders.contains(item)
if (isCollapsed) {
collapsedHeaders.remove(item)
} else {
collapsedHeaders.add(item)
}
// 1 to add items after header
val startPosition = position + 1
if (isCollapsed) {
internalData.addAll(startPosition - ADVERTS_COUNT, item.items)
notifyItemRangeInserted(startPosition, item.items.count())
} else {
internalData.removeAll(item.items)
notifyItemRangeRemoved(startPosition, item.items.count())
}
notifyItemChanged(position)
}
@SuppressLint("SetTextI18n")
private fun bindSlipperViewHolder(holder: SlipperViewHolder, position: Int) {
val slipper = getItem(position) as DuckSlipper
holder.duckSlipperImage.showIcon(slipper.icon)
holder.duckSlipperSize.text = "Размер: ${slipper.size}"
holder.clicksHolder.setOnClickListener { onSlipperClickAction.invoke(slipper) }
}
private fun bindDuckViewHolder(holder: DuckViewHolder, position: Int) {
val duck = getItem(position) as RubberDuck
holder.rubberDuckImage.showIcon(duck.icon)
holder.rubberDuckCounts.adapter = duckCountsAdapters[position - ADVERTS_COUNT]
val context = holder.itemView.context
holder.rubberDuckCounts.layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
}
override fun getItemViewType(position: Int): Int {
if (position == 0) return VIEW_TYPE_ADVERT
return when (getItem(position)) {
is FakeDuck -> VIEW_TYPE_HEADER
is RubberDuck -> VIEW_TYPE_RUBBER_DUCK
is DuckSlipper -> VIEW_TYPE_SLIPPER_DUCK
else -> throw UnsupportedOperationException("unknown type for $position position")
}
}
private fun getItem(position: Int) = internalData[position - ADVERTS_COUNT]
override fun getItemCount() = internalData.count() + ADVERTS_COUNT
class DuckViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val rubberDuckImage: ImageView = view.findViewById(R.id.rubberDuckImage)
val rubberDuckCounts: RecyclerView = view.findViewById(R.id.rubberDuckCounts)
}
class SlipperViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val duckSlipperImage: ImageView = view.findViewById(R.id.duckSlipperImage)
val duckSlipperSize: TextView = view.findViewById(R.id.duckSlipperSize)
val clicksHolder: View = view.findViewById(R.id.clicksHolder)
}
class HeaderViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = view.findViewById(R.id.headerTitle)
val arrow: ImageView = view.findViewById(R.id.headerArrow)
val clicksHolder: View = view.findViewById(R.id.clicksHolder)
}
class AdvertViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val advertTagline: TextView = view.findViewById(R.id.advertTagline)
val advertImage: ImageView = view.findViewById(R.id.advertImage)
}
}
private class FakeDuck(
val titleRes: Int,
val items: List<Duck>
) : Duck
private fun ImageView.showIcon(icon: String, placeHolderRes: Int = R.drawable.duck_stub) {
Picasso.get()
.load(icon)
.config(Bitmap.Config.ARGB_4444)
.fit()
.centerCrop()
.noFade()
.placeholder(placeHolderRes)
.into(this)
}
private class DucksCountAdapter(
private val data: List<Pair<Duck, Int>>,
private val onCountClickAction: (Pair<Duck, Int>) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = parent.context.inflate(R.layout.item_duck_count, parent)
return CountViewHolder(view)
}
override fun getItemCount() = data.count()
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
(holder as CountViewHolder).count.apply {
val item = data[position]
text = item.second.toString()
setOnClickListener { onCountClickAction.invoke(item) }
}
}
class CountViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val count: TextView = view.findViewById(R.id.count)
}
}
Думаю, суть вы улавливаете — такое нагромождение мало напоминает здоровую разработку. А впереди все новые и новые требования от заказчика: закрепить рекламный баннер в начале списка, реализовать возможность выбора количества заказываемых уточек. Только эти задачи в конечном счете превратятся в очередные адаптеры, которые вновь придется писать с нуля.
Процесс разработки классического адаптера в истории гитхаба
Итоги
По факту картина совсем не воодушевляет: отдельные адаптеры приходится затачивать под конкретные кейсы. Мы все понимаем, что в реальном приложении таких экранов-списков бывают десятки, а то и сотни. И содержат они не информацию об уточках, а более сложные данные. Да еще и дизайн у них намного сложнее.
Что не так с нашими адаптерами?
- очевидно, их трудно переиспользовать;
- внутри появляется бизнес-логика и со временем ее становится всё больше;
- трудно поддерживать и расширять;
- высокий риск ошибок при обновлении данных;
- неочевидная конструкция.
Глава вторая, в которой все могло быть иначе
Представить развитие приложения на годы вперед нереально, да и бессмысленно. После пары таких танцев с бубном как в прошлой главе и написания десятков адаптеров у любого возникнет вопрос “Может, есть другие решения?”.
Прошерстив Github, обнаруживаем, что еще в 2015 году появилась первая библиотека AdapterDelegates, а уже через год арсенал разработчиков пополнили Groupie и Epoxy — все они помогают облегчить жизнь, но в каждой есть своя специфика и подводные камни.
Есть еще несколько похожих библиотек (например, FastAdapter), но ни я, ни мои коллеги с ними не работали, поэтому не будем их рассматривать в статье.
Прежде чем сравнивать библиотеки, кратко разберем вышеописанный кейс с онлайн-магазином при условии использования AdapterDelegates — из разбираемых библиотек она наиболее простая с точки зрения внутренней реализации и использования (впрочем, она не во всем продвинутая, поэтому многое приходится дописывать руками).
Библиотека от адаптера нас полностью не избавит, но он будет формироваться из блоков (кирпичиков), которые мы безболезненно можем добавлять в список или убирать из него и менять их местами.
class DucksDelegatesAdapter : ListDelegationAdapter<List<DisplayableItem>>() {
init {
delegatesManager.addDelegate(RubberDuckDelegate())
}
fun setData(items: List<DisplayableItem>) {
this.items = items
notifyDataSetChanged()
}
}
private class RubberDuckDelegate : AbsListItemAdapterDelegate<RubberDuckItem, DisplayableItem, RubberDuckDelegate.ViewHolder>() {
override fun isForViewType(item: DisplayableItem, items: List<DisplayableItem>, position: Int): Boolean {
return item is RubberDuckItem
}
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val item = parent.context.inflate(R.layout.item_rubber_duck, parent, false)
return ViewHolder(item)
}
override fun onBindViewHolder(item: RubberDuckItem, viewHolder: ViewHolder, payloads: List<Any>) {
viewHolder.apply {
rubberDuckImage.showIcon(item.icon)
}
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val rubberDuckImage: ImageView = itemView.findViewById(R.id.rubberDuckImage)
}
}
class DucksDelegatesActivity : AppCompatActivity() {
private lateinit var ducksList: RecyclerView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_ducks_delegates)
ducksList = findViewById(R.id.duckList)
ducksList.apply {
layoutManager = LinearLayoutManager(this@DucksDelegatesActivity)
adapter = createAdapter().apply { showData() }
}
}
fun createAdapter(): DucksDelegatesAdapter {
return DucksDelegatesAdapter()
}
private fun DucksDelegatesAdapter.showData() {
setData(getRubberDucks())
}
private fun getRubberDucks(): List<DisplayableItem> {
return DuckMockData.ducks.orEmpty().map {
RubberDuckItem(it.icon)
}
}
}
Уже с первой задачи мы видим разницу: у нас появляется класс адаптера, который наследуется от библиотеки. И в дополнение — тот самый кирпичик, который называется делегатом и от которого мы также наследуемся и реализуем часть необходимой нам логики. Дальше мы добавляем делегат в менеджер — это тоже класс библиотеки. И последнее, что нужно — создать адаптер и заполнить его данными
Для реализации второй категории магазина и заголовков напишем еще пару делегатов, а анимация появляется благодаря классу DiffUtil.
Здесь обозначу краткий, но категоричный вывод: использование даже этой библиотеки решает все перечисленные проблемы, которые возникали у нас при усложнении приложения в кейсе с онлайн-магазином, но без минусов никуда, и о них дальше.
Процесс разработки адаптера с AdapterDelegates в истории гитхаба
Глава третья, в которой разработчик снимает розовые очки, сравнив библиотеки
Погрузимся подробнее в функционал и работу каждой из библиотек. Все три библиотеки я так или иначе применял на наших проектах, в зависимости от задач и сложности приложения.
AdapterDelegates
Эту библиотеку мы используем в приложении одной из крупнейших российских авиакомпаний. Нам потребовалось заменить простой список оплаты на список с группами и большим количеством различных параметров.
Упрощенно схема работы библиотеки выглядит вот так:
Основной класс — это DelegateAdapter, различные “кирпичики” — это “делегаты”, которые отвечают за отображение определенного типа данных и, конечно, сам список.
Плюсы:
- простота погружения;
- легко переиспользовать адаптеры;
- мало методов и классов;
- никакой рефлексии, кодогенерации и датабиндинга.
Минусы:
- необходимо самому реализовывать логику, например
обновление элементов через DiffUtil (с версии 3.1.0 можно использовать адаптер AsyncListDifferDelegationAdapter); - избыточный код.
В целом, эта библиотека решает все основные сложности при расширении функционала приложения и подойдет тем, кто ранее библиотеки не применял. Но останавливаться только на ней я не советую.
Groupie
Groupie, созданную несколько лет назад Lisa Wray, мы используем часто, в том числе полностью с помощью нее написали приложение для одного латвийского банка.
Для того, чтобы использовать эту библиотеку, в первую очередь нужно разобраться с зависимостями. В дополнение к основной можно использовать несколько вариантов на выбор:
- расширение для Kotlin Android Extensions;
- расширение для Android Data Binding;
- и то, и другое.
Останавливаемся на чём-то одном и прописываем необходимые зависимости.
На примере онлайн-магазина с уточками нам нужно создать Item, унаследованный от класса библиотеки, указать макет и реализовать биндинг через котлиновские синтентики. Если сравнивать с количеством кода, который пришлось написать с AdapterDelegates, это просто небо и земля.
Всё, что остается — задать в качестве адаптера RecyclerView GroupieAdapter, и положить в него смапленные айтемы.
Видно, что схема работы больше и сложнее. Здесь кроме простых айтемов можно использовать целые секции — группы айтемов и другие классы.
Плюсы:
- понятный интерфейс, хотя api заставляет подумать;
- наличие коробочных решений;
- разбивка на группы элементов;
- выбор между обычным вариантом, Kotlin Extensions и DataBinding;
- встраивание ItemDecoration и анимации.
Минусы:
- неполное wiki;
- слабая поддержка мейнтейнером и сообществом;
- небольшие баги, которые приходилось обходить в первой версии;
- diffing в main thread (на данный момент);
- нет поддержки AndroidX (на данный момент, но нужно следить за репозиторием).
Важно, что Groupie при всех своих минусах способна легко заменить AdapterDelegates, особенно если вы планируете делать сворачивающиеся списки первого уровня, и не хочется писать много бойлерплейта.
Реализация списка уточек с помощью Groupie
Epoxy
Последняя библиотека, которую мы стали применять сравнительно недавно — это Epoxy, разработанная ребятами из Airbnb. Библиотека сложная, но позволяет решать целый скоуп задач. Сами программисты Airbnb используют ее для рендера экранов прямо с сервера. Нам Epoxy пригодилась на одном из свежих проектов — приложении для банка в Екатеринбурге.
Чтобы разработать экраны, нам пришлось работать с разными видами данных, огромным числом списков. А один из экранов был прямо-таки нескончаемым. И с этим всем нам помогла справиться Epoxy.
Принцип работы библиотеки в целом похож на две предыдущие, за исключением того, что вместо адаптера для построения списка используется EpoxyController, который позволяет декларативно определить структуру адаптера.
Чтобы этого добиться, библиотека построена на кодогенерации. Как это работает — со всеми нюансами неплохо описано в wiki и отражено в семплах.
Плюсы:
- модели для списка, сгенерированные из обычных View, с возможностью переиспользования в простые экраны;
- декларативное описание экранов;
- DataBinding на максималках — генерирует модели прямо из layout файлов;
- просто отображать из блоков не только списки, но и сложные экраны;
- общий ViewPool на Activity;
- асинхронный diffing из коробки (AsyncEpoxyController);
- не нужно запариваться с горизонтальными списками.
Минусы:
- здоровенная куча классов, процессоры, аннотации;
- сложное погружение с нуля;
- использует ButterKnife-плагин для генерации R2-файлов в модулях;
- очень сложно понять, как правильно работать с Callback’ами (мы сами ещё не поняли);
- есть проблемы, которые нужно обходить: например, падение при одинаковых id.
Реализация списка уточек с помощью Epoxy
Итоги
Главное, что я хотел донести: не стоит мириться со сложностью, которая появляется, когда нужно делать сложные списки и постоянно приходится их переделывать. А такое случается очень часто. Да и в принципе при их реализации, если проект только стартует, или вы занимаетесь его рефакторингом.
Реальность такова, что не стоит лишний раз усложнять логику, думая, что хватит каких-то собственных абстракций. Их хватает ненадолго А работать с ними не только не доставляет удовольствия, так еще и остается соблазн перенести часть логику в UI-часть, которойу там быть не должно. Есть инструменты, которые помогут избежать большинства проблем, и ими нужно пользоваться.
Понимаю, что для многих опытных (и не только) разработчиков это либо очевидно, либо они могут со мной не согласиться. Но считаю важным еще раз сделать на этом акцент.
Так, а что выбрать-то?
Советовать остановиться на одной библиотеке довольно трудно, потому что выбор зависит от многих факторов: от личных предпочтений до идеологии на проекте.
Я бы поступил следующим образом:
- Если вы только начинаете путь в разработке, попробуйте начать на небольшом проекте с AdapterDelegates — это самая простая библиотека, — особых знаний не потребуется. Поймете, как с этим работать и почему это удобнее, чем писать адаптеры самому.
- Groupie подойдет тем, кто уже наигрался с AdapterDelegates и ему надоело писать кучу бойлерплейта, либо всем остальным, кто сразу хочет начать с золотой середины. И не забываем про наличие сворачивающихся групп из коробки — это тоже неплохой аргумент в ее пользу.
- Ну и Epoxy — для тех, кто столкнулся с по-настоящему сложным проектом, с огромным количеством данных, так что сложность c жирностью библиотеки будет меньшей проблемой. Поначалу будет тяжело, зато дальше реализация списков покажется плевым делом. Важным аргументом в пользу Epoxy может стать наличие на проекте DataBinding'а и MVVM — она буквально создана для этого, учитывая возможность генерации моделей из соответствующих макетов.
Если у вас остались вопросы, то можно заглянуть по ссылке, чтобы еще раз посмотреть код нашего приложения с уточками.
Автор: APPKODE