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