
前言在安防监控、车载视频、工业巡检、无人机图传、移动执法和远程运维等场景中Android 终端经常需要同时预览多路 RTSP 或 RTMP 视频流。与单路播放器相比多路播放并不是简单地复制几个播放窗口和按钮而是需要重新处理播放器实例管理、解码资源分配、Surface 生命周期、事件回调路由、录像状态复用、横竖屏布局以及退出时的资源释放。本文基于大牛直播 SDKSmartMediaKit的 Android 单路播放 Demo 和四路多实例播放 Demo分析如何将一个面向功能验证的单路播放器改造成结构清晰、状态独立、便于继续扩展的多路播放器。Demo 当前采用四宫格布局每一路都可以独立播放、停止和录像同时支持软解码、硬解码 OpenGL 渲染和硬解码 Surface 渲染三种模式。虽然示例展示的是四路播放但其设计思路同样适用于后续扩展到 9 路、16 路以及可动态切换窗口数量的监控客户端。一、从单路播放到多路播放改变的不只是窗口数量单路播放 Demo 的结构通常比较直接一个播放器实例、一个播放窗口、一组播放控制按钮再配合截图、录像、静音和状态日志等功能即可。播放器状态基本可以由 Activity 或 Fragment 直接维护。多路播放则不同。每一路视频都需要拥有独立的业务状态和底层资源包括播放地址SmartPlayer 实例和 native handleSurfaceView 或 OpenGL 渲染视图播放与录像状态播放、录像控制按钮视频宽高信息当前操作状态连接、缓冲和下载速度等事件。因此多路播放首先要解决的不是“如何同时调用四次播放接口”而是如何把每一路视频抽象成独立的播放单元。Demo 中通过StreamWindow对单个窗口进行封装private class StreamWindow { int index; String url; LibPlayerWrapper playerWrapper; SurfaceView surfaceView; ViewGroup container; Button btnPlay; Button btnRecord; int videoWidth 0; int videoHeight 0; AtomicBoolean operationBusy new AtomicBoolean(false); StreamWindow(int index, String url, ViewGroup container, SurfaceView surfaceView, Button btnPlay, Button btnRecord) { this.index index; this.url url; this.container container; this.surfaceView surfaceView; this.btnPlay btnPlay; this.btnRecord btnRecord; this.playerWrapper new LibPlayerWrapper(); } }这种设计把“某一路流的地址、播放器、渲染窗口、按钮和状态”放在同一个对象中。Activity 只负责全局调度不再直接维护大量类似playerHandle0、playerHandle1、isPlaying0、isPlaying1的零散变量。更重要的是每个StreamWindow都独立持有一个LibPlayerWrapper。播放器实例之间不存在状态复用可以有效避免一路停止播放时误释放其他窗口资源或者一路录像状态影响其他播放实例的问题。从工程结构上看多路播放器可以分为三层Activity / Fragment │ ├── 全局参数与窗口调度 │ ├── 解码模式 │ ├── RTSP TCP/UDP 策略 │ ├── 缓冲参数 │ └── 横竖屏与日志管理 │ ├── StreamWindow │ ├── URL │ ├── 播放状态 │ ├── 录像状态 │ └── Surface 与控件 │ └── LibPlayerWrapper ├── native handle ├── 播放接口 ├── 录像接口 └── 生命周期管理这也是单路 Demo 向多路工程演进时最关键的一步将原来属于页面的播放器状态下沉为可独立管理的播放单元。安卓RTMP播放器同时播放4路RTMP流延迟测试二、播放器 handle 与窗口必须建立明确映射SmartMediaKit 播放器的事件回调会携带对应的 player handle。单路播放时页面通常只存在一个 handle因此不需要判断事件属于哪个窗口多路播放时如果不能准确完成 handle 到窗口的映射就无法知道连接成功、分辨率变化或者缓冲事件来自哪一路。Demo 使用线程安全的ConcurrentHashMap保存映射关系private final ConcurrentHashMapLong, StreamWindow handleMap new ConcurrentHashMapLong, StreamWindow();创建播放器实例后将 handle 与窗口绑定long handle libPlayer.SmartPlayerOpen(context); win.playerWrapper.set(libPlayer, handle); handleMap.put(handle, win);在事件回调中通过 handle 反查对应的StreamWindowStreamWindow win activity.handleMap.get(handle); if (win null) { Log.w(TAG, Event callback for unknown handle: handle); return; }这样所有事件都可以带上窗口编号[窗口0] 连接成功 [窗口1] RTSP error code: 401 [窗口2] 分辨率: 1920x1080 [窗口3] Buffering: 60%该映射关系还有一个重要作用约束播放器实例的完整生命周期。创建 handle 后加入映射彻底停止播放和录像后移除映射并释放实例Activity 销毁时再统一清理剩余窗口。long handle win.playerWrapper.get(); handleMap.remove(handle); win.playerWrapper.release();多实例场景最容易出现的问题之一就是底层 handle 已经释放但 Java 层仍然保存映射或者窗口已经销毁异步事件仍然尝试访问旧控件。Demo 中的事件回调和主线程 Handler 都使用WeakReferenceSmartPlayer保存 Activity 引用可以降低异步回调长期持有页面对象的风险。此外每个窗口还通过AtomicBoolean operationBusy防止用户快速连续点击播放或录像按钮造成同一路执行重复 Open、Start、Stop 或 Releaseif (!win.operationBusy.compareAndSet(false, true)) { Log.w(TAG, Window index is busy, ignoring click); return; } try { // 执行播放或录像操作 } finally { win.operationBusy.set(false); }这类保护在单路测试 Demo 中可能不明显但在多路播放器中非常重要因为多个窗口的操作可能交错发生底层资源创建和释放必须保持可预测。三、软解、硬解和渲染模式应分别建模多路播放器的性能瓶颈通常不在网络接收而在解码、色彩转换、图像拷贝和渲染。不同 Android 设备的硬解码器数量、MediaCodec 稳定性和 GPU 能力差异较大因此 Demo 提供了三种解码与渲染组合模式视频解码渲染路径适用方向软解码CPU 软解OpenGL兼容性验证、特殊码流兜底硬解码MediaCodecOpenGL兼顾硬解效率与统一渲染能力硬解码 SurfaceMediaCodecSurfaceView性能和资源占用优先代码中将三种模式定义为private static final int DECODE_RENDER_MODE_SOFT_OPENGL 0; private static final int DECODE_RENDER_MODE_HARD_OPENGL 1; private static final int DECODE_RENDER_MODE_HARD_SURFACE 2;模式与内部参数之间的映射如下private void applyDecodeRenderMode(int mode) { decodeRenderMode mode; switch (mode) { case DECODE_RENDER_MODE_SOFT_OPENGL: isHardwareDecoder false; isEnableHardwareRenderMode false; break; case DECODE_RENDER_MODE_HARD_OPENGL: isHardwareDecoder true; isEnableHardwareRenderMode false; break; case DECODE_RENDER_MODE_HARD_SURFACE: isHardwareDecoder true; isEnableHardwareRenderMode true; break; } }当前 Demo 默认采用的是“硬解码 OpenGL”模式private boolean isHardwareDecoder true; private boolean isEnableHardwareRenderMode false; private int decodeRenderMode DECODE_RENDER_MODE_HARD_OPENGL;硬解码 OpenGL 模式适合作为较通用的默认选项视频由 MediaCodec 解码渲染仍通过统一的 OpenGL 路径完成便于后续增加旋转、镜像、纹理处理、叠加和外部渲染等能力。硬解码 Surface 模式则更偏向性能优先。解码结果直接关联 Surface 渲染能够减少部分中间转换和渲染处理但上层需要更谨慎地处理 Surface 尺寸、宽高比、创建销毁以及部分机型的 MediaCodec 兼容性。需要注意的是渲染模式并不是调用播放接口时临时改变一个参数即可。Demo 创建渲染 View 时使用NTRenderer.CreateRenderer(this, !isEnableHardwareRenderMode);当isEnableHardwareRenderMode改变时底层需要的渲染 View 类型也会发生变化。因此从 OpenGL 模式切换到 Surface 模式或者从 Surface 模式切回 OpenGL 模式都需要重新创建各个窗口的渲染 View。四、播放过程中不能随意重建 Surface多路播放器中Surface 生命周期是比播放接口本身更容易出问题的部分。播放过程中直接移除旧 SurfaceView、创建新渲染 View可能导致底层解码器仍然持有旧 Surface进而出现黑屏、花屏、画面停住严重时还可能触发 MediaCodec 或 native 层异常。因此Demo 在切换解码和渲染模式前会先判断是否有任意窗口处于运行状态private boolean hasRunningWindow() { for (StreamWindow win : windowList) { if (win ! null win.playerWrapper ! null win.playerWrapper.is_running()) { return true; } } return false; }这里的is_running()不仅包括播放还包括拉流和录像状态。只要任意一路仍在使用播放器实例就不允许重建渲染窗口if (hasRunningWindow()) { showToast(请先停止所有播放/录像后再切换模式); appendLogMessage(播放/录像中禁止切换解码模式); return false; }确认全部停止后再统一替换四个窗口的渲染 View并重新注册SurfaceHolder.Callbackcontainer.removeView(oldView); newView.setId(oldId); container.addView( newView, childIndex, createDefaultRenderLayoutParams() ); newView.getHolder().addCallback(this); win.surfaceView newView; win.videoWidth 0; win.videoHeight 0;这套处理体现了多路播放器中的一个基本原则播放状态与渲染载体必须同步管理不能只关注播放器 handle而忽略 Surface 是否仍然有效。启动播放前Demo 还会检查 SurfaceView 的尺寸以及底层 Surface 是否已经创建private boolean isPlaybackViewReady(StreamWindow win) { if (win null || win.surfaceView null) return false; if (win.surfaceView.getWidth() 0 || win.surfaceView.getHeight() 0) { return false; } SurfaceHolder holder win.surfaceView.getHolder(); Surface surface holder ! null ? holder.getSurface() : null; return surface ! null surface.isValid(); }如果 View 尚未完成布局或者 Surface 尚不可用则将播放操作重新投递到 View 消息队列待渲染窗口准备好后再继续。这可以避免页面刚创建时立即播放因为 Surface 尚未就绪而导致启动失败。在硬解 Surface 模式下当 Surface 因横竖屏切换或系统生命周期重新创建时Demo 通过以下接口通知底层更新渲染目标libPlayer.SmartPlayerUpdateHWRenderSurface(handle);这说明多路播放中的 Surface 管理并不是一次性的初始化工作而是贯穿播放启动、模式切换、横竖屏变化和页面销毁的完整生命周期管理。五、每一路播放都应遵循完整的状态流程单路播放器中很多初始化代码可以直接写在播放按钮回调里多路播放器更适合把公共参数配置、实例创建、启动播放和异常回收拆开处理。每一路开始播放时基本流程如下播放器公共参数集中在configCommonParams()中配置private boolean configCommonParams(long handle, String url) { libPlayer.SetSmartPlayerEventCallbackV2( handle, new EventHandleV2(this, handle) ); libPlayer.SmartPlayerSetBuffer(handle, playBufferMs); libPlayer.SmartPlayerSetRTSPTimeout(handle, 10); libPlayer.SmartPlayerSetRTSPAutoSwitchTcpUdp(handle, 1); libPlayer.SmartPlayerSetRTSPTcpMode(handle, isUsingTcp); libPlayer.SmartPlayerSetUrl(handle, url); libPlayer.SmartPlayerSetReportDownloadSpeed(handle, 1, 3); return true; }Demo 中playBufferMs默认配置为 0侧重低延迟体验同时打开 RTSP TCP/UDP 自动切换能力并允许通过isUsingTcp设置初始传输模式。在实际项目中缓冲策略不宜简单地认为越小越好。多路播放同时受到网络抖动、设备解码能力、线程调度和内存压力影响。局域网监控、无人机图传等场景可以优先控制低延迟复杂公网或者无线网络场景则需要在延迟和抗抖动之间做平衡。启动播放时调用封装后的StartPlayer()boolean ret win.playerWrapper.StartPlayer( win.surfaceView, surface, null, renderScaleMode, true, isHardwareDecoder, true, isEnableHardwareRenderMode );主要参数包括renderScaleMode控制等比例显示或铺满快速启动减少等待首屏的时间isHardwareDecoder决定是否启用硬件解码低延迟模式面向实时视频预览isEnableHardwareRenderMode决定是否启用硬解 Surface 渲染。如果播放器启动失败并且该实例没有承担录像任务则立即移除 handle 映射并释放资源if (!win.playerWrapper.is_recording()) { long handle win.playerWrapper.get(); handleMap.remove(handle); win.playerWrapper.release(); }停止播放时也遵循同样的规则只停止当前窗口的播放状态。如果该 handle 仍然用于录像则保留播放器实例只有播放和录像都停止后才真正调用 Close 释放底层资源。六、播放与录像可以复用同一个播放器实例在 SmartMediaKit 多路 Demo 中播放和录像不是两个完全独立的播放器实例而是可以共用同一个 player handle。其状态关系可以概括为当前状态启动新操作处理方式未播放、未录像启动播放创建实例并播放正在播放启动录像复用现有实例正在录像启动播放复用现有实例播放和录像同时进行停止播放保留实例录像继续播放和录像同时进行停止录像保留实例播放继续只剩一个任务并将其停止停止任务释放实例启动录像时如果当前窗口还没有播放器实例则先创建 handle并配置播放地址和事件回调if (win.playerWrapper.empty()) { long handle libPlayer.SmartPlayerOpen(context); if (!configCommonParams(handle, win.url)) { libPlayer.SmartPlayerClose(handle); return; } win.playerWrapper.set(libPlayer, handle); handleMap.put(handle, win); }随后创建录像目录、设置文件参数并启动录像libPlayer.SmartPlayerCreateFileDirectory(recDir); win.playerWrapper.SetRecorderDirectory(recDir); win.playerWrapper.SetRecorderFileMaxSize(500); win.playerWrapper.StartRecorder(true);Demo 优先使用应用自己的外部文件目录File externalFile getExternalFilesDir(null); recDir externalFile.getAbsolutePath() /daniulive/playrec;这种目录策略对较新的 Android 存储机制更加友好也减少了直接依赖公共/sdcard路径带来的权限问题。停止录像后如果当前窗口没有继续播放才释放播放器实例win.playerWrapper.StopRecorder(); if (!win.playerWrapper.is_playing()) { long handle win.playerWrapper.get(); handleMap.remove(handle); win.playerWrapper.release(); }这种复用方式减少了重复创建网络连接和底层播放器对象的开销也使播放与录像之间的切换更加自然。但它也要求上层不能简单地把“停止播放”等同于“关闭播放器”而是必须根据播放、录像等多个子状态决定 handle 是否可以释放。七、画面比例、停止状态和多路性能边界1. 硬解 Surface 模式下的宽高比处理OpenGL 渲染通常可以由渲染层完成等比例缩放而硬解 Surface 模式下上层还需要根据视频分辨率调整 SurfaceView 的实际尺寸。Demo 在收到分辨率事件后记录宽高case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_RESOLUTION_INFO: win.videoWidth (int) param1; win.videoHeight (int) param2; if (activity.isEnableHardwareRenderMode) { activity.postApplyAspectLayout(win); } break;随后根据视频宽高比与容器宽高比计算目标尺寸。FIT 模式保证画面完整显示可能保留黑边CROP 模式则铺满容器但可能裁剪部分画面。横竖屏切换后容器尺寸会改变Demo 会重新计算各个窗口的布局。横屏状态下隐藏下方控制区域使四宫格视频区域占满屏幕竖屏状态下显示播放、录像和事件日志区域。2. 停止播放后是否保留最后一帧监控类应用中停止播放后的画面状态需要根据产品需求决定。保留最后一帧便于用户查看停止前的画面但也可能让用户误认为视频仍在实时播放清空画面则状态更明确但窗口切换时可能更突兀。Demo 中通过变量统一控制private boolean keepLastFrameOnStop true;当前代码默认保留最后一帧。需要停止后清空时可以将其改为false并在停止播放后对 Surface 清黑、隐藏private void updatePlaybackViewAfterStop(StreamWindow win) { if (keepLastFrameOnStop) { showPlaybackView(win); } else { clearPlaybackView(win); } }布局文件中视频容器和页面背景均设置为黑色可以避免视频尚未开始、窗口隐藏或者等比例显示时出现灰色边框和背景色不一致的问题。3. 多路音频不宜同时输出多路监控画面一般不建议同时播放所有通道的声音否则多个音频会叠加在一起既影响听感也增加音频处理压力。实际产品通常采用以下策略之一默认所有通道静音仅输出当前选中窗口的声音双击某一路放大时开启该路音频切换音频通道时关闭上一通道。SmartMediaKit 播放实例之间相互独立因此可以按窗口控制静音状态。多路 Demo 后续可以增加“当前音频窗口”的全局状态保证同一时间只允许一路音频输出。4. 四路、九路和十六路不是简单增加 View从代码结构上看将四路扩展到九路或十六路只需要创建更多StreamWindow和对应布局但从运行能力上看窗口数量受到以下因素共同影响视频编码格式和分辨率每路帧率与码率Android SoC 的硬解码能力MediaCodec 可同时创建的解码器数量是否全部使用主码流OpenGL 纹理和渲染开销网络接收总带宽内存带宽和设备散热是否同时录像是否开启音频、截图和原始数据回调。例如四路 720P 子码流与四路 4K 主码流对设备的压力完全不同。九宫格和十六宫格通常没有必要全部使用高分辨率主码流可以在小窗口阶段播放子码流用户放大某一路后再切换到主码流。因此多路播放器的合理设计不是盲目追求“同时打开多少路”而是根据窗口大小、设备能力和业务优先级动态分配解码资源。5. 页面销毁必须统一释放资源多实例播放器退出时必须遍历所有窗口并执行完整释放Override protected void onDestroy() { for (StreamWindow win : windowList) { win.release(); } handleMap.clear(); windowList.clear(); super.onDestroy(); }LibPlayerWrapper.release()会依次检查播放、拉流和录像状态停止仍在运行的任务然后关闭 native handle。相比在 Activity 中直接散落调用多个 JNI Stop 和 Close 接口这种统一封装更容易保证资源释放顺序也便于后续增加新的运行状态。八、从 Demo 走向正式项目还需要补充什么基于现有四路 Demo已经可以完成 RTSP/RTMP 多实例播放、三种解码渲染模式切换、独立录像、事件显示和横竖屏适配。要进一步用于正式项目还可以继续完善以下能力。首先是动态窗口管理。当前 Demo 固定初始化四个窗口正式项目可以将StreamWindow与 RecyclerView、GridLayout 或自定义视频墙结合根据 1、4、9、16 宫格模式动态创建和回收窗口。其次是主辅码流切换。小窗口优先播放低分辨率子码流单路放大后切换主码流可以明显降低整体解码和带宽压力。切换时还需要处理旧实例停止、新 URL 配置和首屏衔接。再次是音频焦点管理。多路播放时应维护唯一的音频活动窗口并处理静音切换、系统音频焦点、耳机插拔和蓝牙输出等状态。此外还可以补充失败重连、网络变化监听、窗口级统计、抓图、录像文件管理、通道权限控制、前后台切换策略以及设备性能分级。对于大规模监控客户端还应根据设备型号建立硬解能力白名单和最大并发路数配置避免在低性能设备上无条件开启全部高清通道。总结Android 多路 RTSP/RTMP 播放的核心并不是在页面上放置多个 SurfaceView然后重复调用多次播放接口而是建立一套可靠的多实例管理模型。基于大牛直播 SDKSmartMediaKit的 Android 四路播放 Demo可以将每一路视频封装为独立的StreamWindow通过LibPlayerWrapper管理播放器状态通过 handle 映射路由异步事件并围绕 Surface 生命周期、解码渲染模式、播放录像复用和资源释放建立清晰的控制边界。其中值得重点关注的工程细节包括每一路播放状态和播放器实例独立管理player handle 与窗口建立明确映射播放或录像期间禁止切换渲染 View 类型播放前确认 Surface 已完成创建硬解 Surface 模式下根据分辨率调整显示比例播放和录像复用实例但根据组合状态决定是否释放使用操作锁避免连续点击导致状态竞争横竖屏变化后重新计算窗口布局页面销毁时统一释放全部 native 资源。在这一结构基础上四路 Demo 可以进一步演进为支持 1/4/9/16 宫格切换、主辅码流调度、单路放大、音频通道选择、录像管理和异常重连的行业级 Android 视频客户端。对于安防监控、车载终端、工业视觉、无人机图传、智慧工地和移动巡检等业务来说真正决定多路播放器可用性的不只是能否把画面播放出来而是多实例长期运行时能否保持状态清晰、资源可控、画面稳定并在复杂设备和网络环境下具备持续扩展能力。 CSDN官方博客音视频牛哥-CSDN博客