背景
这个想法是7月底产生的。上半年云顶之弈返场 S10 强音争霸,很喜欢这个版本里面的每个羁绊自带 BGM 而且还可以组合混音的模式,但是在7月底就下线了,不知道再次返场会是什么时候。于是出于对这个模式的喜欢,就有了手搓一个混音器的想法。
这是我第一次接触原生 Android 开发。之前一直做的 Web 全栈,前端框架和后端 Java 已经写得很熟练了。得益于 Java 的经验,上手 kotlin 没有花太多时间,而且做前端的时候也经常用 React,所以很容易就习惯了 Compose 的写法。
git地址:
kurimuson/heartsteel: 云顶S10强音争霸混音器

项目结构
我是用的是 Kotlin Multiplatform 的模板,UI 部分和各个平台的部分是分开的。目前我只做了 Android 部分,后期有时间了会考虑编译为桌面端。

虽然 Kotlin Multiplatform 可以适配全平台,但是 IOS 端和 Web 我就不考虑了,因为一是我没有 Mac 和 iPhone,也不会 Swift,所以不考虑适配 IOS;二是音频资源非常多,单个文件也非常大,做成 Web 端不太合适,而且这个 APP 的核心功能多音轨同步音频播放可能在浏览器上实现会有困难。
如果未来 Kotlin Multiplatform 官方能直接适配鸿蒙端,我也许会考虑将它在华为手机上跑起来。但是现在我不会研究这个,因为我不太想将 Kuikly 这种框架引入到我这一个很简单纯粹的项目中。
核心功能
这个 APP 的核心功能就是多音轨同步音频播放。如何保证每一条音轨始终是同步播放的,这个要求看似简单,实际上实现起来有一定难度。

踩坑
在一开始,我查阅到 Android 音频播放可以使用 MediaPlayer 来实现。于是花了一天时间来做了下相关的功能。但是很快我发现,MediaPlayer 虽然能正确地将音频文件放出声,但是一旦我在拖动进度条之后,跳转到指定时间位置,就发现音频播放不同步了。因为云顶之弈的每个音频都是同样的一个节奏,如果某一条音轨有哪怕是 0.1s 的延迟,那么听起来就明显感觉节奏对不上。
经过查阅资料,我发现原来我使用的是可变比特率的 ogg 音频,核心原因在于 VBR 编码的时间定位不确定性 。VBR(可变比特率)的本质是"根据音频复杂度动态调整比特率"------复杂段落用高比特率,简单段落用低比特率。这种特性导致字节位置 与播放时间并非线性对应。简单来说,音频也是可以理解为"一帧一帧"的数据,可变比特率就是某一帧的时间长,某一帧的时间短。跳转到指定时间后,实际上不是到绝对的时间,而是到那个时间点所在的帧。所以多音轨下比特率不同,那么帧就不会在同一位置,延迟就产生了。所以在这一阶段,我将音频全部换成了固定比特率的 mp3,延迟在很大程度上缓解了。
接着踩坑
为什么说"在很大程度上缓解了",而不是说"解决了"呢?因为我发现在小概率下这个现象还是存在。经过我的一番检查,发现 MediaPlayer 是存在一定延迟的 ,这个延迟在执行 seekTo()
跳转方法的时候很容易出现,因为每一个音频文件它执行 seekTo()
所花的时间是不同的,这就导致了执行完后播放也就不完全同步了。虽然这个延迟很低,对于单音频文件播放不影响,但是我需要的是零延迟的多音轨同步音频播放,所以 MediaPlayer 这个方案被废弃了。
后来又试了试 ExoPlayer,发现与 MediaPlayer 存在同样的延迟问题。我对 Android 音频没啥经验,询问 AI 得知,MediaPlayer 这种方案的音频路径是经过系统混音器与媒体框架,缓冲区管理固定或自适应缓冲,策略偏稳定。我想应该就是这个原因吧。
解决办法
既然现成的 MediaPlayer 音频 API 不能满足我的需求,那么有没有其它的解决方案呢?于是我在 Google 的 Android 官方文档上找到了高性能音频方案。
高性能音频 | Android NDK | Android Developers
通常,高性能音频应用所需的不仅仅是简单的声音播放或录制功能。它们需要响应式实时系统行为。
本部分介绍了最大限度缩短音频延迟的一般原则。此外,本部分还提供了音频采样建议,以帮助您选择最佳采样率,以及考虑使用浮点数表示音频数据的优缺点。
这不就正好对上了我的需求吗,于是,按照文档的建议,我决定使用 Oboe。
得益于我还有一点 C++ 的基础,所以做起来就不是那么费力了。
在 androidMain 中添加 C++ 相关支持
如下所示,在 composeApp
下的 build.gradle
中添加如下代码。由于我还是对 Java 更加熟悉,以及也知道 JNI 的写法,所以我一并在配置里面开启了 androidMain
模块的 Java 代码支持,也就是说针对 Android 部分可以实现 Java 与 Kotlin 混写。
kts
android {
...
defaultConfig {
...
// C++
externalNativeBuild {
cmake {
cppFlags.add("-std=c++17")
arguments("-DANDROID_STL=c++_shared")
}
}
}
...
// Java适配补充
sourceSets {
getByName("main") {
java.srcDirs("src/androidMain/java") // 确保Java源码目录被包含
}
}
// C++
buildFeatures {
prefab = true
}
externalNativeBuild {
cmake {
path = file("src/androidMain/cpp/CMakeLists.txt")
version = "4.1.0"
}
}
}
可以看到下图我的代码中 Android 部分:我使用 C++ 对播放器进行底层实现,使用 Java 对播放器进行封装,使用 Kotlin 作为 MainActivity 入口以及 commonApp 中接口的实现对接。

对于上图中的几个 Oboe 命名的文件,在此解释下:
OboeEngine.cpp
与OboeEngine.h
:音频播放器的底层类,Oboe 的具体实现;
jni-bridge.cpp
:OboeEngine.cpp
到 OboeEngine.java
的 JNI 桥;
OboeEngine.java
: JNI 方法的封装,将 OboeEngine.cpp
封装在 Java 对象内部,供外部调用;
OboePlayer.java
:抽象类,播放器的逻辑封装,例如播放回调、跳转等相关方法,具有一个播放器最基础的功能;
OboeListPlayer.java
与OboeMixerPlaye.javar
:OboePlayer.java
的子类,不同类型的播放器的具体实现。
PCM 是音频硬件的"原生语言",Oboe 不支持压缩音频格式,并非技术限制,而是"为低延迟场景做减法"的设计选择------ 通过聚焦 PCM 这一硬件原生格式,规避了解码开销,简化了实时处理链路,从而实现其核心价值:"让应用以最低延迟控制音频硬件"。
在 Android 游戏开发的文档中可以找到 Oboe 低延迟音频的使用方法。
低延迟音频 | Android game development | Android Developers
因此我的项目中构建 Oboe 的代码如下:
cpp
auto result = mStreamBuilder
.setCallback(this)
->setDirection(oboe::Direction::Output)
->setPerformanceMode(oboe::PerformanceMode::LowLatency) // 请求低延迟模式
->setSharingMode(oboe::SharingMode::Exclusive) // 请求独占模式
->setUsage(oboe::Usage::Game) // 声明游戏用例
->setSampleRate(mSampleRate) // 使用48000Hz采样率(传入参数固定为48000)
->setChannelCount(mChannels)
->setFormat(audioFormat)
->openStream(&mStream);
具体代码可以在 git 上详细查看。
音频可视化
我觉得音频可视化可以算是这个项目的一大亮点。以前在前端使用 AudioContext 做过音频可视化,可以看我的个人网站:
因为 Compose 同样有 Canvas 组件,于是我将页面底部的可视化效果复刻到了 Compose 中,效果如下:

对比我网站上的音乐播放器,可以说是效果基本一致了。
获取频谱数据
在前端中,是使用 AudioContext 播放音频,在 AudioContext 中获取频谱信息,大致代码如下:
ts
...
let actx = new AudioContext();
let decoded = await actx.decodeAudioData(buf)
let analyser = actx.createAnalyser();
analyser.connect(actx.destination);
// 创建Buffer节点
let sourceNode.buffer = this.decoded;
sourceNode.loop = false;
sourceNode.connect(this.analyser);
// 快速傅里叶变换, 必须为2的N次方
analyser.fftSize = 2048;
...
在获取到频谱信息数组后,就可以将频谱信息数组的值通过 Canvas 绘制出来,达到音频可视化的目的。本项目也是用的这个大致思路。
但是 Oboe 没有提供类似于 js 中 analyser.fftSize = 2048
快速傅里叶变换的方法,那么如何获取频谱信息呢?
在使用 Oboe 播放音频的时候,具体播放的代码是在 Oboe 回调到系统音频线程 OboeEngine::onAudioReady
方法中进行的。在这个方法中会拿到当前音频帧的 buffer 数据,所以根据这个数据就能得到当前音频帧的频谱信息。
当然直接将 buffer 当做频谱绘制出来是不行的,analyser.fftSize
这里,AudioContext 会做快速傅里叶变换处理,最终给出一组平滑的频谱数据。于是在本项目中,我使用 kissfit 插件进行相关的计算,最终得到了与 AudioContext 中基本一致的频谱数据,实现了这一效果。详细实现步骤就不在这里讲了,具体可以看 AudioSpectrumAnalyzer.cpp
中的代码实现。
mborgerding/kissfft: a Fast Fourier Transform (FFT) library that tries to Keep it Simple, Stupid

我在 OboePlayer.java
中已经封装了获取频谱数据的方法,这一块的完整 C++ 和 Java 代码是可以复制到你的项目中直接使用的。
绘制到界面上
绘制到界面上也不难。大致方法为,声明一个 Compose,在其内部创建一个循环方法,每隔 16ms 获取一次频谱数据,更新到 mutable 变量,触发重组更新界面,实现近似于 60 帧的频谱可视化效果。
kt
val scope = rememberCoroutineScope()
val rectList = remember { mutableStateListOf() }
LaunchedEffect(Unit) {
scope.launch {
while (true) {
render() // 获取频谱数据,更新到rectList
delay(16)
}
}
}
Canvas() {
// rectList更新,触发重组,实现界面更新
drawRect()
drawRect()
}
基本思路就是这样,实际代码肯定与这有点不同,但是核心步骤都是一样的。
结尾
第一次做原生 Android,难免会对其中的一些概念理解得有所偏差。欢迎各位大佬指正,也欢迎喜欢云顶的玩家能够喜欢我做的混音器。
现在打开 APP,拖动进度条时所有音轨能精准同步,底部的频谱跟着 BGM 节奏跳动,那种 "亲手实现喜欢的功能" 的成就感,远超过技术本身的难度。这个项目里没有复杂的架构,更多是 "遇到问题就解决问题" 的直白。
如果有人也怀念云顶 S10 的羁绊 BGM,或者对 "低延迟多音轨播放" "Compose 音频可视化" 感兴趣,欢迎来 kurimuson/heartsteel: 云顶S10强音争霸混音器 看看源码,有疑问或优化建议也随时提 Issue ------ 毕竟技术的乐趣,除了自己实现需求,更在于和同好分享碰撞时的惊喜。
最后也算圆了自己一个小遗憾。就算游戏里的版本下线了,那些熟悉的 BGM,也能在自己写的 APP 里一直听下去。