美团猫眼电影android模块化实战--可能是最详细的模块化实战
1 写这篇博客的初衷
首先一句话概括:我想把这几个月做的事情记录下来,并且希望尽量详细,希望读者读了这篇文章能够知道项目进行模块化,项目改业务框架可能会遇到哪些问题,具体每个步骤都做什么,而不是大致的了解。
现在很多人都在谈模块化,网上有一大堆的博客实践都在讲这个。很多谈的只是模块与模块之间的解耦,并且大部分讲的是通过router路由进行解耦,其他谈的不多,而且不乏泛泛而谈。但将一个app真正做到解耦,运行。需要解决的事情远远不止解耦。业务架构、进程间通信、资源等处理、解耦方式等都需要解决。恰好对于猫眼模块化整个过程的实施,从头到尾,分析解决各种问题,我陆陆续续的做了几个月。猫眼app的历史版本是一个耦合度很高的一个工程。从这样的一个历史版本到最终的各个业务模块能够独立运行并且能够做进程间通信,会涉及到各个方面的解耦和一些其他东西。我今天我就以该app为例(其他的app进行解耦可能会遇到不同的问题,这点注意一下),完整的讲下猫眼模块化的整个过程。每一个方面没有照搬网络的一些做法,而是分析对比,采用更好的设计方式。比如解耦使用serviceloader,而不是路由进行;比如架构使用更适合我们业务的一种带生命周期的mvp变种。我还会说下具体的花费时间和一些经验,这样大家以后做模块时也心中有数。(提示一下,其实模块化过程所涉及的东西除了文章提及的还有很多。有些未提及,是因为之前已经完成,比如网络库的缓存由数据库->文本,这点读者注意一下。如果还有遗漏的地方,可以交流~)。
主要内容:serviceloader解耦,mvp变种框架,模块通信,lib独立运行,多端复用。
2 为什么做模块化
首先要说一点:做模块化不是为了炫技。如果没有业务场景需求,不建议做。
为什么要做模块化,网上已经阐述了很多原因了。这里我简单说下猫眼为什么要做:
- 猫眼需要快速移植到其他app(美团,点评..)。
- 解耦首页,减少冷启动时间。
- 开发时减少build时间,代码责任制。
- 服务快捷替换
3 解耦到什么程度?
首先说下,模块化究竟是什么呢?这个大家肯定都耳熟能详了:能够将不同的业务分离成不同的lib module。那么做完模块化,我们的某个业务lib 具有哪些功能呢?我认为是:
总结一句话:无沟通成本,快速,傻瓜式的在任何app上运行。具体就是:这个lib不耦合具体app的服务,不耦合具体app的activity。只要给我一个app(或假的app壳子),通过它的baseActivity,和他们的服务,我就可以非常快速的将这个lib在那个app上运行。停!你可能会说这个服务是什么东西,让我详细的说下吧~
3.1 可以无侵入式的配置各种服务
我们知道每个app都会提供账户信息,设备信息,网络服务,图片加载服务,打点服务,下拉刷新样式,错误状态等。每一个app的这些服务可能都不一样,比如美团使用的网络服务是okhttp,而点评使用的是长连接。所以我们的业务逻辑lib不能耦合这些具体的服务。只能耦合服务抽象而来的接口。在具体app使用的时候,我们再把app的服务提供给这个lib。那么这些服务怎么给呢?如果当需要服务时,我都留了一个传参的口子,这样我就需要把app的服务一个个塞到lib中需要的地方。这样成本太大了。我不希望这么麻烦,我希望的方式是直接把服务实现作为txt文本放在app的某个文件夹,你这个lib就能给我运行。这样我几乎不用管lib里面是什么东西。你只要给我一个业务lib,我添加一个txt文本,就能运行了。
3.2 lib快速便捷多端使用
说下不耦合activity。我们知道每个app有自己的baseAtivity,在里面做统计,处理异常、某些库的初始化等功能。除此之外,每个app的actionBar也不一样,每个页面在不用的app中manifest的schema也不一样。所以在lib中的业务,如果是一个业务,我们不能直接写成Activity,而应该是一个view/fragment,这样对于任何一个app,我们直接新建一个activty,然后把lib中的页面放到那个activity中即可。同样,考虑的是协同合作的成本问题,我不希望在放这个页面的时候,我需要处理很多其他的东西,比如数据加载。我希望你给我这个业务页面pager(其实是一个view),我放到activity onCreate()的setContentView()中即可,它就能运行。别让我做其他处理生命周期,数据绑定,销毁等的事,那都是你pager内部需要做的。
3.3 demo示例
前面这两点说的可能云里雾里的。最近我写一个猫眼问答需求,涉及5个页面,所以做成了一个lib。那么就结合我最近写的lib来图文阐述一下。
这个lib就是问答的业务lib。这不耦合具体的服务,只耦合服务接口。里面的页面(page包下)不是activity,而是view。
那么,这时候另外一个同事想把这个lib用到猫眼app上。怎么做呢?
-
在猫眼app的build.gradle下添加这个lib的依赖:
compile 'com.maoyan.android.business:movie:1.0.2.3'
-
在猫眼宿主app中添加一个lib需要的服务配置:服务实现txt文本(因为是宿主app,之前其实已经存在)。
txt里面是:
-
在宿主app中创建activity,并放置lib中的页面,填写manifest,比如(可能有时候需要在里面写入actionBar的交互逻辑)
这就完成了,就运行了。所使用的各种服务,下拉刷新等都是这个app提供的。是不是快速、无需沟通写作、傻瓜。
如果我们想测试这个app,那么也很简单。随便建一个app壳子,新建activity,把lib中的页面page放进去。然后添加所需要的服务实现txt文本(因为是测试,所以服务实现可以自由一些,可随意配置),
就大功告成了。这种方式来修bug调ui,比启动宿主app修改代码节省很多时间。
我们看下我随意写了一个app来测试lib:
我们可以看到下拉刷新,状态服务等和猫眼app中的都不一样,都可以定制。如果都这么写,其实所有的模块我们都可以快速,傻瓜,可定制的做成app这种解耦程度是不是更好呢
如果感觉还不错的话,那么我们开始工作吧~
4 开始模块化之旅
4.1 原项目耦合结构
开始模块化工作,我首先得给大家呈现下之前未模块时高度耦合的猫眼app。我们这里以电影详情页为例,看看他的耦合情况:
电影详情页是建立在一层层的基类之上,这些基类耦合了具体的网络加载等各种服务。因为详情页有想看、评分、点赞等可编辑状态,所以还耦合了greendao数据库(以前网络加载也耦合了这个数据库,后来换成了retrofit+rxjava,所以替换到了这层耦合,谢天谢地)。该页面因为需要和其他页面互动(比如跳转、评分同步等),所以也同时耦合了其他页面的类。除此之外,还有utils,view,model等。如果想把电影详情页抽离出来,这些所有的耦合都要剥离。具体需要解决的问题,如下:
4.2 准备工作
4.2.1 工作量评估
首先我们说下解耦时需要做的准备工作。因为这些工作是解耦拆分的基础。有两点需要做,如下所示:
首先说明一下,并不是我喜欢打五颗星。确实是这部分工作量比较大~~~
4.2.2 公共资源,model,utils等的拆分
4.2.2.1 耦合情况示例
第一点是公共资源,model,utils等的拆分。这些事情虽然不用考虑太多事情,但是很繁琐。在做模块化的时候,这个地方耗费了不少时间。很大一部分原因是,之前的猫眼历史版本代码不够规范,对代码耦合这些事情不够敏感。举几个例子吧:
- 我们之前的utils基本都写在一个类MovieUtils里面了。这个类就像大染缸。什么都向里面放。在传入的参数方面也不够规范,甚至MaoyanBaseFragment这种业务代码都作为参数传入。导致这个东西及其难拆。
- utils的方法不传context。前人写的时候图省事,在项目中统一加了一个静态的context,导致几乎所有的utils都没有传入context,这样的后果是这些工具方法直接以来宿主app。
- 之前写的common view 不够独立。既然想写common view,那么就尽量让这个view能够独立,不要耦合其他第三方库,尽量使用android 官方库。
4.2.2.2 资源拆分经验
对于资源的拆分,其实是非常繁琐的。尤其是如果string, color,dimens这些资源分布在了代码的各个角落,一个个去拆,非常繁琐。其实大可不必这么做。因为android在build时,会进行资源的merge和shrink。res/values下的各个文件(styles.xml需注意)最后都只会把用到的放到intermediate/res/merged/../valus.xml,无用的都会自动删除。并且最后我们可以使用lint来自动删除。所以这个地方不要耗费太多的时间。刚才说了,styles.xml需注意。那么需要注意什么呢?这个东西是这么写的:
我们在写属性名字的时候,一定要加上前缀限定词。如果不加的话,你这个lib打包成aar后给其他app使用的时候,会出现属性名名字重复的冲突,为什么呢?因为BezelImageView这个名字根本不会出现在intermediate/res/merged/../valus.xml里, 所以不要以为这是属性的限定词!
4.2.3 集成式vs组合式(选做)
前面说了资源utils等的拆分,那么接下来说下第二点,基类的处理。我们看到电影详情页是建立在一堆的基类之上。每一层的基类都做了一些事情。(当时这么写是为了页面的快速开发)如果我们想将电影详情页独立出来,就需要把这些基类打包成一个aar,下沉到基础库,供所有页面使用。但是我们以前的这种基类耦合了很多猫眼的东西,像下拉刷新,页面状态什么的都是写死的,并且如果我需要写个页面,我就需要继承那么一大堆的fragment。当然这种改一改也可以移植。但对以后的代码迭代肯定是不好的(修改,添加业务)。因为它灵活性差。比如如果点评app上需要猫眼某个页面的一部分而不是整个页面,原来那种改起来就不是很方便。我希望的方式是这些页面都是view,而不是fragment。并且也不是这种继承方式,而是组合方式。即如果我想要一个带下拉刷新的列表view,那么我直接build出来这么一个view,需要什么配置就set进来,它就能够使用。这个view你可以放到任何一个view,fragment中和其他view进行组合。即:
这个MovieRcPagePullToRefreshStatusBlock是一个view,可以用在任何页面进行view的组合。
4.2.3.1 组件的插拔式,组合式设计
其实我的做法更大胆,或者更“懒”一些。我希望我这个MovieRcPagePullToRefreshStatusBlock build成功以后,放到页面中就能显示运行,自动加载数据了。就像小时候玩积木那样,组件与组件都是插拔即用式的。至于这个block是怎么加载数据的,使用者无需关系。使用者只需要拿到这个block,然后build时set进去需要的东西。放到页面中就可以运行了。可以参考这个作为示例:
我们可以看到这个页面,我只是build出来了两个view,然后放到这个page中,并没有关心数据加载什么的,数据加载是在这个block内部完成的。然后这个page就像前面说的那样,放到某个app的activty中就可以运行了。插拔式、傻瓜式的思想,可能我这个人比较“懒”~~
那这种架构怎么实现呢?,接下来粗略的看看这种框架大体的实现思路吧(具体的可以看下我写的这一篇android 官方mvp框架优化:lifecycle-mvp,像前端那样组合式写页面)。其实这个框架大体也是mvp框架的思想,不过同时解决了业务场景的一些问题,比如,生命周期,移植性,沟通成本,使用方便与否等。既然要说下实现思路,那么从开始说起,对自己是个总结,对读者们可能有有些许帮助。先说下mvp框架的含义:
4.2.3.2 mvp框架含义
mvp框架总体来说适用于android的场景需求。m代表model,提供数据;v即view,提供的是供presenter调用的view相关的方法;p 即presenter,提供的是页面里触发动作的逻辑方法。
4.2.3.3 官方mvp框架的缺点
mvp框架网上有很多,官方也推荐了mvp框架。和一般的区别是:用contract来承载view和presenter的接口定义。用fragment来实现view接口。不过官方使用fragment来实现view,也有它的无奈。为什么说它无奈呢?对于view层的接口,使用fragment来进行实现,主要是因为fragment有生命周期。但fragment太笨重了。试想一下,我有一个页面,里面有四五块内容。为了以后的各块内容的移动、去除、移植更方面,我希望每一块内容都做成mvp形式,块与块之间不耦合。那么官方的这个mvp框架就不适用了。因为你不可能在一个页面写5个fragment把。android的activity中不建议写那么多的fragment,fragment典型的使用场景是ViewPager。
4.2.3.4 常规变通
那么变通一下,5块内容的view层,不再用fragment实现,而只是一个个普通的view,每个view监听事件的响应还是在view中进行(调用各自的presenter方法)。而对于整个页面的初始化加载或者下拉刷新加载,这5块内容共用一个fragment,在这个fragment的onStart()和下拉刷新的监听回调中加载5块内容对应的presenter的方法。然后在fragment的onCreateView()中把5块内容的view填充进来。5块内容之间可能还需要通信,数据交流,这些借助presenter在fragment中进行。
4.2.3.5 带生命周期的mvp:lifecycle-mvp
上面那么做完全没有问题,并且上面那种做法也存在于我们的项目中。但通过几个版本的迭代,我发现了一些问题:presenter太乱,太散。fragment需要持有所有的presenter,在onStart()时load()数据。各自的view也需要持有各自的presenter。并且view和presenter之间需要互相set()。你还需要在activty或者fragment的onDetroy()方法中管理presenter。总体让人觉得很乱。尤其是如果你的组件需要被别人使用,或组件用需要用到其他app时,其他人拿到你的组件,你要关心两个东西view和presenter,他得知道这两个东西里面的方法,并且他需要在activty/fragment的生命周期中关联他们并调用一些方法。嗯。这个过程肯定存在的大量沟通成本~
所以才想到了前面讲的那种build方式来实例化组件,然后用pager组合组件。特点是(具体可以看下android 官方mvp框架优化:lifecycle-mvp,像前端那样组合式写页面):
- 使用lifecycle-component这个组件提供生命周期。
- presenter被view层内部持有,不向外暴露。
- build创建view实例时,提供TypeFactory,用于业务的扩展。
- 业务代码分层。
用这种mvp的变种框架改写项目的原代/写新业务,就可以使页面更容易移植、拓展,页面内的模块也可以移动改变。当然,这种框架是建立在我们的业务基础之上,框架还是需要因项目而已,没有最好,只有更适合~
4.3 接口的抽离
前面已经阐述了模块化的准备工作,接下来我们需要做什么呢?根据前面介绍的原项目耦合结构,我们知道我们以前的项目直接依赖了各种service的具体实现。我们接下来要做的是把这些具体service实现用接口来剥离:
4.3.1 使用servieloader进行解耦---非显式的调用服务实现类
4.3.1.1 官方serviceloader
从图上可以看到,我们的实现类都被对应的接口所代替。但就这一步本身来说,并没有太大的难度:找到以前服务调用的地方,然后换成接口调用。无非就是有些服务用的比较多,换起来繁琐一些。但我们现在需要考虑一个问题:服务的实现,我们怎么给?首先想到的是,我们留一个参数来传入。但这种方式会导致将来使用lib的时候,沟通成本太大:你需要告诉别人哪里哪里我需要传入什么类型的参数。不然你这个lib就没法运行。我不希望别人在使用你lib的时候,还需要去内部查下你的代码是什么,应该怎么传参数。我希望 别人在使用的时候,对他们来说,lib是尽量透明的。不需要知道lib内部写的是什么,只需要在外部配置一个txt的文本就可以运行lib!那应该怎么做呢?
其实java很早就提供了这种类似的功能:在资源目录META-INF/services中放置提供者配置文件,然后在app运行时,遇到Serviceloader.load(XxxInterface.class)时,会到META-INF/services的配置文件中寻找这个接口对应的实现类全路径名,然后使用反射去生成一个无参的实例。
我们大体的使用方式也是基于java提供的这种功能:
4.3.1.2 对官方serviceloader改造
4.3.1.2.1 官方serviceloader缺陷
从前面的阐述来看,java官方提供的serviceloader至少有三个地方需要改进。
- serviceloader没有缓存功能。因为对于服务来说,大部分我们都需要使用单例模式,而不会频繁的生成新的实例。
- serviceloader使用无参的构造方法进行构建实例。这点不用多说,肯定需要改进。谁的服务构建的时候不需要传入参数呢?
- serviceloader没有预检查等问题。因为在运行时,需要在配置文件中去寻找接口对应的实现类名。那么肯定会遇到接口名写错了,类名写错了,配置方式写错了,找不到接口实现类等,这些错误在编译器是发现不了的。同时,使用serviceloader是一种非显式的调用服务实现类方式,如果不在proguard中保护这些实现类,那么肯定会被shrink掉。除了proguard问题外,配置文件写在资源目录META-INF/services下对于一些手机(三星)也有兼容问题。最后,考虑servic配置文件手动注册的缺点,serviceloader需要提供自动注册功能。
对于上面三种情况的处理,第一点很容易解决。提供一个缓存就可以了,不多说。
4.3.1.2.2 serviceloader构造实例
第二点我们是这么解决的:我们让所有使用serviceloader加载服务的接口都实现Iprovider接口。Iprovider接口提供了一个init(Context context)方法。这样所有的服务实现类都需要实现init(Context)方法,在里面做原构造方法里需要做的初始化逻辑。因此,我们在调用serviceloader加载服务的时候就类似这样:
ImageLoader imageLoader = MovieServiceLoader.getService(context, ImageLoader.class);
在MovieServiceLoader内部,生成的实例会调用一下init(Context)方法。这样我们就解决了第二个问题。这里可能也会有一些朋友有些疑问(比如和美团平台的童鞋就此事讨论过):为什么只传入context参数。如果一个服务实现类还需要其他参数怎么办?就我们的服务和而言,我认为只需要传入context,基本上通过context能够获得android绝大部分的参数。并且对于服务来说,既然它是一种服务,按理说不会依赖你项目一个具体的一个组件。所以我认为传入context就够了,而不是传入不定格式的object参数:
MovieServiceLoader.getService(Object... params, ImageLoader.class);
这种方式固然能够解决所有问题。但是这种设计的思想已经违背了接口和实现的隔离概念。比如说,我想使用图片加载服务,按理说我只需要调用一下
imageLoader = MovieServiceLoader.getService(context, ImageLoader.class);
就ok了,你这个具体的服务是Picasso还是glide别让我知道,我也不想知道。如果使用第二种方案,我难道还要知道你这个具体服务需要哪些参数,然后传入吗?这感觉太不友好了。使用Iprovider还有一个好处,那就是我们只需要在MovieServiceLoader仓库的proguard中添加:
就可以了。其他的地方在使用或新建服务接口时,都不用再考虑proguard问题了。
4.3.1.2.3 serviceloader预处理---gradle插件
第三个需要解决的问题是serviceloader的预检查等。这个解决方法就是写一个gradle插件。插件的大体流程是
- 我们在build的某个阶段拿到所有编译后的class文件(夹)和jar包。
- 使用javassit确定哪些类被@autoService修饰,配置文件中如果不存在,在其添加。
- 查看serviceConfig配置文件里面的格式是不是正确。
- 通过javassit来确定serviceConfig配置文件里面的类是不是在项目中存在,接口类是不是实现了Iprovider接口。
4.3.1.2.4 需要用到的知识:build流程,javassit,groovy
本来这里不想说太多东西,但是考虑到这三样很多读者可能不熟悉。直接去网上google这三方面的东西,单就这些东西,可能还需要学上一学。那么我还是把我的一些经验写上(为了切题,就不详细展开了),读者可以参考参考,些许能够事半功倍。
因为你需要拿到编译后的class文件和jar包,你需要知道build的大体流程,各个task的输入输出是什么,是以文件夹的形式还是jar包的形式。
比如说拿所有class的时机,可以在assembleXxx这个task时(dex task已经完成了),从dex task的输入文件夹/jar包中拿到所有的class。同理javac task的输入也可以。但javac task的输出就不可以,因为javac task输出的intermediate/class文件夹只包含项目中的class文件,不含有aar对应的intermediate/exploded-aar文件里面的class文件。当然transform也是一种实现方式。transform的输入,输出文件路径已经给好了,输入的class为所有的class。
除build流程之外,你还可能使用groovy来写插件逻辑。不过如果你实在不想用groovy,那么也可以用java,两者兼容,只是groovy的很多特性像循环等就没法用了。这里有个小经验:写goovy,ide不能很好提示错误,比如你使用了一个变量或一个方法,如果方法用错了,变量没定义。也不会给你提示找不到。所以最好还是使用先写到.java里面,然后再移动到.groovy里面吧。
最后你还需要知道javassit的一些知识,这个是处理class文件的工具。很强大,和java很像,大部分的使用都会落脚到ctClass的使用。所以这个类最好熟悉。这里有个小经验:有时候需要ctClass->class的转化,记得使用静态变量储存这个class对象,不然会报 classloader多次加载同一个路径的异常。
ok,使用serviceloader来进行解耦的原理,改进,好处已经说完了。
4.3.2 serviceloader解耦 vs 路由方式解耦
网上有关模块化的博客,大部分使用的是路由的方式解耦。路由的方式解耦是怎么一回事呢?
4.3.2.1 路由方式解耦阐述
我们看下大体的路由框架图 ,截取于
Android组件化之通信:
那么这个路由框架是怎么工作的呢?这里的action是一个服务,provider是一个map集合,盛放一个lib里的所有(action名字:action实例)键值对。在宿主app中注册各个lib的provider。这样module A请求moduleB的服务时,通过(代码来源):
即通过提供provider的名字,action的名字,参数名,值,到注册的map中寻找对应的action实例,然后调用其对应的方法。核心就是使用字符串来匹配对应的实例进行解耦。
4.3.2.1.1 路由方式解耦优点
这种方式的最大好处是,新建一个服务时,不需要写接口,所有的都用字符串来进行标志,进行匹配,两个model之间不需要耦合任何东西,甚至接口声明都不需要耦合。如果一个lib中有很多需要被外界调用的服务,并且调用的次数不多,或者我不仅仅对服务解耦,那么用这种路由的方式很好,因为不用写接口了。
4.3.2.1.2 路由方式在服务解耦方面不适用性的讨论
但为什么没选择这种解耦方式呢?因为这种方式,对于android整体的服务解耦来说,我还是提出了如下的顾虑(仅代表自己的观点,可能比较粗鄙,并不是说人家的项目不够优秀):
- 对于大面积的解耦,肯定大部分是app界别的服务进行解耦。特点是大量使用,这时候我写几个接口,下沉到base库,无伤大雅。这样我在使用的时候,serviceloader好处就突出来了:使用服务的时候,我不需要关心实现类的类名,包名是什么,需要传入什么参数,调用的方法的名字是什么。如果使用路由方式接口,我需要关心的事情就多了,如果我需要关心这么多东西,它就不应该叫服务了。如果另一个lib在你不知情的情况下改了名字怎么办?并且在代码移植到其他app或独立运行时,配置方式也不够友好。serviceloader只需要写个配置txt文件放在apk中即可,并且每一个lib的服务写到自己的serviceCinfig即可,不需要宿主app关心。使用路由方式,即使action可以自动注册,也需要在application处理一些注册的事情。
- 路由这种服务框架和serviceloader,本质来说,并不能进行真正意义上的模块间的通信。说的通俗点,路由框架能做的是:b lib可以在不依赖a lib项目的情况下,b可以new 出来a中一个类的实例(或提前new好),然后调用那个实例的方法。这并非通信,只是能够调用其他仓库的方法。而通信指的是监听状态,回调。serviceloader同样也做不到真正意义上的通信。模块间通信只能通过非显式的监听机制才能进行,比如eventbus,广播,contentprovider等来进行。为什么要说这一点呢?因为我看到很多模块化的博客都在说使用路由框架进行模块间通信。但就前面提到的这种路由框架,确实做不到真正意义上的模块间通信。
ok,serviceloader解耦 vs 路由方式解耦就到这里。
4.4 解耦方面的其他工作
4.4.1 工作评估
前面很大一个篇幅都在讲使用serviceloader进行服务的解耦。那么除了这个,还需要做什么?这里我先大体总结一下,再逐个阐述:
4.4.2 服务实现的抽离
第一点的后半句需要注意一下:如果你希望所有模块都能够独立打包运行,那么需要把所有的服务实现也抽离出来。如果不想独立运行,只是想进行解耦,那么还是留在宿主app中即可。虽然说这么一句话很轻松。但是抽离一个服务实现,真正实施起来却需要花费很多的时间,因为一个服务可能耦合了很多的东西,不留神不好拆。这一点读者们心里要先有个数。 不过能抽离就尽量抽离吧,不只是lib的独立运行。对之后服务的替换也有很大的好处。比如网络加载库,以前使用的是retrofi+okhttp,后来升级成了retrofit+长连接。替换的时候只是在服务配置文件中改一句话的事情。如果打算抽离,要注意接口的定义,不要耦合具体某个库的类,考虑要全面,设计要合理。比如INet库,接口定义为:
public interface INetService extends IProvider {
T create(final Class service, String getdataPolicy, String cacheTime);
}
虽然retrofit是一个很棒的库,但接口也没有耦合这个库。说不定哪一天就替换了。
4.4.3 数据库的抽离
第二点说起来很痛苦,数据库的抽离真的是很麻烦。不知道在哪个版本开始,猫眼耦合了greendao。这个数据库本身来说挺优秀的,但是架不住它太大!如果我想把一个lib给别人用,难道我这个lib还得耦合一个大的第三方数据库?!!因为之前没有考虑过模块化,所以基本所有的网络数据,敏感数据等都进行了grrendao的保存。所以解耦的时候每每看到daossion,我都是虎躯一震。网络数据使用文件存储且对业务代码透明。敏感数据使用数据库存储,但用接口隔离,并且数据库建议使用官方的数据库sqlite或者room。
4.4.4 和butterKnife说再见
第三点的意思是如果你想将业务代码独立模块化,那么就得跟像butterknife框架的view注入功能说拜拜了。因为android adt14开始,library的R资源不再是final类型的了,所以在library中你不能使用R.id.xx,需要使用findViewById()来代替;也不能使用switch(R.id.xx),需要使用if..else来代替。
第四点是第一点的后续工作。不存在多少工作量。
4.4.5 页面跳转
4.4.5.1 页面跳转需要做的事情
页面跳转也是app中需要重视的一个事情,因为它是模块化的门户,涉及到页面与页面,其他app、i版到页面之间的通信问题。虽然看起来简单,但如果设计不合理,那么模块化入口的代码优雅度,crash数量,页面降级,运营协作等方面都会受到影响。
对于页面间的跳转,我们的一般做法:
-
如果这个类页面没有隐式跳转功能:
- 那么直接在其他页面首先
获取intent(getContext(),TargetActivity.class),然后intent添加参数。
最后starActivity(getContext(), intent)。 - 在目标activty 的onCreate()里面getIntent().getString(xx_key,defaultValue)等获取参数;
- 如果xx_key对应的value不合法或者解析错误,比如movieId=0,或者等于“”。那么应该跳转到一个其他页面或者跳转失败。
- 那么直接在其他页面首先
-
如果这个页面配置了隐式跳转功能:
- 那么在其他页面你首先得创建一个createXxxActivityIntent()的utils方法,在里面传入落地页的path,参数key,参数value。
- 在manifest中声明。
- 在目标activty 的onCreate()里面getIntent().getData().parseBoolean(xx_key,defaultValue)...等获取参数
- 如果xx_key对应的value不合法或者解析错误,比如movieId=0,或者等于“”。那么应该跳转到一个其他页面或者跳转失败。
4.4.5.2 android原生页面跳转存在的问题
下面说下这种使用原生页面跳转存在的问题~
- 在获取参数的时候,需要写一大推的intent.get(xx),如果这个页面既含有隐式跳转,又含有显示跳转, 那么肯定上面那个过程都需要,这样在onCreate()里面就会非常的乱。要进行if else
- 如果想进行隐式跳转,那么都需要在manifest进行注册intent-filter。一是麻烦,二是我需要在另外一个地方去配置某一个activity的东西,管理不方便。
- 需要另外写一个utils获取隐式intent。
- 没有降级策略,如果运营配错了,那么只能到错误页面,而无法进行一个补救措施,比如进入i版页面。
- 开发人员或者后台配置错误参数的时候,我们需要写兜底逻辑。每一个页面解析都需要写一段相同的逻辑。
- 如果一个页面需要登录用户才可以打开的权限,那么我们经常会写if(isLogin()){//跳转页面}else{//跳转到登录页面} ,每次操作都要写这些个相同的逻辑。
如果觉得在这方面没那么多要求,针对页面间的跳转,为了不耦合其他的模块的类,所有页面都可以采用隐式跳转机制来进行。这基本已经可以满足情况了。但我这里还是想说下阿里推出的开源框架Arouter。其具有拦截功能,这样跳转失败可以有降级处理(比如呈现i版页面),让页面具有登录用户可打开权限;获取参数方式统一等。还是挺不错的。基本解决了上面所面临的问题。具体就不展开了,具体可以看开源最佳实践:Android平台页面路由框架ARouter
4.5 模块间/页面间通信
4.5.0 使用ViewModel来进行页面间数据共享
这一段是新加的内容。我觉得放到这里比较合适。ViewModel是google新推出的lifecycle-component中的类,官方文档中阐述使用ViewModel可以解决页面旋转等配置改变时数据保存的问题。我思考了下,觉得它在解耦页面内数据共享的问题也能发挥作用。
举一个我以前遇到过的例子:一个页面做完了,pm找我做页面的埋点。埋点需要页面的movieId信息,但是需要埋点的那个block中并没有movieId。并且我这个block层级很深。如果想拿到movieId,我需要从activity页面层级一层层传到我这个block中,免不了中间层级的耦合和方法的创建。当时觉得这件事真是让人头大。那时候多么需要有个像事件监听形式的eventbus那样的东西,我只需要把数据放到bus里面,然后这个页面的任何一个地方都能很方便的获取。总结一下:直白点说就是页面block/fragment之间需要使用对方的数据/view时,无需之间硬性的引用,只需要activity的context参数就可以获取对方的数据/view,从而进行数据交流、view访问。而页面的context是系统类型且是很容易获取的,并不存在耦合。
具体使用可以参考我之前写的一篇文章使用ViewModel共享页面内的数据:ActivityDataBus
4.5.1 为什么要去掉eventbus,使用广播
如果已经到了这一步,那么大体上一个页面已经抽离出来了,剩下的是与其他模块、其他页面间的互动了。
前面说了serviceloader和路由方式都没办法做这些事情。我们首先想到的是使用eventbus来做这些事情。使用eventbus的前提是,需要定义一些Event事件。比如:
但如果你将业务代码各自模块化之后,就有一个尴尬的问题摆在面前:Event事件放在哪里?因为很多库都需要收听这个Event事件,所以只能将Event下沉到基础库。这样导致的结果是基础库越来越大,还无法拆分。关于这点
微信Android模块化架构重构实践也提到了这件事情,并且自创性的使用了一种叫“.api”化的方式来解决这件事情。原理是在编译期将公用接口下沉到基础库,供其他module使用,而这段代码的维护仍然放到非基础库中。这种base库不会膨胀,代码维护的责任制更明确,确定挺不错。可惜最近没有那么多时间来写这个gradle插件了。不知道哪个读者有时间和兴趣可以实现这个插件。意义还是很大的,基础库的代码不会越来越膨胀。eventbus除了使基础库膨胀之外,还有一个问题是,不能进行app间的进程通信。
我们使用广播来取代eventbus。android推出的LocalBroadcast实现机制简单来说是looper-handler并维护一个全局的map。性能上和eventbus类似,使用字符串而不是Event model来匹配事件。我们如果使用
一个接口来包装BroadcastManager,那么我们在app内部可以使用域内广播进行,对于模块化后的lib,我们可以使用域外广播来进行app间的通信。
4.5.2 不要乱发广播
如果你项目中大量的使用eventbus,那么你会看到一个类中有大量的onEventMainThread()方法,写起来很爽,阅读起来很痛苦。如果项目中发送这个Event的地方非常多,接收这个Event的地方也很多。在进行代码拆分时,你都不敢轻举妄动,生怕哪些事件没有被接收。广播和eventbus类似,如果项目中存在同一事件的大量发送和接收,那么项目的可读性和可维护性就会变得相当差。这种情况在敏感数据的同步问题上尤为突出:
其实对于敏感数据的同步,不需要发送广播或eventbus来进行同步。可以借助数据库将想看数据本地化来完成同步。大体的思想就是我们从网络中获取的数据都同步到数据库。在进行敏感数据填充view时,采用的数据都来自数据库。在页面返回时,如果页面不触发填充敏感数据view的逻辑,那么在onResume()手动调用,即:
那么模块间/页面间通信大体的就讲完了,这里需要做的工作不多:
4.6 lib独立运行
4.6.1 为什么需要与宿主app进程通信
到这一步,一个业务模块既可以作为library放在宿主app中,也可以作为application独立运行了。作为library很容易理解,和文章前面的问答模块阐述的一样,做宿主app中添加几个activty的壳子,然后添加上lib中的page,然后在manifest中注册即可,即:
当然如果还需要做一些actionBar的交互,需要在宿主activty中写入相应的逻辑。整个app的框架图如:
当业务lib需要调试时,我们需要让这个lib独立运行,就如同文章前面的问答业务模块demo所示。那这时候就有一个问题,我们lib独立运行时,账户的数据从何而来,和app相关的地理位置,城市等等这些数据怎么得到?读者可能会说这些不是服务吗?服务的话,不应该和网络加载,图片加载的服务一样使用serviceloader加载吗?按道理讲是这样的,但账户等一些信息的服务实现类并不是那么容易从宿主app中抽离出来,因为那么服务实现类需要application中进行初始化,还要考虑很多其他东西。所以真实的账户信息并不那么容易通过以前的那种方式获取,那怎么办呢?最简单的办法是制作假数据,比如造一个我自己账户的信息,作为服务实现类使用。但这样的话,账户信息只能是一个人的,对账户信息的修改不可行,账户也不能退出登录。所以还得想新的办法。
4.6.2 与宿主app进程通信过程
最后发现如果我们独立运行的lib能够监听宿主app的账户,位置,城市,登录类型,设备等信息并能够进行同步,那么独立运行的lib中的这些信息就都是真实信息了,并且是动态的。当宿主app退出登录,lib中也是无登录状态。具体的操作是:
-
在宿主app中,我们提供一些contentProvider,各方法提供的内容就是宿主app真实的的账户等数据。当对宿主app账户等信息改变时,通知contentProvider的监听者,比如:
public void onEventMainThread(LoginEvent loginEvent) { getContext().getContentResolver().notifyChange(Uri.parse("content://com.maoyan.android.runenv/loginsession"),null); }
-
在独立app中,其扮演contentProvider的监听者:
mContentResolver.registerContentObserver(Uri.parse("content://com.maoyan.android.runenv/devicesession"), false, new ContentObserver(null) { @Override public void onChange(boolean selfChange) { super.onChange(selfChange); reloadEnviroment(); } });
这样的话,lib中账户等数据就和宿主app的数据保持一致了。我们使用服务接口包一层,这样使用方式和之前的服务使用方式就一致了。
大体的示意图如:
当宿主app退出登录,lib中也是无登录状态,我们看下demo:
最后,按照惯例,当一个模块要独立运行时,需要做的事情评估:
5 最后的话
整个流程终于结束了,希望读者看完后,对模块化有个整体的认识,对每一步需要做什么,耗时多少都有个大致的了解。进行模块化并不是
为了炫技,表明自己多厉害。如果只是这样,那大可不必这么做。因为模块化是一个繁琐,枯燥,耗费时间长,你做了大量的工作,但是在
表面功能上,老板们可能看不到。还不如花一点时间,引入一个第三方库看着花哨。很大一部分工作量是为以前欠设计的代码逻辑买单。我做这件事件也是为了业务服务,因为猫眼电影需要服务的客户端不少。所示做业务解耦,业务进行模块化是必然的事情。通过模块化后,可以很方便的将代码移植到其他端,app内页面的调整也变得简单。
最后的最后,在整个模块化的过程中,有一些经验感悟可以分享给大家,道理都很简单,更重要的是落实:
本文在开源项目:https://github.com/Android-Alvin/Android-LearningNotes 中已收录,里面包含了Android组件化最全开源项目(美团App、得到App、支付宝App、微信App、蘑菇街App、有赞APP...)等,资源持续更新中...
转载请注明出处:
美团猫眼电影android模块化实战--可能是最详细的模块化实战
地址:http://www.jianshu.com/p/d372cc6802e5