游戏开发中实现热更新可以实现无须重新打包,无须发布市场,无须等待审核,只需要将更新包放到服务器上,客户端就可以直接下载更新包来实现游戏的更新,在游戏后期的维护过程中,能为开发者提供十分的便利,正所谓工欲善其事,必先利其器。这篇文章就来说说如何在Libgdx中实现游戏的热更新。
原理
要实现游戏的热更新,首先必须对编译原理有一定的了解,不用掌握技术细节,但是基本流程是必须知道的。我们知道Libgdx的开发语言是Java,Java是一种静态语言,必须先编译成字节码才能在虚拟机中执行。我们正常开发的Java程序都会被编译成class文件,这个class文件就是字节码,程序执行的时候会由操作系统启动一个Java虚拟机,虚拟机再加载字节码,然后再去执行。所以要实现热更新,首先要实现的就是字节码的动态加载,好在Java为我们提供了ClassLoader类,这个类就是专门加载字节码的,虚拟机启动后会首先创建一个ClassLoader,加载程序中已经打包好的class文件,如果我们要加载其他的class,只需创建一个新的ClassLoader即可。当然其过程中需要注意的细节很多,待会儿再来细说。需要注意上面说的是在JAVA桌面程序中,如果是在Android,字节码是以dex为后缀名的文件,它是供Android中的虚拟机(Dalvik或Art)来加载执行的。那么在IOS上呢,IOS中并没有Java虚拟机,但是libgdx的跨平台解决方案使用了Multi-OS Engine( MOE ),来在IOS中运行Java,关于MOE的细节请自行查阅资料,这里只需知道Moe为我们在IOS中提供了一个Art虚拟机,它也是执行的dex文件。这样一来,同一份dex文件可以在android和ios上执行,简直不能再完美了。
理论说太多也没用,下面跟着一起做一个Libgdx的热更新Demo吧。
框架搭建
首先我们来搭建一个基于热更新的简单框架。
1.创建工程
使用Libgdx 1.9.5创建一个基本的libgdx工程,然后在Android Stuio中打开。这个工程包含了Desktop、Android、Ios-moe三个模块,当然还有必须的Core模块。
2.添加Game模块
用AndroidStudio为我们工程增加一个Java Library模块,命名为games,我们的游戏代码将放在这个模块里,以便将需要热更新的内容和主程序分开。Game模块创建好了之后,在它下面会自动生成一个build.gradle文件,我们需要在这里添加core模块的依赖:
dependencies {
//添加core模块的依赖
compile project(":core")
compile fileTree(dir: 'libs', include: ['*.jar'])
}
接下来还要在Game模块中新建一个assets文件夹,用来存放游戏的资源。注意android模块下也有一个assets文件夹,是用来存放主程序资源的。
因为只是demo,这里就把Game模块的代码一起写了吧。我新建了一个GameStage类,继承Stage,并添加了一张我网站的logo展示。注意这里的资源已经不是程序包里面的,而应该是外部资源了,所以需要从外部传入一个资源路径。
public class GameStage extends Stage {
//需要主程序传入资源目录,传入MainGame以便能返回
public GameStage(final MainGame mainGame, String assetsDir) {
//添加一个logo图片,注意这里使用的是绝对路径
Image logo = new Image(new Texture(Gdx.files.absolute(assetsDir + "logo.png")));
logo.setPosition(getWidth() * 0.5f, getHeight() * 0.5f, Align.center);
addActor(logo);
//给logo添加事件
logo.addListener(new ClickListener() {
@Override
public void clicked(InputEvent event, float x, float y) {
//点击将返回到mainStage
mainGame.changeStage(mainGame.mainStage);
}
});
}
}
这个时候,Game模块应该是这样的:
这里的libs文件夹,也可以放一些Game模块需要的jar包,但是打包dex的时候,需要将jar一起打入,比较麻烦,还是建议jar包统一放到主程序中。
3.添加跨平台接口
因为android和ios实现热更新有些不同,而且core里面是无法使用dex的Loader的,所以需要定义一个跨平台的接口,然后在各个平台实现。在Core模块定义接口如下:
public interface HotUpdate {
//获取游戏资源文件路径
String getAssetsDir();
//加载dex文件,并返回一个Class对象,若发成错误抛出异常
Class loadDex(String dexPath, String className) throws Exception;
}
分别在Android和IOS模块下实现这个接口:
public class HotUpdateAndroid implements HotUpdate {
private String assetsDir;//游戏资源文件目录
private Context ctx;//AndroidContext
public HotUpdateAndroid(Context ctx) {
this.ctx = ctx;
//Android的data目录,可以随apk卸载一起删除,并且资源文件的图片不会出现在相册中
assetsDir = ctx.getExternalFilesDir("").getAbsolutePath() + "/";
}
@Override
public String getAssetsDir() {
return assetsDir;
}
@Override
public Class loadDex(String dexPath, String className) throws Exception {
DexClassLoader loader = new DexClassLoader(dexPath, ctx.getCacheDir().getAbsolutePath(), null, ctx.getClassLoader());
Class claz = loader.loadClass(className);
return claz;
}
}
public class HotUpdateIos implements HotUpdate {
@Override
public String getAssetsDir() {
//ios下资源存储目录,等效于 Gdx.files.getExternalStoragePath()
return System.getenv("HOME") + "/Documents/";
}
@Override
public Class loadDex(String dexPath, String className) throws Exception {
//初始化一个PathClassLoader,加载dex文件
PathClassLoader loader = new PathClassLoader(dexPath, getClass().getClassLoader());
Class claz = loader.loadClass(className);
return claz;
}
}
稍微讲解一下PathClassLoader,它是android dalvik下的类,继承自Java的ClassLoader,可以用它来直接加载dex文件。和它一起的还有一个DexClassLoader,它也可以加载dex文件,同时它还能加载apk和jar中的dex。这里Android用的是DexClassLoader而IOS用的是PathClassLoader,其实都是可以的。另外不管是哪一个ClassLoader,其构造方法中有个参数是必不可少的,必须传入一个Parent ClassLoader,它的作用就是使新创建的Loader能够直接访问Parent Loader的类。
好了,接口定义完成,以后我们就只需要在core里面调用HotUpdate接口就能实现热更新的功能了。
MainGame类实现
在Core模块下创建一个MainGame类,并继承ApplicationAdapter。我在MainGame里添加了一个舞台mainStage,并添加了一张图片,点击图片就会调用HotUpdate的方法跳转到游戏界面了。因为HotUpdate只是一个接口,我们需要从各个平台传入它的实例,所以在MainGame的构造方法中传入一个参数。另外我还定义了一个切换舞台的方法,以便在游戏界面中也能返回到主界面。MainGame类代码如下:
public class MainGame extends ApplicationAdapter {
public Stage currentStage, mainStage;
HotUpdate hotUpdate;
public MainGame(HotUpdate hotUpdate) {
this.hotUpdate = hotUpdate;
}
@Override
public void create() {
//添加舞台,并添加图片
mainStage = new Stage();
Image img = new Image(new Texture("badlogic.jpg"));
mainStage.addActor(img);
changeStage(mainStage);
//给图片添加监听
img.addListener(new ClickListener() {
@Override
public void clicked(InputEvent event, float x, float y) {
try {
//加载dex文件,并返回GameStage的Class对象
String className = "com.ayocrazy.tutorial.games.GameStage";//完整类名
Class claz = hotUpdate.loadDex(hotUpdate.getAssetsDir() + "game.dex", className);
//用Class对象初始化一个Stage
Stage gameStage = (Stage) claz.getConstructor(MainGame.class, String.class).newInstance(MainGame.this, hotUpdate.getAssetsDir());
//切换舞台,进入到gameStage
changeStage(gameStage);
} catch (Exception e) {
//捕获异常
Gdx.app.log("loadDex error", e.toString());
e.printStackTrace();
}
}
});
}
//切换舞台
public void changeStage(Stage stage) {
Gdx.input.setInputProcessor(stage);
currentStage = stage;
}
@Override
public void render() {
Gdx.gl.glClearColor(0.9f, 0.9f, 0.9f, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
if (currentStage != null) {
currentStage.act();
currentStage.draw();
}
}
}
最后,分别在AndroidLauncher和IOSLauncher两个类中,将HotUpdateAndroid和HotUpdateIOS的实例传入到MainGame。
public class AndroidLauncher extends AndroidApplication {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
AndroidApplicationConfiguration config = new AndroidApplicationConfiguration();
//传入HotUpdateAndroid的实例
initialize(new MainGame(new HotUpdateAndroid(this)), config);
}
}
public class IOSMoeLauncher extends IOSApplication.Delegate {
protected IOSMoeLauncher(Pointer peer) {
super(peer);
}
@Override
protected IOSApplication createApplication() {
IOSApplicationConfiguration config = new IOSApplicationConfiguration();
config.useAccelerometer = false;
//传入HotUpdateIOS的实例
return new IOSApplication(new MainGame(new HotUpdateIos()), config);
}
public static void main(String[] argv) {
UIKit.UIApplicationMain(0, null, null, IOSMoeLauncher.class.getName());
}
}
至此,框架搭建已经完成,我们可以尝试运行一下看看。
我在ios上进入主程序正常,但是点击图片进入游戏界面无效果,打印了两句log:
System 5 ClassLoader referenced unknown path: /Users/ayo/Library/Developer/CoreSimulator/Devices/2DB6D358-14A6-4D64-A3DE-93E8DCFF7322/data/Containers/Data/Application/9FF34C72-7937-4604-97C4-E138783441AB/Documents/game.dex
loadDex error 4 java.lang.ClassNotFoundException: Didn't find class "GameStage" on path: DexPathList[[],nativeLibraryDirectories=[]]
第一句是ClassLoader报错,提示dex的路径不对,第二句是我捕获的异常,提示没有找到GameStage类。继续往下走。
生成dex
现在我们需要做的就是把游戏代码(这里只有GameStage类)生成dex文件,然后放到服务器上供主程序下载,这里作为演示,直接把生成的dex文件放到目标文件夹中。那么现在问题来了,如何生成dex文件了?原来Android SDK已经为我们提供了便利的工具,在Android SDK的build-tools目录下,有一个dx工具,它的作用是将jar或者class文件转换成dex,所以我们还需要将游戏代码先编译成class文件或者打包成jar,这就要用到jdk为我们提供的javac命令了。是不是好麻烦?限于篇幅,这里不去讨论这些工具的使用。但是我提供一个gradle脚本,可以实现直接将java文件打包成dex,其实原理上还是使用的javac和dx,只不过使用脚本要方便许多,写好一次,以后直接运行就能生成dex文件了。
继续吧!在Game模块下的build.gradle文件里空白处添加一个task,代码如下:
//添加类型为Exec的任务,并依赖java插件提供的classes任务
task packDex(dependsOn: classes, type: Exec) {
//获取sdk的目录
def sdkDir
def btVersion = "25.0.0"//build-tools版本号,需要换成你自己的
def localFile = file("../local.properties")
if (localFile.exists()) {
Properties localProp = new Properties()
localFile.withInputStream { instr ->
localProp.load(instr)
}
sdkDir = localProp.getProperty('sdk.dir')
if (!sdkDir) {
sdkDir = "$System.env.ANDROID_HOME"
}
}
//dx工具路径,win下需改为dx.bat
def dx = sdkDir + "/build-tools/$btVersion/dx"
//要打包的class文件目录,classes任务默认会将java文件编译到这个目录
def input = file("build/classes/main")
//输出的dex文件目录
def output = file("build/dex");
//用于检测文件是否变动
inputs.files input
outputs.dir output
//创建目录
file(output).mkdirs();
//执行命令行
commandLine "$dx", '--dex', "--output=$output/game.dex", input
}
稍微阐述一下,classes是Java Gradle插件的内置任务,会将Java代码打包成class文件,并放到build/classes/main路径下,然后我们写的这个packDex任务将该路径下的class文件打包成dex,放到build/dex路径下。使用方法是先刷新Gradle,然后在Android Studio的右侧找到Gradle的任务列表,在games/other里能找到我们定义的packDex任务,双击执行就可以了。如果你有配置Gradle的环境变量,也可以直接在工程目录下输入gradle packDex
命令来打包,更加快捷。打包成功后,就能在Game模块下的build/dex目录看到dex文件了。此时的Game模块如图:
打包上传
dex生成好了,还差资源文件了。如果是在正式环境,可以将dex和Game模块下的assets文件夹一起打包成zip包,上传到服务器,然后由主程序下载并解压。并且,我们同样可以使用Gradle脚本任务来进行打包操作,十分方便。
这里作为Demo,我直接将dex和资源文件复制到了ios模拟器里。
加载运行
在运行之前还有一个至关重要的事情要做,因为我们的GameStage类引用了MainGame类,然而moe在打包的时候默认是开启混淆的,所以MainGame类在主程序中被混淆了而GameStage类里的MainGame类是没被混淆的,实际运行会报错。所以我们还需要关闭MainGame类的混淆,这里我直接关闭了com.ayocrazy.toturial
包的混淆,在ios-moe模块下的proguard.append.cfg文件中加入:
-keep class com.badlogic.** { *; }
-keep enum com.badlogic.** { *; }
-keep class com.ayocrazy.tutorial.** { *; }
好了,激动人心的时刻到了,赶快运行起来看看效果吧:
在上图中,第一次点击后我将GameStage的代码改了,然后重新打包成dex放入到模拟器里覆盖之前的文件,再次进入的时候已经是新的代码了,可以看到我给logo添加了一个动画,完美实现热更新。
Android下效果是一样的,已经验证,这里不截图了,可以自己运行查看。
效率
关于libgdx的热更新到这里其实已经算是讲完了,但是用这种方式投入生产开发的话效率很低,所以下面讨论下如何提升开发效率:
- 调试。首先dex的内容是没法实现断点调试的,ide不支持,如果你足够强大可以自己写debug工具。但是我们也可以用桌面项目来进行调试,让桌面项目运行的时候将Game模块的内容包含进去,运行desktop模块的时候就不用dex了,也就不存在热更新,况且本来desktop也是没法加载dex的。
- 资源。我们使用AssetManager管理资源的时候需要特别注意,在主程序中使用的是internal路径,而在游戏中是absolute路径,所以无法使用同一个AssetManager来管理,只能分别管理。这个时候就要特别注意主程序和游戏的生命周期,小心处理内存问题。
- 工具。这里主要指Gradle,擅用Gradle可以让你的开发效率大大提升。除了前面说的用gradle生成dex,你还可以用它来打zip包,上传服务器,复制文件到Android手机、IOS模拟器都是可以的,包括第1点里提到的让desktop运行的时候包含Game模块的内容也是可以使用Gradle脚本实现的。当然这里只提供思路,细节还需自行研究,可以告诉大家的是这些功能在我的项目中都已经实现。如果有疑问,可以留言一起探讨。
- 架构。良好的架构可以让程序的稳定性、可维护性大大增强。给游戏添加热更新会提高开发的复杂度,如果没有一个良好的架构支持,必然导致开发过程中痛苦万分,那样就本末倒置了。
最后,作个总结吧。Libgdx实现跨平台的热更新可以归纳为四个步骤:模块分离->代码编写->dex生成->热更加载。 技术上实现并不难,难的它打破了原有的开发流程,在给我们带来强大功能的同时,也失去了一些便利性,所以也不要盲目追求热更新,还是面向需求编程吧。