30、项目实战:综合项目——打造一个功能完整的音视频播放器App

终于走到这一步了。

前面29章,我们从MediaPlayer讲起,一路啃过SurfaceView、TextureView,踩过音频焦点、视频渲染的坑,还自己动手封装过播放器内核。说实话,能坚持到这里的同学,已经具备了独立开发播放器的能力。

但能力归能力,真正要把一个播放器做成一个完整的App,中间还有不少「最后一公里」的活儿。这一章,我们就来把这些活儿干完。

项目目标与整体架构

我们要做的这个播放器App,不是那种只能播个视频的Demo。它要具备以下能力:

  • 本地+网络播放:支持本地文件、HTTP/HTTPS流媒体
  • 基础控制:播放/暂停、进度拖拽、音量调节、倍速播放
  • 手势交互:左右滑动快进快退,上下滑动调节亮度和音量
  • 悬浮窗播放:退出App后还能小窗播放
  • 播放列表管理:支持添加、删除、排序
  • 音视频分离:可以单独提取音频轨道

我个人习惯把播放器App分成三层:

UI层:Activity、Fragment、自定义View,负责用户交互。

业务层:播放器管理器、播放列表管理器、手势控制器,负责逻辑编排。

内核层:MediaPlayer + 各种Renderer,负责音视频解码和渲染。

下面这张图,就是整个项目的核心结构。我建议你把它存下来,写代码时对照着看。

播放器App三层架构 UI层 PlayerActivity | 手势Overlay | 悬浮窗View | 列表Fragment 业务层 PlayerManager | PlaylistManager | GestureController | AudioFocusHelper 内核层 MediaPlayer | AudioTrack | VideoRenderer | MediaExtractor 数据流:用户操作 → 业务逻辑 → 内核解码 → 渲染输出

PlayerManager:播放器的「大脑」

PlayerManager是我在这个项目里最花心思的类。它不直接操作MediaPlayer,而是通过一个接口层来隔离。

为什么要这么做?

因为我在项目中遇到过好几次替换播放内核的需求——从MediaPlayer换到ExoPlayer,或者换到IjkPlayer。如果代码里到处都是MediaPlayer的API调用,那替换起来简直要命。

所以,我定义了一个IPlayer接口:

public interface IPlayer {
    void play(String url);
    void pause();
    void resume();
    void seekTo(long position);
    void setSpeed(float speed);
    void setVolume(float left, float right);
    long getCurrentPosition();
    long getDuration();
    boolean isPlaying();
    void release();
}

然后让MediaPlayerImpl去实现它。这样,哪天想换内核,只需要再写一个ExoPlayerImpl,然后把创建的地方改一下就行。

小技巧:PlayerManager内部维护一个状态机。播放、暂停、释放这些操作,都要先检查当前状态是否合法。比如,在已释放状态下调用play(),应该直接忽略,而不是抛异常。

手势交互:让用户「摸」着控制

手势控制是播放器App的「高级感」来源。我见过不少App,手势做得生硬,滑动起来卡顿,用户体验很差。

手势的核心逻辑其实不复杂:

  • 左右滑动:根据滑动距离计算快进/快退的时间。我一般按屏幕宽度映射到视频总时长,比如滑过半个屏幕就是跳转50%的进度。
  • 左侧上下滑动:调节亮度。用WindowManager.LayoutParams.screenBrightness来实现。
  • 右侧上下滑动:调节音量。通过AudioManagerSTREAM_MUSIC流来调整。

嗯,这里要注意一个细节:手势的触摸区域要划分清楚。我习惯把屏幕左半侧划给亮度,右半侧划给音量。中间区域留给进度拖拽。

// 手势区域划分
if (event.getX() < screenWidth / 2) {
    // 亮度调节
    adjustBrightness(deltaY);
} else {
    // 音量调节
    adjustVolume(deltaY);
}

避坑指南:我曾经在实现手势时,忘记处理多点触控。用户一边滑动进度,一边用另一只手调节音量,结果两个手势互相干扰。解决方案是:在onTouchEvent中记录每个手指的pointerId,不同区域的手指互不干扰。

悬浮窗播放:退出App也不中断

悬浮窗播放,说白了就是把视频画面放到一个系统级的Window上。用户回到桌面,视频还在小窗里继续播。

实现悬浮窗需要申请SYSTEM_ALERT_WINDOW权限。Android 6.0以上需要动态申请,而且不同厂商的ROM对这个权限的处理方式还不一样。我建议你在AndroidManifest.xml里声明权限,然后在代码里用Settings.canDrawOverlays()检查。

悬浮窗的布局很简单:一个TextureView用来渲染视频,一个关闭按钮,一个拖动条用来移动位置。

// 创建悬浮窗
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ?
        WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY :
        WindowManager.LayoutParams.TYPE_PHONE;

WindowManager.LayoutParams params = new WindowManager.LayoutParams(
        dpToPx(320), dpToPx(240),
        type,
        WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
        PixelFormat.TRANSLUCENT
);

// 把TextureView添加到悬浮窗
wm.addView(textureView, params);

这里有个坑:悬浮窗的TextureView不能直接复用Activity里的那个。你需要重新创建一个TextureView,然后把MediaPlayer的Surface重新绑定到新的TextureView上。

核心思路:MediaPlayer的Surface是可以动态切换的。你只需要在创建悬浮窗时,调用mediaPlayer.setSurface(newSurface),视频画面就会自动切换到悬浮窗上。

播放列表管理:不只是存个List

播放列表看起来简单,不就是存一个List<MediaItem>吗?

但实际用起来,你会发现需要处理的事情很多:

  • 当前播放的是哪一首?用索引标记。
  • 播放模式:单曲循环、列表循环、随机播放。
  • 列表持久化:退出App后,下次打开列表还在。
  • 列表排序:支持拖拽排序。

我建议用Room数据库来存储播放列表。每次用户添加或删除歌曲,都同步更新数据库。这样即使App被杀死,列表也不会丢。

@Entity
public class MediaItem {
    @PrimaryKey(autoGenerate = true)
    public int id;
    public String title;
    public String url;
    public long duration;
    public int sortOrder;  // 排序字段
}

播放模式的状态机也要设计好。我习惯用枚举:

public enum PlayMode {
    LIST_LOOP,    // 列表循环
    SINGLE_LOOP,  // 单曲循环
    SHUFFLE       // 随机播放
}

每次播放完成时,根据当前模式决定下一首播什么。随机模式就用Random.nextInt()生成一个不重复的索引。

音视频分离:提取音频轨道

这个功能其实是个「彩蛋」。很多播放器App不提供这个功能,但我觉得加上去会让App更有「专业感」。

音视频分离的核心是MediaExtractorMediaMuxer。流程如下:

  1. MediaExtractor打开视频文件。
  2. 找到音频轨道,把数据读取出来。
  3. MediaMuxer把音频数据写入一个新的文件(比如.m4a)。
MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource(videoPath);

int audioTrackIndex = -1;
for (int i = 0; i < extractor.getTrackCount(); i++) {
    MediaFormat format = extractor.getTrackFormat(i);
    String mime = format.getString(MediaFormat.KEY_MIME);
    if (mime.startsWith("audio/")) {
        audioTrackIndex = i;
        break;
    }
}

extractor.selectTrack(audioTrackIndex);
MediaFormat audioFormat = extractor.getTrackFormat(audioTrackIndex);

MediaMuxer muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
int audioMuxerTrack = muxer.addTrack(audioFormat);
muxer.start();

// 读取数据并写入
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
while (true) {
    int sampleSize = extractor.readSampleData(buffer, 0);
    if (sampleSize < 0) break;
    info.offset = 0;
    info.size = sampleSize;
    info.presentationTimeUs = extractor.getSampleTime();
    info.flags = extractor.getSampleFlags();
    muxer.writeSampleData(audioMuxerTrack, buffer, info);
    extractor.advance();
}

muxer.stop();
muxer.release();
extractor.release();

提示:提取音频时,最好在后台线程执行,避免阻塞UI。提取完成后,通过广播或LiveData通知UI更新。

性能优化与内存管理

播放器App是内存消耗大户。视频帧、音频缓冲区、解码器实例,哪个不小心都会导致OOM。

我总结了几条经验:

  • 及时释放MediaPlayer:退出播放页面时,一定要调用release()。不要依赖onDestroy,因为系统不保证它一定会被调用。
  • 控制TextureView的生命周期:TextureView在后台时,Surface会被销毁。你需要监听SurfaceTextureListener,在Surface可用时重新绑定。
  • 限制悬浮窗数量:悬浮窗只能有一个。如果用户点击了多次悬浮窗按钮,要检查是否已经存在,避免重复创建。
  • 使用弱引用:PlayerManager中持有Activity的引用时,用WeakReference,防止内存泄漏。

我曾经踩过的坑:在低端手机上,频繁创建和销毁MediaPlayer会导致卡顿。后来我改用对象池,复用MediaPlayer实例,效果好了很多。

总结

这一章的内容,说白了就是把前面学到的所有知识点串起来,做成一个真正能用的App。你可能会觉得代码量很大,但别怕,一步一步来。

先搭好三层架构,然后逐个模块实现。每实现一个功能,就测试一下。不要等到所有代码写完了再统一测试,那样你会被bug淹死的。

好了,动手吧。把代码写出来,跑起来,然后享受一下自己亲手打造的播放器App。


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