原文地址:《Android功耗改进》 by 保罗的酒吧
最近几年中,Google在一直极力的改进Android系统的续航能力。在本文中,我们将看到Andrdoi自5.0到8.0这几个版本中对于功耗方面的改进。
前言
移动设备的续航时间无疑是所有用户都非常在意的。我们都希望自己的手机一次充电可以使用更长的时间。但遗憾的是,近几年移动设备的电池元件一直都没有重大的技术突破。并且,随着硬件性能的提升却带来了更多的电量消耗。
如果你对比过近几年的Android和iPhone手机,你就会发现:通常情况下,Android手机的电池要比同时期的iPhone电池容量大很多,但是待机方面却没有太大的优势。这显然是Android系统需要改进的地方。
在最近几年中,Google在一直极力的改进Android系统的续航能力。在本文中,我们将看到Andrdoi自5.0到8.0这几个版本中对于功耗方面的改进。
iOS之所以续航优秀,其很大的原因就在于对于后台进程的限制。在iOS上,后台进程是无法长时间处于活跃状态的。而Android系统正好相反,通过监听广播,添加后台服务等方式,应用程序可以一直在后台保持活跃。太多进程的长时间活跃,显然会导致电量的快速耗尽。
而反过来,想要延长电池寿命的重要措施就是尽可能减少后台应用的活跃性。后文中我们将看到,Android 5.0到8.0的功耗改进,一直都是围绕着“后台进程的活跃性”来展开的。
Project Volta
Project Volta是在Android 5.0(Lollipop)上引入的。
要延长电池的寿命,首先就得明确消耗电量的主要因素是什么。在移动设备上,对于电量消耗最大的是下面三个模块:
- 应用处理器(CPU,GPU)
- 电话信号
- 屏幕
除此之外,设备的频繁唤醒也会导致电量消耗过快。
Android的工程师发现,系统唤醒一秒钟所消耗的电量约等于两分钟系统待机所消耗的电量。
如何系统中安装了大量的应用,每个应用都在不同的时间点将系统唤醒(例如,通过BroadcastReciever或者Service),那无疑会导致电量很快耗尽。
反过来,假设系统能将应用唤醒系统的频度降低,尽可能将不同应用唤醒系统的步调合并和集中,便能够减少电量的消耗。
为了改善电池使用寿命,Project Volta提供的机制包含以下几个方面:
- 提供JobScheduler API
- 在虚拟机层面减少电池消耗
- 提供工具帮助开发者发现问题
- 提供省电模式给用户
下面我们来逐个讲解。
JobScheduler API
Android 5.0 新增了JobScheduler API,这个API允许开发者定义一些系统在稍后或指定条件下(如设备充电时)以异步方式运行的作业,从而优化电池寿命。下列情形下,这个功能很有用:
- 应用具有不面向用户并且可以推迟的作业
- 应用具有在设备插入电源时再进行的作业
- 应用具有一项需要接入网络或连接 WLAN 的任务。
- 应用具有多项希望定期以批处理方式运行的任务。
一个作业单位由一个JobInfo
对象封装。该对象指定计划排定标准。
使用 JobInfo.Builder
类可配置应如何运行已排计划的任务。开发者可以安排任务在特定条件下运行,例如:
- 在设备充电时启动
- 在设备连入无限流量网络时启动
- 在设备空闲时启动
- 在特定期限前或以最低延迟完成
API 说明
JobScheduler API 位于android.app.job 这个包中。这其中包含了如下几个类:
- JobInfo: 描述了一个提交给JobScheduler的Job,开发者通过JobInfo.Builder来构建JobInfo对象
- JobInfo.Builder: 构建JobInfo的Builder。这个类提供了一系列的set方法来设置Job的属性,最后通过
build
方法获取JobInfo - JobInfo.TriggerContentUri: 描述了一个Content URI,这个URI上的改动将触发Job的执行
- JobParameters: 包含了Job参数的类。JobService的
onStartJob
和onStopJob
回调函数的中都会的得到这个类的对象 - JobScheduler: 使用Job功能的服务类,这也是JobScheduler API的入口,提供了提交Job和删除Job的接口
- JobService: Job的入口,开发者通过继承这个类复写
onStartJob
和onStopJob
方法来实现Job逻辑,通过jobFinished
方法来告知系统该Job已经执行完毕。这个类是Service的子类 - JobServiceEngine API Level 26(Android 8.0)新增,Service实现的辅助类,用来与JobScheduler交互
- JobWorkItem API Level 26(Android 8.0)新增,可以通过JobScheduler.enqueue添加到队列的工作单元
JobSchedule API的执行流程如下图所示:
这个过程包含下面几个步骤:
- 应用通过
JobScheduler.schedule(JobInfo job)
向系统提交Job - 在预设的条件满足时,系统通过
JobService.onStartJob(JobParameters params)
通知应用程序开始执行任务 - 任务执行完成之后,由应用程序通过
JobService.jobFinished
通知系统任务执行完成 - 系统通过
JobService.onStopJob(JobParameters params)
通知应用任务结束
下面是一段简单的代码示例。
JobInfo uploadTask = new JobInfo.Builder(mJobId,
mServiceComponent)
.setRequiredNetworkCapabilities(JobInfo.NetworkType.UNMETERED)
.build();
JobScheduler jobScheduler =
(JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
jobScheduler.schedule(uploadTask);
这段代码中:
mServiceComponent
是开发者实现的JobService子类的对象,其中封装中应用需要执行的任务逻辑。setRequiredNetworkCapabilities(JobInfo.NetworkType.UNMETERED)
表示这个任务限制条件是只在非蜂窝网络下才会执行(例如Wifi)
开发者在使用JobInfo.Builder创建JobInfo的时候,通过其提供的API来设置Job需要满足的条件。在这里,可以同时设定多个条件,但必须至少指定一个条件,只有在条件满足的情况下,Job才可能会被执行。所有这些设定条件的方法,必须在build
方法调用之前设定。设定完成之后,调用build
方法获取最终构建出来的JobInfo(很显然,这是Builder设计模式的应用),然后提交给JobScheduler。
下面这行代码构建了一个Job,这个Job在有网络并且充电的情况下,每12个小时会执行一次。
JobInfo jobInfo = new JobInfo.Builder(1, componentName).setPeriodic(43200000)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY).setRequiresCharging(true).build();
下面是JobInfo.Builder提供的一些设定条件:
- 以执行的周期循环执行:
setPeriodic(long intervalMillis)
- 循环执行的间隔:
setPeriodic(long intervalMillis, long flexMillis)
- 执行的最大延迟:
setOverrideDeadline(long maxExecutionDelayMillis)
- 执行的最小延迟:
setMinimumLatency(long minLatencyMillis)
- 设备必须重新充电状态:
setRequiresCharging(boolean requiresCharging)
- 设备必须处于idle状态:
setRequiresDeviceIdle(boolean requiresDeviceIdle)
- 设备必须处于预期的网络连接状态(例如Wifi或者蜂窝):
setRequiredNetworkType (int networkType)
- 即便设备重启,Job也会执行:
setPersisted(boolean isPersisted)
注:随着API Level的升级,JobInfo.Builder中的接口可能会发生改变(例如:Android Level 26 - Android 8.0中增加了一些新的设置条件),因此建议读者在JobInfo.Builder API Reference 获取最新的API。
JobScheduler API功能实现在这个路径:
frameworks/base/services/core/java/com/android/server/job
这其中,JobScheduler的对应实现是JobSchedulerService
(两者通过Binder进行通讯),这个类是管理JobScheduler的系统服务。它位于system_server进程中,由SystemServer.java启动。
Job的提交
开发者通过JobScheduler.schedule
接口来提交任务。这个接口对应的是JobSchedulerService.schedule
,相关源码如下所示:
// JobSchedulerService.java
public int schedule(JobInfo job, int uId) {
return scheduleAsPackage(job, uId, null, -1, null); // ①
}
public int scheduleAsPackage(JobInfo job, int uId, String packageName, int userId,
String tag) {
JobStatus jobStatus = JobStatus.createFromJobInfo(job, uId, packageName, userId, tag); // ②
try {
if (ActivityManagerNative.getDefault().getAppStartMode(uId,
job.getService().getPackageName()) == ActivityManager.APP_START_MODE_DISABLED) { // ③
Slog.w(TAG, "Not scheduling job " + uId + ":" + job.toString()
+ " -- package not allowed to start");
return JobScheduler.RESULT_FAILURE;
}
} catch (RemoteException e) {
}
if (DEBUG) Slog.d(TAG, "SCHEDULE: " + jobStatus.toShortString());
JobStatus toCancel;
synchronized (mLock) {
// Jobs on behalf of others don't apply to the per-app job cap
if (ENFORCE_MAX_JOBS && packageName == null) {
if (mJobs.countJobsForUid(uId) > MAX_JOBS_PER_APP) { // ④
Slog.w(TAG, "Too many jobs for uid " + uId);
throw new IllegalStateException("Apps may not schedule more than "
+ MAX_JOBS_PER_APP + " distinct jobs");
}
}
toCancel = mJobs.getJobByUidAndJobId(uId, job.getId());
if (toCancel != null) {
cancelJobImpl(toCancel, jobStatus); // ⑤
}
startTrackingJob(jobStatus, toCancel); // ⑥
}
mHandler.obtainMessage(MSG_CHECK_JOB).sendToTarget(); // ⑦
return JobScheduler.RESULT_SUCCESS;
}
这段代码说明如下:
- 调用scheduleAsPackage方法,该方法中包含了提交Job的uid和packageName,这样便可以确认应用的身份
- 根据用户提交的JobInfo对象创建对应的JobStatus对象,后者是在系统服务中对于Job的描述对象
- 检查发起调用的应用程序是否已经被禁用
- 检查应用发送的Job数量是否已经达到上限
- 通过jobId确认是否已经有相同的Job,如果有则需要将之前提交的Job取消
- 真正开始跟踪这个Job,这个方法的实现我们接下来会看到
- 发送MSG_CHECK_JOB消息以检查是否有Job需要执行
在前文中我们看到,一个Job可以包含若干不同的执行条件。当条件满足时,Job会开始执行。这些条件在JobStatus中可以获取到:
// JobStatus.java
public boolean hasConnectivityConstraint() {
return (requiredConstraints&CONSTRAINT_CONNECTIVITY) != 0;
}
public boolean hasUnmeteredConstraint() {
return (requiredConstraints&CONSTRAINT_UNMETERED) != 0;
}
public boolean hasNotRoamingConstraint() {
return (requiredConstraints&CONSTRAINT_NOT_ROAMING) != 0;
}
public boolean hasChargingConstraint() {
return (requiredConstraints&CONSTRAINT_CHARGING) != 0;
}
public boolean hasTimingDelayConstraint() {
return (requiredConstraints&CONSTRAINT_TIMING_DELAY) != 0;
}
public boolean hasDeadlineConstraint() {
return (requiredConstraints&CONSTRAINT_DEADLINE) != 0;
}
public boolean hasIdleConstraint() {
return (requiredConstraints&CONSTRAINT_IDLE) != 0;
}
public boolean hasContentTriggerConstraint() {
return (requiredConstraints&CONSTRAINT_CONTENT_TRIGGER) != 0;
}
为了管理这些逻辑,在JobSchedulerService中,内置了多个StateController策略,不同的StateController对应了不同类型的匹配条件。StateController及其子类如下图所示:
这几个StateController说明如下:
类名 | 说明 |
---|---|
AppIdleController | 处理App Standby应用程序的Job,见后文App Standby |
BatteryController | 处理与电源相关的Job |
ConnectivityController | 处理与连接相关的Job |
ContentObserverController | 处理关于Content Uri变更相关的Job |
DeviceIdleJobsController | 处理与Doze状态相关的Job,关于Doze模式见后文 |
IdleController | 处理设备空闲状态相关的Job |
TimeController | 处理与时间相关的Job |
在startTrackingJob方法中,会将应用程序提交的Job提交给所有的StateController,由StateController根据策略决定Job的执行时机:
// JobSchedulerService.java
private void startTrackingJob(JobStatus jobStatus, JobStatus lastJob) {
synchronized (mLock) {
final boolean update = mJobs.add(jobStatus);
if (mReadyToRock) {
for (int i = 0; i < mControllers.size(); i++) {
StateController controller = mControllers.get(i);
if (update) {
controller.maybeStopTrackingJobLocked(jobStatus, null, true);
}
controller.maybeStartTrackingJobLocked(jobStatus, lastJob);
}
}
}
}
Job的执行
这里以TimeController为例,来看看包含了时间相关条件的Job是如何执行的。
TimeController中的maybeStartTrackingJobLocked接受Job的提交:
// TimeController.java
public void maybeStartTrackingJobLocked(JobStatus job, JobStatus lastJob) {
if (job.hasTimingDelayConstraint() || job.hasDeadlineConstraint()) { // ①
maybeStopTrackingJobLocked(job, null, false); // ②
boolean isInsert = false;
ListIterator<JobStatus> it = mTrackedJobs.listIterator(mTrackedJobs.size()); // ③
while (it.hasPrevious()) {
JobStatus ts = it.previous();
if (ts.getLatestRunTimeElapsed() < job.getLatestRunTimeElapsed()) { // ④
// Insert
isInsert = true;
break;
}
}
if (isInsert) {
it.next();
}
it.add(job);
maybeUpdateAlarmsLocked(
job.hasTimingDelayConstraint() ? job.getEarliestRunTime() : Long.MAX_VALUE,
job.hasDeadlineConstraint() ? job.getLatestRunTimeElapsed() : Long.MAX_VALUE,
job.getSourceUid()); // ⑤
}
}
private void maybeUpdateAlarmsLocked(long delayExpiredElapsed, long deadlineExpiredElapsed,
int uid) {
if (delayExpiredElapsed < mNextDelayExpiredElapsedMillis) {
setDelayExpiredAlarmLocked(delayExpiredElapsed, uid); // ⑥
}
if (deadlineExpiredElapsed < mNextJobExpiredElapsedMillis) {
setDeadlineExpiredAlarmLocked(deadlineExpiredElapsed, uid); // ⑦
}
}
这段代码说明如下:
- 确认Job包含了延迟或者定时两个条件中的任何一个(否则这个Job与时间无关)
- 检查是否是重新提交的Job
- 遍历mTrackedJobs,这个对象记录了所有的被跟踪的Job,并且按照截止时间排序
- 根据本次Job的相关信息确定排序位置然后添加到mTrackedJobs中
- 确定本次Job是否需要设置延迟闹钟和定时闹钟
- 设置延迟的闹钟
- 设置定时的闹钟
不同的StateController会依赖不同的机制完成任务的执行。例如:BatteryController依赖电池状态变化来执行任务,ConnectivityController依赖连接状态变化来执行任务。而对于时间相关的任务,TimeController会依赖AlarmManager来完成任务的执行。TimeController会根据Job中是否有延迟或者定时的条件来设定不同的监听器:
// TimeController.java
private void setDelayExpiredAlarmLocked(long alarmTimeElapsedMillis, int uid) {
alarmTimeElapsedMillis = maybeAdjustAlarmTime(alarmTimeElapsedMillis);
mNextDelayExpiredElapsedMillis = alarmTimeElapsedMillis;
updateAlarmWithListenerLocked(DELAY_TAG, mNextDelayExpiredListener,
mNextDelayExpiredElapsedMillis, uid);
}
private void setDeadlineExpiredAlarmLocked(long alarmTimeElapsedMillis, int uid) {
alarmTimeElapsedMillis = maybeAdjustAlarmTime(alarmTimeElapsedMillis);
mNextJobExpiredElapsedMillis = alarmTimeElapsedMillis;
updateAlarmWithListenerLocked(DEADLINE_TAG, mDeadlineExpiredListener,
mNextJobExpiredElapsedMillis, uid);
}
以延迟条件为例,当Job条件满足时,会通过mStateChangedListener.onRunJobNow来执行Job:
// TimeController.java
private void checkExpiredDeadlinesAndResetAlarm() {
synchronized (mLock) {
long nextExpiryTime = Long.MAX_VALUE;
int nextExpiryUid = 0;
final long nowElapsedMillis = SystemClock.elapsedRealtime();
Iterator<JobStatus> it = mTrackedJobs.iterator();
while (it.hasNext()) {
JobStatus job = it.next();
if (!job.hasDeadlineConstraint()) {
continue;
}
final long jobDeadline = job.getLatestRunTimeElapsed();
if (jobDeadline <= nowElapsedMillis) {
if (job.hasTimingDelayConstraint()) {
job.setTimingDelayConstraintSatisfied(true);
}
job.setDeadlineConstraintSatisfied(true);
mStateChangedListener.onRunJobNow(job);
it.remove();
} else { // Sorted by expiry time, so take the next one and stop.
nextExpiryTime = jobDeadline;
nextExpiryUid = job.getSourceUid();
break;
}
}
setDeadlineExpiredAlarmLocked(nextExpiryTime, nextExpiryUid);
}
}
这里的mStateChangedListener实际上就是JobSchedulerService。所有StateController只负责Job状态的控制,而真正的执行都是由JobSchedulerService完成的。
注:实际上,JobSchedulerService最终会借助
JobServiceContext
来执行Job,这部分逻辑建议读者自行尝试分析。
电量消耗分析工具
Batterystats 与 Battery Historian
为了帮助开发者分析系统的电池消耗,Android系统内置了Batterystats工具。我们可以通过下面命令来使用这个工具:
adb shell dumpsys batterystats
这个命令的输出内容非常的长,人工阅读比较困难。所以Google又提供了另外一个开源工具,这个工具将上一步的输出转换成图形的形式方便解读。这个工具称为:Battery Historian。我们可以在Github上获取这个工具及其源码,地址如下:google/battery-historian
通过这两个工具的组合,我们便可以得到一份图形化的电量信息的报表。
整个过程操作步骤如下:
- 从 https://github.com/google/battery-historian 下载工具
- 解压缩刚刚下载的压缩包,并找到historian.py这个脚本
- 将设备连接到电脑上
- 打开一个终端
- 通过
cd
命令切换到historian.py脚本所在路径 - 停止adb server:
adb kill-server
- 重启adb服务并通过
adb devices
确认设备已经连接上 - 重置电池使用历史数据:
adb shell dumpsys batterystats --reset
- 将设备与电脑断开连接
- 正常使用待测试的应用程序
- 重新将设备与电脑连接
- 通过
adb devices
确认设备已经连接成功 - 通过
adb shell dumpsys batterystats > batterystats.txt
将电池统计结果导出到文本文件中 - 通过
python historian.py batterystats.txt > batterystats.html
获取图形化结果 - 通过浏览器打开batterystats.html
- 对结果进行分析
下面是整个步骤的简述版本:
https://github.com/google/battery-historian
> adb kill-server
> adb devices
> adb shell dumpsys batterystats --reset
<disconnect and play with app>...<reconnect>
> adb devices
>adb shell dumpsys batterystats > batterystats.txt
> python historian.py batterystats.txt > batterystats.html
Battery Historian 可视化图
Battery Historian 可视化结果如下图所示:
这个图中显示了随时间变化的功率相关事件。
每一行显示一个彩色的条形段,条形段描述系统组件处于活动状态并且在消耗电量。该图表不显示组件使用了多少电量,而只描述应用程序处于活动状态。整个图表按类别进行组织。
类别
这个图中包含的几个主要类别说明如下(注:一次结果未必会包含下面所有的类别)
- battery_level :电池历史记录,以百分比的形式报告,093表示93%。这里体现了电池整体消耗的统计信息。
- top :这是在最前端运行的应用程序,通常是用户可见的。如果你想要统计你的应用在前台时的电池消耗,请将其放到最前端。反之,如果你想要统计应用在后台时的电池消耗,请将其他应用切换到前台。
- wifi_running :显示Wi-Fi网络连接处于活动状态。
- screen :屏幕已打开。
- phone_in_call :手机在通话过程中。
- wake_lock :应用程序醒来,获取Wakelock,做了一些琐碎的事情,然后又睡眠。这是很重要的一些信息,因为唤醒系统的代价是很大的,如果你看到很多个短条,那可能有问题的。
- running :表示CPU处于唤醒状态。请检查CPU的唤醒和休眠是否与你的预期一致。
- wake_reason :最后一个唤醒内核的原因。如果是由于你的应用,请确认是否是必要的。
- mobile_radio :显示无线电模块处于打开状态,打开无线电模块的代价是很大的。如果有很多的窄条,则意味着需要进行一些合并或者其他方面的优化。
- gps :描述GPS处于打开状态。请确认结果是与你的预期一致的。
- sync:显示应用程序处于同步状态中。这里会显示那个应用在做同步。对于用户来说,他们可能会关闭应用的同步来节省电量。对于开发者来说,应该尽可能减少同步的次数。
输出结果的筛选
可以从batterystats.txt文件中收集来自batterystats命令输出的其他信息。
通过文本编辑器打开batterystats.txt文件,搜索:
- Battery History :这里包含了电源相关事件的时间序列,例如屏幕,Wi-Fi和应用程序启动。这些内容也通过Battery Historian来看到。
- Per-PID Stats :每个进程运行时长
- Statistics since last charge :全系统的统计,例如单元格信号电平和屏幕亮度。这里提供了设备中发生事件的总体情况。这些信息特别有用,不过你要确保没有外部事件影响你的测试。
- UID和外围设备的Estimated power use mAhmAh :这是目前非常粗略的估计,不应该被视为实验数据。
- Per-app mobile ms per packet :无线电唤醒时间除以发送的数据包。高效的应用程序应该批量传输所有的流量,所以这个数字越少越好。
- All partial wake locks :所有应用程序持有的All partial wake locks ,总计持续时间和计数。
在虚拟机层面减少电池消耗
Android 5.0之前的版本,使用的虚拟机是Dalvik。在Android5.0上,正式启用了新的虚拟机 - ART。
Dalvik虚拟机上解释执行和JIT(Just-In-Time),是在应用程序每次运行过程中将Java字节码翻译成机器码,这个翻译过程可能是反复的,多次的。而ART上的AOT(Ahead-Of-Time)是在应用安装的时候,一次性直接将字节码编译成了机器码(虽然说ART后来的版本改进,没有一次性将所以代码编译成机器码,但总的来说,无论是安装时,还是后期运行时,只要有过一次编译成机器码,之后就不用重复翻译了)。
从字节码到机器码这个过程本身是非常消耗CPU的,因此也是非常耗电的。而ART虚拟机的引入和改进,由每次运行多次翻译改成一次编译,这无疑节省了CPU的执行,也节省了电量的消耗。
省电模式
Android 5.0上添加了一个新的省电模式给用户,用户可以通过系统设置主动打开省电模式,也可以设置电量过低时自动打开:
系统设置应用的源码位于这个路径:/packages/apps/Settings。
而省电模式界面的代码位于这里: src/com/android/settings/fuelgauge/BatterySaverSettings.java
在用户手动开关“省电模式”的时候,对应调用的是下面这个方法。
// BatterySaverSettings.java
private void trySetPowerSaveMode(boolean mode) {
if (!mPowerManager.setPowerSaveMode(mode)) {
if (DEBUG) Log.d(TAG, "Setting mode failed, fallback to current value");
mHandler.post(mUpdateSwitch);
}
// TODO: Remove once broadcast is in place.
ConditionManager.get(getContext()).getCondition(BatterySaverCondition.class).refreshState();
}
对于省电模式的逻辑,实际上是由PowerManagerService完成的。关于这部分内容,有兴趣的读者请自行查看PowerManagerService的实现,这里我们就不详细展开了。
JobScheduler API和电量分析工具都是提供给开发者的,因此这个机制对于电池寿命的效果,很大程度上在于开发者的层次和配合程度。
将系统某个方面的行为结果交给开发者的这种做法是有很大风险的,因为开发者很可能会不配合。所以,在Android 6.0 ~ 8.0之间,Android开始逐步加入一些强制手段来限制后台进程。在后面的内容我们将逐步讲解。
Doze模式 与 App StandBy
在上小节中我们提到,Project Volta主要是提供了一些API和工具给开发者,让开发者配合来改善电池寿命,所以这个机制的效果很难得到保证。从Android 6.0开始,系统包含了一些自动的省电行为,这些行为对于系统上的所有应用都会产生影响,不用开发者做特殊适配。
概述
从 Android 6.0(API 级别 23)开始,Android 引入了两个新的省电功能为用户延长电池寿命。
- Doze:该模式的运行机制是:系统会监测设备的活跃状态,如果设备长时间处于闲置状态且没有接入电源,那么便推迟应用的后台CPU和网络活动来减少电池消耗。
- App StandBy:该模式可推迟用户近期未与之交互的应用的后台网络活动。
Doze模式和App StandBy会影响到Android 6.0或更高版本上运行的所有应用,无论它们是否特别设置过API Level。
了解Doze模式
如果用户设备未接入电源、处于静止状态一段时间且屏幕关闭,设备便会进入Doze模式。 在Doze模式下,系统会尝试通过限制应用对网络和CPU密集型服务的访问来节省电量。
系统会定期退出Doze模式一会儿,好让应用完成其已推迟的活动。在此维护时段内,系统会运行所有待定同步、作业和闹铃并允许应用访问网络。下面描述了Doze状态变化下设备的活跃状态:
在每个维护时段结束后,系统会再次进入Doze模式,暂停网络访问并推迟作业、同步和闹铃。 随着时间的推移,系统安排维护时段的次数越来越少,这有助于在设备未连接至充电器的情况下长期处于不活动状态时降低电池消耗。
一旦用户通过移动设备、打开屏幕或连接到充电器唤醒设备,系统就会立即退出Doze模式,并且所有应用都将返回到正常活动状态。
Android 7.0的变更
Android 7.0 包括了旨在延长设备电池寿命和减少 RAM 使用的系统行为变更。这些变更可能会影响应用访问系统资源,以及应用通过特定隐式Intent与其他应用交互的方式。
Android 6.0(API Level 23)引入了Doze模式,当用户设备未插接电源、处于静止状态且屏幕关闭时,该模式会推迟 CPU 和网络活动,从而延长电池寿命。而 Android 7.0 则通过在设备未插接电源且屏幕关闭状态下、但不一定要处于静止状态(例如用户外出时把手持式设备装在口袋里)时应用部分 CPU 和网络限制,进一步增强了Doze模式。
当设备处于充电状态且屏幕已关闭一定时间后,设备会进入Doze模式并应用第一部分限制:关闭应用网络访问、推迟作业和同步。如果进入Doze模式后设备处于静止状态达到一定时间,系统则会对 PowerManager.WakeLock、AlarmManager 闹铃、GPS 和 WLAN 扫描应用余下的Doze模式限制。无论是应用部分还是全部Doze模式限制,系统都会唤醒设备以提供简短的维护时间窗口,在此窗口期间,应用程序可以访问网络并执行任何被推迟的作业/同步。
下图描述了Android 7.0上Doze模式的工作状态:
同样的,一旦激活屏幕或插接设备电源时,系统将退出Doze模式并移除这些处理限制。
Doze模式限制
在Doze模式下,应用会受到以下限制:
- 暂停访问网络。
- 系统将忽略 wake locks。
- 标准 AlarmManager 闹铃(包括 setExact 和 setWindow)推迟到下一维护时段。
- 如果您需要设置在Doze模式下触发的闹铃,请使用 setAndAllowWhileIdle 或 setExactAndAllowWhileIdle。
- 一般情况下,使用 setAlarmClock 设置的闹铃将继续触发 — 但系统会在这些闹铃触发之前不久退出Doze模式。
- 系统不执行 Wi-Fi 扫描。
- 系统不允许运行同步适配器。
- 系统不允许运行 JobScheduler。
将应用调整到Doze模式
Doze模式可能会对应用产生不同程度的影响,具体取决于应用提供的功能和使用的服务。许多应用无需修改即可在Doze模式周期中正常运行。 在某些情况下,开发者必须优化应用管理网络、闹铃、作业和同步的方式。应用应当有效的管理维护窗口内的活动。
Doze模式会对AlarmManager的闹铃和定时器产生较大的影响,因为当系统处于Doze模式时,不会触发 Android 5.1(API 级别 22)或更低版本中的闹铃。
为了帮助您安排闹铃,Android 6.0(API 级别 23)引入了两种新的 AlarmManager 方法:setAndAllowWhileIdle 和 setExactAndAllowWhileIdle。通过这些方法,开发者可以设置即使设备处于Doze模式也会触发的闹铃。
注:对于任何一个应用,setAndAllowWhileIdle 和 setExactAndAllowWhileIdle 触发闹铃的频率都不能超过每9分钟一次。
Doze模式对网络访问的限制也有可能影响应用,特别是当应用依赖于tickle或通知等实时消息时更是如此。如果应用需要持久连接到网络来接收消息,Google建议尽量使用Firebase Cloud Messaging。
要确认应用在Doze模式下按照预期运行,您可以使用 adb 命令强制系统进入和退出Doze模式并观察应用的行为。
了解App StandBy
App StandBy允许系统判定应用在用户未主动使用它时使其处于空闲状态。当用户有一段时间未触摸应用时,系统便会作出此判定。但是对于以下情况,系统将判定应用退出App StandBy状态,这包括:
- 用户显式启动应用。
- 应用有一个前台进程(例如Activity或前台服务,或被另一个Activity或前台服务使用)。
- 应用生成用户可在锁屏或通知栏中看到的通知。
当用户将设备插入电源时,系统将从App StandBy状态释放应用,从而让它们可以自由访问网络并执行任何待定作业和同步。如果设备长时间处于空闲状态,系统将按每天大约一次的频率允许该应用访问网络。
对其他用例的支持
通过妥善管理网络连接、闹钟、作业和同步并使用Firebase Cloud Messaging高优先级消息,几乎所有应用都应该能够支持Doze模式。对于一小部分用例,这可能还不够。对于此类用例,系统为部分免除Doze模式和App StandBy优化的应用提供了一份可配置的白名单。
在Doze模式和App StandBy期间,加入白名单的应用可以使用网络并保留部分 wake locks。 不过,正如其他应用一样,其他限制仍然适用于加入白名单的应用。例如,加入白名单的应用的作业和同步将推迟(在 API 级别 23 及更低级别中),并且其常规 AlarmManager 闹铃不会触发。通过调用 isIgnoringBatteryOptimizations,应用可以检查自身当前是否位于豁免白名单中。
用户可以在 Settings > Battery > Battery Optimization
中手动配置该白名单。
另外,系统也为应用提供了编程接口来请求让用户将其加入白名单。
- 应用可以触发
ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS
Intent,让用户直接进入 电池优化界面,他们可以在其中添加应用。 - 具有
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
权限的应用可以触发系统对话框,让用户无需转到“设置”即可直接将应用添加到白名单。应用将通过触发ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
Intent 来触发该对话框。 - 用户可以根据需要手动从白名单中移除应用。
系统设置中的界面如下图所示:
在Doze模式和App StandBy下进行测试
为了确保用户获得极佳体验,开发者应在Doze模式和App StandBy下全面测试应用的行为。
在Doze模式下测试应用
可按以下步骤测试Doze模式:
- 使用 Android 6.0(API 级别 23)或更高版本的系统映像配置硬件设备或虚拟设备。
- 将设备连接到开发计算机并安装应用
- 运行应用并使其保持活动状态
- 关闭设备屏幕。(应用保持活动状态。)
-
通过运行以下命令强制系统在Doze模式之间循环切换:
$ adb shell dumpsys battery unplug $ adb shell dumpsys deviceidle step
- 您可能需要多次运行第二个命令。不断地重复,直到设备变为空闲状态。
- 在重新激活设备后观察应用的行为。确保应用在设备退出Doze模式时正常恢复。
注意:
- 第一条命令是强制卸下电池,冻结电池状态,因为没有接入电源是进入Doze模式的基本前提
- 执行上面的测试命令时,需要保持屏幕关闭,因为这也是进入Doze模式的基本前提
执行这项测试时,我们的交互通常是下面这样:
angler:/ $ dumpsys battery unplug
angler:/ $ dumpsys deviceidle step
Stepped to deep: IDLE_PENDING
angler:/ $ dumpsys deviceidle step
Stepped to deep: SENSING
angler:/ $ dumpsys deviceidle step
Stepped to deep: LOCATING
angler:/ $ dumpsys deviceidle step
Stepped to deep: IDLE
angler:/ $ dumpsys deviceidle step
Stepped to deep: IDLE_MAINTENANCE
angler:/ $ dumpsys deviceidle step
Stepped to deep: IDLE
angler:/ $ dumpsys deviceidle step
Stepped to deep: IDLE_MAINTENANCE
angler:/ $ dumpsys deviceidle step
Stepped to deep: IDLE
这里我们看到,反复执行dumpsys deviceidle step
设备会在下面几个状态上切换:
- IDLE_PENDING
- SENSING
- LOCATING
- IDLE
- IDLE_MAINTENANCE
在下文讲解Doze模式功能实现的时候,我们就能理解这里的含义了。
在App StandBy下测试应用
要在App StandBy下测试应用,请执行以下操作:
- 使用 Android 6.0(API 级别 23)或更高版本的系统
- 将设备连接到开发计算机并安装应用
- 运行应用并使其保持活动状态
-
通过运行以下命令强制应用进入App StandBy:
$ adb shell dumpsys battery unplug $ adb shell am set-inactive <packageName> true
-
使用以下命令模拟唤醒应用:
$ adb shell am set-inactive <packageName> false $ adb shell am get-inactive <packageName>
- 观察唤醒后的应用行为。确保应用从待机模式中正常恢复。特别地,应检查应用的通知和后台作业是否按预期继续运行
Doze模式的实现
在对Doze模式有了上面的了解之后,下面我们来Doze模式是如何实现的。
Doze模式由DeviceIdleController
这个类实现。该模块也是一个系统服务,因此其源码位于下面这个目录:
frameworks/base/services/core/java/com/android/server/
和其他的系统服务一样,该系统服务位于system_server进程中,由SystemServer在startOtherServices
阶段启动。该类覆写了SystemService的onStart()
和onBootPhase(int phase)
方法(这部分内容在第2章中我们已经讲解过)以完成初始化。
onStart()
方法的主要逻辑是读取配置文件中配置的节电模式白名单列表并将自身服务发布到Binder上以便接收请求。在onBootPhase(int phase)
中逻辑是在PHASE_SYSTEM_SERVICES_READY
阶段进行处理,主要是获取DeviceIdleController
依赖的其他系统服务并注册一些广播接收器。
前面我们已经看到,Doze模式进入条件是:屏幕关闭,没有插入电源,且处于静止状态。为了知道这些信息,DeviceIdleController在启动的时候,设置了对应的BroadcastReceiver来监测这些状态的变化。DeviceIdleController#onBootPhase
方法中相关代码如下:
// DeviceIdleController.java
IntentFilter filter = new IntentFilter();
filter.addAction(Intent.ACTION_BATTERY_CHANGED);
getContext().registerReceiver(mReceiver, filter); // ①
filter = new IntentFilter();
filter.addAction(Intent.ACTION_PACKAGE_REMOVED);
filter.addDataScheme("package");
getContext().registerReceiver(mReceiver, filter); // ②
filter = new IntentFilter();
filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
getContext().registerReceiver(mReceiver, filter); // ③
mDisplayManager.registerDisplayListener(mDisplayListener, null); // ④
这段代码中:
- 注册了一个电池状态变化的广播接收器以便在电池状态变化的时候进行处理。例如:检测到插入电源则退出Doze模式
- 注册了应用包卸载的事件广播接收器以处理节电模式的白名单
- 注册了连接状态变化的广播接收器
- 注册了屏幕状态变化的监听器
为了实现Doze模式,DeviceIdleController以状态机的形式来实现这个功能。状态机的包括下面几种状态:
状态 | 说明 |
---|---|
ACTIVE | 活跃状态,这就是正常设备被使用中所处的状态 |
INACTIVE | 设备处于非活跃状态(屏幕已关闭,且没有运动),等待进入IDLE状态 |
IDLE_PENDING | 设备经过了初始化非活跃时期,等待进入下一次IDLE周期 |
SENSING | 传感器运转中 |
LOCATING | 设备正在定位中,传感器也可能在运作中 |
IDLE | 设备进入了Doze模式 |
IDLE_MAINTENANCE | 设备处于Doze模式下的维护窗口状态中 |
当设备刚启动时,最初会进入ACTIVE状态。
进入Doze模式的基本条件之一是屏幕关闭,因此在屏幕状态变化的监听器中,会判断如果屏幕关闭了,则考虑进入INACTIVE状态(调用becomeInactiveIfAppropriateLocked
)方法,下面代码如下:
// DeviceIdleController.java
void updateDisplayLocked() {
mCurDisplay = mDisplayManager.getDisplay(Display.DEFAULT_DISPLAY);
boolean screenOn = mCurDisplay.getState() == Display.STATE_ON;
if (DEBUG) Slog.d(TAG, "updateDisplayLocked: screenOn=" + screenOn);
if (!screenOn && mScreenOn) {
mScreenOn = false;
if (!mForceIdle) {
becomeInactiveIfAppropriateLocked();
}
} else if (screenOn) {
mScreenOn = true;
if (!mForceIdle) {
becomeActiveLocked("screen", Process.myUid());
}
}
}
这里的 if (!screenOn && mScreenOn)
即表示屏幕由打开进入到了关闭的状态。
在becomeInactiveIfAppropriateLocked
方法中,会将状态设置为INACTIVE,然后调用stepIdleStateLocked
方法。stepIdleStateLocked
是DeviceIdleController中的核心的方法,因为正是这个方法实现了状态机的状态切换。在设备处于静止状态下时,该方法会逐步将系统调整到IDLE状态(Doze模式生效),这个方法中促成的状态变化如下图所示:
在状态的变化过程中,DeviceIdleController
会通过AnyMotionDetector
来检测设备是处于静止状态还是运行状态。AnyMotionDetector
的功能正如其名称所示,这个类可以检测任何的运动动作,它的功能实现主要是依赖于加速度传感器。
AnyMotionDetector
通过下面这个接口回调来告知检测结果:
// AnyMotionDetector.java
interface DeviceIdleCallback {
public void onAnyMotionResult(int result);
}
这个回调结果有三个可能的取值:
- AnyMotionDetector.RESULT_UNKNOWN:由于方向测量的信息不全,状态未知
- AnyMotionDetector.RESULT_STATIONARY:设备处于静止状态
- AnyMotionDetector.RESULT_MOVED:设备处于运动状态
为了实现运动状态的监测,DeviceIdleController
自身就实现了DeviceIdleCallback
接口,其回调处理逻辑如下:
// DeviceIdleController.java
@Override
public void onAnyMotionResult(int result) {
if (DEBUG) Slog.d(TAG, "onAnyMotionResult(" + result + ")");
if (result != AnyMotionDetector.RESULT_UNKNOWN) {
synchronized (this) {
cancelSensingTimeoutAlarmLocked();
}
}
if ((result == AnyMotionDetector.RESULT_MOVED) || // ①
(result == AnyMotionDetector.RESULT_UNKNOWN)) {
synchronized (this) {
handleMotionDetectedLocked(mConstants.INACTIVE_TIMEOUT, "non_stationary"); // ②
}
} else if (result == AnyMotionDetector.RESULT_STATIONARY) { // ③
if (mState == STATE_SENSING) { // ④
// If we are currently sensing, it is time to move to locating.
synchronized (this) {
mNotMoving = true;
stepIdleStateLocked("s:stationary");
}
} else if (mState == STATE_LOCATING) { // ⑤
// If we are currently locating, note that we are not moving and step
// if we have located the position.
synchronized (this) {
mNotMoving = true;
if (mLocated) {
stepIdleStateLocked("s:stationary");
}
}
}
}
}
在这段代码中:
- 假设检测到设备处于移动状态
- 则通过
handleMotionDetectedLocked
将设备置为ACTIVE状态 - 假设设备已经处于静态状态(RESULT_STATIONARY),则通过
stepIdleStateLocked
方法将状态往前推进 - 如果当前是SENSING状态,则会进入LOCATING状态
- 如果当前是LOCATING状态,则会进入IDLE状态,因为LOCATING是IDEL前的最后一个状态
请注意,很多时候设备未必能成功进入Doze模式,例如:用户将设备接上了电源,点亮了屏幕,或者通过命令行强制关闭了Doze模式,这些情况下都会调用becomeActiveLocked
将设备置回ACTIVE状态,becomeActiveLocked
被调用的时机如下图所示:
当stepIdleStateLocked
真正进入到IDLE状态之后,便会发送一条MSG_REPORT_IDLE_ON
消息,这表示设备将要进入Doze模式了。在这条消息的处理中,会通知PowerManager和NetworkPolicManager进入Idle状态,以表示Doze模式打开了,相关代码如下:
// DeviceIdleController.java
case MSG_REPORT_IDLE_ON:
case MSG_REPORT_IDLE_ON_LIGHT: {
EventLogTags.writeDeviceIdleOnStart();
final boolean deepChanged;
final boolean lightChanged;
if (msg.what == MSG_REPORT_IDLE_ON) { // ①
deepChanged = mLocalPowerManager.setDeviceIdleMode(true); // ②
lightChanged = mLocalPowerManager.setLightDeviceIdleMode(false);
} else {
deepChanged = mLocalPowerManager.setDeviceIdleMode(false);
lightChanged = mLocalPowerManager.setLightDeviceIdleMode(true);
}
try {
mNetworkPolicyManager.setDeviceIdleMode(true);
mBatteryStats.noteDeviceIdleMode(msg.what == MSG_REPORT_IDLE_ON
? BatteryStats.DEVICE_IDLE_MODE_DEEP
: BatteryStats.DEVICE_IDLE_MODE_LIGHT, null, Process.myUid()); // ③
} catch (RemoteException e) {
}
if (deepChanged) {
getContext().sendBroadcastAsUser(mIdleIntent, UserHandle.ALL); // ④
}
if (lightChanged) {
getContext().sendBroadcastAsUser(mLightIdleIntent, UserHandle.ALL);
}
EventLogTags.writeDeviceIdleOnComplete();
} break;
这段代码说明如下:
- 如果是Doze模式打开
- 通知PowerManager进入Idle状态
- 通知NetworkPolicyManager和BatteryStats服务进入Idle状态
- 发送全局广播通知所有感兴趣的模块系统已经进入Doze模式。
App StandBy的实现
App StandBy允许系统判定应用在用户未主动使用它时使其处于空闲状态,因此这个功能的实现需要依赖于应用程序被使用的历史数据。
系统会将这些数据记录在物理文件中,其路径是/data/system/usagestats/
,每个用户(关于“多用户”见下一章)会按不同的用户Id分成不同的文件夹。这些文件是XML格式的,并且会按照年,月,周,日分开记录。例如,对于系统的默认用户(设备拥有者)会看到下面这些存放数据文件的目录:
/data/system/usagestats/0 # ls -l
total 20
drwx------ 2 system system 4096 2017-12-09 14:52 daily
drwx------ 2 system system 4096 2017-12-09 14:52 monthly
-rw------- 1 system system 20 2017-10-13 22:41 version
drwx------ 2 system system 4096 2017-12-09 14:52 weekly
drwx------ 2 system system 4096 2017-12-09 14:52 yearly
App StandBy功能的实现源码位于下面这个目录:
/frameworks/base/services/usage/java/com/android/server/usage/
这个目录下的主要类说明如下:
类名 | 说明 |
---|---|
AppIdelHistory | 跟踪最近在应用程序中发生的活动状态更改 |
StorageStatsService | 查询设备存储状态的服务 |
UsageStatsDatabase | 提供从XML数据库中查询UsageStat数据的接口 |
UsageStatsXml | 专门负责读写XML数据文件的类 |
UsageStatsService | 系统服务,用来收集,统计和存储应用的使用数据,其中包含了多个UserUsageStatsService |
UserUsageStatsService | 用户使用情况的数据统计服务,每个用户有一个独立的UserUsageStatsService |
对于App StandBy功能来说,UsageStatsService
是这个功能的核心,这也是一个位于SystemServer中的系统服务,它在SystemServer.startCoreServices
中启动。
为了知道用户和应用程序的信息,UsageStatsService
在启动的时候会注册与用户和应用包相关的一些广播事件监听器,包括:
事件 | 说明 |
---|---|
Intent.ACTION_USER_STARTED | 用户被启动了 |
Intent.ACTION_USER_REMOVED | 用户被删除了 |
Intent.ACTION_PACKAGE_ADDED | 添加了一个新的应用包 |
Intent.ACTION_PACKAGE_CHANGED | 应用包发生了变化 |
Intent.ACTION_PACKAGE_REMOVED | 应用包被删除了 |
每当有一个新的用户启动了,UsageStatsService
中都会启动一个定时任务来检查该用户是否有处于Idle状态的应用程序。这个定时任务通过Handler.sendMessageDelayed
实现。相关代码如下:
// UsageStatsService.java
case MSG_CHECK_IDLE_STATES: // ①
if (checkIdleStates(msg.arg1)) { // ②
mHandler.sendMessageDelayed(mHandler.obtainMessage(
MSG_CHECK_IDLE_STATES, msg.arg1, 0),
mCheckIdleIntervalMillis); // ③
}
break;
这个代码片段说明如下:
- 每当一个用户启动的时候,UsageStatsService就会发一条
MSG_CHECK_IDLE_STATES
异步消息给自己(这段代码我们省略了) - 在这个消息的处理中,先通过
checkIdleStates
检查应用的空闲状态 - 如果有必要,在延迟
mCheckIdleIntervalMillis
时间之后,再发送一条消息给自己
这样便达到了为每个用户定时检查的目的。
这里延迟的时长是从系统全局的设置中读取的,相关代码如下:
// UsageStatsService.java
void updateSettings() {
synchronized (mAppIdleLock) {
// Look at global settings for this.
// TODO: Maybe apply different thresholds for different users.
try {
mParser.setString(Settings.Global.getString(getContext().getContentResolver(),
Settings.Global.APP_IDLE_CONSTANTS));
} catch (IllegalArgumentException e) {
Slog.e(TAG, "Bad value for app idle settings: " + e.getMessage());
// fallthrough, mParser is empty and all defaults will be returned.
}
// Default: 12 hours of screen-on time sans dream-time
mAppIdleScreenThresholdMillis = mParser.getLong(KEY_IDLE_DURATION,
COMPRESS_TIME ? ONE_MINUTE * 4 : 12 * 60 * ONE_MINUTE);
mAppIdleWallclockThresholdMillis = mParser.getLong(KEY_WALLCLOCK_THRESHOLD,
COMPRESS_TIME ? ONE_MINUTE * 8 : 2L * 24 * 60 * ONE_MINUTE); // 2 days
mCheckIdleIntervalMillis = Math.min(mAppIdleScreenThresholdMillis / 4,
COMPRESS_TIME ? ONE_MINUTE : 8 * 60 * ONE_MINUTE); // 8 hours
// Default: 24 hours between paroles
mAppIdleParoleIntervalMillis = mParser.getLong(KEY_PAROLE_INTERVAL,
COMPRESS_TIME ? ONE_MINUTE * 10 : 24 * 60 * ONE_MINUTE);
mAppIdleParoleDurationMillis = mParser.getLong(KEY_PAROLE_DURATION,
COMPRESS_TIME ? ONE_MINUTE : 10 * ONE_MINUTE); // 10 minutes
mAppIdleHistory.setThresholds(mAppIdleWallclockThresholdMillis,
mAppIdleScreenThresholdMillis);
}
}
当UsageStatsService
检测到App处于空闲状态,便会通知所有的AppIdleStateChangeListener
这个事件:
// UsageStatsService.java
void informListeners(String packageName, int userId, boolean isIdle) {
for (AppIdleStateChangeListener listener : mPackageAccessListeners) {
listener.onAppIdleStateChanged(packageName, userId, isIdle);
}
}
从AppIdleStateChangeListener
这个接口的名称上我们就知道,这是一个用来获取应用空闲状态变化的监听器。Framework中有两个类实现了这个接口,它们是下面两个内部类:
- AppIdleController.AppIdleStateChangeListener:AppIdleController在讲解Project Volta的时候我们已经提到过。
- NetworkPolicyManagerService.AppIdleStateChangeListener:从名称就知道,NetworkPolicyManagerService是负责网络策略的系统服务。
在将应用状态通知到这两个内部类之后,相应的系统服务便可以根据这些信息进行应用的活动限制。这部分的逻辑就完全在这两个系统服务中。关于这部分内容就不深入了,读者可以自行研究。
下图描述了这里的执行逻辑:
Android 8.0上的后台限制
前面两个小节我们看到,Android 6.0和7.0两个版本提供了Project Volta,Doze模式以及App StandBy机制来降低功耗以延长电池寿命。
但实际上Android系统上最令人诟病的“后台问题”仍然没有得以解决:应用程序很容易通过监听各种广播的方式来启动后台服务,然后长时间在后台保持活跃。这样做无疑会导致电池电量很快耗尽。Android系统的用户对此应该深有体会,通过系统设置中的运行中应用列表,总能看到一大串的服务在后台运行着。
在Android 8.0版本上,Google官方终于正式将后台限制作为改进的第一要点,以此来提升系统的待机时间。
后台限制主要就是针对BroadcastReceiver和Service。这是应用程序的基本组件,并且它们是自Android最初版本就提供的功能。到8.0版本才决定要对这些基础组件的行为做变更是一件很危险的事情,因为这种变更可能会对应用的兼容性造成影响,即:造成某些应用程序在新版本系统上无法正常工作。
所以,对于系统设计者来说,在考虑系统行为变更的时候,既要考虑系统机制的改进,又要同时兼顾到应用兼容性的问题,不能出现大规模的衰退,否则对整个系统生态是一个非常危险的事情。
Android是一个多任务的操作系统。例如,用户可以在一个窗口中玩游戏,同时在另一个窗口中浏览网页,并使用第三个应用播放音乐。
同时运行的应用越多,对系统造成的负担越大。如果还有应用或服务在后台运行,这会对系统造成更大负担,进而可能导致用户体验下降;例如,音乐应用可能会突然关闭。
为了降低发生这些问题的几率,Android 8.0对应用在用户不与其直接交互时可以执行的操作施加了限制。
应用在两个方面受到限制:
- 后台服务限制:处于空闲状态时,应用可以使用的后台服务存在限制。但这些限制不实施于前台服务,因为前台服务更容易引起用户注意。
- 广播限制:除了有限的例外情况,应用无法使用
AndroidManifest.xml
注册隐式广播。但它们仍然可以在运行时注册这些广播,并且可以使用AndroidManifest.xml
注册专门针对它们的显式广播。
注:默认情况下,这些限制仅适用于针对8.0的应用。不过,用户可以从 Settings 屏幕为任意应用启用这些限制,即使应用并不是以8.0为目标平台。
后台服务限制
在后台中运行的服务会消耗设备资源,这可能会降低用户体验。 为了缓解这一问题,系统对这些服务施加了一些限制。
系统会区分前台和后台应用。(用于服务限制目的的后台定义与内存管理使用的定义不同:一个应用按照内存管理的定义可能处于后台,但按照能够启动服务的定义可能又处于前台。)如果满足以下条件的任意一个,应用都将被视为处于前台:
- 具有可见Activity,不管该Activity处于resume还是pause状态。
- 具有前台服务。
- 另一个前台应用关联到当前应用,可能是绑定到其中一个Service,或者是使用其中一个ContentProvider。
如果以上条件均不满足,则应用将被视为处于后台。
处于前台时,应用可以自由创建和运行前台服务与后台服务。进入后台时,在一个持续数分钟的时间窗内,应用仍可以创建和使用服务。
在该时间窗结束后,应用将被视为处于空闲状态。 此时,系统将停止应用的后台服务,就像应用已经调用服务的Service.stopSelf()
方法。
在下面这些情况下,后台应用将被置于一个临时白名单中并持续数分钟。位于白名单中时,应用可以无限制地启动服务,并且其后台服务也可以运行。
处理对用户可见的任务时,应用将被置于白名单中,例如:
- 处理一条高优先级
Firebase
云消息传递 FCMFCM 消息。 - 接收广播,例如短信/彩信消息。
- 从通知执行
PendingIntent
。
在很多情况下,应用都可以使用 JobScheduler
来替换后台服务。
例如,某个应用需要检查用户是否已经从朋友那里收到共享的照片,即使该应用未在前台运行。之前,应用使用一种会检查其云存储的后台服务。 为了迁移到 Android 8.0,开发者可以使用一个计划作业替换了这种后台服务,该作业将按一定周期启动,查询服务器,然后退出。
在 Android 8.0 之前,创建前台服务的方式通常是先创建一个后台服务,然后将该服务推到前台。
Android 8.0 有一项复杂功能;系统不允许后台应用创建后台服务。 因此,Android 8.0 引入了一种全新的方法,即 Context.startForegroundService()
,以在前台启动新服务。
在系统创建服务后,应用有五秒的时间来调用该服务的 startForeground()
方法以显示新服务的用户可见通知。
如果应用在此时间限制内未调用 startForeground()
,则系统将停止服务并声明此应用为 ANR。
广播限制
如果应用注册为接收广播,则在每次发送广播时,应用的接收器都会消耗资源。 如果多个应用注册为接收基于系统事件的广播,这会引发问题;触发广播的系统事件会导致所有应用快速地连续消耗资源,从而降低用户体验。
为了缓解这一问题,Android 7.0(API 级别 25)对广播施加了一些限制,而Android 8.0 让这些限制更为严格。
-
针对 Android 8.0 的应用无法继续在其
AndroidManifest.xml
中为隐式广播注册广播接收器。 隐式广播是一种不专门针对该应用的广播。 例如,ACTION_PACKAGE_REPLACED
就是一种隐式广播,因为它将发送到注册的所有侦听器,让后者知道设备上的某些软件包已被替换。 不过,ACTION_MY_PACKAGE_REPLACED
不是隐式广播,因为不管已为该广播注册侦听器的其他应用有多少,它都会只发送到软件包已被替换的应用。 - 应用可以继续在它们的清单中注册显式广播。
- 应用可以在运行时使用
Context.registerReceiver()
动态的为任意广播(不管是隐式还是显式)注册接收器。 - 需要签名权限的广播不受此限制所限,因为这些广播只会发送到使用相同证书签名的应用,而不是发送到设备上的所有应用。
在许多情况下,之前注册隐式广播的应用可以使用 JobScheduler
获得类似的功能。
注1:很多隐式广播当前不受此限制所限。应用可以继续在其清单中为这些广播注册接收器,不管应用针对哪个 API级别。有关已豁免广播的列表,请参阅这里:https://developer.android.com/guide/components/broadcast-exceptions.html。
注2:除了上面提到的这些限制之外,在Android 8.0版本上,系统对于应用程序的后台位置也进行了限制:为降低功耗,无论应用的目标 SDK 版本为何,Android 8.0都会对后台应用检索用户当前位置的频率进行限制。
系统实现
有了第二章应用程序管理的讲解,读者应该很容易想到这里新增加的后台限制功能是在哪个模块完成的。是的没错,就是在ActivityManager模块中。
Android 8.0上,明确区分了“前台”和“后台”的概念,前台是用户与之交互的应用,这些应用在处于前台的时刻是对用户来说非常重要的,因此对其不做任何限制。但是,对于处于后台的应用增加了各方面的限制,这就制约了应用程序“偷偷摸摸”的后台活动。
后台服务限制
系统不允许后台应用创建后台服务,这意味着:
- 后台应用可以创建前台应用
- 系统会监测和拒绝后台应用创建后台服务
我们先来看第1点。API Level 26(对应的就是Android 8.0版本)新增了这么一个接口来启动前台服务:
ComponentName Context.startForegroundService(Intent service)
这个接口要求:被启动的Service必须在启动之后调用Service.startForeground(int, android.app.Notification)
,如果在规定的时间内没有调用,则系统将认为该应用发生ANR(App Not Response,即应用无响应),从而将其强制停止。这就是限制了:应用无法在用户无感知的情况下启动服务,前台服务启动之后,需要发送一条通知,用户便可以明确感知到这个事情。而一旦用户可以感知这个事情,应用程序就可能不太敢“骚扰”用户了,因为用户可能会因为觉得这个应用过于“吵闹”而将其卸载。
startForegroundService
接口的实现位于ContextImpl类中,相关代码如下:
// ContextImpl.java
@Override
public ComponentName startForegroundService(Intent service) {
warnIfCallingFromSystemProcess();
return startServiceCommon(service, true, mUser);
}
...
private ComponentName startServiceCommon(Intent service, boolean requireForeground,
UserHandle user) {
try {
validateServiceIntent(service);
service.prepareToLeaveProcess(this);
ComponentName cn = ActivityManager.getService().startService(
mMainThread.getApplicationThread(), service, service.resolveTypeIfNeeded(
getContentResolver()), requireForeground,
getOpPackageName(), user.getIdentifier());
if (cn != null) {
if (cn.getPackageName().equals("!")) {
throw new SecurityException(
"Not allowed to start service " + service
+ " without permission " + cn.getClassName());
} else if (cn.getPackageName().equals("!!")) {
throw new SecurityException(
"Unable to start service " + service
+ ": " + cn.getClassName());
} else if (cn.getPackageName().equals("?")) {
throw new IllegalStateException(
"Not allowed to start service " + service + ": " + cn.getClassName());
}
}
return cn;
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
很显然,ActivityManager.getService().startService
会经过Binder调用到ActivityManagerService
中对应的方法来启动服务。startForegroundService
在调用startServiceCommon
时,第二个参数requireForeground
值设置为true。这个值会传递到ActivityManagerService
中。
并且,如果启动失败,Binder接口将通过返回不同的字符串来描述失败的类型:
- ”!”:表示发起者没有权限启动目标Service,此时会抛出
SecurityException
- ”!!”:表示启动Service失败,此时会抛出
SecurityException
- ”?”:表示不允许启动Service,此时会抛出
IllegalStateException
。这便是后台进程受限时的错误
另外,第二章时我们已经讲过,ActivityManagerService
中会通过ActiveServices
这个子模块来管理Service,因此启动Service的逻辑也是由它处理的。
每一个运行中的Service在服务端(ActivityManagerService
中)都会有一个ServiceRecord与之对应。是否是前台Service会通过下面这个属性进行记录,而这个属性的取值的来源就是上面传递的requireForeground
参数:
// ServiceRecord.java
boolean fgRequired; // is the service required to go foreground after starting?
有了这个属性记录之后,系统服务便可以对其进行接下来的判断和检查。
接下来我们在继续看第2点:系统是如何阻止后台应用创建后台服务的。启动后台Service的是下面这个接口,这是自API Level 1就提供的接口:
ComponentName Context.startService(Intent service)
当由于后台进程的限制而导致启动失败时,这个接口将抛出IllegalStateException
。
这个接口的实现也位于ContextImpl类中,相关代码如下:
// ContextImpl.java
@Override
public ComponentName startService(Intent service) {
warnIfCallingFromSystemProcess();
return startServiceCommon(service, false, mUser);
}
同样,这里也调用了startServiceCommon
方法。这个方法的代码刚刚我们已经看到了。只不过不同的是,startForegroundService
方法调用startServiceCommon
方法的时候第二个参数是true
,而这里是false
。
注:Android Framework中提供给开发者的很多API在内部实现上都是同一个方法,内部实现中通过参数来区分不同的场景。
ActiveServices
中启动服务的相关代码如下:
// ActiveServices.java
ComponentName startServiceLocked(IApplicationThread caller, Intent service, String resolvedType,
int callingPid, int callingUid, boolean fgRequired, String callingPackage, final int userId)
throws TransactionTooLargeException {
...
final boolean callerFg;
if (caller != null) {
final ProcessRecord callerApp = mAm.getRecordForAppLocked(caller); // ①
if (callerApp == null) {
throw new SecurityException(
"Unable to find app for caller " + caller
+ " (pid=" + callingPid
+ ") when starting service " + service);
}
callerFg = callerApp.setSchedGroup != ProcessList.SCHED_GROUP_BACKGROUND; // ②
} else {
callerFg = true;
}
...
// arbitrary service
if (!r.startRequested && !fgRequired) {
// Before going further -- if this app is not allowed to start services in the
// background, then at this point we aren't going to let it period.
final int allowed = mAm.getAppStartModeLocked(r.appInfo.uid, r.packageName,
r.appInfo.targetSdkVersion, callingPid, false, false); // ③
if (allowed != ActivityManager.APP_START_MODE_NORMAL) {
Slog.w(TAG, "Background start not allowed: service "
+ service + " to " + r.name.flattenToShortString()
+ " from pid=" + callingPid + " uid=" + callingUid
+ " pkg=" + callingPackage);
if (allowed == ActivityManager.APP_START_MODE_DELAYED) {
// In this case we are silently disabling the app, to disrupt as
// little as possible existing apps.
return null;
}
// This app knows it is in the new model where this operation is not
// allowed, so tell it what has happened.
UidRecord uidRec = mAm.mActiveUids.get(r.appInfo.uid);
return new ComponentName("?", "app is in background uid " + uidRec); // ④
}
}
这段代码说明如下:
- 获取调用者进程
ProcessRecord
的对象 - 检查调用者进程是前台还是后台。在讲解进程优先级的管理时,我们提到过,进程中应用组件的状态发生变化时,
ActivityManagerService
会重新调整进程的优先级状态,即:会设置ProcessRecord的setSchedGroup字段。因此这里便可以以此为依据来确定调用者是否处于前台 - 这里的mAm就是
ActivityManagerService
,因此这里是在通过ActivityManagerService.getAppStartModeLocked
方法查询此次启动是否允许 - 如果不允许,则返回“?”字符串以及错误信息“app is in background”。上面我们已经看到,”?”:表示不允许启动Service,此时
startService
接口会抛出IllegalStateException
。
getAppStartModeLocked
是Android 8.0上新增的方法,目的就是为了进行后台限制的检查。该方法的签名如下:
// ActivityManagerService.java
int getAppStartModeLocked(int uid, String packageName, int packageTargetSdk,
int callingPid, boolean alwaysRestrict, boolean disabledOnly)
这个方法的返回值定义在ActivityManager中,可能是下面四个值中的一个:
// ActivityManager.java
/** @hide Mode for {@link IActivityManager#isAppStartModeDisabled}: normal free-to-run operation. */
public static final int APP_START_MODE_NORMAL = 0;
/** @hide Mode for {@link IActivityManager#isAppStartModeDisabled}: delay running until later. */
public static final int APP_START_MODE_DELAYED = 1;
/** @hide Mode for {@link IActivityManager#isAppStartModeDisabled}: delay running until later, with
* rigid errors (throwing exception). */
public static final int APP_START_MODE_DELAYED_RIGID = 2;
/** @hide Mode for {@link IActivityManager#isAppStartModeDisabled}: disable/cancel pending
* launches; this is the mode for ephemeral apps. */
public static final int APP_START_MODE_DISABLED = 3;
这其中,只有第一个值表示允许启动。
广播限制
最后我们再来看一下对于广播的后台限制。在第二章中我们也已经提到过,BroadcastQueue.processNextBroadcast
负责处理广播。在这个方法中,也会调用ActivityManagerService.getAppStartModeLocked
方法进行后台检查。如果是因为后台限制而无法接口广播,则此处会通过Slog
输出相应的日志。
// BroadcastQueue.java
if (!skip) {
final int allowed = mService.getAppStartModeLocked(
info.activityInfo.applicationInfo.uid, info.activityInfo.packageName,
info.activityInfo.applicationInfo.targetSdkVersion, -1, true, false);
if (allowed != ActivityManager.APP_START_MODE_NORMAL) {
// We won't allow this receiver to be launched if the app has been
// completely disabled from launches, or it was not explicitly sent
// to it and the app is in a state that should not receive it
// (depending on how getAppStartModeLocked has determined that).
if (allowed == ActivityManager.APP_START_MODE_DISABLED) {
Slog.w(TAG, "Background execution disabled: receiving "
+ r.intent + " to "
+ component.flattenToShortString());
skip = true;
} else if (((r.intent.getFlags()&Intent.FLAG_RECEIVER_EXCLUDE_BACKGROUND) != 0)
|| (r.intent.getComponent() == null
&& r.intent.getPackage() == null
&& ((r.intent.getFlags()
& Intent.FLAG_RECEIVER_INCLUDE_BACKGROUND) == 0)
&& !isSignaturePerm(r.requiredPermissions))) {
mService.addBackgroundCheckViolationLocked(r.intent.getAction(),
component.getPackageName());
Slog.w(TAG, "Background execution not allowed: receiving "
+ r.intent + " to "
+ component.flattenToShortString());
skip = true;
}
}
}
这段代码中的注释很好的描述了这里的情况,不允许广播接收器启动有两种可能性:
- 该应用本身已经被禁止启动了
- 这个广播不是明确发给(隐式广播)这个接收器的,并且它正处于不应该接受的状态
隐式广播的限制是针对那些目标版本是Android 8.0或更高版本的应用的,因此这里的限制会检查应用程序的目标Sdk级别,这个检查在下面这个方法中完成:
// ActivityManagerService.java
int appRestrictedInBackgroundLocked(int uid, String packageName, int packageTargetSdk) {
// Apps that target O+ are always subject to background check
if (packageTargetSdk >= Build.VERSION_CODES.O) {
if (DEBUG_BACKGROUND_CHECK) {
Slog.i(TAG, "App " + uid + "/" + packageName + " targets O+, restricted");
}
return ActivityManager.APP_START_MODE_DELAYED_RIGID;
}
// ...and legacy apps get an AppOp check
int appop = mAppOpsService.noteOperation(AppOpsManager.OP_RUN_IN_BACKGROUND,
uid, packageName);
if (DEBUG_BACKGROUND_CHECK) {
Slog.i(TAG, "Legacy app " + uid + "/" + packageName + " bg appop " + appop);
}
switch (appop) {
case AppOpsManager.MODE_ALLOWED:
return ActivityManager.APP_START_MODE_NORMAL;
case AppOpsManager.MODE_IGNORED:
return ActivityManager.APP_START_MODE_DELAYED;
default:
return ActivityManager.APP_START_MODE_DELAYED_RIGID;
}
}
下图总结了这里提到的调用关系:
结束语
后台限制的增加确实是极大的改善了“后台问题”,但不得不承认这仍然没有彻底杜绝这个问题。毕竟,还有不少的系统广播处于豁免状态,它们不受后台隐式广播的限制,并且,这些广播中包含了系统中非常频繁发生的一些事件,例如:系统启动,连接状态变化,来电,应用包状态变更等等。
究其原因,还是我们前面说到的兼容性和生态的问题,系统如果一次性将所有这些广播全部增加限制,可能会有非常多的应用程序出现问题。因此这种变更需要随着时间的推移,逐步的完成。这并非Android系统独有的问题,很多大型的软件项目都同样有这样的“历史包袱”。
这也同时提醒着我们这些参与软件设计的人们:早期设计所遗留下的问题如果没有及时解决,随着时间推移,这些后果会逐渐扩散,以致于我们要付出更大的代价来弥补才行。所以前期设计需要非常的谨慎,对于拿不准的地方,宁愿收紧也不能放松。毕竟,像iOS那样做加法(不断添加新的功能和API)比Android这样做减法(取消和收回之前公开的机制或者功能)要容易得多。
参考资料与推荐读物
- 针对低电耗模式和应用待机模式进行优化
- Android 7.0 行为变更
- 电源管理
- Google I/O 2014 - Introduction to Project Volta
- Android 5.0 API
- Android Development Tutorial: Project Volta
- 5 Ways Project Volta Improved Battery Life Of Android Devices
- Android Development Tutorial: Job Scheduler
- google/battery-historian
原文地址:《Android功耗改进》 by 保罗的酒吧
来源:oschina
链接:https://my.oschina.net/u/4383937/blog/3290982