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 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/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) 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 取消,唤醒后自动重启 |