iOS アプリと Android アプリ、それぞれに AI 機能を実装しようとすると、同じロジックを Swift と Kotlin で二度書くことになります。私が実際にそれをやってみて感じたのは、「コードの重複よりも、バグ修正や API 変更への対応を2プラットフォームで同時にやる消耗感」でした。
Kotlin Multiplatform(KMP)は、そのコスト構造を根本から変えます。Gemini API を呼ぶロジック、レスポンスのパース、エラーハンドリング、状態管理——これらを共通モジュールに一度書けば、iOS も Android も同じコードで動きます。ネイティブ UI の体験は損なわずに。
ここで扱うのはKMP プロジェクトへの Gemini API 統合を、セットアップから本番デプロイまで動作確認済みのコードで解説します。
KMP + Gemini を選ぶ理由と、避けるべき落とし穴
なぜ Flutter や React Native ではないのか
クロスプラットフォーム開発の選択肢として Flutter や React Native も有力です。ただ、AI 機能を本格的に組み込む際にはそれぞれ課題があります。
Flutter は Dart での Gemini SDK アクセスが限定的で、Google が提供する公式 SDK(Python・JS・Swift・Kotlin)との相性が良くありません。Google が Dart SDK を提供していますが、機能追加が遅れがちです。
React Native は JavaScript ベースで、Gemini の JS SDK をそのまま使えるメリットがありますが、ネイティブのカメラ連携や画像処理パフォーマンスに課題が残ります。
KMP は Kotlin の REST API クライアント(Ktor)を共通モジュールで使い、iOS/Android どちらからも同じビジネスロジックを呼べる 点が決定的な優位性です。SwiftUI と Jetpack Compose それぞれでネイティブ UI を書きながら、AI の呼び出し部分は一箇所にまとめられます。
KMP が本当に向いている AI アプリの特徴
テキスト生成・チャット・要約など、ネットワーク越しの Gemini API 呼び出しが中心
iOS と Android で同じビジネスロジックを持つ(異なるのは UI だけ)
将来的に Desktop(macOS、Windows)や Web への展開を視野に入れている
逆に、デバイス上での推論(Gemini Nano)や、プラットフォーム固有の ML フレームワーク連携が必要な場合は、ネイティブ実装の方が適しています。
プロジェクトセットアップ
必要な環境
Android Studio Giraffe 以降(KMP プラグイン対応版)
Xcode 15 以降(iOS ビルドに必要)
JDK 17 以降
Gemini API キー(Google AI Studio で無料取得)
Gradle の設定
新しい KMP プロジェクトは Android Studio の「New Project → Kotlin Multiplatform App」ウィザードで作成できます。生成されたプロジェクトに Gemini API を組み込むための shared/build.gradle.kts の設定は次の通りです。
// shared/build.gradle.kts
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias (libs.plugins.kotlinMultiplatform)
alias (libs.plugins.androidLibrary)
kotlin ( "plugin.serialization" ) version "2.0.0"
}
kotlin {
androidTarget {
@OptIn (ExperimentalKotlinGradlePluginApi:: class )
compilerOptions {
jvmTarget. set (JvmTarget.JVM_11)
}
}
listOf (
iosX64 (),
iosArm64 (),
iosSimulatorArm64 ()
). forEach { iosTarget ->
iosTarget.binaries. framework {
baseName = "Shared"
isStatic = true
}
}
sourceSets {
commonMain. dependencies {
// Ktor: HTTP クライアント(KMP 全プラットフォーム対応)
implementation ( "io.ktor:ktor-client-core:2.3.12" )
implementation ( "io.ktor:ktor-client-content-negotiation:2.3.12" )
implementation ( "io.ktor:ktor-serialization-kotlinx-json:2.3.12" )
// Kotlinx Serialization(JSON パース)
implementation ( "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1" )
// Coroutines
implementation ( "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1" )
}
androidMain. dependencies {
// Android 向けの Ktor エンジン
implementation ( "io.ktor:ktor-client-okhttp:2.3.12" )
implementation ( "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" )
}
iosMain. dependencies {
// iOS 向けの Ktor エンジン(Darwin = Apple プラットフォーム)
implementation ( "io.ktor:ktor-client-darwin:2.3.12" )
}
}
}
なぜ Ktor か : Gemini の公式 Kotlin SDK(com.google.ai.client.generativeai)は Android/JVM 専用で、iOS を含む共通モジュールからはそのままでは使えません。Ktor + Gemini REST API の直接呼び出しにすることで、iOS・Android・Desktop 全プラットフォームで動作します。
共通モジュールの AI ロジック設計
Repository パターンで API 依存を隔離する
共通モジュールに AI ロジックを集約する際、最も重要なのは「API の詳細をビジネスロジックから隔離する」設計です。インターフェースを先に定義し、実装クラスを差し替えられる構造にします。
// shared/src/commonMain/kotlin/ai/GeminiRepository.kt
interface GeminiRepository {
suspend fun generateText (prompt: String ): Result < String >
suspend fun chat (
history: List < ChatMessage >,
message: String
): Result < String >
suspend fun analyzeImage (
imageBytes: ByteArray ,
prompt: String
): Result < String >
}
data class ChatMessage (
val role: MessageRole ,
val content: String
)
enum class MessageRole { USER, MODEL }
テスト時はモック実装に差し替えられ、将来 Gemini 以外のモデルに切り替えても上位レイヤーは変更不要になります。
実装クラス:Ktor + Gemini REST API
// shared/src/commonMain/kotlin/ai/GeminiRepositoryImpl.kt
import io.ktor.client. *
import io.ktor.client.call. *
import io.ktor.client.plugins.contentnegotiation. *
import io.ktor.client.request. *
import io.ktor.http. *
import io.ktor.serialization.kotlinx.json. *
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
class GeminiRepositoryImpl (
private val apiKey: String ,
private val modelName: String = "gemini-2.5-flash"
) : GeminiRepository {
private val baseUrl =
"https://generativelanguage.googleapis.com/v1beta"
private val client = HttpClient {
install (ContentNegotiation) {
json ( Json {
ignoreUnknownKeys = true
isLenient = true
})
}
}
override suspend fun generateText (prompt: String ): Result < String > =
runCatching {
val response = client. post (
" $baseUrl /models/ $modelName :generateContent?key= $apiKey "
) {
contentType (ContentType.Application.Json)
setBody (
GenerateRequest (
contents = listOf (
Content (parts = listOf ( Part (text = prompt)))
)
)
)
}
val result: GenerateResponse = response. body ()
result.candidates. firstOrNull ()?.content?.parts
?. firstOrNull ()?.text
?: error ( "レスポンスが空でした" )
}
override suspend fun chat (
history: List < ChatMessage >,
message: String
): Result < String > = runCatching {
val contents = history. map { msg ->
Content (
role = msg.role.name. lowercase (),
parts = listOf ( Part (text = msg.content))
)
} + Content (
role = "user" ,
parts = listOf ( Part (text = message))
)
val response = client. post (
" $baseUrl /models/ $modelName :generateContent?key= $apiKey "
) {
contentType (ContentType.Application.Json)
setBody ( GenerateRequest (contents = contents))
}
val result: GenerateResponse = response. body ()
result.candidates. firstOrNull ()?.content?.parts
?. firstOrNull ()?.text
?: error ( "チャット応答が空でした" )
}
override suspend fun analyzeImage (
imageBytes: ByteArray ,
prompt: String
): Result < String > = runCatching {
// ByteArray を Base64 エンコード(KMP 対応)
val base64 = imageBytes. encodeBase64String ()
val response = client. post (
" $baseUrl /models/ $modelName :generateContent?key= $apiKey "
) {
contentType (ContentType.Application.Json)
setBody (
GenerateRequest (
contents = listOf (
Content (
parts = listOf (
Part (
inlineData = InlineData (
mimeType = "image/jpeg" ,
data = base64
)
),
Part (text = prompt)
)
)
)
)
)
}
val result: GenerateResponse = response. body ()
result.candidates. firstOrNull ()?.content?.parts
?. firstOrNull ()?.text
?: error ( "画像解析の結果が空でした" )
}
}
// --- JSON シリアライズ用データクラス ---
@Serializable
data class GenerateRequest ( val contents: List < Content >)
@Serializable
data class Content (
val role: String = "user" ,
val parts: List < Part >
)
@Serializable
data class Part (
val text: String ? = null ,
val inlineData: InlineData ? = null
)
@Serializable
data class InlineData ( val mimeType: String , val data : String )
@Serializable
data class GenerateResponse (
val candidates: List < Candidate > = emptyList ()
)
@Serializable
data class Candidate ( val content: Content )
API キーのセキュアな管理
KMP で最も多い失敗が「API キーをコードに直書きする」ことです。GitHub にプッシュした瞬間に漏洩し、悪用されると予期しない高額請求につながります。
推奨の三段構成:local.properties(開発時)→ BuildConfig 経由 → 暗号化ストレージ(実行時)
// expect/actual でプラットフォーム固有のストレージを抽象化
// shared/src/commonMain/kotlin/security/SecureStorage.kt
expect class SecureStorage {
fun getString (key: String ): String ?
fun putString (key: String , value : String )
}
// shared/src/androidMain/kotlin/security/SecureStorage.android.kt
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
actual class SecureStorage ( private val context: Context ) {
private val prefs by lazy {
val masterKey = MasterKey. Builder (context)
. setKeyScheme (MasterKey.KeyScheme.AES256_GCM)
. build ()
EncryptedSharedPreferences. create (
context, "secure_prefs" , masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
actual fun getString (key: String ): String ? =
prefs. getString (key, null )
actual fun putString (key: String , value : String ) {
prefs. edit (). putString (key, value ). apply ()
}
}
// iosApp/iosApp/Security/SecureStorageIOS.swift
// iOS 側は Swift で Keychain を使う(KMP フレームワーク外で実装)
import Foundation
import Security
class SecureStorageIOS {
func getString ( key : String ) -> String ? {
let query: [ String : Any ] = [
kSecClass as String : kSecClassGenericPassword,
kSecAttrAccount as String : key,
kSecReturnData as String : true ,
kSecMatchLimit as String : kSecMatchLimitOne
]
var item: AnyObject ?
guard SecItemCopyMatching (query as CFDictionary, & item) == errSecSuccess,
let data = item as? Data else { return nil }
return String ( data : data, encoding : . utf8 )
}
func putString ( key : String , value : String ) {
guard let data = value. data ( using : . utf8 ) else { return }
// 既存エントリを削除してから追加(更新)
SecItemDelete ([
kSecClass as String : kSecClassGenericPassword,
kSecAttrAccount as String : key
] as CFDictionary)
SecItemAdd ([
kSecClass as String : kSecClassGenericPassword,
kSecAttrAccount as String : key,
kSecValueData as String : data,
kSecAttrAccessible as String :
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
] as CFDictionary, nil )
}
}
Android の EncryptedSharedPreferences と iOS の Keychain Services を使うことで、アプリのサンドボックス外からアクセスできない安全な場所に API キーを保管できます。
Android UI: Jetpack Compose での実装
共通モジュールの ViewModel と Repository を Compose UI に接続します。
// shared/src/commonMain/kotlin/ui/ChatViewModel.kt
// (KMPのViewModelは lifecycle-viewmodel-compose KMP版を使用)
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ChatViewModel (
private val repository: GeminiRepository ,
private val scope: CoroutineScope = CoroutineScope (Dispatchers.Main)
) {
private val _state = MutableStateFlow ( ChatState ())
val state: StateFlow < ChatState > = _state
fun onInputChanged (text: String ) {
_state. update { it. copy (inputText = text) }
}
fun sendMessage () {
val message = _state. value .inputText. trim ()
if (message. isEmpty () || _state. value .isLoading) return
val userMsg = ChatMessage (MessageRole.USER, message)
_state. update { it. copy (
messages = it.messages + userMsg,
inputText = "" ,
isLoading = true ,
error = null
)}
scope. launch {
repository. chat (
history = _state. value .messages. dropLast ( 1 ),
message = message
). fold (
onSuccess = { reply ->
_state. update { it. copy (
messages = it.messages +
ChatMessage (MessageRole.MODEL, reply),
isLoading = false
)}
},
onFailure = { e ->
_state. update { it. copy (
isLoading = false ,
error = "エラー: ${e.message}"
)}
}
)
}
}
}
data class ChatState (
val messages: List < ChatMessage > = emptyList (),
val inputText: String = "" ,
val isLoading: Boolean = false ,
val error: String ? = null
)
// androidApp/src/main/kotlin/com/yourapp/ui/ChatScreen.kt
@Composable
fun ChatScreen () {
val repository = remember {
GeminiRepositoryImpl (
apiKey = BuildConfig.GEMINI_API_KEY
// 本番では SecureStorage から取得する
)
}
val viewModel = remember { ChatViewModel (repository) }
val state by viewModel.state. collectAsState ()
val listState = rememberLazyListState ()
LaunchedEffect (state.messages.size) {
if (state.messages. isNotEmpty ()) {
listState. animateScrollToItem (state.messages.lastIndex)
}
}
Scaffold (
bottomBar = {
Row (
modifier = Modifier
. fillMaxWidth ()
. padding ( 8 .dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField (
value = state.inputText,
onValueChange = viewModel:: onInputChanged ,
modifier = Modifier. weight ( 1f ),
placeholder = { Text ( "メッセージを入力..." ) },
enabled = \ ! state.isLoading
)
Spacer (Modifier. width ( 8 .dp))
IconButton (
onClick = viewModel:: sendMessage ,
enabled = \ ! state.isLoading && state.inputText. isNotBlank ()
) {
Icon (Icons.Default.Send, contentDescription = "送信" )
}
}
}
) { padding ->
LazyColumn (
state = listState,
contentPadding = padding,
modifier = Modifier. fillMaxSize ()
) {
items (state.messages) { message ->
val isUser = message.role == MessageRole.USER
Row (
modifier = Modifier
. fillMaxWidth ()
. padding ( 8 .dp),
horizontalArrangement =
if (isUser) Arrangement.End else Arrangement.Start
) {
Surface (
color = if (isUser)
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape ( 12 .dp),
modifier = Modifier. widthIn (max = 280 .dp)
) {
Text (
text = message.content,
modifier = Modifier. padding ( 12 .dp),
color = if (isUser)
MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
if (state.isLoading) {
item {
Row (Modifier. padding ( 8 .dp)) {
CircularProgressIndicator (Modifier. size ( 24 .dp))
Spacer (Modifier. width ( 8 .dp))
Text ( "考えています..." )
}
}
}
state.error?. let { error ->
item {
Text (
text = error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier. padding ( 8 .dp)
)
}
}
}
}
}
iOS UI: SwiftUI での実装
KMP フレームワーク(Shared.xcframework)を Xcode に組み込んだ後、Swift から Kotlin の ChatViewModel を利用します。Kotlin の StateFlow を Swift の @Published プロパティに橋渡しするラッパーが必要です。
// iosApp/iosApp/ViewModels/ChatViewModelWrapper.swift
import SwiftUI
import Shared // KMP フレームワーク
@MainActor
class ChatViewModelWrapper : ObservableObject {
private let viewModel: ChatViewModel
@Published var messages: [ChatMessage] = []
@Published var inputText: String = "" {
didSet { viewModel. onInputChanged ( text : inputText) }
}
@Published var isLoading: Bool = false
@Published var error: String ? = nil
private var observationTask: Task< Void , Never > ?
init () {
let repository = GeminiRepositoryImpl (
apiKey : SecureStorageIOS (). getString ( key : "gemini_api_key" ) ?? "" ,
modelName : "gemini-2.5-flash"
)
self .viewModel = ChatViewModel ( repository : repository)
startObserving ()
}
private func startObserving () {
observationTask = Task { @MainActor in
// KMP の StateFlow を Swift の async sequence として購読
for await state in viewModel.state {
self .messages = state.messages
self .isLoading = state.isLoading
self . error = state. error
// inputText の反映は didSet ループを防ぐため条件付きで
if state.inputText \ != self .inputText {
self .inputText = state.inputText
}
}
}
}
func sendMessage () {
viewModel. sendMessage ()
}
deinit {
observationTask ? . cancel ()
}
}
// iosApp/iosApp/Views/ChatView.swift
import SwiftUI
import Shared
struct ChatView : View {
@StateObject private var viewModel = ChatViewModelWrapper ()
var body: some View {
VStack ( spacing : 0 ) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack ( alignment : .leading, spacing : 8 ) {
ForEach (
Array (viewModel.messages. enumerated ()),
id : \.offset
) { index, message in
let isUser =
message.role == .user
HStack {
if isUser { Spacer () }
Text (message.content)
. padding ( 12 )
. background (
isUser
? Color.blue
: Color (.systemGray5)
)
. foregroundColor (
isUser ? .white : .primary
)
. clipShape (
RoundedRectangle ( cornerRadius : 12 )
)
. frame (
maxWidth : 280 ,
alignment : isUser
? .trailing : .leading
)
if \ ! isUser { Spacer () }
}
. id (index)
}
if viewModel.isLoading {
HStack {
ProgressView ()
Text ( "考えています..." )
. foregroundColor (.secondary)
}
. padding (.horizontal)
}
}
. padding ()
}
. onChange ( of : viewModel.messages. count ) { _ in
let lastIdx = viewModel.messages. count - 1
if lastIdx >= 0 {
withAnimation {
proxy. scrollTo (lastIdx, anchor : .bottom)
}
}
}
}
Divider ()
HStack ( spacing : 8 ) {
TextField (
"メッセージを入力..." ,
text : $viewModel.inputText
)
. textFieldStyle (.roundedBorder)
. disabled (viewModel.isLoading)
Button ( action : viewModel.sendMessage) {
Image ( systemName : "arrow.up.circle.fill" )
. font (. system ( size : 32 ))
. foregroundColor (
viewModel.inputText. isEmpty ||
viewModel.isLoading
? .gray : .blue
)
}
. disabled (
viewModel.inputText. isEmpty || viewModel.isLoading
)
}
. padding ()
}
. navigationTitle ( "AI アシスタント" )
. navigationBarTitleDisplayMode (.inline)
}
}
よくある実装ミスと修正パターン
実際に KMP + Gemini を実装して遭遇した落とし穴を、Before/After で整理します。
ミス 1: API キーをコードに直書きする
// ❌ APK/IPA にキーが含まれ、逆コンパイルで丸見えになる
object Config {
const val GEMINI_API_KEY = "YOUR_ACTUAL_API_KEY_HERE" // 絶対禁止
}
// ✅ local.properties に記述 → .gitignore で除外 → 初回起動時に暗号化ストレージへ移行
// local.properties(.gitignore 済み): GEMINI_API_KEY=YOUR_GEMINI_API_KEY
// build.gradle.kts:
buildConfigField ( "String" , "GEMINI_API_KEY" ,
" \" ${localProperties. getProperty ("GEMINI_API_KEY", "")} \" " )
// 起動時: secureStorage.putString("api_key", BuildConfig.GEMINI_API_KEY)
ミス 2: iOS フレームワークをデバッグ版でリリースに含める
# ❌ デバッグビルドのフレームワークをリリースに使うとバイナリが 2〜3 倍に
./gradlew assembleXCFramework # デフォルトは debug
# ✅ リリース向けは明示的に RELEASE ビルドで生成する
./gradlew assembleXCFramework -Pkotlin.native.binary.mode=RELEASE
リリースビルドのフレームワークは App Store 審査でもサイズ要件に引っかかりにくく、実行パフォーマンスも大幅に向上します。
ミス 3: Swift 側で Task のキャンセルを忘れる
// ❌ Task を保持しないと deinit 後も Gemini API を呼び続ける
func startObserving () {
Task { for await state in viewModel.state { ... } }
}
// ✅ Task を保持して deinit でキャンセルする
private var observationTask: Task< Void , Never > ?
func startObserving () {
observationTask = Task { @MainActor in
for await state in viewModel.state { ... }
}
}
deinit {
observationTask ? . cancel ()
}
ミス 4: レート制限(429 エラー)を無視する
無料枠の Gemini API は 1 分あたり 15 リクエストの制限があります。素早い連続送信で 429 が返ると、エラーメッセージ未処理の場合アプリがフリーズしているように見えます。
// ✅ 共通モジュールに指数バックオフ付きリトライを実装
suspend fun < T > withRetry (
maxAttempts: Int = 3 ,
initialDelayMs: Long = 1000L ,
block: suspend () -> T
): T {
var delayMs = initialDelayMs
repeat (maxAttempts - 1 ) {
try {
return block ()
} catch (e: Exception ) {
if (e.message?. contains ( "429" ) == true ||
e.message?. contains ( "RESOURCE_EXHAUSTED" ) == true ) {
kotlinx.coroutines. delay (delayMs)
delayMs = (delayMs * 2 ). coerceAtMost ( 30_000L )
} else {
throw e // レート制限以外はリトライしない
}
}
}
return block () // 最後の試行(失敗時は例外をそのまま投げる)
}
// GeminiRepositoryImpl の generateText 内で使用
override suspend fun generateText (prompt: String ): Result < String > =
runCatching { withRetry { /* API 呼び出し */ } }
ミス 5: iOS フレームワークの再ビルドを Xcode に伝え忘れる
# KMP の Kotlin コードを変更したとき、Xcode は自動で気づかない
# ✅ このスクリプトを Xcode の "Run Script Phase" に追加する
cd "${ SRCROOT }/../"
./gradlew :shared:embedAndSignAppleFrameworkForXcode
これを忘れると「古い Kotlin コードが動いている」という混乱が起きます。
マルチモーダル対応:カメラ画像の AI 解析
アプリで撮影した写真を Gemini に解析させる実装です。共通モジュールの analyzeImage() を UI レイヤーから呼びます。
// androidApp: カメラ撮影 → ByteArray → Gemini 解析
@Composable
fun ImageAnalysisScreen (repository: GeminiRepository ) {
var description by remember { mutableStateOf ( "" ) }
var isLoading by remember { mutableStateOf ( false ) }
val scope = rememberCoroutineScope ()
val launcher = rememberLauncherForActivityResult (
ActivityResultContracts. TakePicturePreview ()
) { bitmap ->
bitmap?. let {
val bytes = ByteArrayOutputStream (). apply {
it. compress (Bitmap.CompressFormat.JPEG, 85 , this )
}. toByteArray ()
scope. launch {
isLoading = true
repository. analyzeImage (
imageBytes = bytes,
prompt = "この画像を日本語で詳しく説明してください。" +
"写っているものを箇条書きで列挙し、" +
"全体の雰囲気も教えてください。"
). fold (
onSuccess = { result ->
description = result
isLoading = false
},
onFailure = { e ->
description = "エラー: ${e.message}"
isLoading = false
}
)
}
}
}
Column (Modifier. fillMaxSize (). padding ( 16 .dp)) {
if (description. isNotEmpty ()) {
Card (Modifier. fillMaxWidth (). weight ( 1f )) {
Text (
text = description,
modifier = Modifier
. padding ( 16 .dp)
. verticalScroll ( rememberScrollState ())
)
}
Spacer (Modifier. height ( 16 .dp))
} else {
Box (Modifier. weight ( 1f ), contentAlignment = Alignment.Center) {
Text (
"写真を撮影すると AI が解析します" ,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Button (
onClick = { launcher. launch ( null ) },
enabled = \ ! isLoading,
modifier = Modifier. fillMaxWidth ()
) {
if (isLoading) {
CircularProgressIndicator (
modifier = Modifier. size ( 20 .dp),
color = MaterialTheme.colorScheme.onPrimary
)
Spacer (Modifier. width ( 8 .dp))
Text ( "解析中..." )
} else {
Icon (Icons.Default.CameraAlt, contentDescription = null )
Spacer (Modifier. width ( 8 .dp))
Text ( "写真を撮影して解析" )
}
}
}
}
本番デプロイ前のチェックリスト
App Store / Google Play への公開前に確認すべき項目です。
セキュリティ:
API キーがコード・APK・IPA に含まれていないこと(strings コマンドや jadx で確認)
HTTPS のみで通信(iOS の ATS、Android の Network Security Config を確認)
ユーザーデータを Gemini に送信することをプライバシーポリシーに明記
パフォーマンス:
タイムアウト設定(Ktor で HttpTimeout を 30 秒程度に設定)
ネットワーク切断時の適切なエラーメッセージ表示
画像送信前のリサイズ(Gemini の推奨は長辺 1024px 以下)
コスト管理:
1 ユーザーあたりの日次リクエスト数制限(アプリ側でカウント)
プロンプト長の上限設定(過度な長文送信を防ぐ)
Google AI Studio でクォータアラートを設定
App Store 審査:
AI 生成コンテンツであることを必要箇所でユーザーに開示
子ども向けカテゴリでの AI 生成コンテンツ利用制限の確認
Android の INTERNET 権限を AndroidManifest.xml に追加済みであること
個人開発者の視点から(実体験メモ)
運用コストの現実
gemini-2.5-flash の料金(目安):
テキスト入力: $0.075 / 100 万トークン
テキスト出力: $0.30 / 100 万トークン
画像入力: 1 画像あたり約 $0.001〜$0.003(解像度による)
チャットアプリで 1 ユーザーが 1 日 20 回メッセージ交換した場合、1 ユーザーあたり月 $0.003〜$0.01 程度です。月間アクティブ 1,000 ユーザーでも月 $3〜$10 の範囲です。
画像解析を多用するアプリでは 1 回の解析で 300〜1,500 トークン消費するため、事前に試算しておくことをお勧めします。無料枠(1 分 15 リクエスト)を超えた場合、有料プラン移行で大幅に制限が緩和されます。
KMP を採用することで iOS/Android 双方のコードメンテナンスコストを大きく削減できます。まずはテキスト要約や翻訳など小さな AI 機能から始めて、ユーザー反応を見ながら機能を拡充していくアプローチがお勧めです。Gemini API と KMP の組み合わせは、個人開発者が本格的な AI アプリをリリースするための現実的な選択肢になっています。