本文へ移動

ホーム / App / 第2部 プラットフォームの基礎

第2部 プラットフォームの基礎・第3章

Android(Kotlin・Jetpack Compose)

★★★ 必須11トピック・面接 5問

Android アプリの土台になる仕組みを押さえる。

ひとことで

画面回転などで Activity が作り直される。状態は ViewModel に置いて引き継ぐ。

Android の画面(Activity)は、表示や裏に回るたびに決まった順番で関数が呼ばれます。これがライフサイクルです。

呼ばれる順番と、プロセスごと消える経路起動作成済み見える操作できるonCreateonStartonResume停止中見えないonPause → onStoponRestart破棄onDestroy構成変更なら作り直しプロセスごと終了メモリ不足回転・ダークモード・言語の変更でも、破棄から作り直す
Activity のライフサイクル(構成変更でも破棄と作り直しを通る)
関数状態やること
onCreate作られた画面の準備。ViewModel の取得
onStart/onStop見える/見えなくなった位置情報やセンサーなどの開始・停止
onResume/onPause前面で操作できる/前面でなくなったカメラなど前面でだけ使うものの開始・停止
onDestroy壊される後片付け。呼ばれない場合もあるので保存には使わない

構成変更

画面の回転、ダークモードの切り替え、言語の変更、折りたたみ端末を開くなどの構成変更が起きると、Activity は一度壊されて作り直されます。画面の中の変数はここで消えます。これを防ぐのが ViewModel で、構成変更の間も生き残るので、読み込んだデータや入力中の状態はここに置きます。

面接ではこう答える

Android の Activity は、onCreate、onStart、onResume で前面に出て、onPause、onStop で裏に回り、決まった順番で関数が呼ばれます。画面の回転やダークモードの切り替えなどの構成変更が起きると、Activity は一度壊されて作り直されるので、画面の中の変数は消えます。これに備えて、読み込んだデータや入力中の状態は構成変更をまたいで残る ViewModel に置きます。onDestroy は呼ばれないこともあるので、保存には使いません。

ひとことで

裏でプロセスが終了されると ViewModel も消えるので、小さな値は SavedStateHandle・rememberSaveable、大きなデータは DB に置いて復元する。

構成変更より見落とされやすいのが、裏に回っている間にプロセスごと終了される場合です。利用者から見ると「戻ったら入力が消えていた」になります。

裏で終了されても、戻ると最後の画面から作り直される裏に回るOS に値を預けるプロセスが終了メモリ不足戻ってくる履歴から開く画面を作り直すViewModel も新しい状態を復元ID で DB を読むadb shell am kill <パッケージ名> で、わざと起こして確かめられる
ViewModel は消えるので、小さな値は保存済みの状態から、大きなデータは DB から戻す

裏に回ったアプリは、メモリが足りなくなるとプロセスごと終了されます。利用者が戻ってくると OS は最後の画面から作り直しますが、ViewModel もここで消えます。これに備えるのが保存済みの状態(SavedStateHandle、Compose なら rememberSaveable)で、OS が Bundle として預かって復元してくれます。

仕組み構成変更プロセスの終了
画面の変数・remember消える消える
ViewModel残る消える
SavedStateHandle・rememberSaveable残る残る(小さなデータだけ)
DB・DataStore残る残る

Bundle に入れるのは、検索語・選んだタブ・表示中の ID のような小さな値だけにします。大きな一覧や画像を入れると TransactionTooLargeException で落ちます。大きなデータは DB に保存し、ID だけを保存済みの状態に入れて、復元時に読み直します。

プロセスの終了は普段の操作では起きにくいので、わざと起こして確かめます。アプリをホームで裏に回してから adb shell am kill <パッケージ名> を流し、アプリに戻ると再現できます。

面接ではこう答える

Android では裏に回ったアプリが、メモリが足りなくなるとプロセスごと終了されます。利用者が戻ると OS は最後の画面から作り直しますが、ViewModel もこのとき消えるので、構成変更の対策だけでは状態が戻りません。検索語や表示中の ID のような小さな値は SavedStateHandle や rememberSaveable で OS に預け、大きなデータは DB に保存して ID から読み直します。確かめるときは、裏に回してから adb で強制的に終了させて戻ります。

3.3Jetpack Compose

用語★★★ 必須
ひとことで

状態から UI を宣言的に作る仕組み。再コンポーズの範囲を意識して書く。

Jetpack Compose は Android の宣言的な UI の仕組みです。@Composable の関数が「状態から画面を作る」役で、状態が変わると関係する関数だけがもう一度呼ばれます。これを再コンポーズと呼びます。

状態の持ち上げ(state hoisting)

部品の中に状態を持たせず、呼び出し側に「値」と「変わったときの処理」を渡してもらう形を状態の持ち上げと言います。部品は表示に専念できるので、使い回しやテスト、プレビューがしやすくなります。

@Composable
fun SearchBox(query: String, onQueryChange: (String) -> Unit) {
  TextField(value = query, onValueChange = onQueryChange)
}
// 呼び出し側(画面)が状態を持つ。画面の状態は ViewModel の StateFlow から
val state by viewModel.uiState.collectAsStateWithLifecycle()
状態は下へ、操作(イベント)は上へViewModelUiState を持つ画面(SearchScreen)SearchBoxResultListuiStateonSearch()queryonQueryChangeitems部品は状態を持たず、値と「変わったときの処理」を受け取るだけ。使い回し・テスト・プレビューがしやすい
状態は上から下へ、操作は下から上へ流れる(状態の持ち上げ)

目安は「状態は、それを読む部品すべての一番近い共通の親に置く」です。画面全体の状態は ViewModel に置き、開閉やスクロール位置のような見た目だけの状態は部品の近くの remember に置きます。

面接ではこう答える

Jetpack Compose は、Composable 関数で状態から画面を作る Android の宣言的な UI の仕組みで、状態が変わると関係する関数だけが呼び直されます。部品には状態を持たせず、呼び出し側から値と変わったときの処理を渡す、状態の持ち上げが基本の形です。状態はそれを読む部品の一番近い共通の親に置き、画面全体の状態は ViewModel の StateFlow から受け取ります。こうすると部品の使い回しとテスト、プレビューがしやすくなります。

ひとことで

Compose は状態を読んだ関数だけを呼び直す。読む場所・key・安定した型で範囲を狭くし、副作用は決まった場所で行う。

Compose が遅くなる原因の多くは、必要のない再コンポーズです。状態が変わると、その状態を読んだ関数が呼び直されるので、読む場所と渡す型を工夫して範囲を狭くします。

状態を「読んだ」関数だけが呼び直されるScreen(読まない)Header(読まない)Counter(count を読む)List(items を読む)count が変わる→ Counter だけが呼び直される
状態を読んだ関数だけが呼び直される。読む場所を下げるほど範囲が狭くなる
注意点対策
一覧で項目が全部作り直されるLazyColumn の items に key = { it.id } を付ける
重い計算が再コンポーズのたびに走るremember(入力) { 計算 } で結果を覚える
スクロール位置などの頻繁に変わる値で全体が描き直されるderivedStateOf で必要な変化だけ拾う。Modifier.offset { } のようなラムダ版で読むのを遅らせる
安定していない型(中身が変わりうる List など)を渡して、変化がなくても呼び直される不変のデータクラスにする。Compose コンパイラーの報告で安定性を確かめる
デバッグ版で計ると遅い性能はリリース版(R8 有効)で計る。Baseline Profile で初回の動きを速くする

落とし穴は、@Composable の中で直接通信や DB への書き込みを始めることです。何度呼ばれても困らないように、副作用は LaunchedEffect などの決まった場所か ViewModel で行います。Layout Inspector で再コンポーズの回数を見られるので、遅いと感じたらまず回数が多すぎる部品を探します。

副作用の置き場所使いどころ
LaunchedEffect(key)画面に出たとき・key が変わったときに suspend の処理を始める。画面から消えるとキャンセル
DisposableEffect(key)登録と解除が対になるもの(リスナー・センサー)
rememberCoroutineScope()ボタンを押したときなど、操作から処理を始める
ViewModel通信・保存など、画面の寿命より長く続けたい処理

面接ではこう答える

Compose は、状態が変わるとその状態を読んだ Composable だけを呼び直します。これが再コンポーズで、遅いときは呼び直しが多すぎることがほとんどです。一覧には key を付け、重い計算は remember で覚え、頻繁に変わる値は derivedStateOf やラムダ版の Modifier で読むのを遅らせ、渡すデータは不変のクラスにします。Composable は何度呼ばれてもよいように書き、通信などの副作用は LaunchedEffect や ViewModel で行います。

Compose のアプリは、Activity を1つにして、その中で画面を切り替える形(シングルアクティビティ)が一般的です。画面の切り替えには Navigation Compose を使い、NavHost にルート(どの画面か)と画面の対応を書きます。Navigation 2.8 からは、ルートを @Serializable のクラスで型安全に書けます。

ルートに渡すのは ID だけ一覧Route: Home詳細Route: Item(id = 12)編集Route: Edit(id = 12)navigatenavigate戻る(pop)詳細の ViewModel は SavedStateHandle から id を受け取り、Repository からデータを読む
画面には ID などの小さな値だけを渡し、データは移った先の ViewModel が読み直す
@Serializable data class Item(val id: Long)
NavHost(navController, startDestination = Home) {
  composable<Home> { HomeScreen(onOpen = { navController.navigate(Item(it)) }) }
  composable<Item> { ItemScreen() }  // ViewModel は SavedStateHandle から id を受け取る
}
気をつけること理由
ルートには ID だけを渡す大きなオブジェクトを渡すと、状態の保存の大きさの制限やプロセスの終了で壊れる
画面は navController を直接持たない「開く」というイベントを呼び出し側に渡すと、テストとプレビューがしやすい
戻るの動きを確かめるログイン後に戻るでログイン画面に戻らないよう、popUpTo で履歴を消す
リンクからの起動deepLinks を書くと URL から途中の画面を開ける

より新しい Navigation 3 というライブラリも出ていて、バックスタックを自分の状態として持つ形になっています。新しく作るときは公式の案内を確かめて選びます。

3.6Kotlin Coroutines と Flow

用語★★☆ 重要
ひとことで

非同期処理を順番に書け、スコープで寿命を管理できる。データの流れは Flow で表す。

Coroutines(コルーチン)は、Kotlin で「待つ処理」を普通の順番のコードで書ける仕組みです。suspend の付いた関数は、待っている間スレッドを手放すので、画面が固まりません。Flow は値が次々に流れてくるデータの流れで、DB の変更の通知や検索語の入力などに使います。

スコープとキャンセル

コルーチンは必ずスコープの中で動き、スコープが終わると中の処理はまとめてキャンセルされます(構造化された並行性)。

スコープいつ終わるか使いどころ
viewModelScopeViewModel が破棄されたとき画面のデータの読み込み。基本はこれ
lifecycleScopeActivity・Fragment が破棄されたとき画面に結びつく一時的な処理
GlobalScope終わらない使わない。漏れやすく、テストもしにくい
WorkManagerOS が管理画面を閉じても終わらせたい送信・同期
親のスコープが終わると、子の処理もまとめて止まるviewModelScopelaunch:一覧を読むDispatchers.IO で DBlaunch:検索入力のたびに前回を止めるasync:画像の加工Dispatchers.DefaultViewModel が破棄される → スコープがキャンセル → 3つの子もすべて止まる(漏れない)
スコープが終わると、その中で始めた処理はまとめてキャンセルされる

キャンセルは「お願い」で、止まるのは suspend 関数の呼び出しのところです。重いループの中では ensureActive() を呼びます。また catch (e: Exception) で CancellationException まで握りつぶすと、キャンセルが効かなくなるので、捕まえたら投げ直します。

面接ではこう答える

Kotlin の Coroutines は、待つ処理を普通の順番のコードで書ける仕組みで、suspend 関数は待っている間スレッドを手放すので画面が固まりません。コルーチンは必ずスコープの中で動き、スコープが終わると中の処理はまとめてキャンセルされます。画面のデータの読み込みは viewModelScope で行い、GlobalScope は使いません。キャンセルは suspend 関数の呼び出しのところで効くので、CancellationException を握りつぶさないように注意します。

ひとことで

Main は画面、IO は待ち時間の長い処理、Default は計算。切り替えは呼ばれる側の withContext で行う。

コルーチンがどのスレッドで動くかは Dispatcher で決まります。画面の更新はメインスレッドでしかできず、メインスレッドで重い処理をすると画面が固まるので、処理の種類ごとに使い分けます。

処理の種類でスレッドを選ぶMain画面の更新IOファイル・DB・通信Default解析・画像の加工ViewModel.load()repo.read() の中withContext(Dispatchers.IO) で切り替えて、終わると Main に戻る
呼ばれる側が withContext で切り替えると、呼ぶ側はスレッドを気にしなくて済む
Dispatcher用途
Dispatchers.Main画面の更新。viewModelScope の既定
Dispatchers.IOファイル・DB・通信など待ち時間の長い処理
Dispatchers.DefaultJSON の解析・画像の加工など CPU を使う計算

切り替えは withContext(Dispatchers.IO) { } で、呼ばれる側の関数の中で行うのがよい形です(呼ぶ側がスレッドを気にしなくて済む)。Room や Retrofit の suspend 関数は自分で切り替えてくれるので、重ねて包む必要はありません。画面で Flow を集めるときは collectAsStateWithLifecycle や repeatOnLifecycle を使い、裏に回ったら集めるのを止めます。

面接ではこう答える

Kotlin のコルーチンでは、Dispatcher で処理を動かすスレッドを選びます。画面の更新は Dispatchers.Main、ファイルや DB や通信のような待ち時間の長い処理は Dispatchers.IO、JSON の解析や画像の加工のような計算は Dispatchers.Default を使います。切り替えは呼ばれる側の関数の中で withContext を使って行い、呼ぶ側がスレッドを気にしなくてよい形にします。Room や Retrofit の suspend 関数は自分で切り替えるので、重ねて包む必要はありません。

3.8Flow・StateFlow・SharedFlow

用語★★★ 必須
ひとことで

Flow は集めると動く cold な流れ、StateFlow は今の値を持つ hot な流れで画面の状態向け、SharedFlow は値を配る hot な流れ。

Flow は値が次々に流れてくるデータの流れです。Room は表が変わるたびに新しい結果を Flow で流せるので、画面は DB を見ているだけで常に最新になります。Flow には、集めたときに動き出すもの(cold)と、集める人がいなくても値を持ち続けるもの(hot)があります。

種類性質使いどころ
Flowcold。集めるたびに最初から動くDB の問い合わせ、通信を順に行う処理
StateFlowhot。必ず今の値を1つ持ち、同じ値は流さない画面の状態(UiState)
SharedFlowhot。値を持たず、そのとき集めている人に配る(設定で過去の値も配れる)複数の購読者に配る通知
Channel1回だけ受け取らせる一度だけの画面遷移・メッセージ(ただし状態にする方法も推奨されている)
val uiState: StateFlow<UiState> = repo.notes()
  .map { UiState.Success(it) }
  .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Loading)

画面で集めるときは collectAsStateWithLifecycle を使い、裏に回ったら集めるのを止めます。普通の collectAsState では、裏に回っても上流の処理が動き続けます。

DB の変更が、画面まで自動で流れるRoom の DAOFlow<List<Note>>Repositorymap で整えるViewModelstateIn → StateFlow画面見える間だけ集めるWhileSubscribed(5_000):最後の購読者がいなくなって5秒たつと上流を止める(回転では止めない)
DB の変更が Flow で流れ、ViewModel が StateFlow にして、画面は見えている間だけ集める

面接ではこう答える

Flow は集めたときに動き出す cold な流れで、DB の問い合わせなどに使います。StateFlow は必ず今の値を1つ持つ hot な流れで、画面の状態を表すのに向いていて、ViewModel では stateIn で Flow を StateFlow に変えます。SharedFlow は値を持たずに、そのとき集めている人に配る流れです。画面では collectAsStateWithLifecycle で集めて、裏に回ったら止め、stateIn には WhileSubscribed(5000) を指定して画面の回転では上流を止めないようにします。

ひとことで

メーカー・画面サイズ・OS バージョンが多様。メーカー独自の電池の最適化で通知や処理が止まることも。

Android は多くのメーカーが端末を出しているので、OS の版・画面の大きさ・メモリ・メーカー独自の設定がばらばらです。全部を確かめることはできないので、「どこまで対応するか」と「どれで確かめるか」を決めて運用します。

3つの SDK の版の役割minSdkこれより古い端末には入らないtargetSdkこの版の挙動に合わせる(毎年上げる)compileSdk使える API(最新に)古い ← Android の版 → 新しい
minSdk は動く下限、targetSdk は合わせる挙動、compileSdk は使える API

対応バージョンの決め方

  • minSdk(動く最低の版):自分のアプリの利用者の OS の分布を見て決めます。たとえば「利用者の 1〜2% 未満になった版は切る」のように基準を決めておき、切る前にはお知らせを出します。古い版を切ると、使える API が増えて分岐のコードが減ります。
  • targetSdk(どの版の挙動に合わせるか):Google Play には、新しいアプリや更新は最新に近い版を target にしなければならない決まりがあり、毎年引き上げられます。target を上げると権限や裏の処理の挙動が変わるので、毎年の作業として予定に入れておきます。
  • compileSdk:新しい API を使うために、基本は最新にします。

iOS は新しい OS への更新が速いので、最新とその1〜2つ前の版までに対応するのが一般的です。

面接ではこう答える

Android は多くのメーカーが端末を出しているので、OS の版、画面の大きさ、メモリ、メーカー独自の電池の節約機能がばらばらです。対応する範囲は、動く下限の minSdk を利用者の分布で決め、挙動を合わせる targetSdk は Google Play の決まりで毎年引き上げ、compileSdk は最新にします。全部の端末は確かめられないので、利用者の多い機種と性能の低い端末、OS の版の両端を選び、リリース後は特定の機種だけのクラッシュを見て確かめる端末を足していきます。

Android の端末はすべてを確かめられないので、利用者の多い機種と、問題が出やすい両端を選んで確かめます。

選ぶ観点例
利用者の多い機種分析ツールで上位の機種。日本ならよく売れているメーカーの主力機
性能の低い端末メモリが少ない安価な機種。起動時間や一覧のもたつきはここで出る
OS の版の両端minSdk の版と最新の版
画面の大きさ・形小さい画面、タブレット、折りたたみ端末
メーカーの癖電池の節約機能が強いメーカー(裏の処理や通知が止められやすい)

手元の実機は数台にとどめ、エミュレーターとクラウドの端末(Firebase Test Lab など)で幅を補います。リリース後は Play Console や Crashlytics で「特定の機種・版だけ落ちている」を見つけて、その機種を確かめる端末に加えていきます。

Android のビルドは Gradle で行い、設定は build.gradle.kts に書きます。リリースで大事なのは、ビルドの種類、コードの縮小(R8)、配る形式(AAB)、署名の4つです。

ビルドから端末に届くまでソースコードGradle ビルドR8 で縮小AAB に署名Play が署名し直す端末には、画面の密度や CPU に合わせた必要な部分だけの APK が届く
アップロード鍵で署名して送り、Google Play がアプリ署名鍵で署名し直して端末ごとの APK を配る
設定内容
buildTypesdebug と release。release では R8 を有効にし、デバッグ用の機能を外す
productFlavors開発用・本番用など、接続先やアプリ ID を変えた版を作る
バージョンカタログlibs.versions.toml にライブラリの版をまとめ、モジュール間でそろえる
R8使っていないコードを消し、名前を短くする。リフレクションで使うクラスは keep のルールが要る
AABGoogle Play に出す形式。新しいアプリは AAB が必須で、端末ごとに必要な部分だけが配られる
Play App Signing手元のアップロード鍵で署名して送り、Play がアプリ署名鍵で署名し直す。アップロード鍵をなくしても再発行を頼める

R8 を有効にしたら、リリース版で一通り動かして確かめるのが大事です。JSON の変換やリフレクションで使うクラスが消されて、リリース版だけで落ちることがよくあります。クラッシュの解析用に mapping ファイルも毎回アップロードします。

この章のまとめ

🖨 まとめシート(印刷・PDF)
  • Activity・Fragment のライフサイクルと構成変更(画面回転)
  • Jetpack Compose と Kotlin Coroutines

この章のクイズ

面接ではこう聞かれる

まず答えを見ずに声に出して答え、そのあと開いて比べる。

Q1画面回転で状態が消える問題をどう防ぎますか?

模範回答

画面回転は構成変更なので、Activity が作り直されて画面の中の変数が消えます。まず読み込んだデータや入力中の状態を ViewModel に置きます。ViewModel は構成変更の間も残るので、回転だけならこれで防げます。

ただし、裏に回っている間にメモリ不足でプロセスが終了されると ViewModel も消えます。これに備えて、検索語・選んだタブ・表示中の ID のような小さな値は SavedStateHandle や rememberSaveable に入れ、OS に預けて復元します。大きなデータは Bundle に入れると上限を超えて落ちるので、DB に保存して ID だけを預け、戻ったときに読み直します。確認は、アプリを裏に回して adb shell am kill でプロセスを終了させてから戻る方法で行います。

深掘りされたら

  • SavedStateHandle に入れるものと入れないものの基準は?
  • 構成変更で作り直させない設定(configChanges)を使わないのはなぜですか?

見られているポイントViewModel・SavedState・プロセス終了まで

Q2Compose の画面が遅いとき、どう調べて直しますか?

模範回答

まずリリース版で計ります。デバッグ版の Compose は遅いので、R8 を有効にしたビルドで確かめます。次に Layout Inspector で再コンポーズの回数を見て、回数が多すぎる部品を探します。

よくある原因は、一覧に key がない、安定していない型を渡している、スクロール位置のように頻繁に変わる値を上のほうで読んでいる、重い計算を remember していない、などです。それぞれ key を付ける、不変のデータにする、derivedStateOf やラムダ版の Modifier で読むのを遅らせる、remember で結果を覚える、で直します。最後に Baseline Profile を入れ、Macrobenchmark で前後を比べます。

深掘りされたら

  • 安定した型(stable)とは何ですか?
  • Baseline Profile は何をしてくれますか?

見られているポイント計測から入り、再コンポーズの原因を具体的に挙げられるか

Q3StateFlow と SharedFlow の違いと使い分けは?

模範回答

StateFlow は「今の値」を必ず1つ持つ流れで、初期値が必要です。新しく集め始めた側にもすぐ最新の値が届き、同じ値が続けて来ても流しません。画面の状態(UiState)を ViewModel から公開するのに使います。

SharedFlow は値を持たない流れで、何個前までを新しい購読者に渡すか(replay)を決められます。通知のような一度きりの出来事に使えますが、購読していない間に流すと取りこぼすことがあります。そのため、画面遷移やメッセージの表示のような一度きりの出来事も、できるだけ状態として持ち、処理したら消す形にするのが今の推奨です。

深掘りされたら

  • stateIn の WhileSubscribed(5000) は何のためですか?
  • Channel との違いは?

見られているポイント状態と一度きりの出来事の扱いの違い

Q4コルーチンのキャンセルで気をつけることは?

模範回答

キャンセルは止まってほしいというお願いで、suspend 関数の呼び出しのところで初めて止まります。CPU を使う長いループでは ensureActive() や yield() を呼んで、止まれる場所を作ります。

よくある失敗は、catch (e: Exception) や runCatching で CancellationException まで捕まえてしまい、キャンセルが効かなくなることです。捕まえたら投げ直します。また、キャンセルされた後に後片付けで suspend 関数を呼ぶ必要があるなら withContext(NonCancellable) で包みます。GlobalScope のように終わらないスコープで始めた処理は誰も止めないので使いません。

深掘りされたら

  • 構造化された並行性とは何ですか?
  • 子の1つが失敗したとき、ほかの子はどうなりますか?

見られているポイント協調的なキャンセルの仕組みと、例外の扱い

Q5特定の機種だけで起きる不具合を調べた経験を教えてください。

模範回答

例えば、あるメーカーの機種だけで、裏に回ったあと通知や同期が止まるという問い合わせが続いたとします。Crashlytics や Play Console で機種と OS の版を絞り込み、同じ機種を手配して再現させたところ、メーカー独自の電池の節約機能が裏の処理を止めていることが分かりました。

対策として、確実に必要な同期は WorkManager に任せ、すぐ必要なものはプッシュ通知で起こす形に変えました。あわせて、ヘルプにその機種の設定方法を載せました。それ以来、売れている機種とメモリの少ない機種をテスト端末に加えています。

深掘りされたら

  • 実機がないときはどう確かめますか?
  • テスト端末はどう選んでいますか?

見られているポイント絞り込み・再現・対策・再発防止の順に話せるか