简介:声波曲线自定义控件是一套面向Android开发者的录音可视化解决方案,以正余弦函数模拟实时音频波动,将抽象的声音强度转化为直观的曲线动画,适合需要打造个性化录音界面的应用,也常被语音助手、社交App等场景采用。压缩包共51个文件,包含7个Java源码、21个XML布局与资源文件、Gradle构建配置、GIF演示动画及示例图片,总大小仅1.68MB,目录结构清晰,便于按模块学习或直接导入Android Studio工程。源码以VoiceLine-master项目为基础,完整演示了Canvas绘制正余弦曲线、录音数据与振幅映射、ValueAnimator动画驱动、录音事件监听、离屏渲染性能优化、自适应布局以及自定义属性样式等关键实现,开发者可对照代码逐段理解,并快速移植到自己的项目中。目前已有872人学习下载,适合具备Android基础、希望进阶自定义View和动画开发的工程师参考实践。
1. 声波曲线控件:为什么大家都在找现成轮子,却还是绕不开自己画
做 Android 录音或语音相关功能时,“声波曲线”几乎是绕不开的视觉元素。不管是语音输入后的波形回显、录音时的实时反馈,还是音乐播放器的频谱效果,本质都是在自定义 View 里把音频的振幅数据画成一条随时间变化的曲线。很多开发者第一反应是去 GitHub 找现成库,但实际接进去会发现:要么数据格式对不上,要么绘制性能扛不住高频刷新,要么只能在固定宽度下显示,一换需求就重构。这个标题背后的真实诉求,是让你能在不依赖第三方库的情况下,用 Canvas 和 AudioRecord 自己捏一个可控、可扩展、性能达标的波形控件。
这篇文章会把这条路完整走一遍:从音频数据怎么来、转换成什么格式,到自定义 View 的绘制核心、缓存与性能优化,再到实际接入时的参数调节和典型坑。我讲的不是某个开源项目的源码解读,而是我自己在多个项目里反复用的一套方案。适合谁看?刚接触自定义 View 的初中级开发者,以及被第三方波形库坑过、想彻底掌握主动权的人。读完你应该能独立写出一版支持实时刷新、可调颜色、可暂停恢复的波形控件。
2. 声波数据的底层逻辑:从麦克风到 Canvas 之间的那座桥
2.1 用 AudioRecord 采集 PCM 数据,而不是 MediaRecorder
要做波形,首先得有数据源。常见做法是用MediaRecorder,但它封装得太死,拿不到实时振幅,只能拿到音量分贝级别的结果,画出来就是一根根等宽的柱状图,不够“曲线”。我更推荐直接操作AudioRecord,它能输出原始的 PCM 数据,既能算 RMS 振幅,也能做 FFT 频谱,还能自定义采样率。
private AudioRecord createAudioRecord() { int sampleRate = 44100; int channelConfig = AudioFormat.CHANNEL_IN_MONO; int audioFormat = AudioFormat.ENCODING_PCM_16BIT; int minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat); AudioRecord record = new AudioRecord(MediaRecorder.AudioSource.MIC, sampleRate, channelConfig, audioFormat, minBufferSize); return record; }这段代码里有几个参数值得解释一下。采样率 44100Hz 是绝大多数 Android 设备都支持的,想省电或者做语音识别可以用 16000Hz,但波形平滑度会差一些。单声道就够了,波形不需要左右声道分开画。getMinBufferSize返回的是系统建议的最小缓冲字节数,直接用这个值初始化 AudioRecord 一般不会出问题。
2.2 把 PCM 字节转成振幅值,别在 UI 线程干这事
PCM 16bit 的数据格式是小端存储,每个采样点占 2 个字节。不能直接把字节数组交给 View 去解析,因为 AudioRecord 的读取是阻塞的,必须在子线程里循环读。每读一帧,就把这一帧里的所有采样点求一次 RMS(均方根),得到一个代表当前音量的浮点值,范围约 0 到 32768。
private fun readAudioLoop(audioRecord: AudioRecord, onAmplitude: (Float) -> Unit) { val bufferSize = 2048 val buffer = ShortArray(bufferSize) while (isRecording) { val read = audioRecord.read(buffer, 0, bufferSize) if (read > 0) { var sum = 0.0 for (i in 0 until read) { sum += buffer[i].toDouble() * buffer[i].toDouble() } val rms = Math.sqrt(sum / read).toFloat() val normalized = rms / 32768f onAmplitude(normalized) } } }这段逻辑说明几点。read返回的是读取的采样点个数,不是字节数,因为传入的是 ShortArray。RMS 比峰值更能反映人耳感知的音量,画出来的曲线也更平滑。normalized把值归一化到 0 到 1 之间,方便 View 层直接乘上高度。注意onAmplitude回调的执行线程是子线程,所以 View 更新要丢到主线程,或者用一个线程安全的缓冲区交给 View 的绘制循环去取。
2.3 数据进 View 之前,先想清楚“曲线”的数学形态
拿到归一化振幅后,面临一个选择:是每个采样点画一条竖线,还是把点连成线。真正的“声波曲线”通常不是频谱那种高频跳动,而是包络线——即振幅随时间变化的轮廓。做法是每个时间片取振幅值,作为 Y 坐标,X 坐标从右往左推进,形成一条左旧右新的滚动曲线。这里有个关键点:振幅值必须做一次平滑,不然曲线会剧烈抖动,观感很差。
private val smoothingFactor = 0.3f private var smoothedAmplitude = 0f fun onAmplitude(newValue: Float) { smoothedAmplitude = smoothingFactor * newValue + (1 - smoothingFactor) * smoothedAmplitude }平滑系数 0.3 是我常用的经验值,意思是新值占 30% 权重,历史值占 70%。系数越大曲线越灵敏、越“炸”;越小越平缓、但延迟感越明显。这个参数建议做成可配置的,不同业务场景要求不一样。做语音对讲时我会用 0.2 左右,做音乐可视化用 0.5 以上。数据这一层搞定之后,才轮到 Canvas 出场。
3. 自定义 View 的绘制核心:从 onDraw 到双缓冲
3.1 用一个数组当环形缓冲,别用 List 无限增长
如果每次刷新都往ArrayList<Float>里 add,一分钟之后内存里就攒了几千个点,而且旧数据越来越多,绘制范围越来越大,性能和内存双双失控。正确做法是定长数组 + 覆盖写,每次新数据来,往数组尾部写,写满后从头部覆盖,绘制时按顺序画。
private val maxPoints = 300 private val points = FloatArray(maxPoints) private var writeIndex = 0 private var pointCount = 0 fun addAmplitude(value: Float) { if (pointCount < maxPoints) pointCount++ points[writeIndex] = value writeIndex = (writeIndex + 1) % maxPoints postInvalidateOnAnimation() }每次调用addAmplitude最多 O(1) 的写入开销。postInvalidateOnAnimation比invalidate好,它会与屏幕的 VSYNC 同步触发重绘,避免一帧内多次绘制造成的浪费。写满之后从头部覆盖,视觉上就是一条无限滚动的曲线,内存占用恒定。
3.2 按顺序绘制波形,注意数组的起始位置不是 0
环形缓冲最坑的地方在于:数组里最早的数据不一定在索引 0。绘制时必须从writeIndex开始,绕一圈回到writeIndex - 1。如果直接按 0 到pointCount顺序画,波形会错乱跳变。
override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val midY = height / 2f val maxAmplitude = height * 0.45f val path = Path() val stepX = width.toFloat() / maxPoints if (pointCount > 1) { for (i in 0 until pointCount) { val idx = (writeIndex - pointCount + i + maxPoints) % maxPoints val x = i * stepX val y = midY - points[idx] * maxAmplitude if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) } } canvas.drawPath(path, paint) }这段代码把环形索引换算写得很明确,idx的计算是核心。maxAmplitude取高度的 45%,给上下留出余量,不然波形贴边很难看。X 轴方向让波形从左往右增长,视觉上像实时写入。如果想做右往左滚动的旧式示波器效果,把x改成width - i * stepX即可。
3.3 绘制线条的 Paint 参数:抗锯齿和渲染优化
Paint 的参数直接影响观感和性能。线条宽度、颜色、抗锯齿是基础;更关键的是把isAntiAlias打开,否则斜线会有明显锯齿。同时,如果波形只是纯色线条,别开setShadowLayer,那是性能杀手,每帧都会触发额外的离屏渲染。
private fun createPaint(): Paint { return Paint().apply { isAntiAlias = true style = Paint.Style.STROKE strokeWidth = 3.dp strokeCap = Paint.Cap.ROUND strokeJoin = Paint.Join.ROUND color = Color.parseColor("#00C853") } }参数说明:strokeWidth3dp 在多数屏幕上看起来比较精致;太细(1dp)在低分辨率设备上会发虚,太粗(5dp+)会把波形细节糊掉。Cap.ROUND和Join.ROUND让曲线转折处更圆润,不会出现尖锐折角。颜色建议提取成属性,方便不同深色/浅色主题切换。
3.4 进阶绘制:渐变填充和网格参考线
想要更精致的视觉效果,可以在曲线下方填充渐变色,让波形看起来有“发光”的层次感。做法是画完 Path 之后,再从曲线的终点向下连到 View 底部闭合,形成封闭区域,然后用 LinearGradient 填充。
val fillPath = Path(path) fillPath.lineTo(width.toFloat(), height.toFloat()) fillPath.lineTo(0f, height.toFloat()) fillPath.close() val gradient = LinearGradient(0f, 0f, 0f, height.toFloat(), Color.argb(120, 0, 200, 83), Color.TRANSPARENT, Shader.TileMode.CLAMP) fillPaint.shader = gradient canvas.drawPath(fillPath, fillPaint)注意Path(path)复制的是当前路径对象,lineTo之后要close()形成闭合区域。LinearGradient 的起点和终点是 Y 轴方向,颜色从半透明绿渐变到完全透明,模拟从亮到暗的衰减。这个效果如果每帧都重新创建 LinearGradient 对象,会触发频繁 GC,正确做法是一次性创建填充 Paint 和 Shader,只更新路径数据。
4. 数据采集线程与 UI 刷新:实时波形不卡顿的关键配合
4.1 子线程采集、主线程绘制:职责划分的边界
AudioRecord 的读操作和 Canvas 的绘制操作绝对不能放在同一线程。前者是 IO 阻塞操作,后者是 UI 操作,混在一起要么导致丢音,要么导致掉帧。我一般用两个独立线程:一个专跑读取循环,另一个由系统主线程的onDraw承担,数据通过线程安全机制传递。
private val handler = Handler(Looper.getMainLooper()) fun onAmplitudeCaptured(value: Float) { handler.post { waveView.addAmplitude(value) } }这里有个容易被忽略的点:handler.post每次都会创建一条消息,如果采集频率是每 20ms 一次,意味着每秒 50 次消息投递。看起来还好,但如果 View 本身又被多个数据源同时调用,消息队列会积压,绘制延迟越来越大。更稳的方案是把数据写进一个并发队列,View 侧的addAmplitude在需要绘制时主动拉取。
4.2 采集线程退出要干净,否则会留下“幽灵线程”
Activity 或 Fragment 销毁后,采集线程可能还在跑,AudioRecord 还占着麦克风,这在某些机型上会导致其他应用录音无声。必须在 onDestroy 里做完整的停止和释放流程,而且要注意:AudioRecord.stop()本身可能抛异常,要捕获。
fun stopRecording() { isRecording = false try { audioRecord?.stop() } catch (e: IllegalStateException) { Log.w(TAG, "AudioRecord already stopped: $e") } audioRecord?.release() audioRecord = null }release()之后要置空,避免重复释放。isRecording是一个volatile布尔值,子线程循环每次检查它。我见过不少项目只在 onPause 里 stop 了 MediaRecorder,忘了释放 AudioRecord,导致页面返回时麦克风仍被占用。这块没有捷径,生命周期每个回调都要打点。
4.3 刷新频率:别让 onDraw 跑得比合理值更快
postInvalidateOnAnimation会把绘制请求合并到下一帧,所以理论上你每秒加多少次数据,屏幕最多刷新 60 或 120 次。但数据采集频率如果远高于屏幕刷新率,很多数据点会被跳过或被覆盖。AudioRecord 的 read 阻塞时长取决于系统调度,实测在 44.1kHz 下每 2048 个采样点大约是 46ms 一次回调。意味着数据点产生的频率远低于屏幕刷新率,波形会一顿一顿地跳。
解决思路是让采集频率和 UI 刷新解耦:采集线程只负责把振幅值写入环形缓冲,View 的onDraw里检查缓冲里是否有新值,有则更新。另一个办法是减少单次读取的样本数,比如从 2048 降到 512,回调频率提到约 11ms 一次,曲线会平滑些,但 CPU 消耗上升。具体用哪个得看设备负载和你的目标帧率。
4.4 小尺寸控件用简化的绘制路径,减少 CPU 负担
如果控件宽度只有 200dp 甚至更小(比如聊天框旁边的语音按钮),300 个点画出来毫无意义,因为横向像素可能只有 100 个。点太密时相邻点的屏幕距离小于 1 像素,绘制路径做了大量无效计算。我一般会做一个降采样:当控件宽度对应的点数大于某个阈值时,直接跳过部分数据点。
val visiblePointCount = min(pointCount, width / 2) val step = pointCount / visiblePointCount for (i in 0 until visiblePointCount) { val idx = (writeIndex - visiblePointCount + i * step + maxPoints) % maxPoints ... }step是跳点步长,比如 300 个数据点只画其中 150 个,步长为 2。这样 X 轴坐标仍然均匀分布,曲线形状变化不大,但每帧绘制的 Path 节点数少了一半,对低端机的帧率提升立竿见影。注意降采样要在环形索引计算之前做,否则跳点逻辑会破坏数据顺序。
5. 声波曲线控件的五个经典坑:从“波形不居中”到“内存泄漏”
5.1 第一个坑:波形上下不对称,底部明显被截断
现象:说话音量很大时,波形上方能到顶,下方却总差一截,或者反过来。原因:我把绘制 Y 坐标计算成了midY - points[idx] * maxAmplitude,但points[idx]的归一化是除以 32768,实际人声的 RMS 远达不到这个值,所以曲线整体偏小被压缩在中心。真正原因是归一化基准设置不合理。解决:不要用理论最大值做分母,用采集到的历史峰值动态归一化。
private var peakAmplitude = 0.02f fun addAmplitude(value: Float) { peakAmplitude = max(peakAmplitude, value) val normalized = value / peakAmplitude ... }这样波形会自动适配当前音量水平。但注意peakAmplitude需要做衰减,否则长时间大音量之后突然变安静,波形会一直很小。我通常每 500ms 让峰值乘一个 0.95 的衰减系数。
5.2 第二个坑:旋转屏幕或切换主题时,波形数据被重置
现象:转屏后波形从空开始,或者背景色变了但波形颜色没变。原因:Activity 重建导致 View 重新创建,而数据存在 View 实例里。解决:把波形数据移到 ViewModel 或一个单例缓冲类里,View 重建后从数据源恢复。不要用onSaveInstanceState存几百个浮点数,那样 parcel 大小会告警。
class WaveDataStore { val points = FloatArray(300) var count = 0 ... }ViewModel 的onCleared里记得停止采集线程,否则转屏后 ViewModel 持有的回调仍会更新已销毁的 View,导致泄漏或空指针。
5.3 第三个坑:onDraw 里创建对象导致 GC 卡顿
现象:波形刷新时偶发丢帧,分配跟踪里能看到大量 Path 和 Float 数组。原因:每次onDraw都 new 一个 Path 对象,绘制完即被 GC。解决:把 Path 和 Paint 都提升为成员变量,onDraw里只复用path.rewind()和path.moveTo()。同理,颜色渐变 Shader 也只在尺寸变化时重建。
override fun onDraw(canvas: Canvas) { path.rewind() ... canvas.drawPath(path, paint) }rewind()清空路径但保留内部缓冲区,比重新new Path()省不少开销。这在高速 60fps 刷新下差异很明显,旧机器上能看出掉帧曲线变平。
5.4 第四个坑:自定义属性在 XML 预览里不生效
现象:Android Studio 的布局预览一片空白,或者颜色不弹出来。原因:自定义 View 的属性在styleable里声明了,但构造函数里没有用context.obtainStyledAttributes读取,预览时直接用了默认值。解决:把属性读取放在init块里,并且在attr声明时指定format="color|dimension"。
private fun readAttributes(attrs: AttributeSet?) { if (attrs == null) return val ta = context.obtainStyledAttributes(attrs, R.styleable.WaveformView) lineColor = ta.getColor(R.styleable.WaveformView_waveColor, Color.GREEN) lineWidth = ta.getDimension(R.styleable.WaveformView_waveWidth, 3.dp) ta.recycle() }ta.recycle()是必须的,忘了会导致资源泄漏警告。预览里看到效果,开发体验会好很多。
5.5 第五个坑:高采样率下波形出现毛刺和尖峰
现象:曲线在音量平稳时仍然上下剧烈抖动,看起来像噪声。原因:PCM 数据本身包含环境噪声,RMS 虽然能平滑一些,但噪声权重在低音量时占比很大。解决:对振幅值做一次低通滤波之外,再加一个静音阈值,低于阈值的振幅直接归零,这样静音时波形保持水平线而不抖。
var lastVisibleAmplitude = 0f fun addAmplitude(value: Float) { val filtered = if (value < 0.005f) 0f else value lastVisibleAmplitude = lastVisibleAmplitude * 0.7f + filtered * 0.3f ... }阈值 0.005 是我测试下来比较合适的值,具体要看麦克风灵敏度。太大会把轻微说话的声音也吃掉,太小则静音时波形还是微微抖动。这个参数也建议做成可配置。
6. 更多玩法:把波形拓宽到频谱、缓存与动画重放
6.1 从时域波形到频域频谱:用 FFT 替代 RMS 做输入
如果你不只是要振幅包络,而是想要类似音乐播放器的频谱柱状图,那 RMS 就不够用了。需要用FastFourierTransform把 PCM 帧变换到频域,取各频段的幅度值。Android 本身没有公开 FFT API,常见的做法是引入一个轻量 FFT 库,或者自己写一个基 2 的 FFT。对实时性要求高的场景,FFT 长度用 1024 或 512,512 在 44.1kHz 下频率分辨率约 86Hz,足够画出好看的 16 段频谱,计算量也小。
// 伪代码示意,用一个长度为 1024 的 FloatArray 做输入 val real = FloatArray(1024) val imag = FloatArray(1024) FFT.fft(real, imag) val magnitude = Math.sqrt(real[i] * real[i] + imag[i] * imag[i]).toFloat()要注意的是 FFT 输入的实部数组是 PCM 采样点,虚部填 0。输出后第 0 个元素是直流分量,画频谱时通常丢弃。频率段的数目和位置需要按照听觉感知做对数分布,而不是线性取前几十个点,这样频谱在视觉上更符合人耳习惯,也更接近主流播放器的效果。
6.2 用 Bitmap 缓存历史波形,降低重绘开销
如果你的控件是横向滚动显示过去几秒的波形,而不再需要实时增长,一个更极端的优化方案是把绘制结果缓存到 Bitmap。每次新数据到达时,把旧的 Bitmap 左移一列,在新位置画上最新的振幅线,然后onDraw里直接drawBitmap。这样避免了 Path 的逐年累加。
private val cacheBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) private val cacheCanvas = Canvas(cacheBitmap) fun appendWaveform(value: Float) { cacheCanvas.drawBitmap(cacheBitmap, -stepX, 0f, null) cacheCanvas.drawLine(width - stepX, midY, width - stepX, midY - value * maxAmp, paint) }这个方案的缺点是 Bitmap 占内存,宽度很大的时候要注意 OOM。我的经验是,只有当宽高超过 500px 且需要长时间滚动时才用 Bitmap 缓存,小控件直接用 Path 重绘更简单可靠。
6.3 让曲线动起来:动画重放一段历史声波
有一个很常见的需求是播放录音时让波形跟着播放进度走。数据已经存在内存里(比如录音文件的解码结果),这时候不需要实时采集,只要在动画回调中按时间索引取振幅值,驱动同一个 View 的刷新即可。
val animator = ValueAnimator.ofFloat(0f, 1f) animator.addUpdateListener { val progress = it.animatedValue as Float val index = (progress * totalPoints).toInt() waveView.addAmplitude(amplitudes[index]) } animator.start()ValueAnimator默认按帧回调,60fps 下足够平滑。如果想要波形和数据帧严格同步,建议用Choreographer或MediaPlayer.getPlaybackPosition来驱动,而不是固定帧动画。我在做某跨平台系统的音频模块时,就是靠这种索引回放方式完成了多个平台视觉一致性的验证。
6.4 验证波形控件的三个步骤
控件写完后别直接上生产,至少做这三步验证。第一步是静音测试:不输入声音,观察曲线是否保持水平且不抖动。第二步是正弦波测试:用系统生成一个 440Hz 的标准正弦波数据源喂给控件,看曲线是否呈现规则的正弦形态,这一步能快速发现采样率或坐标映射错误。第三步是长时间稳定性测试:连续录音 30 分钟,观察内存是否稳定(Android Profiler 看 Native 内存),帧率是否保持在 50fps 以上。这三个测试都过了,再谈业务集成。
回看我自己的踩坑经验,印象最深的一次是在模拟项目X里做语音消息波形,最初用invalidate()而非postInvalidateOnAnimation(),结果在低端机上波形像幻灯片一样跳动,改成后者后立刻流畅了。另一次是忘了处理生命周期,导致页面退到后台后音频线程还在跑,麦克风指示灯一直亮着,用户的隐私顾虑很大。后来我养成了一个习惯:每次写完采集相关代码,第一件事不是跑 UI,而是检查 onPause / onStop / onDestroy 的释放链路是否完整。希望你也能把这个习惯内化,少走我走过的弯路。希望帮到你。
本文还有配套的精品资源,点击获取