Now in REALITY Tech #98 Jetpack ComposeでのLazyColumnとCoilを組み合わせるときのつまずきポイント

こんにちは、Androidエンジニアのmits_sidです。

REALITYのAndroidアプリではJetpack Composeの利用が推奨されており、長く取り組みを行ってきたこともあって広く使われるようになってきました。
画面のUIを実装するに辺り、頻出するのがリストビューです。こちらはJetpack Composeでは LazyColumn や LazyRow を利用することで実現します。
(REALITYでは縦方向にスクロールをするリストビューが多いため、この記事では LazyColumn を中心に取り上げます)

リストビューに並ぶ各アイテムの中身は様々な物が考えられます。テキストやアイコンはもちろんのこと、画像を表示することも考えられます。
そして、この画像をリモートから読み込んで表示するようなパターンも考えられます。例えば他のREALITYユーザーのアイコンサムネイル画像等はサーバーリモート上にあるものを読み込んで表示しています。

REALITYにおいては、こうしたリモートからの画像を読み込んで表示するのに Coil を利用しています。
CoilはKotlin Coroutinesで作られたAndroid用の画像読み込みライブラリで、高速かつ軽量で扱いやすいAPI等の特徴を兼ね備えています。

つまり、リストビューの各アイテムでリモート画像を読み込んで表示する、という要件の画面の場合、LazyColumn と Coil を併用するケースが出てくるのですが、この時に一部の画面では問題が起こってしまいました。
この記事では実際にどんな問題が起こったのかに触れつつ、対応方法と発生した要因について深掘りしていきたいと思います。

発生した問題について

今回発生した問題について触れていきます。今回問題が発生したのはガチャリストの画面でした。
通常の表示と下方向へのスクロールについては問題ないのですが、そこから上方向にスクロールすると、動画のような引っ掛かるような挙動となってしまいます。

下方向スクロール時に引っ掛かる挙動の例

こうした挙動は `jittering scroll` や `stuttering scroll` などと表現されることが多いようです(もしより適切な表現があれば是非教えてください)

LazyColumnについて

この問題の原因に触れる前に、それぞれ利用しているコンポーネントについておさらいしておきましょう。

Jetpack Composeの LazyColumn は旧Android Viewでリストビューを実現するために利用されていた RecyclerView と同じ原則に従うコンポーネントです。
つまり、RecyclerView と同様に「リストアイテムの要素をリサイクルし、アイテムが画面外にスクロールされても、アイテムを破棄せず、画面上にスクロールされた新しいアイテムの再利用」します。

このアイテムの再利用を行う挙動により LazyColumn は大規模なサイズを持つリストビューになった場合でもパフォーマンスやアプリの応答性を維持することが出来ています。

Coilについて

次にCoilについておさらいしておきましょう。
Coilにおいてリモートの画像を読み込んで表示する場合、一般的には AsyncImage composableを利用します。

AsyncImage(
    model = "https://example.com/image.jpg",
    contentDescription = null,
)

また、画像の読み込む際にキャッシュポリシーの指定や読み込み失敗時のfaillback等の指定を行う場合は ImageRequest.Builder と rememberAsyncImagePainter を組み合わせることで読み込むことも可能です。
このように rememberAsyncImagePainter によってpainterを生成している場合、これを通常の Image composableに指定することでリモートの画像を読み込んで表示することが出来ます。

val placeholder = R.drawable.placeholder_no_image

val imagePainter = rememberAsyncImagePainter(
    model = ImageRequest.Builder(LocalContext.current)
        .data(imageUrl)
        .diskCacheKey(imageUrl)
        .diskCachePolicy(CachePolicy.ENABLED)
        .placeholder(placeholder)
        .error(placeholder)
        .fallback(placeholder)
        .build()
)

if (imagePainter.state is AsyncImagePainter.State.Loading) {
    // 読み込み中の表示など
}

Image(
    painter = imagePainter,
    contentDescription = null,
)

REALITYではこの imagePainter.state の読み込み状況を取得して各種ハンドリングを行うケースが多く、こちらのパターンで実装されることが殆どでした。

スクロール問題への対応方法

それでは今回の問題の原因について触れていきましょう。

今回問題になったガチャ画面では、先述したCoilの ImageRequest.Builder と rememberAsyncImagePainter によって生成したpainterを Image に組み合わせてリストビューのアイテムをレイアウトし、それらを LazyColumn を用いてリストビュー表示する方式を取っていました。

仮に最低限のコードで示すと以下のようなイメージです(もちろん、こちらはプロダクションで実利用されているコードではありません)

@Composable
internal fun ListItem(
    modifier: Modifier = Modifier,
    imageUrl: String,
) {
    Column(
        modifier = modifier,
    ) {
        val painter = rememberAsyncImagePainter(
            ImageRequest.Builder(LocalContext.current)
                .data(state.imageUrl)
                .build(),
        )
        Image(
            painter = painter,
            contentDescription = null,
            contentScale = ContentScale.FillWidth,
            modifier = Modifier
                .fillMaxWidth()
                .ifTrue(painter.state is AsyncImagePainter.State.Loading) { Modifier.aspectRatio(16f / 9f) }
        )
    }
}

@Composable
internal fun ListView(
    modifier: Modifier = Modifier,
    imageUrls: List<String>,
) {
    LazyColumn(
        modifier = modifier.fillMaxSize(),
    ) {
        items(imageUrls.size) { index ->
            ListItem(
                imageUrl = imageUrls[index],
            )
        }
    }
}

inline fun Modifier.ifTrue(
    value: Boolean,
    builder: () -> Modifier,
): Modifier {
    return then(if (value) builder() else Modifier)
}

本当に必要最低限の実装ですが、こちらの実装では上スクロール時のjittering scrollが再現します。

そしてこの問題、実はたった一行を足すだけで解決が出来るのです。
ImageRequest.Builder で size(Size.ORIGINAL) の呼び出しを追加するだけです。

val painter = rememberAsyncImagePainter(
    ImageRequest.Builder(LocalContext.current)
        .data(state.imageUrl)
        .size(Size.ORIGINAL) // ここに足すだけ
        .build(),
)

実際に動かしてみると、確かに問題なくなっていることが確認できます。

変更後、引っ掛かる挙動が無くなっている

修正としては容易なものでしたが、一体何故これで問題なくなるのでしょうか。

何故これで直るのか

まず、Coilの画像読み込みの流れについて知っておく必要があります。

画像を読み込んで表示する際、出力画像サイズを決めるための情報が必要になりますが、Coilのデフォルトではパフォーマンス最適化のため「最初のフレームの描画ステップを行うとき」にサイズ情報を解決します。
これが何を意味するかというと、最初のフレームでのコンポジションステップ時には画像を読み込む AsyncImagePainter の状態は読み込み中になり、サイズ情報を処理できないということです。

これは例え画像がディスクやメモリキャッシュされた状態で、最初のフレームからリクエストされた画像が描画可能な状態であってもこの挙動になります。

An image request needs a size to determine the output image's dimensions. By default, both AsyncImage and AsyncImagePainter resolve the request's size after composition occurs, but before the first frame is drawn. It's resolved this way to maximize performance. This means that AsyncImagePainter.state will be Loading for the first composition - even if the image is present in the memory cache and it will be drawn in the first frame.

https://coil-kt.github.io/coil/compose/#observing-asyncimagepainterstate

つまり、最初のコンポジションの時点で画像を表示するImageは画像に紐づいたサイズ情報を保持していません。
そして今回サンプルで示したようなレイアウトの場合「リストビューのアイテムのサイズが画像サイズに基づいて決定される」「読み込み中はアスペクト比のみの設定がされている」という2点の特徴がありました。

また、LazyColumnは「アイテムが画面外にスクロールされても、ビューを破棄せず、画面上にスクロールされた新しいアイテムのビューを再利用」という原則があります。Coilにおいてビューが再利用される場合、基本的には再度画像リクエストが発生すると考えられます。

これらが組み合わさると、「リストアイテムがLazyColumnの挙動によって再利用される度、最初のフレームのみサイズが0になる」という挙動が発生し得ます。

具体的には以下のような流れになります。

  1. 画面外にあるセルが、逆方向スクロールによって画面内に入る

  2. セルが再利用され、セル上で表示している各種情報が更新される

  3. このとき画像情報も更新され、Coilで新しく画像リクエストが走る

  4. ↑のCoilの挙動から、画像の読み込み状態が読み込み中から始まる

  5. このとき、画像サイズは不定になり、リストアイテムの要素サイズを計算できないため、最初のフレームはセルのheightが0になる

  6. 画面内に入ったセルのheight: 0になったことで、セル自体が小さくなり画面外に出てしまう

  7. 逆スクロールの慣性で再びそのセルが画面内に入る

  8. 再び画像のロードが走り最初に戻る

これが今回のスクロール問題の根本原因です。
原因を突き詰めると「Coilで読み込む画像のサイズが最初のコンポジションのときに分からない」ことが LazyColumnの立場だと厳しいことから発生していることが分かってきました。

こうしたケースに対応できるAPIをCoilは提供しています。最初のコンポジションで画像サイズが必要となる場合、ImageRequest.Builderにサイズ指定をするか、SubcomposeAsyncImage を利用することで実現することが出来ます。

If you need AsyncImagePainter.state to be up-to-date during the first composition, use SubcomposeAsyncImage or set a custom size for the image request using ImageRequest.Builder.size. For example, AsyncImagePainter.state will always be up-to-date during the first composition in this example:

https://coil-kt.github.io/coil/compose/#observing-asyncimagepainterstate
@Composable
internal fun ListItem(
    modifier: Modifier = Modifier,
    imageUrl: String,
) {
    Column(
        modifier = modifier,
    ) {
        val painter = rememberAsyncImagePainter(
            ImageRequest.Builder(LocalContext.current)
                .data(state.imageUrl)
                .size(Size.ORIGINAL) // 画像を読み込むターゲットサイズを設定
                .build(),
        )
        Image(
            painter = painter,
            contentDescription = null,
            contentScale = ContentScale.FillWidth,
            modifier = Modifier
                .fillMaxWidth()
                .ifTrue(painter.state is AsyncImagePainter.State.Loading) { Modifier.aspectRatio(16f / 9f) }
        )
    }
}

@Composable
internal fun ListView(
    modifier: Modifier = Modifier,
    imageUrls: List<String>,
) {
    LazyColumn(
        modifier = modifier.fillMaxSize(),
    ) {
        items(imageUrls.size) { index ->
            ListItem(
                imageUrl = imageUrls[index],
            )
        }
    }
}

さらなる深掘り

では、ここで指定している Size.ORIGINAL とは何なのでしょうか。何故これを指定するだけでサイズ周りが問題なくなるのでしょうか。

ImageRequest.Builderに対してサイズを追加していますが、これは「要求する幅・高さ」を示しています。これを設定することでImageRequestの内部的にはサイズ情報を解決するSizeResolverというインスタンスが生成されます。

これが未設定であっても画像サイズについては何処かで解決をする必要があります。これについては上記で取り上げた通り「Coilのデフォルトではパフォーマンス最適化のため 最初のフレームの描画ステップを行うとき にサイズ情報を解決」という挙動になっているようです。

ここからは推測になりますが、こちらの最適化を行っているのが恐らく以下の部分です(REALITYでは2.5.0を利用しているため、そちらのバージョンを参照)。
https://github.com/coil-kt/coil/blob/2.5.0/coil-compose-base/src/main/java/coil/compose/AsyncImagePainter.kt#L272-L275

ここではリクエストに設定されたSizeResolverが存在しない場合、MutableStateFlowであるdrawSizeに有効な値が来るのを待ち、有効な値が来た時にその値を使用してSizeResolverとして設定する処理が書かれています。

このdrawSizeに有効な値が指定されるのが以下のonDraw()メソッドです。
https://github.com/coil-kt/coil/blob/2.5.0/coil-compose-base/src/main/java/coil/compose/AsyncImagePainter.kt#L203-L209

メソッド名から、こちらは描画時に呼び出されると推測できます。
つまり「初めて描画を行われた際にdrawSizeに有効な値が設定され、Flowイベントが伝達し、SizeResolverが設定される」という挙動となりそうです。

こちらは上記で都度取り上げている「Coilのデフォルトではパフォーマンス最適化のため 最初のフレームの描画ステップを行うとき にサイズ情報を解決」という挙動そのものと見て良さそうです。

この問題が発生しないパターン

今回のケースは、例えば画像サイズが常に一定で、レイアウト上の出力サイズが常に一定に定まる場合は発生しません。

@Composable
internal fun ListItem(
    modifier: Modifier = Modifier,
    imageUrl: String,
) {
    Column(
        modifier = modifier,
    ) {
        val painter = rememberAsyncImagePainter(
            ImageRequest.Builder(LocalContext.current)
                .data(state.imageUrl)
                .build(),
        )
        Image(
            painter = painter,
            contentDescription = null,
            contentScale = ContentScale.FillWidth,
            modifier = Modifier
                .fillMaxWidth()
                .height(100.dp), // これが指定できるなら常に高さサイズが保証されるので発生しない
        )
    }
}

しかし一方、読み込む画像サイズによって表示要素のサイズも動的に変化したい場合、こちらのようにheightを直接指定することは出来ないため、今回取り上げたようなアプローチを取る必要があります。

まとめ

Coil + LazyColumn を組み合わせた時に発生し得るスクロール問題と、その対応方法について触れてみました。

REALITYでは読み込み中のハンドリングを別途 `painter.state` で保持して取り回すことが多かった関係上、今回は SubcomposeAsyncImage を利用しないアプローチとなりましたが、こちらは placeholder / error / fallback でComposable関数を設定出来るなど高い拡張性を有しているため、ユースケースに沿う場合はこちらを利用することもオススメです。

いずれにせよ、読み込む画像の内容によってサイズが可変するビューを LazyColumn で利用する際には注意を払う必要があります。リストビューを作る必要がある場合には気をつけておきましょう。