Edited at

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


はじめに

この記事は Flutter #2 Advent Calendar 2018 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

SkyWay - Enterprise Cloud WebRTC Platform

SkyWay Advent Calendar 2018


できたもの

https://github.com/imamurh/flutter_skyway


  • とりあえず1対1のビデオ通話ができるところまで(エラーハンドリングなどは不十分)

  • iOS のみ

  • 後述の理由により、ビデオの表示に PlatformView を使っています

IMG_0591.PNG


Plugin package プロジェクトの作成

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 を追記します。


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

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

cd example; flutter build ios --no-codesign

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

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


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

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


iOS 側


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 に必要な情報が渡ってきます。プロジェクト作成直後のコードでは call を見ていませんが、メソッド名、引数が取得できます。例えば下記のように、メソッド名で処理を分岐させます。


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 側


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 でメソッド呼び出しを実行します。第一引数がメソッド名で、下記のように渡したい情報を第二引数で渡すこともできます。


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 を作成しています。


EventChannel作成

    private func createEventChannel(peerId: String) -> FlutterEventChannel? {

return FlutterEventChannel(name: "flutter_skyway/\(peerId)", binaryMessenger: self.messenger)
}

そして、FlutterStreamHandler プロトコルを実装するクラスを用意し、FlutterEventChannel の setStreamHandler() で登録します。今回の例では FlutterSkywayPeer というクラスを用意しました。


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 を保持しておき、イベント発生のタイミングでコールすることによりイベントを通知することができます。例えば下記の要領です。


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 を作成し、購読します。


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 の実装を参考にさせていただきました。その実装で画面描画は、AVPlayerItemVideoOutput で取得した CVPixelBuffer を FlutterTexture プロトコルの copyPixelBuffer() で渡すことにより実現しているようでした。

一方、今回利用した SkyWay SDK ですが、CVPixelBuffer の取得する手段は用意されておらず、ビデオを表示するには UIView を継承した専用のクラスを利用するしかないようでした。(低レイヤを意識せず利用できることは素晴らしいことですが)

そのため、今回は PlatformView を使ってビデオ表示を行いました。


iOS 側


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 プロトコルを実装し、FlutterPlatformView プロトコルに準拠するクラスを適当に作成します。ここでは flutter_skyway/video_view という ID で Factory を登録しています。

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


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 側

UiKitView(

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

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


おわりに

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