简介一份可直接运行的 Android RTSP 播放示例工程面向需要在直播、网络摄像头或视频监控等场景中接入实时流媒体的 Android 开发者既适合入门学习也适合快速原型验证其核心通过 MediaPlayer 加载 rtsp:// 地址完整演示了异步准备、自动播放与错误处理等调用链路修改地址即可切换不同视频源。压缩包采用 7z 格式共 55 个文件、4.73MB包括 5 个 Java 源文件、8 个 XML 界面配置、15 张 PNG 图片以及编译生成的 class、dex、APK、so 和 jar既有可读源码也有可直接安装的 APK目录覆盖工程配置、资源与依赖库。目前已有 727 人学习下载适合作为 RTSP 播放器开发的起步模板。通过源码可掌握 setDataSource、prepareAsync、OnPreparedListener、错误监听与 release 等 MediaPlayer 用法的关键细节同时观察 SurfaceView/TextureView 等视频展示控件如何配合播放器工作在此基础上也能继续引入 ExoPlayer 等第三方库应对更复杂的流媒体需求。1. 明明只是拉一个RTSP流为什么Android Demo却处处踩坑在电脑端播放器里能正常播放的RTSP地址粘进Android工程后常常开场就是黑屏这个现象几乎每个对接过监控或车载视频的开发者都见过。RTSP播放和点播一个MP4完全不同它不是把文件链接丢给播放器就行而是要先把会话协商完成再持续接收RTP数据任何一个环节出错屏幕都不会给你明确提示只会让你对着黑屏发呆。这篇文章会把Android rtsp播放DEMO做一次完整拆解先讲清楚RTSP在Android平台的支持边界再对比系统MediaPlayer、ExoPlayer与FFmpeg系内核应该怎么选型随后给出一套可以直接落地的Demo代码与参数配置最后集中整理黑屏、闪退、延迟增大、切后台失效这几类高频问题。定位很明确就是给对接IPC摄像头、NVR、车载录像设备做客户端或者要在App里快速验证流服务是否可用的开发者看。新手照着步骤能跑通第一个画面熟手也能在这里核对传输模式、缓冲参数和释放时序这些容易忽略的细节。2. MediaPlayer、ExoPlayer还是FFmpeg内核选对RTSP播放路线才不会白干2.1 先把RTSP的会话过程说清楚再谈播放器RTSP并不仅仅是一个流地址。播放器面对RTSP时至少要经历四步先发DESCRIBE向服务端要SDP描述拿到视频编码、音频编码、分辨率、带宽这些信息再发SETUP协商RTP端口和传输方式然后发PLAY开始收流收流过程中还有RTCP报文帮忙做同步和丢包反馈。也就是说Android端真正在做的事情是完成一次会话并持续接收音视频包而不是简单打开一个网络文件。这个会话过程带来了两个隐藏问题。第一RTP传输分UDP和TCP两种模式大多数IPC都要求客户端在SETUP里明确告知用哪种选错就会出现协商成功但不出画面的情况。第二SDP里声明的编码格式最终要落到Android设备的解码器上设备缺哪个解码器播放器再能干也白搭。所以选播放器的本质不是选哪个API好写而是选哪个方案能把协商、收流、解码整条链路控制得最完整。这也是为什么很多开发者用系统MediaPlayer做RTSP播放最后都把它归作玄学——链路中的每一步都可能因为ROM差异和IPC端的特殊实现而失败。2.2 三条路线的能力边界配一张表就够了我把选型时常用的对比放出来。系统MediaPlayer是Android原生自带不用引任何包但是否能播全看设备脸色ExoPlayer目前更常以Media3形式出现是业务开发用得最多的播放器框架FFmpeg系内核泛指用FFmpeg做解封装和解码的自研或开源播放器兼容能力最强但集成成本也最高。对比项系统MediaPlayerExoPlayer/Media3FFmpeg系内核解码方式MediaCodec硬解MediaCodec硬解为主软硬解可切软解兜底RTSP控制弱只暴露开放接口支持UDP/TCP、可配置超时完整可自定义协商细节H.264/AAC基本可播看ROM良好良好H.265多数设备不支持看设备硬解支持可扩展软解软解兜底兼容最好MJPEG/G711等常直接失败部分支持需逐项验证FFmpeg解封装后基本都能处理集成成本最低中等最高适用场景快速验证源是否连通商用App的主流选择车机、混合编码、IPC兼容要求高的场景如果只是做一个临时页面验证某个源还能不能连通MediaPlayer确实最快。但如果这个Demo后面要长成正式功能我一般直接选ExoPlayer。它的RTSP模块把DESCRIBE、SETUP、PLAY、TEARDOWN这些状态都收在内部开发者只需要关心URL、Surface和生命周期遇到硬解不了的编码还能用FFmpeg扩展去兜底是一个不容易把自己绕进去的方案。FFmpeg系内核在这个阶段优先级最低除非你已经明确知道自己面对的是一堆H.265 IPC、私有音频或特殊封装否则集成成本在Demo阶段不值得背。2.3 用接口把内核隔离开给后面留点后悔药选型定下来之后有一个动作值得做不要在各种页面里直接new播放器对象而是先定义一个很小的接口把系统MediaPlayer和ExoPlayer分别包一层。这步只多花十几分钟但后面一旦发现某个内核搞不定所有页面不用跟着改。// 对外暴露的最小播放接口把所有内核差异挡在后面 interface RtsPlayer { fun init(url: String, surface: Surface) fun play() fun pause() fun release() fun setListener(listener: Listener) interface Listener { fun onPrepared() fun onError(code: Int, message: String) fun onStateChanged(state: Int) } }这个接口的职责很清楚init里负责完成一次完整的RTSP会话play与pause控制播放release必须做彻底释放。调用方只依赖这个接口之后把系统MediaPlayer实现换成ExoPlayer实现页面代码一行都不用动。很多项目最后栽在内核切换上就是因为播放器对象散落在各页面里改一个内核等于重写半个模块。class ExoRtsPlayer(private val context: Context) : RtsPlayer { private var player: ExoPlayer? null override fun init(url: String, surface: Surface) { val exo ExoPlayer.Builder(context).build() exo.setVideoSurface(surface) // 必须在prepare前绑定否则黑屏 exo.setMediaItem(MediaItem.fromUri(url)) exo.prepare() // 异步准备状态通过Listener回调 player exo } override fun play() { player?.play() } override fun release() { player?.release() player null } }这里有两个参数层面的关键点。setVideoSurface必须在prepare之前或至少不晚于play之前调用MediaCodec在configure阶段就需要一个有效Surface错过这个时机画面就是黑的。prepare是异步操作真正的准备结果通过onPrepared回调通知不要在调用prepare后立刻去判断播放状态。release要放在onPause和onDestroy里ExoPlayer内部会释放MediaCodec、解复用器以及网络相关的资源。即便只做Demo也建议让主页面只依赖接口而不是具体实现这个决定会在接入更多播放源时给你省下大量重复劳动。3. 把RTSP画面真正跑出来最小播放Demo的四个必做步骤3.1 先写一个RTSP地址校验器把最蠢的错误拦在门外我见过大量Demo跑不起来最后发现是连接串抄错了主码流地址少了一段路径或者账号密码拼到了错误位置。RTSP地址格式本身很规整先用一段很薄的代码解析并打印出来能省掉大量闷头排查的时间。// 解析RTSP地址并打印关键字段避免手滑写错路径 private fun parseRtspUrl(raw: String): Uri? { val uri Uri.parse(raw) val scheme uri.scheme?.lowercase() if (scheme ! rtsp scheme ! rtsps) { Log.w(TAG, scheme不合法: $scheme) return null } if (uri.host.isNullOrEmpty()) { Log.w(TAG, host为空) return null } val port if (uri.port 0) uri.port else 554 // 默认554端口 val userInfo uri.userInfo ?: Log.i(TAG, 解析结果: $userInfo${uri.host}:$port${uri.path}查${uri.query}) return uri }解析说明rtsps表示RTSP over TLS部分IPC会用到不写端口时默认是554userInfo用于带账号密码的地址query参数在部分流服务里用来选择子码流或加密方式打印出来方便人工核对。这段代码本身不改变播放行为只是把输入字段打出来。但当地址粘贴错误、端口被映射过、主码流与子码流混淆时第一眼就能看出问题。子码流在这里值得单独说一句。很多IPC默认只开主码流主码流分辨率高、带宽占用大调试时稍微不稳就黑屏。常见做法是先在地址里改用子码流路径比如某段地址里的“ch1/sub/av_stream”确认链路没问题后再切回主码流。路径结构因设备而异但思路是一样的先拿低码率验证端到端再上高码率。提示调试RTSP源前先切到子码流确认画面再切主码流能少走很多弯路。3.2 Surface的初始化顺序是黑屏问题的高发区在Android里播放RTSP画面最终要落在一个Surface上。最常见的是用TextureView或者SurfaceView承载两者的坑完全一致在onCreate里直接拿Surface粘到播放器上但此时View可能还没完成尺寸测量和底层绑定得到的Surface无法供MediaCodec正常输出画面自然就是黑的。textureView.surfaceTextureListener object : TextureView.SurfaceTextureListener { override fun onSurfaceTextureAvailable( surfaceTexture: SurfaceTexture, width: Int, height: Int ) { val surface Surface(surfaceTexture) player.init(rtspUrl, surface) // Surface真正可用后再初始化播放器 } override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { player.release() // 释放播放器避免MediaCodec继续持有旧Surface return true } }这段代码说明了两件事播放器的init必须在Surface真正可用的回调里触发onSurfaceTextureDestroyed时要把播放器释放掉否则底层MediaCodec还持有已经销毁的Surface回到前台时需要整套重新初始化。一个细节是onSurfaceTextureDestroyed返回true表示由自己接管SurfaceTexture的释放避免系统回收后播放器内部再次引用已失效对象。参数层面值得留意的是回调里的width和height。如果Demo想做的更完整这里就是设置视频等比缩放、裁剪或全屏拉伸的起点。很多播放器直到画面比例不对才发现没在这里处理而处理位置其实就只有这一个回调函数。3.3 生命周期托管切后台、锁屏、断网后的重建逻辑RTSP播放器和本地视频播放器最大的不同是它依赖一个持续存在的网络会话。Activity一旦onPauseSurface会被系统销毁网络也可能被系统挂起所以不能像本地播放那样暂停在当前位置等回来继续。我做Demo时采用销毁式重建的简单策略保证行为始终一致。override fun onResume() { super.onResume() if (!hasInited) { hasInited true player.init(rtspUrl, surface) } } override fun onPause() { super.onPause() player.release() hasInited false }这里的逻辑是onPause里释放播放器并复位标志位onResume里重新init。看起来粗暴但对RTSP这种实时流来说释放重建的成本远低于状态恢复的复杂度也最不容易出错。每次重建都会重新走一遍DESCRIBE、SETUP、PLAY服务端设备通常也能接受这种重连方式。关键点是hasInited必须和release成对出现。漏掉复位会表现为第二次进入页面黑屏因为onResume看到标志位还是true而播放器已经被释放了。这个细节在自查时优先级最高很多所谓播放器不稳定问题其实就是这里漏了一行赋值。3.4 拉流验证先用电脑端把源确认干净再把问题交给手机手机端排查最浪费时间的情况是IPC源本身已经不可用却在播放器代码里反复找原因。我的习惯是先用电脑端工具把源确认清楚确认没问题后再回Android代码里排查播放器逻辑。# 用ffprobe确认流信息重点看编码格式、分辨率和帧率 ffprobe -rtsp_transport tcp -show_streams -show_format rtsp://192.168.1.64:554/h264/ch1/main/av_stream # 用VLC的命令行播放确认画面和声音这里用cvlc避免打开GUI窗口 cvlc rtsp://192.168.1.64:554/h264/ch1/main/av_stream参数说明ffprobe的-rtsp_transport tcp参数强制使用TCP传输对跨网段拉流更可靠如果TCP模式能正常返回流信息但UDP出不来多半是网络路由或防火墙问题而不是播放器问题。cvlc先播一遍可以提前排除编码、字幕、带宽这些问题电脑端确认后手机端就只剩播放器代码本身需要调试了。4. RTSP播放避坑跑通画面后最常翻车的5个现场4.1 黑画面但无异常日志Surface时序与播放器状态没有对上现象播放器已经调用日志里没有任何Error状态回调也走到了Prepared视频轨道解析出来了但TextureView上一片黑。原因Surface时序不对占大多数。一种情况是在onCreate里拿到TextureView对象就直接new Surface但此时SurfaceTexture还没available生成的Surface在MediaCodec视角是无效的另一种情况是初始化播放器时Surface还没绑定进去播放器内部已经完成视频轨道的configure后续再设置Surface已经来不及生效。解决把初始化整体挪到onSurfaceTextureAvailable回调里确认surface.isValid()为true后再传给播放器。同时在日志里打印onVideoSizeChanged回调如果走了onVideoSizeChanged说明解码器已经在往外输出问题一定出在Surface绑定如果videoSize一直为0说明还没拿到流需要回头查SDP和网络连接。依据这个区分点排查比对着黑屏盲目改参数高效得多。经验是RTSP播放的很多问题都是静默失败没有异常不等于流程没问题。4.2 一点播放就抛Unsupported硬解H.265并不理所当然现象点击播放后立即报MediaCodec.CodecException错误码类似0xfffffff4或者播放器内部抛出Unable to instantiate decoder有时延迟几秒后才报错画面从未出现。原因当前设备的MediaCodec解码器列表里没有H.265硬解码器或者IPC输出的H.265码流带了硬解不支持的扩展参数比如B帧、10bit色深。系统MediaPlayer遇到这种情况一般直接失败ExoPlayer会尝试查找解码器找不到就抛错。解决先用ffprobe确认编码细节再用MediaCodecList打印设备实际支持的解码器类型。如果确认是H.265且设备不支持就选择带FFmpeg软解内核的播放方案或者让IPC端把编码切换成H.264。不要把支持H.265当成默认能力每台真机都要实测。调试阶段在Demo里加一个解码器检测入口能提前拦截这类问题避免用户到现场才发现播不了。4.3 画面卡顿、延迟越拉越大UDP丢包与缓冲策略互相拉扯现象刚连接时画面正常播放一段时间后延迟逐渐增大再过一会画面开始卡顿声音时有时无。原因RTSP默认拉流模式是UDPIPC持续以固定帧率推RTP包网络一抖动就丢包播放器为了救画面会持续积累缓冲延迟从几百毫秒膨胀到好几秒。另一个常见元凶是播放器缓冲设置太大实时流场景却按本地视频点播的缓冲策略来配置。解决先把RTSP传输模式切成TCP大多数IPC在SETUP阶段可以通过Transport字段切换TCP模式对局域网IPC来说换来的是可靠很多。再看播放器缓冲参数ExoPlayer里通过setBufferForPlaybackMs和setBufferForPlaybackAfterRebufferMs调整监控实时预览建议把值调小播放器就不会拼命堆积数据。先明确当前场景要实时还是顺滑参数方向定了再谈具体数值。4.4 切后台回来播放器废了Surface丢了但播放器还握着现象App切到后台再回来画面冻结在最后一帧或直接黑屏点播放暂停都没有反应旋转屏幕后才恢复。原因锁屏或退到后台时Surface被系统回收但播放器没有releaseMediaCodec还持有旧Surface引用。回到前台后Activity或View重建出了新Surface播放器不会自动重新绑定于是状态卡死。解决严格走onPause里release、onResume里重新init的生命周期策略每次resume都从TextureView当前状态里取surfaceTexture重新创建Surface再传给播放器。不要用局部变量缓存Surface跨生命周期使用也不要觉得暂停更省事实时流的会话一旦断掉服务端不会给你保留播放进度。4.5 网络一抖主线程就堵RTSP协商把prepare阻塞在了UI线程现象断网或网络很弱时点击播放后整个界面卡住过几秒系统弹无响应对话框。原因系统MediaPlayer如果用同步prepare网络协商会直接阻塞当前线程。即使文档要求使用异步prepare部分ROM实现里RTSP相关网络请求仍然可能落在主线程弱网下等待时间被无限拉长。解决播放器的初始化、prepare、重连全部放到后台线程UI线程只接收状态回调回调切回主线程用Handler或者主线程Executor。如果使用ExoPlayer它本身有异步调度机制但依然要在Activity销毁时及时release避免底层资源泄漏。生产环境里遇到的大多数播放器卡死不是性能问题而是网络任务把UI线程堵死了。5. 进阶把重连策略与检测清单焊进RTSP播放Demo到这里Demo已经能在本地把画面正常拉起来。但RTSP播放真正见功力的是长时间挂机后不黑屏、不假死、延迟不越拉越大。我一般会在Demo里加三样东西断线自动重连、可量化的源检测清单、适配场景的缓冲参数。// 指数退避重连第一次1秒第二次2秒封顶30秒 private fun scheduleReconnect() { if (retryCount 6) { listener?.onFatal() return } val delayMs ((1L shl retryCount) * 1000L).coerceAtMost(30_000L) reconnectHandler.postDelayed({ val surface lastSurface if (surface ! null surface.isValid) { player.init(lastUrl, surface) // 重建会话而不是复用旧播放器 } else { scheduleReconnect() } }, delayMs) retryCount }重连说明指数退避不是无脑循环它给服务端留出了恢复时间也避免在弱网下反复抢占带宽。每次重连成功要重置retryCount失败才继续累加。重连前必须确认Surface依然有效否则切后台期间触发重连只是在做无效操作。这段代码放在第2章定义的接口后面可以让所有播放内核共享同一套重连策略。再放一份源检测清单在手边遇到现场问题先过一遍验证项预期失败时处理源地址可连通ffprobe能返回SDP检查端口、路径、账号密码编码格式H.264或设备明确声明H.265换成软解内核或让IPC切H.264传输模式TCP与UDP都试过一遍选择现象更好的一种并固化音频编码AAC大概率可播G711需要验证播放器扩展包或关闭音频只验证视频缓冲方向实时场景延迟可控调小缓冲回放场景调大缓冲围绕缓冲参数补一个方向如果是监控实时预览把播放缓冲调小让画面更接近实时如果做录像回放或非实时查看缓冲调大一些换顺滑度。比如ExoPlayer的setBufferForPlaybackMs在实时场景可以设到1500左右回放场景放到5000以上具体值随现场网络质量浮动。判断标准不以单次连接为准连续播放十分钟不出现画面撕裂和音频断流才算过。最后说一个个人习惯写播放器时把异常状态当成一等公民。每次写init之前先想好release入口在哪里每个release入口都要决定是否触发重连。之前有段时间先做画质后补重连结果在验收时翻车不断整条链路的问题被一个接一个的现场报告淹没。现在不管做什么播放器先把状态机和重连写进Demo画质和延迟参数再漂亮也都是在这个骨架上填肉。希望帮到你。本文还有配套的精品资源点击获取