BaaS@rakuzaのFlutter SDK開発中!

こんにちは。フロントエンドエンジニアの岸野(@kishisuke)です。

Flutter人気ですよね。
実は弊社もFlutterについて色々な取り組みを行っています。
その一環として、弊社製品であるBaaS@rakuzaのFlutter SDKの開発を開始しています!

本エントリでは、開発中のBaaS@rakuza Flutter SDKについて、少しだけご紹介します。

Flutterとは

Google社が開発している、iOS/Androidなどのハイブリッドアプリ開発ツールです。最近では、デスクトップ(macOSなど)やWebもサポートするようになりました。

同じハイブリッドアプリ開発ツールとしては他にCordova、React Native、Xamarinなどがあります。
弊社もよく使用しているCordovaは、HTML/CSS/JavaScriptで実装したWebアプリケーションをWebView上で動作させる仕組みですが、FlutterはUIやロジックをネイティブレイヤーで実行します。また、独自のレンダリングエンジンを採用しており、UIKitなどの各プラットフォーム固有のUIコンポーネントを使っていない点がReact NativeやXamarinなどと異なります。

他にも

  • 開発言語がDart(AOTコンパイルによる高速な実行)
  • ホットリロード(コードを編集するとDart VMにより即時反映される)
  • デフォルトのUIテーマがマテリアルデザイン

などの特徴があります。
ちなみに弊社、2013年頃からDartに目をつけていて、ブログとか書いてました。Flutterで久方ぶりにDart熱が上がってます!
http://dart.pscsrv.co.jp/

Dartライブラリ or Flutter Plugin

FlutterでSDKを開発するには以下2つの方法があります。

  • Dartライブラリとして実装
  • Flutter Pluginとして実装

BaaS@rakuzaのSDKはサーバーとの通信部分が多くを占めているため、http パッケージを使うだけでもほぼ対応できます。
ただ、その場合はDartでスクラッチ開発する必要があります。また、将来的に各プラットフォーム依存の機能をSDKとして提供したい場合に、結局はFlutter Pluginとして実装する必要があるため、最初からFlutter Pluginとして実装することにしました。

なお、BaaS@rakuzaは現在

  • iOS
  • Android
  • Cordova(Monaca)
  • JavaScript(alpha版)

の4つのプラットフォームに対応しています。これら公開済みのプラットフォーム向けの資産を流用してFlutter Pluginを開発しています。

Flutter Plugin

プロジェクトの生成

Flutterでは便利なことに、Pluginのプロジェクトを生成するコマンドが用意されています!
以下のようにflutter createコマンドを実行する際に、--template=plugin オプションを付けます。開発言語はiOSはSwift、AndroidはKotlinにします。Cordova PluginだとSwift/Kotlinの環境作成がかなり手間なので、すごく楽に感じます!

flutter create --template=plugin -i swift -a kotlin baasatrakuza_flutter

 

Flutter(Dart)側のコード

Flutter側を実装します。

まず、MethodChannelのインスタンスを生成します。そして、生成したMethodChannelのインスタンスのinvokeMapMethodでiOS/Androidの処理を呼び出します。(他に、invokeMethodinvokeListMethodメソッドがあり、それぞれ戻り値の型が異なります)
invokeMapMethodの第1引数にiOS/Android側のメソッド名、(必要であれば)第2引数にiOS/Android側に渡したいパラメーターを指定します。

static const MethodChannel _channel = MethodChannel('baasatrakuza_flutter');

Future<Data> getData(String objectId, String code) async {
  Map<String, dynamic> data = await _channel.invokeMapMethod('getData', {
    'objectId': objectId,
    'code': code
  });
  return Data.fromMap(data);
}

 

なお、invokeMapMethodのパラメーターと戻り値の型は以下ページの通り制限があります。例えば、Dartのクラス(のインスタンス)はそのままだと渡せません。そのため、Mapなどに変換する必要があります。
Writing custom platform-specific code - Flutter

Android(Kotlin)側のコード

続いてAndroid側のコードです。

Flutter側でinvokeMapMethodメソッドを呼び出す際に指定したメソッド名をMethodCall#methodで参照することができるので、メソッド名を基に処理するAndroid側のメソッドを特定します。

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
  when(call.method) {
    "getData" -> getData(call, result)
    // …
    else -> result.notImplemented()
  }
}

 

パラメーターはMethodCall#argumentで取得することができます。
Android SDKの処理(RKZClient.getInstance().getData())を呼び出して、その結果をFlutter側に返却します。返却はMethodChannel.Resultを介して行います。処理成功時はMethodChannel.Result#successを、失敗時はMethodChannel.Result#errorを呼び出します。

fun getData(call: MethodCall, result: MethodChannel.Result) {
    val objectId = call.argument<String>("objectId")
    val code = call.argument<String>("code")

    RKZClient.getInstance().getData(objectId, code) { data, rkzResponseStatus ->
        if (rkzResponseStatus.isSuccess) {
            result.success(exportObject(data))
        } else {
            result.error(rkzResponseStatus.statusCode, rkzResponseStatus.message, null)
        }
    }
}

 

先ほども書いた通り、FlutterとAndroid間でやりとりできるデータ型は限られています。KotlinのオブジェクトもそのままFlutter側に渡すことができないため、Map型に変換する必要があるのですが、exportObjectがその変換処理です。
以下はexportObjectの中身の処理です。リフレクションを使って、オブジェクトのgetterメソッドの情報を基に、Mapに変換しています。(もちろん、ベタにMapに変換してもOK)

fun <T: Any> exportObject(obj: T?): Map<String, Any?> {
    if (obj == null) return mutableMapOf()

    val map: MutableMap<String, Any?> = mutableMapOf()

    obj::class.java.methods.forEach {
        if (isGetter(it)) {
            map[getPropName(it.name)] = exportValue(it.invoke(obj))
        }
    }
    return map
}

fun exportValue(value: Any?): Any? {
    if (value == null) {
        return null
    } else if (value is String) {
        return value
    } else if (value is Number) {
        return value
    } else if (value is Boolean) {
        return value
    } else if (value is Map<*, *>) {
        return value.mapValues { exportValue(it.value) }
    } else if (value is List<*>) {
        return value.map { exportValue(it) }
    } else if (value is Calendar) {
        return value.time.time
    } else if (value is Date) {
        return value.time
    } else {
        return exportObject(value)
    }
}

private fun isGetter(method: Method): Boolean {
    return Modifier.isPublic(method.modifiers)
            && (method.name.startsWith("get") || method.name.startsWith("is"))
            && method.name != "getClass"
            && method.parameterTypes.isEmpty()
            && method.returnType.name != "void"
}

private fun getPropName(name: String): String {
    if (name.startsWith("get")) {
        return name[3].toLowerCase() + name.substring(4)
    } else if (name.startsWith("is")) {
        return name[2].toLowerCase() + name.substring(3)
    }
    throw IllegalArgumentException("不正なプロパティ名です。name=${name}")
}

 

使ってみる

上記で実装したFlutter側のコードを呼び出してみます。RKZClient#getDataメソッドはFutureを返すので、awaitキーワードで非同期処理が完了するまで待つようにします。

void doSomething() async {
  final data = await RKZClient().getData('todo', '0001');
  print(data); // todoオブジェクトのcode=0001の値が出力される!
}

 
※DartのFutureとasync/awaitは以下の記事が参考になります
https://www.cresc.co.jp/tech/java/Google_Dart2/language/asynchrony_futures/asynchrony_futures.html

FlutterFireを参考にWeb対応を見据える

このようにMethodChannelを使えば簡単に作ることができそうなのですが、Flutter on the web(Flutter Web)も対応したいなあと漠然と考えていました。どうやらFlutterFire(FirebaseのFlutter Plugin)が一部Flutter Webに対応しているらしいので、参考にするためソースコードを見てみます。

FlutterFireは機能ごとにパッケージが複数に分かれていますが、例えばコア機能のfirebase_coreはさらに3つにパッケージが分かれています。

  • firebase_core
  • firebase_core_platform_interface
  • firebase_core_web

firebase_core_platform_interface

firebase_core_platform_interfaceは2つの役割があって、1つは各プラットフォームで抽象化されたインターフェース(プラットフォームインターフェースと呼ぶそうです)、もう1つはMethodChannelを使った実装です。

例えば、プラットフォームインターフェースの各メソッドは以下のようにメソッドのシグネチャのみ定義して、中身は空です。呼び出すとUnimplementedErrorをスローします。

abstract class FirebaseCorePlatform extends PlatformInterface {
  //...
  Future<void> configure(String name, FirebaseOptions options) {
    throw UnimplementedError('configure() has not been implemented.');
  }
}

 
MethodChannel実装は、プラットフォームインターフェースを継承(extends)した上で、中身を実装します。

class MethodChannelFirebaseCore extends FirebaseCorePlatform {
  //...
  @override
  Future<void> configure(String name, FirebaseOptions options) {
    return channel.invokeMethod<void>(
      'FirebaseApp#configure',
      <String, dynamic>{'name': name, 'options': options.asMap},
    );
  }
}

 

firebase_core_web

firebase_core_webはプラットフォームインターフェースを継承したWeb実装を定義しています。

class FirebaseCoreWeb extends FirebaseCorePlatform {
  //...
  @override
  Future<void> configure(String name, FirebaseOptions options) async {
    return fb.initializeApp(
      name: name,
      apiKey: options.apiKey,
      databaseURL: options.databaseURL,
      projectId: options.projectID,
      storageBucket: options.storageBucket,
      messagingSenderId: options.gcmSenderID,
      measurementId: options.trackingID,
      appId: options.googleAppID,
    );
  }
}

 
MethodChannel実装がデフォルトのため、firebase_core_platform_interfaceに含めて、Web実装が使いたい場合はfirebase_core_webをオプションで読み込むイメージです。

プラットフォームインターフェース(FirebaseCorePlatform)はシングルトンパターンでインスタンスを保持していますが、そのインスタンスの初期値はMethodChannel実装です。

abstract class FirebaseCorePlatform extends PlatformInterface {
  //...
  static FirebaseCorePlatform _instance = MethodChannelFirebaseCore();

  static set instance(FirebaseCorePlatform instance) {
    PlatformInterface.verifyToken(instance, _token);
    _instance = instance;
  }
}

 
プラットフォームがWebの場合、FirebaseCorePlatformのインスタンスをWeb実装に切り替えています。

class FirebaseCoreWeb extends FirebaseCorePlatform {
  //...
  static void registerWith(Registrar registrar) {
    FirebaseCorePlatform.instance = FirebaseCoreWeb();
  }
}

 

firebase_core

このライブラリを使う側が直接参照するのがfirebase_coreです。firebase_coreはFacadeパターン的にプラットフォームインターフェースの各処理を呼び出します。

プラットフォームインターフェースの実装は使用しているプラットフォームによって切り替わるので、firebase_coreは単にプラットフォームインターフェースを呼び出すだけで良いということですね。
ちなみに、iOSとAndroidの実装もfirebase_coreに含まれています。

class FirebaseApp {
  //...
  static Future<FirebaseApp> configure({
    @required String name,
    @required FirebaseOptions options,
  }) async {
    assert(name != null);
    assert(name != defaultAppName);
    assert(options != null);
    assert(options.googleAppID != null);
    final FirebaseApp existingApp = await FirebaseApp.appNamed(name);
    if (existingApp != null) {
      return existingApp;
    }
    await FirebaseCorePlatform.instance.configure(name, options);
    return FirebaseApp(name: name);
  }
}

 
※なお、この辺りの詳細なアーキテクチャは以下の記事にまとめられています。
How To Write a Flutter Web Plugin: Part 2 - Flutter - Medium

BaaS@rakuzaとしては一旦、firebase_coreとfirebase_core_platform_interface相当だけ実装して、firebase_core_web相当は後で実装する方向にしました。

コードが冗長にはなってはしまいますが、将来Web対応する時に簡単に対応することができるでしょう。Flutter Webの今後に期待です!

今後

まだ開発途中ですが、夏頃までには一般公開できたらなと考えています。
引き続き、弊社ではFlutterへの取り組みを進めていきます!

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

%d人のブロガーが「いいね」をつけました。