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
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.



