聚美组件化实践之路

13,716 阅读16分钟

从去年开始,就陆陆续续的越来越多的app开始进行了组件化重构。也有很多非常好的组件化方案博客分享,所以这篇文章并不以介绍组件化方案作为主题,而是我们应该如何一步步的从一个古老的项目,慢慢一步步拆分,完成组件化重构的。

组件化的思想是好的,但是并不是所有的项目都适合使用组件化的方式进行开发,所以一般需要使用组件化的项目。基本都是具备项目迭代时期久远、项目大而臃肿、项目组成员多沟通成本大、项目复杂维护成本很高等特点。这类的项目才会有组件化的用武之地。

而其他的一些人员少、功能简单的小项目。就别去直接考虑组件化了。老老实实直接撸码就行了。强行使用组件化只会增加维护成本与开发成本。得不偿失~

组件化结构

组件化从来不是一个说重构就能重构的东西,在进行组件化重构之前。最好先对组件化的结构有一个基本的理解:

上图为组件化最基本的结构。大致可以看出。组件化主要分为三层:

  1. app壳:

    此为组件化的运行容器,壳中定义app入口,依赖业务组件进行运行。

  2. 业务组件:

    此为组件化的中间层,在一个大型项目组中。都有细分下来的不同的业务组,比如管登录的、管购物的、管视频的等等。这些不同业务组分别维护一个各自的业务组件,以达到各自业务组业务解耦的效果。

    原则上来说:各个业务组件之间不能有直接依赖!所有的业务组件均需要可以做到独立运行的效果。对于测试的时候,需要依赖多个业务组件的功能进行集成测试的时候。可以使用app壳进行多组件依赖管理运行。

  3. 基础组件:

    基础组件也叫基础功能组件。此部分组件为上层业务组件提供基本的功能支持。如基础网络组件、基础视图组件、基础数据存取组件等,以及组件化的核心通信组件:路由组件。

以上即是组件化的最基本结构,当然在真正的项目之中,不可能会存在这么简单的结构,都是需要根据你的具体现状进行扩充的。比如你可以在基础组件与业务组件之间,添加一层特殊的功能组件。此层的功能组件只被一个或者多个组件进行依赖,只要不破坏这层由下到上单向依赖链即可

准备

组件化重构从来都不是说重构就能重构的,首先得有个强有力的领导去支持执行,然后你才可能去具体的进行重构。

其次,你得提前对你们的项目进行大方向的分层结构划分,哪些东西需要放在什么层。需要提前有个明确的划分。

重构你的基础组件:即你的各种基础功能框架需要提前从项目中拆分出来。包括网络、图片加载、数据存储、埋点、路由等。

建立组件化项目结构

建立基础组件合集

你需要创建一个基础library module。用于依赖所有的基础功能组件,如baselib。

作用:用于统一依赖基础功能库,并统筹、关联好各功能框架的关系,做好各功能库的初始化封装操作。提供上层业务组件直接调用。

创建各自业务线的业务组件及app壳

与普通的组件化方案做法不同,普通的组件化方案是使用一个变量进行控制。使得业务组件可以在application与library之间进行灵活切换。使得组件也是application。application也是组件。

但是这种做法,因为总是在libraryModule与applicationModule之间进行切换。很容易导致各种混乱问题:比如Manifest冲突,R文件冲突等。

所以我们采用的是多app壳分组加载的方式:

可以看到,每个业务线的业务组件。都分别有一个各自的app壳模块。而主app壳依赖所有的业务组件. 在进行业务开发时。各自业务组成员可以直接运行各自的app壳模块进行测试,主app壳进行全量打包。

在拆分初期,这个时候的建议以原本的项目application作为主app壳

预留一个核心业务组件出来。比如登录组件:此类组件为业务组件,但是又被所有其他组件所需要,所以将其单独作为核心业务组件独立出来。然后别的业务组件。通过各自的app壳工程。依赖进入即可:再次提醒业务组件之间不能直接进行依赖

这种分层结构的好处有:

  1. 业务组件不再在library与application之间进行切换。开发环境统一,不易出现环境切换冲突
  2. app壳单独独立出来。可以在壳工程中添加一些特有的独立代码,由于各自的壳功能不会参与到主app壳中去进行编译,所有这里面你可以针对各自的业务。添加一些独立的入口管理类。比如添加一个RootActivity,在此添加一个可以跳转到任意页面的列表,方便进行测试运行等。

gradle统一配置管理

组件化重构后,module变多了,所以就需要对所有module的一些gradle脚本进行统一配置管理。避免混乱。

  • 新建dependencies.gradle脚本。添加统一的依赖版本号管理:
ext {
    COMPILE_SDK_VERSION = 25
    BUILD_TOOLS_VERSION = '25.0.0'
    MIN_SDK_VERSION = 16
    TARGET_SDK_VERSION = 19
    
    // SUPPORT
    SUPPORT_VERSION = '23.2.0'
    SUPPORTDEPS = [
            supportV4   : "com.android.support:support-v4:${SUPPORT_VERSION}",
            supportV13  : "com.android.support:support-v13:${SUPPORT_VERSION}",
            appcompatV7 : "com.android.support:appcompat-v7:${SUPPORT_VERSION}",
            cardview    : "com.android.support:cardview-v7:${SUPPORT_VERSION}",
            design      : "com.android.support:design:${SUPPORT_VERSION}",
            annotations : "com.android.support:support-annotations:${SUPPORT_VERSION}",
            multidex    : 'com.android.support:multidex:1.0.1'
    ]
    ...
}

此脚本统一配置管理所有的版本号相关的数据。外部需要使用版本号及依赖时,需要统一从此文件配置属性中进行读取。比如要依赖supportV4包:

compile "${SUPPORTDEPS.supportV4}"
  • 定义baseConfig.gradle。统一配置组件基础编译脚本
boolean isAppModule = project.plugins.hasPlugin('com.android.application')
android {
    compileSdkVersion Integer.parseInt("${COMPILE_SDK_VERSION}")
    buildToolsVersion "${BUILD_TOOLS_VERSION}"

    lintOptions {
        abortOnError false
    }
    defaultConfig {
        if (isAppModule) {
            applicationId "com.haoge.component.demo"
        }
        minSdkVersion Integer.parseInt("${MIN_SDK_VERSION}")
        targetSdkVersion Integer.parseInt("${TARGET_SDK_VERSION}")

        versionCode Integer.parseInt("${DEFAULE_CONFIG.versionCode}")
        versionName "${DEFAULE_CONFIG.versionName}"
    }
}

这样。就可以使用apply语法。让所有组件module。都统一依赖此gradle脚本。进行统一环境配置了。

细心点的可以发现。我在baseConfig中,添加了默认的applicationId的指定。这是因为对于大部分应用而言。都有用过各种的第三方sdk。特别是第三方登录,这种的sdk框架。很多都会需要进行包名验证的,所以建议有此种情况的,在此添加上默认的applicationId指定较好。

如果有嫌麻烦的又动手能力强的。可以考虑自己封装个gradle插件来进行统一配置管理

为组件添加资源前缀

我们需要对各自的组件,分别设置他自身的资源前缀来作为命名约束,避免出现不同的组件对不同的资源起了同一个命名,导致编译冲突等问题。

android {
    resourcePrefix 'lg_'
}

这个资源前缀的作用是:当你在该module下创建了一个资源命名时,若名字不能与此前缀进行匹配,则将会进行即时提醒。避免冲突。

大文件资源、图片资源统一管理

组件化之后。资源管理也是个问题,图片资源、assets资源、raw文件资源等。都具有占用资源大、基本很少修改等特点。所以这里最好将其单独拆分出来。统一提供给所有组件进行使用:

所以,可以考虑将此类大文件资源,统一放入组件化的最底层。使得不同组件不用自己单独维护一份此大文件资源。避免资源浪费的现象。比如可以直接将此部分资源。直接放入baselib中,作为基础功能提供库进行使用。

做好各组件的application派发

可能有人会问:为什么要做组件的application的生命周期派发?

举个栗子:都知道。网络库、图片加载库等,都需要进行对应的初始化操作才能进行使用的,但是在组件化中,如果不进行各自application的派发。不能进行一个统一流程的初始化操作。那么可能你组件A需要自己手动写基础库的初始化操作。组件B、组件C也需要。最后你的主app壳也需要,这个时候。就容易乱了!

所以需要有个结构。来让各自的组件。分别完成自身的组件的功能初始化。

比如基础功能组件:初始化网络、图片框架等,上层的业务组件A,初始化自身的其他功能操作。各自的组件分别只初始化自身这部分的操作。而不用管所依赖的其他组件需要进行什么初始化。

这部分的生命周期派发可以参考demo中的baselib的delegate包下的类:

demo链接放在了文章末尾。

组件间通信

路由通信

组件间通信的核心是路由框架,这部分框架需要放置在最底层的基础功能组件中,提供上层进行使用,这里我使用的是我自己的路由框架Router:一款单品、组件化、插件化全支持的路由框架

此路由框架支持在单品、组件化、插件化中均能使用。如果你想要为组件化之后,能在后期有需要的情况下,方便的从组件化切换到插件化的环境中去,建议使用此Router

如果你们项目中已经有使用自己的路由框架,且也直接支持组件化环境使用。建议这块就最好别考虑换了。实话说换一个路由框架任务还是挺重的。

因为基本所有的介绍组件化的blog,都对其中的路由框架,做了非常详细的说明,所以这块我就不准备展开进行详细的赘述了,如有感兴趣的,可以参考上方的链接进行了解使用。

事件通信

与路由通信不同的是:路由主要用于做界面跳转通信,对于普通的事件通信作用不大。比如说我是组件A,需要调组件B中的某个接口,并获取返回数据进行操作。这个时候,就需要别的方式来进行实现了。

很多人一说到事件通信。可能就会想起使用EventBus了。的确EventBus是个很好的事件通信框架,但是相信用过的人都知道。一旦EventBus被滥用。随着时间的迭代,由于其独特的解耦特性,会使得你的代码很难进行调试、维护。

所以这个时候,我们摒弃了使用EventBus来作为组件间时间通信的桥梁。而是简单的使用控制反转的手段。将组件间通信协议定义在底层基础组件中,上层的业务组件分别实现底层对应的各自的协议接口来进行通信。

我们以登录组件为例:

首先,在基础组件层添加一个协议接口。这个接口用于定义登录组件所对外提供的时间通信入口,比如退出登录、清理cookie等:

public interface LoginPipe extends Pipe{
    void logout();
    void clearCookie();
}

然后。在登录组件中。实现此协议接口。并注册入对应的通信管理器:

// 实现协议接口。
public final class LoginPipeImpl implements LoginPipe {
    @Override
    void logout() {
        // do something
    }
    
    void clearCookie() {
        // do something
    }
}
// 注册此实现进协议管理器中
// PipeManager也位于基础组件中。
PipeManager.register(LoginPipe.class, new LoginPipeImpl());

然后即可在别的组件中。通过此PipeManager协议管理器。根据协议类。获取到对应的实现类进行直接调用了:

PipeManager.get(LoginPipe.class).logout();

上面这种做法,虽然的确很简单,但是具备以下几点优点:

  • 提高各组件协议的内聚性。更适于各自组件对各自的协议接口进行统一管理维护。
  • 实现方案简单易懂,易于调试。
  • 在组件化拆分进程中,便于方便后期对主app壳无关代码进行删除。

最后一条可能相对比较复杂一点。所以下面我们针对这条进行展开描述:

上面我们提到了。在对老旧项目进行组件化重构的时候。使用主module作为的主app壳,而app壳其实是需要没有具体的业务代码的。所以这个地方存在冲突。但是我们组件化拆分也不是可以一蹴而就的,只能慢慢一步步、一个页面一个页面的进行拆分并测试。所以拆分过程其实是个漫长的痛苦的过程。

而在拆分过程中,很难避免的就是新旧代码均需要同时存在的尴尬场面。而这种尴尬的场面会一直持续到所有组件均拆分完毕之后。

然后拆分过程中,你也会遇到另外一个问题:就是各业务组的拆分计划其实是不同步的,也就是说很可能你当前拆的业务。需要调用到别的业务组的功能,而这个功能这个时候。很可能还根本没有被提交到拆分计划表上来。所以这个时候。你就必须要在你拆分的组件中,还是先直接调用老项目中的逻辑代码。

所以使用上面的事件通信机制。你会需要在主app中建议一个临时的协议接口。比如:

public interface MainPipe {
    void doSomething();
}

然后主项目实现并注册它。提供给你的组件进行使用。而其他组件遇到此种类似问题时,也于此类似。在此MainPipe种继续添加对应的通信协议方法并实现即可。

由于这样的做法。将所有的主app的临时协议接口。均放置于此MainPipe中。提升了协议的内聚性。当所有业务组均完成组件化重构之后。那么就可以统一的直接对此MainPipe进行重构,将其中各自组件的协议迁移至各自组件的协议类中,然后就可以安全地进行主app中无关业务代码统一删除了。使其成为真正的主app壳工程。

数据通信

很多时候,其实组件间通信。传递的数据都是普通的简单数据,但是也有一些时候。会需要传递复杂数据。比如进行跨组件调用api接口并获取返回数据时,或者说读取用户完整数据时。

以读取用户完整数据为例,数据通信的协议定义仍旧以上方的事件通信机制作为实现载体:

public class User {
    String uid;
    String nickname;
    String email;
    String phone;
    ...
}

这个User类包含了所有的用户信息在里面。然后现在需要将此user实例进行跨组件传递时。你就需要定义一个协议方法。提供获取此User实例的入口:

public interface LoginPipe {
    User getUser();
}

这是正常的做法,但是这样做的话,你就需要将此User实例也一起拷贝到协议定制层,即基础组件中来。

而在开发过程中,这种现象很常见。而且很多时候,随着需求一更改,所需要传递的数据也不一样。也不可能每次都去将对应的实体bean进行迁移,放入协议定制层。这样就太麻烦了。

所以对于这种跨组件通信的做法。建议的方式是通过json数据来进行数据通信

json通信的机制,即可完美的避免实体bean迁移的问题。也能让接收方按需解析读取数据:

比如我接收方的组件。当前只需要nickname与uid两个数据。其他数据我不管。那么我就可以只解析此两个字段的数据即可。做到按需解析。

说到这里。推荐一波我的另一个框架Parceler, 此框架是封装的Bundle的存取操作。也支持json的自动转换功能。具体用法可以参考我另一篇博客,有兴趣的可以看看。

Parceler: 优雅的使用Bundle进行数据存取就靠它了!(文章最后有关于组件化、插件化下应该如何使用此框架的说明)

优化加速

随着组件化拆分重构的进行。你会发现项目下的组件被拆分得越来越多,虽然你已经对组件的拆分粒度。进行过把控了。但是组件化后module持续增加是不争的事实,这个时候。随着module的持续增加。你的项目编译时间也会出现暴涨。

我们知道。项目编译流程中,第一步会将所有的library module先进行打包编译。生成对应的aar。提供给app进行使用,app等待所有module打包完毕后,再解压aar。进行资源、代码合并,并打包成apk执行运行。

所以我们制作了一个gradle加速插件。用于提前将module进行aar编译好。跳过module打包aar的过程。实现编译加速的效果。

具体原理可以参考这篇文章:Speedup:专为项目下Library project过多所设计的加速插件

更多小贴士

因为在组件化开发环境下,你将会遇到的问题远远不止以上这么点,当然上面这些都是很主要的。

所以这里添加此小贴士环节,用于添加一些平时我们开发时。可能会遇到的问题。或者说,一些在特定环境下的编码建议之类的。(这些要点很可能不能在demo中得到体现,所以请尽量认真看下描述)

巧用ActivityLifecycleCallbacks做初始化

因为组件化有个特点: 各自业务组可以任意选择自己的开发模式,如mvp,mvvm,RN等。

Android组件化demo