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

eaglesakuraの技術ブログ

twitterから派生した、技術的にちょっと込み入った話題とか。

Android NDK Android 技術系全般 Google

Android NDKでprotocol buffersを利用する

Protocol Buffersとは

Protocol Buffersは2008年の7月7日=鑑純夏さんの誕生日にGoogleが公開したデータシリアライズ・デシリアライズライブラリです。なお、七夕は雨です。

IDLという独自のテキストフォーマットでデータを記述し、複数プラットフォーム間でデータのシリアライズとデシリアライズが行えます。

利用したいと思ったプラットフォームがAndroidiOSであり、Java/C++が公式に両対応され、かつ2008年公開でネット上に十分な情報が既に公式・非公式問わず大量に公開されているため利用しました。

JSONよりも優れている点

Javaだけで利用する場合はJSONXMLで十分でしたが、C++ではデコードや実際に利用するオブジェクトへのデータバインドがメンドウという欠点が有りました。

Protocol BuffersはIDLで記述したデータフォーマットからJava/C++のクラスを吐き出してくれるため、その点で便利です。

速度的に遅いという情報が多々見受けられますが、データサイズが100kb程度であれば速度やメモリ効率よりもデータの扱いやすさを重視したほうが開発側のコストが減るでしょう。2011年発売のMotorola XoomでもRAMのメモリ帯域は2.6GB/s程度あるようですので、その程度の(かつ読み込み時の一時的な)流量を心配するほど現代のモバイル端末のメモリは遅くないようです。

※Xoomのメモリ帯域はOpenGL Insights参照

protocのインストール

IDLからJava/C++のクラスを出力するには、protocコマンドが必要です。protocコマンドはGoogleから最新版のprotocol buffers一式を落としてきてビルドする必要があります。

cd Protocol Buffersのtarを解凍したディレクトリ
./configure
make
sudo make install

正常に完了すると、protocコマンドとincludeファイル一覧がMacにインストールされます。

データの用意

Protocol Buffersでデータをシリアライズ・デシリアライズするにはまずIDLでファイルを記述しなければなりません。

EclipseにはIDLを記述するためのプラグインが用意されているので、Marketで"Protocol Buffers"あたりで検索をかけるとインストール出来ます。

今回はTexture Packerのライセンスを貰うことが出来たので、JSONで出力されたテクスチャアトラスデータのIDLを記述しました。

IDLファイルの拡張子は"*.proto"を利用するのが通例のようです。

// Primitive.proto

package jc_res;

option java_package = "com.eaglesakura.resource.primitive";
// 四角形
message Rectangle {
    required int32 x = 1000;
    required int32 y = 2000;
    required int32 w = 3000;
    required int32 h = 4000;
}
// アトラス化された画像
message Size {
    required int32 w = 1000;
    required int32 h = 2000;
}

// Texture.proto
package jc_res_texture;

option java_package = "com.eaglesakura.resource.texture";

// テクスチャフォーマット
enum PixelFormat {
    RGBA8888 = 0; //
    RGB888 = 1; //
    RGB565 = 2; //
    RGBA4444 = 3; //
    RGB5551 = 4; //
}

// テクスチャの画像フォーマットを定義する
enum TextureFormat {
    PNG = 0;
    JPEG = 1;
    KTX = 2;
}
// TextureAtlasData.proto
package jc_res_atlas;

import "Primitive.proto";
import "Texture.proto";

option java_package = "com.eaglesakura.resource.texture.atlas";


// ATLAS化された各画像を示す
message AtlasTexture {
    // Origin Filename.
    // 拡張子は取り除かれる
    required string filename = 2000;
    // アトラスグループ内のテクスチャ位置
    required jc_res.Rectangle frame = 3000;
    // テクスチャが縦回転させられていたらtrue
    required bool rotated = 4000;
    // トリミング済みであればtrue
    required bool trimmed = 5000;
    //
    optional jc_res.Rectangle spriteSourceSize = 6000;
    // 元画像サイズ
    optional jc_res.Size sourceSize = 7000;
}

// アトラス化した画像の親グループ
message AtlasGroup {
    // 識別子
    required string uniqueId = 1000;
    // 画像配列
    repeated AtlasTexture images = 2000;
    // 画像ファイル名
    // 拡張子は取り除かれる
    required string filename = 3000;
    // 画像の拡大率
    optional float scale = 4000;
    // 画像読み込み時のピクセルフォーマット
    optional jc_res_texture.PixelFormat importPixelFormat = 5000;
}

IDLには外部ファイルをimport、コメント等の機能があるため、かなり簡単に記述できます。

Messageのデータには「必至/オプション」の設定や配列等の通常必要と思われる要素はひと通り使えます。

GradleでJava/C++用クラスを生成する

ここからが本番で、シリアライズ・デシリアライズ用のクラスを出力します。今回はオフラインツール(java)でデータを用意して、実機(NDK/C++)でデコードするという用途のためJavaC++の両方で生成を行わせます。

この頃Gradleにも興味が出てきたので、せっかくだからGradleにてビルドを行わせています。

// build.gradle

task buildIDL {
    doLast {
        // IDL出力先生成
        File cppOutDir = new File("./gen-cpp").getAbsoluteFile();
        cppOutDir.mkdirs();

        File javaOutDir = new File("./gen-java").getAbsoluteFile();
        javaOutDir.mkdirs();

        File srcDir = new File("./idl/").getAbsoluteFile();

        for(File proto : srcDir.listFiles()) {
            if(proto.getName().endsWith(".proto")) {
                // IDL File
                // build
                println("compile :: " + proto.getName());
                ant.exec(executable : 'protoc', dir : ".", output : "protoc.log") {
                    arg(value:String.format("--proto_path=%s", proto.parentFile.absolutePath));
                    arg(value:"--cpp_out");
                    arg(value:cppOutDir.absolutePath);
                    arg(value:"--java_out");
                    arg(value:javaOutDir.absolutePath);
                    arg(value:proto.absolutePath);
                }
            }
        }
    }
}

これで ./idl ディレクトリ配下の*.protoファイルを全てビルドし、gen-cppとgen-java配下にクラスを生成させています。

gradle buildIDL

Javaで利用する

JavaのプロジェクトでProtocol buffersで生成したクラスを利用するにはProtocol Buffersの開発者向けJarを生成しなければなりません。

Jarファイルを生成するにはMaven2が必要です(maven3では動きませんでした)。ただし、私の環境ではメモリが不足してビルドが失敗することがあったので、mvn2 install前にVMの使用メモリを変更しています。

ビルドには"..src/protoc"が必要(おそらくtestの実行に)ですので、最低でもprotocコマンドのmakeまでは完了させなければならないようです。

cd Protocol Buffersのtarを解凍したディレクトリ
cd java

# メモリ利用量を上げる
export MAVEN_OPTS=-Xmx2048M

# Maven2を利用してビルド
mvn2 install

実行すると、protobuf-java-2.5.0.jar(私の場合)が生成されるので、あとはそれをJavaのプロジェクトに加えれば利用が行えます。

arm向けの静的ライブラリを生成する

今回最も頭を悩ませたのが、NDK向けのprotocl buffersのビルドです。ソースレベルで配布されていますが、configure等を通さないとビルドが行えないため以前導入しようと思った時はここでコケてメンドウになって導入を見送っています。

今回はまじめにいろいろググりながら利用できるようにしました。

configureを通すためには環境変数にいろいろ準備をしないといけないようなので、次のようなスクリプトを書いて実行しています。Android 4.0以上で使えればよかったのでandroid-14で通していますが、パスを変更すれば多分他のバージョンでもいけます(未検証)。

#! /bin/sh
# ndk-configure.sh

PREBUILT=${ANDROID_NDK_HOME}/toolchains//arm-linux-androideabi-4.8
PLATFORM=${ANDROID_NDK_HOME}/platforms/android-14/arch-arm

export CC=${PREBUILT}/prebuilt/darwin-x86_64/bin/arm-linux-androideabi-gcc
export CFLAGS=-'fPIC -DANDROID -nostdlib'
export LDFLAGS="-Wl,-rpath-link=${PLATFORM}/usr/lib/ -L${PLATFORM}/usr/lib/"

export CPPFLAGS=-I${PLATFORM}/usr/include

export LIBS=-lc 
./configure --host=arm-eabi

ndk-configureを実行すると、Android NDK用のconfig.hファイルが生成されます。

次にprotocol buffersを解答したディレクトリをjniにリネームして、次のAndroid.mkとApplication.mkを配置します。無駄なものが含まれているかもしれませんが、おそらくリンク時に排除してくれるので多分問題ありません。

# Application.mk
APP_STL := gnustl_static
APP_ABI := armeabi-v7a armeabi
APP_MODULE := libprotobuf
# Copyright (C) 2009 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
#

LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)

COMPILER_SRC_FILES :=  \
src/google/protobuf/descriptor.cc \
src/google/protobuf/descriptor.pb.cc \
src/google/protobuf/descriptor_database.cc \
src/google/protobuf/dynamic_message.cc \
src/google/protobuf/extension_set.cc \
src/google/protobuf/extension_set_heavy.cc \
src/google/protobuf/generated_message_reflection.cc \
src/google/protobuf/generated_message_util.cc \
src/google/protobuf/message.cc \
src/google/protobuf/message_lite.cc \
src/google/protobuf/reflection_ops.cc \
src/google/protobuf/repeated_field.cc \
src/google/protobuf/service.cc \
src/google/protobuf/text_format.cc \
src/google/protobuf/unknown_field_set.cc \
src/google/protobuf/wire_format.cc \
src/google/protobuf/wire_format_lite.cc \
src/google/protobuf/io/coded_stream.cc \
src/google/protobuf/io/gzip_stream.cc \
src/google/protobuf/io/printer.cc \
src/google/protobuf/io/tokenizer.cc \
src/google/protobuf/io/zero_copy_stream.cc \
src/google/protobuf/io/zero_copy_stream_impl.cc \
src/google/protobuf/io/zero_copy_stream_impl_lite.cc \
src/google/protobuf/stubs/common.cc \
src/google/protobuf/stubs/once.cc \
src/google/protobuf/stubs/structurally_valid.cc \
src/google/protobuf/stubs/strutil.cc \
src/google/protobuf/stubs/stringprintf.cc \
src/google/protobuf/stubs/substitute.cc

# C++ full library
# =======================================================
#include $(CLEAR_VARS)

LOCAL_MODULE := libprotobuf
LOCAL_MODULE_TAGS := optional

LOCAL_CPP_EXTENSION := .cc

LOCAL_SRC_FILES := $(COMPILER_SRC_FILES) 

LOCAL_C_INCLUDES := $(LOCAL_PATH)/src

LOCAL_C_INCLUDES := \
$(LOCAL_PATH)/android \
bionic \
$(LOCAL_PATH)/src \
$(JNI_H_INCLUDE)

# LOCAL_SHARED_LIBRARIES := libz libcutils libutils
# LOCAL_LDLIBS := -lz

# stlport conflicts with the host stl library
#ifneq ($(TARGET_SIMULATOR),true)
#LOCAL_C_INCLUDES += external/stlport/stlport
#LOCAL_SHARED_LIBRARIES += libstlport
#endif

# Define the header files to be copied
#LOCAL_COPY_HEADERS := \
#    src/google/protobuf/stubs/once.h \
#    src/google/protobuf/stubs/common.h \
#    src/google/protobuf/io/coded_stream.h \
#    src/google/protobuf/generated_message_util.h \
#    src/google/protobuf/repeated_field.h \
#    src/google/protobuf/extension_set.h \
#    src/google/protobuf/wire_format_lite_inl.h
#
#LOCAL_COPY_HEADERS_TO := $(LOCAL_MODULE)
# RTTIを有効
LOCAL_CPPFLAGS   += -frtti
LOCAL_CFLAGS   += -frtti
# LOCAL_CFLAGS := -DGOOGLE_PROTOBUF_NO_RTTI

include $(BUILD_STATIC_LIBRARY)

私の場合はdynamic_cast利用のため実行時型情報が欲しかったので、-frttiを有効にしています。ビルドはNDKのためndk-buildコマンドを利用します。

ビルドするにはAndroidプロジェクトと同等の構成にしないとエラーで弾かれるので、protobuf-2.5.0(最新版をDLした場合)をjniにリネームし、適当なAndroidManifest.xmlを配置すればndk-buildが行えます。

ビルドを行うとobj/local/armeabiにlibprotobuf.aが生成されています。

アプリ用のAndroid.mkで利用する

事前に生成した静的ライブラリは最終的にはアプリ用の*.soにリンクしなければなりません。アプリ側のAndroid.mkで次のように記述し、リンクを行っています。

## Import Protocolbuffer
include $(CLEAR_VARS)
LOCAL_MODULE := protobuf
LOCAL_SRC_FILES := 相対パス/libprotobuf.a
include $(PREBUILT_STATIC_LIBRARY)

#################################################################################
include $(CLEAR_VARS)

## アプリのビルド設定...

## libprotobuf.aをリンクする
LOCAL_STATIC_LIBRARIES += protobuf

## *.soをビルドする
include $(BUILD_SHARED_LIBRARY)

アプリ内で*.pb形式のデシリアライズを行う

オフラインツールで出力したProtocol Buffers形式のファイル(*.pbファイル)はNDKだと次のように記述することでデシリアライズを行えます。

今回は"TextureAtlasData.proto"から生成したAtlasGroupクラスを利用しています。

newでクラスを生成して、生成されるAtlasGroup::MergeFromCodedStream()メソッドを呼び出すことでbyte配列からデシリアライズしています。

#include    "TextureAtlasData.pb.h"

中略...


void Hoge::onAppInitialize() {

    // deserialize
    {
        MFileMapper file = Platform::getFileSystem()->loadFile(Uri::fromAssets("atlas.pb"), NULL);
        jc_sp group(new jc_res_atlas::AtlasGroup());


        google::protobuf::io::CodedInputStream is( (uint8_t*)file->getHead(), (int)file->length() );
        
        jclogf("created image size(%d)", group->images_size());

        group->MergeFromCodedStream(&is);

        jclogf("loaded image size(%d)", group->images_size());
        jclogf("file name(%s)", group->filename().c_str());
    }
}

まとめ

前準備が非常にメンドウですが、一度ビルドしてしまえば後は*.protoを書いてクラス生成 -> includeするだけでJava/C++両対応のファイルが生成できるため、今後は非常にデータ形式の生成が楽になりそうです。

makefile等はgithubでも公開しています。 https://github.com/eaglesakura/jointcoding/tree/develop/apps/protocolbuffers

大量の情報を書き留めてくれていたネット上の先人たちに感謝します。

OpenGL Insights 日本語版 (54名のエンジニアが明かす最先端グラフィックス プログラミング)

OpenGL Insights 日本語版 (54名のエンジニアが明かす最先端グラフィックス プログラミング)

マブラヴ ツインパック figma 鑑純夏 1体 同梱

マブラヴ ツインパック figma 鑑純夏 1体 同梱

複数スレッドでGLの処理が可能なGLSurfaceView作りました

OpenGL Android

複数スレッドでGLの処理が可能なGLSurfaceView作りました

何が出来るのか

GLSurfaceViewを継承したクラスです。動作には互換性があり、GLSurfaceViewをMultiContextGLSurfaceViewに切り替えるだけで使えます。

GLSurfaceViewとの違いは、標準で複数スレッドでのOpenGL ESコマンドの利用を可能にしている点です。GLSurfaceViewでテクスチャ等のリソースを非同期で読み込もうと思っても、最終的には GLSurfaceView#queueEvent にキューイングして、描画スレッドを止める必要があります。

GLSurfaceViewは後述のMaster & Slaveの仕組みを利用して、描画スレッドを止めることなく画像ロードを実現しています。

なぜ作ったか

今書いているOpenGL ES 2.0解説本で、付録としてGLSurfaceViewの拡張性の説明をしようと思って、その実証のため作りました。

どうやって実現しているのか

GLSurfaceViewは基本設定がアレで微妙に使いにくいです。(今は違うかもしれないけど)onPauseでGLに関するリソースをすべて解放するため、onPause/onResumeでリソースの再読み込みが必要になる場合があります。その代わり拡張性については非常に優れていて、特にConfigやContextの拡張して外部(開発者)に任せることが出来ます。

今回利用したのは、EGLContextを拡張するためのGLSurfaceView#EGLContextFactoryの仕組みです。

EGLContextFactoryとは

EGLContextFactoryはその名の通り、EGLContextの生成を行わせるクラスです。標準ではeglCreateContextを呼び出すだけですが、今回はMulti-Contextになるように仕掛けを施しています。

そもそもOpenGL ESで指定された値はどこに格納されるのか

例えばglClearColor等で指定された画面クリア用の色はどこに格納されるのでしょうか? GPU全体で1つの設定しか保持できないとしたら、複数のアプリで同時にGLを使うことが出来ませんし、Androidのシステム自体が描画できなくなってしまいます。

OpenGL ESの値が保存される領域がEGLContextです。GLのコマンドが呼び出されると、「そのThreadに紐付けられているEGLContextに対して暗黙的に」操作が行われます。この仕組のおかげで、複数箇所からGLのコマンドが呼び出されても問題なく処理が行われます。

テクスチャ等のメモリもEGLContextが包括して持っています。つまり、EGLContextはRAMとVRAMに跨る様々な情報を保持してくれています。

EGLContextは1プロセス内で(メモリが続く限り)いくつでも生成できます。また、あるEGLContextに対する操作(glClearColor等)を行ったとしても、別なEGLContextへは一切影響を及ぼしません。

EGLContextの制限

OpenGL ESの制限として、1つのThreadに対して1つのEGLContextしか指定できません。また、1つのEGLContextは1つのThreadに対してしかバインド出来ません。つまり、何処かのThreadで使われているEGLContextは別スレッドで利用することが出来ないということです。

そのため、EGLContextをアンバインドせずにThreadが終了してしまった場合、そのEGLContextは永遠に操作できないリーク状態になってしまいます。

さらにEGLContext同士のメモリは基本的に切り離されており、あるEGLContextで読み込んだテクスチャは別なEGLContextでは利用することが出来ません。

GLSurfaceViewはRendererで描画を行わせるためのEGLContextをEGLContextFactoryクラスによって生成させています。リークを避けるためかと思いますが、作成したEGLContextは外部からアクセスできず、勝手にバインド/アンバインド/解放されます。

shared_contextによる制限緩和

結論として、表題のようにOpenGL ESコマンドを複数Threadから呼ぶためにはEGLContextを複数生成し、利用したいThreadに対してバインドすればいいことになります。

但しEGLContext同士はそのままでは独立状態にあるため、仮にRendererが暗黙的に利用しているEGLContextと別なEGLContextを作っただけでは、例えばテクスチャ等の読み込んだデータを共有できません。

EGLContext同士の一部の領域(具体的にはVRAMに相当する部分)を共有するのが、shared_contextの仕組みです。

EGLContextを生成するeglCreateContextコマンドの第3引数に、shared_contextという値があります。それに適当なEGLContextを指定すると、テクスチャ等のVRAM領域を共有することが出来ます。

MultiContextGLSurfaceViewではshared_contextを設定したEGLContextを生成することで、レンダリングスレッドとワーカースレッドの並列性を保ちながらGLのコマンド呼び出しを行えるようにしています。

shared_contextが共有できるリソースは下記です。ちなみにEGL仕様ではBuffer&Textureと記述されています。

  1. テクスチャオブジェクト
  2. レンダリングバッファ(フレームバッファ
  3. VertexBuffer / IndexBuffer
  4. シェーダー&プログラムオブジェクト

Master&Slave方式

MultiContextGLSurfaceViewでは、便宜上EGLContextにMaster ContextとSlave Contextという名前をつけています。

  • Master Context
    • どのThreadにもバインドされず、shared_contextに指定するだけのEGLContext
    • Slave Contextの生成数・廃棄数を数え、Slaveの生存数が0になったら廃棄される
  • Slave Context
    • レンダリングスレッドやワーカースレッドに対してバインドされるEGLContext
    • 同一のMaster Contextから派生しているため、Slave Context同士もshared状態にある

サンプルとなるMainActivityは次のような順番でContextが生成・廃棄されます。

  1. Master Contextが生成される
  2. GLSurfaceViewがEGLContextFactoryに対してEGLContextの生成をリクエストする2.
  3. レンダリング用のSlave Contextを生成する
  4. onSurfaceCreatedが呼び出される
  5. ワーカースレッドが起動し、Slave Contextを生成する
  6. onSurfaceChangedが呼び出される
  7. onDrawFrameで描画を継続する(レンダリングスレッドは停止しない)
  8. ワーカースレッドでテクスチャの読み込みを完了する
  9. ワーカースレッド用のSlave Contextが廃棄される
  10. (アプリを終了させる)
  11. レンダリングスレッド用のSlave Contextが廃棄される
  12. Slave Context数が0になったので、Master Contextが廃棄される

その他:

  • Slave ContextをActivityが保持しておけば、onPause/onResumeによるレンダリングスレッド用のSlave Context廃棄(と、それに伴うMaster Context廃棄)を避けてリソースを保持し続けることが出来ます。サンプル用のMainActivityでonPause/onResumeでテクスチャ解放されていないのが確認できます。

ダミーサーフェイスの生成

EGLContextをバインドする=eglMakeCurrentを呼び出すためには、第2・第3引数に描画対象となるEGLSurfaceが必要です。

EGL_NO_SURFACEを指定することも出来ますが、基本的に呼び出しに失敗します。eglMakeCurrentはレンダリング対象となるEGLSurfaceの指定を義務付けています。(iOSではEGLContext相当となるEAGLContextだけでバインドができるので、その点で楽ですね)

MultiContextGLSurfaceViewでは、ワーカースレッドでeglMakeCurrentを呼び出すために、1x1ピクセルの極小のPBufferSurfaceを生成しています。PBufferSurfaceはウィンドウ(View)に紐付かないレンダリングターゲットです。

MultiContextGLSurfaceView#requestAsyncGLEventの処理

以上の情報を元にrequestAsyncGLEventの実装を見ると、なんとなくこのクラスがやっていることがわかるのでは無いでしょうか。

コールバック終了後、eglMakeCurrent直前にglFinishを呼び出しているのは、一部端末でワーカースレッドがフリーズする不具合を回避するためです。

    /**
     * GL動作を非同期で行う
     * Textureの読み込み等を想定
     * @param event
     */
    public void requestAsyncGLEvent(final Runnable event) {
        Thread thread = (new Thread() {
            @Override
            public void run() {
                // initialize EGL async device.
                final EGLDisplay display = mEGLDisplay;
                final EGLContext context = newSlaveContext();
                final EGLSurface surface = newDummySurface();

                try {
                    mEGL.eglMakeCurrent(display, surface, surface, context);

                    // call event
                    event.run();
                } finally {
                    GLES10.glFinish();
                    mEGL.eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);

                    destroySlaveContext(context);
                    destroyDummySurface(surface);
                }
            }
        });

        thread.setName("GL-Background");
        thread.start();
    }

問題点

古い端末(概ね2010年前後に発売された端末。Nexus Oneとか。)ではワーカースレッドで読み込んだリソースが正常に利用できない場合があります。原因不明なので誰か原因(もしくはEGLの使い方がおかしい?)が分かる人おしえて下しあ。

以上の説明は非常に不親切なため、書籍ではちゃんと図説付きでなるべく親切にまとめます。

Mac OSでPerfHUD ESを利用してNexus7のOpenGL ES負荷を測定する

OpenGL Android

Mac OSでPerfHUD ESを利用してNexus7のOpenGL ES負荷を測定する

いろいろあってヨドバシポイントを2万円分手に入れたので、検証機としてNexus7を購入しました。Nexus7はTegraを搭載していて、便利なツールがMacでも使えるので、そのメモです。

標準ツールでのOpenGL ESデバッグ

現在のAndroid SDKやADTはOpenGL ES用のトレーサーを提供しています。ですが、トレーサーにはいくつかの問題点があり、実際のアプリ開発では有効活用出来ない場合があります。

例えば、次のような問題点があります。

  1. 起動時にしか有効にできない
    • アプリデバッグ中にトレーサーを起動してデバッグを開始するという使い方ができません
    • そのため、アプリ開始時のロードや不要なシーンをスキップする等の作業が行いにくくなります
      • トレーサーをONにすると、OpenGL ES処理に数十倍の時間がかかってしまうためかなりメンドウです
  2. リアルタイムのトレースが出来ない
    • 基本的に呼出履歴の保存とフレームバッファの保存のみが行えるため、リアルタイム性が無い
    • 内容はトレース停止後でしか確認できない

PerfHUD ESとは

PerfHUD ESNVIDIA社が提供している、Tegra系列GPU専用のプロファイリングツールです。

端末のroot不要であり、adb経由でリアルタイムにCPU/GPUの処理内容をトレースすることができます。

また、アプリ起動中からのアタッチ・デタッチも自由に行えるため、標準ツール以上の自由度があります。

Nexus7はNVIDIA社のTegra3を搭載していますので、PerfHUD ESを利用することができます。(ちなみに、ESがつかないほうのPerfHUDはデスクトップ用GPU向けのようです)

ツール自体はNVIDIAのDeveloper登録が必要になりますが、無料で手に入れることができます。

接続方法

Windows版はUSBのWifi接続が可能ですが、Mac版はWifi接続のadb限定のようです。 また、アプリ側に次の準備が必要になります。

その状態でPerfHUD ES付属の"enable_perfhud.sh"スクリプトをターミナルから実行すると、Nexus7側の設定がONになります。enable_perfhud.shは端末再起動ごとに無効化されますので、毎度実行する必要があります。

その状態でデバッグ対象のアプリを実行します。

f:id:eaglesakura:20130511144149j:plain

以上の状態でPerfHUD ESを起動し、右下の"Not Connected"ボタンをクリックするとIPアドレスの入力画面になります。
端末のIPアドレスを入力し、Refreshボタンを押すとスキャンが始まりますのでしばらく待ちます。

f:id:eaglesakura:20130511144141j:plain

該当のPIDが出現したら、選択してconnectします。

するとCPU負荷やGPU処理速度、フレームレート等が取得できるようになります。

PerformanceDashbord

デフォルトウィンドウであるPerformanceDashbordはリアルタイムの負荷を計測するのに役立ちます。

Tegra3はクアッドコアなのでCPU状態が4つ、GPU情報としてドライバの呼び出し時間や合計描画時間等、かなり有用な情報です。

フレームレートについてはeglSwapBuffer〜eglSwapBufferまでの時間を計測しているかと思います。

f:id:eaglesakura:20130511144228j:plain

FrameDebugger

もう一つのウィンドウであるFrameDebuggerに切り替えると、フレームごとのレンダリング情報を見ることができます。

FrameDebugger起動中はレンダリングが停止するため、「現在の描画フレームの状態」を得るのに役立ちます。

  • Frame Overview

    • 現在のフレーム各処理計測
  • Frame Scrubber

    • 各描画命令ごとのバッファ内容の確認ができます。
    • カラーバッファ、カラーバッファのα値、深度バッファ、ステンシルバッファそれぞれのプレビューも可能です。
    • 巻き戻しや、描画命令毎にどのピクセルがレンダリングされたのかを知ることもできます

f:id:eaglesakura:20130511144509p:plain

  • Call Trace
    • OpenGL ES命令の呼出履歴や戻り値の確認ができます

f:id:eaglesakura:20130511144537p:plain

  • Geometry Viewer

    • 実際にVBO/IBOの内容を読み取り、3D表示してくれます
    • バッファの内容が実際にどうなっているのかを知ることができます
  • State Viewer

    • 描画命令ごとにEGLContextの内容を知ることができます
    • 想定と違うStateが設定されている等のチェックを行えます
  • Texture Viewer

    • 読み込み済みのテクスチャ内容を表示してくれます
    • BGRA表示には非対応で、BGRAテクスチャは強制的にRGBA表示されるようです
    • mipmapを生成している場合、mipmapも表示します

f:id:eaglesakura:20130511144606p:plain

  • Shader Viewer
    • コンパイルされたシェーダーのuniform/attribute内容を確認できます
    • シェーダーのソースコードも表示され、ソースコードの修正・コンパイルチェック・修正後のUniform内容チェック・ソースコード保存も行えます
      • ただし、現状だとコンパイルしたシェーダーを端末に書き戻すことは出来ないようです
        • 自力で読み直せってことですね
        • その辺はアプリ側の工夫次第でどうにか、というレベル
    • uniform textureはテクスチャのプレビューも行なってくれます

f:id:eaglesakura:20130511144618p:plain

TextureViewでの問題点

TextureViewはAndroid 4.0から登場したハードウェアアクセラレーション対応のViewです。以前記事に書いたカメラの他、OpenGL ES等の処理が行えます。

もちろんこの状態でもPerfHUD ESは利用できますが、いくつかの問題点があるようです。

  • TextureViewでPerfHUDを接続すると、レンダリングが画面に反映されなくなります
    • プロファイリングは継続されますが、PerfHUD ES自体の動作も不安定になります
  • レンダリング自体の速度が非常に遅くなります

まとめ

調べてみるとかなり便利なツールです。ある程度不安定な動作を差し置いても、開発ではとても役立つと思います。

個人的にはTextureViewでの動作をどうにかして欲しいので、USB接続に対応してくれると非常にありがたいのですが・・・。

初めてのOpenGL ES

初めてのOpenGL ES

Android NDKで"Unknown Application ABI:" エラーが発生した場合の対症療法

Android NDK Android

Android NDKのデバッグは容易になった・・・はずなのに

Androidアプリは基本的にSDKを利用し、Java言語で記述します。ですが、Android NDKを利用することでC/C++言語でもアプリを記述できるのは周知のとおりです。

最初は「できるやつだけついてこい」的にほとんど整備されていなかったNDKの開発環境ですが、最近ではEclipseとの連携が可能になり、更にはコマンドラインベースだったデバッグEclipseを使ってGUI上でできるようになっています。

  1. Add Native Supportを設定する f:id:eaglesakura:20130317220540j:plain
  2. Debug As -> Native Application f:id:eaglesakura:20130317220640j:plain
  3. エディタ上でブレークポイントを設定 f:id:eaglesakura:20130317220728p:plain
  4. あとはステップ実行なり変数のウォッチなりが簡単にできる f:id:eaglesakura:20130317220755p:plain

そのおかげで、あんどろいどたん(Google Play)を始めとして最近の案件はAndroid NDKを多く利用していたわけですが、唐突に次のようなコンソールに出力されるようになり、デバッガが接続できなくなりました。

[2013-03-17 21:36:50 - アプリ名] Unknown Application ABI: 
[2013-03-17 21:36:50 - アプリ名] 
[2013-03-17 21:36:50 - アプリ名] Unable to detect application ABI's

対症療法

しばらくの間は「アルェ〜、ナンデナンデ!?」状態で仕方なくprintfデバッグをしてたわけですが、次の条件でデバッガが接続できることがわかりました。

無駄だったこと

  • 端末再起動
  • ndk-build -clean
  • キャッシュ的なファイルを全て削除
  • Eclipseを通常再起動
  • Eclipseの再インストール(再設定)
  • Eclipseの英語版を利用

効果的だったこと

  • eclipse -clean で起動する
    • ただしclean起動後も、通常起動したらまたデバッグできなくなる
    • つまりはデバッグしたくなる度にclean起動が必要

ですが、boost等の重いライブラリを利用していると、clean起動する度にインデクサーがガリガリと幾千のファイルをチェックしてしまうため、ストレスがマッハです。

デバッガが接続できるアプリ・できないアプリがあった

つい最近わかったことは、同一のEclipse環境・端末環境でもデバッグできるあぷり・できないアプリがあるということです。
チェックしてみると、比較的規模の大きな案件でビルドしたアプリは接続できず、小さいアプリは接続できるということでした。

原因

いろいろ調べた結果、(今のところ)理由は次の一点でした。

  • Android.mk/Application.mkで自分で作ったファイル(共通のmkファイル等)をincludeするとデバッガが接続できない
  • どちらか(もしくは両方)にincludeがあるとエラーが発生する
  • include先に書いてある内容をコピペして、includeしないようにすればおk
# ダメなApplication.mk

APP_MODULES := sample
include shared_application.mk

------
# shared_application.mkの中身

APP_STL:=gnustl_static
APP_ABI := armeabi-v7a
# 問題ないApplication.mk

APP_MODULES := sample
# コピペして持ってくれば大丈夫
APP_STL:=gnustl_static
APP_ABI := armeabi-v7a

反省

「本来includeは使わねーよwwww」とか、私が知らないだけでmakefileのルールに則ってないのかもしれませんが、共通部分をどっかにまとめとこう的なことはしばらく考えないほうがよさそうです。

Galaxy NexusでETC1テクスチャを利用する場合の注意

OpenGL Android

ETC1とGalaxy Nexus

AndroidOpenGL ES実装はETC1をサポートするようになっています。

ETC1テクスチャは4x4のピクセルブロックを基本とした圧縮を行うため、利用するには次のような制限があります。

  • 縦横のピクセル数が4の倍数であること
    • そのため、Android SDK付属のetc1toolを利用すると4で割り切れるように画像末尾に詰め物を入れられる
  • GL_OES_compressed_ETC1_RGB8_textureエクステンション対応のGPUであること

この制限に従っていれば、Power Of Two(2のn乗)である必要はありません。

Galaxy NexusのGPUはスペック上はnpot(Non Power Of Two = 2のn乗以外)テクスチャも扱えますし、ETC1もサポートしています。

f:id:eaglesakura:20130317155132j:plain

f:id:eaglesakura:20130317155228j:plain

当然、次のコマンドも正常に行えますし、assertも通過します。

glCompressedTexImage2D(GL_TEXTURE_2D, 0, GL_ETC1_RGB8_OES, width, height, 0, image_bytes, image);
assert(glGetError() == GL_NO_ERROR);

その状態でテクスチャをbindしてレンダリングすると、問答無用でアプリが落ちます。OpenGLは基本的にエラーが発生したらエラーをセットするだけのため、異常な動作だと思われます。

解決方法としては「npotのETC1を使わない」という点ですが、でっかい背景画像とかnpotのETC1を使いたい場所はそこそこあるので、他の端末で同様の不具合が起きないことを祈るのみです。

その他

回避方法、もしくは「ここが間違ってる」という指摘があったらコメントお願いしますm(__)m

初めてのOpenGL ES

初めてのOpenGL ES

SurfaceTextureでMediaPlayerやカメラ映像をOpenGLテクスチャとして使う場合の注意点

OpenGL Android

SurfaceTextureによるテクスチャへのマルチメディアレンダリング

Android 3.xからSurfaceTextureクラスが導入されて、OpenGLとカメラ・ビデオ等のマルチメディア連携が楽になりました。

具体的には、テクスチャとしてビデオやカメラの映像を取り込めるようになり、リアルタイムでシェーダーによる映像加工したり揺らしたりができるようになりました。

AndroidのコードをみてみるとVideoSurfaceView.javaというクラスでその実装を行なっているらしいですが、その他の情報が殆ど無いので実際に使ってみた結果のハマリどころメモです。

前提として、OpenGL ES 2.0を利用しています。

AndroidのSurfaceTextureは2Dテクスチャではない

正確には、GL_TEXTURE_2D定数を使ってはいけません。GL_TEXTURE_2Dは殆どの場合は「テクスチャを使う場合のおまじない」のような形でglTexXXX関数で使っていると思いますが、それを使うとドハマリします。

SurfaceTextureではGL_TEXTURE_2Dを使わず、GL_TEXTURE_EXTERNAL_OES定数を使います。もしテクスチャ系のUtilクラスを自作している場合、GL_TEXTURE_EXTERNAL_OES定数を使えるように加工しなければなりません。

例えば、私の場合は次のように変更しました。
glTexParameteriに与える引数が、定数直書きからメソッドの引数を利用するように変更になっています。

変更前テクスチャ管理クラス抜粋

    texture.alloc(device->getVRAM(), VRAM_Texture);

    {
        s32 index = state->getFreeTextureUnitIndex();
        this->bind(index);
        {
            // npotでは GL_CLAMP_TO_EDGE を指定する必要がある
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
            context.wrapS = GL_CLAMP_TO_EDGE;
            context.wrapT = GL_CLAMP_TO_EDGE;

            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
            context.magFilter = GL_NEAREST;
            context.minFilter = GL_NEAREST;
        }
        this->unbind();
    }
変更後テクスチャ管理クラス抜粋

    texture.alloc(device->getVRAM(), VRAM_Texture);

    {
        s32 index = state->getFreeTextureUnitIndex();
        this->bind(index);
        {
            // npotでは GL_CLAMP_TO_EDGE を指定する必要がある
            glTexParameteri(target, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
            glTexParameteri(target, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
            context.wrapS = GL_CLAMP_TO_EDGE;
            context.wrapT = GL_CLAMP_TO_EDGE;

            glTexParameteri(target, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
            glTexParameteri(target, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
            context.magFilter = GL_NEAREST;
            context.minFilter = GL_NEAREST;
        }
        this->unbind();
    }

テクスチャ用のメモリ確保はSurfaceTextureが行う

通常のテクスチャの場合glTexImage2D(GL_TEXTURE_2D, XXX)関数を使ってVRAMへテクスチャのピクセルをコピーしますが、SurfaceTextureではそれを行なってはいけません。

例えば、間違ってglTexImage2D(GL_TEXTURE_EXTERNAL_OES, 0, GL_RGB, ビデオ幅, ビデオ高さ, 0, GL_RGB, GL_RGBA, 0)のように確保しようとすると例外が投げられるかエラーログが吐かれるはずです。

glGenTextures()で取得したテクスチャ名(ID)は、glTexParameterで設定後はメモリを確保せずにSurfaceTextureへ渡します。

SurfaceTextureはビデオをレンダリングするために十分なテクスチャ領域を自動的に確保します。

この時の制限として、ビデオの縦横サイズはGPUの性能限界(GL_MAX_TEXTURE_SIZE)を超えてはいけません。
ただし、現在のGPU性能だと2048x2048〜4096x4096程度の確保は行えるので、フルHDレベルであれば問題ありません。

確保されるタイミングはSurfaceTextureに渡したタイミングかも知れませんし、SurfaceTexture#updateTexImage()初回実行時かもしれませんが、開発者自身で確保してはいけません。

シェーダーではsampler2Dは使えない

GL_TEXTURE_2Dではないため、シェーダーではsampler2Dによるテクスチャアクセスはできません。
そのため、sampler2D変数の代わりにsamplerExternalOES変数を定義する必要があります。

使い方はsampler2D変数と変わりません。

内部的には、GL_OES_EGL_image_external機能を利用しますので、シェーダーのextension要求として次が必要です。定義しない場合、コンパイルに失敗するかアプリが強制終了する等の問題が発生します(GNで確認)

#extension GL_OES_EGL_image_external : require
フラグメントシェーダー全文

static const charactor *FRAGMENT_EXTERNAL_SHADER_SOURCE = 
        "#extension GL_OES_EGL_image_external : require\n"
        "        precision mediump float;"
        // UV setting"
        "        varying vec2 fTexCoord;"
        // texture
        "        uniform samplerExternalOES tex;"
        // color
        "        uniform mediump vec4    blendColor;"
        "        void main() {"
        "            vec4 color = texture2D(tex, fTexCoord) * blendColor;"
        "            color.a *= blendColor.a;"
        "            gl_FragColor = color;"
        "        }"
//

レンダリング領域の設定にはクセがある

ビデオの再生を開始すると、定期的にSurfaceTexture#OnFrameAvailableListenerを通じてコールバックが呼ばれます。
コールバック後、次の条件を満たすときにSurfaceTexture#updateTexImage()メソッドを呼び出すことでテクスチャにビデオやカメラの内容がコピーされます。

  • テクスチャと同じEGLContextがeglMakeCurrentに設定されている
    • バインドされていれば、おそらくスレッドは自由です
  • テクスチャがGL_TEXTURE_EXTERNAL_OESにバインドされている
    • 事前にtexture nameを渡しているため、コレは不要かもしれません・・・

SurfaceTextureは渡された空のテクスチャに対してレンダリングするために十分な領域を確保しますが、ビデオのサイズと完全に同一ではありません。
例えば、320x240のビデオをSurfaceTextureを通して描画させると、次のようなテクスチャが出来上がります。

f:id:eaglesakura:20130306094701j:plain

左右や下側に不要領域が作られているのが分かります。ジャストサイズでない理由はおそらく効率上の理由ですが、具体的にどの領域がビデオとして使われるかは問い合わせすることができません。

その代わり、SurfaceTexture#getTransformMatrix()メソッドでUV座標0.0〜1.0を利用する場合に設定するUV行列を取得することができます。引数にfloat[16]の配列を指定することで、4x4の行列として利用できます。

VertexShaderでUV座標を次のように処理すると、レンダリングに必要なUV領域として利用できます。
ただし、単純に行列を当てると、上下が反転してしまいます。

// SurfaceTexture#getTransformMatrix()で取得した行列
uniform mediump mat4    unif_texm;

-- 省略 --

fTexCoord = (unif_texm * vTexCoord).xy;

f:id:eaglesakura:20130306094742j:plain

その対策として、レンダリング用のポリゴンを作る時点で次のようにUV座標を上下反転させています。

これで見た目的に正常なレンダリング領域を設定出来ます。
頂点シェーダーを使ってもいいですが、どうせ毎度同じ処理をするならばUV単位で事前設定してしまったほうが負荷的にもプログラム的にも楽になります。

通常の四角形の場合

const static Quad::QuadVertex g_vertices = {
//
        /**
         // 位置情報
         left, top, //!< 左上
         right, top, //!< 右上
         left, bottom, //!< 左下
         right, bottom, //!< 右下

         //! UV情報
         0, 0, //!< 左上
         1, 0, //!< 右上
         0, 1, //!< 左下
         1, 1, //!< 右下
         */
        // 左上
        { LEFT, TOP, 0.0f, 0.0f, },
        // 右上
        { RIGHT, TOP, 1.0f, 0.0f },
        // 左下
        { LEFT, BOTTOM, 0.0f, 1.0f },
        // 右下
        { RIGHT, BOTTOM, 1.0f, 1.0f },
// end
        };
}

ビデオレンダリング用の四角形の場合

const static Quad::QuadVertex g_revert_vertices = {
// 左上
        { -0.5, 0.5, 0.0f, 1.0f, },
        // 右上
        { 0.5, 0.5, 1.0f, 1.0f },
        // 左下
        { -0.5, -0.5, 0.0f, 0.0f },
        // 右下
        { 0.5, -0.5, 1.0f, 0.0f }, };
//
}

f:id:eaglesakura:20130306094759j:plain

複数のSurfaceTextureを同時に利用する場合の注意点

一枚のSurfaceTextureで一つのVideoを再生するだけなら特に注意点はありません。
複数のビデオを同時に再生する場合や、効率化のためにテクスチャユニット等を細かく制御している場合はドハマリする場合があります。

GL_TEXTURE_EXTERNAL_OESは同時に一つのテクスチャユニットにしか割り当てることができません。
例えば、次のような制御を行うと意図しないテクスチャにビデオがレンダリングされる恐れがあります。

  1. glActiveTextureで0番ユニットに切り替える
  2. ビデオAをレンダリングさせる
  3. glActiveTextureで1番ユニットに切り替える
  4. ビデオBをレンダリングさせる
  5. 〜〜次のフレーム〜〜
  6. glActiveTextureで0番ユニットに切り替える
  7. ビデオAをレンダリングさせる
  8. 何故かビデオBがレンダリングされてる/(^o^)\

GL_TEXTURE_EXTERNAL_OES自体の仕様かAndroidが効率化のために設けた仕様かバグかは不明です。
コレを回避するために、私の場合はビデオ用のテクスチャを必ず同一のテクスチャユニットにバインドすることにしました。

必ず末尾のテクスチャユニットを利用する

    this->textureUnit = GPUCapacity::getMaxTextureUnits() - 1;

追記 3/7

単にActiveTextureの呼び出し忘れ(Utilクラスのバグ)だった疑惑(´・ω・`)

追記 3/8

単にActiveTextureの呼び出し忘れ(Utilクラスのバグ)だった。
glActiveTexture(index)されていて、かつglBindTexture(target, name)されているテクスチャに転送されるらしい。

最後に

SurfaceTextureを使えばカメラとかビデオとかと連携できて夢が広がりますね。
間違いとかあったら教えて下さいm(__)m

初めてのOpenGL ES

初めてのOpenGL ES

特定のAnnotationが付いたクラスやメソッドだけをProguard対象から外す

技術系全般 Android

Android NDKとProguardは相性が悪い

Proguardとは

今更説明することも無いかと思いますが、ProguardといえばAndroiderにとっては欠かせないお友達です。
Javaのコードは容易に逆コンパイルされて、簡単に内容を解析されてしまいます。

逆コンパイル自体を防ぐことはできませんが、コードの解析を難しくしてくれるツールがProguardの大きな役割です。 Proguardを使う目的は、次の点が大きいでしょう。

  1. コードを難読化して第三者による解析を困難にする
  2. 不要なコードを削除してコード全体を小さくする

どちらがメインになるかは開発者毎に違うでしょうが、そのどちらかが当てはまるのでは無いでしょうか。

Proguradの問題点

Android SDKを一般的(あなたが思う範囲が一般的です。他人の同意を得られるとは限りません)に使っている限り、Proguardはとても良いパートナーです。

ですが、例えば次のような使い方をした場合、Proguardは我々にケンカを売ってきます。

  1. リフレクションを利用してクラスやメソッドやフィールドを参照した場合
  2. JNIを利用してnativeコードを呼び出した場合

リフレクションは使わない人は全く使わないJavaの機能ですが、簡単に説明すると「クラス名」「メソッド名」「フィールド名」を文字列として参照し、制御する方法です。

JNIはC/C++で記述された関数を「文字列」としてランタイムで検索し、呼び出します。

Proguardのかかったコードは、次の点で致命的な致命傷を受けます。

  • クラス名を変えられてしまう
    • それによって、リフレクションを取得するために指定した「文字列」と「実際のクラス名」に違いが生じてしまう
    • 例えば、"com.eaglesakura.Hello"というクラスが、Proguardを通すことで"com.eaglesakura.a"という短いクラス名に変わってしまいます。
      プログラム中で"com.eaglesakura.Hello"クラスを文字列で検索しても見つからなくなってしまい、ランタイムエラーが発生します。
  • NDK側でのみ参照しているクラスやメソッドが削除されてしまう
    • Proguardは利用されていないメソッド等をチェックし、コードからまるまる削除してしまいます。
    • そのため、NDKでしか使っていないクラスやメソッドがあった場合、「不要なクラス」と判断されて削除されてしまう恐れがあります。

今回問題になった所

個人的に作っているiOS/Android同時コーディング用のライブラリでは、JavaのクラスにAnnotationをつけることでJava側のコードを呼び出すC++コードを自動出力しています。

@JCClassはコンバータに「変換対象クラス」であることを知らせるための目印で、@JCMethodは「変換対象メソッド」であることを示すための目印です。

Java側コード
クラスに"JCClass"、メソッドに"JCMethod"のannotationをつけています。

@JCClass
class   Hoge {
    @JCMethod
    static void hello() {
        System.out.println("hello world!!");
    }
}
生成されるC++コード(Hello.cpp抜粋)
Helloクラスが自動的に作成されます。

const ::jc::charactor* Hello::CLASS_SIGNATURE = "com/eaglesakura/Hello";
static jclass class_Hello = NULL;
static jmethodID methods_Hello[1];
static void initialize_Hello() {
    // loaded !
    if (class_Hello) {
        return;
    }

    CALL_JNIENV();

    // load class object
    class_Hello = env->FindClass(Hello::CLASS_SIGNATURE);
    class_Hello = (jclass)::ndk::change_globalref(env, class_Hello);

    // load methods
    {
        methods_Hello[0] = ::ndk::JniWrapper::loadMethod(class_Hello, "hello", "()V", true);
    }
}

void Hello::hello() {
    CALL_JNIENV();
    initialize_Hello();
    env->CallStaticVoidMethod(class_Hello, methods_Hello[0]);
}

C++側のコードを見ると、"com/eaglesakura/Hello"というクラス名・パッケージ名を示したシグネチャや、"loadMethod(class_Hello, "hello", "()V", true);"というJavaのメソッド検索部分が生成されているのがわかります。

Proguardをかけてしまうと、クラス名の崩壊によってこれらの文字列では呼び出せなくなってしまいます。 また、hello()がJava側で参照されていない場合、最適化と称してまるっと削除されている恐れすらあります。

しかも呼び出しは「文字列」で行なっている都合上、コンパイル時に見つけることは出来ず、実行時にしかわかりません。

タチの悪いことに、Android JUnitはDebug状態で作成されてProguardされていないため、Jenkinsの自動ビルドでは発見することが困難です。

問題解決のために

ここからが本題です。
上記の問題を解決するためにはProguardでクラス名やメソッド名が変わらないようにしなければなりません。

幸運にも今回問題になったライブラリの場合だと「自動出力のために必ず特定のannotationをつける」必要があったため、どうにか「特定のannotationの付いたクラスやメソッドをProguard対象から外す」というタイトルのようなことが無いかと資料を漁ってみました。

@sys1yagiさんのブログにAnnotation指定でProguard回避する記述があったので、それを元に次の条件でProguardを回避する設定を追加しました。

  • @JCClassのAnnotationがついたクラスはリネームされない
  • @JCMethodがついたメソッドはリネームされない
  • @JCMethodがついたメソッドは最適化で削除されない
今回proguard.cfgに追加した部分

# jointcoding
# keep annotations
-keep class com.eaglesakura.lib.jc.annotation.jnimake.**
-keep @com.eaglesakura.lib.jc.annotation.jnimake.JCClass class *
-keep @com.eaglesakura.lib.jc.annotation.jnimake.JCMethod class *

# keep Java class names
-keepnames @com.eaglesakura.lib.jc.annotation.jnimake.JCClass class *
-keep class * implements @com.eaglesakura.lib.jc.annotation.jnimake.JCClass *

# keep Java unused methods
-keepclassmembers @com.eaglesakura.lib.jc.annotation.jnimake.JCClass class * {
    @com.eaglesakura.lib.jc.annotation.jnimake.JCMethod *;
}

# keep Java method names
-keepclassmembernames class * {
    @com.eaglesakura.lib.jc.annotation.jnimake.JCMethod *;
}

解説

次の3行でAnnotation自体をProguardから守ります。

-keep class com.eaglesakura.lib.jc.annotation.jnimake.**
-keep @com.eaglesakura.lib.jc.annotation.jnimake.JCClass class *
-keep @com.eaglesakura.lib.jc.annotation.jnimake.JCMethod class *

次の2行で、@JCClassのついたクラスの名称変更を回避します。

-keepnames @com.eaglesakura.lib.jc.annotation.jnimake.JCClass class *
-keep class * implements @com.eaglesakura.lib.jc.annotation.jnimake.JCClass *

最後に、@JCMethodのついたメソッドを名称変更・最適による削除対象から外します。

# keep Java unused methods
-keepclassmembers @com.eaglesakura.lib.jc.annotation.jnimake.JCClass class * {
    @com.eaglesakura.lib.jc.annotation.jnimake.JCMethod *;
}

# keep Java method names
-keepclassmembernames class * {
    @com.eaglesakura.lib.jc.annotation.jnimake.JCMethod *;
}

まとめ

Proguardは欲しい条件でちゃんとやってくれそうだけど、指定するための条件付けを見つけるまでが苦労するよね(´・ω・`)