How to Merge Two Images Using Isolate in Flutter

In this tutorial, we will explore how to merge two images using Isolate in Flutter, and how this technique helps enhance performance and avoid UI freeze.

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:

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)

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),
);

Why did we use `compute`?