查看原文
其他

一个平平无奇的NPE引申出来的部分proguard知识点的理解

YocnZhao 鸿洋 2023-12-27

本文作者


作者:YocnZhao

链接:

https://www.jianshu.com/p/5a982f874709

本文由作者授权发布。


同事遇到一个问题找我来看,是一个空指针的问题,看起来样子平平无奇。

1事发场景

Fatal Exceptionjava.lang.NullPointerException:
       at xxx.utils.TorrentDownloadHelper.addTaskCountListener(TorrentDownloadHelper.java:120)
       at xxx.view.OpenTorrentDownloadView.onAttachedToWindow(OpenTorrentDownloadView.kt:65)
       at android.view.View.dispatchAttachedToWindow(View.java:22479)
       ...


报错代码如下:标记①

# 调用者,类名:OpenTorrentDownloadView.kt
override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    if (!EventBus.getDefault().isRegistered(this)) {
        EventBus.getDefault().register(this)
    }
    TorrentDownloadHelper.addTaskCountListener(context, taskCountListener)
}

# 崩溃处,类名:TorrentDownloadHelper.kt
fun addTaskCountListener(context: Context, listener: TorrentTaskCountListener) {
    try {
        val start = getTorrentModule(context)!!.javaClass.getDeclaredMethod( // line 120
            "addTaskCountListener",
            TorrentTaskCountListener::class.java
        )
        start.isAccessible = true
        start.invoke(obj, listener)
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun getTorrentModule(context: Context): Any? {
    if (obj == null) {
        initTorrentDownload(context)
    }
    return obj
}

fun initTorrentDownload(context: Context) {
    if (TorrentBridge.isLoaded()) {
        try {
            val clazz = Class.forName(TorrentBridge.CLASS_NAME_TORRENT_MODULE)
            val getInstance = clazz.getDeclaredMethod("get", Context::class.java)
            getInstance.isAccessible = true
            obj = getInstance.invoke(clazz, context)
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}

好像稀松平常,明显是line 120处getTorrentModule(Context)为空,改掉就可以了。
但是要注意的是这里用try-catch包裹住了,我们暂时抛开!!+try-catch在这里使用是否合理,单纯从道理来讲这个NPE应该是可以被catch住的。而且只有在release包上才会有这个问题。
然后大致先说一下这件事的始末:
  1. 我以为上面的代码就是崩溃的代码,遂查看字节码发现addTaskCountListener的调用在smali中不见了。
  2. 查看mapping.txtusage.txt发现TorrentDownloadHelper.kt这个类被优化了
  3. 百思不得其解,在这儿困了一天,已经开始怀疑自己的知识体系。
  4. 后来同事说他强制把getTorrentModule()置为了null,所以被shrink了。
  5. 后来查看正常包发现addTaskCountListener未被优化,找到真正的调用逻辑。

花了一天的时间查了个寂寞,心情五味杂陈,不过总归学到了点东西。

2理解混淆的输出


Reading ProGuard’s Outputs,这里有一篇简洁的文章来讲打包后关于seeds.txt / usage.txt / mapping.txt的由来和作用。

https://jebware.com/blog/?p=484


  • seeds.txt 列出没有混淆的类和成员。
  • usage.txt 列出从apk中删除的代码。
  • mapping.txt 提供原文件对应混淆后的类、方法和字段名称。
所以当我们遇到我们需要查看release包里面到底是什么样子的时候。我比较习惯直接apk拉到AndroidStudio中直接查看dex,就不用apktool了。
这个位置可以选择mapping.txt文件,AS帮我们做了一下转换可以不用查mapping找obfuscate后的abcxxx了。

找到对应的类或者方法后可以直接右键选择Show Bytecode, 之前写过一篇# 方法调用栈混乱引起的Proguard内联学习,有更详细的介绍,不熟悉的可以移步。

https://juejin.cn/post/7231746152901689402


关于mapping.txt文件的格式解析可以查看# Android R8 mapping.txt文件解读
https://juejin.cn/post/6863089679969812488

3getTorrentModule()为null的情况


也就是这种情况,
fun getTorrentModule(context: Context): Any? {
    return null
}


所以在getTorrentModule()!!下就直接抛空了,看到有其他类似的例子,踩到一个R8代码压缩工具的坑

https://blog.csdn.net/xyq046463/article/details/102929528


在smali中会看到下面的信息,具体指令可以查询Smali指令白皮书,后面也会找一段例子完全标注。

https://juejin.cn/post/7296703158797205555


    .line 18
    invoke-virtual {v0, p0}, Lorg/greenrobot/eventbus/EventBus;->register(Ljava/lang/Object;)V

    .line 19
    .line 20
    .line 21
    :cond_14
    invoke-virtual {p0}, Landroid/view/View;->getContext()Landroid/content/Context;

    .line 22
    .line 23
    .line 24
    move-result-object v0

    .line 25
    if-nez v0, :cond_1b

    .line 26
    .line 27
    return-void

    .line 28
    :cond_1b
    const/4 v0, 0x0

    .line 29
    throw v0
.end method


最后两行,创建了一个空对象,然后就直接throw了。

原因就是R8在shrink的时候发现这段代码后面的代码不会被执行到,并且只要执行到这里就必定为null,所以就直接省掉后面的代码直接抛出了一个空指针。

4查看getTorrentModule()正常的情况


这里让我找了好久,上面提到的TorrentDownloadHelper.addTaskCountListener()也被内联掉了,但是具体的代码放到了com.google.android.play.core.splitinstall.uuz里面。
至于为什么叫uuz,是因为它本来就叫uuz。这个是谷歌的库,它提供给我们使用的aar里面就叫这个名字,它已经混淆过了。但实际上这个类没几行代码,但是proguard硬生生给塞了一堆inline代码进去,使得这个类在我们的工程里面看起来庞大无比。它足足有646680-645787=893个方法在里面。这个是我没想到的。
也就是标记①时候的Smali,完全标注,一行不漏:
.method public static com.mxtech.videoplayer.bridge.torrent.utils.TorrentDownloadHelper.addTaskCountListener(Landroid/content/Context;Lcom/mxtech/videoplayer/bridge/torrent/view/TorrentTaskCountListener;)V
    .registers 8

    .line 1
    :try_start_0
    sget-object v0, Lcom/google/android/play/core/splitinstall/zzu;->com.mxtech.videoplayer.bridge.torrent.utils.TorrentDownloadHelper.obj:Ljava/lang/Object;
# TorrentDownloadHelper.obj赋值给v0, 标记try_start_a [ 这个标记的作用可以看 line40,用于标记try-catch的范围 ]

    .line 2
    .line 3
    const/4 v1, 0x0

    .line 4
    const/4 v2, 0x1
# 初始化v1 v2, v1=0, v2=1

    .line 5
    if-nez v0:cond_29
# v0不为空则跳转到cond_29,在下面的line 41,为空则继续走初始化

    .line 6
    .line 7
    sget-boolean v0, Lkotlin/jvm/internal/CollectionToArray;->com.mxtech.videoplayer.bridge.torrent.TorrentBridge.moduleLoaded:Z
    :try_end_8
    .catch Ljava/lang/Exception; {:try_start_0 .. :try_end_8:catch_47
# 判断isLoaded(),boolean值变量结果存到v0,标记抛异常的范围。

    .line 8
    .line 9
    if-eqz v0:cond_29
# 判断新布尔值v0, false跳到cond_29

    .line 10
    .line 11
    :try_start_a
    const-string v0"com.mxtech.torrent.TorrentModule"
# v0存个字符串

    .line 12
    .line 13
    invoke-static {v0}, Ljava/lang/Class;->forName(Ljava/lang/String;)Ljava/lang/Class;
# 获取字符串类名指向的Class

    .line 14
    .line 15
    .line 16
    move-result-object v0
# 结果继续存v0

    .line 17
    const-string v3, "get"
# 字符串存v3

    .line 18
    .line 19
    new-array v4, v2, [Ljava/lang/Class;
# 上面line4 中 v2 = 1, 所以新建一个Class的数组长度为1,存到v4 

    .line 20
    .line 21
    const-class v5, Landroid/content/Context;
# Context.class存到v5

    .line 22
    .line 23
    aput-object v5, v4, v1
# 将v5存的Context.class值存到v4的数组中,index = v1, v1在line4中初始化为0

    .line 24
    .line 25
    invoke-virtual {v0, v3, v4}, Ljava/lang/Class;->getDeclaredMethod(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;
# 调用v0中存的Class(com.mxtech.torrent.TorrentModule)的getDeclaredMethod方法,传入两个参数,v3中存的字符串“get”, v4中存的Context.class数组,返回值为Method对象

    .line 26
    .line 27
    .line 28
    move-result-object v3
# 返回值Method对象存到v3

    .line 29
    invoke-virtual {v3, v2}, Ljava/lang/reflect/AccessibleObject;->setAccessible(Z)V
# 调用v3的setAccessible方法,传入v2,0x1表示true

    .line 30
    .line 31
    .line 32
    new-array v4, v2, [Ljava/lang/Object;
# v4新建Object数组,size=1

    .line 33
    .line 34
    aput-object p0, v4, v1
# p0表示this指针,将p0存到引用位于v4的数组中,index偏移量为v1=0

    .line 35
    .line 36
    invoke-virtual {v3, v0, v4}, Ljava/lang/reflect/Method;->invoke(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;
# 调用v3=Method对象的invoke方法,传参为v0=TorrentModule.class, v4=this自己

    .line 37
    .line 38
    .line 39
    move-result-object p0
# 返回的Object对象存到p0

    .line 40
    sput-object p0, Lcom/google/android/play/core/splitinstall/zzu;->com.mxtech.videoplayer.bridge.torrent.utils.TorrentDownloadHelper.obj:Ljava/lang/Object;
    :try_end_29
    .catch Ljava/lang/Exception; {:try_start_a .. :try_end_29:catch_29
# p0赋值给TorrentDownloadHelper.obj,标记try_end_47,从标记try_start_a到标记try_end_29中间抛异常直接跳转到catch_29

    .line 41
    .line 42
    :catch_29
    :cond_29
    :try_start_29
    sget-object p0, Lcom/google/android/play/core/splitinstall/zzu;->com.mxtech.videoplayer.bridge.torrent.utils.TorrentDownloadHelper.obj:Ljava/lang/Object;
# 获取TorrentDownloadHelper.obj赋值给p0,标记try_start_29

    .line 43
    .line 44
    invoke-virtual {p0}, Ljava/lang/Object;->getClass()Ljava/lang/Class;
# 调用p0.getClass()

    .line 45
    .line 46
    .line 47
    move-result-object p0
# 结果存到p0, 此时p0存的是TorrentDownloadHelper.obj.class

    .line 48
    const-string v0"addTaskCountListener"
# v0存字符串 "addTaskCountListener"

    .line 49
    .line 50
    new-array v3, v2, [Ljava/lang/Class;
# 创建一个Class数组,存到v3,长度v2=1

    .line 51
    .line 52
    const-class v4, Lcom/mxtech/videoplayer/bridge/torrent/view/TorrentTaskCountListener;
# v4存TorrentTaskCountListener.class

    .line 53
    .line 54
    aput-object v4, v3, v1
# 把v4 = TorrentTaskCountListener.class存到v3的Class数组中,index偏移量为v1=0

    .line 55
    .line 56
    invoke-virtual {p0, v0, v3}, Ljava/lang/Class;->getDeclaredMethod(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;
# 调用p0=TorrentDownloadHelper.obj.class的getDeclaredMethod方法,传参为v0="addTaskCountListener",v3=Class数组,返回Method对象

    .line 57
    .line 58
    .line 59
    move-result-object p0
# Method对象存到p0

    .line 60
    invoke-virtual {p0, v2}, Ljava/lang/reflect/AccessibleObject;->setAccessible(Z)V
# 调用p0=Method的setAccessible()方法,传参v2=true

    .line 61
    .line 62
    .line 63
    sget-object v0, Lcom/google/android/play/core/splitinstall/zzu;->com.mxtech.videoplayer.bridge.torrent.utils.TorrentDownloadHelper.obj:Ljava/lang/Object;
# 获取obj对象存v0

    .line 64
    .line 65
    new-array v2, v2, [Ljava/lang/Object;
# 创建size=1的数组存v2

    .line 66
    .line 67
    aput-object p1, v2, v1
# p1对象存到v2数组中,偏移量为v1

    .line 68
    .line 69
    invoke-virtual {p0, v0, v2}, Ljava/lang/reflect/Method;->invoke(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;
    :try_end_47
    .catch Ljava/lang/Exception; {:try_start_29 .. :try_end_47:catch_47
# 调用p0=Method对象的invoke方法,传参v0, v2,标记try_end_47,从标记try_start_29到标记try_end_47中间抛异常直接跳转到catch_47

    .line 70
    .line 71
    .line 72
    :catch_47
    return-void
# 执行结束,无返回值

.end method


这里把上述的Smali代码一行不漏的注释了一下,其实这样看来其实Smali其实并不难理解和阅读。大家后续查看的时候也可以直接查看Smali,迫不得已的时候可以用jd-gui翻译。

5关于mapping中的RewriteFrame. one moe thing.


有时候我们在mapping文件中我们能看到像这样的信息,这其实也是我们关注的代码被内联的。
some.Class -> a:
  4:4:void other.Class.inlinee():23:23 -> a
  4:4:void caller(other.Class):7 -> a\n"
  # { id: 'com.android.tools.r8.rewriteFrame', "

      conditions: ['throws(Ljava/lang/NullPointerException;)'],
      actions: ['removeInnerFrames(1)'] }


在r8的文档上R8, Retrace and map file versioning,我们能看到用法。

https://r8.googlesource.com/r8/+/bacd974e744c12fea874a908a97bc3b62f0b539d/doc/retrace.md


RewriteFrame信息表示retrace工具在异常回溯到这一帧代码的时候需要重写一下,有以下的信息:
# { id: 'com.android.tools.r8.rewriteFrame', "
      conditions: ['throws(<exceptionDescriptor>)'],
      actions: ['removeInnerFrames(<count>)'] }


很明显,规定了当发生throws(<exceptionDescriptor>)这种情况的时候需要采取removeInnerFrames(<count>)这种对应的措施。
  • throws(<exceptionDescriptor>): 将会为true,如果发生这种 <exceptionDescriptor>
可以通过向列表添加更多项目来组合条件。添加多种条件是实现了AND,如果要实现OR就应该复制多条信息,而不是添加多个条件。
  • removeInnerFrames(<count>):将从最内层帧开始删除帧数。指定高于所有帧的计数是错误的。
下面举一个例子,如果抛出NPE异常,就删除部分内联的代码:
some.Class -> a:
  4:4:void other.Class.inlinee():23:23 -> a
  4:4:void caller(other.Class):7 -> a\n"
  # { id: 'com.android.tools.r8.rewriteFrame', "

      conditions: ['throws(Ljava/lang/NullPointerException;)'],
      actions: ['removeInnerFrames(1)'] }


如果没有RewriteFrame,崩溃栈应该是下面的样子:

Exception in thread "mainjava.lang.NullPointerException: ...
  at other.Class.inlinee(Class.java:23)
  at some.Class.caller(Class.java:7)


使用上述内联信息修改最后一个映射会指示回溯器丢弃上面的帧,从而产生回溯结果:
Exception in thread "mainjava.lang.NullPointerException: ...
  at some.Class.caller(Class.java:7)


rewriteFrame仅当正在回溯的行直接位于异常行下方时,才会应用该信息。

6总结


代码总是要被打到dex里按照字节码来执行,Android是基于寄存器的虚拟机。
崩溃栈有时候会跟我们看到的不一样,我们参照以下的原则来查看crash,肯定能水到渠成。
  1. 一般情况,直接查看代码,崩溃栈跟现有代码清晰一致,皆大欢喜。
  2. 出现崩溃栈跟现有代码对不上,在obfuscate阶段肯定发生了内联,先去usage.txt里查看“嫌疑人代码”有没有被内联掉。
  3. 如果发生内联,去mapping.txt里面查找被内联到了哪里,可能是同一个类,也可能是不同的类。
  4. 去dex中查看真正的代码逻辑,肯定是能跟崩溃栈对的上的。


最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

Kotlin的魔法武器
华为鸿蒙Next全解析
Gradle版本检查中的一个特例



扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

继续滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存