方法调用栈混乱引起的Proguard内联学习
首先说这个事情是怎么来的,先看一段firebase的报错。
Fatal Exception: java.lang.NullPointerException: Attempt to read from field 'java.lang.String xe4.c' on a null object reference
at com.yocn.zyk.online.cash.bean.CashAccountUpi.getVpa(CashAccountUpi.java:31)
at com.yocn.zyk.online.cash.dialog.CashAccountUpiDialogFragment.initView(CashAccountUpiDialogFragment.java:91)
at com.yocn.zyk.online.coins.dialog.CoinsBaseBottomDialogFragment.onViewCreated(CoinsBaseBottomDialogFragment.java:50)
at androidx.fragment.app.FragmentManagerImpl.moveToState(FragmentManagerImpl.java:892)
...
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:499)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:965)
看起来这是一段很平常的NPE的报错,那我们来看代码的调用:
# CashAccountUpiDialogFragment.class
public class CashAccountUpiDialogFragment {
@Override
protected void initView() {
...
if (!TextUtils.isEmpty(cashAccountUpi.getVpa())) { // line:91
cashVpa.setText(cashAccountUpi.getVpa());
}
...
}
}
# CashAccountUpi.class
public class CashAccountUpi {
private String vpa;
public String getVpa() {
return vpa; // line:31
}
}
我们观察发现最顶层的crash发生位置CashAccountUpi.getVpa(CashAccountUpi.java:31)
似乎没有可以发生的条件啊,这里怎么会发生NPE呢?反而第二行的CashAccountUpiDialogFragment.initView(CashAccountUpiDialogFragment.java:91)
似乎才是真正的NPE的发生位置,到底哪儿是crash位置呢?
- 如果
CashAccountUpi
为空,代码就无法执行进CashAccountUpi#getVpa()
。 - 如果
CashAccountUpi
不为空,代码会执行进去,但是line31行不会报空。
这就想不通了,为什么调用栈会崩溃在一个没有任何条件的地方呢?
因为多线程的关系?但是这段代码并没有多线程来执行。并且我试了,即使有多线程执行,调用栈也不是这样的。
去吹水群问了一下,有群友给了这么个关键字,proguard的内联,之前确实不了解,专门去搜了一下,感觉确实像是这个表现,这里有一篇文章感觉很有价值插件化、热补丁中绕不开的Proguard的坑。
mapping的组成
以->
为分界线,表示原始名称->新名称
。
- 类映射,特征:映射以:结束。
- 字段映射,特征:映射中没有()。
- 方法映射,特征:映射中有(),并且左侧的拥有两个数字,代表方法体的行号范围。
- 内联,特征:与方法映射相比,多了两个行号范围,右侧的行号表示原始代码行,左侧表示新的行号。
- 闭包,特征:只有三个行号,它与内联成对出现。
- 注释,特征:以#开头,通常不会出现在mapping中。
什么是内联
在代码优化过程中,对某一些方法进行内联(将被内联的方法体内容Copy到调用方调用被内联方法处,是一个代码展开的过程),修改了调用方的代码结构,所以被内联的方法Copy到调用方时需要考虑带来的副作用。当Copy来的代码发生崩溃时,Java stacktrace无法体现真实的崩溃堆栈和方法调用关系,它受调用方自身代码和内联Copy的代码相互影响。
内联主要分为两类:
- unique method - 被调用并且只被调用一次
- short method - 被调用多次可能,但是这个方法code_length小于8(并非代码行数)。
满足这两种的方法才可能被内联。
case study
找到mapping
文件和apk文件,现在可以使用Android Studio查看apk包的内容,一般会有好几个dex文件,首先需要找到调用者CashAccountUpi
所在的dex文件,像下面的图。
使用proguard文件keep住的会保持原来的目录结构。没有keep的就需要去mapping
文件查找混淆之后的文件名字。
xe4的混淆信息如下(删掉了无关的信息):
com.yocn.zyk.online.cash.bean.CashAccountUpi -> xe4:
java.lang.String vpa -> c
1:1:void ():10:10 ->
...
2:2:java.lang.String getVpa():31:31 -> c
...
...
那我们其实可以看到CashAccountUpi
的名字就是xe4
,可以看到xe4
在dex中的结构,能看到vpa
字符串和getVpa()
方法。
再看在dex中的CashAccountUpi
类,也就是xe4
:
其实这里已经可以看出一些端倪,我们在这里找不到返回值为String
的c()
方法,所以基本上能判定是被内联了。这里没有更多的线索了,那我们继续看调用者CashAccountUpiDialogFragment
,在mapping
文件中的信息如下:
com.yocn.zyk.online.cash.dialog.CashAccountUpiDialogFragment -> ag4:
...
1:1:void initView():83:83 -> initView
2:3:void initView():85:86 -> initView
4:5:void initView():88:89 -> initView
6:6:void initView():91:91 -> initView
7:7:java.lang.String com.yocn.zyk.online.cash.bean.CashAccountUpi.getVpa():31:31 -> initView
7:7:void initView():91 -> initView
8:9:void initView():91:92 -> initView
10:10:java.lang.String com.yocn.zyk.online.cash.bean.CashAccountUpi.getVpa():31:31 -> initView
回忆一下调用者的位置,在initView
中第6行调用了cashAccountUpi.getVpa()
,这里也可以看出来有内联信息,CashAccountUpi.getVpa():31:31
指向了initView
的第6行。这里可以确定确实是实现了内联,但是感觉还是差了点什么,可以继续深入查看。
在dex查看界面找到ag4
,选中并且右键,点Show Bytecode
,可以看到下面的图:
查看initView
的第6行:
.line 6
iget-object v0, p0, Lag4;->i:Lxe4;
.line 7
iget-object v0, v0, Lxe4;->c:Ljava/lang/String;
.line 8
invoke-static {v0}, Landroid/text/TextUtils;->isEmpty(Ljava/lang/CharSequence;)Z
这里涉及到一些Smali
文件信息,iget-object
是获取对象信息,p0
相当于this。
Lag4;->i:Lxe4;
表示获取到Lag4
的i
,是一个Lxe4
类型,放到v0中,i
的定义其实可以在上面的Smali
中找到:
.field public i:Lxe4;
第7行就破案了,直接调用了xe4
的c
。这就找到根儿了,完全没有调用getVpa()
方法,而是直接调用了属性c
,也就是CashAccountUpi
中的vpa
,混淆后的名字是c
。
所以在此案例中,proguard把调用cashAccountUpi.getVpa()
内联后为cashAccountUpi.vpa
,也就是舍弃掉getVpa()方法
直接访问vpa属性
,所以在报错中虽然按照mapping文件中的路径行号backtrace能找到CashAccountUpi
,但是执行的是内联之后的代码cashAccountUpi.vpa
,所以报错原因还是因为cashAccountUpi
为空。
好好利用mapping文件,能找到一些莫名其妙的问题