钉钉 Android 端功耗优化最佳实践
背景
钉钉作为亿级企业数字化平台,高效即时沟通是核心能力之一,消息即时触达以及后台功耗一直是 Android 端核心体验,随着业务快速的发展,越来越多的消息信令通过钉钉自有的长连接通道推送到客户端,同时手机续航也是用户和手机厂商关心的核心体验之一。
面临的问题
挑战
Android App 功耗问题不同于 Crash、ANR 等领域有现成的成熟解决方案。Android 系统通过软件估算应用的耗电量,但是应用拿不到这个估算的电量值。同时,功耗问题通常是综合性问题,造成耗电的原因也多种多样。主要面临的挑战如下:
系统的异常耗电提醒规则和标准不明确
在应用出现异常耗电的情况下,系统会通过通知给用户提示该应用有高耗电的行为。从应用的视角来看,系统异常耗电提醒应用侧无法感知,提醒规则和标准对应用来讲是黑盒。这对于应用侧快速定位根因,解决用户问题提升了难度。
缺少现场信息,难复现
当用户反馈高耗电问题,通常不明确什么场景下出现的,用户只能提供系统的应用耗电排行和应用耗电详情。这些信息能提供一个大概的排查方向,缺少更为详细明确的现场信息,难以复现,大多情况下无法直接定位根因。
排查手段有限
耗电排行和应用耗电详情
用户反馈问题后,最直观的数据就是手机系统的耗电排行和应用耗电详情。但是没有详细信息,对于定位问题并没有太大帮助。
Bugreport + Battery Historian
除了系统的应用耗电排行和应用耗电详情之外,最主要的手段是利用 Android 的 Bugreport 结合 Battery Historian 来排查电量问题。Bugreport 日志有系统的电量统计数据,这个对于我们定位耗电原因有很大的帮助。但是,也有一些不足。
首先,针对手机管家的高功耗提醒与 Bugreport 并非一一对应,难以直接定位原因;其次,Bugreport 没有堆栈信息,比如通过 Bugreport 能看到耗电的大头是 WiFi 、移动网络或蓝牙扫描等,但具体是代码里的哪一行代码调用的还是不好排查。
另外,从实际操作上来说,Bugreport 获取比较麻烦,也需要在问题出现后及时导出,否则会错过问题现场,同时也依赖用户配合提供 Bugreport 日志。
因此,Bugreport 更适合线下分析功耗问题。
Battery Historian 示例
优化效果无法评估
最后,由于耗电量在应用侧没有一个可以量化的指标,当解决了某个功耗问题,只能靠持续回访用户长期观察和对比来判断是否有效,样本量较小,比较难通过量化指标来评估优化效果。
功耗优化方向
根据上述我们主要的功耗问题、排查过程以及面临的挑战,我们梳理了功耗优化方向,如下图:
感知能力:建立线上功耗监控能力以及异常耗电模型,主动发现异常耗电问题以及场景。
快速定位能力:通过异常耗电监控及电量报告,快速定位异常耗电问题根因。
问题治理:结合异常耗电根因,针对性解决潜在的高功耗风险。
防劣化能力:通过主动感知能力,及时感知并解决新增变更引起的高功耗风险。
功耗优化总体设计
目标
为用户提供极致体验的低功耗应用,彻底解决应用功耗问题
建设功耗感知-治理-防劣化体系能力
总体设计
感知能力
针对线上异常耗电无法主动感知的问题:根据系统电量统计原理,全面监控钉钉各个功耗相关部件的使用情况,结合 Android Vitals 异常耗电标准以及系统提醒维度来明确监控指标以及基线,建设异常耗电诊断方案,主动发现异常耗电问题及场景。
快速定位能力
针对功耗问题难定位难排查的问题:周期性地采样功耗部件的使用数据,统计生成电量报告,展示各个模块功耗使用情况和头部问题;通过电量报告及异常耗电检测,快速定位异常耗电问题根因。
防劣化能力
针对线上功耗体验无法衡量的问题,我们提出功耗健康分指标衡量整体的线上功耗体验。同时,也为了防止后续功耗出现劣化情况,我们建设功耗体验指标体系,从功耗部件健康分、异常率等指标及时洞察潜在的新增功耗风险。
治理优化
结合感知能力以及快速定位能力,梳理应用潜在的功耗异常问题,推动问题治理优化,彻底解决功耗体验问题,为用户提供低功耗的极致体验。
下面针对其中核心的关键能力进行详细的介绍。
感知能力 - 功耗部件监控
感知能力的建设对于了解线上功耗健康度非常关键,对于我们甄别头部问题以及防劣化体系意义重大。帮助我们从被动应对转变为主动出击。
系统电量统计原理
首先简单说明下 Android 系统是如何统计耗电量的。物理学中电量计算公式:
电量 = 功率 × 时间 = (电压 × 电流 )× 时间
手机上的电压一般是恒定不变的,所以计算电量可直接使用电流来代替功率;再结合各个硬件模块在不同状态下的使用时间,则可以计算出其消耗的电量。
系统服务 BatteryStatsService 就是用于耗电量计算。负责电池信息收集,以及各个部件、各个应用程序的各类别的耗电量统计。计算电池剩余使用时间,电池充满时间等。
系统统计电量的流程是这样的:Android 系统将各个硬件模块的电流消耗值以及该模块在一段时间内大概消耗的电量以固定值的形式存储在 power_profile.xml(电源配置文件)中。由于硬件之间的差异,电源配置文件需要各个设备制造商进行定制。PowerProfile 负责解析电源配置文件,获取各个功耗部件的功耗值,并将获取的值提供给 BatteryStatsService。BatteryStatsService 则会委托 BatteryStatsImpl 跟踪统计各个硬件模块的状态和使用的时间,通过 BatteryStatsHelper 交给各个硬件模块的 PowerCalculator 计算模块的电量,以此来估算 App 整体耗电量。
主要策略
通过系统的电量统计原理了解到系统的耗电量统计与哪些组件的哪些行为有关,以及统计流程和方法。在无法直接获取耗电量情况下,可以参照系统的统计原理,监控耗电相关的组件的使用情况,以此来统计功耗使用数据、反映功耗消耗情况。
根据系统电量统计原理,结合异常耗电的基线标准,以及钉钉的业务情况,我们主要监控以下模块的使用情况:
接下来将介绍下各个功耗部件的监控方案。
网络使用监控
在前文中已经介绍过,Mobile Radio 和 WiFi 模块的耗电不仅仅与流量大小相关,还与网络状态激活的次数和间隔相关。频繁的连续的网络收发非常影响耗电。所以网络部分主要监控:①流量、②网络收发事件、③网络变化数 3 个指标。
流量:包含移动网络收/发流量、收/发数据包数量;WiFi 网络收/发流量、收/发数据包数量。
网络收发事件:统计钉钉长连接协议上下行事件;以及 Http 请求事件。
网络变化数:单位时间内有一次或多次网络收发事件记录为一次网络变化;一定时间间隔内的两次连续网络变化记录为一次连续网络变化。
技术实现
流量的统计,Android 10 以上主要是利用 TrafficStats 的 getUidRxBytes 和 getUidTxBytes 获取接收和发送的字节,利用 getUidRxPackets 和 getUidTxPackets 获取接收和发送的数据包数量,结合 App 当前前后台状态、WiFi /移动网络连接状态,计算出流量的消耗。Android 10 以下则利用 /proc/net/xt_qtaguid/stats 获取不同类型网络数据,结合 App 当前前后台状态,计算流量消耗。
再通过钉钉统一网络服务统计上下行网络请求记录事件,以及计算网络变化数和最大连续网络变化数。通过网络变化数和最大连续网络变化数,可有助于分析网络的使用频率;网络收发事件则有助于定位原因。
系统服务调用监控
功耗相关的系统服务调用包含:WakeLock、Alarm、蓝牙扫描、WiFi 扫描、Location、Senser 等的使用。根据系统电量统计原理,监控这些服务与功耗相关的事件的调用,输出事件日志,详细的堆栈信息等。
WakeLock:监控部分唤醒锁和亮屏唤醒锁的使用情况。监控指标包含:持锁时长、持锁个数;
Alarm:监控唤醒闹钟的使用情况。监控指标包含:设置次数、唤醒次数;
WiFi 扫描:WiFi 模块的耗电包含 WiFi 网络数据通信部分(已经在网络部分监控),以及 WiFi 的扫描使用情况。这里的监控指标包含:WiFi 扫描次数;
蓝牙扫描:监控蓝牙扫描的使用情况。监控指标包含:蓝牙的扫描次数;
Location:监控定位的使用情况。监控指标包含:定位次数、定位时长。
技术实现
系统服务调用的监控,主要采用 Java Hook 的方式来实现。但是,Hook 系统服务调用在不同 Android 版本上会存在一些的兼容性问题,需要做好适配工作。另外,参考系统相关原理,为了让功耗监控更准确,有些需要注意的细节:
WakeLock:根据系统电量统计的实现,WakeLock 耗电只监控 FULL_WAKE_LOCK / SCREEN_BRIGHT_WAKE_LOCK / SCREEN_DIM_WAKE_LOCK / PARTIAL_WAKE_LOCK / PROXIMITY_SCREEN_OFF_WAKE_LOCK 这几类锁。
Alarm:根据系统电量统计的实现,Alarm 耗电只针对唤醒类型 Alarm,即 ELAPSED_REALTIME_WAKEUP / RTC_WAKEUP 。
WiFi 扫描:为降低耗电量,系统在 Android 8.0 (API 级别 26)及更高版本对后台 WiFi 扫描频率有节流限制,所以高版本上,在监控调用 WifiManager.startScan() 扫描次数的基础上,可根据 WifiManager.startScan() 的调用结果判断是否进行了完整的 WiFi 扫描。
Location 定位:
Location 的监控要注意判断定位类型,GPS 或者 Network。两种方式在电量消耗上有所区分,功耗异常检测上会区别两种类型,所以需要在监控的时候要考虑定位类型。
为降低耗电量,系统在 Android 8.0(API 级别 26)及更高版本会对应用后台获取当前位置信息的频率进行限制。所以高版本上,在监控定位调用次数的基础上,同时还可以根据位置变更回调来判断实际的位置获取调用情况。
CPU 使用监控
CPU 使用监控主要是针对 CPU 长期高负荷、过于繁忙的场景,需要监控 CPU 使用率这个重要指标,主要包含:
进程 CPU 开销:包含进程开销、线程数量等;
线程 CPU 开销:包含普通线程、线程池任务、HandlerThread 任务开销监控;
线程死循环检测:包含死循环任务检测、异常线程堆栈。
技术实现
进程开销:
利用 Linux 的
proc/[pid]/stat
:该文件包含了某一进程所有的活动的信息,该文件中的所有值都是从系统启动开始累计。该文件中的 pid 字段表示进程号。这里比较关键的数据是第 0 位的进程 id、第 1 位的进程名、第 2 位的进程状态,以及第 13-16 位的 utime、stime、cutime 和 cstime(分别是该任务在用户态运行的时间;该任务在核心态运行的时间;所有已死线程在用户态运行的时间;所有已死线程在核心态运行的时间。单位都为 jiffies。)
进程的总 CPU 开销:utime + stime + cutime + cstime,该值包括其所有线程的 CPU 开销。
线程开销: 通过遍历proc/[pid]/task/目录内的子目录, proc/[pid]/task/[tid]/stat
该文件包含了进程下所有的活动的信息,该文件中的所有值都是从系统启动开始累计。该文件中的 tid 字段表示线程号。这里比较关键的数据是第 1 位的线程名、第 2 位的线程状态,以及第 13-14 位的 utime、stime(分别是该任务在用户态运行的时间;该任务在核心态运行的时间。单位都为 jiffies )。
线程 CPU 开销:utime + stime。
进程/线程 CPU 使用率计算
基于上面的背景知识,我们可以每隔一段时间 period 秒读取 proc/[pid]/stat
,解析其中的 utime / stime / cutime / cstime , 将其和(utime + stime + cutime + cstime) 与上一次采样时的和做差,这就是这一段时间内该进程占用 CPU 的时间,单位为 TICK 。而总的 CPU 时间为 period * HZ。所以,进程的 CPU 使用率可以用如下公式计算: ((utime + stime + cutime + cstime)- (lastutime + laststime + lastcutime + lastcstime)) / period * HZ 因为通常 HZ = 100, 当进程/线程的 jiffies 开销约等于每分钟 6000 jiffies 的时候,换算下来进程/线程的 CPU 使用率约为 100%。 类似的,线程的 CPU 使用率为: ((utime + stime )- (lastutime + laststime)) / period * HZ 死循环检测 死循环是造成线程 CPU 使用率过高、引起耗电的一类常见问题。当我们发现某一些线程长时间 CPU 使用率过高,会做一次死循环检测,找出其中疑似死循环的线程。死循环检测能力基于死循环线程有三个主要特征:
①长时间占用 CPU;
②线程不会进入 WAITING 状态;
③线程堆栈相似:出现一个循环点时,线程堆栈的底部是永远相同的。
我们针对长时间 CPU 使用率过高的线程,去做连续 3 帧的堆栈比较,就能比较准确地找出死循环线程,并输出线程堆栈和完整的线程名。
线程池场景 如果是线程池(通过池化技术重复利用已创建的线程)或者 HandlerThread (通过消息队列重复使用当前线程) ,当线程 CPU 使用率高的时候,只分析当前正在执行的任务不一定能找到真正的原因。通过 proc/[pid]/task/
统计的线程开销则还需要进一步拆分定位,每一个任务的执行开销是多少。①线程池 (Executors) 任务 线程池任务的监控,主要是在自身的线程池里 Callable 执行开始和执行结束时监听。 ②HandlerThread 任务:例如主线程,或者其他自定义 HandlerThread 。HandlerThread 任务主要监控两类:Handler 消息 和 IdleHandler 任务。 Handler 消息:通过替换主线程 Looper 的 Printer,解析 Message 和 Callable 两种格式的消息,则可监控到每个消息的执行开始和执行结束。 IdleHandler:通过反射修改 MessageQueue 的 mIdleHandlers (ArrayList<IdleHandler>),替换为自定义的 ArrayList , 在添加和删除 IdleHandler 时,创建 IdleHandlerProxy 代理类并设置。则可监听到 IdleHandler 的 queueIdle() 方法的执行开始和执行结束。 这样,可在任务的开始和结束,计算该线程的 CPU 开销差值,进一步明确该任务是否有功耗异常。
自启动监控
部分手机的耗电详情上统计了应用的自启动次数,鉴于此,主要监控项为:①自启动次数;②自启动原因;③进程近期退出原因。
应用自启动次数:两次用户点击启动之间,应用自启动的次数;
应用自启动原因:每一次应用自启动的原因;
应用退出原因:应用近期进程退出原因。
技术实现
自启动原因监控
四大组件( Activity / Service / ContentProvider / Broadcast )这四大组件在启动的过程中,当其所在的进程不存在时都会调用 startProcessLocked() 创建进程。所以,
在进程执行 attachBaseContext() 过后,Hook 主线程消息队列里的 message ,结合 startService / bindService /广播/ Activity 的启动流程,可根据 message 内容来判断进程本次的启动原因。
使用切面方案监控主进程未存活时、应用内子进程通过 ContentResolver 访问主进程 ContentProvider 从而启动主进程的调用,可感知由于 ContentProvider 被调用拉起进程的启动。
应用退出原因监控
另外,在Android 11上,还可利用 ActivityManager.getHistoricalProcessExitReasons 获取进程退出原因, 可进一步分析是否有异常的应用频繁退出。
应用&设备状态
功耗消耗是一个过程,是一段时间累积的结果。在一段时间当中,应用可能会在前台/后台等多种状态之间切换,设备可能在充电不充电之间切换、亮灭屏之间切换,而异常耗电更多的是关注在后台、并且是不充电的情况下,忽略状态信息可能会导致许多误报的异常功耗问题。所以,在功耗部件使用监控的基础上,还要记录每一次的状态变化事件;将统计窗口内的状态变化,转变为这段时间内每一种状态的时长占比。在分析功耗问题的时候,将上述功耗模块的使用情况结合这一段时间内应用/设备状态的占比信息,就能更准确地定位功耗问题。
这部分监控包含:应用状态;设备状态;电池信息等。
应用状态:前台/后台/前台Service/后台悬浮窗;
设备状态:充电/断电&亮屏/断电&灭屏;
电池信息:电池电量/电池温度。
感知能力 - 异常耗电监控
在对功耗部件使用情况具备监控情况下,接下来就需要对超过阈值的使用情况认定为异常耗电,异常耗电的监控对于主动感知异常耗电问题至关重要。
主要策略
我们参考 Android Vitals 的功耗性能指标和手机系统的异常耗电提醒类型制定钉钉异常耗电规则以及实时诊断感知:
制定异常功耗规则:实现了一套异常功耗诊断方案,检测频繁网络使用、CPU 负载过高、WakeLock 长时间持锁、Alarm 频繁唤醒、蓝牙/ WiFi /定位频繁扫描、频繁自启动等高耗电问题;
实时诊断感知:基于异常功耗规则,实时诊断后台异常功耗问题;并计算头部耗电归因和采集电量报告,快速定位问题。
制定异常功耗规则
耗电类型 | 监控部件 | 耗电原因 |
后台网络使用量过高 | 网络流量 | 退后台网络流量高 |
后台网络使用频繁 | 网络事件 | 退后台频繁唤醒网络 |
后台持锁时间过长 | WakeLock | 退后台长期持有锁不释放 |
后台频繁唤醒 | Alarm | 退后台频繁唤醒 |
后台蓝牙持续扫描 | 蓝牙扫描 | 退后台频繁扫描蓝牙 |
后台 WiFi 频繁扫描 | WiFi 扫描 | 退后台频繁扫描 WiFi |
后台频繁自启动 | 自启动 | 退后台应用频繁自启动 |
后台频繁定位 | Location | 退后台灭屏长时间使用 GPS /网络定位 |
后台 CPU 负载过高 | CPU | 退后台有长耗时线程,线程死循环 |
... | ... | ... |
监控效果
基于这套异常耗电诊断模型,我们能有效感知线上异常高耗电问题。监控上线后,帮我们监控到钉钉潜在的功耗问题。
功耗部件异常监控占比分布,便于洞察功耗头部问题;
单个功耗部件异常功耗的主要归因。例如,下图展示后台长时间持锁的主要归因分布。
快速定位能力 - 电量报告
基于感知能力的功耗部件监控以及使用统计日志,最重要的功耗数据产物之一就是:电量报告。
电量报告会显示一段时间各个功耗部件的使用情况。根据电量报告,就可快速定位这个时间窗口内最主要的电量消耗,再结合电量事件日志,就能准确定位问题了。
防劣化能力
如前文所说,不同于 Crash、ANR 等问题,耗电量在线上是没有一个可以量化的指标的,所以线上用户功耗体验如何,我们的优化是否有效果,是否解决了用户反馈的功耗问题,有无新增的功耗问题等,从应用治理的视角来看,监控能力是防劣化的有效手段。
主要策略
建立线上功耗体验指标体系
由于 App 获取不到直接的电量值,需要有一个指标去量化衡量整体的线上功耗体验,代替电量值这个指标。且功耗涉及多个模块的使用,在我们有了各个功耗部件的使用数据之后,这个指标还需要是一个综合指标。鉴于此,我们提出了功耗健康分的指标,用于衡量一次退后台的生命周期内功耗体验整体优劣情况。
功耗健康分:对一条退后台数据,对各个单项指标进行评分,再以各个模块使用对功耗的影响程度转换为权重系数求和相加得到健康分。
健康分计算:
建设功耗体验报表:感知功耗体验核心指标的优劣和变化趋势。主要包含三个部分:功耗健康分、异常功耗、功耗部件单项指标。
功耗健康分:归一化各个功耗部件的使用情况进行健康分评估,反映钉钉后台整体的功耗体验;
异常功耗:反映各类功耗异常的总数和异常率的变化,以及每一类异常的主要归因;
功耗部件单项指标:反映钉钉后台各个功耗部分上详细的使用情况。
因此,对于钉钉线上功耗体验,有了明确的数据指标衡量体系,能够量化地感知功耗体验和异常问题。不再是依赖用户反馈或者主观使用感受。通过线上功耗体验指标,曾有效地帮我们感知到线上变更引起的高功耗问题;也用于整体衡量我们每个迭代功耗治理优化的效果。
功耗治理实践
基于功耗的监控感知能力,上线后帮助我们累计发现数十例潜在功耗问题,根据问题的严重程度我们依次进行了优化治理。
治理策略
功耗的优化治理,主要是两方面的工作。首先,针对后台重度消息收发引起的网络频繁使用问题,我们研发了“低功耗模式”。同时,在每个迭代中,针对各个功耗模块潜在的问题进行治理。
低功耗模式
针对重度消息收发场景引起的网络频繁唤醒问题,我们研发了“低功耗模式”。
低功耗模式主要思想是将消息进行分层,当 App 处于后台的情况下,服务端采用分级、延迟、合并等推送策略,有效解决网络唤醒频繁的问题。
当 App 处于后台时对于低优先级事件(如免打扰消息)不进行推送。
用户设置低功耗模式后,App 处于后台时:
对于中优先级事件(如普通群消息),按用户设定的时间间隔进行延迟合并推送。
对于高优先级事件(如单聊、VIP 消息),立即推送,且合并未推送的中优先级事件推送。
经过验证,低功耗模式开启后,能有效降低重度用户后台耗电量近 70%。
功耗问题治理
在日常迭代中,持续去对潜在功耗问题进行治理和优化。主要问题分为这几类:
后台网络异常频繁收发治理:优化后台异常的、不必要的频繁网络收发;智能心跳优化等。
后台系统服务调用治理:Alarm 频繁唤醒问题,长期持有亮屏唤醒锁阻止系统休眠问题治理,长期持有部分唤醒锁阻止系统休眠问题治理,蓝牙频繁扫描等。
应用后台自启动优化:进程异常频繁自杀问题,优化粘性 Service 自启动以及子进程跨进程调用拉起主进程行为。
CPU 高负载场景治理等:长耗时线程优化、动画泄露治理、死循环治理等。
治理效果
通过对感知到的头部问题治理后,线上功耗体验也有明显的效果体现:
异常次数/异常率:功耗异常总数和功耗异常率下降 50% 以上
功耗健康分:整体功耗健康分维持 99.9 以上(治理前在95分)
用户体验:用户舆情反馈得到明显收敛,有效提升用户体验
获得金标应用认证:通过金标联盟功耗标准基线测试
总结
在功耗体系化能力建设的同时,持续治理优化潜在的功耗问题,大幅提升用户体验,为用户提供低功耗的体验。未来我们还会不断精进优化 App 体验,持续为用户打造极致用户体验而努力。
微信扫码关注该文公众号作者