Разработка под Android. Немного о быстрой работе со списками
Всем привет! Если вы разработчик, который еще не сформировал для себя алгоритм для построения списков — вам может оказаться полезным почитать этот материал. Я (см. источник) хотел бы предложить здесь готовое решение для разработки, раскрывая в ходе повествования какие-то мысли о том, как я до них докатился к этому пришел.
В этой статье:
- Сформируем несколько базовых классов и интерфейсов для работы с RecyclerView и RecyclerView.Adapter.
- Подключим одну библиотеку из Android Jetpack (по желанию, сначала без нее).
- Для еще более быстрой разработки — вариант темплейта в конце статьи. ;-)
Вступление
Ну что ж! Все уже забыли про ListView и благополучно пишут на RecyclerView (RV). Те времена, когда мы реализовывали сами паттерн ViewHolder, канули в небытие. RV предоставляет нам набор готовых классов для реализации списков и достаточно большой выбор LayoutManager'ов для их отображения. По сути, глядя на множество экранов, списком можно представить большинство из них — именно благодаря возможности для каждого элемента реализовать свой ViewHolder. Более подробно историю развития нам рассказали на Google I/O 2018.
Но всегда есть пара «но»!.. Стандартные ответы на Stackoverflow подсказывают общие решения, которые приводят к копипасте, особенно в месте реализации Adapter'a.
На данный момент (сентябрь 2018) RV уже три года. Инфы по нему туча, и есть много библиотек с готовыми решениями, но что делать, если вам не нужен весь функционал или если вы залазите поглядеть на чужой код — и видите там Древний Ужас не то, что хотели бы видеть, или не то, что вообще себе представляли? За эти три года Android наконец-таки официально принял к себе Kotlin = улучшилась читаемость кода, по RV вышло немало интересных статей, которые в полной мере раскрывают его возможности.
Цель этой — собрать из лучших практик свой велосипед основу, каркас по работе со списками для новых приложений. Этот каркас можно дополнять логикой от приложения к приложению, используя то, что вам необходимо, и отбрасывая лишнее. Я считаю, такой подход намного лучше чужой библиотеки — в своих классах вы имеете возможность разобраться с тем, как все работает, и проконтролировать кейсы, в которых нуждаетесь, не завязываясь на чужом решении.
Давайте мыслить логично и с самого начала
Решать то, что должен делать компонент, будет интерфейс, а не класс, но конкретную логику реализации в конце замкнем на классе, который будет этот интерфейс имплементировать и реализовывать. Но если получится так, что при реализации интерфейса образуется копипаста, — мы можем спрятать ее за абстрактным классом, а после него — класс, который наследуется от абстрактного. Я покажу свою реализацию базовых интерфейсов, но моя цель состоит в том, чтобы разработчик просто попробовал думать в этом же направлении. Еще раз — план такой:
Набор интерфейсов -> абстрактный класс, забирающий копипасту (если это нужно) -> и уже конкретный класс с уникальным кодом.
Реализацию интерфейсов Вы можете выполнить по-другому.
Что может делать со списком адаптер? Ответ на этот вопрос легче всего получить, когда смотришь на какой-то пример. Можно заглянуть в RecyclerView.Adapter, вы найдете пару подсказок. Если же немного подумать, то можно представить примерно такие методы:
interface IBaseListAdapter<T> { fun add(newItem: T) fun add(newItems: ArrayList<T>?) fun addAtPosition(pos : Int, newItem : T) fun remove(position: Int) fun clearAll() }
* Перебирая проекты, я нашел несколько других методов, которые здесь опущу, например getItemByPos(position: Int) или даже subList(startIndex: Int, endIndex: Int). Повторюсь: вы сами должны смотреть, что вам нужно от проекта, и включать функции в интерфейс. Это не сложно, когда знаешь, что все происходит в одном классе. Аскетизм в данном вопросе позволит избавиться от лишней логики, которая ухудшает читаемость кода, потому что конкретная реализация занимает больше строк.
Обратите внимание на дженерик T. В общем случае адаптер работает с любым объектом списка (item), поэтому здесь нет уточнения — мы еще не выбрали наш подход. А в этой статье их будет как минимум два. Первый интерфейс выглядит так:
interface IBaseListItem { fun getLayoutId(): Int }
Ну да, кажется логичным — мы же говорим об элементе списка, значит у каждого элемента должен быть какой-то лейаут, а сослаться на него можно с помощью layoutId. Больше ничего начинающему разработчику, скорее всего, не понадобится, если, конечно, не брать более продвинутые подходы. Если же у вас хватает опыта в разработке, можно, конечно, сделать делегат или обертку, но стоит ли оно того при небольшом проекте — и еще меньшем опыте разработки? Все мои ссылки куда-то в ютуб очень полезны, но если у вас сейчас нет времени — просто запомните их и читайте дальше, потому что здесь подход попроще — я считаю, что при стандартной работе с RV, судя по официальной документации, того, что предлагается выше, не подразумевается.
Пора объединить наш IBaseListAdapter с интерфейсами, и следующий класс будет абстрактным:
abstract class SimpleListAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>(), IBaseListAdapter<IBaseListItem> { protected val items: ArrayList<IBaseListItem> = ArrayList() override fun getItemCount() = items.size override fun getItemViewType(position: Int) = items[position].layoutId protected fun inflateByViewType(context: Context?, viewType: Int, parent: ViewGroup) = LayoutInflater.from(context).inflate(viewType, parent, false) override fun add(newItem: IBaseListItem) { items.add(newItem) notifyDataSetChanged() } override fun add(newItems: ArrayList<IBaseListItem>?) { for (newItem in newItems ?: return) { items.add(newItem) notifyDataSetChanged() } } override fun addAtPosition(pos: Int, newItem: IBaseListItem) { items.add(pos, newItem) notifyDataSetChanged() } override fun clearAll() { items.clear() notifyDataSetChanged() } override fun remove(position: Int) { items.removeAt(position) notifyDataSetChanged() } }
* Примечание: Обратите внимание на переопределенную функцию getItemViewType(position: Int). Нам нужен некий интовый ключ, по которому RV поймет, какой ViewHolder нам подходит. Для этого отлично пригодится val layoutId у нашего item, т.к. Android каждый раз услужливо делает id лейаутов уникальными и больше нуля — этим мы и воспользуемся далее, «надувая» itemView для наших вьюхолдеров в методе inflateByViewType() (следующая строка).
Создаем список
Возьмем для примера экран настроек. Андроид предлагает нам свой вариант, но что если по дизайну понадобится что-то более изощренное? Я предпочитаю наполнять этот экран как список. Тут будет приведен такой кейс:
Мы видим два разных элемента списка, значит SimpleListAdapter и RV тут прекрасно подойдут!
Приступим! Можно начать с верстки лейаутов для item'ов:
item_info.xml
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="56dp"> <TextView android:id="@+id/tv_info_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:layout_marginStart="28dp" android:textColor="@color/black" android:textSize="20sp" tools:text="Balance" /> <TextView android:id="@+id/tv_info_value" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical|end" android:layout_marginEnd="48dp" tools:text="1000 quot; /> </FrameLayout>
item_switch.xml
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"; xmlns:tools="http://schemas.android.com/tools"; android:layout_width="match_parent" android:layout_height="56dp"> <TextView android:id="@+id/tv_switch_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:layout_marginStart="28dp" android:textColor="@color/black" android:textSize="20sp" tools:text="Send notifications" /> <Switch android:id="@+id/tv_switch_value" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical|end" android:layout_marginEnd="48dp" tools:checked="true" /> </FrameLayout>
Затем определяем сами классы, внутрь которых мы хотим передать значения, которые взаимодействуют со списком: первый — это заголовок и какое-либо значение, пришедшее извне (у нас будет заглушка, о запросах в другой раз), второй — это заголовок и boolean переменная, по нажатию на него мы должны выполнить действие. Чтобы различить Switch элементы, подойдут id сущностей с сервера, если же их нет — мы можем создать их сами при инициализации.
class InfoItem(val title: String, val value: String): IBaseListItem { override val layoutId = R.layout.item_info }
class SwitchItem( val id: Int, val title: String, val actionOnReceive: (itemId: Int, userChoice: Boolean) -> Unit ) : IBaseListItem { override val layoutId = R.layout.item_switch }
В простой реализации каждому элементу также понадобится ViewHolder:
class InfoViewHolder.kt(view: View) : RecyclerView.ViewHolder(view) { val tvTitle = view.tv_info_title val tvValue = view.tv_info_value }
class SwitchViewHolder.kt(view: View) : RecyclerView.ViewHolder(view) { val tvTitle = view.tv_switch_title val tvValue = view.tv_switch_value }
Ну и самая интересная часть — конкретная реализация SimpleListAdapter'a:
class SettingsListAdapter : SimpleListAdapter() { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): RecyclerView.ViewHolder { val context = parent.context return when (viewType) { R.layout.item_info -> InfoHolder(inflateByViewType(context, viewType, parent)) R.layout.item_switch -> SwitchHolder(inflateByViewType(context, viewType, parent)) else -> throw IllegalStateException("There is no match with current layoutId") } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { is InfoHolder -> { val infoItem = items[position] as InfoItem holder.tvTitle.text = infoItem.title holder.tvValue.text = infoItem.value } is SwitchHolder -> { val switchItem = items[position] as SwitchItem holder.tvTitle.text = switchItem.title holder.tvValue.setOnCheckedChangeListener { _, isChecked -> switchItem.actionOnReceive.invoke(switchItem.id, isChecked) } } else -> throw IllegalStateException( "There is no match with current holder instance") } } }
* Примечание: Не забывайте про то, что под капотом метода inflateByViewType(context, viewType, parent): viewType = layoutId.
Все составляющие готовы! Теперь остается код Активити, и можно запускать программу.
activity_settings.xml
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"; android:layout_width="match_parent" android:layout_height="match_parent"> <android.support.v7.widget.RecyclerView android:id="@+id/rView" android:layout_width="match_parent" android:layout_height="match_parent" /> </FrameLayout>
class SettingsActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_settings) val adapter = SettingsListAdapter() rView.layoutManager = LinearLayoutManager(this) rView.adapter = adapter adapter.add(InfoItem("User Name", "Leo Allford")) adapter.add(InfoItem("Balance", "350 quot;)) adapter.add(InfoItem("Tariff", "Business")) adapter.add(SwitchItem(1, "Send Notifications") { itemId, userChoice -> onCheck(itemId, userChoice) }) adapter.add(SwitchItem(2, "Send News on Email") { itemId, userChoice -> onCheck(itemId, userChoice) }) } private fun onCheck(itemId: Int, userChoice: Boolean) { when (itemId) { 1 -> Toast.makeText(this, "Notification now set as $userChoice", Toast.LENGTH_SHORT).show() 2 -> Toast.makeText(this, "Send news now set as $userChoice", Toast.LENGTH_SHORT).show() } } }
В итоге, при построении списка вся работа сводится к следующему:
1. Вычислить количество разных лейаутов для итемов.
2. Подобрать им названия. Я пользуюсь правилом: SomethingItem.kt, item_something.xml, SomethingViewHolder.kt.
3. Пишем к этим классам адаптер. В принципе, если вы не претендуете на оптимизацию, то хватит одного общего адаптера. Но в больших проектах я бы все же сделал несколько, по экранам, потому что в первом случае неизбежно разрастается метод onBindViewHolder() (страдает читаемость кода) в вашем адаптере (в нашем случае это SettingsListAdapter) + программе придется каждый раз, для каждого итема, пробегаться по этому методу + по методу onCreateViewHolder().
4. Запускаем код и радуемся!
JetPack
До этого момента мы применяли стандартный подход привязки данных из Item.kt — к нашему item_layout.xml. Но мы можем унифицировать метод onBindViewHolder(), оставить его минимальным, а логику перенести в Item и лейаут.
Зайдем на официальную страницу Android JetPack:
Обратим внимание на первую вкладку в разделе Architecture. Android Databinding — очень обширная тема, я бы хотел поговорить об ней более подробно в других статьях, но сейчас воспользуемся только в рамках текущей — мы сделаем нашу Item.kt — variable для item.xml (или можете назвать ее вьюмоделью для лейаута).
На момент написания статьи Databinding можно было подключить вот так:
android { compileSdkVersion 27 defaultConfig {...} buildTypes {...} dataBinding { enabled = true } dependencies { kapt "com.android.databinding:compiler:3.1.3" //... } }
Пройдемся заново по базовым классам. Интерфейс для итема дополняет предыдущий:
interface IBaseItemVm: IBaseListItem { val brVariableId: Int }
Также мы расширим наш ViewHolder, потому связались с датабайдингом. Будем передавать в него ViewDataBinding, после чего благополучно забудем о создании лейаута и привязке данных.
class VmViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root)
Такой же подход используется здесь, но на котлине это выглядит намного короче, не правда ли? =)
class VmListAdapter : RecyclerView.Adapter<VmViewHolder>(), IBaseListAdapter<IBaseItemVm> { private var mItems = ArrayList<IBaseItemVm>() override fun getItemCount() = mItems.size override fun getItemViewType(position: Int) = mItems[position].layoutId override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VmViewHolder { val inflater = LayoutInflater.from(parent.context) val viewDataBinding = DataBindingUtil.inflate<ViewDataBinding>(inflater!!, viewType, parent, false) return VmViewHolder(viewDataBinding) } override fun onBindViewHolder(holder: VmViewHolder, position: Int) { holder.binding.setVariable(mItems[position].brVariableId, mItems[position]) holder.binding.executePendingBindings() } override fun add(newItem: IBaseItemVm) { mItems.add(newItem) notifyItemInserted(mItems.lastIndex) } override fun add(newItems: ArrayList<IBaseItemVm>?) { val oldSize = mItems.size mItems.addAll(newItems!!) notifyItemRangeInserted(oldSize, newItems.size) } override fun clearAll() { mItems.clear() notifyDataSetChanged() } override fun getItemId(position: Int): Long { val pos = mItems.size - position return super.getItemId(pos) } override fun addAtPosition(pos: Int, newItem: IBaseItemVm) { mItems.add(pos, newItem) notifyItemInserted(pos) } override fun remove(position: Int) { mItems.removeAt(position) notifyItemRemoved(position) } }
Обратите внимание в целом на методы onCreateViewHolder(), onBindViewHolder(). Задумка в том, чтобы они больше не разрастались. Итого, вы получаете один адаптер для любого экрана, с любыми элементами списка.
Наши items:
class InfoItem(val title: String, val value: String) : IBaseItemVm { override val brVariableId = BR.vmInfo override val layoutId = R.layout.item_info }
class SwitchItem( val id: Int, val title: String, private val actionOnReceive: (itemId: Int, userChoice: Boolean) -> Unit ) : IBaseItemVm { override val brVariableId = BR.vmSwitch override val layoutId = R.layout.item_switch val listener = CompoundButton.OnCheckedChangeListener { _, isChecked -> actionOnReceive.invoke(id, isChecked) } }
Здесь становится видно, куда делась логика метода onBindViewHolder(). Ее взял на себя Android Databinding — теперь любой наш лейаут подкреплен своей вьюмоделью, и она спокойно обработает всю логику нажатий, анимаций, запросов и прочего. Что вы сами придумаете. В этом хорошо помогут Binding Adapters — позволив связать вью с данными любого рода. Также связь возможно улучшить благодаря двустороннему датабайдингу. Наверное, он промелькнет в какой-нибудь из следующих статей, в данном примере можно сделать все проще. Нам достаточно одного байндинг адаптера:
@BindingAdapter("switchListener") fun setSwitchListener(sw: Switch, listener: CompoundButton.OnCheckedChangeListener) { sw.setOnCheckedChangeListener(listener) }
После этого связываем наши значения переменных с нашими Item внутри xml:
item_info.xml
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <data> <import type="com.lfkekpoint.adapters .adapters.presentation.modules.bindableItemsSettings.InfoItem" /> <variable name="vmInfo" type="InfoItem" /> </data> <FrameLayout android:layout_width="match_parent" android:layout_height="56dp"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:layout_marginStart="28dp" android:text="@{vmInfo.title}" android:textColor="@color/black" android:textSize="20sp" tools:text="Balance" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical|end" android:layout_marginEnd="48dp" android:text="@{vmInfo.value}" tools:text="1000 quot; /> </FrameLayout> </layout>
item_switch.xml
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android"; xmlns:app="http://schemas.android.com/apk/res-auto"; xmlns:tools="http://schemas.android.com/tools">; <data> <import type="com.lfkekpoint.adapters .adapters.presentation.modules.bindableItemsSettings.SwitchItem" /> <variable name="vmSwitch" type="SwitchItem" /> </data> <FrameLayout android:layout_width="match_parent" android:layout_height="56dp"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:layout_marginStart="28dp" android:text="@{vmSwitch.title}" android:textColor="@color/black" android:textSize="20sp" tools:text="Send notifications" /> <Switch android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical|end" android:layout_marginEnd="48dp" app:switchListener="@{vmSwitch.listener}" tools:checked="true" /> </FrameLayout> </layout>
app:switchListener="@{vmSwitch.listener}" — в этой строке мы воспользовались нашим BindingAdapter'ом.
* Примечание: По вполне справедливым причинам кое-кому может показаться, что мы пишем много больше кода в xml — но это вопрос знаний библиотеки Android Databinding. Она дополняет лейаут, быстро читается и в принципе по большей части убирает именно бойлерплейт. Я думаю, Google собирается хорошо развить эту библиотеку, раз она находится первой во вкладке Architecture, в Android Jetpack. Попробуйте в паре проектов сменить MVP на MVVM — и многие могут быть приятно удивлены.
Ну что ж!.. А, код в SettingsActivity:
… не изменился, разве что поменялся адаптер! =) Но чтобы не прыгать по статье:
class SettingsActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_settings) val adapter = BaseVmListAdapter() rView.layoutManager = LinearLayoutManager(this) rView.adapter = adapter adapter.add(InfoItem("User Name", "Leo Allford")) adapter.add(InfoItem("Balance", "350 quot;)) adapter.add(InfoItem("Tariff", "Business")) adapter.add(SwitchItem(1, "Send Notifications") { itemId, userChoice -> onCheck(itemId, userChoice) }) adapter.add(SwitchItem(2, "Send News on Email") { itemId, userChoice -> onCheck(itemId, userChoice) }) } private fun onCheck(itemId: Int, userChoice: Boolean) { when (itemId) { 1 -> Toast.makeText(this, "Notification now set as $userChoice", Toast.LENGTH_SHORT).show() 2 -> Toast.makeText(this, "Send news now set as $userChoice", Toast.LENGTH_SHORT).show() } } }
Итог
Мы получили алгоритм построения списков и инструменты для работы с ними. В моем случае (почти всегда использую Databinding) вся подготовка сводится к инициализации базовых классов по папкам, верстке ��темов в .xml, а затем привязке к переменным в .kt.
Ускоряем разработку
Для более быстрой работы, я воспользовался шаблонами от Apache для Android Studio — и написал свои темплейты с небольшой демонстрацией, как это все работает. Очень надеюсь, что кому-то пригодится. Обратите внимание, что при работе вызывать темплейт нужно из корневой папки проекта — это сделано потому, что параметр applicationId проекта может вам наврать, если вы поменяли его в Gradle. А вот packageName так просто не проведешь, чем я и воспользовался. Доступным языком про шаблонизацию можно почитать по ссылкам ниже.
Список литературы/медиа
1. Modern Android development: Android Jetpack, Kotlin, and more (Google I/O 2018, 40 m.) — краткий гайд на то, что сегодня в моде, отсюда также в общих чертах станет понятно как развивался RecyclerView
2. Droidcon NYC 2016 — Radical RecyclerView, 36 m. — подробный доклад о RecyclerView от Lisa Wray
3. Create a List with RecyclerView — официальная документация
5. Android IDE Template Format, Тотальная шаблонизация, мануал FreeMarker — удобный подход, который в рамках этой статьи поможет быстро создавать нужные файлы по работе со списками
6. Код к статье (там немного другие названия классов, будьте внимательны), темплейты для работы и видео, как работать с темплейтами
7. Версия статьи на английском языке
Источник: Разработка под Android. Немного о быстрой работе со списками