本文介绍iOS耗电问题的检测手段,从开发阶段的本地工具,到线上的监控体系,帮助团队形成一套完整的耗电可观测能力。


检测手段全景

flowchart TB
    subgraph Dev["研发阶段"]
        EG["Xcode Energy Gauge
实时能效评分"] Inst["Instruments
Energy Log / Time Profiler"] XCT["XCTest能效测试"] end subgraph Test["测试阶段"] Thermal["热节流测试"] Battery["真机电量消耗测试"] IOKit["IOKit内部分析"] LPM["低电量模式回归"] end subgraph Online["线上阶段"] MK["MetricKit"] Self["自建实时采集
电量 / CPU / Thermal / 生命周期"] Report["埋点上报
数据看板"] end Dev --> Test --> Online

一、Xcode Energy Gauge

Xcode在真机调试时,“Debug Navigator → Energy Impact"提供了实时能效评估。

能效评分(Energy Impact)

Xcode将功耗分为5档:

等级含义典型值
Very Low / Low正常使用03 / 48
High偏高,需要关注9~16
Very High严重,必须优化17~20

评分由以下几个因素构成:

  • CPU:平均CPU使用率。
  • Network:网络活跃时间与流量。
  • Location:定位活跃时间与精度。
  • GPU:GPU活跃时间。
  • Background:后台活跃时间。
  • Overhead:系统唤醒代价。

典型使用流程

  1. Xcode连接真机(模拟器的数据不准确)。
  2. 运行App,选择Debug Navigator → Energy Impact。
  3. 进行典型操作(刷Feed、看视频、聊天等),观察评分。
  4. 如果出现High/Very High,使用Instruments进一步定位。

二、Instruments Energy Log

Instruments中有多个和能效相关的工具:

  • Energy Log(仅Mac Catalyst/macOS,iOS已弃用,但仍可作为参考)。
  • Time Profiler:找出CPU热点。
  • Network:查看网络请求时序。
  • Location:查看定位调用时间线。
  • Activity Monitor:看整机能效。

推荐组合:Time Profiler + Network + Points of Interest

xcrun xctrace record --template 'Time Profiler' --attach <bundleId>

在Instruments中可以同时查看:

  • CPU时间线:哪些方法占用CPU最多,是否存在不该运行时仍在跑的线程。
  • Network时间线:是否有高频小请求、尾能耗(Tail Energy)。
  • Points of Interest:业务侧打的标签,关联业务场景。

热点定位三问

在Instruments中看到一段高CPU占用时,要问自己:

  1. 是否必要? 是不是在后台或不可见页面仍在跑。
  2. 是否高效? 有没有低效算法、重复计算。
  3. 是否频繁? 是不是高频Timer或轮询触发。

三、MetricKit:线上功耗数据的黄金源

从iOS 13开始,苹果提供 MetricKit 框架,让开发者可以直接获取设备上报的功耗指标。这是最 官方、最准确 的线上功耗数据来源。

接入方式

import MetricKit

class EnergyMetricReporter: NSObject, MXMetricManagerSubscriber {
    
    static let shared = EnergyMetricReporter()
    
    func start() {
        MXMetricManager.shared.add(self)
    }
    
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            reportEnergy(payload)
        }
    }
    
    private func reportEnergy(_ payload: MXMetricPayload) {
        // 核心能效指标
        let cpuTime = payload.cpuMetrics.cumulativeCPUTime
        let fgTime = payload.applicationTimeMetrics?.cumulativeForegroundTime
        let bgTime = payload.applicationTimeMetrics?.cumulativeBackgroundTime
        let cellDown = payload.networkTransferMetrics?.cumulativeCellularDownload
        let cellUp = payload.networkTransferMetrics?.cumulativeCellularUpload
        let locTime = payload.locationActivityMetrics?.cumulativeBestAccuracyTime
        let pixelLum = payload.displayMetrics?.averagePixelLuminance?.averageMeasurement
        
        Analytics.report(name: "metric_energy", params: [
            "cpu_time_ms": cpuTime.converted(to: .milliseconds).value,
            "fg_time_s": fgTime?.converted(to: .seconds).value ?? 0,
            "bg_time_s": bgTime?.converted(to: .seconds).value ?? 0,
            "cellular_down_mb": cellDown?.converted(to: .megabytes).value ?? 0,
            "cellular_up_mb": cellUp?.converted(to: .megabytes).value ?? 0,
            "loc_best_time_s": locTime?.converted(to: .seconds).value ?? 0,
            "pixel_luminance": pixelLum?.value ?? 0,
        ])
    }
}

MetricKit核心功耗指标

分组字段含义
cpuMetricscumulativeCPUTime累计CPU时间
cumulativeCPUInstructions (iOS 14+)CPU指令数
gpuMetricscumulativeGPUTime累计GPU时间
applicationTimeMetricscumulativeForegroundTime前台时长
cumulativeBackgroundTime后台活跃时长
cumulativeBackgroundAudioTime后台音频时长
cumulativeBackgroundLocationTime后台定位时长
networkTransferMetricscumulativeCellularDownload/Upload蜂窝流量
cumulativeWifiDownload/UploadWiFi流量
locationActivityMetricscumulativeBestAccuracyTime最高精度定位时长
cumulativeHundredMetersAccuracyTime百米级精度定位时长
displayMetricsaveragePixelLuminance平均像素亮度(OLED机型)
cellularConditionMetricscellConditionTime信号质量分布

诊断数据(MXDiagnosticPayload)

iOS 14+提供诊断数据,其中与耗电强相关的是:

  • MXCPUExceptionDiagnostic:CPU超限异常(App使用CPU时间超过系统阈值)。
  • MXHangDiagnostic:主线程挂起(间接导致唤醒开销)。
  • MXDiskWriteExceptionDiagnostic:磁盘写入超限。

特点与限制

  • 数据每24小时聚合一次,第二天启动时下发。
  • 仅限真机。
  • 指标是 累计值(对应24小时窗口)。
  • 业务需要在服务端再做Per-UDID Per-Day的聚合。

四、自建实时采集体系

MetricKit 是线上耗电数据的主来源,但它是天级聚合数据,不适合回答"哪个页面刚刚把 CPU 打满了”、“这次后台同步为什么耗电”、“某个新功能在灰度期间是否立刻发热"这类实时归因问题。因此工程上通常会补一套自建采集。

自建采集不要追求实时电流级精度,而要补齐三件事:

  1. 场景归因:指标要能落到页面、功能、前后台状态、网络类型、设备和版本。
  2. 实时发现:MetricKit 第二天才回调,自建采集要能在灰度期间尽快发现异常。
  3. 低成本监控:监控本身不能制造新的唤醒、请求和 CPU 开销。

整体原则是:

MetricKit 做天级大盘,自建采集做实时归因;能事件驱动就事件驱动,必须轮询时低频采样,并且本地聚合后批量上报。

1. 采集策略总览

数据类型推荐方式建议间隔说明
电量UIDevice.batteryLevel普通场景 35 分钟;重点场景 60120 秒电量粒度粗,不适合秒级采样
CPU 使用率Mach task_threads / thread_info普通场景 5 秒;高风险场景 1 秒只采数值,不要每秒抓堆栈
CPU 堆栈触发式采集CPU 持续超阈值后采 3~5 次堆栈采集成本高,不能全量常态开启
Thermal系统通知不轮询监听 thermalStateDidChangeNotification
Low Power Mode系统通知不轮询监听 NSProcessInfoPowerStateDidChange
网络统一网络层埋点每次请求记录,本地 30~60 秒聚合上报记录请求数、重试数、流量、网络类型
定位统一定位服务埋点start/stop 成对记录记录精度、前后台、场景、持续时长
传感器/音视频/相机生命周期埋点start/stop 成对记录重点看持续时长、采样率、清晰度、帧率

自建采集 SDK 本身也要遵循能效原则:

  • 采样 Timer 使用 DispatchSourceTimer,设置 leeway,允许系统合并唤醒。
  • App 后台时不做常驻轮询,除非正在诊断明确的后台耗电问题。
  • 指标先在本地内存聚合,达到 N 条或 T 秒后批量上报。
  • 普通线上用户按 hash 稳定采样,灰度、内测和高风险功能可以提高采样率。
  • Low Power Mode 或 Thermal serious+ 时,监控本身也要降频。

示例:

final class EnergySampleTimer {
    private var timer: DispatchSourceTimer?

    func start(interval: TimeInterval = 60) {
        let queue = DispatchQueue(label: "energy.sample.timer", qos: .utility)
        let timer = DispatchSource.makeTimerSource(queue: queue)
        timer.schedule(
            deadline: .now() + interval,
            repeating: interval,
            leeway: .seconds(max(5, Int(interval * 0.1)))
        )
        timer.setEventHandler {
            EnergySampler.shared.sampleIfNeeded()
        }
        timer.resume()
        self.timer = timer
    }
}

2. 电量采样:按窗口计算掉电速率

UIDevice.batteryLevel 可以用于前台/后台窗口的掉电速率估算,但它的粒度比较粗,不能用两个相邻采样点直接判断耗电。更可靠的做法是按 会话窗口 计算:

\[ DrainRate = \frac{StartBattery - EndBattery}{DurationHours} \]

例如用户在 Feed 页面前台停留 10 分钟,电量从 80% 降到 78%:

\[ DrainRate = \frac{2\text{%}}{10 / 60} = 12\text{%}/\text{hour} \]

实现上可以在页面/场景进入时记录起点,在离开、进入后台、定时采样时计算窗口:

final class BatteryDrainMonitor {
    private var windowStartLevel: Float = -1
    private var windowStartTime: Date = Date()

    func beginWindow() {
        UIDevice.current.isBatteryMonitoringEnabled = true
        windowStartLevel = UIDevice.current.batteryLevel
        windowStartTime = Date()
    }

    func endWindow(scene: String) {
        guard UIDevice.current.batteryState == .unplugged else { return }
        guard windowStartLevel >= 0 else { return }

        let endLevel = UIDevice.current.batteryLevel
        let duration = Date().timeIntervalSince(windowStartTime)
        guard duration >= 5 * 60 else { return }

        let drop = max(0, windowStartLevel - endLevel) * 100
        let drainPerHour = drop / Float(duration / 3600)

        Analytics.report(name: "battery_drain_window", params: [
            "scene": scene,
            "drop_percent": drop,
            "duration_s": duration,
            "drain_per_hour": drainPerHour
        ])
    }
}

推荐过滤规则:

规则原因
只统计 .unplugged充电、满电、未知状态下电量变化没有参考价值
窗口时长至少 5~10 分钟窗口太短时,电量粒度误差会被放大
最好要求电量至少下降 1%如果电量没有变化,无法说明该窗口不耗电
按场景计算视频、导航、游戏、普通信息流的合理掉电速率不同
按机型/系统/网络分组老设备、蜂窝网络、弱网、低电量模式都会影响结果

推荐指标:

指标计算方式用途
前台掉电速率drop_percent / foreground_hours衡量用户使用时的真实掉电
后台掉电速率drop_percent / background_hours识别后台待机异常耗电
场景掉电速率scene_drop_percent / scene_duration_hours定位高耗电页面或功能
P95 掉电速率分场景统计 P95识别重度受影响用户

阈值建议优先用历史基线对比:

场景经验阈值
普通内容 App 前台> 8%~10% / hour 需要关注
视频/直播/导航10%~20% / hour 可能正常,要和业务基线比较
后台待机> 1%~2% / hour 就比较可疑
灰度版本同机型、同系统、同场景下较基线上涨 20%~30% 告警

3. Thermal 与 Low Power:事件驱动监控

设备热状态是能效压力的滞后指标:设备热起来,说明前一段时间 CPU/GPU、定位、相机、音视频或网络等模块有较高负载。Low Power Mode 则是系统给 App 的明确降级信号。

这两类状态都不应该轮询,而应该监听系统通知:

final class EnergyStateMonitor {
    init() {
        NotificationCenter.default.addObserver(
            forName: ProcessInfo.thermalStateDidChangeNotification,
            object: nil,
            queue: .main
        ) { _ in
            Self.reportEnergyState(reason: "thermal_change")
        }

        NotificationCenter.default.addObserver(
            forName: .NSProcessInfoPowerStateDidChange,
            object: nil,
            queue: .main
        ) { _ in
            Self.reportEnergyState(reason: "power_state_change")
        }
    }

    private static func reportEnergyState(reason: String) {
        Analytics.report(name: "energy_state_change", params: [
            "reason": reason,
            "thermal_state": thermalStateName(ProcessInfo.processInfo.thermalState),
            "low_power": ProcessInfo.processInfo.isLowPowerModeEnabled,
            "battery_level": UIDevice.current.batteryLevel,
            "scene": CurrentScene.name
        ])
    }

    private static func thermalStateName(_ state: ProcessInfo.ThermalState) -> String {
        switch state {
        case .nominal: return "nominal"
        case .fair: return "fair"
        case .serious: return "serious"
        case .critical: return "critical"
        @unknown default: return "unknown"
        }
    }
}

推荐指标:

指标计算方式用途
Thermal serious+ 用户占比serious/critical 用户数 / DAU评估高能耗压力影响面
Thermal critical 次数critical 事件数识别严重发热场景
Thermal 持续时长state_end - state_start判断是否长时间处于降频压力
Low Power 场景占比低电量模式下的功能使用次数 / 总次数判断降级策略覆盖面

告警建议:

  • 灰度版本 serious+ 用户占比较基线翻倍。
  • 某个页面或功能集中出现 critical
  • Low Power Mode 下仍出现高 CPU、高精度定位、自动播放、预加载等非必要任务。

4. CPU 采样:低频看趋势,超阈值再抓堆栈

对于需要精细到业务场景的耗电诊断,可以自建 CPU 使用率采样。它用于补齐 MetricKit 的延迟问题,重点不是每一秒都精确,而是识别 持续高 CPU、后台高 CPU、低电量/过热状态下仍高 CPU

读取进程 CPU 使用率:

#import <mach/mach.h>

CGFloat app_cpu_usage(void) {
    kern_return_t kr;
    thread_array_t thread_list;
    mach_msg_type_number_t thread_count;
    
    kr = task_threads(mach_task_self(), &thread_list, &thread_count);
    if (kr != KERN_SUCCESS) return -1;
    
    float tot_cpu = 0;
    for (int i = 0; i < thread_count; i++) {
        thread_info_data_t thinfo;
        mach_msg_type_number_t thread_info_count = THREAD_INFO_MAX;
        kr = thread_info(thread_list[i], THREAD_BASIC_INFO,
                         (thread_info_t)thinfo, &thread_info_count);
        if (kr != KERN_SUCCESS) continue;
        
        thread_basic_info_t basic_info_th = (thread_basic_info_t)thinfo;
        if (!(basic_info_th->flags & TH_FLAGS_IDLE)) {
            tot_cpu += basic_info_th->cpu_usage / (float)TH_USAGE_SCALE * 100.0;
        }
    }
    vm_deallocate(mach_task_self(), (vm_offset_t)thread_list,
                  thread_count * sizeof(thread_t));
    return tot_cpu;
}

推荐计算方式:

\[ AvgCPU = \frac{\sum CPU_i \times Interval_i}{WindowDuration} \]\[ CPUCost = \sum \frac{CPU_i}{100} \times Interval_i \]

如果 CPU 使用率实现没有按核心数归一化,多核设备上可能超过 100%,服务端需要同时记录 core_count,并计算:

\[ CPUPerCore = \frac{CPUUsage}{CoreCount} \]

推荐阈值:

场景触发条件动作
前台普通场景CPU > 80% 持续 3~5s上报高 CPU 事件,触发短时堆栈采集
后台场景CPU > 20%~30% 持续 30~60s上报后台高 CPU,关联后台任务/定位/音频状态
Low Power ModeCPU > 50% 持续 5s标记低电量高 CPU,建议触发降级
Thermal serious+CPU > 50% 持续 5s降低采样频率并上报高风险事件
模型推理/视频导出按功能单独建基线不套普通阈值,重点看耗时和 Thermal

CPU 高占用预警:

class CPUWatchdog {
    private var timer: DispatchSourceTimer?
    private var highCPUStart: Date?
    
    func start(threshold: CGFloat = 80, duration: TimeInterval = 3) {
        let t = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
        t.schedule(deadline: .now(), repeating: 1, leeway: .milliseconds(100))
        t.setEventHandler { [weak self] in
            let usage = CGFloat(app_cpu_usage())
            if usage > threshold {
                if self?.highCPUStart == nil {
                    self?.highCPUStart = Date()
                } else if Date().timeIntervalSince(self!.highCPUStart!) > duration {
                    // 抓一份主线程堆栈 + 业务上下文
                    self?.reportHighCPU(usage: usage)
                    self?.highCPUStart = nil
                }
            } else {
                self?.highCPUStart = nil
            }
        }
        t.resume()
        self.timer = t
    }
    
    private func reportHighCPU(usage: CGFloat) {
        let stack = StackCapture.captureMainThread()
        Analytics.report(name: "cpu_high", params: [
            "usage": usage,
            "stack": stack,
            "scene": CurrentScene.name
        ])
    }
}

堆栈采集要触发式执行:

低频采 CPU 数值
连续超过阈值
短时间采集 3~5 次堆栈
相同堆栈本地去重 / 退火
聚合后批量上报

这样可以避免每秒抓堆栈带来的额外 CPU 开销,也能抓到"后台还在跑计算”、“Timer 空转”、“死循环"这类典型耗电问题。

5. 网络、定位和硬件能力:优先做生命周期埋点

很多耗电问题不适合靠轮询发现,而应该在统一封装层记录生命周期。

网络层建议记录:

字段说明
request_count请求次数
retry_count重试次数
fail_count失败次数
cellular_bytes / wifi_bytes蜂窝/WiFi 流量
duration请求耗时
endpoint接口归类,建议服务端做 URL 模板化
network_typeWiFi / Cellular / constrained / expensive
scene当前页面或业务场景

推荐指标:

\[ RequestDensity = \frac{RequestCount}{ForegroundHours} \]\[ RetryRate = \frac{RetryCount}{RequestCount} \]\[ CellularIntensity = \frac{CellularMB}{ForegroundHours} \]

告警可以看:请求密度较基线 +20%~30%、重试率超过 10%~20%、某接口短时间出现重试风暴、蜂窝流量强度明显上涨。

定位层建议记录:

字段说明
start_time / stop_time定位生命周期
desiredAccuracy期望精度
activityType运动类型
distanceFilter距离过滤
is_background是否后台
scene业务场景

推荐指标:

\[ LocationDuration = StopTime - StartTime \]\[ BestAccuracyRatio = \frac{BestAccuracyDuration}{TotalLocationDuration} \]\[ BackgroundLocationRatio = \frac{BackgroundLocationDuration}{TotalLocationDuration} \]

经验阈值:

  • 非导航场景高精度定位持续 > 1~3 分钟 可疑。
  • 页面消失后定位仍持续 > 30~60 秒 可疑。
  • 后台定位用户占比突然上涨需要告警。
  • startUpdatingLocation 没有对应 stopUpdatingLocation 要作为强风险事件。

传感器、相机、音视频也按同样思路处理:记录 start/stop、采样率、帧率、分辨率、清晰度、是否后台、业务场景,再按持续时长和参数分布做归因。

6. 本地聚合与上报策略

自建采集的上报策略同样影响耗电。不要每次采样都立即发网络请求,建议使用本地缓冲 + 批量上报:

策略建议
本地聚合窗口30~60 秒,或累计 N 条事件后 flush
上报时机App 前台空闲、进入后台前、下次启动、WiFi 环境
采样率普通线上 1%~5%;灰度/内测/高风险功能可提高
稳定采样按用户或设备 hash,避免每条事件随机采样
弱网处理指数退避 + 抖动,避免监控请求重试风暴
数据保护先落盘再上传,限制单批大小,避免丢失关键异常

推荐默认配置:

电量:
  前台 session 开始/结束必采
  重点场景每 60~120s 采一次
  普通场景每 3~5min 采一次

CPU:
  普通场景 5s 一次
  高风险场景 1s 一次
  前台 >80% 持续 5s 触发堆栈
  后台 >30% 持续 30s 触发告警

Thermal / Low Power:
  监听系统通知,不轮询
  所有耗电事件顺带携带当前状态

网络 / 定位 / 音视频 / 传感器:
  统一封装 start-stop 生命周期
  本地聚合 30~60s 或累计 N 条后上报

五、IOKit电池私有API(仅供分析)

IOKit 的电池接口可以读到更细的数据,但属于私有API,不能上架AppStore,仅用于内部分析。

#import <dlfcn.h>

void *IOKit = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_LAZY);
typedef mach_port_t (*IORegistryEntryFromPathFunc)(mach_port_t, const io_string_t);
// ... IOPSCopyPowerSourcesInfo / IOPSGetPowerSourceDescription ...

可以读出:

  • 当前电压(mV)
  • 当前电流(mA,充电为正、放电为负)
  • 设计容量与实际容量
  • 循环次数
  • 温度

适合做真机对比测试,比如"开启A功能 vs 关闭A功能的电流差异”。


六、XCTest能效测试

从Xcode 11开始支持能效相关的性能指标:

func testFeedScrollEnergy() {
    let app = XCUIApplication()
    app.launch()
    
    measure(metrics: [
        XCTCPUMetric(),
        XCTMemoryMetric(),
        XCTStorageMetric(),
        XCTClockMetric()
    ]) {
        let feed = app.tables["FeedList"]
        for _ in 0..<10 {
            feed.swipeUp(velocity: .fast)
        }
    }
}

可以把关键业务场景做成能效基线,接入CI流水线,回归时自动检测劣化。


七、线上耗电监控体系

一个完整的线上耗电监控体系通常包含:

flowchart LR
    subgraph Collect["采集层"]
        MK1["MetricKit"]
        BT["Battery采样"]
        TH["Thermal事件"]
        CPU1["CPU采样"]
        NET["网络埋点"]
        LOC["定位生命周期"]
        HW["音视频/相机/传感器"]
    end
    
    subgraph Report["上报层"]
        SDK["监控SDK
延迟上报"] end subgraph Backend["服务端"] Storage["存储"] Agg["聚合计算"] Alert["告警"] end subgraph Apply["应用层"] Dash["耗电大盘"] Issue["Issue下钻"] Trace["Trace回溯"] end Collect --> Report --> Backend --> Apply

推荐的大盘指标

层级指标
App级人均日耗电(%/day)、CPU时长、后台时长、蜂窝流量、定位时长
版本级版本对比(新版本 vs 旧版本)、趋势图
场景级每个核心页面/功能的耗电贡献
机型级分机型/系统版本的耗电对比
异常级CPU Exception次数、Hang次数、Thermal升级次数

告警策略

  • 版本级告警:新版本灰度期间,人均耗电指标劣化>X%触发告警。
  • 场景级告警:某个页面的CPU时长P95突增。
  • 异常级告警:MXCPUExceptionDiagnostic数突增。

小结

阶段工具适用场景
研发Xcode Energy Gauge、Instruments、XCTest实时观察、热点定位、回归测试
测试真机电量测试、Thermal监控端到端评估、极端场景验证
线上MetricKit、自建实时采集体系全量数据、长期趋势、分版本/场景分析

检测只是手段,下一步需要对症下药。接下来进入 耗电-CPU与后台优化,以及后续的网络、定位、屏幕等优化篇。