面试过程中;问到关于组件化+插件化的问题;这一篇就够了

Posted 初一十五啊

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试过程中;问到关于组件化+插件化的问题;这一篇就够了相关的知识,希望对你有一定的参考价值。

前言

之前有小伙伴提到在某大厂面试了4轮面试,其中两轮都被问到组件化和插件化的问题,问到的虽然各不相同,但是所有的问题都汇集到组件化和插件化的问题。

哪今天来总结一下,首先看一下组件化的内容,提纲如下:

(文字+视频版)android知识点大全。(音视频;Flutterkotlin;Compose;Framework;性能优化;架构等)全文30W+字。200多个知识点,330张图。PDF版。

💡一丶组件化

1.1.组件化和模块化的区别

组件化就是把可以复用的、独立的、基础的、功能专一的代码封装到一个方法或者代码片段里,在未来需要的地方引入使用。用极少的代码实现之前相同的功能,避免了相同功能代码的复写,提高了开发的效率。在未来对改组件功能进行修改的时候只需要修改组件代码就可修改项目里所有的相同功能。组件化属于纵向分块,每个组件就像一个竖直的线永不相交。

模块化是为了单独实现某一功能模块进行封装的方法,一个模块里可能拥有n个基础组件搭配产生。模块化属于横向分块,每个模块像一条横向把n条竖直的线串联起来形成一个整体。

组件相当于库,模块相当于框架。

对比

1.2.组件之间的跳转和组件通信原理机制

如果一个单体项目进行组件化架构改造,应从以下方面入手

  • 代码解耦
  • 组件单独运行
  • 组件间通信
  • UI跳转
  • 组件生命周期
  • 调试
  • 代码隔离

众所周知,Android 提供了很多不同的信息的传递方式,比如在四大组件中本地 广播、进程间的 AIDL、匿名间的内存共享、Intent Bundle 传递等等,那么在这 么多传递方式,哪种类型是比较适合组件与组件直接的传递呢。

  • 本地广播,也就是 LoacalBroadcastRecevier。更多是用在同一个应用内的不同系 统规定的组件进行通信,好处在于:发送的广播只会在自己的 APP 内传播,不 会泄漏给其他的 APP,其他 APP 无法向自己的 APP 发送广播,不用被其他 APP 干扰。本地广播好比对讲通信,成本低,效率高,但有个缺点就是两者通信机制 全部委托与系统负责,我们无法干预传输途中的任何步骤,不可控制,一般在组 件化通信过程中采用比例不高。

  • 进程间的 AIDL。这个粒度在于进程,而我们组件化通信过程往往是在线程中, 况且 AIDL 通信也是属于系统级通信,底层以 Binder 机制,虽说 Android 提供模 板供我们实现,但往往使用者不好理解,交互比较复杂,往往也不适用应用于组 件化通信过程中。

  • 匿名的内存共享。比如用 Sharedpreferences,在处于多线程场景下,往往会线 程不安全,这种更多是存储一一些变化很少的信息,比如说组件里的配置信息等 等。

  • Intent Bundle 传递。包括显性和隐性传递,显性传递需要明确包名路径,组件 与组件往往是需要互相依赖,这背离组件化中 SOP(关注点分离原则),如果走 隐性的话,不仅包名路径不能重复,需要定义一套规则,只有一个包名路径出错, 排查起来也稍显麻烦,这个方式往往在组件间内部传递会比较合适,组件外与其 他组件打交道则使用场景不多。

说了这么多,那组件化通信什么机制比较适合呢?既然组件层中的模块是相互独 立的,它们之间并不存在任何依赖。没有依赖就无法产生关系,没有关系,就无 法传递消息,那要如何才能完成这种交流?

目前主流做法之一就是引入第三者。组件层的模块都依赖于基础层,从而产生第三者联系,这种第三者联系最终会编 译在 APP Module 中,那时将不会有这种隔阂,那么其中的 Base Module 就是 跨越组件化层级的关键,也是模块间信息交流的基础。比较有代表性的组件化开 源框架有得到 阿里 Arouter等等。

1.3.事件总线

除了这种以通过引入第三者方式,还有一种解决方式是以事件总线方式,但这种 方式目前开源的框架中使用比例不高,如图:

事件总线通过记录对象,使用监听者模式来通知对象各种事件,比如在现实生活 中,我们要去找房子,一般都去看小区的公告栏,因为那边会经常发布一些出租 信息,我们去查看的过程中就形成了订阅的关系,只不过这种是被动去订阅,因 为只有自己需要找房子了才去看,平时一般不会去看。小区中的公告栏可以想象 成一个事件总线发布点,监听者则是哪些想要找房子的人,当有房东在公告栏上 贴上出租房信息时,如果公告栏有订阅信息功能,比如引入门卫保安,已经把之 前来这个公告栏要查看的找房子人一一进行电话登记,那么一旦有新出租消息产 生,则门卫会把这条消息一一进行短信群发,那么找房子人则会收到这条消息进 行后续的操作,是马上过来看,还是延迟过来,则根据自己的实际情况进行处理。

在目前开源库中,有 EventBusRxBus 就是采用这种发布/订阅模式,优点是简 化了 Android 组件之间的通信方式,实现解耦,让业务代码更加简洁,可以动态 设置事件处理线程和优先级,缺点则是每个事件需要维护一个事件类,造成事件 类太多,无形中加大了维护成本。那么在组件化开源框架中有 ModuleBus 等 等。

事件总线,又可以叫做组件总线,以 ModuleBus 框架的源码为例,这个方案特别之处在于其借鉴了 EventBus 的思想,组件的注册/注销和组件调用的事件发送都跟 EventBus 类似,能够传递一些 基础类型的数据,而并不需要在 Base Moudel 中添加额外的类。所以不会影响 Base 模块的架构,但是无法动态移除信息接收端的代码,而自定义的事件信息 类型还是需要添加到 Base Module 中才能让其他功能模块索引。

其中的核心代码是在与 ModuleBus 类,其内部维护了两个 ArrayMap 键对值列 表,如下:

private static ArrayMap<Object,ArrayMap<String,MethodInfo>> 
moduleEventMethods = new ArrayMap<>(); 

private static ArrayMap<Class<?>,ArrayMap<String,ArrayList<Object>>> 
 moduleMethodClient = new ArrayMap<>()

在使用方法上,在 onCreate()onDestroy()中需要注册和解绑,比如

ModuleBus.getInstance().register(this); 

ModuleBus.getInstance().unregister(this);

最终使用类似 EventBuspost 方法一样,进行两个组件间的通信。这个框架 的封装的 post 方法如下

public void post(Class<?> clientClass,String methodName,Object...args)
    if(clientClass == null || methodName == null ||methodName.length() == 0) return;
    ArrayList<Object> clientList = getClient(clientClass,methodName) 
    
    for(Object c: clientList) ArrayMap<String,MethodInfo> methods = moduleEventMethods.get(c); 
      Method method = methods.get(methodName).m; 
          method.invoke(c,args);
 

可以看到,它是通过遍历之前内部的 ArrayMap,把注册在里面的方法找出,根据传入的参数进行匹配,使用反射调用。

1.4.接口+路由

相对于事件总线的方式,组件间通信更多使用的还是基于 Base Module 的接口+路由的方式

接口+路由实现方式则相对容易理解点,我之前实践的一个项目就是通过这种方式实现的。实现思路是专门抽取一个 LibModule 作为路由服务,每个组件声明自己提供的服务 Service API,这些 Service 都是一些接口,组件负责将这些 Service 实现并注册到一个统一的路由 Router 中去,如果要使用某个组件的功能,只需要向 Router 请求这个 Service 的实现,具体的实现细节我们全然不关心,只要能返回我们需要的结果就可以了。比如定义两个路由地址,一个登陆组件,一个设置组件,核心代码:

public class RouterPath 
    public static final String ROUTER_PATH_TO_LOGIN_SERVICE = "/login/service"; 
    public static final String ROUTER_PATH_TO_SETTING_SERVICE = "/setting/service"; 

那么就相应着就有两个接口 API,如下:

public interface ILoginProvider extends IProvider  
    void goToLogin(Activity activity); 
  
      public interface ISettingProvider extends IProvider  
void goToSetting(Activity activity); 
   

这两个接口 API 对应着是向外暴露这两个组件的能提供的通信能力,然后每个组 件对接口进行实现,如下:

@Override
  public void init(Context context)  
 
@Override 
  public void goToLogin(Activity activity)  
    Intent loginIntent = new Intent(activity, LoginActivity.class); 
activity.startActivity(loginIntent);
    

这其中使用的到了阿里的 ARouter 页面跳转方式,内部本质也是接口+实现方式 进行组件间通信。调用则很简单了,如下:

ILoginProvider loginService = (ILoginProvider)       
   ARouter.getInstance().build(RouterPath.ROUTER_PATH_TO_LOGIN_SERVICE).naviga tion(); 
            if(loginService != null)
loginService.goToLogin(MainActivity.this); 

还有一个组件化框架,就是 ModularizationArchitecture ,它本质实现方式也是 接口+实现,但是封装形式稍微不一样点,它是每个功能模块中需要使用注解建立 Action 事件,每个 Action 完成一个事件动作。invoke 只是方法名为反射,并 未用到反射,而是使用接口方式调用,参数是通过 HashMap 传递的,无法传递 对象。具体详解可以看这篇文章 Android 架构思考(模块化、多进程)。

1.5.页面跳转

页面跳转也算是一种组件间的通信,只不过它相对粒度更细化点,之前我们描述 的组件间通信粒度会更抽象点,页面跳转则是定位到某个组件的某个页面,可能 是某个 Activity,或者某个 Fragment,要跳转到另外一个组件的 ActivityFragment,是这两者之间的通信。甚至在一般没有进行组件化架构的工程项目 中,往往也会封装页面之间的跳转代码类,往往也会有路由中心的概念。不过一 般 UI 跳转基本都会单独处理,一般通过短链的方式来跳转到具体的 Activity

每个组件可以注册自己所能处理的短链的 SchemeHost,并定义传输数据的 格式,然后注册到统一的 UIRouter 中,UIRouter 通过 Scheme 和 Host 的匹 配关系负责分发路由。但目前比较主流的做法是通过在每个 Activity 上添加注 解,然后通过 APT 形成具体的逻辑代码。下面简单介绍目前比较主流的两个框架核心实现思路:

ARouter
ARouter 核心实现思路是,我们在代码里加入的 @Route 注解,会在编译时期通 过 apt 生成一些存储 pathactivityClass 映射关系的类文件,然后 app 进程启 动的时候会拿到这些类文件,把保存这些映射关系的数据读到内存里(保存在 map 里),然后在进行路由跳转的时候,通过 build()方法传入要到达页面的路由 地址,ARouter 会通过它自己存储的路由表找到路由地址对应的 Activity.class(activity.class = map.get(path)),然后 new Intent(),当调用 ARouterwithString()方法它的内部会调用 intent.putExtra(String name, String value), 调用 navigation()方法,它的内部会调用 startActivity(intent)进行跳转,这样便可 以实现两个相互没有依赖的 module 顺利的启动对方的 Activity 了。

ActivityRouter
核心实现思路是,它是通过路由 + 静态方法来实现,在静态方 法上加注解来暴露服务,但不支持返回值,且参数固定位(context, bundle),基 于 apt 技术,通过注解方式来实现 URL 打开 Activity 功能,并支持在 WebView 和外部浏览器使用,支持多级 Activity 跳转,支持 BundleUri 参数注入并转换 参数类型。它实现相对简单点,也是比较早期比较流行的做法,不过学习它也是 很有参考意义的。

💡二丶插件化

提纲如下:

Android 系统中,应用是以 Apk 的形式存在的,应用都需要安装才能使用。但实际上 Android 系统安装应用的方式相当简单,其实就是把应用 Apk 拷贝到系统不同的目录下、然后把 so 解压出来而已。

常见的应用安装目录有:

  • /system/app:系统应用
  • /system/priv-app:系统应用
  • /data/app:用户应用

那可能大家会想问,既然安装这个过程如此简单,Android 是怎么运行应用中的代码的呢,我们先看 Apk 的构成,一个常见的 Apk 会包含如下几个部分:

  • classes.dexJava 代码字节码
  • res:资源文件
  • lib:so 文件
  • assets:静态资产文件
  • AndroidManifest.xml:清单文件

其实 Android 系统在打开应用之后,也只是开辟进程,然后使用 ClassLoader 加载 classes.dex 至进程中,执行对应的组件而已。
那大家可能会想一个问题,既然 Android 本身也是使用类似反射的形式加载代码执行,凭什么我们不能执行一个 Apk 中的代码呢?

2.1.插件化的优势

  • 适应并行开发,解耦各个模块,避免模块之间的交叉依赖,加快编译速度, 从而提高并行开发效率。
  • 满足产品随时上线的需求
  • 修复因为我们对自己要求不严格而写出来的 bug。
  • 插件化的结果:分为稳定的 release 版本和不稳定的 snapshot 版本,每 个模块都高度解耦,没有交叉依赖,不会出现一个模块依赖了另一个模块, 其中一个人改了这个模块的代码,对另一个模块造成影响.

2.2.插件化的难点

想让插件的Apk真正运行起来,首先要先能找到插件Apk的存放位置,然后我们要能解析加载Apk里面的代码。

但是光能执行Java代码是没有意义的,在Android系统中有四大组件是需要在系统中注册的,具体来说是在 Android 系统的 ActivityManagerService (AMS) 和 PackageManagerService (PMS) 中注册的,而四大组件的解析和启动都需要依赖 AMSPMS,如何欺骗系统,让他承认一个未安装的 Apk 中的组件,如何让宿主动态加载执行插件ApkAndroid 组件(即 ActivityServiceBroadcastReceiverContentProviderFragment)等是插件化最大的难点。

另外,应用资源引用(特指 R 中引用的资源,如 layoutvalues 等)也是一大问题,想象一下你在宿主进程中使用反射加载了一个插件 Apk,代码中的 R 对应的 id 却无法引用到正确的资源,会产生什么后果。

总结一下,其实做到插件化的要点就这几个

  • 如何加载并执行插件 Apk 中的代码(ClassLoader Injection
  • 让系统能调用插件 Apk 中的组件(Runtime Container
  • 正确识别插件 Apk 中的资源(Resource Injection

2.3.ClassLoader Injection

ClassLoader 是插件化中必须要掌握的,因为我们知道Android 应用本身是基于魔改的 Java 虚拟机的,而由于插件是未安装的 apk,系统不会处理其中的类,所以需要使用 ClassLoader 加载 Apk,然后反射里面的代码。

java 中的 ClassLoader

  • BootstrapClassLoader负责加载 JVM 运行时的核心类,比如 JAVA_HOME/lib/rt.jar 等等

  • ExtensionClassLoader负责加载 JVM 的扩展类,比如 JAVA_HOME/lib/ext 下面的 jar

  • AppClassLoader负责加载 classpath 里的 jar 包和目录

Android中的ClassLoader
Android系统中ClassLoader是用来加载dex文件的,有包含 dexapk 文件以及 jar 文件,dex 文件是一种对class文件优化的产物,在Android中应用打包时会把所有class文件进行合并、优化(把不同的class文件重复的东西只保留一份),然后生成一个最终的class.dex文件

  • PathClassLoader 用来加载系统类和应用程序类,可以加载已经安装的 apk 目录下的 dex 文件
public class PathClassLoader extends BaseDexClassLoader 
    public PathClassLoader(String dexPath, ClassLoader parent) 
        super(dexPath, null, null, parent);
    

    public PathClassLoader(String dexPath, String libraryPath,
            ClassLoader parent) 
        super(dexPath, null, libraryPath, parent);
    

  • DexClassLoader 用来加载 dex 文件,可以从存储空间加载 dex 文件。
public class DexClassLoader extends BaseDexClassLoader 
    public DexClassLoader(String dexPath, String optimizedDirectory,
            String libraryPath, ClassLoader parent) 
        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    

我们在插件化中一般使用的是 DexClassLoader

2.3.双亲委派机制

每一个 ClassLoader 中都有一个 parent 对象,代表的是父类加载器,在加载一个类的时候,会先使用父类加载器去加载,如果在父类加载器中没有找到,自己再进行加载,如果 parent 为空,那么就用系统类加载器来加载。通过这样的机制可以保证系统类都是由系统类加载器加载的。 下面是 ClassLoaderloadClass 方法的具体实现。

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) 
                try 
                    if (parent != null) 
                        // 先从父类加载器中进行加载
                        c = parent.loadClass(name, false);
                     else 
                        c = findBootstrapClassOrNull(name);
                    
                 catch (ClassNotFoundException e) 
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                

                if (c == null) 
                    // 没有找到,再自己加载
                    c = findClass(name);
                
            
            return c;
    

2.4.如何加载插件中的类

要加载插件中的类,我们首先要创建一个 DexClassLoader,先看下 DexClassLoader 的构造函数需要哪些参数。

public class DexClassLoader extends BaseDexClassLoader 
    public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) 
        // ...
    

构造函数需要四个参数:
dexPath 是需要加载的 dex / apk / jar 文件路径
optimizedDirectorydex 优化后存放的位置,在 ART 上,会执行 oatdex 进行优化,生成机器码,这里就是存放优化后的 odex 文件的位置
librarySearchPathnative 依赖的位置
parent 就是父类加载器,默认会先从 parent 加载对应的类
创建出 DexClassLaoder 实例以后,只要调用其 loadClass(className) 方法就可以加载插件中的类了。具体的实现在下面:

    // 从 assets 中拿出插件 apk 放到内部存储空间
    private fun extractPlugin() 
        var inputStream = assets.open("plugin.apk")
        File(filesDir.absolutePath, "plugin.apk").writeBytes(inputStream.readBytes())
    

    private fun init() 
        extractPlugin()
        pluginPath = File(filesDir.absolutePath, "plugin.apk").absolutePath
        nativeLibDir = File(filesDir, "pluginlib").absolutePath
        dexOutPath = File(filesDir, "dexout").absolutePath
        // 生成 DexClassLoader 用来加载插件类
        pluginClassLoader = DexClassLoader(pluginPath, dexOutPath, nativeLibDir, this::class.java.classLoader)
    

2.5.执行插件类的方法

通过反射来执行类的方法

val loadClass = pluginClassLoader.loadClass(activityName)
loadClass.getMethod("test",null).invoke(loadClass)

我们称这个过程叫做 ClassLoader 注入。完成注入后,所有来自宿主的类使用宿主的 ClassLoader 进行加载,所有来自插件 Apk 的类使用插件 ClassLoader 进行加载,而由于 ClassLoader 的双亲委派机制,实际上系统类会不受 ClassLoader 的类隔离机制所影响,这样宿主 Apk 就可以在宿主进程中使用来自于插件的组件类了。

💡三丶组件化和插件化的区别

  • 组件化:是将一个App分成多个模块,每个模块都是一个组件(module),开发过程中可以让这些组件相互依赖或独立编译、调试部分组件,但是这些组件最终会合并成一个完整的Apk去发布到应用市场。
  • 插件化:是将整个App拆分成很多模块,每个模块都是一个Apk(组件化的每个模块是一个lib),最终打包的时候将宿主Apk和插件Apk分开打包,只需发布宿主Apk到应用市场,插件Apk通过动态按需下发到宿主Apk

(文字+视频版)Android知识点大全。(音视频;Flutterkotlin;Compose;Framework;性能优化;架构等)全文30W+字。200多个知识点,330张图。PDF版

以上是关于面试过程中;问到关于组件化+插件化的问题;这一篇就够了的主要内容,如果未能解决你的问题,请参考以下文章

掌握这些Android开发热门前沿知识,看这一篇就够了!

Android面试老生常谈的 View 事件分发机制,看这一篇就够了!

Android面试老生常谈的 View 事件分发机制,看这一篇就够了

Android面试老生常谈的 View 事件分发机制,看这一篇就够了

关于JVM,看这一篇就够了

了解京东面试题中的“红黑树”,这一篇就够了!