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 isRenderingRef = useRef(false)
|
||||||
const imageSizeRef = useRef<{ width: number, height: number } | null>(null)
|
const imageSizeRef = useRef<{ width: number, height: number } | null>(null)
|
||||||
|
|
||||||
// canvas锁定尺寸:首次有效帧确定canvas尺寸后不再变化,
|
// canvas锁定尺寸:取历史最大帧尺寸锁定,
|
||||||
// 避免不同采集模式(MediaProjection/无障碍截图)帧尺寸不一致导致闪烁
|
// 避免不同采集模式(MediaProjection/无障碍截图)帧尺寸不一致导致闪烁
|
||||||
const lockedCanvasSizeRef = useRef<{ width: number, height: number } | null>(null)
|
const lockedCanvasSizeRef = useRef<{ width: number, height: number } | null>(null)
|
||||||
|
|
||||||
|
// Base64预解码缓存:将base64字符串提前转为Blob,避免渲染时阻塞主线程
|
||||||
|
const pendingBlobRef = useRef<Blob | null>(null)
|
||||||
|
|
||||||
// 添加控制权状态跟踪,避免重复申请
|
// 添加控制权状态跟踪,避免重复申请
|
||||||
const [isControlRequested, setIsControlRequested] = useState(false)
|
const [isControlRequested, setIsControlRequested] = useState(false)
|
||||||
const [currentWebSocket, setCurrentWebSocket] = useState<any>(null)
|
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}`)
|
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
|
latestFrameRef.current = data
|
||||||
|
|
||||||
// 只在首帧时更新loading状态,避免每帧触发重渲染
|
|
||||||
if (isLoading) setIsLoading(false)
|
|
||||||
|
|
||||||
// 如果没有正在进行的渲染循环,启动一个
|
|
||||||
if (!isRenderingRef.current) {
|
if (!isRenderingRef.current) {
|
||||||
isRenderingRef.current = true
|
isRenderingRef.current = true
|
||||||
renderLatestFrame()
|
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 renderLatestFrame = useCallback(() => {
|
||||||
const doRender = () => {
|
const doRender = () => {
|
||||||
const frameData = latestFrameRef.current
|
const frameData = latestFrameRef.current
|
||||||
if (!frameData) {
|
const preDecodedBlob = pendingBlobRef.current
|
||||||
|
if (!frameData || !preDecodedBlob) {
|
||||||
// 没有新帧,停止循环,等 handleScreenData 重新启动
|
// 没有新帧,停止循环,等 handleScreenData 重新启动
|
||||||
isRenderingRef.current = false
|
isRenderingRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取走帧数据
|
// 取走帧数据和预解码Blob
|
||||||
latestFrameRef.current = null
|
latestFrameRef.current = null
|
||||||
|
pendingBlobRef.current = null
|
||||||
|
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
@@ -316,27 +347,15 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
|||||||
// 上一帧还在解码,把数据放回去,下个 rAF 再试
|
// 上一帧还在解码,把数据放回去,下个 rAF 再试
|
||||||
if (decodingRef.current) {
|
if (decodingRef.current) {
|
||||||
latestFrameRef.current = frameData
|
latestFrameRef.current = frameData
|
||||||
|
pendingBlobRef.current = preDecodedBlob
|
||||||
rafIdRef.current = requestAnimationFrame(doRender)
|
rafIdRef.current = requestAnimationFrame(doRender)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
decodingRef.current = true
|
decodingRef.current = true
|
||||||
|
|
||||||
let blobPromise: Promise<Blob>
|
// 直接使用预解码的Blob,跳过Base64解码步骤
|
||||||
if (typeof frameData.data === 'string') {
|
createImageBitmap(preDecodedBlob)
|
||||||
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))
|
|
||||||
.then(bitmap => {
|
.then(bitmap => {
|
||||||
decodingRef.current = false
|
decodingRef.current = false
|
||||||
|
|
||||||
@@ -347,23 +366,32 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// canvas尺寸锁定策略:
|
// canvas尺寸锁定策略(增强版):
|
||||||
// 首次有效帧确定canvas尺寸后锁定,后续帧直接绘制到固定尺寸canvas上
|
// 取历史最大帧尺寸锁定canvas,避免小帧导致画面缩小闪烁
|
||||||
// 避免不同采集模式帧尺寸不一致导致canvas反复resize闪烁
|
// 当新帧比锁定尺寸大时,更新锁定尺寸(设备旋转等场景)
|
||||||
const locked = lockedCanvasSizeRef.current
|
const locked = lockedCanvasSizeRef.current
|
||||||
if (!locked) {
|
if (!locked) {
|
||||||
// 首次帧:锁定canvas尺寸
|
// 首次帧:锁定canvas尺寸
|
||||||
lockedCanvasSizeRef.current = { width: bitmap.width, height: bitmap.height }
|
lockedCanvasSizeRef.current = { width: bitmap.width, height: bitmap.height }
|
||||||
canvas.width = bitmap.width
|
canvas.width = bitmap.width
|
||||||
canvas.height = bitmap.height
|
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) {
|
} else if (canvas.width !== locked.width || canvas.height !== locked.height) {
|
||||||
// canvas被外部重置了,恢复锁定尺寸
|
// canvas被外部重置了,恢复锁定尺寸
|
||||||
canvas.width = locked.width
|
canvas.width = locked.width
|
||||||
canvas.height = locked.height
|
canvas.height = locked.height
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 始终将bitmap绘制到整个canvas区域,浏览器自动缩放
|
// 始终将bitmap绘制到整个canvas区域,浏览器自动缩放
|
||||||
// 这样不同尺寸的帧都能正确显示,不会闪烁
|
|
||||||
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
// 使用锁定的canvas尺寸上报,保持稳定
|
// 使用锁定的canvas尺寸上报,保持稳定
|
||||||
@@ -392,7 +420,7 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
|||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
decodingRef.current = false
|
decodingRef.current = false
|
||||||
console.error('图像解码失败:', err)
|
console.error('Image decode failed:', err)
|
||||||
rafIdRef.current = requestAnimationFrame(doRender)
|
rafIdRef.current = requestAnimationFrame(doRender)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user