做者:闲鱼技术-福居java
性能稳定性是App的生命,Flutter带了不少创新与机遇,然而团队在享受Flutter带来的收益同时也迎接了不少新事物带来的挑战。git
本文就内存优化过程当中一些实践经验跟你们作一个分享。github
闲鱼使用一套混合栈管理的方案将Flutter嵌入到现有的App中。在产品体验上咱们取得了优于Native的体验。主要得益于Flutter的在跨平台渲染方面的优点,部分缘由则是由于咱们用Dart语言从新实现的页面抛弃了不少历史的包袱轻装上阵。算法
上线以后各方面技术指标,都达到甚至超出了部分预期。而咱们最为担忧的一些稳定性指标,好比crash也在稳定的范围以内。可是在一段时间后咱们发现因为内存太高而被系统杀死的abort率数据有比较明显的异常。性能稳定性问题是很是关键的,因而咱们火速开展了问题排查。api
显然问题出在了过大的内存消耗上。内存消耗在App中构成比较复杂,如何在复杂的业务中去定位到罪魁祸首呢?稍加观察,咱们肯定Flutter问题相对比价明显。工欲善其事必先利其器,须要更好地定位内存的问题,善用已经的工具是很是有帮助的。好在咱们在Native层和Dart层都有足够多的性能分析工具进行使用。缓存
这里简单介绍咱们如何使用的工具去观察手机数据以便于分析问题。须要注意的是,本文的重点不是工具的使用方法介绍,因此只是简单列举部分使用到的常见工具。bash
Instruments是iOS内存排查的利器,能够比较便捷地观察实时内存使用状况,天然没必要多说。闭包
XCode 8以后推出的MEMGraph是Xcode的内存调试利器,能够看到实时的可视化的内存。更为方便的是,你能够将MemGraph导出,配合命令行工具更好的获得结构化的信息。异步
这是Dart语言官方的调试工具,里面也包含了相似于Xcode的Instruments的工具。在Debug模式下Dart VM启动之后会在特定的端口接受调试请求。官方文档async
在整个过程当中我进行了大量的观察,这里分享一部分典型的数据表现。
经过Xcode Instruments排查的话,咱们观察到CG Raster Data这个数据有些高。这个Raster Data呢实际上是图片光栅化的时候的内存消耗。
咱们将App内存异常的场景的MemGraph导出来,对其执行VMMap指令得出的结果:
vmmap --summary Runner[40957].memgraph
vmmap Runner[40957].memgraph | grep 'IOKit'
复制代码
咱们主要关注resident和dirty的内存。发现IOKit占用了大量的内存。
结合Xcode Raster Data还有IOKit的大量内存消耗,咱们开始怀疑问题是图内存泄漏致使的。通过进一步经过Dart Observatory观察Dart Image对象的内存状况。
这个结果,我估计不少人都已经想到了,App有明显的内存问题颇有可能就是跟多媒体资源有关系。经过工具得出的准确数据线索,咱们获得一个大体的方向去深刻研究。
前面咱们用工具观察到Dart层的Image对象数量过多直接致使了很是大的内存压力,咱们起初怀疑存在图片的内存泄漏。可是咱们在通过进一步确认之后发现图片其实并无真正的泄漏。
Dart语言采用垃圾回收机制(Garbage Collection 下面开始简称GC)来管理分配的内存,VM层面的垃圾回收应该大多数状况下是可信的。可是从实际观察来看,图片数量的爆炸形成的较大的内存峰值直观感受上GC来得有些不及时。在Debug模式下咱们使用Dart Observatory手动触发GC,最终这些图片对象在没有引用的状况下最终仍是会被回收。
至此,咱们基本能够确认,图片对象不存在泄漏。那是什么致使了GC的反应迟钝呢,难道是Dart语言自己的问题吗?
为此我须要了解一下Dart内存管理机制垃圾回收的实现,关于详细的内存问题我团队的 @匠修 同窗已经发过一篇相关文章能够参考:内存文章
我这里不详细讨论Dart垃圾回收实现细节,只聊一聊Flutter与Dart相关的一些内容。
Framework(Dart)(跟iOS平台链接的库Flutter.framework要区别开)特指由Dart编写的Flutter相关代码。
Dart VM执行Dart代码的Dart语言相关库,它是以C实现的Dart SDk形式提供的。对外主要暴露了C接口Dart Api。里面主要包含了Dart的编译器,运行时等等。
FLutter Engine C++实现的Flutter驱动引擎。他主要负责跨平台的绘制实现,包含Skia渲染引擎的接入;Dart语言的集成;以及跟Native层的适配和Embeder相关的一些代码。简单理解,iOS平台上面Flutter.framework, Android平台上的Flutter.jar即是引擎代码构建后的产物。
在Dart代码里面对于GC是没有感知的。
对于Dart SDK也就是Dart语言咱们能够作的颇有限,由于Dart语言自己是一种标准,若是Dart真的有问题咱们须要和Dart维护团队协做推动问题的解决。Dart语言设计的时候初衷也是但愿GC对于使用者是透明的,咱们不该该依赖GC实现的具体算法和策略。不过咱们仍是须要经过Dart SDK的源码去理解GC的大体状况。
既然咱们前面已经确认并不是内存泄漏,因此咱们在对GC延迟的问题的调查主要放在Flutter Engine以及Dart CG入口上。
既然感受GC不及时,先撇开消耗,咱们至少能够尝试多触发几回GC来减轻内存峰值压力。可是我在仔细查阅dart_api.h(/src/third_party/dart/runtime/include/dart_api.h
)接口文件后,可是并无找到显式提供触发GC的接口。
可是找到了以下这个方法Dart_NotifyIdle
:
/** * Notifies the VM that the embedder expects to be idle until |deadline|. The VM * may use this time to perform garbage collection or other tasks to avoid * delays during execution of Dart code in the future. * * |deadline| is measured in microseconds against the system's monotonic time. * This clock can be accessed via Dart_TimelineGetMicros(). * * Requires there to be a current isolate. */
DART_EXPORT void Dart_NotifyIdle(int64_t deadline);
复制代码
这个接口意思是咱们能够在空闲的时候显式地通知Dart,你接下来能够利用这些时间(dealine以前)去作GC。注意,这里的GC不保证会立刻执行,能够理解咱们请求Dart去作GC,具体作不作仍是取决于Dart自己的策略。
另外,我还找到一个方法叫作Dart_NotifyLowMemory
:
/** * Notifies the VM that the system is running low on memory. * * Does not require a current isolate. Only valid after calling Dart_Initialize. */
DART_EXPORT void Dart_NotifyLowMemory();
复制代码
不过这个Dart_NotifyLowMemory方法其实跟GC没有太大关系,它实际上是在低内存的状况下把多余的isolate去终止掉。你能够简单理解,把一些不是必须的线程给清理掉。
在研究Flutter Engine代码后你会发现,Flutter Engine其实就是经过Dart_NotifyIdle去跟Dart层进行GC方面的协做的。咱们能够在Flutter Engine源码animator.cc看到如下代码:
//Animator负责刷新和通知帧的绘制
if (!frame_scheduled_) {
// We don't have another frame pending, so we're waiting on user input
// or I/O. Allow the Dart VM 100 ms.
delegate_.OnAnimatorNotifyIdle(*this, dart_frame_deadline_ + 100000);
}
//delegate 最终会调用到这里
bool RuntimeController::NotifyIdle(int64_t deadline) {
if (!root_isolate_) {
return false;
}
tonic::DartState::Scope scope(root_isolate_.get());
//Dart api接口
Dart_NotifyIdle(deadline);
return true;
}
复制代码
这里的逻辑比较直观:若是当前没有帧渲染的任务时候就经过NotifyIdle
告诉Dart层能够进行GC操做了。注意,这里并非说只有在这种状况下Dart才回去作GC,Flutter只是经过这种方式尽量利用空闲去作GC,配合Dart以更合理的时间去作GC。
看到这里,咱们有足够的理由去尝试一下这个接口,因而咱们在一些内存压力比较大的场景进行了手动请求GC的操做。线上的Abort虽然有明显好转,可是内存峰值并无所以获得改善。咱们须要进一步找到根本缘由。
为了肯定图片大量囤积释放不及时的问题,咱们须要跟踪Flutter图片从初始化到销毁的整个流程。
咱们从Dart层开始去追寻Image对象的生命周期,咱们能够看到Flutter里面因此的图片都是通过ImageProvider来获取的,ImageProvider在获取图片的时候会调用一个Resolve接口,而这个接口会首先查询ImageCache去读取图片,若是不存在缓存就new Image的实例出来。
关键代码:
ImageStream resolve(ImageConfiguration configuration) {
assert(configuration != null);
final ImageStream stream = new ImageStream();
T obtainedKey;
obtainKey(configuration).then<void>((T key) {
obtainedKey = key;
stream.setCompleter(PaintingBinding.instance.imageCache.putIfAbsent(key, () => load(key)));
}).catchError(
(dynamic exception, StackTrace stack) async {
FlutterError.reportError(new FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'services library',
context: 'while resolving an image',
silent: true, // could be a network error or whatnot
informationCollector: (StringBuffer information) {
information.writeln('Image provider: $this');
information.writeln('Image configuration: $configuration');
if (obtainedKey != null)
information.writeln('Image key: $obtainedKey');
}
));
return null;
}
);
return stream;
}
复制代码
大体的逻辑
Flutter ImageCache最初的版本其实很是简单,用Map实现的基于LRU算法缓存。这个算法和实现没有什么问题,可是要注意的是ImageCache缓存的是ImageStream对象,也就是缓存的是一个异步加载的图片的对象。并且缓存没有对占用内存总量作限制,而是采用默认最大限制1000个对象(Flutter在0.5.6 beta中加入了对内存大小限制的逻辑)。缓存异步加载对象的一个问题是,在图片加载解码完成以前,没法知道到底将要消耗多少内存,至少在Flutter这个Cache实现中没有处理这个问题。具体的实现感兴趣的朋友能够阅读ImageCache.dart源码。
其实Flutter自己提供了定制化Cache的能力,因此优化ImageCache的第一步就是要根据机型的物理内存去作缓存大小的适配,设置ImageCache的合理限制。关于ImageCache的问题,能够参考官方文档和这个issue,我这里不展开去聊了。
回到咱们的Image对象跟踪,很明显,在缓存没有命中的状况下会有新的Image产生。继续深刻代码会发现Image对象是由这段代码产生的:
Future<Codec> instantiateImageCodec(Uint8List list) {
return _futurize(
(_Callback<Codec> callback) => _instantiateImageCodec(list, callback, null)
);
}
String _instantiateImageCodec(Uint8List list, _Callback<Codec> callback, _ImageInfo imageInfo) native 'instantiateImageCodec';
复制代码
这里有个native关键字,这是Dart调用C代码的能力,咱们查看具体的源码能够发现这个最终初始化的是一个C++的codec对象。具体的代码在Flutter Engine codec.cc。它大体的过程就是先在IO线程中启动了一个解码任务,在IO完成以后再把最终的图片对象发回UI线程。关于Flutter线程的详细介绍,我在另一篇文章中已经有介绍,这里附上连接给有兴趣的朋友。深刻理解Flutter Engine线程模型。通过来这些代码和线程分析,咱们获得大体的流程图:
也就是说,解码任务在IO线程进行,IO任务队列里面都是C++ lambda表达式,持有了实际的解码对象,也就持有了内存资源。当IO线程任务过多的时候,会有不少IO任务在等待执行,这些内存资源也被闭包所持有而等待释放。这就是为何直观上会有内存释放不及时而形成内存峰值的问题。这也解释了为何以前拿到的vmmap虚拟内存数据里面IOKit是大头。
这样咱们找到了关键的线索,在缓存不命中的状况下,大量初始化Image对象,致使IO线程任务繁重,而IO又持有大量的图片解码所用的内存资源。带这个推论,我在Flutter Engine的Task Runner加入了任务数量和C++ image对象的监控代码,证明了的确存在IO任务线程过载的状况,峰值在极端状况下瞬时达到了100+IO操做。
到这里问题彷佛愈来愈明了了,可是为何会有这么IO任务触发呢?上述逻辑虽然可能会有IO线程过载的状况下占用大量内存的状况。上层要求生成新的图片对象,这种请求是没有错误的,设计就是如此。就比如主线程阻塞大量的任务,必然会致使界面卡顿,但者却不是主线程自己的问题。咱们须要从源头找到致使新对象建立暴涨真正致使IO线程过载的缘由。
在前面的线索之下,咱们继续寻找问题的根源。咱们在实际App操做的过程中发现,页面Push的越多,图片生成的速度愈来愈快。也就是说页面越多请求越快,看起来没有什么大问题。可是可见的图片其实老是在必定数量范围以内的,不该该随着页面增多而加快对象建立的频率。咱们下意识的开始怀疑是否存在不可见的Image Widget也在不断请求图片的状况。最终致使了Cache没法命中而大量生成新的图片的场景。
我开始调查每一个页面的图片加载请求,咱们知道Flutter里面万物皆Widget,页面都是是Widget,由Navigator管理。我在Widget的生命周期方法(详细见Flutter官方文档)中加入监控代码,如我所料,在Navigator栈底下不可见的页面也还在不停的Resolve Image,直接致使了image对象暴涨而致使IO线程过载,致使了内存峰值。
看起来,咱们终于找到了根本缘由。解决方案并不难。在页面不可见的时候不必发出多余的图片加载请求,峰值也就随之降下来了。再通过一番代码优化和测试之后问题获得了根本上的解决。优化上线之后,咱们看到了数据发生了质的好转。 有朋友可能想问,为何不可见的Widget也会被调用到相关的生命周期方法。这里我推荐阅读Flutter官方文档关于Widget相关的介绍,篇幅有限我这里不展开介绍了。widgets
至此,咱们已经解决了一个较为严重的内存问题。内存优化状况复杂,能够点也比较多,接下来我继续简要分享在其它一些方面的优化方案。
咱们是采用嵌入式Flutter并使用一套混合栈模式管理Native和Flutter页面相互跳转的逻辑。因为FlutterView在App中是单例形式存在的,咱们为了更好的用户体验,在页面切换的过程当中使用的截图的方式来进行过渡。
你们都知道,图片是很是占用内存的对象,咱们如何在不下降用户体验的同时得到最小的内存消耗呢?假如咱们每push一个页面都保存一张截图,那么内存是以线性复杂度增加的,这显然不够好。
内存和空间在大多数状况下是一个互相转换的关系,优化不少时候实际上是找一个合理的折中点。 最终我采用了预加载+缓存的策略,在页面最多只在内存中同时存在两个截图,其它的存文件,在须要的时候提早进行预加载。 简要流程图:
这样的话就作到了不影响用户体验的前提下,将空间复杂度从O(n)下降到了O(1)。 这个优化进一步节省了没必要要的内存开销。
对于电商类App存在一个广泛的问题,用户会不断的push页面到栈里面,咱们不能阻止用户这种行为。咱们固然能够把老页面干掉,每次回退的时候从新加载,可是这种用户体验跟Web页同样,是用户不可接受的。咱们要维持页面的状态以保证用户体验。这必然会致使内存的线性增加,最终确定不免要被杀。咱们优化的目的是提升用户可以push的极限页面数量。
对于Flutter页面优化,除了在优化每个页面消耗的内存以外,咱们作了降级兜底策略去保证App的可用性:在极端状况下将老页面进行销毁,在须要的时候从新建立。这的确下降了用户体验,在极端状况下,降级体验仍是比Crash要好一些。
另外我想讨论的一个话题是关于FlutterViewController的。目前Flutter的设计是按照单例模式去运行的,这对于彻底用Flutterc从新开发的App没有太大的问题。可是对于混合型App,多出来的常驻内存确实是一个问题。
实际上,Flutter Engine底层实现是考虑到了析构这个问题,有相关的接口。可是在Embeder这一层(具体FlutterViewController Message Channels这一层),在实现过程当中存在一些循环引用,致使在Native层就算没有引用FlutterViewController的时候也没法释放.
我在通过一段时间的尝试后,算是把循环引用解除了。这些循环引用主要集中在FlutterChannel这一块。在解除以后我顺利的释放了FlutterViewController,能够明显看到常驻内存获得了释放。可是我发现释放FlutterViewController的时候会致使一部分Skia Image对象泄漏,由于Skia Objects必须在它建立的线程进行释放(详情请参考skia_gpu_object.cc源码),线程同步的问题。关于这个问题我在GitHub上面有一个issue你们能够参考。FlutterViewController释放issue
目前,这个优化咱们已经反馈给Flutter团队,期待他们官方支持。但愿你们能够一块儿探索研究。
除此以外,Flutter内存方面其实还有比较多方面能够去研究。我这里列举几个目前观察到的问题。
我在内存分析的时候发现Flutter底层使用的boring ssl库有能够肯定的内存泄漏。虽然这个泄漏比较缓慢,可是对于App长期运行仍是有影响的。我在GitHub上面提了个issue跟进,目前已有相关的人员进行跟进。SSL leak issue
关于图片渲染,目前Flutter仍是有优化空间的,特别是图片的按需剪裁。大多数状况下是没有不要将整一个bitmap解压到内存中的,咱们能够针对显示的区域大小和屏幕的分辨率对图片进行合理的缩放以取得最好的性能消耗。
在分析Flutter内存的MemGraph的时候,我发现Skia引擎当中对于TextLayout消耗了大量的内存.目前我没有找到具体的缘由,可能存在优化的空间。
在这篇文章里,我简要的聊了一下目前团队在Flutter应用内存方面作出的尝试和探索。短短一篇文章没法包含全部内容,只能推出了几个典型的案例来做分析,但愿能够跟你们一块儿探讨研究。欢迎感兴趣的朋友一块儿研究,若有更好的想法方案,我很是乐意看到你的分享。
欢迎加入闲鱼,一块儿探索Flutter更多可能。 简历投递: guicai.gxy@alibaba-inc.com