【Flutter】Isolateで解決!重い処理とUIの両立

対象者

  • FlutterやDartでのアプリケーション開発において、パフォーマンス最適化に興味がある開発者
  • UIの応答性を維持しながら重い処理をバックグラウンドで実行したいと考えている開発者
  • Dartの並行処理に関する理解を深め、実践的な使用例を学びたいと考えている開発者

はじめに

FlutterやDartを使ったアプリケーション開発において、ユーザーに快適な体験を提供することは、開発者にとって最優先事項の一つです。しかし、複雑な計算やデータ処理を行う際に、アプリケーションの応答性が低下することは避けられない課題となりがちです。そんな時、Dartの強力な機能である「Isolate」が、この問題の解決策となり得ます。Isolateを活用することで、重い処理をメインスレッドから分離し、バックグラウンドで非同期に実行することが可能になります。これにより、アプリケーションは重い処理を行いながらも、スムーズなUI操作を維持することができるのです。

しかし、Isolateの概念やその使い方は、初めて触れる方にとっては少々複雑に感じられるかもしれません。この記事では、IsolateがどのようにしてDartの並行処理を実現し、アプリケーションのパフォーマンスを向上させるのかを、基本から応用までわかりやすく解説します。

Isolateとは

IsolateはDartの並行処理を実現するための重要な概念です。Dartは基本的にシングルスレッドで動作しますが、Isolateを使用することで複数のスレッドのように振る舞い、アプリケーションのパフォーマンスを向上させることができます。

Dartの並行処理の基本

Dartでは、全てのコードはメインIsolate(メインスレッド)で実行されます。しかし、重い処理をメインIsolateで行うとUIの応答性が低下するため、DartではIsolateを用いてバックグラウンドで処理を行うことが推奨されています。Isolateは独立したメモリ空間を持ち、メインIsolateとは異なるスレッドで実行されるため、メインIsolateの処理に影響を与えることなく、重い処理を実行することができます。

Isolateの仕組みと特徴

Isolateはそれぞれ独立したヒープメモリを持ち、他のIsolateとは直接的なメモリ共有を行いません。これにより、Isolate間でデータを共有する際には、メッセージパッシングを通じて行う必要があります。Isolate間の通信はシリアライズされたメッセージを送受信することで実現され、これによりデータの整合性を保ちつつ、並行処理を安全に行うことが可能になります。

Isolateの使用により、DartアプリケーションはUIのスムーズな動作を維持しつつ、バックグラウンドでのデータ処理や計算処理を効率的に行うことができます。これは、特にリソースを多く消費するタスクや、ユーザーの操作に応答しながら長時間実行する必要がある処理において、非常に有効です。

Isolateの作成と管理

FlutterとDartの開発において、パフォーマンスの最適化とアプリケーションの応答性の向上は重要な課題です。これらの課題に対処するための強力なツールがIsolateです。このセクションでは、Isolateの生成方法、Isolate間での通信、そしてIsolateのライフサイクル管理について詳しく掘り下げていきます。

Isolateの生成方法

Isolateを生成するプロセスは、Dartの並行処理モデルの核心部分です。Isolateは、メインのUIスレッドとは独立したメモリヒープを持つ軽量なスレッドです。これにより、重い処理をバックグラウンドで実行し、UIのスムーズな動作を保証することができます。

Isolateの生成には主に2つの方法があります。最も一般的な方法は、Isolate.spawn()関数を使用することです。この関数は、新しいIsolateを生成し、指定された関数をそのIsolateで実行します。もう一つの方法は、Isolate.spawnUri()を使用して、Dartのスクリプトファイルを新しいIsolateで実行することです。

Isolate間の通信

Isolateはメモリを共有しないため、Isolate間でデータをやり取りするにはメッセージパッシングを使用します。Dartでは、SendPortReceivePortを使ってこの通信を実現します。送信側IsolateはSendPortを使用してメッセージを送信し、受信側IsolateはReceivePortをリッスンしてメッセージを受け取ります。

このメカニズムを利用することで、データの不整合や競合を避けながら、異なるIsolate間で効率的にデータを交換することが可能になります。

Isolateのライフサイクル

Isolateのライフサイクル管理は、リソースの効率的な使用とアプリケーションのパフォーマンス向上に不可欠です。Isolateを生成した後、そのIsolateがもはや必要ない場合は、適切に終了させることが重要です。Isolateの終了は、Isolate.kill()メソッドを呼び出すことで行います。これにより、Isolateが使用していたリソースが解放され、システムのリソースを無駄に消費することなく、アプリケーションのパフォーマンスを維持することができます。

Isolateの適切な生成、通信、そしてライフサイクル管理を理解し、適用することで、FlutterとDartのアプリケーションのパフォーマンスと応答性を大幅に向上させることができます。

Isolateを使うシナリオ

IsolateはDartの強力な機能であり、Flutterアプリケーションのパフォーマンスを向上させるために重要な役割を果たします。特に、UIの応答性を維持しながら重い処理を行う必要がある場合や、バックグラウンドでのデータ処理、リソース集約型タスクの処理など、さまざまなシナリオでその利用が推奨されます。

UIの応答性を保つためのIsolate

ユーザーインターフェース(UI)の応答性は、アプリケーションのユーザーエクスペリエンスに直接影響を与えます。重い計算やデータ処理をメインスレッドで行うと、アプリケーションのレスポンスが遅くなり、最悪の場合はフリーズしてしまうことがあります。Isolateを使用することで、これらの処理をメインUIスレッドから分離し、バックグラウンドで非同期に実行することができます。これにより、アプリケーションは重い処理を行いながらもスムーズなUI操作を維持することが可能になります。

バックグラウンドでのデータ処理

アプリケーションがバックグラウンドでデータをフェッチしたり、大量のデータを処理する必要がある場合、Isolateを使用すると効率的に処理を行うことができます。例えば、ネットワークからの大量のデータフェッチや、ローカルデータベースへの大規模な書き込み操作などが挙げられます。これらの処理をIsolate内で実行することで、メインスレッドの負荷を軽減し、アプリケーションのパフォーマンスを向上させることができます。

リソース集約型タスクの処理

画像処理、大規模な計算、または複雑なデータ解析など、リソースを大量に消費するタスクは、Isolateを使用して効率的に処理することが推奨されます。これらのタスクをメインスレッドで実行すると、アプリケーションのパフォーマンスが著しく低下する可能性があります。Isolateを利用することで、これらの処理を分離し、アプリケーションの応答性を維持しながら、効率的にタスクを完了させることができます。

Isolateを適切に使用することで、Flutterアプリケーションは高いパフォーマンスと優れたユーザーエクスペリエンスを提供することが可能になります。これらのシナリオを理解し、アプリケーションの要件に応じてIsolateを適切に活用することが、開発者にとって重要です。

Isolateの実装例

簡単なIsolateの使用例

Isolateを使用してバックグラウンドで簡単な計算を行う基本的な例から始めましょう。以下のコードスニペットは、新しいIsolateを生成し、そのIsolateで数値の加算を行う方法を示しています。
Isolate.spawnで処理するメソッドはstaticとして定義します。

import 'dart:isolate';

void startIsolate() async {
  ReceivePort receivePort = ReceivePort(); // 1. メインIsolateで受信ポートを作成
  await Isolate.spawn(doWork, receivePort.sendPort); // 2. 新しいIsolateを生成し、作業を実行

  // 3. 新しいIsolateからのメッセージをリッスン
  receivePort.listen((data) {
    print('Received data: $data');
    receivePort.close();
  });
}

// 新しいIsolateで実行される関数
static void doWork(SendPort sendPort) {
  int result = 3 + 3; // 何らかの計算
  sendPort.send(result); // 結果をメインIsolateに送信
}

compute関数を使った例

Flutterでは、compute関数を使用して簡単にIsolateを扱うことができます。この関数は、関数とデータを引数に取り、新しいIsolateでその関数を実行し、結果を返します。以下は、compute関数を使用してリスト内の数値の合計を計算する例です。

import 'package:flutter/foundation.dart';

Future<int> calculateSum(List<int> numbers) async {
  final sum = await compute(_sum, numbers);
  return sum;
}

// 新しいIsolateで実行される関数
static int _sum(List<int> numbers) {
  return numbers.fold(0, (prev, element) => prev + element);
}

computeの引数として、1つめにメソッド、2つめに引数をとります。そのため引数が一つのメソッドにしか使用できません。引数用のクラスを作るか、Recordを使って複数の値を渡すようにします。

Q&A

Q1: FlutterでUIの応答性を保ちながら重い処理をどうやって実行するの?

Flutterでは、UIの応答性を維持しながら重い処理を実行するためにIsolateを使用します。Isolateは独立したメモリ空間を持つ軽量スレッドで、メインUIスレッドとは別にバックグラウンドで処理を行うことができます。これにより、アプリケーションは重い処理を行いながらもスムーズなUI操作を維持することが可能になります。

Q2: IsolateをFlutterアプリケーションに導入するメリットは何ですか?

Isolateを導入する主なメリットは、アプリケーションのパフォーマンスと応答性の向上です。Isolateを使用することで、重い計算やデータ処理をメインUIスレッドから分離し、バックグラウンドで非同期に実行することができます。これにより、アプリケーションはユーザーの操作に迅速に応答しながら、同時にバックグラウンドでの処理を効率的に進めることができます。

Q3: Isolateの実装において、通信はどのように行われますか?

Isolate間の通信は、メッセージパッシングを通じて行われます。Dartでは、SendPortReceivePortを使ってこの通信を実現します。送信側IsolateはSendPortを使用してメッセージを送信し、受信側IsolateはReceivePortをリッスンしてメッセージを受け取ります。このメカニズムにより、異なるIsolate間でデータを安全にやり取りすることが可能になります。

Q4: Illegal argument in isolate message: object is unsendable というエラーが発生した

上記のエラーが発生しました。私のケースでは1つ目でしたが、他にも以下の理由があるようです。

  • Isolate.spawnに渡すメソッドがstaticメソッドであるか、トップレベル(クラス外)のメソッドであることを確認
  • シリアライズ可能なデータのみを送信するすることを確認
  • FlutterとDartのバージョンを確認する

まとめ

今回は、FlutterのIsolateについて勉強しました。IsolateはUIの応答性を保ちながらバックグラウンドで重い処理を実行するために重要です。Isolateの導入により、アプリケーションのパフォーマンスと応答性が向上することを理解しました。また、Isolate間でのデータ通信はメッセージパッシングを通じて安全に行われることも学びました。これらの知識を活用して、より効率的でユーザーフレンドリーなFlutterアプリケーションの開発が可能になります。

参考

ソース(main.dartにコピペして動作確認用)

import 'dart:isolate';

import 'package:flutter/foundation.dart'; // compute関数を使用するために必要
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

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

  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  String _data = _kWaitingMessage;

  static const _kWaitingMessage = 'Waiting for data...';
  static const _kRepeat = 500000000;

  // Isolateを使用しないで重いタスクを実行する
  void _runHeavyTaskWithoutIsolate() {
    setState(() => _data = _kWaitingMessage);
    final result = _heavyTask(_kRepeat);
    setState(() => _data = '$result');
  }

  // Isolateを使用して重いタスクを実行する
  void _runHeavyTaskWithIsolate() async {
    setState(() => _data = _kWaitingMessage);

    final receivePort = ReceivePort();
    await Isolate.spawn(_doHeavyTask, receivePort.sendPort);
    receivePort.listen((result) {
      setState(() => _data = '$result');
      receivePort.close();
    });
  }

  // バックグラウンドで実行される重いタスク
  static void _doHeavyTask(SendPort sendPort) {
    sendPort.send(_heavyTask(_kRepeat));
  }

  // compute関数を使用して重いタスクを実行する
  void _runHeavyTaskWithCompute() async {
    setState(() => _data = _kWaitingMessage);
    final result = await compute<int, int>(_heavyTask, _kRepeat);
    setState(() => _data = '$result');
  }

  // 重いタスクの実装
  static int _heavyTask(int limit) {
    int result = 0;
    for (int i = 0; i < limit; i++) {
      result += i;
    }
    return result;
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Isolate Demo'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text(_data),
              FilledButton(
                onPressed: _runHeavyTaskWithoutIsolate,
                child: Text('Run without Isolate'),
              ),
              FilledButton(
                onPressed: _runHeavyTaskWithIsolate,
                child: Text('Run with Isolate'),
              ),
              FilledButton(
                onPressed: _runHeavyTaskWithCompute,
                child: Text('Run with Compute'),
              ),
              const CircularProgressIndicator(),
            ],
          ),
        ),
      ),
    );
  }
}