Synetech

Stahování souborů pomocí knihovny Retrofit a zobrazování průběhu stahování

Jako každý SYNETECH vývojář se na projekt po jeho odevzdání často podívám zpětně, zda jsem něco nemohl naimplementovat lépe, ale ať už z důvodu budgetu, plnění termínů nebo že mě to jednoduše v tu chvíli nenapadlo, jsem nerealizoval nejoptimálnější řešení. Většinou jsou to drobnosti, ale čas od času nacházím kód či chování aplikace, která ne úplně odpovídají standardům uživatelů android mobilních zařízení. Jedním takovým příkladem je stahování velkých souborů.

Součástí aplikace, kterou jsme vyvíjeli pro jednoho z našich klientů, bylo stahování zip souborů, které v sobě obsahovaly veškeré zdrojové i multimediální soubory nějaké webové stránky. Stažené soubory se ukládaly lokálně, to umožnilo uživatelům zobrazovat stažené zazipované webové stránky i při nedostupnosti zařízení k internetu. Stažení konkrétního zip souboru lze technicky dosáhnout jednoduchým HTTP GET požadavkem. Tradičně používáme knihovnu Retrofit pro poskytnutí tohoto HTTP požadavku aplikaci.

import okhttp3.ResponseBody
import retrofit2.http.GET

interface FileDownloadApi {
    @GET("someFile.zip")
    suspend fun downloadZipFile(): ResponseBody
}

Problémy, na které jsme narazili

Ihned jsme tam ale narazili na problém: běžně jako součástí best practices nastavujeme síťovým požadavkům časový limit 30ti sekund. Některé zip soubory však dosahovaly desítky (vzácně stovky) MB, a takový časový limit požadavku na stažení takových souborů nestačil. Museli jsme jako řešení nastavit pro tento konkrétní požadavek mnohem vyšší časový limit, v řádu minut.

Druhý problém byl ten, že Retrofit se snaží celý stahovaný soubor nejprve ukládat do runtime paměti aplikace. To může u značně větších souborů způsobit chybovou hlášku OutOfMemoryError a následně pád aplikace.

A posledním, ale nejviditelnějším problémem, byl z hlediska uživatelské přívětivosti. Uživatelům během stahování zobrazujeme nekonečné kolečko načítání na prázdném pozadí, a vzhledem k tomu, že stahování může u větších souborů trvat několik minut, to leckterého uživatele odradí. Navíc s použitou implementací nebylo možné zjistit, kolik dat Retrofit požadavek doposud již stáhl, takže jsme uživatele o průběhu stahování nemohli informovat.

retrofit_knihovna

Pohledem zpět na projekt jsem identifikoval prostor pro zlepšení i příležitost si rozšířit znalosti, které jsem pro danou problematiku postrádal. Objevil jsem Retrofit anotaci @Streaming, která by měla značnou měrou pomoct vyřešit popsané problémy.

Předpoklady

Jste obeznámeni s technologiemi Kotlin, Coroutines a Retrofit.

Implementace

Začněme náhledem do dokumentace Retrofit anotace @Streaming:

Treat the response body on methods returning ResponseBody as is, i.e. without converting the body to byte[].

Znamená to tedy, že pro HTTP požadavky s odpovědí typu ResponseBody za použití této anotace budeme moci nakládat s odpovědí v jejím nezpracovaném stavu. Pro úplnost přikládám dokumentaci k metodě ResponseBody.bytes(), která se používá pro konverzi odpovědi do pole bajtů byte[], a která se volá defaultně v případě, že @Streaming anotace není aplikována. Z dokumentace je patrná příčina případné zmiňované chyby OutOfMemoryError.

/**
* Returns the response as a byte array.
* This method loads entire response body into memory. If the response body is very large this may trigger an OutOfMemoryError. Prefer to stream the response body if this is a possibility for your response.
*/@Throws(IOException::class)
fun bytes() = ...

Pro účely našeho scénáře tedy nadefinujeme HTTP požadavek pro stahování souboru takto:

@Streaming
@GET("large_file_endpoint")
suspend fun downloadLargeFile(): ResponseBody

Dalším krokem pro nás bude implementace vlastního způsobu nakládání s odpovědí (typu ResponseBody), kterou nám server vrátí pro zmíněný HTTP požadavek. K tomuto účelu použijeme metodu ResponseBody.byteStream(), která nám umožní nakládat s odpovědí jako s plynulým tokem dat ze serveru, místo aby se počkalo na přijetí a načtení všech dat ze serveru pro danou odpověď do mezipaměti. Současně přijatá data budeme průběžně ukládat do lokálního uložiště.

private fun ResponseBody.saveFile() {
    val destinationFile = File(destination_path)
    byteStream().use { inputStream->
        destinationFile.outputStream().use { outputStream->
            inputStream.copyTo(outputStream)
        }
    }
}

HTTP požadavek máme nadefinovaný, nakládání s jeho odpovědí máme také naimplementované. Teď už zbývá jenom požadavek poslat:

scope.launch(Dispatchers.IO) {
    apiService.downloadLargeFile().saveFile()
}

A to je vše! S touto implementací už bychom neměli narazit na chybu OutOfMemoryError při stahování větších souborů za použití knihovny Retrofit. A protože už nečekáme, až přijde celá odpověď, ale jednáme s odpovědí jako s tokem dat, nenastává ani problém s časovým limitem pro síťové požadavky. Při každém přijetí částečných dat (kusu odpovědi) se totiž časový limit pro daný HTTP požadavek vyresetuje. Dokud tedy dostáváme nějaká data ze serveru v intervalu časového limitu, timeout by neměl nastat.

Stále ale zůstává jedna otázka: jak o průběhu stahování řádně informovat uživatele. InputStream.copyTo() zapisuje částečná data z odpovědi do námi definovaného lokálního uložiště, ale neumí nám moc říct o tom, kolik dat již zapsal a kolik toho ještě bude zapisovat. Zjistíme však brzy, že není obtížné upravit implementaci metody InputStream.copyTo() tak, aby byla schopná nám potřebné informace o průběhu stahování hlásit.

Začněme jednoduchou třídou, která reprezentuje možné stavy stahování. Jména podtříd by měla být samovysvětlující:

private sealed class DownloadState {
    data class Downloading(val progress: Int) : DownloadState()
    object Finished : DownloadState()
    data class Failed(val error: Throwable? = null) : DownloadState()
}

Tyto stavy budeme v průběhu stahování vysílat (emitovat) pomocí Kotlin Flow, podle těchto stavů naše aplikace potom zobrazí odpovídající stav obrazovky.

private fun ResponseBody.saveFile(): Flow<DownloadState> {
    returnflow{
        emit(DownloadState.Downloading(0))
        val destinationFile = File(destination_path)

        try {
            byteStream().use { inputStream->
                destinationFile.outputStream().use { outputStream->
                    val totalBytes = contentLength()
                    val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
                    var progressBytes = 0L
                    var bytes = inputStream.read(buffer)
                    while (bytes >= 0) {
                        outputStream.write(buffer, 0, bytes)
                        progressBytes += bytes
                        bytes = inputStream.read(buffer)
                        emit(DownloadState.Downloading(((progressBytes * 100) / totalBytes).toInt()))
                    }
                }
            }
            emit(DownloadState.Finished)
        } catch (e: Exception) {
            emit(DownloadState.Failed(e))
        }
    }
    .flowOn(Dispatchers.IO).distinctUntilChanged()
}

Všimněte si try/catch kódového bloku, nepřipomíná vám nějakou funkci? Ano, je skoro totožná s implementací metody InputStream.copyTo() (podívejte se na její zdrojový kód), jenom jsme přidali malý kus logiky, která nám emituje námi definované stavy průběhu stahování. Využili jsme přitom znalosti celkové velikosti stahovaného souboru (ResponseBody.contentLength()) a kumulativní velikosti dosud stažených dat (progressBytes).

A už nám zbývá poslední úkol, a to správně interpretovat emitované stavy na obrazovce aplikace:

apiService.downloadLargeFile().saveFile()
    .collect{downloadState->
        state = when (downloadState) {
            is DownloadState.Downloading -> {
                Log.d("myTag", "progress=${downloadState.progress}")
                showProgress(downloadState.progress)
            }
            DownloadState.Failed -> {
                showError()
            }
            DownloadState.Finished -> {
                showSuccess()
            }
        }
    }

A je to! Než článek uzavřeme, je tu poslední “háček”, který podle mě stojí za zmínku. Pro účely debugování nejspíš budete mít pro Retrofit instanci nastavený HttpLoggingInterceptor s úrovní logování Level.BODY:

HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)

Pokud máte nastavenou úroveň logování přísnější než HEADERS, odpověď na HTTP požadavek se vždy musí celá načíst (ve formě pole bajtů byte[]) do paměti pro účely logování. Tady nám nepomůže ani anotace @Streaming a nastává nám opět problém s načtením větších souborů do mezipaměti, který vede k chybě OutOfMemoryError. Mějte tedy tuto skutečnost na mysli při debugování své aplikace.

A úplně na závěr bych doporučil provést stažení souborů na foreground service, která je pro účely náročnějších asynchronních operací dělaná a se kterou nebudete při průběhu stahování blokovat uživatele před souběžným použitím dané aplikace či ostatních aplikací.

Děkuji za přečtení. Implementační detaily uvedené v článku najdete také v demo aplikaci zde.