Долго я искал в сети способ сделать так, чтобы данные при построении списка RecyclerView не загружались целиком, а подгружались по мере его пролистывания пользователем. Несколько совершенно разных решений находил на StackOverflow. Пробовал применить - работало, но каждый раз, как-то криво и не надежно. После нескольких месяцев работы над проектом в режиме "Когда все дела сделаны и дети слезли с шеи", я наконец достиг, как мне кажется, идеального решения, чем и хочу поделиться в этой статье.

Задача

Мне нужно было отобразить для пользователя моего приложения список клиентов, консультаций и расходов из Базы Данных приложения в разных фрагментах. Один грамотный программист Баз Данных, по совместительству - мой шурин, объяснил мне что лучше отображать не все данные сразу, а только те, которые видны пользователю и реализовать возможность подгружать данные из БД по мере необходимости.

Скриншот главного экрана моего приложения
Скриншот главного экрана моего приложения

Решение

1. Настройка RecyclerView для отображения списка

В нескольких местах в сети прочел, что компонент ListView уже морально устарел. Подробно описывать работу RecyclerView не буду, дам лишь несколько кусков кода в качестве примера с короткими комментариями. Для работы со списком необходимы:

  • Единый макет для элементов спика (rc_timetable.xml).

  • Компонент RecyclerView в макете Активности (androidx.recyclerview.widget.RecyclerView).

  • Адаптер, отвечающий за отображение элементов списка (RecyclerView.Adapter)

  • Функция инициализации адаптера (fun initAdapter).

  • Функция заполнения списка (fun fillAdapter).

1.1 Макет элементов списка

Макет элемента списка ничем не отличается от макетов экранов приложения. Я использую ConstraintLayout, в котором размещаю все, что мне необходимо показать пользователю в качестве отдельного элемента. Не забываю указать родительскому контейнеру layout_height = wrap_content.

Макет элемента спика
Макет элемента спика

1.2 RecyclerView в макете Активности

Про добавление компонета RecyclerView мне сказать особо нечего. В макете Активности пишем его код или вставляем при помощи Визуального Дизайнера.

<androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rcView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

1.3 Адаптер

С адаптером дела обстоят несколько сложнее. Он должен быть описан при помощи двух классов, наследующихся от RecyclerView.Adapter и RecyclerView.ViewHolder соответственно. Первый, как я понял, отвечает за работу всего списка. А второй - создается для каждого элемента в отдельности и отрисовывает его.

Покажу на примере адаптера, отвечающего за отображение списка консультаций из календаря. В качестве параметра при создании объекта класса AdapterTimetable я передаю данные для построения списка в форме ArrayList<ListMeetings>

class ListMeetings {
    var clientName = ""
    var idClient = 0
    var start : Long = 0
    var end : Long = 0
    lateinit var uri: Uri
    var form = 0
    var format = 0
    var isParentsExist = false

    // расчитывается из даты
    var day = 0
    var month = 0
    var dayOfWeek = 0
    var startTime = ""
    var endTime = ""
    var duration = 0
}

Сам код адаптера с некоторыми сокращениями выглядит следующим образом:

class AdapterTimetable(
    private var listItems: ArrayList<ListMeetings>
) :
    RecyclerView.Adapter<AdapterTimetable.MyHolder>() {

    private lateinit var el: RcTimetableBinding

    class MyHolder(
        itemView: View,
        private val el: RcTimetableBinding,
    ) : RecyclerView.ViewHolder(itemView) {

        fun drawItem(item: ListMeetings) {
            ...

            // указываем время Встречи
            el.tvStartTime.text = item.startTime

            // указываем название Услуги
            el.tvService.text = "Консультация"

            // указываем тему Встречи
            el.tvTopic.text = "Тема Встречи"

            ...
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyHolder {
        val inflater = LayoutInflater.from(parent.context)
        el = RcTimetableBinding.inflate(inflater,parent,false)
        return MyHolder(el.root, context, el)
    }

    override fun onBindViewHolder(holder: MyHolder, position: Int) {
        // рисуем элемент списка
        holder.drawItem(listItems[position])
    }

    override fun getItemCount(): Int {
        return listItems.size
    }

    fun updateAdapter(items: ArrayList<ListMeetings>){
      // обновляем список
        listItems.clear()
        listItems.addAll(items)
        notifyDataSetChanged()
    }


    fun removeItem(pos: Int, calManager: CalManager){ 
      // удаляем элемент из списка

        calManager.deleteMeeting(listItems[pos].uri) // удаляем встречу из календаря
        listItems.removeAt(pos) // удаляем элемент из списка с позиции pos
        notifyItemRangeChanged(0,listItems.size) // указываем адаптеру новый диапазон элементов
        notifyItemRemoved(pos) // указываем адаптеру, что один элемент удалился
    }
}

Поясню вкратце вышеприведенный код.

Для обращения к компонентам макета из кода программы я использую некий viewBinding. Эксперты в сети советуют его вместо findViewById. Мне он понравился. Удобно обращаться к компонентам макета через одну переменную (в моей программе - это private val el: RcTimetableBinding). Подключается viewBinding в build.Gradle (Module) следующим образом:

android {
    ...
    buildFeatures {
        viewBinding = true
    }
}

В классе MyHolder единственная функция drawItem заполняет содержимым компоненты макета каждого элемента списка. В качестве параметра она получает данные типа ListMeetings.

В классе адаптера переопределяются три функции: onCreateViewHolder, onBindViewHolder и getItemCount. Первая "раздувает" макет элемента списка (inflate) при его создании. Вторая - наполняет элемент содержимым. А третья - возвращает количество элементов списка.

Также в адаптере должны присутствовать еще две функции: updateAdapter и removeItem. Первая обновляет содержимое списка, а вторая удаляет из него один элемент.

Надеюсь, что мои столь краткие комментарии достаточны, чтобы понять, как работает вышеприведенный код. Подробнее почитать о том, как работает RecyclerView вы можете, например, на сайте Александра Климова: http://developer.alexanderklimov.ru/android/views/recyclerview-kot.php

1.4 Функция инициализации адаптера

private fun initAdapter(){
        el.rcView.layoutManager = LinearLayoutManager(requireContext())
        adapter = AdapterTimetable(ArrayList())
        el.rcView.adapter = adapter
    }

Адаптер используем в активности или фрагменте, который связан с макетом, содержащим RecyclerView. Указываем, что для отображения элементов списка будет использоваться LinearLayoutManager (элементы будут располагаться вертикально один под другим). Создаем adapter и присваеваем его нашему компоненту Recyclerview (rcView).

1.5 Функция заполнения списка

fun fillAdapter(){
  val list = calManager.readMeetingsList()
  if (list.isNotEmpty()) adapter.updateAdapter(list)    
}

Здесь пока все просто - загружаем данные из Базы Данных (calManager.readMeetingsList) и обновляем список новыми данными (adapter.updateAdapter).

2. Динамическая подгрузка данных

Теперь предстоит модернизировать код, добавив возможность подгружать данные по мере пролистывания списка. Вносить изменения придется во блоки кода, описанные выше. Начну с функции заполнения списка данными.

2.1 Модернизация функции заполнения списка

fun fillAdapter(startDate: Long = 0,
                count: Int = Const.RC_ITEM_BUFFER,
                clear: Boolean = true) {
  // указываем в адаптере, что начинаем загрузку данных
  adapter.startLoading()
  val list = calManager.readMeetingsList(startDate, count)
  if (list.isNotEmpty()) adapter.updateAdapter(list, clear)
  // указываем, что загрузка данных закончена
  adapter.setLoaded()
}

Надо сказать, что для отображения списка консультаций при запросе из Базы Данных я упорядочиваю их по возрастанию даты. И теперь функция fillAdapter принимает следующие параметры:

  • startDate - начальная дата, с которой берутся консультации.

  • _count - размер пакета данных, количество консультаций, которые будут отображаться.

  • clear - очищать или не очищать список.

Видно, что если вызвать функцию fillAdapter без параметров, то по умолчанию данные будут браться с самого начала, их количество будет равно некой константе RC_ITEM_BUFFER (в моем случае - 50) и список будет очищаться. Подобный вызов функции происходит в onResume:

override fun onResume() {
        super.onResume()
        fillAdapter()
    }

Из кода видно, что изменились и вызовы функций calManager.readMeetingsList (она теперь возвращает только список консультаций с датой больше заданной и определенного количества) и adapter.updateAdapter (эта функция теперь содержит еще параметр clear - очищать ли список).

Вокруг блока кода, работающего с данными стоят строчки adapter.startLoading() и adapter.setLoaded() Это установка флага загрузки. Она необходима, чтобы при прокрутке списка не вызывалась слишком часто функция fillAdapter (подробнее смотрите далее).

2.2 Модернизация функции updateAdapter

fun updateAdapter(items: ArrayList<ListMeetings>, clear: Boolean = true){
        if (clear) listItems.clear()
        listItems.addAll(items)
        notifyDataSetChanged()
    }

При обновлении списка теперь учитывается нужно ли его очистить или нет. Если нет, то новые элементы просто добавляются в конец списка.

2.3 Подгрузка данных и флаг загрузки

class AdapterTimetable(
    private var listItems: ArrayList<ListMeetings>
) :
    RecyclerView.Adapter<AdapterTimetable.MyHolder>() {

    private lateinit var el: RcTimetableBinding
    var loadMore : MyLoadMore? = null
    var isLoading = false
      
      ...
      
    fun setLoadMore(loadMore: MyLoadMore?) {
        this.loadMore = loadMore
    }
      
    fun startLoading() {
        isLoading = true
    }

    fun setLoaded() {
        isLoading = false
    }
    
    ...
      
    fun getLastItemDate(): Long {
        return if (listItems.size > 0) listItems[listItems.size - 1].start else 0

    }
      
  }
    
  interface MyLoadMore {
    fun onLoadMore()
  }

Сначала про флаг загрузки. В классе адаптера вводим булеву переменную isLoading. Если она установлена в true, то значит происходит загрузка элементов и пока функция fillAdapter не доступна.

Подгрузка данных будет осуществляться при помощи функции onLoadMore, которая определяется через интерфейс MyLoadMore. Установливать ее содержимое будем из активности или фрагмента, связанного с RecyclerView при помощи функции setLoadMore Честно говоря, сам не понял, что сказал - для меня это уже слишком. Объясняю, как могу, ибо сам понимаю с трудом. Но смысл в том, чтобы иметь возможность вынести эту функцию за пределы адаптера в активность.

Ну и фунция, возвращающая дату последней консультации в списке, пригодится нам далее при подгрузке данных.

2.4 Модернизация функции initAdapter

private fun initAdapter(){
        el.rcView.layoutManager = LinearLayoutManager(requireContext())
        adapter = AdapterTimetable(ArrayList())
        el.rcView.adapter = adapter

        // при прокрутке запускаем onLoadMore
        val layoutManager = el.rcView.layoutManager as LinearLayoutManager
        el.rcView.addOnScrollListener(object: RecyclerView.OnScrollListener() {
          
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)

                val totalItemCount = layoutManager.itemCount
                val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
                if (!adapter.isLoading && totalItemCount <= 
                    lastVisibleItem + Const.RC_ITEM_BUFFER / 2) {
                    adapter.loadMore?.onLoadMore()
                }
            }
        })

        // переопределяем функцию onLoadMore
        adapter.setLoadMore(object : MyLoadMore {
            override fun onLoadMore() {
                fillAdapter(adapter.getLastItemDate(), Const.RC_ITEM_BUFFER, false, false)
            }
        })
    }

К созданию адаптера добавляем две вещи: слушатель прокрутки (addOnScrollListener) и переопределение функции onLoadMore.

В слушателе прокрутки проверяем флаг загрузки и последний видимый элемент. Если положение списка близко к концу (RC_ITEM_BUFFER / 2), то подгружаем элементы при помощи модернизированной функции fillAdapter, указав в параметрах дату крайней консультации, размер пакета подгрузки и выключив очистку списка.

Ответ

Получилось вполне рабочее решение. Я его в таком виде в сети не встречал. Делюсь. Возможно, где-то в чем-то я перемудрил или не учел некоторые возможности, о которых просто пока понятия не имею. Буду рад вашим комментариям и предложениям. Есть вопрос про подгузку данных. Как вы думаете, насколько необходимо ее осуществлять при работе с БД на устройстве? Может, просто подтягивать все данные и грузить их целиком в список?

Приложение над которым я сейчас работаю: "Учет клиентов" https://play.google.com/store/apps/details?id=ru.keytomyself.customeraccounting

Собираюсь добавить возможность интеграции с Гугл Календарем. В связи с этим тоже возникает множество вопросов про списки RecyclerView. Там ведь повторяющиеся события, исключения ипрочие сложности...

Комментарии (25)


  1. AlexSkvortsov
    21.01.2022 16:06
    +3

    Вот здесь есть замечательное видео на тему от профессионалов.
    https://www.youtube.com/watch?v=g7wwybnXE40

    Если честно, тщательно даже читать статью не стал - вероятность того, что Вы изобрели что-то лучше просто ничтожна. И почти уверен, что просмотрев видео Вы найдете у себя в реализации по крайней мере 1 существенный недостаток, а скорее всего - больше.

    В любом случае, спасибо, что поделились, другим новичкам может серьезно помочь)

    P.s.

    > Есть вопрос про подгузку данных. Как вы думаете, насколько необходимо ее осуществлять при работе с БД на устройстве?

    Зависит от того, что лежит в БД. Во-первых структура может быть сложной и тянущей данные со множества таблиц, тогда загрузка всех данных может занимать вплоть до минут, что непозволительно долго. Во-вторых, данных могут быть десятки тысяч строк, тогда даже сравнительно простая структура будет тянуться уже не мгновенно.


    1. Andrey_Ananiev Автор
      21.01.2022 20:17

      Спасибо за ссылку на видео, обязательно посмотрю!


  1. gatoazul
    21.01.2022 18:30
    +1

    Для работы со списками есть удобная библиотека FastAdapter. Там есть и бесконечный скроллинг, и подгрузка страницами.


    1. Andrey_Ananiev Автор
      21.01.2022 20:18

      Спасибо. Поищу в сети, посмотрю.


      1. bsod_keks
        22.01.2022 09:45
        +1

        Есть ещё brvah. Конкретно про пагинацию(постепенную подгрузку)


        1. Andrey_Ananiev Автор
          23.01.2022 11:57

          Спасибо!


  1. WraithOW
    21.01.2022 18:35
    +3

    Долго я искал в сети способ сделать так, чтобы данные при построении списка RecyclerView не загружались целиком, а подгружались по мере его пролистывания пользователем. Несколько совершенно разных решений находил на StackOverflow. Пробовал применить — работало, но каждый раз, как-то криво и не надежно.


    Библиотека Paging из Jetpack'a, уже в третьей версии

    fun updateAdapter(items: ArrayList<ListMeetings>, clear: Boolean = true){
            if (clear) listItems.clear()
            listItems.addAll(items)
            notifyDataSetChanged()
        }
    


    notifyDataSetChanged() — можноненадо весь список перерисовывать. DiffUtils в школу скоро пойдет.

        fun removeItem(pos: Int, calManager: CalManager){ 
          // удаляем элемент из списка
    
            calManager.deleteMeeting(listItems[pos].uri) // удаляем встречу из календаря
            listItems.removeAt(pos) // удаляем элемент из списка с позиции pos
            notifyItemRangeChanged(0,listItems.size) // указываем адаптеру новый диапазон элементов
            notifyItemRemoved(pos) // указываем адаптеру, что один элемент удалился
        }


    Зачем здесь notifyItemRangeChanged? Вы уже сказали списку, что один элемент удален — он умный, поймет, что надо сделать. Алсо, у вас здесь баг: за счет того, что размер listItems в момент вызова меньше, чем число элементов в адаптере — вы оставите последний элемент в списке необновленным.


    1. Andrey_Ananiev Автор
      21.01.2022 20:21

      Спасибо за комментарии. Я тут видимо делал по чьему-то гайду, не очень-то вдаваясь в подробности. Исправлю.


  1. alexinzaz
    21.01.2022 20:21
    +3

    Всего-то нужно было загуглить "android paging library". В первой ссылке либа от гугла, и не нужны были бы вот эти все велосипеды.


    1. Andrey_Ananiev Автор
      21.01.2022 20:22

      Я же не знал, что гуглить... Теперь знаю. Велосипед, видимо, нужен только мне... Спасибо.


  1. AlexVWill
    21.01.2022 22:16
    +1

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


    1. Andrey_Ananiev Автор
      22.01.2022 12:42

      Нет. У меня поиск осуществляется по Базе Данных.


      1. AlexVWill
        23.01.2022 01:06
        +1

        так чтобы именно динамический поиск (т.е. поиск когда вводишь первую букву - появляются список, втору - он короче, третью - еще короче) работал, ему нужен полный список, или сразу онлайн обмен с сервером ко всем данным, участвующим в поиске


        1. Andrey_Ananiev Автор
          23.01.2022 12:04

          Да, так и есть. У меня локальная База Данных на устройстве. Из нее и осуществляется поиск. Список обновляется при вводе каждой новой буквы.

          private fun initSearchView(){
                  (activity as MainActivity).el.searchView.setOnQueryTextListener(object: SearchView.OnQueryTextListener{
          
                      override fun onQueryTextSubmit(query: String?): Boolean {
                          // запускается, когда мы нажимаем лупу на виртуальной клавиатуре
                          return true
                      }
          
                      override fun onQueryTextChange(newText: String?): Boolean {
                          // запускается каждый раз, когда мы вводим очередную букву
                          searchText = newText!!
                          fillAdapter()
                          return true
                      }
                  })
              }

          Просто я в статье для простоты изложения убрал параметр searchText из функции fillAdapter().


          1. AlexVWill
            23.01.2022 21:12

            а, тогда понял


  1. shok96
    22.01.2022 06:57
    +1

    Шел 2050 год, программисты до сих используют xml


    1. Andrey_Ananiev Автор
      22.01.2022 12:41

      Это плохо? Как можно без него обойтись в проектах под Android?


      1. shok96
        22.01.2022 14:13
        +1

        ну jetpack compose например)


        1. Andrey_Ananiev Автор
          23.01.2022 12:05

          Спасибо. Как-нибудь и до него дорасту... Посмотрю.


    1. RasM24
      22.01.2022 23:00
      +1

      На корутины то далеко не все перешли, а ты про компоуз :)


      1. Andrey_Ananiev Автор
        23.01.2022 12:07

        Я, например, перешел. Просто из примеров кода в этой статье убрал, чтобы не пугать новичков. И так получился громоздкий гайд.


  1. sBakhrom
    22.01.2022 12:40
    +1

    Когда то использовал в проектах подобную реализацию, но здесь есть один нюанс если размер экрана позволяет не заполняться при первом вызове данных, скажем большой планшет или количество меньше и задается с апи то onScroll не будет вызываться и следующие данные не подгрузятся, решением этого было отказ от scrollListener'a и добавить проверку на onBindViewHolder

    override fun onBindViewHolder(holder: ViewHolder<T, V>, position: Int) {
        holder.bind(items[position], onItemBind)
        if (position == items.lastIndex) {
            onLastItemBind?.invoke()
        }
    }


  1. RasM24
    22.01.2022 22:59
    +1

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

    В качестве ответа на твой вопрос, как можно реализовать уже привели примеры. Та же библиотека с пагинацией активно используется (https://youtu.be/9zVmYVDTcEM) *Ну и для необходимости пагинации надо также смотреть на остальной код. Грубо говоря если не тяжело достать данные разом и держать их в переменной, то проще сделать именно так. RecyclerView как раз содержит в себе решение, что он не будет отображать и рисовать те вьюшки, которые "слишком далеко"

    Но я бы хотел сказать спасибо, что не боишься делиться своими мыслями и решениями :) И хотел бы дать несколько советов по самой статье. Как автор ты должен хорошо разбираться в том, о чем говоришь. То бишь здесь стоит изучить и сам RecyclerView, viewBinding и прочее (либо стараться упоминать в нейтральном ключе). Просто ты как автор несёшь дальше ответственность, что за тобой наверняка кто-то будет повторять и приводит в пример статью)


    1. Andrey_Ananiev Автор
      23.01.2022 12:15

      Спасибо за столь подробный комментарий. Я расту и по мере более лучшего понимания материала буду править код и улучшать статью. Может быть, я плохо гуглил, но не смог найти на русском языке подобное описание реализации "динамической подгрузки данных" (для меня, человека не из IT, эта фраза звучит более подходящей, чем упомянутая многими комментаторами "пагинация"). Дайте ссылку на текст, если я не прав. Видео обязательно посмотрю... Спасибо!


      1. RasM24
        23.01.2022 13:44

        На видео как раз последняя версия библиотеки, с описанием и примерами)

        Названия технологий приходят из английского языка, так потихоньку привыкнешь, что и как искать :)