7

前言

我们所熟知的,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 方法中每次将波形往前移动一个距离,即可达到让波形往前移动的效果。具体对应到正弦公式中的 φ 值,每次只需要在原有值的基础上修改这个值即能改变波形在 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 的绘制与动画原理,然后介绍了一下录音机波形动画的具体实现和优化的方法。但是限于笔者的水平和经验有限,肯定有很多纰漏和错误的地方。大家有更多更好的建议,欢迎一起分享讨论,共同进步。

慢慢学 · 2016年06月27日

很久没看到这么精彩的文章了

回复

iron826 · 2016年06月29日

path方面何不尝试下用贝塞尔曲线去绘制呢.

回复

duckmoon · 6月25日

非常棒的文章,代码可否学习下?

回复

载入中...