Java 脳のまま Jetpack Compose を書いてはいけない (1/2)

Compose Runtime、State、Navigation を一つの設計モデルで理解する

 

🧑🏻‍💻 Compose に移行したのに、なぜコードはレガシーなままなのか

Jetpack Compose への移行は、XML レイアウトを Kotlin の関数に置き換えるだけではありません。

従来の Android 開発では、View を生成し、プロパティを変更し、イベントに応じて UI を更新するのが一般的でした。

例えば、ボタンを押したらテキストを変更する処理は、次のように書けます。


button.setOnClickListener {
    textView.text = "Hello, Compose!"
}

Compose でも、同じ発想で UI を手動更新しようとすると、設計が複雑になりがちです。


@Composable
fun Counter() {
    var count by remember { mutableIntStateOf(0) }

    Column {
        Text("$count")

        Button(
            onClick = {
                count++
            }
        ) {
            Text("Increment")
        }
    }
}

このコードでは、ボタンを押すと count が変わり、Compose Runtime が変更を検知して UI を更新します。

Text の内容を直接書き換えているわけではありません。State が変われば、UI はその State から再び導出されます。

これが宣言型 UI の基本です。

それでも、Compose を使いながら従来の命令型 UI の設計を持ち込んでしまうケースがあります。

・ UI の状態を複数の場所で手動同期する。
・ Composable を View オブジェクトのように扱う。
・ 画面遷移を単純な条件分岐だけで管理する。
・ Composition、ViewModel、Activity の寿命を同じものとして考える。

これらの問題を理解するには、個々の API の使い方だけでなく、Compose Runtime がどのように State と UI を結びつけているかを理解する必要があります。

 

🧑🏻‍💻 「Java 脳」が持ち込む3つの誤解

Composable を View オブジェクトのように扱う

従来の Android UI では、View オブジェクトを保持して、そのプロパティを変更する設計が一般的でした。


class CounterFragment : Fragment() {
    private var count = 0
    private lateinit var counterText: TextView

    private fun increment() {
        count++
        counterText.text = count.toString()
    }
}

このコードでは、count と TextView の表示を手動で同期しています。

Compose では、同じ考え方を持ち込む必要はありません。


@Composable
fun Counter() {
    var count by remember { mutableIntStateOf(0) }

    Column {
        Text("Count: $count")

        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

ボタンのイベントは State を変更するだけです。UI の更新方法をイベントハンドラーに書く必要はありません。

ここで重要なのは、Composable を単なる純粋関数だと考えることでもありません。Composable は remember や State、Effect などを利用でき、Compose Runtime によって管理されます。

Composable は View の代わりとなるオブジェクトではなく、Composition を記述するための関数です。

State を単なるフィールドとして扱う

次のコードは、一見すると問題がなさそうです。


class CounterViewModel : ViewModel() {
    var count = 0
        private set

    fun increment() {
        count++
    }
}

しかし、count は Compose が監視する State ではありません。


@Composable
fun Counter(viewModel: CounterViewModel) {
    Text("Count: ${viewModel.count}")

    Button(onClick = viewModel::increment) {
        Text("Increment")
    }
}

increment() が呼ばれても、count の変更だけでは Compose の再コンポジションは発生しません。

UI が観測できる State に変更する必要があります。


class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count.asStateFlow()

    fun increment() {
        _count.update { it + 1 }
    }
}


@Composable
fun Counter(viewModel: CounterViewModel) {
    val count by viewModel.count.collectAsStateWithLifecycle()

    Column {
        Text("Count: $count")

        Button(onClick = viewModel::increment) {
            Text("Increment")
        }
    }
}

これで、ViewModel が公開する State と UI の依存関係が明確になります。

さらに複雑な画面では、複数の State を combine などでまとめ、StateFlow<UiState> として公開できます。


data class UiState(
    val count: Int = 0,
    val canIncrement: Boolean = true
)

重要なのは、値を変更することと、その変更を UI が観測できることは別だという点です。

Lifecycle を Activity や Fragment の寿命だけで考える

Compose には、Activity や Fragment の Lifecycle とは別に、Composable が Composition に参加してから退出するまでのライフサイクルがあります。

Composable のライフサイクル:


Composition に参加
       |
       v
Recomposition(0 回以上)
       |
       v
Composition から退出

例えば、次のコードを見てみましょう。


@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }

    TextField(
        value = query,
        onValueChange = { query = it }
    )
}

remember は再コンポジションをまたいで値を保持します。しかし、この Composable が Composition から取り除かれると、その remember の値は通常破棄されます。

画面回転やプロセス終了をまたいで状態を保持したいなら、別の仕組みが必要です。


@Composable
fun SearchScreen() {
    var query by rememberSaveable { mutableStateOf("") }

    TextField(
        value = query,
        onValueChange = { query = it }
    )
}

rememberSaveable は保存可能な値を構成変更やプロセス再生成から復元するために利用できます。ただし、任意のオブジェクトをそのまま保存できるわけではありません。

このように、状態をどこに置くかは、値の性質だけでなく、どの期間保持したいかによって決める必要があります。

 

🧑🏻‍💻 Compose Runtime は State と UI の依存関係を管理する

UI = f(State):UI を手動で更新しない

宣言型 UI の考え方は、次の式にまとめられます。


UI = f(State)

UI は State から導出されます。

例えば、検索画面で検索文字列と検索結果を別々に手動更新するのではなく、検索文字列から結果を導出できます。


@Composable
fun SearchScreen(items: List<String>) {
    var query by remember { mutableStateOf("") }

    val filteredItems = items.filter {
        it.contains(query, ignoreCase = true)
    }

    Column {
        TextField(
            value = query,
            onValueChange = { query = it },
            label = { Text("Search") }
        )

        LazyColumn {
            items(filteredItems) { item ->
                Text(item)
            }
        }
    }
}

検索文字列が変わると、filteredItems は再コンポジション時に再計算されます。

検索文字列と検索結果の両方を別々の可変 State として保持し、毎回同期する必要はありません。

ただし、計算コストが高い場合や、入力データが大きい場合には derivedStateOf、ViewModel、Flow などを含めた設計を検討します。宣言型であることは、あらゆる計算を毎回行うという意味ではありません。

Recomposition は単なる再描画ではない

Compose Runtime は、Composable が読み取った State を追跡します。


@Composable
fun ProfileScreen() {
    var name by remember { mutableStateOf("Alice") }
    var age by remember { mutableIntStateOf(30) }

    Column {
        Text("Name: $name")
        Text("Age: $age")

        Button(onClick = { name = "Bob" }) {
            Text("Change name")
        }
    }
}

name が変わると、その State に依存する Composition の範囲で再コンポジションがスケジュールされます。

ただし、再コンポジションが発生することと、画面全体が実際に再描画されることは同じではありません。 Compose は変更の影響範囲やスキップ可能性などに応じて処理します。

設計上、避けたいのは、必要以上に広い範囲で State を読み取り、無関係な UI まで頻繁に再コンポジションの対象にしてしまうことです。

例えば、State を使う範囲を小さくできます。


@Composable
fun Screen() {
    Column {
        StaticHeader()
        Counter()
    }
}

@Composable
fun StaticHeader() {
    Text("Welcome")
}

@Composable
fun Counter() {
    var count by remember { mutableIntStateOf(0) }

    Text("Count: $count")

    Button(onClick = { count++ }) {
        Text("Increment")
    }
}

この例では、カウンターの State をカウンター自身の近くに置いています。これにより、状態の依存関係が分かりやすくなります。

State を画面の最上位に集めることが常に悪いわけではありません。複数の子 UI が同じ State を共有する場合には、上位への State hoisting が適切です。

大切なのは、State の所有者と State を読み取る場所を意識することです。

State の所有者と観測する場所を区別する

画面が複雑になると、UI の状態をすべて Composable の内部に置くのは適切ではありません。

例えば、Repository のデータ、検索条件、読み込み状態を組み合わせる画面では、ViewModel が状態を導出できます。


data class ItemsUiState(
    val items: List<String> = emptyList(),
    val query: String = "",
    val isLoading: Boolean = false
)

class ItemsViewModel(
    repository: ItemsRepository
) : ViewModel() {

    private val query = MutableStateFlow("")

    val uiState: StateFlow<ItemsUiState> =
        combine(
            repository.items,
            query
        ) { items, query ->
            ItemsUiState(
                items = items.filter {
                    it.contains(query, ignoreCase = true)
                },
                query = query
            )
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = ItemsUiState()
        )

    fun onQueryChange(query: String) {
        this.query.value = query
    }
}

ここでは、UI アクションが更新するのは ViewModel 内部の query です。公開する uiState は、Repository のデータと検索条件から導出されます。

UI 側は、その状態を購読して表示します。


@Composable
fun ItemsScreen(
    viewModel: ItemsViewModel
) {
    val uiState by
        viewModel.uiState.collectAsStateWithLifecycle()

    Column {
        TextField(
            value = uiState.query,
            onValueChange = viewModel::onQueryChange
        )

        LazyColumn {
            items(uiState.items) { item ->
                Text(item)
            }
        }
    }
}

この設計では、公開された uiState を手動で変更する必要がありません。依存関係を Flow として記述し、ViewModel が UI の状態を導出します。

stateIn は Flow を StateFlow に変換し、共有するための仕組みです。一方、collectAsStateWithLifecycle() は Android の Lifecycle に応じて UI 側の収集を管理します。両者は異なる責務を持っています。

(→ 2/2 へつづく)


Related Categories :  Android・Developmemt・JetpackCompose・KMP・Kotlin・Kotlin Multiplatform Mobile・Newbie・Recommended