perf: optimize mobile line chart performance and remove window controls

- Remove drop-shadow filters on SVG paths on mobile (SignalChart, SummaryCurve)
- Hide scan-haze overlay (mix-blend-mode: screen) on mobile
- Remove feTurbulence noise filter on mobile (biggest perf win)
- Simplify backgrounds and box-shadows on mobile
- Remove blur transition on inactive panels
- Hide window control buttons (minimize/maximize/close) on mobile
- Configure Android release build to sign with debug keystore
- Update README with changelog and Android build instructions
This commit is contained in:
lenn
2026-05-11 22:11:40 +08:00
parent 551022215c
commit c5f4f854bf
6 changed files with 252 additions and 10 deletions

View File

@@ -1,10 +1,11 @@
# Tauri Demo (SvelteKit + TypeScript)
# JE-Skin (SvelteKit + Tauri)
## 环境要求
- Node.js 18+(建议 LTS
- Rust stable`rustup` + `cargo`
- Windows 下请确保已安装 WebView2 Runtime 和 MSVC C++ 构建工具
- Android 构建需要 Android SDK + NDK
## 安装依赖
@@ -42,12 +43,43 @@ npm run build
npm run tauri build
```
构建 Android APK / AAB
```sh
npx tauri android build
```
产物路径:
- APK: `src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk`
- AAB: `src-tauri/gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab`
Release APK 默认使用 debug keystore 签名(`src-tauri/gen/android/app/je-skin-debug.keystore`),可直接 `adb install` 到设备。
## 代码检查
```sh
npm run check
```
## v0.4.0 修改记录
### 移动端性能优化
- **SignalChart / SummaryCurve**:在 `@media (max-width: 900px)` 下移除 SVG 路径上的 `filter: drop-shadow()`,避免移动端 GPU 软件回流导致卡顿
- **SignalChart**:隐藏 `.scan-haze``mix-blend-mode: screen` 合成开销大),简化面板 `background` / `box-shadow`
- **SummaryCurve**:移动端移除 `.summary-line``.summary-dot` 的 drop-shadow 滤镜
- **页面级**:移动端隐藏 `.hud-noise``feTurbulence` SVG 滤镜是最大性能杀手),降低 `.hud-vignette` 透明度,简化 `.hud-gradient`
- 移除 inactive 面板的 `filter: blur()` 过渡动画
- 移除 transition 中的 `filter` 属性,添加 `will-change: d` 优化路径更新
### 移动端 UI 调整
- **隐藏三大金刚**`@media (max-width: 900px)` 下隐藏标题栏右侧的最小化/最大化/关闭按钮Android 系统自带窗口管理)
### Android 打包
- Release 构建配置使用 debug keystore 签名,输出签名 APK 而非 unsigned
## 推荐 IDE 插件
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)

View File

@@ -43,6 +43,7 @@ android {
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
signingConfig = signingConfigs.getByName("debug")
}
}
kotlinOptions {
@@ -63,6 +64,7 @@ dependencies {
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
implementation("com.github.mik3y:usb-serial-for-android:3.9.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")

View File

@@ -460,6 +460,12 @@
color: rgb(var(--hud-orange-rgb) / 0.96);
}
@media (max-width: 900px) {
.window-controls {
display: none;
}
}
.control-bar {
display: grid;
gap: 0.45rem;

View File

@@ -200,7 +200,6 @@
border-color: transparent;
opacity: 0;
transform: translateX(var(--offset-x)) scale(0.98) rotate(-0.6deg);
filter: blur(1.3px);
pointer-events: none;
transition-delay: 0ms;
}
@@ -302,6 +301,7 @@
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 2px rgb(0 0 0 / 0.42));
will-change: d;
}
.series-line.tone-cyan {
@@ -397,6 +397,10 @@
aspect-ratio: 1.5 / 1;
min-block-size: 10.1rem;
}
.chart-stage {
block-size: clamp(5.7rem, 7.6vw, 6.9rem);
}
}
@media (max-height: 900px) {
@@ -452,6 +456,21 @@
inline-size: 100%;
aspect-ratio: 1.7 / 1;
min-block-size: 0;
background: linear-gradient(160deg, rgb(var(--hud-surface-alt-rgb) / 0.86) 0%, rgb(var(--hud-surface-deep-rgb) / 0.9) 100%);
box-shadow: inset 0 0 0 1px rgb(var(--hud-border-strong-rgb) / 0.08), 0 0 8px rgb(var(--hud-glow-rgb) / 0.1);
}
.series-line {
filter: none;
}
.scan-haze {
display: none;
}
.chart-stage {
background: linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.78), rgb(var(--hud-surface-deep-rgb) / 0.88));
box-shadow: none;
}
}
</style>

View File

@@ -637,6 +637,20 @@
@media (max-width: 900px) {
.signal-panel {
inline-size: 100%;
background: linear-gradient(160deg, rgb(var(--hud-surface-alt-rgb) / 0.86) 0%, rgb(var(--hud-surface-deep-rgb) / 0.9) 100%);
box-shadow: inset 0 0 0 1px rgb(var(--hud-border-strong-rgb) / 0.08), 0 0 8px rgb(var(--hud-glow-rgb) / 0.1);
}
.summary-line {
filter: none;
}
.summary-dot {
filter: none;
}
.chart-stage {
background: linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.78), rgb(var(--hud-surface-deep-rgb) / 0.88));
}
}
</style>

View File

@@ -44,6 +44,29 @@
dtsMs: number;
}
interface AndroidUsbSerialDevice {
name: string;
vendorId: number;
productId: number;
manufacturer: string;
product: string;
serial: string;
hasPermission: boolean;
}
interface AndroidUsbSerialListResult {
devices: AndroidUsbSerialDevice[];
}
interface AndroidUsbSerialOpenResult {
fd: number;
name: string;
vendorId: number;
productId: number;
}
type AndroidUsbSerialCommand = "list" | "open" | "close";
const copyByLocale: Record<LocaleCode, HudCopy> = {
"zh-CN": {
appName: "JE-Skin",
@@ -203,6 +226,7 @@
let connectionState: ConnectionState = "offline";
let serialPortValue = "COM14";
let serialPortOptions = ["COM4", "COM9", "COM14", "COM16"];
let androidUsbSerialDevices: AndroidUsbSerialDevice[] = [];
let isRefreshingPorts = false;
let connectionNotice = "";
let connectionNoticeTone: HudNoticeTone = "info";
@@ -287,6 +311,66 @@
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
function isAndroidRuntime(): boolean {
if (!isTauriRuntime() || typeof navigator === "undefined") {
return false;
}
return /Android/i.test(navigator.userAgent);
}
function formatAndroidUsbSerialLabel(device: AndroidUsbSerialDevice): string {
const product = device.product || device.manufacturer || "USB Serial";
const ids = `${device.vendorId.toString(16).padStart(4, "0")}:${device.productId
.toString(16)
.padStart(4, "0")}`;
return `${product} (${ids})`;
}
function findAndroidUsbSerialDevice(name: string): AndroidUsbSerialDevice | null {
return androidUsbSerialDevices.find((device) => device.name === name) ?? null;
}
function normalizeAndroidUsbSerialDevices(value: unknown): AndroidUsbSerialDevice[] {
if (Array.isArray(value)) {
return value.filter((device): device is AndroidUsbSerialDevice => {
return typeof device === "object" && device !== null && typeof (device as AndroidUsbSerialDevice).name === "string";
});
}
if (typeof value === "object" && value !== null) {
return Object.values(value).filter((device): device is AndroidUsbSerialDevice => {
return typeof device === "object" && device !== null && typeof (device as AndroidUsbSerialDevice).name === "string";
});
}
return [];
}
async function invokeAndroidUsbSerial<T>(
command: AndroidUsbSerialCommand,
args?: Record<string, unknown>
): Promise<T> {
const commandNames: Record<AndroidUsbSerialCommand, [string, string]> = {
list: ["usb_serial_list", "usbSerialList"],
open: ["usb_serial_open", "usbSerialOpen"],
close: ["usb_serial_close", "usbSerialClose"]
};
const [primary, fallback] = commandNames[command];
try {
return await invoke<T>(`plugin:usb-serial|${primary}`, args);
} catch (error) {
const message = String(error);
if (!message.includes("No command")) {
throw error;
}
return await invoke<T>(`plugin:usb-serial|${fallback}`, args);
}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
@@ -1221,6 +1305,10 @@
function handlePortChange(event: CustomEvent<string>): void {
serialPortValue = event.detail;
if (isAndroidRuntime()) {
const selectedDevice = findAndroidUsbSerialDevice(serialPortValue);
deviceValue = selectedDevice ? formatAndroidUsbSerialLabel(selectedDevice) : "Android USB Serial";
}
connectionState = "offline";
connectionNotice = "";
clearHudPanels();
@@ -1256,7 +1344,7 @@
case "InvalidConfig":
return "当前串口配置无效,请重新选择端口。";
default:
return "串口连接失败,请稍后重试。";
return `串口连接失败${errorCode}`;
}
}
@@ -1277,7 +1365,7 @@
case "InvalidConfig":
return "The selected serial port is invalid. Choose another port.";
default:
return "Connection failed. Please try again.";
return `Connection failed: ${errorCode}`;
}
}
@@ -1288,14 +1376,16 @@
const errorCode = normalizeInvokeError(error);
if (locale === "zh-CN") {
return errorCode === "ScanError"
? "串口列表刷新失败,请确认系统串口服务正常。"
: "刷新串口列表失败,请稍后重试。";
if (errorCode === "ScanError") {
return "串口列表刷新失败,请确认系统串口服务正常。";
}
return `刷新串口列表失败:${errorCode}`;
}
return errorCode === "ScanError"
? "Refreshing serial ports failed. Check whether the OS serial service is available."
: "Refreshing serial ports failed. Please try again.";
: `Refreshing serial ports failed: ${errorCode}`;
}
function resolveExportNotice(error: unknown): string {
@@ -1350,6 +1440,33 @@
isRefreshingPorts = true;
try {
try {
const result = await invokeAndroidUsbSerial<AndroidUsbSerialListResult>("list");
androidUsbSerialDevices = normalizeAndroidUsbSerialDevices(result.devices);
serialPortOptions = androidUsbSerialDevices.map((device) => device.name);
if (serialPortOptions.includes(serialPortValue)) {
return;
}
serialPortValue = serialPortOptions[0] ?? "";
const selectedDevice = findAndroidUsbSerialDevice(serialPortValue);
deviceValue = selectedDevice ? formatAndroidUsbSerialLabel(selectedDevice) : "Android USB Serial";
if (!serialPortValue) {
connectionState = "offline";
clearHudPanels();
connectionNotice =
locale === "zh-CN" ? "未发现 USB 串口设备,请确认已通过 OTG 接入设备。" : "No USB serial device found.";
connectionNoticeTone = "warn";
}
return;
} catch (androidError) {
if (isAndroidRuntime()) {
throw androidError;
}
}
const ports = await invoke<string[]>("serial_enum");
serialPortOptions = ports;
@@ -1390,7 +1507,28 @@
connectionNotice = "";
try {
const result = await invoke<SerialConnectResult>("serial_connect", { port: event.detail });
let result: SerialConnectResult;
if (isAndroidRuntime()) {
const selectedDevice = findAndroidUsbSerialDevice(event.detail);
const opened = await invokeAndroidUsbSerial<AndroidUsbSerialOpenResult>("open", {
name: event.detail,
vendorId: selectedDevice?.vendorId,
productId: selectedDevice?.productId
});
result = await invoke<SerialConnectResult>("serial_connect_fd", {
fd: opened.fd,
deviceName: opened.name,
device_name: opened.name
});
const openedDevice = findAndroidUsbSerialDevice(opened.name) ?? selectedDevice;
deviceValue = openedDevice
? formatAndroidUsbSerialLabel(openedDevice)
: `USB Serial (${opened.vendorId.toString(16)}:${opened.productId.toString(16)})`;
} else {
result = await invoke<SerialConnectResult>("serial_connect", { port: event.detail });
}
connectionState = result.connected ? "online" : "offline";
serialPortValue = result.port;
connectionNotice = "";
@@ -1398,6 +1536,13 @@
clearHudPanels();
console.info("[serial] connect result:", result.message);
} catch (error) {
if (isAndroidRuntime()) {
try {
await invokeAndroidUsbSerial("close");
} catch (closeError) {
console.warn("USB serial close after failed connect failed:", closeError);
}
}
connectionState = "offline";
connectionNotice = resolveSerialNotice(error, "connect");
connectionNoticeTone = "warn";
@@ -1409,6 +1554,9 @@
async function handleSerialDisconnect(): Promise<void> {
try {
const result = await invoke<SerialConnectResult>("serial_disconnect");
if (isAndroidRuntime()) {
await invokeAndroidUsbSerial("close");
}
connectionState = result.connected ? "online" : "offline";
connectionNotice = "";
connectionNoticeTone = "info";
@@ -2014,6 +2162,27 @@
mix-blend-mode: soft-light;
}
@media (max-width: 900px) {
.hud-noise {
display: none;
}
.hud-vignette {
opacity: 0.4;
}
.hud-gradient {
background:
linear-gradient(170deg, var(--hud-bg-20) 0%, var(--hud-bg-10) 56%, var(--hud-bg-30) 100%);
}
.hud-layout {
gap: clamp(0.3rem, 1vw, 0.6rem);
padding: clamp(0.4rem, 1.2vw, 0.8rem);
box-shadow: inset 0 -12px 24px rgb(0 0 0 / 0.24);
}
}
.hud-layout {
position: relative;
z-index: 1;