Основы Android NDK на примере работы с OpenAL

в 13:42, , рубрики: android, c++, Ndk, ogg, wav, библиотеки, Разработка под android, метки: , , , ,

День добрый, уважаемые читатели!

С недавних пор занимаюсь разработкой приложений под Android, в частности разработкой игр. Так сложилось, что для одного проекта пришлось работать с Android ndk. Все трудности и нюансы работы с native рассмотреть в принципе невозможно в рамках одной статьи, решил в данной статье небольшое введение в ndk написать.
А чтобы статья была интересна не только новичкам, покажу как работать с OpenAL и форматами WAV, OGG.

Введение

Про настройку среды писать много не стоит, как мне кажется. Независимо от того, в какой среде вы разрабатываете (Eclipse, IntelliJ IDEA и т.д.), настройка довольно простая.

  1. Сам Android NDK.
  2. Для сборки под WIn понадобится Cygwin .
  3. Плагины, для того же Eclipse: CDT.

Естественно, у вас уже должны стоять ADT, JDK.

Зачем нужен NDK?

  • Работа с OpenGL ES. Думаю, большинство тех, кто использует NDK, используют его как раз для написания игр.
  • Использование кросс-платформенных игровых движков вроде Cocos2Dx
  • Самый очевидный случай – это когда вам надо использовать уже написанный на C++ код. За десятилетия на C++ уже куча всего написано. Да и не всё можно переписать, думаю тот же openCV бессмысленно бы было переписывать, в том время когда можно просто подключить готовые исходники.

Вызов C++ кода из Java

В целом всё довольно просто, основные шаги:

  1. Создание файлов с C++ и определение методов для экспорта.
  2. Создание .mk файлов.
  3. Генерация библиотеки.
  4. Подключение библиотеки в Java.

Про Makefiles(.mk) расписывать не буду. Можно почитать про него тут. К тому же, на хабре есть хорошая статья по работе с .mk файлами от BubaVV.

Про библиотеки из ndk можете почитать тут.

Создание C++ файлов

Необходимо определить методы для экспорта, который мы будем вызывать из Java. Как пример, при запуске приложения мы будем грузить музыку в OpenAL. Для этого определим метод:

JNIEXPORT void JNICALL Java_ru_suvitruf_androidndk_tutorial4_MainActivity_loadAudio(JNIEnv *pEnv, jobject pThis, jobject pNativeCallListener, jobject assetManager);

Я всё это ручками пишу, но есть удобная утилита для автоматической генерации javah.

Затем нам необходимо будет его реализовать, но об этом немного позже.

Немного про наименование

Стоит немного сказать про наименование методов. Java_ – обязательный префикс. ru_suvitruf_androidndk_tutorial4, так как у нас пакет ru.suvitruf.androidndk.tutorial4, ну а дальше наименование класса и метода на стороне Java. В каждой функции в качестве аргумента имеется JNIEnv* — интерфейс для работы с Java, при помощи него можно вызывать Java-методы, создавать Java-объекты. Второй обязательный параметр — jobject или jclass — в зависимости от того, является ли метод статическим. Если метод статический, то аргумент будет типа jclass (ссылка на класс объекта, в котором объявлен метод), если не статический — jobject — ссылка на объект, у которого был вызван метод.
Подключение библиотеки в Java

После генерации библиотека, необходимо её подключить в Java.

static {
		System.loadLibrary("AndroidNDK");
	}

И определить метод с тем же названием, как и в C++ коде:

//загрузка ресурсов
native public void loadAudio(NativeCalls nativeCallListener, AssetManager mng);

Вызывать так:

loadAudio(activity, activity.getResources().getAssets());

Вызов Java из C++

Немного посложнее, но не всё так страшно. Что нам надо:

  1. Определить у класса метод (в Java), который хотим вызвать.
  2. Получить дескриптор нужного класса (в C++).
  3. Описать сигнатуру метода.
  4. Получить идентификатор метода (ссылку).
  5. Вызвать метод у нужного объекта.

Конечно можно просто определить метод у класса, но лучше использовать интерфейсы. Тогда нам не придётся менять native код, если захотим работать с другим классом.

Как пример, создадим интерфейс всего с одним методом:

public interface NativeCalls {
	public void sendLog(String result);
}
CalledFromWrongThreadException и правильная реализация интерфейса

Ох уж эти потоки. Проблема в том, что нельзя воздействовать на вьюху из другого потока. Поэтому вся реализация интерфейса будет примерно такой:

protected Handler handler = new Handler()
    {  
    	 @Override
         public void handleMessage(Message msg) {
    		 showResult(msg.getData().getString("result"));   		 
    	 }
    };
	
    
    public void showResult(String result){
    	((TextView) findViewById(R.id.log)).
    	setText(((TextView) findViewById(R.id.log)).getText()+result+"n");
    
    }
	
    //отобразить количество прочитаных байт
	@Override 
	public void sendLog(String result){
		Message msg = new Message();
		Bundle data = new Bundle();
		data.putString("result", result);
		msg.setData(data);
				
		handler.sendMessage(msg);
	}

Проблем с потоками может и не быть, но в нашем случае при запуске приложения будем создавать отдельный поток для загрузки ресурсов, поэтому для нас вопрос актуален.

Java интерфейсу в нативном коде на C++ будет соответствовать следующий класс:

NativeCallListener

class NativeCallListener {
public:

	NativeCallListener(JNIEnv* pJniEnv, jobject pWrapperInstance);
	NativeCallListener() {}
	//апуск таймера

	//передать значение в Java метод
    void sendLog(jobject log);
    //очистка всех ресурсов
    void destroy();
	~NativeCallListener(){

	}
	void loadAudio();
	//void play();
	//void playOGG();
	ALCdevice* device;
	ALCcontext* context;
private:
	JNIEnv* getJniEnv();

    //ссылка на метод
	jmethodID sendLogID;
	//ссылка на объект
	jobject mObjectRef;
	JavaVM* mJVM;


	ALuint soundWAV;
	ALuint soundOGG;
	void load();
	void clean();

};

Теперь можно показать реализацию loadAudio метода, хэдер которого в первой части статьи был.

JNIEXPORT void JNICALL Java_ru_suvitruf_androidndk_tutorial4_MainActivity_loadAudio(JNIEnv *pEnv, jobject pThis, jobject pNativeCallListener, jobject assetManager) {
	listener = NativeCallListener(pEnv, pNativeCallListener);
	mgr = AAssetManager_fromJava(pEnv, assetManager);
	listener.loadAudio();
}

В конструкторе класса мы сохраняем дескриптор класса и получаем ссылку на его метод:

NativeCallListener::NativeCallListener(JNIEnv* pJniEnv, jobject pWrappedInstance) {
	pJniEnv->GetJavaVM(&mJVM);
	mObjectRef = pJniEnv->NewGlobalRef(pWrappedInstance);
	jclass cl = pJniEnv->GetObjectClass(pWrappedInstance);
        //тот самый, что определён в нашем интерфейсе в Java
	sendLogID = pJniEnv->GetMethodID(cl, "sendLog", "(Ljava/lang/String;)V");

}

Теперь мы может вызывать Java метод написав:

void NativeCallListener::sendLog(jobject log) {
	JNIEnv* jniEnv = getJniEnv();
	jniEnv->CallIntMethod(mObjectRef, sendLogID, log);

}

AAssetManager

Раньше использовалась open source библиотека libzip для работы с ресурсами приложения.
С 2.3 версии API в Android ndk появился замечательный класс для работы с директорией assets прямо из C++ кода.
Методы похожи на методы по работе с файлами из stdio.h. AAssetManager_open вместо fopen, AAsset_read вместо fread, AAsset_close вместо fclose.

Я для него небольшую обёртку написал. Код вставлять сюда не буду, так как в целом работа та же, что и с FILE обычным.

Работа с OpenAL

Статья уже довольная большая, а к самому интересному так и не приступил. Прошу меня простить за это…

Подготовка

В первую нужно собрать OpenAL. Для работы с WAV этого достаточно, но мы же ещё хотим и с OGG поработать. Для OGG нужен декодер Tremor.

Для звука я написал обёртки с необходимыми методами. Весь код тут приводить смысла нет, освещу самое интересное, а именно загрузку.

Прочитать WAV файл

Сначала необходимо описать структуру для хэдеров:

BasicWAVEHeader

typedef struct {
  char  riff[4];//'RIFF'
  unsigned int riffSize;
  char  wave[4];//'WAVE'
  char  fmt[4];//'fmt '
  unsigned int fmtSize;
  unsigned short format;
  unsigned short channels;
  unsigned int samplesPerSec;
  unsigned int bytesPerSec;
  unsigned short blockAlign;
  unsigned short bitsPerSample;
  char  data[4];//'data'
  unsigned int dataSize;
}BasicWAVEHeader;

Теперь читаем:

void OALWav::load(AAssetManager *mgr, const char* filename){
	this->filename = filename;
	this->data = 0;
        //читаем файл
	this->data = this->readWAVFull(mgr, &header);
        //узнать формат
	getFormat();
        //создаём OpenAL буфер
	createBufferFromWave(data);
	source = 0;
	alGenSources(1, &source);
	alSourcei(source, AL_BUFFER, buffer);
}
readWAVFull

char* OALWav::readWAVFull(AAssetManager *mgr, BasicWAVEHeader* header){
	char* buffer = 0;

	AAssetFile f = AAssetFile(mgr, filename);

	if (f.null()) {
		LOGE("no file %s in readWAV",filename);
		return 0;
	}
	int res = f.read(header,sizeof(BasicWAVEHeader),1);
	if(res){
		if (!(
			// Заголовки должны быть валидны.
			// Проблема в том, что не всегда так.
			// Многие конвертеры недобросовестные пихают в эти заголовки свои логотипы =/
			memcmp("RIFF",header->riff,4) ||
			memcmp("WAVE",header->wave,4) ||
			memcmp("fmt ",header->fmt,4)  ||
			memcmp("data",header->data,4)
		)){
			buffer = (char*)malloc(header->dataSize);
			if (buffer){
				if(f.read(buffer,header->dataSize,1)){
					f.close();
					return buffer;
				}
				free(buffer);
			}
		}
	}
	f.close();
	return 0;
}

Стоит сказать об WAV кое-что. Порой, файл на PC вроде прослушивается отлично, но в при работе в OpenAL с ним возникают ошибки. Это следствие того, что битые заголовки. Я встречал много конвертеров, которые в хэдеры писал какую-то чушь (свой логотип как пример), как правило в dataSize. Так почему не работает, а на PC играет?
Непосредственно сами данные аудио хранятся после хэдера и их размер в dataSize. Если с этим полем что-то не так, то будут ошибки. Можно правда посчитать размер в лоб. Размер данных = размер файла — размер хэдера. Так что, думаю, плееры берут размер данных вычитая, а не из хэдера.

По работе с WAV вроде всё просто, так как формат не сжатый. При работе с .Ogg всё посложнее.

Прочитать Ogg файл

В чём особенность Ogg по сравнению с WAV? Это сжатый формат. Так что, перед там как записать данные в буфер OpenAL, нам необходимо данные декодировать.
Загвоздка в том, что по умолчанию Vorbis стримит из FILE, так что нам необходимо переопределить все callback методы по работе с данными:

callbacks

static size_t  read_func(void* ptr, size_t size, size_t nmemb, void* datasource)
{
    unsigned int uiBytes = Min(suiSize - suiCurrPos, (unsigned int)nmemb * (unsigned int)size);
    memcpy(ptr, (unsigned char*)datasource + suiCurrPos, uiBytes);
    suiCurrPos += uiBytes;

    return uiBytes;
}

static int seek_func(void* datasource, ogg_int64_t offset, int whence)
{
    if (whence == SEEK_SET)
        suiCurrPos = (unsigned int)offset;
    else if (whence == SEEK_CUR)
        suiCurrPos = suiCurrPos + (unsigned int)offset;
    else if (whence == SEEK_END)
        suiCurrPos = suiSize;

    return 0;
}

static int close_func(void* datasource)
{
    return 0;
}

static long tell_func(void* datasource)
{
    return (long)suiCurrPos;
}

Теперь необходимо прочитать:

Чтение Ogg

void OALOgg::getInfo(unsigned int uiOggSize, char* pvOggBuffer){
	// Заменяем колбэки
	    ov_callbacks callbacks;
	    callbacks.read_func = &read_func;
	    callbacks.seek_func = &seek_func;
	    callbacks.close_func = &close_func;
	    callbacks.tell_func = &tell_func;


	    suiCurrPos = 0;
	    suiSize = uiOggSize;
	    int iRet = ov_open_callbacks(pvOggBuffer, &vf, NULL, 0, callbacks);

	    // Заголовки
	    vi = ov_info(&vf, -1);

	    uiPCMSamples = (unsigned int)ov_pcm_total(&vf, -1);
}
void * OALOgg::ConvertOggToPCM(unsigned int uiOggSize, char* pvOggBuffer)
{
	if(suiSize == 0){
		getInfo( uiOggSize, pvOggBuffer);
		current_section = 0;
		iRead = 0;
		uiCurrPos = 0;
	}

    void* pvPCMBuffer = malloc(uiPCMSamples * vi->channels * sizeof(short));

    // Декодим
    do
    {
        iRead = ov_read(&vf, (char*)pvPCMBuffer + uiCurrPos, 4096, &current_section);
        uiCurrPos += (unsigned int)iRead;
    }
    while (iRead != 0);

    return pvPCMBuffer;
}

void OALOgg::load(AAssetManager *mgr, const char* filename){
	this->filename = filename;
	char* buf = 0;
	AAssetFile f = AAssetFile(mgr, filename);
	if (f.null()) {
		LOGE("no file %s in readOgg",filename);
		return ;
	}

	buf = 0;
	buf = (char*)malloc(f.size());
	if (buf){
		if(f.read(buf,f.size(),1)){
		}
		else {
			free(buf);
			f.close();
			return;
		}
	}

	char * data = (char *)ConvertOggToPCM(f.size(),buf);
	f.close();

	 if (vi->channels == 1)
	    format = AL_FORMAT_MONO16;
	  else
	    format = AL_FORMAT_STEREO16;

	alGenBuffers(1,&buffer);
	alBufferData(buffer,format,data,uiPCMSamples * vi->channels * sizeof(short),vi->rate);

	source = 0;
	alGenSources(1, &source);
	alSourcei(source, AL_BUFFER, buffer);
}

Мы при загрузке приложения вызываем C++ метод loadAudio, который вызывает load у NativeCallListener, который и грузит звкуи:

void NativeCallListener:: load(){
	oalContext = new OALContext();
        //sound = new OALOgg();
	sound = new OALWav();

	char *  fileName = new char[64];
	strcpy(fileName, "audio/industrial_suspense1.wav");
	//strcpy(fileName, "audio/Katatonia - Deadhouse_(piano version).ogg");
	sound->load(mgr,fileName);
}

sound у меня типа OALSound. Для работы с WAV и Ogg у меня классы, которые наследуются от него. Нам для них необходимо лишь написать реализацию загрузки переопределив метод базового класса virtual void load(AAssetManager *mgr, const char* filename)= 0;
Это позволяет унифицировать работу со звуков.

Заключение

Ещё раз извиняюсь, что статья вышла довольно объёмная, иначе не представляю как написать. С помощью представленной реализации можно работать со звуком независимо от платформы. Скажем, если вы пишите движок игры для iOS и Android.

Есть тут нюанс — аудио грузится целиком. Поэтому для звуков такое решение отличное, но для музыки нет. Представьте, сколько будет памяти потреблять распакованная .ogg песня. Поэтому, будет отлично, если кто-то на основе этого решения напишет проигрывание аудио со стримингом, а не полной загрузкой в буфер.

Исходники

Проект написан на Eclipse. Исходники можно посмотреть на github.

P.S. жду критики и советов
P.P.S. если вы нашли грамматические ошибки в тексте, то лучше напишите в пм.

Автор: Suvitruf

Источник

Поделиться

* - обязательные к заполнению поля