August 28, 2019

Разработка под 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.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)

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

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 — официальная документация

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

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

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

7. Версия статьи на английском языке

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