Android自绘动画实现与优化实战——以Tencent OS录音机波形动
user2041
8年前
<h2>前言</h2> <p>我们所熟知的,Android 的图形绘制主要是基于 View 这个类实现。 每个 View 的绘制都需要经过 onMeasure、onLayout、onDraw 三步曲,分别对应到测量大小、布局、绘制。</p> <p>Android 系统为了简化线程开发,降低应用开发的难度,将这三个过程都放在应用的主线程(UI 线程)中执行,以保证绘制系统的线程安全。</p> <p>这三个过程通过一个叫 Choreographer 的定时器来驱动调用更新, Choreographer 每16ms被 vsync 这个信号唤醒调用一次,这有点类似早期的电视机刷新的机制。在 Choreographer 的 doFrame 方法中,通过树状结构存储的 ViewGroup,依次递归的调用到每个 View 的 onMeasure、onLayout、onDraw 方法,从而最后将每个 View 都绘制出来(当然最后还会经过 SurfaceFlinger 的类来将 View 合成起来显示,实际过程很复杂)。</p> <p>同时每个 View 都保存了很多标记值 flag,用来判断是否该 View 需要重新被 Measure、Layout、Draw。 这样对于那些没有变化,不需要重绘的 View,则不再调用它们的方法,从而能够提高绘制效率。</p> <p>Android 为了方便开发者进行动画开发,提供了好几种动画实现的方式。 其中比较常用的是属性动画类(ObjectAnimator),它通过定时以一定的曲线速率来改变 View 的一系列属性,最后产生 View 的动画的效果。比较常见的属性动画能够动态的改变 View 的大小、颜色、透明度、位置等值,此种方式实现的效率比较高,也是官方推荐的动画形式。</p> <p>为了进一步的提升动画的效率,防止每次都需要多次调用 onMeasure、onLayout、onDraw,重新绘制 View 本身。 Android 还提出了一个层 Layer 的概念。</p> <p>通过将 View 保存在图层中,对于平移、旋转、伸缩等动画,只需要对该层进行整体变化,而不再需要重新绘制 View 本身。 层 Layer 又分为软绘层(Software Layer)和硬绘层(Harderware Layer) 。它们可以通过 View 类的 setLayerType(layerType, paint);方法进行设置。软绘层将 View 存储成 bitmap,它会占用普通内存;而硬绘层则将 View 存储成纹理(Texture),占用 GPU 中的存储。 需要注意的是,由于将 View 保存在图层中,都会占用相应的内存,因此在动画结束之后需要重新设置成LAYER_ TYPE_ NONE,释放内存。</p> <p>由于普通的 View 都处于主线程中,Android 除了绘制之外,在主线程中还需要处理用户的各种点击事件。很多情况,在主线程中还需要运行额外的用户处理逻辑、轮询消息事件等。 如果主线程过于繁忙,不能及时的处理和响应用户的输入,会让用户的体验急剧降低。如果更严重的情况,当主线程延迟时间达到5s的时候,还会触发 ANR(Application Not Responding)。 这样当界面的绘制和动画比较复杂,计算量比较大的情况,就不再适合使用 View 这种方式来绘制了。</p> <p>Android 考虑到这种场景,提出了 SurfaceView 的机制。SurfaceView 能够在非 UI 线程中进行图形绘制,释放了 UI 线程的压力。SurfaceView 的使用方法一般是复写一下三种方法:</p> <pre> <code class="language-java">public void surfaceCreated(SurfaceHolder holder); public void surfaceChanged(SurfaceHolder holder, int format, int width, int height); public void surfaceDestroyed(SurfaceHolder holder);</code></pre> <p>surfaceCreated 在 SurfaceView 被创建的时候调用, 一般在该方法中创建绘制线程,并启动这个线程。</p> <p>surfaceDestroyed 在 SurfaceView 被销毁的时候调用,在该方法中设置标记位,让绘制线程停止运行。</p> <p>绘制子线程中,一般是一个 while 循环,通过判断标记位来决定是否退出该子线程。 使用 sleep 函数来定时的调起绘制逻辑。 通过 mHolder.lockCanvas()来获得 canvas,绘制完毕之后调用 mHolder.unlockCanvasAndPost(canvas);来上屏。 这里特别要注意绘制线程和 surfaceDestroyed 中需要加锁。否则会有 SurfaceView 被销毁了,但是绘制子线程中还是持有对 Canvas 的引用,而导致 crash。下面是一个常用的框架:</p> <pre> <code class="language-java">private final Object mSurfaceLock = new Object(); private DrawThread mThread; @Override public void surfaceCreated(SurfaceHolder holder) { mThread = new DrawThread(holder); mThread.setRun(true); mThread.start(); } @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { //这里可以获取SurfaceView的宽高等信息 } @Override public void surfaceDestroyed(SurfaceHolder holder) { synchronized (mSurfaceLock) { //这里需要加锁,否则doDraw中有可能会crash mThread.setRun(false); } } private class DrawThread extends Thread { private SurfaceHolder mHolder; private boolean mIsRun = false; public DrawThread(SurfaceHolder holder) { super(TAG); mHolder = holder; } @Override public void run() { while(true) { synchronized (mSurfaceLock) { if (!mIsRun) { return; } Canvas canvas = mHolder.lockCanvas(); if (canvas != null) { doDraw(canvas); //这里做真正绘制的事情 mHolder.unlockCanvasAndPost(canvas); } } Thread.sleeep(SLEEP_TIME);); } } public void setRun(boolean isRun) { this.mIsRun = isRun; } }</code></pre> <p>Android 为绘制图形提供了 Canvas 类,可以理解这个类是一块画布,它提供了在画布上画不同图形的方法。它提供了一系列的绘制各种图形的 API, 比如绘制矩形、圆形、椭圆等。对应的 API 都是 drawXXX的形式。</p> <p>不规则的图形的绘制比较特殊,它同于规则图形已有绘制公式的情况,它有可能是任意的线条组成。Canvas 为画不规则形状,提供了 Path 这个类。通过 Path 能够记录各种轨迹,它可以是点、线、各种形状的组合。通过 drawPath 这个方法即可绘制出任意图形。</p> <p>有了画布 Canvas 类,提供了绘制各种图形的工具之后,还需要指定画笔的颜色,样式等属性,才能有效的绘图。Android 提供了 Paint 这个类,来抽象画笔。 通过 Paint 可以指定绘制的颜色,是否填充,如果处理交集等属性。</p> <h2>动画实现</h2> <p>既然是实战,当然要有一个例子啦。 这里以 TOS 里面的录音机的波形动效实现为例。 首先看一下设计狮童鞋给的视觉设计图:</p> <p><img src="https://simg.open-open.com/show/6b485f372138fb87cb070551b7619240.png"></p> <p>下面是动起来的效果图:</p> <p><img src="https://simg.open-open.com/show/43bec419402edf4f37d2e463cf060c4d.gif"></p> <p>看到这么高大上的动效图,不得不赞叹一下设计狮童鞋,但同时也深深的捏了把汗——这个动画要咋实现捏。</p> <p>粗略的看一下上面的视觉图。 感觉像是多个正弦曲线组成。 每条正弦线好像中间高,两边低,应该有一个对称的衰减系数。 同时有两组上下对称的正弦线,在对称的正弦线中间采用渐变颜色来进行填充。然后看动效的效果图,好像这个不规则的正弦曲线有一个固定的速率向前在运动。</p> <p>看来为了实现这个动效图,还得把都已经还给老师的那点可怜的数学知识捡起来。下面是正弦曲线的公式:</p> <p>y=Asin(ωx+φ)+k</p> <p>A 代表的是振幅,对应的波峰和波谷的高度,即 y 轴上的距离;ω 是角速度,换成频率是 2πf,能够控制波形的宽度;φ 是初始相位,能够决定正弦曲线的初始 x 轴位置;k 是偏距,能够控制在 y 轴上的偏移量</p> <p>为了能够更加直观,将公式图形化的显示出来,这里强烈推荐一个网站: <a href="/misc/goto?guid=4959636049748159383" rel="nofollow,noindex">https://www.desmos.com/calculator</a> , 它能将输入的公式转换成坐标图。这正是我们需要的。比如 sin(0.75πx - 0.5π) 对应的图形是下图:</p> <p><img src="https://simg.open-open.com/show/63acdcfd8db817c280605546b9b90620.png"></p> <p>与上面设计图中的相比,还需要乘上一个对称的衰减函数。 我们挑选了如下的衰减函数425/(4+x4):</p> <p><img src="https://simg.open-open.com/show/577465db4b7d078060f65d9bb91ec407.png"></p> <p>将sin(0.75πx - 0.5π) 乘以这个衰减函数 425/(4+x4),然后乘以0.5。 最后得出了下图:</p> <p><img src="https://simg.open-open.com/show/6b19091638af96676e9f85f351021f6f.png"></p> <p>看起来这个曲线与视觉图中的曲线已经很像了,无非就是多加几个算法类似,但是相位不同的曲线罢了。 如下图:</p> <p><img src="https://simg.open-open.com/show/81e8f2c446e533a3dacbd7bdd5d800ca.png"></p> <p>看看,用了我们足(quan)够(bu)强(wang)大(ji)的数学知识之后, 我们好像也创造出来了类似视觉稿中的波形了。</p> <p>接下来,我们只需要在 SurfaceView 中使用 Path,通过上面的公式计算出一个个的点,然后画直线连接起来就行啦! 于是我们得出了下面的实际效果(为了方便显示,已将背景调成白色):</p> <p><img src="https://simg.open-open.com/show/2f56fcc695041847adf80cdea61f10dc.jpg"></p> <p>曲线画出来了,然后要做的就是渐变色的填充了。 这也是视觉还原比较难实现的地方。</p> <p>对于渐变填充,Android 提供了 LinearGradient 这个类。它需要提供起始点和终结点的坐标,以及起始点和终结点的颜色值:</p> <pre> <code class="language-java">public LinearGradient(float x0, float y0, float x1, float y1, int color0, int color1, TileMode tile);</code></pre> <p>TileMode 包括了 CLAMP、REPEAT、MIRROR 三种模式。 它指定了,如果填充的区域超过了起始点和终结点的距离,颜色重复的模式。CLAMP 指使用终点边缘的颜色,REPEAT 指重复的渐变,而MIRROR则指的是镜像重复。</p> <p>从 LinearGradient 的构造函数就可以预知,渐变填充的时候,一定要指定精确的起始点和终结点。否则如果渐变距离大于填充区域,会出现渐变不完整,而渐变距离小于填充区域则会出现多个渐变或填不满的情况。如下图所示:</p> <p><img src="https://simg.open-open.com/show/252291f41a7cc0b124d0ee99fdb2a439.jpg"></p> <p>图中左边是精确设置渐变起点和终点为矩形的顶部和底部; 图中中间为设置的渐变起点为顶部,终点为矩形的中间; 右边的则设置的渐变起点和终点都大于矩形的顶部和底部。代码如下:</p> <pre> <code class="language-java">LinearGradient gradient = new LinearGradient(100, mHeight_2 - 200, 100, mHeight_2 + 200, line_1_start_color, region_1_end_color, Shader.TileMode.REPEAT); mPaint.setShader(gradient); mPaint.setStyle(Paint.Style.FILL); canvas.drawRect(100, mHeight_2 - 200, 300, mHeight_2 + 200, mPaint); gradient = new LinearGradient(400, mHeight_2 - 200, 400, mHeight_2, line_1_start_color, region_1_end_color, Shader.TileMode.REPEAT); mPaint.setShader(gradient); mPaint.setStyle(Paint.Style.FILL); canvas.drawRect(400, mHeight_2 - 200, 600, mHeight_2 + 200, mPaint); gradient = new LinearGradient(700, mHeight_2 - 400, 700, mHeight_2 + 400, line_1_start_color, region_1_end_color, Shader.TileMode.REPEAT); mPaint.setShader(gradient); mPaint.setStyle(Paint.Style.FILL); canvas.drawRect(700, mHeight_2 - 200, 900, mHeight_2 + 200, mPaint);</code></pre> <p>对于矩形这种规则图形进行渐变填充,能够很容易设置渐变颜色的起点和终点。 但是对于上图中的正弦曲线如果做到呢? 难道需要将一组正弦曲线的每个点上下连接,使用渐变进行绘制? 那样计算量将会是非常巨大的!那又有其他什么好的方法呢?</p> <p>Paint 中提供了 Xfermode 图像混合模式的机制。 它能够控制绘制图形与之前已经存在图形的混合交叠模式。其中比较有用的是 PorterDuffXfermode 这个类。它有多种混合模式,如下图所示:</p> <p><img src="https://simg.open-open.com/show/fcb8092fb8a388ce80194fa55ba14bcd.jpg"></p> <p>这里 canvas 原有的图片可以理解为背景,就是 dst; 新画上去的图片可以理解为前景,就是 src。有了这种图形混合技术,能够完成各种图形交集的显示。</p> <p>那我们是否可以脑洞大开一下,将上图已经绘制好的波形图,与渐变的矩形进行交集,将它们相交的地方画出来呢。 它们相交的地方好像恰好就是我们需要的效果呢。</p> <p>这样,我们只需要先填充波形,然后在每组正弦线相交的封闭区域画一个以波峰和波谷为高的矩形,然后将这个矩形染色成渐变色。以这个矩形与波形做出交集,选择 SrcIn 模式,即能只显示相交部分矩形的这一块的颜色。 这个方案看起来可行,先试试。下面图是没有执行 Xfermode 的叠加图, 从图中可以看出,两个正弦线中间的区域正是我们需要的!</p> <p><img src="https://simg.open-open.com/show/d5cf7e661023d296e333a09ea96d8709.png"></p> <p>下面是执行 SrcIn 模式混合之后的图像:</p> <p><img src="https://simg.open-open.com/show/b646637a5fe50037a7b1afccfcbade3e.png"></p> <p>神奇的事情出现了, 视觉图中的效果被还原了。</p> <p>我们再依葫芦画瓢,再绘制另外一组正弦曲线。 这里需要注意的是,由于 Xfermode 中的 Dst 指的原有的背景,因此这里两组正弦线的混合会互相产生影响。 即第二组在调用 SrcIn 模式进行混合的时候,会将第一组的图形进行剪切。如下图所示:</p> <p><img src="https://simg.open-open.com/show/7788ec2a8d72f3480ad11dcc6821c2c5.jpg"></p> <p>因此在绘制的时候,必须将两组正弦曲线分开单独绘制在不同 Canvas 层上。 好在 Android 系统为我们提供了这个功能,Android 提供了不同 Canvas 层,以用于进行离屏缓存的绘制。我们可以先绘制一组图形,然后调用 canvas.saveLayer 方法将它存在离屏缓存中,然后再绘制另外一组曲线。最后调用 canvas.restoreToCount(sc);方法恢复 Canvas,将两屏混合显示。最后的效果图如下所示:</p> <p><img src="https://simg.open-open.com/show/a6a6968e58886f6d41e3a75c1cb35951.jpg"></p> <p>这里总结一下绘制的顺序:</p> <p>1、计算出曲线需要绘制的点</p> <p>2、填充出正弦线</p> <p>3、在每组正弦线相交的地方,根据波峰波谷绘制出一个渐变线填充的矩形。并且设置图形混合模式为 SrcIn</p> <pre> <code class="language-java">mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));</code></pre> <p>4、对正弦线进行描边</p> <p>5、离屏存储 Canvas,再进行下一组曲线的绘制</p> <p>静态的绘制已经完成了。接下来就是让它动起来了。 根据上面给出来的框架,在绘制线程中会定时执行 doDraw 方法。我们只需要在 doDraw 方法中每次将波形往前移动一个距离,即可达到让波形往前移动的效果。具体对应到正弦公式中的 φ 值,每次只需要在原有值的基础上修改这个值即能改变波形在 X 轴的位置。每次执行 doDraw 都会根据下面的计算方法重新计算图形的初相值:</p> <pre> <code class="language-java">this.mPhase = (float) ((this.mPhase + Math.PI * mSpeed) % (2 * Math.PI));</code></pre> <p>在计算波形高度的时候,还可以乘以音量大小。即正弦公式中的 A 的值可以为 volume * 绘制的最大高度 * 425/(4+x4)。 这样波形的振幅即能与音量正相关。波形可以随着音量跳动大小。</p> <h2>动画的优化</h2> <p>虽然上面已经实现了波形的动画。但是如果以为工作已经结束了,那就真是太 sample,naive了。</p> <p>现在手机的分辨率变的越来越大,一般都是1080p的分辨率。随着分辨率的增加,图形绘制所需要的计算量也越来越大(像素点多了)。这样导致在某些低端手机中,或某些伪高端手机(比如某星S4)中,CPU 的计算能力不足,从而导致动画的卡顿。 因此对于自绘动画,可能还需要不断的进行代码和算法的优化,提高绘制的效率,尽量减少计算量。</p> <p>自绘动画优化的最终目的是减少计算量,降低 CPU 的负担。为了达到这个目的,笔者总结归纳了以下几种方法,如果大家有更多更好的方法,欢迎分享:</p> <h3>1、降低分辨率</h3> <p>在实际动画绘制的过程中,如果对每个像素点的去计算(x,y)值,会导致大量的计算。但是这种密集的计算往往都是不需要的。 对于动画,人的肉眼是有一定的容忍度的,在一定范围内的图形失真是无法察觉的,特别是那种一闪而过的东西更是如此。 这样在实现的时候,可以都自己拟定一个比实际分辨率小很多的图形密度,这个图形密度上来计算 Y 值。然后将我们自己定义的图形密度成比例的映射到真实的分辨率上。 比如上面绘制正弦曲线的时候,我们完全可以只计算100个点。然后将这60个点成比例的放在1024个点的X轴上。 这样我们一下子便减少了接近10倍的计算量。这有点类似栅格化一副图片。</p> <p>由于采用了低密度的绘制,将这些低密度的点用直线连接起来,会产生锯齿的现象,这样同样会对体验产生影响。但是别怕,Android 已经为我们提供了抗锯齿的功能。在 Paint 类中即可进行设置:</p> <pre> <code class="language-java">mPaint.setAntiAlias(true);</code></pre> <p>使用 Android 优化过了的抗锯齿功能,一定会比我们每个点的去绘制效率更高。</p> <p>通过动态调节自定义的绘制密度,在绘制密度与最终实现效果中找到一个平衡点(即不影响最后的视觉效果,同时还能最大限度的减少计算量),这个是最直接,也最简单的优化方法。</p> <h3>2、减少实时计算量</h3> <p>我们知道在过去嵌入式设备中计算资源都是相当有限的,运行的代码经常需要优化,甚至有时候需要在汇编级别进行。虽然现在手机中的处理器已经越来越强大,但是在处理动画这种短时间间隔的大量运算,还是需要仔细的编写代码。 一般的动画刷新周期是16ms,这也意味着动画的计算需要尽可能的少做运算。</p> <p>只要能够减少实时计算量的事情,都应该是我们应该做的。那么如何才能做到尽量少做实时运算呢? 一个比较重要的思维和方法是利用用空间来换取时间。一般我们在做自绘动画的时候,会需要做大量的中间运算。而这些运算有可能在每次绘制定时到来的时候,产生的结果都是一样的。这也意味着有可能我们重复的做出了需要冗余的计算。 我们可以将这些中间运算的结果,存储在内存中。这样下次需要的时候,便不再需要重新计算,只需要取出来直接使用即可。 比较常用的查表法即使利用这种空间换时间的方法来提高速度的。</p> <p>具体针对本例而言, 在计算 425/(4+x4) 这个衰减系数的时候,对每个 X 轴上固定点来说,它的计算结果都是相同的。 因此我们只需要将每个点对应的 y 值存储在一个数组中,每次直接从这个数组中获取即可。这样能够节省出不少 CPU 在计算乘方和除法运算的计算量。 同样道理,由于 sin 函数具有周期性,因此我们只需要将这个周期中的固定 N 个点计算出值,然后存储在数组中。每次需要计算 sin 值的时候,直接从之前已经计算好的结果中找出近似的那个就可以了。 当然其实这里计算 sin 不需要我们做这样的优化,因为 Android 系统提供的 Math 方法库中计算 sin 的方法肯定已经运用类似的原理优化过了。</p> <p>CPU 一般都有一个特点,它在快速的处理加减乘运算,但是在处理浮点型的除法的时候,则会变的特别的慢,多要多个指令周期才能完成。因此我们还应该努力减少运算量,特别是浮点型的除法运算。 一般比较通用的做法是讲浮点型的运算转换成整型的运算,这样对速度的提升也会比较明显。 但是整型运算同时也意味着会丢失数据的精确度,这样往往会导致绘制出来的图形有锯齿感。 之前有同事便遇到即使采用了 Android 系统提供的抗锯齿方法,但是绘制出来的图形锯齿感还是很强烈,有可能就是数值计算中的精确度的问题,比如采用了不正确的整型计算,或者错误的四舍五入。 为了保证精确度,同时还能使用整型来进行运算,往往可以将需要计算的参数,统一乘上一个精确度(比如乘以100或者1000,视需要的精确范围而定)取整计算,最后再将结果除以这个精确度。 这里还需要注意整型溢出的问题。</p> <h3>3、减少内存分配次数</h3> <p>Android 在内存分配和释放方面,采用了 JAVA 的垃圾回收 GC 模式。 当分配的内存不再使用的时候,系统会定时帮我们自动清理。这给我们应用开发带来了极大的便利,我们从此不再需要过多的关注内存的分配与回收,也因此减少很多内存使用的风险。但是内存的自动回收,也意味着会消耗系统额外的资源。一般的 GC 过程会消耗系统ms级别的计算时间。在普通的场景中,开发者无需过多的关心内存的细节。但是在自绘动画开发中,却不能忽略内存的分配。</p> <p>由于动画一般由一个16ms的定时器来进行驱动,这意味着动画的逻辑代码会在短时间内被循环往复的调用。 这样如果在逻辑代码中在堆上创建过多的临时变量,会导致内存的使用量在短时间稳步上升,从而频繁的引发系统的GC行为。这样无疑会拖累动画的效率,让动画变得卡顿。</p> <p>处理分析内存分配,减少不必要的分配呢, 首先我们需要先分析内存的分配行为。 对于Android内存的使用情况,Android Studio提供了很好用,直观的分析工具。 为了更加直观的表现内存分配的影响,在程序中故意创建了一些比较大的临时变量。然后使用Memory Monitor工具得到了下面的图:</p> <p><img src="https://simg.open-open.com/show/be14e0fea8e9cdb92a741bfba5a224e8.png"></p> <p>并且在log中看到有频繁的打印D/dalvikvm: GC_FOR_ALLOC freed 3777K, 18% free 30426K/36952K, paused 33ms, total 34ms</p> <p>图中每次涨跌的锯齿意味着发生了一次GC,然后又分配了多个内存,这个过程不断的往复。 从log中可以看到系统在频繁的发起GC,并且每次GC都会将系统暂停33ms,这当然会对动画造成影响。 当然这个是测试的比较极端的情况,一般来说,如果内存被更加稳定的使用的话,触发GC的概率也会大大的降低,上面图中的颠簸锯齿出现到概率也会越低。</p> <p>上面内存使用的情况,也被称为内存抖动,它除了在周期性的调用过程中出现,另外一个高发场景是在for循环中分配、释放内存。它影响的不仅仅是自绘动画中,其他场景下也需要尽量避免。</p> <p>从上图中可以直观的看到内存在一定时间段内分配和释放的情况,得出是否内存的使用是否平稳。但是当出现问题之后,我们还需要借助 Allocation Tracker 这个工具来追踪问题发生的原因,并最后解决它。Allocation Tracker 这个工具能够帮助我们追踪内存对象的分配和释放情况,能够获取内存对象的来源。比如上面的例子,我们在一段时间内进行追踪,可以得到如下图:</p> <p><img src="https://simg.open-open.com/show/a3c70ab2e283dd6920871317df2ed802.png"></p> <p>从图中我们可以看到大部分的内存分配都来自线程18 Thread 18,这也是我们的动画的绘制线程。 从图中可以看到主要的内存分配有以下几个地方:</p> <p>1、我们故意创建的临时大数组</p> <p>2、来自 getColor 函数, 它来自对 getResources().getColor()的调用,需要获取从系统资源中获取颜色资源。这个方法中会创建多个 StringBuilder 的变量</p> <p>3、创建 Xfermode 的临时变量,来自 mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); 这个调用。</p> <p>4、创建渐变值的 LinearGradient gradient = new LinearGradient(getXPos(startX), startY, getXPos(startX), endY,</p> <pre> <code class="language-java">gradientStartColor, gradientEndColor, Shader.TileMode.REPEAT);</code></pre> <p>对于第2、3,这些变量完全不需要每次循环执行的时候,重复创建变量。 因为每次他们的使用都是固定的。可以考虑将它们从临时变量转为成员变量,在动画初始化的同时也将这些成员变量初始化好。需要的时候直接调用即可。</p> <p>而对于第4类这样的内存分配,由于每次动画中的波形形状都不一样,因此渐变色必现得重新创建并设值。因此这里并不能将它作为成员变量使用。这里是属于必须要分配的。好在这个对象也不大,影响很小。</p> <p>对于那些无法避免,每次又必须分配的大量对象,我们还能够采用对象池模型的方式来分配对象。对象池来解决频繁创建与销毁的问题,但是这里需要注意结束使用之后,需要手动释放对象池中的对象。</p> <p>经过优化的内存分配,会变得平缓很多。比如对于上面的例子。 去除上面故意创建的大量数组,以及优化了2、3两个点之后的内存分配如下图所示:</p> <p><img src="https://simg.open-open.com/show/a9a7e85215d37f9ec48908ba5b0d464f.png"></p> <p>可以看出短时间内,内存并没有什么明显的变化。并且在很长一段时间内都没有触发一次 GC</p> <h3>4、减少 Path 的创建次数</h3> <p>这里涉及到对特殊规则图形的绘制的优化。 Path 的创建也涉及到内存的分配和释放,这些都是需要消耗资源的。并且对于越复杂的 Path,Canvas 在绘制的时候,也会更加的耗时。因此我们需要做的就是尽量优化 Path 的创建过程,简化运算量。这一块并没有很多统一的标准方法,更多的是依靠经验,并且将上面提到到的3点优化方法灵活运用。</p> <p>首先 Path 类中本身即提供了数据结构重用的接口。它除了提供 reset 复位方法之外,还提供了 rewind 的方法。这样每次动画循环调用的时候,能够做到不释放之前已经分配的内存就能够重用。这样避免的内存的反复释放和分配。特别是对于本例中,每次绘制的 Path 中的点都是一样多的情况更加适用。</p> <p>采用方法一种低密度的绘图方法,同样还能够减少 Path 中线段的数量,这样降低了 Path 构造的次数,同能 Canvas 在绘制 Path 的时候,由于 Path 变的简单了,同样能够加快绘制速度。</p> <p>特别的,对于本文中的波形例子。 视觉图中给出来的效果图,除了要用渐变色填充正弦线中间的区域之外。还需要对正弦线本身进行描边。 同时一组正弦线中的上下两根正弦线的颜色还不一样。 这样对于一组完整的正弦线的绘制其实需要三个步骤:</p> <p>1、填充正弦线</p> <p>2、描正弦线上边沿</p> <p>3、描正弦线下边沿</p> <p>如何很好的将这三个步骤组合起来,尽量减少 Path 的创建也很有讲究。比如,如果我们直接按照上面列出来的步骤来绘制的话,首先需要创建一个同时包含上下正弦线的 Path,需要计算一遍上下正弦线的点,然后对这个 Path 使用填充的方式来绘制。 然后再计算一遍上弦线的点,创建只有上弦线的 Path,然后使用 Stroke 的模式来绘制,接着下弦线。 这样我们将会重复创建两边 Path,并且还会重复一倍点坐标的计算量。</p> <p>如果我们能采用上面步骤2中提到的,利用空间换取时间的方法。 首先把所有点位置都记在一个数组中,然后利用这些点来计算并绘制上弦线的 Path,然后保存下来;再计算和绘制下弦线的 Path 并保存。最后创建一个专门记录填充区的 Path,利用 mPath.addPath();的功能,将之前的两个 path 填充到该 Path 中。 这样便能够减少 Path 的计算量。同时将三个 Path 分别用不同的变量来记录,这样在下次循环到来的时候,还能利用 rewind 方法来进行内存重用。</p> <p>这里需要注意的是,Path 提供了 close的方法,来将一段线封闭。 这个函数能够提供一定的方便。但是并不是每个时候都好用。有的时候,还是需要我们手动的去添加线段来闭合一个区域。比如下面图中的情形,采用 close,就会导致中间有一段空白的区域:</p> <p><img src="https://simg.open-open.com/show/cfcb278e8837a07d5b2b8b9cde024635.jpg"></p> <h3>5、优化绘制的步骤</h3> <p>什么? 经过上面几个步骤的优化,动画还是卡顿?不要慌,这里再提供一个精确分析卡顿的工具。 Android 还为我们提供了能够追踪监控每个方法执行时间的工具 TraceView。 它在 Android Device Monitor 中打开。比如笔者在开发过程中发现动画有卡顿,然后用上面 TraceView 工具查看得到下图:</p> <p><img src="https://simg.open-open.com/show/db3baf63252616618211ed935e82b5ae.png"></p> <p>发现 clapGradientRectAndDrawStroke 这个方法占用了72.1%的 CPU 时间,而这个方法中实际占用时间的是 drawPath。这说明此处的绘制存在明显的缺陷与不合理,大部分的时间都用在绘制 clapGradientRectAndDrawStroke 上面了。那么我们再看一下之前绘制的原理,为了能够从矩形和正弦线之间剪切出交集,并显示渐变区域。笔者做出了如下图的尝试:</p> <p><img src="https://simg.open-open.com/show/d3b36f3922034f3685bb59448d5a8ae0.jpg"></p> <p>首先绘制出渐变填充的矩形; 然后再将正弦线包裹的区域用透明颜色进行反向填充(白色区域),这样它们交集的地方利用 SrcIn 模式进行剪切,这时候显示出来便是白色覆盖了矩形的区域(实际是透明色)加上它们未交集的地方(正弦框内)。这样同样能够到达设计图中给出的效果。代码如下:</p> <pre> <code class="language-java">mPath.rewind(); mPath.addPath(mPathLine1); mPath.lineTo(getXPos(mDensity - 1), -mLineCacheY[mDensity - 1] + mHeight_2 * 2); mPath.addPath(mPathLine2); mPath.lineTo(getXPos(0), mLineCacheY[0]); mPath.setFillType(Path.FillType.INVERSE_WINDING); mPaint.setStyle(Paint.Style.FILL); mPaint.setShader(null); mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); mPaint.setColor(getResources().getColor(android.R.color.transparent)); canvas.drawPath(mPath, mPaint); mPaint.setXfermode(null);</code></pre> <p>虽然上面的代码同样也实现了效果,但是由于使用的反向填充,导致填充区域急剧变大。最后导致 canvas.drawPath(mPath, mPaint);调用占据了70%以上的计算量。</p> <p>找到瓶颈点并知道原因之后,我们就能做出针对性的改进。 我们只需要调整绘制的顺序,先将正弦线区域内做正向填充,然后再以 SrcIn 模式绘制渐变色填充的矩形。 这样减少了需要绘制的区域,同时也达到预期的效果。</p> <p><img src="https://simg.open-open.com/show/8860002076e949fc5fdd3bc126e3fd91.jpg"></p> <p>下面是改进之后 TraceView 的结果截图:</p> <p><img src="https://simg.open-open.com/show/6045cb692fc5c7887bf2e44aff18945c.png"></p> <p>从截图中可以看到计算量被均分到不同的绘制方法中,已经没有瓶颈点了,并且实测动画也变得流畅了。 一般卡顿都能通过此种方法比较精确的找到真正的瓶颈点。</p> <h2>总结</h2> <p>本文主要简单介绍了一下 Android 普通 View 和 SurfaceView 的绘制与动画原理,然后介绍了一下录音机波形动画的具体实现和优化的方法。但是限于笔者的水平和经验有限,肯定有很多纰漏和错误的地方。大家有更多更好的建议,欢迎一起分享讨论,共同进步。</p> <p>腾讯 Bugly是一款专为移动开发者打造的质量监控工具,帮助开发者快速,便捷的定位线上应用崩溃的情况以及解决方案。智能合并功能帮助开发同学把每天上报的数千条 Crash 根据根因合并分类,每日日报会列出影响用户数最多的崩溃,精准定位功能帮助开发同学定位到出问题的代码行,实时上报可以在发布后快速的了解应用的质量情况,适配最新的 iOS, Android 官方操作系统,鹅厂的工程师都在使用,快来加入我们吧!</p> <p> </p> <p>来自:http://dev.qq.com/topic/591aa307b8157a82534f3cac</p> <p> </p>