Android插件化加载调研

[TOC]

插件化介绍

  • 插件化: 在主程序能独立运行的前提下,插件程序给主程序提供一些辅助的功能,目前主要以apk或者jar包的方式提供插件。
  • 动态加载:主程序在需要的时候才加载该功能的class文件,以达到减少内存占用的目的。
  • 主程序:支持插件功能的应用。大型app都有类似的需求。
  • 插件程序:给主程序作为补充的程序。例如一些二维码扫描之类的程序。

开源框架

实现原理介绍

如何加载插件里的class

DexClassloader

public DexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }

具体参数的含义

Parameters:
    dexPath 需要装载的APK或者Jar文件的路径。包含多个路径用File.pathSeparator间隔开,在Android上默认是 ":" 
    optimizedDirectory  优化后的dex文件存放目录,不能为null
    libraryPath 目标类中使用的C/C++库的列表,每个目录用File.pathSeparator间隔开; 可以为 null
    parent  该类装载器的父装载器,一般用当前执行类的装载器

<font color='red'>classloader会有parent的classloader,这样查找类的时候不管是自己的还是parent的类都能够查找到。</font>

具体查找过程

浅析dex文件加载机制

如何使用插件里的资源

在Android里,资源的获取是通过context里的两个方法来实现的

/** Return an AssetManager instance for your application's package. */

public abstract AssetManager getAssets();

/** Return a Resources instance for your application's package. */

public abstract Resources getResources();

重写这个两个方法,返回含有插件资源的Resource对象。

加载资源的方法是通过反射,通过调用AssetManager中的addAssetPath方法,我们可以将一个apk中的资源加载到Resources对象中,由于addAssetPath是隐藏API我们无法直接调用,所以只能通过反射, 代码如下:

try {
        AssetManager assetManager = AssetManager.class.newInstance();
        Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
        addAssetPath.invoke(assetManager, mDexPath);
        mAssetManager = assetManager;
    } catch (Exception e) {
        e.printStackTrace();
    }

只要想办法把这个重新实现的的context对象返回给插件,就可以加载插件的资源。这个后面会再讨论到。

如何启动插件中的activity

上面提到的两点基本上是业内已经确定的做法,后面则不是特别确定,我们分实现方式来讨论。

方式一:静态代理的模式 dynamic-load-apk

将Activity的生命周期方法提取出来作为一个接口(比如叫DLPlugin),然后通过代理Activity去调用插件Activity的生命周期方法,这样就完成了插件Activity的生命周期管理

public interface DLPlugin {
    public void onStart();
    public void onRestart();
    public void onActivityResult(int requestCode, int resultCode, Intent
    data);
    public void onResume();
    public void onPause();
    public void onStop();
    public void onDestroy();
    public void onCreate(Bundle savedInstanceState);
    public void setProxy(Activity proxyActivity, String dexPath);
    public void onSaveInstanceState(Bundle outState);
    public void onNewIntent(Intent intent);
    public void onRestoreInstanceState(Bundle savedInstanceState);
    public boolean onTouchEvent(MotionEvent event);
    public boolean onKeyUp(int keyCode, KeyEvent event);
    public void onWindowAttributesChanged(LayoutParams params);
    public void onWindowFocusChanged(boolean hasFocus);
    public void onBackPressed();
}

首先 插件中的Activity要继承DLBasePluginActivity

public class DLBasePluginActivity extends FragmentActivity implements DLPlugin {

然后主程序的Manifest文件中要注册用来代理的Activity

<activity
  android:name="com.ryg.dynamicload.DLProxyActivity"
  android:label="@string/app_name" >
            <intent-filter>
                <action android:name="com.ryg.dynamicload.proxy.activity.VIEW" />

                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
        </activity>

DLProxyActivity内容基本如下:

@Override
protected void onStart() {
     mRemoteActivity.onStart();
     super.onStart();
}
@Override
public AssetManager getAssets() {
     return impl.getAssets() == null ? super.getAssets() : impl.getAssets();
}

@Override
public Resources getResources() {
    return impl.getResources() == null ? super.getResources() : impl.getResources();
}

mRemoteActivity就是插件中的activity

这种方式的优势是实现简单易懂,缺点是插件必须基于插件库开发,必须要继承DLBasePluginActivity。

缺点:

  1. 慎用this(接口除外):因为this指向的是当前对象,即apk中的activity,但是由于activity已经不是常规意义上的activity,所以this是没有意义的,但是如果this表示的是一个接口而不是context,比如activity实现了而一个接口,那么this继续有效。
  2. 使用that:既然this不能用,那就用that,that是apk中activity的基类BaseActivity中的一个成员,它在apk安装运行的时候指向this,而在未安装的时候指向宿主程序中的代理activity,anyway,that is better than this。
  3. activity的成员方法调用问题:原则来说,需要通过that来调用成员方法,但是由于大部分常用的api已经被重写,所以仅仅是针对部分api才需要通过that去调用用。同时,apk安装以后仍然可以正常运行。
  4. 启动新activity的约束:启动外部activity不受限制,启动apk内部的activity有限制,首先由于apk中的activity没注册,所以不支持隐式调用,其次必须通过BaseActivity中定义的新方法startActivityByProxy和startActivityForResultByProxy,还有就是不支持LaunchMode。

方式二: 反射的方式

@Override
protected void onResume() {
    super.onResume();
    Method onResume = mActivityLifecircleMethods.get("onResume");
    if (onResume != null) {
        try {
            onResume.invoke(mRemoteActivity, new Object[] { });
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

这种方式的优点是插件的apk不需要依赖任何的库文件,通过查找插件的AndroidManifest.xml文件中的activity,把activity启动起来,直接调用即可。缺点就是反射效率较低。

方式三 : Hook的方式

作者将原生的ActivityManager替换成自己的IActivityManagerHook。当startActivity时如果是插件的Activity,这个方法就会被Hook住,改成调用自己实现的startActivity方法。

IActivityManagerHook.java

@Override
public void onInstall(ClassLoader classLoader) throws Throwable {
Class cls = ActivityManagerNativeCompat.Class();
Object obj = FieldUtils.readStaticField(cls, "gDefault");
if (obj == null) {
   ActivityManagerNativeCompat.getDefault();
   obj = FieldUtils.readStaticField(cls, "gDefault");
}

if (IActivityManagerCompat.isIActivityManager(obj)) {
   setOldObj(obj);
   Class<?> objClass = mOldObj.getClass();
   List<Class<?>> interfaces = Utils.getAllInterfaces(objClass);
   Class[] ifs = interfaces != null && interfaces.size() > 0 ? interfaces.toArray(new Class[interfaces.size()]) : new Class[0];
   Object proxiedActivityManager = MyProxy.newProxyInstance(objClass.getClassLoader(), ifs, this);
   FieldUtils.writeStaticField(cls, "gDefault", proxiedActivityManager);
}

这样调用方法时,就会进入到invoke方法里,这里作者重写了所有的方法

public class IActivityManagerHookHandle extends BaseHookHandle {

    private static final String TAG = IActivityManagerHookHandle.class.getSimpleName();

    public IActivityManagerHookHandle(Context hostContext) {
        super(hostContext);
    }

    @Override
    protected void init() {
        sHookedMethodHandlers.put("startActivity", new startActivity(mHostContext));
        sHookedMethodHandlers.put("startActivityAsUser", new startActivityAsUser(mHostContext));
        sHookedMethodHandlers.put("startActivityAsCaller", new startActivityAsCaller(mHostContext));
        sHookedMethodHandlers.put("startActivityAndWait", new startActivityAndWait(mHostContext));
        sHookedMethodHandlers.put("startActivityWithConfig", new startActivityWithConfig(mHostContext));
        sHookedMethodHandlers.put("startActivityIntentSender", new startActivityIntentSender(mHostContext));
        sHookedMethodHandlers.put("startVoiceActivity", new 
        .////// more
    }

作者也是蛮拼的。

这样在activitymanager里面替换掉要启动的activityinfo和classloader,就可以启动activity了。
框架作者就是用这种方式,在application初始化的过程中将十几个Hook挂在各个系统API上。并且针对不同版本的API做了很多兼容工作,在用户调用系统API的时候框架悄无声息的运作着,使得框架非常易用,几乎任何已有的apk都可以作为插件运行起来。
个人觉得,DroidPlugin应当是目前最好的开源框架。

其余四大组件

其余和activity的做法类似,

  1. service一般需要预先注册,如果没有的话只能在主程序中注册一个serivce,用来管理所有插件的service。360的做法是注册了10个serivce,如果插件的serivce超过的10个话就把第一个启动的serivce替换掉。
  2. broadcast的话动态注册完全没有问题,静态注册的话要读取插件的注册信息,再动态注册一下就可以了,
  3. contentprovider试验了下一般可以直接访问。

如果使用插件中的view和fragment

这里我们用fragment来做示例。
分为两种情况:

  1. 插件的fragment是attach在插件的activity上,因为之前assetmanager已经通过代理模式重写过,所以没有关系。
  2. 插件的fragment是attach在主程序的activity上,这时会默认去读取主程序的资源,显然无法获取。
    但是fragment的getResource()方法是final的。无法重写。导致出现问题。

研究办法

  1. 重写support V4包,把final关键字去掉。
    但是因为不知道资源是属于主程序还是插件的,此方法仍然不能彻底解决问题。
  2. 修改aapt,给每个子apk中的资源分配不同头字节PackageID,这样就不会再互相冲突。

在Android中,所有资源会在Java源码层面生成对应的常量ID,这些ID会记录到R.java文件中,参与到之后的代码编译阶段中。在R.java文件中,Android资源在编译过程中会生成所有资源的ID,作为常量统一存放在R类中供其他代码引用。在R类中生成的每一个int型四字节资源ID,实际上都由三个字段组成。第一字节代表了Package,第二字节为分类,三四字节为类内ID。例如:

//android.jar中的资源,其PackageID为0x01
public static final int cancel = 0x01040000;

//用户app中的资源,PackageID总是0x7F
public static final int zip_code = 0x7f090f2e;

我们修改aapt后,是可以给每个子apk中的资源分配不同头字节PackageID,这样就不会再互相冲突。

修改完之后增加如下一行代码即可

 Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
            // 加入插件资源
            addAssetPath.invoke(assetManager, dexPath);
            //加入宿主资源
            addAssetPath.invoke(assetManager,
                    mContext.getApplicationInfo().sourceDir);

这是我们的做法,把所有资源加载一起,其实这时已经可以根据资源ID的前缀区分这个资源属于插件还是主程序,这样通过重写getResource动态来返回也是可以的

Android打包过程如下:

  1. 使用aapt生成R.java类文件:aapt.exe package -f -m -J
  2. 使用android SDK提供的aidl.exe把.aidl转成.java文件:aidl OPTIONS INPUT [OUTPUT]
  3. 编译.java类文件生成class文件: javac
  4. 使用android SDK提供的dx.bat命令行脚本生成classes.dex文件:dx.bat --dex --output=
  5. 使用Android SDK提供的aapt.exe生成资源包文件(包括res、assets、androidmanifest.xml等): aapt package
  6. 生成未签名的apk安装文件:apkbuilder
  7. 使用jdk的jarsigner对未签名的包进行apk签名: use jarsigner jarsigner -keystore

我们主要修改的就是第1步R文件的生成过程,然后第5步aapt会用R文件的值作为KEY,来查找真正的资源。

ps:其实根据我aapt dump的结果,图片等资源只是存储一个相对路径,例如drawable/*png,并没有带上包名,按道理说后面也是找不到的,但是demo的结果是可以找到的,后面会深入研究。
具体AAPT的修改方式如下:

aapt/Bundle.h
// 参数类,用来封装aapt的参数例如 aapt package -f
class Bundle {
public:
    Bundle(void)
        : mCmd(kCommandUnknown), mVerbose(false), mAndroidList(false),
          mForce(false), mGrayscaleTolerance(0), mMakePackageDirs(false),
          mUpdate(false), mExtending(false),
          mRequireLocalization(false), mPseudolocalize(false),
          mWantUTF16(false), mValues(false),
          mCompressionMethod(0), mJunkPath(false), mOutputAPKFile(NULL),
          mManifestPackageNameOverride(NULL), mInstrumentationPackageNameOverride(NULL),
          mAutoAddOverlay(false), mGenDependencies(false),
          mAssetSourceDir(NULL), 
          mCrunchedOutputDir(NULL), mProguardFile(NULL),
          mAndroidManifestFile(NULL), mPublicOutputFile(NULL),
          mRClassDir(NULL), mResourceIntermediatesDir(NULL), mManifestMinSdkVersion(NULL),
          mMinSdkVersion(NULL), mTargetSdkVersion(NULL), mMaxSdkVersion(NULL),
          mVersionCode(NULL), mVersionName(NULL), mCustomPackage(NULL), mExtraPackages(NULL),
          mMaxResVersion(NULL), mDebugMode(false), mNonConstantId(false), mProduct(NULL),
          mUseCrunchCache(false), mErrorOnFailedInsert(false), mOutputTextSymbols(NULL),
          mSingleCrunchInputFile(NULL), mSingleCrunchOutputFile(NULL),
          mArgc(0), mArgv(NULL), mIsPlugin(false)
        {}
    ~Bundle(void) {}
    
src/aapt/Resource.cpp

static status_t parsePackage(Bundle* bundle, const sp<AaptAssets>& assets,
    const sp<AaptGroup>& grp)
{
    // do something...
     assets->setPackage(String8(block.getAttributeStringValue(nameIndex, &len)));

    printf("======chenchen test ===package-verifier--> %s\n", assets->getPackage().string());
        //add by plugin
    if(indexOf(const_cast<char*>(assets->getPackage().string()), PLUGIN_PREFIX) != -1){
        bundle->setIsPlugin(true);
        printf("======chenchen test === it is plugin!!!!");
    }
}

在resource.cpp里,在解析完package的信息后,如果发现是插件,就将bundle里的参数设成true。

aapt/ResourceTable.cpp

ResourceTable::ResourceTable(Bundle* bundle, const String16& assetsPackage)
    : mAssetsPackage(assetsPackage), mNextPackageId(1), mHaveAppPackage(false),
      mIsAppPackage(!bundle->getExtending()),
      mNumLocal(0),
      mBundle(bundle)
{
    if(bundle->IsPlugin()){
        mPackageID = PLUGIN_PACKAGE_ID; //赋值
    }else {
        mPackageID = 127;
    }
    printf("==== package id is %d ====", mPackageID);
    
}

修改后的编译方法如下:

lunch sdk_eng
make aapt
# 如果想编译自己操作系统对应的aapt,直接编译就好,同时linux系统可以为window编译aapt可执行程序
window版本执行程序编译命令
USE_MINGW=1 OUT_DIR=out-x86 LOCAL_MULTILIB=32 make aapt

修改之后还有一个问题,就是主程序和插件必须使用不同的packageID,之前的想法是修改gradle打包的命令,最后采取修改在插件中定义meta-data,约定一个特殊的字符串,aapt打包时读取这个字符串,发现则认为是插件。没有的话还是正常的打包策略。这个meta-data的value就是给插件指定的id,这样就可以通过读取meta-data,识别这个资源id属于哪个插件。

assets文件夹的访问

插件和主程序的assets文件夹內的资源不可以有同名文件,应该约定加上一些前缀。如plugin_.mp3, host_.mp3

插件化优缺点讨论

优势

  1. 模块解耦,
  2. 动态升级,
  3. 高效并行开发(编译速度更快)
  4. 按需加载,内存占用更低
  5. 节省升级流量
  6. 65536限制
  7. 扩展方便,随时加入新功能

劣势

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

推荐阅读更多精彩内容