第17讲:Android OkHttp 全面详解
OkHttp 是一套处理 HTTP 网络请求的依赖库,由 Square 公司设计研发并开源,目前可以在 Java 和 Kotlin 中使用。对于 Android App 来说,OkHttp 现在几乎已经占据了所有的网络请求操作,RetroFit + OkHttp 实现网络请求似乎成了一种标配。因此它也是每一个 Android 开发工程师的必备技能,了解其内部实现原理可以更好地进行功能扩展、封装以及优化。
因为是 HTTP 网络请求的依赖库,所以需要有一定的网络知识基础,这里我推荐一本入门级的书籍《网络是怎样连接的》(由日本作者户根勤所著)。这本书对入门级开发人员极为友好,能够快速帮你梳理从前端到服务器间的整套网络流程。
网络请求流程分析
在 2016 年我写过几篇对 OkHttp 深入分析的文章:OkHttp的整个异步请求流程。但是几年过去了,OkHttp 经过几次迭代后,已经发生了很多变化。更好的 WebSocket 支持、更多的 Interceptor 责任链,甚至连最核心的 HttpEngine 也变成了 HttpCodec。本课时会在最新的 3.10.0 的基础上,重新梳理整个网络请求的流程,以及实现机制。
先看下 OkHttp 的基本使用:
除了直接 new OkHttpClient 之外,还可以使用内部工厂类 Builder 来设置 OkHttpClient。如下所示:
请求操作的起点从 OkHttpClient.newCall().enqueue() 方法开始:
- newCall
这个方法会返回一个 RealCall 类型的对象,通过它将网络请求操作添加到请求队列中。
- RealCall.enqueue
调用 Dispatcher 的入队方法,执行一个异步网络请求的操作。
可以看出,最终请求操作是委托给 Dispatcher的enqueue 方法内实现的。
Dispatcher 是 OkHttpClient 的调度器,是一种门户模式。主要用来实现执行、取消异步请求操作。本质上是内部维护了一个线程池去执行异步操作,并且在 Dispatcher 内部根据一定的策略,保证最大并发个数、同一 host 主机允许执行请求的线程个数等。
Dispatcher的enqueue 方法的具体实现如下:
可以看出,实际上就是使用线程池执行了一个 AsyncCall,而 AsyncCall 实现了 Runnable 接口,因此整个操作会在一个子线程(非 UI 线程)中执行。
继续查看 AsyncCall 中的 run 方法如下:
在 run 方法中执行了另一个 execute 方法,而真正获取请求结果的方法是在 getResponseWithInterceptorChain 方法中,从名字也能看出其内部是一个拦截器的调用链,具体代码如下:
每一个拦截器的作用如下。
- BridgeInterceptor:主要对 Request 中的 Head 设置默认值,比如 Content-Type、Keep-Alive、Cookie 等。
- CacheInterceptor:负责 HTTP 请求的缓存处理。
- ConnectInterceptor:负责建立与服务器地址之间的连接,也就是 TCP 链接。
- CallServerInterceptor:负责向服务器发送请求,并从服务器拿到远端数据结果。
在添加上述几个拦截器之前,会调用 client.interceptors 将开发人员设置的拦截器添加到列表当中。
对于 Request 的 Head 以及 TCP 链接,我们能控制修改的成分不是很多。所以本课时我重点讲解 CacheInterceptor 和 CallServerInterceptor。
CacheInterceptor 缓存拦截器
CacheInterceptor 主要做以下几件事情:
a. 根据 Request 获取当前已有缓存的 Response(有可能为 null),并根据获取到的缓存 Response,创建 CacheStrategy 对象。
b. 通过 CacheStrategy 判断当前缓存中的 Response 是否有效(比如是否过期),如果缓存 Response 可用则直接返回,否则调用 chain.proceed() 继续执行下一个拦截器,也就是发送网络请求从服务器获取远端 Response。具体如下:
c. 如果从服务器端成功获取 Response,再判断是否将此 Response 进行缓存操作。
通过 Cache 实现缓存功能
通过上面分析缓存拦截器的流程可以看出,OkHttp 只是规范了一套缓存策略,但是具体使用何种方式将数据缓存到本地,以及如何从本地缓存中取出数据,都是由开发人员自己定义并实现,并通过 OkHttpClient.Builder 的 cache 方法设置。
OkHttp 提供了一个默认的缓存类 Cache.java,我们可以在构建 OkHttpClient 时,直接使用 Cache 来实现缓存功能。只需要指定缓存的路径,以及最大可用空间即可,如下所示:
上述代码使用 Android app 内置 cache 目录作为缓存路径,并设置缓存可用最大空间为 20M。实际上在 Cache 内部使用了 DiskLruCach 来实现具体的缓存功能,如下所示:
DiskLruCache 最终会以 journal 类型文件将需要缓存的数据保存在本地。如果感觉 OkHttp 自带的这套缓存策略太过复杂,我们可以设置使用 DiskLruCache 自己实现缓存机制。
CallServerInterceptor 详解
CallServerInterceptor 是 OkHttp 中最后一个拦截器,也是 OkHttp 中最核心的网路请求部分,其 intercept 方法如下:
如上图所示,主要分为 2 部分。蓝线以上的操作是向服务器端发送请求数据,蓝线以下代表从服务端获取相应数据并构建 Response 对象。
OkHttp 使用扩展
仔细看刚才 CallServerInterceptor 中的 intercept 方法,可以发现在向服务端发送数据以及获取数据都是使用一个 Okio 的框架来实现的。Okio 是 Square 公司打造的另外一个轻量级 IO 库,它是 OkHttp 框架的基石。
我们在构建 Response 时,需要调动 body() 方法传入一个 ResponseBody 对象。ResponseBody 内部封装了对请求结果的流读取操作。我们可以通过继承并扩展 ResponseBody 的方式获取网络请求的进度。
a. 继承 ResponseBody
其中 progressListener 是一个自定义的进度监听器,通过它向上层汇报网络请求的进度。
b. 自定义 ProgressBarClient
代码如下:
解释说明:
getClient 可以根据项目的不同添加其他共通设置,比如 timeout 时间、DNS、Log 日志 interceptor 等。
getProgressBarClient 通过添加一个拦截器,并且在 intercept 方法中将自定义的 ProgressResponseBody 传给 body 方法。
当通过 getProgressBarClient 发送网络请求时,OkHttpClient 从服务端获取到数据之后,会不断调用 ProgressResponseBody 中的 source 方法,然后通过 ProgressListener 向上层通知请求进度的结果。
c. 实践拓展 — Picasso
我们甚至可以将上面自定义的 ProgressBarClient 用在 Square 公司另外一个请求库—Picasso。Picasso 是 Square 公司研发用来从网络端获取图片数据的依赖库,内部实质上是使用 OkHttp 来实现请求操作的。因此我们可以将 ProgressBarClient 替换 Picasso 中的 OkHttpClient,这样就能获取下载图片的进度,代码如下:
后续只要通过 getPicasso 方法即可获得一个自带下载进度的 Picasso 对象,因为 OkHttp、Picasso、Okio 都来自 Square 公司,所以我将这些工具栏的 get 方法放在一个 SquareUtils 类中,我已经上传到代码仓库中,详细代码可以查看:SquareUtils.java
如果结合第 15 课时用过的 PieImageView,我们就可以实现一个带进度提示的图片下载效果,代码如下:
最终运行效果如下:
总结
这节课主要分析了 OkHttp 的源码实现:
- 首先 OkHttp 内部是一个门户模式,所有的下发工作都是通过一个门户 Dispatcher 来进行分发。
- 然后在网络请求阶段通过责任链模式,链式的调用各个拦截器的 intercept 方法。其中我重点介绍了 2 个比较重要的拦截器:CacheInterceptor 和 CallServerInterceptor。它们分别用来做请求缓存和执行网络请求操作。
- 最后我们在理解源码实现的基础上,对 OkHttp 的功能进行了一些扩展,实现了网络请求进度的实现。
第18讲:Android Bitmap 全面详解
本课时我们主要对 Bitmap 进行详解。
每一个 Android App 中都会使用到 Bitmap,它也是程序中内存消耗的大户,当 Bitmap 使用内存超过可用空间,则会报 OOM。 因此如何正确使用也是 Android 工程师的重点关注内容。
Bitmap 占用内存分析
Bitmap 用来描述一张图片的长、宽、颜色等信息。通常情况下,我们可以使用 BitmapFactory 来将某一路径下的图片解析为 Bitmap 对象。
当一张图片加载到内存后,具体需要占用多大内存呢?
getAllocationByteCount 探索
我们可以通过 Bitmap.getAllocationByteCount() 方法获取 Bitmap 占用的字节大小,比如以下代码:
上图中 rodman 是保存在 res/drawable-xhdpi 目录下的一张 600*600,大小为 65Kb 的图片。打印结果如下:
I/Bitmap ( 5673): bitmap size is 1440000
解释
默认情况下 BitmapFactory 使用 Bitmap.Config.ARGB_8888 的存储方式来加载图片内容,而在这种存储模式下,每一个像素需要占用 4 个字节。因此上面图片 rodman 的内存大小可以使用如下公式来计算:
宽 * 高 * 4 = 600 * 600 * 4 = 1440000
屏幕自适应
但是如果我们在保证代码不修改的前提下,将图片 rodman 移动到(注意是移动,不是拷贝)res/drawable-hdpi 目录下,重新运行代码,则打印日志如下:
I/Bitmap ( 6047): bitmap size is 2560000
可以看出我们只是移动了图片的位置,Bitmap 所占用的空间竟然上涨了 77%。这是为什么呢?
实际上 BitmapFactory 在解析图片的过程中,会根据当前设备屏幕密度和图片所在的 drawable 目录来做一个对比,根据这个对比值进行缩放操作。具体公式为如下所示:
- 缩放比例 scale = 当前设备屏幕密度 / 图片所在 drawable 目录对应屏幕密度
- Bitmap 实际大小 = 宽 * scale * 高 * scale * Config 对应存储像素数
在 Android 中,各个 drawable 目录对应的屏幕密度分别为下:
我运行的设备是 Nexus 4,屏幕密度为 320。如果将 rodman 放到 drawable-hdpi 目录下,最终的计算公式如下:
rodman 实际占用内存大小 = 600 * (320 / 240) * 600 * (320 / 240) * 4 = 2560000
assets 中的图片大小
我们知道,Android 中的图片不仅可以保存在 drawable 目录中,还可以保存在 assets 目录下,然后通过 AssetManager 获取图片的输入流。那这种方式加载生成的 Bitmap 是多大呢?同样是上面的 rodman.png,这次将它放到 assets 目录中,使用如下代码加载:
最终打印结果如下:
I/Bitmap ( 5673): bitmap size is 1440000
可以看出,加载 assets 目录中的图片,系统并不会对其进行缩放操作。
Bitmap 加载优化
上面的例子也能看出,一张 65Kb 大小的图片被加载到内存后,竟然占用了 2560000 个字节,也就是 2.5M 左右。因此适当时候,我们需要对需要加载的图片进行缩略优化。
修改图片加载的 Config
修改占用空间少的存储方式可以快速有效降低图片占用内存。比如通过 BitmapFactory.Options 的 inPreferredConfig 选项,将存储方式设置为 Bitmap.Config.RGB_565。这种存储方式一个像素占用 2 个字节,所以最终占用内存直接减半。如下:
打印日志如下:
I/Bitmap ( 6339): bitmap size is 720000
另外 Options 中还有一个 inSampleSize 参数,可以实现 Bitmap 采样压缩,这个参数的含义是宽高维度上每隔 inSampleSize 个像素进行一次采集。比如以下代码:
因为宽高都会进行采样,所以最终图片会被缩略 4 倍,最终打印效果如下:
I/Bitmap ( 6414): bitmap size is 180000 // 170Kb
Bitmap 复用
场景描述
如果在 Android 某个页面创建很多个 Bitmap,比如有两张图片 A 和 B,通过点击某一按钮需要在 ImageView 上切换显示这两张图片,实现效果如下所示:
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/9c440d0748457443abf938e8bf8fdfed.gif#pic_center)可以使用以下代码实现上述效果:
但是在每次调用 switchImage 切换图片时,都需要通过 BitmapFactory 创建一个新的 Bitmap 对象。当方法执行完毕后,这个 Bitmap 又会被 GC 回收,这就造成不断地创建和销毁比较大的内存对象,从而导致频繁 GC(或者叫内存抖动)。像 Android App 这种面相最终用户交互的产品,如果因为频繁的 GC 造成 UI 界面卡顿,还是会影响到用户体验的。可以在 Android Studio Profiler 中查看内存情况,多次切换图片后,显示的效果如下:
使用 Options.inBitmap 优化
实际上经过第一次显示之后,内存中已经存在了一个 Bitmap 对象。每次切换图片只是显示的内容不一样,我们可以重复利用已经占用内存的 Bitmap 空间,具体做法就是使用 Options.inBitmap 参数。将 getBitmap 方法修改如下:
解释说明:
- 图中 1 处创建一个可以用来复用的 Bitmap 对象。
- 图中 2 处,将 options.inBitmap 赋值为之前创建的 reuseBitmap 对象,从而避免重新分配内存。
重新运行代码,并查看 Profiler 中的内存情况,可以发现不管我们切换图片多少次,内存占用始终处于一个水平线状态。
注意:在上述 getBitmap 方法中,复用 inBitmap 之前,需要调用 canUseForInBitmap 方法来判断 reuseBitmap 是否可以被复用。这是因为 Bitmap 的复用有一定的限制:
- 在 Android 4.4 版本之前,只能重用相同大小的 Bitmap 内存区域;
- 4.4 之后你可以重用任何 Bitmap 的内存区域,只要这块内存比将要分配内存的 bitmap 大就可以。
canUserForInBitmap 方法具体如下:
细心的你可能也发现了在每次加载之前,除了 inBitmap 参数之外,我还将 Options.inMutable 置为 true,这里如果不置为 true 的话,BitmapFactory 将不会重复利用 Bitmap 内存,并输出相应 warning 日志:
W/BitmapFactory: Unable to reuse an immutable bitmap as an image decoder target.
完整的代码可以查看:拉勾 AndroidBitmap
BitmapRegionDecoder 图片分片显示
有时候我们想要加载显示的图片很大或者很长,比如手机滚动截图功能生成的图片。
针对这种情况,在不压缩图片的前提下,不建议一次性将整张图加载到内存,而是采用分片加载的方式来显示图片部分内容,然后根据手势操作,放大缩小或者移动图片显示区域。
图片分片加载显示主要是使用 Android SDK 中的 BitmapRegionDecoder 来实现。用下面这张图rodman3.png 举例:
BitmapRegionDecoder 基本使用
首先需要使用 BitmapRegionDecoder 将图片加载到内存中,图片可以以绝对路径、文件描述符、输入流的方式传递给 BitmapRegionDecoder,如下所示:
运行后显示效果如下:
在此基础上,我们可以通过自定义View,添加 touch 事件来动态地设置 Bitmap 需要显示的区域 Rect。具体实现网上已经有很多成熟的轮子可以直接使用,比如 LargeImageView 。张鸿洋先生也有一篇比较详细文章对此介绍:Android 高清加载巨图方案。
Bitmap 缓存
当需要在界面上同时展示一大堆图片的时候,比如 ListView、RecyclerView 等,由于用户不断地上下滑动,某个 Bitmap 可能会被短时间内加载并销毁多次。这种情况下通过使用适当的缓存,可以有效地减缓 GC 频率保证图片加载效率,提高界面的响应速度和流畅性。
最常用的缓存方式就是 LruCache,基本使用方式如下:
解释说明:
- 图中 1 处指定 LruCache 的最大空间为 20M,当超过 20M 时,LruCache 会根据内部缓存策略将多余 Bitmap 移除。
- 图中 2 处指定了插入 Bitmap 时的大小,当我们向 LruCache 中插入数据时,LruCache 并不知道每一个对象会占用大多内存,因此需要我们手动指定,并且根据缓存数据的类型不同也会有不同的计算方式。
总结:
这节课详细介绍了 Bitmap 开发中的几个常见问题:
- 一张图片被加载成 Bitmap 后实际占用内存是多大。
- 通过 Options.inBitmap 可以实现 Bitmap 的复用,但是有一定的限制。
- 当界面需要展示多张图片,尤其是在列表视图中,可以考虑使用 Bitmap 缓存。
- 如果需要展示的图片过大,可以考虑使用分片加载的策略