网易友品组件化演进

项目背景

主站业务经历了长期的迭代维护,业务的增长同时带来每个版本业务量繁重,迭代周期很快。同时团队也在不断的扩张,对应拆分了组内不同的业务线对接不同业务线的需求,最初的Android客户端单一的设计架构已经逐渐不满足快速的业务开发需求。历经组内讨论开始对项目整理进行组件化的迁移,通过组件化的方式满足不同业务线业务开发的稳定性,是迭代开发更灵活,组内协作开发效率得到提升。同时又有新的项目立项需要投入开发,一方面可以通过新项目实践和推进组件化的迁移,另一方面也可以通过组件化拆分后的技术组件复用来更快的搭建和开发新的项目。

组件化的准备

技术准备

  1. 主站最初的app项目只有一个模块,业务耦合严重,技术组件很难复用,所以我们采取的第一步是拆分部分基础组件下沉为一个Base库,尽量去解耦业务提取基础技术组件达到多业务模块的复用,也是为了支持新项目和主站项目多个app的技术支持。

  2. 考虑组件化后的业务相对隔离,但是客户端组件间需要建立访问,所以需要组件间通信的介入。我们采取的方式是路由、服务和全局通知。

  3. 搭建路由库支持,目的是解决业务组件物理隔离后的UI跳转和访问,通过维护路由表的方式寻址到需要访问的业务组件UI。我们采取的是技术实现是通过注解给对应的业务UI比如LoginActivity上用注解申明对应的路由地址,在公共依赖的接口处公开维护这个路由地址常量,暴露给其他业务组件通过方位该地址来跳转到对应的业务组件UI。


@Router(RouterPath.LOGIN_PAGE)

public class LoginActivity extends BaseCompatActivity

public class RouterPath {

    /**

    * 登录

    */

    public static final String LOGIN_PAGE = "/native/xxx-login\\.html";

    /**

    * 搜索key

    */

    public static final String SEARCH_KEY = "/native/xxx-search-key\\.html";

    ...

}

对应Act绑定上路由地址后,需要对路由的地址进行统一的收集管理。同时也为了支持某些服务动态下发的地址,策略是优先在本地的路由表进行匹配,如果查询到了该地址有对应的Native界面优先跳转到Native的界面,未匹配到则跳转到由webView容器承载的网页。目前我们采取的方式是通过APT自动生成对应路由注解后的activity的收集类。


//自动生成的类,命名规则是RouterGenerator+业务组件模块名称

//RouterGenerator_login.class

public class RouterGenerator_login implements RouterProvider {

    public RouterGenerator_login() {

    }

    public void loadRouter(Map<String, Route> routerMap, Map<String, Route> pageNameRouterMap) {

        String keyLoginActivity = "((https|http|domain|native)://(\\w+\\.)?domain\\.com/native/domain-login\\.html)|(" + RouteBuilder.generateUriFromClazz(LoginActivity.class) + ")";

        routerMap.put(keyLoginActivity, RouteBuilder.build(keyLoginActivity, 0, false, (String[])null, LoginActivity.class));

    }

}

然后再通过ASM的方式在编译期对所有加载到工程里面的模块组件通过特定的规则进行上面路由辅助类的收集。


//收集路由地址

[

    'scanInterface'        : 'com.kaola.annotation.provider.RouterProvider',

    'scanSuperClasses'    : [],

    'codeInsertToClassName': 'com.kaola.core.center.router.RouterMap',

    //未指定codeInsertToMethodName,默认插入到static块中,故此处register必须为static方法

    'registerMethodName'  : 'register',

    'include'              : ['com/kaola/annotation/provider/result/.*'

]

//根据工程依赖的所有组件模块收集所有实现RouterProvider的辅助类。

//然后插入到RouterMap的静态代码块中,默认调用无参构造。

//遍历执行RouterMap中的静态方法register,添加所有路由地址信息到全局路由表sRouterMap中。

public class RouterMap {

    private static Map<String, Route> sRouterMap = new ConcurrentHashMap<>();

    private static Map<String, Route> sPageRouterMap = new ConcurrentHashMap<>();

    private static void register(RouterProvider routerProvider) {

        routerProvider.loadRouter(sRouterMap, sPageRouterMap);

    }

}

具体实现不再此展开了,此方式的好处就是可以根据需求加载需要的业务组件并且实现自动注册和收集路由到路由表。如果觉得独立开发路由库的成本较高,也可以采取业界主流的一些路由库比如ARouter等,基本类似。

  1. 关于组件间服务通信的方式,目前采取的是暴露对应的服务接口供各个业务组件方调用。每个业务组件都会申明需要对外暴露提供的方法,并在自己的业务组件模块内实现这些具体被调用的方法。对外接口库根据模块划分,可以申明和维护通信间的一些数据类型,比如公开的数据model和对应需要访问的一些路由地址等。为了便于服务的动态收集,这些服务接口可以统一的继承某个规则接口,然后采取上述路由的方式,对所有实现了该规则接口的服务接口统一的收集管理。

facade/pay/

            model/PayModel.class

            IPayService.class

    //IService.class,统一对继承IService的服务接口的具体实现类进行收集

    interface PayService : IService {

        fun startH5PaySercive(context: Context)

    }

pay_module/

    PayServiceImpl.class

    class PayServiceImpl : PayService {

        override fun startH5PaySercive(context: Context) {

            //...

        }

    }

剩下一些特点场景的业务,比如:登录成功后需要全局通知刷新多个UI某个业务状态的时候,目前采取EventBus的方式进行订阅通知。

  1. 在组件base库一定下沉和组件间通信方式的确立,开始对组件的具体的拆分粒度进行划分。大致划分为业务组件和技术组件两部分。

组件化的拆分流程

拆分前的考虑

考虑新的项目投入的人力资源有限,并且需要快速的开发上线,同时业务也有重合的场景。所以当时采取的开发策略是将主站未组件化的代码完全拷贝一份到新项目,并在此的基础上进行改造。改造的原则必须遵循2个应用共建同一套BaseLib,但是由于主站的BaseLib里面会耦合一些自身的业务组件,同时避免对BaseLib的修改影响到主站的业务开发而增加不必要的工作量。当时采取的策略是通过增加一层业务基础组件库来做新项目组件化拆分的缓冲层BaseCompatLib。

拆分过程

WX20190313-113131@2x.png

拆分过程中有很多业务组件共用的情况,结合当时的开发周期可以适当的去解耦部分业务组件重新划分到对应拆分后的业务模块中。如果时间有限,可以先挪到BaseCompatLib这个缓冲成暂时共用待后续再拆,从而避免对2个项目共用的Base库频繁修改带来的负担。

初期的业务模块独立编译的配置方式,仅供参考:


//gradle.properties中申明编译配置是否是独立编译

# Module Build

isModuleInjectBuild=true

//moduleLibrary的build.gradle中申明编译方式

if (isModuleInjectBuild.toBoolean()) {

    apply from: '../build_module.gradle'

} else {

    apply from: '../build_app.gradle'

}

//新建一个appbuild文件,用来支业务组件以app方式编译时所需的配置

//示例:

java/appbuild/

            BuildInfo.class //独立配置

            HomeServiceImpl.class //改写应用启动跳转的UI

            App.class //独立编译时的application,用于初始化配置

android {

    //配置源码路径

    sourceSets {

        main {

            jniLibs.srcDirs = ['src/main/jnilibs']

            //如果是整体编译,可以移除独立编译所需的额外代码

            if (isModuleInjectBuild.toBoolean()) {

                java {

                    exclude 'appbuild/**'

                }

            }

        }

    }

}

遇到的问题

拆分后的独立模块由于一些基础服务的初始化仍停留在app壳工程,一些sdk或者初始化服务没有统一的管理。优先级混乱并且耦合大量的业务逻辑,导致业务模块拆分后无法独立运行,缺失对应组件所需服务的初始化步骤。开始改造初始化的业务,原理同自动收集一致。


image2018-11-16 16_23_42.png

interface IInitializer {

    fun loadInQueue(queue: PriorityQueue<InitialTask>) //收集需要的服务进队列

    fun init(processName: String) //对应初始化服务的实现

}

class InitialManager {

    companion object {

        private val mInitializerQueue = PriorityQueue<InitialTask>() //服务队列

        private var mCurProcessName: String = "" //当前启动的进程

        //应用初始化时的调用的入口函数

        @JvmStatic

        fun initial(curProcessName: String) {

            mCurProcessName = curProcessName

            initialInProcess()

        }

        @JvmStatic

        fun initialInProcess() {

            loop@ while (mInitializerQueue.isNotEmpty()) {  //搜索接入了多少三方sdk功能,总任务队列

                val initialTask = mInitializerQueue.poll() //按优先级取

                //根据是否拥有权限去加载普通任务

                //特殊不需要检查权限的任务,包括:Config和Permission初始化本身的任务。

                //目前这些优先级必须高于普通任务,否则会被提前打断,等到权限获取后才会执行。

                when {

                    PermissionUtils.isNecessaryPermissionGranted() || initialTask.isNoNeedPermissionCheck() -> {

                        executeTask(initialTask)

                    }

                    else -> {

                        //一旦被权限检查打断不能执行,取出的任务重新放回队列。跳出任务队列,等待权限获取后的再次执行。

                        mInitializerQueue.add(initialTask)

                        break@loop

                    }

                }

            }

        }

        /**

        * 执行任务,匹配对应进程,对应进程启动对应需要初始化的任务,沿用主站的逻辑

        */

        private fun executeTask(initialTask: InitialTask) {

            initialTask.processName.forEach {

                //当前进程和服务需要初始化的进程相匹配或者是全进程需要就加载

                if (it == mCurProcessName || it == InitialTask.INITIAL_ALL_PROCESS) {

                    Log.d("InitialManager", "initial - process:$mCurProcessName & initialTask:${initialTask.initialName}")

                    initialTask.initializer.init(mCurProcessName)

                    return@forEach

                }

            }

        }

        @JvmStatic

        fun register(initializer: IInitializer) {

            initializer.loadInQueue(mInitializerQueue)

        }

    }

//示例服务   

class QiyuSdkInitial : IInitializer {

    override fun loadInQueue(queue: PriorityQueue<InitialTask>) {

        //主进程需要

        val initialTask = InitialTask(

                processName = mutableListOf(ProcessConst.MAIN_PROCESS, ProcessConst.NIM_PROCESS),

                initialName = this::class.java.simpleName,

                initializer = this

        )

        queue.add(initialTask)

    }

    override fun init(processName: String) {

        try {

            QiyuSdk.initUnicorn(AppDelegate.sApplication)

        } catch (e: Throwable) {

            e.printStackTrace()

        }

    }

}

彻底组件化

架构图

友品组件化.png

组件库的独立发布和维护

原有拆分的本地组件彻底分离出去,采取独立发布和维护的方式迭代更新。

  1. 新建git仓库和本地组件项目,然后以module的方式将原有项目中的业务module导入到本地新建的项目中。推送该项目到git的独立仓库。(目前未采取git subModule的 方式管理,但大致差不多)

  2. 新建的本地项目中再新建一个对应的接口工程用于对外暴露模块中的业务访问。


project: component-login

                        /app            //壳工程

                        /login          //登录模块module

                        /login-facade  //登录模块接口module

  1. 添加打包aar发布到maven仓库的脚本用来独立发布login和login-facade模块。

  2. 遵循对应的发布规范,不同项目的app壳工程根据自身的业务需求进行对应的组件依赖,版本开发阶段可采取snapshot的进行依赖。不同的业务组件也可以通过依赖其他不同的业务组件接口达到访问的目的。(如需实际运行,不光需要再接入接口库还需要依赖对应的组件工程)

  3. 目前友品采取的是jenkins的打包发布方式,仅供参考。

本地开发调试模式

在组件开发过程中,单纯的依靠远程方式依赖,对开发阶段的频繁修改不友好。所以我们采取依赖覆盖的方式,让原有的依赖在编译过程中替换掉远程的版本改用本地的版本进行引用。


// 自定义const.gradle环境声明

def version = '1.5.11'

ext.sdk = [

        YpBase        : { "com.kaola:ypbase:${version}" }

]

//app build.gradle

dependencies {

    api gradle.sdk.YpBase(this)

}

//setting.gradle

gradle.ext {

    sdk = sdk

}

//本地依赖时需要修改为本地的路径

def YpBase_PATH = "localpath/base"

def YpBase_as_aar = []

def YpBase_as_sources = [

        ['YpBase', ":base", ['type': 'project', 'path': "${YpBase_PATH}/base"]],

]

def overrideList = YpBase_as_aar

// *核心* 打开注释使用源码引入YpBase

overrideList = YpBase_as_sources

def overrideLibrary(Map define, String whichLibrary, String name, Map prjType) {

    def overrideType = prjType.get("type")

    if (overrideType == 'module') {

        include(name)

        define.put(whichLibrary, {

            it.project(name)

        })

    } else if (overrideType == 'project') {

        include(name)

        project(name).projectDir = new File(prjType.get('path'))

        define.put(whichLibrary, {

            it.project(name)

        })

    } else if (overrideType == 'aar') {

        define.put(whichLibrary, { prjType.get('path') })

    } else {

        ; // ignore

    }

}

for (int i = 0; i < overrideList.size(); i++) {

    def override = overrideList[i]

    println 'override: ' + override[0]

    overrideLibrary(sdk, override[0], override[1], override[2])

}

通过以上的方式让Base的依赖从远程替换为本地module的形式。开发阶段就可以通过AS的refactor进行代码的优化和重构,对本地Base修改后到Base的git分支进行对应的提交或MR合回主分支然后走规范的发布打包流程。

组件版本依赖管理

组件项目中会有对Base或者接口库的引用,对于Base我们可以选择compileOnly的方式,也可以选择直接依赖的方式。在集成到项目中后依赖会遵循gradle的依赖传递原则。特别注意:

  1. 避免环形依赖的产生。比如:facade -> base, base -> facade。遇到这种情况需要拆分所需依赖到另外一层。

  2. 在远程依赖替换为本地依赖做开发修改时可能会遇到远程依赖和本地依赖的冲突。比如:app -> login -> com.xxx:base; app -> home -> /localpath/base。 此时可以采取下面的方式进行依赖优先选择本地的方式排除掉其他组件中的远程依赖。


//setting.gradle

def base_exist = false

for (int i = 0; i < overrideList.size(); i++) {

    def override = overrideList[i]

    println 'override: ' + override[0]

    if (override[0] == 'YpBase') {

        base_exist = true

    }

    overrideLibrary(sdk, override[0], override[1], override[2])

}

gradle.ext {

    kulabase_exist = base_exist

}

//app.gradle

if (gradle.kulabase_exist) {

    println 'kulabase_exist exist, exclude all aar dependences'

    android {

        configurations {

            all*.exclude group: 'com.xxx', module: 'base'

        }

    }

}

后续

到此为止基本上组件化就可以持续稳定的开发和维护了,组件化后也给团队的开发效率带来一定的提升,代码也可以在一定可控的范围内稳定的维护。并且在各自维护的组件中,大家也可以根据各自需求选择合适自己业务的开发框架比如:mvp、LiveData、Rx等或者尝试使用新语言Kotlin去编写。解决业务耦合带来的负担同时也使各个组件达到了较高的可复用性,灵活的支持不同的应用项目,达到可插拔的方式集成开发。后续项目也会做一些优化,针对版本依赖的管理和简化组件编译和发布集成的流程来提高协作开发的效率。

ASM自动收集参考:https://github.com/luckybilly/AutoRegister

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