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。

許多作業和生命週期信號可由平台或程式庫隨需觸發,因此開發人員無法掌握程式碼的所有潛在呼叫網址,舉例來說,如果應用程式內容必須填入以填滿可用空間 (例如 RecyclerView),則可在同步交易中將 Fragment 新增至 FragmentManager,以回應 View 的測量和版面配置。回應此片段 onStart 生命週期回呼的 LifecycleObserver 可能會在此處執行一次性啟動作業,這可能會是產生無卡頓動畫影格的重要程式碼路徑。開發人員應一律確信,在回應這類生命週期回呼時呼叫 任何非同步 API 不會導致畫面卡頓。

這表示在異步 API 傳回之前執行的工作必須非常輕量,最多只會建立要求和相關聯的回呼記錄,並將其註冊至執行引擎,以便執行工作。如果為非同步作業註冊需要 IPC,API 的實作方式應採取必要措施,以符合開發人員的期望。這可能包括下列一或多項:

  • 將基礎 IPC 實作為單向繫結器呼叫
  • 將雙向繫結器呼叫傳送至系統伺服器,在該伺服器中完成註冊作業時,不必取得高度競爭的鎖定
  • 將要求發布至應用程式處理程序中的背景工作執行緒,以便透過 IPC 執行阻斷註冊

非同步 API 應傳回 void,且只會針對無效的引數擲回

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

非同步 API 可能會檢查引數是否為空值並擲回 NullPointerException,或是檢查提供的引數是否在有效範圍內並擲回 IllegalArgumentException。舉例來說,如果函式接受的 float 介於 01f 之間,函式可能會檢查參數是否在這個範圍內,並在參數超出範圍時擲回 IllegalArgumentException;或者,函式可能會檢查短 String 是否符合有效格式,例如僅包含英數字元。(請注意,系統伺服器絕不應信任應用程式程序!任何系統服務都應在系統服務本身中複製這些檢查項目。)

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

  • 要求的作業終端失敗
  • 安全性例外狀況:缺少完成作業所需的授權或權限
  • 執行作業時已超出配額
  • 應用程式程序並未充分處於「前景」,無法執行操作
  • 已中斷連線的必要硬體
  • 網路連線失敗
  • 逾時
  • Binder 終止或無法使用的遠端程序

非同步 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

當選用 CoroutineContext 參數出現在 API 途徑時,預設值必須是 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)

    // ...
}