[Перевод] Ошибка в stacktrace из продакшена

В этой статье я расскажу про исключительную ситуацию, которая произошла с одним исключением в продакшене Android приложения.

63ce6c2777577382ebaef411aa7a25ab.jpg

Однажды я получили краш со следующим stacktrace (Firebase Crashlytics и Google Play Console показывали примерно это):

Fatal Exception: java.lang.IllegalArgumentException: 
        Parameter specified as non-null is null: 
        method com.example.feature.c.a, parameter $this$extensionB
    at com.example.extensions.ExtentionsA.extensionA(ExtentionsA.java:29)
    at com.example.service.FcmService.onMessageReceived(FcmService.java:92)
    ...

В то время как в коде приложения было:

FcmService.java:

package com.example.service;

class FcmService {
    ...
    void onMessageReceived() {
        ExtensionsB.extensionB(someNullableString, param);
    }
    ...
}

ExtensionB.kt:

@file:JvmName("ExtensionB")
package com.example.extensions

fun String.extensionB(param: Int) {
    ...
}

Как вы можете заметить, в stacktrace краша указано, что FcmService.onMessageReceived вызывает ExtensionsA.extensionA, хотя на самом деле он вызывает ExtensionsB.extensionB. Похоже, что причиной данного исключения является первый аргумент метода ExtensionsB.extensionB, который был передан из Java кода в Kotlin код со ссылкой на null. Но почему в stacktrace указан какой-то другой класс ExtensionA? Может быть, где-то на этапе кодогенерации/компиляции/пост-обработке байткода случилась ошибка, и теперь в результате FcmService.onMessageReceived реально вызывает какой-то другой метод?

Если посмотреть внимательнее на crash stacktrace, то можно увидеть еще парочку неопределенностей. Во-первых, в сообщении об ошибке указан какой-то странный методcom.example.feature.c.a. Во-вторых, там указано, что проблемный аргумент назывется $this$extensionB. Это запутало меня окончательно, и я начал расследование!

Действующие лица

Перед тем, как начнем погружаться в кишочки глубИны проблемы, познакомимся с основными действующие лица в данной статье.

ExtensionsA.extensionA — убийца приложения по версии Firebase Crashlytics, написан на kotlin, является extension-функцией;

ExtensionsB.extensionB — убийца по версии исходного кода, написан на kotlin, является extension-функцией;

FcmService.onMessageReceived — ближайший метод, в котором происходит убийство приложения, написан на Java.

R8

Наше Android приложение, как и многие другие, перед публикацией в Google Play проходит этап обфускации с использованием инструмента R8. Это в том числе означает, что после такой обфускации имена классов будут модифицированы, чтобы уменьшить конечный размер бинарного файла. Например, если в коде есть класс class ExtensionA, то после обработки R8 он может получить другое более короткое имя class c.

Для того, чтобы Firebase Crashlytics и Google Play могли сделать crash stacktrace читаемым, мы вместе с бинарным файлом приложения загружаем специальный mapping.txt файл, который содержит информацию о соответствии оригинальных имен к обфусцированным. Для расшифровки таких crash stacktrace Firebase Crashlytics использует утилиту ./retrace:

$ ./retrace mapping.txt stacktrace.txt > result_stacktrace.txt

Таким образом class c снова станет class ExtensionA в crash stacktrace.

В этом процессе где-то произошла ошибка, и, в результате, вместо исходного class ExtensionB получился class ExtensionA.

Давайте посмотрим на mapping.txt, и найдем ExtensionA и все его методы:

com.example.feature.R$style -> com.example.feature.c:
    1:1:void com.example.ExtensionsA.extensionA(java.lang.String,java.lang.Integer,java.lang.Integer):29:30 -> a
    ...
    9:9:android.view.View com.example.feature.extensions.AndroidExtensionsKt.inflate(android.view.ViewGroup,int,boolean):0:0 -> a
    9:9:android.view.View com.example.feature.AndroidExtensionsKt.inflate$default(android.view.ViewGroup,int,boolean,int,java.lang.Object):23 -> a
    10:10:android.view.View com.example.feature.extensions.AndroidExtensionsKt.inflate(android.view.ViewGroup,int,boolean):24:24 -> a
    10:10:android.view.View com.example.feature.extensions.AndroidExtensionsKt.inflate$default(android.view.ViewGroup,int,boolean,int,java.lang.Object):23 -> a
    ...
    14:15:void com.example.ExtensionsA.extensionC(java.lang.CharSequence,java.lang.String):11:12 -> a
    16:17:void com.example.ExtensionsA.extensionC(java.lang.CharSequence,java.lang.String):15:16 -> a
    18:18:void com.example.ExtensionsA.extensionC(java.lang.CharSequence,java.lang.String) -> a
    19:19:void com.example.ExtensionsA.extensionC(java.lang.CharSequence,java.lang.String):17:17 -> a
    ...
    27:28:void com.example.ExtensionsB.extensionB(java.lang.String,java.lang.Integer):18:19 -> a

Тут видно, что очень много методов класса com.exampe.feature.c имеют одно и то же имя — a(отличия только в аргументах и в типах возвращаемого значения, так что их сигнатуры уникальны). Чтобы убедиться, что это действительно так, посмотрим на байткод этого класса из бинарного файла приложения. Это можно сделать, например, из AndroidStudio:

Build → Analyse APK… →