Flutterで60fpsを維持:Isolate.runとWorker Poolを用いた重いJSON/AI処理の別スレッド化

Flutterは基本的にシングルスレッド(Main Isolate)ベースで動作します。つまり、UIのレンダリング、イベント処理、そして私たちが記述したロジックまで、すべて1つのスレッドで実行されます。そのため、大容量のJSONをパースしたり、AIモデルの推論のような重い演算をメインスレッドで実行すると、画面がフリーズしたりカクつく「Jank(ジャンク)」現象が発生します。
このような問題を解決し、滑らかな60fps(または120fps)の環境を維持するには、重い処理を別のスレッドに分離する必要があります。Dartではこれを Isolate(アイソレート) と呼びます。この記事では、シンプルな Isolate.run から、繰り返し処理に効率的な Worker Pool の構築方法までを詳しく見ていきます。
単発の重い処理には Isolate.run
Dart 2.15 から Isolate.run が導入されたことで、別のスレッドを生成して結果を受け取るプロセスが非常に簡単になりました。ネットワーク通信後に受け取った大容量のJSONをオブジェクトのリストに変換する作業を例に挙げてみましょう。
import 'dart:convert';
import 'dart:isolate';
// 変換するデータモデル
class HeavyData {
final int id;
final String content;
HeavyData({required this.id, required this.content});
factory HeavyData.fromJson(Map<String, dynamic> json) {
return HeavyData(id: json['id'], content: json['content']);
}
}
// メインスレッドで実行される関数
Future<List<HeavyData>> fetchAndParseData(String jsonString) async {
// 別のIsolateでパース処理を実行
return await Isolate.run(() {
final List<dynamic> parsed = jsonDecode(jsonString);
return parsed.map((e) => HeavyData.fromJson(e)).toList();
});
}
上記の例では、Isolate.run ブロック内にあるコードは新しい Isolate で実行されます。演算が完了すると結果がメイン Isolate に安全に渡され、一時的に生成された Isolate は即座に終了します。複雑なポート(Port)の設定なしで、きれいにスレッドを分離することができます。
繰り返し処理には Worker Pool パターン
Isolate.run は毎回新しい Isolate を生成して終了するため、オーバーヘッドが発生する可能性があります。もし、オンデバイス(On-device)でのAI推論や画像フィルタリングのように、短くて重い処理が継続的に発生する場合は、Isolateを事前に生成しておいて再利用する Worker Pool パターンが有利です。
Worker Pool の実装
isolate パッケージのポートを使用して、メイン Isolate とバックグラウンド Isolate 間の通信チャネルを構築します。
import 'dart:isolate';
class ComputationWorker {
late SendPort _sendPort;
late Isolate _isolate;
bool _isReady = false;
Future<void> init() async {
final receivePort = ReceivePort();
_isolate = await Isolate.spawn(_workerEntry, receivePort.sendPort);
// 最初のメッセージとしてSendPortを受け取ります
_sendPort = await receivePort.first as SendPort;
_isReady = true;
}
// バックグラウンドで実行されるエントリポイント
static void _workerEntry(SendPort mainSendPort) {
final workerReceivePort = ReceivePort();
mainSendPort.send(workerReceivePort.sendPort);
workerReceivePort.listen((message) {
if (message is List) {
final SendPort replyPort = message[0];
final dynamic data = message[1];
// 重い処理の実行 (例)
final result = data.toString().toUpperCase();
// 結果を返す
replyPort.send(result);
}
});
}
// メインスレッドから処理をリクエストする関数
Future<dynamic> compute(dynamic data) async {
if (!_isReady) throw Exception('Worker is not ready');
final responsePort = ReceivePort();
_sendPort.send([responsePort.sendPort, data]);
final result = await responsePort.first;
responsePort.close();
return result;
}
void dispose() {
_isolate.kill();
}
}
この Worker オブジェクトをシングルトンやプロバイダーとして保持し、必要な時に compute メソッドを呼び出すだけで済みます。毎回スレッドを生成するコストを節約し、途切れることのない60fpsのアニメーションを維持できます。
結論
Flutterアプリでパフォーマンスの問題が発生する場合、そのほとんどの原因はメインスレッドをブロックする同期的な処理にあります。
- 1回限りの読み込みやパースには、直感的な
Isolate.runを積極的に使用してください。 - 継続的に発生する処理には、
Worker Poolを自分で構成するか、関連パッケージを活用することをおすすめします。
重い処理は別スレッドに任せ、FlutterのメインスレッドがUIのレンダリングのみに集中できるように設計しましょう!