简介:面向需要为录音功能添加动态视觉反馈的Android开发者,这份自定义声波曲线控件源码用于在录音过程中实时显示声音波动,以正余弦函数为基本图形模拟动态波形,帮助用户直观感知录音质量与强度。压缩包约1.68MB,共51个文件,其中包含7个Java源码、21个XML布局与配置、5张PNG图片及GIF演示动画等,目录结构清晰,便于按模块查看核心实现与运行效果。源码覆盖了Canvas绘制曲线、ValueAnimator动画驱动、录音事件监听、离屏渲染与局部重绘等关键知识点,并给出自适应布局与自定义样式属性的处理思路,适合中高级开发者深入理解自定义View的绘制流程和动画机制。工程提供app与mylibrary两个模块,示例代码与可复用库分离,便于直接抽取mylibrary中的控件集成到实际项目,也可对照app示例快速验证效果。已有872人学习,值得作为录音反馈场景的实用参考。
1. 声波曲线自定义控件:它到底在画什么
录音应用的音量波形、语音助手界面的居中波纹、音乐播放器里随节拍起伏的曲线,这些看着不一样的东西底层画的是同一份数据:麦克风采集到的 PCM 音频按时间顺序展开,每一瞬的幅度映射到画布纵轴,就成了声波曲线。这个标题没有官方定义,但它解决的问题很具体——把一个实时变化的音频流变成可控、好看、不太耗电的自绘控件。适合两类人:一类是自定义 View 已经写熟、第一次要接音频流的开发者,另一类是做过录音功能、但声波显示总被性能和细节拖住的团队。判断自己是否需要它很简单:如果你的 App 里有录音、语音或实时音频展示,且需要比系统回调更细的可视化粒度,这篇就能帮你把落地方案铺开。
2. 绘制骨架:用 Path 把振幅数组变成曲线
2.1 为什么坚持用 Path,而不是一根根 drawLine
绘制声波最直观的做法是在 onDraw 里 for 循环调 drawLine。以一块宽 1080 像素的屏幕为例,每个像素画一段折线,一帧就是上千次绘图调用;onDraw 每秒会被触发几十次,累计下来 CPU 侧的调用开销非常可观。Path 的优势在于它把一组坐标收成一个绘制单元,底层的图形库在处理单条 Path 时能做整体光栅化,优化空间远大于一千条相互独立的线段。另一个实际观察是 drawLine 的拐角在音量骤变时非常难看,两条线段之间的夹角会被放大成尖刺,而 Path 配合二阶贝塞尔可以做出自然的圆滑过渡。
再往深一层说,Path 承载的不仅是曲线本身,后续要给波形加渐变、裁剪、描边效果,全都依赖同一个 Path 对象。如果用散线段,想统一改样式就得遍历所有线段重新设置画笔状态,维护成本直接失控。所以「路径绘制 + 少量采样点」是一线项目里最主流的组合,也是这篇笔记所有后续操作的地基。
2.2 声波曲线控件的最小可运行框架
先定义四样东西:振幅环形缓冲区、Path 对象、画笔、一个数据写入入口。环形缓冲区解决「历史数据和最新数据怎么共存」的问题,常见做法是固定长度数组加一个写指针,写满后自动覆盖最旧的值。
public class WaveformView extends View { private static final int BUFFER_SIZE = 256; private final float[] samples = new float[BUFFER_SIZE]; private final Path wavePath = new Path(); private final Paint wavePaint = new Paint(Paint.ANTI_ALIAS_FLAG); private int writeIndex; public WaveformView(Context context) { super(context); wavePaint.setColor(0xFF22C55E); wavePaint.setStyle(Paint.Style.STROKE); wavePaint.setStrokeCap(Paint.Cap.ROUND); wavePaint.setStrokeWidth(dp(2f)); } public void pushSample(float value) { samples[writeIndex] = value; writeIndex = (writeIndex + 1) % BUFFER_SIZE; postInvalidateOnAnimation(); } }代码逻辑说明:samples 数组的长度决定控件里同时显示多少个振幅点,256 个点在 60fps 下大约对应 4 秒的历史宽度,足够覆盖大多数语音场景。pushSample 一般由后台采集线程调用,注意这里不能直接调 invalidate——invalidate 只能在 UI 线程使用,从采集线程调用会抛异常;postInvalidateOnAnimation 是线程安全的变体,它把刷新请求挂到下一帧的同步信号上,不会立刻打断当前帧的绘制,这是后面性能部分的关键习惯。
参数说明:BUFFER_SIZE 不是越大越好。它直接决定 onDraw 里的循环次数,512 以上的缓冲区在低端机上每帧要多花几毫秒;但太小会让曲线只有一两秒的历史,看不到完整的语音停顿。我一般控制在 128 到 256,需要长波形的场景放到 512,但要在设置里留一个开关,避免所有机型都背着大缓冲区开销。
提示:postInvalidateOnAnimation 只是「请求刷新」,onDraw 的实际执行时机由系统帧信号决定,刷新节奏和采集节奏的对齐问题会在第 5 章展开讲。
2.3 onDraw 里怎么画成居中对称的声波曲线
居中对称是语音类控件最常见的形态:中轴线在垂直居中位置,振幅大时曲线上下扩张,静音时收缩成一条直线。绘制时从中心线往左右两侧展开,而不是从头画到尾,这样音量变化的感觉是「膨胀-收缩」,比左到右滚动更有实况感。
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int w = getWidth(); int h = getHeight(); if (w == 0 || h == 0) return; float centerX = w / 2f; float centerY = h / 2f; float maxAmp = centerY - dp(8f); int halfPoint = Math.min(BUFFER_SIZE / 2, w / 4); wavePath.reset(); int start = (writeIndex - halfPoint + BUFFER_SIZE) % BUFFER_SIZE; for (int i = 0; i <= halfPoint; i++) { int idx = (start + i) % BUFFER_SIZE; float x = centerX - (i * (w / 2f) / halfPoint); float y = centerY - samples[idx] * maxAmp; if (i == 0) { wavePath.moveTo(x, centerY); } else { wavePath.lineTo(x, y); } } // 右半边用镜像逻辑,保证曲线在中心点连续 for (int i = 1; i <= halfPoint; i++) { int idx = (start + halfPoint - i) % BUFFER_SIZE; float x = centerX + (i * (w / 2f) / halfPoint); float y = centerY - samples[idx] * maxAmp; wavePath.lineTo(x, y); } canvas.drawPath(wavePath, wavePaint); }说下这段的取舍:左半边从中心向左按时间正向延伸,右半边反向镜像,本质是同一段缓冲数据画了两遍,视觉上构成对称。samples 里存的是已经归一化到 0 到 1 的振幅比例,所以 onDraw 只做坐标换算,不做音量数学,采集线程传什么值控件就画什么,职责边界清晰。
这里有一个很容易被忽略的点:为什么左半边第一点要 moveTo,右半边直接 lineTo?因为 Path 绘制是连续的,左半边最后一个点落在最左侧,lineTo 回中心再向右,路径才能从头到尾连成一条线,不会在中间出现断口。如果想用二阶贝塞尔平滑,最好在数据层做,不要在绘制层做——居中对称场景下,左右两条边在中心点交接,贝塞尔的控制点方向不一致会在中心点形成一个明显的尖角,数据层滤波对左右两侧同时生效,就没有这个问题。
3. 数据源:AudioRecord 采集与振幅计算
3.1 为什么 MediaRecorder 的取振幅方案不够用
很多第一次做声波的人会直接用 MediaRecorder 加振幅监听回调,拿 getMaxAmplitude 当音量值。这个方案能跑通,但有两个硬伤:一是它只能拿到一个内部固定窗口里的峰值,拿不到原始 PCM 采样点,想做 FFT 频谱完全没有入口;二是回调周期由系统内部决定,不会精确对齐你的 UI 刷新,画出来的曲线节奏感很差。所以只要目标是曲线或频谱,正路就是 AudioRecord 直接读 PCM 数据。
AudioRecord 配置里最关键的是三样:音频源、采样率、缓冲区大小。音频源用麦克风,采样率在语音场景下 44100 就够,缓冲区取系统建议最小值的两倍,避免 read 阻塞时间过长导致数据断裂。这套配置在不同机型上的兼容性问题多数出在缓冲区和权限时机上,后面会专门讲。
3.2 最小可用的 PCM 采集代码
private AudioRecord createRecorder() { int sampleRate = 44100; int channelConfig = AudioFormat.CHANNEL_IN_MONO; int format = AudioFormat.ENCODING_PCM_16BIT; int minBuf = AudioRecord.getMinBufferSize(sampleRate, channelConfig, format); return new AudioRecord(MediaRecorder.AudioSource.MIC, sampleRate, channelConfig, format, Math.max(minBuf * 2, 4096)); }逻辑说明:单声道 16bit PCM 是语音输入最常见的格式,每个采样占 2 字节;minBuf 是系统保证正常读写的最小缓冲,乘 2 是给它留出余量,避免高负载时数据槽被填满导致丢帧。采样率选 44100 而不是 48000,因为语音场景下人声频率范围大概在 300Hz 到 3400Hz,44.1kHz 已经覆盖,低采样率意味着每次 read 拿到的数据量更少,CPU 占用更低。
采集循环放在后台线程里执行:
private void captureLoop() { short[] pcm = new short[1024]; while (running) { int count = recorder.read(pcm, 0, pcm.length, AudioRecord.READ_BLOCKING); if (count <= 0) continue; float rms = computeRms(pcm, count); view.pushSample(rms); } }READ_BLOCKING 的意思是数组里没有新数据时线程会阻塞等待,不会空转耗电。count 是实际读到的样本数,不完全等于 1024,计算振幅时只处理前 count 个,否则会把上次残留的数据算进去,音量会虚高。另一个细节是 read 返回的是 PCM 样本个数,不是字节数,别拿返回值去除缓冲区字节长度。
3.3 振幅提取:RMS 与峰值分别用在什么时候
振幅提取有两个常用指标:峰值和 RMS。峰值取一段采样里绝对值最大的值,对瞬时爆音敏感,适合做「这个瞬间有没有人在说话」的判定;RMS 是均方根,反映这段采样内的平均能量,画曲线时视觉上更沉稳,不会因为一个突发噪声采样点乱跳。只做声波曲线的话,RMS 一个指标就够。
private float computeRms(short[] pcm, int count) { long sum = 0L; // 用 long 避免平方累加溢出 for (int i = 0; i < count; i++) { sum += pcm[i] * pcm[i]; } double rms = Math.sqrt(sum / (double) count); return (float) (rms / 32768.0); // 16bit PCM 归一化到 0~1 }这里有个隐藏得很深的坑:平方再求和容易溢出。int 类型的 sum 在 1024 个采样、音量接近爆表时,平方累加会瞬间超过 int 上限变成负数,开方后就得到 NaN,曲线直接消失。用 long 是最朴素的解决方案,性能损失几乎可以忽略。除以 32768 是因为 16bit PCM 的取值范围是 -32768 到 32767,这样才能把 RMS 归一到 0 到 1 区间,与绘制层的 maxAmp 相乘才有正确语义。
如果你想要「音量表」那种一格一格跳的效果,峰值比 RMS 更适合,因为它对声音的起始响应更快;但给声波曲线用峰值会让波形看起来毛毛躁躁的,所以我的习惯是 RMS 用于绘制、峰值用于触发「是否有人说话」的业务逻辑,两个指标各管各的。
3.4 采集线程、生命周期与权限的三件套
AudioRecord 的 read 是阻塞调用,绝对不能放主线程。线程建议用 Thread 加标记位,或者用单线程的 ScheduledExecutorService,不要用 HandlerThread——它的循环模型不适合长时间阻塞 read。控件销毁时释放的顺序有讲究:
private void stopCapture() { running = false; if (captureThread != null) { captureThread.interrupt(); captureThread = null; } if (recorder != null) { recorder.stop(); recorder.release(); recorder = null; } }顺序别写反:先停线程再释放 recorder。如果反过来,线程正在 read 时资源被释放,会抛 IllegalStateException,而且很难复现。另外麦克风权限要在 start 前检查,很多机型上首次申请权限后 AudioRecord 虽然构造成功,但内部还没准备好,立即 start 会失败——我一般会在申请权限后延迟 100ms 再进入采集循环,这个等待能避掉大部分首次启动无声音的问题。
采集也要跟页面生命周期绑定:onStop 时暂停采集,onResume 时恢复,避免应用切到后台还在疯狂读麦克风。后台录音既不礼貌又耗电,而且很多机型上后台拿不到音频焦点,read 会一直返回 0,白白浪费线程资源和电量。这个绑定看起来基础,却是声波控件在实际业务里出问题频率最高的环节之一。
4. 参数调校:平滑、缩放与频谱的进阶玩法
4.1 数据层平滑:一阶低通滤波与参数表
原始 RMS 在语音停顿处会剧烈抖动,直接画出来像锯齿。常见做法是在 pushSample 之前对 RMS 做一阶低通滤波,相当于给信号加一层惯性:
private float smoothFactor = 0.6f; private float smoothed = 0f; public void pushRms(float rms) { float target = Math.max(rms, 0.02f); // 底部限幅,静音时不完全归零 smoothed = smoothed * smoothFactor + target * (1f - smoothFactor); pushSample(smoothed); }smoothFactor 的意思是保留多少旧值、采纳多少新值。数值越大,曲线越「懒」,音量突然变大时它要花更长的时间才爬上去;数值越小,响应越快,但抖动也越明显。我建议的可调范围和使用感受整理成一张表:
| 参数 | 建议区间 | 设置后的表现 |
|---|---|---|
| smoothFactor | 0.5 ~ 0.8 | 越大曲线越平滑,对小音节响应越慢 |
| 底部限幅 | 0.01 ~ 0.03 | 静音时曲线保留轻微起伏,不僵成直线 |
| 噪声门限 | 0.01 ~ 0.02 | 低于该 RMS 的输入直接归零,消除底噪 |
注意滤波这行代码一定要在采集线程执行,不要放进 onDraw。onDraw 是高频调用,任何浮点运算放进去都会被放大成千上万次,性能账不划算。底部限幅 0.02 是刻意保留一点基础幅度,让静音时曲线不是一条绝对直线,而是极轻微起伏,屏幕看起来更「活着」,同时避免后续计算里除零。
4.2 纵向缩放:慢速自动增益的写法
RMS 还有一个天然问题:同样是正常说话,离麦克风远的人 RMS 可能只有 0.05,贴嘴边的人能到 0.8,固定缩放比会导致前者曲线贴地、后者顶破天。我一般维护一个跟随平均音量缓慢移动的增益基准:
private float gainRef = 0.3f; private void autoGain(float rms) { gainRef += (rms - gainRef) * 0.02f; float mapped = rms / Math.max(gainRef, 0.08f); pushSample(Math.min(mapped, 1.5f)); // 允许瞬时超过 1,绘制层再做钳制 }autoGain 的核心思想是让增益基准自己适应环境:安静时 gainRef 变小,弱信号被放大;嘈杂时 gainRef 变大,强信号被压回来。0.02 的系数让 gainRef 需要几秒才能完成一次明显变化,不会被单个音节带偏。允许 mapped 超过 1 是有意的——音量峰值偶尔顶到控件边缘甚至轻微裁切,比永远缩在中间更有冲击力,onDraw 里用 Math.min(1f, sample) 钳制即可,两个 1f 互不干扰。
4.3 升级成频谱柱状图:FFT 的接入边界
如果想把控件从声波曲线升级成频谱柱状图,核心是把 PCM 数据喂给 FFT,取各频段能量。实现上有两条路:用开源 FFT 库,或者自己写基 2 算法。能用库就用库,接口简单且经过大量验证;自己写的话只推荐处理 1024 点以内的小样本,再大的样本数,优化空间就远不如成熟库了。
频谱和声波的本质区别在于坐标系:声波是时间在横向展开,频率是横向展开、能量在纵向表示。所以不要试图在同一个控件里同时画两种语义,常见做法是声波控件只做时域,频谱单独一个控件,同一份 PCM 数据分别喂给两条链,两者的刷新频率可以不一样。接入时的关键一步是加窗,直接对原始 PCM 做 FFT 会引入频谱泄漏,常见做法是乘一个汉宁窗,让每一帧数据的两端平滑归零,减少频率成分的旁瓣干扰。
4.4 视觉风格:渐变、柔光与笔触细节
声波曲线最容易在视觉上翻车的是画笔配置。strokeWidth 太细时曲线像发丝,太粗时峰谷糊成一片。我的经验是取 2 到 3dp,配合 ROUND 线帽,端点圆润,音头音尾看起来干净。渐变色不能直接用在单条 stroke 上,需要 Shader:
Shader shader = new LinearGradient(0, 0, 0, getHeight(), new int[]{0xFF1677FF, 0xFF22C55E, 0xFFFACC15}, null, Shader.TileMode.CLAMP); wavePaint.setShader(shader);渐变 Shader 在 onSizeChanged 里创建,因为它的坐标依赖控件高度。发光效果不要用 setShadowLayer,硬件加速下它对大 Path 的性能开销非常大,在模拟项目X的 200 条线段场景下掉帧很明显。更稳的做法是两层描边叠加:先画一条粗的、半透明的 Path 做光晕,再画一条细的、不透明的 Path 做主线条,颜色一致时视觉上就有柔光感,而成本只是多画一次 Path。
5. 避坑指南:声波控件最常见的 5 个坑
5.1 onDraw 里反复 new 对象,GC 把采集线程也拖停
现象:运行十几秒后曲线开始一卡一卡,Logcat 里频繁出现 GC 日志,采集的数据断断续续。
原因:onDraw 每秒执行几十次,任何在方法体内创建的对象都会变成垃圾。GC 触发时所有线程都会暂停,采集线程正在 read 的那 100 多毫秒就丢了。最常见的元凶是在 onDraw 里 new Path、new Paint 或者new float[]。
解决:Paint、Path、Shader、浮点数组全部提升为成员变量,onDraw 只做 reset 和数据写入,不分配任何新对象。我在某个图像处理 Demo 里见过 onDraw 里 new Path 导致帧率只有 20 的情况,改成复用后直接恢复流畅,这是性能问题里性价比最高的一处修复。
5.2 曲线一顿一顿,刷新节奏和采集节奏没对齐
现象:音量一直在变,但曲线波形忽快忽慢,像在刷新时被截断了。
原因:采集线程按自己的节奏往缓冲区写数据,UI 线程按系统帧信号刷新,如果数据写入和帧绘制各走各的钟,同一个采样点可能被绘制在屏幕上不同的时间位置,看起来就是节奏错乱。
解决:统一用 postInvalidateOnAnimation 触发刷新,它会把刷新请求挂到下一帧的 vsync 上,而不是立即执行;不要在采集线程里调 invalidate,更不要在 onDraw 里主动 sleep 去「对齐」采集频率。数据是数据的节奏,绘制是绘制的节奏,两者通过环形缓冲区解耦,这才是正确模型。
5.3 曲线长时间不动,环形缓冲区的取模回绕写错
现象:波形停在一个固定形状不更新,或者循环播放一段旧数据,像卡死了一样。
原因:环形缓冲区的写指针绕回开头后,绘制循环里取(start + i) % BUFFER_SIZE时 start 算成了负数,或者 halfPoint 超过了缓冲区长度,导致读回大片旧数据。
解决:所有环形索引统一用(index % BUFFER_SIZE + BUFFER_SIZE) % BUFFER_SIZE处理负数,关键取模运算写成一个工具方法;再用单元测试把 3 个采样写进缓冲区、循环 1000 次,断言绘制循环读到的索引顺序始终正确。这个坑不写测试很难自己发现,因为它在特定写入次数下才暴露。
5.4 静音时曲线高频抖动,麦克风底噪被放大了
现象:环境安静时曲线不是水平的,而是一直有小锯齿在跳。
原因:RMS 把麦克风底噪也算进去了,归一化后底噪在 0.01 到 0.05 之间,本来视觉上不明显,结果 autoGain 一放大,锯齿直接可见。
解决:在 RMS 计算之后加一个噪声门限,低于阈值的直接归零:
float rms = computeRms(pcm, count); if (rms < 0.015f) rms = 0f;阈值 0.015 是按 44.1kHz、16bit 实测的参考值,不同机型麦克风底噪不一样,把这个值做成设置项让用户自己调,比写死体验好得多。注意门限不要设太高,否则轻语会被吞掉,曲线在音量很低时直接消失。
5.5 横竖屏切换后曲线错位,尺寸相关的缓存没重建
现象:屏幕旋转后曲线偏上或偏下,渐变色方向也错了,像被按比例拉伸过。
原因:onSizeChanged 里没有重建依赖尺寸的缓存对象,旧的 Shader 坐标和新的画布高度不匹配。
解决:在 onSizeChanged 里重建所有依赖尺寸的东西,并重置 Path:
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); wavePaint.setShader(createGradient(w, h)); wavePath.reset(); }尺寸变化在当前帧绘制前发生,重建缓存要赶在 onDraw 之前完成,否则这一帧还是会用到旧数据。可以把重建逻辑提取成一个方法,onSizeChanged 和配置变更时统一调用,避免只处理横竖屏、没处理分屏的遗漏。
6. 收尾方法:用录制回放把控件调「好看」再上真机
前面几章都在讲怎么把控件做出来,最后聊一个很多人跳过的环节:怎么验证你的声波曲线真的反映了声音。直接拿麦克风调试有两个麻烦:每次说的音量不一样,没法对比修改前后的效果;声音是单向的,改完参数根本不知道是变好了还是变坏了。我现在的习惯是,开发阶段把一段 10 秒的 PCM 数据存成文件,写一个测试入口按固定节奏回放给控件:
// 测试入口:读取预录 PCM 按固定节奏喂给控件 private void replayForDebug() { byte[] pcm = readAssetsFile("sample.pcm"); int offset = 0; while (offset < pcm.length) { float rms = computeRmsFromBytes(pcm, offset, 2048); view.pushSample(rms); offset += 2048; SystemClock.sleep(5); // 模拟真实采集节奏 } }这样改完平滑系数、autoGain、降采样逻辑之后,同一份数据反复跑,参数好坏一眼就能看出来。真机效果和录制回放在麦克风灵敏度和底噪上会有差别,但整体形态是一致的,回放调参至少能解决八成的观感问题。注意这个入口要用 debug 配置包起来,不要出现在发布包里。
上真机后我也是保留一个习惯:把归一化后的关键数值做成 Debug 浮层,直接显示在控件右上角——当前 RMS、gainRef、缓冲区写指针三个数。真机问题大多能从这三个值里看出端倪:RMS 一直为零说明权限或录音源有问题;gainRef 涨得飞快说明 autoGain 系数太大,正在把噪声当人声补偿;写指针异常说明环形缓冲区的取模还是没写对。这三个数字是我调试声波控件时最大的帮手,比 Logcat 里翻日志快得多。
最后说一句我的血泪教训:声波控件真正难的不是画曲线,而是让数据写入、帧刷新、生命周期三者步调一致。只要采集线程别泄漏、刷新走同步信号、环形缓冲的取模运算写对并配了测试,剩下就是调参和审美的活了。希望这篇能帮你少走弯路,早点把曲线调到自己满意的状态。
本文还有配套的精品资源,点击获取