Android 非同步和非阻斷 API 指南

非阻斷 API 會要求執行工作,然後將控制權交還給呼叫執行緒,以便在要求執行的作業完成前執行其他工作。如果要求的工作可能正在進行中,或需要等待 I/O 或 IPC 完成、高爭用系統資源可用性,或使用者輸入內容後才能繼續進行,這些 API 就非常實用。設計完善的 API 特別提供取消進行中作業的方法,並停止代表原始呼叫端執行的工作,在不再需要作業時維護系統健康狀態和電池續航力。

非同步 API 是實現非阻塞行為的方法之一。非同步 API 會接受某種形式的續傳或回呼,在作業完成時或作業進行期間發生其他事件時,系統會通知您。

撰寫非同步 API 的主要動機有兩個:

  • 並行執行多項作業,其中第 N 項作業必須在第 N-1 項作業完成前啟動。
  • 避免封鎖呼叫執行緒,直到作業完成為止。

Kotlin 大力推廣結構化並行,這是一系列以暫停函式為基礎的原則和 API,可將程式碼的同步和非同步執行作業,與執行緒阻斷行為分離。暫停函式為「非阻塞」和「同步」

暫停函式:

  • 請勿封鎖呼叫執行緒,而是在等待其他位置執行的作業結果時,將執行緒做為實作詳細資料產生。
  • 同步執行,且不需要非封鎖 API 的呼叫端與 API 呼叫啟動的非封鎖工作並行執行。

本頁詳細說明開發人員使用非阻斷和非同步 API 時,可安全持有的最低基準期望,接著提供一系列配方,協助您在 Android 平台或 Jetpack 程式庫中,以 Kotlin 或 Java 語言編寫符合這些期望的 API。如有疑慮,請將開發人員的期望視為任何新 API 介面的需求。

開發人員對非同步 API 的期望

除非另有註明,否則下列期望是從非暫停 API 的角度撰寫。

接受回呼的 API 通常是非同步

如果 API 接受的回呼並未記錄為只會就地呼叫 (也就是只由呼叫執行緒在 API 呼叫本身傳回前呼叫),則 API 會假設為非同步,且該 API 應符合下列章節中記錄的所有其他期望。

只會在適當位置呼叫的回呼範例,是高階對應或篩選函式,這類函式會在傳回前,對集合中的每個項目叫用對應器或述詞。

非同步 API 應盡快傳回

開發人員希望非同步 API 不會阻斷,並在啟動作業要求後快速傳回。隨時呼叫非同步 API 應一律安全無虞,且呼叫非同步 API 絕不應導致影格不穩或 ANR。

平台或程式庫可視需要觸發許多作業和生命週期信號,要求開發人員掌握程式碼所有潛在呼叫位置的全域知識,是不切實際的做法。舉例來說,為回應 View 測量和版面配置,可將 Fragment 新增至同步交易中的 FragmentManager,以便填滿可用空間 (例如 RecyclerView) 時填入應用程式內容。回應這個片段 onStart 生命週期回呼的 LifecycleObserver 可能會在此合理地執行一次性啟動作業,而這可能位於產生無抖動動畫影格的重大程式碼路徑上。開發人員應一律確信,呼叫任何非同步 API 來回應這類生命週期回呼,不會導致影格不穩。

這表示非同步 API 在傳回前執行的工作必須非常輕量,最多只能建立要求記錄和相關聯的回呼,並向執行引擎註冊,由該引擎執行工作。如果註冊非同步作業需要 IPC,API 的實作方式應採取必要措施,以滿足開發人員的期望。這可能包括一或多個:

  • 將基礎 IPC 實作為單向繫結器呼叫
  • 向系統伺服器發出雙向繫結器呼叫,完成註冊時不需要取得高度競爭的鎖定
  • 將要求發布至應用程式程序中的工作執行緒,透過 IPC 執行封鎖註冊

非同步 API 應傳回空白,且只會針對無效引數擲回例外狀況

非同步 API 應將所要求作業的所有結果回報給提供的回呼。開發人員可以藉此為成功和錯誤處理作業實作單一程式碼路徑。

非同步 API 可能會檢查引數是否為空值並擲回 NullPointerException,或檢查提供的引數是否在有效範圍內並擲回 IllegalArgumentException。舉例來說,如果函式接受 float1f 範圍內的 0,函式可能會檢查參數是否在這個範圍內,如果超出範圍則會擲回 IllegalArgumentException,或者可能會檢查 String 是否符合有效格式 (例如僅限英數字元)。(請注意,系統伺服器絕不應信任應用程式程序!任何系統服務都應在系統服務本身重複執行這些檢查。)

所有其他錯誤都應回報給提供的回呼。這包括但不限於:

  • 要求的作業發生終結性錯誤
  • 安全例外狀況:缺少完成作業所需的授權或權限
  • 超出執行作業的配額
  • 應用程式程序「前景」不足,無法執行作業
  • 必要硬體已中斷連線
  • 網路連線失敗
  • 逾時
  • 繫結器終止或遠端程序無法使用

非同步 API 應提供取消機制

非同步 API 應提供方法,向正在執行的作業指出呼叫端不再需要結果。這項取消作業應會發出兩項信號:

應發布對呼叫者提供的回呼的硬式參照

提供給非同步 API 的回呼可能包含大型物件圖形的硬式參照,而持續進行的工作會保留該回呼的硬式參照,導致這些物件圖形無法進行垃圾收集。取消作業時釋出這些回呼參照,物件圖表可能比允許作業執行完畢時更早符合垃圾收集資格。

為呼叫端執行作業的執行引擎可能會停止該作業

非同步 API 呼叫啟動的工作可能會耗用大量電力或其他系統資源。如果呼叫端可透過 API 發出訊號,表示不再需要這項工作,就能在工作耗用更多系統資源前停止。

快取或凍結應用程式的特別注意事項

設計非同步 API 時,如果回呼源自系統程序並傳送至應用程式,請考量下列事項:

  1. 程序和應用程式生命週期:接收端應用程式程序可能處於快取狀態。
  2. 快取應用程式凍結器:接收端應用程式程序可能會凍結。

應用程式處理程序進入快取狀態時,表示該程序並未主動代管任何使用者可見的元件,例如活動和服務。應用程式會保留在記憶體中,以防再次顯示給使用者,但同時不應執行任何工作。在大多數情況下,應用程式進入快取狀態時,您應暫停分派應用程式回呼,並在應用程式離開快取狀態時繼續分派,以免在快取應用程式程序中引發工作。

快取應用程式也可能會凍結。應用程式凍結後,會收到零 CPU 時間,完全無法執行任何工作。系統會緩衝處理對該應用程式註冊回呼的所有呼叫,並在應用程式解除凍結時傳送。

應用程式解除凍結並處理緩衝交易時,這些交易可能已過時。緩衝區是有限的,如果溢位,會導致接收端應用程式當機。為避免應用程式因過時事件而負荷過重或緩衝區溢位,請勿在應用程式程序凍結時傳送應用程式回呼。

審查中:

  • 應用程式程序快取時,您應考慮暫停分派應用程式回呼。
  • 應用程式程序凍結時,您必須暫停分派應用程式回呼。

狀態追蹤

如要追蹤應用程式進入或退出快取狀態的時間,請按照下列步驟操作:

mActivityManager.addOnUidImportanceListener(
    new UidImportanceListener() { ... },
    IMPORTANCE_CACHED);

如要追蹤應用程式凍結或解除凍結的時間,請按照下列步驟操作:

IBinder binder = <...>;
binder.addFrozenStateChangeCallback(executor, callback);

繼續傳送應用程式回呼的策略

無論應用程式進入快取狀態或凍結狀態時,您是否暫停分派應用程式回呼,應用程式離開相應狀態時,都應在應用程式離開相應狀態後,繼續分派應用程式的已註冊回呼,直到應用程式取消註冊回呼或應用程式程序終止為止。

例如:

IBinder binder = <...>;
bool shouldSendCallbacks = true;
binder.addFrozenStateChangeCallback(executor, (who, state) -> {
    if (state == IBinder.FrozenStateChangeCallback.STATE_FROZEN) {
        shouldSendCallbacks = false;
    } else if (state == IBinder.FrozenStateChangeCallback.STATE_UNFROZEN) {
        shouldSendCallbacks = true;
    }
});

或者,您也可以使用 RemoteCallbackList,這樣一來,當目標程序凍結時,就不會將回呼傳送至該程序。

例如:

RemoteCallbackList<IInterface> rc =
        new RemoteCallbackList.Builder<IInterface>(
                        RemoteCallbackList.FROZEN_CALLEE_POLICY_DROP)
                .setExecutor(executor)
                .build();
rc.register(callback);
rc.broadcast((callback) -> callback.foo(bar));

只有在程序未凍結時,才會叫用 callback.foo()

應用程式通常會將透過回呼收到的更新儲存為最新狀態的快照。假設應用程式可透過 API 監控剩餘電量百分比:

interface BatteryListener {
    void onBatteryPercentageChanged(int newPercentage);
}

假設應用程式凍結時發生多個狀態變更事件。應用程式解除凍結後,您應只將最新狀態傳送給應用程式,並捨棄其他過時的狀態變更。應用程式解除凍結後,系統應立即傳送這類訊息,讓應用程式「趕上進度」。您可以按照下列方式完成此作業:

RemoteCallbackList<IInterface> rc =
        new RemoteCallbackList.Builder<IInterface>(
                        RemoteCallbackList.FROZEN_CALLEE_POLICY_ENQUEUE_MOST_RECENT)
                .setExecutor(executor)
                .build();
rc.register(callback);
rc.broadcast((callback) -> callback.onBatteryPercentageChanged(value));

在某些情況下,您可以追蹤傳送至應用程式的最後一個值,這樣應用程式就不會在解除凍結後收到相同值的通知。

狀態可以更複雜的資料表示。假設有一個虛擬 API,可讓應用程式接收網路介面通知:

interface NetworkListener {
    void onAvailable(Network network);
    void onLost(Network network);
    void onChanged(Network network);
}

暫停應用程式的通知時,請記住應用程式上次看到的網路和狀態組合。恢復連線後,建議依序通知應用程式:遺失的舊網路、可用的新網路,以及狀態已變更的現有網路。

如果回呼暫停期間有網路可用,但隨後中斷連線,請勿通知應用程式。應用程式不應收到凍結期間發生的完整事件記錄,且 API 說明文件不應保證在明確的生命週期狀態之外,事件串流會不間斷地傳送。在這個範例中,如果應用程式需要持續監控網路可用性,就必須保持在生命週期狀態,避免遭到快取或凍結。

在審查期間,您應合併暫停通知後和恢復通知前發生的事件,並簡潔地將最新狀態傳送至已註冊的應用程式回呼。

開發人員說明文件注意事項

非同步事件的傳送可能會延遲,原因可能是傳送者暫停傳送一段時間 (如上一節所示),或是接收者應用程式沒有足夠的裝置資源,無法及時處理事件。

請避免開發人員假設應用程式收到事件通知的時間與事件實際發生時間之間的時間差。

暫停 API 的開發人員期望

熟悉 Kotlin 結構化並行的開發人員,會預期任何暫停 API 都有下列行為:

暫停函式應先完成所有相關工作,再傳回或擲回

非封鎖作業的結果會以一般函式傳回值的形式傳回,而錯誤則會透過擲回例外狀況回報。(這通常表示不需要回呼參數)。

暫停函式應只就地叫用回呼參數

暫停函式應一律先完成所有相關作業,再傳回值,因此絕不應在暫停函式傳回後,叫用提供的回呼或其他函式參數,或保留對這些參數的參照。

除非另有說明,否則接受回呼參數的暫停函式應保留內容

在暫停函式中呼叫函式,會導致函式在呼叫端的 CoroutineContext 中執行。由於暫停函式應先完成所有相關工作,再傳回或擲回,且應只在適當位置叫用回呼參數,因此預設期望是,任何這類回呼會使用相關聯的調度器,在呼叫 CoroutineContext 上執行。如果 API 的用途是在呼叫 CoroutineContext 以外的位置執行回呼,則應清楚記錄這項行為。

暫停函式應支援 kotlinx.coroutines 作業取消

提供的任何暫停函式都應配合 kotlinx.coroutines 定義的工作取消作業。如果取消進行中的作業呼叫工作,函式應盡快以 CancellationException 繼續執行,以便呼叫端盡快清理並繼續作業。這項作業會由 suspendCancellableCoroutinekotlinx.coroutines 提供的其他暫停 API 自動處理。程式庫實作項目通常不應直接使用 suspendCoroutine,因為這項函式預設不支援取消行為。

在背景 (非主執行緒或 UI 執行緒) 執行阻斷工作的暫停函式,必須提供設定所用調度器的機制

不建議封鎖函式完全暫停,以切換執行緒。

呼叫暫停函式時,不應建立額外執行緒,且不允許開發人員提供自己的執行緒或執行緒集區來執行該工作。舉例來說,建構函式可能會接受 CoroutineContext,用於執行類別方法背景工作。

如果暫停函式只會接受選用的 CoroutineContextDispatcher 參數,然後切換至該調度器來執行封鎖工作,則應改為公開基礎封鎖函式,並建議呼叫開發人員使用自己的 withContext 呼叫,將工作導向所選調度器。

啟動協同程式的類別

啟動協同程式的類別必須有 CoroutineScope,才能執行這些啟動作業。遵循結構化並行原則,表示您會採用下列結構模式來取得及管理該範圍。

在將啟動並行工作的類別寫入其他範圍之前,請考慮替代模式:

class MyClass {
    private val requests = Channel<MyRequest>(Channel.UNLIMITED)

    suspend fun handleRequests() {
        coroutineScope {
            for (request in requests) {
                // Allow requests to be processed concurrently;
                // alternatively, omit the [launch] and outer [coroutineScope]
                // to process requests serially
                launch {
                    processRequest(request)
                }
            }
        }
    }

    fun submitRequest(request: MyRequest) {
        requests.trySend(request).getOrThrow()
    }
}

公開 suspend fun 以執行並行作業,可讓呼叫端在自己的環境中叫用作業,不必讓 MyClass 管理 CoroutineScope。要求處理作業的序列化會變得更簡單,狀態通常可以做為 handleRequests 的本機變數,而不是類別屬性,否則需要額外同步處理。

管理協同程式的類別應公開關閉和取消方法

如果類別會啟動協同程式做為實作詳細資料,就必須提供方法,以便乾淨地關閉這些持續進行的並行工作,避免不受控的並行工作洩漏到父項範圍。通常會以建立所提供 CoroutineContext 的子項 Job 形式進行:

private val myJob = Job(parent = `CoroutineContext`[Job])
private val myScope = CoroutineScope(`CoroutineContext` + myJob)

fun cancel() {
    myJob.cancel()
}

此外,系統也可能會提供 join() 方法,讓使用者程式碼等待物件執行的任何未完成並行作業。(這可能包括取消作業時執行的清除工作)。

suspend fun join() {
    myJob.join()
}

終端機作業命名

用於方法中的名稱應能清楚關閉物件擁有的並行工作,且這些工作仍在進行中,並應反映關閉作業的行為合約:

如果進行中的作業可能會完成,但呼叫 close() 傳回後,系統可能不會開始執行新作業,請使用 close()

如果正在進行的作業可能會在完成前取消,請使用 cancel()。 在呼叫 cancel() 傳回後,可能無法開始新的作業。

類別建構函式接受 CoroutineContext,而非 CoroutineScope

如果禁止物件直接啟動至提供的父項範圍,CoroutineScope 做為建構函式參數的適用性就會失效:

// Don't do this
class MyClass(scope: CoroutineScope) {
    private val myJob = Job(parent = scope.`CoroutineContext`[Job])
    private val myScope = CoroutineScope(scope.`CoroutineContext` + myJob)

    // ... the [scope] constructor parameter is never used again
}

CoroutineScope 會成為不必要且誤導性的包裝函式,在某些用途中,可能只會建構為傳遞做為建構函式參數,但隨即遭到捨棄:

// Don't do this; just pass the context
val myObject = MyClass(CoroutineScope(parentScope.`CoroutineContext` + Dispatchers.IO))

CoroutineContext 參數預設為 EmptyCoroutineContext

當 API 介面中出現選用 CoroutineContext 參數時,預設值必須是 Empty`CoroutineContext` 標記。這樣一來,API 行為的組合效果會更好,因為來自呼叫端的 Empty`CoroutineContext` 值會以與接受預設值相同的方式處理:

class MyOuterClass(
    `CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
    private val innerObject = MyInnerClass(`CoroutineContext`)

    // ...
}

class MyInnerClass(
    `CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
    private val job = Job(parent = `CoroutineContext`[Job])
    private val scope = CoroutineScope(`CoroutineContext` + job)

    // ...
}