联系我们
简单又实用的WordPress网站制作教学
当前位置:网站首页 > 程序开发学习 > 正文

不当暖宝宝,Android 耗电检测之路

作者:访客发布时间:2023-12-24分类:程序开发学习浏览:64


导读:背景介绍耗电问题可能在市面上并不多见,因为大部分APP的使用时间是非常短暂的,即使有着强耗电行为,也不容易被用户感知,同时对于用户来说,耗电问题最直观的就是单位时间手机掉电快,也...

背景介绍

耗电问题可能在市面上并不多见,因为大部分APP的使用时间是非常短暂的,即使有着强耗电行为,也不容易被用户感知,同时对于用户来说,耗电问题最直观的就是单位时间手机掉电快,也有可能伴随着较高的设备温度,但是大部分硬件层都有控制,比如高通芯片会在CPU高温的情况下,会暂缓超大核的调度。然而不同硬件厂商调度策略不同,Android系统也存在碎片化严重现象,同样的CPU不同的厂商也有不一样的调度策略,因此耗电问题比较难以排查,而且线下复现非常困难。

货拉拉司机端运行时间非常长,而且司机都在外活动,任何高耗电行为都容易引起客诉,因此我们将进一步探索,如何做到耗电监控,以及如何通过监控发现可以优化的点。本文涉及的项目已经开源,batteryfinder-android,欢迎pr!

系统耗电计算

在深入优化之前,我们有必要回顾一下,我们的Android系统是如何进行耗电统计的,我们应用有一些耗电排行以及信息,其实都是通过getCurrentBatteryUsageStats获取的,比如我们执行

adb shell dumpsys batterystats --reset

getCurrentBatteryUsageStats很长,但是基本逻辑是获取所有的参与耗电统计Calculator,通过getPowerCalculators,然后遍历计算即可,当然每个Calculator会有自己的计算逻辑

   private BatteryUsageStats getCurrentBatteryUsageStats(BatteryUsageStatsQuery query,
            long currentTimeMs) {
        // 记录时间
        final long realtimeUs = elapsedRealtime() * 1000;
        final long uptimeUs = uptimeMillis() * 1000;

        final boolean includePowerModels = (query.getFlags()
                & BatteryUsageStatsQuery.FLAG_BATTERY_USAGE_STATS_INCLUDE_POWER_MODELS) != 0;
        final boolean includeProcessStateData = ((query.getFlags()
                & BatteryUsageStatsQuery.FLAG_BATTERY_USAGE_STATS_INCLUDE_PROCESS_STATE_DATA) != 0)
                && mStats.isProcessStateDataAvailable();
        final boolean includeVirtualUids =  ((query.getFlags()
                & BatteryUsageStatsQuery.FLAG_BATTERY_USAGE_STATS_INCLUDE_VIRTUAL_UIDS) != 0);

        final BatteryUsageStats.Builder batteryUsageStatsBuilder = new BatteryUsageStats.Builder(
                mStats.getCustomEnergyConsumerNames(), includePowerModels,
                includeProcessStateData);
        // TODO(b/188068523): use a monotonic clock to ensure resilience of order and duration
        // of stats sessions to wall-clock adjustments
        batteryUsageStatsBuilder.setStatsStartTimestamp(mStats.getStartClockTime());
        batteryUsageStatsBuilder.setStatsEndTimestamp(currentTimeMs);
       
        SparseArray<? extends BatteryStats.Uid> uidStats = mStats.getUidStats();
        for (int i = uidStats.size() - 1; i >= 0; i--) {
            final BatteryStats.Uid uid = uidStats.valueAt(i);
            if (!includeVirtualUids && uid.getUid() == Process.SDK_SANDBOX_VIRTUAL_UID) {
                continue;
            }

            batteryUsageStatsBuilder.getOrCreateUidBatteryConsumerBuilder(uid)
                    .setTimeInStateMs(UidBatteryConsumer.STATE_BACKGROUND,
                            getProcessBackgroundTimeMs(uid, realtimeUs))
                    .setTimeInStateMs(UidBatteryConsumer.STATE_FOREGROUND,
                            getProcessForegroundTimeMs(uid, realtimeUs));
        }
        //powerCalculators 一步步计算
        final int[] powerComponents = query.getPowerComponents();
        final List<PowerCalculator> powerCalculators = getPowerCalculators();
        for (int i = 0, count = powerCalculators.size(); i < count; i++) {
            PowerCalculator powerCalculator = powerCalculators.get(i);
            if (powerComponents != null) {
                boolean include = false;
                for (int j = 0; j < powerComponents.length; j++) {
                    if (powerCalculator.isPowerComponentSupported(powerComponents[j])) {
                        include = true;
                        break;
                    }
                }
                if (!include) {
                    continue;
                }
            }
            powerCalculator.calculate(batteryUsageStatsBuilder, mStats, realtimeUs, uptimeUs,
                    query);
        }

        if ((query.getFlags()
                & BatteryUsageStatsQuery.FLAG_BATTERY_USAGE_STATS_INCLUDE_HISTORY) != 0) {
            if (!(mStats instanceof BatteryStatsImpl)) {
                throw new UnsupportedOperationException(
                        "History cannot be included for " + getClass().getName());
            }

            BatteryStatsImpl batteryStatsImpl = (BatteryStatsImpl) mStats;

            // Make a copy of battery history to avoid concurrent modification.
            Parcel historyBuffer = Parcel.obtain();
            historyBuffer.appendFrom(batteryStatsImpl.mHistoryBuffer, 0,
                    batteryStatsImpl.mHistoryBuffer.dataSize());
            // 保存的电量信息
            final File systemDir =
                    batteryStatsImpl.mBatteryStatsHistory.getHistoryDirectory().getParentFile();
            final BatteryStatsHistory batteryStatsHistory =
                    new BatteryStatsHistory(batteryStatsImpl, systemDir, historyBuffer);

            batteryUsageStatsBuilder.setBatteryHistory(batteryStatsHistory);
        }

        BatteryUsageStats stats = batteryUsageStatsBuilder.build();
        if (includeProcessStateData) {
            verify(stats);
        }
        return stats;
    }

那么参与耗电计算的Calculator,其实就代表着当前耗电会存在的功耗项,我们看一下getPowerCalculators方法


    private List<PowerCalculator> getPowerCalculators() {
        synchronized (mLock) {
            if (mPowerCalculators == null) {
                mPowerCalculators = new ArrayList<>();

                // Power calculators are applied in the order of registration
                mPowerCalculators.add(new BatteryChargeCalculator());
                mPowerCalculators.add(new CpuPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new MemoryPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new WakelockPowerCalculator(mPowerProfile));
                if (!BatteryStats.checkWifiOnly(mContext)) {
                    mPowerCalculators.add(new MobileRadioPowerCalculator(mPowerProfile));
                }
                mPowerCalculators.add(new WifiPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new BluetoothPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new SensorPowerCalculator(
                        mContext.getSystemService(SensorManager.class)));
                mPowerCalculators.add(new GnssPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new CameraPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new FlashlightPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new AudioPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new VideoPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new PhonePowerCalculator(mPowerProfile));
                mPowerCalculators.add(new ScreenPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new AmbientDisplayPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new IdlePowerCalculator(mPowerProfile));
                mPowerCalculators.add(new CustomMeasuredPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new UserPowerCalculator());

                // It is important that SystemServicePowerCalculator be applied last,
                // because it re-attributes some of the power estimated by the other
                // calculators.
                mPowerCalculators.add(new SystemServicePowerCalculator(mPowerProfile));
            }
        }
        return mPowerCalculators;
    }

作为手机厂商的一方,其实是可以不断在里面补充自己的耗电规则,只需要继承PowerCalculator 然后把类放入list即可,作为应用开发者的我们,可以通过这些规则,去定义我们自己耗电选项,只需要符合我们自己的耗电项是系统耗电项的子集即可。

我们按照功耗计算,大概分为两类,一类是器件功耗,即产生耗电的原因是单一的,比如我们使用到了GPS,那肯定就产生了GPS对应的功耗。另一类是间接功耗,比如屏幕,只要亮着就会有耗电产生,再比如我们常说的CPU,只要有运行肯定就有使用到CPU,这类功耗往往很难单独说由于某个调用产生了功耗,而是我们使用过程就会去产生的聚合功耗,这些功耗一般很难去拆分到某个使用。

器件功耗间接功耗
SensorPowerCalculatorCpuPowerCalculator
BluetoothPowerCalculatorMemoryPowerCalculator
GnssPowerCalculatorWakelockPowerCalculator
FlashlightPowerCalculatorWifiPowerCalculator
AudioPowerCalculatorScreenPowerCalculator
........

直接功耗的计算比较简单:即功耗 ≈ 单位功耗 * 时长,之所以不是等于号,是因为单位功耗本身会有偏差,同时部分器件也有梯度计算的概念,为了简单,我们可以直接用这个公式。间接功耗中,比如CPU,CPU 的每一个集群(Cluster,一般一个集群包含一个或多个规格相同的 Core)也有额外的耗电,此外整个 CPU 处理器芯片也有功耗。简单计算的话,CPU 电量 = SUM(各核心功耗)+ 各集群(Cluster)功耗 + 芯片功耗 。可以看到,间接耗电的计算比较复杂,这其中比较特殊的是WakeLock,我们第一印象中,WakeLock唤醒锁其实并不带来直接的功耗,因为只是防止系统进行休眠罢了,但是实际上,我们Android系统会把它当成跟器件功耗的计算一样,采用POWER_CPU_IDLE,cpu空闲时功耗设为WakeLock的基础功耗

    在Android11中
    public WakelockPowerCalculator(PowerProfile profile) {
        mPowerWakelock = profile.getAveragePower(PowerProfile.POWER_CPU_IDLE);
    }
    
    public void calculateApp(BatterySipper app, BatteryStats.Uid u, long rawRealtimeUs,
                             long rawUptimeUs, int statsType) {
        long wakeLockTimeUs = 0;
        final ArrayMap<String, ? extends BatteryStats.Uid.Wakelock> wakelockStats =
                u.getWakelockStats();
        final int wakelockStatsCount = wakelockStats.size();
        for (int i = 0; i < wakelockStatsCount; i++) {
            final BatteryStats.Uid.Wakelock wakelock = wakelockStats.valueAt(i);

            // Only care about partial wake locks since full wake locks
            // are canceled when the user turns the screen off.
            BatteryStats.Timer timer = wakelock.getWakeTime(BatteryStats.WAKE_TYPE_PARTIAL);
            if (timer != null) {
                wakeLockTimeUs += timer.getTotalTimeLocked(rawRealtimeUs, statsType);
            }
        }
        app.wakeLockTimeMs = wakeLockTimeUs / 1000; // convert to millis
        mTotalAppWakelockTimeMs += app.wakeLockTimeMs;

        时长* 单位功耗(cpu idle)
        app.wakeLockPowerMah = (app.wakeLockTimeMs * mPowerWakelock) / (1000*60*60);
        if (DEBUG && app.wakeLockPowerMah != 0) {
            Log.d(TAG, "UID " + u.getUid() + ": wake " + app.wakeLockTimeMs
                    + " power=" + BatteryStatsHelper.makemAh(app.wakeLockPowerMah));
        }
    }

排查应用耗电

我们通过上文,简单了解到了系统是怎么进行耗电计算的,那么作为应用开发者的我们,怎么去排查自己应用的耗电情况呢?

系统派

我们刚刚也看到了,系统通过batterystats回去存储当前的耗电情况,比如我们可以通过以下命令dump出来一份耗电数据

adb shell dumpsys batterystats --reset

通过这份数据,我们可以在此基础上去进行解析,目前Google官方提供的就是Battery Historian ,通过解析系统级别的耗电数据,从而获取到具体的明细:

通过读取系统的数据,我们能够获取到较为准确的且全面的数据。我们能够找到当前耗电占比,也能明确到大概是哪部分原因导致了耗电的产生,但是通过这些占比数据,有时候我们往往很难去发现真正的问题出现在哪?比如我们可能看到,Wifi扫描占据了主要的耗电,但是项目中,发起wifi扫描的地方会有很多,有可能出现在某个三方库,也有可能出现在使用姿势不正确导致的耗电。因此,往往我们需要的是更精确的调用数据,比如堆栈/调用类等,去获取更加全面的跟踪数据。同时,基于系统派的工具,往往也依赖于线下测试用例的执行,因为不同的场景,所产生耗电往往不一样。由于线上环境的复杂,比如存在AB实验,又或者存在多种因素,往往导致Badcase产生的场景,很有可能无法在线下复现。

插桩派

为了弥补“系统派”检测的不足,行业内对于电量检测往往选择了“插桩”派,比如facebook battery-metric。这里需要明确的是,两个派系并非对立的存在,我们可以互相结合,达到互补的目的。

货拉拉司机版经过了多年的迭代,其中代码复杂度是比较高的,同时由于涉及到各种三方库以及地图等等,其中可能存在很多隐藏的耗电问题,比如我们在某个版本上,经常受到司机们的反馈,一些厂商会提示我们阻止系统休眠,应用耗电过快,建议结束运行,会带给司机们很不好的体验。

我们在线下,通过各种手段,也没能够很直观的找到问题所在,而且当前开源项目中,也很缺乏能够满足我们排查问题的方案,比如我们上面提到的 battery-metric,插桩部分不开源。通过battery historian,我们也只能看到大概的占比数据,并不能找到真正的源头。因此,我们实现了自己的一套插桩派耗电检测工具。

插桩派顾名思义,其实就是通过字节码编辑技术,在编译时把可能导致耗电的API统一监控起来,通过在编译时插入自定义监控代码,我们可以很方便的拿到一些信息,比如在编译时插入堆栈打印代码,我们就能够在运行时获取到这些堆栈数据。

image.png

通过插桩的方式,我们很方便的在运行时,拿到跟踪数据,同时还能排查到各种疑似bug的场景,比如极端情况下只有申请调用,没有释放调用的异常case。

然而为了实现插桩派方案,有几个以下待解决的问题:

  1. 插桩需要HOOK哪些API
  2. 如何降低插桩的成本,以及监控方法的处理
  3. 如何把耗电量化

下面我们将围绕着上述三个问题,进行展开。

利用插桩派实现耗电检测

插桩需要HOOK哪些API

我们在上文学习到了系统耗电是如何统计的,因此我们在功耗层面,hook的api必然是系统耗电计算项的子集。

                mPowerCalculators.add(new BatteryChargeCalculator());
                mPowerCalculators.add(new CpuPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new MemoryPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new WakelockPowerCalculator(mPowerProfile));
                if (!BatteryStats.checkWifiOnly(mContext)) {
                    mPowerCalculators.add(new MobileRadioPowerCalculator(mPowerProfile));
                }
                mPowerCalculators.add(new WifiPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new BluetoothPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new SensorPowerCalculator(
                        mContext.getSystemService(SensorManager.class)));
                mPowerCalculators.add(new GnssPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new CameraPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new FlashlightPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new AudioPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new VideoPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new PhonePowerCalculator(mPowerProfile));
                mPowerCalculators.add(new ScreenPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new AmbientDisplayPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new IdlePowerCalculator(mPowerProfile));
                mPowerCalculators.add(new CustomMeasuredPowerCalculator(mPowerProfile));
                mPowerCalculators.add(new UserPowerCalculator());

在这些基础上,我们更细致的划分了以下几个维度

从大概的功耗角度,我们划分了两项

  1. 直接耗电器件:直接产生功耗的因素,这些就是只要我们用到了这些器件,就会产生相应的耗电其中又分为了两类,单因素功耗与多因素功耗

1.1 单因素功耗:耗电因素单一,比如像GPS耗电,只要用了,就会去产生耗电,耗电的调用者往往只有一个,就是调用的发起者。

1.2 多因素功耗:耗电因素复杂,比如CPU,任何触发CPU运转的动作,都会触发CPU耗电,比如绘制了一个view,又或者是应用了某些计算,因此,多因素功耗往往有多个调用的发起者,而且比较难以把原因归类为单个因素影响。

  1. 间接耗电:不直接产生功耗,但是由于运行的机制,可能会导致直接耗电器件处于更长时间的运行从而产生耗电。比较典型的例子是WakeLock,也就是我们常说的唤醒锁,我们持有唤醒锁这一行为其实本身并不产生耗电,但是因为持有了WakeLock,系统层会根据AP锁的定义,从而阻止CPU进行低功耗的休眠态,从而导致了CPU一直运行,而这部分电量,可以说是间接的耗电产生。常见的AP锁有以下几种,我们常用的PowerWakeLock就属于第一种:

在项目中,我们主要关注的是直接功耗中的单因素功耗与间接功耗,因为这两种情况的耗电往往是耗电问题主要原因,比如申请了资源一直不释放,又或者是错误的申请资源。我们没有对多因素投入更多的检测手段,原因是多因素衡量原因复杂,同时我们也有相应的APM平台能够对CPU/流量等进行了记录,所以我们把更多的经历投入到对以上两种耗电场景进行检测。

下面我们主要关注的HOOK API

具体原因可以参考我们之前的这一篇文章一种Android应用耗电定位方案,同时这一篇文章也得到了Goolgle Android开发者Dtalk的一个推荐。

耗电维度监控API(仅列举方法名)
定位(持续定位)requestLocationUpdates removeUpdates
蓝牙(扫描)startScan stopScan
传感器registerListener unregisterListener
WakeLock(PowerWakeLock & WifiWakLock)acquire release
AlarmsetExact setExactAndAllowWhileIdle
其他辅助数据 (耗电变化,CPU等)广播/ /proc/self/stat

如何降低插桩成本

我们明确了大概需要插桩的API之后,还需要考虑一个事情,就是插桩成本以及维护成本,在监控API这一栏中,我们注意到,我只列出了一个方法名,实际上,真实调用到这些API的时候,会有很多重载的方法,比如requestLocationUpdates,就有超过5个以上。

作为监控方,我们想要监控调用requestLocationUpdates的所有参数

fun requestLocationUpdates(

    provider: String,

    minTime: Long,

    minDistance: Float,

    listener: LocationListener) 

比如这个requestLocationUpdates,我们想要获取它的provider等信息,这也就意味着,我们的插桩方案一定需要一个能力是参数捕获的能力,同时我们还想在线下的环境获取堆栈(线上的话有性能影响风险,因为每一个堆栈获取都会触发遍历),线上环境下获取到调用者的类名,因此插桩方案还需要一个功能是调用者类名插入的能力以及代码插入的能力。不仅如此,我们还要保证插件代码要足够少足够小,才能够减少维护成本。写过字节码插件,比如ASM插件的朋友会知道,要想实现它,可能会需要一个不少的工作量,同时我们需要hook的api还有很多,且各个方法都有重载。

实现插桩

那么,唯一的突破点就是,如何找到这些方法的共性,我们简单的以注册定位requestLocationUpdates 和 注册传感器的registerListener举例子,例子如下:

注册持续定位
manager.requestLocationUpdates(
    LocationManager.GPS_PROVIDER,
    10L,
    10f,
    locationListener
)
注册传感器
sensorManager.registerListener(sensorListener, sensor, 0)

它们分别会被编译为以下字节码

注册持续定位

  L7
    LINENUMBER 128 L7
    ALOAD 2
   L8
    LINENUMBER 129 L8
    LDC "gps"
   L9
    LINENUMBER 130 L9
    LDC 10
   L10
    LINENUMBER 131 L10
    LDC 10.0
   L11
    LINENUMBER 132 L11
    ALOAD 3
   L12
    LINENUMBER 128 L12
    INVOKEVIRTUAL android/location/LocationManager.requestLocationUpdates (Ljava/lang/String;JFLandroid/location/LocationListener;)V
    GOTO L13

注册传感器

    ALOAD 2
    ALOAD 3
    CHECKCAST android/hardware/SensorEventListener
    ALOAD 4
    ICONST_0
    INVOKEVIRTUAL android/hardware/SensorManager.registerListener (Landroid/hardware/SensorEventListener;Landroid/hardware/Sensor;I)Z
    POP

我们看一下上面的字节码,看一下能否发现什么共同点?我们发现,最后调用方法的时候,都是以INVOKEVIRTUAL指令结束,因为这是一个虚方法,需要在virtualtable找到实现方法(区别于INVOKESTATIC),同时虽然两个方法需要的参数不同,但是大概还是有一定的相似点可寻,因为它们在操作数栈的表现大致能用以下模型概括

image.png

我们发现,虽然操作数栈最底部的不一定是我们需要发起调用的类对象,但是在类对象之上的所有指令,其实都跟方法调用需要的参数有关,我们如果想要插桩,实现想要的参数捕获以及参数新增等功能,那么就不能破坏原本复杂的指令排序,而是在指令排序的基础上,通过新增的方法去修改操作数栈的参数排列,同时因为修改了操作数栈的原本指令,我们就不能再用INVOKEVIRTUAL指令发起调用,而是通过改写成INVOKESTATIC,新增一个类与一个静态方法,做到参数捕获以及发起正常调用,如下图:

image.png

这里实现的功能,其实就是每个我们需要监控的API中,多一个调用者类名的参数,同时也保证其正常发起调用,我们还是以SensorManager registerListener为例子。

变更前字节码如下:

    ....
    ALOAD 3
    CHECKCAST android/hardware/SensorEventListener
    ALOAD 4
    ICONST_0
    INVOKEVIRTUAL android/hardware/SensorManager.registerListener (Landroid/hardware/SensorEventListener;Landroid/hardware/Sensor;I)Z
    POP

变更后字节码如下:

    ....
    ALOAD 3
    ALOAD 4
    CHECKCAST android/bluetooth/le/ScanCallback
    LDC "com/example/batteryfinder/BatteryTestActivity" 调用者类名
    //BlueToothHook 是我们新增的Hook类
    INVOKESTATIC com/battery/api/bluetooth/BlueToothHook.startScan (Landroid/bluetooth/le/BluetoothLeScanner;Ljava/util/List;Landroid/bluetooth/le/ScanSettings;Landroid/bluetooth/le/ScanCallback;Ljava/lang/String;)V

我们通过LDC指令,把能够在编译时确定的调用者类名放入函数中,同时改写INVOKEVIRTUAL,为INVOKESTATIC,变成了我们一个自定义的静态方法调用。

通过这种方式,我们只需要做一个指令更改以及函数签名更改,就能完成非常通用的插桩处理,以下是ASM tree api代码

static public void replaceNode(MethodInsnNode node, ClassNode klass, MethodNode method, String owner) {
    LdcInsnNode ldc = new LdcInsnNode(klass.name);
    method.instructions.insertBefore(node, ldc);
    node.setOpcode(Opcodes.INVOKESTATIC);
    int anchorIndex = node.desc.indexOf(")");
    String subDesc = node.desc.substring(anchorIndex);
    String origin = node.desc.substring(1, anchorIndex);
    node.desc = "(L" + node.owner + ";" + origin + "Ljava/lang/String;" + subDesc;
    node.owner = owner;
    System.out.println("replaceNode result is " + node.desc);
}

每当我们有新增需要hook的api时,我们只需要调用replaceNode方法,就能完成一次较为通用的插桩处理,比如,完成registerListener与unregisterListener,我们只需要几行代码就完成,而不需要写复杂的插桩逻辑

void transformInvokeVirtual(MethodInsnNode node, ClassNode klass, MethodNode method) {
    if (!node.owner.equals("android/hardware/SensorManager")) {
        return;
    }
    // 处理 acquire 跟 release
    if ((node.name.equals("registerListener")) && registerListenerHookListDesc.contains(node.desc)) {
        HookHelper.replaceNode(node, klass, method, HOOKCLASS);
    }

    if ((node.name.equals("unregisterListener")) && unregisterListenerHookListDesc.contains(node.desc)) {
        HookHelper.replaceNode(node, klass, method, HOOKCLASS);
    }
}

遇到的坑

当然,低成本的背后,我们也遇到了一定的踩坑点,因为我们的插桩类,实际运行的Hook类,是一开始就生成好的,如果我们单单只用这一个耗电插件,其实是没问题的,但是我们有一个同类的插件,也hook了我们耗电插件的本身的Hook类,导致了一个死循环的产生,从而运行时会导致StackOverFlowError,而这个error好巧不巧被第三方catch了,因此我们没能及时在线下发现,从而ANR有一定的上涨。

为了应对这种问题,我们决定加入自检手段,即在每个Hook类中,加入了一段递归检测的代码,当这些不应该产生递归的代码发生递归调用时,我们将发送一个信号值为5的陷阱信号,从而在debug环境下引发native crash,触发进程死亡,从而不受到可能存在的trycatch导致异常无法及时被发现。

如何把耗电量化

耗电量化,其实我们可以通过耗电API持有的时长或者调用次数作为一种衡量方式,当然,为了更加模拟匹配线下真实的手机环境,我们也支持直接上传手机本身的power_profile文件作为基准耗电,通过耗电API持有时长* 耗电基础功耗得到耗电量。

power_profile 可以通过 获取手机本身的framework-res.apk获取,解析能在xml下获取到当前的功耗配置文件:

/system/framework/framework-res.apk

通过把功耗文件放入assets目录下,就能够得到基准耗电文件,能让我们计算更加准确。

解析的时候,可以根据自己需要的项目,逐步获取到基准功耗数值

fun parseXml(inputStream: InputStream?): BatteryPowerProfile? {
    if (inputStream == null) {
        Log.e(
            Constant.TAG,
            "inputStream is null,make sure power_profile.xml is in the asset!"
        )
        return null
    }
    val powerProfile: BatteryPowerProfile = BatteryPowerProfile()
    val parser = Xml.newPullParser()
    parser.setInput(inputStream, "utf-8")
    var type = parser.eventType
    while (type != XmlPullParser.END_DOCUMENT) {
        when (type) {
            XmlPullParser.START_TAG -> {
                if ("item" == parser.name) {
                    // 先拿属性后next
                    when (parser.getAttributeValue(null, "name")) {
                        "bluetooth.on" -> powerProfile.blueTooth = parser.nextText().toDouble()
                        "gps.on" -> powerProfile.gps = parser.nextText().toDouble()
                        "cpu.idle" -> powerProfile.wakeLockPower = parser.nextText().toDouble()
                        "battery.capacity" -> powerProfile.capacity =
                            parser.nextText().toDouble()
                        "wifi.on" -> powerProfile.wifiOn = parser.nextText().toDouble()
                        "screen.on" -> powerProfile.screenOnPower = parser.nextText().toDouble()

                    }
                }
            }
        }
        //继续往下读取标签类型
        type = parser.next()
    }
    return powerProfile
}

数据采集

当采集完我们需要的数据之后,我们还需要一种方式,用于告诉使用者,我们这里采用了SPI机制,通过定义好实现类,就能够在匹配一定的条件后,通过ServiceLoader把所需要的数据告诉上层使用者,同时我们这里预先定义了一些特征采集相关的数据。

这里比如WakeLockData的作用是用于获取当前wakelock的一些数据,比如是否采用了引用计数,是否自动释放了等等。

class WakeLockData : InvokeData(), BatteryConsumer, Serializable {
    // acquire 方法调用次数
    var acquireTime: Int = 0

    // 释放次数
    var releaseTime: Int = 0

    // 最终持有唤醒的时间
    var useTime: Long = 0L

    // 这里是每一个请求的时间
    var startHoldTime: Long = 0L

    // 最后一次请求时间
    var lastAcquireTime: Long = 0L

    // 这里是每一次释放的时间
    var endHoldTime: Long = 0L

    // 是否采用了引用计数
    var isRefCounted = true

    // 针对调用acquire(long timeout)却不调用release 的场景
    var autoReleaseByTimeOver: Long = 0L

    // 自动release 次数
    var autoReleaseTime: Int = 0

    var holdClassName: String = ""

    var releaseClassName: String = ""


    // WakeLock 是否已经被释放
    private fun isRelease(): Boolean {
        if (!isRefCounted) {
            // 非引用计数,一次删除即可,同时要确保最后记录时间>开始记录时间
            if (releaseTime > 0 && endHoldTime > lastAcquireTime) {
                return true
            }
        } else {
            if (acquireTime == releaseTime) {
                return true
            }
            // 如果acquire的次数 == releaseTime && 超时删除acquire已超时
            if ((acquireTime - autoReleaseTime) == releaseTime && SystemClock.uptimeMillis() - autoReleaseByTimeOver > 0) {
                return true
            }
        }
        return false
    }


    // 如果被释放了,那么实际使用时间就是useTime
    // 如果没有被释放,那么实际使用时间就是useTime + SystemClock.uptimeMillis() - startHoldTime
    override fun getRecordingBatteryConsume(): Double {
        if (state == InvokeState.Recorded) {
            return 0.0
        }
        val profile = BatteryFinderDataCenter.powerProfile ?: return 0.0
        if (!isRelease()) {
            val realUseTime = useTime + SystemClock.uptimeMillis() - startHoldTime
            return realUseTime * profile.wakeLockPower / (1000 * 60 * 60)
        }
        return 0.0
    }

    override fun getRecordedBatteryConsume(): Double {
        val profile = BatteryFinderDataCenter.powerProfile ?: return 0.0
        if (state == InvokeState.Recording) {
            return 0.0
        }
        if (isRelease()) {
            return useTime * profile.wakeLockPower / (1000 * 60 * 60)
        }
        return 0.0
    }


    override fun toString(): String {
        return "WakeLockData(acquireTime=$acquireTime, releaseTime=$releaseTime, useTime=$useTime, isRefCounted=$isRefCounted, autoReleaseByTimeOver=$autoReleaseByTimeOver, autoReleaseTime=$autoReleaseTime, holdClassName='$holdClassName', releaseClassName='$releaseClassName')"
    }

}

每一个数据都有一个状态,就是当前是否正在被持有,分为

记录中状态 :当前调用者正在持有,还未调用release相关的调用

记录完成状态:已经调用过release相关的调用,后续不再产生耗电。

每一项数据都会被保存在一个ConcurrentHashMap中,当满足一定的条件时通过spi机制回调给使用者。

class InvokeHashMap<T : Any, F : InvokeData>(
    val block: InvokeHashMap<T, F>.() -> Boolean,
    val dumper: InvokeHashMap<T, F>.(state: InvokeState, invokeData: F) -> Unit
) :
    ConcurrentHashMap<T, F>() {
    
    // 结束时dumper dump出数据,删除记录map中的数据
    fun updateRecord(key: T, value: F) {
        this[key] = value
        if(block()){
            dumper.invoke(this, InvokeState.Recording, value)
        }
    }

    fun deleteRecord(key: T, value: F) {
        this[key] = value
        if (block()){
            dumper.invoke(this, InvokeState.Recorded, value)
        }
        this.remove(key)
    }

}

如何长治

通过了上面的实现,我们有了一个能够针对耗电进行检测的工具,我们开发团队通过与测试团队一起结合,搭建了一个可视化的面板,这里能够提供给开发人员查看每个版本下,主流程运行过程中,可能会存在哪些耗电,同时也有相关的堆栈信息/调用者信息用于给开发人员排查。

我们通过线下主流程复测,线上全流程部分定点用户开启耗电检测的方式,从开发到上线,完成了一整套耗电检测方法,这样就再也不用再陷入用户反馈耗电而我们开发没有方法排查的困境中。

耗电优化案例

异常情况下长期持有唤醒锁

我们还是回到一开始提到的例子,我们发现了厂商有这类型的警告,

我们通过耗电检测工具,找到了问题所在,即某业务方会存在全局持有唤醒锁,而在一定条件下不释放。同时也有一个老的实验没有下线,会导致线上用户走到全生命周期持有锁的场景,从而引发高耗电。

通过治理这部分不合理wakelock使用,最终也没有再受到类似的反馈,同时我们也摆脱了“放一晚上,第二天就没电”的窘境。解决WakeLock问题能够不打扰手机进入Doze模式,更加符合使用者习惯以及系统的cpu调度策略。

异常传感器

通过线上耗电数据,找到多个特别长时间持有传感器的场景,经过开发人员查看路径,发现是一个很古老的历史代码问题。

最终我们得以发现问题,并及时修改了部分不合理的逻辑。

持续定位频次与provider问题

对于一些持续定位,我们通常有以下几个定位提供方式

GPS_PROVIDER:通过GPS来获取地理位置的经纬度信息,优点是获取地址位置信息准确度高,获取耗时耗电,功耗计算 = GPS功耗; NETWORK_PROVIDER:通过移动网络的基站或者WIFI来获取地理位置,优点是有网络就可以快速定位,功耗 = wifi_active; PASSIVE_PROVIDER:被动接受更新地理位置信息,不用自己请求定位,蹭定位结果,数据通过provider 产生 功耗 = 0;

通过检测,我们发现了部分并不需要高频定位的调用,但是采用了3s定位等场景,通过捕获监控数据,我们找到了修改方,让其降低了定位频率,同时如果不需要非常及时的数据,我们可以采用PASSIVE_PROVIDER的方式,不去主动发起定位而是获取上一次的已知定位,这样的话我们可以节省功耗消耗。

总结

以上是我们货拉拉司机团队关于Android应用耗电的治理经验总结。今天我们把这个工具开源,相信能够在社区的土壤下吸取更多的精华,补充更多更准确的维度监控,同时也希望能为面临Android耗电问题的开发者提供帮助。

引用文献

一种Android应用耗电定位方案 公众号


标签:之路不当宝宝安卓系统


程序开发学习排行
最近发表
网站分类
标签列表