从零开始的 Android 新项目 11 - 组件化实践(1)

1,280 阅读13分钟
原文链接: mp.weixin.qq.com

最近更新不太频繁,一方面工作上比较忙,除了 Android 也在负责前端,另外周末和深夜也在帮人做 Go 后台、设计技术方案、管进度的事情(因为报酬不错没忍心拒绝,而且确实对个人成长还有帮助),所以实在对不住。

另外,文章最底下有捐款啊,最近真是都没钱吃饭了。。。

前言

这里的组件化,指的是 MDCC 2016 上冯森林提出的《回归初心,从容器化到组件化》。

我个人一直是比较反感黑科技的,其中首当其冲的就是 插件化 以及 保活。作为一个开发者,除了研究技术,提高自己以外,是否应该考虑些其他东西呢?尤其是我们这些嵌入式系统(客户端)开发者,在依赖、受哺于系统生态下,是不是应该考虑一下,怎么反哺?怎么去更好地维护这个生态环境,而不是一味破坏、消耗它呢?

想一想那些黑科技带来的。插件化导致线上可以执行任何代码且不留下痕迹,用户安全性和信任感何在?保活导致应用长时间不释放,抢占系统资源,让用户产生 Android 越用越卡的感觉。全家桶互相唤醒,确定不是逼着用户删除应用?至少我在 Android 手机上是不敢装某些知名应用的。

Greenify —— 绿色守护 帮助我们解决了应用死不掉的问题。那其他的呢?作为一个 Android 开发者,我不敢在我的 Android 手机上装一些应用 —— 支付宝、淘宝、闲鱼(Web 上还不让用)、天猫、京东、百度贴吧。有朋友找我推荐手机的时候,我从不会推荐 iPhone,但给他们推荐 Android 后,又会担心他们能不能 hold 住国内生态下的 Android 手机。有一个买了 Sony Z5 的女孩子,当时问我为啥用电那么快后,我实在无言以对。只能给她指导了一些姿势和黑科技。


幸而时至半年后的今天,她用得还挺顺手,而 iOS10 也顺利给自己抹黑了一把。

然而——
今天你在消耗这个生态,明天你就得为此承担结果。

组件化是什么

组件化,相对于容器化(插件),是一种没有黑科技的相互隔离的并行开发方式。为了了解组件化,不得不先说一下插件化。

为什么我们需要插件化

现代 Android 开发中,往往会堆积很多的需求进项目,超过 65535 后,MultiDex、插件化都是解决方案。但方法数不是引入插件化的唯一原因,更多的时候,引入插件化有另外几个理由:

  • 满足产品经理随时上线的需求(注意,这在国外是命令禁止的,App store 和 Google Play 都不允许这种行为,支付宝因此被 Google Play 下架过,仔细想想,如果任何应用都能在线上替换原来的行为,审查还有什么用?)。

  • 团队比较有钱,愿意养人做这个。技术人员觉得不做业务简直太棒了,可以安心研究技术。

  • 并行开发,常见于复杂的各种东西往里塞的大型应用,比如 —— 手Q、手空、手淘、支付宝、大众点评、携程等等。这些团队的 Android 开发动辄是数百人,并分成好几个业务组,如此要并行开发便需要解耦各个模块,避免互相依赖。而且代码一多吧,编译也会很慢(我们公司现在的工程已经需要 5 - 6 分钟了,手空使用 ant 都需要 5 分钟,而 手Q 使用 ant 则需要 10 分钟,改成 gradle 的话姑且乘个2,都是几十分钟的级别)。插件化可以加快编译速度,从而提高开发效率。

其实真正的理由就只有第三个(我相信业务技术人员也不会真的想无休止地发版本,除了一些分 架构组/业务组 的地方,架构组会不考虑业务组的感受)。在知乎上,小梁也有对此作出回答:怎么将 Android 程序做成插件化的形式?,建议去读一下。

本篇里不多说插件化的工作原理,建议移步去别处学习,直接看源码也可以,像 atlas 这样 Hook 构成的插件框架可能阅读起来会有些困难,其他还好。

插件化的恶

躺不完的坑。
—— 即便是一些做了很多年的插件化框架,依然在不断躺坑,更何况是使用他们的开发者,简直是花式中枪。

发不完的版本。
—— 什么?赶不上?没事,迟些可以单独发版本。这回你可真是搬砖的码农了。

这个在我的插件里是好的呀。
—— 在各自的壳里运行很完美,然而集成后各种问题不断,甚至一启动就 ANR。

版本带来的问题。
—— 因为要动态发版本,所以每个插件自然需要有各种版本。什么?那个不对?肯定是你引用的版本错啦。更何况发版本本身就是个让人很心累的事情。

等等等等,不赘述。垃圾插件,还我青春。

组件化 VS 插件化

组件化带来的,是一个没有黑科技的插件化。应用了 Android 原有的技术栈以及 Gradle 的灵活性,失去的是动态发版本的能力,其他则做得比插件化更好。因为没有黑科技,所以不会有那么多黑科技和各种 hook 导致的坑,以及为了规避它们必须小心翼翼遵守的开发规范,几乎和没有使用插件化的 Android 开发一模一样。

而我们需要关心的,只是如何做好隔离,如何更好地设计,以及提高开发效率与产品体验。

Take Action

Gradle

组件化的基本就是通过 gradle 脚本来做的。

通过在需要组件化的业务 module 中:

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

并在业务 module 中放一个 gradle.properties:

isDebug=false

如此,当我们设置 isDebug 为 true 时,则这个 module 将会作为 application module 编译为 apk,否则 为 library module 编译为 aar。

下面的 gradle 是我们的一个组件化业务 module 的完整 build.gralde:

println isDebug.toBoolean()if (isDebug.toBoolean()) {
    apply plugin: 'com.android.application'} else {
    apply plugin: 'com.android.library'}

apply plugin: 'me.tatarka.retrolambda'apply plugin: 'com.neenbedankt.android-apt'android {
    compileSdkVersion rootProject.ext.compileSdkVersion
    buildToolsVersion rootProject.ext.buildToolsVersion

    defaultConfig {
        minSdkVersion rootProject.ext.minSdkVersion
        targetSdkVersion rootProject.ext.targetSdkVersion
        versionCode rootProject.ext.versionCode
        versionName rootProject.ext.versionName
        multiDexEnabled true

        if (isDebug.toBoolean()) {
            ndk {
                abiFilters "armeabi-v7a", "x86"
            }
        }
    }
    compileOptions {
        sourceCompatibility rootProject.ext.javaVersion
        targetCompatibility rootProject.ext.javaVersion
    }
    lintOptions {
        abortOnError rootProject.ext.abortOnLintError
        checkReleaseBuilds rootProject.ext.checkLintRelease
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
    dataBinding {
        enabled = true
    }    if (isDebug.toBoolean()) {
        splits {
            abi {
                enable true
                reset()
                include 'armeabi-v7a', 'x86'
                universalApk false
            }
        }
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile project(':lib_stay_base')
    apt rootProject.ext.libGuava
    apt rootProject.ext.libDaggerCompiler
}

各位根据实际需要参考修改即可。

这里另外提供一个小诀窍,为了对抗 Android Studio 的坑爹,比如有时候改了 gradle,sync 后仍然没法直接通过 IDE 启动 module app,可以修改 settings.gradle,比如:

include ':app'include ':data'include ':domain'include ':module_setting'include ':module_card'include ':module_discovery'include ':module_feed'include ':lib_stay_base'// 省略一堆 sdk 库

可以把不需要的 module 都给先注释了(只留下需要的 module,lib_base,以及 sdk),尤其是 app module。然后基本上就没问题。

Manifest

一个很常见的需求就是,当我作为独立业务运行的时候,manifest 会不同,比如会多些 activity(用来套的,或者测试调试用的),或者 application 不同,总之会有些细微的差别。

一个简单的做法是:

sourceSets {
    main {        if (isDebug.toBoolean()) {
            manifest.srcFile 'src/debug/AndroidManifest.xml'
        } else {
            manifest.srcFile 'src/release/AndroidManifest.xml'
        }
    }
}

这样在编译时使用两个 manifest,但是这样一来,两者就有很多重复的内容,会有维护、比较的成本。

我们可以利用自带 flavor manifest merge,分别对应 debug/AndroidManifest.xml, main/AndroidManifest.xml, 以及 release/AndroidManifest.xml。

main 下的 manifest 写通用的东西,另外 2 个分别写各自独立的,通常 release 的 manifest 只是一个空的 application 标签,而 debug 的会有 application 和调试用的 activity(你总得要有个启动 activity 吧)及权限。

这里有一个小 tip,就是在 release 的 manifest 中,application 标签下尽量不要放任何东西,只是占个位,让上面去 merge,否则比如一个 module supportsRtl 设置为了 true,另一个 module 设置为了 false,就不得不去做 override 了。

Wrapper

看一个 debug manifest 的例子:



    

        
            
                
                
            
        

    

    

这里的 WrapActivity 就是我们所谓的 wrapper 了。

因为入口页可能是一个 fragment,所以就需要一个 activity 来包一下它,并作为启动类。

Application

BaseApplication 继承了 MultiDexApplication,而真正最后集成的 Application 则继承自
BaseApplication,并添加了一些集成时需要做的事情(比如监控、埋点、Crash上报的初始化)。

但大部分的仍会放在 BaseApplication,比如图片库、React Native、Log 等。然后各个 Module 则直接使用 BaseApplication,免去各自去写初始化的代码。

当然,如果一定想复杂化,也可以专门搞个 library module 做初始化,但我个人不建议过度复杂的设计。

可以先阅读阿布的总结文章:项目组件化之遇到的坑,也感谢小梁抛砖引玉的 Demo

我这边简单也讲一讲。

Data Binding

见我上一篇写到的记一次 Data Binding 在 library module 中遇到的大坑,简单说起来就是 data binding 在 library module 的支持有一个 bug,就是不支持 get ViewModel 的方法,只能 set 进去,从而导致做好模块化的 module 在作为 application 可以独立运行后,作为 library module 无法通过编译。

另外碰到一个问题,就是时不时会有如下的报错(出现在集成 application 的时候,且并不是必现):

查看图片

经过分析和猜测,发现每次都是同一个 module 堵住的,进去看了看…竟然几乎是空的,是个还没有进行组件化重构的模块(只有一个 manifest 和 string.xml),然而 build.gradle 却使用了 data binding。看来又是个 Google 埋下的坑。心很累,就不去报 bug 了。

Dagger2

几个月前写过从零开始的Android新项目4 - Dagger2篇,用了快一年时间的 Dagger2 后,越来越觉得这种注入方式很不错。

然而没想到在组件化改造中会这么坑,但是也不能怪 Dagger2,而是原先隔离就做的不够好。

从设计上来说,Component 和独有的 Module 都只能放在对应的业务 module 中。module 之间不能互相访问彼此的 Dagger Module。且 data 和 domain 两个 module 中各种业务独有的类也应该放在业务 module 中,或者至少应该分拆出来。否则在 Module A 进行组件化开发的时候,却能引用 Module B 的 Api 类以及数据 Bean,简单来说也就是知道得太多。

所以如果使用了 Dagger2,这里就需要把原来的 scope 更进一步做到极致,理清所有依赖的可见区域。

最佳实践

每个 module 包名都应该使用 “business” 形式,资源使用业务名开头,比如 “feed_ic_like.png”。

另外,在组件化实践过程中可能碰到的就是依赖的问题了,然而因为我们项目本身就设计得还算不错,所以并没有在这方面需要做任何修改,整个项目的架构图如下:

查看图片

简化了不少,有些省略了,因为实在懒得画。对模块来说,通用的东西放在底层 library(utils、widget),而只有自己用的则放在自己 module 就行了。

作为一个善意提醒,如果一个模块分拆为三个模块,那 clean build 的速度肯定会变慢,要有心理准备。

模块隔离

可参考上图,关键的点就是高内聚,低耦合。

通用的东西按照其功能性划分在不同 library 模块中。见上图(已经省略了不少了,实际 module 更多一些)。

改进点在于,从组件化角度来讲,data 和 domain 并不是一个 public 的 scope,也应该放在各个业务模块中,但因为目前的实现,进行重构代价太大,只能放在以后新模块进行实践。

RPC

RPC 在广义上指的是一种通信协议,允许运行于一台计算机的程序调用另一台计算机的子程序,而开发者无需额外地为这个交互作用编程。Android 上的 AIDL 也是一种 RPC 的实现。

这里指的 RPC 并没有跨进程或者机器,而是一种类似的 —— 在彼此无法互相访问的时候的接口定义和调用。

Proxy

通用的 Proxy 抽象类:

public abstract class Proxy implements IProxy {    private static final String TAG = "Proxy";    private Module proxy;    @Override
    public final T getUiInterface() {        return getProxy().getUiInterface();
    }    @Override
    public final C getServiceInterface() {        return getProxy().getServiceInterface();
    }    public abstract String getModuleClassName();    public abstract Module getDefaultModule();    protected Module getProxy() {        if (proxy == null) {
            String module = getModuleClassName();            if (!TextUtils.isEmpty(module)) {                try {
                    proxy = (Module) ModuleManager.LoadModule(module);
                } catch (Throwable e) {
                    LogUtils.e(TAG, module + " module load failed", e);
                    proxy = getDefaultModule();
                }
            }
        }        return proxy;
    }
}

实现类则集成并重载两个抽象方法:

public class FeedProxy extends Proxy {    public static final FeedProxy g = new FeedProxy();    // 在没有获得真实实现时候的默认实现
    @Override
    public Module getDefaultModule() {      return new DefaultFeedModule();
    }    // 真实实现的类
    @Override
    public String getModuleClassName() {        return "com.amokie.stay.module.feed.FeedModule";
    }
}

IFeedUI 定义 Feed 模块中的 UI 相关接口,IFeedService 则是 Feed 模块的服务接口。

建议直接暴露 intent 或者 void 方法来提供跳转,而不是返回 activity。

Router

最 low 的就是用 Class.forName 去拿 activity 或者 fragment 了…其他可以使用 scheme、各自注册、甚至类 RPC 的调用方式。

为什么说 forClass 去获取 activity 或者 fragment 很 low ?模块 A 想去模块 B 的一个页面,拿到 activity 后,难道还要自己去填 intent,还要自己去问人到底需要哪些参数,需要以什么形式过去?再者如果是要去模块 B 的某个 activity 中的某个 fragment,怎么表示?

性能问题就不谈了。这么定义后,以后包名类名都不敢换了。

RPC

就是上面提到的类似 IFeedUI 这样的类了,使用的时候

FeedProxy.g.getUiInterface().goToUserHome(context, userId);

根据灵活性和需要,也可以把 intent 本身作为初始参数传入。

注册

即每个页面自行去中央 Navigator 注册自己的 Url。

中央 Navigator 维护一个 Hashmap 用于查询跳转。

如此,我们就依然可以通过 Android 原生的 Bundle/Intent 来传 Parcelable 数据。

scheme

Android 原生的 scheme。当我们在浏览器或者一个应用呼起另一个应用,使用的就是这个机制。

与上一个方法不同的是,这是 Android 原生支持的,我们需要在 manifest 进行注册:



    
        

        
        

        
    
    

跳转调用更简单:

intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));

参数可以使用类似 url param 的形式,比如:stayapp://feed-detail/?id=1234&guest=true。
简单情况下也能直接使用 Rest 形式,即 stayapp://feed-detail/1234,但如此就只能传递一个数据过去了,毕竟 Rest 是一种资源描述。

Software -> Peopleware,在项目逐渐变大后,团队人数变大,需求复杂度上升,组件化的开发形式可以隔绝模块间耦合,降低中大型团队的开发成本,而且编译速度也能提升(独立模块编译运行)。

下一节将会讲到组件化实践中的:

  • 底层 library 设计

  • SharedUserId 共享数据

  • 组件间通讯(Service、EventBus)