Flutter Local ML Model Inference: A Practical, Production-Ready Guide

A practical guide to running fast, private, offline ML inference in Flutter using TFLite, ONNX Runtime, and PyTorch Mobile.

ASOasis
8 min read
Flutter Local ML Model Inference: A Practical, Production-Ready Guide

Image used for representation purposes only.

Overview

On-device machine learning (ML) in Flutter unlocks fast, private, and offline experiences—from real‑time vision to audio and NLP. This guide shows how to run local inference in Flutter using mainstream runtimes (TensorFlow Lite, ONNX Runtime, and PyTorch Mobile), how to package and update models, and how to optimize performance and reliability for production apps.

Why local inference?

  • Low latency: Avoid network round trips; keep interactions within the 16 ms/frame UI budget where possible.
  • Privacy: Keep user data on device; minimize regulatory risk and user mistrust.
  • Resilience: Work offline and degrade gracefully during network loss.
  • Cost control: Reduce server inference costs and egress fees.

Architectural choices

Flutter’s UI runs in the Dart VM, while native ML runtimes exist as Android/iOS libraries. You integrate them via:

  • Platform plugins: Prebuilt bindings that expose a Dart API to native runtimes.
  • FFI (Foreign Function Interface): Call C/C++ APIs directly from Dart for fine-grained control.
  • Platform channels: Send messages to small native wrappers that call ML libraries.

Most teams start with a plugin, then adopt FFI or custom channels when they need advanced delegates, custom ops, or tight memory control.

Common runtimes for Flutter

  • TensorFlow Lite (TFLite): Lightweight, mobile-first; broad hardware delegate support (NNAPI on Android, Metal/GPU on iOS, XNNPACK CPU).
  • ONNX Runtime Mobile: Good for models exported to ONNX; selective operator builds keep size down.
  • PyTorch (Mobile/ Lite Interpreter): Useful if your training stack is PyTorch and you want TorchScript models.
  • ML Kit (on-device APIs): Turnkey vision/NLP features without managing your own model files; less flexible for custom architectures.

Pick based on your training framework, model format, target hardware, and the need for custom ops.

Packaging the model

  1. As an asset (simplest):

    • Place assets/models/model.tflite (or .onnx / .ptl) in your project.
    • Reference it in pubspec.yaml:
      flutter:
        assets:
          - assets/models/model.tflite
      
    • Pros: Easy distribution; no first-run download.
    • Cons: App update required to change the model; increases APK/IPA size.
  2. Dynamic download:

    • Host the model on a CDN, verify its hash, download on first run, and cache under getApplicationSupportDirectory().
    • Pros: Update models without shipping a new app.
    • Cons: Add remote config, integrity checks, and migration logic.

Tip: Keep models under 50–100 MB for mobile; split per-ABI on Android to avoid shipping all native binaries to every device.

Threading and isolates

Inference must not block Flutter’s main isolate (UI thread). Use:

  • Isolate.run (Dart 3+) for synchronous compute off the main isolate.
  • compute for simple background tasks.
  • Streams/EventChannels when the native side pushes frames/results (e.g., camera pipelines).

Avoid sharing a single interpreter across isolates/threads concurrently unless the runtime guarantees thread safety. Create one interpreter per worker or use explicit serialization.

Example: TensorFlow Lite with tflite_flutter

Below is a minimal classifier that runs on a background isolate, supports hardware delegates, and loads a model from assets.

import 'dart:io';
import 'dart:isolate';
import 'package:flutter/services.dart' show rootBundle;
import 'package:tflite_flutter/tflite_flutter.dart';

class TfliteClassifier {
  final String assetPath;
  Interpreter? _interpreter;

  TfliteClassifier(this.assetPath);

  Future<void> init() async {
    final options = InterpreterOptions()
      ..threads = (Platform.numberOfProcessors.clamp(2, 8))
      ..useNnApiForAndroid = Platform.isAndroid; // CPU accel; safe default

    // Try a GPU/Metal delegate where available; swallow if not supported
    try {
      if (Platform.isAndroid) {
        options.addDelegate(GpuDelegateV2());
      } else if (Platform.isIOS) {
        options.addDelegate(GpuDelegate()); // uses Metal under the hood
      }
    } catch (_) {
      // Fallback to CPU
    }

    _interpreter = await Interpreter.fromAsset(assetPath, options: options);
  }

  Future<List<double>> run(List<double> input, List<int> inputShape) async {
    if (_interpreter == null) throw StateError('Interpreter not initialized');

    // Allocate tensors only once in production; shown inline for clarity
    _interpreter!.allocateTensors();

    // Reshape input if dynamic; ensure types match the model (e.g., float32 vs int8)
    var inputTensor = TensorBuffer.createFixedSize(inputShape, TfLiteType.float32);
    inputTensor.loadList(input);

    // Prepare output buffer; adapt to your model’s output shape
    final output = List.filled(1001, 0.0).reshape([1, 1001]);

    await Isolate.run(() {
      _interpreter!.run(inputTensor.getFloatList(), output);
    });

    return List<double>.from(output.first);
  }

  void close() => _interpreter?.close();
}

Notes:

  • Replace GpuDelegateV2/GpuDelegate with your target-specific delegates as needed.
  • Prefer allocating tensors once, reusing input/output buffers per frame.
  • If your model is quantized (int8/uint8), convert inputs accordingly and dequantize outputs using the tensor’s scale/zero-point.

Example: Loading a downloaded model file

import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:tflite_flutter/tflite_flutter.dart';

Future<Interpreter> loadDownloadedModel(String fileName) async {
  final dir = await getApplicationSupportDirectory();
  final path = '${dir.path}/$fileName';
  final options = InterpreterOptions()..threads = 4;
  return Interpreter.fromFile(File(path), options: options);
}

Add integrity checks (e.g., SHA‑256) before trusting the model. Store and compare expected hashes obtained from your backend or remote config.

Example: ONNX Runtime Mobile via FFI

ONNX Runtime exposes a C API, which Flutter can call using FFI through community plugins. The high-level flow:

  • Initialize the environment and session with mobile build settings.
  • Load the .onnx model from assets or disk.
  • Create input tensors and run Session.run().

This path is powerful if your training/export pipeline already outputs ONNX or you rely on operators that map more naturally to ONNX Runtime.

Example: PyTorch Mobile

For TorchScript (.pt/.ptl) models, a Flutter plugin can host a minimal native wrapper that:

  • Loads the TorchScript module.
  • Converts inputs (e.g., Float32List image tensors) to native tensor types.
  • Invokes forward() and returns outputs through a channel or FFI.

Torch-based pipelines can be convenient if you need parity with existing PyTorch code and quantization workflows like QAT.

Camera + real‑time inference pipeline

For vision tasks:

  • Use camera plugin to stream YUV/RGB frames.
  • Convert frames to the model’s input size and color space in native code or with SIMD-optimized libraries; avoid per-frame heap churn in Dart.
  • Batch work on a fixed-rate scheduler (e.g., every 2–3 frames) to maintain 60 FPS UI.
  • Pin CPU/GPU delegates early and reuse interpreters.

Performance fundamentals

  • Model choice: Prefer mobile‑friendly backbones (MobileNetV3, EfficientNet‑Lite, MobileViT, etc.).
  • Quantization:
    • Post‑training dynamic range (weight‑only): Smallest effort, largest gains for CPU.
    • Full‑integer (int8 activations/weights): Great for NNAPI/TPU‑like accelerators.
    • Float16: Useful on GPUs; lower precision with minimal accuracy drop.
  • Delegates:
    • Android: NNAPI for hardware acceleration, GPU delegate for shader‑friendly ops, XNNPACK for fast CPU.
    • iOS: Metal (via GPU delegate) for many conv/activation patterns; Accelerate for CPU.
  • Threads: Tune InterpreterOptions.threads empirically; more threads ≠ always faster due to contention.
  • Warmup: Run a few dummy inferences during startup to JIT/warm caches and allocate buffers.
  • Memory: Reuse input/output buffers; avoid copying large tensors across isolates.
  • Release builds: Benchmark on profile/release; debug mode skews timing.

Benchmarking

  • Use Stopwatch around only the run() call; measure preprocessing and postprocessing separately.
  • Collect p50/p90/p99 latencies across device classes (low, mid, flagship) and both cold and warm states.
  • Monitor energy impact with Android Studio Profiler and Xcode Energy Log; throttling can mask perf gains.

Model updates and rollouts

  • Version your models (e.g., model_v7_int8.tflite) and expose a JSON manifest with:
    • Version, SHA‑256, size, min app version, supported ABIs.
    • Optional rollout percentage for staged releases.
  • On startup, check for a newer version, validate integrity, then atomically swap files.
  • Keep the previous model for fast rollback.

Security and privacy

  • Integrity: Verify checksums and, if feasible, sign model files.
  • Obfuscation: You can obfuscate model names and apply light encryption; determined actors can still reverse‑engineer.
  • PII: Keep inputs/outputs local; avoid logging raw frames or embeddings.
  • Policy: Document on-device inference in your privacy policy; offer opt‑outs for analytics.

Handling unsupported operators

  • TFLite: Convert with SELECT_TF_OPS only if unavoidable; it increases binary size. Prefer re‑architecting to supported ops.
  • ONNX: Build a mobile package with just the needed kernels to keep the footprint small.
  • PyTorch: Ensure your TorchScript avoids custom Python-only ops.

Build and size tips

  • Android:
    • Enable ABI splits to avoid shipping all native libraries:
      android {
        splits {
          abi {
            enable true
            reset()
            include 'armeabi-v7a', 'arm64-v8a', 'x86_64'
            universalApk false
          }
        }
      }
      
    • Use R8/ProGuard; keep native symbols off release unless you need crash symbolication.
  • iOS:
    • Use release builds with bitcode disabled (modern Xcode toolchains); ensure Metal is available for GPU delegate devices.
    • Exclude simulator slices from your app store build to save size.

Testing matrix

  • CPU/GPU/NNAPI/Metal variations across:
    • Android 8–14, armv7/arm64, with/without Play Services.
    • iOS 14–latest, A‑series devices across tiers.
  • Stress with background tasks (music, downloads) to simulate real user conditions.

Troubleshooting checklist

  • Inference is slow: Reduce input size, quantize, enable XNNPACK/NNAPI/Metal, batch every N frames.
  • Crashes on delegate init: Catch/disable the delegate and fall back to CPU; log device/GPU details for telemetry.
  • Dimension mismatch: Inspect interpreter input/output shapes at runtime; resize inputs accordingly.
  • Out-of-memory: Reuse buffers, downscale images, and avoid holding multiple interpreters per screen.

When to pick each runtime

  • TFLite: Best default for mobile; strong tooling, delegates, and small model formats.
  • ONNX Runtime Mobile: If your pipeline is ONNX-centric or you need operators that map cleanly to ONNX.
  • PyTorch Mobile: If you train in PyTorch and need TorchScript parity, especially with QAT workflows.
  • ML Kit: If you want production-grade vision/NLP features quickly and can accept less flexibility.

Production-ready template (high level)

  • App startup:
    • Load model manifest → validate local cache → warmup interpreter.
  • Inference service:
    • Singleton per model with queueing; background isolate; adaptive rate control.
  • Telemetry:
    • Log inference latency (p50/p90), delegate in use, and fallbacks; no raw user data.
  • Rollout:
    • Staged percentage with rollback on regression; keep N-1 model for safety.

Conclusion

Local ML inference in Flutter is mature and production-ready. Start with TFLite for most workloads, consider ONNX Runtime or PyTorch Mobile based on your training stack, and design for isolates, delegates, and staged updates from the outset. With smart quantization and careful pipeline engineering, you can deliver private, real-time ML experiences that feel native and scale to millions of users—without a single server in the loop.

Related Posts