perf: Web端视频流帧率和画面稳定性优化
- fetch+dataURI替代atob逐字节Base64解码,避免主线程阻塞 - 预解码Blob缓存,渲染循环直接使用createImageBitmap - canvas尺寸锁定策略增强:取历史最大帧尺寸,防止小帧导致缩小闪烁 - 新帧比锁定尺寸大时动态更新锁定尺寸(适配设备旋转)
This commit is contained in:
@@ -38,10 +38,13 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
const isRenderingRef = useRef(false)
|
||||
const imageSizeRef = useRef<{ width: number, height: number } | null>(null)
|
||||
|
||||
// canvas锁定尺寸:首次有效帧确定canvas尺寸后不再变化,
|
||||
// canvas锁定尺寸:取历史最大帧尺寸锁定,
|
||||
// 避免不同采集模式(MediaProjection/无障碍截图)帧尺寸不一致导致闪烁
|
||||
const lockedCanvasSizeRef = useRef<{ width: number, height: number } | null>(null)
|
||||
|
||||
// Base64预解码缓存:将base64字符串提前转为Blob,避免渲染时阻塞主线程
|
||||
const pendingBlobRef = useRef<Blob | null>(null)
|
||||
|
||||
// 添加控制权状态跟踪,避免重复申请
|
||||
const [isControlRequested, setIsControlRequested] = useState(false)
|
||||
const [currentWebSocket, setCurrentWebSocket] = useState<any>(null)
|
||||
@@ -147,17 +150,43 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
console.log(`[屏幕数据] 已收到 ${frameCountRef.current} 帧, 数据大小: ${dataLen}, 渲染中: ${isRenderingRef.current}, 解码中: ${decodingRef.current}`)
|
||||
}
|
||||
|
||||
// 只保存最新帧引用,不立即解码
|
||||
// 提前将Base64转为Blob,避免渲染循环中阻塞主线程
|
||||
// fetch+data URI方式比atob+逐字节复制快得多,且不阻塞主线程
|
||||
const format = data?.format ?? 'JPEG'
|
||||
const mimeType = `image/${format.toLowerCase()}`
|
||||
|
||||
if (typeof data.data === 'string') {
|
||||
try {
|
||||
const dataUri = `data:${mimeType};base64,${data.data}`
|
||||
fetch(dataUri)
|
||||
.then(res => res.blob())
|
||||
.then(blob => {
|
||||
pendingBlobRef.current = blob
|
||||
latestFrameRef.current = data
|
||||
|
||||
// 只在首帧时更新loading状态,避免每帧触发重渲染
|
||||
if (isLoading) setIsLoading(false)
|
||||
|
||||
// 如果没有正在进行的渲染循环,启动一个
|
||||
if (!isRenderingRef.current) {
|
||||
isRenderingRef.current = true
|
||||
renderLatestFrame()
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Base64 pre-decode failed:', err)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Frame pre-decode error:', err)
|
||||
}
|
||||
} else {
|
||||
pendingBlobRef.current = new Blob([data.data], { type: mimeType })
|
||||
latestFrameRef.current = data
|
||||
|
||||
if (!isRenderingRef.current) {
|
||||
isRenderingRef.current = true
|
||||
renderLatestFrame()
|
||||
}
|
||||
}
|
||||
|
||||
// 只在首帧时更新loading状态,避免每帧触发重渲染
|
||||
if (isLoading) setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,14 +322,16 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
const renderLatestFrame = useCallback(() => {
|
||||
const doRender = () => {
|
||||
const frameData = latestFrameRef.current
|
||||
if (!frameData) {
|
||||
const preDecodedBlob = pendingBlobRef.current
|
||||
if (!frameData || !preDecodedBlob) {
|
||||
// 没有新帧,停止循环,等 handleScreenData 重新启动
|
||||
isRenderingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
// 取走帧数据
|
||||
// 取走帧数据和预解码Blob
|
||||
latestFrameRef.current = null
|
||||
pendingBlobRef.current = null
|
||||
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) {
|
||||
@@ -316,27 +347,15 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
// 上一帧还在解码,把数据放回去,下个 rAF 再试
|
||||
if (decodingRef.current) {
|
||||
latestFrameRef.current = frameData
|
||||
pendingBlobRef.current = preDecodedBlob
|
||||
rafIdRef.current = requestAnimationFrame(doRender)
|
||||
return
|
||||
}
|
||||
|
||||
decodingRef.current = true
|
||||
|
||||
let blobPromise: Promise<Blob>
|
||||
if (typeof frameData.data === 'string') {
|
||||
const binaryStr = atob(frameData.data)
|
||||
const len = binaryStr.length
|
||||
const bytes = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryStr.charCodeAt(i)
|
||||
}
|
||||
blobPromise = Promise.resolve(new Blob([bytes], { type: `image/${frameData.format.toLowerCase()}` }))
|
||||
} else {
|
||||
blobPromise = Promise.resolve(new Blob([frameData.data], { type: `image/${frameData.format.toLowerCase()}` }))
|
||||
}
|
||||
|
||||
blobPromise
|
||||
.then(blob => createImageBitmap(blob))
|
||||
// 直接使用预解码的Blob,跳过Base64解码步骤
|
||||
createImageBitmap(preDecodedBlob)
|
||||
.then(bitmap => {
|
||||
decodingRef.current = false
|
||||
|
||||
@@ -347,23 +366,32 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
return
|
||||
}
|
||||
|
||||
// canvas尺寸锁定策略:
|
||||
// 首次有效帧确定canvas尺寸后锁定,后续帧直接绘制到固定尺寸canvas上
|
||||
// 避免不同采集模式帧尺寸不一致导致canvas反复resize闪烁
|
||||
// canvas尺寸锁定策略(增强版):
|
||||
// 取历史最大帧尺寸锁定canvas,避免小帧导致画面缩小闪烁
|
||||
// 当新帧比锁定尺寸大时,更新锁定尺寸(设备旋转等场景)
|
||||
const locked = lockedCanvasSizeRef.current
|
||||
if (!locked) {
|
||||
// 首次帧:锁定canvas尺寸
|
||||
lockedCanvasSizeRef.current = { width: bitmap.width, height: bitmap.height }
|
||||
canvas.width = bitmap.width
|
||||
canvas.height = bitmap.height
|
||||
} else {
|
||||
// 后续帧:只在新帧更大时更新锁定尺寸,防止缩小闪烁
|
||||
const needUpdate = bitmap.width > locked.width || bitmap.height > locked.height
|
||||
if (needUpdate) {
|
||||
const newW = Math.max(locked.width, bitmap.width)
|
||||
const newH = Math.max(locked.height, bitmap.height)
|
||||
lockedCanvasSizeRef.current = { width: newW, height: newH }
|
||||
canvas.width = newW
|
||||
canvas.height = newH
|
||||
} else if (canvas.width !== locked.width || canvas.height !== locked.height) {
|
||||
// canvas被外部重置了,恢复锁定尺寸
|
||||
canvas.width = locked.width
|
||||
canvas.height = locked.height
|
||||
}
|
||||
}
|
||||
|
||||
// 始终将bitmap绘制到整个canvas区域,浏览器自动缩放
|
||||
// 这样不同尺寸的帧都能正确显示,不会闪烁
|
||||
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 使用锁定的canvas尺寸上报,保持稳定
|
||||
@@ -392,7 +420,7 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
})
|
||||
.catch(err => {
|
||||
decodingRef.current = false
|
||||
console.error('图像解码失败:', err)
|
||||
console.error('Image decode failed:', err)
|
||||
rafIdRef.current = requestAnimationFrame(doRender)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user