NDK同步读取Unity资源
本文主要介绍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了, 步骤如下:
- 将ndk的目录配置到环境变量PATH里去
- 创建jni目录,把需要编译的cpp和头文件放进去
- 编写Android.mk和Application.mk, 相关文档参考这里
- 在终端执行如下命令:
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, 设置如下:
- 设置ndk的路径, File->Project Structure->SDK Location
- 右键你的工程, 选中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目录)上。