I encountered a use case where the user needed to submit both front and back images of their ID card, and it was necessary to merge the images before uploading them to the server.
I found a function for merging images online, but the problem wasn't with how merging works.
In the code, a submit button shows a loading state using a CircularProgressIndicator.
But once the image merging function is called, the loading spinner stutters—or the app freezes temporarily.
This is because the merge function involves:
- Reading image files from storage
- Decoding the images
- Creating a new canvas
- Merging the images pixel by pixel
- Saving the final image
All of this happens on the main thread (UI thread), which prevents Flutter from redrawing the UI during those operations.
Legacy Code (No Isolate)
Future<File> mergeImagesVertically(File front, File back) async {
final img.Image image1 = img.decodeImage(await front.readAsBytes())!;
final img.Image image2 = img.decodeImage(await back.readAsBytes())!;
// Determine max width and combined height
final int finalWidth = max(image1.width, image2.width);
final int finalHeight = image1.height + image2.height;
// Create a new canvas with final dimensions
final img.Image canvas = img.copyExpandCanvas(
image1,
newWidth: finalWidth,
newHeight: finalHeight,
position: img.ExpandCanvasPosition.topLeft,
);
// Place second image below the first one
final img.Image finalImage = img.compositeImage(
canvas,
image2,
dstX: 0,
dstY: image1.height,
);
// Save the final image to local storage
final dir = await getApplicationDocumentsDirectory();
final path = join(dir.path, 'merged_${DateTime.now().millisecondsSinceEpoch}.jpg');
final file = File(path);
await file.writeAsBytes(img.encodeJpg(finalImage));
return file;
}
New Code (Using Isolate)
// A simple class to bundle parameters for the isolate function
class MergeImagesArgs {
final String frontPath;
final String backPath;
final String outputPath;
// Constructor
MergeImagesArgs(this.frontPath, this.backPath, this.outputPath);
}
// This function will run in a separate isolate using 'compute'
Future<String> _mergeImagesInIsolate(MergeImagesArgs args) async {
// Load images from file paths
final front = File(args.frontPath);
final back = File(args.backPath);
// Decode image bytes to image objects
final img.Image image1 = img.decodeImage(await front.readAsBytes())!;
final img.Image image2 = img.decodeImage(await back.readAsBytes())!;
// Determine final canvas size
final int finalWidth = max(image1.width, image2.width);
final int finalHeight = image1.height + image2.height;
// Create canvas with enough height to hold both images vertically
final img.Image canvas = img.copyExpandCanvas(
image1,
newWidth: finalWidth,
newHeight: finalHeight,
position: img.ExpandCanvasPosition.topLeft,
);
// Draw the second image below the first one on the canvas
final img.Image finalImage = img.compositeImage(
canvas,
image2,
dstX: 0,
dstY: image1.height,
);
// Save the result to the specified output path
final file = File(args.outputPath);
await file.writeAsBytes(img.encodeJpg(finalImage));
return file.path;
}
// This function is called from the main thread to perform image merge in the background
Future mergeImagesVerticallyInBackground(File frontImage, File backImage) async {
// Define the output path
final dir = await getApplicationDocumentsDirectory();
final outputPath = join(dir.path, 'merged_${DateTime.now().millisecondsSinceEpoch}.jpg');
// Run the merge function in a background isolate using 'compute'
final mergedPath = await compute(
_mergeImagesInIsolate,
MergeImagesArgs(frontImage.path, backImage.path, outputPath),
);
// Return the result as a File
return File(mergedPath);
}
Explanation of the Change
final mergedPath = await compute(
_mergeImagesInIsolate,
MergeImagesArgs(frontImage.path, backImage.path, outputPath),
);
- `compute` automatically spawns a new isolate (parallel thread).
- The `_mergeImagesInIsolate` function runs inside this isolate.
- It receives a `MergeImagesArgs` object containing:
- - front image path
- - back image path
- - output path for the merged image
- After processing, it returns the final merged image path stored in `mergedPath`.
Why did we use `compute`?