feat(webrtc): 新增解码耗时统计并修复黑屏问题
1. 新增自编码与全托管模式的解码耗时探针(VideoSink),统计帧率与解码耗时。 2. 修复 SPS 与 PPS 作为独立 CONFIG 单元发送时被误丢弃导致 H.264 永久黑屏的问题。 3. 优化分片重组缓冲,丢弃过期单元并限制缓冲堆积以防内存泄漏。
This commit is contained in:
@@ -16,6 +16,10 @@ import io.flutter.view.TextureRegistry
|
|||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
|
import org.webrtc.VideoFrame
|
||||||
|
import org.webrtc.VideoSink
|
||||||
|
import org.webrtc.VideoTrack
|
||||||
|
import com.cloudwebrtc.webrtc.FlutterWebRTCPlugin
|
||||||
|
|
||||||
private const val CHANNEL = "com.ttstd.fluttercontroller/self_codec"
|
private const val CHANNEL = "com.ttstd.fluttercontroller/self_codec"
|
||||||
private const val TAG = "SelfCodecDecoderPlugin"
|
private const val TAG = "SelfCodecDecoderPlugin"
|
||||||
@@ -27,6 +31,8 @@ private const val UNIT_TYPE_CONFIG = 1
|
|||||||
private const val UNIT_TYPE_FRAME = 2
|
private const val UNIT_TYPE_FRAME = 2
|
||||||
private const val PLACEHOLDER_W = 1920
|
private const val PLACEHOLDER_W = 1920
|
||||||
private const val PLACEHOLDER_H = 1080
|
private const val PLACEHOLDER_H = 1080
|
||||||
|
// 解码耗时滑动平均窗口(帧数)。
|
||||||
|
private const val DECODE_STATS_WINDOW = 90
|
||||||
|
|
||||||
/// 单个单元的分片重组缓冲。
|
/// 单个单元的分片重组缓冲。
|
||||||
private class ChunkBuffer(val seq: Int, val total: Int) {
|
private class ChunkBuffer(val seq: Int, val total: Int) {
|
||||||
@@ -52,6 +58,80 @@ private class ChunkBuffer(val seq: Int, val total: Int) {
|
|||||||
|
|
||||||
private class Frame(val data: ByteArray, val pts: Long, val key: Boolean)
|
private class Frame(val data: ByteArray, val pts: Long, val key: Boolean)
|
||||||
|
|
||||||
|
/// WebRTC 全托管模式下的解码耗时探针:给远端视频轨道额外挂一个 [VideoSink],
|
||||||
|
/// 逐帧记录真实到达时间,计算帧间耗时(≈真实解码+渲染周期)的 last/avg/peak 与帧率。
|
||||||
|
/// 与自编码解码器不同,这里拿不到 MediaCodec 的单帧解码开销,只能测"连续两帧被解码
|
||||||
|
/// 交付"的真实时间间隔——它是原生层实测值,不会像 totalDecodeTime 那样恒为 0。
|
||||||
|
private class DecodeProbeSink {
|
||||||
|
private val lock = Any()
|
||||||
|
private var lastArrivalNs: Long = 0
|
||||||
|
private var sumMs = 0.0
|
||||||
|
private var count = 0
|
||||||
|
private var lastMs = 0.0
|
||||||
|
private var avgMs = 0.0
|
||||||
|
private var peakMs = 0.0
|
||||||
|
private var lastFpsStampMs = 0L
|
||||||
|
private var framesSinceFps = 0
|
||||||
|
private var fpsNow = 0
|
||||||
|
|
||||||
|
fun onFrame() {
|
||||||
|
val now = System.nanoTime()
|
||||||
|
synchronized(lock) {
|
||||||
|
if (lastArrivalNs != 0L) {
|
||||||
|
val deltaMs = (now - lastArrivalNs) / 1_000_000.0
|
||||||
|
// 丢弃异常跳变(首帧 / 长时间卡顿后的大间隔),避免污染统计。
|
||||||
|
if (deltaMs in 0.5..1000.0) {
|
||||||
|
lastMs = deltaMs
|
||||||
|
if (peakMs < deltaMs) peakMs = deltaMs
|
||||||
|
count++
|
||||||
|
sumMs += deltaMs
|
||||||
|
avgMs = sumMs / count
|
||||||
|
// 1 秒滑动窗口估算当前帧率。
|
||||||
|
val nowMs = System.currentTimeMillis()
|
||||||
|
framesSinceFps++
|
||||||
|
if (lastFpsStampMs != 0L && nowMs - lastFpsStampMs >= 1000) {
|
||||||
|
fpsNow = framesSinceFps * 1000 / (nowMs - lastFpsStampMs)
|
||||||
|
framesSinceFps = 0
|
||||||
|
lastFpsStampMs = nowMs
|
||||||
|
} else if (lastFpsStampMs == 0L) {
|
||||||
|
lastFpsStampMs = nowMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastArrivalNs = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshot(): Map<String, Any> = synchronized(lock) {
|
||||||
|
mapOf(
|
||||||
|
"lastMs" to lastMs,
|
||||||
|
"avgMs" to avgMs,
|
||||||
|
"peakMs" to peakMs,
|
||||||
|
"totalFrames" to count,
|
||||||
|
"fpsNow" to fpsNow,
|
||||||
|
"fpsAvg" to if (avgMs > 0) (1000.0 / avgMs).toInt() else 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() = synchronized(lock) {
|
||||||
|
lastArrivalNs = 0
|
||||||
|
sumMs = 0.0
|
||||||
|
count = 0
|
||||||
|
lastMs = 0.0
|
||||||
|
avgMs = 0.0
|
||||||
|
peakMs = 0.0
|
||||||
|
lastFpsStampMs = 0L
|
||||||
|
framesSinceFps = 0
|
||||||
|
fpsNow = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ProbeHolder(
|
||||||
|
val track: VideoTrack,
|
||||||
|
val sink: VideoSink,
|
||||||
|
val stats: DecodeProbeSink
|
||||||
|
)
|
||||||
|
|
||||||
/// 自编码解码器(控制端原生实现)。
|
/// 自编码解码器(控制端原生实现)。
|
||||||
///
|
///
|
||||||
/// 对应 WebRTCController 的 SelfCodecDecoder.java:从 `video` DataChannel 接收
|
/// 对应 WebRTCController 的 SelfCodecDecoder.java:从 `video` DataChannel 接收
|
||||||
@@ -63,6 +143,10 @@ class SelfCodecDecoderPlugin :
|
|||||||
private var methodChannel: MethodChannel? = null
|
private var methodChannel: MethodChannel? = null
|
||||||
private var textureRegistry: TextureRegistry? = null
|
private var textureRegistry: TextureRegistry? = null
|
||||||
|
|
||||||
|
/// WebRTC 全托管模式的解码耗时探针(独立通道,复用同一 MethodCallHandler)。
|
||||||
|
private var probeChannel: MethodChannel? = null
|
||||||
|
private var probeHolder: ProbeHolder? = null
|
||||||
|
|
||||||
private var textureEntry: TextureRegistry.SurfaceTextureEntry? = null
|
private var textureEntry: TextureRegistry.SurfaceTextureEntry? = null
|
||||||
private var surfaceTexture: SurfaceTexture? = null
|
private var surfaceTexture: SurfaceTexture? = null
|
||||||
private var surface: Surface? = null
|
private var surface: Surface? = null
|
||||||
@@ -77,17 +161,34 @@ class SelfCodecDecoderPlugin :
|
|||||||
private val frameQueue = ArrayList<Frame>()
|
private val frameQueue = ArrayList<Frame>()
|
||||||
private val inputQueue = ArrayList<Int>()
|
private val inputQueue = ArrayList<Int>()
|
||||||
|
|
||||||
|
// 已完成重组的最大单元序号,用于丢弃明显过期的旧单元。
|
||||||
|
private var lastSeq = -1
|
||||||
|
|
||||||
|
// —— 解码耗时统计 ——
|
||||||
|
// 记录每帧从 queueInputBuffer 到 onOutputBufferAvailable 的时延(毫秒)。
|
||||||
|
private val statsLock = Any()
|
||||||
|
private val decodeStartNs = HashMap<Long, Long>() // pts(us) -> 入队 nanoTime
|
||||||
|
private val recentDecodeMs = ArrayDeque<Double>() // 最近若干帧耗时,用于滑动平均
|
||||||
|
private var lastDecodeMs = 0.0
|
||||||
|
private var maxDecodeMs = 0.0
|
||||||
|
private var decodedFrames = 0L
|
||||||
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
textureRegistry = binding.textureRegistry
|
textureRegistry = binding.textureRegistry
|
||||||
methodChannel = MethodChannel(binding.binaryMessenger, CHANNEL)
|
methodChannel = MethodChannel(binding.binaryMessenger, CHANNEL)
|
||||||
methodChannel?.setMethodCallHandler(this)
|
methodChannel?.setMethodCallHandler(this)
|
||||||
|
// 解码耗时探针(WebRTC 全托管模式):独立通道,复用同一 handler。
|
||||||
|
probeChannel = MethodChannel(binding.binaryMessenger, "webrtc_decode_probe")
|
||||||
|
probeChannel?.setMethodCallHandler(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
methodChannel?.setMethodCallHandler(null)
|
methodChannel?.setMethodCallHandler(null)
|
||||||
methodChannel = null
|
methodChannel = null
|
||||||
|
probeChannel?.setMethodCallHandler(null)
|
||||||
|
probeChannel = null
|
||||||
textureRegistry = null
|
textureRegistry = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,14 +227,75 @@ class SelfCodecDecoderPlugin :
|
|||||||
if (!enabled) releaseDecoder()
|
if (!enabled) releaseDecoder()
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"getStats" -> {
|
||||||
|
result.success(collectStats())
|
||||||
|
}
|
||||||
"dispose" -> {
|
"dispose" -> {
|
||||||
releaseAll()
|
releaseAll()
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"attachWebRtcDecodeProbe" -> {
|
||||||
|
val trackId = call.argument<String>("trackId")
|
||||||
|
if (trackId == null) {
|
||||||
|
result.error("PROBE", "trackId is null", null)
|
||||||
|
} else {
|
||||||
|
attachWebRtcDecodeProbe(trackId, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"detachWebRtcDecodeProbe" -> detachWebRtcDecodeProbe(result)
|
||||||
|
"getWebRtcDecodeStats" -> getWebRtcDecodeStats(result)
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— WebRTC 全托管模式解码耗时探针 ——
|
||||||
|
|
||||||
|
private fun attachWebRtcDecodeProbe(trackId: String, result: MethodChannel.Result) {
|
||||||
|
try {
|
||||||
|
val plugin = FlutterWebRTCPlugin.sharedSingleton
|
||||||
|
val track = plugin?.getRemoteTrack(trackId)
|
||||||
|
if (track !is VideoTrack) {
|
||||||
|
result.error("PROBE", "remote video track not found: $trackId", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detachWebRtcDecodeProbeInternal()
|
||||||
|
val stats = DecodeProbeSink()
|
||||||
|
val sink = VideoSink { stats.onFrame() }
|
||||||
|
track.addSink(sink)
|
||||||
|
probeHolder = ProbeHolder(track, sink, stats)
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "attachWebRtcDecodeProbe failed", e)
|
||||||
|
result.error("PROBE", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detachWebRtcDecodeProbeInternal() {
|
||||||
|
probeHolder?.let { (track, sink, stats) ->
|
||||||
|
try {
|
||||||
|
track.removeSink(sink)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "removeSink failed", e)
|
||||||
|
}
|
||||||
|
stats.reset()
|
||||||
|
}
|
||||||
|
probeHolder = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detachWebRtcDecodeProbe(result: MethodChannel.Result) {
|
||||||
|
detachWebRtcDecodeProbeInternal()
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getWebRtcDecodeStats(result: MethodChannel.Result) {
|
||||||
|
val holder = probeHolder
|
||||||
|
if (holder == null) {
|
||||||
|
result.success(null)
|
||||||
|
} else {
|
||||||
|
result.success(holder.stats.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createInternal(): Int {
|
private fun createInternal(): Int {
|
||||||
if (textureEntry != null) return textureEntry!!.id().toInt()
|
if (textureEntry != null) return textureEntry!!.id().toInt()
|
||||||
val entry = textureRegistry?.createSurfaceTexture() ?: return -1
|
val entry = textureRegistry?.createSurfaceTexture() ?: return -1
|
||||||
@@ -178,14 +340,26 @@ class SelfCodecDecoderPlugin :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleChunk(seq: Int, total: Int, idx: Int, data: ByteArray) {
|
private fun handleChunk(seq: Int, total: Int, idx: Int, data: ByteArray) {
|
||||||
|
if (total <= 0) return
|
||||||
|
// 过期检查:同一"单元"的所有分片共用同一个 seq(以 idx 区分),
|
||||||
|
// 因此不能用 seq <= lastSeq 丢弃——否则同一单元除首片外的分片都会被误删,
|
||||||
|
// 导致大帧永远无法重组、自编码串流黑屏。这里只丢弃明显更旧(seq 明显小于 lastSeq)
|
||||||
|
// 的旧单元分片,相同 seq(同单元的其它分片)放行。
|
||||||
|
if (lastSeq != -1 && seq < lastSeq && (lastSeq - seq) < 1000) return
|
||||||
var buf = chunkBuffers[seq]
|
var buf = chunkBuffers[seq]
|
||||||
if (buf == null) {
|
if (buf == null) {
|
||||||
buf = ChunkBuffer(seq, total)
|
buf = ChunkBuffer(seq, total)
|
||||||
chunkBuffers[seq] = buf
|
chunkBuffers[seq] = buf
|
||||||
|
// 防止内存堆积:重组缓冲超过 24 个未完成单元时丢弃最旧的。
|
||||||
|
if (chunkBuffers.size > 24) {
|
||||||
|
val minKey = chunkBuffers.keys.minOrNull()
|
||||||
|
if (minKey != null) chunkBuffers.remove(minKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
buf.add(idx, data)
|
buf.add(idx, data)
|
||||||
if (buf.isComplete()) {
|
if (buf.isComplete()) {
|
||||||
chunkBuffers.remove(seq)
|
chunkBuffers.remove(seq)
|
||||||
|
if (seq > lastSeq) lastSeq = seq
|
||||||
handleUnit(buf.join())
|
handleUnit(buf.join())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,9 +382,21 @@ class SelfCodecDecoderPlugin :
|
|||||||
|
|
||||||
private fun handleConfig(config: ByteArray) {
|
private fun handleConfig(config: ByteArray) {
|
||||||
synchronized(configLock) {
|
synchronized(configLock) {
|
||||||
if (configured) return // 忽略重复参数集
|
// 去重:仅当队列中尚无相同参数集时加入,避免关键帧前重发造成堆积。
|
||||||
|
// 关键:绝不能因为 configured 就 return —— 编码端把 SPS(csd-0) 与 PPS(csd-1)
|
||||||
|
// 作为两个独立的 CONFIG 单元分别发送。若第一个 CONFIG(SPS) 到达后即置
|
||||||
|
// configured=true 而直接丢弃第二个 CONFIG(PPS),解码器只拿到 SPS 缺少 PPS,
|
||||||
|
// H.264 将无法解码 → 永久黑屏(这正是"自编码无法显示"的根因)。
|
||||||
|
for (c in pendingConfigs) {
|
||||||
|
if (c.contentEquals(config)) return
|
||||||
|
}
|
||||||
pendingConfigs.add(config)
|
pendingConfigs.add(config)
|
||||||
tryConfigure()
|
if (!configured) {
|
||||||
|
tryConfigure()
|
||||||
|
} else {
|
||||||
|
// 已配置时也尝试喂入,以应对中途的参数集更新。
|
||||||
|
feed()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +426,8 @@ class SelfCodecDecoderPlugin :
|
|||||||
|
|
||||||
override fun onOutputBufferAvailable(
|
override fun onOutputBufferAvailable(
|
||||||
codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
|
codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
|
||||||
|
// 用输出帧的 presentationTimeUs 匹配入队时间戳,统计解码耗时。
|
||||||
|
recordDecodeTime(info.presentationTimeUs)
|
||||||
try {
|
try {
|
||||||
codec.releaseOutputBuffer(index, true)
|
codec.releaseOutputBuffer(index, true)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -311,6 +499,12 @@ class SelfCodecDecoderPlugin :
|
|||||||
val flags = if (frame.key) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0
|
val flags = if (frame.key) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0
|
||||||
decoder?.queueInputBuffer(
|
decoder?.queueInputBuffer(
|
||||||
index, 0, frame.data.size, frame.pts, flags)
|
index, 0, frame.data.size, frame.pts, flags)
|
||||||
|
// 记录该帧入队时间,供输出回调计算解码耗时。
|
||||||
|
synchronized(statsLock) {
|
||||||
|
decodeStartNs[frame.pts] = System.nanoTime()
|
||||||
|
// 防止乱序/丢帧导致 map 泄漏。
|
||||||
|
if (decodeStartNs.size > 300) decodeStartNs.clear()
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "queue frame failed", e)
|
Log.w(TAG, "queue frame failed", e)
|
||||||
}
|
}
|
||||||
@@ -318,6 +512,33 @@ class SelfCodecDecoderPlugin :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 输出帧到达时计算解码耗时(毫秒),并更新滑动平均/峰值/计数。
|
||||||
|
private fun recordDecodeTime(pts: Long) {
|
||||||
|
synchronized(statsLock) {
|
||||||
|
val startNs = decodeStartNs.remove(pts) ?: return
|
||||||
|
val ms = (System.nanoTime() - startNs) / 1_000_000.0
|
||||||
|
lastDecodeMs = ms
|
||||||
|
if (ms > maxDecodeMs) maxDecodeMs = ms
|
||||||
|
decodedFrames++
|
||||||
|
recentDecodeMs.addLast(ms)
|
||||||
|
while (recentDecodeMs.size > DECODE_STATS_WINDOW) recentDecodeMs.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 汇总解码统计供 Dart 侧读取(毫秒)。
|
||||||
|
private fun collectStats(): Map<String, Any> {
|
||||||
|
synchronized(statsLock) {
|
||||||
|
val avg = if (recentDecodeMs.isEmpty()) 0.0
|
||||||
|
else recentDecodeMs.sum() / recentDecodeMs.size
|
||||||
|
return mapOf(
|
||||||
|
"lastMs" to lastDecodeMs,
|
||||||
|
"avgMs" to avg,
|
||||||
|
"maxMs" to maxDecodeMs,
|
||||||
|
"count" to decodedFrames
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun releaseDecoder() {
|
private fun releaseDecoder() {
|
||||||
synchronized(configLock) {
|
synchronized(configLock) {
|
||||||
try {
|
try {
|
||||||
@@ -333,6 +554,17 @@ class SelfCodecDecoderPlugin :
|
|||||||
pendingConfigs.clear()
|
pendingConfigs.clear()
|
||||||
frameQueue.clear()
|
frameQueue.clear()
|
||||||
inputQueue.clear()
|
inputQueue.clear()
|
||||||
|
// 重置序号并清空重组缓冲,避免重连/重启后首帧被误判为过期而丢弃。
|
||||||
|
lastSeq = -1
|
||||||
|
chunkBuffers.clear()
|
||||||
|
// 重置解码耗时统计。
|
||||||
|
synchronized(statsLock) {
|
||||||
|
decodeStartNs.clear()
|
||||||
|
recentDecodeMs.clear()
|
||||||
|
lastDecodeMs = 0.0
|
||||||
|
maxDecodeMs = 0.0
|
||||||
|
decodedFrames = 0L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,22 @@ class SelfCodecDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取解码耗时统计。返回包含以下键的 Map(毫秒),失败返回 null:
|
||||||
|
/// - lastMs:最近一帧解码耗时;
|
||||||
|
/// - avgMs:最近窗口内的平均解码耗时;
|
||||||
|
/// - maxMs:峰值解码耗时;
|
||||||
|
/// - count:累计已解码帧数。
|
||||||
|
Future<Map<String, dynamic>?> getStats() async {
|
||||||
|
if (_disposed || _textureId == null) return null;
|
||||||
|
try {
|
||||||
|
final res = await _channel.invokeMethod<dynamic>('getStats');
|
||||||
|
if (res is Map) return res.cast<String, dynamic>();
|
||||||
|
} catch (_) {
|
||||||
|
// 解码未就绪或被释放时忽略。
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// 释放原生解码资源。重复调用安全。
|
/// 释放原生解码资源。重复调用安全。
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
|
||||||
import 'self_codec_decoder.dart';
|
import 'self_codec_decoder.dart';
|
||||||
@@ -9,6 +10,24 @@ import '../proto/control_message.pb.dart';
|
|||||||
import '../signaling/signaling_client.dart';
|
import '../signaling/signaling_client.dart';
|
||||||
import '../utils/control_commands.dart';
|
import '../utils/control_commands.dart';
|
||||||
|
|
||||||
|
/// WebRTC 全托管模式下,由原生 VideoSink 探针逐帧实测得到的解码耗时统计。
|
||||||
|
class _DecodeProbeStats {
|
||||||
|
final double lastMs;
|
||||||
|
final double avgMs;
|
||||||
|
final double peakMs;
|
||||||
|
final int totalFrames;
|
||||||
|
final int fpsNow;
|
||||||
|
final int fpsAvg;
|
||||||
|
const _DecodeProbeStats({
|
||||||
|
required this.lastMs,
|
||||||
|
required this.avgMs,
|
||||||
|
required this.peakMs,
|
||||||
|
required this.totalFrames,
|
||||||
|
required this.fpsNow,
|
||||||
|
required this.fpsAvg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// 封装 WebRTC 连接逻辑(对应 Android 端 WebRtcClient)。
|
/// 封装 WebRTC 连接逻辑(对应 Android 端 WebRtcClient)。
|
||||||
///
|
///
|
||||||
/// 职责:
|
/// 职责:
|
||||||
@@ -204,6 +223,53 @@ class WebRtcController {
|
|||||||
}
|
}
|
||||||
renderer.srcObject = stream;
|
renderer.srcObject = stream;
|
||||||
onRemoteStream?.call(renderer);
|
onRemoteStream?.call(renderer);
|
||||||
|
// 仅在 WebRTC 全托管模式 attach 解码耗时探针(自编码模式用自己的解码器统计)。
|
||||||
|
if (_selfCodecDecoder == null) {
|
||||||
|
final trackId = event.track.id;
|
||||||
|
if (trackId != null) _attachWebRtcDecodeProbe(trackId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解码耗时探针通道(原生侧给远端视频轨道 attach 一个测量 VideoSink)。
|
||||||
|
static const MethodChannel _decodeProbeChannel =
|
||||||
|
MethodChannel('webrtc_decode_probe');
|
||||||
|
|
||||||
|
Future<void> _attachWebRtcDecodeProbe(String trackId) async {
|
||||||
|
try {
|
||||||
|
await _decodeProbeChannel.invokeMethod<void>(
|
||||||
|
'attachWebRtcDecodeProbe', {'trackId': trackId});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[decodeProbe] attach failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _detachWebRtcDecodeProbe() async {
|
||||||
|
try {
|
||||||
|
await _decodeProbeChannel.invokeMethod<void>('detachWebRtcDecodeProbe');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[decodeProbe] detach failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_DecodeProbeStats?> _getWebRtcDecodeStats() async {
|
||||||
|
try {
|
||||||
|
final res =
|
||||||
|
await _decodeProbeChannel.invokeMethod<dynamic>('getWebRtcDecodeStats');
|
||||||
|
if (res is Map) {
|
||||||
|
final m = res.cast<String, dynamic>();
|
||||||
|
return _DecodeProbeStats(
|
||||||
|
lastMs: (m['lastMs'] as num? ?? 0).toDouble(),
|
||||||
|
avgMs: (m['avgMs'] as num? ?? 0).toDouble(),
|
||||||
|
peakMs: (m['peakMs'] as num? ?? 0).toDouble(),
|
||||||
|
totalFrames: (m['totalFrames'] as num? ?? 0).toInt(),
|
||||||
|
fpsNow: (m['fpsNow'] as num? ?? 0).toInt(),
|
||||||
|
fpsAvg: (m['fpsAvg'] as num? ?? 0).toInt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[decodeProbe] getStats failed: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDataChannel(RTCDataChannel channel) {
|
void _onDataChannel(RTCDataChannel channel) {
|
||||||
@@ -264,7 +330,11 @@ class WebRtcController {
|
|||||||
_creatingDecoder = true;
|
_creatingDecoder = true;
|
||||||
try {
|
try {
|
||||||
final decoder = SelfCodecDecoder(
|
final decoder = SelfCodecDecoder(
|
||||||
onSize: (w, h) => onResolutionReported?.call(w, h),
|
onSize: (w, h) {
|
||||||
|
_selfCodecWidth = w;
|
||||||
|
_selfCodecHeight = h;
|
||||||
|
onResolutionReported?.call(w, h);
|
||||||
|
},
|
||||||
onError: (err) => debugPrint('自编码解码器: $err'),
|
onError: (err) => debugPrint('自编码解码器: $err'),
|
||||||
);
|
);
|
||||||
final textureId = await decoder.create();
|
final textureId = await decoder.create();
|
||||||
@@ -294,6 +364,10 @@ class WebRtcController {
|
|||||||
onStreamModeReport?.call(msg.streamMode);
|
onStreamModeReport?.call(msg.streamMode);
|
||||||
break;
|
break;
|
||||||
case Action.REPORT_RESOLUTION:
|
case Action.REPORT_RESOLUTION:
|
||||||
|
if (msg.width > 0 && msg.height > 0) {
|
||||||
|
_selfCodecWidth = msg.width;
|
||||||
|
_selfCodecHeight = msg.height;
|
||||||
|
}
|
||||||
onResolutionReported?.call(msg.width, msg.height);
|
onResolutionReported?.call(msg.width, msg.height);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -394,6 +468,16 @@ class WebRtcController {
|
|||||||
int _prevBytesSent = 0;
|
int _prevBytesSent = 0;
|
||||||
int _prevStatsTimestamp = 0;
|
int _prevStatsTimestamp = 0;
|
||||||
|
|
||||||
|
/// 上一次采样时的累计解码帧数,用于计算自编码模式下的解码帧率。
|
||||||
|
int _prevDecodeCount = 0;
|
||||||
|
|
||||||
|
/// WebRTC 全托管模式下,由原生 VideoSink 探针实测的解码耗时统计缓存。
|
||||||
|
_DecodeProbeStats? _webRtcDecodeProbe;
|
||||||
|
|
||||||
|
/// 解码器上报的真实分辨率(自编码模式下 WebRTC 视频统计为空,需用此覆盖)。
|
||||||
|
int _selfCodecWidth = 0;
|
||||||
|
int _selfCodecHeight = 0;
|
||||||
|
|
||||||
/// 连接建立时的时间戳(毫秒),用于计算连接时长。
|
/// 连接建立时的时间戳(毫秒),用于计算连接时长。
|
||||||
int? _connectedAt;
|
int? _connectedAt;
|
||||||
|
|
||||||
@@ -425,6 +509,11 @@ class WebRtcController {
|
|||||||
int dcMessagesSent = 0;
|
int dcMessagesSent = 0;
|
||||||
int dcMessagesReceived = 0;
|
int dcMessagesReceived = 0;
|
||||||
|
|
||||||
|
// 与上次采样的时间差(秒),供循环内估算解码帧率,也供下方计算每秒网速。
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final double dtSec =
|
||||||
|
_prevStatsTimestamp != 0 ? (now - _prevStatsTimestamp) / 1000.0 : 0;
|
||||||
|
|
||||||
for (final report in reports) {
|
for (final report in reports) {
|
||||||
final values = report.values;
|
final values = report.values;
|
||||||
final type = report.type;
|
final type = report.type;
|
||||||
@@ -482,17 +571,13 @@ class WebRtcController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 计算每秒网速(与上次采样差值)。
|
// 计算每秒网速(与上次采样差值)。
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
|
||||||
String downSpeed = '-';
|
String downSpeed = '-';
|
||||||
String upSpeed = '-';
|
String upSpeed = '-';
|
||||||
if (_prevStatsTimestamp != 0) {
|
if (dtSec > 0) {
|
||||||
final dt = (now - _prevStatsTimestamp) / 1000.0;
|
final downBps = (bytesReceived - _prevBytesReceived) / dtSec;
|
||||||
if (dt > 0) {
|
final upBps = (bytesSent - _prevBytesSent) / dtSec;
|
||||||
final downBps = (bytesReceived - _prevBytesReceived) / dt;
|
downSpeed = _formatSpeed(downBps);
|
||||||
final upBps = (bytesSent - _prevBytesSent) / dt;
|
upSpeed = _formatSpeed(upBps);
|
||||||
downSpeed = _formatSpeed(downBps);
|
|
||||||
upSpeed = _formatSpeed(upBps);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_prevBytesReceived = bytesReceived;
|
_prevBytesReceived = bytesReceived;
|
||||||
_prevBytesSent = bytesSent;
|
_prevBytesSent = bytesSent;
|
||||||
@@ -522,11 +607,66 @@ class WebRtcController {
|
|||||||
? '已连接'
|
? '已连接'
|
||||||
: (dcState == null ? '未连接' : dcState.name);
|
: (dcState == null ? '未连接' : dcState.name);
|
||||||
|
|
||||||
return '分辨率: ${width}x$height 帧率: $fps 延迟: $delay ms\n'
|
// —— 自编码(自建 MediaCodec 硬解)补充:真实分辨率 + 解码耗时 ——
|
||||||
|
// 自编码模式视频走 DataChannel 透传裸码流,WebRTC 的视频统计(分辨率/帧率/
|
||||||
|
// 解码格式)均为空。这里只要解码器处于活动状态就读取原生解码统计,用解码器
|
||||||
|
// 上报的真实分辨率/解码帧率覆盖 WebRTC 的空值,并把解码耗时一并显示,
|
||||||
|
// 确保「分辨率」与「解码耗时」这些信息显示在一起。
|
||||||
|
String? decodeInfo;
|
||||||
|
// 基础行要展示的解码耗时:WebRTC 模式用 inbound-rtp 估算,自编码模式用原生平均。
|
||||||
|
double nativeAvg = 0;
|
||||||
|
if (_selfCodecDecoder != null) {
|
||||||
|
final ds = await _selfCodecDecoder!.getStats();
|
||||||
|
if (ds != null) {
|
||||||
|
final last = (ds['lastMs'] as num?)?.toDouble() ?? 0;
|
||||||
|
final avg = (ds['avgMs'] as num?)?.toDouble() ?? 0;
|
||||||
|
final peak = (ds['maxMs'] as num?)?.toDouble() ?? 0;
|
||||||
|
final cnt = (ds['count'] as num?)?.toInt() ?? 0;
|
||||||
|
nativeAvg = avg;
|
||||||
|
if (_selfCodecWidth > 0 && _selfCodecHeight > 0) {
|
||||||
|
width = _selfCodecWidth;
|
||||||
|
height = _selfCodecHeight;
|
||||||
|
}
|
||||||
|
double decodeFps = 0;
|
||||||
|
if (dtSec > 0 && cnt > 0) {
|
||||||
|
decodeFps = (cnt - _prevDecodeCount) / dtSec;
|
||||||
|
}
|
||||||
|
_prevDecodeCount = cnt;
|
||||||
|
if (decodeFps > 0) fps = decodeFps.round();
|
||||||
|
decodeInfo = '解码耗时: ${last.toStringAsFixed(1)} ms 平均: ${avg.toStringAsFixed(1)} ms 峰值: ${peak.toStringAsFixed(1)} ms\n'
|
||||||
|
'解码帧率: ${decodeFps.toStringAsFixed(0)} fps 已解码: $cnt 帧';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无活动解码器时清零计数,避免重连后帧率计算异常。
|
||||||
|
if (_selfCodecDecoder == null) _prevDecodeCount = 0;
|
||||||
|
|
||||||
|
// WebRTC 全托管模式:flutter_webrtc 1.5.2 不报 totalDecodeTime / framesDecoded,
|
||||||
|
// 改为由原生 VideoSink 探针逐帧实测真实解码耗时(last/avg/peak)与帧率。
|
||||||
|
// 探针在远端视频轨道额外挂一个 VideoSink,记录每帧真实到达时间,算出帧间
|
||||||
|
// 耗时(≈ 真实解码+渲染周期),是原生层实测值,不会恒为 0。
|
||||||
|
if (_selfCodecDecoder == null) {
|
||||||
|
_webRtcDecodeProbe = await _getWebRtcDecodeStats();
|
||||||
|
final p = _webRtcDecodeProbe;
|
||||||
|
if (p != null && p.totalFrames > 0) {
|
||||||
|
nativeAvg = p.avgMs;
|
||||||
|
decodeInfo =
|
||||||
|
'解码耗时: ${p.lastMs.toStringAsFixed(1)} ms 平均: ${p.avgMs.toStringAsFixed(1)} ms 峰值: ${p.peakMs.toStringAsFixed(1)} ms\n'
|
||||||
|
'解码帧率: ${p.fpsNow} fps (平均 ${p.fpsAvg} fps) 已解码: ${p.totalFrames} 帧';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String decodeTimeText = '-';
|
||||||
|
if (decodeInfo != null && nativeAvg > 0) {
|
||||||
|
decodeTimeText = '${nativeAvg.toStringAsFixed(1)} ms';
|
||||||
|
}
|
||||||
|
|
||||||
|
final base = '分辨率: ${width}x$height 帧率: $fps 延迟: $delay ms 解码耗时: $decodeTimeText\n'
|
||||||
'解码格式: $codec 抖动: $jitter ms 丢包: $lossRate%\n'
|
'解码格式: $codec 抖动: $jitter ms 丢包: $lossRate%\n'
|
||||||
'丢帧: $framesDropped ↓下载: $downSpeed ↑上传: $upSpeed\n'
|
'丢帧: $framesDropped ↓下载: $downSpeed ↑上传: $upSpeed\n'
|
||||||
'连接类型: $connType ($protocol) 时长: $duration\n'
|
'连接类型: $connType ($protocol) 时长: $duration\n'
|
||||||
'控制通道: $dcStatus 收发: $dcMessagesSent/$dcMessagesReceived';
|
'控制通道: $dcStatus 收发: $dcMessagesSent/$dcMessagesReceived';
|
||||||
|
|
||||||
|
return decodeInfo != null ? '$base\n$decodeInfo' : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 依据选定候选对的本地/远端候选类型,返回 P2P / TURN 中继描述。
|
/// 依据选定候选对的本地/远端候选类型,返回 P2P / TURN 中继描述。
|
||||||
@@ -591,6 +731,9 @@ class WebRtcController {
|
|||||||
try {
|
try {
|
||||||
await _dataChannel?.close();
|
await _dataChannel?.close();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await _detachWebRtcDecodeProbe();
|
||||||
|
} catch (_) {}
|
||||||
try {
|
try {
|
||||||
await _pc?.close();
|
await _pc?.close();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -617,6 +760,10 @@ class WebRtcController {
|
|||||||
_prevBytesReceived = 0;
|
_prevBytesReceived = 0;
|
||||||
_prevBytesSent = 0;
|
_prevBytesSent = 0;
|
||||||
_prevStatsTimestamp = 0;
|
_prevStatsTimestamp = 0;
|
||||||
|
_prevDecodeCount = 0;
|
||||||
|
_webRtcDecodeProbe = null;
|
||||||
|
_selfCodecWidth = 0;
|
||||||
|
_selfCodecHeight = 0;
|
||||||
_connectedAt = null;
|
_connectedAt = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user