Хабрахабр

[Из песочницы] Разработка под Android. Немного о быстрой работе со списками

Всем привет! Мои посты — желание помочь в работе с какими-то элементами Android. Если вы разработчик, который еще не сформировал для себя алгоритм для построения списков — вам может оказаться полезным почитать этот материал. В основном, я бы хотел предлагать готовые решения для разработки, раскрывая в ходе повествования какие-то мысли о том, как я до них докатился к этому пришел.

В этой статье:

  • формируем несколько базовых классов и интерфейсов для работы с RecyclerView и RecyclerView.Adapter
  • подключим одну библиотеку из Android Jetpack (по желанию, сначала без нее)
  • для еще более быстрой разработки — вариант темплейта в конце статьи 😉

Вступление

Ну что ж! Все уже забыли про ListView и благополучно пишут на RecyclerView(Rv). Те времена, когда мы реализовывали сами паттерн ViewHolder, канули в небытие. Rv предоставляет нам набор готовых классов для реализации списков и достаточно большой выбор LayoutManager'ов для их отображения. По сути, глядя на множество экранов, списком можно представить большинство из них — именно благодаря возможности для каждого элемента реализовать свой ViewHolder. Более подробно историю развития нам рассказали на Google I/O.

Стандартные ответы на Stackoverflow подсказывают общие решения, которые приводят к копипасте, особенно в месте реализации Adapter'a. Но, всегда есть пара «но»!..

Инфы по нему туча, и много библиотек с готовыми решениями, но что делать, если вам не нужен весь функционал, или если вы залазите поглядеть на чужой код — и видите там Древний Ужас не то, что хотели бы видеть, или не то что вообще себе представляли? На данный момент, Rv уже три года. За эти три года Android наконец-таки официально принял к себе Kotlin = улучшилась читаемость кода, по Rv вышло немало интересных статей, которые в полной мере раскрывают его возможности.

Этот каркас можно дополнять логикой от приложения к приложению, используя то, что вам необходимо, и отбрасывая лишнее. Цель этой — собрать из лучших практик свой велосипед основу, каркас по работе со списками для новых приложений. Я считаю такой подход намного лучше чужой библиотеки — в своих классах вы имеете возможность разобраться с тем, как все работает, и проконтролировать кейсы в которых нуждаетесь, не завязываясь на чужом решении.

Давайте мыслить логично и с самого начала

Решать то, что должен делать компонент, будет интерфейс, а не класс, но конкретную логику реализации в конце замкнем на классе, который будет этот интерфейс имплементировать и реализовывать. Но, если получится так, что при реализации интерфейса образуется копипаста — мы можем спрятать ее за абстрактным классом, а после него — класс, который наследуется от абстрактного. Я покажу свою реализацию базовых интерфейсов, но моя цель состоит в том, чтобы разработчик просто попробовал думать в этом же направлении. Еще раз — план такой: Набор интерфейсов -> абстрактный класс, забирающий копипасту (если это нужно) -> и уже конкретный класс с уникальным кодом. Реализацию интерфейсов Вы можете выполнить по другому.

Ответ на этот вопрос легче всего получить, когда смотришь на какой то пример. Что может делать со списком адаптер? Adapter, вы найдете пару подсказок. Можно заглянуть в RecyclerView. Если же немного подумать, то можно представить примерно такие методы:

IBaseListAdapter

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 с интерфейсами, и следующий класс будет абстрактным:

SimpleListAdapter

abstract class SimpleListAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>(), IBaseListAdapter<IBaseListItem> 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, т.к. Андроид каждый раз услуживо делает id лейаутов уникальными, и все значения больше нуля — этим мы и воспользуемся далее, «надувая» itemView для наших вьюхолдеров в методе inflateByViewType() (следующая строка).

Создаем список

Возьмем для примера экран настроек. Андроид предлагает нам свой вариант, но что если по дизайну понадобится что-то более изощренное? Я предпочитаю наполнять этот экран как список. Тут будет приведен такой кейс:

Мы видим два разных элемента списка, значит SimpleListAdapter и Rv тут прекрасно подойдут!

Можно начать с верстки лейаутов для item'ов: Приступим!

item_info.xml; 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_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 $" /> </FrameLayout> <!----> <?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 сущностей с сервера, если же их нет — мы можем создать их сами при инициализации.

InfoItem.kt, SwitchItem.kt

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:

InfoViewHolder.kt, SwitchViewHolder.kt

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:

SettingsListAdapter.kt

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>

SettingsActivity.kt

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 $")) 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:

Android Databinding — очень обширная тема, я бы хотел поговорить об ней более подробно в других статьях, но сейчас воспользуемся только в рамках текущей — мы сделаем нашу Item.ktvariable для item.xml (или можете назвать ее вьюмоделью для лейаута). Обратим внимание на первую вкладку в разделе Architecture.

На момент написания статьи, 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)

Такой же подход используется здесь, но на котлине это выглядит намного короче, не правда ли? =)

VmListAdapter

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:

InfoItem.kt , SwitchItem.kt

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; item_switch.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 $" /> </FrameLayout>
</layout> <!----> <?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. Попробуйте в паре проектов сменить MVP на MVVM — и многие могут быть приятно удивлены. Я думаю, Google собирается хорошо развить эту библиотеку, раз она находится первой во вкладке Architecture, в Android Jetpack.

А, код в SettingsActivity: Ну что ж!..

SettingsActivity.kt

… не изменился, разве что поменялся адаптер! =) Но чтобы не прыгать по статье:

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 $")) 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 — и написал свои темплейты с небольшой демонстрацией, как это все работает. А вот packageName так просто не проведешь, чем я и воспользовался. Обратите внимание, что при работе вызывать темплейт нужно из корневой папки проекта — это сделано потому что параметр applicationId проекта может вам наврать, если вы поменяли его в Gradle. Доступным языком про шаблонизацию можно почитать по ссылкам ниже

Список литературы/медиа

1. Modern Android development: Android Jetpack, Kotlin, and more (Google I/O 2018, 40 m.) — краткий гайд на то, что сегодня в моде, отсюда также в общих чертах станет понятно как развивался RecyclerView;

Droidcon NYC 2016 — Radical RecyclerView, 36 m. 2. — подробный доклад о RecyclerView от Lisa Wray;

Create a List with RecyclerView — официальная документация 3.

Интерфейсы vs. 4. классы

Android IDE Template Format, Тотальная шаблонизация, мануал FreeMarker — удобный подход, который в рамках этой статьи поможет быстро создавать нужные файлы по работе со списками 5.

Код к статье(там немного другие названия классов, будьте внимательны), темплейты для работы и видео, как работать с темплейтами 6.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть