🛣️

【Flutter】FlutterWeb で Worker を使って並列処理を行う

2024/12/03に公開

初めに

今回は Flutter Web で Worker を用いて並列処理を実装を行いたいと思います。
Flutter には Isolate や compute 関数などの並列処理を実行するための仕組みが用意されています。しかし、以下のIssueで議論されている通り、Flutter Web では Isolate を用いた並列処理が実行されないようです。したがって、今回は Worker という仕組みを使って並列処理を実装してみたいと思います。

https://github.com/flutter/flutter/issues/111870#issuecomment-1250980545

https://github.com/flutter/flutter/issues/33577

記事の対象者

  • Flutter 学習者
  • Flutter Web で重たい処理を実装したい方

目的

今回の目的は上記の通り、Flutter Web で Worker を用いた並列処理を行うことです。
特に画像圧縮の処理を例にとって実装していきます。
画像圧縮が並列処理でできれば、ユーザーの手を止めることなくアップロードされた画像を扱うことができるようになります。また、Firebase や AWS に画像をアップロードする場合はそのデータ量を抑えることができるようになります。

Worker とは

Web Worker に関して、Web Workers APIのドキュメントが参考になるので一部抜粋します。

ウェブワーカー (Web Worker) とは、ウェブアプリケーションにおけるスクリプトの処理をメインとは別のスレッドに移し、バックグラウンドでの実行を可能にする仕組みのことです。時間のかかる処理を別のスレッドに移すことが出来るため、 UI を担当するメインスレッドの処理を中断・遅延させずに実行できるという利点があります。
ワーカーとメインスレッドとの間では、メッセージのシステムを通してデータがやり取りされます。両者は postMessage() メソッドを使ってメッセージを送信したり、受け取ったメッセージには onmessage イベントハンドラーで返信したりします(メッセージはメッセージイベントの data 属性に格納されます)。なお、データは共有されるのではなく複製されます。

まとめると以下のようなことが言えそうです。

  • Web Worker は処理をメインスレッドとは別のスレッドに移してバックグラウンドで実行する仕組み
  • UI を担当するメインスレッドの処理を中断・遅延させずに実行できる
  • Worker とメインスレッドはメッセージを使ってデータのやり取りを行う

今回は「画像の圧縮を並列処理で行い、ユーザーの操作を中断しないようにしたい」という要件であり、その要件に合致しているかと思います。

ちなみに、Isolate at Flutter for Webという記事に以下のような記述があります。

dart1 support dart:isolate, but dart2 not suport isoate for web now.
dart1 used worker to impolement isolate.
Let's use Worker, following Dart1 series dart:isolate.

ここからわかる通り、Dart1 では Flutter Web でも Isolate の仕組みがサポートされており、その時に使用されていたのが今回使用する Worker です。
Dart2 からはサポートされなくなり、自分で実装する必要があるようなので、今回記事にまとめています。

導入

以下のパッケージの最新バージョンを pubspec.yamlに記述します。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter

  flutter_riverpod: ^2.5.1
  riverpod_annotation: ^2.3.5
  hooks_riverpod: ^2.4.5
  flutter_hooks: ^0.20.5
  build_runner: ^2.4.13

  image: ^4.3.0
  image_picker: ^1.1.2
  image_picker_web: ^4.0.0
  photo_view: ^0.15.0

dev_dependencies:
  flutter_test:
    sdk: flutter

  riverpod_generator: ^2.3.3
  json_serializable: ^6.6.1

または

以下をターミナルで実行

flutter pub add flutter_riverpod riverpod_annotation hooks_riverpod flutter_hooks build_runner image image_picker image_picker_web photo_view
flutter pub add -d riverpod_generator json_serializable

実装

次に画像の圧縮を Worker で行う実装を行います。
実装は以下の手順で進めていきます。

  1. 圧縮処理の実装
  2. Web 側の実装
  3. 圧縮処理の呼び出し実装
  4. Provider の実装
  5. UIの実装

最終的には以下の動画のように、ユーザーが選択した画像を圧縮して表示できるようにしたいと思います。

https://youtu.be/wqM1arkeUtM

1. 圧縮処理の実装

まずは画像圧縮の処理を実装していきます。
lib/worker/service のディレクトリに image_compress_worker.dart ファイルを作成します。このパス自体は特に指定はなく、どこにおいても問題ありません。

コードは以下の通りです。

lib/worker/service/image_compress_worker.dart
// ignore: avoid_web_libraries_in_flutter
import 'dart:html';
import 'dart:typed_data';
import 'package:image/image.dart' as img;

DedicatedWorkerGlobalScope get self => DedicatedWorkerGlobalScope.instance;

void main() {
  self.postMessage({'log': 'Worker started'});

  self.onMessage.listen((event) {
    try {
      final data = event.data;
      if (data['action'] == 'compress') {
        final buffer = data['imageData'] as ByteBuffer;
        final imageData = Uint8List.view(buffer);
        final compressedImage = compressImage(imageData);
        self.postMessage(
          {'compressedImage': compressedImage},
          [compressedImage.buffer],
        );
      } else {
        throw Exception('Unknown action: ${data['action']}');
      }
    } on Exception catch (e, stackTrace) {
      self.postMessage({
        'error': 'Error in worker: $e',
        'stackTrace': stackTrace.toString(),
      });
    }
  });
}

Uint8List compressImage(Uint8List input) {
  final image = img.decodeImage(input);
  if (image == null) {
    throw Exception('Failed to decode image');
  }
  final resized = img.copyResize(image, width: 800);
  final compressed = img.encodeJpg(resized, quality: 85);
  return Uint8List.fromList(compressed);
}

それぞれ詳しくみていきます。

以下では DedicatedWorkerGlobalScope のインスタンスを self として参照できるようにしています。
DedicatedWorkerGlobalScope に関しては Web Workers API > ワーカーの種類 の記事に以下の記述があります。

専用ワーカー (dedicated worker) は、単一のスクリプトで利用されるワーカーです。このコンテキストは DedicatedWorkerGlobalScope オブジェクトで表現されます。

今回は画像の圧縮というタスクのみを行う単一のスクリプトを使用するため、 DedicatedWorkerGlobalScope を用いています。これで、 self に対してメッセージを送信することで Worker を用いて別スレッドで処理を実行することができるようになります。

DedicatedWorkerGlobalScope get self => DedicatedWorkerGlobalScope.instance;

以下では main 関数の中で DedicatedWorkerGlobalScope に対して postMessage で Worker の処理が始まったことを通知しています。 このようにメインスレッドと Worker の別スレッドはメッセージで通信を行います。
また、 self.onMessage.listen ではメインスレッドから Worker のスレッドにメッセージが送られてきていないか監視しています。どのようなメッセージを監視しているかは次で見ていきます。

void main() {
  self.postMessage({'log': 'Worker started'});

  self.onMessage.listen((event) {

以下が self.onMessage.listen の内容になります。
メインスレッドから送られてきたメッセージの中で、 actioncompress だった場合にデータの圧縮処理を行なっています。メインスレッドから送られてきた imageDatacompressImage メソッド(後述します)の引数に入れて圧縮しています。
そして、圧縮した画像データを postMessage メソッドでメインスレッドの方に返却しています。

これらの一連の処理でエラーが出た場合は Exception を投げたり、メインスレッドの方にエラーを通知したりしています。

try {
  final data = event.data;
  if (data['action'] == 'compress') {
    final buffer = data['imageData'] as ByteBuffer;
    final imageData = Uint8List.view(buffer);
    final compressedImage = compressImage(imageData);
    self.postMessage(
      {'compressedImage': compressedImage},
      [compressedImage.buffer],
    );
  } else {
    throw Exception('Unknown action: ${data['action']}');
  }
} on Exception catch (e, stackTrace) {
  self.postMessage({
    'error': 'Error in worker: $e',
    'stackTrace': stackTrace.toString(),
  });
}

以下が画像の圧縮処理を行う compressImage メソッドになります。
image パッケージに含まれている以下のメソッドで画像の圧縮を行なっています。画像のクオリティを少し落として返却するようにしています。

  • decodeImage: Uint8List 型のデータを Image 型のデータに変換
  • copyResize: 画像のサイズを調整
  • encodeJpg: Image 型のデータをJPEG形式に変換して、クオリティを調整
Uint8List compressImage(Uint8List input) {
  final image = img.decodeImage(input);
  if (image == null) {
    throw Exception('Failed to decode image');
  }
  final resized = img.copyResize(image, width: 800);
  final compressed = img.encodeJpg(resized, quality: 85);
  return Uint8List.fromList(compressed);
}

これで画像の圧縮処理の実装は完了です。
なお、以下のコードのようにそれぞれ log を追加して、画像がどの程度圧縮されたかや正常に Worker が動作しているかを見ておくと問題が起きた際にわかりやすいかと思います。

log を追加したコード
lib/worker/service/image_compress_worker.dart
// ignore: avoid_web_libraries_in_flutter
import 'dart:html';
import 'dart:typed_data';
import 'package:image/image.dart' as img;

DedicatedWorkerGlobalScope get self => DedicatedWorkerGlobalScope.instance;

void main() {
  self.postMessage({'log': 'Worker started'});

  self.onMessage.listen((event) {
    try {
      self.postMessage({'log': 'Received message in worker'});
      final data = event.data;
      if (data['action'] == 'compress') {
        self.postMessage({'log': 'Compress action received'});
        final buffer = data['imageData'] as ByteBuffer;
        final imageData = Uint8List.view(buffer);
        self.postMessage(
          {'log': 'Image data received, length: ${imageData.length}'},
        );
        final compressedImage = compressImage(imageData);
        self
          ..postMessage(
            {'log': 'Image compressed, new length: ${compressedImage.length}'},
          )
          ..postMessage(
            {'compressedImage': compressedImage},
            [compressedImage.buffer],
          );
      } else {
        throw Exception('Unknown action: ${data['action']}');
      }
    } on Exception catch (e, stackTrace) {
      self.postMessage({
        'error': 'Error in worker: $e',
        'stackTrace': stackTrace.toString(),
      });
    }
  });
}

Uint8List compressImage(Uint8List input) {
  self.postMessage({'log': 'Starting image compression'});
  final image = img.decodeImage(input);
  if (image == null) {
    throw Exception('Failed to decode image');
  }
  self.postMessage({'log': 'Image decoded successfully'});
  final resized = img.copyResize(image, width: 800);
  self.postMessage({'log': 'Image resized'});
  final compressed = img.encodeJpg(resized, quality: 85);
  self.postMessage({'log': 'Image encoded as JPG'});
  return Uint8List.fromList(compressed);
}

2. Web 側の実装

次に Web 側の実装に移ります。
先程作成した image_compress_worker.dart を Web でそのまま使用することはできません。したがって、Dart のコードを JavaScript にコンパイルしておく必要があります。
コンパイルには以下のコマンドを使用します。

dart compile js {コンパイルしたいDartファイルのパス} -o {コンパイルされたJavaScriptファイルの出力先パス}

自分の手元では、lib/worker/service/image_compress_worker.dart の Dart ファイルをコンパイルして、 web/worker/image_compress_worker.dart.js に出力したいので、以下のようなコマンドになります。

dart compile js lib/worker/service/image_compress_worker.dart -o web/worker/image_compress_worker.dart.js

このコマンドを実行すると以下の三つのファイルが作成されるかと思います。

  • image_compress_worker.dart.js
  • image_compress_worker.dart.js.deps
  • image_compress_worker.dart.js.map

これでコンパイルは完了です。

次に Web 側で生成したファイルを読み込む必要があります。
web ディレクトリの index.htmlhead 部分に生成したファイルのパスを追加しておきます。script タグで追加することでコンパイルされたファイルが Web 側で参照できるようになります。

web/index.html
<!DOCTYPE html>
<html>
<head>

<!-- 画像圧縮 -->
<script src="worker/image_compress_worker.dart.js" type="application/javascript"></script>
</head>

これで Web 側の実装は完了です。

3. 圧縮処理の呼び出し実装

1、2章では画像圧縮処理の実装とその処理を Web で参照できるような実装を行いました。今までの実装で、圧縮処理が呼び出された際に Worker を用いて圧縮処理を行い、圧縮された画像を返却するまでは実装ができています。

この章では圧縮処理を呼び出せるような実装を行います。
lib/worker/service ディレクトリに /image_compress_service.dart を追加します。
コードは以下のように変更しておきます。

lib/worker/service/image_compress_service.dart
// image_compress_service.dart
import 'dart:async';
// ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html;
import 'package:flutter/foundation.dart';

class ImageCompressionWorker {
  ImageCompressionWorker() {
    try {
      _worker = html.Worker('web/worker/image_compress_worker.dart.js');
      debugPrint('Web Worker created successfully');
    } on Exception catch (e) {
      debugPrint('Failed to create Web Worker: $e');
      rethrow;
    }

    _worker.onError.listen((event) {
      if (event is html.ErrorEvent) {
        debugPrint('Worker error: ${event.message}');
        if (event.error != null) {
          debugPrint('Error details: ${event.error}');
        }
        if (event.filename != null) {
          debugPrint(
            'Error in file: ${event.filename}, line: ${event.lineno}, column: ${event.colno}',
          );
        }
      } else {
        debugPrint('Unknown worker error occurred: $event');
      }
    });
  }
  late html.Worker _worker;

  Future<Uint8List> compressImage(Uint8List imageData) {
    final completer = Completer<Uint8List>();

    void onMessage(html.Event event) {
      if (event is html.MessageEvent) {
        if (event.data['log'] != null) {
          debugPrint('Worker log: ${event.data['log']}');
        } else if (event.data['error'] != null) {
          debugPrint('Worker error: ${event.data['error']}');
          debugPrint('Stack trace: ${event.data['stackTrace']}');
          completer.completeError(Exception(event.data['error']));
        } else if (event.data['compressedImage'] != null) {
          final result =
              (event.data['compressedImage'] as List<dynamic>).cast<int>();
          completer.complete(Uint8List.fromList(result));
        } else {
          completer
              .completeError(Exception('Invalid message format from worker'));
        }
      }
    }

    _worker.addEventListener('message', onMessage);

    try {
      _worker.postMessage(
        {
          'action': 'compress',
          'imageData': imageData.buffer,
        },
        [imageData.buffer],
      );
    } on Exception catch (e) {
      debugPrint('Error posting message to worker: $e');
      completer.completeError(e);
    }

    Future.delayed(
      const Duration(seconds: 30),
      () {
        if (!completer.isCompleted) {
          completer.completeError(
            TimeoutException('Worker did not respond in time'),
          );
        }
      },
    );

    return completer.future;
  }
}

それぞれ詳しく見ていきます。

以下では ImageCompressionWorker を定義しています。
html.Worker の引数に先程生成した Web 側の JavaScript ファイルのパスを渡します。
これで _worker に Web Worker が代入されます。
Web Worker の生成に失敗した場合はエラーが出力されます。ここで Web Worker の生成が失敗する場合は指定しているファイルのパスが誤っている場合が多いです。

class ImageCompressionWorker {
  ImageCompressionWorker() {
    try {
      _worker = html.Worker('web/worker/image_compress_worker.dart.js');
      debugPrint('Web Worker created successfully');
    } on Exception catch (e) {
      debugPrint('Failed to create Web Worker: $e');
      rethrow;
    }

以下では Web Worker でエラーが起きた際にそれを監視できるようにしています。
エラーが起こった場合は debugPrint でコンソールにエラーが表示されます。

_worker.onError.listen((event) {
  if (event is html.ErrorEvent) {
    debugPrint('Worker error: ${event.message}');
    if (event.error != null) {
      debugPrint('Error details: ${event.error}');
    }
    if (event.filename != null) {
      debugPrint(
        'Error in file: ${event.filename}, line: ${event.lineno}, column: ${event.colno}',
      );
    }
  } else {
    debugPrint('Unknown worker error occurred: $event');
  }
});

以下では、画像圧縮を行う compressImage メソッドのうち、 Woker 側からのメッセージを監視する部分の実装を行なっています。 compressImage というメソッドの名前にしていますが、実際には画像の圧縮を Worker に依頼するだけでなく、 Worker からのメッセージにも対応できるようにしています。

送られてきたメッセージの種類に応じて、以下のように実行する処理が異なります。

  • log : Worker からのログのメッセージ。debugPrint でコンソールに出力
  • error : Worker からのエラーメッセージ。コンソールに出力し、completer にエラーを渡す
  • compressedImage : Worker で圧縮された画像データ。completer に渡す
Future<Uint8List> compressImage(Uint8List imageData) {
  final completer = Completer<Uint8List>();

  void onMessage(html.Event event) {
    if (event is html.MessageEvent) {
      if (event.data['log'] != null) {    // log
        debugPrint('Worker log: ${event.data['log']}');
      } else if (event.data['error'] != null) {    // error
        debugPrint('Worker error: ${event.data['error']}');
        debugPrint('Stack trace: ${event.data['stackTrace']}');
        completer.completeError(Exception(event.data['error']));
      } else if (event.data['compressedImage'] != null) {    // compressedImage
        final result =
            (event.data['compressedImage'] as List<dynamic>).cast<int>();
        completer.complete(Uint8List.fromList(result));
      } else {
        completer
            .completeError(Exception('Invalid message format from worker'));
      }
    }
  }

  _worker.addEventListener('message', onMessage);

以下では Worker に対して postMessage を実行して、画像の圧縮処理を依頼しています。
先程の Worker の実装で、 actioncompress の場合には画像の処理を実行するように設定しました。action と一緒に圧縮処理を行いたい画像のデータを渡しています。
これで Worker に対して画像の圧縮処理を依頼するメッセージが送信できます。
なお、メッセージの送信に失敗した場合はエラーを返すようにしています。

try {
  _worker.postMessage(
    {
      'action': 'compress',
      'imageData': imageData.buffer,
    },
    [imageData.buffer],
  );
} on Exception catch (e) {
  debugPrint('Error posting message to worker: $e');
  completer.completeError(e);
}

以下では compressImage 関数が実行されてから30秒経っても処理が完了していない場合はタイムアウトとしてエラーが返されるように設定しています。タイムアウトの秒数やエラーの扱いはそれぞれ調整する必要があるかと思います。

Future.delayed(
  const Duration(seconds: 30),
  () {
    if (!completer.isCompleted) {
      completer.completeError(
        TimeoutException('Worker did not respond in time'),
      );
    }
  },
);

処理の流れとしては以下のようになります。

  1. ImageCompressionWorkercompressImage が呼び出される
  2. Worker 側からのメッセージの監視が開始される
  3. 画像圧縮処理を Worker 側にメッセージで依頼する
  4. 1、2章で実装した Web 側の Worker が圧縮処理を実行、返却
  5. Worker 側のメッセージで圧縮された画像が届く or 届かない
  6. 画像が届いた場合はそれを返り値として渡す
  7. 画像が届かず30秒経過するとタイムアウトでエラーを返す

これで圧縮処理の呼び出し実装は完了です。

4. Provider の実装

次に画像圧縮の機能を呼び出しやすくするための Provider を実装していきます。
lib/worker/providers ディレクトリに image_compress_provider.dart ファイルを作成して以下のように変更します。

lib/worker/providers/image_compress_provider.dart
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sample_flutter/worker/service/image_compress_service.dart';

part 'image_compress_provider.g.dart';


Future<Uint8List> imageCompress(
  Ref ref,
  Uint8List file,
) async {
  try {
    final worker = ImageCompressionWorker();
    return await worker.compressImage(file);
  } on Exception catch (e) {
    debugPrint('Image compression error: $e');
    rethrow;
  }
}

FutureProvider として imageCompressProvider を定義してます。
引数に Uint8List 型の file を受け取っています。


Future<Uint8List> imageCompress(
  Ref ref,
  Uint8List file,
) 

以下で先程定義した ImageCompressionWorkercompressImage メソッドを実行してその返り値を返却しています。

final worker = ImageCompressionWorker();
return await worker.compressImage(file);

以下のコマンドを実行して、ファイルが生成されれば Provider の実装は完了です。

flutter pub run build_runner build --delete-conflicting-outputs

5. UIの実装

最後にUIの実装を行います。
今回はユーザーが選択した画像を圧縮して表示させるまでの実装を行います。

lib/worker/screens ディレクトリに image_picker_sample_screen.dart ファイルを作成して内容を以下のように変更します。長いコードですが、以下で細かく分けてみていきます。

lib/worker/screens/image_picker_sample_screen.dart
import 'dart:typed_data';
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker_web/image_picker_web.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:sample_flutter/worker/providers/image_compress_provider.dart';

class ImagePickerSampleScreen extends HookConsumerWidget {
  const ImagePickerSampleScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final compressedImageFileList = useState<List<Uint8List>>([]);
    final isLoading = useState(false);
    final pageController = usePageController();
    final currentPage = useState(0);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Image Picker Sample'),
      ),
      body: Center(
        child: Column(
          children: [
            if (compressedImageFileList.value.isNotEmpty)
              Expanded(
                child: Stack(
                  children: [
                    PhotoViewGallery.builder(
                      key: ValueKey(compressedImageFileList.value.length),
                      pageController: pageController,
                      itemCount: compressedImageFileList.value.length,
                      builder: (context, index) {
                        return PhotoViewGalleryPageOptions(
                          imageProvider: MemoryImage(
                            compressedImageFileList.value[index],
                          ),
                        );
                      },
                      scrollPhysics: const BouncingScrollPhysics(),
                      backgroundDecoration: BoxDecoration(
                        color: Theme.of(context).canvasColor,
                      ),
                      enableRotation: true,
                      onPageChanged: (index) {
                        currentPage.value = index;
                      },
                      loadingBuilder: (context, event) {
                        return const Center(
                          child: CircularProgressIndicator(),
                        );
                      },
                    ),
                    if (currentPage.value > 0)
                      Positioned(
                        left: 10,
                        top: 0,
                        bottom: 0,
                        child: Center(
                          child: IconButton(
                            icon: const Icon(
                              Icons.arrow_back_ios,
                              color: Colors.white,
                              size: 30,
                            ),
                            onPressed: currentPage.value > 0
                                ? () {
                                    pageController.previousPage(
                                      duration:
                                          const Duration(milliseconds: 300),
                                      curve: Curves.easeInOut,
                                    );
                                  }
                                : null,
                          ),
                        ),
                      ),
                    if (currentPage.value <
                        compressedImageFileList.value.length - 1)
                      Positioned(
                        right: 10,
                        top: 0,
                        bottom: 0,
                        child: Center(
                          child: IconButton(
                            icon: const Icon(
                              Icons.arrow_forward_ios,
                              color: Colors.white,
                              size: 30,
                            ),
                            onPressed: currentPage.value <
                                    compressedImageFileList.value.length - 1
                                ? () {
                                    pageController.nextPage(
                                      duration:
                                          const Duration(milliseconds: 300),
                                      curve: Curves.easeInOut,
                                    );
                                  }
                                : null,
                          ),
                        ),
                      ),
                    if (compressedImageFileList.value.length > 1)
                      Positioned(
                        bottom: 20,
                        left: 0,
                        right: 0,
                        child: Center(
                          child: Container(
                            padding: const EdgeInsets.symmetric(
                              vertical: 4,
                              horizontal: 8,
                            ),
                            decoration: BoxDecoration(
                              color: Colors.black54,
                              borderRadius: BorderRadius.circular(12),
                            ),
                            child: Text(
                              '${currentPage.value + 1} / ${compressedImageFileList.value.length}',
                              style: const TextStyle(
                                color: Colors.white,
                                fontSize: 16,
                              ),
                            ),
                          ),
                        ),
                      ),
                  ],
                ),
              ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () async {
                isLoading.value = true;
                final List<Uint8List>? pickedImageFileList =
                    await ImagePickerWeb.getMultiImagesAsBytes();
                if (pickedImageFileList == null) {
                  isLoading.value = false;
                  return;
                }

                final List<Future<Uint8List>> compressionFutures =
                    pickedImageFileList.map((imageFile) {
                  return ref.read(imageCompressProvider(imageFile).future);
                }).toList();

                try {
                  final List<Uint8List> compressedImages =
                      await Future.wait(compressionFutures);
                  compressedImageFileList.value = [
                    ...compressedImageFileList.value,
                    ...compressedImages
                  ];
                  if (compressedImages.isNotEmpty) {
                    currentPage.value =
                        compressedImageFileList.value.length - 1;
                    WidgetsBinding.instance.addPostFrameCallback((_) {
                      if (pageController.hasClients) {
                        pageController.jumpToPage(currentPage.value);
                      }
                    });
                  }
                } catch (e) {
                  debugPrint('Image compression error: $e');
                  if (!context.mounted) return;
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(
                      content: Text('画像の圧縮中にエラーが発生しました: $e'),
                    ),
                  );
                } finally {
                  isLoading.value = false;
                }
              },
              child: isLoading.value
                  ? const SizedBox(
                      width: 20,
                      height: 20,
                      child: CircularProgressIndicator(
                        color: Colors.white,
                        strokeWidth: 2,
                      ),
                    )
                  : const Text(
                      '画像選択',
                    ),
            ),
            const SizedBox(height: 40),
          ],
        ),
      ),
    );
  }
}

以下ではそれぞれのデータの状態を保持しています。

  • compressedImageFileList : 圧縮された画像のリストの状態。初期値はからのリスト
  • isLoading : ローディング中かどうかのフラグ
  • pageController : 圧縮された画像を表示する際のビューのページコントローラー
  • currentPage : 圧縮された画像を表示する際に、現在表示されている画像のページ番号
final compressedImageFileList = useState<List<Uint8List>>([]);
final isLoading = useState(false);
final pageController = usePageController();
final currentPage = useState(0);

以下では、圧縮された画像を PhotoViewGallery.builder で表示させています。
それぞれコメントに記載されている通りです。

PhotoViewGallery.builder(
  key: ValueKey(compressedImageFileList.value.length),
  pageController: pageController,  // usePageController で定義したコントローラー
  itemCount: compressedImageFileList.value.length,  // 圧縮された画像の数だけ表示
  builder: (context, index) {
    return PhotoViewGalleryPageOptions(
      imageProvider: MemoryImage(  // MemoryImage で Uint8List 型のデータから画像を表示
        compressedImageFileList.value[index],
      ),
    );
  },
  scrollPhysics: const BouncingScrollPhysics(),  // スクロールの挙動を調整
  backgroundDecoration: BoxDecoration(
    color: Theme.of(context).canvasColor,
  ),
  enableRotation: true,  // 画像を回転可能に変更
  onPageChanged: (index) {  // 表示されている画像が切り替わった際に currentPage の値を更新
    currentPage.value = index;
  },
  loadingBuilder: (context, event) {  // ローディング中には CircularProgressIndicator を表示
    return const Center(
      child: CircularProgressIndicator(),
    );
  },
),

以下では現在表示されている画像の番号が0より大きい場合、つまりユーザーから見て左側にも画像がある場合にその画像に遷移できるボタンを実装しています。ボタンを押すと一つ左側の画像に遷移するようにしています。

if (currentPage.value > 0)
  Positioned(
    left: 10,
    top: 0,
    bottom: 0,
    child: Center(
      child: IconButton(
        icon: const Icon(
          Icons.arrow_back_ios,
          color: Colors.white,
          size: 30,
        ),
        onPressed: currentPage.value > 0
            ? () {
                pageController.previousPage(
                  duration:
                      const Duration(milliseconds: 300),
                  curve: Curves.easeInOut,
                );
              }
            : null,
      ),
    ),
  ),

以下では、圧縮された画像が2枚以上ある場合に、どの画像を表示しているかを表す表示を作成しています。例えば3枚あるうちの1枚目であれば「1/3」のように表示されるようになります。

if (compressedImageFileList.value.length > 1)
  Positioned(
    bottom: 20,
    left: 0,
    right: 0,
    child: Center(
      child: Container(
        padding: const EdgeInsets.symmetric(
          vertical: 4,
          horizontal: 8,
        ),
        decoration: BoxDecoration(
          color: Colors.black54,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Text(
          '${currentPage.value + 1} / ${compressedImageFileList.value.length}',
          style: const TextStyle(
            color: Colors.white,
            fontSize: 16,
          ),
        ),
      ),
    ),
  ),

以下は画像のアップロード、圧縮処理です。 image_picker パッケージの ImagePickerWeb には複数枚の画像を Uint8List 型でギャラリーから取得できる getMultiImagesAsBytes が用意されています。それを使ってユーザーに画像をアップロードしてもらいます。

そして、アップロードされた画像を一枚ずつ imageCompressProvider に渡して圧縮していきます。圧縮された画像は compressionFutures として保持しておきます。

ElevatedButton(
  onPressed: () async {
    isLoading.value = true;
    final List<Uint8List>? pickedImageFileList =
        await ImagePickerWeb.getMultiImagesAsBytes();
    if (pickedImageFileList == null) {
      isLoading.value = false;
      return;
    }

    final List<Future<Uint8List>> compressionFutures =
        pickedImageFileList.map((imageFile) {
      return ref.read(imageCompressProvider(imageFile).future);
    }).toList();

以下では、先程保持していた compressionFutures の圧縮が終わるまで待って、その結果を compressedImageFileList に追加しています。これで圧縮された画像が追加されます。
また、画面上の表示では、最後に追加された画像にジャンプするようにしています。

try {
  final List<Uint8List> compressedImages =
      await Future.wait(compressionFutures);
  compressedImageFileList.value = [
    ...compressedImageFileList.value,
    ...compressedImages
  ];
  if (compressedImages.isNotEmpty) {
    currentPage.value =
        compressedImageFileList.value.length - 1;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (pageController.hasClients) {
        pageController.jumpToPage(currentPage.value);
      }
    });
  }
} 

以上のコードで実行すると以下の動画のようにアップロードした画像が表示されるかと思います。

https://youtu.be/wqM1arkeUtM

また、1. 圧縮処理の実装の最後で示した「log を追加したコード」に変更して、もう一度コンパイルしてから実行すると圧縮前後の画像のサイズがわかります。

筆者の手元で実行すると出力は以下のようになりました。
それぞれの画像が圧縮されてサイズが小さくなっていることがわかります。

Worker log: Image data received, length: 169391
Worker log: Image data received, length: 155713
Worker log: Image data received, length: 191308
Worker log: Image encoded as JPG
Worker log: Image compressed, new length: 68691
Worker log: Image encoded as JPG
Worker log: Image compressed, new length: 79502
Worker log: Image encoded as JPG
Worker log: Image compressed, new length: 59750

以上です。

まとめ

最後まで読んでいただいてありがとうございました。

記事の冒頭でも述べましたが、Flutter Web でも画像の圧縮が別のスレッドでできれば、ユーザーを待たせることなく圧縮ができ、かつストレージの使用量を抑えることができるため、メリットが大きいかと思います。

誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://chikuwa-pome.com/2024/07/flutter-web-worker-1/

https://dev.to/kyorohiro/isolate-at-flutter-for-web-28lg

https://developer.mozilla.org/ja/docs/Web/API/DedicatedWorkerGlobalScope

Discussion