mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
feat: 全新 Korom 资源管理服务组件,优化内存占用
新增完整的程序本体资源管理与内存优化功能: 1. 新增背景图预处理服务,主进程将大图降采样至屏幕适配尺寸,避免全分辨率大图占用渲染进程大量内存 2. 实现资源注册表系统,支持引用计数、LRU自动逐出与释放回调 3. 新增资源与内存调试面板,可查看渲染进程JS堆、Electron进程内存与资源缓存状态 4. 修复进程内存信息API的单位与字段映射错误 5. 优化背景层缓存键避免过长dataURL,为Silk动画添加窗口隐藏时停帧逻辑减少GPU占用 6. 修复资源注册表类型适配问题并更新项目文档
This commit is contained in:
@@ -66,6 +66,7 @@ build/ ← generated by switch-icon.js (gitignored)
|
|||||||
- `electron/config.ts`: YAML config management (sparse save)
|
- `electron/config.ts`: YAML config management (sparse save)
|
||||||
- `electron/auth.ts`: Auth data persistence (JSON file)
|
- `electron/auth.ts`: Auth data persistence (JSON file)
|
||||||
- `electron/core/`: @xmcl/* integrations (auth, installer, launcher, modrinth, instance)
|
- `electron/core/`: @xmcl/* integrations (auth, installer, launcher, modrinth, instance)
|
||||||
|
- `electron/core/background-image.ts`: 背景图处理服务(自选背景降采样/重编码,程序本体资源管理)
|
||||||
- `electron/handlers/`: IPC handlers (config, auth, install, launch, mods, instance, background, task, system, window)
|
- `electron/handlers/`: IPC handlers (config, auth, install, launch, mods, instance, background, task, system, window)
|
||||||
|
|
||||||
## Frontend notes
|
## Frontend notes
|
||||||
@@ -73,6 +74,12 @@ build/ ← generated by switch-icon.js (gitignored)
|
|||||||
- `src/api/ipc.ts`: Core IPC utilities (invoke, onIpcEvent)
|
- `src/api/ipc.ts`: Core IPC utilities (invoke, onIpcEvent)
|
||||||
- `src/api/*.ts`: API modules wrapping IPC calls
|
- `src/api/*.ts`: API modules wrapping IPC calls
|
||||||
- `src/stores/`: Zustand state management
|
- `src/stores/`: Zustand state management
|
||||||
|
- `src/resources/`: 启动器程序本体「资源管理」子系统(与游戏无关):
|
||||||
|
- `registry.ts` 资源注册表服务(acquire/release、引用计数、预算 + LRU 逐出、onRelease 释放回调)
|
||||||
|
- `store.ts` 注册表 → zustand 镜像(调试面板消费)
|
||||||
|
- `image.ts` 图片解码管线(按显示尺寸降采样)、`hooks.ts`/`ManagedImage.tsx` 复用组件(供列表缩略图)
|
||||||
|
- 当前接线点:`BackgroundLayer` 把当前背景登记为 `background` 类资源;主进程 `background:prepare` 在进渲染端前压小大图
|
||||||
|
- 监控入口:debug 页「资源与内存」(`debug-resource`)
|
||||||
- `src/hooks/useTheme.ts`: Dark mode sync with Electron theme
|
- `src/hooks/useTheme.ts`: Dark mode sync with Electron theme
|
||||||
- `src/components/system/WindowControls.tsx`: Custom window controls (min/max/close), uses `<button>` with `WebkitAppRegion: "no-drag"`
|
- `src/components/system/WindowControls.tsx`: Custom window controls (min/max/close), uses `<button>` with `WebkitAppRegion: "no-drag"`
|
||||||
- `src/components/system/TitleBar.tsx`: Custom title bar with navigation, uses `WebkitAppRegion: "drag"`
|
- `src/components/system/TitleBar.tsx`: Custom title bar with navigation, uses `WebkitAppRegion: "drag"`
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
appVersion: 1.2.1
|
appVersion: 1.2.5
|
||||||
oobe: false
|
oobe: false
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 背景图处理服务(主进程,程序本体资源管理)。
|
||||||
|
*
|
||||||
|
* 目标:把用户自选的大图背景在进入渲染进程前,降采样到
|
||||||
|
* 「窗口实际需要」的尺寸(长边按 maxEdge 限制),从而避免
|
||||||
|
* 数 MB~数十 MB 的原图以 base64 + 全分辨率解码的形式常驻内存,
|
||||||
|
* 且不改变可见显示效果(超出屏幕物理像素的部分在视觉上不可见)。
|
||||||
|
*
|
||||||
|
* 规则:
|
||||||
|
* - 长边 ≤ maxEdge → 原样返回(零损耗,效果 100% 一致);
|
||||||
|
* - 长边 > maxEdge → 等比 resize 后重编码(JPEG 有损 q0.9 / 带透明通道用 PNG 无损);
|
||||||
|
* - 动画 GIF / 解析失败 / 无法解码 → 返回 null 或原样,由调用方回退到原始文件(不改变现有行为)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import electron from 'electron';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const { nativeImage } = electron;
|
||||||
|
|
||||||
|
export interface PreparedBackground {
|
||||||
|
dataUrl: string | null;
|
||||||
|
bytes: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
optimized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_MAP: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.bmp': 'image/bmp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSPARENT_MIMES = new Set(['image/png', 'image/webp', 'image/gif']);
|
||||||
|
|
||||||
|
function bufferToDataUrl(buffer: Buffer, mime: string): string {
|
||||||
|
return `data:${mime};base64,${buffer.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析并(按需)优化背景图片,返回可直接给 CSS 使用的 data URL。
|
||||||
|
* @param srcPath 原图文件路径
|
||||||
|
* @param maxEdge 目标长边上限(像素),默认 4096
|
||||||
|
*/
|
||||||
|
export function prepareBackgroundImage(srcPath: string, maxEdge = 4096): PreparedBackground | null {
|
||||||
|
const raw: PreparedBackground = { dataUrl: null, bytes: 0, width: 0, height: 0, optimized: false };
|
||||||
|
try {
|
||||||
|
const buffer = fs.readFileSync(srcPath);
|
||||||
|
const ext = path.extname(srcPath).toLowerCase();
|
||||||
|
const mime = MIME_MAP[ext] || 'image/png';
|
||||||
|
|
||||||
|
// 动画 GIF:nativeImage 只能解码首帧,直接原样返回,避免破坏动画
|
||||||
|
if (mime === 'image/gif') {
|
||||||
|
raw.dataUrl = bufferToDataUrl(buffer, mime);
|
||||||
|
raw.bytes = buffer.length;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = nativeImage.createFromBuffer(buffer);
|
||||||
|
if (image.isEmpty()) return null;
|
||||||
|
|
||||||
|
const size = image.getSize();
|
||||||
|
const longEdge = Math.max(size.width, size.height);
|
||||||
|
const needResize = longEdge > maxEdge && size.width > 0 && size.height > 0;
|
||||||
|
|
||||||
|
// 仅当确实需要降尺寸时才做重编码(视觉零影响的边界:超出屏幕物理像素的部分不可见);
|
||||||
|
// 未超限但体积大的图原样返回,避免任何有损重编码改变显示效果。
|
||||||
|
if (!needResize) {
|
||||||
|
raw.dataUrl = bufferToDataUrl(buffer, mime);
|
||||||
|
raw.bytes = buffer.length;
|
||||||
|
raw.width = size.width;
|
||||||
|
raw.height = size.height;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = image;
|
||||||
|
const scale = maxEdge / longEdge;
|
||||||
|
const w = Math.max(1, Math.round(size.width * scale));
|
||||||
|
const h = Math.max(1, Math.round(size.height * scale));
|
||||||
|
output = image.resize({ width: w, height: h, quality: 'best' });
|
||||||
|
if (output.isEmpty()) return null;
|
||||||
|
|
||||||
|
const outSize = output.getSize();
|
||||||
|
const hasTransparency = TRANSPARENT_MIMES.has(mime);
|
||||||
|
let outBuffer: Buffer;
|
||||||
|
let outMime: string;
|
||||||
|
if (hasTransparency) {
|
||||||
|
outBuffer = output.toPNG();
|
||||||
|
outMime = 'image/png';
|
||||||
|
} else {
|
||||||
|
outBuffer = output.toJPEG(90);
|
||||||
|
outMime = 'image/jpeg';
|
||||||
|
}
|
||||||
|
if (outBuffer.length === 0) return null;
|
||||||
|
|
||||||
|
raw.dataUrl = bufferToDataUrl(outBuffer, outMime);
|
||||||
|
raw.bytes = outBuffer.length;
|
||||||
|
raw.width = outSize.width;
|
||||||
|
raw.height = outSize.height;
|
||||||
|
raw.optimized = true;
|
||||||
|
return raw;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import electron from 'electron';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { loadConfig, saveConfig } from '../config';
|
import { loadConfig, saveConfig } from '../config';
|
||||||
|
import { prepareBackgroundImage } from '../core/background-image';
|
||||||
|
|
||||||
const { ipcMain } = electron;
|
const { ipcMain } = electron;
|
||||||
|
|
||||||
@@ -117,4 +118,14 @@ export function registerBackgroundHandlers() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 背景图降采样/重编码(程序本体资源管理):把大图在进渲染进程前压到屏幕所需尺寸
|
||||||
|
ipcMain.handle('background:prepare', async (_event, payload: { srcPath: string; maxEdge?: number }) => {
|
||||||
|
try {
|
||||||
|
const result = prepareBackgroundImage(payload.srcPath, payload.maxEdge || 4096);
|
||||||
|
return { success: true, data: result, error: null };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ export function registerSystemHandlers() {
|
|||||||
try {
|
try {
|
||||||
const info = await process.getProcessMemoryInfo();
|
const info = await process.getProcessMemoryInfo();
|
||||||
mainProcess = {
|
mainProcess = {
|
||||||
workingSetSize: info.workingSetSize,
|
workingSetSize: info.residentSet ?? 0,
|
||||||
privateBytes: info.privateBytes,
|
privateBytes: info.private ?? 0,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// 个别平台不支持 getProcessMemoryInfo,忽略
|
// 个别平台不支持 getProcessMemoryInfo,忽略
|
||||||
|
|||||||
+28
-3
@@ -23,6 +23,31 @@ function getFileAsDataUrl(filePath: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 按当前窗口实际像素需求计算背景图长边上限(含高分屏余量),
|
||||||
|
// 避免把数 MB~数十 MB 原图原样塞进渲染进程。
|
||||||
|
function computeMaxEdge(): number {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const css = Math.max(window.innerWidth || 1280, window.innerHeight || 800);
|
||||||
|
const target = Math.round(Math.max(css, 1920) * dpr * 1.1);
|
||||||
|
return Math.min(4096, Math.max(1920, target));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 经主进程降采样/重编码后返回 data URL;主进程无法处理时回退到原始文件(行为不变)。
|
||||||
|
async function prepareBackgroundDataUrl(filePath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const result = (await ipcRenderer.invoke('background:prepare', {
|
||||||
|
srcPath: filePath,
|
||||||
|
maxEdge: computeMaxEdge(),
|
||||||
|
})) as { success?: boolean; data?: { dataUrl?: string | null } | null };
|
||||||
|
if (result?.success && typeof result.data?.dataUrl === 'string' && result.data.dataUrl.length > 0) {
|
||||||
|
return result.data.dataUrl;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallthrough to raw
|
||||||
|
}
|
||||||
|
return getFileAsDataUrl(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// Generic IPC
|
// Generic IPC
|
||||||
invoke: (channel: string, ...args: unknown[]) =>
|
invoke: (channel: string, ...args: unknown[]) =>
|
||||||
@@ -76,14 +101,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// Copy to userData via main process
|
// Copy to userData via main process
|
||||||
const destPath = await ipcRenderer.invoke('background:copyToUserData', srcPath, ext);
|
const destPath = await ipcRenderer.invoke('background:copyToUserData', srcPath, ext);
|
||||||
if (!destPath) return null;
|
if (!destPath) return null;
|
||||||
return getFileAsDataUrl(destPath);
|
return prepareBackgroundDataUrl(destPath);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get cached background as base64 data URL
|
// Get cached background as base64 data URL(自动降采样到屏幕所需尺寸)
|
||||||
getBackgroundDataUrl: async (): Promise<string | null> => {
|
getBackgroundDataUrl: async (): Promise<string | null> => {
|
||||||
const filePath = await ipcRenderer.invoke('background:getCachedPath');
|
const filePath = await ipcRenderer.invoke('background:getCachedPath');
|
||||||
if (!filePath) return null;
|
if (!filePath) return null;
|
||||||
return getFileAsDataUrl(filePath);
|
return prepareBackgroundDataUrl(filePath);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Open external URL in system browser
|
// Open external URL in system browser
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { VersionCardDebug } from "./pages/debug/version-card-debug";
|
|||||||
import { UpdateDebug } from "./pages/debug/update-debug";
|
import { UpdateDebug } from "./pages/debug/update-debug";
|
||||||
import { TaskDebug } from "./pages/debug/task-debug";
|
import { TaskDebug } from "./pages/debug/task-debug";
|
||||||
import { CrashDebug } from "./pages/debug/crash-debug";
|
import { CrashDebug } from "./pages/debug/crash-debug";
|
||||||
|
import { ResourceDebug } from "./pages/debug/resource-debug";
|
||||||
import { Oobe } from "./pages/oobe";
|
import { Oobe } from "./pages/oobe";
|
||||||
import { OobeLanguage } from "./pages/oobe/step-language";
|
import { OobeLanguage } from "./pages/oobe/step-language";
|
||||||
import { OobeAgreement } from "./pages/oobe/step-agreement";
|
import { OobeAgreement } from "./pages/oobe/step-agreement";
|
||||||
@@ -74,6 +75,7 @@ const pageMap = {
|
|||||||
"debug-update": UpdateDebug,
|
"debug-update": UpdateDebug,
|
||||||
"debug-task": TaskDebug,
|
"debug-task": TaskDebug,
|
||||||
"debug-crash": CrashDebug,
|
"debug-crash": CrashDebug,
|
||||||
|
"debug-resource": ResourceDebug,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|||||||
+3
-3
@@ -21,10 +21,10 @@ export interface ProcessMemoryMetric {
|
|||||||
peakWorkingSetSize: number; // KB
|
peakWorkingSetSize: number; // KB
|
||||||
}
|
}
|
||||||
|
|
||||||
/** process.getProcessMemoryInfo()(主进程);单位为字节 */
|
/** process.getProcessMemoryInfo()(主进程);单位为 KB */
|
||||||
export interface MainProcessMemory {
|
export interface MainProcessMemory {
|
||||||
workingSetSize: number; // bytes
|
workingSetSize: number; // KB(residentSet)
|
||||||
privateBytes: number; // bytes
|
privateBytes: number; // KB(private)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemMemorySnapshot {
|
export interface SystemMemorySnapshot {
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ export function BackgroundLayer() {
|
|||||||
// 背景切换时旧条目被释放丢弃,大 dataURL 字符串不再被缓存层额外持有。
|
// 背景切换时旧条目被释放丢弃,大 dataURL 字符串不再被缓存层额外持有。
|
||||||
const trackedKey = useMemo(() => {
|
const trackedKey = useMemo(() => {
|
||||||
if (!bgImage || !bgImage.startsWith("data:")) return null;
|
if (!bgImage || !bgImage.startsWith("data:")) return null;
|
||||||
return `background:current:${bgImage.slice(0, 96)}`;
|
// 只取前缀 + 长度作为 key,避免把整段数 MB 的 dataURL 重复存进 Map key
|
||||||
|
return `background:current:${bgImage.length}:${bgImage.slice(0, 96)}`;
|
||||||
}, [bgImage]);
|
}, [bgImage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable react/no-unknown-property */
|
/* eslint-disable react/no-unknown-property */
|
||||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||||
import { forwardRef, useRef, useMemo, useLayoutEffect } from "react";
|
import { forwardRef, useRef, useMemo, useLayoutEffect, useEffect } from "react";
|
||||||
import { Color, type Mesh, type ShaderMaterial } from "three";
|
import { Color, type Mesh, type ShaderMaterial } from "three";
|
||||||
|
|
||||||
const hexToNormalizedRGB = (hex: string) => {
|
const hexToNormalizedRGB = (hex: string) => {
|
||||||
@@ -83,7 +83,17 @@ const SilkPlane = forwardRef(function SilkPlane({ uniforms }: SilkPlaneProps, re
|
|||||||
invalidate();
|
invalidate();
|
||||||
}, [ref, viewport, invalidate]);
|
}, [ref, viewport, invalidate]);
|
||||||
|
|
||||||
|
// 窗口隐藏/最小化时停帧(无可见画面,零视觉影响);恢复可见后立即刷新一帧重启动画
|
||||||
|
useEffect(() => {
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (!document.hidden) invalidate();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
return () => document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
}, [invalidate]);
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
|
if (document.hidden) return; // 不可见时不再推进 uTime / 请求帧,避免 GPU 空转
|
||||||
if (ref && typeof ref === "object" && ref.current) {
|
if (ref && typeof ref === "object" && ref.current) {
|
||||||
(ref.current.material as ShaderMaterial).uniforms.uTime.value += 0.1 * delta;
|
(ref.current.material as ShaderMaterial).uniforms.uTime.value += 0.1 * delta;
|
||||||
invalidate();
|
invalidate();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRouteStore } from "@/stores/routeStore";
|
import { useRouteStore } from "@/stores/routeStore";
|
||||||
import { Monitor, Paintbrush, CreditCard, ListTodo, ChevronRight, FlaskConical, Rocket, AlertTriangle, RefreshCw, SquareTerminal } from "lucide-react";
|
import { Monitor, Paintbrush, CreditCard, ListTodo, ChevronRight, FlaskConical, Rocket, AlertTriangle, RefreshCw, SquareTerminal, MemoryStick } from "lucide-react";
|
||||||
|
|
||||||
const debugPages = [
|
const debugPages = [
|
||||||
{
|
{
|
||||||
@@ -50,6 +50,14 @@ const debugPages = [
|
|||||||
color: "text-cyan-500",
|
color: "text-cyan-500",
|
||||||
bg: "bg-cyan-500/10",
|
bg: "bg-cyan-500/10",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "debug-resource" as const,
|
||||||
|
icon: MemoryStick,
|
||||||
|
title: "资源与内存",
|
||||||
|
desc: "监控渲染进程 JS 堆、进程工作集与资源注册表占用,验证内存优化",
|
||||||
|
color: "text-violet-500",
|
||||||
|
bg: "bg-violet-500/10",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "oobe" as const,
|
key: "oobe" as const,
|
||||||
icon: Rocket,
|
icon: Rocket,
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { RefreshCw, Eraser, RotateCcw } from "lucide-react";
|
||||||
|
import { GlassCard, PageHeader, SettingRow } from "./components";
|
||||||
|
import { getMemorySnapshot, type ProcessMemoryMetric, type SystemMemorySnapshot } from "@/api/system";
|
||||||
|
import { useResourceStore } from "@/resources/store";
|
||||||
|
import type { ResourceKind } from "@/resources/types";
|
||||||
|
|
||||||
|
interface JsHeapInfo {
|
||||||
|
used: number; // bytes
|
||||||
|
total: number; // bytes
|
||||||
|
limit: number; // bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsHeap(): JsHeapInfo | null {
|
||||||
|
const m = (performance as unknown as { memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number } }).memory;
|
||||||
|
if (!m) return null;
|
||||||
|
return { used: m.usedJSHeapSize, total: m.totalJSHeapSize, limit: m.jsHeapSizeLimit };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtMB = (bytes: number, fraction = 1): string => `${(bytes / 1024 / 1024).toFixed(fraction)} MB`;
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<ResourceKind, string> = {
|
||||||
|
background: "背景图",
|
||||||
|
image: "图片/图标",
|
||||||
|
blob: "Blob",
|
||||||
|
text: "文本",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResourceDebug() {
|
||||||
|
const [proc, setProc] = useState<SystemMemorySnapshot | null>(null);
|
||||||
|
const [heap, setHeap] = useState<JsHeapInfo | null>(null);
|
||||||
|
const [domNodes, setDomNodes] = useState(0);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const snapshot = useResourceStore((s) => s.snapshot);
|
||||||
|
const budgets = useResourceStore((s) => s.budgets);
|
||||||
|
const clearFree = useResourceStore((s) => s.clearFree);
|
||||||
|
const resetCounters = useResourceStore((s) => s.resetCounters);
|
||||||
|
|
||||||
|
const sample = async () => {
|
||||||
|
setHeap(readJsHeap());
|
||||||
|
setDomNodes(document.querySelectorAll("*").length);
|
||||||
|
try {
|
||||||
|
const data = await getMemorySnapshot();
|
||||||
|
setProc(data);
|
||||||
|
} catch {
|
||||||
|
setProc(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sample();
|
||||||
|
timerRef.current = setInterval(sample, 1000);
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const byKind = snapshot.stats.byKind;
|
||||||
|
const kinds = Object.keys(KIND_LABELS) as ResourceKind[];
|
||||||
|
const processes = [...(proc?.metrics ?? [])].sort((a, b) => b.workingSetSize - a.workingSetSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-8">
|
||||||
|
<PageHeader
|
||||||
|
title="资源与内存"
|
||||||
|
desc="启动器程序本体资源/内存监控(仅调试页可见):JS 堆、进程工作集、资源注册表占用"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 渲染进程堆 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||||
|
渲染进程 JS 堆(约 1s 采样)
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex flex-wrap gap-x-8 gap-y-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-muted-foreground">已用堆</p>
|
||||||
|
<p className="text-lg font-semibold text-foreground tabular-nums">
|
||||||
|
{heap ? fmtMB(heap.used) : "不可用"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-muted-foreground">堆上限</p>
|
||||||
|
<p className="text-lg font-semibold text-foreground tabular-nums">
|
||||||
|
{heap ? fmtMB(heap.limit) : "不可用"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-muted-foreground">DOM 节点</p>
|
||||||
|
<p className="text-lg font-semibold text-foreground tabular-nums">{domNodes}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-muted-foreground">采样时间</p>
|
||||||
|
<p className="text-lg font-semibold text-foreground tabular-nums">
|
||||||
|
{proc ? new Date(proc.timestamp).toLocaleTimeString() : "--"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进程工作集 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||||
|
Electron 进程工作集(主进程返回,KB)
|
||||||
|
</h3>
|
||||||
|
<GlassCard>
|
||||||
|
{proc === null ? (
|
||||||
|
<p className="text-[13px] text-muted-foreground">主进程不可达(非 Electron 环境)</p>
|
||||||
|
) : processes.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-muted-foreground">暂无进程数据</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-muted-foreground/70">
|
||||||
|
<th className="py-1 pr-4 font-medium">进程</th>
|
||||||
|
<th className="py-1 pr-4 font-medium text-right">PID</th>
|
||||||
|
<th className="py-1 pr-4 font-medium text-right">工作集</th>
|
||||||
|
<th className="py-1 font-medium text-right">峰值</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{processes.map((p: ProcessMemoryMetric) => (
|
||||||
|
<tr key={`${p.type}-${p.pid}`} className="border-t border-foreground/5">
|
||||||
|
<td className="py-1.5 pr-4 text-foreground">{p.type}</td>
|
||||||
|
<td className="py-1.5 pr-4 text-right text-muted-foreground tabular-nums">{p.pid}</td>
|
||||||
|
<td className="py-1.5 pr-4 text-right tabular-nums">{fmtMB(p.workingSetSize * 1024)}</td>
|
||||||
|
<td className="py-1.5 text-right text-muted-foreground tabular-nums">
|
||||||
|
{fmtMB(p.peakWorkingSetSize * 1024)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 资源注册表 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||||
|
资源注册表(background:current:* = 当前背景 dataURL 估算)
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex flex-wrap gap-x-8 gap-y-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-muted-foreground">缓存条目</p>
|
||||||
|
<p className="text-lg font-semibold text-foreground tabular-nums">{snapshot.stats.entries}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-muted-foreground">估算占用</p>
|
||||||
|
<p className="text-lg font-semibold text-foreground tabular-nums">{fmtMB(snapshot.stats.totalBytes)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-muted-foreground">命中 / 未命中</p>
|
||||||
|
<p className="text-lg font-semibold text-foreground tabular-nums">
|
||||||
|
{snapshot.stats.hits} / {snapshot.stats.misses}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-muted-foreground">LRU 逐出</p>
|
||||||
|
<p className="text-lg font-semibold text-foreground tabular-nums">{snapshot.stats.evictions}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{kinds.map((kind) => {
|
||||||
|
const group = byKind[kind];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={kind}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-foreground/10 px-3 py-1 text-[12px]"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">{KIND_LABELS[kind]}</span>
|
||||||
|
<span className="tabular-nums font-medium">
|
||||||
|
{group ? `${group.count} / ${fmtMB(group.bytes)}` : "0 / 0 MB"}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground/50">预算 {fmtMB(budgets[kind])}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<p className="text-[13px] font-medium text-foreground">条目明细(按估算字节降序)</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => useResourceStore.getState().refresh()}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-foreground/10 px-2 py-1 text-[12px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3 h-3" />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearFree}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-foreground/10 px-2 py-1 text-[12px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Eraser className="w-3 h-3" />
|
||||||
|
释放缓存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={resetCounters}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-foreground/10 px-2 py-1 text-[12px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
重置计数
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{snapshot.entries.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-muted-foreground">暂无缓存条目(自定义背景图生效后此处可见 background:current:*)</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-muted-foreground/70">
|
||||||
|
<th className="py-1 pr-4 font-medium">Key</th>
|
||||||
|
<th className="py-1 pr-4 font-medium">类型</th>
|
||||||
|
<th className="py-1 pr-4 font-medium text-right">估算</th>
|
||||||
|
<th className="py-1 font-medium text-right">引用</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{snapshot.entries.slice(0, 40).map((e) => (
|
||||||
|
<tr key={e.key} className="border-t border-foreground/5">
|
||||||
|
<td className="py-1.5 pr-4 font-mono text-[12px] text-foreground/80 max-w-[420px] truncate">
|
||||||
|
{e.key}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-4 text-muted-foreground">{KIND_LABELS[e.kind] ?? e.kind}</td>
|
||||||
|
<td className="py-1.5 pr-4 text-right tabular-nums">{fmtMB(e.bytes)}</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums">{e.refs}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GlassCard>
|
||||||
|
<SettingRow
|
||||||
|
label="优化效果验证指引"
|
||||||
|
desc="在首页空闲时记录 JS 堆与进程工作集 → 在设置里选择一张 ≥3MB 的大图作背景 → 观察 background:current 估算字节与 JS 堆;窗口最小化 Silk 动画停帧(GPU 占用回落)。"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
</SettingRow>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,9 +99,10 @@ class ResourceRegistry {
|
|||||||
payload: null,
|
payload: null,
|
||||||
inFlight: null,
|
inFlight: null,
|
||||||
cache: opts.cache ?? true,
|
cache: opts.cache ?? true,
|
||||||
onRelease: opts.onRelease,
|
|
||||||
};
|
};
|
||||||
this.entries.set(key, entry);
|
this.entries.set(key, entry);
|
||||||
|
// 用桥接闭包把调用方的 (payload: T) => void 适配为内部 (payload: unknown) => void
|
||||||
|
entry.onRelease = opts.onRelease ? (payload: unknown): void => opts.onRelease?.(payload as T) : undefined;
|
||||||
|
|
||||||
const run = async (): Promise<T | null> => {
|
const run = async (): Promise<T | null> => {
|
||||||
let value: T | null = null;
|
let value: T | null = null;
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export type RouteKey =
|
|||||||
| "debug-version-card"
|
| "debug-version-card"
|
||||||
| "debug-update"
|
| "debug-update"
|
||||||
| "debug-task"
|
| "debug-task"
|
||||||
|
| "debug-resource"
|
||||||
| "debug-crash";
|
| "debug-crash";
|
||||||
|
|
||||||
export type TitleBarMode = "default" | "sub" | "window" | "oobe";
|
export type TitleBarMode = "default" | "sub" | "window" | "oobe";
|
||||||
|
|
||||||
export type TransitionDirection = "forward" | "backward";
|
export type TransitionDirection = "forward" | "backward";
|
||||||
@@ -82,6 +82,7 @@ export const allRoutes: RouteItem[] = [
|
|||||||
{ key: "debug-version-card", label: "版本卡片调试", path: "/debug/version-card", hidden: true },
|
{ key: "debug-version-card", label: "版本卡片调试", path: "/debug/version-card", hidden: true },
|
||||||
{ key: "debug-update", label: "更新功能测试", path: "/debug/update", hidden: true },
|
{ key: "debug-update", label: "更新功能测试", path: "/debug/update", hidden: true },
|
||||||
{ key: "debug-task", label: "任务队列调试", path: "/debug/task", hidden: true },
|
{ key: "debug-task", label: "任务队列调试", path: "/debug/task", hidden: true },
|
||||||
|
{ key: "debug-resource", label: "资源与内存", path: "/debug/resource", hidden: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const topLevelKeys = new Set(routes.map((r) => r.key));
|
const topLevelKeys = new Set(routes.map((r) => r.key));
|
||||||
|
|||||||
Reference in New Issue
Block a user