通过精简进程让后台工作保持运行

许多应用在界面不再可见时会执行合法工作,例如下载大型资源包、同步本地数据库、流式传输音频、记录锻炼情况或维持已连接设备的会话。当此后台工作与前台界面和图形引擎在同一操作系统进程中运行时,整个进程以及界面分配的所有内存都会保持较高的重要性级别(oom_score_adj 介于 0 和 250 之间)。

本页介绍了为什么这样做会损害您自己的应用以及设备的其他部分,如何使用 android:process 将后台工作移至专用的轻量级进程,以及如何使用 adb shell 和 Perfetto 验证内存节省情况。

为什么单进程工作会保留界面内存

Android 会在操作系统进程 (pid) 级别(而非单个组件 [Activity、Service 或 BroadcastReceiver] 级别)管理内存和进程终止。Activity Manager (OomAdjuster) 会为每个进程分配一个 oom_score_adj 值,该值反映了该进程中最重要的活跃组件或传入绑定:

  • 当用户与您的 Activity 互动时,该进程会在 TOP 状态 (oom_score_adj = 0) 下运行。
  • 当用户离开应用且应用没有活跃的后台组件时,进程会移至 PREV (oom_score_adj = 700) 或 CACHED (oom_score_adj >= 900) 存储分区。在缓存存储分区中,平台会使用 cgroup v2 冻结器冻结进程,使用 CachedAppOptimizer 压缩其内存,并让 lmkd 在内存压力下首先终止该进程。
  • 不过,如果同一进程还运行前台服务(例如 dataSync、mediaPlayback、connectedDevice 或 location 类型,位于 oom_score_adj = 200),或托管系统或其他重要客户端绑定到的服务,则即使在 Activity 停止后,整个进程仍会保持较高的优先级。
单进程共置(将界面内存固定在 oom_score_adj 200)与拆分进程隔离(仅将工作器保持在 oom_score_adj 200,而界面进程降至缓存状态)的比较。
图 1. 后台工作期间单进程共置与拆分进程隔离的对比。

当繁重的界面进程保持在 oom_score_adj = 200 时,几个大类别的内存仍会常驻:

  • 已停止的 Activity 和视图层次结构:除非 Activity 已明确完成,否则当用户在另一个应用中时,其 Window、视图树、布局结构和视图状态仍可在 Java 堆上访问。
  • 原生图形上下文和 GPU 分配:标准 HardwareRenderer 会保留其 GPU 上下文和缓存,而停止的 Activity 则位于未缓存的进程中。创建自己的 Vulkan(VkDevice、VkSwapchainKHR、描述符池、流水线缓存)或 OpenGL ES(EGLContext、纹理、顶点缓冲区)上下文的应用和游戏引擎也会保留其驱动程序结构、着色器程序和图形缓冲区,除非引擎在停止时明确拆除图形设备。
  • 堆碎片和运行时开销:即使应用在 onTrimMemory 中释放缓存,部分填充的原生分配器页面、脏 Zygote 写时复制页面、加载的共享库 (.so) 和运行时元数据仍会驻留在进程的私有内存中。由于该进程高于缓存阈值 (oom_score_adj < 900),因此平台不会将其冻结,也不会对其运行缓存应用压缩。如需详细了解绑定服务的这种影响,请参阅将绑定服务与内存密集型界面共置。

精简的后台进程如何保护您的应用

在后台状态下保留前台大小的内存会使您自己的后台工作面临风险:

  • 后台内存限制比前台限制更严格。在 Android 17 及更高版本中,当应用的任何进程均未显示可见界面(包括前台服务运行时)时,内存限制器会为应用应用较小的不可见内存限制。该限制涵盖应用的所有进程。如果运行前台服务的进程仍保留其界面和显存,则该内存会计入较小的限制。当应用超出限制时,内核会在回收其页面时对其进行限制,这会减慢正在运行的下载、同步或播放速度。如果您的应用在交换空间耗尽后继续分配内存,系统会终止该应用。如需检查您的应用是否受到影响,请参阅应用内存限制。
  • 在内存压力下,系统会首先终止较大的进程。当 lmkd 必须释放内存,并且配置为选择重要性存储桶 (ro.lmk.kill_heaviest_task) 中最重的进程时,一个同样占用数百 MB 界面内存的前台服务进程会在相同重要性的较小进程之前终止,您的后台工作也会随之停止。请参阅 kswapd 和 lmkd 交互。
  • 缓存的界面状态可以冻结、压缩并写回闪存。 允许前台界面状态移入缓存进程,可让操作系统冻结该进程,将其内存压缩到 zRAM 中,并将其冷页写回闪存。其常驻内存占用会缩减,而界面状态保持不变,从而使后台工作和缓存的界面都有最大可能保留到用户返回。
  • 每个进程的内存预算变得切实可行。仅运行后台工作的进程具有较小且可预测的工作集,因此您可以为其提供严格的内存预算,而不会限制界面。

运行第二个进程可能会增加应用的总内存占用,因为第二个运行时会产生少量基准费用。只有当单个进程在转换到后台工作时能够完全释放其所有界面和图形状态时,这种比较才成立,而应用和界面工具包在实践中无法可靠地做到这一点。在常见情况下,让操作系统冻结、压缩和写回单独的缓存界面进程会使用更少的物理内存,使应用保持在不可见限制内,并减少应用和整个设备的低内存终止。

在专用进程中隔离后台工作

为了消除后台工作期间的界面和图形驻留,请在专用轻量级进程中运行长时间运行的后台组件,同时将界面和图形引擎保留在主进程中:

流程 清单声明 托管组件 用户在后台工作期间离开界面时的状态
主界面进程 默认(com.example.app) Activity、界面视图、图片缓存、Vulkan 或 OpenGL ES 渲染程序、音频和场景引擎 降至 PREV,然后降至 CACHED (oom_score_adj >= 900)。冻结、压缩并写回闪存。
后台工作器进程 android:process=":bg" 前台 Service、后台资源下载器、同步工作器或已连接设备管理器 在 FGS (oom_score_adj = 200) 上运行,仅包含 worker 自己的堆和代码,没有界面视图或 GPU 上下文。

声明进程和内存预算

通过在 AndroidManifest.xml 中设置 android:process=":bg",将后台 Service 分配给私有子进程。前导冒号 (:) 会创建一个限定于应用 UID 的私有进程。

在 Android 17 QPR2(API 级别 37.2)及更高版本中,将进程拆分与 <processes> 块内的每个进程应用内存预算相结合,以便操作系统对后台工作器强制执行严格的工作集上限,而不会限制前台界面进程:

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

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

        <!-- App-wide budgets that cover every process, including :bg -->
        <memory-budget android:maxMb="512" />
        <memory-budget
            android:maxMb="128"
            android:state="background" />

        <processes>
            <!-- Tight budget for the headless background worker process -->
            <process android:process=":bg">
                <memory-budget android:maxMb="32" />
            </process>
        </processes>

        <activity
            android:name=".MainActivity"
            android:exported="true" />

        <service
            android:name=".AssetDownloadService"
            android:process=":bg"
            android:foregroundServiceType="dataSync"
            android:exported="false" />

    </application>
</manifest>

:bg 中的内存用量会同时计入其自身预算和应用级预算。在搭载 Android 17 QPR2 之前版本的设备上,系统会忽略 <memory-budget>,而 android:process=":bg" 适用于所有 API 级别。

在 Application.onCreate 中控制初始化

Android 会实例化您的 Application 类,并在为您的软件包生成的每个进程中调用 Application.onCreate。采用 android:process=":bg" 时,一个常见的陷阱是将繁重的界面工具包、图片加载池、Analytics SDK 或原生引擎初始化 (System.loadLibrary) 留在不受保护的 Application.onCreate 方法中。这样做会使 :bg 进程膨胀,从而产生您想要避免的相同基准占用空间。

在 onCreate 中检查 Application.getProcessName(在 Android 9 [API 级别 28] 及更高版本中可用),并仅初始化当前进程所需的依赖项:

Kotlin

class HeavyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        val processName = Application.getProcessName()
        if (processName != null && processName.endsWith(BG_PROCESS_SUFFIX)) {
            initBackgroundWorkerProcess()
            return
        }
        initMainUiProcess()
    }

    private fun initBackgroundWorkerProcess() {
        // Initialize only lightweight HTTP, storage, and sync primitives.
        // Avoid loading native rendering libraries or allocating UI caches.
    }

    private fun initMainUiProcess() {
        // Initialize UI frameworks, image caches, and native engines.
        System.loadLibrary("heavy_engine_jni")
    }

    companion object {
        private const val BG_PROCESS_SUFFIX = ":bg"
    }
}

Java

public final class HeavyApplication extends Application {
    private static final String BG_PROCESS_SUFFIX = ":bg";

    @Override
    public void onCreate() {
        super.onCreate();
        String processName = Application.getProcessName();
        if (processName != null && processName.endsWith(BG_PROCESS_SUFFIX)) {
            initBackgroundWorkerProcess();
            return;
        }
        initMainUiProcess();
    }

    private void initBackgroundWorkerProcess() {
        // Initialize only lightweight HTTP, storage, and sync primitives.
        // Avoid loading native rendering libraries or allocating UI caches.
    }

    private void initMainUiProcess() {
        // Initialize UI frameworks, image caches, and native engines.
        System.loadLibrary("heavy_engine_jni");
    }
}

通过 content provider 初始化的库(例如 Jetpack App Startup)仅在主进程中初始化,因为在未声明 android:process 的情况下声明的 content provider 会在主进程中运行。如果 :bg 中的代码依赖于此类库,请在 initBackgroundWorkerProcess 中明确初始化该库。

如果您使用 Unity 等游戏引擎进行构建,请在不引用 UnityPlayer 等引擎类的 Java 或 Kotlin 编写的普通 Android 库或插件中实现后台服务。然后,:bg 进程永远不会加载引擎原生库。如需确认,请检查 :bg 进程的 adb shell cat /proc/<pid>/maps 是否未列出 libunity.so 或 libil2cpp.so。

在不固定界面进程的情况下进行通信

当用户打开 MainActivity 且 AssetDownloadService 在 :bg 中运行时,界面通常需要显示实时下载进度。进程重要性从客户端流向托管其使用的服务或 content provider 的进程,因此每个依赖项的方向都很重要:

  • 与工作器绑定的界面是安全的。当 MainActivity 绑定到 AssetDownloadService 时,系统会将 :bg 的重要性提升到可见界面的重要性。当用户离开时,界面进程会被缓存,并且绑定不再提升任何内容。
  • 依赖于界面进程的工作器不安全。如果 :bg 中的代码绑定到在主进程中运行的服务或查询在主进程中运行的 content provider,系统会将主进程提升到 worker 提升后的重要性,并且界面内存会再次保持固定。改为让界面向工作器注册回调,例如 Messenger 或 AIDL 回调接口。将 binder 对象作为回调传递不会改变任一进程的重要性。

在解除绑定之前,请先取消注册回调,这样,在系统冻结工作器后,工作器就不会再调用界面进程。以下示例仅在界面可见时绑定到工作器:

Kotlin

class MainActivity : Activity() {
    private var downloadService: IBinder? = null

    private val downloadConnection = object : ServiceConnection {
        override fun onServiceConnected(
                name: ComponentName,
                service: IBinder
        ) {
            downloadService = service
            // Register a Messenger or AIDL callback for progress updates.
        }

        override fun onServiceDisconnected(name: ComponentName) {
            // The binding stays active and reconnects if the worker restarts.
            downloadService = null
        }
    }

    override fun onStart() {
        super.onStart()
        val intent = Intent(this, AssetDownloadService::class.java)
        bindService(intent, downloadConnection, Context.BIND_AUTO_CREATE)
    }

    override fun onStop() {
        super.onStop()
        if (downloadService != null) {
            // Unregister the progress callback here, before unbinding.
            downloadService = null
        }
        // Unbind the ServiceConnection even if onServiceConnected hasn't run
        // yet.
        unbindService(downloadConnection)
    }
}

Java

public final class MainActivity extends Activity {
    private IBinder downloadService;

    private final ServiceConnection downloadConnection =
            new ServiceConnection() {
                @Override
                public void onServiceConnected(
                        ComponentName name, IBinder service) {
                    downloadService = service;
                    // Register a Messenger or AIDL callback for progress
                    // updates.
                }

                @Override
                public void onServiceDisconnected(ComponentName name) {
                    // The binding stays active and reconnects if the worker
                    // restarts.
                    downloadService = null;
                }
            };

    @Override
    protected void onStart() {
        super.onStart();
        Intent intent = new Intent(this, AssetDownloadService.class);
        bindService(intent, downloadConnection, Context.BIND_AUTO_CREATE);
    }

    @Override
    protected void onStop() {
        super.onStop();
        if (downloadService != null) {
            // Unregister the progress callback here, before unbinding.
            downloadService = null;
        }
        // Unbind the ServiceConnection even if onServiceConnected hasn't run
        // yet.
        unbindService(downloadConnection);
    }
}

如需详细了解绑定标志如何影响跨进程的 oom_score_adj,请参阅服务绑定和进程状态。

安全地协调共享的磁盘状态

由于主进程和 :bg 在具有单独运行时和文件描述符的单独地址空间中运行,因此:

  • SQLite 和 Room:使用预写式日志记录(大多数设备上 Room 中的默认日志模式),以便两个进程可以同时读取和写入数据库。请勿从 :bg 调用 enableMultiInstanceInvalidation。Room 在主进程中托管其失效服务,因此工作器会绑定到主进程,并使界面内存保持在工作器的提升重要性级别。而是让 worker 通过界面注册的回调报告更改,并让界面在启动时重新查询数据库。
  • 偏好设置:请勿跨进程共享 SharedPreferences。在多进程代码中使用 DataStore,或通过 IPC 显式传递状态。
  • 资源文件:将下载的资源包写入 :bg 中的暂存文件,在写入后调用 fdatasync,然后调用 :bg 中的 posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED)(如将内存页返回给操作系统中所示),并以原子方式将文件重命名。让界面在下次启动并绑定到工作器时获取结果,而不是调用可能已冻结的界面进程。

在 adb shell 中观察效果

您可以在前往主屏幕后,通过检查 Activity Manager 进程状态和 dumpsys meminfo,验证拆分前台和后台进程是否能隔离内存用量增加。本部分中的示例输出来自 MemoryLab 示例应用,该应用在两种配置下都在其界面进程中运行相同的 64 MB 原生分配,并在 :remote 进程中托管其拆分前台服务。

1. 比较进程状态和 OOM 得分

启动后台工作负载,按主屏幕按钮以停止 Activity,然后查询 Activity Manager:

adb shell dumpsys activity processes com.example.app | grep -E "Proc #|oom:|state:"

在单进程架构中,即使主进程的 Activity 已停止,主进程仍会保持提升状态。在用户离开后,Activity Manager 会将运行前台服务的进程保持在 oom_score_adj = 50 (fg-service-act) 状态大约 15 秒,然后将其降至 200 (fg-service) 状态。以下输出是在按下主屏幕按钮 20 秒后捕获的:

    Proc #1: prcp     F/S/FGS  ---NFUATI  t: 0 13794:com.android.memorylab/u0a284 (fg-service)
        oom: max=1001 curRaw=200 setRaw=200 cur=200 set=200
        state: cur=FGS  set=FGS   lastRss=0.00 lastCachedRss=0.00

在拆分进程架构中,只有工作器进程保持在 oom_score_adj = 200,而主界面进程会降至 PREV (oom_score_adj = 700),随后降至缓存存储分区 (oom_score_adj >= 900):

    Proc #3: prcp     F/S/FGS  ---NFUATI  t: 0 13544:com.android.memorylab:remote/u0a284 (fg-service)
        oom: max=1001 curRaw=200 setRaw=200 cur=200 set=200
        state: cur=FGS  set=FGS   lastRss=0.00 lastCachedRss=0.00
    Proc #1: prev     b/ /LAST -------TI  t: 0 13312:com.android.memorylab/u0a284 (previous)
        oom: max=1001 curRaw=700 setRaw=700 cur=700 set=700
        state: cur=LAST set=LAST  lastRss=0.00 lastCachedRss=0.00

前一个应用超时后,主进程会移至缓存存储分区(cch、oom_score_adj = 900、previous-expired),并失去其所有进程功能 (---------),包括隐式 CPU 时间 (I)。此时,系统会将其冻结,并可以随时对其进行压缩或回收,而不会中断工作器进程。

2. 比较不同进程的 dumpsys meminfo

将提权工作器的 dumpsys meminfo 摘要与界面进程进行比较:

adb shell dumpsys meminfo com.example.app:bg
adb shell dumpsys meminfo com.example.app

oom_score_adj = 200 处的 MemoryLab 工作器进程的 App Summary 输出示例:

 App Summary
                       Pss(KB)                        Rss(KB)
                        ------                         ------
           Java Heap:      380                          13840
         Native Heap:     1548                           2832
                Code:      132                          55816
               Stack:      200                            208
            Graphics:        0                              0
       Private Other:      496
              System:     1926
             Unknown:                                    4168

           TOTAL PSS:     4682            TOTAL RSS:    76864       TOTAL SWAP PSS:      441

oom_score_adj = 700 处的 MemoryLab 界面进程的 App Summary 输出示例,该进程在 Private Other 中保留了 64 MB 的原生分配:

 App Summary
                       Pss(KB)                        Rss(KB)
                        ------                         ------
           Java Heap:     1316                          19816
         Native Heap:    30408                          31596
                Code:     3148                         115796
               Stack:      656                            664
            Graphics:        0                              0
       Private Other:    67044
              System:    13687
             Unknown:                                   71092

           TOTAL PSS:   116259            TOTAL RSS:   238964       TOTAL SWAP PSS:      259

工作器进程需要不到 5 MB 的 PSS,因为它与 Zygote 共享大部分代码和运行时页面。在通过 Vulkan 或 OpenGL ES 渲染的应用中,界面进程的 Graphics 行还包含 GPU 内存。此示例是在具有软件渲染功能的 Cuttlefish 虚拟设备上捕获的,因此 Graphics 在这两个进程中均为 0。

3. 检查每个进程的 cgroup 统计

UID 下的每个进程在应用的父 cgroup 中都有自己的 cgroup v2 目录。以下命令会列出应用的每个进程的 oom_score_adj、memory.current 和应用范围内的 memory.high:

adb shell '
  for PID in $(pidof com.example.app com.example.app:bg); do
    CG=/sys/fs/cgroup$(sed -n "s/^0:://p" /proc/$PID/cgroup)
    echo "pid=$PID ($(tr -d "\0" < /proc/$PID/cmdline))" \
      "adj=$(cat /proc/$PID/oom_score_adj)" \
      "memory.current=$(cat $CG/memory.current)" \
      "app.memory.high=$(cat ${CG%/*}/memory.high)"
  done
'

按下主屏幕按钮后 MemoryLab 的示例输出:

pid=15301 (com.android.memorylab) adj=700 memory.current=103260160 app.memory.high=966787072
pid=16518 (com.android.memorylab:remote) adj=200 memory.current=3608576 app.memory.high=966787072

工作器进程的费用低于 4 MB,而界面进程的费用约为 98 MB。每个进程都有自己的 memory.current,但内存限制器会在包含应用所有进程的父 cgroup 上设置限制。两条线都显示相同的 app.memory.high(966,787,072 字节),即以下输出中不可见的 922 MB 限制。

如需查看内存限制器在设备上应用的可见和不可见限制,请运行以下命令:

adb shell am memory-limiter status

以下是 RAM 为 4 GB 的设备的输出示例,其中不可见限制是可见限制的一半:

Memory limiter
  enabled                  limits=true              killing=true             ignored=none
  visible:                 memHigh=1843MB           memMax=2048MB            swapMax=1024MB
  not-visible:             memHigh=922MB            memMax=1024MB            swapMax=1024MB

在 Perfetto 中观察效果

借助 Perfetto,您可以直观地查看当用户从前台界面切换到主屏幕,而后台工作仍在继续时,两个进程的 oom_score_adj 和 RSS 的时间轴。

1. 记录轨迹

在应用中启动后台作业并转到主屏幕时,记录一个 15 秒的跟踪记录,以捕获进程内存计数器、GPU 内存和 oom_score_adj 更改:

adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/process-splitting.perfetto-trace <<'EOF'
buffers: {
  size_kb: 32768
  fill_policy: DISCARD
}
data_sources: {
  config {
    name: "linux.process_stats"
    process_stats_config {
      scan_all_processes_on_start: true
      proc_stats_poll_ms: 250
    }
  }
}
data_sources: {
  config {
    name: "android.gpu.memory"
  }
}
data_sources: {
  config {
    name: "linux.ftrace"
    ftrace_config {
      ftrace_events: "oom/oom_score_adj_update"
      ftrace_events: "gpu_mem/gpu_mem_total"
    }
  }
}
duration_ms: 15000
EOF
adb pull /data/misc/perfetto-traces/process-splitting.perfetto-trace

2. 在 Perfetto 界面中检查进程轨迹

在 Perfetto 界面中打开跟踪记录,然后展开 com.example.app 和 com.example.app:bg 的进程组:

  1. 固定两个进程的 oom_score_adj 和 mem.rss.anon 轨道,使其并排显示在时间轴顶部。
  2. 找到用户按下主屏幕按钮时的时间戳:
    • 在工作器进程中,oom_score_adj 保持在 200,mem.rss.anon 保持平稳且较小。
    • 在界面进程中,oom_score_adj 从 0 升至 200,在 Activity 停止期间保持该值约一秒,然后升至 700 (PREV)。在上一应用超时后,它会移至缓存范围,系统可以在不影响工作器的情况下压缩和回收其内存。
界面进程和工作器进程的 Perfetto 轨迹。在按下主屏幕按钮后,界面进程 oom_score_adj 会从 0 升至 200,然后升至 700,而工作器进程会保持在 200,并具有大约 19 MB 的匿名 RSS。
图 2. 在用户按下主屏幕按钮后拆分进程。

在图 2 中,界面进程短暂地停留在 200,而其 Activity 停止,然后上升到 700,而工作器停留在 200,匿名 RSS 约为 19 MB。为了进行比较,图 3 显示了共置配置的 35 秒跟踪记录,其中单个进程保持在 50,然后保持在 200,并保留其所有界面内存。

单个共置进程的 Perfetto 跟踪记录。oom_score_adj 在主屏幕后从 0 升至 50,并在大约 15 秒后升至 200,而匿名 RSS 保持较高水平。
图 3. 用户按下主屏幕按钮后,系统会启动一个共置进程。

如需探索这些视图,请在 Perfetto 界面中打开拆分跟踪记录和共置跟踪记录。

3. 使用 PerfettoSQL 查询内存占用量增加的情况

在查询 (SQL) 标签页中运行以下 PerfettoSQL 查询,以测量应用的每个进程在每个 oom_score_adj 级别持有的平均匿名和文件 RSS:

INCLUDE PERFETTO MODULE android.memory.process;

SELECT
  process_name,
  score AS oom_score_adj,
  ROUND(SUM(dur) / 1e9, 2) AS duration_sec,
  ROUND(SUM(anon_rss * dur) / SUM(dur) / 1048576.0, 1) AS avg_anon_mb,
  ROUND(SUM(file_rss * dur) / SUM(dur) / 1048576.0, 1) AS avg_file_mb
FROM memory_oom_score_with_rss_and_swap_per_process
WHERE process_name GLOB 'com.example.app*'
GROUP BY process_name, score
ORDER BY process_name, score;

图 2 中拆分跟踪记录的示例输出:

process_name                  oom_score_adj  duration_sec  avg_anon_mb  avg_file_mb
com.android.memorylab                     0          4.11         93.3        110.4
com.android.memorylab                   200          1.06        126.8        110.5
com.android.memorylab                   700          9.72        115.1        110.5
com.android.memorylab:remote              0          0.01         NULL         NULL
com.android.memorylab:remote            200         12.87         18.9         53.7

图 3 中共置跟踪记录的示例输出:

process_name           oom_score_adj  duration_sec  avg_anon_mb  avg_file_mb
com.android.memorylab              0          4.10         96.0        110.4
com.android.memorylab             50         16.04        117.6        110.5
com.android.memorylab            200         14.68        116.5        110.6

在拆分配置中,只有大约 19 MB 的匿名内存在第一秒后仍保留在 oom_score_adj = 200 中。在共置配置中,只要前台服务运行,大约 117 MB 的内存就会一直保留在 50 中,然后保留在 200 中。


← 将内存页返回给操作系统 | ↑ 向上