OkHttp原理分析小结

 更新时间:2024年01月17日 14:32:00   作者:Young丶  
OkHttp 是 Square 公司开源的一款网络框架,封装了一个高性能的 http 请求库,本文对OkHttp原理给大家详细讲解,感兴趣的朋友跟随小编一起看看吧

Okhttp 介绍

OkHttp 是 Square 公司开源的一款网络框架,封装了一个高性能的 http 请求库。

https://github.com/square/okhttp

特点

  • 支持 spdy、http2.0、websocket 等协议
  • 支持同步、异步请求
  • 封装了线程池,封装了数据转换,提高性能。
  • 在 Android 6.0 中自带的网络请求 API 的底层就是使用了 okhttp 来进行的
  • 使用 okhttp 比较接近真正的 HTTP 协议的框架

这个类主要是用来配置 okhttp 这个框架的,通俗一点讲就是这个类是管理这个框架的各种设置的。

Call 类的工厂,通过 OkHttpClient 才能得到 Call 对象。

Okhttp 中几个重要类的介绍

OkHttpClient

这个类主要是用来配置 okhttp 这个框架的,通俗一点讲就是这个类是管理这个框架的各种设置的。

Call 类的工厂,通过 OkHttpClient 才能得到 Call 对象。

OkHttpClient使用注意

OkHttpClient 应该被共享,使用 okhttp 这个框架的时候,最好要将 OkHttpClient 设置成单例模式,所有的 HTTP 在进行请求的时候都要使用这一个 Client 。因为每个 OkHttpClient 都对应了自己的连接池和线程池。减少使用连接池和线程池可以减少延迟和内存的使用。相反的如果每个请求都创建一个 OkHttpClient 的话会很浪费内存资源。

OkHttpClient的创建

OkHttpClient 有三个创建方法

第一个方法:直接使用 new OkHttpClient() 来创建一个实例对象就可以了,这个实例对象有默认的配置。默认请求连接超时时间 10 s ,读写超时时间 10 s,连接不成功会自动再次连接。

第二个方法:就是通过 Builder的方式来自己定义一个 OkHttpclient 。当然如果你直接 build 没有自己配置参数的话,效果和第一个方法是一样的。

public final OkHttpClient = new OkHttpClient.Builder()
  .addInterceptor(new HttpLoggingInterceptor())
  .cache(new Cache(cacheDir,cacheSize))
  .等等配置
  .build();

第三个方法:就是通过已有的 OkHttpClient 对象来复制一份共享线程池和其他资源的 OkHttpClient 对象。

OkHttpClient agerClient = client.newBuilder()
  .readTimeout(500,TimeUnit.MILLSECONS)
  .build();

这种方法的好处就是,当我们有一个特殊的请求,有的配置有点不一样,比如要求连接超过 1 s 就算超时,这个时候我们就可以使用这个方法来生成一个新的实例对象,不过他们共用很多其他的资源,不会对资源造成浪费。

关于 OkHttpClient 的配置改变都在 Builder 中进行

不需要了可以关闭

其实持有的线程池和连接池将会被自定释放如果他们保持闲置的话。

你也可以自动释放,释放后将来再调用 call 的时候会被拒接。

client.dispatcher().excurorService().shutdown()

清除连接池,注意清除后,连接池的守护线程可能会立刻退出。

client.connectionPool().evictAll()

如果 Client 有缓存,可以关闭。注意:再次调用一个被关闭的 cache 会发生错误。也会造成 crash。

client.cache().close();

OkHttp 在 HTTP/2 连接的时候也会使用守护线程。他们闲置的时候将自动退出。

知道有这么一回事就行,一般不会主动调用。

Call 类

Call 这个类就是用来发送 HTTP 请求和读取 HTTP 响应的一个类

image-20221108142020997

这个类的方法很少,从上到下依次是:放弃请求、异步执行请求、同步执行请求。

Request 类

这个类就是相当于 http 请求中的请求报文,是用来表达请求报文的,所以这里可以设置请求的 url、请求头、请求体等等和请求报文有关的内容。

主要方法罗列:

// 获取请求 url
public HttpUrl url();
// 获取请求方法类型
public String method();
// 获取请求头
public Headers headers();
//获取请求体
public RequestBody body();
// 获取 tag
public Object tag();
// 返回缓存控制指令,永远不会是 null ,即使响应不包含 Cache-Control 响应头
public CacheControl cacheControl();
// 是否是 https 请求
public boolean isHttps();
// Resquest{method=" ",url=" ",tag = " "}
public String toString();

image-20221108142440493

这是它的 Builder 中提供的方法,只设置 .url() 的时候默认是 post 请求。

RequestBody

介绍完请求报文就要介绍请求体了,这都是和 http协议紧密联系的。

RequestBody 就是用来设置请求体的,它的主要方法就是下面这个几个静态方法,用来生成对应的请求体:

在这里插入图片描述

就是通过这几个方法来产生对应的不同的请求体。MediaType 是用来描述请求体或者响应体类型的。比如请求体类型是 json 串格式的,那对应的 MediaType 就是MediaType.parse("application/json; charset=utf-8"); ,如果上传的是文件那么对应的就是 application/octet-stream,还有几个常用的类型 text/plain imge/png text/x-markdown 等等。

它还有两个子类:

在这里插入图片描述

FormBody 这个请求体是我们平时最常用的,就是我们平时使用 post 请求的时候,参数是键值对的形式。就是使用这个请求体最简单了。

说深一点,对应的请求报文是:

POST /test HTTP/1.1   请求行
Host: 32.106.24.148:8080  下面都是请求头
Content-Type: application/x-www-form-urlencoded 用于指明请求体的类型。
User-Agent: PostmanRuntime/7.15.0
Accept: */*
Cache-Control: no-cache
Postman-Token: 954bda0d-dbc2-4193-addf-a7631cab2cfa,5ba2ebed-90b4-4f35-bcf5-80c4777de471
Host: 39.106.24.148:8080
accept-encoding: gzip, deflate
content-length: 133
Connection: keep-alive
cache-control: no-cache

key0=value0&key1=value1  请求体(也是我们的参数)

这是发送的原始的报文格式,用代码实现的话就是

// 创建客户端
OkHttpClient client = new OkHttpclient();
// 建立请求体 
FormBody formBody = new FormBody.Builder()
                    .add("key0", "value0")
                    .add("key1","value1")
                    .build();
// 建立请求报文
Request request = new Request.Builder
                                .post(formBody)
                                .url("请求url")
                                .addHeader("Content-Type", "application/x-www-form-urlencoded")
  .addHeader("User-Agent", "PostmanRuntime/7.15.0")
  .addHeader("Accept", "*/*")
  .addHeader("Cache-Control", "no-cache")
  .addHeader("Postman-Token", "954bda0d-dbc2-4193-addf-a7631cab2cfa,af7c027c-a7ba-4560-98ae-3a2a473ab88a")
  .addHeader("Host", "39.106.24.148:8080")
  .addHeader("accept-encoding", "gzip, deflate")
  .addHeader("content-length", "133")
  .addHeader("Connection", "keep-alive")
  .addHeader("cache-control", "no-cache")
  .build();
// 发起请求
client.newCall(request).excute();

上面是使用了 FormBody 的形式,如果使用 RequestBody 的话就要更麻烦一些。

OkHttpClient client = new OkHttpClient();
MediaType mediaType = MediaType.parse("application/x-www-form-urlencoded");
RequestBody body = RequestBody.create(mediaType, "key0=value0&key1=value1");
Request request = new Request.Builder()
  .url("http://39.106.24.148:8080/test")
  .post(body)
  .addHeader("Content-Type", "application/x-www-form-urlencoded")
  .addHeader("User-Agent", "PostmanRuntime/7.15.0")
  .addHeader("Accept", "*/*")
  .addHeader("Cache-Control", "no-cache")
  .addHeader("Postman-Token", "954bda0d-dbc2-4193-addf-a7631cab2cfa,af7c027c-a7ba-4560-98ae-3a2a473ab88a")
  .addHeader("Host", "39.106.24.148:8080")
  .addHeader("accept-encoding", "gzip, deflate")
  .addHeader("content-length", "133")
  .addHeader("Connection", "keep-alive")
  .addHeader("cache-control", "no-cache")
  .build();
Response response = client.newCall(request).execute();

当然平时我们使用的时候,不用拼上这么多的请求头,我这样写的目的就是为了更加还原请求报文。

还有一个子类 MultipartBody这个可以用来构建比较复杂的请求体。

1995 年 Content-Type 的类型扩充了 multipart/form-data 用来支持向服务器发送二进制数据。如果一次提交多种类型的数据,比如:一张图片和一个文字,这个时候引入了 boundaryboundary使得 POST 可以满足这种提交多种不同的数据类型。通过 boundary 可以实现多个不同类型的数据同时存在在一个 Request 中。两个 boundary之间就是一个类型的数据,并且可以重新设置 Content-Type

与 HTML 文件上传形式兼容。每块请求体都是一个请求体,可以定义自己的请求头。这些请求头可以用来描述这块请求。例如,他们的 Content-Disposition。如果 Content-Length 和 Content-Type 可用的话,他们会被自动添加到请求头中。

来看一下这种类型的请求报文是什么样的:

POST /web/UploadServlet HTTP/1.1
Content-Type: multipart/form-data; boundary=e1b05ca4-fc4e-4944-837d-cc32c43c853a
Content-Length: 66089
Host: localhost.tt.com:8080
Connection: Keep-Alive
Accept-Encoding: gzip
User-Agent: okhttp/3.5.0

–e1b05ca4-fc4e-4944-837d-cc32c43c853a
Content-Disposition: form-data; name=”file”; filename=”**.png”
Content-Type: image/png
Content-Length: 65744

fdPNG
IHDR�0B7M�iM�M�CCPIM�CC ProfileH��……………………IEND�B`�
–e1b05ca4-fc4e-4944-837d-cc32c43c853a
Content-Disposition: form-data; name=”comment”
Content-Length: 30

上传一个图
–e1b05ca4-fc4e-4944-837d-cc32c43c853a–

第一个数据是一张 png 的图,重新设置了 Content-Type:image/png 中间的乱码就是图片的数据。这一堆数据前有一个空行,表示上下分别是请求头、请求体。

第二个数据,就是一个文本数据。

这样它们一起构成了请求体。

讲起来可能比较复杂,就记住,当既需要上传参数,又需要上传文件的时候用这种请求体。

MediaType mediaType = MediaType.parse("image/png");
        RequestBody requestBody = new MultipartBody.Builder()
                    // 需要设置成表单形式否则无法上传键值对参数
                .setType(MultipartBody.FORM)
                .addPart(Headers.of("Content-Disposition", "form-data;name=\"title\""),
                        RequestBody.create(null, "Square Logo"))
                .addPart(
                        Headers.of("Content-Disposition", "form-data;name=\"imge\""),
                        RequestBody.create(mediaType, new File("路径/logo.png"))
                ).
                        build();
        Request request = new Request.Builder()
                .post(requestBody)
                .url("https://api.imgur.com/3/image")
                .build();
        try {
            mOkHttpClient.newCall(request).execute();
        } catch (IOException e) {
            e.printStackTrace();
        }

简化写法:

MediaType mediaType = MediaType.parse("image/png");
        RequestBody requestBody = new MultipartBody.Builder()
                 .setType(MultipartBody.FORM)
               .addFormDataPart("title","logo")
                .addFormDataPart("img","logo.png",RequestBody.create(mediaType,new File("路径/logo.png")))
                .build();

Content-Disposition 可以用在消息体的子部分中,用来给出其对应字段的相关信息。作为 multipart body 中的消息头,第一个参数总是固定不变的 form-data; 附加的参数不区分大小写,并且拥有参数值,参数名与参数值用等号连接,参数之间用分号分隔。参数值用双引号括起来

// 比如这样,就是这种固定的格式
"Content-Disposition","form-data;name=\"mFile\";filename=\"xxx.mp4\""

到这里关于请求的几个重要的类就讲完了。

总结一下

只要掌握 http 请求的原理,使用起 okhttp 来也就不是什么问题了。

首先 OkHttpClient 是用来设置关于请求工具的一些参数的,比如超时时间、是否缓存等等。

Call 对象是发起 Http 请求的对象,通过 Call 对象来发起请求。

发起请求的时候,需要有请求报文,Request 对象就是对应的请求报文,可以添加对应的请求行、请求头、请求体。

说起请求体就是对应了 RequestBody 了。然后这个网络请求过程就完成了!

OKHTTP架构图

OKHTTP架构图

OKHttp发送主体流程

image-20221108145253547

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

同时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;
}

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

  • Call: 每一个请求的实例,比如登录login 对应一个Call、获取用户信息 对应一个Call。Call本身就是一个接口,用户的每一个Http请求就是一个Call实例,而且每一个Call都对应一个线程。
  • Call包含了 request()、execute()、enqueue() 方法。
  • RealCall: 具体的Call接口实现类,代表每一个HTTP请求。每一个RealCall内部有一个AsyncCall final类。
  • AsyncCall: RealCall类的内部final类,实现了NamedRunnable类的execute()。继承于NamedRunnable类,NamedRunnable类实现了Runnable接口,并且有一个execute()抽象方法,这个抽象方法在Runnable的run()里执行。
  • Dispatcher:
    • OkHttp的任务队列,其内部维护了一个线程池,进行线程分发,实现非阻塞,高可用,高并发。
    • 当有接收到一个Call时,Dispatcher负责在线程池中找到空闲的线程并执行其execute方法。
    • Okhttp采用Deque作为缓存队列,按照入队的顺序先进先出。
    • OkHttp最出彩的地方就是在try/finally中调用了finished函数,可以主动控制等待队列的移动,而不是采用 锁或者wait/notify,极大减少了编码复杂性。

分发器

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.
	}
}

请求流程

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

@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

1.可扩展性高。类似于缓存,Dns,请求/连接/响应超时时间等等都可以通过配置传入,甚至线程池都可以根据自己的需求来配置。
2.OKHttp使用了连接池缓存,提高通信效率。
3.责任链五层拦截器模式,每层功能清晰明了,并且提供了两层可扩展的拦截器方便进行所需要的改造。
4.层次结构清晰,方便进行问题的排查。
5.观察者模式的充分使用,查看请求状态和监控请求状态变得十分简单。
6.使用了OKIO框架进行数据的处理,效率和安全性上更高。

到此这篇关于OkHttp原理分析总结的文章就介绍到这了,更多相关OkHttp原理内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • Flutter Navigator路由传参的实现

    Flutter Navigator路由传参的实现

    本文主要介绍了Flutter Navigator路由传参的实现,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-04-04
  • Android自定义StickinessView粘性滑动效果

    Android自定义StickinessView粘性滑动效果

    这篇文章主要为大家详细介绍了Android自定义StickinessView粘性滑动效果的相关资料,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2017-03-03
  • Android编程中黑名单的实现方法

    Android编程中黑名单的实现方法

    这篇文章主要介绍了Android编程中黑名单的实现方法,结合实例形式详细分析了Android通过对比通信录及自动挂断电话等技巧实现黑名单功能的功能,需要的朋友可以参考下
    2016-02-02
  • 查看apk签名信息方法

    查看apk签名信息方法

    用shell写了一个查看apk签名的脚本,下面与大家分享下如何查看apk签名信息,感兴趣的朋友可以了解下哈
    2013-06-06
  • 如何正确使用Android线程详解

    如何正确使用Android线程详解

    线程是程序员进阶的一道重要门槛。除了了解各类开线程的API之外,更需要理解线程本身到底是个什么样的存在,并行是否真的高效?系统是怎么样去调度线程的?开线程的方式那么多,什么样的姿势才正确?下面通过本文来好好再学习下。
    2016-08-08
  • Android Flutter实现自定义下拉刷新组件

    Android Flutter实现自定义下拉刷新组件

    在Flutter开发中官方提供了多平台的下拉刷新组件供开发者使用。本文将改造一下这些组件,实现自定义的下拉刷新组件,感兴趣的可以了解一下
    2022-08-08
  • Android studio实现简易的计算器功能

    Android studio实现简易的计算器功能

    这篇文章主要为大家详细介绍了Android studio实现简易的计算器功能,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-05-05
  • Android Scroller及下拉刷新组件原理解析

    Android Scroller及下拉刷新组件原理解析

    这篇文章主要为大家详细解析了Android Scroller及下拉刷新组件原理,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2017-01-01
  • Android中Snackbar的使用方法及小技巧

    Android中Snackbar的使用方法及小技巧

    这篇文章主要给大家介绍了关于Android中Snackbar的使用方法及小技巧的相关资料,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面来一起学习学习吧。
    2018-03-03
  • Android 使用SharePerference判断是否为第一次登陆的实现代码

    Android 使用SharePerference判断是否为第一次登陆的实现代码

    很多app中在第一次安装登陆时会有引导欢迎界面,第二次打开时就不再显示引导页面。这个怎么实现呢?下面小编给大家介绍下使用SharePerference判断是否为第一次登陆的实现代码,需要的的朋友参考下吧
    2017-03-03

最新评论