Android 开发:自定义View与属性动画实现仿微信浪漫满屋💗爱心雨动画

933 阅读17分钟

“无数颗风的心

在我们相爱的寂静里跳动。”

——巴勃罗·聂鲁达

12月13日(5).gif

给自己打个广告,小弟我在准备25届秋招和春招,如果有大佬觉得小弟写的还行,有机会面试得话推荐推荐,万分感谢!

文章最后有完整代码,需要的可以自提

用过微信的朋友都知道,如果你在聊天框输入“我想你了”,就会有满屏的星星✨落下,一直好奇这个效果是怎么实现的。正好今天读到这么美的诗句,加上最近在学自定义动画,马上学习并实现了上面诗句描述的爱心雨效果。这是我安卓学习笔记的第二篇,欢迎关注。

在这篇文章中,您将看到如下技术要点:1.属性动画的使用,2.如何用SurfaceView优化性能,图片压缩思想,内存防抖要点,回收复用思想。 发车,发车!

image.png

要实现这个动画 要分如下几步完成:

1. 画出N个爱心

2. 让爱心符合物理规律的飘落

3. 渐变和消失

下面我们一步步分析和实现效果:

(1)画出N个爱心

如何实现?分两步走:第一步搞出一个爱心,第二步搞出多个爱心。

1.1如何画出一个爱心?

首先,画出N个爱心,得先有一个爱心,这个爱心如何画出来呢?有两种方式:

1.通过path自定义路径然后绘制.

2.使用现有图片,加载到Bitmap进行绘制.

注意:采用1比较考验技术和审美,2比较考验内存,尤其是多个爱心图片同时显示在屏幕上,对内存压力较大,这里如何降低绘制的N个爱心对内存的占用就比较关键了,否则不光动画会卡,而且可能导致OOM.

这里我选用 2.使用现有图片,加载到Bitmap进行绘制的方法,因为我懒,比较省事。 那么这里就涉及到一个Bitmap图片压缩的问题。

首先一张图片在内存中的大小是 其宽 * 高 * 每个像素占用的大小,如果我们想减小图片占用的内存空间,只要调整这三个参数就行。而单个像素占用的字节数载Android中有如下规定

格式所占空间说明
Bitmap.Config.ALPHA_81B该种格式表示图片只有透明度没有颜色,1个像素占用8位
Bitmap.Config.ARGB_44442B该种格式表示图片透明通道 A 及颜色 R、G、B 各占用4位,共16位
Bitmap.Config.ARGB_88884B该种格式表示图片透明通道 A 及颜色 R、G、B 各占用8位,共32位
Bitmap.Config.RGB_5652B该种格式表示图片没有透明通道,颜色 R、G、B 各占用5、6、6位,共16位

以我使用的爱心图片为例:

sp_dianzanhou_da.png

其宽高为150*150,如果选用Bitmap.Config.ARGB_8888模式,一个爱心就要占用 150 * 150 * 4B=90KB内存空间,如果我的屏幕上同时有100个爱心,那么就要占9MB空间。这是不可接受的,在实际开发中内存空间是比较宝贵的,如果某功能对内存占用过大,容易导致卡顿和OOM。

这里如何优化呢,首先,可以调整颜色显示模式,用占2B的模式,那么一张图的内存空间将从90KB->45KB.接着我们可以调整宽高,这里我们的业务场景对分辨率要求不是很高,可以调整其宽高进一步缩小。

所幸,BitMap支持BitmapFactory.Options进行图片参数的配置。如代码所示:

// Decode压缩选项
val options = BitmapFactory.Options().apply {
    inSampleSize = 4
    inPreferredConfig = Bitmap.Config.RGB_565
}

我配置了inSampleSize = 4,这个inSampleSize是什么呢?它是一个采样率压缩参数:其核心原理是按一定比例缩小图片在内存中的分辨率,即加载到内存的像素数据减少,而不是直接对文件中的图像数据修改。例如,inSampleSize == 4 ,则加载的图像为原始宽度/高度的1/4,像素数目的1/16。一般计算采样率inSampleSize的值的流程是:

  1. 解析图像元数据:

• 在解码图片前,BitmapFactory 会通过 Options.inJustDecodeBounds = true 解析图片的宽高,而不加载像素数据到内存中。这一步返回图片的实际宽高 outWidth 和 outHeight。

  1. 计算采样率 inSampleSize

• 根据目标显示区域的宽高和原图宽高,计算合适的 inSampleSize,保证解码后的图片宽高小于或接近目标宽高。

为了省事,我直接设置成4了。

接着,inPreferredConfig = Bitmap.Config.RGB_565,配置颜色模式为占2B空间的选项。

好的,看看压缩效果,原来100张图片的内存占用空间为 150 * 150 * 4B * 100=9MB 而经过压缩之后占9/2/16=0.28MB (对于100张爱心来说还算能接受吧)

1.2 如何画出N个爱心并确定其起始位置和大小?

在1.1小节,我们使用Bitmap加载爱心图片,并通过配置参数压缩其占用的内存空间。现在我们有了一个可以绘制的Bitmap,只要通过在Ondraw里执行:

canvas.drawBitmap(bmp, null, dstRect, paint)

方法调用,就能把Bitmap绘制到屏幕上的指定位置,注意看! 这里的drawBitmap方法参数有4个,bmp代表要绘制的Bitmap,null这里你可以指定一个资源(如已经加载过图片到bitmap就不需要),dstRect是你要绘制的目标区域,paint是你的画笔。

这里目标dstRect是一个Rect类型的实例,其作用是在当前View中定位一片矩形区域(通过left,bottom,top,right)定义,然后我们就能把bmp绘制到该区域。 如果我们要绘制100个不同大小的爱心,只要根据每个爱心的大小和位置,计算好各自的dstRect,绘制就行了。

好的,那么如何计算100个爱心的位置和大小呢?自然地,我们需要定义一个爱心数据类及其相关属性,用于保存其位置,大小,速度等信息。

data class Heart(
    var x: Float, //x坐标
    var y: Float, //y坐标
    var vX: Float,//x轴上的速度
    var vY: Float,//y轴上的速度
    val scaleFactor: Float,//缩放比例
    val gravity: Float,//重力加速度
    var alpha: Float = 1f// 透明度(1=不透明, 0=透明)
)

在绘制阶段,我们先关注其位置x,y和缩放比例scaleFactor,一个用于控制位置,一个用于控制大小。

对于每个爱心的位置和大小,我们要依赖当前View的宽高确定,那View的宽高何时确定? 是在onMeasure()方法中吗? No!No! 我们看表:

方法触发时机是否获取最终宽高
onAttachedToWindowView 被附加到窗口否,宽高未定
onMeasureView 的测量阶段否,需要手动测量
onSizeChanged宽高首次确定或发生变化(在OnDraw之前)是,宽高已定
onDraw绘制内容时是,可直接使用宽高

这么看我们在自定义View中,如果要参考View的宽高来确定某图形或图像的位置,onSizeChanged回调是最好的!好的,那就在onSizeChanged先确定每个爱心的位置和缩放比例吧。

private fun setupHearts(count: Int, viewWidth: Int) {
    hearts.clear()
    //循环遍历count次
    for (i in 0 until count) {
    
        val startX = Random.nextFloat() * viewWidth//将对象的起始 X 坐标随机分布在屏幕的水平范围内。
        val startY = Random.nextFloat() * height//将对象的起始 Y 坐标随机分布在屏幕的水平范围内。
        val vX = (Random.nextFloat() - 0.5f) * 600f
        val vY = 0f
        val scaleFactor = 0.6f + Random.nextFloat() * 1.0f //设定缩放参数
        val gravity = 200f + Random.nextFloat() * 800f
        val energyLoss = 0.9f + Random.nextFloat() * 0.25f
        val velocityThreshold = 40f

        hearts.add(
            Heart(
                x = startX,
                y = startY,
                vX = vX,
                vY = vY,
                scaleFactor = scaleFactor,
                gravity = gravity,
                energyLoss = energyLoss,
                velocityThreshold = velocityThreshold,
                alpha = 1f // 初始不透明
            )
        )
    }
}

其他不管,目前的重点看:

val startX = Random.nextFloat() * viewWidth//将对象的起始 X 坐标随机分布在View的宽度水平范围内。

val startY = Random.nextFloat() * height//将对象的起始 Y 坐标随机分布在View的高度水平范围内。

val scaleFactor = 0.6f + Random.nextFloat() * 1.0f //设定缩放参数

有了这三个参数就能实现绘制N个爱心了。

在此对绘制多个爱心的流程作一个总结:

1.首先我们定义了一个爱心数据类用于保存爱心的各种属性(位置,大小.....),以及一个BitMap用于加载和压缩爱心图片

2.创建一个列表 hearts 用于保存100个爱心的数据

3.在onSizeChanged回调中,执行初始化创建方法setupHearts 循环100次,确定每个爱心的相关属性。

4.在onDraw中根据每个爱心的位置和大小绘制每个爱心

来看在onDraw方法中的绘制流程

baseBitmap?.let { bmp ->
    for (heart in hearts) {
        if (heart.alpha > 0f) {
            val scaledW = bmp.width * heart.scaleFactor//根据缩放比例调整宽高
            val scaledH = bmp.height * heart.scaleFactor
            //确定left,top,right,bottom
            val left = heart.x - scaledW / 2f 
            val top = heart.y - scaledH / 2f
            // 使用 dstRect.set() 复用 RectF
            dstRect.set(left, top, left + scaledW, top + scaledH)
            //val dstRect = RectF(left, top, left + scaledW, top + scaledH)
            // 根据heart.alpha设置Paint透明度
            val alphaInt = (heart.alpha * 255).toInt().coerceIn(0, 255)
            paint.alpha = alphaInt
            canvas.drawBitmap(bmp, null, dstRect, paint)
        }
    }
}

其实很简单,其他设置不管,看重要的

首先是根据缩放比例确定最终图像宽高(这里由于进一步缩放了,图片占空间比之前计算的还更小),

接着确定绘制区域,确定left,top,right,bottom,用爱心的中心坐标减去宽高的一半先确定left,top,再确定,right,bottom。你停下来想想这个逻辑就明白了,仔细想想,没有那么难理解的。

注意这里有两个重要细节

第一我的目标dstRect是一个全局成员变量进行复用,因为如果在onDraw中频繁创建对象会导致内存抖动,应该记住这一点。

第二我的baseBitmap只有一个,即每次绘制都是从baseBitmap取出图像,改变宽高和位置,绘制到不同的区域。这也是一个复用机制。

复用,复用,是非常重要的开发思想。

给出一个绘制完成的图片:

Screenshot_20241214_094336.png

(2)让100个爱心动起来

在(1)中我们绘制了100个爱心, 接着考虑,如何让它动起来,实现飘落效果。 因此我们要考虑两个问题

1.这100个爱心飘落的规律是什么?它们按照什么规则运动?

2.知道了规则之后,如何让它们动起来?

首先是第一个问题,运动规则,每个爱心的运动规则是什么呢?我们想想,在现实生活中,花瓣飘落是一个多美的场景,那花瓣飘落有那些运动过程呢,无非就是垂直自由落体,加风吹导致的平移。 在这里,我模仿花瓣飘落的场景,让爱心做一个垂直的自由落体运动,并且下落的过程中将其按某方向不断轻微平移。

而垂直自由落体运动的过程涉及到三个因素,分别是:速度,重力加速度,时间和Y坐标 它们之间的关系是这样的 假设:

• 初始纵坐标的位置:y_0 = 0 px。

• 初始速度:vY0 = 0 px/s。

• 重力加速度:gravity = 200 px/s²。

时间来到了第0.1秒,则新的纵坐标位置应该通过如下公式更新: 即

最新速度=当前速度 * 时间*重力加速度

最新位置=当前位置+时间乘速度 如下:

vY1=vY0+v0.1s*gravity=20px/s

y1=y_0+20px*0.1s=0+2px=2px

最终Y1的位置来到了2px的位置

而时间来到0.2秒则

vY2=vY1+v0.2s*gravity=20+40=60px/s

y2=y_1+60px*0.2s=2+12px=14px

最终Y2的位置来到了14px的位置

可见这是一个随着时间的推移加速下落的过程,而调整我们的重力加速度的值就可以控制下来的速度。 对于横坐标X也是一样,但是我们在这里只是对X坐标做一个缓慢的线性增加,不改变其平移速度。

好了,我们分别通过重力加速度公式计算了垂直位置的变化Y_NEW和线性增加/减少来计算水平位置X_NEW的变化。还记得我们给每个爱心定义了数据类吗?在数据类中存放了它的X,Y坐标值,并且在绘制的时候根据每个X,Y的位置确定了它绘制的区域,随着X,Y的不断更新,我们的爱心就动起来了。

且慢,且慢! 还有一个问题没有解决呢,你怎么控制每隔0.1或者每隔0.01秒重新计算一次位置,然后更新坐标呢? 哎这时候就是用到属性动画---ValueAnimator的时候了

ValueAnimator 是 Android 动画系统中的一个核心类,用于创建数值变化动画。它会在一段时间内生成一系列的中间值(称为动画值),并通过回调通知我们这些值的变化,用于驱动动画的更新。

也就是说当我们设置了动画的起始值和结束值 如0f-1f,并设置了每次动画播放的时间 如1S 给ValueAnimator添加监听后,它能返回每个时间间隔中,动画属性的值 如 0.1s对应0.1。那么更新动画的逻辑就如代码所示:

animator = ValueAnimator.ofFloat(0f, 1f).apply {
    duration = 1000L
    repeatCount = ValueAnimator.INFINITE
    interpolator = LinearInterpolator()
    //添加回调监听,这里我们用系统记录的时间差值来做动画更新计算的参数,
    //更精准和丝滑,但道理是一样的。
    addUpdateListener {
        val currentTime = System.currentTimeMillis()
        val deltaT = if (lastTime == 0L) 0f else (currentTime - lastTime) / 1000f
        lastTime = currentTime
        if (deltaT > 0) {
            //根据时间差值计算新的横纵坐标值,并重绘
            updatePhysics(deltaT)
        }
        invalidate()
    }
}

updatePhysics(deltaT) 这个方法用来计算新的横纵坐标,并计算新的透明度值,计算有无越过view的宽高边界,如越过则消失,透明度衰减到0了也是消失

重要重要:针对消失的爱心,这里复用的思想又起到作用了,我们要把消失的爱心添加到空闲队列,这样下一帧更新图像的时候,我们就能从空闲队列里拿出来一个空闲对象,然后重新使用它,而不是重新创建新的爱心。


//添加一个新的爱心到队列
private fun spawnHeart() {
//空闲队列不为空,则从空闲队列取
    if(idleHearts.isNotEmpty()){
        val reusedHeart = idleHearts.removeAt(0)
        resetHeart(reusedHeart)
        hearts.add(reusedHeart)
        
     //否则,绘制队列小于100或等于0时才创建新的对象添加
    }else if(hearts.size<100||hearts.size==0){
        val newHeart = Heart(0f, 0f, 0f, 0f, 0f, 0f)
        resetHeart(newHeart)
        hearts.add(newHeart)
    }
}

通过这段代码即控制爱心总量又复用它们,防止占用频繁的创建和销毁对象导致的内存抖动,同时不要忘了,在VIEW被销毁的时候,清除队列和取消动画以及置空操作

(3)使用SurfaceView优化性能

但是到这里,动画总是那里怪怪的,卡的很,又继续查看优化思路和技术,发现了一个叫 SurfaceView 的东东,看了一下,牛逼,这个东西好。它的基本原理是 将绘制和计算流程放到子线程里完成,我们这里的计算和更新绘制要操作100次,且每隔0.1秒就执行2到3个循环非常耗时,放到子线程操作就不卡主线程了。

具体来看 独立的渲染线程

普通 View

• 绘制在主线程(UI 线程)上执行,调用 onDraw() 时与主线程的事件队列共享资源,容易造成阻塞。

SurfaceView:

• 提供了独立的 SurfaceHolder,允许开发者在独立线程中完成绘制操作,从而减少主线程的压力,提升绘制性能。

原理:

• SurfaceView 使用双缓冲机制绘制内容:

  1. 绘制的内容首先写入后台缓冲区。

  2. 绘制完成后,后台缓冲区切换到前台显示。

• 这种机制可以减少绘制闪烁,提升视觉流畅性。

优化效果:

• 减少了屏幕内容直接更新时可能的闪烁或撕裂问题。

• 每帧的绘制完成后,才会提交更新,确保帧与帧之间的过渡平滑。

因此,我们将自定义View继承自SurfaceView ,并且在surfaceCreated回调中创建一个绘制线程drawThread

override fun surfaceCreated(holder: SurfaceHolder) {
    drawThread = DrawThread(holder).also {
        it.running = true
        it.start()
    }
}

然后在其run方法中进行计算和绘制,并提交渲染

while (running) {
    val canvas = surfaceHolder.lockCanvas()
    if (canvas != null) {
        try {
            synchronized(surfaceHolder) {
                val currentTime = System.currentTimeMillis()
                val deltaT = (currentTime - lastTime) / 1000f
                lastTime = currentTime

                // 更新物理状态
                updatePhysics(deltaT)

                // 绘制内容
                drawHearts(canvas)
            }
        } finally {
            surfaceHolder.unlockCanvasAndPost(canvas)
        }
    }

好了,这次的开发完美完成,总结如下:

第一步,如何绘制一个爱心,采用Bitmap加载图片的方式,这个过程要注意压缩图片在内存中的大小

第二步,绘制100个爱心,设置数据类,保存每个爱心的位置,大小,速度信息,然后调用canvas绘制

第三步: 爱心动起来,使用属性动画作为其时间计时器,设置监听回调,每隔0.1秒按自由落体规则计算新的横纵坐标,并请求重绘

第四步:处理消失的爱心,复用思想,将其回收到空闲队列,下次更新使用,同时每一步都要注意,不要在循环里创建新的对象,要复用

12月13日(6).gif

最后附上完整代码:

package com.example.viewofanm.uiof

import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.SurfaceHolder
import android.view.SurfaceView
import com.example.viewofanm.R
import kotlin.random.Random

/**
 * @author Hongye yuan
 *
 */
class RainHeartSurfaceView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : SurfaceView(context, attrs, defStyleAttr), SurfaceHolder.Callback {
    //保存每个爱心的信息
    data class Heart(
        var x: Float,
        var y: Float,
        var vX: Float,
        var vY: Float,
        var scaleFactor: Float,
        var gravity: Float,
        var alpha: Float = 1f
    )

    private val hearts = mutableListOf<Heart>()   // 活跃的Heart列表
    private val idleHearts = mutableListOf<Heart>() // 空闲的Heart列表

    private var drawThread: DrawThread? = null
    private val dstRect = RectF()

    private var baseBitmap: Bitmap? = null
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)

    private val fadeSpeed = 0.02f
    private val spawnChance = 0.2f
    private val maxHearts = 100 // Heart的最大数量

    init {
        holder.addCallback(this) // 注册SurfaceHolder.Callback

        // 加载位图
        val options = BitmapFactory.Options().apply {
            inSampleSize = 4
            inPreferredConfig = Bitmap.Config.RGB_565
            inDither = true
        }
        baseBitmap = BitmapFactory.decodeResource(resources, R.mipmap.sp_dianzanhou_da, options)
    }

    private fun spawnHeart() {
        if (idleHearts.isNotEmpty()) {
            val reusedHeart = idleHearts.removeAt(0)
            resetHeart(reusedHeart)
            hearts.add(reusedHeart)
        } else if (hearts.size < maxHearts) {
            val newHeart = Heart(0f, 0f, 0f, 0f, 0f, 0f)
            resetHeart(newHeart)
            hearts.add(newHeart)
        }
    }

    private fun resetHeart(heart: Heart) {
        val w = width
        if (w <= 0) return

        heart.x = Random.nextFloat() * w
        heart.y = -Random.nextFloat() * height // 生成在屏幕的位置
        heart.vX = (Random.nextFloat() - 0.5f) * 100f // 水平随机速度
        heart.vY = 0f // 垂直初始速度
        heart.scaleFactor = 0.5f + Random.nextFloat() * 1.0f // 随机缩放
        heart.gravity = 100f + Random.nextFloat() * 100f // 随机重力
        heart.alpha = 1f // 恢复完全不透明
    }

    private fun updatePhysics(deltaT: Float) {
        val bottom = height.toFloat()
        if (Random.nextFloat() < spawnChance) {
            spawnHeart()
        }
        baseBitmap?.let { bmp ->
            val it = hearts.iterator()
            while (it.hasNext()) {
                val heart = it.next()
                // 下落
                heart.vY += heart.gravity * deltaT
                heart.y += heart.vY * deltaT
                heart.x += heart.vX * deltaT

                val scaledH = bmp.height * heart.scaleFactor
                val halfH = scaledH / 2f

                // 如果触底或透明度为0,重置并放入空闲列表
                if (heart.y + halfH >= bottom || heart.alpha <= 0f) {
                    idleHearts.add(heart) // 放入空闲列表
                    it.remove() // 从活跃列表移除
                    continue
                }

                // 渐隐效果
                heart.alpha -= fadeSpeed * deltaT
                if (heart.alpha <=0f) {
                    idleHearts.add(heart)
                    it.remove()
                }
            }
        }
    }

    override fun surfaceCreated(holder: SurfaceHolder) {
        drawThread = DrawThread(holder).also {
            it.running = true
            it.start()
        }
    }

    override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
    }

    override fun surfaceDestroyed(holder: SurfaceHolder) {
        drawThread?.running = false
        drawThread?.join()
    }

    //将绘制和计算放到子线程
    private inner class DrawThread(private val surfaceHolder: SurfaceHolder) : Thread() {
        var running = false

        override fun run() {
            var lastTime = System.currentTimeMillis()

            while (running) {
                val canvas = surfaceHolder.lockCanvas()
                if (canvas != null) {
                    try {
                        synchronized(surfaceHolder) {
                            val currentTime = System.currentTimeMillis()
                            val deltaT = (currentTime - lastTime) / 1000f
                            lastTime = currentTime

                            // 更新物理状态
                            updatePhysics(deltaT)

                            // 绘制内容
                            drawHearts(canvas)
                        }
                    } finally {
                        surfaceHolder.unlockCanvasAndPost(canvas)
                    }
                }
            }
        }
    }

    private fun drawHearts(canvas: Canvas) {
        canvas.drawColor(Color.WHITE) // 清空背景

        baseBitmap?.let { bmp ->
            for (heart in hearts) {
                val scaledW = bmp.width * heart.scaleFactor
                val scaledH = bmp.height * heart.scaleFactor
                val left = heart.x - scaledW / 2f
                val top = heart.y - scaledH / 2f
                dstRect.set(left, top, left + scaledW, top + scaledH)

                val alphaInt = (heart.alpha * 255).toInt().coerceIn(0, 255)
                paint.alpha = alphaInt

                canvas.drawBitmap(bmp, null, dstRect, paint)
            }
        }
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        idleHearts.clear()
        hearts.clear();
    }
}