קריסות מחשב

אפליקציית Android קורסת בכל פעם שיש יציאה לא צפויה שנגרמת על ידי חריגה או אות שלא טופלו. אפליקציה שנכתבה באמצעות Java או Kotlin קורסת אם היא יוצרת חריגה שלא טופלה, שמיוצגת על ידי המחלקה Throwable. אפליקציה שנכתבה באמצעות קוד מכונה או C++ קורסת אם יש אות שלא טופל, כמו SIGSEGV, במהלך ההפעלה שלה.

כשאפליקציה קורסת, מערכת Android מסיימת את התהליך של האפליקציה ומציגה תיבת דו-שיח כדי להודיע למשתמש שהאפליקציה הפסיקה לפעול, כמו שמוצג באיור 1.

קריסת אפליקציה במכשיר עם מערכת הפעלה Android
איור 1. קריסת אפליקציה במכשיר עם Android.

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

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

זיהוי הבעיה

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

תפקוד האפליקציה

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

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

  • שיעור הקריסות המרובות: אחוז המשתמשים הפעילים ביום (DAU) שחוו לפחות שתי קריסות.

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

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

ב-Play הוגדרו שני ספים להתנהגות לא תקינה למדד הזה:

  • סף ההתנהגות הלא תקינה הכולל: לפחות 1.09% מהמשתמשים הפעילים ביום (DAU) חווים קריסה שהשפיעה על המשתמשים, בכל דגמי המכשירים.
  • סף ההתנהגות הלא תקינה לכל מכשיר: לפחות 8% מהמשתמשים הפעילים ביום (DAU) חווים קריסה שהשפיעה על המשתמש, בדגם מכשיר יחיד.

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

הנתונים של מדד תפקוד האפליקציה יכולים לשלוח לכם התראות ב-Play Console אם האפליקציה שלכם קורסת יותר מדי פעמים.

מידע על האופן שבו Google Play אוסף נתונים של תפקוד האפליקציה ב-Android זמין במסמכי התיעוד של Play Console.

אבחון הקריסות

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

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

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

איך קוראים דוח קריסות

השלב הראשון בתיקון קריסה הוא לזהות את המקום שבו היא מתרחשת. אם אתם משתמשים ב-Play Console, תוכלו להשתמש בדוח קריסות שזמין בפרטי הדוח, או בפלט של הכלי logcat. אם אין לכם דוח קריסות זמין, אתם צריכים לשחזר את הקריסה באופן מקומי, או על ידי בדיקה ידנית של האפליקציה או על ידי פנייה למשתמשים המושפעים, ולשחזר אותה באמצעות Logcat.

במעקב הבא מוצגת דוגמה לקריסה באפליקציה שנכתבה באמצעות Jetpack Compose:

--------- beginning of crash
AndroidRuntime: FATAL EXCEPTION: main
Process: com.android.developer.crashsample, PID: 3686
java.lang.NullPointerException
    at com.android.developer.crashsample.ComposableSingletons$MainActivityKt.lambda$0(MainActivity.kt:27)
    at androidx.compose.foundation.ClickableNode.handleUpEvent(Clickable.kt:958)
    at androidx.compose.foundation.ClickableNode.onPointerEvent-H0pRuoY(Clickable.kt:895)
    at androidx.compose.ui.input.pointer.Node.dispatchMainEventPass(HitPathTracker.kt:446)
    at androidx.compose.ui.input.pointer.HitPathTracker.dispatchChanges(HitPathTracker.kt:181)
    at androidx.compose.ui.input.pointer.PointerInputEventProcessor.process-BIzXfog(PointerInputEventProcessor.kt:118)
    at androidx.compose.ui.platform.AndroidComposeView.dispatchTouchEvent(AndroidComposeView.android.kt:2650)
    at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:2969)
    at android.app.Activity.dispatchTouchEvent(Activity.java:4683)
    at android.os.Looper.loop(Looper.java:398)
    at android.app.ActivityThread.main(ActivityThread.java:9569)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:918)

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

  • סוג החריגה שהופעלה.
  • הקטע בקוד שבו מתרחשת החריגה.

סוג החריגה שמוחזרת הוא בדרך כלל רמז חזק מאוד לגבי מה שהשתבש. בודקים אם מדובר ב-IOException, ב-OutOfMemoryError או במשהו אחר, ומחפשים את התיעוד לגבי מחלקת החריגים.

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

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

עקבות מחסנית לאפליקציות עם קוד C ו-C++‎ פועלים באופן דומה.

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'google/foo/bar:10/123.456/78910:user/release-keys'
ABI: 'arm64'
Timestamp: 2020-02-16 11:16:31+0100
pid: 8288, tid: 8288, name: com.example.testapp  >>> com.example.testapp <<<
uid: 1010332
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
Cause: null pointer dereference
    x0  0000007da81396c0  x1  0000007fc91522d4  x2  0000000000000001  x3  000000000000206e
    x4  0000007da8087000  x5  0000007fc9152310  x6  0000007d209c6c68  x7  0000007da8087000
    x8  0000000000000000  x9  0000007cba01b660  x10 0000000000430000  x11 0000007d80000000
    x12 0000000000000060  x13 0000000023fafc10  x14 0000000000000006  x15 ffffffffffffffff
    x16 0000007cba01b618  x17 0000007da44c88c0  x18 0000007da943c000  x19 0000007da8087000
    x20 0000000000000000  x21 0000007da8087000  x22 0000007fc9152540  x23 0000007d17982d6b
    x24 0000000000000004  x25 0000007da823c020  x26 0000007da80870b0  x27 0000000000000001
    x28 0000007fc91522d0  x29 0000007fc91522a0
    sp  0000007fc9152290  lr  0000007d22d4e354  pc  0000007cba01b640

backtrace:
  #00  pc 0000000000042f89  /data/app/com.example.testapp/lib/arm64/libexample.so (com::example::Crasher::crash() const)
  #01  pc 0000000000000640  /data/app/com.example.testapp/lib/arm64/libexample.so (com::example::runCrashThread())
  #02  pc 0000000000065a3b  /system/lib/libc.so (__pthread_start(void*))
  #03  pc 000000000001e4fd  /system/lib/libc.so (__start_thread)

אם לא מוצג מידע ברמת המחלקה והפונקציה בנתוני מעקב אחר מחסנית (stack trace) מקוריים, יכול להיות שתצטרכו ליצור קובץ בשפה אנושית לניפוי באגים מקורי ולהעלות אותו ל-Google Play Console. מידע נוסף זמין במאמר בנושא ביטול ההסתרה של דוחות קריסות. מידע כללי על קריסות מקוריות זמין במאמר אבחון קריסות מקוריות.

טיפים לשחזור קריסה

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

שגיאות זיכרון

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

הגדרת זיכרון ב-AVD Manager
איור 2. הגדרת הזיכרון במנהל ה-AVD.

החרגות ברשת

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

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

אפשרות נוספת היא להפחית את איכות הרשת באמולטור על ידי בחירה של הדמיית מהירות רשת, השהיית רשת או שניהם. אפשר להשתמש בהגדרות Speed (מהירות) ו-Latency (זמן אחזור) במנהל AVD, או להפעיל את האמולטור עם הדגלים -netdelay ו--netspeed, כמו בדוגמה הבאה של שורת פקודה:

emulator -avd [your-avd-image] -netdelay 20000 -netspeed gsm

בדוגמה הזו מוגדר עיכוב של 20 שניות בכל בקשות הרשת, ומהירות העלאה והורדה של ‎14.4 Kbps. מידע נוסף על אפשרויות שורת הפקודה של האמולטור זמין במאמר הפעלת האמולטור משורת הפקודה.

קריאה באמצעות Logcat

אחרי שתצליחו לשחזר את הקריסה, תוכלו להשתמש בכלי כמו logcat כדי לקבל מידע נוסף.

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

מניעת קריסות שנגרמות מחריגים של מצביע null

חריגות של מצביע Null (שמזוהות לפי סוג שגיאת זמן הריצה NullPointerException) מתרחשות כשמנסים לגשת לאובייקט שהוא Null, בדרך כלל על ידי הפעלת השיטות שלו או גישה לחברים שלו. חריגות של מצביע null הן הסיבה העיקרית לקריסות של אפליקציות ב-Google Play. המטרה של הערך null היא לציין שהאובייקט חסר – לדוגמה, הוא עדיין לא נוצר או לא הוקצה.

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

כדי שלא תצטרכו לבצע בדיקות של ערכי null לכל פרמטר של כל הפעלת method, אתם יכולים להסתמך על סביבת פיתוח משולבת (IDE) או על סוג האובייקט כדי לציין מאפיין המציין אם ערך יכול להיות ריק (nullability).

Kotlin

ב-Kotlin, ‏ nullability הוא חלק ממערכת הסוגים. לדוגמה, צריך להצהיר על משתנה מההתחלה כמשתנה שאפשר להקצות לו ערך null או כמשתנה שלא ניתן להקצות לו ערך null. סוגים שניתן להגדיר להם ערך Null מסומנים בסימן ?:

// non-null
var s: String = "Hello"

// null
var s: String? = "Hello"

אי אפשר להקצות ערך null למשתנים שלא יכולים להיות null, וצריך לבדוק אם משתנים שיכולים להיות null הם null לפני שמשתמשים בהם כמשתנים שלא יכולים להיות null.

אם לא רוצים לבדוק במפורש אם הערך הוא null, אפשר להשתמש באופרטור ?. safe call:

val length: Int? = string?.length  // length is a nullable int
                                   // if string is null, then length is null

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

אלה כמה דרכים לבדוק אם יש ערך null:

  • if בדיקות

    val length = if(string != null) string.length else 0
    

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

  • ?: אופרטור אלביס

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

    val length = string?.length ?: 0
    

עדיין אפשר לקבל NullPointerException ב-Kotlin. אלה המצבים הנפוצים ביותר:

  • כשמבצעים השלכה מפורשת של NullPointerException.
  • כשמשתמשים באופרטור null assertion !!. האופרטור הזה ממיר כל ערך לסוג שאינו null, ומחזיר NullPointerException אם הערך הוא null.
  • כשניגשים להפניה ריקה מסוג פלטפורמה.

סוגי פלטפורמות

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

val list = ArrayList<String>() // non-null (constructor result) list.add("Item")
val size = list.size // non-null (primitive int) val item = list[0] // platform
type inferred (ordinary Java object) item.substring(1) // allowed, may throw an
                                                       // exception if item == null

‫Kotlin מסתמכת על היסק סוגים כשערך של פלטפורמה מוקצה למשתנה Kotlin, או שאפשר להגדיר את הסוג הצפוי. הדרך הכי טובה לוודא את מאפיין המציין אם ערך יכול להיות ריק (nullability) של הפניה שמגיעה מ-Java היא להשתמש באנוטציות בנושא מאפיין המציין אם ערך יכול להיות ריק (nullability) (לדוגמה, @Nullable) בקוד Java. הקומפיילר של Kotlin ייצג את ההפניות האלה כסוגים בפועל שניתנים או לא ניתנים לאיפוס, ולא כסוגי פלטפורמה.

ממשקי API של Java Jetpack קיבלו הערות עם @Nullable או @NonNull לפי הצורך, וב-Android 11 SDK נעשה שימוש בגישה דומה. סוגים שמגיעים מערכת ה-SDK הזו ומשמשים ב-Kotlin יוצגו כסוגים נכונים שיכולים להיות ריקים או לא ריקים.

מערכת הטיפוס של Kotlin מפחיתה באופן משמעותי את מספר הקריסות של NullPointerException. לדוגמה, באפליקציית Google Home חלה ירידה של 30% במספר הקריסות שנגרמו על ידי חריגות של מצביע null במהלך השנה שבה בוצעה העברה של פיתוח תכונות חדשות ל-Kotlin.