插件化Activity: 技术方案分享

·  阅读 4436

改不完的 Bug,写不完的矫情。公众号 杨正友 现在专注移动基础平台开发 ,涵盖音视频, APM和信息安全等各个知识领域;只做全网最 Geek 的公众号,欢迎您的关注!精彩内容不容错过~

前言

插件化技术从 2015 年就开始百花齐放,如: 奇虎 360 的 replugin,滴滴的 VirtualAPK,到现在的 VirtualApp,插件化经历了市场严峻的考验,也算逐步成熟,今天就带大家手把手实现一个插件化Activity框架,希望对你有所帮助~

插件化概念

插件化是一种动态加载四大组件的技术。最早是为了解决 65535 限制的问题,后来 Google 出来了 multidex 来专门解决

现在市面使用插件化一定程度上可以减少安装包大小,实现项目组件化,将项目拆分方便隔离,降低组件化耦合度太高的问题

当然插件化也能 实现 bug 热修复,由于虚拟机的存在,Java 本身是支持动态加载任意类的。只是安卓系统在四大组件上做了限制,当你尝试打开不在清单中的组件时,给你一个崩溃。

所谓插件化,本质上是为了绕过这个限制,使得应用可以自由地打开和使用四大组件。

插件化业务价值

插件化无非是为了解决类加载和资源加载的问题,资源加载一般是通过反射 AssertManager , 按照类加载划分,插件化一般分为静态代理和 Hook 的方式,使用插件化一般为了解决应用新版本覆盖慢的问题。

四大组件可动态加载,意味着用户不需要手动安装新版本的应用,我们也可以给用户提供新的功能和页面,或者在用户无感的情况下修复 bug。

插件化项目结构

插件化开发流程

第一步: 创建 app 主工程作为宿主工程

第二步: 创建 plugin_package 作为插件工程,负责打插件包

第三步: 创建接口工程 lifecycle_manager ,负责管理四大组件的生命周期

第四步: 安装插件

4.1 把 Assets 里面得文件复制到 /data/data/files 目录下
    public static void extractAssets(Context context, String sourceName) {
        AssetManager am = context.getAssets();
        InputStream is = null;
        FileOutputStream fos = null;
        try {
            is = am.open(sourceName);
            File extractFile = context.getFileStreamPath(sourceName);
            fos = new FileOutputStream(extractFile);
            byte[] buffer = new byte[1024];
            int count = 0;
            while ((count = is.read(buffer)) > 0) {
                fos.write(buffer, 0, count);
            }
            fos.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            closeSilently(is);
            closeSilently(fos);
        }

    }
复制代码
4.2 通过静态代理构建 DexClassLoader

因为没有上下文环境,上下文环境需要宿主提供给它,一个 DexClassLoader 就包含一个插件,

    // 获取插件目录下的文件
        File extractFile = mContext.getFileStreamPath(mApkName);
        // 获取插件包路径
        String dexPath = extractFile.getPath();
        // 创建Dex输出路径
        File fileRelease = mContext.getDir("dex", Context.MODE_PRIVATE);

        // 构建 DexClassLoader 生成目录
        mPluginClassLoader = new DexClassLoader(dexPath,
                fileRelease.getAbsolutePath(), null, mContext.getClassLoader());
复制代码

而 Hook 方式是把 dex 文件合并到宿主的 DexClassLoader 里面,但是绕过 AMS 清单文件注册的 Activity 会 抛 ClassNotFuoundException,所以需要 Hook startActivity 和 handleResumeActivity ,前者实现简单,兼容性好,而且插件是分离的,后者兼容性差,开发方便,但是如果多个插件如果有相同的类,就会出现问题。这里使用静态代理来处理。

4.3 通过反射 AssertManager 实现资源加载
       try {
            AssetManager assetManager = AssetManager.class.newInstance();
            Method method = AssetManager.class.getMethod("addAssetPath", String.class);
            method.invoke(assetManager, dexPath);
            mPluginResources = new Resources(assetManager, mContext.getResources().getDisplayMetrics(),
                    mContext.getResources().getConfiguration());
        } catch (Exception e) {
            Toast.makeText(mContext, "加载 Plugin 失败", Toast.LENGTH_SHORT).show();
        }
复制代码

第五步: 解析插件

静态代理实现方式很简单,不需要熟悉 Activity 启动流程什么的,直接面向接口编程,首先需要在宿主 App 加载插件构造 DExClassCloder 和 Resource 对象,有了 DexClassLoader,就可以加载插件里面的类 Resource 是通过反射 AssertManager 的 addAssertPath 创建一个 AssertManager,再构造 Resource 对象,当然启动 Service、注册动态广播其实和启动 Activity 一样,都是通过宿主的 Context 去启动,但是 DL 框架不支持静态广播。静态广播是在应用安装的时候才会去解析并注册的,而我们插件的 Manifest 是没法注册的,所以里面的静态广播只能我们手动去解析注册,利用的是反射调用 PackageParser 的 parsePackage 方法,把静态广播都转变为动态广播,具体实现是在 PluginManager#parserApkAction 方法的实现

 public void parserApkAction() {
        try {
            Class packageParserClass = Class.forName("android.content.pm.PackageParser");
            Object packageParser = packageParserClass.newInstance();
            Method method = packageParserClass.getMethod("parsePackage", File.classint.class);
            File extractFile = mContext.getFileStreamPath(mApkName);
            Object packageObject = method.invoke(packageParser, extractFile, PackageManager.GET_RECEIVERS);
            Field receiversFields = packageObject.getClass().getDeclaredField("receivers");
            ArrayList arrayList = (ArrayList) receiversFields.get(packageObject);

            Class packageUserStateClass = Class.forName("android.content.pm.PackageUserState");
            Class userHandleClass = Class.forName("android.os.UserHandle");
            int userId = (int) userHandleClass.getMethod("getCallingUserId").invoke(null);

            for (Object activity : arrayList) {
                Class component = Class.forName("android.content.pm.PackageParser$Component");
                Field intents = component.getDeclaredField("intents");
                // 1.获取 Intent-Filter
                ArrayList<IntentFilter> intentFilterList = (ArrayList<IntentFilter>) intents.get(activity);
                // 2.需要获取到广播的全类名,通过 ActivityInfo 获取
                // ActivityInfo generateActivityInfo(Activity a, int flags, PackageUserState state, int userId)
                Method generateActivityInfoMethod = packageParserClass
                        .getMethod("generateActivityInfo", activity.getClass(), int.class,
                                packageUserStateClassint.class)
;
                ActivityInfo activityInfo = (ActivityInfo) generateActivityInfoMethod.invoke(null, activity, 0,
                        packageUserStateClass.newInstance(), userId);
                Class broadcastReceiverClass = getClassLoader().loadClass(activityInfo.name);
                BroadcastReceiver broadcastReceiver = (BroadcastReceiver) broadcastReceiverClass.newInstance();
                for (IntentFilter intentFilter : intentFilterList) {
                    mContext.registerReceiver(broadcastReceiver, intentFilter);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
复制代码

有了 AssertManager 对象就可以访问资源文件了,但是插件是没有 Context 上下文环境的,这个上下文环境需要宿主提供给他,具体做法是通过 PackManager 获取插件入口的 Activity 注注入宿主 Context,这就完成了宿主 App 跳转插件 App 的步骤。但是插件 App 是没有上下文环境的,所以插件 App 里面是不能直接 startActivity,需要拿到宿主 Context startActivity

第六步,代理 Activity: 在 lifecycle_mananager 构建 ActivityInterface 负责管理插件 Activity 生命周期

public interface ActivityInterface {

// 插入Activity上下文
    void insertAppContext(Activity hostActivity);

// Activity各个生命周期方法
    void onCreate(Bundle savedInstanceState);

    void onStart();

    void onResume();

    void onPause();

    void onStop();

    void onDestroy();
}
复制代码

第七步,代理 Activity: 在 plugin_package 构建 BaseActivity 实现 ActivityInterface

在 BaseActivity 提供 startActivity,丢给宿主 Activity 去启动

    public void startActivity(Intent intent) {

        Intent newIntent = new Intent();
        newIntent.putExtra("ext_class_name", intent.getComponent().getClassName());
        mHostActivity.startActivity(newIntent);
    }
复制代码

第八步,代理 Activity: 在 plugin_package 构建 Activity 插件


public class PluginActivity extends BaseActivity {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

    findViewById(R.id.btn_start).setOnClickListener(
                v -> startActivity(new Intent(mHostActivity, TestActivity.class))
        )
;
 }
}

// 测试插件Activity
public class TestActivity extends BaseActivity {

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_test);
    }
}
复制代码

第九步: 启动插件的入口 Activity

这一步主要做的就是给插件注册一个宿主的 Context

   // PorxyActivity
protected void onCreate(@Nullable Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       // 获取到真正要启动的插件 Activity,然后执行 onCreate 方法
       String className = getIntent().getStringExtra(EXT_CLASS_NAME);
       try {
           Class clazz = getClassLoader().loadClass(className);
           ActivityInterface activityInterface = (ActivityInterface) clazz.newInstance();
           // 注册宿主的 Context
           activityInterface.insertAppContext(this);
           activityInterface.onCreate(savedInstanceState);
       } catch (Exception e) {
           e.printStackTrace();
       }
   }

       @Override
    public void startActivity(Intent intent) {
        String className = intent.getStringExtra(EXT_CLASS_NAME);
        Intent proxyIntent = new Intent(this, ProxyActivity.class);
        proxyIntent.putExtra(EXT_CLASS_NAME, className);
        super.startActivity(proxyIntent);
    }
复制代码

这样其实就已经完成了 PluginActivity 的启动了,但是需要注意的是,在插件的 Activity 里面,我们不能再使用 this 了,因为插件并没有上下文环境,所以一些调用 Context 的方法都需要使用宿主的 Context 去执行,比如:

在 BaseActivity 提供 findViewById,可以查找布局 Id 文件

    public View findViewById(int layoutId) {
        return mHostActivity.findViewById(layoutId);
    }
复制代码

在 BaseActivity 提供 setContentView,方便渲染 UI 布局

    public void setContentView(int resId) {
        mHostActivity.setContentView(resId);
    }
复制代码

插件化原理介绍

  1. 使用 DexClassLoader 加载插件的 Apk
  2. 通过代理的 Activity 去执行插件中的 Activity,加载对应的生命周期
  3. 通过反射调用 AssetManager 的 addAssetPath 来加载插件中的资源

插件化遇到的问题

找到的 Activity 不在插件包里面

我们真正打开的确实一个在插件包中定义的 Activity,这个 Activity 需要的信息在插件包中的,而不是宿主的。

解决方案

插件 Activity 也同时重写了 attachBaseContext 方法。在这一步, 用插件的 classloader 和 Resources 实例创建一个自己的上下文,并用它替换 base context 传递给父类保存。如此一来,业务调用 getClassLoader()或者 getResources()时,取得的就都是插件的信息了。

资源 Id 类型不匹配 找不到

你需要通过一个资源 ID 获取一个 drawable 的时候,取得的是 color 或者其他资源

解决方案

主要发生在 8.0 以下版本。经过调查发现在 8.0 以下的插件包中,ContextThemeWrapper.mResources 是宿主的 Resource,而非插件的 Resource。从而导致同一个 ID 找到的资源不对应。

插件包 leakcanary 引发的崩溃

leakcanary 会使用栈顶的 activity 的 Resource 去加载它要显示的一张图片,但这个资源有可能不在当前插件中。

解决方案

宿主和所有插件都依赖 leakcanary 即可。

总结

本文主要是根据我自身实际投产的 插件组件化 实践,分享一些动态加载 SDK插件 时需要考虑的问题。内容主要包括插件化方案的共同问题、插件包 leakcanary 引发的崩溃、资源 Id 类型不匹配 、宿主Activity 找不到问题,千言万语汇成一句话:

插件有风险,投资须谨慎!

参考链接

沐小晨曦

VirtualApk 插件化

分类:
Android
标签:
分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改