Использование Paging library совместно с Realm

    На одном из митингов 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. Сергей выложил исходный код библиотеки и пример использования. Не придётся пилить ещё один велосипед, если столкнётесь с похожей ситуацией.
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

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

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

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое