「ドメインモデル、ぶっちゃらいらなくね?」本当に必要な2つの判断基準

Android アプリの設計(Clean Architecture、MVVM/MVI、Jetpack Composeなど)を学びはじめると、必ずと言っていいほどこう言われます。

「Entity や DTO をそのままUI層に露出させるな。必ずドメインモデルへマッピングしろ。」

しかし、実務でシンプルな CRUD 画面や小さなアプリを作っていると、こう感じたことはないでしょうか?

・「これ、単に Entity を Domain に、Domain を UiState にコピーしてるだけの『作業ゲー』になってないか?」

・「わざわざすべての画面でレイヤーを分けてマッピングを書く意味あるのか?」

結論から言えば、すべての画面にドメインモデルや厳格なレイヤー分割は不要です。

CRUD 中心のシンプルな画面や、単にデータを保存して表示するだけのアプリであれば、Entity や DTO をそのまま UiState まで直通させたほうが明らかにコード量が減り、開発スピードが上がります。

では、ドメインモデルがどうしても必要になる瞬間(=存在意義)は一体いつなのか?

突き詰めると、理由は以下の2つのケースに集約されます。

それぞれのケースについて、「使わない場合」と「使う場合」のコードを比較してみましょう。

 

🧑🏻‍💻 複数のデータソースを混ぜて独自のルールを適用したいとき

ドメインモデルを使わない場合

ViewModel 内で直接データソースを結合し、判定ロジックも直書きしている状態です。ViewModel が肥大化し、テストが書きづらくなります。


@HiltViewModel
class UserViewModel @Inject constructor(
    private val userDao: UserDao,
    private val pointApi: PointApi
) : ViewModel() {

    val uiState: StateFlow<UserUiState> = combine(
        userDao.observeUser("123"),
        pointApi.observePoints("123")
    ) { entity, response ->
        val isVip = response.points >= 1000 && entity.planType == "PREMIUM"
        val discountRate = if (isVip) 0.20 else 0.0

        UserUiState.Success(entity.name, isVip, discountRate)
    }.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = UserUiState.Loading)
}

ドメインモデルを使う場合

ドメインモデル UserProfile に判定ロジックをカプセル化し、Repository で結合します。ViewModel は極めてシンプルになります。


data class UserProfile(
    val id: String,
    val name: String,
    val planType: String,
    val totalPoints: Int
) {
    val isVip: Boolean get() = totalPoints >= 1000 && planType == "PREMIUM"
    val discountRate: Double get() = if (isVip) 0.20 else 0.0
}

class UserRepositoryImpl @Inject constructor(
    private val userDao: UserDao,
    private val pointApi: PointApi
) : UserRepository {
    override fun observeUserProfile(userId: String): Flow<UserProfile> {
        return combine(userDao.observeUser(userId), pointApi.observePoints(userId)) { entity, response ->
            UserProfile(entity.id, entity.name, entity.planType, response.points)
        }
    }
}

@HiltViewModel
class UserViewModel @Inject constructor(
    observeUserProfileUseCase: ObserveUserProfileUseCase
) : ViewModel() {
    val uiState: StateFlow<UserUiState> = observeUserProfileUseCase("123")
        .map { profile -> UserUiState.Success(profile.name, profile.isVip, profile.discountRate) }
        .stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = UserUiState.Loading)
}

 

🧑🏻‍💻 API や DB のスキーマ変更による影響を防ぎたいとき

ドメインモデルを使わない場合

API の DTO がそのまま ViewModel まで直通しているため、バックエンドの変更がそのままアプリ全体の修正地獄に繋がります。


@Serializable
data class UserDtoV1(
    @SerialName("user_id") val userId: String,
    @SerialName("first_name") val firstName: String,
    @SerialName("last_name") val lastName: String
)

@HiltViewModel
class UserViewModel @Inject constructor(
    private val userApi: UserApi
) : ViewModel() {
    suspend fun loadUser(userId: String) {
        val dto: UserDtoV1 = userApi.getUser(userId)
        val displayName = "${dto.firstName} ${dto.lastName}"
    }
}

ドメインモデルを使う場合

Repository の内部の Mapper で変動しやすい DTO を受け止め、不変のドメインモデルに変換します。上流の ViewModel や UI は完全に無傷で守られます。


data class User(
    val id: String,
    val fullName: String
)

class UserRepositoryImpl @Inject constructor(
    private val userApi: UserApi
) : UserRepository {
    override suspend fun getUser(userId: String): User {
        val dto = userApi.getUser(userId)
        
        return User(
            id = dto.userId,
            fullName = "${dto.firstName} ${dto.lastName}"
        )
    }
}

@HiltViewModel
class UserViewModel @Inject constructor(
    private val userRepository: UserRepository
) : ViewModel() {
    suspend fun loadUser(userId: String) {
        val user: User = userRepository.getUser(userId)
    }
}

 

🧑🏻‍💻 まとめ

・ CRUD だけのシンプルな画面やアプリ:無駄なマッピングやドメインモデルは作らず、Entity/DTOをそのまま使ってスピードを優先する。

・ 複雑なビジネスロジックや複数データの結合がある場合:ドメインモデルを「計算・ロジックの避難所」として使う。

・ 外部スキーマ変更の耐性を高めたい場合:Repository の Mapper を「防壁」として機能させる。

設計の原則をすべての場所に盲目的に適用するのではなく、

「そのコスト(マッピングの手間)を払うだけのメリット(変更への耐性やロジックの隔離)があるか」

を見極めることが、実務でメンテしやすいコードベースを維持する最大のコツです。


「PHP2Shell」のサービス終了ドミノと、Android SDKバージョンの厳格化は同じ構造の悩みではないか?

古いPHPバージョンを維持できずに、そのままサービスを終了せざるを得ないWebサービスやサーバーのニュースを耳にする機会が増えています。古いPHPにおける「PHP2Shell」などの脆弱性リスクやサポート終了に伴う対応の難しさは、IT業界の大きな頭痛の種です。

ふと思ったのですが、これってスマートフォンのAndroidアプリにおける「Android SDK(APIレベル)」のバージョン強制問題とまったく同じ構造ではないでしょうか?

今回は、この「レガシー基盤のジレンマ」について、共通点と背景を整理してみたいと思います。

 

🤔 なぜ「同じ」と言えるのか? 共通する3つの構造

表面上は「サーバー側の言語(PHP)」と「モバイルのOS環境(Android)」で領域が違いますが、本質的な課題は驚くほど似ています。

土台のライフサイクルと、容赦ない切り捨て

PHPの現実: 古いPHPバージョン(7系や初期の8系など)はすでに公式サポートが終了しています。重大な脆弱性が見つかってもパッチが当たらないため、安全性を保つには最新版へ上げるしかありません。しかし、古いWebアプリや自作のプラグインがそのバージョンに対応していません。

Androidの現実: Google Playストアのポリシーにより、アプリがターゲットとすべき「ターゲットAPIレベル(SDKのバージョン)」の最低要件が毎年引き上げられます。古いSDKのまま放置されたアプリは、新しいAndroid OSのセキュリティ基準を満たせず、ストアから削除されたりアップデートができなくなります。

「動いているのに、強制的に動かなくなる」ジレンマ

どちらのケースも、「プログラム自体は今のままで、エラーも出さずに元気に動いている」という状態であることが多くあります。
しかし、言語側やプラットフォーム側が「セキュリティ」や「エコ全体の健全化」を理由にアップデートを強制するため、現役で動いていたはずのシステムが突如として「NG」判定を受けることになります。

膨大な「移行・修正コスト」の壁

古いコードを最新のPHPや新しいAndroid SDKに追従させるには、非推奨(デプリケーティッド)になった関数の書き換えや、ライブラリの全面的な見直しが必要です。これには膨大な開発工数とコストがかかるため、リソースのない個人開発や中小サービスでは、「直すくらいならサービスを終了しよう」という苦渋の決断に至ってしまいます。

 

🤔 決定的な違いは「誰が強制しているか」

構造は似ていますが、外圧の性質には少し違いがあります。

Android SDK: Googleという「絶対的なプラットフォームホルダー」が、規約をもってトップダウンで強制してくる。

PHP2Shell / サーバー: プラットフォームからの強制というよりは、ネット上の「セキュリティの脅威(ボットによる攻撃や脆弱性)」というボトムアップ(環境起因)の現実が、サービスを安全に継続させることを不可能にさせる。

結果として「続けられなくなる」という結末は同じでも、そのプレッシャーの出所が異なります。

 

🤔 まとめ:ITエコシステムの高齢化という宿命

PHPのバージョン問題も、Android SDKの追従問題も、根っこにあるのは「ITエコシステムの高齢化と、それに伴う保守コストの肥大化」です。

ソフトウェアは一度作ったら終わりではなく、その周りを取り巻く環境(OSや言語、セキュリティ基準)の高速な進化に合わせて絶えずメンテナンスし続けなければなりません。

「動いているからそのままにしておきたい」という開発者側の願いと、「安全のために進化を強制する」というプラットフォームのルールの板挟み。このジレンマにどう向き合うかが、現代のシステム開発・運営における大きな課題だと言えそうです。


PHP2Shell で騒ぐ前に知っておくべきこと:「PHPのバージョンを上げれば終わり」ができない現場の構造的欠陥

最近、「PHP2Shell」と呼ばれる深刻な脆弱性が話題になり、SNSやセキュリティ関連のニュースで大きく取り上げられています。

これに対して「何をそんなに大騒ぎしているのか? PHPのバージョンを安全な版にアップデートすれば終わる話ではないのか?」と感じた人も多いのではないでしょうか。

結論から言えば、その通りです。

技術的な解決策としては「修正されたバージョンに上げる」のが唯一かつ完全な正解です。しかし、それでもこれほどの大騒ぎになっているのは、「バージョンを上げることすらできないレガシーシステムが、世の中に大量に放置されているから」に他なりません。

この記事では、なぜ「バージョンアップ」という当たり前の対応ができないのか、そしてその裏にある現場の構造的な課題について整理します。

 

🤔 なぜ「PHPのバージョンアップ」で終わらないのか?

今回の脆弱性は、PHP 5.4から8.5という非常に広い範囲に影響を与えます。そして、ここが最大のネックなのですが、すでにサポートが終了(EOL)しているPHP 8.1以前のバージョンには、公式からのパッチが提供されません。

つまり、古いPHPを使っている環境は「修正版の8.2以降」へ一気にジャンプアップ(メジャー/マイナーバージョンアップ)する必要があります。

しかし、古いPHPで動いているシステムをそのまま最新のPHP 8.2/8.3に持っていくと、以下のような問題が確実に発生します。

・ 非推奨(Deprecated)機能・廃止関数の影響で画面が真っ白になる
・ 古いフレームワークやWordPressプラグインが動作しなくなる
・ データベースの接続ドライバや仕様変更に伴う挙動不審

結果として、「ただPHPのバイナリを差し替える」だけでは済まず、アプリケーションコードの大規模な改修と全機能の再テストが必要になります。

 

🤔 バージョンアップできないシステムは、ただの「延命」である

「コードを直せばいいじゃないか」と思うかもしれませんが、現場ではそれができません。

・ 仕様書がなく、当時の開発者もいない(誰も全体像を把握していない)
・「現状動いているから触るな」という経営陣・決裁者の圧力
・ 売上を生み出さない基盤改修に予算が出ない

このようにして、「修正費用が出せないから」「壊れるのが怖いから」とバージョンアップを先送りにしてきたシステムが、今回のPHP2Shellのような致命的な脆弱性に晒された瞬間、手も足も出なくなるのです。

WAF(Web Application Firewall)の設定調整や、一時的な設定変更で攻撃を防ごうとする現場もありますが、これはあくまで「時間稼ぎの応急処置」にすぎません。

バージョンアップに追従できないシステムは、ソフトウェアとしての寿命をすでに迎えており、「技術的負債の返済を先送りして延命しているだけ」というのが残酷な現実です。

 

🤔 開発者・エンジニアが取るべき姿勢

今回の騒動から私たちが学ぶべきなのは、「脆弱性が出たときにどう対処するか」だけではありません。「そもそもバージョンアップを前提としたアーキテクチャや保守体制をどう維持するか」という点です。

・ フレームワークや言語のマイナーアップデートを日々の運用に組み込む
・ EOL(サポート終了)を迎える前に、計画的なマイグレーション予算を確保する
・「動いているから触らない」ではなく、「触り続けられる状態(テストコードやCI/CD)を保つ」

これらができていないシステムは、どれだけ強固なセキュリティ製品を前段に置いたとしても、いつか必ず破綻します。

 

🤔 まとめ

「PHPのバージョンを上げたら終わりじゃないの?」という疑問に対する答えは、「理屈の上ではその通り。でも、上げられないシステムがあまりにも多すぎるから大騒ぎになっている」です。

セキュリティの脆弱性問題は、単なる技術の問題ではなく、これまでのシステム運用・投資判断のツケが可視化された結果とも言えます。

「そもそも、アナウンス済みのさまざまな脆弱性を放置したままだったよな?」

自社で管理しているシステムや関わっているプロダクトがあるなら、今回の件を機に「いつでも言語バージョンを上げられる状態になっているか?」を改めて見直してみるのが良いかもしれません。

👉 PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 fix two critical vulnerabilities that can allow unauthenticated remote code execution. All PHP users are strongly encouraged to update immediately.


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

👉 Java 脳のまま Jetpack Compose を書いてはいけない (1/2) の続きです。

 

🧑🏻‍💻 画面遷移を if 文だけで管理してはいけない理由

Composition から外れると remember の状態はどうなるのか

小さな UI なら、条件分岐で表示を切り替える方法も有効です。


@Composable
fun App() {
    var showDetail by remember {
        mutableStateOf(false)
    }

    if (showDetail) {
        DetailScreen(
            onBack = { showDetail = false }
        )
    } else {
        HomeScreen(
            onOpenDetail = { showDetail = true }
        )
    }
}

しかし、HomeScreen が Composition から外れると、その内部の remember で保持していた状態は通常失われます。

例えば、ホーム画面でスクロールし、詳細画面へ移動して戻ると、ホーム画面を再構築したときにスクロール位置が初期化されることがあります。

この挙動自体がバグというわけではありません。単純な条件分岐は、複数画面の履歴や状態を管理する仕組みではないからです。

画面履歴、戻る操作、状態の保存・復元が必要になったら、Navigation の導入を検討すべきです。

navBackStack は単なる画面履歴ではない

Navigation 3 では、バックスタックをアプリ側が所有し、画面を識別するキーのリストとして扱います。NavDisplay はその状態を監視し、対応する画面を表示します。

👉 基本を理解して実装する  |  App architecture  |  Android Developers

概念的には、次のような流れです。


ユーザー操作
    |
    v
Back Stack の変更
    |
    v
NavDisplay が状態を反映
    |
    v
表示する画面が変わる

Navigation 3 の基本的な例を見てみましょう。


@Serializable
data object Home : NavKey

@Serializable
data class Detail(
    val itemId: String
) : NavKey

画面を表すキーを定義します。実際の画面コンテンツそのものではなく、画面を特定するための情報です。

次にバックスタックを作成します。


@Composable
fun AppNavigation() {
    val backStack = rememberNavBackStack(Home)

    NavDisplay(
        backStack = backStack,
        onBack = {
            backStack.removeLastOrNull()
        },
        entryProvider = entryProvider {
            entry<Home> {
                HomeScreen(
                    onOpenDetail = { id ->
                        backStack.add(Detail(id))
                    }
                )
            }

            entry<Detail> { key ->
                DetailScreen(itemId = key.itemId)
            }
        }
    )
}

この例では、ホーム画面から詳細画面に移動すると、バックスタックに Detail が追加されます。戻る操作では最後のエントリーが削除されます。

コードのポイントは、画面の表示を手動で切り替えていないことです。バックスタックというナビゲーション状態を変更し、その状態を Navigation が UI に反映します。

なお、この例は Navigation 3 の API を使用しています。必要な依存関係やシリアライザーの設定は、プロジェクトの採用バージョンに合わせて追加してください。

バックスタックだけで状態がすべて保持されるわけではない

ここは誤解しやすいポイントです。

バックスタックに画面のキーが残っていることと、画面内部の状態が自動的にすべて復元されることは同じではありません。

Navigation 3 で状態を適切に保持するには、必要に応じて保存可能なバックスタック、Saveable State、ViewModel のスコープなどを構成します。公式ドキュメントでも、rememberNavBackStack やエントリー用のデコレーターを利用した状態管理が説明されています。

👉 Save and manage navigation state  |  App architecture  |  Android Developers

例えば、画面エントリーに ViewModel のスコープを与える場合は、対応する Navigation 3 の ViewModel 統合を利用します。


NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    entryProvider = entryProvider {
        entry<Home> {
            HomeScreen()
        }

        entry<Detail> { key ->
            DetailScreen(itemId = key.itemId)
        }
    },
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),
        rememberViewModelStoreNavEntryDecorator()
    )
)

これはエントリーごとの Saveable State と ViewModel の管理を組み込む例です。利用する依存関係と API のシグネチャは、採用バージョンの公式ドキュメントで確認してください。

Navigation 3 が重要なのは、単に戻るボタンを実装できるからではありません。画面履歴を明示的な State として表現し、画面の表示や状態管理をそのモデルと連携させられることにあります。

 

🧑🏻‍💻 State・Runtime・Navigation を一つの設計モデルで考える

ここまでの話は、別々のテクニックに見えるかもしれません。しかし、共通する考え方があります。


アプリの状態
    |
    +----------------------+
    |                      |
    v                      v
画面の UI State        Navigation State
    |                      |
    v                      v
Compose Runtime        Back Stack
    |                      |
    v                      v
Composition          表示する画面の決定
    |                      |
    +-----------+----------+
                |
                v
          ユーザーに見える UI

この図は責務の関係を概念的に示したものです。実際には Navigation の画面表示も Compose Runtime 上の Composition として実行されます。

設計では、次の 3 点を区別します。

・ State: UI に何を表示するかを決めるデータ。
・ Compose Runtime: State の読み取りを追跡し、Composition の更新を管理する仕組み。
・ Navigation: 画面履歴やエントリーを管理し、ナビゲーション状態を UI に反映する仕組み。

それぞれの責務を分けると、コードも自然に整理されます。

例えば、次のような実装は避けたいものです。


// Avoid: UI state and navigation are mixed together.
var currentScreen by mutableStateOf("home")
var detailTitle by mutableStateOf("")
var isLoading by mutableStateOf(false)

// Every action manually synchronizes several values.
fun openDetail(title: String) {
    currentScreen = "detail"
    detailTitle = title
    isLoading = false
}

複数の変数を手動で同期すると、更新漏れや矛盾が生まれやすくなります。

代わりに、画面のデータは UiState として導出し、画面遷移はバックスタックの変更として表現します。


// UI state is derived from data sources.
val uiState: StateFlow<DetailUiState> =
    combine(repository.detail, selectedItemId) {
            detail, selectedId ->
        DetailUiState(
            detail = detail,
            selectedItemId = selectedId
        )
    }.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5_000),
        DetailUiState()
    )

// Navigation is handled separately.
backStack.add(Detail(itemId))

これは概念例であり、selectedItemId の定義や combine の型はアプリに合わせる必要があります。重要なのは、UI データと画面履歴を一つの巨大な可変オブジェクトに詰め込まないことです。

 

🧑🏻‍💻 まとめ: Compose 化とは、構文ではなく設計モデルを変えること

Compose を使えば、自動的に宣言型の設計になるわけではありません。従来の命令型 UI の考え方を持ち込めば、状態の手動同期や画面管理の複雑さは残ります。

移行時には、次の点を確認してみてください。

・ UI を View のプロパティのように直接変更していないか。
・ UI の状態を複数の場所で手動同期していないか。
・ State をどこで所有し、どこで読み取るかが明確か。
・ Composition の寿命と ViewModel の寿命を区別しているか。
・ 画面履歴と戻る操作を、単純な条件分岐だけに任せていないか。
・ 状態の保存・復元を、利用する仕組みに合わせて設計しているか。

Compose の本質は、UI のコードを Kotlin に書き換えることではありません。

State と UI の依存関係を宣言し、Runtime の仕組みに沿って Composition を更新し、Navigation の状態と画面のライフサイクルを適切に連携させることです。

Compose を本当に使いこなすには、Composable の書き方を覚えるだけでなく、Compose Runtime が前提としている設計モデルに合わせる必要があります。

 

🧑🏻‍💻 参考

👉 Lifecycle of composables  |  Jetpack Compose  |  Android Developers
👉 State and Jetpack Compose  |  Android Developers
👉 Understand and implement the basics  |  App architecture  |  Android Developers
👉 Save and manage navigation state  |  App architecture  |  Android Developers


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 へつづく)