VirtualAPK:滴滴 Android 插件化的实践之路

JustinShapi 4年前
   <h2>一、前言</h2>    <p>在 Android 插件化技术日新月异的今天,开发并落地一款插件化框架到底是简单还是困难,这个问题不同人会有不同的答案。但是我相信,完成一个插件化框架的 Demo 并不是多难的事儿,然而要开发一款完善的插件化框架却并非易事,尤其在国内,各大 ROM 厂商都对 Android 系统做了一定程度的定制,这更进一步加剧了 Android 本身的碎片化问题。</p>    <p>滴滴出行在插件化上的探索起步较晚,由于业务发展较快,迭代占据了大量的时间,这使得我们在2016年才开始研究这方面的技术。经过半年的开发、测试、适配和线上验证,今天,我们正式推出一款较为完善的插件化框架——VirtualAPK。之所以现在推出,是因为 VirtualAPK 在内部已经得到了很好的验证,我们在迭代过程中不断地做机型适配和细节特性的支持,目前已达到一个非常稳定的状况,足以支撑滴滴部分乃至全部业务的动态发版需求。目前滴滴出行最新版本(v5.0.4)上,小巴和接送机业务均为插件,大家可以去体验。</p>    <h2>二、插件化的现状</h2>    <p>到目前为止,业界已经有很多优秀的开源项目,比如早期的基于静态代理思想的 DynamicLoadApk,随后的基于占坑思想的 DynamicApk、Small,还有360手机助手的 DroidPlugin。它们都是优秀的开源项目,很大程度上促进了国内插件化技术的发展。</p>    <p>尽管有如此多的优秀框架存在,但是兼容性问题仍然是制约插件化发展的一大难题。一款插件化框架,也许可以在一款手机上完美运行,但是在数以千万的设备上却总是容易存在这样那样的兼容性问题。我相信上线过插件化的工程师应该深有体会。滴滴为什么还要自研一款新的插件化框架?因为我们需要一款功能完备、兼容性优秀、适用于滴滴业务的插件化框架,目前市面上的开源不能满足我们的需求,所以必须重新造轮子,于是 VirtualAPK 诞生了。</p>    <h2>三、VirtualAPK 的诞生</h2>    <p>VirtualAPK 是滴滴出行自研的一款优秀的插件化框架,主要有如下几个特性。</p>    <h3>1. 功能完备</h3>    <ul>     <li>支持几乎所有的 Android 特性;</li>     <li> <p>四大组件方面: 四大组件均不需要在宿主 manifest 中预注册,每个组件都有完整的生命周期。</p>      <ul>       <li>Activity:支持显示和隐式调用,支持 Activity 的 theme 和 LaunchMode ,支持透明主题;</li>       <li>Service:支持显示和隐式调用,支持 Service 的 start 、 stop 、 bind 和 unbind ,并支持跨进程 bind 插件中的 Service;</li>       <li>Receiver:支持静态注册和动态注册的 Receiver;</li>       <li>ContentProvider:支持 provider 的所有操作,包括 CRUD 和 call 方法等,支持跨进程访问插件中的 Provider。</li>      </ul> </li>     <li> <p>自定义View:支持自定义 View,支持自定义属性和 style,支持动画;</p> </li>     <li>PendingIntent:支持 PendingIntent 以及和其相关的 Alarm 、 Notification 和 AppWidget ;</li>     <li>支持插件 Application 以及插件 manifest 中的 meta-data ;</li>     <li>支持插件中的 so 。</li>    </ul>    <h3>2. 优秀的兼容性</h3>    <ul>     <li>兼容市面上几乎所有的 Android 手机,这一点已经在滴滴出行客户端中得到验证;</li>     <li>资源方面适配小米、Vivo、Nubia 等,对未知机型采用自适应适配方案;</li>     <li>极少的 Binder Hook,目前仅仅 hook 了两个 Binder: AMS 和 IContentProvider ,Hook过程做了充分的兼容性适配;</li>     <li>插件运行逻辑和宿主隔离,确保框架的任何问题都不会影响宿主的正常运行。</li>    </ul>    <h3>3. 入侵性极低</h3>    <ul>     <li>插件开发等同于原生开发,四大组件无需继承特定的基类;</li>     <li>精简的插件包,插件可以依赖宿主中的代码和资源,也可以不依赖;</li>     <li>插件的构建过程简单,通过Gradle插件来完成插件的构建,整个过程对开发者透明。</li>    </ul>    <h2>四、VirtualAPK 的工作过程</h2>    <p>VirtualAPK 对插件没有额外的约束,原生的 apk 即可作为插件。插件工程编译生成 apk 后,即可通过宿主 App 加载,每个插件 apk 被加载后,都会在宿主中创建一个单独的 LoadedPlugin 对象。如下图所示,通过这些 LoadedPlugin 对象,VirtualAPK 就可以管理插件并赋予插件新的意义,使其可以像手机中安装过的App一样运行。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/561e7be4fa0e11c52d0504c4144db18f.png"></p>    <h3>1. VirtualAPK 的运行形态</h3>    <p>我们计划赋予 VirtualAPK 两种工作形态,耦合形态和独立形态。目前 VirtualAPK 对耦合形态已经有了很好的支持,接下来将计划支持独立形态。</p>    <ul>     <li><strong><em>耦合形态</em> </strong> :插件对宿主可以有代码或者资源的依赖,也可以没有依赖。这种模式下,插件中的类不能和宿主重复,资源 id 也不能和宿主冲突。这是 VirtualAPK 的默认形态,也是适用于大多数业务的形态。</li>     <li><strong><em>独立形态</em> </strong> :插件对宿主没有代码或者资源的依赖。这种模式下,插件和宿主没有任何关系,所以插件中的类和资源均可以和宿主重复。这种形态的主要作用是用于运行一些第三方 apk。</li>    </ul>    <h3>2. 如何使用</h3>    <p>第一步: 初始化插件引擎</p>    <pre>  <code class="language-java">@Override  protected void attachBaseContext(Context base) {      super.attachBaseContext(base);      PluginManager.getInstance(base).init();  }</code></pre>    <p>第二步:加载插件</p>    <pre>  <code class="language-java">public class PluginManager {      public void loadPlugin(final File apk);      public void loadPlugin(final String moduleCode);  }</code></pre>    <p>我们对上述加载过程进行了一些封装,通过如下方式即可异步地去加载一个插件。</p>    <pre>  <code class="language-java">// 示例:启动插件中的Activity  DownloadManager downloadManager = DownloadManager.getInstance(this);  downloadManager.loadModule("com.ryg.test", true, this, new ILoadListener() {      @Override      public void onLoadEnd(int resultCode) {          if (resultCode == ILoadListener.LOAD_SUCCESS) {              Intent intent = new Intent();              intent.setClassName("com.ryg.test", "com.ryg.test.MainActivity");              startActivity(intent);          } else {              // todo load plugin failed          }      }  });</code></pre>    <p>当插件入口被调用后,插件的后续逻辑均不需要宿主干预,均走原生的 Android 流程。比如,在插件内部,如下代码将正确执行:</p>    <pre>  <code class="language-java">@Override  protected void onCreate(Bundle savedInstanceState) {      super.onCreate(savedInstanceState);      setContentView(R.layout.activity_book_manager);      LinearLayout holder = (LinearLayout)findViewById(R.id.holder);      TextView imei = (TextView)findViewById(R.id.imei);      imei.setText(IDUtil.getUUID(this));        // bind service in plugin      Intent service = new Intent(this, BookManagerService.class);      bindService(service, mConnection, Context.BIND_AUTO_CREATE);        // start activity in plugin      Intent intent = new Intent(this, TCPClientActivity.class);      startActivity(intent);  }</code></pre>    <h2>五、探究原理</h2>    <h3>1. 基本原理</h3>    <ul>     <li><strong>合并宿主和插件的ClassLoader</strong> :需要注意的是,插件中的类不可以和宿主重复;</li>     <li><strong>合并插件和宿主的资源</strong> :重设插件资源的 packageId ,将插件资源和宿主资源合并;</li>     <li><strong>去除插件包对宿主的引用</strong> :构建时通过 Gradle 插件去除插件对宿主的代码以及资源的引用。</li>    </ul>    <h3>2. 四大组件的实现原理</h3>    <p style="text-align:center"><img src="https://simg.open-open.com/show/756f025cc685cc169817b03490081b2f.png"></p>    <ul>     <li><strong>Activity</strong> :采用宿主 manifest 中占坑的方式来绕过系统校验,然后再加载真正的 Activity;</li>     <li><strong>Service</strong> :动态代理 AMS,拦截 Service 相关的请求,将其中转给一个虚拟空间(Matrix)去处理,Matrix 会接管系统的所有操作;</li>     <li><strong>Receiver</strong> :将插件中静态注册的 Receiver 重新注册一遍;</li>     <li><strong>ContentProvider</strong> :动态代理 IContentProvider ,拦截 Provider 相关的请求,将其中转给一个虚拟空间(Matrix)去处理,Matrix 会接管系统的所有操作。</li>    </ul>    <p>以下是 VirtualAPK 的整体结构图。</p>    <p><img src="https://simg.open-open.com/show/7831537ca41fa57e68f475568504ad6e.png"></p>    <h2>六、填坑之路</h2>    <p>在实践中我们遇到了很多很多的问题,比如机型适配、API 版本适配、Binder Hook 的稳定性保证等问题,这里拿一个典型的资源适配问题来说明。</p>    <p>其实这是一个很无奈的问题,由于国内各大 ROM 厂商喜欢深度定制 Android 系统,所以就出现了这种适配问题。</p>    <p>正常情况下我们通过如下代码去创建插件的 Resources 对象:</p>    <pre>  <code class="language-java">Resources newResources = new Resources(assetManager,   hostResources.getDisplayMetrics(), hostResources.getConfiguration());</code></pre>    <p>然后在 Vivo 手机上,竟然出现了如下的类型转换错误,看起来是 Vivo 自己派生了 Resources 的子类。</p>    <pre>  <code class="language-java">java.lang.RuntimeException: Unable to start activity ComponentInfo{com.sdu.didi.psnger/com.didi.virtualapk.core.A$1}: java.lang.ClassCastException: android.content.res.Resources cannot be cast to android.content.res.VivoResources      at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2196)      at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2245)      at android.app.ActivityThread.access$800(ActivityThread.java:140)      at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1202)      at android.os.Handler.dispatchMessage(Handler.java:102)      at android.os.Looper.loop(Looper.java:136)      at android.app.ActivityThread.main(ActivityThread.java:5143)      at java.lang.reflect.Method.invokeNative(Native Method)      at java.lang.reflect.Method.invoke(Method.java:515)      at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:786)      at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:602)      at dalvik.system.NativeStart.main(Native Method)  Caused by: java.lang.ClassCastException: android.content.res.Resources cannot be cast to android.content.res.VivoResources      at android.app.ResourcesManager.getTopLevelResources(ResourcesManager.java:236)      at android.app.ContextImpl.<init>(ContextImpl.java:2057)      at android.app.ContextImpl.createActivityContext(ContextImpl.java:2008)      at android.app.ActivityThread.createBaseContextForActivity(ActivityThread.java:2207)      at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2140)      ... 11 more</code></pre>    <p>于是反编译了下 Vivo 的 Framework 代码,果不其然,在如下代码中进行了类型转换,所以在加载插件资源的时候就报错了。</p>    <pre>  <code class="language-java">@VivoHook(hookType = VivoHookType.NEW_METHOD)      public Resources getTopLevelResources(String pkgName, String resDir, int displayId, Configuration overrideConfiguration, CompatibilityInfo compatInfo, IBinder token) {          Resources resources = getTopLevelResources(resDir, displayId, overrideConfiguration, compatInfo, token);          if (resources != null) {              ((VivoResources) resources).init(pkgName);          }          return resources;      }</code></pre>    <p>为了解决这个问题,我们分析了 VivoResources 的代码实现,然后在创建插件资源的时候,采用了如下的代码。</p>    <pre>  <code class="language-java">private static final class VivoResourcesCompat {      private static Resources createResources(Resources hostResources, AssetManager assetManager) throws Exception {          Class resourcesClazz = Class.forName("android.content.res.VivoResources");          Resources newResources = (Resources)ReflectUtil.invokeConstructor(resourcesClazz,                  new Class[]{AssetManager.class, DisplayMetrics.class, Configuration.class},                  new Object[]{assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration()});          return newResources;      }  }</code></pre>    <p>除了 Vivo 以外,有类似问题的还有 MIUI、Nubia 以及其他不知名的机型。而且在 Vivo 手机上,除了类型转换错误的问题,还有其他很坑的问题。</p>    <p>事实上我们还处理了很多其他的坑,这里无法一一说明,所以说如何保证插件化的稳定性是一件很有技术挑战的事情。</p>    <h2>七、一些暂时不支持的特性</h2>    <p>由于种种原因,VirtualAPK 目前未能支持所有的 Android 的特性,已知的有如下几点:</p>    <ul>     <li>不支持 Activity 的部分属性,比如 process 、 configChanges 等;</li>     <li>暂不支持 overridePendingTransition(int enterAnim, int exitAnim) 这种形式的转场动画;</li>     <li>插件中弹通知,不能使用插件中的资源,比如图片。</li>    </ul>    <h2>八、开源计划</h2>    <p>我们的目标是打造一款功能完备的插件化框架,使得各个业务线都能以插件的形式集成,从而实现 Android App 的热更新能力。</p>    <p>目前 VirtualAPK 还有一些特性需要进一步完善,待完善后,将会进行开源计划。我们期望 VirtualAPK 开源后,可以让其他 App 能够无缝集成,无需考虑细节实现和兼容性问题即可轻松拥有热更新能力。</p>    <p> </p>    <p> </p>    <p>来自:http://geek.csdn.net/news/detail/130917</p>    <p> </p>