11、直播流处理:HLS与DASH协议在TV应用中的实现

做TV开发,绕不开直播流。

你想想看,用户买智能电视回家,最常干的事是什么?看直播。不管是新闻联播、体育赛事,还是春晚,直播才是TV端真正的核心场景。我这些年做过的TV项目,十个里有八个都跟直播流打交道。

今天我们就来聊聊HLS和DASH这两个协议。说白了,它们就是视频流的分片打包方式。HLS是苹果搞的,DASH是国际标准。在TV端,两个都得支持,因为不同的内容提供商用的协议不一样。

11.1 HLS协议:苹果的"老将"

HLS,全称HTTP Live Streaming。这玩意儿虽然年头不短了,但在TV端依然很能打。

它的核心思路很简单:把一整段视频切成一个个小片段,每个片段大概2-10秒。播放器下载一个,播一个,再下载下一个。这样用户就不用等整个视频下载完,点开就能看。

HLS的核心文件结构:

  • m3u8文件:相当于目录索引,告诉播放器有哪些码率的视频流
  • ts文件:视频切片,实际播放的内容
  • key文件:加密密钥(如果用了加密)

我在项目中遇到过一个问题:有些低端电视解码ts格式特别慢。后来发现是硬件解码器对ts的兼容性不好。嗯,这里要注意,如果你的目标设备是老旧电视,可以考虑用fmp4格式替代ts。

11.2 DASH协议:国际标准

DASH,Dynamic Adaptive Streaming over HTTP。它比HLS晚出生几年,但设计上更灵活。

DASH用的是mpd文件做索引,视频切片通常是fmp4格式。它的自适应码率算法更智能,切换更平滑。

个人经验:我建议在TV端优先使用DASH。原因很简单——DASH的fmp4格式在Android TV上解码效率更高,而且支持更多的编码参数调整。当然,如果内容源只提供HLS,那也没得选。

11.3 自适应码率:TV端的关键

自适应码率,说白了就是根据网络状况自动切换视频清晰度。你想想看,用户家的WiFi信号时好时坏,总不能看着看着就卡住吧?

Android TV的ExoPlayer内置了自适应码率算法。但说实话,默认算法在TV端表现一般。我习惯自己调整参数。

// 自定义自适应码率策略
TrackSelection.Factory adaptiveTrackSelectionFactory =
    new AdaptiveTrackSelection.Factory.Builder()
        .setMinDurationForQualityIncreaseMs(10000)  // 至少10秒才升码率
        .setMaxDurationForQualityDecreaseMs(5000)    // 5秒内降码率
        .setMinDurationToRetainAfterDiscardMs(3000)  // 降码率后保留3秒
        .setBandwidthFraction(0.7f)                  // 使用70%的带宽
        .setBufferedFractionToLiveEdgeForQualityIncrease(0.5f)
        .build();

为什么要这么调?

TV端跟手机不一样。手机你盯着小屏幕,码率切换不太明显。但电视屏幕大,码率一降,满屏的马赛克,用户立马就发现了。所以我让升码率更保守,降码率更激进。说白了,宁可降得快一点,也别让用户看马赛克。

避坑指南:我曾经在某个项目中,自适应码率算法没调好,导致用户在观看体育赛事时频繁切换清晰度。足球比赛画面本来就快,码率一切换,画面就花一下。后来我把升码率的等待时间从5秒改到15秒,问题就解决了。

11.4 ExoPlayer集成HLS和DASH

ExoPlayer是Google官方的媒体播放器,对HLS和DASH支持得最好。我建议TV开发都用它,别用MediaPlayer。

// 创建播放器
ExoPlayer player = new ExoPlayer.Builder(context)
    .setMediaSourceFactory(
        new DefaultMediaSourceFactory(context)
            .setLiveTargetOffsetMs(5000)  // 直播延迟5秒
            .setLiveMinOffsetMs(3000)
            .setLiveMaxOffsetMs(10000))
    .build();

// 加载HLS流
MediaItem hlsItem = MediaItem.fromUri(
    "https://example.com/live/stream.m3u8");
player.setMediaItem(hlsItem);
player.prepare();

// 加载DASH流
MediaItem dashItem = MediaItem.fromUri(
    "https://example.com/live/stream.mpd");
player.setMediaItem(dashItem);
player.prepare();

代码看着简单吧?但实际坑不少。

11.5 直播延迟优化

直播延迟是个老大难问题。HLS默认延迟在20-30秒,DASH好一点,但也有10-15秒。对于体育直播来说,这个延迟太要命了。你想想,隔壁邻居都喊"进球了",你这边还在等,多尴尬。

降低延迟的方法:

  • 缩短切片时长:把ts或fmp4的切片时长从6秒降到2秒
  • 降低缓冲区:播放器缓冲区从30秒降到5秒
  • 使用低延迟模式:HLS的LL-HLS,DASH的UTCTiming
// 低延迟直播配置
MediaItem.Builder builder = new MediaItem.Builder()
    .setUri(uri)
    .setLiveConfiguration(
        new MediaItem.LiveConfiguration.Builder()
            .setTargetOffsetMs(3000)    // 目标延迟3秒
            .setMinOffsetMs(2000)       // 最小延迟2秒
            .setMaxOffsetMs(5000)       // 最大延迟5秒
            .build());

player.setMediaItem(builder.build());

我的建议:低延迟模式对网络要求很高。如果你的用户群体网络条件一般,别把延迟压得太低。我曾经试过把延迟压到2秒,结果用户频繁卡顿,体验更差。后来折中到5秒,效果最好。

11.6 直播流的生命周期管理

TV应用跟手机不一样,用户可能一开就是几个小时。直播流的生命周期管理就特别重要。

public class LivePlayerManager {
    private ExoPlayer player;
    private Handler mainHandler;
    private boolean isLiveStream = false;
    
    // 监听直播流状态
    private final Player.Listener listener = new Player.Listener() {
        @Override
        public void onPlaybackStateChanged(int playbackState) {
            switch (playbackState) {
                case Player.STATE_BUFFERING:
                    // 缓冲中,显示加载动画
                    showBufferingIndicator();
                    break;
                case Player.STATE_READY:
                    // 准备就绪,隐藏加载动画
                    hideBufferingIndicator();
                    break;
                case Player.STATE_ENDED:
                    // 直播流不应该结束,检查是否断流
                    if (isLiveStream) {
                        reconnect();
                    }
                    break;
            }
        }
        
        @Override
        public void onPlayerError(PlaybackException error) {
            // 直播流出错,自动重连
            if (isLiveStream) {
                scheduleReconnect();
            }
        }
    };
    
    private void scheduleReconnect() {
        // 指数退避重连
        int delay = Math.min(++retryCount * 2000, 30000);
        mainHandler.postDelayed(this::reconnect, delay);
    }
}

这里有个细节:直播流如果断了,不能像点播那样直接报错。用户正看着球赛呢,突然黑屏,那不得骂娘?所以一定要做自动重连,而且重连间隔要逐渐增大,别把服务器打崩了。

11.7 多码率切换的UI体验

TV端的多码率切换,UI交互跟手机完全不同。手机可以点一下设置按钮,弹个菜单选清晰度。电视遥控器操作,得用方向键上下选。

我习惯的做法:

  • 在播放器底部显示一个清晰度选择条
  • 用左右方向键切换
  • 切换时显示当前码率信息
  • 切换过程中保持画面不黑屏
// 获取可用码率
TrackGroupArray trackGroups = player.getCurrentTrackGroups();
for (int i = 0; i < trackGroups.length; i++) {
    TrackGroup group = trackGroups.get(i);
    if (group.type == C.TRACK_TYPE_VIDEO) {
        for (int j = 0; j < group.length; j++) {
            Format format = group.getFormat(j);
            int bitrate = format.bitrate;
            int width = format.width;
            int height = format.height;
            // 显示给用户:1080p、720p、480p等
        }
    }
}

// 手动切换码率
player.setTrackSelectionParameters(
    player.getTrackSelectionParameters()
        .buildUpon()
        .setMaxVideoSize(1920, 1080)
        .setMaxVideoBitrate(5000000)  // 5Mbps
        .build());

注意:手动切换码率时,ExoPlayer会重新请求新的分片。如果网络慢,可能会出现短暂的黑屏。我建议在切换时先预加载下一个码率的分片,等准备好了再切换。这样用户就感觉不到黑屏了。

11.8 直播流的DRM加密

TV端的直播流,很多都带了DRM加密。尤其是付费频道,不加密谁还交钱?

Android TV支持Widevine DRM,这是最主流的方案。集成起来也不复杂。

// 配置DRM
MediaItem.Builder builder = new MediaItem.Builder()
    .setUri(uri)
    .setDrmConfiguration(
        new MediaItem.DrmConfiguration.Builder(
            C.WIDEVINE_UUID)
            .setLicenseUri("https://license.example.com/wv")
            .setLicenseRequestHeaders(
                Collections.singletonMap("Authorization", "Bearer token123"))
            .build());

player.setMediaItem(builder.build());

DRM这块有个坑:不同电视厂商对Widevine的支持级别不一样。索尼、三星的高端电视支持L1级别,可以播4K内容。但有些国产电视只支持L3,只能播720p。你想想看,用户买了4K电视,结果只能看720p,那不得退货?

所以,我建议在应用启动时先检测设备的DRM能力,然后根据能力选择对应的码率。

11.9 直播流的性能监控

直播流跑起来之后,不能就不管了。你得监控它的运行状态,出了问题及时处理。

// 获取播放统计信息
AnalyticsListener analyticsListener = new AnalyticsListener() {
    @Override
    public void onBandwidthEstimate(
        EventTime eventTime, int totalLoadTimeMs, 
        long totalBytesLoaded, long bitrateEstimate) {
        // 当前带宽估计
        Log.d("LiveStream", "带宽: " + bitrateEstimate + " bps");
    }
    
    @Override
    public void onDroppedVideoFrames(
        EventTime eventTime, int droppedFrames, long elapsedMs) {
        // 丢帧统计
        if (droppedFrames > 10) {
            // 丢帧太多,考虑降码率
            requestLowerBitrate();
        }
    }
    
    @Override
    public void onVideoSizeChanged(
        EventTime eventTime, int width, int height, 
        int unappliedRotationDegrees, float pixelWidthHeightRatio) {
        // 视频分辨率变化
        Log.d("LiveStream", "分辨率: " + width + "x" + height);
    }
};

这些监控数据,我一般会做成一个调试面板,在开发阶段打开。上线后可以关掉,但保留关键日志,方便排查问题。

11.10 知识体系总览

说了这么多,我们来梳理一下直播流处理的核心知识体系:

直播流处理知识体系 HLS协议 m3u8 + ts/fmp4 切片 DASH协议 mpd + fmp4 切片 ExoPlayer 播放引擎 自适应码率 · 低延迟 · DRM解密 自适应码率 带宽估计 · 码率切换 延迟优化 切片时长 · 缓冲区 生命周期 重连 · 状态管理 性能监控 · DRM检测 · 码率UI 丢帧统计 · 带宽监控 · 设备兼容

这张图把直播流处理的核心模块都串起来了。从上到下,协议层、播放器层、功能层、监控层,每一层都有各自的职责。你在实际开发中,可以对照这张图来检查自己的实现是否完整。


直播流处理这块,说难不难,说简单也不简单。关键是要理解协议的本质,然后根据TV端的特点做适配。我做了这么多年,最大的体会就是:别照搬手机端的方案,TV端有TV端的玩法。

好了,这一章的内容就到这里。如果你在实际开发中遇到什么问题,欢迎来交流。

本章要点回顾:

  • HLS和DASH是两种主流的直播流协议,TV端都要支持
  • 自适应码率算法需要针对TV端大屏幕特点做调优
  • ExoPlayer是Android TV上最好的直播流播放器
  • 直播延迟优化要在流畅度和实时性之间找平衡
  • DRM加密和性能监控是上线前必须做好的工作

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