作者:一只修仙的猿
前言
关于性能优化,可能我们的第一反应是这是高手做的事情,一直以来我也是这样认为的。但在最近一段时间,在公司项目上做了一些框架的性能优化,让我初步掀开了性能优化的面纱,也对他有了进一步的认识。所以这篇文章结合我做的一些优化,做一些相关经验的分享。
性能优化一般情况下分为两类:时间优化与空间优化。前者是降低处理器处理时间,后者是降低内存使用量。归根到底都是降低对硬件资源的使用,来提高程序的性能,从而提高程序运行流畅性、降低功耗等。今天也主要从这两个方面来展开聊一聊。性能优化一般需要针对具体的代码、或者场景,所以后续的内容,我都会结合具体的场景来展开讲述,从0开始去讲述在项目中的思考过程,并总结一些性能优化的通用思路。因此,在讲性能优化之前,需要先向你介绍一下我们项目的大概内容。
了解项目
首先需要简单来了解一下我们工作的项目框架流程。我们项目的框架是一个图像处理框架,其功能如下图:
接收上层传下来的图像帧,进行渲染处理之后,将处理完成的图像返回给上层。
这是宏观上对于框架的描述。但由于这套框架,他是纯c++开发,且接口复杂,较难以理解,导致接入成本过高。因此,在原有的框架上,我们进行再一次封装,如下图:
c++接口主要对接native接入业务;对于android,则封装java接口,让接入方减少编写jni的成本。 需要注意的是,图中的Java接口以及c++接口,都是完整封装的框架层,具有自己的数据结构,向上屏蔽底层细节。
现在来观察一下整体的处理流程:
- 业务层使用相机捕获数据帧,并将数据帧传递给Java接口层
- Java接口层将数据帧,通过JNI调用,把数据传递到c++层
- JNI接口层将数据封装后传递给c++接口层
- 最后,c++接口层将数据封装后传递给底层SDK。
- 处理完成之后则反过来走完上述流程
如下图:
大概了解到这个程度就可以了。接下来详细展开我所做的几个性能优化。
优化一
由于整个框架主要是对帧数据进行处理,因此我们需要关注帧数据的处理流程。我们从相机采集开始来分析:
- 相机采集数据,并将帧数据存放在缓冲区中,这是最原始的数据
- 我们不能直接使用缓冲区中的数据,因为缓冲区是复用的,因此需要拷贝出来。这里发生一次数据拷贝,以及一帧数据内存的分配。
- 业务方将数据封装到我们的java接口数据结构,并传递到我们的框架java接口层。伪代码如下面的代码:
// 相机数据帧回调
void onCameraFrameArrive(byte[] data,int width,int hight) {
// 创建框架数据结构,Frame内部拷贝一次相机帧数据
Frame frame = new Frame(data,width,hight);
// 调用接口传递数据
sendFrame(frame);
}
- java接口通过jni,将数据传递给c++层。JNI层需要对java数据进行一次拷贝,c++不能直接持有并处理java内存数据。c++持有java内存会让内存管理变得复杂,且无法直接对java内存进行数据处理。JNI层再将数据封装后,传递给c++接口层。伪代码如下
// java代码
void sendFrame(Frame frame){
sendFrameToNative(frame);
}
private native void sendFrameToNative(Object frame);
// c++代码
void copyByteArray(jbyteArray value) {
jsize arraySize = jniEnv->GetArrayLength(value);
// 创建c++数组,并拷贝一份新的数据
auto* array = new int8_t[size];
jniEnv->GetByteArrayRegion(value, 0, arraySize, array);
}
好了,流程上先了解到这里。流程中一共发生了两次内存的分配与拷贝:
- 相机缓存不能直接使用,java业务层发生了一次拷贝;
- c++不能直接使用java内存,发生了一次从java内存到c++内存的拷贝
这里的优化思路是:我们可以让JNI直接从相机缓存中进行拷贝,而没有必要拷贝一份中间数据,这样可以减少一次内存拷贝与内存分配。我们发生拷贝的地方在于Frame类的构造函数中,优化伪代码如下:
class Frame {
private byte[] mData;
// 优化前:
public Frame(byte[] data,int width,int height) {
mData = new byte[data.length];
...
}
// 优化后
public Frame(byte[] data,int width,int height) {
mData = data;
...
}
}
通过持有内存的引用,来代替拷贝内存。那么可能有读者有疑问:那我们是不是以后都通过持有引用的方式就可以了?并不是的,还是得根据业务的内容来决定。我们这里直接持有了相机缓存的引用,那么我们必须将数据处理操作设置为同步操作,并在处理结束后解除引用,否则会造成数据错误。
在本流程中,Frame属于框架层接口。对于框架的设计,我们可以将业务层传递的byte数组,做一次拷贝,这是最安全的。不管上层传递的byte数组是否复用、是否释放等,都不会造成错误,但同时会带来一定的性能损耗。而设计为直接持有上层byte数组,意味着业务方必须了解接口参数的意义,懂得byte数组参数是被框架直接持有,如有必要,需要在外部做数据拷贝。这降低了一定的接口易用性,但也带来了更好地性能表现。另一种折中的解决方案,是创建两个不同的接口:拷贝与不拷贝,让用户决定使用哪个接口,但这也会为接口带来更高的复杂性。
最后我们来总结一下:
- 跟踪核心数据的处理流程,例子中是视频图像帧,记录数据发生拷贝、内存分配、运算等地方,重新思考是否有更好的解决方案,来减少计算和内存成本。
- 如果发生在接口层的优化,需要考虑优化的成本,是否符合场景需求。在易用性、复杂性、高性能等因素中找到平衡点。
接下来我们看第二个优化点。
优化二
android studio有一个非常好用的性能分析工具:android profile,他可以帮助我们分析运行中的cpu占用情况,以及内存的分配情况。从这些数据中我们可以去分析,我们的程序是否存在问题,是否有优化的空间。
继续案例一中的场景,在开发中,利用这个工具完成了许多性能优化,或者说是bug的排查。这里主要讲两个:内存抖动和内存泄露。具体工具的使用方式可以移步官方文档 Android Profiler ,这里我主要介绍使用这个工具解决问题的思路。
首先第一个:内存抖动。android profile可以在运行时,查看内存的占用情况,在开发过程中我使用工具查看了一下运行时内存情况,类似下图:(图源网络)
ps:下面相关的图像我均采用网络图片代替,嗯,,因为我懒得去重现一次场景再记录图像(手动狗头)
内存出现频繁增长与垃圾回收,图像呈现锯齿状。内存的频繁申请与释放,会损耗大量的性能,最终导致的结果就是我们的应用卡顿。android profile具有的另一个功能是记录函数的内存申请大小,如onCreate
函数申请了多少内存,剩余多少内存等。通过这个功能,我查询了一下其在运行时内存分配所在的函数以及对象情况,如下:(图源网络)
工具详细记录了所创建的对象,如上面两个图:byte数组占大多数内存分配;createSubDecor
函数占大多数的内存分配。那么我们拿到这些信息之后,就可以去到对应的方法进行排查。
在我的项目,我的原因主要是,在java层每帧都拷贝一次数据,导致不断开辟内存,但是却使用一次就丢弃。场景一的优化之后,减少了这次拷贝即解决了这个问题。
第二,内存泄露。检测内存泄露最好的方式就是:不断重进场景,观察内存增量。如果每次进入、退出之后,内存都有增量,则非常可能发生了内存泄露。在当时的检测中,发现运行时内存不断增长,即使手动垃圾回收也无济于事,最后导致OOM程序崩溃。这很明显就是发生了内存泄露。通过记录内存申请记录,发现是在jni,在c++线程中创建了java对象,使用完成后未删除局部引用,导致对象无法被虚拟机回收(在c++线程结束后,对象才会被回收)。
借助类似的类似的工具,可以查看程序对于资源的使用情况,也是非常方便帮助我们做性能优化的。
优化三
第三个优化,是学习了android在屏幕刷新机制上的思路,来提升整体的帧率性能。还回到我们工作的项目中来。我们的项目框架主要的能力就是渲染数据帧,但其对输入有一个要求:在上一帧渲染完成之前,不允许下一帧输入,否则会被限流节点丢弃数据。因此在原来的程序是这样的:
横向代表时间线,竖直线代表帧的输入,蓝色箭头代表框架sdk正在渲染数据帧,此时无法接受新的帧输入
观察上面的图像,框架处理数据的时间比帧输入更长,因此当第二帧输入的时候,第一帧还没处理完成,此时第二帧被丢弃。但是,当第一帧处理完成的时候,此时第三帧数据尚未到达,框架sdk进入空闲状态。
为了提高整体的处理帧率,我们需要让框架sdk时刻保持运行状态。因此我在这里学习android的渲染机制,加上双缓冲。
- 输入的数据缓存到双缓冲中进行保存,直接覆写到back内存中,如果front内存没有数据,则交换前后缓冲区
- 框架sdk从front区读取数据,并交换前后缓冲区
增加了缓存之后,可以保证框架sdk时刻处于运行当中,提高整体输出帧率,如下图:
这次优化的核心思路在于:充分利用cpu、内存等资源,来实现我们需要的效果。前面我们说的,都是如何节省资源,降低消耗,但都是在保证相同的输出效果,或者可接受损失的情况下。而这次性能优化,则是充分利用我们的硬件资源,实现更好的表现效果。对于渲染库而言,帧率表现,也是其性能表现的一个方面。
但此类型的优化也有他的代价:增加cpu与内存的负载。当评估下来之后,觉得这些资源的付出,值得换来帧率的提升,那么这次优化就是有意义的。反之,在一些低性能低下的机器,内存本身就非常紧张,双缓冲需要的内存代价就太大了。需要结合具体的业务情况来做判断。
优化四
做android开发的读者都知道,我们不能在主线程做耗时操作,会直接导致界面卡顿。因此大多数的操作我们会选择放在子线程去运行。多线程并行处理数据,可以提高整体的处理效率。在我们的项目中,对数据帧的处理通常是多节点的,如下:
节点的处理之间,有严格的先后关系,也有无关的可并行关系。对于并行关系的节点,我们可以创建多个线程,进行并行处理,提高处理时间,如下:
将处理2和处理3进行并行处理,再分别输出。这是最基本的优化方案,但事实上,在实际开发中还会遇到一些其他的问题。
处理2节点与处理3并行之后,需要付出的代价有:增加一个线程,处理增加线程切换的代价;增加数据占用的内存;增加对cpu的负载等等。换来的效率提升是否值得也是需要综合评估。
在项目中,我处理成并行是有效果的。原因是处理2与处理3耗时在几十ms,而线程切换带来的损耗小于1ms,内存占用低,因此对于数据的传递我是采用指针传递的,不涉及内存的拷贝。综合评估下,这是一个值得的优化,可以降低整体流程几十ms的时间。
你以为这就结束了吗?在测试中又发现了新的问题。在低端机器中,本身cpu已经跑满了,增加了线程之后,非但没有提升效率,反而线程切换的损耗带来了效率的下降。cpu负载这一代价,导致此优化是无效的。因此,该优化仅在中高端机器上开启。
线程是一把双刃剑,运用得好,可以为我们带来很大的效率提升,但同时也要注意需要付出的代价,避免反向优化。
总结
好,以上就是近段时间,我的一些关于性能优化的经验,咱们再来总结一下性能优化的思路:
- 性能优化是跟着具体的业务场景去实现的,跟踪核心数据、核心流程,如图像帧处理流程,分析每个步骤的处理是否合理,是否有优化的空间。
- 利用工具分析运行时的内存与cpu等资源情况,发现可能存在的内存泄露、内存抖动、cpu占用过高等问题。
- 性能优化可以是保证效果降低资源的使用,也可以是充分利用资源,提高程序的表现效果。
- 在做出优化的决策前,要综合评估当前的环境因素,分析优化需要付出的代价,避免反向优化。
性能优化过程中,可以参考以下的具体建议:
- 尽量减少数据拷贝
- 尽量减少内存申请,使用缓存池来代替反复申请与释放
- 平衡线程的数量与并行处理的任务耗时之间的关系,找到最佳平衡点
- 注意代码细节的性能问题,如遍历次数、内存占用量等,不同的编程语言也会有不同的特性,通过练习算法题目可以增强性能意识
性能优化并不是一个高深莫测的技能,而是需要我们在开发过程中时刻注意的问题,少一次拷贝、少一次遍历,都能让我们的程序性能更好。但我们无法在开发初期则达到最佳的性能表现,需要我们阶段性进行整体的性能检测与优化,来排查代码中存在的性能问题。
练习算法题目是个不错的方式,能够提高自己对性能的意识,增强自己性能优化能力,写出更加强壮的代码。不同的编程语言有自己的特性,需要结合自己的语言去学习,如java的垃圾回收、c++的主动释放等。我认为,面向api写代码大家都会,而真正决定差距的,是代码的设计与性能。
为了帮助到大家更好的全面清晰的掌握好性能优化,准备了相关的学习路线以及核心笔记(还该底层逻辑):https://qr18.cn/FVlo89
大家可以进行参考学习:
性能优化核心笔记:https://qr18.cn/FVlo89
启动优化
内存优化
UI优化
网络优化
Bitmap优化与图片压缩优化:https://qr18.cn/FVlo89
多线程并发优化与数据传输效率优化
体积包优化
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D7Xf3HIR-1688019747949)(https://upload-images.jianshu.io/upload_images/28895723-7e80e0711187dbe2.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
《Android 性能监控框架》:https://qr18.cn/FVlo89
《Android Framework学习手册》:https://qr18.cn/AQpN4J
- 开机Init 进程
- 开机启动 Zygote 进程
- 开机启动 SystemServer 进程
- Binder 驱动
- AMS 的启动过程
- PMS 的启动过程
- Launcher 的启动过程
- Android 四大组件
- Android 系统服务 - Input 事件的分发过程
- Android 底层渲染 - 屏幕刷新机制源码分析
- Android 源码分析实战