记一次Android动画性能优化总结

2,226 阅读4分钟

前言

入职新公司4个多月了,一直忙于需求没有再过多的输出技术博客,最近手里刚好接到一个动画的需求,对性能要求非常高,所以今天就针对于动画性能这一块来做一个较为细致的总结,也希望能在性能优化方面为大家贡献一点绵薄之力~

需求概述

这次的需求是针对于一个麦克风的显示动画,会非常频繁的在界面上出现,所以对于性能的把控尤为重要,首先我们的目标是要实现下面的这个样式。

device-2022-04-09-131444.png

然后呢,我们的素材就是一个原始的图片

microphone_icon.png

动画的效果是这样的

ezgif.com-gif-maker (2).gif

实现方案

为了做一个比较明显的对比,我们先介绍一个最差的实现方案,看代码

package com.tx.microphone

import android.animation.ValueAnimator
import android.animation.ValueAnimator.*
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View

/**
 * create by xu.tian
 * @date 2022/4/8
 */
class MicPhoneView1 : View {

    private val mPaint = Paint()
    var mVolumeFilledColor = Color.GREEN
    var mVolumePercent = 0
    var mBitmap : Bitmap? = null
    var mPath = Path()
    var mRect = RectF()
    var mVolumeH = 0F
    var mAnimator = ValueAnimator()
    init {
        setLayerType(LAYER_TYPE_SOFTWARE, mPaint)
        mBitmap = BitmapFactory.decodeResource(context.resources, R.drawable.microphone_icon)
        mPaint.color = mVolumeFilledColor
        mPaint.style = Paint.Style.FILL
        initAnimator()
    }
    constructor(context: Context) : super(context) {

    }
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {

    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawBitmap(mBitmap!!, 0F, 0F, mPaint)
        canvas.clipPath(mPath)
        canvas.drawRect(mRect.left, mRect.top + (100 - mVolumePercent).toFloat() / 100 * mVolumeH, mRect.right, mRect.bottom, mPaint)
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        mBitmap = Bitmap.createScaledBitmap(mBitmap!!, w, h, false)
        updateVolumePath(w, h)
    }

    /**
     * 按中间话筒的形状得到一个path
     */
    private fun updateVolumePath(width: Int, height: Int) {
        val left = width.toFloat() / 200 * 62
        val top = height.toFloat() / 200 * 12
        val right = width.toFloat() / 200 * 138
        val bottom = height.toFloat() / 200 * 125
        mRect.left = left
        mRect.top = top
        mRect.right = right
        mRect.bottom = bottom
        mVolumeH = bottom - top

        mPath.reset()
        mPath.moveTo(left , top + mVolumeH / 3 )
        mPath.arcTo(RectF(left, top, right, bottom - mVolumeH / 3), 180F, 180F)
        mPath.lineTo(right, top + mVolumeH / 3)
        mPath.arcTo(RectF(left, top + mVolumeH /3, right, bottom), 0F, 180F)
        mPath.lineTo(left, top + mVolumeH / 3)
        mPath.close()
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        cancelAnimator()
    }

    private fun drawVolume(volume: Int) {
        if (volume == mVolumePercent) {
            return
        }
        mVolumePercent = volume
        postInvalidate()
    }

    private fun initAnimator() {
        mAnimator.setIntValues(0, 100)
        mAnimator.duration = 1000
        mAnimator.addUpdateListener {
            drawVolume(it.animatedValue as Int)
        }
        mAnimator.repeatMode = REVERSE
        mAnimator.repeatCount = INFINITE
    }

    private fun cancelAnimator() {
        if (mAnimator.isStarted || mAnimator.isRunning) {
            mAnimator.cancel()
        }
    }

    fun startAnimation() {
        cancelAnimator()
        mAnimator.start()
    }


}

方案概述

很简单,就是每次先绘制这个图片,然后根据话筒形状获得一个Path,这样的话可以按这个Path来切割画布,然后再根据音量的大小绘制内部的矩形。那么我们来看一下debug包下的cpu性能(release的性能数据会比debug好一点)

捕获1.PNG 稳定以后,差不多会占到接近10%,测试机是一款很低端的红米手机

目前实现的方案

先用一个容器将这个图片和这个音量条的view分开绘制,然后更新的时候单独更新音量条view 布局文件

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <androidx.appcompat.widget.AppCompatImageView
        android:id="@+id/mic_icon"
        app:srcCompat="@drawable/microphone_icon"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
    <com.tx.microphone.VolumeView
        android:id="@+id/volume_view"
        android:layout_centerHorizontal="true"
        android:layout_alignParentTop="true"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
</RelativeLayout>

整个控件的View

package com.tx.microphone

import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.widget.RelativeLayout
import java.util.*

/**
 * create by xu.tian
 * @date 2022/4/8
 */
class MicPhoneView2 : RelativeLayout {

    private val mPaint = Paint()
    var mVolumeFilledColor = Color.GREEN
    var mNewVolume = 0
    var mRect = RectF()
    var mVolumeH = 0F
    var mVolumeView : VolumeView? = null
    var mCycleFrameCount = -1
    val mTimer = Timer()
    var mTimerTask : TimerTask? = null
    fun startTimerTask() {
        mTimerTask = object : TimerTask() {
            override fun run() {
                mCycleFrameCount = (mCycleFrameCount + 1) % 40
                mVolumeView?.drawVolume(getCycleFrame())
            }
        }
        mTimer.schedule(mTimerTask, 0, 50)
    }

    fun cancelTimerTask() {
        mTimerTask?.cancel()
        mTimerTask = null
        mCycleFrameCount = -1
    }

    fun getCycleFrame() : Int{
        return if (mCycleFrameCount <= 20) {
            100 / 20 * mCycleFrameCount
        } else {
            (40 - mCycleFrameCount) * 100 / 20
        }
    }

    init {
        val view = inflate(context, R.layout.layout_mic_view, this)
        mVolumeView = view.findViewById(R.id.volume_view)
        mPaint.color = mVolumeFilledColor
        mPaint.style = Paint.Style.FILL
    }
    constructor(context: Context) : super(context) {

    }
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {

    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        post {
            updateVolumePath(w, h)
        }

    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        // updateVolumePath(width, height)
    }

    /**
     * 按中间话筒的形状得到一个path
     */
    private fun updateVolumePath(width: Int, height: Int) {
        val left = width.toFloat() / 200 * 62
        val top = height.toFloat() / 200 * 12
        val right = width.toFloat() / 200 * 138
        val bottom = height.toFloat() / 200 * 125
        mRect.left = left
        mRect.top = top
        mRect.right = right
        mRect.bottom = bottom
        mVolumeH = bottom - top

        var layoutParams =  mVolumeView?.layoutParams as LayoutParams
        layoutParams.width = (right - left).toInt()
        layoutParams.height = mVolumeH.toInt()
        layoutParams.setMargins(0, top.toInt(), 0, 0)
        mVolumeView?.layoutParams = layoutParams
        mVolumeView?.requestLayout()

    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
    }

}

然后是音量条view

package com.tx.microphone

import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View

/**
 * create by xu.tian
 * @date 2022/4/9
 */
class VolumeView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    private var mLastVolume = 0
    private var mNewVolume = 100
    private var mPaint = Paint()
    private var mPaintColor = Color.GREEN
    private var mPath = Path()
    init {
        setLayerType(LAYER_TYPE_HARDWARE, mPaint)
        mPaint.color = mPaintColor
        mPaint.style = Paint.Style.FILL
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.clipPath(mPath)
        canvas.drawRect(0F, (100 - mNewVolume).toFloat() / 100 * height, width.toFloat(), height.toFloat(), mPaint)
        mLastVolume = mNewVolume
    }


    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        val mVolumeH = h.toFloat()
        mPath.reset()
        mPath.moveTo(0F , mVolumeH / 3)
        mPath.arcTo(RectF(0F, 0F, w.toFloat(), mVolumeH / 3 * 2), 180F, 180F)
        mPath.lineTo(w.toFloat(), mVolumeH / 3 * 2)
        mPath.arcTo(RectF(0F, mVolumeH / 3, w.toFloat(), mVolumeH), 0F, 180F)
        mPath.lineTo(0F, mVolumeH / 3)
        mPath.close()
    }

    fun drawVolume(volume: Int) {
        mNewVolume = volume
        if (mLastVolume == mNewVolume) {
            return
        }
        val rect = getChangeRect()
        postInvalidate(rect.left.toInt(), rect.top.toInt(), rect.right.toInt(), rect.bottom.toInt())
    }

    private fun getChangeRect() : RectF{
        return if (mNewVolume > mLastVolume) {
            RectF(0F, (100 - mNewVolume).toFloat() / 100 * height , width.toFloat(), (100 - mLastVolume).toFloat() / 100 * height)
        } else {
            RectF(0F, (100 - mLastVolume).toFloat() / 100 * height , width.toFloat(), (100 - mNewVolume).toFloat() / 100 * height)
        }
    }

}

目前的性能优化的思路

  • 将音量条与图片剥离,更新视图时就省去了绘制图片的开销
  • 将音量条的绘制控制在指定范围,不要整个重绘,拿新的一帧和上一帧做比较,绘制增量的部分
  • 将ValueAnimator替换为timerTask,这样可以省去不必要的计算开销,尤其是在动画的帧数比较低的时候,这个优化收益会非常的高 那么我们再来看这样实现的cpu占用

捕获2.PNG 好家伙,cpu直接就控制在了1%-2%之间,比起最开始的10%左右,这个简直是天差地别,然后动画的效果几乎也是一模一样的,所以关于动画方面性能的优化的重要性也能看出来了,一个好的动画不会让你的手机变成一个发烫的暖手宝~

结尾,打个小广告~

上海字节飞书音视频团队招人中~Android岗位可以找我内推!wx号在此,麻烦备注 “掘金内推 姓名+工作年限+毕业院校”

微信图片_20220409135138.jpg

期待你和我们一起做更有意义的事~