Android和iOS開發中的非同步處理(一)——開篇

張鐵蕾發表於2016-08-16

本文是我打算完成的一個系列《Android和iOS開發中的非同步處理》的開篇。

從2012年開始開發微愛App的第一個iOS版本計算,我和整個團隊接觸iOS和Android開發已經有4年時間了。現在回過頭來總結,iOS和Android開發與其它領域的開發相比,有什麼獨特的特徵呢?一個合格的iOS或Android開發人員,應該具備哪些技能呢?

如果仔細分辨,iOS和Android客戶端的開發工作仍然可以分為“前端”和“後端”兩大部分(就如同伺服器的開發可以分為“前端”和“後端”一樣)。

所謂“前端”工作,就是與UI介面更相關的部分,比如組裝頁面、實現互動、播放動畫、開發自定義控制元件等等。顯然,為了能遊刃有餘地完成這部分工作,開發人員需要深入瞭解跟系統有關的“前端”技術,主要包含三大部分:

  • 渲染繪製(解決顯示內容的問題)
  • layout(解決顯示大小和位置的問題)
  • 事件處理(解決互動的問題)

而“後端”工作,則是隱藏在UI介面背後的東西。比如,操縱和組織資料、快取機制、傳送佇列、生命週期設計和管理、網路程式設計、推送和監聽,等等。這部分工作,歸根結底,是在處理“邏輯”層面的問題,它們並不是iOS或Android系統所特有的東西。然而,有一大類問題,在“後端”程式設計中佔據了極大的比重,這就是如何對“非同步任務”進行“非同步處理”。

尤其值得指出的是,大部分客戶端開發人員,他們所經歷的培訓、學習經歷和開發經歷,似乎都更偏重“前端”部分,而在“後端”程式設計的部分存在一定的空白。因此,本文會嘗試把與“後端”程式設計緊密相關的“非同步處理”問題進行總結概括。

本文是系列文章《Android和iOS開發中的非同步處理》的第一篇,表面上看起來話題不算太大,卻至關重要。當然,如果我打算強調它在客戶端程式設計中的重要性,我也可以說:縱觀整個客戶端程式設計的過程,無非就是在對各種“非同步任務”進行“非同步處理”而已——至少,對於與系統特性無關的那部分來說,我這麼講是沒有什麼大的問題的。

那麼,這裡的“非同步處理”,到底指的是什麼呢?

我們在程式設計當中,經常需要執行一些非同步任務。這些任務在啟動後,呼叫者不用等待任務執行完畢即可接著去做其它事情,而任務什麼時候執行完是不確定的,不可預期的。本文要討論的就是在處理這些非同步任務過程中所可能涉及到的方方面面。

為了讓所要討論的內容更清楚,先列一個提綱如下:

  • (一)概述——介紹常見的非同步任務,以及為什麼這個話題如此重要。

  • (二)非同步任務的回撥——討論跟回撥介面有關的一系列話題,比如錯誤處理、執行緒模型、透傳引數、回撥順序等。

  • (三)多個非同步任務協作

  • (四)非同步任務和佇列

  • (五)非同步任務的取消和暫停,以及start ID——Cancel掉正在執行的非同步任務,實際上非常困難。

  • (六)關於封屏與不封屏

  • (七)Android Service例項分析——Android Service提供了一個執行非同步任務的嚴密框架 (後面也許會再多提供一些其它的例項分析,加入到這個系列中來)。

顯然,本篇文章要討論的是提綱的第(一)部分。

為了描述清楚,這個系列文章中出現的程式碼已經整理到GitHub上(持續更新),程式碼庫地址為:

其中,當前這篇文章中出現的Java程式碼,位於com.zhangtielei.demos.async.programming.introduction這個package中;而iOS的程式碼位於iOSDemos單獨的目錄中。

下面,我們先從一個具體的小例子開始:Android中的Service Binding。

public class ServiceBindingDemoActivity extends Activity {
    private ServiceConnection serviceConnection = new ServiceConnection() {
        @Override
        public void onServiceDisconnected(ComponentName name) {
            //解除Activity與Service的引用和監聽關係
            ...
        }

        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            //建立Activity與Service的引用和監聽關係
            ...
        }
    };

    @Override
    public void onResume() {
        super.onResume();

        Intent intent = new Intent(this, SomeService.class);
        bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
    }

    @Override
    public void onPause() {
        super.onPause();

        //解除Activity與Service的引用和監聽關係
        ...

        unbindService(serviceConnection);
    }
}複製程式碼

上面的例子展示了Activity和Service之間進行互動的一個典型用法。Activity在onResume的時候與Service繫結,在onPause的時候與Service解除繫結。在繫結成功後,onServiceConnected被呼叫,這時Activity拿到傳進來的IBinder的例項(service引數),便可以通過方法呼叫的方式與Service進行通訊(程式內或跨程式)。比如,這時在onServiceConnected中經常要進行的操作可能包括:將IBinder記錄下來存入Activity的成員變數,以備後續呼叫;呼叫IBinder獲取Service的當前狀態;設定回撥方法,以監聽Service後續的事件變化;等等,諸如此類。

這個過程表面看上去無懈可擊。但是,如果考慮到bindService是一個“非同步”呼叫,上面的程式碼就會出現一個邏輯上的漏洞。也就是說,bindService被呼叫只是相當於啟動了繫結過程,它並不會等繫結過程結束才返回。而繫結過程何時結束(也即onServiceConnected被呼叫),是無法預期的,這取決於繫結過程的快慢。而按照Activity的生命週期,在onResume之後,onPause也隨時會被執行。這樣看來,在bindService執行完後,可能onServiceConnected會先於onPause執行,也可能onPause會先於onServiceConnected執行。

當然,在一般情況下,onPause不會那麼快執行,因此onServiceConnected一般都會趕在onPause之前執行。但是,從“邏輯”的角度,我們卻不能完全忽視另外一種可能性。實際上它真的有可能發生,比如剛開啟頁面就立即退到後臺,這種可能性便能以極小的概率發生。一旦發生,最後執行的onServiceConnected會建立起Activity與Service的引用和監聽關係。這時應用很可能是在後臺,而Activity和IBinder卻可能仍互相引用著對方。這可能造成Java物件長時間釋放不掉,以及其它一些詭異的問題。

這裡還有一個細節,最終的表現其實還取決於系統的unbindService的內部實現。當onPause先於onServiceConnected執行的時候,onPause先呼叫了unbindService。如果unbindService在呼叫後能夠嚴格保證ServiceConnection的回撥不再發生,那麼最終就不會造成前面說的Activity和IBinder相互引用的情況出現。但是,unbindService似乎沒有這樣的對外保證,而且根據個人經驗,在Android系統的不同版本中,unbindService在這一點上的行為還不太一樣。

像上面的分析一樣,我們只要瞭解了非同步任務bindService所能引發的所有可能情況,那就不難想出類似如下的應對措施。

public class ServiceBindingDemoActivity extends Activity {
    /**
     * 指示本Activity是否處於running狀態:執行過onResume就變為running狀態。
     */
    private boolean running;

    private ServiceConnection serviceConnection = new ServiceConnection() {
        @Overridepublic void onServiceDisconnected(ComponentName name) {
            //解除Activity與Service的引用和監聽關係
            ...
        }

        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            if (running) {
                //建立Activity與Service的引用和監聽關係
                ...                
            }
        }​​
    };

    @Override
    public void onResume() {
        super.onResume();
        running = true;

        Intent intent = new Intent(this, SomeService.class);
        bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
    }

    @Override
    public void onPause() {
        super.onPause();
        running = false;

        //解除Activity與Service的引用和監聽關係
        ...

        unbindService(serviceConnection);

    }
}複製程式碼

下面我們再來看一個iOS的小例子。

現在假設我們要維護一個客戶端到伺服器的TCP長連線。這個連線在網路狀態發生變化時能夠自動進行重連。首先,我們需要一個能監聽網路狀態變化的類,這個類叫做Reachability,它的程式碼如下:

//
//  Reachability.h
//
#import <Foundation/Foundation.h>
#import <SystemConfiguration/SystemConfiguration.h>

extern NSString *const networkStatusNotificationInfoKey;
extern NSString *const kReachabilityChangedNotification;

typedef NS_ENUM(uint32_t, NetworkStatus) {
    NotReachable = 0,
    ReachableViaWiFi = 1,
    ReachableViaWWAN = 2
};

@interface Reachability : NSObject {
@private
    SCNetworkReachabilityRef reachabilityRef;
}

/**
 * 開始網路狀態監聽
 */
- (BOOL)startNetworkMonitoring;
/**
 * 結束網路狀態監聽
 */
- (BOOL)stopNetworkMonitoring;
/**
 * 同步獲取當前網路狀態
 */
- (NetworkStatus) currentNetworkStatus;
@end

//
//  Reachability.m
//
#import "Reachability.h"
#import <sys/socket.h>
#import <netinet/in.h>

NSString *const networkStatusNotificationInfoKey = @"networkStatus";
NSString *const kReachabilityChangedNotification = @"NetworkReachabilityChangedNotification";

@implementation Reachability

- (instancetype)init {
    self = [super init];
    if (self) {
        struct sockaddr_in zeroAddress;
        memset(&zeroAddress, 0, sizeof(zeroAddress));
        zeroAddress.sin_len = sizeof(zeroAddress);
        zeroAddress.sin_family = AF_INET;

        reachabilityRef = SCNetworkReachabilityCreateWithAddress(kCFAllocatorDefault, (const struct sockaddr*)&zeroAddress);

    }

    return self;
}

- (void)dealloc {
    if (reachabilityRef) {
        CFRelease(reachabilityRef);
    }
}

static void ReachabilityCallback(SCNetworkReachabilityRef target, SCNetworkReachabilityFlags flags, void* info) {

    Reachability *reachability = (__bridge Reachability *) info;

    @autoreleasepool {
        NetworkStatus networkStatus = [reachability currentNetworkStatus];
        [[NSNotificationCenter defaultCenter] postNotificationName:kReachabilityChangedNotification object:reachability userInfo:@{networkStatusNotificationInfoKey : @(networkStatus)}];
    }
}

- (BOOL)startNetworkMonitoring {
    SCNetworkReachabilityContext context = {0, (__bridge void * _Nullable)(self), NULL, NULL, NULL};

    if(SCNetworkReachabilitySetCallback(reachabilityRef, ReachabilityCallback, &context)) {
        if(SCNetworkReachabilityScheduleWithRunLoop(reachabilityRef, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode)) {
            return YES;
        }

    }

    return NO;
}

- (BOOL)stopNetworkMonitoring {
    return SCNetworkReachabilityUnscheduleFromRunLoop(reachabilityRef, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
}

- (NetworkStatus) currentNetworkStatus {
    //此處程式碼忽略...
}

@end複製程式碼

上述程式碼封裝了Reachability類的介面。當呼叫者想開始網路狀態監聽時,就呼叫startNetworkMonitoring;監聽完畢就呼叫stopNetworkMonitoring。我們設想中的長連線正好需要建立和呼叫Reachability物件來處理網路狀態變化。它的程式碼的相關部分可能會如下所示(類名ServerConnection;標頭檔案程式碼忽略):

//
//  ServerConnection.m
//
#import "ServerConnection.h"
#import "Reachability.h"

@interface ServerConnection() {
    //使用者執行socket操作的GCD queue
    dispatch_queue_t socketQueue;
    Reachability *reachability;
}
@end

@implementation ServerConnection

- (instancetype)init {
    self = [super init];
    if (self) {
        socketQueue = dispatch_queue_create("SocketQueue", NULL);

        reachability = [[Reachability alloc] init];
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(networkStateChanged:) name:kReachabilityChangedNotification object:reachability];
        [reachability startNetworkMonitoring];
    }
    return self;
}

- (void)dealloc {
    [reachability stopNetworkMonitoring];
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}


- (void)networkStateChanged:(NSNotification *)notification {
    NetworkStatus networkStatus = [notification.userInfo[networkStatusNotificationInfoKey] unsignedIntValue];
    if (networkStatus != NotReachable) {
        //網路變化,重連
        dispatch_async(socketQueue, ^{
            [self reconnect];
        });
    }
}

- (void)reconnect {
    //此處程式碼忽略...
}
@end複製程式碼

長連線ServerConnection在初始化時建立了Reachability例項,並啟動監聽(呼叫startNetworkMonitoring),通過系統廣播設定監聽方法(networkStateChanged:);當長連線ServerConnection銷燬的時候(dealloc)停止監聽(呼叫stopNetworkMonitoring)。

當網路狀態發生變化時,networkStateChanged:會被呼叫,並且當前網路狀態會被傳入。如果發現網路變得可用了(非NotReachable狀態),那麼就非同步執行重連操作。

這個過程看上去合情合理。但是這裡面卻隱藏了一個致命的問題。

在進行重連操作時,我們使用dispatch_async啟動了一個非同步任務。這個非同步任務在啟動後什麼時候執行完,是不可預期的,這取決於reconnect操作執行的快慢。假設reconnect執行比較慢(對於涉及網路的操作,這是很有可能的),那麼可能會發生這樣一種情況:reconnect還在執行中,但ServerConnection即將銷燬。也就是說,整個系統中所有其它物件對於ServerConnection的引用都已經釋放了,只留下了dispatch_async排程時block對於self的一個引用。

這會導致什麼後果呢?

這會導致:當reconnect執行完的時候,ServerConnection真正被釋放,它的dealloc方法不在主執行緒執行!而是在socketQueue上執行。

而這接下來又會怎麼樣呢?這取決於Reachability的實現。

我們來重新分析一下Reachability的程式碼來得到這件事發生的最終影響。這個情況發生時,Reachability的stopNetworkMonitoring在非主執行緒被呼叫了。而當初startNetworkMonitoring被呼叫時卻是在主執行緒的。現在我們看到了,startNetworkMonitoring和stopNetworkMonitoring如果前後不在同一個執行緒上執行,那麼在它們的實現中的CFRunLoopGetCurrent()就不是指的同一個Run Loop。這已經在邏輯上發生“錯誤”了。在這個“錯誤”發生之後,stopNetworkMonitoring中的SCNetworkReachabilityUnscheduleFromRunLoop就沒有能夠把Reachability例項從原來在主執行緒上排程的那個Run Loop上卸下來。也就是說,此後如果網路狀態再次發生變化,那麼ReachabilityCallback仍然會執行,但這時原來的Reachability例項已經被銷燬過了(由ServerConnection的銷燬而銷燬)。按上述程式碼的目前的實現,這時ReachabilityCallback中的info引數指向了一個已經被釋放的Reachability物件,那麼接下來發生崩潰也就不足為奇了。

有人可能會說,dispatch_async執行的block中不應該直接引用self,而應該使用weak-strong dance. 也就是把dispatch_async那段程式碼改成下面的形式:

        __weak ServerConnection *wself = self;
        dispatch_async(socketQueue, ^{
            __strong ServerConnection *sself = wself;
            [sself reconnect];
        });複製程式碼

這樣改有沒有效果呢?根據我們上面的分析,顯然沒有。ServerConnection的dealloc仍然在非主執行緒上執行,上面的問題也依然存在。weak-strong dance被設計用來解決迴圈引用的問題,但不能解決我們這裡碰到的非同步任務延遲的問題。

實際上,即使把它改成下面的形式,仍然沒有效果。

        __weak ServerConnection *wself = self;
        dispatch_async(socketQueue, ^{
            [wself reconnect];
        });複製程式碼

即使拿weak引用(wself)來呼叫reconnect方法,它一旦執行,也會造成ServerConnection的引用計數增加。結果仍然是dealloc在非主執行緒上執行。

那既然dealloc在非主執行緒上執行會造成問題,那我們強制把dealloc裡面的程式碼排程到主執行緒執行好了,如下:

- (void)dealloc {
    dispatch_async(dispatch_get_main_queue(), ^{
        [reachability stopNetworkMonitoring];
    });
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}複製程式碼

顯然,在dealloc再呼叫dispatch_async的這種方法也是行不通的。因為在dealloc執行過之後,ServerConnection例項已經被銷燬了,那麼當block執行時,reachability就依賴了一個已經被銷燬的ServerConnection例項。結果還是崩潰。

那不用dispatch_async好了,改用dispatch_sync好了。仔細修改後的程式碼如下:

- (void)dealloc {
    if (![NSThread isMainThread]) {
        dispatch_sync(dispatch_get_main_queue(), ^{
            [reachability stopNetworkMonitoring];
        });
    }
    else {
        [reachability stopNetworkMonitoring];
    }

    [[NSNotificationCenter defaultCenter] removeObserver:self];
}複製程式碼

經過“前後左右”打補丁,我們現在總算得到了一段可以基本能正常執行的程式碼了。然而,在dealloc裡執行dispatch_sync這種可能耗時的“同步”操作,總不免令人膽戰心驚。

那到底怎樣做更好呢?

個人認為:並不是所有的銷燬工作都適合寫在dealloc裡

dealloc最擅長的事,自然還是釋放記憶體,比如呼叫各個成員變數的release(在ARC中這個release也省了)。但是,如果要依賴dealloc來維護一些作用域更廣(超出當前物件的生命週期)的變數或過程,則不是一個好的做法。原因至少有兩點:

  • dealloc的執行可能會被延遲,無法確保精確的執行時間;
  • 無法控制dealloc是否會在主執行緒被呼叫。

比如上面的ServerConnection的例子,業務邏輯自己肯定知道應該在什麼時機去停止監聽網路狀態,而不應該依賴dealloc來完成它。

另外,對於dealloc可能會在非同步執行緒執行的問題,我們應該特別關注它。對於不同型別的物件,我們應該採取不同的態度。比如,對於起到View角色的物件,我們的正確態度是:不應該允許dealloc在非同步執行緒執行的情況出現。為了避免出現這種情況,我們應該竭力避免在View裡面直接啟動非同步任務,或者避免在生命週期更長的非同步任務中對View產生強引用。

在上面兩個例子中,問題出現的根源在於非同步任務。我們仔細思考後會發現,在討論非同步任務的時候,我們必須關注一個至關重要的問題,即條件失效問題。當然,這也是一個顯而易見的問題:當一個非同步任務真正執行的時候(或者一個非同步事件真正發生的時候),境況很可能已與當初排程它時不同,或者說,它當初賴以執行或發生的條件可能已經失效。

在第一個Service Binding的例子中,非同步繫結過程開始排程的時候(bindService被呼叫的時候),Activity還處於Running狀態(在執行onResume);而繫結過程結束的時候(onServiceConnected被呼叫的時候),Activity卻已經從Running狀態中退出(執行過了onPause,已經又解除繫結了)。

在第二個網路監聽的例子中,當非同步重連任務結束的時候,外部對於ServerConnection例項的引用已經不復存在,例項馬上就要進行銷燬過程了。繼而造成停止監聽時的Run Loop也不再是原來那一個了。

在開始下一節有關非同步任務的正式討論之前,我們有必要對iOS和Android中經常碰到的非同步任務做一個總結。

  1. 網路請求。由於網路請求耗時較長,通常網路請求介面都是非同步的(例如iOS的NSURLConnection,或Android的Volley)。一般情況下,我們在主執行緒啟動一個網路請求,然後被動地等待請求成功或者失敗的回撥發生(意味著這個非同步任務的結束),最後根據回撥結果更新UI。從啟動網路請求,到獲知明確的請求結果(成功或失敗),時間是不確定的。

  2. 通過執行緒池機制主動建立的非同步任務。對於那些需要較長時間同步執行的任務(比如讀取磁碟檔案這種延遲高的操作,或者執行大計算量的任務),我們通常依靠系統提供的執行緒池機制把這些任務排程到非同步執行緒去執行,以節約主執行緒寶貴的計算時間。關於這些執行緒池機制,在iOS中,我們有GCD(dispatch_async)、NSOperationQueue;在Android上,我們有JDK提供的傳統的ExecutorService,也有Android SDK提供的AsyncTask。不管是哪種實現形式,我們都為自己創造了大量的非同步任務。

  3. Run Loop排程任務。在iOS上,我們可以呼叫NSObject的若干個performSelectorXXX方法將任務排程到目標執行緒的Run Loop上去非同步執行(performSelectorInBackground:withObject:除外)。類似地,在Android上,我們可以呼叫Handler的post/sendMessage方法或者View的post方法將任務非同步排程到對應的Run Loop上去。實際上,不管是iOS還是Android系統,一般客戶端的基礎架構中都會為主執行緒建立一個Run Loop(當然,非主執行緒也可以建立Run Loop)。它可以讓長時間存活的執行緒週期性地處理短任務,而在沒有任務可執行的時候進入睡眠,既能高效及時地響應事件處理,又不會耗費多餘的CPU時間。同時,更重要的一點是,Run Loop模式讓客戶端的多執行緒程式設計邏輯變得簡單。客戶端程式設計比伺服器程式設計的多執行緒模型要簡單,很大程度上要歸功於Run Loop的存在。在客戶端程式設計中,當我們想執行一個長的同步任務時,一般先通過前面(2)中提及的執行緒池機制將它排程到非同步執行緒,在任務執行完後,再通過本節提到的Run Loop排程方法或者GCD等機制重新排程回主執行緒的Run Loop上。這種“主執行緒->非同步執行緒->主執行緒”的模式,基本成為了客戶端多執行緒程式設計的基本模式。這種模式規避了多個執行緒之間可能存在的複雜的同步操作,使處理變得簡單。在後面第(三)部分——執行多個非同步任務,我們還有機會繼續探討這個話題。

  4. 延遲排程任務。這一類任務在指定的某個時間段之後,或者在指定的某個時間點開始執行,可以用於實現類似重試佇列之類的結構。延遲排程任務有多種實現方式。​在iOS中,NSObject的performSelector:withObject:afterDelay:,GCD的dispatch_after或dispatch_time,另外,還有NSTimer;在Android中,Handler的postDelayed和postAtTime,View的postDelayed,還有老式的java.util.Timer,此外,安卓中還有一個比較重的排程器——能在任務排程執行時自動喚醒程式的AlarmService。

  5. 跟系統實現相關的非同步行為。這類行為種類繁多,這裡舉幾個例子。比如:安卓中的startActivity是一個非同步操作,從呼叫後到Activity被建立和顯示,仍有一小段時間。再如:Activity和Fragment的生命週期是非同步的,即使Activity的生命週期已經到了onResume,你還是不知道它所包含的Fragment的生命週期走到哪一步了(以及它的view層次有沒有被建立出來)。再比如,在iOS和Android系統上都有監聽網路狀態變化的機制(本文前面的第二個程式碼例子中就有涉及),網路狀態變化回撥何時執行就是一個非同步事件。這些非同步行為同樣需要統一完整的非同步處理。

本文在最後還需要澄清一個關於題目的問題。這個系列雖命名為《Android和iOS開發中的非同步處理》,但是對於非同步任務的處理這個話題,實際中並不侷限於“iOS或Android開發”中,比如在伺服器的開發中也是有可能遇到的。在這個系列中我所要表達的,更多的是一個抽象的邏輯,並不侷限於iOS或Android某種具體的技術。只是,在iOS和Android的前端開發中,非同步任務被應用得如此廣泛,以至於我們應該把它當做一個更普遍的問題來對待了。

(完)

其它精選文章

Android和iOS開發中的非同步處理(一)——開篇

相關文章