Android網絡庫之Okio源碼分析

今天來扒一扒Square公司的IO流的庫Okio,現在越來越多Android項目都在使用Square公司的網絡開源全家桶,即 Okio + OkHttp + Retrofit。這三個庫的層級是從下網上來看,Okio用來處理IO流,OkHttp用來實現Http協議,Retrofit用來做Android端的網絡使用接口,關於Retrofit,之前寫過源碼分析。但是相對於RetrofitOkHttp,Okio就比較低調,因爲它偏底層,大部分同學對它可能不太熟悉,我們今天就來看看這個幕後英雄吧。

Square功德無量,著名的JakeWharton大神之前就一直在這家公司(據說今年7月份離職了)。

一 爲什麼需要Okio

首先我們需要強調Okio是一個Java庫,所以它的底層流肯定都是JavaIO中定義的基礎流。基礎流指的是Java中對於從不同數據來源抽象的流,比如是FileInputStream ByteInputStream PipedInputStream等,Okio只是優化了Java IO庫中對於基礎流包裝的API

1.1 更加簡潔

我們經常說JavaIO庫是JDK設計的比較精妙的一個API,由於使用了裝飾者模式,大大減少了類的數目。然而即便如此,大家使用時仍然感覺比較麻煩,比如我們想要從一個文件中讀取出來一個int數據,我們至少要創建如下幾個對象:

FileInputStream //用於打開文件流
BufferedInputStream //對FileInputStream做裝飾,添加buffer功能,避免頻繁IO
DataInputStream //用於將字節流轉化成Java基本類型

此時,通過DataInputStream.readInt()就可以讀出來一個int了。但是機智的你已經發現了,我們需要和至少三個跟輸入相關的類打交道,而Okio把這些都做了集中處理,你可能只需要一個類就可以很方便的進行以上的各種操作了。

1.2 提高性能

首先okio內部用一個Segment對象來描述內存數據,Segment對象中就有byte[]作爲數據的載體。對於Segment來說,Okio不是每次都去創建,而且通過一個對象池來做複用,這樣就可以減少對象創建,銷燬代價,實際上也可以減少byte[]數組zero-fill的代價。關於Segment,我們會在第四章專門進行介紹。

其次,Okio中多個流之間的數據是可以共享的,而不需要進行內存拷貝。我們舉個栗子,如果我使用Java IO從一個文件中讀取數據,寫入另外一個文件中,代碼大致如下:

 BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(new File("in.file")));
 BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(new File("out.file")));
 byte[] bytes = new byte[2048];
 int count;
 while ((count = bufferedInputStream.read(bytes)) != -1) {
      bufferedOutputStream.write(bytes,0,count);
 }
bufferedOutputStream.flush();

BufferedInputStream內部有個緩存,每次read的時候,先看看自己緩存中是否滿足讀取方的需要,如果滿足,直接從內部緩存中做內存copy到讀取方傳入的byte數組(步驟一);如果不滿足,調用fill方法從原始流中讀取數據到內部緩存,重複步驟一。同時,BufferedOutPutStream中也有個內部緩存,寫入方先把數據寫入到內部緩存,然後再由內部緩存統一寫入到原始輸出IO,如果使用okio,應該可以減少兩份數據copy,第二節我們對兩種方式從使用和性能進行了對比。

1.3 超時看門狗

Java IO對於數據流的處理是沒有超時概念的,比如從某個流中讀取數據如果輸入流一直沒有數據,那麼當前工作線程就會一直阻塞(當然NIO裏面是異步的,我們這裏不討論)。Okio中所有關於流的操作都可以設置超時器,用來做超時處理。比如,從一個InputStream讀取數據,如果3S沒有響應數據,應用就可以考慮這個數據源頭可能已經發生錯誤了,可以嘗試過段時間再嘗試。

二 流程總述

2.1 來個栗子

爲了更加深入瞭解Okio的原理,我們把第一節中的栗子用Okio重新實現一遍。

BufferedSource bufferedSource = Okio.buffer(Okio.source(new File("in.file")));
BufferedSink bufferedSink = Okio.buffer(Okio.sink(new File("out.file")));
bufferedSink.writeAll(bufferedSource);
bufferedSink.flush();

這裏功能還是一樣的,從一個文件讀取數據,寫入另外一個文件。你可以看到,這裏代碼比之前使用JavaIO庫的代碼簡潔了不少,連我們經常寫的while讀取都省下來了。我能告訴你這段代碼的性能也比上面使用Java IO的好嗎?我做了一個簡單的測試,讀取一個20M左右的文件,對比JavaIOOkio分別進行2000次重複操作,打印一下大概的耗時:

COOL!

2.2 流程分析

既然Okio看起來這麼叼叼的,我們這一小節就來看看它大概流程是怎麼走的。

2.2.1 輸入輸出

對於IO來說,就是兩件事情,輸入和輸出,所謂輸入就是從IO設備(硬盤、網絡、外設等)中讀取數據到內存,輸出就是把內存中的數據輸出到IO設備。

Java中對於IO流的定義有內存流的概念,比如將一個字符串或者一個byte數組作爲輸入數據源,上面定義並沒有涵蓋這個例外。

不管是在JavaIO或者是Okio中輸入輸出都是對稱的,JavaIO中有你熟悉的InputStream和OutputStream,在Okio中對應爲 Source 和 Sink,由於輸入輸出是對稱的,下面我們只聊輸入流就好了。

首先我們看一下InputStream的接口定義:

 public abstract int read() throws IOException;
 public int read(byte b[]) throws IOException;

我們看到InputStream的接口定義很清晰,read方法要不通過返回值返回數據,要不通過外面傳入的byte數組來帶回數據,數據的來源就是原始流。
相對於InputStream,Source的定義稍微有點繞,

public interface Source extends Closeable {
  long read(Buffer sink, long byteCount) throws IOException;
  Timeout timeout();
}

你會看到這個地方的read方法的入參是一個Buffer參數,表示從當前Source流中讀出byteCount個字節放到Buffer中(參數名爲sink,代表輸出),那Buffer是什麼?不要着急,我們第四節將會單獨來說,這裏我們先結合一個最簡單的栗子一步步看調用流程吧。

從文件test.file中讀出來內容當成UTF-8編碼的字符串。

BufferedSource bufferedSource = Okio.buffer(Okio.source(new File("test.file")));
String s = bufferedSource.readUtf8();
2.2.2 適配InputStream

Okio.source(new File("test.file")),就可以創建一個Source了,當然這個Source的基礎流肯定是個FileInputStream:

public static Source source(File file) throws FileNotFoundException {          
 return source(new FileInputStream(file));
}

看起來,所有的邏輯應該是在Okio.source(InputStream in)這個方法中,它就是把一個普通的JavaIO的InputStream適配成Okio的Source的過程(這就是一個典型的適配器模式的應用)。

public static Source source(InputStream in) {
  return source(in, new Timeout());
 }

我們可以看到這裏出現了Timeout類,這就是我們所說的超時器,我們在第五章會重點介紹,這裏先忽略。

private static Source source(final InputStream in, final Timeout timeout) {
  return new Source() {
    public long read(Buffer sink, long byteCount) throws IOException {
        //將數據讀入Buffer
        return bytesRead;
    }

  @Override public void close() throws IOException {in.close();}

  @Override public Timeout timeout() {return timeout;}

  @Override public String toString() {return "source(" + in + ")";}
};
}

我們看到source方法的邏輯很簡單,生成一個Source類的內部類,主要邏輯就是代理基礎流的各種方法,read方法就是從基礎流中讀出數據加入到Buffer中。
到此,我們就通過Okio這個適配器,將一個普通的Java InputStream適配成了一個Source類。

裝飾Source

和JavaIO一樣,產生了Source之後,我們可以將它裝飾成BufferedSource。什麼是BufferedSource?就是爲Source提供了一個內部的Buffer作爲數據存儲的地方,你還記得Source接口的read方法嗎?它的入參需要一個Buffer,對於BufferedSource來說,傳入的就是內部自己的Buffer。此外,BufferedSource提供了一堆快捷API,比如readString等。Okio提供了BufferedSource的具體實現RealBufferedSource。

readUtf8()

到此,我們已經得到了一個RealBufferedSource,通過調RealBufferedSource的readUtf8()我們就得到了文件二進制內容,並以UTF-8進行編碼,我們看看readUtf8的實現吧:

public String readUtf8() throws IOException {
  buffer.writeAll(source);
  return buffer.readUtf8();
}

這裏我們看到,首先從Source讀取數據到Buffer中,然後調用Buffer的readUtf8()。
至此,我們已經分析了上面使用Okio讀取一個文件內容,並進行的整個流程。使用Okio適配InputStream,使用BufferedSource進行裝飾和接口加強,RealBufferedSource真正實現了BufferedSource,內部持有一個Buffer類,讀取數據之前,通過將Source中數據寫入Buffer,然後從Buffer中讀出來數據。

四 Buffer詳解

前面很多地方已經使用了Buffer這個類,但是我們並沒有細說,現在來重點分析一下。這個類是整個Okio中最最核心的結構之一,也是設計的非常巧妙的一個類,所有關於Okio性能提升的地方都是通過這個類實現的。首先我們來看一下這個類的繼承結構:



是不是毀三觀?是不是很驚訝?Buffer居然同時實現了BufferedSink和BufferedSource,即你既可以對它進行數據寫入,也可以從它做數據讀出。仔細想想前面說的,Source是Okio對InputStream的抽象,從Source讀取的數據被放到那裏去了?入參的Buffer!那我要使用這些數據怎麼辦?肯定從Buffer中取啊!所以Buffer設計成可寫可讀理解起來就沒有什麼問題了吧!

4.1 數據隊列

那Buffer是怎麼實現的呢?很簡單,Buffer本質上就是一個雙向列表,每次做數據寫入時都從表尾追加,讀取的時候從表頭進行讀取。表的每一個節點就是一個Segment。

如果你仔細想想就會發現這個設計比原生的JavaIO裏面的BufferedInputStream好在哪裏了,官方的BufferedInputStream內部的緩存對外是不可見的,每次都是先讀取到自己的緩存,然後從緩存中往外copy內存;Okio是先讀取到Buffer中,外面使用的時候,直接把Buffer中的數據共享出來。

我們還以前面的栗子來分析吧,通過Okio.source做InputStream到Source代理的時候,Source的read方法的實現展開如下:

public long read(Buffer sink, long byteCount) throws IOException {
      Segment tail = sink.writableSegment(1);
      int maxToCopy = (int) Math.min(byteCount, Segment.SIZE - tail.limit);
      int bytesRead = in.read(tail.data, tail.limit, maxToCopy);
      if (bytesRead == -1) return -1;
      tail.limit += bytesRead;
      sink.size += bytesRead;
      return bytesRead;
  }
獲取Buffer寫入位置

Segment tail = sink.writableSegment(1);獲取可寫入的尾節點,我們看看Buffer是怎麼做的:

Segment writableSegment(int minimumCapacity) {
  //如果Buffer還沒有數據,那麼就初始化一個頭
  if (head == null) {
    head = SegmentPool.take(); // Acquire a first segment.
    return head.next = head.prev = head;
  }
  //拿到最後一個Segment
Segment tail = head.prev;
//如果不夠放,或者不是owner,那麼新加一個Segment放到隊尾
if (tail.limit + minimumCapacity > Segment.SIZE || !tail.owner) {
  tail = tail.push(SegmentPool.take()); 
}
return tail;
}
寫入數據到Buffer

寫入Buffer中,其實是寫入到第一步返回的Segment的byte[]數組中,這裏其實就是使用了JavaIO中InputStream的read(byte[])方法,可以一次讀入多個字節,減少多次IO。

buffer.readUtf8()

從文件讀取的內容已經被放到了Buffer的Segement單鏈表中,我們調用readUtf8()就是簡單的把從表頭開始遍歷Segment,把數據解碼成UTF-8編碼的數據。

五 超時器

Okio提供了超時器的功能,之前我們通過Okio適配JavaIO時,默認都會給我們提供一個Timeout實例,現在我們來重點聊聊。
在Okio中對於超時的定義有兩種:
1、執行時間限制,這個操作需要在指定的時間段內完成,通過timeout方法指定;
2、截至時間限制,這個操作需要在某個時間點之前完成,通過deadLine方法指定。

Okio爲我們提供了兩個默認的超時器實現 Timeout 和 它的子類AsyncTimeout。其中Timeout是同步超時器,也是大部分Okio默認會給JavaIO添加的超時器,比如:

private static Source source(final InputStream in, final Timeout timeout) {
return new Source() {
  public long read(Buffer sink, long byteCount) throws   IOException {
      timeout.throwIfReached(); //判斷是否已經超時
      //do some thing
  }
};
}

所謂同步,就是在執行操作的線程裏面去判斷當前是否已經超時,其實同步判斷超時是不準的,我們知道使用JavaIO(非NIO)其實大部分操作都是阻塞的,那如果操作在阻塞階段發生了超時,同步肯定就發現不了。這個時候我們就需要異步的超時器AsyncTimeout,針對socket,Okio默認添加了異步超時器(因爲Okio的主要側重點還是在網絡IO方面,所以Socket有福利也很正常)。看看Okio中關於Socket的代碼:

  public static Source source(Socket socket) throws IOException {
    AsyncTimeout timeout = timeout(socket); //(1)
    Source source = source(socket.getInputStream(), timeout);   //(2)
    return timeout.source(source);//(3)
  }

(1) 生成AsyncTimeout

private static AsyncTimeout timeout(final Socket socket) {
return new AsyncTimeout() {
  protected IOException newTimeoutException(IOException cause) {
    InterruptedIOException ioe = new SocketTimeoutException("timeout");
    if (cause != null) {
      ioe.initCause(cause);
    }
    return ioe;
  }

  @Override protected void timedOut() {
    //超時器超時時需要進行的操作
      socket.close();
  }
};

}
我們看到生成的AsyncTimeout主要邏輯就是處理超時時應該怎麼處理,這裏是直接關閉socket。
(2) 生成Source,這個代碼前面已經分析過了,不再贅述
(3)用AsyncTimeout裝飾一下Source,不僅僅是在JavaIO中大量使用裝飾者,在Okio中同樣大量使用了。

  public final Source source(final Source source) {
return new Source() {
  @Override public long read(Buffer sink, long byteCount) throws IOException {
    //進入工作        
    enter();
    try {
        //do some thing
    } finally {
      //退出工作
      exit();
    }
  }
}

首先在每一次read之前,我們調用enter()方法,將前這個AsyncTimeout加入到一個加入單鏈表中,單鏈表中的AsyncTimeout按照最近到期的順序插入。
後臺有一個看門狗線程,WatchDog,它不斷的從這個單鏈表中讀取首個超時器,取出超時時間,並直接wait阻塞;如果一個超時器時間到了,就會直接調用它的timeout方法,就完成了超時的邏輯了。關於超時邏輯判斷的這塊邏輯有興趣的自己可以查看,還挺有意思的。

需要第一步(1)中複寫的timedOut,這是超時器能夠工作的關鍵。你想想如果工作線程正和socket進行數據交換,此時是block的,異步的看門狗發現這個數據過程超時了,它怎麼通知工作線程呢?這就是爲什麼AsyncTimeout需要持有socket的原因,看門狗發現超時了,它就會直接調用socket.close,(3)中的dosomething就必然會拋出一個異常,因爲底下的流都被關閉了。

總結

Okio作爲Square公司專門爲網絡IO設計的一個流加強庫,極大簡化了各種IO操作的使用。除了上面分析的,Okio還提供了ByteString作爲Byte數組數據向各種格式做轉化的Utils類,同時,作爲網絡中常用的Gzip壓縮、Hash摘要,Okio也方便的提供了流操作。這些都比較簡單,各位看官可以自己去看源碼分析吧。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章