Не так давно столкнулся с задачей по отображению прогресс бара при отправке файла. Начал искать информацию по данной теме и понял, что ничего толкового на русском языке нет. Подумал-подумал и решил написать свою статью о способах отслеживания прогресса при загрузке и отправке файлов.
Для отправки есть один точный вариант, который работает как часы. Основная задача — переопределить класс okhttp3.RequestBody
.
Для получения прогресса во время загрузки есть такой же простой вариант, аннотация @Streaming
. Eсть еще один вариант, но он более продвинутый и построен на встроенном DownloadManager
.
Отправка файла с получением прогресса
Как говорилось ранее, основная задача — это сделать собственный класс RequestBody
, назовем его ProgressRequestBody
и унаследуем от RequestBody.
class ProgressRequestBody(): RequestBody() {
override fun contentType(): MediaType? {
TODO("Not yet implemented")
}
override fun writeTo(sink: BufferedSink) {
TODO("Not yet implemented")
}
}
В contentType
мы просто должны вернуть MediaType
, который можно передать в конструктор нашего класса, а перед этим достать его из URI с помощь ContentResolver.getType()
context.contentResolver?.getType(URI)?.toMediaType()
В функцииwriteTo
нам необходимо записать в sink
наш файл частями, размер которых вы определяете сами. Я возьму DEFAULT_BUFFER_SIZE
, который равен бит. В это же время нужно добавить коллбек, который будет возвращать уже записанное число бит.
Итоговый код ProgressRequestBody
получился таким:
class ProgressRequestBody(
private val file: File,
private val contentType: MediaType,
private val callback: (Long, Long) -> Unit
): RequestBody() {
override fun contentType() = contentType
override fun writeTo(sink: BufferedSink) {
val length = file.length()
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
val fileInputStream = FileInputStream(file)
var uploaded = 0L
fileInputStream.use { inputStream ->
var read:Int
while (inputStream.read(buffer).also { read = it } != -1) {
uploaded += read.toLong()
callback(length, uploaded)
sink.write(buffer, 0, read)
}
}
}
}
Функция inputStream.read(buffer)
возвращает количество байт записанных в buffer
, если данных для чтение не осталось, то функция возвращает -1
В функцию интерфейса сервиса необходимо добавить аннотацию @Multipart
, а параметром передать файл с аннотацией @Part
и типом MultipartBody.Part
interface ApiService {
@Multipart
@POST("URL")
suspend fun uploadFile(
@Part file: MultipartBody.Part
): Response<ResponseBody>
}
Функция для отправки файла примет следующий вид:
fun uploadFile(file: File, api: ApiService, contentType: MediaType) {
viewModelScope.launch {
val requestBody = ProgressRequestBody(
file = file,
contentType = contentType
) { totalSize, uploaded ->
TODO("Добавить обработку полученных данных")
}
val multipartData = MultipartBody.Part.create(requestBody)
api.uploadFile(multipartData)
}
}
В итоге один простой класс позволяет реализовать отслеживание статуса отправки, который можно показать пользователю.
Загрузка файла с получением прогресса, используя DownloadManager
Самый подходящий вариант для загрузки файлов - это использование DownloadManager.
DownloadManager
— системный сервис, который обрабатывает длительные загрузки по протоколу HTTP. Этот сервис заслуживает отдельной статьи, так как имеет множество настроек и методов для работы с загрузкой.
Ниже приведен пример, на сколько просто можно загрузить файл с его помощью:
private val downloadManager by lazy {
requireContext().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
}
...
val downloadRequest = DownloadManager.Request(Uri.parse(URL))
.setTitle("My Dowload")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, FILENAME)
downloadManager.enqueue(downloadRequest)
Для базовой загрузки этого достаточно. Удобно, не правда ли?
Последней строкой запрос добавляется в очередь системного загрузчика. После этого должно появиться уведомление о начале загрузке, в котором будет отображаться прогресс загрузки + кнопка отмены.
Для нашего случая этого не достаточно, прогресс, конечно, отображается, но нам необходимо показывать его в нашем приложении. Для этого сделаем собственный ContentObserver
и зарегистрируем его.
val contentProviderObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) {
TODO("Добавить обработку полученных данных")
}
}
Метод onChange()
срабатывает, когда содержимое по данному uri
изменяется. В этот момент необходимо получить информацию о загрузке из DownloadManager
.
Для таких случаев у менеджера есть метод DownloadManager.query(Query query)
, инстанс которого можно получить с помощью метода setFilterById()
:
val query = DownloadManager.Query().setFilterById(downloadId)
В то же время downloadId
можно достать из уже знакомого метода enqueue()
:
val downloadId = downloadManager.enqueue(downloadRequest)
Метод DownloadManager.query(Query query)
возвращает cursor
, из которого мы и будем доставать необходимую информацию.
Далее получаем значения количества загруженных байтов и размер целого файла:
val downloadBytesColumnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val totalBytesColumnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
cursor.moveToFirst()
val curr = cursor.getInt(downloadBytesColumnIndex)
val total = cursor.getInt(totalBytesColumnIndex)
И остается только отобразить пользователю информацию, но сделать это нужно только после того, как total
будет неравен -1 (когда данные действительно появятся):
if (total!= -1) {
TODO("Добавить обработку полученных данных")
}
В итоге получаем ContentObserver
следующего вида:
val contentProviderObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) {
downloadManager.query(query).use { cursor ->
val downloadBytesColumnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val totalBytesColumnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
cursor.moveToFirst()
val curr = cursor.getInt(downloadBytesColumnIndex)
val total = cursor.getInt(totalBytesColumnIndex)
if (total != -1) {
TODO("Добавить обработку полученных данных")
}
}
}
}
Для регистрации ContentObserver
нам нужен URI места, куда загружается файл. Его можно получить все из того же DownloadManager.query(Query query)
следующим образом:
cursor.moveToFirst()
val index = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
val localUri = cursor.getString(index) //*
Будьте внимательны, при установке setDestinationInExternalPublicDir
или setDestinationInExternalFilesDir
localUri
будет возвращать null
. Потому при установке собсвенного пути не забывайте запоминать URI.
И последний штрих — это регистрация ContentObserver
:
contentResolver.registerContentObserver(Uri.parse(localUri), false, contentProviderObserver)
Все заворачиваем в функцию и получаем:
private fun createDownload(url: String) {
val downloadRequest = DownloadManager.Request(Uri.parse(link))
.setTitle("My Dowload")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
val downloadId = downloadManager.enqueue(downloadRequest)
val query = DownloadManager.Query().setFilterById(downloadId)
val contentProviderObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) {
downloadManager.query(query).use { cursor ->
val downloadBytesColumnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val totalBytesColumnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
cursor.moveToFirst()
val curr = cursor.getInt(downloadBytesColumnIndex)
val total = cursor.getInt(totalBytesColumnIndex)
if (total != -1) {
TODO("Добавить обработку полученных данных")
}
}
}
}
downloadManager.query(query).use {
it.moveToFirst()
val index = it.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
val localUri = it.getString(index)
context.contentResolver.registerContentObserver(Uri.parse(localUri), false, contentProviderObserver)
}
}
P.S. Если вы хотите скрыть уведомление от системного загрузчика, то можете установить флаг VISIBILITY_HIDDEN
в .setNotificationVisibility
, но для этого необходимо добавить пермишен в манифест:
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
Загрузка файла с получением прогресса, используя аннотацию Retrofit’a
Переходим ко второму варианту, который выглядит просто только на первый взгляд. Сразу после того, как вы решите сделать правильную и безопасную реализацию, вы столкнетесь с большим количеством вопросов: “А что будет после закрытия приложения? Что будет, если перейти на другой фрагмент?”
Для правильного функционирования нужно будет написать кучу кода и воспользоваться WorkManager’ом
для работы в фоне (но и тут есть нюансы, т.к WorkManager
не дает гарантии того, что ваша работа будет запущена в тот же момент, когда вы нажали на кнопку “Загрузить”).
Для реализации этого варианта потребуется аннотация @Streaming
. Она позволяет обрабатывать ответ без преобразования тела в byte[]
, поэтому у нас есть возможность оперировать скачиваемым потоком данных так, как нам это необходимо.
Остется добавить ее к фунции сервиса:
interface ApiService {
@Streaming
@GET
suspend fun downloadFile(
@Url url: String
): ResponseBody
}
И добавить функцию, которая во время считывания данных будет рассчитывать прогресс загрузки и отправлять его UI:
fun downloadFileWithRetrofit() {
viewModelScope.launch {
val response = api.downloadFile()
response.byteStream().use { inputStream ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var progressBytes = 0L
val totalSize = response.contentLength()
var bytes = inputStream.read(buffer)
while (bytes >= 0) {
progressBytes += bytes
val percent = ((progressBytes * 100 /totalSize))
bytes = inputStream.read(buffer)
_progress.emit(percent.toInt())
}
}
}
}
И все готово. Мы получили проценты, а затем отобразили их пользователю.
P.S. Не забудьте сохранить скачанный файл
P.P.S Надеюсь, статья была полезной. Буду благодарен за обратную связь!
Rusrst
На английском информации же навалом по этой теме. GitHub тоже никто не отменял пока...
alexannu Автор
Согласен, на английском достаточно информации)
Но считаю, что на русском статьи тоже нужны — можно упрощать другим разработчикам поиск информации.