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()`:画面のアニメーションや軽量なカメラプレビューの一時停止など、フォアグラウンド固有の短時間で終わる処理だけを行います。このコールバックは速やかに完了させる必要があり、長い処理は Activity 遷移時に次の Activity の再開を遅らせることがあります。 - `onStop()`:画面の可視性に結びついた比較的重いリソースを解放する主な場所です。例えば、GPS(全地球測位システム)などの位置情報リスナー、センサーの監視、メディアプレーヤーの再生、ネットワークのポーリングが該当します。表示中の画面だけが必要とするリソースは、ここで停止します。意図的にバックグラウンドで再生や追跡を継続する場合は、適切な独立した管理主体と Android のバックグラウンド実行制限への対応が必要です。また、システムは追加の通知なしにバックグラウンドプロセスを終了することがあります。 - `onDestroy()`:ローカルなスレッドや参照の整理など、Activity インスタンスの最終的な後処理に使用します。ただし、Android はメモリを確保するため、`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複雑な Android 画面について、MVVM(モデル・ビュー・ビューモデル)と MVI(モデル・ビュー・インテント)を比較してください。状態更新を不可分にする仕組みは何ですか。また、不変の状態とレデューサーを使っても、どのような並行処理の問題が残りますか?

MVVM や MVI という名称だけでは、並行処理の正しさは決まりません。MVVM でも一つの不変な `UiState` を公開し、レデューサーを使えますし、MVI の実装でも競合は起こり得ます。 レデューサーは、以前の状態とイベントから新しい状態を計算します。状態を不可分に公開するには、イベントの直列処理、ミューテックス、単一の状態値に対する `MutableStateFlow.update` など、具体的な仕組みが必要です。`update` では、競合時にレデューサーが複数回実行される場合があるため、副作用を含めてはいけません。 関連するフィールドを一つのスナップショットで更新すれば、部分的にしか反映されていない状態の公開を避けられます。ただし、論理的に不正な状態や、古いネットワーク応答による新しい結果の上書きまでは防げません。状態の不変条件を定義し、要求の世代を識別し、不要になった結果をキャンセルまたは無視し、副作用を純粋なレデューサーの外に置きます。 MVI で用いられる明示的なインテントと状態遷移は、追跡とテストを改善します。同じ手法を MVVM に取り入れることもできます。設計パターンの名称が正しさを保証すると考えず、イベントの実行順序の入り交じりや失敗時の経路をテストします。

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ユーザーに強制せず、初回の要求、理由の説明、拒否、「今後表示しない」、設定画面への任意の誘導に対応する実行時権限のフローをどのように設計しますか?

強制感のないユーザーフレンドリーなランタイムパーミッションフローは、段階的な開示、明確な理由説明、および拒否されたすべての状態におけるグレースフルデグラデーション(段階的な機能縮小)に従って設計します。 1. **コンテキストに応じた要求(初回)**: アプリ起動時に一括で要求するのではなく、ユーザーが特定の機能を利用したタイミング(例: カメラ権限の場合は「コードをスキャン」をタップした時)にのみ権限を要求します。 2. **理由を説明する画面**: `shouldShowRequestPermissionRationale()` が `true` を返した場合は、システムのダイアログを表示する前に、アプリ内の画面(ボトムシートやダイアログなど)で権限が必要な理由とそれによるメリットを分かりやすく説明します。 3. **拒否時のグレースフルデグラデーション**: ユーザーが要求を拒否した場合はその選択を尊重し、関係のない他の機能までブロックしないようにします。可能な限り代替フローを提供します(例: カメラへのアクセスが拒否された場合はテキストでの手動入力を促すなど)。 4. **恒久的な拒否(「今後表示しない」)と設定画面への誘導**: `shouldShowRequestPermissionRationale()` が `false` を返すことだけでは、恒久的な拒否とは判断できません。初回の要求前にも `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` で拒否されることがあります。機密情報を除いた診断用の操作履歴として、ホストと Fragment・View のライフサイクル、`FragmentManager.isStateSaved`、現在の遷移先、タスクとインスタンスの識別子、ナビゲーション要求の識別子、スレッド、トランザクションのタイミングを記録します。 画面遷移はメインスレッドで実行し、適切なライフサイクル所有者を使い、単一のナビゲーション管理主体を通して要求を直列化します。コミット前には、現在の遷移先と、FragmentManager が状態変更を受け付けられることの両方を確認します。古くなった要求は、明示的な方針に従って延期または破棄します。ライフサイクルを考慮した収集は役立ちますが、それだけでイベントの重複や、確認時点と使用時点の間の競合を防げるわけではありません。 確実な配信が重要なナビゲーション要求は、状態として表現し、処理済みであることを確認できるようにします。単発イベントのストリームは、設定次第でイベントを失ったり再配信したりします。実際の API(アプリケーション・プログラミング・インターフェース)は `commitAllowingStateLoss()` です。これは復元時にトランザクションが失われる可能性を承知で受け入れるものであり、本当に失われてもよい一時的な画面表示だけに限定します。 Jetpack Navigation と 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 バケット、端末メーカー独自のタスク終了機能などのシステムの電源管理機能は、バックグラウンドのスケジューリングにどのような影響を与えますか。また、WorkManager を使って耐障害性のある同期エンジンをどのように設計しますか?

システムのバッテリー最適化は、バックグラウンド実行を厳しく制限します。Doze モードは非アクティブな期間に CPU(中央処理装置)、ネットワークアクセス、バックグラウンドジョブを制限し、通常のバックグラウンド処理の多くをメンテナンスウィンドウまで延期します。ただし、文書化された例外があります。App Standby バケットは、最近のアプリ利用状況に応じて、Active から Restricted や Never までの区分で、ジョブ頻度とネットワークアクセスを動的に制限します。さらに、端末メーカー独自の積極的な電源管理機能(MIUI や OneUI など)は、AOSP(Android のオープンソース基盤)の標準動作とは異なり、バックグラウンドプロセスを頻繁に終了したり、標準のアラームを無視したり、自動起動の権限を取り消したりします。 耐障害性のある同期エンジンに対し、WorkManager はプラットフォームの制約と連携した永続的なスケジューリングを提供します。ただし、Doze、割り当て枠、強制停止、メーカー独自の制限を回避するものではありません。`NetworkType.CONNECTED` や `requiresBatteryNotLow(true)` などの厳格な実行条件を宣言し、Doze のメンテナンスウィンドウや接続の復旧まで実行を自動的に延期できます。 予期しないプロセス終了、一時的な接続断、メーカー機能による終了に耐えるには、指数バックオフとエンドツーエンドの冪等性が重要です。通常は指数バックオフなど適切な再試行方針を使い、待ち時間のランダムな揺らぎとサーバーのレート制限も考慮します。指数的に待ち時間を延ばすだけでは、再試行が一斉に集中することを防げません。 ワーカーでは、ローカルのチェックポイントを不可分に更新し、リモート操作を冪等にします。ローカルストレージとサーバーを一つの不可分なトランザクションで扱えるとは想定しません。決定的に生成するトランザクション識別子、ローカルの状態フラグ、サーバー側の重複排除キーを使うことで、途中でワーカーが終了し再度キューに入っても、再試行によるデータの重複やローカルデータベースの破損を防ぎます。

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)を介して具体的な実装を結合します。このパターンは、安定した API(公開インターフェース)の境界と小さなコンパイルクラスパスによって、再コンパイルを減らせる可能性があります。ただし、モジュールの追加や結線の負担と比較して効果を測定する必要があります。エンジニアが `:feature:impl` 内の実装詳細を変更しても、`:feature:api` の公開 ABI(バイナリインターフェース)は変化しません。コンパイルの入力が変わらない利用側では、再コンパイルを避けられます。ただし、実装モジュールとアプリのパッケージングには、変更の影響を受けた処理が引き続き必要です。アノテーションプロセッサー、生成コード、公開境界に漏れた型によって、再実行が必要な範囲が広がる場合もあります。ビルドキャッシュはタスクの出力を再利用します。一方、構成キャッシュは構成済みのタスクグラフを再利用する別の仕組みです。組織統治の観点からも、このパターンはCODEOWNERSなどのツールを用いてチーム間の明確な責務境界を確立します。チームは非公開クラスを外部に晒すことなく内部実装を安全に変更できるため、依存関係チェックで境界を強制すれば、意図しない結合を減らせます。ただし、依存グラフを管理しなければ API モジュール間でも循環は発生し得ます。

// :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 コーチを使ってこの質問に答えてみる