“无数颗风的心
在我们相爱的寂静里跳动。”
——巴勃罗·聂鲁达
给自己打个广告,小弟我在准备25届秋招和春招,如果有大佬觉得小弟写的还行,有机会面试得话推荐推荐,万分感谢!
文章最后有完整代码,需要的可以自提
用过微信的朋友都知道,如果你在聊天框输入“我想你了”,就会有满屏的星星✨落下,一直好奇这个效果是怎么实现的。正好今天读到这么美的诗句,加上最近在学自定义动画,马上学习并实现了上面诗句描述的爱心雨效果。这是我安卓学习笔记的第二篇,欢迎关注。
在这篇文章中,您将看到如下技术要点:1.属性动画的使用,2.如何用SurfaceView优化性能,图片压缩思想,内存防抖要点,回收复用思想。 发车,发车!
要实现这个动画 要分如下几步完成:
1. 画出N个爱心
2. 让爱心符合物理规律的飘落
3. 渐变和消失
下面我们一步步分析和实现效果:
(1)画出N个爱心
如何实现?分两步走:第一步搞出一个爱心,第二步搞出多个爱心。
1.1如何画出一个爱心?
首先,画出N个爱心,得先有一个爱心,这个爱心如何画出来呢?有两种方式:
1.通过path自定义路径然后绘制.
2.使用现有图片,加载到Bitmap进行绘制.
注意:采用1比较考验技术和审美,2比较考验内存,尤其是多个爱心图片同时显示在屏幕上,对内存压力较大,这里如何降低绘制的N个爱心对内存的占用就比较关键了,否则不光动画会卡,而且可能导致OOM.
这里我选用 2.使用现有图片,加载到Bitmap进行绘制的方法,因为我懒,比较省事。 那么这里就涉及到一个Bitmap图片压缩的问题。
首先一张图片在内存中的大小是 其宽 * 高 * 每个像素占用的大小,如果我们想减小图片占用的内存空间,只要调整这三个参数就行。而单个像素占用的字节数载Android中有如下规定
格式 | 所占空间 | 说明 |
---|---|---|
Bitmap.Config.ALPHA_8 | 1B | 该种格式表示图片只有透明度没有颜色,1个像素占用8位 |
Bitmap.Config.ARGB_4444 | 2B | 该种格式表示图片透明通道 A 及颜色 R、G、B 各占用4位,共16位 |
Bitmap.Config.ARGB_8888 | 4B | 该种格式表示图片透明通道 A 及颜色 R、G、B 各占用8位,共32位 |
Bitmap.Config.RGB_565 | 2B | 该种格式表示图片没有透明通道,颜色 R、G、B 各占用5、6、6位,共16位 |
以我使用的爱心图片为例:
其宽高为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的值的流程是:
- 解析图像元数据:
• 在解码图片前,BitmapFactory 会通过 Options.inJustDecodeBounds = true 解析图片的宽高,而不加载像素数据到内存中。这一步返回图片的实际宽高 outWidth 和 outHeight。
- 计算采样率 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! 我们看表:
方法 | 触发时机 | 是否获取最终宽高 |
---|---|---|
onAttachedToWindow | View 被附加到窗口 | 否,宽高未定 |
onMeasure | View 的测量阶段 | 否,需要手动测量 |
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取出图像,改变宽高和位置,绘制到不同的区域。这也是一个复用机制。
复用,复用,是非常重要的开发思想。
给出一个绘制完成的图片:
(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 使用双缓冲机制绘制内容:
-
绘制的内容首先写入后台缓冲区。
-
绘制完成后,后台缓冲区切换到前台显示。
• 这种机制可以减少绘制闪烁,提升视觉流畅性。
• 优化效果:
• 减少了屏幕内容直接更新时可能的闪烁或撕裂问题。
• 每帧的绘制完成后,才会提交更新,确保帧与帧之间的过渡平滑。
因此,我们将自定义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秒按自由落体规则计算新的横纵坐标,并请求重绘
第四步:处理消失的爱心,复用思想,将其回收到空闲队列,下次更新使用,同时每一步都要注意,不要在循环里创建新的对象,要复用
最后附上完整代码:
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();
}
}