2014年から iPhone / Android 向けに壁紙アプリや癒し系アプリを個人開発していて、累計 5,000万 DL を超えたあたりから「ユーザーから直接フィードバックをもらえる小さな AI 機能」を載せてみたい、と長く考えていました。Gemini API が Flash 系で十分実用的な単価まで降りてきた 2026 年、手元の Flutter アプリの一部に AI チャットを組み込んで実運用に回したのが、この記事のもとになっています。
私の作品制作(国際芸術賞 17 冠の現代美術プロジェクト)と Gemini のような AI ツール活用ははっきり分けていて、作品の側には AI を一切入れていません。一方で、長年の制作で身についた「同じ素材を別の角度から何度も組み直す」という習慣は、AI チャット機能の UI 設計にもよく効きました。読者のあなたが個人開発の延長で AI を載せようとしているなら、まずはここに書いた範囲で「動くものを 1 本リリースする」ことから始めるのがいちばん近道だと感じています。
取り組みの背景 — なぜ Flutter × Gemini を個人開発で選んだのか
最初に断っておくと、私の個人開発アプリは AdMob 広告収益で運用していて、月の AdMob 売上が固定費を上回るかどうかが事業の生命線です。AI チャットを足す動機は「課金率を上げたい」ではなく、「無料ユーザーの 1 セッションあたり滞在時間を伸ばし、結果として広告インプレッションを増やす」ことでした。
Flutter は単一コードベースで iOS / Android / Web をカバーでき、google_generative_ai パッケージで Gemini API をそのまま叩けます。SwiftUI / Jetpack Compose で 2 本書く手間が省けるので、個人開発の限られた時間配分にちょうど合います。Gemini Flash の入力 1M トークンあたり $0.075、出力 $0.30(2026年5月時点)という単価は、平均 1 セッション 800〜1,200 トークンの利用なら、AdMob の追加 eCPM で十分回収できる計算でした。
この記事は Gemini API × Kotlin で Android アプリに AI を組み込む実践ガイド で扱ったネイティブ実装の知見を、クロスプラットフォームに延ばして「個人開発の運用に耐える」ところまで持っていく内容です。
環境構築とプロジェクトセットアップ
前提条件
開発を始める前に、以下の環境を準備してください。
- Flutter SDK 3.22 以上(Dart 3.4 以上)
- Google AI Studio で取得した Gemini API キー
- Firebase プロジェクト(認証・Firestore を使用する場合)
- Android Studio または VS Code(Flutter 拡張機能付き)
プロジェクト作成と依存パッケージ
まず Flutter プロジェクトを作成し、必要なパッケージを追加します。
flutter create gemini_chat_app
cd gemini_chat_app
flutter pub add google_generative_ai
flutter pub add flutter_markdown
flutter pub add image_picker
flutter pub add firebase_core firebase_auth cloud_firestore
flutter pub add flutter_riverpod
flutter pub add envied envied_generator build_runner --dev
主要パッケージの役割は以下の通りです。
google_generative_ai — Gemini API 公式 Dart SDK。モデル呼び出し・ストリーミング・マルチモーダル入力を統一的に扱える
flutter_markdown — Gemini の Markdown 形式レスポンスをリッチにレンダリング
flutter_riverpod — 状態管理。チャット履歴やストリーミング状態の管理に使用
envied — API キーをコンパイル時に難読化して埋め込む(ハードコード防止)
API キーの安全な管理
API キーをソースコードにハードコードするのは厳禁です。envied パッケージを使い、環境変数から読み込む設計にします。
// lib/env/env.dart
import 'package:envied/envied.dart';
part 'env.g.dart';
@Envied(path: '.env')
abstract class Env {
@EnviedField(varName: 'GEMINI_API_KEY', obfuscate: true)
static String geminiApiKey = _Env.geminiApiKey;
}
プロジェクトルートに .env ファイルを作成し、.gitignore に追加してください。
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
dart run build_runner build
これにより、API キーがコンパイル済みバイナリ内で難読化され、リバースエンジニアリングへの耐性が向上します。
Gemini サービス層の設計
アプリケーションの中核となる Gemini API とのやり取りを、サービスクラスとして分離します。これにより、UI 層とビジネスロジックの責務を明確に分けられます。
// lib/services/gemini_service.dart
import 'dart:async';
import 'dart:typed_data';
import 'package:google_generative_ai/google_generative_ai.dart';
import '../env/env.dart';
class GeminiService {
late final GenerativeModel _model;
late final GenerativeModel _visionModel;
ChatSession? _chatSession;
GeminiService() {
// テキストチャット用モデル
_model = GenerativeModel(
model: 'gemini-2.5-flash',
apiKey: Env.geminiApiKey,
generationConfig: GenerationConfig(
temperature: 0.7,
topP: 0.95,
topK: 40,
maxOutputTokens: 8192,
),
systemInstruction: Content.text(
'あなたは親切で知識豊富なAIアシスタントです。'
'ユーザーの質問に対して、正確で分かりやすい回答を提供してください。'
'コード例を含む場合は、Markdown形式で記述してください。',
),
);
// マルチモーダル用モデル
_visionModel = GenerativeModel(
model: 'gemini-2.5-flash',
apiKey: Env.geminiApiKey,
generationConfig: GenerationConfig(
temperature: 0.4,
maxOutputTokens: 4096,
),
);
}
/// 新しいチャットセッションを開始
void startNewChat({List<Content>? history}) {
_chatSession = _model.startChat(history: history ?? []);
}
/// ストリーミングでメッセージを送信
Stream<String> sendMessageStream(String message) async* {
_chatSession ??= _model.startChat();
final response = _chatSession!.sendMessageStream(
Content.text(message),
);
await for (final chunk in response) {
final text = chunk.text;
if (text != null && text.isNotEmpty) {
yield text;
}
}
}
/// 画像付きメッセージを送信(マルチモーダル)
Future<String> sendImageMessage({
required String prompt,
required Uint8List imageBytes,
required String mimeType,
}) async {
final content = Content.multi([
TextPart(prompt),
DataPart(mimeType, imageBytes),
]);
final response = await _visionModel.generateContent([content]);
return response.text ?? '応答を生成できませんでした。';
}
/// チャット履歴を取得
List<Content>? get chatHistory => _chatSession?.history.toList();
/// セッションをリセット
void resetSession() {
_chatSession = null;
}
}
ここでのポイントは3つあります。
第一に、テキストチャットとマルチモーダルでモデルインスタンスを分けている点です。テキストチャットでは ChatSession による会話履歴の自動管理が必要ですが、画像解析は単発のリクエストで十分なケースが多いため、パラメータを別々に最適化しています。
第二に、sendMessageStream がストリーミングを Stream<String> として返す設計です。Flutter の StreamBuilder と自然に統合でき、UI でリアルタイムにテキストが流れる体験を実現できます。
第三に、systemInstruction でモデルの振る舞いを定義している点です。Gemini 2.5 系モデルでは System Instruction が正式サポートされており、チャットの一貫性を保つ上で非常に有効です。
状態管理 — Riverpod によるチャットステート設計
チャットアプリの状態管理は予想以上に複雑です。メッセージ一覧、送信中フラグ、ストリーミング中の部分テキスト、エラー状態——これらを適切に管理するために、Riverpod を使った設計を採用します。
// lib/models/chat_message.dart
import 'dart:typed_data';
enum MessageRole { user, assistant }
enum MessageStatus { complete, streaming, error }
class ChatMessage {
final String id;
final MessageRole role;
final String text;
final MessageStatus status;
final DateTime timestamp;
final Uint8List? imageBytes;
const ChatMessage({
required this.id,
required this.role,
required this.text,
this.status = MessageStatus.complete,
required this.timestamp,
this.imageBytes,
});
ChatMessage copyWith({
String? text,
MessageStatus? status,
}) {
return ChatMessage(
id: id,
role: role,
text: text ?? this.text,
status: status ?? this.status,
timestamp: timestamp,
imageBytes: imageBytes,
);
}
}
// lib/providers/chat_provider.dart
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/chat_message.dart';
import '../services/gemini_service.dart';
final geminiServiceProvider = Provider((ref) => GeminiService());
final chatProvider =
StateNotifierProvider<ChatNotifier, List<ChatMessage>>((ref) {
return ChatNotifier(ref.watch(geminiServiceProvider));
});
final isLoadingProvider = StateProvider<bool>((ref) => false);
class ChatNotifier extends StateNotifier<List<ChatMessage>> {
final GeminiService _geminiService;
ChatNotifier(this._geminiService) : super([]);
/// テキストメッセージを送信(ストリーミング)
Future<void> sendMessage(String text, WidgetRef ref) async {
// ユーザーメッセージを追加
final userMsg = ChatMessage(
id: DateTime.now().millisecondsSinceEpoch.toString(),
role: MessageRole.user,
text: text,
timestamp: DateTime.now(),
);
state = [...state, userMsg];
// AI応答のプレースホルダーを追加
final assistantId =
(DateTime.now().millisecondsSinceEpoch + 1).toString();
final assistantMsg = ChatMessage(
id: assistantId,
role: MessageRole.assistant,
text: '',
status: MessageStatus.streaming,
timestamp: DateTime.now(),
);
state = [...state, assistantMsg];
ref.read(isLoadingProvider.notifier).state = true;
try {
final buffer = StringBuffer();
await for (final chunk
in _geminiService.sendMessageStream(text)) {
buffer.write(chunk);
// ストリーミング中のメッセージを更新
state = [
...state.where((m) => m.id != assistantId),
assistantMsg.copyWith(
text: buffer.toString(),
),
];
}
// 完了状態に更新
state = [
...state.where((m) => m.id != assistantId),
assistantMsg.copyWith(
text: buffer.toString(),
status: MessageStatus.complete,
),
];
} catch (e) {
state = [
...state.where((m) => m.id != assistantId),
assistantMsg.copyWith(
text: 'エラーが発生しました: ${e.toString()}',
status: MessageStatus.error,
),
];
} finally {
ref.read(isLoadingProvider.notifier).state = false;
}
}
/// 画像付きメッセージを送信
Future<void> sendImageMessage({
required String prompt,
required Uint8List imageBytes,
required String mimeType,
required WidgetRef ref,
}) async {
final userMsg = ChatMessage(
id: DateTime.now().millisecondsSinceEpoch.toString(),
role: MessageRole.user,
text: prompt,
timestamp: DateTime.now(),
imageBytes: imageBytes,
);
state = [...state, userMsg];
ref.read(isLoadingProvider.notifier).state = true;
try {
final response = await _geminiService.sendImageMessage(
prompt: prompt,
imageBytes: imageBytes,
mimeType: mimeType,
);
state = [
...state,
ChatMessage(
id: (DateTime.now().millisecondsSinceEpoch + 1).toString(),
role: MessageRole.assistant,
text: response,
timestamp: DateTime.now(),
),
];
} catch (e) {
state = [
...state,
ChatMessage(
id: (DateTime.now().millisecondsSinceEpoch + 1).toString(),
role: MessageRole.assistant,
text: 'エラーが発生しました: ${e.toString()}',
status: MessageStatus.error,
timestamp: DateTime.now(),
),
];
} finally {
ref.read(isLoadingProvider.notifier).state = false;
}
}
/// 会話をリセット
void clearChat() {
_geminiService.resetSession();
state = [];
}
}
この設計で重要なのは、ストリーミング中のメッセージ更新です。sendMessageStream から chunk が到着するたびに state を更新することで、UI 側では StreamBuilder を使わずとも Riverpod の watch だけでリアルタイム更新が実現できます。
ただし、state の再代入は毎回リスト全体のコピーを伴うため、メッセージ数が数百を超える長い会話では、パフォーマンスに影響が出る可能性があります。そのような場合は、ストリーミング中のメッセージだけを別の StateProvider に切り出す最適化を検討してください。
チャット UI の実装 — ストリーミング対応
UI 層では、Gemini のレスポンスをリアルタイムに表示するチャット画面を構築します。Markdown レンダリング対応で、コードブロックも正しく表示されるようにします。
// lib/screens/chat_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:image_picker/image_picker.dart';
import '../models/chat_message.dart';
import '../providers/chat_provider.dart';
class ChatScreen extends ConsumerStatefulWidget {
const ChatScreen({super.key});
@override
ConsumerState<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends ConsumerState<ChatScreen> {
final _controller = TextEditingController();
final _scrollController = ScrollController();
@override
void dispose() {
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
Future<void> _sendMessage() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
_controller.clear();
await ref.read(chatProvider.notifier).sendMessage(text, ref);
_scrollToBottom();
}
Future<void> _pickAndSendImage() async {
final picker = ImagePicker();
final image = await picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1024,
);
if (image == null) return;
final bytes = await image.readAsBytes();
final mimeType = image.path.endsWith('.png')
? 'image/png'
: 'image/jpeg';
await ref.read(chatProvider.notifier).sendImageMessage(
prompt: _controller.text.isNotEmpty
? _controller.text.trim()
: 'この画像について説明してください。',
imageBytes: bytes,
mimeType: mimeType,
ref: ref,
);
_controller.clear();
_scrollToBottom();
}
@override
Widget build(BuildContext context) {
final messages = ref.watch(chatProvider);
final isLoading = ref.watch(isLoadingProvider);
// ストリーミング中は自動スクロール
if (messages.isNotEmpty &&
messages.last.status == MessageStatus.streaming) {
_scrollToBottom();
}
return Scaffold(
appBar: AppBar(
title: const Text('Gemini Chat'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () =>
ref.read(chatProvider.notifier).clearChat(),
),
],
),
body: Column(
children: [
Expanded(
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: messages.length,
itemBuilder: (context, index) {
return _MessageBubble(message: messages[index]);
},
),
),
_InputBar(
controller: _controller,
isLoading: isLoading,
onSend: _sendMessage,
onPickImage: _pickAndSendImage,
),
],
),
);
}
}
class _MessageBubble extends StatelessWidget {
final ChatMessage message;
const _MessageBubble({required this.message});
@override
Widget build(BuildContext context) {
final isUser = message.role == MessageRole.user;
final theme = Theme.of(context);
return Align(
alignment: isUser
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.all(12),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
),
decoration: BoxDecoration(
color: isUser
? theme.colorScheme.primary.withValues(alpha: 0.1)
: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.imageBytes != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
message.imageBytes!,
height: 150,
fit: BoxFit.cover,
),
),
),
if (isUser)
Text(message.text)
else
MarkdownBody(
data: message.text,
selectable: true,
),
if (message.status == MessageStatus.streaming)
const Padding(
padding: EdgeInsets.only(top: 4),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
),
);
}
}
class _InputBar extends StatelessWidget {
final TextEditingController controller;
final bool isLoading;
final VoidCallback onSend;
final VoidCallback onPickImage;
const _InputBar({
required this.controller,
required this.isLoading,
required this.onSend,
required this.onPickImage,
});
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.image),
onPressed: isLoading ? null : onPickImage,
),
Expanded(
child: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'メッセージを入力...',
border: OutlineInputBorder(
borderRadius:
BorderRadius.all(Radius.circular(24)),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
maxLines: 4,
minLines: 1,
textInputAction: TextInputAction.send,
onSubmitted: (_) => onSend(),
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.send),
onPressed: isLoading ? null : onSend,
),
],
),
),
);
}
}
UI 実装で特に注目すべきは、ストリーミング中の自動スクロール処理です。messages.last.status == MessageStatus.streaming を監視し、新しいチャンクが到着するたびに _scrollToBottom を呼び出すことで、ユーザーは AI の応答が「タイピングされている」ような自然な体験を得られます。
また、MarkdownBody を使って AI 応答をレンダリングしているため、Gemini がコードブロックやリスト、太字などの Markdown 記法で返した場合も、美しく表示されます。
Firebase 連携 — 認証と会話履歴の永続化
本番アプリでは、ユーザー認証と会話履歴の永続化が不可欠です。Firebase Auth でログイン機能を、Cloud Firestore で会話データの保存を実装します。
// lib/services/firestore_service.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../models/chat_message.dart';
class FirestoreService {
final FirebaseFirestore _db = FirebaseFirestore.instance;
String? get _userId => FirebaseAuth.instance.currentUser?.uid;
/// 会話リストを取得
Stream<List<Map<String, dynamic>>> getConversations() {
if (_userId == null) return Stream.value([]);
return _db
.collection('users')
.doc(_userId)
.collection('conversations')
.orderBy('updatedAt', descending: true)
.limit(50)
.snapshots()
.map((snap) => snap.docs
.map((doc) => {'id': doc.id, ...doc.data()})
.toList());
}
/// メッセージを保存
Future<void> saveMessage({
required String conversationId,
required ChatMessage message,
}) async {
if (_userId == null) return;
final convRef = _db
.collection('users')
.doc(_userId)
.collection('conversations')
.doc(conversationId);
// 会話ドキュメントの更新
await convRef.set({
'updatedAt': FieldValue.serverTimestamp(),
'title': message.role == MessageRole.user
? message.text.substring(
0,
message.text.length > 50 ? 50 : message.text.length,
)
: null,
}, SetOptions(merge: true));
// メッセージを追加
await convRef.collection('messages').doc(message.id).set({
'role': message.role.name,
'text': message.text,
'timestamp': FieldValue.serverTimestamp(),
});
}
/// 会話のメッセージ一覧を取得
Future<List<ChatMessage>> loadMessages(
String conversationId) async {
if (_userId == null) return [];
final snap = await _db
.collection('users')
.doc(_userId)
.collection('conversations')
.doc(conversationId)
.collection('messages')
.orderBy('timestamp')
.get();
return snap.docs.map((doc) {
final data = doc.data();
return ChatMessage(
id: doc.id,
role: data['role'] == 'user'
? MessageRole.user
: MessageRole.assistant,
text: data['text'] ?? '',
timestamp:
(data['timestamp'] as Timestamp?)?.toDate() ??
DateTime.now(),
);
}).toList();
}
}
Firestore のデータ構造は users/{uid}/conversations/{convId}/messages/{msgId} という階層型です。この設計により、Firestore のセキュリティルールで users/{uid} 配下のデータを当該ユーザーのみに制限でき、プライバシーを確保できます。
Firestore セキュリティルール
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId}/{document=**} {
allow read, write: if request.auth != null
&& request.auth.uid == userId;
}
}
}
エラーハンドリングとリトライ戦略
本番環境では、ネットワーク断、API レート制限、タイムアウトなど様々なエラーが発生します。堅牢なエラーハンドリングを実装しましょう。
// lib/services/gemini_service_resilient.dart
import 'dart:async';
import 'package:google_generative_ai/google_generative_ai.dart';
class ResilientGeminiService {
static const _maxRetries = 3;
static const _baseDelay = Duration(seconds: 1);
/// 指数バックオフ付きリトライ
Future<T> _withRetry<T>(Future<T> Function() operation) async {
for (var attempt = 0; attempt < _maxRetries; attempt++) {
try {
return await operation();
} on GenerativeAIException catch (e) {
// レート制限 (429) の場合はリトライ
if (e.toString().contains('429') &&
attempt < _maxRetries - 1) {
final delay = _baseDelay * (1 << attempt);
await Future.delayed(delay);
continue;
}
rethrow;
} on TimeoutException {
if (attempt < _maxRetries - 1) {
await Future.delayed(_baseDelay * (1 << attempt));
continue;
}
throw Exception(
'リクエストがタイムアウトしました。'
'ネットワーク接続を確認してください。');
}
}
throw Exception('最大リトライ回数に達しました。');
}
/// タイムアウト付きリクエスト
Future<GenerateContentResponse> generateWithTimeout(
GenerativeModel model,
List<Content> content, {
Duration timeout = const Duration(seconds: 30),
}) {
return _withRetry(() => model
.generateContent(content)
.timeout(timeout));
}
}
指数バックオフ(Exponential Backoff)は、リトライ間隔を 1 秒 → 2 秒 → 4 秒と倍増させる手法です。Gemini API のレート制限に引っかかった場合、即座にリトライするとさらに制限される可能性がありますが、間隔を空けることで回復を待てます。
パフォーマンス最適化
レスポンスキャッシュ
同じ質問に対して毎回 API を呼ぶのはコストの無駄です。よくある質問への回答をローカルにキャッシュする仕組みを実装します。
// lib/services/cache_service.dart
import 'dart:collection';
class ResponseCache {
static const _maxSize = 100;
final _cache = LinkedHashMap<String, _CacheEntry>();
String? get(String prompt) {
final entry = _cache[_normalizeKey(prompt)];
if (entry == null) return null;
// 1時間以内のキャッシュのみ有効
if (DateTime.now().difference(entry.timestamp).inHours >= 1) {
_cache.remove(_normalizeKey(prompt));
return null;
}
return entry.response;
}
void put(String prompt, String response) {
final key = _normalizeKey(prompt);
if (_cache.length >= _maxSize) {
_cache.remove(_cache.keys.first); // LRU 的に最古を削除
}
_cache[key] = _CacheEntry(response, DateTime.now());
}
String _normalizeKey(String prompt) {
return prompt.trim().toLowerCase();
}
}
class _CacheEntry {
final String response;
final DateTime timestamp;
_CacheEntry(this.response, this.timestamp);
}
トークン使用量の監視
Gemini API のコストを管理するために、トークン使用量を追跡する仕組みも重要です。
// レスポンスからトークン使用量を取得
final response = await model.generateContent([content]);
final usage = response.usageMetadata;
if (usage != null) {
print('入力トークン: ${usage.promptTokenCount}');
print('出力トークン: ${usage.candidatesTokenCount}');
print('合計: ${usage.totalTokenCount}');
}
テスト戦略
本番品質のアプリには、適切なテストが不可欠です。
// test/services/gemini_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
// GeminiService のインターフェースを定義してモック化
abstract class GeminiServiceInterface {
Stream<String> sendMessageStream(String message);
Future<String> sendImageMessage({
required String prompt,
required Uint8List imageBytes,
required String mimeType,
});
}
@GenerateMocks([GeminiServiceInterface])
void main() {
group('ChatNotifier Tests', () {
test('メッセージ送信でユーザーメッセージが追加される', () {
// テスト実装
// MockGeminiServiceInterface を使用して
// API 呼び出しなしでロジックを検証
});
test('ストリーミング中は状態が streaming になる', () {
// ストリーミング状態の遷移を検証
});
test('エラー時は error 状態になる', () {
// エラーハンドリングの検証
});
});
}
テストでは Gemini API を直接呼び出さず、モックを使用します。これにより、API キーなしでも CI/CD パイプラインでテストを実行でき、API コストも発生しません。
公式に書かれていない運用知見 — 個人開発アプリに載せてから気づいたこと
ここまでの内容は公式ドキュメントや SDK のサンプルに沿って実装できます。けれど実際に手元の Flutter アプリに載せて 1〜2 ヶ月運用すると、サンプル通りには動かない場面がいくつも出てきます。ここでは、私が踏んだ落とし穴と対処をそのまま並べておきます。
iOS バックグラウンドでストリーミングが止まる
ストリーミング応答の途中で iOS をホームに戻すと、復帰したときに onError も onDone も呼ばれないまま固まることがあります。原因は OS が HTTP/2 のソケットを suspend するためで、google_generative_ai 側からは生存しているように見えてしまいます。
私は WidgetsBindingObserver でアプリのライフサイクルを監視し、AppLifecycleState.resumed で「最後に streaming 状態だった会話」をクライアント側でロールバック→再送する設計に落ち着きました。Android では同じ症状は出ていませんが、念のため iOS / Android 共通のコードパスにしています。
// lib/state/lifecycle_recovery.dart
class StreamRecovery extends ConsumerStatefulWidget {
const StreamRecovery({super.key, required this.child});
final Widget child;
@override ConsumerState createState() => _StreamRecoveryState();
}
class _StreamRecoveryState extends ConsumerState<StreamRecovery>
with WidgetsBindingObserver {
@override
void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); }
@override
void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); }
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
final chat = ref.read(chatNotifierProvider.notifier);
// streaming のまま 5 秒以上経過していたら、最後のユーザーメッセージから再送
chat.resumeIfStuck(threshold: const Duration(seconds: 5));
}
}
@override Widget build(BuildContext context) => widget.child;
}
resumeIfStuck は ChatNotifier 側で「streaming=true かつ最終更新が 5 秒以上前」のときだけ、unstable 状態の AI メッセージを破棄してリクエストし直します。ユーザーから見ると「数秒の間が空いてから続きが流れてくる」体感になり、固まったままよりずっと許容されました。
Cold Start を AdMob で隠す
Gemini Flash の初回応答は、日本リージョンから叩いて 1.6〜2.2 秒、Pro になると 3.5 秒前後かかります。チャット画面に遷移してすぐタップされると、無音のローディングだけが流れて離脱されやすかったです。
私の運用では、チャット画面の初回ロードで AdMob インタースティシャル相当の小さなネイティブ広告を 1 枚挟み、その裏で Gemini への "あいさつ" プロンプトを先行送信して下準備するようにしました。広告が表示されている 2 秒弱で Cold Start 分を裏で済ませてしまうイメージです。eCPM の落ち込みは観測できず、初回応答までの体感時間は 1.5 秒前後まで短く感じられるようになりました。
ここは「個人開発アプリだからこそ AdMob と AI 機能の同居設計を自分で決められる」部分で、SaaS で同じことをやるとブランド規約に引っかかりやすいです。
Firestore コストはストリーミング 1 行ごとに書かない
最初の実装では、ストリーミングで届いた差分を逐一 Firestore に upsert していました。これが想像以上に書き込み課金を膨らませ、月の Firestore 書き込みが 1 桁増えました。
私の落とし所は「ストリーミング中はローカル状態だけで、onDone の段階でメッセージ全文と usageMetadata を 1 回だけ書き込む」です。途中経過の保存はオフラインキャッシュ(Hive)に逃がします。これで Firestore 書き込みが約 90 %減り、月額 $7 前後だったコストが $0.8 前後まで落ちました。
// onDone でまとめて書き込む
await chatRef.collection('messages').add({
'role': 'assistant',
'content': fullText,
'tokensIn': usage?.promptTokenCount,
'tokensOut': usage?.candidatesTokenCount,
'createdAt': FieldValue.serverTimestamp(),
});
google_generative_ai の Stream は再 subscribe できない
generateContentStream が返す Stream はホットで、.listen した後にもう一度 listen しようとすると例外になります。これに気づかず、ChatNotifier のテストで Stream を 2 回受けにいって長く詰まりました。
asBroadcastStream() で多重 subscribe を可能にすることもできますが、私は途中経過に複数のリスナを生やすのではなく、ChatNotifier が 1 つだけ subscribe して state に書き戻し、UI 側は state を購読する設計を勧めます(このほうがウィジェットツリーの再構築コストも明確になります)。
Web ビルドでだけ環境変数が露出する
Flutter Web で envied を使う場合、難読化された文字列も main.dart.js に最終的に展開されます。Web 用に Gemini を直接叩く構成は、API キー流出のリスクが現実にあります。
個人開発の本番運用では、Web ビルドからは Gemini を直接叩かず、Cloud Functions / Cloud Run 経由でプロキシする構成に切り替えました。モバイル(iOS / Android)はそのまま直接叩いていて、流出リスクはコードサイニング・難読化・App Attest(iOS)と Play Integrity(Android)でリクエストを縛って許容しています。
個人開発の月額コスト構成(実測)
参考に、私の Flutter アプリ 1 本に AI チャットを追加して 1 ヶ月運用したときのコスト構成を出しておきます。MAU はおよそ 8,000、AI チャット利用ユーザーは MAU の 12 %、平均 1 セッション 1,100 トークンでした。
- Gemini API(Flash)入出力合計: 月 約 $4.8
- Firestore(メッセージ書き込み・読み出し): 月 約 $0.9
- Cloud Functions(Web 用プロキシのみ): 月 約 $0.4
- AdMob 追加収益(滞在時間増加分の eCPM 上振れ): 月 約 ¥3,200
差し引きでは AdMob 増収分のほうが Gemini と Firestore の合計を上回り、機能追加としては 1 ヶ月目から黒字でした。これは MAU が小さいことに助けられている部分が大きく、MAU が 10 倍になると AdMob 線形に対して API コストはやや非線形に膨らむ印象です。プレミアム課金(無広告 + AI 利用上限解除)への切り替えは、MAU 50,000 を目安に検討しています。
設計上の判断 — 私がいま個人開発で選ぶ構成
ここまでの落とし穴を踏まえて、いま新しく Flutter × Gemini のチャットを足すなら、私は次の組み合わせを推奨します。
- モデル: Gemini Flash を基本、Pro は「履歴 8 ターン以上 + 画像入力あり」のときだけ切替(コストの非線形ジャンプを避けるため)
- 状態管理: Riverpod + 専用 ChatNotifier(StreamSubscription は ChatNotifier に集約)
- 永続化: Firestore に書くのは
onDone のみ、途中経過は Hive
- ライフサイクル:
AppLifecycleState.resumed で 5 秒以上 streaming のまま固まった会話を自動再送
- Web: 直接呼び出しを避け、Cloud Functions プロキシ経由
- 課金: AdMob + 無料ユーザー(基本路線)/プレミアム購読は MAU が 5 万を超えてから
「AI チャットは要件として強いが、運用負荷で個人開発が破綻しない」と感じられる最低限のラインがこのあたりでした。読者の規模やビジネスモデルでチューニングする余地はあるはずなので、まずはこの構成で動かして、自分の数字でズレを測ってもらうのがいいと思います。
次の一歩 — リリースに向けて手を動かすところまで
このノートで扱った範囲を 1 本のアプリにまとめるなら、まずは google_generative_ai + Riverpod + Hive の最小構成で、ストリーミングと履歴ローカル永続化だけ動かしてみてください。AdMob と Firebase は、最小構成が安定してから足すほうが、後で「どこで eCPM が動いたか」を切り分けやすくなります。
状態管理・テスト戦略の章は、ここで触れたチャット UI を保守しやすくするためのリファレンスとしても活きます。
同じ Flutter × AI を別の角度から扱うガイドも整理してあります。マルチモーダルを掘り下げたい場合は Gemini API マルチモーダル高度テクニック を、Firebase 側のロジック管理を深めたい場合は Flutter × Firebase で AI ロジックを管理する方法 を、あわせて読み比べてみてください。
個人開発で AI を載せようとしている方の参考になればうれしいです。お読みいただきありがとうございました。