阶段性批量提交

This commit is contained in:
2026-01-05 14:54:06 +08:00
parent 917d76a87f
commit a697aab3e0
21 changed files with 1479 additions and 379 deletions

View File

@@ -0,0 +1,45 @@
using System.IO;
using System.Windows.Media.Imaging;
namespace SHH.CameraDashboard;
/// <summary>
/// [UI层] 图像数据转换助手
/// 职责:将内存中的二进制 JPEG 数据高效转换为 WPF 可用的 BitmapImage
/// 优化:使用 OnLoad 缓存策略和 Freeze 冻结对象,支持跨线程访问,防止内存泄漏
/// </summary>
public static class BitmapHelper
{
public static BitmapImage? ToBitmapImage(byte[] blob)
{
if (blob == null || blob.Length == 0) return null;
try
{
using (var stream = new MemoryStream(blob))
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
// 关键优化 1: 立即加载流到内存,允许 stream 在方法结束后被释放
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.StreamSource = stream;
// 关键优化 2: 忽略内嵌的色彩配置和缩略图,提升解码速度
bitmap.CreateOptions = BitmapCreateOptions.IgnoreColorProfile | BitmapCreateOptions.IgnoreImageCache;
bitmap.EndInit();
// 关键优化 3: 冻结对象,使其变得线程安全(可以跨线程传递给 UI
bitmap.Freeze();
return bitmap;
}
}
catch
{
// 解码失败(可能是坏帧),返回 null 忽略该帧
return null;
}
}
}

View File

@@ -0,0 +1,167 @@
using NetMQ;
using NetMQ.Sockets;
using Newtonsoft.Json;
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text;
using System.Threading.Tasks;
namespace SHH.CameraDashboard.Services;
/// <summary>
/// [Dashboard端] 指令控制服务
/// 职责:双向通信通道。接收 Service 心跳/响应,向 Service 发送控制指令。
/// 核心模式ROUTER (Dashboard) <--> DEALER (Service)
/// </summary>
public class CommandServer : IDisposable
{
// 单例模式
public static CommandServer Instance { get; } = new CommandServer();
// 事件:收到消息时触发 (ServiceId, MessageContent)
public event Action<string, string>? OnMessageReceived;
private RouterSocket? _routerSocket;
private NetMQPoller? _poller;
// 【关键新增】发送队列:用于解决跨线程发送的安全问题
// UI线程 -> Enqueue -> Poller线程 -> Socket.Send
private NetMQQueue<CommandPacket>? _sendQueue;
public int ListenPort { get; private set; }
public bool IsRunning => _poller != null && _poller.IsRunning;
// 在线设备表 (可选,用于记录谁在线)
// Key: ServiceId (Identity字符串)
private readonly ConcurrentDictionary<string, DateTime> _onlineClients = new();
private CommandServer() { }
public void Start(int port)
{
ListenPort = port;
if (IsRunning) return;
try
{
// 1. 初始化 Router Socket
_routerSocket = new RouterSocket();
_routerSocket.Bind($"tcp://*:{ListenPort}");
_routerSocket.ReceiveReady += OnSocketReady;
// 2. 初始化发送队列
_sendQueue = new NetMQQueue<CommandPacket>();
_sendQueue.ReceiveReady += OnQueueReady;
// 3. 启动 Poller (同时监听 Socket 接收 和 队列发送)
_poller = new NetMQPoller { _routerSocket, _sendQueue };
// RunAsync 会自动开启后台线程
_poller.RunAsync();
Console.WriteLine($"[Dashboard] 指令服务启动,监听: tcp://*:{ListenPort}");
}
catch (Exception ex)
{
Console.WriteLine($"[Dashboard] 指令端口绑定失败: {ex.Message}");
throw; // 必须抛出,让 App 感知
}
}
/// <summary>
/// 处理来自 Service 的网络消息 (运行在 Poller 线程)
/// </summary>
private void OnSocketReady(object? sender, NetMQSocketEventArgs e)
{
try
{
// 1. 读取身份帧 (Identity)
// 只要 Service 端 DealerSocket 设置了 Identity这里收到就是那个 ID
var identityBytes = e.Socket.ReceiveFrameBytes();
string serviceId = Encoding.UTF8.GetString(identityBytes);
// 2. 读取内容帧 (假设 Dealer 直接发内容,中间无空帧)
// 如果你使用了 REQ/REP 模式,中间可能会有空帧,需注意兼容
string message = e.Socket.ReceiveFrameString();
// 3. 简单的心跳保活逻辑
_onlineClients[serviceId] = DateTime.Now;
// 4. 触发业务事件
// 注意:这依然在 Poller 线程UI 处理时需 Invoke
Console.WriteLine($"[指令] From {serviceId}: {message}");
OnMessageReceived?.Invoke(serviceId, message);
}
catch (Exception ex)
{
Debug.WriteLine($"[Command Receive Error] {ex.Message}");
}
}
/// <summary>
/// 处理发送队列 (运行在 Poller 线程)
/// </summary>
private void OnQueueReady(object? sender, NetMQQueueEventArgs<CommandPacket> e)
{
try
{
if (_routerSocket == null) return;
// 从队列取出一个包
if (e.Queue.TryDequeue(out var packet, TimeSpan.Zero))
{
// Router 发送标准三步走:
// 1. 发送目标 Identity (More = true)
// 2. 发送空帧 (可选取决于协议约定Router-Dealer 直连通常不需要空帧)
// 3. 发送数据 (More = false)
// 这里我们采用最简协议:[Identity][Data]
_routerSocket.SendMoreFrame(packet.TargetId)
.SendFrame(packet.JsonData);
Console.WriteLine($"[指令] To {packet.TargetId}: {packet.JsonData}");
}
}
catch (Exception ex)
{
Debug.WriteLine($"[Command Send Error] {ex.Message}");
}
}
/// <summary>
/// 发送指令 (线程安全,可由 UI 线程调用)
/// </summary>
public void SendCommand(string targetServiceId, object commandData)
{
if (_sendQueue == null) return;
var json = JsonConvert.SerializeObject(commandData);
// ★★★ 核心修复:不直接操作 Socket而是入队 ★★★
_sendQueue.Enqueue(new CommandPacket
{
TargetId = targetServiceId,
JsonData = json
});
}
public void Dispose()
{
_poller?.Stop();
_poller?.Dispose();
_routerSocket?.Dispose();
_sendQueue?.Dispose();
_poller = null;
_routerSocket = null;
_sendQueue = null;
}
// 内部数据包结构
private class CommandPacket
{
public string TargetId { get; set; } = "";
public string JsonData { get; set; } = "";
}
}

View File

@@ -0,0 +1,37 @@
using System;
namespace SHH.CameraDashboard;
/// <summary>
/// [Dashboard端] Service 启动参数构建器
/// 职责:生成标准化的命令行参数字符串,告诉 Service 如何反向连接
/// </summary>
public static class ServiceLaunchOptions
{
/// <summary>
/// 生成启动参数
/// </summary>
/// <param name="serviceId">给子服务起的唯一ID (如 "CamService_01")</param>
/// <param name="dashboardIp">Dashboard 的 IP (通常是 127.0.0.1)</param>
/// <param name="streamPort">Dashboard 监听视频的端口 (如 6000)</param>
/// <param name="serviceApiPort">指定子服务 WebAPI 监听的端口 (如 5005)</param>
/// <returns>命令行参数字符串</returns>
public static string BuildArguments(string serviceId, string dashboardIp, int streamPort, int serviceApiPort)
{
// 获取当前 Dashboard 进程 ID传给子进程做“父进程守护”
int parentPid = Environment.ProcessId;
// 拼接参数:
// --pid: 父进程ID
// --uris: 反向连接的目标地址 (Dashboard 的地址)
// --mode: 1 (Active模式代表 Service 主动连接 Dashboard)
// --ports: Service 自身的 WebAPI 端口 (防止与 Dashboard 冲突)
return $"" +
$"--pid {parentPid} " +
$"--id \"{serviceId}\" " +
$"--uris \"{dashboardIp},{streamPort}\" " +
$"--mode 1 " +
$"--ports \"{serviceApiPort},100\""; // 100 是保留位,暂不用
}
}

View File

@@ -0,0 +1,133 @@
using NetMQ;
using NetMQ.Sockets;
using System.Diagnostics; // 用于 Debug 输出
namespace SHH.CameraDashboard.Services;
public class StreamReceiverService : IDisposable
{
// 单例模式
public static StreamReceiverService Instance { get; } = new StreamReceiverService();
public event Action<string, byte[]>? OnFrameReceived;
private SubscriberSocket? _subSocket;
private Task? _receiveTask;
// 【修复1】不要在这里初始化改为在 Start 中初始化
private CancellationTokenSource? _cts;
public int ListenPort { get; private set; }
// 增加运行状态标记
public bool IsRunning => _receiveTask != null && !_receiveTask.IsCompleted;
private StreamReceiverService() { }
public void Start(int port = 6000)
{
// 1. 防止重复启动
if (IsRunning) return;
ListenPort = port;
// 【修复1】每次启动时创建新的 TokenSource
_cts = new CancellationTokenSource();
try
{
// 2. 初始化 Socket
_subSocket = new SubscriberSocket();
// 【优化】设置高水位限制 (HWM)
// 如果 UI 处理不过来,积压超过 1000 帧直接丢弃,防止内存爆炸
_subSocket.Options.ReceiveHighWatermark = 1000;
string bindAddr = $"tcp://*:{ListenPort}";
_subSocket.Bind(bindAddr);
_subSocket.Subscribe("");
Console.WriteLine($"[Dashboard] 视频流接收服务启动: {bindAddr}");
}
catch (Exception ex)
{
Console.WriteLine($"[Dashboard] 致命错误 - 端口绑定失败: {ex.Message}");
// 清理资源
_subSocket?.Dispose();
_subSocket = null;
// 【修复4】抛出异常让上层知道启动失败了
throw new Exception($"端口 {port} 绑定失败,可能被占用。", ex);
}
// 3. 启动任务
_receiveTask = Task.Run(ReceiveLoop, _cts.Token);
}
private void ReceiveLoop()
{
var token = _cts?.Token ?? CancellationToken.None;
while (!token.IsCancellationRequested)
{
try
{
// 【修复2】线程安全检查
if (_subSocket == null) break;
// 接收 Topic
if (!_subSocket.TryReceiveFrameString(TimeSpan.FromMilliseconds(500), out string cameraId))
continue;
// 接收 Payload
if (!_subSocket.TryReceiveFrameBytes(TimeSpan.FromMilliseconds(100), out byte[] jpgBytes))
continue;
// 触发事件
OnFrameReceived?.Invoke(cameraId, jpgBytes);
}
catch (ObjectDisposedException)
{
// 【修复2】这是正常的退出流程Socket被Dispose了优雅退出循环
break;
}
catch (Exception ex)
{
// 记录日志,但不崩溃
Debug.WriteLine($"[ReceiverLoop Error] {ex.Message}");
}
}
Console.WriteLine("[Dashboard] 接收循环已停止");
}
/// <summary>
/// 停止服务(支持停止后重新 Start
/// </summary>
public void Stop()
{
// 1. 发出取消信号
if (_cts != null && !_cts.IsCancellationRequested)
{
_cts.Cancel();
}
// 2. 销毁 Socket (这会触发 ReceiveLoop 中的 ObjectDisposedException 从而退出循环)
if (_subSocket != null)
{
try { _subSocket.Dispose(); } catch { }
_subSocket = null;
}
// 3. 清理 Token
_cts?.Dispose();
_cts = null;
_receiveTask = null;
}
public void Dispose()
{
Stop();
}
}