2023年5月27日土曜日

Compose Multiplatform で Image loading ってどうやるの?

技術書典14用に書いたんだけど mhidaka 多忙により寝かされてしまったため、ここで供養します。
夏コミ(C102)は別の記事を書く予定です。

==========================


今年(2023年)のKotlinConf(https://kotlinconf.com/)でCompose Multiplatformが発表されました。これまではCompose Desktopを使うことでAndroidとDesktopでUIコードを共通化することができました。Compose Multiplatformではこれに加えiOSとWebもサポートしています。2023年5月時点ではiOSはAlpha版、WebはExperimental版です。

この章ではAndroidとiOSのUIをCompose Multiplatformで共通化したときに、画像をネットワークから非同期に読み込む方法を紹介します。

ここで紹介する方法は2023年5月時点のものであり、今後APIが変わる可能性が大いにあります。注意してください。



Resources

Compose MultiplatformにはResourceというinterfaceが用意されています。 Resourceにはsuspend関数のreadBytes()が定義されており、戻り値はByteArrayです。
  1. @ExperimentalResourceApi  
  2. interface Resource {  
  3.     suspend fun readBytes(): ByteArray  
  4. }  
このResourceを実装したLoadImageResourceを用意します。 LoadImageResourceのreadBytes()では指定されたURLからByteArrayを取得する処理を実装します。LoadImageResourceではKtor(https://ktor.io/)を使っています。
  1. @OptIn(ExperimentalResourceApi::class)  
  2. class LoadImageResource(private val url: String) : Resource {  
  3.     override suspend fun readBytes(): ByteArray {  
  4.         return HttpClient().use {  
  5.             it.get(url).readBytes()  
  6.         }  
  7.     }  
  8. }  


rememberImageBitmap

Resourceの拡張関数としてrememberImageBitmap()が用意されています。 rememberImageBitmap()はComposable関数で、戻り値はLoadState<ImageBitmap>です。 LaunchedEffectのなかでResourceのreadBytes()を呼び出し、返ってきたByteArrayをtoImageBitmap()でImageBitmapに変換しています。
  1. @ExperimentalResourceApi  
  2. @Composable  
  3. fun Resource.rememberImageBitmap(): LoadState<ImageBitmap> {  
  4.     val state: MutableState<LoadState<ImageBitmap>> = remember(this) { mutableStateOf(LoadState.Loading()) }  
  5.     LaunchedEffect(this) {  
  6.         state.value = try {  
  7.             LoadState.Success(readBytes().toImageBitmap())  
  8.         } catch (e: Exception) {  
  9.             LoadState.Error(e)  
  10.         }  
  11.     }  
  12.     return state.value  
  13. }  
LoadStateはsealed classでLoading、Success、Errorの3つのサブクラスがあります。
  1. sealed class LoadState<T> {  
  2.     class Loading<T> : LoadState<T>()  
  3.     data class Success<T>(val value: T) : LoadState<T>()  
  4.     data class Error<T>(val exception: Exception) : LoadState<T>()  
  5. }  


AsyncImage

画像を読み込んで表示したいURLに対してLoadImageResourceを作り、rememberImageBitmap()の返す LoadStateに応じてComposableを表示します。 LoadState.SuccessのvalueプロパティからImageBitmapを取得し、BitmapPainterでPainterを作ります。 最後にBitmapPainterをImage composableにセットすることで画像が表示されます。
  1. @OptIn(ExperimentalResourceApi::class)  
  2. @Composable  
  3. fun AsyncImage(  
  4.     url: String,  
  5.     contentDescription: String?,  
  6.     modifier: Modifier = Modifier,  
  7.     contentScale: ContentScale = ContentScale.Fit,  
  8. ) {  
  9.     val resource = remember(url) { LoadImageResource(url) }  
  10.   
  11.     when (val loadState = resource.rememberImageBitmap()) {  
  12.         is LoadState.Loading,  
  13.         is LoadState.Error -> {  
  14.             Spacer(modifier = modifier.background(Color.LightGray))  
  15.         }  
  16.         is LoadState.Success -> {  
  17.             Image(  
  18.                 painter = BitmapPainter(loadState.value),  
  19.                 contentDescription = contentDescription,  
  20.                 contentScale = contentScale,  
  21.                 modifier = modifier  
  22.             )  
  23.         }  
  24.     }  
  25. }  


まとめ

これでCompose MultiplatformのAndroid、iOS両方でネットワークから読み込んだ画像が表示されます。 実際のアプリで使うにはキャッシュ機能などが必要になりますし、そのうちライブラリもでてくるでしょう。 Compose Multiplatformぜひ試してみてください。