您好!
欢迎来到京东云开发者社区
登录
首页
博文
课程
大赛
工具
用户中心
开源
首页
博文
课程
大赛
工具
开源
更多
用户中心
开发者社区
>
博文
>
京麦客户端组件化设计与实践
分享
打开微信扫码分享
点击前往QQ分享
点击前往微博分享
点击复制链接
京麦客户端组件化设计与实践
京麦研发团队
2021-06-07
IP归属:未知
751520浏览
敏捷架构
### PC原生客户端组件化 在当今的移动互联网时代,人们离不开手机、智能手表等移动设备。但在办公等B端复杂场景,人们需要更高的效率时,仍常使用PC电脑。众多平台型软件,如微信,千牛等在开发APP的同时,都同时提供PC客户端软件。这使PC客户端软件仍占有一席之地。京东内部同样存在不少PC原生客户端团队,如技术中台的企业咚咚、平台生态的京麦PC客户端、JDA机器人、企业信息化部的鲸盘等。 移动APP的组件化有成熟的技术方案,技术与数据中心-共享技术部-移动技术研发组提供了通用的Router框架。PC原生客户端暂没有通用的技术架构。本文将根据京麦PC客户端的组件化实践,介绍京麦PC客户端的组件化实现技术细节,希望能给其他PC团队与参考。内容主要包括: **1、** **为什么需要组件化** **2、** **组件拆分原则** **3、** **组件化架构** **4、** **组件接口及初始化** **5、** **组件通信** 京麦PC客户端开发环境为VS2017,开发语言为C++,UI框架为QT5.12.10,本文中出现的大量示意代码,都为项目中的实际代码,欢迎联系我进行更深入的交流。 #### **一、** **为什么需要组件化** 京麦PC客户端(后续简称为京麦)作为京东面向B端商家经营的一站式客户端,提供了支持多域多角色账号的登录,登录后通过内置CEF浏览器承载了商家经营链路上的全部功能,如菜单导航、店铺数据查看、学习中心等。为了更好的体验,主流浏览器中的功能,京麦中也都提供了,如收藏栏,网页浏览历史记录。界面如图: ![京麦](//img1.jcloudcs.com/developer.jdcloud.com/008e4b60-98d6-4550-a287-65e52a45437720210607140009.png) 这使京麦的代码复杂的很高,在实现组件化之前,一个较小的产品需求,可能牵涉到多个模块DLL的代码改造。以收藏栏功能为例: ![书签](//img1.jcloudcs.com/developer.jdcloud.com/b3d69aca-2f8d-41e4-b361-b2d995cb1ff320210607140038.png) 代码架构采用MVC隔离,但由于平台实体定义库、平台逻辑层包含了与平台相关的所有实体和接口定义,修改收藏栏相关的代码,很可能影响其他功能。JMBookmark动态库中包含了与收藏栏相关的UI,其他多个DLL引用该库。这些都可能由于修改收藏栏导致其他的功能受影响,并导致测试范围不可控。 另外,由于每个功能模块都可能存在商家自定义的参数,京麦中将系统设置集成到“系统设置”页面,如图: ![设置](//img1.jcloudcs.com/developer.jdcloud.com/36ddfa76-3416-434a-b73f-3712f3a3a42420210607140058.png) 组件化之前,通过动态库PlatformSetting实现该页面的功能。该动态库和其他具体功能,如收藏栏的动态库,都需要依赖设置数据的读取、保存相关代码逻辑。从而使系统设置的数据管理、系统设置UI成为京麦中心化的动态库,这显然是个糟糕的设计。 通过组件化,可以完美的解决上述问题:**一个组件实现一个内聚化的功能,组件内部采用MVC架构。如收藏栏所有功能,包括收藏栏、收藏按钮、收藏栏相关菜单和系统设置都放在同一个组件内**。要具备上述能力,组件化框架针对京麦首页、系统设置等功能具备从不同组件中加载UI,并统一展示的能力。通过组件化使代码更加内聚,各组件互不影响。 #### **二、** **组件拆分原则** 组件的拆分原则,区分是平台功能还是业务功能,比如收藏栏、历史记录、系统设置等属于平台功能;首页左侧菜单,店铺提醒、店铺数据、服务市场等属于业务功能。 - 平台功能:所有与该功能相关的代码,都在同一个组件内,包括实体定义、接口调用、界面UI以及与其他组件的通信逻辑; - 业务功能:所有同一个业务场景的功能放在同一个组件内,比如服务市场相关的功能,在京麦中存在若干入口: ![服务市场入口](//img1.jcloudcs.com/developer.jdcloud.com/cdf83bed-1e47-477e-9ac1-378e1fe5ec9020210607140119.png) 1) 左上角服务市场入口 2) 首页服务市场模块 3) 右上角按钮弹出的系统菜单中,应用权限设置 将以上功能都放到一个组件内,京麦主框架如何从组件中获取这些功能,并展示在不同的区域呢?在下面的内容中将详细介绍。 #### **三、** **组件化架构** 从领域架构和交互层面,京麦的整体架构如下图: ![架构图](//img1.jcloudcs.com/developer.jdcloud.com/a8e8771a-6347-4a61-8407-f7a591494c4720210607140135.png) 本文主要关心业务服务层。最底层为基础组件,为领域组件提供基础能力的支撑,包括UI组件库、HTTP协议栈、TCP协议栈、高分屏框架等。一个领域组件可能由多个组件DLL实现,如数据域分为店铺提醒组件、店铺数据组件等。 ![组件列表](//img1.jcloudcs.com/developer.jdcloud.com/7ac8c621-375a-4e57-8f98-63be50652d0520210607140153.png) 上图展示了京麦中的部分组件,基础组件之间可以相互依赖,如JMTcpModule依赖JMCore。**业务组件可以依赖任何基础组件,但业务组件之间不相互依赖**。 #### **四、** **组件接口及初始化** 一个组件的接口,最起码应该包括组件的初始化、反初始化、生命周期和Router拦截(组件通信)。上文中提到,京麦首页和系统设置等大功能模块,其功能可能由多个组件提供的UI组合而成,那么组件的接口中,需要包含大功能模块的工厂接口。京麦的组件化接口IBusinessModule如下: ![组件接口](//img1.jcloudcs.com/developer.jdcloud.com/cf19b972-ea84-497a-9686-d52fc79a263720210607140212.png) 一个组件可能由多个DLL组成,但暴露给组件框架的是一个固定的DLL。京麦通过QT的插件机制,通过动态库名称加载DLL,并转换为接口。核心代码如下: ```c++ QObject* JMModuleManager::LoadModule(const QString& moduleName) { // 判断DLL是否存在 QString modulePath = SuperDir().GetCurrentDir() + "modules" + QDir::separator() + moduleName + ".dll"; if (!QFile::exists(modulePath)) return nullptr; QObject* obj = JMPluginLoader::Instance()->Load(modulePath); return obj; } ``` 根据DLL的名称,拼接为DLL全路径,而后通过Load()函数加载插件库,其中Load函数代码如下: ```C++ QObject* JMPluginLoader::Load(const QString& strPath) { QPluginLoader loader(strPath); if (!loader.load()) return nullptr; QObject *obj = loader.instance(); if (obj == nullptr) return nullptr; return obj; } ``` QT基础框架中的QPluginLoader类,提供了加载一个DLL,并转换为QObject对象的方法。最后,我们将QObject的指针,转换为IBusinessModule的指针: ```C++ QObject* obj = LoadModule(moduleName); if (obj == nullptr) continue; // 指针转换 IBusinessModule* pModule = qobject_cast<IBusinessModule*>(obj); ``` 获取IBusinessModule的指针后,即可调用接口中的函数进行初始化、反初始化操作。 那么,加载组件,并初始化的时机是什么时候呢?在程序运行期间,组件是不允许进行增减的,若增减组件,客户端必须重启。所以我们可以在应用程序入口处,加载所有组件,并进行初始化。 ![入口](//img1.jcloudcs.com/developer.jdcloud.com/9acbf64d-e529-4a96-be67-4c591392d4b820210607140233.png) 京麦中的部分非核心组件,允许通过界面配置删除,核心组件不允许删除。在上述的Initialize()函数中,会从配置文件中读取非核心组件列表并加载组件: ```C++ // 读取模块定义字符串 QString strModules = JMConfigManager::Instance()->GetIniValue("modules", "business", "modules", "").toString(); // 使用,分割 m_listModuleName = strModules.split(","); // 加载所有模块 for (const auto& moduleName : m_listModuleName) { QObject* obj = LoadModule(moduleName); if (obj == nullptr) continue; // 添加组件 IBusinessModule* pModule = qobject_cast<IBusinessModule*>(obj); m_mapModule[moduleName] = pModule; } ``` 若从配置文件中去掉某个组件,该组件将不再加载。对于核心组件,通过代码直接加载: ```c++ JMFrameModule::Instance(); JMFrameModule::Instance()->Initialize(); JMFrameModule::Instance()->AddModule(MODULE_ACCOUNT, new JMAccountModule()); JMFrameModule::Instance()->AddModule(MODULE_BROWSER, new JMBrowserModule()); ``` 由于核心组件,通过代码直接加载,所以主框架需要依赖组件的动态库。而非核心组件采用插件机制加载,动态库之间没有任何依赖。京麦中提供页面对非核心组件进行配置: ![组件配置](//img1.jcloudcs.com/developer.jdcloud.com/9fb361c2-323a-460a-9deb-631c2f5579e720210607140250.png) 组件化接口中,除了初始化和反初始化外,生命周期和Router拦截器也是核心接口。除此之外的接口与京麦的界面拆分相关。Router拦截器属于组件间通讯机制,在后面的部分讲述。 ##### 4.1 生命周期 生命周期是与京麦登录、主框架相关。各组件根据自身逻辑选择实现部分接口。若一个组件与生命周期无关,可直接返回空指针。代码如下: ![生命周期](//img1.jcloudcs.com/developer.jdcloud.com/aecc5194-7569-4031-9c4c-bd7662b393cc20210607140307.png) 以LoginSuccess()接口为例,京麦登录成功后,将循环遍历所有的组件调用各组件的LoginSuccess()函数。比如,系统消息组件在登录成功后,会调用接口拉取系统消息列表;收藏栏组件在登录成功后会调用接口拉取收藏栏数据。 ##### 4.2 系统设置工厂接口 系统设置是由各组件组合返回控件,组合而成界面的一个。ISysSettingFactory接口代码如下: ![设置工厂](//img1.jcloudcs.com/developer.jdcloud.com/e8d65979-52aa-48a0-bf2a-9b0f8461de8220210607140322.png) 其中: 1) GetSettingCategory()返回分类指针,若组件不添加新分类,则返回空对象; 2) GetSettingWidgetIdList()返回系统设置项的Id列表; 3) CreateWidgetByType()根据返回的Id,创建具体的Widget; 4) GetSearchInfo()返回搜索信息列表; 系统设置组件,会遍历所有组件,若组件返回的ISysSettingFactory接口指针不为空,则获取类别信息,判断是否需添加类别;而后获取模块设置项列表进行排序,分别创建设置项并拼装成系统设置界面。 #### **五、** **组件通信** 业务组件之间由于没有直接依赖关系,无法通过代码调用的方式通信。京麦与行业上组件化一样,提供了Router机制实现组件间的通信。IRouteInterceptor接口定义如下: ![Router](//img1.jcloudcs.com/developer.jdcloud.com/28c9121f-510f-45db-961a-b702b21a03cb20210607140342.png) JMRouterProtocol对象由字符串构造,如: const QString JM_ROUTER_BROWSER_DPICONFIG = "jm://dpi"; const QString JM_ROUTER_WEBVIEW_OPEN = "jm://webview/open"; 调用方式: JMRouterProtocol(JM_ROUTER_HTTPDNS_UPDATE).Execute(); JMRouterProtocol类提供以下三个方法: ```C++ /*@brief 执行匹配协议 */ bool Execute(); /*@brief 获取协议对象数据 */ QObject* GetObj(); /*@brief 获取协议基础数据 */ QVariant GetData(); ``` 其中: 1) Execute()方法执行时,将遍历所有组件,并调用组件的HandleIntercept()函数,返回执行是否成功; 2) GetObj()方法执行时,将遍历所有组件,并调用组件的GetHandleObject ()函数,返回执行结果对象;若一个组件往外导出Widget,可以定义实现QObject的接口,收藏栏组件导出接口: ![接口](//img1.jcloudcs.com/developer.jdcloud.com/a5f57644-c9c9-4ca8-b4ce-52213c973ecc20210607140402.png) 调用方获通过GetObj方法获取QObject对象,转换为IBookmarkService对象,从而获取QWidget对象。 3) GetDat()方法执行时,将遍历所有组件,并调用组件的GetHandleData ()函数,返回执行结果数据。 通过Router机制,组件直接相互依赖的只有接口,如上面代码中提到的IBookmarkService接口。 Router机制提供了一对一的通信方式,如协议jm://dpi,实现该协议的应该是唯一的一个组件。若一个组件的变更,要通知多个其他组件,则需要调用多个Router协议,这是Router机制的缺点。京麦中提供了组件间的监听机制来解决这个问题,在此不再详述,有兴趣可以联系我探讨。
原创文章,需联系作者,授权转载
上一篇:架构研究:中台的收敛与前台的外延
下一篇:nginx反向代理时保持长连接
相关文章
京麦客户端组件化设计与实践
Docker与虚拟化技术浅析第一弹之docker与Kubernetes
架构研究:研发敏捷与中台架构(论前台bp研发敏捷)
京麦研发团队
文章数
11
阅读量
69603
作者其他文章
01
京麦客户端组件化设计与实践
详细阐述了京麦windows客户端程序组件化的架构设计与实现
01
深度解析移动端各技术栈
移动端
01
Widget开发实践
Widget开发实践
01
借助Xposed辅助检测安卓App隐私相关合规问题
借助Xposed辅助检测安卓App隐私相关合规问题
京麦研发团队
文章数
11
阅读量
69603
作者其他文章
01
深度解析移动端各技术栈
01
Widget开发实践
01
借助Xposed辅助检测安卓App隐私相关合规问题
添加企业微信
获取1V1专业服务
扫码关注
京东云开发者公众号