一、概述
在语音识别、语音助手等应用中,需要实时展示音频输入状态。本文介绍如何从零实现一个声纹小球动画组件库,涵盖:
- 基于 Canvas 的自定义 View
- 使用 ValueAnimator 实现流畅动画
- 贝塞尔曲线实现跳跃轨迹
- 相位延迟实现波浪传播效果
- 平滑插值算法避免动画突变
最终效果:多个小球(默认 3 个,支持 1-10 个)从左到右依次跳动,形成波浪传播,适用于语音识别、语音助手等场景。
二、效果展示
项目地址:github.com/qingfeng194...

组件支持两种状态:
- 待机状态:小球轻微起伏,呈现呼吸效果(使用正弦波实现)
- 讲话状态:根据输入振幅,小球从前往后依次沿贝塞尔曲线跳动
通过相位延迟,实现从左到右的波浪传播动画,视觉效果自然流畅。
三、技术方案
3.1 自定义 View + Canvas
优势:
- 性能:直接使用 Canvas 绘制,避免多 View 层级带来的性能问题
- 减少测量与布局(Measure/Layout)的开销:Android 绘制界面时,会对 View 树进行自上而下的递归式测量和布局。层级越深、节点越多,递归次数越多,CPU 计算开销越大,在界面刷新、屏幕旋转、列表 Item 复用等场景下会明显拖慢界面响应速度。
- 避免过度绘制(Overdraw):多 View 层级嵌套时,同一屏幕像素点可能被多次绘制。层级越深,嵌套的带背景/遮罩的布局越多,过度绘制问题越严重,极端情况下会导致界面帧率下降。
- 灵活性:可精确控制每个小球的运动轨迹
- 可扩展性:易于添加新功能和自定义样式
3.2 组件库设计思路
参考开源项目 ShapeView 的设计模式,采用 app:voicewave_xxx 的命名规范,支持:
- XML 属性配置:方便在布局文件中使用
- 代码动态修改:运行时灵活调整
- 完整的 getter/setter:符合 Java Bean 规范
四、核心实现详解
4.1 自定义 View 基础结构
首先定义 VoiceWaveView 类,继承自 View:
kotlin
class VoiceWaveView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
}
@ColorInt
private var ballColor: Int = 0xFFFFFFFF.toInt()
private var ballCount: Int = 3
private var ballRadius: Float = 6.8f // dp,将在 onDraw 中转换为 px
private var ballGap: Float = 20f // dp,将在 onDraw 中转换为 px
private var maxJumpHeight: Float = 14f // dp,将在 onDraw 中转换为 px
// 动画相关
private var animationSpeed: Float = 0.06f // 每帧时间增量
private var amplitudeSensitivity: Float = 1.25f
private var idleAmplitude: Float = 0.16f
private var smoothFactor: Float = 0.85f // 平滑因子
// 动画状态
private var amp01: Float = 0f // 输入的振幅值(0-1)
private var t: Float = 0f // 动画时间
private var smoothAmp: Float = 0f // 平滑后的振幅值
private var animator: ValueAnimator? = null
init {
// 解析 XML 属性
attrs?.let { parseAttributes(it, defStyleAttr) }
}
// dp 转 px 的工具方法
private fun dp(v: Float): Float = v * resources.displayMetrics.density
}
要点:
- 使用 Paint.ANTI_ALIAS_FLAG 开启抗锯齿,确保小球边缘平滑
- 使用 @JvmOverloads 支持 Java 调用
- 成员变量使用 dp 单位存储,在 onDraw() 中通过 dp() 方法转换为 px
- dp() 方法依赖 DisplayMetrics 进行正确的单位转换,适配不同分辨率设备
- 注意:在 View 构造函数中,resources 已可用(View(context) 构造函数会初始化),因此可以在 init 块中安全使用
4.2 ValueAnimator 实现流畅动画
为什么选择 ValueAnimator?ValueAnimator 是 Android 官方提供的属性动画 API,在 Android 5.0+ 之后底层完全基于 Choreographer 实现:
- 自动注册 Choreographer 的帧回调,与系统 VSYNC 信号严格同步
- 不存在刷新率不同步的问题,默认就能保证 60fps 流畅度
- onAnimationUpdate 回调的触发时机与 Choreographer 帧回调完全一致
- 提供了 TimeInterpolator、TypeEvaluator 等封装,更易控制动画节奏
- 作为官方上层封装,性能开销与直接使用 Choreographer 几乎无差异
- 代码更简洁,生命周期管理更方便
实现代码:
kotlin
override fun onAttachedToWindow() {
super.onAttachedToWindow()
startAnimation()
}
override fun onDetachedFromWindow() {
stopAnimation()
super.onDetachedFromWindow()
}
private fun startAnimation() {
if (animator != null) return
// 创建无限循环的 ValueAnimator
animator = ValueAnimator.ofFloat(0f, 1f).apply {
repeatCount = ValueAnimator.INFINITE
duration = Long.MAX_VALUE // 无限时长
addUpdateListener { animation ->
// 更新动画时间(固定增量)
t += animationSpeed
// 平滑插值:避免振幅突变
smoothAmp = smoothAmp * smoothFactor + amp01 * (1f - smoothFactor)
// 触发重绘
invalidate()
}
start()
}
}
private fun stopAnimation() {
animator?.cancel()
animator = null
}
关键点:
- 在 onAttachedToWindow() 中启动动画
- 在 onDetachedFromWindow() 中停止动画并清理资源,避免内存泄漏
- 使用 ValueAnimator.INFINITE 实现无限循环
- 每帧更新动画时间 t 和平滑振幅 smoothAmp
- 调用 invalidate() 触发重绘
注意:当前实现中,t += animationSpeed 使用固定增量,这意味着在高刷新率设备(如 120fps)上,动画播放速度会更快。这是当前实现的特性。
4.3 贝塞尔曲线实现跳跃轨迹
为了让小球有自然的跳跃轨迹,使用二次贝塞尔曲线。数学原理:二次贝塞尔曲线公式:
B(t) = (1-t)²·P₀ + 2(1-t)·t·P₁ + t²·P₂
其中:
- P₀:起始点
- P₁:控制点
- P₂:结束点
- t:参数,范围 [0, 1]
实现代码:
kotlin
private fun bezierArcY(t: Float): Float {
val p0 = 0f // 起始点:底部
val p1 = 1f // 控制点:顶部
val p2 = 0f // 结束点:底部
val u = 1f - t
return u * u * p0 + 2f * u * t * p1 + t * t * p2
}
分析:
- 当 t = 0 时,y = 0(底部)
- 当 t = 0.5 时,y = 0.5(中间最高点)
- 当 t = 1 时,y = 0(底部)
简化公式:
y = 2(1-t)·t
这个函数返回值的范围是 [0, 0.5],在 t = 0.5 时达到最大值 0.5。在 onDraw() 中使用时:
val y = cy - bezierArcY(p.coerceIn(0f, 1f)) * maxJump
注意:
- 函数内部没有对参数 t 做范围限制
- 在调用时对参数 p 做了 coerceIn(0f, 1f) 限制,确保传入的值在有效范围内
- 由于 bezierArcY() 的最大值是 0.5,实际跳跃高度是 maxJumpHeight * 0.5
4.4 相位延迟实现波浪传播
要实现从左到右的波浪传播,需要为每个小球设置不同的相位偏移。核心逻辑:
- 相位偏移的本质是为每个小球设置不同的动画时间偏移量
- 使得多个小球的动画节奏存在时间差,从而形成波浪传播效果
- offset = startOffset + i * 0.9f 中,0.9f 是相位差系数,控制相邻小球之间的时间差
实现代码:
scss
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val w = width.toFloat()
val h = height.toFloat()
if (w <= 0f || h <= 0f) return
val cy = h / 2f
val centerX = w / 2f
val gap = dp(ballGap) // dp 转 px
val baseR = dp(ballRadius) // dp 转 px
val maxJump = dp(maxJumpHeight) // dp 转 px
// 待机状态:轻微起伏(使用正弦波)
val idle = idleAmplitude + 0.06f * sin(t)
// 讲话状态:输入振幅驱动
val speak = (smoothAmp * amplitudeSensitivity).coerceIn(0f, 1f)
paint.color = ballColor
// 计算起始偏移量,使小球居中
val startOffset = -(ballCount - 1) * 0.5f * 0.9f
// 绘制多个小球
for (i in 0 until ballCount) {
// 为每个小球设置不同的相位偏移
// 0.9f 是相位差系数:值越大,相位差越大,波浪传播越慢;值越小,相位差越小,波浪传播越快
val offset = startOffset + i * 0.9f
// 计算当前小球的波形值
// 叠加逻辑:p = idle + speak * wave01(t + offset)
// idle 是待机状态的振幅,speak 是讲话状态的振幅
// 当 speak = 0 时,只有待机状态;当 speak > 0 时,讲话状态叠加在待机状态上
val p = idle + speak * wave01(t + offset)
// 计算小球位置
val x = centerX + (i - (ballCount - 1) * 0.5f) * gap
val y = cy - bezierArcY(p.coerceIn(0f, 1f)) * maxJump
// 小球半径随振幅变化
val r = baseR * (1f + 0.18f * p)
canvas.drawCircle(x, y, r, paint)
}
}
// 将正弦波转换为 0-1 范围
private fun wave01(x: Float): Float =
((sin(x) + 1f) * 0.5f).coerceIn(0f, 1f)
关键点:
- offset = startOffset + i * 0.9f:每个小球相位偏移 0.9,形成时间差
- wave01(t + offset):使用偏移后的时间计算波形,实现波浪传播
- 0.9f 是经验值,控制波浪传播速度
- 叠加逻辑:p = idle + speak * wave01(t + offset) 将待机状态和讲话状态叠加,当 speak = 0 时只有待机状态,当 speak > 0 时讲话状态叠加在待机状态上
- 对 p 做 coerceIn(0f, 1f) 限制,确保值在有效范围内
4.5 平滑插值算法
直接使用输入的振幅值会导致动画突变,需要平滑处理。使用一阶指数平滑算法(也叫简单指数平滑):
ini
smoothAmp = smoothAmp * smoothFactor + amp01 * (1f - smoothFactor)
原理:
- smoothFactor = 0.85:保留 85% 的旧值
- (1 - smoothFactor) = 0.15:使用 15% 的新值
- 值越大,变化越平滑,但响应越慢
算法特点:
- 适用于无趋势、无季节性的平稳数据
- 当 amp01 突变时(如从 0 直接跳到 1),该算法会有滞后性
- 默认值 0.85 是兼顾平滑度和响应速度的经验值
为什么不设置为 1?
- 设置为 1 会导致 smoothAmp 恒定不变,失去对原始振幅的响应性,动画失效
- 取值需权衡「平滑过渡」和「响应速度」:越接近 1 越平滑但越滞后,反之则越灵敏但越生硬
效果对比:
- 不使用平滑:动画会突然跳动
- 使用平滑:动画过渡自然
4.6 XML 属性解析
支持在 XML 中配置属性,使用 TypedArray 解析:
scss
private fun parseAttributes(attrs: AttributeSet, defStyleAttr: Int) {
val typedArray = context.obtainStyledAttributes(
attrs,
com.voiceprintball.R.styleable.VoiceWaveView,
defStyleAttr,
0
)
try {
ballColor = typedArray.getColor(
com.voiceprintball.R.styleable.VoiceWaveView_voicewave_ballColor,
0xFFFFFFFF.toInt()
)
ballCount = typedArray.getInt(
com.voiceprintball.R.styleable.VoiceWaveView_voicewave_ballCount,
3
).coerceIn(1, 10) // 限制在 1-10 之间
// 注意:getDimension() 返回的是已转换为 px 的值
// 但我们的成员变量存储的是 dp 值,需要转换回 dp
ballRadius = typedArray.getDimension(
com.voiceprintball.R.styleable.VoiceWaveView_voicewave_ballRadius,
dp(6.8f) // 默认值先转 px
) / resources.displayMetrics.density // 转回 dp
ballGap = typedArray.getDimension(
com.voiceprintball.R.styleable.VoiceWaveView_voicewave_ballGap,
dp(20f)
) / resources.displayMetrics.density
maxJumpHeight = typedArray.getDimension(
com.voiceprintball.R.styleable.VoiceWaveView_voicewave_maxJumpHeight,
dp(14f)
) / resources.displayMetrics.density
animationSpeed = typedArray.getFloat(
com.voiceprintball.R.styleable.VoiceWaveView_voicewave_animationSpeed,
1.0f
) * 0.06f // 基础速度因子
amplitudeSensitivity = typedArray.getFloat(
com.voiceprintball.R.styleable.VoiceWaveView_voicewave_amplitudeSensitivity,
1.25f
)
idleAmplitude = typedArray.getFloat(
com.voiceprintball.R.styleable.VoiceWaveView_voicewave_idleAmplitude,
0.16f
).coerceIn(0f, 1f)
smoothFactor = typedArray.getFloat(
com.voiceprintball.R.styleable.VoiceWaveView_voicewave_smoothFactor,
0.85f
).coerceIn(0f, 1f)
} finally {
// 重要:必须回收资源
typedArray.recycle()
}
}
在 res/values/attrs.xml 中定义属性:
ini
<declare-styleable name="VoiceWaveView">
<attr name="voicewave_ballColor" format="color" />
<attr name="voicewave_ballCount" format="integer" />
<attr name="voicewave_ballRadius" format="dimension" />
<attr name="voicewave_ballGap" format="dimension" />
<attr name="voicewave_maxJumpHeight" format="dimension" />
<attr name="voicewave_animationSpeed" format="float" />
<attr name="voicewave_amplitudeSensitivity" format="float" />
<attr name="voicewave_idleAmplitude" format="float" />
<attr name="voicewave_smoothFactor" format="float" />
</declare-styleable>
关键点:
- typedArray.getDimension() 返回的是已转换为 px 的值(支持 dp、px、sp 等单位)
- 我们的成员变量存储的是 dp 值,需要将 px 值转换回 dp:px / displayMetrics.density
- 在 onDraw() 中再通过 dp() 方法转换为 px,避免重复转换
- 使用 try-finally 确保 typedArray.recycle() 被调用
- 在 View 构造函数中,resources 已可用,可以在 init 块中安全使用 dp() 方法
4.7 动态修改小球数量
支持通过代码动态修改 ballCount:
kotlin
fun setBallCount(count: Int) {
ballCount = count.coerceIn(1, 10)
invalidate()
}
实现简单直接:限制数量范围后触发重绘。
五、性能优化实践
5.1 绘制优化
- 避免过度绘制
- 只在需要时调用 invalidate()
- ValueAnimator 自动控制刷新频率
- Paint 对象复用
- 将 Paint 定义为成员变量,避免每次创建
- 计算优化
- 预计算常量值
- 避免在 onDraw() 中创建对象
- dp 转 px 在 onDraw() 中执行,虽然每帧都会执行,但计算开销很小
5.2 动画性能
ValueAnimator 的优势:
- 底层基于 Choreographer,与系统 VSYNC 同步
- 目标 60fps,实际帧率取决于设备性能
- 低端设备也能保持流畅
性能测试建议:
- 使用 Android Studio Profiler 监控帧率
- 在不同设备上测试
- 关注内存使用情况
5.3 内存管理
关键点:
- 及时停止 ValueAnimator 动画
- 在 onDetachedFromWindow() 中清理资源
- 避免持有 Activity 引用
六、使用指南
6.1 快速集成
Gradle 依赖(JitPack)
rust
allprojects {
repositories {
maven { url 'https://jitpack.io' }
}
}
dependencies {
implementation 'com.github.qingfeng19491001:VoiceprintBall:1.0.0'
}
本地模块方式
java
dependencies {
implementation project(':library')
}
6.2 XML 配置
ini
<com.voiceprintball.view.VoiceWaveView
android:id="@+id/voice_wave"
android:layout_width="match_parent"
android:layout_height="120dp"
app:voicewave_ballColor="#FFFFFF"
app:voicewave_ballCount="3"
app:voicewave_animationSpeed="1.0"
app:voicewave_amplitudeSensitivity="1.25" />
6.3 代码动态控制
scss
val voiceWaveView = findViewById<VoiceWaveView>(R.id.voice_wave)
// 设置小球颜色
voiceWaveView.setBallColor(Color.WHITE)
// 输入振幅值(0-1)
voiceWaveView.pushAmplitude01(0.5f)
// 设置动画速度
voiceWaveView.setAnimationSpeed(1.5f)
// 设置声纹灵敏度
voiceWaveView.setAmplitudeSensitivity(1.5f)
// 设置小球数量
voiceWaveView.setBallCount(5)
6.4 实际应用场景
场景 1:语音识别回调
rust
speechRecognizer.setOnResultListener { amplitude ->
// amplitude 范围 0-1
voiceWaveView.pushAmplitude01(amplitude)
}
场景 2:音频录制可视化
rust
audioRecorder.setOnAmplitudeListener { amplitude ->
voiceWaveView.pushAmplitude01(amplitude)
}
场景 3:手动控制(测试用)
kotlin
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) {
voiceWaveView.pushAmplitude01(progress / 100f)
}
}
// ...
})
七、开源与未来规划
项目已开源:github.com/qingfeng194...
未来规划:
- 支持更多小球样式(渐变、阴影等)
- 支持自定义动画曲线
- 添加更多预设样式
- 性能进一步优化
欢迎提交 Issue 和 Pull Request。
八、总结
本文介绍了如何实现一个声纹小球动画组件,涵盖:
- 自定义 View 基础:Canvas 绘制、Paint 配置
- ValueAnimator 动画:底层基于 Choreographer,与系统 VSYNC 同步,确保流畅
- 贝塞尔曲线:实现自然的跳跃轨迹
- 相位延迟:实现波浪传播效果
- 平滑插值:避免动画突变
- 性能优化:内存管理、绘制优化
技术要点:
- ValueAnimator 底层基于 Choreographer,性能与直接使用 Choreographer 几乎无差异,且代码更简洁
- 贝塞尔曲线可以创造自然的运动轨迹
- 平滑算法对动画体验很重要
- 及时清理资源,避免内存泄漏
- 注意单位转换,避免二次转换问题
适用场景:
- 语音识别应用
- 语音助手
- 音频录制可视化
- 任何需要音频反馈的场景