GEMINI LABEN
SIRI — WWDC 2026で刷新版SiriがGoogle Geminiモデルで動くと確定。ただしEUではDMAによりiOS 27時点で提供されませんFLASH3.5 — Gemini 3.5 FlashがGA。エージェント・コーディングで持続的なフロンティア性能を発揮する最上位FlashモデルですIMAGE-GA — Gemini 3.1 Flash Image / 3.1 Pro Imageがネイティブ視覚モデルとしてGA。preview版は6/25に終了予定MANAGED-AGENTS — Gemini APIでManaged Agentsが公開プレビュー。Googleホストの隔離Linuxサンドボックスで自律エージェントを構築できますFILE-SEARCH — File Searchがマルチモーダル対応。gemini-embedding-2で画像のネイティブ埋め込み・検索が可能になりましたDEPRECATION — gemini-3.1-flash-image-preview / gemini-3-pro-image-previewは6/25に停止。GA版への移行をお早めにSIRI — WWDC 2026で刷新版SiriがGoogle Geminiモデルで動くと確定。ただしEUではDMAによりiOS 27時点で提供されませんFLASH3.5 — Gemini 3.5 FlashがGA。エージェント・コーディングで持続的なフロンティア性能を発揮する最上位FlashモデルですIMAGE-GA — Gemini 3.1 Flash Image / 3.1 Pro Imageがネイティブ視覚モデルとしてGA。preview版は6/25に終了予定MANAGED-AGENTS — Gemini APIでManaged Agentsが公開プレビュー。Googleホストの隔離Linuxサンドボックスで自律エージェントを構築できますFILE-SEARCH — File Searchがマルチモーダル対応。gemini-embedding-2で画像のネイティブ埋め込み・検索が可能になりましたDEPRECATION — gemini-3.1-flash-image-preview / gemini-3-pro-image-previewは6/25に停止。GA版への移行をお早めに
記事一覧/API / SDK
API / SDK/2026-04-03上級

Gemini API × SwiftUI 本番アプリ実装ガイド:ストリーミング・マルチモーダル・エラーハンドリング・App Store対策まで

Gemini APIをSwiftUIアプリに本番レベルで統合する実装ノウハウ。ストリーミング応答・マルチモーダル入力・エラーハンドリング・テスト戦略・App Store申請対策まで、プロ向けの実装ノウハウを実例とともに整理します。

gemini-api286swift4swiftui4ios12mobile4streaming20multimodal32app-store7

プレミアム記事

iOS アプリ開発者として、Gemini API を「ちょっとお試し」する段階から「本番リリースして収益を得る」段階へ進もうとしたとき、壁にぶつかった経験はないでしょうか。ストリーミング応答が途切れる、マルチモーダル入力が重い、App Store の審査で API キーの扱いを指摘される——そんな悩みを持つ方に向けて、ここでは Firebase AI Logic SDK に頼らず、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 による排他制御です。複数のリクエストが同時に走った際の競合状態を防ぐため、GeminiStreamingClientactor として定義しています。ストリーミングの実装詳細についてはGemini APIでストリーミング応答とマルチターン会話を実装するも参考になります。

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
バックグラウンド遷移でストリームが無言で固まる問題を scenePhase と Task.isCancelled で解決する実装
モバイル回線で頻発する SSE 行分割を吸収する SSELineBuffer の具体コード
MAU 8,000 規模での実測レイテンシ・月間 API 費用・Crashlytics クラッシュフリー率の判断材料
Stripe による安全な決済 · いつでもキャンセル可能
シェア

お読みいただきありがとうございます

Gemini Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

API / SDK2026-05-12
Gemini 3.2 Pro の Function Calling を iOS/Android アプリに統合する:個人開発 12 年・累計 5,000 万 DL から見えた設計パターン
Gemini 3.2 Pro の Function Calling を iOS/Android アプリに統合する実装ガイド。SwiftUI・Kotlin 両対応のコード例と、12 年の個人開発・累計 5,000 万 DL 事業で実証した本番設計パターンを解説します。
API / SDK2026-05-14
Gemini TTS API を SwiftUI アプリに組み込む — AVAudioEngine との連携で詰まった2つの問題
Gemini TTS API が返す PCM データを SwiftUI + AVAudioEngine で再生する実装を解説。PCM フォーマット変換と AVAudioSession 設定の2つの落とし穴を、実際のコードとともに紹介します。
API / SDK2026-05-05
Gemini APIキーをモバイルアプリに埋め込んではいけない:Firebase App Check で実現する多層防御アーキテクチャ
モバイルアプリでGemini APIを安全に使うためのバックエンド設計パターン。Firebase App Check・Cloud Functions・レートリミット・異常検知を組み合わせた本番グレードの多層防御実装ガイド。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →