クラウドの LLM は便利ですが、通信が前提です。電波の届かない場所や、データを外に出したくない場面では使えません。
そこで、Google の Gemma 4 を Android 端末の中だけで動かす、テキスト・画像・音声に対応したアプリを作りました。
モデルのダウンロードを除けば、推論時の通信は一切ありません。完全にオフラインで動きます。
本記事では、作ったものと使い方、そして実装でつまずいた点を中心にまとめます。
本アプリは個人が開発した非公式のプロジェクトで、Google とは一切関係ありません(提携・後援・承認はありません)。
"Gemma" は Google LLC の商標です。本記事ではモデルを指す説明的な用法としてのみ使用しています。
Gemma 4 モデルは Apache License 2.0 で配布されています。
なぜオンデバイスで動かすのか
クラウド API ではなく、あえて端末内で推論する利点は次の通りです。
- プライバシー: 入力したテキスト・画像・音声が端末の外に出ない
- オフライン動作: ネットワークがなくても推論できる
- API 課金なし: 利用回数に応じたコストが発生しない
- レイテンシ: サーバとの往復がない
その代わり、どんなモデルでも端末内で動くわけではありません。クラウド前提の大規模モデルをローカルのデバイスなどで処理することはむずかしいです。そこで、端末のリソースで動かせるよう軽量化・量子化されたモデルが必要になります。
今回は、モバイル向けに量子化された Gemma 4 E2B(QAT モバイル量子化版・約2.5GB)を選びました。
なお、モデル全般の性能の比べ方やリーダーボードの読み方については別記事「AIリーダーボードガイド — 用途別おすすめサイトまとめ」にまとめています(こちらはクラウド含む一般的なモデルの話です)。
作ったもの
オンデバイス LLM を実際に触ってみたかったこと、そして画像や音声まで含めたマルチモーダルな処理を端末内で完結させたかったことが動機です。
できることは次の4つです。
| 機能 | 内容 |
|---|---|
| テキストチャット | マルチターン会話。トークンを逐次表示するストリーミング出力 |
| 画像解析 / OCR | カメラ撮影またはギャラリーの画像を解析。文字抽出(OCR)のクイックボタン付き |
| 音声入力 | マイク録音した音声をそのままモデルへ入力 |
| ツール(function calling) | 現在時刻の取得・数値計算を、モデルが必要なときだけ自動で呼び出す |
使い方
必要なものは Android 実機(USB デバッグ有効)、Node.js 22 以上、Android Studio です。
1. クローンと依存関係のインストール
git clone https://github.com/kaze-uta/unofficial-gemma-vlm-android.git
cd unofficial-gemma-vlm-android
npm install
2. ビルドと起動
npx expo run:android
3. モデルの自動ダウンロード
初回起動時に、アプリが Gemma 4 E2B(約2.5GB)をアプリ専用領域へ自動でダウンロードします。進捗バーが表示され、中断後の再開にも対応しています(Wi-Fi 推奨)。
ネットワークの問題などで自動ダウンロードに失敗した場合は、HuggingFace から手動で取得して adb push で配置する手順も用意してあります(詳細は README を参照)。
各モードの操作
- チャット: メッセージを送ると回答がストリーミング表示されます。「いま何時?」「3.5 と 12 と 7 を全部掛けて」のように入力すると、モデルが必要に応じて組込み関数を自動実行し、その結果を踏まえて答えます。
- 画像解析 / OCR: 「カメラ」か「ギャラリー」を選び、質問を入力して解析します。「OCR 文字抽出」チップを押すと文字起こし用の指示が入ります。
- 音声入力: 「録音開始」で話し、「停止して解析」を押すと処理されます。「要約」「文字起こし」「英訳」などのチップで指示を切り替えられます。
技術スタックと構成
| 項目 | 内容 |
|---|---|
| フレームワーク | React Native 0.84 + Expo 55 |
| 推論エンジン | LiteRT-LM 0.13.1(MediaPipe tasks-genai の後継・公式推奨) |
| モデル | gemma-4-E2B-it(QAT モバイル量子化, .litertlm 形式, 約2.5GB) |
| カメラ / 画像 | react-native-vision-camera + expo-image-picker |
| 音声 | expo-audio |
| 対応 OS | Android 7.0(API 24)以上 |
構成は次の3層です。
-
JS 層(React Native UI): 画面とユーザー操作。
App.tsxがチャット・画像・音声の3モードを持つ -
ネイティブブリッジ(
GemmaModule.kt): JS からの呼び出しを受け、推論エンジンを操作。結果はイベントとして JS へストリーミングで返す - 推論エンジン(LiteRT-LM): モデルの読み込みと推論を担う
実装でつまずいた点
MediaPipe tasks-genai から LiteRT-LM へ移行した
当初は MediaPipe の tasks-genai で推論する構成でしたが、これが非推奨となったため、公式が後継として推奨する LiteRT-LM 0.13.1 へ移行しました。
モデル形式も .task から .litertlm に変わります。API は Engine / Conversation を中心とした構成になっています。
GPU を優先し、失敗したら CPU にフォールバックする
GPU バックエンドは速い一方、RAM の少ない端末では初期化に失敗することがあります。そこで、まず GPU 構成で初期化を試み、例外が出たら CPU 構成で再初期化する形にしました。
音声処理は CPU の方が安定するため、GPU 構成でも音声バックエンドだけは CPU を指定しています。
val created = try {
val gpuConfig = EngineConfig(
modelPath = resolvedPath,
backend = Backend.GPU(),
visionBackend = Backend.GPU(),
audioBackend = Backend.CPU(),
cacheDir = cacheDir,
)
Engine(gpuConfig).also { it.initialize() }
} catch (e: Exception) {
// GPU 初期化に失敗したら CPU 構成で再初期化
val cpuConfig = EngineConfig(
modelPath = resolvedPath,
backend = Backend.CPU(),
visionBackend = Backend.CPU(),
audioBackend = Backend.CPU(),
cacheDir = cacheDir,
)
Engine(cpuConfig).also { it.initialize() }
}
録音した音声をそのまま渡すとデコードできない(AAC → WAV 変換)
LiteRT-LM の音声デコーダ(miniaudio)は WAV / MP3 / FLAC にしか対応していません。一方、expo-audio の録音は AAC(.m4a)で出力されるため、そのまま渡すと読み込めませんでした。
対処として、録音ファイルを MediaExtractor と MediaCodec で PCM16 にデコードし、WAV ヘッダを自前で付けて書き出してから推論へ渡すようにしました。
// .m4a(AAC) は miniaudio が読めないため、PCM16 の WAV に変換してから渡す
val wavPath = decodeToWavFile(path)
parts.add(Content.AudioFile(wavPath))
function calling を Kotlin の @Tool 注釈で実装する
チャットで「いま何時?」のような質問に正確に答えるため、function calling(ツール)を組み込みました。
LiteRT-LM では @Tool 注釈を付けた関数をエンジンがリフレクションで自動的に呼び出してくれます(automaticToolCalling)。
ツールは @Tool を付けた関数として定義します。
class BuiltinToolSet(private val onCall: (String, String) -> Unit) : ToolSet {
@Tool(description = "現在の日付と時刻を取得する。今が何時か・今日の日付を問われたら使う。")
fun getCurrentDateTime(): String {
val fmt = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss (EEE)", java.util.Locale.JAPAN)
return fmt.format(java.util.Date())
}
@Tool(description = "数値のリストの積を計算する。")
fun product(
@ToolParam(description = "掛け合わせる数値の配列(小数可)") numbers: List<Double>,
): Double = numbers.fold(1.0) { acc, n -> acc * n }
}
補足: 定義した
BuiltinToolSetは、推論を開始する前にToolSetとして登録しておきます。あとはautomaticToolCallingを有効にしておけば、モデルが必要と判断したときにエンジンが自動で該当関数を呼び出してくれます。
// ツールを登録し、自動呼び出しを有効にする
val toolSet = BuiltinToolSet { name, result ->
// [Tool] 呼び出しを履歴に反映するなどのコールバック
}
val conversation = engine.createConversation(
ToolConfig(
toolSet = toolSet,
automaticToolCalling = true,
)
)
これで土台は完成です。
ツールを増やしたいときは、BuiltinToolSet に @Tool を付けた関数を1つ足すだけで済みます。
まとめ
- Gemma 4 E2B を React Native + LiteRT-LM で、Android 端末上で完全オンデバイス動作させました
- テキスト・画像・音声のマルチモーダル入力と、function calling に対応しています
- 実装上の要点は、LiteRT-LM への移行、GPU/CPU フォールバック、音声の WAV 変換、
@Toolによる function calling の4つでした
約2.5GB のモデルが、通信なしで端末内だけでテキスト・画像・音声を処理できる、というところまで到達できました。
ソースコードは GitHub で公開しています。スターやいいねをもらえると励みになります!
ここまで読んでいただきありがとうございました!
- ライセンス: アプリコードは MIT License、Gemma 4 モデルは Apache License 2.0