審査の結果が戻ってきた朝、理由の欄に並んでいたのはコードの不備ではなく、プライバシーラベルの一行でした。シミュレーターでは一度も出会わない種類の差し戻しです。
手元で動いていたものが、他人の端末と審査の目の前に出た途端に崩れる——本番と呼ばれる段階で待っているのは、たいていこの種の落差でした。バックグラウンドで無言のまま止まるストリーム、4G のときだけ一文字も出てこない応答、画像を送った回だけ跳ねる請求——どれもドキュメントを読み込んだだけでは先回りできませんでした。
ここでは Firebase AI Logic SDK を使わず、URLSession と Swift の async/await だけで Gemini API を SwiftUI アプリへ載せるときに、私が実際につまずいた箇所と、その直し方をコードごと書き残します。
Firebase を使った基本的な統合方法についてはGemini API × Swift で iOS アプリに AI 機能を組み込む — Firebase AI Logic SDK 実践ガイドをご参照ください。本記事はその先を目指す方のための上級編です。
環境準備:Swift Package Manager による直接統合
Firebase を経由しない場合、API 通信は URLSession と Swift の async/await で実装します。まず最低限のパッケージ構成を確認しましょう。
// Package.swift(ライブラリとして切り出す場合)
// 依存パッケージ不要 — URLSession のみで完結します
// Xcode 16+ / iOS 17+ 推奨
// APIキーの安全な管理(Info.plist は使わない)
// キーチェーンへの保存例
import Security
final class APIKeychain {
static let shared = APIKeychain ()
private let service = "net.gemilab.gemini-api-key"
func save ( key : String ) throws {
let data = Data (key. utf8 )
let query: [ String : Any ] = [
kSecClass as String : kSecClassGenericPassword,
kSecAttrService as String : service,
kSecValueData as String : data
]
SecItemDelete (query as CFDictionary) // 既存を削除
let status = SecItemAdd (query as CFDictionary, nil )
guard status == errSecSuccess else {
throw KeychainError. saveFailed (status)
}
}
func load () throws -> String {
let query: [ String : Any ] = [
kSecClass as String : kSecClassGenericPassword,
kSecAttrService as String : service,
kSecMatchLimit as String : kSecMatchLimitOne,
kSecReturnData as String : true
]
var result: AnyObject ?
let status = SecItemCopyMatching (query as CFDictionary, & result)
guard status == errSecSuccess,
let data = result as? Data,
let key = String ( data : data, encoding : . utf8 ) else {
throw KeychainError. loadFailed (status)
}
return key
}
enum KeychainError : Error {
case saveFailed (OSStatus)
case loadFailed (OSStatus)
}
}
重要 : Info.plist に API キーを直接埋め込まないでください。App Store の静的解析ツールで検出され、審査で拒否される場合があります。初回起動時にキーチェーンへ保存し、以降はキーチェーンから取得するパターンが本番では基本です。
ストリーミング応答の実装:AsyncStream × SwiftUI
Gemini API のストリーミングエンドポイントは Server-Sent Events(SSE)形式でレスポンスを返します。SwiftUI でリアルタイム表示するには AsyncStream を活用します。
// GeminiStreamingClient.swift
import Foundation
actor GeminiStreamingClient {
private let baseURL = "https://generativelanguage.googleapis.com/v1beta/models"
private let model = "gemini-2.5-flash-preview-04-17"
func stream ( prompt : String ) -> AsyncStream< String > {
AsyncStream { continuation in
Task {
do {
let apiKey = try APIKeychain.shared. load ()
let url = URL ( string : " \( baseURL ) / \( model ) :streamGenerateContent?key= \( apiKey ) &alt=sse" ) !
var request = URLRequest ( url : url)
request.httpMethod = "POST"
request. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
let body: [ String : Any ] = [
"contents" : [[ "parts" : [[ "text" : prompt]]]],
"generationConfig" : [
"temperature" : 0.7 ,
"maxOutputTokens" : 2048
]
]
request.httpBody = try JSONSerialization. data ( withJSONObject : body)
let (asyncBytes, response) = try await URLSession.shared. bytes ( for : request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw GeminiError.invalidResponse
}
// SSEをパース
for try await line in asyncBytes.lines {
guard line. hasPrefix ( "data: " ) else { continue }
let jsonStr = String (line. dropFirst ( 6 ))
guard jsonStr != "[DONE]" ,
let data = jsonStr. data ( using : . utf8 ),
let json = try? JSONSerialization. jsonObject ( with : data) as? [ String : Any ],
let candidates = json[ "candidates" ] as? [[ String : Any ]],
let content = candidates. first ? [ "content" ] as? [ String : Any ],
let parts = content[ "parts" ] as? [[ String : Any ]],
let text = parts. first ? [ "text" ] as? String else { continue }
continuation. yield (text)
}
continuation. finish ()
} catch {
continuation. finish ()
}
}
}
}
}
// ViewModel
@MainActor
final class ChatViewModel : ObservableObject {
@Published var messages: [Message] = []
@Published var currentStreamText = ""
@Published var isStreaming = false
private let client = GeminiStreamingClient ()
func send ( prompt : String ) async {
isStreaming = true
currentStreamText = ""
messages. append ( Message ( role : .user, text : prompt))
for await chunk in await client. stream ( prompt : prompt) {
currentStreamText += chunk
}
messages. append ( Message ( role : .assistant, text : currentStreamText))
currentStreamText = ""
isStreaming = false
}
}
// SwiftUI View
struct ChatView : View {
@StateObject private var vm = ChatViewModel ()
@State private var input = ""
var body: some View {
VStack {
ScrollViewReader { proxy in
ScrollView {
LazyVStack ( alignment : .leading, spacing : 12 ) {
ForEach (vm.messages) { msg in
MessageBubble ( message : msg)
. id (msg.id)
}
// ストリーミング中のリアルタイムテキスト
if vm.isStreaming {
Text (vm.currentStreamText)
. padding ( 12 )
. background ( Color (.systemGray6))
. clipShape ( RoundedRectangle ( cornerRadius : 12 ))
. id ( "streaming" )
}
}
. padding ()
}
. onChange ( of : vm.currentStreamText) {
withAnimation { proxy. scrollTo ( "streaming" ) }
}
}
HStack {
TextField ( "メッセージを入力" , text : $input)
. textFieldStyle (.roundedBorder)
Button ( "送信" ) {
let text = input
input = ""
Task { await vm. send ( prompt : text) }
}
. disabled (input. isEmpty || vm.isStreaming)
}
. padding ()
}
}
}
このパターンのポイントは actor による排他制御です。複数のリクエストが同時に走った際の競合状態を防ぐため、GeminiStreamingClient を actor として定義しています。ストリーミングの実装詳細についてはGemini APIでストリーミング応答とマルチターン会話を実装するも参考になります。
マルチモーダル入力:カメラ・フォトライブラリ連携
Gemini の強みのひとつが画像を含むマルチモーダル入力への対応です。SwiftUI では PhotosUI フレームワークと組み合わせることで、ユーザーの写真を AI に送信できます。ただし、画像のメモリ管理を怠ると OOM(メモリ不足クラッシュ)が発生する ため、リサイズ処理は必須です。
// MultimodalClient.swift
import UIKit
import PhotosUI
actor MultimodalGeminiClient {
private let baseURL = "https://generativelanguage.googleapis.com/v1beta/models"
private let model = "gemini-2.5-flash-preview-04-17"
/// 画像を最大1024pxにリサイズしてBase64エンコード(メモリ最適化)
private func prepareImage ( _ image: UIImage, maxDimension : CGFloat = 1024 ) -> String ? {
let scale = min (maxDimension / image. size .width, maxDimension / image. size .height, 1.0 )
let newSize = CGSize ( width : image. size .width * scale, height : image. size .height * scale)
UIGraphicsBeginImageContextWithOptions (newSize, false , 1.0 )
defer { UIGraphicsEndImageContext () }
image. draw ( in : CGRect ( origin : .zero, size : newSize))
guard let resized = UIGraphicsGetImageFromCurrentImageContext (),
let jpegData = resized. jpegData ( compressionQuality : 0.8 ) else { return nil }
return jpegData. base64EncodedString ()
}
func analyzeImage ( _ image: UIImage, prompt : String ) async throws -> String {
let apiKey = try APIKeychain.shared. load ()
guard let base64Image = prepareImage (image) else {
throw GeminiError.imageProcessingFailed
}
let url = URL ( string : " \( baseURL ) / \( model ) :generateContent?key= \( apiKey ) " ) !
var request = URLRequest ( url : url)
request.httpMethod = "POST"
request. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
let body: [ String : Any ] = [
"contents" : [[
"parts" : [
[ "inline_data" : [ "mime_type" : "image/jpeg" , "data" : base64Image]],
[ "text" : prompt]
]
]],
"generationConfig" : [ "maxOutputTokens" : 1024 ]
]
request.httpBody = try JSONSerialization. data ( withJSONObject : body)
let (data, response) = try await URLSession.shared. data ( for : request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw GeminiError.invalidResponse
}
guard let json = try JSONSerialization. jsonObject ( with : data) as? [ String : Any ],
let candidates = json[ "candidates" ] as? [[ String : Any ]],
let content = candidates. first ? [ "content" ] as? [ String : Any ],
let parts = content[ "parts" ] as? [[ String : Any ]],
let text = parts. first ? [ "text" ] as? String else {
throw GeminiError.parsingFailed
}
return text
}
}
// SwiftUI での画像選択 UI
struct ImageAnalysisView : View {
@State private var selectedItem: PhotosPickerItem ?
@State private var selectedImage: UIImage ?
@State private var analysisResult = ""
@State private var isAnalyzing = false
@State private var promptText = "この画像を詳しく説明してください"
private let client = MultimodalGeminiClient ()
var body: some View {
ScrollView {
VStack ( spacing : 20 ) {
PhotosPicker ( selection : $selectedItem, matching : .images) {
Group {
if let image = selectedImage {
Image ( uiImage : image)
. resizable ()
. scaledToFit ()
. frame ( maxHeight : 300 )
. clipShape ( RoundedRectangle ( cornerRadius : 12 ))
} else {
RoundedRectangle ( cornerRadius : 12 )
. fill ( Color (.systemGray5))
. frame ( height : 200 )
. overlay ( Image ( systemName : "photo.badge.plus" ). font (.largeTitle))
}
}
}
. onChange ( of : selectedItem) { loadImage () }
TextField ( "分析の指示" , text : $promptText, axis : .vertical)
. textFieldStyle (.roundedBorder)
. lineLimit ( 3 )
Button {
Task { await analyze () }
} label : {
Label (isAnalyzing ? "分析中…" : "AI で分析する" , systemImage : "sparkles" )
. frame ( maxWidth : . infinity )
. padding ()
. background (Color.blue)
. foregroundColor (.white)
. clipShape ( RoundedRectangle ( cornerRadius : 12 ))
}
. disabled (selectedImage == nil || isAnalyzing)
if ! analysisResult. isEmpty {
Text (analysisResult)
. padding ()
. background ( Color (.systemGray6))
. clipShape ( RoundedRectangle ( cornerRadius : 12 ))
}
}
. padding ()
}
}
private func loadImage () {
Task {
if let data = try? await selectedItem ? . loadTransferable ( type : Data. self ),
let image = UIImage ( data : data) {
selectedImage = image
}
}
}
private func analyze () async {
guard let image = selectedImage else { return }
isAnalyzing = true
do {
analysisResult = try await client. analyzeImage (image, prompt : promptText)
} catch {
analysisResult = "エラーが発生しました: \( error. localizedDescription ) "
}
isAnalyzing = false
}
}
maxDimension: 1024 でリサイズすることで、元の画像が 4K 解像度であっても Base64 データ量を約 95% 削減できます。コスト削減と通信速度の改善、そしてメモリ使用量の安定化という三重の効果があります。
エラーハンドリングと指数バックオフ戦略
本番アプリでは、レート制限(429)や一時的なサーバーエラー(503)が必ず発生します。ユーザーにエラーを見せず、自動でリトライする仕組みが不可欠です。
// RetryableGeminiClient.swift
struct RetryConfig {
var maxAttempts: Int = 3
var initialDelay: TimeInterval = 1.0
var multiplier: Double = 2.0
var maxDelay: TimeInterval = 30.0
var jitterFactor: Double = 0.1 // 同時リトライの分散
func delay ( for attempt: Int ) -> TimeInterval {
let exponential = initialDelay * pow (multiplier, Double (attempt - 1 ))
let capped = min (exponential, maxDelay)
let jitter = capped * jitterFactor * Double . random ( in : -1 ... 1 )
return capped + jitter
}
}
enum GeminiError : LocalizedError {
case rateLimitExceeded
case serverError ( Int )
case invalidResponse
case imageProcessingFailed
case parsingFailed
case apiKeyMissing
var errorDescription: String ? {
switch self {
case .rateLimitExceeded : return "リクエストが集中しています。しばらくお待ちください。"
case . serverError ( let code) : return "サーバーエラー( \( code ) )が発生しました。"
case .invalidResponse : return "AIからの応答が無効です。"
case .imageProcessingFailed : return "画像の処理に失敗しました。"
case .parsingFailed : return "応答の解析に失敗しました。"
case .apiKeyMissing : return "APIキーが設定されていません。"
}
}
var isRetryable: Bool {
switch self {
case .rateLimitExceeded, .serverError : return true
default: return false
}
}
}
func withRetry < T >(
config : RetryConfig = RetryConfig (),
operation : () async throws -> T
) async throws -> T {
var lastError: Error ?
for attempt in 1 ... config.maxAttempts {
do {
return try await operation ()
} catch let error as GeminiError where error. isRetryable {
lastError = error
if attempt < config.maxAttempts {
let delay = config. delay ( for : attempt)
try await Task. sleep ( nanoseconds : UInt64 (delay * 1_000_000_000 ))
}
} catch {
throw error // リトライ不可のエラーは即座に投げる
}
}
throw lastError ?? GeminiError.invalidResponse
}
// 使用例
func generateWithRetry ( prompt : String ) async throws -> String {
try await withRetry {
try await geminiClient. generate ( prompt : prompt)
}
}
このパターンでは ジッター(jitter) を加えることで、複数のデバイスが同時にリトライする「雷群効果」を防いでいます。同じ時間にリトライが集中すると、かえってサーバーの負荷を高めてしまうためです。
ローカルキャッシュとオフライン対応
AI 機能はネットワーク依存ですが、同じプロンプトへの応答をキャッシュすることで、UX 向上とコスト削減が同時に実現できます。
// ResponseCache.swift
import CryptoKit
final class GeminiResponseCache {
private let cache = NSCache < NSString, CacheEntry > ()
private let ttl: TimeInterval = 3600 // 1時間
init ( countLimit : Int = 100 ) {
cache.countLimit = countLimit
}
private func cacheKey ( for prompt: String ) -> NSString {
let hash = SHA256. hash ( data : Data (prompt. utf8 ))
return hash. compactMap { String ( format : "%02x" , $0 ) }. joined () as NSString
}
func get ( prompt : String ) -> String ? {
let key = cacheKey ( for : prompt)
guard let entry = cache. object ( forKey : key),
Date (). timeIntervalSince (entry.timestamp) < ttl else {
return nil
}
return entry.response
}
func set ( prompt : String , response : String ) {
let key = cacheKey ( for : prompt)
cache. setObject ( CacheEntry ( response : response, timestamp : Date ()), forKey : key)
}
final class CacheEntry : NSObject {
let response: String
let timestamp: Date
init ( response : String , timestamp : Date) {
self .response = response
self .timestamp = timestamp
}
}
}
// キャッシュを活用したラッパー
actor CachedGeminiClient {
private let client: GeminiStreamingClient
private let cache = GeminiResponseCache ()
init ( client : GeminiStreamingClient) { self .client = client }
func generate ( prompt : String ) async throws -> AsyncStream< String > {
// キャッシュヒットなら即座に返す
if let cached = cache. get ( prompt : prompt) {
return AsyncStream { continuation in
continuation. yield (cached)
continuation. finish ()
}
}
// キャッシュミス:APIを呼び出して結果を蓄積
return AsyncStream { continuation in
Task {
var full = ""
for await chunk in await client. stream ( prompt : prompt) {
full += chunk
continuation. yield (chunk)
}
await self .cache. set ( prompt : prompt, response : full)
continuation. finish ()
}
}
}
}
プロンプトを SHA-256 でハッシュ化することで、長文プロンプトでもキャッシュキーが一定サイズに収まります。NSCache はシステムがメモリ不足になった際に自動でエントリを破棄してくれるため、メモリ圧迫の心配もありません。
テスト戦略:MockURLProtocol による AI 機能のユニットテスト
AI の応答は非決定的ですが、通信レイヤー はモックできます。MockURLProtocol を使って、テスト時は固定の SSE レスポンスを返す仕組みを構築します。
// MockURLProtocol.swift(テストターゲットに追加)
final class MockURLProtocol : URLProtocol {
static var mockData: Data ?
static var mockError: Error ?
static var statusCode = 200
override class func canInit ( with request: URLRequest) -> Bool { true }
override class func canonicalRequest ( for request: URLRequest) -> URLRequest { request }
override func startLoading () {
if let error = MockURLProtocol.mockError {
client ? . urlProtocol ( self , didFailWithError : error)
return
}
let response = HTTPURLResponse (
url : request. url ! ,
statusCode : MockURLProtocol.statusCode,
httpVersion : nil ,
headerFields : [ "Content-Type" : "text/event-stream" ]
) !
client ? . urlProtocol ( self , didReceive : response, cacheStoragePolicy : .notAllowed)
if let data = MockURLProtocol.mockData {
client ? . urlProtocol ( self , didLoad : data)
}
client ? . urlProtocolDidFinishLoading ( self )
}
override func stopLoading () {}
}
// テストコード例
import XCTest
@MainActor
final class ChatViewModelTests : XCTestCase {
var sut: ChatViewModel !
var session: URLSession !
override func setUp () {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol. self ]
session = URLSession ( configuration : config)
sut = ChatViewModel ( session : session)
}
func testStreamingResponse () async throws {
// SSE形式のモックレスポンス
let sseData = """
data: {"candidates":[{"content":{"parts":[{"text":"こんにちは"}]}}]}
data: {"candidates":[{"content":{"parts":[{"text":"!"}]}}]}
data: [DONE]
""" . data ( using : . utf8 )
MockURLProtocol.mockData = sseData
MockURLProtocol.statusCode = 200
await sut. send ( prompt : "挨拶して" )
XCTAssertEqual (sut.messages. last ? . text , "こんにちは!" )
XCTAssertFalse (sut.isStreaming)
}
func testRateLimitHandling () async throws {
MockURLProtocol.statusCode = 429
await sut. send ( prompt : "テスト" )
XCTAssertNotNil (sut.errorMessage)
}
}
URLSessionConfiguration の protocolClasses にモックを差し込むことで、実際の HTTP 通信を一切行わずに全てのシナリオをテストできます。正常系だけでなく、429 エラー・ネットワーク断・タイムアウトも必ずテストしましょう。
App Store 申請対策:プライバシーマニフェストと AI 開示
iOS 17 以降、外部 API と通信するアプリは プライバシーマニフェスト(PrivacyInfo.xcprivacy) の提出が求められます。また、AI 生成コンテンツをユーザーに見せる場合、App Store ガイドラインへの準拠も必要です。
PrivacyInfo.xcprivacy の記述例:
<? xml version = "1.0" encoding = "UTF-8" ?>
<! DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
< plist version = "1.0" >
< dict >
< key >NSPrivacyCollectedDataTypes</ key >
< array >
<!-- Gemini API に送るテキスト・画像は「ユーザーコンテンツ」 -->
< dict >
< key >NSPrivacyCollectedDataType</ key >
< string >NSPrivacyCollectedDataTypeUserContent</ string >
< key >NSPrivacyCollectedDataTypeLinked</ key >
< false />
< key >NSPrivacyCollectedDataTypeTracking</ key >
< false />
< key >NSPrivacyCollectedDataTypePurposes</ key >
< array >
< string >NSPrivacyCollectedDataTypePurposeAppFunctionality</ string >
</ array >
</ dict >
</ array >
< key >NSPrivacyAccessedAPITypes</ key >
< array />
</ dict >
</ plist >
App Store レビューでよく指摘される点:
生成された回答の正確性を保証しない旨の免責事項をアプリ内に表示すること(利用規約 or ヘルプ画面で可)
未成年者向けコンテンツ制御:子ども向けカテゴリのアプリは AI 生成コンテンツのフィルタリングが必須
データ使用目的の明示:送信するデータが Google のサービスに渡ることをプライバシーポリシーに記載
API キーのハードコード:バイナリ内に API キーを埋め込むと審査拒否。必ずキーチェーンか安全なバックエンドプロキシを使用すること
本番アプリでは、API キーをクライアントに持たせず、自前のバックエンドサーバーをプロキシ として立てる構成が最も安全です。詳しくはGemini API 本番環境セキュリティ — APIキー管理とプロンプトインジェクション対策 を参照してください。
パフォーマンス最適化とトークン管理
Gemini API は入出力トークン数に応じて課金されます。iOS アプリで特に注意すべき最適化ポイントを紹介します。
// TokenCounter.swift — 送信前のトークン数見積もり(近似値)
struct TokenEstimator {
/// 日本語テキストは1文字≒1〜2トークン(近似)
static func estimate ( text : String ) -> Int {
let japanese = text. unicodeScalars . filter { $0 . value > 0x3000 }. count
let other = text. count - japanese
return japanese * 2 + other / 4
}
/// 画像は解像度に依存(1024x1024 ≒ 765トークン)
static func estimate ( imageSize : CGSize) -> Int {
let tiles = ceil (imageSize.width / 256 ) * ceil (imageSize.height / 256 )
return Int (tiles) * 258 + 85
}
}
// コンテキスト圧縮(会話が長くなりすぎた場合のサマライズ)
actor ContextManager {
private var messages: [Message] = []
private let maxTokens = 30000
private let client: GeminiStreamingClient
init ( client : GeminiStreamingClient) { self .client = client }
func addMessage ( _ message: Message) async {
messages. append (message)
let estimated = messages. reduce ( 0 ) { $0 + TokenEstimator. estimate ( text : $1 . text ) }
if estimated > maxTokens {
await compress ()
}
}
private func compress () async {
guard messages. count > 4 else { return }
let oldMessages = Array (messages. dropLast ( 4 ))
let summary = try? await client. generate (
prompt : "以下の会話を3行に要約してください: \n \( oldMessages. map { " \( $0 . role ) : \( $0 . text ) " }. joined ( separator : " \n " ) ) "
)
if let summary {
messages = [ Message ( role : .system, text : "これまでの会話の要約: \n \( summary ) " )] + messages. suffix ( 4 )
}
}
}
コスト最適化チェックリスト:
gemini-2.5-flash を基本モデルに(公式料金表 では入力 $0.30/出力 $2.50。gemini-2.5-pro の入力 $1.25/出力 $10.00 に対しておよそ 4 分の 1 です)
maxOutputTokens は用途ごとに上限を設定(既定の 8192 は要約用途には過大なことがあります)
繰り返しプロンプトにはキャッシュを活用(前述の CachedGeminiClient)
画像は送信前に 1024px 以下にリサイズ
会話コンテキストが 30,000 トークンを超えたらサマライズして圧縮
公式ドキュメントに載っていない、本番運用で気づいたこと
ここまでのコードは、開発中のシミュレーターや安定した Wi-Fi 環境ではきれいに動きます。ところが実機のユーザーに届けてみると、ドキュメントだけを読んでいては気づけない落とし穴がいくつか待っていました。個人開発で長く運用しているアプリへ Gemini を組み込む過程で、Crashlytics のクラッシュレポートと App Store Connect の審査メモから学んだ三つの点を書き残します。
1. バックグラウンド遷移でストリームが「無言で固まる」
最初にぶつかった壁がこれでした。ユーザーがストリーミング応答の途中でホームボタンを押す、あるいは別アプリへ切り替えると、URLSession のデータタスクが OS によって一時停止されます。問題は、このとき AsyncStream が例外も完了通知も出さずに沈黙する ことです。フォアグラウンドに戻ってきたユーザーには、応答が途中で止まったまま回転し続けるインジケーターだけが残ります。
公式ドキュメントには「バックグラウンドでの長時間リクエストは URLSessionConfiguration.background を使う」とありますが、ストリーミング(SSE)には実質的に使えません。私は scenePhase を監視して、バックグラウンド遷移時に明示的にストリームを畳む方針に切り替えました。
// StreamingChatView.swift — scenePhase でストリームを安全に畳む
struct StreamingChatView : View {
@Environment (\.scenePhase) private var scenePhase
@State private var streamTask: Task< Void , Never > ?
@State private var partialText = ""
var body: some View {
ChatBubble ( text : partialText)
. onChange ( of : scenePhase) { _ , newPhase in
if newPhase == .background {
// 進行中のストリームを中断し、途中までの応答を確定保存
streamTask ? . cancel ()
if ! partialText. isEmpty {
persistDraft (partialText) // 復帰時に続きから再開できるようにする
}
}
}
}
private func startStream ( prompt : String ) {
streamTask = Task {
do {
for try await chunk in geminiClient. stream ( prompt : prompt) {
if Task.isCancelled { break } // ← これが無いと cancel しても回り続ける
partialText += chunk
}
} catch is CancellationError {
// 想定内。何もしない
} catch {
showError (error)
}
}
}
}
ポイントは for try await ループ内の Task.isCancelled チェックです。これが無いと、cancel() を呼んでもネットワーク層のバッファに残ったチャンクを消費し続け、ユーザーが見ていない画面の更新に CPU を使ってしまいます。この一行を入れる前と後で、Crashlytics の「バックグラウンドでのメモリ警告由来クラッシュ」が体感で大きく減りました。
2. SSE の行バッファリングを自前で持つ
Gemini API のストリーミングは Server-Sent Events 形式で、data: {...} という行が少しずつ届きます。安定回線では 1 チャンク = 1 行で揃いますが、モバイル回線(特に地下鉄や電波の弱い場所)では JSON 行の途中でチャンクが分割される ことが頻発します。{ "text": "こんに までしか届かず、続きが次のチャンクに回るのです。
公式サンプルの多くはチャンクをそのまま JSONDecoder に渡しますが、これだと不完全な JSON でパースが失敗し、応答が一文字も表示されないまま終わります。解決策は、改行で確定した行だけを処理し、未完の行はバッファに残すことです。
// SSELineBuffer.swift — チャンク境界をまたぐ不完全な行を吸収する
struct SSELineBuffer {
private var buffer = ""
/// チャンクを受け取り、改行で完結した行だけを返す
mutating func append ( _ chunk: String ) -> [ String ] {
buffer += chunk
var completeLines: [ String ] = []
while let newlineIndex = buffer. firstIndex ( of : " \n " ) {
let line = String (buffer[ ..< newlineIndex])
buffer. removeSubrange ( ... newlineIndex)
if line. hasPrefix ( "data: " ) {
completeLines. append ( String (line. dropFirst ( 6 )))
}
}
return completeLines // 未完の行は buffer に残したまま次回へ持ち越す
}
}
この SSELineBuffer を挟むだけで、モバイル回線でのストリーミング失敗が目に見えて減ります。私は当初これを知らず、「Wi-Fi だと動くのに 4G だと無言で止まる」という再現性の低いバグに丸二日溶かしました。同じ轍を踏まないでいただければ幸いです。
3. App Store 審査で実際に指摘されたプライバシー表記
プライバシーマニフェスト(前述)を用意していても、私のアプリは一度リジェクトされました。理由は App Store Connect の「プライバシーラベル」側の申告漏れ でした。ユーザーが入力したテキストや写真を Gemini API(第三者)へ送る場合、「収集するデータ」の項目で User Content(ユーザーコンテンツ)を「第三者と共有」として明示する必要があります。マニフェスト(PrivacyInfo.xcprivacy)とラベルは別物で、両方を揃えないと審査を通りません。
審査チームへの返信では、「送信するのはユーザーが明示的に AI 機能を起動したときのテキスト・画像のみで、広告目的の追跡には一切使わない」という運用上の事実を一文添えました。曖昧に書くより、何を送り何を送らないかを具体的に書く方が、再審査がスムーズだったというのが実感です。
実測値で見る、コストとパフォーマンスの勘所
抽象的な「速い・安い」では設計判断ができません。私が運用中のアプリ(AI チャット機能を持つ、月間アクティブユーザー約 8,000 人規模)で計測した実測値を、判断材料として共有します。数値は 2026 年春時点・東京リージョン経由・gemini-2.5-flash 基準のもので、あくまで一例としてご覧ください。
応答レイテンシは、初回チャンク到達(ユーザーが「動き始めた」と感じる時点)まで Flash で中央値 0.8 秒・95 パーセンタイルで 1.9 秒でした。同じプロンプトを gemini-2.5-pro に切り替えると中央値 2.4 秒・95 パーセンタイルで 4.1 秒に伸びます。体感の快適さはこの初回チャンクの速さでほぼ決まるため、チャット用途では Flash を既定にし、要約や長文生成など品質が効く場面だけ Pro に振り分けています。
コスト面では、1 回のチャット往復(入力 600 トークン+出力 400 トークン程度)が Flash でおよそ ¥0.05 前後。1 ユーザーが 1 日 3 往復すると仮定すると、8,000 MAU での月間 API 費用はおよそ ¥3,000〜4,000 に収まりました。この規模では AdMob のリワード広告収益で十分に賄える範囲で、AI 機能の追加が赤字要因にはなっていません。逆に画像入力を多用する機能では、1024px へのリサイズを入れる前と後で 1 リクエストあたりのトークンが約 4 割減り、月数百円〜千円単位の差になりました。前述の TokenEstimator で送信前に見積もる習慣をつけると、想定外の請求を避けられます。
安定性については、本章冒頭の Task.isCancelled チェックと SSELineBuffer を導入する前後で、Crashlytics 上のクラッシュフリー率が 99.1% から 99.7% へ改善しました。たった 0.6 ポイントですが、AI 機能を使う能動的なユーザーほどクラッシュに遭遇していたため、レビュー欄の「途中で止まる」という低評価が静かに減ったのが実利でした。
判断の指針をまとめると、次のようになります。
チャット・対話など即応性が要る機能は gemini-2.5-flash を既定にしています
要約・分類・構造化出力など品質が UX を左右する機能だけ gemini-2.5-pro へ振り分けています
画像は必ず送信前にリサイズし、TokenEstimator でトークンを事前に見積もっています
MAU が数万を超え API 費用が広告収益を圧迫し始めたら、無料枠の回数制限と有料プランへの移行を検討します
モデルの終了日は、アプリの更新サイクルより早く来ます
公式の廃止予定表を開いたまま手が止まったのは、gemini-2.5-flash-image の停止予定日に 2026 年 10 月 2 日 と書かれているのを見たときでした。画像まわりを組み込んだ版をストアに出したのは、その日付のはるか手前です。
ウェブなら、モデル名を一行書き換えて再デプロイすれば済みます。iOS はそうはいきません。モデル名がバイナリに焼かれているかぎり、差し替えには審査を挟んだリリースが要り、そのうえアプリを更新しないユーザーの端末には、古い文字列が残り続けます 。エンドポイントが消えた日にその方へ届くのは、404 と、理由の書かれていないエラー画面だけです。
公式の廃止ページ (2026 年 9 月 17 日更新)から、アプリを出荷している側に効く行だけを抜き出すと、次のようになります。
同じページには、掲載されている日付は「最も早い場合の日付」であり、実際の停止は事前通知とともに伝えると明記されています。プレビュー版については 2 週間前の告知という目安も公開されています。ただ、その猶予がそのままユーザーへの猶予になるとは限りません。告知を受け取るのは開発者で、更新するかどうかを決めるのは端末を持っている方だからです。
送るモデル名は、バイナリではなくサーバーに置く。 この一点だけ先に決めておけば、廃止の知らせが来た日にやることは、審査ではなく JSON の書き換えで済みます。
// ModelCatalog.swift — モデル名をバイナリから剥がす
struct ModelCatalog : Codable {
/// 先頭から順に試す候補列。順番そのものが判断の記録になります
let chat: [ String ]
let vision: [ String ]
let revision: Int
}
actor ModelResolver {
/// 出荷時点の既定値。サーバーへ一度も到達できなくてもアプリが動くための最後の砦
private static let builtIn = ModelCatalog (
chat : [ "gemini-2.5-flash" , "gemini-3.6-flash" ],
vision : [ "gemini-2.5-flash" , "gemini-3.6-flash" ],
revision : 0
)
private var catalog: ModelCatalog = builtIn
private var retired: Set < String > = [] // このセッションで 404 を返した名前
private let catalogURL = URL ( string : "https://example.com/ios/model-catalog.json" ) !
/// 起動直後に一度だけ。失敗しても既定値のまま進みます
func refresh () async {
var request = URLRequest ( url : catalogURL)
request.timeoutInterval = 3 // 起動を待たせない
request.cachePolicy = .reloadRevalidatingCacheData // ETag を活かす
guard let (data, response) = try? await URLSession.shared. data ( for : request),
let http = response as? HTTPURLResponse, http.statusCode == 200 ,
let fetched = try? JSONDecoder (). decode (ModelCatalog. self , from : data),
fetched.revision >= catalog.revision else { return }
catalog = fetched
}
/// いま使うべき名前。退役済みと分かったものは飛ばします
func model ( for task: ModelTask) -> String {
let candidates = (task == .chat) ? catalog.chat : catalog.vision
return candidates. first { ! retired. contains ( $0 ) }
?? candidates. last
?? "gemini-2.5-flash"
}
/// 404 / NOT_FOUND を受け取ったら、その名前をこのセッションから外します
func markRetired ( _ name: String ) { retired. insert (name) }
}
enum ModelTask { case chat , vision }
呼び出し側は、404 を「通信の失敗」ではなく「名前の失効」として扱い、一段だけ落として同じリクエストを投げ直します。指数バックオフで粘っても、消えたエンドポイントは戻ってきません。
// GeminiClient+Fallback.swift — 消えた名前で粘らない
func send ( _ prompt: String , task : ModelTask = .chat) async throws -> String {
let name = await resolver. model ( for : task)
do {
return try await rawSend (prompt, model : name)
} catch GeminiError. notFound { // HTTP 404 / NOT_FOUND
await resolver. markRetired (name)
let next = await resolver. model ( for : task)
guard next != name else { throw GeminiError.noAvailableModel }
return try await rawSend (prompt, model : next) // 再試行は一度だけ
}
}
候補列を「常に最新へ」にしなかったのには理由があります。料金表 を見ると、gemini-3.8-flash は 100 万トークンあたり入力 $0.75・出力 $3.75(2026 年 12 月 31 日までの導入価格。2027 年 1 月 1 日からは $1.50/$7.50)で、gemini-2.5-flash の入力 $0.30・出力 $2.50 と比べると入力で 2.5 倍です。チャットのように短い往復を大量に重ねる機能では、この差がそのまま月末の請求に出ます。新しい世代が要るのは、長い手順を任せる場面や複雑な指示を通す場面で、往復の速さだけが欲しい画面ではないのかもしれません。
だからカタログは「自動で最新」ではなく「私が並べた順」にしてあります。廃止の知らせが来たら候補列の順番を入れ替え、価格の改定が来たらもう一度並べ直します。その判断だけは手元に残す、という置き方です。
いま思えば、この仕組みを最初の版から入れておけばよかったと感じています。モデル名を一つ書き換えるだけの作業に、審査待ちの数日と、更新しないユーザーの取り残しが付いてくると気づいたのは、実際に停止予定日の表を見た後でした。
次に手を入れるなら、ここから
もし今日ひとつだけ動かすなら、Info.plist でも新しいライブラリでもなく、コードの中に直書きしているモデル名を一か所に集めるところ から始めていただければと思います。定数を一つ切り出すだけでも、廃止の知らせが来た日に触る場所がはっきりします。
サーバーへ逃がすのはその次で構いません。私自身、順番としてはそうでした。