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.