首页 > 解决方案 > Retrofit 无法在 Android 应用程序中下载二进制文件 - 最终结果是文件损坏,比预期大

问题描述

我正在尝试在用 Kotlin 编写的 Android 应用程序上使用 Retrofit 2 下载 PDF 文件。下面的片段基本上是我的整个代码。根据我的日志输出,文件似乎已成功下载并保存到预期位置。

但是,下载的文件比预期的要大,并且已损坏。我可以在 PDF 阅读器中打开它,但 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'
}

标签: androidkotlinretrofit2okhttp3

解决方案


假设问题不在于改造,而在于您的 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源代码以获取更多详细信息。

假设使用 body.source() 会有所帮助。(或者只是避免拦截二进制文件)

好的拦截器实现示例在这里: HTTPLoggingInterceptor


推荐阅读