Cordova プラグインを Promise 化するためのヒント

Cordova プラグインは、Cordova 本体の API の都合上、コールバック地獄になりやすい作りになっていることが多い。

例えば cordova-plugin-googlemaps の V1 系の場合。

document.addEventListener('deviceready', () => {
  var div = document.getElementById('map_canvas');
  var map = plugin.google.maps.Map.getMap(div);
  
  map.addEventListener(plugin.google.maps.event.MAP_READY, () => {
    map.animateCamera({
    target: {lat: 37.422359, lng: -122.084344},
    zoom: 17,
    tilt: 60,
    bearing: 140,
    duration: 5000
  }, () => {
    map.addMarker({
      position: {lat: 37.422359, lng: -122.084344},
      title: 'Welecome to \nCordova GoogleMaps plugin for iOS and Android',
      snippet: 'This plugin is awesome!',
      animation: plugin.google.maps.Animation.BOUNCE
    }, (marker) => {
      marker.showInfoWindow();
      marker.on(plugin.google.maps.event.INFO_CLICK, () => {
        // To do something...
        alert('Hello world!');
      });
    });
  });
}, false);

少し大袈裟にはしたものの、animateCamera() から addMarker() して marker.on() ぐらいの操作はありがちだろう。直列的な処理の流れなのに、コールバック関数の中にコールバック関数、という形になってしまい、つらい。

他にも、cordova-plugin-bluetoothle でペリフェラル端末の準備をする処理。

bluetoothle.initializePeripheral((initializePeripheralResult) => {
  // initializePeripheral() が終わってから addService() したいので…
  bluetoothle.addService((addServiceResult) => {
    // addService() したら startAdvertising() して…
    bluetoothle.startAdvertising((startAdvertisingResult) => {
      console.log('ペリフェラル端末の準備ができました');
    }, (error) => {
      console.log(error);
    }, {
      services: ['1234'],  // iOS
      service: '1234',     // Android
      name: 'Hello World'
    });
  }, (error) => {
    console.log(error);
  }, {
    service: '1234',
    characteristics: [{
      uuid: 'ABCD',
      permissions: {
        read: true,
        write: true
      },
      properties: {
        read: true,
        writeWithoutResponse: true,
        write: true,
        notify: true,
        indicate: true
      }
    }]
  });
}, (error) => {
  console.log(error);
}, {
  request: true,
  restoreKey: 'bluetoothleplugin'
});

cordova-plugin-bluetoothle の場合は、全ての API が bluetoothle.METHOD(successCallback, errorCallback, parameters) の順になっていて、理解はしやすいのだが、コールバック地獄になると外側のメソッドのパラメータが離れた所に書かれてしまって分かりにくくなったりする。

なんでこうなるの

Cordova プラグインの大多数がこのようなコールバック地獄になりやすい API になっているのは、Cordova 本体がネイティブと連携する、cordova.exec() メソッドの作りにある。

cordova.exec(function(winParam) {},
             function(error) {},
             'service',
             'action',
             ['firstArgument', 'secondArgument', 42, false]
            );
  • function(winParam) {} : 成功コールバック関数。仮定すると、exec の呼び出しが正常に完了、およびすべてのパラメーターを渡します。この関数を実行します。
  • function(error) {} エラー・コールバック関数。操作が正常に完了しない場合この関数は省略可能なエラーのパラメーターを持つ実行します。
  • "service" : をネイティブ側で呼び出すサービス名。これは、詳細については以下にネイティブガイドで利用可能なネイティブクラスに対応します。
  • "action" : をネイティブ側で呼び出すアクション名。これは一般に、ネイティブクラスのメソッドに対応します。次に示すネイティブのガイドを参照してください。
  • [/* arguments */] : ネイティブ環境に渡す引数の配列。

機械翻訳で説明がアレだが、要するに全ての Cordova プラグインはこの cordova.exec() に引数を渡してネイティブコードを実行するため、

の3つを引数に取る関数になっているものが多いのだ。

実際に cordova-plugin-bluetoothle の JavaScript からネイティブコードを実行しようとする部分のコードを見てみると、以下のようになっている。

var bluetoothleName = "BluetoothLePlugin";
var bluetoothle = {
  // …中略…
  initializePeripheral: function(successCallback, errorCallback, params) {
    cordova.exec(successCallback, errorCallback, bluetoothleName, "initializePeripheral", [params]);
  },
  // …中略…
}

これが bluetoothle.initializePeripheral() メソッドの実装だ。

そこで Promise 化よぉ

Cordova 本体の作り上、Cordova プラグインのほとんどがコールバック関数になりやすい実装にならざるを得ないことは分かった。ではどのようにして使いやすくするか。

コールバック関数が続く API は Promise でラップしてやるのが手っ取り早い。各メソッドを Promise でラップした、ラッパークラスを作ってしまうと良いだろう。

例えば cordova-plugin-bluetoothle ならこんな要領だ。

/** cordova-plugin-bluetoothle のメソッドを Promise 化したラッパークラス */
class PromiseBluetoothLE {
  /**
   * bluetoothle.initializePeripheral() のラッパー
   * 
   * @param initializePeripheral() の第3引数に渡すパラメータオブジェクト
   * @return 成功時のコールバック関数で受け取る結果オブジェクトを resolve する
   */
  initializePeripheral(params) {
    // プラグインがない場合は reject() する
    if(!window.bluetoothle) {
      return Promise.reject('Plugin not found');
    }
    
    // Promise でプラグインの API をラップする
    return new Promise((resolve, reject) => {
      window.bluetoothle.initializePeripheral((result) => {
        // 成功時のコールバック関数内で結果オブジェクトを受け取り resolve する
        resolve(result);
      }, (error) => {
        // エラー時は reject する
        reject(error);
      }, params);
    });
  }
  
  // 他のメソッドも同様に…
}

このように、プラグインの各メソッドを Promise でラップしたクラスを作ってやれば良い。

実はコレ、やっていることは AngularJS における ngCordova や、Angular における Ionic Native の実装とほぼ同じなのだ。AngularJS の場合は $q サービス (Q ライブラリ) を使用しているが、基本的にはこのようにラップしているだけ。

最初にデッチ上げるのは少し面倒だったり、bluetoothle.initializePeripheral() の場合は Q.notify() を使いたい感もあったりはするのだが、とりあえずこういう発想で Promise 化しておけば、コールバック地獄を回避できる。

promiseBluetoothLE.initializePeripheral({
  request: true,
  restoreKey: 'bluetoothleplugin'
})
  .then(() => {
    return promiseBluetoothLE.addService({
      service: '1234',
      characteristics: [{
        uuid: 'ABCD',
        permissions: {
          read: true,
          write: true
        },
        properties: {
          read: true,
          writeWithoutResponse: true,
          write: true,
          notify: true,
          indicate: true
        }
      }]
    });
  })
  .then(() => {
    return promiseBluetoothLE.startAdvertising({
      services: ['1234'],  // iOS
      service: '1234',     // Android
      name: 'Hello World'
    });
  })
  .then(() => {
    console.log('ペリフェラル端末の準備ができました');
  })
  .catch((error) => {
    console.log(error);
  });

スッキリである。

ES2015 (ES6)・TypeScript、どちらでも使えるし、Angular に限らないやり方なので、Promise 化、オススメ。