货拉拉技术

一种Android应用耗电定位方案

背景

通常来说,app耗电相比于其他的性能问题(Crash,Anr)等,会受到比较少的关注,耗电通常是一个app隐藏的性能问题,同时又由于手机性能不同,使用时长不同,使用习惯不同,“耗电问题”从诞生以来,都被业内誉为伪命题,因为耗电量通常不具备较为“标准化”的衡量(我们常说的耗电量 = 模块功率 × 模块耗时),但是模块功率不同手机相差较大,同时不同厂商的定制化原因,导致了耗电的更加无法被有效衡量,但是应用耗电是客观的事实,因此google官方提出了耗电监测工具Battery Historian,希望能以客观的角度衡量耗电。但是实际耗电是关系到定制化的(比如不同app有不同的使用场景,同一个app也有使用场景不同从而导致耗电不同)所以业内也有像meta公司(facabook)的Battery-metrics一样采用了自定义化的标准去衡量自己的应用。本文从官方耗电计算、自定义耗电检测两个出发,从而实现一种app耗电的定位的方案。

耗电计算

在Android系统中,android官方要求了设备制造商必须在 /frameworks/base/core/res/res/xml/power_profile.xml 中提供组件的电源配置文件,以此声明自身各个组件的功耗(文档)

Image

功耗文件获取

通常,power_profile.xml位于/system/framework/framework-res.apk中,这是一个android设备的系统apk,我们可以通过

  1. adb pull /system/framework/framework-res.apk ./

获取当前系统的framework-res apk,这步不需要root即可进行,接着我们可以通过反编译工具,apktool或者jadx都可以,对该apk进行反编译处理,我们所需要的功耗文件就在 /res/xml/power_profile.xml 中。

系统功耗计算

我们得到的功耗文件后,系统是怎么计算功耗的呢?其实就在BatteryStatsHelper中,大部分都是通过使用时长* 功耗(功耗文件对应项)得到每一个模块的耗电,而在我们系统中,每个参与电量计算的模块都继承于PowerCalculator这个基类,同时会重写calculatorApp方法进行自定义的模块耗时计算,我们可以在BatteryStatsHelper的refreshStats方法中看到参与计算的模块。

  1. refreshStats 中

  2. if(mPowerCalculators ==null){

  3. mPowerCalculators =newArrayList<>();

  4. // Power calculators are applied in the order of registration

  5. mPowerCalculators.add(newCpuPowerCalculator(mPowerProfile));

  6. mPowerCalculators.add(newMemoryPowerCalculator(mPowerProfile));

  7. mPowerCalculators.add(newWakelockPowerCalculator(mPowerProfile));

  8. if(!mWifiOnly){

  9. mPowerCalculators.add(newMobileRadioPowerCalculator(mPowerProfile));

  10. }

  11. mPowerCalculators.add(newWifiPowerCalculator(mPowerProfile));

  12. mPowerCalculators.add(newBluetoothPowerCalculator(mPowerProfile));

  13. mPowerCalculators.add(newSensorPowerCalculator(

  14. mContext.getSystemService(SensorManager.class)));

  15. mPowerCalculators.add(newGnssPowerCalculator(mPowerProfile));

  16. mPowerCalculators.add(newCameraPowerCalculator(mPowerProfile));

  17. mPowerCalculators.add(newFlashlightPowerCalculator(mPowerProfile));

  18. mPowerCalculators.add(newMediaPowerCalculator(mPowerProfile));

  19. mPowerCalculators.add(newPhonePowerCalculator(mPowerProfile));

  20. mPowerCalculators.add(newScreenPowerCalculator(mPowerProfile));

  21. mPowerCalculators.add(newAmbientDisplayPowerCalculator(mPowerProfile));

  22. mPowerCalculators.add(newSystemServicePowerCalculator(mPowerProfile));

  23. mPowerCalculators.add(newIdlePowerCalculator(mPowerProfile));

  24. mPowerCalculators.add(newCustomMeasuredPowerCalculator(mPowerProfile));

  25. mPowerCalculators.add(newUserPowerCalculator());

  26. }

我们从上面可以看到cpu,wifi,gps等等都参与耗电的模块计算,同时我们的厂商可以基于此,去定制自己的耗电视图,通常可以在应用信息-电量可以看到,以我的vivo为例子

Image

耗电检测

当然,上面的信息是原生android提供的信息,对于手机厂商来说,是可以在此基础上增加多种耗电检测手段,因此处于一个“大杂烩”的现象。在Android P 及以上版本,谷歌官方推出了 Android Vitals 项目监控后台耗电,目前还在推进过程中,高耗电Android应用提醒的标准,比如

Image

对于应用开发者来说,目前检测自己的应用是否耗电,有以下几个方案

方案1 电流仪测试法

通过外部电流设备,测试当前应用的耗电,同时由于我们可以获取 power_profile.xml 文件,因此可以通过电流仪解析各个模块的对应耗电。

Image

该方案优点是计算准确,缺点是硬件设备投入高且无法定位出是哪个具体代码原因导致的电量消耗。

方案2 Battery Historian

Battery Historian,是谷歌官方提供给应用的耗电检测工具,只需简单的操作配置后,我们能够得到当前运行时应用各模块耗电信息。

Image

覆盖范围包括了所有耗电模块信息(包括了cpu,wifi,gps等),该方案优点是实施较为简单,也能得到较为精确的耗电数据,也能得到相关的耗电详情,缺点是无法定位出是代码中哪部分引起的耗电。

方案3 插桩法

我们可以通过插桩的方式,在相关的耗电api中进行字节码插桩,通过调用者的频次,调用时间进行一定的收集整理,最终可以得到相关的耗电数据,同时因为对代码进行了插桩,我们也能够获取相关调用者的数据,便于之后的代码分析,该方案优点是能够精确定位出代码调用级别的问题,缺点是耗电量化相对于前两个方案来说不那么精确,同时对于插桩api的选择也要有一定的了解,对于数据的整合需要开发。

方案选择

通过对现有方案的调研,我们最终使用了方案3 插桩法,因为耗电有一方面是客观原因,比如对于货运司机app来说,定位数据的获取是伴随着整个app应用周期的,因此耗电量也肯定集中在这个部分。选择插桩法能让我们快速定位出某些不合理的耗电调用,从而达到在不影响业务前提下进行优化。

既然选择了方案3,那么我们需要明确一下插桩的api选择,由于现在行业内并没有相关的开源,较为相关的是以Battery-metrics为代表的定制化检测工具,Battery-metrics依靠插桩的方式,统计了多个部分的耗电时长与使用频率,但是虽然数据处理这部分开源了,但是对于插桩这部分却没有开源,因此对于插桩api的选择,我们参考了 Android Vitals 监控后台耗电的规则

Image

同时补充了我们收集的常见耗电api数据进行补充,且我们最终的耗电模块一定是系统耗电模块的子集,这里选取的是bluetooth,cpu,location,sensor,wakelock来分析,同时还要一个特别的alarm模块也需要加入,因为alarm属于杂项耗电的一种,部分厂商也会对alarm进行监控(alarm过多也会提示应用频繁,降低耗电等),好了,目标明确,我们进行开发。

耗电监控实现

这里分为两个部分,一部分是耗电 api 的选择,一部分是ASM插桩实现

耗电api选择

BlueTooth

蓝牙部分中,扫描部分是主要的耗电存在,属于梯度耗电计算,功耗模块中也有wifi.scan去记录蓝牙的扫描功耗,通常我们可以通过以下方式开启扫描

  1. val bluetooth =this.getSystemService(Context.BLUETOOTH_SERVICE)asBluetoothManager

  2. val scanSettings:ScanSettings=ScanSettings.Builder()

  3. .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)// 设置连续扫描

  4. .build()

  5. val scanner = bluetooth.adapter.bluetoothLeScanner

  6. val callback =object:ScanCallback(){

  7. override fun onBatchScanResults(results:MutableList<ScanResult>?){

  8. super.onBatchScanResults(results)

  9. }

  10. override fun onScanFailed(errorCode:Int){

  11. super.onScanFailed(errorCode)

  12. }

  13. override fun onScanResult(callbackType:Int, result:ScanResult?){

  14. super.onScanResult(callbackType, result)

  15. }

  16. }

  17. scanner.startScan(null, scanSettings, callback)

其中值得注意的是,ScanSettings中可以配置ScanMode,这里的ScanMode的设置不同对耗电也有不同的影响

  • SCANMODELOW_POWER : 低功耗模式,默认此模式,如果应用不在前台,则强制此模式

  • SCANMODEBALANCED :平衡模式,一定频率下返回结果

  • SCANMODELOW_LATENCY :高功耗模式,建议应用在前台才使用此模式

  • SCANMODEOPPORTUNISTIC:这种模式下, 只会监听其他APP的扫描结果回调

同时我们可以通过

  1. scanner.stopScan(callback)

关闭本次蓝牙扫描,到这里,我们就明白了,我们主要关注的插桩api是startScan(开启扫描)stopScan(停止扫描),并记录耗电时间与当次扫描模式,以便后续按需进行优化。

cpu

cpu的使用时长我们可以通过读取 /proc/self/stat文件获取,得到的数据

  1. 24010(cat) R 2400724010240073481724010421068849300010002001042056617218490060889818446744073709551615392793329664392793777696549292849424000001073775864000171000003927938001603927938105203932043427845492928518605492928518805492928518805492928552720

上面的数据我们需要第14项-17项,分别是用户态运行时间,内核态运行时间,用户态下等待子进程运行的时间(子进程运行时间),内核态下等待子进程运行的时间(子进程运行时间),我们可以在linux manual上看到各个项的含义。

值得注意的是,我们得到的时间是以clock ticks(cpu时钟节拍)计算的,所以我们需要获取cpu运行了多少秒的话,那么就需要cpu每秒的节拍,这个可以通过

  1. Os.sysconf(OsConstants._SC_CLK_TCK)

获取,通过两者相除,我们就能得到程序cpu在用户态以及内核台运行的时间。

定位

定位也是一个耗电巨头,常见的定位是gps,当然获取不到gps定位时也会切换成net网络定位,还有wifi辅助定位等等,我们一般通过requestLocationUpdates发起一次持续定位,requestSingleUpdate发起一次单次定位。虽然requestSingleUpdate会让定位provider(比如gps)保持一定的活跃时间,但是单次定位的消耗远远小于requestLocationUpdates持续定位,我们来关注一下requestLocationUpdates,它有很多重载的函数,

  1. fun requestLocationUpdates(

  2. provider:String,

  3. minTime:Long,

  4. minDistance:Float,

  5. listener:LocationListener)

我们以此函数为例子

  • provider指当前定位由谁提供,常见有gps,network

  • minTime表示经过当前时间后,会重新发起一次定位

  • minDistance表示超过当前距离后,也会重新发起一次定位

  • listener就是当前定位信息的回调

持续定位存在耗电主要有以下方面:1.定位时间长,比如只有requestLocationUpdates,而没有removeUpdates,导致定位在全局范围使用 2.minTime配置时间过短,导致定位频繁 3.minDistance距离过短,也会导致定位频繁。

取消定位可以通过removeUpdates去取消,当然官方推荐是需要时就开启requestLocationUpdates,不需要就要通过removeUpdates及时关闭,达到一个性能最优的状态,当然实际开发中,我们会遇到其他三方的sdk,比如百度定位,高德定位等,因为是第三方,一般都会内部封装了requestLocationUpdates的调用。

因此,我们需要进行插桩的api就是requestLocationUpdates与removeUpdates啦,两次调用的时间间隔就是定位的耗时。

sensor

sensor 传感器也是按照梯度计算的,主要是通过时的samplingPeriodUs,与maxReportLatencyUs区分不同的梯度

  1. publicboolean registerListener(SensorEventListener listener,Sensor sensor,

  2. int samplingPeriodUs,int maxReportLatencyUs){

  3. int delay = getDelay(samplingPeriodUs);

  4. return registerListenerImpl(listener, sensor, delay,null, maxReportLatencyUs,0);

  5. }

  • samplingPeriodUs两次传感器事件的最小间隔(ms)可以理解为采样率,就算我们指定了这个参数,实际调度也是按照系统决定,samplingPeriodUs越大耗电越少

  • maxReportLatencyUs 允许被延迟调度的最大时间,默认为0,即希望立即调度,但是实际上也是由系统决定调度。

同样的,我们也可以通过unregisterListener取消当前的sensor监听,还是跟定位一样,官方建议我们按需使用。

通过对sensor的理解,我们会发现sensor一般是由厂商定制化决定的调度时间,我们设定的参数会有影响,不过实际也是按照系统调度,传感器事件产生时,会放入一个队列(队列大小可由厂商定制)中,当系统处于低功耗时,非wakeup的sensor就算队列满了,也不会退出低功耗休眠模式。相反,如果属于wakeup的sensor,系统就会退出休眠模式在队列满之前处理事件,进一步加大耗电,因为会使得AP(Application Processor AP是ARM架构的处理器)处于非休眠状态,该状态下ap能耗至少在50mA。我们判断sensor是否wakeup可通过

  1. publicboolean isWakeUpSensor(){

  2. return(mFlags & SENSOR_FLAG_WAKE_UP_SENSOR)!=0;

  3. }

因此对于sensor我们主要插桩的api是registerListener与unregisterListener,统计sensor的耗时与wakeup属性。

wakelock

wakelock是一种锁的机制,只要有应用持有这个锁,CPU就无法进入休眠状态(AP处理会处于非休眠状态),会一直处于工作状态。因此就算是屏幕处于熄屏状态,我们的系统也无法进行休眠,不仅如此,一些部分厂商也会通过wakelock持有市场,会弹窗提示应用耗电过多,因为无法进入低功耗状态,所以往往会放大其他模块的耗电量,即使用户什么也没做。因此如果cpu异常的app,可以排查wakelock的使用(往往是因为这个导致非使用app耗电,比如把应用放一晚上,第二天没电的情况,可着重排查wakelock使用)。

wakelock包括PowerManager.WakeLock与WifiManager.WifiLock,两者都提供了acquire方法获取一个唤醒锁

  1. val mWakeLock = pm.newWakeLock(

  2. PowerManager.PARTIAL_WAKE_LOCK orPowerManager.ACQUIRE_CAUSES_WAKEUP,

  3. this.javaClass.canonicalName

  4. )

  5. mWakeLock.setReferenceCounted(false)

  6. mWakeLock.acquire()

其中这个lock默认是引用计数的。怎么理解呢?就是调用acquire方法与调用release方法次数一致时,才会真正把这个锁给释放掉,否则会一直持有该lock,因此,它是一个隐藏很深的耗电刺客,需要时刻注意。当然我们也可以取消引用计数机制,可通过setReferenceCounted(false)设置,此时调用一次release即可释放掉该lock。

值得一提的是,acquire也提供了timeout释放的策略

  1. publicvoid acquire(long timeout){

  2. synchronized(mToken){

  3. acquireLocked();

  4. mHandler.postDelayed(mReleaser, timeout);

  5. }

  6. }

  1. privatefinalRunnable mReleaser =()-> release(RELEASE_FLAG_TIMEOUT);

本质也是通过handler进行的postDelayed然后时间到了调用release方法释放。

因此我们插桩的api是acquire,setReferenceCounted以及release函数,获取wakelock的基础信息以及持有时长。

alarm

alarm严格来说并不在我们上述的耗电计算中,属于杂项耗电,但是alarm通常会被乱用,同时部分精确的闹钟(比如setAlarmClock方法)会在low-power idle mode下,也会被触发,导致低功耗模式下也进一步耗电。同时精确闹钟会脱离了系统以耗电最优的周期去触发alarm,因此耗电效率不高但是较为“守时”。(普通的alarm会被系统安排在一定的周期进行)

Image

该图引自分析 Android 耗电原理后,飞书是这样做耗电治理的

因为set方法会被系统调度,所以我们本次不在此讨论,我们分析精确闹钟的api即可,分别是

  1. low-power idle 能执行

  2. publicvoid setAlarmClock(AlarmClockInfo info,PendingIntent operation){

  3. setImpl(RTC_WAKEUP, info.getTriggerTime(), WINDOW_EXACT,0,0, operation,

  4. null,null,(Handler)null,null, info);

  5. }

  1. low-power idle 能执行

  2. publicvoid setExactAndAllowWhileIdle(@AlarmTypeint type,long triggerAtMillis,

  3. PendingIntent operation){

  4. setImpl(type, triggerAtMillis, WINDOW_EXACT,0, FLAG_ALLOW_WHILE_IDLE, operation,

  5. null,null,(Handler)null,null,null);

  6. }

  1. low-power idle 不执行,但是精确闹钟会一定程度阻碍了系统采取耗电最优的方式进行触发

  2. publicvoid setExact(@AlarmTypeint type,long triggerAtMillis,PendingIntent operation){

  3. setImpl(type, triggerAtMillis, WINDOW_EXACT,0,0, operation,null,null,(Handler)null,

  4. null,null);

  5. }

因为alarm的特性,很多应用都采取alarm进行任务的调度,但是更加好的做法是,如果是应用内的定时任务,官方更加推荐直接采用Handler去实现,同时如果是后台任务,更好的做法也是采用Worker Manager去实现。但是因为历史原因,alarm其实还是被滥用的风险还是很高的,因此我们还是要对setExact,setExactAndAllowWhileIdle,setAlarmClock去进行插桩监控

耗电统计

到最后,我们如何获取耗电百分比呢?其实我们可以直接通过广播去获取当前的电量level,单位时间后再次获取,就是我们这段时间的耗电了,可以通过

  1. val intent = registerReceiver(null,IntentFilter(Intent.ACTION_BATTERY_CHANGED))

  2. val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL,-1)?:-1

  3. val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE,-1)?:-1

因为电量变化是一个粘性广播,我们可以直接从intent的返回获取到当前的电量数值,同时也可以通过注册一个广播接听当前是否处于充电状态

  1. override fun onReceive(context:Context?, intent:Intent?){

  2. synchronized(this){

  3. when(intent?.action){

  4. Intent.ACTION_POWER_CONNECTED ->{

  5. receive.invoke(SystemClock.elapsedRealtime(),Intent.ACTION_POWER_CONNECTED)

  6. }

  7. Intent.ACTION_POWER_DISCONNECTED ->{

  8. receive.invoke(SystemClock.elapsedRealtime(),Intent.ACTION_POWER_DISCONNECTED)

  9. }

  10. }

  11. }

  12. }

ASM插桩实现

通过耗电api的选择这一部分的介绍,我们能够得到了具体要进行字节码插桩的api,其实他们插桩的思路都大体一致,我们以wifiLock举例子。

我们首先要明确我们需要的统计信息是什么:

  1. 函数调用时的参数:我们知道耗电会有梯度计算,不同模式下耗电影响也不同,所以我们需要发出api调用时的参数,才能做归类统计

  2. 调用时的调用者:只是知道耗电处是不够的,还要知道是谁发起的调用,方便我们后续排查问题。

因此,我们需要调用函数的时候,不仅要能够保证获取函数原本的参数调用,同时也要添加调用者参数。我们来看一下原本的wifiLock的使用以及对应编译后的字节码:

  1. val wifiLock:WifiManager.WifiLock=

  2. wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL,"mylock")

  3. wifiLock.acquire()

  1. L4

  2. LINENUMBER 92 L4

  3. ALOAD 2

  4. ICONST_1

  5. LDC "mylock"

  6. INVOKEVIRTUAL android/net/wifi/WifiManager.createWifiLock (ILjava/lang/String;)Landroid/net/wifi/WifiManager$WifiLock;

  7. ASTORE 4

  8. ALOAD 4

  9. LDC "wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL, "mylock")"

  10. INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkNotNullExpressionValue (Ljava/lang/Object;Ljava/lang/String;)V

  11. ALOAD 4

  12. L5

  13. LINENUMBER 91 L5

  14. ASTORE 3将对象wifilock存入了index为3的局部变量表

  15. L6

  16. LINENUMBER 93 L6

  17. ALOAD 3在局部变量表取出index为3的对象 wifilock

  18. INVOKEVIRTUAL android/net/wifi/WifiManager$WifiLock.acquire ()V

可以看到,字节码调用上本来就存在着环境相关的指令,比如ALoad,虽然跟我们的acquire方法调用无关,但是我们不能破坏指令的结构,因此我们在不破坏操作数栈的情况下,可以采用同类替换的方式,即把属于WifiLock的acquire方法转化为我们自定义的acquire方法,转化后实际调用如下:

  1. val wifiLock:WifiManager.WifiLock=

  2. wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL,"mylock")

  3. // 替换后

  4. wifiLock.acquire()===>WifiWakeLockHook.acquire(wifiLock)

Image

那么我们转化的时候,就应该考虑的是:acquire被调用时,操作数栈其实隐含了一个wifilock对象,所以才能采用INCVOKEVIRTUAL的指令调用,如果想要调用变成我们自定义的hook类的话,我们也需要把wifilock对象当作参数列表的第一个参数传入,同时为了保持操作数栈的结果,可以把INCVOKEVIRTUAL指令改为INVOKESTATIC指令,同时我们也需要记录当前的调用者类名,我们就需要通过一个LDC指令把类名信息放入操作数栈中,通过INVOKESTATIC指令调用一个全新的方法,原理讲解完毕,我们一步步开始实现:

自定义Hook类,即WifiWakeLock 调用方法的替代实现

  1. @Keep

  2. objectWifiWakeLockHook{

  3. @Synchronized

  4. @JvmStatic

  5. fun setReferenceCounted(wakeLock:WifiLock, value:Boolean){

  6. if(wifiWakeLockRecordMap[wakeLock.hashCode()]==null){

  7. wifiWakeLockRecordMap[wakeLock.hashCode()]=WakeLockData()

  8. }

  9. with(wifiWakeLockRecordMap[wakeLock.hashCode()]!!){

  10. isRefCounted = value

  11. }

  12. wakeLock.setReferenceCounted(value)

  13. }

  14. @JvmStatic

  15. fun acquire(wifiLock:WifiLock, acquireClass:String){

  16. wifiLock.acquire()

  17. if(wifiWakeLockRecordMap[wifiLock.hashCode()]==null){

  18. wifiWakeLockRecordMap[wifiLock.hashCode()]=WakeLockData()

  19. }

  20. with(wifiWakeLockRecordMap[wifiLock.hashCode()]!!){

  21. acquireTime++

  22. if(startHoldTime ==0L){

  23. startHoldTime =SystemClock.uptimeMillis()

  24. }

  25. holdClassName = acquireClass

  26. }

  27. }

  28. @JvmStatic

  29. fun release(wifiLock:WifiLock, releaseClass:String){

  30. wifiLock.release()

  31. if(wifiWakeLockRecordMap[wifiLock.hashCode()]==null){

  32. throwNoRecordException()

  33. }

  34. with(wifiWakeLockRecordMap[wifiLock.hashCode()]!!){

  35. heldTime =SystemClock.uptimeMillis()- startHoldTime

  36. releaseTime++

  37. releaseClassName = releaseClass

  38. }

  39. }

  40. }

同时我们把需要记录的数据放在一个map中,为了不产生内存泄漏,我们可以直接存入对象的hashcode作为key,同时value为我们自定义的需要采集的数据。

  1. classWakeLockData(){

  2. // acquire 方法调用次数

  3. var acquireTime:Int=0

  4. // 释放次数

  5. var releaseTime:Int=0

  6. // 最终持有唤醒的时间 = 最后release - startHoldTime

  7. var heldTime:Long=0L

  8. // 开始唤醒的时间

  9. var startHoldTime:Long=0L

  10. // 是否采用了引用计数

  11. var isRefCounted =true

  12. // 针对调用acquire(long timeout)却不调用release 的场景

  13. var autoReleaseByTimeOver:Long=0L

  14. // 自动release 次数

  15. var autoReleaseTime:Int=0

  16. var holdClassName :String=""

  17. var releaseClassName:String=""

  18. // WakeLock 是否已经被释放

  19. fun isRelease():Boolean{

  20. if(!isRefCounted){

  21. if(releaseTime >0){

  22. returntrue

  23. }

  24. }else{

  25. if(acquireTime == releaseTime){

  26. returntrue

  27. }

  28. // 如果acquire的次数 == releaseTime && 超时删除acquire已超时

  29. if((acquireTime - autoReleaseTime)== releaseTime &&SystemClock.uptimeMillis()- autoReleaseByTimeOver >0){

  30. returntrue

  31. }

  32. }

  33. returnfalse

  34. }

  35. override fun toString():String{

  36. return"WakeLockData(acquireTime=$acquireTime, releaseTime=$releaseTime, heldTime=$heldTime, startHoldTime=$startHoldTime, isRefCounted=$isRefCounted, autoReleaseByTimeOver=$autoReleaseByTimeOver, autoReleaseTime=$autoReleaseTime, holdClassName='$holdClassName', releaseClassName='$releaseClassName')"

  37. }

  38. }

ASM Hook

进行hook之前,我们需要找到我们想要hook的函数特征,我们想要hook的函数是acquire 与 release的,即如何唯一识别一个函数,有三大法宝:

  • 函数名:MethodInsnNode中的name,本例子分别是 acquire 与 release

  • 函数签名:MethodInsnNode中的desc,本例子函数签名都是()V

  • 函数调用者:MethodInsnNode的owner,本例子android/net/wifi/WifiManager$WifiLock,WifiLock是WifiManager的内部类

通过这一步,我们能够找到了我们想要的函数,这样就不会因为错误的hook导致其他函数的改变,接着我们根据上述思想,在这个方法的指令集中进行我们的“小操作”

Image

按照流程图,wifilock对象我们不需要改变,接着我们希望在调用函数最后加上调用者名称,这个加的位置是在所以应调函数的背后,比如 acquire() 函数,我们加上调用者名称后就变成这样了acquire(String 调用者名称) ,上面我们能够了解,我们可以通过MethodInsnNode的owner属性获取,接着我们通过LDC指令即可把这个字符串打入操作数栈,最后INVOKESTATIC调用自定义类的hook方法即可,最后别忘了修改函数签名(desc) ,因为我们要调用的函数指令已经变成了自定义类的函数指令

由于我们需要hook的api都是INVOKEVIRYTUAL指令,所以我们可以采用上述的思想,形成一个工具类

  1. ASM tree api

  2. publicclassHookHelper{

  3. staticpublicvoid replaceNode(MethodInsnNode node,ClassNode klass,MethodNode method,String owner){

  4. LdcInsnNode ldc =newLdcInsnNode(klass.name);

  5. method.instructions.insertBefore(node, ldc);

  6. node.setOpcode(Opcodes.INVOKESTATIC);

  7. int anchorIndex = node.desc.indexOf(")");

  8. String subDesc = node.desc.substring(anchorIndex);

  9. String origin = node.desc.substring(1, anchorIndex);

  10. node.desc ="(L"+ node.owner +";"+ origin +"Ljava/lang/String;"+ subDesc;

  11. node.owner = owner;

  12. System.out.println("replaceNode result is "+ node.desc);

  13. }

  14. }

  • node 当前方法的某一条指令

  • klass 当前调用class

  • method 当前方法

  • owner 为我们需要变更后的hookclass的类名

数据层

通过以上步骤,我们能够拿到所需的所有数据了,这里再做一个统一的管理

Image

同时各个数据的暴露方式可通过接口的方式提供给调用层,当数据更新时,调用者只需关心自己感兴趣的部分即可

Image

当然,我们默认也可以有一个debug环境下可使用的调试页面,方便及时查看自己想要的数据。

Image

后续补充方案

我们详细介绍了耗电定位的做法,把复杂的耗电量计算转换为单位时间内,耗电时长计算与实际耗电 api 模块调用次数去计算, 当然这并不是终点,通过插桩我们可以得到各个模块的使用时间与使用次数,在后续计划中,我们可以通过对 power_profile的解析,拿到不同手机的的模块数据,通过耗电量 = (各个模块调用时间 * 各个单位模块功耗)就可以低成本去量化耗电量这个指标,提供给更多的业务使用。

该方案优点如下:

| 优点 | | ---------------------------------- | | 基于aop方案字节码插桩asm实现,对代码无侵入 | | 自动初始化 | | 可根据调用粒度(比如调用次数)按需dump调用 | | 可记录调用者class,方便后续排查问题,精准定位“bad sdk” |

总结

通过上面,我们能够了解到自定义耗电检测方案的原理与实现,当然具体需要采集的数据以及比对我们可自定义处理。