読者です 読者をやめる 読者になる 読者になる

一部機能だけをcocos2d-xにするには?

android Cocos2d java ネイティブアプリ技術

ご紹介

初めまして。何でも屋さんです。アプリ事業部で主にAndroid開発を担当しています。

簡単に自己紹介をしますとオープン系SIerからスタートしてクラウドサービスの立ち上げおよびモバイル開発を経験してまいりました。Speee入社してからは主にAndroid開発に努めています。より詳しい情報はMEMBERSページをご覧ください。

概要

さて、今回お話する内容はiOSソシャゲをAndroid向けに移植した時のことです。タイトル通りにアプリ一部機能だけをcocos2d-xに実装する必要があったのでそれに関する内容になります。

移植元のアプリは主にWebViewで動作するアプリです。そしてコアな部分としてバトル機能があり、そこだけがcocos2dで実装されていました。簡単に図です。

f:id:amirtta:20160113213545p:plain ところがandroidにはcocos2dをそのまま使えることはできないためcocos2d-xに書き換える必要があります。(移植当時のcocos2d-x 2.1.4はある程度cocos2dインターフェースを継承していましたが、現時点のcocos2d-x 3.0は独自インターフェースになっています。)工数コストは置いといても、問題になるのは構成上の連携方式です。objective-cでそのまま書くcocos2dに対し、cocos2d-xはc++で実装した上でJNI経由で利用することになります。

androidの構成図です。

f:id:amirtta:20160113213558p:plain

もちろんwebview(javascript)とandroid(java)の間でもAndroid Javascript Interfaceを経由しなければなりませんが、今回はcocos2d-xとandroidとの連携だけ紹介します。

ゲームの流れとしてバトルシーンが呼ばれるタイミングはマップ(webview)上の敵と遭遇した時です。webviewのjavascriptがバトル開始とそのパラメータ(json)をandroid(activity)に渡します。そしてactivityはそのパラメータを基にバトルシーンを呼び出します。またバトルが終わったら呼び出し元のactivityを再び呼び出す(もちろん結果パラメータと一緒に)ことになります。その一連の処理が何十回も続けて行います。

ゲームの流れの簡単な図です。

基本的にはマップ上イベント(バトル)が中心になる流れです。そしてこの中で、バトル開始・終了と課金への導線がjavaであるactivityとc++であるcocos2d-xとの連携部分です。

「ただJNIで連携部だけ実装すればいいのでは?」と思う方もいるでしょう。しかし実際にはもっと複雑かつ面倒なことになります。その中で何個かを簡単にまとめてみました。

  • c++側のバトル開始と終了のクラスが異なるためJNI受け口の引数参照仕組みが必要
  • CCDirectorのendメソッドを使うとアプリが完全終了される
  • 先読み(CCSpriteFrameCache)が10倍以上遅い
  • CCLabel系の一部UI部品が化ける

それではどのように対応したのか簡単にお話します。

JNI(Java Native Interface)について

次に進める前にJNIを利用するための準備についてご紹介します。JNIとはjavaからc/c++ APIを利用するためのインタフェース仕様です。javaからネイティブ呼び出しはもちろんネイティブからjavaを呼び出すこともできます。javaの拡張性に深く関係する重要な機能の一つとも言えるでしょう。

もちろんandroidでもJNIを利用できます。しかし基本android SDKでは開発できないためNDKおよびcocos2d-x開発環境構築が必要です。しかし今回の趣旨には合わないため以下の手順に関しては他記事を参考してください。ここでは簡単に手順だけまとめました。

  1. NDKのダウンロードとインストール
  2. cocos2d-x SDKのダウンロードと環境設定
  3. build_native.sh編集
  4. Application.mkおよびAndroid.mk編集
  5. JNIの自動ビルド設定
  6. 生成されたsoファイルを該当Activity内に静的ライブラリとして宣言

cocos2d-xからjavaを呼び出す時の引数問題

バトルはいろんな機能を持っているため数十個のクラスで構成されています。

iOSではcocos2dとネイティブの区別を認識しなくても特に問題ないためどこからもバトル終了や課金処のような連携ができますが、androidではそのような連携処理をJNI経由で行うため開始時で渡された引数を保持する必要があります。

コードで説明します。

void Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeInit(JNIEnv* env, jobject thiz, jint w, jint h, jstring battleJson)
{
   CCEGLView *view = CCEGLView::sharedOpenGLView();
   view->setFrameSize(w, h);
   const char *battleParam = env->GetStringUTFChars(battleJson, NULL);
   // 参照カウンターを増やします。
   jobject globalObj = env->NewGlobalRef(thiz);
   pAppDelegate = new AppDelegate();
   // Applicationに渡します。
   pAppDelegate->init(battleParam, globalObj);
   CCApplication::sharedApplication()->run();
}

第2引数をNewGlobalRefを利用してjavaによるGCで回収されないようにしています。もしNewGlobalRefしなかった場合は後で参照すると下記のエラーになります。

JNI ERROR (app bug): accessed stale local reference 0x3c03012 (index 18 in a table of size 8)

そしてAppDelegateに渡してどのクラスでも参照出来るようにします。このようにJNI受け口の第2引数だけ保持して置けば特に問題はありません。

下記は例として終了するコードです。

void BattleScene::endBattle(CCDictionary* dic)
{
    JniMethodInfo methodInfo;
    JniHelper::getMethodInfo(methodInfo, "org/cocos2dx/lib/Cocos2dxRenderer", "onFinishBattle", "(Ljava/lang/String;)V");
    jstring jobj = methodInfo.env->NewStringUTF(CCJSONConverter::sharedConverter()->strFrom(dic));
    // applicationオブジェクトを取得します。
    AppDelegate *delegate = (AppDelegate*)CCApplication::sharedApplication();
    // envオブジェクトはmethod infoから参照できます。
    methodInfo.env->CallVoidMethod(delegate->getJObject(), methodInfo.methodID, jobj);
    // これをしないと解除されません。
    env->DeleteGlobalRef(globalObj);
    // cocos2d-xを完全終了します。
    CCDirector::sharedDirector()->end();
}

CCDirector終了時の挙動問題

バトルを終了するため先ほどCCDirectorのendメソッドを利用しましたが、バトルだけではなくアプリ全体が完全終了されます。これはcocos2d-xでそのように実装されているためです。そして望ましいのはバトルの使い回しですがアプリ仕様関係上バトルが終わったタイミングでcocos2d-xを完全解除する必要がありました。

それではCCDirectorのendではどのようにアプリを完全終了しているかについて確認しましょう。

// CCEGLView.cpp
void CCEGView::end()
{
    terminateProcessJNI();
}

// Java_org_cocos2dx_lib_Cocos2dxHelper.cpp
void terminateProcessJNI() {
{
    JniMethodInfo t;
        if (JniHelper::getStaticMethodInfo(t, CLASS_NAME, "terminateProcess", "()V")) {
        t.env->CallStaticVoidMethod(t.classID, t.methodID);
        t.env->DeleteLocalRef(t.classID);
    }
}

// Cocos2dxHelper.java
public static void terminateProcess() {
    android.os.Process.killProcess(android.os.Process.myPid());
}

結果的にCCDirectorのendで実行されるのはcocos2d-xのヘルパークラス(android限定)にあるterminateProcessです。そしてこの処理はプロセスをkillすることになるためアプリ全体が終了されます。

とりあえずこの処理だけを削除することでアプリ完全終了は回避できます。ただそれによる影響をなくすためには各々クラスでのメモリ解放処理をしっかり行う必要があります。

先読みの遅さ問題

CCSpriteFrameCacheを利用する時に遅くなる問題です。

iOSでは数十個を先読みしても1秒以内で済みましたが、Androidだと大体15秒から30秒が掛かります。これはandroidのio性能や全体処理速度に関係があります。従って全部読み込むことは避けた方がいいです。ここでは必要なものだけを読み込むように変更して対応しました。

void BattleScene::preloadSpriteAnimation()
{
    CCSpriteFrameCache *cache = CCSpriteFrameCache::sharedSpriteFrameCache();
    cache->addSpriteFramesWithFile("sprite001.plist");
    cache->addSpriteFramesWithFile("sprite002.plist");
    // バトル開始後すぐ必要なものは001と002だけなので003以下は必要な個所で先読みする
    //cache->addSpriteFramesWithFile("sprite003.plist");
    //cache->addSpriteFramesWithFile("sprite004.plist");
    .
    .
    .
}

効果発動タイミングとかにはdelay処理かユーザインプット待ちの所がいるはずです。その隙間を上手く活用すれば全部先読みしなくても短時間ローディングでスムーズに描画できます。

特定挙動時にUIが化ける問題

主にCCLabelTTFが化けて表示される現象です。この問題は課金や広告SDKとの連携でJNI経由を使うと高確率で起こります。私の場合は課金(IAB v3)処理後JNI経由でバトルに復帰するタイミングで起こりました。対応する方法はJNI経由の復帰する処理をglスレッドにすることです。これによりJNI経由でもcocos2d-xオブジェクトが化けて表示されたりする現象はなくなります。

((MainActivity)context).runOnGLThread(new Runnable() {
    Cocos2dxRenderer.nativeEndPurchase(result);
}

最後に

これで一通りandroidの一部機能をcocos2d-xで実装した時に遭遇する課題についてお話しました。しかし気づいている方も多いと思いますが、この構成の最大の課題は紛れもなく実装コスト問題です。

当然ですがJNIを用いることでプログラム構造が複雑になってしまいます。これは製造コストはもちろん維持補修に影響を及ぼします。実際マルウェアは対応されにくくするため極力複雑な構造を採用しますが、その一つの方法としてJNIを採用するケースが増えています。

そしてもう一つはcocos2d-xを拡張することになってしまったので、今後のバージョンアップにコストが発生します。個人の考えに過ぎませんがフレームワークやライブラリを独自で拡張するのは宜しくないことです。特に安定化されたとは言えバージョンアップが進んでいるcocos2d-xだとなおさらです。

皆さんはどこを目指してエンジニアリングをしていますか?私はソフトウェアアーキテクトを目指しています。そのためにもなるべく多くの言語、フレームワーク、システムに関わるよう心がけています。その一番の理由は最大の学習に繋がるのが課題から得られた知識だからです。

未熟な知識から生まれたノウハウですがどうか今回の記事が皆さんのエンジニアリング経験に繋がることを願います。