Android App 电量统计原理与优化

4679次阅读  |  发布于3年以前

App “耗电综合征”

当我们说一个 App 耗电的时候我们在说什么?

我们可能是指 App 吃 CPU 导致系统掉电快,也可能是在说系统告警 App 后台扫描频繁消耗电量,还可能是在说使用 App 时手机发烫严重…… 是的,相对于 Crash、ANR 等常见的 APM 指标,Android App 电量优化更像是一个综合性的问题。

一方面,造成 App 耗电的原因是多种多样的,比如 CPU/GPU Load、屏幕、传感器以及其他硬件开销等,每个分类的排查思路是大相径庭的,再加上 AOSP 没有 “官方” 的耗电异常检测框架,各个 OEM 厂商自家系统对 App 耗电的监控方案又各不相同(且没有充分的公开文档),所以检测方案需要结合具体 App 项目实际和用户反馈状况,针对具体的耗电类型做出考量和取舍。另一方面,耗电问题也经常是比较 “主观” 的,比如用户感觉 App 新版本掉电比较快了,或者在户外气温比较高的环境使用 App 时感觉设备发烫了,又或只是单纯的因为使用时间变长了导致系统耗电排行靠前了等等,这些通常都是一些比较微妙的主观感受,难以量化问题。

因此,如何检测各种类型的耗电异常,以及如何提炼耗电问题的规则(划红线)是优化电量指标的关键所在。微信 Android 项目在与 App 耗电异常这项 “疑难杂症” 日常斗智斗勇的过程中,产出了一些比较实用的工具和优化思路。本文针对 Anroid App 的耗电问题,具体分为 “App 电量统计原理”、“耗电异常监控方案”、以及相关的 “优化案例” 三部分进行解析和分享。

App 电量统计原理

电量计算公式

了解 App 电量统计原理之前,有必要先复习一下电量计算公式:

电量 = 功率 × 时间

其中需要注意一点的是, 功率 = 电压 × 电流。而在数码产品中,元器件一般对电流比较敏感,而电压基本是恒定的,所以我们直接使用电流来代替功率,这也是我们经常说 “毫安时”(mAh)而不说 “千瓦时 / 度”(kWh)的原因。

Android 硬件模块的电量统计方式

了解计算公式之后,App 的电量统计思路就比较清晰了:

App 电量 = SUM (模块功率 × 模块时间)

其中模块主要是指 Android 设备的各种硬件模块,主要可以分为以下三类。

第一类,像 Camera/FlashLight/MediaPlayer/ 一般传感器等之类的模块,其工作功率基本和额定功率保持一致,所以模块电量的计算只需要统计模块的使用时长再乘以额定功率即可。

第二类,像 Wifi/Mobile/BlueTooth 这类数据模块,其工作功率可以分为几个档位。比如,当手机的 Wifi 信号比较弱的时候,Wifi 模块就必须工作在比较高的功率档位以维持数据链路。所以这类模块的电量计算有点类似于我们日常的电费计算,需要 “阶梯计费”。

第三类,也是最复杂的模块,CPU 模块除了每一个 CPU Core 需要像数据模块那样阶梯计算电量之外,CPU 的每一个集群(Cluster,一般一个集群包含一个或多个规格相同的 Core)也有额外的耗电,此外整个 CPU 处理器芯片也有功耗。简单计算的话,CPU 电量 = SUM (各核心功耗) + 各集群(Cluster)功耗 + 芯片功耗 。如果往复杂方向考虑的话,CPU 功耗还要考虑超频以及逻辑运行的信息熵损耗等电量损耗(这方面有兴趣的话可以自行拓展查证,Android 系统 CPU 的电量统计只计算到芯片功耗这一层)。屏幕模块的电量计算就更麻烦了,很难把屏幕功耗合理地分配给各个 App, 因此 Android 系统只是简单地计算 App 屏幕锁(WakeLock)的持有时长,按固定系数增加 App CPU 的统计时长,粗略地把屏幕功耗算进 CPU 里面。

最后,需要特别注意的是, 以上提到的各种功率和时间在 Android 系统上的统计都是估算的 ,可想而知最终计算出来的电量数值可能与实际值相差巨大,这点大家心里要有一点概念。

Android 系统电量统计服务

Android 系统的电量统计工作,是由一个叫 BatteryStatsService 的系统服务完成的。

先了解一下其中四个比较关键的角色:

  1. 功率:power_profile.xml,Android 系统使用此文件来描述设备各个硬件模块的额定功率,包括上面提到的多档位功率和 CPU 电量算需要到的各种参数值。
  2. 时长:StopWatch & SamplingCounter,其中 StopWatch ⏱ 是用来计算 App 各种硬件模块的使用时长,而 SamplingCounter 则是用来采样统计 App 在不同 CPU Core 和不同 CpuFreq 下的工作时长。
  3. 计算:PowerCalculators,每个硬件模块都有一个相应命名的 PowerCalculator 实现,主要是用来完成具体的电量统计算法。
  4. 存储:batterystats.bin,电量统计服务相关数据的持久化文件。

工作流程

BatteryStatsService 的工作流程大致可以分为两个部分:时长统计 & 功耗计算。

BatteryStatsService 时长统计流程

BatteryStatsService 框架的核心是 ta 持有的一个叫 BatteryStats 的类,BatteryStats 又持有一个 Uid [] 数组,每一个 Uid 实例实际上对应一个 App,当我们安装或者卸载 App 的时候,BatteryStats 就会更新相应的 Uid 元素以保持最新的映射关系。同时 BatteryStats 持有一系列的 StopWatch 和 SamplingCounter,当 App 开始使用某些硬件模块的功能时,BatteryStats 就会调用相应 Uid 的 StopWatch 或 SamplingCounter 来统计其硬件使用时长。

这里以 Wifi 模块来举例:当 App 通过 WifiManager 系统服务调用 Wifi 模块开始扫描的时候,实际上会通过 WifiManager#startScan () --> WifiScanningServiceImp --> BatteryStatsService#noteWifiScanStartedFromSource () --> BatteryStats#noteWifiScanStartedLocked (uid) 等一连串的调用,通知 BatteryStats 开启 App 相应 Uid 的 Wifi 模块的 StopWatch 开始计时。当 App 通过 WifiManager 停止 Wifi 扫描的时候又会通过类似的流程调用 BatteryStats#noteWifiScanStoppedLocked (uid) 结束 StopWatch 的计时,这样一来就通过 StopWatch 完成 App 对 Wifi 模块使用时长的统计。

BatteryStatsService 功耗计算流程

具体电量计算方面,BatteryStats 是通过 ta 依赖的一个 BatteryStatsHelper 的辅助类来完成的。BatteryStatsHelper 通过组合使用 Uid 里的时长数据、PoweProfile 里的功率数据(power_profile.xml 的解析实例)以及具体各个模块的 PowerCalculator 算法,计算出每一个 App 的综合电量消耗,并把计算结果保存在 BatterySipper [] 数组里(按计算值从大到小排序)。

还是以 Wifi 模块来举例:当需要计算 App 电量消耗的时候,BatteryStats 会通过调用 BtteryStatsHelper#refreshStats () --> #processAppUsage () 来刷新 BatterySipper [] 数组以计算最新的 App 电量消耗数据。而其中 Wifi 模块单独的电量统计就是在 processAppUsage 方法中通过 WifiPowerCalculator 来完成的:Wifi 模块电量 = PowerProfile 预置的 Idle 功率 + Uid 统计的 Wifi Idle 时间 + 上行功率 × 上行时间 + 下行功率 × 下行时间。

public class WifiPowerCalculator extends PowerCalculator {

    @Override
    public void calculateApp(BatterySipper app, BatteryStats.Uid u, long rawRealtimeUs,
                             long rawUptimeUs, int statsType) {
        ...
        app.wifiPowerMah =
                ((idleTime * mIdleCurrentMa) + (txTime * mTxCurrentMa) + (rxTime * mRxCurrentMa))
                / (1000*60*60);
    }
}

应用场景

作为补充,这里罗列几个 BatteryStatsService 系统服务的应用场景来说明其工作方式。

Android 系统 App 耗电排行

通过以上分析,我们其实已经知道 Android 系统 App 耗电排行是通过读取 BatteryStatsHelper 里的 BatterySipper [] 数据来实现排行的。一般情况下,BatteryStats 的统计口径是 STATS_SINCE_CHARGED, 也就距离上次设备充满电到现在的状态。不过个别 OEM 系统上这里的统计细节有所不同,有的 Android 设备系统可以显示最近数天甚至一周以上的 App 的电量统计数据,具体实现细节不得而知,姑且推断是根据 BatteryStatsHelper 自行定制的服务。

adb dumpsys batterystats & adb bugreport

或许你已经知道怎么通过 adb dumpsys batterystats 或者 adb bugreport Dump 出系统的电量统计数据,以及如何配合 Battery Historian 工具来分析这些数据,实际上这些 adb 命令都是通过 BatteryStatsService 查询 BatteryStats 里持有的 Uid [] 来获得相应的电量统计数据,具体实现可以参考 com.android.server.am.BatteryStatsService#dump

CPU Load/Usage

“CPU Load xx% yy% zz%” 之类的数据相信大家都或多或少见过,ANR 的 traces.txt、以上的 batterystats 和 bugreport Dump 出来的数据,以及 adb top 命令里都会显示类似的 CPU 负载数据,实际上这个数据也是通过 CPU 模块的统计时长来计算:CPU Load = SUM (App CPU Core 时长时间) / CPU 工作时间。需要注意的是 App CPU 时长是按 CPU Core 为单位分开计算的,所以计算结果完全可能超过 100%,比如一个 8 核心的 CPU 计算结果的理论上限是 800%。

BatteryCanary

经过以上分析,我们知道 BatteryStatsService 里已经有比较详细的 App 电量统计数据。不过上帝刚给我们开了一扇窗,转身就把门给拆了。实际上这个系统服务对 App 是隐藏的,也就是说在 App 里我们无法直接访问 BatteryStatsService 里的数据(HealthStats 服务能间接访问一部分数据),不过这也不是说我们完全没有办法。

既然我们已经知道了 Android 系统的调用统计原理,那么用类似的计算方案在 App 内部进行电量统计应该也能得到一个近似解 。这也是 Matrix BatteryCanary 的核心原理,具体实现大致可以分为两部分:线程监控 & 系统服务调用监控。

线程监控

实际上,我们除了通过 SystemClock.currentThreadTimeMillis () 来获取当前 Java 线程的工作时间此外,并没有直接的办法能够直接获取 App 所有线程的工作时长和状态,幸运的是 Linux 的 proc 命令可以给我们提供一些帮助。

Linux 命令 proc/[pid]/statproc/[pid]/task/[tid]/stat 可以 Dump 当前 App 进程和线程的统计信息。

> cat /proc/<mypid>/task/<tid>/stat
10966 (terycanary.test) S 699 699 0 0 -1 1077952832 6187 0 0 0 22 2 0 0 20 0 17 0 9087400 5414273024 24109 18446744073709551615 421814448128 421814472944 549131058960 0 0 0 4612 1 1073775864 1 0 0 17 7 0 0 0 0 0 421814476800 421814478232 422247952384 549131060923 549131061022 549131061022 549131063262 0

这里比较关键的数据是 进程 / 线程名进程 / 线程状态,以及第 13 - 16 位的 utimestimecutimecstime。utime 和 stime 分别是进程 / 线程的用户时间和系统时间,而 cutime/cstime 是当前进程等在子进程的时间(在 Android 进程上大都是 0)。实际上我们对这些数据内容也不完全是陌生的,Logcat 里一些线程相关的 syslog 也有类似的输出,如下图所示。

在这里有一点需要单独拎出来讲:utime 和 stime 具体代表什么意义呢? 我们已经知道它们是表示线程的工作时长,但实际上其单位 jiffy 并不是一个时间的单位,而是一个频率的单位!

就如你所看到的,上图截图中有个圈起来的线程数据:HZ=100。这个数值代表当前 Linux 系统给进程(具体点说是线程)分配 CPU Slice 资源的周期频率是 100Hz,换句话说就是系统软件中断的频率是每秒 100 次。假如一个周期的这 100 个 CPU Slice 全部分配给某个线程,那么这个线程的 CPU 开销就是 100 Jiffies,共占用 CPU 一个周期的时间 (1 秒)。

Android Linux 上,100 Jiffies ≈ 1 Second

所以我们可以记住一个比较重要的结论:在 Android 系统上,Jiffy 和 Millis 的换算关系大概是 1 比 10。(100 Hz 是一个 Linux 系统的编译参数,在不同的 Linux 版本上这个值可能是不同的。)

系统服务调用监控

系统服务调用监控就比较简单了,主要分为 SystemService Hook 和 ASM 插桩两种方案,具体实现大家应该都比较熟悉了,这里不再赘述。不同需要注意的是,SystemService Hook 在不同 Android API Level 上面存在较多的兼容性问题(特别是在新 API Level 的变动上面坑比较多),而 SystemService ASM 经常会插漏一些运行时动态加载的插件。具体生产环境的应用上,以 ASM 为主 Hook 为辅的方案比较合适。

一些实现细节

以上提到的监控方案,已经能兜住大部分的 App 耗电监控需要,不过在实际具体项目的实现细节上,依然存在不少的挑战。

App 状态统计

电量监控跟其他质量指标监控相比有个不同的地方就是 ta 是一个过程,其他异常比如 Crash / ANR 发生的时候是瞬时的。比如 Crash 发生的时候,可以一并获取 App 的前后台状态再上报,这样我们就能有效区分前台、后台 Crash(这很重要,因为前台 Crash 优先级要高许多)。然而电量上报就不能这么做了,因为我们监控到一个 App 在 10 分钟的统计窗口里出现了耗电异常,当我们上报的时候获取得到 App 状态是后台的,然而 App 完全有可能前面 9 分钟都是前台的,最后 1 分钟才进入了后台,因此这样我们统计出来的状态数据是有失真的。同样,设备充电、亮灭屏等状态也有类似的问题。

这个问题早期给我们带来不少麻烦,甚至造成一些电量 Bug 交付的乌龙。好在我们很快就发现这个问题并做出了改善:类似 CPU Slice,我们使用一种叫做 Event Slice 的方案来计算一段时间窗口内 App 的状态占比。根据上图,在设备状态发生变化的时候,我们记下每一个 Event 的 Stamp,统计 App 状态时,根据统计窗口的起始时间和结束时间,我们就可以计算出这段时长内 App 每个事件状态的占比。

线程池问题细分

通过 procStat 监控到的异常线程,如果其业务相关性比较强的话一般都能很快解决。不过如果出现异常的是线程池之类的线程就比较麻烦了:和上面的状态统计一样,线程出现异常的时候,正在执行的 Runnable 任务,不一定就是真正导致异常的元凶。

为了解决这个问题,我们设计了以下两种方案:

  1. Runnable Jiffies 统计:我们给线程池的每一个 TaskRunnable 实例都包裹一层 RunnableWrapper,并在 Wrapper#run 里执行 TaskRunnable 的前后,分别计算当前线程的 Jiffies 差值 Delta,已得到当前 TaskRunnable 的实际 Jiffies 开销。
  2. Task Slice 方案:在方案 1 的基础上,我们已经得到每个线程池线程上执行的 TaskRunnable Jiffies 信息,然后应用类似 Event Slice 的统计方案,我们就能得出每个线程池线程某一段出现异常的 Jiffies Delta 窗口里,执行的 TaskRunnable 的占比信息,从而推断出哪些 TaskRunnable 是导致线程耗电的元凶。

线程异常现场

通过线程 Jiffies 和线程 State,我们可以监控到某些线程出现了异常,比如 App 待机的时候线程一直处于 Running 状态(State R)并且持续消耗 Jiffies。毋庸置疑,我们已经发现了 App 耗电异常的原因和问题现场。这时候新的挑战出现了,虽然我们已经足够确信目标线程出现耗电异常,但是这类有问题的线程并不是一直有 Log 输出的(特别是处于死循环状态的线程),缺乏现场日志,我们分析和优化耗电异常的效率将大大降低。

虽然我们很快想到了用 Thread 相关的 API 来 Dump Java 线程异常现场的 StackTrace,但是对于 Native 线程却是一丁点儿办法都没有。无奈之下,一开始我们也只能用一些比较笨的办法来处理,比如 Review 可疑代码并加多 Log,甚至通过 tkill/tgkill 的方式来主动触发 Native 线程 Crash 从而把 StackTrace Dump 出来。后来我们发现一种成本比较低的方案:通过主动模拟 ANR 触发系统 Dump 线程 Trace Log 从而获取所有线程的 StackTrace(目前由 Matrix AnrDumper 工具实现)。

以上陈述的各种方案,已经帮我们项目监控到了大部分的耗电问题,以下列举一些优化案例作为分享。

案例分享

很多看似复杂的耗电异常问题(特别是高耗),最终排查下来都是一些看似简单的 “低级错误” 导致,如果一个个单独拎出来讲解的话,总有一股 “走近科学” 的味道,所以这里聚合成几类问题分别进行讲解。

长时、频繁任务

相信不少人同我一样有一个朴素的开发观念,那就是对于一个长时任务就应该把 ta 丢进 Worker 线程、线程池里去执行。换句话说,只要不在 UI 线程执行,大家对 ta 性能开销的敏感度或者心理预期就大大降低了。然而,这类长时任务,或者频繁的短时任务有时候也会造成 App 后台 CPU 负载异常。

比如微信部分为了优化用户体验的 PreLoad 逻辑(Emoji 资源、朋友圈资源等),会直接被丢进线程池里执行,有时候 App 已经待机好长一段时间了,相关的预载逻辑还在执行,从而导致系统 CPU Load 异常告警。又比如,有个古老的轻量 IO 逻辑为了方便是直接在 UI 线程里执行的,后来我们发现这里产生 ANR 了,然后就把这个逻辑挪到了 Woker 线程里执行并解决了 ANR,然而这个逻辑实际上在极端的条件下是因为出现死循环才导致的 ANR,这样简单的处理方案会导致 Woker 线程进入死循环并且不宜被发现。

类似的 “低级错误” 还有好多,这给我们提供一个深刻的教训: 运行时间在分钟量级以上的任务都应该考虑待机耗电问题,而不是简单地异步化就完事了 。在这方面,BatteryCanary 提供了电量相关的生命周期接口和事件回调,用于在 App 进入待机后的某个恰当时机来退出长时、频繁任务。

前后台判断

Android App 耗电异常发生在后台的状态的案例比较多,实际上 Android 系统的性能优化也一直朝着收敛 App 后台活动范围的方向发展,所以我们有必要在 App 进入后台并持续一段适合的时间后,执行一些退出和析构逻辑,并严格限制后台 Task。不幸的是,我们也在这里翻车了。

早期微信小程序框架并没有提供 持续定位 的接口,部分需要持续定位的小程序则是通过循环调用单次定位接口来实现目的,而有时候定位服务需要通过 Wifi 或者 Bluetooth 来实现辅助定位,所以一旦小程序进入后台后还在循环调用单次定位接口来实现实时导航等服务时,我们不仅获取了一堆系统的后台 GPS/Wifi/Bluetoosh 扫描异常告警,还成功把 App 推上系统耗电排行榜的宝座。

另一次翻车则与播放器有关:微信的视频播放有个自动播放的逻辑,当视频 Prepared 完毕就可以自动 Loop 播放,当 App 进入后台时候停止视频播放,然而这里有个遗留的代码缺陷:

fun onBackground() {
    if (player.isPrepared) {
        player.stop()
    }
}

也许你已经看出问题了,当 App 进入后台时,如果 Player 还没有完成 Prepare,那么 ta 将彻底失去 stop 的机会,当缓冲完毕的时候,播放器就会一直在后台 Loop 播放了。

此外,Android App 前后台状态的判断有很多种办法,这里必须指出的是部分方案是有版本兼容性问题的,比如有个方案是通过判断 App 自身是否在 ActivityManager#getRunningAppTasks 数组顶部来判断 App 是否在前台,然而 Android L 之后这个 API 只会返回 App 自身相关的 Tasks,也就是说结果会恒为前台,最终导致有些需要通过这个 API 来判断并限制后台活动的任务就会失控。(作为目前比较靠谱的方案,可以考虑通过 ActivityManager.RunningAppProcessInfo#importance 的值来判断 App 前后台状态。)

Loop 退出逻辑不完备

Loop 循环控制异常是耗电问题的重灾区。

while(true) {}

有些算法实现使用 while (true) {} 之类的 Loop 结构来完成通常会比较简单和直观,然而这里也是比较容易翻车的。

// case 1: 退出判断不在当前 while block 内部
while (true) {
    ...
    if (shouldExit()) {
        break
    }
}

// case 2: 有限状态机 + 责任链
var playerStatus = int(status)
val consumers = listOf(consumers)

while (true) {
     for (var cosumer : consumers) {
       if (consumer.accept(playerStatus)) {
         return
       }
     }
}

对于 Case 1,因为作为 Exit Conditioning 的判断方法 shouldExit () 不在 while 循环体内部,很容易在后续的代码迭代过程中因为调整了 shouldExit 的实现细节,导致 while block 出现了死循环的破绽。而对于 Case 2,这实际上是微信播放器某个老版本的实现,通过 “状态机 + 责任链” 的模式能有效地解耦播放器各个业务模块的控制逻辑,但是这样的设计实际上也是把 while block 的 Exit Conditioning 挪到了外部的 Consumer 里,容易因为出现某个新添加进来 playerStatus 所有的 consumers 都无法消耗,从而出现 while 死循环。

Loop 嵌套

目前 Android 项目中,Java 8 或者 Kotlin 相对 Java 7 的占比还是比较小(特别是规模比较大的项目),因此在处理一些比较复杂的数据结构时,相比起使用新语言特性中 Collection 相关的 “流式编程”,我们更加偏向使用传统 Loop 嵌套的方式来实现数据结构的转化,而且其中往往还伴随着比较严重的 “胶水代码”,这也给 Loop 循环控制埋下了隐患。

// Loop 套娃
while (condition1) {
    while (condition2) {
        if (exit == true) {
            break or continue or return?
        }
    }
}

例如我们项目中,就发现不止一处在类似以上代码的 Loop 嵌套结构中,因为 “忘记” 自己当前在哪一层,使用了错误的中断或者退出语句,结果导致了外层或者内层 Loop 死循环的悲剧。

Loop + Sleep

我们经常需要周期性地执行某些逻辑,所以我们也写了不少以下结构的代码来实现 Polling 操作:

// Loop + Sleep = Polling
while (true) {
    try {
        doSomething()
        sleep(10 * 1000L)
    } catch (e) {
    }
}

实际上这样的写法也有不小的隐患:一旦 try-catch 代码块出现了异常,则 Loop 逻辑可能再也没有办法进入预设的 Sleep 状态,结果是还是死循环。令人意想不到的是,除了 Java 外,Native 代码也有类似的 Polling 问题。

while (true) {
    try {
        ret = poll(fds, fdsCount, 10 * 1000L)
    } catch (e) {
    }
}

我们发现个别机型会因为某些具体未知的原因(比如 App 进入后台因为系统限制 fd 出现了异常)导致 poll 系统调用失败,异常的 poll 调用会立即返回,而不是按照预想的每次 block 住线程 1000ms,最终也导致了 Native 线程出现了死循环。

以上 Loop 问题产生的原因虽然各不相同,但是结果却 “出奇” 地殊途同归:线程死循环。这是导致 App 出现系统耗电排行 “屠榜” 的重要原因,我综合地将以上所有的 Loop 问题原因归纳为 “Loop 退出逻辑不完备,或者不健壮”。在编写类似 Loop 结构体的时候必须格外谨慎,最好是在 App 进入待机状态后有一个全局退出逻辑来作为兜底冗余。

使用 BatteryCanary

当我们发现 App 耗电的时候能做些什么?

现在,除了像以往一样通过 adb top 查看 App 进程 / 线程异常,或者通过 adb dumpsys batterystats 和 Battery Historian 查看 App 耗电数据之外,我们还能通过 BatteryCanary 实现 App 耗电问题的检测和线上监控。

文章最后,简单推广一下 BatteryCanary 的使用方式,主要包括以下两部分。

Battery Lifecycle

BatteryCanary 默认会将一系列与电量相关的生命周期和事件输出到 Log 里,通过过滤 TAG Matrix.battery.LifeCycle 就可以获得相应的日志。

Battery 电量报告 & Thread Dump

除了 Battery 生命周期事件之外,BatteryCanary 还会周期性的 Dump 当前 App 的电量统计报告。当出现线程状态以及 Jiffies 开销异常的时候,还会将线程的 StackTrace Dump 出来。

以上方某用户反馈的耗电问题为例,可以按照以下流程来排查耗电问题:

  1. fg=bg, during=86:当前电量报告的统计口径为 App 进入后台 86 分钟的一段时间窗口。
  2. avg (jffies/min)=6016:这 86 分钟内,当前进程每分钟的 CPU 开销为 6016 Jiffies,根据前面的分析, 6016 Jiffies = 6016 × 10 Millis ≈ 1 Min,也就是说当前进程在后台这一个多小时里,一直占满一个 CPU Core 以上的资源。
  3. inc_thread_num & cur_thread_num:进程当前一共 175 条线程,统计期间减少了 5 条。
  4. (~/R) Thread-29 (27479) 5940/510924 jiffies:Top 1 的问题线程是 “Thread-29”,tid 为 27479,统计期间一共消耗 510924 Jiffies(每分钟 5940)。
  5. scanning:统计期间进程 Bluetooth/Wifi/GPS 调用为 0。
  6. app_stats:统计期间进程的前后台、充电、亮灭屏等状态。

根据以上的排查结果,可以发现导致 App 耗电的主要原因是 “Thread-29” 线程在后台期间一直 Running,在电量统计报告的下方也可以看到该线程的 StackTrace Dump,从中可以定位到该线程一直在 mg_mgr_poll 方法中循环…… 至此,后续的工作就简单多了。

使用效果

实际项目使用 BatteryCanary 监控线程、线程池功耗负载效果如上图。可以看出,解决大部分负载异常之后,目前我们项目线程功耗主要分布在基础功能以及部分音视频播放上,而且每个版本新增的耗电问题(特别是线程死循环)都能有效地在灰度环节拦截消化掉。

目前,BatteryCanary 作为 Matrix 项目集成的一个插件模块,已经在微信 Android 项目上稳定运行多个版本,并且帮助我们成功定位和优化多个严重的耗电问题,欢迎各位前来食用和反馈:https://github.com/Tencent/matrix。


References

以下内容为拓展阅读,罗列的数据仅供参考,具体以大家实际测试和使用感受为准。

Benchmarks

电量监控本身势必给 App 带来额外的功耗,所以全量应用在线上环境的时候要格外小心。BatteryCanary 框架的监控功耗主要是在系统服务调用(统计 App、Device 状态需要)和 procStat 数据解析这两个方面。

Pixel 1/Pixel 4/Pixel 5 设备上的测试数据如下:

## Linux procStat 数据解析
# Benchmark 1. 连续 10000 次 procStat 解析
Pixel 1: 耗时均值约 3467 ms,平均每次解析耗时 0.34 ms
Pixel 4: 耗时均值约 1100 ms,平均每次解析耗时 0.11 ms  
Pixel 5: 耗时均值约 1000 ms,平均每次解析耗时 0.10 ms  

## 线程池 Task 监控
# 1. 整体额外开销大概在 2% ~ 5%
# 2. 看起来 CPU 比较差、闪存比较老旧的设备上,监控带来的额外消耗平摊下来占比反而比较小
# 3. 线程池的监控 Benchmarks 模拟的是极端状况,在短频、长时的状况下要乐观许多
# Benchmark 2. 线程池串行执行 100 个 Task(100ms),计算开启线程池监控的额外消耗
Pixel 1: 耗时对比增量均值约 +4.2%
Pixel 4: 耗时对比增量均值约 +4.7%
Pixel 5: 耗时对比增量均值约 +4.9%

# Benchmark 3. 低并发执行 100 个 Task(100ms),计算开启线程池监控的额外消耗
Pixel 1: 耗时对比增量均值约 +3.5%
Pixel 4: 耗时对比增量均值约 +5.1%
Pixel 5: 耗时对比增量均值约 +5.0%

# Benchmark 4. 高并发执行 100 个 Task(100ms),计算开启线程池监控的额外消耗
Pixel 1: 耗时对比增量均值约 +2.0%
Pixel 4: 耗时对比增量均值约 +5.5%
Pixel 5: 耗时对比增量均值约 +5.5%

Facebook Battery-Metrics

目前 Android 平台电量相关的开源方案并不多,我们只发现 Facebook 有个电量统计相关的开源项目 Battery-Metrics 能满足类似的需求,其基本设计思路与 BatteryCanary 类似,都是模拟 BatteryStatsService 的统计行为来测量 App 的电量消耗。不过 Battery-Metrics 的设计初衷是其工程师不 “信任” Android 系统的耗电报告,因此编写此框架来统计他们自己想要的电量指标,两者在具体实现和数据取舍上的区别比较大。

  1. BatteryCanary 最核心的功能是通过监控线程异常来定位 App 的耗电 Bug,主要包括线程电量统计、堆栈信息和线程池问题细分等;BatteryMetrics 似乎比较关心 App 整体的耗电,CPU 模块的电量统计只有进程层级的。
  2. BatteryCanary 通过 Hook、ASM 等手段实现 App 系统服务调用的监控;BatteryMetrics 则是设计一系列调用服务统计的 MetricsCollectors,需要在 App 调用系统服务时显式地调用一下 Collector 进行统计。
  3. BatteryCanary 默认会监控 App 进入后台后的待机功耗,并统计 App、Device 的状态变化,用来检测用户实际使用感受中耗电比较敏感的场景;BatteryMetrics 则比较偏向线下电量压测,通过一堆 Collectors 收集尽可能多的数据来测试 App 功耗指标。

参考链接

[1] Matrix BatteryCanary: https://github.com/Tencent/matrix/tree/master/matrix/matrix-android/matrix-battery-canary

[2]power_profile.xml: https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/res/res/xml/power_profile.xml

[3]BatteryStatsHelper: https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/com/android/internal/os/BatteryStatsHelper.java;l=568?q=BatteryStatsHelper

[4] Facebook Battery-Metrics: https://github.com/facebookincubator/Battery-Metrics

Copyright© 2013-2019

京ICP备2023019179号-2