Android客户端HTTP网络框架设计与实践

不管是android、ios还是浏览器端的开发,在正常的产品迭代过程中HTTP网络请求都是高频使用的功能。以android端为例,在使用常见的http网络框架时,如HttpUrlConnection,HttpClient或者okHttp ,开发者都必须在此自身业务场景的基础上进行api的二次封装。一个功能强大且易用的网络框架不仅仅能够提高开发效率,起到事半功倍的效果,还能起到规范业务开发结果的作用。
希望通过这篇文章,总结下自己在设计和实现一个网络框架时的思考过程,也帮助团队同学了解现有网络框架的能力和不足。

0x02 使用者的视角

2.1 使用volley

发送Http get请求的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Instantiate the RequestQueue.
RequestQueue queue = Volley.newRequestQueue(this);
String url ="http://www.google.com";

// Request a string response from the provided URL.
StringRequest stringRequest = new StringRequest(Request.Method.GET, url,
new Response.Listener<String>() {
@Override
public void onResponse(String response) {
// Display the first 500 characters of the response string.
mTextView.setText("Response is: "+ response.substring(0,500));
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
mTextView.setText("That didn't work!");
}
});

// Add the request to the RequestQueue.
queue.add(stringRequest);

查看以上Volley使用的官方示例,可以将其划分为以下几个使用步骤:

  1. 构建一个请求队列
  2. 构造一个请求Request对象和接收请求结果的Response.Listener
  3. 将请求Request添加到请求队列中

通过volley的使用方法你就可以大致猜测volley在完成一个网络请求的大致过程。开发者使用请求相关信息和接收返回结果的Callback封装成一个Request,并将其放在请求队列中,在请求队列的背后一定有负责真正网络请求任务的线程从队列中消费网络请求的Request,在获取网络请求结果后通过线程间消息机制将网络请求的结果在主线程返回给接受消息的Response.Listener。当然真正的过程肯定会更复杂。但是不管怎样,volley的设计思路是一个经典的生产者消费者模式。
从一个Volley api使用者的视角回头再看一下volley,你是否有这些疑问?

  • 在一个开发者的使用过程,他需要知道请求队列的存在吗?除非他想要改变请求的优先级规则或者有其他想要改变请求在请求队列中顺序的需求。
  • 构造Request的方式不友好。以Get请求为例,需要自己手动将请求参数拼接到请求url中。如果是POST请求情况还会更复杂。
  • 接受请求结果的方式不友好,指定Response.Listener对象接收请求让代码不够美观不说,更建立了请求者和请求框架之间的强耦合关系。以上面的示例代码为例,如果这段代码写在Activity中,构造的Response.Listener的匿名内部类存在当前Activity的隐式引用,很容易引起不必要的内存泄露。如果这段代码不在Activity中,那还要多一层数据的轮转机制。简直是开发者的噩梦。
  • 针对失败的回调处理并不科学。在实际的业务开发中,你可能需要针对某几类返回数据做异常处理,虽然他们整个Http请求的网络过程是正常的,但是针对这些返回的结果把它看成异常流。如果希望Volley在这一点达到你的要求你还需要多一层封装。

2.2 使用okhttp

发送Http post请求的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//创建网络处理的对象
OkHttpClient client = new OkHttpClient.Builder()
.readTimeout(5, TimeUnit.SECONDS)
.build();

//post请求来获得数据
//创建一个RequestBody,存放重要数据的键值对
RequestBody body = new FormBody.Builder()
.add("showapi_appid", "13074")
.add("showapi_sign", "ea5b4bf2e140498bb772d1bf2a51a7a0").build();
//创建一个请求对象,传入URL地址和相关数据的键值对的对象
Request request = new Request.Builder()
.url("http://route.showapi.com/341-3")
.post(body).build();

//创建一个能处理请求数据的操作类
Call call = client.newCall(request);

//使用异步任务的模式请求数据
call.enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(“TAG”,"错误信息:" + e.toString());
}

@Override
public void onResponse(Call call, Response response) throws IOException {
Log.e(“TAG”,response.body().string());
}
});

相比较volley,okhttp在请求api的设计上更合理些。okhttp使用RequestBuilder和BodyBuilder构造网络请求,并且隐藏了网络框架的内部实现,让请求的过程更简单优雅。但在接收请求结果上依然选择Callback监听的方式。

0x03 设计者如何面对

3.1 请求需求与请求协议

目前android平台上主流的网络请求都是基于OkHttp框架的,okhttp框架针对http协议进行了封装和优化,支持http/2协议,共享连接池的设计有利于提高请求效率,拦截器的设计支持监视、重写、和重试等特殊业务场景的需求。极大地降低了开发者的使用成本,同时兼备稳定性和可扩展性。所以我们在分析volley和okhttp在实际业务中的不足和优势,选择使用okhttp进行网络请求和连接,在其基础上进行业务封装,力求设计出尽可能的符合自身业务需求和场景的网络框架。具体使用设计需求如下:

  1. 尽可能优雅的方式构造一个网络请求,不管他是get、post还是其他请求方法,也不管请求参数格式是form还是json。在构造Request方式上力求简单统一。
  2. 尽可能简单的方式获取请求结果,最好不要再用Callback的方式接收回调数据。
  3. 支持多种不同的请求结果类型,不管是String还是JSON,不管是Java对象还是文件,都能简单的获取。
  4. 对失败的异常处理要更符合自身业务场景,并不是网络连接错误才会触发请求失败的接口

结合以上几点对网络框架的期望,所以诞生了以下实例的API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Test
public void getMethodTest() {
TestLog.d("get url:" + getUrl(PATH_GET));

BasicRequest request = RequestBuilder.obtain().get()
.setUrl(getUrl(PATH_GET))
.addParam("shopIds", 123445)
.into(this, "getMethod", 1, 1L, (short) 1, false, 1D, 1f)
.buildJsonRequest(ShopInfo.class);

request.send();
}

@Keep
@NetworkCallback(name = "getMethod;getMethod2", type = ResponseType.SUCCESS)
private void onGetMethodSuccess(ShopInfo info, int h, long h1, short h2, boolean h3, double h4
, float h5) {
TestLog.d("++++++++get method success:" + info.toString());
}

@Keep
@NetworkCallback(name = "getMethod", type = ResponseType.FAILED)
private void onGetMethodFailed(CommonError error, int h, long h1, short h2, boolean h3,
double h4, float h5) {
TestLog.d("++++++++get method failed:" + error.toString());
}

API说明:

  • RequestBuilder.obtain从对象池中返回复用的Request对象
  • .get() .postJson() .postFormEncode() postStream(Binary binary)等方法封装了常见的get与post请求并指定了不同的body格式
  • 使用addParam(key, value)方法添加请求参数,这里并不因为请求方法的不同而存在api上的差异
  • 使用注解标记接收网络请求结果回调的方法。如上例中,onGetMethodSuccess()onGetMethodFailed()方便被NetworkCallback注解标记,并且通过BasicRequest.into(this, "getMethod", 1, 1L, (short) 1, false, 1D, 1f)绑定了请求与回调方法之间的关系。其中into的方法参数一次为:包含改回调方法的对象,注解的名称,以及Callback方法需要的其他额外参数。
  • buildJsonRequest将返回结果自动转换成ShopInfo对象。

3.2 缓存

Http缓存策略是一个相对复杂的问题,大致分为以下三个方面:

3.2.1 缓存存储策略

决定Http的相应内容是否可缓存在客户端。Http响应头中的Cache-Control字段,分为PublicPrivateno-cachemax-ageno-store5种类型。其中前4个都会缓存文件数据(关于 no-cache 应理解为“不建议使用本地缓存”,其仍然会缓存数据到本地),后者 no-store 则不会在客户端缓存任何响应数据。

3.2.2 缓存过期策略

决定客户端是否可直接从本地缓存数据中加载数据并展示,否则就发请求到服务端获取。Http响应头中的Expires字段指明了缓存数据有效的绝对时间,告诉客户端到了这个时间点后该本地缓存就该作废了。这里的作废是指客户端不能直接再从本地读取缓存,需要再发一次请求到服务端去确认。确认下这个缓存还有没有用。这个过程就要说到下面的缓存对比策略。

3.2.3 缓存对比策略

决定客户端本地的缓存数据是否仍然有效。客户端检测到数据过期或浏览器刷新后,往往会重新发起一个 http 请求到服务器,服务器此时并不急于返回数据,而是看请求头有没有带标识( If-Modified-Since、If-None-Match)过来,如果判断标识仍然有效,则返回304告诉客户端取本地缓存数据来用即可(这里要注意的是你必须要在首次响应时输出相应的头信息(Last-Modified、ETags)到客户端)。

根据上述的三种缓存策略,这里贴出客户端对http缓存控制的关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 先检查是否有未过期缓存
*/
CacheControl cacheControl = cacheKey.getCacheControl();
if (cacheControl != null && cacheControl.shouldCache()) {
cacheEntry = mCacheManager.get(cacheKey);
// TestLog.d("缓存=====cache entry:" + cacheEntry);
}

/**
* 如果存在缓存数据,检查是否过期
*/
if (cacheEntry != null) {
boolean isExpired = cacheEntry.isExpired();
if (!isExpired) {
// 没有过期,delivery缓存信息
Headers headers = CacheExecutor.getResponseHeaders(cacheEntry);
byte[] body = CacheExecutor.getResponseBody(cacheEntry);

Object result = performParseResponse(requestEvent, headers, body);
response = BasicResponse.success(result);
return;
} else {
// 过期,检查是否有Etag和Last-Modified信息
if (!TextUtils.isEmpty(cacheEntry.etag)) {
requestHeaders.set("If-None-Match", cacheEntry.etag);
}

if (cacheEntry.lastModified > 0) {
requestHeaders.set("If-Modified-Since",
DateUtils.formatMillisToGMT(cacheEntry.lastModified));
}
}
}

如何确定缓存时间的关键流程:

3.3 JSON转换与错误处理

首先需要定义一个CommonResponse,所有的JSON格式的Response都继承它。按照服务端低响应状态的约定,当status为false时表示请求结果失败,这里说的失败指的是无法返回客户端预期的正确业务结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class CommonResponse implements Serializable {
/**
* 状态码
*/
private boolean status;
/**
* 描述
*/
private String message;
/**
* 响应码
*/
private String responseCode;
}

当网络出现异常导致的连接失败时,或者当服务端返回的数据无法正常序列化为指定的类的实例时,或者CommonResponse的status变量为false时,网络框架都会抛出一个可被自动捕获的Throwable,并将返回值和错误类型回调到标记为@NetworkCallback(type = ResponseType.FAILED)的对应方法中。

0x04 总结

因为篇幅的原因,暂时只针对上述的几个方面较的阐述了一个网络框架的设计思路,还包括但不仅限于下面的这些讨论方向,有机会再详聊!如:

线程的切换
对响应数据完整性的校验
Zip文件的请求与自动解压缩,bspatch算法的增量文件请求并根据增量文件自动生成全量文件等
网络模块与缓存模块的解耦设计
网络性能的监控
httpDNS方案的应用
cookie的管理(可参考github开源项目nohttp项目的设计)

在实际的业务开发过程中,针对上述问题的设计和封装已经能够覆盖大部分复杂的业务场景。相信一定能让一个开发人员写出赏心悦目自嗨的代码了。

0x05 参考文档

https://tech.youzan.com/android_http/

https://mp.weixin.qq.com/s/qOMO0LIdA47j3RjhbCWUEQ

https://www.cnblogs.com/chenqf/p/6386163.html

http://blog.csdn.net/yaofeiNO1/article/details/54428021

http://blog.csdn.net/qmickecs/article/details/73696954

http://blog.csdn.net/qmickecs/article/details/73822619

转载请标明出处病已blog https://ivonhoe.github.io/