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之类的方案并非适用于全部的状况。并且,在大多数状况下,无论是咱们读到的别人的代码,仍是咱们本身产生的代码,面临的都是一些基本的异步编程的场景。须要咱们仔细想清楚的主要是逻辑问题,而不是套用某个框架就天然能解决全部问题。


你们已经看到,本文用了大部分篇幅在说明一些看起来彷佛显而易见的东西,可能略显啰嗦。但若是仔细审查,咱们会发现,咱们日常所接触到的不少异步接口,都不是咱们最想要的理想的形式。咱们须要清楚地认识到它们的不足,才能更好地利用它们。所以,咱们值得花一些精力对各类状况进行总结和从新审视。

毕竟,定义好的接口须要深厚的功力,工做多年的人也鲜有人作到。而本文也并未教授具体怎样作才能定义出好的接口和回调接口。实际上,没有一种选择是完美无瑕的,咱们须要的是取舍。

最后,咱们能够试着总结一下评判接口好坏的标准(一个并不严格的标准),我想到了如下几条:

  • 逻辑完备(各个接口逻辑不重叠且无遗漏)
  • 能自圆其说
  • 背后有一个符合常理的抽象模型
  • 最重要的:让调用者温馨且能知足需求

(完)

其它精选文章

相关文章
相关标签/搜索