【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のコードをさらにモダンでスッキリとしたものに変えてくれることは間違いありません。

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


Androidアーキテクチャの現状:GoogleのUDFと現実世界の「MVI風MVVM」の比較

現代のAndroid開発においてどのアーキテクチャを採用するかを議論する際、議論はほぼ常にMVVM対MVIのどちらにするかという点に集約されます。

しかし、近年の傾向を見ると、この2つは対立する力ではなく、収束しつつあることは明らかです。

 

🧑🏻‍💻 Googleの立場:特定のパターン名よりもUDFを優先

Googleの公式「アプリアーキテクチャガイド」を詳しく見てみると、「MVI」という用語はほとんど出てこないことに気づくでしょう。その代わりに、Googleは一貫してUDF(単方向データフロー)という概念を推奨しています。

Googleは、コミュニティに既に馴染みのある「MVI」ではなく、なぜ抽象的な用語である「UDF」を使用するのでしょうか?

その理由は、Googleが柔軟性を重視していることにあり、彼らの哲学は次のようなものと思われます。

「MVIのような厳格な命名規則にとらわれすぎないでください。アーキテクチャの本質である一方向のデータフローが維持されるようにするだけで十分です。」

Reduxのような厳密な状態管理ライブラリを使用する場合でも、シンプルなViewModelで実装する場合でも、Googleが重視するのは「状態は下へ流れ、イベントは上へ流れる」という基本原則に従うことです。

 

🧑🏻‍💻 現代の標準:MVI風MVVMの実装

現場では、Googleが推奨するUDFに対する最も一般的なアプローチは、私が「MVI風味のMVVM」と呼ぶもので、MVIルールをMVVMコンテナに詰め込むものです。これには、状態、意図、効果という3つのコア要素が含まれます。

実際には以下のようになります。


Composeのバケツリレーが限界? CompositionLocalでスマートに解決

etpack Composeで階層が深くなると発生する「バケツリレー(Prop Drilling)」。

「自分(中間コンポーネント)は使わないのに、孫に渡すためだけに引数を定義する」という不毛な作業は、CompositionLocal でスマートにショートカットしましょう。

🧑🏻‍💻 1. CompositionLocal の仕組み

通常、データは「親 → 子 → 孫」と引数で手渡ししますが、CompositionLocal はツリー全体にデータを「漂わせる」イメージです。

下の階層にいるコンポーネントは、必要なときにそのデータを「キャッチ」するだけで済みます。

🧑🏻‍💻 2. 実装例:2つのデータ(名前と色)を孫まで飛ばす

「ユーザー名」と「テーマカラー」の2つのデータを、中間の「子」を介さずに「孫」へ届けます。

① データの「鍵」を定義する

まず、共有したいデータの種類ごとに CompositionLocal オブジェクトを作成します。


import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color

// 1. 各データの「鍵」を作成(デフォルト値を設定)
val LocalUserName = staticCompositionLocalOf { "ゲスト" }
val LocalUserColor = staticCompositionLocalOf { Color.Black }

② 親・子・孫の実装

中間の ChildView が引数を一つも受け取っていない点に注目してください。


@Composable
fun ParentScreen() {
    val name = "桃太郎"
    val brandColor = Color(0xFFFF5722) // オレンジ

    // 2. CompositionLocalProvider で値を注入(複数一気に指定可能)
    CompositionLocalProvider(
        LocalUserName provides name,
        LocalUserColor provides brandColor
    ) {
        // 子を呼び出す(引数で渡す必要なし!)
        ChildView()
    }
}

// --- 中間のコンポーネント ---
@Composable
fun ChildView() {
    // 自身はデータを使わないので、引数も処理もスッキリ!
    println("ChildView: 私は何も知りません。")
    GrandChildView()
}

// --- データの使い道がある末端(孫) ---
@Composable
fun GrandChildView() {
    // 3. .current を使って必要なデータだけを直接取得
    val name = LocalUserName.current
    val color = LocalUserColor.current

    Column {
        Text(text = "こんにちは、${name}さん!", color = color)
        Text(text = "親から直接データを受け取りました。")
    }
}

🧑🏻‍💻 3. なぜ「2方向」でも楽なのか?

バケツリレーの場合、渡す項目が「名前」「色」「権限」「ID」と増えるたびに、ルートから末端までの全関数の引数を書き直す必要があります。

CompositionLocal なら:
親: provides を追加するだけ

子: 修正不要(ここが最大のメリット!)

孫: .current で取り出すだけ

🧑🏻‍💻 4. 注意点:使いすぎに注意!

魔法のように便利な CompositionLocal ですが、使いすぎると「このコンポーネントは何に依存しているのか?」がコードから読み取りにくくなります。

推奨: アプリ全体のテーマ(色・フォント)、ログインユーザー情報、ロケール設定など。

非推奨: その画面内の特定のボタンでしか使わないような一時的なフラグ。

バケツリレーが3階層を超え、中間コンポーネントが「ただの運び屋」になっていたら、CompositionLocal への切り替え時かもしれません。


「process death」とは何?

Androidにおける Process Death (プロセス終了) とは、システムがメモリ不足(Memory Pressure)になった際に、バックグラウンドにあるアプリのプロセスを OS が強制的に終了させる仕組みのことです。

ユーザーがアプリを閉じたり明示的に終了させたりする「通常の終了」とは異なり、OS側の都合で実行されるため、適切な対策をしないとアプリに戻った際にデータが消えてしまう原因になります。

 

🤔 なぜ気ににする必要があるのか

長いお問い合わせフォームを入力中に、少し調べ物をして戻ったら全部消えていた。

ECサイトで商品を比較していたのに、トップ画面に戻された。

「このアプリは不安定だ」「使いにくい」と思われ、アンインストールや低評価に直結します。

 

🤔 予期せぬクラッシュを防ぐため

Process Death からの復帰時、OSは「最後に開いていた画面」をいきなり表示しようとします。

もし、その画面が 「前の画面から渡されたデータ(IDなど)」 に依存しているのに、それをメモリ上の変数(ViewModelのフィールドなど)にしか持っていなかった場合、復帰した瞬間にデータが null や空になり、アプリがクラッシュします。

 

🤔 「バックグラウンド=一時停止」ではない

Androidの設計思想では、アプリがバックグラウンドに回った瞬間から、いつ消されても文句は言えないことになっています。

「メモリが潤沢にあるから大丈夫だろう」という想定は、現代のAndroid(特にバックグラウンドで動くサービスや重いゲームが多い環境)では通用しません。

「プロセスは必ずいつか死ぬ」という前提で設計することが、Android開発におけるプロフェッショナルな作法とされています。

 

🤔 開発時の確認方法

以下で開発時に確認しておくのがいいです。


1. アプリを起動したあと、ホーム画面に移動する。(終了させるのではなくて、バックグラウンドに移動する)

2. adb shell am kill <アプリのパッケージ名>

3. アプリの起動履歴から再度アプリを選択する。

注意: am force-stop とは異なり、am kill はバックグラウンドにいるアプリに対して「メモリが足りなくなったからシステムが回収した」という状態をシミュレートします。

起動履歴から再度アプリを起動したときに、バックグラウンドに移動する前の画面と同じものが表示されれば OK です。

 

🤔 まとめ

上記手順でやってみました。

これでOK!!


Navigation3 entryProvider DSLの使い方と設計

「手動マッピング(命令型)」か、「DSLマッピング(宣言型・型安全)か」

ということなります。

これまで:
when 式などを使って、ルートごとに手動でインスタンスを生成・紐付けするスタイル。自由度は高いですが、記述量が増えやすく、型の不整合も起きがちです。

DSL形式:
entry() のように、型を渡すだけで自動的にマッピングを完結させるスタイル。ボイラープレートが排除され、型安全性が保証されます。

 

🤔 比較してみる


entryProvider = { key ->
    when (key) {
        is RouteA -> NavEntry(key) { ... }
        is RouteB -> NavEntry(key) { ... }
        else -> error()
    }
}

  • key は Any
  • Any → is チェック必須
  • 毎回 is RouteB などの分岐が必要


entryProvider = entryProvider {
    entry<RouteA> { ... }
    entry<RouteB> { key ->
        Text(key.id)
    }
}

  • key は 型付き (RouteB)
  • 分岐不要
  • entry → コンパイル時保証

 

🤔 まとめ


NavDisplay
  └ entryProvider (lambda)
       └ when(key)
            ├ RouteA → NavEntry + UI
            ├ RouteB → NavEntry + UI
            └ else → error


NavDisplay
  └ entryProvider (DSL)
       ├ entry<RouteA> { UI }
       └ entry<RouteB> { key -> UI(key.id) }

DSLは便利だが、抽象化が増えるため「内部の仕組み」が見えにくい感じに思います。