Skip to content

mixigroup/2025BeginnerTrainingAndroid

Repository files navigation

概要

株式会社 MIXI の 2025 年新卒向け Android 研修で使用するリポジトリです。 研修では、Kotlin や Android アプリの開発経験がなくても学習できるように、基本的な内容から解説しています。 研修の後半では演習も用意しています。演習では簡易なアプリを作成します。

開発環境

  • Kotlin 2.1.20
  • Android Studio Meerkat Patch 1

はじめに

Android とは、Google が開発している Linux ベースの携帯端末向け OS です。ソースコードは AOSP(Android Open Source Project)として一般公開されており、各スマホメーカーはこれをベースに独自のカスタマイズを加えた OS を提供しています。

Android アプリとは、Android OS 上で動作するアプリケーションです。Android OS は Java VM のような仮想マシンを搭載しており、アプリはその仮想マシン上で実行されます。

ユーザーは Google Play などのストアから Android アプリをインストールして利用できます(もしくは、デバイスにプリインストールされています)。スマートフォンやタブレットはもちろん、TV(Android TV)、車載システム(Android Auto)やウェアラブル端末(Wear OS)など、幅広いデバイスで動作します。

Android アプリは、いまや様々なプログラミング言語で開発することができます。その中でも下記の理由から Kotlin が注目を浴びています。

  • Google が 2019 年に Kotlin を Android 開発の推奨言語として位置づけ、API やドキュメントの整備を積極的に進めることを公式に発表した(公式資料
  • 古いアプリで主に使われていた Java と互換性を保ちつつ、よりシンプルに記述でき、生産性高く開発できる
  • マルチプラットフォーム対応が進んでおり、Android と iOS で共通のコードを活用できる

また、開発環境としては、Kotlin との親和性や利便性の高さから、多くのプロジェクトで Android Studio を利用されています。

そのため、この研修でも Kotlin x Android Studio を使ったアプリ開発手法について解説します。

Kotlin について

Kotlin は、JetBrains 社によって開発された静的型付けのプログラミング言語です。オープンソースとしてGitHubで公開されています。Java プログラムとの相互運用性を保ちながら、可読性が高く簡潔にプログラミングできるため、多くの開発者に利用されています。

Kotlin の基礎知識

ブラウザ上で Kotlin コードを実行できる Web サイトがあるので、実際にコードを動かしながら学習していきましょう。

https://play.kotlinlang.org/

main 関数

多くのプログラミング言語と同様に、main関数がプログラムが最初に実行されます。funは予約語で、関数を宣言するときにつけます。printlnは引数で受け取った文字列を標準出力に表示する関数です。標準ライブラリに含まれています。

fun main() {
    println("Hello, world!") // Hello, world!と出力される
}

変数

変数宣言時にはvarvalをつけます。varにすると後から変更可能にでき、valは後から変更不可能にできます。また、初期値を設定すると型推論により型を省略できます。

fun main() {
    var firstName: String
    firstName = "mike" // varなので値を代入できる

    val lastName = "robert"
    lastName = "robert" // valなので代入できずコンパイルエラーになる
}

関数

引数を受け取る関数を定義したい時には、引数名: 型名()の中に記述すれば良いです。その後ろに戻り値を書きます。,で区切れば複数の引数を受け取ることができます。

fun add(a: Int, b: Int, c: Int): Int {
    return a + b + c
}

単一の式のみが含まれる関数は以下のように {} を省略して記述できます。その場合、戻り値の型は型推論されるので省略できます。

fun add(a: Int, b: Int, c: Int) = a + b + c

関数の呼び出し時には、引数名もつけて引数に渡せます。

fun add(a: Int, b: Int, c: Int) = a + b + c

fun main() {
    println(add(1, 2, 3))
    println(add(a = 1, b = 2, c = 3)) // これでもOK
}

引数にはデフォルト値を設定できます。

fun add(a: Int, b: Int, c: Int = 0) = a + b + c

fun main() {
    println(add(a = 1, b = 2)) // cはデフォルト値があるので省略できる
}

if 式

条件分岐はifを使って書くことができます。Kotlin ではifは式なので、値を返すことができます(そのためか、Kotlin には三項演算子がありません)。

fun main() {
    val num = 10
    println(if (num % 2 == 0) "even" else "odd") // even もしくは odd が表示される
}

クラス

クラスの宣言にはclassという予約語とクラス名が必要です。プロパティは{}の中で宣言することができます。デフォルトはpublicで、外部からアクセスできます。外部に公開したくないプロパティはprivateをつけます。クラス名に続けて()の中に引数を記述すれば、初期化時に引数を受け取ることができます。

class Contact(val name: String, private val email: String) {
    var tag: String = ""
}

fun main() {
    val c = Contact(name = "mike", email = "[email protected]")
    println(c.name)  // nameはpublicなので外部からアクセスできる
    println(c.email) // emailはprivateなのでコンパイルエラーになる
    c.tag = "colleague"
    println(c.tag)
}

インスタンスに紐づくメソッドは、プロパティと同様にクラスの宣言時に宣言します。

class Cat(val name: String) {
    fun speak() {
        println("$name: meow meow")
    }
}

fun main() {
    val cat = Cat("tama")
    cat.speak()
}

companion objectはクラスの静的なメソッドや定数を宣言することができます。インスタンスを生成するファクトリ関数などを宣言するときに使うと便利です。

enum class HttpStatus(
    val code: Int,
) {
    OK(200),
    NOT_FOUND(404),
    INTERNAL_ERROR(500),
    ;

    companion object {
        fun fromCode(code: Int) = if (code == 200) {
            OK
        } else if (code == 404) {
            NOT_FOUND
        } else if (code == 500) {
            INTERNAL_ERROR
        } else {
            throw IllegalArgumentException()
        }
    }
}

fun main() {
    val status = HttpStatus.fromCode(123) // 不明なコードなのでIllegalArgumentExceptionが発生する
}

when 式

複数パターンの分岐がある時はwhen式を使うと簡潔に書けます。他のプログラミング言語でのswitchに相当します。

     companion object {
-        fun fromCode(code: Int) = if (code == 200) {
-            OK
-        } else if (code == 404) {
-            NOT_FOUND
-        } else if (code == 500) {
-            INTERNAL_ERROR
-        } else {
-            throw IllegalArgumentException()
-        }
+        fun fromCode(code: Int) = when (code) {
+            200 -> OK
+            404 -> NOT_FOUND
+            500 -> INTERNAL_ERROR
+            else -> throw IllegalArgumentException()
+        }
     }

型で分岐させることもできます。すべての型を網羅している場合はelseをつけなくてもコンパイルできます。

enum class HttpStatus(val code: Int) {
    OK(200),
    NOT_FOUND(404),
    INTERNAL_ERROR(500),
}

fun main() {
    val status = HttpStatus.NOT_FOUND

    val message = when (status) {
        HttpStatus.OK -> "Success"
        HttpStatus.NOT_FOUND -> "Not Found"
        HttpStatus.INTERNAL_ERROR -> "Server Error"
    }

    println(message)
}

when式ではなるべくelseはつけない方が良いです。HttpStatusに値を追加して分岐を追加し忘れてもコンパイルが通ってしまいます。ミスを防ぐためにもelseをつけずに全パターン網羅することを意識しましょう!

データクラス

データをクラスで表現したいときはdata classを使うと便利です。全部は解説しませんが、data classは以下のメソッドを自動生成してくれます。

  • equals()/hashCode()
  • toString()
  • componentN() 
  • copy()
data class User(val name: String, val age: Int)

Kotlin ではデータの定義にclassを使った場合、equalsメソッドを自分で実装しないとデータ同士の比較が意図した挙動になりません。例えば、上記のUserデータで考えたときにnameageが同じなら、インスタンスが別々でもデータとしては等価なはずなので==で比較したときにtrueを返却して欲しいです。しかし、自分で実装しなければfalseが返却されます。

いちいち自分で実装するのは面倒なので、データの定義にはdata classがよく使われます。

data class UserDataClass(val name: String, val age: Int)

class UserClass(val name: String, val age: Int)

fun main() {
    // classで定義したUserデータ。falseが返却される
    println(UserClass(name = "mike", age = 35) == UserClass(name = "mike", age = 35))

    // data classで定義したUserデータ。trueが返却される
    println(UserDataClass(name = "mike", age = 35) == UserDataClass(name = "mike", age = 35))
}

拡張関数

拡張関数は、既存のクラスに継承せずとも関数を追加できます。

fun Int.isEven() = this % 2 == 0

fun main() {
    val number = 10
    println(number.isEven())
}

Null 安全

Java とは異なり、型でnullになり得るかどうかを区別することができます。これにより、不要な null チェックを省略できたり、null なオブジェクトを誤って参照してしまうことで発生する実行時エラー(NullPointerException)を回避できます。

nullable な変数を宣言したい場合、型の後ろに?をつければ良いです。

fun main() {
    var name: String?
    name = null
}

null なオブジェクトは not-null なオブジェクトのように.だけではメソッド呼び出しができないようになっています。

fun main() {
    var firstName: String?
    firstName = null

    // コンパイルエラーになる
    println(firstName.length)
}

?をつければオブジェクトがnull以外のときのみ、メソッドを実行します。nullの場合は、nullが戻り値として返却されます。null チェックが不要になりシンプルに記述できます。

fun main() {
    var firstName: String?
    firstName = "mike"

    println(firstName?.length)
}

他にも、スコープ関数と呼ばれるletを使うとスマートに null チェックができます。firstNamenullでなければ、let のブロックに渡した関数が実行されます。itにはfirstNameが入っています。

fun main() {
    var firstName: String?
    firstName = "mike"

    firstName?.let { println(it) }
}

!!をつけると nullable なオブジェクトを not-null なものとして扱うことができます。ただ、そのオブジェクトがnullの場合はNullPointerExceptionという例外が投げられます。よっぽどな理由がない限り、利用は避けるのが良いでしょう。

fun main() {
    var firstName: String?
    firstName = null

    // firstNameはnullなので実行時エラーになる
    println(firstName!!.length)
}

関数型とクロージャ

関数を受け取れる関数を宣言することができます。Unit は戻り値を返さないことを表す型です。Java や C の void に相当します。

fun call(number: Int, onCall: (Int) -> Unit) {
    onCall(number)
}

fun main() {
    val onCall: (Int) -> Unit = { number -> println(number) }

    call(
        number = 12,
        onCall = onCall,
    )
}

onCall は宣言しなくても直接引数に渡せます。

 }

 fun main() {
-    val onCall: (Int) -> Unit = { number -> println(number) }
-
     call(
         number = 12,
-        onCall = onCall,
+        onCall = { number -> println(number) },
     )
 }

渡している関数の引数が 1 つの場合、引数名を省略してitでアクセスできます。

 fun main() {
     call(
         number = 12,
-        onCall = { number -> println(number) },
+        onCall = { println(it) },
     )
 }

また、関数を受け取る引数が最後の場合は、渡す関数を引数の外に出すことができさらに簡潔に書けます。

 fun main() {
-    call(
-        number = 12,
-        onCall = { println(it) },
-    )
+    call(number = 12) {
+        println(it)
+    }
 }

引数として渡している関数のスコープを超えて変数にアクセスすることができます。

fun call(number: Int, onCall: (Int) -> Unit) {
    onCall(number)
}

fun main() {
    var c = 0

    call(number = 12) {
        c = it + 1
    }

    println(c) // 13
}

演習

サンプルコードを Kotlin Playground で実行してみましょう。

余裕があれば公式ドキュメントで以下について調べてみましょう。

  • 例外
  • List や Set などのコレクション

Android Studio について

Android Studio は IntelliJ IDEA ベースの IDE です。Android アプリ開発に特化しています。

操作に慣れるために、プロジェクトを作成してアプリを実行してみましょう!

アプリを実行する

プロジェクトを作成してアプリを実行してみましょう。まずは Android Studio を https://developer.android.com/studio からインストールして起動してください。

初めて起動すると初期設定のウィザードが表示されるかもしれません。Next を何回かクリックしてツールの DL をしてください。完了したら Finish をクリックしてください。

次に New Project で新規にプロジェクトを作成します。

image

Empty Activity を選択して Next をクリックします。

image

アプリ名などを入力します。ここで入力した情報は後からでも変更可能です。

  • Name
    • ユーザーから見えるアプリの名前
  • Package name
    • Java のパッケージ名のように、ドメインの逆順で命名します
      • 例:mitene.us であれば us.mitene など
  • Minimum SDK
    • サポートする最低 OS バージョンです。設定したバージョン未満の OS を利用しているユーザーは、Google Play 上でアプリを見つけられなくなります。
    • Android のバージョンは呼び方が 3 種類あります。例えば、Android 15 だと下記の通りです。OS バージョンによって処理を変えたい時に、コードネームや API レベルを使うことがあるので知っておくと良いです。他のバージョンは公式資料に記載があります。
      • バージョン : Android 15
      • API レベル : 35
      • バージョンコード : VanillaIceCream

image

Finish を押すと自動でプロジェクトを開きます。しばらくは indexing など IDE 側の処理が走るので少し待ちます。

image

完了したらこんな感じの画面になります。

image

早速エミュレータにアプリをインストールしてみましょう。Android の実機をお持ちの方は実機にもインストールできます。

エミュレータの場合

まずはエミュレータを作成します。Device Manager をクリックします。

image

Create Virtual Device をクリックします。

image

エミュレートする端末を選択します。

image

Finish をクリックします。システムイメージの DL が始まった場合は完了するまで待ちます。

image

実機の場合

まずは、開発者モードを有効にします。設定アプリを開き、デバイス情報を確認できる画面に遷移してください。ビルド番号を 7 回タップすると有効にできます。

image image

USB で接続する

USB ケーブルをお持ちの方は PC と実機を接続してください。接続できると以下のように接続したデバイス情報が表示されると思います。

image

Wi-Fi で接続する

Wi-Fi で接続する場合は、青矢印の部分をクリックします。

image

二次元コードが表示されるので、設定アプリの開発者オプションから実機で読み取ってください。

接続が完了したので、ビルドしてアプリをインストールしてみましょう。インストール先のデバイスを選択し、ビルド対象は app モジュールを選択します。緑色の ▶️ をクリックするとビルドからインストールまで自動で行ってくれます。Ctrl + R でも実行できます。

image

デバイスの画面にテキストが表示されれば OK です。

プロジェクトの構成

アプリのプロジェクトは以下のような構成になっています。

  • manifests(赤枠)
    • アプリ名やアイコン、使用するパーミッションなどの設定を記述するファイルです
  • kotlin+java(黄枠)
    • アプリのソースコードを記述する場所です
  • res(青枠)
    • 画像やレイアウト、テキストなどのリソースを記述する場所です
  • Gradle Scripts(紫枠)
    • ライブラリの管理やビルドの設定を記述する場所です

スクリーンショット 2025-04-18 7 54 52

実際のディレクトリツリーのような UI で表示するには、Project を選択してください。

スクリーンショット 2025-04-18 7 55 32

ログの表示

Log クラスを使うとログを出力できます。

import android.util.Log

Log.e("tagName", "call onCreate")

Logcat で出力されたログを確認できます。フィルターをかけるとログを絞り込むことができます。

スクリーンショット 2025-04-18 8 34 38

また、ログにはレベルをつけることができます。

import android.util.Log

Log.v("tagName", "call onCreate") // Verbose
Log.i("tagName", "call onCreate") // Info
Log.d("tagName", "call onCreate") // Debug
Log.w("tagName", "call onCreate") // Warning
Log.e("tagName", "call onCreate") // Error

Logcat ではレベルごとに異なる色で表示されます。

スクリーンショット 2025-04-18 8 29 43

他にも色々な機能があります。公式ドキュメントで紹介されているので、余裕があれば見てみてください。 https://developer.android.com/studio/intro

アプリ開発演習

この章では、Github にある mixigroup のリポジトリを取得し、ブックマークできるアプリを作成します。

2025-04-14.23.12.13.mov

演習ではアプリを完成させるまでの道のりをステップに分けています。

各ステップで学習する量はなるべく大きすぎないように設計しています。必要な情報は都度解説をつけています。ステップの最後には演習パートを用意しています。ぜひ自分でもコードを書いて Android アプリ開発を体験してみてください!

セットアップ

演習を始める前に、以下の準備を行ってください。

  1. リポジトリの URL をコピー

スクリーンショット 2025-04-18 7 23 19

  1. リポジトリのクローン

すでにプロジェクトを開いている場合は、左上のアプリ名から「Clone Repository」を選択して、クローンしてください。

スクリーンショット 2025-04-18 7 26 47

URL: 1.で取得したリポジトリの URL を入力してください。

Directory: リポジトリを保存する場所を選択してください。

スクリーンショット 2025-04-18 7 25 06

Clone をクリックすると、リポジトリのクローンが始まります。

  1. プロジェクトを開く

This Window を選択してプロジェクトを開いてください。

スクリーンショット 2025-04-18 7 29 35

※ Android Studio でプロジェクトを開いていない場合は、起動した際に表示される以下の画面から「Clone Repository」を選択してください。すでにクローンしたリポジトリを開きたい場合は、「Open」を選択してください。

スクリーンショット 2025-04-18 7 24 36

Step 0 : Jetpack Compose の基本について学習する

今回の演習では、Jetpack Compose(以下、Compose)を使ったモダンな方法で UI を実装していきます。まずは Compose について学習しましょう。

Compose について

Compose とは、Google が開発している UI ツールキットです。2021 年に安定版がリリースされました。従来の実装方法(以下、Android View)と比較して、Kotlin だけでシンプルに UI を記述することができます。

Android View vs Compose

単にテキストを表示する実装で比較してみます。

Android View

<TextView
    android:id="@+id/text"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
</TextView>
val view = findViewById<TextView>(R.id.text)
view.text = "Hello World!"

Compose

Text(text = "Hello World!")

Android View では、xml ファイルに表示したいコンポーネントやレイアウトを書きます。データを反映するには Java や Kotlin で、View オブジェクトを操作する必要があります。

一方で、Compose では Kotlin だけで実装できます。View オブジェクトのような UI の状態をプログラマが操作する必要はありません。単に関数を呼び出すだけで UI を記述できます。表示するデータの変更を UI に反映するには、関数に渡している引数を変更するだけで良いです。

Composable 関数

Compose では関数を呼び出すだけで UI を記述できます。しかし、ただ普通の関数ではダメです。Composable 関数という特別な関数にする必要があります。

Composable 関数の宣言

基本的には普通の関数と同じように宣言ができます。ただし、@Composableというアノテーションをつける必要があります。

UI を記述する Composable 関数の命名はパスカルケースで名詞にし、戻り値はUnitであるべきとされています(公式ガイドライン)。引数にはModifierという振る舞いの定義や見た目を装飾するオブジェクトを渡せるようにして、外部から変更できるようにすると再利用しやすいです。

@Composable
fun NameLabel(
    name: String,
    modifier: Modifier = Modifier,
) {
    Text(
        text = name,
        modifier = modifier,
    )
}

Composable 関数の呼び出し

Composable 関数は Composable 関数の中でしか呼び出すことができません。

// ok
@Composable
fun Composable() {
    NameLabel("mike")
}

// @Composableがないのでコンパイルエラーになる
fun NotComposable() {
    NameLabel("nick")
}

実際のアプリではActivityonCreatesetContentという関数を使って、Composable 関数を呼び出せるようにします。

※ Activity : View をホストするコンテナのようなオブジェクト

※ onCreate : Activity のライフサイクルに応じて呼び出されるコールバックの一種。画面を開いた時に呼び出される、ぐらいの認識で OK

class HogeActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            // Composable関数をここで呼び出せる
            NameLabel("ika")
        }
    }
}

// setContentの定義
public fun ComponentActivity.setContent(
    parent: CompositionContext? = null,
    content: @Composable () -> Unit // Composable関数の関数型を渡せるようになっている
)

Composable 関数の再実行

データの変更を UI に反映するには、Composable 関数を再度実行する必要があります。これを Recompose と呼びます。ただし、自分で関数を呼び出す必要はありません。Compose 自身が Recompose を引き起こすべきか判断して、自動的に再実行してくれます。

引数をHello, World!からBye!に変更すると自動的に反映されるイメージです

Text(text = "Hello, World!")
スクリーンショット 2025-04-14 3 45 31
Text(text = "Bye!")
スクリーンショット 2025-04-14 3 45 59

しかし、ただのオブジェクトを変更しても Compose は変更を検知してくれません。State型もしくはMutableState型にすることで、Compose がよしなに変更を検知してくれます。

例として、ボタンがクリックされた回数を表示する Composable 関数の実装を見てみます。

@Composable
fun CountButton() {
    var count by remember { mutableIntStateOf(0) }
    Button(
        onClick = { count++ }
    ) {
        Text("count: $count")
    }
}
  • mutableIntStateOf
    • mutableIntStateOfは変更可能なInt型の value を保持するMutableState型を生成します
    • 0は初期値です
  • remember
    • Recompose により関数が再実行されても、値を記憶させる関数です。これがないと、再実行のたびにcount0で初期化されてしまい、クリックされた回数をカウントできません。
  • by
    • Kotlin の delegated properties(委譲)という言語機能です。これをつけておくと、MutableState型Int型のように扱えます。

以上の 3 つを使って、クリックされた状態を管理するcountという変数を生成します。これをボタンがタップされたら実行されるコールバックのonClickで更新します。Compose はMutableState型countの変更を検知し、Recompose により再実行します。そのときにはTextに渡している文字列が更新されているので、クリックされた回数を表示することができます。

Modifier

Composable 関数で作成した UI の振る舞いや見た目を装飾するオブジェクトです。例えば、タップできるようにしたり、背景色を変更したりできます。

@Composable
fun ClickableText(
    modifier: Modifier = Modifier,
) {
    var count by remember { mutableIntStateOf(0) }

    Text(
        modifier = modifier
            .clickable { count++ }
            .background(color = Color.Gray),
        text = "$count times clicked",
    )
}
2025-04-03.13.43.33.mov

プレビュー機能

Composable 関数で作成した UI は、デバイスにインストールしなくてもサクッとプレビュー機能で確認できます。

プレビュー機能を使うには、プレビュー用の Composable 関数を作成します。普通の Composable 関数に@Previewというアノテーションを付与すれば良いです。プレビュー用の関数は公開しなくても UI を確認できるのでprivateにします。また、命名は{プレビューで表示したいComposable関数}Previewという命名がよく使われます。デフォルトだと背景が透過されみづらいので、showBackgroundtrueにして背景を白くしています。

@Composable
fun CountButton() {
    var count by remember { mutableIntStateOf(0) }
    Button(
        onClick = { count++ }
    ) {
        Text("count: $count")
    }
}

@Preview(showBackground = true)
@Composable
fun CountButtonPreview() {
    CountButton()
}

では、プレビューを表示してみましょう。右上の Split をクリックすれば右側に表示されます。

image

プレビューは差分を検知して自動で更新してくれます。Textに渡している文字列を変更してみてください。プレビューにも自動で反映されたと思います。

また、Interactive Modeにすると動的な部分もプレビューでチェックできます。

image

2025-04-03.14.30.48.mov

Step 1 : リポジトリを表示する

このステップでは、以下の UI を表示する Composable 関数を実装します。

スクリーンショット 2025-04-14 3 52 15

Compose でレイアウトを組む

Composable 関数をただ並べても、同じ場所に表示されてしまい UI が重なってしまいます。

Text("hello")
Text("world")
スクリーンショット 2025-04-14 3 52 15

Compose で UI を並べるにはColumnRowBoxを使えば良いです。

Column

  • 垂直方向に並べたいときに使います
Column {
    Square(color = Color.Red, size = 50.dp)
    Square(color = Color.Blue, size = 25.dp)
}
スクリーンショット 2025-04-14 4 20 02
  • 並べ方を調整したい時は、ArrangementAlignmentを設定すると良いです
Column(
    verticalArrangement = Arrangement.spacedBy(4.dp),
    horizontalAlignment = Alignment.End,
) {
    Square(color = Color.Red, size = 50.dp)
    Square(color = Color.Blue, size = 25.dp)
}
スクリーンショット 2025-04-14 4 20 27

Row

  • 水平方向に並べたいときに使います
Row {
    Square(color = Color.Red, size = 50.dp)
    Square(color = Color.Blue, size = 25.dp)
}
スクリーンショット 2025-04-14 4 20 51
  • Columnと同様に並べ方を調整したい時は、ArrangementAlignmentを設定すると良いです
Row(
    horizontalArrangement = Arrangement.spacedBy(4.dp),
    verticalAlignment = Alignment.CenterVertically,
) {
    Square(color = Color.Red, size = 50.dp)
    Square(color = Color.Blue, size = 25.dp)
}
スクリーンショット 2025-04-14 4 21 23

Box

  • 重ねて並べたいときに使います
Box {
    Square(color = Color.Red, size = 50.dp)
    Square(color = Color.Blue, size = 25.dp)
}
スクリーンショット 2025-04-14 4 21 45
  • BoxAligmentで重ねる位置を調整できます
Box(
    contentAlignment = Alignment.CenterEnd,
) {
    Square(color = Color.Red, size = 50.dp)
    Square(color = Color.Blue, size = 25.dp)
}
スクリーンショット 2025-04-14 4 22 11

演習

早速リポジトリの情報を表示できるようにしましょう。プレビューで確認できれば ok です。その責務を担うコンポーネントはRepoListItemとして定義することにします。

@Composable
fun RepoListItem(
    repo: Repo,
    modifier: Modifier = Modifier,
) {
    // ToDo: repo.title, repo.description, repo.stars を表示する
}

Repoは表示するリポジトリの情報を表現するクラスです。Repoには以下の情報を含ませています。

  • リポジトリの ID
  • リポジトリ名
  • 概要
  • スター数

概要が設定されていないリポジトリも存在するので、nullable にしています。

data class Repo(
    val id: Int,
    val name: String,
    val description: String? = null,
    val stars: Int,
)

Tips

  • コードが赤文字になりエラーが表示された場合は、import が足りてません。Opt + Enter を押せば候補を出してくれるので、適当に選択します。選択した import を自動で追加してくれます

    • ものによっては大量に候補が出てきますが、大体は@Composable がついているものを選択すれば大丈夫なはずです

image

解説

概要は nullable なので、null チェックをしてからテキストを表示しましょう。if 文でのチェックでも良いですが、スコープ関数のletを使って Kotlin らしく書くことができます。テキストのフォントの太さはFontWeightで調整できます。

 fun RepoListItem(
     repo: Repo,
     modifier: Modifier = Modifier,
-)
+) {
+    Text(
+        text = repo.name,
+        fontWeight = FontWeight.Bold,
+    )
+    repo.description?.let { Text(text = it) }
+}

プレビューで確認すると、テキストが重なって表示されてしまいます。Columnを使って縦に並べましょう。

     repo: Repo,
     modifier: Modifier = Modifier,
 ) {
-    Text(
-        text = repo.name,
-        fontWeight = FontWeight.Bold,
-    )
-    repo.description?.let { Text(text = it) }
+    Column {
+        Text(
+            text = repo.name,
+            fontWeight = FontWeight.Bold,
+        )
+        repo.description?.let { Text(text = it) }
+    }
 }

次にアイコンを表示してみましょう。Material Design の一部のアイコンが Icons で取得できるようになっています。アイコンの色を変えるにはtintColorを設定してください。 contentDescriptionにセットした文字列は、Talkback という Android のスクリーンリーダー機能で読み上げられます。

詳しくは https://developer.android.com/develop/ui/compose/accessibility/key-steps を参照してください。

     Column {
         Text(
              text = repo.name,
              fontWeight = FontWeight.Bold,
         )
         repo.description?.let { Text(text = it) }
+        Icon(
+            imageVector = Icons.Outlined.Star,
+            tint = Color.LightGray,
+            contentDescription = null,
+        )
     }
 }

スターアイコンの右隣にスター数を表示したいので、IconTextRowで囲います。

     Column {
         Text(
              text = repo.name,
              fontWeight = FontWeight.Bold,
         )
         repo.description?.let { Text(text = it) }
-        Icon(
-            imageVector = Icons.Outlined.Star,
-            tint = Color.LightGray,
-            contentDescription = null,
-        )
+        Row {
+            Icon(
+                imageVector = Icons.Outlined.Star,
+                tint = Color.LightGray,
+                contentDescription = null,
+            )
+            Text(text = "${repo.stars}")
+        }
     }
 }

最後に余白をModifierで設定します。また、Column内の余白はverticalArrangementで入れます。ColumnRowでのアイテム間の余白の入れ方は他にも各アイテムのModifierで設定する方法と、Spacerという Composable 関数で設定する方法がありますが、私個人としては下記の方法が好みです。理由としては、コードがスッキリして見通しが良くなるのと、アイテムを削除するときに意図せず余白が残ってしまうのを防げるためです。

     repo: Repo,
     modifier: Modifier = Modifier,
 ) {
-    Column {
+    Column(
+        modifier = modifier.padding(8.dp),
+        verticalArrangement = Arrangement.spacedBy(4.dp),
+    ) {
         Text(
             text = repo.name,
             fontWeight = FontWeight.Bold,

実装例の差分は以下を参考にしてみてください。 https://github.com/mixigroup/2025BeginnerTrainingAndroid/compare/main...reference/step-1

Tips: プレビュー用のデータセットを作成する

プレビューは複数定義できるので、概要がない場合の UI もプレビューで表示できるようにしたいです。もう一つプレビュー用の Composable 関数を追加しても良いですが、プレビューで確認したい状態が増える度に追加するのも少し面倒です。そんな時はPreviewParameterProviderを使うと楽にできます。

class RepoPreviewParameterProvider: PreviewParameterProvider<Repo> {
    override val values = sequenceOf(
        Repo(
            id = 1,
            name = "hoge",
            stars = 123,
        ),
        Repo(
            id = 2,
            name = "foo",
            description = "This is awesome repository.",
            stars = 1234,
        ),
    )
}

さっき追加したプレビュー用の関数で、PreviewParameterを受け取りましょう。概要がない状態の UI もプレビューで表示できているはずです。

@Preview(showBackground = true)
@Composable
private fun RepoListItemPreview(
    @PreviewParameter(RepoPreviewParameterProvider::class) repo: Repo,
) {
    RepoListItem(repo = repo)
}

Step 2 : リストで複数のリポジトリを表示する

次にリポジトリをリストで表示できるようにしてみましょう。

スクリーンショット 2025-04-14 4 22 11

リポジトリを一覧できる画面をホーム画面と呼ぶことにします。まずは、ホーム画面の UI を記述する Composable 関数を作成します。よく使われる命名としては{画面名}Screenです。今回はHomeScreenという命名で作成します。

では、複数のリポジトリをリストで表示してみましょう。Columnの中で for 文などで繰り返しRepoListItemを呼び出せば、リスト形式で表示できます。

@Composable
fun HomeScreen(
    repos: List<Repo>,
    modifier: Modifier = Modifier,
) {
    Column(modifier = modifier) {
        repos.forEach {
            RepoListItem(repo = it)
        }
    }
}

しかし、この方法で大量のリポジトリを表示する場合、パフォーマンスが著しく劣化します。例えば 10000 個のリポジトリを表示するとどうでしょう(※ IDE が非常に重くなるので皆さんは試さなくて大丈夫です)。エミュレータで表示してみると筆者の環境だとメモリが足りずアプリがクラッシュしました。原因はColumnは画面からはみ出て見えない部分も全て描画しようするからです。

ではどうすれば良いかというと、LazyColumnを使うと良いです。LazyColumnは見えている部分のみ表示するため、リソースの消費を抑えられます。プレビューやエミュレータで表示してみてください。問題なく表示できると思います。

LazyColumn(modifier = modifier) {
    items(
        items = repos,
        key = { it.id },
    ) {
        RepoListItem(repo = it)
    }
}

演習

Step 2 のコードを実装して、プレビューでリポジトリの一覧を表示できることを確認しましょう。

実装例の差分は以下を参考にしてみてください。 https://github.com/mixigroup/2025BeginnerTrainingAndroid/compare/reference/step-1...reference/step-2

Step 3: 画面のタイトルを表示する

デバイスでリポジトリを表示してみると、OS のステータスバーと被って表示されてしまいました。一般的なアプリは画面の最上部にスペースを開けて、その画面の機能やメニューなどを表示できるようにテキストやアイコンを表示しているでしょう。Compose ではそのようなレイアウトが簡単に実装できるような API が公開されています。それがScaffoldです。

表示したい UI をScaffoldで囲みましょう。

Scaffold(
    modifier = modifier,
) {
    LazyColumn {
        items(
            items = uiState.repos,
            key = { it.id },
        ) { repo ->
            RepoListItem(repo = repo)
        }
    }
}

次に画面トップにアプリバーを表示できるようにします。

 Scaffold(
     modifier = modifier,
+    topBar = {
+        TopAppBar(
+            title = {
+                Text("ホーム")
+            }
+        )
+    },
 ) {

なんだか良さそうに見えますが、実は最初のリポジトリがアプリバーと被って見えなくなっています。Scaffold では padding を受け取れるので、囲った Composable 関数に padding を渡してオフセットをかけるようにします。

             }
         )
     },
-) {
-         LazyColumn {
+) { innerPadding ->
+         LazyColumn(
+             modifier = Modifier.padding(innerPadding),
+         ) {
        items(
            items = uiState.repos,
            key = { it.id },

演習

Step 3 のコードを実装して、デバイスにリポジトリの一覧を表示できることを確認しましょう。

実装例の差分は以下を参考にしてみてください。 https://github.com/mixigroup/2025BeginnerTrainingAndroid/compare/reference/step-2...reference/step-3

Step 4 : サーバーからリポジトリを取得する

この章では、実際にネットワーク通信をして Github API からリポジトリを取得する処理を実装していきます。

API について

https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-organization-repositories の API を使って、MIXI GROUP 配下にあるリポジトリを取得します。

トークンの設定

Github API は認証がない状態で API を叩きすぎると、制限がかかります。念のため、各自でトークンを発行してください。

https://docs.github.com/ja/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens

発行したトークンをプロジェクトで利用できるようにします。local.propertiesにトークンを追加します。

GITHUB_API_KEY=<発行したトークン>

appにあるbuild.gradle.ktslocal.propertiesファイルを読み込み、BuildConfigで取得できるようにします。

BuildConfig : アプリのビルド時に生成されるオブジェクトです。

android {
    defaultConfig {
        // local.propertiesを読み込む
        val properties =
            properties.apply {
                loadProperties(project.rootProject.file("local.properties").path)
            }

        // BuildConfigに値をセット
        buildConfigField("String", "GITHUB_API_KEY", "\"${properties["GITHUB_API_KEY"]}\"")
    }

    buildFeatures {
        buildConfig = true // BuildConfigを生成するフラグ
        compose = true
    }
}

コード上ではBuildConfigから取得できます。

BuildConfig.GITHUB_API_KEY // これでトークンを取得できる

非同期処理

通信処理など完了に時間がかかるタスクは、メインスレッドとは別のスレッドで実行するべきです。そのような重たい処理をメインスレッドで行うと画面がフリーズしたようにみえ、ユーザー体験が悪くなります。

Android では、5 秒間メインスレッドがブロックされると ANR(Application Not Responding)が発生します。ANR が発生するアプリ = 品質の低いアプリと評価され、Google Play の内部アルゴリズムによってアプリをユーザーが見つけにくくなってしまいます(公式資料)。ビジネス的にも悪い影響を及ぼすので、なるべく ANR が発生しないようにちゃんと非同期で処理してあげましょう。

2025-04-03.16.03.21.mov

Kotlin Coroutines による非同期処理

Kotlin はコルーチンを使った非同期処理をサポートしています。コルーチンとは、中断・再開ができる軽量のスレッドのようなものです。コルーチンを使うと、同期的な処理と同じような書き方で非同期処理を実装できます。

scope.launch {
    val result = runHeavyTask()
    showResult(result)
}

コルーチンの起動

コルーチンを起動するには、CoroutineScopeが必要です。CoroutineScopeとは、その中で起動したコルーチンを管理するオブジェクトという理解で良いです。コルーチンは紐づいているCoroutineScopeでしか生存できません。

CoroutineScopeの作り方はいくつかありますが、今回の研修ではライブラリが用意しているものを使うので説明を省略します。

(詳細を知りたい方は、ドキュメント https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-scope/ などを読んでください。)

CoroutineScopeに対してlaunchを呼んであげるとコルーチンを起動できます。

コルーチンの中断・再開

suspend 関数を使うと自動で呼び出し元のコルーチンを中断(suspend)、再開(resume)ができます。中断している間はスレッドをブロックすることなく、他のコルーチンを実行することができるため、効率よく計算することができます。

suspend 関数とは、普通の関数にsuspendキーワードが付いた関数です。

suspend fun runHeavyTask()

suspend 関数は suspend 関数の中からしか呼び出すことができません。

suspend fun runSuspend() {
    runHeavyTask() // ok
}

fun run() {
    runHeavyTask() // コンパイルエラー
}

コルーチンのキャンセル

CoroutineScopeに対してcancelを呼ぶと、起動しているコルーチンをキャンセルできます。

scope.launch {
    // 処理の途中でもキャンセルされると、それ以降の処理は実行されない
    val result = runHeavyTask()
    showResult(result)
}

scope.cancel()

スレッドの切り替え

withContextを使うと、処理を実行するスレッドを切り替えることができます。 例えばサーバーとの通信やファイルの読み書きのような処理を行いたい場合は、Dispatchers.IOを指定することで I/O 専用のスレッドに切り替えることができます。

scope.launch {
    // メインスレッドで実行される部分
    val result = withContext(Dispatchers.IO) {
        // IOスレッドで実行される部分
        runHeavyTask()
    }
    showResult(result)
}

スレッドの切り替えをうっかり忘れてしまうかもしれません。そのようなミスを防ぐために、スレッドを切り替える処理を隠蔽するよう実装することが推奨されています(この考え方をメインセーフと呼びます)。

suspend fun runHeavyTask() = withContext(Dispatchers.IO) {
    // 重たい処理
}

Composable 関数内でコルーチンを起動する

Composable 関数ではLaunchedEffectを使って、コルーチンを起動することができます。LaunchedEffectが recompose された時にコルーチンがキャンセルされ再起動します。つまり、引数に渡しているkey1を変化させて、コルーチンの起動タイミングを制御できます。key を複数受け取れるようにしたLaunchedEffectも用意されています。

LaunchedEffect(key1 = key) {
    // このコールバックでsuspend関数を呼び出せる
}

HTTP クライアントライブラリの導入

ライブラリの選定

Java の API(HttpsURLConnection)を使って通信処理を実装することもできますが、実際の現場では HTTP ライブラリを使うことがほとんどだと思います。なので、この研修でもライブラリを使うことにします。

よく使われているライブラリとしてはRetrofitKtorがあります。今回は後で KMP(Kotlin Multiplatform の略。他プラットフォームとコードを共有する技術のこと)対応をしたいので、Ktorを選択します。

ライブラリの依存関係の管理

ライブラリの依存関係の管理には Gradle が提供しているVersion Catalogを使います。Gradle とは、Java プロジェクト向けのビルドシステムです。Android Studio ではデフォルトで Gradle を利用しています。

Version Catalogでは toml ファイルに依存するライブラリを書きます。libs.versions.tomlというファイルを開いてください。CMD + Shift + O でファイル検索すると良いです。

  • [versions]
    • ライブラリのバージョンを変数で定義できるセクションです
    • 定義した変数は以下の libraries と plugins セクションで利用することができます
  • [libraries]
    • ライブラリを定義できるセクションです
  • [plugins]
    • プラグインを定義できるセクションです
[versions]
agp = "8.9.0"
composeBom = "2024.09.00"

[libraries]
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

app配下にあるbuild.gradle.ktsでどのライブラリを利用するかを宣言すれば、ライブラリを利用できます。

dependencies {
    implementation(platform(libs.androidx.compose.bom))
}

plugin の場合は、プロジェクトルートにあるbuild.gradle.ktsにも宣言しておきます。

// build.gradle.kts
plugins {
    alias(libs.plugins.android.application) apply false
}
// app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
}

JSON のパース

API からは JSON が返却されます。それをパースして Kotlin のオブジェクトに変換する必要があります。変換処理を丸ごと担ってくれるライブラリがプラグインとして Jetbrains から公開されているので、これを利用します。

使い方は非常に簡単です。まずは、JSON から変換したいクラスに@Serializableをつけます。JSON の命名とマッピング先のクラスで命名を変更したいときは、@SerialNameで変更できます。

@Serializable
data class Item(
    val id: Int,
    @SerialName("is_added") val addedCart: Boolean,
)

演習

では、実際にネットワーク通信をしてサーバーからリポジトリを取得してみましょう。HTTP クライアントは以下を利用してください。

// HTTPクライアントオブジェクトの生成コストは低くはないので、インスタンスを使いまわせるようグローバル空間で生成しておく
val httpClient = HttpClient(CIO) {
    install(ContentNegotiation) {
        // 不要なJSONは無視したいので、ignoreUnknownKeysをtrueにする
        json(json = Json { ignoreUnknownKeys = true })
    }

    // ヘッダーにアクセストークンをセットしておく
    install(Auth) {
        bearer {
            BearerTokens(accessToken = BuildConfig.GITHUB_API_KEY, refreshToken = null)
        }
    }
}

HTTP クライアント(Ktor)は以下のライブラリを導入してください。

  • io.ktor:ktor-client-core
  • io.ktor:ktor-client-cio
  • io.ktor:ktor-client-content-negotiation
  • io.ktor:ktor-serialization-kotlinx-json
  • io.ktor:ktor-client-auth
  • バージョンは全て3.1.0を利用してください

JSON パーサー(kotlin serialization)は以下のプラグインを導入してください。

  • org.jetbrains.kotlin.plugin.serialization
  • バージョンは Kotlin と同じバージョンを利用してください

Tips

  • Version Catalog への追加は Android Studio のサポート機能を使うと楽ができます

    • app/build.gradle.ktsに導入したいライブラリを直書きします

      dependencies {
          implementation("io.ktor:ktor-client-core:3.1.0")
      }
    • Replace new library catalog declaration … をクリックします

    image

    • 自動的に toml ファイルへ移行してくれます
  • ライブラリの追加などで gradle(ビルドシステムのこと)のスクリプトを修正すると、下記のように通知が表示されます。これが表示されたらとりあえずSync Nowをクリックすると良いです。ライブラリの追加やバージョンを変更したなら、該当ライブラリの DL を自動で実行してくれます。

    image

解説

まずKtorを導入します。

# libs.versions.toml
[versions]
ktorClient = "3.1.0"

[libraries]
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktorClient" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClient" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktorClient" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktorClient" }

次にapp配下にあるbuild.gradle.ktsに、toml ファイルに書いたライブラリを書きます。

// app/build.gradle.kts
dependencies {
    ...
    implementation(libs.ktor.client.core)
    implementation(libs.ktor.client.cio)
    implementation(libs.ktor.client.content.negotiation)
    implementation(libs.ktor.serialization.kotlinx.json)
    ...

プラグインなので、さっきとは違い[plugins]の中に書きます。

# libs.versions.toml
[plugins]
...
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

プロジェクトルートにあるbuild.gradle.ktsapp配下にある両方に plugin を追加します。

// build.gradle.kts
plugins {
    ...
    alias(libs.plugins.kotlin.serialization) apply false
}

// app/build.gradle.kts
plugins {
    ...
    alias(libs.plugins.kotlin.serialization)
}

Repoクラスを JSON レスポンスから変換できるようにします。JSON をパースしてオブジェクトに変換するには@Serializableをつければ OK です。ただし、JSON のキー名は一致させる必要があります。JSON キーがスネークケースなどプロパティ名を一致させるのが難しい場合は、@SerialNameを使いましょう。

+@Serializable
 data class Repo(
     val id: Int,
     val name: String,
     val description: String? = null,
-    val stars: Int,
+    @SerialName("stargazers_count") val stars: Int,
 )

プレビューではネットワーク通信ができません。なのでHomeScreenMainActivityで呼び出し、実機で表示できるようにしておきます。また、Android アプリは事前に「このアプリはインターネット通信をします」という宣言をしておかないと、通信できません。この状態でアプリを起動してもクラッシュします。なので、AndroidManifest.xmlに以下を追記します。

<uses-permission android:name="android.permission.INTERNET" />

<application
  ...

HTTP クライアントを使ってGETしてみます。

get メソッドは suspend 関数のため、呼び出すには Coroutine Scope が必要です。Composable 関数内で Coroutine を起動するには、LaunchedEffectを使います。(※ LaunchedEffect はネットワーク処理をするための Composable 関数ではありませんが、まずは通信できることを確認したいので許容します)

 ) {
+    LaunchedEffect(Unit) {
+        val result: List<Repo> = httpClient.get("https://api.github.com/orgs/mixigroup/repos").body()
+    }
+
     HomeScreen(
         modifier = modifier,
         repos = emptyList(),

取得したリポジトリを表示できるようにしましょう。List<Repo>を監視して、取得に成功したら更新するようにします。監視するためには State オブジェクトにします。また、remember を使って Recomposition で関数が再実行されても値を記憶させます。

 fun HomeScreen(
     modifier: Modifier = Modifier,
 ) {
+    var repos = remember { mutableStateListOf<Repo>() }
+
     LaunchedEffect(Unit) {
         val result: List<Repo> = httpClient.get("https://api.github.com/orgs/mixigroup/repos").body()
+        repos.addAll(result)
     }

     HomeScreen(
         modifier = modifier,
-        repos = emptyList(),
+        repos = repos,
     )
 }

実装例の差分は以下を参考にしてみてください。 https://github.com/mixigroup/2025BeginnerTrainingAndroid/compare/reference/step-3...reference/step-4

Step 5 : アプリアーキテクチャの導入

ここまでで、API からリポジトリを取得して画面に表示できるようになりました。しかし、全ての処理が Composable 関数に実装されており、受け持つ責務が必要以上に大きいように見えます。テストも非常に書きづらいです。そこでアーキテクチャを導入し、処理を適切な単位で分割していくことにします。

アーキテクチャについて

アーキテクチャについて考える上で特に重要なのが「関心の分離(Separation of Concerns)」です。これは、アプリケーションを構成するコンポーネントを役割ごとに分割することで、各コンポーネントの目的を明確にしプログラムに秩序を持たせるという設計原則です。

例えば、UI の表示と、データの取得や保存といった処理は、それぞれ異なる責務を持つものであり、同じクラスやファイルに混在させない方がコードの見通しが良くなるでしょう。このように処理を適切に分割することで以下のメリットを得られます。

  • テストが書きやすくなる
    • UI 層とデータ層を分離することで、それぞれの層に特化した単体テストを書きやすくなります
    • これにより、個々のコンポーネントの振る舞いを独立して検証でき、テストの網羅性と信頼性が向上します
  • 再利用性の向上
    • 複数の機能で共通して利用される処理を切り出すことで、同じ処理を何度も実装する必要がなくなります
    • プログラムの再利用性を高めることで開発効率が向上できます
  • 保守性の向上
    • 各層やコンポーネント間の結合度を下げることで、修正や機能追加の影響範囲を狭めることができます
    • これにより、変更に強い柔軟な構造を実現できます

Android アプリの推奨アーキテクチャ

Android アプリの公式ドキュメントでは、以下のようなレイヤーがあるアーキテクチャが推奨されています。 https://developer.android.com/topic/architecture

※ ドキュメントでは UI 層と Data 層の間に Domain 層が optional として存在しますが、今回は省略します。

UI 層

UI の表示処理を主に担うレイヤーです。UI 層はさらに以下のように分割することが推奨されています。

  • UI Elements
    • 画面の UI を構成するコンポーネントです。例えば、ボタンやテキストなどです。
    • UI を表示する Composable 関数が当てはまります
  • State Holder
    • 以下のような責務を担うコンポーネントです
      • UI の状態の保持
      • Data 層から取得したデータを UI の状態に変換
      • UI の状態を UI Elements に伝達
      • UI からのイベント(タップ操作など)のハンドリング
    • ViewModel や Presenter といったクラスが当てはまります
      • 公式資料でも推奨されているため、研修では ViewModel を使います

具体的には以下のような実装になるイメージです。

まずは、UI の状態を定義します。

data class MainUiState(
    val items: List<String> = emptyList(),
)

State Holder である ViewModel では UI の状態を UI State として公開します。

class MainViewModel : ViewModel() {
    // 変更可能な StateFlow は private にして、外部からは変更できないようにする
    private val _uiState = MutableStateFlow(MainUiState())
    // 外部には変更不可な StateFlow を公開する
    val uiState = _uiState.asStateFlow()
}

Flowはデータを連続的に送ることができるパイプのようなオブジェクトです。データを送りたい時はupdateメソッドかセッターを呼び出します。

// どちらでもデータを送ることができる
uiState.update { it.copy(items = items) }
uiState.value = MainUiState(items = items)

データを受け取るときはFlowcollectします。Composable 関数で受け取る場合はcollectAsStateWithLifecycleを呼び出せば良いです。こうすれば UI State が ViewModel 側で更新されるたびに、Composable 関数に値が流れてきます。

val uiState by viewModel.uiState.collectAsStateWithLifecycle()

Composable関数内で ViewModel を生成するには下記のようにします。

androidx.lifecycle:lifecycle-viewmodel-compose ライブラリの依存を追加する必要があります。

val viewModel: MainViewModel = ViewModel()

Data 層

アプリデータの操作やビジネスロジックを含むレイヤーです。Data 層はさらに以下のように分割することが推奨されています。

  • Repository
    • データソース(≒ データの保存先)を隠蔽するレイヤーです
    • 関連するビジネスロジックも含む場合があります
  • RemoteDataSource
    • リモートにあるデータを操作するレイヤーです
    • サーバーの API を叩く処理などはここに実装します
  • LocalDataSource
    • ローカルにあるデータを操作するレイヤーです
    • 端末内にデータを保存する処理などをここに実装します

具体的には以下のような実装になるイメージです。

単体テストしやすくするために、依存するクラスをコンストラクタで受け取るようにします。例えば、Repository では RemoteDataSource と LocalDataSource に依存するので、2 つ受け取っています。

class Repository(
    private val remoteDataSource: RemoteDataSource,
    private val localDataSource: LocalDataSource,
)

データの操作には時間がかかります。Repository で公開する API は suspend 関数にして、コルーチン内で呼び出すことを強制させる方が良いでしょう。また、呼び出し元でスレッドを意識させないようにしてあげるとミスを防げます(メインセーフ)。

class Repository(
    private val remoteDataSource: RemoteDataSource,
    private val localDataSource: LocalDataSource,
) {
    // suspend 関数にして、呼び出し元でコルーチンを起動させる
    suspend fun getRepoList(): List<Repo> {
        return remoteDataSource.getRepoList()
    }
}

class RemoteDataSource {
    // withContextを使ってIO処理用のスレッドで実行させる
    suspend fun getRepoList(): List<Repo> = withContext(Dispatchers.IO) {
        return httpClient.get()
    }
}

// ViewModelではスレッドを意識しなくて良い
viewModelScope.launch {
    val repoList = repository.getRepoList()
}

演習

今まで作ったアプリにアーキテクチャを導入してみましょう

ViewModel ではファクトリを実装して、インスタンスの生成はファクトリに任せるようにします。ファクトリはライブラリで提供されているので以下のように実装します。

class HogeViewModel(private val repository: Repository): ViewModel() {

    companion object {
        // ViewModelProvider.Factoryを使ってファクトリを実装する
        val Factory = object : ViewModelProvider.Factory {
            @Suppress("UNCHECKED_CAST")
            override fun <T : ViewModel> create(modelClass: Class<T>): T {
                return HogeViewModel(
                    repository = Repository(),
                ) as T
            }
        }
    }
}
解説

最初に ViewModel を作成することにします。まずは UI State を宣言します。

data class HomeUiState(
    val repos: List<Repo>,
)

ViewModel を作成します。一旦、HTTP クライアントを直接使うことを許容します。

class HomeViewModel: ViewModel() {
    var uiState = MutableStateFlow(
        HomeUiState(
            repos = emptyList(),
        )
    )
        private set

    fun onLaunched() {
        viewModelScope.launch {
            val repos: List<Repo> = httpClient.get("https://api.github.com/orgs/mixigroup/repos").body()
            uiState.update {
                it.copy(
                    repos = repos,
                )
            }
        }
    }
}

Composable 関数にあるロジックを ViewModel で置き換えていきます。

@Composable
fun HomeScreen(
    modifier: Modifier = Modifier,
    viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    LaunchedEffect(Unit) {
        viewModel.onLaunched()
    }

    HomeScreen(
        modifier = modifier,
        uiState = uiState,
    )
}

次にネットワーク通信をする処理を Repository と DataSource に分割します。

class GithubRepoRemoteDataSource {
    suspend fun fetchRepoList(): List<Repo> {
        return httpClient.get("https://api.github.com/orgs/mixigroup/repos").body()
    }
}
class GithubRepoRepository(
    private val remoteDataSource: GithubRepoRemoteDataSource = GithubRepoRemoteDataSource(),
) {
    suspend fun getRepoList(): List<Repo> {
        return remoteDataSource.fetchRepoList()
    }
}

ViewModel で Repository を使います。

-class HomeViewModel: ViewModel() {
+class HomeViewModel(
+    private val repository: GithubRepoRepository = GithubRepoRepository(),
+): ViewModel() {
     var uiState = MutableStateFlow(
         HomeUiState(
             repos = emptyList(),

     fun onLaunched() {
         viewModelScope.launch {
-            val repos: List<Repo> = httpClient.get("https://api.github.com/orgs/mixigroup/repos").body()
             uiState.update {
                 it.copy(
-                    repos = repos,
+                    repos = repository.getRepoList(),
                 )
             }
         }

ファクトリを実装します。

class HomeViewModel(
    private val repository: RepoRepository,
): ViewModel() {
    companion object {
        val Factory = object : ViewModelProvider.Factory {
            @Suppress("UNCHECKED_CAST")
            override fun <T : ViewModel> create(modelClass: Class<T>): T =
                HomeViewModel(
                    repository = RepoRepository(
                        remoteDataSource = RepoRemoteDataSource(),
                    ),
                ) as T
        }
    }
}

実装したファクトリを渡して ViewModel を生成するようにします。

 @Composable
 fun HomeScreen(
     modifier: Modifier = Modifier,
-    viewModel: HomeViewModel = viewModel(),
+    viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
 ) {
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()

実装例の差分は以下を参考にしてみてください。 https://github.com/mixigroup/2025BeginnerTrainingAndroid/compare/reference/step-4...reference/step-5

Step 6 : ブックマーク機能を作る

次にブックマーク機能を作ります。まずは永続化は考えないことにします。

イベントのハンドリング

「ボタンをクリックした」などのユーザー操作によるイベントは、その操作を受けた Composable 関数に流れてきます。

// クリックされるたびにonClickが呼ばれる
Button(onClick = onClick) {
    Text("ボタン")
}
2025-04-15.2.15.27.mov

アプリはユーザーの操作を起点に、API を叩くなど様々なロジックを実行します。そのような処理の実行責任は State Holder が担うべきです。

class ViewModel() {
    // クリックできるComposable関数に渡すリスナー
    fun onClick() {
        // 状態を更新する
        uiState.update { ... }
    }
}

リスナーは下記のように渡せます。

// ラムダ式で渡す
Button(onClick = { viewModel.onClick() })
// 関数参照で渡す
Button(onClick = viewModel::onClick)

リソースを取り込む

ブックマークのアイコンは、スターアイコンとは違ってライブラリにはありません。依存を追加すれば同じように取り込めますが、今回はアイコンをアプリに取り込むことにします。

アイコンのデータは プロジェクトルートのassetに置いてあります(ファイル形式は svg です)。 ※ アイコンのデータは https://fonts.google.com/icons を利用しています。

次に Android Studio の Resource Manager を開いてください(赤矢印)。Resource Manager は、アプリ内で使用する画像やアイコンなどを管理する機能です。 +アイコンをクリック(青矢印)して、Import Drawables でアイコンを取り込みましょう(橙矢印)。

image

ファイルを選択すると下記のように取り込んだアイコンが表示されます。

image

赤枠はリソースの名前です。コードから呼び出すときはここに設定した名前でR.drawable.icon_nameのようにアクセスします。

drawable には画像も追加できます。コードでアクセスした時にパッと見で画像用のリソースなのかアイコン用のリソースなのか区別できるように、ic_など prefix をつけておくと良いでしょう。

演習

ブックマークアイコンをタップすると、タップされた状態を反映するようにしてください。データの永続化は考慮しなくて良いです。

2025-04-07.4.57.23.mov
解説

RepoListItemでブックマークアイコンを表示できるようにします。

@Composable
fun RepoListItem(
    repo: Repo,
    modifier: Modifier = Modifier,
) {
    Row(
        modifier = modifier.padding(8.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Column(
            modifier = Modifier.weight(1f),
            verticalArrangement = Arrangement.spacedBy(4.dp),
        ) {
            Text(
                text = repo.name,
                fontWeight = FontWeight.Bold,
            )
            repo.description?.let { Text(text = it) }
            Row {
                Icon(
                    imageVector = Icons.Outlined.Star,
                    tint = Color.LightGray,
                    contentDescription = null,
                )
                Text(text = "${repo.stars}")
            }
        }

        IconButton(onClick = {}) {
            Icon(
                painter = painterResource(R.drawable.bookmark),
                contentDescription = null,
            )
        }
    }
}

ブックマークアイコンがタップされたらアイコンを切り替えてブックマークされている状態がわかるようにしてみます。UI State を変更して、ブックマークされているリポジトリをSetで持たせるようにします。

 data class HomeUiState(
     val repos: List<Repo>,
+    val bookmarkedRepos: Set<Repo>,
 )

次にブックマークアイコンがタップされた時に発火させるコールバックをHomeViewModelに実装します。

fun onBookmarkIconClick(repo: Repo) {
    uiState.update {
        val bookmarkedRepos = if (repo in uiState.value.bookmarkedRepos) {
            it.bookmarkedRepos - repo
        } else {
            it.bookmarkedRepos + repo
        }

        it.copy(bookmarkedRepos = bookmarkedRepos)
    }
}

RepoListItemでコールバックを受け取れるようにします。また、ブックマークされたかどうかの状態も受け取ってアイコンを切り替えるようにします。

 fun RepoListItem(
     repo: Repo,
     isBookmarked: Boolean,
+    onBookmarkIconClick: (Repo) -> Unit,
     modifier: Modifier = Modifier,
 ) {
     Row(
	     ...
             }
         }

-        IconButton(onClick = {}) {
+        IconButton(onClick = { onBookmarkIconClick(repo) }) {
             Icon(
                 painter = painterResource(
                     if (isBookmarked) R.drawable.bookmark_filled else R.drawable.bookmark
   fun RepoListItem(
         IconButton(onClick = { onBookmarkIconClick(repo) }) {
             Icon(
-                painter = painterResource(R.drawable.bookmark),
+                painter = painterResource(
+                    if (isBookmarked) R.drawable.bookmark_filled else R.drawable.bookmark
+                ),
                 contentDescription = null,
             )
         }

HomeScreenでクリックリスナーを受け取れるようにします。また、ブックマーク状態を反映するようにします。

     HomeScreen(
         modifier = modifier,
         uiState = uiState,
+        onBookmarkIconClick = viewModel::onBookmarkIconClick,
     )
 }

 ...

  private fun HomeScreen(
     uiState: HomeUiState,
+    onBookmarkIconClick: (Repo) -> Unit,
     modifier: Modifier = Modifier,
 ) {
     Scaffold(

 ...
              ) { repo ->
                 RepoListItem(
                     repo = repo,
+                    onBookmarkIconClick = onBookmarkIconClick,
+                    isBookmarked = repo in uiState.bookmarkedRepos,
                 )
             }
         }

実装例の差分は以下を参考にしてみてください。 https://github.com/mixigroup/2025BeginnerTrainingAndroid/compare/reference/step-5...reference/step-6

Step 7 : データを端末内に永続化する

今の状態では、アプリを終了した時にブックマークしたリポジトリの情報が失われてしまいます。データを永続化して、アプリを再起動してもブックマークしたリポジトリが消去されないようにしてみましょう。

永続化の方法

データの永続化方法は大きく分けて 2 つありますが、今回はデータ量がそこそこ多いのとリレーションを貼りたいのでデータベース(Jetpack Room)を使います。

保存先 ライブラリ名 使い分け
ファイル Jetpack DataStore 保存するデータが設定などのフラグやデータ量が少ない時に使う
データベース Jetpack Room 保存するデータが大量で部分更新や参照整合性をサポートしたい時に使う

Jetpack Room について

Jetpack Room (以下、Room)とは Google が開発している公式の DB ライブラリです。SQLite を抽象化しています。

依存の追加

Room を利用するには、以下の依存を追加する必要があります。

  • androidx.room:room-compiler
  • androidx.room:room-ktx
  • androidx.room:room-runtime

また、ライブラリ側でアノテーション(@Composableなどがアノテーション)をパースする必要があるので KSP(Kotlin Symbol Processing)というプラグインも追加します。

  • com.google.devtools.ksp

スキーマの定義

Room ではテーブルのスキーマを Kotlin のオブジェクトで表現できます。 デフォルトではクラス名がテーブル名になりますが、@Entityアノテーションを使って変更できます。

@Entity(
    tableName = "items",
)
data class ItemEntity(
    @PrimaryKey val id: Int, // プライマリキーを指定
    val isAdded: Boolean,
)

DAO の定義

DAO(Data Access Object) は以下のように定義できます。

※ DAO : データベースにアクセスするためのオブジェクト

@Dao
interface ItemDao {
    @Insert // Insert文を発行するメソッドにできる
    suspend fun insert(item: ItemEntity)

    @Insert
    suspend fun insertAll(vararg item: ItemEntity)

    @Query("SELECT * FROM repo") // 生のクエリをかける
    suspend fun findAll(): List<RepoEntity>

    @Delete
    suspend fun delete(item: ItemEntity)
}

データベースの作成

DAO インスタンスを生成するデータベースクラスを定義します。

@Database(
    entities = [
        ItemEntity::class, // スキーマオブジェクトを渡す
    ],
    version = 1,
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun itemDao(): ItemDao // DAO を返すメソッドを定義
}

DAO のインスタンス化

実際に DAO インスタンスを生成するときは以下のようにします。

val appDatabase = Room.databaseBuilder(
                        app, // Application(後述)オブジェクトが必要
                        AppDatabase::class.java,
                        "app_database",
                  ).build()

val dao = appDatabase.itemDao()

ファクトリの実装

DAO のインスタンスを注入するために、簡易的な Factory を実装します。

object LocalDataSourceFactory

DAO のインスタンスを作るには、Application オブジェクトが必要です。Applicationオブジェクトはカスタムで定義できるMyApplicationクラスのonCreateで取得することにします。

※ Application オブジェクト : アプリのパッケージ名など全体的な設定が含まれるオブジェクトです ※ MyApplication : Application は一番最初にインスタンスが作られます。これを継承して自作の Application クラスを作成できます。初期化処理などが実装される場合が多いです。

object LocalDataSourceFactory {
    private lateinit var appDatabase: AppDatabase

    fun initialize(app: Application) {
        appDatabase =  Room.databaseBuilder(
            app,
            AppDatabase::class.java,
            "app_database",
        ).build()
    }
}
class MyApplication: Application() {
    override fun onCreate() {
        super.onCreate()

        LocalDataSourceFactory.initialize(this)
    }
}

MyAppllicationAndroidManifest.xml に登録する必要があります。

<application
    android:name=".MyApplication"
    ... >

演習

ブックマークしたリポジトリを永続化できるようにしましょう。

テーブルは以下のような ER 図を満たすようにしてください。

erDiagram
repo {
  int id PK
  string name
  string description
  int stars
}

bookmark_repo {
  int repo_id PK, FK
}

repo ||--|| bookmark_repo : ""
Loading
解説

依存を追加します。

[versions]
room = "2.6.1"
ksp = "2.0.21-1.0.27"

[libraries]
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }

[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
// build.gradle.kts
plugins {
    alias(libs.plugins.ksp) apply false
}

// app/build.gradle.kts
plugins {
    alias(libs.plugins.ksp)
}

dependencies {
    implementation(libs.androidx.room.runtime)
    ksp(libs.androidx.room.compiler)
    implementation(libs.androidx.room.ktx)
}

Room ではスキーマを Kotlin のオブジェクトで表現できます。

@Entity(
    tableName = "repo",
)
data class RepoEntity(
    @PrimaryKey val id: Int,
    val name: String,
    val description: String? = null,
    val stars: Int,
)
@Entity(
    tableName = "bookmark_repo",
    primaryKeys = ["repo_id"],
    foreignKeys = [
        ForeignKey(
            entity = RepoEntity::class,
            parentColumns = ["id"],
            childColumns = ["repo_id"],
            onDelete = ForeignKey.CASCADE,
        )
    ]
)
data class BookmarkRepoEntity(
    @ColumnInfo("repo_id") val repoId: Int,
)

データベースを定義します。さきほど定義したスキーマオブジェクトを渡します。

@Database(
    entities = [
        RepoEntity::class,
        BookmarkRepoEntity::class,
    ],
    version = 1,
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun repoDao(): RepoDao
}

DAO を実装します。

@Dao
interface RepoDao {
    @Query("SELECT * FROM repo")
    suspend fun findAll(): List<RepoEntity>

    @Insert
    suspend fun insertAll(vararg repos: RepoEntity)

    @Insert
    suspend fun insertBookmark(repo: BookmarkRepoEntity)

    @Delete
    suspend fun deleteBookmark(repo: BookmarkRepoEntity)

    @Query("""
        SELECT *
        FROM repo
        WHERE id IN bookmark_repo
    """)
    suspend fun findAllBookmark(): List<RepoEntity>
}

DB 用のリポジトリを表すオブジェクト(RepoEntity, BookmarkRepoEntity)とRepoを変換する処理を拡張関数で実装します。

fun RepoEntity.toModel() = Repo(
    id = id,
    name = name,
    description = description,
    stars = stars,
)

fun Repo.toEntity() = RepoEntity(
    id = id,
    name = name,
    description = description,
    stars = stars,
)

fun Repo.toBookmarkEntity() = BookmarkRepoEntity(
    repoId = id,
)

LocalDataSourceを作成します。

class GithubRepoLocalDataSource(
    private val dao: RepoDao,
) {
    suspend fun getRepoList(): List<Repo> = dao.findAll().map { it.toModel() }

    suspend fun saveRepoList(repoList: List<Repo>) {
        dao.insertAll(*repoList.map { it.toEntity() }.toTypedArray())
    }

    suspend fun saveAsBookmark(repo: Repo) {
        dao.insertBookmark(repo.toBookmarkEntity())
    }

    suspend fun saveAsUnBookmark(repo: Repo) {
        dao.deleteBookmark(repo.toBookmarkEntity())
    }

    suspend fun getBookmarkRepoListFlow(): List<Repo> {
        return dao.findAllBookmark().map { it.toModel() }
    }
}

Repository を修正します。

class RepoRepository(
    private val localDataSource: RepoLocalDataSource,
    private val remoteDataSource: RepoRemoteDataSource,
) {
    suspend fun getRepoList(): List<Repo> {
        return localDataSource.getRepoList().ifEmpty {
            val repoList = remoteDataSource.getRepoList()
            localDataSource.saveRepoList(repoList)
            repoList
        }
    }

    suspend fun saveAsBookmark(repo: Repo) {
        localDataSource.saveAsBookmark(repo)
    }

    suspend fun saveAsUnBookmark(repo: Repo) {
        localDataSource.saveAsUnBookmark(repo)
    }

    suspend fun getBookmarkedRepoList(): List<Repo> {
        return localDataSource.getBookmarkRepoList()
    }
}

HomeViewModelのファクトリでRepoLocalDataSourceのインスタンスを渡すようにします。

   override fun <T : ViewModel> create(modelClass: Class<T>): T =
       HomeViewModel(
           repository = RepoRepository(
+              localDataSource = LocalDataSourceFactory.createRepoLocalDataSource(),
               remoteDataSource = RepoRemoteDataSource(),
           ),
       ) as T

HomeViewModel を修正してブックマークしたリポジトリを永続化します。

fun onLaunched() {
    viewModelScope.launch {
        uiState.update {
            it.copy(
                repos = repository.getRepoList(),
                bookmarkedRepos = repository.getBookmarkedRepoList().toSet(),
            )
        }
    }
}

fun onBookmarkIconClick(repo: Repo) {
    viewModelScope.launch {
        uiState.update {
            if (repo in uiState.value.bookmarkedRepos) {
                repository.saveAsUnBookmark(repo)
            } else {
                repository.saveAsBookmark(repo)
            }

            it.copy(bookmarkedRepos = repository.getBookmarkedRepoList().toSet())
        }
    }
}

実装例の差分は以下を参考にしてみてください。 https://github.com/mixigroup/2025BeginnerTrainingAndroid/compare/reference/step-6...reference/step-7

Step 8: ブックマーク画面への遷移処理を実装する

Compose では Navigation Compose を使って画面遷移を実装できます。ひとまず、空の Composable 関数をBookmarkScreenとして追加しておきます。

まずは依存を追加します。

[versions]
navigationCompose = "2.8.9"

[libraries]
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
// app/build.gradle.kts
dependencies {
    implementation(libs.androidx.navigation.compose)
}

次に画面下部にボトムバー表示して遷移できるようにします。その責務はTrainingAppとして別の Composable 関数が担うようにします。

Navigation Compose では遷移先を型で表現できます。

@Serializable
object Home // ホーム画面

@Serializable
object Bookmark // ブックマーク画面

次に遷移グラフを定義します。ここで、上記で定義した型と遷移先の Composable 関数の対応関係を定義するイメージです。

@Composable
fun BeginnerTrainingApp(
    modifier: Modifier = Modifier,
) {
    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = Home) {
        // ホーム画面
        composable<Home> {
            HomeScreen()
        }

        // ブックマーク画面
        composable<Bookmark> {
            BookmarkScreen()
        }
    }
}

rememberNavControllerNavHostControllerを記憶し、返却する Composable 関数です。NavHostControllerについては詳しく解説しません。navigateメソッドを呼べば、NavHostで定義した Composable 関数へ遷移できるものという理解で大丈夫です。

// HomeScreenを開く
navController.navigate(Home)

NavigationBarItemNavigationBarで囲めば良い感じの見た目で遷移バーを実装できます。下記のコードはホーム画面に遷移するアイコンを一つだけ表示します。

NavigationBar(modifier = modifier) {
      NavigationBarItem(
          selected = false,
          onClick = {
              navController.navigate(Home)
          },
          icon = {
              Icon(
                  imageVector = Icons.Outlined.Home,
                  contentDescription = null,
              )
          },
      )
}

あとは同様にブックマーク画面に遷移するアイコンを表示します。

data class TopLevelRoute<T : Any>(val route: T, val icon: ImageVector)

val topLevelRoutes = listOf(
    TopLevelRoute(Home, Icons.Outlined.Home),
    TopLevelRoute(Bookmark, Icons.Outlined.FavoriteBorder)
)
NavigationBar(modifier = modifier) {
    topLevelRoutes.forEach { route ->
        NavigationBarItem(
            selected = false,
            onClick = {
                navController.navigate(route.route)
            },
            icon = {
                Icon(
                    imageVector = route.icon,
                    contentDescription = null,
                )
            },
        )
    }
}

現在表示している画面のアイコンは選択状態にしたいです。NavControllerからルートを取得して、型が一致していたらselectedtrueにします。

+val navBackStackEntry by navController.currentBackStackEntryAsState()
+val currentDestination = navBackStackEntry?.destination
+
 NavigationBar(modifier = modifier) {
     topLevelRoutes.forEach { route ->
         NavigationBarItem(
-            selected = false,
+            selected = currentDestination?.hierarchy?.any { it.hasRoute(route.route::class) } == true,
             onClick = {
                 navController.navigate(route.route)
             },

ホームとブックマーク画面を行き来してもスワイプしたらアプリを閉じてアプリ一覧画面に戻すには、バックスタックに積まないようにします。

         NavigationBarItem(
             selected = currentDestination?.hierarchy?.any { it.hasRoute(route.route::class) } == true,
             onClick = {
-                navController.navigate(route.route)
+                navController.navigate(route.route) {
+                    popUpTo(navController.graph.findStartDestination().id) {
+                        saveState = true
+                    }
+                    launchSingleTop = true
+                    restoreState = true
+                }
             },
             icon = {
                 Icon(

アプリ下部に遷移バーを表示したければ、前述したScaffoldを使います。bottomBarに Composable 関数を渡せばそれを下側に表示できます。

@Composable
fun BeginnerTrainingApp(
    modifier: Modifier = Modifier,
) {
    val navController = rememberNavController()

    Scaffold(
        modifier = modifier,
        bottomBar = {
            BeginnerTrainingNavigationBar(navController)
        },
    ) { innerPadding ->
        NavHost(navController = navController, startDestination = Home) {
            composable<Home> {
                HomeScreen()
            }
            composable<Bookmark> {
                BookmarkScreen()
            }
        }
    }
}

演習

  • Step 8 の画面遷移処理を実装しましょう
  • ブックマーク画面を実装してみましょう。ブックマーク画面ではブックマークされたリポジトリのみを一覧表示してください。

Step 9 : 単体テストを書いてみる

Android アプリにおけるテスト

テストの種類はざっくり分けて UI テストと単体テストがあります。

UI テスト

  • ユーザーに見える部分に注目した統合的なテスト
  • テスト実行環境は実機 or エミュレータを使う
  • 実際の環境に近い状態でテストできる(デバイステスト)が、安定性と実行速度に難がある

単体テスト

  • テスト対象が一つのメソッドの小さなテスト
  • テスト実行環境は Java 仮想マシン上
  • UI テストと比較して高速にテストできる(ローカルテスト)が、プログラムの内部しか検証できない(= テストが通ってたとしても実際のアプリが正しい挙動になるかというとそうではない)

テストファイルの置き場所

テストファイルは下記のどちらかにおきます。

UI テストのような実デバイス上で実行したいテストは、赤枠の場所におきます。単体テストのようなローカルで実行したいテストは青枠の場所におきます。

image

テストファイルの作り方

すでにあるファイルと同じ階層に置けば良いです。

もしくは、テスト対象のクラスで Opt + Enter から Create test を選択すればテストファイルを作成してくれます。

image

Member のところでチェックしたメソッドはファイル作成と同時にテストメソッドを作成してくれます。

image

実デバイスで実行したい場合は、androidTest を選択してください。ローカルで実行したい場合はそのままで ok です。(以下の説明はすべてローカルテストです。ただデバイステストでも大体一緒です。)

image

OK を押すとこんな感じでテストファイルが作成されます。

class HomeViewModelTest {
    @Test
    fun onBookmarkIconClick() {
    }
}

テストの実行

テストクラスの左側に表示されている ▶️ から実行できます。

class の横にある ▶️ をクリックすると、そのクラスのすべてのテストを実行できます。 テストメソッドの横にある ▶️ をクリックすると、そのテストだけを実行できます。

image

image

テストが通ると下記のように詳細が表示されます。

image

テストが失敗するとこんな感じになります。

image

フェイクオブジェクトを使う

例として、HomeViewModel の単体テストを書くことを考えてみましょう。現状の実装だと、テスト実行時に実際に API を叩いてリポジトリを取得してしまいます。このままでは、アクセス先のサーバーが落ちた場合や通信環境が悪い場合にテストが失敗してしまいます。

外部への依存をなくし、テストの信頼性を向上させるためのテクニックとしてテストダブルという考え方があります。今回はその 1 つであるフェイクを使います。

具体的には、Repository を interface にしてテストでは fake の Repository を使うようにします。

interface GithubRepoRepository {
    suspend fun getRepoList(): List<Repo>

    suspend fun saveAsBookmark(repo: Repo)

    suspend fun saveAsUnBookmark(repo: Repo)

    suspend fun getBookmarkedRepoList(): List<Repo>
}

実際のコードで使う用の Repository とテスト用に使う Repository を用意します。

-class RepoRepository(
+class DefaultRepoRepository(
     private val localDataSource: RepoLocalDataSource = LocalDataSourceFactory.createRepoLocalDataSource(),
     private val remoteDataSource: RepoRemoteDataSource = RepoRemoteDataSource(),
-) {
-    suspend fun getRepoList(): List<Repo> {
+) : RepoRepository {
+    override suspend fun getRepoList(): List<Repo> {
         return localDataSource.getRepoList().ifEmpty {
             val repoList = remoteDataSource.fetchRepoList()
             localDataSource.saveRepoList(repoList)
...
         }
     }

-    suspend fun saveAsBookmark(repo: Repo) {
+    override suspend fun saveAsBookmark(repo: Repo) {
         localDataSource.saveAsBookmark(repo)
     }

-    suspend fun saveAsUnBookmark(repo: Repo) {
+    override suspend fun saveAsUnBookmark(repo: Repo) {
         localDataSource.saveAsUnBookmark(repo)
     }

-    suspend fun getBookmarkedRepoList() = localDataSource.getBookmarkRepoList()
+    override suspend fun getBookmarkedRepoList() = localDataSource.getBookmarkRepoList()
 }

テストで使う用の Repository です。初期値としてリポジトリを設定できるようにしているのと、実際の挙動に近づけるために delay を入れています。

class FakeRepoRepository(
    private val repos: List<Repo>,
    bookmarkedRepos: List<Repo>,
) : GithubRepoRepository {
    private val _bookmarkedRepos: MutableList<Repo> = bookmarkedRepos.toMutableList()

    override suspend fun getRepoList(): List<Repo> {
        delay(100)
        return repos
    }

    override suspend fun saveAsBookmark(repo: Repo) {
        delay(100)
        _bookmarkedRepos.add(repo)
    }

    override suspend fun saveAsUnBookmark(repo: Repo) {
        delay(100)
        _bookmarkedRepos.remove(repo)
    }

    override suspend fun getBookmarkedRepoList(): List<Repo> {
        delay(100)
        return _bookmarkedRepos
    }
}

ViewModel では Repository の interface を受け取るようにして、テストコードでフェイクに差し替えられるようにしましょう。

演習

HomeViewModel の単体テストを書いてみましょう。

解説

テストファイルを作成しましょう。

class HomeViewModelTest {}

onLaunchedメソッドのテストを書いてみます。

@Test
fun onLaunchedTest() {
    val repos = listOf(
        Repo(
            id = 1,
            name = "fake repo1",
            stars = 12,
        ),
        Repo(
            id = 2,
            description = "this is fake repository",
            name = "fake repo2",
            stars = 3,
        ),
    )

    val viewModel = HomeViewModel(
        repository = FakeGithubRepoRepository(
            repos = repos,
            bookmarkedRepos = emptyList(),
        ),
    )

    viewModel.onLaunched()

    assertEquals(
        HomeUiState(
            repos = repos,
            bookmarkedRepos = emptySet(),
        ),
        viewModel.uiState.value,
    )
}

この時点でテストを実行しても実はエラーになります。エラー文を読むと Dispatchers.Main が見つからないようです。どこで Dispatchers.Main が使われているかというと、viewModelScope の中で使われています。

Exception in thread "Test worker" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

ではどうすれば良いかというと、テスト用に Dispatchers.Main を書き換えれば ok です。コルーチンのテストライブラリが必要なので、依存を追加します。

[versions]
kotlinxCoroutinesTest = "1.10.2"

[libraries]
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
testImplementation(libs.kotlinx.coroutines.test)

Dispatchers.Mainを書き換えます。@Beforeをつけたメソッドはテスト毎に最初に呼ばれます。初期化処理などを書くのに便利です。@Afterはテスト毎に終了時に呼ばれます。リソースの解放などの処理を書くのに便利です。

class HomeViewModelTest {
    @Before
    fun setUp() {
        Dispatchers.setMain(StandardTestDispatcher())
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

...

とりあえずテストは実行できるようになりました。しかし、意図通りの値が入っておらずテストに失敗しています。原因はフェイクの Repository でdelayで待っているからです。delayが終了するのを待たずにテストが実行されてassertで失敗しています。

Expected :HomeUiState(repos=[Repo(id=1, name=fake repo1, description=null, stars=12), Repo(id=2, name=fake repo2, description=this is fake repository, stars=3)], bookmarkedRepos=[]) Actual :HomeUiState(repos=[], bookmarkedRepos=[])

どうすればdelayの完了を待てるのでしょうか?実はdelayを良い感じにスキップしてくれるテスト用の API があります。runTestです。

 @Test
-fun onLaunchedTest() {
+fun onLaunchedTest() = runTest {
    val repos = listOf(
        Repo(
            id = 1,

あとはassertの前に時間を進める API を呼んであげれば良いです。

     )

     viewModel.onLaunched()
+    advanceUntilIdle()

     assertEquals(
         HomeUiState(
             repos = repos,
             bookmarkedRepos = emptySet(),
         ),
         viewModel.uiState.value,
     )
 }

これでテストが通るようになったと思います。

実装例の差分は以下を参考にしてみてください。 https://github.com/mixigroup/2025BeginnerTrainingAndroid/compare/reference/step-8...reference/step-9

Step 10 : iOS アプリとコードを共有する

Kotlin Multiplatform

Kotlin Multiplatform(以下、KMP)という技術を使えば、iOS など他のプラットフォームとコードを共有することができます。

共有できる部分は UI 以外のロジックの部分です。後述の Compose Multiplatform を使えば、UI 部分も共有することができます。

KMP は Kotlin を開発している Jetbrains が開発しています。Google も KMP を公式にサポートすることを宣言していて、Google 公式のライブラリの KMP 対応などが進んでいます。

Android と iOS はすでに安定版がリリースされていて、KMP を導入しているアプリが増えてきている印象があります。

https://www.jetbrains.com/help/kotlin-multiplatform-dev/supported-platforms.html#current-platform-stability-levels-for-the-core-kotlin-multiplatform-technology

Compose Multiplatform

Compose Multiplatform(CMP)を使えば、UI のコードも共有することができます。

Android は安定版がリリースされていますが、iOS はまだベータ版です。

https://www.jetbrains.com/help/kotlin-multiplatform-dev/supported-platforms.html#current-platform-stability-levels-for-compose-multiplatform-ui-framework

環境構築

以下のツールをインストールしてください。詳細は https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-setup.html を参考にしてください。

  • Xcode
  • Kotlin Multiplatform plugin

演習

https://github.com/mixigroup/2025BeginnerTrainingAndroid/tree/reference/step-10 のブランチに切り替えて、iOS でもアプリを動かしてみましょう。

時間があれば、差分を眺めて普通の Android アプリとはどう違うかを調べてみましょう!

※ ブランチを切り替えたら Sync してください(赤矢印)

※ Android アプリをビルド&インストールするに は composeApp、iOS は iosApp を選択してください

スクリーンショット 2025-04-15 14 27 00

補足

Google と Jetbrains から公式に公開されているリソースを紹介します。 この研修では触れられなかった内容も多くあるので、興味があればぜひ見てみてください。

公式サンプルアプリ

公式ブログ

公式チュートリアル

https://developer.android.com/get-started/codelabs

About

2025年Android新卒研修で使用するリポジトリ

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages