cordova-plugin-bluetoothle を使って iOS 同士で Bluetooth 通信する Cordova アプリを作る : 6 モック化編

前回の続き。最終回。

前回までで、cordova-plugin-bluetoothle を使ったペリフェラルとセントラルの実装ができたので、実機に入れてしまえば実際に動作させられるだろう。

しかし、開発中にブラウザで確認したり、iOS シミュレータで確認したりする時は、通信対象の端末がいない状態でも動くように、プラグインの動作をモック化してしまうと良いだろう。つまり、実際には Bluetooth 通信をせず、相手の端末がいるかのような嘘の処理を行わせ、コンポーネントの動作などを確認しやすくするのだ。

Angular4 ベースのアプリである前提で紹介するが、ノウハウは色々なフレームワークに応用が利くと思うので参考にして欲しい。

ブラウザ起動を判別する

開発中に ng serve といった方法で、Mac のブラウザで画面を確認していることを検知するには、どうしたら良いだろうか。

環境変数ファイルを使う

ひとつは、ng serve する際に必ず環境変数ファイルを適用し、Cordova プラグインをモック化して良いと知らせる方法。これが確実に意図したとおりに動かせるだろう。

Angular の場合は .angular-cli.json を設定することで任意の環境変数ファイルを追加できるので、--environment=mockmode といった設定を追加したりできる。

どうやら @angular/cli の v1.2 系はこの環境変数ファイルを切り替えられないバグがあるようなので、@angular/cli の v1.3 系を使おう。

環境変数ファイルを指定できたら、ルートコンポーネントあたりで以下のように条件分岐すれば良い。

import { mockCordovaBluetoothLE } from './mockCordovaBluetoothLE';

@Component({ /* 省略 */ })
export class AppComponent implements OnInit {
  ngOnInit(): void {
    // Cordova プラグインをモック化して良い場合はモック化する
    if(environment.isMockMode) {
      mockCordovaBluetoothLE();
    }
  }
}

mockCordovaBluetoothLE() の実装は後述する。

cordova.js を読み込んだかどうかで判定する

もう少し横着したやり方では、cordova build した後でないと読み込めない cordova.js による変化を捉える方法。

これは純粋にプラグインの API の有無を調べれば良いので、if(environment.isMockMode) 部分が以下のようになる。

if(!(window as any).bluetoothle) {
  // プラグインが存在しないのでモック化する
  mockCordovaBluetoothLE();
}

もう少し広く調べるなら、window.cordova プロパティを調べる、とかでも良いと思う。

iOS シミュレータで起動していることを判別する

iOS シミュレータにおける Bluetooth 通信は、Mac に接続されている Bluetooth デバイスを利用して動作する仕組みになっている。通常はそれ用の Bluetooth デバイスをなかなか用意できないので、iOS シミュレータの場合もブラウザと同様のモック化を行いたい。

そこで、デバイス情報を取得できる Cordova プラグインの cordova-plugin-device と、その Ionic Native ラッパーである @ionic-native/device を使って、iOS シミュレータで起動されたかどうかを判別しよう。

# プラグインのインストール
$ cordova plugin add cordova-plugin-device

# Ionic Native プラグインのインストール (Core は別途インストールしておくこと)
$ npm install @ionic-native/device -D

この程度のプラグインなら直接利用しても良いが、今回は Ionic Native を使ってみる。

import { Device } from '@ionic-native/device';
import { mockCordovaBluetoothLE } from './mockCordovaBluetoothLE';

@Component({ /* 省略 */ })
export class AppComponent implements OnInit {
  // @ionic-native/device を DI する
  constructor(device: Device) {}
  
  ngOnInit(): void {
    // 環境変数でモック化を指定した場合、もしくは window.cordova が存在しない場合
    if(environment.isMockMode || !(window as any).cordova) {
      // モック化処理を呼ぶ
      mockCordovaBluetoothLE();
    }
    else {
      // モック化指定がなく、cordova.js が読み込まれている場合
      // 通常なら Cordova プラグインを使いたいが、iOS シミュレータの場合を判別したい
      
      // cordova-plugin-device は DeviceReady イベント以降で動作する
      document.addEventListener('deviceready', () => {
        // cordova-plugin-device を直接使う場合は (window as any).device.isVirtual とする
        if(this.device.isVirtual) {
          // isVirtual = true の場合は iOS シミュレータで起動しているので
          // モック化処理を呼ぶ
          mockCordovaBluetoothLE();
        }
      });
    }
  }
}

iOS シミュレータでアプリを起動すると、window.device.isVirtualtrue になるので判別できる。その他、device.serialunknown だったり、modelx86_64 となっていたりすることを確認しても、iOS 実機ではないことが確認できるだろう。

これで、モック化したいタイミングの判別はできるようになった。では実際に cordova-plugin-bluetoothle プラグインの動作をモック化してみよう。

cordova-plugin-bluetoothle プラグインのモック化

mockCordovaBluetoothLE() を作る。

window.bluetoothle を上書きしたら、各メソッドを定義し、第1引数の successCb() を実行する、というのが基本形。この引数を自前で用意するのがポイント。

export function fakeCordovaBluetoothLE(): void {
  // window.bluetoothle を上書きしてしまう
  (window as any).bluetoothle = {
    // 以下ペリフェラル向けの API
    // initializePeripheral をモック化する
    initializePeripheral: (successCb, failureCb) => {
      // 第1引数に渡されている成功時のコールバックを実行する
      // ココで過不足なく引数にオブジェクトを渡してやる必要がある
      successCb({
        // status をチェックする実装があるので、status プロパティは必須
        status: 'Fake Init'
      });
      
      // initializePeripheral の場合、write 要求を受け取った場合などに繰り返しコールバック関数が実行されるので
      // それを再現するために setTimeout で遅延実行する
      
      // 1秒遅らせて writeRequested を再現する
      setTimeout(() => {
        successCb({
          status: 'writeRequested',
          // 予め BluetoothService.encodeText() を使って 'Fake Write Request' という文字列をエンコードしておく
          // value に設定しておくことで、セントラルから 'Fake Write Request' と送られてきたテイにする
          value: 'RmFrZSBXcml0ZSBSZXF1ZXN0'
        });
      }, 1000);
      
      // write よりさらに1秒遅らせて readRequested を再現する
      setTimeout(() => {
        successCb({
          status: 'readRequested',
          // requestId を適当に決めておく
          requestId: '9999'
        });
      }, 2000);
    },
    addService: (successCb, failureCb, options) => {
      // addService は特にやることがないので黙って流しておく
      successCb();
    },
    startAdvertising: (successCb, failureCb, options) => {
      successCb();
    },
    respond: (successCb, failureCb, options) => {
      // respond 自体が何を応答しても受信する側がいないので
      // option.value は特に使わない
      successCb({
        value: 'Fake Respond'
      });
    },
    stopAdvertising: (successCb, failureCb) => {
      successCb();
    },
    removeAllServices: (successCb, failureCb) => {
      successCb();
    },
    // 以下セントラル向けの API
    initialize: (successCb) => {
      successCb();
    },
    startScan: (successCb, failureCb, options) => {
      // ダミーで探索中処理として実行する
      successCb({
        fake: 'Fake'
      });
      
      // 定数で指定したアドバタイジング名を見つけたテイにする
      setTimeout(() => {
        successCb({
          advertisement: {
            localName: bluetoothConstants.advertisingName
          },
          address: 'FakeAddress'
        });
      }, 1000);
    },
    isScanning: (successCb, failureCb) => {
      // スキャン中のテイにしておき stopScan を実行させるようにする
      successCb({
        isScanning: true
      });
    },
    stopScan: (successCb, failureCb) => {
      successCb();
    },
    connect: (successCb, failureCb, options) => {
      successCb();
    },
    discover: (successCb, failureCb, options) => {
      successCb();
    },
    write: (successCb, failureCb, options) => {
      // ペリフェラルが write 要求を受け取り、通信が成功するまでの間隔を再現するため
      // 適当に setTimeout で遅延実行する
      setTimeout(() => {
        successCb();
      }, 1000);
    },
    read: (successCb, failureCb, options) => {
      // write と同様、readRequested を待ったかのような間隔を再現して遅延実行する
      // ペリフェラルが返却したテイのメッセージは 'Fake Read Response' を予めエンコードしておく
      setTimeout(() => {
        successCb({
          value: 'RmFrZSBSZWFkIFJlc3BvbnNl'
        });
      }, 1000);
    },
    disconnect: (successCb, failureCb, options) => {
      successCb();
    },
    close: (successCb, failureCb, options) => {
      successCb();
    }
  };
}

いずれの関数も successCb() が呼ばれるので、常に通信が成功したかのような動きをすることになる。引数に渡すオブジェクトも、前回までの実装の中で参照したプロパティだけ定義しておけばエラーにはならないので、実際のオブジェクトの状態を完コピする必要もない。メソッドについても、実装の中で呼び出していないメソッドに関してはモックコードを用意しなくても問題はない。

writereadsetTimeout による遅延実行はお好みで。通信している感を出すために1・2秒遅らせたりすると良いだろう。initializePeripheral だけ特殊で、各イベントを発火させるために status を変えて複数回呼び出すようにしておかないと、ペリフェラルでの通信がいつまで経っても完了しなくなってしまう (ペリフェラル側は readRequested を受け取ったら応答して終了する、という実装にしているので)。

writeRequestedread などで、予めエンコードしておいた固定文言を返すようにしておくことで、あたかもどこかの端末からテキストが送信されたかのように動作させることができる。今回はペリフェラルとセントラルという2つの役割がどういうタイミングで処理を行うのか、よくよく把握しておかないと、モックコードの作成も大変だ。


今回紹介したモックコードの有効・無効の判別方法や、モックコードの実装手法は、cordova-plugin-bluetoothle に限らず、色々な Cordova プラグインで作ることができる。

と、こんなもんである。