第30章 综合实战项目:从零开发一款车载音乐播放器,覆盖Android Auto与CarPlay双平台

终于到了最后一章。说实话,写到这里我自己也有点感慨。前面29章我们聊了那么多底层原理、协议细节、UI规范,今天咱们就把这些东西串起来,真正动手做一个能跑在车机上的音乐播放器。

这个项目我去年在内部培训时带团队做过一次。当时有个同事问我:“老张,能不能一周搞出来?”我说:“如果你只做Demo,三天就够了。但要上生产环境,至少两周。”嗯,今天我们就按“能上路”的标准来。

30.1 项目整体架构设计

先看整体结构。我习惯把车载音乐播放器分成三层:

核心架构三层模型

  • 数据层:本地媒体库扫描 + 在线音乐API + 缓存管理
  • 业务层:播放控制、歌单管理、搜索、收藏
  • 展示层:Android Auto模板 + CarPlay模板 + 通用UI组件
车载音乐播放器架构图 展示层 Android Auto 模板 | CarPlay 模板 | 通用UI组件 业务层 播放控制 | 歌单管理 | 搜索 | 收藏 数据层 本地媒体库 | 在线音乐API | 缓存管理

为什么这么分?我在项目里吃过亏。一开始把播放逻辑和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虽然平台不同,但核心思想是一样的:把手机上的音乐体验,安全、高效地投射到车机上。别被那些协议细节吓到,动手做一遍,你会发现其实没那么复杂。

公众号:蓝海资料掘金营,微信deep3321