The mobile app landscape is undergoing a monumental paradigm shift. For years, integrating Artificial Intelligence meant sending user data over the network to cloud-based APIs, waiting for processing, and returning the result. While cloud-hosted models like Gemini 1.5 Pro offer massive reasoning capabilities, they introduce critical challenges: network latency, high API costs, data privacy issues, and complete dependence on internet connectivity.
On-device AI solves these challenges by running models directly on the user's hardware. In this tutorial, we will explore how to integrate Google’s Gemini Nano—a highly efficient LLM designed specifically for on-device execution—into a cross-platform Flutter application. We will cover the architecture required for local LLM inference, how to offload intensive tasks using Dart Isolates, how to bridge Dart with native SDKs using platform channels, and how to handle real-time streaming UI updates without dropping frames.
Why On-Device AI with Gemini Nano?
Running LLMs locally on mobile devices offers distinct advantages for both users and developers:
- Privacy-First Execution: Since the user's input never leaves the device, it is ideal for applications handling sensitive personal data, health records, or financial information.
- Zero Network Latency: Users do not have to wait for server roundtrips, resulting in instantaneous feedback and a snappier user experience.
- Offline Availability: The AI works in areas with poor or no connectivity, such as subways, airplanes, or remote locations.
- Zero Infrastructure Cost: You leverage the user’s on-device NPU/GPU hardware to run inference, completely eliminating recurring cloud API charges.
Gemini Nano is Google's most compact model, optimized to execute directly on mobile hardware. On Android, it runs via AICore, a system-level service that manages model updates, safety filters, and execution optimizations. On iOS and other platforms, we can run Gemini Nano (or similar open models like Gemma) using the MediaPipe LLM Inference API, which handles hardware-accelerated execution across platforms.
The Architecture of an AI-Native Flutter App
Running a 1.5B+ parameter LLM locally is resource-intensive. It can consume over 1GB of RAM and max out CPU/GPU resources during token generation. If you execute these operations on the main thread, the Flutter engine will block, leading to frozen animations, input lag, and a poor user experience. To avoid this, we must build a multi-threaded, reactive architecture:
+-------------------------------------------------------------------+
| Flutter UI |
| (Main Isolate: Renders UI, listens to Streams, updates state) |
+---------------------------------+---------------------------------+
|
Spawns / Runs | Yields Tokens via Stream
v
+-------------------------------------------------------------------+
| Dart Isolate Worker |
| (Handles file loading, serialization, background processing) |
+---------------------------------+---------------------------------+
|
Communicates via | Receives Stream Events
Platform Channels v
+-------------------------------------------------------------------+
| Native Platform Wrapper |
| (Kotlin / Swift - Manages MediaPipe LLM Inference / AICore) |
+---------------------------------+---------------------------------+
|
Executes | Streams Tokens Real-time
Inference v
+-------------------------------------------------------------------+
| Gemini Nano Engine |
| (On-Device Hardware: NPU / GPU Acceleration) |
+-------------------------------------------------------------------+
This decoupled flow ensures that the Flutter main thread remains dedicated solely to rendering UI frames at 60Hz or 120Hz, while the heavy-lifting of model loading, token serialization, and inference runs asynchronously.
Step 1: Managing the Model Lifecycle & On-Demand Download
Because LLM binaries are large (typically between 1.0GB and 1.5GB), bundling them directly inside the .apk or .ipa is impractical. Instead, we should check if the model is present locally on app launch, and if not, download it from a secure server (like Firebase Storage or a custom CDN) and save it to the application's local documents directory.
Here is how to write a model downloader and path resolver in Dart using the path_provider and dio packages:
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:dio/dio.dart';
class ModelManager {
static const String _modelFileName = 'gemini-nano.bin';
static const String _modelDownloadUrl = 'https://your-cdn.com/models/gemini-nano.bin';
Future<String> getModelPath() async {
final directory = await getApplicationDocumentsDirectory();
return '${directory.path}/$_modelFileName';
}
Future<bool> isModelDownloaded() async {
final path = await getModelPath();
return File(path).exists();
}
Stream<double> downloadModel() async* {
final dio = Dio();
final path = await getModelPath();
final tempPath = '$path.tmp';
try {
final response = await dio.download(
_modelDownloadUrl,
tempPath,
onReceiveProgress: (received, total) {
if (total != -1) {
// Yield the progress percentage
yield received / total;
}
},
);
if (response.statusCode == 200) {
// Rename temp file to actual model file on completion
await File(tempPath).rename(path);
}
} catch (e) {
// Handle error, cleanup temp file
final tempFile = File(tempPath);
if (await tempFile.exists()) {
await tempFile.delete();
}
rethrow;
}
}
}Step 2: Implementing Native Bridges (Platform Channels)
To access the MediaPipe LLM Inference API, we must write native platform code. We will use a MethodChannel to send commands (like model initialization and starting inference) and an EventChannel to stream generated tokens back to Dart as they are produced by Gemini Nano.
Android Kotlin Integration
On Android, we configure the MediaPipe LLM Inference library. The Kotlin plugin handles initialization on a background thread pool and uses a coroutine scope to listen for inference updates, sending them back to the Flutter UI via the event channel sink.
package com.example.gemini
import android.content.Context
import com.google.mediapipe.tasks.genai.llminference.LlmInference
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.File
class GeminiNanoPlugin: FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var llmInference: LlmInference? = null
private var context: Context? = null
private var eventSink: EventChannel.EventSink? = null
private val scope = CoroutineScope(Dispatchers.Default + Job())
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
methodChannel = MethodChannel(binding.binaryMessenger, "com.example.gemini/inference")
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, "com.example.gemini/stream")
eventChannel.setStreamHandler(this)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> {
val modelPath = call.argument<String>("modelPath")
if (modelPath != null && File(modelPath).exists()) {
initializeModel(modelPath, result)
} else {
result.error("INVALID_PATH", "Model file does not exist", null)
}
}
"startInference" -> {
val prompt = call.argument<String>("prompt")
if (prompt != null) {
startInference(prompt)
result.success(true)
} else {
result.error("INVALID_PROMPT", "Prompt cannot be null", null)
}
}
else -> result.notImplemented()
}
}
private fun initializeModel(modelPath: String, result: MethodChannel.Result) {
scope.launch {
try {
val options = LlmInference.LlmInferenceOptions.builder()
.setModelPath(modelPath)
.setMaxTokens(1024)
.setTemperature(0.7f)
.build()
llmInference = LlmInference.createFromOptions(context!!, options)
scope.launch(Dispatchers.Main) { result.success(true) }
} catch (e: Exception) {
scope.launch(Dispatchers.Main) { result.error("INIT_FAILED", e.message, null) }
}
}
}
private fun startInference(prompt: String) {
scope.launch {
try {
llmInference?.generateResponseAsync(prompt)
// Hook into response flow of MediaPipe to forward chunks via eventSink
} catch (e: Exception) {
scope.launch(Dispatchers.Main) { eventSink?.error("INFERENCE_ERROR", e.message, null) }
}
}
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
llmInference?.close()
}
}iOS Swift Integration
On iOS, we write a Swift class utilizing the MediaPipe Tasks library. Swift tasks utilize DispatchQueues to make sure the main thread remains untouched during long-running tasks.
import Flutter
import UIKit
import MediaPipeTasksGenAI
public class GeminiNanoPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
private var llmInference: LlmInference?
private var eventSink: FlutterEventSink?
public static func register(with registrar: FlutterPluginRegistrar) {
let methodChannel = FlutterMethodChannel(name: "com.example.gemini/inference", binaryMessenger: registrar.messenger())
let eventChannel = FlutterEventChannel(name: "com.example.gemini/stream", binaryMessenger: registrar.messenger())
let instance = GeminiNanoPlugin()
registrar.addMethodCallDelegate(instance, channel: methodChannel)
eventChannel.setStreamHandler(instance)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
if call.method == "initialize" {
guard let args = call.arguments as? [String: Any],
let modelPath = args["modelPath"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "Path is missing", details: nil))
return
}
initializeModel(modelPath: modelPath, result: result)
} else if call.method == "startInference" {
guard let args = call.arguments as? [String: Any],
let prompt = args["prompt"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "Prompt is missing", details: nil))
return
}
startInference(prompt: prompt)
result(true)
} else {
result(FlutterMethodNotImplemented)
}
}
private func initializeModel(modelPath: String, result: @escaping FlutterResult) {
DispatchQueue.global(qos: .userInitiated).async {
do {
let options = LlmInference.Options(modelPath: modelPath)
options.maxTokens = 1024
options.temperature = 0.7
self.llmInference = try LlmInference(options: options)
DispatchQueue.main.async {
result(true)
}
} catch {
DispatchQueue.main.async {
result(FlutterError(code: "INIT_FAILED", message: error.localizedDescription, details: nil))
}
}
}
}
private func startInference(prompt: String) {
guard let llmInference = self.llmInference else {
self.eventSink?(FlutterError(code: "NOT_INITIALIZED", message: "Model is not initialized", details: nil))
return
}
DispatchQueue.global(qos: .userInitiated).async {
do {
let response = try llmInference.generateResponse(prompt: prompt)
self.eventSink?(response)
} catch {
self.eventSink?(FlutterError(code: "INFERENCE_FAILED", message: error.localizedDescription, details: nil))
}
}
}
public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
self.eventSink = nil
return nil
}
}Step 3: Offloading Work via Dart Isolates
Even though the native code executes in background threads, Dart has to handle the incoming messages, tokenize strings, parse metadata, and write text variables to state objects. Since Flutter 3.7, platform channels can be safely invoked inside background isolates. This means we can offload the entire stream interception and pre-processing to a background isolate, freeing the main thread to only receive clean, UI-ready text chunks.
Let's write a Flutter service that establishes a long-running background isolate using the Isolate.run API or a helper class to handle token accumulation:
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class GeminiIsolateService {
static const MethodChannel _methodChannel = MethodChannel('com.example.gemini/inference');
static const EventChannel _eventChannel = EventChannel('com.example.gemini/stream');
Future<bool> loadModelAsync(String modelPath) async {
return await compute<String, bool>(_initModelStatic, modelPath);
}
static Future<bool> _initModelStatic(String path) async {
try {
final success = await _methodChannel.invokeMethod<bool>('initialize', {'modelPath': path});
return success ?? false;
} catch (e) {
return false;
}
}
Stream<String> generateText(String prompt) {
final StreamController<String> controller = StreamController<String>();
_methodChannel.invokeMethod('startInference', {'prompt': prompt}).then((_) {
_eventChannel.receiveBroadcastStream().listen(
(event) {
final String chunk = event as String;
controller.add(chunk);
},
onError: (err) {
controller.addError(err);
},
onDone: () {
controller.close();
},
);
}).catchError((err) {
controller.addError(err);
controller.close();
});
return controller.stream;
}
}Step 4: Designing the Reactive Streaming UI
To display real-time output, we need a chat UI that updates instantly as tokens stream in. A critical requirement for chat applications is auto-scrolling: when a message grows in length, the list view must automatically scroll down so the user can read the text as it is generated, without having to manually swipe.
Here is a complete, production-ready Flutter widget showing a chat interface utilizing a ScrollController to handle fluid auto-scrolling and a Stream to feed incoming words to the screen:
import 'package:flutter/material.dart';
class ChatScreen extends StatefulWidget {
final String modelPath;
const ChatScreen({Key? key, required this.modelPath}) : super(key: key);
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final TextEditingController _inputController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final GeminiIsolateService _aiService = GeminiIsolateService();
final List<Map<String, dynamic>> _messages = [];
bool _isModelLoading = true;
bool _isGenerating = false;
@override
void initState() {
super.initState();
_initializeModel();
}
Future<void> _initializeModel() async {
final success = await _aiService.loadModelAsync(widget.modelPath);
setState(() {
_isModelLoading = false;
_messages.add({
'text': success
? 'Gemini Nano initialized locally. Ask me anything!'
: 'Failed to initialize model. Please check file path.',
'isUser': false,
});
});
}
void _sendMessage() {
final text = _inputController.text.trim();
if (text.isEmpty || _isGenerating) return;
_inputController.clear();
setState(() {
_messages.add({'text': text, 'isUser': true});
_messages.add({'text': '', 'isUser': false});
_isGenerating = true;
});
_scrollToBottom();
final responseIndex = _messages.length - 1;
final stream = _aiService.generateText(text);
stream.listen(
(token) {
setState(() {
_messages[responseIndex]['text'] =
_messages[responseIndex]['text'] + token;
});
_scrollToBottom();
},
onDone: () {
setState(() {
_isGenerating = false;
});
},
onError: (error) {
setState(() {
_messages[responseIndex]['text'] = 'Inference error: $error';
_isGenerating = false;
});
},
);
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
}
});
}
@override
Widget build(BuildContext context) {
if (_isModelLoading) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading Gemini Nano into memory...'),
],
),
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Gemini Nano On-Device'),
backgroundColor: Colors.indigo,
),
body: Column(
children: [
Expanded(
child: ListView.builder(
controller: _scrollController,
itemCount: _messages.length,
padding: const EdgeInsets.all(12.0),
itemBuilder: (context, index) {
final message = _messages[index];
final isUser = message['isUser'] as bool;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 6.0),
padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 10.0),
decoration: BoxDecoration(
color: isUser ? Colors.indigo[100] : Colors.grey[200],
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(12),
topRight: const Radius.circular(12),
bottomLeft: Radius.circular(isUser ? 12 : 0),
bottomRight: Radius.circular(isUser ? 0 : 12),
),
),
child: Text(
message['text'] as String,
style: const TextStyle(fontSize: 15.0),
),
),
);
},
),
),
if (_isGenerating) const LinearProgressIndicator(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
color: Colors.white,
child: Row(
children: [
Expanded(
child: TextField(
controller: _inputController,
decoration: const InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(24.0)),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16.0),
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
color: Colors.indigo,
onPressed: _isGenerating ? null : _sendMessage,
),
],
),
),
],
),
);
}
}Best Practices for On-Device LLM Integration
Building local-first AI applications requires a defensive engineering mindset. Pay close attention to these performance and safety considerations:
1. Memory Management & App Lifecycle
Since LLMs reside in system RAM, leaving your model initialized when the user exits the application can cause the OS to aggressively terminate your process. Implement a lifecycle listener in your Flutter application to release the model memory when the application enters the background:
class LifecycleManager extends StatefulWidget {
final Widget child;
const LifecycleManager({Key? key, required this.child}) : super(key: key);
@override
State<LifecycleManager> createState() => _LifecycleManagerState();
}
class _LifecycleManagerState extends State<LifecycleManager> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
const MethodChannel('com.example.gemini/inference').invokeMethod('dispose');
}
}
@override
Widget build(BuildContext context) => widget.child;
}2. The Hybrid Fallback Pattern
Not all devices can handle running a local 1.5B+ parameter LLM. Lower-end devices may crash due to RAM limits, or suffer from extremely slow response speeds. To prevent this, build a hybrid model architecture. On app startup, perform a basic benchmark test. If the hardware has fewer than 6GB of RAM or lacks a supported GPU/NPU, fallback gracefully to a cloud-based API like Gemini Flash via the official google_generative_ai package.
Future Outlook: The Rise of Hardware-Aware Frameworks
We are still in the early days of on-device mobile AI. As chip manufacturers integrate powerful Neural Processing Units (NPUs) into entry-level mobile SOCs, the speed of local generation will increase from single-digit tokens per second to hundreds. We also expect the Flutter ecosystem to release first-party plugin wrappers for AICore and CoreML, removing the need for developers to write boilerplate Kotlin and Swift platform channels. Learning these principles today gives you a headstart in constructing the next wave of AI-native applications.
Conclusion
Integrating Gemini Nano inside Flutter applications unlocks an entirely new class of mobile software: one that respects user privacy, works deep in transit tunnels, and costs you nothing in server fees. By adopting a clean architecture using Dart Isolates, handling the download lifecycle properly, and listening to native system events via channels, you can craft beautiful, responsive AI-native experiences. Get started by implementing local models today and take your applications offline!


