앱 메모리 예산 설정

앱 메모리 예산을 사용하면 앱이 자체 메모리 예산을 선언할 수 있으며, 이를 통해 앱이 설정된 예산보다 더 많이 사용할 때 메모리 사용량을 줄이도록 시스템에 알릴 수 있습니다. 이는 시스템 및 번들 앱 또는 메모리가 제한된 기기를 타겟팅하는 앱에 특히 유용합니다. 개발자가 예상 메모리 작업 세트 (사용자가 현재 하고 있는 작업에 앱이 적극적으로 필요로 하는 메모리)를 알고 있으며 앱이 시스템의 공유 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>

이렇게 하면 패키지의 모든 프로세스와 상태에 256MB의 상주 메모리 예산이 설정됩니다. 앱의 메모리 사용량이 256MB를 초과하면 운영체제는 제거 및 스왑을 사용하여 비활성 메모리 페이지를 자릅니다.

프로세스 상태에 따라 예산 변경

앱은 사용자 공개 상태에 따라 다양한 양의 메모리가 필요합니다.

  • 포그라운드: 프로세스가 사용자와 상호작용하는 표시되는 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에 따라 매니페스트 예산 상태를 런타임 프로세스 상태에 매핑합니다. 이는 활동 관리자가 프로세스 자체의 활성 구성요소와 다른 앱의 수신 서비스 바인딩 또는 콘텐츠 제공자 쿼리에서 계산합니다.

  • foreground: 활성 상태의 표시되는 사용자 상호작용입니다. 예를 들어 재개된 Activity 호스팅, 화면이 꺼질 때 상단 앱 상태 유지, 상단 포그라운드 앱에 의해 활성 상태로 바인딩되거나 쿼리되는 서비스 또는 콘텐츠 제공업체 호스팅이 있습니다.
  • perceptible: 활성 미디어 재생, 세부 경로 안내 내비게이션, 카메라 또는 마이크 캡처, 활성 백그라운드 다운로드, 데이터 동기화 포그라운드 서비스 또는 BIND_NOT_FOREGROUND과 같은 플래그를 사용하여 포그라운드 클라이언트에 의해 바인딩된 서비스와 같이 표시되는 창 없이 사용자가 인식할 수 있는 워크로드입니다 (BIND 플래그로 상속 제어 참고). 내부 플랫폼 측정항목은 이러한 워크로드 중 일부를 PROCESS_STATE_IMPORTANT_FOREGROUND로 라벨을 지정할 수 있지만, 이 내부 상수는 표시되는 기간을 나타내지 않으며 perceptible 예산에 의거하여 제어됩니다.
  • background: 백그라운드 작업, 알람, broadcast receiver, 사용자가 다른 곳으로 이동한 후의 이전 활동과 같이 사용자가 즉시 인식할 수 없는 작업입니다.

다음 표에서는 런타임 중요도 수준이 매니페스트 상태에 매핑되는 방식을 보여줍니다.

매니페스트 android:state 런타임 중요도 (RunningAppProcessInfo) 일반적인 구성요소
foreground IMPORTANCE_FOREGROUND
IMPORTANCE_TOP_SLEEPING
재개된 표시 활동, 화면이 잠긴 상단 앱, 상단 앱에 의해 바인딩된 서비스 또는 콘텐츠 제공업체
perceptible IMPORTANCE_FOREGROUND_SERVICE
IMPORTANCE_VISIBLE
활성 미디어 재생, 탐색, 다운로드 또는 동기화 포그라운드 서비스, 포그라운드 서비스 또는 표시 플래그와 결합된 서비스
background IMPORTANCE_PERCEPTIBLE
IMPORTANCE_CANT_SAVE_STATE
IMPORTANCE_SERVICE
백그라운드 작업, 수신기, 백그라운드 동기화, 백그라운드의 이전 활동 또는 홈 앱

클라이언트 바인딩이 서비스 또는 프로바이더 호스트 프로세스를 승격하는 방법과 클라이언트가 바인딩을 해제하거나 화면 밖으로 이동한 후 프로세스가 background 또는 cached로 다시 전환되는 방법에 관한 자세한 내용은 서비스 결합 및 프로세스 상태를 참고하세요.

프로세스가 활성 구성요소가 없는 캐시된 상태 (IMPORTANCE_CACHED)로 전환되면 플랫폼은 활성 메모리 예산을 해제하고 표준 캐시된 프로세스 회수를 통해 메모리를 관리합니다.

앱의 프로세스 상태 및 예산 검사

개발 중에 앱의 활성 프로세스 상태와 중요도를 검사하는 한 가지 방법은 ADB를 사용하여 활동 관리자를 쿼리하는 것입니다.

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>

Android 17 QPR2부터 이 출력에는 활성 한도 상한, 한도 소스 (예: AndroidManifest 또는 MemoryBudgetManager), 현재 상주 메모리를 표시하는 메모리 예산 섹션이 포함됩니다.

멀티 프로세스 앱

애플리케이션이 여러 프로세스에 걸쳐 작업을 분할하는 경우 <processes> 내의 <process> 태그를 사용하여 전용 프로세스 예산을 구성합니다.

예를 들어 스트리밍 음악 앱 (com.example.radio)을 생각해 보세요.

  1. 기본 프로세스: 표시되는 UI와 오디오 재생 엔진(mediaPlayback 포그라운드 서비스가 있는 MediaSessionService)을 호스팅합니다. 표시되면 프로세스는 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): 2D 래스터 드로어블 및 UI 애셋이 너비와 높이 모두에서 확장되므로 mdpi (1.0x / 160dpi)를 기준으로 디스플레이 밀도 비율의 제곱에 비례하여 메가바이트를 추가합니다.

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

    mdpi 디스플레이 (1.0x)에서 예산은 180 + 16 × 12 = 196MB입니다. xxhdpi 디스플레이 (3.0x)에서 예산은 180 + 16 × 32 = 324MB로 확장됩니다.

  • 실제 디스플레이 해상도에 따라 확장(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; 259만 픽셀)에서는 기준 예산에 약 41.4MB가 추가됩니다. 1440p 디스플레이(1440 × 3120 &approx; 449만 픽셀)에서는 약 71.8MB가 추가됩니다.

이 두 속성은 대체 속성입니다. 앱의 기본 배율과 일치하는 속성을 선택하고 동일한 절에서 두 속성을 결합하지 마세요.

기기 폼 팩터에 맞게 전문화

휴대전화, 태블릿, 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 Enum 모두 이 예산이 적용되는 프로세스 상태: foreground, perceptible 또는 background 생략하면 절이 지정되지 않은 상태의 대체 역할을 합니다.
android:additionalMbPerDensity 정수 (0 이상) 0 mdpi (1.0x)에 대한 디스플레이 밀도 비율의 제곱을 곱한 추가 메가바이트입니다.
android:additionalBytesPerDisplayPixel 정수 (0 이상) 0 물리적 디스플레이 픽셀 (너비 × 높이)당 할당된 추가 바이트로, 표면 버퍼 및 비트맵에 유용합니다.
android:feature 문자열 모두 특정 하드웨어 기능(watch, automotive 또는 leanback)을 선언하는 기기로 절을 제한합니다.

Runtime API (보조 동적 옵션)

AndroidManifest.xml에서 정적으로 예산을 선언하는 것이 거의 모든 앱에 권장되는 솔루션입니다. 하지만 동적 워크로드가 있는 애플리케이션이나 런타임 실험의 경우 Android는 런타임 SDK 및 NDK API를 보조 옵션으로 제공합니다.

런타임 API를 사용하면 다음 작업을 할 수 있습니다.

  • 현재 메모리 사용량과 유효 예산을 쿼리합니다.
  • 프로세스 예산을 동적으로 하향 조정합니다.
  • 운영체제가 직접 회수를 트리거하기 전에 예산 초과 이벤트를 수신 대기하여 캐시를 사전에 자릅니다.

Android SDK API (MemoryBudgetManager)

MemoryBudgetManager 시스템 서비스는 Android 17 QPR2 (부 SDK 출시, API 수준 37.2 / Build.VERSION_CODES_FULL.CINNAMON_BUN_2)부터 Kotlin 및 Java로 작성된 앱에서 사용할 수 있습니다.

서비스 가져오기

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()

프로세스가 foreground, perceptible, background 상태 간에 전환될 때마다 자동으로 예산을 전환하는 <memory-budget> 매니페스트 절과 달리 MemoryBudgetManager를 통해 설정된 동적 예산은 코드가 업데이트되거나 삭제될 때까지 단일 상한을 적용합니다. 런타임 API를 사용하여 필드 실험을 실행하거나 상태별 한도를 관리하는 경우 수명 주기 전환 전반에서 동적 예산을 업데이트하거나 삭제합니다 (예: 적응형 이미지 편집기 예에 표시된 대로 ProcessLifecycleOwner 또는 활동 수명 주기 콜백 사용).

성능 및 멱등성

런타임에 MemoryBudgetManager를 사용할 때는 다음 특성에 유의하세요.

  • 멱등성: 모든 예산 변형 API는 멱등적입니다. clearProcessBudget() 또는 clearPackageBudget()를 반복적으로 호출하거나 활성 상태의 동적 예산이 없을 때 호출하는 것은 안전한 노옵스(no-ops)입니다. 마찬가지로 setProcessBudgetBytes() 또는 setPackageBudgetBytes()를 동일한 값으로 연속해서 설정해도 중복 시스템 재구성이 트리거되지 않습니다.
  • 호출 빈도: 예산을 설정, 업데이트 또는 삭제하면 시스템 서비스에 대한 교차 프로세스 호출이 이루어져 프로세스의 운영체제 메모리 컨트롤러가 업데이트됩니다. 가볍지만 완전히 무료는 아닙니다. 개별 수명 주기 전환 및 작업 마일스톤 (예: 활동 시작 또는 종료, 백그라운드 프로세스 시작 또는 완료, 음성 요청 처리) 중에 이러한 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 스레드에서 프레임 렌더링과 경합하지 않습니다. 기본 스레드 바인딩 UI 상태만 터치하는 리스너를 위해 Looper.getMainLooper를 예약합니다.
  • 반복 콜백 예상: 예산 초과 콜백은 일회성 알림이 아닙니다. 사용량이 예산을 초과하면 운영체제는 먼저 콜드 페이지를 회수하고, 압박이 계속되면 시스템은 메모리를 계속 회수하고 최대 1초에 한 번 리스너를 호출합니다. 콜백을 멱등성으로 만듭니다. 앱에 단일 알림만 필요하거나 정리 패스 간에 더 긴 대기 시간이 필요한 경우 콜백 내에서 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);

캐시 제거가 프레임 렌더링과 경합하지 않도록 감시자를 기본 UI 또는 렌더링 스레드가 아닌 백그라운드 작업자 스레드의 ALooper에 바인딩합니다. 앱이 초기 예산 초과 이벤트를 놓치지 않도록 AMemoryBudgetManager_setProcessBudget 또는 AMemoryBudgetManager_setPackageBudget로 예산을 줄이기 전에 감시자를 만듭니다. 프로세스 또는 패키지가 예산을 초과하고 운영체제가 메모리를 계속 회수하는 동안 감시자는 콜백을 반복적으로 호출합니다 (debounce_ms 간격당 최대 한 번). 콜백을 멱등원으로 만들거나 앱에 일회성 알림만 필요한 경우 AMemoryBudgetManager_Watcher_destroy (콜백 내에서 호출해도 안전함)를 호출합니다.

Runtime API 예시

다음 예에서는 Android SDK API (Kotlin으로 작성됨) 및 네이티브 NDK API (C++로 작성됨)를 사용하여 런타임 API를 구현하는 방법을 보여줍니다.

Android SDK 예: 적응형 이미지 편집기

이 예에서는 매니페스트가 다중 레이어 캔버스를 수용하기 위해 256MB 상한을 선언하는 이미지 편집 앱 (com.example.imageeditor)을 보여줍니다.

<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를 등록한 후 갤러리 예산을 적용하여 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 엔진

이 예에서는 애플리케이션의 매니페스트가 고화질 그래픽 (android:maxMb="512")을 수용하기 위해 512MB 상한을 선언한다고 가정하고 활성 그래픽 품질 수준에 따라 메모리 예산을 관리하는 네이티브 C++ 게임 엔진을 보여줍니다. 엔진은 낮은 품질 사전 설정의 예산을 줄이기 전에 백그라운드 작업자 스레드의 ALooper에서 AMemoryBudgetManager_Watcher_create를 시작하고 예산을 초과하면 텍스처 밉맵을 언로드합니다.

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