1 | public class MainActivity extends Activity { |
这样可以看到更新成功了,且没有抛出异常. 子线程能直接更新UI ? 其实是不能。
因为Android的UI控件不是线程安全的,多个线程并发访问可能会导致UI 控件处于不可预期的状态,那既然这样,为什么不加锁呢?
缺点: 加锁会让UI访问的逻辑变的复杂,也会降低UI 的访问效率,因为锁的机制会阻塞某些线程的执行. 所有就采用单线程的方式来更新UI
1 | void checkThread() { |
但是上面为什么可以更新UI呢?
因为执行速度, 因为 ViewRootImpl 这个时候还没创建,这是在调用了 onResume 之后才创建的, ViewRootImpl关于UI 的操作都会 checkThread 如果不在主线程就会抛出异常。所以上面更新的时候还没执行到 checkThread 方法。
作为一个Android 开发,我们肯定会想到 Handler ,下面是一个最简单的但是不太规范的示例,这样我们就可以在子线程中做了处理然后更新 UI 了。
1 |
|
首先看下构造方法 我们最常用的 new handler方法做了什么
1 | public Handler() { |
根据上面的构造方法可以看出来, 我们在创建Handler 时,如果不指定 callback 时,会默认为空, 如果没有指定 Looper 时,系统会自动 通过 Looper.myLooper() 帮我们指定当前线程的 Looper 。 如果 looper 对象为空,就会抛出异常。
我们看下 Looper.myLooper() 是怎么实现的呢
1 | public static Looper myLooper() { |
根据方法和资料我们可以知道, ThreadLocal 是所属与线程的,使用ThreadLocal创建的变量只能被当前线程访问,其他线程则无法访问和修改。
根据经验,有get 肯定有set 的地方,我们看下 Looper 中的 ThreadLocal 的set 的地方
1 | public static void prepare() { |
根据上面可以看到, 一个线程中最多只能有一个 Looper ,在想使用到 Looper 的线程 中调用 prepare 方法就可以创建出 Looper了, 在 new handler 时就不会出现 looper 为空的情况了
但是有没有发现,我们在主线程就是没有调用 prepare 方法呀, 使用的时候也没有报错。这又是怎么回事呢?
通过看App 启动流程的代码可以发现, 在 ActivityThread 类中的 main 方法 这个类中的main方法就是整个App 的主线程的执行入口
在这个方法中,通过调用 Looper.prepareMainLooper() 去初始化了主线程的 looper
1 |
|
可以看到 prepare(false); 创建了一个不可以退出的 looper。
然后 main 方法中还通过调用 Looper.loop() 开启主线程的循环。
直接看loop方法到底做了什么
1 | public static void loop() { |
中间省略部分源码, 跟着我们上面的分析,因为主线程在 App 初始化时在程序的住入口已经初始化过Looper 和开启了 loop 循环, 内容就是 从当前线程也就是主线程 的looper 关联的 MessageQueue 里不停的死循环 取出 Message 消息, 如果有消息就调用 msg.target.dispatchMessage(msg); 分发消息。
处理消息。消息分发 但是这个 msg.target 又是什么东西呢
1 | 经过看源码 msg.target 的赋值是在这做的 Message 类中 |
msg.target其实就是初始化的handler,然后调用Handler的dispatchMessage();
从消息池中取出消息,如果没有的话就直接new一个Message对象,所以我们在写项目创建Message对象的时候尽量用handle.obtainMessage(),不要直接new Message(),复用会比较好。
知道 msg.target 就是 handler 了,那看下 handler 的 dispatchMessage 方法
1 |
|
1 |
|
MessageQueue的 next 方法做了什么操作呢
1 | Message next() { |
也是死循环取消息 同一线程在同一时间只能处理一个消息,同一线程代码执行是不具有并发性,所以需要队列来保存消息和安排每个消息的处理顺序。
多个其他线程往UI线程发送消息,UI线程必须把这些消息保持到一个列表(它同一时间不能处理那么多任务),然后挨个拿出来处理,每一个Looper线程都会维护这样一个队列,而且仅此一个,这个队列的消息只能由该线程处理。
Message 就没有太多可以说的,它就是一个消息的载体,用来保存消息的。
常用的方法
1 |
|
主线程的死循环一直运行是不是特别消耗 CPU 资源呢? 其实不然,这里就涉及到 Linux pipe/epoll机制,简单说就是在主线程的 MessageQueue 没有消息时,便阻塞在 loop 的 queue.next() 中的 nativePollOnce() 方法里,此时主线程会释放CPU资源进入休眠状态,直到下个消息到达或者有事务发生,通过往pipe管道写端写入数据来唤醒主线程工作。
这里采用的 epoll 机制,是一种 IO 多路复用机制,可以同时监控多个描述符,当某个描述符就绪(读或写就绪),则立刻通知相应程序进行读或写操作,本质同步 I/O ,即读写是阻塞的。 所以说,主线程大多数时候都是处于休眠状态,并不会消耗大量 CPU 资源。
其实这也是整个 Android 系统的做法,App 启动,然后就进入死循环,如果没有消息,就阻塞在哪些,AMS, WMS 等等 会通过binder抛过来一些消息,然后执行 onCreate 之类的方法,Activity 的生命周期的方法都是 msg,有消息过来就执行
1 |
|
可以看到代码 如果当前线程是主线程, 直接调用 Runnable 的run 方法,去执行, 如果不是主线程, 则调用 mHandler 的 post 方法 ,上面我们已经分析过 post 方法的原理了。 上面这个 mHandler 就是 Activity 的 Handler 也就是主线程的 Handler ,发送到主线程执行这个 Runnable。
根据上面的分析,我们在子线程中要使用handler 发送消息的话, 需要 手动在子线程的Handler 创建之前,调用 Looper.prepare 创建一个looper 来跟当前线程关联, 然后在创建完成 handler之后 调用 Looper.loop() 开启消息循环, 然后其他线程就可以通过这个线程创建出的 handler 往这个线程发送消息了。
Handler 机制是现在各个公司面试必问的问题,掌握 Handler 原理对我们日常开发工作也是非常有帮助的。代码量也不大,比较好懂,作为一个 Android 开发工程师非常有必要掌握这些知识点。 欢迎交流学习。
本文的目录大致是这样:
首先要在 gradle 中加上
1 | compile 'com.zhy:okhttputils:2.6.2' |
将直接使用okhttp默认的配置生成OkhttpClient,如果你有任何配置,记得在Application中调用initClient方法进行设置。
1 | public class MyApplication extends Application |
在具体的使用过程中
GET 请求
1 | String url = "http://www.csdn.net/"; |
Post JSON
1 | OkHttpUtils |
能看出常用的GET ,POST 请求写起来非常简单流畅, 并且网络回调直接到主线程中了,可以直接处理数据。
我们通过这个get 请求,进去到源码中看下是如何封装的。
1 | OkHttpUtils.get().url("http://www.baidu.com").build().execute(new com.zhy.http.okhttp.callback.Callback() { |
1 |
|
根据代码 能看出 OkHttpUtils 做了双重锁定的单例处理,因为一个App有一个 OkHttpClient 对象就行了。new 出了一个 OkHttpClient 。
我们在Application中初始化后,就生成这样一个对象, 以后每次用的时候就取得这个,无需重复创建。
实例化 OkHttpUtils 时 会创建 mPlatform = Platform.get() 。我们看下这是什么。
1 | private static final Platform PLATFORM = findPlatform(); |
此外在OkHttpUtils的构造方法中可以注意到有一个mPlatform的变量,他会根据当前是Android还是其他平台的不同被初始化为Android主线程或者普通线程池。
当是 Android 系统,new 了一个Android ,里面一个内部类,实现了 Executor, 创建了一个 handler 传入 Looper.getMainLooper() 主线程的 Looper,线程池执行时在这把可执行的 runnable 发送到主线程, 然后执行,就实现了线程切换。
它的功能就是实现线程之间的切换的。
1 | //GetRequest |
使用 get请求 new GetBuilder() 创建了一个继承 OkHttpRequest 的 GetRequest,配置需要的参数。
然后 看下 execute 的具体内容
1 | public void execute(Callback callback) |
1 |
|
来看看处理返回成功的回调方法
1 | public void sendSuccessResultCallback(final Object object, final Callback callback, final int id) |
是不是有点眼熟 mPlatform 其实就是上面的那个 Android , 把这个 runnable 发送到主线程。
在本文中,okhttputils将初始化OkHttpClient的动作提取出来,这样同一个应用只需要在最开始的时候配置一下诸如网络超时、cookie等既可。
在具体的实现中,通过OkHttpRequestBuilder收集网络请求的属性并传递给OkHttpRequest,在其子类中按照不同的需要实现生成Request的方法。
OkHttpRequestBuilder的build()方法会生成RequestCall对象,RequestCall对象的execute()方法会调用OkHttpRequestBuilder对象的generateRequest()方法产生Request,并据此产生Call对象,最后通过该Call对象的enqueue方法执行网络请求。
至此 OkHttpUtils 的源码分析完成了,其实代码相对简单,就是对 OkHttp 的一个封装, 少写了一些代码, 把常用的 get , post ,postString,postFile,head,put, delete 都进行了一个封装,调用起来非常方便。
并且网络请求的返回内容也回调到主线程,方便进行 UI 操作,
这也解决了上一个篇文章中说的 OkHttp 的两个缺点。
]]>本文的目录大致是这样:
在 gradle 中添加依赖
1 | compile 'com.squareup.okhttp3:okhttp:3.5.0' |
1.首先创建OkHttpClient
1 | OkHttpClient client = new OkHttpClient(); |
2.构造Request对象
1 | Request request = new Request.Builder() |
3.将Request封装为Call
1 | Call call = client.newCall(request); |
4.根据需要调用同步或者异步请求方法
1 | //同步调用,返回Response,会抛出IO异常 |
同步调用会阻塞主线程,一般不用
异步调用的回调函数是在子线程,我们不能在子线程更新UI,需要借助于runOnUiThread()方法或者Handler来处理
post 也是类似的, 相信大家都会用使用,接下来我们来看重头戏-源码。

首先来看,我们进行网络请求时使用的方法
1 | Call call = okHttpClient.newCall(request); |
实际上的 Call 的 enqueue 调用的是 RealCall的 enqueue方法
1 | call.enqueue(new ...); |
下面我们看下 RealCall的 enqueue是如何实现的
1 | public void enqueue(Callback responseCallback) { |
可以看到最终的请求处理是 dispatcher 来完成的,接下来看下 dispatcher
1 | //最大并发请求书 |
Dispatcher 有两个构造方法,可以自己指定线程池, 如果没有指定, 则会默认创建默认线程池,可以看到核心数为0,缓存数可以是很大, 比较适合执行大量的耗时比较少的任务。
接着看 enqueue是如何实现的
1 |
|
当正在运行的异步请求队列中的数量小于64, 并且 正在运行的请求主机数小于5,把请求加载到runningAsyncCalls 中并在线程池中执行, 否则就加入到 readyAsyncCalls 进行缓存等待。
runningCallsForHost是如何实现的呢
1 |
|
正在执行的网络请求中 同一个host最多只能是5个。
上面可以看到传递进来的是 AsyncCall 然后 execute 那我们看下 AsyncCall方法
1 | final class AsyncCall extends NamedRunnable { |
看到 NamedRunnable 实现了 Runnable,AsyncCall 中的 execute 是对网络请求的具体处理。
1 | Response response = getResponseWithInterceptorChain(); |
能明显看出这就是对请求的处理,在看它的具体实现之前先看下 client.dispatcher().finished 的方法实现。
1 | /** Used by {@code AsyncCall#run} to signal completion. */ |
由于 promoteCalls 是true 我们看下 promoteCalls 的方法实现
1 |
|
根据代码可以明显看出 , 当一个请求结束了调用 finished 方法,最终到promoteCalls就是把 异步等待队列中的请求,取出放到 异步执行队列中。
接着看 RealCall 的 getResponseWithInterceptorChain 方法
1 |
|
看下 RealInterceptorChain 的实现
1 |
|
根据上面的代码 我们可以看出,新建了一个RealInterceptorChain 责任链 并且 index+1,然后 执行interceptors.get(index); 返回Response。
责任链中每个拦截器都会执行chain.proceed()方法之前的代码,等责任链最后一个拦截器执行完毕后会返回最终的响应数据,而chain.proceed() 方法会得到最终的响应数据,这时就会执行每个拦截器的chain.proceed()方法之后的代码,其实就是对响应数据的一些操作。
接下来看下各个拦截器的具体代码
1 | public Response intercept(Chain chain) throws IOException { |
当发生 RouteException 和 IOException 都会进行 recover 重试。
1 | @Override public Response intercept(Chain chain) throws IOException { |
能看出 BridgeInterceptor 主要做的就是
在请求发出之前 把请求的 信息拿出来处理成Request.Builder.header 发送出去
当请求结果回来之后,处理header 信息。处理返回的信息。
缓存拦截器
1 | @Override public Response intercept(Chain chain) throws IOException { |
如果用户自己配置了缓存拦截器,cacheCandidate = cache.Response 获取用户自己存储的Response,否则 cacheCandidate = null;同时从CacheStrategy 获取cacheResponse 和 networkRequest
如果cacheCandidate != null 而 cacheResponse == null 说明缓存无效清除cacheCandidate缓存。
如果networkRequest == null 说明没有网络,cacheResponse == null 没有缓存,返回失败的信息,责任链此时也就终止,不会在往下继续执行。
如果networkRequest == null 说明没有网络,cacheResponse != null 有缓存,返回缓存的信息,责任链此时也就终止,不会在往下继续执行。
然后
执行下一个拦截器,也就是请求网络
责任链执行完毕后,会返回最终响应数据,如果缓存存在更新缓存,如果缓存不存在加入到缓存中去。
1 | @Override public Response intercept(Chain chain) throws IOException { |
连接复用的逻辑就是这里面, 寻找可用的链接, 复用, 这个待会分析。
这个是自定义的网络拦截器
1 | @Override public Response intercept(Chain chain) throws IOException { |
OkhttpClient 实现了Call.Fctory,负责为Request 创建 Call;
RealCall 为Call的具体实现,其enqueue() 异步请求接口通过Dispatcher()调度器利用ExcutorService实现,而最终进行网络请求时和同步的execute()接口一致,都是通过 getResponseWithInterceptorChain() 函数实现
getResponseWithInterceptorChain() 中利用 Interceptor 链条,责任链模式 分层实现缓存、透明压缩、网络 IO 等功能;最终将响应数据返回给用户。
我们知道 OkHttp 支持5个并发 socket 连接,默认keepAlive 时间为5分钟。 那究竟是怎么做到的呢
在 ConnectInterceptor 中我们知道 newStream
1 | public HttpCodec newStream(OkHttpClient client, boolean doExtensiveHealthChecks) { |
从上面的分析,获取RealConnection的流程,总结如下:
在ConnectInterceptor中获取StreamAllocation的引用,通过StreamAllocation去寻找RealConnection
如果RealConnection不为空,那么直接返回。否则去连接池中寻找并返回,如果找不到直接创建并设置到连接池中,然后再进一步判断是否重复释放到Socket。
在实际网络连接connect中,选择不同的链接方式(有隧道链接(Tunnel)和管道链接(Socket))
把RealConnection和HttpCodec传递给下一个拦截器
在从连接池中获取一个连接的时候,使用了 Internal 的 get() 方法。Internal 有一个静态的实例,会在 OkHttpClient 的静态代码快中被初始化。我们会在 Internal 的 get() 中调用连接池的 get() 方法来得到一个连接。并且,从中我们明白了连接复用的一个好处就是省去了进行 TCP 和 TLS 握手的一个过程。因为建立连接本身也是需要消耗一些时间的,连接被复用之后可以提升我们网络访问的效率。
连接池的位于 ConnectionPool 中
1 | /** 空闲 socket 最大连接数 */ |
构造方法可以看到,空闲socket的最大连接数为5个,ConnectionPool是在 OkHttpClient 实例化时创建的。
1 | RealConnection get(Address address, StreamAllocation streamAllocation) { |
看下 put,get 方法,get 方法会遍历 connection 缓存列表, 当某个连接计数小于限制的大小,并且 request 的地址和缓存列表中此链接的地址完全匹配时, 则直接复用缓存列表中的 connection 作为request 的连接。
上面可以看到 put 方法会调用清理线程。
1 | private final Runnable cleanupRunnable = new Runnable() { |
cleanup方法的过程是 根据连接中的引用计数来计算空闲连接数和活跃连接数,,然后标记出空闲连接数。
如果空闲连接keepAlive 时间超过5分钟,或者空闲连接数超过5个,则从Deque 中移除次连接,
如果空闲连接数大于0,则返回此连接即将到期的时间,如果都是活跃连接,并大于0,则返回5分钟。 如果没有任何连接,则返回-1,
清除算法,使用类似GC中的引用计算算法,如果弱引用StreamAllocation列表为0,则表示空闲需要进行回收。
可以看出连接池复用的核心就是用 Deque
优点:
缺点:
所以综合上面的缺点,OkHttpUtils 及类似的 封装应用而生。下一篇我们来通过 OkHttpUtils源码解析 看下是如何封装并解决这些问题的。
目前已经有很多文章写volley源码解析的了,
为什么这么多呢? 可能是因为volley 的源码相对来说比较少, 逻辑相对简单,好读。
为什么我还要写volley源码解析呢? 和上面的问题的原因一样, 还有就是我也读了好几遍volley的源码了,每次读完后过段时间就会忘记一些,应了那句老话,“好记性不如烂笔头”,现在也不用纸笔了,直接电脑上敲出来,更方便。 我不一定写的比其他同学的高明,但是我会尽量写出我的理解,和现实工作内容联系起来书写。
本文的目录结构是:
Volley 是 Google 推出的 Android 异步网络请求框架和图片加载框架。在 Google I/O 2013 大会上发布。
特别适合数据量小,通信频繁的网络操作
1 | 在项目的gradle 文件中添加依赖 |
使用起来很简单,举一个StringRequest例子,其他的类似
1 |
|

首先我们看下请求队列的创建。
1 | RequestQueue queue = Volley.newRequestQueue(this); |
1 |
|
先判断是否有指定 stack 也就是实际请求的方式,如果有传进来,就用传的来指定的方式,
如果为空, 就按照sdk 版本创建相应的请求方式, sdk版本<9的使用 httpClient 请求
大于等于9的使用 httpUrlconnection 来请求,这是因为,httpUrlconnection在Android 2.2之前有bug。但是httpClient 没有 httpUrlconnection 性能好, Api简单,体积较小,压缩和缓存机制也可有效减少网络访问的流量, 而且httpclient 请求的方式 在Android6.0之后也直接从sdk 中直接去掉了。
1 | public RequestQueue(Cache cache, Network network, int threadPoolSize) { |
可以重点关注 ExecutorDelivery(new Handler(Looper.getMainLooper())))
接下来在线程切换地方法讲到它,这个是成功把子线程数据发送到主线程的关键
1 | 任务队列的启动方法 |
任务队列创建完成,等到request 加入,request的创建比较简单,可以根据自己的需求创建相应的request ,系统提供了 String ,Image, JsonObject ,jsonArray 等request,他们的区别主要是在 parseNetworkResponse 方法中根据不同的数据类型,进行相应数据类型的解析。
再看网络请求的 add 方法,就是把request 添加到 任务队列中
1 | public <T> Request<T> add(Request<T> request) { |
这个 mNetworkQueue 和 mCacheQueue 都是 PriorityBlockingQueue 优先级阻塞队列 现在根据情况分别加入到相应的调度线程等待执行
上面已经把request加入到缓存队列中了,接下来看下 CacheDispatcher 是如何处理这些request的。
1 |
|
能看到这个 CacheDispatcher 继承 Thread ,前面我们也看到start 方法中已经 调用了线程的 start() ,那我们看下run方法
1 |
|
能看到代码相当简洁, 就是一个 while (true) 死循环, 里面执行 processRequest方法,
1 | private void processRequest() throws InterruptedException { |
英文注释写的也特别清楚,就是从 BlockingQueue阻塞队列中取出,如果有数据就处理,没有数据就阻塞在这。
如果BlockQueue是空的,从BlockingQueue取东西的操作将会被阻断进入等待状态,直到BlockingQueue进了东西才会被唤醒。
同样,如果BlockingQueue是满的,任何试图往里存东西的操作也会被阻断进入等待状态,直到BlockingQueue里有空间才会被唤醒继续操作。
了解了 BlockingQueue 我们看下真正处理request的详细方法
1 |
|
看完了 缓存队列的执行方式, 再看下网络请求线程的执行,跟上面的方法方式类似,我们直奔processRequest看它是如何实现的。
1 | void processRequest(Request<?> request) { |
具体执行网络请求的是 BasicNetwork 的 performRequest 方法
1 |
|
我们可以再看下建立网络链接的部分,这个是httpUrlcinnection,
1 |
|
总结上面这个方法就是 把所有的请求头信息发送出去,如果有请求体,把请求体也发送过去, 等待服务端相应,响应后把返回值回传到上一个方法中处理。
接下来看下我们上面多次看到的 mDelivery.postResponse(request, response); 是怎么处理的
先看下构造方法
1 | public ExecutorDelivery(final Handler handler) { |
通过这种方式,mResponsePoster.execute 是得里面的runnable能够在主线程中得到执行,
1 | // If this request has canceled, finish it and don't deliver. |
最后调用 request 的 finish 方法,表示该请求已经执行结束了,同时,如果 ResponseDeliveryRunnable 的构造方法中的第三个参数 runnable 不为空,立即执行该 runnable 的 run 方法。
再看这个Stringrequest中的
1 |
|
在主线程 通过接口回调,回调到发起请求的位置,进行相应的处理。
优点:
缺点:
有时候我们整个项目在使用的volley 如果要替换成okhttp 的成本就比较高了, 那能不能方便简单的时候okhttp 的优秀的功能呢, 答案当然是可以的。
还记得这个吗? 最开始分析源码时,
1 |
|
如果我们传stack 进来,这个 stack 就是null 了,所以,我们只需要实现这个HttpStack 然后传进来就行了。
1 |
|
总体来看,volley 源码还是比较简单的,结构清晰,代码量少,对于想读源码的同学还是一个比较不错的选择。有兴趣的可以尝试下。 也希望看这篇文章的同学能从本文中受益,也欢迎与我交流学习。
下一篇是分析 okHttp 的源码,敬请期待。
]]>目前存在的两种网络分层模型:OSI模型和TCP/IP模型。OSI模型一共分为七层,TCP/IP模型和OSI模型类似,但是只分为四层。
TCP/IP模型分为四层:应用层(Application)、传输层(Host-to-Host Transport)、互联网层(Internet)、网络接口层(Network Interface)。
在TCP/IP模型中并不包含物理层。另外,两个重要的协议ARP(Address Resolution Protocol,地址解析协议)和RARP(Reverse Address Resolution Protocol,反向地址转换协议),在OSI模型中一般被认为是在位于第二层数据链路层和第三层网络层之间,而在TCP/IP模型中则位于网络接口层。
应用层:TFTP,HTTP,SNMP,FTP,SMTP,DNS,Telnet 等等
传输层:TCP,UDP
网络层:IP,ICMP,OSPF,EIGRP,IGMP
数据链路层:SLIP,CSLIP,PPP,MTU
每一抽象层建立在低一层提供的服务上,并且为高一层提供服务
注意:TCP 并不能保证数据一定会被对方接收到,因为这是不可能的。TCP 能够做到的是,如果有可能,就把数据递送到接收方,否则就(通过放弃重传并且中断连接这一手段)通知用户。因此准确说 TCP 也不是 100% 可靠的协议,它所能提供的是数据的可靠递送或故障的可靠通知。
所谓三次握手(Three-way Handshake),是指建立一个 TCP 连接时,需要客户端和服务器总共发送3个包。
三次握手的目的是连接服务器指定端口,建立 TCP 连接,并同步连接双方的序列号和确认号,交换 TCP 窗口大小信息。在 socket 编程中,客户端执行 connect() 时。将触发三次握手。
客户端发送一个 TCP 的 SYN 标志位置1的包,指明客户端打算连接的服务器的端口,以及初始序号 X,保存在包头的序列号(Sequence Number)字段里。
发送完毕后,客户端进入 SYN_SEND 状态。
服务器发回确认包(ACK)应答。即 SYN 标志位和 ACK 标志位均为1。服务器端选择自己 ISN 序列号,放到 Seq 域里,同时将确认序号(Acknowledgement Number)设置为客户的 ISN 加1,即X+1。 发送完毕后,服务器端进入 SYN_RCVD 状态。
客户端再次发送确认包(ACK),SYN 标志位为0,ACK 标志位为1,并且把服务器发来 ACK 的序号字段+1,放在确定字段中发送给对方,并且在数据段放写ISN的+1
发送完毕后,客户端进入 ESTABLISHED 状态,当服务器端接收到这个包时,也进入 ESTABLISHED 状态,TCP 握手结束。
客户端或服务器均可主动发起挥手动作,在 socket 编程中,任何一方执行 close() 操作即可产生挥手操作。
假设客户端想要关闭连接,客户端发送一个 FIN 标志位置为1的包,表示自己已经没有数据可以发送了,但是仍然可以接受数据。
发送完毕后,客户端进入 FIN_WAIT_1 状态。
服务器端确认客户端的 FIN 包,发送一个确认包,表明自己接受到了客户端关闭连接的请求,但还没有准备好关闭连接。
发送完毕后,服务器端进入 CLOSE_WAIT 状态,客户端接收到这个确认包之后,进入 FIN_WAIT_2 状态,等待服务器端关闭连接。
服务器端准备好关闭连接时,向客户端发送结束连接请求,FIN 置为1。
发送完毕后,服务器端进入 LAST_ACK 状态,等待来自客户端的最后一个ACK。
客户端接收到来自服务器端的关闭请求,发送一个确认包,并进入 TIME_WAIT状态,等待可能出现的要求重传的 ACK 包。
服务器端接收到这个确认包之后,关闭连接,进入 CLOSED 状态。
客户端等待了某个固定时间(两个最大段生命周期,2MSL,2 Maximum Segment Lifetime)之后,没有收到服务器端的 ACK ,认为服务器端已经正常关闭连接,于是自己也关闭连接,进入 CLOSED 状态。
UDP 是一个简单的传输层协议。和 TCP 相比,UDP 有下面几个显著特性:
Socket 是对 TCP/IP 协议族的一种封装,是应用层与TCP/IP协议族通信的中间软件抽象层。从设计模式的角度看来,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。
socket起源于UNIX,在Unix一切皆文件哲学的思想下,socket是一种”打开—读/写—关闭”模式的实现,服务器和客户端各自维护一个”文件”,在建立连接打开后,可以向自己文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。
最近在做项目过程中频繁使用列表,今天抽空总结下过程中遇到的问题,下面会有具体对应的解决办法;
Listview不能显示正常的条目,只显示一条或二条
这是因为:由于listView在scrollView中无法正确计算它的大小, 故只显示一行。
1 | WrapperListView.java: |
只需要在setAdapter之后调用如下方法即可:
1 | public void setListViewHeightBasedOnChildren(ListView listView) { |
这时最好给ListView之外嵌套一层LinearLayout,不然有时候这种方法会失效
可以确定的是:这种方式可以改变ListView的高度,但是,还有一个严重的问题就是listview的数据是可变动的,除非你能正确的写出listview的高度,否则这种方式就是个鸡肋。
如果只有数据 layout 完全没有必要嵌套 ScrollView 的.之说以需要嵌套,很多是在listView 头部或者底部多了一部分Layout ,这个时候可以尝试 listView 的 addHeadView() 尝试解决
比如有5组数据要填充到listView。listView会先调用onMeasure,此时会调用5次getView。然后才调用onLayout,此时又会调用5次getView,这样就重复了。所以导致多次调用getView方法
的确调用了三遍
解决办法:
ListView 的高度 从 wrap_content 改成 match_patent 或者一个固定值,就能减少getview 调用次数
或者重写 listView 的onMasure onLayout 方法 赋值 一个变量 检测 是测量还是layout 测量时不加载自己写的那段逻辑
在listview子布局里面写,可以解决焦点失去的问题
android:descendantFocusability=”blocksDescendants”
1.首先,虽然大家都知道,还是提一下,利用好 convertView 来重用 View,切忌每次 getView() 都新建。ListView 的核心原理就是重用 View。ListView 中有一个回收器,Item 滑出界面的时候 View 会回收到这里,需要显示新的 Item 的时候,就尽量重用回收器里面的 View。
2.利用好 View Type,例如你的 ListView 中有几个类型的 Item,需要给每个类型创建不同的 View,这样有利于 ListView 的回收,当然类型不能太多
Github Pages + Hexo 博客搭建,Next主题个性化修改 有很多链接,包括从创建到优化,还有视频教程。
Hexo博客主题安装及Next主题个性化修改 不得不说,这个对我帮助很大。
hexo - Next 主题添加评论功能 这个是给我们的网站加评论的, 我选择 Valine 评论系统 ,我希望可以匿名评论。
后续还有 seo 优化还没有做,这个会继续更新的。
]]>随着公司业务发展,项目越来越大,项目有好几个,项目虽然具体业务不同,但是其中也有很多相同的功能, 比如都有 weex 需求,网络请求、 图片处理、视频处理等等共同的需求,如果每个项目都重新写一遍,实现一遍,势必影响进度,重复造轮子的问题,项目进展缓慢,所以 就需要一种方式来避免这种重复劳动,彻底解决这种问题。
解决办法 : 使用组件化的思想, 把公共的组件抽离出来,和主项目的关系是依赖主项目的关系。module 是以 library的形式存在于整个project 中,依赖于主工程。在需要使用的时候,引入即可。
类似我们使用 recyclerview butterknife 之类的这些的功能, 这些内容有的是 Android官方提供的支持,有的是国内外优秀开发者开源出来的控件,我们可以很方便的集成到项目中。如下图:
目前有 jcenter() ,mavenCentral(), google() 等等仓库中心,这些仓库是用来保存代码的,组织或者个人开发者 开源出很多优秀的代码,工具,控件,上传到这些仓库中,我们可以通过 在 gradle 中配置相应的仓库地址 ,就可以方便使用相应的代码,
而内部使用的组件,不适合开源的可以自己搭建 私有仓库,内网使用,
还有一些需要借助代理访问,国内访问起来特别慢或者无法访问的内容,也可以上传到我们的私服上,能够很好地提高效率,减少同步等待时间
类似下图 我们在其中一个项目中使用的 仓库地址:
我们通过在 gradle 中配置相应的依赖,项目在 build 完成后就会将相应的 依赖工程代码 下载到 项目中, 可以在project 模式下 查看 External Libraries 看到实际下载下来的代码。
下面分别介绍下 这几个仓库,和私有仓库的搭建
网页地址 http://jcenter.bintray.com/
网页地址http://central.maven.org/maven2/
网页地址 https://dl.google.com/dl/android/maven2/
以阿里云为例
网页地址 http://maven.aliyun.com/nexus/content/repositories/jcenter/
上图中已经展示了 在项目中的配置这些仓库的方式。
maven中央仓库(http://repo1.maven.org/maven2/)是由Sonatype公司提供的服务,它是Apache Maven、SBT和其他构建系统的默认仓库,并能很容易被Apache Ant/Ivy、Gradle和其他工具所使用。开源组织例如Apache软件基金会、Eclipse基金会、JBoss和很多个人开源项目都将构件发布到中央仓库。 maven中央仓库已经将内容浏览功能禁掉了。
jcenter仓库(https://jcenter.bintray.com )是由JFrog公司提供的Bintray中的Java仓库。它是当前世界上最大的Java和Android开源软件构件仓库。 所有内容都通过内容分发网络(CDN)使用加密https连接获取。JCenter是Goovy Grape内的默认仓库,Gradle内建支持(jcenter()仓库),非常易于在(可能除了Maven之外的)其他构建工具内进行配置。
JCenter相比mavenCenter构件更多,性能也更好。但还是有些构件仅存在mavenCenter中。
google()存储库是Google maven资源库的快捷方式。 它是在Gradle 4.x +中引入的。 使用的实际存储库URL是“https://dl.google.com/dl/android/maven2/”, 也可以使用 maven { url ‘https://maven.google.com' }
但是 google(),则需要Gradle 4.x +,Android Studio 3.x +和Android 3.x +的Gradle插件。
maven (阿里云) maven.aliyun.com代理了很多公共的maven仓库。使用maven.aliyun.com中的仓库地址作为下载源,速度更快更稳定。
https://maven.aliyun.com/repository/public 是 central仓和jcenter仓的聚合仓 经过测试发现,可以代替 central 和 jcenter ,把阿里云的这个放到第一位会加快同步速度。
阿里云除了代理了 mavenCentral, jcenter还有 上面的 google() 基本上主流的 仓库都有, 在国内这个网络环境下, 使用阿里云的代理仓库是一个挺好的选择.
Maven 是一个项目管理和自动构建工具。是一个软件(特别是Java软件)项目管理以及自动构建工具,由Apache软件基金会所提供。是基于项目对象模型(缩写:POM)概念,Maven利用一个中央信息片断能管理一个项目的构建、报告和文档等步骤。
Maven也可以被利用与构建和管理各种项目,例如:C#、Ruby、Scala和其他语言编写的项目。
Maven项目使用项目对象模型(Project Object Modle,POM)来配置项目,对象模型存储在名为pom.xm的文件中。
Gradle是一个基于Apache Ant和Apache Maven概念的项目自动化构建工具,是一款通用灵活的构建工具,支持maven, Ivy仓库,支持传递性依赖管理,而不需要远程仓库或者是pom.xml和ivy.xml配置文件,基于Groovy(DSL语言,所谓的DSL是指这个语言应用在特定的领域,而类似Java这样是DCL语言,可以运用在普通的各个领域),而不是传统的xml语言,build脚本使用Groovy编写。当前支持的语言仅限于Java、Groovy、Scala、Kotlin。计划未来支持更多的语言。
Gradle改良了过去Maven、Ant带给开发者的问题,也已经成为Android Studio内置封装部署工具
上面也说了,我们可以把自己写的优秀代码共享到这些代码仓库中供全世界 开发者使用,但是由于公司的项目很多不能公开,只可在公司范围使用,又需要在不同项目中可以方便接入,在后续功能修改时 又能通过版本控制 使用修改前后的功能均可正常使用, 这个时候可以 搭建私有 maven 仓库,部署我们的公共组件代码,就可以方便使用了。
下面演示在window上的安装过程
下载地址:http://www.sonatype.com/download-oss-sonatype
然后打开我的电脑->属性->高级系统设置->高级->环境变量
在path 中配置 上面的bin 文件夹的路径进去 F:\Downloads\nexus-3.16.1-02-win64\nexus-3.16.1-02\bin
以管理员身份运行 cmd 进入 上面bin的文件夹下, 运行nexus.exe/install Nexus Service命令
查看服务里就能看到
启动这个服务,稍等一会时间,在浏览器中访问 http://localhost:8081/。就能够看到这个页面
默认的用户名和密码登录(admin/admin123) 目前为止 服务搭建成功。
仓库的详细配置过程,请移步到官方网站
http://books.sonatype.com/nexus-book/reference3/admin.html#admin-repositories
下面来看怎么使用的问题;
访问:http://localhost:8081/nexus,
先创建一个你要上传的仓库信息:
点击create repository
name那输入你想创建的名称,如 younger
然后点创建,就可以看到了自己的仓库 http://10.1.1.147:8081/repository/younger/ (此ip为我自己电脑局域网ip)
新建一个module,这个module就是你要编译的aar
module下gradle配置如下,
添加代码:
apply plugin:’maven’
和
1 | uploadArchives { |
groupId,version要注意填好。
然后,在Gradle projects列表中可以看到upload的Task
点击后即可编译上传,success后就可以了。
//添加仓库
在根gradle 中添加
1 | allprojects { |
1 | implementation 'demo.younger.com:testmaven:0.0.1' |
就可以正常引用了, 可以看到上面的顺序是 groupId :artifactId: version
也可以在 External Libraries 查看到
项目中也可以引用啦
目前项目中weex就是采用这种方式, 搭建一个nexus 私服,把weex 官方的sdk 下载下来,我们根据项目需要,自行实现很多不同的功能,
而这些功能在不同的项目都可以用到,就统一放到私服上,在我们具体的项目中,只需要简单设置,就可以非常方便的使用这些功能,
如果新增了什么功能,可以直接把代码上传到公共组件中,升级版本, 项目中用的时候只需要改成相应的版本号就可以方便使用到最新的功能。
可以发现,创建私服,上传aar文件, 引用都非常简单, 具体有没有必要使用还是需要根据自身实际情况来看,那些公共仓库上的内容可以优先使用阿里云的代理仓库,这样会加快同步速度,自己内部使用或者比较特殊的内容就可以放到私服上了,同样是特别方便。
]]>因为包含来自美国的技术,ARM(英国)已经要求员工“停止所有与华为及其子公司正在生效的合约、支持及未决约定”
从最近的新闻来看,美国的贸易禁令使得华为公司腹背受敌,ARM彻底暂停与华为合作已成定局,最新款的芯片技术肯定是用不上了,不过华为已经获得了ARMv8的永久授权。但是ARM是什么样的存在? 为什么对华为有这么大的影响力呢,事实上 ARM处理器 已经一统移动端了。今天就来谈谈 ARM 与 Android SO 的那些事。
公司某项目新上功能部分页面使用 weex 来做的,测试过程中发现 weex 页面在某些手机上出现异常,经过排查定位到是 SO 库的问题,下面是这个问题的解决过程的一个记录,希望能对遇到类似问题的同学提供一些帮助。
公司某项目 V1.2.0版本最初上线时,使用的 weex 是官方的 SDK 的接入方式, 接下来 App版本需要把官方的 SDK 替换成我们自己私服上的 SDK(因为私服上有我们针对自己项目需要,自定义了许多控件和公共组件) ,替换后,出现 weex 页面加载异常的问题(白屏)
报错信息如下:
我们注意到,上面有一行错误是 invokeInitFramework java.lang.UnsatisfiedLinkError: 见过该错误的开发者都知道,这个是 JNI 相关的错误信息,根据错误信息能看到是 weex 相关的 JNI 调用出了问题,由JNI 又想到了 SO 库文件, 那就引出今天的话题。
SO(shared object,共享库)是机器可以直接运行的二进制代码
SO 机制让开发者最大化利用已有的 C 和 C++ 代码,达到复用的效果,利用软件世界积累了几十年的优秀代码;
SO 是二进制,没有解释编译的开销,用SO实现的功能比纯java实现的功能要快;
SO 内存分配不受 Dalivik/ART 的单个应用限制,减少 OOM;
相对于java代码,二进制代码的反编译难度更大,一些核心代码可以考虑放在 SO 中。
在Android 中 提到 SO 就不能不提 ABI
应用程序二进制接口(Application Binary Interface)
定义了其所对应的CPU架构能够执行的二进制文件(特别是.so文件)的格式规范。在 Android 系统上,不同 Android 手机使用不同的 CPU,因此支持不同的指令集。CPU 与指令集的每种组合都有其相应的应用二进制界面(或 ABI)。
ABI 可以非常精确地定义应用的机器代码在运行时如何与系统交互。 您必须为应用要使用的每个 CPU 架构指定 ABI:armeabi,armeabi-v7a,arm64-v8a,x86,x86_64,mips,mips64;
目前 Android 共支持七种不同类型的 CPU 架构,分别是:ARMv5,ARMv7 (从2010年起),x86 (从2011年起),MIPS (从2012年起),ARMv8,MIPS64和x86_64 (从2014年起)
每一个 CPU 架构对应一个 ABI,一个 CPU 属于某一种架构,多核 CPU 属于相同架构才能一起工作,很多设备仅支持一种 CPU 架构。
如果你要完美兼容所有类型的机型,理论上是要在的 libs 目录下放置各个架构平台的 SO 文件。
但项目体积也会变得非常庞大。是否一定需要带入这么多SO文件去兼容呢?答案是否定的。
根据目前Android共支持七种不同类型的CPU架构,其兼容特点可总结如下:
armeabi设备只兼容armeabi;
armeabi-v7a设备兼容armeabi-v7a、armeabi;
arm64-v8a设备兼容arm64-v8a、armeabi-v7a、armeabi;
X86设备兼容X86、armeabi;
X86_64设备兼容X86_64、X86、armeabi;
mips64设备兼容mips64、mips;
mips只兼容mips;
armeabi 的 SO 文件基本上可以说是兼容目前市面上的大部分手机,它能运行在除了mips和mips64的设备上,但在非 armeabi 设备上运行性能会有所损耗;
64位的CPU架构总能向下兼容其对应的32位指令集,如:x86_64兼容X86,arm64-v8a兼容armeabi-v7a,mips64兼容mips;
总结成一句话就是, 新的CPU 架构总能向下兼容;
所以知道这些信息后,查看公司其他项目怎么用的, 发现其他项目, 在 build.gradle 中关于 ndk.abiFilters 都是这样写的:
而这个项目的写法是这样的:
区别就是这个 ndk.abiFilters , 既然这样写,必定有它的原因, 那就继续找,为什么要这么写,咨询相关的同事,了解到相关路径下的文件是 视频聊天、语音相关的 SO 库。
那能不能改成只支持 armeabi 或者 他们其中的部分呢。那就模拟这些情况,然后分别打出了这么多包(包的命名有点随意,这不是重点),分别在不同的手机上验证是否能正常加载 weex 。
经测试发现 armeabi 的和 armeabi +x86 的都可以正常加载 weex 页面,感觉问题这么简单就解决了,但是!!!
想到去掉这么多东西 会不会影响其他正常功能呢, 那就验证下吧,试了下视频相关功能,果不其然的 app 崩溃了。
说明上面的方向肯定是有问题,使用工具 Native libs Monitor 查看下Apk 中具体的SO 文件
首先查看下那些我们常用的App 是怎么做的,微信、QQ、支付宝等等
微信(只适配armeabi,有少量 v7a);
qq(只适配armeabi,文件夹下有少量x86);
百度地图(只适配armeabi);
大众点评(只适配armeabi);
google 家 (基本都是 arm64);
支付宝 (基本都是 armeabi 的,2个x86的);
而且 公司别的项目App 中 用到的 face++ 的人脸识别,身份证识别 也是只提供了armeabi 的SO 文件
下图是微信的Apk 解压出的情况, 微信的lib下虽然只有armeabi-v7a一个目录,但目录内的文件仍放着v7a和 armeabi架构的SO文件,用于处理兼容带来的某些性能运算问题。
看完大公司的适配情况,然后看下我们自己 公司这几个项目,看看到底有什么区别, 还有上面打出的不同的包,相应的 SO 引入情况。
经过对比发现, 指定不同的 abi 会 在apk 中 打入相应 的文件(前提是 你有这些文件),这就是一个过滤器,只在包中引入指定的cpu 架构的 SO 文件。
既然重点是 weex 加载的出了问题,然后就重点查看 weex 相关的 SO 引入情况, 既然官方的可以正常使用, 我们私服上却出了问题,然后对比使用官方的 sdk 集成方式,和我们私服上的集成方式的 SO 库有什么不同;
而集成私服上的 sdk 后,只在 armv5中有 libweexjsc.so 而 armv7 中是没有这个问题的。 区别找到了,那就去私服的项目中找原因
并没有 armeabi-v7a 的文件,相应的 lib下也没有相应的文件夹和文件,按照一样的写法,在这加入 armeabi-v7a 相应的路径,相应的 lib 下,也新建了相应的文件夹,放入相应的文件。重新编译
再次尝试, 查看 lib 在 arm v7 和 v8a 的手机上,查看 lib 情况,发现, 在都能看到相应的 SO 文件了,
多部手机尝试, 视频聊天、语音、 weex 都正常、至此 问题得到解决。
在排查问题的过程中发现,其实这个项目 本来的 build.gradle 文件中的 arm64-v8a 写错了,以前写成了 armeabi-v8a 其实这样是无法使用到 arm64-v8a下的 so 的。
但是会向下兼容, 使用了 armeabi-v7a 中的文件,也没有报错,其实也是因祸得福,如果写正确的话,weex 在 arm64-v8a 也会加载失败,因为 官方也没有提供这样的一个文件夹和文件。
但是我们要怎么配置呢?
从目前移动端CPU市场的份额数据看,ARM架构几乎垄断,所以,除非你的用户很特殊,否则几乎可以不考虑单独编译带入X86、X86_64、mips、mips64架构SO文件。除去这四个架构之后,还要带入armeabi、armeabi-v7a、arm64-v8a 这三个不同类型,这对于一个拥有大量SO文件的应用来说,安装包的体积将会增大不少。
目前主流的Android设备主要是 armeabi-v7a ARMv8 架构的,程序在运行的时候去加载不同平台对应的so,这是较为完美的一种解决方案,但是有时候为了减少包体积的大小,
不会同时设置 armeabi, armeabi-v7a 和 x86。根据不同的情况,可以进行不同的适配,
1.只适配 armeabi-v7a,因为目前主流机型是 ARMv7,并且 ARMv8 设备也向下兼容了armeabi-v7a, Facebook、WhatsApp、王者荣耀等就是只适配了armeabi-v7a。(Google play store下载 Native libs Monitor 进行查看)。
2.只适配 armeabi,因为 ARMv7 、ARMv8 还是 x86 都兼容 armeabi,但是性能都会有些损耗,例如ARMv7 支持硬件浮点运算等没法体现,x86 支持 armeabi 同样具有相应的损耗。
3.同时适配 armeabi-v7a 和 armeabi,既能够支持所有 ARM 架构,同时又能具有 ARMv7 支持硬件浮点运算等特性,例如Line等应用。
4.同时适配 x86 和 armeabi,既能支持所有 ARM 架构,又能支持x86架构,唯一的缺点就是没有了ARMv7 支持硬件浮点运算等一系列特性,例如QQ。
5.同时适配 armeabi, armeabi-v7a 和 x86,在性能方面来说是较为完美的方案,只是APK的大小也会随之变大。
6.还有其他的一些方案,例如微信只适配了armeabi,但是对于某些需要利用 ARMv7 支持硬件浮点运算等一系列特性的操作,在armeabi目录下存在v7对应的so文件,通过代码判断加载不同的so文件。即达到了减少APK大小的目的,又能达到适配ARMv7等架构以便使用其架构的一些新特性的目的。
就目前市场份额而言,绝大部分的设备都已经是armeabi-v7a、arm64-v8a,可以考虑只保留armeabi-v7a架构的SO文件,这样能获得更好的性能效果。所以我们的 这个项目选择采用第3种方案;
现在把这个项目中的 ndk.abiFilters 配置成上图所示,几乎覆盖所有机型,weex 能够正常加载,暂时没有发现影响其他功能,这个问题也算是得到解决。在不影响太多性能的情况下,也可以明显减少包的体积。
使用NDK时,你可能会倾向于使用最新的编译平台,但事实上这是错误的,因为NDK平台不是向下兼容的,而是向上兼容的。推荐使用app的minSdkVersion对应的编译平台。
这也意味着当你引入一个预编译好的.so文件时,你需要检查它编译时所用的平台版本。
arm64-v8a是可以向下兼容的,但前提是你的项目里面没有arm64-v8a的文件夹,如果你有两个文件夹armeabi和arm64-v8a,两个文件夹,armeabi里面有a.so 和 b.so,arm64-v8a里面只有a.so,
那么arm64-v8a的手机在用到b的时候发现有arm64-v8a的文件夹,发现里面没有b.so,就报错了,
所以这个时候删掉arm64-v8a文件夹,这个时候手机发现没有适配arm64-v8a,就会直接去找armeabi的so库,或者把 arm64-v8a文件夹 的 b.so补齐。
https://zhuanlan.zhihu.com/p/21302804
https://www.zhihu.com/question/36893314/answer/78467097
https://blog.csdn.net/zophar_development/article/details/84329054
https://www.cnblogs.com/janehlp/p/7473240.html
https://www.jianshu.com/p/cb05698a1968
https://www.jianshu.com/p/cb15ba69fa89
等
]]>终于我也有自己的个人网站了
昨天是周日,下午开始弄这个,申请域名, 搭建网站,整个过程真的好简单,以前一直以为很难的,就一直没有动手的去弄; 目前来看, 困难都是自己想象出来的, 真正动手去做,就会发现,也没有那么难,真正需要做的就是行动, 勇敢踏出第一步
域名是在 namesilo 买的, 用了优惠码后一年也就是不到50元,支持支付宝支付,方便至极 ,如果有需要可以寻找网上的优惠码, 可以节省1美元, 我这个域名买下来一年5.99美元,也就是不到45块钱,可谓是相当的划算了.
关于域名商的选择我也是查了下资料, 之所以没有选择国内的服务商, 主要是众所周知的原因, 国内的需要备案, 且你的域名随时可能被关掉等不安全因素, 还是在国际知名的大的域名商买比较靠谱, namesilo 这个打的广告比较少, 说是让利给消费者, 的确是很便宜, 我是对比了多家, 发现这家便宜的, 还有 namecheap 也比较便宜. 这个还是见仁见智了, 想买哪个都行, 续费便宜, 服务好就行.
使用的是 github.io + hexo 搭建的,
过程非常的简单, 感谢各位大佬的教程
hexo官网的文档 也很详细,还有视频教程,
另外还有好多主题供选择; 傻瓜式安装.每一步都有详细的教程, 然后就成了我现在弄的这个样子, 后续我肯定还会继续更新的.
今天简单弄了一下,记录一下, 虽然用的也都是别人的轮子,但还是很开心呀!
]]>