Android和iOS開發中的異步處理(二)——異步任務的回調

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

在iOS中,回調一般表現爲delegate的形式;而在Android中,回調一般以listener的形式存在。但無論表現形式如何,回調都是接口設計不可分割的一部分。回調接口設計的好壞,直接影響整個接口設計的成功與否。java

那麼在回調接口的設計和實現中,咱們須要考慮哪些方面呢?如今咱們先把本文要討論的子話題列出以下,而後再逐個討論:git

  • 必須產生結果回調
  • 重視失敗回調 & 錯誤碼應該儘可能詳細
  • 調用接口和回調接口應該有清晰的對應關係
  • 成功結果回調和失敗結果回調應該彼此互斥
  • 回調的線程模型
  • 回調的context參數(透傳參數)
  • 回調順序
  • 閉包形式的回調和Callback Hell

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

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


必須產生結果回調

當接口設計成異步的形式時,接口的最終執行結果就經過回調來返回給調用者。數據庫

但回調接口並不老是傳遞最終結果。實際上咱們能夠將回調分紅兩類:編程

  • 中間回調
  • 結果回調

而結果回調又包含成功結果回調和失敗結果回調。後端

中間回調可能在異步任務開始執行時,執行進度有更新時,或者其它重要的中間事件發生時被調用;而結果回調要等異步任務執行到最後,有了一個明確的結果(成功了或失敗了),才被調用。結果回調的發生意味着這次異步接口的執行結束。緩存

「必須產生結果回調」,這條規則並不像想象的那樣容易遵照。它要求在異步接口的實現中不管發生什麼異常情況,都要在有限的時間內產生結果回調。好比,接收到非法的輸入參數,程序的運行時異常,任務中途被取消,任務超時,以及種種意想不到的錯誤,這些都是發生異常情況的例子。安全

這裏的難度就在於,接口的實現要慎重對待全部可能的錯誤狀況,無論哪一種狀況出現,都必須產生結果回調。不然,可能會致使調用方整個執行流程的中斷。

重視失敗回調 & 錯誤碼應該儘可能詳細

先看一段代碼例子:

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之類的方案並非適用於全部的狀況。並且,在大多數狀況下,無論是咱們讀到的別人的代碼,仍是咱們本身產生的代碼,面臨的都是一些基本的異步編程的場景。須要咱們仔細想清楚的主要是邏輯問題,而不是套用某個框架就天然能解決全部問題。


你們已經看到,本文用了大部分篇幅在說明一些看起來彷佛顯而易見的東西,可能略顯囉嗦。但若是仔細審查,咱們會發現,咱們日常所接觸到的不少異步接口,都不是咱們最想要的理想的形式。咱們須要清楚地認識到它們的不足,才能更好地利用它們。所以,咱們值得花一些精力對各類狀況進行總結和從新審視。

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

最後,咱們能夠試着總結一下評判接口好壞的標準(一個並不嚴格的標準),我想到了如下幾條:

  • 邏輯完備(各個接口邏輯不重疊且無遺漏)
  • 能自圓其說
  • 背後有一個符合常理的抽象模型
  • 最重要的:讓調用者溫馨且能知足需求

(完)

其它精選文章

相關文章
相關標籤/搜索