Android自定义 View + Canvas—声纹小球动画

一、概述

在语音识别、语音助手等应用中,需要实时展示音频输入状态。本文介绍如何从零实现一个声纹小球动画组件库,涵盖:

  • 基于 Canvas 的自定义 View
  • 使用 ValueAnimator 实现流畅动画
  • 贝塞尔曲线实现跳跃轨迹
  • 相位延迟实现波浪传播效果
  • 平滑插值算法避免动画突变

最终效果:多个小球(默认 3 个,支持 1-10 个)从左到右依次跳动,形成波浪传播,适用于语音识别、语音助手等场景。

二、效果展示

项目地址:github.com/qingfeng194...

组件支持两种状态:

  1. 待机状态:小球轻微起伏,呈现呼吸效果(使用正弦波实现)
  1. 讲话状态:根据输入振幅,小球从前往后依次沿贝塞尔曲线跳动

通过相位延迟,实现从左到右的波浪传播动画,视觉效果自然流畅。

三、技术方案

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

}

关键点:

  1. 在 onAttachedToWindow() 中启动动画
  1. 在 onDetachedFromWindow() 中停止动画并清理资源,避免内存泄漏
  1. 使用 ValueAnimator.INFINITE 实现无限循环
  1. 每帧更新动画时间 t 和平滑振幅 smoothAmp
  1. 调用 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)
 

关键点:

  1. offset = startOffset + i * 0.9f:每个小球相位偏移 0.9,形成时间差
  1. wave01(t + offset):使用偏移后的时间计算波形,实现波浪传播
  1. 0.9f 是经验值,控制波浪传播速度
  1. 叠加逻辑:p = idle + speak * wave01(t + offset) 将待机状态和讲话状态叠加,当 speak = 0 时只有待机状态,当 speak > 0 时讲话状态叠加在待机状态上
  1. 对 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>

关键点:

  1. typedArray.getDimension() 返回的是已转换为 px 的值(支持 dp、px、sp 等单位)
  1. 我们的成员变量存储的是 dp 值,需要将 px 值转换回 dp:px / displayMetrics.density
  1. 在 onDraw() 中再通过 dp() 方法转换为 px,避免重复转换
  1. 使用 try-finally 确保 typedArray.recycle() 被调用
  1. 在 View 构造函数中,resources 已可用,可以在 init 块中安全使用 dp() 方法

4.7 动态修改小球数量

支持通过代码动态修改 ballCount:

kotlin 复制代码
fun setBallCount(count: Int) {

    ballCount = count.coerceIn(1, 10)

    invalidate()

}

实现简单直接:限制数量范围后触发重绘。

五、性能优化实践

5.1 绘制优化

  1. 避免过度绘制
  • 只在需要时调用 invalidate()
  • ValueAnimator 自动控制刷新频率
  1. Paint 对象复用
  • 将 Paint 定义为成员变量,避免每次创建
  1. 计算优化
  • 预计算常量值
  • 避免在 onDraw() 中创建对象
  • dp 转 px 在 onDraw() 中执行,虽然每帧都会执行,但计算开销很小

5.2 动画性能

ValueAnimator 的优势:

  • 底层基于 Choreographer,与系统 VSYNC 同步
  • 目标 60fps,实际帧率取决于设备性能
  • 低端设备也能保持流畅

性能测试建议:

  • 使用 Android Studio Profiler 监控帧率
  • 在不同设备上测试
  • 关注内存使用情况

5.3 内存管理

关键点:

  1. 及时停止 ValueAnimator 动画
  1. 在 onDetachedFromWindow() 中清理资源
  1. 避免持有 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。

八、总结

本文介绍了如何实现一个声纹小球动画组件,涵盖:

  1. 自定义 View 基础:Canvas 绘制、Paint 配置
  1. ValueAnimator 动画:底层基于 Choreographer,与系统 VSYNC 同步,确保流畅
  1. 贝塞尔曲线:实现自然的跳跃轨迹
  1. 相位延迟:实现波浪传播效果
  1. 平滑插值:避免动画突变
  1. 性能优化:内存管理、绘制优化

技术要点:

  • ValueAnimator 底层基于 Choreographer,性能与直接使用 Choreographer 几乎无差异,且代码更简洁
  • 贝塞尔曲线可以创造自然的运动轨迹
  • 平滑算法对动画体验很重要
  • 及时清理资源,避免内存泄漏
  • 注意单位转换,避免二次转换问题

适用场景:

  • 语音识别应用
  • 语音助手
  • 音频录制可视化
  • 任何需要音频反馈的场景
相关推荐
_李小白2 小时前
【Android FrameWork】延伸阅读:AMS 的 handleApplicationCrash
android·开发语言·python
_李小白3 小时前
【Android FrameWork】第四十九天:SystemUI
android
Mr -老鬼3 小时前
移动端跨平台适配技术框架:从发展到展望
android·ios·小程序·uni-app
城东米粉儿3 小时前
compose measurePoliy 笔记
android
城东米粉儿3 小时前
Compose 延迟列表
android
GoldenPlayer3 小时前
SOLID原则-Software Develop
android
GoldenPlayer3 小时前
Android文件管理系统
android
冬奇Lab3 小时前
【Kotlin系列02】变量与数据类型:从val/var到空安全的第一课
android·kotlin·编程语言