Android和iOS開發中的非同步處理(三)——多個非同步任務協作

張鐵蕾發表於2019-02-25

本文是系列文章《Android和iOS開發中的非同步處理》的第三篇。在本篇文章中,我們主要討論在執行多個非同步任務的時候可能碰到的相關問題。

通常我們都需要執行多個非同步任務,使它們相互協作來完成需求。本文結合典型的應用場景,講解非同步任務的三種協作關係:

  • 先後接續執行
  • 併發執行,結果合併
  • 併發執行,一方優先

以上三種協作關係,本文分別以三種應用場景為例展開討論。這三種應用場景分別是:

  • 多級快取
  • 併發網路請求
  • 頁面快取

最後,本文還會嘗試給出一個使用RxJava這樣的框架來實現“併發網路請求”的案例,並進行相關的探討。

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

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


多個非同步任務先後接續執行

“先後接續執行”指的是一個非同步任務先啟動執行,待執行完成結果回撥發生後,再啟動下一個非同步任務。這是多個非同步任務最簡單的一種協作方式。

一個典型的例子是靜態資源的多級快取,其中最為大家所喜聞樂見的例子就是靜態圖片的多級快取。通常在客戶端載入一個靜態圖片,都會至少有兩級快取:第一級Memory Cache和第二級Disk Cache。整個載入流程如下:

  1. 先查詢Memory Cache,如果命中,則直接返回;否則,執行下一步
  2. 再查詢Disk Cache,如果命中,則直接返回;否則,執行下一步
  3. 發起網路請求,下載和解碼圖片檔案。

通常,第1步查詢Memory Cache是一個同步任務。而第2步和第3步都是非同步任務,對於同一個圖片載入任務來說,這兩步之間便是“先後接續執行”的關係:“查詢Disk Cache”的非同步任務完成後(發生結果回撥),根據快取命中的結果再決定要不要啟動“發起網路請求”
的非同步任務。

下面我們就用程式碼展示一下“查詢Disk Cache”和“發起網路請求”這兩個非同步任務的啟動和執行情況。

首先,我們需要先定義好“Disk Cache”和“網路請求”這兩個非同步任務的介面。

public interface ImageDiskCache {
    /**
     * 非同步獲取快取的Bitmap物件.
     * @param key
     * @param callback 用於返回快取的Bitmap物件
     */
    void getImage(String key, AsyncCallback<Bitmap> callback);
    /**
     * 儲存Bitmap物件到快取中.
     * @param key
     * @param bitmap 要儲存的Bitmap物件
     * @param callback 用於返回當前儲存操作的結果是成功還是失敗.
     */
    void putImage(String key, Bitmap bitmap, AsyncCallback<Boolean> callback);
}複製程式碼

ImageDiskCache介面用於存取圖片的Disk Cache,其中引數中的AsyncCallback,是一個通用的非同步回撥介面的定義。其定義程式碼如下(本文後面還會用到):

/**
 * 一個通用的回撥介面定義. 用於返回一個引數.
 * @param <D> 非同步介面返回的引數資料型別.
 */
public interface AsyncCallback <D> {
    void onResult(D data);
}複製程式碼

而發起網路請求下載圖片檔案,我們直接呼叫上一篇文章《Android和iOS開發中的非同步處理(二)——非同步任務的回撥》中介紹的Downloader介面(注:採用最後帶有contextData引數的那一版本的Dowanloder介面)。

這樣,“查詢Disk Cache”和“發起網路下載請求”的程式碼示例如下:

    //檢查二級快取: disk cache
    imageDiskCache.getImage(url, new AsyncCallback<Bitmap>() {
        @Override
        public void onResult(Bitmap bitmap) {
            if (bitmap != null) {
                //disk cache命中, 載入任務提前結束.
                imageMemCache.putImage(url, bitmap);
                successCallback(url, bitmap, contextData);
            }
            else {
                //兩級快取都沒有命中, 呼叫下載器去下載
                downloader.startDownload(url, getLocalPath(url), contextData);
            }
        }
    });複製程式碼

Downloader的成功結果回撥的實現程式碼示例如下:

    @Override
    public void downloadSuccess(final String url, final String localPath, final Object contextData) {
        //解碼圖片, 是個耗時操作, 非同步來做
        imageDecodingExecutor.execute(new Runnable() {
            @Override
            public void run() {
                final Bitmap bitmap = decodeBitmap(new File(localPath));
                //重新排程回主執行緒
                mainHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        if (bitmap != null) {
                            imageMemCache.putImage(url, bitmap);
                            imageDiskCache.putImage(url, bitmap, null);
                            successCallback(url, bitmap, contextData);
                        }
                        else {
                            //解碼失敗
                            failureCallback(url, ImageLoaderListener.BITMAP_DECODE_FAILED, contextData);
                        }
                    }
                });
            }
        });
    }複製程式碼

多個非同步任務併發執行,結果合併

“併發執行,結果合併”,指的是同時啟動多個非同步任務,它們同時併發地執行,等到它們全部執行完成的時候,再合併所有執行結果一起做後續處理。

一個典型的例子是,同時發起多個網路請求(即遠端API介面),等獲得所有請求的返回資料之後,再將資料一併處理,更新UI。這樣的做法通過併發網路請求縮短了總的請求時間。

我們根據最簡單的兩個併發網路請求的情況來給出示例程式碼。

首先,還是要先定義好需要的非同步介面,即遠端API介面的定義。

/**
 * Http服務請求介面.
 */
public interface HttpService {
    /**
     * 發起HTTP請求.
     * @param apiUrl 請求URL
     * @param request 請求引數(用Java Bean表示)
     * @param listener 回撥監聽器
     * @param contextData 透傳引數
     * @param <T> 請求Model型別
     * @param <R> 響應Model型別
     */
    <T, R> void doRequest(String apiUrl, T request, HttpListener<? super T, R> listener, Object contextData);
}

/**
 * 監聽Http服務的監聽器介面.
 *
 * @param <T> 請求Model型別
 * @param <R> 響應Model型別
 */
public interface HttpListener <T, R> {
    /**
     * 產生請求結果(成功或失敗)時的回撥介面.
     * @param apiUrl 請求URL
     * @param request 請求Model
     * @param result 請求結果(包括響應或者錯誤原因)
     * @param contextData 透傳引數
     */
    void onResult(String apiUrl, T request, HttpResult<R> result, Object contextData);
}複製程式碼

需要注意的是: 在HttpService這個介面定義中,請求引數request使用Generic型別T來定義。如果這個介面有一個實現,那麼在實現程式碼中應該會根據實際傳入的request的型別(它可以是任意Java Bean),利用反射機制將其變換成Http請求引數。當然,我們在這裡只討論介面,具體實現不是這裡要討論的重點。

而返回結果引數result,是HttpResult型別,這是為了讓它既能表達成功的響應結果,也能表達失敗的響應結果。HttpResult的定義程式碼如下:

/**
 * HttpResult封裝Http請求的結果.
 *
 * 當伺服器成功響應的時候, errorCode = SUCCESS, 且伺服器的響應轉換成response;
 * 當伺服器未能成功響應的時候, errorCode != SUCCESS, 且response的值無效.
 *
 * @param <R> 響應Model型別
 */
public class HttpResult <R> {
    /**
     * 錯誤碼定義
     */
    public static final int SUCCESS = 0;//成功
    public static final int REQUEST_ENCODING_ERROR = 1;//對請求進行編碼發生錯誤
    public static final int RESPONSE_DECODING_ERROR = 2;//對響應進行解碼發生錯誤
    public static final int NETWORK_UNAVAILABLE = 3;//網路不可用
    public static final int UNKNOWN_HOST = 4;//域名解析失敗
    public static final int CONNECT_TIMEOUT = 5;//連線超時
    public static final int HTTP_STATUS_NOT_OK = 6;//下載請求返回非200
    public static final int UNKNOWN_FAILED = 7;//其它未知錯誤

    private int errorCode;
    private String errorMessage;
    /**
     * response是伺服器返回的響應.
     * 只有當errorCode = SUCCESS, response的值才有效.
     */
    private R response;

    public int getErrorCode() {
        return errorCode;
    }

    public void setErrorCode(int errorCode) {
        this.errorCode = errorCode;
    }

    public String getErrorMessage() {
        return errorMessage;
    }

    public void setErrorMessage(String errorMessage) {
        this.errorMessage = errorMessage;
    }

    public R getResponse() {
        return response;
    }

    public void setResponse(R response) {
        this.response = response;
    }
}複製程式碼

HttpResult也包含一個Generic型別R,它就是請求成功時返回的響應引數型別。同樣,在HttpService可能的實現中,應該會再次利用反射機制將請求返回的響應內容(可能是個Json串)變換成型別R(它可以是任意Java Bean)。

好了,現在有了HttpService介面,我們便能演示如何同時傳送兩個網路請求了。

public class MultiRequestsDemoActivity extends AppCompatActivity {
    private HttpService httpService = new MockHttpService();
    /**
     * 快取各個請求結果的Map
     */
    private Map<String, Object> httpResults = new HashMap<String, Object>();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_multi_requests_demo);

        //同時發起兩個非同步請求
        httpService.doRequest("http://...", new HttpRequest1(),
                new HttpListener<HttpRequest1, HttpResponse1>() {
                    @Override
                    public void onResult(String apiUrl,
                                         HttpRequest1 request,
                                         HttpResult<HttpResponse1> result,
                                         Object contextData) {
                        //將請求結果快取下來
                        httpResults.put("request-1", result);
                        if (checkAllHttpResultsReady()) {
                            //兩個請求都已經結束
                            HttpResult<HttpResponse1> result1 = result;
                            HttpResult<HttpResponse2> result2 = (HttpResult<HttpResponse2>) httpResults.get("request-2");
                            if (checkAllHttpResultsSuccess()) {
                                //兩個請求都成功了
                                processData(result1.getResponse(), result2.getResponse());
                            }
                            else {
                                //兩個請求並未完全成功, 按失敗處理
                                processError(result1.getErrorCode(), result2.getErrorCode());
                            }
                        }
                    }
                },
                null);
        httpService.doRequest("http://...", new HttpRequest2(),
                new HttpListener<HttpRequest2, HttpResponse2>() {
                    @Override
                    public void onResult(String apiUrl,
                                         HttpRequest2 request,
                                         HttpResult<HttpResponse2> result,
                                         Object contextData) {
                        //將請求結果快取下來
                        httpResults.put("request-2", result);
                        if (checkAllHttpResultsReady()) {
                            //兩個請求都已經結束
                            HttpResult<HttpResponse1> result1 = (HttpResult<HttpResponse1>) httpResults.get("request-1");
                            HttpResult<HttpResponse2> result2 = result;
                            if (checkAllHttpResultsSuccess()) {
                                //兩個請求都成功了
                                processData(result1.getResponse(), result2.getResponse());
                            }
                            else {
                                //兩個請求並未完全成功, 按失敗處理
                                processError(result1.getErrorCode(), result2.getErrorCode());
                            }
                        }
                    }
                },
                null);
    }

    /**
     * 檢查是否所有請求都有結果了
     * @return
     */
    private boolean checkAllHttpResultsReady() {
        int requestsCount = 2;
        for (int i = 1; i <= requestsCount; i++) {
            if (httpResults.get("request-" + i) == null) {
                return false;
            }
        }
        return true;
    }

    /**
     * 檢查是否所有請求都成功了
     * @return
     */
    private boolean checkAllHttpResultsSuccess() {
        int requestsCount = 2;
        for (int i = 1; i <= requestsCount; i++) {
            HttpResult<?> result = (HttpResult<?>) httpResults.get("request-" + i);
            if (result == null || result.getErrorCode() != HttpResult.SUCCESS) {
                return false;
            }
        }
        return true;
    }

    private void processData(HttpResponse1 data1, HttpResponse2 data2) {
        //TODO: 更新UI, 展示請求結果. 省略此處程式碼
    }

    private void processError(int errorCode1, int errorCode2) {
        //TODO: 更新UI,展示錯誤. 省略此處程式碼
    }
}複製程式碼

我們首先要等兩個請求全部都完成了,才能將它們的結果進行合併。而為了判斷兩個非同步請求是否全部完成了,我們需要在任一個請求回撥時都去判斷所有請求是否已經返回。這裡需要注意的是,之所以我們能採取這樣的判斷方法,有一個很重要的前提:HttpService的onResult已經排程到主執行緒執行。我們在上一篇文章《Android和iOS開發中的非同步處理(二)——非同步任務的回撥》中“回撥的執行緒模型”一節,對回撥發生的執行緒環境已經進行過討論。在onResult已經排程到主執行緒執行的前提下,兩個請求的onResult回撥順序只能有兩種情況:先執行第一個請求的onResult再執行第二個請求的onResult;或者先執行第二個請求的onResult再執行第一個請求的onResult。不管是哪種順序,上面程式碼中onResult內部的判斷都是有效的。

然而,如果HttpService的onResult在不同的執行緒上執行,那麼兩個請求的onResult回撥就可能交叉執行,那麼裡面的各種判斷也會有同步問題。

相比前面講過的“先後接續執行”,這裡的併發執行顯然帶來了不小的複雜度。如果不是對併發帶來的效能提升有特別強烈的需求,也許我們更願意選擇“先後接續執行”的協作關係,讓程式碼邏輯保持簡單易懂。

多個非同步任務併發執行,一方優先

“併發執行,一方優先”,指的是同時啟動多個非同步任務,它們同時併發地執行,但不同的任務卻有不同的優先順序,任務執行結束時,優先採用高優先順序的任務返回的結果。如果高優先順序的任務先執行結束了,那麼後執行完的低優先順序任務就被忽略;如果低優先順序的任務先執行結束了,那麼後執行完的高優先順序任務的返回結果就覆蓋之前低優先順序任務的返回結果。

一個典型的例子是頁面快取。比如,一個頁面要顯示一份動態的列表資料。如果每次頁面開啟時都是隻從伺服器取列表資料,那麼碰到沒有網路或者網路比較慢的情況,頁面會長時間空白。這時通常顯示一份舊的資料,比什麼都不顯示要好。因此,我們可能會考慮給這份列表資料增加一個本地持久化的快取。

本地快取也是一個非同步任務,介面程式碼定義如下:

public interface LocalDataCache {
    /**
     * 非同步獲取本地快取的HttpResponse物件.
     * @param key
     * @param callback 用於返回快取物件
     */
    void getCachingData(String key, AsyncCallback<HttpResponse> callback);

    /**
     * 儲存HttpResponse物件到快取中.
     * @param key
     * @param data 要儲存的HttpResponse物件
     * @param callback 用於返回當前儲存操作的結果是成功還是失敗.
     */
    void putCachingData(String key, HttpResponse data, AsyncCallback<Boolean> callback);
}複製程式碼

這個本地快取所快取的資料物件,就是之前從伺服器取到的一個HttpResponse物件。非同步回撥介面AsyncCallback,我們在前面已經講過。

這樣,當頁面開啟時,我們可以同時啟動本地快取讀取任務和遠端API請求的任務。其中後者比前者的優先順序高。

public class PageCachingDemoActivity extends AppCompatActivity {
    private HttpService httpService = new MockHttpService();
    private LocalDataCache localDataCache = new MockLocalDataCache();
    /**
     * 從Http請求到的資料是否已經返回
     */
    private boolean dataFromHttpReady;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_page_caching_demo);

        //同時發起本地資料請求和遠端Http請求
        final String userId = "xxx";
        localDataCache.getCachingData(userId, new AsyncCallback<HttpResponse>() {
            @Override
            public void onResult(HttpResponse data) {
                if (data != null && !dataFromHttpReady) {
                    //快取有舊資料 & 遠端Http請求還沒返回,先顯示舊資料
                    processData(data);
                }
            }
        });
        httpService.doRequest("http://...", new HttpRequest(),
                new HttpListener<HttpRequest, HttpResponse>() {
                    @Override
                    public void onResult(String apiUrl,
                                         HttpRequest request,
                                         HttpResult<HttpResponse> result,
                                         Object contextData) {
                        if (result.getErrorCode() == HttpResult.SUCCESS) {
                            dataFromHttpReady = true;
                            processData(result.getResponse());
                            //從Http拉到最新資料, 更新本地快取
                            localDataCache.putCachingData(userId, result.getResponse(), null);
                        }
                        else {
                            processError(result.getErrorCode());
                        }
                    }
                },
                null);
    }


    private void processData(HttpResponse data) {
        //TODO: 更新UI, 展示資料. 省略此處程式碼
    }

    private void processError(int errorCode) {
        //TODO: 更新UI,展示錯誤. 省略此處程式碼
    }
}複製程式碼

雖然讀取本地快取資料通常來說比從網路獲取資料要快得多,但既然都是非同步介面,就存在一種邏輯上的可能性:網路獲取資料先於本地快取資料發生回撥。而且,我們在上一篇文章《Android和iOS開發中的非同步處理(二)——非同步任務的回撥》中“回撥順序”一節提到的“提前的失敗結果回撥”和“提前的成功結果回撥”,為這種情況的發生提供了更為現實的依據。

在上面的程式碼中,如果網路獲取資料先於本地快取資料回撥了,那麼我們會記錄一個布林型的標記dataFromHttpReady。等到獲取本地快取資料的任務完成時,我們判斷這個標記,從而忽略快取資料。

單獨對於頁面快取這個例子,由於通常來說讀取本地快取資料和從網路獲取資料所需要的執行時間相差懸殊,所以這裡的“併發執行,一方優先”的做法對效能提升並不明顯。這意味著,如果我們把頁面快取的這個例子改為“先後接續執行”的實現方式,可能會在沒有損失太多效能的前提下,獲得程式碼邏輯的簡單易懂。

當然,如果你決意要採用本節的“併發執行,一方優先”的非同步任務協作關係,那麼一定要記得考慮到非同步任務回撥的所有可能的執行順序。

使用RxJava zip來實現併發網路請求

到目前為止,為了對付多個非同步任務在執行時的各種協作關係,我們沒有采用任何工具,可以說是屬於“徒手搏鬥”的情形。本節接下來就要引入一個“重型武器”——RxJava,看一看它在Android上能否會讓非同步問題的複雜度有所改觀。

我們以前面講的第二種場景“併發網路請求”為例。

在RxJava中,有一個建立在lift操作之上的zip操作,它可以把多個Observable的資料合併在一起,成為一個新的Observable。這正是“併發網路請求”這一場景所需要的特性。

我們可以把兩個併發的網路請求看成兩個Observable,然後使用zip操作將它們的結果進行合併。這看起來簡化了很多。不過,這裡我們首先要解決另一個問題:把HttpService代表的非同步網路請求介面封裝成Observable。

通常來說,把一個同步任務封裝成Observable比較簡單,而把一個現成的非同步任務封裝成Observable就不是那麼直觀了,我們需要用到AsyncOnSubscribe。

public class MultiRequestsDemoActivity extends AppCompatActivity {
    private HttpService httpService = new MockHttpService();

    private TextView apiResultDisplayTextView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_multi_requests_demo);

        apiResultDisplayTextView = (TextView) findViewById(R.id.api_result_display);

        /**
         * 先根據AsyncOnSubscribe機制將兩次請求封裝成兩個Observable
         */

        Observable<HttpResponse1> request1 = Observable.create(new AsyncOnSubscribe<Integer, HttpResponse1>() {
            @Override
            protected Integer generateState() {
                return 0;
            }

            @Override
            protected Integer next(Integer state, long requested, Observer<Observable<? extends HttpResponse1>> observer) {
                final Observable<HttpResponse1> asyncObservable = Observable.create(new Observable.OnSubscribe<HttpResponse1>() {
                    @Override
                    public void call(final Subscriber<? super HttpResponse1> subscriber) {
                        //啟動第一個非同步請求
                        httpService.doRequest("http://...", new HttpRequest1(),
                                new HttpListener<HttpRequest1, HttpResponse1>() {
                                    @Override
                                    public void onResult(String apiUrl, HttpRequest1 request, HttpResult<HttpResponse1> result, Object contextData) {
                                        //第一個非同步請求結束, 向asyncObservable中傳送結果
                                        if (result.getErrorCode() == HttpResult.SUCCESS) {
                                            subscriber.onNext(result.getResponse());
                                            subscriber.onCompleted();
                                        }
                                        else {
                                            subscriber.onError(new Exception("request1 failed"));
                                        }
                                    }
                                },
                                null);
                    }
                });
                observer.onNext(asyncObservable);
                observer.onCompleted();
                return 1;
            }
        });

        Observable<HttpResponse2> request2 = Observable.create(new AsyncOnSubscribe<Integer, HttpResponse2>() {
            @Override
            protected Integer generateState() {
                return 0;
            }

            @Override
            protected Integer next(Integer state, long requested, Observer<Observable<? extends HttpResponse2>> observer) {
                final Observable<HttpResponse2> asyncObservable = Observable.create(new Observable.OnSubscribe<HttpResponse2>() {
                    @Override
                    public void call(final Subscriber<? super HttpResponse2> subscriber) {
                        //啟動第二個非同步請求
                        httpService.doRequest("http://...", new HttpRequest2(),
                                new HttpListener<HttpRequest2, HttpResponse2>() {
                                    @Override
                                    public void onResult(String apiUrl, HttpRequest2 request, HttpResult<HttpResponse2> result, Object contextData) {
                                        //第二個非同步請求結束, 向asyncObservable中傳送結果
                                        if (result.getErrorCode() == HttpResult.SUCCESS) {
                                            subscriber.onNext(result.getResponse());
                                            subscriber.onCompleted();
                                        }
                                        else {
                                            subscriber.onError(new Exception("reques2 failed"));
                                        }
                                    }
                                },
                                null);
                    }
                });
                observer.onNext(asyncObservable);
                observer.onCompleted();
                return 1;
            }
        });

        //對於兩個Observable表示的request,用zip合併它們的結果
        Observable.zip(request1, request2, new Func2<HttpResponse1, HttpResponse2, List<Object>>() {
            @Override
            public List<Object> call(HttpResponse1 response1, HttpResponse2 response2) {
                List<Object> responses = new ArrayList<Object>(2);
                responses.add(response1);
                responses.add(response2);
                return responses;
            }
        }).subscribe(new Subscriber<List<Object>>() {
            private HttpResponse1 response1;
            private HttpResponse2 response2;

            @Override
            public void onNext(List<Object> responses) {
                response1 = (HttpResponse1) responses.get(0);
                response2 = (HttpResponse2) responses.get(1);
            }

            @Override
            public void onCompleted() {
                processData(response1, response2);
            }

            @Override
            public void onError(Throwable e) {
                processError(e);
            }

        });
    }

    private void processData(HttpResponse1 data1, HttpResponse2 data2) {
        //TODO: 更新UI, 展示資料. 省略此處程式碼
    }

    private void processError(Throwable e) {
        //TODO: 更新UI,展示錯誤. 省略此處程式碼
    }複製程式碼

通過引入RxJava,我們簡化了非同步任務執行結束時的判斷邏輯,但把大部分精力花在了“將HttpService封裝成Observable”上面了。我們說過,RxJava是一件“重型武器”,它所能完成的事情遠遠大於這裡所需要的。把RxJava用在這裡,不免給人“殺雞用牛刀”的感覺。

對於另外兩種非同步任務的協作關係:“先後接續執行”和“併發執行,一方優先”,如果想應用RxJava來解決,那麼同樣首先需要先成為RxJava的專家,這樣才有可能很好地完成這件事。

而對於“先後接續執行”的情況,它本身已經足夠簡單了,不引入別的框架反而更簡單。有時候,我們也許更希望處理邏輯簡單,那麼把多個非同步任務的執行,都按照“先後接續執行”的方式來處理,也是一種解決思路。雖然這會損害一些效能。


本文先後討論了三種多非同步任務的協作關係,最後並不想得到這樣一個結論:把多個非同步任務的執行都改成“先後接續執行”以簡化處理邏輯。取捨仍然在於開發者自己。

而且,一個不容忽視的問題是,在很多情況下,選擇權不在我們手裡,我們拿到的程式碼架構也許已經造成了各種各樣的非同步任務協作關係。我們需要做的,就是在這種情況出現時,能夠總是保持頭腦的冷靜,從紛繁複雜的程式碼邏輯中識別和認清當前所處的局面到底屬於哪一種。

(完)

其它精選文章

Android和iOS開發中的非同步處理(三)——多個非同步任務協作

相關文章