18、媒体应用开发:CarMediaApp 模板、音频焦点处理、播放列表管理
好,咱们今天聊聊车载媒体应用开发。说实话,这部分内容在 Android Automotive OS 里算是比较有「车味儿」的模块。你想想看,手机上的音乐 App 和车机上的音乐 App,虽然界面长得差不多,但背后的逻辑差别其实挺大的。
我个人习惯把车载媒体开发拆成三个核心块:模板架构、音频焦点、播放列表管理。这三块搞定了,一个能上车的媒体应用基本就成型了。
18.1 CarMediaApp 模板:别从零造轮子
Android Automotive OS 提供了一套现成的媒体应用模板,叫 CarMediaApp。说白了,Google 已经帮你搭好了架子,你只需要往里面填业务逻辑就行。
我在项目中遇到过不少团队,上来就自己写 MediaBrowserService,结果折腾两周发现跟系统的深色主题、方向盘控制、语音交互全对不上。嗯,这里要提醒你:能用模板就别自己造。
模板的核心结构是这样的:
// 模板入口:CarMediaApp 继承自 Application
class MyMediaApp : CarMediaApp() {
override fun createMediaBrowserService(): MediaBrowserServiceCompat {
return MyMediaBrowserService()
}
override fun createNowPlayingController(): NowPlayingController {
return MyNowPlayingController()
}
}
你只需要实现两个方法:createMediaBrowserService() 和 createNowPlayingController()。前者负责提供媒体数据,后者负责控制播放状态。
核心要点:CarMediaApp 模板会自动处理与系统 UI 的绑定,包括通知栏、锁屏、方向盘按键。你不需要手动注册任何 BroadcastReceiver。
模板还内置了分屏适配。我记得有一次测试,发现 App 在宽屏模式下布局错乱,后来发现是没调用模板提供的 onConfigurationChanged() 方法。其实模板已经帮你处理好了,你只需要确保 Activity 继承自 CarMediaActivity。
18.2 音频焦点处理:别让 App 打架
车载环境里,音频资源是稀缺的。导航要说话、电话要响铃、音乐要播放——谁先谁后?这就是音频焦点要解决的问题。
Android Automotive OS 的音频焦点机制比手机版更严格。为什么?因为车里多个 App 可能同时请求音频,系统需要根据「优先级」来决定谁出声。
我画了一张图,帮你理清音频焦点的流转逻辑:
代码实现上,你需要注册一个 AudioManager.OnAudioFocusChangeListener:
private val focusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
// 获得焦点,恢复播放
mediaPlayer?.start()
}
AudioManager.AUDIOFOCUS_LOSS -> {
// 永久失去焦点,停止播放
mediaPlayer?.stop()
abandonFocus()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
// 暂时失去焦点,暂停播放
mediaPlayer?.pause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
// 可以降低音量继续播放(比如导航播报时)
mediaPlayer?.setVolume(0.3f, 0.3f)
}
}
}
注意:车载环境下,AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK 的使用要谨慎。有些车机对音量变化很敏感,频繁「闪避」会让用户觉得声音忽大忽小。我曾经遇到过一个案例,导航每播报一次,音乐就「抖」一下,用户体验极差。
18.3 播放列表管理:别让用户等
播放列表管理,说白了就是怎么把一堆歌曲组织起来,让用户能切歌、能搜索、能记住播放位置。
Android Automotive OS 推荐使用 MediaBrowserServiceCompat 来管理媒体库。它的核心是一个树形结构:根节点下面有专辑、歌手、播放列表等子节点。
我习惯这样组织:
class MyMediaBrowserService : MediaBrowserServiceCompat() {
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot? {
// 返回根节点
return BrowserRoot("__ROOT__", null)
}
override fun onLoadChildren(
parentId: String,
result: Result<List<MediaBrowserCompat.MediaItem>>
) {
result.sendResult(when (parentId) {
"__ROOT__" -> listOf(
createCategoryItem("专辑", "albums"),
createCategoryItem("歌手", "artists"),
createCategoryItem("播放列表", "playlists")
)
"albums" -> loadAlbums()
"playlists" -> loadPlaylists()
else -> emptyList()
})
}
}
这里有个坑:不要在主线程加载媒体数据。车载设备的存储可能是 U 盘、SD 卡或者云端,读取速度差异很大。我建议用 AsyncTask 或者协程来异步加载。
小技巧:播放列表的持久化推荐使用 Room 数据库。SharedPreferences 存不了复杂结构,文件存储又不好做增量更新。Room 配合 LiveData 或 Flow,可以实现播放列表的实时刷新。
播放列表的排序也是个细节。用户期望的是:
- 最近播放的排前面
- 收藏的歌曲有特殊标记
- 支持拖拽排序
嗯,这里我建议用 RecyclerView 配合 ItemTouchHelper 来实现拖拽。代码大概长这样:
val touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
0
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.adapterPosition
val to = target.adapterPosition
// 交换播放列表中的位置
playlistAdapter.swapItems(from, to)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// 不需要滑动删除
}
})
touchHelper.attachToRecyclerView(recyclerView)
最后提一下「续播」功能。车载场景下,用户可能熄火下车,下次上车还想接着听。你需要保存当前播放的歌曲 ID 和播放位置(毫秒级)。我一般存在 DataStore 里,启动时读取并恢复。
| 存储方式 | 适用场景 | 我的建议 |
|---|---|---|
| SharedPreferences | 简单键值对 | 不推荐,类型不安全 |
| DataStore | 偏好设置、播放状态 | 推荐,支持协程 |
| Room | 复杂播放列表、历史记录 | 强烈推荐,功能最全 |
我曾经遇到过一个 Bug:用户熄火后立即启动车辆,播放位置恢复到了上一首歌的开头。排查后发现是 onSaveInstanceState 和 onRestoreInstanceState 的时机问题。后来改用 DataStore 实时写入,问题解决。
好了,媒体应用开发的核心就这些。模板帮你搭架子,音频焦点帮你管声音,播放列表帮你管数据。三件事做好,你的车载音乐 App 就能稳稳上路了。