From 87079eddb2d5da59cc1704dcbcd1f4562097af7c Mon Sep 17 00:00:00 2001 From: ysicing Date: Thu, 1 Oct 2026 10:10:59 +0800 Subject: [PATCH] =?UTF-8?q?perf(cpu):=20=E5=87=8F=E5=B0=91=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=A0=8F=E8=AF=BB=E5=8F=96=E5=92=8C=E7=A3=81=E7=9B=98?= =?UTF-8?q?=E6=8E=92=E8=A1=8C=E9=87=8D=E5=A4=8D=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../XStatsUI/AppKit/MenuBarController.swift | 2 +- .../XStatsUI/AppKit/MenuBarRenderer.swift | 94 +++++++++++-------- .../XStatsUI/Settings/SettingsPages.swift | 2 +- .../Sources/XStatsUI/State/MetricsStore.swift | 14 ++- .../XStatsUITests/MenuBarReadingTests.swift | 81 ++++++++++++++++ .../XStatsUITests/MetricsStoreTests.swift | 35 +++++++ 6 files changed, 184 insertions(+), 44 deletions(-) create mode 100644 Packages/XStatsKit/Tests/XStatsUITests/MenuBarReadingTests.swift create mode 100644 Packages/XStatsKit/Tests/XStatsUITests/MetricsStoreTests.swift diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarController.swift index 3b3719a..adbece6 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarController.swift @@ -77,7 +77,7 @@ final class MenuBarController: NSObject { func refreshImages(force: Bool = false) { let settings = model.settings - let reading = MenuBarReading(model: model) + let reading = MenuBarReading(model: model, items: model.visibleMenuBarItems) for (index, entry) in items.enumerated() { let itemsToDraw = entry.key.map { [$0] } ?? model.visibleMenuBarItems var entryReading = reading diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift index 67da64e..032006e 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift @@ -58,45 +58,60 @@ struct MenuBarReading { /// 蓝牙设备电量低于这个百分比才在菜单栏提示 static let lowBluetoothPercent = 20 + /// 只读取展示项目所需的指标,避免单项预览观察无关采样并重复栅格化。 + /// 默认仍提供全部读数,供完整读数调用方使用;防休眠标记与项目无关,始终保留。 @MainActor - init(model: AppModel) { + init(model: AppModel, items: [MenuBarItem] = MenuBarItem.allCases) { let store = model.store - cpu = store.cpu?.total - cpuHistory = store.cpuTotal.elements - gpu = store.gpu?.utilization - gpuHistory = store.gpuHistory.elements - memory = store.memory?.usedFraction - memoryHistory = store.memoryHistory.elements - disk = store.disk?.usedFraction - diskHistory = disk.map { Array(repeating: $0, count: 30) } ?? [] - upload = store.network?.uploadBytesPerSecond - download = store.network?.downloadBytesPerSecond - networkLocationStyle = model.settings.publicIPLookup ? model.settings.networkLocationStyle : .off - networkCountryCode = model.network.publicAddresses?.countryCode - temperature = store.sensors?.temperature(.cpu)?.maximum - fanRPM = store.fastestFan?.current - battery = store.battery?.level - batteryHistory = battery.map { Array(repeating: $0, count: 30) } ?? [] - aiTokens = model.aiUsage.todayTokens - aiUsageShowsLocalUsage = model.settings.aiUsageShowsLocalUsage - aiQuotas = model.aiUsage.visibleQuotaProviders(for: nil).compactMap { provider in - let state = model.aiUsage.visibleQuotaState(for: provider) - guard let snapshot = state.snapshot else { return nil } - // 已过重置时间的窗口不再代表当前额度(常见于重启后离线恢复的缓存),不显示过期额度。 - let now = Date() - let windows = snapshot.windows.filter { $0.resetsAt.map { $0 > now } ?? true } - // 周额度比短时会话额度更适合作为常驻读数;模型专属周额度仅在通用周额度缺席时使用。 - let window = windows.first(where: { $0.kind == .weekly }) - ?? windows.first(where: { $0.kind == .fableWeekly }) - ?? windows.first(where: { $0.kind == .opusWeekly || $0.kind == .sonnetWeekly }) - ?? windows.first(where: { $0.kind == .session }) - return window.map { MenuBarQuota(provider: provider, window: $0, source: snapshot.source, - fetchedAt: snapshot.fetchedAt, isStale: state.isStale) } - } - batteryCharging = store.battery?.isCharging ?? false - if let lowest = model.bluetooth.lowest, - battery == nil || (model.settings.bluetoothLowBatteryInMenuBar && lowest.percent <= Self.lowBluetoothPercent) { - bluetoothDevice = (lowest.device.kind.symbol, lowest.percent) + for item in items { + switch item { + case .cpu: + cpu = store.cpu?.total + cpuHistory = store.cpuTotal.elements + case .gpu: + gpu = store.gpu?.utilization + gpuHistory = store.gpuHistory.elements + case .memory: + memory = store.memory?.usedFraction + memoryHistory = store.memoryHistory.elements + case .disk: + disk = store.disk?.usedFraction + diskHistory = disk.map { Array(repeating: $0, count: 30) } ?? [] + case .network: + upload = store.network?.uploadBytesPerSecond + download = store.network?.downloadBytesPerSecond + networkLocationStyle = model.settings.publicIPLookup ? model.settings.networkLocationStyle : .off + networkCountryCode = model.network.publicAddresses?.countryCode + case .temperature: + temperature = store.sensors?.temperature(.cpu)?.maximum + case .fan: + fanRPM = store.fastestFan?.current + case .battery: + battery = store.battery?.level + batteryHistory = battery.map { Array(repeating: $0, count: 30) } ?? [] + batteryCharging = store.battery?.isCharging ?? false + if let lowest = model.bluetooth.lowest, + battery == nil || (model.settings.bluetoothLowBatteryInMenuBar && lowest.percent <= Self.lowBluetoothPercent) { + bluetoothDevice = (lowest.device.kind.symbol, lowest.percent) + } + case .aiUsage: + aiTokens = model.aiUsage.todayTokens + aiUsageShowsLocalUsage = model.settings.aiUsageShowsLocalUsage + aiQuotas = model.aiUsage.visibleQuotaProviders(for: nil).compactMap { provider in + let state = model.aiUsage.visibleQuotaState(for: provider) + guard let snapshot = state.snapshot else { return nil } + // 已过重置时间的窗口不再代表当前额度(常见于重启后离线恢复的缓存),不显示过期额度。 + let now = Date() + let windows = snapshot.windows.filter { $0.resetsAt.map { $0 > now } ?? true } + // 周额度比短时会话额度更适合作为常驻读数;模型专属周额度仅在通用周额度缺席时使用。 + let window = windows.first(where: { $0.kind == .weekly }) + ?? windows.first(where: { $0.kind == .fableWeekly }) + ?? windows.first(where: { $0.kind == .opusWeekly || $0.kind == .sonnetWeekly }) + ?? windows.first(where: { $0.kind == .session }) + return window.map { MenuBarQuota(provider: provider, window: $0, source: snapshot.source, + fetchedAt: snapshot.fetchedAt, isStale: state.isStale) } + } + } } keepAwake = model.keepAwake.isActive } @@ -258,8 +273,9 @@ enum MenuBarRenderer { static func image(for model: AppModel) -> NSImage { let settings = model.settings - return image(reading: MenuBarReading(model: model), - items: model.visibleMenuBarItems, + let items = model.visibleMenuBarItems + return image(reading: MenuBarReading(model: model, items: items), + items: items, style: { settings.style(for: $0) }, networkStyle: settings.networkStyle, colorizeHighLoad: settings.colorizeHighLoad, diff --git a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift index 39a652c..5bf0d0b 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift @@ -489,7 +489,7 @@ private struct ItemStyleRow: View { /// 这一项按当前风格、实时读数画出来的样子,底色跟随浅色 / 深色外观 private var preview: some View { let settings = model.settings - let image = MenuBarRenderer.image(reading: MenuBarReading(model: model), items: [item], + let image = MenuBarRenderer.image(reading: MenuBarReading(model: model, items: [item]), items: [item], style: { settings.style(for: $0) }, networkStyle: settings.networkStyle, colorizeHighLoad: settings.colorizeHighLoad, fahrenheit: settings.useFahrenheit) return Image(nsImage: MenuBarRenderer.preview(image, dark: colorScheme == .dark)) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/MetricsStore.swift b/Packages/XStatsKit/Sources/XStatsUI/State/MetricsStore.swift index d998bdd..0f9b1cc 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/MetricsStore.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/MetricsStore.swift @@ -108,10 +108,18 @@ public final class MetricsStore { if let battery = snapshot.battery { self.battery = battery } if let processes = snapshot.processes { self.processes = processes - var activity: [String: Double] = [:] - for row in ProcessRowModel.grouped(processes) { - activity[row.id] = row.disk.map { $0.read + $0.write } ?? 0 + // 排行只需要磁盘计数,不必构建带名称、文案和其他指标的完整界面行。 + // 分组键与 ProcessRowModel 保持一致;同组有读取计数时才采用写入合计, + // 保留混合权限进程和缺失计数的既有语义,并分别累加读写以保持浮点运算顺序。 + var totals: [String: (read: Double?, write: Double)] = [:] + for process in processes { + let id = process.appBundlePath.map { "app-\($0)" } ?? "pid-\(process.pid)" + var total = totals[id] ?? (nil, 0) + if let read = process.diskRead { total.read = (total.read ?? 0) + read } + total.write += process.diskWrite ?? 0 + totals[id] = total } + let activity = totals.mapValues { total in total.read.map { $0 + total.write } ?? 0 } diskRanking.update(activity) } if let counts = snapshot.systemCounts { systemCounts = counts } diff --git a/Packages/XStatsKit/Tests/XStatsUITests/MenuBarReadingTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/MenuBarReadingTests.swift new file mode 100644 index 0000000..fc50d31 --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/MenuBarReadingTests.swift @@ -0,0 +1,81 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AppKit +import Foundation +import Observation +import SMC +import Testing +@testable import Metrics +@testable import XStatsUI + +@MainActor +struct MenuBarReadingTests { + @Test func batteryPreviewIgnoresCPUAndStillObservesBattery() async throws { + let suite = "MenuBarReadingTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let model = AppModel(settings: AppSettings(defaults: defaults), historyURL: nil, + aiUsageProviders: [], aiQuotaProviders: []) + await confirmation(expectedCount: 0) { changed in + withObservationTracking { + _ = MenuBarReading(model: model, items: [.battery]) + } onChange: { changed() } + var snapshot = MetricsSnapshot() + snapshot.cpu = CPULoad(total: 0.5, user: 0.3, system: 0.2, perCore: [0.5], loadAverage: [1, 1, 1]) + model.store.apply(snapshot) + } + await confirmation(expectedCount: 1) { changed in + withObservationTracking { + _ = MenuBarReading(model: model, items: [.battery]) + } onChange: { changed() } + var snapshot = MetricsSnapshot() + snapshot.battery = BatteryStatus(level: 0.75, isCharging: true, isPluggedIn: true, + isFullyCharged: false, minutesRemaining: 20, cycleCount: 30, + health: 0.98, adapterWatts: 60) + model.store.apply(snapshot) + } + } + + @Test func scopedReadingsPreserveEveryItemImage() throws { + let suite = "MenuBarReadingTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let model = AppModel(settings: AppSettings(defaults: defaults), historyURL: nil, + aiUsageProviders: [], aiQuotaProviders: []) + var snapshot = MetricsSnapshot() + snapshot.cpu = CPULoad(total: 0.83, user: 0.6, system: 0.23, perCore: [0.83], loadAverage: [1, 1, 1]) + snapshot.gpu = GPUUsage(name: "Fixture", utilization: 0.3, coreCount: 8) + snapshot.memory = MemoryUsage(total: 1_000, app: 300, wired: 100, compressed: 50, cached: 200, + swapUsed: 0, swapTotal: 0, pressure: .normal, uncompressed: 100, + swapInBytes: 0, swapOutBytes: 0) + snapshot.sensors = SensorReadings(temperatures: [TemperatureSummary(group: .cpu, average: 50, + maximum: 60, sensorCount: 4)], + fans: [FanState(id: 0, current: 1_800, minimum: 0, maximum: 6_000, + target: 1_800, isManual: false)], fanCount: 1) + snapshot.disk = DiskUsage(volumeName: "Fixture", total: 1_000, available: 350) + snapshot.network = NetworkRate(downloadBytesPerSecond: 1_500_000, uploadBytesPerSecond: 20_000, + totalDownloaded: 10_000_000, totalUploaded: 100_000) + snapshot.battery = BatteryStatus(level: 0.15, isCharging: true, isPluggedIn: true, + isFullyCharged: false, minutesRemaining: 20, cycleCount: 30, + health: 0.98, adapterWatts: 60) + model.store.apply(snapshot) + for item in MenuBarItem.allCases { + let complete = MenuBarReading(model: model) + let scoped = MenuBarReading(model: model, items: [item]) + #expect(scoped.tooltip(items: [item], fahrenheit: false) == complete.tooltip(items: [item], fahrenheit: false)) + for style in MenuBarStyle.options(for: item) { + #expect(scoped.hasSameImage(as: complete, item: item, style: style)) + let completeImage = MenuBarRenderer.image(reading: complete, items: [item], style: { _ in style }, + networkStyle: .dots, colorizeHighLoad: true, fahrenheit: false) + let scopedImage = MenuBarRenderer.image(reading: scoped, items: [item], style: { _ in style }, + networkStyle: .dots, colorizeHighLoad: true, fahrenheit: false) + for dark in [false, true] { + let before = try #require(MenuBarRenderer.preview(completeImage, dark: dark).tiffRepresentation) + let after = try #require(MenuBarRenderer.preview(scopedImage, dark: dark).tiffRepresentation) + #expect(before == after) + } + } + } + } +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/MetricsStoreTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/MetricsStoreTests.swift new file mode 100644 index 0000000..0eb15a7 --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/MetricsStoreTests.swift @@ -0,0 +1,35 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Testing +import Metrics +@testable import XStatsUI + +@MainActor +struct MetricsStoreTests { + @Test func diskRankingPreservesGroupingAndDecay() { + let store = MetricsStore() + var expected = ActivityRanking() + let processes = (0..<1_000).map { index in + var process = ProcessUsage(pid: Int32(index + 1), name: "worker-\(index)", executablePath: nil, + appBundlePath: index % 3 == 0 ? nil : "/Applications/Fixture\(index % 17).app", + cpu: Double(index % 10) / 100, memory: UInt64(index * 1_024)) + process.diskRead = index % 5 == 0 ? nil : Double(index * 17) + process.diskWrite = index % 7 == 0 ? nil : Double(index * 11) + return process + } + // 包括单独写计数、同应用混合权限、进程退出,以及空采样后的平滑衰减。 + for batch in [processes, Array(processes.prefix(200)), [], [], []] { + let activity = Dictionary(uniqueKeysWithValues: ProcessRowModel.grouped(batch).map { + ($0.id, $0.disk.map { $0.read + $0.write } ?? 0) + }) + expected.update(activity) + var snapshot = MetricsSnapshot() + snapshot.processes = batch + store.apply(snapshot) + #expect(store.diskRanking == expected) + #expect(store.processes == batch) + } + } +}