WWDC26 の閉幕翌日、運用中のアプリのソースを開いて軽く後悔しました。Gemini API のクライアントを View 層から直接呼んでいる箇所が、思った以上に多かったからです。
Apple Foundation Models が一定条件の開発者に無償開放され、Claude や Gemini を同一の Swift API から呼べるサーバーサイド統合まで発表された今、「どのモデルを使うか」をコードの奥深くに焼き付ける書き方は、1年後の自分への負債になります。
個人開発において、モデルの乗り換えは「起こるかもしれないこと」ではなく「毎年起こること」です。私自身、この2年でテキスト要約・翻訳・画像説明のバックエンドを何度も切り替えてきました。そのたびにアプリ側のコードを書き換えるのは、もう終わりにしたい。そう考えて設計し直した「アプリ内 AI 層の3層抽象化」を、Swift のコードとあわせて整理します。
モデル直結の実装は、なぜ1年で破綻するのか
直結実装の問題は、動かなくなることではありません。動き続けるのに、変えられなくなることです。
硬直化のポイントは3つあります。
モデル固有のリクエスト形式が UI 層まで漏れる : Gemini のリクエスト構造体を View Model が直接組み立てていると、プロバイダ変更がそのまま UI 層の改修になります
料金・無償枠の条件変更に追従できない : 今回の Apple の無償開放には「初回ダウンロード 200 万未満」という線引きがあると発表されています。この種の条件は、アプリの成長やポリシー改定で立場が変わりうるものです。条件が変わるたびに呼び出し箇所を全部書き換えるのは現実的ではありません
フォールバックが書けない : オンデバイスで失敗したらクラウドへ、という多段構成は、呼び出し口が統一されていないと実装のしようがありません
私のアプリでは、Gemini クライアントへの直接 import が 14 ファイルに散っていました。この数字を見た瞬間に、抽象化レイヤーを入れる決心がつきました。
2026年夏以降のアプリ内 AI は3層で考える
WWDC26 後の構図を整理すると、iOS アプリから使える AI 実行環境は3層になります。
第1層 オンデバイス(Foundation Models framework) : 遅延が最も小さく、通信不要で、追加コストもかかりません。語彙力や長文の一貫性は上位層に譲りますが、分類・短文生成・キーワード抽出には十分です
第2層 Private Cloud Compute : 今回無償開放が発表された層です。画像入力に対応し、端末外ではあるものの Apple のプライバシー基盤の上で動きます
第3層 サードパーティ API(Gemini API など) : 性能の天井が最も高く、その分従量課金です。発表されたサーバーサイド統合では、この層も同一の Swift API から扱える方向だとされています
設計の要点は、機能ごとに「どの層を使うか」を決めることではありません。層を行き来できる構造を先に作り、判断は後から差し替えられるようにすること です。判断基準は後半で扱います。
プロトコルひとつでモデルクライアントを束ねる
アプリ内のあらゆる AI 呼び出しが依存する、唯一のインターフェースを定義します。何を解決するコードかと言えば、「呼び出し側がプロバイダを知らずに済む」ことです。
// アプリ内の AI 呼び出しはすべてこのプロトコルだけに依存させる
protocol AITextClient : Sendable {
var tier: AITier { get }
func generate ( _ request: AIRequest) async throws -> AIResponse
}
enum AITier : Int , Comparable , Sendable {
case onDevice = 0
case privateCloud = 1
case thirdParty = 2
static func < ( lhs : Self , rhs : Self ) -> Bool { lhs. rawValue < rhs. rawValue }
}
struct AIRequest : Sendable {
let prompt: String
let maxTokens: Int
let privacy: PrivacyClass // 入力データの性質。ルーティングで使う
let deadline: TimeInterval // 呼び出し側が許容できる待ち時間(秒)
}
enum PrivacyClass : Sendable {
case sensitive // 個人情報・ユーザー作成コンテンツを含む
case standard // 端末外に出せるが、自社管理外には出したくない
case open // どの層に送ってもよい
}
struct AIResponse : Sendable {
let text: String
let servedBy: AITier // どの層が応答したかを記録しておくと運用分析に効く
}
AIRequest に privacy と deadline を持たせているのが、この設計の中心です。「どのモデルを使うか」ではなく「この呼び出しは何を許容できるか」を呼び出し側に宣言させます。プロバイダの選択はルーター側の仕事になり、呼び出し側のコードはモデルが変わっても一切変わりません。
もうひとつの実利はテスト容易性です。プロトコルに準拠したスタブを1つ書けば、ネットワークに触れずに全画面の AI 連携をユニットテストできます。直結実装の頃は UI テストでしか検証できなかった分岐が、プロトコル化の後は数十ミリ秒で回るテストに置き換わりました。
各プロバイダは、このプロトコルに準拠する形で実装します。
struct GeminiClient : AITextClient {
let tier: AITier = .thirdParty
private let apiKey: String // Keychain 等から注入する。コードに直書きしない
init ( apiKey : String ) {
self .apiKey = apiKey
}
func generate ( _ request: AIRequest) async throws -> AIResponse {
var urlRequest = URLRequest ( url : URL ( string :
"https://generativelanguage.googleapis.com/v1/models/gemini-pro:generateContent?key= \( apiKey ) " ) ! )
urlRequest.httpMethod = "POST"
urlRequest. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
urlRequest.httpBody = try JSONEncoder (). encode (
GeminiPayload ( prompt : request.prompt, maxOutputTokens : request.maxTokens))
let (data, _ ) = try await URLSession.shared. data ( for : urlRequest)
let decoded = try JSONDecoder (). decode (GeminiResult. self , from : data)
return AIResponse ( text : decoded. text , servedBy : .thirdParty)
}
}
API キーは YOUR_GEMINI_API_KEY のようなプレースホルダーをコードに残さず、初期化時に Keychain から注入する構造にしておきます。テスト時にモックへ差し替えやすくなる副次効果もあります。
フォールバック順序は「ポリシー」として分離する
多段フォールバックを呼び出し側の do-catch で書き始めると、同じ分岐がアプリ中に増殖します。ルーターとして一箇所に閉じ込めるのが、長期運用では効きます。
enum AIRouterError : Error {
case allTiersFailed
case timedOut
}
struct AIRouter {
let clients: [AITextClient] // tier の昇順で渡す
func generate ( _ request: AIRequest) async -> Result<AIResponse, AIRouterError> {
for client in orderedClients ( for : request) {
do {
let response = try await withTimeout ( seconds : request.deadline) {
try await client. generate (request)
}
return . success (response)
} catch {
continue // この層は諦めて次の層へ
}
}
return . failure (.allTiersFailed)
}
private func orderedClients ( for request: AIRequest) -> [AITextClient] {
switch request.privacy {
case .sensitive :
// 個人情報を含む入力は端末の外に出さない
return clients. filter { $0 .tier == .onDevice }
case .standard :
return clients. filter { $0 .tier <= .privateCloud }
case .open :
return clients
}
}
}
func withTimeout < T : Sendable >(
seconds : TimeInterval,
_ work: @escaping @Sendable () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup ( of : T. self ) { group in
group. addTask { try await work () }
group. addTask {
try await Task. sleep ( nanoseconds : UInt64 (seconds * 1_000_000_000 ))
throw AIRouterError.timedOut
}
guard let result = try await group. next () else {
throw AIRouterError.allTiersFailed
}
group. cancelAll ()
return result
}
}
なぜこう書くのか。フォールバックの順序・打ち切り時間・プライバシー境界という「ポリシー」が、AIRouter と orderedClients だけに集まるからです。来年どこかの層の条件が変わっても、直すのはこのファイルひとつで済みます。
逆に、本番運用でほぼ確実に落とし穴になる書き方も挙げておきます。各画面の View Model に if onDeviceFailed { callGemini() } を直接書くパターンです。最初の1画面では問題になりませんが、3画面目あたりからタイムアウト値や順序が画面ごとに食い違い始め、不具合報告を再現できなくなります。実際に私はこれで数日を溶かしました。タイムアウトと順序をルーターに一元化しておけば、この種の再現不能バグは構造的に回避できます。
どの層に送るかを決める3つの基準
ルーティングの判断基準は、運用してみると次の3つに収斂しました。
プライバシー : ユーザーが書いた文章や写真を含むなら、原則オンデバイスに限定します。利便性のためにクラウドへ送る選択肢はありますが、その場合は設定画面で明示的にオプトインを取る作りにしています
遅延 : UI をブロックする呼び出し(入力補完・分類など)は deadline を 1 秒以下に設定し、実質オンデバイス専用にします。手元の iPhone 16 では短文生成が概ね 0.5 秒前後で返ります。同じプロンプトを第3層経由で送ると 2〜3 秒かかっていたので、体感では 4〜6 倍の差です。実際、運用中のアプリでは AI 呼び出しの約 80% がこの第1層だけで完結しています。一方、バックグラウンドで走る要約などは 10 秒許容にして上位層まで開放します
コスト : サードパーティ層は従量課金なので、呼び出し回数が読めない機能には使いません。回数上限を AIRouter の手前でカウントし、月の予算を超えたら第2層までに自動で切り下げる作りが安心です。個人的には、判断に迷う呼び出しは下位の層へ保守的に倒す運用をお勧めします
どの機能をどの層に割り当てるかという優先順位の付け方は、Apple Foundation Models の無償開放 — 壁紙アプリに組み込む機能を3つの基準で絞り込む で詳しく書きましたので、あわせて参考にしていただければ幸いです。
Antigravity に実装を任せるときは「層の境界」を先に渡す
この抽象化レイヤーの実装は、Antigravity のエージェントに任せやすい仕事です。ただし任せ方に一つコツがあります。
エージェントに「Gemini 対応を追加して」とだけ指示すると、高い確率で View Model に直接クライアントを生やしてきます。エージェントは既存コードの最短距離を選ぶからです。私が効果を実感した指示の切り方は、先にプロトコル定義と禁止事項を渡すことでした。
AITextClient プロトコルの定義ファイルを最初にコンテキストへ含める
「AIRouter 以外の場所から各クライアントを直接 import しない」という否定形の制約を明記する
受け入れ条件として「既存の呼び出し側コードに差分が出ないこと」を指定する
この3点を渡してからは、エージェントの実装が設計の境界を破ってくることがほぼなくなりました。抽象化レイヤーは人間のためだけでなく、エージェントに安全に作業を委ねるための「柵」としても機能します。
まとめ — 最初の一歩は import 文の棚卸しから
設計の話は、手を動かす入口が見えないと絵に描いた餅になります。次にやることは一つだけです。
プロジェクトで grep -r "import GoogleGenerativeAI" --include="*.swift" . のように検索して、モデル SDK への直接依存が何ファイルに散っているかを数えてください。その数字が2桁なら、機能追加より先にプロトコル定義から始める価値があります。
Apple と Google の発表が続いた今週は、アプリ内 AI の前提が大きく動いた一週間でした。実装の参考になれば幸いです。