Featured image of post [Android] Jetpack Compose を使っても宣言的にはならない

[Android] Jetpack Compose を使っても宣言的にはならない

Compose はフレームワークであり、宣言的かどうかはコードの書き方で決まる。命令的な Compose コードの問題と改善例

要点

「Jetpack Compose は宣言的 UI フレームワークである」— これは正しい。しかし、Compose を使えば自動的に宣言的になるわけではない

Compose で書かれたコードにも、命令的なパターンは頻繁に現れる。宣言的かどうかはフレームワークではなく コードの書き方 で決まる。

宣言的と命令的の違い

宣言的 命令的
記述すること 何を(What) 表示するか どうやって(How) 表示するか
状態の扱い 状態を入力として受け取り、UI を出力する 状態の変化に応じて UI を操作する
制御フロー データの変換 手続きの連鎖

宣言的 UI の理想は、UI = f(State) である。同じ状態を入力すれば、常に同じ UI が出力される。

UI 以外での命令的・宣言的の違い

宣言的・命令的の違いは UI に限った話ではない。以下はデータモデルの例である。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// NG: 命令的 — ロジックで属性を判定している
sealed class FileType {
    data object Pdf : FileType()
    data object Csv : FileType()
    data object Png : FileType()

    val isPreviewable: Boolean
        get() = when (this) {
            is Pdf, is Png -> true
            else -> false
        }
}

// OK: 宣言的 — 属性をデータとして宣言している
sealed class FileType(val isPreviewable: Boolean) {
    data object Pdf : FileType(isPreviewable = true)
    data object Csv : FileType(isPreviewable = false)
    data object Png : FileType(isPreviewable = true)
}

NG の例では when で分岐して属性を判定しており、新しい型を追加するたびにロジックの修正が必要になる。OK の例では属性をコンストラクタ引数として宣言しているだけであり、新しい型を追加しても既存のロジックに手を入れる必要がない。

NG:Compose で書かれた命令的コード

パターン 1:LaunchedEffect でロジックを実行する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Composable
fun UserScreen(viewModel: UserViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // NG: Composable 内で副作用としてロジックを実行
    LaunchedEffect(uiState.userId) {
        if (uiState.userId != null && uiState.userName.isEmpty()) {
            viewModel.loadUserName(uiState.userId!!)
        }
    }

    Text(text = uiState.userName)
}

LaunchedEffect 自体はアニメーションやスナックバー表示など正当な用途がある。しかし、この例のように データ取得やビジネスロジックを実行する のは NG である。「状態が変わったら手続きを実行する」という命令的な発想が漏れている。

パターン 2:Composable 内で状態を加工する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Composable
fun ItemListScreen(viewModel: ItemListViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // NG: Composable 内でフィルタ・ソート
    val activeItems = uiState.items.filter { it.isActive }
    val sortedItems = activeItems.sortedBy { it.name }

    LazyColumn {
        items(sortedItems) { item ->
            Text(text = item.name)
        }
    }
}

フィルタ・ソートのたびに再コンポジションが走る。ロジックが Composable に漏れているため、テストもできない。

パターン 3:var と mutableStateOf の乱用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }
    var message by remember { mutableStateOf("") }

    // NG: 状態の更新ロジックが Composable に散在
    Button(onClick = {
        count++
        message = if (count >= 10) "達成!" else "あと${10 - count}回"
    }) {
        Text(text = message)
    }
}

単純な例では remember + mutableStateOf で問題ない。問題は、この発想をそのまま規模の大きい画面に拡大すること である。Composable が状態管理の責務を抱え込み、テストも困難になる。

OK:宣言的に書き直す

パターン 1 の改善:データの取得は ViewModel に任せる

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// ViewModel で状態を構築
class UserViewModel(...) : ViewModel() {
    init {
        viewModelScope.launch {
            val name = repository.getUserName(userId)
            _uiState.update { it.copy(userName = name) }
        }
    }
}

// Composable は状態を受け取って描画するだけ
@Composable
fun UserScreen(uiState: UserScreenUiState) {
    Text(text = uiState.userName)
}

パターン 2 の改善:変換は UiState に隠蔽する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
data class ItemListUiState(
    val items: List<Item> = emptyList(),
) {
    val activeItems: List<Item>
        get() = items.filter { it.isActive }.sortedBy { it.name }
}

@Composable
fun ItemListScreen(uiState: ItemListUiState) {
    LazyColumn {
        items(uiState.activeItems) { item ->
            Text(text = item.name)
        }
    }
}

前回の記事で述べた通り、ロジックは UiState の導出プロパティに隠蔽する。

filter や sort を ViewModel に直接書くのをやめる。ロジックの置き場所を関心の分離から考える
cover.webp

パターン 3 の改善:状態管理を ViewModel に移す

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ViewModel
class CounterViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(CounterUiState())
    val uiState: StateFlow<CounterUiState> = _uiState.asStateFlow()

    fun increment() {
        _uiState.update { it.copy(count = it.count + 1) }
    }
}

data class CounterUiState(val count: Int = 0) {
    val message: String
        get() = if (count >= 10) "達成!" else "あと${10 - count}回"
}

// Composable は描画のみ
@Composable
fun CounterScreen(uiState: CounterUiState, onIncrement: () -> Unit) {
    Button(onClick = onIncrement) {
        Text(text = uiState.message)
    }
}

Composable は 状態を受け取り、イベントを返す。それだけである。

宣言的かどうかの判断基準

Composable 関数を見て、以下に該当するなら命令的に書かれている可能性がある。

チェック項目 命令的の兆候
LaunchedEffect でデータ取得やロジックを実行していないか ViewModel の責務が漏れている
Composable 内で filter / sort / map していないか UiState に隠蔽すべき変換
remember { mutableStateOf(...) } が多用されていないか 状態管理が Composable に集中している
if / when の条件が複雑になっていないか 導出プロパティに切り出すべき

まとめ

Compose は宣言的 UI を 書けるようにする フレームワークであり、宣言的であることを 強制する フレームワークではない。

宣言的に書くための原則は単純である。

  • Composable は 状態を受け取り、UI を返す
  • ロジックは UiState か ViewModel に置く
  • Composable に残すのは 描画の記述だけ

UI = f(State) が成り立っているか。それが宣言的かどうかの判断基準である。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。