从先前的通道性能测试结果来看,基于 FFI 构建的平台通道得益于共享内存的特性,其在传输效率上仍较官方通道更胜一筹。因此,使用相同的思路,以传递内部指针代替值拷贝,我们可以构建一套平台通道来替代官方提供的通道,来获得更高的通信效率。
局限性
就目前 Flutter 及 Dart 所提供的能力来看,在不修改引擎或 Dart VM 的情况下仍不足以构建完整的基于共享内存实现的平台通道,主要有以下几方面原因。
- 尽管官方已经通过 NativeApi 暴露了部分接口,Dart FFI 中尚无法访问完整的 Dart VM API,导致我们无法从提取对象的原始指针进行传递。
- Dart 运行时的垃圾回收器不具备 Pinning 机制,无法保持对象在内存中的位置,带来内存安全风险。
但考虑到在大部分场景下,平台通道都被用于从平台侧取得数据并用于 Dart 侧的业务和渲染,请求的数据量远小于接收的数据量,单向通道的实现仍能赋能这一场景,带来一定的收益。
同步通道
由于 Dart 的 FFI 函数是同步调用,实现同步通道的复杂度相对较小,不需要关心 Dart Isolate 的消息机制。以下是大致通道链路(以 Android 为例)。
- Dart:在 C 堆上划分内存,拷贝请求数据,指针通过FFI传递给C函数。
...
msgPtr = allocate<Uint8>(count: message.length);
msgPtr.asTypedList(message.length).setAll(0, message);
...
_sendSynchronousMessageToPlatform(channel, length, msgPtr);
...
- C++:将线程 Attach 到 JVM,从原始指针创建 DirectByteBuffer,通过 JNI 调用 JVM 函数进行响应。
...
auto message = env->NewDirectByteBuffer(message, length);
auto result = env->CallStaticObjectMethod(handle_message_from_dart_class_,
handle_message_from_dart_method_,
channel, message);
...
- JVM:从已注册的 handlers 中找到对应的 handler 进行响应。
private fun handleMessageFromDart(channel: Long, message: ByteBuffer?): ByteBuffer? {
...
val handler = messageHandlers[channel]
return if (handler != null) {
handler.onMessage(message)
} else {
...
null
}
}
- JVM:以 DirectByteBuffer 形式返回响应结果。
...
override fun onMessage(message: ByteBuffer?): ByteBuffer? {
...
return ByteBuffer.allocateDirect(size)
}
...
- C++:从 DirectByteBuffer 中获取原始指针,返回 Dart。
auto ret = new struct SynchronousResultWrapper;
if (result != nullptr) {
ret->data = static_cast<uint8_t *>(env->GetDirectBufferAddress(result));
ret->length = env->GetDirectBufferCapacity(result);
...
}
return ret;
- Dart:得到数据,完成通道调用。
var wrappedResult =
_sendSynchronousMessageToPlatform(channel, length, msgPtr);
Uint8List? result;
if (!wrappedResult.isNull) {
result = wrappedResult.data;
...
}
...
return result;
为了便于使用,这些功能被封装在 SynchronousNativeBinaryMessenger
和 SynchronousMethodChannel
中,它们拥有与官方 BinaryMessenger 和 MethodChannel 相似的 API。在使用 FFI 基于共享内存实现的几种通道中,同步通道拥有最高的通道性能,但受限于同步调用的机制,它不应该被用于在 UI 线程中传输大量数据(可能导致 UI 阻塞)。
异步通道
异步通道是官方通道的实现形式,但在实际依照上述思路进行实现时却存在一个致命的问题。
由于 Dart FFI 是同步调用,我们必须在消息经过 FFI 调用到达 C++ 层时迅速将任务转交给其他线程执行后将当前线程控制权交回 Dart 以避免阻塞。在 Flutter 的线程模型下,所有平台通道的调用都将被转交给 Platform Task Runner(运行在 Plarform 线程)完成,而 Platform 线程则是在引擎初始化时指定的,在 FFI 下并不可见,无法进行复用。
...
std::thread([=](){
SendMessageToPlatform(channel, seq, length, *data);
}).detach();
...
在这段示例中,我们创建了一个线程用于执行平台侧的代码,但这意味着每次平台通道的调用都需要创建一个新的线程,而线程创建的开销是相对较大的,在我们的测试中,它几乎能够抹平FFI和共享内存所带来的性能优势,使用起来得不偿失。因此,我们放弃了标准异步通道的实现。
并行通道
从 Flutter 的线程模型可以看出,所有的异步平台调用都以 Task 形式交给 Platform 线程完成,而 Task 的执行是同步的,这意味着在平台侧直接执行一些耗时操作将导致 Platform 线程被阻塞,后续的调用任务将被搁置,且单线程队列执行的方式无法充分利用 CPU 并行性能。因此,我们选择维护一个线程(协程)池来并行执行平台调用任务。
...
for (unsigned i = 0; i < concurrency; i++) {
threads_.emplace_back(SendConcurrentMessageToPlatformWorker);
}
...
- Dart:与同步通道一致,并额外赋予每个请求唯一的序列号用于异步响应。
- C++:将请求加入队列,唤醒线程。
...
message_queue_.push({channel, {seq, length, data}});
condition_variable_.notify_one();
- C++:Worker 从队列中取出请求,通过 JNI 调用 JVM 函数进行响应。
...
auto msg = message_queue_.front();
...
JniEnv env;
jobject message = env->NewDirectByteBuffer(msg.message.data, msg.message.length);
env->CallStaticVoidMethod(handle_message_from_dart_class_,
handle_message_from_dart_method_, msg.channel, message,
msg.message.seq);
- JVM:从已注册的 handlers 中找到对应的 handler 进行响应。
private fun handleMessageFromDart(channel: Long, message: ByteBuffer?, seq: Long) {
...
val handler = messageHandlers[channel]
if (handler != null) {
try {
handler.onMessage(message, Reply(seq, message))
} catch (ex: Exception) {
...
}
}
...
}
- JVM:以 DirectByteBuffer 形式返回响应结果。
...
override fun onMessage(message: ByteBuffer?, reply: Reply) {
...
reply.reply(ByteBuffer.allocateDirect(size))
}
...
- C++:从 DirectByteBuffer 中获取原始指针,以通过 Isolate Port 发送回 Dart。
...
if (reply != nullptr) {
ret->data = static_cast<uint8_t *>(env->GetDirectBufferAddress(reply));
ret->length = env->GetDirectBufferCapacity(reply);
...
}
Dart_PostInteger_DL(replyPort_, reinterpret_cast<int64_t>(ret));
- Dart:取得结果,根据序列号找到调用时注册的 Completer 并完成。
...
var data = wrappedResult.data;
_resultCompleters.remove(seq)?.complete(data);
...
类似的,这些功能被封装在 ConcurrentNativeBinaryMessenger
和 ConcurrentMethodChannel
中,它们也拥有与官方 BinaryMessenger 和 MethodChannel 相似的 API。并行通道在业务层无感的情况下赋予了平台通道调用并行的能力,虽然异步调用在返回值的投送和序列号查找上仍有一定开销,但在 MethodChannel 模拟测试(20次,每次接收1M数据)下,并行通道的性能仍能达到官方通道的两倍左右。
垃圾回收
在使用上述通道时,传输的数据实际上并不安全,因为它以引用形式在两个不同运行时(JVM、Dart)的环境下传递数据。设想一下,在 JVM 将数据返回后,若该数据在 JVM 中没有其他引用,则下次 GC 运行时该数据就将被回收,这将导致 Dart 侧得到的指针失效,造成非法访问。所幸,JVM GC 提供的全局引用机制和 Dart VM 提供的 Finalizer 机制能够很好的帮助我们处理共享对象的垃圾回收问题,其生命周期如下。
- 在数据返回后,向 JVM 注册对该数据的全局引用,GC 将保证该对象不被回收,对于
DirectByteBuffer
, 其内部指针将保持有效。
...
env->NewGlobalRef(reply);
...
- Dart收到响应数据后,在将数据返回给调用者前为对象注册 Finalizer。
...
result = wrappedResult.data;
registerFinalizer(
result, // Dart 对象
wrappedResult.ref._data, // 内部指针
wrappedResult.ref.length, // 对象大小
);
... // 返回给调用者
- 对象被 Dart GC 回收,Finalizer 被触发,通知 JVM 释放全局引用。
...
env->DeleteGlobalRef(object);
...
- 对象被 JVM GC 回收,内存释放。
至此,我们可以保证在 Dart 对象有效时,其内部指针始终有效。但是,在实际测试过程中,我们发现由于 JVM GC 的调度问题,许多对象在其全局引用被移除后并没有及时地被回收,在分配的对象较大时容易出现内存溢出的情况。对此,我们在 Finalizer 释放全局引用后强制唤醒 JVM GC,情况有较大缓解,但是频繁的 GC 也容易对性能造成影响,在这方面仍然有待改进。
上述实现已经封装成 Flutter 插件共开发者们使用,访问 Github 仓库以获取源代码。