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

от автора

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

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

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

Вступление

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

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

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

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

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

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

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

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> {      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, т.к. Андроид каждый раз услуживо делает 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:

Обратим внимание на первую вкладку в разделе Architecture. Android Databinding — очень обширная тема, я бы хотел поговорить об ней более подробно в других статьях, но сейчас воспользуемся только в рамках текущей — мы сделаем нашу Item.ktvariable для 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) 

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

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. Она дополняет лейаут, быстро читается и в принципе по большей части убирает именно бойлерплейт. Я думаю, Google собирается хорошо развить эту библиотеку, раз она находится первой во вкладке Architecture, в Android Jetpack. Попробуйте в паре проектов сменить MVP на MVVM — и многие могут быть приятно удивлены.

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

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

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

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


ссылка на оригинал статьи https://habr.com/post/424129/


Комментарии

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

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