前言
入职新公司4个多月了,一直忙于需求没有再过多的输出技术博客,最近手里刚好接到一个动画的需求,对性能要求非常高,所以今天就针对于动画性能这一块来做一个较为细致的总结,也希望能在性能优化方面为大家贡献一点绵薄之力~
需求概述
这次的需求是针对于一个麦克风的显示动画,会非常频繁的在界面上出现,所以对于性能的把控尤为重要,首先我们的目标是要实现下面的这个样式。
然后呢,我们的素材就是一个原始的图片
动画的效果是这样的
实现方案
为了做一个比较明显的对比,我们先介绍一个最差的实现方案,看代码
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好一点)
稳定以后,差不多会占到接近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占用
好家伙,cpu直接就控制在了1%-2%之间,比起最开始的10%左右,这个简直是天差地别,然后动画的效果几乎也是一模一样的,所以关于动画方面性能的优化的重要性也能看出来了,一个好的动画不会让你的手机变成一个发烫的暖手宝~
结尾,打个小广告~
上海字节飞书音视频团队招人中~Android岗位可以找我内推!wx号在此,麻烦备注 “掘金内推 姓名+工作年限+毕业院校”
期待你和我们一起做更有意义的事~