mcpField
C
compose-state-holder-ui-split

compose-state-holder-ui-split

github.com/chrisbanes開発1.1k969 回インストール1 日前に更新
リポジトリ

compose-state-holder-ui-split について

状態ホルダーと UI 描画を分離する Compose スキルです。UI を不変状態とコールバック中心にし、画面のプレビューやテスト、再利用をしやすくします。

主な機能

  1. 01状態の接続と UI レイアウトの分離
  2. 02状態ホルダーでのビジネス状態収集
  3. 03状態とコールバックによる UI 契約の受け渡し
  4. 04状態ホルダー付近での副作用処理
  5. 05UI ローカル状態とビジネス状態の区別

活用シーン

  1. 01ViewModel 依存画面のレイアウト分離
  2. 02アプリ全体の依存構築を省いた UI テスト
  3. 03UI 内のナビゲーションを明示的なコールバックに置換

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/chrisbanes/skills --skill compose-state-holder-ui-split

インストール後、Claude Code で/compose-state-holder-ui-splitを入力して読み込まれたか確認します。

詳細

Compose:状態ホルダーと UI の分離

基本原則

状態ホルダーの配線と UI の描画を分離します。状態ホルダーのコンポーザブルは、ViewModel、コンポーネント、フロー、ナビゲーション、副作用を扱います。UI コンポーザブルは、変更不可のプレーンな UI 状態とコールバックを受け取り、レイアウトを記述します。

これにより、画面のプレビューやテストがしやすくなり、Android、Desktop、TV、KMP/CMP の各ターゲットで再利用しやすくなります。

このスキルを使う場面

Compose の画面で次のいずれかに当てはまる場合に使います。

  • ViewModel、コンポーネント、コントローラー、ナビゲーター、リポジトリ、サービスを直接受け取っている。
  • 大部分の UI をレイアウトする関数内で、アプリやビジネスの状態、または副作用を収集している。
  • 明示的な状態とコールバックではなく、状態ホルダー全体を子コンポーザブルに渡している。
  • 依存性注入、ナビゲーション、ライフサイクル、またはフェイクサービスが必要なため、プレビューが難しい。
  • 単純なレイアウト分岐を検証するために、UI テストでアプリ全体のスタックを構築しなければならない。

パターン

小さな公開状態ホルダーコンポーザブルを使います。

@Composable
fun ProfileScreen(component: ProfileComponent, modifier: Modifier = Modifier) {
    val state by component.state.collectAsStateWithLifecycle()

    ProfileScreen(
        state = state,
        onNameChange = component::onNameChange,
        onSaveClick = component::save,
        onBackClick = component::back,
        modifier = modifier,
    )
}

次に、状態ホルダーを一切知らないプレーンなコンポーザブルに UI を配置します。

@Composable
fun ProfileScreen(
    state: ProfileUiState,
    onNameChange: (String) -> Unit,
    onSaveClick: () -> Unit,
    onBackClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    ProfileContent(
        name = state.name,
        isSaving = state.isSaving,
        canSave = state.canSave,
        onNameChange = onNameChange,
        onSaveClick = onSaveClick,
        onBackClick = onBackClick,
        modifier = modifier,
    )
}

プライベートなコンテンツ関数でレイアウトを分割できます。

@Composable
private fun ProfileContent(
    name: String,
    isSaving: Boolean,
    canSave: Boolean,
    onNameChange: (String) -> Unit,
    onSaveClick: () -> Unit,
    onBackClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    // Layout only.
}

基本的な指針

項目状態ホルダーコンポーザブルUI コンポーザブル
ViewModel/コンポーネントの状態を収集するはいいいえ
一度限りのエフェクトを収集するはい。または小さな別のエフェクトハンドラー通常はいいえ
依存性注入されたオブジェクトを保持するはいいいえ
変更不可の UI 状態を受け取る通常はそのまま渡すはい
ユーザーイベント用のラムダを受け取る配線する呼び出す
レイアウト、モディファイア、セマンティクス、テストタグを所有するいいえ/最小限はい
スクロール、フォーカス、テキスト入力、アニメーション、インタラクションなどの UI ローカル状態を所有する場合によっては初期化するはい
プレビュー/スクリーンショットへの対応必ずしもそうではないはい

「UI コンポーザブルで収集しない」というルールは、アプリやビジネスの状態と副作用のストリームを対象としています。プレーンな UI コンポーザブルは、rememberScrollState、rememberLazyListState、FocusRequester、フォーカス状態、アニメーション状態、TextFieldState、MutableInteractionSource.collectIsPressedAsState() など、描画されるウィジェットに属する UI ローカルなフレームワーク状態を引き続き所有できます。

その UI ローカル状態が、複数の関連するフィールドや操作を含む協調的な動作に発展した場合は、compose-state-hoisting を使って、コンポジション内で記憶されるプレーンな状態ホルダークラスにするべきか判断してください。

渡すもの

必要最小限の UI コントラクトを渡します。

  • 画面に実際の状態がある場合は、無関係なプリミティブ値を多数渡すのではなく、専用の UiState/State オブジェクトを優先します。
  • コンポーネント全体を渡すのではなく、明示的なラムダ(onRetryClick、onItemSelected)を優先します。
  • ビジネスルールを UI に持ち込むことになる場合は、UI コンポーザブルでドメインモデルを使わないでください。UI に異なる形のデータが必要な場合は、UI モデルにマッピングします。
  • ナビゲーションはコールバックとして扱います。UI コンポーザブルは「ユーザーが戻るボタンをクリックした」と伝え、「ルート X に移動する」とは伝えません。
  • 変更時にツリー全体の再コンポジションを引き起こすべきではないフレームレート値や UI ローカル値には、compose-state-deferred-reads に従ってプロバイダーラムダと遅延読み取りを優先します。

副作用

compose-side-effects では、エフェクト API(LaunchedEffect、DisposableEffect、SideEffect)、キー、クリーンアップ、rememberUpdatedState を扱います。

エフェクトのソースと命令型の処理先の両方を利用できる、状態ホルダーの近くでエフェクトを処理します。

@Composable
fun ProfileScreen(component: ProfileComponent, snackbarHostState: SnackbarHostState) {
    val state by component.state.collectAsStateWithLifecycle()

    LaunchedEffect(component) {
        component.effects.collect { effect ->
            when (effect) {
                ProfileEffect.Saved -> snackbarHostState.showSnackbar("Saved")
            }
        }
    }

    ProfileScreen(state = state, onSaveClick = component::save)
}

エフェクト処理が大きくなった場合は、コンポーネントを UI コンポーザブルに渡すのではなく、ProfileEffects(component, snackbarHostState) を切り出します。

よくある間違い

間違い問題点修正方法
fun Screen(viewModel: MyViewModel) にレイアウト全体を含めるAndroid のライフサイクルや DI なしではプレビューやテストが困難ですstate とコールバックを受け取る、プレーンな UI オーバーロードを追加します
子コンポーザブルが component を受け取る依存関係がツリー全体に漏れます子が必要とする状態とコールバックだけを渡します
UI コンポーザブルがナビゲーションを開始するUI がアプリのルーティングと密結合になりますonBackClick、onItemClick などを公開します
UI コンポーザブルがアプリ/ビジネスフローを収集する収集のライフサイクルがレイアウト内に隠れてしまいます状態ホルダーの近くで収集し、値を下位に渡します
理由もなく UI ローカルの状態を状態ホルダーに持ち上げる状態ホルダーがレイアウトの仕組みまで所有するようになりますスクロール、フォーカス、アニメーション、テキストフィールドの操作状態が UI の動作だけに関するものであれば、UI コンポーザブルに保持します
小さなコンポーザブルのすべてに状態ホルダー用のオーバーロードを追加する儀式的な記述が増えすぎますすべての Row ではなく、画面やセクションの境界で分割します

適用しない場合

  • すでにプレーンな値とコールバックを受け取っている、小規模で単発のコンポーザブル。
  • Button、Card、ListItem などのデザインシステムのプリミティブ。これらは状態ホルダーではなく、スロットと修飾子を公開するべきです。
  • 状態ホルダーを使うコンポーザブルがプリミティブを 1 つ渡すだけで、分離の効果がない場合。

関連項目

  • compose-ui-testing-patterns — アプリ全体のグラフを使わずに、プレーンな状態駆動型 UI コンポーザブルをテストする方法。
  • compose-state-hoisting — プレーンな状態ホルダークラスを含め、UI 要素の状態と UI ロジックをどこに置くかを判断する方法。
  • kotlin-multiplatform-expect-actual — 共有 UI がプラットフォーム固有のリーフと連携する場合の、プラットフォームサービス、ネイティブビュー、expect/interface の境界。

コメント