第30章 综合实战项目:从零开发一款车载音乐播放器,覆盖Android Auto与CarPlay双平台
终于到了最后一章。说实话,写到这里我自己也有点感慨。前面29章我们聊了那么多底层原理、协议细节、UI规范,今天咱们就把这些东西串起来,真正动手做一个能跑在车机上的音乐播放器。
这个项目我去年在内部培训时带团队做过一次。当时有个同事问我:“老张,能不能一周搞出来?”我说:“如果你只做Demo,三天就够了。但要上生产环境,至少两周。”嗯,今天我们就按“能上路”的标准来。
30.1 项目整体架构设计
先看整体结构。我习惯把车载音乐播放器分成三层:
核心架构三层模型
- 数据层:本地媒体库扫描 + 在线音乐API + 缓存管理
- 业务层:播放控制、歌单管理、搜索、收藏
- 展示层:Android Auto模板 + CarPlay模板 + 通用UI组件
为什么这么分?我在项目里吃过亏。一开始把播放逻辑和UI混在一起写,结果换CarPlay模板时改得想哭。后来老老实实分层,Android Auto和CarPlay共用同一套业务逻辑,只换模板文件,省了至少一半工作量。
30.2 Android Auto端实现
Android Auto这边,核心是MediaBrowserService。说白了,车机通过它来发现你的音乐内容。
我的经验:MediaBrowserService的onLoadChildren方法里,记得按专辑、歌手、歌单分类返回。别一股脑把所有歌曲都丢进去,车机屏幕就那么点大,用户翻起来会疯的。
// 核心服务实现
public class MusicPlaybackService extends MediaBrowserServiceCompat {
private MediaSessionCompat mediaSession;
private MusicPlayer player;
@Override
public void onCreate() {
super.onCreate();
// 初始化MediaSession
mediaSession = new MediaSessionCompat(this, "MusicService");
mediaSession.setCallback(new MediaSessionCallback());
mediaSession.setFlags(
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS |
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
);
setSessionToken(mediaSession.getSessionToken());
// 初始化播放器
player = new MusicPlayer(this);
}
@Override
public BrowserRoot onGetRoot(String clientPackageName,
int clientUid, Bundle rootHints) {
// 只允许车机连接
if (!clientPackageName.equals("com.google.android.projection.gearhead")) {
return null;
}
return new BrowserRoot("__ROOT__", null);
}
@Override
public void onLoadChildren(String parentId,
Result<List<MediaItem>> result) {
List<MediaItem> items = new ArrayList<>();
if ("__ROOT__".equals(parentId)) {
// 返回分类目录
items.add(createCategory("albums", "专辑",
MediaDescriptionCompat.BT_FOLDER_TYPE_ALBUMS));
items.add(createCategory("artists", "歌手",
MediaDescriptionCompat.BT_FOLDER_TYPE_ARTISTS));
items.add(createCategory("playlists", "歌单",
MediaDescriptionCompat.BT_FOLDER_TYPE_PLAYLISTS));
} else if ("albums".equals(parentId)) {
// 返回专辑列表
items.addAll(loadAlbums());
}
result.sendResult(items);
}
}
UI方面,Android Auto提供了几种固定模板。音乐播放器我推荐用列表模板 + 播放控制模板的组合。别想着自定义布局,谷歌不允许,而且说实话,模板已经够用了。
避坑提醒:我曾经在MediaSession的setActions里漏掉了ACTION_SEEK_TO,结果用户无法拖动进度条。排查了半天才发现是少加了一个flag。记住,播放、暂停、上一首、下一首、拖动进度,这五个是必须的。
30.3 CarPlay端实现
CarPlay这边用的是MPPlayableContent框架。说实话,苹果的文档写得比谷歌清楚,但限制也更多。
核心就两个东西:
- MPPlayableContentDataSource:提供内容数据
- MPPlayableContentDelegate:处理播放事件
// CarPlay数据源实现
class CarPlayDataSource: NSObject, MPPlayableContentDataSource {
let library: MusicLibrary
init(library: MusicLibrary) {
self.library = library
}
func numberOfChildItems(at indexPath: IndexPath) -> Int {
if indexPath.isEmpty {
// 根节点:专辑、歌手、歌单
return 3
} else if indexPath.first == 0 {
// 专辑列表
return library.albums.count
}
return 0
}
func contentItem(at indexPath: IndexPath) -> MPContentItem? {
let item = MPContentItem()
if indexPath.isEmpty {
// 根节点
item.title = indexPath.first == 0 ? "专辑" :
indexPath.first == 1 ? "歌手" : "歌单"
item.subtitle = ""
item.isContainer = true
item.isPlayable = false
} else if indexPath.first == 0 {
// 专辑详情
let album = library.albums[indexPath.last]
item.title = album.name
item.subtitle = album.artist
item.isContainer = true
item.isPlayable = false
}
return item
}
func beginLoadingChildItems(at indexPath: IndexPath,
completionHandler: @escaping (Error?) -> Void) {
// 异步加载数据
DispatchQueue.global().async {
// 加载专辑封面、歌曲列表等
completionHandler(nil)
}
}
}
CarPlay有个坑:封面图片必须提前缓存好。我遇到过车机卡在加载封面的情况,后来发现是每次都在主线程下载图片。解决方案很简单:在beginLoadingChildItems里提前把图片URL转成本地缓存路径。
30.4 双平台通用播放引擎
这部分是我最想分享的。Android Auto和CarPlay的播放控制逻辑其实一模一样,区别只在于怎么把状态同步到车机屏幕。
| 功能 | Android Auto | CarPlay |
|---|---|---|
| 播放/暂停 | MediaSession.Callback.onPlay() | MPPlayableContentDelegate.playContentItem() |
| 下一首 | MediaSession.Callback.onSkipToNext() | MPPlayableContentDelegate.skipToNext() |
| 进度同步 | MediaMetadata + PlaybackState | MPNowPlayingInfoCenter |
| 歌单浏览 | MediaBrowserService.onLoadChildren() | MPPlayableContentDataSource |
我的做法是抽象一个PlaybackEngine接口,两边各自实现适配器。这样核心播放逻辑只写一次,测试也方便。
// 通用播放引擎接口
public interface PlaybackEngine {
void play(String songId);
void pause();
void skipToNext();
void skipToPrevious();
void seekTo(long position);
PlaybackState getCurrentState();
}
// Android Auto适配器
public class AndroidAutoAdapter implements PlaybackEngine {
private MediaSessionCompat session;
@Override
public void play(String songId) {
// 构建MediaMetadata
MediaMetadataCompat metadata = buildMetadata(songId);
session.setMetadata(metadata);
// 更新播放状态
PlaybackStateCompat state = new PlaybackStateCompat.Builder()
.setState(PlaybackStateCompat.STATE_PLAYING, 0, 1.0f)
.build();
session.setPlaybackState(state);
// 实际播放
player.play(songId);
}
}
// CarPlay适配器
public class CarPlayAdapter implements PlaybackEngine {
private MPNowPlayingInfoCenter nowPlaying;
@Override
public void play(String songId) {
// 更新NowPlayingInfo
Map<String, Object> info = new HashMap<>();
info.put(MPNowPlayingInfoPropertyElapsedPlaybackTime, 0);
info.put(MPMediaItemPropertyTitle, getSongTitle(songId));
nowPlaying.setNowPlayingInfo(info);
// 实际播放
player.play(songId);
}
}
30.5 避坑指南与性能优化
项目做完了,说说我踩过的坑:
常见问题汇总
- 音频焦点:Android Auto必须处理AudioFocus。我遇到过导航语音和音乐打架的情况,后来加了AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK才解决。
- 缓存策略:封面图片建议用LRU缓存,大小控制在50MB以内。车机内存本来就紧张,别把人家撑爆了。
- 网络切换:在线音乐在进入隧道时容易断。我的做法是预缓存当前歌单的前三首歌,保证切换时无缝。
- 蓝牙延迟:CarPlay走无线连接时,播放指令有200-500ms延迟。UI反馈要做loading动画,不然用户以为没点到。
我的小技巧:调试时用模拟器就够了,但最终测试一定要上车。我见过模拟器上跑得飞起,上车后卡成PPT的情况。原因?车机的CPU和内存跟手机差太远了。
30.6 项目结构一览
最后给个完整的项目目录结构,照着这个搭,基本不会乱:
MusicPlayer/
├── app/
│ ├── src/main/java/com/example/musicplayer/
│ │ ├── engine/ # 通用播放引擎
│ │ │ ├── PlaybackEngine.java
│ │ │ ├── MusicPlayer.java
│ │ │ └── PlaylistManager.java
│ │ ├── auto/ # Android Auto相关
│ │ │ ├── MusicPlaybackService.java
│ │ │ ├── AutoMediaSession.java
│ │ │ └── AutoAdapter.java
│ │ ├── carplay/ # CarPlay相关
│ │ │ ├── CarPlayDataSource.java
│ │ │ ├── CarPlayDelegate.java
│ │ │ └── CarPlayAdapter.java
│ │ ├── data/ # 数据层
│ │ │ ├── MusicLibrary.java
│ │ │ ├── SongRepository.java
│ │ │ └── ImageCacheManager.java
│ │ └── ui/ # 通用UI组件
│ │ ├── NowPlayingView.java
│ │ └── PlaylistView.java
│ └── src/main/res/
│ ├── drawable/ # 图标资源
│ └── values/ # 字符串、颜色
└── carplay/ # CarPlay独立target
└── CarPlayScene.swift
嗯,这个项目做完,你基本就掌握了车载音乐播放器的全部套路。Android Auto和CarPlay虽然平台不同,但核心思想是一样的:把手机上的音乐体验,安全、高效地投射到车机上。别被那些协议细节吓到,动手做一遍,你会发现其实没那么复杂。