Navigation 3 時代でも ViewModel は State Holder であるべきか?

Compose と Navigation 3 が変えた ViewModel の役割

長年、Android 開発では一つの考え方が当たり前とされてきました。

画面の状態(State)は ViewModel に持たせる。

これは単なるベストプラクティスではなく、Android アーキテクチャの基本原則の一つでした。

ViewModel は Configuration Changes(画面回転など)でも破棄されず、さらに SavedStateHandle を使えば Process Death 後の状態復元もできます。

そのおかげで Activity や Fragment は UI を表示することに専念でき、画面の状態はすべて ViewModel が管理する、という設計が一般的になりました。

しかしここ数年で、この考え方は少しずつ変わり始めています。

Compose も Navigation 3 も ViewModel を不要にしたわけではありません。

代わりに、それぞれが 「本来その State を持つべき場所」 を用意するようになりました。

そこで、こんな疑問が生まれます。

Navigation 3 の時代でも、ViewModel は State Holder であるべきなのでしょうか?

 

🤔 以前の ViewModel は画面の State をすべて抱えていた

Compose が登場する以前は、多くの ViewModel が画面に関するほぼすべての状態を保持していました。


ViewModel
├── UI State
│   ├── text
│   ├── scrollPosition
│   ├── selectedTab
│   └── dialogState
│
├── Navigation State
│   └── screen arguments
│
└── Presentation Logic

この構成には多くのメリットがありました。

- Configuration Changes に強い
- Process Death 後も状態を復元できる
- Activity / Fragment をシンプルに保てる
- UI とビジネスロジックを分離できる

そのため、新しい State が増えたら、

とりあえず ViewModel に置く

という設計が自然になっていました。

 

🤔 Compose は UI State を UI に戻した

Jetpack Compose は、この考え方を少し変えました。

すべての UI State を ViewModel に持たせる必要はなく、Composable 自身が State を持てるようになったのです。

例えば検索文字列なら、


var query by remember {
    mutableStateOf("")
}

だけで十分です。

さらに Configuration Changes や Process Death を越えて保持したいなら、


var query by rememberSaveable {
    mutableStateOf("")
}

を使えます。

例えば次のような State は、

- TextField の入力内容
- 選択中のタブ
- 展開状態
- ダイアログの表示状態
- スクロール位置

どれも 純粋な UI State です。

ビジネスロジックではありません。

そのため、実際に使っている Composable の中に置く方が責務も分かりやすくなります。

Compose はまず、

「UI State は UI が持つ」

という考え方を広めました。

 

🤔 Navigation 3 はさらに一歩進めた

Navigation 3 は、この流れをさらに推し進めています。

まず、画面引数を SavedStateHandle 経由で受け渡す必要がなくなりました。

各画面は自分専用の NavKey を持ちます。


data class UserDetail(
    val userId: Long
) : NavKey

さらに Navigation 3 では rememberSerializable が追加されました。

rememberSaveable が Android の保存・復元機構を利用するのに対し、

rememberSerializable は NavEntry 単位 で UI State を保持します。

つまり、UI State は Navigation の Back Stack と一緒に移動するようになります。

画面ごとの State を ViewModel に保存する必要はなく、

各画面自身が UI State を持つ

という構造になります。

役割を整理すると次のようになります。


UI State
    │
rememberSerializable
    │
Composable


Navigation State
    │
NavKey


Presentation Logic
    │
ViewModel

UI State は UI のもの。

Navigation State は Navigation のもの。

どちらも、もう ViewModel が管理すべき State ではありません。

 

🤔 では ViewModel には何が残るのか?

UI State も Navigation State も ViewModel の役割ではなくなったとしたら、

ViewModel は何を担当するのでしょうか?

実は、やることはまだたくさんあります。


ViewModel
├── Repository の調整
├── ビジネスロジック
├── Flow の変換
├── Paging
├── Retry
└── viewModelScope

ここで面白いことがあります。

この中には、

State そのものはほとんどありません。

あるのは責務です。

ViewModel は

- Repository からデータを取得し
- ユーザー操作に応答し
- ビジネスロジックを実行し
- UI が描画しやすい形へ加工する

という役割を担います。

 

🤔 ViewModel は「State Holder」ではなく「State Producer」へ

例えば次のような ViewModel を考えてみます。


val uiState = combine(
    userRepository.users,
    settingsRepository.settings
) { users, settings ->
    HomeUiState(
        users = users,
        darkMode = settings.darkMode
    )
}.stateIn(...)

ここで実際の State はどこにあるでしょうか。

ViewModel ではありません。

データの正しい管理場所(Single Source of Truth)は Repository にあります。

ViewModel は、それらを UI 用に変換しているだけです。

つまり ViewModel は

State を保持する存在ではなく、State を生成する存在

になりつつあります。

一見小さな違いに思えますが、

Presentation Layer の考え方を大きく変える変化です。

 

🤔 State を持つ責任は、それぞれのレイヤーへ

Compose より前は、


ViewModel
├── UI State
├── Navigation State
└── Presentation Logic

という構成が一般的でした。

Navigation 3 では、


rememberSerializable
└── UI State

NavKey
└── Navigation State

ViewModel
└── Presentation Logic

へと役割が整理されます。

Compose は UI State を UI に戻しました。

Navigation 3 は Navigation State を NavKey に任せ、

UI State を NavEntry ごとに保持できるようにしました。

ViewModel が不要になったわけではありません。

責務がより明確になった のです。

 

🤔 よりシンプルなアーキテクチャ

その結果、アーキテクチャは次のように整理できます。


+----------------------+
|      Composable      |
|----------------------|
| UI State             |
| rememberSerializable |
+----------+-----------+
           |
           v
+----------------------+
|      ViewModel       |
|----------------------|
| Presentation Logic   |
| Flow Transformation  |
| Repository Calls     |
+----------+-----------+
           |
           v
+----------------------+
|      Repository      |
|----------------------|
| Persistent Data      |
| DataStore / Database |
+----------------------+

各レイヤーが、それぞれ最も適した責務を持ちます。

- Composable は UI State
- NavKey は Navigation State
- Repository は永続データ
- ViewModel は Presentation Logic

責務が自然に分離された構造です。

 

🧑🏻‍💻 まとめ

Navigation 3 が登場しても、ViewModel が不要になるわけではありません。

ViewModel はこれまでどおり、

- ビジネスロジック
- Repository の調整
- Flow の変換
- Paging
- 長時間動作する Coroutine

などを担当する最適な場所です。

一方で、Compose は rememberSaveable を導入し、UI State を UI に持たせやすくしました。

さらに Navigation 3 は rememberSerializable を追加し、UI State を NavEntry ごとに保持できるようにしました。また、Navigation State は NavKey が担当します。

その結果、「とりあえず ViewModel に State を置く」という設計は、必ずしも最適ではなくなりました。

これからは、

この State を ViewModel に保存するにはどうすればいいか?

ではなく、

そもそも、この State は ViewModel が持つべきものなのか?

と考える方が自然でしょう。

ViewModel は今でも欠かせない存在です。

しかし Navigation 3 の時代における主な役割は、State Holder ではなく Presentation Logic を担うことへと変化しつつあります。

なお、UI State を ViewModel の外へ移したからといって、Configuration Changes や Process Death に弱くなるわけではありません。

- UI State は rememberSaveable や rememberSerializable
- Navigation State は NavKey
- 永続データは Repository

というように、それぞれのレイヤーが適切な仕組みを使えば、Configuration Changes と Process Death の両方に対応した堅牢なアプリケーションを構築できます。

重要なのは State を保存することではなく、

「その State を最も自然に所有すべきレイヤーが管理すること」なのです。

 

🧑🏻‍💻 参考


Navigation 3 時代の「二重遷移」を防ぐ正しい方法 - dropUnlessResumed は debounce ではない

Android アプリでボタンを素早く連打すると、同じ画面が何度も積み重なってしまうことがあります。


Home
  ↓
Detail
  ↓
Detail
  ↓
Detail

この問題を防ぐために debounce や throttle を使うケースは少なくありません。

しかし、Navigation 3 が提供する dropUnlessResumed は考え方がまったく異なります。

これは「一定時間タップを無視する」のではなく、画面が遷移できる状態かどうかを見てイベントを受け付ける API です。

 

🤔 debounce との違い

一見すると似ていますが、見ているものが違います。

つまり、


debounce
    ↓
「まだ500ms経ってないから無視」

dropUnlessResumed
    ↓
「この画面はもう操作できないから無視」

という違いがあります。

 

🤔 Navigationでは「時間」より「状態」が重要

画面遷移が始まると、現在の画面はすぐに RESUMED ではなくなります。


RESUMED
    │
ボタン押下
    │
navigate()
    │
STARTED
    │
STOPPED

この間にもう一度クリックされても、


dropUnlessResumed {
    navController.navigate(...)
}

であれば実行されません。

つまり、


クリック1回目
    ↓
navigate()

クリック2回目
    ↓
画面はもう RESUMED じゃない
    ↓
無視

となります。

 

🤔 なぜ debounce より自然なのか

例えば画面遷移アニメーションが長くなったとします。

debounce は

500ms待つ

という固定時間なので、

- アニメーションが300ms
- アニメーションが700ms

どちらにも最適とは限りません。

一方 dropUnlessResumed は


画面が操作可能
      ↓
受け付ける

画面遷移中
      ↓
受け付けない

戻ってきた
      ↓
再び受け付ける

と、Lifecycle に合わせて自動的に動作します。

 

🤔 内部では何をしているの?

実装は驚くほどシンプルです。

概念的には次のような処理です。


if (lifecycle.currentState == Lifecycle.State.RESUMED) {
    block()
}

つまり、

- 現在の Lifecycle を確認する
- RESUMED のときだけラムダを実行する

それだけです。

タイマーも、Coroutine も、待ち時間もありません。

 

🤔 Navigation 3 での使い方

Compose ではクリックイベントをそのまま包むだけです。


val onClick = dropUnlessResumed {
    navController.navigate(Detail)
}

Button(
    onClick = onClick
) {
    Text("Open")
}

これだけで、

- 二重 Push
- 二重画面生成
- 連打による BackStack の重複

を簡単に防げます。

 

🤔 debounce を使うべき場面

もちろん debounce が不要になったわけではありません。

例えば

- 検索ボックス
- API リクエスト
- テキスト入力
- リアルタイム検索

のように「連続イベントを間引く」目的なら debounce が適しています。

一方、

- Navigation
- Dialog を開く
- BottomSheet を表示する

など Lifecycle に依存する UI 操作では dropUnlessResumed の方が自然です。

 

🤔 まとめ

dropUnlessResumed は debounce の代替ではありません。

見る対象が時間ではなく Lifecycle だからです。

Navigation では「今この画面は操作可能か」が最も重要になります。

そのため Navigation 3 では、時間ベースの制御ではなく Lifecycle ベースの制御で二重遷移を防ぐ設計になっています。

一度仕組みを理解すると、「なぜ Navigation 用に専用 API が用意されているのか」がよく分かるはずです。

 

🤔 参考


Composeのコンポーネントツリーにおけるバケツリレーを回避する方法

深くネストされたUIツリーの可読性と保守性を維持するための4つのComposeパターンを紹介します。

Jetpack Composeでは、小さく再利用可能なコンポーザブルを組み合わせてUIを構築することが推奨されています。しかし、アプリケーションが成長するにつれて、それらのコンポーザブルは自然と深くネスト(階層化)されていくものです。

その結果としてよく起こるのが、「プロップドリル(データのバケツリレー)」です。これは、特定のデータやコールバックを、それらを実際には必要としない中間層のコンポーザブルをいくつも経由して、下層へと引き渡していく現象を指します。


Parent
  ↓
Root
  ↓
Content
  ↓
Card
  ↓
UserName

この例では、Root、Content、Card は単に UserName にパラメータを転送しているだけです。彼ら自身はそのデータを利用しておらず、単なる「中継役」として機能しています。

Composeがこの問題を完全に消し去ってくれるわけではありませんが、問題を軽減または回避するための洗練された方法がいくつか用意されています。今回は、私が特によく使う4つのパターンを見ていきましょう。

 

🤔 レイアウト用コンポーネントには「Slot API」を使う

中間層のコンポーザブルが単にレイアウト(配置)を定義しているだけなら、通常は「Slot API」を使うのがもっともクリーンな解決策です。

対応前
すべてのコンポーザブルが、同じパラメータをひたすらバケツリレーしています。


@Composable
fun ParentScreen() {
    val userName = "Alice"

    Root(userName)
}

@Composable
fun Root(userName: String) {
    Content(userName)
}

@Composable
fun Content(userName: String) {
    Card(userName)
}

@Composable
fun Card(userName: String) {
    UserName(userName)
}

@Composable
fun UserName(userName: String) {
    Text(userName)
}

実際には、UserName だけが userName を必要としています。

対応後
代わりに、親(呼び出し側)に子コンポーザブルを組み立てさせます。


@Composable
fun ParentScreen() {
    val userName = "Alice"

    CardLayout {
        UserName(userName)
    }
}

@Composable
fun CardLayout(
    content: @Composable () -> Unit
) {
    Card {
        content()
    }
}

これでレイアウト用コンポーネント CardLayout は、userName について何も知る必要がなくなりました。単に、受け取ったコンテンツを「どこに表示するか」を決めているだけです。

Scaffold や LazyColumn、Button といったComposeの標準APIが、何十個ものパラメータを個別に公開するのではなく、スロット(content ラムダ)を採用しているのはまさにこれが理由です。

コンポーザブルの役割が「データ」の処理ではなく「レイアウト」である場合は、いつでもSlot APIの採用を検討しましょう。

 

🤔 共有オブジェクトには「CompositionLocal」を使う

オブジェクトの中には、特定のUIブランチ(階層)だけでなく、コンポジション全体で共有されるべきものがあります。

たとえば以下のようなものです。

- Navigator(画面遷移)
- Theme(テーマ・デザインシステム)
- Analytics(ログ分析ツール)
- User session(ユーザーセッション情報)
- Density(画面密度)

これらをすべてのコンポーザブルに引数で渡そうとすると、すぐに同じコードの繰り返しになってしまいます。

対応前


@Composable
fun ParentScreen() {
    Root(navigator)
}

@Composable
fun Root(navigator: Navigator) {
    Content(navigator)
}

@Composable
fun Content(navigator: Navigator) {
    Detail(navigator)
}

@Composable
fun Detail(navigator: Navigator) {
    Button(
        onClick = { navigator.pop() }
    ) {
        Text("Back")
    }
}

対応後


// 1. CompositionLocalを定義する
val LocalNavigator = staticCompositionLocalOf<Navigator> { 
    error("No Navigator provided") 
}

@Composable
fun ParentScreen() {
    // 2. 最上位で値をプロバイドする
    CompositionLocalProvider(LocalNavigator provides navigator) {
        Root()
    }
}

@Composable fun Root() { Content() }
@Composable fun Content() { Detail() }

@Composable
fun Detail() {
    // 3. 必要な場所で直接呼び出す
    val navigator = LocalNavigator.current
    Button(
        onClick = { navigator.pop() }
    ) {
        Text("Back")
    }
}

これにより、中間層にあるすべてのコンポーザブルが依存関係のチェーンから解放され、コードがすっきりします。

ただし、トレードオフとして「依存関係が暗黙的(コードの表面上は見えにくく)になる」という点には注意が必要です。そのため、CompositionLocal の使用は、UIの大部分で本当に広く共有される値だけに限定するのがベストです。

 

🤔 状態(State)とイベント(Event)をまとめる

プロップドリルが問題になるのは、状態(データ)を渡すときだけではありません。

実は、コールバック(関数)のバケツリレーのほうが、より大きな問題になりがちです。

対応前
引数が増えるたびに、中間層のすべてのコンポーザブルで同じコールバックを転送し続けなければなりません。


Child(
    userName = state.userName,
    isLoading = state.isLoading,
    onRefresh = viewModel::refresh,
    onRetry = viewModel::retry,
    onDelete = viewModel::delete,
    onRename = viewModel::rename,
    onLogout = viewModel::logout
)


@Composable
fun Content(
    userName: String,
    isLoading: Boolean,
    onRefresh: () -> Unit,
    onRetry: () -> Unit,
    onDelete: () -> Unit,
    onRename: (String) -> Unit,
    onLogout: () -> Unit
) {
    Child(
        userName,
        isLoading,
        onRefresh,
        onRetry,
        onDelete,
        onRename,
        onLogout
    )
}

対応後
複数のコールバックを個別に公開するのではなく、単一の「イベントディスパッチャー(イベント通知用ラムダ)」にまとめます。


sealed interface ScreenEvent {
    data object Refresh : ScreenEvent
    data object Retry : ScreenEvent
    data object Delete : ScreenEvent
    data object Logout : ScreenEvent
    data class Rename(val name: String) : ScreenEvent
}


Child(
    state = state,
    onEvent = viewModel::onEvent
)


Button(
    onClick = {
        onEvent(ScreenEvent.Refresh)
    }
) {
    Text("Refresh")
}

これにより、コンポーザブルが公開するAPI(引数)が圧倒的にすっきりします。さらに、新しいユーザーアクションを追加したくなったときも、中間層の関数をすべて書き直す必要がなくなるのが大きなメリットです。

 

🤔 巨大なViewModelを分割する

時には、プロップドリル(バケツリレー)が根本的な原因ではなく、別の問題から生じている「症状」に過ぎないこともあります。

もし、単一の ScreenViewModel が画面全体のあらゆる状態(State)を管理しているとしたら、すべてのデータがその1つのオブジェクトから流れ出すことになるため、バケツリレーが発生するのは当然と言えます。

対応前
すべての下層コンポーネントが、同じ単一のViewModelに依存しています。


ScreenViewModel
  ↓
Screen
├── Toolbar
├── Content
│   ├── Tab
│   │   ├── BottomSheet
│   │   └── Dialog

対応後
ViewModelを分割し、UIの各パーツにスコープを合わせます。


ScreenViewModel
  ↓
Screen
├── Toolbar
├── Content
│   ├── Tab
│   │
│   ├── BottomSheetViewModel
│   │     ↓
│   │   BottomSheet
│   │
│   └── DialogViewModel
│         ↓
│       Dialog


@Composable
fun BottomSheet() {
    val viewModel: BottomSheetViewModel = viewModel()

    val state by viewModel.state.collectAsState()

    // ...
}

Navigation 3やネストされたナビゲショングラフ(Nested Navigation Graphs)の登場により、UIのより小さな単位(パーツ)に対してViewModelのスコープを制限することが、以前よりもはるかに簡単になりました。

その結果、不要な共有状態(State)が減り、プロップドリルも大幅に解消されます。

 

🧑🏻‍💻 まとめ

プロップドリル(データのバケツリレー)が起きているからといって、必ずしも設計(アーキテクチャ)が悪いとは限りません。UIツリーが成長していく過程で、自然と発生してしまうケースも多々あります。

ここで重要になるのは、「なぜそのデータが、これほど多くの階層を経由しているのか」を掘り下げて考えることです。

これらのパターンは、どれか一つしか選べないというものではありません。事実、洗練されたComposeのコードベースでは、適材適所でこれら4つのアプローチがすべて組み合わされて使われています。


Compose State を Flow に変換する唯一の方法、それが snapshotFlow

snapshotFlow がなぜCompose専用に用意されているのか、その仕組みと実践的な使い方を紹介します。

Jetpack ComposeにはFlowを扱う機会が数多くあります。

Repositoryからデータを受け取るために Flow を collect したり、ViewModel が公開する StateFlow を collectAsState() したりすることは、すでに日常的なパターンになっています。

一方で、Compose State を逆に Flow へ変換したいと思ったことはないでしょうか。

例えば、

- スクロール位置を監視したい。
- 選択中のタブが変わったことを Analytics へ送信したい。
- TextField の入力を debounce() したい。
- Compose State を Flow の演算子で加工したい。

このような場面で登場するのが snapshotFlow です。

そして実は、Compose StateをFlowへ変換するCompose専用のAPIは snapshotFlow だけです。

 

🧑🏻‍💻 なぜflow {}ではダメなのか

最初に思い付くのは、普通の flow ではないでしょうか。


flow {
    emit(state.value)
}

もちろんこれは動きます。

しかし、一度値を送信するだけです。

その後 state.value が変化しても、新しい値は流れません。

なぜなら、flow {} はCompose Stateの変更を監視する仕組みを持っていないからです。

 

🧑🏻‍💻 snapshotFlow は Compose Snapshot を監視する

snapshotFlow は Compose の Snapshot システムと統合されています。


LaunchedEffect(Unit) {
    snapshotFlow {
        listState.firstVisibleItemIndex
    }.collect { index ->
        println(index)
    }
}

ラムダ内で読み取った Compose State を Compose 自身が監視し、

値が変化すると新しい値を Flow へ流します。

つまり、自分で emit() を書く必要はありません。

 

🧑🏻‍💻 イメージするとこうなる


Compose State
     │
     ▼
Compose Snapshot
     │
     ▼
snapshotFlow
     │
     ▼
  Kotlin Flow
     │
     ▼
map / filter / debounce / collect

Compose の世界と Flow の世界をつないでいるのが snapshotFlow です。

 

🧑🏻‍💻 Cold Flowであることも重要

snapshotFlow は Cold Flow です。

つまり、


val flow = snapshotFlow {
    state.value
}

これだけでは監視は始まりません。

実際に監視が始まるのは collect() された瞬間です。


LaunchedEffect(Unit) {
    snapshotFlow {
        state.value
    }.collect {
        // Side Effect
    }
}

そのため、Compose では LaunchedEffect と組み合わせて使うのが一般的です。

 

🧑🏻‍💻 実践例① スクロール位置を監視する

最もよく使われる例です。


LaunchedEffect(Unit) {
    snapshotFlow {
        listState.firstVisibleItemIndex
    }.collect(viewModel::onScrollChanged)
}

例えば、

- Toolbarの表示・非表示
- FABの表示切り替え
- Analytics送信

などに利用できます。

 

🧑🏻‍💻 実践例② Analyticsを送信する

Compose State の変化をイベントとして扱えます。


LaunchedEffect(Unit) {
    snapshotFlow {
        selectedTab
    }.collect(analytics::logTabSelected)
}

UIロジックを汚さず、副作用だけを分離できます。

 

🧑🏻‍💻 実践例③ Flow演算子を組み合わせる

snapshotFlow は通常の Flow なので、そのまま演算子を利用できます。


LaunchedEffect(Unit) {
    snapshotFlow {
        query
    }
        .debounce(300)
        .distinctUntilChanged()
        .collect(viewModel::search)
}

Compose Stateを、そのままリアクティブな Flow パイプラインへ接続できます。

 

🧑🏻‍💻 snapshotFlow が監視するのは Compose State だけ

重要なのは、監視対象はラムダ内で読み取ったCompose Stateだけという点です。


snapshotFlow {
    listState.firstVisibleItemIndex
}

このようなCompose Stateは監視できます。

一方、


var count = 0

snapshotFlow {
    count
}

通常の変数は Compose Snapshot が管理していないため、変更しても Flow は新しい値を流しません。

 

🧑🏻‍💻 まとめ

Compose にはさまざまな Flow API があります。

しかし、Compose State を Flow へ変換するために設計された Compose 専用 API は snapshotFlow だけです。

その役割は単に State を Flow へ変換することではありません。

Compose Snapshot とKotlin Flow を橋渡しし、

- スクロール監視
- Analytics
- TextField の入力監視
- Flow 演算子との連携

など、Compose で副作用を書くための基盤となるAPIです。

snapshotFlow を理解すると、「Compose State を Flowとして扱う」という考え方が自然になり、Composeらしい副作用の書き方が身に付くはずです。


【Kotlin 2.4】ついに登場!コレクションリテラル(実験的サポート)と型推論の仕組みを徹底解説

Kotlin開発者の皆さん、お待たせしました!

2026年6月にリリースされた Kotlin 2.4 にて、待望の「コレクションリテラル(Collection Literals)」が実験的(Experimental)にサポートされました。

これまで listOf() や mutableListOf()、arrayOf() などの関数を使って記述していた配列やリストの生成が、ついにスクエアブラケット [] を使って、よりシンプルかつ直感的に書けるようになります。

この記事では、コレクションリテラルの導入方法から、最も重要な挙動である「文脈依存の型推論(Context-sensitive Type Inference)」について詳しく解説します。

 

🧑🏻‍💻 コレクションリテラルとは?

Kotlin 2.4.0からは、以下のようにブラケット [] を使ってコレクション(配列やリストなど)を簡潔に作成できるようになります。


// Kotlin 2.4からの新しい書き方(リテラル表記)
val names = ["Joe", "Alice"]

従来の listOf("Joe", "Alice") と比べてタイピング量が減り、他言語(Javaの配列リテラルや、JavaScript/TypeScript/Pythonなどの配列・リスト表記)に慣れている開発者にとっても、より親しみやすいコードになります。

 

🧑🏻‍💻 導入方法(実験的サポートの有効化)

Kotlin 2.4時点では、この機能はまだ実験的(Experimental)な位置づけです。そのため、プロジェクトで利用するにはコンパイラオプションで明示的に機能を有効化する必要があります。

build.gradle.kts に以下の設定を追加してください。


kotlin {
    jvmToolchain(21)
    compilerOptions {
        // コレクションリテラルを有効化するコンパイラ引数
        freeCompilerArgs.add("-Xcollection-literals")
    }
}

 

🧑🏻‍💻 注目すべき「型推論」の挙動

コレクションリテラルを使う上で、最も興味深いのが「コンパイラがどのように型を推論するか」という点です。

Kotlinのコレクションリテラルは、単一の固定された型を表すのではなく、周囲の文脈(期待される型)に応じて最終的な型が変化する「文脈依存(Context-sensitive)」の性質を持っています。

具体的なコードで比較してみましょう。

1. 明示的な型指定がない場合

ターゲットとなる型を何も指定せずにリテラルを書いた場合、コンパイラはデフォルトで Array(配列) と推論します。


val names = ["Joe", "Alice"]
// 推論される型: Array<String>

2. 期待される型(型注釈)がある場合

変数の型を明示的に指定すると、リテラルの中身は同じ [] であっても、コンパイラが文脈を読み取って適切なコレクション型へと変換してくれます。


val names: MutableList<String> = ["Joe", "Alice"]
// 推論される型: MutableList<String>

このように、左辺の MutableList<String> という情報をコンパイラが解釈し、[] の部分を適切に MutableList として扱ってくれるのが、今回の型推論の面白いところです。

 

🧑🏻‍💻 まとめ:Kotlinのコードはさらに洗練される

Kotlin 2.4のコレクションリテラルは、ただの「書き方の省略」にとどまらず、Kotlinの強力な型推論エンジンとシームレスに融合している点が大きな特徴です。

現在はまだ実験的な機能であるため、プロダクション環境への導入には慎重になる必要がありますが、将来的に正式機能となれば、Kotlinのコードをさらにモダンでスッキリとしたものに変えてくれることは間違いありません。

興味のある方は、ぜひコンパイラオプションを追加して、手元のプロジェクトで新しい書き味を試してみてください!