目次へ戻る

1. Android NDK てほどき

2012-05-10 (鈴)

1 はじめに

本章では Android NDK を簡単に紹介する。NDK とは Native Development Kit の略である。 Android NDK は C/C++ によるネイティブ・コードを JNI (Java Native Interface) 共有ライブラリとしてコンパイルし, Android アプリケーションに組み込むことを可能にする。 その実体は ARM など Android がサポートする各 CPU へのクロス・コンパイラとして構成された gcc [gnu.org] と,それに関連するツール等の一群である。

というわけで Android NDK はその動作に Unix ライクな環境を要求します。 Windows に対して現行の Android NDK は Cygwin 1.7.* のもとで,より自然に動作します。 Cygwin 1.7.* 全般の環境整備については,例えば,Life with Cygwin 24 を参考にしてください。 Cygwin のインストールでは,setup.exe で自動的に選択される必須パッケージに加えて,少なくとも make パッケージを追加してください。
とはいえ Windows 用の Android NDK は最低限の Unix 互換コマンドを自前で用意していますから, Cygwin がなくても,一応それなりには使えます。 しかし,(Android.mk などの) makefile 内でできる処理に限りがあって不便ですし,貧弱な Windows ネイティブのコマンド行環境でわざわざ苦労することはありませんから,ここでは勧めません。

Android NDK として,執筆時現在 Android NDK [android.com] 本家ページで公開されている Android NDK, Revision 8 を使う。 以下,プラットフォームとして Mac OS X (intel) を仮定するが,その説明は Windows (Cygwin) にも適用できる。

ここで説明する範囲ではプラットフォームによる違いは特にありません。 あえて先取りして言えば,相違点として Cygwin で作成したシンボリック・リンクが Android NDK の gcc から認識されないことが挙げられます。 Android NDK の gcc それ自体は同じ GNU 系とはいえ MinGW [mingw.org] 由来であり, Cygwin 上のプログラムではない,つまり cygwin1.dll を共有しないからです。
ですから Mac OS X や Linux 上でシンボリック・リンクを含んだソース・ファイル群を tar ファイルにまとめ, Cygwin で展開したとき,この理由でコンパイルに失敗します。

Android の基本的な開発環境の整備については Android 開発手順 を参照されたい。

2 Android NDK のインストール

インストールは単に Android NDK [android.com] 本家ページで配布されている圧縮ファイルを適当なディレクトリに展開するだけである。

01:~$ tar xf android-ndk-r8-darwin-x86.tar.bz2
01:~$ mv android-ndk-r8 android-ndk
01:~$  
展開したままではディレクトリ名が android-ndk-r8 とやや長いので,ここでは mv コマンドで android-ndk へと縮めています。 こうすると将来,NDK のリビジョン番号が上がってもディレクトリ名をそのまま使えるという利点もあります。 リビジョン番号は ~/android-ndk/RELEASE.TXT に書かれています。

コマンド行から直接呼び出す Android NDK のコマンドはさしあたり ndk-build だけである。 以下の説明では Android NDK を上記のように ~/android-ndk/ として展開したと仮定し,特に PATH を通さず, ~/android-ndk/ndk-build としてこのコマンドを使うこととする。

3 最初の NDK プログラム

Android NDK にはいくつかのサンプル・プログラムが含まれている。 以下,最も初歩的な hello-jni をとり上げる。

まず ~/android-ndk/samples/ の下にある hello-jni を適当なところにコピーし, android (Cygwin では android.bat) コマンドで update project して,ant のプロジェクトに仕立てよう。

01:~/tmp$ cp -pr ~/android-ndk/samples/hello-jni .
01:~/tmp$ cd hello-jni
01:~/tmp/hello-jni$ android update project --path . --target 1
Picked up _JAVA_OPTIONS: -Dfile.encoding=UTF-8
Picked up _JAVA_OPTIONS: -Dfile.encoding=UTF-8
Picked up _JAVA_OPTIONS: -Dfile.encoding=UTF-8
Updated and renamed default.properties to project.properties
Updated local.properties
No project name specified, using Activity name 'HelloJni'.
If you wish to change it, edit the first line of build.xml.
Added file ./build.xml
Added file ./proguard-project.txt
It seems that there are sub-projects. If you want to update them
please use the --subprojects parameter.
01:~/tmp/hello-jni$  

ここで --target の引数 1android list target で得られるターゲットの id 番号である。 id 番号については Android 開発手順 - 応用編 3.1 節「プロジェクトの新規作成」 を参照されたい。 ちなみに今回,筆者が実行した環境ではこれはたまたま android-8 に該当する。 この設定値は上記で更新され改名された project.properties に書かれている。

ここで android-8 に設定した,ということはすぐ後で使うのでおぼえておいてください。
Picked up _JAVA_OPTIONS: -Dfile.encoding=UTF-8
は筆者が環境変数 _JAVA_OPTIONS を ~/.bash_profile 中で export _JAVA_OPTIONS="-Dfile.encoding=UTF-8" として設定したために表示されています。 この環境変数を設定すると,いかなる形態であれ java が起動するとき自動的に -Dfile.encoding=UTF-8 のオプションが付加されます。 これは SJIS をデフォルト・エンコーディングとする Mac OS X 上の Java で UTF-8 を使うための バッドノウハウな常套手段です。 しかし,その代償として,このようなメッセージがいちいち出力されます。
他のプラットフォームの人には煩わしいだけですから,これ以降はこのメッセージの表示を省略します。

次に,いよいよ C 言語のソースから ネイティブ・コードへのクロス・コンパイル をしよう。 ソースと makefile は jni フォルダの下に hello-jni.cAndroid.mk としてある。 君がすべきことは ndk-buid を実行することだ。

01:~/tmp/hello-jni$ ~/android-ndk/ndk-build
Gdbserver      : [arm-linux-androideabi-4.4.3] libs/armeabi/gdbserver
Gdbsetup       : libs/armeabi/gdb.setup
Compile thumb  : hello-jni <= hello-jni.c
SharedLibrary  : libhello-jni.so
Install        : libhello-jni.so => libs/armeabi/libhello-jni.so
01:~/tmp/hello-jni$  

特に指定しなければ CPU アーキテクチャとして armeabi (ARM Embedded Application Binary Interface) が選択される。 中間生成物はプロジェクト直下の obj ディレクトリに置かれる。 最終的な JNI 共有ライブラリはプロジェクト直下の libs ディレクトリに置かれる。

ARM アーキテクチャが選択された,ということもすぐ後で使うのでおぼえておいてください。

普通,libs ディレクトリには Java アプリケーションが利用する追加の jar ファイルが置かれる (再び Android 開発手順 - 応用編 3.1 節「プロジェクトの新規作成」を参照されたい)。 これと同じように,ただし CPU アーキテクチャごとにディレクトリを分けて,libs に共有ライブラリを置くことまでが ndk-build の仕事である。

残りの作業は普通の Android プロジェクトと変わらない。 もしもエミュレータが動作中であれば,単に ant debug install をすることによって,残りの作業 (Java ソースのコンパイルと apk ファイルの作成,起動中のエミュレータへのインストールまで) が数秒から十数秒で完了する (Android 開発手順 - 導入編 の 1.3 節 と 1.4 節を参照されたい)。

01:~/tmp/hello-jni$ ant debug install
Buildfile: /Users/suzuki/tmp/hello-jni/build.xml

-set-mode-check:

-set-debug-files:

-set-debug-mode:

-debug-obfuscation-check:

-setup:
     [echo] Creating output directories if needed...
    [mkdir] Created dir: /Users/suzuki/tmp/hello-jni/bin
……中略……
install:
     [echo] Installing /Users/suzuki/tmp/hello-jni/bin/HelloJni-debug.apk onto default emulator or device...
     [exec] 793 KB/s (85877 bytes in 0.105s)
     [exec] 	pkg: /data/local/tmp/HelloJni-debug.apk
     [exec] Success

BUILD SUCCESSFUL
Total time: 8 seconds
01:~/tmp/hello-jni$  

エミュレータ上の HelloJni という名前のアイコンをクリックしてアプリケーションを実行しよう。 左図はその実行例である。

残念ながらも当然ながら,初歩的な例ということもあり,このとき特に驚くべきことは起こらない。 ただ Hello form JNI! と画面に表示されるだけだ。 しかし,この文字列は Java プログラムではなく C 言語のプログラムに由来している。

下記に C 言語のソース jni/hello-jni.c からコメントを除いたコードを示す。 1個の関数から構成されており,その名前は,これが Java の ネイティブ・メソッド com.example.hellojni.HelloJni#stringFromJNI() の実装関数であることを示している。

引数 JNIEnv* env は JNI (Java Native Interface) の実行時の環境 (environment) を表す構造体への二重ポインタである。 その構造体は関数ポインタをメンバとすることにより,事実上の仮想メンバ関数 (ないし仮想メソッド) を模倣している。 NewStringUTF は,与えられた文字列から Unicode 文字列をヒープ上に作成する。 その後,戻り値として Java に渡された Unicode 文字列は,Java によって画面に表示され,Java のメモリ管理機構によって解放される。

引数 jobject thiz は,このメソッド呼び出しでの Java の this を表している。 this を予約語としている Java や C++ と異なり C 言語だからズバリ this という変数にしてもよいが,普通は避けたほうが無難であり,ここでもその慣習 (ないし生活の知恵) に従っている。

JNIEnvjobject などの具体的な定義は,ここでは android-8ARM アーキテクチャを使っているから, ~/android-ndk/platforms/android-8/arch-arm/usr/include/jni.h にあるものが使われる。

#include <string.h>
#include <jni.h>

jstring
Java_com_example_hellojni_HelloJni_stringFromJNI( JNIEnv* env,
                                                  jobject thiz )
{
    return (*env)->NewStringUTF(env, "Hello from JNI !");
}
なぜ env 引数が二重ポインタなのか?,あるいは JNIEnv 型が構造体ではなく,構造体へのポインタとして定義されているのか? は良い質問です。
JNI のための実行時環境を表す構造体は,Java の内部実装に由来するものですから, Java 言語でのオブジェクト (つまりクラスのインスタンス) と同じ領域に格納され,同じように管理されても不自然ではありません。 ですから,garbage collection による compaction によって構造体の所在する場所 (つまりアドレス) が変更させられるかもしれません。
env の実引数値は Java の内部実装で作られるわけですから,env が指すポインタの場所が garbage collector にとって既知であるようにできます。 ですから garbage collection によって構造体の場所替えをしなければならなくなったとき,そのポインタを garbage collector が書き換えて,構造体の場所替えに追従させることができます。
ですから,われらが C 言語の関数からは,つねに env が指す先のポインタが指す先として構造体を二重間接参照するようにすれば,たとえ garbage collection がわれらが関数の実行中に Java 内部で発生したとしても構造体を見失うことはありません。
伝統的に,この env のようなものをしばしば handle と呼びます。
ちなみに,ここでは使っていない C++ 用の JNI では JNIEnv は見かけ上,環境を表す構造体 (ないしクラスのインスタンス) そのものとして振舞います。 前述の return 文は C++ では
    return env->NewStringUTF("Hello from JNI !");
となります。
しかし,その実体はやはり本当の構造体への単なるポインタです,
と言っても何のことか要領を得ないと思いますから,是非 jni.h を見てください。 条件コンパイルの場合分けによって,たしかに C++ では JNIEnv はメンバ関数付きの構造体として定義されます。 しかし,よく読むとそのメモリ上のレイアウト, 実際にメモリに置かれる値は1個のポインタのそれと同じです。 コンパイル後の機械語コードからは両者を事実上区別できないことが,C++ 言語仕様の保証する範囲内だけからも請け合えます。 つまり,決して C と C++ 用に別々の JNI があるのではなく,同じインタフェースを C++ では言語の巧妙ながらも正当なトリックを使ってもっともらしく見せかけているわけです。
一方,jstring や jobject はそれぞれポインタの別名として定義されています。 これらの値は,JNI が提供する関数の引数や戻り値としてだけ使われます。 そのポインタの指す先が実際には構造体なのか,それともポインタなのか, そもそも jstring や jobject 自体が本当にポインタなのか,具体的にどう実装するかは処理系作成者の自由に完全に任されています。
つまり,env と同じく thiz も handle かもしれません。

下記に jni/Android.mk をコメントを除いて示す。 その記述内容については ~/android-ndk/docs/ANDROID-MK.html を,正確な構文則については GNU make [gnu.org] コマンドのマニュアルをそれぞれ参照されたい。 共有ライブラリのファイル名 (ここでは libhello-jni.so) は, LOCAL_MODULE の値から導出される。 共有ライブラリに収録される C 言語関数のソース・ファイルは,LOCAL_SRC_FILES の値として空白で区切って列挙されたもの (ここでは hello-jni.c の1個のみ) が使われる。

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE    := hello-jni
LOCAL_SRC_FILES := hello-jni.c

include $(BUILD_SHARED_LIBRARY)

下記にアプリケーションの Activity となる src/com/example/hellojni/HelloJni.java をコメントを除いて示す。

package com.example.hellojni;

import android.app.Activity;
import android.widget.TextView;
import android.os.Bundle;

public class HelloJni extends Activity
{
    @Override
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);

        TextView  tv = new TextView(this);
        tv.setText( stringFromJNI() );
        setContentView(tv);
    }

    public native String  stringFromJNI();

    public native String  unimplementedStringFromJNI();

    static {
        System.loadLibrary("hello-jni");
    }
}

実行時,はじめに静的初期化子で System.loadLibrary("hello-jni") が呼び出されて共有ライブラリ libhello-jni.so が読み込まれる。

onCreate(Bundle) メソッドからネイティブ・メソッド stringFromJNI() が呼び出されたとき,Java 実行時処理系は,メソッドのパッケージ名,クラス名,メソッド名をもとに Java_com_example_hellojni_HelloJni_stringFromJNI という関数名を組み立て,共有ライブラリから該当する関数を探し出して呼び出す。 このとき,第1引数には JNI 環境 (へのポインタのポインタ) を,第2引数には今の this (を JNI 向けに表現したもの) を渡す。

このようにネイティブ・メソッドと C 言語関数の結合は,実行時,ネイティブ・メソッドが最初に呼び出されたとき行われる。 このことを説明するため,このクラスは unimplementedStringFromJNI() という名前のネイティブ・メソッドも宣言している。 それに対応する Java_com_example_hellojni_HelloJni_unimplementedStringFromJNI という名前の関数は共有ライブラリに定義されていないが,そもそもこのネイティブ・メソッドは呼び出されないから,なんらエラーにはならない。

4 簡単な実験

ここで簡単な実験を二つしてみよう。

まず jni/hello-jni.c を編集して return 文の部分を次のように書き換えよう。 文字コードは UTF-8 とする。

#include <string.h>
#include <jni.h>

jstring
Java_com_example_hellojni_HelloJni_stringFromJNI( JNIEnv* env,
                                                  jobject thiz )
{
    return (*env)->NewStringUTF(env, "JNI から こんにちは!");
}

最初と同じように下記を行う。

01:~/tmp/hello-jni$ ~/android-ndk/ndk-build
01:~/tmp/hello-jni$ ant debug install

エミュレータ上の HelloJni アプリケーションを実行して,今度は左図のように表示されることを確かめよう。 確かに C 言語の関数から返した文字列がアプリケーションで表示されていることが実感できたことと思う。

次にログ出力の実験をしよう。

jni/hello-jni.c に下記のように3行書き加える。 ここで定義した i_printf マクロは INFO レベルで (HelloJni の頭文字にちなんで) hj というタグを付けて,printf と同じ形式でログ出力をする。__VA_ARGS__ は C99 で規定されており,マクロの残余引数の並びに展開される。

#include <string.h>
#include <jni.h>
#include <android/log.h>

#define i_printf(...) __android_log_print(ANDROID_LOG_INFO, "hj", __VA_ARGS__)

jstring
Java_com_example_hellojni_HelloJni_stringFromJNI( JNIEnv* env,
                                                  jobject thiz )
{
    i_printf("hello, world.  5 + 6 = %d\n", 5 + 6);
    return (*env)->NewStringUTF(env, "JNI から こんにちは!");
}

jni/Android.mk に下記のように1行書き加える。 これにより共有ライブラリ libllog.so が使われる。

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE    := hello-jni
LOCAL_SRC_FILES := hello-jni.c

LOCAL_LDLIBS := -llog

include $(BUILD_SHARED_LIBRARY)
実体は ~/android-ndk/platforms/android-8/arch-arm/usr/lib/liblog.so です。

最初と同じように下記を行う。

01:~/tmp/hello-jni$ ~/android-ndk/ndk-build
01:~/tmp/hello-jni$ ant debug install

エミュレータでアプリケーションを実行するとともに,adb logcat でそのログ出力を見てみよう。

01:~/tmp/hello-jni$ adb logcat
D/dalvikvm(   33): GC_EXPLICIT freed 1403K, 55% free 2310K/5123K, external 0K/0K
, paused 81ms
D/dalvikvm(   33): GC_EXPLICIT freed 148K, 54% free 2373K/5123K, external 0K/0K,
 paused 71ms
……中略……
D/dalvikvm(  379): No JNI_OnLoad found in /data/data/com.example.hellojni/lib/li
bhello-jni.so 0x40515180, skipping init
I/hj      (  379): hello, world.  5 + 6 = 11
I/ActivityManager(   61): Displayed com.example.hellojni/.HelloJni: +1s774ms

確かに i_printf の表示内容が出力されていることが分かる。

単なる printf ではどこにも出力されませんから,これは後々とても役に立ちます!
ただし,そのときは,ここで適当に決めたマクロ名やタグ名は適宜変更してください。

5 おわりに

最後に,構築された生成物を消してプロジェクトをクリーンにしておこう。

01:~/tmp/hello-jni$ ant clean
Buildfile: /Users/suzuki/tmp/hello-jni/build.xml

-pre-clean:

clean:
   [delete] Deleting directory /Users/suzuki/tmp/hello-jni/bin
   [delete] Deleting directory /Users/suzuki/tmp/hello-jni/gen

BUILD SUCCESSFUL
Total time: 0 seconds
01:~/tmp/hello-jni$  
01:~/tmp/hello-jni$ ~/android-ndk/ndk-build clean
Clean: hello-jni [armeabi]
Clean: stdc++ [armeabi]
01:~/tmp/hello-jni$  
はじめての NDK プログラムということで記念にアーカイブしておくとよいかもしれません ;-)
01:~/tmp/hello-jni$ cd ..
01:~/tmp$ tar cf hello-jni-24-05-10.tar hello-jni
01:~/tmp$ bzip2 -v9 hello-jni-24-05-10.tar
  hello-jni-24-05-10.tar:  9.674:1,  0.827 bits/byte, 89.66% saved, 55296 in, 57
16 out.
01:~/tmp$  

Copyright (c) 2012 OKI Software Co., Ltd.