设置应用内存预算

应用内存预算允许应用自行声明内存预算,以便在应用使用的内存超出其设置的预算时,告知系统缩减其内存用量。这对于系统应用和预装应用,或针对内存受限设备的应用尤其有用,因为开发者知道其预期的内存工作集(应用为用户当前正在执行的操作而主动需要的内存),并希望确保其应用不会使用过多的系统共享 RAM 资源。

系统会使用内存逐出和交换来移除最近未使用的内存页,从而将应用的内存占用量集中在其当前工作集上,以此来保持预算平衡。当应用超出其声明的预算时,操作系统会专门针对该应用进行回收:

  1. 干净的有文件支持的内存页(例如不活跃的代码和映射的资源)会首先被逐出,因为在需要时可以从存储空间重新读取这些页。
  2. 脏文件支持的页面会写回存储空间并被逐出。
  3. 匿名内存页(例如堆分配量)会被压缩并交换到 zRAM。

只要工作集不超过预算,应用就能正常运行,同时使用的内存不会超过其设置的预算。操作系统会逐出未使用的内存,并将非活跃堆页面压缩到交换空间,以便在不终止进程的情况下使内存分配保持在一定范围内。

应用超出预算时会发生什么

超出内存预算不会导致系统终止或崩溃您的应用。相反,当应用使用的内存超出其预算允许的范围时,操作系统会找到应用一段时间内未使用的内存,并将其安全地收起,直到再次需要。这样可以为新的应用内存分配腾出空间,并将应用的总体内存用量保持在预算范围内。

只要应用在任何给定时间的需求(其工作集)与设定的预算之间存在余量,应用就会继续正常运行。如果应用设置的预算小于其工作集,则应用在运行时的性能可能会因此而降低。

如需了解操作系统如何跟踪和回收内存,请参阅内存架构指南:

在 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>

这会为软件包的所有进程和状态设置 256MB 的常驻内存预算。当应用的内存占用超过 256MB 时,操作系统会使用逐出和交换来修剪非活跃内存页。

根据流程状态调整预算

应用所需的内存量取决于其对用户的可见性:

  • 前台:进程正在托管与用户互动的可见 Activity。由于活跃的界面和图形,此状态通常具有最大的内存占用。
  • 可感知:用户可以感知到该进程,但该进程不托管可见窗口(例如,托管媒体播放前台服务、活跃的后台下载、精细导航或活跃的输入法)。
  • 后台:进程正在运行后台作业、接收器或数据同步。预计会保持最小占用空间。

您可以声明多个 <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 预算分配合适的大小,以实现流畅的帧渲染:为前台界面的活跃工作集提供足够的余量,以便同步回收不会延迟帧渲染。如果您的应用包含占用大量内存的独立工作流(例如高分辨率图片或视频编辑器),请考虑将该 activity 移至专用进程(请参阅多进程应用),以便其分配独立于预算,并在用户退出时销毁。
  • 为无头工作严格控制 background 预算:现场遥测中的高后台 RSS 通常反映了离开前台后保留的界面分配(请参阅将绑定服务与内存密集型界面共置和根据事件释放内存),或后台索引编制和同步期间的一次性文件读取。由于无头后台工作没有帧截止时间,因此当后台同步短暂分配超出其预算时,逐出一过性文件缓存页并将非活跃堆页交换到 zRAM 不会导致用户可见的卡顿,同时可防止后台峰值逐出用户的前台应用。

如需了解分步工作流,请参阅衡量工作集并校准预算。

预算状态与流程状态的对应关系

平台会根据 RunningAppProcessInfo.importance 将清单预算状态映射到运行时进程状态,Activity Manager 会根据进程自身的活跃组件以及来自其他应用的任何传入服务绑定或 content provider 查询来计算该状态:

  • foreground:活跃、可见的用户互动,例如托管已恢复的 Activity、在屏幕关闭时保持顶部应用状态,或托管由顶部前台应用主动绑定或查询的服务或 content provider。
  • perceptible:用户可感知但没有可见窗口的工作负载,例如活跃的媒体播放、精细导航、摄像头或麦克风捕获、活跃的后台下载、数据同步前台服务,或由前台客户端使用 BIND_NOT_FOREGROUND 等标志绑定的服务(请参阅使用 BIND 标志控制继承)。虽然内部平台指标可能会将其中一些工作负载标记为 PROCESS_STATE_IMPORTANT_FOREGROUND,但该内部常量并不表示可见窗口,而是受 perceptible 预算约束。
  • background:用户无法立即感知的工作,例如后台作业、闹钟和广播接收器。

下表显示了运行时重要性级别与清单状态的对应关系:

清单 android:state 运行时重要性 (RunningAppProcessInfo) 典型组件
foreground IMPORTANCE_FOREGROUND
IMPORTANCE_TOP_SLEEPING
已恢复的可见 activity、屏幕锁定时位于顶部的应用、由顶部应用绑定的服务或 content provider
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 反映了进程处于前台服务状态。如果您的应用托管了处于活跃状态的可见 activity,则会显示为 TOP。如果它正在运行重要的前台组件(例如下载或同步),则会显示为 IMPF。
  • 在进程 OOM 控制表中,prcp 表示系统会根据可感知优先级层级(对应于 perceptible 预算)评估进程。

如需检查当前强制执行的内存预算和常驻内存用量,请执行以下操作:

adb shell dumpsys meminfo <package-name>

从 Android 17 QPR2 开始,此输出包含一个 Memory Budget 部分,其中显示了有效限制上限、限制来源(例如 AndroidManifest 或 MemoryBudgetManager)以及当前常驻内存。

多进程应用

如果您的应用将工作划分到多个进程中,请使用 <processes> 内的 <process> 标记配置专用进程预算。

例如,假设有一个流式音乐应用 (com.example.radio):

  1. 主进程:托管可见界面和音频播放引擎(MediaSessionService,具有 mediaPlayback 前台服务)。当进程可见时,它会在 180MB 的前台预算下运行。当用户离开应用而音乐继续播放时,进程会进入 perceptible 状态,此时 64MB 的预算足以满足播放引擎和音频缓冲区的需求。
  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.0 倍 / 160 dpi)的平方,因为 2D 光栅可绘制对象和界面资源会同时在宽度和高度上缩放:

    <!-- 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。

  • 按物理显示分辨率缩放 (android:additionalBytesPerDisplayPixel):直接按物理显示像素(宽度 × 高度)添加字节。这非常适合分配全屏图形 Surface、渲染缓冲区或全分辨率照片缓存的应用,因为这些应用的内存消耗量会直接随原始显示像素数(而非界面密度存储分区)而变化:

    <!-- 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; 259 万像素)上,这会使基准预算增加 &approx; 41.4 MB。在 1440p 显示屏(1440 × 3120 &approx; 4.49M 像素)上,它会增加 &approx; 71.8 MB。

这两个属性是替代属性。请选择与应用的主要缩放比例相匹配的属性,并避免在同一子句中同时使用这两个属性。

针对设备规格进行专门设计

在手机、平板电脑和 Wear OS 之间发布 APK 时,请使用 android:feature 属性针对不同的硬件目标调整预算。

在 Wear OS 手表上,RAM 受到限制,应用的界面和功能集要简单得多。您可以为 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 额外兆字节数乘以显示密度比相对于 mdpi (1.0x) 的平方。
android:additionalBytesPerDisplayPixel 整数(≥ 0) 0 每个物理显示像素(宽度 × 高度)分配的额外字节数,适用于 Surface 缓冲区和位图。
android:feature 字符串 不限 将子句限制为声明特定硬件功能的设备:watch、automotive 或 leanback。

衡量工作集并校准预算

合理的预算应略高于应用在给定状态下的工作集,而不是其不受限制的峰值。如需找到该点,请运行真实的用户体验流程,逐步降低预算,并观察应用何时开始为此付出代价。

了解工作集与常驻内存

如果没有预算,应用的常驻内存占用 (memory.current) 会包含两种内存:

  • 工作集:Java、Kotlin 和原生堆,以及当前用户体验流程反复访问的热文件支持页面(DEX 代码、原生库和资源)
  • 冷闲置:早期屏幕中的位图缓存、一次性启动缓冲区、分配突发后留下的堆闲置,以及读取一次后不再访问的文件页面

如果预算达到不受限制的峰值,则永远无法回收冷松弛。如果预算低于工作集,内核会换出堆页并逐出代码页,而您的应用会立即通过缺页错误将这些页换回。这些工作集重新故障会消耗 CPU 时间和电池电量,一旦它们出现在界面线程上,也会消耗帧。

您要寻找的是拐点:即最低预算,在此预算下,回收仅会移除冷闲置,因此重新故障、CPU 时间和帧时间会保持在没有预算时的水平。

准备测试环境

设置一个行为与生产环境类似的可重复测试:

  • 校准您发布的经过 R8 优化且不可调试的发布 build。调试 build 会使用更多内存,因此在调试 build 上调整的预算对于发布 build 来说过高。
  • 在搭载 Android 17 QPR2(API 级别 37.2)或更高版本的设备或模拟器上进行测试。最好使用 userdebug build 或不带 Google Play 的模拟器映像,因为某些设备仅允许您在运行 adb root 后读取以下部分中的 cgroup 文件。
  • 使用 Jetpack Macrobenchmark、UI Automator 或 adb shell input 为 30 到 60 秒的用户体验流程编写脚本,每个您计划设置预算的状态编写一次。空闲应用不会告诉您有关其工作集的任何信息。
    • foreground:滚动密集列表和媒体网格、在主要目的地之间移动、打开菜单和对话框,以及运行动画
    • perceptible:离开可见窗口,同时继续执行可感知的工作(例如音频播放或下载),让其运行 10 到 15 秒,然后返回应用
    • background:在应用没有可见界面的情况下触发同步、WorkManager 作业(例如,使用 adb shell cmd jobscheduler run -f <package-name> <job-id>)、已启动的服务或广播接收器

在后台工作运行时测量 background 预算。进程进入缓存状态后,平台会释放其预算(请参阅预算状态如何映射到进程状态),因此空闲的缓存进程不会告诉您有关 background 预算的任何信息。

衡量基准水平

首先,在完全没有预算的情况下运行,以便了解正常情况。 使用不含 <memory-budget> 声明的 build,并从全新进程 (adb shell am force-stop <package-name>) 启动每次运行。

每个应用进程都有自己的 cgroup v2 内存控制器。请从 /proc/<PID>/cgroup 的 0:: 行中查找其路径,而不是自行构建路径,因为 cgroup 布局可能因设备而异。将每个设备命令作为单个带引号的字符串传递给 adb shell,以便设备 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,然后重试。如果您无法获取 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,从基准 build 中进行扫描。运行时预算只能收紧清单预算,因此请在没有 <memory-budget> 声明的 build 上进行扫描。在进程状态发生变化时,它也会保持有效,直到您调用 clearProcessBudget。

从基准中的最高 memory.current 开始,每次降低 10 到 15 MB。对于每个候选预算:

  1. 启动一个新进程,将其驱动到目标状态,然后应用预算。
  2. 运行一次用户体验流程作为预热。应用预算会逐出冷启动页面一次,而您想要衡量的是稳定状态,而不是该过渡状态。
  3. 重置帧统计信息,并记录起始 refault 计数器和进程 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'

每个候选版本至少重复三次,并交错运行候选版本,而不是连续运行一个候选版本的所有重复。将每个结果与基准运行的分布情况进行比较,而不是与单次运行进行比较。

查看结果

随着预算下降,您的应用会经历三种状态:

  • 高于膝盖:memory.current 通过丢弃冷 file 页面和空闲堆来降低预算。anon 保持平稳,重新缺页次数保持在基准附近,CPU 时间和帧时间没有变化。这就是您想要达到的目标。
  • 低于拐点:冷页面已消失,因此内核开始换出应用仍使用的堆页面。每次运行的重新故障次数会增加数百或数千页,并且 CPU 时间会增加。在其他方面处于空闲状态的测试设备上,帧时间可能几乎不会发生变化,但您的应用现在会消耗 CPU 时间和电池电量来压缩和解压缩其自己的工作集。
  • 远低于膝部:回收和换入故障会落在界面线程和 RenderThread 上,卡顿、帧时间和热恢复时间都会增加。

与无限制基准相比,如果候选预算满足以下所有条件(这是一个合理的起点),则该预算通过:

  • 进程在运行期间持续存在
  • 重新故障差值保持在基准附近
  • 进程 CPU 时间增加不超过 5%
  • 卡顿帧率上升不超过 1 个百分点
  • P95 帧时间增加不超过 3 毫秒
  • 对于 perceptible 和 background 预算,热恢复时间(从 adb shell am start -W 恢复到 TotalTime)的增幅不超过 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> 声明的发布 build,再次运行用户体验流程,并检查通过条件是否仍然成立。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。

运行时 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 或 activity 生命周期回调,如自适应图片编辑器示例中所示)。

性能和幂等性

在运行时使用 MemoryBudgetManager 时,请注意以下特征:

  • 幂等性:所有预算变更 API 都是幂等的。重复调用 clearProcessBudget() 或 clearPackageBudget(),或者在没有动态预算处于活动状态时调用,都是安全的空操作。同样,连续将 setProcessBudgetBytes() 或 setPackageBudgetBytes() 设置为相同的值不会触发多余的系统重新配置。
  • 调用频率:设置、更新或清除预算会向系统服务发出跨进程调用,以更新进程的操作系统内存控制器。虽然轻量,但并非完全免费。在离散的生命周期转换和任务里程碑(例如进入或离开 activity、启动或完成后台处理,或处理语音请求)期间调用这些 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。在后台线程上运行缓存逐出或文件清理可避免在进程面临内存压力时与界面线程上的帧渲染发生争用。为仅轻触主线程绑定界面状态的监听器预留 Looper.getMainLooper。
  • 预期会收到重复的回调:超预算回调不是一次性通知。当用量超出预算时,操作系统会先回收冷页,如果压力持续存在,系统会继续回收内存,并每秒最多调用一次监听器。使回调具有幂等性。如果您的应用只需要单个通知,或者希望在清理过程之间有更长的冷却时间,请在回调中调用 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,而不是主界面或渲染线程,这样缓存逐出就不会与帧渲染发生争用。请在通过 AMemoryBudgetManager_setProcessBudget 或 AMemoryBudgetManager_setPackageBudget 缩减预算之前创建观察器,以免应用错过初始超预算事件。如果进程或软件包仍超出预算,并且操作系统继续回收内存,则观察器会重复调用回调(每个 debounce_ms 间隔最多一次)。使回调具有幂等性,或者如果您的应用只需要一次性通知,则调用 AMemoryBudgetManager_Watcher_destroy(从回调内部调用是安全的)。

Runtime API 示例

以下示例演示了如何使用 Android SDK API(用 Kotlin 编写)和 Native NDK API(用 C++ 编写)实现运行时 API。

Android SDK 示例:自适应图片编辑器

以下示例展示了一个图片编辑应用 (com.example.imageeditor),其清单声明了 256MB 的上限,以容纳其多层画布:

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

当用户浏览轻量级缩略图库时,应用会使用 Kotlin 中的 Android SDK API 将其进程预算动态缩减至 96MB。当用户打开多层编辑画布时,应用会清除动态预算,以恢复完整的 256MB 清单上限。它还会在后台 HandlerThread Looper 上注册 OnOverBudgetListener,然后应用图库预算,以便在内存不足时逐出缓存的预览位图,而不会阻塞界面线程。

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;
};