7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlutterアプリでIsolateを使ってみる

7
Posted at

Flutterアプリ開発で重いデータ処理を実装しようとしたとき、非同期処理と並んで頭に浮かぶのが Isolate です。

Isolateとは?

Dartにおけるネイティブスレッドです。ただしいくつか特徴的な制約があるためThreadとは呼ばずIsolateという名称になっているようです。

特徴1. 決まったエントリポイントが必要

インスタンスメソッドなどではなく、トップレベルの関数かクラスのstaticメソッドとして実装します。
Isolate自体の実行はIsolate.runかIsolate.spawnを使います。
キャンセル処理などでIsolateインスタンスを操作したい場合はspawnを選択することになります。

// Isolate.run - 単発の計算向け(結果を直接受け取る)
final result = await Isolate.run(() => heavyComputation());

// Isolate.spawn - 継続的な通信向け(SendPort経由でやりとり)
final receivePort = ReceivePort();
final isolate = await Isolate.spawn(heavyComputation, receivePort.sendPort);

特徴2. メモリを共有しない

CやJavaとは異なりIsolateは専用のメモリ空間で実行されます。これがIsolateという命名の由来だと思います。
たとえば以下のコードをIsolateで実行すると、メインIsolateとは無関係に必ず11が返ることになります。

static int someGlobalVariable = 10;

int increment() {
  return ++someGlobalVariable;
}

この挙動に関して、プログラマーのメンタルモデルとしてはProcessが一番マッチしそうですね。
また、メモリを共有しないということは、Isolateを生成する毎にそれなりのメモリを消費するということです。

メモリ使用量について

あまり参考になるグラフではありませんが、以下はサンプルで作ったアプリのメモリ使用量チャートです。

スクリーンショット 2026-02-02 16.12.26.png

X軸の底辺を這っている青い破線がアプリのメモリ使用量です。青いドットはGCが発生していることを表しています。

状態 メモリ使用量
Isolateがない 11.5MB
Isolate存在中 12.4MB

IsolateをspawnするたびにGCが起こっているので、そこでGCの閾値を超える処理が走るのかもしれません。そしてIsolateのアーキテクチャ上、アプリのstaticなメモリ使用量が増えるにつれてIsolateのメモリ使用量も増えるのが想像できます。

特徴3. ポートによってデータをやりとりする

Linuxのプロセス間通信や、Erlangのメッセージパッシングのような機構で、Isolateとデータをやりとりします。

  • SendPort : データをIsolate側に送る (今回は扱いません)
  • ReceivePort : Isolateからデータを受け取る

メモリを共有しないというポリシーのため、参照ではなくコピーを送受信することになります。

これには以下のメリット・デメリットがあります。

  • シングルスレッドで動くFlutterをブロックしない
  • 純粋な処理しかできない (画面への反映などはメインIsolate側でやる必要がある)
  • データの受け渡しのためにメモリコピーが発生する (大量のデータ転送が負荷になる)

メモリの参照を渡せるケース

Uint8ListなどTypedDataではTransfereableTypedDataを使ってコピーなしにデータをIsolateに渡すことができます。

Rustの所有権に似た考え方が導入されていて、データを渡した後、もとのIsolateからは利用できなくなります。

特徴4. MethodChannelを利用できない

IsolateからはMethodChannelを利用できません。
が、2025年初頭にisolate_channelというパッケージが登場し、なんとか使えるようになったようです。

ユースケース

Isolateの出番といえば大量のデータを処理したい場合かと思います。メモリの参照を渡せるケースで触れたTransferableTypedDataを使うことになりそうです。

  • 何かしらの検索
  • データ圧縮

何か作ってみる

以下のような機能のカウンターアプリを作って、メインIsolateとサブIsolateからカウントしてみます。

  • メインIsolateからカウンターをインクリメント
  • Isolateからカウンターをインクリメント
  • Isolate実行中はプログレスバーを表示
  • 実行中のIsolateをキャンセルできる

実行画面

Isolateは空のタイトループが入っていて最適化なしのときには結構な時間を消費しますが、UIが動いていることがわかります。
また、Isolateのカウンターは常に3で、メモリ空間が別れていることもわかります。

output.avif

ソースコード

実行可能なソースコードです。Flutter 3.38.9で実装しています。

main.dart
import 'dart:async';
import 'dart:isolate';

import 'package:flutter/material.dart';

import 'computation.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Isolate Sample',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: const IsolateSample(),
    );
  }
}

class IsolateSample extends StatefulWidget {
  const IsolateSample({super.key});

  @override
  State<IsolateSample> createState() => _IsolateSampleState();
}

/// Isolateから受け取る計算結果を格納するクラス
class _ComputationResult {
  final int random;
  final int isolateCounter;

  _ComputationResult({required this.random, required this.isolateCounter});
}

class _IsolateSampleState extends State<IsolateSample> {
  final List<_ComputationResult> _results = [];
  bool _isLoading = false;
  double _progress = 0.0;

  /// 生成したIsolateへの参照。キャンセル時にkill()するために保持する。
  Isolate? _isolate;

  /// Isolateからメッセージを受信するためのReceivePort。
  /// Isolate間の通信はSendPort/ReceivePortのペアで行う。
  ReceivePort? _receivePort;

  /// ReceivePortのlistenで返されるSubscription。キャンセル時に解除するために保持する。
  StreamSubscription<dynamic>? _subscription;

  Future<void> _runInIsolate() async {
    setState(() {
      _isLoading = true;
      _progress = 0.0;
    });

    // ReceivePortを作成。このportでIsolateからのメッセージを受信する。
    _receivePort = ReceivePort();

    // Isolate.spawnで新しいIsolateを生成。
    // 第1引数: Isolateで実行する関数(トップレベル関数またはstatic関数である必要がある)
    // 第2引数: Isolateに渡すメッセージ(ここではSendPortを渡して通信経路を確立)
    _isolate = await Isolate.spawn(heavyComputation, _receivePort!.sendPort);

    // キャンセルによってnullになっている可能性があるためチェック
    final receivePort = _receivePort;
    if (receivePort == null) {
      return;
    }

    // ReceivePortをlistenして、Isolateからのメッセージを段階的に受信する。
    // Isolateは複数回send()でき、その都度このコールバックが呼ばれる。
    _subscription = receivePort.listen((message) {
      final data = message as Map<String, dynamic>;
      final type = data['type'] as String;

      if (!mounted) return;

      // メッセージのtypeに応じて処理を分岐
      if (type == 'progress') {
        // 進捗更新
        setState(() {
          _progress = data['progress'] as double;
        });
      } else if (type == 'result') {
        // 最終結果を受信。Isolate内のカウンター値はメインと独立している。
        setState(() {
          _results.add(_ComputationResult(
            random: data['random'] as int,
            isolateCounter: data['isolateCounter'] as int,
          ));
          _isLoading = false;
        });
        _cleanup();
      }
    }, onError: (e) {
      debugPrint(e.toString());
      _cleanup();
    });
  }

  /// 実行中のIsolateをキャンセルする
  void _cancelIsolate() {
    // Isolate.killで強制終了。priorityでタイミングを指定できる。
    // Isolate.immediate: 即座に終了
    // Isolate.beforeNextEvent: 次のイベント処理前に終了
    _isolate?.kill(priority: Isolate.immediate);
    _cleanup();
    setState(() {
      _isLoading = false;
      _progress = 0.0;
    });
  }

  /// リソースのクリーンアップ
  void _cleanup() {
    _subscription?.cancel();
    _subscription = null;
    // ReceivePortをcloseしないとメモリリークの原因になる
    _receivePort?.close();
    _receivePort = null;
    _isolate = null;
  }

  @override
  void dispose() {
    _cancelIsolate();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Isolate Sample'),
      ),
      body: Column(
        children: [
          // メインIsolateのカウンター表示エリア
          // computation.dartにも同名の変数があるが、Isolateのメモリ空間は独立しているため
          // こちらの値がIsolate内の処理で変更されることはない。
          Container(
            padding: const EdgeInsets.all(16.0),
            color: Theme.of(context).colorScheme.primaryContainer,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  'Main Isolate Counter: ${getCurrentCounter()}',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                ElevatedButton(
                  onPressed: () {
                    setState(() {
                      incrementCounter();
                    });
                  },
                  child: const Text('Increment'),
                ),
              ],
            ),
          ),
          // 結果リスト
          // Isolate Counterは毎回0からスタートするため常に3になる(3回インクリメントしている)
          // これはIsolateが毎回新しいメモリ空間で実行されることを示している。
          Expanded(
            child: _results.isEmpty
                ? const Center(child: Text('No results yet'))
                : ListView.builder(
                    itemCount: _results.length,
                    itemBuilder: (context, index) {
                      final result = _results[index];
                      return ListTile(
                        leading: CircleAvatar(child: Text('${index + 1}')),
                        title: Text('Random: ${result.random}'),
                        subtitle: Text('Isolate Counter: ${result.isolateCounter}'),
                      );
                    },
                  ),
          ),
          // 進捗表示
          if (_isLoading)
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16.0),
              child: Column(
                children: [
                  LinearProgressIndicator(value: _progress),
                  const SizedBox(height: 8),
                  Text('${(_progress * 100).toInt()}%'),
                ],
              ),
            ),
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: ElevatedButton(
              onPressed: _isLoading ? _cancelIsolate : _runInIsolate,
              child: _isLoading
                  ? const Text('Cancel')
                  : const Text('Run Computation'),
            ),
          ),
        ],
      ),
    );
  }
}
computation.dart
import 'dart:isolate';
import 'dart:math';

// =============================================================================
// このファイルの変数・関数はIsolate内で実行される。
// main.dartでもこのファイルをimportしているが、Isolateで実行すると
// 別のメモリ空間にコピーされるため、メインIsolateからの操作とは完全に独立する。
// =============================================================================

/// カウンター
int _counter = 0;

/// 現在のカウンターの値を取得する
int getCurrentCounter() {
  return _counter;
}

/// カウンターをインクリメントし、その値を返す
int incrementCounter() {
  return ++_counter;
}

/// Isolateで実行される重い計算処理
///
/// Isolate.spawnで呼び出される関数は以下の制約がある:
/// - トップレベル関数またはstatic関数であること
/// - 引数は1つのみ(通常はSendPortを渡して通信経路を確立する)
///
/// [sendPort] メインIsolateへメッセージを送信するためのport。
/// SendPort.send()で送信したデータは、メインIsolateのReceivePortで受信できる。
void heavyComputation(SendPort sendPort) {
  const totalIterations = 4000000000;
  const progressPoints = [0.25, 0.50, 0.75];
  var nextProgressIndex = 0;

  // 進捗0%を送信
  sendPort.send({'type': 'progress', 'progress': 0.0});

  // 重い計算処理(タイトループ)
  // 進捗ポイントに達したらメインIsolateに通知する
  for (var i = 0; i < totalIterations; i++) {
    if (nextProgressIndex < progressPoints.length) {
      final progressPoint = progressPoints[nextProgressIndex];
      if (i >= totalIterations * progressPoint) {
        // SendPort.send()は非同期的にメッセージを送信する。
        // メインIsolateのReceivePort.listen()で受信される。
        sendPort.send({'type': 'progress', 'progress': progressPoint});
        nextProgressIndex++;
      }
    }
  }

  // Isolate内でカウンターを3回インクリメント
  // このIsolateは毎回新しいメモリ空間で実行されるため、_counterは常に0からスタートする。
  // そのため、ここでの結果は常に3になる。
  incrementCounter();
  incrementCounter();
  final isolateCounter = incrementCounter();

  // 最終結果を送信
  // Mapやプリミティブ型など、シリアライズ可能なオブジェクトを送信できる。
  // 注意: 関数やクロージャなど、一部のオブジェクトは送信できない。
  sendPort.send({
    'type': 'result',
    'random': Random().nextInt(1000),
    'isolateCounter': isolateCounter,
  });
}

最後に

株式会社ボトルキューブではFlutterを使ったお仕事を募集中です。
お問い合わせフォームからご連絡ください。

また、一緒に働く仲間も募集しています。
詳細は採用情報ページをご覧ください。

7
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?