取り組みの背景:なぜ Android ネイティブ × Gemini API なのか
スマートフォン向け AI アプリ開発の選択肢は増え続けていますが、**Android ネイティブ(Kotlin + Jetpack Compose)**で Gemini API を統合することには、React Native や Flutter にはない固有の優位性があります。
まず、OS との深い統合が可能です。Android の Camera2 API や MediaStore、WorkManager、Notification などのシステム API をそのままコルーチンと組み合わせられるため、バックグラウンド処理やメディア操作において最高のパフォーマンスを発揮できます。また、Jetpack Compose の宣言的 UI は、ストリーミングレスポンスのような動的なコンテンツ更新と非常に相性が良く、少ないコードで滑らかな UX を実現できます。
Gradle × Kotlin DSL でのセキュアな SDK 統合
MVVM アーキテクチャ(ViewModel + StateFlow + Repository)
テキスト・画像マルチモーダルチャット UI(Jetpack Compose)
Room Database による会話履歴の永続化
WorkManager を使ったバックグラウンド AI タスク
secrets-gradle-plugin によるAPIキー保護
Compose Testing + Hilt によるテスト戦略
開発環境の準備
必要な前提条件
Android Studio Koala(2024.1.1)以降
Kotlin 2.0+
Android Gradle Plugin 8.4+
Google AI Studio で取得した Gemini API キー(Google AI Studio で無料取得可能)
minSdk: 26 以上を推奨(SDK の一部機能に要件あり)
Gradle 設定(Kotlin DSL)
まず、libs.versions.toml に依存関係を追加します。
# gradle/libs.versions.toml
[ versions ]
generativeai = "0.9.0"
room = "2.6.1"
lifecycle = "2.8.4"
hilt = "2.51"
coroutines = "1.8.1"
[ libraries ]
google-ai-generativeai = { group = "com.google.ai.client.generativeai" , name = "generativeai" , version.ref = "generativeai" }
room-runtime = { group = "androidx.room" , name = "room-runtime" , version.ref = "room" }
room-ktx = { group = "androidx.room" , name = "room-ktx" , version.ref = "room" }
room-compiler = { group = "androidx.room" , name = "room-compiler" , version.ref = "room" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle" , name = "lifecycle-viewmodel-compose" , version.ref = "lifecycle" }
hilt-android = { group = "com.google.dagger" , name = "hilt-android" , version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger" , name = "hilt-android-compiler" , version.ref = "hilt" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx" , name = "kotlinx-coroutines-android" , version.ref = "coroutines" }
次に、app/build.gradle.kts を設定します。
// app/build.gradle.kts
plugins {
alias (libs.plugins.android.application)
alias (libs.plugins.kotlin.android)
alias (libs.plugins.hilt)
alias (libs.plugins.ksp)
id ( "com.google.android.libraries.mapsplatform.secrets-gradle-plugin" )
}
android {
compileSdk = 35
defaultConfig {
minSdk = 26
targetSdk = 35
}
buildFeatures {
compose = true
buildConfig = true // BuildConfig でAPIキーにアクセスするため
}
}
dependencies {
implementation (libs.google.ai.generativeai)
implementation (libs.room.runtime)
implementation (libs.room.ktx)
ksp (libs.room.compiler)
implementation (libs.lifecycle.viewmodel.compose)
implementation (libs.hilt.android)
ksp (libs.hilt.compiler)
implementation (libs.kotlinx.coroutines.android)
}
secrets-gradle-plugin によるAPIキー保護
APIキーをハードコードすることは絶対に避けなければなりません。secrets-gradle-plugin を使うことで、local.properties に記載した値を BuildConfig フィールドとして安全に注入できます。
# local.properties(Gitの管理外にすること)
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
// app/build.gradle.kts の secrets ブロック
secrets {
propertiesFileName = "local.properties"
defaultPropertiesFileName = "local.defaults.properties"
}
これで BuildConfig.GEMINI_API_KEY としてコード内でアクセスできるようになります。local.properties は必ず .gitignore に追加してください。
アーキテクチャ設計:MVVM + Repository パターン
クリーンアーキテクチャの原則に従い、以下の層を設計します。
UI層 (Compose)
↕
ViewModel層 (StateFlow でUI状態管理)
↕
Repository層 (AI API + Room DB を抽象化)
↕
DataSource層 (GeminiDataSource + RoomDataSource)
GeminiRepository の実装
// data/repository/GeminiRepository.kt
interface GeminiRepository {
fun sendMessage (messages: List < ChatMessage >, imageUri: Uri ? = null ): Flow < GenerationResult >
suspend fun getChatHistory (sessionId: String ): List < ChatMessage >
suspend fun saveChatMessage (message: ChatMessage )
}
data class GenerationResult (
val text: String = "" ,
val isComplete: Boolean = false ,
val error: String ? = null
)
GeminiDataSource:ストリーミング実装
Google AI SDK の generateContentStream を Flow でラップすることで、Jetpack Compose との親和性が高まります。
// data/datasource/GeminiDataSource.kt
class GeminiDataSource @Inject constructor () {
// マルチモーダル対応:テキスト + 画像
private fun buildGenerativeModel (modelName: String = "gemini-2.0-flash" ): GenerativeModel {
return GenerativeModel (
modelName = modelName,
apiKey = BuildConfig.GEMINI_API_KEY,
generationConfig = generationConfig {
temperature = 0.7f
maxOutputTokens = 8192
topP = 0.95f
},
safetySettings = listOf (
SafetySetting (HarmCategory.HARASSMENT, BlockThreshold.MEDIUM_AND_ABOVE),
SafetySetting (HarmCategory.HATE_SPEECH, BlockThreshold.MEDIUM_AND_ABOVE)
)
)
}
fun streamChatResponse (
chatHistory: List < ChatMessage >,
newMessage: String ,
imageBitmap: Bitmap ? = null
): Flow < GenerationResult > = flow {
val model = buildGenerativeModel ()
val chat = model. startChat (
history = chatHistory. dropLast ( 1 ). map { it. toContent () }
)
// 最後のメッセージ(マルチモーダル対応)
val content = content ( "user" ) {
if (imageBitmap != null ) {
image (imageBitmap)
}
text (newMessage)
}
try {
chat. sendMessageStream (content). collect { chunk ->
chunk.text?. let { text ->
emit ( GenerationResult (text = text, isComplete = false ))
}
}
emit ( GenerationResult (text = "" , isComplete = true ))
} catch (e: GoogleGenerativeAIException ) {
emit ( GenerationResult (error = "API エラー: ${e.message}" , isComplete = true ))
}
}. flowOn (Dispatchers.IO)
}
Jetpack Compose チャット UI の実装
ChatViewModel:StateFlow でUI状態管理
// ui/chat/ChatViewModel.kt
@HiltViewModel
class ChatViewModel @Inject constructor (
private val geminiRepository: GeminiRepository
) : ViewModel () {
private val _uiState = MutableStateFlow ( ChatUiState ())
val uiState: StateFlow < ChatUiState > = _uiState. asStateFlow ()
// 選択された画像URI
private val _selectedImageUri = MutableStateFlow < Uri ?>( null )
val selectedImageUri: StateFlow < Uri ?> = _selectedImageUri. asStateFlow ()
fun sendMessage (userInput: String , imageBitmap: Bitmap ? = null ) {
if (userInput. isBlank () && imageBitmap == null ) return
val userMessage = ChatMessage (
text = userInput,
role = Role.USER,
imageUri = _selectedImageUri. value ,
timestamp = System. currentTimeMillis ()
)
// ユーザーメッセージをUIに即時反映
_uiState. update { current ->
current. copy (
messages = current.messages + userMessage,
isLoading = true ,
pendingBotText = ""
)
}
_selectedImageUri. value = null
viewModelScope. launch {
val currentMessages = _uiState. value .messages
geminiRepository. sendMessage (currentMessages, imageBitmap = imageBitmap)
. collect { result ->
when {
result.error != null -> {
_uiState. update { it. copy (
isLoading = false ,
errorMessage = result.error
)}
}
result.isComplete -> {
// ストリーミング完了 → pending テキストを確定メッセージへ
val finalText = _uiState. value .pendingBotText
val botMessage = ChatMessage (
text = finalText,
role = Role.MODEL,
timestamp = System. currentTimeMillis ()
)
_uiState. update { current ->
current. copy (
messages = current.messages + botMessage,
isLoading = false ,
pendingBotText = ""
)
}
// DBに保存
geminiRepository. saveChatMessage (botMessage)
}
else -> {
// ストリーミング中:リアルタイムに文字を追加
_uiState. update { current ->
current. copy (pendingBotText = current.pendingBotText + result.text)
}
}
}
}
}
}
}
data class ChatUiState (
val messages: List < ChatMessage > = emptyList (),
val isLoading: Boolean = false ,
val pendingBotText: String = "" ,
val errorMessage: String ? = null
)
Composable:チャット画面の実装
// ui/chat/ChatScreen.kt
@Composable
fun ChatScreen (
viewModel: ChatViewModel = hiltViewModel ()
) {
val uiState by viewModel.uiState. collectAsStateWithLifecycle ()
val selectedImageUri by viewModel.selectedImageUri. collectAsStateWithLifecycle ()
val listState = rememberLazyListState ()
var inputText by remember { mutableStateOf ( "" ) }
// 新しいメッセージが来たら自動スクロール
LaunchedEffect (uiState.messages.size, uiState.pendingBotText) {
if (uiState.messages. isNotEmpty ()) {
listState. animateScrollToItem (
index = uiState.messages.size + if (uiState.pendingBotText. isNotEmpty ()) 1 else 0
)
}
}
Scaffold (
bottomBar = {
ChatInputBar (
inputText = inputText,
selectedImageUri = selectedImageUri,
isLoading = uiState.isLoading,
onInputChange = { inputText = it },
onSend = {
viewModel. sendMessage (inputText)
inputText = ""
},
onImageSelected = { uri -> viewModel. setSelectedImage (uri) }
)
}
) { paddingValues ->
LazyColumn (
state = listState,
modifier = Modifier
. fillMaxSize ()
. padding (paddingValues),
contentPadding = PaddingValues ( 16 .dp),
verticalArrangement = Arrangement. spacedBy ( 8 .dp)
) {
items (uiState.messages) { message ->
ChatBubble (message = message)
}
// ストリーミング中の「入力中」バブル
if (uiState.pendingBotText. isNotEmpty ()) {
item {
StreamingBubble (text = uiState.pendingBotText)
}
}
}
}
}
@Composable
fun StreamingBubble (text: String ) {
Box (
modifier = Modifier
. fillMaxWidth ( 0.85f )
. clip ( RoundedCornerShape ( 12 .dp))
. background (MaterialTheme.colorScheme.secondaryContainer)
. padding ( 12 .dp)
) {
// ストリーミング中はカーソル(|)を末尾に点滅表示
val cursor by rememberInfiniteTransition (label = "cursor" ). animateFloat (
initialValue = 0f ,
targetValue = 1f ,
animationSpec = infiniteRepeatable (
animation = tween ( 600 ),
repeatMode = RepeatMode.Reverse
),
label = "cursorAlpha"
)
Text (
text = text + if (cursor > 0.5f ) "│" else " " ,
style = MaterialTheme.typography.bodyMedium
)
}
}
Room Database による会話履歴の永続化
長期間の会話を記録し、セッションをまたいで文脈を引き継ぐには、Room Database が最適です。
// data/local/ChatEntity.kt
@Entity (tableName = "chat_messages" )
data class ChatEntity (
@PrimaryKey (autoGenerate = true ) val id: Long = 0 ,
val sessionId: String ,
val text: String ,
val role: String , // "user" or "model"
val imageUri: String ?,
val timestamp: Long ,
val tokenCount: Int = 0
)
// data/local/ChatDao.kt
@Dao
interface ChatDao {
@Query ( "SELECT * FROM chat_messages WHERE sessionId = :sessionId ORDER BY timestamp ASC" )
fun observeSessionMessages (sessionId: String ): Flow < List < ChatEntity >>
@Insert (onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessage (entity: ChatEntity )
@Query ( "DELETE FROM chat_messages WHERE sessionId = :sessionId" )
suspend fun clearSession (sessionId: String )
// 古いセッションの自動削除(ストレージ管理)
@Query ( "DELETE FROM chat_messages WHERE timestamp < :cutoffMs" )
suspend fun deleteOlderThan (cutoffMs: Long )
}
WorkManager によるバックグラウンド AI タスク
要約生成・翻訳・定期レポート作成など、UI を離れても継続させたい重い処理には WorkManager が適しています。
// worker/SummarizeWorker.kt
@HiltWorker
class SummarizeWorker @AssistedInject constructor (
@Assisted context: Context ,
@Assisted params: WorkerParameters ,
private val geminiRepository: GeminiRepository
) : CoroutineWorker ( context , params ) {
override suspend fun doWork (): Result {
val sessionId = inputData. getString (KEY_SESSION_ID) ?: return Result. failure ()
return try {
val messages = geminiRepository. getChatHistory (sessionId)
val summaryRequest = "以下の会話を3行で要約してください: \n " +
messages. joinToString ( " \n " ) { "${it.role}: ${it.text}" }
var summaryText = ""
geminiRepository. sendMessage (
listOf ( ChatMessage (text = summaryRequest, role = Role.USER))
). collect { result ->
if (result.isComplete) {
summaryText = result.text
}
}
val outputData = workDataOf (KEY_SUMMARY to summaryText)
Result. success (outputData)
} catch (e: Exception ) {
if (runAttemptCount < 3 ) Result. retry () else Result. failure ()
}
}
companion object {
const val KEY_SESSION_ID = "session_id"
const val KEY_SUMMARY = "summary"
fun buildRequest (sessionId: String ): OneTimeWorkRequest {
val inputData = workDataOf (KEY_SESSION_ID to sessionId)
val constraints = Constraints. Builder ()
. setRequiredNetworkType (NetworkType.CONNECTED)
. build ()
return OneTimeWorkRequestBuilder < SummarizeWorker >()
. setInputData (inputData)
. setConstraints (constraints)
. setBackoffCriteria (BackoffPolicy.EXPONENTIAL, 30 , TimeUnit.SECONDS)
. build ()
}
}
}
セキュリティ設計:本番リリースに向けた対策
APIキー保護の多層防御
Android アプリのAPKからは、逆コンパイルによりコード内のAPIキーが容易に取得できます。本番環境では以下の戦略を組み合わせることを強く推奨します。
推奨アーキテクチャ:プロキシサーバー経由
最も安全な方法は、APIキーをアプリに持たせず、自前のバックエンドサーバーを中継させることです。
Android App → 自前バックエンドAPI(認証済み)→ Gemini API
// 開発・プロトタイプ段階での BuildConfig 利用(本番は要検討)
class GeminiDataSource @Inject constructor () {
private val apiKey: String
get () = if (BuildConfig.DEBUG) {
BuildConfig.GEMINI_API_KEY // 開発時のみ直接使用
} else {
"" // 本番では空文字→プロキシ経由に切り替え
}
}
ProGuard / R8 によるコード難読化
app/proguard-rules.pro に以下を追加します。
# Gemini SDK
-keep class com.google.ai.client.generativeai.** { *; }
-keepattributes Signature
-keepattributes *Annotation*
テスト戦略
ViewModel のユニットテスト
// test/ChatViewModelTest.kt
@OptIn (ExperimentalCoroutinesApi:: class )
class ChatViewModelTest {
@get : Rule
val mainDispatcherRule = MainDispatcherRule ()
private val fakeRepository = FakeGeminiRepository ()
private lateinit var viewModel: ChatViewModel
@Before
fun setup () {
viewModel = ChatViewModel (fakeRepository)
}
@Test
fun `sendMessage updates messages and clears loading state` () = runTest {
// Given
fakeRepository.streamResponse = flowOf (
GenerationResult (text = "Hello" , isComplete = false ),
GenerationResult (text = " World" , isComplete = false ),
GenerationResult (text = "" , isComplete = true )
)
// When
viewModel. sendMessage ( "こんにちは" )
advanceUntilIdle ()
// Then
val state = viewModel.uiState. value
assertEquals ( 2 , state.messages.size) // user + bot
assertEquals ( "Hello World" , state.messages. last ().text)
assertFalse (state.isLoading)
}
}
Compose UI テスト
// androidTest/ChatScreenTest.kt
@HiltAndroidTest
class ChatScreenTest {
@get : Rule ( order = 0 )
val hiltRule = HiltAndroidRule ( this )
@get : Rule ( order = 1 )
val composeTestRule = createAndroidComposeRule < MainActivity >()
@Test
fun chatInput_sendMessage_displaysInList () {
composeTestRule. onNodeWithTag ( "ChatInput" )
. performTextInput ( "Gemini について教えてください" )
composeTestRule. onNodeWithTag ( "SendButton" )
. performClick ()
// ユーザーメッセージが表示されていることを確認
composeTestRule. onNodeWithText ( "Gemini について教えてください" )
. assertIsDisplayed ()
}
}
本番の失敗を Crashlytics に構造化して送り、エラー率を可視化する
個人開発でAIアプリを出荷していて痛感したのは、try/catch でエラーをUIに出すだけでは「実機で何がどのくらいの頻度で失敗しているか」が永遠に見えない、という点です。Gemini API の失敗は単一ではありません。レート超過(HTTP 429)、安全性ブロック、RECITATION による打ち切り、ネットワークタイムアウト、APIキー不正 — これらを「ひとつの例外」として握りつぶすと、本番のエラー率は霧の中に消えます。
私はこのスタックでは、失敗をカテゴリへ正規化してから Firebase Crashlytics のカスタムキー付きで記録するようにしています。こうすると Crashlytics のダッシュボードで「429 が全体の何%か」「特定機種で safety ブロックが偏っていないか」を後から追えます。
// GeminiFailure.kt — 失敗を運用可能なカテゴリへ正規化する
enum class GeminiFailureType {
RATE_LIMIT, SAFETY_BLOCKED, RECITATION, TIMEOUT, AUTH, NETWORK, UNKNOWN
}
fun classifyGeminiFailure (t: Throwable ): GeminiFailureType = when {
t is com.google.ai.client.generativeai.type.PromptBlockedException -> GeminiFailureType.SAFETY_BLOCKED
t is com.google.ai.client.generativeai.type.ServerException &&
t.message?. contains ( "429" ) == true -> GeminiFailureType.RATE_LIMIT
t.message?. contains ( "RECITATION" ) == true -> GeminiFailureType.RECITATION
t is java.net.SocketTimeoutException -> GeminiFailureType.TIMEOUT
t is com.google.ai.client.generativeai.type.InvalidAPIKeyException -> GeminiFailureType.AUTH
t is java.io.IOException -> GeminiFailureType.NETWORK
else -> GeminiFailureType.UNKNOWN
}
そのうえで、DataSource の例外ハンドラから構造化ログを送ります。recordException だけでなく、カスタムキーで分類・モデル名・リトライ回数を添えるのが要点です。
// GeminiDataSource.kt の catch 節から呼ぶ
import com.google.firebase.crashlytics.FirebaseCrashlytics
private fun reportFailure (t: Throwable , modelName: String , attempt: Int ) {
val type = classifyGeminiFailure (t)
FirebaseCrashlytics. getInstance (). apply {
setCustomKey ( "gemini_failure_type" , type.name)
setCustomKey ( "gemini_model" , modelName)
setCustomKey ( "gemini_attempt" , attempt)
// 安全性ブロックやレート超過は「想定内の失敗」なので
// クラッシュ扱いにせず log にとどめ、ノイズを減らす
if (type == GeminiFailureType.SAFETY_BLOCKED || type == GeminiFailureType.RATE_LIMIT) {
log ( "gemini_${type.name. lowercase ()} model= $modelName attempt= $attempt " )
} else {
recordException (t)
}
}
}
ここで意図的に分けているのは、「想定内の失敗(safety・429)」を recordException に流さないことです。出荷後にすべての 429 を例外として送ると Crashlytics の Non-fatal が洪水になり、本当に直すべき NullPointerException が埋もれてしまいます。私はこの線引きを、壁紙系アプリの本番運用でクラッシュ指標を整理していく中で固めました。429 と safety はカスタムキー付きの log で件数だけ追い、UNKNOWN / AUTH / NETWORK の異常だけを Non-fatal として浮かび上がらせます。モデル名を添えておくと、既定モデルが世代更新で静かに差し替わったあとに失敗の傾向が変わったかどうかも、同じダッシュボードで追えます。
こうして失敗が分類されて溜まると、運用は静かに楽になります。私自身は週に一度、gemini_failure_type の比率をざっと眺めるだけにしていますが、たとえば特定機種で NETWORK が突出していれば回線の弱い環境向けにタイムアウトとリトライを緩める、SAFETY_BLOCKED がじわじわ増えていればプロンプト側の言い回しを見直す、といった判断が数字で裏づけられます。個人開発では計測の手間そのものがコストなので、「クラッシュ指標を汚さずに、見たい失敗だけを見える化する」この一点に絞るのが続けやすいやり方だと感じています。
画面回転とプロセス death をまたいでもストリーミング応答を失わない
ViewModel + StateFlow は構成変更(画面回転)を生き延びますが、プロセス death は別問題です。低メモリ端末でアプリがバックグラウンドに退避し、OS にプロセスごと終了されると ViewModel は消えます。長いストリーミング応答の途中で戻ってきたユーザーは、空のチャットに放り出されます。これは実機では珍しくない経路で、エミュレータ中心の検証だと見落としがちです。
対策は二段構えにしています。受信中の部分テキストを1チャンクごとに SavedStateHandle へ退避し、プロセス death から復帰したときに「途中まで」を即座に再描画できるようにします。そして確定した発話は Room に保存し、Single Source of Truth を DB 側に置きます。
class ChatViewModel @Inject constructor (
private val repository: GeminiRepository ,
private val savedState: SavedStateHandle
) : ViewModel () {
fun sendMessage (prompt: String ) {
viewModelScope. launch {
val buffer = StringBuilder ()
repository. streamResponse (prompt)
. onCompletion { cause ->
if (cause == null ) {
repository. persistTurn (prompt, buffer. toString ()) // Room へ確定保存
savedState[KEY_PARTIAL] = "" // 退避をクリア
}
}
. collect { chunk ->
buffer. append (chunk.text)
savedState[KEY_PARTIAL] = buffer. toString () // 1チャンクごとに退避
emitToUi (buffer. toString ())
}
}
}
init {
// 復帰時に退避テキストが残っていれば「未完了の応答」として復元する
val pending = savedState. get < String >(KEY_PARTIAL). orEmpty ()
if (pending. isNotBlank ()) {
emitToUi (pending, incomplete = true ) // 「中断されました・再生成しますか?」UI
}
}
companion object { private const val KEY_PARTIAL = "partial_response" }
}
SavedStateHandle は onSaveInstanceState のバンドルに乗るため、保存できるのは数十 KB 程度の小さなデータに限られます。応答全文を延々と積むのではなく「直近の1応答の部分テキスト」だけを置き、確定履歴は Room に逃がす、という役割分担が現実的です。個人開発で低価格帯端末のレビューを読んでいると、この「戻ってきたら会話が消えていた」という不満は意外と多く、SavedStateHandle への部分退避だけで体感の信頼性がはっきり変わります。
次の一歩
まず手元のアプリで、Gemini 呼び出しの catch 節を1か所だけ classifyGeminiFailure 経由に置き換えて、Crashlytics に gemini_failure_type のカスタムキーを流してみてください。数日分の本番データが溜まると、「自分のアプリで実際に何が、どの機種で失敗しているか」が初めて数字で見えます。そこを起点に、リトライすべき失敗(429・タイムアウト)と、UI で丁寧に説明すべき失敗(safety ブロック)を分けていくと、設計の優先順位が自然と定まっていきます。
ネイティブ実装の判断材料として、Gemini × Android Studio で始めるAI駆動アプリ開発 と Gemini 3.2 Pro の Function Calling をモバイルアプリで使う個人開発ガイド もあわせてご覧ください。同じ課題に取り組む方の助けになれば幸いです。