Speee DEVELOPER BLOG

Speee開発陣による技術情報発信ブログです。 メディア開発・運用、スマートフォンアプリ開発、Webマーケティング、アドテクなどで培った技術ノウハウを発信していきます!

Boltsを使ってiOS/Androidの非同期処理を綺麗にする

最近「白猫プロジェクト」をやりまくってる「僧職系エンジニア」です。
ミラLv100、アンナLv98、クライブLv90のPTで頑張ってます!



今回は実際の業務でiOS/Androidの非同期通信のロジックを書いてみて
感じたことと、実際にどうやったらその問題を解決出来るかを記事にしました。 同じ事に悩んでるネイティブエンジニアの役に立てたらいいなぁ。  

iOS/Android開発の移行作業について

そもそも異なる言語を使っているため、移行作業はどうしても発生しますがその中でも
特に「非同期処理」の移行作業が一番ストレス感じます。禿げるぐらいに。  

非同期処理のインタフェース設計

非同期処理なので処理終了後に呼び出してもらいたい処理をiOSだとBlock、AndroidだとInterfaceでメソッドに渡しますが、そのパターンも
 
  • 成功時と失敗時は別々のコールバックで処理する
  • 成功時と失敗時は同一のコールバックで処理する
  • 失敗時は無視して、成功時のみ処理する
等、色々頭使わないとだめですよね。

移行する際の思考の切り替え

インタフェースが決まっても、実際にどちらかからロジックを移行する際に
iOSだとアレだったけど、Androidはコレだったな」とインタフェースの実装の差に毎回少しずつ精神が削られていきます。
 

コールバックがネストした場合の制御

単一のコールバックだったらまだしも、Aが終わったらBみたいな流れを実装するケースは存在します。
そんなときもiOSAndroidで実装方法違う+可読性低下により、段々泣きそうになります。
思わず心の中で(Node.jsで実装してるんじゃねーんだよ...)と呟くぐらいに。

何とかならないもんか?

「全部1人で出来るもん」の人は全然苦にならないと思いますが、大体の開発はチームで行うので
そこらへんも兼ねて、何とかうまい事できないものかなぁと模索してたところ
「そういえば前にそれっぽいライブラリをどこかで見たなぁ」と思い出し、記憶を遡ったところ
今回のお題であるBolts(iOS/Android)に当たったわけです。

What is Bolts?

iOS/Android両対応のライブラリです。同じようなAPIで扱うことができます。
Facebookに買収されたParseチームが作ったそうな。
最新のFacebookiOSSDKなどでも使用されています。
公式ページには既に「Swiftだとこう書くよ!」と暖かく新言語サポートもしてくれており期待出来ます!
ではこれで何が出来るのか?

jsのpromiseのような機能が使えるよ!

アプリを構築する上で、様々な非同期処理を各々専用のライブラリを用いて実装すると思いますが、それらを全て、Boltsの提供するTaskというオブジェクトでラップします。
一度BoltsのTaskにラップしてしまえば、
  • 並列処理
  • 直列処理
  • インタフェースの統一
など、まさに求めていたものを、統一的に、しかもiOS/Android間の差異を最小限に扱うことができます。
それ以外にも機能はあるんですが、今回は使用しないので一旦無視します。

Boltsを使用した処理フロー

非同期処理をBoltsを使用して整理するには、先に述べた通り、まず個別の非同期処理をTaskでラップします。 具体的には、iOSではBFTaskAndroidではTask<TResult>をcreateし、実際の非同期処理(例えばHTTP通信など)を開始してから、Taskオブジェクトを返します。
BFTask、Task<TResult>には処理の実行ステータスや処理結果、エラーなどを設定できるAPIがあるので、非同期処理の終了時にこれらを呼んでやります。
結果やエラーのどちらかを設定することにより呼び出し元のcontinueWithXXX内部の処理がトリガーされる仕組みになっています。
用意したTaskオブジェクトは、以下のように実行します。  
Taskを返すメソッド.continueWithXXX(
  // ここで非同期処理後の内容を制御する.
  // nullを返す事によりこのスコープの処理を終わらせる.
  return null;
);
Aが終わった後にBを実行するなどの制御が必要な場合は
// A
Taskを返すメソッド.continueWithXXX(
  return Taskを返すメソッド; // B
).continueWithXXXX(
  // タスク
  return null;
);
の様に処理をつなげていきます。 では実際にiOSAndroidのロジックをBotls対応にしてみます。

Bolts導入前のiOS/Androidの非同期ロジック

まずは、よくある非同期通信のロジックを、Boltsを使わずに書いてみます。
内容は「都市名、国を引数で渡し、成功時には天気の結果をJSONで返し、失敗の場合はエラー情報を返す」です。
天気情報に関してはOpenWeatherMapのAPIを使用させていただいており、
通信部分に関してはiOSAFNetworking
AndroidVolleyを使用しています。

iOS

OpenWeatherMapManager.h
#import <Foundation/Foundation.h>

@interface OpenWeatherMapManager : NSObject
/**
 天気情報取得(Bolts非対応)
 */
-(void) getWeatherInfo:(NSString*)city country:(NSString*)country success:(void(^)(NSDictionary* json))success failure:(void(^)(NSError* error))failure;
@end
OpenWeatherMapManager.m
#import "OpenWeatherMapManager.h"

#import "AFNetworking.h"

@implementation OpenWeatherMapManager

-(void) getWeatherInfo:(NSString*)city country:(NSString*)country success:(void(^)(NSDictionary* json))success failure:(void(^)(NSError* error))failure {
    AFHTTPRequestOperationManager *manager = [AFHTTPRequestOperationManager manager];
    manager.responseSerializer = [AFJSONResponseSerializer serializer];
    [manager GET:@"http://api.openweathermap.org/data/2.5/weather" parameters:@{@"q":[NSString stringWithFormat:@"%@,%@", city, country]} success:^(AFHTTPRequestOperation *operation, id responseObject) {
        if(success) {
            success(responseObject);
        }
    } failure:^(AFHTTPRequestOperation *operation, NSError *error) {
        if(failure) {
            failure(error);
        }
    }];
}
呼び出し元
    [[OpenWeatherMapManager new] getWeatherInfo:@"Tokyo" country:@"jp" success:^(NSDictionary *result) {
        NSLog(@"お天気情報:%@", result);
    } failure:^(NSError *error) {
        NSLog(@"エラーになりました。%@", error.description);
    }];

Android

OpenWeatherMapManager.java
public class OpenWeatherMapManager {

    private Context context;
    public OpenWeatherMapManager(final Context context) {
        this.context = context;
    }

    /**
     * コールバック用
     */
    public interface WeatherMapResult {
        public void success(JSONObject json);
        public void failure(Exception e);
    }

    /**
     *  天気情報取得(Bolts非対応)
     *  OMVollyRequestはRequestを継承した独自クラスです。
     *  OMApplicationにはApplicationを継承した独自クラスで、メンバー変数にVolleyのRequestQueueを持っています。
     */
    public void getWeatherInfo(String city, String country, final WeatherMapResult callback) {
        String url = "http://api.openweathermap.org/data/2.5/weather?q=" + city + "," + country;
        // リクエストを作成し
        OMVollyRequest request = new OMVollyRequest(Request.Method.GET, url, null, new Response.Listener() {
            @Override
            public void onResponse(JSONObject response) {
                if(callback != null) {
                    callback.success(response);
                }
            }
        }, new Response.ErrorListener() {
            @Override
            public void onErrorResponse(VolleyError error) {
                if(callback != null) {
                    callback.failure(error);
                }
            }
        });
        // Queueに追加する
        ((OMApplication)context).getRequestQueue().add(request);
    }
}
呼び出し元
        // MainActivityからの呼び出し例
        OpenWeatherMapManager manager = new OpenWeatherMapManager(getApplication());
        manager.getWeatherInfo("Tokyo", "jp", new OpenWeatherMapManager.WeatherMapResult() {
            @Override
            public void success(JSONObject json) {
                Log.i("OWM", "お天気情報:" + json.toString());
            }
            @Override
            public void failure(Exception e) {
                Log.i("OWM", "エラーになりました。:" + e.toString());
            }
        });
まぁこれでも悪くはないんですが、先に書いた通りインタフェースが違うので
移行するときに疲れます。では続いてBoltsを使用したケースを見てみましょう。

Bolts導入後のiOS/Androidの非同期ロジック

OpenWeatherMapManager.h
#import <Foundation/Foundation.h>

@class BFTask;

@interface OpenWeatherMapManager : NSObject

/**
 天気情報取得(Bolts対応)

 コールバックが消えてるだと...
 */
-(BFTask*) getWeatherInfo:(NSString*)city country:(NSString*)country;

@end
OpenWeatherMapManager.m
#import "OpenWeatherMapManager.h"

#import "AFNetworking.h"
#import "BFTask.h"
#import "BFTaskCompletionSource.h"

@implementation OpenWeatherMapManager

-(BFTask*) getWeatherInfo:(NSString*)city country:(NSString*)country {
    BFTaskCompletionSource *task = [BFTaskCompletionSource taskCompletionSource];
    AFHTTPRequestOperationManager *manager = [AFHTTPRequestOperationManager manager];
    manager.responseSerializer = [AFJSONResponseSerializer serializer];
    [manager GET:@"http://api.openweathermap.org/data/2.5/weather" parameters:@{@"q":[NSString stringWithFormat:@"%@,%@", city, country]} success:^(AFHTTPRequestOperation *operation, id responseObject) {
// 成功時にはresultに値を設定し
        [task setResult:responseObject];
    } failure:^(AFHTTPRequestOperation *operation, NSError *error) {
        // 失敗時にはerrorに値を設定
        [task setError:error];
    }];
    return task.task;
}
@end
呼び出し元
    [[[OpenWeatherMapManager new] getWeatherInfo:@"Tokyo" country:@"jp"] continueWithBlock:^id(BFTask *task) {
// task.isCancelledでtaskのキャンセル判定も出来ます。
        if(task.error){
            NSLog(@"エラーになりました。%@", error.description);
        }else{
            NSLog(@"お天気情報:%@", result);
        }
        return nil;
    }];

Android

OpenWeatherMapManager.java
public class OpenWeatherMapManager {

    private Context context;
    public OpenWeatherMapManager(final Context context) {
        this.context = context;
    }

    public Task getWeatherInfo(String city, String country) {
        // iOSと同じくタスクを生成し
        final Task.TaskCompletionSource task = Task. create();
        String url = "http://api.openweathermap.org/data/2.5/weather?q=" + city + "," + country;
        OMVollyRequest request = new OMVollyRequest(Request.Method.GET, url, null, new Response.Listener() {
            @Override
            public void onResponse(JSONObject response) {
                // 結果を設定
                task.setResult(response);
            }
        }, new Response.ErrorListener() {
            @Override
            public void onErrorResponse(VolleyError error) {
                // エラーを設定
                task.setError(error);
            }
        });
        ((OMApplication)context).getRequestQueue().add(request);
        return task.getTask();
    }
}
呼び出し元
        // MainActivityからの呼び出し例
        OpenWeatherMapManager manager = new OpenWeatherMapManager(((MainActivity) getActivity()).getLDApplication());
        manager.getWeatherInfo("Tokyo", "jp").continueWith(new Continuation<JSONObject, Object>() {
            @Override
            public Object then(Task jsonObjectTask) throws Exception {
                if(jsonObjectTask.getError() != null) {
                    Log.i("OWM", "エラーになりました。:" + jsonObjectTask.getError().toString());
                }else{
                    Log.i("OWM", "お天気情報:" + jsonObjectTask.getResult().toString());
                }
                return null;
            }
        });
なんということでしょう、Boltsのお力添えによりインタフェースが統一され、可読性がアップしております。
とまぁ、導入前に比べると色々考えることがなくなり、移行作業が楽になりますね。
もう少しルール決めをしっかりすれば、軽めのコンバーターを作って、
コンバーターを実行するともう片方のソースに変換するぐらいのことは出来そうな感じしませんか?しますよね?
そうすると移行コストが結構減る!->他の事に時間が使えるという幸せ連鎖が発動します。

最後に

ということで、Boltsを触ってみましたがいかがでしたでしょうか?
個人的には導入後の方が移行コストが格段に下がった感じがしたので
現状のプロジェクトでも途中から導入し始めました。
快く協力してくれた同じチームの何でも屋に感謝でございます。 「continueWithXXX」って書くのが長いと感じており、iOSアプリだけでいい人は
PromiseKitの方が記述量が減ると思います。