Unity引擎热更新DLL
2016-10-28 10:46:010x01 前言
现在的手机游戏在运营期经常需要更新版本,虽然在发布渠道可以很便捷的更新 APK,但是仍然需要用户主动点击更新并且重新经历一遍安装过程,带来用户流失的可能性很大。所以行业中有许多的热更新方案,Unity 官方也提供了 AssetBundle 的方式可以动态加载资源。然而 AssetBundle 的方式在移动平台上不能动态加载代码(可以将代码打包进 Bundle 中,PC 可以动态加载),所以官方对于需要动态更新代码的需求是没办法实现的。于是很多团队在 Unity 游戏中使用了 lua 这类脚本语言,方便热更新。但是也带来了一些弊端,首先是 C# 本身已经是一个动态语言,在其基础上打造的 lua 虚拟机性能上就已经有些许损失了,另外就是 C# 中的各种 API 需要在 lua 中进行绑定,是一件十分繁琐而且易出错的事情。
0x02 起因
最近在研究怎样把安装包体积较大的 APP 缩小体积,在运行之后再动态从外部加载资源。在研究 Unity 游戏类型 APP 的时候误打误撞发现了这个可以用来在外部加载代码和资源的方法,注意哦,代码和资源都可以动态加载,而且是不需要在开发流程上做任何的改变,不需要使用 AssetBundle,不需要使用 lua。
0x03 原理
首先需要知道的是,Android 平台上,一个 APK 安装之后并没有把所有的文件解压到手机中,而是把 APK 文件原封不动的放在 /data/app/ 目录下,在 APP 运行过程中,无论是通过 Java 的 AssetManager 还是通过 JNI 的 AAssetManager_open 函数,都是将这个压缩包读取到内存,然后解压出需要的子文件。
但是在 Cocos2D-X 2.X 版本中,是通过 libz 的接口将 APK 文件当作压缩包来读取资源的,所以我怀疑 Unity 也没有通过 AndroidSDK 中提供的接口读取 assets 目录下的文件。
在反编译 unity-classed.jar 之后可以看到 UnityPlayer 构造函数中有 this.nativeFile(this.m.getPackageCodePath());
的一行代码。而 nativeFile 是一个 native 函数:private final native void nativeFile(String filepath);
。搜索 nativeFile 关键字,发现还在另一个函数中有使用:
private void j() {
if(this.x.getBoolean("useObb")) {
String[] var1;
int var2 = (var1 = a((Context)this.m)).length;
for(int var3 = 0; var3 < var2; ++var3) {
String var4;
String var5 = a(var4 = var1[var3]);
if(this.x.getBoolean(var5)) {
this.nativeFile(var4);
}
this.x.remove(var5);
}
}
}
private void j()
这个函数是在 UnityPlayer 的构造函数中调用完 nativeFile 之后调用的,观察发现 var4 这个变量是从以下函数中返回:
private static String[] a(Context var0) {
String var1 = var0.getPackageName();
Vector var2 = new Vector();
int var6;
try {
var6 = var0.getPackageManager().getPackageInfo(var1, 0).versionCode;
} catch (NameNotFoundException var5) {
return new String[0];
}
if(Environment.getExternalStorageState().equals("mounted")) {
File var3 = Environment.getExternalStorageDirectory();
if((var3 = new File(var3.toString() + "/Android/obb/" + var1)).exists()) {
String var4;
if(var6 > 0) {
var4 = var3 + File.separator + "main." + var6 + "." + var1 + ".obb";
if((new File(var4)).isFile()) {
var2.add(var4);
}
}
if(var6 > 0) {
var4 = var3 + File.separator + "patch." + var6 + "." + var1 + ".obb";
if((new File(var4)).isFile()) {
var2.add(var4);
}
}
}
}
String[] var7 = new String[var2.size()];
var2.toArray(var7);
return var7;
}
其中有个 obb 文件后缀。Google 之后发现,这个文件是用来扩展 APK 资源的。看来 nativeFile 这个接口就是 Unity 从 Java 层将存在资源文件的压缩包路径传入 native 层的接口。
0x04 实现
知道原理之后,实现就很简单了,也有很多的实现方法,可以需要根据不同的业务需求进行设计。下面是我想到的一种方式: 打包 APK 时,导出 Android Project(一般接入SDK的游戏都会这样打包)。将 new UnityPlayer 修改为类似以下代码:
String path_to_assets_zip; // 下面会说明
boolean update = check_update(); // 检查是否有更新,或者是否第一次运行
download_update(); // 是的话从网络加载 zip 并且存放至 path_to_assets_zip
mUnityPlayer = new UnityPlayer(this);
try {
Method method = mUnityPlayer.getClass().getDeclaredMethod("nativeFile", String.class);
method.setAccessible(true);
method.invoke(mUnityPlayer, path_to_assets_zip);
} catch (Exception e) {
e.printStackTrace();
}
注意,检查更新和下载过程完成之后才能构造 UnityPlayer。另外,invoke method 的时候,传入的参数是一个路径,这个路径指向的需要是一个与 APK 文件压缩方式相同的压缩包,压缩包中存放需要更新的文件。 然后打包生成 APK,复制出另一个 APK,我们称为 APK2,然后用压缩软件,例如 7-zip 打开 APK2,将 assets/bin/Data 目录下除了 settings.xml 文件之外的全部文件删除,然后保存 APK2。此时的 APK2 仍然是一个可以安装运行正常签名的 APK2,但是不包含任何资源文件和编译成 DLL 的 C# 代码,这个 APK2 就是可以发给渠道的安装包。而复制前的完整 APK 其实就是网络更新的资源和代码,为了减少下载的大小,可以将这个 APK 中除了 assets 目录以外的文件和目录全部删除。 以后的版本更新,就可以用 Unity 打包出 APK,然后删除掉除了 assets 目录以外的文件和目录,就OK了。如果需要的话,还可以和以前版本进行差分,生成差分文件,减少用户的下载。
0x05 展望
上面我提出的这种方法,一方面可以完成动态更新,一方面也减小了 APK 安装包,但是也带来一个弊端,就是用户在安装完游戏之后不能马上玩,必须从网络再下载一个资源包才能进入游戏。虽然现在市面上已经有很多大厂的游戏也是这么做的(这里吐槽一下网易的《天下》,竟然要下载几百M的资源才能进游戏),但是对于用户体验来说确实是一个非常大的短板。但是我觉得可能还是有办法能解决的,因为 nativeFile
这个方法在 Unity 本身也会多次调用,所以肯定存在优先级的问题,只要多尝试几次,推测出 Unity 内部是怎样的一个优先级来读取文件的,就能做到替换更新。
转载请注明出处:http://leafnsand.com/post/dynamic_update_unity_dll_on_android