Реализация прогресс бара при отправке или загрузке файлов

от автора

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

Для отправки есть один точный вариант, который работает как часы. Основная задача — переопределить класс 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, который равен 8 * 1024 бит. В это же время нужно добавить коллбек, который будет возвращать уже записанное число бит.

Итоговый код  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 Надеюсь, статья была полезной. Буду благодарен за обратную связь!


ссылка на оригинал статьи https://habr.com/ru/articles/735710/