阅读 622

Android高手笔记 - 耗电优化

耗电的背景知识

  1. 电池技术:电池容量,充电时间,寿命,安全性;
  2. 电量和硬件:应用程序不会直接去消耗电池,而是通过使用硬件模块消耗相应的电能;CPU、屏幕、WiFi 和数据网络、GPS 以及音视频通话都是我们日常的耗电大户。
  3. 电量和应用程序:电能 = 电压 * 电流 * 时间,手机电压一般不会改变,所以模块电量(mAh) = 模块电流(mA) * 模块耗时(h);
    • 模块电流应该怎样去获取呢?Android 系统要求不同的厂商必须在 /frameworks/base/core/res/res/xml/power_profile.xml 中提供组件的电源配置文件。
    • Android 系统的电量计算PowerProfile也是通过读取power_profile.xml的数值而已;
    (1). 不同的厂商具体的数值都不太一样,我们可以通过下面的方法获取:
    1. 从手机中导出/system/framework/framework-res.apk文件。
    2. 使用反编译工具(如 apktool)对导出文件framework-res.apk进行反编译。
    3. 查看power_profile.xml文件在framework-res反编译目录路径:/res/xml/power_profile.xml。
    (2). 系统的电量消耗情况,我们可以通过 dumpsys batterystats 导出:
    adb shell dumpsys batterystats > battery.txt
    // 各个Uid的总耗电量,而且是粗略的电量计算估计。
    Estimated power use (mAh):
        Capacity: 3450, Computed drain: 501, actual drain: 552-587
        ...
        Idle: 41.8
        Uid 0: 135 ( cpu=103 wake=31.5 wifi=0.346 )
        Uid u0a208: 17.8 ( cpu=17.7 wake=0.00460 wifi=0.0901 )
        Uid u0a65: 17.5 ( cpu=12.7 wake=4.11 wifi=0.436 gps=0.309 )
        ...
    // reset电量统计
    adb shell dumpsys batterystats --reset
    (3).当测试或者其他人反馈耗电问题时,bug report结合Battery Historian是最好的排查方法。
    //7.0和7.0以后
    $ adb bugreport bugreport.zip
    //6.0和6.0之前:
    $ adb bugreport > bugreport.txt
    //通过historian图形化展示结果
    python historian.py -a bugreport.txt > battery.html
    复制代码

Android 耗电的演进历程

  1. 野蛮生长:Pre Android 5.0
    • Android 5.0 之前,系统并不是那么完善,对于电量优化相对还是比较少的。特别没有对应用的后台做严格的限制,多进程、fork native 进程以及广播拉起等各种保活流行了起来。
  2. 逐步收紧:Android 5.0~Android 8.0
    • Android 5.0 专门开启了一个Volta 项目,目标是改善电池的续航。在优化电量的同时,还增加了的 dumpsys batteryst 等工具生成设备电池使用情况统计数据。
    (5.0: Volta项目,JobScheduler,dumpsys batterystats,BatteryHistorian,修复native fork进程保活的bug)
    • 从 Android 6.0 开始,Google 开始着手清理后台应用和广播来进一步优化省电。
    (6.0: 省电功能,Doze低功耗模式,AppStandby应用待机模式) (7.0: 优化省电功能,Doze加强版,implicit broadcasts限制,混合编译) (8.0: 更多优化省电功能,后台执行限制,后台位置限制)
  3. 最严限制:Android 9.0
    • 从 Android 9.0 开始,Google 对电源管理引入了几个更加严格的限制。
    (9.0: 应用待机分组AppStandbyBueckets,应用后台限制BackgroundRestrictions,省电模式BatterySaver)

耗电优化

什么是耗电优化

  • 所谓的耗电优化不就是减少应用的耗电,增加用户的续航时间吗?
  • 但是落到实践中,如果我们的应用需要播放视频、需要获取 GPS 信息、需要拍照,这些耗电看起来是无法避免的。

从哪些方面优化

1. 后台耗电:
  • 用户对于实际经常使用的应用耗电是有预期的,但是如果一个不常用的应用耗电耗却非常多就会很容易引起关注,所以电优化的第一个方向是优化应用的后台耗电;例如长时间获取 WakeLock、WiFi 和蓝牙的扫描等。
2. 符合系统的规则
  • Android P 是通过 Android Vitals 监控后台耗电,所以我们需要符合 Android Vitals 的规则
后台 Alarm 唤醒、后台网络、后台 WiFi 扫描以及部分长时间 WakeLock 阻止系统后台休眠:
1. Alarm Manager Wakeup唤醒过多:手机非充电状态时,每小时唤醒次数大于10次;
2. 频繁使用局部唤醒锁:手机非充电状态时,partial wake lock持有超时1小时;
3. 后台网络使用过高:手机非充电状态时,且应用在后台,每小时网络使用量超过50MB;
4. 后台wifi scans过多:手机非充电状态时,且应用在后台,每小时大于4次;
复制代码
耗电优化的几个问题
  1. 缺乏现场,无法复现;
  2. 信息不全,难以定位;
  3. 无法评估结果;
为什么需要在后台耗电
  1. 某个需求场景。最普遍的场景就是推送,为了实现推送我们只能做各种各样的保活。在需求面前,用户的价值可能被排到第二位。
  2. 代码的 Bug。因为某些逻辑考虑不周,可能导致 GPS 没有关闭、WakeLock 没有释放。

耗电优化的方法

  1. 找到需求场景的替代方案,后台任务的总体指导思想是减少、延迟和合并
    • 推送:
      1. 厂商通道;
      2. 定时拉取最新消息;
      3. foreground service或引导用户加入白名单;
    • 若需要后台运行:
      1. 长时间下载:DownloadManager
      2. 数据同步:SyncAdapter(尽可能的打包所有需要同步的任务在一个周期中执行)
      3. 本地任务:JobScheduler(minApi21,Google官方建议网络请求相关业务放到JobScheduler)
      /**
       * 开启 JobScheduler
       */
      private void startJobScheduler() {
          if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
              JobScheduler jobScheduler = (JobScheduler) getSystemService(Context.JOB_SCHEDULER_SERVICE);
              JobInfo.Builder builder = new JobInfo.Builder(1, new ComponentName(getPackageName(), JobSchedulerService.class.getName()));
              // 设置仅在 充电和WIFI 下才使用 JobScheduler 进行批量任务处理
              builder.setRequiresCharging(true)
                      .setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED);
              jobScheduler.schedule(builder.build());
          }
      }
      //JobSchedulerService 就是用于进行批量任务处理的服务
      /**
       * 用于进行批量任务处理的 JobSchedulerService
       */
      @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
      public class JobSchedulerService extends JobService {
      
          @Override
          public boolean onStartJob(JobParameters params) {
              // 此处执行在主线程
              // 模拟一些处理:批量网络请求,APM日志上报
              return false;
          }
      
          @Override
          public boolean onStopJob(JobParameters params) {
              return false;
          }
      }
      复制代码
      1. 特定时间执行:AlarmManager(持有Wake Lock,时间间隔|重复的任务,不建议网络请求使用)
      2. 实时通信:FCM、MiPush
      3. 立刻执行:foreground service
  2. 符合Android规则
    • 系统大部分耗电监控是在没充电时,所以我们可以在充电时才做一些耗电工作;
    IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
    Intent batteryStatus = context.registerReceiver(null, ifilter);
    //获取用户是否在充电的状态或者已经充满电了
    int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
    boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL;
    复制代码
    • 尽早适配最新的TargetAPI,版本越高,系统的限制约严格;
  3. 异常情况监控
    • 监控异常情况并且上报日志,便于定位线上问题;

耗电监控

  • 仿照Android Vitals指定自己的规则;
使用Java Hook实现耗电监控:
  1. WakeLock 用来阻止 CPU、屏幕甚至是键盘的休眠。类似 Alarm、JobService 也会申请 WakeLock 来完成后台 CPU 操作。
//WakeLock 的核心控制代码都在PowerManagerService中。
// 代理PowerManagerService
ProxyHook().proxyHook(context.getSystemService(Context.POWER_SERVICE), "mService", this);
@Override
public void beforeInvoke(Method method, Object[] args) {
    // 申请Wakelock
    if (method.getName().equals("acquireWakeLock")) {
        if (isAppBackground()) {
            // 应用后台逻辑,获取应用堆栈等等
         } else {
            // 应用前台逻辑,获取应用堆栈等等
         }
    // 释放Wakelock
    } else if (method.getName().equals("releaseWakeLock")) {
       // 释放的逻辑
    }
}
复制代码
  1. Alarm 用来做一些定时的重复任务,它一共有四个类型,其中ELAPSED_REALTIME_WAKEUP和RTC_WAKEUP类型都会唤醒设备。
//Alarm 的核心控制逻辑都在AlarmManagerService中
// 代理AlarmManagerService
new ProxyHook().proxyHook(context.getSystemService
(Context.ALARM_SERVICE), "mService", this);
public void beforeInvoke(Method method, Object[] args) {
    // 设置Alarm
    if (method.getName().equals("set")) {
        // 不同版本参数类型的适配,获取应用堆栈等等
    // 清除Alarm
    } else if (method.getName().equals("remove")) {
        // 清除的逻辑
    }
}
复制代码
  1. 其他
    • 对于后台 CPU,我们可以使用卡顿监控学到的方法。
    • 对于后台网络,同样我们可以通过网络监控学到的方法。
    • 对于 GPS 监控,我们可以通过 Hook 代理LOCATION_SERVICE。
    • 对于 Sensor,我们通过 Hook SENSOR_SERVICE中的“mSensorListeners”,可以拿到部分信息。
  2. 通过 Hook,我们可以在申请资源的时候将堆栈信息保存起来。当我们触发某个规则上报问题的时候,可以将收集到的堆栈信息、

电池是否充电、CPU 信息、应用前后台时间等辅助信息也一起带上。

通过插桩实现耗电监控
  • 虽然使用 Hook 非常简单,但是某些规则可能不太容易找到合适的 Hook 点。而且在 Android P 之后,很多的 Hook 点都不支持了。
  • 以WakeLock为例
public class WakelockMetrics {
    // Wakelock 申请
    public void acquire(PowerManager.WakeLock wakelock) {
        wakeLock.acquire();
        // 在这里增加Wakelock 申请监控逻辑
    }
    // Wakelock 释放
    public void release(PowerManager.WakeLock wakelock, int flags) {
        wakelock.release();
        // 在这里增加Wakelock 释放监控逻辑
    }
}
复制代码

电量检测方案

  1. 设置—耗电排行:直观,但没有详细数据,对解决问题帮助不大
  2. 使用广播监听电量变化—ACTION_BATTERY_CHANGED:价值不大:针对手机整体的耗电量,而非单个 App
  3. dumpsys batterystats:adb shell dumpsys batterystats > battery.txt
  4. Battery Historian:github.com/google/batt…

Gradle 耗电量统计插件中 BatteryCreateMethodVisitor 的核心实现代码

@Override
public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {
    // 监控 Wakelock
    String monitorClass = "com/ss/android/ugc/bytex/example/battery_monitor/WakelockMetrics";
    if (!monitorClass.equals(className)
            && "android/os/PowerManager$WakeLock".equals(owner)
            && opcode == Opcodes.INVOKEVIRTUAL
            && "acquire".equals(name)) {
        mv.visitMethodInsn(
                Opcodes.INVOKESTATIC,
                monitorClass,
                name,
                "(Landroid/os/PowerManager$WakeLock;J)V",
                isInterface
        );
        return;
    }
    if (!monitorClass.equals(className)
            && "android/os/PowerManager$WakeLock".equals(owner)
            && opcode == Opcodes.INVOKEVIRTUAL
            && "release".equals(name)) {
        mv.visitMethodInsn(
                Opcodes.INVOKESTATIC,
                monitorClass,
                name,
                "(Landroid/os/PowerManager$WakeLock;)V",
                isInterface
        );
        return;
    }
    // 监控 Gps
    monitorClass = "com/ss/android/ugc/bytex/example/battery_monitor/GpsMetrics";
    if (!monitorClass.equals(className)
            && "android/location/LocationManager".equals(owner)
            && opcode == Opcodes.INVOKEVIRTUAL
            && "requestLocationUpdates".equals(name)) {
        mv.visitMethodInsn(
                Opcodes.INVOKESTATIC,
                monitorClass,
                name,
                "(Landroid/location/LocationManager;Ljava/lang/String;JFLandroid/location/LocationListener;)V",
                isInterface
        );
        return;
    }
    if (!monitorClass.equals(className)
            && "android/location/LocationManager".equals(owner)
            && opcode == Opcodes.INVOKEVIRTUAL
            && "removeUpdates".equals(name)) {
        mv.visitMethodInsn(
                Opcodes.INVOKESTATIC,
                monitorClass,
                name,
                "(Landroid/location/LocationManager;Landroid/location/LocationListener;)V",
                isInterface
        );
        return;
    }
    // 监控 Alarm Service
    monitorClass = "com/ss/android/ugc/bytex/example/battery_monitor/AlarmMetrics";
    if (!monitorClass.equals(className)
            && "android/app/AlarmManager".equals(owner)
            && opcode == Opcodes.INVOKEVIRTUAL
            && "set".equals(name)) {
        mv.visitMethodInsn(
                Opcodes.INVOKESTATIC,
                monitorClass,
                name,
                "(Landroid/app/AlarmManager;IJLandroid/app/PendingIntent;)V",
                isInterface
        );
        return;
    }
    if (!monitorClass.equals(className)
            && "android/app/AlarmManager".equals(owner)
            && opcode == Opcodes.INVOKEVIRTUAL
            && "cancel".equals(name)) {
        mv.visitMethodInsn(
                Opcodes.INVOKESTATIC,
                monitorClass,
                name,
                "(Landroid/app/AlarmManager;Landroid/app/PendingIntent;)V",
                isInterface
        );
        return;
    }
    super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
}

复制代码

参考文章

我是今阳,如果想要进阶和了解更多的干货,欢迎关注微信公众号 “今阳说” 接收我的最新文章

文章分类
Android