Не так давно столкнулся с задачей по отображению прогресс бара при отправке файла. Начал искать информацию по данной теме и понял, что ничего толкового на русском языке нет. Подумал-подумал и решил написать свою статью о способах отслеживания прогресса при загрузке и отправке файлов.
Для отправки есть один точный вариант, который работает как часы. Основная задача — переопределить класс 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 Надеюсь, статья была полезной. Буду благодарен за обратную связь!