简介这是一份面向Android开发者的音乐播放器Demo实现类似千千静听风格的跳动频谱与均衡器功能适合学习音频播放、可视化与UI交互的进阶项目。资源共14个文件包含Java源码、XML布局、PNG图标、properties配置、project工程文件及示例MP3压缩包仅2.69MB结构完整可直接导入工程。已有2085人浏览学习。Demo覆盖MediaPlayer/ExoPlayer播放框架、FFT频谱变换、Canvas实时绘制、AudioEffect均衡器设置、RecyclerView歌曲列表、权限管理与多线程调度等核心知识点并附带可播放的音频文件作为演示素材。通过研读代码与运行效果可快速掌握Android音乐应用从播放控制到频谱渲染的完整实现路径。1. 从千千静听说起Android上还原跳动频谱的关键路径如果你是从桌面播放器时代走过来的人多半记得那个随着节拍起伏、左右对称跳动的彩色频谱条。想把“android 跳动频谱 带均衡 播放器”这类需求真正落地要解决三件事从音频数据里拿到够用的频域信息、用自定义View画出一个会跳动的频谱并把刷新率控制在合理范围、再把均衡器接到同一路音频会话上让频谱和声音一起变化。这套路径并不依赖任何第三方播放器内核只靠系统自带的Visualizer和Equalizer两个类就能完成。读完你可以直接复现需要准备的是Android Studio、一台真机和一首MP3。本文默认读者能独立开发应用、但没碰过音频效果新手也能顺着代码跑通。2. 频谱数据从哪来Visualizer与手动FFT的选型对比2.1 Visualizer直接给你波形和FFT别一上来就引入第三方库很多人一听到“频谱”第一反应是找FFT库把PCM数据拿回来自己算。实际上Android系统里已经内置了Visualizer它挂在android.media.audiofx包下作用是从指定的audio session抓取正在播放的音频数据内部完成FFT并通过回调把结果给你。也就是说做播放器频谱最常用的方案就是Visualizer你不用关心FFT的输入输出细节也不用担心不同设备上FFT库的精度差异。Visualizer能给你两种原始数据一种是getWaveForm()返回时域波形范围和数值都偏小适合画示波器线条另一种是getFft()返回频域数据是复数形式长度等于captureSize的一半适合画跳动频谱。我们做类似桌面播放器那种彩色竖条直接用getFft()解析幅度即可。什么时候才需要自己算FFT只有当你的音频来源不在系统音频会话里比如你自己用AudioRecord采集麦克风、处理外部输入或者你想对频谱做更细的窗函数和插值Visualizer的固定FFT满足不了需求才值得绕路。普通播放器场景下自己接AudioTrack环回再跑FFT反而会引入额外延迟和电量开销属于典型的过度设计。我一般会先用Visualizer跑通确认有问题再换。2.2 最小接入一份可以COPY的Visualizer封装下面是一段可以直接抄走的Visualizer封装核心步骤是先拿到播放器的audio session再创建Visualizer设置捕获大小和监听器最后启用。注意这里的session是播放器内部音频会话的IDMediaPlayer、AudioTrack、ExoPlayer都有自己的session不能拿0去猜。class SpectrumDataHelper(private val audioSessionId: Int) { private var visualizer: Visualizer? null private val fftMagnitudes FloatArray(SPECTRUM_BARS) fun start(onSpectrumData: (FloatArray) - Unit) { try { val v Visualizer(audioSessionId) val range v.captureSizeRange val size range[1].coerceAtMost(1024) // 1024 点足够画 4050 根柱子 v.captureSize size v.setDataCaptureListener( object : Visualizer.OnDataCaptureListener { override fun onWaveFormDataCapture( waveform: ByteArray?, samplingRate: Int ) { // 时域数据这里不用 } override fun onFftDataCapture( fft: ByteArray?, samplingRate: Int ) { if (fft null) return val energy FloatArray(SPECTRUM_BARS) val fftSize fft.size for (i in 0 until SPECTRUM_BARS) { val idx (i * (fftSize / 2)) / SPECTRUM_BARS val real fft[idx * 2].toInt() val imag fft[idx * 2 1].toInt() energy[i] kotlin.math.sqrt( (real * real imag * imag).toFloat() ) } onSpectrumData(energy) } }, Visualizer.getMaxCaptureRate() / 2, false, true ) v.setEnabled(true) visualizer v } catch (e: RuntimeException) { // 权限未授予或会话不存在时会走到这里 visualizer?.release() visualizer null } } fun release() { visualizer?.setEnabled(false) visualizer?.release() visualizer null } companion object { private const val SPECTRUM_BARS 48 } }这里有两个容易搞混的参数captureSize表示一次采集多少个PCM采样点数值越大频率分辨率越高、刷新开销也越大1024点对手机竖屏的几十根柱子完全够用回调里第三个参数传false表示只要FFT不要波形第四个参数传true表示FFT数据有效。另外getMaxCaptureRate()返回的是设备支持的捕获速率上限直接用上限会推高CPU占用我习惯取它的一半左右差不多相当于每秒几十次回调再配合View端16毫秒一帧的重绘视觉上既流畅又不浪费电。还有一点动态申请RECORD_AUDIO权限这步必须先做很多厂商实现里缺少这个权限会让new Visualizer()直接抛RuntimeException而不是静默失败。2.3 频率轴映射从FFT桶到横坐标的换算FFT返回的每个下标对应一个频率桶换算公式是频率 下标 * 采样率 / captureSize。如果你的采样率是44100、captureSize是1024那么每个桶的宽度约43Hz。问题来了拿1024点FFT能覆盖到20kHz但人耳和歌曲能量主要集中在低频和中频直接用线性映射会把低频挤在左边一小块区域右边全是没啥内容的空条。千千静听那种频谱看起来舒展很大程度是因为它把频率坐标做了近似对数或者人工加权低频段给更多柱子高频段稀疏一些。常见做法是用对数映射把20Hz到采样率一半的范围压缩到View宽度上同时限制在人耳敏感区间内fun frequencyToPosition( binIndex: Int, sampleRate: Int, captureSize: Int ): Float { val frequency binIndex * sampleRate.toFloat() / captureSize val minFreq 60f val maxFreq 16000f val normalized (Math.log(frequency / minFreq) / Math.log(maxFreq / minFreq)).toFloat() return normalized.coerceIn(0f, 1f) }注意这里不是严格的八度对数而是把整个音乐频谱中能量最集中的60Hz到16kHz区间做对数压缩。这样你分配柱子位置时会发现100Hz附近能分到好几根柱子而8000Hz以上可能只有两三根符合人耳对低频变化更敏感的特性。如果你把FFT桶直接按线性画用户看到的就是一条总是往左偏的波形那才是真正的翻车现场。3. 绘制跳动频谱自定义View与动画管线的实现3.1 频谱View的思路数据、峰值与绘制分离拿到频谱数据后最重要的一件事是把“数据回调”和“绘制”解耦。Visualizer的回调跑到的是Binder线程如果直接在回调里更新View并调用invalidate会带来两个问题一是回调频率和设备相关不是你想要的恒定帧率二是每个回调都触发重绘屏幕上会出现肉眼可见的闪烁和不规则运动。我的做法是在自定义View里维护两个数组一个保存当前频谱柱子的能量值另一个保存峰值高度。数据回调来了只往当前数组里写值View绘制时由Choreographer或Handler定时触发每一帧做一次“衰减峰值保持”运算再统一画出整个频谱。这样无论底层回调多快界面上始终是稳定、平滑的30到60帧动画。数据数组的长度建议固定比如48根柱子。FFT返回的桶数量可能远大于48所以要做一次分组抽样。常见的做法是把FFT桶按对数频率分配到48个柱子上每个柱子取该区间内所有桶的最大值或均值。取最大值会让频谱看起来更饱满取均线会更平滑我用最大值居多因为跳动感更强。3.2 跳动机制峰值保持、衰减速度与刷新率想让柱子“跳”得好看不能直接拿实时能量画。实时能量的抖动量很大画出来会让人觉得毛糙。千千静听那种有弹性的跳动本质上是一种带惯性的一阶跟随柱子的目标值追击实时能量但没有一秒追上而是每帧走一部分同时每个柱子维护一个峰值峰值缓慢下降视觉上就有经典的“余晖”效果。两个参数最值得调上升速度反应柱子跟手程度一般取0.4到0.7数值越大随着鼓点抬升得越快衰减速度决定柱子落下时的拖尾感一般取0.08到0.15数值越小掉得越慢。峰值下降可以再慢一点0.02到0.05这样峰值线会比主柱体慢半拍很像老播放器的感觉。刷新率用16毫秒一帧足够不必每帧都跑invalidate。这里要特别说一句频谱“跳动”不是把数值放大一点就完事。我看到不少作品把柱子乘了一个系数2结果低频柱子天天顶到屏幕顶部高频柱子仍旧几乎看不见。正确做法是给不同频段的柱子做独立的增益曲线低频段增益低些、中频段接近1、高频段适当放大每根柱子再做一次归一化让整体分布均匀。3.3 核心绘制代码柱子、峰值线与渐变下面是一个简化过的SpectrumBarView重点看onDraw里的循环每一帧先对柱子做指数衰减再用drawRect画当前高度最后画峰值线。为了避免在绘制循环里创建RectF对象我复用了一个成员RectF这也是低端机不卡的关键细节之一。class SpectrumBarView JvmOverloads constructor( context: Context, attrs: AttributeSet? null ) : View(context, attrs) { private val barValues FloatArray(48) { 0f } private val peakValues FloatArray(48) { 0f } private val rectF RectF() private val paint Paint(Paint.ANTI_ALIAS_FLAG) private val peakPaint Paint(Paint.ANTI_ALIAS_FLAG) init { paint.color Color.parseColor(#4A90D9) paint.style Paint.Style.FILL peakPaint.color Color.parseColor(#FF6B6B) peakPaint.strokeWidth dp(2f) } fun updateSpectrum(data: FloatArray) { for (i in barValues.indices) { barValues[i] maxOf(data[i].toFloat() / 128f, barValues[i]) } } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val barWidth width.toFloat() / (barValues.size * 1.6f) val gap barWidth * 0.6f for (i in barValues.indices) { // 每帧向平均值回落形成惯性跳动 barValues[i] * 0.92f peakValues[i] maxOf(peakValues[i] - 0.02f, barValues[i]) val barHeight barValues[i] * height * 0.8f val left i * (barWidth gap) val top height - barHeight rectF.set(left, top, left barWidth, height) canvas.drawRect(rectF, paint) val peakY height - peakValues[i] * height * 0.8f canvas.drawLine( left - 2f, peakY, left barWidth 2f, peakY, peakPaint ) } // 通过 Choreographer 驱动下一帧 postInvalidateOnAnimation() } private fun dp(value: Float): Float { return value * resources.displayMetrics.density } }这段代码里的updateSpectrum会被数据回调调用但它只更新barValues的上限真正的回落发生在onDraw里。0.92这个因子意味着每帧柱子往回落8%如果每16毫秒一帧则一根柱子从满高掉到接近0大约需要0.3秒这个节奏刚好适配流行音乐的鼓点。峰值衰减0.02更慢会留下一条清晰的余晖线。柱子宽度和间隙也需要注意。我让柱宽和间隙的比例接近1比0.6缝隙太大频谱会显得稀疏太小则糊成一团。颜色的选择上柱体和峰值线分开配色柱体用半透明蓝、峰值用亮红视觉层次更接近经典桌面播放器的风格。3.4 性能优化重用对象、避免每帧分配整个绘制链路中最常见的性能问题有两个一是在onDraw里无脑new对象二是把View层叠在复杂的父布局里导致每帧都在做无意义的测量。自定义View的绘制循环每帧会执行几十次任何一次循环内的对象分配都会触发垃圾回收表现就是跑几秒后界面卡一下。解决思路其实很简单所有Paint在init里创建RectF作为成员变量复用颜色、数值计算尽量用Float和Int避免在循环里调用资源查找。如果你需要渐变效果可以提前把LinearGradient shader创建好不要每帧new Shader。硬件加速默认开启的情况下drawRect这种基础绘制开销非常低只要不在绘制路径里做FFT计算帧率都能稳定在60。另外一个我踩过的坑是刷新驱动不能写成while(true)死循环。正确做法是像上面代码一样在onDraw末尾调用postInvalidateOnAnimation()它会把下一帧安排在下一个Vsync信号处既省电又不会掉帧。如果你需要控制刷新率可以包一层Handler.postDelayed但大多数音乐播放器的频谱需求下跟随系统Vsync就是最好的选择。4. 带均衡的播放器把Equalizer接进频谱链路4.1 Equalizer绑定到同一个audio session均衡器和Visualizer一样都是AudioEffect的子类绑定方式也一致构造时传入audio session ID。最关键的一点是Visualizer和Equalizer必须绑定同一个session否则会出现“频谱显示的是原声、均衡器调的是另一路声音”的诡异问题。我用MediaPlayer时的固定流程是setDataSource之后先取audioSessionId再分别创建两个效果器。private fun setupPlayerAndEffects(url: String) { val player MediaPlayer() player.setAudioAttributes( AudioAttributes.Builder() .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .setUsage(AudioAttributes.USAGE_MEDIA) .build() ) player.setDataSource(url) player.prepare() val sessionId player.audioSessionId val spectrumHelper SpectrumDataHelper(sessionId) spectrumHelper.start { energy - runOnUiThread { spectrumView.updateSpectrum(energy) } } val equalizer Equalizer(0, sessionId) equalizer.setEnabled(true) // 把EQ对象保存为成员后续UI调节要用 this.equalizer equalizer }注意两个细节音频属性必须显式设置成媒体播放某些设备上默认的用法无法创建非零的audio sessionprepare之后audioSessionId才稳定所以效果器创建要放在prepare之后。如果你的播放器是跨进程的比如第三方播放内核跑在另一个进程里session ID依然可以传给本进程的Visualizer但你需要确认该session确实在同一个音频策略空间内否则初始化会失败。一开始接触这套API的人容易把这里的session和MediaSession混为一谈。MediaSession是给媒体通知、锁屏控制的负责对外展示audio session是音频流内部的路由标识Visualizer和Equalizer认的是后者。两者可以同时存在但用途完全不同不要拿着MediaSession的token去new Visualizer。4.2 频段参数表中心频率、增益范围与滑杆映射Equalizer把频段信息暴露得非常直白通过numberOfBands拿到段数通过getCenterFreq获取每段中心频率通过getBandLevelRange获取增益上限。不同机型的频段数量不同常见的是5段或10段中心频率分布也有差异。写死频率表不是好习惯正确做法是运行时读取。val bands equalizer.numberOfBands val range equalizer.getBandLevelRange() // 例如 [-1500, 1500]单位毫分贝 class EqPreset(val name: String, val levels: IntArray) { fun apply(equalizer: Equalizer) { for (i in levels.indices) { equalizer.setBandLevel(i.toShort(), levels[i].toShort()) } } }滑杆到增益的映射也要按毫分贝算。假设range是-1500到1500UI上滑杆位置从0到100那么实际增益 (position - 50) / 50f * range[1]。很多人的滑杆看起来能拖、声音却没变化问题就出在把setBandLevel的第二个参数直接传了0到100的整数。它要求的单位是毫分贝不是百分比这个单位问题足够让新手排查半天。对于想要一键还原经典音效的场景我会预设几组记忆曲线流行音乐把低频和高频各抬3到5dB中频稍微压一点摇滚把低频抬4dB、高频抬3dB人声则压低低频和中低频。预设用代码里的EqPreset保存一份频段数组切换时依次调setBandLevel就能实现不需要引入音效库。4.3 让频谱跟随EQ曲线变化所见即所得的可视化这里要提前说一个容易让人困惑的事实Visualizer绑定在player session上抓取到的数据在不同设备的音频框架里可能处于effect链的不同位置。有的设备抓到的已经是EQ处理后的数据频谱会随EQ改动而变有的设备抓的是解码后的原始PCM无论EQ怎么调频谱纹丝不动。这个“黑匣子”行为没法用一行代码保证统一所以更稳妥的做法是把EQ增益曲线叠加到当前频谱显示上做“可视化仿真”。具体的实现也不复杂在SpectrumBarView里增加一个eqGains数组长度与柱子数一致绘制时把柱子的能量值乘上一个由EQ增益换算出来的系数。增益为正就放大柱子高度为负就压低。代码如下fun applyEqualizerCurve(eqGains: FloatArray) { // eqGains 的单位是 dB转成线性放大倍率 for (i in eqGains.indices) { eqOffset[i] Math.pow(10.0, eqGains[i] / 20.0).toFloat() } invalidate() }在使用时你把每条柱子的中心频率对应到Equalizer的频段上柱子频率落在某个频段的覆盖范围内就使用该频段的增益系数落在频段边界附近的用相邻两段做线性插值避免出现柱子之间高度断层。这样无论设备底层数据是否受EQ影响用户看到的效果都是拉动低音滑杆左侧柱子立刻高起来右侧柱子几乎不变体验和你调节的声音变化方向一致。这套叠加逻辑的价值在于它把“无法控制的底层行为”变成“由UI层保证的一致性”。你不必去猜设备音频框架怎么走只要用户耳朵听到的和眼睛看到的趋势相同体验就不会翻车。如果你的目标是精确的“所见即所得”那就要放弃MediaPlayer改用AudioTrack自建渲染链路把Visualizer和Equalizer都绑到AudioTrack的session上这是另一种架构选择复杂度会上升一档。5. 避坑频谱不跳、EQ失效、卡顿的排查记录5.1 一创建Visualizer就崩溃权限和会话的叠加问题现象new Visualizer(sessionId) 这一行抛出RuntimeException日志里能看到“cannot initialize Visualizer engine”或直接就是unhandled异常应用闪退。 原因有两个常见诱因叠加。一是Android 6以上的运行时权限没有动态申请RECORD_AUDIO部分厂商实现会在初始化效果引擎时检查录音权限二是传入的sessionId无效比如在MediaPlayer尚未prepare时就提前创建Visualizer或者拿到的sessionId是0且没有对应Player在播放。 解决先确认Manifest里包含RECORD_AUDIO和MODIFY_AUDIO_SETTINGS两个权限再在启动播放前动态申请RECORD_AUDIO创建Visualizer前确保player已prepare。最后给整段初始化套上try-catch失败时直接释放并降级为“无频谱”模式不要让播放器跟着崩。5.2 频谱有数据却一直是一条直线FFT解析出了偏差现象onFftDataCapture回调在跑数据数组也不是空的但画出来的所有柱子高度几乎一样、没有起伏或者只有第一根柱子动。 原因FFT返回的是byte数组里面保存的是复数实部和虚部按顺序一对一对存放。新手常犯的错误是直接把fft[i]当作强度值或者忘了把byte转成int就做平方运算导致数值溢出后幅度被压平。另一种情况是用线性频率映射时大部分能量集中在低频前几个桶后面全是接近0的高频桶视觉上就像直线。 解决按“real取偶数下标、imag取奇数下标”的方式解析用hypot求模再做对数频率分组把柱子平均分配给60Hz到16kHz区间。解析完可以先打一个log看一下最大值是不是集中在低频区确认数据本身的分布正常再看绘制层是否做了归一化。5.3 切歌之后均衡器归零session变了但EQ还挂在旧会话上现象第一首歌时EQ调得很有感觉播放器自动切到下一首后所有频段回到0但UI滑杆还停在原来的位置。 原因很多播放器实现会在切歌时销毁并重建MediaPlayer新的播放器会分配一个全新的audio session ID。你之前创建的Equalizer还绑在旧session上旧session已经失效新播放器没有EQ效果。 解决监听播放器的完成和切换回调在每次prepare成功后重新执行“取sessionId、创建Equalizer、再应用当前预设”完整流程。不要复用之前创建的效果器实例直接release旧的再新建。这个规则同样适用于Visualizer所以我在封装里把start和release设计成成对调用切换歌曲时统一重置。5.4 拉动EQ滑杆但声音一点不变单位与生效开关现象滑杆能拖动UI也更新了但耳机里听不出任何区别甚至把低音拉到最高还是原声。 原因要么setBandLevel传的单位不对把UI百分比当成毫分贝直接传进去比如传了70而实际范围只有-1500到150070毫分贝几乎听不出变化要么是setEnabled(true)没调用Equalizer实例创建后处于禁用状态所有设置都压在效果器内部没有生效。 解决使用getBandLevelRange()读真实增益范围再把滑杆位置线性映射到该区间创建效果器后立刻调setEnabled(true)。验证时别用手机外放外放扬声器对低频响应很差换一副能听到明显低频的耳机测试100Hz频段抬12dB应该能立刻感受到低音变重。5.5 低端机开启频谱后整机发烫卡顿绘制和回调双重超标现象打开播放页后明显掉帧滑动RecyclerView卡顿运行几分钟后手机背面发热。 原因常是三个问题叠加。第一Visualizer的captureSize设到了最大值比如16384回调频率也设成上限导致Binder线程高频压入大量数据第二在onDraw里new Path、new RectF、甚至做了FFT解析第三View没有使用postInvalidateOnAnimation而是用每帧postDelayed 10ms强制刷新烧掉了GPU和UI线程。 解决把captureSize降到1024回调频率设为maxCaptureRate的一半即可48根柱子根本不需要更高精度的FFT绘制循环里所有对象复用FFT解析放在数据回调线程而不是UI线程刷新用postInvalidateOnAnimation跟随系统Vsync。按这三步改完老机型也能稳定跑出30帧以上的频谱而不是靠牺牲音质和流畅度硬撑。6. 进阶验证用测试音频让频谱和EQ真正对齐很多人把代码写完播一首歌觉得“挺好看”就收工了但边听边看只能验证整体效果验证不了频谱是否正确。我这里有一套三步验证法适用于任何播放器频谱项目。第一步用固定单音验证频率轴。自己做一个小工具用AudioTrack输出440Hz正弦波看频谱里的最大能量柱是否落在你映射的440Hz附近。如果差出几百Hz说明采样率或captureSize换算有偏差如果柱子在低频端乱跳说明解析或归一化有问题。第二步扫频验证覆盖范围。让测试音从100Hz线性扫到12kHz观察柱子能量是否跟着从左往右平滑移动中间不能有断崖。第三步对比EQ前后。关掉EQ播放同一首歌记录频谱分布再把低音频段拉高12dB如果频谱曲线在低频端有明显隆起说明可视化叠加或底层的联动生效了。这套方法能自动区分是你的代码问题还是设备黑匣子行为省下大量怀疑人生的时间。我自己的习惯是再补一个视觉润色给柱子加一个最低高度阈值让静音时也有一点点微弱的跳动避免播放切歌间隙屏幕完全空掉。阈值控制在满高度的0.02到0.04之间既不会让人觉得假又不会让频谱像断了电一样。项目上线前别忘了做不同设备的分组测试有些机型自身音频后处理很强Visualizer捕获到的数据会有明显的染色这时候优先信任你听到的声音而不是屏幕上的形状。如果你真想要那种“一秒回到桌面播放器”的效果可以把画笔换成渐变色、柱子改成两侧对称、再加一组峰值保持线整体质感会再高一截。但这些都是锦上添花核心的视觉链路还是那几十根柱子、平滑的衰减、以及和EQ互相呼应的变化。做这个方向最忌讳的就是堆功能把FFT、均衡器、可视化全部糅进一个Activity里而互不相通那才是既难调又难查的真正事故源头。先跑通最小链路再谈好看这样即使将来翻车你也能知道该去调试哪一层。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?