随着Html5技术成熟,轻应用越来越受欢迎,特别是其更新成本低的特点。与Native App相比,Web App不依赖于发布下载,也不需要安装使用,兼容多平台。目前也有不少Native App使用原生嵌套WebView的方式开发。但由于Html渲染特性,其执行效率不及Native App好,在硬件条件不佳的机子上流畅度很低,给用户的体验也比较差。反观Native App,尽管其执行效率高,但由于更新频率高而导致频繁下载安装,这一点也令用户很烦恼。本文参考java虚拟机的类加载机制,以及网上Android动态加载jar的例子,提出一种不依赖于重新安装而更新Native App的方式。
目的:利用Android类加载原理,实现免安装式更新Native App
1. 先回顾Java动态加载类的原理
实现一个Java应用,使用动态类加载,从外部jar中加载应用的核心代码。
制作一个ClassLoader,提供读取类的方法
1 package com.kavmors.classloadtest;
2
3 import java.net.URL;
4 import java.net.URLClassLoader;
5
6 import com.kavmors.classes.RemoteEntry;
7
8 public class RemoteClassLoader {
9 /**
10 * 读取一个类,并返回实例
11 * @param jarPath jar包的地址
12 * @param classPath 类所在的地址(包括package名)
13 * @return 继承RemoteEntry接口的实体类实例,失败则返回null
14 */
15 public static RemoteEntry load(String jarPath, String classPath) {
16 URLClassLoader loader;
17 try {
18 loader = new URLClassLoader(new URL[]{new URL(jarPath)});
19 Class<?> c = loader.loadClass(classPath);
20 RemoteEntry instance = (RemoteEntry)c.newInstance();
21 loader.close();
22 return instance;
23 } catch (Exception e) {
24 e.printStackTrace();
25 return null;
26 }
27 }
28 }
制作一个供核心代码继承的接口。这个接口很简单,只有一个execute方法。
1 package com.kavmors.classes;
2
3 import com.kavmors.classloadtest.Main;
4
5 public interface RemoteEntry {
6 public void execute(Main main);
7 }
其中的Main类如下,是整个程序的主入口
1 package com.kavmors.classloadtest;
2
3 import com.kavmors.classes.RemoteEntry;
4
5 public class Main {
6 //这里定义核心代码所在类的包名+类名
7 private final static String classPath = "com.kavmors.classes.MainEntry";
8 //这里定义jar包的地址
9 private final static String jarPath = "file:D:/MainEntry.jar";
10
11 //提供一个Main类的成员方法
12 public void printTime() {
13 System.out.println(System.currentTimeMillis());
14 }
15
16 //主入口在这里
17 public static void main(String[] args) {
18 Main main = new Main();
19 RemoteEntry entry = RemoteClassLoader.load(jarPath, classPath);
20 if (entry!=null) entry.execute(main); //执行核心代码
21 }
22 }
从以上代码看,RemoteClassLoader.load从jarPath读取了MainEntry.jar,然后从jar包中读取了MainEntry类并返回了该类的实例,最后运行实例中execute方法。到此应用的框架就制作好了,可以把以上代码打包成Runnable jar,命令为RemoteLoader.jar,方便后面的测试。
接下来,需要生成MainEntry,继承RemoteEntry接口。MainEntry里的就是核心代码。
1 package com.kavmors.classes;
2
3 import com.kavmors.classloadtest.Main;
4
5 public class MainEntry implements RemoteEntry {
6 @Override
7 public void execute(Main main) {
8 System.out.println("Execute MainEntry.execute");
9 main.printTime();
10 }
11 }
以上,实现了接口中execute方法,并调用了Main类中的成员方法。把这个Class打包成jar,命名为MainEntry.jar,路径为D:/MainEntry.jar。
现在测试一下,执行java -jar RemoteLoader.jar,结果在控制台中打印"Execute MainEntry.execute和时间戳。由于MainEntry继承了RemoteEntry,RemoteClassLoader.load返回的相当于MainEntry类的实例,所以执行了其中execute方法。注意RemoteLoader.jar中是没有MainEntry这个类的,这个类是在MainEntry.jar中定义的。
以上仅用URLClassLoader实现动态加载,原理详见参考资料[1]。
2. Android动态类加载框架
以上例子中,程序的主入口与核心代码进行了分离。如果把RemoteClassLoader.jar看成安装在机子上的Native App,MainEntry.jar看成远程服务器上的文件,那么对于每次更新,只需把MainEntry.jar更新后部署在服务器上就可以了,Native App不需要任何修改。根据这种想法,可以实现不依赖于重新安装的更新方式。
在JVM上,使用URLClassLoader可以调用本地及网络上的jar,把jar中的class读取出来。而在安卓上,类生成的概念与JVM不完全一样[2]。Dalvik将编译到的.class文件重新打包成dex类型的文件,因此也有自己的类加载器DexClassLoader,只需要把上面例子的URLClassLoader换成DexClassLoader就可以。
考虑到现实开发的场景,在首次启动应用或需要更新的时候从服务器下载jar,存到本地,不需要更新的时候就直接使用本地的jar。这样,首先需要一个操作jar的类,用来判断jar是否存在,以及处理创建、删除、下载的任务。
1 package com.kavmors.remoteloader;
2
3 import java.io.File;
4 import java.io.FileOutputStream;
5 import java.io.IOException;
6 import java.io.InputStream;
7 import java.io.OutputStream;
8 import java.net.URL;
9 import java.net.URLConnection;
10
11 import android.os.AsyncTask;
12
13 public class JarUtil {
14 private OnDownloadCompleteListener mListener;
15 private String jarPath;
16
17 public JarUtil(String jarPath) {
18 this.jarPath = jarPath;
19 }
20
21 //下载任务完成后,回调接口内的方法
22 public interface OnDownloadCompleteListener {
23 public void onSuccess(String jarPath);
24 public void onFail();
25 }
26
27 //jar不存在则返回false
28 //若文件大小为0表示jar无效,删除该文件再返回false
29 public boolean isJarExists() {
30 File jar = new File(jarPath);
31 if (!jar.exists()) {
32 return false;
33 }
34 if (jar.length()==0) {
35 jar.delete();
36 return false;
37 }
38 return true;
39 }
40
41 public boolean create() {
42 try {
43 File file = new File(jarPath);
44 file.getParentFile().mkdirs();
45 file.createNewFile();
46 return true;
47 } catch (IOException e) {
48 return false;
49 }
50 }
51
52 public boolean delete() {
53 File file = new File(jarPath);
54 return file.delete();
55 }
56
57 public void download(String remotePath, OnDownloadCompleteListener listener) {
58 mListener = listener;
59 //启动异步类发送下载请求
60 AsyncTask<String,String,String> task = new AsyncTask<String,String,String>() {
61 @Override
62 protected String doInBackground(String... path) {
63 if (execDownload(path[0], path[1])) {
64 return path[1]; //成功返回jarPath
65 } else {
66 return null; //不成功时返回null
67 }
68 }
69
70 @Override
71 protected void onPostExecute(String jarPath) {
72 if (mListener==null) return;
73 //根据下载任务执行结果回调
74 if (jarPath==null) {
75 mListener.onFail();
76 } else {
77 mListener.onSuccess(jarPath);
78 }
79 }
80 };
81 task.execute(remotePath, jarPath);
82 }
83
84 private boolean execDownload(String remotePath, String jarPath) {
85 try {
86 URLConnection connection = new URL(remotePath).openConnection();
87 InputStream in = connection.getInputStream();
88 byte[] bs = new byte[1024];
89 int len = 0;
90 OutputStream out = new FileOutputStream(jarPath);
91 while ((len=in.read(bs))!=-1) {
92 out.write(bs, 0, len);
93 }
94 out.close();
95 in.close();
96 return true;
97 } catch (IOException e) {
98 return false;
99 }
100 }
101 }
以下组装ClassLoader辅助类
1 package com.kavmors.remoteloader;
2
3 import com.kavmors.core.RemoteEntry;
4
5 import android.app.Activity;
6 import dalvik.system.DexClassLoader;
7
8 public class ClassLoaderUtil {
9 private Activity mActivity;
10
11 public ClassLoaderUtil(Activity activity) {
12 mActivity = activity;
13 }
14
15 /**
16 * 读取一个类,并返回实例
17 * @param jarPath jar包的本地路径
18 * @param classPath 类所在的地址(包括package名)
19 * @return 继承RemoteEntry接口的实体类实例,失败则返回null
20 */
21 public RemoteEntry load(String jarPath, String classPath) {
22 DexClassLoader loader;
23 try {
24 String optimizedDir = mActivity.getDir(mActivity.getString(R.string.app_name), Activity.MODE_PRIVATE).getAbsolutePath();
25 loader = new DexClassLoader(jarPath, optimizedDir, null, mActivity.getClassLoader());
26 Class<?> c = loader.loadClass(classPath);
27 RemoteEntry instance = (RemoteEntry)c.newInstance();
28 return instance;
29 } catch (Exception e) {
30 return null;
31 }
32 }
33 }
简单解释DexClassLoader构造方法[3]。第一个参数dexPath表示jar文件的路径,用File.pathSeparator隔开;第二个参数是优化后dex文件的存储路径,可以理解为解压jar得到的文件的路径;第三个参数是目标类使用的本地C/C++库,这里为null;第四个参数是要加载的类的父加载器,一般是当前的加载器。需要说明,第二个参数需要宿主程序目录,只允许当前程序访问,因此不能为SD卡路径,官网上建议使用context.getCodeCacheDir().getAbsolutePath()的方法获取,在低于API 21的应用可以用上面例子的方法。为了避免漏洞,建议jar路径(第一个参数)也设为宿主目录,但由于测试中方便删除,这里将直接使用SD卡路径。
返回的RemoteEntry类很简单,传入参数为Activity
1 package com.kavmors.core;
2
3 import android.app.Activity;
4
5 public interface RemoteEntry {
6 public void execute(Activity activity);
7 }
下面开始主程序。首先生成一个布局文件activity_main.xml,内容很简单,一个TextView一个Button,分别加@+id/txt和@+id/btn。Activity的执行逻辑是,先判断jar文件是否存在,存在则直接执行类加载任务。若不存在,则下载jar到SD卡路径中,再加载。加载完成后,执行RemoteEntry.execute(Activity)。细节方面,在下载jar时生成一个ProgressDialog提示。
1 package com.kavmors.remoteloader;
2
3 import java.io.File;
4
5 import com.kavmors.core.RemoteEntry;
6
7 import android.app.Activity;
8 import android.app.ProgressDialog;
9 import android.os.Bundle;
10 import android.os.Environment;
11 import android.widget.Toast;
12
13 public class MainActivity extends Activity implements JarUtil.OnDownloadCompleteListener {
14 private final String REMOTE_PATH = "http://127.0.0.1/kavmors/MainEntry.jar"; //服务器上MainEntry.jar的URL
15 private ProgressDialog dialog;
16
17 @Override
18 protected void onCreate(Bundle savedInstanceState) {
19 super.onCreate(savedInstanceState);
20 setContentView(R.layout.activity_main);
21
22 JarUtil util = new JarUtil(getJarPath());
23 if (util.isJarExists()) {
24 onSuccess(getJarPath()); //存在则直接执行类加载
25 } else {
26 //创建新的jar文件
27 util.create();
28 //显示ProgressDialog
29 dialog = new ProgressDialog(this);
30 dialog.setTitle("提示");
31 dialog.setMessage("加载中...");
32 dialog.show();
33 //执行下载
34 util.download(REMOTE_PATH, this);
35 }
36 }
37
38 @Override
39 public void onSuccess(String jarPath) {
40 if (dialog!=null) dialog.dismiss();
41 //使用加载器加载,获取一个RemoteEntry实例
42 RemoteEntry entry = new ClassLoaderUtil(this).load(jarPath, getClassPath());
43 if (entry==null) onFail();
44 else entry.execute(this);
45 }
46
47 @Override
48 public void onFail() {
49 if (dialog!=null) dialog.dismiss();
50 Toast.makeText(this, "Fail to load class", Toast.LENGTH_SHORT).show();
51 }
52
53 //返回jar路径
54 private String getJarPath() {
55 String exterPath = Environment.getExternalStorageDirectory().getAbsolutePath();
56 return exterPath + File.separator + this.getResources().getString(R.string.app_name) + File.separator + "MainEntry.jar";
57 }
58
59 //返回包+类路径
60 private String getClassPath() {
61 return "com.kavmors.core.MainEntry";
62 }
63 }
编译一下,这个应用框架已经完成了,先安装到机子上,但由于没有MainEntry.jar,这时运行会提示“Fail to load class.”。
3. 动态类的编译和打包
还差一个MainEntry.jar。现在创建一个MainEntry类继承RemoteEntry接口,做一些简单的控件操作。
1 package com.kavmors.core;
2
3 import com.kavmors.remoteloader.R;
4
5 import android.app.Activity;
6 import android.view.View;
7 import android.widget.Button;
8 import android.widget.TextView;
9
10 public class MainEntry implements RemoteEntry {
11 @Override
12 public void execute(Activity activity) {
13 //控件操作
14 final TextView txt = (TextView) activity.findViewById(R.id.txt);
15 Button btn = (Button) activity.findViewById(R.id.btn);
16 btn.setOnClickListener(new View.OnClickListener() {
17 @Override
18 public void onClick(View v) {
19 txt.setText("Button on click");
20 }
21 });
22 }
23 }
和Java应用的例子一样,把MainEntry单独打包成MainEntry.jar。这里还有一步,由于Dalvik执行dex文件,还需要把jar使用SDK包中的工具制成dex文件[4]。这个工具在SDK包中,路径为SDK/build-tools/22.0.1/dx.bat,中间的22.0.1表示API版本。可以把这个路径加入环境变量,调用命令为
【dx --dex --output=MainEntry.jar MainEntry.jar】
--output的参数表示压缩为dex后生成的文件,与原始jar同名即覆盖。压缩后,把MainEntry.jar放上服务器,服务器路径在MainActivity中定义了。
4. 总结
原理很简单,与Java加载的例子一样道理,只是ClassLoader换成了DexClassLoader,以及生成jar后要再次压缩成dex。本例只是提供一种思路,以及简述实现该思路的方法,如果要用在实际应用中,需要考虑的情况很多,如根据版本号更新jar,下载jar失败时的策略,等。应用庞大的时候需要考虑到下载更新一次jar需要很长时间,这时可以拆分为多个jar,按需更新。同时,这种方式加载可能增加被破解的风险,也带来应用签名的问题。实际情况实际考虑,有兴趣深入研究,推荐查阅【安卓插件化】的相关资料和开源框架[5]。
参考资料及引用
[1] ClassLoader原理:开源中国. Java Classloader机制解析.
http://my.oschina.net/aminqiao/blog/262601#OSC_h1_1
[2] 安卓类加载器:CSDN博客. Android中的类装载器DexClassLoader.
http://blog.csdn.net/com360/article/details/14125683
[3] DexClassLoader构造方法:Android Developers. DexClassLoader.
http://developer.android.com/reference/dalvik/system/DexClassLoader.html
[4] dex文件:CSDN博客. class文件和dex文件的区别(DVM和JVM的区别)及Android DVM介绍.
http://m.blog.csdn.net/blog/fangchao3652/42246049
[5] 插件化框架:Github. dynamic-load-apk.
https://github.com/singwhatiwanna/dynamic-load-apk