Flutter 多 Isolate 实战:搭建主 Isolate 数据与能力桥
Flutter 应用引入后台服务或计算 Isolate 后,经常会遇到一个容易被忽略的问题:不同 Isolate 之间的内存并不共享。
主 Isolate 中保存的登录令牌、API 地址和单例对象,在后台 Isolate 中都会变成另一份独立副本。更麻烦的是,一些带状态的 Flutter 插件并不适合被多个 Isolate 同时调用。
本文介绍如何通过 ReceivePort、SendPort 和 IsolateNameServer,搭建一套轻量级的主 Isolate 数据与能力桥。
一、问题背景
假设应用通过主 Isolate 保存了这些运行时数据:
dart
例如用户的token;
后台服务需要定时请求接口,因此同样需要 Token 和 API 地址。
但在 Dart 中,每个 Isolate 都有独立的堆内存。后台 Isolate 读取:
得到的并不是主 Isolate 中那份数据,而是后台 Isolate 自己的静态副本。
仅从本地存储读取也不够稳妥:
- 内存中的 Token 可能已经刷新,但还没有同步到本地。
- 当前 API 环境可能在运行期间发生切换。
- 部分插件实例维护在主 Isolate,后台直接调用可能产生状态竞争。
- 主 Isolate 暂时不可用时,又必须有超时和降级策略。
因此,需要在两个 Isolate 之间建立一条请求---响应通道。
二、整体通信流程
本方案将主 Isolate 作为服务端,后台 Isolate 作为客户端:
这里要注意:IsolateNameServer 主要负责端口注册和查找,真正的数据传输仍然由 SendPort 和 ReceivePort 完成。
三、定义跨 Isolate 数据协议
首先使用枚举限定允许读取的数据:
dart
enum MainIsolateDataKey {
accessToken,
}
相比直接传递任意字符串,枚举有几个优势:
- 调用端拥有类型提示。
- 新增数据项时更加集中。
- 主端可以通过
switch完整处理。 - 减少协议字段拼写错误。
主 Isolate 根据名称读取当前内存数据:
dart
static Object? _readMainByKeyName(String keyName) {
late final MainIsolateDataKey key;
try {
key = MainIsolateDataKey.values.byName(keyName);
} catch (_) {
return null;
}
switch (key) {
case MainIsolateDataKey.accessToken:
return GlobalData().token;
}
}
跨 Isolate 传递的不是对象引用,而是可发送数据的副本。因此协议中应优先使用:
nullboolint、doubleString- 由可发送类型组成的
List和Map SendPort
复杂业务对象建议先转换成 JSON 或普通 Map。
四、在主 Isolate 注册服务端口
主 Isolate 启动后创建 ReceivePort,再通过固定名称暴露它的 SendPort:
dart
static const String _portName =
'com.example.app/main_isolate_data_bridge.v1';
static ReceivePort? _receivePort;
static StreamSubscription<dynamic>? _subscription;
static bool _registered = false;
static Future<void> register({bool force = false}) async {
if (_registered && !force) {
return;
}
if (_registered || force) {
await unregister();
}
_receivePort = ReceivePort();
IsolateNameServer.removePortNameMapping(_portName);
final bool success = IsolateNameServer.registerPortWithName(
_receivePort!.sendPort,
_portName,
);
if (!success) {
_receivePort?.close();
_receivePort = null;
return;
}
_subscription = _receivePort!.listen(_onRequest);
_registered = true;
}
这段实现解决了三个问题:
- 重复调用时保持幂等。
- 注册前清理可能残留的旧映射。
- 监听消息前保留
ReceivePort和订阅,避免端口被提前释放。
这样即使主通道暂时不可用,后续降级逻辑仍然可以读取本地 Token。
五、用临时 ReplyPort 实现请求---响应
后台 Isolate 发起请求时,先查找主端口:
dart
final SendPort? mainPort = IsolateNameServer.lookupPortByName(_portName);
然后为当前请求创建独立的 ReceivePort:
dart
final ReceivePort response = ReceivePort();
mainPort.send(<String, Object?>{
'reply': response.sendPort,
'key': key.name,
});
final dynamic result = await response.first.timeout(timeout);
消息中的 reply 相当于这个请求的回信地址。
主 Isolate 收到消息后,通过对应的 SendPort 返回结果:
dart
static void _onRequest(dynamic message) {
if (message is! Map) {
return;
}
final SendPort? replyPort = message['reply'] as SendPort?;
if (replyPort == null) {
return;
}
final String? keyName = message['key'] as String?;
if (keyName == null) {
return;
}
try {
replyPort.send(_readMainByKeyName(keyName));
} catch (_) {
replyPort.send(null);
}
}
每次请求拥有独立的 ReplyPort,因此不需要额外维护请求 ID、回调表或 Completer 映射,并发请求也不会互相串线。
请求结束后一定要关闭端口:
dart
try {
// 发送并等待响应
} finally {
response.close();
}
六、不只共享数据,还可以代理主端能力
这套桥接机制也可以看作一个轻量级 RPC。
除了读取 Token,还可以允许后台 Isolate 请求主 Isolate 执行一些命令:
dart
mainPort.send(<String, Object?>{
'reply': response.sendPort,
'command': 'openFile',
'fileAddr': 'https://xxxxx/x.pdf',
});
主 Isolate 根据 command 分发任务:
dart
static Future<void> _handleCommand(
String command,
Map<String, Object?> message,
SendPort replyPort,
) async {
switch (command) {
case 'openFile':
try {
//执行的命令
replyPort.send(result);
} catch (_) {
replyPort.send(null);
}
return;
default:
replyPort.send(null);
}
}
这样做的核心价值是:定位插件始终由主 Isolate 统一调用,后台 Isolate 只负责发出请求和处理结果。
七、超时与降级策略
跨 Isolate 调用不能无限等待,因此每个请求都需要超时控制。
当前实现采用分层降级:
| 场景 | 处理方式 |
|---|---|
| 主端口不存在 | 执行调用方提供的 fallback |
| 请求超时 | 根据数据键执行内置 _timeoutFallback |
| 发送、接收或类型转换异常 | 优先执行调用方 fallback |
| 定位命令失败 | 返回 null |
| 模拟定位检测失败 | 返回 false |
| 请求结束 | 在 finally 中关闭临时端口 |
例如 Token 可以降级到本地存储:
dart
static Future<String?> getAccessToken() {
return read<String?>(
MainIsolateDataKey.accessToken,
fallback: () => SpUtil.getString(
SpKey.accessToken,
defValue: null,
),
);
}
这种设计符合后台任务的一项重要原则:通信失败应该可控,不能让后台调度永久阻塞。
八、处理热重载和生命周期恢复
热重载后,旧端口映射可能失效或指向旧监听对象,因此实现中提供了强制重新注册:
dart
static Future<void> reregister() {
return register(force: true);
}
在 Flutter 的 reassemble 阶段重新绑定:
dart
@override
void reassemble() {
super.reassemble();
unawaited(MainIsolateDataBridge.reregister());
}
应用重新进入前台时,也可以再次注册:
dart
if (state == AppLifecycleState.resumed) {
unawaited(MainIsolateDataBridge.reregister());
}
这能提高开发阶段和生命周期切换后的通道可用性。
九、总结
MainIsolateDataBridge 本质上是一个基于 Dart Port 的轻量级 RPC:
IsolateNameServer负责发现主端口。SendPort负责发送请求。- 每次请求创建独立
ReceivePort接收响应。 - 主 Isolate 提供内存数据和插件能力。
- 超时、异常和端口缺失时执行降级。
- 热重载与生命周期恢复时重新注册。
它解决的不只是"后台 Isolate 怎样获取 Token",而是提供了一种更通用的架构:
将必须由主 Isolate 管理的数据和能力集中暴露,其他 Isolate 通过明确、可超时、可降级的消息协议进行访问。
当 Flutter 应用逐渐加入后台任务、前台服务、数据解析和多 Isolate 调度时,这种边界清晰的通信桥会比复制全局状态更加可靠。