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 יושבתו בצורה נקייה וישוחררו כשהם יושמדו, צריך לבצע את הפעולות הבאות:
- מסירים את
WebViewמהמאגר הראשי (ViewGroup). - עצירת הטעינה הפעילה ומחיקת היסטוריית הניווט.
- רוצה להתקשר ל-
destroy()? - מנקים את ההפניה אל
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גדל בכל ניווט ולא חוזר לערך הבסיס, יש באפליקציה זליגה של מופע JavaWebViewאו מארחActivity(לדוגמה, בגללViewGroup.removeView()חסר או הפניות למאזינים שנשמרו). בגלל ש-Activityשדלף מצמיד את כל עץ התצוגה שלו ואת משאבי התמונה המפוענחים בזיכרון, ביקורים חוזרים ירוקנו במהירות את ערימת ה-Java ויגרמו לקריסות שלOutOfMemoryError.זליגה מקומית או זליגה ב-DOM: אם הערכים של
WebViewsו-Activitiesנשארים קבועים, אבל הערכים של Private Other ו-RSS של התהליך הכולל ממשיכים לעלות, מקור הזליגה הוא במשאבים מקומיים שלא שוחררו, ברכיבי DOM או בקישורים של מנוע JavaScript. ההקצאות האלה נמצאות בזיכרון המקורי ועוקפות את מנגנון איסוף האשפה של ART, ולכן הן לא גלויות לכלים הרגילים של Java לזיהוי דליפות, וממשיכות להצטבר עד שמערכת ההפעלה סוגרת את האפליקציה.
יצירת פרופיל של תהליך מנוע הרינדור המבודד באמצעות CLI
אם מריצים את dumpsys meminfo עם שם החבילה של האפליקציה, הפלט הוא רק הזיכרון של תהליך המארח הראשי. כדי לבדוק את תהליך העיבוד המבודד שבו דפי אינטרנט עוברים עיבוד:
מוצאים את מזהה התהליך (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:...}בודקים את פירוט הזיכרון של תהליך הרינדור באמצעות מזהה התהליך (PID) שלו:
adb shell dumpsys meminfo <var>RENDERER_PID</var>בודקים את תהליך האפליקציה המארחת כדי להעריך את טביעת הרגל בצד הדפדפן:
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 או המנוע המקורי:
מבודדים את סוג הדליפה (Java לעומת נייטיב): מריצים את
dumpsys meminfo -a <var>PACKAGE_NAME</var> | grep -A 10 "Objects"לפני ואחרי מעברים חוזרים של משתמשים (כמו פתיחה וסגירה של מאמרים באינטרנט או החלקה בפידים).- תצפית: אם הספירות של
Activitiesו-WebViewsנשארות יציבות (לדוגמה, 1-2 מופעים פעילים), אין באפליקציה דליפה של הקשרים שלActivityאו של מופעי Java שלWebView.
- תצפית: אם הספירות של
מדידת השינוי בזיכרון בין אינטראקציות (מעקב אחר סדרות זמן): אפשר לתעד תמונות מצב של
dumpsys meminfoבכמה אינטראקציות של משתמשים כדי לחשב את קצב ההקצאה לכל מעבר:- תצפית: ערימת Java נשארת מוגבלת ותקינה (עולה במהלך השימוש ויורדת אחרי איסוף זבל), אבל Private Other ו-Native Heap עולים בהתמדה בכמה מגה-בייטים בכל מעבר. זה מוכיח שהדליפה היא לגמרי בזיכרון המערכת, מחוץ לסביבת זמן הריצה של ART.
תמונות מצב רגילות של הזיכרון מ-Java (
.hprof) לא יציגו בעיות.
- תצפית: ערימת Java נשארת מוגבלת ותקינה (עולה במהלך השימוש ויורדת אחרי איסוף זבל), אבל Private Other ו-Native Heap עולים בהתמדה בכמה מגה-בייטים בכל מעבר. זה מוכיח שהדליפה היא לגמרי בזיכרון המערכת, מחוץ לסביבת זמן הריצה של ART.
תמונות מצב רגילות של הזיכרון מ-Java (
בדיקת מפות זיכרון אנונימיות: בודקים את מפות הזיכרון של התהליך באמצעות ADB (ראו בדיקת מפות זיכרון והקצאות):
adb shell "cat /proc/$(pidof <var>PACKAGE_NAME</var>)/maps" | grep "anon:"- תצפית: הגידול בזיכרון מתרכז בערימות של
[anon:partition_alloc]או של מנוע הסקריפטים המוטמע, ומלווה בעלייה איטית בהפניות הגלובליות של JNI. זה מצביע על כך שהתצוגות של Java הוחלפו, אבל אובייקטי הדף המקוריים או הקישורים של JavaScript לא שוחררו.
- תצפית: הגידול בזיכרון מתרכז בערימות של
תיקון:
- חשוב לוודא שכל
WebViewשממוחזר או נמחק עוצר באופן מפורש סקריפטים פעילים (stopLoading()), מנקה את ההיסטוריה וקורא ל-destroy(). - מבטלים את הקריאות החוזרות (callbacks) של גשר JavaScript בהתאמה אישית או את ההפניות הגלובליות של JNI שמשויכות לתצוגות שנסגרו.
- מוודאים ש-
Private Otherו-RSS של התהליך מתייצבים אחרי מעברים בניווט.
- חשוב לוודא שכל
מקורות מידע נוספים
למידע נוסף על ניפוי באגים ופרופיילינג של זיכרון וביצועים של WebView, עיינו במקורות המידע הבאים: