Crash 是每一个 Android 应用都会遇到的问题。通常来说,应用层的 Crash 比较好查问题,直接查看崩溃日志就行,都是直观的应用层业务源代码,哪怕混效过的源代码也能通过打包生成的对应 Mapping 文件还原回来。
相反,Native 层的 Crash 比较棘手,log 信息都是一堆 C++ 的指针地址,虽然也提供 Crash 发生时调用的依赖库 so 文件信息,却没有给到具体的 C++ 类、函数和代码行数信息,需要开发人员借助一些工具,譬如 NDK 安装包里面的 ndk-stack 工具,来反编译这些让人一时摸不着头脑的日志信息。
为了讲清楚 ndk-stack 的使用和分析方法,文章的开始需要先利用 Android Studio 开发工具建立一个简单的工程,并编写一段产生错误的 C++ 源代码,然后利用 ndk-build 工具生成对应的 so 文件进行打包测试。
接下来的内容太多,捡重点说。
准备工作做起来。
建立工程,通过 SDK Manager 下载 NDK 软件包,并配置到工程里面,比较简单:
local.properties:
|
|
新建 jni 源码目录
右键 app/src/main 目录(java 同级目录),依次选择 New -> Folder -> JNI Folder 选项,AS 工具会自动生成一个名为 jni 的目录,用于存放 C++ 源代码等。
接下来我们将本文中用到的命令行操作做成 AS 快捷键的方式,提升开发效率。
新建 javah 快捷键
JDK 提供的 javah 工具能够根据 java 代码生成对应的 C++ 头文件,我们把这个过程做成 AS 开发工具中的 External Tools 快捷键。
打开 AS 设置窗口,找到 Toos -> External Tools 选项,点击 + 选项添加名为 javah-jni 的快捷操作:
javah-jni 名字可以随意取值,主要是 Tool Settings 中这几个命令参数设置,不能有错。
Program: javah 工具的安装路径;
|
|
Arguments: 指定 C++ 头文件输出目录,即上一步新建的 jni 目录;以及依赖的 java 类信息,采用 $FileClass$ 配置;
|
|
Working Directory: 指定命令执行时所处的工作目录,也是固定值;
|
|
设置完成,保存即可。
备注:这里的 $JDKPath$、$ModuleFileDir$ 和 $FileClass$ 通配地址都可以在设置窗口对应选项右侧 Insert Macros 中直接选择。
新建 ndk-bundle 快捷键
还是类似上一步的操作,ndk-bundle 名字可以随意取值,重点还是配置命令行工具的路径参数:
Program: 配置第一步下载的 NDK 安装包中 ndk-build 工具地址,按需修改;
|
|
Working Directory: 工作目录
|
|
这两步配置完成之后,右键项目,弹出的快捷窗口 External Tools 选项中都可以看到对应名字的快捷操作选项,非常方便。
接下来进入正式的 NDK 开发工作。
NDK 开发测试
编写测试代码,创建一个应用层 Activity 调用 native 方法的例子,比如:
|
|
这里主要包括两个部分,加载名为 “libfengtest” 的 so 库(这是后面步骤我们打包到 apk 安装包中的自定义库文件),以及一个 native 的测试函数。
注意:System.loadLibrary 加载 so 文件时,会自动添加 lib 前缀和 .so 后缀,所以这里加载 libfengtest.so 文件,参数名只需要 “fengtest” 部分即可。
接下来利用 javah 工具生成对应 MainActivity.java 类和其中包含的 native 方法的 C++ 头文件。
右键 MainActivity 文件,在弹出的 External Tools 窗口中点击前面创建的 javah-jni 快捷命令,工具会自动在 jni 包下生成名以 MainActivity 类的包名和类名命名的头文件,内容如下:
|
|
这个自动生成的头文件无需任何修改,接下来手动编写 cpp 源码部分,创建一个名为 FengTest.cpp 的 C 语言源代码文件:
|
|
主要实现给应用层 java 类中调用的 native 方法,注意方法名字满足特定格式,别写错了。
然后是创建 Android.mk 和 Application.mk 这两个配置文件。
Android.mk
|
|
Application.mk
|
|
接着是在 app/build.gradle 配置文件中指定关联信息:
|
|
到这一步,开发工作就完成了。可以直接 run 起来测试,MainActivity 界面能够成功调用 cpp 文件中的函数并拿到返回结果就可以了。
这时可以直接在 Android Studio 中打开 build/outputs/apk/debug/app-debug.apk 安装包,能够看到 lib 目录下有对应 CPU 架构的 so 文件。
编译 so 文件
上面步骤中,我们通过 gradle 编译的方式直接采取 run 操作将 so 文件打包生成的 apk 文件里。还可以通过执行前面配置的 ndk-build 快捷键来生成我们想要的 so 文件。
右键 jni 目录,选择 External Tools 中的 ndk-build 快捷方式,ndk-build 工具会帮我们编译生成我们配置文件里面指定的 CPU 架构对应 so 文件。
注意:ndk-buid 工具编译生成的 so 文件有两种:一种存放于 libs 目录下,用于打包进 apk 文件中使用的;另一种位于 obj/local 目录中,Google 称之为未剥离版共享库。
比如前面我们在 Application.mk 指定 APP_ABI 为 all,就会生成常见 arm 和 x86 的所有架构 so 文件,按需使用即可。
ndk-stack 工具
文章开头说了,要使用 ndk-stack 反编译 native 层的错误日志信息。我们稍微修改一下 cpp 部分代码,手动制造一个 C++ native 层的空指针异常:
|
|
再次运行工程时,应用就会崩溃,在 logcat 工具中可以找到对应的 crash 日志信息:
|
|
可以看到 crash 所在的 so 库,却看不到具体的源代码行数信息。如果代码量很大的话,根据这些内存地址信息几乎找不到对应的错误源头。
这时利用 ndk-stack 工具可以很方便地帮助我们反编译这些日志,转化成更具可读性的日志信息。这里我们需要借助 ndk-build 工具生成的 so 文件帮助我们反编译 native 日志,完整命令如下:
|
|
注意,这里用的不是 libs 目录中打包进 apk 文件里面的那个 so 库,而是编译生成的 obj 目录下对应 CPU 架构里面的 so 文件。Gradle 编译打包产生的 build 目录文件也指明了当前调试连接设备的 ABI 类型,其实还可以通过 adb 命令查看(先进入 shell,再获取):
|
|
执行 logcat 命令,既可以将设备本地记录的之前的 log 信息进行转换打印,也可以重新操作复现问题,实时打印新的日志。
回到正题,执行 ndk-stack 工具命令,我们就可以将只有指针地址信息的 native 日志转换成更具可读性的日志信息。还是上面的例子,我们看下转换过后的日志:
|
|
可以看到,Crash 所在位置具体到 so 库的哪一个类哪一个方法以及哪一行代码都历历在目,这样排查 Native 层的 Crash 问题就非常方便了。