Native App和Web App的一个重大区别就在于Web App可以非常容易的实现热补丁修复,而Native App伴随着版本的发布一些问题就无法修改,而且Android App存在一个普遍的用户不会及时更新的问题,如果在已发布的版本中出现某些问题,开发者就傻眼了。如果Android App也能够实现热补丁方式更新,或者部分问题能够通过hot fix方式来解决,那么对于开发者的收益是十分可观的。Android插件技术已经被广泛传播,基本的实现原理就是通过暴露一套接口把插件编译位apk或者dex,在运行时的DexClassLoader动态加载进来。以下就通过分析Android的一些特性来尝试各种方案。
一、 Android特性
App的加载过程:
众所周知Android App的源代码是Java的,在开发后Java文件会被编译位.class文件,只是与Java编译器不同的是在编译时编译器又会将众多的classes文件合并成一个文件dex文件,安装时将dex文件保存到/data/dalvik-cache目录,运行时转换成字节码放到DVM上运行。
二、ClassLoader
从上面可以得知Android的App在安装的时候就已经把APK内class的文件展开并且合并了,那么我们如果想要修改某些App的功能,除了发布一个新的版本以外似乎毫无办法。
那么为了解决这个问题我们不得不把时间回退到更初期的Android开发中面临的一个著名问题。Android问世以后,先驱们在经历一两年的开发,项目都变得越来越大,突然某一天大家都碰到了一个相同的问题:64k method references limit. 其实叫他问题不合适,应该叫他限制。
请参考这篇文章:http://android-developers.blogspot.pt/2011/07/custom-class-loading-in-dalvik.html
原因是DVM中使用的是dex file来进行类的加载,而这个文件中对于方法的索引数最大值就只有64k。对于一些大型的应用e.g (facebook, 微信, 功能太多太复杂)很快就超限了,就算是一些小的App如果会持续不断的更新和发布,那么超限也只是个时间问题。
在Java的世界中,ClassLoader是用来将Java类装进JVM中运行的,当我们在实例化某些类的时候,系统会使用默认的ClassLoader将class读入内存。但是前面讲到Android的class文件与JVM是不一样的,他们都被整理进一个dex文件里了。那么为了解决实现dex的类加载Android的开发者们就提供了一个DexClassLoader的东西。
至于ClassLoader的加载机制这里就不赘述了,基本原理就是子ClassLoader将类的加载先委托给父类,如果父类无法加载再去子类中查找,想要了解的请移步看下这篇文章:http://weli.iteye.com/blog/1682625
看到这里就觉得看到了曙光,那你就太天真了。
三、两座大山
通过以上的分析我们可以知道通过DexClassLoader可以实现类的动态加载,那么发布以后如果发现某个功能有问题,可以通过网络下载一个新的dex文件不就可以实现热补丁了吗。
对于一些基础的实现类是没有问题的,但是我们不要忘记App里面除了基础的类的加载还有一些其他问题你不得不关注。
两个问题:
问题1:
前面讲到了Android以APK形式发布的内容除了dex文件以外还有一些image、video、audio、text等附件。那么这些附件在Android开发过程中都是通过R文件引入到你的代码中的,这个R文件其实就是一个索引。R文件是通过aapt自动生成的,它包含了资源的ID到资源目录的一个映射关系。一旦你在xml文件中创建了一个ID字段,它都将会自动的加到这个R文件里。
那么在插件中如果要使用这些资源文件怎么办?插件中的资源文件可是不能够随着DexClassLoader的加载而放入R文件中的。
问题2:
DexClassLoader动态加载了类以后,这些类就只能够被当做简单的类来看待,如果是加载了一个Activity的类,它是不具备Android类的生命周期的。如果是直接启动插件中的Activity呢?Android中Activity的启动是需要在AndroidManifest中进行启动的,如果插件中的Activity就没有注册根本就无法启动。
这两个问题无疑成为了实现Android插件化开发的两座大山。
四、问题解决
针对问题1的解决方案其实很简单,我们分析到了资源文件不可使用的问题在于这些资源文件都没有通过Resource进行加载,那如果我们将这些资源文件也加载进Resource中,自然也就可以被调用了。如何进行Resource的加载呢?研究源代码会发现所有的资源文件都是通过AssetManager进行加载的,但是这个类是一个lower-level API,他的构造函数和方法都无法在包外进行访问(Java的作用域)。还好Java可以反射,因此我们可以通过反射得到一个实例来进行操作。
针对问题2的解决方案也很简单,如果不能控制Activity的生命周期,那我们就选择绕过吧。众所周知在Android中Fragment是可以被动态创建并被绑定到Activity中的,而且Fragment是不需要在AndroidManifest中定义的,也就绕过了Activity注册的问题。看上去似乎是一个不错的实现方式,但是如果要实现Fragment之间的跳转那似乎就变得太麻烦了。
好了,技术分析就到这里,下面来讲一下代码实现。
先来讲讲DexClassLoader动态加载的实现
首先我们要写一个Delegate的项目,里面提供一个DelegateFragment如下
/** * A simple {@link Fragment} subclass. */ public class DelegateFragment extends Fragment { public DelegateFragment() { // Required empty public constructor } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { // Inflate the layout for this fragment Toast.makeText(getActivity(), "Show toast test", Toast.LENGTH_SHORT).show(); return inflater.inflate(R.layout.fragment_delegate, container, false); } }
在这个Fragment的onCreateView方法内我们Toast了一个字符串。写完这里就可以打包成APK待用,我把它打包为app-release.apk。
接下来需要写一个HostActivity来加载这个Fragment,这个Activity也很简单。
先看一下onCreate方法
protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); AppCompatDelegate delegate = AppCompatDelegate.create(this, this); delegate.onCreate(savedInstanceState); delegate.setContentView(R.layout.activity_main); Toolbar toolbar = (Toolbar) findViewById(R.id.my_awesome_toolbar); delegate.setSupportActionBar(toolbar); loadPlugin(); }
在初始化时调用loadPlugin()加载插件,下面先看一下loadPlugin的实现:
private void loadPlugin() { new AsyncTask() { @Override protected Object doInBackground(Object[] params) { parseApk(); return null; } @Override protected void onPostExecute(Object o) { getSupportFragmentManager().beginTransaction().replace(R.id.fl_container, mFragment).commit(); } }.execute(); }
如果一个Apk插件太大,直接进行加载可能会出现ANR,所以在这里把加载的过程放到了一个异步任务里。接着看看parseApk()
private void parseApk() { File optimizedDexOutPutPath = getOptimizedDexOutPutPath(); File apkFile = createInternalFile(optimizedDexOutPutPath); if (!writeApk2Internal(apkFile)) { initResource(apkFile.getAbsolutePath()); initData(apkFile, optimizedDexOutPutPath); } }
在这里先生成一个APK存放的路径,然后再将APK文件读取出来,读取之后再初始化APK资源文件内的Resource。
步骤:
private void initResource(String libPath) { try { AssetManager assetManager = AssetManager.class.newInstance(); Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class); addAssetPath.invoke(assetManager, libPath); Resources superRes = super.getResources(); mRes = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration()); } catch (Exception e) { Log.e(TAG, e.getMessage(), e); } }
余下的代码就自己看了
private File getOptimizedDexOutPutPath() { return getDir("dex", Context.MODE_PRIVATE); } private void initData(File internalFile, File optimizedDexOutputPath) { dexClassLoader = new DexClassLoader( internalFile.getAbsolutePath(), optimizedDexOutputPath.getAbsolutePath(), null, getClassLoader()); try { Class<Fragment> myLibraryClazz = (Class<Fragment>) dexClassLoader.loadClass("com.huber.delegatedemo.DelegateFragment"); mFragment = myLibraryClazz.newInstance(); } catch (Exception e) { Log.e(TAG, e.getMessage(), e); } } private boolean writeApk2Internal(File internalFile) { BufferedInputStream bis = null; OutputStream dexWriter = null; final int BUF_SIZE = 8 * 1024; try { bis = new BufferedInputStream(getAssets().open("apk/app-release.apk")); dexWriter = new BufferedOutputStream( new FileOutputStream(internalFile)); byte[] buf = new byte[BUF_SIZE]; int len; while ((len = bis.read(buf, 0, BUF_SIZE)) > 0) { dexWriter.write(buf, 0, len); } } catch (Exception e) { Log.e(TAG, e.getMessage(), e); return true; } finally { try { if (dexWriter != null) dexWriter.close(); if (bis != null) bis.close(); } catch (IOException e) { e.printStackTrace(); } } return false; } private File createInternalFile(File optimizedDexOutPutPath) { return new File(optimizedDexOutPutPath, "app-release.apk"); }
好了,写到这里基本上所有大的问题都可以搞定了,但是依然会有一些小坑需要慢慢填平,这个留到开发过程中去解决吧。
补充:
虽然我们可以通过DexClassLoader加上反射的方式实现Android插件化开发,但是这种方式毕竟不是官方推荐的方式。我们采用的很多做法其实是绕过了Android的一些屏障,因此安全性和可靠性都不一定能够得到保障。
关于DexClassLoader动态加载机制我们其实已经可以实现Android开发的IoC(控制反转),这本来只是官方提供给开发者一个解决64k method limit的方案,我们最好还是合理的去运用它。如果用它去加载Activity是无法像一个真正的Activity来使用的。
关于资源文件的读取则纯粹是利用反射的机制去Hack了Android的实现,将一些系统保护起来的类和方法进行了使用,这虽然能够实现但是隐患也是存在的(万一某些ROM修改了一下,这种方法就会失效了)。
我们再反过头来思考一下我们要做的事情,Web App比Native App更灵活就在于它能够直接修改服务器端代码就更新了客户端的实现,除了可以快速的在线发布新的功能以外还可以在不更新App的情况及时的解决一些重大Bug。
如果Native App能够实现及时解决一部分Bug的在线修复(hot fix)功能,那么对于产品的收益也是十分巨大的。所以Native App插件化开发的重心应当放到修复功能上来,新功能的实现(事关UI)还是留给版本的更新来完成。
那又如何来实现在线修复问题呢?这就又回到软件架构方面的问题上来了。我们的目的是实现IoC,Android开发架构也遵从了MVC的原则。Activity的角色比较特殊,它其实承担了Controller层的角色也担负着View层的责任。因此在Android的开发实践中把Activity的代码梳理好,能够把这两个角色从Activity中剥离出来,就是一个理想的架构了。
如果把Controller层从Activity中剥离出来,通过DexClassLoader的方式动态加载,那么似乎这种方式就可行了。这篇文章篇幅有限,也就讨论到此,只当做引玉之砖,欢迎大家更热烈的讨论。