Retrofit 无法在 Android 应用程序中下载二进制文件——最终结果是损坏的文件大于预期
Retrofit fails to download binary file in an Android app – end result is a corrupted file that is larger than expected
我正在尝试在用 Kotlin 编写的 Android 应用程序上使用 Retrofit 2 下载 PDF 文件。下面的代码片段基本上是我的全部代码。根据我的日志输出,文件似乎已成功下载并保存到预期位置。
但是,下载的文件比预期的要大,而且已损坏。我可以用 PDF reader 打开它,但 PDF 是空白的。在下面的示例中,我尝试下载 https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf。如果我通过浏览器下载这个文件,结果是一个 13,264 字节的 PDF。然而,使用此代码下载后,它有 22,503 字节,比预期大大约 70%。对于 JPEG 等其他二进制文件,我得到了类似的结果。不过,下载一个TXT其实没问题,即使是大的。因此,问题似乎与二进制文件无关。
package com.ebelinski.RetrofitTestApp
import android.app.Application
import android.content.Context
import android.os.Build
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import com.google.gson.FieldNamingPolicy
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import org.jetbrains.anko.doAsync
import retrofit2.Call
import retrofit2.http.*
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.*
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
interface FileService {
@Streaming
@GET
@Headers("Content-Type: application/pdf", "Accept: application/pdf")
fun fileFromUrl(@Url url: String,
@Header("Authorization") tokenTypeWithAuthorization: String): Call<ResponseBody>
}
class MainActivity : AppCompatActivity() {
val TAG = "MainActivity"
val RETROFIT_CONNECT_TIMEOUT_SECONDS = 60
private val RETROFIT_READ_TIMEOUT_SECONDS = 60
private val RETROFIT_WRITE_TIMEOUT_SECONDS = 60
private val retrofit: Retrofit
get() {
val gson = GsonBuilder()
.setDateFormat("yyyyMMdd")
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create()
val converterFactory = GsonConverterFactory.create(gson)
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(RETROFIT_CONNECT_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS)
.readTimeout(RETROFIT_READ_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS)
.writeTimeout(RETROFIT_WRITE_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS)
.addInterceptor { chain ->
val userAgentValue = "doesn't matter"
val originalRequest = chain.request().newBuilder().addHeader("User-Agent", userAgentValue).build()
var response = chain.proceed(originalRequest)
if (BuildConfig.DEBUG) {
val bodyString = response.body()!!.string()
Log.d(TAG, String.format("Sending request %s with headers %s ", originalRequest.url(), originalRequest.headers()))
Log.d(TAG, String.format("Got response HTTP %s %s \n\n with body %s \n\n with headers %s ", response.code(), response.message(), bodyString, response.headers()))
response = response.newBuilder().body(ResponseBody.create(response.body()!!.contentType(), bodyString)).build()
}
response
}
.build()
return Retrofit.Builder()
.callbackExecutor(Executors.newCachedThreadPool())
.baseUrl("https://example.com")
.addConverterFactory(converterFactory)
.client(okHttpClient)
.build()
}
private val fileService: FileService = retrofit.create(FileService::class.java)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
doAsync { downloadFile() }
}
fun downloadFile() {
val uri = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
val auth = "doesn't matter"
val response = fileService.fileFromUrl(
uri,
auth
).execute()
if (!response.isSuccessful) {
Log.e(TAG, "response was not successful: " +
response.code() + " -- " + response.message())
throw Throwable(response.message())
}
Log.d(TAG, "Server has file for ${uri}")
saveFileFromResponseBody(response.body()!!)
}
// Returns the name of what the file should be, whether or not it exists locally
private fun getFileName(): String? {
return "dummy.pdf"
}
fun saveFileFromResponseBody(body: ResponseBody): Boolean {
val fileName = getFileName()
val localFullFilePath = File(getFileFullDirectoryPath(), fileName)
var inputStream: InputStream? = null
var outputStream: OutputStream? = null
Log.d(TAG, "Attempting to download $fileName")
try {
val fileReader = ByteArray(4096)
val fileSize = body.contentLength()
var fileSizeDownloaded: Long = 0
inputStream = body.byteStream()
outputStream = FileOutputStream(localFullFilePath)
while (true) {
val read = inputStream.read(fileReader)
if (read == -1) break
outputStream.write(fileReader, 0, read)
fileSizeDownloaded += read.toLong()
Log.d(TAG, "$fileName download progress: $fileSizeDownloaded of $fileSize")
}
outputStream.flush()
Log.d(TAG, "$fileName downloaded successfully")
return true
} catch (e: IOException) {
Log.d(TAG, "$fileName downloaded attempt failed")
return false
} finally {
inputStream?.close()
outputStream?.close()
}
}
fun getFileFullDirectoryPath(): String {
val directory = getDir("test_dir", Context.MODE_PRIVATE)
return directory.absolutePath
}
}
如果有帮助,这是我的 build.gradle
文件:
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 28
defaultConfig {
applicationId "com.ebelinski.RetrofitTestApp"
minSdkVersion 21
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.android.support:appcompat-v7:28.0.0'
implementation 'com.android.support.constraint:constraint-layout:1.1.3'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
implementation 'com.squareup.retrofit2:retrofit:2.5.0'
implementation 'com.squareup.okhttp3:okhttp:3.12.0'
implementation "org.jetbrains.anko:anko-commons:0.10.1"
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}
假设问题不在于 Retrofit,而在于你的 OkHTTP3 拦截器,具体如下:
val bodyString = response.body()!!.string()
这里是 string() 的内容:
/**
* Returns the response as a string.
*
* If the response starts with a
* [Byte Order Mark (BOM)](https://en.wikipedia.org/wiki/Byte_order_mark), it is consumed and
* used to determine the charset of the response bytes.
*
* Otherwise if the response has a `Content-Type` header that specifies a charset, that is used
* to determine the charset of the response bytes.
*
* Otherwise the response bytes are decoded as UTF-8.
*
* 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 string(): String = source().use { source ->
source.readString(charset = source.readBomAsCharset(charset()))
}
您可以查看 ResponseBody source code 了解更多详情。
假设改用 body.source() 会有所帮助。
(或者只是避免拦截二进制文件)
好的拦截器实现示例在这里:
HTTPLoggingInterceptor.
我正在尝试在用 Kotlin 编写的 Android 应用程序上使用 Retrofit 2 下载 PDF 文件。下面的代码片段基本上是我的全部代码。根据我的日志输出,文件似乎已成功下载并保存到预期位置。
但是,下载的文件比预期的要大,而且已损坏。我可以用 PDF reader 打开它,但 PDF 是空白的。在下面的示例中,我尝试下载 https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf。如果我通过浏览器下载这个文件,结果是一个 13,264 字节的 PDF。然而,使用此代码下载后,它有 22,503 字节,比预期大大约 70%。对于 JPEG 等其他二进制文件,我得到了类似的结果。不过,下载一个TXT其实没问题,即使是大的。因此,问题似乎与二进制文件无关。
package com.ebelinski.RetrofitTestApp
import android.app.Application
import android.content.Context
import android.os.Build
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import com.google.gson.FieldNamingPolicy
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import org.jetbrains.anko.doAsync
import retrofit2.Call
import retrofit2.http.*
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.*
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
interface FileService {
@Streaming
@GET
@Headers("Content-Type: application/pdf", "Accept: application/pdf")
fun fileFromUrl(@Url url: String,
@Header("Authorization") tokenTypeWithAuthorization: String): Call<ResponseBody>
}
class MainActivity : AppCompatActivity() {
val TAG = "MainActivity"
val RETROFIT_CONNECT_TIMEOUT_SECONDS = 60
private val RETROFIT_READ_TIMEOUT_SECONDS = 60
private val RETROFIT_WRITE_TIMEOUT_SECONDS = 60
private val retrofit: Retrofit
get() {
val gson = GsonBuilder()
.setDateFormat("yyyyMMdd")
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create()
val converterFactory = GsonConverterFactory.create(gson)
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(RETROFIT_CONNECT_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS)
.readTimeout(RETROFIT_READ_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS)
.writeTimeout(RETROFIT_WRITE_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS)
.addInterceptor { chain ->
val userAgentValue = "doesn't matter"
val originalRequest = chain.request().newBuilder().addHeader("User-Agent", userAgentValue).build()
var response = chain.proceed(originalRequest)
if (BuildConfig.DEBUG) {
val bodyString = response.body()!!.string()
Log.d(TAG, String.format("Sending request %s with headers %s ", originalRequest.url(), originalRequest.headers()))
Log.d(TAG, String.format("Got response HTTP %s %s \n\n with body %s \n\n with headers %s ", response.code(), response.message(), bodyString, response.headers()))
response = response.newBuilder().body(ResponseBody.create(response.body()!!.contentType(), bodyString)).build()
}
response
}
.build()
return Retrofit.Builder()
.callbackExecutor(Executors.newCachedThreadPool())
.baseUrl("https://example.com")
.addConverterFactory(converterFactory)
.client(okHttpClient)
.build()
}
private val fileService: FileService = retrofit.create(FileService::class.java)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
doAsync { downloadFile() }
}
fun downloadFile() {
val uri = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
val auth = "doesn't matter"
val response = fileService.fileFromUrl(
uri,
auth
).execute()
if (!response.isSuccessful) {
Log.e(TAG, "response was not successful: " +
response.code() + " -- " + response.message())
throw Throwable(response.message())
}
Log.d(TAG, "Server has file for ${uri}")
saveFileFromResponseBody(response.body()!!)
}
// Returns the name of what the file should be, whether or not it exists locally
private fun getFileName(): String? {
return "dummy.pdf"
}
fun saveFileFromResponseBody(body: ResponseBody): Boolean {
val fileName = getFileName()
val localFullFilePath = File(getFileFullDirectoryPath(), fileName)
var inputStream: InputStream? = null
var outputStream: OutputStream? = null
Log.d(TAG, "Attempting to download $fileName")
try {
val fileReader = ByteArray(4096)
val fileSize = body.contentLength()
var fileSizeDownloaded: Long = 0
inputStream = body.byteStream()
outputStream = FileOutputStream(localFullFilePath)
while (true) {
val read = inputStream.read(fileReader)
if (read == -1) break
outputStream.write(fileReader, 0, read)
fileSizeDownloaded += read.toLong()
Log.d(TAG, "$fileName download progress: $fileSizeDownloaded of $fileSize")
}
outputStream.flush()
Log.d(TAG, "$fileName downloaded successfully")
return true
} catch (e: IOException) {
Log.d(TAG, "$fileName downloaded attempt failed")
return false
} finally {
inputStream?.close()
outputStream?.close()
}
}
fun getFileFullDirectoryPath(): String {
val directory = getDir("test_dir", Context.MODE_PRIVATE)
return directory.absolutePath
}
}
如果有帮助,这是我的 build.gradle
文件:
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 28
defaultConfig {
applicationId "com.ebelinski.RetrofitTestApp"
minSdkVersion 21
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.android.support:appcompat-v7:28.0.0'
implementation 'com.android.support.constraint:constraint-layout:1.1.3'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
implementation 'com.squareup.retrofit2:retrofit:2.5.0'
implementation 'com.squareup.okhttp3:okhttp:3.12.0'
implementation "org.jetbrains.anko:anko-commons:0.10.1"
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}
假设问题不在于 Retrofit,而在于你的 OkHTTP3 拦截器,具体如下:
val bodyString = response.body()!!.string()
这里是 string() 的内容:
/**
* Returns the response as a string.
*
* If the response starts with a
* [Byte Order Mark (BOM)](https://en.wikipedia.org/wiki/Byte_order_mark), it is consumed and
* used to determine the charset of the response bytes.
*
* Otherwise if the response has a `Content-Type` header that specifies a charset, that is used
* to determine the charset of the response bytes.
*
* Otherwise the response bytes are decoded as UTF-8.
*
* 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 string(): String = source().use { source ->
source.readString(charset = source.readBomAsCharset(charset()))
}
您可以查看 ResponseBody source code 了解更多详情。
假设改用 body.source() 会有所帮助。 (或者只是避免拦截二进制文件)
好的拦截器实现示例在这里: HTTPLoggingInterceptor.