Android工程师进阶第九课 Android优化实战

564 篇文章 138 订阅
38 篇文章 11 订阅

第24讲:APK 如何做到包体积优化?

关于 APK Size 的优化,网上有很多版本的介绍。但是因为每个项目的背景、实现方式都不尽相同,导致各个项目之间能列出的共性相对较少。所以这节课我主要分享一下我在项目中对包体积优化的一些尝试。主要分两部分:安装包监控、安装包大小优化。

安装包监控

Android Studio 的 APK Analyser

这是 Android Studio 提供的一个 APK 检测工具,通过它可以查看一个 APK 文件内部各项内容所占的大小,并且按照大小排序显示。因此我们很容易观察到 APK 中哪一部分内容占用了最大空间。APK Analyzer 的使用非常简单,只要将需要分析的 APK 文件拖入 Android Studio 中即可,显示内容类似下图所示:

Drawing 0.png

从上图中可以很明显看出部分图片占用了比较大的资源空间,因此可以针对性地对其做压缩优化等操作。

实际上 APK Analyzer 的作用不光是查看 APK 大小,从它的名字也能看出它是用来分析 APK 的,因此可以使用它来分析一些优秀 APK 的目录结构、代码规范,甚至是使用了哪些动态库技术等。

Matrix中 的 ApkChecker

ApkChecker 是腾讯开源框架 Matrix 的一部分,主要是用来对 Android 安装包进行分析检测,并输出较为详细的检测结果报告。正常情况下我们需要下载 Matrix 源码,并单独编译 matrix-apk-cananry 部分。但是如果想快速使用 ApkChecker,可以直接在网上下载其 ApkChecker.jar 文件,然后创建一个配置文件 .json 即可。

官方的配置文件格式如下:

Drawing 1.png

配置文件有几个地方是需要我们去替换的。

  • apk:需要分析的 APK 文件的路径;

  • mappingTxt:指定混淆 mapping 文件的路径;

  • output:分析报告的输出目录;

  • rTx:APK 文件生成时,对应的 R 文件目录。

ApkChecker 的好处是可以命令行使用,这样我们可以很方便将其配置在自动化集成系统中,并对最终生成的 APK 文件进行分析,将产出报告发送到指定位置。这样不管是程序开发人员或者是测试工程师,都可以很直观地对当前版本 APK 有一个大概的评估。

注意:Matrix 也有一定的缺陷,比如使用 UnusedAssetTask 检索 assets 中的资源,这个过程只是调用 DexFileFactory.loadDexFile 加载 dex 文件,所以只会去搜索 java 文件中的引用。如果在 assets 目录下有一个 .json 文件,此 .json 文件中记录 assets 文件夹中的其他图片路径,然后在 Java 代码中通过 AssetManager 读取这个 .json 文件之后,循环遍历出它所引用的图片,对于这种方式 Matrix 是检测不到的,会将它置为 unused。关于这方面我也给 Matrix 作者提过反馈,目前是没有好的解决办法。

安装包优化实践

删除无用文件

使用 Lint 查看未引用资源。Lint 是一个静态扫描工具,它可以识别出项目中没有被任何代码所引用到的资源文件。具体使用也很简单,只要在 Android Studio 中点击 Analyze -> Inspect Code,然后选中整个项目即可,如下所示:

Drawing 2.png

如果项目中有未被使用资源,则 Lint 会在窗口 Inspection Result 中显示,类似结果如下:

Drawing 3.png

下面两个选项可以在项目编译时期减少被打包到 APK 中的文件, 使用 shrinkResources 能够在项目编译阶段,删除所有在项目中未被使用到的资源文件。但是需要将 minifyEnabled 选项设置为 true。

使用 resConfig 限定国际化资源文件。有时候我们使用到的三方库中可能会对各种国际化语言进行支持,但是我们自己的项目只支持某个语言,比如中文,那我们可以在 gradle 的 defaultConfig 中使用 resConfig 来限制打包到 APK 中的国际化资源文件,具体如下所示:

Drawing 4.png

文件优化
  • 关于静态图片优化

优先使用 VectorDrawable 图片,如果 UI 无法提供 VectorDrawable 图片,那么 webp 格式是一个不错的选择。Android Studio 也支持直接将 png 或者 jpg 格式图片转化为 webp 格式,如下所示:

Drawing 5.png

  • 关于动态图片优化

实际上 webp 也可以作动态图,只是目前对 webp 动图支持的三方库并不多,谷歌官方推荐的 Glide 对 webp 支持也不是很友好。

但是谷歌推出了一套 C++ 依赖库,上层开发人员可以基于此库的基础上使用 JNI 来解析 Animated webp 图片,并将解析出来的每一帧封装成一个 Bitmap,并根据解析出来的时间差值动态显示相应的帧 Bitmap 即可。如果 JNI 不熟或者不想再花时间精力去实现 JNI 调用,可以考虑使用 GitHub 的 Android-WebP 。Android 开发人员只需使用 WebpImageView 控件并指定图片路径即可。

另外针对动态图片,我们也做了其他方面的尝试。做过游戏开发的一般都比较熟悉 TextureAlas 这种图片格式,就是将多个序列图按照一定的排放位置合成到一张图片中,比如以下图片:

Drawing 6.png

并且跟随图片一起生成的还有一个用来对其解析的文本配置文件。主要是用来识别合成图中的路径、每张帧图片的序列、位置等。一般情况下配置文本的格式如下:

Drawing 7.png

这套方案主要是借鉴了一个轻量级游戏引擎 libgdx 的实现思路,解析上述的 TextureAtlas 图片,将生成的 Texture 渲染到 Bitmap 上展示每一帧内容。具体代码如下:

Drawing 8.png

![在这里插入图片描述](https://img-blog.csdnimg.cn/98d7f916ae744a098f3c019648290650.png#pic_center)

更多实现细节,可以参考 libgdx 在 GitHub上的介绍:Libgdx github wiki : Texture packer

这套方案的优点是图片压缩效果比 webp 和 gif 更加显著,生成的合成图片比 webp 和 gif 更小。但是缺点是使用技术门槛较高,需要有一定 OpenGL 基础。

  • 关于引入三方库

在 App 中会引入各种三方的”轮子”,但是在引入之前最好权衡一下是否需要将其代码全部引入,造成不必要的代码或者资源也被打包到 APK 中。

比如在我们项目中曾经使用到哔哩哔哩的 ijkplayer 库,原因是我们实现的视频渲染功能,在某些旧的厂商手机中无法正常播放。后来分析下来总结是厂商手机并没有很好地支持谷歌最新的硬编码格式,而使用 ijkplayer 的软编码恰恰能解决此问题。

但是 ijkplayer 是一套完整的视频播放器,很多功能都不是必需的,这种情况下如果只是因为解决一个在很小部分的手机上的 bug,而引入一个比较大的库性价比是不高的,因此需要将 ijkplayer 中关于软编码的功能摘取出来放到项目中。

这种做法同样适用于对 webp 动图的实现方案,上文中有介绍我们使用了谷歌官方推荐的 libwebp,但是这个库不光是为了解析 webp 图片,还有很大一部分代码是为了实现生成一个 webp 图片,这部分代码我们是不需要的,因此也需要将这部分代码给删除,最终编译之后生成的 so 库大小可以减少 1/ 3 左右。

  • 关于 App Bundle

如果 App 是海外项目,那么会舒服很多。因为谷歌官方支持动态发布。正常情况下我们的 APK 中为了更好地适配屏幕、语言等,会在项目里添加多套相应的资源文件,比如不同 hdpi 的 drawable,或者不同 CPU 下的 so 文件,最终打包生成的 APK 中会包含所有的资源文件。但是实际上一台手机设备只会用到这其中的一套资源,这无形中就已经产生了一些不必要的资源浪费。而谷歌的 Dynamic Delivery 功能就天然地解决了这个问题,通过 Google Play Store 安装 APK 时,会根据安装设备的属性,只选取相应的资源打包到 APK 文件中。

另外我们在项目中也使用了另一个 App Bundle 中比较好用的选项--Dynamic Asset Delivery。这个功能本来只是针对安装包超过 100M 的 App,但是不影响我们使用这套方案进行安装包优化。具体做法就是将大部分 assets 中的资源使用无损压缩的方式,压缩成一个 .obb 格式的文件,然后每次发布 APK 时都将此 obb 文件设置为 APK 的 bundle 文件,这样也可以减少用户实际的安装包大小。

但是 App Bundle 目前只适合在 Google Play Store 上发布的项目,国内目前还是通过各家的插件化方案来实现动态部署,一定程度上也可以算作减少安装包大小的方案。

总结

这节课内容主要介绍了我们平时在项目中关于安装包优化的一些实践总结,主要分两方面:

  • 安装包的监控

主要介绍了几个可以用来分析安装包大小以及详细内容的工具:Apk Analyzer 和 ApkChecker。实际上,在开发过程中,良好的编程习惯和严格的 code review 也是非常重要的。

  • 安装包优化实践

主要思路就是删减无用资源或者代码,并对资源文件进行相应的压缩优化。实际上除了资源文件,对于代码部分也可以更进一步的优化,比如使用 Proguard,或者直接使用 R8 编译方式。 只是因为 R8 还处于实验阶段,我们项目中没有过多的实践过程。对于这一部分极力推荐你阅读一下 Jake Wharton 的个人博客:jakewharton 中的相关介绍。


第25讲:Android 崩溃的那些事儿

在 Android 开发圈子里,似乎没有一个统一的标准来衡量一个工程师的水平高低,也没有一个标准的规范来表明 App 是好是坏。但有一条不成文的约定,大家似乎都会讨论各家 App 的崩溃率。这在一定程度上也说明了,崩溃问题是衡量 App 质量的决定性考核标准。其实也很容易理解,一个 5 分钟内连续崩溃超过 3 次的 App,被"卸载”几乎是不可避免的了。

好在 Android 系统会输出各种相应的 log 日志,大程度上降低了工程师 debug 崩溃问题的难度。如果要给 crash 日志进行分类,可以分成 2 大类:JVM 异常(Exception)堆栈信息、native 代码崩溃日志。

JVM 异常堆栈信息

Java 中异常(Exception)分两种:检查异常 checked Exception 和非检查异常 unchecked Exception。

所谓检查异常就是在代码编译时期,Android Studio 就会提示代码有错误,无法通过编译,比如 IOException。如果我们没有在代码中将这些异常 catch,而是直接抛出,最终也有可能导致程序崩溃。

非检查异常包括 error 和运行时异常(RuntimeException),AS 并不会在编译时期提示这些异常信息,而是在程序运行时期因为代码错误而直接导致程序崩溃,比如 OOM 或者空指针异常(NPE)。

Java 异常

对于上述两种异常我们都可以使用 UncaughtExceptionHandler 来进行捕获操作,它是 Thread 的一个内部接口,定义如下:

image

从官方对其介绍能够看出,对于传入的 Thread,如果因为“未捕获”异常而导致被终止,uncaughtException 则会被调用。我们可以借助它来间接捕获程序异常,并进行异常信息的记录工作,或者给出更友好的异常提示信息。

自定义异常处理类

自定义类实现 UncaughtExceptionHandler 接口,并实现 uncaughtException 方法:

image

需要注意的几点:

  • 在自定义异常处理类中需要持有线程默认异常处理类,如图中红框处所示。这样做的目的是在自定义异常处理类无法处理或者处理异常失败时,还可以将异常交给系统做默认处理。

  • 如果自定义异常处理类成功处理异常,需要进行页面跳转,或者将程序进程“杀死”。否则程序会一直卡死在崩溃界面,并弹出无响应对话框。如上图所示,我使用 CrashDisplayActivity 页面来展示所捕获的异常详细信息。

一般情况下,在 handlerException 方法中需要做以下几件事情。

1.收集 crash 现场的相关信息,如下面方法获取当前 App 的版本信息,以及所有手机设备的相关信息。

image

实际上,除了上述信息,还可以添加额外的自定义信息。

2.日志的记录工作,将收集到的信息保存在本地,比如以文件的方式保存。

image

从图中可以看出,除了我们自己收集的日志,还需要将系统抛出的异常信息也保存到文件中,方便后续开发人员分析问题原因。

使用自定义异常处理类

LagouExceptionHandler 定义好之后,就可以将其初始化,并将主线程注册到 LagouExceptionHandler 中。如下:

image

最终保存的 crash 日志信息格式下图所示:

image

需要注意的是,因为使用了文件写操作,所以需要动态申请文件操作的权限。

native 异常

当程序中的 native 代码发生崩溃时,系统会在 /data/tombstones/ 目录下保存一份详细的崩溃日志信息。如果一个 native crash 是必现的,不妨在模拟器上重现 bug,并将 /data/tombstones 中的崩溃日志拉到本地电脑中加以分析。

比如创建一个模拟 native crash 的项目 LagouNativeCrash,项目结构如下:

image

MainActivity 中有一个点击按钮 Button,当点击此按钮时,会调用 crash() 方法触发 native 代码崩溃,如下:

image

native crash 在 native-lib.cpp 文件中声明,如下:

image

当点击 Button,触发 native 崩溃之后,系统会在 /data/tombstones 目录下生成 tombstone 日志文件,可以在此日志文件中,查看详细的报错信息。如下所示:

image

但是如果 native crash 是偶发现象,并且在模拟器上一时难以复现,那么就需要将工作交给测试工程师在真机上尝试复现。那就需要一种机制,将 native crash 现场的日志信息保存到我们可以访问的手机目录中。目前比较成熟,使用也比较广泛的就是谷歌的 BreakPad。

Breakpad 是一个跨平台的开源库,我们也可以在其 Breakpad Github 上下载自己编译。并通过 JNI 的方式引入到项目中。

NDK 导入 BreakPad

在 Breakpad GitHub 官网上,有一个 README Android 的介绍文件。这个文件专门介绍了如何在 Android 项目中导入 BreakPad。我们可以直接使用 CMake 方式将其编译为一个静态库。

image

在捕获 native crash 之前,需要初始化 Breakpad,主要是设置 BreakPad 保存 crash 日志的路径,如下所示:

image

图中传入的 path 就是 Breakpad 保存日志的文件目录,一般情况下保存在外置 SD 卡目录。

初始化好之后,就可以在我们自己的 native 业务层模拟一个崩溃现场,Breakpad 会自动捕获这次 crash,并将生成的 crash 信息保存在所设置的目录中。

image

breakpad 生成的文件是 .dmp 文件,需要将其行转换,如下所示:

minidump_stackwalk 生成的 crash.dmp > result.txt

上述命令是将 breakpad 生成的 dmp 文件转化为 .txt 文件,然后使用文本编辑器打开此 txt 文件,就可以看出代码中是哪一行发生崩溃。

对于程序崩溃信号机制的介绍,可以参考腾讯的这篇文章:Android 平台 Native 代码的崩溃捕获机制及实现

线上崩溃日志获取

上面介绍的 Java 和 Native 崩溃的捕获都是基于现场能够复现 bug 的前提下。但是对于线上的用户,这种操作方式是不太现实的,首先我们不可能将一个 debug 版本的 APK 安装到终端用户的手机上;另外也不太可能要求用户操作一遍手机并将某一目录下的文件发送给开发人员。

对于大多数公司来说,针对线上版本,没有必要自己实现一个抓取 log 的平台系统。最快速的实现方式就是集成第三方 SDK。目前比较成熟,采用也比较多的就是腾讯的 Bugly。Bugly 基本能够满足线上版本捕获 crash 的所有需求,包括 Java 层和 Native 层的 crash 都可以获取相应的日志。并且每天 Bugly 都会邮件通知上一天的崩溃日志,方便测试和开发统计 bug 的分布以及崩溃率。

除了 Bugly 之外,还有一些其他的 crash 上报工具。比如 XCrash 和 Sentry。这两者比 Bugly 好的地方就是除了自动拦截界面崩溃事件,还可以主动上报错误信息。以 XCrash 为例,基本使用如下所示:

image

可以看出 XCrash 的使用更加灵活,工程师的掌控性更高。可以通过设置不同的过滤方式,针对性地上报相应的 crash 日志。并且在捕获到 crash 之后,可以加入自定义的操作,比如本地保存日志或者直接进行网络上传等。

另外:Sentry 还有一个好处就是可以通过设置过滤,来判断是否上报 crash 日志。这对于 SDK 的开发人员是很有用的。比如一些 SDK 的开发商只是想收集自身 SDK 引入的 crash,对于用户的其他操作导致的 crash 进行过滤,这种情况就可以考虑集成 Sentry。

总结

这节课主要介绍了 Android 崩溃的相关知识。对 Android 工程师来说,crash 可以分 2 类:Java 层和 Native 层。针对这 2 者进行捕获的方式也不尽相同。针对 Java 层一般通过自定义 UncaughtExceptionHandler 进行异常拦截;针对 Native 层可以考虑集成谷歌的 breakpad 进行捕获,并保存日志在本地。最后介绍了几个线上捕获 crash 的工具,实际上这几个工具的实现原理都是基于上文介绍的基础知识。


第26讲:面对内存泄漏,如何进行优化?

内存泄漏是一个隐形炸弹,其本身并不会造成程序异常,但是随着量的增长会导致其他各种并发症:OOM,UI 卡顿等。

Activity 内存泄漏预防

为什么要单独将 Activity 单独做预防,是因为 Activity 承担了与用户交互的职责,因此内部需要持有大量的资源引用以及与系统交互的 Context,这会导致一个 Activity 对象的 retained size 特别大。一旦 Activity 因为被外部系统所持有而导致发生内存泄漏,被牵连导致其他对象的内存泄漏也会非常多。

造成 Activity 内存泄漏的场景主要有以下几种情况。

1. 将 Context 或者 View 置为 static

View 默认会持有一个 Context 的引用,如果将其置为 static 将会造成 View 在方法区中无法被快速回收,最终导致 Activity 内存泄漏。

Drawing 0.png

图中的 imageView 会造成 ActivityB 无法被 GC 回收。

2. 未解注册各种 Listener

在 Activity 中可能会注册各种系统监听器,比如广播。

Drawing 1.png

运行上述 ActivityC,然后按下返回键。控制台将会显示如下 log,提示有内存泄漏发生:

Drawing 2.png

3. 非静态 Handler 导致 Activity 泄漏

Drawing 3.png

上述代码中的Handler也会造成ActivityD内存泄漏,一般需要将其置为static,然后内部持有一个Activity的弱引用来避免内存泄漏。如下所示:

Drawing 4.png

4. 三方库使用 Context

在项目中经常会使用各种三方库,有些三方库的初始化需要我们传入一个 Context 对象。但是三方库中很有可能一直持有此 Context 引用,比如以下代码:

Drawing 5.png

上述代码中将 ActivityE 本身当作一个 Context 传递给了一个模拟的三方库 ThirdParty 中,但是在三方库中将传入的 context 重新置为一个静态 static 类型。这种情况是一种隐形的 Activity 泄漏,在我们自己的项目中很难察觉出,所以平时开发过程中,尽量使用 Context.getApplicationContext,不要直接将 Activity 传递给其他组件。

提示:这也提醒我们自己在实现 SDK 时,也尽量避免造成外部 Context 的泄漏。比如下图是 JPush 中初始化的部分混淆代码:

Drawing 6.png

虽然是经过混淆之后的代码,但是也能大概猜出 checkContext 方法内部会使用 context.getApplicationContext 给内部 Context 赋值,因此即使我们传给 JPush 的是 Activity,也不会造成 Activity 泄漏。

内存泄漏检测

在开发阶段安卓工程师可以直接使用 Android Studio 来查看 Activity 是否存在内存泄漏,并结合 MAT 来查看发生内存泄漏的具体对象。这部分内容相信大多数安卓工程师都信手拈来,这节内容不展开详细介绍,详细使用过程可以参考:Android Studio和MAT结合使用来分析内存问题

除了 Android Studio 之外,另一个检查内存泄漏的神器就是 LeakCanary,也是本节重点介绍内容。

LeakCanary 是 Square 公司的一个开源库。通过它可以在 App 运行过程中检测内存泄漏,当内存泄漏发生时会生成发生泄漏对象的引用链,并通知程序开发人员。

可以看出 LeakCanary 主要分 2 大核心部分:

  1. 如何检测内存泄漏;

  2. 分析内存泄漏对象的引用链。

如何检测内存泄漏

JVM 理论知识

Java 中的 WeakReference 是弱引用类型,每当发生 GC 时,它所持有的对象如果没有被其他强引用所持有,那么它所引用的对象就会被回收。比如以下代码:

Drawing 7.png

上述代码运行之后,打印结果如下:

before gc, reference.get is com.danny.lagoumemoryleak.WeakRefDemo$BigObject@7852e922
after gc, reference.get is null

WeakReference 的构造函数可以传入 ReferenceQueue,当 WeakReference 指向的对象被垃圾回收器回收时,会把 WeakReference 放入 ReferenceQueue 中。比如我在上述代码中,调用 WeakReference 的构造器时,传入一个自定义的 ReferenceQueue,如下所示:

Drawing 8.png

那么打印结果如下:

before gc, reference.get is com.danny.lagoumemoryleak.WeakRefDemo$BigObject@7852e922
before gc, queue is null
after gc, reference.get is null
after gc, queue is java.lang.ref.WeakReference@4e25154f

可以看出,当 BigObject 被回收之后,WeakReference 会被添加到所传入的 ReferenceQueue 中。
再修改一下上述代码,模拟一个内存泄漏,如下所示:

Drawing 9.png

birObject 是一个强引用,导致 new BigObject() 的内存空间不会被 GC 回收。最终打印结果如下:

before gc, reference.get is com.danny.lagoumemoryleak.WeakRefDemo$BigObject@7852e922
before gc, queue is null
after gc, reference.get is com.danny.lagoumemoryleak.WeakRefDemo$BigObject@7852e922
after gc, queue is null
实现思路

LeakCanary 中对内存泄漏检测的核心原理就是基于 WeakReference 和 ReferenceQueue 实现的。

  1. 当一个 Activity 需要被回收时,就将其包装到一个 WeakReference 中,并且在 WeakReference 的构造器中传入自定义的 ReferenceQueue。

  2. 然后给包装后的 WeakReference 做一个标记 Key,并且在一个强引用 Set 中添加相应的 Key 记录

  3. 最后主动触发 GC,遍历自定义 ReferenceQueue 中所有的记录,并根据获取的 Reference 对象将 Set 中的记录也删除

经过上面 3 步之后,还保留在 Set 中的就是:应当被 GC 回收,但是实际还保留在内存中的对象,也就是发生泄漏了的对象。

源码分析

在上面原理介绍的例子里,我们知道一个可回收对象在 System.gc() 之后就应该被 GC 回收。可是在 Android App 中,我们并不清楚何时系统会回收 Activity。但是,按照正常流程,当 Activity 调用 onDestroy 方法时就说明这个 Activity 就已经处于无用状态了。因此我们需要监听到每一个 Activity 的 onDestroy 方法的调用。

ActivityRefWatch

LeakCanary 中监听 Activity 生命周期是由 ActivityRefWatch 来负责的,主要是通过注册 Android 系统提供的 ActivityLifecycleCallbacks,来监听 Activity 的生命周期方法的调用,如下所示:

Drawing 10.png

lifecycleCallbacks 的具体代码如下:

Drawing 11.png

可以看出当监听到 Activity 的 onDestroy 方法后,会将其传给 RefWatcher 的 watch 方法。

RefWatcher

它是 LeakCanary 的一个核心类,用来检测一个对象是否会发生内存泄漏。主要实现是在 watch 方法中,如下所示:

Drawing 12.png

解释说明:

  1. 图中 1 处生成一个随机的字符串 key,这个 key 就是用来标识 WeakReference 的,就相当于给 WeakReference 打了个标签;

  2. 图中 2 处将被检测对象包装到一个 WeakReference 中,并将其标识为步骤 1 中生成 key;

  3. 图中 3 处调用 ensureGoneAsync 开始执行检测操作。

因此关键代码就是在 ensureGoneAsync 方法中,代码如下:

Drawing 13.png

通过 WatchExecutor 执行了一个重载的方法 ensureGone。

ensureGone 中实现了内存泄漏的检测,方法具体实现如下:

Drawing 14.png

解释说明:

  1. 图中 1 处会遍历 ReferenceQueue 中所有的元素,并根据每个元素中的 key,相应的将集合 retainedKeys 中的元素也删除。

  2. 图中 2 处判断集合 retainedKeys 是否还包含被检测对象的弱引用,如果包含说明被检测对象并没有被回收,也就是发生了内存泄漏。

  3. 图中 3 处生成 Heap “堆”信息,并生成内存泄漏的分析报告,上报给程序开发人员。

removeWeaklyReachableReferences() 方法如下:

Drawing 15.png

可以看出这个方法的主要目的就是从 retainedKeys 中移除已经被回收的 WeakReference 的标志。

gone(reference) 方法判断 reference 是否被回收了,如下:

Drawing 16.png

实现很简单,只要在 retainedKeys 中不包含此 reference,就说明 WeakReference 引用的对象已经被回收。

LeakCanary 的实现原理其实比较简单,但是内部实现还有一些其他的细节值得我们注意。

内存泄漏的检测时机

很显然这种内存泄漏的检测与分析是比较消耗性能的,因此为了尽量不影响 UI 线程的渲染,LeakCanary 也做了些优化操作。在 ensureGoneAsync 方法中调用了 WatchExecutor 的 execute 方法来执行检测操作,如下:

Drawing 17.png

可以看出实际是向主线程 MessageQueue 中插入了一个 IdleHandler,IdleHandler 只会在主线程空闲时才会被 Looper 从队列中取出并执行。因此能够有效避免内存检测工作占用 UI 渲染时间。

通过 addIdleHandler 也经常用来做 App 的启动优化,比如在 Application 的 onCreate 方法中经常做 3 方库的初始化工作。可以将优先级较低、暂时使用不到的 3 方库的初始化操作放到 IdleHandler 中,从而加快 Application 的启动过程。不过个人感觉方法名叫 addIdleMessage 更合适一些,因为向 MessageQueue 插入的都是 Message 对象。

特殊机型适配

因为有些特殊机型的系统本身就存在一些内存泄漏的情况,导致 Activity 不被回收,所以在检测内存泄漏时,需要将这些情况排除在外。在 LeakCanary 的初始化方法 install 中,通过 excludedRefs 方法指定了一系列需要忽略的场景。

Drawing 18.png

这些场景都被枚举在 AndroidExcludedRefs 中,这种统一规避特殊机型的方式,也值得我们借鉴,因为国内的手机厂商实在是太多了。

LeakCanary 如何检测其他类

LeakCanary 默认只能机检测 Activity 的泄漏,但是 RefWatcher 的 watch 方法传入的参数实际是 Object,所以理论上是可以检测任何类的。LeakCanary 的 install 方法会返回一个 RefWatcher 对象,我们只需要在 Application 中保存此 RefWatch 对象,然后将需要被检测的对象传给 watch 方法即可,具体如下所示:

Drawing 19.png

testedObj 就是一个需要被检测内存泄漏的对象。

总结

这节课主要介绍了 Android 内存泄漏优化的相关知识。主要分两部分:

  • 内存泄漏预防

这需要我们了解 JVM 发生内存泄漏的原因,并在平时开发阶段养成良好的编码规范,避免引入会发生内存泄漏的代码。针对编码规范 Android Studio 可以安装一个阿里代码规范的插件,能够起到一定的代码检查效果。

  • 内存泄漏检测

内存泄漏检测工具有很多 Android Studio 自带的 Profiler,以及 MAT 都是不错的选择。但是相比较而言,使用这些工具排查内存泄漏门槛稍高,并且全部是手动操作,略显麻烦。除了这两个工具之外,我还介绍了一个自动检测内存泄漏的开源库—LeakCanary。主要包括它的实现原理以及部分源码实现细节。


第27讲:面对 UI 卡顿,如何入手分析解决问题?

本课时我们开始学习面对 UI 卡顿时,应该如何入手分析解决问题。

对于 UI 性能分析,Systrace 是目前使用最广的工具。它能够帮助开发者分析多个模块的运行状态以及详细信息。比如 SurfaceFlinger、View 刷机机制等。通过 Android 提供的脚本 systrace.py,可以设置数据采集方式并收集相关程序运行数据,最终生成一个网页文件提供程序开发者分析程序性能问题。

Systrace 简单使用

在 Android SDK 中提供了运行 Systrace 的脚本,具体路径在 android-sdk/platform-tools/systrace/ 文件夹中。

在此目录下执行以下命令,就可以抓取 Systrace 日志:

python systrace.py --time=10 -o my_systrace.html

Systrace 生成的是 html 文件,需要使用 Chrome 打开 my_systrace.html,显示效果如下:

Drawing 0.png

但是 Systrace 的使用有一定的难度,技术门槛比较高。了解屏幕刷新的机制能够更好地理解 Systrace 中的各个部分代表的含义。

CPU & GPU

在之前的章节也已经介绍过 View 的绘制会经过 Measure、Layout、Draw 这 3 个阶段,而这 3 个阶段的工作都是由 CPU 来负责完成。另外 CPU 还会负责一些用户输入、View 动画等事件,这部分工作都是在 UI 线程中完成。

当 CPU 绘制完成之后,会在 RenderThread 线程中将这部分数据提交给 GPU。GPU 负责对这些数据进行栅格化(Rasterization)操作,并将数据缓存的一个 Buffer 中。

最后手机屏幕再从这个 Buffer 中读取数据显示到屏幕上。实际上真正对 Buffer 中的数据进行合成显示到屏幕上的是 SurfaceFlinger。

具体流程如下图所示:

01.png

很明显这个过程有一个阶段是互相矛盾的,就是 GPU 向 Buffer 缓存数据,而屏幕从 Buffer 中取出数据。这就会存在并发问题,如果 GPU 正在向 Buffer 中做缓存,而此时屏幕也正好在从 Buffer 中取数据。这时就会造成 Buffer 中数据污染,屏幕有可能显示错乱。

为了避免这种情况发生,Android 又引入了双缓冲机制,意思就是有 2 个 Buffer:Back Buffer 和 Frame Buffer。CPU 提交的数据被缓存到 Back Buffer 中,然后 GPU 对 Back Buffer 中的数据做栅格化操作,完成之后将其交换(swap)到 Frame Buffer 中,最后屏幕从 Frame Buffer 中取数据显示。如下图所示:

02.png

注意:

因为 GPU 合成的数据会经常更新,所以它会负责定期的交换 Back Buffer 和 Frame Buffer 的数据,从而保证屏幕上显示最新内容。如果当 CPU 正在向 Back Buffer 中写入数据时,GPU 会将 Back Buffer 锁定。如果此时正好到了交换两个 Buffer 的时间节点,那么这次 swap 会被忽略放弃。直接导致的结果就是 Frame Buffer 中还是保存着上一帧的数据,最终屏幕上也显示之前的内容,这也就是我们常说的丢帧因此为了保证 App 能够流畅工作,我们需要在每帧 16ms 以内处理完所有的 CPU 与 GPU 的计算,绘制,渲染等等操作。完美的屏幕绘制过程应当如下图所示:

Drawing 3.png

Vsync

通过双缓冲技术再加上在应用层的优化,大多情况下已经完全能满足程序流程运行。但是有时还是会发生“丢帧”现象。这是因为屏幕刷新率和 GPU 绘制帧率并不一定是一致的。

screen refresh rate--屏幕刷新率。指的是手机屏幕每秒钟可以刷新多少次。目前在大多数的厂商手机上的屏幕刷新率是 60HZ,也就是以 16.6ms 进行一次刷新。
frame rate -- GPU 绘制帧率,指的是 GPU 每秒能够合成绘制多少帧。

屏幕刷新率是一个硬件指标,当手机出厂设置之后,屏幕刷新率就已经确定,软件层并没有办法对其进行修改。可是软件层触发 View 绘制的时机是随机的(代码里可以在任意时间调用 Invalidate 或者 requestLayout 刷新),因此我们无法控制 View 绘制的起始时间。比如之前图中的绘制过程也有可能会发生以下情况:

Drawing 4.png

可以看出在“帧 1”阶段,虽然 CPU 和 GPU 所消耗时间小于 16ms,但是它们开始的时间太晚,距离下一次屏幕刷新太近。所以当下一次屏幕刷新时,屏幕从 Frame Buffer 中拿到的数据还是“帧1”的数据,还是会“丢帧”。

为了解决这个问题,Android 系统引入了 Vsync 机制。每隔 16ms 硬件层发出 vsync 信号,应用层接收到此信号后会触发 UI 的渲染流程,同时 vsync 信号也会触发 SurfaceFlinger 读取 Buffer 中的数据,进行合成显示到屏幕上。简单来讲就是将上面 CPU 和 GPU 的开始时间与屏幕刷新强行拖拽到同一起跑线。实现下图效果:

Drawing 5.png

可以看出 vsync 的频率同屏幕刷新频率是一致的,因此 View 的渲染和 SurfaceFlinger 的合成也都按照 vsync 的信号的频率有条不紊地进行着。

Choreographer 编舞者

那么软件层是如何接受硬件发出的 vsync 信号,并进行 View 绘制操作的呢?答案就是 Choreographer。

在第 21 课时我们了解了 View 的渲染流程,这里再回顾一遍着整个流程。每次调用 View 的 invalidate 时,流程都会执行到 ViewRootImpl 的 requestLayout 方法,如下:

Drawing 6.png

在 requestLayout 中调用 scheduleTraversals,此方法具体如下:

Drawing 7.png

这个方法最核心的就是红框中所示的 mChoreographer.postCallback 方法,这个方法将 TraversalRunnable 以参数的形式传递给 Choreographer,方法实现具体如下:

Drawing 8.png

通过一系列调用,最终代码执行到了以下方法:

Drawing 9.png

图中红框中代码表示在 ViewRootImpl 中传入的 TraversalRunnable 插入到一个 CallbackQueue 中。后续当 Choreographer 接收到 vsync 信号时,会将此 TraversalRunnable 从队列中取出执行,具体稍后介绍。

何时注册 vsync 信号

Choreographer 是何时注册 vsync 信号的呢?还是要重新看一下上图中的 postCallbackDelayedInternal 方法,此方法中在将 TraversalRunnabl 插入队列 CallbackQueue 之后,还有一个比较重要的操作—sheduleFrameLocked,方法具体如下:

Drawing 10.png

可以看出,最终调用了一个 native 的本地方法 nativeScheduleVsync,这个方法实际上就是向系统订阅一次 vsync 信号。Android 系统每过 16.6ms 会发送一个 vsync 信号。但这个信号并不是所有 App 都能收到的,只有订阅的才能收到。这样设计的合理之处在于,当 UI 没有变化的时候就不会去调用 nativeScheduleVsync 去订阅,也就不会收到 vsync 信号,减少了不必要的绘制操作。

注意:

每次订阅只能收到一次 vsync 信号,如果需要收到下次信号需要重新订阅。比如 Animation 的实现就是在订阅一次信号之后,紧接着再次调用 nativeScheduleVsync 方法订阅下次 vsync 信号,因此会不断地刷新 UI。

何时接收 vsync 信号

注册 vsync 信号的操作是由 FrameDisplayEventReceiver 中的 nativeScheduleVsync 方法实现的,而 vsync 信号实际上也是由 FrameDisplayEventReceiver 来接收。当它接收到 vsync 信号后,会调用其内部的 onVsync 方法,如下所示:

Drawing 11.png

在 onVsync 方法中,会将 FrameDisplayEventReceiver 自身发送到 MessageQueue,所以需要查看其 run 方法,如下:

Drawing 12.png

最终调用了 Choreographer 的 doFrame 方法,这是一个非常重要的方法。就是在这个方法中将之前插入到 CallbackQueue 中的 Runnable 取出来执行。部分核心代码如下所示:

Drawing 13.png

可以看出在 doFrame 方法中主要完成 2 大操作:

  • 图中 1 处进行掉帧逻辑计算,并添加用于性能分析的 Trace 日志;

  • 图中 2 处执行各种 Callbacks,其实这就是一帧内真正做的事情。可以看出其实 Android 屏幕绘制一帧主要就是做处理 Input、animation、traversal 这 3 件事情。

Choreographer小结

Choreographer 是一个承上启下的角色。

  • 承上:接收应用层的各种 callback 输入,包括 input、animation、traversal 绘制。但是这些 callback 并不会被立即执行。而是会缓存在 Choreographer 中的 CallbackQueue 中。

  • 启下:内部的 FrameDisplayEventReceiver 负责接收硬件层发成的 vsync 信号。当接收到 vsync 信号之后,会调用 onVsync 方法 -> doFrame -> doCallbacks,在 doCallbacks 中从 CallbackQueue 中取出进行绘制的 TraversalRunnable,并调用其 run 方法进行绘制。

通过这样一套机制,保证软件层和屏幕刷新处于同一频率,使 Android App 可以以一个比较稳定的帧率运行,减少因为频率波动造成“丢帧”的概率。

利用 Choreographer 实现帧渲染监控

现在我们已经了解在 UI 渲染的过程中,Choreographer 会在 doFrame 方法中回调各种 callback,其中包括绘制 View 的 TraversalRunnable。

Choreographer 向外部提供了 FrameCallback 接口,来监听 doFrame 方法的执行过程。接口具体定义如下:

Drawing 14.png

可以看出这个接口中的 doFrame 方法会在绘制每一帧时被调用,所以我们可以在 App 层主动向 Choreographer 中添加 Callback,然后通过检测两次 doFrame 方法执行的时间间隔来判断是否发生“丢帧”。简单实现如下:

Drawing 15.png

postFrameCallback 方法实际上同 requestLayout 是平级关系,都会往 Choreographer 中的 CallbackQueues 中插入 Callback,只是 postFrameCallback 插入 Callback 的类型是 CALLBACK_ANIMATION。 正常情况下上述代码只会打印出“doFrame”日志,但是如果 doFrame 的执行时间超过 16.6ms,则会打印“发生丢帧!”。

注意: 上文中已经介绍过,每一次订阅都只会接收一次 vsync 信号,而我们需要一直监听 doFrame 的回调,因此在方法最后需要递归的执行 postFrameCallback 方法。

特殊优化--同步屏障消息

在 requestLayout 刷新 View 时,会调用 scheduleTraversals 方法,有一个小细节还没有介绍,如下:

Drawing 16.png

在向 Choreographer 插入 TraversalRunnable 之前,调用了 postSyncBarrier() 方法设置同步屏障。

Handler 中的 Message 分 2 类,同步消息和异步消息。可以通过以下代码进行设置:

Message msg = Message.obtain();
msg.setAsynchronous(true);

一般情况下,Looper 从 MessageQueue 取消息时对这两者并不会做区别对待。但是如果 MessageQueue 设置了同步屏障就会出现差异。

当 MessageQueue 设置同步屏障之后,在 next 方法获取消息时会忽略所有的同步消息,只取异步消息,也就是说异步消息在此时的优先级更高。而 TraversalRunnable 会被封装到一个异步的 Message 中,因此 View 绘制的一系列操作会被优先执行,这也是提高渲染性能的一种手段。

再看 Systrace

掌握了基本的屏幕刷新原理,再来看 Systrace 的分析日志文件,就简单多了。再次看一下文章开头的 Systrace 内容:

Drawing 17.png

![在这里插入图片描述](https://img-blog.csdnimg.cn/ad613c7f0e5d49f385f6827095d3142b.png#pic_center)

很明显,在 Choreographer 调用 doFrame 的过程中,在处理 animation callback 的时候,执行了耗时的 inflate 和 decodeBitmap 方法,最终导致系统无法在 16ms 内完成一帧的绘制。再联系实际测试场景应该就能想到上述 trace 是在执行一系列 animation 的过程中抓取的,事实上也确实如此,我是在一个 ListView 的滑动过程中抓取的 trace 日志,再查看一下自定义 Adapter 的实现如下:

Drawing 18.png

很明显,问题的原因就是滑动过程中,每当有新的 Item 在界面上显示时,都会调用 inflate 创建 Layout,然后通过 findViewById 初始化,这种写法造成丢帧是不可避免的。

除了 animation 造成卡顿之外,还有以下几种常见的卡顿现象:

自定义 View 的 draw 方法耗时太长:

Drawing 19.png

自定义 View 的 measure 和 layout 方法耗时太长:

Drawing 20.png

decodeBitmap 转换图片耗时太长:

Drawing 21.png

总结

这节课从介绍 Systrace 的使用开始,要彻底掌握 Systrace 的使用,就必须理解系统中屏幕是如何刷新的。

  • 首先我们了解了 CPU 和 GPU 是如何协同工作,将 View 绘制的数据保存在缓存 Buffer 中;

  • 然后为了解决 Buffer 的读取并发问题,Android 引入了双缓冲机制;

  • 但是双缓冲机制也并不是完美的,因为软件层刷新是随机的,为此 Android 又引入了 vsync 机制,vsync 机制的实现主要依赖 Choreographer 来实现。

了解了这整个过程之后,再通过 Systrace 日志,就可以分析出具体是哪一部分耗费性能较高,并针对性地做出优化。


  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值