修复内存泄漏问题,优化Blob URL清理机制

This commit is contained in:
2025-09-18 23:48:16 +08:00
parent a4583eb1f0
commit 803cc100be
9 changed files with 1009 additions and 408 deletions

48
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "vite-react-typescript-starter",
"name": "ano-banana-ai-image-editor",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vite-react-typescript-starter",
"name": "ano-banana-ai-image-editor",
"version": "0.0.0",
"dependencies": {
"@google/genai": "^1.16.0",
@@ -21,6 +21,7 @@
"konva": "^9.3.22",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-day-picker": "^9.10.0",
"react-dom": "^18.3.1",
"react-konva": "^18.2.10",
"tailwind-merge": "^3.3.1",
@@ -359,6 +360,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -2915,6 +2922,22 @@
"node": ">=12"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
@@ -5267,6 +5290,27 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "9.10.0",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.10.0.tgz",
"integrity": "sha512-tedecLSd+fpSN+J08601MaMsf122nxtqZXxB6lwX37qFoLtuPNuRJN8ylxFjLhyJS1kaLfAqL1GUkSLd2BMrpQ==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0",
"date-fns-jalali": "^4.1.0-0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",

View File

@@ -28,6 +28,7 @@
"konva": "^9.3.22",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-day-picker": "^9.10.0",
"react-dom": "^18.3.1",
"react-konva": "^18.2.10",
"tailwind-merge": "^3.3.1",

File diff suppressed because it is too large Load Diff

View File

@@ -38,9 +38,18 @@ export const ImageCanvas: React.FC = () => {
// 加载图像并在 canvasImage 变化时自动适应
useEffect(() => {
let img: HTMLImageElement | null = null;
if (canvasImage) {
const img = new window.Image();
img = new window.Image();
let isCancelled = false;
img.onload = () => {
// 检查是否已取消
if (isCancelled) {
return;
}
setImage(img);
// 每次有新图像时都自动适应画布,而不仅仅是在初始状态下
@@ -59,10 +68,26 @@ export const ImageCanvas: React.FC = () => {
// 居中图像
setCanvasPan({ x: 0, y: 0 });
};
img.onerror = () => {
if (!isCancelled) {
console.error('图像加载失败');
}
};
img.src = canvasImage;
} else {
setImage(null);
}
// 清理函数
return () => {
// 取消图像加载
if (img) {
img.onload = null;
img.onerror = null;
}
};
}, [canvasImage, stageSize, setCanvasZoom, setCanvasPan]);
// 处理舞台大小调整

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import * as indexedDBService from '../services/indexedDBService';
export const useIndexedDBListener = () => {
@@ -6,44 +6,73 @@ export const useIndexedDBListener = () => {
const [edits, setEdits] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const isMountedRef = useRef(true);
const loadRecords = async () => {
if (!isMountedRef.current) return;
try {
setLoading(true);
const allGenerations = await indexedDBService.getAllGenerations();
const allEdits = await indexedDBService.getAllEdits();
setGenerations(allGenerations);
setEdits(allEdits);
setError(null);
if (isMountedRef.current) {
setGenerations(allGenerations);
setEdits(allEdits);
setError(null);
}
} catch (err) {
console.error('从IndexedDB加载记录失败:', err);
setError('加载历史记录失败');
if (isMountedRef.current) {
setError('加载历史记录失败');
}
} finally {
setLoading(false);
if (isMountedRef.current) {
setLoading(false);
}
}
};
useEffect(() => {
// 标记组件已挂载
isMountedRef.current = true;
// 初始化数据库并加载记录
const initAndLoad = async () => {
try {
await indexedDBService.initDB();
await loadRecords();
if (isMountedRef.current) {
await loadRecords();
}
} catch (err) {
console.error('初始化IndexedDB失败:', err);
setError('初始化数据库失败');
setLoading(false);
if (isMountedRef.current) {
setError('初始化数据库失败');
setLoading(false);
}
}
};
initAndLoad();
// 设置定时器定期检查新记录
const interval = setInterval(() => {
loadRecords();
intervalRef.current = setInterval(() => {
if (isMountedRef.current) {
loadRecords();
}
}, 3000); // 每3秒检查一次
return () => clearInterval(interval);
// 清理函数
return () => {
// 标记组件已卸载
isMountedRef.current = false;
// 清除定时器
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, []);
const refresh = () => {

View File

@@ -62,6 +62,24 @@ export class GeminiService {
if (candidate.finishReason === 'IMAGE_SAFETY') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
}
// 检查finishReason为STOP但没有inlineData的情况
if (candidate.finishReason === 'STOP') {
// 检查是否有inlineData
let hasInlineData = false;
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData) {
hasInlineData = true;
break;
}
}
}
// 如果没有inlineData则抛出错误
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
throw new Error(candidate.content.parts[0].text || '生成失败:未返回图像数据');
}
}
}
const images: string[] = []
@@ -132,6 +150,24 @@ export class GeminiService {
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
}
// 检查finishReason为STOP但没有inlineData的情况
if (candidate.finishReason === 'STOP') {
// 检查是否有inlineData
let hasInlineData = false;
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData) {
hasInlineData = true;
break;
}
}
}
// 如果没有inlineData则抛出错误
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
throw new Error(candidate.content.parts[0].text || '编辑失败:未返回图像数据');
}
}
}
const images: string[] = []
@@ -196,6 +232,24 @@ export class GeminiService {
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
}
// 检查finishReason为STOP但没有inlineData的情况
if (candidate.finishReason === 'STOP') {
// 检查是否有inlineData
let hasInlineData = false;
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData) {
hasInlineData = true;
break;
}
}
}
// 如果没有inlineData则抛出错误
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
throw new Error(candidate.content.parts[0].text || '分割失败:未返回结果数据');
}
}
}
const responseText = response.candidates[0].content.parts[0].text

View File

@@ -7,6 +7,49 @@ const UPLOAD_URL = 'https://api.pandorastudio.cn/auth/OSSupload'
// 创建一个Map来缓存已上传的图像
const uploadCache = new Map<string, UploadResult>()
// 缓存配置
const MAX_CACHE_SIZE = 100; // 最大缓存条目数
const CACHE_EXPIRY_TIME = 30 * 60 * 1000; // 缓存过期时间30分钟
/**
* 清理过期的缓存条目
*/
function cleanupExpiredCache(): void {
const now = Date.now();
let deletedCount = 0;
uploadCache.forEach((value, key) => {
if (now - value.timestamp > CACHE_EXPIRY_TIME) {
uploadCache.delete(key);
deletedCount++;
}
});
if (deletedCount > 0) {
console.log(`清除了 ${deletedCount} 个过期的缓存条目`);
}
}
/**
* 检查并维护缓存大小
*/
function maintainCacheSize(): void {
// 如果缓存大小超过限制,删除最旧的条目
if (uploadCache.size >= MAX_CACHE_SIZE) {
// 获取所有条目并按时间排序
const entries = Array.from(uploadCache.entries());
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
// 删除最旧的条目,直到缓存大小在限制内
const deleteCount = Math.max(1, Math.floor(MAX_CACHE_SIZE * 0.1)); // 删除10%的条目
for (let i = 0; i < deleteCount && uploadCache.size >= MAX_CACHE_SIZE; i++) {
uploadCache.delete(entries[i][0]);
}
console.log(`缓存已满,删除了 ${deleteCount} 个最旧的条目`);
}
}
/**
* 生成图像的唯一标识符
* @param base64Data - base64编码的图像数据
@@ -35,8 +78,15 @@ export const uploadImage = async (base64Data: string, accessToken: string, skipC
const imageHash = getImageHash(base64Data)
if (!skipCache && uploadCache.has(imageHash)) {
console.log('从缓存中获取上传结果')
return uploadCache.get(imageHash)!
const cachedResult = uploadCache.get(imageHash)!;
// 检查缓存是否过期
if (Date.now() - cachedResult.timestamp < CACHE_EXPIRY_TIME) {
console.log('从缓存中获取上传结果')
return cachedResult;
} else {
// 缓存过期,删除它
uploadCache.delete(imageHash);
}
}
try {
@@ -73,6 +123,12 @@ export const uploadImage = async (base64Data: string, accessToken: string, skipC
const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || ''
const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data
// 清理过期缓存
cleanupExpiredCache();
// 维护缓存大小
maintainCacheSize();
// 将上传结果存储到缓存中
const uploadResult = { success: true, url: fullUrl, error: undefined }
uploadCache.set(imageHash, {
@@ -88,6 +144,12 @@ export const uploadImage = async (base64Data: string, accessToken: string, skipC
console.error('上传图像时出错:', error)
const errorResult = { success: false, url: undefined, error: error instanceof Error ? error.message : String(error) }
// 清理过期缓存
cleanupExpiredCache();
// 维护缓存大小(即使是失败的结果也缓存,但时间较短)
maintainCacheSize();
// 将失败的上传结果也存储到缓存中(可选)
uploadCache.set(imageHash, {
...errorResult,
@@ -152,4 +214,5 @@ export const uploadImages = async (base64Images: string[], accessToken: string,
*/
export const clearUploadCache = (): void => {
uploadCache.clear()
console.log('上传缓存已清除')
}

View File

@@ -123,6 +123,10 @@ interface AppState {
addBlob: (blob: Blob) => string;
getBlob: (url: string) => Blob | undefined;
cleanupOldHistory: () => void;
// Blob URL清理操作
revokeBlobUrls: (urls: string[]) => void;
cleanupAllBlobUrls: () => void;
}
export const useAppStore = create<AppState>()(
@@ -314,6 +318,36 @@ export const useAppStore = create<AppState>()(
// 清理旧记录以保持在限制内现在限制为1000条
if (updatedProject.generations.length > 1000) {
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
const generationsToRemove = updatedProject.generations.slice(0, updatedProject.generations.length - 1000);
generationsToRemove.forEach(gen => {
gen.sourceAssets.forEach(asset => {
if (asset.blobUrl.startsWith('blob:')) {
urlsToRevoke.push(asset.blobUrl);
}
});
gen.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
});
// 释放Blob URLs
if (urlsToRevoke.length > 0) {
set((innerState) => {
urlsToRevoke.forEach(url => {
URL.revokeObjectURL(url);
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.delete(url);
innerState = { ...innerState, blobStore: newBlobStore };
});
return innerState;
});
}
// 清理数组
updatedProject.generations.splice(0, updatedProject.generations.length - 1000);
// 同时清理IndexedDB中的旧记录
indexedDBService.cleanupOldRecords(1000).catch(err => {
@@ -409,6 +443,34 @@ export const useAppStore = create<AppState>()(
// 清理旧记录以保持在限制内现在限制为1000条
if (updatedProject.edits.length > 1000) {
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
const editsToRemove = updatedProject.edits.slice(0, updatedProject.edits.length - 1000);
editsToRemove.forEach(edit => {
if (edit.maskReferenceAssetBlobUrl && edit.maskReferenceAssetBlobUrl.startsWith('blob:')) {
urlsToRevoke.push(edit.maskReferenceAssetBlobUrl);
}
edit.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
});
// 释放Blob URLs
if (urlsToRevoke.length > 0) {
set((innerState) => {
urlsToRevoke.forEach(url => {
URL.revokeObjectURL(url);
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.delete(url);
innerState = { ...innerState, blobStore: newBlobStore };
});
return innerState;
});
}
// 清理数组
updatedProject.edits.splice(0, updatedProject.edits.length - 1000);
// 同时清理IndexedDB中的旧记录
indexedDBService.cleanupOldRecords(1000).catch(err => {
@@ -437,16 +499,56 @@ export const useAppStore = create<AppState>()(
const generations = [...state.currentProject.generations];
const edits = [...state.currentProject.edits];
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
// 如果生成记录超过1000条只保留最新的1000条
if (generations.length > 1000) {
const generationsToRemove = generations.slice(0, generations.length - 1000);
generationsToRemove.forEach(gen => {
gen.sourceAssets.forEach(asset => {
if (asset.blobUrl.startsWith('blob:')) {
urlsToRevoke.push(asset.blobUrl);
}
});
gen.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
});
generations.splice(0, generations.length - 1000);
}
// 如果编辑记录超过1000条只保留最新的1000条
if (edits.length > 1000) {
const editsToRemove = edits.slice(0, edits.length - 1000);
editsToRemove.forEach(edit => {
if (edit.maskReferenceAssetBlobUrl && edit.maskReferenceAssetBlobUrl.startsWith('blob:')) {
urlsToRevoke.push(edit.maskReferenceAssetBlobUrl);
}
edit.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
});
edits.splice(0, edits.length - 1000);
}
// 释放Blob URLs
if (urlsToRevoke.length > 0) {
set((innerState) => {
urlsToRevoke.forEach(url => {
URL.revokeObjectURL(url);
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.delete(url);
innerState = { ...innerState, blobStore: newBlobStore };
});
return innerState;
});
}
// 同时清理IndexedDB中的旧记录
indexedDBService.cleanupOldRecords(1000).catch(err => {
console.error('清理IndexedDB旧记录失败:', err);
@@ -460,6 +562,27 @@ export const useAppStore = create<AppState>()(
updatedAt: Date.now()
}
};
}),
// 释放指定的Blob URLs
revokeBlobUrls: (urls: string[]) => set((state) => {
urls.forEach(url => {
if (state.blobStore.has(url)) {
URL.revokeObjectURL(url);
const newBlobStore = new Map(state.blobStore);
newBlobStore.delete(url);
state = { ...state, blobStore: newBlobStore };
}
});
return state;
}),
// 释放所有Blob URLs
cleanupAllBlobUrls: () => set((state) => {
state.blobStore.forEach((_, url) => {
URL.revokeObjectURL(url);
});
return { ...state, blobStore: new Map() };
})
}),
{

View File

@@ -7,4 +7,11 @@ export default defineConfig({
optimizeDeps: {
exclude: ['lucide-react'],
},
resolve: {
alias: {
'react-day-picker/dist/locale/zh-CN': 'date-fns/locale/zh-CN',
'react-day-picker/dist/locale': 'date-fns/locale',
'react-day-picker/locale': 'date-fns/locale',
},
},
});