feat: 连接显示更多信息

This commit is contained in:
2026-07-15 18:15:18 +08:00
parent 97ef11a025
commit 839a980b45
3 changed files with 339 additions and 7 deletions

View File

@@ -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 @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -239,6 +247,7 @@ public class MainActivity extends AppCompatActivity {
webRtcClient.setConnectionListener(new WebRtcClient.ConnectionListener() { webRtcClient.setConnectionListener(new WebRtcClient.ConnectionListener() {
@Override @Override
public void onConnectionEstablished() { public void onConnectionEstablished() {
connectionStartTime = System.currentTimeMillis();
runOnUiThread(() -> { runOnUiThread(() -> {
tvStatus.setText("状态: 已连接 - 远程控制中"); tvStatus.setText("状态: 已连接 - 远程控制中");
updateUI(true); updateUI(true);
@@ -394,6 +403,11 @@ public class MainActivity extends AppCompatActivity {
wsClient.disconnect(); wsClient.disconnect();
wsClient = null; wsClient = null;
} }
// 重置网速统计基线,避免下次连接首帧显示错误速率。
prevBytesReceived = 0;
prevBytesSent = 0;
prevStatsTime = 0;
connectionStartTime = 0;
remoteVideoView.clearImage(); remoteVideoView.clearImage();
updateUI(false); updateUI(false);
tvStatus.setText("状态: 已停止"); tvStatus.setText("状态: 已停止");
@@ -422,8 +436,25 @@ public class MainActivity extends AppCompatActivity {
String codecName = "-"; String codecName = "-";
double avgDecodeTimeMs = 0; 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()) { 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"); Object w = stats.getMembers().get("frameWidth");
if (w instanceof Number) width = ((Number) w).longValue(); if (w instanceof Number) width = ((Number) w).longValue();
@@ -466,20 +497,104 @@ public class MainActivity extends AppCompatActivity {
String d = (String) decoder; String d = (String) decoder;
codecName = codecName.equals("-") ? d : codecName + " (" + d + ")"; codecName = codecName.equals("-") ? d : codecName + " (" + d + ")";
} }
// 抖动(秒 -> 毫秒)
Object jitter = stats.getMembers().get("jitter");
if (jitter instanceof Number) {
jitterMs = ((Number) jitter).doubleValue() * 1000;
} }
if ("candidate-pair".equals(stats.getType())) {
// 丢包率
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(type)) {
Object nominated = stats.getMembers().get("nominated"); Object nominated = stats.getMembers().get("nominated");
if (Boolean.TRUE.equals(nominated)) { if (Boolean.TRUE.equals(nominated)) {
hasNominatedPair = true;
Object rtt = stats.getMembers().get("currentRoundTripTime"); Object rtt = stats.getMembers().get("currentRoundTripTime");
if (rtt instanceof Number) { if (rtt instanceof Number) {
delay = (long) (((Number) rtt).doubleValue() * 1000); 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(), final String statsText = String.format(Locale.getDefault(),
"分辨率: %dx%d 帧率: %d 延迟: %dms\n解码格式: %s 平均解码耗时: %.1fms", "分辨率: %dx%d 帧率: %d 延迟: %dms\n"
width, height, fps, delay, codecName, avgDecodeTimeMs); + "解码格式: %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(() -> { runOnUiThread(() -> {
if (tvStats != null) { if (tvStats != null) {
tvStats.setText(statsText); 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 @Override
protected void onDestroy() { protected void onDestroy() {
super.onDestroy(); super.onDestroy();

View File

@@ -319,4 +319,9 @@ public class WebRtcClient {
peerConnection.getStats(callback); peerConnection.getStats(callback);
} }
} }
/// 控制 DataChannel 是否已打开(用于状态显示)。
public boolean isDataChannelOpen() {
return dataChannel != null && dataChannel.state() == DataChannel.State.OPEN;
}
} }

View File

@@ -146,6 +146,7 @@ class WebRtcController {
// ignore: avoid_print // ignore: avoid_print
print('ICE connection state: $state'); print('ICE connection state: $state');
if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) { if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) {
_connectedAt = DateTime.now().millisecondsSinceEpoch;
onConnectionEstablished?.call(); onConnectionEstablished?.call();
} else if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) { } else if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) {
onIceDisconnected?.call('ICE 连接已断开'); onIceDisconnected?.call('ICE 连接已断开');
@@ -191,7 +192,19 @@ class WebRtcController {
} }
} }
/// 上一次统计采样的字节数与时间戳,用于计算每秒网速。
int _prevBytesReceived = 0;
int _prevBytesSent = 0;
int _prevStatsTimestamp = 0;
/// 连接建立时的时间戳(毫秒),用于计算连接时长。
int? _connectedAt;
/// 采集连接统计信息,回调格式化后的文本(对应 Android 端 updateStats /// 采集连接统计信息,回调格式化后的文本(对应 Android 端 updateStats
///
/// 包含:分辨率/帧率/延迟/解码格式、抖动、丢包率、丢帧、
/// 每秒上下行网速、连接类型(P2P/TURN)+候选协议、连接时长、
/// 控制 DataChannel 状态与收发消息数。
Future<String> getStatsText() async { Future<String> getStatsText() async {
if (_pc == null) return ''; if (_pc == null) return '';
final reports = await _pc!.getStats(); final reports = await _pc!.getStats();
@@ -201,14 +214,41 @@ class WebRtcController {
dynamic fps = '-'; dynamic fps = '-';
dynamic delay = '-'; dynamic delay = '-';
dynamic codec = '-'; 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) { for (final report in reports) {
final values = report.values; 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'] ?? '-'; width = values['frameWidth'] ?? '-';
height = values['frameHeight'] ?? '-'; height = values['frameHeight'] ?? '-';
fps = values['framesPerSecond'] ?? '-'; 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']; final codecId = values['codecId'];
if (codecId != null) { if (codecId != null) {
final codecReport = reports.where((r) => r.id == codecId).firstOrNull; final codecReport = reports.where((r) => r.id == codecId).firstOrNull;
@@ -217,18 +257,136 @@ class WebRtcController {
codec = mime.substring(6); codec = mime.substring(6);
} }
} }
} else if (report.type == 'candidate-pair') { } else if (type == 'candidate-pair') {
if (values['nominated'] == true) { if (values['nominated'] == true) {
hasNominatedPair = true;
final rtt = values['currentRoundTripTime']; final rtt = values['currentRoundTripTime'];
if (rtt is num) { if (rtt is num) {
delay = (rtt * 1000).toStringAsFixed(0); 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' 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<StatsReport> 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<StatsReport> 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 (_) {} } catch (_) {}
_dataChannel = null; _dataChannel = null;
_pc = null; _pc = null;
// 重置网速统计基线,避免下次连接首帧显示错误的瞬时速率。
_prevBytesReceived = 0;
_prevBytesSent = 0;
_prevStatsTimestamp = 0;
_connectedAt = null;
} }
} }