From 7ef52dae2d77642eb126a529052e47ea0cea6166 Mon Sep 17 00:00:00 2001 From: Shine Date: Sat, 13 Jun 2026 23:57:56 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E7=81=AD=E5=B1=8F=E6=9A=82=E5=81=9C?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=A0=8F=E5=AE=9A=E6=97=B6=E5=99=A8=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=8F=9C=E5=8D=95=E6=89=93=E5=BC=80=E6=97=B6?= =?UTF-8?q?=E9=80=9F=E7=8E=87=E5=86=BB=E7=BB=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MenuBarStatusController 新增 setupScreenObservers(),监听 screensDidSleep/willSleep 通知后立即 invalidate 菜单栏刷新定时器, didWake/screensDidWake 后重启,消除灭屏状态下持续 ~1.2% 的 CPU 占用 - RunLoop 模式从 .default 改为 .common,确保菜单打开期间(RunLoop 处于 .eventTracking 模式)定时器继续触发,修复速率显示冻结的 bug - updateLabels() 仅在上/下行文本实际变化时才调用 reconcileSpeedLabelWidths(), 减少无流量变化时的冗余文本测宽计算 --- NetMeter/MenuBarStatusController.swift | 51 ++++++++++++++++++++++---- docs/perf-energy.md | 2 +- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/NetMeter/MenuBarStatusController.swift b/NetMeter/MenuBarStatusController.swift index 26bda07..dac554c 100644 --- a/NetMeter/MenuBarStatusController.swift +++ b/NetMeter/MenuBarStatusController.swift @@ -47,6 +47,7 @@ final class MenuBarStatusController: NSObject { private var aboutWindow: NSWindow? /// 定时拉取速率刷新菜单栏,避免 withObservationTracking 高频闭环占满 CPU private var menuBarRefreshTimer: Timer? + private var screenObservers: [NSObjectProtocol] = [] private override init() { super.init() @@ -55,6 +56,8 @@ final class MenuBarStatusController: NSObject { deinit { shrinkWidthWorkItem?.cancel() menuBarRefreshTimer?.invalidate() + let ws = NSWorkspace.shared.notificationCenter + screenObservers.forEach { ws.removeObserver($0) } } func install(monitor: NetworkSpeedMonitor) { @@ -139,6 +142,7 @@ final class MenuBarStatusController: NSObject { item.menu = menu updateLabels() startMenuBarRefreshTimer(for: monitor) + setupScreenObservers() } private func makeArrowLabel(isUpload: Bool) -> NSTextField { @@ -176,10 +180,43 @@ final class MenuBarStatusController: NSObject { } } t.tolerance = interval * 0.5 - RunLoop.main.add(t, forMode: .default) + RunLoop.main.add(t, forMode: .common) menuBarRefreshTimer = t } + private func setupScreenObservers() { + let ws = NSWorkspace.shared.notificationCenter + screenObservers.forEach { ws.removeObserver($0) } + screenObservers.removeAll() + + let pause: @Sendable (Notification) -> Void = { [weak self] _ in + DispatchQueue.main.async { + MainActor.assumeIsolated { + self?.menuBarRefreshTimer?.invalidate() + self?.menuBarRefreshTimer = nil + } + } + } + let resume: @Sendable (Notification) -> Void = { [weak self] _ in + DispatchQueue.main.async { + MainActor.assumeIsolated { + guard let self, let monitor = self.monitor else { return } + self.startMenuBarRefreshTimer(for: monitor) + } + } + } + screenObservers = [ + ws.addObserver(forName: NSWorkspace.willSleepNotification, + object: nil, queue: nil, using: pause), + ws.addObserver(forName: NSWorkspace.screensDidSleepNotification, + object: nil, queue: nil, using: pause), + ws.addObserver(forName: NSWorkspace.didWakeNotification, + object: nil, queue: nil, using: resume), + ws.addObserver(forName: NSWorkspace.screensDidWakeNotification, + object: nil, queue: nil, using: resume), + ] + } + private func makeSpeedLabel() -> NSTextField { let f = NSTextField(labelWithString: "—") f.font = MenuBarSpeedLines.menuBarMonospaceFont @@ -280,13 +317,13 @@ final class MenuBarStatusController: NSObject { private func updateLabels() { guard let monitor else { return } let lines = MenuBarSpeedLines.make(uploadBps: monitor.uploadBps, downloadBps: monitor.downloadBps) - if labelUp?.stringValue != lines.upload { - labelUp?.stringValue = lines.upload - } - if labelDown?.stringValue != lines.download { - labelDown?.stringValue = lines.download + let uploadChanged = labelUp?.stringValue != lines.upload + let downloadChanged = labelDown?.stringValue != lines.download + if uploadChanged { labelUp?.stringValue = lines.upload } + if downloadChanged { labelDown?.stringValue = lines.download } + if uploadChanged || downloadChanged { + reconcileSpeedLabelWidths(upload: lines.upload, download: lines.download) } - reconcileSpeedLabelWidths(upload: lines.upload, download: lines.download) } /// 目标宽小于已应用宽时防抖收窄;变宽立即跟上 diff --git a/docs/perf-energy.md b/docs/perf-energy.md index 973eb77..679d633 100644 --- a/docs/perf-energy.md +++ b/docs/perf-energy.md @@ -6,7 +6,7 @@ | 优化 | 说明 | |------|------| -| RunLoop `.default` | 菜单栏 Timer 从 `.common` 改为 `.default`,允许 App Nap coalescing | +| RunLoop `.common` | 菜单栏 Timer 使用 `.common` 模式,确保菜单打开期间速率显示持续刷新 | | Timer tolerance 50% | 允许系统将唤醒合并到同一 CPU 唤醒周期 | | 休眠/灭屏停止采样 | `NSWorkspace` 通知驱动,屏关后采样 Task 取消,唤醒后自动重启 | From 7b753ca9757a0baa26f2f504995c387f6eb6a6d3 Mon Sep 17 00:00:00 2001 From: Shine Date: Sun, 14 Jun 2026 00:01:17 +0800 Subject: [PATCH 2/3] =?UTF-8?q?ci:=20=E6=96=B0=E5=A2=9E=20PR=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=9E=84=E5=BB=BA=E4=B8=8E=E8=AF=84=E8=AE=BA=E5=9B=9E?= =?UTF-8?q?=E6=8A=A5=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 69 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5045b60 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + pull-requests: write + +env: + SCHEME: NetMeter + PROJECT: NetMeter.xcodeproj + +jobs: + build-and-test: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Build (arm64) + id: build + continue-on-error: true + run: | + set -o pipefail + START=$SECONDS + xcodebuild clean build \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration Debug \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_ALLOWED=NO \ + 2>&1 + echo "duration=$((SECONDS - START))s" >> "$GITHUB_OUTPUT" + + - name: Test + id: test + continue-on-error: true + run: | + set -o pipefail + START=$SECONDS + xcodebuild test \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -destination 'platform=macOS' \ + 2>&1 + echo "duration=$((SECONDS - START))s" >> "$GITHUB_OUTPUT" + + - name: Post result to PR + if: github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: ci-result + message: | + ## CI 结果 + + | 步骤 | 状态 | 耗时 | + |------|------|------| + | 🔨 Build (arm64) | ${{ steps.build.outcome == 'success' && '✅ 通过' || '❌ 失败' }} | ${{ steps.build.outputs.duration }} | + | 🧪 Tests | ${{ steps.test.outcome == 'success' && '✅ 通过' || '❌ 失败' }} | ${{ steps.test.outputs.duration }} | + + > `${{ github.sha }}` · [查看完整日志](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + - name: Fail if build or tests failed + if: steps.build.outcome == 'failure' || steps.test.outcome == 'failure' + run: exit 1 From f75db5b6dce4c4a652dc1196370a7235f98d5342 Mon Sep 17 00:00:00 2001 From: Shine Date: Sun, 14 Jun 2026 00:29:06 +0800 Subject: [PATCH 3/3] =?UTF-8?q?perf:=20=E7=A9=BA=E9=97=B2=E9=80=80?= =?UTF-8?q?=E9=81=BF=E6=94=B9=E4=B8=BA=E6=8C=87=E6=95=B0=E9=80=92=E8=BF=9B?= =?UTF-8?q?=E5=BC=8F=EF=BC=8C=E5=8E=BB=E9=99=A4=E6=AD=BB=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=20idleBackoffCap=EF=BC=9B=E7=BC=93=E5=AD=98=20DisplayName?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- NetMeter/NetMeterApp.swift | 4 ++-- NetMeter/NetworkSpeedMonitor.swift | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/NetMeter/NetMeterApp.swift b/NetMeter/NetMeterApp.swift index 75e7fb5..60c8fd1 100644 --- a/NetMeter/NetMeterApp.swift +++ b/NetMeter/NetMeterApp.swift @@ -21,10 +21,10 @@ struct NetMeterApp: App { /// 菜单、关于页、状态栏 toolTip 用的展示名(勿用 `AppBundleDisplay` 作类型名,易与编译器生成符号冲突) enum NetMeterDisplayName { - static var resolved: String { + static let resolved: String = { let b = Bundle.main if let s = b.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String, !s.isEmpty { return s } if let s = b.object(forInfoDictionaryKey: "CFBundleName") as? String, !s.isEmpty { return s } return ProcessInfo.processInfo.processName - } + }() } diff --git a/NetMeter/NetworkSpeedMonitor.swift b/NetMeter/NetworkSpeedMonitor.swift index c727ea4..165da2e 100644 --- a/NetMeter/NetworkSpeedMonitor.swift +++ b/NetMeter/NetworkSpeedMonitor.swift @@ -24,11 +24,9 @@ struct SpeedDisplayState: Equatable, Sendable { private enum SamplingPolicy { static let minInterval: Double = 0.2 static let maxInterval: Double = 60 - /// 连续无流量超过此次数后降低采样频率 + /// 连续无流量超过此次数后进入退避;每经过一个 threshold 周期指数翻倍,直至 maxInterval static let idleStreakThreshold = 3 static let idleBackoffMultiplier: Double = 2 - /// 空闲降频上限(秒) - static let idleBackoffCap: Double = 8 } // MARK: - 门面 @@ -230,7 +228,11 @@ final class NetworkSpeedMonitor: ObservableObject, @unchecked Sendable { var sleepSec = interval if idleStreak >= SamplingPolicy.idleStreakThreshold { - sleepSec = min(SamplingPolicy.idleBackoffCap, interval * SamplingPolicy.idleBackoffMultiplier) + let steps = idleStreak / SamplingPolicy.idleStreakThreshold + sleepSec = min( + SamplingPolicy.maxInterval, + interval * pow(SamplingPolicy.idleBackoffMultiplier, Double(steps)) + ) } let sleepClamped = max(SamplingPolicy.minInterval, min(sleepSec, SamplingPolicy.maxInterval)) let ns = UInt64(sleepClamped * 1_000_000_000)