「画像を説明してもらう機能を付けたいけど、API 費用が毎月いくらかかるか心配で踏み出せない」——そんな悩みを持ちながらアプリ開発をされている方は、意外と多いのではないでしょうか。
私も壁紙アプリや画像系のコンテンツアプリを長年個人開発してきた中で、同じ壁に何度かぶつかりました。OpenAI の Vision API や Google Gemini API は強力ですが、無料枠を使い切ったあとのコスト予測が難しく、ユーザーが増えるほど赤字になるリスクがあります。
Gemma 4 と Antigravity の組み合わせは、この問題を根本から解決してくれます。モデルをデバイス上で動かすため API 費用はゼロです。Antigravity がコード生成をアシストしてくれるため、Core ML の難解な実装も短時間で仕上げられます。カメラ・フォトライブラリ・テキスト入力の 3 パスに対応した SwiftUI マルチモーダルアプリを、本番投入できる品質で実装する手順を順を追って整理していきます。
なぜ Gemma 4 × SwiftUI なのか — オンデバイス vs クラウド API の選択基準
マルチモーダル機能をアプリに組み込む方法は、大きく分けると「クラウド API 呼び出し」と「オンデバイス推論」の 2 択です。
クラウド API のメリットは、モデルが常に最新で精度が高い点と、デバイス側の実装が比較的シンプルな点です。デメリットは、ユーザーごとに API コストが発生すること、推論にネットワーク遅延が伴うこと(オフライン不可)、そして画像データが外部サーバーに送信されるプライバシー上の懸念が残ることです。
一方、オンデバイス(Gemma 4)のメリットは鮮明です。API 課金なし——ユーザーが何万人になってもコストはゼロです。オフラインで動作します。そして、画像データがデバイスから出ないため、GDPR・App Store プライバシー審査で有利に働きます。デメリットは、モデルサイズ分(数百 MB〜数 GB)のダウンロードが発生することと、端末スペックに性能が左右されることです。
個人開発アプリでは「課金ゼロで動く」メリットが特に大きく、壁紙・フォト・アート系アプリとの相性は抜群です。Gemma 4 の 2B パラメータ版であれば、iPhone 15 以降で実用的な速度で推論が可能です。
Gemma 4 のオンデバイス実装の基礎については、Gemma 4 × Antigravity — iOS・Android 両対応オンデバイスAI 完全実装ガイドでも詳しく解説していますので、あわせてご参照ください。
Antigravity でプロジェクト構成を整える — AGENTS.md が品質を決める
まず Antigravity にプロジェクトルートを開いてもらい、AGENTS.md を作成します。Antigravity はこのファイルを読んでコンテキストを把握するため、iOS プロジェクト特有の制約(Swift Concurrency、メインアクター隔離など)を最初に明記しておくと、生成コードの品質が格段に上がります。
# AGENTS.md
## プロジェクト概要
iOS アプリ(SwiftUI、最小ターゲット iOS 17)。
Gemma 4 をオンデバイス推論に使用。Core ML モデルを `Models/` フォルダに配置。
## コーディング規約
- Swift Concurrency を全面採用(async/await、Actor 分離)
- メインアクター上での UI 更新を徹底すること
- エラーは `enum AppError: LocalizedError` で統一的に扱う
- `@MainActor` を ViewModel クラスに付与し、`Task {}` 内で非同期推論を呼ぶパターンを維持する
## 禁止事項
- `DispatchQueue.main.async` の新規追加(Structured Concurrency に統一)
- 推論結果を直接 `@Published` 変数に代入(メインアクター外からの変更禁止)
- 画像の向き修正を省略すること
## テスト方針
- XCTest を使用
- `GemmaInferenceManager` はモックを使ったユニットテスト対象
- 推論のインテグレーションテストは `XCTSkip` で実機専用に制限する
- 画像処理(向き修正・リサイズ)は純粋な関数テストとして実装するこの設定をしておくと、Antigravity に「カメラ入力のコードを書いて」とだけ指示しても、Swift Concurrency 対応のコードが返ってきます。AGENTS.md への投資は記事執筆で言えば「アウトライン作成」に相当するもので、手間をかけた分だけ後工程が楽になります。
Gemma 4 Core ML モデルの統合と推論マネージャー
Google AI Edge が提供する Gemma 4 の Core ML 変換済みモデルを使います。まず Google AI Edge SDK for iOS を Xcode の Package Dependencies から追加し、次にモデルファイル(gemma-2b-it-cpu-int4.bin)を Models/ フォルダに配置してターゲットに追加してください。
Antigravity に「Gemma 4 のマルチモーダル推論を行う GemmaInferenceManager クラスを作成して」と指示すると、ベースコードが生成されます。ただし、生成されたままでは Swift Concurrency のパターンが甘いことがあるため、以下の実装を参考に必ず確認・修正してください。
import Foundation
import MediaPipeTasksGenAI
// ✅ 正しいパターン: @MainActor + Task.detached で UI とバックグラウンドを分離
@MainActor
class GemmaInferenceManager: ObservableObject {
@Published var inferenceResult: String = ""
@Published var isInferring: Bool = false
@Published var isModelLoaded: Bool = false
@Published var error: AppError?
private var inference: LlmInference?
// モデルロードは起動時に一度だけ行う
func loadModel() async throws {
guard let modelPath = Bundle.main.path(
forResource: "gemma-2b-it-cpu-int4",
ofType: "bin"
) else {
throw AppError.modelFileNotFound
}
let options = LlmInference.Options(modelPath: modelPath)
options.maxTokens = 1024
options.topk = 40
options.temperature = 0.8
// モデルロードはバックグラウンドで実行し、UIスレッドをブロックしない
let loaded = try await Task.detached(priority: .userInitiated) {
try LlmInference(options: options)
}.value
self.inference = loaded
self.isModelLoaded = true
}
// マルチモーダル推論 — 画像 + テキストプロンプト
func infer(image: UIImage, prompt: String) async {
guard let inference = inference else {
self.error = .modelNotLoaded
return
}
isInferring = true
inferenceResult = ""
error = nil
do {
// 推論前に画像をリサイズしてメモリ消費を抑える
let resizedImage = image.resizedForInference()
guard let mpImage = try? MPImage(uiImage: resizedImage) else {
throw AppError.imageConversionFailed
}
let fullPrompt = "<image>\n\(prompt)"
// ストリーミング推論でリアルタイム表示
try await Task.detached(priority: .userInitiated) {
for try await token in inference.generateResponseAsync(
inputText: fullPrompt,
inputImages: [mpImage]
) {
// メインアクターに切り替えてから @Published を更新
await MainActor.run {
self.inferenceResult += token
}
}
}.value
} catch {
self.error = .inferenceError(error.localizedDescription)
}
isInferring = false
}
}
// MARK: - アプリ全体で使用するエラー定義
enum AppError: LocalizedError {
case modelNotLoaded
case modelFileNotFound
case imageConversionFailed
case inferenceError(String)
case cameraPermissionDenied
var errorDescription: String? {
switch self {
case .modelNotLoaded:
return "AIモデルの読み込みが完了していません。しばらくお待ちください。"
case .modelFileNotFound:
return "AIモデルファイルが見つかりませんでした。アプリを再インストールしてください。"
case .imageConversionFailed:
return "画像の変換中にエラーが発生しました。別の画像をお試しください。"
case .inferenceError(let msg):
return "AI推論中にエラーが発生しました: \(msg)"
case .cameraPermissionDenied:
return "カメラの使用許可が必要です。設定アプリから許可してください。"
}
}
}ここで陥りやすい間違い①: Task.detached を省略して inference.generateResponseAsync をメインアクター上で直接呼ぶと、推論中に UI がフリーズします。推論は必ず Task.detached か Task(priority: .background) 内で実行し、UI 更新だけ MainActor.run に切り替えてください。Swift Concurrency の詳細な使い方は、Antigravity × Swift Concurrency 完全マスターガイドも参考になります。
カメラ・フォトライブラリ・テキストの 3 入力パスを実装する
SwiftUI で 3 種類の入力をひとつのビューで扱うには、入力ソースを enum で管理するのが最もシンプルです。以下は実際のアプリに組み込める完全なビュー実装です。
import SwiftUI
import PhotosUI
import AVFoundation
struct MultimodalInputView: View {
@EnvironmentObject private var inferenceManager: GemmaInferenceManager
@State private var selectedImage: UIImage?
@State private var selectedPhotoItem: PhotosPickerItem?
@State private var promptText: String = ""
@State private var showCamera: Bool = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
imagePreviewSection
inputSourceButtons
promptInputSection
// モデル未ロード時の警告
if !inferenceManager.isModelLoaded {
Label("AIモデルを読み込み中...", systemImage: "hourglass")
.foregroundStyle(.secondary)
.font(.callout)
}
resultSection
}
.padding()
}
.navigationTitle("AI 画像アナライザー")
.sheet(isPresented: $showCamera) {
CameraView(capturedImage: $selectedImage)
}
.onChange(of: selectedPhotoItem) { _, newItem in
Task {
if let data = try? await newItem?.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
await MainActor.run {
selectedImage = image.fixedOrientation()
}
}
}
}
}
}
// MARK: - サブビュー
@ViewBuilder
private var imagePreviewSection: some View {
if let image = selectedImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 280)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(alignment: .topTrailing) {
// 画像クリアボタン
Button {
selectedImage = nil
inferenceManager.inferenceResult = ""
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.white)
.background(Circle().fill(.black.opacity(0.5)))
}
.padding(8)
}
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemGray6))
.frame(height: 200)
.overlay {
VStack(spacing: 8) {
Image(systemName: "photo.on.rectangle")
.font(.system(size: 40))
.foregroundStyle(.secondary)
Text("画像を選択してください")
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
}
private var inputSourceButtons: some View {
HStack(spacing: 12) {
// カメラボタン
Button {
checkCameraPermissionAndOpen()
} label: {
Label("カメラ", systemImage: "camera")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
// フォトライブラリボタン
PhotosPicker(
selection: $selectedPhotoItem,
matching: .images,
photoLibrary: .shared()
) {
Label("ライブラリ", systemImage: "photo.on.rectangle.angled")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
private var promptInputSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("プロンプト(省略可)")
.font(.caption)
.foregroundStyle(.secondary)
TextField("この画像について...", text: $promptText, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(3...6)
}
}
private var resultSection: some View {
VStack(alignment: .leading, spacing: 12) {
if inferenceManager.isInferring {
HStack {
ProgressView()
Text("AI が分析中...")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
if !inferenceManager.inferenceResult.isEmpty {
GroupBox("AI の回答") {
Text(inferenceManager.inferenceResult)
.font(.body)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
}
if let error = inferenceManager.error {
Label(error.localizedDescription, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
.font(.callout)
}
if selectedImage != nil {
Button {
runInference()
} label: {
Label("AI で分析する", systemImage: "sparkles")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(inferenceManager.isInferring || !inferenceManager.isModelLoaded)
}
}
}
// MARK: - プライベートメソッド
private func checkCameraPermissionAndOpen() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
showCamera = true
case .notDetermined:
Task {
let granted = await AVCaptureDevice.requestAccess(for: .video)
await MainActor.run {
if granted {
showCamera = true
} else {
inferenceManager.error = .cameraPermissionDenied
}
}
}
default:
inferenceManager.error = .cameraPermissionDenied
}
}
private func runInference() {
guard let image = selectedImage else { return }
let prompt = promptText.trimmingCharacters(in: .whitespacesAndNewlines)
let finalPrompt = prompt.isEmpty ? "この画像を詳しく説明してください。" : prompt
Task {
await inferenceManager.infer(image: image, prompt: finalPrompt)
}
}
}ここで陥りやすい間違い②: PhotosPicker の onChange コールバック内で UIImage への変換を忘れると、選択後に画像が表示されません。loadTransferable(type: Data.self) → UIImage(data:) の変換は必須です。さらに、フォトライブラリから選択した画像は imageOrientation が .up 以外になっていることが多いため、fixedOrientation() で必ず正規化してください。
CameraView — AVFoundation カメラ実装の完全版
SwiftUI から UIViewControllerRepresentable でカメラを呼び出します。Antigravity に「@Binding var capturedImage: UIImage? で画像を返す UIViewControllerRepresentable」と具体的に指示すると、的確なコードが生成されます。
import AVFoundation
import SwiftUI
struct CameraView: UIViewControllerRepresentable {
@Binding var capturedImage: UIImage?
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = context.coordinator
picker.allowsEditing = false
picker.cameraDevice = .rear // デフォルトはリアカメラ
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: CameraView
init(parent: CameraView) {
self.parent = parent
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
if let image = info[.originalImage] as? UIImage {
// ✅ 向き修正してから保存
parent.capturedImage = image.fixedOrientation()
}
parent.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.dismiss()
}
}
}
// MARK: - UIImage ユーティリティ Extension
extension UIImage {
/// カメラ撮影後の向きメタデータを正規化する
func fixedOrientation() -> UIImage {
guard imageOrientation != .up else { return self }
UIGraphicsBeginImageContextWithOptions(size, false, scale)
defer { UIGraphicsEndImageContext() }
draw(in: CGRect(origin: .zero, size: size))
return UIGraphicsGetImageFromCurrentImageContext() ?? self
}
/// 推論前に最大辺を 1024px に収めてリサイズ(メモリ節約・高速化)
func resizedForInference(maxDimension: CGFloat = 1024) -> UIImage {
let longSide = max(size.width, size.height)
guard longSide > maxDimension else { return self }
let scale = maxDimension / longSide
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
return UIGraphicsImageRenderer(size: newSize).image { _ in
draw(in: CGRect(origin: .zero, size: newSize))
}
}
}ここで陥りやすい間違い③: カメラで撮影した UIImage をそのまま Core ML に渡すと、向きメタデータ(imageOrientation)が正しく処理されず、AI が回転した画像として認識することがあります。fixedOrientation() を必ず挟んでください。また、高解像度の撮影画像(48MP 相当)はメモリ使用量が跳ね上がるため、resizedForInference() でのリサイズは省略しないことをおすすめします。
アプリエントリーポイントとモデルの事前ロード
モデルをいつロードするかは、UX に直結します。推論ボタンを押した瞬間にロードすると、数秒〜十数秒のラグが発生してユーザー体験を損ないます。起動時に非同期でロードするのが正解です。
@main
struct MultimodalAIApp: App {
@StateObject private var inferenceManager = GemmaInferenceManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(inferenceManager)
.task {
// ✅ 起動直後にバックグラウンドでモデルをロード
do {
try await inferenceManager.loadModel()
} catch {
// エラーは GemmaInferenceManager 側で @Published error に格納済み
// ここでの追加処理は不要(UIはerrorをバインドして表示)
print("モデルロード失敗: \(error.localizedDescription)")
}
}
}
}
}起動直後に重いモデルをロードすることに抵抗を感じる方もいらっしゃるかもしれません。ただ、実際に計測してみると、Gemma 4 2B の量子化モデル(int4)は iPhone 15 で 1〜2 秒程度でロードが完了します。スプラッシュ画面の表示中に裏でロードを済ませておけば、ユーザーには待ち時間として体感されません。
モデルのバンドル方法については、アプリバイナリにモデルを同梱する方法と、初回起動時に Background Assets フレームワークを使って非同期ダウンロードする方法の 2 択があります。200MB を超えるモデルをバンドルするとアプリの初回ダウンロードサイズが大きくなり、インストール率が下がることがあります。個人開発アプリでは、まず小型の量子化モデルをバンドルして検証し、ユーザーフィードバックに基づいて大型モデルへの移行を判断するのが現実的な進め方だと思います。
App Store 審査 — AI 機能のプライバシー・ガイドライン対応
オンデバイス AI をアプリに組み込む際に必須の申請対応をまとめます。
Info.plist には以下の権限説明を追加してください:
NSCameraUsageDescription— 例: 「AI で画像を分析するためにカメラを使用します。撮影した写真はデバイス内でのみ処理され、外部サーバーに送信されません。」NSPhotoLibraryUsageDescription— 例: 「AI で画像を分析するためにフォトライブラリへのアクセスを求めます。選択した写真はデバイス内でのみ処理されます。」
App Privacy の設定では「データを収集しない」を正直に選択できます。これはオンデバイス AI の大きなアドバンテージで、クラウド API では「推論のためにデータを送信する」を開示する必要がありますが、オンデバイスではその必要がありません。審査担当者からも好意的に見てもらいやすい構成です。
AI 生成コンテンツのラベリングについては、Apple のガイドラインでは現時点で必須ではありませんが、明示することで信頼性が高まります。推論結果の表示エリアに「AI による分析結果」というラベルを添えておくと、ユーザーにとっても分かりやすい UX になります。
Antigravity バックグラウンドエージェントを使ったテスト自動化
ここまでのコードを Antigravity の バックグラウンドエージェントに任せてテストを書いてもらう方法を紹介します。
Antigravity のバックグラウンドエージェントを使えば、本体の UI 実装を進めながら裏でテストを生成してもらえます。依頼するときの指示はこのように具体的にしてください:
「GemmaInferenceManager のユニットテストを XCTest で書いて。モックプロトコルを使って LlmInference への依存を切り離し、エラーケース(modelNotLoaded / imageConversionFailed)が正しく @Published error に格納されるかをテストします。実機が必要な推論テストは XCTSkip でスキップする」
この指示があれば、Antigravity は MockLlmInference プロトコルと 5〜8 件のテストケースを自動生成します。ポイントは「エラーケースを指定すること」です。エラーケースを指定しないと、ハッピーパスのテストしか生成されない傾向があります。
よくある落とし穴と解決策のまとめ
実装を進める中で特に注意が必要なポイントを整理します。
モデル初期化の遅延によるクラッシュ: 推論実行前に isModelLoaded フラグを確認し、未ロード時はボタンを無効化してください。上のコード例では .disabled(!inferenceManager.isModelLoaded) でこれを防いでいます。
並列推論リクエストによる競合: 複数回素早くボタンを押した場合に推論が重複して実行されます。isInferring フラグで排他制御するとともに、ボタンの disabled 条件に含めてください。
低スペック端末での推論失敗: Gemma 4 2B int4 は iPhone 12 以下で OOM(メモリ不足)エラーが発生することがあります。do-catch でエラーを補足し、ユーザーに「AIモデルの実行に必要なメモリが不足しています。他のアプリを閉じてから再試行してください。」と伝える実装を入れておくことをおすすめします。
ストリーミング出力中の画面スクロール: 推論結果が増えていく過程で、テキストが増えるたびにスクロールビューが上にジャンプすることがあります。ScrollViewReader と .onChange(of: inferenceManager.inferenceResult) を組み合わせて、最下部に自動スクロールする実装を加えると UX が改善します。
本番運用に向けてすべきこと
ここまでの実装でプロトタイプは動く状態になっています。本番リリースに向けて追加すべき要素を挙げておきます。
推論履歴の保存は、SwiftData を使うのが最もシンプルです。@Model で推論結果エンティティを定義し、過去の分析結果を一覧表示する機能を追加すれば、リテンション向上につながります。
収益化の入口として、推論回数に制限(例: 1 日 5 回無料)を設けて、それ以上は StoreKit 2 の消耗型課金で追加回数を購入できる設計にする方法が定番です。オンデバイスなのでサーバー側の検証が不要で、実装がシンプルに収まります。
まず今日できる一歩は、この記事のコードを使って AGENTS.md を作成し、Antigravity に「GemmaInferenceManager のベースコードを生成して」と指示することです。Antigravity があれば、この記事の実装をゼロから始めても 1〜2 日でプロトタイプが動く状態になるはずです。ぜひ試してみてください。