どくぴーの備忘録

真面目なことを書こうとするクソメガネのブログ。いつ投げ捨てられるのかは不明

kotlinx.coroutinesでAndroid用画像ローダを実現する

まずこの記事について

この記事は #kosen10s Advent Calendar の6日目の記事です。

<- 前日の記事(@do_su_0805) 翌日の記事(@kogepan159)->

Kotlinについて何かを書きたい

ちょくちょくAndroidについて書いたりしている(最近イベントレポートしかしてない。まずい)んですが、最近業務でKotlinを書いてた(今はJavaに戻ったりしている)り、趣味コードや勉強会に出るときのサンプルを全部Kotlinにしたりしています。KotlinかわいいよKotlin。

というわけで今年も終わるということでちょっと趣味で書いているAndroid向けの画像ローダについて話してみます。ちゃんと欲しい機能ができればOSS化するつもりですが時間がなかなか取れなくてこいつにコミットする時間がなくてア

作りたい物

Androidで画像ローダといったら square/picassobumptech/glide が有名ですが、これらと似たような使用感の小さいライブラリを用意したいという感じです。usageとしてはこんな感じ

Katsushika.with(context)
        .load(url)
        .into(imageView)

トップクラスの命名は適当です。これを思いついたときはSplatoon2にドハマリしてたので思考回路がわかる人にはわかるはず。

何が必要か

ネットワークを介して画像をロードするので、Androidでは当然非同期処理を扱います(メインスレッドで通信はできない)。非同期で扱いたい処理を挙げると

  • 画像のネットワークからのダウンロード
  • 画像の縮小
  • etc.(キャッシュとか。今回は考えない)

そしてこれらの処理を終えた後にImageViewに対して画像を反映させるわけです。

まずは画像取得以外を作ってみる

とりあえずネットワーク周りはめんどくさいので今のところは square/okhttp を頼ります。

とりあえず最初のスニペットで動かせるように考えて実際にBitmapの取得以外をKotlinで実装するとこんなコードになるはず。 @JvmStatic をcompanion object内のwith関数につけるとJavaコードからも読めるようになるけど今回は省略。

class Katsushika private constructor(private val context: Context) {

    private var url: String? = null

    companion object {
        fun with(context: Context): Katsushika {
            return Katsushika(context)
        }
    }

    fun load(url: String): Katsushika {
        this.url = url
        return this
    }

    fun into(target: ImageView) {
        url ?: return

        // ここで画像ロード
    }

}

実際にpicassoやglideみたいに機能をバシバシつけたいときはこの部分を拡充していくことになるのだけど、キリがないので今回はここまでにします。

画像ロードをする

というわけでinto関数の中身を実装していきましょう。まずは愚直にCall.enqueueのCallbackを使って実装してみます。

fun into(target: ImageView) {

    OkHttpClient().newCall(request).enqueue(object: Callback {
        override fun onResponse(call: Call?, response: Response?) {
            val body = response?.body()
            body ?: return

            // 取得した画像バイト配列から縮小画像生成
            val byteArray = body.bytes()
            val options = byteArray.getBitmapOptions()
            val bitmap = byteArray.decodeByteArray(options, byteArray.size)

            // 対象のImageViewに画像を表示
            (context as Activity).runOnUiThread {
                target.setImageBitmap(bitmap)
            }
        }
    })

}

ちなみにここでのバイト配列からの縮小画像生成はByteArrayにKotlinの拡張関数を生やしたりして手抜きをしています

// Bitmapのサイズ情報を取得する
fun ByteArray.getBitmapOptions(): BitmapFactory.Options {
    val imageOptions = BitmapFactory.Options()
    imageOptions.inJustDecodeBounds = true
    BitmapFactory.decodeByteArray(this, 0, this.size, imageOptions)
    return imageOptions
}

// 取得したBitmapFactory.Optionsを元に2のべき乗のサンプルサイズで縮小する
fun ByteArray.getScaledBitmap(target: ImageView, options: BitmapFactory.Options): Bitmap {
    val widthScale = options.outWidth/target.width
    val bitmap : Bitmap
    if (widthScale > 2) {
        val imageOptions = BitmapFactory.Options()

        var i = 2
        while (i <= widthScale) {
            imageOptions.inSampleSize = i
            i *= 2
        }

        bitmap = BitmapFactory.decodeByteArray(this, 0, this.size, imageOptions)
    } else {
        bitmap = BitmapFactory.decodeByteArray(this, 0, this.size)
    }
    return bitmap
}

ここでinto関数を見ると、 Callbackの中で runOnUiThread ブロックを実行したりしている都合でネストが深めになってしまっています。しかもこんな感じでバシバシCallbackを使う処理を使っているとコールバック地獄になったりしてかなり怖い感じです。どうせならPromiseとか使って上から流れるように書いておきたい…

kotlinx.coroutinesを投入する

Coroutines - Kotlin Programming Language

kotlinx.coroutines はkotlin 1.1にてExperimentalとして実装された言語機能で、コルーチンを扱う低レベルAPIkotlin.coroutines.experimental を扱う高レベルAPIとなっています(開発者がコルーチンを扱うために触るAPIはこっち)。公式リファレンスのコルーチンに関するページを雑に読んでみると

  • コルーチンは軽量のスレッドのようなもの
  • 非同期処理の呼び出しでコルーチンを中断・再開することができる

みたいなことが書いています。kotlinx.coroutinesは外部ライブラリとして提供されているので、使用する際にはbuild.gradleのdependenciesに宣言してあげます。

dependencies {
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:0.20"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:0.20"
}

詳しく日本語で読みたい場合は以下の記事とかがいいかも…

qiita.com

コルーチンの中断・再開というのがとても重要で、コルーチン内から呼ぶことができるsuspend関数と言うものを使うようになります

suspend fun foo(): Bar { // 何かしらの処理... }

kolinx.coroutinesではES7やC# 5.0以降で提供されるようなasync/awaitが一機能として提供されており、非同期処理を同期処理っぽく書くことが出来ます。

fun requestItems() {
    itemApi.getItems(object: Callback {
        override fun onSuccess(item: Item) {
            textView.text = item.name
        }
    })
}

という風に書いていたものがkotlinx.coroutinesのasync/awaitを使うことで

fun requestItems() {
    val job = launch(UI) {
        // 一旦ここで関数の実行がitemApi.getItems()が完了するまで止まる
        val item = async { itemApi.getItems() }.await()
        textView.text = item.name
    }
}

こんな風にかけるようになります。 launch(UI) のブロックはコルーチンビルダーといい、引数のCoroutineContextにUIを与えることでUIスレッドで動くコルーチンを作成することになります。async のブロックでは新しく別のスレッドで動くコルーチンを作成し、await()を呼ぶことでブロック内の処理が完了するまで停止することができ、ブロック内の返り値を処理の完了後に返すことが出来ます。asyncの引数としてこちらもCoroutineContextを渡したりでき、これによって実行するスレッドを制御したりすることが可能です。

すると最初に書いたinto関数もいい感じに書けそうだ、という感じがしてきます。とはいうもののまずはCall.enqueueをsuspend関数として呼べるようにCallに拡張関数を用意してあげる必要がありそう。

Call.enqueueをsuspendCancellableCoroutineで包んであげて、その結果に応じてcontinuationの発火をさせるメソッドを変更するようにしてあげます(理解不足で説明が謎なことになっている…)

suspend fun Call.do(): ResponseBody {
    return suspendCancellableCoroutine { continuation ->
        enqueue(object : Callback {
            override fun onResponse(call: Call, response: Response) {
                val responseBody = response.body()
                if (responseBody == null) {
                    // responseBodyが空なら失敗として扱う
                    continuation.resumeWithException(Exception("ResponseBody is null")) 
                } else {
                    // このresumeがawait()した結果の返り値となる
                    continuation.resume(responseBody) 
                }
            }

            override fun onFailure(call: Call, e: IOException) {
                if (continuation.isCancelled) return
                continuation.resumeWithException(e)
            }
        })
    }
}

最後に、これを扱うようにinto関数を書き換えて完了です。

fun into(target: ImageView) {
    url ?: return

    val job = launch(UI) {
        val byteArray = async(CommonPool) { // Responseを待ってからバイト配列を取得
            OkHttpClient().newCall(request).do().bytes()
        }.await()
        
        val options = async(CommonPool) { // 結果からBitmapFactory.Optionsを取得
            byteArray.getBitmapOptions()
        }.await()

        val bitmap = async(CommonPool) { // 上2つが終わったらBitmapを縮小
            byteArray.decodeByteArray(options, byteArray.size)
        }.await()

        target.setImageBitmap(bitmap.await()) // bitmapが出力されたらtargetに表示
    }
}

まとめ

こんな感じでkotlinx.coroutinesを利用することで動作スレッド・処理の完了待機を明示的に宣言でき、非同期処理を同期処理っぽい羅列で書くことが出来ます。やはりこういう機能やPromiseなりで非同期処理を完結にかけると嬉しいですね。

実際はpicasso/glideが強すぎるのでこういう完全な車輪の再発明はうーんうーんって感じになりがちですが、新しい機能なりで試すと結構勉強になったりでいいなぁと思いました。

追記

今回はコルーチン内のasyncを途中でキャンセルさせることをあまり考えていませんでしたが、実際にコルーチンをキャンセルするとその中のasyncもキャンセル出来ないケースがあるそうで、そういうケースにはasyncのCoroutineContextにContext(こっちはAndroidの方)を与えることで親のジョブの子のような扱いになり、ジョブをキャンセルしたときに子もキャンセルされるようになるらしいです。そして async(context + CommomPool) のように和で指定することでUIスレッドでない状態でかつジョブのキャンセルもされるようになるらしいです。

↓参考↓

medium.com