На одном из митингов Android-отдела я подслушал, как один из наших разработчиков сделал небольшую либу, которая помогает сделать «бесконечный» список при использовании Realm, сохранив «ленивую загрузку» и нотификации.
Сделал и написал черновик статьи, которой почти в неизменном виде, делюсь с вами. Он со своей стороны пообещал, что разгребётся с задачами и придёт в комментарии, если возникнут вопросы.
Одна из задач, с которой мы сталкиваемся — отобразить информацию списком, при прокручивании которого, данные подгружаются и вставляются незаметно для пользователя. Для пользователя это выглядит так, что он скроллит бесконечный список.
Алгоритм получается примерно следующий:
Упрощённо: для отображения списка в первую очередь опрашивается кэш, а сигналом загрузки новых данных является конец кэша.
Для реализация бесконечной прокрутки (endless scrolling) можно использовать готовые решения:
Мы в качестве мобильной базы данных используем Realm, и, попробовав все перечисленные подходы, остановились на использовании Paging library.
На первый взгляд Android Paging Library — отличное решение для загрузки данных и при использовании sqlite совместно с Room отлично подходит в качестве БД. Однако, при использовании Realm в качестве БД мы лишаемся всего, к чему так привыкли — ленивой загрузки (lazy loading) и data change notifications. Нам же не хотелось отказываться от всех этих вещей, но в то же время использовать Paging library.
Быстрый поиск сразу выдал решение — библиотеку Realm monarchy. После беглого изучения выяснилось, что это решение нас не устраивает — библиотека не поддерживает ни ленивую загрузку, ни notifications. Пришлось создавать своё.
Итак, требования:
С начала попробуем разобраться, как работает Paging library, и что сделать, чтобы нам было хорошо.
Кратко — библиотека состоит из следующих компонентов:
DataSource — базовый класс для загрузки данных постранично.
Имеет реализации: PageKeyedDataSource, PositionalDataSource и ItemKeyedDataSource, но их предназначение сейчас нам не важно.
PagedList — список, который подгружает данные порциями из источника DataSource. Но так как мы используем Realm — загрузка данных порциями для нас не актуальна.
PagedListAdapter — класс, ответственный за отображение данных, загруженных PagedList.
В исходниках эталонной реализации мы увидим, как работает схема.
1. PagedListAdapter в методе getItem(int index) вызывает для PagedList метод loadAround(int index):
2. PagedList выполняет проверки и вызывает метод void tryDispatchBoundaryCallbacks(boolean post):
3. В этом методе проверяется необходимость загрузки следующей порции данных и происходит запрос на загрузку:
4. В итоге все вызовы попадают в DataSource, где и происходит загрузка данных из БД или из других источников:
Пока все выглядит просто — достаточно взять и сделать. Всего-то делов:
Опустим незначительные технические детали, и вот результат — библиотека RealmPagination. Попробуем создать приложение, которое отображает список пользователей.
0. Добавляем библиотеку в проект:
1. Создаём класс User:
2. Создаём DataSource:
3. Создаем адаптер:
4. Создаём ViewModel:
5. Инициализируем список:
6. Создаём фрагмент со списком:
7. Готово.
Получилось, что использовать Realm также просто, как и Room. Сергей выложил исходный код библиотеки и пример использования. Не придётся пилить ещё один велосипед, если столкнётесь с похожей ситуацией.
Сделал и написал черновик статьи, которой почти в неизменном виде, делюсь с вами. Он со своей стороны пообещал, что разгребётся с задачами и придёт в комментарии, если возникнут вопросы.
Бесконечный список и готовые решения
Одна из задач, с которой мы сталкиваемся — отобразить информацию списком, при прокручивании которого, данные подгружаются и вставляются незаметно для пользователя. Для пользователя это выглядит так, что он скроллит бесконечный список.
Алгоритм получается примерно следующий:
- получаем данные из кэша для первой страницы;
- если кэш пуст — получаем данные сервера, отображаем их в списке и пишем в БД;
- если кэш есть — загружаем его в список;
- если доходим до конца БД, то запрашиваем данные с сервера, отображаем их в списке и пишем в БД.
Упрощённо: для отображения списка в первую очередь опрашивается кэш, а сигналом загрузки новых данных является конец кэша.
Для реализация бесконечной прокрутки (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. Сергей выложил исходный код библиотеки и пример использования. Не придётся пилить ещё один велосипед, если столкнётесь с похожей ситуацией.