На одном из митингов Android-отдела я подслушал, как один из наших разработчиков сделал небольшую либу, которая помогает сделать «бесконечный» список при использовании Realm, сохранив «ленивую загрузку» и нотификации.

Сделал и написал черновик статьи, которой почти в неизменном виде, делюсь с вами. Он со своей стороны пообещал, что разгребётся с задачами и придёт в комментарии, если возникнут вопросы.

Бесконечный список и готовые решения


Одна из задач, с которой мы сталкиваемся — отобразить информацию списком, при прокручивании которого, данные подгружаются и вставляются незаметно для пользователя. Для пользователя это выглядит так, что он скроллит бесконечный список.

Алгоритм получается примерно следующий:

  • получаем данные из кэша для первой страницы;
  • если кэш пуст — получаем данные сервера, отображаем их в списке и пишем в БД;
  • если кэш есть — загружаем его в список;
  • если доходим до конца БД, то запрашиваем данные с сервера, отображаем их в списке и пишем в БД.

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

Для реализация бесконечной прокрутки (endless scrolling) можно использовать готовые решения:


Мы в качестве мобильной базы данных используем Realm, и, попробовав все перечисленные подходы, остановились на использовании Paging library.

На первый взгляд Android Paging Library — отличное решение для загрузки данных и при использовании sqlite совместно с Room отлично подходит в качестве БД. Однако, при использовании Realm в качестве БД мы лишаемся всего, к чему так привыкли — ленивой загрузки (lazy loading) и data change notifications. Нам же не хотелось отказываться от всех этих вещей, но в то же время использовать Paging library.

Может быть, мы не первые, кому это нужно


Быстрый поиск сразу выдал решение — библиотеку Realm monarchy. После беглого изучения выяснилось, что это решение нас не устраивает — библиотека не поддерживает ни ленивую загрузку, ни notifications. Пришлось создавать своё.

Итак, требования:

  1. Продолжить использовать Realm;
  2. Сохранить lazy loading для Realm;
  3. Сохранить notifications;
  4. Использовать Paging library для загрузки данных из БД и постраничной загрузки данных с сервера, так же, как это предлагает Paging library.

С начала попробуем разобраться, как работает Paging library, и что сделать, чтобы нам было хорошо.

Кратко — библиотека состоит из следующих компонентов:

DataSource — базовый класс для загрузки данных постранично.
Имеет реализации: PageKeyedDataSource, PositionalDataSource и ItemKeyedDataSource, но их предназначение сейчас нам не важно.

PagedList — список, который подгружает данные порциями из источника DataSource. Но так как мы используем Realm — загрузка данных порциями для нас не актуальна.
PagedListAdapter — класс, ответственный за отображение данных, загруженных PagedList.

В исходниках эталонной реализации мы увидим, как работает схема.

1. PagedListAdapter в методе getItem(int index) вызывает для PagedList метод loadAround(int index):

/**
* Get the item from the current PagedList at the specified index.
* <p>
* Note that this operates on both loaded items and null padding within the PagedList.
*
* @param index Index of item to get, must be >= 0, and < {@link #getItemCount()}.
* @return The item, or null, if a null placeholder is at the specified position.
*/
@SuppressWarnings("WeakerAccess")
@Nullable
public T getItem(int index) {
   if (mPagedList == null) {
       if (mSnapshot == null) {
           throw new IndexOutOfBoundsException(
                   "Item count is zero, getItem() call is invalid");
       } else {
           return mSnapshot.get(index);
       }
   }

   mPagedList.loadAround(index);
   return mPagedList.get(index);
}

2. PagedList выполняет проверки и вызывает метод void tryDispatchBoundaryCallbacks(boolean post):

/**
* Load adjacent items to passed index.
*
* @param index Index at which to load.
*/
public void loadAround(int index) {
   if (index < 0 || index >= size()) {
       throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size());
   }

   mLastLoad = index + getPositionOffset();
   loadAroundInternal(index);

   mLowestIndexAccessed = Math.min(mLowestIndexAccessed, index);
   mHighestIndexAccessed = Math.max(mHighestIndexAccessed, index);

   /*
    * mLowestIndexAccessed / mHighestIndexAccessed have been updated, so check if we need to
    * dispatch boundary callbacks. Boundary callbacks are deferred until last items are loaded,
    * and accesses happen near the boundaries.
    *
    * Note: we post here, since RecyclerView may want to add items in response, and this
    * call occurs in PagedListAdapter bind.
    */
   tryDispatchBoundaryCallbacks(true);
}

3. В этом методе проверяется необходимость загрузки следующей порции данных и происходит запрос на загрузку:

/**
* Call this when mLowest/HighestIndexAccessed are changed, or
* mBoundaryCallbackBegin/EndDeferred is set.
*/
@SuppressWarnings("WeakerAccess") /* synthetic access */
void tryDispatchBoundaryCallbacks(boolean post) {
   final boolean dispatchBegin = mBoundaryCallbackBeginDeferred
           && mLowestIndexAccessed <= mConfig.prefetchDistance;
   final boolean dispatchEnd = mBoundaryCallbackEndDeferred
           && mHighestIndexAccessed >= size() - 1 - mConfig.prefetchDistance;

   if (!dispatchBegin && !dispatchEnd) {
       return;
   }

   if (dispatchBegin) {
       mBoundaryCallbackBeginDeferred = false;
   }
   if (dispatchEnd) {
       mBoundaryCallbackEndDeferred = false;
   }
   if (post) {
       mMainThreadExecutor.execute(new Runnable() {
           @Override
           public void run() {
               dispatchBoundaryCallbacks(dispatchBegin, dispatchEnd);
           }
       });
   } else {
       dispatchBoundaryCallbacks(dispatchBegin, dispatchEnd);
   }
}

4. В итоге все вызовы попадают в DataSource, где и происходит загрузка данных из БД или из других источников:

@SuppressWarnings("WeakerAccess") /* synthetic access */
void dispatchBoundaryCallbacks(boolean begin, boolean end) {
   // safe to deref mBoundaryCallback here, since we only defer if mBoundaryCallback present
   if (begin) {
       //noinspection ConstantConditions
       mBoundaryCallback.onItemAtFrontLoaded(mStorage.getFirstLoadedItem());
   }
   if (end) {
       //noinspection ConstantConditions
       mBoundaryCallback.onItemAtEndLoaded(mStorage.getLastLoadedItem());
   }
}

Пока все выглядит просто — достаточно взять и сделать. Всего-то делов:

  1. Создать свою реализацию PagedList (RealmPagedList) которая будет работать с RealmModel;
  2. Создать свою реализацию PagedStorage (RealmPagedStorage), которая будет работать с OrderedRealmCollection;
  3. Создать свою реализацию DataSource (RealmDataSource) которая будет работать с RealmModel;
  4. Создать свой адаптер для работы с RealmList;
  5. Убрать ненужное, добавить нужное;
  6. Готово.

Опустим незначительные технические детали, и вот результат — библиотека RealmPagination. Попробуем создать приложение, которое отображает список пользователей.

0. Добавляем библиотеку в проект:

allprojects {
    repositories {
        maven { url "https://jitpack.io" }
    }
}
implementation 'com.github.magora-android:realmpagination:1.0.0'


1. Создаём класс User:

@Serializable
@RealmClass
open class User : RealmModel {
   @PrimaryKey
   @SerialName("id") var id: Int = 0
   @SerialName("login") var login: String? = null
   @SerialName("avatar_url") var avatarUrl: String? = null
   @SerialName("url") var url: String? = null
   @SerialName("html_url") var htmlUrl: String? = null
   @SerialName("repos_url") var reposUrl: String? = null
}

2. Создаём DataSource:

class UsersListDataSourceFactory(
   private val getUsersUseCase: GetUserListUseCase,
   private val localStorage: UserDataStorage
) : RealmDataSource.Factory<Int, User>() {

   override fun create(): RealmDataSource<Int, User> {
       val result = object : RealmPageKeyedDataSource<Int, User>() {

           override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Int, User>) {...}

           override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, User>) {
	...
           }

           override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, User>) {
	...
           }
       }
       return result
   }

   override fun destroy() {

   }
}

3. Создаем адаптер:

class AdapterUserList(
   data: RealmPagedList<*, User>,
   private val onClick: (Int, Int) -> Unit
) : BaseRealmListenableAdapter<User, RecyclerView.ViewHolder>(data) {

   override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
       val view = LayoutInflater.from(parent.context).inflate(R.layout.item_user, parent, false)
       return UserViewHolder(view)
   }

   override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
      ...
   }
}

4. Создаём ViewModel:

private const val INITIAL_PAGE_SIZE = 50
private const val PAGE_SIZE = 30
private const val PREFETCH_DISTANCE = 10

class VmUsersList(
   app: Application,
   private val dsFactory: UsersListDataSourceFactory,
) : AndroidViewModel(app), KoinComponent {

   val contentData: RealmPagedList<Int, User>
       get() {
           val config = RealmPagedList.Config.Builder()
               .setInitialLoadSizeHint(INITIAL_PAGE_SIZE)
               .setPageSize(PAGE_SIZE)
               .setPrefetchDistance(PREFETCH_DISTANCE)
               .build()

           return RealmPagedListBuilder(dsFactory, config)
               .setInitialLoadKey(0)
               .setRealmData(localStorage.getUsers().users)
               .build()
       }
  
   fun refreshData() { ... }

   fun retryAfterPaginationError() { ...  }

   override fun onCleared() {
       super.onCleared()
       dsFactory.destroy()
   }
}

5. Инициализируем список:


recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.adapter = AdapterUserList(viewModel.contentData) { user, position ->
//...
}

6. Создаём фрагмент со списком:

class FragmentUserList : BaseFragment() {

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
 super.onViewCreated(view, savedInstanceState)
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.adapter = AdapterUserList(viewModel.contentData) { user, position ->  ...   }
}

7. Готово.

Получилось, что использовать Realm также просто, как и Room. Сергей выложил исходный код библиотеки и пример использования. Не придётся пилить ещё один велосипед, если столкнётесь с похожей ситуацией.

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


  1. MrSwimmer
    12.09.2019 14:52
    +1

    Спасибо за статью! Если кому-то интересно как использовать пагинацию совместно с Room, то я описывал это в статье