Kaede Akatsuki

中二病也要开发 Android

Matrix BatteryCanary 电量优化框架

好了,经过上一篇文章《Android App 电量统计原理与优化》的分析,相信你已经完全掌握了 Android App 电量的计算方式,现在可以开始给自己的 App 开发电量异常检测功能了。如果觉得麻烦的话可以先尝试一下我们的开源方案:Matrix 已经实现了类似的功能 —— BatteryCanary,并且在我们的项目上全量稳定运行了一年多,帮我们发现了不少新增 & 隐藏多年的电量问题。

Matrix BatteryCanary

跟其他 APM 指标优化框架一样,电量优化框架 BatteryCanary 也是作为一个相对独立的 Plugin 功能集成在 Matrix 框架里面。不过电量相关的问题比较复杂,一般来说,比较“客观”的异常检测(比如 Crash 或者 ANR),都能做到“开箱即用”(out-of-box);而像卡顿反馈(没有 ANR 弹窗但用户感觉卡顿)这类比较“主观”的异常检测,就需要做一些额外的自定义配置了。

电量异常的判断标准则要比卡顿问题“主观更多”,而且导致耗电的原因更是多种多样,比如线程问题、WakeLock 问题、Wifi/蓝牙/GPS 扫描频繁问题等等。相应的 BatteryCanary 也开发了许多针对以上种种问题的功能模块,从而导致 BatteryCanary 的设计相比其他 APM 框架繁琐了不少,使用前需要根据自己 App 需要指定启用哪些模块以及相应的自定义配置。

初始化

如果你只想使用 BatteryCanary 的核心功能,那么只需要执行一下简单的初始化即可:

1
2
3
4
5
6
public static BatteryMonitorPlugin createMonitor(Context context) {
return new BatteryMonitorPlugin(new BatteryMonitorConfig.Builder()
// 线程 CPU 功耗监控
.enable(JiffiesMonitorFeature.class)
.build());
}

如果你对 BatteryCanary 的全部功能感兴趣,那么你可以尝试使用以下复杂的初始化配置,并自己尝试折腾一下每个功能的自定义参数配置(一般情况下使用默认配置即可):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public static BatteryMonitorPlugin createMonitor(Context context) {
// Init MMKV only when BatteryStatsFeature is & MMKVRecorder is enabled.
MMKV.initialize(context);
MMKV mmkv = MMKV.mmkvWithID("battery-stats.bin", MMKV.MULTI_PROCESS_MODE);
registerUIStat((Application) context.getApplicationContext());

BatteryMonitorConfig batteryConfig = new BatteryMonitorConfig.Builder()
// Thread Activities Monitor
.enable(JiffiesMonitorFeature.class)
.enableStatPidProc(true)
.greyJiffiesTime(3 * 1000L)
.enableBackgroundMode(false)
.backgroundLoopCheckTime(30 * 60 * 1000L)
.enableForegroundMode(true)
.foregroundLoopCheckTime(20 * 60 * 1000L)
.setBgThreadWatchingLimit(5000)
.setBgThreadWatchingLimit(8000)

// CPU Stats
.enable(CpuStatFeature.class)

// App & Device Status Monitor For Better Invalid Battery Activities Configure
.setOverHeatCount(1024)
.enable(DeviceStatMonitorFeature.class)
.enable(AppStatMonitorFeature.class)
.setSceneSupplier(new Callable<String>() {
@Override
public String call() {
return "Current AppScene";
}
})

// AMS Activities Monitor:
// alarm/wakelock watch
.enableAmsHook(true)
.enable(AlarmMonitorFeature.class)
.enable(WakeLockMonitorFeature.class)
.wakelockTimeout(2 * 60 * 1000L)
.wakelockWarnCount(3)
.addWakeLockWhiteList("Ignore WakeLock TAG1")
.addWakeLockWhiteList("Ignore WakeLock TAG2")
// scanning watch (wifi/gps/bluetooth)
.enable(WifiMonitorFeature.class)
.enable(LocationMonitorFeature.class)
.enable(BlueToothMonitorFeature.class)
// .enable(NotificationMonitorFeature.class)

// BatteryStats
.enable(BatteryStatsFeature.class)
.setRecorder(new BatteryRecorder.MMKVRecorder(mmkv))
.setStats(new BatteryStats.BatteryStatsImpl())

// Lab Feature:
// network monitor
// looper task monitor
.enable(TrafficMonitorFeature.class)
.enable(LooperTaskMonitorFeature.class)
.addLooperWatchList("main")
.useThreadClock(false)
.enableAggressive(true)

// Monitor Callback
.setCallback(new BatteryStatsListener())
.build();

return new BatteryMonitorPlugin(batteryConfig);
}

开始 & 结束监控

Crash / ANR 等常见的异常,可以通过捕获系统、App 相关的异常信号或者监听回调来判断异常是否发生,而相比之下耗电异常的检测就要麻烦许多。前者一般是瞬时的状态,在异常发生的时候捕获一下异常并 Dump 出异常现场信息(StackTrace、前后台等信息)后就能比较有效地分析和修复问题;后者则是一段时间内发生的事情,比如 App 使用十几分钟后发现这段时间内耗电比较严重…… 所以检测 App 电量异常,实际上就是监控 App 某一段时间内(结束时间的电量数据 - 起始时间的电量数据)电量的统计数据有没有出现问题。

为了方便计算统计数据 Diff,BatteryCanary 使用一个组合器(CompositeMonitors)来存放每一个电量监控模块(Monitor)的统计数据,封装了比较繁琐的数据 Diff 的计算代码,使用的时候只需要在开始监控的地方调用一下 CompositeMonitors#start() 并在结束的地方调用一下 CompositeMonitors#finish() ,即可获得这段时间内的电量统计信息,然后可以根据自身 App 的指标需要,判断出哪些电量监控模块出否出现异常。

这里需要特别强调的是,正因为耗电检测是一个过程,所以每一个过程(或者说是使用场景)对应的时间窗口是可能出现重叠的。我们可以根据徐存,给每一个使用场景创建一个不同的 CompositeMonitors 来监控(不同场景通过 Scope 来区分)。

例如,以下代码实现了 App 不同使用场景下的电量监控:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. 后台电量监控
CompositeMonitors compositor = new CompositeMonitors(core, "scope_bg");
// App 进入后台
compositor.start();
// App 进入前台
compositor.finish();
// 获取线程功耗统计数据
Delta<JiffiesSnapshot> appJiffies = compositor.getDelta(JiffiesSnapshot.class);
// 获取功耗 TOP1 的线程信息
ThreadJiffiesEntry threadJiffies = appJiffies.dlt.threadEntries.getList().get(0);


// 2. 针对某 Activity 的电量监控
CompositeMonitors compositor = new CompositeMonitors(core, "scope_activity_xxx");
// 进入 Activity#onActivityStarted
compositor.start();
// 退出 Activity#onActivityFinished
compositor.finish();
// 获取电量统计数据 ...

使用场景

目前我们的项目主要使用 BatteryCanary 来检测是否出现线程功耗异常和系统服务调用异常(不符合隐私合规,或者频繁调用)。

检测线程功耗异常

线程的监控模块主要可以分为一般线程(Java + Native)和线程池线程(Executor + HandlerThread + 其他用来执行多种 Runnable 的线程)。上一篇文章我们提到,一般线程我们可以用线程名来聚合问题,而线程池则需要使用 Runnable 名来聚合问题。

首先记得在 BatteryCanary 初始化的时候开启线程 & 线程池的监控功能:

1
2
3
4
BatteryMonitorConfig batteryConfig = new BatteryMonitorConfig.Builder()
.enable(JiffiesMonitorFeature.class) // All threads
.enable(YourAbsTaskMonitorFeature.class) // ThreadPool-styled threads
.build();

然后就可以开始愉快地玩耍了。

Java & Native 线程

判断一个线程功耗是否出现异常需要先了解一下 Linux Jiffy 的概念,忘了的同学可以先复习一下上节课的内容:线程监控。我们通过线程的 Jiffiy 消耗以及线程的运行状态,就可以推断出当前线程在这段时间内有没有异常。

1
2
3
4
5
6
7
8
9
10
11
Delta<JiffiesSnapshot> delta = compositor.getDelta(JiffiesSnapshot.class);
if (delta != null) {
long windowMillis = delta.during; // 时间窗口
for (ThreadJiffiesEntry threadEntry : delta.dlt.threadEntries.getList()) {
String name = threadEntry.name; // 线程名
int tid = threadEntry.tid; // tid
String status = threadEntry.stat; // 线程状态
long jiffies = threadEntry.get(); // 线程在这段时间内的 Jif
...
}
}

一般来说,当线程的 Jiffy 开销约等于 6000 jiffies/分钟的时候,就说线程在这段时间内一直在吃 CPU 资源(CPU 中断),换算下来线程的 CPU Load 约为 100%(App 的 CPU Load 为所有线程的 CPU Load 累加,具体值的区间在 [0, 100 × CPU Core Num])。当 App 在后台的时候,线程持续这种状态超过 10 分钟以上,就说明已经出现待机功耗异常;当时间超过 30min 甚至 1 hour 以上,就可以在系统电量统计排行上面看到明显的电量曲线的变化。

以上线程异常的规则定义可以根据自身 App 需要灵活定义,不过我们实践发现,如果长时间的后台线程 Jiffies 推算出来的 CPU Load 一直在 100% ,并且该线程没有明显的 Log 输入,则说明当前线程很可能出现了死循环(一般是 Bug 导致,而且陈年老代码里更容易出现);而如果有大量相关的 Log 输出,则说明该线程相关的业务功能在 App 进入待机状态后,可能漏了执行相应的退出和析构逻辑(一般在新开发而没经过测试 & 先上考验的新代码里容易出现)。

线程池

线程池的 Jiffy 开销异常于普通线程的统计方式不太一样,需要把线程池整体的 Jiffy 消耗分摊到每个 Runnable 上面,而且考虑到同一个 Runable 会反复被执行,还需要把相同 Runnable 的 Jiffy 累计起来。

因为每个 App 项目自身的 ThreadPool 库大相径庭,所以 BatteryCanary 并没有直接集成具体的线程池监控模块,而是提供一个基类(AbsTaskMonitorFeature),具体项目需要根据自身实际情况做一下扩展。以下是一个简单的线程池监控拓展样例:

1
2
3
4
5
6
7
8
9
public final class ThreadPoolJiffiesMonitor extends AbsTaskMonitorFeature {
ExecutorService yourExecutor = Executors.newCachedThreadPool();

public void execute(Runnable runnable) {
onTaskStarted(runnable.getClass().getName(), runnable.hashCode());
yourExecutor.execute(runnable);
onTaskFinished(runnable.getClass().getName(), runnable.hashCode());
}
}

与普通线程的异常定义类似,线程池只需要计算每一个 Runnable 的 CPU Load 就能检测出该 Runnable 有没有出现功耗异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1. 获取当前线程池所有 Runnable 的 CPU 功耗
ThreadPoolJiffiesMonitor feat = BatteryCanary.getMonitorFeature(ThreadPoolJiffiesMonitor.class);
List<Delta<TaskJiffiesSnapshot>> taskDeltas = feat.currentJiffies();
for (Delta<TaskJiffiesSnapshot> delta : taskDeltas) {
long windowsMillis = delta.dlt.timeMillis; // Runnable 执行的时间窗口
String taskName = delta.dlt.name; // Runnable 名字
long jiffies = delta.dlt.jiffies.get(); // Runnable 消耗的 Jiify
}

// 2. 获取过去一段时间窗口内,当前线程池某个线程的 CPU 功耗分布(Runnable 名字聚合)
int tid = xxx; // 线程池线程 tid
long deltaJiffy = xxx; // 线程池线程在这段时间内的 Jiffy 开销
long currJiffy = xxx; // 线程池线程当前累计的 Jitty 开销
TimePortions taskPortions = feat.getTaskPortions(tid, deltaJiffy, currJiffy);
Portion top = taskPortions.top1();
String taskName = top.key; // 功耗 top1 Runnable
float ratio = top.ratio; // 功耗 top1 Runnable Jiffy 开销与线程池线程所有开销的占比

野线程“Thread-xxx”治理

一般的线程问题,可以直接通过线程名看出具体是 App 的哪个业务模块出现问题了(复杂点的需要配合线程 StackTrace 来聚合问题)。在这种情形下,“野线程”(没有设置名字的线程)的问题就比较难处理了。

尽管在代码规范上,我们一直强调不要随意 new Thread() 以及创建新线程一定要设置强业务相关的线程名,不过我们在治理线程功耗上面还是经常能看到一些新增的“Thread-xxx”,经过仔细排查之后我们有了意外的收获:

Native 线程回调 Java 代码会导致线程名被重置成 Thread-xxx!

主要原因是 Native Call Java 的时候,需要先执行一下 AttachCurrentThread,这个调用如果没有显示指定当前线程名的话,就会导致线程名被重置。具体源码可以参考 thread.cc,其调用链如下:

Text
1
2
3
4
5
JVM->AttachCurrentThread(&env, nullptr);
- Thread->Attach
- Thread->CreatePeer
- Thread->GetThreadName -> java_lang_Thread_name // 这里因为传入进来的线程名为 null, NDK 会尝试读取 java_lang_Thread_name 作为新的线程名
- Thread->SetThreadName

解决方案也非常简单,记得指定线程名或者使用另一个相同功能的 API:

1
2
3
4
5
JavaVMAttachArgs args;
args.name = "native_thread_name";
JVM->AttachCurrentThread(&env, &args);
// or
AttachCurrentThreadWithName();

线程堆栈采集

通过以上手段可以有效地检测 App 哪些线程出现了异常,而在具体生产项目的应用上,检测哪些线程出现异常往往还不够(只有线程名、Jiffy & CPU Load),更重要的是要检测线程在哪些代码上出现了异常,这里就需要想办法采集到 Android 的线程堆栈信息(包括 native + Java)。

实际上,除了线程名 & Runnable 之外,线程堆栈也是分析和聚合耗电问题的一项关键的数据。不过线程堆栈采集(StackTrace Collect)主要用到的 unwind 技术是一个比较成熟的课题,包括电量优化在内的大部分 APM 优化项目都或多或少需要用到该技术,这里就不做过多展开。简单说一下我们方案大致的思路:通过 libunwindstack 来采集指定 tid 线程的 native 堆栈 & 通过 tgkill 来获取指定 Java 线程的 Thread 引用从而获取 Java 堆栈(具体应用上,我们跟倾向于优先采集 Java 线程堆栈,因为 native 堆栈的干扰数据太多,不太容易聚合问题)。

检测系统服务调用异常

电量优化另一个比较重要的场景是跟系统服务调用相关的,主要包括 WakeLock/Alarm 的请求,以及 Wifi/BlueTooth/GPS 等需要执行硬件扫描的功能的调用。

一方面,以上服务都是 Android 系统电量统计中权重占比比较高的模块,而且 Google Android Vitals 里功耗相关的适配指导也比较注重这几个模块的内容。另一方面,除了 Vitals 里定义的异常指标之外,国产手机的 OEM 系统也针对性地加入了针对以上模块使用情况的合规 & 功耗异常告警,这些告警给我们项目增加了许多 App 耗电异常的用户反馈,因此我们也非常重视相关系统服务调用的使用情况。

考虑到稳定性问题,BatteryCanary 主要通过 Hook 以及 ASM 两种方案来实现 App 系统服务调用的监控:Hook 方案接入简单但是在新系统以及个别设备上稳定性较差,比较适合采样监控;ASM 方案兼容性好适合现网全量启用,但是需要配合 ASM 插桩框架使用(每个项目使用的插桩框架不同,为了避免插桩冲突 Matrix 并没有提供内建的插桩功能),而且无法覆盖到动态代码(插桩工作主要在编译环节执行)。

SystemService Hook

Hook 方案的接入方式最简单,只需要在初始化 BatteryCanary 的时候启用 AMS Hook 配置即可:

1
2
3
4
5
6
 public static BatteryMonitorPlugin createMonitor(Context context) {
return new BatteryMonitorPlugin(new BatteryMonitorConfig.Builder()
...
.enable(JiffiesMonitorFeature.class)
.build());
}

通过以下代码可以从 CompositeMonitors 中获取 GPS 定位服务调用的统计信息(其他模块的访问方式类似):

1
2
3
4
5
Delta<LocationSnapshot> delta = compositor.getDelta(LocationSnapshot.class);
if (delta != null) {
int scanCount = delta.dlt.scanCount.get(); // 时间窗口内 GPS 扫描的调用次数
String stack = delta.dlt.stack; // 最后一次调用 GPS 扫描服务的代码堆栈
}

ASM

为了避免数据重复,ASM 方案和 Hook 方案只能同时启用一个。在关闭 Hook 功能的时候,需要通过 ASM 框架针对相关的系统服务调用进行插桩,并通过以下方式将插桩数据整合到 BatteryCanary 框架内,这里还是以 GPS 为例:

1
2
3
4
5
public void asmOnLocationScan() {
BatteryCanary.getMonitorFeature(LocationMonitorFeature.class, feat -> {
feat.getTracing().onStartScan();
});
}

判断 App 状态 & 设备状态

上面提到,耗电异常是指某一段内发生的异常,因此耗电异常时候的 App 和 Device 状态也必须统计的是一段时间内状态,具体讲就是这端事件内状态的切换细节以及每个状态的时间除以整个时间窗口的占比。

这部分状态的统计工作主要是由 AppStatMonitor 和 DeviceStatMonitor 这两个监控模块完成的。其中 AppStatMonitor 负责监控 App 前台、后台、ForegroundService 以及浮窗 4 种状态的变化,而 DeviceStatMonitor 则负责设备充电、未充电、息屏、Doze(低电耗模式)、App Standby(应用待机模式)这 5 种状态的统计。

应用状态 AppStat

在启用 App 状态监控模块的时候,可以通过一下方式获取 App 状态的统计数据:

1
2
3
4
5
6
7
8
BatteryCanary.getMonitorFeature(AppStatMonitorFeature.class, feat -> {
long duringMillis = xxx; // 时间窗口
AppStatMonitorFeature.AppStatSnapshot stats = feat.currentAppStatSnapshot(duringMillis);
long fgRatio = stats.fgRatio.get(); // 前台时间占比
long bgRatio = stats.bgRatio.get(); // 后台时间占比
long fgSrvRatio = stats.fgSrvRatio.get(); // 前台服务时间占比
long floatRatio = stats.floatRatio.get(); // 浮窗时间占比
});

设备状态 DevStat

在启用 Device 状态监控模块的时候,可以通过以下方式获取设备状态的统计数据:

1
2
3
4
5
6
7
8
BatteryCanary.getMonitorFeature(DeviceStatMonitorFeature.class, feat -> {
long duringMillis = xxx; // 时间窗口
DeviceStatMonitorFeature.DevStatSnapshot stats = feat.currentDevStatSnapshot(duringMillis);
long unChargingRatio = stats.unChargingRatio.get(); // 未充电状态时间占比
long screenOff = stats.screenOff.get(); // 息屏状态时间占比
long lowEnergyRatio = stats.lowEnergyRatio.get(); // 低电耗状态时间占比
long chargingRatio = stats.chargingRatio.get(); // 充电状态时间占比
});

AppStat 和 DevStat 是耗电异常判断的重要依据,例如:在 App 开启前台服务或者浮窗的情况下,我们运行 App 出现比较高的功耗,同样在设备出于充电状态的情况下我们也可以适当放宽一些待机功耗的限制;相反在 App 处于后台并且设备处于息屏状态的时候,我们应当最大程度收紧待机功耗的阈值,取消 App 的后台任务或者降低相关线程的优先级,让 App 进入 Standby 状态并且让设备能成功进入 Doze 模式。

根据我们对多份耗电异常的 Battery Historian 的排查,我们发现设备无法进入 Doze 模式是系统耗电排行中电量曲线出现明显下降的主要原因。而导致设备无法进入 Doze 模式的主要原因基本都是 App 的进程优先级一直没有下降(比如有活跃的线程一直在 Running 导致 CPU Load 负载很高)。

监听电量相关事件

电量相关系统 & App 的事件的监听是电量优化需要用到的基础功能,BatteryCanary 内部实现主要通过 BatteryEventDelegate 这个类来实现相关事件的监听,并且向外提供相应的接口以便上层业务在需要在时候做出功耗优化的策略调整。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
BatteryEventDelegate.getInstance().addListener(new ExListener() {
@Override
public boolean onStateChanged(String event) {
// 收到系统电量事件广播
return false;
}

@Override
public boolean onBatteryStateChanged(BatteryState batteryState, boolean isLowBattery) {
// 电量状态变化
batteryState.isCharging();
batteryState.isForeground();
batteryState.isLowBattery();
batteryState.isPowerSaveMode();
batteryState.isScreenOn();
batteryState.getBackgroundTimeMillis();
batteryState.getBatteryPercentage();
batteryState.getBatteryCapacity();
return false;
}

@Override
public boolean onBatteryPowerChanged(BatteryState batteryState, int levelPct) {
// 电池百分比变化
return false;
}

@Override
public boolean onBatteryTemperatureChanged(BatteryState batteryState, int temperature) {
// 电池温度变化
return false;
}

@Override
public boolean onAppLowEnergy(BatteryState batteryState, long backgroundMillis) {
// App 进入后台一段时间后的回调, backgroundMillis 为进入后台的持续时间(5, 10, 30min)
return false;
}
});

提炼电量优化规则

正如上篇文章提及的一样,如何检测各种类型的耗电异常,以及如何提炼耗电问题的规则(划红线)是优化电量指标的关键所在。从电量专项指标立项以来,我们项目在与 Android App 电量异常这项“疑难杂症”日常斗智斗勇的过程中,根据我们项目自身需要、Android Vitals 最佳实践指导 & 国内厂商自定义实现的系统电量异常告警功能的规则细节等,提炼出一套比较稳定的电量优化规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 待机功耗
## 普通异常
1. App 待机情况下(进程优先级为后台 + 设备未充电),进程持续 __10min__ Jiffy 开销超过 5000 jiffies/min(CPU Load ≈ 100%)
2. App 待机情况下(进程优先级为后台 + 设备未充电),进程持续 __60min__ Jiffy 开销超过 1000 jiffies/min(CPU Load ≈ 20%)
## 高耗异常
1. App 待机情况下(进程优先级为后台 + 设备未充电),进程持续 __30min__ Jiffy 开销超过 5000 jiffies/min(CPU Load ≈ 100%)


# 系统服务调用
## 休眠唤醒
1. App 待机情况下(进程优先级为后台),WakeLock 持有时间累计超过 __5min__
2. App 待机情况下(进程优先级为后台),Alarm 唤醒服务次数超过 __12__
## 扫描服务
1. App 待机情况下(进程优先级为后台),GPS 扫描时间累计超过 __5min__
2. App 待机情况下(进程优先级为后台),Wifi 扫描时间平均超过 **3min/hour**,扫描次数不超过 __4__ 次/hour
3. App 待机情况下(进程优先级为后台),BlueTooth 扫描时间平均超过 __5min/hour__


# 隐私合规
## 访问设备信息
1. App 在后台时,禁止访问设备信息:TelephonyManager#getImei, #getMeid, #getDeviceId
2. App 在后台时,禁止访问营运商信息: TelephonyManager#getSimOperator
## 访问位置信息
1. App 在后台时,如果需要定位功能,应该尽量使用 Android 的持续定位功能,避免直接获取位置信息
2. App 在后台时,禁止访问设备位置:TelephonyManager#getCellLocation
3. App 在后台时,避免或者减少直接使用 LocationManager/WifiManaer/BluetoohAdapter 获取位置信息

其他使用姿势

上面着重强调的“线程待机功耗异常”以及“系统服务调用异常”则是电量指标中比较好制定的异常规则,其他规则的提炼(比如 App 整体前台功耗,或者使用使用某一业务功能时的功耗),则需要根据 App 自身实际情况,通过以上监控模块统计到的数据做进一步的拿捏。

BatteryCanary 把一些电量规则提炼中比较重要的数据做了易用性的封装,可以通过以下方式获取。

CPU Load

CPU Load 是判断 App 当前功耗大小的一个重要依据,通过 adb top 命令我们可以轻松地获取 App 进程 & 线程的 CPU Load 数据,而在 App 内获取这个数据却不容易,通常我们只有在 ANR 的时候才会看到 App 把这个数据 Dump 出来。

根据类似的计算方式,BatteryCanary 提供了相应的 CPU Load 获取接口:

1
int cpuLoad = compositor.getCpuLoad(); // [0, 100 * cpu_core_num]

CpuFreq 采样

CpuFreq 也是跟 CPU 功耗相关的一个主要的依据。一个 CPU 往往由几个集群(Cluster)组成,每个集群里面的 CPU Core 的规格都是一样的,比如我的手机就是由大(2 core)、中(4 core)、小(2 core)三个集群共 8 CPU Core 组成。CPU 工作时,每个 Core 会运行在不同的 CpuFreq,工作频率(Step)越高则功耗越大,相应的设备发烫也就更严重。

获取 App 当前瞬时的 CpuFreq 意义不大,我们需要通过监控 CpuFreq 的变化,来大抵推断出 App 在某个使用场景下 CPU 功耗的变化情况,如果 CpuFreq 变化太大且长时间处于比较高的 Step,则我们应该着重关注该场景下用户发热发烫的反馈情况,必要时候做出相应的降频策略。

Battery 提供以下的方式来采样 CpuFreq 的变化数据:

1
2
3
4
5
6
7
8
9
10
11
Result sampling = compositor.getSamplingResult(CpuFreqSnapshot.class);
if (sampling != null) {
long duringMillis = sampling.duringMillis; // 时间窗口
long interval = sampling.interval ; // 采样周期
int count = sampling.count; // 采样次数
double sampleFst = sampling.sampleFst; // 第一次采样
double sampleLst = sampling.sampleLst; // 最后一次采样
double sampleMax = sampling.sampleMax; // 最大采样值
double sampleMin = sampling.sampleMin; // 最小采样值
double sampleAvg = sampling.sampleAvg; // 平均采样值
}

电池温度采样

我们认为设备机身温度也是电量优化的一个重要指标,遗憾的是 Android Framework 并没有提供相应的 API 来获取该数据(需要系统 App 权限)。好在电池温度变化与设备温度正相关性比较强,而且当设备整体功耗比较大的时候,电池的输出功率也比较大,从而电池温度也会上升得比较快,在一定程度上我们可以用电池温度作为依据来判断设备机身的发烫状况(不过快充等充电场景也会导致电池升温,需要做好相关的状态判断和过滤)。

类似 CpuFreq,当前电池温度也应该通过采样的方式来判断其变化状态:

1
2
3
4
5
6
7
8
9
10
11
Result sampling = compositor.getSamplingResult(BatteryTmpSnapshot.class);
if (sampling != null) {
long duringMillis = sampling.duringMillis; // 时间窗口
long interval = sampling.interval ; // 采样周期
int count = sampling.count; // 采样次数
double sampleFst = sampling.sampleFst; // 第一次采样
double sampleLst = sampling.sampleLst; // 最后一次采样
double sampleMax = sampling.sampleMax; // 最大采样值
double sampleMin = sampling.sampleMin; // 最小采样值
double sampleAvg = sampling.sampleAvg; // 平均采样值
}

AppStats

为了方便统一管理数据,BatteryCanary 把电量问题相关的 App 状态、Device 状态、Activity 切换信息等数据都封装到 AppStats 这个类里,可以通过以下方式来获取当前时间窗口内的 AppStats:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
long duringMillis = xxx; // 时间窗口
AppStats appStats = AppStats.current(duringMillis);
if (appStats.isValid) {
long minute = appStats.getMinute(); // 时间窗口(分钟)

int appStat = appStats.getAppStat(); // App 状态
long fgRatio = appStats.fgRatio.get(); // 前台时间占比
long bgRatio = appStats.bgRatio.get(); // 后台时间占比
long fgSrvRatio = appStats.fgSrvRatio.get(); // 前台服务时间占比
long floatRatio = appStats.floatRatio.get(); // 浮窗时间占比

int devStat = appStats.getDevStat(); // Device 状态
long unChargingRatio = appStats.unChargingRatio.get(); // 未充电状态时间占比
long screenOff = appStats.screenOff.get(); // 息屏状态时间占比
long lowEnergyRatio = appStats.lowEnergyRatio.get(); // 低电耗状态时间占比
long chargingRatio = appStats.chargingRatio.get(); // 充电状态时间占比

String scene = appStats.sceneTop1; // Top1 Activity
int sceneRatio = appStats.sceneTop1Ratio; // Top1 Activity 占比
}

电量统计报告

除了以上介绍的各种异常的检测之外,如何将各种监控模块统计到的异常数据以一种“read-friendly”的方式展示出来也是个麻烦的事情。BatteryCanary 通过两种方式来展示统计数据:logcat 日志 & 时间轴 UI。

Battery Dumper & Printer

待机功耗监控的 CompositeMonitors(SCOPE_CANARY)会通过默认的 Dumper & Printer 来往 logcat 输出以下统计报告(其他监控场景 App 可以根据需要自定义自己的 Dumper),这里以开启全部监控功能的待机场景为例:

1
2
3
4
5
6
Printer printer = new Printer();
printer.writeTitle();
new Dumper().dump(compositor, printer);
printer.writeEnding();

Log.i(TAG, "dump battery report: \n" + printer.toString());

Logcat 会输出以下格式化的文本:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
**__************************************** PowerTest *****************************************
| pid=12773 fg=fg during(min)=1 diff(jiffies)=10828 avg(jiffies/min)=10828
+ --------------------------------------------------------------------------------------------
| jiffies(13) :
| -> desc = (status)name(tid) avg/total
| -> inc_thread_num = 2
| -> cur_thread_num = 30
| -> (+/R)Benchmark(12833) 10436/10436 jiffies
| -> (~/S)RenderThread(12808) 135/135 jiffies
| -> (~/R).tencent.matrix(12773) 126/126 jiffies
| -> (+/S)JDWP Transport (12908) 38/38 jiffies
| -> (~/S)Jit thread pool(12787) 33/33 jiffies
| -> (~/S)default_matrix_(12802) 26/26 jiffies
| -> (~/S)matrix_time_upd(12800) 8/8 jiffies
| -> (~/S)Binder:12773_2(12794) 7/7 jiffies
| ......
| #overHeat
+ --------------------------------------------------------------------------------------------
| awake :
| <alarm>
| -> 104732(mls) 1(min)
| -> inc_alarm_count = 0
| -> inc_trace_count = 0
| -> inc_dupli_group = 0
| -> inc_dupli_count = 0
| <wake_lock>
| -> 104732(mls) 1(min)
| -> inc_lock_count = 0
| -> inc_time_total = 0
+ --------------------------------------------------------------------------------------------
| scanning :
| <bluetooh>
| -> 104719(mls) 1(min)
| -> inc_regs_count = 0
| -> inc_dics_count = 0
| -> inc_scan_count = 0
| <wifi>
| -> 104648(mls) 1(min)
| -> inc_scan_count = 0
| -> inc_qury_count = 0
| <location>
| -> 104646(mls) 1(min)
| -> inc_scan_count = 0
+ --------------------------------------------------------------------------------------------
| dev_stats :
| <cpu_load>
| -> 104715(mls) 1(min)
| -> usage = 103%
| -> cpu0 = [0, 0, 0, 1832, 0, 0, 3, 4675, 1194, 434, 412, 91, 37, 77, 59, 48, 45, 1564]
| -> cpu1 = [0, 0, 0, 1832, 0, 0, 3, 4675, 1194, 434, 412, 91, 37, 77, 59, 48, 45, 1564]
| -> cpu2 = [0, 0, 0, 1832, 0, 0, 3, 4675, 1194, 434, 412, 91, 37, 77, 59, 48, 45, 1565]
| -> cpu3 = [0, 0, 0, 1832, 0, 0, 3, 4675, 1194, 434, 412, 91, 37, 77, 59, 48, 45, 1565]
| -> cpu4 = [7190, 37, 31, 32, 17, 429, 405, 254, 128, 25, 19, 13, 9, 8, 4, 6, 1868]
| -> cpu5 = [7190, 37, 31, 32, 17, 429, 405, 254, 128, 25, 19, 13, 9, 8, 4, 6, 1868]
| -> cpu6 = [7190, 37, 31, 32, 17, 429, 405, 254, 128, 25, 19, 13, 9, 8, 4, 6, 1868]
| -> cpu7 = [1603, 11, 0, 0, 79, 4, 4, 11, 4, 5, 8, 2, 0, 0, 2, 0, 0, 0, 0, 8738]
| <cpu_sip>
| -> inc_cpu_sip = 21.34(mAh)
| -> cur_cpu_sip = 66720.12(mAh)
| -> inc_prc_sip = 3.58(mAh)
| -> cur_prc_sip = 4.81(mAh)
| <cpufreq>
| -> 104686(mls) 1(min)
| -> inc = [0, 0, 0, 0, -192, -192, -96, 0]
| -> cur = [1785, 1785, 1785, 1785, 2227, 2227, 2323, 2841]
| <cpufreq_sampling>
| -> 104719(mls) 1000(itv)
| -> max = 2419.0
| -> min = 2419.0
| -> avg = 2419.0
| -> cnt = 17
| <batt_temp>
| -> 104719(mls) 1(min)
| -> inc = 0
| -> cur = 273
| <batt_temp_sampling>
| -> 104720(mls) 1000(itv)
| -> max = 273.0
| -> min = 273.0
| -> avg = 273.0
| -> cnt = 17
+ --------------------------------------------------------------------------------------------
| app_stats :
| <stat_time>
| -> time = 1(min)
| -> fg = 100
| -> bg = 0
| -> fgSrv = 0
| -> devCharging = 100
| -> devScreenOff = 0
| -> sceneTop1 = Current AppScene/100
| <run_time>
| -> time = 1(min)
| -> fg = 100
| -> bg = 0
| -> fgSrv = 0
**********************************************************************************************

Battery Stats

启用 BatteryStatsFeature 功能的时候,BatteryCanary 会通过 MMKV 持久化电量统计数据,并通过 BatteryStatsActivity 以时间轴的形式展示相关的电量事件 & 电量报告,以便于快速排查并定位异常现场。

电量事件

电量事件是判断 App 有没有出现电量问题的重要数据,主要包括以下事件:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
App 前后切换

Intent.ACTION_SCREEN_ON 亮屏
Intent.ACTION_SCREEN_OFF 息屏
Intent.ACTION_POWER_CONNECTED 充电
Intent.ACTION_POWER_DISCONNECTED 解除充电

Intent.ACTION_BATTERY_CHANGED 电量变化(电池百分比,电池温度)
Intent.ACTION_BATTERY_LOW 低电量
Intent.ACTION_BATTERY_OKAY 从低电量恢复

PowerManager.ACTION_POWER_SAVE_MODE_CHANGED App 进入待机模式
PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED 系统进入 Doze 模式

电量报告

电量报告主要包括是以上提及的各种监控场景的 CompositeMonitors 统计到的电量数据,以一种比较直观的方式展示数据,用于快速定位某个 App 使用场景下的电量问题。



参考链接:

  1. Matrix 开源项目
  2. BatteryCanary 使用 Example
  3. Android Vitals