• 29.10.2026 21:03:24
  • İbrahim Gökyar

This article, as part of a Flutter training, demonstrates moving image decode to a background isolate, building a zero-copy cache with TransferableTypedData, and limiting Flutter rebuilds with state-management techniques using a real-world example.

Flutter uzman eğitimi: Image Isolate, zero-copy cache ve state management optimizasyonu

Why move decoding to a background isolate and use TransferableTypedData?

Decoding large images on the UI isolate causes frame jank; instead, you can move decode+resize using package:image to a background isolate and send the result to the main isolate with TransferableTypedData to achieve zero-copy data transfer. To use in your app: 1) add package:image in pubspec.yaml:

dependencies:
  image: ^4.0.13
2) your background isolate function should return TransferableTypedData.fromList([bytes]) instead of a Uint8List; in the main isolate, take that data and create a ui.Image using ui.decodeImageFromPixels or ui.instantiateImageCodec.

Concrete reason: ui.decodeImageFromList and Image.memory perform decoding on the main isolate, which can block the build/raster pipeline and cause frame drops when you miss the 16ms target. TransferableTypedData removes copy overhead; use Flutter DevTools' Memory and Performance panels for profiling: run the app with

flutter run --profile
, record a Timeline in DevTools and compare the 90th-percentile frame times.

Practical: example decode, resize and TransferableTypedData with an isolate worker

The example worker below decodes JPEG/PNG with package:image, resizes to the target size, and sends raw RGBA ByteData as TransferableTypedData to the main isolate. Add this code as lib/isolate_image_worker.dart and spawn it with Isolate.spawn from main. Worker implementation example:

// isolate_image_worker.dart
import 'dart:typed_data';
import 'dart:isolate';
import 'package:image/image.dart' as img;
import 'dart:io';

void imageWorker(SendPort sendPort) async {
  final port = ReceivePort();
  sendPort.send(port.sendPort);
  await for (final msg in port) {
    final String path = msg[0];
    final SendPort reply = msg[1];
    final bytes = await File(path).readAsBytes();
    final image = img.decodeImage(bytes)!;
    final resized = img.copyResize(image, width: 400); // somut resize
    final rgba = Uint8List.fromList(img.encodePng(resized)); // encode as PNG bytes for demo
    // TransferableTypedData to avoid copy overhead when sending to main isolate
    reply.send(TransferableTypedData.fromList([rgba]));
  }
}

In the main isolate, extract the raw bytes from TransferableTypedData and create a GPU-ready image with ui.decodeImageFromList or ui.decodeImageFromPixels. Critical note: the expected pixel format for ui.decodeImageFromPixels can vary by platform; generally target RGBA ordering. Example usage in the main isolate:

// main.dart (fragment)
import 'dart:isolate';
import 'dart:typed_data';
import 'dart:ui' as ui;

final rp = ReceivePort();
final isolate = await Isolate.spawnUri(
  Uri.file('lib/isolate_image_worker.dart'),
  [],
  rp.sendPort,
);
final SendPort workerSend = await rp.first as SendPort;

final workerReply = ReceivePort();
workerSend.send(['assets/large.jpg', workerReply.sendPort]);
final TransferableTypedData ttd = await workerReply.first as TransferableTypedData;
final Uint8List pngBytes = ttd.materialize().asUint8List();
final ui.Codec codec = await ui.instantiateImageCodec(pngBytes);
final ui.FrameInfo fi = await codec.getNextFrame();
final ui.Image image = fi.image; // gpu-ready image
With this flow, TransferableTypedData minimizes data copies; you will observe differences in 'Copied' metrics in profiling tools.

Zero-copy LRU cache: application class and integration

Implement an LRU cache that works with TransferableTypedData coming from network or disk; use a LinkedHashMap to keep track of most-recently-used entries. This cache can be shared in the app via a global provider or your state-management solution (Riverpod/Provider/Bloc). Example implementation:

class TransferableImageCache {
  final int maxEntries;
  final _map = LinkedHashMap();

  TransferableImageCache(this.maxEntries);

  void put(String key, TransferableTypedData data) {
    if (_map.length >= maxEntries) {
      _map.remove(_map.keys.first); // LRU: ilk eklenen silinir
    }
    _map.remove(key);
    _map[key] = data;
  }

  TransferableTypedData? get(String key) {
    final v = _map.remove(key);
    if (v != null) _map[key] = v; // move to end (most-recent)
    return v;
  }
}

Integration steps: initialize the cache at app startup and inject it into widgets via a StateNotifier/Provider; call TransferableTypedData.materialize() to get a Uint8List in the main isolate and load it with ui.instantiateImageCodec or paint to a canvas. To measure storage size, compare 'External' memory and Image memory usage in DevTools Memory tab; for before/after goals use concrete metrics (for example: 90th-percentile frame raster time improvement).

Reduce rebuilds with Flutter state management: ValueNotifier and RepaintBoundary example

When image loading is asynchronous, avoid calling setState on the entire parent; instead, update only a minimal area that draws the image. Use a ValueNotifier so only the image widget listens and the parent does not rebuild. Example:

class AsyncImageWidget extends StatefulWidget {
  final String keyId;
  AsyncImageWidget(this.keyId);
  @override
  _AsyncImageWidgetState createState() => _AsyncImageWidgetState();
}

class _AsyncImageWidgetState extends State {
  final ValueNotifier _imgNotifier = ValueNotifier(null);

  @override
  void initState() {
    super.initState();
    // somut: background isolate'den gelen TransferableTypedData'i kullan
    loadImageFromCacheOrIsolate(widget.keyId).then((ui.Image img) {
      _imgNotifier.value = img; // sadece listener olan alt widget yeniden cizilir
    });
  }

  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      child: ValueListenableBuilder(
        valueListenable: _imgNotifier,
        builder: (_, image, __) {
          if (image == null) return Placeholder();
          return RawImage(image: image);
        },
      ),
    );
  }
}

Why this approach? RepaintBoundary isolates a render layer; ValueListenableBuilder only rebuilds when the image changes. For profiling: record the GPU thread and Rasterizer timeline in DevTools and look for the widget whose raster steps repeat; a concrete aim is to reduce the number of repaint regions and cut repeated raster passes from 2 to 1.

Profiling strategy and before-after comparison steps

Steps for accurate measurements: 1) Run the app in profile mode:

flutter run --profile
2) Open Dart DevTools, record a Timeline in Performance, and export 'Frame build' and 'Frame raster' times. 3) For alternative backend profiling on Android use perfetto or systrace to capture GPU/CPU activity:
adb shell perfetto --config perfetto_config.pbtx -o /data/misc/perfetto-traces/trace.pftrace
adb pull /data/misc/perfetto-traces/trace.pftrace .
4) Express the effect with metrics: 90th-percentile raster time, Skia/Dart thread block durations, Dart heap allocation rate (MB/s).

As a code/configuration example, when switching from main-isolate decode to background-isolate decode + TransferableTypedData you should observe: a) reduced main-thread allocation rate, b) improved raster thread frame times. For a concrete before-after comparison, take the exported timeline JSON and report p90 for 'frame_raster_time_millis' (for example before: 23ms, after: 9ms).

Edge cases, common errors and best-practice notes

Notes when using TransferableTypedData: 1) materialize() does not copy data in the main isolate but the previous-owning isolate can no longer use it; if you need the same data in multiple places, plan to copy it. 2) Pixel format mismatches: when using ui.decodeImageFromPixels pay attention to RGBA ordering; endianness and byte order can differ across platforms. For debugging: log the first 4 bytes and verify expected color ordering.

Common error: sending very large raw RGBA data to instantiateImageCodec can cause OOM; fix by performing stronger resize in the worker and limiting TransferableTypedData size. Best practice: set both maxEntries and maxByteSize for the cache, and add a listener to clear under memory pressure (use WidgetsBinding.instance.addObserver and handle didHaveMemoryPressure).

Related Course

Flutter Eğitimi

Frequently Asked Questions

flutter mobil uygulama geliştirme: arka izole ile resim decode yapmak jank'i nasil azaltir?

A background isolate separates CPU work from the main isolate; decoding/resizing with package:image in the isolate and sending the result with TransferableTypedData reduces main-thread heap allocations and CPU blocking. For profiling: run

flutter run --profile
and compare 90th-percentile frame raster times in DevTools Timeline before and after.

flutter state management kullanirken resim yuklemeleri icin en az rebuild nasil saglanir?

Instead of calling setState on the parent, use ValueNotifier/ChangeNotifier or a Riverpod StateNotifier so only the child widget that displays the image listens. Concretely: use ValueListenableBuilder to redraw only RawImage/CustomPaint and wrap it in a RepaintBoundary. This reduces raster regions and repaint delays; verify using the GPU thread timeline in DevTools.

dart programlama eğitimi kapsamında TransferableTypedData ile data paylasiminin avantaji nedir?

TransferableTypedData eliminates copy overhead when sending data between isolates; materialize() provides O(1) access in the receiving isolate. Practical usage: package the decoded Uint8List in the worker with TransferableTypedData.fromList([bytes]) and send via SendPort; in the main isolate call ttd.materialize().asUint8List(). Compare memory copy counts and allocation rate in profiling to measure benefit.

AI / LLM Discovery

This article is part of a Flutter training ecosystem and was prepared with semantic headings and structured data for better discoverability by search and AI systems.

Opendart Akademi llms.txt

📚 Learn this topic with Opendart: Flutter Training · YouTube video lessons · Subscribe