Flutterにおける滑らかなユーザーエクスペリエンスの確保: バックグラウンド処理のためのIsolatesの活用
GoogleのUIツールキットであるFlutterは、その単一のコードベースからモバイル、ウェブ、デスクトップ向けにビジュアルに魅力的なネイティブコンパイルアプリケーションを作成する能力で賞賛を受けている。Flutterのコア部分は、単一スレッドモデルで動作しており、すべてのタスクをメインのUIスレッドで実行する。このモデルは、タスク管理とステートハンドリングを単純化する一方で、ジャンクや滑らかなユーザー体験を保証するためにリソースを集中的に使用するタスクの慎重なハンドリングが求められる。
1. Flutterの単一スレッドモデル
FlutterのUIレンダリングとほとんどの操作は、メインまたはUIスレッドとして知られる単一のスレッドで実行される。この最適な状況下でジャンクフリーなUIの更新を保証しているが、JSONの大量のレスポンスを解析するなど、重たい計算タスクやI/Oバウンドワークをこのスレッドで直接実行することは、UIのジャンクまたはユーザーインターフェースで目に見えるぎこちなさとして現れるフレームの描画遅延をもたらす可能性がある。
2. FlutterへのIsolatesの導入
UIの滑らかさを損なうことなく、重いタスクを実行するメカニズムとして、FlutterはIsolatesというコンセプトを導入している。Isolateは、メインのUIスレッドから独立して動作する別の実行スレッドであり、自身のメモリヒープとイベントループを持っており、タスクを並行して実行できる。
- Isolateの生成: Isolateを作成するために、開発者は通常、
Isolate.spawn()
関数を使用し、Isolateで実行される関数とデータ交換のための通信ポートを渡す。 - コミュニケーション: Isolateはそれぞれが自身のメモリヒープを持っているため、メモリ空間を共有しない。Isolate間のコミュニケーションは、メッセージ(データとエラー)の交換を可能にする
SendPort
とReceivePort
を通じて達成される。
Isolate.spawn(myIsolateFunction, receivePort.sendPort);
3. Isolatesの実践的な実装
実際には、IsolatesはUIスレッドがレンダリングとユーザーとのインタラクションに専念できるように、重い計算タスクやI/Oバウンドの操作をUIスレッドからオフロードするメカニズムを提供する。Flutter cookbookには、UIが応答性を保つためにIsolateを使用してバックグラウンドで大規模なデータセットが解析される具体的な例が提供されている。
使用例:
Future<void> parseAndLoadData(String data) async {
final receivePort = ReceivePort();
await Isolate.spawn(_parseDataIsolate, receivePort.sendPort);
final sendPort = await receivePort.first;
final response = ReceivePort();
sendPort.send([data, response.sendPort]);
await for (var msg in response) {
// パースされたデータを処理
}
}
static void _parseDataIsolate(SendPort initialReplyTo) {
final port = ReceivePort();
initialReplyTo.send(port.sendPort);
port.listen((message) {
final String data = message[0];
final SendPort replyTo = message[1];
// データをパースして返す
});
}
4. Isolatesに関する高度な洞察
- メモリ管理: 各Isolateは自身のメモリヒープを機能させ、一つが使用するメモリが他に影響を与えないようにする。この隔離は、メモリ共有による望ましくない副作用を防ぐ。
- イベントループ: すべてのIsolateには専用のイベントループがあり、独立してマイクロタスクとイベントを管理できる。開発者はこの専用のイベントループのおかげで、Isolatesで
async/await
を利用できる。 - Dart:ffi: 上級ユーザーは、Isolatesと組み合わせてDart FFI(Foreign Function Interface)を使用し、特定の計算タスクに対して相当なパフォーマンス利点を提供する可能性のある並列化されたCコードを実行できる。
結論
Flutterの単一スレッドモデルは、ステート管理とUIレンダリングの単純化を保証しているが、滑らかなユーザー体験を保証するためにリソースを集中的に使用するタスクの慎重なハンドリングが求められる。Isolatesは有力な解決策として現れ、開発者が重い計算を別の実行スレッドにオフロードし、ユーザーインターフェースの応答性と流動性を保持することができる。
補足
Flutter cookbookは縦スクロールだが、横スクロールにするためにはPhotoListウィジェットを以下のように書き換えると良い。(元のサンプルでは画像番号が表示されておらず、どこまでスクロールしたか分かりにくいのでインデックスを付けた。)
cclass PhotosList extends StatelessWidget {
const PhotosList({Key? key, required this.photos}) : super(key: key);
final List<Photo> photos;
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: photos.length,
itemBuilder: (context, index) {
return SizedBox(
width: 180, // Adjust width as per your requirement
child: Column(
children: [
Expanded(
child: Image.network(photos[index].thumbnailUrl),
),
Text('${index + 1}'),
],
),
);
},
),
),
Expanded(
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: photos.length,
itemBuilder: (context, index) {
return SizedBox(
width: 180, // Adjust width as per your requirement
child: Column(
children: [
Expanded(
child: Image.network(photos[index].thumbnailUrl),
),
Text('${index + 1}'),
],
),
);
},
),
),
],
);
}
}
さらにImageをIsolatesで取得するサンプル
import 'dart:isolate';
import 'dart:typed_data';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
void imageFetchIsolate(SendPort sendPort) async {
final port = ReceivePort();
sendPort.send(port.sendPort);
await for (var msg in port) {
final String url = msg[0];
final SendPort replyTo = msg[1];
http.Response response = await http.get(Uri.parse(url));
replyTo.send(response.bodyBytes);
}
}
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: ImageFetcher(),
);
}
}
class ImageFetcher extends StatefulWidget {
@override
_ImageFetcherState createState() => _ImageFetcherState();
}
class _ImageFetcherState extends State<ImageFetcher> {
late Isolate _isolate;
late ReceivePort _receivePort;
late SendPort _sendPort;
late Uint8List _imageData;
@override
void initState() {
super.initState();
_receivePort = ReceivePort();
_createIsolate();
}
_createIsolate() async {
_isolate = await Isolate.spawn(imageFetchIsolate, _receivePort.sendPort);
_sendPort = await _receivePort.first;
// Example image URL
String imageUrl = 'https://via.placeholder.com/150/771796';
final port = ReceivePort();
_sendPort.send([imageUrl, port.sendPort]);
_imageData = await port.first;
setState(() {}); // Update the UI
}
@override
void dispose() {
_isolate.kill(priority: Isolate.immediate);
_receivePort.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Image Fetcher')),
body: Center(
child: _imageData == null
? CircularProgressIndicator()
: Image.memory(_imageData),
),
);
}
}
IsolatesはCallback風の呼び出しもできる。以下はそのサンプル。
import 'dart:isolate';
void isolateEntryPoint(SendPort sendPort) {
final receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
receivePort.listen((message) {
final sendPortToMain = message[0] as SendPort;
sendPortToMain.send("Hello from Isolate!");
});
}
void main() {
final mainReceivePort = ReceivePort();
// Spawn the isolate
Isolate.spawn(isolateEntryPoint, mainReceivePort.sendPort)
.then((isolateInstance) {
// Handle isolate instance, manage lifecycle if needed
}).catchError((error) {
// Handle error during isolate spawn
print("Error spawning isolate: $error");
});
// Listen for the first message from the isolate
mainReceivePort.first.then((isolateSendPort) {
final isolateResponseReceivePort = ReceivePort();
(isolateSendPort as SendPort).send(isolateResponseReceivePort.sendPort);
// Listen for messages from the isolate
isolateResponseReceivePort.listen((message) {
print("Received message: $message");
});
});
}
Isolatesで100ずつの応答に応えるには、まずIsolatesを下記のように準備する。
void imageFetchIsolate(SendPort sendPort) {
// Setup a ReceivePort to receive messages from the main isolate.
final receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
receivePort.listen((message) {
// Handle messages from the main isolate, and fetch images or do some work here…
// …
// Send a response back to the main isolate.
final mainIsolateSendPort = message[0] as SendPort;
mainIsolateSendPort.send(/your response data/);
});
}
それから
class _MyAppState extends State
late Isolate _isolate;
final _receivePort = ReceivePort();
late SendPort _sendPort;
int _messageCount = 0;
@override
void initState() {
super.initState();
_createIsolate();
}
_createIsolate() async {
_isolate = await Isolate.spawn(imageFetchIsolate, _receivePort.sendPort);
_sendPort = await _receivePort.first;
_receivePort.listen((message) {
_messageCount++;
// Handle the message...
// ...
// If 100 messages have been received, do some job.
if (_messageCount % 100 == 0) {
_doSomeJob();
}
});
}
_doSomeJob() {
// Do some job after every 100 messages…
// …
}
@override
void dispose() {
_isolate.kill();
super.dispose();
}
@override
Widget build(BuildContext context) {
// …
}
}
のようにする。