鸿蒙模块化开发实战:分支协作模块架构设计 1. 项目当初是怎么被拆出来的Pocket Tool 这个工具集在做到第三个版本的时候遇到了一个几乎所有工具类应用都会撞上的墙需求开始失控了。今天加个文本对比明天要个JSON格式化后天又想把正则测试塞进来。功能多了之后单一页面承载不了这么多入口代码开始出现一个现象——什么模块都能访问公共数据什么模块都要往主流程里挂一截逻辑。整包体积、编译时间、可维护性全部开始恶化。与其继续往一个巨型单模块里堆功能不如把工具集拆成一个宿主框架加若干独立业务模块的形态。这个思路在服务端领域叫微服务在前端叫微前端落到鸿蒙应用上我当时选的路子是模块化加按需加载。每个工具类功能独立成模块通过统一的框架协议接入宿主模块之间不做直接依赖只依赖框架暴露的接口。这就是分支协作模块的雏形。先说结论这套东西最后做出来的效果是功能模块可以独立开发、独立编译、独立调测甚至可以让不同开发者并行维护同一个应用里的不同模块而不需要互相等待。这个机制在团队协作层面的价值远大于在技术层面的价值。2. 分支协作模块的核心设计思路2.1 这个模块到底解决什么问题在没有协作框架之前多个人开发同一个鸿蒙应用最常见的协作方式就是代码仓库开分支你开发文本工具我开发图片压缩工具最后往主干合并。听起来没什么问题但实际操作一段时间就会暴露几个很麻烦的情况第一个是只要模块之间有数据交互就必然出现接口冲突。两个人改了同一个公共类谁先合代码谁就占便宜后来的人冲突解决到怀疑人生。第二个是模块的编译互相拖累。改一行公共代码整个工程要重新编译几十个模块挨个过一遍。第三个是调试依赖。我想调自己模块的功能但主工程别人的模块还没写好一编译就报错根本没法跑。分支协作模块做的就是把这种代码层面的耦合转成接口层面的约束。每个模块不再直接引用其他模块的类而是通过一个中间层完成数据交换。组合方式从源代码交叉依赖变成运行时动态装配。2.2 为什么用插件化而不是直接依赖这个问题是当时在方案评审时讨论最激烈的。有同事提议直接用模块依赖把公共部分抽成公共库大家依赖公共库就够了不需要搞这么复杂的协作框架。这个方案对于功能较少的小应用完全成立。但Pocket Tool的规划方向是要逐步挂载几十个工具类子模块而且这些模块里不少是带有独立SDK的比如文件处理相关的、多媒体相关的体积都不小。如果全部用编译期依赖最后主工程会在逻辑上很简单但在编译和启动层面都会很吃力。所以最终选的是接口加实现分离的路子。模块A只依赖模块A接口库模块B只依赖模块B接口库宿主只依赖全部接口库。运行期由宿主负责把接口绑定到对应实现。这个设计带来的最大好处是模块可以在编译期完全解耦想单独拎出来跑单元测试直接把模块的实现包和接口包打进去就行完全不需要宿主。2.3 接口通讯的格式设计模块之间传数据最早想做的是直接传对象每个模块都传自己的数据类。这样看起来很方便但很快就发现一个问题接口库会越来越大因为每个模块的请求类和响应类都要暴露出去。后来改成了统一的数据包裹格式。每个接口方法的入参是Request对象内部带一个Map结构不同方法往Map里放不同key的字段。返回值是Result对象除了业务数据之外统一带errorCode和errorMessage。这套格式一开始被团队诟病说太简陋但实际用下来之后真香因为不管后续挂多少新模块接口库的体量都是恒定的协议本身不需要再改。参数取值和校验都在模块内部做宿主不关心参数细节。这个边界在协作场景下特别重要因为它让接口定义变得非常稳定很少因为需求变化而改动。3. 分模块的落地过程与实现细节3.1 框架基础组件有哪些整个分支协作模块最后沉淀下来的核心组件是四块模块管理器、服务注册表、事件总线、页面路由桥。模块管理器负责拿到模块生命周期包括注册、初始化、销毁。服务注册表里存的是接口类型到实现类的映射关系。事件总线处理模块间的松耦合事件通知。页面路由桥负责把模块内的页面跳转统一收口避免模块A直接引用模块B的页面类。这四块各自独立但组合在一起就构成了完整的协作框架。其中服务注册表是最基础的没有它模块间根本没办法找到对方的服务。3.2 服务注册与发现的实现模块在启动时需要把自己提供的服务注册到注册表里。这里用的是ArkTS的装饰器能力配合模块的初始化入口统一完成。// 模块初始化入口 export default class PocketToolModuleInit implements IModuleInit { onInitialize(context: IModuleContext): void { // 注册当前模块对外提供的服务 ModuleRegistry.register( TextCompareService, new TextCompareServiceImpl() ); ModuleRegistry.register( RegexTesterService, new RegexTesterServiceImpl() ); // 订阅自己关心的模块事件 EventBus.subscribe(FileModuleReady, this.onFileModuleReady); } onRelease(): void { ModuleRegistry.unregister(TextCompareService); ModuleRegistry.unregister(RegexTesterService); EventBus.unsubscribe(FileModuleReady, this.onFileModuleReady); } }这里值得注意的一点是模块管理器中保存的不是服务实例而是工厂方法。这个设计是为了避免在宿主还没启动时模块就被提前实例化。真实场景里很多服务都依赖上下文提前new出来轻则空指针重则直接崩溃。3.3 宿主侧如何聚合与装配模块宿主是整个应用的壳它不关心模块内部实现只关心模块有没有正确注册。代码上宿主做的事情其实很少但设计上最难的部分是模块的发现机制。鸿蒙应用模块在工程上是独立的HAR包或HSP包。宿主在编译期知道全部模块的静态依赖但在运行期可控的做法是启动时扫描所有模块的初始化入口统一调用onInitialize。实现方式是在各模块的初始化类上打注解标记宿主框架在启动时通过AbilityStage的上下文拿到所有已加载模块的初始化入口列表逐个回调。// 宿主AbilityStage中的初始化逻辑 onCreate(): void { super.onCreate(); const moduleInitList: ArrayIModuleInit ModuleLoader.loadAllModuleInits(this); moduleInitList.forEach(init { init.onInitialize(ModuleContext.getInstance()); }); }这套机制在调试阶段的经验是每个模块都放了自己的初始化类文件但宿主不需要硬编码任何模块的类名。新加模块时宿主代码零改动这正好就是协作的价值所在。3.4 页面路由桥的实现Pocket Tool里的工具模块大多有独立页面。早期版本页面跳转都是直接通过router.pushUrl传入目标页面的url字符串。模块间既然不依赖源码自然也不能依赖url字符串。因为在模块化架构下url字符串本质上也是一个编译期约束。页面路由桥的做法是把页面跳转统一封装成服务调用模块发起跳转时只传递页面标识到路由桥路由桥负责把标识映射到实际页面地址。RouteBridge.push({ pageId: text-compare, params: { left: sourceText, right: targetText } });这个pageId在路由桥内部通过页面注册表转换为可跳转的url。模块注册时除了注册服务还会注册自己包含的页面标识与url的映射关系。这样做还有一个额外的好处当某个模块没有安装时路由桥可以根据页面注册表判断出来直接给出友好的提示页而不是跳转失败白屏。4. 并行协作开发的工程配置心得4.1 开发模式怎么切分Pocket Tool的协作模块落地成功后团队内部从原先的主干开发模式切换到了每个工具子模块一条独立开发线的模式。有人负责文本类工具模块代码整体在text-tools这条分支上维护。有人负责图像类工具在image-tools分支上维护。双方唯一的交集是接口库和注册协议。由于接口库本身足够稳定两个分支之间经常几个星期不需要互相合并。实际踩坑后的一个经验是接口库必须由第三方维护不能由模块开发者维护。让模块开发者自己定义接口很容易出现接口随着实现走表面上是接口规范实际还是实现细节。由独立的接口负责人维护接口才能真正稳定下来。4.2 构建与包体控制鸿蒙的HSP包共享包天然适合这种模块化场景。每个工具模块打包成独立的HSP宿主按需加载。这里涉及到运行时按需加载的实现用的是动态import方式页面跳转时才真正去加载模块的代码。// 动态加载图片压缩工具模块 async function loadImageCompressModule() { const module await import(pocket/feature-image-compress); return module.default; }动态加载这套方案最直接的收益是冷启动时间明显下降。原本所有模块都在主包里启动时全部要初始化现在只有常用模块随宿主启动其他模块等用户首次使用时才加载。不过这里也有一个反面教训如果模块拆得太细每个工具都单独一个HSP会在应用市场审核和包管理层面带来额外复杂度。我的建议是围绕功能域来分比如文本类工具合成一个模块图像类工具合成一个模块比每个工具单独拆模更好管理。4.3 模块间的依赖怎么约束不用代码级别的强约束单靠团队自觉约束不了太久。实现上最终还是加了编译期检查把模块间禁止跨模块import这个规则变成一个编译脚本在CI流程里跑。脚本的做法很简单解析各模块的源码检查import语句中是否出现了其他业务模块的包前缀如果出现就报错阻止合并且给出提示信息。# 伪代码示意实际是个python脚本 for module in modules: imports extract_imports(module) forbidden find_business_imports(imports) if forbidden: report(f禁止跨模块依赖: {forbidden})这个约束带来的效果非常直接谁想图省事直接import别模块的类编译直接失败立刻被拉回正轨。时间久了大家写代码时就会主动思考这个依赖要不要走接口。5. 踩坑实录与排查技巧5.1 服务注册了但调用时报空实现这个坑出现的频率非常高。场景是模块A注册了服务但模块B在宿主启动的早期阶段就去调用模块A的服务结果拿到的实现是空的。排查发现原因在初始化顺序。模块A和模块B都实现了IModuleInit但模块初始化并不是并行执行的。宿主在扫描初始化入口时是按文件的加载顺序来的B模块恰好排在了A模块前面。当时的解决方案分两步。第一步在框架层对所有模块初始化做一个短暂的延时等待等主模块全部就绪后再开放服务调用。第二步更重要在业务规范上明确模块B不能假设模块A已经就绪必须要订阅模块A的就绪事件。这个订阅机制靠EventBus实现。模块B在初始化时注册对ModuleReady事件的监听收到模块A就绪的消息后再去拿服务。这个模式需要开发者适应但它在架构上安全很多。5.2 动态加载后路由跳转白屏页面路由桥实现后第一个版本上线测试时出现了一个偶发问题用户快速点击入口时有时会跳到空白页面既不是跳转失败提示也不是正常页面就是纯白。定位过程比较曲折。最后通过抓取路由栈信息发现白屏场景下目标页面的路由已经入栈但页面内容没有渲染。原因在于动态import是异步的路由桥在import还没完成时就执行了跳转命令页面容器已经创建但模块代码还没有挂载完成。修复方式是给路由跳转加了一个异步屏障确保目标模块加载完成后才真正执行路由跳转。async function pushWithModuleReady(pageId: string, params: Recordstring, string) { const pageRoute RouteBridge.resolve(pageId); if (pageRoute.moduleLoaded) { // 已加载直接跳 router.pushUrl({ url: pageRoute.url, params: params }); } else { // 未加载则等待模块加载完成 await waitForModuleReady(pageRoute.moduleName); router.pushUrl({ url: pageRoute.url, params: params }); } }这个修复之后白屏问题彻底消失。5.3 跨模块数据格式变更的兼容接口库稳定是稳定但不代表永远不改。有一次文本工具模块要对结果格式做升级新增了一个字段同时废弃了一个旧字段。问题出现在升级步骤上。旧版本宿主还在线上占很大比例模块实现如果直接上线会导致旧宿主无法解析新数据格式。新宿主如果直接上线又可能遇到旧模块还在跑。当时的处理是做了双版本兼容。新模块同时输出新旧两个字段旧模块维持旧字段不动。新宿主优选解析新字段解析不到时回退到旧字段。这个兼容期持续了两个版本周期确认旧占比降到足够低之后再去掉旧字段。这套处理思路本身不复杂但能顺利执行的前提是服务注册表里保存了模块版本号。这样宿主才能根据模块版本决定解析策略。6. 这个架构还能往哪个方向延伸Pocket Tool的协作模块在完成基础能力之后后续又逐步扩展了三个方向在这里一并分享出来。第一个方向是模块级别的远程配置能力。现在每个模块在框架层都支持读取远程配置基于配置决定模块的可用性。这带来一个好处某个工具模块如果突然出现严重的线上问题可以直接远程关停该模块而不需要发版本紧急屏蔽。第二个方向是模块级埋点上报。每个模块初始化时上报模块名称、版本、加载耗时框架层统一收集沉淀出各个模块的使用频率和性能数据。这个数据反过来指导模块的优先级排序哪些模块应该继续投入哪些模块基本没人用可以考虑下线一目了然。第三个方向是模块级独立测试入口。框架内置了模块自检页面在开发模式下可以只加载某一个模块单独运行调试不用把整个宿主都跑起来。这对并行开发的效率提升很明显因为开发者不再需要依赖其他人提交的代码才能在本地运行。这套架构的形态在我个人看来非常适合中型以上的工具类应用或者功能聚合型应用。如果只是一个小工具、页面不超过五个这个协作模块的工程值不值得做需要认真评估因为框架本身的复杂度会吃掉那一点点协作收益。但一旦模块数量开始超过十五个团队人数在三个人以上分支协作模块的投入回报比就是正向的了。如果让我再重做一遍这个项目我会在第一天就把服务注册表和初始化顺序这两个事情想清楚。后面绝大部分坑本质上都是这两个基础设计不够完善引出来的。工具应用做协作模块关键不在于代码技巧有多高而在于边界画得清楚节奏定得合理这两件想明白了剩下的事情都是体力活。