背景

最近RN的项目中用的音频文件是fmod的.bank 格式的,关于fmod是什么,可以参考fmod的官网(https://www.fmod.com/),或者自己去网上搜索。本项目的核心诉求是,在播放音频的同时,音频会不定时的产生事件回调,通知到RN,然后RN根据事件来绘制不同的页面(展示不同的内容)。

在网上根本找不到任何RN使用fmod的资料,退一步,iOS和Android 使用fmod的资料也是少之又少,都看了一遍之后,毫无帮助,没办法,只能靠自己去尝试了。

从fmod官网下载了iOS、Android、H5的相关库,里面的例子全是C++的,iOS和Android的项目工程文件,根本打不开,官方指导也没有说明第一步、第二步等等分别调用什么API。fmod 以前是做PC上的游戏的,他对移动端的支持非常有限,从文档不全就能略知一二。

本文就梳理一下自己摸索的流程和踩过的坑。

iOS

首先还是必须去官网下载fmod的API文档(https://www.fmod.com/download),虽然看了文档也是一脸懵逼,但是聊胜于无,也必须看,因为所有的尝试还是需要依据官方文档来进行。

这个要分成两部分来讲,因为iOS和Android 两个平台,要分别写两套代码,虽然核心使用fmod api的部分是一样的。

对于,iOS的平台,需要提前知道的知识点:

  • 会react-native(废话,不会这个就不会有这一堆问题了);
  • 稍微会一点点Object-c;
  • 会OC和C++的混编;

基本思路是在RN界面点击播放按钮,然后将音频的相关URL传递给iOS的native函数,下载音频文件,调用FMOD STUDIO API 播放、暂停、停止等操作,同时给播放时注册回调函数,在回调函数中回去数据再回调给RN,然后更新界面。

下面给出关键步骤的部分代码。

React-Native 调用iOS Native 接口

在RN端是通过NativeModules 来实现的

import {NativeModules} from 'react-native';

// OpenNativeModule 这个是iOS端和Android端自己定义和实现的module
var nativeModule = NativeModules.OpenNativeModule;

...

// 调用的时候
nativeModule.testNativeDownloadFile(params);
// testNativeDownloadFile 是OpenNativeModule中实现的 method

RN端的核心代码就这几行,剩下的是iOS端的实现。

// 所有能被RN调用的方法,需要用RCT_EXPORT_METHOD声明
RCT_EXPORT_METHOD(testNativeDownloadFile:(NSDictionary *)dict) {
  NSArray *url_list = [dict objectForKey:@"url_list"];
  NSMutableArray __block *file_list = [NSMutableArray arrayWithCapacity:url_list.count];
  NSInteger __block task_count = 0;
  for (int i = 0; i < url_list.count; ++i) {
    // 替换url中的空格,否则ios不支持,会有错误码-1002
    NSString *tmpUrl = [url_list[i] stringByReplacingOccurrencesOfString:@" " withString:@"%20"];
    NSURL* url = [NSURL URLWithString:tmpUrl];
    NSLog(@"xxx log: download url: %@", tmpUrl);
    // 得到session对象
    NSURLSession* session = [NSURLSession sharedSession];
    
    // 创建任务
    
    NSURLSessionDownloadTask* downloadTask = [session downloadTaskWithURL:url completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) {
      //NSLog(@"xxx log: file path: %@", location.path);
      // NSLog(@"xxx log: file name: %@", response.suggestedFilename);
      //NSLog(@"xxx log: error code: %@", error);   // 如果有异常,输出错误信息
      // [file_list addObject:response.suggestedFilename];
      
      NSString *caches = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
      // response.suggestedFilename :建议使用的文件名,一般跟服务器端的文件名一致
      NSString *file = [caches stringByAppendingPathComponent:response.suggestedFilename];
      
      // 将临时文件剪切或者复制Caches文件夹
      NSFileManager *mgr = [NSFileManager defaultManager];
      
      // AtPath : 剪切前的文件路径
      // ToPath : 剪切后的文件路径
      [mgr moveItemAtPath:location.path toPath:file error:nil];
      
      NSLog(@"xxx log: file path:%@", file);
      
      [file_list addObject:file];
      NSLog(@"xxx log: LINE:%d, file list: %lu", __LINE__, file_list.count);
      --task_count;  // 任务完成
      }];
    // 开始任务
    ++task_count;
    [downloadTask resume];
    NSLog(@"xxx log: finish");
    
    //break;
  }
  while (true) {
    if (task_count <= 0) {
      NSLog(@"xxx log: LINE:%d, file list: %lu", __LINE__, file_list.count);
      for (int i = 0; i < file_list.count; ++i) {
        NSLog(@"xxx log: LINE:%d, file_name: %@", __LINE__, file_list[i]);
      }
      break;
    }
  }

播放FMOD Studio 的 bank 音频

由于FMOD官方给的API都是C/C++的,所以这里需要OC和C++混编来实现音频播放,上面的调用Native接口的代码已经把所有的音频文件下载好了,剩下的就是加载相关的库来播放。


FMOD::Studio::System* gsystem = NULL;
FMOD::Studio::EventDescription * geventDesc = NULL;
FMOD::Studio::EventInstance * gengine = NULL;

const int BANK_COUNT = (int)file_list.count;
  FMOD::Studio::Bank* banks[BANK_COUNT];
  if (gsystem == NULL) {
    NSLog(@"xxx log: LINE:%d, init fmod studio system", __LINE__);
    //FMOD::Studio::System::create(&gsystem);
  } else {
    NSLog(@"xxx log: LINE:%d, unloadAll fmod studio system", __LINE__);
    //gengine->stop(FMOD_STUDIO_STOP_IMMEDIATE);
    //gsystem->unloadAll();
    gsystem->release();
  }
  FMOD::Studio::System::create(&gsystem);
  gsystem->initialize(1024, FMOD_STUDIO_INIT_NORMAL, FMOD_INIT_NORMAL, 0);
  gsystem->setCallback(studioCallback, FMOD_STUDIO_SYSTEM_CALLBACK_BANK_UNLOAD);
  for (int i = 0; i < file_list.count; ++i) {
    gsystem->loadBankFile([file_list[i] cStringUsingEncoding:NSUTF8StringEncoding], FMOD_STUDIO_LOAD_BANK_NORMAL, &banks[i]);
  }
  gsystem->update();
  NSString *eventStr = [@"event:/" stringByAppendingString: fmod_name];
  NSLog(@"xxx log: LINE:%d, event_file: %@", __LINE__, eventStr);
  gsystem->getEvent([eventStr cStringUsingEncoding:NSUTF8StringEncoding], &geventDesc);
  // 控制延迟的,暂时不用
  //FMOD::System *lowLevelSystem;
  //gsystem->getLowLevelSystem(&lowLevelSystem);
  //lowLevelSystem->setDSPBufferSize(4096, 2);
  //gsystem->getEvent("event:/xxxxx", &geventDesc);
  geventDesc->createInstance(&gengine);
  gengine->start();
  gsystem->update();
}


// Callback to free memory-point allocation when it is safe to do so
//
FMOD_RESULT F_CALLBACK studioCallback(FMOD_STUDIO_SYSTEM *system, FMOD_STUDIO_SYSTEM_CALLBACK_TYPE type, void *commanddata, void *userdata)
{
  if (type == FMOD_STUDIO_SYSTEM_CALLBACK_BANK_UNLOAD)
  {
    // For memory-point, it is now safe to free our memory
    FMOD::Studio::Bank* bank = (FMOD::Studio::Bank*)commanddata;
    void* memory;
    ERRCHECK(bank->getUserData(&memory));
    if (memory)
    {
      free(memory);
    }
  }
  return FMOD_OK;
}

现在,已经可以正常播放FMOD的音频了,接下来我们要获取音频播放过程中的事件,这个相对就很简单了,设置一下回调就ok。

gengine->setCallback(markerCallback,
                       FMOD_STUDIO_EVENT_CALLBACK_TIMELINE_MARKER
                       | FMOD_STUDIO_EVENT_CALLBACK_TIMELINE_BEAT
                       | FMOD_STUDIO_EVENT_CALLBACK_SOUND_PLAYED
                       | FMOD_STUDIO_EVENT_CALLBACK_SOUND_STOPPED);

// marker的回调
FMOD_RESULT F_CALLBACK markerCallback(FMOD_STUDIO_EVENT_CALLBACK_TYPE type, FMOD_STUDIO_EVENTINSTANCE *event, void *parameters) {
  cout << "LINE:" << __LINE__ << ",xxx log: type:" << type << ",obj:" << FMOD_STUDIO_EVENT_CALLBACK_TIMELINE_MARKER << endl;
  if (type == FMOD_STUDIO_EVENT_CALLBACK_TIMELINE_MARKER) {
    FMOD_STUDIO_TIMELINE_MARKER_PROPERTIES* props = (FMOD_STUDIO_TIMELINE_MARKER_PROPERTIES*)parameters;
    ///TODO
  }
  ...
  return FMOD_OK;
}

接下来需要把音频的回调传递给RN侧,iOS侧通过一个事件通知来做。

// native 主动通知 rn端
- (instancetype)init {
  self = [super init];
  if (self) {
    NSNotificationCenter *defaultCenter = [NSNotificationCenter defaultCenter];
    [defaultCenter removeObserver:self];
    [defaultCenter addObserver:self
                      selector:@selector(sendCustomEvent:)
                          name:@"sendCustomEventNotification"
                        object:nil];
  }
  return self;
}
- (void)sendCustomEvent:(NSNotification *)notification {
  NSString *name = notification.object;
  NSLog(@"LINE: %d,xxx log: notification:%@", __LINE__,name);
  [self sendEventWithName:@"customEvent" body:name];
}

RN侧接收事件,然后进行相应渲染。

componentDidMount() {
    let eventEmitter = new NativeEventEmitter(nativeModule);
    this.listener = eventEmitter.addListener("customEvent", this.listenCallback);
  }
  listenCallback(item) {
    console.log("native notition:"+item);
  }

以上,就是在iOS上播放fmod studio 的 bank 音频的整个流程。

Android

同样由于FMOD的API是c/c++的,所以Android端只能用JNI来实现。这里和iOS不同的地方在于,在iOS上,OC和C/C++可以混编,所以可以同时实现,但是Android端的java和C/C++不能混编,只能分开弄,也就是对音频文件的下周再Android做,调用音频播放接口在C/C++这里做。

下载音频文件的代码如下:

// 能够被rn调用的方法使用@ReactMethod声明
@ReactMethod
public void testNativeDownloadFile(ReadableMap map) {
    // 测试文件下载
    //System.out.print(map);
    Log.d("xxx", map.getString("event"));
    ReadableArray urlList = map.getArray("url_list");
    for (int i = 0; i < urlList.size(); ++i) {
        Log.d("xxx", "url:"+urlList.getString(i));
    }
    // /storage/emulated/0
    String path = Environment.getExternalStorageDirectory().getAbsolutePath();
    Log.d("xxx","path: " + path);
    String path1 = Environment.getDataDirectory().getPath();
    Log.d("xxx","path1: " + path1);   // /data
    String path2 = Environment.getDownloadCacheDirectory().getPath();
    Log.d("xxx","path2: " + path2);   // /data/cache
    String path3 = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getPath();
    Log.d("xxx","path3: " + path3);   // /storage/emulated/0/Download
    String path4 = Environment.getExternalStorageDirectory().toString();
    Log.d("xxx","path4: " + path4);
    String path5 = mReactContext.getExternalFilesDir(null).toString();
    Log.d("xxx","path5:"+path5);  // /storage/emulated/0/Android/data/com.test_rn_native/files

    String namePre = map.getString("name_pre");
    String fmodName = map.getString("event");

    String[] fileList = new String[urlList.size()];

    for (int i = 0; i < urlList.size(); ++i) {
        String fileName = downloadFile(urlList.getString(i), namePre);
        fileList[i] = fileName;
    }
    for (int i = 0; i < fileList.length; ++i) {
        File f = new File(fileList[i]);
        if (f.exists() && f.isFile()) {
            Log.d("xxx", fileList[i] + " size:" + f.length());
        } else {
            Log.d("xxx", "file not exists:" + fileList[i]);
        }
    }
}
public String downloadFile(String url, String namePre) {
    Log.i("xxx","downloadFile step into,url=" + url);
    String filePath = "";
    try {
        String downPath = mReactContext.getExternalFilesDir(null).toString() + "/";
        long startTime = System.currentTimeMillis();
        String filename = namePre + url.substring(url.lastIndexOf("/") + 1);
        Log.i("xxx","start download");
        URL myURL = new URL(url);
        URLConnection conn = myURL.openConnection();
        conn.connect();
        InputStream is = conn.getInputStream();
        int fileSize = conn.getContentLength();//根据响应获取文件大小
        if (fileSize <= 0) throw new RuntimeException("无法获知文件大小 ");
        if (is == null) throw new RuntimeException("stream is null");
        File file1 = new File(downPath);
        if (!file1.exists()) {
            file1.mkdirs();
        }
        filePath = downPath + filename;
        FileOutputStream fos = new FileOutputStream(filePath);
        byte buf[] = new byte[4096];
        int downLoadFileSize = 0;
        do{
            //循环读取
            int numread = is.read(buf);
            if (numread == -1)
            {
                break;
            }
            fos.write(buf, 0, numread);
            downLoadFileSize += numread;
            //更新进度条
        } while (true);
        Log.i("xxx","download success:" + filePath);
        Log.i("xxx","totalTime="+ (System.currentTimeMillis() - startTime));
    } catch (Exception ex) {
        Log.e("xxx", "error: " + ex.getMessage(), ex);
    }
    return filePath;
}

然后把下载的文件传递给JNI层,这里有一点要特别注意, JNI 的编译,只能用CMAKE,老的方式是用NDK编译,但是会有各种各样的问题,用cmake编译,一切都会变得简单。

JNI的代码如下:

static FMOD::Studio::System* gsystem = NULL;
static FMOD::Studio::EventDescription * geventDesc = NULL;
static FMOD::Studio::EventInstance * gengine = NULL;
static bool gStop = false;

// 回调使用
JavaVM *gVM = NULL;
jobject gObj;

//
// Callback to free memory-point allocation when it is safe to do so
//
FMOD_RESULT F_CALLBACK studioCallback(FMOD_STUDIO_SYSTEM *system, FMOD_STUDIO_SYSTEM_CALLBACK_TYPE type, void *commanddata, void *userdata)
{
    if (type == FMOD_STUDIO_SYSTEM_CALLBACK_BANK_UNLOAD)
    {
        // For memory-point, it is now safe to free our memory
        FMOD::Studio::Bank* bank = (FMOD::Studio::Bank*)commanddata;
        void* memory;
        // ERRCHECK(bank->getUserData(&memory));
        bank->getUserData(&memory);
        if (memory)
        {
            free(memory);
        }
    }
    return FMOD_OK;
}

// marker的回调
FMOD_RESULT F_CALLBACK markerCallback(FMOD_STUDIO_EVENT_CALLBACK_TYPE type, FMOD_STUDIO_EVENTINSTANCE *event, void *parameters) {
    //cout << "LINE:" << __LINE__ << ",xxx log: type:" << type << ",obj:" << FMOD_STUDIO_EVENT_CALLBACK_TIMELINE_MARKER << endl;
    if (type == FMOD_STUDIO_EVENT_CALLBACK_TIMELINE_MARKER) {
        FMOD_STUDIO_TIMELINE_MARKER_PROPERTIES* props = (FMOD_STUDIO_TIMELINE_MARKER_PROPERTIES*)parameters;
         LOGD("jni params marker: %s",props->name);
        // [[NSNotificationCenter defaultCenter] postNotificationName:@"sendCustomEventNotification" object:[NSString stringWithUTF8String:props->name]];
        // 回调java代码
        // 获取方法ID, 通过方法名和签名, 调用静态方法
        // 非静态方法需要创建实例
        JNIEnv *env;
        int getEnvStat = gVM->GetEnv((void **) &env,JNI_VERSION_1_6);
        if (getEnvStat == JNI_EDETACHED) {
            //如果没有,主动附加到jvm环境中,获取到env
            if (gVM->AttachCurrentThread(&env, NULL) != 0) {
                return FMOD_OK;
            }
        }
        //通过全局变量g_obj 获取到要回调的类
        jclass javaClass = env->GetObjectClass(gObj);
        if (javaClass == 0) {
            LOGD("Unable to find class");
            gVM->DetachCurrentThread();
            return FMOD_OK;
        }
        jmethodID mid = env->GetMethodID(javaClass, "callMethod", "(Ljava/lang/String;)V");
        if (mid == NULL) {
            LOGD("Unable to find method:callMethod");
            return FMOD_OK;
        }

        env->CallVoidMethod(gObj, mid, env->NewStringUTF(props->name));
    }
    return FMOD_OK;
}


JNIEXPORT jstring JNICALL Java_com_test_1rn_1native_OpenNativeModule_testParams
  (JNIEnv *env, jobject obj, jobjectArray urlList, jstring fmodName) {
    LOGD("STEP into native method");
    jboolean isCopy = false;
    const char *str = env->GetStringUTFChars(fmodName, &isCopy);
    if (str == NULL) {
        LOGD("this error");
        return NULL;
    }
    LOGD("fmodName:%s", str);
    string fmodNameStr = str;

    int len = env->GetArrayLength(urlList);
    vector<string> fileList;
    for (int i = 0; i < len; ++i) {
        jstring jstr = (jstring)env->GetObjectArrayElement(urlList, i);
        string url = env->GetStringUTFChars(jstr, NULL);
        fileList.push_back(url);
    }

    for (int i = 0; i < fileList.size(); ++i) {
        LOGD("file %d: %s",i, fileList[i].c_str());
    }

    //FMOD::Studio::System* gsystem;
    //FMOD::Studio::EventDescription * geventDesc;
    //FMOD::Studio::EventInstance * gengine;

    const int BANK_COUNT = fileList.size();
    FMOD::Studio::Bank* banks[BANK_COUNT];

    if (gsystem == NULL) {
        LOGD("init fmod studio");
    } else {
        LOGD("release fmod studio");
        gsystem->release();
    }
    FMOD::Studio::System::create(&gsystem);
    gsystem->initialize(1024, FMOD_STUDIO_INIT_NORMAL, FMOD_INIT_NORMAL, 0);
    gsystem->setCallback(studioCallback, FMOD_STUDIO_SYSTEM_CALLBACK_BANK_UNLOAD);
    for (int i = 0; i < fileList.size(); ++i) {
        gsystem->loadBankFile(fileList[i].c_str(), FMOD_STUDIO_LOAD_BANK_NORMAL, &banks[i]);
    }
    gsystem->update();

    string eventStr = "event:/" + fmodNameStr;

    LOGD("event:%s",eventStr.c_str());

    gsystem->getEvent(eventStr.c_str(), &geventDesc);
    geventDesc->createInstance(&gengine);

    gengine->setCallback(markerCallback,
                         FMOD_STUDIO_EVENT_CALLBACK_TIMELINE_MARKER
                         | FMOD_STUDIO_EVENT_CALLBACK_TIMELINE_BEAT
                         | FMOD_STUDIO_EVENT_CALLBACK_SOUND_PLAYED
                         | FMOD_STUDIO_EVENT_CALLBACK_SOUND_STOPPED);

    env->GetJavaVM(&gVM);
    gObj = env->NewGlobalRef(obj);

    gengine->start();
    gsystem->update();

    return env->NewStringUTF("success testParams");
}

JNIEXPORT void JNICALL Java_com_test_1rn_1native_OpenNativeModule_testFmodPause
        (JNIEnv *env, jobject obj) {
    if (gsystem == NULL) {
        return;
    }
    bool paused = false;
    int ret = gengine->getPaused(&paused);
    LOGD("pause: %d, ret:%d", paused, ret);
    ret = gengine->setPaused(!paused);
    gsystem->update();
}

JNIEXPORT void JNICALL Java_com_test_1rn_1native_OpenNativeModule_testFmodStop
        (JNIEnv *env, jobject obj) {
    if (gsystem == NULL) {
        return;
    }
    if (gStop == false) {
        gengine->stop(FMOD_STUDIO_STOP_IMMEDIATE);
        gStop = true;
    } else {
        gengine->start();
        gStop = false;
    }

    gsystem->update();
    LOGD("stop status: %d", gStop);
}

需要修改app/build.gradle 文件,才能把fmod相关的.so 打包进apk中。

    sourceSets.main {
       jniLibs.srcDirs = ['src/main/jniLibs']

    }

要解决react-native 和 FMOD之后引起的32-bit和64-bit 的arm结构不兼容问题,也需要修改app/build.gradle.

defaultConfig {
    applicationId "com.test_rn_native"
    minSdkVersion 16
    targetSdkVersion 22
    versionCode 1
    versionName "1.0"
    ndk {
        abiFilters "armeabi-v7a", "x86"
    }

    externalNativeBuild {
        cmake {
            cppFlags ""
            abiFilters "armeabi-v7a", "x86"
        }
    }
    packagingOptions {
        exclude "lib/arm64-v8a"    // 排除这个文件夹
    }
}

现在,Android端应该也能正常播放fmod studio bank的音频文件了。最后一步,在rn端接受Android的回调。

componentDidMount() {
    let eventEmitter = new NativeEventEmitter(nativeModule);
    this.listener = eventEmitter.addListener("customEvent", this.listenCallback);
    DeviceEventEmitter.addListener('customEvent', this.listenCallback);
  }
  listenCallback(item) {
    console.log("native notition:"+item);
  }

整个项目的demo工程已经放到GitHub,在几乎没有资料的情况下,自己摸索和尝试,记录一下,给后面有需要的朋友一些参考,当然上面并没有列全我这个过程碰到的各种问题。