perf: 优化图片消息后台加载与解密缓存

- 缩略图优先展示并在后台准备原图
- 增加图片请求去重与受控并发队列
- 缓存图片路径、账号目录和解密结果
This commit is contained in:
Wxw-Gu
2026-07-28 16:11:59 +08:00
parent 49684f3365
commit d0ae9e6019
6 changed files with 400 additions and 107 deletions
+151 -29
View File
@@ -9,10 +9,23 @@ const imageDecryptLog = (...args: unknown[]): void => {
if (imageDecryptDebugEnabled) console.log(...args) if (imageDecryptDebugEnabled) console.log(...args)
} }
type DecodedImage = {
data: string
filePath: string
isThumbnail: boolean
}
const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
export class ImageDecryptService { export class ImageDecryptService {
private xorKey: number = 0 private xorKey: number = 0
private aesKey: string = '' private aesKey: string = ''
private wcdb4Client: Wcdb4Client | null = null private wcdb4Client: Wcdb4Client | null = null
private accountDirResolved = false
private cachedAccountDir: string | null = null
private imagePathCache = new Map<string, string>()
private decodedImageCache = new Map<string, DecodedImage>()
private decodedImageCacheBytes = 0
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) { constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
// 解析 XOR Key (支持 0x40 或 64 格式) // 解析 XOR Key (支持 0x40 或 64 格式)
@@ -32,9 +45,13 @@ export class ImageDecryptService {
* 获取账号目录 * 获取账号目录
*/ */
private getAccountDir(): string | null { private getAccountDir(): string | null {
if (this.accountDirResolved) return this.cachedAccountDir
this.accountDirResolved = true
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot() const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) { if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
return wcdbAccountRoot this.cachedAccountDir = wcdbAccountRoot
return this.cachedAccountDir
} }
const homeDir = os.homedir() const homeDir = os.homedir()
@@ -69,7 +86,8 @@ export class ImageDecryptService {
} }
// 返回最新的账号目录 // 返回最新的账号目录
return join(accountRoot, accounts[0].name) this.cachedAccountDir = join(accountRoot, accounts[0].name)
return this.cachedAccountDir
} }
/** /**
@@ -78,16 +96,32 @@ export class ImageDecryptService {
findImageFile( findImageFile(
md5?: string, md5?: string,
imageDatName?: string, imageDatName?: string,
options?: { allowThumbnail?: boolean; accountDir?: string } options?: { allowThumbnail?: boolean; accountDir?: string; preferThumbnail?: boolean }
): string | null { ): string | null {
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
const accountDir =
options?.accountDir && existsSync(options.accountDir) ? options.accountDir : this.getAccountDir()
if (!accountDir) return null
const allowThumbnail = options?.allowThumbnail !== false const allowThumbnail = options?.allowThumbnail !== false
const normalizedMd5 = this.normalizeDatBase(md5 || '') const normalizedMd5 = this.normalizeDatBase(md5 || '')
const normalizedDatName = this.normalizeDatBase(imageDatName || '') const normalizedDatName = this.normalizeDatBase(imageDatName || '')
const pathCacheKey = [
normalizedMd5,
normalizedDatName,
allowThumbnail ? 'thumb' : 'original',
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
options?.accountDir || ''
].join('|')
const cachedPath = this.imagePathCache.get(pathCacheKey)
if (cachedPath && existsSync(cachedPath)) return cachedPath
const rememberPath = (path: string | null): string | null => {
if (path) this.imagePathCache.set(pathCacheKey, path)
return path
}
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
const accountDir =
options?.accountDir && existsSync(options.accountDir)
? options.accountDir
: this.getAccountDir()
if (!accountDir) return null
imageDecryptLog('[ImageDecrypt] findImageFile:', { imageDecryptLog('[ImageDecrypt] findImageFile:', {
md5: normalizedMd5, md5: normalizedMd5,
imageDatName: normalizedDatName, imageDatName: normalizedDatName,
@@ -99,10 +133,14 @@ export class ImageDecryptService {
const hardlink = this.wcdb4Client?.resolveImageHardlink(key) const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : '' const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
if (fullPath && existsSync(fullPath)) { if (fullPath && existsSync(fullPath)) {
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail) const selected = this.getPreferredDatVariantPath(
fullPath,
allowThumbnail,
options?.preferThumbnail
)
if (allowThumbnail || !this.isThumbnailName(basename(selected))) { if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected) imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
return selected return rememberPath(selected)
} }
} }
} }
@@ -111,28 +149,75 @@ export class ImageDecryptService {
const attachDir = join(accountDir, 'msg', 'attach') const attachDir = join(accountDir, 'msg', 'attach')
if (!existsSync(attachDir)) { if (!existsSync(attachDir)) {
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir) imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName) return rememberPath(
this.findImageFileInLegacyDirs(
accountDir,
normalizedMd5 || normalizedDatName,
allowThumbnail,
options?.preferThumbnail
)
)
} }
const searchKeys = this.uniq([normalizedMd5, normalizedDatName]) const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
if (searchKeys.length === 0) return null if (searchKeys.length === 0) return null
for (const key of searchKeys) { for (const key of searchKeys) {
const directHit = this.fastProbabilisticSearch(attachDir, key, allowThumbnail) const directHit = this.fastProbabilisticSearch(
if (directHit) return directHit attachDir,
key,
allowThumbnail,
options?.preferThumbnail
)
if (directHit) return rememberPath(directHit)
} }
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail) const legacyHit = this.findImageFileInLegacyDirs(
if (legacyHit) return legacyHit accountDir,
searchKeys[0],
allowThumbnail,
options?.preferThumbnail
)
if (legacyHit) return rememberPath(legacyHit)
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys) imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
return null return null
} }
getCachedDecodedImage(key: string): DecodedImage | null {
const cached = this.decodedImageCache.get(key)
if (!cached) return null
this.decodedImageCache.delete(key)
this.decodedImageCache.set(key, cached)
return cached
}
cacheDecodedImage(key: string, image: DecodedImage): void {
const size = image.data.length * 2
const previous = this.decodedImageCache.get(key)
if (previous) {
this.decodedImageCacheBytes -= previous.data.length * 2
this.decodedImageCache.delete(key)
}
this.decodedImageCache.set(key, image)
this.decodedImageCacheBytes += size
while (
this.decodedImageCacheBytes > MAX_DECODED_IMAGE_CACHE_BYTES &&
this.decodedImageCache.size > 1
) {
const oldestKey = this.decodedImageCache.keys().next().value
if (!oldestKey) break
const oldest = this.decodedImageCache.get(oldestKey)
this.decodedImageCache.delete(oldestKey)
this.decodedImageCacheBytes -= oldest?.data.length ? oldest.data.length * 2 : 0
}
}
private fastProbabilisticSearch( private fastProbabilisticSearch(
attachDir: string, attachDir: string,
datName: string, datName: string,
allowThumbnail = true allowThumbnail = true,
preferThumbnail = false
): string | null { ): string | null {
const normalized = this.normalizeDatBase(datName) const normalized = this.normalizeDatBase(datName)
if (!normalized) return null if (!normalized) return null
@@ -149,7 +234,7 @@ export class ImageDecryptService {
join(attachDir, dir1, dir2, 'Image', variant), join(attachDir, dir1, dir2, 'Image', variant),
join(attachDir, dir1, dir2, 'image', variant) join(attachDir, dir1, dir2, 'image', variant)
] ]
const found = this.getLargestExistingPath(candidates, allowThumbnail) const found = this.getLargestExistingPath(candidates, allowThumbnail, preferThumbnail)
if (found) { if (found) {
imageDecryptLog('[ImageDecrypt] prefix path hit:', found) imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
return found return found
@@ -177,7 +262,8 @@ export class ImageDecryptService {
const found = this.getLargestExistingPath( const found = this.getLargestExistingPath(
variants.map((variant) => join(imgDir, variant)), variants.map((variant) => join(imgDir, variant)),
allowThumbnail allowThumbnail,
preferThumbnail
) )
if (found) { if (found) {
imageDecryptLog('[ImageDecrypt] found at:', found) imageDecryptLog('[ImageDecrypt] found at:', found)
@@ -196,7 +282,8 @@ export class ImageDecryptService {
private findImageFileInLegacyDirs( private findImageFileInLegacyDirs(
accountDir: string, accountDir: string,
datName: string, datName: string,
allowThumbnail = true allowThumbnail = true,
preferThumbnail = false
): string | null { ): string | null {
const normalized = this.normalizeDatBase(datName) const normalized = this.normalizeDatBase(datName)
if (!normalized) return null if (!normalized) return null
@@ -208,7 +295,7 @@ export class ImageDecryptService {
].filter((root) => existsSync(root)) ].filter((root) => existsSync(root))
for (const root of roots) { for (const root of roots) {
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail) const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail, preferThumbnail)
if (found) return found if (found) return found
} }
@@ -219,30 +306,52 @@ export class ImageDecryptService {
dir: string, dir: string,
datName: string, datName: string,
depth: number, depth: number,
allowThumbnail = true allowThumbnail = true,
preferThumbnail = false
): string | null { ): string | null {
if (depth < 0) return null if (depth < 0) return null
try { try {
const variants = new Set( const variantNames = this.buildPreferredDatNames(datName).filter(
this.buildPreferredDatNames(datName).filter(
(name) => allowThumbnail || !this.isThumbnailName(name) (name) => allowThumbnail || !this.isThumbnailName(name)
) )
const variants = new Set(
preferThumbnail
? [
...variantNames.filter((name) => this.isThumbnailName(name)),
...variantNames.filter((name) => !this.isThumbnailName(name))
]
: variantNames
) )
const entries = readdirSync(dir) const entries = readdirSync(dir)
const matchingFiles: string[] = []
for (const entry of entries) { for (const entry of entries) {
const fullPath = join(dir, entry) const fullPath = join(dir, entry)
const stat = statSync(fullPath) const stat = statSync(fullPath)
if (stat.isFile() && variants.has(entry.toLowerCase())) { if (stat.isFile() && variants.has(entry.toLowerCase())) {
imageDecryptLog('[ImageDecrypt] legacy path hit:', fullPath) matchingFiles.push(fullPath)
return fullPath
} }
} }
const preferredFile = this.getLargestExistingPath(
matchingFiles,
allowThumbnail,
preferThumbnail
)
if (preferredFile) {
imageDecryptLog('[ImageDecrypt] legacy path hit:', preferredFile)
return preferredFile
}
for (const entry of entries) { for (const entry of entries) {
const fullPath = join(dir, entry) const fullPath = join(dir, entry)
if (!statSync(fullPath).isDirectory()) continue if (!statSync(fullPath).isDirectory()) continue
const found = this.recursiveFindDat(fullPath, datName, depth - 1, allowThumbnail) const found = this.recursiveFindDat(
fullPath,
datName,
depth - 1,
allowThumbnail,
preferThumbnail
)
if (found) return found if (found) return found
} }
} catch { } catch {
@@ -491,7 +600,11 @@ export class ImageDecryptService {
] ]
} }
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string { private getPreferredDatVariantPath(
inputPath: string,
allowThumbnail: boolean,
preferThumbnail = false
): string {
const actualDir = dirname(inputPath) const actualDir = dirname(inputPath)
const base = this.normalizeDatBase(basename(inputPath)) const base = this.normalizeDatBase(basename(inputPath))
const variants = this.buildPreferredDatNames(base) const variants = this.buildPreferredDatNames(base)
@@ -500,13 +613,18 @@ export class ImageDecryptService {
: variants.filter((name) => !this.isThumbnailName(name)) : variants.filter((name) => !this.isThumbnailName(name))
const largest = this.getLargestExistingPath( const largest = this.getLargestExistingPath(
ordered.map((variant) => join(actualDir, variant)), ordered.map((variant) => join(actualDir, variant)),
allowThumbnail allowThumbnail,
preferThumbnail
) )
if (largest) return largest if (largest) return largest
return inputPath return inputPath
} }
private getLargestExistingPath(paths: string[], allowThumbnail: boolean): string | null { private getLargestExistingPath(
paths: string[],
allowThumbnail: boolean,
preferThumbnail = false
): string | null {
const toSized = (candidates: string[]): { candidate: string; size: number }[] => const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
candidates candidates
.filter((candidate) => existsSync(candidate)) .filter((candidate) => existsSync(candidate))
@@ -519,6 +637,10 @@ export class ImageDecryptService {
}) })
.sort((left, right) => right.size - left.size) .sort((left, right) => right.size - left.size)
const thumbnail = toSized(
paths.filter((candidate) => this.isThumbnailName(basename(candidate)))
)
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
const nonThumb = toSized( const nonThumb = toSized(
paths.filter((candidate) => !this.isThumbnailName(basename(candidate))) paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
) )
+25 -3
View File
@@ -702,7 +702,7 @@ app.whenReady().then(async () => {
imageMd5?: string, imageMd5?: string,
imageDatNameOrThumb?: string | boolean, imageDatNameOrThumb?: string | boolean,
_sessionId?: string, _sessionId?: string,
options?: { force?: boolean } options?: { force?: boolean; preferThumbnail?: boolean }
) => { ) => {
void _sessionId void _sessionId
if (!imageDecryptService) { if (!imageDecryptService) {
@@ -719,12 +719,28 @@ app.whenReady().then(async () => {
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
const force = options?.force === true const force = options?.force === true
const preferThumbnail = options?.preferThumbnail === true
const imageCacheKey = [
imageMd5 || '',
imageDatName || '',
force ? 'original' : preferThumbnail ? 'thumbnail' : 'auto'
].join('|')
const cachedImage = imageDecryptService.getCachedDecodedImage(imageCacheKey)
if (cachedImage) {
return {
success: true,
data: cachedImage.data,
isThumb: cachedImage.isThumbnail,
filePath: cachedImage.filePath
}
}
let filePath = force let filePath = force
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false }) ? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
: null : null
if (!filePath) { if (!filePath) {
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, { filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
allowThumbnail: true allowThumbnail: true,
preferThumbnail
}) })
} }
if (!filePath) { if (!filePath) {
@@ -736,12 +752,18 @@ app.whenReady().then(async () => {
return { success: false, error: '图片解密失败' } return { success: false, error: '图片解密失败' }
} }
return { const result = {
success: true, success: true,
data: decrypted.data, data: decrypted.data,
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath), isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
filePath: decrypted.filePath filePath: decrypted.filePath
} }
imageDecryptService.cacheDecodedImage(imageCacheKey, {
data: result.data,
filePath: result.filePath,
isThumbnail: result.isThumb
})
return result
} }
) )
+1 -1
View File
@@ -196,7 +196,7 @@ declare global {
imageMd5?: string, imageMd5?: string,
imageDatNameOrThumb?: string | boolean, imageDatNameOrThumb?: string | boolean,
sessionId?: string, sessionId?: string,
options?: { force?: boolean } options?: { force?: boolean; preferThumbnail?: boolean }
) => Promise<{ ) => Promise<{
success: boolean success: boolean
data?: string data?: string
+1 -1
View File
@@ -61,7 +61,7 @@ const api = {
imageMd5?: string, imageMd5?: string,
imageDatNameOrThumb?: string | boolean, imageDatNameOrThumb?: string | boolean,
sessionId?: string, sessionId?: string,
options?: { force?: boolean } options?: { force?: boolean; preferThumbnail?: boolean }
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options), ) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes), getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5), getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
+51 -69
View File
@@ -1,44 +1,6 @@
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import type { JSX, MouseEvent } from 'react' import type { JSX, MouseEvent } from 'react'
import { getCachedLoadedImage, requestImage } from './image-loader'
type CachedImage = { data: string; isThumbnail: boolean }
const MAX_IMAGE_CACHE_ENTRIES = 80
const imageDataUrlCache = new Map<string, CachedImage>()
function imageCacheKeys(imageMd5?: string, imageDatName?: string): string[] {
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
Boolean
)
}
function getCachedImage(imageMd5?: string, imageDatName?: string): CachedImage | undefined {
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
const cached = imageDataUrlCache.get(key)
if (cached) {
imageDataUrlCache.delete(key)
imageDataUrlCache.set(key, cached)
return cached
}
}
return undefined
}
function cacheImage(
imageMd5: string | undefined,
imageDatName: string | undefined,
cached: CachedImage
): void {
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
imageDataUrlCache.delete(key)
imageDataUrlCache.set(key, cached)
}
while (imageDataUrlCache.size > MAX_IMAGE_CACHE_ENTRIES) {
const oldestKey = imageDataUrlCache.keys().next().value
if (!oldestKey) break
imageDataUrlCache.delete(oldestKey)
}
}
interface ImageBubbleProps { interface ImageBubbleProps {
imageMd5?: string imageMd5?: string
@@ -53,11 +15,12 @@ export function ImageBubble({
imageMd5, imageMd5,
imageDatName, imageDatName,
sessionId, sessionId,
isThumb = false,
fallbackUrl, fallbackUrl,
onImageClick onImageClick
}: ImageBubbleProps): JSX.Element { }: ImageBubbleProps): JSX.Element {
const initialCachedImage = getCachedImage(imageMd5, imageDatName) const initialCachedImage = getCachedLoadedImage(imageMd5, imageDatName, {
preferThumbnail: true
})
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null) const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [upgrading, setUpgrading] = useState(false) const [upgrading, setUpgrading] = useState(false)
@@ -65,6 +28,26 @@ export function ImageBubble({
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail)) const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
const [usingFallback, setUsingFallback] = useState(false) const [usingFallback, setUsingFallback] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const mountedRef = useRef(true)
const backgroundUpgradeRef = useRef(false)
useEffect(() => {
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 () => { const loadImage = useCallback(async () => {
if (imageUrl || loading) return if (imageUrl || loading) return
@@ -81,37 +64,42 @@ export function ImageBubble({
setLoading(true) setLoading(true)
try { try {
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId) const result = await requestImage(
if (result.success && result.data?.startsWith('data:image/')) { imageMd5,
cacheImage(imageMd5, imageDatName, { imageDatName,
data: result.data, sessionId,
isThumbnail: Boolean(result.isThumb) { preferThumbnail: true },
}) 0
)
setImageUrl(result.data) setImageUrl(result.data)
setUsingFallback(false) setUsingFallback(false)
setIsThumbnail(Boolean(result.isThumb)) setIsThumbnail(result.isThumbnail)
setError(null) setError(null)
} else { if (result.isThumbnail) upgradeOriginalInBackground()
} catch (error) {
if (fallbackUrl) { if (fallbackUrl) {
setImageUrl(fallbackUrl) setImageUrl(fallbackUrl)
setUsingFallback(true) setUsingFallback(true)
setError(null) setError(null)
} else { } else {
setError(result.error || '加载图片失败') setError(error instanceof Error ? error.message : '加载图片失败')
}
}
} catch {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError('加载图片失败')
} }
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [fallbackUrl, imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading]) }, [
fallbackUrl,
imageDatName,
imageMd5,
imageUrl,
loading,
sessionId,
upgradeOriginalInBackground
])
useEffect(() => {
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground()
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
useEffect(() => { useEffect(() => {
if (imageUrl || loading || error) return if (imageUrl || loading || error) return
@@ -154,17 +142,11 @@ export function ImageBubble({
setUpgrading(true) setUpgrading(true)
try { try {
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId, { const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0)
force: true if (result.data.startsWith('data:image/')) {
})
if (result.success && result.data?.startsWith('data:image/')) {
cacheImage(imageMd5, imageDatName, {
data: result.data,
isThumbnail: Boolean(result.isThumb)
})
setImageUrl(result.data) setImageUrl(result.data)
setUsingFallback(false) setUsingFallback(false)
setIsThumbnail(Boolean(result.isThumb)) setIsThumbnail(result.isThumbnail)
setError(null) setError(null)
onImageClick?.(result.data) onImageClick?.(result.data)
return return
+167
View File
@@ -0,0 +1,167 @@
export type LoadedImage = {
data: string
isThumbnail: boolean
}
export type ImageLoadOptions = {
force?: boolean
preferThumbnail?: boolean
}
type QueueItem = {
priority: number
run: () => Promise<LoadedImage>
resolve: (value: LoadedImage) => void
reject: (error: Error) => void
}
const MAX_CONCURRENT_IMAGE_LOADS = 3
const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
const imageCache = new Map<string, LoadedImage>()
const imageCacheSizes = new Map<string, number>()
const imageRequests = new Map<string, Promise<LoadedImage>>()
const imageQueue: QueueItem[] = []
let activeImageLoads = 0
let imageCacheBytes = 0
function imageIdentityKeys(imageMd5?: string, imageDatName?: string): string[] {
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
Boolean
)
}
function cacheMode(options: ImageLoadOptions): string {
if (options.force) return 'original'
if (options.preferThumbnail) return 'thumbnail'
return 'auto'
}
function cacheKeys(
imageMd5: string | undefined,
imageDatName: string | undefined,
options: ImageLoadOptions
): string[] {
return imageIdentityKeys(imageMd5, imageDatName).map(
(identity) => `${identity}:${cacheMode(options)}`
)
}
function getCachedImage(
imageMd5: string | undefined,
imageDatName: string | undefined,
options: ImageLoadOptions
): LoadedImage | undefined {
const keys = cacheKeys(imageMd5, imageDatName, options)
for (const key of keys) {
const cached = imageCache.get(key)
if (!cached) continue
imageCache.delete(key)
imageCache.set(key, cached)
return cached
}
if (!options.force && options.preferThumbnail) {
for (const identity of imageIdentityKeys(imageMd5, imageDatName)) {
const fallbackKey = `${identity}:auto`
const cached = imageCache.get(fallbackKey)
if (cached) return cached
}
}
return undefined
}
function cacheImage(
imageMd5: string | undefined,
imageDatName: string | undefined,
options: ImageLoadOptions,
image: LoadedImage
): void {
const keys = cacheKeys(imageMd5, imageDatName, options)
const size = image.data.length * 2
for (const key of keys) {
const previousSize = imageCacheSizes.get(key) || 0
imageCacheBytes -= previousSize
imageCache.delete(key)
imageCacheSizes.delete(key)
imageCache.set(key, image)
imageCacheSizes.set(key, size)
imageCacheBytes += size
}
while (imageCacheBytes > MAX_IMAGE_CACHE_BYTES && imageCache.size > 1) {
const oldestKey = imageCache.keys().next().value
if (!oldestKey) break
imageCache.delete(oldestKey)
imageCacheBytes -= imageCacheSizes.get(oldestKey) || 0
imageCacheSizes.delete(oldestKey)
}
}
function pumpImageQueue(): void {
while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) {
imageQueue.sort((left, right) => left.priority - right.priority)
const item = imageQueue.shift()
if (!item) return
activeImageLoads += 1
void item
.run()
.then(item.resolve, item.reject)
.finally(() => {
activeImageLoads -= 1
pumpImageQueue()
})
}
}
export function getCachedLoadedImage(
imageMd5?: string,
imageDatName?: string,
options: ImageLoadOptions = {}
): LoadedImage | undefined {
return getCachedImage(imageMd5, imageDatName, options)
}
export function requestImage(
imageMd5: string | undefined,
imageDatName: string | undefined,
sessionId: string | undefined,
options: ImageLoadOptions = {},
priority = 0
): Promise<LoadedImage> {
const cached = getCachedImage(imageMd5, imageDatName, options)
if (cached) return Promise.resolve(cached)
const identity = imageIdentityKeys(imageMd5, imageDatName)[0]
if (!identity) return Promise.reject(new Error('缺少图片标识'))
const requestKey = `${identity}:${cacheMode(options)}`
const existingRequest = imageRequests.get(requestKey)
if (existingRequest) return existingRequest
const request = new Promise<LoadedImage>((resolve, reject) => {
imageQueue.push({
priority,
resolve,
reject,
run: async () => {
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, options)
if (!result.success || !result.data?.startsWith('data:image/')) {
throw new Error(result.error || '加载图片失败')
}
const loadedImage = {
data: result.data,
isThumbnail: Boolean(result.isThumb)
}
cacheImage(imageMd5, imageDatName, options, loadedImage)
return loadedImage
}
})
pumpImageQueue()
})
imageRequests.set(requestKey, request)
void request.then(
() => imageRequests.delete(requestKey),
() => imageRequests.delete(requestKey)
)
return request
}