随着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]。
Android 动态类加载实现免安装更新 - KavMors - 博客园 (cnblogs.com)