Flutterを活用してUnity製アプリの表現をさらに広げよう!

FlutterとUnityを連携させる

本連載は、iOS/Android向けのアプリでUIの表現力を高めることを目標に、Unity製アプリにFlutterを導入した例を具体的な実装方法を交えながら紹介する記事の4回目となります。

前回の記事ではUnityからExportしたXcodeプロジェクトに対してFlutterを組み込む方法を解説しました。4回目となる今回は実際にFlutterとUnityで連携を行う方法を紹介します。

前回までの記事で、UnityからExportされたAndroid/iOSプロジェクトにFlutterを組み込みビルドする方法を紹介していますのでまだ見ていない方はまずそちらをご覧ください。

FlutterとUnityの連携概要

2回目の記事でも少し触れたのですが、アプリ内にFlutterEngineとUnityEngineが存在しそれぞれがViewに描画している状態になっています。なので、アプリのネイティブ部分を介してデータをやり取りすることでFlutterとUnityで連携が実現できます。Unityとネイティブの連携、Flutterとネイティブの連携を実装し、ネイティブ側でそれをつなげるイメージです。

FlutterとUnityの連携

まずはUnityとネイティブの連携ですがUnityのネイティブプラグインを作成しUnityからメソッドを呼び出し、ネイティブ側からはUnitySendMessageを使用しUnity側にデータを渡します。Unityの一般的な手法で検索をすると具体的な実装例なども出てくると思うので詳細な解説は端折ります。

弊社ココネの技術ブログでも紹介されている記事もあるので良ければご覧ください。

次にFlutterとネイティブの連携ですがFlutterの MethodChannel という仕組みを利用します。Flutter、ネイティブ双方に同じ名前のChannelを作成することでデータのやり取りが可能となるものです。こちらもドキュメントを参照し、検索をかければ具体的なサンプルなどが出てくると思うので詳細は端折ります。

では実際にネイティブプラグイン、MethodChannelを使用したどサンプルコードを紹介しながら解説していきます。

Unityの実装

Unity側ではネイティブの呼び出しとネイティブからのメッセージの受け取りを実装します。以下のコードでファイルを作成しシーン上に FlutterContoller という名前のGameObjectとして配置しておきます。

using UnityEngine;
using System.Runtime.InteropServices;

public class FlutterController : MonoBehaviour
{
#if UNITY_ANDROID
    private AndroidJavaObject context;
#elif UNITY_IOS
    [DllImport("__Internal")]
    private static extern void listenFlutter();

    [DllImport("__Internal")]
    private static extern void sendToFlutter(string value);
#endif

    private void Awake()
    {
#if UNITY_ANDROID
        // ネイティブ呼び出しのためcurrentActivityを取得
        var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
        context = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
#elif UNITY_IOS
        // ios側でFlutterからのメッセージを受け取る準備
        listenFlutter();
#endif
    }

    public void Send(string value)
    {
#if UNITY_ANDROID
        context.Call("SendToFlutter", value);
#elif UNITY_IOS
        sendToFlutter(value);
#endif
    }

    public void OnReceive(string value)
    {
        Debug.Log(value);
    }
}

Flutter(dart)の実装

Flutter側にネイティブ側とやり取りするためのMethodChannelを作成します。

import 'dart:developer';

import 'package:flutter/services.dart';

// チャンネル名は任意だがユニークになるようにする必要がある
MethodChannel nativeChannel = const MethodChannel("com.example.module/channel");

class NativeChannelController {
  NativeChannelController() {
    // MethodChannelで受け取ったメッセージを_callMethodで受け取る
    nativeChannel.setMethodCallHandler(_callMethod);
  }

  Future<dynamic> _callMethod(MethodCall call) async {
    // ネイティブ側から toFlutter というキーでメッセージを送る実装をする
    if (call.method == "toFlutter") {
      log('received: ${call.arguments}');
    }

    return "success";
  }

  void sendToUnity(String value) {
    // ネイティブ側で toUnity というキーでメッセージを受け取る実装をする
    nativeChannel.invokeMethod("toUnity", value);
  }
}

Android(java)の実装

UnityとFlutterの実装に合わせてAndroidの実装を行います。Unity側で作成しているGameObject名やメソッド名、Flutter側で作成したChannel名やキーなどと一致させる必要があります。

@Override
protected void onCreate(Bundle savedInstanceState) {
    // UnityEngineやFlutterEngineの初期化
    
    // Flutterと通信するためのChannelを作成
    // チャンネル名はFlutterで実装した名前と一致させる必要あり
    methodChannel = new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), "com.example.module/channel");

    // FlutterからのメッセージをmethodCallで受け取る
    methodChannel.setMethodCallHandler(this::flutterMethodCall);
}

private void flutterMethodCall(MethodCall call, MethodChannel.Result result) {

    // FlutterからのメッセージをUnity側に送る
    if(call.method.equals("toUnity")) {
        UnitySendMessage("FlutterController", "OnReceive", (String) call.arguments);
        result.success(true);
        return;
    }

    result.error("NotImplemented", "Method not implemented", null);
}

// Unityから呼び出されるメソッド
public void SendToFlutter(String value) {
    runOnUiThread(new Runnable() {
        @Override
        public void run() {
            methodChannel.invokeMethod("toFlutter", value);
        }
    });
}

iOS(objective-c)の実装

UnityとFlutterの実装に合わせてiOSの実装を行います。Unity側で作成しているGameObject名やメソッド名、Flutter側で作成したChannel名やキーなどと一致させる必要があります。前回の記事で説明しましたが NSNotificationCenter を使用してメッセージのやり取りを行うのでAndroidよりも少し複雑になります。

前回の記事で紹介したFlutterController.mmのコードを改変する形で紹介します。

- (id) init:(UnityFramework *)unityFramework {
    self = [super init];
    unityFrameworkCache = unityFramework;

    // NSNotificationCenterに送られてくるメッセージを購読
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleUnityNotification:) name:@"unity2flutter" object:nil];
    
    return self;
}

- (void)handleUnityNotification:(NSNotification *)notification {
    NSDictionary *info = notification.userInfo;
    
    // toFlutterのキーで送られてきたメッセージをMethodChannelを使用しFlutterに送る
    if([info objectForKey:@"toFlutter"]) {
        NSString *str = info[@"toFlutter"];
        [flutterChannel invokeMethod:@"toFlutter" arguments:str];
        return;
    }
    
    if([info objectForKey:@"unity_initialized"]) {
        rootViewController = [[unityFrameworkCache appController] rootViewController];
        [self initializeFlutter];
    }

    // 以下省略
}

- (void)initializeFlutter {
    // FlutterEngineのインスタンス作成
    flutterEngine = [[FlutterEngine alloc] initWithName:@"io.flutter" project:nil];
    
    // Flutter初期化時にエントリポイントとなるメソッドを指定
    [flutterEngine runWithEntrypoint:@"main" initialRoute:@"/"];

    // FlutterのPlugin管理のためにEngineをセット
    [GeneratedPluginRegistrant registerWithRegistry:flutterEngine];
    
    // Flutterと通信するためのMethodChannelを作成
    flutterChannel = [FlutterMethodChannel methodChannelWithName:@"com.example.module/channel" binaryMessenger:flutterEngine.binaryMessenger];
    [flutterChannel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
        [self flutterMethodCall:call result:result];
    }];
}

-(void) flutterMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
    
    // Flutterから来たtoUnityがキーのメッセージをUnityへ送る
    if([call.method isEqualToString:@"toUnity"]) {
        NSDictionary *dic = @{
            @"value":(NSString *)call.arguments
        };
        [[NSNotificationCenter defaultCenter] postNotificationName:@"flutter2unity" object:nil  userInfo:dic];
        result(@"success");
        return;
    }
    
    result(FlutterMethodNotImplemented);
}

前回の記事で紹介した方法ではFlutterController.mmからUnitySendMessageを呼び出すことはできないのでアプリ本体とUnityFrameworkをつなぐためのコードを用意します。具体的には以下のコードを .mm のファイルとして保存しUnityのPluginsフォルダに配置しておくだけで大丈夫です。

extern "C" {

// FlutterController.mmで受け取ったFlutterからのメッセージをUnityへ送る
void listenFlutter() {
    [[NSNotificationCenter defaultCenter] addObserverForName:@"flutter2unity" object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notice) {
        UnitySendMessage("FlutterController", "OnReceive", [notice.userInfo[@"value"] UTF8String]);
    }];
}

// Unityから受け取ったメッセージをFlutterController.mmに送る
void sendToFlutter(const char *value) {
    NSString *str = [NSString stringWithUTF8String:value];
    [[NSNotificationCenter defaultCenter] postNotificationName:@"unity2flutter" object:nil  userInfo:@{@"toFlutter":str}];
}

}

コードが多くなってしまいましたがやっているやっているのは、Unityとネイティブで通信、Flutterとネイティブで通信、ネイティブ内で通信をつなぐだけになります。1つ1つの要素で分解していけばそれほど複雑なことはやっていないはずです。

それでは最後にUnityからのメッセージをトリガーにFlutterのViewの表示制御の実装です。ここもFlutterからのメッセージが来たタイミングでネイティブで表示、非表示を切り替えるだけなので難しくはないはずです。AndroidでFlutterViewの表示切り替え

@Override
protected void onCreate(Bundle savedInstanceState) {
    // UnityEngineやFlutterEngineの初期化
    // MethodChannelの初期化
    
    // あらかじめFlutter用のViewを作成しておく
    // 作成しているだけで表示はしない
    flutterFragment = FlutterFragment.withCachedEngine(FLUTTER_ENGINE_ID)
        .renderMode(RenderMode.surface)
        .transparencyMode(TransparencyMode.transparent)
        .build();
}

private void flutterMethodCall(MethodCall call, MethodChannel.Result result) {

    // Flutter用のViewの表示・非表示切り替え
    if (call.method.equals("show")) {
        boolean show = (boolean) call.arguments;

        if(show) {
            getSupportFragmentManager().beginTransaction().add(rootViewId, flutterFragment, TAG_FLUTTER_FRAGMENT).commit();
        }
        else {
            getSupportFragmentManager().beginTransaction().remove(flutterFragment).commit();
        }
        result.success(true);
        return;
    }

    // 以下略
}

iOSでFlutterViewの表示切り替え

-(void) flutterMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {

    // Flutter用のViewの表示・非表示切り替え
    if ([call.method isEqualToString:@"show"]) {
        
        NSNumber *show = (NSNumber *)call.arguments;
        
        if(show.intValue == 1 && flutterViewController == nil) {
            flutterViewController = [[FlutterViewController alloc] initWithEngine:flutterEngine nibName:nil bundle:nil];
            flutterViewController.modalPresentationStyle = UIModalPresentationOverCurrentContext;
            [rootViewController presentViewController:flutterViewController animated:NO completion:nil];
        }
        else if(show.intValue == 0 && flutterViewController != nil) {
            [flutterViewController dismissViewControllerAnimated:NO completion:nil];
            flutterViewController = nil;
        }
        result(@"success");
    }

    // 以下略
}

あとはFlutter側からMethodChannelを使用しネイティブ側に show と送ればViewが表示されるようになります。流れとしては以下となります

  1. Unityでボタンを押す
  2. Unityからネイティブにメッセージを送る
  3. ネイティブで受け取ったメッセージをFlutterに送る
  4. Flutter側でメッセージを受け取りメッセージの内容を処理
  5. FlutterからネイティブにView表示のメッセージを送る
  6. ネイティブ側でViewを表示

手順としては多くなってしまっていますが内部的にデータを送り合っているだけなので1度組んでしまえば呼び出すだけで動く状態になります。

UnityからFlutter呼び出し

まとめ

コードが多くなってしまいましたが1つ1つの処理は難しくはないと思うので各処理が何を目的としているのかを意識しながら読んでもらえれば良いと思います。

ここまで4回にわたって連載をしてきましたが今回の内容までで実際にサービスを作るための準備はできたはずです。あとはどのようなデータをやり取りしどのタイミングで処理を行うかというアプリケーション実装の話になってくると思います。

実際に試してもらえるとわかると思いますがこのままではFlutterとUnityを合わせてビルドしなければどちらの実装の動作確認もできないと言う状態になっており、このままではUnityとFlutterの実装->確認サイクルの速さという大きなメリットがなくなってしまうことになります(これはUnity as a Libraryなどを用いる場合でも言えるかと思います⁠⁠。

なので、次回の記事では少しでも確認サイクルを早くするための工夫を紹介し本連載を終えようと思います。

おすすめ記事

記事・ニュース一覧