Android和iOS開發中的非同步處理(二)——非同步任務的回撥

張鐵蕾發表於2019-02-26

本文是系列文章《Android和iOS開發中的非同步處理》的第二篇。在本篇文章中,我們主要討論跟非同步任務的回撥有關的諸多問題。

在iOS中,回撥通常表現為delegate的形式;而在Android中,回撥通常以listener的形式存在。但不管表現形式如何,回撥都是介面設計不可分割的一部分。回撥介面設計的好壞,直接影響整個介面設計的成功與否。

那麼在回撥介面的設計和實現中,我們需要考慮哪些方面呢?現在我們先把本文要討論的子話題列出如下,然後再逐個討論:

  • 必須產生結果回撥
  • 重視失敗回撥 & 錯誤碼應該儘量詳細
  • 呼叫介面和回撥介面應該有清晰的對應關係
  • 成功結果回撥和失敗結果回撥應該彼此互斥
  • 回撥的執行緒模型
  • 回撥的context引數(透傳引數)
  • 回撥順序
  • 閉包形式的回撥和Callback Hell

注:本系列文章中出現的程式碼已經整理到GitHub上(持續更新),程式碼庫地址為:

其中,當前這篇文章中出現的Java程式碼,位於com.zhangtielei.demos.async.programming.callback這個package中。


必須產生結果回撥

當介面設計成非同步的形式時,介面的最終執行結果就通過回撥來返回給呼叫者。

但回撥介面並不總是傳遞最終結果。實際上我們可以將回撥分成兩類:

  • 中間回撥
  • 結果回撥

而結果回撥又包含成功結果回撥和失敗結果回撥。

中間回撥可能在非同步任務開始執行時,執行進度有更新時,或者其它重要的中間事件發生時被呼叫;而結果回撥要等非同步任務執行到最後,有了一個明確的結果(成功了或失敗了),才被呼叫。結果回撥的發生意味著此次非同步介面的執行結束。

“必須產生結果回撥”,這條規則並不像想象的那樣容易遵守。它要求在非同步介面的實現中無論發生什麼異常狀況,都要在有限的時間內產生結果回撥。比如,接收到非法的輸入引數,程式的執行時異常,任務中途被取消,任務超時,以及種種意想不到的錯誤,這些都是發生異常狀況的例子。

這裡的難度就在於,介面的實現要慎重對待所有可能的錯誤情況,不管哪種情況出現,都必須產生結果回撥。否則,可能會導致呼叫方整個執行流程的中斷。

重視失敗回撥 & 錯誤碼應該儘量詳細

先看一段程式碼例子:

public interface Downloader {
    /**
     * 設定監聽器.
     * @param listener
     */
    void setListener(DownloadListener listener);
    /**
     * 啟動資源的下載.
     * @param url 要下載的資源地址.
     * @param localPath 資源下載後要儲存的本地位置.
     */
    void startDownload(String url, String localPath);
}

public interface DownloadListener {
    /**
     * 下載結束回撥.
     * @param result 下載結果. true表示下載成功, false表示下載失敗.
     * @param url 資源地址
     * @param localPath 下載後的資源儲存位置. 只有result=true時才有效.
     */
    void downloadFinished(boolean result, String url, String localPath);

    /**
     * 下載進度回撥.
     * @param url 資源地址
     * @param downloadedSize 已下載大小.
     * @param totalSize 資源總大小.
     */
    void downloadProgress(String url, long downloadedSize, long totalSize);
}複製程式碼

這段程式碼定義了一個下載器介面,用於從指定的URL下載資源。這是一個非同步介面,呼叫者通過呼叫startDownload啟動下載任務,然後等著回撥。當downloadFinished回撥發生時,表示下載任務結束了。如果返回result=true,則說明下載成功,否則說明下載失敗。

這個介面定義基本上算是比較完備了,能夠完成下載資源的基本流程:我們能通過這個介面啟動一個下載任務,在下載過程中獲得下載進度(中間回撥),在下載成功時能夠取得結果,在下載失敗時也能得到通知(成功和失敗都屬於結果回撥)。但是,如果在下載失敗時我們想獲知更詳細的失敗原因,那麼現在這個介面就做不到了。

具體的失敗原因,上層呼叫者可能需要處理,也可能不需要處理。在下載失敗後,上層的展示層可能只是會為下載失敗的資源做一個標記,而不區分是如何失敗的。當然也有可能展示層會提示使用者具體的失敗原因,讓使用者接下來知道需要做哪些操作來恢復錯誤,比如,由於“網路不可用”而造成的下載失敗,可以提示使用者切換到更好的網路;而由於“儲存空間不足”而造成的下載失敗,則可以提示使用者清理儲存空間。總之,應該由上層呼叫者來決定是否顯示具體錯誤原因,以及如何顯示,而不是在定義底層回撥介面時就決定。

因此,結果回撥中的失敗回撥,應該返回儘可能詳細的錯誤碼,讓呼叫者在發生錯誤時有更多的選擇。這一規則,對於library的開發者來說,似乎毋庸置疑。但是,對於上層應用的開發者來說,往往得不到足夠的重視。返回詳盡的錯誤碼,意味著在失敗處理上花費更多的工夫。為了“節省時間”和“實用主義”,人們往往對於錯誤情況採取“簡單處理”,但卻給日後的擴充套件帶來了隱患。

對於上面下載器介面的程式碼例子,為了能返回更詳盡的錯誤碼,其中DownloadListener的程式碼修改如下:

public interface DownloadListener {
    /**
     * 錯誤碼定義
     */
    public static final int SUCCESS = 0;//成功
    public static final int INVALID_PARAMS = 1;//輸入引數有誤
    public static final int NETWORK_UNAVAILABLE = 2;//網路不可用
    public static final int UNKNOWN_HOST = 3;//域名解析失敗
    public static final int CONNECT_TIMEOUT = 4;//連線超時
    public static final int HTTP_STATUS_NOT_OK = 5;//下載請求返回非200
    public static final int SDCARD_NOT_EXISTS = 6;//SD卡不存在(下載的資源沒地方存)
    public static final int SD_CARD_NO_SPACE_LEFT = 7;//SD卡空間不足(下載的資源沒地方存)
    public static final int READ_ONLY_FILE_SYSTEM = 8;//檔案系統只讀(下載的資源沒地方存)
    public static final int LOCAL_IO_ERROR = 9;//本地SD存取有關的錯誤
    public static final int UNKNOWN_FAILED = 10;//其它未知錯誤

    /**
     * 下載成功回撥.
     * @param url 資源地址
     * @param localPath 下載後的資源儲存位置.
     */
    void downloadSuccess(String url, String localPath);
    /**
     * 下載失敗回撥.
     * @param url 資源地址
     * @param errorCode 錯誤碼.
     * @param errorMessage 錯誤資訊簡短描述. 供呼叫者理解錯誤原因.
     */
    void downloadFailed(String url, int errorCode, String errorMessage);

    /**
     * 下載進度回撥.
     * @param url 資源地址
     * @param downloadedSize 已下載大小.
     * @param totalSize 資源總大小.
     */
    void downloadProgress(String url, long downloadedSize, long totalSize);
}複製程式碼

在iOS中,Foundation Framework對於程式錯誤有一個系統的封裝:NSError。它能以非常通用的方式來封裝錯誤碼,而且能將錯誤分成不同的domain。NSError就很適合用在這種失敗回撥介面的定義中。

呼叫介面和回撥介面應該有清晰的對應關係

我們通過一個真實的介面定義的例子來分析這個問題。

下面是來自國內某廣告平臺的視訊廣告積分牆的介面定義程式碼(為展示清楚,省略了一些無關的程式碼)。

@class IndependentVideoManager;

@protocol IndependentVideoManagerDelegate <NSObject>
@optional
#pragma mark - independent video present callback 視訊廣告展現回撥

...

#pragma mark - point manage callback 積分管理

...

#pragma mark - independent video status callback 積分牆狀態
/**
 *  視訊廣告牆是否可用。
 *  Called after get independent video enable status.
 *
 *  @param IndependentVideoManager
 *  @param enable
 */
- (void)ivManager:(IndependentVideoManager *)manager
didCheckEnableStatus:(BOOL)enable;

/**
 *  是否有視訊廣告可以播放。
 *  Called after check independent video available.
 *
 *  @param IndependentVideoManager
 *  @param available
 */
- (void)ivManager:(IndependentVideoManager *)manager
isIndependentVideoAvailable:(BOOL)available;


@end

@interface IndependentVideoManager : NSObject {

}

@property(nonatomic,assign)id<IndependentVideoManagerDelegate>delegate;

...

#pragma mark - init 初始化相關方法

...

#pragma mark - independent video present 積分牆展現相關方法
/**
 *  使用App的rootViewController來彈出並顯示列表積分牆。
 *  Present independent video in ModelView way with App`s rootViewController.
 *
 *  @param type 積分牆型別
 */
- (void)presentIndependentVideo;

...

#pragma mark - independent video status 檢查視訊積分牆是否可用
/**
 *  是否有視訊廣告可以播放
 *  check independent video available.
 */
- (void)checkVideoAvailable;

#pragma mark - point manage 積分管理相關廣告
/**
 *  檢查已經得到的積分,成功或失敗都會回撥代理中的相應方法。
 *
 */
- (void)checkOwnedPoint;
/**
 *  消費指定的積分數目,成功或失敗都會回撥代理中的相應方法(請特別注意引數型別為unsigned int,需要消費的積分為非負值)。
 *
 *  @param point 要消費積分的數目
 */
- (void)consumeWithPointNumber:(NSUInteger)point;

@end複製程式碼

我們來分析一下在這段介面定義中呼叫介面和回撥介面之間的對應關係。

使用IndependentVideoManager可以呼叫的介面,除了初始化的介面之外,主要有這幾個:

  • 彈出並顯示視訊 (presentIndependentVideo)
  • 檢查是否有視訊廣告可以播放 (checkVideoAvailable)
  • 積分管理 (checkOwnedPoint和consumeWithPointNumber:)

而回撥介面 (IndependentVideoManagerDelegate) 可以分為下面幾類:

  • 視訊廣告展現回撥類
  • 積分牆狀態類 (ivManager:didCheckEnableStatus:和ivManager:isIndependentVideoAvailable:)
  • 積分管理類

總體來說,這裡的對應關係還是比較清楚的,這三類回撥介面基本上與前面的三部分呼叫介面能夠一一對應上。

不過,積分牆狀態類的回撥介面還是有一點讓人迷惑的細節:看起來呼叫者在呼叫checkVideoAvailable後,會收到積分牆狀態類的兩個回撥 (ivManager:didCheckEnableStatus:和ivManager:isIndependentVideoAvailable:);但是,從介面名稱所能表達的含義來看,呼叫checkVideoAvailable是為了檢查是否有視訊廣告可以播放,那麼單單是ivManager:isIndependentVideoAvailable:這一個回撥介面就能返回所需要的結果了,似乎不太需要ivManager:didCheckEnableStatus:。而從ivManager:didCheckEnableStatus所表達的含義(視訊廣告牆是否可用)上來看,它似乎在任何呼叫介面被呼叫時都可能會執行,而不應該只對應checkVideoAvailable。這裡的回撥介面設計,在與呼叫介面的對應關係上,是令人困惑的。

此外,IndependentVideoManager的介面在上下文引數的設計上也有一些問題,本文後面會再次提到。

成功結果回撥和失敗結果回撥應該彼此互斥

當一個非同步任務結束時,它或者呼叫成功結果回撥,或者呼叫失敗結果回撥。兩者只能呼叫其一。這是顯而易見的要求,但若在實現時不加註意,卻也可能無法遵守這一要求。

假設我們前面提到的Downloader介面在最終產生結果回撥的時候程式碼如下:

    int errorCode = parseDownloadResult(result);
    if (errorCode == SUCCESS) {
        listener.downloadSuccess(url, localPath)
    }
    else {
        listener.downloadFailed(url, errorCode, getErrorMessage(errorCode));
    }複製程式碼

進而我們發現,為了能夠達到“必須產生結果回撥”的目標,我們應該考慮parseDownloadResult這個方法拋異常的可能。於是,我們修改程式碼如下:

    try {
        int errorCode = parseDownloadResult(result);
        if (errorCode == SUCCESS) {
            listener.downloadSuccess(url, localPath)
        }
        else {
            listener.downloadFailed(url, errorCode, getErrorMessage(errorCode));
        }
    }
    catch (Exception e) {
        listener.downloadFailed(url, UNKNOWN_FAILED, getErrorMessage(UNKNOWN_FAILED));
    }複製程式碼

程式碼改成這樣,已經能保證即使出現了意想不到的情況,也能對呼叫者產生一個失敗回撥。

但是,這也帶來另一個問題:如果在呼叫listener.downloadSuccess或listener.downloadFailed的時候,回撥介面的實現程式碼拋了異常呢?那會造成再多呼叫一次listener.downloadFailed。於是,成功結果回撥和失敗結果回撥不再彼此互斥地被呼叫了:或者成功和失敗回撥都發生了,或者連續兩次失敗回撥。

回撥介面的實現是歸呼叫者負責的部分,難道呼叫者犯的錯誤也需要我們來考慮?首先,這主要還是應該由上層呼叫者來負責處理,回撥介面的實現方(呼叫者)實在不應該在異常發生時再把異常拋回來。但是,底層介面的設計者也應當盡力而為。作為介面的設計者,通常不能預期呼叫者會怎麼表現,如果在異常發生時,我們能保證當前錯誤不至於讓整個流程中斷和卡死,豈不是更好呢?於是,我們可以嘗試把程式碼改成如下這樣:

    int errorCode;
    try {
        errorCode = parseDownloadResult(result);
    }
    catch (Exception e) {
        errorCode = UNKNOWN_FAILED;
    }
    if (errorCode == SUCCESS) {
        try {
            listener.downloadSuccess(url, localPath)
        }
        catch (Throwable e) {
            e.printStackTrace();
        }
    }
    else {
        try {
            listener.downloadFailed(url, errorCode, getErrorMessage(errorCode));
        }
        catch (Throwable e) {
            e.printStackTrace();
        }
    }複製程式碼

回撥程式碼複雜了一些,但也更安全了。

回撥的執行緒模型

非同步介面能夠得以實現的技術基礎,主要有兩個:

  • 多執行緒(介面的實現程式碼在與呼叫執行緒不同的非同步執行緒中執行)
  • 非同步IO(比如非同步網路請求。在這種情況下,即使整個程式只有一個執行緒,也能實現出非同步介面)

不管是哪種情況,我們都需要對回撥發生的執行緒環境有清晰的定義。

通常來講,定義結果回撥的執行執行緒環境主要有三種模式:

  1. 在哪個執行緒上呼叫介面,就在哪個執行緒上發生結果回撥。
  2. 不管在哪個執行緒上呼叫介面,都在主執行緒上發生結果回撥(例如Android的AsyncTask)。
  3. 呼叫者可以自定義回撥介面在哪個執行緒上發生。(例如iOS的NSURLConnection,通過scheduleInRunLoop:forMode:來設定回撥發生的Run Loop)

顯然第3種模式最為靈活,因為它包含了前兩種。

為了能把執行程式碼排程到其它執行緒,我們需要使用在上一篇Android和iOS開發中的非同步處理(一)——概述最後提到的一些技術,比如iOS中的GCD、NSOperationQueue、performSelectorXXX方法,Android中的ExecutorService、AsyncTask、Handler,等等(注意:ExecutorService不能用於排程到主執行緒,只能用於排程到非同步執行緒)。我們有必要對執行緒排程的實質加以理解:能把一段程式碼排程到某一個執行緒去執行,前提條件是那個執行緒有一個Event Loop。這個Loop顧名思義,就是一個迴圈,它不停地從訊息佇列裡取出訊息,然後處理。我們做執行緒排程的時候,相當於向這個佇列裡傳送訊息。這個佇列本身在系統實現裡已經保證是執行緒安全的(Thread Safe Queue),因此呼叫者就規避了執行緒安全問題。在客戶端開發中,系統都會為主執行緒建立一個Loop,但非主執行緒則需要開發者自己來使用適當的技術進行建立。

在客戶端程式設計的大多數情況下,我們一般會希望結果回撥發生在主執行緒上,因為我們一般會在這個時機更新UI。而中間回撥在哪個執行緒上執行,則取決於具體應用場景。在前面Downloader的例子中,中間回撥downloadProgress是為了回傳下載進度,下載進度一般也是為了在UI上展示,因此downloadProgress也是排程到主執行緒上執行更好一些。

回撥的context引數(透傳引數)

在呼叫一個非同步介面的時候,我們經常需要臨時儲存一份跟該次呼叫相關的上下文資料,等到非同步任務執行完回撥發生的時候,我們能重新拿到這份上下文資料。

我們還是以前面的下載器為例。為了能清晰地討論各種情況,我們這裡假設一個稍微複雜一點的例子。假設我們要下載若干個表情包,每個表情包包含多個表情圖片檔案,下載完全部表情圖片之後,我們需要把表情包安裝到本地(可能是修改本地資料庫的操作),以便使用者能夠在輸入皮膚中使用它們。

假設表情包的資料結構定義如下:

public class EmojiPackage {
    /**
     * 表情包ID
     */
    public long emojiId;
    /**
     * 表情包圖片列表
     */
    public List<String> emojiUrls;
}複製程式碼

在下載過程中,我們需要儲存一個如下的上下文結構:

public class EmojiDownloadContext {
    /**
     * 當前在下載的表情包
     */
    public EmojiPackage emojiPackage;
    /**
     * 已經下載完的表情圖片計數
     */
    public int downloadedEmoji;
    /**
     * 下載到的表情包本地地址
     */
    public List<String> localPathList = new ArrayList<String>();
}複製程式碼

再假設我們要實現的表情包下載器遵守下面的介面定義:

public interface EmojiDownloader {
    /**
     * 開始下載指定的表情包
     * @param emojiPackage
     */
    void startDownloadEmoji(EmojiPackage emojiPackage);

    /**
     * 這裡定義回撥相關的介面, 忽略. 不是我們要討論的重點.
     */
    //TODO: 回撥介面相關定義
}複製程式碼

如果利用前面已有的Downloader介面來完成表情包下載器的實現,那麼根據傳遞上下文的方式不同,我們可能會產生三種不同的做法:

(1)全域性儲存一份上下文。

注意:這裡所說的“全域性”,是針對一個表情包下載器內部而言的。程式碼如下:

public class MyEmojiDownloader implements EmojiDownloader, DownloadListener {
    /**
     * 全域性儲存一份的表情包下載上下文.
     */
    private EmojiDownloadContext downloadContext;
    private Downloader downloader;

    public MyEmojiDownloader() {
        //例項化有一個下載器. MyDownloader是Downloader介面的一個實現。
        downloader = new MyDownloader();
        downloader.setListener(this);
    }

    @Override
    public void startDownloadEmoji(EmojiPackage emojiPackage) {
        if (downloadContext == null) {
            //建立下載上下文資料
            downloadContext = new EmojiDownloadContext();
            downloadContext.emojiPackage = emojiPackage;
            //啟動第0個表情圖片檔案的下載
            downloader.startDownload(emojiPackage.emojiUrls.get(0),
                    getLocalPathForEmoji(emojiPackage, 0));
        }
    }

    @Override
    public void downloadSuccess(String url, String localPath) {
        downloadContext.localPathList.add(localPath);
        downloadContext.downloadedEmoji++;
        EmojiPackage emojiPackage = downloadContext.emojiPackage;
        if (downloadContext.downloadedEmoji < emojiPackage.emojiUrls.size()) {
            //還沒下載完, 繼續下載下一個表情圖片
            String nextUrl = emojiPackage.emojiUrls.get(downloadContext.downloadedEmoji);
            downloader.startDownload(nextUrl,
                    getLocalPathForEmoji(emojiPackage, downloadContext.downloadedEmoji));
        }
        else {
            //已經下載完
            installEmojiPackageLocally(emojiPackage, downloadContext.localPathList);
            downloadContext = null;
        }
    }

    @Override
    public void downloadFailed(String url, int errorCode, String errorMessage) {
        ...
    }

    @Override
    public void downloadProgress(String url, long downloadedSize, long totalSize) {
        ...
    }

    /**
     * 計算表情包中第i個表情圖片檔案的下載地址.
     */
    private String getLocalPathForEmoji(EmojiPackage emojiPackage, int i) {
        ...
    }

    /**
     * 把表情包安裝到本地
     */
    private void installEmojiPackageLocally(EmojiPackage emojiPackage, List<String> localPathList) {
        ...
    }
}複製程式碼

這種做法的缺點是:同時只能有一個表情包在下載。必須要等到前一個表情包下載完畢之後才能開始下載新的一個表情包。

雖然這種“全域性儲存一份上下文”的做法有這樣明顯的缺點,但是在某些情況下,我們卻只能採取這種方式。這個後面會再提到。

(2)用對映關係來儲存上下文。

在現有Downloader介面的定義下,我們只能用URL來作為這份對映關係的索引。由於一個表情包包含多個URL,因此我們必須為每一個URL都索引一份上下文。程式碼如下:

public class MyEmojiDownloader implements EmojiDownloader, DownloadListener {
    /**
     * 儲存上下文的對映關係.
     * URL -> EmojiDownloadContext
     */
    private Map<String, EmojiDownloadContext> downloadContextMap;
    private Downloader downloader;

    public MyEmojiDownloader() {
        downloadContextMap = new HashMap<String, EmojiDownloadContext>();
        //例項化有一個下載器. MyDownloader是Downloader介面的一個實現。
        downloader = new MyDownloader();
        downloader.setListener(this);
    }

    @Override
    public void startDownloadEmoji(EmojiPackage emojiPackage) {
        //建立下載上下文資料
        EmojiDownloadContext downloadContext = new EmojiDownloadContext();
        downloadContext.emojiPackage = emojiPackage;
        //為每一個URL建立對映關係
        for (String emojiUrl : emojiPackage.emojiUrls) {
            downloadContextMap.put(emojiUrl, downloadContext);
        }
        //啟動第0個表情圖片檔案的下載
        downloader.startDownload(emojiPackage.emojiUrls.get(0),
                getLocalPathForEmoji(emojiPackage, 0));
    }

    @Override
    public void downloadSuccess(String url, String localPath) {
        EmojiDownloadContext downloadContext = downloadContextMap.get(url);
        downloadContext.localPathList.add(localPath);
        downloadContext.downloadedEmoji++;
        EmojiPackage emojiPackage = downloadContext.emojiPackage;
        if (downloadContext.downloadedEmoji < emojiPackage.emojiUrls.size()) {
            //還沒下載完, 繼續下載下一個表情圖片
            String nextUrl = emojiPackage.emojiUrls.get(downloadContext.downloadedEmoji);
            downloader.startDownload(nextUrl,
                    getLocalPathForEmoji(emojiPackage, downloadContext.downloadedEmoji));
        }
        else {
            //已經下載完
            installEmojiPackageLocally(emojiPackage, downloadContext.localPathList);
            //為每一個URL刪除對映關係
            for (String emojiUrl : emojiPackage.emojiUrls) {
                downloadContextMap.remove(emojiUrl);
            }
        }
    }

    @Override
    public void downloadFailed(String url, int errorCode, String errorMessage) {
        ...
    }

    @Override
    public void downloadProgress(String url, long downloadedSize, long totalSize) {
        ...
    }

    /**
     * 計算表情包中第i個表情圖片檔案的下載地址.
     */
    private String getLocalPathForEmoji(EmojiPackage emojiPackage, int i) {
        ...
    }

    /**
     * 把表情包安裝到本地
     */
    private void installEmojiPackageLocally(EmojiPackage emojiPackage, List<String> localPathList) {
        ...
    }
}複製程式碼

這種做法也有它的缺點:並不能每次都能找到恰當的能唯一索引上下文資料的變數。在這個表情包下載器的例子中,能唯一標識下載的變數本來應該是emojiId,但在Downloader的回撥介面中卻無法取到這個值,因此只能改用每個URL都建立一份到上下文資料的索引。這樣帶來的結果就是:如果兩個不同表情包包含了某個相同的URL,就可能出現衝突。另外,這種做法的實現比較複雜。

(3)為每一個非同步任務建立一個介面例項。

通常來講,按照我們的設計初衷,我們希望只例項化一個介面例項(即一個Downloader例項),然後用這一個例項來啟動多個非同步任務。但是,如果我們每次啟動新的非同步任務都是新建立一個介面例項,那麼非同步任務就和介面例項個數一一對應了,這樣就能將非同步任務的上下文資料存到這個介面例項中。程式碼如下:

public class MyEmojiDownloader implements EmojiDownloader {
    @Override
    public void startDownloadEmoji(EmojiPackage emojiPackage) {
        //建立下載上下文資料
        EmojiDownloadContext downloadContext = new EmojiDownloadContext();
        downloadContext.emojiPackage = emojiPackage;
        //為每一次下載建立一個新的Downloader
        final EmojiUrlDownloader downloader = new EmojiUrlDownloader();
        //將上下文資料存到downloader例項中
        downloader.downloadContext = downloadContext;

        downloader.setListener(new DownloadListener() {
            @Override
            public void downloadSuccess(String url, String localPath) {
                EmojiDownloadContext downloadContext = downloader.downloadContext;
                downloadContext.localPathList.add(localPath);
                downloadContext.downloadedEmoji++;
                EmojiPackage emojiPackage = downloadContext.emojiPackage;
                if (downloadContext.downloadedEmoji < emojiPackage.emojiUrls.size()) {
                    //還沒下載完, 繼續下載下一個表情圖片
                    String nextUrl = emojiPackage.emojiUrls.get(downloadContext.downloadedEmoji);
                    downloader.startDownload(nextUrl,
                            getLocalPathForEmoji(emojiPackage, downloadContext.downloadedEmoji));
                }
                else {
                    //已經下載完
                    installEmojiPackageLocally(emojiPackage, downloadContext.localPathList);
                }
            }

            @Override
            public void downloadFailed(String url, int errorCode, String errorMessage) {
                //TODO:
            }

            @Override
            public void downloadProgress(String url, long downloadedSize, long totalSize) {
                //TODO:
            }
        });

        //啟動第0個表情圖片檔案的下載
        downloader.startDownload(emojiPackage.emojiUrls.get(0),
                getLocalPathForEmoji(emojiPackage, 0));
    }

    private static class EmojiUrlDownloader extends MyDownloader {
        public EmojiDownloadContext downloadContext;
    }

    /**
     * 計算表情包中第i個表情圖片檔案的下載地址.
     */
    private String getLocalPathForEmoji(EmojiPackage emojiPackage, int i) {
        ...
    }

    /**
     * 把表情包安裝到本地
     */
    private void installEmojiPackageLocally(EmojiPackage emojiPackage, List<String> localPathList) {
        ...
    }
}複製程式碼

這樣做自然缺點也很明顯:為每一個下載任務都建立一個下載器例項,這有違我們對於Downloader介面的設計初衷。這會建立大量多餘的例項。特別是,當介面例項是個很重的大物件時,這樣做會帶來大量的開銷。

上面三種做法,每一種都不是很理想。根源在於:底層的非同步介面Downloader不能支援上下文(context)傳遞(注意,它跟Android系統中的Context沒有什麼關係)。這樣的上下文引數不同的人有不同的叫法:

  • context(上下文)
  • 透傳引數
  • callbackData
  • cookie
  • userInfo

不管這個引數叫什麼名字,它的作用都是一樣的:在呼叫非同步介面的時候傳遞進去,當回撥介面發生時它還能傳回來。這個上下文引數由上層呼叫者定義,底層介面的實現並不用理解它的含義,而只是負責透傳。

支援了上下文引數的Downloader介面改動如下:

public interface Downloader {
    /**
     * 設定回撥監聽器.
     * @param listener
     */
    void setListener(DownloadListener listener);
    /**
     * 啟動資源的下載.
     * @param url 要下載的資源地址.
     * @param localPath 資源下載後要儲存的本地位置.
     * @param contextData 上下文資料, 在回撥介面中會透傳回去.可以是任何型別.
     */
    void startDownload(String url, String localPath, Object contextData);
}
public interface DownloadListener {
    /**
     * 錯誤碼定義
     */
    public static final int SUCCESS = 0;//成功
    public static final int INVALID_PARAMS = 1;//輸入引數有誤
    public static final int NETWORK_UNAVAILABLE = 2;//網路不可用
    public static final int UNKNOWN_HOST = 3;//域名解析失敗
    public static final int CONNECT_TIMEOUT = 4;//連線超時
    public static final int HTTP_STATUS_NOT_OK = 5;//下載請求返回非200
    public static final int SDCARD_NOT_EXISTS = 6;//SD卡不存在(下載的資源沒地方存)
    public static final int SD_CARD_NO_SPACE_LEFT = 7;//SD卡空間不足(下載的資源沒地方存)
    public static final int READ_ONLY_FILE_SYSTEM = 8;//檔案系統只讀(下載的資源沒地方存)
    public static final int LOCAL_IO_ERROR = 9;//本地SD存取有關的錯誤
    public static final int UNKNOWN_FAILED = 10;//其它未知錯誤

    /**
     * 下載成功回撥.
     * @param url 資源地址
     * @param localPath 下載後的資源儲存位置.
     * @param contextData 上下文資料.
     */
    void downloadSuccess(String url, String localPath, Object contextData);
    /**
     * 下載失敗回撥.
     * @param url 資源地址
     * @param errorCode 錯誤碼.
     * @param errorMessage 錯誤資訊簡短描述. 供呼叫者理解錯誤原因.
     * @param contextData 上下文資料.
     */
    void downloadFailed(String url, int errorCode, String errorMessage, Object contextData);

    /**
     * 下載進度回撥.
     * @param url 資源地址
     * @param downloadedSize 已下載大小.
     * @param totalSize 資源總大小.
     * @param contextData 上下文資料.
     */
    void downloadProgress(String url, long downloadedSize, long totalSize, Object contextData);
}複製程式碼

利用這個最新的Downloader介面,前面的表情包下載器就有了第4種實現方式。

(4)利用支援上下文傳遞的非同步介面。

程式碼如下:

public class MyEmojiDownloader implements EmojiDownloader, DownloadListener {
    private Downloader downloader;

    public MyEmojiDownloader() {
        //例項化有一個下載器. MyDownloader是Downloader介面的一個實現。
        downloader = new MyDownloader();
        downloader.setListener(this);
    }

    @Override
    public void startDownloadEmoji(EmojiPackage emojiPackage) {
        //建立下載上下文資料
        EmojiDownloadContext downloadContext = new EmojiDownloadContext();
        downloadContext.emojiPackage = emojiPackage;
        //啟動第0個表情圖片檔案的下載, 上下文引數傳遞進去
        downloader.startDownload(emojiPackage.emojiUrls.get(0),
                getLocalPathForEmoji(emojiPackage, 0),
                downloadContext);

    }

    @Override
    public void downloadSuccess(String url, String localPath, Object contextData) {
        //通過回撥介面的contextData引數做Down-casting獲得上下文引數
        EmojiDownloadContext downloadContext = (EmojiDownloadContext) contextData;

        downloadContext.localPathList.add(localPath);
        downloadContext.downloadedEmoji++;
        EmojiPackage emojiPackage = downloadContext.emojiPackage;
        if (downloadContext.downloadedEmoji < emojiPackage.emojiUrls.size()) {
            //還沒下載完, 繼續下載下一個表情圖片
            String nextUrl = emojiPackage.emojiUrls.get(downloadContext.downloadedEmoji);
            downloader.startDownload(nextUrl,
                    getLocalPathForEmoji(emojiPackage, downloadContext.downloadedEmoji),
                    downloadContext);
        }
        else {
            //已經下載完
            installEmojiPackageLocally(emojiPackage, downloadContext.localPathList);
        }
    }

    @Override
    public void downloadFailed(String url, int errorCode, String errorMessage, Object contextData) {
        ...
    }

    @Override
    public void downloadProgress(String url, long downloadedSize, long totalSize, Object contextData) {
        ...
    }

    /**
     * 計算表情包中第i個表情圖片檔案的下載地址.
     */
    private String getLocalPathForEmoji(EmojiPackage emojiPackage, int i) {
        ...
    }

    /**
     * 把表情包安裝到本地
     */
    private void installEmojiPackageLocally(EmojiPackage emojiPackage, List<String> localPathList) {
        ...
    }
}複製程式碼

顯然,最後第4種實現方法更合理一些,程式碼更緊湊,也沒有前面3種的缺點。但是,它要求我們呼叫的底層非同步介面對上下文傳遞有完善的支援。在實際情況中,我們需要呼叫的介面大都是既定的,無法修改的。如果我們碰到的介面對上下文引數傳遞支援得不好,我們就別無選擇,只能採取前面3種做法中的一種。總之,我們在這裡討論前3種做法並非自尋煩惱,而是為了應對那些對回撥上下文支援不夠的介面,而這些介面的設計者通常是無意中給我們出了這樣的難題。

一個典型的情況是:提供給我們的介面不支援自定義的上下文資料傳遞,而且我們也找不到恰當的能唯一索引上下文資料的變數,從而逼迫我們只能使用前面第1種“全域性儲存一份上下文”的做法。

現在,我們可以很容易得出結論:一個好的回撥介面定義,應該具有傳遞自定義上下文資料的能力

我們再從上下文傳遞能力的角度來重新審視一下一些系統的回撥介面定義。比如說iOS中UIAlertViewDelegate的alertView:clickedButtonAtIndex:,或者UITableViewDataSource的tableView:cellForRowAtIndexPath:,這些回撥介面的第一個引數都會回傳那個UIView本身的例項(其實UIKit中大多數回撥介面都以類似的方式定義)。這起到了一定的上下文傳遞的作用,它可以用來區分不同的UIView例項,但不能用來區分同一個UIView例項內的不同回撥。如果同一個頁面內需要先後多次彈出UIAlertView框,那麼我們每次都需要新建立一個UIAlertView例項,然後在回撥中就能根據傳回的UIAlertView例項來區分是哪一次彈框。這類似於前面討論過的第3種做法。UIView本身還預定義了一個用於傳遞整型上下文的tag引數,但如果我們想傳遞更多的其它型別的上下文,那麼我們就只能像前述第3種做法一樣,繼承一個UIView的自己的子類出來,在裡面放置上下文引數。

UIView每次新的展示都建立一個例項,這本身並不能被視為過多的開銷。畢竟,UIView的典型用法就是為了一個個建立出來並新增到View層次中加以展示的。但是,我們在前面提到的IndependentVideoManager的例子就不同了。它的回撥介面被設計成第一個引數回傳IndependentVideoManager例項,比如ivManager:isIndependentVideoAvailable:,可以猜測這樣的回撥介面定義必定是參考了UIKit。但IndependentVideoManager的情況明顯不同,它一般只需要建立一個例項,然後通過在同一個例項上多次呼叫介面來多次播放廣告。這裡更需要區分的是同一個例項上多次不同的回撥,每次回撥攜帶了哪些上下文引數。這裡真正需要的上下文傳遞能力,跟我們上面討論的第4種做法類似,而像UIKit那樣的介面定義方式提供的上下文傳遞能力是不夠的。

在回撥介面的設計中,上下文傳遞能力,關鍵的一點在於:它能否區分單一介面例項的多次回撥

再來看一下Android上的例子。Android上的回撥介面以listener的形式呈現,典型的程式碼如下:

Button button = (Button) findViewById(...);
button.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        ...
    }
});複製程式碼

這段程式碼中一個Button例項,可以對應多次回撥(多次點選事件),但我們不能通過這段程式碼在這些不同的回撥之間進行區分處理。所幸的是,我們實際上也不需要。

通過以上討論,我們發現,與View層面有關的偏“前端”的開發,通常不太需要區分單個介面例項的多次回撥,因此不太需要複雜的上下文傳遞機制。而偏“後端”開發的非同步任務,特別是生命週期長的非同步任務,卻需要更強大的上下文傳遞能力。所以,本系列文章的上一篇才會把“非同步處理”問題列為與“後端”程式設計緊密相關的工作。

關於上下文引數的話題,還有一些小問題也值得注意:比如在iOS上,context引數在非同步任務執行期間是保持strong還是weak的引用?如果是強引用,那麼如果呼叫者傳進來的context引數是View Controller這樣的大物件,那麼就會造成迴圈引用,有可能導致記憶體洩漏;而如果是弱引用,那麼如果呼叫者傳進來的context引數是臨時建立的物件,那麼就會造成臨時物件剛建立就銷燬,根本透傳不過去。這本質上是引用計數的記憶體管理機制帶來的兩難問題。這就要看我們預期的是什麼場景,我們這裡討論的context引數能夠用於區分單個介面例項的多次回撥,所以傳進來的context引數不太可能是生命週期長的大物件,而應該是生命週期與一個非同步任務基本相同的小物件,它在每次介面呼叫開始時建立,在單次非同步任務結束(結果回撥發生)的時候釋放。因此,在這種預期的場景下,我們應該為context引數傳進來的物件保持強引用。

回撥順序

還是以前面的下載器介面為例,假如我們連續呼叫兩次startDownload,啟動了兩個非同步下載任務。那麼,兩個下載任務哪一個先執行完,是不太確定的。那就意味著可能先啟動的下載任務,反而先執行了結果回撥(downloadSuccess或downloadFailed)。這種回撥順序與初始介面呼叫順序不一致的情況(可以稱為回撥亂序),是否會造成問題,取決於呼叫方的應用場景和具體實現邏輯。但是,從兩個方面來考慮,我們必須注意到:

  • 作為介面呼叫方,我們必須弄清楚我們正在使用的介面是否會發生“回撥亂序”。如果會,那麼我們在處理介面回撥的時候就要時刻注意,保證它不會帶來惡性後果。
  • 作為介面實現方,我們在實現介面的時候就要明確是否為回撥順序提供強的保證:保證不會發生回撥亂序。如果需要提供這種保證,那麼就會增加介面實現的複雜度。

從非同步介面的實現方來講,引發回撥亂序的因素可能有:

  • 提前的失敗結果回撥。實際上,這種情況很容易發生,但卻很難讓人意識到這會導致回撥亂序。一個典型的例子是,一個非同步任務的實現通常要排程到另一個非同步執行緒去執行,但在排程到非同步執行緒之前,就檢查到了某種嚴重的錯誤(比如傳入引數無效導致的錯誤)從而結束了整個任務,並觸發了失敗結果回撥。這樣,後啟動但提前失敗的非同步任務,可能會比先啟動但正常執行的任務更早一步回撥。
  • 提前的成功結果回撥。與“提前的失敗結果回撥”情況類似。一個典型的例子是多級快取的提前命中。比如Memory快取一般都是同步地去查,如果先查Memory快取的時候命中了,這樣就有可能在當前主執行緒直接發生成功結果回撥了,而省去了排程到另一個非同步執行緒再回撥的步驟。
  • 非同步任務的併發執行。非同步介面背後的實現可能對應一個併發的執行緒池,這樣併發執行的各個非同步任務的完成順序就是隨機的。
  • 底層依賴的其它非同步任務是回撥亂序的。

不管回撥亂序是以上那種情況,如果我們想要保證回撥順序與初始介面呼叫順序保持一致,也還是有辦法的。我們可以為此建立一個佇列,當每次呼叫介面啟動非同步任務的時候,我們可以把呼叫引數和其它一些上下文引數進佇列,而回撥則保證按照出佇列順序進行。

也許在很多時候,介面呼叫方並沒有那麼苛刻,偶爾的回撥亂序並不會帶來災難性的後果。當然前提是介面呼叫方對此有清醒的認識。這樣我們在介面實現上保證回撥不發生亂序的做法就沒有那麼大的必要了。當然,具體怎麼選擇,還是要看具體應用場景的要求和介面實現者的個人喜好。

閉包形式的回撥和Callback Hell

當非同步介面的方法數量較少,且回撥介面比較簡單的時候(回撥介面只有一個方法),有時候我們可以用閉包的形式來定義回撥介面。在iOS上,可以利用block;在Android上,可以利用內部匿名類(對應Java 8以上的lambda表示式)。

假如之前的DownloadListener簡化為只有一個回撥方法,如下:

public interface DownloadListener {
    /**
     * 錯誤碼定義
     */
    public static final int SUCCESS = 0;//成功
    //... 其它錯誤碼定義(忽略)

    /**
     * 下載結束回撥.
     * @param errorCode 錯誤碼. SUCCESS表示下載成功, 其它錯誤碼錶示下載失敗.
     * @param url 資源地址.
     * @param localPath 下載後的資源儲存位置.
     * @param contextData 上下文資料.
     */
    void downloadFinished(int errorCode, String url, String localPath, Object contextData);
}複製程式碼

那麼,Downloader介面也能夠簡化,不再需要一個單獨的setListener介面,而是直接在下載介面中接受回撥介面。如下:

public interface Downloader {
    /**
     * 啟動資源的下載.
     * @param url 要下載的資源地址.
     * @param localPath 資源下載後要儲存的本地位置.
     * @param contextData 上下文資料, 在回撥介面中會透傳回去.可以是任何型別.
     * @param listener 回撥介面例項.
     */
    void startDownload(String url, String localPath, Object contextData, DownloadListener listener);
}複製程式碼

這樣定義的非同步介面,好處是呼叫起來程式碼比較簡潔,回撥介面引數(listener)可以傳入閉包的形式。但如果巢狀層數過深的話,就會造成Callback Hell ( callbackhell.com )。試想利用上述Downloader介面來連續下載三個檔案,閉包會有三層巢狀,如下:

    final Downloader downloader = new MyDownloader();
    downloader.startDownload(url1, localPathForUrl(url1), null, new DownloadListener() {
        @Override
        public void downloadFinished(int errorCode, String url, String localPath, Object contextData) {
            if (errorCode != DownloadListener.SUCCESS) {
                //...錯誤處理
            }
            else {
                //下載第二個URL
                downloader.startDownload(url2, localPathForUrl(url2), null, new DownloadListener() {
                    @Override
                    public void downloadFinished(int errorCode, String url, String localPath, Object contextData) {
                        if (errorCode != DownloadListener.SUCCESS) {
                            //...錯誤處理
                        }
                        else {
                            //下載第三個URL
                            downloader.startDownload(url3, localPathForUrl(url3), null, new DownloadListener(

                            ) {
                                @Override
                                public void downloadFinished(int errorCode, String url, String localPath, Object contextData) {
                                    //...最終結果處理
                                }
                            });
                        }
                    }
                });
            }
        }
    });複製程式碼

對於Callback Hell,這篇文章 callbackhell.com 給出了一些實用的建議,比如,Keep your code shallow和Modularize。另外,有一些基於Reactive Programming的方案,比如ReactiveX(在Android上RxJava已經應用很廣泛),經過適當的封裝,對於解決Callback Hell有很好的效果。

然而,針對非同步任務處理的整個非同步程式設計的問題,ReactiveX之類的方案並不是適用於所有的情況。而且,在大多數情況下,不管是我們讀到的別人的程式碼,還是我們自己產生的程式碼,面臨的都是一些基本的非同步程式設計的場景。需要我們仔細想清楚的主要是邏輯問題,而不是套用某個框架就自然能解決所有問題。


大家已經看到,本文用了大部分篇幅在說明一些看起來似乎顯而易見的東西,可能略顯囉嗦。但如果仔細審查,我們會發現,我們平常所接觸到的很多非同步介面,都不是我們最想要的理想的形式。我們需要清楚地認識到它們的不足,才能更好地利用它們。因此,我們值得花一些精力對各種情況進行總結和重新審視。

畢竟,定義好的介面需要深厚的功力,工作多年的人也鮮有人做到。而本文也並未教授具體怎樣做才能定義出好的介面和回撥介面。實際上,沒有一種選擇是完美無瑕的,我們需要的是取捨。

最後,我們可以試著總結一下評判介面好壞的標準(一個並不嚴格的標準),我想到了以下幾條:

  • 邏輯完備(各個介面邏輯不重疊且無遺漏)
  • 能自圓其說
  • 背後有一個符合常理的抽象模型
  • 最重要的:讓呼叫者舒適且能滿足需求

(完)

其它精選文章

相關文章