Android自绘控件开发与性能优化实践——以录音波浪动画为例

JayGoo / 文 发表于2017-08-08 18:40 次阅读 性能优,波浪,自定义控件,android

前言

本文实战性较强,主要目的是通过一个自定义控件的开发,引出我对自定义控件性能优化的一些思考和实践,欢迎各位喜欢移动开发的小伙伴来拍砖~

本文由于篇幅有限,只讲解思路,并没有放出大量源代码,如果对本项目感兴趣,文末会放出Demo,可以自行去Github上fork和star。


动画效果

这是最近正在开发功能里的一个录音控件,我们的UI设计说做成某软件的效果,于是仿照它做了一个,相似度还是很高的:


知识储备

众所周知,一般自绘动画我们都是在View中实现的,一般会重写onMeasure(测量)、onLayout(布局)、onDraw(绘制)三个方法。

Android系统为了简化线程开发,将这三个过程都放在主线程中执行,以保证绘制系统的线程安全。

整个绘制过程通过一个Choreographer定时器驱动调用更新,每16ms会刷新一次,通过树状结构存储的 ViewGroup,依次递归的调用到每个 View 的 onMeasure、onLayout、onDraw 方法,从而最后将每个 View 都绘制出来(为了保证绘制效率,并不是每个View的这些方法每个绘制周期都会调用,那些没有变化的不会被重绘)。

但是由于普通的 View 都处于主线程中,Android 除了绘制之外,在主线程中还需要处理用户的各种点击事件。很多情况,在主线程中还需要运行额外的用户处理逻辑、轮询消息事件等。 如果主线程过于繁忙,不能及时的处理和响应用户的输入,会让用户的体验急剧降低。如果更严重的情况,当主线程延迟时间达到5s的时候,还会触发 ANR(Application Not Responding)。 如果界面的绘制和动画比较复杂,计算量比较大的情况,就不再适合使用 View 这种方式来绘制了。

Android考虑到这种场景,提出了SurfaceView机制,它可以在非主线程进行图形绘制,释放了主线程的压力,所以我们可以把View的绘制放到SurfaceView中完成。如果对SurfaceView不太熟悉,可以自行百度,或参看demo中封装好的RenderView,这里由于篇幅限制,这里就不作详细介绍了。


动画实现

看了一下要实现的效果,感觉是由四条振幅不等的正弦曲线组成,这些曲线振幅中间比较高,两边比较低,应该有一个对称的衰减系数,然后这些曲线根据声音的大小上下波动,保持一个速率向右运动。

这里再取回还给高中老师的数学知识,下面是正弦曲线的公式:y=Asin(ωx+φ)+k,其中A 代表的是振幅,对应的波峰和波谷的高度,即 y 轴上的距离;ω 是角速度,换成频率是 2πf,能够控制波形的宽度;φ 是初始相位,能够决定正弦曲线的初始 x 轴位置;k 是偏距,能够控制在 y 轴上的偏移量。

那么我们只需根据时间改变φ,那么曲线就可以实现移动,通过一个对称衰减函数乘以A,就可实现曲线衰减变化,通过改变A的值可以实现上下波动。恩,完美,开干!

这里我要推荐大家一个绘图网站 https://www.desmos.com/calculator ,它可以帮你将函数转换成相应的图形,十分方便。 函数很简单,正弦就行,主要是衰减函数的选取,这里要找一个对称的衰减函数,如图所示: 我们只要将每个点的x分别映射到衰减函数的一个对称区间,根据函数计算出相应的衰减系数,就可以实现振幅不同的波动曲线了。 最后通过一些调整,我们可以大致可以得出和目标效果图相似的曲线:

接下来,我们只需要在 SurfaceView 中使用 Path,通过上面的公式计算出一个个的点,然后画直线连接起来就行啦!效果如图所示:

然后就是让它动起来了,前面也说了,可以根据时间改变曲线的相位值φ来实现移动,我在封装好的RenderView中实现了一个叫做onRender的方法,它主要是代替onDraw工作的,我们传入时间millisPassed,定义位移系数offsetSpeed,那么相位值φ = π * millisPassed / offsetSpeed,每次渲染周期都将φ代入函数就可以让曲线实现位移效果了。最后再给一个volume变量,volume乘以一个初始振幅amplitude和根据横坐标算出的衰减系数,作为纵坐标,便实现了曲线形状和根据声音大小波动的效果。

这里再总结下大致实现步骤:

  • 计算出函数曲线和对称衰减函数
  • 根据函数计算出需要绘制的点,通过Path连线
  • 根据时间改变曲线的φ,实现曲线位移效果
  • 根据volume和衰减函数改变振幅,实现曲线上下波动

动画优化

你以为效果实现就完事那就大错特错了!当我把动画运行到一个性能较差的低端机时,看到CPU的占有率达到30%多,有时候还会一卡一卡的时候,惊呆了……和想象的不一样啊!到底是哪里出了问题呢? 我们可以通过Android Studio的Monitor工具的CPU method trace查看到底是哪些方法占用了我的CPU:

从CPU trace中可以看出calcValue和path的lineTo方法很耗时,占了一半的CPU时间,那么有什么方法可以降低呢?

降低绘制密度

Ok,让我们review下绘制过程,第二步的时候我们需要计算曲线的点,然后通过Path连线这些点,而现在的手机屏幕1960x1080已经成为了标配,如果我们把宽度的像素点叫做采样点,每次我们要把每个采样点的x代入函数求出y,然后调用lineTo连线,那么我们每16ms都需要做出大量的计算。

但是事实上人的肉眼是有一定容忍度的,特别是快速运动的动画,一些失真的地方,肉眼很难分辨,所以我们没必要把1080个点每个都算出来,经过试验发现我们只要在60个以上的采样点,效果还是十分的平滑,粗略计算,这样做可以将计算量减少到原来的1/16,于是可以释放大量的CPU时间(由于采样点的减少,图形会出现锯齿,我们可以通过Paint的抗锯齿属性优化)。

总结:通过动态调节自定义的绘制密度,在绘制密度与最终实现效果中找到一个平衡点(即不影响最后的视觉效果,同时还能最大限度的减少计算量),这个是最直接,也最简单的优化方法。

减少重复实时计算量

虽然现在的设备的CPU已经足够强大,但是由于每16ms中,系统要做大量工作,为了保证动画流畅稳定,我们还是要尽量的减少一些重复的计算。

最常用的方法就是使用查表法,利用空间换时间(注意把握空间和时间的关系,莫要一味追求时间而牺牲大量内存空间,那么就得不偿失了)。

学过计算机的都知道,CPU在计算加减乘是非常快的,但是除法是比较慢的,特别是浮点数除法,我们可以将这些浮点运算转换成整数除法,除数、被除数乘以一个统一的精确度,用到时再除以精确度,这个方法在大量浮点计算时是很有效的,但是注意处理整形溢出。另外还要避免一些乘方、开平方根等运算的重复计算。

就本例来讲,calcValue方法是为了计算每个点代表的衰减系数,但其实我们计算衰减函数的时候对于每次固定的x,我们算出的衰减系数都是一样,这就会产生大量重复的计算。我们可以把这些计算好的值直接放入表中,然后通过查表的方式,下次就不需要重复计算这些复杂运算了。关于存储,如果数量不是很多建议使用SparseArray,它可以避免自动装箱,节约不少时间。理论上是这样的,但其实由于本例的衰减函数不是很复杂,这种做法的优化空间并不是很大,而且由于前面已经降低了绘制密度,已经减少了大量的计算,统计了下,耗时节约了几ms左右,但这确实也是一个优化的好方向,特别是一些复杂的运算,还是很有意义的。

总结:尽量减少重复运算,对重复复杂的计算,可以适当使用空间换时间。尽量减少浮点数除法运算。

经过前两步的优化,再看一下目前的CPU trace,发现已经降低了很多,动画也流畅了起来。

内存泄漏

CPU占有率降下去了,动画也流畅了,不过还有问题需要特别注意,那就是内存泄漏。

Android 在内存分配和释放方面,采用了 JAVA 的垃圾回收 GC 模式。 当分配的内存不再使用的时候,系统会定时帮我们自动清理。这给我们应用开发带来了极大的便利,我们从此不再需要过多的关注内存的分配与回收,也因此减少很多内存使用的风险。但是由于一些不正确的操作,当一个对象已经不需要再使用了,本该被回收时,而另外一个正在使用的对象持有它的引用从而导致它不能被回收,这就导致本该被回收的对象不能被回收而停留在堆内存中,内存泄漏就产生了,内存泄漏时导致程序OOM的原因之一,而OOM就意味着Crash。

常见的内存泄漏检测方法是通过手动GC以及监听Java heap的情况,通过查看Reference Tree的层级确定是否内存泄漏,通过MAT工具,分析具体泄漏原因,但是,不得不说,这个方法确实很复杂,如果不是很熟练,很难发现隐藏的一些内存泄漏,这里推荐使用LeakCanary,通过代码接入的方式,监听内存泄漏,它会以插件的形式伴随程序一起,如果发生内存泄漏,LeakCanary会给出泄漏的层级,十分清晰。

就本例,我通过多次创建销毁Activity,检测程序是否发生了内存泄漏。 结果LeakCanary提示程序发生了内存泄漏,如图所示

RenderThread持有了Activity的隐式context,导致Activity不能释放资源, 追踪到代码,我们发现这样一段代码:

 public RenderThread(SurfaceHolder holder) {
            super("RenderThread");
            surfaceHolder = holder 
 }

在 Java 中,非静态匿名内部类会持有其外部类的隐式引用,所以RenderThread所持有的context就是holder和它的Runable方法持有的引用,而Activity销毁时,因为Thread的持有强引用导致无法及时的释放掉内存,从而导致内存泄漏。

解决方案就是,将RenderThread改为私有的静态内部类,这样它便不会持有其外部类的引用,另外可以对surfaceHolder使用弱引用,确保GC可以及时释放掉holder。

            surfaceHolder = new WeakReference<>(holder);

获取surfaceHolder时可以使用(注意判空)surfaceHolder.get()

总结:当然内存泄露不只这一类情况,情况还有很多,百度也有一大堆,就不再累述。如果想了解更多的内存优化方面的,可以关注胡凯的博客,他从各个方面讲解性能优化,干货很多,目前我也只是消化了一部分,并应用到项目,任重而道远,有兴趣的可以关注一下。

优化内存

尽量减少内存的分配次数,因为每次GC都是会耗一定时间的,如果放到平时倒无所谓,但如果放到一个16ms的定时器中,如果GC的频率过高也会引起动画有卡顿感,合理的减少内存的分配次数还可以有效的避免产生内存抖动问题,优化动画体验。 这里其实已经做得不错,主要是总结下一些常用的方案:

  1. 减少对象的重复创建,例如Paint,Path,Rect等
  2. 减少大量临时对象的创建,对于那些无法避免,每次又必须分配的,我们可以采用对象池模型的方式来分配对象。对象池来解决频繁创建与销毁的问题,但是这里需要注意结束使用之后,需要手动释放对象池中的对象。
  3. 减少一些资源操作,例如getColor,这个方法中会创建多个 StringBuilder 的变量

总结

通过一系列的优化,动画在中端手机上CPU稳定在2~3%左右,内存在2MB左右,在一些低端手机CPU占有率在控制在10%左右,内存在15MB左右(为什么内存这么高?我还没有研究),不过欣慰的是两者动画都十分流畅。

本文介绍了从需求开始,如何一步步开发一个自定义控件,并通过降低绘制密度、减少重复实时计算量、避免和解决内存泄漏、如何优化内存等四方面对控件的性能进行了优化,希望能给大家平时开发工作带来一些启发和帮助,也希望大家可以提出更多更好的优化方案~

限于笔者的水平和经验有限,本文如果有纰漏和错误的地方,欢迎大家指出。如果大家有更多更好的建议,欢迎一起分享讨论,共同进步。

Github

https://github.com/Jay-Goo/WaveLineView 欢迎各位Star,Mark

打赏

收藏 赞 (18) 踩 (1)