• 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

  • 相关阅读:
    context:component-scan报错
    goland 实用键
    React-Native 指定模拟器RUN-IOS
    mac 卸载编辑器卸不干净
    go 区分指针
    go 学习Printf
    我的命令行
    mysql8的坑
    小三角
    eslint 禁用命令
  • 原文地址:https://www.cnblogs.com/kavmors/p/4761460.html
Copyright © 2020-2023  润新知