Help us understand the problem. What is going on with this article?

ネイティブプラグイン開発のススメ (Unity/Cocos2dx)

More than 3 years have passed since last update.

モバイルアプリを開発する上で、ネイティブプラグインの開発が必須になります。
また、これらのネイティブプラグインは OS のバージョンアップに併せてメンテすることも必要です。
しかし、ネイティブプラグインは対応する OS や ゲームエンジンが多いほど、コードが複雑になりメンテナンスコストは決して低くありません。

今回はネイティブプラグインを開発する際に工夫していることをご紹介致します。

0. 前提

対応プラットフォームは iOS, Android とします。
対応ゲームエンジンは Unity, Cocos2d-x とします。

※すべてのサンプルコードは実際のコードとは異なります。

1. まずは Cocos2d-x 用に書く!そして STL は使わない

いきなり STL を使わないとか強烈な制約が出てきましたが、STL を使わない理由は STL のバージョンによりバイナリ互換性がなくなってしまうからです。(ソースコードごと組み込む場合は OK)
大抵のネイティブプラグインは各プラットフォーム特有のデータを取得するだけ等、特に難しいことをやらないことが多いのであまり問題になることはありません。ちょっと凝ったプラグインを作る場合は素直に STL を使って幾つかの STL バージョン用のバイナリを生成してます。

Cocos2d-x は C++ なので、最もシンプルにプラグインの実装が行えます。
また、ここでインターフェイスを考える際に、iOS / Android で同じように利用できることを意識して設計します。

plugin.h
// ネイティブ機能へのC++のインターフェイス
// Cocos2d-x はこれをこのまま使う
namespace Plugin {
    const char* getApplicationCode();
    const char* getVersionString();
    bool openUrl(const char* url);
}

2. Unity 用はラッパーに留める

Unity 用には Cocos2d-x 用に書いた実装にラッパーを作ってあげます。

UnitySupport/Common.h
// このような複数のプラグインで利用する機能はプラグインの
// ソースとは別に管理しビルド時にインクルード/リンクする
static inline const char* allocateString(const char* value) {
    if (!value) return nullptr;
    char* allocated = new char[strlen(value) + 1];
    strcpy(allocated, value);
    return allocated;
}
UnitySupport.cpp
// Unity用のラッパー
// C#のマーシャリングの制約により文字列はヒープに確保しreturnする必要がある
// 確保したメモリはC#側でマネージドなメモリにコピーされたあと開放される
extern "C" {
    const char* _getApplicationCode() {
        return allocateString(Plugin::getApplicationCode());
    }
    const char* _getVersionString() {
        return allocateString(Plugin::getVersionString());
    }
    bool _openUrl(const char* url) {
        return Plugin::openUrl(const char* url);
    }
}
Plugin.cs
// C#用のインターフェイス
// DllImportのEntryPointの指定がうまくいかないようなので、こんな感じの実装になっている
public static class Plugin {
#if UNITY_IPHONE
    const string LIBNAME = "__Internal";
#else
    const string LIBNAME = "Plugin";
#endif
    [DllImport(LIBNAME)] private static extern string _getApplicationCode();
    [DllImport(LIBNAME)] private static extern string _getVersionString();
    [DllImport(LIBNAME)] private static extern bool _openUrl(string url);

    public static string GetApplicationCode() {
        return _getApplicationCode();
    }
    public static string GetVersionString() {
        return _getVersionString();
    }
    public static string OpenUrl(string url) {
        return _openUrl(url);
    }
}

3. JNI は C++ で書く

どうせ、Cocos2d-x 用に C++ で書くのだからそれを使いましょう。上記のように C# のコードもすっきりします。組み込む方も .so ファイルが増えるだけで大した手間ではないでしょう。
Unity の JNI 実装の方が効率的な呼び出しができますが、毎フレーム呼ばれるようなネイティブプラグインでないかぎり誤差程度のパフォーマンスは求められないでしょう。

4. Android の UnitySendMessage は C++ から呼ぶ

Unity のドキュメントでは Java の void UnityPlayer.UnitySendMessage(String, String, String) しか紹介がありません。
JNI を C++ で書き、処理を Cocos2d-x と共有しているので、単純に Unity のメインスレッドに処理を戻そうとすると Unity に到達するまでに遠回りな処理なります。

Java 経由と直接呼び出しの経路比較(イメージ)

  • Java > JNI > C++ > JNI > Java > JNI > Unity (Java 経由)
  • Java > JNI > C++ > Unity (直接呼び出し)

しかし、iOS に提供されている void UnitySendMessage(const char*, const char*, const char*) 相当のものがネイティブコードに存在しないとは思えないので調べてみます。

※コマンドが長くなるので変数に代入してます

% NM=~/android-ndk-r10d/toolchains/arm-linux-androideabi-4.6/prebuilt/darwin-x86_64/bin/arm-linux-androideabi-nm
% LIB=/Applications/Unity_5.2.1p2/Unity.app/Contents/PlaybackEngines/AndroidPlayer/Variations/mono/Development/Libs/armeabi-v7a/libunity.so
% $(NM) $(LIB) | grep UnitySendMessage
004b9e78 T UnitySendMessage   << ありました

ついでに iOS の方もみてみます

% NM=/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/nm
% LIB=/Applications/Unity_5.2.1p2/Unity.app/Contents/PlaybackEngines/iossupport/Trampoline/Libraries/libiPhone-lib.a
% $(NM) $(LIB) | grep UnitySendMessage
00000574 T _UnitySendMessage   << 勿論あります

このことから void UnityPlayer.UnitySendMessage(String, String, String) は下記のようにネイティブコードにバインドされていると推測できます。

static native void UnitySendMessage(String, String, String);

上記の推測通りであれば、C++ 側から JNI を通して Java のメソッドを呼ぶのは無駄なので直接 libunity.soUnitySendMessage を呼んでみましょう。
シグネチャは iOS と同じく下記のように実装してみます。

android.bootstrap.cpp
#include <jni.h>
#include <dlfcn.h>

typedef void (*UnitySendMessagePtr)(const char* objectName, const char* methodName, const char* message);
static UnitySendMessagePtr pUnitySendMessage = nullptr;

extern "C" void UnitySendMessage(const char* gameObjectName, const char* methodName, const char* message) {
    pUnitySendMessage(gameObjectName, methodName, message);
}

extern "C" {
    jint JNI_OnLoad(JavaVM* vm, void *reserved) {
        void* handle = dlopen("libunity.so", RTLD_LAZY);
        pUnitySendMessage = (UnitySendMessagePtr)dlsym(handle, "UnitySendMessage");
        dlclose(handle);

        return JNI_VERSION_1_4;
    }
}

これで、C++ から直接 libunity.soUnitySendMessage を呼ぶことができました。

本当はビルド時にダイナミックリンクの指定をやってあげたかったのですが、古い Android だと正常にライブラリがロードできないようなので dlfcn を利用しています。

Android をビルドする際に、このソースファイルを含めてあげることで void UnitySendMessage(const char*, const char*, const char*) を利用する実装が iOS / Android 共通で書けるようになりました。

5. 共通処理は共通プラグインにまとめる

殆どの Android プラグインは初期化処理としてメインアクティビティを取得しているだけなので、この初期化処理を共通プラグインで行い、各プラグインは共通プラグインを参照することで、各プラグインに初期化処理を実装する必要がなくなり、プラグイン組み込み側は共通プラグインを初期化するだけになります。
各ゲームエンジン毎に異なるメインスレッドの呼び出し処理も、このプラグインで共通化しています。

※このプラグインは Android 専用なので以降は Android の話になります

メインアクティビティの取得

メインアクティビティの取得はJNIで実装しています。Cocos2d-x のメインアクティビティを参照している Cocos2dxHelper.sActivity が protected となっており、Java側から参照することができない為です。

下記のような各ゲームエンジン用に定義した関数をうまく組み合わせてビルドしてあげます。

android.bootstrap.cpp
// unity用のアクティビティ取得関数
extern "C" jobject GenericPlugin_getCurrentActivity(JNIEnv* env) {
    jclass unityPlayerClass = env->FindClass("com/unity3d/player/UnityPlayer");
    jobject nativeActivity = env->GetStaticObjectField(unityPlayerClass,
        env->GetStaticFieldID(unityPlayerClass, "currentActivity", "Landroid/app/Activity;"));
    env->DeleteLocalRef(unityPlayerClass);
    return nativeActivity;
}

// cocos2dx用のアクティビティ取得関数
extern "C" jobject GenericPlugin_getCurrentActivity(JNIEnv* env) {
    jclass cocos2dxHelperClass = env->FindClass("org/cocos2dx/lib/Cocos2dxHelper");
    jobject currentActivity = env->GetStaticObjectField(cocos2dxHelperClass,
        env->GetStaticFieldID(cocos2dxHelperClass, "sActivity", "Landroid/app/Activity;"));
    env->DeleteLocalRef(cocos2dxHelperClass);
    return currentActivity;
}

// メインアクティビティを解決する関数
extern "C" void GenericPlugin_resolveCurrentActivity(JNIEnv* env) {
    jobject activity = GenericPlugin_getCurrentActivity(env);
    jclass cls = env->FindClass("com/example/GenericPlugin");
    jmethodID mid = env->GetStaticMethodID(cls, "setCurrentActivity", "(Landroid/app/Activity;)V");
    env->CallStaticVoidMethod(cls, mid, activity);
    env->DeleteLocalRef(cls);
    env->DeleteLocalRef(activity);
}

GenericPlugin の Java 側の実装は下記のような感じになります

com/example/GenericPlugin.java
public class GenericPlugin {
    protected static Activity currentActivity = null;

    public static void setCurrentActivity(Activity activity) {
        currentActivity = activity;
    }

    public static Activity getCurrentActivity() {
        return currentActivity;
    }
}

これで各プラグインは GenericPlugin.getCurrentActivity() でアクティビティを取得することができます。

Unity / Cocos2d-x それぞれに合わせたメインスレッドの呼び出し

Unity と Cocos2d-x でメインスレッドの呼び出し方法が異なるので、Java のコードも共通化しようとすると困ってしまいます。

各ゲームエンジンのメインスレッドへ返す処理を共通プラグインで吸収してあげることで解決します。
下記のような関数を、それぞれのゲームエンジン用の jar ファイルにビルドしてあげます。

com/example/GameEngineProxy.java
public class GameEngineProxy implements GameEngineProxyInterface {

    // UnityはC++側でメインスレッドへ返すのでそのまま実行する
    public void runOnMainThread(final Runnable runnable) {
        runnable.run();
    }

    // Cocos2dxはGLThreadへ渡す
    public void runOnMainThread(final Runnable runnable) {
        ((Cocos2dxActivity)GenericPlugin.getCurrentActivity()).runOnGLThread(runnable);
    }
}

GenericPlugin の Java のコードに下記の実装を追加します。

com/example/GenericPlugin.java
public class GenericPlugin {
    protected static Activity currentActivity = null;
    protected static GameEngineProxy gameEngineProxy = null;

    public static void setCurrentActivity(Activity activity) {
        currentActivity = activity;
        gameEngineProxy = new GameEngineProxy();
    }

    public static void runOnMainThread(final Runnable runnable) {
        gameEngineProxy.runOnMainThread(runnable);
    }

    // インターフェイス統一のため runOnUiThread も定義しておくと良いかもしれません
    public static void runOnUiThread(final Runnable runnable) {
        currentActivity.runOnUiThread(runnable);
    }
}

各プラグインでメインスレッドへ渡す場合は下記のように書けます。

GenericPlugin.runOnMainThread(new Runnable() {
    @Override
    public void run() {
        // メインスレッドで行いたい処理
    }
});

6. cmake でビルド処理も共通化する

共通処理を共通プラグインにまとめたことで、各プラグインのビルド時には、共通プラグインや Android, Unity, Cocos2d-x の .h .cpp .jar .dll 等、色々なものをインクルードしたり参照したりする必要があり、とても複雑になってしまいました。

多数のプラグインをこの状態で管理するのはとても辛いので、cmake を利用して各プラグインのビルド設定を共通化し、ついでに iOS 用の Xcode プロジェクトや Android 用の Android.mk の代わりに共通の CMakeLists.txt で管理するようにしました。

CMakeLists.txt
cmake_minimum_required(VERSION 2.8)
project(${PROJECT_NAME})

if (ANDROID)
    set(__src_ext cpp)
    set(__src_platform android)
else()
    set(__src_ext *)
    set(__src_platform ios)
endif()

file(GLOB_RECURSE SRC_FILES
    src/common/*.${__src_ext}
    src/platform/${__src_platform}/*.${__src_ext}
)

file(GLOB_RECURSE UNITY_SRC_FILES
    src/unity/${__src_platform}/*.${__src_ext}
    src/unity/src/*.${__src_ext}
)

include_directories(
    ${GENERIC_PLUGIN_PATH}/include
    src/
    src/common/
    src/platform/${__src_platform}/
)

file(GLOB_RECURSE JAVA_SRC_FILES src/platform/android/java/*.java)
list(APPEND CMAKE_JAVA_INCLUDE_PATH "${CMAKE_JAVA_INCLUDE_PATH}"
    ${ANDROID_SDK}/extras/android/support/v4/android-support-v4.jar
)

if (UNITY)
    list(APPEND SRC_FILES ${UNITY_SRC_FILES} ${GENERIC_PLUGIN_SRC_FILES})
endif()

add_compile_options(-std=c++11 -O2 -Wall)
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wno-implicit-function-declaration")
set_objc_arc(${SRC_FILES})

if (JAVA)
    add_jar(${PROJECT_NAME} ${JAVA_SRC_FILES} OUTPUT_NAME lib${PROJECT_NAME})
elseif (ANDROID AND UNITY)
    add_library(${PROJECT_NAME} SHARED ${SRC_FILES})
else()
    add_library(${PROJECT_NAME} STATIC ${SRC_FILES})
endif()

iOS/Android の Toolchain ファイルとして下記のモジュールを利用しています。
iOS: https://code.google.com/p/ios-cmake/
Android: https://github.com/taka-no-me/android-cmake

まとめ

ちょっとやりすぎた(複雑になりすぎた)と思いもするが、同じようなものを至る所でメンテするのもどうかなと思うので難しいところ。
結果的には、各プラグインのメンテは非常に楽になり、cmake 初心者でもうまくメンテできているので良かったかなと思っています。

Takezoh
株式会社よむネコでレンダリングを学ぶプログラマ
gumi
Python、Erlang、Elixir などちょっと変わった技術でゲームをつくったりする会社。プログラマだけじゃなく、企画、デザイン、イラストなど開発全般揃ってます。
http://gu3.co.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした