Сделал и написал черновик статьи, которой почти в неизменном виде, делюсь с вами. Он со своей стороны пообещал, что разгребётся с задачами и придёт в комментарии, если возникнут вопросы.
Бесконечный список и готовые решения
Одна из задач, с которой мы сталкиваемся — отобразить информацию списком, при прокручивании которого, данные подгружаются и вставляются незаметно для пользователя. Для пользователя это выглядит так, что он скроллит бесконечный список.
Алгоритм получается примерно следующий:
- получаем данные из кэша для первой страницы;
- если кэш пуст — получаем данные сервера, отображаем их в списке и пишем в БД;
- если кэш есть — загружаем его в список;
- если доходим до конца БД, то запрашиваем данные с сервера, отображаем их в списке и пишем в БД.
Упрощённо: для отображения списка в первую очередь опрашивается кэш, а сигналом загрузки новых данных является конец кэша.
Для реализация бесконечной прокрутки (endless scrolling) можно использовать готовые решения:
- реализацию RecyclerView.OnScrollListener, или найти на Github что-нибудь готовое, вроде EndlessRecyclerViewScrollListener;
- реактивный подход, используя RxJava для контроля подгрузки данных;
- готовую реализацию списка;
- Paging library из Jetpack.
Мы в качестве мобильной базы данных используем Realm, и, попробовав все перечисленные подходы, остановились на использовании Paging library.
На первый взгляд Android Paging Library — отличное решение для загрузки данных и при использовании sqlite совместно с Room отлично подходит в качестве БД. Однако, при использовании Realm в качестве БД мы лишаемся всего, к чему так привыкли — ленивой загрузки (lazy loading) и data change notifications. Нам же не хотелось отказываться от всех этих вещей, но в то же время использовать Paging library.
Может быть, мы не первые, кому это нужно
Быстрый поиск сразу выдал решение — библиотеку Realm monarchy. После беглого изучения выяснилось, что это решение нас не устраивает — библиотека не поддерживает ни ленивую загрузку, ни notifications. Пришлось создавать своё.
Итак, требования:
- Продолжить использовать Realm;
- Сохранить lazy loading для Realm;
- Сохранить notifications;
- Использовать 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()); } }
Пока все выглядит просто — достаточно взять и сделать. Всего-то делов:
- Создать свою реализацию PagedList (RealmPagedList) которая будет работать с RealmModel;
- Создать свою реализацию PagedStorage (RealmPagedStorage), которая будет работать с OrderedRealmCollection;
- Создать свою реализацию DataSource (RealmDataSource) которая будет работать с RealmModel;
- Создать свой адаптер для работы с RealmList;
- Убрать ненужное, добавить нужное;
- Готово.
Опустим незначительные технические детали, и вот результат — библиотека 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/
Добавить комментарий