perf(cpu): 减少菜单栏读取和磁盘排行重复计算
This commit is contained in:
1 parent
a068fb3e38
commit
87079eddb2
6 files changed
+184
-44
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user