本文字数:11171字
预计阅读时间:28分钟
在android开发中,无论是去解决网络请求中遇到的问题,还是优化网络请求的效率,都离不开最根本的网络传输协议(http和tcp等),所以本文的第一部分是对网络协议相关的基础知识进行讲解。掌握了必备的基础知识之后,接下来的第二部分,会对android中使用最广泛的网络请求框架okhttp进行分析,这款框架不仅对基础的网络请求逻辑进行了封装,还尽善尽美的替我们做好了几乎每一件可以去优化的事情,如果能很好的理解并使用它,我相信大部分网络请求相关的问题都可以很好的得到解决。本文的最后部分,想说一说我是如何使用kotlin反射对离线数据上传进行封装的。
网络传输协议大概分为4层(图片来自于极客教程):
(图1)
即链路层、网络层、传输层、应用层。其中,链路层协议是用来在各个物理节点(主机、交换机、路由器)之间传输的协议,会把网络层的ip数据包和mac地址封装成帧,通过mac地址来定位传输以帧为单位的数据。网络层协议是通过ip地址进行路由选择。这两层的协议基本上不用我们操心,我们主要来看一下传输层和应用层协议。
TCP和UDP都是传输层协议,不关心数据的格式,只负责传输,把上层的数据分割成一段一段的去传输。TCP协议是可靠的,有连接的,顺序发送数据,顺序接收(收到数据之后会确认,得到确认之后才会继续发送)。UPD是不可靠的,无连接的,顺序发送数据,乱序接收(不需要确认,不关心有没有收到)。
TCP传输的每一段数据都会被装在一个TCP头里,头的内容包括接收方端口号、序列号、标志位、确认码。
三次握手:TCP需要经过三次握手创建连接: 第一次握手:客户端首先向服务端发送一个标志位为SYN的数据段,它的序列号为X,表示要创建连接 第二次握手:服务端收到客户端的SYN数据段,会向客户端回应一个标志位为ACK的确认数据段,它的序列号为Y,还会附带一个确认号X+1,X+1表示是对序列号为X数据段的确认 第三次握手:客户端再向服务端回应一个标志位为ACK的确认数据段,它的序列号为X+1,确认号为Y+1,服务端收到后就完成了TCP连接的创建
为什么要使用三次握手?一个原因是可以协商双方的初始序列号,后续的传输都可以通过初始序列号偏移和确认。另外一个原因是可以防止服务端资源的浪费,比如一次握手就建立了连接,服务端开始向客户端发送数据,这段数据可能因为某种原因没有到达客户端,客户端误以为连接失败便不再接收数据,此时服务端依然在向客户端发送数据,造成资源的浪费。
有了三次握手创建的可靠连接,交换了初始序列号,后续通过确认号确认收到数据,如果没有收到就重新发送,由此便实现了可靠的数据传输
四次挥手:四次挥手用于安全的关闭连接 第一次挥手:客户端向服务端发送一个标志位为FIN的数据段,表示要断开连接 第二次挥手:服务端收到FIN数据段,向客户端发送一个标志位为ACK的确认数据段,然后开始发送最后的数据 第三次挥手:服务端发送完所有数据后,向客户端发送一个标志位为FIN的数据段 第四次挥手:客户端向服务端再发送一个标志位为ACK的确认数据段,连接关闭
http即超文本传输协议,是应用层协议,它把应用层的各种类型数据封装为http报文,使用tcp协议分割为报文段进行传输。http报文分为两种,请求报文与响应报文。
请求报文的组成包括:请求行、头部字段、实体数据请求行的组成为:请求方式、url地址、http协议版本号
响应报文的组成包括:状态行、响应头、响应体状态行的组成为:状态码和http协议版本
常用请求方式有五种:GET:从服务端查询数据,请求报文里不包含实体数据 POST:向服务端提交数据,请求报文中包含实体数据 HEAD:获取响应报文的头部字段 PUT:直接向服务器写入或更新资源 DELETE:从服务器删除资源
http协议版本:http1.0:每次请求都需要重新建立tcp连接,连接无法复用,有性能问题
http1.1:引入了长连接keep-alive,各个请求是串行处理的,一个请求出现超时,其他请求就会被阻塞
http2.0:
OKHttp已经替我们做好了几乎所有事情,包括创建http请求报文、建立tcp连接、设置缓存等,我们只需要根据自己的需要发号施令就可以发起网络请求了。它巧妙地使用了责任链模式,使各个功能模块独立出来,各司其职,就像流水线一样,产品每经过一个加工站,装上几个零件就发往下一个站点。请求报文和响应报文就是这样的产品,经过每一个处理单元,得到最终的请求数据或响应数据。
OKHttp中的 拦截器(interceptor) 就是http报文的加工站,我们只需要理解每一个interceptor的职责就可以搞清楚它的原理了。
在使用拦截器之前,第一件事是要创建请求数据,和我们前面说的http请求报文的内容类似,包括url地址、请求方式、头部字段、实体数据:
其中实体数据包括媒体类型和数据体:
接着就是把Request依次向每一个拦截器传递,注意这个拦截顺序是不能改变的,后面会详细说。请求完成,得到响应数据之后,再依次向上传递。
具体实现:
一个请求对应一个拦截器链Chain,每一个拦截器调用Chain的proceed方法把Request向下传递,同时proceed方法返回一个Response,类似于一个栈,先进后出,最后处理Response的就是第一个拦截器
应用拦截器是第一个拦截器,它拿到的是最原始的请求数据,通常由开发者自己去创建,用于向header中添加参数,也可以做一些对参数加密的工作。之所以把它放在第一个,是因为第二个拦截器就是RetryAndFollowUpInterceptor(重试和重定向),避免在发生重试或重定向时做重复的逻辑处理,例如我们想要加一个打log的拦截器,只想关心请求的结果,不想关心其他过程(重试、缓存),就可以使用应用拦截器。除此之外,应用拦截器也可以中断网络请求。
这个拦截器会在网络请求失败或者重定向时进行重试。因为重试依赖于Response,所以它的主要工作都是在获取到Response之后进行的。但是在调用proceed获取Response之前,它还要完成一项工作,那就是要准备好连接,一个TCP连接可以处理多个http请求,所以连接的复用要做好
复用连接的流程大概为:
优先使用已分配的连接,比如重定向时再次请求,这也是为什么要在RetryAndFollowUpInterceptor里就要准备好连接
使用Address去连接池里找,Address的主要组成为host(主机名)、prot(端口号)、dns,还有其他的一些代理、ssl算法相关的配置。通过这些标识就可以做到唯一识别一个tcpl连接了。
若ConnectionPool中找不到可以复用的连接,使用dns解析url,得到目标服务器的ip地址,重新创建一个连接放入ConnectionPool中。
空闲连接的清除策略:连接池维护一个5分钟的定时任务,如果最长空闲连接大于5分钟 或 空闲连接数多于5个就清除掉一个最长空闲连接。如果空闲数不大于5 且 最长的空闲时间不大于5分钟,就等5分钟之后再来清理
准备好连接之后就开始调用chain的proceed方法向下传递,得到Response之后,通过响应码来进行重试及重定向的操作。
这个拦截器叫做桥接拦截器,主要帮我们加一些固定的头部字段,处理cookie和压缩。
我们重点要注意一下Accept-Encoding:gzip,如果响应体过大,可以和服务端协商使用gzip进行压缩,okhttp会帮助我们做解压缩的工作
4.CacheInterceptor
这个拦截器就是处理缓存的,OkHttp的缓存机制也是基于http协议的,所以我们先要看一下http协议的缓存机制:
http1.0中使用Expires这个头部字段作为过期时间,例如:Expires: Thu, 12 June 2022 12:08:54 GMT,它有一个缺陷就是客户端时间与服务器时间可能存在差异,所以在http1.1中进行了优化
http1.1中使用Cache-Control:max-age=xxx作为过期标识,表示xxx秒后过期。除此之外Cache-Control还可以进行一些其他的配置,如Cache-Control: public,private, max-age=0, no-cache,no-store
public:表示资源可以被代理服务器和客户端缓存 private:表示该资源只能被客户端缓存 no-cache:和max-age=0是一样的,表示本地可以缓存数据,但是不能直接使用,需要和服务器验证之后再使用,后面会说到缓存的验证 no-store:表示不能被缓存,本地可以不存
缓存验证用到的头部字段:Etag:响应头上带的字段,表示该资源在服务器中的唯一标识 If-None-Match:请求头中的字段,把缓存的Etag传给服务器去做验证
Last-Modified:响应头上带的字段,表示资源在服务器的最后修改时间 If-Modified-Since:请求头中的字段,把缓存的Last-Modified传给服务器去做验证
验证流程:客户端发起请求时,先判断已缓存的资源是否过期(Cache-Control:max-age),如果缓存已经过期了,会去缓存的响应头中找Etag字段,如果存在Etag字段,就在请求头中带上If-None-Match,服务器会把请求头中的Etag与服务端的Etag进行比较,如果一致说明资源没有修改过,返回304,告诉客户端缓存可以继续使用,如果不一致就返回200,重新返回新的资源给客户端。如果缓存的响应头里没有Etag字段,那么就使用If-Modified-Since去传给服务端做验证,流程和Etag一样。
为什么有了Etag还要使用Last-Modified? Last-Modified是http1.0中使用的,最后修改时间只能精确到秒,而且也有可能出现时间修改,但是资源没有修改的情况,所以还是优先使用Etag。
OkHttp默认是不使用缓存的,需要自己配置,而且只支持GET请求:
全局配置:
OkHttpClient client = new OkHttpClient
.Builder()
//这里的Cache就是使用DiskLruCache进行缓存的
. cache(new Cache(new File("cache"),1024*1024*10))
.build();
单个request配置:(如果全局也进行了配置会替换)
new Request
.Builder()
.cacheControl(new CacheControl())
.build();
//强制使用网络,不使用缓存
public static final CacheControl FORCE_NETWORK = new Builder().noCache().build();
//强制使用缓存,不使用网络
public static final CacheControl FORCE_CACHE = new Builder()
.onlyIfCached()
.maxStale(Integer.MAX_VALUE, TimeUnit.SECONDS)
.build();
ConnectInterceptor看起来非常简单,Exchane(老版本为StreamAllocation) 是在RetryAndFollowUpInterceptor里就准备好的,用于对connection和stream的管理。
ExchaneCodec(老版本为HttpCodec)处理数据流的编码解码等工作。在exchangeFinder.find方法中会创建真正的tcp连接,然后把Stream封装在ExchaneCodec里,接着就继续调用下一个拦截器了。
网络拦截器和应用拦截器一样,也是由开发者自己去创建和扩展的,只不过位置不同,作用也不同。网络拦截器位于倒数第二个位置,最后一个拦截器就是要真正发起网络请求了,把网络拦截器放在这里,就是为了避开缓存的影响,只拦截真正发起网络请求的事件。例如我们每一次发起请求时都要向header里添加token,而使用缓存时就不需要。应用拦截器由于只调用一次的原因,可以统计客户端发起请求的情况,而网络拦截器可以监控网络链路的使用情况。
到了这一步就是要发起最后的请求了,主要就是组装http请求报文,通过socket连接发送请求报文,接收响应报文。具体代码就不列举了,大概分为如下几个步骤:
下面说一下写入请求体的两种方式:
一种方式是定长写入,就是已经知道了请求体数据的长度,直接写入,这个没什么好说的。我们主要来看一下这个Chuncked方式,即不定长写入: 如果请求头中有Transfer-Encoding:chunked这个字段那么就会使用不定长写入:
这种写入方式需要每次传入长度和要写入的数据,一段一段的写入,一段一段发送。为什么需要这种写入方式呢?它的好处是什么?试想如果请求体的数据是动态生成的而且数据量较大,等所有数据都生成完成再发送,即要等待生成数据的时间,又要等待大量数据的网络传输,效率会非常低。chunked写入方式可以应用于很多场景去提高传输效率,如加密、压缩、传输音频文件等,一边动态生成数据,一边传输。
最后用一张图来总结一下OkHttp各个拦截器的作用及关系
(图2)
图片来源:
https://blog.csdn.net/jhyshenyu/article/details/100195190)
为什么需要离线上传?当客户端向服务端上传数据时,如果网络不通导致上传失败,就需要把数据暂存在本地,等待网络联通后重新上传。
通常我们最容易想到的实现方案可能是这样的:
遇到网络不通上传失败时,把要上传的数据、参数、类别都保存起来,保存成json字符串或者parcel到文件里,等到网络恢复时重新上传
这样的实现方案有一个很明显的问题,那就是通用性太差了,如果有100个上传接口,就需要处理100种不同的情况。而且参数必须使用string,使用不同类型的参数还需要做转型。
除此还有另外一种方案,那就是直接把http请求的一些必要参数保存下来,如url、请求方式、请求参数、请求体等,然后使用OkHttp去进行一个统一的请求调用。这样的方式也存在几个问题
所以我就想到了使用kotlin反射去实现,可以复用项目中现有的代码资源,直接反射去调用就可以
此处先看一个kotlin反射相比java的优势:
使用java反射动态调用类A的function方法:
使用kotlin反射调用:
其中有两个主要的区别:
接下来看看怎么把一个上传函数的调用流程保存下来:
要保存的函数调用信息大概有:
类名:Repository
函数名:upload
参数名:id,data
参数值:1001,hello
拥有了这些信息,是不是就可以通过反射来调用上传方法了?
不过这里还忽略了一个问题,那就是对上传结果的处理
val result = Repository.upload(id,data)
// ...callback(result)_
所以还需要保存一个callback函数,用做上传结果的回调。然后我们就可以做如下的持久化保存了:(以json数据结构举例,使用Moshi框架)
完整代码如下:
object OfflineUploadManager {
// MMKV框架,用于持久化存储键值对(此处也可以直接使用文件存储)
private val uploadKV = MMKV.mmkvWithID("OfflineUploadKV", context.filesDir.absolutePath)
init {
GlobalScope.launch(Dispatchers.IO) {
while (true) {
delay(3000)
// 有网的情况下检查本地是否有需要上传的数据
if (GlobalDataManager.netWorkType.value != NetWorkType.NONE && uploadKV.count() > 0) {
checkToUpload()
}
}
}
}
// 检查uploadkv中保存需要上传的数据
private suspend fun checkToUpload() {
// 使用channel缓存区,防止并发量过大
val channel = Channel<suspend () -> Unit>(50)
GlobalScope.launch(Dispatchers.IO) {
for (call in channel) {
launch {
call()
}
}
}
// 遍历每一条缓存的上传数据,向channel发消息去上传
uploadKV.allKeys()?.forEach {
val info = uploadKV.getString(it, null)?.fromJson<OfflineUploadInfo>()
// 暂时删除,上传失败后会重新添加
uploadKV.remove(it)
info?.let {
channel.send {
offlineUpload(
className = info.className,
methodName = info.methodName,
params = info.params,
callbackName = info.callbackName,
callbackParams = info.callbackParams
)
}
}
}
channel.close()
}
// 向本地缓存中添加需要离线上传的数据,防止数据量过大磁盘空间不够用,可以设置一个可以缓存的最大值
private fun setUploadCache(info: OfflineUploadInfo) {
if (uploadKV.count() > 3000) {
uploadKV.allKeys()?.take(200)?.let {
uploadKV.removeValuesForKeys(it.toTypedArray())
}
}
uploadKV.putString(
"${info.className}${info.methodName}${System.currentTimeMillis()}",
info.toJson()
)
}
// 核心上传逻辑
suspend fun offlineUpload(
className: String,
methodName: String,
params: List<OfflineUploadParam>,
callbackName: String = "",
callbackParams: List<OfflineUploadParam>? = null
): Boolean {
return try {
// 上传方法所在的类的反射,一般是xxxRepository,这里的Repository都是单例
val cls: KClass<*> = Class.forName(className).kotlin
// 上传方法的反射
val methods = cls.memberFunctions
val method = methods.first { it.name == methodName }
// 调用上传方法所需的参数
val callKParameters = mutableMapOf<KParameter, Any?>()
// Repository是一个单例,直接配置单例对象
callKParameters[method.instanceParameter!!] = cls.objectInstance
// 配置调用方法的参数
params.forEach {
callKParameters[method.findParameterByName(it.paramName)!!] = it.let {
if (!it.isJson) {
it.value
} else {
Moshi.Builder().build().adapter(Class.forName(it.jsonClassName))
.fromJson(it.value as String)
}
}
}
// HttpResult为调用上传方法返回的结果
when (val result: HttpResult<*> =
method.callSuspendBy(callKParameters) as HttpResult<*>) {
is HttpResult.Success -> {
// 上传成功之后,有回调方法需要调用
if (callbackName.isNotEmpty()) {
// 方便管理,回调函数都可以写在OfflineUploadManager中,也可以写在其他类中,但是要传入其他类的classname了
//这里直接拿到回调函数的反射并配置参数完成回调
val callbackMethod =
javaClass.kotlin.functions.first { it.name == callbackName }
val callbackKParameters = mutableMapOf<KParameter, Any?>()
callbackKParameters[callbackMethod.instanceParameter!!] =
javaClass.kotlin.objectInstance
callbackParams?.forEach {
callbackKParameters[callbackMethod.findParameterByName(it.paramName)!!] =
it.let {
if (!it.isJson) {
it.value
} else {
Moshi.Builder().build()
.adapter(Class.forName(it.jsonClassName))
.fromJson(it.value as String)
}
}
}
// HttpResult为必须传入的一个回调参数,其他的参数自定义配置
callbackKParameters[callbackMethod.findParameterByName("result")!!] =
result.data
callbackMethod.callSuspendBy(callbackKParameters)
}
return true
}
// 上传失败后重新放入缓存,等待下一次上传
is HttpResult.Failure -> {
setUploadCache(
OfflineUploadInfo(
className,
methodName,
params,
callbackName,
callbackParams
)
)
return false
}
is HttpResult.Error -> {
setUploadCache(
OfflineUploadInfo(
className,
methodName,
params,
callbackName,
callbackParams
)
)
return false
}
}
} catch (e: Exception) {
false
}
}
//*******************************************************上传成功回调*************************
suspend fun onUploadSuccess(
result: Any?
) {
}
}
使用:比如没网的情况下生成了一条客户的信息,要把它加入到离线上传缓存中,并在上传成功后进行回调
fun saveClientInfo(clientInfo: ClientInfoForSave) {
GlobalScope.launch(Dispatchers.IO) {
OfflineUploadManager.offlineUpload(
className = Repository.javaClass.name,
methodName = Repository::saveClient.name,
params = listOf(
OfflineUploadParam(
Repository::saveClient.parameters.first().name!!,
clientInfo.toJson(),
true,
ClientInfoForSave::class.java.name
)
),
callbackName = OfflineUploadManager::onUploadSuccess.name,
)
}
}
这样就可以使用kotlin反射完成通用离线上传的封装了
最后总结一下我们开发中可能会用到的一些网络请求优化的思路:
1.http2.0支持多路复用、二进制传输、头部压缩,传输效率更高,优先使用http2.0
2.okhttp的应用拦截器可以拦截最原始的网络请求,可以统一加一些头部字段,还可以中断网络请求(避免无效的请求)
3.okhttp支持对响应体使用gzip压缩,需要在请求头里加上Accept-Encoding:gzip,同时也需要服务端支持,以此来提升网络传输的效率
4.okhttp支持http协议的缓存机制,需要在创建OkHttpClient的时候去配置缓存,同时也需要服务端支持http协议的缓存机制
5.okhttp的网络拦截器会在每次发起网络请求时调用,可以使用网络拦截器监控网络链路的使用情况,同时也可以在真正发起请求时加一些头部字段,如token
6.如果向服务端上传的实体数据是动态生成的,如边压缩边上传,边加密边上传等,可以使用chuncked方式提升效率
两张图片来自极客教程 https://blog.csdn.net/jhyshenyu/article/details/100195190
也许你还想看
(▼点击文章标题或封面查看)
2021-10-28
2022-08-11
2022-08-04
2022-04-07
2022-02-10