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


ссылка на оригинал статьи https://habr.com/ru/post/467097/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *