◉GEMINI LABEN
●ARGON — gemini-4-argon が 10/13 に GA。複雑なコーディング・エージェント・数学・科学向けです●DEEP RES — deep-research-pro-preview-12-2025 は 10/23 に停止、明日です。移行先は deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 が GA(10/06)。gemini-3.1-flash-image は非推奨で停止日は未発表です●MCP — ツール結果が 8.5万トークンに膨らんだ事例を、ADK で 310 トークンに包んで抑えた報告が出ています●FLASH — gemini-3.7-flash と 3.5-flash は新しい Flash モデルに自動で回されます(10/08)●NEW — クラッシュ報告を毎回ゼロから読ませない — 既知バグ早見表を先に grep し…●ARGON — gemini-4-argon が 10/13 に GA。複雑なコーディング・エージェント・数学・科学向けです●DEEP RES — deep-research-pro-preview-12-2025 は 10/23 に停止、明日です。移行先は deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 が GA(10/06)。gemini-3.1-flash-image は非推奨で停止日は未発表です●MCP — ツール結果が 8.5万トークンに膨らんだ事例を、ADK で 310 トークンに包んで抑えた報告が出ています●FLASH — gemini-3.7-flash と 3.5-flash は新しい Flash モデルに自動で回されます(10/08)●NEW — クラッシュ報告を毎回ゼロから読ませない — 既知バグ早見表を先に grep し…
記事一覧/API / SDK
◈ API / SDK/2026-04-03上級

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

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

gemini-api290swift4swiftui4ios13mobile5streaming18multimodal24app-store7

✦ プレミアム記事

審査の結果が戻ってきた朝、理由の欄に並んでいたのはコードの不備ではなく、プライバシーラベルの一行でした。シミュレーターでは一度も出会わない種類の差し戻しです。

手元で動いていたものが、他人の端末と審査の目の前に出た途端に崩れる——本番と呼ばれる段階で待っているのは、たいていこの種の落差でした。バックグラウンドで無言のまま止まるストリーム、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でストリーミング応答とマルチターン会話を実装するも参考になります。

✦

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

この記事の続きを読む

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

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

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

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

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

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

関連記事

◈ API / SDK2026-06-23
Gemini 3.2 Pro の Function Calling を iOS/Android アプリに統合する実装パターン
Gemini 3.2 Pro の Function Calling を iOS/Android アプリに統合する実装ガイド。SwiftUI・Kotlin 両対応の動くコード例と、個人開発の壁紙アプリで実証した本番設計パターン(コスト・レイテンシ・段階ロールアウト・回帰テスト)を解説します。
◈ API / SDK2026-05-31
Gemini 2.5 Flash で App Store のキーワード欄を多言語最適化した1ヶ月の所感
App Store Connect の100文字キーワード欄を、40本の壁紙アプリ×複数ロケールで Gemini 2.5 Flash に下書きさせた1ヶ月の運用ログです。CJK の文字数、タイトル重複の排除、禁止語の落とし穴、ロケールごとの効きどころの違いまで、現場でつまずいた点を淡々と記録します。
◈ API / SDK2026-05-29
Embedding APIでアプリレビュー1万件を意味クラスタリングして改善優先度を割り出す設計
貯まり続けるアプリレビューを Gemini Embedding で意味クラスタに束ね、改善優先度を割り出す設計です。HDBSCAN による粗いクラスタリング、Gemini によるクラスタ主訴の要約、優先度スコアリングという3層パイプラインとコスト設計の勘所を残します。