まずこの記事について
この記事は #kosen10s Advent Calendar の6日目の記事です。
<- 前日の記事(@do_su_0805) 翌日の記事(@kogepan159)->
Kotlinについて何かを書きたい
ちょくちょくAndroidについて書いたりしている(最近イベントレポートしかしてない。まずい)んですが、最近業務でKotlinを書いてた(今はJavaに戻ったりしている)り、趣味コードや勉強会に出るときのサンプルを全部Kotlinにしたりしています。KotlinかわいいよKotlin。
というわけで今年も終わるということでちょっと趣味で書いているAndroid向けの画像ローダについて話してみます。ちゃんと欲しい機能ができればOSS化するつもりですが時間がなかなか取れなくてこいつにコミットする時間がなくてア
作りたい物
Androidで画像ローダといったら square/picasso や bumptech/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)
(context as Activity).runOnUiThread {
target.setImageBitmap(bitmap)
}
}
})
}
ちなみにここでのバイト配列からの縮小画像生成はByteArrayにKotlinの拡張関数を生やしたりして手抜きをしています
fun ByteArray.getBitmapOptions(): BitmapFactory.Options {
val imageOptions = BitmapFactory.Options()
imageOptions.inJustDecodeBounds = true
BitmapFactory.decodeByteArray(this, 0, this.size, imageOptions)
return imageOptions
}
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として実装された言語機能で、コルーチンを扱う低レベルAPIの kotlin.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) {
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) {
continuation.resumeWithException(Exception("ResponseBody is null"))
} else {
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) {
OkHttpClient().newCall(request).do().bytes()
}.await()
val options = async(CommonPool) {
byteArray.getBitmapOptions()
}.await()
val bitmap = async(CommonPool) {
byteArray.decodeByteArray(options, byteArray.size)
}.await()
target.setImageBitmap(bitmap.await())
}
}
まとめ
こんな感じでkotlinx.coroutinesを利用することで動作スレッド・処理の完了待機を明示的に宣言でき、非同期処理を同期処理っぽい羅列で書くことが出来ます。やはりこういう機能やPromiseなりで非同期処理を完結にかけると嬉しいですね。
実際はpicasso/glideが強すぎるのでこういう完全な車輪の再発明はうーんうーんって感じになりがちですが、新しい機能なりで試すと結構勉強になったりでいいなぁと思いました。
追記
今回はコルーチン内のasyncを途中でキャンセルさせることをあまり考えていませんでしたが、実際にコルーチンをキャンセルするとその中のasyncもキャンセル出来ないケースがあるそうで、そういうケースにはasyncのCoroutineContextにContext(こっちはAndroidの方)を与えることで親のジョブの子のような扱いになり、ジョブをキャンセルしたときに子もキャンセルされるようになるらしいです。そして async(context + CommomPool)
のように和で指定することでUIスレッドでない状態でかつジョブのキャンセルもされるようになるらしいです。
↓参考↓
medium.com