Expo で組んだチャット画面に、Gemini の応答が一文字ずつ流れ始めた瞬間。手が止まりました。そこにたどり着くまで、三日かかっています。
最初の数日は「公式ドキュメント通りに書いているのに動かない」という状態が続きました。
特にストリーミングレスポンスの実装で詰まりました。Node.jsやNext.jsの環境ではスムーズに動く実装が、React Nativeの環境ではそのままでは動かないのです。EventSourceが使えない、TextDecoderの挙動が微妙に違う、fetch APIのストリーム処理がExpoのJavaScriptエンジンによって制限される……といった問題が次々と出てきます。
Expo環境でGemini APIを使ったAIチャットアプリを実際に作り、App Store・Google Playの審査を通過するまでの実装記録を実例とともに整理しました。公式ドキュメントには書かれていない、実際の開発で詰まったポイントを中心に解説します。
Expo を選んだ理由と前提環境
React Native でモバイルアプリを作る場合、大きく「Expo(マネージドワークフロー)」と「React Native CLI(ベアワークフロー)」の2択があります。個人開発者にとって、Expo を選ぶ理由は明確です。
ネイティブコードを直接書かずに済む点が最大のメリットです。特に iOS と Android 両方に対応する場合、ネイティブモジュールの管理コストが跳ね上がります。AppStore 申請でのビルドも Expo Go や EAS Build を使えば、Mac を持っていない環境でも対応できます。
ただし、ネイティブ API へのアクセスに制限がある点は理解しておく必要があります。Gemini APIの統合においては、カメラ・画像ライブラリへのアクセスは expo-image-picker、音声入力は expo-av を経由するかたちになります。
前提環境は以下の通りです。
- Expo SDK 52 以降(ここでは SDK 53 を使用)
- Node.js 20.x
@google/genai SDK 1.x(@google/generative-ai から移行済み)
- EAS Build によるビルド
Gemini APIクライアントの初期セットアップ
インストールからはじめます。
npx create-expo-app my-ai-app --template blank-typescript
cd my-ai-app
npx expo install @google/genai expo-image-picker expo-secure-store
APIキーの管理は、開発初期に必ず正しく設定しておく必要があります。コードに直接書くのは絶対に避けてください。GitHubにpushした瞬間にスキャンされます。
本番環境では expo-secure-store を使ってデバイス上に安全に保管する方法と、サーバーサイドプロキシ経由でAPIキーを隠す方法があります。個人開発でコスト管理を重視するなら、まずサーバーサイドプロキシを検討してください。APIキーをクライアントに持たせると、ユーザーが意図せず大量のリクエストを送れてしまいます。
開発中のクイックスタートとして、環境変数を使う方法を示します。
// lib/gemini.ts
import { GoogleGenAI } from "@google/genai";
// ⚠️ 本番では環境変数を使い、クライアントにAPIキーを直接持たせない
const API_KEY = process.env.EXPO_PUBLIC_GEMINI_API_KEY ?? "";
if (!API_KEY) {
throw new Error(
"GEMINI_API_KEY が設定されていません。.env.local を確認してください。"
);
}
export const genAI = new GoogleGenAI({ apiKey: API_KEY });
export const getModel = () =>
genAI.models; // モデルはリクエスト時に指定
.env.local に以下を記述します(EXPO_PUBLIC_ プレフィックスがないとクライアントサイドで読めません)。
EXPO_PUBLIC_GEMINI_API_KEY=YOUR_GEMINI_API_KEY
ストリーミングレスポンスの実装と詰まったポイント
ここが最も時間を取られたポイントです。
Webブラウザ環境では EventSource や fetch のボディストリームが比較的素直に動きます。しかし React Native では、fetch のストリームサポートが Hermes エンジンのバージョンと Expo SDK のバージョンによって挙動が変わります。SDK 53 / Hermes 0.75 以降では概ね動くようになりましたが、古いバージョンでは詰まります。
よくある失敗パターン
// ❌ これはReact Nativeでは動かないことがある
const response = await fetch(url, { method: "POST", body: JSON.stringify(body) });
const reader = response.body!.getReader();
// → response.body が null になるケースがある
動作する実装パターン
@google/genai SDK の generateContentStream を使う方法が最もシンプルで信頼性が高いです。SDK がストリーミングの低レベル処理を吸収してくれます。
// hooks/useGeminiChat.ts
import { useState, useCallback } from "react";
import { genAI } from "../lib/gemini";
import type { Content } from "@google/genai";
interface Message {
role: "user" | "model";
text: string;
}
export function useGeminiChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const sendMessage = useCallback(async (userText: string) => {
const userMessage: Message = { role: "user", text: userText };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setIsStreaming(true);
// チャット履歴をGemini SDK のフォーマットに変換
const history: Content[] = updatedMessages.slice(0, -1).map((msg) => ({
role: msg.role,
parts: [{ text: msg.text }],
}));
let assistantText = "";
setMessages((prev) => [...prev, { role: "model", text: "" }]);
try {
const chat = genAI.chats.create({
model: "gemini-2.5-flash",
config: {
systemInstruction:
"あなたは親切なAIアシスタントです。簡潔で分かりやすい日本語で答えてください。",
},
history,
});
const stream = await chat.sendMessageStream({ message: userText });
for await (const chunk of stream) {
const chunkText = chunk.text ?? "";
assistantText += chunkText;
// ストリーミング中にリアルタイムで最後のメッセージを更新
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
role: "model",
text: assistantText,
};
return updated;
});
}
} catch (error) {
console.error("Gemini API エラー:", error);
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
role: "model",
text: "申し訳ありません、エラーが発生しました。しばらくしてから再試行してください。",
};
return updated;
});
} finally {
setIsStreaming(false);
}
}, [messages]);
return { messages, isStreaming, sendMessage };
}
for await ... of stream の構文がReact Nativeで動くかどうかは、Hermes のバージョンに依存します。Expo SDK 52 以降であれば、基本的に問題なく動作します。SDK 51 以前を使っている場合は、アップグレードを先に行うことを強くおすすめします。
ストリーミング中のUIデザイン
ストリーミング中のUX設計で一つ発見があります。チャンク更新のたびに setState を呼ぶと、文字が1文字ずつ流れるような表示になりますが、これが逆にギクシャクして見えることがあります。
解決策として、setTimeout でバッファリングするか、16ms以内のチャンクをまとめてから更新する実装にすると、スムーズに見えます。
// 更新をバッチ処理してUIのちらつきを防ぐ
let updateBuffer = "";
let updateTimer: ReturnType<typeof setTimeout> | null = null;
for await (const chunk of stream) {
updateBuffer += chunk.text ?? "";
if (!updateTimer) {
updateTimer = setTimeout(() => {
const textToAppend = updateBuffer;
updateBuffer = "";
updateTimer = null;
assistantText += textToAppend;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = { role: "model", text: assistantText };
return updated;
});
}, 16); // 約60fps
}
}
チャット履歴の管理と永続化
セッションをまたいでチャット履歴を保持したい場合、AsyncStorage が最もシンプルな選択肢です。ただし、履歴が長くなるとトークン数が増えてコストに直結します。
個人開発でのコスト管理として、直近N件のメッセージだけを履歴として送る「スライディングウィンドウ」方式を採用しています。
// utils/chatHistory.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
const HISTORY_KEY = "chat_history";
const MAX_HISTORY_PAIRS = 10; // 直近10往復(20メッセージ)まで保持
export async function saveHistory(messages: Message[]): Promise<void> {
const trimmed = messages.slice(-MAX_HISTORY_PAIRS * 2);
await AsyncStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed));
}
export async function loadHistory(): Promise<Message[]> {
const stored = await AsyncStorage.getItem(HISTORY_KEY);
return stored ? JSON.parse(stored) : [];
}
export async function clearHistory(): Promise<void> {
await AsyncStorage.removeItem(HISTORY_KEY);
}
公式ドキュメントには「全履歴を渡す」前提のサンプルが多いですが、実際のアプリでは履歴のトリミングは必須です。Gemini 2.5 Flash の input token コストは非常に低いとはいえ、セッションをまたぐたびに全履歴を送り続けると、ヘビーユーザーのコストが跳ね上がります。
画像入力(マルチモーダル)の実装
Gemini APIはマルチモーダルに対応しているので、画像の解析や説明ができます。Expoでカメラ・ライブラリから画像を取得し、Gemini APIに渡す実装を示します。
// components/ImagePickerButton.tsx
import * as ImagePicker from "expo-image-picker";
import * as FileSystem from "expo-file-system";
export async function pickImageAndAnalyze(
prompt: string = "この画像を説明してください"
): Promise<string> {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.7, // 品質を下げてファイルサイズを削減(コスト削減)
base64: true, // base64エンコードを有効化
});
if (result.canceled || !result.assets?.[0]) {
throw new Error("画像が選択されませんでした");
}
const asset = result.assets[0];
if (!asset.base64) {
throw new Error("base64エンコードに失敗しました");
}
// MIMEタイプを取得
const mimeType = asset.mimeType ?? "image/jpeg";
// Gemini APIへの送信
const response = await genAI.models.generateContent({
model: "gemini-2.5-flash",
contents: [
{
parts: [
{ text: prompt },
{
inlineData: {
mimeType,
data: asset.base64,
},
},
],
},
],
});
return response.text ?? "";
}
ここで注意点があります。quality: 0.7 で圧縮していますが、大きな画像を inlineData で送ると、ペイロードが大きくなりすぎてタイムアウトすることがあります。目安として2MB以下に収まるよう、expo-image-manipulator を使って事前にリサイズしておくと安心です。
また、expo-image-picker の base64: true オプションはデフォルトでは無効です。設定し忘れると asset.base64 が null になり、ランタイムエラーが発生します。これは私が最初に詰まったポイントの一つです。
コスト管理とフリーミアム設計
個人開発でAPIコストを管理しながら収益化するには、設計段階でコスト上限を組み込む必要があります。私が採用している設計パターンを紹介します。
無料枠とAdMob収益のバランス
壁紙アプリで10年以上AdMobと向き合ってきた経験から言うと、ユーザーはADを受け入れてくれる代わりに機能を使いたいというマインドがあります。AIチャットアプリでも同じ設計が使えます。
- 無料ユーザー: 1日10回まで(Gemini 2.5 Flash使用)、回数超過後は広告視聴で追加
- 有料ユーザー: 無制限(Gemini 2.5 Pro も使用可)
リクエスト回数はサーバーサイドで管理するのが理想ですが、個人開発の初期フェーズでは AsyncStorage でローカル管理するシンプルな方法も現実的です。
// utils/usageTracker.ts
const USAGE_KEY = "daily_usage";
const FREE_LIMIT = 10;
interface DailyUsage {
date: string;
count: number;
}
export async function checkAndIncrementUsage(): Promise<{
allowed: boolean;
remaining: number;
}> {
const today = new Date().toISOString().split("T")[0];
const stored = await AsyncStorage.getItem(USAGE_KEY);
const usage: DailyUsage = stored
? JSON.parse(stored)
: { date: today, count: 0 };
// 日付が変わったらリセット
if (usage.date !== today) {
usage.date = today;
usage.count = 0;
}
if (usage.count >= FREE_LIMIT) {
return { allowed: false, remaining: 0 };
}
usage.count += 1;
await AsyncStorage.setItem(USAGE_KEY, JSON.stringify(usage));
return { allowed: true, remaining: FREE_LIMIT - usage.count };
}
ローカル管理の弱点はサーバーと同期していないため、アプリ再インストールでリセットされてしまうことです。本格的なリリースの前に、Firebase Auth + Firestore での管理に移行することを検討してください。ただし「まずリリースして反応を見る」フェーズでは十分機能します。
モデル選択のコスト設計
Gemini 2.5 Flash と Gemini 2.5 Pro のコスト差は大きいです。Flash は Pro の約1/10のコストで、日常的な質問応答には十分な精度があります。
// 用途に応じてモデルを使い分ける
const MODEL_CONFIG = {
free: "gemini-2.5-flash", // 無料ユーザー向け:コスト重視
premium: "gemini-2.5-pro", // 有料ユーザー向け:品質重視
image: "gemini-2.5-flash", // 画像解析:FlashでもVisionは十分
} as const;
export function selectModel(isPremium: boolean, hasImage: boolean): string {
if (hasImage) return MODEL_CONFIG.image;
return isPremium ? MODEL_CONFIG.premium : MODEL_CONFIG.free;
}
App Store / Google Play 審査での注意点
AIアプリ固有の審査対策で、事前に知っておけばよかったと感じたポイントを共有します。
App Store(Apple)
Apple のガイドラインでは、AIが生成したコンテンツに対してアプリ側が責任を持つことが求められます。具体的には以下の対応が必要です。
プライバシーポリシーの明記: チャット内容がGemini APIに送信されることを明確に記載する必要があります。「Google の Gemini API を使用しており、入力テキストはGoogle のサーバーで処理されます」という旨を、プライバシーポリシーとアプリ内の説明の両方に記載してください。
AI生成コンテンツの開示: アプリの説明文やApp Store Connect の「プライバシーの実践」セクションに、AIが回答を生成していることを明記します。私の場合、App Storeのスクリーンショットにも「AI powered」の表示を入れました。
年齢制限: AIチャットアプリは基本的に17歳以上の制限(4+では通過しないケースがある)を設定するよう求められることがあります。コンテンツフィルタリングの実装状況も確認されます。
Gemini APIのデフォルト安全設定は比較的厳しく設定されているので、多くのケースでは追加フィルタリングなしで審査を通過できます。ただし、カスタムプロンプトで安全設定を緩めた場合は要注意です。
Google Play(Android)
Google Play は AI 関連のポリシーがAppleより明示的です。「AIによって生成されたコンテンツ」として扱われ、ヘイトスピーチやハラスメントにつながるコンテンツの生成を防ぐ仕組みが必要と明記されています。
実際の申請では、「どのようにコンテンツをモデレートしているか」を説明できる状態にしておく点が肝心です。Gemini APIの安全設定を適切に構成していることをスクリーンショットや資料で示せるようにしておくと、審査官とのやり取りがスムーズになります。
両プラットフォーム共通
ネットワーク要件の開示: APIへのアクセスにはインターネット接続が必要であることを明記します。オフライン時の挙動(エラーメッセージ等)も実装しておく必要があります。
コンプライアンス: Gemini APIの利用規約に準拠していることが前提です。特にユーザーデータの二次利用に関する条件を確認してください。
ストリーミング方式の実測比較 — どこで体感が変わるのか
ストリーミングを入れるかどうかで迷う方が多いので、手元の数字を残しておきます。
iPhone 14(iOS 18.4)を Wi-Fi 下に置き、同じプロンプト(日本語 400 字の文章に対する要約依頼)を各方式 30 回ずつ実行した平均値です。モデルは gemini-2.5-flash、maxOutputTokens は 512 に固定しました。
| 方式 | 最初の文字が出るまで | 全文が出そろうまで | 実装の手間 |
一括取得(generateContent) | —(4,820 ms まで無表示) | 4,820 ms | 小 |
streamGenerateContent + 手動チャンク分割 | 640 ms | 5,110 ms | 中 |
expo/fetch のストリーム API | 610 ms | 5,040 ms | 中 |
注目していただきたいのは、全文が出そろうまでの時間はストリーミングの方が 200〜300 ms 遅いという点です。総処理時間だけを見れば、ストリーミングは負けています。
それでも私はストリーミングを選びます。無表示の 4.8 秒と、0.6 秒で動き始めて 5.1 秒で終わる体験は、まったく別物だからです。前者ではユーザーがアプリを閉じます。実際、一括取得のまま TestFlight に配ったビルドでは、応答待ちの途中で画面を離れる操作が目に見えて多くありました。
数字の上での最適化と、指が離れるかどうかは別の軸にあります。
チャンク境界で JSON が割れる
React Native でストリーミングを実装したとき、いちばん時間を溶かしたのがこれでした。streamGenerateContent が返すチャンクは、JSON オブジェクトの途中で切れます。到着したテキストをそのまま JSON.parse に渡す実装は、数十回に一度の頻度で落ちます。
再現率が低いので、開発中は気づけません。リリース後にクラッシュログで気づく類の不具合です。
// ❌ Before: チャンクがそのまま完全な JSON である前提
for await (const chunk of stream) {
const json = JSON.parse(chunk); // 数十回に一度落ちる
appendText(json.candidates[0].content.parts[0].text);
}
バッファに溜め、閉じ括弧が揃った時点でだけ切り出す。それだけで安定します。
// ✅ After: 未完成の断片をバッファに残す
let buffer = "";
function drainCompleteObjects(chunk: string): string[] {
buffer += chunk;
const results: string[] = [];
let depth = 0;
let start = -1;
let inString = false;
let escaped = false;
for (let i = 0; i < buffer.length; i++) {
const c = buffer[i];
if (escaped) { escaped = false; continue; }
if (c === "\\") { escaped = true; continue; }
if (c === '"') { inString = !inString; continue; }
if (inString) continue;
if (c === "{") { if (depth === 0) start = i; depth++; }
else if (c === "}") {
depth--;
if (depth === 0 && start >= 0) {
results.push(buffer.slice(start, i + 1));
start = -1;
}
}
}
// 未完成の断片だけを残す
buffer = start >= 0 ? buffer.slice(start) : "";
return results;
}
depth を数えているのは、text の中に { を含む回答(コードを返させたときなど)で誤検出しないためです。文字列リテラルの中は無視する必要があります。この 2 点を落とすと、コード生成を扱った瞬間に壊れます。
公式ドキュメントに書かれていない運用の勘所
ドキュメントは「動かし方」を教えてくれますが、「壊れ方」は教えてくれません。個人開発で運用しながら気づいた点を並べます。
入力トークンは、会話が伸びるほど加速して増えます。 毎ターン履歴を丸ごと送り直す実装では、10 往復目のリクエストは 1 往復目のおよそ 10 倍のトークンを積んでいます。ターン数に対して線形、累計では二次で効いてきます。履歴の要約か、コンテキストキャッシュのどちらかを早めに入れておくと後が楽です。キャッシュ側の設計は Gemini API コンテキストキャッシュでコストを削減する に、履歴の持ち方そのものは 長期記憶とセッション永続化の設計パターン にまとめています。
429 のリトライは、コストを増幅させます。 指数バックオフは正しい対処ですが、リトライしたリクエストにも課金は発生します。レート超過が常態化しているときにリトライだけ厚くすると、請求だけが静かに伸びます。まず並列度を下げるほうが先です。
画像は送る前に縮める。 inlineData のペイロードが 2 MB を超えたあたりから、モバイル回線でのタイムアウトが跳ね上がりました。長辺 1,024 px・JPEG 品質 0.7 まで落としても、UI のスクリーンショット判定の精度はほとんど変わっていません。同じ判断の根拠は 画像を送る前に縮小しておくべき理由 に書きました。
思考量は既定値に任せない。 推論の深さを制御できるモデルでは、既定のまま使うとチャット用途には過剰なことがあります。上限を明示するだけで請求が目に見えて落ちます。制御の実装は thinking_budget を制御してコストを抑える が詳しいです。
私はまず Flash 側に寄せた設計から始めることを好みます。品質が足りない箇所を特定してから Pro に上げるほうが、最初から Pro を敷いてコストを削っていくより、判断の材料が残ります。
リリース前に通しているチェックリスト
審査に出す前、私は次の順に確認しています。
- APIキーがクライアントに埋め込まれていないこと(
EXPO_PUBLIC_ 接頭辞の変数はバンドルに入ります)
- 機内モードでアプリを起動し、エラーメッセージが表示され、クラッシュしないこと
- 応答の途中で画面遷移した際、ストリームが確実に中断されること(
AbortController の接続確認)
- 5,000 字を超える長文をペーストしても UI が固まらないこと
- 1 日の無料上限に到達した状態で、案内文と導線が正しく出ること
- 画像入力で
base64: true を外した場合の分岐が例外にならないこと
- プライバシーポリシーに「入力内容が Google のサーバーで処理される」旨が明記されていること
- トークン使用量が計測できていること(後述のログを最初から入れておく)
このうち 3 番と 6 番は、実際に審査ではなく実機で落ちて気づいた項目です。
本番運用で意識していること
リリース後に気づいたことをいくつか共有します。
APIのレートリミットは、予想より早く到達します。Gemini 2.5 Flash でも無料枠のRPM(1分あたりのリクエスト数)を超えると429エラーが発生します。エラーハンドリングに指数バックオフを実装しておくことを強くおすすめします。
// utils/retry.ts
export async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error: any) {
const isRateLimit =
error?.status === 429 || error?.message?.includes("429");
if (!isRateLimit || attempt === maxRetries - 1) throw error;
const delay = baseDelay * Math.pow(2, attempt);
console.log(`レートリミット到達。${delay}ms後に再試行...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error("最大リトライ回数を超えました");
}
AIを組み込んだアプリは「公開してから本当の問題が見える」という感覚が、他のジャンルより強くあります。ストリーミングの遅延、モデルの応答品質のばらつき、コストの予想外の跳ね上がり——これらをリリース前にすべて想定するのは、正直に申し上げて難しいです。
大切なのは、観測できる状態を作ることです。トークン使用量のログ、エラーレート、ユーザーのセッション継続率——これらを計測できる仕組みを最初から組み込んでおくと、改善の優先順位をつけやすくなります。
Gemini APIと個人開発の組み合わせには、まだ探索されていない可能性が多く残っていると感じています。同じ課題に取り組んでいる方の参考になれば幸いです。
ストリーミングの一文字目が流れた瞬間の感覚を、どなたかにも味わっていただけたら嬉しいです。お読みいただきありがとうございました。