fix: 优化 Windows 启动性能与聊天图片缓存

- 增加聊天图片磁盘持久化缓存,重启后直接复用
- 将 DAT 图片解密移至 Worker,避免阻塞主进程
- 增加图片加载优先级和并发控制
- 优化会话目录与图片文件的异步查找
- 使用本地媒体协议加载缓存图片,减少 Base64 IPC 开销
- 优化启动缓存与数据库初始化流程,降低窗口未响应时间

(cherry picked from commit 82bcc8d32ca674de38c745cc9925ed2309887dc7)
This commit is contained in:
电摇小子
2026-08-03 10:03:19 +08:00
committed by Wxw-Gu
parent 8a6d443acc
commit a3955d691d
10 changed files with 1615 additions and 362 deletions
+68 -69
View File
@@ -29,93 +29,92 @@ export function ImageBubble({
const [usingFallback, setUsingFallback] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const mountedRef = useRef(true)
const backgroundUpgradeRef = useRef(false)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const upgradeOriginalInBackground = useCallback(() => {
if (!isThumbnail || backgroundUpgradeRef.current || (!imageMd5 && !imageDatName)) return
backgroundUpgradeRef.current = true
void requestImage(imageMd5, imageDatName, sessionId, { force: true }, 1)
.then((original) => {
if (!mountedRef.current) return
setImageUrl(original.data)
setIsThumbnail(false)
})
.catch(() => undefined)
}, [imageDatName, imageMd5, isThumbnail, sessionId])
const loadImage = useCallback(async () => {
if (imageUrl || loading) return
if (!imageMd5 && !imageDatName) {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
const loadImage = useCallback(
async (priority = 0) => {
if (imageUrl) return
if (!imageMd5 && !imageDatName) {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
return
}
setError('缺少图片标识')
return
}
setError('缺少图片标识')
return
}
setLoading(true)
try {
const result = await requestImage(
imageMd5,
imageDatName,
sessionId,
{ preferThumbnail: true },
0
)
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(result.isThumbnail)
setError(null)
if (result.isThumbnail) upgradeOriginalInBackground()
} catch (error) {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError(error instanceof Error ? error.message : '加载图片失败')
if (loading) {
if (priority === 0) {
void requestImage(
imageMd5,
imageDatName,
sessionId,
{ preferThumbnail: true },
priority
).catch(() => undefined)
}
return
}
} finally {
setLoading(false)
}
}, [
fallbackUrl,
imageDatName,
imageMd5,
imageUrl,
loading,
sessionId,
upgradeOriginalInBackground
])
setLoading(true)
try {
const result = await requestImage(
imageMd5,
imageDatName,
sessionId,
{ preferThumbnail: true },
priority
)
if (!mountedRef.current) return
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(result.isThumbnail)
setError(null)
} catch (error) {
if (!mountedRef.current) return
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError(error instanceof Error ? error.message : '加载图片失败')
}
} finally {
if (mountedRef.current) setLoading(false)
}
},
[fallbackUrl, imageDatName, imageMd5, imageUrl, loading, sessionId]
)
useEffect(() => {
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground()
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
useEffect(() => {
if (imageUrl || loading || error) return
if (imageUrl || error) return
const element = containerRef.current
if (!element || typeof IntersectionObserver === 'undefined') {
const timer = window.setTimeout(() => void loadImage(), 0)
const timer = window.setTimeout(() => void loadImage(0), 0)
return () => window.clearTimeout(timer)
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return
const entry = entries.find((candidate) => candidate.isIntersecting)
if (!entry) return
const rect = entry.boundingClientRect
const isInViewport =
rect.bottom >= 0 &&
rect.top <= window.innerHeight &&
rect.right >= 0 &&
rect.left <= window.innerWidth
observer.disconnect()
void loadImage()
void loadImage(isInViewport ? 0 : 1)
},
{ rootMargin: '400px 0px' }
{ rootMargin: loading ? '0px' : '400px 0px' }
)
observer.observe(element)
return () => observer.disconnect()
@@ -143,7 +142,7 @@ export function ImageBubble({
setUpgrading(true)
try {
const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0)
if (result.data.startsWith('data:image/')) {
if (result.data.startsWith('data:image/') || result.data.startsWith('wxe-media://')) {
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(result.isThumbnail)
@@ -162,7 +161,7 @@ export function ImageBubble({
if (loading) {
return (
<div className="image-bubble image-loading" onClick={handleClick}>
<div ref={containerRef} className="image-bubble image-loading" onClick={handleClick}>
<div className="image-loading-skeleton" aria-hidden />
<div className="image-quality-badge"></div>
</div>
@@ -171,7 +170,7 @@ export function ImageBubble({
if (error) {
return (
<div className="image-bubble image-error" onClick={loadImage}>
<div className="image-bubble image-error" onClick={() => void loadImage(0)}>
<div className="image-error-text">{error || '图片未缓存'}</div>
<div className="image-quality-badge"></div>
</div>
+22 -4
View File
@@ -9,13 +9,17 @@ export type ImageLoadOptions = {
}
type QueueItem = {
requestKey: string
priority: number
run: () => Promise<LoadedImage>
resolve: (value: LoadedImage) => void
reject: (error: Error) => void
}
const MAX_CONCURRENT_IMAGE_LOADS = 3
// Cache probes are cheap asynchronous IPC calls. Cold decrypts are serialized
// in the main process, so renderer concurrency only controls how quickly disk
// cache hits can become visible after a restart.
const MAX_CONCURRENT_IMAGE_LOADS = 32
const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
const imageCache = new Map<string, LoadedImage>()
const imageCacheSizes = new Map<string, number>()
@@ -99,6 +103,8 @@ function cacheImage(
}
function pumpImageQueue(): void {
if (activeImageLoads >= MAX_CONCURRENT_IMAGE_LOADS || imageQueue.length === 0) return
while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) {
imageQueue.sort((left, right) => left.priority - right.priority)
const item = imageQueue.shift()
@@ -114,6 +120,10 @@ function pumpImageQueue(): void {
}
}
function isSupportedImageUrl(value: string | undefined): value is string {
return Boolean(value?.startsWith('data:image/') || value?.startsWith('wxe-media://'))
}
export function getCachedLoadedImage(
imageMd5?: string,
imageDatName?: string,
@@ -136,16 +146,24 @@ export function requestImage(
if (!identity) return Promise.reject(new Error('缺少图片标识'))
const requestKey = `${identity}:${cacheMode(options)}`
const existingRequest = imageRequests.get(requestKey)
if (existingRequest) return existingRequest
if (existingRequest) {
const queuedItem = imageQueue.find((item) => item.requestKey === requestKey)
if (queuedItem && priority < queuedItem.priority) queuedItem.priority = priority
return existingRequest
}
const request = new Promise<LoadedImage>((resolve, reject) => {
imageQueue.push({
requestKey,
priority,
resolve,
reject,
run: async () => {
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, options)
if (!result.success || !result.data?.startsWith('data:image/')) {
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, {
...options,
priority
})
if (!result.success || !isSupportedImageUrl(result.data)) {
throw new Error(result.error || '加载图片失败')
}
const loadedImage = {