发布时间:2021年6月15日 - 9分钟阅读
在过去的几年里,我一直对 "我们如何使Flutter和它的主机平台之间的通信更快、更容易 "的问题感兴趣。这是一个让Flutter插件开发者和附加应用开发者特别感兴趣的问题。
Flutter和主机平台之间的通信通常是通过平台通道完成的,所以我的精力一直集中在这里。在2019年底,为了补救使用平台通道所需的大量模板和严格的类型化代码,我设计了一个codegen包,Pigeon,使平台通道类型安全,并且团队继续改进它。在2020年春天,我对平台通道和外国函数接口(FFI)的性能进行了审计。现在,我已经把目光投向了提高平台通道的性能。因为Pigeon是建立在平台通道之上的,而且我计划在Pigeon之上为多个Flutter实例建立一个数据同步解决方案,这是一个很好的机会,可以帮助满足开发者的许多不同需求,也是我的倡议。
经过一些调查,我能够识别在平台通道上发送的多余的数据副本,并能够将它们删除。下面你会看到这一变化的结果,以及导致识别和删除这些副本的工作概述。
结果
当从Flutter向主机平台发送1MB的二进制数据并有1MB的响应时,删除多余的副本后,我们看到iOS上的性能提高了约42%。在安卓系统上,结果更细微一些。在迁移到新的BinaryCodec.INSTANCE_DIRECT编解码器时,我们的自动化性能测试提高了约15%,而本地测试看到约52%的增长。这种差异可能是因为自动化性能测试是在一个较旧的设备上运行的,但这种差异也可能是微观测试,特别是在一个较旧的设备上如何执行的工件(例如,锤击垃圾回收器)。你可以在 platform_channels_benchmarks/lib/main.dart 找到自动化性能测试的源代码。
对于使用StandardMessageCodec的平台通道,我看到的性能提升较少(在14k有效载荷下约为5%)。我用一大批支持的类型进行了测试,对编码和解码进行了压力测试。我发现,MessageCodecs的编码和解码时间与在平台之间复制消息的时间相比相形见绌。大部分的编码时间是由于遍历数据结构和使用反射来弄清其内容的成本。
所以,你的里程可能会有所不同,这取决于你如何使用平台通道和你的设备。如果你想用平台通道进行最快的通信,那么你应该在iOS上使用带有FlutterBinaryCodec的BasicMessageChannels,在Android上使用BinaryCodec.INSTANCE_DIRECT,并开发你自己的编码和解码消息的协议,不依赖反射。实现一个新的MessageCodec可能会更干净)。
如果你想玩新的更快的平台通道,它们现在可以在主通道上使用。
拷贝删除的细节
如果你对深入了解我是如何实现这些结果的,以及我必须克服的问题不感兴趣,现在就不要再读了。如果你喜欢了解这些细节,请继续阅读。
自2017年以来,平台频道的API没有什么变化。因为平台通道是引擎和插件运行的基础,所以它们不容易改变。虽然我对平台通道的运作有一个大致的概念,但它们有些错综复杂。因此,改善其性能的第一步是了解它们到底是做什么的。
下图概述了框架在使用平台通道从Flutter与iOS通信时遵循的原始流程。
从图中可以得到一些启示。
- 消息从UI线程跳转到平台线程,再回到UI线程。(用Flutter引擎的说法,UI线程是执行Dart的地方,而平台线程是主平台的主线程。)
- 消息和它的响应使用C++作为Flutter和主机平台的目标语言之间沟通的中间层。
- 消息的信息在到达Objective-C(Obj-C)处理程序之前被复制了4次(步骤3、5、7、8)。步骤3和8进行翻译,而步骤5和8进行复制,将数据的所有权转移到一个新的内存布局。同样的过程以相反的方式重复用于回复。
- 步骤1、9和16是由使用Flutter的开发人员编写的代码。
从Flutter向Java/Kotlin发送消息是类似的,只是在C++和Java虚拟机(JVM)之间有一个Java Native Interface(JNI)层。
在确定了平台通道是如何工作的之后,很明显,消除在这些层之间传输数据时的拷贝(例如从C++到Obj-C)是提高性能的一个明显方法。为了实现这一点,Flutter引擎必须将数据放在内存中,其方式是可以直接从Java/Obj-C中访问,并具有与主机平台兼容的内存管理语义。
平台通道的消息最终被主机平台的MessageCodec的decodeMessage方法所消耗。在Android上,这意味着一个ByteBuffer,而在iOS上,这意味着NSData。C++中的数据需要符合这些接口。当接近这个问题时,我发现消息的信息以std::vector的形式驻留在C++内存中,在一个PlatformMessage对象内,由一个共享指针维护。这意味着开发人员在将数据从C++发送到主机平台时无法安全地删除副本,因为他们无法保证数据在被移交给主机平台后不会被C++变异。此外,我不得不小心,因为BinaryCodec的实现将encodeMessage和decodeMessage视为无用的,这可能导致使用BinaryCodec的代码在不知不觉中直接接收ByteBuffer。虽然不太可能有人对MessageCodec的变化感到惊讶,但很少有人实现自己的编解码器。另一方面,使用BinaryCodecs则是非常普遍的。
在读完代码后,我发现,虽然PlatformMessage是由一个共享指针管理的,但从语义上讲,它是一个唯一的指针。其目的是在同一时间只有一个客户端可以访问它(这并不完全是事实,因为在线程之间传递PlatformMessage时,会有多个副本存在,但这只是为了方便,实际上并不打算如此)。这意味着我们可以从共享指针迁移到唯一指针,使我们能够安全地将数据传递给主机平台。
迁移到唯一指针后,我必须找到一种方法,将信息的所有权从C++传递到Obj-C。(我首先实现了Obj-C,我将在后面详细讨论Java。)信息被存储在std::vector中,它没有办法释放底层缓冲区的所有权。你唯一的选择是复制出数据,提供一个拥有std::vector的适配器,或者取消对std::vector的使用。
我的第一个尝试是对NSData进行子类化,将std::移动std::vector并从那里读取其数据,从而消除了拷贝。这个尝试并不顺利,因为事实证明,NSData在Foundation中是一个类群。这意味着你不能只是对NSData进行子类化。在阅读了苹果公司的许多文档之后,他们的建议似乎是使用组合和消息转发来使一个对象表现得看起来像一个NSData。这将欺骗那些使用代理对象的人,除了那些调用-[NSObject isKindOfClass:]的人。虽然这不太可能,但我不能排除这种可能性。虽然我认为在Obj-C运行时可能会有一些摆弄,可以使对象表现出我想要的方式,但这越来越复杂。我选择了将内存从std::vector中移出,移到我们自己的缓冲区类中,允许释放数据的所有权。这样,我就可以使用-[NSData dataWithBytesNoCopy:length:]来将数据的所有权转移到Obj-C。
在Android上复制这个过程被证明是有点困难的。在Android上,平台通道符合ByteBuffer,它有直接ByteBuffers的概念,它允许Java代码直接与以C/C++风格布置的内存对接。在很短的时间内,我实现了向直接ByteBuffers的转移,但我没有看到我所期望的改进。我花了很多时间学习Android剖析工具,当这些工具失败或返回我无法相信的东西时,我最终选择了跟踪语句。结果发现,在UI线程上从平台线程调度对平台通道消息的回复是非常缓慢的,而且它的缓慢程度似乎是随着消息的有效载荷而变化的。长话短说,我在编译Dart虚拟机时使用了不正确的编译标志,我认为--无优化意味着没有链接时优化,但该标志实际上是用于运行时优化。
在我发现我的失误的兴奋中,我忘记了在向Flutter客户端代码发送数据时直接使用ByteBuffer的后果,特别是通过自定义MessageCodecs或BinaryCodec的客户端。发送直接的ByteBuffer意味着你有一个与C/C++内存通信的Java对象,所以如果你删除了C/C++内存,那么Java就会与随机的垃圾进行交互,并可能会因为操作系统的访问违规而崩溃。
按照iOS的例子,我试图将C/C++内存的所有权传递给Java,这样,当Java对象被垃圾回收时,它就会删除C/C++内存。事实证明,当直接ByteBuffer是通过NewDirectByteBuffer从JNI中创建的时候,这样做是不可能的。JNI没有提供任何钩子来知道一个Java对象何时被删除。你不能对ByteBuffer进行子类化,使其在最终确定时调用JNI。唯一的希望是在前述图表中的第5步从Java API中分配直接ByteBuffer。通过Java分配的直接ByteBuffers就没有这个限制。然而,在Java中引入一个新的入口点将是一个巨大的变化,任何使用过JNI的人都知道这是危险的。
相反,我选择了向团队请愿,让他们在解码消息的调用中直接接受ByteBuffers。起初,我给MessageCodec引入了一个新的方法,bool wantsDirectByteBufferForDecoding(),以确保没有人得到直接的ByteBuffer,除非他们要求并知道它们的语义(也就是说,当底层的C/C++内存仍然有效)。这被证明是复杂的,而且担心的是,开发人员可能仍然订阅,但不知道直接ByteBuffers的语义,因为它们的操作与典型的ByteBuffers相反,而且可能已经删除了它们下面的C内存支持。存储编码缓冲区是在不可能的使用之上的非典型使用,但团队不能排除它。经过多次讨论和协商,我们决定每个MessageCodec都得到一个直接的ByteBuffer,在DecodeMessage被调用后被清除掉。这样一来,如果有人缓存了编码后的消息,那么在C语言底层内存被清理后,如果他们试图使用ByteBuffer,就会在Java中得到一个确定的、贴切的错误。
让每个人都能获得直接ByteBuffers的性能提升,效果很好,但这是对BinaryCodec的破坏性改变,BinaryCodec的encodeMessage和decodeMessage实现是无用的,它们只是将其输入作为其返回值。为了保持BinaryCodec的内存语义不变,我引入了一个新的实例变量,控制解码后的消息是直接的ByteBuffer(新的、更快的代码)还是标准的ByteBuffer(旧的、更慢的代码)。我们无法创造一种方法,让BinaryCodec的所有客户端都能获得性能上的加速。
未来的工作
现在,消除拷贝的工作已经完成,我接下来的工作是改善Flutter和主机平台之间的通信。
- 为Pigeon实现一个自定义的MessageCodec,它不依赖反射来实现更快的编码和解码。
- 实现FFI平台通道,允许你从Dart调用到主机平台,而无需在UI和平台线程之间跳转。
我希望你喜欢这个对性能改进细节的深入研究!