Android程式設計師面試會遇到的演算法(part 4 訊息佇列的應用)

qing的世界發表於2018-05-09

好久沒有更新了,前段時間因為簽證的問題一直很鬧心所以沒有寫東西。

今天雖然依然沒有好訊息,而且按照往年的資料,現在還抽不中H1b的估計都沒戲了,也可能我的矽谷夢就會就此破滅。。。

但是想了想,生活還得繼續,學習不能停下。我還是要按照正常的節奏來。

這一期就主要給大家介紹在安卓應用或者輪子中最常見的一個設計,就是訊息佇列

message-queue-small.png

我這次會以一個簡單的例子來一步步的展示訊息佇列這種設計的應用,最後會借鑑Java和安卓原始碼中對訊息佇列實現的例項來做一個簡化版的程式碼,希望大家在看完這篇文章之後在自己今後的app開發,或者輪子開發中能利用訊息佇列設計來優化程式碼結構,讓程式碼更加可讀。

1.網路請求(Volley)

相信大部分安卓開發者都有用過這個叫Volley的網路請求庫,底層的網路請求實際上是用HttpUrlConnection類或者HttpClient這個庫做的。Volley在這些基礎庫上做了封裝,例如執行緒的控制,快取和回撥。這裡我們詳細說說大部分網路請求佇列的處理。

一個最基本最簡單的設計是,使用一個執行緒(非主執行緒),不停的從一個佇列中獲取請求,處理完畢之後從佇列丟擲並且發射回撥,回撥確保在主執行緒執行。

實現起來非常簡單,這裡借鑑Volley原始碼的設計,簡化一下:

/**
簡化版本的請求類,包含請求的Url和一個Runnable 回撥
**/
class Request{
	public String requestUrl;
    public Runnable callback;
	public Request(String url, Runnable callback)
    {
    	this.requestUrl = url;
        this.callback = callback;
    }
    
}

//訊息佇列
Queue<Request> requestQueue = new LinkedList<Request>();

new Thread( new Runnable(){
    public void run(){
    	//啟動一個新的執行緒,用一個True的while迴圈不停的從佇列裡面獲取第一個request並且處理
		while(true){
    		if( !requestQueue.isEmpty() ){
        		Request request = requestQueue.poll();
        		String response = // 處理request 的 url,這一步將是耗時的操作,省略細節
            	new Handler( Looper.getMainLooper() ).post( request.callback )
       		 }
    	}
    }
}).start();


複製程式碼

上面這一系列程式碼就把我們的準備工作做好了。那麼往這個傻瓜版輪子裡面新增一個請求就非常簡單了。


requestQueue.add( new Request("http.....", new Runnable(  -> //do something  )) );

複製程式碼

就這樣,一個簡化版的網路請求的輪子就完成了,是不是很簡單,雖然我們沒有考慮同步,快取等問題,但其實看過Volley原始碼的朋友也應該清楚,Volley的核心就是這樣的佇列,只不過不是一個佇列,而是兩種佇列(一個佇列真正的進行網路請求,一個是嘗試從快取中找對應request的返回內容)

程式碼的核心也就是用while迴圈不停的彈出請求,再處理而已。

2.傳送延遲訊息

訊息佇列的還有一種玩法就是傳送延遲訊息,比如說我想控制當前傳送的訊息在三秒之後處理,那這樣應該怎麼寫我們的程式碼呢,畢竟在網路請求的例子裡面,我們完全不在乎訊息的執行順序,把請求丟進佇列之後就就開始等待回撥了。

這個時候我們可以採用連結串列這個資料結構來取代佇列(當然Java裡面連結串列可以作為佇列的例項),按照每個請求或者訊息的執行時間進行排序。

廢話不多說,先上簡版程式碼。

//一個訊息的類結構,除了runnable,還有一個該Message需要被執行的時間execTime,兩個引用,指向該Message在連結串列中的前任節點和後繼節點。
public class Message{
	public long execTime = -1;
    public Runnable task;
    public Message prev;
    public Message next;

	public Message(Runnable runnable, long milliSec){
    	this.task = runnable;
        this.execTime = milliSec;
    }

}




public class MessageQueue{

	//維持兩個dummy的頭和尾作為我們訊息連結串列的頭和尾,這樣做的好處是當我們插入新Message時,不需要考慮頭尾為Null的情況,這樣程式碼寫起來更加簡潔,也是一個小技巧。
    //頭的執行時間設定為-1,尾是Long的最大值,這樣可以保證其他正常的Message肯定會落在這兩個點之間。
	private Message head = new Message(null,-1);
    private Message tail = new Message(null,Long.MAX_VALUE);
    
    public void run(){
    
    	new Thread( new Runnable(){
        	public void run(){
            //用死迴圈來不停處理訊息
            while(true){
            		//這裡是關鍵,當頭不是dummy頭,並且當前時間是大於或者等於頭節點的執行時間的時候,我們可以執行頭節點的任務task。
            			if( head.next != tail && System.currentTimeMillis()>= head.next.execTime ){
                    	//執行的過程需要把頭結點拿出來並且從連結串列結構中刪除
             			Message current = head.next;
                        Message next = current.next;
                   		current.task.run();
                        current.next = null;
                        current.prev =null;
                        head.next = next;
                        next.prev = head;
                       
                	}
                }
            }
        }).start();
    
    }
    
    public void post(Runnable task){
    	//如果是純post,那麼把訊息放在最尾部
    	Message message = new Message( task,  System.currentMilliSec() );
        Message prev = tail.prev;
        
        prev.next = message;
        message.prev = prev;
        
        message.next = tail;
        tail.prev = message;
            
        
    }
    
    
    public void postDelay(Runnable task, long milliSec){
    
    	//如果是延遲訊息,生成的Message的執行時間是當前時間+延遲的秒數。
        Message message = new Message( task,  System.currentMilliSec()+milliSec);

		//這裡使用一個while迴圈去找第一個執行時間在新建立的Message之前的Message,新建立的Message就要插在它後面。
    	Message target = tail;
        while(target.execTime>= message.execTime){
            target = target.prev;
        }
            
        Message next = target.next;
            
        message.prev = target;
        target.next = message;
        
        message.next = next;
        next.prev = message;
           
    }
}

複製程式碼

上述程式碼有幾個比較關鍵的點。

  1. 訊息採用連結串列的方式儲存,為的是方便插入新的訊息,每次插入尾部的時間複雜度為O(1),插入中間的複雜度為O(n),大家可以想想如果換成陣列會是什麼複雜度。
  2. 程式碼中可以用兩個Dummy node作為頭和尾,這樣我們每次插入新訊息的時候不需要檢查空指標, 如果頭為空,我們插入Message還需要做 if(head == null){ head = message } else if( tail == null ){head.next = message; tail = message} 這樣的檢查。 3.每次傳送延遲訊息的時候,遍歷迴圈找到第一個時間比當前要插入的訊息的時間小。以下面這個圖為例子。

Screen Shot 2018-05-01 at 10.34.06 PM.png

當前插入Message時間為3的時候,它需要插入在1和5中間,那麼1節點就是我們上面程式碼迴圈中的最後的Target了。

這樣,我們就完成了一個延遲訊息的輪子了!哈哈,呼叫程式碼非常簡單。


MessageQueue queue = new MessageQueue();
//開啟queue的while迴圈
queue.run();

queue.post( new Runnable(....) )

//三秒之後執行
queue.postDelay( new Runnable(...) , 3*1000 )

複製程式碼

大家可能覺得post,和postDelay看起來非常眼熟,沒錯,這個就是安卓裡面Handler的經典方法

Screen Shot 2018-05-01 at 10.39.09 PM.png

在安卓系統中的原始碼裡面,postDelay就是運用上述的原理,只不過安卓系統對回收Message還有額外的處理。但是對於延遲訊息的傳送,安卓的Handler就是對其對應的Looper裡面的訊息連結串列進行處理,比較執行時間從而實現延遲訊息傳送的。

最後大家再思考一下,像上述程式碼的例子裡面,延遲三秒,是不是精確的做到了在當前時間的三秒後執行

答案當然是NO!

在這個設計下,我們只能保證:

假如訊息A延遲的秒數為X,當前時間為Y,系統能保證A不會在X+Y之前執行。 這樣其實很好理解,因為如果使用佇列來執行程式碼的話,你永遠不知道你前面那個Message的執行時間是多少,假如前面的Message執行時間異常的長。。。。那麼輪到當前Message執行的時候,肯定會比它自己的execTime偏後。但是這是可接受的。

如果我們需要嚴格讓每個Message按照設計的時間執行,那就需要Alarm,類似鬧鐘的設計了。大家有興趣可以想想看怎麼用最基本的資料結構實現。

3.執行緒池的實現

說到執行緒池,我一直有很多疑惑,網上很多文章都會以執行緒池最全解析,或者史上最詳細Java執行緒池原理諸如此類的Title為標題,但卻主要以怎麼操作Java執行緒池的API為內容。

在我看來這類文章都是耍流氓,對於一個合格的Java開發來說,如果連API都不會查,那乾脆別幹了,還需要你專門寫一篇文章來介紹API怎麼用嘛。。。。。我也一直在問我自己,為啥大家都對原始碼沒有興趣。。。。

download.jpeg

這個章節我就會用簡單版本的程式碼把執行緒池的實現給展示一下。

其實執行緒池的實現很簡單,就是使用一個佇列若干Thread就行了。


public class ThreadPool{

	//用一個Set或者其他資料結構把建立的執行緒儲存起來,為的是方便以後獲取執行緒的handle,做其他操作。
	Set<WorkerThread> set = null;
    private Queue<Runnable> queue;
    //初始化執行緒池,建立內部類WorkerThread並且啟動它
    public ThreadPool(int size){
    	for( int i = 0 ;i < size ;i++ ){
        	WorkerThread t = new WorkerThread();
            t.start();
            set.add( t );
        }
        queue = new LinkedList<Runnable>();
    }


	//submit一個runnable進執行緒池
    public void submit(Runnable runnable){
    	synchronized (queue){
        	queue.add(runnable);
        }
    }
    
    //WorkerThread用一個死迴圈不停的去向Runnable佇列拿Runnable執行。
    public class  WorkerThread extends Thread{
        @Override
        public void run() {
            super.run();
            while(true){
            	synchronized (queue){
                	Runnable current = queue.poll();
                    current.run();
                }
            }
        }
    }
    

}


複製程式碼

這樣,一個簡單版本的執行緒池就完成了。。。。使用一組Thread,不停的向Runnable佇列去拿Runnable執行就好了。。。看起來完全沒有技術含量。但是這卻是Java的執行緒池的基本原理。大家抽空可以去看看原始碼。還有很多細節我都沒有寫出來,比如說怎麼shutdown執行緒池,或者執行緒池內部的WorkerThread怎麼處理異常。怎麼設定最大執行緒數量等等。

注意點不多,就是要使用synchronized對併發部分的程式碼做好同步就可以了。

呼叫程式碼簡單

ThreadPool pool = new ThreadPool(5);

pool.submit(new Runnable(...))

複製程式碼

華麗麗的分割線


後記

這一期的分享結束啦,其實上面三個例子都是大部分安卓開發者會接觸到的,如果稍微有點興趣和耐心就可以明白其原理,都是用最簡單的資料結構加最“幼稚”的設計完成的。

最後我還想說,希望每個安卓開發者都能有一顆疑問的心, 比如執行緒池,基於Java的Thread這個類,怎麼去完成一個執行緒池的實現,如果每次在使用這些API之後都能問問自己,為什麼,保持一顆願意提問的心,這些都能學會。願大家都能有且保持這種熱忱。

我也需要時刻提醒自己,無論能不能去矽谷都好,都要一直有這種熱情,一刻也不能懈怠。如果我的熱情因為不能去矽谷而破滅,那我的堅持也太脆弱了。

相關文章