当前位置: 首页 > 科技观察

总结Android模块化的一些知识点

时间:2023-03-15 20:10:35 科技观察

我有一些关于Android模块化的说法。我不知道该不该说。最近,公司的一个项目采用了模块化设计。我参与了一个小模块的开发,但是整体设计不是我设计的。开发历时半年多。在这里记录下我的想法。为什么模块化场景需要模块化?当一个APP的用户量增加,业务量增加时,会有很多开发工程师参与同一个项目,人员数量也会增加。原来的小团队开发方式已经不适合了。原来的代码现在需要多人维护,每个人的代码质量都不一样。也很难进行代码审查,也容易出现代码冲突。同时,随着业务的增多,代码越来越复杂,各个模块之间的代码耦合也越来越严重。解耦的问题亟待解决,编译时间会越来越长。人员增多,每个业务组件单独实现一套,导致同一个app的UI风格和技术实现不同,团队的技术无法沉淀。架构演进项目架构一开始采用的是MVP模型,这也是近年来非常流行的一种架构方式。以下是该项目的原始设计。随着业务的增加,我们加入了Domain的概念。Domain从Data中获取数据。数据可能是Net,File,Cache,各种IO等等,然后项目架构就变成了这样。然后随着人员的增加,各种基础组件越来越多,业务也非常复杂。业务和业务还是强耦合的,就变成了这个样子。使用模块化技术后,架构变成了这样。技术要点这里简单介绍一下Android项目实现模块化需要用到的技术和技术难点。在librarymodule开始模块化之前,需要将每一个业务单独抽取成一个AndroidLibraryModule。这是AndroidStudio自带的一个功能,可以将那些依赖较少的作为基础组件提取到一个单独的模块中。如图所示,我将每个模块分离到一个单独的项目中。在主工程中使用gradle添加代码依赖。//commoncompileproject(':ModuleBase')compileproject(':ModuleComponent')compileproject(':ModuleService')//bizcompileproject(':ModuleUser')compileproject(':ModuleOrder')compileproject(':ModuleShopping')库模块开发问题将代码提取到各个库模块中时会遇到各种问题。最常见的问题是R文件问题。在Android开发中,每个资源文件都放在res目录下。在编译过程中,会生成R.java文件。R文件中包含每个资源文件对应的id。这个id是一个静态常量,但是在LibraryModule中,这个id不是静态常量,所以在开发的时候要避免这样的问题。举个常见的例子,同一个方法处理多个view的点击事件,有时使用switch(view.getId())然后使用caseR.id.btnLogin进行判断。这时候就会出现问题,因为id不是常量常量,那么这个方法就不能用了。同样,在开发过程中,使用最多的第三方库是ButterKnife,目前没有ButterKnife。使用ButterKnife时,需要使用注解配置一个id来找到对应的view,或者绑定对应的事件处理。但是注解中各个字段的赋值也需要静态常量,所以不能使用ButterKnife。有几种解决方法:1.新建一个Gradle插件,生成R2.java文件。这个文件中的每个id都是一个静态常量,所以可以正常使用。2.使用Android系统提供的最原始的方法,直接使用findViewById和setOnClickListener方法。3.设置项目支持Databinding,然后使用Binding中的对象,但是会增加方法的数量。同时Databinding也会有编译问题和学习成本,不过这些都是小问题,个人认为问题不大。以上为主流方案,个人推荐优先级为3>2>1。分离模块后,每个人可以分别对相应的模块进行分组,但是会出现资源冲突。我个人的建议是给每个模块的资源名加上前缀。比如用户模块中登录界面的布局是activity_login.xml,那么可以写成us_activity_login.xml。这避免了资源冲突问题。同时Gradle还提供了一个字段resourcePrefix来保证每一个资源的名称都是正确的。具体使用请参考官方文档。依赖管理Library模块完成后,代码基本就很清晰了,和我们上面最终的架构很像,有了最基本的骨架,但是还是没有完成,因为还有多人在操作同一个git仓库,各个开发伙伴还是需要对同一个仓库进行各种fork和pr。有了代码的划分,但是主工程app依赖比较多,如果修改了lib里面的代码,编译时间会很恐怖,粗略算一下,本来在同一个模块的时候,编译时间大概需要2-3分钟,但分开后要5-6分钟左右,绝对不能忍受。上面的***问题可以通过为每个子模块使用单独的git仓库来解决,这样大家只需要关注自己需要的git仓库,主仓库使用gitsubmodule来依赖每个子模块模块。但是这样还是不能解决编译时间过长的问题。我们还将每个模块单独打包。每个子模块开发完成后发布到maven仓库,然后在主工程中使用依赖版本。比如迭代某个版本,这个版本叫1.0.0,那么各个模块的版本也叫同一个版本。版本测试发布后,标记每个模块对应的版本,然后就很清楚的了解了每个模块的代码分布。gradle依赖如下。//commoncompile'c??n.mycommons:base:1.0.0'compile'c??n.mycommons:component:1.0.0'compile'c??n.mycommons:service:1.0.0'//bizcompile'c??n.mycommons:user:1.0.0'compile'c??n.mycommons:order:1.0.0'compile'c??n.mycommons:shopping:1.0.0'可能有人会问,既然每个模块都是单独开发的,如果做开发联调,不用担心,这个问题暂且保留,以后再讨论这个问题。数据通信当一个大项目拆分成几个小项目时,调用的姿势发生了一点变化。这里我总结了几种App各个模块之间数据通信的方式。页面跳转,比如在下单页面下单,需要判断用户是否登录,如果没有登录,则需要跳转到登录界面。主动获取数据。比如下单时,用户已经登录,需要传递用户的基本信息才能下单。被动获取数据。比如切换用户时,有时需要更新数据,比如订单页面,需要清空原用户的购物车数据。让我们看一下App的结构。第一个问题,原来的方式可以直接指定某个页面的ActivityClass,然后通过intent跳转,但是在新的架构中,由于购物模块不直接依赖于用户,所以不能使用原来的方式一是跳转,我们的解决方案是使用Router路由跳转。第二个问题是原来的方法有一个特殊的业务好处,比如UserManager,可以直接调用,也可以因为依赖变化而无法调用。解决方案是将所有需要的操作定义为接口并放在服务中。第三个问题,原来的方法可以提供事件变化的回调接口。当我需要监听一个事件时,只需要设置一个回调即可。页面路由跳转分析如上,原方法代码如下。Intentintent=newIntent(this,UserActivity.class);startActivity(intent);但是使用Router之后,调用方式发生了变化。RouterHelper.dispatch(getContext(),"app://user");具体原理是什么,很简单,做个简单的映射匹配,把"app://user"和UserActivity.class配对,具体就是定义一个Map,key是对应的Router字符,value是活动的类。跳转时从地图中获取对应的ActivityClass,然后使用原来的方法。可能有人会问,我想给别的页面传参数怎么办?我们可以直接在路由器后面添加参数。如果是复杂的对象,我们可以将对象序列化成json字符串,然后将相应页面的response传过去。以序列化的方式获取对应的对象。例如:RouterHelper.dispatch(getContext(),"app://user?id=123&obj={"name":"admin"}");注意:上面router中的json字符串需要进行url编码,否则会出现问题,这里只是举例。除了用Router跳转,我也想过。可以参考Retrofit方法直接定义跳转Java接口。如果需要传递额外的参数,以函数参数的形式定义。这个Java接口没有实现类,可以使用动态代理的方法,然后next的方法就和使用Router一样了。那么这两种方式各有什么优缺点呢?Router方法:有点:没有什么技术难点,简单易用,直接使用字符串定义跳转,向下兼容性好缺点:因为使用的是字符串配置,如果是输入字符的话,很难发现bug同时,也很难知道某个参数的含义。像Retrofit方法:因为是Java接口定义,所以很容易找到对应的跳转方法,参数定义也很清楚,可以直接写在接口定义中,方便引用。也因为是Java接口定义,如果需要扩展参数,只能重新定义新的方法,这样就会出现多个方法重载。如果修改了原来的接口,对应的原调用者也必须修改response,比较麻烦。以上是两种实现方式。如果有相应的同学想实现模块化,可以根据实际情况进行选择。上面分析了Interface和Implement。如果我们需要获取某个业务的数据,需要分别定义接口和实现类,然后在获取的时候通过反射实例化对象。下面是一个简单的代码示例接口定义}@NonNullpublicstaticIModuleConfiggetIModuleConfig(){returngetAppContext().getModuleConfig();}@NullablepublicstaticTgetInstance(ClasstClass){IModuleConfigconfig=getIModuleConfig();ClassimplementClass=config.getServiceImplementClass(tClass);if(implementClass!=null){try{returnimplementClass.newInstance();}catch(Exceptione){e.printStackTrace();}}returnnull;}}实际上调用了IUserServiceuserService=InjectHelper.getInstance(IUserService.class);if(userService!=null){Toast.makeText(getContext(),userService.getUserName(),Toast.LENGTH_SHORT).show();}在这个例子中,每次调用都使用反射来生成e一个新的对象,在实际应用中可能会结合IoC工具使用,比如Dagger2.EventBus对于上面第三个问题,原来的设计方法也是可以的,只需要连接回调即可端口定义在相应的服务接口中,然后调用者就可以使用了。但是,我建议使用另一种方法——EventBus。EventBus也使用观察者模式来监听事件,这是一种更优雅的回调实现方式。优点:不需要定义很多回调接口,只需要定义事件Class,然后利用Claas的唯一性进行事件匹配。缺点:需要定义很多额外的类来表示事件,还需要注意EventBus的生命周期。当不需要使用事件时,需要取消事件绑定,否则容易造成内存泄漏。