顯示速度緩慢

UI 轉譯是指從應用程式產生影格並在螢幕中顯示的行為。為了確保使用者與應用程式的互動順暢,應用程式的影格轉譯速度必須在 16 毫秒以內,才能達到每秒 60 個影格 (fps)。如要瞭解建議使用 60 fps 的原因,請觀看「Android 效能模式:為什麼要使用 60fps?」影片。如果要嘗試達到 90 fps,這個轉譯速度須提升至 11 毫秒,而要達到 120 fps 則須提升至 8 毫秒。

如果把這個時間降到 1 毫秒,並不表示該影格會在 1 毫秒後顯示,而是 Choreographer 會完全捨棄該影格。如果應用程式的 UI 顯示速度緩慢,系統會強制略過影格,使用者就會感覺到應用程式有延遲現象,這就是所謂的「卡頓」。本頁說明如何診斷及修正卡頓情形。

如果開發的遊戲並未使用 View 系統,就會略過 Choreographer。在這種情況下,Frame Pacing 程式庫可協助 OpenGLVulkan 遊戲,在 Android 裝置上流暢地顯示畫面並修正影格同步情形。

為協助改善應用程式品質,Android 會自動監控應用程式中的卡頓情形,並在 Android Vitals 資訊主頁中顯示相關資訊。如要瞭解資料收集方式,請參閱「使用 Android Vitals 監控應用程式的技術品質」一文。

找出卡頓問題

要找出應用程式中造成卡頓的程式碼並不容易。本節說明三種找出卡頓的方法:

「目視檢測」方法可在幾分鐘內快速檢查應用程式的所有用途,但詳細程度比不上 Systrace。「Systrace」可提供更多詳細資訊,但如果是針對應用程式的所有用途執行 Systrace,就可能會取得難以分析的大量資料。目視檢測方法和 Systrace 都可偵測本機裝置中的卡頓情形。如果無法在本機裝置中重現卡頓,您可以建構「自訂效能監控」,在實際執行的裝置中評估應用程式的特定部分。

目視檢測

您可以使用目視檢測方法找出產生卡頓的用途。如要執行目視檢測,請開啟應用程式並手動瀏覽應用程式的不同部分,尋找是否有 UI 卡頓情形。

以下是執行目視檢測的秘訣:

  • 執行應用程式的發布版本,或至少執行不可進行偵錯的版本。為了支援偵錯功能,ART 執行階段會停用多項重要的最佳化功能,因此請確保畫面上的內容與使用者看到的內容相似。
  • 啟用「剖析 GPU 轉譯」功能。剖析 GPU 轉譯功能會在畫面中顯示長條,方便您查看轉譯 UI 視窗影格所需的時間,並與每影格 16 毫秒的基準進行比較。每個長條都有不同顏色的部分,對應各個轉譯管道階段,方便您查看轉譯時間最長的部分。舉例來說,如果影格需要很長時間才能處理輸入內容,請查看處理使用者輸入內容的應用程式程式碼。
  • 執行所有經常造成卡頓的元件,例如 RecyclerView
  • 使用冷啟動方式啟動應用程式。
  • 在速度較慢的裝置上執行應用程式,讓卡頓情形更加明顯。

找出產生卡頓的用途後,也許就能知道應用程式產生卡頓的原因。您可以使用 Systrace 進一步探索更多資訊。

Systrace

雖然 Systrace 是用來顯示整個裝置運作情形的工具,但也可以用來找出應用程式中的卡頓情形。Systrace 耗用最少系統資源,因此在檢測過程中可以用最接近真實情況的方式找出卡頓問題。

在裝置上執行出現卡頓的用途時,可以使用 Systrace 記下追蹤記錄。如要瞭解 Systrace 的使用方式,請參閱「在指令列擷取系統追蹤記錄」一文。Systrace 會依照程序和執行緒分割資料。請在 Systrace 中尋找應用程式程序,如圖 1 所示。

Systrace 範例
圖 1. Systrace 範例。

圖 1 的 Systrace 範例包含以下資訊,可識別卡頓情形:

  1. Systrace 會顯示每個影格的繪製時間,並為每個影格加上顏色標記,醒目顯示轉譯速度緩慢的影格。如此一來,您就能找出產生卡頓的個別影格,比目視檢查更準確。詳情請參閱「檢查 UI 影格和快訊」。
  2. Systrace 可偵測應用程式中的問題,並在個別影格和「快訊」面板中顯示快訊。建議您按照快訊中的指示操作。
  3. Android 架構和程式庫中的 RecyclerView 等部分會包含追蹤記錄標記。因此,Systrace 時間軸會顯示這些方法在 UI 執行緒上執行的時間,以及執行所需時間。

查看 Systrace 輸出內容後,或許就能找出應用程式中產生卡頓的方法。舉例來說,如果時間軸顯示產生緩慢影格的原因是 RecyclerView 花費太多時間,您就可以在相關程式碼中加入自訂追蹤記錄事件,然後重新執行 Systrace 以取得更多資訊。在新的 Systrace 中,時間軸會顯示應用程式呼叫方法的時間,以及執行所需時間。

如果 Systrace 沒有顯示 UI 執行緒花費過長時間的原因相關詳細資料,請使用 Android CPU 分析器記錄取樣或檢測方法追蹤記錄。一般來說,方法追蹤記錄會在負荷量大時誤判卡頓,且無法判斷執行緒正在執行和遭到封鎖的時間,因此不適合用於識別卡頓。不過,方法追蹤記錄可協助您找出應用程式中耗時最長的方法。找出這些方法後,加入追蹤記錄標記,然後重新執行 Systrace,確認這些方法是否會產生卡頓。

詳情請參閱「瞭解 Systrace」一文。

自訂效能監控

如果無法在本機裝置中重現卡頓,您可以在應用程式中建構自訂效能監控機制,在實際使用的裝置中找出卡頓來源。

如要採用此方法,請使用 FrameMetricsAggregator 從應用程式的特定部分收集影格轉譯時間,然後使用 Firebase 效能監控機制記錄和分析資料。

詳情請參閱「Android 效能監控入門」一文。

凍結影格

凍結影格是指轉譯時間超過 700 毫秒的 UI 影格。這會造成問題,因為應用程式在顯示時似乎停滯不動,且在影格轉譯時幾乎有整整一秒不會回應使用者輸入內容。建議您對應用程式進行最佳化調整,讓單一影格的轉譯時間不超過 16 毫秒,確保 UI 的流暢度。不過,在應用程式啟動期間或轉換至其他畫面時,由於應用程式必須加載檢視畫面、安排畫面版面配置,並從頭開始執行初始繪圖作業,初始影格所需的繪圖時間通常都會超過 16 毫秒。為此,Android 會分別追蹤凍結影格和轉譯速度緩慢情形。應用程式中不應出現轉譯時間超過 700 毫秒的影格。

為協助提升應用程式品質,Android 會自動監控應用程式的凍結影格,並在 Android Vitals 資訊主頁中顯示相關資訊。如要瞭解資料收集方式,請參閱「使用 Android Vitals 監控應用程式的技術品質」一文。

凍結影格指的是轉譯速度緩慢最嚴重的情況,因此診斷與修正問題的程序相同。

追蹤卡頓

FrameTimeline Perfetto 可協助使用者追蹤速度緩慢或 凍結影格

緩慢影格、凍結影格與 ANR 之間的關係

緩慢影格、凍結影格和 ANR 都是應用程式可能會遇到的各種卡頓形式。請參閱下表瞭解差異。

緩慢影格 凍結影格 ANR
轉譯時間 介於 16 和 700 毫秒之間 介於 700 毫秒和 5 秒之間 大於 5 秒
可見的使用者影響區域
  • RecyclerView 捲動行為異常
  • 畫面上的複雜動畫未正確呈現
  • 應用程式啟動期間
  • 從一個畫面移至另一個畫面 (例如畫面轉換)
  • 活動在前景執行時,應用程式未在 5 秒內回應輸入事件或 BroadcastReceiver (例如按鍵或螢幕觸控事件)。
  • 前景並未執行任何活動,而 BroadcastReceiver 已經長時間無法結束執行作業。

分別追蹤緩慢影格和凍結影格

在應用程式啟動期間或轉換至其他畫面時,由於應用程式必須加載檢視畫面、安排畫面版面配置,並從頭開始執行初始繪圖作業,初始影格所需的繪圖時間通常都會超過 16 毫秒。

確定優先順序並解決卡頓的最佳做法

在應用程式中嘗試解決卡頓時,請牢記下列最佳做法:

  • 找出並解決最容易重現的卡頓。
  • 優先處理 ANR。緩慢影格或凍結影格可能導致應用程式延遲,但 ANR 會導致應用程式停止回應。
  • 轉譯速度緩慢很難重現,但您可以先解決 700 毫秒的凍結影格問題。應用程式啟動或變更畫面時,這種情況最常見。

修正卡頓

如要修正卡頓,請檢查哪些影格未能在 16 毫秒內完成轉譯,然後找出問題所在。檢查是否 Record View#drawLayout 在某些影格上耗時過長。請參閱「卡頓的常見來源」一節,進一步瞭解這類問題和其他情況。

為避免產生卡頓,請在 UI 執行緒之外,以非同步方式執行長時間執行的工作。 請隨時注意執行程式碼時採用的執行緒,並在將重要工作發布至主執行緒時特別留意。

如果應用程式有複雜且重要的主要 UI (例如中央捲動清單),建議您編寫檢測設備測試,這可自動偵測轉譯速度緩慢的情形,並經常執行測試來避免迴歸問題。

卡頓的常見來源

以下各節說明使用 View 系統的應用程式中常見的卡頓來源,以及解決這些問題的最佳做法。如要瞭解如何修正 Jetpack Compose 的效能問題,請參閱「Jetpack Compose 效能」一文。

可捲動的清單

最容易產生卡頓的複雜捲動清單通常會使用 ListView,尤其會使用 RecyclerView。這兩者皆包含 Systrace 標記,因此您可以使用 Systrace 確認其是否造成應用程式中的卡頓。請傳送指令列引數 -a <your-package-name>,才能在 RecyclerView 和加入的追蹤記錄標記中顯示追蹤記錄區段。在適用情況下,請按照 Systrace 輸出內容所產生快訊中的指引進行操作。在 Systrace 內點選 RecyclerView 追蹤記錄區段,即可查看 RecyclerView 所執行工作的相關說明。

RecyclerView:notifyDataSetChanged()

如果在單一影格中看到 RecyclerView 中的每個項目正在重新繫結,並因此重新配置和重新繪製,請確保您「沒有」呼叫 notifyDataSetChanged()setAdapter(Adapter)swapAdapter(Adapter, boolean) 進行小幅更新。這些方法表示整個清單內容已變更,且會在 Systrace 中顯示為「RV FullInvalidate」。如果已變更或新增內容,請改用 SortedListDiffUtil 產生最少量的更新內容。

舉例來說,如果應用程式會從伺服器接收新版本的消息內容清單,您將這項資訊發布至轉接程式時,該程式可能會呼叫 notifyDataSetChanged(),如下所示:

Kotlin

fun onNewDataArrived(news: List<News>) {
    myAdapter.news = news
    myAdapter.notifyDataSetChanged()
}

Java

void onNewDataArrived(List<News> news) {
    myAdapter.setNews(news);
    myAdapter.notifyDataSetChanged();
}

這樣做的缺點是,如果是簡單的變動 (例如在頂端加入單一項目),RecyclerView 就不會察覺。因此,系統會指示 RecyclerView 捨棄整個快取項目狀態,因而需要重新繫結所有項目。

建議您使用 DiffUtil,方便計算及發送最少量的更新內容:

Kotlin

fun onNewDataArrived(news: List<News>) {
    val oldNews = myAdapter.items
    val result = DiffUtil.calculateDiff(MyCallback(oldNews, news))
    myAdapter.news = news
    result.dispatchUpdatesTo(myAdapter)
}

Java

void onNewDataArrived(List<News> news) {
    List<News> oldNews = myAdapter.getItems();
    DiffResult result = DiffUtil.calculateDiff(new MyCallback(oldNews, news));
    myAdapter.setNews(news);
    result.dispatchUpdatesTo(myAdapter);
}

如要通知 DiffUtil 如何檢查清單,請將 MyCallback 定義為 Callback 實作。

RecyclerView:巢狀 RecyclerView

應用程式常會建立多個 RecyclerView 例項的巢狀結構,特別是在多個水平捲動清單中有單一垂直清單的情況。Play 商店主頁面的應用程式格線就是一例。這種做法效果不錯,但需要移動大量檢視畫面。

如果在首次向下捲動頁面時發現許多內部項目正在加載,建議先檢查是否在多個內部 (水平) RecyclerView 例項之間共用 RecyclerView.RecycledViewPool。根據預設,每個 RecyclerView 都有專屬的項目集區。但是,如果畫面上會同時顯示十多個 itemViews,而所有列都顯示類似的檢視畫面類型,不同的水平清單卻無法共用 itemViews,就會發生問題。

Kotlin

class OuterAdapter : RecyclerView.Adapter<OuterAdapter.ViewHolder>() {

    ...

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        // Inflate inner item, find innerRecyclerView by ID.
        val innerLLM = LinearLayoutManager(parent.context, LinearLayoutManager.HORIZONTAL, false)
        innerRv.apply {
            layoutManager = innerLLM
            recycledViewPool = sharedPool
        }
        return OuterAdapter.ViewHolder(innerRv)
    }
    ...

Java

class OuterAdapter extends RecyclerView.Adapter<OuterAdapter.ViewHolder> {
    RecyclerView.RecycledViewPool sharedPool = new RecyclerView.RecycledViewPool();

    ...

    @Override
    public void onCreateViewHolder(ViewGroup parent, int viewType) {
        // Inflate inner item, find innerRecyclerView by ID.
        LinearLayoutManager innerLLM = new LinearLayoutManager(parent.getContext(),
                LinearLayoutManager.HORIZONTAL);
        innerRv.setLayoutManager(innerLLM);
        innerRv.setRecycledViewPool(sharedPool);
        return new OuterAdapter.ViewHolder(innerRv);

    }
    ...

如果要進一步最佳化,也可以對內部 RecyclerViewLinearLayoutManager 呼叫 setInitialPrefetchItemCount(int)。舉例來說,如果一列總是會顯示 3.5 個項目,請呼叫 innerLLM.setInitialItemPrefetchCount(4)。這會向 RecyclerView 發出信號,指出在水平列即將於畫面中顯示時,如果 UI 執行緒中有剩餘的時間,就會嘗試預先擷取內部的項目。

RecyclerView:加載過多或 Create 耗時過長

在大多數情況下,RecyclerView 的預先擷取功能可在 UI 執行緒處於閒置狀態時提前完成工作,解決加載耗用資源的問題。如果在影格處理期間 (而不是在「RV Prefetch」區段中) 看到加載,請確認您使用支援的裝置測試,並使用最新版本的支援資料庫。預先擷取功能僅適用於 Android 5.0 API 級別 21 以上版本。

如果畫面顯示新項目時經常因加載作業而產生卡頓,請確認僅使用所需的檢視畫面類型。RecyclerView 內容中的檢視畫面類型越少,畫面顯示新項目類型所需的加載作業就越少。在合理情況下,請盡可能合併檢視畫面類型。如果在類型之間只變更一個圖示、顏色或一小段文字,您可以在繫結時更改,並且避免加載,這可以減少應用程式的記憶體用量。

如果檢視畫面類型沒問題,就要設法降低加載作業成本。您可以減少不必要的容器和結構檢視畫面,並考慮使用 ConstraintLayout 建構 itemViews,這有助於減少結構檢視畫面。

如果想要進一步提升效能,而且項目階層相當簡單,也不需要複雜的主題設定和樣式功能,不妨自行呼叫建構函式。但是,捨棄 XML 的簡化效果和功能通常並不值得。

RecyclerView:繫結耗時過長

繫結 (也就是 onBindViewHolder(VH, int)) 必須非常簡單,除了最複雜的項目之外,所需時間通常都不到一毫秒。繫結必須只從轉接程式的內部項目資料中取用一般 Java 物件 (POJO) 項目,並在 ViewHolder 中對檢視畫面呼叫 setter。如果 RV OnBindView 耗時過長,請確認僅在繫結程式碼中執行最少的工作。

如果是使用基本 POJO 物件保留轉接程式中的資料,只要使用資料繫結程式庫,即可完全避免在 onBindViewHolder 中編寫繫結程式碼。

RecyclerView 或 ListView:版面配置或繪製作業耗時過長

如果是繪製作業和版面配置相關問題,請參閱有關版面配置效能轉譯效能的章節。

ListView:加載作業

如果不夠小心,就可能在 ListView 中停用回收功能。如果畫面顯示每個項目時都出現加載作業,請檢查 Adapter.getView() 實作項目是否正在使用、重新繫結和傳回 convertView 參數。如果 getView() 實作項目總是在加載,應用程式就無法享有 ListView 回收功能的好處。幾乎在所有情況下,getView() 的結構都必須類似於下列實作項目:

Kotlin

fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
    return (convertView ?: layoutInflater.inflate(R.layout.my_layout, parent, false)).apply {
        // Bind content from position to convertView.
    }
}

Java

View getView(int position, View convertView, ViewGroup parent) {

    if (convertView == null) {
        // Only inflate if no convertView passed.
        convertView = layoutInflater.inflate(R.layout.my_layout, parent, false)
    }
    // Bind content from position to convertView.
    return convertView;
}

版面配置效能

如果 Systrace 顯示 Choreographer#doFrameLayout 區段正在執行太多工作,或者執行工作的頻率過高,就表示出現版面配置效能問題。應用程式的版面配置效能,取決於檢視區塊階層的哪個部分出現版面配置參數或輸入內容異動。

版面配置效能:成本

如果區段所需時間超過數毫秒,就表示 RelativeLayoutsweighted-LinearLayouts 可能出現最糟的巢狀結構效能問題。由於這類版面配置會觸發子項的多項測量/版面配置傳遞作業,如果為這類版面配置建立巢狀結構,可能會導致巢狀深度出現 O(n^2) 行為。

除了階層最低的分葉節點以外,請盡量避免使用 RelativeLayoutLinearLayout 權重功能。您可以透過下列方式進行:

  • 重新整理結構檢視畫面。
  • 定義自訂版面配置邏輯。如需具體範例,請參閱「最佳化版面配置階層」一文。您可以嘗試轉換為 ConstraintLayout,這可以提供類似的功能,但不會有效能上的缺點。

版面配置效能:頻率

畫面顯示新內容時,例如新項目捲動至 RecyclerView 中的檢視畫面,就會出現版面配置。如果每個影格都有大量版面配置,就可能是在建立版面配置動畫,而這很可能導致掉格。

一般來說,動畫必須在 View 的繪圖屬性上執行,例如下列屬性:

比起邊框間距、邊界等版面配置屬性,變更這些屬性的成本要低許多。一般來說,變更檢視畫面繪圖屬性的成本也更低,方法是呼叫可觸發 invalidate() 的 setter,然後在下一個影格中呼叫 draw(Canvas)。這會重新記錄無效檢視畫面的繪圖作業,通常也會比版面配置的成本低許多。

轉譯效能

Android UI 分兩個階段運作:

  • 在 UI 執行緒上記錄 View#draw,這個階段會在每個無效檢視畫面上執行 draw(Canvas),並可能會在自訂檢視畫面或程式碼中叫用呼叫。
  • RenderThread 上執行 DrawFrame,這會在原生 RenderThread 上執行,但以「記錄 View#draw」階段產生的工作為基礎運作。

轉譯效能:UI 執行緒

如果「記錄 View#draw」需要很長的時間,通常是因為在 UI 執行緒上繪製點陣圖所致。繪製點陣圖時會使用 CPU 算繪功能,因此一般應盡量避免這種情況。您可以搭配使用方法追蹤記錄功能和 Android CPU 分析器,查看這是否為問題所在。

當應用程式要先修飾再顯示點陣圖時,通常就會繪製點陣圖,例如加上圓角等裝飾:

Kotlin

val paint = Paint().apply {
    isAntiAlias = true
}
Canvas(roundedOutputBitmap).apply {
    // Draw a round rect to define the shape:
    drawRoundRect(
            0f,
            0f,
            roundedOutputBitmap.width.toFloat(),
            roundedOutputBitmap.height.toFloat(),
            20f,
            20f,
            paint
    )
    paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.MULTIPLY)
    // Multiply content on top to make it rounded.
    drawBitmap(sourceBitmap, 0f, 0f, paint)
    setBitmap(null)
    // Now roundedOutputBitmap has sourceBitmap inside, but as a circle.
}

Java

Canvas bitmapCanvas = new Canvas(roundedOutputBitmap);
Paint paint = new Paint();
paint.setAntiAlias(true);
// Draw a round rect to define the shape:
bitmapCanvas.drawRoundRect(0, 0,
        roundedOutputBitmap.getWidth(), roundedOutputBitmap.getHeight(), 20, 20, paint);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.MULTIPLY));
// Multiply content on top to make it rounded.
bitmapCanvas.drawBitmap(sourceBitmap, 0, 0, paint);
bitmapCanvas.setBitmap(null);
// Now roundedOutputBitmap has sourceBitmap inside, but as a circle.

如果這是您要在 UI 執行緒上執行的工作類型,您可以改為在背景的解碼執行緒中執行這項作業。在某些情況下 (例如前述範例),甚至可以在繪圖的同時執行這項工作。因此,如果您的 DrawableView 程式碼類似如下:

Kotlin

fun setBitmap(bitmap: Bitmap) {
    mBitmap = bitmap
    invalidate()
}

override fun onDraw(canvas: Canvas) {
    canvas.drawBitmap(mBitmap, null, paint)
}

Java

void setBitmap(Bitmap bitmap) {
    mBitmap = bitmap;
    invalidate();
}

void onDraw(Canvas canvas) {
    canvas.drawBitmap(mBitmap, null, paint);
}

就可以替換為以下程式碼:

Kotlin

fun setBitmap(bitmap: Bitmap) {
    shaderPaint.shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
    invalidate()
}

override fun onDraw(canvas: Canvas) {
    canvas.drawRoundRect(0f, 0f, width, height, 20f, 20f, shaderPaint)
}

Java

void setBitmap(Bitmap bitmap) {
    shaderPaint.setShader(
            new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP));
    invalidate();
}

void onDraw(Canvas canvas) {
    canvas.drawRoundRect(0, 0, width, height, 20, 20, shaderPaint);
}

您也可以透過這種方式加上背景保護 (例如在點陣圖上方繪製漸層) 和圖片濾鏡 (使用 ColorMatrixColorFilter),也就是修改點陣圖的另外兩種常見作業。

如果您是基於另一種原因繪製點陣圖 (也許是做為快取使用),請嘗試將其繪製為直接傳遞至 ViewDrawable 的硬體加速 Canvas。如有必要,也可以考慮透過 LAYER_TYPE_HARDWARE 呼叫 setLayerType(),快取複雜的轉譯輸出內容,同時仍能利用 GPU 轉譯。

轉譯效能:RenderThread

部分 Canvas 作業的記錄成本很低,但會在 RenderThread 上會觸發高成本的運算作業。Systrace 一般會在快訊中指出這些情況。

建立大型路徑動畫

在傳遞至 View 的硬體加速 Canvas 上呼叫 Canvas.drawPath() 時,Android 會先在 CPU 中繪製這些路徑,再將這些路徑上傳至 GPU。如果路徑較大,請避免逐一編輯每個影格中的路徑,這樣才能有效率地快取和繪製路徑。drawPoints()drawLines()drawRect/Circle/Oval/RoundRect() 較有效率,即使最終使用較多繪製呼叫,也建議使用這些項目。

Canvas.clipPath

clipPath(Path) 會觸發消耗大量資源的裁剪行為,在一般情況下必須避免使用。請盡可能選擇繪製圖形,而不要裁剪為非矩形。這種方式效能更好,且支援反鋸齒功能。舉例來說,以下 clipPath 呼叫能以不同形式表示:

Kotlin

canvas.apply {
    save()
    clipPath(circlePath)
    drawBitmap(bitmap, 0f, 0f, paint)
    restore()
}

Java

canvas.save();
canvas.clipPath(circlePath);
canvas.drawBitmap(bitmap, 0f, 0f, paint);
canvas.restore();

可改用以下形式表示上述範例:

Kotlin

paint.shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
// At draw time:
canvas.drawPath(circlePath, mPaint)

Java

// One time init:
paint.setShader(new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP));
// At draw time:
canvas.drawPath(circlePath, mPaint);
點陣圖上傳作業

Android 會以 OpenGL 紋理顯示點陣圖,並在影格首次顯示點陣圖時,將點陣圖上傳至 GPU。這在 Systrace 中會顯示為「Texture upload(id) width x height」。這項作業可能需要數毫秒 (如圖 2 所示),但系統必須使用 GPU 顯示圖片。

如果作業時間過長,請先檢查追蹤記錄中的寬度和高度數值。請確保要顯示的點陣圖沒有明顯大於畫面上的顯示區域。如果明顯較大,代表會浪費上傳時間和記憶體。一般來說,點陣圖載入程式庫可讓您要求大小合適的點陣圖。

在 Android 7.0 中,點陣圖載入程式碼作業 (一般由程式庫執行) 可呼叫 prepareToDraw(),在需要點陣圖前提早觸發上傳程序。這樣一來,上傳作業會在 RenderThread 處於閒置狀態時提早進行。只要知道點陣圖為何,這項作業的執行時間就可以設在解碼後,或點陣圖繫結至檢視畫面時。在理想情況下,點陣圖載入程式庫會自動完成此作業,但如果您要自行管理,或確保不會在較新型裝置中進行上傳作業,可以在自己的程式碼中呼叫 prepareToDraw()

應用程式在上傳大型點陣圖的影格中花費大量時間
圖 2. 應用程式在上傳大型點陣圖的影格中花費大量時間。使用 prepareToDraw() 解碼時,請縮減點陣圖大小或提早觸發上傳作業。

執行緒排程延遲

執行緒排程器是 Android 作業系統的一部分,負責決定必須執行哪些系統中的執行緒,以及執行的時機和時間長度。

有時候,如果應用程式的 UI 執行緒遭到封鎖或未執行,就會出現卡頓。Systrace 使用不同的顏色 (如圖 3 所示) 代表執行緒狀態,包括「休眠」(灰色)、「可執行」(藍色:可以執行,但排程器尚未指定執行)、「正在執行」(綠色) 或「不中斷休眠」(紅色或橘色)。對執行緒排程延遲所造成的卡頓問題偵錯時,這非常實用。

醒目顯示 UI 執行緒處於休眠狀態的期間
圖 3. 醒目顯示 UI 執行緒處於休眠狀態的期間。

在應用程式執行作業中造成長時間停頓的通常是繫結器呼叫,這是 Android 的處理序間通訊 (IPC) 機制。在較新版本的 Android 上,這是 UI 執行緒停止執行最常見的原因之一。一般而言,解決方法是避免呼叫會發出繫結器呼叫的函式。如果無法避免,請快取該值,或將工作移至背景執行緒。隨著程式碼集擴大,可能會因叫用某些低層級方法而不小心加入繫結器呼叫,但只要使用追蹤記錄,就可以找出並修正這類呼叫。

如果有繫結器交易,可以使用下列 adb 指令擷取呼叫堆疊:

$ adb shell am trace-ipc start
… use the app - scroll/animate ...
$ adb shell am trace-ipc stop --dump-file /data/local/tmp/ipc-trace.txt
$ adb pull /data/local/tmp/ipc-trace.txt

有時候,getRefreshRate() 等看似無害的呼叫可能會觸發繫結器交易,如果經常呼叫,還會造成嚴重問題。定期追蹤記錄有助於找出並修正問題。

在 RV 快速滑過動作中,UI 執行緒因繫結器交易而處於休眠狀態。請確保使用明確的繫結邏輯,並運用 trace-ipc 追蹤及移除繫結器呼叫。
圖 4. 在 RV 快速滑過動作中,UI 執行緒因繫結器交易而處於休眠狀態。請確保使用簡單的繫結邏輯,並運用 trace-ipc 追蹤及移除繫結器呼叫。

如果沒有看到繫結器活動,但 UI 執行緒仍沒有執行,請確認您等候的目標沒有鎖定,或其實是其他執行緒上的作業。一般來說,UI 執行緒不須等候其他執行緒的結果,其他執行緒必須將資訊發布至 UI 執行緒。

物件配置與垃圾收集

自從 Android 5.0 將 Android 執行階段導入為預設執行階段後,物件配置和垃圾收集 (GC) 問題造成的影響就大幅降低,但這類額外工作仍可能拖慢執行緒的速度。您可以針對一秒內發生次數不多的少有事件進行配置,例如使用者輕觸按鈕的事件,但請注意每項配置作業都會增加成本。如果配置作業位於經常呼叫的緊密迴圈,請考慮避免進行配置,減輕 GC 的負載。

Systrace 會顯示 GC 是否經常執行,Android 記憶體分析器則會顯示配置作業的來源。如果盡可能避免進行配置 (特別是在緊密迴圈中),就不太可能會出現問題。

HeapTaskDaemon 上的 GC 耗時 94 毫秒
圖 5. HeapTaskDaemon 執行緒上的 GC 耗時 94 毫秒。

在較新版本的 Android 上,GC 通常會在名為 HeapTaskDaemon 的背景執行緒上執行。大量配置作業可能代表 GC 耗用更多 CPU 資源,如圖 5 所示。