Android-自定义View播放Gif动画

前言

性能检测与分析,一直在 APP 开发中相当重要,但又被我们常常忽略。很多 APP 或者开发者总是急功近利,总想着快速充实 APP 的相关功能,把开发进度放在首位没有问题,可很多时候就没有然后了,这在整个开发界屡见不鲜。
在这样的大背景下,加之我之前写的 RxJava 2.0 系列 收到较好的反馈,诱导我想出这么一个性能优化系列。

一些你可能需要知道的

了解我的知道,我出的系列基本都是比较基础,而且侧重于常见必备。所以不一定全面,也不一定深入,所以希望理解。
性能优化大概可以概括为:

  • 性能优化说起来很简单,但做起来难。
  • 性能优化点多并且繁杂,需要耐心和经验。也许每个优化点很小,但积累起来会从量变变成质的飞跃。
  • 性能优化必须建立在产品设计之上,不能为了追求性能而忽略了产品设计。

提供一些资源 ( 你可能需要梯子 )

善用 ArrayMap

程序内存的管理是否合理高效对应用的性能有着很大的影响,有时候对容器的使用不当也会导致内存管理效率低下。想必已经有很多童鞋知道了 SparseArray,但它只是用于替代 List。
我们经常会在程序中用到 HashMap,它非常好用,这毋庸置疑。但它却非常耗内存。HashMap 的工作原理这里我们就不讲了,有兴趣的自行科普。

更高效的 ArrayMap 容器

为了解决 HashMap 更占内存的弊端,Android 提供了内存效率更高的 ArrayMap。它内部使用两个数组进行工作,其中一个数组记录 key hash 过后的顺序列表,另外一个数组按 key 的顺序记录 Key - Value 的值。如下图所示:

20171112151047249478577.png

当你想获取某个 Value 的时候,ArrayMap 会计算输入 key 转换过后的 hash 值,然后对 hash 数组使用二分查找法寻找到对应的 index,然后我们可以通过这个 index 在另外一个数组中直接访问到需要的键值对。如果在第二个数组键值对中的 key 和前面输入的查询 key 不一致,那么就认为是发生了碰撞冲突。为了解决这个问题,我们会以该 key 为中心点,分别上下展开,逐个去对比查找,直到找到匹配的值。如下图所示:

20171112151047254836245.png

随着数组中的对象越来越多,查找访问单个对象的花费也会跟着增长,这是在内存占用与访问时间之间做权衡交换。
既然ArrayMap中的内存占用是连续不间断的,那么它是如何处理插入与删除操作的呢?请看下图所示,演示了Array的特性:

20171112151047290144965.png

20171112151047306821392.png

很明显,ArrayMap 的插入与删除的效率是不够高的,但是如果数组的列表只是在一百这个数量级上,则完全不用担心这些插入与删除的效率问题。HashMap 与 ArrayMap 之间的内存占用效率对比图如下:

20171112151047311164769.png

HashMap 想必,ArrayMap 在循环遍历的时候也更加简单高效,因为其采用的是 fori 循环,而 HashMap 却使用的是糟糕的 Iterator

并不是所有情况下都用 ArrayMap

ArrayMap 这么优秀,但并不是所有情况下都适合使用 ArrayMap,我们应该在满足下面两个条件的时候才考虑使用 ArrayMap

  • 对象个数的数量级最好在 1000 以内。
  • 数据组织形式包含 Map 结构。

所以我们需要学会在特定清醒下选择相对更加高效的实现方式。

UI 性能确实很重要

UI 界面是整个 APP 性能的最前端展示,也是最容易看出性能问题的地方。可以毫不夸张地说,UI 性能的好坏直接影响这用户的体验和留存。UI 性能的目标是:

  • 减少绘图的等待时间。
  • 使帧率更加平稳、连贯。

UI 布局的核心原则

核心原则一句话,尽可能地减少 View 的数量!
我们可以通过 includemergeviewstub 进行布局复用,通过控件属性 DrawableLeft、DrawableRight 等方式进行控件整合。

听说我们要尽可能地用 RelativeLayout ?

想必一些资历较老的 Android 开发者都知道,一开始系统默认创建的布局是 LinearLayout,后面替换成了 RelativeLayout,现在又变成了 ConstraintLayout 。由于我对 ConstraintLayout 不是特别了解,所以这里我着重说说 RelativeLayout 和 LinearLayout 。
我不知道在什么时候听说过,如果我们能用 RelativeLayout 的地方,尽量不要用 LinearLayout
这种说法是不对的,实际开发中,决不能简单地说 RelativeLayout 和 LinearLayout 谁的性能更好,必须结合实际使用来进行分析。
基本可以总结为:一般情况下,如果使用 LinearLayout,则一定要保证层级不能太深;如果使用 RelativeLayout,则需要尽量避免嵌套。

正文

提高 APP 的启动速度对我们意义深远,很显然,APP 的启动时间越短,使用它的用户越有耐心等待打开这个 APP 进行使用。反之,启动时间太长,用户则有可能还没等到 APP 打开就已经切换到其他 APP 了。

程序启动过程中那些复杂错误的操作很有可能导致严重的性能问题。Android 系统会根据用户的操作行为调整程序的显示策略,用来提高程序的显示性能。例如,一旦用户点击桌面图标,Android 系统会立即显示一个启动窗口,这个窗口会一直保持显示直到画面中的元素成功加载并绘制完第一帧。这种行为常见于程序的冷启动,或者程序的热启动场景(程序从后台被唤起或者从其他 APP 界面切换回来)。

那么关键的问题是,用户很可能会因为从启动窗口到显示画面的过程耗时过长而感到厌烦,从而导致用户没有来得及等程序启动完毕就切换到其他 APP 了。更严重的是,如果启动时间过长,可能导致程序出现 ANR。我们应该避免出现这两种糟糕的情况。

从技术角度来说,当用户点击桌面图标开始,系统会立即为这个 APP 创建独立的专属进程,然后显示启动窗口,直到 APP 在自己的进程里面完成了程序的创建以及主线程完成了 Activity 的初始化显示操作,再然后系统进程就会把启动窗口替换成 APP 的显示窗口。

20171112151047382978489.png

上述流程里面的绝大多数步骤都是由系统控制的,一般来说不会出现什么问题,可是对于启动速度,我们能够控制并且需要特别关注的地方主要有三处:

  • Activity 的 onCreate 流程,特别是UI的布局与渲染操作,如果布局过于复杂很可能导致严重的启动性能问题。
  • Application 的 onCreate 流程,对于大型的 APP 来说,通常会在这里做大量的通用组件的初始化操作。
  • 目前有部分 APP 会提供自定义的启动窗口,这里可以做成品牌宣传界面或者是给用户提供一种程序已经启动的视觉效果。

在正式着手解决问题之前,我们需要掌握一套正确测量评估启动性能的方法。所幸的是,Android 系统有提供一些工具来帮助我们定位问题。

有趣的启动时长定位

display time

从 Android KitKat 版本开始,Logcat 中会输出从程序启动到某个 Activity 显示到画面上所花费的时间。这个方法比较适合测量程序的启动时间。

2017111215104739066103.png

reportFullyDrawn()

我们通常来说会使用异步懒加载的方式来提升程序画面的显示速度,这通常会导致的一个问题是,程序画面已经显示,可是内容却还在加载中。为了衡量这些异步加载资源所耗费的时间,我们可以在异步加载完毕之后调用 activity.reportFullyDrawn() 方法来告诉系统此时的状态,以便获取整个加载的耗时。

20171112151047399580505.png

Method Tracing

前面两个方法提供了启动耗时的总时间,可是却无法提供具体的耗时细节。为了获取具体的耗时分布情况,我们可以使用 Method Tracing 工具来进行详细的测量。

20171112151047401087421.png

Systrace

我们可以在 onCreate() 方法里面添加 trace.beginSection()trace.endSection() 方法来声明需要跟踪的起止位置,系统会帮忙统计中间经历过的函数调用耗时,并输出报表。

2017111215104740573026.png

如果优化 APP 启动速度?

提升 Activity 的创建速度

提升 Activity 的创建速度是优化 APP 启动速度的首要关注目标。从桌面点击 APP 图标启动应用开始,程序会显示一个启动窗口等待 Activity 的创建加载完毕再进行显示。在 Activity 的创建加载过程中,会执行很多的操作,例如设置页面的主题,初始化页面的布局,加载图片,获取网络数据,读写 Preference 等等。

20171112151047407664811.png

上述操作的任何一个环节出现性能问题都可能导致画面不能及时显示,影响了程序的启动速度。上一个段落我们介绍了使用 Method Tracing 来发现那些耗时占比相对较多的方法。假设我们发现某个方法执行时间过长,接下去就可以使用 Systrace 来帮忙定位到底是什么原因导致那个方法执行时间过长。

除了使用工具进行具体定位分析性能问题之外,以下两点经验可以帮助我们对 Activity 启动做性能优化:

  • 优化布局耗时:一个布局层级越深,里面包含需要加载的元素越多,就会耗费更多的初始化时间。关于布局性能的优化,这里就不展开描述了!
  • 异步延迟加载:一开始只初始化最需要的布局,异步加载图片,非立即需要的组件可以做延迟加载。

别让 Application 初始化不必要的东西

在 Application 初始化的地方做太多繁重的事情是可能导致严重启动性能问题的元凶之一。Application 里面的初始化操作不结束,其他任意的程序操作都无法进行。

有时候,我们会一股脑的把绝大多数全局组件的初始化操作都放在 Application 的 onCreate() 里面,但其实很多组件是需要做区队对待的,有些可以做延迟加载,有些可以放到其他的地方做初始化操作,特别需要留意包含 Disk IO 操作,网络访问等严重耗时的任务,他们会严重阻塞程序的启动。

20171112151047411112263.png

优化这些问题的解决方案是做延迟加载,可以在 Application 里面做延迟加载,也可以把一些初始化的操作延迟到组件真正被调用到的时候再做加载。

20171112151047412041394.png

恰当地使用闪屏

启动闪屏不仅仅可以作为品牌宣传页,还能够减轻用户对启动耗时的感知,但是如果使用不恰当,将适得其反。前面介绍过当点击桌面图标启动 APP 的时候,程序会显示一个启动窗口,一直到页面的渲染加载完毕。如果程序的启动速度足够快,我们看的闪屏窗口停留显示的时间则会很短,但是当程序启动速度偏慢的时候,这个启动闪屏可以一定程度上减轻用户等待的焦虑感,避免用户过于轻易的关闭应用。

目前大多数开发者都会通过设置启动窗口主题的方式来替换系统默认的启动窗口,通过这种方式只是使用『障眼法』弱化了用户对启动时间的感知,但本质上并没有对启动速度做什么优化。也有些 APP 通过关闭启动窗口属性 android:windowDisablePreview 的方式来直接移除系统默认的启动窗口,但是这样的弊端是用户从点击桌面图标到真的看到实际页面的这段时间当中,画面没有任何变化,这样的用户体验是十分糟糕的!

20171112151047413813045.png

20171112151047415075230.png

对于启动闪屏,正确的使用方法是自定义一张图片,把这张图片通过设置主题的方式显示为启动闪屏,代码执行到主页面的 onCreate() 的时候设置为程序正常的主题。

前言

内存泄漏从来都是我们老生常谈的话题,无论是 Android Studio 自带的内存泄漏分析工具还是专业的 Eclipse MAT 抑或是备受青睐的第三方插件 LeakCanary,都为我们的内存泄漏检测提供了便利。如果从根源上解决内存泄漏,内存优化必不可少。所以本章节我们参考扔物线胡凯的内存优化策略,直接拿出一章节来谈内存优化。

内存优化基本可以分为下面几个方面

  • 减少对象的内存占用
  • 对内存对象进行复用
  • 避免对象的内存泄漏
  • 内存使用策略优化

减少对象的内存占用

避免在 Android 里面使用 Enum

Enum 是 Java 中包含固定常量的数据类型,当需要知道预先定制的几个值,这几个值表示一些数据类,我们都可以使用 Enum。我们一般用 Enum 做一些编译时检查,以避免传入不合法的参数。

但 Enum 的每个对象都是 Object,在 Android 官网上就早已明确指出应该在 Android 开发中避免使用 Enum,因为与静态常量想必,它对内存的占用是要大很多的。

因此在实际开发中,我更加倾向于接口变量,因为接口会自动把成员变量设置为 static 和 final 的,这一点可以防止某些情况下错误地添加新的常量,这也使得代码看起来更加简单和清晰。

使用更加轻量的数据结构

前面第一节已经说过,我们应该更加倾向于考虑使用 ArrayMapSparseArray 而不是 HashMap 等传统数据结果,前面已经用图示演示了 HashMap 的简要工作原理,相比起 Android 系统专门为移动操作系统编写的 ArrayMap 容器,在大多数情况下,都显示效率低下,更占内存。通常的 HashMap 的实现方式更加消耗内存,因为它需要一个额外的实例对象来记录 Mapping 操作。另外,SparseArray 更加高效在于他们避免了对 keyvalueautobox 自动装箱,并且避免了装箱后的解箱。

使用更小的图片

在设计给到资源图片的时候,我们需要特别留意这张图片是否存在可以压缩的空间,是否可以使用一张更小的图片。尽量使用更小的图片不仅仅可以减少内存的使用,还可以避免出现大量的 InflationException。假设有一张很大的图片被 XML 文件直接引用,很有可能在初始化视图的时候就会因为内存不足而发生 InflationException,这个问题的根本原因其实是发生了 OOM。

减少 Bitmap 对象的内存占用

Bitmap是一个极容易消耗内存的大胖子,减小创建出来的Bitmap的内存占用是很重要的,通常来说有下面2个措施:

  • inSampleSize:缩放比例,在把图片载入内存之前,我们需要先计算出一个合适的缩放比例,避免不必要的大图载入。
  • decode format:解码格式,选择 ARGB_8888 / RBG_565 / ARGB_4444 / ALPHA_8,存在很大差异。

尽量地采用 int 类型

Android 系统中 float 类型的数据存取速度是 int 类型的一半,尽量优先采用 int 类型。而同样能作为整数的代名词,采用 int 替换 Integer 会让你的内存开销更小。

对内存对象进行复用

复用系统自带的资源

Android 系统本身内置了很多的资源,例如字符串 / 颜色 / 图片 / 动画 / 样式以及简单布局等等,这些资源都可以在应用程序中直接引用。这样做不仅仅可以减少应用程序的自身负重,减小 APK 的大小,另外还可以一定程度上减少内存的开销,复用性更好。但是也有必要留意 Android 系统的版本差异性,对那些不同系统版本上表现存在很大差异,不符合需求的情况,还是需要应用程序自身内置进去。

注意 ListView / GridView 的 Adapter 对 ConvertView 进行复用

这个貌似没啥好说的,太基础了,而且我们可能现在更加青睐于 RecyclerView。

尽量的采用 StringBuilder

这个也特别基础,我们点到为止。大概就是尽量的采用 StringBuilder / StringBuffer 来替换我们频繁的字符串拼接。

尽量使用原字符串的 subString

当从已经存在的数据集中抽取出 String 的时候,尝试返回原数据的 subString 对象,而不要创建一个重复的对象。

避免在 onDraw() 里面执行对象的创建

类似 onDraw() 等频繁调用的方法,一定需要注意避免在这里做创建对象的操作,因为他会迅速增加内存的使用,而且很容易引起频繁的 gc,甚至是内存抖动。

避免对象的内存泄漏

内存对象的泄漏,会导致一些不再使用的对象无法及时释放,这样一方面占用了宝贵的内存空间,很容易导致后续需要分配内存的时候,空闲空间不足而出现 OOM。显然,这还使得每级 Generation 的内存区域可用空间变小,gc 就会更容易被触发,容易出现内存抖动,从而引起性能问题。

注意 Activity 的泄漏

通常来说,Activity 的泄漏是内存泄漏里面最严重的问题,它占用的内存多,影响面广,我们需要特别注意以下两种情况导致的 Activity 泄漏:

  • 内部类引用导致 Activity 的泄漏
    最典型的场景是 Handler 导致的 Activity 泄漏,如果 Handler 中有延迟的任务或者是等待执行的任务队列过长,都有可能因为 Handler 继续执行而导致 Activity 发生泄漏。此时的引用关系链是 Looper -> MessageQueue -> Message -> Handler -> Activity。为了解决这个问题,可以在 UI 退出之前,执行 remove Handler 消息队列中的消息与 runnable 对象。或者是使用 Static + WeakReference 的方式来达到断开 Handler 与 Activity 之间存在引用关系的目的。

  • Activity Context 被传递到其他实例中,这可能导致自身被引用而发生泄漏。
    内部类引起的泄漏不仅仅会发生在 Activity 上,其他任何内部类出现的地方,都需要特别留意!我们可以考虑尽量使用 static 类型的内部类,同时使用 WeakReference 的机制来避免因为互相引用而出现的泄露。

尽量地采用 Application Context

对于大部分非必须使用 Activity Context 的情况(Dialog 的 Context 就必须是Activity Context),我们都可以考虑使用 Application Context 而不是 Activity 的 Context,这样可以避免不经意的 Activity 泄露。

而且如果习惯 Glide 的童鞋可能会发现,Glide 需要传递的 Context 如果是 Activity 的 Context ,那么在 Activity 被销毁后还没加载出来的话还会引发崩溃。所以,请在使用 Glide 或者 Toast 等的时候,直接传递 Application Context 吧。

注意 Cursor 对象是否及时关闭

在程序中我们经常会进行查询数据库的操作,但时常会存在不小心使用 Cursor 之后没有及时关闭的情况。这些 Cursor 的泄露,反复多次出现的话会对内存管理产生很大的负面影响,我们需要谨记对 Cursor 对象的及时关闭。

注意 WebView 的泄漏

Android中 的 WebView 存在很大的兼容性问题,不仅仅是 Android 系统版本的不同对 WebView 产生很大的差异,另外不同的厂商出货的 ROM 里面 WebView 也存在着很大的差异。更严重的是标准的 WebView 存在内存泄露的问题,看这里。所以通常根治这个问题的办法是为 WebView 开启另外一个进程,通过 AIDL 与主进程进行通信,WebView 所在的进程可以根据业务的需要选择合适的时机进行销毁,从而达到内存的完整释放。

注意临时 Bitmap 对象的及时回收

虽然在大多数情况下,我们会对 Bitmap 增加缓存机制,但是在某些时候,部分 Bitmap 是需要及时回收的。例如临时创建的某个相对比较大的 Bitmap 对象,在经过变换得到新的 Bitmap 对象之后,应该尽快回收原始的 Bitmap,这样能够更快释放原始 Bitmap 所占用的空间。

需要特别留意的是 Bitmap 类里面提供的 createBitmap() 方法:

20171112151047477494165.png

这个函数返回的 Bitmap 有可能和 source bitmap 是同一个,在回收的时候,需要特别检查 source bitmap 与 return bitmap 的引用是否相同,只有在不等的情况下,才能够执行 source bitmap 的 recycle() 方法。

注意监听器的注销

在 Android 程序里面存在很多需要 register 与 unregister 的监听器,我们需要确保在合适的时候及时 unregister 那些监听器。自己手动 add 的 listener,需要记得及时 remove 这个 listener。

内存使用策略优化

谨慎使用 large heap

Android 设备根据硬件与软件的设置差异而存在不同大小的内存空间,他们为应用程序设置了不同大小的 Heap 限制阈值。你可以通过调用 getMemoryClass() 来获取应用的可用 Heap 大小。在一些特殊的情景下,你可以通过在 manifest 的 application 标签下添加 largeHeap = true 的属性来为应用声明一个更大的 heap 空间。然后,你可以通过 getLargeMemoryClass() 来获取到这个更大的 heap size 阈值。然而,声明得到更大 Heap 阈值的本意是为了一小部分会消耗大量 RAM 的应用 ( 例如一个大图片的编辑应用 ) 。不要轻易的因为你需要使用更多的内存而去请求一个大的 Heap Size。只有当你清楚的知道哪里会使用大量的内存并且知道为什么这些内存必须被保留时才去使用 large heap。因此请谨慎使用 large heap 属性。使用额外的内存空间会影响系统整体的用户体验,并且会使得每次 gc 的运行时间更长。在任务切换时,系统的性能会大打折扣。另外, large heap 并不一定能够获取到更大的 heap。在某些有严格限制的机器上,large heap 的大小和通常的 heap size 是一样的。因此即使你申请了 large heap,你还是应该通过执行 getMemoryClass() 来检查实际获取到的 heap 大小。

资源文件需要选择合适的文件夹进行存放

我们知道 hdpi / xhdpi / xxhdpi 等等不同 dpi 的文件夹下的图片在不同的设备上会经过 scale 的处理。例如我们只在 hdpi 的目录下放置了一张 100 x 100 的图片,那么根据换算关系,xxhdpi 的手机去引用那张图片就会被拉伸到 200 x 200。需要注意到在这种情况下,内存占用是会显著提高的。对于不希望被拉伸的图片,需要放到 assets 或者 nodpi 的目录下。

Try catch某些大内存分配的操作

在某些情况下,我们需要事先评估那些可能发生 OOM 的代码,对于这些可能发生 OOM 的代码,加入 catch 机制,可以考虑在 catch 里面尝试一次降级的内存分配操作。例如 decode bitmap 的时候,catch 到 OOM,可以尝试把采样比例再增加一倍之后,再次尝试 decode。

谨慎使用 static 对象

因为 static 的生命周期过长,和应用的进程保持一致,使用不当很可能导致对象泄漏,在 Android 中应该谨慎使用 static 对象。

特别留意单例对象中不合理的持有

虽然单例模式简单实用,提供了很多便利性,但是因为单例的生命周期和应用保持一致,使用不合理很容易出现持有对象的泄漏。特别是持有 Context 的引用,需要谨慎对待。

优化布局层次,减少内存消耗

越扁平化的视图布局,占用的内存就越少,效率越高。我们需要尽量保证布局足够扁平化,当使用系统提供的 View 无法实现足够扁平的时候考虑使用自定义 View 来达到目的。

谨慎使用多进程

使用多进程可以把应用中的部分组件运行在单独的进程当中,这样可以扩大应用的内存占用范围,但是这个技术必须谨慎使用,绝大多数应用都不应该贸然使用多进程,一方面是因为使用多进程会使得代码逻辑更加复杂,另外如果使用不当,它可能反而会导致显著增加内存。当你的应用需要运行一个常驻后台的任务,而且这个任务并不轻量,可以考虑使用这个技术。

一个典型的例子是创建一个可以长时间后台播放的 Music Player。如果整个应用都运行在一个进程中,当后台播放的时候,前台的那些 UI 资源也没有办法得到释放。类似这样的应用可以切分成 2 个进程:一个用来操作 UI,另外一个给后台的 Service。

写在最后

内存优化并不就是说程序占用的内存越少就越好,如果因为想要保持更低的内存占用,而频繁触发执行 gc 操作,在某种程度上反而会导致应用性能整体有所下降,这里需要综合考虑做一定的权衡。