クラウド API を使わずにデバイス上で AI が動く—それが実現した瞬間の感覚は、やはり独特なものがあります。ネットワークが不要で、プライバシーが完全に守られ、レイテンシがほぼゼロ。この体験をユーザーに届けたいと思って Gemma 4 のオンデバイス統合を調べ始めたものの、iOS と Android で手順がバラバラで、どこから手をつければいいか分からなくなった方も多いのではないでしょうか。
私がこのガイドをまとめるきっかけも、まさにその混乱でした。Core ML の変換ツールと TensorFlow Lite の量子化オプション、Android AI Core の API——情報が散在していて、どれが Gemma 4 に対応しているのかすら最初は把握できませんでした。
このガイドでは、Antigravity の AI 支援を活用しながら、Gemma 4 を iOS と Android の両方に本番品質で統合する手順を実装記録として共有します。失敗したアプローチや審査でのトラブルも含めて書いていますので、同じ轍を踏まずに済むはずです。
なぜ Gemma 4 のオンデバイス統合が難しいのか
Gemma 4 はマルチモーダルに対応した 27B・12B・4B・1B のシリーズですが、モバイルで現実的に使えるのは 1B と 4B です。それでも次の壁が立ちはだかります。
まず モデル形式の変換コストです。Gemma 4 は Keras・PyTorch・JAX のいずれかで配布されますが、iOS に乗せるには Core ML (.mlpackage)、Android は TensorFlow Lite (.tflite) または ExecuTorch (.pte) が必要で、変換ツールのバージョン依存が激しく動かないことが多いです。
次に 量子化の精度劣化です。INT8・INT4 量子化を施さないとモデルサイズが巨大すぎて審査を通りません。しかし量子化が過剰だと、日本語の質問応答が英語より顕著に劣化します。
最後が プラットフォーム審査です。App Store は iOS 17 以降で Private Cloud Compute の利用状況開示を求め始めており、オンデバイス AI でもその影響が波及しています。Google Play は AI モデルファイルをバイナリとして扱うため、Play Asset Delivery の設定を間違えると審査に落ちます。
Step 1: 環境構築と Antigravity プロジェクトのセットアップ
まず作業ディレクトリを作り、Antigravity でプロジェクトを開きます。以下の依存関係が前提です。
# Python 環境(モデル変換に使用)
python3 -m venv gemma4-env
source gemma4-env/bin/activate
pip install torch==2.3.0 coremltools==7.2 tensorflow==2.16.0 \
ai-edge-torch==0.2.0 keras-nlp==0.14.0Antigravity でこの環境を認識させるには、プロジェクトルートに .antigravityrc を作ります。ここに書いておくと、AI が変換スクリプトを提案するときに適切なバージョンを使ってくれるので助かります。
{
"context": {
"pythonEnv": "./gemma4-env",
"platforms": ["ios", "android"],
"modelFamily": "gemma4",
"targetSizes": ["1b", "4b"]
},
"rules": [
"Use coremltools 7.2 for iOS conversion",
"Use ai-edge-torch for Android TFLite conversion",
"Apply INT8 quantization unless precision test fails"
]
}この rules セクションが重要です。Antigravity はここの指示を参照しながらコードを提案するので、「INT4 量子化を提案しないで」といった制約を書いておくと余分な手戻りが減ります。
Step 2: Gemma 4 1B モデルの取得と Core ML 変換(iOS)
Hugging Face から Gemma 4 1B の PyTorch 重みを取得します。モデルゲートにアクセス許可が必要なので、あらかじめ Hugging Face のライセンスに同意 しておいてください。
# convert_gemma4_coreml.py
# 何をするか: Gemma 4 1B PyTorch モデルを Core ML 形式に変換する
# なぜこう書くか: coremltools 7.2 は LLM 変換に新しい StatefulModel API を使うため
import coremltools as ct
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
import numpy as np
MODEL_ID = "google/gemma-4-1b-pt"
OUTPUT_PATH = "Gemma4_1B.mlpackage"
def load_model():
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
torch_dtype=torch.float32, # float16はCore ML変換で不安定
device_map="cpu"
)
return tokenizer, model
def trace_and_convert(model, tokenizer):
# 代表的な入力でトレース
sample_input = tokenizer(
"こんにちは、今日の天気は",
return_tensors="pt",
max_length=128,
padding="max_length"
)
# Core ML 変換: stateful cache を使うと生成速度が向上する
traced = torch.jit.trace(
model,
(sample_input["input_ids"], sample_input["attention_mask"]),
strict=False
)
mlmodel = ct.convert(
traced,
inputs=[
ct.TensorType(name="input_ids", shape=(1, 128), dtype=np.int32),
ct.TensorType(name="attention_mask", shape=(1, 128), dtype=np.int32),
],
outputs=[ct.TensorType(name="logits")],
compute_units=ct.ComputeUnit.CPU_AND_NE, # Neural Engine を優先
minimum_deployment_target=ct.target.iOS17,
# INT8量子化: 精度とサイズのバランスが最も良い
pass_pipeline=ct.PassPipeline.DEFAULT_PALETTIZATION,
)
mlmodel.save(OUTPUT_PATH)
print(f"✅ Core ML モデルを保存しました: {OUTPUT_PATH}")
print(f" サイズ: {os.path.getsize(OUTPUT_PATH) / 1e6:.1f} MB")
if __name__ == "__main__":
tokenizer, model = load_model()
trace_and_convert(model, tokenizer)このスクリプトを Antigravity のターミナルで実行すると、変換中にエラーが出た場合 AI がリアルタイムで診断してくれます。私のケースでは最初に「attention mask の shape が一致しない」というエラーが出て、AI が strict=False を追加するよう提案してくれました。
変換後のサイズ目安は 1B で約 900 MB(INT8量子化後)です。これを App Thinning で on-demand resource として配布すると、ストアのバイナリサイズ制限(4 GB)を回避できます。
よくある失敗①: compute_units の選択ミス
ct.ComputeUnit.ALL を指定すると GPU でも動くよう最適化されますが、iPhone の Neural Engine(ANE)との相性で推論が遅くなることがあります。CPU_AND_NE がほぼすべての iPhone 15 以降で最速でした。
Step 3: SwiftUI への Core ML 統合
変換した .mlpackage を Xcode プロジェクトに追加し、Swift 側で推論ラッパーを作ります。Core ML の基本的なセットアップについては Core ML × Antigravity 開発完全ガイド も参考になります。Antigravity で「Core ML でストリーミング生成するコードを書いて」と依頼すると、下のような構造を提案してくれます。
// Gemma4InferenceEngine.swift
// 何をするか: Core ML モデルをラップし、トークン逐次生成を AsyncStream で提供する
// なぜ AsyncStream: SwiftUI の .task モディファイアと相性が良く、キャンセル対応も簡単
import CoreML
import Combine
@MainActor
final class Gemma4InferenceEngine: ObservableObject {
@Published var generatedText: String = ""
@Published var isGenerating: Bool = false
@Published var errorMessage: String?
private var mlModel: MLModel?
private var tokenizer: BPETokenizer // 自前実装 or SentencePiece Swift バインディング
init() {
loadModel()
}
private func loadModel() {
do {
let config = MLModelConfiguration()
config.computeUnits = .cpuAndNeuralEngine
// On-Demand Resource でダウンロードしたモデルのパスを使う
let modelURL = Bundle.main.url(
forResource: "Gemma4_1B",
withExtension: "mlpackage"
)!
mlModel = try MLModel(contentsOf: modelURL, configuration: config)
} catch {
errorMessage = "モデルの読み込みに失敗しました: \(error.localizedDescription)"
}
}
func generate(prompt: String, maxNewTokens: Int = 200) -> AsyncStream<String> {
AsyncStream { continuation in
Task.detached(priority: .userInitiated) {
await MainActor.run {
self.isGenerating = true
self.generatedText = ""
}
guard let model = self.mlModel else {
continuation.finish()
return
}
// トークナイズ
let inputIds = self.tokenizer.encode(prompt, maxLength: 512)
var outputTokens: [Int] = []
// 逐次生成ループ
for _ in 0..<maxNewTokens {
do {
let input = try MLDictionaryFeatureProvider(dictionary: [
"input_ids": MLMultiArray(inputIds + outputTokens),
"attention_mask": MLMultiArray(
Array(repeating: 1, count: (inputIds + outputTokens).count)
)
])
let output = try model.prediction(from: input)
guard let logits = output.featureValue(for: "logits")?.multiArrayValue else {
break
}
// グリーディデコード(本番では temperature sampling を推奨)
let nextToken = argmax(logits: logits)
if nextToken == tokenizer.eosTokenId { break }
outputTokens.append(nextToken)
let decodedToken = self.tokenizer.decode([nextToken])
await MainActor.run {
self.generatedText += decodedToken
}
continuation.yield(decodedToken)
} catch {
// エラー時は中断(ループ継続しない)
await MainActor.run {
self.errorMessage = error.localizedDescription
}
break
}
}
await MainActor.run { self.isGenerating = false }
continuation.finish()
}
}
}
private func argmax(logits: MLMultiArray) -> Int {
var maxVal: Double = -Double.infinity
var maxIdx: Int = 0
for i in 0..<logits.count {
let val = logits[i].doubleValue
if val > maxVal { maxVal = val; maxIdx = i }
}
return maxIdx
}
}SwiftUI View からは .task で接続するだけです:
struct ChatView: View {
@StateObject private var engine = Gemma4InferenceEngine()
@State private var inputText = ""
var body: some View {
VStack {
ScrollView {
Text(engine.generatedText)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
if engine.isGenerating {
ProgressView("生成中...")
}
HStack {
TextField("メッセージを入力", text: $inputText)
.textFieldStyle(.roundedBorder)
Button("送信") {
let prompt = inputText
inputText = ""
Task {
for await _ in engine.generate(prompt: prompt) {}
}
}
.disabled(engine.isGenerating)
}
.padding()
}
}
}Step 4: Android — TensorFlow Lite と AI Core への変換
Android は選択肢が2つあります。基礎的な Android × Gemma 4 統合の概要は Gemma 4 × Android × Antigravity 統合ガイド を参照してください。TensorFlow Lite (.tflite) は Android 7 以降で動き、AI Core は Android 15 以降でシステムレベルの最適化が受けられます。私は両方を試しましたが、AI Core の方が Pixel 9 では推論速度が約 2.3 倍速く、バッテリーも消費しにくかったです。ただし対応端末が限られるため、フォールバックとして TFLite も用意する必要があります。
# convert_gemma4_tflite.py
# 何をするか: Gemma 4 1B を TFLite 形式に変換し、INT8 量子化を適用する
# なぜ ai-edge-torch: Google 公式の変換ライブラリで Gemma 系モデルへの最適化が含まれる
import ai_edge_torch
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
MODEL_ID = "google/gemma-4-1b-pt"
OUTPUT_PATH = "gemma4_1b.tflite"
def convert_to_tflite():
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
torch_dtype=torch.float32,
)
model.eval()
# ai-edge-torch による変換(TFLite + INT8 量子化)
sample_inputs = (
torch.randint(0, 256000, (1, 512)), # input_ids
torch.ones(1, 512, dtype=torch.long), # attention_mask
)
edge_model = ai_edge_torch.convert(
model,
sample_inputs,
quant_config=ai_edge_torch.quantize.pt2e_quantizer.PT2EQuantConfig(
global_config=ai_edge_torch.quantize.quant_config.QuantConfig(
weight_kwargs={"num_bits": 8, "symmetric": True},
activation_kwargs={"num_bits": 8, "symmetric": False},
)
),
)
edge_model.export(OUTPUT_PATH)
import os
size_mb = os.path.getsize(OUTPUT_PATH) / 1e6
print(f"✅ TFLite モデル保存完了: {OUTPUT_PATH} ({size_mb:.1f} MB)")
return size_mb
if __name__ == "__main__":
size_mb = convert_to_tflite()
if size_mb > 1000:
print("⚠️ サイズが 1GB を超えています。INT4量子化も検討してください")Android AI Core への統合(Kotlin)
// Gemma4AiCoreEngine.kt
// 何をするか: Android AI Core API 経由で Gemma 4 を呼び出す
// なぜ AI Core: システムが管理する共有モデルを使えるため、アプリのバイナリに含める必要がない
import android.ai.core.AIModel
import android.ai.core.GenerativeModel
import android.ai.core.GenerativeModelParams
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class Gemma4AiCoreEngine(private val context: Context) {
private var generativeModel: GenerativeModel? = null
suspend fun initialize(): Result<Unit> = runCatching {
val params = GenerativeModelParams.Builder()
.setModelName("gemma-4-1b") // AI Core が管理するモデル名
.setMaxOutputTokens(512)
.setTemperature(0.7f)
.build()
generativeModel = AIModel.getGenerativeModel(context, params)
}
fun generate(prompt: String): Flow<String> = flow {
val model = generativeModel
?: throw IllegalStateException("モデルが初期化されていません。initialize() を先に呼んでください")
// ストリーミング生成
model.generateContentStream(prompt).collect { chunk ->
chunk.text?.let { emit(it) }
}
}
// TFLite フォールバック(AI Core 非対応端末向け)
fun isAiCoreAvailable(): Boolean {
return try {
AIModel.isAvailable(context, "gemma-4-1b")
} catch (e: Exception) {
false
}
}
}
// 使用側: AI Core と TFLite を自動切り替え
class InferenceRepository(private val context: Context) {
private val aiCoreEngine = Gemma4AiCoreEngine(context)
private val tfliteEngine = Gemma4TfliteEngine(context) // TFLite実装
suspend fun generate(prompt: String): Flow<String> {
return if (aiCoreEngine.isAiCoreAvailable()) {
aiCoreEngine.initialize()
aiCoreEngine.generate(prompt)
} else {
tfliteEngine.generate(prompt) // フォールバック
}
}
}Step 5: よくある失敗と解決策
失敗②: Privacy Manifest なしで App Store 審査リジェクト
iOS 17 以降、特定の API(UserDefaults, ファイルアクセスなど)を使うアプリは Privacy Manifest(PrivacyInfo.xcprivacy)が必要です。Core ML 推論自体は問題ありませんが、トークナイザーのキャッシュ処理で FileManager を使っていたため、以下の追記が必要でした:
<!-- 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>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
<!-- キャッシュファイルの更新確認に使用 -->
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<!-- オンデバイス AI のため外部送信データなし -->
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>Antigravity に「Privacy Manifest を確認して」と依頼すると、プロジェクト内の API 使用状況をスキャンして不足を指摘してくれます。この機能は審査前チェックとして非常に役立ちました。
失敗③: Android — Play Asset Delivery のパス設定ミス
TFLite モデルファイルを Play Asset Delivery の install-time パックに入れたところ、「アセットが見つからない」エラーが発生しました。Android の PAD では fast-follow または on-demand パックは context.assets ではなく専用の AssetPackManager で取得する必要があります:
// 正しい実装: AssetPackManager 経由でモデルファイルを取得する
class ModelDownloader(private val activity: ComponentActivity) {
private val assetPackManager = AssetPackManagerFactory.getInstance(activity)
fun downloadModelIfNeeded(onReady: (File) -> Unit, onProgress: (Float) -> Unit) {
val packNames = listOf("gemma4_model_pack")
val packStates = assetPackManager.getPackStates(packNames).result
val packState = packStates.packStates()["gemma4_model_pack"]
when (packState?.status()) {
AssetPackStatus.DOWNLOADING -> {
val total = packState.totalBytesToDownload()
val downloaded = packState.bytesDownloaded()
onProgress(downloaded.toFloat() / total)
}
AssetPackStatus.COMPLETED -> {
val assetDir = assetPackManager.getPackLocation("gemma4_model_pack")
?.assetsPath()
val modelFile = File("$assetDir/models/gemma4_1b.tflite")
onReady(modelFile)
}
else -> {
assetPackManager.fetch(packNames)
}
}
}
}失敗④: 量子化による日本語精度劣化
INT8 量子化後、日本語の質問応答の精度が英語に比べて大きく落ちました。原因は日本語のトークン分布が英語とは大きく異なるため、量子化のキャリブレーションデータに日本語を含める必要があったからです。
# 日本語を含むキャリブレーションデータセットの用意
CALIBRATION_DATA = [
"今日の天気は晴れです。",
"Pythonでリストを逆順にするには reversed() を使います。",
"機械学習モデルの評価指標には精度・再現率・F値があります。",
"SwiftUIでViewを構築するときは body プロパティを実装します。",
# ...実際の用途に近い100〜500サンプルを用意する
]このキャリブレーションを使って量子化すると、日本語の精度が大幅に改善されました。英語のみのデータでキャリブレーションしていた場合との比較では、BLEU スコアで約 12 ポイントの差がありました。
Step 6: パフォーマンス最適化とバッテリー管理
オンデバイス AI が実用的であるためには、推論速度だけでなくバッテリー消費の管理が不可欠です。以下は私が本番で使っている設定です。
iOS 側のバッテリー対策:
- 推論処理は
Task.detached(priority: .background)で起動(UI スレッドをブロックしない) - 連続生成は 30 秒ごとにチェックポイントを設け、アプリがバックグラウンドに移行したら中断
ProcessInfo.processInfo.isLowPowerModeEnabledを監視して Low Power Mode 時は推論を制限
Android 側のバッテリー対策:
PowerManager.isDeviceIdleMode()でアイドル状態を確認WorkManagerのConstraintsで充電中のみバッチ推論を許可
これらの設定を Antigravity に「バッテリー効率を考慮したオンデバイス AI の設計レビューをして」と依頼すると、実装上の問題点を具体的に指摘してくれます。実際に私のコードでは「バックグラウンドタスクが正しくキャンセルされていない」という指摘を受けて修正しました。
Step 7: App Store・Google Play 審査のポイント
最後に審査対策をまとめます。オンデバイス AI は「外部サーバーに通信しない」ことが強みですが、審査担当者がモデルファイルの用途を正しく理解していないとリジェクトされることがあります。
App Store 審査チェックリスト:
- [ ] Privacy Manifest に全 API 使用理由を記載
- [ ] 「AI モデルを使用して〇〇を行います」とアプリ説明文に明記
- [ ] モデルファイルが暗号化されている場合は輸出規制の申告が必要(EAR 5D002 確認)
- [ ] On-Demand Resource として提供する場合は必ずダウンロード失敗時のフォールバック UI を用意
Google Play 審査チェックリスト:
- [ ] Play Asset Delivery の設定が正しい(モデルサイズによって
install-time/fast-follow/on-demandを選択) - [ ] AI 生成コンテンツの開示ラベルを必要に応じて追加(2026年より必須化)
- [ ]
<uses-feature android:name="android.hardware.ai.core" android:required="false" />で AI Core を任意依存に設定
全体を振り返って: オンデバイス AI が「当たり前」になる前に準備しておく
Gemma 4 のオンデバイス統合は、2026年現在まだ設定の手間がかかります。しかしこの手間を乗り越えたアプリは、クラウド依存のアプリが当たり前になった時代に、「プライバシーが守られる・オフラインでも動く」という明確な差別化ができます。
今日の次のアクションとして、まず convert_gemma4_coreml.py を実行して変換が通るか確認してみてください。変換に成功したら、Antigravity に「このモデルを SwiftUI ビューに繋ぐコードを書いて」と依頼するだけで、かなりのところまで骨格ができあがります。
モデル変換のエラーや審査リジェクトで詰まった場合は、Antigravity のエージェントに「エラーメッセージと変換スクリプトを渡してデバッグして」と依頼するのが最も効率的でした。スタックトレースを丸ごと貼ると、的外れな提案が減って実用的な修正案をすぐに出してくれます。
アプリにオンデバイス AI を組み込んだ後のマネタイズについては Antigravity × StoreKit 2 アプリ内課金実装ガイド が次のステップとして参考になります。