隨著Android版本的不斷更新,如何正確的處理後臺任務變得越來越複雜。因此, Google釋出了 WorkManager(作為JetPack的一部分)來幫助開發者解決這一難題。
在學習WorkManager之前,首先得知道我們為什麼需要它。本文將從以下三部分來闡述:
- Android系統記憶體相關基礎知識
- 現有的解決方案
- WorkManager
1. Android Memory
Android系統的核心是基於Linux核心的,它與其他那些基於Linux核心的系統的主要差別在於Android系統沒有交換空間(Swap space)。當系統記憶體資源已被耗盡,但是又有額外的記憶體資源請求的時候,記憶體中不活動的頁面會被移動到交換空間。交換空間是磁碟上的一塊區域,因此其訪問速度比實體記憶體慢。
鑑於此,Android系統引入了OOM( Out Of Memory ) Killer 來解決記憶體資源被耗盡的問題。它的作用是根據程式所消耗的記憶體大小以及程式的“visibility state”來決定是否殺死這個程式,從而達到釋放記憶體的目的。
Activity Manager會給不同狀態下的程式設定相對應的oom_adj 值。下面是一些示例:
# Define the oom_adj values for the classes of processes that can be
# killed by the kernel. These are used in ActivityManagerService.
setprop ro.FOREGROUND_APP_ADJ 0 //前臺程式
setprop ro.VISIBLE_APP_ADJ 1 //可見程式
setprop ro.SECONDARY_SERVER_ADJ 2 //次要服務
setprop ro.BACKUP_APP_ADJ 2 //備份程式
setprop ro.HOME_APP_ADJ 4 //桌面程式
setprop ro.HIDDEN_APP_MIN_ADJ 7 //後臺程式
setprop ro.CONTENT_PROVIDER_ADJ 14 //內容供應節點
setprop ro.EMPTY_APP_ADJ 15 //空程式
複製程式碼
程式的omm_adj 值越大,它被 OOM killer 殺死的可能性越大。OOM killer是依據系統空閒的記憶體空間大小和omm_adj閾值的組合規則來殺死程式的。比如,當空閒的記憶體空間大小小於X1時,殺死那些omm_adj值大於Y1的程式。它的基本處理流程如下圖所示:
到現在為止,我希望你知道兩點:
- 你的應用消耗的記憶體越少,它被系統強行殺死的可能性越小,也就是說你的應用存活的時間越長。
- 你必須清楚的瞭解應用的各種不同狀態。當你的應用退到後臺,但是它需要繼續執行任務時,你就必須使用Service。
A Service is an application component that can perform long-running operations in the background, and it does not provide a user interface.
使用service的理由如下:
- 告訴系統你的應用有一個需要長時間執行的任務,並獲得任務所屬程式所對應的omm_adj值。
- 它是Android應用程式的四大元件之一(其它三個元件分別是BroadcastReceiver, Activity, ContentProvider)。
- Service能執行在獨立的程式。
但是也存在一個缺點:我開發了自己的第一個應用,它竟然在不到3個小時的時間內將電池電量從100%消耗至0%,因為我的應用開啟了一個service:每3分鐘從伺服器中獲取資料。
那時候我還只是一個年輕的沒有經驗的開發者。但不知為何,6年之後,仍然有許多未知的應用程式在做著同樣的事情。
每一位開發者可以毫無限制地在後臺執行著任何他們想做的操作。google也意識到了這一點,並試圖採取一些改進的措施。
從 Marshmallow 開始,然後是 Nougat , Android系統引入了休眠模式 (Doze mode)
何為休眠模式?簡而言之,當使用者關閉了手機螢幕之後,系統會自動進入休眠模式,禁止所有應用的網路請求、資料同步、GPS、鬧鐘、wifi掃描等功能,直到使用者重新點亮螢幕或者手機接入了電源,這樣可以有效節省手機的電量。
但這感覺像是滄海一粟,因此從Android Oreo (API 26) 開始,Google 做了進一步改進:如果一個應用的目標版本為Android 8.0,當它在某些不被允許建立後臺服務的場景下,呼叫了Service的startService()方法,該方法會丟擲IllegalStateException。這個問題可以通過調整targeting SDK的值來解決,一些知名應用的target API都是22,因為他們不願意處理執行時許可權。
但是,接下來你會發現:
- 2018年8月: 所有新開發應用的target API level必須是26(Android 8.0)甚至更高。
- 2018年11月: 所有已釋出應用的target API level必須更新至26甚至更高。
- 2019年起: 在每一次釋出新版本的Android系統之後,所有新開發以及待更新的應用都必須在一年內將target API level調整至對應的系統版本甚至更高。
說了這麼多 - (我相信你會得出同樣的結論):
我們所熟知的Servcie已經被棄用了,因為它不再被允許在後臺執行長時間的操作,而這卻是它最初被設計出來的目的。
除了Foreground service之外,我們已經沒有任何理由再去使用Service了。
2. I have a network call. What is out there:
首先舉個簡單的例子:有一個簡單的網路請求,它能下載幾千位元組的資料。最簡單的方法(並不正確的方法)就是開啟一個單獨的執行緒來執行這一請求。
int threads = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(threads);
executor.submit(myWork);
複製程式碼
再考慮下登入場景。使用者填寫了郵箱、密碼,然後點選了登入按鈕。使用者的手機是3G網路,訊號很差,接著他走進了電梯。
當應用正在執行登入網路請求的時候,使用者接了一個電話。
OkHttp 預設的超時時間很長
connectTimeout = 10_000;
readTimeout = 10_000;
writeTimeout = 10_000;
複製程式碼
通常我們都會設定網路請求重試的次數為3
因此, 最壞的情況是: 3 * 30 = 90 秒.
現在請回答一個問題 ——
登入成功了嗎?
當你的應用退到後臺之後,你就什麼都不知道了。正如我們所瞭解的,你不能指望你的應用程式會一直存活,以完成網路請求,處理響應並儲存使用者登入資訊。更不用說使用者的手機還有可能進入離線模式,失去網路連線。
從使用者的角度來看,我已經輸入了我的郵箱和密碼,並且點選了登入按鈕,因此我應該已經登入成功了。假設沒有登入成功的話,使用者會認為你的應用的使用者體驗很差,但事實上這並不是使用者體驗的問題,而是一個技術問題。
接下來你就會思考,ok,一旦我的應用即將退到後臺,我就開啟Service去執行登入操作,但是你不能!!!
這時候JobScheduler
就能派上用場了。
ComponentName service = new ComponentName(this, MyJobService.class);
JobScheduler mJobScheduler = (JobScheduler)getSystemService(Context.JOB_SCHEDULER_SERVICE);
JobInfo.Builder builder = new JobInfo.Builder(jobId, serviceComponent)
.setRequiredNetworkType(jobInfoNetworkType)
.setRequiresCharging(false)
.setRequiresDeviceIdle(false)
.setExtras(extras).build();
mJobScheduler.schedule(jobInfo);
複製程式碼
JobScheduler首先會排程一個任務,然後在合適的時機(比如說延遲若干時間之後,或者等手機空閒了)系統會開啟你的MyJobService,然後執行onStartJob()裡的處理邏輯。這個想法理論上很好,但是它只在API>21的系統上可用,而且在API 21&22的系統裡JobScheduler還存在一個重大bug。
這意味著你只能在API>22的系統上使用JobScheduler。
如果你應用的minSDK < 23,你可以使用JobDispatcher
。
Job myJob = firebaseJobDispatcher.newJobBuilder()
.setService(SmartService.class)
.setTag(SmartService.LOCATION_SMART_JOB)
.setReplaceCurrent(false)
.setConstraints(ON_ANY_NETWORK)
.build();
firebaseJobDispatcher.mustSchedule(myJob);
複製程式碼
等等, 它需要使用 Google Play Services!!
因此如果你打算使用JobDispatcher,你將會拋棄數千萬使用者。
因此,JobDispatcher 可能不是一個好的選擇。那麼AlarmManager呢?通過AlarmManager去輪詢檢查網路請求是否執行成功,如果沒有的話嘗試再次執行它?
如果你還是想用Service來立即執行網路請求的話,可以選擇JobIntentService
當SDK<26的時候,採用IntentService來執行任務;當SDK ≥ 26的時候,採用JobScheduler來執行任務。
Ahhhh… 它無法在 Android Oreo 上立即執行請求。
所以回到我們開始的地方:當應用退到後臺的時候,依據Android系統的版本和手機的狀態,選擇合適的任務排程器來排程執行後臺任務。
天吶,要做到既能節省手機電池的的電量又能為使用者提供驚豔的使用者體驗實在是太難了吧!
3. WorkManager. Just because work should be easy to do.
依據手機所處的狀態、Android系統版本、手機是否擁有Google Play Services,可以選擇對應的解決方案。你可能會嘗試著自己去實現這一整套複雜的處理邏輯。好訊息是Android framework的設計者已經聽到了我們的抱怨,他們決定去解決這個問題。
On the last Google I/O Android framework, the team announced WorkManager:
WorkManager aims to simplify the developer experience by providing a first-class API for system-driven background processing. It is intended for background jobs that should run even if the app is no longer in the foreground. Where possible, it uses JobScheduler or Firebase JobDispatcher to do the work; if your app is in the foreground, it will even try to do the work directly in your process.
哇! 這正是我們需要的!
WorkManager庫包含以下幾個元件:
WorkManager
接收帶引數和約束條件的WorkRequest,並將其排入佇列。
Worker
你只需要實現doWork() 這一個方法,它是執行在一個單獨的後臺執行緒裡的。所有需要在後臺執行的任務都在這個方法裡完成。
WorkRequest
給Worker設定引數和約束條件(比如,是否聯網、是否接通電源)等。
WorkResult
Success, Failure, Retry.
Data
傳遞給Worker的持久化的鍵值對。
首先新建一個繼承了Worker的類,並實現它的 doWork()方法:
public class LocationUploadWorker extends Worker {
...
//Upload last passed location to the server
public WorkerResult doWork() {
ServerReport serverReport = new ServerReport(getInputData().getDouble(LOCATION_LONG, 0),
getInputData().getDouble(LOCATION_LAT, 0), getInputData().getLong(LOCATION_TIME,
0));
FirebaseDatabase database = FirebaseDatabase.getInstance();
DatabaseReference myRef =
database.getReference("WorkerReport v" + android.os.Build.VERSION.SDK_INT);
myRef.push().setValue(serverReport);
return WorkerResult.SUCCESS;
}
}
複製程式碼
然後使用WorkManager將它排入任務佇列:
Constraints constraints = new Constraints.Builder().setRequiredNetworkType(NetworkType
.CONNECTED).build();
Data inputData = new Data.Builder()
.putDouble(LocationUploadWorker.LOCATION_LAT, location.getLatitude())
.putDouble(LocationUploadWorker.LOCATION_LONG, location.getLongitude())
.putLong(LocationUploadWorker.LOCATION_TIME, location.getTime())
.build();
OneTimeWorkRequest uploadWork = new OneTimeWorkRequest.Builder(LocationUploadWorker.class)
.setConstraints(constraints).setInputData(inputData).build();
WorkManager.getInstance().enqueue(uploadWork);
複製程式碼
接下來,WorkManager將會合理地排程執行你的任務;它會儲存任務所有的引數,任務的細節,更新任務的狀態。你甚至可以使用LiveData來訂閱觀察它的狀態變化:
WorkManager.getInstance().getStatusById(locationWork.getId()).observe(this,
workStatus -> {
if(workStatus!=null && workStatus.getState().isFinished()){
...
}
});
複製程式碼
WorkManager庫的架構圖如下所示:
它能做的還不止這些。
你可以通過它來執行定時任務:
Constraints constraints = new Constraints.Builder().setRequiredNetworkType
(NetworkType.CONNECTED).build();
PeriodicWorkRequest locationWork = new PeriodicWorkRequest.Builder(LocationWork
.class, 15, TimeUnit.MINUTES).addTag(LocationWork.TAG)
.setConstraints(constraints).build();
WorkManager.getInstance().enqueue(locationWork);
複製程式碼
你也可以讓多個任務按順序執行:
WorkManager.getInstance(this)
.beginWith(Work.from(LocationWork.class))
.then(Work.from(LocationUploadWorker.class))
.enqueue();
複製程式碼
你還可以讓多個任務同時執行:
WorkManager.getInstance(this).enqueue(Work.from(LocationWork.class,
LocationUploadWorker.class));
複製程式碼
當然你也可以將以上三種任務執行方式結合起來使用。
注意: 你不能構建一個將定時任務和一次性任務混合在一起的任務鏈。
WorkManager可以做很多事情: 取消任務, 組合任務, 構建任務鏈, 將一個任務的引數合併到另一個任務。我建議你去查閱官方文件,裡面有許多好的例子。
總結
為了遵循節省使用者手機電池電量的原則,Android每一個版本都在不斷改進,處理後臺任務變得十分複雜。感謝Android團隊,現在我們可以使用WorkManager來更加簡單直接地處理處理後臺任務。