OkHttp 3.10.0版本,最新OkHttp为:4.0.1逻辑与3版本并没有太大变化,但是改为kotlin实现。
OkHttp介绍
OkHttp是当下Android使用最频繁的网络请求框架,由Square公司开源。Google在Android4.4以后开始将源码中
的HttpURLConnection底层实现替换为OKHttp,同时现在流行的Retrofit框架底层同样是使用OKHttp的。
优点:
支持Http1、Http2、Quic以及WebSocket
连接池复用底层TCP(Socket),减少请求延时
无缝的支持GZIP减少数据流量
缓存响应数据减少重复的网络请求
请求失败自动重试主机的其他ip,自动重定向
…….

使用流程

在使用OkHttp发起一次请求时,对于使用者最少存在 OkHttpClient 、 Request 与 Call 三个角色。其中
OkHttpClient 和 Request 的创建可以使用它为我们提供的 Builder (建造者模式)。而 Call 则是把 Request 交
给 OkHttpClient 之后返回的一个已准备好执行的请求。

建造者模式:将一个复杂的构建与其表示相分离,使得同样的构建过程可以创建不同的表示。实例化
OKHttpClient和Request的时候,因为有太多的属性需要设置,而且开发者的需求组合千变万化,使用建造
者模式可以让用户不需要关心这个类的内部细节,配置好后,建造者会帮助我们按部就班的初始化表示对象

同时OkHttp在设计时采用的门面模式,将整个系统的复杂性给隐藏起来,将子系统接口通过一个客户端
OkHttpClient统一暴露出来。
OkHttpClient 中全是一些配置,比如代理的配置、ssl证书的配置等。而 Call 本身是一个接口,我们获得的实现
为: RealCall

static RealCall newRealCall(OkHttpClient client, Request originalRequest, boolean forWebSocket)
{
// Safely publish the Call instance to the EventListener.
RealCall call = new RealCall(client, originalRequest, forWebSocket);
call.eventListener = client.eventListenerFactory().create(call);
return call;
}

Call 的 execute 代表了同步请求,而 enqueue 则代表异步请求。两者唯一区别在于一个会直接发起网络请求,而
另一个使用OkHttp内置的线程池来进行。这就涉及到OkHttp的任务分发器。

分发器
Dispatcher ,分发器就是来调配请求任务的,内部会包含一个线程池。可以在创建 OkHttpClient 时,传递我们
自己定义的线程池来创建分发器。
这个Dispatcher中的成员有:

//异步请求同时存在的最大请求
private int maxRequests = 64;
//异步请求同一域名同时存在的最大请求
private int maxRequestsPerHost = 5;
//闲置任务(没有请求时可执行一些任务,由使用者设置)
private @Nullable Runnable idleCallback;
//异步请求使用的线程池
private @Nullable ExecutorService executorService;
//异步请求等待执行队列
private final Deque<AsyncCall> readyAsyncCalls = new ArrayDeque<>();
//异步请求正在执行队列
private final Deque<AsyncCall> runningAsyncCalls = new ArrayDeque<>();
//同步请求正在执行队列
private final Deque<RealCall> runningSyncCalls = new ArrayDeque<>();

同步请求

synchronized void executed(RealCall call) {
runningSyncCalls.add(call);
}

因为同步请求不需要线程池,也不存在任何限制。所以分发器仅做一下记录。
异步请求

synchronized void enqueue(AsyncCall call) {
if (runningAsyncCalls.size() < maxRequests && runningCallsForHost(call) <
maxRequestsPerHost) {
runningAsyncCalls.add(call);
executorService().execute(call);
} else {
readyAsyncCalls.add(call);
}
}

当正在执行的任务未超过最大限制64,同时 runningCallsForHost(call) < maxRequestsPerHost 同一Host的请求
不超过5个,则会添加到正在执行队列,同时提交给线程池。否则先加入等待队列。
加入线程池直接执行没啥好说的,但是如果加入等待队列后,就需要等待有空闲名额才开始执行。因此每次执行完
一个请求后,都会调用分发器的 finished 方法

//异步请求调用
void finished(AsyncCall call) {
finished(runningAsyncCalls, call, true);
}
//同步请求调用
void finished(RealCall call) {
finished(runningSyncCalls, call, false);
}
private <T> void finished(Deque<T> calls, T call, boolean promoteCalls) {
int runningCallsCount;
Runnable idleCallback;
synchronized (this) {
//不管异步还是同步,执行完后都要从队列移除(runningSyncCalls/runningAsyncCalls)
if (!calls.remove(call)) throw new AssertionError("Call wasn't in-flight!");
if (promoteCalls) promoteCalls();
//异步任务和同步任务正在执行的和
runningCallsCount = runningCallsCount();
idleCallback = this.idleCallback;
}
// 没有任务执行执行闲置任务
if (runningCallsCount == 0 && idleCallback != null) {
idleCallback.run();
}
}

需要注意的是 只有异步任务才会存在限制与等待,所以在执行完了移除正在执行队列中的元素后,异步任务结束会
执行 promoteCalls() 。很显然这个方法肯定会重新调配请求。

private void promoteCalls() {
//如果任务满了直接返回
if (runningAsyncCalls.size() >= maxRequests) return;
//没有等待执行的任务,返回
if (readyAsyncCalls.isEmpty()) return;
//遍历等待执行队列
for (Iterator<AsyncCall> i = readyAsyncCalls.iterator(); i.hasNext(); ) {
AsyncCall call = i.next();
//等待任务想要执行,还需要满足:这个等待任务请求的Host不能已经存在5个了
if (runningCallsForHost(call) < maxRequestsPerHost) {
i.remove();
runningAsyncCalls.add(call);
executorService().execute(call);
}
if (runningAsyncCalls.size() >= maxRequests) return; // Reached max capacity.
}
}

在满足条件下,会把等待队列中的任务移动到 runningAsyncCalls 并交给线程池执行。所以分发器到这里就完了。
逻辑上还是非常简单的。

请求流程
用户是不需要直接操作任务分发器的,获得的 RealCall 中就分别提供了 execute 与 enqueue 来开始同步请求或异
步请求。

@Override public Response execute() throws IOException {
synchronized (this) {
if (executed) throw new IllegalStateException("Already Executed");
executed = true;
}
captureCallStackTrace();
eventListener.callStart(this);
try {
//调用分发器
client.dispatcher().executed(this);
//执行请求
Response result = getResponseWithInterceptorChain();
if (result == null) throw new IOException("Canceled");
return result;
} catch (IOException e) {
eventListener.callFailed(this, e);
throw e;
} finally {
//请求完成
client.dispatcher().finished(this);
}
}

异步请求的后续同时是调用 getResponseWithInterceptorChain() 来执行请求

@Override
public void enqueue(Callback responseCallback) {
synchronized (this) {
if (executed) throw new IllegalStateException("Already Executed");
executed = true;
}
captureCallStackTrace();
eventListener.callStart(this);
//调用分发器
client.dispatcher().enqueue(new AsyncCall(responseCallback));
}

如果该 RealCall 已经执行过了,再次执行是不允许的。异步请求会把一个 AsyncCall 提交给分发器。
AsyncCall 实际上是一个 Runnable 的子类,使用线程启动一个 Runnable 时会执行 run 方法,在 AsyncCall 中被
重定向到 execute 方法:

final class AsyncCall extends NamedRunnable {
private final Callback responseCallback;
AsyncCall(Callback responseCallback) {
super("OkHttp %s", redactedUrl());
this.responseCallback = responseCallback;
}
//线程池执行
@Override
protected void execute() {
boolean signalledCallback = false;
try {
Response response = getResponseWithInterceptorChain();
//.......
} catch (IOException e) {
//......
} finally {
//请求完成
client.dispatcher().finished(this);
}
}
}
public abstract class NamedRunnable implements Runnable {
protected final String name;
public NamedRunnable(String format, Object... args) {
this.name = Util.format(format, args);
}
@Override
public final void run() {
String oldName = Thread.currentThread().getName();
Thread.currentThread().setName(name);
try {
execute();
} finally {
Thread.currentThread().setName(oldName);
}
}
protected abstract void execute();
}

同时 AsyncCall 也是 RealCall 的普通内部类,这意味着它是持有外部类 RealCall 的引用,可以获得直接调用外
部类的方法。
可以看到无论是同步还是异步请求实际上真正执行请求的工作都在 getResponseWithInterceptorChain() 中。这个
方法就是整个OkHttp的核心:拦截器责任链。但是在介绍责任链之前,我们再来回顾一下线程池的基础知识。

分发器线程池
前面我们提过,分发器就是来调配请求任务的,内部会包含一个线程池。当异步请求时,会将请求任务交给线程池
来执行。那分发器中默认的线程池是如何定义的呢?为什么要这么定义?

public synchronized ExecutorService executorService() {
if (executorService == null) {
executorService = new ThreadPoolExecutor(
0, //核心线程
Integer.MAX_VALUE, //最大线程
60, //空闲线程闲置时间
TimeUnit.SECONDS, //闲置时间单位
new SynchronousQueue<Runnable>(), //线程等待队列
Util.threadFactory("OkHttp Dispatcher", false) //线程创建工厂
);
}
return executorService;
}

在OkHttp的分发器中的线程池定义如上,其实就和 Executors.newCachedThreadPool() 创建的线程一样。首先核
心线程为0,表示线程池不会一直为我们缓存线程,线程池中所有线程都是在60s内没有工作就会被回收。而最大线
程 Integer.MAX_VALUE 与等待队列 SynchronousQueue 的组合能够得到最大的吞吐量。即当需要线程池执行任务
时,如果不存在空闲线程不需要等待,马上新建线程执行任务!等待队列的不同指定了线程池的不同排队机制。一
般来说,等待队列 BlockingQueue 有: ArrayBlockingQueue 、 LinkedBlockingQueue 与 SynchronousQueue 。
假设向线程池提交任务时,核心线程都被占用的情况下:
ArrayBlockingQueue :基于数组的阻塞队列,初始化需要指定固定大小。
当使用此队列时,向线程池提交任务,会首先加入到等待队列中,当等待队列满了之后,再次提交任务,尝试加入
队列就会失败,这时就会检查如果当前线程池中的线程数未达到最大线程,则会新建线程执行新提交的任务。所以
最终可能出现后提交的任务先执行,而先提交的任务一直在等待。

LinkedBlockingQueue :基于链表实现的阻塞队列,初始化可以指定大小,也可以不指定。
当指定大小后,行为就和 ArrayBlockingQueu 一致。而如果未指定大小,则会使用默认的 Integer.MAX_VALUE 作
为队列大小。这时候就会出现线程池的最大线程数参数无用,因为无论如何,向线程池提交任务加入等待队列都会
成功。最终意味着所有任务都是在核心线程执行。如果核心线程一直被占,那就一直等待。
SynchronousQueue : 无容量的队列。
使用此队列意味着希望获得最大并发量。因为无论如何,向线程池提交任务,往队列提交任务都会失败。而失败后
如果没有空闲的非核心线程,就会检查如果当前线程池中的线程数未达到最大线程,则会新建线程执行新提交的任
务。完全没有任何等待,唯一制约它的就是最大线程数的个数。因此一般配合 Integer.MAX_VALUE 就实现了真正的
无等待。
但是需要注意的时,我们都知道,进程的内存是存在限制的,而每一个线程都需要分配一定的内存。所以线程并不
能无限个数。那么当设置最大线程数为 Integer.MAX_VALUE 时,OkHttp同时还有最大请求任务执行个数: 64的限
制。这样即解决了这个问题同时也能获得最大吞吐。

拦截器责任链
OkHttp最核心的工作是在 getResponseWithInterceptorChain() 中进行,在进入这个方法分析之前,我们先来了
解什么是责任链模式,因为此方法就是利用的责任链模式完成一步步的请求。
责任链顾名思义就是由一系列的负责者构成的一个链条,类似于工厂流水线

为请求创建了一个接收者对象的链。这种模式给予请求的类型,对请求的发送者和接收者进行解耦。在这种模式
中,通常每个接收者都包含对另一个接收者的引用。如果一个对象不能处理该请求,那么它会把相同的请求传给下
一个接收者,依此类推。

那整个过程是什么样子的呢?

在责任链模式中,每一个对象对其下家的引用而接起来形成一条链。请求在这个链上传递,直到链上的某一个对象
决定处理此请求。客户并不知道链上的哪一个对象最终处理这个请求,系统可以在不影响客户端的 情况下动态的重
新组织链和分配责任。处理者有两个选择:承担责任或者把责任推给下家。一个请求可以最终不被任何接收端对象
所接受。

拦截器流程

而OkHttp中的 getResponseWithInterceptorChain() 中经历的流程为

请求会被交给责任链中的一个个拦截器。默认情况下有五大拦截器:
1.  RetryAndFollowUpInterceptor
第一个接触到请求,最后接触到响应;负责判断是否需要重新发起整个请求
2.  BridgeInterceptor
补全请求,并对响应进行额外处理
3.  CacheInterceptor
请求前查询缓存,获得响应并判断是否需要缓存
4.  ConnectInterceptor
与服务器完成TCP连接
5.  CallServerInterceptor
与服务器通信;封装请求数据与解析响应数据(如:HTTP报文)

拦截器详情
一、重试及重定向拦截器
第一个拦截器: RetryAndFollowUpInterceptor ,主要就是完成两件事情:重试与重定向。

重试
请求阶段发生了 RouteException 或者 IOException会进行判断是否重新发起请求。
RouteException

catch (RouteException e) {
//todo 路由异常,连接未成功,请求还没发出去
if (!recover(e.getLastConnectException(), streamAllocation, false, request)) {
throw e.getLastConnectException();
}
releaseConnection = false;
continue;
}

IOException

catch (IOException e) {
//todo 请求发出去了,但是和服务器通信失败了。(socket流正在读写数据的时候断开连接)
// HTTP2才会抛出ConnectionShutdownException。所以对于HTTP1 requestSendStarted一定是true
boolean requestSendStarted = !(e instanceof ConnectionShutdownException);
if (!recover(e, streamAllocation, requestSendStarted, request)) throw e;
releaseConnection = false;
continue;
}

两个异常都是根据 recover 方法判断是否能够进行重试,如果返回 true ,则表示允许重试。

private boolean recover(IOException e, StreamAllocation streamAllocation,
boolean requestSendStarted, Request userRequest) {
streamAllocation.streamFailed(e);
//todo 1、在配置OkhttpClient是设置了不允许重试(默认允许),则一旦发生请求失败就不再重试
if (!client.retryOnConnectionFailure()) return false;
//todo 2、如果是RouteException,不用管这个条件,
// 如果是IOException,由于requestSendStarted只在http2的io异常中可能为false,所以主要是第二个条件
if (requestSendStarted && userRequest.body() instanceof UnrepeatableRequestBody)
return false;
//todo 3、判断是不是属于重试的异常
if (!isRecoverable(e, requestSendStarted)) return false;
//todo 4、有没有可以用来连接的路由路线
if (!streamAllocation.hasMoreRoutes()) return false;
// For failure recovery, use the same route selector with a new connection.
return true;
}

所以首先使用者在不禁止重试的前提下,如果出现了某些异常,并且存在更多的路由线路,则会尝试换条线路进行
请求的重试。其中某些异常是在 isRecoverable 中进行判断:

private boolean isRecoverable(IOException e, boolean requestSendStarted) {
// 出现协议异常,不能重试
if (e instanceof ProtocolException) {
return false;
}
// 如果不是超时异常,不能重试
if (e instanceof InterruptedIOException) {
return e instanceof SocketTimeoutException && !requestSendStarted;
}
// SSL握手异常中,证书出现问题,不能重试
if (e instanceof SSLHandshakeException) {
if (e.getCause() instanceof CertificateException) {
return false;
}
}
// SSL握手未授权异常 不能重试
if (e instanceof SSLPeerUnverifiedException) {
return false;
}
return true;
}

1、协议异常,如果是那么直接判定不能重试;(你的请求或者服务器的响应本身就存在问题,没有按照http协议来
定义数据,再重试也没用) 2、超时异常,可能由于网络波动造成了Socket连接的超时,可以使用不同路线重试。
3、SSL证书异常/SSL验证失败异常,前者是证书验证失败,后者可能就是压根就没证书,或者证书数据不正确,
那还怎么重试?
经过了异常的判定之后,如果仍然允许进行重试,就会再检查当前有没有可用路由路线来进行连接。简单来说,比
如 DNS 对域名解析后可能会返回多个 IP,在一个IP失败后,尝试另一个IP进行重试。

重定向
如果请求结束后没有发生异常并不代表当前获得的响应就是最终需要交给用户的,还需要进一步来判断是否需要重
定向的判断。重定向的判断位于 followUpRequest 方法

private Request followUpRequest(Response userResponse) throws IOException {
if (userResponse == null) throw new IllegalStateException();
Connection connection = streamAllocation.connection();
Route route = connection != null
? connection.route()
: null;
int responseCode = userResponse.code();
final String method = userResponse.request().method();
switch (responseCode) {
// 407 客户端使用了HTTP代理服务器,在请求头中添加 “Proxy-Authorization”,让代理服务器授权
case HTTP_PROXY_AUTH:
Proxy selectedProxy = route != null
? route.proxy()
: client.proxy();
if (selectedProxy.type() != Proxy.Type.HTTP) {
throw new ProtocolException("Received HTTP_PROXY_AUTH (407) code while not using
proxy");
}
return client.proxyAuthenticator().authenticate(route, userResponse);
// 401 需要身份验证 有些服务器接口需要验证使用者身份 在请求头中添加 “Authorization”
case HTTP_UNAUTHORIZED:
return client.authenticator().authenticate(route, userResponse);
// 308 永久重定向
// 307 临时重定向
case HTTP_PERM_REDIRECT:
case HTTP_TEMP_REDIRECT:
// 如果请求方式不是GET或者HEAD,框架不会自动重定向请求
if (!method.equals("GET") && !method.equals("HEAD")) {
return null;
}
// 300 301 302 303
case HTTP_MULT_CHOICE:
case HTTP_MOVED_PERM:
case HTTP_MOVED_TEMP:
case HTTP_SEE_OTHER:
// 如果用户不允许重定向,那就返回null
if (!client.followRedirects()) return null;
// 从响应头取出location
String location = userResponse.header("Location");
if (location == null) return null;
// 根据location 配置新的请求 url
HttpUrl url = userResponse.request().url().resolve(location);
// 如果为null,说明协议有问题,取不出来HttpUrl,那就返回null,不进行重定向
if (url == null) return null;
// 如果重定向在http到https之间切换,需要检查用户是不是允许(默认允许)
boolean sameScheme = url.scheme().equals(userResponse.request().url().scheme());
if (!sameScheme && !client.followSslRedirects()) return null;
Request.Builder requestBuilder = userResponse.request().newBuilder();
/**
* 重定向请求中 只要不是 PROPFIND 请求,无论是POST还是其他的方法都要改为GET请求方式,
* 即只有 PROPFIND 请求才能有请求体
*/
//请求不是get与head
if (HttpMethod.permitsRequestBody(method)) {
final boolean maintainBody = HttpMethod.redirectsWithBody(method);
// 除了 PROPFIND 请求之外都改成GET请求
if (HttpMethod.redirectsToGet(method)) {
requestBuilder.method("GET", null);
} else {
RequestBody requestBody = maintainBody ? userResponse.request().body() : null;
requestBuilder.method(method, requestBody);
}
// 不是 PROPFIND 的请求,把请求头中关于请求体的数据删掉
if (!maintainBody) {
requestBuilder.removeHeader("Transfer-Encoding");
requestBuilder.removeHeader("Content-Length");
requestBuilder.removeHeader("Content-Type");
}
}
// 在跨主机重定向时,删除身份验证请求头
if (!sameConnection(userResponse, url)) {
requestBuilder.removeHeader("Authorization");
}
return requestBuilder.url(url).build();
// 408 客户端请求超时
case HTTP_CLIENT_TIMEOUT:
// 408 算是连接失败了,所以判断用户是不是允许重试
if (!client.retryOnConnectionFailure()) {
return null;
}
// UnrepeatableRequestBody实际并没发现有其他地方用到
if (userResponse.request().body() instanceof UnrepeatableRequestBody) {
return null;
}
// 如果是本身这次的响应就是重新请求的产物同时上一次之所以重请求还是因为408,那我们这次不再重请求
了
if (userResponse.priorResponse() != null
&& userResponse.priorResponse().code() == HTTP_CLIENT_TIMEOUT) {
return null;
}
// 如果服务器告诉我们了 Retry-After 多久后重试,那框架不管了。
if (retryAfter(userResponse, 0) > 0) {
return null;
}
return userResponse.request();
// 503 服务不可用 和408差不多,但是只在服务器告诉你 Retry-After:0(意思就是立即重试) 才重请求
case HTTP_UNAVAILABLE:
if (userResponse.priorResponse() != null
&& userResponse.priorResponse().code() == HTTP_UNAVAILABLE) {
return null;
}
if (retryAfter(userResponse, Integer.MAX_VALUE) == 0) {
return userResponse.request();
}
return null;
default:
return null;
}
}

整个是否需要重定向的判断内容很多,记不住,这很正常,关键在于理解他们的意思。如果此方法返回空,那就表
示不需要再重定向了,直接返回响应;但是如果返回非空,那就要重新请求返回的 Request ,但是需要注意的是,
我们的 followup 在拦截器中定义的最大次数为20次。
总结
本拦截器是整个责任链中的第一个,这意味着它会是首次接触到 Request 与最后接收到 Response 的角色,在这个
拦截器中主要功能就是判断是否需要重试与重定向。
重试的前提是出现了 RouteException 或者 IOException 。一但在后续的拦截器执行过程中出现这两个异常,就会
通过 recover 方法进行判断是否进行连接重试。
重定向发生在重试的判定之后,如果不满足重试的条件,还需要进一步调用 followUpRequest 根据 Response 的响
应码(当然,如果直接请求失败, Response 都不存在就会抛出异常)。 followup 最大发生20次。

OkHttp原理解析(一)相关推荐

  1. OkHttp原理解析(二)

    前言 上一篇我们学习了OKHttp的请求执行流程,知道了最终请求流程都会交给getResponseWithInterceptorChain方法来执行,接下来我们就详细分析执行getResponseWi ...

  2. OkHttp 原理解析

    一.前言: HTTP是现代应用常用的一种交换数据和媒体的网络方式,高效地使用HTTP能让资源加载更快,节省带宽.OkHttp是一个高效的HTTP客户端,它有以下默认特性: 支持HTTP/2,允许所有同 ...

  3. OkHttp原理解析之面试题分析

    面试中通常是问些问题考察你对OkHttp的原理是否有深入理解. 使用OkHttp发送网络请求并根据请求结果刷新UI有哪几种方式 使用AsyncTask + OkHttp的同步请求 使用OkHttp的异 ...

  4. OkHttp原理解析

    OkHttp是当下Android使用最频繁的网络请求框架. Google在Android4.4以后,开始将源码中 的HttpURLConnection底层实现 替换为 OKHttp,(即HttpURL ...

  5. OkHttp原理解析(1)

    基于OkHttp 3.10.0版本,最新OkHttp为:4.0.1逻辑与3版本并没有太大变化,但是改为kotlin实现. OkHttp介绍 OkHttp是当下Android使用最频繁的网络请求框架,由 ...

  6. Android:安卓学习笔记之OkHttp原理的简单理解和使用

    Android OkHttp使用原理的简单理解和使用 OkHttp 0.前言 1.请求与响应流程 1.1 请求的封装 1.2 请求的发送 1.3 请求的调度 1.4 请求的处理 2.拦截器 2.1 R ...

  7. OkHttp原理分析总结

    OkHttp原理解析 OkHttp 3.10.0版本,最新OkHttp为:4.0.1逻辑与3版本并没有太大变化,但是改为kotlin实现. OkHttp介绍 OkHttp是当下Android使用最频繁 ...

  8. Android OkHttp完全解析 是时候来了解OkHttp了

    Android OkHttp完全解析 是时候来了解OkHttp了 标签: AndroidOkHttp 2015-08-24 15:36 316254人阅读 评论(306) 收藏 举报  分类: [an ...

  9. OkHttp全解析(一)——基本概念与使用方法

    目录 1.背景 2.基本概念 3.使用方法 4.总结 5.参考 1.背景 kHttp全解析系列文章分为三个部分,包括基本概念与使用方法.原理解析.高级特性的解析.本文为第一部分,基本概念与使用方法,适 ...

最新文章

  1. 推荐 Python 十大经典练手项目,让你的 Python 技能点全亮!
  2. BCH压力测试即将开始,你确定不来凑凑热闹?
  3. IDEA maven项目查自动查看依赖关系,解决包冲突问题
  4. EXE4J(Windows Launcher Generator)——EXE4J概述、下载以及使用
  5. GameMaker Studio 中的组合技(Combo)设置
  6. 与梦城Typecho博客数据站+API站
  7. docker之快速部署gogs git
  8. android opencv中图像分割,opencv在android平台下的开发【4】-图像滤波详解
  9. ubuntu16.04 kinect2 驱动和ROS驱动
  10. c语言游戏计算器代码,C语言计算器代码.doc
  11. ce修改面板属性_鬼谷八荒ce教程 教你如何修改属性+灵石+物品+时间等方法
  12. python如何调用hslcommunication_C#读写PLC数据问题
  13. python建模概述
  14. UML(Unified Modeling Language) 统一建模语言
  15. HTML5教程7-实战:调色板
  16. ide是什么,IDE是什么,ide,IDE
  17. katacontainers二进制安装
  18. 大数据与Hadoop系列之分布式文件系统(一)
  19. 数据结构习题集作业代码(第一章)
  20. HTML5常见面试题及答案(一)

热门文章

  1. python-批量生成指定名字的图片名
  2. 怎么用c语言写病毒程序,来来来,教你一个用C语言写个小病毒
  3. char 数组java_使用Java char数组处理C char数组
  4. Oracle数据库基本信息查询命令
  5. #考试酷解析# 简介
  6. C#编写Windows桌面应用程序在窗体上滚动字幕
  7. 如何白嫖copilot
  8. BZOJ4283: 魔法少女伊莉雅(最短路径图+最短路径树)
  9. ubuntu18.04 设置静态IP 能连网
  10. Ubuntu连接不了网络的解决方法(第二种亲测可行)