Flutter Plugin Package を作ってみる 〜 SkyWay SDK で WebRTC 〜

# はじめに

この記事は [Flutter #2 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/flutter-2) 11日目の記事です。Firebase について書こうかと思っていたのですが大したネタができなかったので Plugin packages について書きます。:sweat_smile:

# Plugin packages

Flutter の Package には、Dart のみで書かれたものと、OS 固有の実装を書いて連携するものの2種類があり、前者は Dart packages、後者は Plugin packages と呼ばれます。今回 OS と連携する部分について調べてみようと思い、何か Plugin packages を作ってみることにしました。あまり単純すぎてもつまらないので、SkyWay SDK を使って WebRTC をやってみたいと思います。

[Developing packages & plugins](https://flutter.io/docs/development/packages-and-plugins/developing-packages)
[SkyWay - Enterprise Cloud WebRTC Platform](https://webrtc.ecl.ntt.com)
[SkyWay Advent Calendar 2018](https://qiita.com/advent-calendar/2018/skyway)

# できたもの

https://github.com/imamurh/flutter_skyway

- とりあえず1対1のビデオ通話ができるところまで（エラーハンドリングなどは不十分）
- iOS のみ
- 後述の理由により、ビデオの表示に **PlatformView** を使っています

<img width="375" alt="IMG_0591.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/121719/d93bef13-23a5-2445-8a0c-e9dc2dfdc078.png">

# Plugin package プロジェクトの作成


```shell
flutter create --template=plugin -i swift -a kotlin flutter_skyway
```

--template=plugin を指定して flutter create を実行します。ネイティブ言語はデフォルトだと Objective-C/Java になりますが、上記のようにオプションで Swift/Kotlin も選択できます。

# 依存するライブラリのインストール

今回利用する SkyWay SDK は CocoaPods でのインストールに対応しています。下記の通り ios/flutter_skyway.podspec に SkyWay を追記します。

```diff:ios/flutter_skyway.podspec
 #
 # To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html
 #
 Pod::Spec.new do |s|
   s.name             = 'flutter_skyway'
   s.version          = '0.0.1'
   s.summary          = 'A new flutter plugin project.'
   s.description      = <<-DESC
 A new flutter plugin project.
                        DESC
   s.homepage         = 'http://example.com'
   s.license          = { :file => '../LICENSE' }
   s.author           = { 'Your Company' => 'email@example.com' }
   s.source           = { :path => '.' }
   s.source_files = 'Classes/**/*'
   s.public_header_files = 'Classes/**/*.h'
   s.dependency 'Flutter'
+  s.dependency 'SkyWay'
 
   s.ios.deployment_target = '8.0'
 end
```

依存ライブラリをインストールするため、下記コマンドを一度実行しておきます。

```shell
cd example; flutter build ios --no-codesign
```

この後 example/ios/Runner.xcworkspace を Xcode で開いて Swift のコードを書いていきます。

Swift を選んでプロジェクトを作成すると、[FlutterPlugin](https://docs.flutter.io/objcdoc/Protocols/FlutterPlugin.html) プロトコルに準拠したクラスが2つ用意されています。今回の例では FlutterSkywayPlugin と SwiftFlutterSkywayPlugin ですが、Obj-C で書かれた FlutterSkywayPlugin は SwiftFlutterSkywayPlugin を呼び出すだけなので、実際の処理は SwiftFlutterSkywayPlugin の方に記述していきます。

# Flutter から iOS 側へのメソッド呼び出し

Flutter から iOS のメソッド呼び出しには **MethodChannel** という仕組みを利用します。

## iOS 側

```swift:SwiftFlutterSkywayPlugin.swift（プロジェクト作成直後）
public class SwiftFlutterSkywayPlugin: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
    let channel = FlutterMethodChannel(name: "flutter_skyway", binaryMessenger: registrar.messenger())
    let instance = SwiftFlutterSkywayPlugin()
    registrar.addMethodCallDelegate(instance, channel: channel)
  }

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    result("iOS " + UIDevice.current.systemVersion)
  }
}
```

register(with:) では `flutter_skyway` という名前で MethodChannel を作成し、SwiftFlutterSkywayPlugin インスタンスを登録しています。Flutter 側から MethodChannel 経由でメソッドが呼び出しが実行されると handle(:result:) が呼び出され、[FlutterMethodCall](https://docs.flutter.io/objcdoc/Classes/FlutterMethodCall.html) に必要な情報が渡ってきます。プロジェクト作成直後のコードでは call を見ていませんが、メソッド名、引数が取得できます。例えば下記のように、メソッド名で処理を分岐させます。

```swift:SwiftFlutterSkywayPlugin.swift
public class SwiftFlutterSkywayPlugin: NSObject, FlutterPlugin {
    ...
    enum Method: String {
        case connect
        case destroy
        case listAllPeers
        case call
        case accept
        case reject
    }

    public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
        guard let method = Method.init(rawValue: call.method) else {
            result(FlutterMethodNotImplemented)
            return
        }
        switch method {
        case .connect:      connect(call, result: result)
        case .destroy:      destroy(call, result: result)
        case .listAllPeers: listAllPeers(call, result: result)
        case .call:         self.call(call, result: result)
        case .accept:       accept(call, result: result)
        case .reject:       reject(call, result: result)
        }
    }
}
```

処理が完了したら result を呼び出します。呼び出し元に何か値を返したい場合は result の引数に値を渡し呼び出します。



## Flutter 側

```dart:lib/flutter_skyway.dart（プロジェクト作成直後）
class FlutterSkyway {
  static const MethodChannel _channel =
      const MethodChannel('flutter_skyway');

  static Future<String> get platformVersion async {
    final String version = await _channel.invokeMethod('getPlatformVersion');
    return version;
  }
}
```

Flutter 側でも `flutter_skyway` という名前で MethodChannel を作成します。そして invokeMethod でメソッド呼び出しを実行します。第一引数がメソッド名で、下記のように渡したい情報を第二引数で渡すこともできます。

```dart:lib/flutter_skyway.dart
final MethodChannel _channel = const MethodChannel('flutter_skyway');

class FlutterSkyway {
  static Future<String> connect(String apiKey, String domain) async {
    final String peerId = await _channel.invokeMethod('connect', {
      'apiKey': apiKey,
      'domain': domain,
    });
    print('peerId: $peerId');
    return peerId;
  }
}
```

簡単ですね。:relaxed:

# iOS から Flutter 側へのイベント通知

逆に iOS から Flutter 側へ状態変化などのイベントを通知したい場合には、**EventChannel** を利用します。

## iOS 側

MethodChannel と同様に名前を指定して EventChannel を作成します。下記の例では `flutter_skyway/${peerId}` という名前で EventChannel を作成しています。

```swift:EventChannel作成
    private func createEventChannel(peerId: String) -> FlutterEventChannel? {
        return FlutterEventChannel(name: "flutter_skyway/\(peerId)", binaryMessenger: self.messenger)
    }
```

そして、[FlutterStreamHandler](https://docs.flutter.io/objcdoc/Protocols/FlutterStreamHandler.html) プロトコルを実装するクラスを用意し、FlutterEventChannel の setStreamHandler() で登録します。今回の例では FlutterSkywayPeer というクラスを用意しました。

```swift:FlutterSkywayPeer.swift
final class FlutterSkywayPeer: NSObject {
    var eventChannel: FlutterEventChannel? {
        didSet {
            oldValue?.setStreamHandler(nil)
            eventChannel?.setStreamHandler(self) // 登録
        }
    }
    var eventSink: FlutterEventSink?
}

extension FlutterSkywayPeer: FlutterStreamHandler {
    func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
        eventSink = events // 保持
        return nil
    }
    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        eventSink = nil
        return nil
    }
}
```

Flutter 側で EventChannel を購読すると FlutterStreamHandler の onListen(withArguments:eventSink:) が呼び出されます。この eventSink を保持しておき、イベント発生のタイミングでコールすることによりイベントを通知することができます。例えば下記の要領です。

```swift:FlutterSkywayPeer.swift（イベント通知）
final class FlutterSkywayPeer: NSObject {
    ...
    // Call を受け取った
    private func onCall(mediaConnection: SKWMediaConnection) {
        guard self.mediaConnection == nil,
            let from = mediaConnection.peer else {
            mediaConnection.close()
            return
        }
        self.mediaConnection = mediaConnection
        // イベント通知
        eventSink?(["event": "onCall", "from": from])
    }
}
```

## Flutter 側

Flutter 側でも同じく `flutter_skyway/${peerId}` という名前で EventChannel を作成し、購読します。

```dart:lib/flutter_skyway.dart
final MethodChannel _channel = const MethodChannel('flutter_skyway');
...

typedef ReceiveCallCallback = void Function(String remotePeerId);

class SkywayPeer {
  final String peerId;
  SkywayPeer({this.peerId});

  ReceiveCallCallback onReceiveCall;
  StreamSubscription<dynamic> _eventSubscription;

  void initialize() {
    // 購読
    _eventSubscription = EventChannel('flutter_skyway/$peerId')
        .receiveBroadcastStream()
        .listen(_eventListener, onError: _errorListener);
  }

  Future<void> dispose() async {
    await _eventSubscription?.cancel();
  }

  // イベントが通知されたら呼び出される
  void _eventListener(dynamic event) {
    final Map<dynamic, dynamic> map = event;
    switch (map['event']) {
      case 'onCall':
        if (onReceiveCall != null) {
          onReceiveCall(map['from']);
        }
        break;
    }
  }

  void _errorListener(Object obj) {
    print('onError: $obj');
  }
}
```

# 描画する

今回 Plugin package を作成するにあたって、[video_player](https://github.com/flutter/plugins/tree/master/packages/video_player) の実装を参考にさせていただきました。その実装で画面描画は、AVPlayerItemVideoOutput で取得した CVPixelBuffer を [FlutterTexture](https://docs.flutter.io/objcdoc/Protocols/FlutterTexture.html) プロトコルの copyPixelBuffer() で渡すことにより実現しているようでした。
一方、今回利用した SkyWay SDK ですが、CVPixelBuffer の取得する手段は用意されておらず、ビデオを表示するには UIView を継承した専用のクラスを利用するしかないようでした。（低レイヤを意識せず利用できることは素晴らしいことですが）
そのため、今回は **PlatformView** を使ってビデオ表示を行いました。

## iOS 側

```swift:SwiftFlutterSkywayPlugin.swift
public class SwiftFlutterSkywayPlugin: NSObject, FlutterPlugin {

    public static func register(with registrar: FlutterPluginRegistrar) {
        let channel = FlutterMethodChannel(name: "flutter_skyway", binaryMessenger: registrar.messenger())
        let instance = SwiftFlutterSkywayPlugin(registrar: registrar)
        registrar.addMethodCallDelegate(instance, channel: channel)
        registrar.register(instance, withId: "flutter_skyway/video_view") // 登録
    }

    private let remoteView = UIView()
    ...
}

extension SwiftFlutterSkywayPlugin: FlutterPlatformViewFactory {
    public func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView {
        remoteView.frame = frame
        remoteView.backgroundColor = .black
        if let view = peers.first?.value.remoteStreamView {
            view.frame = remoteView.bounds
            view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            remoteView.addSubview(view)
        }
        return FlutterSkywayPlatformView(remoteView)
    }
}

class FlutterSkywayPlatformView: NSObject, FlutterPlatformView {
    let platformView: UIView
    init(_ platformView: UIView) {
        self.platformView = platformView
        super.init()
    }
    func view() -> UIView {
        return platformView
    }
}
````

[FlutterPlatformViewFactory](https://docs.flutter.io/objcdoc/Protocols/FlutterPlatformViewFactory.html) プロトコルを実装し、[FlutterPlatformView](https://docs.flutter.io/objcdoc/Protocols/FlutterPlatformView.html) プロトコルに準拠するクラスを適当に作成します。ここでは `flutter_skyway/video_view` という ID で Factory を登録しています。

なお、PlatformView を利用するには、現時点では Info.plist に `io.flutter.embedded_views_preview = YES` を追加する必要があります。

```diff:example/ios/Runner/Info.plist
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
+       <key>io.flutter.embedded_views_preview</key>
+       <true/>
        <key>CFBundleDevelopmentRegion</key>
        <string>en</string>
```

## Flutter 側

```dart
UiKitView(
    viewType: 'flutter_skyway/video_view',
    onPlatformViewCreated: (id) {
        print('UiKitView created: id = $id');
    },
)
```

登録した Factory の ID `flutter_skyway/video_view` で UiKitView を使用するだけです。

# おわりに

Plugin Package の作成を通し、Flutter と iOS で相互に連携するやり方について調べました。今回時間が足りなくて調べきれていませんので、もっといい方法がある、内容に誤りがあるなどありましたら、ご指摘いただけると幸いです。