### 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浏览器承载了商家经营链路上的全部功能,如菜单导航、店铺数据查看、学习中心等。为了更好的体验,主流浏览器中的功能,京麦中也都提供了,如收藏栏,网页浏览历史记录。界面如图:

这使京麦的代码复杂的很高,在实现组件化之前,一个较小的产品需求,可能牵涉到多个模块DLL的代码改造。以收藏栏功能为例:

代码架构采用MVC隔离,但由于平台实体定义库、平台逻辑层包含了与平台相关的所有实体和接口定义,修改收藏栏相关的代码,很可能影响其他功能。JMBookmark动态库中包含了与收藏栏相关的UI,其他多个DLL引用该库。这些都可能由于修改收藏栏导致其他的功能受影响,并导致测试范围不可控。
另外,由于每个功能模块都可能存在商家自定义的参数,京麦中将系统设置集成到“系统设置”页面,如图:

组件化之前,通过动态库PlatformSetting实现该页面的功能。该动态库和其他具体功能,如收藏栏的动态库,都需要依赖设置数据的读取、保存相关代码逻辑。从而使系统设置的数据管理、系统设置UI成为京麦中心化的动态库,这显然是个糟糕的设计。
通过组件化,可以完美的解决上述问题:**一个组件实现一个内聚化的功能,组件内部采用MVC架构。如收藏栏所有功能,包括收藏栏、收藏按钮、收藏栏相关菜单和系统设置都放在同一个组件内**。要具备上述能力,组件化框架针对京麦首页、系统设置等功能具备从不同组件中加载UI,并统一展示的能力。通过组件化使代码更加内聚,各组件互不影响。
#### **二、** **组件拆分原则**
组件的拆分原则,区分是平台功能还是业务功能,比如收藏栏、历史记录、系统设置等属于平台功能;首页左侧菜单,店铺提醒、店铺数据、服务市场等属于业务功能。
- 平台功能:所有与该功能相关的代码,都在同一个组件内,包括实体定义、接口调用、界面UI以及与其他组件的通信逻辑;
- 业务功能:所有同一个业务场景的功能放在同一个组件内,比如服务市场相关的功能,在京麦中存在若干入口:

1) 左上角服务市场入口
2) 首页服务市场模块
3) 右上角按钮弹出的系统菜单中,应用权限设置
将以上功能都放到一个组件内,京麦主框架如何从组件中获取这些功能,并展示在不同的区域呢?在下面的内容中将详细介绍。
#### **三、** **组件化架构**
从领域架构和交互层面,京麦的整体架构如下图:

本文主要关心业务服务层。最底层为基础组件,为领域组件提供基础能力的支撑,包括UI组件库、HTTP协议栈、TCP协议栈、高分屏框架等。一个领域组件可能由多个组件DLL实现,如数据域分为店铺提醒组件、店铺数据组件等。

上图展示了京麦中的部分组件,基础组件之间可以相互依赖,如JMTcpModule依赖JMCore。**业务组件可以依赖任何基础组件,但业务组件之间不相互依赖**。
#### **四、** **组件接口及初始化**
一个组件的接口,最起码应该包括组件的初始化、反初始化、生命周期和Router拦截(组件通信)。上文中提到,京麦首页和系统设置等大功能模块,其功能可能由多个组件提供的UI组合而成,那么组件的接口中,需要包含大功能模块的工厂接口。京麦的组件化接口IBusinessModule如下:

一个组件可能由多个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(obj);
```
获取IBusinessModule的指针后,即可调用接口中的函数进行初始化、反初始化操作。
那么,加载组件,并初始化的时机是什么时候呢?在程序运行期间,组件是不允许进行增减的,若增减组件,客户端必须重启。所以我们可以在应用程序入口处,加载所有组件,并进行初始化。

京麦中的部分非核心组件,允许通过界面配置删除,核心组件不允许删除。在上述的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(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());
```
由于核心组件,通过代码直接加载,所以主框架需要依赖组件的动态库。而非核心组件采用插件机制加载,动态库之间没有任何依赖。京麦中提供页面对非核心组件进行配置:

组件化接口中,除了初始化和反初始化外,生命周期和Router拦截器也是核心接口。除此之外的接口与京麦的界面拆分相关。Router拦截器属于组件间通讯机制,在后面的部分讲述。
##### 4.1 生命周期
生命周期是与京麦登录、主框架相关。各组件根据自身逻辑选择实现部分接口。若一个组件与生命周期无关,可直接返回空指针。代码如下:

以LoginSuccess()接口为例,京麦登录成功后,将循环遍历所有的组件调用各组件的LoginSuccess()函数。比如,系统消息组件在登录成功后,会调用接口拉取系统消息列表;收藏栏组件在登录成功后会调用接口拉取收藏栏数据。
##### 4.2 系统设置工厂接口
系统设置是由各组件组合返回控件,组合而成界面的一个。ISysSettingFactory接口代码如下:

其中:
1) GetSettingCategory()返回分类指针,若组件不添加新分类,则返回空对象;
2) GetSettingWidgetIdList()返回系统设置项的Id列表;
3) CreateWidgetByType()根据返回的Id,创建具体的Widget;
4) GetSearchInfo()返回搜索信息列表;
系统设置组件,会遍历所有组件,若组件返回的ISysSettingFactory接口指针不为空,则获取类别信息,判断是否需添加类别;而后获取模块设置项列表进行排序,分别创建设置项并拼装成系统设置界面。
#### **五、** **组件通信**
业务组件之间由于没有直接依赖关系,无法通过代码调用的方式通信。京麦与行业上组件化一样,提供了Router机制实现组件间的通信。IRouteInterceptor接口定义如下:

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的接口,收藏栏组件导出接口:

调用方获通过GetObj方法获取QObject对象,转换为IBookmarkService对象,从而获取QWidget对象。
3) GetDat()方法执行时,将遍历所有组件,并调用组件的GetHandleData ()函数,返回执行结果数据。
通过Router机制,组件直接相互依赖的只有接口,如上面代码中提到的IBookmarkService接口。
Router机制提供了一对一的通信方式,如协议jm://dpi,实现该协议的应该是唯一的一个组件。若一个组件的变更,要通知多个其他组件,则需要调用多个Router协议,这是Router机制的缺点。京麦中提供了组件间的监听机制来解决这个问题,在此不再详述,有兴趣可以联系我探讨。
京东黑板报 为您提供最权威、最全面的京东官方新闻报导,传递京东最新资讯
微信扫一扫
关注京东黑板报