Android 动态类加载实现免安装更新

随着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

原文地址:https://www.cnblogs.com/kavmors/p/4761460.html