漫聊 Android 插件化

前言

聊完了热修复,那么它的好姐们插件化怎么能不说。

原理剖析

一、加载类

即加载外部的dex,这里有两步操作

第一步:安装

即把外部的apk里的dex拷贝系统目录下。apk 来源分两种,一种来自内置在Assert目录下的插件,一种是来自网络下载的。
拷贝包含三个部分:

  • 将插件(apk)拷贝到创建的存储插件的文件夹下,data/data/包名/插件名
  • 创建一个dex 输出文件夹,即dexOutputDir ,这个在创建加载插件的ClassLoader时需要需要(在构造这个ClassLoader时,还需要一个dexPath,这个就是前面apk内置存储的文件路径)
  • 拷贝插件里的so

第二步:加载

系统的ClassLoader是不会去加载外部的Apk 里的类,因此就需要自定义ClassLoader去加载。传统做法是用DexClassLoader 去加载插件里的类。
但有的开源项目不一样,掌阅加载插件的PluginClassLoader 不是继承自DexClassLoader,而是继承ClassLoader。

在加载类的时候,入口是从IreaderClassloader进行分发,我们先调用系统的ClassLoader去加载类,如果加载到了,则返回。没有加载到,则继续从自定义的PluginClassLoader去加载。

PluginClassLoader加载类的过程也是先从系统的根ClassLoader 加载类(即双亲委托模型,这样做既可以避免类的重复加载,也可以避免自定义类来替换系统的核心类的API而导致的安全隐患),即调用系统的findLoadClass方法。如果为空,表示类没有找到。接下来就自定义实现父类的findClass方法,在加载类前,首先要先加载插件里的dex文件,在安装插件的时候,就可以每一个插件的dex输入路径和输出路径,这里需要调用的方法是DexFile 的LoadDex方法(最终会调用Native层的方法加载dex),然后返回一个dex,这里会将已经加载的dex放在一个dex数组中。插件的dex安装完成后,就是加载类,这里就会调用DexFile 里的LoadClass方法。

总结起来,PluginClassLoader的角色很像BaseDexClassLoader,因为无论是DexClassLoader还是PathClassLoader ,他们加载类的逻辑都是在其父类BaseDexClassLoader里实现的。掌阅里的mDexs数组就是对应系统的DexPathList。(这样做还有一个目的,就是去实现热修复的功能,掌阅的热修复类HotfixClassLoader使用的是类替换的方式,这里就会涉及到加载dex顺序的问题,具体实现思路我在热修复部分有过讲解,这里不啰嗦)

这里还是要补充几点:
补充1:关于PathClassLoader 和DexClassLoader 的区别?(这个在ClassLoader文章里有提到,还是想在提下,因为非常重要)
早在Android 4.4 版本之前,他们的区别和网上说的一样,即:
• DexClassLoader : 可加载jar、apk和dex,可以SD卡中加载
• PathClassLoader : 只能加载已安裝到系統中(即/data/app目录下)的apk文件

在Android 5.0 ~ Android 8.0 系统之间,PathClassLoader没有该限制,而且由于PathClassLoader中optimizedDirectory固定为null,所以无法进行dex2oat操作,最后会直接加载原始dex,达到了禁用dex2oat以实现加载加速的效果!

在Android 8.1以上,DexClassLoader 和 PathClassLoader 已经没有区别,因为他们的optimizedDirectory都是固定传递null,oat输出目录在dex目录/oat/下.
总结下:DexClassLoader 和 PathClassLoader 都可加载外部的dex或者apk.

补充2:接着上面的要点,optimizedDirectory 参数传空和不为空有什么区别 ?
optmizedDirectory 不为空时,使用用户定义的目录作为 DEX 文件优化后产物 .odex 的存储目录,为空时,会使用默认的 /data/dalvik-cache/ 目录。

二、加载资源

Android 资源分类

  • 原始资源:就是放在res目录下的可编译的资源。编译时会生成R.java文件。方位访问时通过Context的getResource方法获取
  • 编译资源:放在assert目录下,不参与编译。访问时需要使用AssertManager
    关于AssertManager和Resource关系的补充。Resource对外提供的getString 、getDrawable等方法,都是间接调用的AssertManager的私有方法。这个的具体调用顺序是在执行getDrawable方法时,调用的ResourceImpl类的loadDrawable方法,内部又调用了AssertManager类的私有方法getSourceName,这个方法的具体实现在Native层。

1、为啥系统无法加载外部资源 ?

在APP启动的时候,系统会使用AssetManager中一个addAssetPath(String path) 方法将当前的apk路径传进去,那么接下来AssertManager和Resource就能访问当前apk 的所有资源。

2、加载插件资源的方式?

常用的做法使用的是反射AssertManager里的addAssetPath方法,将当前的插件传入,将插件资源加入到系统的资源池中。(热修复里关于资源的修复用的就是这个套路,和插件化里加载插件里的资源实现思路是一样的)

3、如何解决宿主资源ID和插件资源ID之间的冲突?

  • 修改AAPT中的打包命令,由于宿主的资源ID 指定是0x7f,因此需要指定插件的资源ID,例如0x71。
  • 在插件apk打包后,修改 R.java 和 resource.arsc 中存储的资源ID值
  • 为每一个插件生成一个AssertManager和Resource,使用这两个对象加载对应插件的资源。当然也只能加载插件的资源,不会和宿主冲突。

4、补充

考虑到APP 在每次打包后,会随着资源的增减,同一个资源的ID 值也会随着变化。如果宿主APP 的某个资源ID被插件引用,那么就会出现插件资源ID无法找到。
解决办法:将宿主的资源ID写死,即创建一个public.gradle 文件,使用gradle脚本将工程中的资源ID固定主

三、四大组件插件化

1、Activity插件化

目前关于Activity的插件化有3种实现思路:

  • 反射实现;由于影响性能,所以主流的框架都没有使用。
  • 接口实现;任玉刚的dynamic-load-apk 用的就是这个。
  • Hook技术实现;主流实现,重点分析。

使用Hook技术实现的方案又分两种:

  • Hook IActivityManager ;用这个实现最典型的例子就是滴滴的VirtualApk。关于VirtualApk 后面我会单独开一片文章讲解,顺便会详细介绍这种方案的实现原理。
  • Hook Instrumentation;这个实现方案要简单些,原理上和Hook IActivityManager有共通的地方,也是接下来重点讲解的实现思路。

上面两种Hook方案,有一个共通的点,就是需要在清单文件里“占坑”来骗过AMS,否则运行时一定报Activity找不到,不过VirtualApk做了一件很讨巧的事情,官方声明里虽然说不用手动去清单文件里写占坑的Activity,但不代表没有用占坑,而是人家帮你写了,在工程编译期间通过Gradle往你的清单文件里写了占坑的Activity
Hook Instrumentation 实现思路
要做的事情分两步:

  • 1、先用占坑的Activity去骗AMS
  • 2、启动运行时,在将目标Activity替换掉占坑的Activity的。

我们知道,在启动Activity通过AMS校验前,会先执行Instrumentation的方法:

public void startActivityForResult(@RequiresPermission Intent intent, int requestCode,
            @Nullable Bundle options) {
    
    
        if (mParent == null) {
    
    
           .......
            Instrumentation.ActivityResult ar =
                mInstrumentation.execStartActivity(
                    this, mMainThread.getApplicationThread(), mToken, this,
                    intent, requestCode, options);
    .......
}

接着看下execStartActivity 的实现,这个方法超级长,这里我做下裁剪,然后加上注释

public int execStartActivitiesAsUser(Context who, IBinder contextThread,
            IBinder token, Activity target, Intent[] intents, Bundle options,
            int userId) {
    
    
    .......
        try {
    
    
            ......
            // 这里的ActivityManager.getService() 返回的是IActivityManager ,他是一个Binder对象
            // 这个intents 就是我们要启动的目标Activity,下面就是将这个目标Activity送到AMS去做校验了
            // 因此,这里是一个Hook点,将目标Activity的换成占坑的Activity的,从而骗过AMS的校验
            int result = ActivityManager.getService()
                .startActivities(whoThread, who.getBasePackageName(), intents, resolvedTypes,
                        token, options, userId);
            checkStartActivityResult(result, intents[0]);
            return result;
        } catch (RemoteException e) {
    
    
            throw new RuntimeException("Failure from system", e);
        }
    }

再来看看启动目标Activity时做的事情,在ActivityThread中,最终通过H类的消息执行performLaunchActivity方法,这个方法做了非常多且重要的事情。

private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
    
    
        ActivityInfo aInfo = r.activityInfo;
        ......
        // 创建上下文环境
        ContextImpl appContext = createBaseContextForActivity(r);
        Activity activity = null;
        try {
    
    
            java.lang.ClassLoader cl = appContext.getClassLoader();
            // 创建intent 目标Activity,这里就是Hook 点
            activity = mInstrumentation.newActivity(
                    cl, component.getClassName(), r.intent);
           ......
        try {
    
    
            // 创建Application
            Application app = r.packageInfo.makeApplication(false, mInstrumentation);
			......
                appContext.setOuterContext(activity);
                // attach方法里创建了Window等,事情多,重点看
                activity.attach(appContext, this, getInstrumentation(), r.token,
                        r.ident, app, r.intent, r.activityInfo, title, r.parent,
                        r.embeddedID, r.lastNonConfigurationInstances, config,
                        r.referrer, r.voiceInteractor, window, r.configCallback);
                // 设置Activity的主题,换皮肤时这里可以注意这个theme
                int theme = r.activityInfo.getThemeResource();
                if (theme != 0) {
    
    
                    activity.setTheme(theme);
                }

                activity.mCalled = false;
                if (r.isPersistable()) {
    
    
                    // 调用了目标Activity的onCreate方法
                    mInstrumentation.callActivityOnCreate(activity, r.state, r.persistentState);
                } else {
    
    
                    mInstrumentation.callActivityOnCreate(activity, r.state);
                }
                ......
        return activity;
    }

我们看下Instrumentation.newActivity 的实现

public Activity newActivity(ClassLoader cl, String className,
            Intent intent)
          ....
        return getFactory(pkg).instantiateActivity(cl, className, intent);
    }

public @NonNull Activity instantiateActivity(@NonNull ClassLoader cl, @NonNull String className,
            @Nullable Intent intent)
            throws InstantiationException, IllegalAccessException, ClassNotFoundException {
    
    
        // 反射创建Activity
    	return (Activity) cl.loadClass(className).newInstance();
    }

根据Activity的启动流程,我们会发现Instrumentation 是一个很好的Hook点,只要Hook两个方法即可,因此,我们就可以自定义一个Instrumentation 将系统的给换掉,然后重写那两个方法的实现,即可。
这里看下我写的实现:

package coffer.util;

import android.app.Instrumentation;

import java.lang.reflect.Field;
import java.lang.reflect.Method;

/**
 * @author:coffer
 * @date:2020/8/22
 * @Description:
 * @Reviser:
 * @RevisionTime:
 * @RevisionDescription:
 */

public class HookUtils {
    
    

    private static final String TAG = "Hooker";

    public static void hookInstrumentation(){
    
    
        Class<?> activityThread = null;
        try {
    
    
            activityThread =Class.forName("android.app.ActivityThread");
            Method currentActivityThread = activityThread.getDeclaredMethod("currentActivityThread");
            currentActivityThread.setAccessible(true);

            // 获取当前的系统的ActivityThread 对象
            Object activityThreadObject = currentActivityThread.invoke(activityThread);

            // 获取Instrumentation 对象
            Field mInstrumentation = activityThread.getDeclaredField("mInstrumentation");
            mInstrumentation.setAccessible(true);
            Instrumentation instrumentation = (Instrumentation) mInstrumentation.get(activityThreadObject);
            CofferInstrumentation customInstrumentation = new CofferInstrumentation(instrumentation);
            // 将我们自定义的 Instrumentation 把系统的替换掉
            mInstrumentation.set(activityThreadObject,customInstrumentation);
        }catch (Exception e){
    
    
            e.getMessage();
        }
    }
}

HookUtils 的调用时机可以放在自定义Application的attachBaseContext方法中,要尽可能的早。接着看下自定义Instrumentation 的实现。

package coffer.util;

import android.app.Activity;
import android.app.Instrumentation;
import android.content.Intent;

import coffer.androidDemo.animdemo.PropertyAnimActivity;

/**
 * @author:coffer
 * @date:2020/8/22
 * @Description: 将要启动的Activity替换成我们自定义的Activity.
 *
 *  应用在启动一个新的Activity时,会执行ActivityThread # performLaunchActivity 方法。在这个方法中有
 *             java.lang.ClassLoader cl = appContext.getClassLoader();
 *             activity = mInstrumentation.newActivity(
 *                     cl, component.getClassName(), r.intent);
 *             StrictMode.incrementExpectedActivityCount(activity.getClass());
 *             r.intent.setExtrasClassLoader(cl);
 *             r.intent.prepareToEnterProcess();
 *             if (r.state != null) {
 *                 r.state.setClassLoader(cl);
 *             }
 *
 * 这里要做的就是重写 Instrumentation{
    
    {@link #newActivity(ClassLoader, String, Intent)}}方法
 * @Reviser:
 * @RevisionTime:
 * @RevisionDescription:
 */

public class CofferInstrumentation extends Instrumentation{
    
    

    private Instrumentation mBase;
	private String TARGET_INTENT_NAME;

    public CofferInstrumentation(Instrumentation mBase, PackageManager packageManager) {
    
    
        this.mBase = mBase;
    }
    
    public ActivityResult execStartActivity(
            Context who, IBinder contextThread, IBinder token, Activity target,
            Intent intent, int requestCode, Bundle options) {
    
    
        List<ResolveInfo> infos = mPackageManager.queryIntentActivities(intent,PackageManager.MATCH_ALL);
        if (infos == null || infos.size() == 0){
    
    
            // 将要启动的Activity 先存储起来,方便后面替换
            intent.putExtra(TARGET_INTENT_NAME,intent.getComponent().getClassName());
            // 将要送去AMS验证的Activity换成占坑的
            intent.setClassName(who,"com.coffer.subActivity");
        }
        try {
    
    
            // 反射调用execStartActivity
            @SuppressLint("DiscouragedPrivateApi") Method execMethod = Instrumentation.class.getDeclaredMethod("execStartActivity",
                    Context.class,IBinder.class,IBinder.class,Activity.class,Intent.class,int.class,Bundle.class);
            return (ActivityResult) execMethod.invoke(mBase,who,contextThread,token,target,intent,requestCode,options);
        }catch (Exception e){
    
    

        }
        return null;
    }

    @Override
    public Activity newActivity(ClassLoader cl, String className, Intent intent)
            throws ClassNotFoundException, IllegalAccessException, InstantiationException {
    
    
        try {
    
    
            //这里需要setExtrasClassLoader 不然的话,getParecleable 对象可能会拿不到
            //很多hook Instrumentation的人都不知道。
            // 这里try catch 是防止恶意攻击  导致android.os.BadParcelableException: ClassNotFoundException when unmarshalling
            intent.setExtrasClassLoader(cl);
        }catch (Exception e){
    
    
            e.printStackTrace();
        }
        
        // 这里直接让它跳转到属性动画的Activity,这里设置自己的跳转逻辑,将占坑的替换掉,通过解析intent 里的数据
        String intentName = intent.getStringExtra(TARGET_INTENT_NAME);
        if (TextUtils.isEmpty(intentName)){
    
    
            return super.newActivity(cl, intentName, intent);
        }
        return super.newActivity(cl, className, intent);
    }
}

这样来看是不是非常的简单,两个类就可搞定,但这背后是建立在你对Activity的启动流程非常的前提。

不过,这个看似简单,但背后却有一个隐藏的坑,如果感兴趣的话,欢迎在评论区留言说说你的想法。具体这个坑是啥,我会在讲VirtualApk 实现Activity插件化原理中会提到。

2、Service插件化

Service的插件化实现要比Activity复杂的多,和Activity不一样的地方是它没有用到Instrumentation作为辅助启动。我们看看源码:在调用ContextImpl类的中的startService方法后,执行的逻辑

private ComponentName startServiceCommon(Intent service, boolean requireForeground,
            UserHandle user) {
    
    
        try {
    
    
            validateServiceIntent(service);
            service.prepareToLeaveProcess(this);
            // 这边就直接用AMS 校验去了
            ComponentName cn = ActivityManager.getService().startService(
                mMainThread.getApplicationThread(), service, service.resolveTypeIfNeeded(
                            getContentResolver()), requireForeground,
                            getOpPackageName(), user.getIdentifier());
            ......
    }

同样,从AMS回来启动创建Service时的逻辑:

 private void handleCreateService(CreateServiceData data) {
    
    
        // If we are getting ready to gc after going to the background, well
        // we are back active so skip it.
        unscheduleGcIdler();

        LoadedApk packageInfo = getPackageInfoNoCheck(
                data.info.applicationInfo, data.compatInfo);
        Service service = null;
        try {
    
    
  			
            java.lang.ClassLoader cl = packageInfo.getClassLoader();
            //  这里Activity的启动中是用Instrumentation 封装调用的,这里直接调用了
            service = packageInfo.getAppFactory()
                    .instantiateService(cl, data.info.name, data.intent);

            ContextImpl context = ContextImpl.createAppContext(this, packageInfo);
            context.setOuterContext(service);

            Application app = packageInfo.makeApplication(false, mInstrumentation);
            service.attach(context, this, data.info.name, data.token, app,
                    ActivityManager.getService());
            service.onCreate();
            mServices.put(data.token, service);
            try {
    
    
                // 这地方最终会启动Service,调用非常的长,交给ActiveServices类的serviceDoneExecutingLocked处理
                ActivityManager.getService().serviceDoneExecuting(
                        data.token, SERVICE_DONE_EXECUTING_ANON, 0, 0);
            } catch (RemoteException e) {
    
    
                throw e.rethrowFromSystemServer();
            }
        } catch (Exception e) {
    
    
            if (!mInstrumentation.onException(service, e)) {
    
    
                throw new RuntimeException(
                    "Unable to create service " + data.info.name
                    + ": " + e.toString(), e);
            }
        }
    }

从源码中可以看到Service的启动是直接忽略了Instrumentation 类的,因此无法使用Hook Instrumentation 来实现,只能使用Hook IActivityManager实现。这个部分实现原理的内容会和VirtualApk 重合,所以这里仅简单讲下思路,后面讲VirtualApk 时重点分析。

Service由于其自身的特性,考虑的点也会非常的多。

3、ContentProvider插件化

ContentProvider由于使用频率太低了(我本人到目前为止仅使用过一次,是在和宿主APP 做数据透传时用的),很多插件框架都不支持。这里还是推荐学习VirtualApk,它Hook的是IContentProvider,这个和前面提到的IActivityManager 一样,都是Binder。具体实现后面说。

4、BroadcastReceiver插件化

广播的使用频率就多了,因此这个还是很重要的。最终的实现方案这里会用VirtualApk 的实现。这里先简单说下,就是将插件里所有声明静态广播的部分全部转成动态注册,至于为啥,以后说。

总结下:

这里面我重点讲了Activity的插件化,后面都是省略。插件化的本质上就是各种Hook ,而如何Hook ?从那Hook,就需要深刻理解四大组件的启动流程才行。

Coffer 寄语

这篇文章和热修复原理姊妹篇的关系,可以一起看,里面有很多技术相通的点。
现在想想,掌阅的插件化实现思路算是业内最简单的一种方式了,简单的原因是它不支持四大组件以及一些Android 特殊属性。掌阅是直接将插件里需要使用的组件,声明在主工程的清单文件中,这样的做法我觉的最大的好处就是降低了适配Android 系统的难度,谷歌一直在限制开发者对Android 系统各种hook,因此从Android P 开始加入了黑名单机制,很多系统的API 直接标注是@hide了,四大四大组件的启动方式在Android O 版本甚至做了调整,去掉了AMP 改成用AIDL的方式(鬼知道后面还会不会继续改,我觉的有可能),到了Android R的preview 版本,甚至改了资源的加载方式,对Resource做了限制,一度让我们适配到崩溃、绝望(还好技术老大最后果断亲自出手解决难题,让我们度过难关【这就是老大最该做的事情】,后来运行在Android R 的正式版本没事,那个算是preview 版本的BUG),不过我们遇到的适配难度相比那些支持四大组件的开发框架难度还是小些。

不支持四大组件是不是好事?我觉的要看项目框架的设计了。如果在插件中硬要用Activity也可以,直接在主工程里注册即可,不过这样的做法就会导致不能让该功能兼容到老版本了。所以,对于技术框架的选型,适合自己的、够用就行,没必要追求大而全的东西。掌阅的插件的框架在业内算不上优秀的那一批(好像很多人都不知道,但我们真的开源了啊,
链接: https://github.com/iReaderAndroid/ZeusPlugin.)但一定是最容易上手学习的那个。

当然,要想实现一个强壮的插件框架,难度系数还是非常高的,比如在多线程下插件的安装、加载等会出现很多意想不到的BUG,要考虑的点非常多,还有就是插件的安全校验,掌阅的插件框架也在不断的完善和升级。上面的那个GitHub链接代码好几年没维护了,就当学习用吧,如果想用在实战项目中,强烈不建议自己动手造轮子,因为成本和风险都很高,建议使用滴滴开源的VirtualApk 。

作为平时做插件开发的我,理应对插件化的理解更加深刻才行,不仅要深度(例如实现原理以及相关细节、技术难点、解决思路),同时还要有广度,即同行是咋实现的?你们和同行对比有哪些优劣势?说来惭愧,关于同行技术研究我做的很不够,这个是我的盲点。后面我会在“开源框架”文集里聊聊同行滴滴的插件化框架——VirtualApk 的实现原理。
这次寄语说的有点多了,给个赞吧。

猜你喜欢

转载自blog.csdn.net/qq_26439323/article/details/112435490