从单路封装到多路管理:基于 SmartMediaKit 构建 C# 多路RTSP/RTMP直播播放模块

在安防监控、设备预览、远程巡检、工业可视化、无人值守机房、移动布控平台等场景里,多路视频预览几乎是绕不开的基础能力。

很多开发者一开始做多路播放时,容易把注意力放在"底层播放接口如何调用"上,但真正进入项目落地阶段后,会发现更麻烦的往往不是单路播放,而是多路状态管理:

每一路 RTSP/RTMP 地址如何维护?

每一路播放、停止、录像如何独立控制?

4 窗口、9 窗口切换时,播放器实例要不要销毁?

录像文件如何按通道分目录保存?

网络状态、分辨率、下载速度、丢包率等回调如何展示?

退出程序时,如何确保录像文件正常结束、播放连接可靠释放?

大牛直播 SDK(SmartMediaKit)的 C# 多路播放 Demo,重点解决的正是这些上层集成问题。它不是为了做一个复杂华丽的监控平台界面,而是通过一个简洁的 WinForms 示例,把多路 RTSP/RTMP 播放、录像、事件回调、渲染控件和资源释放的组织方式讲清楚,方便开发者快速迁移到自己的项目里。

多路播放的本质:把单路播放器封装好,再做 N 路管理

多路播放不要一开始就写成一个巨大的状态机。更清晰、也更容易维护的方式是:先把单路播放器抽象干净,然后多路只是数组管理、布局管理和事件分发。

Demo 里上层 Form 主要维护三组对象:

cs 复制代码
private const int MaxPlayers = 9;

private readonly NTPlayerWrapper[] _players = new NTPlayerWrapper[MaxPlayers];
private readonly PlayConfig[] _playConfigs = new PlayConfig[MaxPlayers];
private readonly RecordConfig[] _recordConfigs = new RecordConfig[MaxPlayers];

这三组对象的职责非常明确:

_players[i] 表示第 i 路播放器实例。

_playConfigs[i] 表示第 i 路播放参数,比如 RTSP TCP、低延迟、缓冲时间、音量、硬解、等比显示等。

_recordConfigs[i] 表示第 i 路录像参数,比如录像目录、文件名前缀、是否录音视频、是否自动附加日期时间、单文件大小等。

这样设计以后,Form 层不需要到处散落 SDK 原始接口,也不需要每个按钮都直接操作底层 handle。上层业务只需要面对非常清晰的调用:

cs 复制代码
_players[i].StartPlay(url, _playConfigs[i]);
_players[i].StopPlay();

_players[i].StartRecord(_recordConfigs[i]);
_players[i].StopRecord();

这就是多路播放 Demo 最值得借鉴的地方:把复杂度收敛到单路封装类里,多路场景只做实例组织。

播放器实例按需创建,避免一启动就占满资源

Demo 支持 4 窗口和 9 窗口两种布局。播放器实例并不是程序启动时一次性强制创建全部 9 路,而是根据当前显示布局按需创建。

上层逻辑可以理解为:

cs 复制代码
private void EnsurePlayersCreated(int startIndex, int endIndex)
{
    for (int i = startIndex; i <= endIndex; ++i)
    {
        if (_players[i] != null)
            continue;

        _players[i] = new NTPlayerWrapper();

        _playConfigs[i] = CreateDefaultPlayConfig();
        _recordConfigs[i] = CreateDefaultRecordConfig(i);

        BindPlayerCallbacks(i, _players[i]);
    }
}

这种方式非常适合实际业务系统。

默认 4 路预览时,只创建前 4 个播放器实例。

切换到 9 路布局时,再补齐后面的播放器实例。

从 9 路切回 4 路时,可以停止多出来的通道,避免后台继续拉流、录像或占用解码资源。

对于监控预览类项目来说,很多界面并不是永远显示全部通道。比如设备列表里有几十路、上百路摄像头,但当前屏幕只显示 4 路、9 路或 16 路。按需创建实例,既能降低资源占用,也让生命周期管理更清晰。

播放配置统一初始化,业务层只关心 URL

多路播放 Demo 不建议在界面上暴露过多底层参数。对于大多数业务系统来说,播放参数可以在代码里统一初始化一套默认值,然后上层只传 URL。

例如播放配置可以这样组织:

cs 复制代码
private PlayConfig CreateDefaultPlayConfig()
{
    return new PlayConfig
    {
        IsFastStartup = true,
        IsLowLatencyMode = true,

        IsRtspTcpMode = true,
        IsRtspAutoSwitchTcpUdp = true,
        BufferTime = 0,
        RtspTimeout = 10,

        EnableReportDownloadSpeed = true,
        ReportDownloadSpeedIntervalSeconds = 3,

        IsMute = false,
        AudioVolume = 100,

        UseHardwareDecoder = false,
        IsOnlyDecodeKeyFrame = false,
        IsRenderScaleMode = true
    };
}

这样做的好处是,播放按钮不需要关心一堆参数,只需要取出当前通道的 URL,然后调用:

cs 复制代码
bool ok = _players[index].StartPlay(url, _playConfigs[index]);

对于开发者来说,项目集成阶段最常调整的通常只有几项:

RTSP 默认走 TCP 还是 UDP;

是否开启 RTSP TCP/UDP 自动切换;

是否开启低延迟模式;

播放缓冲时间设置多少;

是否静音,默认音量是多少;

是否启用 H.264/H.265 硬解;

画面是拉伸铺满还是等比例显示。

如果后续产品需要暴露配置界面,也可以把 PlayConfig 做成可序列化配置,从配置文件或数据库加载,而不是把参数散落在每个按钮事件里。

录像配置按通道独立管理,文件更容易排查

多路录像比单路播放更容易出问题。特别是当 4 路、9 路同时录像时,如果所有录像都放在同一个目录、使用同一个前缀,后续排查文件会非常混乱。

Demo 的做法是每一路生成独立目录和独立文件名前缀,例如:

cs 复制代码
private RecordConfig CreateDefaultRecordConfig(int index)
{
    string dir = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
        "SmartPlayerRecord",
        string.Format("ch{0}", index + 1));

    Directory.CreateDirectory(dir);

    return new RecordConfig
    {
        RecordDirectory = dir,
        RecordFilePrefix = string.Format("ch{0}_", index + 1),

        IsRecordVideo = true,
        IsRecordAudio = true,
        IsAppendDate = true,
        IsAppendTime = true,
        IsAudioTranscodeAAC = true,
        MaxRecordFileSize = 500 * 1024
    };
}

这种设计看似简单,但对实际项目很重要。

第 1 路的录像进入 ch1 目录,第 2 路进入 ch2 目录,第 9 路进入 ch9 目录。文件名前缀也带通道号,再加上日期时间,后续检索、上传、回放、问题排查都会清楚很多。

播放中录像时,可以直接复用当前播放器句柄:

cs 复制代码
bool ok = _players[index].StartRecord(_recordConfigs[index]);

未播放时直接录像,则需要传入 URL、播放配置和录像配置:

cs 复制代码
bool ok = _players[index].StartRecord(
    url,
    _playConfigs[index],
    _recordConfigs[index]);

这个设计很灵活:用户既可以"边看边录",也可以在不显示画面的情况下做后台录像。对于一些无人值守设备、云端录像或巡检记录场景,这种能力非常实用。

播放器与渲染控件分离,Form 只负责布局

C# Demo 里还有一个非常关键的设计:播放器对象和渲染控件是分离的。

每一路 NTPlayerWrapper 都会暴露自己的渲染控件:

cs 复制代码
Control renderControl = _players[index].RenderControl;
renderControl.Dock = DockStyle.Fill;
renderHost.Controls.Add(renderControl);

Form 层只需要把这个 RenderControl 加到对应的窗口容器里即可,不需要关心底层是 D3D 渲染、GDI 绘制,还是窗口大小变化如何通知 SDK。

这种分层方式非常适合业务集成:

Form 负责 UI 布局;

NTPlayerWrapper 负责播放、停止、录像、截图、事件回调;

NTWrapperRenderWindow 负责外层渲染窗口、全屏切换、窗口大小变化通知;

NTRenderWindow 负责实际渲染目标和 GDI 模式下的 RGB32 绘制。

这样做以后,即使后续要把 WinForms 界面换成更复杂的设备树、多 Tab 页面、多屏预览窗口,也不需要重写底层播放逻辑。只要把每一路播放器的 RenderControl 挂到新的容器里即可。

4 窗口和 9 窗口切换:切的是 UI,不是随便销毁播放器

多窗口布局切换是多路播放器里很常见的功能。Demo 支持 4 窗口和 9 窗口切换,核心思路是:布局变化不等于播放器生命周期变化。

从 9 路切回 4 路时,超出显示范围的通道可以先停止录像、再停止播放:

cs 复制代码
if (newCount < _viewCount)
{
    for (int i = newCount; i < _viewCount; ++i)
    {
        StopRecording(i);
        StopPlayback(i);
    }
}

然后再重建网格布局:

cs 复制代码
_viewCount = newCount;

EnsurePlayersCreated(0, _viewCount - 1);
BuildGridForViewCount(_viewCount);
UpdateAllButtonStates();
NotifyVisibleRenderWindowSizeChanged();

这类设计有两个好处。

首先,UI 布局和播放器实例解耦。窗口从 4 宫格变成 9 宫格,本质是界面容器变化,不应该让 SDK handle 管理变得混乱。

其次,切换逻辑更安全。减少窗口时,主动停止不可见通道,避免用户以为已经关闭,但后台仍然占用带宽、解码和录像资源。

Demo 里还使用了暂停窗口重绘的方式减少布局重建时的闪烁,这对 WinForms 多窗口播放尤其有价值。多路视频窗口本身就容易因为控件拆装、窗口重排、渲染句柄变化出现短暂闪动,批量重建时统一暂停和恢复绘制,界面体验会更平滑。

播放按钮只做业务判断,真正调用收敛到 Wrapper

播放按钮的逻辑应该尽量简单,不要把 SDK 参数设置、事件回调、渲染窗口处理都写进按钮事件里。

Demo 里的播放逻辑可以概括为:

cs 复制代码
private void TogglePlay(int index)
{
    NTPlayerWrapper player = _players[index];

    if (!player.IsPlaying)
    {
        string url = GetUrl(index);

        if (string.IsNullOrEmpty(url))
        {
            MessageBox.Show("请输入 URL");
            return;
        }

        bool ok = player.StartPlay(url, _playConfigs[index]);
        if (!ok)
        {
            MessageBox.Show("开始播放失败,请检查 URL、网络或协议");
            return;
        }

        player.RenderControl.NotifyWindowSizeChanged();
    }
    else
    {
        player.StopPlay();
    }

    UpdateButtonStates(index);
    UpdateStatusLabel(index);
}

上层只判断三件事:

当前是否正在播放;

URL 是否为空;

调用 StartPlay() 是否成功。

真正的 SDK 调用、参数设置、渲染窗口绑定、handle 关闭等逻辑都在 NTPlayerWrapper 内部完成。这样代码可读性更好,也更方便后续扩展,比如增加"全部播放""全部停止""轮巡播放""双击放大某一路"等功能。

录像按钮同时支持播放中录像和未播放录像

录像按钮比播放按钮稍微复杂,因为它需要区分两种状态:

当前通道已经在播放,此时直接基于当前播放器句柄启动录像;

当前通道没有播放,此时需要传入 URL,让 SDK 建立拉流和录像链路。

逻辑可以这样理解:

cs 复制代码
private void ToggleRecord(int index)
{
    NTPlayerWrapper player = _players[index];

    if (!player.IsRecording)
    {
        bool ok;

        if (player.IsPlaying)
        {
            ok = player.StartRecord(_recordConfigs[index]);
        }
        else
        {
            string url = GetUrl(index);

            if (string.IsNullOrEmpty(url))
            {
                MessageBox.Show("未播放时录像也需要 URL");
                return;
            }

            ok = player.StartRecord(
                url,
                _playConfigs[index],
                _recordConfigs[index]);
        }

        if (!ok)
        {
            MessageBox.Show(player.LastRecorderError ?? "开始录像失败");
            return;
        }
    }
    else
    {
        player.StopRecord();
    }

    UpdateButtonStates(index);
    UpdateStatusLabel(index);
}

这个设计非常贴近实际项目。

在监控平台里,用户可能正在看某一路画面,然后手动点击录像;也可能某一路不在当前预览窗口,但后台需要按事件触发录像。把这两种情况都封装好,可以让上层业务非常灵活。

事件统一入口,按通道号分发

多路播放最忌讳的是给每一路播放器写一套重复事件处理逻辑。4 路还勉强能维护,到了 9 路、16 路、32 路就会变得非常混乱。

Demo 的做法是:绑定事件时捕获通道号,然后统一进入状态处理函数。

cs 复制代码
private void BindPlayerCallbacks(int channelIndex, NTPlayerWrapper player)
{
    int ch = channelIndex;

    player.VideoSizeChanged += delegate(object sender, VideoSizeEventArgs e)
    {
        _videoW[ch] = e.Width;
        _videoH[ch] = e.Height;
        UpdateStatusLabel(ch);
    };

    player.SDKEvent += delegate(object sender, SDKEventArgs e)
    {
        HandleSDKEvent(ch, e);
    };

    player.RecordStatusChanged += delegate(object sender, RecordEventArgs e)
    {
        UpdateButtonStates(ch);
        UpdateStatusLabel(ch);
    };
}

统一事件入口里,可以集中处理连接状态、缓冲状态、下载速度、丢包率、视频时长、播放结束、录像结束等事件。

例如下载速度事件可以记录到数组中:

cs 复制代码
_downloadSpeedBytes[ch] = e.Param1;
_netQualityMetrics[ch] = e.Param2;

状态行再统一展示:

cs 复制代码
下载速度: 2500kbps 312KB/s
丢包率: 0.50%
分辨率: 1920x1080

这种方式的好处是显而易见的:事件处理逻辑不会随着通道数线性膨胀。后续从 4 路扩到 9 路、16 路甚至更多路,只需要增加实例数量和布局,不需要复制粘贴事件代码。

渲染窗口独立封装,双击全屏和窗口变化不侵入业务层

多路播放器里,视频窗口不只是一个普通 Panel。它需要处理渲染句柄、窗口大小变化、D3D/GDI 绘制、双击全屏、ESC 退出全屏等细节。

Demo 里通过 NTWrapperRenderWindowNTRenderWindow 做了双层封装:

NTWrapperRenderWindow 是外层包装窗口,负责全屏切换、窗口大小变化通知、键盘事件和双击事件。

NTRenderWindow 是内层渲染窗口,作为 D3D 渲染目标,也支持 GDI 模式下接收 RGB32 数据并绘制。

这样 Form 层就不需要处理太多渲染细节。窗口大小变化时,只需要通知可见播放器:

cs 复制代码
private void NotifyVisibleRenderWindowSizeChanged()
{
    for (int i = 0; i < _viewCount; ++i)
    {
        if (_players[i] != null && _players[i].IsPlaying)
            _players[i].RenderControl.NotifyWindowSizeChanged();
    }
}

这对多路播放非常关键。因为 4 宫格和 9 宫格切换、窗口最大化、拖拽调整大小、双击全屏恢复,都会导致渲染窗口尺寸变化。如果没有统一通知机制,很容易出现画面比例异常、渲染区域不刷新、黑边处理不一致等问题。

底层 SDK 调用集中在 NTPlayerWrapper,业务层不碰 handle

NTPlayerWrapper 的价值在于把底层 SDK 调用封装起来,让业务层不直接关心 handle 的创建、关闭和参数设置。

播放时,Wrapper 内部大致做了几件事:

  • 初始化公共参数;
  • 设置播放相关参数;
  • 设置渲染窗口;
  • 调用 NT_SP_StartPlay()
  • 成功后更新 IsPlaying 状态。

停止播放时,则会清理渲染窗口、停止播放、重置状态,并在没有录像的情况下关闭播放器句柄。

录像也是类似逻辑:

  • 播放中录像,直接配置录像参数并调用 NT_SP_StartRecorder()
  • 未播放录像,先初始化公共播放参数,再配置录像参数,然后启动录像;
  • 停止录像时调用 NT_SP_StopRecorder(),如果没有播放,则关闭句柄。

这种设计非常适合多路场景。因为播放和录像可能共用同一个播放器句柄,不能简单粗暴地"停止播放就关闭一切"。正确做法应该是:

播放停了,但录像还在,就不能关闭 handle;

录像停了,但播放还在,也不能关闭 handle;

只有播放和录像都停止后,才释放底层句柄。

这也是多路播放器长期稳定运行时非常重要的细节。

退出程序时,先停录像,再停播放,最后释放实例

多路播放器退出时,资源释放顺序一定要清楚。尤其是正在录像的情况下,如果直接释放播放器实例,有可能导致文件没有正常收尾。

推荐顺序是:

  • 先停止录像,确保录像文件完整结束;
  • 再停止播放,断开 RTSP/RTMP 拉流;
  • 最后释放播放器实例和 SDK 相关资源。

Demo 里的逻辑可以概括为:

cs 复制代码
private void DisposeAllPlayers()
{
    for (int i = 0; i < MaxPlayers; ++i)
    {
        if (_players[i] == null)
            continue;

        if (_players[i].IsRecording)
            _players[i].StopRecord();

        if (_players[i].IsPlaying)
            _players[i].StopPlay();

        _players[i].Dispose();
        _players[i] = null;
    }
}

这个顺序简单但可靠。对于长期运行的监控客户端、工业预览工具或多路录像系统来说,退出释放的稳定性并不比播放本身低。很多线上问题不是出在"开始播放",而是出在"频繁打开关闭、多路切换、程序退出、异常断网后恢复"等边界场景。

推荐的工程集成方式

如果要把这个 Demo 迁移到自己的项目中,建议按下面的结构理解,而不是只看某几个按钮事件。

业务界面层负责:

  • 创建多个播放器实例;
  • 维护每一路 URL;
  • 维护播放、录像按钮状态;
  • 负责 4 宫格、9 宫格、16 宫格布局;
  • 把每一路 RenderControl 放到对应窗口容器;
  • 展示连接状态、下载速度、丢包率、分辨率等信息。

NTPlayerWrapper 负责:

  • SDK 初始化和引用计数;
  • 播放器 handle 管理;
  • 播放参数配置;
  • 录像参数配置;
  • 播放、停止、录像、停止录像;
  • 截图、切换 URL、暂停、跳转等扩展能力;
  • 事件回调转发;
  • 向上暴露 RenderControl。

NTWrapperRenderWindow 负责:

  • 承载内层渲染窗口;
  • 窗口大小变化通知 SDK;
  • 双击全屏;
  • ESC 退出全屏;
  • 渲染窗口句柄管理。
  • NTRenderWindow 负责:
  • 作为底层渲染目标;
  • GDI 模式下绘制 RGB32 数据;
  • 缓存最后一帧用于重绘;
  • 控制背景清理,减少闪屏;
  • 合并 Invalidate,避免高帧率下消息堆积。

这样的分层方式非常利于后续扩展。比如你要做设备树、分组预览、拖拽上墙、轮巡、录像计划、告警弹窗、双击放大、右键菜单、截图管理,都可以在上层业务里扩展,而不需要破坏播放器核心封装。

为什么这个 Demo 更适合作为项目起点

很多 SDK Demo 容易出现一个问题:为了展示接口,把所有逻辑都写在一个界面文件里。刚开始看起来很直观,但真正集成到项目时,开发者往往需要重新拆分。

这个 C# 多路播放 Demo 的价值在于,它没有把重点放在"堆接口",而是把多路播放的工程组织方式提前梳理好了。

它把 SDK 调用集中到 NTPlayerWrapper

它把渲染窗口封装到独立控件。

它把播放配置和录像配置分离。

它把通道状态用数组维护。

它把事件统一入口处理。

它把 UI 布局和播放器生命周期解耦。

它把退出释放顺序固定下来。

这些设计并不复杂,但非常贴近实际项目。对于开发者来说,基于这个 Demo 往自己的系统里迁移,通常只需要替换界面布局、接入自己的设备列表和业务配置,再根据项目需要扩展录像、截图、状态上报和异常处理即可。

小结

基于大牛直播 SDK 快速搭建 C# 多路 RTSP/RTMP 播放 Demo,核心并不是把多个播放窗口画出来,而是把多路播放的上层调用逻辑组织清楚。

建议抓住几个关键原则:

  • 先封装单路播放器,再扩展多路;
  • 播放配置和录像配置分开维护;
  • 每一路播放器实例独立管理状态;
  • 播放、停止、录像、停止录像都通过 Wrapper 收敛;
  • 事件统一入口处理,再按通道号分发;
  • UI 布局和播放器生命周期解耦;
  • 渲染控件独立封装,Form 只负责摆放;
  • 退出时先停录像,再停播放,最后释放实例。

只要这套结构搭好,多路播放本身并不复杂。无论是 4 路预览、9 路预览,还是后续扩展到 16 路、更多路,整体思路都是一致的:把单路播放器封装干净,多路只是实例管理、布局管理和状态管理。

对于安防监控、工业视觉、远程巡检、设备运维、无人值守录像等场景,这种 Demo 结构比单纯展示底层接口更有参考价值。它让开发者可以更快把大牛直播 SDK 的 RTSP/RTMP 播放、低延迟预览、多路录像和窗口渲染能力接入到自己的 C# 桌面应用中,快速形成一个稳定、清晰、可扩展的多路视频播放基础框架。


📎 CSDN官方博客:音视频牛哥-CSDN博客

相关推荐
音视频牛哥22 天前
大牛直播SDK(SmartMediaKit)Windows平台RTSP/RTMP直播播放SDK集成说明(C#版)
音视频·低延迟rtsp播放器·windows rtsp播放器·windows rtmp播放器·低延迟rtmp播放器·c# rtsp播放器·c# rtmp播放器
音视频牛哥22 天前
大牛直播SDK(SmartMediaKit)Windows平台RTSP/RTMP直播播放SDK集成说明(C++版)
windows·音视频·实时音视频·windows rtsp播放器·windows rtmp播放器·超低延迟rtsp播放器·超低延迟rtmp播放器
音视频牛哥6 个月前
【深度选型】RTSP超低延迟播放器:自研陷阱与成熟模块的效益分析
音视频·rtsp播放器·低延迟rtsp播放器·linux rtsp播放器·windows rtsp播放器·安卓rtsp播放器·ios rtsp播放器
音视频牛哥6 个月前
C# 开发工业级 RTSP/RTMP 播放器实战:基于 SmartMediakit 的低延迟与高可靠性设计
音视频·rtsp播放器·rtmp播放器·windows rtsp播放器·windows rtmp播放器·c# rtsp播放器·c# rtmp播放器
音视频牛哥7 个月前
从 RTSP/RTP/RTCP 到系统级时间闭环:跨平台低延迟RTSP播放架构解析
计算机视觉·机器人·音视频·rtsp播放器·linux rtsp播放器·windows rtsp播放器·安卓播放rtsp流
音视频牛哥2 年前
从规范到实现解读Windows平台如何播放RTSP流
大牛直播sdk·rtsp播放器·rtsp player·windows rtsp播放器·windows rtsp·rtsp播放·windows播放rtsp
音视频牛哥2 年前
RTMP播放器延迟最低可以做到多少?
音视频·大牛直播sdk·rtmp播放器·linux rtmp播放器·windows rtmp播放器·android rtmp播放器·ios rtmp播放器
音视频牛哥2 年前
Windows平台RTSP|RTMP播放器如何实时调节音量
大牛直播sdk·播放器·rtsp播放器·rtmp播放器·rtsp player·rtmp player·windows rtsp播放器