Как стать автором
Обновить

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

Время на прочтение 14 мин
Количество просмотров 9.2K
Всем привет! Мои посты — желание помочь в работе с какими-то элементами 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. Код к статье(там немного другие названия классов, будьте внимательны), темплейты для работы и видео, как работать с темплейтами

7. Версия статьи на английском языке
Теги:
Хабы:
+14
Комментарии 12
Комментарии Комментарии 12

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн