rxjs入门学习笔记

之前早就听说并简单看过响应式函数编程,但并没有仔细的学习并写过代码练习,(其实作为一个程序员特别是跟UI打交道的程序员,挺不应该的…响应式函数编程号称是找到了解决UI编程复杂性的方法),今天抽时间学习练习一下(比较浅薄的记录,对rxjs很熟悉的人就不要看了…)。

先了解一些基本概念,
Functional programming: 函数式编程,参考维基百科链接

Reactive Programming: 响应式编程,参考维基百科链接

Functional Reactive Programming: 响应式函数编程, 参考维基百科链接
简单说就是: 函数式编程+响应式编程, 思想来自于微软。在各个语言都有其实现, OC和Swift有ReactCocoa, Java有RxJava, Javascript有Rxjs。

为什么选择Javascript来学习呢?因为我猜Javascript更有可能统一宇宙吧…

先学习一下rxjs中的一些基本概念(当然其他语言的实现中也包含这些基本概念)。

Observable
一组可被观测的值,可以用来表示未来的值或者事件
Observer
观察者,知道如何处理来自Observable的值
Subscription
订阅,表示Observable的执行,Observable只有在subscribe时才开始执行
Operators
纯函数,用来处理和转换Observable发出的事件流
Subject
既是Observer又是Observable,可以用来实现广播的功能
Schedulers
用来集中处理并发,允许我们控制订阅的执行的时机和事件通知的时机

不太想也没办法一开始就把所有概念理解的非常清楚,在对基本的概念有了大致了解的基础上,还是动手来写点代码会更有助于理解一些。

因此考虑实现一个比较常见的业务场景来使用下rxjs: 搜索框输入文字,在输入的过程中,边输入边查询服务器的服务并显示输入提示的信息。
这个场景看似比较简单,但如果完全正确实现,还是有一些细节要考虑:
1. 监听输入框的文本变化
2. 查询服务器搜索提示服务
3. 取得结果并显示出来
4. 为了防止每次输入都去请求服务器造成浪费,需要输入以后延迟一小段时间(例如500ms)再去请求服务器
5. 请求服务器过程是异步的,在请求过程中,如果输入框内容变化了,请求结果不应该显示出来

按这些点分别讲一下实现:
1)监听输入框文本变化事件, 通过查文档,rxjs有一个fromEvent方法可以将dom事件转成Observable
2) 先用Mock的方法模拟一个服务器请求和相应的结果
3) 直接显示结果
4) 仔细查了下,在Operator里有一个throttleTime可以实现延迟执行,在规定时间内只执行1次的需求
5) 请求结果带请求时的输入参数,如果请求时输入参数和返回时输入参数有了变化,则不更新搜索提示

写的比较简单,但实际还是摸索了很久/(ㄒoㄒ)/

源码如下:


<div> <br /> <span>{{recommendResult}}</span> </div> import { fromEvent, asyncScheduler } from 'rxjs'; import { throttleTime, map, switchMap } from 'rxjs/operators'; export default { name: 'HelloWorld', data() { return { recommendResult: '', }; }, mounted() { function mockApiCall(text) { return new Promise((resolve) =&gt; { setTimeout(() =&gt; { resolve({ query: text, recommend: `recommend of ${text}`, }); }, 300); }); } fromEvent(this.$refs.input, 'input') .pipe( throttleTime(500, asyncScheduler, { leading: false, trailing: true }), map(() =&gt; this.$refs.input.value), switchMap(text =&gt; mockApiCall(text)), ) .subscribe((result) =&gt; { if (result.query === this.$refs.input.value) { this.recommendResult = result.recommend; } }); }, };

几个小坑:
1. rxjs 最新的api和之前的差不少,所以网上的一些文章(没标明版本的)不太好参考,还是看官方文档比较靠谱
2. throttleTime 如果不加参数,默认是{ leading: true, trailing: false }这样的效果就是,输入一开始就有一个事件出来,但是最终停止了是没有事件的,但我们想要的是停止的时候需要有一个事件,因此要改成{ leading: false, trailing: true }
3. mock的api请求是基于promise的接口,想融入到Observable的事件流里边,花了一番精力,理解文档,上网搜,才知道用switchMap这个Operator可以实现

看源码,核心的控制整个事件监听和响应的流程的代码一共10行左右,提到的那些小点也都满足了,整个控制流程不会散落在各个函数里边,代码还是比较清晰的,更少的代码代表着更少的bug,不过如果对rx不熟的人理解起代码来或者上手还是比较费劲的。

思考: 第4点其实有更合理的方式,应该是输入停止一小段时间(例如300ms)再去请求服务器,这样如果用户一直在快速输入,其实是不需要任何请求的,如果采用这种方式,如何用rxjs来实现呢?

注: 文中rxjs版本基于6.5.2, 主要参考自官网: rxjs官网链接

iOS 从AFNetworking中获取请求度量信息

事情起因是想更多的获取到iOS客户端访问网络情况的一些信息。
于是翻看iOS网络库文档, 主要是NSURLSession相关的,找到一个NSURLSessionTaskMetrics,里边记录了每次Http请求中的可能多个重定向中每一次的具体情况的时间度量信息(记录在NSURLSessionTaskTransactionMetrics), 包括请求开始时间、DNS解析开始时间、DNS解析结束时间、开始连接时间、响应开始时间等等具体的信息, 虽然还是没有例如连接的服务器ip等信息,但总归是多了不少信息。

查看头文件,系统是通过是通过NSURLSessionTaskDelegate 的如下方法给的NSURLSessionTaskMetrics的回调信息。

/*
 * Sent when complete statistics information has been collected for the task.
 */
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

绝大部分iOS客户端不会直接用NSURLSession,而是会借助第三方的网络库(AFNetworking)的封装,来更简单的使用,伴鱼也不例外,所以能不能把这个统计信息拿出来就看AFNetworking给不给力了…

查看了项目中现在用的AFNetworking源码,AFURLSessionManager 实现了NSURLSessionTaskDelegate, 但是搜一下“didFinishCollectingMetrics”,并没有实现这个方法,也就是,没办法直接用AFNetworking的api来做这件事情了。

第一时间想到的是不是升级下AFNetworking就好了, 于是来到AFNetworking的Github主页,还是这个类AFURLSessionManager, 找最新的代码搜了下,搜到了下边这个方法:

- (void)URLSession:(NSURLSession *)session
              task:(NSURLSessionTask *)task
didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics
{
    AFURLSessionManagerTaskDelegate *delegate = [self delegateForTask:task];
    // Metrics may fire after URLSession:task:didCompleteWithError: is called, delegate may be nil
    if (delegate) {
        [delegate URLSession:session task:task didFinishCollectingMetrics:metrics];
    }

    if (self.taskDidFinishCollectingMetrics) {
        self.taskDidFinishCollectingMetrics(session, task, metrics);
    }
}

一阵欣喜,看来生个级就好了,pod update一下,开心的等了不少时间执行完了之后发现,本地的AFNetworking代码里边还是没有这个方法… 看了下pod执行的提示,原来AFNetworking已经是最新的3.2.1版了,再仔细看Github上是看的master代码,切换到tag 3.2.1, 果然没有了这个方法…3.2.1是好久之前发布的了,AFNetworking不知道啥时候才能再发布把现在master的代码放上去,肯定是等不及它发布的。

那就想办法把这个信息从AFNetworking了里拿出来吧,OC办法会比较多的,想了下AFURLSessionManager既然没有实现这个方法,我们帮它实现好了,利用OC Runtime方式,把实现加到这个类上应该就可以了,动手实现了下:

@implementation XCHttpClient

+ (void)load
{
    Class afHttpSessionManagerClass = AFHTTPSessionManager.class;
    Class clientClass = self;
    SEL selector = @selector(URLSession:task:didFinishCollectingMetrics:);
    Method method = class_getInstanceMethod(clientClass, selector);
    IMP imp = class_getMethodImplementation(clientClass, selector);
    BOOL success =  class_addMethod(afHttpSessionManagerClass, selector, imp, method_getTypeEncoding(method));
    NSLog(@"af ---> class success: %@", @(success));
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics
{
    NSLog(@"didFinishCollectingMetrics.... %@", metrics);
}

这里只是为了试验没有考虑代码结构合理性,正常应该单独搞一个类来处理这个逻辑。断点、运行, 成功了…拿到了时间信息。
(此处后来想到有更简单的方式,不用runtime,读者可以简单思考下…)

还有一个问题是,之前是在请求成功或失败,做了一些处理,然后记录的网络请求信息,如何把这个详细的时间信息交给到请求成功或者失败的block里边,从而跟之前的逻辑串起来?

很自然的想到,是不是在回调中把信息记录到NSURLSessionTask这个对象上,这样成功或者失败都从这个对象把信息取出来?

写代码尝试了一下(标准的利用关联对象生成新属性):

static char XCHttpClientMetrics;
@interface NSURLSessionTask (XCHttpClientMetrics)
@property (nonatomic, strong) NSURLSessionTaskMetrics *metrics;
@end
@implementation NSURLSessionTask
- (void)setMetrics:(NSURLSessionTaskMetrics *)metrics
{
    objc_setAssociatedObject(self, &XCHttpClientMetrics, metrics, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSURLSessionTaskMetrics *)metrics
{
   return objc_getAssociatedObject(self, &XCHttpClientMetrics);
}
@end

相应的回调方法修改下实现如下:

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics
{
    task.metrics = metrics;
}

运行,Crash !!!, 未识别的selector,仔细检查了代码,没有问题,打断点调试下:

(lldb) po [task respondsToSelector:@selector(setMetrics:)]
NO

(lldb) po task.class
__NSCFLocalDataTask

(lldb) po task.superclass
__NSCFLocalSessionTask

(lldb) po task.superclass.superclass
__NSCFURLSessionTask

很熟悉的苹果的做法,用一个私有类替换了NSURLSessionTask,所以确实不响应setMetrics这个selector。
怎么办?给这个私有类加一个属性吗? 理论上是可以做的,不过想想好累…
参考下AFNetworking的做法吧,AFNetworking应该也给task记录了一些信息,不然没办法从回调的方式转成success/fail回调的方式,翻看代码:

- (void)setDelegate:(AFURLSessionManagerTaskDelegate *)delegate
            forTask:(NSURLSessionTask *)task
{
    NSParameterAssert(task);
    NSParameterAssert(delegate);

    [self.lock lock];
    self.mutableTaskDelegatesKeyedByTaskIdentifier[@(task.taskIdentifier)] = delegate;//关键代码
    [self addNotificationObserverForTask:task];
    [self.lock unlock];
}

关键是每个task有一个整数标识(taskIdentifier)! 于是解决方案就很简单了,找个地方存一个map,用taskIdentifier作为key,具体的metrics信息作为value,然后在成功或者失败回调里根据key把信息取出来就可以了。

由于代码太简单,就不贴了…测试通过。能看到metrics信息里边对于ip直连的请求dns解析开始时间等信息都是null了,对于复用了连接的请求,connect时间也是null了。仔细分析metrics信息又是另外一个话题了,需要模拟下各种网络环境分析成功或失败请求的数据,就不在这里分析了。

注意: 根据文档Task对象并不是从一开始请求,到请求结束都是用的同一个Task对象,而是可能中间会换成另外一个对象,只是identifier应该是相同的,可以参考方法- (void)URLSession:(NSURLSession *)session
dataTask:(NSURLSessionDataTask *)dataTask
didBecomeDownloadTask:(NSURLSessionDownloadTask *)downloadTask
的文档,也可以试验一下。

OkHttp源码解析(四) — 连接池

事情起源于讨论伴鱼App中当前的Http2跟服务器交互过程中是否只走了同一个TCP连接。之前没有仔细思考过这件事情,只是记得Http2协议支持多路复用,在一条TCP连接上可以允许多个http请求和响应。(还未经思考的认为针对同一个主机和端口只能有1个TCP连接,这件事情仔细想想也有问题,如果只允许一个,那么Http1时代,客户端和服务端之间只能1个接1个的处理Http请求和响应,也太不合理了,经过提示: 只要client端端口不一样不就能开多个嘛…)

在这篇文章中,我们不讨论http2协议是怎么多路复用的,只是想从OkHttp源码分析中,尝试回答这个问题: 当前跟服务端Http2交互过程中,是走了1个TCP连接还是多个?

参考之前的系列文章, 我们知道StreamAllocation是对于逻辑上1次http请求的抽象,每次http请求会生成1个HttpCodec, 参考StreamAllocation的newStream方法:

  public HttpCodec newStream(
      OkHttpClient client, Interceptor.Chain chain, boolean doExtensiveHealthChecks) {
///省略...
    try {
      RealConnection resultConnection = findHealthyConnection(connectTimeout, readTimeout,
          writeTimeout, pingIntervalMillis, connectionRetryEnabled, doExtensiveHealthChecks);//关键代码: 找到一个健康的连接
      HttpCodec resultCodec = resultConnection.newCodec(client, chain, this); //从这个连接生成HttpCodec对象
      synchronized (connectionPool) {
        codec = resultCodec;
        return resultCodec;
      }
    } catch (IOException e) {
      throw new RouteException(e);
    }
  }

主要逻辑就是要找到一个健康的连接(根据之前文章,Connection是对客户端服务器连接的抽象),我们查看findHealthyConnection方法:

  private RealConnection findHealthyConnection(int connectTimeout, int readTimeout,
      int writeTimeout, int pingIntervalMillis, boolean connectionRetryEnabled,
      boolean doExtensiveHealthChecks) throws IOException {
    while (true) { //一定要找到!!
      RealConnection candidate = findConnection(connectTimeout, readTimeout, writeTimeout,
          pingIntervalMillis, connectionRetryEnabled); //关键代码: 找到一个连接

      synchronized (connectionPool) {
        if (candidate.successCount == 0) {
          return candidate;//  如果是新生成的连接,可以返回
        }
      }
      //如果是重用的连接,检查下这个连接是否健康
      if (!candidate.isHealthy(doExtensiveHealthChecks)) {
        noNewStreams();
        continue;
      }

      return candidate;
    }
  }

主要逻辑,就是循环找连接,如果连接可用就返回,否则一直找
查看findConnection方法(这个方法好长…省略了非主要逻辑):

private RealConnection findConnection(int connectTimeout, int readTimeout, int writeTimeout,
      int pingIntervalMillis, boolean connectionRetryEnabled) throws IOException {
    //省略...
    synchronized (connectionPool) {
      //尝试重用之前获得的connection
      if (this.connection != null) {
        result = this.connection;
      }
      if (result == null) {
        Internal.instance.get(connectionPool, address, this, null);//尝试从连接池取一个Connection
        if (connection != null) {
          result = connection;
        } 
      }
    }
    if (result != null) {
      return result; //如果从连接池得到了一个,则返回
    }
    synchronized (connectionPool) {
      if (newRouteSelection) {
        List<Route> routes = routeSelection.getAll();
        for (int i = 0, size = routes.size(); i < size; i++) {// 在"connection coalescing"这种场景下,可能会找到另外一个可重用连接
          Route route = routes.get(i);
          Internal.instance.get(connectionPool, address, this, route);
          if (connection != null) {
            result = connection;
            this.route = route;
            break;
          }
        }
      }

        //如果没有找到,则创建一个新的连接
      if (!foundPooledConnection) {
        if (selectedRoute == null) {
          selectedRoute = routeSelection.next();
        }
        route = selectedRoute;
        refusedStreamCount = 0;
        result = new RealConnection(connectionPool, selectedRoute);
        acquire(result, false);
      }
    }
    if (foundPooledConnection) {
      return result;
    }

    //新创建的连接需要做TCP+TLS握手
    result.connect(connectTimeout, readTimeout, writeTimeout, pingIntervalMillis,
        connectionRetryEnabled, call, eventListener);
    routeDatabase().connected(result.route());

    Socket socket = null;
    synchronized (connectionPool) {
      reportedAcquired = true;

      // 把新创建的连接放入连接池
      Internal.instance.put(connectionPool, result);

      //如果有其他的对同一地址的多路复用连接,则复用这个连接(当并发请求http2时会出现这种情况)
      if (result.isMultiplexed()) {
        socket = Internal.instance.deduplicate(connectionPool, address, this);
        result = connection;
      }
    }
    return result;
  }

这个查找连接的方法主要逻辑是
1. 尝试之前绑定的connection
2. 从连接池中找
3. 处理”connection coalescing”这种场景下的连接复用,参考: https://daniel.haxx.se/blog/2016/08/18/http2-connection-coalescing/ (简单解释: 复用同一个ip地址配置多个域名的场景)
4. 创建新连接
5. 新连接做tcp+ssl握手,如果因为并发创建了对同一个地址的多个连接,且是多路复用(http2),则重用这个连接

重点需要看一下连接池的复用逻辑,我们看一下尝试获取可用连接的get方法:

  @Nullable RealConnection get(Address address, StreamAllocation streamAllocation, Route route) {
    assert (Thread.holdsLock(this));
    for (RealConnection connection : connections) {
      if (connection.isEligible(address, route)) {//关键方法: 如果对该地址可以复用,则复用这个连接
        streamAllocation.acquire(connection, true);
        return connection;
      }
    }
    return null;
  }

查看isEligible这个方法:

public boolean isEligible(Address address, @Nullable Route route) {
    // 如果大于了allocationLimit则不允许再复用
    if (allocations.size() >= allocationLimit || noNewStreams) return false;

    // 如果除去host字段,有其他不相等的,则不允许复用
    if (!Internal.instance.equalsNonHost(this.route.address(), address)) return false;

    // 如果是同一域名则允许复用
    if (address.url().host().equals(this.route().address().url().host())) {
      return true; 
    }

    // 处理"connection coalescing"这种场景,不同域名共享同一个ip地址
    // 1. 必须是 HTTP/2.
    if (http2Connection == null) return false;

    // 2.  必须共享同一个ip,  非使用代理
    if (route == null) return false;
    if (route.proxy().type() != Proxy.Type.DIRECT) return false;
    if (this.route.proxy().type() != Proxy.Type.DIRECT) return false;
    if (!this.route.socketAddress().equals(route.socketAddress())) return false;

    // 3. 当前连接的服务端证书必须覆盖新的域名
    if (route.address().hostnameVerifier() != OkHostnameVerifier.INSTANCE) return false;
    if (!supportsUrl(address.url())) return false;

    // 4. 如果有pins,检查新域名是否满足pin
    try {
      address.certificatePinner().check(address.url().host(), handshake().peerCertificates());
    } catch (SSLPeerUnverifiedException e) {
      return false;
    }

    return true; // 可以复用
  }

对于http2来说,大于allocationLimit的新的连接是不能重用之前连接的,我们查看allocationLimit在http2下的限制:

if (http2Connection != null) {
      synchronized (connectionPool) {
        allocationLimit = http2Connection.maxConcurrentStreams();
      }
    }

//maxConcurrentStreams方法实现:
public synchronized int maxConcurrentStreams() {
    return peerSettings.getMaxConcurrentStreams(Integer.MAX_VALUE);
  }

因此,应该是服务端对于http2最大流并发数的限制了。查看服务端nginx对http2最大并发流限制,文档中http2_max_concurrent_streams 默认值是128(需要再对照服务端nginx配置看一下有没有改这个值…,这里先假设这个值是128).

因此当并发数小于128时,okhttp会复用同一个tcp连接,结合之前文章分析,maxRequestsPerHost 默认值为5, 也就是对同一个域名多于5个的请求会在后边排队。

现在来回答文章开头提出的问题,是否对于http2所有请求都复用同一个TCP连接,简单回答,在Okhttp默认配置下: 是的。

OkHttp源码解析(三)–io操作

在前边文章分析中,我们已经知道CallServerInterceptor中实现了从服务端读/写数据, 因此分析IO操作,我们就从这个类入手。
查看这个类的Intercept方法(摘取关键部分):

  @Override public Response intercept(Chain chain) throws IOException {
   ///此处省略若干行...
    httpCodec.writeRequestHeaders(request); //写请求头
    ///省略
    if (HttpMethod.permitsRequestBody(request.method()) && request.body() != null) {
      if ("100-continue".equalsIgnoreCase(request.header("Expect"))) {
        httpCodec.flushRequest();//把缓存的请求flush出去
        responseBuilder = httpCodec.readResponseHeaders(true);//读响应
      }

      if (responseBuilder == null) {
        long contentLength = request.body().contentLength();
        CountingSink requestBodyOut =
            new CountingSink(httpCodec.createRequestBody(request, contentLength));
        BufferedSink bufferedRequestBody = Okio.buffer(requestBodyOut);//请求体Buffer
        request.body().writeTo(bufferedRequestBody);
        bufferedRequestBody.close();
          //省略...
    httpCodec.finishRequest(); //结束请求

    if (responseBuilder == null) {
      realChain.eventListener().responseHeadersStart(realChain.call());
      responseBuilder = httpCodec.readResponseHeaders(false);//读响应头
    }

    Response response = responseBuilder
        .request(request)
        .handshake(streamAllocation.connection().handshake())
        .sentRequestAtMillis(sentRequestMillis)
        .receivedResponseAtMillis(System.currentTimeMillis())
        .build();

    int code = response.code();
    if (code == 100) {//处理100-continue
      // try again to read the actual response
      responseBuilder = httpCodec.readResponseHeaders(false);//读响应头
    //省略
    }
    if (forWebSocket && code == 101) {
      // Connection is upgrading, but we need to ensure interceptors see a non-null response body.
      response = response.newBuilder()
          .body(Util.EMPTY_RESPONSE)
          .build();
    } else {
      response = response.newBuilder()
          .body(httpCodec.openResponseBody(response))//处理Http响应
          .build();
    }
//省略...
    return response;
  }

代码比较长,我们只看重要的部分httpCodec.writeRequestHeaders(request);, 根据之前文章OkHttp分别通过Http1Codec 和 Http2Codec实现了http1.x和2.0协议,简单起见,我们先只关注Http1Codec, 查看Http1Codec的writeRequestHeaders方法如下:

  @Override public void writeRequestHeaders(Request request) throws IOException {
    String requestLine = RequestLine.get(
        request, streamAllocation.connection().route().proxy().type());
    writeRequest(request.headers(), requestLine);
  }

  public void writeRequest(Headers headers, String requestLine) throws IOException {
    if (state != STATE_IDLE) throw new IllegalStateException("state: " + state);
    sink.writeUtf8(requestLine).writeUtf8("\r\n");
    for (int i = 0, size = headers.size(); i < size; i++) {
      sink.writeUtf8(headers.name(i))
          .writeUtf8(": ")
          .writeUtf8(headers.value(i))
          .writeUtf8("\r\n");
    }
    sink.writeUtf8("\r\n");
    state = STATE_OPEN_REQUEST_BODY;
  }

我们看到是把具体的http请求行和请求头信息写到了一个sink里边,sink的声明final BufferedSink sink;BufferedSink 接口声明: interface BufferedSink : Sink, WritableByteChannel, 查看Sink接口中的说明:

* ### Comparison with OutputStream
 *
 * This interface is functionally equivalent to [java.io.OutputStream].
 ```
 Sink的设计跟OutputStream一样,因此Sink是Okio中对于输出流的抽象,但跟OutputStream不一样的是,为了效率Sink没有直接的write方法,而只有` fun write(source: Buffer, byteCount: Long)`方法。

 看完写数据,我们接着来看一个从服务器读取数据的环节: `responseBuilder = httpCodec.readResponseHeaders(true);`, 同样查看Http1Codec的 readResponseHeaders方法如下:
 ```java
 @Override public Response.Builder readResponseHeaders(boolean expectContinue) throws IOException {
    try {
      StatusLine statusLine = StatusLine.parse(readHeaderLine());//读取状态行

      Response.Builder responseBuilder = new Response.Builder()
          .protocol(statusLine.protocol)
          .code(statusLine.code)
          .message(statusLine.message)
          .headers(readHeaders());//读取header
        ///省略部分代码...
  }
   private String readHeaderLine() throws IOException {
      String line = source.readUtf8LineStrict(headerLimit);//关键的读取代码
      headerLimit -= line.length();
    return line;
  }

查看source的声明: final BufferedSource source;, BufferedSource的接口声明: interface BufferedSource : Source, ReadableByteChannel ,查看Source接口的说明:

* ### Comparison with InputStream
 * This interface is functionally equivalent to [java.io.InputStream].

Source的设计和Java中的InputStream功能一致,跟Sink类似,为了效率考虑没有InputStream中的read方法,而是有一个: fun read(sink: Buffer, byteCount: Long): Long

输入(BufferedSource)输出(BufferedSink)都只是接口定义,但是不难找到真正的实现类Buffer, 类声明class Buffer : BufferedSource, BufferedSink, Cloneable, ByteChannel, 查看类说明:

/**
 * A collection of bytes in memory.
 *
 * **Moving data from one buffer to another is fast.** Instead of copying bytes from one place in
 * memory to another, this class just changes ownership of the underlying byte arrays.
 *
 * **This buffer grows with your data.** Just like ArrayList, each buffer starts small. It consumes
 * only the memory it needs to.
 *
 * **This buffer pools its byte arrays.** When you allocate a byte array in Java, the runtime must
 * zero-fill the requested array before returning it to you. Even if you're going to write over that
 * space anyway. This class avoids zero-fill and GC churn by pooling byte arrays.

简单来说Buffer同时实现了BufferedSource和BufferedSink接口,做了如下改进: 在不同的Buffer直接移动数据避免了内存拷贝,而只是将字节数组的所有权叫做交换;通过缓存字节数组,避免了Java的运行时在分配内存时的zero-fill以及释放内存时的GC,因此对于频繁的io操作或者数据移动是很高效的。

Buffer的实现很长,我们还是从读取Http头的代码查看读取的流程,通过追踪读取http头的的代码String line = source.readUtf8LineStrict(headerLimit);(中间经过很多步骤,但不是很重要就忽略了), 我们追踪到最终的读取实现在Buffer这个类的如下方法:
“`java
override fun read(sink: ByteArray, offset: Int, byteCount: Int): Int {
checkOffsetAndCount(sink.size.toLong(), offset.toLong(), byteCount.toLong())

<pre><code>val s = head ?: return -1//head是一个Segment类型
val toCopy = minOf(byteCount, s.limit – s.pos)
System.arraycopy(s.data, s.pos, sink, offset, toCopy)

s.pos += toCopy
size -= toCopy.toLong()

if (s.pos == s.limit) {
head = s.pop()
SegmentPool.recycle(s)//回收Segment
}

return toCopy
</code></pre>

}
<code>我们看到了Segment以及SegmentPool, Segment的类描述重要部分如下:</code>java
/**
* A segment of a buffer.
*
* Each segment in a buffer is a circularly-linked list node referencing the following and
* preceding segments in the buffer.
*
* Each segment in the pool is a singly-linked list node referencing the rest of segments in the
* pool.
*
<code>Segment要么在buffer中,表示一个循环双链表节点,连接其他Segment,要么在Pool中,连接其他Pool中的Segment。
查看SegmentPool类描述:</code>java
/**
* A collection of unused segments, necessary to avoid GC churn and zero-fill.
* This pool is a thread-safe static singleton.
*/
“`
可以看到SegmentPool是一个全局唯一的单例,用来存储缓存的Segment,通过查看源码,能看懂SegmentPool中设置一个缓存数据的最大值,如果这个池子满了,则Segment放不进去了。

我们通过这个流程简单梳理了OkHttp请求和响应过程中的io处理,当然,我们只是抓住主要的输入输出做了简单分析,了解了Okio主要的几个类,Source, Sink, Buffer, Segment, SegmentPool等,其中还有很多细节并没有涉及,例如: Buffer是如何尽量的避免内存数据拷贝的? Http2的交互细节,等等。感兴趣的同学可以再自己查看下源码梳理下吧。

OkHttp源码解析(二) —线程管理

跟上篇文章一样,我们还是按照请求和响应的处理流程梳理OkHttp的源码,不过这次重点放到线程管理这一部分。

入口仍然是两种调用方式,同步调用:

Response execute() throws IOException;

异步调用:

void enqueue(Callback responseCallback);

具体的实现类RealCall, 同步调用全部在当前线程处理,得到响应,并不会使用OkHttp内部创建的线程,因此我们重点查看异步调用的实现:

@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));//生成一个AsyncCall对象,并调用dispatcher的enqueue方法
  }

最关键的只有一行代码,生成一个AsyncCall对象,并调用dispatcher的enqueue方法,因此我们查看enqueue方法:

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

我们看到OkHttp在这里做了并发控制,如果总并发数小于允许的最大并发数,且对于单一域名的并发数小于允许的最大并发数,则调用excutorService的execute去执行,否则加入准备队列。多线程控制肯定在excutorService无疑了。

executorService实现如下:

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

我们看到如果没有设置自定义的executorService, 就会创建一个线程池, 查看OkHttpClient类的代码, 默认的HttpClient.Bulder 创建了一个dispatcher:dispatcher = new Dispatcher(); 默认的构造函数并没有对executorService赋值,因此可以确定OkHttpClient默认即是采用的这个ThreadPoolExecutor。

于是关键的代码就在于这个ThreadPoolExecutor的构造函数了,相关文档说明如下:

    /**
     * Creates a new {@code ThreadPoolExecutor} with the given initial
     * parameters and default rejected execution handler.
     *
     * @param corePoolSize the number of threads to keep in the pool, even
     *        if they are idle, unless {@code allowCoreThreadTimeOut} is set
     * @param maximumPoolSize the maximum number of threads to allow in the
     *        pool
     * @param keepAliveTime when the number of threads is greater than
     *        the core, this is the maximum time that excess idle threads
     *        will wait for new tasks before terminating.
     * @param unit the time unit for the {@code keepAliveTime} argument
     * @param workQueue the queue to use for holding tasks before they are
     *        executed.  This queue will hold only the {@code Runnable}
     *        tasks submitted by the {@code execute} method.
     * @param threadFactory the factory to use when the executor
     *        creates a new thread
     * @throws IllegalArgumentException if one of the following holds:<br>
     *         {@code corePoolSize < 0}<br>
     *         {@code keepAliveTime < 0}<br>
     *         {@code maximumPoolSize <= 0}<br>
     *         {@code maximumPoolSize < corePoolSize}
     * @throws NullPointerException if {@code workQueue}
     *         or {@code threadFactory} is null
     */
    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             threadFactory, defaultHandler);
    }

根据传入的参数和文档说明, 这个线程池的配置如下: 核心线程为0, 最大线程数为2^32-1, 非核心线程的活跃时间为60秒。单纯考虑这个线程池并未限制线程数,综合考虑dispatcher自己的限制:

private int maxRequests = 64;
private int maxRequestsPerHost = 5;

因此就只有最多64个并发请求,如果App内只有1个域名的话,最多只能有5个并发请求。

再来看一下ThreadPoolExecutor的workQueue 参数: new SynchronousQueue(), 字面意义是同步队列,扩展了AbstractQueue 并实现了BlockingQueue , BlockingQueue定义了阻塞队列的接口,阻塞队列在获取元素时如果队列为空则等待,同样插入队列元素时,如果队列满了也等待。

同步队列的说明如下:

/**
 * A {@linkplain BlockingQueue blocking queue} in which each insert
 * operation must wait for a corresponding remove operation by another
 * thread, and vice versa.  A synchronous queue does not have any
 * internal capacity, not even a capacity of one.  You cannot
 * {@code peek} at a synchronous queue because an element is only
 * present when you try to remove it; you cannot insert an element
 * (using any method) unless another thread is trying to remove it;
 * you cannot iterate as there is nothing to iterate.  The
 * <em>head</em> of the queue is the element that the first queued
 * inserting thread is trying to add to the queue; if there is no such
 * queued thread then no element is available for removal and
 * {@code poll()} will return {@code null}.  For purposes of other
 * {@code Collection} methods (for example {@code contains}), a
 * {@code SynchronousQueue} acts as an empty collection.  This queue
 * does not permit {@code null} elements.
 *
 * <p>Synchronous queues are similar to rendezvous channels used in
 * CSP and Ada. They are well suited for handoff designs, in which an
 * object running in one thread must sync up with an object running
 * in another thread in order to hand it some information, event, or
 * task.
 *
 * <p>This class supports an optional fairness policy for ordering
 * waiting producer and consumer threads.  By default, this ordering
 * is not guaranteed. However, a queue constructed with fairness set
 * to {@code true} grants threads access in FIFO order.
 *
 * <p>This class and its iterator implement all of the
 * <em>optional</em> methods of the {@link Collection} and {@link
 * Iterator} interfaces.
 ```
简要总结下是说在插入同步队列元素时,需要等待其他线程移除元素,在取元素时需要等待其他线程插入元素, 同步队列内部并不存储元素,因此不能peek。  同步队列也实现了一个可选的公平策略,允许等待线程排序,默认不采用公平策略。

最后看一下threadFactory参数: `Util.threadFactory("OkHttp Dispatcher", false)`, 实现如下:

```java
public static ThreadFactory threadFactory(final String name, final boolean daemon) {
    return new ThreadFactory() {
      @Override public Thread newThread(Runnable runnable) {
        Thread result = new Thread(runnable, name);
        result.setDaemon(daemon);
        return result;
      }
    };
  }

生成了一个Thread,同时设置成非守护线程(用户线程),守护线程和非守护线程的唯一区别在于: 如果用户线程已经全部退出运行了,只剩下守护线程存在了,虚拟机也就退出了。 因为没有了被守护者,守护线程也就没有工作可做了,也就没有继续运行程序的必要了。

至此,我们搞明白了OkHttp对于异步和线程这块的管理。

OkHttp源码解析(一) —请求和响应的处理过程

一直以来只是知道OkHttp是一个强大又精巧的http client库, 简单看过api以及它的一个整体的设计, 并一直在项目里边用,但并没有进入到代码层面去仔细看他的实现,因此花了一些时间来读他的源码并记录一些细节,希望对更好的使用OkHttp起到一些积极作用,避免出现一些使用上的问题,同时学习它的设计思路。

我们从一个Http请求的发出及得到相应过程入手,来梳理下它的源码。 使用OkHttp来发送http请求有同步及异步两种方式,同步方式如下:

OkHttpClient eagerClient = client.newBuilder()
    .build();
Response response = eagerClient.newCall(request).execute();

异步方式如下:

OkHttpClient eagerClient = client.newBuilder()
    .build();
Response response = eagerClient.newCall(request).enqueue(new Callback () {
    public void onFailure(Call call, IOException e) {
    }
    public void onResponse(Call call, Response response) throws IOException {}
    )
}

略过OkHttpClient实例的构建过程不讲,无论同步还是异步的方式都是先通过client实例的newCall调用获得一个Call实例,newCall的实现如下:

/**
   * Prepares the {@code request} to be executed at some point in the future.
   */
  @Override public Call newCall(Request request) {
    return RealCall.newRealCall(this, request, false /* for web socket */);
  }

可以看到返回的是一个RealCall(实现了Call接口)的实例,因此RealCall就是http调用接口的真正实现了,以下是RealCall的同步调用execute()的实现(只摘取关键部分):

  @Override public Response execute() throws IOException {
    synchronized (this) {
        /*一些异常处理忽略*/
    eventListener.callStart(this);//通知调用开始
    try {
      client.dispatcher().executed(this);//通知dispatcher调用执行开始
      Response result = getResponseWithInterceptorChain();//这一行最关键,是真正的http调用
      return result;
    } catch (IOException e) {
      eventListener.callFailed(this, e);//通知调用失败
      throw e;
    } finally {
      client.dispatcher().finished(this);//通知dispatcher调用结束
    }
  }

异步调用enqueue()方法的实现如下:

@Override public void enqueue(Callback responseCallback) {
    synchronized (this) {
    eventListener.callStart(this);
    client.dispatcher().enqueue(new AsyncCall(responseCallback));//生成一个AsyncCall对象放入到dispatcher中
  }

其实这个方法没有什么太多的信息量,AsyncCall是一个实现只是把一个AsyncCall对象放入到了dispatcher中,我们再看dispatcher.enqueue()的处理:

synchronized void enqueue(AsyncCall call) {
    if (runningAsyncCalls.size() < maxRequests && runningCallsForHost(call) < maxRequestsPerHost) {
      runningAsyncCalls.add(call);
      executorService().execute(call); //放入线程池执行
    } else {
      readyAsyncCalls.add(call); //添加到待执行列表
    }
  }

根据变量名runningAsyncCalls, runningCallsForHost,应该是说当所有这个dispatcher上的请求小于最大允许请求数,且对于这个请求的主机并发数小于单个主机最大允许请求数,则放入线程池执行,否则添加到待执行列表。 AsyncCall 继承自 NamedRunnable(实现了Runnable接口),NamedRunnable的run方法调用了: execute方法,因此我们需要看一下AsyncCall的execute方法才知道真正做的事情是什么:

    @Override protected void execute() {
      boolean signalledCallback = false;
      try {
        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);
      }
    }

我们看到无论同步还是异步的http请求调用最终都是走到了realcall.getResponseWithInterceptorChain(), 这个方法应该是最核心的逻辑了,方法实现如下:

  Response getResponseWithInterceptorChain() throws IOException {
    // Build a full stack of interceptors.
    List<Interceptor> interceptors = new ArrayList<>();
    interceptors.addAll(client.interceptors()); //应用设置的拦截器
    interceptors.add(retryAndFollowUpInterceptor);//重试和302跳转处理拦截器
    interceptors.add(new BridgeInterceptor(client.cookieJar())); //处理用户请求,添加header
    interceptors.add(new CacheInterceptor(client.internalCache())); //处理缓存逻辑
    interceptors.add(new ConnectInterceptor(client)); //处理连接逻辑
    if (!forWebSocket) {
      interceptors.addAll(client.networkInterceptors());//处理app设置的网络连接器
    }
    interceptors.add(new CallServerInterceptor(forWebSocket));//处理往服务端写数据以及读数据的逻辑

    Interceptor.Chain chain = new RealInterceptorChain(interceptors, null, null, null, 0,
        originalRequest, this, eventListener, client.connectTimeoutMillis(),
        client.readTimeoutMillis(), client.writeTimeoutMillis()); //生成一个真正的Chain

    return chain.proceed(originalRequest);//真正执行
  }

看到了熟悉的interceptor和Chain的逻辑,这是OkHttp的核心设计,不同部分的处理逻辑分散在不同的Interceptor中,同时允许应用扩展interceptor, 靠Chain连接起不同的interceptor, 实现了不同逻辑之间的解耦,同时靠Interceptor的前后顺序控制了请求发出时的处理顺序,以及相应到达后的处理顺序(和请求发出时相反),相应的实现在RealInterceptorChain中, 核心逻辑如下:

@Override public Response proceed(Request request) throws IOException {
    return proceed(request, streamAllocation, httpCodec, connection);
  }

  public Response proceed(Request request, StreamAllocation streamAllocation, HttpCodec httpCodec,
      RealConnection connection) throws IOException {
      //忽略了一些错误监测
    // Call the next interceptor in the chain.
    RealInterceptorChain next = new RealInterceptorChain(interceptors, streamAllocation, httpCodec,
        connection, index + 1, request, call, eventListener, connectTimeout, readTimeout,
        writeTimeout); //生成下一个链对象
    Interceptor interceptor = interceptors.get(index); //得到interceptor
    Response response = interceptor.intercept(next); //调用interceptor的intercept方法
    // Confirm that the next interceptor made its required call to chain.proceed().
    if (httpCodec != null && index + 1 < interceptors.size() && next.calls != 1) {
      throw new IllegalStateException("network interceptor " + interceptor
          + " must call proceed() exactly once");
    }
      ///忽略了一些错误监测
    return response;
  }

因为interceptor的intercept方法中调用chain.proceed方法,而chain的proceed方法中又生成了下一个chain对象,调用了下一个interceptor的intercept方法,这样所有的interceptor的方法就会一直调用下,那直到某一个interceptor的方法不再调用proceed方法,这样后边的interceptor就不再调用,整个链就停止了。

猜测缓存处理的逻辑(CacheInterceptor)可能会找到缓存会直接返回response而不调用proceed,最后的请求服务器的逻辑(CallServerInterceptor)也不会调用chain的proceed方法,不然就会数组越界,查看了相应的代码证实确实如此。

这样整个从请求到拿到响应的大的逻辑就走通了,其实整体逻辑还是非常清晰和简单的。

在读源码的过程中的一个比较有疑问的问题, 在RealCall的getResponseWithInterceptorChain方法中生成的RealInterceptorChain的代码:

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

几个非常重要的参数:

public RealInterceptorChain(List<Interceptor> interceptors, StreamAllocation streamAllocation,
      HttpCodec httpCodec, RealConnection connection, int index, Request request, Call call,
      EventListener eventListener, int connectTimeout, int readTimeout, int writeTimeout) {

StreamAllocation, HttpCodec, RealConnection 都为空,通过阅读文件头注释和方法注释,可知: StreamAllocation是用来协调3个实例: Connections(物理的和服务端的socket连接), Streams(逻辑的http请求/相应), Calls(逻辑的请求实例).
HttpCodec是用来编码http请求和解析http响应的接口定义,有Http1Codec和Http2Codec两个实现分别处理http1.x以及http2.x的协议。
RealConnection是连接的实现,里边包含了socket连接等逻辑。 那么这3个类的实例是在哪里初始化的呢?

利用IDE的findUsage功能,在3个类的构造函数上分别找了下,很容易找到了答案, StreamAllocation在所有的interceptor第一个RetryAndFollowUpInterceptor 的intercept方法中(除去应用自己的)初始化的,然后在后边的RealInterceptorChain的proceed方法中就传入了这个实例。
HttpCodec在ConnetInterceptor的intercept方法中调用 streamAllocation.newStream方法生成了,也就是在连接服务器时生成的。
RealConnection是在StreamAllocation中的findConnection方法时,调用acquire方法将这个streamAllocation对象和connection对象绑定到一起的,同样findConnection也是在newStream方法时被调用到的,因此也是在ConnetInterceptor.intercept方法时生成的。

自此整个http请求的请求和响应的逻辑就基本畅通了。当然OkHttp源码还有很多其他比较重要的逻辑,比如: 线程管理、IO操作等,我们后边的文章再详细讲。

下篇预告:
OkHttp源码解析(二) — 线程管理

weex工作原理简介

weex的设计理念是: “write once run anywhere”, 相对于ReactNative的“learn once write anywhere”更吸引人。因此Weex从设计之初就是为了做彻底的跨端方案,个人感觉,做为跨端方案单纯从设计角度看,weex要优于RN, 当然weex是站在RN 巨人的肩膀上,借助了大量RN的方案,肯定在某些方面要优于RN才对, 否则就真的是完全重造轮子了…

下面就看一下weex的工作原理, weex的整体架构如下:

开发者可在本地编写Vue文件,通过命令行工具编译成js代码, 生成一个weex的JS bundle, js bundle可以部署到CDN,通过网络请求或者预下载的方式加载到用户端,客户端的Weex SDK会准备好JavaScript执行环境,并在用户打开一个Weex页面是,执行相应的JS bundle, 并将执行时的各种命令发送到Native端进行界面渲染、数据存储、调用设备能力等。

下边对图上的几个概念做一些简要说明:

Weex File:
Weex的源文件(最新的Weex版本支持的是Vue文件),如果想用React, 也可以用Rax(兼容React接口), 甚至如果可能,可以支持更多的前端框架。因为根据Weex设计前端框架仅仅是语法层(或者叫DSL), 它与原生渲染引擎是分离的。当然自己扩展支持另一套前端框架也比较麻烦,需要做不少工作。

transformer:
transformer作用是将源文件(例如.vue)编译成可以直接在JS运行时中运行的js bundle文件,

JS Bundle:
编译完成输出的可以直接在Javascript运行时中运行的js文件

JS Framework:
JS Framework 负责 接收并执行JS Bundle,做模板编译、数据绑定等操作,输出json格式的Virtual Dom操作传递给iOS/Android Native, 用来衔接JS Bundle, 前端框架和Native

JavascriptCore/V8:
Javascript执行引擎,用来在Native环境执行JS代码,早期的weex版本在iOS下用的是JavascriptCore,在Android下用的是V8

RenderEngine:
WeexSDK中提供的在不同平台(iOS/Android/H5)实现的基于DOM模型的标准界面渲染接口,供Javascript引擎调用, 并实现了一套统一的基础组件。

根据WeexConf2018上的文章(https://yq.aliyun.com/articles/444904)显示,Weex架构要演化成如下的架构:

新的架构把更多通用的逻辑放到了WeexCore中,保证各个平台核心逻辑统一,降低维护成本,通过用c++实现,提高了Android端的代码执行性能。提供了标准的Weex Dom API层,简化了DSL接入成本,把Android、iOS的适配层做的尽量薄,有利于降低Weex扩展到新平台的成本。将JS引擎、布局引擎等通用模块做到可插拔,方面不同实现之间进行切换。

Android通用翻页组件的实现

在伴鱼绘本1.0版中,有两个很重要的核心功能是听绘本和录绘本,这两个功能在页面展现上形式类似,都是模拟看实体绘本书,一页一页的展示,其中有一个非常核心的交互体验设计是翻页效果的设计。当时找了几个效果给大家评估,其中非常复杂的逼近真实翻书体验的、带纸张卷曲效果的翻页效果,然而大家非常明智的认为,这个效果太复杂了,翻页还是应该简单直接一些,因此最终决定,还是简单一些,对于竖向的绘本(图片宽<)手机竖屏展示,模拟书脊在左边,对于横向(图片宽>)绘本手机横屏展示,书脊在中间,用户左右滑动屏幕时,跟随用户手势,做一个页面翻起并落下的效果即可。

然而,这个简单的效果也不是Android自带的,简单的翻了下Github也没有找到类似的实现,看来只能自己造轮子了。因为要左右滑动翻页来展示整个绘本内容,多的可能要20~30页,同时每页也要展示文本、页码等元素,使用ViewPager来实现这个左右滑动的效果是最恰当的。

ViewPager中,默认的页面切换效果是滑动,页面从左到右排列,跟随手势做滑动操作,翻了下ViewPagerAPI文档,以及源码,可以看到setPageTransformer可以设置自定义的页面切换效果,允许在scroll的时候自定义实现页面切换效果。ViewPagersetPageTransformer方法定义如下,

PageTransformer这个接口定义如下:

根据文档在页面Scroll的时候,会调用这个自定义的接口,来根据-1 1 的区间映射到页面从最左边到最右边的位置,这样应用可以添加自定义的变换到页面上以实现自定义的页面切换效果。查看ViewPager的源码,在onPageScrolled方法实现中找到mPageTransformer的调用如下:

也就是说每次onPageScroll 所有ViewPager的子View,除了标记为isDecor的, 也就是说所有的页都会调用一次mPageTransformer.transformPage,用来设置变换。看明白这个原理,很容易就能想到对于竖向的绘本页,在滑动时将每一页的X坐标位置都置到左上角,然后对于正在翻页的那一页绕Y轴做恰当角度的旋转变换,再加上一个透视视角,就能达到竖向绘本沿着书左书脊翻书的效果,代码如下:

主要是3部分逻辑,对于不显示的页,将其透明度置为0,将可能之前设置的Rotation, translation都清除,防止其影响画面展示, 对于画面中显示的两页,首先保证两页显示X坐标都为0且不变,由于ViewPager默认已经将两个页面移动了位置(源码见ViewPageronPageScrolled方法),所以需要做一个反向的setTranslationX操作,将页面复位,然后对于前边一页(正在翻的页),计算旋转角度,设置恰当的camera位置,对于后边一页(不动的页),清空旋转角度。

这个竖向的绘本页翻页动画关键逻辑就这么点了,相对比较简单。但是仔细想下横向的翻页,由于书脊在中间,在页面切换的过程中,某一页绘本半页做Y轴旋转,半页不动,ViewPager提供的这中自定义的扩展不灵了起码不是很直接就能解决问题了。

在绘本页上除了绘本图片还有相关文本的展示,因此不能只绘制图片,还需要处理文本,最好的方案是按整页View处理,而不是手动去绘制图片和文本。有一个思路是当监测到用户滑动页面的时候,不绘制ViewPager的页面,而是将需要显示每一页View截图成一张Bitmap,分成左右两个Bitmap,然后根据滑动距离对Bitmap做一定的变换,绘制出翻页效果。但是这样做有一个问题是每一页里边的绘本图片是异步加载的,可能用户滑动的过程中图片加载出来,这样也需要处理图片加载出来时再更新一下Bitmap,需要将图片下载的逻辑和Bitmap更新的逻辑耦合起来,没办法做成通用的组件,将页面的内容和页面切换效果隔离开来。

更通用的方案是在View的绘制周期做特殊处理,绘制出翻页效果,这样也利用到了View的内容刷新机制,只要View刷新就会自动触发重绘流程。 那么利用View/ViewGroup的那个绘制函数?因为整个翻页效果涉及到多个页元素的自定义绘制,因此自定义ViewPager(继承自ViewGroup)的某个绘制方法可能是合适的方案。一般可以重写drawChild,重写绘制单个子View的过程或者dispatchDraw完整的重写整个ViewGroup的绘制子View的过程,dispatchDraw的灵活性会更大一些,可以完整的控制绘制整个子View的过程,可以以任意顺序绘制。于是就可以根据滑动的比例计算出应该旋转的角度,然后canvasclip一半子View大小,这样就能半个半个的绘制子View实现从View中间翻转的效果。

还有一个点是ViewPager是动态新增和消耗View,甚至重用View的,如何根据页面position找到对应是哪个View在负责显示这一页,从而绘制正确的页?翻看ViewPager的源码,可以看到ViewPager.LayoutParams 有一个position 的变量 用来保存当前View显示的数据的position,但是这个变量是保护的,没办法直接取到,可以通过反射方式来取得具体的值。代码如下:

取得这个值以后,就能通过遍历所有子View的方式找到正确的View

绘制的过程并不复杂,分成两种情况,当前页翻转过程未到50%时,后一个页面右半部分可以显示出来部分,需要最先绘制(简单处理可以全部绘制或者只绘制右半部分,因为会被其他部分覆盖),当前页面左半边是正常绘制,右半边绕中心点旋转一定角度翻转透视,  然后绘制。代码如下(current表示当前第一个显示的页面):

第二种情况是翻转过了50%时, 当前页面只绘制左半边页面(优先绘制),后一页的左半页做一定旋转和透视变换,右半页正常绘制,代码如下:

这样整个动画的绘制就完成了,如果理解了整个View的绘制流程,以及坐标变换的一些知识,即便这种可能半个View需要做变换,另外半个View不太一样的情况,是不是也比较简单就能实现了?

如何实现一个简单的WebViewJavascriptBridge

在移动应用的开发的过程中,经常会有应用内部的活动,以及部分对灵活性要求很高的功能会采用基于WebView的方式实现,用来免去应用提交到应用市场的审核时间,以及用户的升级成本。这种最基础的Hybrid应用场景中,WebView页面中的javascript环境经常需要共享native环境(iOSAndroid)中的部分数据,例如: 用户登录授权信息、应用的版本信息等等,以及需要借助native的能力实现某些功能,例如: 录音、拍照、GPS位置信息、蓝牙等等,还有另外的场景是重用部分native已经实现的功能,例如:跳转到App内其他页面,调起native的某个功能弹窗等等,因此提供一个js native交互的通道是非常基础的一个需求。

原理

如果对AndroidiOS 的相应的WebViewapi比较熟悉的话,有这么几个API可以实现javascriptnative之间的互相调用。

Android下边的android.webkit.WebView, javascript调用native的实现方式:

首先在native端调用addJavascriptInterface (Object object, String name),将Java对象object注册到Javascript环境的主Frame上,这样就可以在javascript中调用到nativeJava对象的方法。为了安全方面的考虑在JELLY_BEAN_MR1 版本以上的Android系统中,只有Java对象的公开的并且有JavascriptInterface注解的方法才能被Javascript调用到。

native调用Javascript的方式:

KitKat版本之后,WebView提供了一个evaluateJavascript方法,执行传入的字符串表示的js代码并能返回结果。在这个版本之前只能通过loadUrl(“javascript:”)这种方式执行js代码。

iOS8新加入了WebView的实现WKWebView, 现在基本上应用支持到iOS8以上就够了,因此这里只考虑WKWebView。在WKWebViewjs调用native端可以通过WKUserContentControlleraddScriptMessageHandler:name实现,在native中调用这个方法将相应的handler添加到WKWebViewWKUserContentController上,在Javascript中就可以通过调用window.webkit.messageHandlers.handlerName.postMessagejs消息发送到nativeWKWebView同样实现了一个跟AndroidWebView类似的evaluateJavaScript的方法,用来在native端直接在WKWebViewjs环境执行字符串表示的Javascript代码。

业内的开源实现

当然业内也有比较出色的开源实现,最出名的是marcuswestin/WebViewJavascriptBridge, 这是一个iOS下边的WebViewJavascriptBridge库实现,实现了一个iOSjs-native双向调用的bridge,可以支持UIWebViewWKWebView。这个项目的解析在网上很多,在这里不再赘述。只讲几个很关键的设计:

1) 执行环境的保持

jsnative的互相调用过程中,如果能保持执行环境,以js调用native举例,如果可以封装给上层一个调用接口,同时支持方法回调,这样上层的代码写起来就会非常顺畅。这个的实现其实也比较简单,给回调生成一个唯一id,在js端保存这个id和回调方法的映射,将id传给native, native端处理完,回调的时候同时将这个id传回给js端,这样js端就能找到相应的回调函数并执行。

2) 消息排队机制

native端和js端,这个库都实现了一个消息排队机制,用来缓存bridge准备好之前的消息,这样上层就可以不再关心bridge是否准备好了的逻辑,更方便调用。

3) iFrame的方式

js端新插入了一个不可见的iFrame,通过修改这个iFramesrc为自定义的schema来通知native端更新,这样可以保证完全不影响任何主Frame的逻辑。当然这种方式主要受限于UIWebView的接口,只能通过修改src的方式来让native端监听到变化,如果是WKWebView则完全没有必要用这种方式处理。

简化的设计

我们希望做一个iOS/Android下边通用的WebViewJavascriptBridge,在js端、iOS端、Android端统一接口,因此并不能直接使用这个开源实现。理论上我们可以根据这个库的实现,在js端稍作修改,判断Android环境上走javascript调用java的方法,而不是修改iFramesrc,同时在Java端解析发过来的消息,跟iOS端一样的解析和处理逻辑,就能实现三端统一的接口。

但是,这是最简单的方式吗?结合我们自己的需求,因为不需要兼容UIWebView, iOS端和Android端都可以在一开始就将相应handler注册进去,所以js一开始加载就可以调用到相应的handler,在js端不需要做消息排队机制,完全可以直接调用。同样因为不用兼容UIWebViewjs也就没必要创建iFrame来实现通知native端消息。那么native端消息排队的机制是必须的吗?如果native主动调用javascript,而javascript加载是异步的,native端除非限制只能在页面加载完之后再调用,否则必须增加消息排队机制,来缓存bridge准备好之前的调用消息。

我们考虑软件分层设计,把客户端作为更为基础的不易变的下层,把h5做为更灵活易变的上层,通常下层是不允许主动调用上层的,而是提供好基础的接口,等待上层调用,如果有状态变化或者其他需要通知上层的,通常是通过上层注册通知回调、消息监听等机制来实现。因此我们应该只需要实现js调用native的方法,并支持方法回调的异步处理机制,以及支持消息监听机制即可满足所有需求。

js调用native的方法可以很自然的联想到RPC(远程过程调用)js等价于RPC中的客户机,native等价于RPC中的服务端,中间的传输不是经过网络而是经过webview提供的js环境和native环境之间的字符串传输。因此可以参考RPC的设计,js需要实现的是每次调用生成唯一id,组装方法名和参数,保存这次调用的回调方法,将这些信息:调用id,调用方法名、方法参数转成字符串,传递给nativenative需要实现的就是接收方法名、方法参数,然后找到对应的方法并执行,并将执行结果返回给jsjs再根据客户端传回的调用id和结果信息找到相应的回调方法并执行。实现js监听native的事件也非常简单,对比RPC调用的每次调用一次回调,监听的方式是一次调用(或者说注册监听),可以允许多次回调,这样native需要将js端注册的回调记录下,当事件发生时,多次调用这个回调,js端在收到native的回调成功后不把回调方法移除,这样就能够收到native的多次回调。

模块化

如果native提供方法很多,则需要根据不同的业务来提供不同的方法实现。可用的模块化方式很多,比如最基本的每个模块自己注册js方法的处理函数的方式,在iOS下也可以利用OCruntime机制将js调用的方法名映射到OC中的方法,在Android下类似,也可以利用Java的反射机制将js调用的方法名映射成Java的类和方法名。

安全性

如果我们nativewebview打开了一个第三方的网站,如果这些方法暴露给了这些第三方网站的js,假设native实现的方法返回了一些敏感信息,那么第三方网站就能获取这些敏感信息。因此native需要判断调用的合法性,我们自己网站可以全部支持httpsnative需要禁止任何http站点的调用,强校验站点的https证书,只有自己站点才允许调用native提供的方法。当然native提供的方法最好不要返回任何敏感信息。