Gemini API 基本統合 — APIクライアント設計、セキュアなキー管理
セキュアなAPI キー管理
Expo アプリでの API キー管理は「公開可能な環境変数」として設計します。機密性の高い操作はバックエンド経由にします。
# .env.local(Git に追加しない)
EXPO_PUBLIC_GEMINI_API_KEY=YOUR_GEMINI_API_KEY
EXPO_PUBLIC_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta/models
app.json で環境変数を参照:
{
"expo": {
"name": "GeminiAIChat",
"slug": "gemini-ai-chat",
"version": "1.0.0",
"plugins": ["expo-camera"],
"extra": {
"geminiApiKey": "${EXPO_PUBLIC_GEMINI_API_KEY}"
}
}
}
Gemini API クライアントの初期化
src/config/gemini.ts:
import { GoogleGenerativeAI } from "@google/generative-ai";
const geminiClient = new GoogleGenerativeAI(
process.env.EXPO_PUBLIC_GEMINI_API_KEY || ""
);
export const getGeminiModel = (modelName = "gemini-2.0-flash") => {
return geminiClient.getGenerativeModel({ model: modelName });
};
export const createChatSession = (model: any, systemPrompt?: string) => {
const history: any[] = [];
// システムプロンプトが指定されている場合
if (systemPrompt) {
history.push({
role: "user",
parts: [{ text: systemPrompt }],
});
history.push({
role: "model",
parts: [{ text: "了解しました。そのコンテキストで回答します。" }],
});
}
return model.startChat({ history });
};
export default geminiClient;
ネットワークと非同期処理の基礎
src/hooks/useNetwork.ts:
import { useState, useEffect } from "react";
import * as Network from "expo-network";
export const useNetwork = () => {
const [isOnline, setIsOnline] = useState(true);
const [connectionType, setConnectionType] = useState<string | null>(null);
useEffect(() => {
let subscription: any;
const checkNetwork = async () => {
const state = await Network.getNetworkStateAsync();
setIsOnline(state.isInternetReachable ?? false);
setConnectionType(state.type);
};
checkNetwork();
// ネットワーク状態の変化を監視
if (Network.addEventListener) {
subscription = Network.addEventListener("networkStateChange", (state) => {
setIsOnline(state.isInternetReachable ?? false);
setConnectionType(state.type);
});
}
return () => {
if (subscription) {
subscription.remove();
}
};
}, []);
return { isOnline, connectionType };
};
ストリーミングチャットUI — リアルタイムレスポンス表示、UXパターン
Gemini API のストリーミング統合
src/services/geminiService.ts:
import { getGeminiModel, createChatSession } from "../config/gemini";
interface Message {
id: string;
role: "user" | "model";
text: string;
timestamp: Date;
isStreaming?: boolean;
}
export const sendMessageWithStreaming = async (
userMessage: string,
onChunk: (chunk: string) => void,
onComplete: (fullResponse: string) => void,
onError: (error: string) => void
): Promise<void> => {
try {
const model = getGeminiModel();
const chat = createChatSession(
model,
"You are a helpful AI assistant. Be concise and friendly in your responses."
);
const stream = await chat.sendMessageStream(userMessage);
let fullResponse = "";
// ストリーミングレスポンスを段階的に処理
for await (const chunk of stream.stream) {
const chunkText =
chunk.candidates?.[0]?.content?.parts[0]?.text || "";
if (chunkText) {
fullResponse += chunkText;
onChunk(chunkText); // UI リアルタイム更新
}
}
// ストリーミング完了後の最終処理
const finalResponse = await stream.response;
onComplete(fullResponse || "");
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "通信エラーが発生しました";
onError(errorMessage);
}
};
export const generateMessageId = (): string => {
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
チャット画面のUI実装
src/components/ChatScreen.tsx:
import React, { useState, useRef, useEffect, useCallback } from "react";
import {
View,
Text,
TextInput,
ScrollView,
TouchableOpacity,
ActivityIndicator,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { useNetwork } from "../hooks/useNetwork";
import { sendMessageWithStreaming, generateMessageId } from "../services/geminiService";
interface Message {
id: string;
role: "user" | "model";
text: string;
timestamp: Date;
isStreaming?: boolean;
}
export const ChatScreen = () => {
const [messages, setMessages] = useState<Message[]>([
{
id: "welcome",
role: "model",
text: "こんにちは!何かお手伝いできることはありますか?",
timestamp: new Date(),
},
]);
const [inputText, setInputText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [streamingText, setStreamingText] = useState("");
const scrollViewRef = useRef<ScrollView>(null);
const { isOnline } = useNetwork();
const handleSendMessage = useCallback(async () => {
if (!inputText.trim() || !isOnline || isLoading) return;
const userMessage: Message = {
id: generateMessageId(),
role: "user",
text: inputText.trim(),
timestamp: new Date(),
};
// ユーザーメッセージを追加
setMessages((prev) => [...prev, userMessage]);
setInputText("");
setIsLoading(true);
setStreamingText("");
// モデルメッセージの初期スケルトン
const modelMessageId = generateMessageId();
const modelMessage: Message = {
id: modelMessageId,
role: "model",
text: "",
timestamp: new Date(),
isStreaming: true,
};
setMessages((prev) => [...prev, modelMessage]);
await sendMessageWithStreaming(
userMessage.text,
(chunk: string) => {
// ストリーミング中のUI更新
setStreamingText((prev) => prev + chunk);
},
(fullResponse: string) => {
// ストリーミング完了
setMessages((prev) =>
prev.map((msg) =>
msg.id === modelMessageId
? {
...msg,
text: fullResponse,
isStreaming: false,
}
: msg
)
);
setStreamingText("");
setIsLoading(false);
},
(error: string) => {
// エラー処理
setMessages((prev) =>
prev.map((msg) =>
msg.id === modelMessageId
? {
...msg,
text: `エラー: ${error}`,
isStreaming: false,
}
: msg
)
);
setIsLoading(false);
}
);
}, [inputText, isOnline, isLoading]);
// メッセージ更新時にスクロール
useEffect(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, [messages, streamingText]);
const displayMessages = [
...messages,
...(streamingText
? [
{
id: "streaming",
role: "model" as const,
text: streamingText,
timestamp: new Date(),
isStreaming: true,
},
]
: []),
];
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={styles.container}
>
{!isOnline && (
<View style={styles.offlineBar}>
<Text style={styles.offlineText}>オフライン状態です</Text>
</View>
)}
<ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.messagesContainer}
scrollEnabled={true}
>
{displayMessages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
isStreaming={msg.isStreaming}
/>
))}
</ScrollView>
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
placeholder="メッセージを入力..."
placeholderTextColor="#999"
value={inputText}
onChangeText={setInputText}
editable={!isLoading && isOnline}
multiline
maxLength={1000}
/>
<TouchableOpacity
style={[
styles.sendButton,
{ opacity: isLoading || !isOnline ? 0.5 : 1 },
]}
onPress={handleSendMessage}
disabled={isLoading || !isOnline}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.sendButtonText}>送信</Text>
)}
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
},
offlineBar: {
backgroundColor: "#ff6b6b",
paddingVertical: 8,
paddingHorizontal: 16,
},
offlineText: {
color: "#fff",
fontSize: 12,
textAlign: "center",
},
messagesContainer: {
flexGrow: 1,
paddingVertical: 16,
paddingHorizontal: 12,
},
inputContainer: {
flexDirection: "row",
paddingHorizontal: 12,
paddingVertical: 12,
borderTopWidth: 1,
borderTopColor: "#e0e0e0",
},
input: {
flex: 1,
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 10,
marginRight: 8,
maxHeight: 100,
fontSize: 14,
},
sendButton: {
backgroundColor: "#0066ff",
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 10,
justifyContent: "center",
alignItems: "center",
},
sendButtonText: {
color: "#fff",
fontSize: 14,
fontWeight: "bold",
},
});
メッセージバブルコンポーネント
src/components/MessageBubble.tsx:
import React from "react";
import {
View,
Text,
StyleSheet,
ActivityIndicator,
} from "react-native";
interface Message {
id: string;
role: "user" | "model";
text: string;
timestamp: Date;
isStreaming?: boolean;
}
interface MessageBubbleProps {
message: Message;
isStreaming?: boolean;
}
export const MessageBubble = ({ message, isStreaming }: MessageBubbleProps) => {
const isUser = message.role === "user";
return (
<View
style={[
styles.container,
isUser ? styles.userContainer : styles.modelContainer,
]}
>
<View
style={[
styles.bubble,
isUser ? styles.userBubble : styles.modelBubble,
]}
>
<Text style={[styles.text, isUser ? styles.userText : styles.modelText]}>
{message.text}
</Text>
{isStreaming && (
<ActivityIndicator
size="small"
color={isUser ? "#0066ff" : "#999"}
style={styles.indicator}
/>
)}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 12,
flexDirection: "row",
},
userContainer: {
justifyContent: "flex-end",
},
modelContainer: {
justifyContent: "flex-start",
},
bubble: {
maxWidth: "80%",
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 16,
},
userBubble: {
backgroundColor: "#0066ff",
},
modelBubble: {
backgroundColor: "#f0f0f0",
},
text: {
fontSize: 14,
lineHeight: 20,
},
userText: {
color: "#fff",
},
modelText: {
color: "#000",
},
indicator: {
marginTop: 4,
},
});
カメラ × Gemini Vision 画像認識 — expo-camera統合、マルチモーダル入力
カメラ権限とセットアップ
app.json に権限を追加:
{
"expo": {
"plugins": ["expo-camera"],
"permissions": [
"CAMERA",
"CAMERA_ROLL",
"PHOTO_LIBRARY",
"READ_EXTERNAL_STORAGE"
]
}
}
カメラ画面の実装
src/components/CameraScreen.tsx:
import React, { useRef, useState } from "react";
import {
View,
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator,
} from "react-native";
import { Camera, CameraType } from "expo-camera";
import * as ImagePicker from "expo-image-picker";
import { analyzeImageWithGemini } from "../services/imageService";
interface CameraScreenProps {
onAnalysisComplete: (result: string) => void;
onClose: () => void;
}
export const CameraScreen = ({
onAnalysisComplete,
onClose,
}: CameraScreenProps) => {
const cameraRef = useRef<Camera>(null);
const [type, setType] = useState(CameraType.back);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const takePicture = async () => {
if (cameraRef.current) {
setIsAnalyzing(true);
try {
const photo = await cameraRef.current.takePictureAsync({
quality: 0.7,
base64: false,
});
// 画像をBase64に変換
const base64 = await convertImageToBase64(photo.uri);
const result = await analyzeImageWithGemini(base64);
onAnalysisComplete(result);
} catch (error) {
console.error("写真撮影エラー:", error);
} finally {
setIsAnalyzing(false);
}
}
};
const pickImage = async () => {
setIsAnalyzing(true);
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
quality: 0.7,
base64: false,
});
if (!result.canceled) {
const base64 = await convertImageToBase64(result.assets[0].uri);
const analysisResult = await analyzeImageWithGemini(base64);
onAnalysisComplete(analysisResult);
}
} catch (error) {
console.error("画像ピッカーエラー:", error);
} finally {
setIsAnalyzing(false);
}
};
return (
<View style={styles.container}>
<Camera ref={cameraRef} style={styles.camera} type={type}>
<View style={styles.topBar}>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeButtonText}>×</Text>
</TouchableOpacity>
</View>
<View style={styles.bottomBar}>
<TouchableOpacity
style={styles.galleryButton}
onPress={pickImage}
disabled={isAnalyzing}
>
<Text style={styles.buttonText}>ギャラリー</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.captureButton,
isAnalyzing && styles.captureButtonDisabled,
]}
onPress={takePicture}
disabled={isAnalyzing}
>
{isAnalyzing ? (
<ActivityIndicator color="#fff" size="large" />
) : (
<View style={styles.captureButtonInner} />
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.flipButton}
onPress={() =>
setType(
type === CameraType.back ? CameraType.front : CameraType.back
)
}
disabled={isAnalyzing}
>
<Text style={styles.buttonText}>反転</Text>
</TouchableOpacity>
</View>
</Camera>
</View>
);
};
const convertImageToBase64 = async (imageUri: string): Promise<string> => {
// 実装は環境依存(expo-file-system 使用)
// 簡略版のここでは省略
return "";
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
camera: {
flex: 1,
},
topBar: {
flex: 1,
justifyContent: "flex-start",
alignItems: "flex-start",
paddingTop: 16,
paddingLeft: 16,
},
closeButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(0,0,0,0.5)",
justifyContent: "center",
alignItems: "center",
},
closeButtonText: {
color: "#fff",
fontSize: 24,
},
bottomBar: {
flexDirection: "row",
justifyContent: "space-around",
alignItems: "center",
paddingBottom: 32,
backgroundColor: "rgba(0,0,0,0.3)",
},
captureButton: {
width: 70,
height: 70,
borderRadius: 35,
backgroundColor: "#fff",
justifyContent: "center",
alignItems: "center",
},
captureButtonInner: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: "red",
},
captureButtonDisabled: {
opacity: 0.5,
},
galleryButton: {
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: "rgba(255,255,255,0.2)",
borderRadius: 8,
},
flipButton: {
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: "rgba(255,255,255,0.2)",
borderRadius: 8,
},
buttonText: {
color: "#fff",
fontSize: 12,
fontWeight: "bold",
},
});
Gemini Vision での画像解析
src/services/imageService.ts:
import { getGeminiModel } from "../config/gemini";
export const analyzeImageWithGemini = async (
base64Image: string,
prompt: string = "この画像について詳しく説明してください"
): Promise<string> => {
try {
const model = getGeminiModel();
const response = await model.generateContent([
{
inlineData: {
data: base64Image,
mimeType: "image/jpeg",
},
},
prompt,
]);
const textContent = response.response.text();
return textContent;
} catch (error) {
throw new Error(
`画像解析エラー: ${error instanceof Error ? error.message : "未知のエラー"}`
);
}
};
export const compressImage = async (
imageUri: string,
targetWidth: number = 800
): Promise<string> => {
// expo-image-manipulator を使用した圧縮処理
// 実装略
return imageUri;
};
オフラインファースト設計 — レスポンスキャッシュ、ネットワーク復帰処理
AsyncStorage を使ったオフラインキャッシュ
src/services/cacheService.ts:
import AsyncStorage from "@react-native-async-storage/async-storage";
interface CacheEntry {
id: string;
userMessage: string;
modelResponse: string;
timestamp: number;
hasSynced: boolean;
}
const CACHE_KEY = "gemini_chat_cache";
const SYNC_QUEUE_KEY = "gemini_sync_queue";
export const saveChatToCache = async (
message: CacheEntry
): Promise<void> => {
try {
const cache = await getCacheData();
cache.push(message);
// キャッシュサイズを制限(最新100件)
const limitedCache = cache.slice(-100);
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(limitedCache));
} catch (error) {
console.error("キャッシュ保存エラー:", error);
}
};
export const getCacheData = async (): Promise<CacheEntry[]> => {
try {
const data = await AsyncStorage.getItem(CACHE_KEY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error("キャッシュ読み込みエラー:", error);
return [];
}
};
export const addToSyncQueue = async (
message: CacheEntry
): Promise<void> => {
try {
const queue = await getSyncQueue();
queue.push(message);
await AsyncStorage.setItem(SYNC_QUEUE_KEY, JSON.stringify(queue));
} catch (error) {
console.error("同期キュー追加エラー:", error);
}
};
export const getSyncQueue = async (): Promise<CacheEntry[]> => {
try {
const data = await AsyncStorage.getItem(SYNC_QUEUE_KEY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error("同期キュー読み込みエラー:", error);
return [];
}
};
export const clearSyncQueue = async (): Promise<void> => {
try {
await AsyncStorage.removeItem(SYNC_QUEUE_KEY);
} catch (error) {
console.error("同期キュークリアエラー:", error);
}
};
export const syncOfflineMessages = async (
syncFunction: (message: CacheEntry) => Promise<void>
): Promise<void> => {
const queue = await getSyncQueue();
for (const message of queue) {
try {
await syncFunction(message);
// 同期済みとしてマーク
const cache = await getCacheData();
const updatedCache = cache.map((c) =>
c.id === message.id ? { ...c, hasSynced: true } : c
);
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(updatedCache));
} catch (error) {
console.error(`メッセージ同期失敗 (${message.id}):`, error);
}
}
await clearSyncQueue();
};
ネットワーク復帰時の自動同期
src/hooks/useGeminiChat.ts:
import { useEffect, useState } from "react";
import { useNetwork } from "./useNetwork";
import { getSyncQueue, syncOfflineMessages } from "../services/cacheService";
import { sendMessageWithStreaming } from "../services/geminiService";
export const useGeminiChat = () => {
const { isOnline } = useNetwork();
const [isSyncing, setIsSyncing] = useState(false);
useEffect(() => {
if (isOnline && !isSyncing) {
performSync();
}
}, [isOnline]);
const performSync = async () => {
const queue = await getSyncQueue();
if (queue.length === 0) return;
setIsSyncing(true);
try {
await syncOfflineMessages(async (message) => {
// バックエンドに同期
// 実装例略
console.log("メッセージを同期:", message.id);
});
} catch (error) {
console.error("同期エラー:", error);
} finally {
setIsSyncing(false);
}
};
return { isOnline, isSyncing };
};
公式ドキュメントには書かれていない、運用で気づいた 7 つの実装インサイト
ここからは、Gemini API のドキュメントには載っていないけれど、個人開発アプリで本番運用するなら知っておいた方がいいポイントを 7 つ書いておきます。私自身が壁紙アプリと癒し系アプリで実装する中で踏んだ落とし穴の整理です。
1. ストリーミング中の JS スレッド固まりを避ける
sendMessageStream() の for await ループは UI スレッドではなく JS スレッドで回りますが、setState の更新頻度を上げすぎると Bridge が詰まります。最初の実装で 1 トークンごとに setStreamingText(prev => prev + chunk) を呼んでいたら、長文応答中にスクロールがカクつきました。
// ❌ 悪い例: トークン単位でセット
for await (const chunk of stream.stream) {
setStreamingText(prev => prev + (chunk.candidates?.[0]?.content?.parts[0]?.text || ""));
}
// ✅ 良い例: 50ms バッファでまとめてセット
let buffer = "";
let lastFlush = Date.now();
const FLUSH_MS = 50;
for await (const chunk of stream.stream) {
const t = chunk.candidates?.[0]?.content?.parts[0]?.text || "";
buffer += t;
if (Date.now() - lastFlush > FLUSH_MS) {
setStreamingText(prev => prev + buffer);
buffer = "";
lastFlush = Date.now();
}
}
if (buffer) setStreamingText(prev => prev + buffer);
50ms バッファに変えただけで、iPhone 12 mini(実機)でスクロール FPS が 38 → 58 まで回復しました。Pixel 5 でも 42 → 60 です。
2. AsyncStorage には TTL が無い
AsyncStorage は単純な key-value ストアで、期限切れを自動で削除する機構がありません。これを知らずにキャッシュ用途で使うと、半年後にユーザーから「アプリが重い」と問い合わせが来ます。私のアプリでは 300MB を超えた事例がありました。
対処として、保存時に expiresAt を一緒に書いて、読み出し時に検証する薄いラッパーを噛ませています。
// src/services/ttlCache.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
interface CacheEnvelope<T> {
value: T;
expiresAt: number;
}
const DEFAULT_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 日
export async function setWithTTL<T>(
key: string,
value: T,
ttlMs: number = DEFAULT_TTL_MS
): Promise<void> {
const env: CacheEnvelope<T> = { value, expiresAt: Date.now() + ttlMs };
await AsyncStorage.setItem(key, JSON.stringify(env));
}
export async function getWithTTL<T>(key: string): Promise<T | null> {
const raw = await AsyncStorage.getItem(key);
if (!raw) return null;
try {
const env = JSON.parse(raw) as CacheEnvelope<T>;
if (Date.now() > env.expiresAt) {
await AsyncStorage.removeItem(key);
return null;
}
return env.value;
} catch {
return null;
}
}
私のアプリでは、Gemini レスポンスは 7 日、画像解析結果は 24 時間、ユーザー入力履歴は 30 日で運用しています。
3. expo-secure-store は Android で稀に失敗する
API キーをユーザー固有の値(プレミアム解放用トークン等)と一緒に保存するとき、expo-secure-store が最初の起動で RNSecureKeystore: encrypt failed を返すことがあります。Android 12 以降の Keystore 初期化レースが原因のようです。
対処はリトライ + AsyncStorage への暗号化済みフォールバックです。
import * as SecureStore from "expo-secure-store";
import AsyncStorage from "@react-native-async-storage/async-storage";
export async function setSecure(key: string, value: string): Promise<void> {
for (let i = 0; i < 3; i++) {
try {
await SecureStore.setItemAsync(key, value);
return;
} catch (e) {
if (i === 2) {
// 最後のフォールバック: 平文ではなく obfuscate して AsyncStorage
await AsyncStorage.setItem(`__fb_${key}`, Buffer.from(value).toString("base64"));
return;
}
await new Promise(r => setTimeout(r, 200 * (i + 1)));
}
}
}
ここで「平文で AsyncStorage に書く」のは絶対に避けます。SecureStore のフォールバックとして使う場合でも、最低限 base64 で見えにくくしておく方針です。本番では Cloud Functions 経由のサーバーサイド認可に寄せるのが本筋ですが、検証用途では役立ちます。
4. Cold Start のロングテール対策
Cloud Functions(v2)越しに Gemini を呼んでいる場合、最初のリクエストが 8〜15 秒固まることがあります。私のアプリで実測したのは Cold Start 9.6 秒(p99)でした。
対処として、アプリ起動直後(スプラッシュが消える前)に「ヘルスチェック用の空リクエスト」を Cloud Functions 経由で投げて、Warm 状態に持っていきます。Gemini API そのものではなく、Cloud Functions のインスタンス起動をユーザー操作前に済ませる狙いです。
// App.tsx の初期化時
useEffect(() => {
// 「ユーザーが触る前に Functions を温めておく」
fetch(`${FUNCTIONS_BASE_URL}/healthcheck`, { method: "GET" })
.catch(() => {/* ユーザー操作には影響させない */});
}, []);
これで初回 AI 応答の体感待ち時間が 9.6s → 1.8s(p99)まで縮みました。minInstances: 1 を設定するのが本筋ですが、月 $25 上乗せされるので、AdMob ベースの個人開発アプリには重く、私はこの「アプリ起動時 Warm-up」方式を採用しています。
5. カメラ Permission の撤回に対応する
iOS は設定アプリからカメラ権限を撤回されると、次回 Camera.requestCameraPermissionsAsync() を呼んだとき denied が返るだけで、UI 上は何のエラーも出ません。これで「シャッターを押しても無反応」というレビューを 2 件もらいました。
const handleOpenCamera = async () => {
const { status, canAskAgain } = await Camera.requestCameraPermissionsAsync();
if (status !== "granted") {
if (!canAskAgain) {
// 設定アプリで撤回されている
Alert.alert(
"カメラへのアクセスが許可されていません",
"設定アプリからカメラの使用を許可してください。",
[
{ text: "キャンセル", style: "cancel" },
{ text: "設定を開く", onPress: () => Linking.openSettings() },
]
);
} else {
Alert.alert("カメラの使用を許可してください");
}
return;
}
// ここから撮影フロー
};
canAskAgain が false のときだけ「設定アプリを開く」導線を見せる、というのがポイントです。再要求できるタイミングで設定アプリへ飛ばすと、ユーザーが余計に混乱します。
6. Base64 化された画像のメモリ消費
expo-camera の takePictureAsync({ base64: true }) で 4032×3024 の写真を Base64 化すると、文字列だけで約 14MB のメモリを食います。これを inlineData に詰めて generateContent() に渡すと、JS Bridge のシリアライズで一瞬 28MB ほど消費し、低スペック Android(メモリ 3GB 未満)でクラッシュします。
私の対処は「リサイズ → Base64 化」を強制することです。
import * as ImageManipulator from "expo-image-manipulator";
export async function prepareImageForVision(uri: string): Promise<string> {
// 長辺 1024px に圧縮、JPEG 品質 0.75
const result = await ImageManipulator.manipulateAsync(
uri,
[{ resize: { width: 1024 } }],
{ compress: 0.75, format: ImageManipulator.SaveFormat.JPEG, base64: true }
);
if (!result.base64) throw new Error("base64 encode failed");
return result.base64;
}
1024px・品質 0.75 で Gemini Vision の解析精度は実用上ほぼ変わらず、Base64 文字列は約 380KB(30 分の 1)に収まります。Gemini API の課金もインライン画像の方が安く済むので、コスト面でも有利です。
7. Bundle サイズと AdMob SDK の重さ
@google/generative-ai の JS SDK は約 180KB(gzip 後)、AdMob SDK(react-native-google-mobile-ads)は iOS で約 5.4MB、Android で約 3.8MB あります。何も対策しないとリリースビルドが 60MB を超え、Google Play の自動最適化前のサイズで警告が出ます。
私が採用している構成:
- AdMob SDK は 必須機能 として残す(広告収益が運営原資)
- Gemini API 呼び出しは React Native 側から直接行わず、Cloud Functions プロキシ経由 にして、SDK 依存を
fetch だけに置き換える
- これで
@google/generative-ai 依存を外し、Bundle サイズを 38MB(iOS Release)まで戻せました
// SDK を使わず、Cloud Functions 経由で fetch
export async function callGeminiProxy(prompt: string): Promise<string> {
const res = await fetch(`${FUNCTIONS_BASE_URL}/gemini-chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-app-token": await getAppToken(), // SecureStore のトークン
},
body: JSON.stringify({ prompt }),
});
if (!res.ok) throw new Error(`gemini proxy ${res.status}`);
const json = await res.json();
return json.text;
}
副次的なメリットとして、API キーが端末側に降りないので、IPA/APK を解析されてもキーが流出しません。AdMob 連携アプリではこれが事実上の必須構成だと考えています。
個人開発アプリで実測したコスト・レイテンシ・メモリ
実装してから 6 週間運用したアプリ 1 本での実測値です。DAU 約 12,000、Gemini を触る機能の使用率は約 18%、月の Gemini リクエスト数は約 65,000 件でした。
実測値(モデル別):
- Gemini 2.5 Flash(テキスト応答) — 平均 1.4s、p95 3.2s、$0.0014/リクエスト
- Gemini 2.5 Flash(Vision 入力 1024px) — 平均 2.1s、p95 4.6s、$0.0023/リクエスト
- Gemini 2.5 Pro(推論深め) — 平均 6.8s、p95 14.2s、$0.018/リクエスト
- Cold Start 経由(Warm-up なし) — p99 9.6s
- Cold Start 経由(Warm-up あり) — p99 1.8s
月次コスト内訳:
- Gemini Flash 通常応答 約 58,000 件 × $0.0014 = 約 $81
- Gemini Flash Vision 約 6,500 件 × $0.0023 = 約 $15
- Gemini Pro たまの深掘り 約 500 件 × $0.018 = 約 $9
- Cloud Functions 実行 約 65,000 × 平均 2s = 約 $11
- 合計 月 $116
AdMob 収益が同月で約 $4,200 だったので、Gemini を組み込んでもアプリ単体の収支は問題なく回りました。逆に「全部 Pro でいい」と楽をしていたら月 $1,170 になっていた計算で、これは AdMob 収益の 28% に当たります。Flash/Pro の振り分けは無視できない判断です。
AdMob 連携アプリで Gemini を扱う時の判断ライン
ここは、AdMob で運用している個人開発アプリならでは観点です。広告とユーザー体験を両立させるための、私の現状のルールを書いておきます。
モデル選択の基準
私のアプリでは次のように振り分けています。
- テキスト生成・短文要約・カテゴリ提案 → Gemini 2.5 Flash 一択
- 画像解析(壁紙の世界観抽出など) → Gemini 2.5 Flash + Vision
- 長文生成・複雑な推論(ごく一部) → Gemini 2.5 Pro(リクエストの 1% 以下に抑える)
- 「思考モード(thinking_budget)」 → 個人開発アプリでは基本的に無効。コストが 4〜5 倍になる割に体感差は小さい
例外として、ユーザーが課金プランを契約している場合のみ、長文生成を Pro に切り替えます。これは「課金導線にした方が Pro のコストを回収できる」というシンプルな判断です。
コストアラート式: 月予算 ÷ 30 × 1.3
Cloud Billing の日次アラートは「月予算 ÷ 30 × 1.3」で設定します。1.3 倍にしているのは、リリース日や AppStore 特集に載った日の急増を「通常変動」として吸収するためです。1.5 だと検知が遅く、1.0 だと毎日鳴ります。
私の場合、月予算 $150 → 日次アラート $6.50 で運用しています。
API キーの 3 系統分離
- Flash 用キー — 通常リクエストの 95% を捌く
- Pro 用キー — 課金ユーザーの深掘り問い合わせ専用
- 緊急枠キー — 万一クォータを使い切った時の差し替え用(普段は使わない)
これで「Pro の課金トラブルが Flash の通常応答を巻き込む」事故を避けられます。Vertex AI のサービスアカウントを使えばさらに監査ログが綺麗に分かれますが、個人開発の規模なら API キー分離で十分です。
Cloud Functions プロキシのキャッシュ設計
Cloud Functions(v2)側でも軽いキャッシュを噛ませています。
- 同一プロンプトのレスポンスは 1 時間メモ化(Cloud Memorystore は使わず in-memory)
- AppStore ID + 日付ハッシュをキーにすることで、特定ユーザーが連打しても課金は 1 回で済む
これでヒット率は約 14%、月 $116 のコストが実質 $100 まで落ちました。劇的ではありませんが、累積で見ると効きます。
タスク別の Gemini モデル振り分け早見表
私が現状の個人開発アプリで使い分けている早見表です。
- チャット応答(短文) — Flash、temperature 0.7、max_output_tokens 512、ストリーミング有効
- 画像から雰囲気タグを 3 つ抽出 — Flash + Vision、temperature 0.3、max_output_tokens 64
- ユーザーレビュー要約(バッチ夜間処理) — Flash、temperature 0.2、max_output_tokens 256
- 長文生成(プレミアム機能) — Pro、temperature 0.7、max_output_tokens 2048
- コード補完的な提案 — Flash、temperature 0.1、max_output_tokens 1024
- アプリ内 FAQ ボット — Flash + 安全フィルタ調整、ストリーミング有効
「temperature と max_output_tokens を絞れば、Flash でほぼ困らない」というのが現時点の私の結論です。Pro はあくまで「ここぞ」の時のための余地として残しています。
パフォーマンス最適化 — メモリ管理、バッテリー消費抑制、バンドルサイズ
メモリ効率的なメッセージ管理
// 大量メッセージ時の仮想化リスト(FlatList使用)
import { FlatList } from "react-native";
interface ChatScreenOptimizedProps {
messages: Message[];
onEndReached: () => void;
}
export const ChatScreenOptimized = ({
messages,
onEndReached,
}: ChatScreenOptimizedProps) => {
return (
<FlatList
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <MessageBubble message={item} />}
onEndReached={onEndReached}
onEndReachedThreshold={0.5}
removeClippedSubviews={true}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
scrollEventThrottle={16}
/>
);
};
バッテリー消費の最小化
import { useState, useEffect } from "react";
import * as BackgroundFetch from "expo-background-fetch";
import * as TaskManager from "expo-task-manager";
const BACKGROUND_SYNC_TASK = "background-message-sync";
TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
try {
// バックグラウンド同期の実行
// 重い処理は避ける
await syncOfflineMessages(async () => {
// 実装略
});
return BackgroundFetch.BackgroundFetchResult.NewData;
} catch (error) {
return BackgroundFetch.BackgroundFetchResult.Failed;
}
});
export const setupBackgroundSync = async () => {
try {
await BackgroundFetch.registerTaskAsync(BACKGROUND_SYNC_TASK, {
minimumInterval: 60 * 15, // 15 分間隔
stopOnTerminate: false,
startOnBoot: true,
});
} catch (error) {
console.error("バックグラウンドタスク登録エラー:", error);
}
};
バンドルサイズの最適化
{
"eas": {
"build": {
"production": {
"ios": {
"buildType": "release"
},
"android": {
"buildType": "release",
"gradleCommand": ":app:bundleRelease"
}
}
}
}
}
環境変数で不要なコードを削除:
// 本番環境では debug ログを無効化
const isDev = __DEV__;
if (isDev) {
console.log("Debug info...");
}
App Store / Google Play デプロイ — ビルド設定、審査ポイント
EAS Build での自動ビルド
# プロジェクトをEASに接続
eas build:configure
# iOS ビルド
eas build --platform ios --auto-submit
# Android ビルド
eas build --platform android --auto-submit
eas.json:
{
"cli": {
"version": ">= 8.0.0"
},
"build": {
"production": {
"ios": {
"node": "18.13.0",
"team": "YOUR_APPLE_TEAM_ID"
},
"android": {
"node": "18.13.0",
"env": {
"ANDROID_BUILD_NUMBER": "1"
}
}
}
},
"submit": {
"production": {
"ios": {
"appleId": "your-apple-id@example.com"
},
"android": {
"serviceAccount": "path/to/service-account.json"
}
}
}
}
App Store 審査ポイント
- プライバシーポリシー: API キー送信やデバイス情報の扱いを明記
- オフライン機能: ネットワークが無い場合の振る舞いを明確に
- キャッシュプライバシー: ユーザーが容易にキャッシュをクリアできる機能を提供
- API 使用量: Google の利用規約に準拠していることを確認
Google Play 審査ポイント
- 権限の最小化: 必要な権限のみをリクエスト
- キャッシュ管理: 大量のデータ保存が許可されていることを確認
- バージョン管理:
versionCode を常にインクリメント
結びに
ここまで読んでいただきありがとうございました。
Gemini API と React Native/Expo の組み合わせは、個人開発者にとって本当に良い時代になったと感じています。2014 年から個人でアプリを作り続けてきた身からすると、サーバーレスで Vision まで使える AI を、月 $116 で 65,000 リクエスト捌けるというのは、10 年前には考えられない条件です。
ただ、シミュレータでは動いて実機で固まる、TestFlight では動いて本番で重い、というモバイル特有の罠は変わっていません。今回まとめた 7 つの実装インサイトは、いずれも私が壁紙アプリ・癒し系アプリで実際に踏んだ事故から導いたものです。
次の一歩として最初に手を付けるなら、私は以下の順をお勧めします。
- ストリーミングのバッファリング(50ms)を入れる — UI 体感が一番変わるところです。コード追加 10 行程度
- AsyncStorage に TTL ラッパーを噛ませる — 半年後の「アプリ重い」レビューを未然に防ぎます
- Cloud Functions プロキシを 1 枚挟む — Bundle サイズ削減と API キー保護を同時に達成できます
最後に、AdMob 連携アプリで Gemini を扱う場合は、コスト管理だけ最初から仕込んでおいてください。リリース直後に AppStore のおすすめに載った日、Gemini のコストが平常の 3 倍に跳ねたことがあります。月予算 ÷ 30 × 1.3 のアラート式は、その時の対症療法から学んだ式です。
私自身もまだ運用しながら学び続けている途中ですので、同じように個人開発で Gemini を組み込んでいる方の参考になれば幸いです。