הגדרת תקציבי זיכרון לאפליקציות

תקציבי זיכרון לאפליקציות מאפשרים לאפליקציות להצהיר על תקציב זיכרון משלהן, מה שאומר למערכת לצמצם את השימוש בזיכרון כשהאפליקציה משתמשת ביותר מהתקציב שהוגדר לה. האפשרות הזו שימושית במיוחד לאפליקציות מערכת ואפליקציות בחבילה, או לאפליקציות שמטרגטות מכשירים עם מגבלות זיכרון, שבהן המפתח יודע מהי קבוצת העבודה הצפויה בזיכרון (הזיכרון שהאפליקציה צריכה באופן פעיל לפעולה שהמשתמש מבצע כרגע) ורוצה לוודא שהאפליקציה לא משתמשת ביותר מדי ממשאבי ה-RAM המשותפים של המערכת.

כדי לשמור על איזון בתקציב, המערכת משתמשת בפינוי זיכרון ובהחלפה כדי להסיר דפי זיכרון שלא היו בשימוש לאחרונה, וכך מתמקדת בזיכרון שבשימוש של האפליקציה בקבוצת העבודה הנוכחית שלה. כשאפליקציה חורגת מהתקציב שהוקצה לה, מערכת ההפעלה מנסה להחזיר את הזיכרון שהוקצה לאפליקציה הזו:

  1. דפים נקיים שמגובים בקבצים (כמו קוד לא פעיל ונכסים ממופים) מפונים קודם, כי אפשר לקרוא אותם מחדש מהאחסון במקרה הצורך.
  2. דפים מלוכלכים שמגובים בקבצים נכתבים בחזרה לאחסון ומוסרים.
  3. דפי זיכרון אנונימיים (כמו הקצאות זיכרון בערימה) נדחסים ומוחלפים ב-zRAM.

כל עוד קבוצת העבודה לא חורגת מהתקציב, האפליקציה תפעל היטב בלי להשתמש ביותר זיכרון מהתקציב שהוגדר לה. מערכת ההפעלה מפנה זיכרון שלא בשימוש ודוחסת דפי ערימה לא פעילים להחלפה, כדי שהקצאות הזיכרון יישארו מוגבלות בלי לסיים את התהליך.

מה קורה כשאפליקציה חורגת מהתקציב שלה

חריגה מתקציב הזיכרון לא גורמת למערכת לסגור את האפליקציה או לקרוס. במקום זאת, כשאפליקציה משתמשת ביותר זיכרון ממה שהתקציב שלה מאפשר, מערכת ההפעלה מוצאת זיכרון שהאפליקציה לא השתמשה בו במשך זמן מה ומאחסנת אותו באופן בטוח עד שיהיה בו צורך שוב. כך אפשר להקצות זיכרון חדש לאפליקציה ולשמור על השימוש הכולל בזיכרון של האפליקציה במסגרת התקציב.

כל עוד יש מרווח בין מה שהאפליקציה צריכה בכל זמן נתון (קבוצת העבודה שלה) לבין התקציב שהוגדר, האפליקציה ממשיכה לפעול כרגיל. אם אפליקציה מגדירה תקציב קטן יותר מקבוצת העבודה שלה, הביצועים של האפליקציה בזמן הריצה עלולים להיות איטיים יותר כתוצאה מכך.

במדריך לארכיטקטורת הזיכרון מוסבר איך מערכת ההפעלה עוקבת אחרי הזיכרון ומחזירה אותו:

הצהרה על תקציבים במניפסט של Android

הצהרה על תקציבי הזיכרון ב-AndroidManifest.xml היא השיטה העיקרית והמומלצת להגדרת תקציבים. הוא לא דורש קוד זמן ריצה, נכנס לתוקף מיד עם הפעלת התהליך ומספק חוזה ברור למערכת ההפעלה.

הצהרות <memory-budget> נכנסות לתוקף במכשירים עם Android 17 QPR2 (רמת API ‏37.2) ומעלה. בגרסאות קודמות של Android, מנתח המניפסט של הפלטפורמה מתעלם באופן בטוח מרכיבי XML לא מזוהים, כך שאתם יכולים להשתמש ב-<memory-budget> בלי להשפיע על התאימות לדורות קודמים.

הצהרה על תקציב בסיסי

ברוב האפליקציות, כל מה שצריך לעשות הוא להגדיר תקציב אחד לאפליקציה. מצהירים על רכיב <memory-budget> ישירות בתוך התג <application>:

<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, מערכת ההפעלה מצמצמת את דפי הזיכרון הלא פעילים באמצעות פינוי והחלפה.

שינוי התקציבים בהתאם למצב התהליך

אפליקציה דורשת כמויות שונות של זיכרון בהתאם לנראות שלה למשתמש:

  • חזית: התהליך מארח פעילות גלויה שיוצרת אינטראקציה עם המשתמש. במצב הזה בדרך כלל יש את טביעת הרגל הגדולה ביותר בגלל ממשק המשתמש והגרפיקה הפעילים.
  • ניתן לזיהוי: המשתמש יכול לזהות את התהליך, אבל הוא לא מארח חלון גלוי (לדוגמה, אירוח של שירות שפועל בחזית להפעלת מדיה, הורדה פעילה ברקע, מסלול מפורט או שיטת קלט פעילה).
  • ברקע: התהליך מריץ משימות, מקלטים או סנכרוני נתונים ברקע. הוא צפוי לשמור על טביעת רגל מינימלית.

אפשר להצהיר על כמה משפטי <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 לעיבוד חלק של פריימים: צריך להקצות מספיק מקום לממשק המשתמש שפועל בחזית כדי שפינוי הזיכרון הסינכרוני לא יעכב את עיבוד הפריימים. אם האפליקציה כוללת תהליך עבודה עצמאי שדורש הרבה זיכרון (כמו עורך תמונות או סרטונים ברזולוציה גבוהה), כדאי להעביר את הפעילות הזו לתהליך ייעודי (ראו אפליקציות עם כמה תהליכים) כדי שההקצאות שלו יתוקצבו בנפרד ויוסרו כשהמשתמש ייצא.
  • הקצאת תקציבי background קפדניים לעבודה ללא ממשק משתמש: ערכי RSS גבוהים ברקע בנתוני הטלמטריה בשטח משקפים לעיתים קרובות הקצאות של ממשק משתמש שנשמרו אחרי היציאה מהחזית (ראו מיקום משותף של שירות מאוגד עם ממשק משתמש שדורש הרבה זיכרון ושחרור זיכרון בתגובה לאירועים) או קריאות חד-פעמיות של קבצים במהלך הוספה לאינדקס וסנכרון ברקע. מכיוון שלעבודה ברקע ללא ממשק משתמש אין מועדים אחרונים למסגרות, פינוי דפים של מטמון קבצים חד-פעמיים והחלפת דפים לא פעילים של ערימה ל-zRAM כשסנכרון ברקע מקצה לזמן קצר מעבר לתקציב שלו לא גורם לבעיות שגלויות למשתמש, ומונע מעליות חדות ברקע לפנות את האפליקציה שפועלת בחזית של המשתמש.

כאן תוכלו לקרוא הוראות מפורטות למדידת קבוצת העבודה וכיול התקציבים.

איך מצבי התקציב ממפים למצבי התהליך

הפלטפורמה ממפה את מצבי התקציב במניפסט למצבי תהליך בזמן ריצה על סמך RunningAppProcessInfo.importance, שמנהל הפעילות מחשב גם מהרכיבים הפעילים של התהליך עצמו וגם מכל כבילות השירות הנכנסות או שאילתות של ספקים לניהול גישה מאפליקציות אחרות:

  • foreground: אינטראקציות פעילות וגלויות של משתמשים, כמו אירוח של Activity שהתחדשה, שמירה על מצב האפליקציה העליונה כשהמסך נכבה, או אירוח של שירות או ספק לניהול גישה שקשורים באופן פעיל לאפליקציה שפועלת בחזית או נשלחת אליהם שאילתה מהאפליקציה הזו.
  • ‫perceptible: עומסי עבודה שהמשתמש יכול להבחין בהם בלי חלון גלוי, כמו הפעלת מדיה פעילה, ניווט מפורט, צילום במצלמה או הקלטה במיקרופון, הורדות פעילות ברקע, שירותים שפועלים בחזית לסנכרון נתונים או שירותים שקשורים ללקוח שפועל בחזית באמצעות דגלים כמו BIND_NOT_FOREGROUND (ראו שליטה בירושה באמצעות דגלי BIND). יכול להיות שבמדדים פנימיים של הפלטפורמה חלק מעומסי העבודה האלה יסומנו כ-PROCESS_STATE_IMPORTANT_FOREGROUND, אבל הקבוע הפנימי הזה לא מציין חלון גלוי והוא נשלט על ידי התקציב של perceptible.
  • background: עבודה שהמשתמש לא יכול לראות באופן מיידי, כמו משימות ברקע, התראות ומקלטי שידור.

בטבלה הבאה מוצג המיפוי של רמות החשיבות בזמן הריצה למצבי המניפסט:

מניפסט 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) בלי רכיבים פעילים, הפלטפורמה משחררת את תקציב הזיכרון הפעיל שלו ומנהלת את הזיכרון שלו באמצעות החזרה רגילה של תהליכים שנשמרו במטמון.

בדיקת מצב התהליך והתקציב של האפליקציה

אחת הדרכים לבדוק את המצב והחשיבות של התהליך הפעיל של האפליקציה במהלך הפיתוח היא לשלוח שאילתה ל-Activity Manager באמצעות 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.
  • בטבלת הבקרה של Process OOM, הערך prcp מציין שהתהליך נבדק ברמת העדיפות של perceptible, שתואמת לתקציב perceptible.

כדי לבדוק את מגבלת הזיכרון שנאכפת כרגע ואת השימוש בזיכרון הפיזי:

adb shell dumpsys meminfo <package-name>

החל מ-Android 17 QPR2, הפלט הזה כולל את הקטע Memory Budget, שבו מוצגים התקרה של המגבלה הפעילה, מקור המגבלה (כמו AndroidManifest או MemoryBudgetManager) והזיכרון הנוכחי של התושב.

אפליקציות עם כמה תהליכים

אם האפליקציה מחלקת את העבודה שלה בין כמה תהליכים, צריך להגדיר תקציבי תהליכים ייעודיים באמצעות התג <process> בתוך <processes>.

לדוגמה, נניח שיש לכם אפליקציה לסטרימינג של מוזיקה (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> במניפסט, צריך לציין רשומות <process> רק לתהליכים שמצהירים על <memory-budget>. אם רוצים להקצות תקציב רק לתהליכי משנה ספציפיים ולהשאיר את שאר האפליקציה במגבלת המערכת של המכשיר, אפשר להשמיט את הרכיבים <memory-budget> ישירות מתחת ל-<application>.

השימוש בזיכרון בכל תהליך משנה נספר גם בתקציב התהליך וגם בתקציב החבילה שמכילה אותו. תהליך חווה עומס על הזיכרון בזמן ריצה אם הוא חורג מתקציב התהליך או מתקציב החבילה, לפי הסף שאליו מגיעים קודם.

התאמת התקציבים למסכים עם דחיסות גבוהה של פיקסלים

באפליקציות שבהן הזיכרון שבשימוש משתנה באופן משמעותי בהתאם למספר הפיקסלים שצריך לצייר בתצוגה בבת אחת – כמו אפליקציית גלריית תמונות ששומרת במטמון מפות סיביות בגודל המסך – Android מספקת שני מנגנונים חלופיים לשינוי דינמי של התקציבים בהתאם למפרט התצוגה:

  • התאמה לפי קטגוריית צפיפות התצוגה (android:additionalMbPerDensity): הוספת מגה-בייטים באופן יחסי לריבוע של יחס הצפיפות של התצוגה בהשוואה ל-mdpi (1.0x / 160 dpi), כי נכסי ממשק המשתמש ורכיבי ה-drawable של רסטר דו-ממדי מותאמים גם לרוחב וגם לגובה:

    <!-- 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): הוספת בייטים ישירות לכל פיקסל בתצוגה הפיזית (רוחב × גובה). האפשרות הזו מתאימה לאפליקציות שמקצות משטחים גרפיים במסך מלא, מאגרי נתונים זמניים לעיבוד או מטמונים של תמונות ברזולוציה מלאה, שבהם צריכת הזיכרון משתנה ישירות בהתאם למספר הפיקסלים הגולמיים בתצוגה ולא בהתאם לקטגוריות של צפיפות ממשק המשתמש:

    <!-- 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.4MB לתקציב הבסיסי. במסך 1440p (‏‎1440 × 3120‎, בערך 4.49 מיליון פיקסלים), זה מוסיף בערך 71.8MB.

שני המאפיינים האלה הם חלופות. בחרו את המאפיין שתואם לגורם העיקרי לקביעת קנה המידה של האפליקציה, ואל תשלבו את שניהם באותו סעיף.

התאמה לגורמי צורה של מכשירים

כשמפיצים קובץ APK לטלפונים, לטאבלטים ול-Wear OS, משתמשים במאפיין 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 charge (לא כולל זיכרון משותף כמו Zygote).

מאפיין פורמט ברירת מחדל תיאור
android:maxMb מספר שלם (‎> 0) חובה מגבלת התקציב הבסיסית של הזיכרון התושב ב-MB.
android:state טיפוסים בני מנייה (enum) כל צבע מצב התהליך שהתקציב הזה חל עליו: foreground,‏ perceptible או background. אם משמיטים את הסעיף, הוא משמש כברירת מחדל לכל מצב שלא צוין.
android:additionalMbPerDensity מספר שלם (‎≥ 0) 0 מגה-בייטים נוספים כפול הריבוע של יחס צפיפות התצוגה ביחס ל-mdpi (1.0x).
android:additionalBytesPerDisplayPixel מספר שלם (‎≥ 0) 0 בייטים נוספים שהוקצו לכל פיקסל במסך הפיזי (רוחב × גובה), שימושי למאגרי נתונים זמניים (buffers) של משטחים ולמפות סיביות (bitmaps).
android:feature מחרוזת כל צבע הגבלת הסעיף למכשירים שמצהירים על תכונות חומרה ספציפיות: watch,‏ automotive או leanback.

מדידת קבוצת העבודה וכיול התקציבים

תקציב טוב הוא תקציב שגבוה רק במעט מה-working set של האפליקציה במצב נתון, ולא בשיא הבלתי מוגבל שלה. כדי למצוא את הנקודה הזו, מפעילים מסלול משתמש ריאלי, מקטינים את התקציב בהדרגה וצופים ברגע שבו האפליקציה מתחילה לשלם על כך.

ההבדל בין זיכרון של קבוצת עבודה לבין זיכרון תושב

בלי תקציב, טביעת הרגל של האפליקציה בזיכרון (memory.current) מכילה שני סוגים של זיכרון:

  • קבוצת העבודה: הערימה של Java,‏ Kotlin ו-Native, והדפים החמים שמגובים בקבצים (קוד DEX, ספריות Native ונכסים) שמסלול המשתמש הנוכחי נוגע בהם שוב ושוב
  • מרווח קר: מטמוני מפת סיביות ממסכים קודמים, מאגרי הפעלה חד-פעמיים, מרווח בערימה שנשאר אחרי הקצאה פתאומית ודפי קבצים שנקראו פעם אחת ולא נגעו בהם שוב

תקציב בשיא הבלתי מוגבל אף פעם לא מחזיר את ההפרש מהתקופה הקרה. אם התקציב נמוך מהקבוצה הפעילה, הליבה תחליף דפי ערימה ותסיר דפי קוד שהאפליקציה תחזיר מיד. השגיאות האלה בסט העבודה גוזלות זמן מעבד וסוללה, וכשהן מגיעות לשרשור ה-UI, הן גוזלות גם פריימים.

מה שאתם מחפשים הוא הברך: התקציב הנמוך ביותר שבו ההחזרה מסירה רק את הזיכרון הלא פעיל, כך שהתקלות החוזרות, זמן CPU (מעבד) וזמני רינדור פריים נשארים כמו שהיו ללא תקציב.

הכנת סביבת הבדיקה

הגדירו בדיקה שניתן לחזור עליה, שמתנהגת כמו סביבת הייצור:

  • מבצעים כיול של אותה גרסת build להפצה שעברה אופטימיזציה ל-R8, שלא ניתן לנפות בה באגים, ושאתם מפיצים. גרסאות build לניפוי באגים משתמשות ביותר זיכרון, ולכן תקציב שמותאם לגרסת build לניפוי באגים הוא גבוה מדי לגרסת הפצה.
  • בודקים במכשיר או באמולטור עם Android 17 QPR2 (רמת API‏ 37.2) ומעלה. הכי טוב להשתמש ב-build של userdebug או בתמונת אמולטור בלי Google Play, כי בחלק מהמכשירים אפשר לקרוא את קובצי ה-cgroup בקטעים הבאים רק אחרי שמריצים את adb root.
  • יוצרים סקריפט של מסלול משתמש באורך 30 עד 60 שניות באמצעות Jetpack Macrobenchmark, ‏UI Automator או adb shell input, פעם אחת לכל מצב שמתכננים להקצות לו תקציב. אפליקציה לא פעילה לא מספקת מידע על קבוצת העבודה שלה.
    • ‫foreground: גלילה ברשימות צפופות וברשתות מדיה, מעבר בין היעדים העיקריים, פתיחת תפריטים ותיבות דו-שיח והפעלת אנימציות
    • ‫perceptible: יוצאים מהחלון הגלוי בזמן שהפעולה שניתן להבחין בה ממשיכה (לדוגמה, הפעלת אודיו או הורדה), נותנים לה לפעול במשך 10 עד 15 שניות ואז חוזרים לאפליקציה.
    • ‫background: הפעלת סנכרון, משימה של WorkManager (לדוגמה, באמצעות adb shell cmd jobscheduler run -f <package-name> <job-id>), שירות שהופעל או מקלט שידור, כשהאפליקציה לא מציגה ממשק משתמש

מודדים תקציב של background בזמן שהעבודה ברקע פועלת. אחרי שהתהליך נשמר במטמון, הפלטפורמה משחררת את התקציב שלו (ראו מיפוי מצבי התקציב למצבי התהליך), כך שתהליך לא פעיל שנשמר במטמון לא מספק מידע על התקציב של background.

מדידת קו הבסיס

כדאי להתחיל עם הרצות ללא תקציב בכלל, כדי לדעת איך נראית הרצה רגילה. משתמשים בגרסה ללא הצהרות <memory-budget>, ומתחילים כל הרצה מתהליך חדש (adb shell am force-stop <package-name>).

לכל תהליך של אפליקציה יש בקר זיכרון cgroup v2 משלו. חפשו את הנתיב שלו בשורה 0:: של /proc/<PID>/cgroup במקום ליצור את הנתיב בעצמכם, כי פריסת ה-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, בבייטים
  • ‫anon ו-file ב-memory.stat: זיכרון אנונימי וזיכרון מגובה קבצים, בבייטים
  • ‫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) לאותו ערך, כך שתקציב מועמד שנמוך מקבוצת העבודה של האפליקציה יכול לגרום לסגירת התהליך במקום רק להאט אותו. התייחסו לזה כאל תקציב שנכשל.

אפשר גם לבצע סריקה מ-build של בדיקת השוואה על ידי קריאת ערך מועמד מארגומנט אינסטרומנטציה והגדרת MemoryBudgetManager.processBudgetBytes. תקציב זמן ריצה יכול רק להקטין תקציב מניפסט, לכן צריך לבצע סריקה בגרסה ללא הצהרות <memory-budget>. הוא גם נשאר בתוקף במהלך שינויים במצב התהליך עד שקוראים ל-clearProcessBudget.

מתחילים בערך memory.current הגבוה ביותר מקו הבסיס, ומפחיתים 10 עד 15 MB בכל פעם. לכל תקציב מועמד:

  1. מתחילים תהליך חדש, מביאים אותו למצב היעד ומחילים את התקציב.
  2. מפעילים את מסלול המשתמש פעם אחת כחימום. החלת תקציב מסירה דפים של הפעלה מההתחלה (cold startup) פעם אחת, ואתם רוצים למדוד מצב יציב ולא את המעבר הזה.
  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, את מוני ה-refault ואת זמן ה-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 נשאר קבוע, הערכים של refaults נשארים קרובים לקו הבסיס, וזמן המעבד וזמני רינדור הפריים לא משתנים. זה המקום שבו אתם רוצים להיות.
  • מתחת לברך: הדפים הקרים נעלמו, ולכן הליבה מתחילה להחליף דפי ערימה שהאפליקציה עדיין משתמשת בהם. מספר השגיאות מסוג Refault קופץ במאות או באלפי דפים בכל הפעלה, וזמן המעבד (CPU) עולה. זמני רינדור הפריים כמעט לא ישתנו במכשיר בדיקה שבמצב אחר היה לא פעיל, אבל האפליקציה תנצל עכשיו את זמן המעבד (CPU) ואת הסוללה כדי לדחוס ולפרוס את קבוצת העבודה שלה.
  • הרבה מתחת לברך: שגיאות של החזרה והחלפה נוחתות על שרשור ה-UI ועל RenderThread, ובעיות בממשק (jank), זמני רינדור פריים וזמני החזרה לפעולה במצב חם עולים.

בהשוואה לתקציב הבסיסי הלא מוגבל, תקציב מועמד עובר אם הוא עומד בכל התנאים הבאים, שהם נקודת התחלה סבירה:

  • התהליך ממשיך גם אחרי ההרצה
  • ההפרשים בשיעור ברירת המחדל נשארים קרובים לקו הבסיס
  • זמן ה-CPU של התהליך עולה ב-5% לכל היותר
  • עלייה של עד אחוז אחד בפריימים שגורמים לבעיות בממשק (jank)
  • זמן רינדור הפריים באחוזון ה-95 עולה ב-3 אלפיות השנייה לכל היותר
  • זמן החזרה לפעילות במצב ביניים (TotalTime מתוך adb shell am start -W) עולה ב-15% לכל היותר, בתקציבים של perceptible ו-background

הברך היא התקציב המועמד הנמוך ביותר שעובר. לדוגמה, הנה סריקה של החזית של אפליקציה היפותטית עם שיא של 138MB ללא הגבלה:

תקציב המועמד memory.current anon file החזרת ברירת המחדל (דפים) זמן רינדור פריים באחוזון ה-95 משטר
ללא 138 MB 75MB 63MB 0 ‫10.5 אלפיות השנייה ערך הבסיס
125MB 121 MB 74MB 47MB 10 ‫10.5 אלפיות השנייה מעל הברך
110 MB 110 MB 75MB 35MB 15 ‫10.8 אלפיות השנייה ברך
95MB 95MB 74MB ‫21 MB 176 ‫11.2 אלפיות השנייה מתחת לברך
85MB 85MB 68MB 17 MB 2,050 ‫12.8 אלפיות השנייה מתחת לברך
70MB 70MB 54 MB ‫16 MB 8,771 ‫16.5 אלפיות השנייה הרבה מתחת לברך

בחירת התקציב

כדאי להגדיר את android:maxMb ב-10% עד 20% מעל לערך הסף, כדי שהבדלים בנתוני המשתמשים, במיקומים ובפרצי ההקצאה לא יגרמו לאפליקציה לרדת מתחת לערך הסף. בדוגמה הקודמת, נקודת הברך היא ב-110MB, ולכן 125MB היא בחירה טובה.

אחר כך מתקינים גרסת build להפצה עם ההצהרות החדשות של <memory-budget>, מפעילים שוב את מסלול המשתמש ובודקים אם הקריטריונים למעבר עדיין מתקיימים. בקטע "תקציב הזיכרון" של dumpsys meminfo מוצג התקציב הפעיל והמקור שלו.

אימות תקציבים שלא פועלים בחזית עם החזרה חמה

תקציב מצומצם של 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 (כ-2.59 מיליון פיקסלים) ו-‎1440 × 3120 (כ-4.49 מיליון פיקסלים). מחלקים את ההפרש בין שתי נקודות הברך, בבייטים, בהפרש במספר הפיקסלים כדי לקבל את android:additionalBytesPerDisplayPixel. מפחיתים את הכמות הזו לכל פיקסל של המסך הקטן יותר מהברך שלו כדי לקבל את הבסיס הבלתי תלוי ברזולוציה, ומוסיפים מרווח כדי לקבל android:maxMb.

ממשקי API של זמן ריצה (אפשרות דינמית משנית)

הצהרה על תקציבים באופן סטטי ב-AndroidManifest.xml היא הפתרון המועדף כמעט לכל האפליקציות. עם זאת, באפליקציות עם עומסי עבודה דינמיים או לניסויים בזמן ריצה, Android מספקת ממשקי API של SDK ו-NDK בזמן ריצה כאפשרות משנית.

באמצעות ה-API של זמן הריצה אפשר:

  • שאילתה לגבי השימוש הנוכחי בזיכרון והתקציבים בפועל.
  • התאמה דינמית של תקציב התהליך כלפי מטה.
  • האזינו לאירועים של חריגה מהתקציב כדי לצמצם באופן יזום את המטמונים לפני שמערכת ההפעלה מפעילה החזרה ישירה.

Android SDK API (MemoryBudgetManager)

שירות המערכת MemoryBudgetManager זמין לאפליקציות שכתובות ב-Kotlin וב-Java החל מ-Android 17 QPR2 (גרסת SDK משנית, רמת API‏ 37.2 / Build.VERSION_CODES_FULL.CINNAMON_BUN_2).

אחזור השירות

לפני שאתם ניגשים אל MemoryBudgetManager, ודאו שבמכשיר פועלת גרסת Android 17 QPR2 ואילך. כדי לעשות את זה, צריך לבדוק את SDK_INT_FULL. אם minSdk של האפליקציה נמוך מ-36, קודם צריך לבדוק את SDK_INT כדי שמכשירים עם Android בגרסה 15 (רמת API‏ 35) או גרסאות קודמות לא יזרקו את NoSuchFieldError בזמן הקריאה של SDK_INT_FULL:

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 האלה במהלך מעברים נפרדים במחזור החיים ובאבני דרך של משימות (כמו כניסה לפעילות או יציאה ממנה, התחלה או השלמה של עיבוד ברקע או טיפול בבקשות דיבור), ולהימנע מהפעלתם בלולאות הדוקות, בשרשורים של עיבוד אודיו או בשגרות עיבוד לכל פריים.
  • תדירות השאילתות: מאפייני השאילתות processCurrentUsageBytes ו-packageCurrentUsageBytes מאחזרים את השימוש בזיכרון בזמן אמת משירות המערכת. למרות שהן מהירות, צריך להפעיל שאילתות לפי דרישה (למשל במהלך מעברים בין משימות או בתוך קריאות חוזרות (callbacks) של OnOverBudgetListener) במקום לדגום אותן בלולאות רציפות.

האזנה לקריאות חוזרות (callbacks) של לחץ על התקציב

אפליקציות יכולות לרשום מאזין כדי לקבל התראה כשהשימוש בזיכרון חורג מסף התקציב. כך האפליקציה יכולה לבצע ניקוי יזום ברמת האפליקציה (כמו ניקוי מטמונים של מפת סיביות בזיכרון) בזמן שמערכת ההפעלה מפנה זיכרון כדי לשמור על התהליך או החבילה במסגרת התקציב:

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 למאזינים שנוגעים רק במצב של ממשק המשתמש שקשור ל-thread הראשי.
  • התראות חוזרות: התראות על חריגה מהתקציב הן לא התראות חד-פעמיות. כשהשימוש חורג מהתקציב, מערכת ההפעלה מחזירה קודם דפים קרים. אם הלחץ נמשך, המערכת ממשיכה להחזיר זיכרון ולהפעיל את ה-listener לכל היותר פעם בשנייה. חשוב לוודא שהקריאה החוזרת היא אידמפוטנטית. אם האפליקציה צריכה רק התראה אחת או אם רוצים תקופת צינון ארוכה יותר בין סבבי הניקוי, צריך להפעיל את unregisterProcessOverBudgetListener (או את unregisterPackageOverBudgetListener) בתוך פונקציית ה-callback, או להגן על הניקוי באמצעות חותמת זמן של תקופת צינון או AtomicBoolean.
  • רישום לפני הקטנת התקציב: אם השימוש הנוכחי בזיכרון כבר חורג מהתקציב החדש כשמפעילים את setProcessBudgetBytes או את setPackageBudgetBytes, אירוע חריגה מהתקציב מופעל ברגע שהתקציב החדש מוחל. כדי שהאפליקציה לא תפספס את ההתראה הראשונית על חריגה מהתקציב, צריך לרשום את המאזין לפני שמגדירים את התקציב.
  • מהירות: פעולות ההחזרה צריכות להציע הקלה מיידית. חישובים מורכבים בזמן עומס פוגעים בביצועים.
  • הימנעו מהקצאות: אל תקצו אובייקטים חדשים ואל תתחילו שרשורים חדשים בתוך הקריאה החוזרת, כי זה עלול להפעיל מיד החזרה ישירה של מערכת ההפעלה.
  • התמקדות ביעדים עם תשואה גבוהה: פינוי של מפות סיביות (bitmap) גדולות, מאגרי נתונים זמניים (buffer) לעיבוד או סגירת קבצים שממופים בזיכרון, יעילים הרבה יותר משחרור של הרבה אובייקטים קטנים.

Native NDK API (<android/memory_budget_manager.h>)

אפליקציות נייטיב יכולות להשתמש ב-C NDK API שחשוף ל-libandroid.so החל מ-Android 17 QPR2 (רמת API‏ 37.2).

הגדרת 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. ‫High-Level Watcher (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 של Thread עובד ברקע, במקום לממשק המשתמש הראשי או ל-Thread הרינדור, כדי שפינוי המטמון לא יתחרה ברינדור הפריימים. כדי שהאפליקציה לא תפספס את האירוע הראשוני של חריגה מהתקציב, צריך ליצור את הצופה לפני שמצמצמים את התקציב באמצעות AMemoryBudgetManager_setProcessBudget או AMemoryBudgetManager_setPackageBudget. כל עוד התהליך או החבילה חורגים מהתקציב ומערכת ההפעלה ממשיכה לפנות זיכרון, הצופה מפעיל את הקריאה החוזרת שוב ושוב (לכל היותר פעם אחת בכל מרווח זמן של debounce_ms). הפכו את הקריאה החוזרת לאידמפוטנטית, או קראו ל-AMemoryBudgetManager_Watcher_destroy (שאפשר לקרוא לה בבטחה מתוך הקריאה החוזרת) אם האפליקציה שלכם צריכה רק התראה חד-פעמית.

דוגמאות ל-Runtime API

הדוגמאות הבאות מראות איך להטמיע את ממשקי ה-API של זמן הריצה באמצעות Android SDK API (כתוב ב-Kotlin) ו-Native NDK API (כתוב ב-C++‎).

דוגמה ל-Android SDK: עורך תמונות מותאם אישית

בדוגמה הזו מוצגת אפליקציה לעריכת תמונות (com.example.imageeditor) שהמניפסט שלה מצהיר על תקרה של 256MB כדי להתאים לקנבס מרובה השכבות שלה:

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

כשהמשתמש מדפדף בגלריית התמונות הממוזערות הקלה, האפליקציה משתמשת ב-Android SDK API ב-Kotlin כדי להקטין באופן דינמי את תקציב התהליך ל-96MB. כשהמשתמש פותח את אזור העריכה של כמה שכבות, האפליקציה מנקה את התקציב הדינמי כדי לשחזר את התקרה המלאה של 256MB במניפסט. הוא גם רושם OnOverBudgetListener ב-HandlerThread ברקע, Looper לפני החלת תקציב הגלריה כדי להסיר מפות סיביות (bitmap) של תצוגה מקדימה שנשמרו במטמון, בלי לחסום את שרשור ה-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++‎: מנוע תלת-ממדי מקורי

בדוגמה הזו מוצג מנוע משחק מקורי ב-C++‎ שמנהל את תקציבי הזיכרון על סמך רמת האיכות הפעילה של הגרפיקה, בהנחה שבמניפסט של האפליקציה מוגדרת תקרה של 512MB כדי להתאים לגרפיקה באיכות גבוהה (android:maxMb="512"). המנוע מפעיל את AMemoryBudgetManager_Watcher_create ב-ALooper של Thread עובד ברקע לפני שהוא מצמצם את התקציב להגדרות קבועות מראש באיכות נמוכה יותר, ופורק את ה-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;
};