这篇文章我们接着上篇文章的拦截器继续描述
BridgeInterceptor
BridgeInterceptor拦截器的作用大概有三点:
- 请求时补全header
- 响应阶段保存Cookie
- 响应阶段处理GZIP
源码也比较简单,加上构造方法也就才3个方法,下面我截取intercept()部分代码给大家简单描述
@Override
public Response intercept(Chain chain) throws IOException {
Request userRequest = chain.request();
Request.Builder requestBuilder = userRequest.newBuilder();
RequestBody body = userRequest.body();
if (body != null) {
//对请求头的一些信息进行补充
MediaType contentType = body.contentType();
if (contentType != null) {
requestBuilder.header("Content-Type", contentType.toString());
}
long contentLength = body.contentLength();
if (contentLength != -1) {
requestBuilder.header("Content-Length", Long.toString(contentLength));
requestBuilder.removeHeader("Transfer-Encoding");
} else {
requestBuilder.header("Transfer-Encoding", "chunked");
requestBuilder.removeHeader("Content-Length");
}
}
boolean transparentGzip = false;
...
...
//启动下一个拦截器
Response networkResponse = chain.proceed(requestBuilder.build());
HttpHeaders.receiveHeaders(cookieJar, userRequest.url(), networkResponse.headers());
Response.Builder responseBuilder = networkResponse.newBuilder()
.request(userRequest);
//处理Gzip,由okio完成,随后将Content-Encoding和Content-Length从头中移除
if (transparentGzip
&& "gzip".equalsIgnoreCase(networkResponse.header("Content-Encoding"))
&& HttpHeaders.hasBody(networkResponse)) {
GzipSource responseBody = new GzipSource(networkResponse.body().source());
Headers strippedHeaders = networkResponse.headers().newBuilder()
.removeAll("Content-Encoding")
.removeAll("Content-Length")
.build();
responseBuilder.headers(strippedHeaders);
responseBuilder.body(new RealResponseBody(strippedHeaders, Okio.buffer(responseBody)));
}
return responseBuilder.build();
}
BridgeInterceptor是OKHttp五个内置拦截器最简单的一个,代码也比较少,并且没有任何逻辑性可言,全是按照HTTP协议来的,关于这个拦截器大家了解一下即可。
CacheInterceptor
CacheInterceptor是OKHttp中用来处理缓存的一个拦截器,完全基于HTTP对缓存进行封装,如果对HTTP缓存不太熟悉的同学可以先看我这篇文章,由于CacheInterceptor的intercept()方法比较长,所以我会把该部分源码分两部分进行分析。
在HTTP的缓存策略中,会首先判断强制缓存是否存在,我们来看OKHttp关于这部分的代码实现
@Override
public Response intercept(Chain chain) throws IOException {
//读取缓存
Response cacheCandidate = cache != null
? cache.get(chain.request())
: null;
long now = System.currentTimeMillis();
//一种缓存策略,使用强制缓存和对比缓存
// cacheResponse=null的时候代表没缓存
// 但是cacheResponse!=null时缓存是否有效不确定,要根据判断缓存策略中的networkResponse
// 如果networkResponse==null&&cacheResponse!=null此时缓存是有效的
// 如果networkResponse!=null&&cacheResponse!=null此时只能证明缓存存在,但不确定是否有效
// 进行对比缓存的验证
CacheStrategy strategy = new CacheStrategy.Factory(now, chain.request(), cacheCandidate).get();
Request networkRequest = strategy.networkRequest;
Response cacheResponse = strategy.cacheResponse;
//如果缓存不为空,进行缓存监控
if (cache != null) {
cache.trackResponse(strategy);
}
//缓存存在,通过CacheStrategy判定缓存无效,关闭原始资源
if (cacheCandidate != null && cacheResponse == null) {
closeQuietly(cacheCandidate.body()); // The cache candidate wasn't applicable. Close it.
}
// 根据缓存策略,网络不可用并且缓存不存在或者已经失效,强制返回504
if (networkRequest == null && cacheResponse == null) {
return new Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(504)
.message("Unsatisfiable Request (only-if-cached)")
.body(Util.EMPTY_RESPONSE)
.sentRequestAtMillis(-1L)
.receivedResponseAtMillis(System.currentTimeMillis())
.build();
}
//网络不可用,缓存中存在有效数据,则返回缓存中的数据
if (networkRequest == null) {
return cacheResponse.newBuilder()
.cacheResponse(stripBody(cacheResponse))
.build();
}
...
...
...
}
首先读取缓存,然后通过CacheStrategy获取到缓存状态和网络状态,如果此时网络不可用并且缓存不存在或者已经失效,会返回给客户端504。如果网络不可用,但缓存中存在有效数据会直接将缓存中数据返回给客户端。这是intercept()方法第一部分。
根据HTTP缓存策略,如果强制缓存不存在或者已经失效会继续判断对比缓存,下面我们来看OKHttp中对应的代码:
@Override
public Response intercept(Chain chain) throws IOException {
...
...
...
//以下过程需要网络
Response networkResponse = null;
try {
//执行下一个拦截器
networkResponse = chain.proceed(networkRequest);
} finally {
if (networkResponse == null && cacheCandidate != null) {
closeQuietly(cacheCandidate.body());
}
}
// cacheResponse=null的时候代表没缓存
// 但是cacheResponse!=null时缓存是否有效不确定,要根据判断缓存策略中的networkResponse
// 如果networkResponse==null&&cacheResponse!=null此时缓存是有效的
// 如果networkResponse!=null&&cacheResponse!=null此时只能证明缓存存在,但不确定是否有效
// 进行对比缓存的验证
if (cacheResponse != null) {
//如果服务器返回304,说明缓存有效(对比缓存)
if (networkResponse.code() == HTTP_NOT_MODIFIED) {
Response response = cacheResponse.newBuilder()
.headers(combine(cacheResponse.headers(), networkResponse.headers()))
.sentRequestAtMillis(networkResponse.sentRequestAtMillis())
.receivedResponseAtMillis(networkResponse.receivedResponseAtMillis())
.cacheResponse(stripBody(cacheResponse))
.networkResponse(stripBody(networkResponse))
.build();
networkResponse.body().close();
cache.trackConditionalCacheHit();
//更新缓存
cache.update(cacheResponse, response);
return response;
} else {
closeQuietly(cacheResponse.body());
}
}
//执行到这说明缓存中没有数据或者数据无效,直接从网络获取
Response response = networkResponse.newBuilder()
.cacheResponse(stripBody(cacheResponse))
.networkResponse(stripBody(networkResponse))
.build();
if (cache != null) {
//满足缓存条件
if (HttpHeaders.hasBody(response) && CacheStrategy.isCacheable(response, networkRequest)) {
// Offer this request to the cache.
//将数据写入缓存中
CacheRequest cacheRequest = cache.put(response);
return cacheWritingResponse(cacheRequest, response);
}
if (HttpMethod.invalidatesCache(networkRequest.method())) {
try {
cache.remove(networkRequest);
} catch (IOException ignored) {
}
}
}
return response;
}
如果强制缓存不存在或者无效,会直接启动下一个拦截器请求服务器进行对比缓存验证。当服务器做出响应后,OKHttp会继续判断缓存是否为空,如果不为空再进行判断服务器返回的状态码,如果为304代表缓存有效,然后会将本地缓存更新并返回给上一层。如果不满足上述两个条件:缓存不为空、缓存有效,就直接获取到服务器返回的数据,将数据保存本地后返回给上一层。
在OKHttp中使用缓存的时候需要开辟一块本地空间,也就是这段代码:
File fileCache = new File(context.getExternalCacheDir(),"response");
int cacheSize = 10*1024*1024;//缓存大小为10M
Cache cache = new Cache(fileCache, cacheSize);
//进行OkHttpClient的一些设置
OkHttpClient okHttpClient = new OkHttpClient.Builder()
...
...
.cache(cache)//设置缓存
.build();
这段代码需要开发者自己去写,讲OKHttp使用的时候我们也提到过,就不再过多叙述。另外,OKHttp中缓存是通过DiskLruCache来实现的,DiskLruCache内部维护了一个LinkedHashMap来实现缓存淘汰算法。
关于OKHttp的缓存严格遵守HTTP缓存,熟悉HTTP缓存的同学阅读起源码应该是非常轻松。另外,源码中每行代码我基本都标有详细注释,文字部分就显得少了一些,所以我建议大家能够按着顺序结合注释去读一遍源码。
ConnectInterceptor
这个拦截器源码大概就十几行,只是创建了HttpCodec和RealConnection传递给下一个拦截器
public Response intercept(Chain chain) throws IOException {
RealInterceptorChain realChain = (RealInterceptorChain) chain;
Request request = realChain.request();
StreamAllocation streamAllocation = realChain.streamAllocation();
boolean doExtensiveHealthChecks = !request.method().equals("GET");
//通过StreamAllocation创建HttpCodec和RealConnection
HttpCodec httpCodec = streamAllocation.newStream(client, doExtensiveHealthChecks);
RealConnection connection = streamAllocation.connection();
//启动下一个拦截器
return realChain.proceed(request, streamAllocation, httpCodec, connection);
}
HttpCodec和RealConnection 在传输数据中扮演者非常重要的角色,HttpCodec可以理解为进行IO传输的stream,RealConnection代表连接内部对Socket进行了封装,二者由StreamAllocation 进行管理。下面我们来着重分析StreamAllocation 、RealConnection 、HttpCodec。
StreamAllocation
StreamAllocation 中在newStream()中完成HttpCodec的创建
public HttpCodec newStream(OkHttpClient client, boolean doExtensiveHealthChecks) {
int connectTimeout = client.connectTimeoutMillis();
int readTimeout = client.readTimeoutMillis();
int writeTimeout = client.writeTimeoutMillis();
boolean connectionRetryEnabled = client.retryOnConnectionFailure();
try {
//获取连接
RealConnection resultConnection = findHealthyConnection(connectTimeout, readTimeout,
writeTimeout, connectionRetryEnabled, doExtensiveHealthChecks);
//根据获取到的连接创建HttpCodec
HttpCodec resultCodec = resultConnection.newCodec(client, this);
synchronized (connectionPool) {
codec = resultCodec;
return resultCodec;
}
} catch (IOException e) {
throw new RouteException(e);
}
}
首先调用findHealthyConnection()可以获取到一个RealConnection 对象,然后通过该RealConnection对象创建 HttpCodec 对象。来看一下获取连接的findHealthyConnection()方法
private RealConnection findHealthyConnection(......) {
while (true) {
RealConnection candidate = findConnection(connectTimeout, readTimeout, writeTimeout,
connectionRetryEnabled);
synchronized (connectionPool) {
// successCount代表该连接执行任务的此时,
// 如果是一个新连接就直接拿来使用
if (candidate.successCount == 0) {
return candidate;
}
}
//如果连接不可用
if (!candidate.isHealthy(doExtensiveHealthChecks)) {
//将改连接进行回收
noNewStreams();
continue;
}
return candidate;
}
}
内部是一个无限的while()循环,通过调用findConnection()方法获取连接,获取到连接后判断该连接是否是一个新连接,说到这可能有些同学会有疑问,连接怎么还有新旧之分呢?这里先简单说一下,OKHttp中通过维护一个连接处来实现连接的复用,所以findConnection()获取到的连接可能是直接从连接池中获取到的。接着上面说,如果是一个新连接就直接将该连接返回然后结束循环,如果不是一个新连接会再次判断该连接是否可用,如果不可用就将该连接回收随后跳出本次循环进行下次循环,如果可用就返回该连接随后跳出循环。下面来看一下findConnection()源码:
findConnection()源码较长,所以我就分开进行描述
private RealConnection findConnection(......) {
//声明一个路由
Route selectedRoute;
synchronized (connectionPool) {
// 首先使用已存在的连接
RealConnection allocatedConnection = this.connection;
//noNewStreams为true的时候代表该连接不可以创建流对象
if (allocatedConnection != null && !allocatedConnection.noNewStreams) {
return allocatedConnection;
}
//从连接池中去连接,将获取到的连接赋值给this的connection
Internal.instance.get(connectionPool, address, this, null);
if (connection != null) {
return connection;
}
selectedRoute = route;
}
}
首先声明一个路由Route ,然后判断当前对象中是否存在连接,如果存在并且该连接的noNewStreams值为false就直接返回,否则会从连接池中获取连接,
Internal.instance.get(connectionPool, address, this, null)这句代码大致意思就是从连接池中获取address对应的连接,如果获取到就讲连接赋值给this,也就是当前StreamAllocation对象,如果没有从连接池中获取到连接就执行如下步骤:
private RealConnection findConnection(......) {
...
...
// 重新选一个路由,多IP支持
if (selectedRoute == null) {
selectedRoute = routeSelector.next();
}
//能执行到这说明connection为null,说明从连接池中没有取到合适的连接
RealConnection result;
synchronized (connectionPool) {
//如果已经取消请求抛出异常
if (canceled) throw new IOException("Canceled");
// 拿着新路由再次去连接池中找连接
Internal.instance.get(connectionPool, address, this, selectedRoute);
if (connection != null) {
route = selectedRoute;
return connection;
}
route = selectedRoute;
refusedStreamCount = 0;
//以上条件都不符合,创建一个连接
result = new RealConnection(connectionPool, selectedRoute);
//将StreamAllocation对象添加到connection的StreamAllocation集合中
//表示该StreamAllocation对象使用过自己
acquire(result);
}
//拿到连接对象后建立socket连接
result.connect(connectTimeout, readTimeout, writeTimeout, connectionRetryEnabled);
...
...
}
选择下一个路由,这里简单说下路由这个概念,有些服务器一个域名是可以对应多个IP的,如果存在对IP,请求DNS服务器时会返回多个IP。代码中通过routeSelector.next()来选择一个路由,然后拿着selectedRoute后会重新去连接池中找合适的连接,如果找到了直接将连接返回否则会创建一个新的连接,创建完毕后将StreamAllocation添加到connect对象中的StreamAllocation集合中,用来表示使用过自己的StreamAllocation对象。以上步骤只是获取到了connetc对象并未产生真正的连接,所以还需要调用connect的connect()方法进行socket连接的建立。连接完成之后还需要将连接对象加入到连接池中,下面我们来看实现步骤:
private RealConnection findConnection(......) {
...
...
//新建的连接肯定可以用,所以将该连接移出黑名单
routeDatabase().connected(result.route());
Socket socket = null;
synchronized (connectionPool) {
//将连接加入到连接池当中
Internal.instance.put(connectionPool, result);
//如果同时创建到同一地址的另一个多路复用连接,则释放该连接
if (result.isMultiplexed()) {
socket = Internal.instance.deduplicate(connectionPool, address, this);
result = connection;
}
}
closeQuietly(socket);
return result;
}
首先将新连接从routeDatabase路由黑名单中移除,然后将连接加入到连接池,下面会判断该地址是否已经存在重复的socket连接,如果存在将重复的socket连接关闭。
以上内容就是StreamAllocation创建HttpCodec和RealConnection的流程,另外,在StreamAllocation内部也可以通过调用cancel()、release()来实现取消请求,释放连接。StreamAllocation主要内容差不多就是这些,下面我们来研究一下连接池ConnectionPool内部原理
ConnectionPool
声明:由于对连接类RealConnection某些地方理解不太到位,为了避免误人子弟,我先不对其进行源码分析,等整明白了再补上吧。
先来看ConnectionPool中几个重要成员变量和构造函数:
private static final Executor executor = new ThreadPoolExecutor(0 /* corePoolSize */,
Integer.MAX_VALUE /* maximumPoolSize */, 60L /* keepAliveTime */, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(), Util.threadFactory("OkHttp ConnectionPool", true));
private final int maxIdleConnections;//存储的最大连接数
private final long keepAliveDurationNs;//闲置的连接存活的时间
//存储连接的集合
private final Deque<RealConnection> connections = new ArrayDeque<>();
public MyConnectionPool(int maxIdleConnections, long keepAliveDuration,
TimeUnit timeUnit) {
this.maxIdleConnections = maxIdleConnections;
this.keepAliveDurationNs = timeUnit.toNanos(keepAliveDuration);
// Put a floor on the keep alive duration, otherwise cleanup will spin loop.
if (keepAliveDuration <= 0) {
throw new IllegalArgumentException("keepAliveDuration <= 0: " +
keepAliveDuration);
}
}
指定了缓存中最大连接数和闲置连接的存活时间,默认值分别是:5个、5分钟。同时内部维护了一个静态类型的线程池,该线程池的作用是用来清理失效的连接。我们首先来看连接入队操作:
//往连接池中添加连接
void put(RealConnection connection) {
assert (Thread.holdsLock(this));
//没添加一次都会判断清理无效连接的线程是否正在工作
//如果没有就开启清理线程
if (!cleanupRunning) {
cleanupRunning = true;
executor.execute(cleanupRunnable);
}
connections.add(connection);
}
在每一次进行put()操作的时候都会试图开启清理连接线程池,随后将连接加入到connections中。来看一下清理连接的线程任务cleanupRunnable:
private final Runnable cleanupRunnable = new Runnable() {
@Override public void run() {
while (true) {
long waitNanos = cleanup(System.nanoTime());
if (waitNanos == -1) return;
if (waitNanos > 0) {
long waitMillis = waitNanos / 1000000L;
waitNanos -= (waitMillis * 1000000L);
synchronized (MyConnectionPool.this) {
try {
MyConnectionPool.this.wait(waitMillis, (int) waitNanos);
} catch (InterruptedException ignored) {
}
}
}
}
}
};
内部逻辑很简单,调用了cleanup()方法进行清理操作,如果cleanup()返回值waitNanos 值为-1继续清理操作,如果大于0进行wait()操作,waitNanos 值的含义我会在下面详细解析。来看一下cleanup()源码:
long cleanup(long now) {
int inUseConnectionCount = 0;
int idleConnectionCount = 0;
RealConnection longestIdleConnection = null;
long longestIdleDurationNs = Long.MIN_VALUE;
synchronized (this) {
for (Iterator<RealConnection> i = connections.iterator(); i.hasNext(); ) {
RealConnection connection = i.next();
//如果当前连接正在使用,遍历下一个
if (pruneAndGetAllocationCount(connection, now) > 0) {
//统计正在使用的连接
inUseConnectionCount++;
continue;
}
//统计空闲连接的数量
idleConnectionCount++;
// If the connection is ready to be evicted, we're done.
long idleDurationNs = now - connection.idleAtNanos;
//当前连接超过最大闲置时间
if (idleDurationNs > longestIdleDurationNs) {
longestIdleDurationNs = idleDurationNs;
longestIdleConnection = connection;
}
}
//超出空闲时间||闲置连接超出最大闲置连接
if (longestIdleDurationNs >= this.keepAliveDurationNs
|| idleConnectionCount > this.maxIdleConnections) {
//对闲置时间超过keepAliveDurationNs的连接进行清除
connections.remove(longestIdleConnection);
} else if (idleConnectionCount > 0) {
// 存在闲置的连接,但还未超出keepAliveDurationNs,
// 返回下次需要执行清理的等待时间
return keepAliveDurationNs - longestIdleDurationNs;
} else if (inUseConnectionCount > 0) {
//没有空闲的连接,让清理线程等待keepAliveDuration之后再次执行
return keepAliveDurationNs;
} else {
//不存在任何连接,清理结束,并结束清理线程
cleanupRunning = false;
return -1;
}
}
closeQuietly(longestIdleConnection.socket());
//执行完一个空闲连接后返回0,代表不等待立即清理下一个
return 0;
}
- 开启一个for()循环,计算当前闲置连接和正在使用连接的数量,并记录下一来个超出最大闲置时间的连接
- 当前连接超出最大空闲时间、当前闲置连接数超出最大闲置连接数,两个条件满足其一就进行清理操作,然后返回0立即进行下一次清理
- 如果存在闲置连接,但未超出最大闲置时间,则通知线程等待一定的时间后再开启清理操作
- 没有闲置的连接,通知线程等待keepAliveDurationNs时间后再次开启清理操作
- 以上条件都不满足代表即代表不存在任何连接,直接返回-1结束清理任务
下面我们来看获取连接操作:
RealConnection get(Address address, StreamAllocation streamAllocation, Route route) {
assert (Thread.holdsLock(this));
for (RealConnection connection : connections) {
//存在传入地址的连接
if (connection.isEligible(address, route)) {
streamAllocation.acquire(connection);
return connection;
}
}
return null;
}
获取连接操作也比较简单,拿着传入的address和route进行比较, 比较成功代表该连接可能符合复用要求直接返回。
ConnectionPool主要内容大概就是这些,入队和出队操作都比较好理解,只有清理操作比较复杂但是逻辑也很清晰,也不难。
HttpCodec
HttpCodec是一个接口,OKHttp为其提供了两个实现类,分别是Http1Codec、Http2Codec对应HTTP1和HTTP2,内部就是一些数据的读取写入,本篇文章就不再进行叙述,感兴趣的同学可自行了解下。
CallServerInterceptor
CallServerInterceptor是最后一个拦截器,它的作用就是实现数据在网络上传输,OKHttp中数据传输是通过连接RealConnection和流HttpCodec实现的,而这两个对象在上一个拦截器已经常见完毕,所以CallServerInterceptor中只需要数据读写即可,下面我们来分析intercept()源码:
RealInterceptorChain realChain = (RealInterceptorChain) chain;
HttpCodec httpCodec = realChain.httpStream();
StreamAllocation streamAllocation = realChain.streamAllocation();
RealConnection connection = (RealConnection) realChain.connection();
Request request = realChain.request();
获取到上个拦截器传来的对象
httpCodec.writeRequestHeaders(request);//写入请求头
首先通过httpCodec将请求头信息写入
Response.Builder responseBuilder = null;
//请求体为空做如下步骤
if (HttpMethod.permitsRequestBody(request.method()) && request.body() != null) {
//在发送主体前先询问服务器,是否处理post数据,如果处理就上传主体,反之不上传
//实际应用中,请求体比较大时 才会用到100-continue协议
if ("100-continue".equalsIgnoreCase(request.header("Expect"))) {
httpCodec.flushRequest();
responseBuilder = httpCodec.readResponseHeaders(true);
}
//如果responseBuilder为null代表服务区可接受post数据,此时将body写入
if (responseBuilder == null) {
// Write the request body if the "Expect: 100-continue" expectation was met.
Sink requestBodyOut = httpCodec.createRequestBody(request, request.body().contentLength());
BufferedSink bufferedRequestBody = Okio.buffer(requestBodyOut);
//写入请求体
request.body().writeTo(bufferedRequestBody);
//关闭写入流
bufferedRequestBody.close();
} else if (!connection.isMultiplexed()) {//如果是HTTP1就关闭流
streamAllocation.noNewStreams();
}
}
这部分代码是请求体的写入,大概有三个判断,如下:
- 判断请求头中是否存在Expect:100-continue,如果存在说明需要先询问服务器是否接受post请求体
- 如果responseBuilder为null代表服务器可接收post请求体,可以通过httpCodec获取到输出流Sink将数据写入
- 如果服务器不接收post请求体并且http版本为http1就将连接关闭
以上步骤为请求步骤,下面来看响应步骤
//请求结束
httpCodec.finishRequest();
//获取响应头
if (responseBuilder == null) {
responseBuilder = httpCodec.readResponseHeaders(false);
}
//构建响应
Response response = responseBuilder
.request(request)
.handshake(streamAllocation.connection().handshake())
.sentRequestAtMillis(sentRequestMillis)
.receivedResponseAtMillis(System.currentTimeMillis())
.build();
//读取响应码
int code = response.code();
if (forWebSocket && code == 101) {
// 连接正在升级,随意构建一个非null响应体
response = response.newBuilder()
.body(Util.EMPTY_RESPONSE)
.build();
} else {
//读取响应体
response = response.newBuilder()
.body(httpCodec.openResponseBody(response))
.build();
}
...
...
return response;
- 首先将请求结束
- 获取响应头
- 构建响应结构
- 读取状态码,如果状态码为101代表没有响应体,然后构建一个响应体为null的response
- 如果状态码不为101就读取响应体
- 将response返回给上一个拦截器
CallServerInterceptor的作用大概就是这些
关于OKHttp的源码分析差不多就是这些了,主要描述了请求/响应体、同/异步请求、连接池、以及5个拦截器,基本涵盖了OKHttp所有内容,仅剩Okio而这部分内容封装在另一个包中,提供了Sink和Source来负责流的读写,这部分内容在本系列文章中也不多做叙述,感兴趣的同学可参考其他文章。
总结
OKHttp源码部分分析完毕。通过本篇文章也发现了很多自己的不足,本来是要分析RealConnection这个类的,但是对其源码某些地方理解不太到位,其实具体也可以说是对Socket一些细节理解不太到位,害怕把大家带跑偏就没有对RealConnection做出分享,但我相信我会把它整明白的。如果大家发现文章中存在描述不当的地方还望能够及时指出,也让我能够及时发现问题解决问题,在此事先谢过。好了,本篇文章就到这了,下篇文章《Volley源码解析》。明天休息,祝大家周末快乐!!
网友评论