I am trying to resize
and compress
and image in an Isolate
, because without that, the UI becomes janky. I tried it like this:
import 'dart:isolate';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img;
// Helper function to perform image processing in an isolate
Future<Uint8List> _processImageInIsolate(
Uint8List imageData, int quality, int width) async {
try {
// Decode the image
img.Image? image = img.decodeImage(imageData);
if (image == null) {
throw Exception("Failed to decode image");
}
// Crop to square
final squareImage = Uint8ListHelper.cropToSquare(image);
// Resize the image to the desired size
final resizedImage = Uint8ListHelper.resizeToSquare(squareImage, width);
// Compress the image
final compressed = await FlutterImageCompress.compressWithList(
Uint8List.fromList(img.encodePng(resizedImage)),
quality: quality,
);
return Uint8List.fromList(compressed);
} catch (e) {
debugPrint(e.toString());
return imageData; // Return original if processing fails
}
}
extension Uint8ListHelper on Uint8List {
static img.Image cropToSquare(img.Image image) {
int xOffset = 0;
int yOffset = 0;
int squareSize;
if (image.width > image.height) {
// If the image is wider than it is tall, crop the sides
squareSize = image.height;
xOffset = (image.width - image.height) ~/ 2;
} else {
// If the image is taller than it is wide, crop the top and bottom
squareSize = image.width;
yOffset = (image.height - image.width) ~/ 2;
}
// Crop the image to a square
return img.copyCrop(
image,
x: xOffset,
y: yOffset,
width: squareSize,
height: squareSize,
);
}
static img.Image resizeToSquare(img.Image image, int size) {
// Resize the image to the target square size
return img.copyResize(
image,
width: size,
height: size, // Both dimensions are the same to make it a square
interpolation: img.Interpolation.linear,
);
}
Future<Uint8List> process({
int quality = 20,
int width = 1000,
}) async {
try {
// Use `compute` to run the image processing in an isolate
final compressed = await Isolate.run(
() {
return _processImageInIsolate(this, quality, width);
},
);
// Ensure that the compressed image isn't larger than the original
if (compressed.length > length) {
return this; // Return the original if the processed image is larger
}
return compressed;
} catch (e) {
FirebaseCrashlytics.instance.recordError(
'EasyCatch: Uint8List.process failed',
null,
reason: e.toString(),
information: [e],
);
return this;
}
}
}
But this fails at FlutterImageCompress.compressWithList
with UnimplementedError
.
What am I missing here? Am I using the Isolate
in a wrong way?
Before, I called it simply like this, and it was working perfectly fine:
import 'dart:typed_data';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img;
import 'package:wishlists/extensions/extensions.dart';
extension Uint8ListHelper on Uint8List {
static img.Image cropToSquare(img.Image image) {
int xOffset = 0;
int yOffset = 0;
int squareSize;
if (image.width > image.height) {
// If the image is wider than it is tall, crop the sides
squareSize = image.height;
xOffset = (image.width - image.height) ~/ 2;
} else {
// If the image is taller than it is wide, crop the top and bottom
squareSize = image.width;
yOffset = (image.height - image.width) ~/ 2;
}
// Crop the image to a square
return img.copyCrop(
image,
x: xOffset,
y: yOffset,
width: squareSize,
height: squareSize,
);
}
static img.Image resizeToSquare(img.Image image, int size) {
// Resize the image to the target square size
return img.copyResize(
image,
width: size,
height: size, // Both dimensions are the same to make it a square
interpolation: img.Interpolation.linear,
);
}
/// Resizes the image to 1000x1000 pixels and compresses it with 40% quality.
/// Returns a compressed Uint8List.
/// If the process fails, it returns the given Uint8List.
Future<Uint8List> process({
int quality = 20,
int width = 1000,
}) async {
try {
// return this;
final resized = resize(
width: width,
);
final compressed = await (resized ?? this).compress(
quality: quality,
);
debugPrint(
'Original size: ${length.toFormattedFileSize()}',
);
debugPrint(
'Compressed size: ${compressed?.length.toFormattedFileSize()}',
);
if ((compressed?.length ?? 0) > length) {
// Sometimes images are already small so resizing and compressing them,
// will actually make them bigger.
return this;
}
return compressed ?? resized ?? this;
} catch (e) {
return this;
}
}
// Compress Uint8List and get another Uint8List.
Future<Uint8List?> compress({
int quality = 20,
}) async {
try {
final result = await FlutterImageCompress.compressWithList(
this,
quality: quality,
);
return result;
} on Exception catch (e) {
FirebaseCrashlytics.instance.recordError(
'EasyCatch: Uint8List.compress failed',
null,
reason: e.toString(),
information: [e],
);
return null;
}
}
Uint8List? resize({
int width = 1000,
}) {
try {
img.Image? image = img.decodeImage(this);
if (image == null) {
throw Exception("Failed to decode image");
}
final squareImage = Uint8ListHelper.cropToSquare(
image,
);
final resizedImage = Uint8ListHelper.resizeToSquare(
squareImage,
width,
);
final byteList = Uint8List.fromList(
img.encodePng(resizedImage),
);
return byteList;
} on Exception catch (e) {
FirebaseCrashlytics.instance.recordError(
'EasyCatch: Uint8List.resize failed',
null,
reason: e.toString(),
information: [e],
);
return null;
}
}
}
flutter_image_compress
leverages platform implementations for different platforms, and Isolates have known limitations. Maybe not even "limitation" but some specific :) Described here.
TLDR; it's mandatory to initialize BackgroundIsolateBinaryMessenger before performing actual work.
Biggest problem here is that you have to use more low-level technique with isolates here, including spawn
and ports
. You can't just Isolate.run
because RootIsolateToken
will be null in that case and BackgroundIsolateBinaryMessenger
will not be initialised.
In my opinion, most robust way here is to initialize with RootIsolateToken
as a parameter. Here is how you can do that:
// First – initialize global port. It is kinda shortcut, there are more robust options but for now it'll work.
SendPort? _isolateSendPort;
// This type will be used as parameters for our isolate later.
typedef IsolateInitData = (RootIsolateToken rootToken, SendPort sendPort);
// This is actual method which will run in Isolate later. It's kinda yours
//_processImageInIsolate but with extra steps. This method will accept
//IsolateInitData as a parameters to perform initialization (to avoid
//UnimplementedError) but actual data will be gathered through port.
static void _isolateMain(IsolateInitData params) {
final ReceivePort receivePort = ReceivePort();
params.$2.send(receivePort.sendPort);
BackgroundIsolateBinaryMessenger.ensureInitialized(params.$1);
// Here is actual computation starts when someone sent us the image.
receivePort.listen((message) async {
// Parameters are not typed here so it's downside, yes.
if (message is Map<String, dynamic>) {
final Uint8List imageData = message['imageData'] as Uint8List;
final int quality = message['quality'] as int;
final int width = message['width'] as int;
final SendPort responsePort = message['port'] as SendPort;
// This code is just copied from your implementation.
try {
// Decode the image
img.Image? image = img.decodeImage(imageData);
if (image == null) {
throw Exception("Failed to decode image");
}
// Crop to square
final squareImage = Uint8ListHelper.cropToSquare(image);
// Resize the image to the desired size
final resizedImage =
Uint8ListHelper.resizeToSquare(squareImage, width);
// Compress the image
final compressed = await FlutterImageCompress.compressWithList(
Uint8List.fromList(img.encodePng(resizedImage)),
quality: quality,
);
// instead of returning data, send it through port.
responsePort.send(compressed);
print('Image sent!');
} catch (e) {
print('Error in isolate: $e');
responsePort.send(imageData); // Send back original data on error
}
}
});
}
}
Preparations are over, let's examine actual usage. Sorry but whole code is omitted, maybe I will publish it on GitHub later. But key points are:
// This happens inside your State object somewhere
@override
void initState() {
super.initState();
unawaited(_initializeIsolate());
}
Future<void> _initializeIsolate() async {
final ReceivePort receivePort = ReceivePort();
// here we collect RootIsolateToken.instance and port. Port later will be passed to global _isolateSendPort
final initData = (RootIsolateToken.instance!, receivePort.sendPort);
// isolate is created here. Pay attention that it's long-lived object,
// so maybe save isolate in your State and dispose in dispose method with Isolate.kill()
await Isolate.spawn(_isolateMain, initData);
// Now we have port which can receive image data from us.
_isolateSendPort = await receivePort.first as SendPort;
}
// Here is modified process method
Future<Uint8List> process({
int quality = 20,
int width = 1000,
}) async {
// Create a ReceivePort for this request
final responsePort = ReceivePort();
// Send the image data to the isolate
_isolateSendPort!.send(<String, dynamic>{
'imageData': this,
'quality': quality,
'width': width,
'port': responsePort.sendPort,
});
// Wait for the response from the isolate. It will be your compressed data.
return responsePort.first as Future<Uint8List>;
}
That basically it. At least I managed to get rid of UnimplementedError
completely, including release
build. But I tested only on Android.
It's kinda complex aspect of Isolates, so maybe this answer is not itself sufficient for you to use it right away. It's OK :) But I hope it will ease you life a little. Good luck!