Compose でパフォーマンスの高いレイアウトを作る

Jetpack Compose は「より優れたアプリを迅速にビルドする」というスローガンを掲げていますが、優れたアプリの重要な要素の一つが UI のパフォーマンスです。

この記事では、まず Compose と View のレイアウトシステムを簡単に比較して Compose の強みを説明し、それから Compose でパフォーマンスの高いレイアウトを作るコツを例を交えて紹介します。

## Compose のレイアウトシステムの改善

#### View のレイアウトシステムの問題点

1. View では一つのレイアウトを複数回測定（measure）することが許されていて、実際その状況に陥ることもよくあります。例えば、`LinearLayout` の `baselineAligned` はデフォルトで `true` なので、明示的に `baselineAligned` をオフにせずに `LinearLayout` 内で `layout_weight` を使うと子レイアウトが複数回測定されます。このようなレイアウトをネストする場合、測定回数が指数関数的に増える可能性があります。`ConstraintLayout` がこの問題を解決するために生まれました。
2. `requestLayout()` が呼ばれる時、再レイアウトの理由とレイアウトのサイズ変更が親レイアウトに影響するかどうかわからないため、サイズが変わったレイアウトだけ再レイアウトするようなことはできず、ルートレイアウトから再レイアウトするしかありません。

#### Compose のレイアウト

問題点 1 を解決するために、Compose は一つのレイアウトを複数回測定することを禁止しています。そのため、Compose では基本的にレイアウトを平坦化する必要はありませんし、`ConstraintLayout` を使うことでパフォーマンスが改善されることもありません。

やむなく複数回測定が必要な場合は「固有の測定値」を使う必要があります。

https://developer.android.com/jetpack/compose/layouts/intrinsic-measurements

問題点 2 に関して、Compose はどのレイアウトのサイズが変わったか分かるため、そのレイアウトのサイズに依存しているところだけ再レイアウトすることができます。また、View より多くキャッシュできます。

## Compose でパフォーマンスの高いレイアウトを作るには？

### 循環依存をなくす

よくある間違いの一つが循環依存です。

##### 例 1

![例 1](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953273/332fdc35-1a08-24c8-b41e-d1273a90d9bf.png)
このレイアウトを作るとします。2 行目のテキストは一行目より長いですが、1 行目と同じ幅に収めなければなりません。

このようにコードを書く人もいるかもしれません。
```Kotlin
Column {
    var width by remember { mutableStateOf(0) }
    Text(
        text = "Short Text",
        modifier = Modifier.onSizeChanged { width = it.width }
    )
    Text(
        text = "Long Long Long Text",
        maxLines = 1,
        overflow = TextOverflow.Ellipsis,
        modifier = if (width > 0) {
            Modifier.width(with(LocalDensity.current) { width.toDp() })
        } else {
            Modifier
        }
    )
}
```
これにどんな問題があるか考えましょう。

Compose ではコンポジション、レイアウト、描画という 3 つのフェーズがあり（[ドキュメント](https://developer.android.com/jetpack/compose/phases)）、`Modifier.onSizeChanged` はレイアウトフェーズで呼ばれるため、コンポジション（`Modifier.width(width)`）がレイアウト（`Modifier.onSizeChanged`）に依存していることになります。ただしレイアウト自体はコンポジションの後に行われ、コンポジションに依存しているため、これは循環依存になります。

循環依存の結果、このレイアウトが最終状態になるには 2 回のレイアウトが必要なため、パフォーマンスが悪くなります。また、2 回のレイアウトの結果が違うため、ちらつきによって UX も悪くなります。

では、どうやって循環依存をなくしましょう？
2 つの `Text` の短い方の幅にしたいため、固有の測定値は使えません。
ここで登場するのがカスタムレイアウトです。
カスタムレイアウトの基本的な書き方はこちらをご参照ください。

https://developer.android.com/jetpack/compose/layouts/custom

コードはこちらです。
```Kotlin
Layout(content = {
    Text(text = "Short Text")
    Text(text = "Long Long Long Text", maxLines = 1, overflow = TextOverflow.Ellipsis)
}) { measurables, constraints ->
    // 外部からの constraints は固定サイズの可能性があるため、最小サイズ 0 の constraints を作る
    val relaxedConstraints = constraints.copy(minWidth = 0, minHeight = 0)
    val firstText = measurables[0].measure(relaxedConstraints)
    // 1 行目の幅を最大幅として 2 行目を測定する
    val secondText = measurables[1].measure(
        relaxedConstraints.copy(maxWidth = firstText.width)
    )
    // 外部からの constraints に準拠したサイズを計算する
    val width = constraints.constrainWidth(firstText.width)
    val height = constraints.constrainHeight(firstText.height + secondText.height)
    layout(width, height) {
        firstText.placeRelative(0, 0)
        secondText.placeRelative(0, firstText.height)
    }
}
```
このように、Compose では View よりはるかに簡単にカスタムレイアウトが作れます。

##### 例 2

![例 2](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953273/f29119de-06f4-a04b-04f8-88b51bc680db.png)

もう 1 つ例を挙げましょう。今回のレイアウトは、テキストとボタンがあり、テキストは左揃えでボタンは右揃えですが、それらが 1 行に入る場合は 1 行に入れて垂直方向の中央に揃え、1 行に入らない場合はボタンをテキストの下に置きます。

コードはこちらです。
```Kotlin
@Composable
fun TextWithAction(
    text: @Composable () -> Unit,
    action: @Composable () -> Unit,
    modifier: Modifier = Modifier
) {
    Layout(content = {
        Box { text() }
        Box { action() }
    }, modifier = modifier) { measurables, constraints ->
        val relaxedConstraints = constraints.copy(minWidth = 0, minHeight = 0)
        // まずは両方を測定する
        val textBox = measurables[0].measure(relaxedConstraints)
        val actionBox = measurables[1].measure(relaxedConstraints)
        val width = constraints.maxWidth
        // 1 行に入るかどうかを判断する
        val oneRow = textBox.width + actionBox.width <= width
        val height = if (oneRow) {
            max(textBox.height, actionBox.height)
        } else {
            textBox.height + actionBox.height
        }
        layout(width, constraints.constrainHeight(height)) {
            if (oneRow) {
                // 1 行に入る場合は垂直方向の中央に揃える
                textBox.placeRelative(0, (height - textBox.height) / 2)
                actionBox.placeRelative(width - actionBox.width, (height - actionBox.height) / 2)
            } else {
                // 1 行に入らない場合は上から下に置く
                textBox.placeRelative(0, 0)
                actionBox.placeRelative(width - actionBox.width, textBox.height)
            }
        }
    }
}
```

### 固有の測定値を活用する

では、どんな時に固有の測定値を使うのでしょう？

##### 例 3

![例 3](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953273/f7f8a6ba-b042-86ac-9cfd-c30c256f2d9c.png)

このレイアウトを考えましょう。左揃えと右揃えの 2 つのテキストですが、スペースを最大限利用するために、長い方のテキストは残りのスペースを全て占めますが、両方長い場合はスペースを均等に配分します。

テキストの長さがわからないとどうやってスペースを配分すべきかわからないため、実際測定する前にテキストの長さを知る必要があります。そのために使うのが固有の測定値です。

コードはこちらです。
```Kotlin
@Composable
fun TextRow(
    text1: @Composable () -> Unit,
    text2: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    margin: Dp = 0.dp,
    verticalAlignment: Alignment.Vertical = Alignment.Top
) {
    Layout(content = {
        Box { text1() }
        Box { text2() }
    }, modifier = modifier) { measurables, constraints ->
        val marginPx = margin.roundToPx()
        val width = constraints.maxWidth - marginPx
        val halfWidth = width / 2
        // テキストの固有の幅を取得する
        val intrinsicWidths = measurables.map { it.maxIntrinsicWidth(constraints.maxHeight) }
        val min = intrinsicWidths.min()
        val max = intrinsicWidths.max()
        val childrenWidths = if (max <= halfWidth || min > halfWidth) {
            // 両方ともスペースの半分より短いまたは両方とも半分より長い場合は半分ずつ配分する
            listOf(halfWidth, halfWidth)
        } else {
            // 片方だけ半分より長い場合は長い方に残りのスペースを全て配分する
            intrinsicWidths.map { if (it <= halfWidth) it else width - min }
        }
        // 計算したスペース配分で測定する
        val placeables = measurables.mapIndexed { i, measurable ->
            measurable.measure(
                constraints.copy(minWidth = 0, minHeight = 0, maxWidth = childrenWidths[i])
            )
        }
        val height = constraints.constrainHeight(placeables.maxOf { it.height })
        layout(constraints.maxWidth, height) {
            placeables[0].let {
                it.placeRelative(0, verticalAlignment.align(it.height, height))
            }
            placeables[1].let {
                it.placeRelative(
                    constraints.maxWidth - it.width,
                    verticalAlignment.align(it.height, height)
                )
            }
        }
    }
}
```

### できるだけ SubcomposeLayout (BoxWithConstraints を含む) を避ける

`SubcomposeLayout` は簡単に言うと、レイアウトフェーズでコンポジションを行う特殊なレイアウトです。`BoxWithConstraints` も裏側では `SubcomposeLayout` が使われています。

`SubcomposeLayout`/`BoxWithConstraints` はこういう場合に役立ちます。
1. 利用可能なスペースによってレイアウトの**構造や内容**を変えたい場合
2. 特定の子レイアウトのサイズなどの測定値によって他の子レイアウトの**構造や内容**を変えたい場合

逆にこういう場合は `SubcomposeLayout`/`BoxWithConstraints` を使う必要はありません。
1. 利用可能なスペースによって子レイアウトの**サイズや位置**を変えたい場合
2. 特定の子レイアウトのサイズなどの測定値によって子レイアウトの**サイズや位置**を変えたい場合

`SubcomposeLayout`/`BoxWithConstraints` は普通のレイアウトと比べるとオーバーヘッドがあるため、避けられる場合は避けて modifier やカスタムレイアウトを活用しましょう！

例えば、画面のルートコンポーネントとして `Scaffold` が使われがちですが、`Scaffold` は画面内容とボトムバーやスナックバーなどのコンポーネントの位置や相互関係をうまく調整するためにあるもので、`SubcomposeLayout` を使っているので、トップバーと画面内容だけのような簡単な画面であれば、`Column` を使えば十分ですしパフォーマンスも高いです。
```Kotlin
Column {
    TopAppBar(modifier = Modifier.zIndex(1f))
    ScreenContent()
}
```

## TL;DR

- Compose のレイアウトシステムの設計は View より優れており、レイアウトを平坦化する必要はありません。
- 循環依存は必ずなくしましょう！
- レイアウトをパフォーマンスの高い順に並ぶと: 固有の測定値を使わないレイアウト > 固有の測定値を使うレイアウト > SubcomposeLayout
- カスタムレイアウトを恐れずに、標準のレイアウトと modifier で実現できない時は積極的に使いましょう！

最後に、Android Dev Summit '22 ではカスタムレイアウトを使ってより複雑な画面を実現する動画が公開されており、そこでは親子レイアウト間のコミュニケーションを可能にする `ParentDataModifier` も紹介されているので、ぜひ見てみてください！

https://youtu.be/xcfEQO0k_gU