ניהול הזיכרון של WebView ואבחון בעיות שקשורות לזיכרון

WebView מריץ קוד Native בכמה תהליכים כדי לעבד תוכן מהאינטרנט באפליקציה ל-Android. אם לא מנהלים את המופעים של WebView, עלולות להתרחש דליפות זיכרון, קריסות בגלל חוסר בזיכרון (OOM) ופגיעה בביצועי האפליקציה.

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

הסבר על ארכיטקטורת הזיכרון של WebView

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

  • הפעלה של כמה תהליכים: ב-Android מגרסה 8.0 (רמת API‏ 26) ומעלה, WebView מפריד בין תוכן מהאינטרנט לבין הפונקציות העיקריות של האפליקציה בכמה תהליכים (במכשירים עם זיכרון RAM נמוך, יכול להיות שהמערכת תחזור לתהליך יחיד):

    • תהליך מארח (דפדפן): התהליך הראשי של האפליקציה שבו פועלים Activity וקוד Java או Kotlin.
    • תהליך עיבוד מבודד: תהליך נפרד בארגז חול (SandboxedProcessService) שמנתח HTML ו-CSS, מפעיל JavaScript ומעבד דפי אינטרנט.
  • הזיכרון שבשימוש: רוב הזיכרון של WebView, כולל גרפיקה שעברה רינדור, עץ ה-DOM וזיכרון זמן הריצה של JavaScript, מוקצה בזיכרון המקורי ולא ב-Java heap. תמונת מצב של הזיכרון מ-Java‏ (.hprof) מציגה רק אובייקט wrapper קל של Java, ולא מתעדת את הזיכרון האמיתי שבו משתמש התוכן מהאינטרנט.

  • ההשפעה של זיכרון נייטיב על המערכת: בניגוד להקצאות זיכרון בערימה (heap allocation) של Java, שמוגבלות על ידי המגבלה של maxHeap באפליקציה ונכשלות במהירות עם OutOfMemoryError, זיכרון נייטיב יכול לגדול בשקט לג'יגה-בייטים. ככל שהזיכרון המקורי שלא שוחרר ממלא את זיכרון ה-RAM הפיזי ואת שטח ההחלפה (zRAM), מנגנון Low Memory Killer‏ (LMK) של Android מתחיל לסיים תהליכים ברקע כדי לפנות זיכרון. הפעולה הזו פוגעת ביכולת של המכשיר לבצע כמה משימות בו-זמנית, ובסופו של דבר סוגרת את האפליקציה שפועלת בחזית.

ניהול מחזור החיים של WebView

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

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

ניקוי מופעים של WebView

כדי לוודא שהמשאבים של Activity או Fragment יושבתו בצורה נקייה וישוחררו כשהם יושמדו, צריך לבצע את הפעולות הבאות:

  1. מסירים את WebView מהמאגר הראשי (ViewGroup).
  2. עצירת הטעינה הפעילה ומחיקת היסטוריית הניווט.
  3. רוצה להתקשר ל-destroy()?
  4. מנקים את ההפניה אל null.

בדוגמה הבאה אפשר לראות איך מנקים WebView בצורה נכונה:

Kotlin

override fun onDestroy() {
    myWebView?.let {
        // Remove the WebView from its parent ViewGroup.
        (it.parent as? ViewGroup)?.removeView(it)
        // Stop active loading and clear history.
        it.stopLoading()
        it.clearHistory()
        // Destroy the instance.
        it.destroy()
    }
    myWebView = null
    super.onDestroy()
}

Java

@Override
protected void onDestroy() {
    if (myWebView != null) {
        // Remove the WebView from its parent ViewGroup.
        if (myWebView.getParent() instanceof ViewGroup) {
            ((ViewGroup) myWebView.getParent()).removeView(myWebView);
        }
        // Stop active loading and clear history.
        myWebView.stopLoading();
        myWebView.clearHistory();
        // Destroy the instance.
        myWebView.destroy();
    }
    myWebView = null;
    super.onDestroy();
}

הסבר על הזיכרון אחרי ההשמדה

כשמפעילים את destroy(), המערכת משחררת את ההקשר של Activity, מנקה את היררכיות התצוגות ומפסיקה את העבודה של האתר ברקע. עם זאת, יכול להיות שתבחינו שהזיכרון הפיזי של התהליך (Resident Set Size) לא יורד מיד לרמת הבסיס שלפני WebView.

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

מדדי מפתח לניפוי באגים

כשמנתחים את צריכת הזיכרון של WebView, צריך להתמקד במדדים הבאים:

  • ‫Resident Set Size (RSS): זיכרון ה-RAM הפיזי הכולל שממופה לתהליך, כולל קוד וספריות משותפים (מסומן כ-Total בכלי הפרופיילינג של Android Studio).

  • ‫RSS אנונימי (RssAnon): זיכרון שהוקצה ישירות על ידי התהליך, שלא מגובה בקובץ בדיסק (כמו הקצאות של זמן ריצה של JavaScript ו-native heap). זה מייצג את עלות הזיכרון העיקרית של התוכן מהאינטרנט (מסומן כ-Allocated ב-Android Studio Profiler).

  • הזיכרון הפרטי שבשימוש (PMF): הסכום של RSS אנונימי ושטח החלפה (zRAM). ה-PMF משקף את עומס הזיכרון בפועל שהאפליקציה מטילה על המערכת, שלא ניתן לפנות.

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

  • ‫Live Object Counts (WebViews, Activities, Views): מספר המופעים הפעילים של ממשק המשתמש, ההקשר ו-WebView ששמורים בזיכרון. מעקב אחרי הנתונים האלה מאפשר לזהות אם הגידול בזיכרון נגרם על ידי הפניות Java שנשמרו או הקצאות מקוריות בלבד.

  • Private Other ו-Native Heap: ב-dumpsys meminfo, הקצאות מקוריות של C/C++‎ ומיפויים מותאמים אישית של זיכרון (כמו PartitionAlloc של Chromium או ערימות של זמן ריצה מוטמע של JavaScript) מופיעים בקטעים Native Heap ו-Private Other, ולא ב-Java Heap.

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

תהליכי עבודה מעשיים לאבחון

‫WebView פועל בכמה תהליכים ומקצה זיכרון מקורי, לכן צריך להשתמש בכלים ובשיטות הבאים כדי לבדוק את טביעת הרגל שלו:

כלי פרופיילינג ואבחון

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

  • Memory Profiler ב-Android Studio: אתם יכולים להשתמש ב-Memory Profiler כדי להציג הקצאות מקוריות, לעקוב אחרי קטגוריות של זיכרון לאורך זמן ולזהות דליפות של Activity במעברים בין מסכים.

  • מעקב אחרי הזיכרון באמצעות Perfetto: אתם יכולים להשתמש ב-Perfetto כדי לתעד את מוני הזיכרון ברמת המערכת (כמו RSS ו-RSS אנונימי) ולעקוב אחרי הגידול הכולל בזיכרון. שימו לב שהקצאות מנוע מקוריות של WebView לא יוצרות מחסניות קריאות בכלי ליצירת תמונת מצב של ערימה ב-Perfetto. משתמשים בכלי הפיתוח ל-Chrome כדי לבדוק את תמונות המצב של ה-heap של JavaScript ואת הקצאות ה-DOM בתוכן מהאינטרנט.

בדיקת מספר האובייקטים בזמן אמת

כדי לקבוע אם הגידול בזיכרון נגרם על ידי אובייקטים שמורים של מסגרת Java (כמו רכיבי ממשק משתמש) או הקצאות מקוריות, בודקים את הקטע Objects של dumpsys meminfo:

adb shell dumpsys meminfo -a <var>PACKAGE_NAME</var> | grep -A 10 "Objects"

בפלט מוצגות ספירות של אובייקטים בזמן אמת:

 Objects
               Views:     142         ViewRootImpl:        1
         AppContexts:       3           Activities:        1
              Assets:      12        AssetManagers:        0
       Local Binders:      32        Proxy Binders:       45
       Parcel memory:      15         Parcel count:       30
    Death Recipients:       2             WebViews:        1

בקטע הזה מוצגות ספירות של אובייקטים פעילים של מסגרת, נקודות אחיזה של IPC והקצאות של Parcel. כדי לאבחן את WebView, מתמקדים בעיקר ב-Activities וב-WebViews.

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

  • זליגת מופעים: אם הערך של WebViews או Activities גדל בכל ניווט ולא חוזר לערך הבסיס, יש באפליקציה זליגה של מופע Java WebView או מארח Activity (לדוגמה, בגלל ViewGroup.removeView() חסר או הפניות למאזינים שנשמרו). בגלל ש-Activity שדלף מצמיד את כל עץ התצוגה שלו ואת משאבי התמונה המפוענחים בזיכרון, ביקורים חוזרים ירוקנו במהירות את ערימת ה-Java ויגרמו לקריסות של OutOfMemoryError.

  • זליגה מקומית או זליגה ב-DOM: אם הערכים של WebViews ו-Activities נשארים קבועים, אבל הערכים של Private Other ו-RSS של התהליך הכולל ממשיכים לעלות, מקור הזליגה הוא במשאבים מקומיים שלא שוחררו, ברכיבי DOM או בקישורים של מנוע JavaScript. ההקצאות האלה נמצאות בזיכרון המקורי ועוקפות את מנגנון איסוף האשפה של ART, ולכן הן לא גלויות לכלים הרגילים של Java לזיהוי דליפות, וממשיכות להצטבר עד שמערכת ההפעלה סוגרת את האפליקציה.

יצירת פרופיל של תהליך מנוע הרינדור המבודד באמצעות CLI

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

  1. מוצאים את מזהה התהליך (PID) של שירות העיבוד המבודד:

    adb shell dumpsys activity processes <var>PACKAGE_NAME</var> | grep "Isolated.*SandboxedProcessService"

    הפלט מציג את רשומת התהליך המבודד ואת ה-PID שלו RENDERER_PID (לדוגמה, 22155):

    Isolated #5: ProcessRecord{... 22155:com.google.android.webview.debug:sandboxed_process0:...}
    
  2. בודקים את פירוט הזיכרון של תהליך הרינדור באמצעות מזהה התהליך (PID) שלו:

    adb shell dumpsys meminfo <var>RENDERER_PID</var>
  3. בודקים את תהליך האפליקציה המארחת כדי להעריך את טביעת הרגל בצד הדפדפן:

    adb shell dumpsys meminfo <var>PACKAGE_NAME</var>

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

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

adb shell "cat /proc/$(pidof <var>PACKAGE_NAME</var>)/maps" | grep "anon:"

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

תג זיכרון תת-מערכת רלוונטיות לתוכן של האפליקציה ותוכן מהאינטרנט מה הסיבה הנפוצה לעלייה בשימוש בזיכרון?
[anon:partition_alloc] ‫Chromium PartitionAlloc הקצאות לעצי DOM, מאגרי עיבוד, ערימת JavaScript V8 והרצת WebAssembly ב-WebView. כן (גבוה): טעינה של דפי אינטרנט כבדים, מודלים של אובייקטים במסמך (DOM) עשירים במדיה או כשל בקריאה ל-destroy() במופעים של WebView שנמחקו, מגדילים ישירות את התג הזה.
[anon:scudo...] או [anon:libc_malloc] מקצי הזיכרון בערימה (heap) של Android‏ (Scudo / jemalloc) הקצאות מקוריות כלליות של C/C++‎ שמשמשות את ספריות NDK, גשרי JNI וצינורות עיבוד נתונים גרפיים מקוריים. כן (בינוני עד גבוה): הגידול מתרחש כש-wrapper מקורי של JNI או תלויות C++‎ של צד שלישי שומרים על הקצאות שלא שוחררו בין ניווטים.
[anon:...] (לדוגמה, [anon:quickjs_heap...]) סקריפטים בהתאמה אישית או סביבות זמן ריצה מקוריות מנועי JavaScript מוטמעים, סביבות זמן ריצה מותאמות אישית של WebAssembly או מאגרי מאגרים מקוריים מותאמים אישית. כן (תלוי בהקשר): נפוץ באפליקציות היברידיות שמפעילות מנועי סקריפטים לצד תצוגות מקוריות, ולא מצליחות לנקות את קישורי זמן הריצה.

מגבלות של ממשקי API לזיכרון באפליקציה

ממשקי API של זיכרון בתוך האפליקציה (כמו Debug.getMemoryInfo או ActivityManager.getProcessMemoryInfo) מודדים רק את תהליך הקריאה החוזרת. במצב ריבוי תהליכים, ממשקי ה-API האלה לא יכולים לתעד את הזיכרון שנצרך על ידי תהליך העיבוד המבודד. כדי לקבל הערכה מדויקת של הזיכרון הכולל, צריך להסתמך על כלי מערכת כמו dumpsys meminfo,‏ Perfetto או Android Studio Profiler.

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

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

  1. מבודדים את סוג הדליפה (Java לעומת נייטיב): מריצים את dumpsys meminfo -a <var>PACKAGE_NAME</var> | grep -A 10 "Objects" לפני ואחרי מעברים חוזרים של משתמשים (כמו פתיחה וסגירה של מאמרים באינטרנט או החלקה בפידים).

    • תצפית: אם הספירות של Activities ו-WebViews נשארות יציבות (לדוגמה, 1-2 מופעים פעילים), אין באפליקציה דליפה של הקשרים של Activity או של מופעי Java של WebView.
  2. מדידת השינוי בזיכרון בין אינטראקציות (מעקב אחר סדרות זמן): אפשר לתעד תמונות מצב של dumpsys meminfo בכמה אינטראקציות של משתמשים כדי לחשב את קצב ההקצאה לכל מעבר:

    • תצפית: ערימת Java נשארת מוגבלת ותקינה (עולה במהלך השימוש ויורדת אחרי איסוף זבל), אבל Private Other ו-Native Heap עולים בהתמדה בכמה מגה-בייטים בכל מעבר. זה מוכיח שהדליפה היא לגמרי בזיכרון המערכת, מחוץ לסביבת זמן הריצה של ART. תמונות מצב רגילות של הזיכרון מ-Java (.hprof) לא יציגו בעיות.
  3. בדיקת מפות זיכרון אנונימיות: בודקים את מפות הזיכרון של התהליך באמצעות ADB (ראו בדיקת מפות זיכרון והקצאות):

    adb shell "cat /proc/$(pidof <var>PACKAGE_NAME</var>)/maps" | grep "anon:"
    • תצפית: הגידול בזיכרון מתרכז בערימות של [anon:partition_alloc] או של מנוע הסקריפטים המוטמע, ומלווה בעלייה איטית בהפניות הגלובליות של JNI. זה מצביע על כך שהתצוגות של Java הוחלפו, אבל אובייקטי הדף המקוריים או הקישורים של JavaScript לא שוחררו.
  4. תיקון:

    • חשוב לוודא שכל WebView שממוחזר או נמחק עוצר באופן מפורש סקריפטים פעילים (stopLoading()), מנקה את ההיסטוריה וקורא ל-destroy().
    • מבטלים את הקריאות החוזרות (callbacks) של גשר JavaScript בהתאמה אישית או את ההפניות הגלובליות של JNI שמשויכות לתצוגות שנסגרו.
    • מוודאים ש-Private Other ו-RSS של התהליך מתייצבים אחרי מעברים בניווט.

מקורות מידע נוספים

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