☰
Android低延迟播放实战:SmartMediaKit把RTSP/RTMP延迟压到毫秒级
2026/10/2 4:22:54 网站建设 项目流程

做Android端音视频这些年,我接到最多的需求就是“把延迟降下来”。不管你是对接海康、大华这类安防摄像头的RTSP流,还是做直播App里接RTMP源,“延迟”两个字永远绕不过去。我自己在几个项目里最终都是用 SmartMediaKit 把这条链路搭完的,它处理RTSP/RTMP拉流、解码、渲染都够稳,而且把缓冲控制权交到了开发手里,做低延迟直播播放模块非常合适。这篇文章就把整套技术实践完整拆开:先讲选型和协议差异,再讲集成与取流参数,然后给核心代码和调优逻辑,最后把我实测过的延迟数据和踩过的坑一并列出来。适合正在接摄像头、做直播播放、以及想把播放延迟从秒级压到毫秒级的Android开发同学。

我不太喜欢讲虚的,所以下面尽量多给能直接抄的参数和代码。不同项目细节会有差异,但思路是通用的。只要你能自己改Java层代码,这篇文章里的内容就可以直接搬到你自己的模块里。

1. 选型之前:这个模块到底解决什么问题

1.1 RTSP 和 RTMP,两套协议打天下

先说RTSP,Realtime Streaming Protocol,实时流传输协议。它在安防领域几乎是标配,海康、大华、宇视的摄像头和NVR都开放RTSP取流。控制面用RTSP的DESCRIBE、SETUP、PLAY这些命令,数据面实际走RTP,默认基于UDP,也可以指定走TCP。地址一般长这样:rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101,后面这个“101”代表主码流,“102”是子码流。RTSP本身不算复杂,但它的协商流程多,播放器需要先和摄像头来回几次交互才能开始拉媒体数据,协议上就比RTMP多了不少握手时间,首帧延迟也天然偏高。

再说RTMP,Adobe制定的直播协议,基于TCP,数据以FLV tag封装。现在很多直播平台、CDN、推流工具(比如OBS)仍然大量使用RTMP,因为它虽然古老但稳定、穿透性好。常见的播放地址长这样:rtmp://192.168.1.10:1935/live/stream。Android这边原生MediaPlayer对RTMP基本不买账,对RTSP也只是“能用”级别,延迟高、可控性差。我早期试过用VideoView去放海康的RTSP流,能出画面,但延迟动辄两三秒,而且切码流、断线重连这些需求完全没法做。ExoPlayer对RTSP的新版本支持也在补,但对RTMP同样不原生。

所以要在Android上做一个统一的低延迟播放模块,最省力的方案就是借助一个成熟的多媒体播放器内核。这就是SmartMediaKit的定位:它底层用FFmpeg做协议解析和解码,上层暴露接近MediaPlayer的Java接口,RTSP、RTMP都能拉,硬解软解都能配,关键是jitter buffer这类影响延迟的参数是可以调的。下面这张表是我整理过的几个候选方案差异,可以帮你判断为什么选它。

方案协议支持定制空间维护状态适合场景
SmartMediaKitRTSP/RTMP/HTTP-FLV等高,暴露缓冲和解码参数活跃低延迟播放模块
ijkplayerRTMP/HTTP/部分RTSP中,需编译期改配置维护放缓通用直播播放器
VLC for AndroidRTSP/RTMP等低,模块化程度差活跃但体积大万能播放器
自研FFmpeg方案取决于自己最高自己维护长期产品化

1.2 为什么不自己写 FFmpeg,而是选 SmartMediaKit

很多人一听到“低延迟”,第一反应就是自己用FFmpeg拉流,觉得可控性最强。这个想法没错,但实际上代价非常高:首先你需要在Linux环境交叉编译FFmpeg,生成带openssl、rtmp等依赖的so库,光编出来就是一门手艺;然后要写JNI封装,把AVFormatContext、AVCodecContext这些结构体暴露给Java层;还要自己处理Surface渲染(涉及ANativeWindow)、音频播放(AudioTrack那套线程模型)、音视频同步、丢帧策略。这一套做下来,没有两三个月的稳定期,根本不可能直接上线。

SmartMediaKit的价值在于,它把这些脏活累活都封装好了,但没把手脚捆住。它同样跑在FFmpeg之上,所以协议和解码能力不会有天花板;它在JNI层做了缓冲管理和音视频同步,同时把“缓冲上界”、“硬解开关”、“传输模式”这些关键参数暴露出来。你在Java层只需要创建播放器、设置Surface、填URL、调两个参数,就能拿到一条可以调优的播放链路。对大多数项目来说,这是“用最少的成本把延迟做下来”的最优解,而不是自己去和Native层的线程模型作斗争。

我选它的另一个原因,是接口风格接近MediaPlayer。项目里其他工程师上手快,不用每个人都去啃FFmpeg那套API,文档成本低。后面所有示例代码也都基于这套接口。需要说明的是,不同版本SmartMediaKit的API名可能有差异,但核心思想不变,照着思路迁移很轻松。这也是我推荐先把它跑通、再考虑二次开发的原因。

2. 环境集成与取流地址的正确姿势

2.1 Android 工程接入 SmartMediaKit

先说怎么把SmartMediaKit接进工程。两种方式:如果你只是想快速验证,优先用Gradle直接依赖仓库里的预编译包;如果你的项目需要改底层逻辑,或者要把特定解码器编进去,再考虑源码编译。

implementation 'com.github.smartmediakit:smart-player:1.x.x'

AndroidManifest里加权限:

<uses-permission android:name="android.permission.INTERNET" />

这里有一个很容易踩的坑:Android 9(API 28)之后默认禁止明文HTTP流量,而很多RTSP/RTMP测试地址并不是HTTPS,尤其局域网里的海康摄像头,直接访问会报“Cleartext HTTP traffic not permitted”。如果只在调试阶段用,可以给debug变体配置一个network security config,允许特定IP段明文传输;不要图省事把整个App的cleartextTrafficPermitted设成true就上生产,后面安全审计会找你麻烦。还有NDK和ABI的问题:一般真机只需要arm64-v8a,模拟器可能需要x86_64,多打包几个ABI会导致包体积变大,自己按需取舍。

源码编译方式就不给具体命令了,因为它依赖你和NDK环境的版本匹配。我见过最多的错误是NDK版本过高导致JNI库编译失败,或者CMake路径不对。建议直接跟随项目README的版本组合来配环境,不要用Android Studio自动下载的最新NDK头铁去试。如果你用Android Studio打开工程一直报so库找不到,多半是ABI过滤问题,检查一下app的build.gradle里abiFilters有没有写对。

2.2 RTSP 取流参数:安防摄像头是主要场景

我实际项目里,RTSP场景九成是安防摄像头。海康的取流地址分主码流和子码流,标准格式是rtsp://用户名:密码@IP:554/Streaming/Channels/101,101是主码流,102是子码流。大华则是rtsp://用户名:密码@IP:554/cam/realmonitor?channel=1&subtype=0,subtype=0是主码流、1是子码流。主码流分辨率高、码率高,画质好但解码压力大;子码流适合多路预览和低延迟场景。做低延迟播放模块时,我通常建议业务侧默认接子码流,画面只需要“看得到”而不是“看得精”时,子码流能让整条链路的延迟和卡顿都降一个档次。

接下来是RTSP的传输模式。SmartMediaKit一般会让你选UDP、TCP或AUTO。UDP延迟低,因为省了TCP的重传和拥塞控制,但丢包后会花屏、拉丝;TCP抗丢包能力强,延迟会略高。我的经验是:局域网内、网络质量好的场景,直接UDP,低延迟优先;跨公网、Wi-Fi信号不稳的场景,选TCP,否则一旦丢包,画面卡成PPT更让人崩溃。调试时还可以先开AUTO模式看它自动选型,再结合具体网络手动指定。这里贴一段我常用的Kotlin配置代码:

player.setDataSource(rtspUrl) player.setOption("rtsp_transport", "udp") player.setConnectTimeout(3000) player.setReadTimeout(5000) player.prepareAsync()

还有一个容易被忽略的点:超时配置。摄像头不在线、密码错误、网络断连时,如果播放器傻等,用户就会觉得“卡死”了。要给连接超时和读超时都设上比较短的值,比如连接3秒、读超时5秒,并在回调里及时抛错误、走重连逻辑。如果你对接的是H.265摄像头,还要留意硬解兼容性。很多中低端手机的MediaCodec不支持H.265,遇到这种流不提前做软解兜底,出来的就是绿屏或者黑屏。这个问题我后面在排坑部分专门讲。

2.3 RTMP 拉流参数与测试地址

RTMP场景主要是直播,地址形如rtmp://host:1935/live/streamKey。它基于TCP,所以传输层面的坑比RTSP少,延迟主要被缓冲和播放器内部的队列吃掉。SmartMediaKit内部对RTMP流的处理方式是解析成FLV tag再走解复用,这个细节决定了你要关注的关键参数:关键帧间隔、GOP大小,以及FLV tag缓冲长度。

调试RTMP最缺的是一个稳定可用的测试地址。我的常规做法是:本地用OBS推流,然后用SRS或nginx-rtmp搭一个简单的RTMP服务,最后用rtmp://127.0.0.1:1935/live/test来测。如果你是在Android模拟器里访问宿主机,记得用10.0.2.2代替127.0.0.1;真机调试则要填电脑的局域网IP。不想自己搭服务的话,也可以找公开的rtmp测试地址,但公开地址往往不稳定,我建议还是本地起一个,排查起来可控。

RTMP的低延迟配置思路和RTSP其实差不多,都是控制缓冲上界。不过RTMP因为是TCP传输,不会出现UDP那种大量丢包,把缓冲调低后延迟能比较稳定。我还习惯在RTMP场景额外关注“队列不要被慢渲染拖垮”:如果你的SurfaceView在后台被回收了,但播放器还在跑,解码队列会无限堆积,这是延迟飙升的元凶之一。这个留到第3节统一讲。

3. 低延迟播放模块的核心实现逻辑

3.1 播放器初始化与渲染绑定

核心流程其实不复杂:创建一个播放器实例,绑定Surface,设置数据源,然后异步prepare。下面是我手机端模块初始化的Kotlin骨架:

class LowLatencyPlayer(private val surface: Surface) { private val player = SmartPlayer() fun start(url: String) { player.setSurface(surface) player.setEnableHardDecode(true) player.setBufferSize(300) player.setOption("rtsp_transport", "udp") player.setDataSource(url) player.prepareAsync() player.setOnPreparedListener { it.start() } player.setOnErrorListener { _, code, msg -> handleError(code, msg) } } }

渲染层我建议优先用SurfaceView,不是TextureView。原因很直接:SurfaceView在系统里是独立窗口,合成路径短,GPU开销小,实测延迟比TextureView低。TextureView的优势是可以随便做动画、截图、变形,但它本质是一个普通View,要经过View渲染管线再合成,多了一道流程,延迟在部分机型上会高出二三十毫秒甚至更多。低延迟直播播放,能上SurfaceView就上SurfaceView。只有在需要做圆角、蒙层这种视觉效果时才退回去用TextureView。

硬解和软解的选择也直接影响延迟和兼容性。硬解走MediaCodec,延迟低、省电,但各家芯片对编码格式的支持参差不齐;软解走FFmpeg内置解码器,兼容性好,但CPU占用高。我的默认策略是“硬解优先,出错自动切软解”:先尝试硬解,一旦收到解码错误回调,就释放播放器、换软解重来。不要一个策略走到底,这两个是互斥取舍,没有绝对最优。

3.2 低延迟靠什么:缓存与时钟两手抓

播放延迟不是单点问题,而是整条链路累积的结果:网络抖动缓冲(jitter buffer)、解复用器的队列、解码器内部缓冲、渲染线程的排队、音视频同步的等待,每一环都会吃掉几十到几百毫秒。SmartMediaKit能做的,就是把其中几个可控点交给我们。最重要的一招是压缩缓冲上界。很多播放器默认会缓存3到5秒数据来抗网络抖动,对监控和直播这种实时性场景,这个数显然是灾难。我在模块里把缓冲上界压到300毫秒左右,让播放器宁可偶尔卡一下,也不让画面永远慢半拍。具体数值按你的网络环境微调:Wi-Fi稳定可以再低,弱网环境则适当放宽到500毫秒,否则频繁卡顿体验更差。

第二招是丢帧策略。直播播放到后期最常见的问题不是首帧慢,而是“延迟越拉越大”。原因很简单:网络有一点波动,播放器就攒一点缓冲,渲染线程消化不完,队列越排越长,画面和真实时间的差距就越拉越大。解决办法是在同步层做“追帧”:当检测到当前缓冲播放位置落后太多了,就主动跳过未渲染的视频帧,只解码关键帧和最新帧,快速追赶。这个逻辑用伪代码表达就是:

if (player.bufferedPosition - player.currentPosition > MAX_DELAY) { player.dropVideoFrames() }

音频方面,我的做法是维持AudioTrack低延迟输出。SmartMediaKit在播放带音频的RTSP/RTMP流时,默认会做A/V同步,通常以音频时钟为主,因为人耳对声音的卡顿比视觉更敏感。你要做的是把音频缓冲长度也压下来,让声音不要比画面快太多,也不要一直积压。假设音频buffer有300毫秒,视频buffer也有300毫秒,光这两个叠加就600毫秒了,再加上解码和渲染开销,整体延迟自然高。所以整个模块优化时,视频和音频的缓冲参数要一起调,别只盯画面。

3.3 生命周期、断线重连与后续扩展

这部分是很多做过播放器的人都踩过的坑:Activity还在,画面没了;或者退后台再回来,播放器傻了。我的标准做法很简单:onPause的时候把Surface从播放器摘掉并暂停播放,onResume的时候重新绑定并恢复播放。不要想着让播放器自己扛,Android的Surface生命周期跟Activity是绑定的,你不主动释放,大概率会遇到“黑屏但声音还在”的诡异状态。

override fun onPause() { super.onPause() player.pause() player.setSurface(null) } override fun onResume() { super.onResume() player.setSurface(surface) player.start() }

断线重连也是直播模块的刚需。我的做法是在onError回调里区分错误类型:连接超时、IO错误这类网络问题才走重连;解码错误这种硬件问题直接提示用户或切软解,不要盲目重连死循环。重连用指数退避,第一次等1秒,第二次2秒,再往后4秒,最多到15秒封顶,避免网络没恢复时疯狂打请求。具体可以这样组织:

var retryCount = 0 private fun handleError(code: Int, msg: String) { if (isNetworkError(code)) { val delay = min(15000L, 1000L shl retryCount) retryCount++ handler.postDelayed({ start(url) }, delay) } }

再聊几句扩展。我在这套模块上还做过预览多路切换、录像回放和倍速播放。思路是复用一个播放器实例,切换数据源时先把旧流释放干净,再setDataSource新地址;倍速播放则不要把Turbo模式拉到太高,2倍以上丢帧策略要跟着调整。说白了,播放模块的价值不全在“能播”,而在于“怎么播得又稳又低延迟”,这些扩展在后面加的时候才能体会到架构设计的重要性。

4. 实测与排坑:从 800ms 压到 200ms 的过程

4.1 延迟是怎么测出来的

在调优之前,先说怎么量化延迟。我用的方法很土但非常准:电脑上打开一个毫秒级秒表页面,手机用另一个摄像头或者录屏功能,对着电脑屏幕同时拍摄播放画面,然后对比两边时间戳的差值。也可以在推流端把带时间戳的画面推出去,播放端直接截图对比。这个方法的输出就是“画面比真实时间慢了多少毫秒”。别凭感觉判断,延迟优化最忌讳拍脑袋。

我实际测试的环境:同一局域网,海康子码流(RTSP,H.264,常见720P级别),手机和摄像头都连同一个路由器。初始参数下,播放器默认缓冲较大,实测延迟在780到850毫秒之间浮动。我把RTSP传输切成UDP、缓冲上界压到300毫秒、打开硬解、音频缓冲同步调低之后,同样的流稳定在200到280毫秒区间。RTMP场景用OBS推流到本地SRS,延迟大概在220到330毫秒,主要波动来自关键帧间隔和电脑编码负载。

下面这个表是我在不同配置下记录的参考数据,网络环境是稳定Wi-Fi:

配置RTSP延迟RTMP延迟卡顿表现
默认缓冲(3s级)780ms900ms+几乎不卡,但不可用
TCP+缓冲500ms380ms420ms正常,偶发小卡
UDP+缓冲300ms+硬解220ms-稳定,偶见花屏
TCP+缓冲200ms300ms320ms偶发卡顿,可接受

数据不一定代表所有机型,但趋势很明确:延迟和抗抖动是一对反比关系,你要根据业务容忍度找平衡点。

4.2 那些年踩过的坑

坑一:延迟越拉越大。这是直播播放最典型的问题。现象是刚开始延迟还行,直播放了十分钟,延迟从200ms慢慢涨到1秒多。根因就是解码和渲染队列的累积速度大于消费速度,又不做追帧。排查方法:在代码里周期打印currentPosition和bufferedPosition,如果两者差持续增大,基本实锤。解决办法就是我前面说的丢帧策略,在同步层加延时检测,超阈值就丢帧追上。

坑二:花屏、绿屏、画面撕裂。大体分两类。一类是H.265流在硬解不支持的机型上解码失败,表现是绿屏甚至直接没画面,对策是先查MediaCodecInfo是否支持HEVC,不支持就强制软解;另一类是UDP传输丢包导致RTP数据丢失,画面出现马赛克或局部绿块,对策是切TCP,或降低码流规格。我遇到过最坑的一种情况,是SurfaceView前置到非主线程、或者Surface被释放后播放器还在写,导致渲染崩溃,这个要用好onPause/onResume的摘Surface逻辑。

坑三:首帧太慢。RTSP因为要DESCRIBE、SETUP、PLAY来回几轮协商,加上必须等到IDR关键帧才能出图,没优化时首帧可能要两三秒。优化方向:缩短连接超时、启用快速启动模式、在UI层先显示一个loading占位而不是无助地等待。另外把取流地址换成子码流也能显著加快首帧,因为子码流的GOP通常更小,关键帧更快到来。

坑四:声音和画面各说各话。音频比视频快或者慢,核心是A/V同步策略没调好。直播流如果音频缓冲比视频缓冲大很多,声音会明显滞后,反之声音超前。我在模块里的做法是统一以音频时钟为主,把音频缓冲调小、视频缓冲追音频,同时保留一个“偏差超过300ms就快进音频”的自愈逻辑。这个方法在多个项目里验证过,比所谓“自动同步”稳得多。

坑五:国产ROM和模拟器兼容性问题。华为、小米、部分定制系统对SurfaceView的生命周期管理有差异,偶尔会出现播放器启动后黑屏,三星反而正常。我比较有效的应急手段是把渲染层切换到TextureView,或者通过postDelayed在Surface真正available之后再绑定。模拟器上则优先检查ABI匹配,用x86的镜像却只打了arm64的so库,必然找不着解码器。

5. 写在最后的经验小结

踩了这么多坑之后,我个人的体会是:低延迟播放模块的成败,不在某一个参数上,而在于你是否理解整条链路在哪里“囤货”。网络层囤包、缓冲层囤数据、解码层囤帧、渲染层囤画面,任何一环都会把延迟撑大。所以我的习惯是每次优化都先量化当前延迟贡献最大的环节,再动手调,而不是凭感觉把所有缓冲都压到最低,那样延迟是下来了,卡顿也跟着来了,用户照样骂。

另外再说一个很多新手会忽略的小细节:调试阶段一定要把播放器状态回调打得足够详细,尤其是onInfo里关于缓冲开始、缓冲结束、首帧渲染、丢帧数量这些消息。我见过太多人上线之后才加日志,结果问题复现不了。这类经验在文档里没人会教你,只能靠实战攒。

最后补充一个我常用的思路:不要把RTSP和RTMP两套场景的配置写死在一套参数里。我的模块里提供两套Profile,一套UDP优先、低缓冲,专门给安防内网;一套TCP稳、缓冲略高、带完善重连,专门给公网直播。业务方按需取用,比统一调参要省心得多。如果你也在折腾SmartMediaKit的低延迟播放,希望这篇实践记录能让你少走几段弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询