本文主要介绍Android Studio下, Java与JNI(c/c++)与Unity(c#)之间的交互, 并利用Android原生的接口,解决StreamingAssets目录下资源IO问题。因为默认情况下,Unity StreamingAssets的资源, 在apk安装之后依旧在apk里压缩文件里,只能通过Assetbundle(吐槽)接口来加载,并不能使用File这样的IO的接口来操纵。

AndroidStudio 生成jar包

在新建样例工程,鼠标右键点击app->New->选择Module

选择AndroidLibrary->点击Next

快速测试Java代码

快速测试library代码, 而不必在宿主程序里运行, 主要是为了节省时间。在自定义的类中 添加一个静态的main方法,如下:

public static void main(String[] args) {
    System.out.println("hello world");
}

然后在代码区鼠标右键, 就可以直接看到main函数的入口了:

Android 工程目录中,用于存放资源文件的文件夹有两个,分别为 res 和 assets。其中assets文件夹对应unity中streamingasset文件夹下资源, 下面列表列出二者的区别:

res asset
不支持深度子目录 可以使用任意深度的子目录进行存储
资源最终将被打包到编译后的 Java 文件中,可以直接通过 R 资源类访问 资源打包到应用程序中的静态文件,这些文件不会被编译,最终会直接部署到目标设备中;另外,不能直接通过 R 资源类读取,只能使用流的形式读取。
利用率较高 利用率相对较低

Android 的资源编译器 AAPT(Android Asset Packaging Tool)会依照资源所在的子目录及其格式对其进行编译。

java读取streamingasset文件夹下的资源, 即assets资源的代码:

InputStream ins = null;
try {
    // path是相对于streamingasset的相对路径
    ins = gameContext.getAssets().open(path);
    ByteArrayOutputStream os = new ByteArrayOutputStream();
    int idx = -1;
    while ((idx = ins.read()) != -1) {
        os.write(idx);
    }
    return os.toString();
}
catch (Exception e) {
    Log.e(TAG, e.getMessage());
}
finally {
    try {
        if (ins != null) ins.close();
    }
    catch (Exception e) {
        Log.e(TAG, e.getMessage());
    }
}

java读取Res资源, 即aapt编译资源代码:

// 文字
txtName.setText(getResources().getText(R.string.name));
// 图片
imgIcon.setBackgroundDrawableResource(R.drawable.icon);
// 颜色
txtName.setTextColor(getResouces().getColor(R.color.red));

打开Library工程的build.gardle文件,并添加代码

task makeJar(type: Copy) {
    delete 'build/libs/sdk.jar'
    from('build/intermediates/bundles/release/')
    into('build/libs/')
    include('classes.jar')
    rename('classes.jar', 'sdk.jar')
}

makeJar.dependsOn(build)

默认编译的代码都放在classes.jar里了, 所在目录build/intermediates/bundles/release,我们即需把相应对象拷贝出来即可并重名为sdk.jar.

打开Terminal控制台,输入指令 gradlew make

gradlew makejar

如果你是用的是mac版本的Android studio, 提示报错 gradlew command not found的话, 你可以给gradlew加个权限:

# 进入项目根目录
chmod +x gradlew
# make
./gradlew makejar

首次生成会慢一点,Success如下图,jar文件会出现在Library工程对应的build->libs->中,同样arr会出现在outputs中。如果你的library工程有依赖第三方的jar, 则可以把相应的jar都拷贝到跟build同级目录的libs目录, 然后sync一下即可。

关于gradlew更多的命令:

gradlew -v 版本号
gradlew clean 清除工程目录下的build文件夹
gradlew build 检查依赖并编译打包

这只是一种生成jar的方式, 你当然可以更直接的使用更原生的jar命令方式生成jar包。

JNI 开发

JNI是Java Native Interface的缩写,它提供了若干的API实现了Java和其他语言的通信(主要是C&C++).通俗来说,就是JAVA调用C/C++函数的接口.如果你要想调用C系列的函数,你就必须遵守这样的约定.

JNI接口都长什么样?

就一个native的关键字.

public  class NativeDemo {
	{
		/**
		 * 系统加载其他的语言的函数
		 */
		 System.loadLibrary("Utility");
	}
	/**
	 * 就这个natice关键字.标记了这个接口,看起来像是abstract
	 */
	public native void sayHello();
	
	public static void main(String[] args) {
		new NativeDemo().sayHello();
	}
}

sayHello()方法加了一个关键字native,就代表是一个native接口.执行这个方法时,会根据jni.h来找到真正的C来编写的sayHello()的实际函数.

jni.h是什么?

它实际上就存在%JAVA_HOME%\bin\include下面的一个文件,另外还有个%JAVA_HOME%\bin\include\win32下的jni_md.h.

这东西不说其他的作用,我也不清楚,只知道它里面存储了大量的函数和对象,它有个很好的方法就是通过native接口名,获取C函数.

打个比方类似如下:

public static String getCMethod(String javaMethodName);

它可以根据你的java接口,找到C函数并调用.

但这就意味着,你不能在C里随意写函数名,因为如果你写的java方法叫native aaa();C函数也叫aaa();但jni.h通过getCMethod(String javaMethodName)去找的结果是xxx();那这样就无法调用了.

既然不能随意写,怎么办? 没事,jdk提供了一个通过java方法生成c函数接口名的工具javah.

javah是什么?

就像java是运行main方法一样,javah就是提供具有native method的java对象的c函数接口.

dos命令如下:

javac NativeDemo.java
javah NativeDemo

这个命令可以提供一个c函数的接口.

上面那个NativeDemo被javah了之后就生成了一个文件Hello.h(可能我改了名字),就是C函数的接口.里面有方法名和返回值什么的. javah 生成的代码如下:

/* DO NOT EDIT THIS FILE - it is machine generated */
#include "jni.h"
/* Header for class NativeDemo */
 
#ifndef _Included_NativeDemo
#define _Included_NativeDemo
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     NativeDemo
 * Method:    sayHello
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_NativeDemo_sayHello
  (JNIEnv *, jobject);
 
#ifdef __cplusplus
}
#endif
#endif

最重要的C函数接口就是这样:JNIEXPORT void JNICALL Java_NativeDemo_sayHello(JNIEnv *, jobject);

JNIEXPORT :在Jni编程中所有本地语言实现Jni接口的方法前面都有一个”JNIEXPORT”,这个可以看做是Jni的一个标志,至今为止没发现它有什么特殊的用处。

void :这个学过编程的人都知道,当然是方法的返回值了。

JNICALL :这个可以理解为Jni 和Call两个部分,和起来的意思就是 Jni调用XXX(后面的XXX就是JAVA的方法名)。

Java_NativeDemo_sayHello:这个就是被上一步中被调用的部分,也就是Java中的native 方法名,这里起名字的方式比较特别,是:包名+类名+方法名。

JNIEnv * env:这个env可以看做是Jni接口本身的一个对象,jni.h头文件中存在着大量被封装好的函数,这些函数也是Jni编程中经常被使用到的,要想调用这些函数就需要使用JNIEnv这个对象。例如:env->GetObjectClass()。(详情请查看jni.h)

jobject obj:代表着native方法的调用者,本例即new NativeDemo();但如果native是静态的,那就是NativeDemo.class .

也就是说,我们的native sayHello()方法实际上是运行C的Java_NativeDemo_sayHello()这个方法,我们是不能随意写C函数名的的,只能这样写。

NDK 生成so

首先, 你需要去google官网下载NDK, 然后就可以使用ndk-build生成so了, 步骤如下:

  1. 将ndk的目录配置到环境变量PATH里去
  2. 创建jni目录,把需要编译的cpp和头文件放进去
  3. 编写Android.mk和Application.mk, 相关文档参考这里
  4. 在终端执行如下命令:
    ndk-build clean
    ndk-build
    

    就可以看到跟jni同级目录libs文件下生成的so文件了。

Android Studio也支持快速的ndk-build, 打开File->Settings->External Tools, 点击+, 做如图设置:

然后配置 ndk-build clean:

然后选中modile, 右键external tool -> ndk-build 就可以生成so了。

默认的话, 对于Android Studio对c++不会默认补全提示的, 为了友好的编辑c++代码,需要链接到ndk, 设置如下:

  1. 设置ndk的路径, File->Project Structure->SDK Location

  1. 右键你的工程, 选中Link C++ Project with Gradle, 在弹出的窗口中设置Android.mk的路径, 然后sync下, 就可以啦

如果你的代码引用了ndk库的内容, 如下:

#include <android/asset_manager.h>
#include <android/log.h>

那么在编写android.mk的时候, 需要加上如下声明:

LOCAL_LDLIBS += -llog
LOCAL_LDLIBS += -landroid

而如果使用忽略的方式编译出来的.so文件

# 设置忽略某些symblos
LOCAL_ALLOW_UNDEFINED_SYMBOLS := true

会在运行时的时候,报如下错误:

12-05 20:31:43.364 25559 25591 E Unity   : Unable to find NativeLib
12-05 20:31:43.475 25559 25591 E Unity   : DllNotFoundException: NativeLib
12-05 20:31:43.475 25559 25591 E Unity   : at (wrapper managed-to-native) Native
12-05 20:31:43.475 25559 25591 E Unity   : at test.NDKRead (System.String file)[0x
java调用c++代码

c++的函数声明如下:

// Java_(包名下划线分割)_类名_函数名

static AAssetManager *assetManager = nullptr;

JNIEXPORT void JNICALL Java_com_sdk_NativeHelper_SetAssetManager
        (JNIEnv *env, jclass jobj, jobject jassetManager) {
    LOGD("set asset manager");
    assetManager = AAssetManager_fromJava(env, jassetManager);
}

java 测需要声明一个native方法:

package com.sdk;

public static native void SetAssetManager(AssetManager assetManager);

StreamingAssets同步

在Unity开发当中StreamingAssets始终是一个比较重要的目录,里面一般会放一些重要的资源,配置等等,在PC以及iOS等平台,我们能够直接通过文件的形式访问到StreamingAssets文件夹下的文件,但是在安卓平台下,这些文件是经过压缩的。
实际上这些资源在android下是通过AssetManager进行管理的。我们可以通过Android的JavaAPI进行直接的读取。如下代码

但是在实际的使用过程当中我们会发现,如果我们将Java堆当中的bytes直接通过Unity提供的CallFunc的方式调用的话每一次调用都会带来相应bytes大小的GCAlloc,导致大量的GC出现,所以这个时候NDK就需要出来帮忙了。

实际上NDK中的C++代码不仅仅是java虚拟机可以通过jni的方式调用,C#也可以直接通过PInvoke的方式进行调用。

使用ndk读取assetmanager的代码如下:

#ifdef __cplusplus
extern "C" {
#endif

#define TAG    "native-read-bytes" 
#define LOGD(...)  __android_log_print(ANDROID_LOG_DEBUG,TAG,__VA_ARGS__) 

JNIEXPORT int32_t JNICALL ReadAssetsBytes(char* fileName, unsigned char** result){
    if(assetManager == nullptr) return -1;
    AAsset* asset = AAssetManager_open(assetManager, fileName, AASSET_MODE_UNKNOWN);
    if(asset == nullptr) return -2;
    off_t size = AAsset_getLength(asset);
    if(size > 0){
        *result = new unsigned char[size];
        AAsset_read(asset, *result, size);
    }
    AAsset_close(asset);
    return (int32_t)size;
}

JNIEXPORT int32_t JNICALL ReadAssetsBytesWithOffset(char* fileName, unsigned char** result, int32_t offset, int32_t length){

    if(assetManager == nullptr) return -1;
    AAsset* asset = AAssetManager_open(assetManager, fileName, AASSET_MODE_UNKNOWN);
    if(asset == nullptr) return -2;
    
    off_t size = AAsset_getLength(asset);
    if(size > 0){
        try {
            *result = new unsigned char[length];
            AAsset_seek(asset, offset, SEEK_SET);
            AAsset_read(asset, *result, length);
        }catch (std::bad_alloc){
            *result = nullptr;
            return -2;
        }
    }
    AAsset_close(asset);
    return (int32_t)length;
}

JNIEXPORT int32_t JNICALL ReadRawBytes(char* fileName, unsigned char** result){
    if(fileName == nullptr) return -1;
    FILE* file = fopen(fileName, "r");
    if(file == nullptr) return -2;
    fseek(file, 0L, SEEK_END);
    int32_t size = ftell(file);
    if(size <= 0) return -3;
    *result = new uint8_t[size];
    fseek(file, 0, SEEK_SET);
    fread(*result, sizeof(uint8_t), static_cast<size_t>(size), file);
    fclose(file);
    return size;
}

JNIEXPORT void JNICALL ReleaseBytes(unsigned char* bytes){
    delete[] bytes;
}

#ifdef __cplusplus
}
#endif

上述的方法第一个直接通过AssetManager读取文件,另外一个则以offset的形式进行读取AssetManager的文件,第三个方法则是直接读取文件,例如沙盒目录的文件就可以使用该方法读取,最后一个则是释放bytes。

接下来我们需要将bytes读取到C#当中:
我们先将方法导出到C#当中:

[DllImport(libName)]
public static extern int Add(int a, int b);

[DllImport(libName)]
public static extern int ReadRawBytes(string name, ref IntPtr ptr);

[DllImport(libName)]
public static extern void ReleaseBytes(IntPtr ptr);

这样我们就可以直接调用C++的方法了。可以看到我们这里使用了流作为缓冲区,流里面的buffer可以反复为我们利用,这样我们就可以通过Marshal.Copy不断将数据写入到流中,当我们需要使用的时候我们就可以直接读取数据。
下面同样的,是直接读取文件的方法:

MemoryStream stream = new MemoryStream();
var ptr = IntPtr.Zero;
int size = NativeRead.ReadAssetsBytes(file, ref ptr);
Debug.Log("native size: " + size);
if (size > 0)
{
    if (ptr == IntPtr.Zero)
    {
        Debug.LogError("read failed!");
    }
    else
    {
        stream.SetLength(size);
        stream.Position = 0;
        Marshal.Copy(ptr, stream.GetBuffer(), 0, size);
        var reader = new BinaryReader(stream);
        Debug.Log(reader.ReadString());
        NativeRead.ReleaseBytes(ptr);
    }
}

以上,通过这样的方法,我们可以毫无GC地同步读取Android下StreamingAssets的代码。如果不使用Java也不使用NDK的情况下,如果我们使用WWW去加载,不仅仅无法同步读取,其带来的GC也是不可小觑的。

总结

受限于Assetsbundle鸡肋的性能, 我更推荐于使用File(c#), fopen(c++))这种更原生的方式来进行io操作,这就意味着我们可以使用多线程/线程池来加载资源。

另一个使用场景是设置lua的搜索路径,我们把压缩成zip的lua文件放在StreamingAssets目录,第一次运行的时候把对应的资源copy到Persistent(SDCard, lua的搜索路径)目录,所有的lua文件都是使用原生的fopen来加载,再也不用unity的Resources.Load/Assetbundle.Load了。

平时我们的资源工作路径都是SteamingAssets, 或者打包的时候把资源转移到StreamAssets目录, 然后进行压缩,第一次运行的时候使用多线程解压到磁盘目录。线上版本需要热更的时候,直接下载到磁盘(Persistent目录)上。