Android自绘动画实现与优化实战——以Tencent OS录音机波形动画为实例

转自 http://bugly.qq.com/bbs/forum.php?mod=viewthread&tid=1180

前言

我们所熟知的,Android 的图形绘制主要是基于 View 这个类实现。 每个 View 的绘制都需要经过 onMeasure、onLayout、onDraw 三步曲,分别对应到测量大小、布局、绘制。

Android 系统为了简化线程开发,降低应用开发的难度,将这三个过程都放在应用的主线程(UI 线程)中执行,以保证绘制系统的线程安全。

这三个过程通过一个叫 Choreographer 的定时器来驱动调用更新, Choreographer 每16ms被 vsync 这个信号唤醒调用一次,这有点类似早期的电视机刷新的机制。在 Choreographer 的 doFrame 方法中,通过树状结构存储的 ViewGroup,依次递归的调用到每个 View 的 onMeasure、onLayout、onDraw 方法,从而最后将每个 View 都绘制出来(当然最后还会经过 SurfaceFlinger 的类来将 View 合成起来显示,实际过程很复杂)。

同时每个 View 都保存了很多标记值 flag,用来判断是否该 View 需要重新被 Measure、Layout、Draw。 这样对于那些没有变化,不需要重绘的 View,则不再调用它们的方法,从而能够提高绘制效率。

Android 为了方便开发者进行动画开发,提供了好几种动画实现的方式。 其中比较常用的是属性动画类(ObjectAnimator),它通过定时以一定的曲线速率来改变 View 的一系列属性,最后产生 View 的动画的效果。比较常见的属性动画能够动态的改变 View 的大小、颜色、透明度、位置等值,此种方式实现的效率比较高,也是官方推荐的动画形式。

为了进一步的提升动画的效率,防止每次都需要多次调用 onMeasure、onLayout、onDraw,重新绘制 View 本身。 Android 还提出了一个层 Layer 的概念。

通过将 View 保存在图层中,对于平移、旋转、伸缩等动画,只需要对该层进行整体变化,而不再需要重新绘制 View 本身。 层 Layer 又分为软绘层(Software Layer)和硬绘层(Harderware Layer) 。它们可以通过 View 类的 setLayerType(layerType, paint);方法进行设置。软绘层将 View 存储成 bitmap,它会占用普通内存;而硬绘层则将 View 存储成纹理(Texture),占用 GPU 中的存储。 需要注意的是,由于将  View 保存在图层中,都会占用相应的内存,因此在动画结束之后需要重新设置成LAYER TYPE NONE,释放内存。

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

Android 考虑到这种场景,提出了 SurfaceView 的机制。SurfaceView 能够在非 UI 线程中进行图形绘制,释放了 UI 线程的压力。SurfaceView 的使用方法一般是复写一下三种方法:

   public void surfaceCreated(SurfaceHolder holder);
   public void surfaceChanged(SurfaceHolder holder, int format, int width,
                              int height);
   public void surfaceDestroyed(SurfaceHolder holder);

surfaceCreated 在 SurfaceView 被创建的时候调用, 一般在该方法中创建绘制线程,并启动这个线程。

surfaceDestroyed 在 SurfaceView 被销毁的时候调用,在该方法中设置标记位,让绘制线程停止运行。

绘制子线程中,一般是一个 while 循环,通过判断标记位来决定是否退出该子线程。 使用 sleep 函数来定时的调起绘制逻辑。 通过 mHolder.lockCanvas()来获得 canvas,绘制完毕之后调用 mHolder.unlockCanvasAndPost(canvas);来上屏。 这里特别要注意绘制线程和 surfaceDestroyed 中需要加锁。否则会有 SurfaceView 被销毁了,但是绘制子线程中还是持有对 Canvas 的引用,而导致 crash。下面是一个常用的框架:

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.sleep(SLEEP_TIME);
        }
    }

    public void setRun(boolean isRun) {
        this.mIsRun = isRun;
    }
}

Android 为绘制图形提供了 Canvas 类,可以理解这个类是一块画布,它提供了在画布上画不同图形的方法。它提供了一系列的绘制各种图形的 API, 比如绘制矩形、圆形、椭圆等。对应的 API 都是 drawXXX的形式。

不规则的图形的绘制比较特殊,它同于规则图形已有绘制公式的情况,它有可能是任意的线条组成。Canvas 为画不规则形状,提供了 Path 这个类。通过 Path 能够记录各种轨迹,它可以是点、线、各种形状的组合。通过 drawPath 这个方法即可绘制出任意图形。

有了画布 Canvas 类,提供了绘制各种图形的工具之后,还需要指定画笔的颜色,样式等属性,才能有效的绘图。Android 提供了 Paint 这个类,来抽象画笔。 通过 Paint 可以指定绘制的颜色,是否填充,如果处理交集等属性。

动画实现

既然是实战,当然要有一个例子啦。 这里以 TOS 里面的录音机的波形动效实现为例。 首先看一下设计狮童鞋给的视觉设计图:


下面是动起来的效果图:


看到这么高大上的动效图,不得不赞叹一下设计狮童鞋,但同时也深深的捏了把汗——这个动画要咋实现捏。

粗略的看一下上面的视觉图。 感觉像是多个正弦曲线组成。 每条正弦线好像中间高,两边低,应该有一个对称的衰减系数。 同时有两组上下对称的正弦线,在对称的正弦线中间采用渐变颜色来进行填充。然后看动效的效果图,好像这个不规则的正弦曲线有一个固定的速率向前在运动。

看来为了实现这个动效图,还得把都已经还给老师的那点可怜的数学知识捡起来。下面是正弦曲线的公式:

y=Asin(ωx+φ)+k

A 代表的是振幅,对应的波峰和波谷的高度,即 y 轴上的距离;ω 是角速度,换成频率是 2πf,能够控制波形的宽度;φ 是初始相位,能够决定正弦曲线的初始 x 轴位置;k 是偏距,能够控制在 y 轴上的偏移量

为了能够更加直观,将公式图形化的显示出来,这里强烈推荐一个网站:https://www.desmos.com/calculator ,它能将输入的公式转换成坐标图。这正是我们需要的。比如 sin(0.75πx - 0.5π) 对应的图形是下图:


与上面设计图中的相比,还需要乘上一个对称的衰减函数。 我们挑选了如下的衰减函数 425/(4+x4)


sin(0.75πx - 0.5π)乘以这个衰减函数425/(4+x4),然后乘以0.5。 最后得出了下图:


看起来这个曲线与视觉图中的曲线已经很像了,无非就是多加几个算法类似,但是相位不同的曲线罢了。 如下图:


看看,用了我们足(quan)够(bu)强(wang)大(ji)的数学知识之后, 我们好像也创造出来了类似视觉稿中的波形了。

接下来,我们只需要在 SurfaceView 中使用 Path,通过上面的公式计算出一个个的点,然后画直线连接起来就行啦!  于是我们得出了下面的实际效果(为了方便显示,已将背景调成白色):


曲线画出来了,然后要做的就是渐变色的填充了。 这也是视觉还原比较难实现的地方。

对于渐变填充,Android 提供了 LinearGradient 这个类。它需要提供起始点和终结点的坐标,以及起始点和终结点的颜色值:

public LinearGradient(float x0, float y0, float x1, float y1, int color0, int color1,
             TileMode tile);

TileMode 包括了 CLAMP、REPEAT、MIRROR 三种模式。 它指定了,如果填充的区域超过了起始点和终结点的距离,颜色重复的模式。CLAMP 指使用终点边缘的颜色,REPEAT 指重复的渐变,而MIRROR则指的是镜像重复。

从 LinearGradient 的构造函数就可以预知,渐变填充的时候,一定要指定精确的起始点和终结点。否则如果渐变距离大于填充区域,会出现渐变不完整,而渐变距离小于填充区域则会出现多个渐变或填不满的情况。如下图所示:


图中左边是精确设置渐变起点和终点为矩形的顶部和底部; 图中中间为设置的渐变起点为顶部,终点为矩形的中间; 右边的则设置的渐变起点和终点都大于矩形的顶部和底部。代码如下:

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);

对于矩形这种规则图形进行渐变填充,能够很容易设置渐变颜色的起点和终点。 但是对于上图中的正弦曲线如果做到呢? 难道需要将一组正弦曲线的每个点上下连接,使用渐变进行绘制? 那样计算量将会是非常巨大的!那又有其他什么好的方法呢?

Paint 中提供了 Xfermode 图像混合模式的机制。 它能够控制绘制图形与之前已经存在图形的混合交叠模式。其中比较有用的是 PorterDuffXfermode 这个类。它有多种混合模式,如下图所示:


这里 canvas 原有的图片可以理解为背景,就是 dst; 新画上去的图片可以理解为前景,就是 src。有了这种图形混合技术,能够完成各种图形交集的显示。

那我们是否可以脑洞大开一下,将上图已经绘制好的波形图,与渐变的矩形进行交集,将它们相交的地方画出来呢。 它们相交的地方好像恰好就是我们需要的效果呢。

这样,我们只需要先填充波形,然后在每组正弦线相交的封闭区域画一个以波峰和波谷为高的矩形,然后将这个矩形染色成渐变色。以这个矩形与波形做出交集,选择 SrcIn 模式,即能只显示相交部分矩形的这一块的颜色。 这个方案看起来可行,先试试。下面图是没有执行 Xfermode 的叠加图, 从图中可以看出,两个正弦线中间的区域正是我们需要的!


下面是执行 SrcIn 模式混合之后的图像:


神奇的事情出现了, 视觉图中的效果被还原了。

我们再依葫芦画瓢,再绘制另外一组正弦曲线。 这里需要注意的是,由于 Xfermode 中的 Dst 指的原有的背景,因此这里两组正弦线的混合会互相产生影响。 即第二组在调用 SrcIn 模式进行混合的时候,会将第一组的图形进行剪切。如下图所示:


因此在绘制的时候,必须将两组正弦曲线分开单独绘制在不同 Canvas 层上。 好在 Android 系统为我们提供了这个功能,Android 提供了不同 Canvas 层,以用于进行离屏缓存的绘制。我们可以先绘制一组图形,然后调用 canvas.saveLayer 方法将它存在离屏缓存中,然后再绘制另外一组曲线。最后调用 canvas.restoreToCount(sc);方法恢复 Canvas,将两屏混合显示。最后的效果图如下所示:


这里总结一下绘制的顺序:

1、计算出曲线需要绘制的点

2、填充出正弦线

3、在每组正弦线相交的地方,根据波峰波谷绘制出一个渐变线填充的矩形。并且设置图形混合模式为 SrcIn

 mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); 

4、对正弦线进行描边

5、离屏存储 Canvas,再进行下一组曲线的绘制

静态的绘制已经完成了。接下来就是让它动起来了。 根据上面给出来的框架,在绘制线程中会定时执行 doDraw 方法。我们只需要在 doDraw 方法中每次将波形往前移动一个距离,即可达到让波形往前移动的效果。具体对应到正弦公式y=Asin(ωx+φ)+k中的 φ 值,每次只需要在原有值的基础上修改这个值即能改变波形在 X 轴的位置。每次执行 doDraw 都会根据下面的计算方法重新计算图形的初相值:

this.mPhase = (float) ((this.mPhase + Math.PI * mSpeed) % (2 * Math.PI));

在计算波形高度的时候,还可以乘以音量大小。即正弦公式中的 A 的值可以为 volume 绘制的最大高度 425/(4+x4)。 这样波形的振幅即能与音量正相关。波形可以随着音量跳动大小。

动画的优化

虽然上面已经实现了波形的动画。但是如果以为工作已经结束了,那就真是太 sample,naive了。

现在手机的分辨率变的越来越大,一般都是1080p的分辨率。随着分辨率的增加,图形绘制所需要的计算量也越来越大(像素点多了)。这样导致在某些低端手机中,或某些伪高端手机(比如某星S4)中,CPU 的计算能力不足,从而导致动画的卡顿。 因此对于自绘动画,可能还需要不断的进行代码和算法的优化,提高绘制的效率,尽量减少计算量。

自绘动画优化的最终目的是减少计算量,降低 CPU 的负担。为了达到这个目的,笔者总结归纳了以下几种方法,如果大家有更多更好的方法,欢迎分享:

1、降低分辨率

在实际动画绘制的过程中,如果对每个像素点的去计算(x,y)值,会导致大量的计算。但是这种密集的计算往往都是不需要的。 对于动画,人的肉眼是有一定的容忍度的,在一定范围内的图形失真是无法察觉的,特别是那种一闪而过的东西更是如此。 这样在实现的时候,可以都自己拟定一个比实际分辨率小很多的图形密度,这个图形密度上来计算 Y 值。然后将我们自己定义的图形密度成比例的映射到真实的分辨率上。 比如上面绘制正弦曲线的时候,我们完全可以只计算100个点。然后将这60个点成比例的放在1024个点的X轴上。 这样我们一下子便减少了接近10倍的计算量。这有点类似栅格化一副图片。

由于采用了低密度的绘制,将这些低密度的点用直线连接起来,会产生锯齿的现象,这样同样会对体验产生影响。但是别怕,Android 已经为我们提供了抗锯齿的功能。在 Paint 类中即可进行设置:

mPaint.setAntiAlias(true);

使用 Android 优化过了的抗锯齿功能,一定会比我们每个点的去绘制效率更高。

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

2、减少实时计算量

我们知道在过去嵌入式设备中计算资源都是相当有限的,运行的代码经常需要优化,甚至有时候需要在汇编级别进行。虽然现在手机中的处理器已经越来越强大,但是在处理动画这种短时间间隔的大量运算,还是需要仔细的编写代码。 一般的动画刷新周期是16ms,这也意味着动画的计算需要尽可能的少做运算。

只要能够减少实时计算量的事情,都应该是我们应该做的。那么如何才能做到尽量少做实时运算呢? 一个比较重要的思维和方法是利用用空间来换取时间。一般我们在做自绘动画的时候,会需要做大量的中间运算。而这些运算有可能在每次绘制定时到来的时候,产生的结果都是一样的。这也意味着有可能我们重复的做出了需要冗余的计算。 我们可以将这些中间运算的结果,存储在内存中。这样下次需要的时候,便不再需要重新计算,只需要取出来直接使用即可。 比较常用的查表法即使利用这种空间换时间的方法来提高速度的。

具体针对本例而言, 在计算 425/(4+x4)  这个衰减系数的时候,对每个 X 轴上固定点来说,它的计算结果都是相同的。 因此我们只需要将每个点对应的 y 值存储在一个数组中,每次直接从这个数组中获取即可。这样能够节省出不少 CPU 在计算乘方和除法运算的计算量。 同样道理,由于 sin 函数具有周期性,因此我们只需要将这个周期中的固定 N 个点计算出值,然后存储在数组中。每次需要计算 sin 值的时候,直接从之前已经计算好的结果中找出近似的那个就可以了。 当然其实这里计算 sin 不需要我们做这样的优化,因为 Android 系统提供的 Math 方法库中计算 sin 的方法肯定已经运用类似的原理优化过了。

CPU 一般都有一个特点,它在快速的处理加减乘运算,但是在处理浮点型的除法的时候,则会变的特别的慢,多要多个指令周期才能完成。因此我们还应该努力减少运算量,特别是浮点型的除法运算。 一般比较通用的做法是讲浮点型的运算转换成整型的运算,这样对速度的提升也会比较明显。 但是整型运算同时也意味着会丢失数据的精确度,这样往往会导致绘制出来的图形有锯齿感。 之前有同事便遇到即使采用了 Android 系统提供的抗锯齿方法,但是绘制出来的图形锯齿感还是很强烈,有可能就是数值计算中的精确度的问题,比如采用了不正确的整型计算,或者错误的四舍五入。 为了保证精确度,同时还能使用整型来进行运算,往往可以将需要计算的参数,统一乘上一个精确度(比如乘以100或者1000,视需要的精确范围而定)取整计算,最后再将结果除以这个精确度。 这里还需要注意整型溢出的问题。

3、减少内存分配次数

Android 在内存分配和释放方面,采用了 JAVA 的垃圾回收 GC 模式。 当分配的内存不再使用的时候,系统会定时帮我们自动清理。这给我们应用开发带来了极大的便利,我们从此不再需要过多的关注内存的分配与回收,也因此减少很多内存使用的风险。但是内存的自动回收,也意味着会消耗系统额外的资源。一般的 GC 过程会消耗系统ms级别的计算时间。在普通的场景中,开发者无需过多的关心内存的细节。但是在自绘动画开发中,却不能忽略内存的分配。

由于动画一般由一个16ms的定时器来进行驱动,这意味着动画的逻辑代码会在短时间内被循环往复的调用。 这样如果在逻辑代码中在堆上创建过多的临时变量,会导致内存的使用量在短时间稳步上升,从而频繁的引发系统的GC行为。这样无疑会拖累动画的效率,让动画变得卡顿。

处理分析内存分配,减少不必要的分配呢, 首先我们需要先分析内存的分配行为。 对于Android内存的使用情况,Android Studio提供了很好用,直观的分析工具。 为了更加直观的表现内存分配的影响,在程序中故意创建了一些比较大的临时变量。然后使用Memory Monitor工具得到了下面的图:


并且在log中看到有频繁的打印D/dalvikvm: GC_FOR_ALLOC freed 3777K, 18% free 30426K/36952K, paused 33ms, total 34ms

图中每次涨跌的锯齿意味着发生了一次GC,然后又分配了多个内存,这个过程不断的往复。 从log中可以看到系统在频繁的发起GC,并且每次GC都会将系统暂停33ms,这当然会对动画造成影响。 当然这个是测试的比较极端的情况,一般来说,如果内存被更加稳定的使用的话,触发GC的概率也会大大的降低,上面图中的颠簸锯齿出现到概率也会越低。

上面内存使用的情况,也被称为内存抖动,它除了在周期性的调用过程中出现,另外一个高发场景是在for循环中分配、释放内存。它影响的不仅仅是自绘动画中,其他场景下也需要尽量避免。

从上图中可以直观的看到内存在一定时间段内分配和释放的情况,得出是否内存的使用是否平稳。但是当出现问题之后,我们还需要借助 Allocation Tracker 这个工具来追踪问题发生的原因,并最后解决它。Allocation Tracker  这个工具能够帮助我们追踪内存对象的分配和释放情况,能够获取内存对象的来源。比如上面的例子,我们在一段时间内进行追踪,可以得到如下图:


从图中我们可以看到大部分的内存分配都来自线程18 Thread 18,这也是我们的动画的绘制线程。 从图中可以看到主要的内存分配有以下几个地方:

1、我们故意创建的临时大数组

2、来自 getColor 函数, 它来自对 getResources().getColor()的调用,需要获取从系统资源中获取颜色资源。这个方法中会创建多个 StringBuilder 的变量

3、创建 Xfermode 的临时变量,来自 mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); 这个调用。

4、创建渐变值的 LinearGradient gradient = new LinearGradient(getXPos(startX), startY, getXPos(startX), endY,
               gradientStartColor, gradientEndColor, Shader.TileMode.REPEAT);

对于第2、3,这些变量完全不需要每次循环执行的时候,重复创建变量。 因为每次他们的使用都是固定的。可以考虑将它们从临时变量转为成员变量,在动画初始化的同时也将这些成员变量初始化好。需要的时候直接调用即可。

而对于第4类这样的内存分配,由于每次动画中的波形形状都不一样,因此渐变色必现得重新创建并设值。因此这里并不能将它作为成员变量使用。这里是属于必须要分配的。好在这个对象也不大,影响很小。

对于那些无法避免,每次又必须分配的大量对象,我们还能够采用对象池模型的方式来分配对象。对象池来解决频繁创建与销毁的问题,但是这里需要注意结束使用之后,需要手动释放对象池中的对象。

经过优化的内存分配,会变得平缓很多。比如对于上面的例子。 去除上面故意创建的大量数组,以及优化了2、3两个点之后的内存分配如下图所示:


可以看出短时间内,内存并没有什么明显的变化。并且在很长一段时间内都没有触发一次 GC

4、减少 Path 的创建次数

这里涉及到对特殊规则图形的绘制的优化。 Path 的创建也涉及到内存的分配和释放,这些都是需要消耗资源的。并且对于越复杂的 Path,Canvas 在绘制的时候,也会更加的耗时。因此我们需要做的就是尽量优化 Path 的创建过程,简化运算量。这一块并没有很多统一的标准方法,更多的是依靠经验,并且将上面提到到的3点优化方法灵活运用。

首先 Path 类中本身即提供了数据结构重用的接口。它除了提供 reset 复位方法之外,还提供了 rewind 的方法。这样每次动画循环调用的时候,能够做到不释放之前已经分配的内存就能够重用。这样避免的内存的反复释放和分配。特别是对于本例中,每次绘制的 Path 中的点都是一样多的情况更加适用。

采用方法一种低密度的绘图方法,同样还能够减少 Path 中线段的数量,这样降低了 Path 构造的次数,同能 Canvas 在绘制 Path 的时候,由于 Path 变的简单了,同样能够加快绘制速度。

特别的,对于本文中的波形例子。 视觉图中给出来的效果图,除了要用渐变色填充正弦线中间的区域之外。还需要对正弦线本身进行描边。 同时一组正弦线中的上下两根正弦线的颜色还不一样。 这样对于一组完整的正弦线的绘制其实需要三个步骤:

1、填充正弦线

2、描正弦线上边沿

3、描正弦线下边沿

如何很好的将这三个步骤组合起来,尽量减少 Path 的创建也很有讲究。比如,如果我们直接按照上面列出来的步骤来绘制的话,首先需要创建一个同时包含上下正弦线的 Path,需要计算一遍上下正弦线的点,然后对这个 Path 使用填充的方式来绘制。 然后再计算一遍上弦线的点,创建只有上弦线的 Path,然后使用 Stroke 的模式来绘制,接着下弦线。 这样我们将会重复创建两边 Path,并且还会重复一倍点坐标的计算量。

如果我们能采用上面步骤2中提到的,利用空间换取时间的方法。 首先把所有点位置都记在一个数组中,然后利用这些点来计算并绘制上弦线的 Path,然后保存下来;再计算和绘制下弦线的 Path 并保存。最后创建一个专门记录填充区的 Path,利用 mPath.addPath();的功能,将之前的两个 path 填充到该 Path 中。 这样便能够减少 Path 的计算量。同时将三个 Path 分别用不同的变量来记录,这样在下次循环到来的时候,还能利用 rewind 方法来进行内存重用。

这里需要注意的是,Path 提供了 close的方法,来将一段线封闭。 这个函数能够提供一定的方便。但是并不是每个时候都好用。有的时候,还是需要我们手动的去添加线段来闭合一个区域。比如下面图中的情形,采用 close,就会导致中间有一段空白的区域:


5、优化绘制的步骤

什么? 经过上面几个步骤的优化,动画还是卡顿?不要慌,这里再提供一个精确分析卡顿的工具。 Android 还为我们提供了能够追踪监控每个方法执行时间的工具 TraceView。 它在 Android Device Monitor 中打开。比如笔者在开发过程中发现动画有卡顿,然后用上面 TraceView 工具查看得到下图:


发现 clapGradientRectAndDrawStroke 这个方法占用了72.1%的 CPU 时间,而这个方法中实际占用时间的是 drawPath。这说明此处的绘制存在明显的缺陷与不合理,大部分的时间都用在绘制 clapGradientRectAndDrawStroke 上面了。那么我们再看一下之前绘制的原理,为了能够从矩形和正弦线之间剪切出交集,并显示渐变区域。笔者做出了如下图的尝试:


首先绘制出渐变填充的矩形; 然后再将正弦线包裹的区域用透明颜色进行反向填充(白色区域),这样它们交集的地方利用 SrcIn 模式进行剪切,这时候显示出来便是白色覆盖了矩形的区域(实际是透明色)加上它们未交集的地方(正弦框内)。这样同样能够到达设计图中给出的效果。代码如下:

    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);

虽然上面的代码同样也实现了效果,但是由于使用的反向填充,导致填充区域急剧变大。最后导致 canvas.drawPath(mPath, mPaint);调用占据了70%以上的计算量。

找到瓶颈点并知道原因之后,我们就能做出针对性的改进。 我们只需要调整绘制的顺序,先将正弦线区域内做正向填充,然后再以 SrcIn 模式绘制渐变色填充的矩形。 这样减少了需要绘制的区域,同时也达到预期的效果。


下面是改进之后 TraceView 的结果截图:


从截图中可以看到计算量被均分到不同的绘制方法中,已经没有瓶颈点了,并且实测动画也变得流畅了。 一般卡顿都能通过此种方法比较精确的找到真正的瓶颈点。

总结

本文主要简单介绍了一下 Android 普通 View 和 SurfaceView 的绘制与动画原理,然后介绍了一下录音机波形动画的具体实现和优化的方法。但是限于笔者的水平和经验有限,肯定有很多纰漏和错误的地方。大家有更多更好的建议,欢迎一起分享讨论,共同进步。

你可能感兴趣的:(转载)