Phát hiện và chẩn đoán sự cố

Sự cố là bất kỳ khi nào ứng dụng Android bất ngờ thoát ra do gặp tình huống ngoại lệ hoặc tín hiệu chưa được xử lý. Ứng dụng (viết bằng ngôn ngữ Java hoặc Kotlin) bị sự cố khi ứng dụng đó gửi một trường hợp ngoại lệ chưa được xử lý, thuộc lớp Throwable. Ứng dụng được viết bằng ngôn ngữ mã gốc bị sự cố nếu gặp tín hiệu chưa được xử lý, như SIGSEGV, khi đang chạy.

Khi ứng dụng bị sự cố, Android sẽ kết thúc quy trình của ứng dụng và hiển thị một hộp thoại để báo cho người dùng biết ứng dụng đó đã dừng hoạt động, như trong hình 1.

Sự cố ứng dụng trên thiết bị Android

Hình 1. Sự cố ứng dụng trên thiết bị Android

Không chỉ các ứng dụng chạy trên nền trước mới bị sự cố. Bất kỳ thành phần nào của ứng dụng, kể cả các thành phần như broadcast receiver hoặc nhà cung cấp nội dung đang chạy trong nền, đều có thể khiến ứng dụng gặp sự cố. Các sự cố này thường làm người dùng bối rối vì họ không trực tiếp tương tác với ứng dụng.

Nếu ứng dụng của bạn gặp sự cố, bạn có thể sử dụng hướng dẫn trên trang này để chẩn đoán và khắc phục vấn đề.

Phát hiện vấn đề

Không phải lúc nào bạn cũng có thể biết được người dùng đang gặp phải số lượng sự cố bất thường với ứng dụng của bạn. Nếu ứng dụng đã được phát hành, thì Android vitals có thể giúp bạn biết được vấn đề đó.

Android vitals

Android vitals có thể giúp cải thiện hiệu suất hoạt động của ứng dụng bằng cách thông báo cho bạn qua Play Console khi ứng dụng gặp quá nhiều sự cố. Android vitals coi số lượng sự cố là quá nhiều khi ứng dụng:

  • Gặp tối thiểu một sự cố trong ít nhất 1,09% số phiên hằng ngày của ứng dụng.
  • Gặp hai sự cố trở lên trong ít nhất 0,18% số phiên hằng ngày.

Phiên hằng ngày tức là một ngày mà ứng dụng của bạn được sử dụng. Để biết thông tin về cách Google Play thu thập dữ liệu Android vitals, hãy xem tài liệu Play Console.

Khi biết ứng dụng của mình đang gặp quá nhiều sự cố, bước tiếp theo bạn cần làm là chẩn đoán các lỗi đó.

Chẩn đoán sự cố

Việc giải quyết sự cố có thể rất khó khăn. Tuy nhiên, nếu có thể xác định nguyên nhân gốc của sự cố, thì khả năng cao là bạn sẽ tìm được giải pháp cho sự cố đó.

Ứng dụng của bạn có thể gặp sự cố vì nhiều lý do. Một số lý do rất hiển nhiên như kiểm tra một giá trị rỗng hoặc chuỗi trống, nhưng một số lý do khác thì khó nhận ra hơn, như truyền các đối số không hợp lệ đến một API hoặc thậm chí là các tương tác nhiều chuỗi phức tạp.

Sự cố trên Android gây ra dấu vết ngăn xếp. Dấu vết ngăn xếp là ảnh chụp nhanh về trình tự các hàm lồng nhau được gọi trong chương trình của bạn cho tới thời điểm xảy ra sự cố. Bạn có thể xem dấu vết ngăn xếp sự cố tại Android vitals.

Đọc dấu vết ngăn xếp

Để khắc phục sự cố, bước thứ nhất là xác định nơi xảy ra sự cố. Bạn có thể sử dụng dấu vết ngăn xếp có trong chi tiết báo cáo nếu đang sử dụng Play Console hoặc thông tin đầu ra của công cụ logcat. Nếu không có sẵn dấu vết ngăn xếp, bạn nên tái tạo sự cố ở cục bộ bằng cách tự mình kiểm tra ứng dụng hoặc liên hệ với người dùng gặp sự cố và tái tạo sự cố trong khi sử dụng công cụ logcat.

Dấu vết sau đây là một ví dụ về sự cố trên một ứng dụng được viết bằng ngôn ngữ lập trình Java:

--------- beginning of crash
AndroidRuntime: FATAL EXCEPTION: main
Process: com.android.developer.crashsample, PID: 3686
java.lang.NullPointerException: crash sample
at com.android.developer.crashsample.MainActivity$1.onClick(MainActivity.java:27)
at android.view.View.performClick(View.java:6134)
at android.view.View$PerformClick.run(View.java:23965)
at android.os.Handler.handleCallback(Handler.java:751)
at android.os.Handler.dispatchMessage(Handler.java:95)
at android.os.Looper.loop(Looper.java:156)
at android.app.ActivityThread.main(ActivityThread.java:6440)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.Zygote$MethodAndArgsCaller.run(Zygote.java:240)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:746)
--------- beginning of system

Dấu vết ngăn xếp cho thấy hai đoạn thông tin quan trọng để gỡ lỗi sự cố:

  • Loại trường hợp ngoại lệ được gửi.
  • Phần mã nơi ngoại lệ được gửi.

Loại trường hợp ngoại lệ được gửi thường là gợi ý rất rõ ràng về những gì đã xảy ra. Hãy xem liệu sự cố đó là IOException, OutOfMemoryError hay sự cố khác rồi tìm tài liệu về lớp ngoại lệ đó.

Lớp, phương thức, tệp và số dòng của tệp nguồn nơi ngoại lệ được gửi sẽ hiển thị trên dòng thứ hai của dấu vết ngăn xếp. Với mỗi hàm được gọi, có một dòng khác hiển thị vị trí gọi trước đó (được gọi là khung ngăn xếp). Bằng cách xem xét ngăn xếp từ dưới lên và kiểm tra mã, bạn có thể tìm ra vị trí nhận giá trị không chính xác. Nếu mã không xuất hiện trong dấu vết ngăn xếp, thì có thể bạn đã chuyển một thông số không hợp lệ vào một toán tử không đồng bộ ở đâu đó. Thường bạn có thể phát hiện điều gì đã xảy ra khi kiểm tra từng dòng của dấu vết ngăn xếp, tìm ra mọi lớp API bạn đã sử dụng và xác nhận rằng thông số bạn đã chuyển là chính xác và bạn đã gọi thông số đó từ một vị trí được phép.

Dấu vết ngăn xếp cho các ứng dụng dùng mã C và C++ cũng hoạt động tương tự như vậy.

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
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)

Nếu không thấy thông tin cấp lớp và hàm trong dấu vết ngăn xếp gốc, bạn cần phải tạo tệp biểu tượng gỡ lỗi gốc rồi tải tệp đó lên Google Play Console. Để biết thêm thông tin, vui lòng xem phần Gỡ rối mã nguồn dấu vết ngăn xếp sự cố. Để biết thông tin chung về các trục trặc mã gốc, vui lòng xem phần Chẩn đoán các trục trặc mã gốc.

Mẹo tái tạo sự cố

Chưa chắc bạn có thể tái tạo được sự cố nếu chỉ khởi động trình mô phỏng hoặc kết nối thiết bị với máy tính. Các môi trường phát triển thường có nhiều tài nguyên hơn, chẳng hạn như băng thông, bộ nhớ và dung lượng lưu trữ. Hãy dùng loại ngoại lệ để xác định tài nguyên khan hiếm hoặc tìm mối tương quan giữa phiên bản Android, loại thiết bị hoặc phiên bản ứng dụng của bạn.

Lỗi bộ nhớ

Nếu gặp lỗi OutOfMemoryError, thì bạn có thể tạo một trình mô phỏng có dung lượng bộ nhớ thấp để kiểm thử. Hình 2 cho thấy chế độ cài đặt của trình quản lý thiết bị ảo Android (AVD), nơi bạn có thể kiểm soát dung lượng bộ nhớ trên thiết bị.

Chế độ cài đặt bộ nhớ trên trình quản lý AVD

Hình 2. Chế độ cài đặt bộ nhớ trên trình quản lý AVD

Các ngoại lệ về kết nối mạng

Do người dùng thường xuyên di chuyển ra vào trong phạm vi phủ sóng của mạng di động hoặc Wi-Fi, nên trong một ứng dụng, các ngoại lệ kết nối mạng thường không được coi là lỗi, mà là tình trạng hoạt động bình thường xảy ra ngoài dự kiến.

Nếu bạn cần tái tạo một ngoại lệ về kết nối mạng, chẳng hạn như UnknownHostException, hãy thử bật chế độ trên máy bay trong khi ứng dụng vẫn đang cố sử dụng mạng.

Một lựa chọn khác là giảm chất lượng mạng trong trình mô phỏng bằng cách chọn một quy trình mô phỏng tốc độ mạng và/hoặc độ trễ mạng. Bạn có thể sử dụng tuỳ chọn cài đặt Tốc độĐộ trễ trên trình quản lý AVD hoặc có thể khởi động trình mô phỏng với cờ -netdelay-netspeed, như trong ví dụ về dòng lệnh dưới đây:

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

Ví dụ này đặt độ trễ là 20 giây trên tất cả các yêu cầu kết nối mạng và tốc độ tải lên và tải xuống là 14,4 Kb/giây. Để biết thêm thông tin về các tuỳ chọn dòng lệnh cho trình mô phỏng, hãy xem phần Khởi động trình mô phỏng từ dòng lệnh.

Đọc bằng logcat

Sau khi xác định các bước để tái tạo sự cố, bạn có thể sử dụng một công cụ như logcat để thu thập thêm thông tin.

Thông tin từ logcat sẽ cho biết bạn đã in những thông điệp nhật ký nào khác cũng như những thông điệp nào khác từ hệ thống. Đừng quên tắt mọi câu lệnh Log khác bạn đã thêm vào vì việc in các câu lệnh này làm tổn hao CPU và pin khi ứng dụng đang chạy.

Phòng ngừa sự cố do ngoại lệ con trỏ rỗng

Các ngoại lệ con trỏ rỗng (được xác định theo loại lỗi thời gian chạy NullPointerException) xảy ra khi bạn cố gắng truy cập một đối tượng rỗng, thường là bằng cách gọi các phương thức hoặc truy cập các thành phần của đối tượng đó. Ngoại lệ con trỏ rỗng là nguyên nhân lớn nhất gây ra sự cố ứng dụng trên Google Play. Mục đích của giá trị rỗng là nhằm cho biết đối tượng bị thiếu. Ví dụ: đối tượng đó chưa được tạo hoặc gán. Để phòng ngừa ngoại lệ con trỏ rỗng, cần bảo đảm các tham chiếu đối tượng mà bạn đang sử dụng không rỗng trước khi gọi phương thức trên các tham chiếu đó hoặc cố gắng truy cập vào các thành phần của tham chiếu. Nếu tham chiếu đối tượng là rỗng, hãy xử lý khéo léo trường hợp này (ví dụ: thoát khỏi một phương thức trước khi thực hiện bất kỳ thao tác nào trên tham chiếu đối tượng và ghi thông tin vào nhật ký gỡ lỗi).

Vì không muốn kiểm thử giá trị rỗng trên mọi thông số của mọi phương thức được gọi, nên bạn có thể dựa vào IDE hoặc loại đối tượng để báo hiệu tính chất rỗng.

Ngôn ngữ lập trình Java

Các mục sau đây áp dụng cho ngôn ngữ lập trình Java.

Cảnh báo thời gian biên dịch

Dùng @Nullable@NonNull để chú thích thông số của phương thức và trả về các giá trị nhằm nhận cảnh báo thời gian biên dịch từ IDE. Những cảnh báo này nhắc bạn rằng có thể có một đối tượng rỗng:

Cảnh báo ngoại lệ con trỏ rỗng

Thực hiện việc kiểm thử rỗng này với các đối tượng mà bạn biết có thể mang giá trị rỗng. Ngoại lệ xuất hiện trên đối tượng @NonNull là dấu hiệu cho thấy mã của bạn bị lỗi và cần được xử lý.

Lỗi thời gian biên dịch

Do tầm quan trọng của tính chất rỗng nên bạn có thể nhúng thuộc tính này vào các loại bạn sử dụng để kiểm thử thời gian biên dịch cho giá trị rỗng. Nếu biết một đối tượng có thể rỗng và tính chất rỗng đó cần được xử lý, thì bạn có thể gói đối tượng đó trong một đối tượng như Optional. Bạn nên luôn ưu tiên những loại đối tượng truyền tải tính chất rỗng.

Kotlin

Trong Kotlin, tính chất rỗng là một phần của hệ thống loại. Ví dụ: một biến phải được khai báo ngay từ đầu là giá trị có thể rỗng hoặc không thể rỗng. Các loại có thể rỗng được đánh dấu bằng dấu ?:

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

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

Biến không thể rỗng không thể được gán giá trị rỗng, còn biến có thể rỗng cần phải được kiểm thử tính chất rỗng trước khi được sử dụng như một biến không rỗng.

Nếu không muốn trực tiếp kiểm thử giá trị rỗng, bạn có thể dùng toán tử gọi an toàn ?.:

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

Tốt nhất là bạn hãy nhớ xử lý trường hợp rỗng cho một đối tượng có thể rỗng, nếu không thì ứng dụng của bạn có thể rơi vào những trạng thái không mong muốn. Nếu ứng dụng của bạn không còn gặp sự cố với NullPointerException, thì bạn sẽ không biết những lỗi này vẫn tồn tại.

Sau đây là một số cách để kiểm thử giá trị rỗng:

  • Kiểm thử if

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

    Nhờ vào smart-cast và việc kiểm thử giá trị rỗng, trình biên dịch Kotlin biết giá trị chuỗi là không rỗng, vì vậy, trình biên dịch này cho phép bạn trực tiếp sử dụng tệp tham chiếu mà không cần đến toán tử gọi an toàn.

  • Toán tử Elvis ?:

    Toán tử này cho phép bạn yêu cầu: "nếu đối tượng là không rỗng, hãy trả về đối tượng; nếu không, hãy trả về đối tượng khác".

    val length = string?.length ?: 0
    

Bạn vẫn có thể nhận một NullPointerException trong Kotlin. Sau đây là những trường hợp phổ biến nhất:

  • Khi bạn rõ ràng đang gửi một NullPointerException.
  • Khi bạn sử dụng toán tử !! nhận định rỗng. Toán tử này chuyển đổi bất kỳ giá trị nào thành loại không rỗng, gửi NullPointerException nếu giá trị là rỗng.
  • Khi truy cập vào một tham chiếu rỗng của một loại nền tảng.

Loại nền tảng

Loại nền tảng là các đối tượng khai báo từ Java. Những loại này được đặc biệt xử lý; các lần kiểm thử rỗng không được thực thi, do đó, quy trình bảo đảm không rỗng cũng giống như trong Java. Khi bạn truy cập vào một tệp tham chiếu loại nền tảng, Kotlin không tạo lỗi thời gian biên dịch nhưng các tệp tham chiếu này có thể dẫn đến lỗi thời gian chạy. Hãy xem ví dụ dưới đây trong tài liệu về 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 dựa vào suy luận loại khi một giá trị nền tảng được gán cho một biến Kotlin hoặc bạn có thể xác định loại dự kiến. Cách tốt nhất để bảo đảm trạng thái rỗng chính xác của một tệp tham chiếu có trong Java là sử dụng các chú thích về tính rỗng (ví dụ: @Nullable) trong mã Java của bạn. Trình biên dịch Kotlin sẽ biểu thị các tệp tham chiếu này dưới dạng các loại có thể rỗng hoặc không thể rỗng thực sự, chứ không phải là các loại nền tảng.

Các API Java Jetpack được chú thích bằng @Nullable hoặc @NonNull khi cần và một phương pháp tương tự đã được thực hiện trong SDK Android 11. Các loại đến từ SDK này, được sử dụng trong Kotlin, sẽ được biểu thị dưới dạng các loại có thể rỗng hoặc không thể rỗng chính xác.

Nhờ hệ thống loại của Kotlin, chúng tôi thấy lượng sự cố NullPointerException của các ứng dụng giảm đáng kể. Ví dụ: ứng dụng Google Home đã giảm được 30% số sự cố do các trường hợp ngoại lệ con trỏ rỗng vào năm mà ứng dụng này chuyển phát triển tính năng mới sang Kotlin.