設定應用程式記憶體預算

應用程式記憶體預算可讓應用程式自行宣告記憶體預算,當應用程式用量超過設定的預算時,系統就會減少記憶體用量。這項功能特別適用於系統和套裝應用程式,或指定記憶體受限裝置的應用程式,開發人員知道預期的記憶體工作集 (應用程式主動需要用於使用者目前操作的記憶體),並希望確保應用程式不會使用過多系統共用 RAM 資源。

系統會使用記憶體淘汰和交換功能,移除最近未使用的記憶體頁面,將應用程式的記憶體用量集中在目前的工作集,藉此維持預算平衡。當應用程式超出宣告的預算時,作業系統會專門針對該應用程式回收:

  1. 乾淨的檔案備份頁面 (例如閒置程式碼和對應資產) 會優先遭到移除,因為系統可在需要時從儲存空間重新讀取這些頁面。
  2. 已變更的檔案支援頁面會寫回儲存空間並遭到淘汰。
  3. 匿名記憶體頁面 (例如堆積分配量) 會經過壓縮並交換至 zRAM。

只要工作集不超過預算,應用程式就能正常運作,且使用的記憶體不會超過設定的預算。作業系統會移除未使用的記憶體,並將閒置的堆積頁面壓縮至交換空間,以便在不終止程序的情況下,將記憶體配置限制在一定範圍內。

應用程式超出預算時會發生什麼情況

超出記憶體預算不會導致系統終止或當機。相反地,當應用程式使用的記憶體超過預算時,OS 會尋找應用程式一段時間未使用的記憶體,並安全地將其收起,直到再次需要為止。這樣一來,系統就能分配新的應用程式記憶體,並將整體應用程式的記憶體用量控制在預算範圍內。

只要應用程式在任何指定時間的需求 (工作集) 與設定的預算之間有餘裕,應用程式就會繼續正常執行。如果應用程式設定的預算小於工作集,執行階段的效能可能會降低。

如要瞭解 OS 如何追蹤及回收記憶體,請參閱記憶體架構指南:

在 Android 資訊清單中宣告預算

在 AndroidManifest.xml 中宣告記憶體預算,是定義預算的主要方法,也是建議做法。這項功能不需要執行階段程式碼,會在程序啟動時立即生效,並為作業系統提供明確的合約。

<memory-budget> 宣告會在搭載 Android 17 QPR2 (API 級別 37.2) 以上版本的裝置上生效。在較低版本的 Android 上,平台資訊清單剖析器會安全地忽略無法辨識的 XML 元素,因此您可以採用 <memory-budget>,而不會影響回溯相容性。

宣告基準預算

對大多數應用程式來說,只要定義單一預算即可。直接在 <application> 標記內宣告 <memory-budget> 元素:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.simpleapp">

    <application
        android:label="@string/app_name">

        <!-- Baseline budget for the application -->
        <memory-budget android:maxMb="256" />

    </application>
</manifest>

這會為套件的所有程序和狀態設定 256 MB 的常駐記憶體預算。當應用程式的記憶體用量超過 256 MB 時,作業系統會使用淘汰和交換機制,刪除閒置的記憶體頁面。

根據處理狀態調整預算

應用程式所需的記憶體用量取決於使用者是否可見:

  • 前景:程序正在託管與使用者互動的可見 Activity。由於 UI 和圖形處於啟用狀態,這個狀態通常會占用最多記憶體。
  • 可察覺:使用者可察覺到程序,但程序不會託管可見視窗 (例如託管媒體播放前景服務、主動背景下載、即時路況導航或主動輸入法)。
  • 背景:程序正在執行背景工作、接收器或資料同步。預期會維持最小資源用量。

您可以宣告多個 <memory-budget> 子句,以比對這些狀態:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.simpleapp">

    <application
        android:label="@string/app_name">

        <!-- Default budget for visible foreground UI -->
        <memory-budget android:maxMb="200" />

        <!-- Tighter budget when playing audio in background -->
        <memory-budget
            android:maxMb="120"
            android:state="perceptible" />

        <!-- Minimal budget when fully in background -->
        <memory-budget
            android:maxMb="48"
            android:state="background" />

    </application>
</manifest>

不一定要有不含 android:state 的基本子句;如果只指定特定州的子句 (例如 android:state="background"),其他州就不會受到應用程式預算限制。如果包含,不含 android:state 的子句會做為未指定狀態 (例如前景) 的預設備用,當應用程式轉換為 perceptible 或 background 狀態時,後續更嚴格的子句會覆寫該子句。

為每個狀態調整預算時,請指定該狀態的使用中工作集,而非尖峰常駐記憶體 (RSS 高水位標記):

  • foreground 預算大小,確保影格流暢轉譯:為前景 UI 的有效工作集提供足夠的空間,避免同步回收延遲影格轉譯。如果應用程式包含需要大量記憶體的獨立工作流程 (例如高解析度圖片或影片編輯器),請考慮將該活動移至專屬程序 (請參閱「多程序應用程式」),以便獨立分配預算,並在使用者退出時終止。
  • 嚴格限制無頭工作的background大小:現場遙測中背景 RSS 偏高,通常反映出離開前景後保留的 UI 分配 (請參閱「將繫結服務與記憶體密集型 UI 放在同一位置」和「釋出記憶體以回應事件」),或背景索引和同步期間的一次性檔案讀取。由於無頭背景工作沒有影格期限,因此當背景同步作業短暫分配超出預算時,系統會移除一次性檔案快取頁面,並將不活躍的堆積頁面交換至 zRAM,這樣就不會造成使用者可見的卡頓,同時防止背景尖峰移除使用者的前景應用程式。

如需逐步工作流程,請參閱「測量工作集並校正預算」。

預算狀態與處理狀態的對應關係

平台會根據 RunningAppProcessInfo.importance,將資訊清單預算狀態對應至執行階段程序狀態,而 Activity Manager 會根據程序本身的作用中元件,以及其他應用程式傳入的任何服務繫結或內容供應器查詢,計算出該狀態:

  • foreground:主動、可見的使用者互動,例如託管已恢復的 Activity、在螢幕關閉時維持頂層應用程式狀態,或託管由頂層前景應用程式主動繫結或查詢的服務或內容供應器。
  • perceptible:使用者可感知但沒有可見視窗的工作負載,例如主動媒體播放、即時路況導航、相機或麥克風擷取、主動背景下載、資料同步前景服務,或由前景用戶端使用 BIND_NOT_FOREGROUND 等旗標繫結的服務 (請參閱「使用 BIND 旗標控制繼承」)。雖然內部平台指標可能會將部分工作負載標示為 PROCESS_STATE_IMPORTANT_FOREGROUND,但該內部常數並不代表可見視窗,且受 perceptible 預算控管。
  • background:使用者無法立即察覺的工作,例如背景工作、鬧鐘和廣播接收器。

下表說明執行階段重要性層級與資訊清單狀態的對應關係:

資訊清單 android:state 執行階段重要性 (RunningAppProcessInfo) 常見元件
foreground IMPORTANCE_FOREGROUND
IMPORTANCE_TOP_SLEEPING
已恢復顯示的 Activity、螢幕鎖定時位於頂端的應用程式、由頂端應用程式繫結的服務或內容供應器
perceptible IMPORTANCE_FOREGROUND_SERVICE
IMPORTANCE_VISIBLE
主動媒體播放、導航、下載或同步前景服務;與前景服務或可見標記繫結的服務
background IMPORTANCE_PERCEPTIBLE
IMPORTANCE_CANT_SAVE_STATE
IMPORTANCE_SERVICE
背景工作、接收器、背景同步

如要進一步瞭解用戶端繫結如何提升服務或供應商主機程序,以及在用戶端解除繫結或移出畫面後,程序如何轉換回 background 或 cached,請參閱「服務繫結和程序狀態」。

當程序進入快取狀態 (IMPORTANCE_CACHED) 且沒有任何有效元件時,平台會釋出其有效記憶體預算,並透過標準快取程序回收機制管理其記憶體。

檢查應用程式的程序狀態和預算

在開發期間,您可以透過 ADB 查詢 Activity Manager,檢查應用程式的活動程序狀態和重要性:

adb shell dumpsys activity processes <package-name>

以下簡短範例顯示執行前景服務的應用程式,其程序記錄和 OOM 控制項目:

ACTIVITY MANAGER RUNNING PROCESSES (dumpsys activity processes)
  All known processes:
  *APP* UID 10123 ProcessRecord{edf056c 3919:com.example.app/u0a123}
    pid=3919
    oom adj: max=1001 curRaw=200 setRaw=200 cur=200 set=200
    curProcState=4 mRepProcState=4 setProcState=4 lastStateTime=-42s360ms
    hasStartedServices=true
    mHasForegroundServices=true forcingToImportant=null
...
  Process OOM control (48 total):
    Proc #22: prcp  F/S/FGS  ---NFU-TI  t: 0 3919:com.example.app/u0a123 (fg-service)
        oom: max=1001 curRaw=200 setRaw=200 cur=200 set=200
        state: cur=FGS  set=FGS  lastRss=0.00 lastCachedRss=0.00

在這個輸出內容中:

  • curProcState=4 和 state: cur=FGS 表示程序處於前景服務狀態。如果應用程式正在託管可見的活動,則會顯示為 TOP。如果正在執行重要的前景元件 (例如下載或同步處理),則會顯示為 IMPF。
  • 在程序 OOM 控制表中,prcp 表示程序是在可感知優先順序層級下評估,對應至 perceptible 預算。

如要檢查應用程式目前的常駐記憶體用量,請按照下列步驟操作:

adb shell dumpsys meminfo <package-name>

多程序應用程式

如果應用程式將工作分配給多個程序,請在 <processes> 中使用 <process> 標記,設定專屬程序預算。

舉例來說,假設有一個音樂串流應用程式 (com.example.radio):

  1. 主要程序:代管可見的使用者介面和音訊播放引擎 (MediaSessionService 搭配 mediaPlayback 前景服務)。當程序可見時,會在 180 MB 的前景預算下運作。當使用者離開應用程式,但音樂繼續播放時,程序會進入 perceptible 狀態,此時 64 MB 的預算足以用於播放引擎和音訊緩衝區。
  2. 同步處理程序 (:sync):專門執行背景中繼資料同步處理和下載索引的程序。由於這項程序只會在背景執行,因此您不需要明確宣告 state="background",只要套用單一預算即可。
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.radio">

    <application
        android:label="@string/app_name">

        <!-- Package baseline: main process with UI and audio playback -->
        <memory-budget android:maxMb="180" />

        <!-- Tighter budget when audio plays in the background -->
        <memory-budget
            android:maxMb="64"
            android:state="perceptible" />

        <processes>
            <!-- Dedicated background sync process -->
            <process android:process=":sync">
                <memory-budget android:maxMb="32" />
            </process>
        </processes>

        <service
            android:name=".playback.AudioPlayerService"
            android:foregroundServiceType="mediaPlayback"
            android:exported="false" />

        <service
            android:name=".sync.PlaylistSyncService"
            android:process=":sync"
            android:exported="false" />

    </application>
</manifest>

在資訊清單中宣告 <processes> 區塊時,請僅針對宣告 <memory-budget> 的程序列出 <process> 項目。如果只想為特定子程序設定預算,並讓應用程式的其他部分維持在裝置的系統限制內,可以直接省略 <application> 下方的 <memory-budget> 元素。

任何子程序使用的記憶體都會計入其程序預算和封閉套件預算。如果程序在執行階段超出程序預算或套件預算 (以先達到的門檻為準),就會面臨記憶體壓力。

調整高密度螢幕的預算

如果應用程式的記憶體用量會隨著一次在螢幕上繪製的像素數量而大幅增加 (例如相片庫應用程式會快取與螢幕大小相同的點陣圖),Android 會提供兩種替代機制,可根據螢幕規格動態調整預算:

  • 依螢幕密度值區 (android:additionalMbPerDensity) 縮放:依螢幕密度比率相對於 mdpi (1.0x/160 dpi) 的平方比例增加 MB,因為 2D 光柵可繪項目和 UI 資產會依寬度和高度縮放:

    <!-- Baseline 180MB + 16MB multiplied by (density ratio)^2 -->
    <memory-budget
        android:maxMb="180"
        android:additionalMbPerDensity="16" />
    

    在 mdpi 螢幕 (1.0x) 上,預算為 180 + 16 × 12 = 196 MB。在 xxhdpi 螢幕 (3.0x) 上,預算會調整為 180 + 16 × 32 = 324 MB。

  • Scale by physical display resolution (android:additionalBytesPerDisplayPixel):直接依據實體螢幕像素 (寬度 × 高度) 增加位元組。這項功能非常適合用於分配全螢幕圖像介面、算繪緩衝區或完整解析度相片快取的應用程式,因為這類應用程式的記憶體消耗量會直接隨著原始顯示像素數量 (而非 UI 密度值區) 增加:

    <!-- Baseline 128MB + 16 bytes per physical display pixel -->
    <!-- For example, a 4-byte RGBA full-screen buffer with double or quadruple buffering -->
    <memory-budget
        android:maxMb="128"
        android:additionalBytesPerDisplayPixel="16" />
    

    在 1080p 螢幕 (1080 × 2400 &approx; 2.59M 像素) 上,這會為基準預算增加 &approx; 41.4 MB。在 1440p 螢幕 (1440 × 3120 &approx; 4.49M 像素) 上,會增加 &approx; 71.8 MB。

這兩個屬性可擇一使用。請選擇與應用程式主要縮放比例係數相符的屬性,並避免在同一個子句中合併兩者。

針對裝置板型規格進行特化

在手機、平板電腦和 Wear OS 上推出 APK 時,請使用 android:feature 屬性調整不同硬體目標的預算。

Wear OS 手錶的 RAM 有限,應用程式的 UI 和功能集也較簡單。您可以為 watch 功能宣告更嚴格的預算:

<!-- General phone and tablet baseline -->
<memory-budget android:maxMb="180" />

<!-- Wear OS override: simpler UI and constrained hardware -->
<memory-budget
    android:maxMb="48"
    android:feature="watch" />

解決規則:最後一個適用的子句會生效

為應用程式或程序定義多個 <memory-budget> 元素時,系統會按照資訊清單中的宣告順序評估這些元素。系統會強制執行最後一個適用的預算子句。

由於系統會採用最後一個適用的預算,因此順序很重要。請一律先列出最通用的基準預算,再列出更具體的覆寫條款 (例如特定州或特定硬體的條款)。

XML 屬性參考資料

所有記憶體大小屬性都以 MB 為單位,並對應至 Linux cgroup memory.current 費用 (不含 Zygote 等共用記憶體)。

屬性 格式 預設 說明
android:maxMb 整數 (> 0) 必要 基準常駐記憶體預算限制 (MB)。
android:state 列舉 不限 這項預算適用的程序狀態:foreground、perceptible 或 background。如果省略,該子句會做為任何未指定狀態的備用方案。
android:additionalMbPerDensity 整數 (≥ 0) 0 額外 MB 乘以顯示密度比率相對於 mdpi (1.0x) 的平方。
android:additionalBytesPerDisplayPixel 整數 (≥ 0) 0 每個實體顯示像素 (寬度 × 高度) 分配的額外位元組,適用於介面緩衝區和點陣圖。
android:feature 字串 不限 將子句限制為宣告特定硬體功能的裝置:watch、automotive 或 leanback。

評估工作集並調整預算

理想的預算應略高於應用程式在特定狀態下的工作集,而非不受限制的峰值。如要找出這個臨界點,請執行實際的使用者流程,逐步降低預算,並觀察應用程式何時開始受到影響。

瞭解工作集與常駐記憶體

如果沒有預算,應用程式的常駐記憶體用量 (memory.current) 會保留兩種記憶體:

  • 工作集:Java、Kotlin 和原生堆積,以及目前使用者流程反覆存取的熱門檔案支援頁面 (DEX 程式碼、原生程式庫和資產)
  • 冷閒置:先前畫面的點陣圖快取、一次性啟動緩衝區、分配爆發後留下的堆積閒置,以及讀取一次後就未再觸碰的檔案頁面

預算達到無限制高峰時,絕不會回收冷閒置。如果預算低於工作集,核心會換出堆積頁面,並移除應用程式立即錯誤返回的程式碼頁面。這些工作集重新錯誤會耗用 CPU 作業時間和電池電力,一旦進入 UI 執行緒,也會耗用影格。

您要找的是膝部:最低預算,回收作業只會移除冷閒置,因此重新錯誤、CPU 作業時間和影格時間會維持在沒有預算時的狀態。

準備測試環境

設定可重複執行的測試,模擬實際工作環境:

  • 請校正您發布的 R8 最佳化、不可偵錯的發布子版本。偵錯版本會使用更多記憶體,因此針對偵錯版本調整的預算對於發布版本來說過高。
  • 在搭載 Android 17 QPR2 (API 級別 37.2) 以上版本的裝置或模擬器上進行測試。建議使用 userdebug 版本或不含 Google Play 的模擬器映像檔,因為部分裝置只允許您在執行 adb root 後,讀取下列各節中的 cgroup 檔案。
  • 使用 Jetpack Macrobenchmark、UI Automator 或 adb shell input 編寫 30 到 60 秒的使用者流程指令碼,每個要預算狀態各一次。閒置的應用程式不會提供任何工作集資訊。
    • foreground:捲動密集清單和媒體格線、在主要目的地之間移動、開啟選單和對話方塊,以及執行動畫
    • perceptible:在可感知的工作 (例如音訊播放或下載) 繼續進行時離開可見視窗,讓工作執行 10 到 15 秒,然後返回應用程式
    • background:在應用程式沒有可見 UI 時,觸發同步、WorkManager 工作 (例如使用 adb shell cmd jobscheduler run -f <package-name> <job-id>)、已啟動的服務或廣播接收器

在背景工作執行時,評估 background 預算。程序快取後,平台會釋出預算 (請參閱「預算狀態與程序狀態的對應方式」),因此閒置的快取程序不會提供任何 background 預算相關資訊。

衡量基準

先從完全沒有預算的執行作業開始,瞭解正常情況。使用不含 <memory-budget> 宣告的版本,並從全新程序 (adb shell am force-stop <package-name>) 開始每次執行。

每個應用程式程序都有自己的 cgroup v2 記憶體控制器。請從 /proc/<PID>/cgroup 的 0:: 行查詢其路徑,而不是自行建構路徑,因為 cgroup 版面配置可能會因裝置而異。請將每個裝置指令以單引號字串的形式傳遞至 adb shell,讓裝置殼層看到引號:

PID=$(adb shell pidof -s <package-name>)
CG=/sys/fs/cgroup$(adb shell "sed -n 's/^0:://p' /proc/$PID/cgroup")
adb shell "cat $CG/memory.current $CG/memory.swap.current"
adb shell "grep -E '^(anon|file|workingset_refault_.*) ' $CG/memory.stat"

讀取這些檔案的成本很低,不會干擾應用程式,因此您可以在執行期間每秒取樣一次:

  • memory.current:程序使用的常駐記憶體,以位元組為單位。這是 <memory-budget> 限制的數量。
  • memory.swap.current:壓縮至 zRAM 交換空間的記憶體,以位元組為單位
  • memory.stat 中的 anon 和 file:常駐匿名和檔案支援的記憶體,以位元組為單位
  • workingset_refault_anon 和 workingset_refault_file (memory.stat):應用程式再次需要已換出或收回的頁面時,系統會累計次數。這些計數器是預算過低的主要指標。

如果發生權限錯誤,請執行 adb root,然後再試一次。如果無法取得根層級,adb shell dumpsys meminfo <package-name> 會提供較粗略的檢視畫面。在 App Summary 中,Java Heap、Native Heap 和 Stack 大多是匿名記憶體,而 Code 則是以檔案為備份。軟體點陣圖會計入 Native Heap 和預算,而硬體點陣圖和 Graphics 則不會。測量影格時間時,請勿在迴圈中執行 dumpsys meminfo。這項工具會強制在應用程式中收集垃圾,並檢查所有記憶體對應,進而變更您要測量的內容。

候選預算

您可以嘗試候選預算,而不必重新建構應用程式。使用 am memory-limiter manual 為執行中的程序設定預算,並使用 none 移除預算:

adb shell am memory-limiter manual <PID> 120MB
adb shell am memory-limiter manual <PID> none

手動覆寫會持續到該程序執行個體結束為止,並會跨越程序狀態變更,因此請先清除或重新啟動程序,再繼續操作。此外,這項設定也會將程序的硬性限制 (memory.max) 設為相同的值,因此如果候選預算低於應用程式的工作集,系統可能會終止程序,而不只是降低程序速度。並將其視為預算失敗。

您也可以從檢測引數讀取候選值,並設定 MemoryBudgetManager.processBudgetBytes,從基準測試版本進行掃描。執行階段預算只能縮減資訊清單預算,因此請在沒有 <memory-budget> 宣告的情況下執行建構作業。在程序狀態變更期間,這項設定也會保持有效,直到您呼叫 clearProcessBudget 為止。

從基準的最高 memory.current 開始,每次減少 10 到 15 MB。針對每個候選預算:

  1. 啟動新程序,將其驅動至目標狀態,然後套用預算。
  2. 先執行一次使用者流程,套用預算會逐一剔除冷啟動頁面,而您想評估的是穩定狀態,而非轉換狀態。
  3. 重設影格統計資料,並記錄起始重新錯誤計數器和程序 CPU 作業時間 (utime 和 stime,以時鐘週期為單位):

    adb shell dumpsys gfxinfo <package-name> reset
    adb shell "grep -E '^workingset_refault_(anon|file) ' $CG/memory.stat"
    adb shell "cut -d ' ' -f 14,15 /proc/$PID/stat"
  4. 執行使用者流程的評估階段。

  5. 再次記錄 memory.current、anon、file、重新錯誤計數器和 CPU 作業時間,並收集影格統計資料。dumpsys gfxinfo 可能會多次輸出相同的統計資料,因此請採用第一個符合項目,其中涵蓋整個應用程式:

    adb shell "dumpsys gfxinfo <package-name>" | grep -m 1 'Janky frames:'
    adb shell "dumpsys gfxinfo <package-name>" | grep -m 1 '95th percentile'

每個候選項目至少重複三次,並交錯執行,而非連續執行同一個候選項目的所有重複項目。請將每個結果與基準測試的分布情形比較,而非與單次測試比較。

解讀結果

隨著預算減少,應用程式會經歷三個階段:

  • Above the knee:memory.current 透過捨棄冷 file 頁面和閒置堆積,遵循預算。anon 保持平穩,重新預設值保持在基準附近,CPU 作業時間和影格時間不會變動。這就是您要達到的目標。
  • 低於膝蓋:冷頁面已消失,因此核心開始換出應用程式仍使用的堆積頁面。每次執行時,重新錯誤會跳過數百或數千個頁面,CPU 作業時間也會增加。在閒置的測試裝置上,影格時間可能幾乎不會變動,但應用程式現在會耗用 CPU 作業時間和電池電力,壓縮及解壓縮自己的工作集。
  • 遠低於膝蓋:回收和交換錯誤會落在 UI 執行緒和 RenderThread 上,且卡頓、影格時間和暖啟動時間都會增加。

與無限制基準相比,候選預算如果符合下列所有條件,就會通過,這些條件是合理的起點:

  • 程序在執行期間
  • 重新錯誤差異接近基準
  • 程序 CPU 作業時間增加不超過 5%
  • 資源浪費影格增加不超過 1 個百分點
  • P95 影格時間增加不超過 3 毫秒
  • perceptible 和 background 預算的暖恢復時間 (TotalTime 來自 adb shell am start -W) 增加不超過 15%

轉折點是通過測試的最低候選預算。舉例來說,假設某個應用程式的無限制峰值為 138 MB,前景掃描結果如下:

候選預算 memory.current anon file 重新錯誤 (頁面) P95 影格時間 政權
無 138 MB 75 MB 63 MB 0 10.5 毫秒 基準
125 MB 121 MB 74 MB 47 MB 10 10.5 毫秒 膝上
110 MB 110 MB 75 MB 35 MB 15 10.8 毫秒 膝蓋
95 MB 95 MB 74 MB 21 MB 176 11.2 毫秒 膝下
85 MB 85 MB 68 MB 17 MB 2,050 12.8 毫秒 膝下
70 MB 70 MB 54 MB 16 MB 8,771 16.5 毫秒 遠低於膝蓋

選擇預算

將 android:maxMb 設為高於臨界值 10% 到 20%,這樣一來,使用者資料、語言代碼和分配突發狀況的差異,就不會導致應用程式低於臨界值。在上述範例中,膝點為 110 MB,因此 125 MB 是不錯的選擇。

接著,安裝含有新 <memory-budget> 宣告的發布子版本,再次執行使用者流程,並檢查通過條件是否仍成立。dumpsys meminfo 會在「Memory Budget」部分顯示有效預算及其來源。

使用暖啟動驗證非前景預算

當應用程式處於該狀態時,perceptible 或 background 預算吃緊可能看起來沒問題,但當使用者返回時,就會耗費資源。也請測試回程:

  1. 執行前景流程,將應用程式移至目標狀態,並使用 am memory-limiter manual 套用候選預算。
  2. 讓可感知或背景工作執行 10 到 15 秒。
  3. 清除覆寫 (adb shell am memory-limiter manual <PID> none),然後立即使用 adb shell am start -W 重新啟動應用程式,並記錄 TotalTime。如果改為透過 MemoryBudgetManager 設定候選項目,請在應用程式傳回前呼叫 clearProcessBudget。
  4. 執行短暫的前景互動,並根據通過條件檢查重新錯誤和影格統計資料。

挑選應用程式處於該狀態和熱返回時,能通過這兩項測試的最低預算,並加上相同的 10% 至 20% 緩衝空間。

校正螢幕縮放比例

如果您使用 android:additionalBytesPerDisplayPixel,請在兩個解析度不同的螢幕上找出前景膝點,例如 1080 × 2400 (約 259 萬像素) 和 1440 × 3120 (約 449 萬像素)。將兩個轉折點之間的差異 (以位元組為單位) 除以像素數量的差異,即可得出 android:additionalBytesPerDisplayPixel。從較小螢幕的膝點減去每個像素的量,即可取得與解析度無關的基準,再加上餘裕即可取得 android:maxMb。

Runtime API (次要動態選項)

在 AndroidManifest.xml 中靜態宣告預算是幾乎所有應用程式的首選解決方案。不過,對於具有動態工作負載或執行階段實驗的應用程式,Android 提供執行階段 SDK 和 NDK API 做為次要選項。

執行階段 API 可讓您:

  • 查詢目前的記憶體用量和有效預算。
  • 動態調降程序預算。
  • 監聽超出預算事件,在作業系統觸發直接回收前,主動清除快取。

Android SDK API (MemoryBudgetManager)

自 Android 17 QPR2 (SDK 次要版本,API 級別 37.2/Build.VERSION_CODES_FULL.CINNAMON_BUN_2) 起,以 Kotlin 和 Java 編寫的應用程式可使用 MemoryBudgetManager 系統服務。

擷取服務

存取 MemoryBudgetManager 前,請先檢查 SDK_INT_FULL,確認裝置搭載 Android 17 QPR2 以上版本。如果應用程式的 minSdk 低於 36,請先檢查 SDK_INT,這樣搭載 Android 15 (API 級別 35) 以下版本的裝置在讀取 SDK_INT_FULL 時,就不會擲回 NoSuchFieldError:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA &&
    Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.CINNAMON_BUN_2
) {
    val budgetManager = context.getSystemService(MemoryBudgetManager::class.java)
}

查詢用量和預算

// Query current memory charged to this process and the package UID
val processUsageBytes = budgetManager.processCurrentUsageBytes
val packageUsageBytes = budgetManager.packageCurrentUsageBytes

// Query effective budgets (returns LIMIT_IS_DISABLED if unconstrained)
val processBudgetBytes = budgetManager.processBudgetBytes
val packageBudgetBytes = budgetManager.packageBudgetBytes

動態設定或清除預算

您可以在執行階段設定更嚴格的預算,限制輕量工作期間的記憶體用量,或在工作完成時清除記憶體:

// Set a tighter dynamic budget on the current process (e.g., 96 MB)
try {
    budgetManager.processBudgetBytes = 96L * 1024L * 1024L
} catch (e: IllegalArgumentException) {
    // Thrown if the budget is <= 0 or exceeds the manifest ceiling or system limit
    Log.e(TAG, "Requested budget exceeds manifest or system ceiling", e)
}

// Clear the dynamic process budget to restore the manifest limit (or unconstrained baseline)
budgetManager.clearProcessBudget()

<memory-budget> 資訊清單子句會在程序於 foreground、perceptible 和 background 狀態之間轉換時自動切換預算,而透過 MemoryBudgetManager 設定的動態預算則會套用單一上限,直到程式碼更新或清除為止。如果您使用執行階段 API 執行實地實驗或管理特定狀態的限制,請在生命週期轉換期間更新或清除動態預算 (例如使用 ProcessLifecycleOwner 或活動生命週期回呼,如自動調整圖片編輯器範例所示)。

效能和冪等性

在執行階段使用 MemoryBudgetManager 時,請注意下列特性:

  • 冪等性:所有預算異動 API 都具有冪等性。重複呼叫 clearProcessBudget() 或 clearPackageBudget(),或在沒有動態預算時呼叫,都是安全的無作業。同樣地,連續將 setProcessBudgetBytes() 或 setPackageBudgetBytes() 設為相同的值,不會觸發多餘的系統重新設定。
  • 呼叫頻率:設定、更新或清除預算時,會對系統服務進行跨程序呼叫,更新程序的作業系統記憶體控制器。雖然輕量,但並非完全免費。請在不同的生命週期轉換和工作里程碑 (例如進入或離開活動、開始或完成背景程序,或處理語音要求) 期間呼叫這些 API,並避免在緊密迴圈、音訊處理執行緒或每影格算繪常式中呼叫這些 API。
  • 查詢頻率:查詢屬性 processCurrentUsageBytes 和 packageCurrentUsageBytes 會從系統服務擷取即時記憶體用量。雖然查詢速度很快,但應視需要叫用 (例如在工作轉換期間或在 OnOverBudgetListener 回呼內),而不是在連續迴圈中輪詢。

監聽預算超載壓力回呼

應用程式可以註冊事件監聽器,在記憶體用量超過預算門檻時收到通知。這樣一來,應用程式就能主動執行應用程式層級的清理作業 (例如清除記憶體內點陣圖快取),同時作業系統會回收記憶體,將程序或套件維持在預算範圍內:

val cleanupThread = HandlerThread("MemoryBudgetCleanup").apply { start() }

val listener = MemoryBudgetManager.OnOverBudgetListener { budgetBytes ->
    Log.w(TAG, "Process exceeded memory budget of $budgetBytes bytes")
    // Proactively evict thread-safe caches on the background Looper
    imageTileCache.evictAll()
}

// Register on a background Looper before tightening a dynamic budget
budgetManager.registerProcessOverBudgetListener(cleanupThread.looper, listener)

// When done (for example, in onStop)
budgetManager.unregisterProcessOverBudgetListener(listener)

預算超支回呼的最佳做法:

  • 使用背景 Looper:盡可能將背景 HandlerThread Looper 傳遞至 registerProcessOverBudgetListener 或 registerPackageOverBudgetListener。在背景執行緒上執行快取清除或檔案清理作業,可避免在程序記憶體不足時,與 UI 執行緒上的影格轉譯作業發生衝突。請保留 Looper.getMainLooper,用於僅觸及主執行緒繫結 UI 狀態的事件監聽器。
  • 預期會重複回呼:預算超支回呼並非一次性通知。當用量超過預算時,作業系統會先回收冷頁,如果壓力持續,系統會持續回收記憶體,並每秒最多叫用一次事件監聽器。請將回呼設為冪等。如果應用程式只需要單一通知,或希望在清理作業之間有較長的冷卻時間,請在回呼中呼叫 unregisterProcessOverBudgetListener (或 unregisterPackageOverBudgetListener),或使用冷卻時間戳記或 AtomicBoolean 保護清理作業。
  • 在縮減預算前註冊:呼叫 setProcessBudgetBytes 或 setPackageBudgetBytes 時,如果目前的記憶體用量已超過新預算,則套用新預算後,系統會立即觸發預算超額事件。請先註冊事件監聽器,再設定預算,以免應用程式錯過初始的預算超支通知。
  • 快速:回收作業必須立即見效。在壓力下執行複雜運算會降低效能。
  • 避免配置:請勿在回呼中配置新物件或啟動新執行緒,否則可能會立即觸發作業系統直接回收。
  • 著重於高收益目標:相較於釋出許多小型物件,移除大型點陣圖、轉譯緩衝區或關閉記憶體對應檔案的效果更好。

原生 NDK API (<android/memory_budget_manager.h>)

自 Android 17 QPR2 (API 級別 37.2) 起,原生應用程式可以使用 libandroid.so 公開的 C NDK API。

CMake 設定

find_library(android-lib android)
target_link_libraries(my_native_engine PRIVATE ${android-lib})

納入標頭和查詢使用情形

#include <android/memory_budget_manager.h>

// Query current memory usage
int64_t process_usage = AMemoryBudgetManager_getProcessCurrentUsageBytes();
int64_t package_usage = AMemoryBudgetManager_getPackageCurrentUsageBytes();

// Query current budget
int64_t process_budget = 0;
AMemoryBudgetResult result = AMemoryBudgetManager_getProcessBudget(&process_budget);
if (result == AMEMORY_BUDGET_RESULT_SUCCESS) {
    // Current budget available in process_budget
} else if (result == AMEMORY_BUDGET_RESULT_LIMIT_IS_DISABLED) {
    // No budget is currently active
}

動態設定原生預算

// Set a tighter process budget (e.g. 160MB)
AMemoryBudgetResult result = AMemoryBudgetManager_setProcessBudget(160LL * 1024 * 1024);
if (result != AMEMORY_BUDGET_RESULT_SUCCESS) {
    const char* error_msg = AMemoryBudgetManager_resultToString(result);
    // Handle error (e.g. AMEMORY_BUDGET_RESULT_ERROR_EXCEEDS_MANIFEST_LIMIT)
}

// Clear the dynamic budget to resume manifest limits (or unconstrained baseline)
AMemoryBudgetManager_clearProcessBudget();

監控記憶體壓力事件

NDK 提供兩種監控記憶體事件的方法:

  1. 高階監控器 (AMemoryBudgetManager_Watcher_create):監控 ALooper 上的事件,並自動去抖動。
  2. 低階檔案描述元:AMemoryBudgetManager_getProcessMemoryPressureFd 會傳回原生檔案描述元,可直接整合至自訂 epoll 引擎迴圈。
void onMemoryPressure(int32_t event_mask, const AMemoryBudgetEvents* events, void* userdata) {
    // High-yield eviction of unused native textures or geometry caches
    purgeNativeTextureCaches();
}

// Register watcher on a background worker ALooper with a 1000ms debounce interval
AMemoryBudgetManagerWatcher* watcher = AMemoryBudgetManager_Watcher_create(
    background_looper,
    AMEMORY_BUDGET_MANAGER_EVENT_PROCESS,
    1000 /* debounce_ms */,
    &onMemoryPressure,
    NULL /* userdata */
);

// When done:
AMemoryBudgetManager_Watcher_destroy(watcher);

將監控工具繫結至背景工作執行緒的 ALooper,而非主要 UI 或轉譯執行緒,這樣快取清除作業就不會與影格轉譯作業發生衝突。請先建立監控工具,再使用 AMemoryBudgetManager_setProcessBudget 或 AMemoryBudgetManager_setPackageBudget 縮減預算,以免應用程式錯過初始的預算超支事件。如果程序或套件仍超出預算,且作業系統持續回收記憶體,監控工具會重複叫用回呼 (每個 debounce_ms 間隔最多一次)。請將回呼設為冪等,或在應用程式只需要一次性通知時,呼叫 AMemoryBudgetManager_Watcher_destroy (可安全地從回呼內部呼叫)。

Runtime API 範例

以下範例說明如何使用 Android SDK API (以 Kotlin 編寫) 和原生 NDK API (以 C++ 編寫) 實作執行階段 API。

Android SDK 範例:自動調整圖片編輯器

以下範例顯示圖片編輯應用程式 (com.example.imageeditor),其資訊清單宣告 256 MB 的上限,以容納多層畫布:

<manifest ... >
    <application ... >
        <!-- Manifest ceiling accommodates the heaviest editing workload -->
        <memory-budget android:maxMb="256" />
    </application>
</manifest>

使用者瀏覽輕量縮圖庫時,應用程式會使用 Kotlin 中的 Android SDK API,動態將程序預算縮減至 96 MB。當使用者開啟多層編輯畫布時,應用程式會清除動態預算,還原完整的 256 MB 資訊清單上限。此外,它也會在背景 HandlerThread Looper 上註冊 OnOverBudgetListener,然後套用相片庫預算,在記憶體不足時移除快取的預覽點陣圖,而不會封鎖 UI 執行緒。

package com.example.imageeditor.ui

import android.app.Activity
import android.app.MemoryBudgetManager
import android.graphics.Bitmap
import android.os.Bundle
import android.os.HandlerThread
import android.util.Log
import android.util.LruCache

class ImageEditorActivity : Activity() {

    private lateinit var budgetManager: MemoryBudgetManager
    private lateinit var cleanupThread: HandlerThread

    // Thread-safe in-memory cache for rendered preview tiles (32MB limit)
    private val previewCache = object : LruCache<String, Bitmap>(32 * 1024 * 1024) {
        override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
    }

    private val overBudgetListener = MemoryBudgetManager.OnOverBudgetListener { budgetBytes ->
        Log.w(TAG, "Process memory pressure detected (budget: ${budgetBytes / 1048576}MB). Evicting preview cache.")
        previewCache.evictAll()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        budgetManager = getSystemService(MemoryBudgetManager::class.java)
        cleanupThread = HandlerThread("MemoryBudgetCleanup").apply { start() }
    }

    override fun onStart() {
        super.onStart()
        // Register listener on a background Looper before tightening the budget
        budgetManager.registerProcessOverBudgetListener(cleanupThread.looper, overBudgetListener)
        // Constrain memory during lightweight gallery browsing
        applyGalleryBudget()
    }

    override fun onStop() {
        super.onStop()
        budgetManager.unregisterProcessOverBudgetListener(overBudgetListener)
    }

    override fun onDestroy() {
        cleanupThread.quitSafely()
        super.onDestroy()
    }

    /**
     * Called when the user enters the high-resolution editing canvas.
     * Clears the dynamic budget, restoring the full 256MB manifest ceiling.
     */
    fun enterEditingCanvas() {
        // Clear the tighter dynamic budget to restore the full manifest ceiling (256MB)
        budgetManager.clearProcessBudget()
        Log.i(TAG, "Restored manifest budget ceiling (256MB) for editing canvas")
    }

    /**
     * Called when the user exits the editor back to the thumbnail gallery.
     * Re-applies the tighter dynamic budget.
     */
    fun exitToGallery() {
        previewCache.trimToSize(8 * 1024 * 1024)
        applyGalleryBudget()
    }

    private fun applyGalleryBudget() {
        try {
            // Dynamically tighten budget to 96MB for the lightweight gallery view
            budgetManager.processBudgetBytes = 96L * 1024L * 1024L
            Log.i(TAG, "Tighter dynamic budget applied for gallery: 96MB")
        } catch (e: IllegalArgumentException) {
            Log.e(TAG, "Could not apply dynamic budget", e)
        }
    }

    companion object {
        private const val TAG = "ImageEditor"
    }
}

NDK C++ 範例:原生 3D 引擎

這個範例顯示原生 C++ 遊戲引擎根據有效的圖像品質等級管理記憶體預算,假設應用程式的資訊清單宣告 512MB 的上限,以容納高畫質圖像 (android:maxMb="512")。引擎會在背景工作執行緒的 ALooper 上啟動 AMemoryBudgetManager_Watcher_create,然後縮減低畫質預設值的預算,並在超出預算時卸載紋理 mipmap。

#include <android/memory_budget_manager.h>
#include <android/looper.h>
#include <android/log.h>

#define LOG_TAG "Native3DEngineMemory"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)

class MemoryGovernor {
public:
    MemoryGovernor() : mWatcher(nullptr) {}

    ~MemoryGovernor() {
        stopMonitoring();
    }

    // Configures process budget based on user graphics quality settings
    bool setQualityBudget(int qualityLevel) {
        int64_t targetBytes = 0;
        switch (qualityLevel) {
            case 0: // Low (budget: 128MB)
                targetBytes = 128LL * 1024 * 1024;
                break;
            case 1: // Medium (budget: 256MB)
                targetBytes = 256LL * 1024 * 1024;
                break;
            case 2: // High (budget: 512MB)
                targetBytes = 512LL * 1024 * 1024;
                break;
            default:
                // Clear dynamic override and restore manifest limit
                AMemoryBudgetManager_clearProcessBudget();
                return true;
        }

        AMemoryBudgetResult result = AMemoryBudgetManager_setProcessBudget(targetBytes);
        if (result != AMEMORY_BUDGET_RESULT_SUCCESS) {
            LOGW("Could not set quality budget: %s", AMemoryBudgetManager_resultToString(result));
            return false;
        }
        return true;
    }

    // Call on a background worker ALooper before tightening the budget with setQualityBudget
    bool startMonitoring(ALooper* backgroundLooper) {
        if (!backgroundLooper) return false;

        // Monitor process budget events, debounced to at most once every 1000ms
        mWatcher = AMemoryBudgetManager_Watcher_create(
            backgroundLooper,
            AMEMORY_BUDGET_MANAGER_EVENT_PROCESS,
            1000,
            &MemoryGovernor::onPressureEvent,
            this
        );
        return mWatcher != nullptr;
    }

    void stopMonitoring() {
        if (mWatcher) {
            AMemoryBudgetManager_Watcher_destroy(mWatcher);
            mWatcher = nullptr;
        }
    }

    void unloadUnusedTextures() {
        LOGW("Memory pressure callback triggered. Purging cached texture mipmaps...");
        // Fast, idempotent, high-yield eviction without allocating memory
    }

private:
    static void onPressureEvent(
        int32_t event_mask,
        const AMemoryBudgetEvents* events,
        void* userdata
    ) {
        auto* governor = static_cast<MemoryGovernor*>(userdata);
        governor->unloadUnusedTextures();
    }

    AMemoryBudgetManagerWatcher* mWatcher;
};