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

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

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

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

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

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

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

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

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

מדדים מרכזיים לניפוי באגים

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

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

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

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

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

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

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

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

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

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

כלי פרופיל וניתוח

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

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

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

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

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

  • דליפה מקומית או דליפה ב-DOM: אם הערכים של WebViews ו-Activities נשארים קבועים בזמן שהערך של RSS של התהליך הכולל ושל Private Other ממשיך לעלות, מקור הדליפה הוא במשאבים מקומיים שלא שוחררו, ברכיבי DOM או בקשרי JavaScript engine. ההקצאות האלה נמצאות בזיכרון המקורי ועוקפות את איסוף הזבל של 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, למאגרי רינדור, ל-heap של JavaScript ב-V8 ולביצוע של WebAssembly ב-WebView. כן (גבוה): טעינה של דפי אינטרנט כבדים, של DOM עשירים במדיה או של קריאה ל-destroy() במופעי WebView שנמחקו, מגדילה את הערך של התג הזה באופן ישיר.
[anon:scudo...] או [anon:libc_malloc] מנהלי הקצאת זיכרון (heap) מובנים ב-Android ‏(Scudo / jemalloc) הקצאות מקומיות כלליות של C/C++‎ שמשמשות ספריות NDK, גשרים של JNI וצינורות מקומיים של גרפיקה. כן (בינוני עד גבוה): הגידול מתרחש כשעטיפות JNI מקוריות או תלות C++ של צד שלישי שומרות הקצאות שלא שוחררו במהלך הניווטים.
[anon:...] (לדוגמה, [anon:quickjs_heap...]) סקריפטים בהתאמה אישית או סביבות ריצה מקוריות מנועי JavaScript מוטמעים, סביבות זמן ריצה מותאמות אישית של WebAssembly או מאגרי נתונים מקומיים מותאמים אישית. כן (תלוי בהקשר): נפוץ באפליקציות היברידיות שמריצות מנועי סקריפטים לצד תצוגות Native, ולא מצליחות לנקות את הקישורים בזמן הריצה.

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

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

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

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

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

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

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

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

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

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

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