Android 组件化应用

一个软件系统的开发可能只需要2到3个月就能完成,而这个系统的迭代和维护时间可能达2到3年之久——《不记得哪本书上说的》
Android移动端项目经过长时间的迭代和维护,代码经手不同的人,产生冗余和规范问题也是一件不可避免的事情,而单工程的架构在面对这样的情况更是心有余而力不足,组件化也正是针对此类场景衍生出来的技术手段


源码地址

特点

组件化最明显的两个优势:代码解耦并行开发。通过不同维度和应用环境下进行不同程度的拆分,达到组件灵活配置,增加开发效率的目的。 所以细化来说,组件化就是根据功能和业务来拆分module,最后module组成模块,而后模块组装成应用。

组件化基础架构.png

Demo地址

应用入口:该层其实只是一个空壳,用来存放启动页面,同时依赖所有业务组件,从这里开始,便是全量应用的起始位置。

应用业务组件:根据不同业务横向拆分出来的业务组件,任何一个业务组件都可以独立出来成为一个应用存在,有完整的应用周期,这些组件是横向拆分出来的,所以他们之间并不存在依赖关系。

通用业务功能组件:通用业务其实相当用这类业务是从应用业务中抽取出来的并集,从应用上说,他属于业务,而针对应用业务而言则更像是一种功能,好比登录这种业务功能,不需要关心有没有界面,当中是怎样的逻辑,只需要提供结果即可,如有必要,中间的过程也可以通过回调的方式向外部暴露出来。

支撑服务组件接口:通用业务组件对外声明的功能接口抽取放到该层,可以说通用业务功能组件就是这层接口的实现者,同时考虑到应用业务通用代码的抽取,带业务属性的Base也可以放到该层。该层的接口抽取也可以理解这里是Module依赖的一种妥协,当同样层级的两个通用功能组件需要互相通信的时候,如果没有抽取一层公共的接口层出来,就会造成你中有我我中有你的尴尬局面,破坏了组件化解耦项目的初衷。

功能支撑:项目中用到的诸如网络请求,图片加载,依赖的第三方和XXUtils等通用纯Feature代码就可以放到这里,是以上每层代码的功能支撑,尤为重要,同时对部分通用View的封装也可以放到这里供上层使用。

每层的横向拆分模块之间是没有任何的依赖关系的,如果要做到相互之间的通信或是页面的跳转,那么就需要提供一个路由来作为组件间通信的桥梁。

综上所述,一个简单的组件化工程结构就出来了,说白了就是多Module开发而已


组件化工程目录.png

代码解耦

代码解耦主要是从两个方面,其一是公共代码的抽取和归纳,其二是面向接口编程,接口下沉。

譬如说日期序列化方法,获取字符串中的年月日,对外获取日期字符串和format格式即可,常看到的XXUtils便是常用的代码抽取方式,上层只关心结果,不关心具体的实现逻辑这是一种简单的减少代码冗余方式。

public static Date serializeDate(String dateString, String dateFormat);

而代码解耦其实与这种方式类似,只不过所调用的静态方法变成了一个接口

public interface DateSerializable{
    void serializeDate(String dateString, String dateFormat);
}

上层去持有这个接口,而不是一个具体的类,通过实例化对应的实现来获得具有能力的实例来进行业务,那么当底层发生了改变或是实现的时候,上层只需要实例化对应的新实现类即可,如果把这层实例化也作为接口去作,那么上层完全不用改变就能拥抱变化,遵循设计原则去设计具体的应用和功能代码

依赖注入

继续深入之后就是面向接口编程,上层与下层之间的通信以及下层提供能力的媒介以接口的形式存在,这样后期实现的改变可以更换不同的实现类即可,上层几乎不需要做任何的改动。对于能够提供通用能力与业务没有直接关系的Feature接口应尽量下沉到底层。

而横向的业务代码或者功能实现可以进行依赖注入的方式来达到解耦的目。

依赖注入简单示例
类比程序员ICoder,提供一个coding的能力

private interface ICoder {
    void coding();
}

而后提供一个工作者的类,由工作者来持有coding这项技能,如下:

private static class Worker{
    private ICoder mCoder;

    public Worker(ICoder coder){
        mCoder = coder;
    }

    public void working(){
        mCoder.coding();
    }
}

显而易见,工作者内部持有一个ICoder这样的一个对象,而这个对象则是通过构造传递进来,这样的方式便能称之为依赖注入Di(Dependency injection)

完善接下来的代码

private static class Javaer implements ICoder{
        
    @Override
    public void coding(){
        System.out.println("i am java coder, code for java");
    }
}

private static class Pythoner implements ICoder {
    @Override
    public void coding(){
        System.out.println("i am python coder, code for python");
    }
}

创建两个实现coding能力的类,分为Java开发和Python开发,现在就相当于公司有两个程序员,他们分别是Java和Python,当来活的时候,可以甩锅到这两位同僚身上。

public static void main(){
    Worker worker = new Worker(new Javaer());
    worker.working();
}

可以看到工作者持有的能力由外部赋值进去,工作者内部需要持有对这层工作能力的接口。

上面依赖注入的方式就是一个控制反转Ioc(Inversion of control)的例子:由程序内部定制规则,外部传递遵循了内部规则的实现。

结构解耦

结构的解耦其实一般针对应用的整体业务而言进行的一个"分Module"操作,根据业务类型的横向拆分,业务属性的纵向拆分以及功能SDK下沉。
古老的开发套路一般都会将Feature相关的代码单独作为一个Module进行依赖,然后上层的应用和业务写到APP中去。


一般工程结构.png

这是最常见的操作,通过抽取常用的功能代码,做上简单的封装,抽取到一个公共库中,在编写具体界面或者业务是可以直接拿来用。

而我们组件化的操作,则在此基础上做一个更加细粒度的抽取,根据功能或是业务来进行横向拆分纵向排序

横向拆分

横向就是对类别不同的模块做一个拆分,对应的就是业务类模块,功能类模块,而具体细化下来就负责业务的
Business类模块:businessA、businessB...
基础业务功能模块:login、share、welcome...
支撑功能类模块:baseUI、commonLib...

纵向排序

而纵向排序就是对横向拆分出来的模块做一个依赖关系的排序


组件化纵向依赖关系.png

技术细节

多Module工程

根据上述理论对工程进行拆分
根据上面的框架图,大致把一个基础应用项目拆分出下列几个Module


组件化工程目录.png

在此有一个小细节,可以对基础功能库所依赖的三方做一个简单的gradle管理

dependencies = [
            "appcompatv7"              : "com.android.support:appcompat-v7:${SUPPORT_LIB_VERSION}",
            "constraintlayout"         : "com.android.support.constraint:constraint-layout:${othersVersion.constraintlayout}",
            "recyclerview"             : "com.android.support:recyclerview-v7:${SUPPORT_LIB_VERSION}",
            "cardview"                 : "com.android.support:cardview-v7:${SUPPORT_LIB_VERSION}",
            "design"                   : "com.android.support:design:${SUPPORT_LIB_VERSION}",
        

            "glide"                    : "com.github.bumptech.glide:glide:${othersVersion.glideVersion}",
            "glideCompiler"            : "com.github.bumptech.glide:compiler:${othersVersion.glideVersion}",

            "rxjava"                   : "io.reactivex.rxjava2:rxjava:${othersVersion.rxjava2}",
            "rxandroid"                : "io.reactivex.rxjava2:rxandroid:${othersVersion.rxandroid}",
            "litepal"                  : "org.litepal.android:core:${othersVersion.litepal}",
            "retrofit"                 : "com.squareup.retrofit2:retrofit:${othersVersion.retrofit}",
            "convertergson"            : "com.squareup.retrofit2:converter-gson:${othersVersion.convertergson}",
            "adapterrxjava2"           : "com.squareup.retrofit2:adapter-rxjava2:${othersVersion.adapterrxjava2}",
            "converterScalars"         : "com.squareup.retrofit2:converter-scalars:${othersVersion.converterScalars}",
            "okHttpLoggingInterceptor" : "com.squareup.okhttp3:logging-interceptor:${othersVersion.okHttpLoggingInterceptor}",
            "eventbus"                 : "org.greenrobot:eventbus:${othersVersion.eventbus}",

            "leakcanaryAndroid"        : "com.squareup.leakcanary:leakcanary-android:${othersVersion.leakcanaryAndroid}",
            "leakcanaryAndroidNoOp"    : "com.squareup.leakcanary:leakcanary-android-no-op:${othersVersion.leakcanaryAndroidNoOp}",
            "leakcanarySupportFragment": "com.squareup.leakcanary:leakcanary-support-fragment:${othersVersion.leakcanarySupportFragment}",

            "arouterApi"               : "com.alibaba:arouter-api:${othersVersion.arouterApi}",
            "arouterCompiler"          : "com.alibaba:arouter-compiler:${othersVersion.arouterCompiler}",

    ]

图中对依赖的三方进行了一个简单的依赖,后期也可写一个函数来将依赖进行升级,重点不在写法,在于抽取。

  1. 在根目录下建立一个xx.gradle文件
  2. 编写对应的依赖常量代码
  3. 在build.gradle中引用
  4. 在对应的文件中使用
    注意,如果想要在别的.gradle中使用声明的这些常量,一定要在抽取的xx.gradle文件中将对应的代码块用"ext"进行包裹
/**
     * 三方依赖库
     */
    othersVersion = [
            glideVersion             : "4.8.0",

            rxjavaVersion            : "2.1.1",
            rxandroidVersion         : "2.0.1",

            constraintlayout         : "1.1.2",
            rxjava2                  : "2.1.1",
            rxandroid                : "2.0.1",
            recyclerview             : "27.1.1",
            litepal                  : "2.0.0",
            retrofit                 : "2.4.0",
            converterScalars         : "2.4.0",
            okHttpLoggingInterceptor : "3.8.0",
            convertergson            : "2.4.0",
            adapterrxjava2           : "2.4.0",
            eventbus                 : "3.1.1",

            leakcanaryAndroid        : "1.6.1",
            leakcanaryAndroidNoOp    : "1.6.1",
            leakcanarySupportFragment: "1.6.1",

            multiDex                 : "1.0.3",

            arouterApi               : "1.4.1",
            arouterCompiler          : "1.2.2",
            arouterRegister          : "1.0.2",

            buglyCrashReport         : "latest.release",
            buglyNativeCrashReport   : "latest.release"
    ]

在工程根目录的build.gradle中加上如下代码:

apply from: "config.gradle"

这样在Module对应的编译脚本文件中就能正常引用了,如:

def ANDROID_VERSIONS = rootProject.ext.android
def OTHER_LIBRARY = rootProject.ext.dependencies
...
dependencies {
    implementation fileTree(include: ['*.jar'], dir: 'libs')
    api OTHER_LIBRARY.appcompatv7
    api OTHER_LIBRARY.constraintlayout
    api OTHER_LIBRARY.recyclerview
    api OTHER_LIBRARY.cardview
    api OTHER_LIBRARY.retrofit
    api OTHER_LIBRARY.converterScalars
    api OTHER_LIBRARY.okHttpLoggingInterceptor
    api OTHER_LIBRARY.convertergson
    api OTHER_LIBRARY.adapterrxjava2
}

如此以后在查看或者更换依赖的时候也方便查看和维护,注意在进行依赖的过程中,因为依赖不同的三方,可能会出现重复依赖相同库而版本不一致的情况,这里有两种解决办法,一种是在对应依赖的三方中剔除对应的pom依赖,如:

api('com.facebook.fresco:fresco:0.10.0') {
       exclude module: 'support-v4'
}

另外一种是强制依赖的相同库的版本,如:

configurations.all {
    resolutionStrategy.eachDependency { DependencyResolveDetails details ->
        def requested = details.requested
        if (requested.group == 'com.android.support') {
            if (requested.name.startsWith("support-") ||
                    requested.name.startsWith("animated") ||
                    requested.name.startsWith("cardview") ||
                    requested.name.startsWith("design") ||
                    requested.name.startsWith("gridlayout") ||
                    requested.name.startsWith("recyclerview") ||
                    requested.name.startsWith("transition") ||
                    requested.name.startsWith("appcompat")) {
                details.useVersion SUPPORT_LIB_VERSION
            } else if (requested.name.startsWith("multidex")) {
                details.useVersion OTHER_VERSION.multiDex
            }
        }
    }
}

组件路由

路由其实是组件化的核心组件,网上也有很多优秀的开源库,这里就直接使用阿里的开源库ARouter,ARouter也是其中比较知名和稳定的路由框架之一。

  1. 添加依赖和配置

    android {
        defaultConfig {
            ...
            javaCompileOptions {
                annotationProcessorOptions {
                    arguments = [AROUTER_MODULE_NAME: project.getName()]
                }
            }
        }
    }
    
    dependencies {
        // 替换成最新版本, 需要注意的是api
        // 要与compiler匹配使用,均使用最新版可以保证兼容
        compile 'com.alibaba:arouter-api:x.x.x'
        annotationProcessor 'com.alibaba:arouter-compiler:x.x.x'
        ...
    }
    // 旧版本gradle插件(< 2.2),可以使用apt插件,配置方法见文末'其他#4'
    // Kotlin配置参考文末'其他#5'
    
  2. 添加注解

    // 在支持路由的页面上添加注解(必选)
    // 这里的路径需要注意的是至少需要有两级,/xx/xx
    @Route(path = "/test/activity")
    public class YourActivity extend Activity {
        ...
    }
    
  3. 初始化SDK

    if (isDebug()) {           // 这两行必须写在init之前,否则这些配置在init过程中将无效
        ARouter.openLog();     // 打印日志
        ARouter.openDebug();   // 开启调试模式(如果在InstantRun模式下运行,必须开启调试模式!线上版本需要关闭,否则有安全风险)
    }
    ARouter.init(mApplication); // 尽可能早,推荐在Application中初始化
    
  4. 发起路由操作

    // 1. 应用内简单的跳转(通过URL跳转在'进阶用法'中)
    ARouter.getInstance().build("/test/activity").navigation();
    
    // 2. 跳转并携带参数
    ARouter.getInstance().build("/test/1")
                .withLong("key1", 666L)
                .withString("key3", "888")
                .withObject("key4", new Test("Jack", "Rose"))
                .navigation();
    
  5. 添加混淆规则(如果使用了Proguard)

    -keep public class com.alibaba.android.arouter.routes.**{*;}
    -keep public class com.alibaba.android.arouter.facade.**{*;}
    -keep class * implements com.alibaba.android.arouter.facade.template.ISyringe{*;}
    
    # 如果使用了 byType 的方式获取 Service,需添加下面规则,保护接口
    -keep interface * implements com.alibaba.android.arouter.facade.template.IProvider
    
    # 如果使用了 单类注入,即不定义接口实现 IProvider,需添加下面规则,保护实现
    # -keep class * implements com.alibaba.android.arouter.facade.template.IProvider
    
  6. 使用 Gradle 插件实现路由表的自动加载 (可选)

    apply plugin: 'com.alibaba.arouter'
    
    buildscript {
        repositories {
            jcenter()
        }
    
        dependencies {
            classpath "com.alibaba:arouter-register:?"
        }
    }
    

    可选使用,通过 ARouter 提供的注册插件进行路由表的自动加载(power by AutoRegister), 默认通过扫描 dex 的方式
    进行加载通过 gradle 插件进行自动注册可以缩短初始化时间解决应用加固导致无法直接访问
    dex 文件,初始化失败的问题,需要注意的是,该插件必须搭配 api 1.3.0 以上版本使用!

  7. 使用 IDE 插件导航到目标类 (可选)
    在 Android Studio 插件市场中搜索 ARouter Helper, 或者直接下载文档上方 最新版本 中列出的 arouter-idea-plugin zip 安装包手动安装,安装后
    插件无任何设置,可以在跳转代码的行首找到一个图标 (

    navigation
    )
    点击该图标,即可跳转到标识了代码中路径的目标类

单独调试

当工程被拆分为组件化的时候,那么Module的单独调试就显得尤为重要,无论是对问题的跟踪还是业务线的并行开发,都需要工程具备单独运行调试的能力。这里单独调试同样是对gradle的操作,通过对编译脚本的编写来达到组件单独运行的目的
1.对需要单独运行的Module抽取变量进行记录

 /**
     * 欢迎组件
     */
    welcomeLibRunAlone = false

    /**
     * 分享组件
     */
    shareLibRunAlone = false

    /**
     * 主业务组件
     */
    mainBusinessLibRunAlone = false

    /**
     * 登录组件
     */
    loginLibRunAlone = false
  1. 在对应Module的编译脚本文件中添加判断Module是以Lib形式依赖还是以App方式进行依赖,如下代码
def runAlone = rootProject.ext.loginLibRunAlone.toBoolean()

if (runAlone) {
    apply plugin: 'com.android.application'
} else {
    apply plugin: 'com.android.library'
}

当进行到这里,其实就可以让Module单独运行起来,同时添加Application中一些必要的元素,清单文件Manifest.xml文件,但是这个xml文件是文件在单独运行的过程中所需要的,所以这里要放到一个单独的目录下


组件化单独运行目录结构.png

同时在此基础上通过编译脚本配置单独运行时获取的Android相关文件

sourceSets {
        main {
            if (runAlone) {
                manifest.srcFile 'src/main/runAlone/AndroidManifest.xml'
            } else {
                manifest.srcFile 'src/main/AndroidManifest.xml'
                java{
                    exclude 'src/main/runAlone/*'
                }
            }
        }
    }

根据以上配置,就可以使得登录模块单独运行起来,调试起来非常方便

整体调试

根据上述方案,app壳其实只需要依赖业务组件即可

dependencies {
    implementation fileTree(include: ['*.jar'], dir: 'libs')
    implementation project(':mainbusinesslib')
}

业务组件在根据调试变量对相应的基础组件进行依赖

dependencies {
    implementation fileTree(include: ['*.jar'], dir: 'libs')

    api project(':baseuilib')
    annotationProcessor OTHER_LIBRARY.arouterCompiler
    if(!shareAlone){
        api project(':sharelib')
    }
    if(!welcomeAlone){
        api project(':welcomelib')
    }
    if(!loginAlone){
        api project(':loginlib')
    }
}

编译优化

这里的编译优化是针对gradle编译而言,其一是gradle在编译时的配置,如优化不检查png图片的合法性等等

其二是固化底层代码,只编译业务线代码,所谓固化其实是对开发不相关的代码进行aar依赖,这样在编译的时候就不需要去编译这些库中的代码,大大节省编译时间,增加开发效率

def dependencyMode = "aar"
...
    commonLibVersino = "1.0.0"
    loginLibVersino = "1.0.0"
    shareLibVersino = "1.0.0"
    welcomeLibVersino = "1.0.0"
    switchs = [
            "CommonLib"  : dependencyMode == "aar",
            "ShareModule": dependencyMode == "",
            "LoginModule": dependencyMode == "",
            "WelcomeLib" : dependencyMode == "",
    ]
...
dependencies = [
           ...
            "commonlib" : switchs['CommonLib'] ? "com.done.commonlib:CommonLib:" + "$commonLibVersino" : findProject(':commonlib')

    ]

同时在setting.gradle中编写一下代码

...
include switchs['CommonLib'] ? '' : 'commonlib'

综上,一个简单的组件化架构就搭建完毕了

后期会整理一下的相关的代码发布到github上哈欢迎大家进群交流哈群号:929891705
Demo地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268