Android 开发艺术探索学习笔记(六)

Part 6

结合 官方文档 阅读《Android 开发艺术探索》时所做的学习笔记。本篇记录第 13~15 章。

综合技术

使用 UncaughtExceptionHandler 收集崩溃信息

当应用崩溃的时候会弹出一个『App has stopped』的弹窗,同时我们的应用也会被杀死。我们可以通过替代系统默认的 UncaughtExceptionHandler 来改变这种行为,也可以对崩溃信息进行收集。

UncaughtExceptionHandler 是 Thread 中的一个静态接口,当抛出未被捕获的异常时会回调这个接口中的方法,我们只要实现其中的 uncaughtException(Thread t, Throwable e) 方法,然后再用它替代默认的 handler 就可以了。如下:

class CrashHandler(private var context: Context) : Thread.UncaughtExceptionHandler {

    private val debug = false

    private val mDefaultCrashHandler: Thread.UncaughtExceptionHandler
            = Thread.getDefaultUncaughtExceptionHandler()

    init {
        Thread.setDefaultUncaughtExceptionHandler(this)
    }

    override fun uncaughtException(t: Thread?, ex: Throwable?) {
        if (!handleException(ex)) {
            // 由系统处理
            mDefaultCrashHandler.uncaughtException(t, ex)
        } else {

            try {
                Thread.sleep(2000)
            } catch (e: InterruptedException) {
                e.printStackTrace()
            }

            if (!debug) {
//                val intent = Intent(context, MainActivity::class.java)
//                val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
//                val pendingIntent = PendingIntent.getActivity(context,
//                        0, intent, PendingIntent.FLAG_ONE_SHOT)
//                mgr[AlarmManager.RTC, System.currentTimeMillis() + 100] = pendingIntent

                // 使用 killProcess() 在某些情况下也会使应用重启
                Process.killProcess(Process.myPid())
//                exitProcess(1) // 效果与上面相同
            }
        }
    }

    private fun handleException(ex: Throwable?): Boolean {
        if (ex == null) {
            return false
        }

        // 展示 Toast 提醒
        object : HandlerThread("ShowToast") {
            override fun onLooperPrepared() {
                Toast.makeText(context,
                        "很抱歉,程序出现异常,即将退出。",
                        Toast.LENGTH_LONG).show()
            }
        }.start()

        /* 保存出错日志、用户机型、操作记录等信息 */
        dumpExceptionToSDCard(ex)

        /* 上传服务器 */
        uploadExceptionToServer()

        return true
    }

    private fun dumpExceptionToSDCard(ex: Throwable?) {

    }

    private fun uploadExceptionToServer() {

    }
}

除此之外,我们也可以利用它自定义发生崩溃时的 UI 显示。比如这个三方库:CustomActivityOnCrash,可以看到该库在 install() 方法中创建了一个 Thread.UncaughtExceptionHandler,并在其中启动默认的或者开发者自定义的 crash activity。

使用 multidex 解决方法数越界

65536 应该是每个 Android 开发都熟悉的数字,因为单个 dex 文件所能包含的最大方法数为 65536。官方的解决方案是:添加 multidex 支持。

关于如何配置见:Configure your app for multidex

关于 multidex 的缺点见:Limitations of the multidex support library

好消息是 Android 5.0 以上默认就开启了 multidex,所以随着时间的推移,也许以后就不需要担心这个问题了。

Android 的动态加载技术

随着项目越来越庞大,动态加载技术(插件化技术)也越来越频繁地出现在我们的视线中,比如 VirtualAPKatlas 等库,插件化技术的使用也越来越简便,我们可以通过插件化减少内存和 CPU 的占用,另外,它的热插拔效果在某些业务场景下也算是比较实用。尽管目前来看,插件化不再像过去两年那么受到追捧了,但是其中的技术依旧值得我们研究。

插件化 App 分为宿主 App 和插件,一般将插件打包处理成 apk(也可以是 dex)。另外还需要用到一个代理 Activity,用于启动插件中的 Activity。一个插件化方案的实现至少要解决三个问题:资源访问、Activity 生命周期的管理、插件 ClassLoader 的管理。

  1. 资源访问

主要利用 AssetManager 中的 addAssetPath 方法,加载指定位置的 apk 即我们的插件。这是一个 hidden 方法,所以我们需要通过反射来调用。

具体见:DLPluginManager#createAssetManager

  1. Activity 生命周期的管理

一般通过将 Activity 的生命周期方法提取出来作为接口,然后再在代理 Activity 方法中调用对应的周期方法。相比使用反射,这种方法较为简单,节省性能开销。

具体见:DLProxyActivity.java

  1. 插件 ClassLoader 的管理

我们需要对插件的 DexClassLoader 进行管理,从而避免多个 ClassLoader 加载同一个类发生类型转换错误。比如,我们可以将多个 ClassLoader 用 HashMap 保存并管理。

具体见:DLPluginManager.java

反编译入门

这里部分主要是介绍工具,而工具很容易过时 (Apktool, dex2jar, JD-GUI),关于这些旧工具的使用方法推荐看看郭霖的文章:

下面介绍下目前我觉得更好用的反编译工具吧。

  1. jadx

支持命令行和 GUI 的 dex 和 apk 反编译工具。

使用介绍可以看这篇文章:Android 反编译利器,jadx 的高级技巧

  1. JEB Decompiler

功能强大的支持查看 Smali 代码的 Dalvik 反编译工具,逆向必备。

使用介绍:Android 反编绎工具JEB简介及下载

  1. apkstudio

基于 QT 的反编译工具,功能和 jadx 差不多。

JNI 和 NDK 编程

JNI 即 Java Native Interface,提供了一种直接和 native 代码(C、C++)进行交互的方式。NDK 是 Android SDK 的一部分,它是提供了一系列工具帮助我们管理 native 代码,以及访问系统底层的能力。我们可以利用 NDK 把 native 代码编译成本地 so 库,然后再通过 JNI 去调用它们。

JNI 开发流程

  1. 在 Java 类中声明 native 方法

除了声明方法之外,一般在静态代码块中使用 System.loadLibrary 加载 so 库,也可以使用 ReLinker

详情见:Native libraries

  1. 编译 Java 文件类,再将 class 文件导出 JNI 头文件
javac java/file/path/JniClass.java
javah java.file.path.JniClass

之后生成一个头文件 java_file_path_JniClass.h,其中包含我们定义的 native 方法声明,其方法名遵循格式如 Java_PackageName_ClassName_MethodName。

  1. 实现 JNI 方法

我们需要将头文件复制到 jni 目录下,然后创建 .cpp 和 .c 文件并在其中实现 JNI 方法。

  1. 编译 so 库并在 Java 中使用

我们可以使用 gcc 进行编译生成 so 库,然后再使用 java 命令进行调用。

推荐阅读

NDK 开发流程

使用 NDK 具有以下好处:

  • 提高代码安全性,因为 so 库反编译更困难。
  • 可以使用已有的 C/C++ 开源库,比如 FFmpeg。
  • 便于平台间的移植,比如 Dropbox 移动应用早期就是主要使用 C/C++ 来实现平台间代码共享的,不过由于维护成本较高后来被放弃了。其博客文章:The (not so) hidden cost of sharing code between iOS and Android
  • 提高应用程序在某些场景下的执行效率,常见的比如 3D 图形、音视频等。

使用 NDK 所需的工具有:

  • NDK:基本工具集
  • CMake:配合 gradle 使用,用于构建 native 代码库,一些老项目可能还在使用 ndk-build (.mk)
  • LLDB:用于 debug native 代码

关于使用方式的介绍:How it Works?,这里的例子还是使用 ndk-build 的,推荐使用 CMake 进行构建。

Sample 见:ndk-sample

JNI 的数据类型和描述符

JNI 基本数据类型与 Java 对应关系

Java Type Native Type Description
boolean jboolean unsigned 8 bits
byte jbyte signed 8 bits
char jchar unsigned 16 bits
short jshort signed 16 bits
int jint signed 32 bits
long jlong signed 64 bits
float jfloat 32 bits
double jdouble 64 bits
void void N/A

JNI 引用类型

  • jobject

    • jclass (java.lang.Class objects)

    • jstring (java.lang.String objects)

    • jarray (arrays)

      • jobjectArray (object arrays)
      • jbooleanArray (boolean arrays)
      • jbyteArray (byte arrays)
      • jcharArray (char arrays)
      • jshortArray (short arrays)
      • jintArray (int arrays)
      • jlongArray (long arrays)
      • jfloatArray (float arrays)
      • jdoubleArray (double arrays)
    • jthrowable (java.lang.Throwable objects)

类型签名

Type Signature Java Type
Z boolean
B byte
C char
S short
I int
J long
F float
D double
L fully-qualified-class ; (注意『;』是必需的) fully-qualified-class
[ type type[]
( arg-types ) return-type method type

比如一个 Java 方法:

long f (int n, String s, int[] arr);

其类型签名为:

(ILjava/lang/String;[I)J

参见:JNI Types and Data Structures

JNI 调用 Java 方法的流程

简单来说,对于静态方法,首先通过类名找到类 (FindClass),然后再通过方法名找到方法 ID (GetStaticMethodID),最后构建参数并对方法进行调用。如果非静态方法,则需要先创建对象,然后再通过对象获取方法 ID (GetMethodID),两者流程类似。

推荐阅读

JNI Functions

(JNI 和 NDK 接触不多,以后使用到再慢慢研究)

Android 性能优化

Android 性能优化方法

布局优化

布局的层级越复杂,Android 所需要的绘制时间就越长。优化布局首先我们要删除布局中无用的控件和嵌套,一般 lint 都会有提醒,但是某些情况下可能 lint 识别不了,就需要我们在开发过程中注意了。可以使用 Hierarchy Viewer 来对布局进行检测和优化。另外,善用 include, mergeViewStub,让布局尽量清晰和易于管理。

对于简单布局尽量用 FrameLayout 和 LinearLayout,对于需要嵌套的布局,尽量使用单个 RelativeLayout 替代,而对于某些复杂的布局,需要嵌套很多层的那种,考虑是否可以通过使用 ContraintLayout 来减少嵌套层数。

推荐阅读:Improving Layout Performance

绘制优化

避免在 onDraw() 方法中执行大量操作,主要表现在两个方面:

  1. 避免创建局部对象

因为 onDraw() 可能被频繁调用,意味着临时会创建出大量的局部对象,此时如果内存不足,有可能引发 GC 从而造成界面卡顿。

  1. 避免做耗时操作

同样因为 onDraw() 会被多次调用,如果有耗时操作,则绘制流程变慢从而造成卡顿。View 的绘制帧率应该保持在 60 FPS,意味着每帧耗时要不超过 1000/60=16 毫秒。

推荐阅读:Slow rendering

内存泄露优化

内存泄露的场景
  1. 静态变量导致的内存泄露

比如静态的 Context 或者 View/Drawable 的引用被其他对象持有,导致 GC 始终无法回收 Activity 或者 Fragment。

解决方案:避免使用静态 Context 或者 View/Drawable 等。

  1. 非静态内部类持有 Activity 的引用

解决方案:

  • 不使用内部类或者使用静态内部类
  • 如果必须引用 Context 或者 View,使用 WeakReference,这样垃圾回收器就可以在其不再使用时将其回收
  1. 内部类广播创建并注册后没有在 onStop() 中解注册

解决方案:记得在 onStop() 解注册广播。

  1. 单例类持有 Activity 后没有及时销毁,导致 Activity 无法被回收

解决方案:

  • 记得在 activity 销毁后同时也销毁单例类中的引用
  • 不使用 Activity 的 Context 而是使用 ApplicationContext
  1. 无限循环的属性动画没有及时停止

解决方案:在 onDestroy() 中及时 cancel 动画。

  1. AsyncTask 的错误使用

比如直接在 Activity 中使用内部类实现 AsyncTask;在 Activity 销毁后没有及时 cancel AsyncTask;在 AsyncTask 中直接访问持有了 View 的引用。

解决方案:

  • 使用静态内部类
  • 在 onDestroy() 中及时取消 AsyncTask
  • 使用 WeakReference 来访问 View 的引用
  1. Handler 的错误使用

与 AsyncTask 类似,我们不能直接创建 handler 然后利用其 postXXX 方法(创建匿名内部类 Runnable),因为 Message 或者 runnable 会持有 handler 的引用。

解决方案:使用静态内部类并且使用 WeakReference

  1. Thread 的错误使用

解决方案:使用静态内部类和弱引用,以及在 onDestroy() 中调用 interrupt() 中止线程。

  1. TimerTask 的错误使用

解决方案:和上面类似,使用静态内部类和弱引用并在 onDestroy() 中 cancel()

推荐阅读

响应速度优化和 ANR 日志分析

我们应该避免在主线程中做耗时操作,Android 系统如果检测到 Activity 超过 5 秒钟没有响应屏幕触摸事件或者键盘输入,就会报 ANR,另外 BroadcastReceiver 如果 10 秒钟之内还没执行完操作也会报 ANR。

发生 ANR 后,我们可以从 /data/anr/ 目录导出日志文件:

adb root
adb shell ls /data/anr
adb pull /data/anr/<filename>

老系统用的文件名可能是 /data/anr/traces.txt,新系统 (8.0 以上) 可能用的多个 /data/anr/anr_{date_time_id} 文件。

我们可以根据时间找到需要的文件,打开搜索 main,找到发生 ANR 的方法调用信息,然后利用这些信息分析造成 ANR 的原因。

推荐阅读:ANRs

线程优化

采用线程池代替直接创建线程,从而更好地重用线程以及减少线程频繁创建和销毁带来的性能开销,根据不同场景使用合适的线程池从而避免出现线程阻塞的出现。

推荐阅读

其它性能优化建议

  • 避免在 Activity 或者 Fragment 中创建过多的对象
  • 使用 Android 提供的一些数据结构,比如 SparseArrayPair 等,它们拥有更好的性能
  • 适当采用软引用弱引用
  • 如果必须使用内部类,尽量使用静态内部类,可以避免我们犯错而导致的内存泄露
  • 使用内存缓存和磁盘缓存
  • 不要过多使用枚举类,因为它比 int 类更占内存
推荐阅读:Performance tips

内存泄露分析工具

这部分是工具推荐,同样会因为时间推移而有更好的工具出现,所以就跳过不看了。目前我们有更好的工具可以使用,比如 LeakCanaryMemory Profiler

提高程序的可维护性

我们在软件开发过程中,功能开发可能只占很小一部分,大部分的时间都花在了维护上,所以代码的可维护性就显得特别重要了。可维护性体现在两个方面:可读性和可扩展性。想要让代码可读性好就需要我们遵循良好的代码风格以及养成良好的编码习惯。

除此之外,要注意一些细节,比如:

  • 命名简单易懂,不要用复杂的单词,参考周围人的意见
  • 注意一行代码的长度,以及代码的排列和对齐方式
  • 适当使用 region 合并代码组
  • 注释要写得规范,不写无用的注释
  • 减少复制粘贴,如果复制两次以上,就可以考虑提取成公共的函数(重构的时候)

能否写出可扩展性强的代码与开发者的经验有关,一般来说,至少需要我们学会使用设计模式,懂得重构,另外还需要对业务有较好的理解,从而能够应对各种变化。这是一个长期积累的过程。


系列小结

都说《Android 开发艺术探索》是迈向中高级开发的第一步,此刻看完这本书,心里觉得,其实要学的东西还有很多呐。尤其是对照着本书看官方文档的时候,现在的 Android 官方文档比之前做的好多了,归类更合理,但是内容似乎也变多了(原来的 Training 和 Guide 被合并到了一起),所以以后还是想花点时间把文档完整地看一遍。

另外,我跳过了第 9 章,四大组件的工作过程,因为这章太重要了,涉及到的知识也太多了,需要你融汇贯通本书中的所有核心知识才能阅读,而且这章大部分内容都是源码解析,最佳的阅读方式是自己对照源码结合本书然后再借助搜索引擎来阅读。以后我会慢慢把自己的阅读过程以及心得总结成博客发在这里。

系列文章