原文地址:《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 保羅的酒吧