Android插件化进阶——Atlas 源码分析(一)

这两天项目上要做 MVVM 和 DataBinding 的重构,所以插件化的文章就停了几天,后面会分享一下关于 MVVM 架构封装相关的文章。这篇文章我准备作为我插件化系列文章的最终章,我将分析目前最成熟最强大的插件化框架 Atlas 的一些基本流程的源码,以后如果有机会作更深入的研究,再进行插件化系列的更新。

Atlas 是手淘以及一系列阿里系 App 的插件化方案,实际上它又和其他插件化框架有一些区别,说的严谨点应该是一个容器化框架或者动态组件化框架,为什么这样说,后面会讲到。

Atlas 功能非常强大,技术也非常成熟,背后有阿里的一支技术实力非常强劲的团队在维护,刚开始学习 Atlas 的时候,我发现它官方的文档写的不是特别详细,导致一些配置我花了很多时间也没有完全搞明白,所以开始的印象不是很好。但自从我看过了官方的 Gitbook 源码文档后,我对 Atlas 团队的印象就彻底改观了,可以说,Gitbook 文档写的非常详细,而且逻辑清晰,实际上大家完全可以通过研读官方的源码文档,就可以对 Atlas 的整体启动流程和插件加载流程比较清楚了。

具体 Atlas 功能范围以及和一些常用插件化框架的对比,可以看我之前的文章。

Gitbook 的地址

要看这个文档,需要先把 atlas 从 Githud clone 下来,然后进入atlas-docs目录,在终端输入

gitbook serve

最后在浏览器输入http://localhost:4000即可进入 Gitbook 观看文档,当然提前你得配置下 Gitbook,这个请读者自行查阅步骤。这边文章也是根据官网的分析流程进行修改优化所得。言归正传,我们先来看下 Atlas 的启动过程。

Atlas 的启动过程

这里先说一下,为什么 Atlas 不算是真正意义上的插件化框架,而是一个动态组件化框架,主要原因是两者的原理有区别。

插件化的核心思想实际上是一种埋坑机制借尸还魂,它会在宿主的 manifest 中预留很多的组件坑,在运行时,进行一系列的填坑操作,将插件的四大组件通过「借尸还魂」的手段来加载运行。

而 Atlas 有着本质区别,在编译期,每个插件 bundle 的 manifest 就已经写入到 apk 中了,所以运行的时候,不需要进行一些特殊操作,bundle 的组件的使用就像正常宿主中声明的组件一样,无需再进行额外处理了。

阿里的 Atlas 沙龙视频中也非常明确的讲述了 Atlas 的设计灵感就是 Android 系统本身,我们可以把 Android 系统看成一个容器化框架,那么 Android 系统中的所有 App 实际上就是一种插件,Android 系统本身是没有这些插件的,而作为开发者,我们实际上开发 App 就是开发插件的过程,通过发布,容器加载了插件,从而可以使用。

Atlas 就是按照这样的思想,他们试图将 Atlas 设计成这样的组件,我们所有的 bundle 就是插件。这是从一个特殊的角度来看待 Atlas 框架实现的一个效果。我们下面来看一张时序图,时序图能够帮我们更好的理清源码的调用逻辑和思路。

时序图

这张图描述了集成了 Atlas 框架后,整个 App 的启动流程,图来源于官网。通过一些技术手段,我们绕过了一些系统的步骤,达到我们期望的目的。

本文只关注步骤 1-5。

1. 入口

app 的入口是application,这每一个 Android 开发者都了解,我们来看一下集成了 Atlas 后 application 是什么。代码来源于atlas-demo

<application
    android:name=".DemoApplication"
    android:allowBackup="true"
    ```
    >

看起来,入口就应该是DemoApplication,但我们看一下反编译 apk 后拿到的 manifest

<application
    android:name="android.taobao.atlas.startup.AtlasBridgeApplication"
    android:allowBackup="true"
    ```
    >

很奇怪,实际的入口却是AtlasBridgeApplication,原来,Atlas 在编译期就已经偷偷的替换了入口 application,但实际上,运行期依然会调用你自己写的 DemoApplication 的相关方法。我们来看下AtlasBridgeApplication的源码。根据常规的启动流程,我们跟进方法attachBaseContext

protected void attachBaseContext(Context base){
    super.attachBaseContext(base);
    //一些逻辑

    //1.构造 BridgeApplicationDelegate 对象
    Class BridgeApplicationDelegateClazz =getBaseContext().getClassLoader.loadClass("android.taobao.atlas.bridge.BridgeApplicationDelegate");
    Constructor<?> con = BridgeApplciationDelegateClazz.getConstructor(parTypes);
    mBridgeApplicationDelegate = con.newInstance(this,getProcessName(...));

    //2.执行 BridgeApplicationDelegate 的 attachBaseContext 方法
    Method method = BridgeApplicationDelegateClazz.getDeclaredMethod("attachBaseContext");
    method.invoke(mBridgeApplicationDelegate);
}

如果大家是看源码,这段代码还是比较复杂也比较长,但实际上我们抽取关键部分发现,它实际上就做了两件事:

  1. 反射构造 BridgeApplicationDelegate 实例
  2. 执行 BridgeApplicationDelegate 的 attachBaseContext 方法

这时候我们就要保持耐心,进入 BridgeApplicationDelegate 类,先看它的构造方法:

public BridgeApplicationDelegate(Application rawApplication ...){
    mRawApplication = rawApplication;
    PackageManagerDelegate.delegatepackageManager(
        rawApplication.getBaseContext()
    );
}

跟进方法delegatepackageManager:

public static void delegatepackageManager(Context context){
    mBaseContext = context;
    1.反射pm
    PackageManager manager = mBaseContext.getPackageManager();
    Class ApplicationPackageManager = Class.forName("android.app.ApplicationPackageManager");
    Field field = ApplicationPackageManager.getDeclaredField("mPM");
    field.setAccessible(true);
    Object rawPm = field.get(manager);
    2.动态代理
    Class IPackageManagerClass = Class.forName("android.content.pm.IPackageManager");
    mPackageManagerProxyhandler = new PackageManagerProxyhandler(rawPm);
    mProxyPm = Proxy.newProxyInstance(mBaseContext.getClassLoader,new Class[]{IPackageManagerClass},mPackageManagerProxyhandler);
    3.替换pm

这段代码,注释写的比较清楚了,核心思想就是把系统的 pm 替换为我们实现的动态代理PackageManagerProxyhandler,具体该动态代理的实现我们先不细讲,动态代理不熟悉的同学可以自己去查阅下资料,网上很多,我们现在继续看BridgeApplicationDelegateattachBaseContext的实现。

2. BridgeApplicationDelegate.attachBaseContext

public void attachBaseContext(){
    //2.1 hook 之前要准备的工作
    AtlasHacks.defineAndVerify();
    
    //2.2 回调预留接口
    launcher.initBeforeAtlas(mRawApplication.getBaseContext());

    //2.3 初始化 atlas
    Atlas.getInstance().init(mRawApplication,mIsUpdated);

    //2.4 处理 provider
    AtlasHacks.ActivityThread$AppBindData_providers.set(mBoundApplication,null);
}

通过注释我们可以看到,方法被分成了四个部分,我们将一个个来看这四个步骤,需要提醒的是,大家需要记住现在的代码位置,对应好文章开头的时序图,理清我们整体的代码逻辑。

3. AtlasHacks.defineAndVerify

我们知道,Android 上所有的动态加载方案,有三个关键的地方是必须要处理的:

  • 动态加载 class
  • 动态加载资源
  • 处理四大组件

为了实现这三个目标,我们需要在系统关键调用处进行 Hook,例如我们会通过对 ClassLoader 做一些手脚来进行动态加载 Class,四大组件的处理比较特殊,这在之前我们也提到了。

回到正题,我们来看看 Atlas 为了实现上述的要求做了什么。我们先看下AtlasHacks这个类,这个类是用来定义 Atlas 需要 Hook 的类、方法、属性等字段。这段代码的静态成员变量的代码风格特别棒,大家可以学习一下,通过设置 AS 可以做到这样的效果。

//AtlasHacks.java

     // Classes
    public static HackedClass<Object>                           LoadedApk;
    public static HackedClass<Object>                           ActivityThread;
    public static HackedClass<android.content.res.Resources>    Resources;

    // Fields
    public static HackedField<Object, Instrumentation>          ActivityThread_mInstrumentation;
    public static HackedField<Object, Application>              LoadedApk_mApplication;
    public static HackedField<Object, Resources>                LoadedApk_mResources;

    // Methods
    public static HackedMethod                                  ActivityThread_currentActivityThread;
    public static HackedMethod                                  AssetManager_addAssetPath;
    public static HackedMethod                                  Application_attach;
    public static HackedField<Object, ClassLoader>              LoadedApk_mClassLoader;

    // Constructor
    public static Hack.HackedConstructor                        PackageParser_constructor;

我们跟进方法defineAndVerify函数:

//AtlasHacks.java
public static boolean defineAndVerify() throws AssertionArrayException{
    allClasses();
    allConstructors();
    allFields();
    allMethods();
}

这几个方法就是对之前定义字段进行赋值,例如allFields()方法:

public static void allFields() throws HackAssertionException{
    ActivityThread_mInsturmentation = ActivityThread.field("mInstrumentation").ofType(Instrumentation.class);
    ActivityThread_mAllApplications = ActivityThread.field("mAllApplications").ofGenericType(ArrayList.class);
}

执行到这里,Atlas 框架的准备工作就完成了,下面就是整个框架的初始化。

4. 回调预留接口

这里的回调接口就是调用我们在 Atlas 配置的时候设置的preLaunch()方法,该方法会在 Atlas 初始化之前运行的。我们来探索下 Atlas 是如何找到这个方法并调用的。

在时序图中的第 4 步,BridgeApplicationDelegate.attachBaseContext()这个方法中,做了一个接口回调。

public void attachBaseContext(){
    String preLaunchStr = (String)RuntimeVariables.getFrameworkProperty("preLaunch");
    AtlasPreLauncher launcher = (AtlasPreLauncher) Class.forName(preLaunchStr).newInstance();
    launcher.initBeforeAtlas(mRawApplication.getBaseContext());
}

通过preLaunch字段读取类名,反射类上的initBeforeAtlas方法,AtlasPreLauncher实际上还是个接口,供接入者使用,在这个点上,Atlas 还没有对系统进行 hook,目前仍然是 Android 原生的运行环境。那么这个preLaunch字段到底在哪里定义的呢?我们来反推一下。

进入RuntimeVariables.java

public static Object getFrameworkProperty(String fieldName){
    Field field = FrameworkPropertiesClazz.getDeclaredField(fieldName);
    return field.get(FrameworkPropertiesClazz);
}

我们跟进FrameworkProperties发现这个类啥都没用,是个空实现,这种反常肯定有鬼,我们直接去看反编译的代码

public class FrameworkProperties{
    public static String autoStartBundles;
    public static String preLaunch;

    static{
        autoStartBundles = "com.taobao.firstbundle";
        preLaunch = "com.taobao.demo.DemoPreLaunch";
    }
}

看到这里我们就回想起来我们在 Gradle 配置的代码了,原来 Atlas 在 gradle 插件在编译的时候干了不少事。

    tBuildConfig{
        autoStartBundles = ['com.taobao.firstbundle']
        preLaunch = 'com.taobao.demo.DemoPreLaunch'
    }

这个部分牵扯开发-编译-运行三个阶段,下图可以帮助你捋一下关系。


三个阶段

5. atlas.init

准备工作做好之后,就是初始化了。我们回到Atlas.java这个类中。

public void init(Application application,boolean reset){
    //读取配置项
    ApplicationInfo appInfo = mRawApplication.get...;
    mRealApplicationName = appInfo.metaData.getString("REAL_APPLICATION");
    boolean multidexEnable = appInfo.metaData.getBoolean("multidex_enable");

    if(multidexEnable){
      MultiDex.install(mRawApplication);
   }
    //...   
}

这里读取了两个在编译期由 Atlas 插件写到 manifest 中的数据multidexEnablemRealApplicationName,在 manifest 中它们是这样的:

<meta-data android:name="REAL_APPLICATION" android:value="com.taobao.demo.DemoApplication"/>
<meta-data android:name="multidex_enable" android:value="true"/>

multidexEnable 是 true,这个是在 gradle 中可配的。
mRealApplicationName 实际上是 DemoApplication,即 app 工程在 manifest 中指定的启动路径。下面我们继续看 init 方法。

public void init(Application application,boolean reset) {
   //...
   Atlas.getInstance().init(mRawApplication, mIsUpdated);
}

public void init(Application application,boolean reset) throws AssertionArrayException, Exception {
     //...

     //1. 换classloader 
     AndroidHack.injectClassLoader(packageName, newClassLoader);
     //2. 换Instrumentatio
     AndroidHack.injectInstrumentationHook(new InstrumentationHook(AndroidHack.getInstrumentation(), application.getBaseContext()));
     //3. hook ams
     try {
         ActivityManagerDelegate activityManagerProxy = new ActivityManagerDelegate();
         Object gDefault = null;
         if(Build.VERSION.SDK_INT>25 || (Build.VERSION.SDK_INT==25&&Build.VERSION.PREVIEW_SDK_INT>0)){
             gDefault=AtlasHacks.ActivityManager_IActivityManagerSingleton.get(AtlasHacks.ActivityManager.getmClass());
         }else{
               gDefault=AtlasHacks.ActivityManagerNative_gDefault.get(AtlasHacks.ActivityManagerNative.getmClass());
         }
         AtlasHacks.Singleton_mInstance.hijack(gDefault, activityManagerProxy);
      }catch(Throwable e){}
      //4. hook H
      AndroidHack.hackH();
}

这里,注释也把具体步骤写了,主要是对系统关键地方进行了 hook,hook 的具体细节大家可以看源码或者田维术的 blog。

6. 处理 provider

在 2.4 步骤中,处理了 provider。

public void attachBaseContext(){
    //...

   //2.4 处理provider
   Object mBoundApplication = AtlasHacks.ActivityThread_mBoundApplication.get(activityThread);
   mBoundApplication_provider = AtlasHacks.ActivityThread$AppBindData_providers.get(mBoundApplication);
   if(mBoundApplication_provider!=null && mBoundApplication_provider.size()>0){
           AtlasHacks.ActivityThread$AppBindData_providers.set(mBoundApplication,null);
    }
}

这里先读取 provider 数据,如果有的话,就从系统中删除,让系统认为 apk 并没有申请任何 provider,那么为啥要这么做呢,我们先回顾下 app 启动的流程:


启动流程

上图中可以看到,第 4 步和第 7 步这两个关键调用之间,第 5 步调用了installContentProviders

private void installContentProviders(Context context, List<ProviderInfo> providers) {
    for (ProviderInfo cpi : providers) {
        installProvider(context, null, cpi,...);
    }
}

收集了所有 provider 的信息,并且调用了installProvider方法:

private IActivityManager.ContentProviderHolder installProvider(Context context,IActivityManager.ContentProviderHolder holder, ProviderInfo info,...) {
    final java.lang.ClassLoader cl = c.getClassLoader();
   localProvider = (ContentProvider)cl.loadClass(info.name).newInstance();
   //...
}

这里的函数会根据 manifest 中登记的 provider 信息,实例化对象。但有些 provider 是存在于 bundle 中的,在 主 dex 中并不存在,如果不先清除掉 provider 的信息,进行延迟加载,程序就会出现ClassNotFind崩溃,这就是为啥要有一个清除操作的原因。

未完待续。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容