From 839a980b45707fa7f7f0402046d59058f03aee02 Mon Sep 17 00:00:00 2001 From: tongtongstudio Date: Wed, 15 Jul 2026 18:15:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=9E=E6=8E=A5=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=9B=B4=E5=A4=9A=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ttstd/controller/MainActivity.java | 172 +++++++++++++++++- .../ttstd/controller/webrtc/WebRtcClient.java | 5 + .../lib/webrtc/webrtc_controller.dart | 169 ++++++++++++++++- 3 files changed, 339 insertions(+), 7 deletions(-) diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java b/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java index becfb5b..312d8d3 100644 --- a/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java +++ b/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java @@ -64,6 +64,14 @@ public class MainActivity extends AppCompatActivity { } }; + // 网速统计基线(累计字节数与上次采样时间戳) + private long prevBytesReceived = 0; + private long prevBytesSent = 0; + private long prevStatsTime = 0; + + // 连接建立时的时间戳(毫秒),用于计算连接时长。 + private long connectionStartTime = 0; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -239,6 +247,7 @@ public class MainActivity extends AppCompatActivity { webRtcClient.setConnectionListener(new WebRtcClient.ConnectionListener() { @Override public void onConnectionEstablished() { + connectionStartTime = System.currentTimeMillis(); runOnUiThread(() -> { tvStatus.setText("状态: 已连接 - 远程控制中"); updateUI(true); @@ -394,6 +403,11 @@ public class MainActivity extends AppCompatActivity { wsClient.disconnect(); wsClient = null; } + // 重置网速统计基线,避免下次连接首帧显示错误速率。 + prevBytesReceived = 0; + prevBytesSent = 0; + prevStatsTime = 0; + connectionStartTime = 0; remoteVideoView.clearImage(); updateUI(false); tvStatus.setText("状态: 已停止"); @@ -422,8 +436,25 @@ public class MainActivity extends AppCompatActivity { String codecName = "-"; double avgDecodeTimeMs = 0; + // 网速 / 连接类型相关 + long bytesReceived = 0; + long bytesSent = 0; + String localCandidateId = null; + String remoteCandidateId = null; + boolean hasNominatedPair = false; + + // 抖动 / 丢包 / 丢帧 + double jitterMs = 0; + String lossRate = "-"; + long framesDropped = 0; + + // 控制 DataChannel 收发消息数 + long dcMessagesSent = 0; + long dcMessagesReceived = 0; + for (org.webrtc.RTCStats stats : report.getStatsMap().values()) { - if ("inbound-rtp".equals(stats.getType()) && "video".equals(stats.getMembers().get("kind"))) { + String type = stats.getType(); + if ("inbound-rtp".equals(type) && "video".equals(stats.getMembers().get("kind"))) { // 分辨率 Object w = stats.getMembers().get("frameWidth"); if (w instanceof Number) width = ((Number) w).longValue(); @@ -466,20 +497,104 @@ public class MainActivity extends AppCompatActivity { String d = (String) decoder; codecName = codecName.equals("-") ? d : codecName + " (" + d + ")"; } + + // 抖动(秒 -> 毫秒) + Object jitter = stats.getMembers().get("jitter"); + if (jitter instanceof Number) { + jitterMs = ((Number) jitter).doubleValue() * 1000; + } + + // 丢包率 + Object pr = stats.getMembers().get("packetsReceived"); + Object pl = stats.getMembers().get("packetsLost"); + if (pr instanceof Number && pl instanceof Number) { + long total = ((Number) pr).longValue() + ((Number) pl).longValue(); + lossRate = total > 0 + ? String.format(Locale.getDefault(), "%.1f", ((Number) pl).doubleValue() / total * 100) + : "0.0"; + } + + // 丢帧 + Object fd = stats.getMembers().get("framesDropped"); + if (fd instanceof Number) framesDropped = ((Number) fd).longValue(); } - if ("candidate-pair".equals(stats.getType())) { + if ("candidate-pair".equals(type)) { Object nominated = stats.getMembers().get("nominated"); if (Boolean.TRUE.equals(nominated)) { + hasNominatedPair = true; Object rtt = stats.getMembers().get("currentRoundTripTime"); if (rtt instanceof Number) { delay = (long) (((Number) rtt).doubleValue() * 1000); } + // candidate-pair 的 bytesReceived/bytesSent 为整条连接 + // (视频 + DataChannel + ICE)的累计值,用于计算每秒网速。 + Object br = stats.getMembers().get("bytesReceived"); + if (br instanceof Number) bytesReceived = ((Number) br).longValue(); + Object bs = stats.getMembers().get("bytesSent"); + if (bs instanceof Number) bytesSent = ((Number) bs).longValue(); + Object lc = stats.getMembers().get("localCandidateId"); + if (lc instanceof String) localCandidateId = (String) lc; + Object rc = stats.getMembers().get("remoteCandidateId"); + if (rc instanceof String) remoteCandidateId = (String) rc; + } + } + if ("data-channel".equals(type)) { + // 仅统计与对端的控制通道 + Object label = stats.getMembers().get("label"); + if ("control_channel".equals(label)) { + Object ms = stats.getMembers().get("messagesSent"); + if (ms instanceof Number) dcMessagesSent = ((Number) ms).longValue(); + Object mr = stats.getMembers().get("messagesReceived"); + if (mr instanceof Number) dcMessagesReceived = ((Number) mr).longValue(); } } } + + // 计算每秒上下行网速(与上次采样差值)。 + long now = System.currentTimeMillis(); + String downSpeed = "-"; + String upSpeed = "-"; + if (prevStatsTime != 0) { + double dt = (now - prevStatsTime) / 1000.0; + if (dt > 0) { + double downBps = (bytesReceived - prevBytesReceived) / dt; + double upBps = (bytesSent - prevBytesSent) / dt; + downSpeed = formatSpeed(downBps); + upSpeed = formatSpeed(upBps); + } + } + prevBytesReceived = bytesReceived; + prevBytesSent = bytesSent; + prevStatsTime = now; + + // 判定连接类型与传输协议:两端候选任一为 relay 即走 TURN 中继,否则为 P2P 直连。 + String connType = "-"; + String protocol = "-"; + if (hasNominatedPair) { + connType = resolveConnectionType(report, localCandidateId, remoteCandidateId); + protocol = resolveProtocol(report, localCandidateId, remoteCandidateId); + } + + // 连接时长(mm:ss) + String duration = "-"; + if (connectionStartTime != 0) { + long secs = (now - connectionStartTime) / 1000; + duration = String.format(Locale.getDefault(), "%02d:%02d", secs / 60, secs % 60); + } + + // 控制 DataChannel 状态 + String dcStatus = (webRtcClient != null && webRtcClient.isDataChannelOpen()) + ? "已连接" : "未连接"; + final String statsText = String.format(Locale.getDefault(), - "分辨率: %dx%d 帧率: %d 延迟: %dms\n解码格式: %s 平均解码耗时: %.1fms", - width, height, fps, delay, codecName, avgDecodeTimeMs); + "分辨率: %dx%d 帧率: %d 延迟: %dms\n" + + "解码格式: %s 抖动: %.0fms 丢包: %s%% 解码耗时: %.1fms\n" + + "丢帧: %d ↓下载: %s ↑上传: %s\n" + + "连接类型: %s (%s) 时长: %s\n" + + "控制通道: %s 收发: %d/%d", + width, height, fps, delay, codecName, jitterMs, lossRate, avgDecodeTimeMs, + framesDropped, downSpeed, upSpeed, connType, protocol, + duration, dcStatus, dcMessagesSent, dcMessagesReceived); runOnUiThread(() -> { if (tvStats != null) { tvStats.setText(statsText); @@ -489,6 +604,55 @@ public class MainActivity extends AppCompatActivity { } } + // 依据选定候选对的本地/远端候选类型,返回 P2P / TURN 中继描述。 + private String resolveConnectionType(org.webrtc.RTCStatsReport report, + String localCandidateId, + String remoteCandidateId) { + String local = candidateTypeOf(report, localCandidateId); + String remote = candidateTypeOf(report, remoteCandidateId); + boolean isRelay = "relay".equals(local) || "relay".equals(remote); + if (isRelay) { + if ("relay".equals(local)) return "TURN 中继 (本端经服务器)"; + return "TURN 中继"; + } + // host: 同一局域网直连; srflx/prflx: 经 NAT 打洞的 P2P。 + return "P2P 直连"; + } + + // 返回选定候选对的传输协议(udp/tcp)。 + private String resolveProtocol(org.webrtc.RTCStatsReport report, + String localCandidateId, + String remoteCandidateId) { + String protoOf = candidateMemberOf(report, localCandidateId, "protocol"); + String protoOfRemote = candidateMemberOf(report, remoteCandidateId, "protocol"); + if (!"-".equals(protoOf) && !"-".equals(protoOfRemote) && !protoOf.equals(protoOfRemote)) { + return protoOf + "/" + protoOfRemote; + } + return !"-".equals(protoOf) ? protoOf : protoOfRemote; + } + + private String candidateTypeOf(org.webrtc.RTCStatsReport report, String candidateId) { + return candidateMemberOf(report, candidateId, "candidateType"); + } + + private String candidateMemberOf(org.webrtc.RTCStatsReport report, String candidateId, String key) { + if (candidateId == null) return "-"; + org.webrtc.RTCStats candidate = report.getStatsMap().get(candidateId); + if (candidate == null) return "-"; + Object value = candidate.getMembers().get(key); + return value instanceof String ? (String) value : "-"; + } + + // 将字节/秒格式化为人类可读字符串(B/s、KB/s、MB/s)。 + private String formatSpeed(double bytesPerSecond) { + if (bytesPerSecond >= 1024 * 1024) { + return String.format(Locale.getDefault(), "%.1f MB/s", bytesPerSecond / (1024 * 1024)); + } else if (bytesPerSecond >= 1024) { + return String.format(Locale.getDefault(), "%.1f KB/s", bytesPerSecond / 1024); + } + return String.format(Locale.getDefault(), "%.0f B/s", bytesPerSecond); + } + @Override protected void onDestroy() { super.onDestroy(); diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java b/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java index 667b2fb..3e6a361 100644 --- a/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java +++ b/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java @@ -319,4 +319,9 @@ public class WebRtcClient { peerConnection.getStats(callback); } } + + /// 控制 DataChannel 是否已打开(用于状态显示)。 + public boolean isDataChannelOpen() { + return dataChannel != null && dataChannel.state() == DataChannel.State.OPEN; + } } diff --git a/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart b/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart index d42e1a7..66d9ff6 100644 --- a/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart +++ b/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart @@ -146,6 +146,7 @@ class WebRtcController { // ignore: avoid_print print('ICE connection state: $state'); if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) { + _connectedAt = DateTime.now().millisecondsSinceEpoch; onConnectionEstablished?.call(); } else if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) { onIceDisconnected?.call('ICE 连接已断开'); @@ -191,7 +192,19 @@ class WebRtcController { } } + /// 上一次统计采样的字节数与时间戳,用于计算每秒网速。 + int _prevBytesReceived = 0; + int _prevBytesSent = 0; + int _prevStatsTimestamp = 0; + + /// 连接建立时的时间戳(毫秒),用于计算连接时长。 + int? _connectedAt; + /// 采集连接统计信息,回调格式化后的文本(对应 Android 端 updateStats)。 + /// + /// 包含:分辨率/帧率/延迟/解码格式、抖动、丢包率、丢帧、 + /// 每秒上下行网速、连接类型(P2P/TURN)+候选协议、连接时长、 + /// 控制 DataChannel 状态与收发消息数。 Future getStatsText() async { if (_pc == null) return ''; final reports = await _pc!.getStats(); @@ -201,14 +214,41 @@ class WebRtcController { dynamic fps = '-'; dynamic delay = '-'; dynamic codec = '-'; + dynamic jitter = '-'; + dynamic lossRate = '-'; + dynamic framesDropped = '-'; + + int bytesReceived = 0; + int bytesSent = 0; + String? localCandidateId; + String? remoteCandidateId; + bool hasNominatedPair = false; + + // 控制 DataChannel 统计(仅统计与对端的控制通道)。 + int dcMessagesSent = 0; + int dcMessagesReceived = 0; for (final report in reports) { final values = report.values; - if (report.type == 'inbound-rtp' && values['kind'] == 'video') { + final type = report.type; + if (type == 'inbound-rtp' && values['kind'] == 'video') { width = values['frameWidth'] ?? '-'; height = values['frameHeight'] ?? '-'; fps = values['framesPerSecond'] ?? '-'; + final j = values['jitter']; + if (j is num) jitter = (j * 1000).toStringAsFixed(0); + + final pr = values['packetsReceived']; + final pl = values['packetsLost']; + if (pr is num && pl is num) { + final total = pr + pl; + lossRate = total > 0 ? ((pl / total) * 100).toStringAsFixed(1) : '0.0'; + } + + final fd = values['framesDropped']; + if (fd is num) framesDropped = fd; + final codecId = values['codecId']; if (codecId != null) { final codecReport = reports.where((r) => r.id == codecId).firstOrNull; @@ -217,18 +257,136 @@ class WebRtcController { codec = mime.substring(6); } } - } else if (report.type == 'candidate-pair') { + } else if (type == 'candidate-pair') { if (values['nominated'] == true) { + hasNominatedPair = true; final rtt = values['currentRoundTripTime']; if (rtt is num) { delay = (rtt * 1000).toStringAsFixed(0); } + // candidate-pair 的 bytesReceived/bytesSent 为整条连接(视频+DataChannel+ICE)的累计值。 + final br = values['bytesReceived']; + final bs = values['bytesSent']; + if (br is num) bytesReceived = br.toInt(); + if (bs is num) bytesSent = bs.toInt(); + final lc = values['localCandidateId']; + final rc = values['remoteCandidateId']; + if (lc is String) localCandidateId = lc; + if (rc is String) remoteCandidateId = rc; + } + } else if (type == 'data-channel') { + if (values['label'] == kDataChannelLabel) { + final ms = values['messagesSent']; + final mr = values['messagesReceived']; + if (ms is num) dcMessagesSent = ms.toInt(); + if (mr is num) dcMessagesReceived = mr.toInt(); } } } + // 计算每秒网速(与上次采样差值)。 + final now = DateTime.now().millisecondsSinceEpoch; + String downSpeed = '-'; + String upSpeed = '-'; + if (_prevStatsTimestamp != 0) { + final dt = (now - _prevStatsTimestamp) / 1000.0; + if (dt > 0) { + final downBps = (bytesReceived - _prevBytesReceived) / dt; + final upBps = (bytesSent - _prevBytesSent) / dt; + downSpeed = _formatSpeed(downBps); + upSpeed = _formatSpeed(upBps); + } + } + _prevBytesReceived = bytesReceived; + _prevBytesSent = bytesSent; + _prevStatsTimestamp = now; + + // 连接类型(P2P/TURN)与候选协议(udp/tcp)。 + String connType = '-'; + String protocol = '-'; + if (hasNominatedPair) { + connType = + _resolveConnectionType(reports, localCandidateId, remoteCandidateId); + protocol = _resolveProtocol(reports, localCandidateId, remoteCandidateId); + } + + // 连接时长。 + String duration = '-'; + if (_connectedAt != null) { + final secs = ((now - _connectedAt!) / 1000).floor(); + final m = (secs ~/ 60).toString().padLeft(2, '0'); + final s = (secs % 60).toString().padLeft(2, '0'); + duration = '$m:$s'; + } + + // 控制 DataChannel 状态。 + final dcState = _dataChannel?.state; + final dcStatus = dcState == RTCDataChannelState.RTCDataChannelOpen + ? '已连接' + : (dcState == null ? '未连接' : dcState.name); + return '分辨率: ${width}x$height 帧率: $fps 延迟: $delay ms\n' - '解码格式: $codec'; + '解码格式: $codec 抖动: $jitter ms 丢包: $lossRate%\n' + '丢帧: $framesDropped ↓下载: $downSpeed ↑上传: $upSpeed\n' + '连接类型: $connType ($protocol) 时长: $duration\n' + '控制通道: $dcStatus 收发: $dcMessagesSent/$dcMessagesReceived'; + } + + /// 依据选定候选对的本地/远端候选类型,返回 P2P / TURN 中继描述。 + String _resolveConnectionType( + List reports, + String? localCandidateId, + String? remoteCandidateId, + ) { + String? typeOf(String? id) { + if (id == null) return null; + final report = reports.where((r) => r.id == id).firstOrNull; + final t = report?.values['candidateType']; + return t is String ? t : null; + } + + final local = typeOf(localCandidateId); + final remote = typeOf(remoteCandidateId); + final isRelay = local == 'relay' || remote == 'relay'; + + if (isRelay) { + // 进一步用本地候选具体类型细化(优先说明本端是否经 TURN 中继)。 + if (local == 'relay') return 'TURN 中继 (本端经服务器)'; + return 'TURN 中继'; + } + // host: 同一局域网直连; srflx/prflx: 经 NAT 打洞的 P2P。 + return 'P2P 直连'; + } + + /// 返回选定候选对的传输协议(udp/tcp)。 + String _resolveProtocol( + List reports, + String? localCandidateId, + String? remoteCandidateId, + ) { + String protoOf(String? id) { + if (id == null) return '-'; + final report = reports.where((r) => r.id == id).firstOrNull; + final p = report?.values['protocol']; + return p is String ? p : '-'; + } + + final local = protoOf(localCandidateId); + final remote = protoOf(remoteCandidateId); + if (local != '-' && remote != '-' && local != remote) { + return '$local/$remote'; + } + return local != '-' ? local : remote; + } + + /// 将字节/秒格式化为人类可读字符串(B/s、KB/s、MB/s)。 + String _formatSpeed(double bytesPerSecond) { + if (bytesPerSecond >= 1024 * 1024) { + return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)} MB/s'; + } else if (bytesPerSecond >= 1024) { + return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s'; + } + return '${bytesPerSecond.toStringAsFixed(0)} B/s'; } /// 释放所有资源。 @@ -244,5 +402,10 @@ class WebRtcController { } catch (_) {} _dataChannel = null; _pc = null; + // 重置网速统计基线,避免下次连接首帧显示错误的瞬时速率。 + _prevBytesReceived = 0; + _prevBytesSent = 0; + _prevStatsTimestamp = 0; + _connectedAt = null; } }