woshidan's loose leaf

ぼんやり勉強しています

RetrofitのファイルDLでハマった話

原因をはっきり検証したわけでなく体感的なメモに近いのですが、対応していて面白かったので、適当に業務に関する部分のコード削りながら、こっちに置きます。

  • Retrofitの通信処理をバックグラウンドに指定して、InputStream型のレスポンスの処理をメインスレッドに指定したらandroid.os.NetworkOnMainThreadException
    • レスポンスボディのログを吐いた場合はエラーが出ないが、レスポンスボディのログを吐かないとエラー
    • おそらくInputStreamを受け取った時点では全部のデータをサーバから受け取っていなくて、バッファーに読み込むときに足りなくなったらサーバからデータを追加で読み込んでいるとかでは
      • ログを吐いた場合エラーがでないのはログを吐くために一旦データをすべて読み込んでいるため?
      • 同じような原因で、読み込めたバイト数を指定せずにFileOutputStream#writeメソッドを呼んでいたらサーバ上のファイルサイズとDLしたファイルサイズがずれた
  • binaryのjpegutf-8JSONで読み込んで画像が途中からばけた

上記をふまえた注意点のコメントが入った雑なコードを置いておきます。

// Kotlinです
apiClient.downloadItemFile(itemId)
        .map {  response ->
            var downloadFile = writeResponseStreamToFile("/absolute/to/path", response)
            downloadFile // InputStreamを扱っている部分もバックグラウンドで処理されるようにobserveOnの置き所に注意
        }.map { downloadFile ->
            downloadFile?.let {
                insertContentProvider("fileName", "/absolute/to/path")
            } // ここはもしかしたらメインスレッドでもいいかも
        }
        . subscribeOn(Schedulers.newThread())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe( ... )

// ApiClient#downloadItemFile()のインターフェイス
        @Streaming
        @GET("/items/{itemId}/download")
        fun downloadItemFile(
                @Path("itemId") itemId: Int,
                // RetrofitでJSON以外のファイルを扱う場合はContentTypeヘッダの設定を行うこと
                @Header("Content-Type") contentType: String = "image/jpg; charset=binary"
        ): Observable<Response>

// absoluteFilePathで渡されたパスのファイルへ、Responseのバイト列を書き込む
private fun writeResponseStreamToFile(absoluteFilePath: String, response: Response) : String? {
    var inputStream : InputStream? = null
    var outputStream : FileOutputStream? = null

    try {
        if (response.status == 200) {
            inputStream = response.body.`in`()
            var buff = kotlin.ByteArray(4096)
            val fileSize = response.body.length()
            var downloadedSize = 0L
            var readByte = 0

            outputStream = FileOutputStream(absoluteFilePath)

            while(downloadedSize < fileSize) {
                // InputStreamにすべてのデータがのっていない場合は、
                // ネットワーク上から追加でファイルを読み込みながらbuffにバイト列を書き込むので
                // 書き込めるバイト数は不定になるので注意
                readByte = inputStream.read(buff)
                if (readByte == -1) {
                    break
                }
                outputStream.write(buff, 0, readByte)  // バイト数はちゃんと指定しましょう
            }
            outputStream.flush()
        }
    } catch (e: IOException) {
        e.printStackTrace()
    } finally {
        inputStream?.close()
        outputStream?.close()
        return absoluteFilePath
    }
}

// fileNameで渡されたファイル名のファイルをContentProviderに登録
private fun insertContentProvider(fileName: String, absoluteFilePath: String) {
    var values = ContentValues()
    val contentResolver = mContext.contentResolver
    values.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
    values.put(MediaStore.MediaColumns.TITLE, fileName)
    values.put("_data", absoluteFilePath);
    contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
}