Android の面接対策

Android 面接問題

よく出る Android の面接問題20問。Android 開発は、Kotlin ベースのモバイルアプリを構築し、Android SDK と Jetpack を扱い、ライフサイクル、性能、ネットワーク、ストレージ、テスト、リリースの課題を解決する、需要の高い分野です。問題は難易度別に用意されており、面接トレーナーで声に出して回答の練習ができます。

Android の AI 面接を開始クレジットカードは不要です。1回分の無料セッションがあります。
英語での技術面接練習非ネイティブ話者が技術面接の合格を目指して練習できるモードです。

初級者向け質問

1Activityの生成から破棄に至る主要なライフサイクルコールバックの流れを説明し、リソースの解放に関して onPause()、onStop()、onDestroy() の違いを述べてください。

Activityは、`onCreate()`、`onStart()`、`onResume()`、`onPause()`、`onStop()`、`onDestroy()` という6つの主要なライフサイクルコールバックを経て遷移します。`onCreate()` はViewのインフレートなどの一回限りの初期化を実行します。`onStart()` はActivityを可視状態にし、`onResume()` はActivityをフォアグラウンドに配置してユーザー操作のフォーカスを与えます。画面から離脱する際、`onPause()` はフォーカスが失われたことを示し、`onStop()` は画面上に表示されなくなったことを示し、`onDestroy()` はActivityインスタンスの最終的な破棄を示します。 リソースの解放に関する違いは以下の通りです: - `onPause()`: `onPause()` 内のコード実行は次に前面へ来るActivityの起動を直接ブロックするため、フォアグラウンド特有の短時間の処理(UIアニメーションの一時停止や軽量なカメラプレビューの一時停止など)のみを停止します。 - `onStop()`: UIの可視性に結びついた重いリソース(GPS/位置情報リスナー、センサーの監視、メディアプレーヤーの再生、ネットワークのポーリングなど)を解放するための主要なコールバックです。Activityが完全に不可視となるため、これらを動作させ続けるとバッテリーを浪費し、システムによって事前の通知なくバックグラウンドプロセスが強制終了される可能性があります。 - `onDestroy()`: Activityインスタンスの最終的なクリーンアップ(ローカルスレッドや参照の解除など)に使用されます。ただし、Androidはメモリ確保のために `onDestroy()` を実行することなく停止中のアプリプロセスを直接強制終了できるため、重要なリソースの解放を `onDestroy()` まで遅らせるべきではありません。

class LocationActivity : AppCompatActivity() {
    private var locationClient: LocationClient? = null

    override fun onStart() {
        super.onStart()
        // Start listening when visible to the user
        locationClient?.startLocationUpdates()
    }

    override fun onStop() {
        super.onStop()
        // Release when invisible to save battery and prevent leaks on process kill
        locationClient?.stopLocationUpdates()
    }
}
AI コーチを使ってこの質問に答えてみる

2Androidアプリケーションの4つの主要コンポーネントは何ですか?また、それらのデフォルトのコールバックにおけるライフサイクルとスレッディングモデルはどのようなものですか?

Androidアプリケーションの4つの主要コンポーネントは、Activity、Service、BroadcastReceiver、ContentProviderです。Activityは画面操作のためのユーザーインターフェイスを提供します。Serviceは専用のUIを持たずにバックグラウンド処理を実行します。BroadcastReceiverはシステム全体またはアプリレベルのブロードキャスト通知をリッスンし、応答します。ContentProviderは構造化データを管理し、他のアプリケーションや内部モジュールに公開します。 デフォルトでは、Activity(onCreate、onStart、onResumeなど)、Service(onCreate、onStartCommand、onBindなど)、BroadcastReceiver(onReceive)の主要なライフサイクルコールバックは、アプリケーションのメインスレッド(UIスレッド)上で同期的に実行されます。ContentProviderのメソッド(onCreateなど)もメインスレッドで初期化されますが、プロセス間をまたいで呼び出されるqueryやinsertなどの操作はBinderスレッドプールのスレッド上で実行されます。 デフォルトのコールバックはメインスレッドで実行されるため、ネットワークリクエストや大規模なデータベースのディスクI/Oといった重いブロッキング処理をコールバック内で直接実行すると、UIループがブロックされ、ANR(Application Not Responding)エラーが発生します。

class DataSyncService : Service() {
    private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // onStartCommand runs on the Main Thread; heavy work must be offloaded
        serviceScope.launch {
            performDiskAndNetworkSync()
            stopSelf(startId)
        }
        return START_NOT_STICKY
    }

    override fun onBind(intent: Intent?): IBinder? = null

    override fun onDestroy() {
        super.onDestroy()
        serviceScope.cancel()
    }

    private fun performDiskAndNetworkSync() {
        // Blocking I/O safely executed on Dispatchers.IO
    }
}
AI コーチを使ってこの質問に答えてみる

3AndroidのMVVM(Model-View-ViewModel)アーキテクチャにおいて、ActivityやFragmentなどのUIコントローラと比較したViewModelの主な責務は何ですか?また、ViewModelにContextを渡すことが推奨されない理由は何ですか?

AndroidのMVVMアーキテクチャにおいて、ViewModelの責務はUI関連の状態の保持・管理、プレゼンテーションロジックの統括、および画面回転などの構成変更(configuration changes)をまたいで状態を維持することです。対照的に、UIコントローラ(ActivityやFragment)は画面上へのUI要素の描画、状態変更の監視(observe)、ユーザーの直接的な操作(クリック、ジェスチャー)の処理、およびAndroidライフサイクルイベントの管理を担当します。 ViewModelにActivityのContextやViewの参照を渡すことは強く非推奨とされています。構成変更が発生した際、ViewModelの寿命は通常UIコントローラの寿命よりも長いためです。画面回転時にActivityが破棄されて再生成される際、ViewModelがそのContextを保持していると古いActivityがガベージコレクション(GC)されるのを阻害し、メモリリークを引き起こします。システムレベルの操作でAndroidのContextがどうしても必要な場合は、アプリケーションスコープのContext(`AndroidViewModel`の利用やApplicationContextのインジェクションなど)を代わりに使用する必要があります。

// Bad: Storing Activity context or View reference causes leaks
class LeakyViewModel(private val activityContext: Context) : ViewModel() {
    fun showToast() { Toast.makeText(activityContext, "Hello", Toast.LENGTH_SHORT).show() }
}

// Good: ViewModel manages state; Activity observes and handles context-specific tasks
class SafeViewModel(private val repository: UserRepository) : ViewModel() {
    private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
    
    fun loadUser(id: String) {
        viewModelScope.launch {
            _uiState.value = UserUiState.Success(repository.getUser(id))
        }
    }
}
AI コーチを使ってこの質問に答えてみる

4Androidにおける通常権限(normal permissions)と危険権限(dangerous permissions)の違いは何ですか。また、アプリが実行時に危険権限を要求すると何が起こりますか?

通常権限(Normal permissions)は、ユーザーのプライバシーやデバイスの動作に対するリスクが極めて低く、`AndroidManifest.xml`に宣言されていればアプリのインストール時にシステムによって自動的に付与されます(`ACCESS_NETWORK_STATE`など)。危険権限(Dangerous permissions、ランタイムパーミッションとも呼ばれます)は、カメラ、マイク、位置情報、連絡先など、プライベートなユーザーデータや制限されたデバイス機能へのアクセスを制御します。アプリが実行時に危険権限を要求すると、Androidシステムはアクセスを許可するか拒否するかをユーザーに促すシステムダイアログを表示します。ユーザーが権限を許可した場合、アプリは要求された機能を実行します。ユーザーが権限を拒否した場合、アプリは拒否のコールバックを受け取り、クラッシュさせることなく該当の機能を無効化または制限し、必要に応じてユーザーに理由を説明するUIを表示して適切に処理する必要があります。

val requestPermissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
    if (isGranted) {
        accessCameraFeature()
    } else {
        showPermissionDeniedExplanation()
    }
}

fun checkAndRequestPermission() {
    when {
        ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
            accessCameraFeature()
        }
        shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
            showRationaleDialog { requestPermissionLauncher.launch(Manifest.permission.CAMERA) }
        }
        else -> {
            requestPermissionLauncher.launch(Manifest.permission.CAMERA)
        }
    }
}
AI コーチを使ってこの質問に答えてみる

5Androidにおけるdebugビルドとreleaseビルドの違いは何ですか?また、ビルドタイプ(build types)とプロダクトフレーバー(product flavors)はどのように組み合わされてビルドバリアント(build variants)になりますか?

Android開発において、ビルドタイプは開発の各段階に応じたパッケージングや実行時のプロパティを設定します。debugビルドはデバッグフラグ(`isDebuggable = true`)を有効化し、デフォルトのデバッグ用キーストアを使用し、迅速にビルドを反復できるよう通常はコードの圧縮や縮小(R8/ProGuard)を無効にします。一方、releaseビルドはデバッグフラグを無効化し、最適化や難読化を有効化するとともに、配布用に安全なリリース署名鍵を必要とします。プロダクトフレーバーは、同じ共通コードベースを共有しながら、異なる環境(ステージング環境と本番環境など)や製品エディション(無料版と有料版など)といった異なるバージョンやターゲットを表します。Gradleでは、ビルドバリアントはプロダクトフレーバーとビルドタイプの直積によって生成されます(ビルドバリアント = プロダクトフレーバー × ビルドタイプ)。たとえば、フレーバーの `staging` と `production` をビルドタイプの `debug` と `release` と組み合わせると、`stagingDebug`、`stagingRelease`、`productionDebug`、`productionRelease` という4つのビルドバリアントが生成されます。

android {
    flavorDimensions += "environment"
    productFlavors {
        create("staging") {
            dimension = "environment"
            applicationIdSuffix = ".staging"
        }
        create("production") {
            dimension = "environment"
        }
    }
    buildTypes {
        getByName("debug") {
            isDebuggable = true
        }
        getByName("release") {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
}
AI コーチを使ってこの質問に答えてみる

6Kotlinにおけるコルーチンとは何ですか?また、Androidの実行時において中断(サスペンド)とスレッドのブロックにはどのような違いがありますか?

Kotlinにおけるコルーチンとは、非同期でノンブロッキングなコードを順次処理のように記述できるようにする軽量な並行処理の抽象化機構です。OSレベルのスレッドのような重いメモリ消費やコンテキストスイッチのオーバーヘッドを伴うことなく、単一スレッド上やスレッドプールを跨いで複数のコルーチンを並行実行できます。中断(サスペンド)とブロックの本質的な違いは、スレッドの利用効率にあります。 - ブロック(`Thread.sleep()` や同期的なディスク/ネットワークI/Oなど)は、実行中の基盤スレッドを停止させ、他のいかなる処理にも利用できない状態にします。Androidのメインスレッドでブロックが発生すると、UIがフリーズし、ANR(Application Not Responding)エラーが引き起こされます。 - 中断(`delay()` などの `suspend` 関数経由)は、基盤スレッドを停止させることなくコルーチンのみを一時停止します。コルーチンは実行状態を Continuation に保存し、スレッドをランタイムに返却するため、そのスレッドは他の処理(UIインタラクションの処理など)を実行できます。非同期処理が完了すると、コルーチンは再開されます。

suspend fun fetchUserData(): String {
    delay(1000) // Suspends execution and yields the thread
    return "User Data"
}

fun fetchUserDataBlocking(): String {
    Thread.sleep(1000) // Blocks and locks the underlying thread
    return "User Data"
}
AI コーチを使ってこの質問に答えてみる

7Jetpack Composeにおいて、`remember` で保持される状態と `rememberSaveable` で保持される状態の違いは何ですか。また、`mutableStateOf` は再コンポジションをまたいでどのようにUI (User Interface) の更新を促しますか?

Jetpack Composeにおいて、`remember` はコンポーザブルがコンポジションツリーにとどまっている限り、再コンポジションをまたいでオブジェクトをメモリ内に保持します。ただし、`remember` だけで保持された状態は、画面の回転などの構成変更(configuration change)やシステム起因のプロセス破棄(process death)が発生すると失われます。 対照的に、`rememberSaveable` は、AndroidのSaved Instance Stateバンドル(または非対応データ型に対するカスタム `Saver` 実装)を介して値を自動的に保存・復元することで、構成変更やプロセス破棄をまたいでも状態を維持します。 `mutableStateOf` は、ComposeのSnapshot状態システムに支えられたオブザーバブルな `MutableState` オブジェクトで値をラップします。コンポーザブルがコンポジション中に `MutableState` の値を読み取ると、Composeはその読み取り操作を記録します。その後値が変更されると、Composeはその値を読み取っていたすべてのコンポーザブルを無効(invalid)としてマークし、再コンポジションをスケジュールします。これにより、UIが更新後の状態と常に同期されるようになります。

@Composable
fun CounterExample() {
    // Resets to 0 on screen rotation
    var localCount by remember { mutableStateOf(0) }

    // Persists across screen rotation and process death
    var persistentCount by rememberSaveable { mutableStateOf(0) }

    Column {
        Button(onClick = { localCount++; persistentCount++ }) {
            Text("Local: $localCount | Saved: $persistentCount")
        }
    }
}
AI コーチを使ってこの質問に答えてみる

8Kotlinのnull安全はAndroidアプリのクラッシュ防止にどのように役立ちますか?また、null許容型と非null型はどのように使い分けますか?

Kotlinのnull安全は、null許容性を型システムの一部として組み込むことで、多くのAndroidアプリのクラッシュを防ぎます。`String`として宣言された値は非nullとして扱われるため、コンパイラは`name.length`のような直接アクセスを許可します。一方、`String?`として宣言された値はnullになる可能性があるため、Kotlinはそれを利用する前にハンドリング処理を行うことを強制します。これにより、オプショナルなIntentのExtra、APIレスポンス、Bundleの引数、JavaやプラットフォームAPIに起因する予期せぬ`NullPointerException`の発生を抑制できます。 非null型は、オブジェクトや関数が正常に機能するためにその値が必須である場合に使用します。null許容型は、プロフィールの画像URLが未設定である場合やサーバーからのレスポンスで一部の項目が欠落している場合のように、「値が存在しない」ことが想定された正常な状態である場合に使用します。null許容値は通常、`user?.name`のようなセーフコール演算子、`user?.name ?: "Guest"`のようなElvis演算子、または明示的なnullチェックを用いて処理されます。`!!`演算子はnull安全の仕組みを無効化し、値がnullの場合に例外をスローするため、使用は極力避け、値が絶対にnullでないことを開発者が確実に証明できる場面に限定すべきです。

val displayName: String? = intent.getStringExtra("display_name")

val greeting = "Hello, ${displayName ?: "Guest"}"
val lengthText = displayName?.length?.toString() ?: "No name provided"

// Risky: crashes if displayName is null
val riskyLength = displayName!!.length
AI コーチを使ってこの質問に答えてみる

9生存期間の長いオブジェクトにActivity Contextを渡すこととApplication Contextを渡すことの仕組み上の違いは何ですか?また、選択を誤るとどのようにメモリリークにつながるのでしょうか?

Activity Contextは特定の画面およびUI階層のライフサイクルに直接結びついているのに対し、Application Contextはアプリプロセス全体のライフサイクルに結びついています。Activityが終了(finish)するか、画面回転などの構成変更(configuration change)によって再生成されると、そのライフサイクルは終了し、メモリはGC(Garbage Collector)によって回収される必要があります。もしActivity Contextをシングルトン、静的フィールド、長時間実行されるバックグラウンドスレッドなどの生存期間の長いオブジェクトに渡してしまうと、そのオブジェクトがActivityへの強参照を保持し続けます。生存期間の長いオブジェクトはGCルートとして機能するか、GCルートから到達可能であるため、ガベージコレクタは破棄されたActivityを回収できません。これにより、Activityインスタンス自体だけでなく、関連付けられたView階層、Drawable、リソース全体が保持され続け、深刻なメモリリークが発生します。対照的に、applicationContextを渡すことは、そのライフサイクルがプロセスの寿命と一致することが想定されているため、生存期間の長いコンポーネントに対しても安全です。

class LocationRepository private constructor(private val context: Context) {
    companion object {
        @Volatile
        private var INSTANCE: LocationRepository? = null

        fun getInstance(context: Context): LocationRepository {
            return INSTANCE ?: synchronized(this) {
                // SAFE: Using context.applicationContext prevents retaining an Activity reference
                INSTANCE ?: LocationRepository(context.applicationContext).also { INSTANCE = it }
            }
        }
    }
}
AI コーチを使ってこの質問に答えてみる

10Google Play App Signing を利用する際、アップロード鍵とアプリ署名鍵にはどのような違いがありますか?

Google Play App Signing において、アップロード鍵とアプリ署名鍵は2つの異なるセキュリティ上の役割を果たします。 アップロード鍵は開発者が保持し、ビルド成果物(AAB)を Google Play Console にアップロードする前に署名するために使用され、開発者の身元を Google に対して証明します。 アプリ署名鍵は Google のクラウドインフラ内に安全に保管され、エンドユーザーのデバイスに実際に配信・インストールされる生成済み APK に Google Play が署名するために使用されます。これにより、Android 上におけるアプリの恒久的な暗号アイデンティティが確立されます。 運用上の大きなメリットは鍵の復旧性です。開発者がアップロード鍵を紛失または侵害された場合でも、Google Play サポートに本人確認を行うことで、アプリのアップデート経路を壊すことなくアップロード鍵を再設定できます。開発者がアプリ署名鍵を直接保持していた従来の方式では、鍵を紛失するとアプリを二度と更新できなくなっていました。

android {
    signingConfigs {
        create("release") {
            storeFile file("my-upload-key.jks")
            storePassword System.getenv("KEYSTORE_PASSWORD")
            keyAlias "upload-alias"
            keyPassword System.getenv("KEY_PASSWORD")
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.getByName("release")
            minifyEnabled true
        }
    }
}
AI コーチを使ってこの質問に答えてみる

中級者向け質問

11端末の画面回転後にユーザーが入力したフォームデータが失われる問題が発生しています。この原因が状態保存の欠落、不適切なViewModelのスコープ指定、またはビューの再バインドによるものかを体系的に診断するにはどうすればよいですか?

画面回転後にフォームデータが失われる原因を体系的に診断するには、以下の3つの主要領域を切り分けて調査します。 1. **ViewModelのスコープと保持(Scoping & Retention):** 画面回転(Configuration Change)の前後で、ViewModelのインスタンスが再生成されずに保持されているかを確認します。回転前後でViewModelのハッシュコード(`viewModel.hashCode()`)をログ出力して検証してください。インスタンスの取得には、直接のコンストラクタ呼び出し(例: `MyViewModel()`)ではなく、`by viewModels()` や `by activityViewModels()` などの委譲プロパティ、または `ViewModelProvider(this)` が適切に使用されていることを確認します。 2. **保存された状態(Saved State)とView IDの復元:** フォームがAndroid標準のビュー階層による自動復元のみに依存していないか確認します。自動的なビュー状態の保存・復元が行われるには、レイアウト内で各ビューに `android:id` 属性が定義されている必要があります。カスタムビューや `SavedStateHandle` を使用している場合は、`onSaveInstanceState` や `SavedStateHandle` で対象フィールドが正しく保存および復元されているかを検証します。 3. **ビューの再バインドとObserverロジック:** `onViewCreated` 内の処理や、`StateFlow`、`LiveData` に対するObserverの購読ロジックを精査します。新しく生成されたビューが正しく再購読しているか、あるいはビューのセットアップコードが復元されたテキストを誤ってデフォルトの空値で上書きしていないかを確認します。さらに、双方向バインディングや `TextWatcher` によって、復元された空のビューから即座に空文字の更新がViewModelへ逆流・発行されるループが発生していないかも調査します。

class FormFragment : Fragment(R.layout.fragment_form) {
    // 1. Ensure correct lifecycle scoping
    private val viewModel: FormViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        Log.d("FormDiag", "VM instance hash: ${viewModel.hashCode()}")

        // 2. Observe state without clobbering input during view recreation
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.formText.collect { text ->
                    if (binding.inputField.text.toString() != text) {
                        binding.inputField.setText(text)
                    }
                }
            }
        }
    }
}
AI コーチを使ってこの質問に答えてみる

12モダンなJetpack Activity Result API(Application Programming Interface)はレガシーなstartActivityForResultをどのように置き換え、Activity再生成時におけるコールバックの消失をどのように防ぎますか?

モダンなJetpack Activity Result APIは、従来の `startActivityForResult()` および `onActivityResult()` を、型安全で疎結合なコントラクトベースの仕組みへと置き換えます。呼び出し元は、任意のリクエストコード数値や巨大な `onActivityResult` の switch 文を管理する代わりに、入力インテントパラメータと期待されるパース済み出力を定義する `ActivityResultContract<I, O>` を定義し、`registerForActivityResult()` を使用して型付けされたコールバックを登録することで `ActivityResultLauncher` を取得します。 このAPIは、厳格なライフサイクル登録を通じて、Activityの再生成やプロセス破棄(process death)時におけるコールバックの消失を防ぎます。 1. カメラやドキュメントピッカーなどの外部Activityが起動されると、呼び出し元のActivityはメモリ不足や構成変更によってOSにより破棄される可能性があります。 2. `registerForActivityResult()` をActivityまたはFragmentが `STARTED` 状態に達する前(通常はプロパティ初期化時または `onCreate` 内)に無条件で呼び出すことを義務付けることで、状態の復元が行われる前に結果コールバックが `ActivityResultRegistry` に登録されます。 3. 起動されたActivityから戻り、ホスト側のActivityが再生成された際、`ActivityResultRegistry` はシステムからの保留中の結果を新しく登録されたコールバックと照合し、安全に結果をディスパッチします。

class ProfileActivity : AppCompatActivity() {
    // Registered unconditionally during initialization / before onStart()
    private val takePictureLauncher = registerForActivityResult(
        ActivityResultContracts.TakePicturePreview()
    ) { bitmap: Bitmap? ->
        bitmap?.let {
            findViewById<ImageView>(R.id.avatarImage).setImageBitmap(it)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_profile)

        findViewById<Button>(R.id.btnCapture).setOnClickListener {
            takePictureLauncher.launch(null)
        }
    }
}
AI コーチを使ってこの質問に答えてみる

13複雑な画面におけるMVVM (Model-View-ViewModel) とMVI (Model-View-Intent) アーキテクチャを比較してください。MVIの状態レデューサーとIntentのディスパッチは、MVVMと比較してどのようにアトミックな状態更新を保証しますか?

従来のMVVMでは、ViewModelが独立した複数のリアクティブストリーム(`isLoading`、`userData`、`errorMessage` を個別の `StateFlow` や `LiveData` プロパティとして公開するなど)や、複数のパブリックな更新メソッドを公開することがよくあります。複数の非同期タスクが並行して完了すると、これらのストリームが個別に更新され、競合状態、不完全な中間状態、または画面のちらつきが発生する可能性があります。 MVI (Model-View-Intent) では、状態管理は以下の3つの要素を中心に構築された厳格な単一方向データフロー(UDF: Unidirectional Data Flow)に従います。 1. 画面全体の状態を表す単一のイミュータブルな `UiState` 2. すべてのユーザー操作やシステムイベントを表す個別の `UiIntent`(またはAction)型 3. 純粋な状態レデューサー関数: `(PreviousState, UiIntent) -> NewState` MVIがアトミックな状態更新を保証できるのは、すべてのイベントが明確なIntentとしてディスパッチされ、状態レデューサーを通じて順次処理されるためです。レデューサーは既存の状態のイミュータブルなスナップショットを受け取り、関連するすべてのプロパティが同時に更新された完全に新しい状態スナップショットを生成します。状態の更新が集約され、遷移が逐次的に行われるため、UIが不完全、非同期、あるいは矛盾した状態スナップショットを観測することはありません。

data class ScreenState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null
)

sealed interface ScreenIntent {
    data object Refresh : ScreenIntent
    data class DataLoaded(val items: List<Item>) : ScreenIntent
    data class LoadFailed(val message: String) : ScreenIntent
}

fun reduce(state: ScreenState, intent: ScreenIntent): ScreenState = when (intent) {
    is ScreenIntent.Refresh -> state.copy(isLoading = true, error = null)
    is ScreenIntent.DataLoaded -> state.copy(isLoading = false, items = intent.items, error = null)
    is ScreenIntent.LoadFailed -> state.copy(isLoading = false, error = intent.message)
}
AI コーチを使ってこの質問に答えてみる

14ユーザーに強制感を与えることなく、初回の要求、根拠を示すUI(User Interface)、拒否、「今後表示しない」、および設定画面への誘導を処理するランタイムパーミッションのフローをどのように設計しますか?

強制感のないユーザーフレンドリーなランタイムパーミッションフローは、段階的な開示、明確な理由説明、および拒否されたすべての状態におけるグレースフルデグラデーション(段階的な機能縮小)に従って設計します。 1. **コンテキストに応じた要求(初回)**: アプリ起動時に一括で要求するのではなく、ユーザーが特定の機能を利用したタイミング(例: カメラ権限の場合は「コードをスキャン」をタップした時)にのみ権限を要求します。 2. **根拠を示すUI**: `shouldShowRequestPermissionRationale()` が `true` を返した場合は、システムのダイアログを表示する前に、アプリ内のUI(ボトムシートやダイアログなど)で権限が必要な理由とそれによるメリットを分かりやすく説明します。 3. **拒否時のグレースフルデグラデーション**: ユーザーが要求を拒否した場合はその選択を尊重し、関係のない他の機能までブロックしないようにします。可能な限り代替フローを提供します(例: カメラへのアクセスが拒否された場合はテキストでの手動入力を促すなど)。 4. **恒久的な拒否(「今後表示しない」)と設定画面への誘導**: 権限が拒否され、`shouldShowRequestPermissionRationale()` が `false` を返した場合(ユーザーが「今後表示しない」を選択した、または拒否を繰り返した場合)は、機能が利用できない理由を説明した上で、`Settings.ACTION_APPLICATION_DETAILS_SETTINGS` を介してアプリの設定画面へ誘導する任意のボタンを提供します。その際、ユーザーが閉じ込められることなく、簡単にキャンセルや前の画面への復帰ができるように配慮します。

fun onScanButtonClicked() {
    when {
        ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
            openScanner()
        }
        activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
            showRationaleDialog(onConfirm = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) })
        }
        else -> {
            cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
        }
    }
}

fun handlePermissionResult(isGranted: Boolean) {
    if (isGranted) {
        openScanner()
    } else if (!activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
        showSettingsRedirectDialog()
    } else {
        showManualEntryFallback()
    }
}
AI コーチを使ってこの質問に答えてみる

15プルリクエストの検証から、署名済みリリース候補版の作成、内部テスト、Google Playトラックへのアップロード、および段階的ロールアウトの昇格に至るAndroidのCI/CD(継続的インテグレーション/継続的デリバリー)パイプラインをどのように設計しますか?

本番環境向けのAndroid CI/CDパイプラインは、まずプルリクエスト検証フェーズにおける高速な自動チェックから始まります。これには静的解析(ktlint、detekt、Android Lint)、単体テスト、およびデバッグビルドのコンパイルが含まれます。mainブランチやリリースブランチへのマージをトリガーとしてリリースパイプラインが実行され、CI側で管理されたリリースキーストアの認証情報、Proguard/R8によるコード縮小、バージョン番号の自動インクリメントを用いて、署名済みのリリース候補版(RC)Android App Bundle(`.aab`)を生成します。署名されたAABと難読化解除用のマッピングファイルは、Gradle Play Publisher(GPP)やFastlaneなどのツールを使用してGoogle Playの内部テストトラック(またはFirebase App Distribution)へアップロードされ、そこで自動スモークテストやインスツルメンテーションテストが実行されます。社内QAや関係者による承認が得られたら、ソースから再ビルドすることなく、全く同一のバイナリアーティファクトを下流環境へ昇格(内部テスト -> クローズドアルファ/ベータ -> 本番ロールアウト)させます。最後に、クラッシュ率やAndroid Vitalsの自動監視と連動した段階的ロールアウト(例: 5% -> 20% -> 100%)を実施し、安全なリリースを担保します。

lane :promote_internal_to_beta do
  upload_to_play_store(
    track: 'internal',
    track_promote_to: 'beta',
    skip_upload_aab: true, # Promotes existing binary without rebuilding
    skip_upload_metadata: false,
    skip_upload_changelogs: false
  )
end
AI コーチを使ってこの質問に答えてみる

16CancellationException を再スローせずに汎用的な Exception や Throwable をキャッチするとコルーチンのキャンセル機構が損なわれるのはなぜですか。また、キャンセルの問題はどのように診断しますか?

Kotlin Coroutines は、CancellationException を介して実装された協調的キャンセル機構に依存しています。コルーチンの Job がキャンセルされると、中断を伴う呼び出し(`delay` や `yield` など)が CancellationException をスローしてコールスタックを巻き戻し、コルーチンを終了させます。CancellationException を再スローせずに汎用的な Exception や Throwable をキャッチすると、このキャンセル通知がもみ消されてしまいます。その結果、コルーチンは中断に失敗して実行を継続し、CPUやメモリを浪費してリソースリークを引き起こすだけでなく、破棄済みのUIコンポーネントに対する無効な状態遷移やクラッシュを引き起こす「ゾンビコルーチン」となります。キャンセルの問題を診断するには以下の手順を踏みます: 1. 例外処理の健全性を確認し、CancellationException を明示的に再スローしているか、または汎用的な Throwable/Exception ではなくドメイン固有の例外をキャッチしているかを検証する。 2. デバッグビルドにおいて Kotlin Coroutines Debugger (`-Dkotlinx.coroutines.debug`) や Android Studio の Coroutines Inspector を用いてコルーチンの状態を調査し、終了しているべき実行中コルーチンを特定する。 3. コルーチンのライフサイクル状態(例: `job.isActive`、`job.isCancelled`)をログに出力するか、キャンセルの完了ハンドラ(`job.invokeOnCompletion`)内で構造化ロギングを利用する。

import kotlinx.coroutines.CancellationException

// ANTI-PATTERN: Swallows cancellation, creating a zombie coroutine
try {
    doSuspendingWork()
} catch (e: Exception) {
    logError(e)
}

// CORRECT: Preserves cancellation propagation
try {
    doSuspendingWork()
} catch (e: Exception) {
    if (e is CancellationException) throw e
    logError(e)
}
AI コーチを使ってこの質問に答えてみる

17Jetpack Compose において、状態ホイスティングと UDF (Unidirectional Data Flow) はどのように機能し、状態の配置先を末端のコンポーザブル、親コンポーザブル、ViewModel のいずれにすべきかをどのように判断しますか?

状態ホイスティング(state hoisting)とは、コンポーザブルをステートレスにして純粋な UI コンポーネントにするために、状態をコンポジション階層の上位へ移動させる Jetpack Compose のパターンです。これにより、状態が下位へ流れ(親や ViewModel から子コンポーザブルへ引数として渡される)、イベントが上位へ流れる(子コンポーザブルから親や ViewModel へコールバックとして渡される)単方向データフロー(UDF)が実現されます。 状態をどこに配置すべきかの判断基準は次のとおりです。 1. **末端のコンポーザブル(ローカル UI 状態):** 状態が完全に一時的かつ視覚的なものであり、親や兄弟コンポーネントで必要とされない場合(例: 内部の展開/折りたたみアニメーションの実行状態やローカルなリップル効果など)、`remember` を使用して末端のコンポーザブル内でローカルに保持します。 2. **親コンポーザブル(ホイスティングされた UI 状態):** 兄弟関係にあるコンポーザブル間で状態を共有・反応させる必要がある場合や、親がコンポーネントの表示/非表示やバリデーションを制御する場合は、共通の直接の親コンポーザブルへ状態をホイスティングします。これにより末端コンポーザブルはステートレスになります(`value` と `onValueChange` を受け取る)。 3. **ViewModel(画面/ビジネス状態):** 状態がビジネスデータを表す場合、画面回転などの設定変更(configuration changes)をまたいで保持する必要がある場合、画面遷移を駆動する場合、またはドメイン層/リポジトリ層とのやり取りが必要な場合は、ViewModel に配置し、監視可能な状態(`StateFlow` など)として公開します。ViewModel はビジネスロジックを処理し、それに応じて UI 状態を更新します。

// Stateless Leaf Composable
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
    TextField(value = query, onValueChange = onQueryChange)
}

// Stateful Screen / ViewModel Owner
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    SearchBar(
        query = uiState.query,
        onQueryChange = { newQuery -> viewModel.onQueryChanged(newQuery) }
    )
}
AI コーチを使ってこの質問に答えてみる

上級者向け質問

18本番環境における FragmentManager の状態喪失(state loss)や画面遷移の競合状態を検知、診断、防止するために、テレメトリ、クラッシュブレッドクラム、およびアーキテクチャ上のガードレールをどのように設計しますか?

FragmentManager の状態喪失(`IllegalStateException: Can not perform this action after onSaveInstanceState`)や非同期の画面遷移における競合状態は、ネットワークコールバックやリアクティブストリームなどの非同期処理が、ホストのライフサイクルが `onSaveInstanceState()` や `onStop()` を通過した後に UI トランザクションを実行しようとした際に発生します。本番環境でこれらを検知および診断するために、`Application.ActivityLifecycleCallbacks` や `FragmentManager.FragmentLifecycleCallbacks` を介してライフサイクルのテレメトリとブレッドクラムを実装し、クラッシュ前のタイムスタンプ付き遷移ログ、保留中のバックスタック数、実行コンテキストを記録します。アーキテクチャとして状態喪失を防ぐには、UI トランザクションや画面遷移を生の非同期コールバックからではなく、ライフサイクルを認識する状態オブザーバー(`repeatOnLifecycle(Lifecycle.State.RESUMED)` やライフサイクルバインディングを伴って collect される `StateFlow` など)によって排他的に駆動させる必要があります。画面遷移イベントは、単一方向データフロー(UDF: Unidirectional Data Flow)の離散的な状態遷移、またはライフサイクル状態が少なくとも `STARTED` または `RESUMED` である間のみ消費される単発イベントとしてモデル化すべきです。さらに、アーキテクチャ上のガードレールとして、復元不要な一時的コンテキストでのみ `commitStateLoss()` を明示的に許可するか、より望ましくは厳格なライフサイクル境界を持つ Jetpack Navigation コンポーネントへ移行することを強制します。カスタム Android Lint ルールによる静的解析や、デバッグビルドでのランタイム強制(例: Fragment の StrictMode)により、本番環境に到達する前に違反を検出できます。

class NavigationDispatcher @Inject constructor() {
    private val _events = Channel<NavigationCommand>(Channel.BUFFERED)
    val events: Flow<NavigationCommand> = _events.receiveAsFlow()

    fun navigate(command: NavigationCommand) {
        _events.trySend(command)
    }
}

// In Fragment / Activity
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
        navigationDispatcher.events.collect { command ->
            CrashReporting.leaveBreadcrumb("Navigating to ${command.destination} at state ${lifecycle.currentState}")
            command.execute(parentFragmentManager)
        }
    }
}
AI コーチを使ってこの質問に答えてみる

19Doze モード、App Standby バケット、OEM (Original Equipment Manufacturer) 独自のタスクキラーなどのシステムレベルの電源管理機能はバックグラウンドのスケジューリングにどのような影響を与えますか?また、WorkManager を使用して耐障害性の高い同期エンジンを設計するにはどうすればよいですか?

システムレベルのバッテリー最適化は、バックグラウンドでの処理実行を厳しく制限します。Doze モードは非アクティブ期間中に CPU、ネットワークアクセス、バックグラウンドジョブを制限し、定期的なメンテナンスウィンドウの間だけ実行を許可します。App Standby バケットは、アプリの直近の使用頻度(Active から Restricted または Never まで)に基づいて、バックグラウンドジョブの頻度とネットワークアクセスを動的にスロットリングします。さらに、アグレッシブな OEM 独自の電源管理機能(MIUI や OneUI など)は、バニラな AOSP の挙動に関係なく、バックグラウンドプロセスを頻繁に強制終了し、標準のアラームを無視し、自動起動権限を取り消します。 耐障害性の高い同期エンジンを構築するには、WorkManager が標準的な基盤となります。これは JobScheduler、AlarmManager、BroadcastReceiver を抽象化し、OS の制約と直接統合されるためです。WorkManager を使用すると、厳格な実行前提条件(`NetworkType.CONNECTED`、`requiresBatteryNotLow(true)` など)を宣言でき、Doze のメンテナンスウィンドウまたは接続が復旧するまで実行を自動的に遅延させることができます。 予期せぬプロセスの終了、一時的なネットワーク切断、OEM による強制終了に耐えるため、同期エンジンは「指数バックオフ(exponential backoff)」と「エンドツーエンドの冪等性(idempotency)」という 2 つのコア原則に従う必要があります。Doze からの復帰時にバックエンドサーバーへのサンダリングハード問題を回避するため、WorkManager には `BackoffPolicy.EXPONENTIAL` を設定する必要があります。さらに、ワーカーは同期処理をアトミックかつ冪等なものとして扱う必要があります(決定論的なトランザクション ID、ローカルの状態フラグ、サーバー側の重複排除キーを使用)。これにより、ワーカーが処理の途中で不意に強制終了され、再度キューに入れられた場合でも、再試行によってデータが重複したりローカルデータベースが破損したりすることを防ぎます。

val syncConstraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .build()

val syncWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(syncConstraints)
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        WorkRequest.MIN_BACKOFF_MILLIS,
        TimeUnit.MILLISECONDS
    )
    .addTag("data_sync_tag")
    .build()

WorkManager.getInstance(context).enqueueUniqueWork(
    "unique_data_sync",
    ExistingWorkPolicy.KEEP,
    syncWorkRequest
)
AI コーチを使ってこの質問に答えてみる

20Gradleのビルド時間を最適化し、厳格なコントラクト境界を強制するために、「API-Implementation」モジュール分離パターンを用いた拡張性の高いマルチチーム向けAndroidアーキテクチャをどのように設計しますか?

大規模なマルチチーム構成のAndroidコードベースにおいて、API-Implementation(API-Impl)パターンは各機能モジュールやドメインモジュールを2つの明確なGradleサブプロジェクトに分割します。一方は公開インターフェース、モデル、画面遷移コントラクトを含む軽量な `:feature:api` モジュールであり、もう一方は内部のビジネスロジック、UI、リポジトリの実装を含む `:feature:impl` モジュールです。これを利用するモジュール側は `implementation project(':feature:api')` によって `:feature:api` のみに依存し、ルートの `:app` モジュールや専用のコンポジションルートが依存性の注入(DI、例: Dagger/Hilt)を介して具体的な実装を結合します。このパターンは、ABI(Application Binary Interface)の安定性とコンパイルクラスパスの分離により、Gradleのビルドパフォーマンスを大幅に向上させます。エンジニアが `:feature:impl` 内の実装詳細を変更しても、`:feature:api` の公開ABIは変化しません。そのため、Gradleは `:feature:api` にのみ依存するすべての下流モジュールの再コンパイルをスキップでき、Gradleリモートビルドキャッシュやコンフィグレーションキャッシュの効果を最大限に引き出せます。組織統治の観点からも、このパターンはCODEOWNERSなどのツールを用いてチーム間の明確な責務境界を確立します。チームは非公開クラスを外部に晒すことなく内部実装を安全に変更できるため、大規模チームにおける望ましくない密結合や循環依存を防止できます。

// :feature:checkout:api/src/main/java/com/example/checkout/api/CheckoutApi.kt
package com.example.checkout.api

interface CheckoutLauncher {
    fun launchCheckoutFlow(cartId: String)
}

// :feature:checkout:impl/src/main/java/com/example/checkout/impl/CheckoutLauncherImpl.kt
package com.example.checkout.impl

import com.example.checkout.api.CheckoutLauncher
import javax.inject.Inject

internal class CheckoutLauncherImpl @Inject constructor(
    private val paymentGateway: PaymentGateway
) : CheckoutLauncher {
    override fun launchCheckoutFlow(cartId: String) {
        // Concrete checkout implementation
    }
}

// :feature:checkout:impl/src/main/java/com/example/checkout/impl/di/CheckoutModule.kt
package com.example.checkout.impl.di

import com.example.checkout.api.CheckoutLauncher
import com.example.checkout.impl.CheckoutLauncherImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@Module
@InstallIn(SingletonComponent::class)
internal abstract class CheckoutBindingModule {
    @Binds
    abstract fun bindCheckoutLauncher(impl: CheckoutLauncherImpl): CheckoutLauncher
}
AI コーチを使ってこの質問に答えてみる