Flutter 基础体系 · 第 30/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Dart Isolate:消息复制、TransferableTypedData、Worker 和成本

Dart 的并发模型不是“多个线程共享一块堆内存”,而是多个 isolate 各自拥有独立的内存堆和事件循环。Isolate 之间不能直接读写对方的对象,只能通过消息传递通信。

这带来一个直接结果:

Isolate 可以避免共享可变状态和锁竞争,但消息通信本身有成本,尤其是发送大对象时。

理解 Isolate 的关键,不是只记住“耗时任务放到后台”,而是要同时回答四个问题:

  1. 消息发送时,数据到底是复制、共享,还是转移?
  2. TransferableTypedData 优化了哪一段成本,又没有优化哪一段?
  3. Dart、Flutter 和 Web 中的 “Worker” 分别指什么?
  4. 创建 isolate、发送消息、等待结果和销毁 isolate 的成本如何影响设计?

一、Isolate 的基本模型:独立堆、独立事件循环、消息端口

一个 isolate 至少包含以下几个概念:

  • 独立堆:保存该 isolate 自己创建的 Dart 对象。
  • 独立事件循环:处理异步 I/O、定时器、消息和微任务。
  • ReceivePort:接收消息的端口。
  • SendPort:向某个 ReceivePort 发送消息的能力。
  • Isolate:对另一个执行单元的控制句柄,例如暂停、恢复和终止。

可以把两个 isolate 抽象为:

┌──────────────────────┐          message          ┌──────────────────────┐
│ Isolate A             │ ───────────────────────▶ │ Isolate B             │
│                      │                           │                      │
│ Heap A               │                           │ Heap B               │
│ Event loop A         │                           │ Event loop B         │
│ ReceivePort A        │ ◀─────────────────────── │ ReceivePort B        │
│ SendPort B           │          message          │ SendPort A           │
└──────────────────────┘                           └──────────────────────┘

发送消息时,发送者并不是把一个普通 Dart 对象的引用交给接收者。接收者得到的是一个可以在自己堆中使用的数据表示。

因此,下面的代码不会让两个 isolate 共享同一个 List

// 概念示例
final numbers = [1, 2, 3];
sendPort.send(numbers);

接收 isolate 可以修改它收到的列表,但这不会修改发送 isolate 中的 numbers

// 接收 isolate
final received = message as List<int>;
received.add(4); // 不会改变发送方原来的 List

这里的“不会改变”不是因为 Dart 自动给列表加了锁,而是因为两个 isolate 使用的不是同一份可变对象。

1. Isolate 不等于 Future

Future 表示异步结果,它通常仍然运行在当前 isolate 的事件循环中:

final result = await someAsyncOperation();

如果 someAsyncOperation 内部执行的是文件、网络或数据库异步 I/O,当前 isolate 可以在等待期间处理其他事件。但如果它在当前 isolate 中进行大量 CPU 计算,Future 不会自动把计算迁移到后台线程。

例如:

Future<int> calculate() async {
  var result = 0;

  for (var i = 0; i < 500000000; i++) {
    result += i;
  }

  return result;
}

调用 calculate() 仍可能阻塞 Flutter 的 UI isolate,因为循环本身是同步计算。

Isolate 才是 Dart 中用于隔离 CPU 计算的主要机制。Dart VM 通常会为可并行运行的 isolate 调度独立线程,但“一个 isolate 必然对应一个永久独立操作系统线程”不是应用层应依赖的接口保证。正确的抽象是:isolate 拥有独立内存和独立执行上下文,运行时负责调度它。

2. Isolate 之间没有共享锁和共享可变变量

下面这种思路在 Dart isolate 模型中不存在:

多个线程 ──共享── 一个 List / Map / 对象
多个线程 ──加锁── 保护这块对象

通常的通信方式是:

Isolate A:
  读取自己的状态
  生成消息
  发送消息

Isolate B:
  接收消息
  修改自己的状态
  发送结果

因此,Isolate 解决的是共享内存并发中的一部分问题,但它没有消除并发设计问题。消息顺序、请求关联、取消、异常、关闭和背压仍然需要由应用设计。


二、消息发送的复制语义

1. 普通消息通常需要复制对象图

从一个 isolate 发送对象时,运行时需要处理从消息根对象可达的对象图。例如:

final message = <String, Object>{
  'id': 42,
  'items': [10, 20, 30],
};

sendPort.send(message);

运行时需要传递:

Map
├── String 'id'
├── Integer 42
└── List
    ├── Integer 10
    ├── Integer 20
    └── Integer 30

接收方不能直接使用发送 isolate 堆中的 MapList。对于可发送的数据,运行时会构造接收 isolate 可以使用的表示。

“复制”不一定意味着对每一种对象都执行完全相同的逐字段拷贝。字符串、某些不可变值和运行时内部表示可能有特殊处理;具体实现也可能随 Dart VM 版本变化。但应用不应把普通可变对象当成跨 isolate 共享对象来设计。

工程上应使用这个稳定的思维模型:

普通可变数据跨 isolate 发送,按需要复制来估算成本;不要依赖内部是否偶尔进行了共享或优化。

2. 可发送对象不是任意 Dart 对象

消息必须能够被 isolate 通信机制表示和传递。常见的可发送数据包括:

  • null、布尔值、数字、字符串;
  • 由可发送值组成的 ListMap 等集合;
  • SendPort
  • Capability
  • TransferableTypedData
  • 某些运行时支持的不可变对象。

不能简单地把任意实例、打开的文件句柄、锁、流控制器或包含原生资源的对象发送过去。

例如,下面的设计通常是错误方向:

class DatabaseSession {
  final Object nativeHandle;

  DatabaseSession(this.nativeHandle);
}

// 不应假设可以把数据库会话直接发送到另一个 isolate
sendPort.send(DatabaseSession(nativeHandle));

数据库连接、文件对象、插件对象和平台通道对象通常带有 isolate 或原生线程上下文。即使某个版本、某个平台下偶然没有立即报错,也不代表这种对象可以安全跨 isolate 使用。

更可靠的方式是传递纯数据:

sendPort.send({
  'sql': 'SELECT * FROM users',
  'parameters': ['active'],
});

或者让每个 isolate 自己初始化它需要的资源。

3. 闭包捕获会影响 Isolate.spawn

使用 Isolate.spawn 时,入口函数和传入参数必须适合跨 isolate 发送。尤其要注意闭包隐式捕获外部对象:

final hugeCache = ...;

// 这个闭包可能捕获 hugeCache,导致无法发送或产生额外成本
await Isolate.spawn((SendPort port) {
  port.send(hugeCache.length);
}, sendPort);

推荐使用顶层函数或静态函数,并显式传递最小参数:

void workerEntry(SendPort port) {
  port.send('worker started');
}

这不仅减少可发送性问题,也让 worker 的输入、输出和生命周期更容易审查。


三、复制消息的成本:时间、内存和延迟

设一条消息中需要传递的数据总大小为 NN 字节,复制带宽为 BB,固定调度和协议开销为 CC,那么可以用下面的近似模型理解发送成本:

TcopyC+NBT_{\text{copy}} \approx C + \frac{N}{B}

其中:

  • TcopyT_{\text{copy}}:发送并构造接收方数据表示的大致时间;
  • CC:消息调度、类型处理和固定管理成本;
  • NN:需要处理的数据规模;
  • BB:当前设备和运行时的有效复制带宽。

这不是 Dart API 的性能保证,而是用于推导的成本模型。实际时间还会受到对象数量、嵌套层级、垃圾回收、缓存局部性和消息队列状态影响。

对于一个包含大量小对象的结构,成本不仅由字节数决定,还与对象数量有关:

TstructuredC+αK+NBT_{\text{structured}} \approx C + \alpha \cdot K + \frac{N}{B}

其中 KK 是对象节点数量,α\alpha 是每个对象节点的处理成本。

因此,两个占用相同字节数的消息可能表现不同:

消息 A:
  一个连续的 Uint8List

消息 B:
  数十万个小 Map、String、List 和数字

消息 B 需要遍历和重建更多对象,接收方还可能产生更多短命对象,从而增加垃圾回收压力。

1. 内存峰值不只是消息大小

发送普通数据时,短时间内可能同时存在:

发送 isolate 中的原始数据
接收 isolate 中的复制数据
消息编码或中间表示

因此,峰值内存可以粗略理解为:

MpeakMsource+Mcopy+MtemporaryM_{\text{peak}} \approx M_{\text{source}} + M_{\text{copy}} + M_{\text{temporary}}

如果发送一个 100 MB 的字节数组,不能只因为“最终结果也是 100 MB”就认为运行过程中只需要 100 MB。复制和中间处理可能让峰值显著增加。

2. 复制不会自动消除背压问题

如果发送方持续发送速度大于接收方处理速度,消息会在端口对应的队列中堆积:

发送速度 > 处理速度
        ↓
队列增长
        ↓
内存增长
        ↓
延迟增加,最终可能触发 OOM

Isolate 消息端口不是无限容量的流式管道。对于大批量任务,需要限制未完成请求数、分块处理或显式设计确认消息。


四、TransferableTypedData:转移字节数据的所有权

1. 它解决的具体问题

TransferableTypedData 位于 dart:typed_data,用于跨 isolate 传递一段二进制数据。

普通方式:

sendPort.send(Uint8List(...));

运行时需要按普通消息语义处理这个字节数据。

转移方式:

final transferable = TransferableTypedData.fromList([
  bytes,
]);

sendPort.send(transferable);

它允许运行时把底层字节数据的使用权转移给接收 isolate,避免在 isolate 之间再次复制整段数据。这里的关键词是 transfer,不是 shared memory:

  • 不是两个 isolate 同时拥有一个可变数组;
  • 不是把 Uint8List 的同一个引用暴露给两个堆;
  • 是发送方把这段可转移数据交给接收方。

接收方通过 materialize() 取得一个 ByteBuffer

final buffer = transferable.materialize();
final bytes = buffer.asUint8List();

TransferableTypedData 设计为一次性转移。发送后,不应继续把同一个 TransferableTypedData 当作可重复发送的普通值使用;接收方也应按一次性 materialize 的语义处理它。

2. fromList 不是“所有步骤零拷贝”

下面的代码更准确地表示成本:

final bytes = Uint8List(size);

// 构造可转移表示
final transferable = TransferableTypedData.fromList([bytes]);

// 发送可转移表示
sendPort.send(transferable);

可以把总成本拆成:

Ttransfer-totalTpack+Tsend-metadata+TmaterializeT_{\text{transfer-total}} \approx T_{\text{pack}} + T_{\text{send-metadata}} + T_{\text{materialize}}

其中:

  • TpackT_{\text{pack}}:把输入的 typed data 组织为可转移表示的成本;
  • Tsend-metadataT_{\text{send-metadata}}:跨 isolate 传递所有权和描述信息的成本,通常不随数据量线性复制;
  • TmaterializeT_{\text{materialize}}:接收方建立可访问视图的成本。

因此,TransferableTypedData 主要避免的是 跨 isolate 消息传递阶段对整个字节数组的再次复制。它不保证:

  • 解码器不复制数据;
  • fromList 完全没有内存整理成本;
  • materialize 后的所有后续操作都是零拷贝;
  • 不同平台和不同 Dart 后端有完全相同的实现成本。

如果输入是多个切片:

final parts = <Uint8List>[
  Uint8List(1024),
  Uint8List(2048),
];

final transferable = TransferableTypedData.fromList(parts);

运行时会把这些 typed data 组织成可转移的数据表示。应用不应依赖接收端仍然保留“多个原始切片”的边界;接收方得到的是一个可访问的连续字节缓冲语义。

3. 什么时候适合使用

它适合以下数据流:

  • 图片压缩或解压前后的原始字节;
  • 加密、哈希、编码转换;
  • 二进制协议解析;
  • 大型音频或视频块;
  • 大量数值组成的连续缓冲区。

它不适合替代结构化消息:

// 适合普通消息:任务参数少,结构清晰
sendPort.send({
  'requestId': 7,
  'operation': 'decode',
  'format': 'png',
});

// 适合 TTD:主体是大段二进制数据
sendPort.send(TransferableTypedData.fromList([encodedBytes]));

任务元数据和二进制主体通常可以组合:

sendPort.send({
  'requestId': 7,
  'payload': TransferableTypedData.fromList([encodedBytes]),
});

但整个消息仍必须满足跨 isolate 的可发送约束。


五、完整示例:长生命周期 Worker 使用 TransferableTypedData

下面的示例是一个可运行的 Dart VM 程序。它创建一个长期存活的 worker,把一段字节数据转移给 worker,由 worker 统计字节总和,再把整数结果发回主 isolate。

它展示了完整链路:

主 isolate 创建 ReceivePort
        ↓
spawn worker
        ↓
worker 创建自己的 ReceivePort,并回传 SendPort
        ↓
主 isolate 通过 worker SendPort 发送任务
        ↓
worker materialize TransferableTypedData
        ↓
worker 计算并返回结果
        ↓
主 isolate 关闭端口并终止 worker
import 'dart:async';
import 'dart:isolate';
import 'dart:typed_data';

void workerEntry(SendPort parentPort) {
  final inbox = ReceivePort();

  // 把 worker 自己的接收端口交给主 isolate。
  parentPort.send(inbox.sendPort);

  inbox.listen((Object? message) {
    if (message is! Map<Object?, Object?>) {
      parentPort.send({
        'type': 'error',
        'message': 'unexpected message type: ${message.runtimeType}',
      });
      return;
    }

    final requestId = message['requestId'];
    final payload = message['payload'];

    if (payload is! TransferableTypedData) {
      parentPort.send({
        'type': 'error',
        'requestId': requestId,
        'message': 'payload is not TransferableTypedData',
      });
      return;
    }

    try {
      final bytes = payload.materialize().asUint8List();

      var sum = 0;
      for (final byte in bytes) {
        sum += byte;
      }

      parentPort.send({
        'type': 'result',
        'requestId': requestId,
        'length': bytes.length,
        'sum': sum,
      });
    } catch (error, stackTrace) {
      parentPort.send({
        'type': 'error',
        'requestId': requestId,
        'message': error.toString(),
        'stackTrace': stackTrace.toString(),
      });
    }
  });
}

Future<void> main() async {
  final parentPort = ReceivePort();
  final errorPort = ReceivePort();
  final exitPort = ReceivePort();

  final worker = await Isolate.spawn(
    workerEntry,
    parentPort.sendPort,
    onError: errorPort.sendPort,
    onExit: exitPort.sendPort,
    errorsAreFatal: false,
  );

  final workerPortCompleter = Completer<SendPort>();
  final resultCompleter = Completer<Map<Object?, Object?>>();

  late StreamSubscription<Object?> parentSubscription;
  parentSubscription = parentPort.listen((Object? message) {
    if (message is SendPort && !workerPortCompleter.isCompleted) {
      workerPortCompleter.complete(message);
      return;
    }

    if (message is Map<Object?, Object?> &&
        message['type'] == 'result' &&
        !resultCompleter.isCompleted) {
      resultCompleter.complete(message);
      return;
    }

    if (message is Map<Object?, Object?> &&
        message['type'] == 'error' &&
        !resultCompleter.isCompleted) {
      resultCompleter.completeError(
        StateError(message['message'] ?? 'worker error'),
      );
    }
  });

  errorPort.listen((Object? error) {
    print('uncaught worker error: $error');
  });

  exitPort.listen((Object? _) {
    print('worker exited');
  });

  final workerPort = await workerPortCompleter.future;

  final bytes = Uint8List.fromList(List<int>.generate(
    1024 * 1024,
    (index) => index % 256,
  ));

  final transferable = TransferableTypedData.fromList([bytes]);

  workerPort.send({
    'requestId': 1,
    'payload': transferable,
  });

  final result = await resultCompleter.future;

  print('length = ${result['length']}');
  print('sum = ${result['sum']}');

  await parentSubscription.cancel();
  parentPort.close();
  errorPort.close();
  exitPort.close();

  worker.kill(priority: Isolate.immediate);
}

预期输出类似:

length = 1048576
sum = 133693440

这里的总和可以推导出来。字节序列是 0, 1, ..., 255 循环出现:

  • 每 256 个字节的总和是:

0+1++255=255×2562=326400 + 1 + \cdots + 255 = \frac{255 \times 256}{2} = 32640

  • 1 MiB 等于 40964096 个 256 字节块;
  • 所以总和是:

32640×4096=13369344032640 \times 4096 = 133693440

这个示例中的生命周期

1. 创建主端口

final parentPort = ReceivePort();

ReceivePort 是主 isolate 用来接收 worker 消息的端口。它本身不是一个普通数据容器,而是事件源。

2. 创建 worker

final worker = await Isolate.spawn(
  workerEntry,
  parentPort.sendPort,
);

传给 worker 的初始参数是 SendPort,它可以被跨 isolate 发送。worker 拿到它之后,再创建自己的 ReceivePort 并回传对应的 SendPort

3. 建立双向通信

主 isolate ── parentPort.sendPort ──▶ worker
主 isolate ◀─ workerPort ─────────── worker

主 isolate 保存 workerPort,以后向它发送任务;worker 保存 parentPort,以后向主 isolate 返回结果。

4. 转移数据

final transferable = TransferableTypedData.fromList([bytes]);
workerPort.send({
  'requestId': 1,
  'payload': transferable,
});

requestId 用于把响应关联回请求。payload 是二进制主体,使用 TransferableTypedData 避免普通消息路径对大段数据进行额外复制。

5. 处理异常和退出

onError 只处理未被 worker 自己捕获的未处理异常;worker 内部的业务异常仍然应该转换为带请求 ID 的错误响应。

onExit 只表示 isolate 退出,不代表任务成功。生产代码不能把“收到退出通知”当成“收到正确结果”。

6. 关闭资源

ReceivePort.close() 停止继续接收消息,Isolate.kill() 请求终止 isolate。长期 worker 应有明确的关闭协议,例如:

workerPort.send({'type': 'shutdown'});

由 worker 收到后关闭自己的 ReceivePort,再自然退出。强制 kill 更适合作为超时或故障恢复手段,因为它不会等待当前业务逻辑完成,也不会替应用执行业务层清理。


六、短任务 API:Isolate.run 和 Flutter 的 compute

长生命周期 worker 不是所有任务都需要的。对于“一次提交、一次返回”的任务,可以使用 Isolate.run

import 'dart:isolate';

Future<int> sumNumbers(List<int> numbers) {
  return Isolate.run(() {
    var total = 0;
    for (final number in numbers) {
      total += number;
    }
    return total;
  });
}

其语义可以理解为:

创建一个临时 isolate
    ↓
执行闭包
    ↓
把结果发送回调用方
    ↓
任务结束后退出

它简化了端口管理,但没有消除以下成本:

  • 创建 isolate 的成本;
  • 把闭包输入传入 worker 的成本;
  • 把返回结果传回调用方的成本;
  • 临时 isolate 结束时的资源和调度成本。

如果任务很多且每个任务很小,反复 Isolate.run 可能让创建和通信开销超过计算本身。

Flutter 的 compute 是常见的便捷 API:

import 'package:flutter/foundation.dart';

Future<int> parseInBackground(String text) {
  return compute(parseText, text);
}

int parseText(String text) {
  return text.split(',').length;
}

在支持并行 isolate 的原生 Flutter 平台上,compute 通常用于把计算函数放到后台 isolate。回调和参数必须满足对应的 isolate 发送约束,不能依赖 UI 状态、BuildContext、打开的插件对象或不可发送的资源。

compute 不是一种新的并发模型,它只是对一次性 isolate 任务的封装。需要:

  • 多次提交任务;
  • 任务排队;
  • 取消;
  • 进度;
  • 自定义错误和退出协议;
  • 二进制数据的精细传输控制;

通常应直接使用 Isolate.spawn,或者使用经过维护的 worker pool 封装。

Flutter Web 的重要差异

Flutter Web 不能直接按原生 Dart VM 的性能和线程模型推断。

当前 Flutter compute 的 Web 行为可以在当前事件循环中执行回调,而不是保证创建一个真正并行执行的 Web Worker。因此:

await compute(parseText, input);

在 Web 上不应被当作“必然不阻塞浏览器主线程”的保证。

Dart Web 的 isolate 能力还受到编译后端、浏览器 Worker、可发送对象以及 JavaScript 互操作边界的限制。涉及 Web 时,应针对目标 Flutter/Dart 版本验证:

  • Isolate.spawn 是否被目标编译配置支持;
  • 回调是否真的运行在 Worker;
  • TransferableTypedData 是否走了目标后端支持的转移路径;
  • JavaScript 对象是否满足 Dart isolate 的消息约束。

因此,本文中的 Isolate.spawnTransferableTypedData 完整示例应优先在 Dart VM 环境运行,例如 Android、iOS、Windows、macOS、Linux 的 Flutter 原生目标或独立 Dart VM 程序。不能把原生平台上的测量结果直接套到 Web。


七、“Worker”不是 Dart 中唯一确定的 API 名称

在并发文章中,Worker 可能指不同层次的东西。

1. Dart Worker:通常是长期运行的 isolate

Dart 标准库没有一个统一的 Worker 类来代表所有后台任务。工程上常把这种结构称为 worker:

一个长期存活的 isolate
+ 一个接收任务的 ReceivePort
+ 一个返回结果的 SendPort
+ 请求协议
+ 错误和退出处理

也就是说,前面的 workerEntry 就是一个 Dart worker 的实现,而不是调用了名为 Worker 的 Dart 核心 API。

长期 worker 的状态可以表示为:

Created
   ↓ spawn 成功
Ready
   ↓ 收到任务
Busy
   ↓ 返回结果
Ready
   ↓ 收到 shutdown / 发生故障
Stopping
   ↓
Stopped

如果允许并行处理多个任务,还需要为每个任务保存:

requestId → Future / 状态 / 超时定时器

否则多个响应到达时,调用方无法知道某个结果属于哪个请求。

2. Flutter Worker:常见含义是 isolate 封装

Flutter 社区中的 “worker” 往往指:

  • compute 创建的一次性后台任务;
  • Isolate.spawn 创建的长期 isolate;
  • 第三方 isolate pool;
  • 某个插件内部创建的 isolate。

这些实现的生命周期和错误模型并不相同。阅读代码时,不能只看到 “worker” 这个名称就假定它支持取消、复用、并行或真正的后台线程。

3. Web Worker:浏览器平台概念

Web Worker 是浏览器提供的线程/执行上下文模型,通信通常使用 postMessage,数据复制遵循浏览器结构化克隆或 Transferable 对象规则。

它与 Dart isolate 有相似之处:

  • 不能随意共享普通可变对象;
  • 通过消息通信;
  • 可使用可转移的二进制缓冲区。

但它们不是同一个 API,也不能把 Web Worker 的行为直接推断为 Dart VM isolate 的行为。Dart 编译到 Web 后,Dart isolate 可能映射到浏览器 Worker,也可能因 Flutter API 和后端限制表现不同。


八、短任务和长期 Worker 的成本比较

设一次任务的计算时间为 TworkT_{\text{work}},输入复制或转移准备成本为 TinT_{\text{in}},输出成本为 ToutT_{\text{out}},调度成本为 TscheduleT_{\text{schedule}},创建 isolate 的成本为 TspawnT_{\text{spawn}}

一次性 isolate 的总时间可以近似表示为:

Tone-shotTspawn+Tin+Tschedule+Twork+ToutT_{\text{one-shot}} \approx T_{\text{spawn}} + T_{\text{in}} + T_{\text{schedule}} + T_{\text{work}} + T_{\text{out}}

长期 worker 在 worker 已经就绪后,每个任务的平均成本更接近:

TreusedTin+Tschedule+Twork+ToutT_{\text{reused}} \approx T_{\text{in}} + T_{\text{schedule}} + T_{\text{work}} + T_{\text{out}}

当任务数量为 QQ 时,一次性方案多出约 QTspawnQ \cdot T_{\text{spawn}} 的创建成本;长期 worker 则需要承担:

  • worker 常驻内存;
  • 端口和协议维护;
  • 队列管理;
  • 错误恢复;
  • worker 卡死或退出后的重建;
  • 应用生命周期中的关闭处理。

因此不能简单地说“长期 worker 一定更快”。如果任务只有一次,且计算量足够大,一次性 isolate 更简单;如果有很多小任务,复用 worker 更可能合理;如果任务本身只是几百次简单运算,任何 isolate 通信都可能比直接计算更贵。

一个反例:把很小的任务全部丢给 isolate

for (var i = 0; i < 10000; i++) {
  await Isolate.run(() => i * 2);
}

这里每次都创建 isolate,并进行输入、调度和输出通信。即使每个乘法操作不耗时,总成本也可能远高于:

for (var i = 0; i < 10000; i++) {
  final value = i * 2;
}

Isolate 的价值来自隔离 CPU 工作和避免 UI 事件循环被长时间占用,而不是让每一个函数调用都获得加速。


九、一个完整的选择推导

假设需要处理一张压缩图片:

压缩图片字节:8 MB
解析后像素数据:32 MB
解析过程:明显 CPU 密集
调用频率:用户偶尔选择图片

可以按以下步骤推导:

第一步:确认是否真的需要 isolate

如果解析过程在 UI isolate 上执行会造成明显帧丢失,就有必要隔离。仅仅因为输入是 8 MB,并不能单独证明需要 isolate;关键是 CPU 计算是否阻塞事件循环。

第二步:确定传输形态

图片主体是连续二进制数据,使用:

TransferableTypedData.fromList([compressedBytes])

比构造很多层 MapList<int> 更合适。

第三步:确定 worker 生命周期

用户偶尔处理一张图片,使用 Isolate.runcompute 可能足够。若应用连续处理相册中的数百张图片,反复创建 isolate 的成本就值得评估,长期 worker 或 worker pool 可能更合适。

第四步:估算输出成本

如果 worker 返回 32 MB 的原始像素数据,再次把普通 Uint8List 发送回主 isolate,可能重新产生大数据传输成本。可以考虑:

  • 返回压缩后的结果;
  • 在 worker 中完成更多后续处理;
  • 使用 TransferableTypedData 返回二进制结果;
  • 只返回统计信息、索引或元数据。

第五步:设计失败路径

必须处理:

输入格式错误
内存不足
worker 未捕获异常
worker 意外退出
任务超时
页面或 Flutter 页面销毁

compute 的异常会通过 Future 传播;自建 worker 则需要同时监听业务错误、onErroronExit


十、常见误解和失败表现

误解一:async/await 会自动使用后台线程

错误。

Future<void> parse() async {
  final data = expensiveSynchronousParse();
  print(data);
}

async 只改变 Future 表达方式。expensiveSynchronousParse() 仍运行在当前 isolate。

诊断方法是观察 Flutter DevTools 的 UI 帧时间或在代码中插入同步计算。如果 UI isolate 的事件循环在计算期间无法处理帧和输入,说明它仍然阻塞。

误解二:TransferableTypedData 是共享内存

错误。

它转移数据的使用权,而不是让两个 isolate 同时修改同一个数组。接收方 materialize 后,发送方不能继续把原来的可转移对象当作共享缓冲区使用。

误解三:把 List<int> 换成 Uint8List 就必然零拷贝

错误。

Uint8List 只是 typed data 表示。普通发送:

sendPort.send(bytes);

和使用:

sendPort.send(TransferableTypedData.fromList([bytes]));

在通信语义和成本上不同。前者不能仅凭类型名推断为所有权转移。

误解四:worker 抛异常后主 isolate 一定能从业务响应中收到错误

错误。

worker 可能在发送业务错误之前就发生未处理异常。必须注册:

onError: errorPort.sendPort,
onExit: exitPort.sendPort,

并区分:

  • 业务错误;
  • 未处理异常;
  • 正常退出;
  • 被强制终止。

误解五:杀掉 isolate 就等于取消一个安全的函数调用

Isolate.kill 是终止执行上下文的控制操作,不是所有库都能感知的协作式取消协议。正在进行的文件、原生插件或外部资源操作可能有平台相关清理行为。

更可控的取消设计是协议化:

workerPort.send({
  'type': 'cancel',
  'requestId': requestId,
});

worker 在算法可以安全停止的位置检查取消状态。对于无法及时检查的同步原生操作,仍需要超时和强制终止作为最后恢复手段。


十一、诊断 Isolate 性能问题

1. 先分解端到端时间

不要只测 worker 内部计算时间。应至少区分:

创建 worker
输入准备
输入复制或打包
消息发送
worker 排队
实际计算
输出打包
结果接收

伪代码:

final t0 = Stopwatch()..start();

final transferable = TransferableTypedData.fromList([bytes]);
final t1 = t0.elapsedMicroseconds;

workerPort.send(transferable);
final t2 = t0.elapsedMicroseconds;

// 等待 worker 返回
final result = await resultFuture;
final t3 = t0.elapsedMicroseconds;

print('pack: ${t1} us');
print('send scheduling: ${t2 - t1} us');
print('end-to-end: ${t3} us');

这不是严格的微基准测试,但可以帮助发现“计算本身只占很小比例,通信和创建才是主要成本”的情况。

2. 使用稳定输入,避免错误归因

测量时应分别比较:

  • 小型结构化消息;
  • 大型 Uint8List 普通发送;
  • 大型 TransferableTypedData
  • 一次性 isolate;
  • 已就绪的长期 worker。

同时记录:

  • 数据大小;
  • 对象数量;
  • 任务数量;
  • 是否发生 GC;
  • 是否为 Debug 或 Release;
  • 目标平台和编译后端。

不能把 Debug 模式下的一次测量结果当成所有 Android、iOS、桌面和 Web 平台的通用数字。

3. 用帧表现验证“是否值得隔离”

Flutter 中真正需要关注的通常是:

UI isolate 是否错过帧
输入响应是否延迟
消息队列是否持续增长
内存峰值是否上升

如果一个任务只花 1 ms,但创建 isolate 和传递数据花费更多,那么它可能不应被拆到 worker;如果任务持续占用 UI isolate 数十毫秒,即使通信有成本,也可能值得隔离。


十二、平台边界

Android、iOS 和桌面 Flutter

Flutter 原生目标基于 Dart VM,通常可以利用 Dart isolate 执行后台 CPU 计算。仍需注意:

  • isolate 不自动继承 UI 状态;
  • BuildContext 不能作为后台计算上下文;
  • 插件是否支持从后台 isolate 调用,要看插件和平台实现;
  • 主 isolate 与后台 isolate 的消息传递仍有复制或转移成本;
  • release 模式性能与 debug 模式可能明显不同。

Flutter Web

Web 目标受到浏览器和 Dart Web 后端限制:

  • compute 不应被视为必然创建并行 Web Worker;
  • JavaScript 互操作对象不能自动变成可发送的 Dart isolate 消息;
  • 浏览器 Worker 的创建、结构化克隆和 Transferable 行为与 Dart VM 不完全相同;
  • 某些 Dart VM API 在 Web 上有不同实现、限制或不可用情况。

因此应将 Web 的并行性、API 支持和数据转移路径作为独立平台能力验证,而不是仅凭原生平台代码推断。


十三、最终的成本判断

Dart Isolate 的核心收益是:

独立内存
+ 不共享可变对象
+ 可以把 CPU 密集工作从 UI isolate 中隔离

核心代价是:

创建成本
+ 消息调度成本
+ 普通对象复制成本
+ 内存峰值
+ 协议、错误和生命周期复杂度

TransferableTypedData 的作用可以精确表述为:

对跨 isolate 的大段 typed data 提供所有权转移路径,主要减少消息传递阶段的整块复制;它不是共享内存,也不保证从输入构造到最终业务处理的全链路零拷贝。

Worker 则不是一个可以脱离上下文理解的 Dart 固定 API。它通常是长期运行 isolate 加上任务协议,也可能是 Flutter 的一次性 compute 封装,或者浏览器的 Web Worker。判断一个方案是否合适,必须同时看:

收益=避免 UI 阻塞的价值创建、通信、内存和维护成本\text{收益} = \text{避免 UI 阻塞的价值} - \text{创建、通信、内存和维护成本}

当计算足够重、数据传输可控、worker 生命周期设计清楚时,Isolate 能够改善 UI 响应;当任务很小、消息很大或请求协议混乱时,额外的 isolate 反而会成为新的性能和可靠性问题。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。