搜狐技术产品

Android网络请求优化及离线上传封装

前言

在android开发中,无论是去解决网络请求中遇到的问题,还是优化网络请求的效率,都离不开最根本的网络传输协议(http和tcp等),所以本文的第一部分是对网络协议相关的基础知识进行讲解。掌握了必备的基础知识之后,接下来的第二部分,会对android中使用最广泛的网络请求框架okhttp进行分析,这款框架不仅对基础的网络请求逻辑进行了封装,还尽善尽美的替我们做好了几乎每一件可以去优化的事情,如果能很好的理解并使用它,我相信大部分网络请求相关的问题都可以很好的得到解决。本文的最后部分,想说一说我是如何使用kotlin反射对离线数据上传进行封装的。

网络传输协议

网络传输协议大概分为4层(图片来自于极客教程):

Image

(图1)

即链路层、网络层、传输层、应用层。其中,链路层协议是用来在各个物理节点(主机、交换机、路由器)之间传输的协议,会把网络层的ip数据包和mac地址封装成帧,通过mac地址来定位传输以帧为单位的数据。网络层协议是通过ip地址进行路由选择。这两层的协议基本上不用我们操心,我们主要来看一下传输层和应用层协议。

TCP和UDP协议

TCP和UDP都是传输层协议,不关心数据的格式,只负责传输,把上层的数据分割成一段一段的去传输。TCP协议是可靠的,有连接的,顺序发送数据,顺序接收(收到数据之后会确认,得到确认之后才会继续发送)。UPD是不可靠的,无连接的,顺序发送数据,乱序接收(不需要确认,不关心有没有收到)。

TCP如何实现可靠传输

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即超文本传输协议,是应用层协议,它把应用层的各种类型数据封装为http报文,使用tcp协议分割为报文段进行传输。http报文分为两种,请求报文与响应报文。

请求报文的组成包括:请求行、头部字段、实体数据请求行的组成为:请求方式、url地址、http协议版本号

响应报文的组成包括:状态行、响应头、响应体状态行的组成为:状态码和http协议版本

常用请求方式有五种:GET:从服务端查询数据,请求报文里不包含实体数据 POST:向服务端提交数据,请求报文中包含实体数据 HEAD:获取响应报文的头部字段 PUT:直接向服务器写入或更新资源 DELETE:从服务器删除资源

http协议版本:http1.0:每次请求都需要重新建立tcp连接,连接无法复用,有性能问题

http1.1:引入了长连接keep-alive,各个请求是串行处理的,一个请求出现超时,其他请求就会被阻塞

http2.0:

  1. 使用二进制格式传输,1.x版本都是基于文本的
  2. 多路复用,一个tcp连接可以同时支持多个http请求,提升了效率
  3. header优化:header进行了压缩且会进行缓存
  4. 支持服务端向客户端push消息

OkHttp原理

OKHttp已经替我们做好了几乎所有事情,包括创建http请求报文、建立tcp连接、设置缓存等,我们只需要根据自己的需要发号施令就可以发起网络请求了。它巧妙地使用了责任链模式,使各个功能模块独立出来,各司其职,就像流水线一样,产品每经过一个加工站,装上几个零件就发往下一个站点。请求报文和响应报文就是这样的产品,经过每一个处理单元,得到最终的请求数据或响应数据。

OKHttp中的 拦截器(interceptor) 就是http报文的加工站,我们只需要理解每一个interceptor的职责就可以搞清楚它的原理了。

在使用拦截器之前,第一件事是要创建请求数据,和我们前面说的http请求报文的内容类似,包括url地址、请求方式、头部字段、实体数据:

Image


其中实体数据包括媒体类型和数据体:

Image

Image

接着就是把Request依次向每一个拦截器传递,注意这个拦截顺序是不能改变的,后面会详细说。请求完成,得到响应数据之后,再依次向上传递。

具体实现:

Image

一个请求对应一个拦截器链Chain,每一个拦截器调用Chain的proceed方法把Request向下传递,同时proceed方法返回一个Response,类似于一个栈,先进后出,最后处理Response的就是第一个拦截器

1.应用拦截器

应用拦截器是第一个拦截器,它拿到的是最原始的请求数据,通常由开发者自己去创建,用于向header中添加参数,也可以做一些对参数加密的工作。之所以把它放在第一个,是因为第二个拦截器就是RetryAndFollowUpInterceptor(重试和重定向),避免在发生重试或重定向时做重复的逻辑处理,例如我们想要加一个打log的拦截器,只想关心请求的结果,不想关心其他过程(重试、缓存),就可以使用应用拦截器。除此之外,应用拦截器也可以中断网络请求。

2.RetryAndFollowUpInterceptor

这个拦截器会在网络请求失败或者重定向时进行重试。因为重试依赖于Response,所以它的主要工作都是在获取到Response之后进行的。但是在调用proceed获取Response之前,它还要完成一项工作,那就是要准备好连接,一个TCP连接可以处理多个http请求,所以连接的复用要做好

复用连接的流程大概为:

  1. 优先使用已分配的连接,比如重定向时再次请求,这也是为什么要在RetryAndFollowUpInterceptor里就要准备好连接

  2. 使用Address去连接池里找,Address的主要组成为host(主机名)、prot(端口号)、dns,还有其他的一些代理、ssl算法相关的配置。通过这些标识就可以做到唯一识别一个tcpl连接了。

  3. 若ConnectionPool中找不到可以复用的连接,使用dns解析url,得到目标服务器的ip地址,重新创建一个连接放入ConnectionPool中。

空闲连接的清除策略:连接池维护一个5分钟的定时任务,如果最长空闲连接大于5分钟 或 空闲连接数多于5个就清除掉一个最长空闲连接。如果空闲数不大于5 且 最长的空闲时间不大于5分钟,就等5分钟之后再来清理

准备好连接之后就开始调用chain的proceed方法向下传递,得到Response之后,通过响应码来进行重试及重定向的操作。

3.BridgeInterceptor

这个拦截器叫做桥接拦截器,主要帮我们加一些固定的头部字段,处理cookie和压缩。

Image

我们重点要注意一下Accept-Encoding:gzip,如果响应体过大,可以和服务端协商使用gzip进行压缩,okhttp会帮助我们做解压缩的工作

4.CacheInterceptor

这个拦截器就是处理缓存的,OkHttp的缓存机制也是基于http协议的,所以我们先要看一下http协议的缓存机制:

  1. 新鲜度: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:表示不能被缓存,本地可以不存

  1. 缓存的验证:如果缓存过期了,但是服务端的资源并没有被修改过,那么缓存依旧还是可以用的,所以需要验证过后进行使用。

缓存验证用到的头部字段: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();

5.ConnectInterceptor

Image

ConnectInterceptor看起来非常简单,Exchane(老版本为StreamAllocation) 是在RetryAndFollowUpInterceptor里就准备好的,用于对connection和stream的管理。

Image

ExchaneCodec(老版本为HttpCodec)处理数据流的编码解码等工作。在exchangeFinder.find方法中会创建真正的tcp连接,然后把Stream封装在ExchaneCodec里,接着就继续调用下一个拦截器了。

6.NetworkInterceptor(网络拦截器)

网络拦截器和应用拦截器一样,也是由开发者自己去创建和扩展的,只不过位置不同,作用也不同。网络拦截器位于倒数第二个位置,最后一个拦截器就是要真正发起网络请求了,把网络拦截器放在这里,就是为了避开缓存的影响,只拦截真正发起网络请求的事件。例如我们每一次发起请求时都要向header里添加token,而使用缓存时就不需要。应用拦截器由于只调用一次的原因,可以统计客户端发起请求的情况,而网络拦截器可以监控网络链路的使用情况。

7.CallServerInterceptor

到了这一步就是要发起最后的请求了,主要就是组装http请求报文,通过socket连接发送请求报文,接收响应报文。具体代码就不列举了,大概分为如下几个步骤:

  1. 写入请求头,包括请求行和头部字段
  2. 如果请求头里有"100-continue",会把请求头发送给服务端,看服务端是否愿意接收,愿意接收再进行请求体的发送
  3. 写入请求体
  4. 发起http请求
  5. 读取响应体,构建Response,如果响应码为100则需要重新请求一次,如果响应头里有“close”,关闭连接
  6. 返回Response交给它的上层拦截器一一处理Response

下面说一下写入请求体的两种方式:

Image

一种方式是定长写入,就是已经知道了请求体数据的长度,直接写入,这个没什么好说的。我们主要来看一下这个Chuncked方式,即不定长写入: 如果请求头中有Transfer-Encoding:chunked这个字段那么就会使用不定长写入:

Image

这种写入方式需要每次传入长度和要写入的数据,一段一段的写入,一段一段发送。为什么需要这种写入方式呢?它的好处是什么?试想如果请求体的数据是动态生成的而且数据量较大,等所有数据都生成完成再发送,即要等待生成数据的时间,又要等待大量数据的网络传输,效率会非常低。chunked写入方式可以应用于很多场景去提高传输效率,如加密、压缩、传输音频文件等,一边动态生成数据,一边传输。

最后用一张图来总结一下OkHttp各个拦截器的作用及关系

Image

(图2)

图片来源:

https://blog.csdn.net/jhyshenyu/article/details/100195190)

通用离线上传封装

为什么需要离线上传?当客户端向服务端上传数据时,如果网络不通导致上传失败,就需要把数据暂存在本地,等待网络联通后重新上传。

通常我们最容易想到的实现方案可能是这样的:

Image

遇到网络不通上传失败时,把要上传的数据、参数、类别都保存起来,保存成json字符串或者parcel到文件里,等到网络恢复时重新上传

Image

这样的实现方案有一个很明显的问题,那就是通用性太差了,如果有100个上传接口,就需要处理100种不同的情况。而且参数必须使用string,使用不同类型的参数还需要做转型。

除此还有另外一种方案,那就是直接把http请求的一些必要参数保存下来,如url、请求方式、请求参数、请求体等,然后使用OkHttp去进行一个统一的请求调用。这样的方式也存在几个问题

  1. 封装较为复杂,http请求的元素太多,可能使用form表单上传,可能使用实体数据上传,可能是post上传,可能是put上传。
  2. 如果项目中已经使用Retrofit等框架对网络请求做了一些底层的封装也没办法复用了。
  3. 因为是离线的,上传之后的回调逻辑没法实现

所以我就想到了使用kotlin反射去实现,可以复用项目中现有的代码资源,直接反射去调用就可以

使用Kotlin反射封装离线上传

此处先看一个kotlin反射相比java的优势:

Image

使用java反射动态调用类A的function方法:

Image

使用kotlin反射调用:Image

其中有两个主要的区别:

  1. kotlin拥有函数引用,可以使用A::function.name拿到方法名,就可以避免直接使用硬编码的方式去传入方法名,便于维护
  2. kotlin还可以动态传入参数名去调用方法,大大提高了灵活性

接下来看看怎么把一个上传函数的调用流程保存下来:

Image

要保存的函数调用信息大概有:

类名: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

Image

也许你还想看

(▼点击文章标题或封面查看)

Flutter之美

2021-10-28

Image

Jetpack Compose自定义Layout详解

2022-08-11

Image

try catch finally机制分析

2022-08-04

Image

子线程更新UI全解

2022-04-07

Image

Flutter状态管理-Provider的使用和源码解析

2022-02-10

Image