Как написать загрузчик для приложения

Решим задачу по созданию загрузчика и разберёмся, какие частые ошибки встречаются в коде (и как их можно исправить)

Базовые функции

Наша задача — загрузить N постов с бэкенда и отобразить их в ленте.

Artboard

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

Затем переключаемся на главный поток, чтобы работать с view. В adapter для recyclerview вставляем данные, которые загрузили, и уведомляем его о том, что пришли новые данные.

lifecycleScope. launch(Dispatchers.I0) {
val items = feedApi.getApi( )
withContext(Dispatchers.Main) {
adapter.appendItems(items)
adapter.notifyDataSetChanged( )
}
}

Добавляем пагинацию

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

Так как у нас появляется пагинация, введём понятие current page. Для этого заводим переменную, которая обозначает, какую page мы загрузим следующей, — изначально нулевую. Затем при начале загрузки увеличиваем её, чтобы отобразить следующую страницу.

var currentPage = 0

fun load( ) {
val pageToLoad = currentPage++
lifecycleScope. launch(Dispatchers.I0) {
val items = feedApi.getApi(pageToLoad)
withContext(Dispatchers.Main) {
adapter.appendItems(items)
adapter.notifyDataSetChanged( )
}
}
}

После передаём page в API, загружаем нужную страницу и прибавляем загруженные данные в адаптер. Важно, что у адаптера мы вызываем метод appendItems, который прибавляет объекты к уже существующим. Обратите внимание, тут не setItems, мы не обновляем их, а присоединяем.

Но это ещё не пагинация. Нам нужно загружать данные, когда пользователь проскролил до конца. Для этого можно добавить listener на recycler. Мы смотрим, какой предмет последний, и если до конца списка осталось немного — начинаем следующую загрузку.

recyclerView.addOnScrollListener(object :
RecyclerView.OnScrollListener( ) {
override fun onScrolled(
recyclerView: RecyclerView,
dx: Int,
dy: Int
)  {
val lastVisibleIndex = findLastVisibleItemPosition( )
if (getItemsCount( ) – LastVisibleIndex <= THRESHOLD) {
load( )
}
}

})

Здесь возникает проблема: мы никак не ограничиваем количество запросов. Этот метод вызывается при любом изменении скролла, и каждый раз он запускает новую загрузку. Поэтому нужно добавить новые флаги, что у нас идёт загрузка: так мы не даём начать следующую. В начале загрузки также уточняем, что она запущена. Если всё успешно, флаг устанавливаем false и указываем, что активной загрузки нет и можно загружать снова.

var isLoading = false


var currentPage = 0

fun load( ) {
if (isLoading) {
return
}
isLoading = true
val pageToLoad = currentPage++
lifecycleScope. launch(Dispatchers.I0) {
val items = feedApi.getApi(pageToLoad)
withContext(Dispatchers.Main) {
isLoading = false
adapter.appendItems(items)
adapter.notifyDataSetChanged( )
}
}
}

Добавляем кеш

Пользователи не хотят ждать, пока мы загрузим данные из сети, они хотят сразу видеть их при открытии приложения. Для этого нужно добавить кеш.

Структура осталась та же, но добавляется переменная cacheEnded. Она показывает, есть ли данные в кеше. Если данных нет, то cacheEnded = true — грузим данные из API.

Для этого мы делаем запрос в API, получаем данные, кладём их в базу данных и возвращаем. Представим, что все инструменты поддерживают один и тот же тип. Что делать, если кеш не кончился? Отправляем в него запрос.

Однако может возникнуть ситуация, когда кеш окажется пустым. В таком случае мы ставим флаг, что он закончился. За новой страницей делаем запрос в сеть, её кладём в кеш и возвращаем.

fun load(clearDataBeforeSet: Boolean) {
val pageToLoad = currentPage
//…
val loadJob = lifecycleScope. launch(Dispatchers.I0) {
val = items = try {
if (cacheEnded) {
val items = feedApi.getApi(pageToLoad)
db.putPage(pageToLoad, items)
items
} else {
val items = db.getPage(pageToLoad)
if (items.isEmpty()) {
withContext(Dispatchers.Main) {
cacheEnded = true
}
val items = feedApi.getApi(pageToLoad)
db.putPage(pageToLoad, items)
items
} else {
items
}
}
}
}
}

В итоге для реализации 5–6 задач получилось полотно кода. Он сложный: в нём трёхуровневые if, прямая работа с потоками, разные флаги, которые друг с другом не связаны. С этим работать уже откровенно сложно.

Делаем особый вид экрана

Если у пользователя нет подписок и в ленту не пришло ни одной картинки, ему показывается предложение подписаться на кого-нибудь. Если в адаптере сейчас нет предметов, будем показывать какой-то пустой state. Например, предложение подписаться. Если предметы есть, например картинки, — покажем их.

Artboard

Но это не работает, потому что изначально, когда начинается работа вашего feed, адаптер пуст. Возможно, вы часто видели такие баги в разных приложениях. Например, в Glovo: когда вы заходите на экран заказов, а там говорится, что у вас пусто. Затем начинается загрузка и появляются ваши заказы.

Добавим, что в случае успешной загрузки, если предметы пустые, показываем пустое состояние, иначе — применяем обычную логику. Это иногда может работать, но приводит к багу. Чтобы его исключить, добавим ещё один if, который будет проверять, что нулевая страница сразу пустая.

fun load(clearDataBeforeSet: Boolean) {
if (isLoading) {
return
}
val pageToLoad = currentPage
isLoading = true
loadJob = lifecycleScope. launch(Dispatchers.I0) {
val = items = //…
withContext(Dispatchers.Main) {
currentPage++
isLoading = false
adapter.setIsError(false)
if (items.isEmpty( ) pageToLoad = = 0) { 
adapter.showEmptyState( )
} else {
if (clearDataBeforeSet) {
adapter.setItems(items)
} else {
adapter.appendItems(items )
} 
adapter.notifyDataSetChanged( )
} 
} 
} 

Мы добавили ещё кода, работать с ним стало ещё сложнее. Теперь представьте, что мы живём не в идеальном мире, у нас есть ещё три разных слоя данных:

  • база данных;

  • данные из сети;

  • то, что мы сообщаем из приложения.

Как и любую большую функциональность, загрузчики лучше прятать за интерфейсами. Для загрузчиков можно взять удобный интерфейс loader. У него есть понятие «состояние» — то, что он вам сейчас показывает как клиенту:

  • данные (data);

  • флаг о том, загружает ли он что-то сейчас;

  • флаг об ошибке.

С помощью этих трёх флагов можно реализовывать очень большой спектр загрузок.

У loader есть два публичных метода: state, в котором лежат данные, и load, который клиенты дёргают по скроллу. С таким внешним состоянием, конечно, много не сделать. Нужно работать с внутренним состоянием, и тут полей уже побольше. С их помощью вы будете решать, как работать вашему loader. Я бы выделил шесть полей во внутреннем состоянии:

  • текущие данные;

  • флаг загрузки из кеша;

  • флаг загрузки из сети;

  • флаг, есть ли что-то в кеше;

  • флаг, есть ли что-то в сети;

  • флаг о том, что случилась ошибка в сети.

С помощью функции map вы получаете внутренний state и возвращаете публичный. Флажком isLoading показываете, загружается что-то из сети или из кеша. Флаг ошибки также есть во внутреннем state. Таким образом, внутри loader мы можем работать с внутренним state, а для клиентов он обновляется автоматически.

private val _state: MutableStateFlow<InnerState<Data>> =
MutableStateFlow(createDefaultState( ))

override val state: Flow<Loader.State<Data>> =
_state.map {
Loader.State(
data = it.currentData,
isLoading = it.isLoadingFromRemote || it.isLoadingFromCache,
isError = it.remoteError,
)
}

Добавляем корутины

Очень большую поддержку оказывают корутины, потому что у них можно легко взять один поток. Такой workDispatcher с помощью флага limitedParallelism позволяет обновлять внутренний state строго на одном потоке. Если экран закроется, все загрузки остановятся, поэтому также важно взять scope, чтобы ресурсы не потерялись.

@OptIn(ExperimentalCoroutinesApi: :class)
private val workDispatcher = Dispatchers.I0.limitedParallelism(1)

private val scope: CoroutineScope

Loader легко представить state-машиной. Мы берём scope, запускаем на workDispatcher и внутри этого процесса берём старый state. К нему применяем мутацию и устанавливаем новым state. Благодаря тому, что внутренний state преобразуется во внешний, будут мутировать оба.

private fun launchWork(block: (InnerState<Data>) -> InnerState<Data>) {
scope. launch(workDispatcher) {
 _state.value = block(_state.value)
}
}

Реализуем функции load и launchLoad

Мы вызываем наш утилитный метод launchWork, в который передаём лямбду — мутацию state. Нужно реализовать функцию launchLoad, которая берёт state и возвращает его, чтобы делать загрузку из кеша или из сети.

override fun load ( ) {
launchWork { state –>
launchLoad(state = state)
}
}

Мы начинаем работать с флагами. Главное преимущество этого подхода в том, что теперь наши флаги и номера страниц собраны в одном месте и мы работаем с ними строго с одного потока.

Сначала пытаемся запустить загрузку из кеша. Для этого вызываем метод launchCacheLoad. Если загрузить из кеша не получилось, смотрим, можем ли загрузить из сети. Для этого нужно, чтобы соблюдались условия:

  • в сети есть данные и feed не кончился;

  • из сети и кеша сейчас ничего не грузят;

  • нет ошибки.

private fun launchLoad(state: InnerState<Data>,): InnerState<Data> {
if (state.hasMoreInCache && !state.isLoadingFromCache) {
return launchCacheLoad(state = state)
}

if (state.hasMoreInRemote
&& !state.isLoadingFromRemote
&& !state.isLoadingFromCache
&& !state.remoteError
) {
return launchRemoteLoad(state)
}
return state
}

Этот состав флагов может быть разным, можно наворачивать какую угодно логику, главное — подход, что все флаги вместе и синхронизированы.

Если мы можем запустить загрузку из сети, запускаем. Если не смогли, возвращаем state — ничего не изменилось.

Запускаем загрузку из кеша

LaunchCacheLoad берёт новую страницу для загрузки. Предположим, что данные знают, какую страницу грузить дальше. Тогда берём и отдельным потоком запускаем загрузку из репозитория, например вашего локального.

Мы оказываемся в ситуации, когда мы на стороннем потоке, а не в state-машине, и эти данные нужно вернуть. Тогда мы снова вызываем нашу функцию, получаем новый state и вызываем метод onCacheLoaded. Передаём новый state и данные, которые загрузили.

private fun launchCacheLoad(
state: InnerState<Data>,
): InnerState<Data> {
val pageToLoad = state.currentData.nextPage
scope. launch(Dispatchers.I0) {
val chunk = localRepository.load(pageToLoad)
launchwork {
onCacheLoaded(chunk, it)
}
}
return state.copy(
loadingFromCache = true
)
}

В CacheLoaded мы передаём не тот state, что был в начале загрузки, а тот, что будет на момент её окончания.

Когда загрузка завершилась, получаем загруженные данные, сливаем их с новыми и снова мутируем state. Так мы устанавливаем, что у нас есть новые и старые данные. Указываем, что теперь не грузим из кеша, и устанавливаем флаг о том, есть ли что-то в кеше.

private fun onCacheLoaded(
chunk: Data,
state: InnerState<Data>,
): InnerState<Data> {
val mergedData = state.currentData + chunk
return state.copy(
currentData = mergedData,
isLoadingFromCache = false,
hasMoreInCache = chunk.hasMore,
)
}

Запускаем загрузку из сети

При загрузке из сети могут возникать ошибки. Как и раньше, берём номер страницы, начиная с которой нам необходимо грузить. Затем делаем API-запрос в удалённый репозиторий — нашу сеть или облако. Смотрим, пришли ли данные.

Если данные не пришли, значит, случилась какая-то ошибка. Её нужно обработать. Здесь пользуемся тем же механизмом: получаем актуальный state на момент окончания загрузки. Важно понимать, что в этот промежуток между началом загрузки из сети и окончанием никаких новых загрузок начато быть не может.

private fun launchRemoteLoad(state: InnerState<Data>): InnerState<Data> {
val pageToLoad = state.currentData.nextPage
scope. launch(Dispatchers.I0) {
val result = invokeApiCall { remoteRepository.load(pageToLoad) }
val chunk = result.getOrNull()
if (chunk == null) i
launchWork {
it.copy(
isLoadingFromRemote = false,
remoteError = true,
)
} 
return@launch
}
launchWork {
onRemoteLoaded(chunk, it)
}
}
return state.copy(
isLoadingFromRemote = true
)
}

Если данные загрузились, мы так же берём актуальный state на текущий момент и вызываем onRemoteLoaded. Всё происходит на стороннем потоке, а на потоке в нашей state-машине мы мутируем state и говорим, что теперь загружаем из кеша.

В методе onRemoteLoaded мы сливаем данные и говорим, что теперь у нас в сети есть столько-то. Сообщаем, что теперь нет загрузки из кеша и нет ошибки, потому что мы загрузили из сети успешно.

Краткий пересказ от Yandex GPT