Mobile Developer の面接対策

Mobile Developer の面接問題

よく出る Mobile Developer の面接問題20問。モバイル開発は、信頼性の高い iOS、Android、クロスプラットフォームアプリを構築し、プロダクト、性能、リリース、オフライン、デバイス連携の課題を解決する、需要の高い分野です。問題は難易度別に用意されており、面接トレーナーで声に出して回答の練習ができます。

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

初級者向け質問

1SharedPreferencesやUserDefaultsのような標準的なキー・バリューストアに認証トークンを保存する場合と、Android KeystoreやiOS Keychainのようなプラットフォームのセキュアストレージに保存する場合の違いは何ですか?

AndroidのSharedPreferencesやiOSのUserDefaultsといった標準的なストレージ機構は、軽量で機密性の高くない設定情報を対象に設計されています。これらはアプリケーションのサンドボックスディレクトリ内に、暗号化されていない平文ファイル(XMLまたはplist)としてデータを保存します。そのため、ルート化や脱獄(Jailbreak)された端末への物理的アクセス権、暗号化されていないバックアップ、またはファイルシステムへのアクセス権があれば、誰でもトークンを直接読み取ることが可能です。 対照的に、プラットフォームのセキュアストレージ(具体的にはiOS Keychainや、EncryptedSharedPreferencesなどを通じて利用されるAndroid Keystore)は保存データの暗号化を提供します。iOS Keychainは端末のハードウェア(Secure Enclaveなど)に紐づく鍵を用いて保存項目を暗号化し、きめ細かなアクセスポリシーを設定できます。Android Keystoreは、ハードウェア的に隔離されたセキュリティモジュール(TEE: Trusted Execution Environment や StrongBox)内部で暗号鍵を生成・保持するため、暗号鍵がアプリケーションメモリ上に露出せず、ファイルシステムから抽出されることも防ぎます。

// --- ANDROID ---
// INSECURE (Plain SharedPreferences):
val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("auth_token", token).apply() // Plaintext XML on disk

// SECURE (EncryptedSharedPreferences backed by Android Keystore):
val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()
val securePrefs = EncryptedSharedPreferences.create(
    context,
    "secure_prefs",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
securePrefs.edit().putString("auth_token", token).apply()

// --- IOS (Swift) ---
// INSECURE:
UserDefaults.standard.set(token, forKey: "auth_token") // Plain plist on disk

// SECURE (Keychain Services API):
let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: "user_auth_token",
    kSecValueData as String: token.data(using: .utf8)!,
    kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
]
SecItemAdd(query as CFDictionary, nil)
AI コーチを使ってこの質問に答えてみる

2モバイル向けCI/CD (Continuous Integration/Continuous Delivery) パイプラインの主な目的は何ですか?また、Webやバックエンドのデプロイパイプラインと区別される固有のステージや制約にはどのようなものがありますか?

モバイルCI/CDパイプラインの主な目的は、モバイルクライアントアプリケーションのビルド、テスト、署名、配信を自動化し、一貫した品質と再現性のあるリリースを維持することです。継続的インテグレーション(CI)は、自動ビルド、静的解析、ユニットテストを通じてコードの変更を検証します。継続的デリバリー/デプロイ(CD)は、バイナリアーティファクトをパッケージングして署名し、テスト配信トラック(TestFlightやFirebase App Distributionなど)やアプリストアへと配布します。 モバイルCI/CDは、主に以下の点でWebやバックエンドのパイプラインと異なります。 1. アーティファクトの種類: サーバー上で直接実行されたりコンテナイメージ内で動作したりするのではなく、コンパイル済みのクライアントバイナリ(.ipa、.apk、.aab)を生成します。 2. ランナーのハードウェア制約: iOSのコンパイルにはmacOSハードウェアとXcodeコマンドラインツールが必要ですが、バックエンドのパイプラインは通常、軽量なLinuxコンテナ上で実行されます。 3. リリースの遅延と審査: エンドユーザーへの公開にはサードパーティのアプリストア審査プロセス(Apple App Store / Google Play Store)が伴うため、サーバーの再デプロイのように即座にロールバックすることはできません。修正には署名された新しいバイナリの再提出、または実行時のフィーチャーフラグが必要となります。

name: Mobile CI/CD Workflow

on:
  pull_request:
    branches: [main]
  push:
    tags:
      - 'v*.*.*'

jobs:
  ci_validation:
    name: Lint & Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Linter
        run: ./gradlew lint
      - name: Run Unit Tests
        run: ./gradlew testDebugUnitTest

  cd_release_ios:
    name: Build & Distribute iOS
    needs: ci_validation
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - name: Install Signing Certificates
        run: fastlane match appstore --readonly
      - name: Build & Upload to TestFlight
        run: fastlane ios beta
AI コーチを使ってこの質問に答えてみる

3クロスプラットフォームモバイルアプリケーションにおいて、プレゼンテーション層、ドメイン層、データ層を分離する目的は何ですか?また、この構造はiOSとAndroid間でのコード共有をどのように促進しますか?

アプリケーションをプレゼンテーション層、ドメイン層、データ層に分割することで、明確な責務の境界が確立されます。 1. **プレゼンテーション層:** UIの描画、ユーザー入力の処理、画面レベルの状態管理を担います(例: View、Widget、ViewModel、Presenter)。 2. **ドメイン層:** 純粋なビジネスロジック、ドメインモデル・エンティティ、ユースケースをカプセル化します。UIやプラットフォームのフレームワークから完全に独立しています。 3. **データ層:** リポジトリやデータソースを介して、リモートAPI、ローカルデータベース、デバイスキャッシュからのデータ取得および永続化を管理します。 クロスプラットフォームのモバイル開発において、このアーキテクチャはコード共有を大幅に促進します。ドメイン層とデータ層がプラットフォーム非依存であるためです。中核となるビジネスルール、データ変換、ネットワーク通信処理はiOSとAndroidの間で100%共有できます。これにより、FlutterやReact Nativeのようにスタック全体を共有することも、Kotlin Multiplatformのようにプラットフォーム固有のネイティブプレゼンテーション層を維持しつつビジネス・データロジックのみを共有することも可能になります。

[Presentation Layer] (UI, Screens, ViewModels)
         │
         ▼ calls
[Domain Layer]       (Use Cases, Business Rules, Entities)
         ▲
         │ implements interfaces
[Data Layer]         (Repositories, API Clients, Local Storage)
AI コーチを使ってこの質問に答えてみる

4モバイル開発における命令型と宣言型のUI(User Interface)パラダイムの主な違いは何ですか。また、FlutterのウィジェットとReact Nativeのコンポーネントは宣言型モデルをどのように体現していますか?

命令型UI開発では、開発者がUI要素を作成、変更、破棄するための具体的な手順を段階的に記述します(IDでビューを検索し、`setText`や`setVisibility`などのメソッドを直接呼び出すなど)。対照的に、宣言型UIモデルでは、特定の状態に対してUIがどのように見えるべきかを記述します(多くの場合、`UI = f(state)`と表現されます)。状態が変化すると、フレームワークが表示を効率的に更新する方法を決定します。FlutterとReact Nativeはいずれもこの宣言型パラダイムを採用しています。Flutterでは、ウィジェットはイミュータブル(変更不能)な構成の記述です。動的なデータが変化した際に`StatefulWidget`内で`setState()`を呼び出すと再ビルドがスケジュールされ、`build()`メソッドが新しいウィジェットツリーの記述を返します。Flutterはこれを`Element`ツリーおよび`RenderObject`ツリーと照合(リコンシリエーション)します。React Nativeでは、コンポーネントはJSXを返す関数またはクラスです。stateやpropsが更新されると再レンダリングがトリガーされ、Reactが仮想要素ツリーを照合し、ブリッジまたはネイティブランタイムを介して必要最小限のネイティブ更新を適用します。

class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});
  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int _count = 0;

  void _increment() {
    setState(() {
      _count++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: _increment,
      child: Text('Count: $_count'),
    );
  }
}
AI コーチを使ってこの質問に答えてみる

5モバイルアプリケーションにおけるコールドスタート、ウォームスタート、ホットスタートの動作上の違いは何ですか?また、なぜコールドスタートがパフォーマンス予算において最も重要な指標とされるのですか?

モバイルアプリのライフサイクルにおいて、起動状態はアプリのプロセスおよびメモリ状態がOS上に既に存在するかどうかによって異なります。 - コールドスタート: OSがプロセスを作成していないか、プロセスが過去に強制終了された状態からアプリが起動します。OSは新規プロセスを割り当て、バイナリやランタイムをロードし、Application/ランタイムコンテキストを初期化して最初のビューをインフレート(生成)する必要があります。最もレイテンシが大きくなります。 - ウォームスタート: アプリのプロセスは通常すでにメモリ上に残っていますが、バックグラウンドでの再作成や戻るナビゲーションなどにより、アクティビティやビュー階層が破棄または退避された状態です。OSはプロセスを一からフォーク/生成することなく、UIやアクティビティを再作成します。 - ホットスタート: アプリとそのUI状態がすべてメモリ上に完全に保持されている状態です(ユーザーがホームボタンを押してすぐに戻った場合など)。OSは既存のビュー階層をフォアグラウンドに復帰させるだけであり、初期化のオーバーヘッドはほぼゼロです。 コールドスタートはユーザーにとって最も遅い第一印象の体験となるため、パフォーマンス予算において最も重要な指標です。コールドスタートの遅延は、即座の離脱、継続率の低下、およびアプリストアでの評価悪化(Android Vitalsのしきい値など)に直結します。

Cold Start: [OS Process Spawn] -> [App Runtime Init] -> [Activity/UI Creation] -> [First Frame Drawn]
Warm Start: [Existing Process]  -> [Activity/UI Recreation] -> [First Frame Drawn]
Hot Start:  [Existing Process]  -> [Bring Existing View to Foreground] (Instant)
AI コーチを使ってこの質問に答えてみる

6クロスプラットフォームのモバイルアプリにおけるネイティブブリッジとは何ですか。また、機能を完全にFlutterやReact Nativeのコードで記述するのではなく、ブリッジを使用するのはどのような場合ですか。

ネイティブブリッジとは、クロスプラットフォームのコードからiOSやAndroidのプラットフォーム固有コードを呼び出したり、ネイティブコードから処理結果を返したりイベントを送信したりできるようにする通信レイヤーです。Flutterではプラットフォームチャンネルやプラグインによって実現されるのが一般的です。React Nativeではネイティブモジュール、TurboModules、またはネイティブUIコンポーネントによって実現されます。ブリッジは、プラットフォームAPI、デバイスのハードウェア機能、ネイティブSDK、パフォーマンスが重視されるネイティブ実装、ネイティブUIコンポーネントなど、純粋なFlutterやReact Nativeのコードでは十分に対応できないものを機能が必要とするときに使用します。代表的な例として、カメラ機能、Bluetooth、プッシュ通知、決済、セキュアストレージ、バックグラウンドサービス、ヘルスケアAPI、あるいはSwift/Objective-CやKotlin/Java向けの連携のみが提供されているSDKなどが挙げられます。優れたブリッジは通常、クロスプラットフォーム層に対して`getBatteryLevel`、`startBluetoothScan`、`openNativePaymentSheet`のような小さく明確なAPIを公開し、プラットフォーム固有の実装がiOSやAndroidの実際の詳細な処理を担当します。

Cross-platform screen:
  calls NativePayments.openPaymentSheet(orderId)

iOS implementation:
  uses Apple Pay / native payment SDK

Android implementation:
  uses Google Pay / native payment SDK
AI コーチを使ってこの質問に答えてみる

7モバイル開発におけるオフラインファーストアーキテクチャとは何を意味し、標準的なHTTP(Hypertext Transfer Protocol)レスポンスキャッシュとは根本的にどのように異なりますか?

モバイル開発において、オフラインファーストアーキテクチャはローカルストレージを読み込みおよび書き込み操作の両方における信頼できる唯一の情報源(Single Source of Truth)として扱います。画面描画やユーザーの操作許可をネットワークリクエストの完了まで待つのではなく、アプリはローカルデータベースやストレージ層と直接やり取りし、接続が利用可能になった際にバックグラウンド同期プロセスがリモートサーバーとの変更の調整を処理します。これは標準的なHTTPレスポンスキャッシュと主に以下の点で根本的に異なります。 1. **データモデル**: HTTPキャッシュはリクエストURLやヘッダーをキーとして未加工のネットワークレスポンス(JSONペイロードなど)を保存します。オフラインファーストアーキテクチャでは、ローカルデータベース(Room、SQLite、SwiftData/Core Dataなど)に構造化されたドメインエンティティを保存します。 2. **書き込みと読み込み**: HTTPキャッシュは主に読み込みを最適化する仕組みであり、オフラインでの書き込みトランザクションや更新をネイティブにはサポートしていません。オフラインファーストでは完全なローカル書き込みが可能であり、後でサーバーと同期するために変更をキューに登録します。 3. **クエリ機能とライフサイクル**: キャッシュされたHTTPレスポンスは自動キャッシュ破棄ポリシーの対象となり、任意のクエリ実行、フィルタリング、結合は行えません。オフラインファーストの永続化では、ネットワークの状態に依存せず、柔軟なクエリ実行、インデックス作成、決定論的なライフサイクル管理が提供されます。

/* Network-First with HTTP Caching */
UI -> Network Request -> [Cache Hit ? Return Cached JSON : Fetch from API -> Save to HTTP Cache] -> UI
// Limitation: Read-only optimization; offline writes fail immediately.

/* Offline-First with Local Source of Truth */
UI <-> Observes Local DB (e.g., Room / Core Data / SQLite)
User Write -> Write to Local DB -> Queue Sync Task
Sync Worker (Background) -> Send queued mutations to Server -> Update Local DB with Server Response
AI コーチを使ってこの質問に答えてみる

8モバイルアプリにおけるリモートプッシュ通知とローカル通知の違いは何ですか?

ローカル通知とリモートプッシュ通知の根本的な違いは、通知の発生元(起点)とトリガーの方法にあります。 1. ローカル通知: OSのAPI(iOSの UserNotifications、Androidの AlarmManager、WorkManager、NotificationManager など)を使用し、アプリによってデバイス上で完全に生成、スケジュール、およびトリガーされます。特定の日時、カウントダウンタイマー、ジオフェンシング(地理的境界)といった端末側の条件に基づいてトリガーされます。OSのデーモン上でローカルに実行されるため、配信時にバックエンドサーバーやアクティブなインターネット接続は不要です。 2. リモートプッシュ通知: 外部のアプリケーションサーバーから送信され、プラットフォームごとのプッシュゲートウェイ(Apple端末向けは APNs [Apple Push Notification service]、Android向けは FCM [Firebase Cloud Messaging])を経由してインターネット経由で配信されます。ゲートウェイが端末のOSレベルのデーモンにペイロードを配信し、アプリを復帰(ウェイク)させたりバナーを表示したりします。チャットメッセージの受信、友達申請、速報ニュースなど、リアルタイムな外部イベントにはリモート通知が必要です。

let content = UNMutableNotificationContent()
content.title = "Workout Reminder"
content.body = "Time for your daily exercise routine."
content.sound = .default

// Triggers locally after 1 hour (3600 seconds) without any server interaction
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
let request = UNNotificationRequest(identifier: "dailyWorkout", content: content, trigger: trigger)

UNUserNotificationCenter.current().add(request) { error in
    if let error = error {
        print("Failed to schedule notification: \(error)")
    }
}
AI コーチを使ってこの質問に答えてみる

9アプリストア経由でアプリを配信する際、AAB(Android App Bundle)、APK(Android Package)、およびiOSのIPA(iOS App Store Package)アーカイブの間にはどのような構造上および運用上の違いがありますか?

APK(Android Package)は、コンパイル済みのDEXバイトコード、リソース、アセット、ネイティブライブラリを含む実行可能パッケージ形式であり、Android端末に直接インストールして実行できます。ユニバーサルAPKにはすべての画面密度、CPUアーキテクチャ、言語のリソースがまとめられているため、ダウンロードサイズが大きくなります。AAB(Android App Bundle)は、Google Playでのストア配信に必須のアップロードおよび公開用形式です。AABを端末に直接インストールすることはできません。代わりに、Google PlayがバンドルとPlay App Signingを利用して、特定の端末のアーキテクチャ、画面密度、言語に合わせて動的に最適化された分割APK(Split APKs)を生成します(Dynamic Delivery)。iOSのIPA(.ipa)はアプリケーションアーカイブファイルであり、Payloadディレクトリ、`.app`バンドル、署名アセット、メタデータを格納したZIPコンテナです。App Store Connectにアップロードされると、AppleがApp Thinning(App Slicingなど)を実行し、ダウンロードする端末に必要なアセットとバイナリのみを配信します。運用面では、APKとIPAは署名とプロビジョニングを満たしていれば物理テスト端末へ直接インストール可能ですが、AABはローカル端末へのインストール前にまず分割APKへと変換(`bundletool`などを利用)する必要があります。

# 1. Standalone APK installs directly via ADB
adb install myapp-universal.apk

# 2. AAB requires bundletool to generate device-tailored split APKs
bundletool build-apks --bundle=myapp.aab --output=myapp.apks --connected-device

# 3. Install the generated split APK set onto the connected device
bundletool install-apks --apks=myapp.apks
AI コーチを使ってこの質問に答えてみる

10TLS (Transport Layer Security) はモバイルアプリケーションのネットワーク呼び出しに対してどのような保護を提供しますか。また、現代のモバイルオペレーティングシステムはどのようにしてセキュアなトランスポートのデフォルト設定を強制していますか?

TLS (Transport Layer Security) は、主に3つの重要な保証を提供することでモバイルのネットワーク呼び出しを保護します。 - 機密性:転送中のデータを暗号化し、盗聴者がペイロードやヘッダーを読み取れないようにします。 - データの完全性:転送中のリクエストやレスポンスに対する改ざんや改変を検出します。 - サーバー認証:信頼できる認証局(CA)に照らしてサーバーのデジタル証明書を検証し、中間者攻撃(Man-in-the-Middle 攻撃)を防止します。 現代のモバイルオペレーティングシステムは、暗号化されていない平文の HTTP トラフィックをブロックすることで、デフォルトでセキュアなトランスポートを強制します。 1. iOS:App Transport Security (ATS) を強制し、Info.plist で明示的にドメイン例外が定義されていない限り、ネットワーク接続(URLSession 経由など)に TLS 1.2 以降の HTTPS の使用を義務付けます。 2. Android (API 28+):デフォルトで平文の HTTP トラフィックを無効化します(`cleartextTrafficPermitted=false`)。暗号化されていない HTTP を許可するには、ネットワークセキュリティ構成(`network_security_config.xml`)またはマニフェスト属性 `android:usesCleartextTraffic` を通じて明示的な例外を設定する必要があります。

<!-- Android: res/xml/network_security_config.xml -->
<network-security-config>
    <!-- Cleartext HTTP blocked by default. Exceptions require explicit declaration -->
    <domain-config cleartextTrafficPermitted="false">
        <domain includeSubdomains="true">api.example.com</domain>
    </domain-config>
</network-security-config>

<!-- iOS: Info.plist ATS configuration -->
<!-- ATS blocks HTTP by default; exceptions must be declared inside NSAppTransportSecurity -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <false/>
</dict>
AI コーチを使ってこの質問に答えてみる

中級者向け質問

11生体認証(Face ID / 指紋認証)に成功したときのみ暗号鍵のロックが解除されるように、プラットフォームのセキュアストレージと連携した生体認証をどのように実装しますか?

生体認証によって暗号鍵を安全に保護するには、生体認証プロンプトから返される単なる真偽値(boolean)のUIコールバックに依存してはなりません。代わりに、鍵の使用前に生体認証を強制するアクセスコントロールポリシーを設定した上で、ハードウェアベースのセキュアストレージ(iOSのSecure Enclave、AndroidのKeystoreやStrongBox)内に暗号鍵を生成・保管する必要があります。 iOSでは、`SecAccessControlCreateWithFlags` を使用し、`.biometryCurrentSet`(または `.userPresence`)などのフラグを指定して鍵やKeychain項目を構成します。署名や復号のために秘密鍵が要求されると、OSが自動的にユーザーへFace IDやTouch IDのプロンプトを表示します。 Androidでは、`KeyGenParameterSpec.Builder` を使用し、`.setUserAuthenticationRequired(true)` を設定して鍵を生成します。暗号化処理(`Cipher` や `Signature` など)を初期化して `BiometricPrompt.CryptoObject` としてラップし、`BiometricPrompt.authenticate()` に渡します。鍵は認証に成功した `onAuthenticationSucceeded` コールバック内において、認証済みの `CryptoObject` を経由してのみロック解除され、使用可能になります。 iOSの `.biometryCurrentSet` やAndroidの `setInvalidatedByBiometricEnrollment(true)` を設定することで、端末に新しい指紋や生体情報が追加登録された場合に既存の鍵を恒久的に無効化できます。これにより、デバイスのパスコードが漏洩した場合でも不正アクセスを防ぐことができます。

val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(
    "biometric_auth_key",
    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    .setUserAuthenticationRequired(true)
    .setInvalidatedByBiometricEnrollment(true)
    .build()

keyGen.init(spec)
keyGen.generateKey()
AI コーチを使ってこの質問に答えてみる

12クロスプラットフォーム対応のモバイルCI(Continuous Integration)ランナーにおいて、プルリクエストのビルド時間を大幅に短縮するためにどのようなキャッシュ戦略やパイプラインの最適化を導入できますか?

モバイルCIランナーにおけるプルリクエストのビルド時間を大幅に短縮するには、依存関係の解決、コンパイルのキャッシュ、および選択的実行を対象とした最適化を行う必要があります。 第1に、ロックファイルのハッシュ(例: `package-lock.json`、`Podfile.lock`、`gradle/verification-metadata.xml`、SPM(Swift Package Manager)の解決済みファイルなど)をキーにした効果的な依存関係キャッシュを導入します。これにより、クリーンなランナーインスタンス上で外部パッケージを再ダウンロードしたり再解決したりする処理を防ぎます。 第2に、ネイティブビルドキャッシュを設定します。Androidの場合は、リモートHTTPキャッシュまたは `~/.gradle/caches` やビルドディレクトリに対するCI固有の永続キャッシュと組み合わせ、Gradle Build Cache(`--build-cache`)を有効にします。iOSの場合は、Xcodeの `DerivedData` やSwift Package / CocoaPodsのコンパイル成果物を慎重にキャッシュするか、TuistやBazelなどの最新のリモートキャッシュツールを活用します。React NativeやFlutterの場合は、JSバンドル出力、node_modules、Flutter engine SDKのキャッシュを保存します。 第3に、ジョブの選択的実行(変更検知およびテスト影響分析)を適用します。Gitのパスフィルターを使用してAndroidやバックエンドのファイルのみが変更されたときはiOSビルドをスキップし、負荷の高いUIテストやコンパイル工程の前に軽量な静的解析(lint)や単体テストステージを実行します。さらに、シミュレータ/デバッグビルドや単体テストの実行のみで十分なPR実行では、完全なリリース用バイナリ(AABやユニバーサルIPAなど)の生成を回避します。

name: PR Fast Check
on:
  pull_request:
    paths:
      - 'android/**'
      - 'shared/**'

jobs:
  android-unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: 'zulu'
          java-version: '17'

      # Gradle build caching for dependencies & task outputs
      - uses: gradle/actions/setup-gradle@v3
        with:
          cache-read-only: false

      - name: Run Unit Tests & Lint Only
        run: ./gradlew testDebugUnitTest lintDebug --build-cache --parallel
AI コーチを使ってこの質問に答えてみる

13プレゼンテーション、ドメインのユースケース、およびデータアクセスを、iOSとAndroidの間で独立してテスト可能かつ再利用できるように維持するには、共有機能をどのように構造化しますか?

iOSとAndroidの間で独立したテスト容易性と再利用性を実現するために共有機能を構造化するには、プレゼンテーション層、ドメイン層、データ層に整理されたクリーンでレイヤードなアーキテクチャを採用します。 1. **ドメイン層(純粋な共有ロジック):** 純粋なビジネスエンティティ、リポジトリのインターフェイス(コントラクト)、およびユースケース/インタラクター(例: `GetCartUseCase`、`ApplyPromoCodeUseCase`)を含みます。この層はUIフレームワーク(UIKit、SwiftUI、Android Views、Jetpack Compose、Flutter widgets)やプラットフォームAPIへの依存関係を一切持ちません。各ユースケースは単一のビジネス操作をカプセル化するため、プレーンなモックを用いて100%単体テストが可能です。 2. **データ層(データアクセスとカプセル化):** ドメインのリポジトリインターフェイスを実装します。リモートデータソース(REST/GraphQL)およびローカルの永続化ストレージ(SQLite/キーバリューストア)とやり取りします。ネットワークのDTO (Data Transfer Object) やデータベースエンティティは、この層を出る前にクリーンなドメインエンティティへと厳格にマッピングされ、外部のシリアライズスキーマやデータベースの変更がドメイン層やUI層に漏洩するのを防ぎます。 3. **プレゼンテーション層(UIと状態管理):** 状態保持コンポーネント(ViewModel、Bloc、Presenterなど)とUIビュー/ウィジェットで構成されます。状態保持コンポーネントはドメインのユースケースを呼び出し、UI状態(ローディング、成功、エラー)を管理して、監視可能な状態をビューに公開します。ビューは単にこの状態を監視して描画します。 この構造により、UIなしでユースケースを分離して単体テストすること、リポジトリをモックしてViewModelをテストすること、そしてドメインとデータのロジック全体を再利用しながらプラットフォーム間でプレゼンテーションの実装を切り替えることが可能になります。

[ UI View / Compose / SwiftUI ]
         |
         v (Observes State / Dispatches Events)
[ ViewModel / Presenter / BLoC ] (Presentation Layer)
         |
         v (Executes pure business operations)
[ Use Case / Interactor ] (Domain Layer - Pure Shared Logic)
         |
         v (Calls interface contract)
[ Repository Interface ] (Domain Layer Contract)
         ^
         | (Implements)
[ Repository Implementation ] (Data Layer)
         |---- Maps NetworkDTO -> DomainEntity
         |---- Maps DatabaseEntity -> DomainEntity
    [ API Client / Local DB / Storage ]
AI コーチを使ってこの質問に答えてみる

14複数の開発者が携わるクロスプラットフォームのコードベースにおいて、ローカルなコンポーネント状態では不十分になるタイミングをどのように見極め、適切なアーキテクチャ上の状態管理アプローチを選択しますか?

クロスプラットフォームのモバイル開発において、ローカルなコンポーネント状態(`useState` や `StatefulWidget`)からアーキテクチャレベルの状態管理ソリューションへ移行する判断は、状態のスコープ、ライフサイクルの要件、およびテスト容易性に基づいて行います。 1. **ローカル状態では不十分なケース**: - **コンポーネント間の共有とバケツリレー(Prop Drilling)**: 異なるナビゲーション画面間や、ウィジェット/コンポーネントツリーの離れた階層間で状態へのアクセスや更新が必要な場合。 - **ライフサイクルの永続化**: 画面の破棄、画面遷移、ナビゲーションのリセットをまたいでデータを維持する必要がある場合(例: ユーザーセッション、カート情報、キャッシュされたフィードなど)。 - **関心の分離とテスト容易性**: ビジネスロジック、バリデーション、副作用がUIコンポーネントと密結合し、ヘッドレスな単体テストの自動化が困難になった場合。 2. **複数人の開発体制におけるアーキテクチャの選択**: - **単方向データフロー/予測可能なコンテナ(BLoC、Redux、Riverpodなど)**: UIが明示的なイベント/アクションを発行し、専用のビジネスロジック層から発行されるイミュータブルな状態を描画するという厳格な分離を強制します。明確なインターフェース規約が生まれ、副作用を減らし、ビジネスロジック単体でのテストが容易になります。 - **アトミック型/スコープ付きリアクティブストア(Zustand、MobX、Providerなど)**: ボイラープレートコードが少なく、セレクターによる柔軟な購読が可能なため、軽量な疎結合化や特定コンポーネントのみの再描画を重視するチームに適しています。 チーム開発における主な設計目標は、純粋なビジネスロジックをUI描画層から分離し、不要なツリー全体の再描画を防ぐために選択的な購読を行うことです。

// Zustand store: pure state container isolated from React UI hierarchy
import { create } from 'zustand';

interface CartState {
  items: string[];
  addItem: (item: string) => void;
  clearCart: () => void;
}

export const useCartStore = create<CartState>((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  clearCart: () => set({ items: [] }),
}));

// Usage in components uses selective subscriptions to avoid unnecessary re-renders:
// const itemCount = useCartStore((state) => state.items.length);
AI コーチを使ってこの質問に答えてみる

15画像や動的コンテンツの長いリストをスクロールする際にスタッター(画面のカクつき)が発生するクロスプラットフォームモバイルアプリ画面において、ジャンク(描画遅延)を調査し軽減するにはどうすればよいですか?

スクロール時のジャンク(描画遅延)は、フレームバジェットの超過、JSまたはDart側の処理、メイン/UIスレッド側の処理、GPU/ラスタライズ処理、レイアウト計算、画像のデコード、メモリ圧迫、ネットワーク読み込みなど複数の要因に起因するため、まずは実機でスタッターを再現してプロファイリングを実施します。画面更新レートが60Hzの場合は1フレームあたり約16.7ms、120Hzの場合は約8.3msの時間的猶予しかないため、スクロール中に負荷の高い描画、デコード、レイアウト、あるいは同期処理が実行されるとフレーム落ちが発生します。真のボトルネックを特定するために、Flutter DevTools、React NativeのパフォーマンスツールやFlipper、Android Studio Profiler、Xcode Instruments、およびフレームタイムラインビューなどのツールを使用します。長いリストに対しては、遅延読み込み(lazy loading)または仮想化(virtualization)が適用されていることを確認します(例: React NativeのFlatList、FlashList、RecyclerListView、またはFlutterのListView.builder、SliverListなどのビルダー)。また、安定したキーを使用し、親コンポーネントの状態変更時にすべての行が再構築・再レンダリングされるのを防ぎ、必要に応じて行コンポーネントやセレクタをメモ化し、buildやrenderItem内の処理を軽量に保ち、ソート、フィルタリング、JSON解析、フォーマット処理、画像処理などの処理をスクロールの処理パスから除外します。アイテムのサイズが予測可能な場合は、React NativeのgetItemLayoutやFlutterの固定/プロトタイプアイテム寸法などのレイアウトヒントを指定します。画像に関しては、適切なサムネイルサイズでの配信、キャッシュの活用、小さなセルに対するフル解像度画像のデコード回避、プレースホルダーや遅延読み込みの利用を徹底し、大量の大きなビットマップによるメモリ変動(churn)を監視します。さらに、過度に複雑な行レイアウトの簡素化、重いシャドウ・クリッピング・オーバードロー・不透明度の削減、データのバッチ取得やページネーションを実施した上で再度プロファイリングを行い、ドロップフレームやフレーム時間が改善したことを確認します。

1. Record a trace while scrolling on a real device.
2. Check frame timeline: are frames exceeding 16.7 ms / 8.3 ms?
3. Identify bottleneck: JS/Dart, main/UI thread, raster/GPU, image decode, memory, or network.
4. Fix the largest measured bottleneck: virtualization, image resizing/caching, row memoization, layout simplification, moving work off scroll path.
5. Re-test on low-end and target-refresh-rate devices.
AI コーチを使ってこの質問に答えてみる

16ネイティブブリッジの呼び出しにおいてスレッド境界はどのように機能しますか。また、ネイティブモジュールが重いバックグラウンド処理を実行する際に、スレッド切り替えによる停滞やUI (User Interface) のカクつき(jank)を防ぐにはどうすればよいですか?

クロスプラットフォームフレームワークでは、ブリッジ呼び出しに対して特定のスレッド規約が採用されています。例えば、標準的なFlutterの `MethodChannel` 呼び出しはプラットフォームのメインUIスレッドに到達しますが、従来のReact Nativeのブリッジ呼び出しは専用のJavaScriptスレッドで動作し、ネイティブスレッドへ非同期にディスパッチされます。ネイティブブリッジのメソッドがネイティブのUI/メインスレッド上で実行される場合、CPU負荷の高い処理、長時間の同期ディスクI/O、ブロッキングを伴うネットワーク処理を実行するとメインのランループがブロックされます。これによりフレーム落ちや目に見えるUIのカクつき(jank)が発生し、AndroidではANR (Application Not Responding)、iOSではウォッチドッグによるプロセス強制終了につながります。これを防止するには、ネイティブのブリッジハンドラ内で重い処理やブロッキング処理をバックグラウンド実行プール(例: `Dispatchers.IO`/`Dispatchers.Default` を使用したKotlin Coroutines、Androidの `ThreadPoolExecutor`、Swiftの `Task.detached` / GCDの `DispatchQueue.global()`)にオフロードする必要があります。処理が完了したら、結果をフレームワークが想定するブリッジスレッドへディスパッチして戻すことで(例: 標準のMethodChannel向けにUIスレッドで結果を返す、またはバックグラウンドタスクキューを使用する)、クロスプラットフォーム側の状態更新がUI描画サイクルをブロックしないようにします。

class ImageProcessorPlugin : MethodChannel.MethodCallHandler {
    private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())

    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
        if (call.method == "applyFilter") {
            val imageBytes = call.argument<ByteArray>("image") ?: return result.error("INVALID_ARG", "Image null", null)
            
            // Dispatch heavy computation off the main UI thread
            scope.launch {
                try {
                    val processed = processBitmapBytes(imageBytes) // CPU heavy work
                    withContext(Dispatchers.Main) {
                        result.success(processed)
                    }
                } catch (e: Exception) {
                    withContext(Dispatchers.Main) {
                        result.error("PROCESSING_FAILED", e.localizedMessage, null)
                    }
                }
            }
        } else {
            result.notImplemented()
        }
    }
}
AI コーチを使ってこの質問に答えてみる

17Stale-While-Revalidate と条件付き HTTP (Hypertext Transfer Protocol) ヘッダーを使用して、リスト画面やフィード画面のキャッシュおよび無効化戦略をどのように設計しますか?

フィードやリスト画面に対する耐障害性の高いキャッシュおよび無効化戦略では、Stale-While-Revalidate (SWR) パターンと条件付き HTTP 検証ヘッダー(`ETag` / `If-None-Match` や `Last-Modified` / `If-Modified-Since` など)を組み合わせます。画面が開いた際、信頼できる唯一の情報源(SSOT: Single Source of Truth、例えば Room や Core Data/SQLite)であるローカルデータベースからキャッシュされたデータを即座に読み込んで描画し、ユーザーに瞬時に UI を表示します。同時に、キャッシュされている `If-None-Match: <etag>` ヘッダーを付与したバックグラウンドネットワークリクエストを送信します。サーバーが `304 Not Modified` を返した場合、ペイロードは転送されず、帯域幅とバッテリーを節約しながらローカルキャッシュの有効性を確認できます。サーバーが `200 OK` を返した場合は、新しいデータと更新された `ETag` がトランザクション内でローカルデータベースに書き込まれ、リアクティブなデータベースオブザーバーが更新されたリストを自動的に UI へ通知・反映します。ページネーションについては、ページトークンやオフセットをキャッシュされたレコードとともに保存します。無効化は、TTL の期限切れ、ユーザーの操作(条件付きヘッダーをバイパスする pull-to-refresh など)、またはローカルでの変更による副作用(例: アイテムの作成や削除時にローカルデータベースを楽観的に更新し、キャッシュされたページカーソルに再検証フラグを立てるなど)によってトリガーされます。

fun getFeed(): Flow<List<FeedItem>> = flow {
    val cached = feedDao.getFeedItems()
    if (cached.isNotEmpty()) emit(cached)
    
    val lastEtag = feedDao.getFeedEtag()
    try {
        val response = api.fetchFeed(ifNoneMatch = lastEtag)
        if (response.code() == 200 && response.body() != null) {
            feedDao.updateFeedTransaction(response.body()!!, response.headers()["ETag"])
            emit(feedDao.getFeedItems())
        }
    } catch (e: Exception) {
        if (cached.isEmpty()) throw e
    }
}
AI コーチを使ってこの質問に答えてみる

上級者向け質問

18金融系モバイルアプリケーションにおいて、リフレッシュトークンを端末上に安全に保存し、生体認証によるロック解除をサポートし、OS(Operating System)アップデート後も維持され、一時的なオフラインセッションでも安全に動作させる必要があります。このトークン保存およびアクセスのアーキテクチャをどのように設計しますか?

堅牢なモバイルトークン保存アーキテクチャでは、ハードウェア支援によるセキュリティモジュールを採用します。具体的には、Secure Enclaveに支えられたiOS Keychainや、StrongBoxまたはTEE(Trusted Execution Environment)に支えられたAndroid Keystoreです。リフレッシュトークンは、生体認証による暗号化ゲーティング(iOSの `kSecAccessControlBiometryCurrentSet` や `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`、Androidの `AUTH_BIOMETRIC_STRONG` を指定した `setUserAuthenticationRequired(true)` など)で保護された鍵を用いて保存時に暗号化する必要があります。暗号化ゲーティングにより、アプリコード内のバイパス可能な真偽値チェックに依存するのではなく、ハードウェアが生体認証の成功時にのみ暗号鍵をアンラップ(復号)することが保証されます。OSアップデート後も維持しつつ不正アクセスを防止するため、鍵をデバイスのハードウェアにバインドし、新しい生体情報が登録された場合に無効化または再認証を促す登録変更ポリシーを適用します(例:Androidの `setInvalidatedByBiometricEnrollment(true)`)。一時的なオフラインセッションに対しては、有効期限の短い暗号化アクセストークンと権限が限定されたローカルオフラインセッション状態を定義されたローカルTTLとスコープ権限の範囲内で運用し、特権を要するリフレッシュ処理はネットワーク接続が回復するまで延期します。再接続時には、バックエンド側での1回限りの再生検知を伴うリフレッシュトークンローテーションによってセッションを検証します。トークンの再利用やアカウントの失効が検出された場合、バックエンドはクライアントにハードウェア保護された鍵の破棄とオフラインストレージの消去をトリガーする無効化シグナルを返します。

val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(
    "refreshTokenKeyAlias",
    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    .setUserAuthenticationRequired(true)
    .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
    .setInvalidatedByBiometricEnrollment(true)
    .build()
keyGenerator.init(spec)
keyGenerator.generateKey()
AI コーチを使ってこの質問に答えてみる

19複数のチームが開発に参加するクロスプラットフォームのモバイルモノレポにおいて、リソースが限られ高コストなmacOSランナーのキャパシティを管理しながら、スケーラブルなCI/CD (Continuous Integration/Continuous Delivery) インフラストラクチャをどのように設計しますか?

希少で高価なmacOSのキャパシティを最適化しつつ、クロスプラットフォームのモバイルモノレポ向けにスケーラブルなCI/CDインフラを設計するには、ビルドグラフに基づく変更検出、ハイブリッドなランナー構成による厳格なワークロードの分離、およびmacOSの仮想化/エフェメラル(一時的)なオーケストレーションを軸としたアーキテクチャが必要です。 第一に、分散リモートキャッシュを備えたモノレポビルドグラフツール(Bazel、Nx、Turborepoなど)を導入します。プルリクエストごとに、CIパイプラインが対象ブランチに対する有向非巡回グラフ(DAG)の差分を計算し、変更の影響を受けるパッケージとその下流の依存関係のみをビルドおよびテスト対象とすることで、変更のないモジュールを完全にスキップします。 第二に、ハイブリッドランナー割り当て戦略を実施します。Xcodeを必須としないすべてのタスク(TypeScript/Dartの静的解析やコンパイル、単体テスト、静的コード解析、セキュリティスキャン、AndroidのGradleビルドなど)を、コスト効率が高くスケーラブルなLinux/Kubernetesランナーにオフロードします。macOSランナーは、最終的なiOSのアセンブリ、Swift/Objective-Cのコンパイル、コード署名、およびiOSシミュレータでのテスト実行にのみ厳格に限定して割り当てます。 第三に、仮想化インフラ(NomadまたはKubernetes経由でオーケストレーションされたTart、Anka、AWS/MacStadiumのベアメタルノードなど)を使用してmacOSランナーを管理します。各iOSビルドは、事前ウォームアップ済みのツールチェーンとDerivedDataキャッシュを持つクリーンなエフェメラル仮想マシン上で実行されるため、ランナーの状態ドリフトを防ぎ、パイプラインのキュー深度に応じた迅速なオートスケーリングが可能になります。

name: Cross-Platform Monorepo CI
on: [pull_request]
jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      ios_affected: ${{ steps.filter.outputs.ios }}
      android_affected: ${{ steps.filter.outputs.android }}
    steps:
      - uses: actions/checkout@v4
      - id: filter
        run: |
          # Determine affected targets via monorepo tool (e.g. nx / bazel)
          echo "ios=$(./tools/affected.sh ios)" >> $GITHUB_OUTPUT
          echo "android=$(./tools/affected.sh android)" >> $GITHUB_OUTPUT

  build-android:
    needs: detect-changes
    if: needs.detect-changes.outputs.android_affected == 'true'
    runs-on: ubuntu-latest-16core
    steps:
      - uses: actions/checkout@v4
      - run: ./gradlew assembleRelease

  build-ios:
    needs: detect-changes
    if: needs.detect-changes.outputs.ios_affected == 'true'
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - run: bundle exec fastlane ios build_and_test
AI コーチを使ってこの質問に答えてみる

20iOSとAndroidの間で大半のビジネスロジックを共有しつつ、プラットフォーム固有のUI(User Interface)、ナビゲーション、ネイティブ連携を独立して進化させられるクロスプラットフォームモバイルアーキテクチャをどのように設計しますか?

アプリの中心に、ドメインモデル、バリデーション、ユースケース、ビジネスルール、データアクセスの契約など、安定したビジネス振る舞いを担う共通コアを配置して設計します。プラットフォーム固有のUI、ナビゲーション、ネイティブSDK、パーミッション、ライフサイクル管理、アプリシェルの関心事は、そのコアの外側に保つべきです。共有コードにはUIKit、SwiftUI、Jetpack、AndroidフレームワークAPI、React Nativeのナビゲーション、Flutterのナビゲーション、ネイティブSDKの詳細を決してインポートしてはなりません。代わりに、各プラットフォームが実装するAuthRepository、SecureStorage、Analytics、CameraService、PaymentProviderのような限定的なインターフェイスに依存させます。1つの巨大な共有レイヤーを作るのではなく、可能な限り機能単位でコードを構成します。例えば、チェックアウト、検索、アカウント、メッセージングの各機能が、独自の共有ドメイン/ユースケースコード、状態の契約、リポジトリインターフェイスを持ちます。そしてiOSとAndroidのシェルが、画面の組み立て、ネイティブUIパターン、ナビゲーションスタック、パーミッション、アダプタの結合を担当します。プレゼンテーションロジックは、単純な状態とアクションを発行するリデューサー、ステートマシン、ビューモデルの契約のように、真にUIフレームワークに依存しない場合に限り共有できますが、実際のビューやナビゲーションはプラットフォーム側が所有し続け、それぞれが独立して進化できるようにすべきです。主なトレードオフは、共通で安定したビジネスロジックを積極的に共有しつつ、プラットフォームの実質的な差異を覆い隠してしまうような抽象化を避けることです。モジュールの依存関係ルール、依存性の注入(DI)、パブリックAPI、契約テスト、アーキテクチャ検証、明確な責務分担によって境界を強制します。ネイティブ連携にはポート/アダプタパターンを採用し、共有機能コードからは共通の機能として見せつつ、各プラットフォームがSDKの挙動、ライフサイクル、パーミッション、エラー、UXの違いを自身のレイヤーで処理できるようにします。

shared-core/
  checkout/
    domain/          # Money, Cart, CheckoutRules, PlaceOrderUseCase
    ports/           # PaymentGateway, TaxRepository, Analytics
    state/           # CheckoutStateMachine or ViewModel contract

ios-app/
  checkout-ui/       # SwiftUI/UIKit screens and iOS navigation
  adapters/          # ApplePayPaymentGateway, KeychainStorage

android-app/
  checkout-ui/       # Compose/XML screens and Android navigation
  adapters/          # GooglePayPaymentGateway, EncryptedSharedPrefsStorage
AI コーチを使ってこの質問に答えてみる