“深入交流“系列:Okhttp(一)请求流程解析

Okhttp请求流程源码解析

前言

最近发现以前学习过的好多东西,都忘记了,所以打算复习一次,并且通过输出博客来加深印象。Okhttp准备分成两篇文章来讲解,这篇文章主要叙述一下Okhttp的整体请求流程,第二篇文章则讲述一下Okhttp中极为重要的拦截器。“深入交流”系列打算从一些第三方框架源码或者FrameWork层源码入手,进行"深入"的交流,会持续输出下去,敬请期待。

1、Okhttp的同步请求和异步请求

在分析Okhttp的源码之前,我们首先了解一下Okhttp最基本的实现一次网络请求的方式。

Okhttp的同步请求,如下:

//[1]、创建OkhttpClient对象
OkHttpClient client = new OkHttpClient.Builder()
        .readTimeout(5000, TimeUnit.MILLISECONDS)
        .build();

//[2]、请求报文创建,包含常用的请求信息,如url、get/post方法,设置请求头等
Request request = new Request.Builder()
        .url("http://www.baidu.com")
        .get()
        .build();

//[3]、创建Call对象
Call call = client.newCall(request);

try {
    //[4]、同步请求,发送请求后,就会进入阻塞状态,直到收到响应
    Response response = call.execute();
    Log.d(TAG, "requestNet: response");
} catch (IOException e) {
    e.printStackTrace();
} 

接下来看一下okhttp的异步网络请求,如下:

// [1]、创建OkhttpClient对象
OkHttpClient client = new OkHttpClient.Builder()
        .readTimeout(5000, TimeUnit.MILLISECONDS)
        .build();

// [2]、请求报文创建,包含常用的请求信息,如url、get/post方法,设置请求头等
Request request = new Request.Builder()
        .url("http://www.baidu.com")
        .get()
        .build();

// [3]、创建Call对象
Call call = client.newCall(request);
// [4]、发送异步请求,接收回调信息
call.enqueue(new Callback() {
    @Override
    public void onFailure(Call call, IOException e) {
        Log.d(TAG, "onFailure: request error......");
    }

    @Override
    public void onResponse(Call call, Response response) throws IOException {
        Log.d(TAG, "onResponse: request success......");
    }
}); 

可以看到同步请求和异步请求的使用在前3步都是相同的,只用在第4步的时候同步请求调用的是call.execute()方法,异步请求调用的是call.enqueue()方法。接下来我们会根据异步请求来分析一下Okhttp整体的请求流程,同步请求相对于异步请求来说,较为简单,掌握异步请求后再看同步请求,问题不大。。。

在正式分析源码之前我们先来看一下Okhttp的请求流程图,从宏观的角度先对整体的请求流程有个印象,之后我们再从每一步流程的细节进行分析。我自己把整体的请求分成了8个小步骤,在这8步中,Dispatcher较为重要,而Interceptors作为整个网络请求的核心部分,那是相当重要。

“深入交流“系列:Okhttp(一)请求流程解析_第1张图片

2、异步请求流程分析

[1]、构建OkhttpClient对象

OkhttpClient:相当于配置中心,所有的请求都会共享这些配置,OkhttpClient中定义了网络协议、DNS、请求时间等等。创建对象的方式有两种,一种是通过直接new对象的方式,另一种是通过Builder模式设置参数来进行创建。

方法一:使用默认参数,不需要配置参数
OkHttpClient client = new OkHttpClient();
方法二:通过Builder来配置参数
OkHttpClient client = new OkHttpClient.Builder()
        .readTimeout(5000, TimeUnit.MILLISECONDS)
        .build(); 

两者的构造方法分别如下:

// 默认方式,这种方式不需要配置参数,使用的都是默认创建的参数
public OkHttpClient() {
    this(new Builder());
}
// builder模式,通过Builder来配置参数
OkHttpClient(Builder builder) {
    this.dispatcher = builder.dispatcher;
    this.proxy = builder.proxy;
    this.protocols = builder.protocols;
    // 太多了,省略一部分
    ......
}

public Builder() {
    //调度器,用于调度后台发起的网络请求,有后台总请求数和单主机总请求数的控制。
    dispatcher = new Dispatcher();
    //⽀持的应⽤层协议,即 HTTP/1.1、HTTP/2 等
    protocols = DEFAULT_PROTOCOLS;
    //应⽤层⽀持的 Socket 设置,即使⽤明⽂ 传输(⽤于 HTTP)还是某个版本的 TLS(⽤于 HTTPS)。
    connectionSpecs = DEFAULT_CONNECTION_SPECS;
    eventListenerFactory = EventListener.factory(EventListener.NONE);
    proxySelector = ProxySelector.getDefault();
    //管理 Cookie 的控制器。OkHttp 提供了 Cookie 存取的判断⽀持(即什么时候需要存 Cookie,什么时候需要读取Cookie,但没有给出具体的存取实现。
    //如果需要存取 Cookie,你得⾃⼰写实现,例如⽤ Map 存在内存⾥,或者⽤别的⽅式存在本地存储或者数据库
    cookieJar = CookieJar.NO_COOKIES;
    socketFactory = SocketFactory.getDefault();
    //⽤于验证 HTTPS 握⼿过程中下载到的证书所 属者是否和⾃⼰要访问的主机名⼀致
    hostnameVerifier = OkHostnameVerifier.INSTANCE;
    certificatePinner = CertificatePinner.DEFAULT;
    proxyAuthenticator = Authenticator.NONE;
    authenticator = Authenticator.NONE;
    //客户端和服务器之间的连接抽象为一个connection,
    //每一个connection都会放到连接池当中,当请求的url是相同的时候可以进行复用
    connectionPool = new ConnectionPool();
    dns = Dns.SYSTEM;
    followSslRedirects = true;
    followRedirects = true;
    //在请求失败的时候是否⾃动重试。注意,⼤多数的请求失败并不属于 OkHttp 所定义的「需要重试」,
    //这种重试只适⽤于「同⼀个域名的 多个 IP 切换重试」「Socket 失效重试」等情况
    retryOnConnectionFailure = true;
    //建⽴连接(TCP 或 TLS)的超时时间
    connectTimeout = 10_000;
    //发起请求到读到响应数据的超时时间
    readTimeout = 10_000;
    //发起请求并被⽬标服务器接受的超时时间。(因为有时候 对⽅服务器可能由于某种原因⽽不读取你的 Request)
    writeTimeout = 10_000;
    pingInterval = 0;
} 

[2]、构建Request对象

Request:网络请求信息的封装类,内置urlheadget/post请求等。 Request对象的构建只能通过builder模式来构建,具体的构建过程同OkhttpClient是一样的,都是使用了Builder构建模式。

public static class Builder {
        HttpUrl url;
        String method;
        okhttp3.Headers.Builder headers;
        RequestBody body;
        Object tag;

        public Builder() {
            this.method = "GET";
            this.headers = new okhttp3.Headers.Builder();
        }

        Builder(Request request) {
            this.url = request.url;
            this.method = request.method;
            this.body = request.body;
            this.tag = request.tag;
            this.headers = request.headers.newBuilder();
        }

        ......
    } 

[3]、创建Call对象

Call:网络请求的执行者,Call用来描述一个可被执行、中断的请求,client.newCall(request) 方法就是指创建一个新的将要被执行的请求,每一个Request最终将会被封装成一个Realcall对象。RealcallCall接口唯一的实现类,AsyncCallRealcall中的内部类,你也可以把 RealCall 理解为同步请求操作,而 AsyncCall 则是异步请求操作

public interface Call extends Cloneable {
    
  //返回当前请求
  Request request();

  //同步请求方法,此方法会阻塞当前线程直到请求结果返回
  Response execute() throws IOException;

  //异步请求方法,此方法会将请求添加到队列中,然后等待请求返回
  void enqueue(Callback responseCallback);

  //取消请求
  void cancel();

  //请求是否在执行,当execute()或者enqueue(Callback responseCallback)执行后该方法返回true
  boolean isExecuted();

  //请求是否被取消
  boolean isCanceled();

  //创建一个新的一模一样的请求
  Call clone();

  interface Factory {
    Call newCall(Request request);
  }
} 

[4]、发送异步请求

在准备工作(OkhttpClientRequestCall)都完成之后,接下来我们就要调用call.enqueue()正式的开始进行网络请求了。前面我们提到,RealCallCall唯一的 实现类,所以我们来查看RealCall中的enqueue()

@Override public void enqueue(Callback responseCallback) {
    //使用synchronized锁住了当前对象,防止多线程同时调用,this == realCall对象
      synchronized (this) {
          //1、判断当前call是否已经执行过
        if (executed) throw new IllegalStateException("Already Executed");
        executed = true;
      }
      //打印堆栈信息
      captureCallStackTrace();
      eventListener.callStart(this);
      //2、封装一个AsyncCall对象,完成实际的异步请求
      client.dispatcher().enqueue(new AsyncCall(responseCallback));
} 

enqueue方法中首先会判断当前的call是否已经执行过一次,如果已经执行过的话,就会抛出一个异常,如果没有执行的话会给executed变量进行赋值,表示已经执行过,从这里也可以看出call只能执行一次。接着我们看最后一行,一共做了两件事,先是封装了一个AsyncCall对象,然后通过client.dispatcher().enqueue()方法开始实际的异步请求。进入AsyncCall中查看代码,如下:

final class AsyncCall extends NamedRunnable {
  private final Callback responseCallback;
      AsyncCall(Callback responseCallback) {
        super("OkHttp %s", redactedUrl());
        this.responseCallback = responseCallback;
      }
      ......
  } 

我们看到AsyncCall类继承自NamedRunnable,紧接着我们再进入到NamedRunnable中可以看到它实现了Runnable接口,所以最终确定AsyncCall就是一个Runnable

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() {
       ......
  }
} 

看完封装AsyncCall对象之后,我们再来看一下client.dispatcher().enqueue(),先是通过client.dispatcher()获取到dispatcher对象,然后调用Dispatcher中的enqueue方法。那么这个Dispatcher是什么呢,接下来我们就来了解一下。

[5]、Dispatcher分发器

DispatcherDispatcher是一个任务分发器,用于管理其对应OkhttpClient的所有请求。它的主要功能如下:

  • 发起/取消网络请求APIexecuteenqueuecancel
  • 线程池管理异步任务。
  • 记录同步任务、异步任务及等待执行的异步任务(内部维护了三个队列)。
public final class Dispatcher {
  /** 最大并发请求数 */
  private int maxRequests = 64;
  /** 每个主机的最大请求数 */
  private int maxRequestsPerHost = 5;
  private @Nullable Runnable idleCallback;
  /** Executes calls. Created lazily. */
  /**线程池 */
  private @Nullable ExecutorService executorService;

  /** Ready async calls in the order they'll be run. */
  /** 将要运行异步请求队列 */
  private final Deque readyAsyncCalls = new ArrayDeque<>();

  /** Running asynchronous calls. Includes canceled calls that haven't finished yet. */
  /** 正在执行的异步请求队列,包含了已经取消但是还没有执行完成的请求 */
  private final Deque runningAsyncCalls = new ArrayDeque<>();

  /** Running synchronous calls. Includes canceled calls that haven't finished yet. */
  /** 正在执行的同步请求队列,包含了已经取消但是还没有执行完成的请求 */
  private final Deque runningSyncCalls = new ArrayDeque<>();

  public Dispatcher(ExecutorService executorService) {
    this.executorService = executorService;
  }

  public Dispatcher() {
  } 

了解完Dispatcher之后,我们继续回到刚才的 client.dispatcher().enqueue()方法,查看Dispatcher中的enqueue(),如下:

synchronized void enqueue(AsyncCall call) {
    if (runningAsyncCalls.size() < maxRequests && runningCallsForHost(call) < maxRequestsPerHost) {
      //添加到正在执行的异步请求队列
      runningAsyncCalls.add(call);
      //使用线程池执行异步请求
      executorService().execute(call);
    } else {
      //添加到将要运行的异步请求队列
      readyAsyncCalls.add(call);
    }
  } 

当正在执行的异步请求队列中的数量小于最大并发请求数(64)并且正在执行的请求主机数小于每个主机的最大请求数(5)时,就会把请求call添加到正在执行的异步请求队列中,并且使用线程池执行异步请求。反之如果这个判断条件不成立,就会把请求call添加到将要运行的异步请求队列中缓存起来,添加到等待队列中。这里我们先留个疑问,添加到等待队列中的任务什么时候会被执行呢? 此问题稍后再议。

可以看到将请求call添加到执行任务队列之后,立马通过线程池来执行了异步请求,所以我们先来通过executorService()方法了解一下分发器的线程池。

 public synchronized ExecutorService executorService() {
    if (executorService == null) {
      executorService = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS,
          new SynchronousQueue(), Util.threadFactory("OkHttp Dispatcher", false));
    }
    return executorService;
  } 

分发器线程池 首先我们来看一下分发器中线程池是如何定义的:

  • 0:核心线程池的数量

  • Integer.MAX_VALUE:最大线程数量

  • 60 & TimeUnit.SECONDS:空闲线程的闲置时间为60s

  • new SynchronousQueue():线程等待队列

  • Util.threadFactory(“OkHttp Dispatcher”, false)):线程创建工厂

那么分发器中线程池为什么要这么定义呢?其实和Executors.newCachedThreadPool()创建的线程一样。首先核心线程为0,表示线程池不会一直为我们缓存线程,线程池中所有线程都是在60s内没有工作就会被回收,当需要线程池执行任务时,如果不存在空闲线程那么也不需要等待,直接开启一个新的线程来执行任务,等待队列的不同指定了线程池的不同排队机制。一般来说,等待队列 BlockingQueue 有: ArrayBlockingQueueLinkedBlockingQueueSynchronousQueue

假设向线程池提交任务时,核心线程都被占用 的情况下:

ArrayBlockingQueue:基于数组的阻塞队列,初始化时需要指定固定的大小

当使用此队列时,向线程池提交任务,会首先加入到等待队列中,当等待队列满了之后,再次提交任务,尝试加入队列就会失败,这时就会检查如果当前线程池中的线程数未达到最大线程,则会新建线程执行新提交的任务,所以最终可能出现后提交的任务先执行,而先提交的任务一直在等待。

LinkedBlockQueue:基于链表的阻塞队列,初始化时可以指定大小,也可以不指定

当指定大小后,行为就和ArrayBlockingQueue一致,而如果未指定大小,则会使用默认的Integer.MAX_VALUE作为队列大小。这时候就会出现线程池的最大线程数参数无用,因为无论如何,向线程池提交任务加入等待队列都会成功。最终意味着所有任务都是在核心线程执行,如果核心线程一直被占,那就一直等待。

SynchronousQueue:没有容量的队列

使用此队列意味着希望获得最大并发量。因为无论如何,向线程池提交任务,往队列提交任务都会失败,而失败后如果没有空闲的非核心线程,就会检查如果当前线程池中的线程数未达到最大线程,则会新建线程执行新提交的任务。完全没有任何等待,唯一制约它的就是最大线程池的个数。因此一般配合Integer.MAX_VALUE 就实现了真正的无等待。

但是需要注意的时,我们都知道,进程的内存是存在限制的,而每一个线程都需要分配一定的内存。所以线程并不能无限个数。那么当设置最大线程数为 Integer.MAX_VALUE 时,OkHttp同时还有最大请求任务执行个数64的限制。这样即解决了这个问题同时也能获得最大吞吐。

在前面我们提到了AsyncCall可以当做是一个异步请求,它继承自NamedRunnable,而NamedRunnable实现了Runnable,既然它是一个Runnable,那么它就一定有run方法,所以通过NamedRunnable中的run方法可以知道executorService().execute(call)这行代码最终的执行就是在AsyncCall中的execute()中进行的,代码如下:

@Override protected void execute() {
      boolean signalledCallback = false;
      try {
        //构建拦截器链,返回Response对象
        Response response = getResponseWithInterceptorChain();
        //判断重定向拦截器是否取消
        if (retryAndFollowUpInterceptor.isCanceled()) {
          signalledCallback = true;
          responseCallback.onFailure(RealCall.this, new IOException("Canceled"));
        } else {
          signalledCallback = true;
          responseCallback.onResponse(RealCall.this, response);
        }
      } catch (IOException e) {
        if (signalledCallback) {
          // Do not signal the callback twice!
          Platform.get().log(INFO, "Callback failure for " + toLoggableString(), e);
        } else {
          eventListener.callFailed(RealCall.this, e);
          responseCallback.onFailure(RealCall.this, e);
        }
      } finally {
        client.dispatcher().finished(this);
      }
    }
  } 

execute()方法中我们需要重点关注getResponseWithInterceptorChain()这个方法,通过方法名也可以得出此方法的作用是通过构建拦截器链返回了一个Response对象,那么也意味着真正执行网络请求的代码就在此方法中。至于方法中的具体内容,我们后面进行具体分析。

用一张图来总结下分发器的异步请求流程:

“深入交流“系列:Okhttp(一)请求流程解析_第2张图片

[6]、Interceptors拦截器

从上面getResponseWithInterceptorChain()可以知道至此我们已经进入了Okhttp最重要的一个步骤拦截器链中,开始了正式的网络请求以及响应数据的获取。这里我们先来简单的了解下拦截器,至于各个拦截器的具体功能在下一章进行单独的讲解,因为这章我们主要是为了梳理清楚整个请求流程。

Response getResponseWithInterceptorChain() throws IOException {

    // Build a full stack of interceptors.
    List interceptors = new ArrayList<>();
    // 添加自定义拦截器
    interceptors.addAll(client.interceptors());
    // 重试重定向拦截器:负责请求失败的时候实现重试重定向功能
    interceptors.add(retryAndFollowUpInterceptor);
    // 桥接拦截器:将用户构造的请求转换为向服务器发送的请求,将服务器返回的响应转换为对用户友好的响应
    // 主要对 Request 中的 Head 设置默认值,比如 Content-Type、Keep-Alive、Cookie 等
    interceptors.add(new BridgeInterceptor(client.cookieJar()));
    // 缓存拦截器:读取缓存、更新缓存
    interceptors.add(new CacheInterceptor(client.internalCache()));
    // 连接拦截器:负责建立与服务器地址之间的连接,也就是 TCP 链接。
    interceptors.add(new ConnectInterceptor(client));
    if (!forWebSocket) {
        interceptors.addAll(client.networkInterceptors());
    }
    // 服务请求拦截器:从服务器读取响应
    interceptors.add(new CallServerInterceptor(forWebSocket));

    Interceptor.Chain chain = new RealInterceptorChain(interceptors, null, null, null, 0,
            originalRequest, this, eventListener, client.connectTimeoutMillis(),
            client.readTimeoutMillis(), client.writeTimeoutMillis());

    return chain.proceed(originalRequest);
} 

[7&8]、获取Response响应、调用finished

这里我们再次回到第5步中的executed()方法中,可以看到我们是通过getResponseWithInterceptorChain()拿到了服务器返回的响应Response,然后进行请求成功或者请求失败的回调,到这里为止,一次完整的网络请求请求已经结束了。但是细心的小伙伴会发现这段代码中有一个finally,那代表着我们的请求不管成功与否,都会进入到这个finally当中。接下来我们就来看一下这个finally中的代码。

@Override protected void execute() {
      boolean signalledCallback = false;
      try {
        //构建拦截器链,返回Response对象
        Response response = getResponseWithInterceptorChain();
        //判断重定向拦截器是否取消
        if (retryAndFollowUpInterceptor.isCanceled()) {
          signalledCallback = true;
          responseCallback.onFailure(RealCall.this, new IOException("Canceled"));
        } else {
          signalledCallback = true;
          responseCallback.onResponse(RealCall.this, response);
        }
      } catch (IOException e) {
        if (signalledCallback) {
          // Do not signal the callback twice!
          Platform.get().log(INFO, "Callback failure for " + toLoggableString(), e);
        } else {
          eventListener.callFailed(RealCall.this, e);
          responseCallback.onFailure(RealCall.this, e);
        }
      } finally {
        client.dispatcher().finished(this);
      }
    }
  } 

finally中执行了client.dispatcher().finished(this),可以得知是先获取到了Dispatcher对象,然后再Dispatcher中调用了finished方法。我们进入finished方法中,代码如下:

 private  void finished(Deque calls, T call, boolean promoteCalls) {
    int runningCallsCount;
    Runnable idleCallback;
    synchronized (this) {
      //不管是同步还是异步,执行完之后都要移除
      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();
    }
  } 

finished方法中先是将此次请求从队列中移除,然后调用了promoteCalls方法。接下来看一下promoteCalls方法中的具体逻辑。

private void promoteCalls() {
    // 如果正在运行的任务已满,直接return
    if (runningAsyncCalls.size() >= maxRequests) return; // Already running max capacity.
    // 等待队列中没有人物,直接return
    if (readyAsyncCalls.isEmpty()) return; // No ready calls to promote.
    // 遍历等待缓存的异步缓存队列
    for (Iterator i = readyAsyncCalls.iterator(); i.hasNext(); ) {
      AsyncCall call = i.next();
      // 同一Host的请求不大于5个
      if (runningCallsForHost(call) < maxRequestsPerHost) {
        //取出并移除
        i.remove();
        //将readyAsyncCalls取出的请求添加到正在执行的请求队列中
        //这里就把等待队列中的请求添加到了执行队列中
        runningAsyncCalls.add(call);
        //交给线程池来处理请求
        executorService().execute(call);
      }

      if (runningAsyncCalls.size() >= maxRequests) return; // Reached max capacity.
    }
  } 

promoteCalls中关键的一点是从runningCallsCount中取出下一个请求,然后添加到runningAsyncCalls中,最后交由线程池来处理。所以前面我们提的问题 添加到等待队列中的任务什么时候会被执行? 也有了答案,请求就是在这里执行的。

最后我们来看一下runningCallsCout()方法,runningCallsCount()方法重新计算了正在执行的线程数量,方法很简单,就是获取了当前正在执行的同步请求数量和异步请求数量。

public synchronized int runningCallsCount() {
  return runningAsyncCalls.size() + runningSyncCalls.size();
} 

3、总结

最后使用网上找的一张图来总结下异步请求流程,可以对着这张图来分析源码。

“深入交流“系列:Okhttp(一)请求流程解析_第3张图片

Okhttp请求流程的介绍到这里就结束了,如有需要改正的地方,还请各位BaBa及时指正并多多包涵。

最后

按照国际惯例,给大家分享一套十分好用的Android进阶资料:《全网最全Android开发笔记》。

整个笔记一共8大模块、729个知识点,3382页,66万字,可以说覆盖了当下Android开发最前沿的技术点,和阿里、腾讯、字节等等大厂面试看重的技术。

图片

图片

因为所包含的内容足够多,所以,这份笔记不仅仅可以用来当学习资料,还可以当工具书用。

如果你需要了解某个知识点,不管是Shift+F 搜索,还是按目录进行检索,都能用最快的速度找到你要的内容。

相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照整个知识体系编排的。

(一)架构师必备Java基础

1、深入理解Java泛型

2、注解深入浅出

3、并发编程

4、数据传输与序列化

5、Java虚拟机原理

6、高效IO

……

图片

(二)设计思想解读开源框架

1、热修复设计

2、插件化框架设计

3、组件化框架设计

4、图片加载框架

5、网络访问框架设计

6、RXJava响应式编程框架设计

……

图片

(三)360°全方位性能优化

1、设计思想与代码质量优化

2、程序性能优化

  • 启动速度与执行效率优化
  • 布局检测与优化
  • 内存优化
  • 耗电优化
  • 网络传输与数据储存优化
  • APK大小优化

3、开发效率优化

  • 分布式版本控制系统Git
  • 自动化构建系统Gradle

……

图片

(四)Android框架体系架构

1、高级UI晋升

2、Android内核组件

3、大型项目必备IPC

4、数据持久与序列化

5、Framework内核解析

……

图片

(五)NDK模块开发

1、NDK开发之C/C++入门

2、JNI模块开发

3、Linux编程

4、底层图片处理

5、音视频开发

6、机器学习

……

图片

(六)Flutter学习进阶

1、Flutter跨平台开发概述

2、Windows中Flutter开发环境搭建

3、编写你的第一个Flutter APP

4、Flutter Dart语言系统入门

……

图片

(七)微信小程序开发

1、小程序概述及入门

2、小程序UI开发

3、API操作

4、购物商场项目实战

……

图片

(八)kotlin从入门到精通

1、准备开始

2、基础

3、类和对象

4、函数和lambda表达式

5、其他

……

图片

好啦,这份资料就给大家介绍到这了,有需要详细文档的小伙伴,可以微信扫下方二维码免费领取哈~

图片

你可能感兴趣的:(java,android,网络协议)