ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

鸿蒙应用代码结构拆分实战:能力边界、HAR与HSP的选型指南

鸿蒙应用代码结构拆分实战:能力边界、HAR与HSP的选型指南 最近在群里被问得最多的问题不是 ArkTS 语法也不是状态管理而是“鸿蒙 App 的代码结构到底应该怎么拆”。问的人既有刚接触鸿蒙的新人也有从 Android/Flutter 转过来的成熟团队大家的共同痛点是DevEco Studio 新建工程默认只有一个 entry 模块业务一旦多起来几千个文件全堆在一个模块里编译慢、人容易冲突、复用也难。这篇文章就专门聊这个——鸿蒙 App 的代码结构拆分我会先从拆分逻辑讲起再给一套可落地的目录模板最后把跨模块通信、依赖治理和分批重构的坑都摊开说适合正在做鸿蒙应用、又对工程结构越来越头痛的开发者参考。1. 先说结论鸿蒙工程的拆分不是按文件是按“能力边界”很多人拿到工程的第一反应是“我可以把登录、首页、商品详情这些页面放到不同的文件夹里”。文件夹确实能缓解视觉上的混乱但编译还是一个整体依赖关系还是揉在一团。我见过一个项目 entry 模块下塞了八百多个页面文件resources 目录里十几个同名 icon真机编译一次七八分钟。团队四个人并行开发一天至少有三次因为同一个entry.iml上的改动互相覆盖。1.1 一个让人抓狂的真实案例全部塞进 entry 模块之后这个项目一开始只有几十个页面大家觉得“鸿蒙项目和原生项目差不多嘛就按文件夹分呗”。于是出现了entry/src/main/ets/pages/login、pages/order、pages/activity这样整齐的目录。做到半年功能迭代到两百多个页面事情开始失控任何一个页面修改整个应用的所有代码都要参与编译构建时间从二十秒涨到七分钟想让测试只回归订单模块却发现订单模块内还依赖着首页自定义组件谁改谁都会互相影响公共工具类五花八门同一个“时间格式化”方法有三个人各自实现因为谁都不知道另一个封在哪个目录里新人进来想改一个页面要翻完整个工程才能找到对应的 viewModel 和网络请求文件。这些问题不是“代码写得乱”而是从一开始就没有做能力边界的划分。所谓能力边界就是按业务的完整性和变化的频率把系统切成若干个能独立编译、独立演进、独立团队成员负责的部分。1.2 拆分的核心逻辑从“文件夹分类”升级到“业务模块自治”代码结构拆分的本质是让每个业务模块像一个小团队一样自治。它的话事人就是对外暴露的接口内部页面、组件、状态怎么组织都是模块自己的事。模块之间通过接口交互而不是让你在另一个模块里能直接import它的内部页面类。拿一个商城来讲登录、商品列表、购物车、订单、优惠券都可以是独立的业务模块。它们各自维护自己的页面和状态通过“去结算”“跳详情”这类语义话术去通知别的模块而不是直接 new 一个订单页面的对象。这样得到的结构是每个模块有清晰的api区其他业务只能看到这层接口模块内部的改动只要不破坏接口就不会引发连锁反应。这一点也决定了为什么鸿蒙官方推荐 Stage 模型下面的 multi-module 工程而不是把一切都摊在 entry 里。后续我们要讲的 HAR、HSP 都是承载能力边界的载体但在选择载体之前先想清楚这块代码到底属于哪个“能力域”这是最重要的一步。2. 选对拆分载体Module、HAR、HSP 到底怎么用很多人的困惑是“我大概知道要拆但是拆出来的东西应该做成哪种模块”。鸿蒙工程里候选者有Module、HAR和HSP它们名称相近但定位完全不同。选错载体等于没拆甚至可能比不拆更糟糕。2.1 三种载体的定位和差异我先用一个表格把三者的核心差异列出来表格里说的是我在实际工程中感知到的用法差异和官方文档的描述方向一致但也希望大家结合自己的项目版本去核对载体产物形式加载方式典型场景ModuleAPK/SOP 级别的独立模块编译成独立 hap 包可单独打包部署入口模块、平板/手机差异化模块、独立调试模块HAR静态共享包编译期打包进依赖方的模块不独立存在公共工具库、网络层、UI 基础组件、常量定义HSP动态共享包运行时按需加载可分开编译业务功能模块、低频页面、团队并行开发的 feature需要澄清一个很容易混的实践点HAR 是“代码包跟着依赖它的人走”。你有一个common.har首页模块依赖它那么商品模块也用这个 HAR 时它会被打包进商品模块自身。好处是比较简单坏处是如果有十个 feature 模块都依赖这个 HAR那图中会有十份重复产物。HSP 则不同它是独立编译、由系统在运行时去加载和复用能让多个模块共享同一份代码和资源而不是各复制一份。2.2 什么时候用 HAR、什么时候用 HSP、什么时候直接建 Module我的选择标准是这四类判断稳定到不经常变的基础能力如网络框架、日志、工具方法、设计规范里的基础组件用 HAR。它们被所有业务依赖变了会引发全量重编最好保持相对稳定并愿意承担下沉成本。不常变、被多个模块复用的业务逻辑比如会员等级计算、优惠券状态机如果不想打进每个 feature也可以放在 HAR 里但这时要严格管理它的依赖半径避免悄悄引入某个 feature 的内部内容。变化频繁、团队边界清晰的业务板块如首页信息流、商品详情、订单中心用 HSP。HSP 的优势是某个业务团队可以独立改版、独立出包整体包体也能因为按需加载而受益。需要面对不同设备或不同分发策略的模块例如 Pad 大屏版、车机版、关键的登录权限模块则直接建独立 Module放到工程根目录它的出包和多态能力更匹配这类场景。一个工程里同时存在 HAR、HSP 和多个 Module 是完全正常的。反而只在 entry 和 library 两个名字之间做选择往往是压根没想清楚能力边界。2.3 依赖关系的方向控制载体选完另一个大头是依赖关系。模块拆分最怕出现“A 依赖 BB 又依赖 A”的环。鸿蒙工程的 Gradle 构建确实能检测到循环依赖并报错但这类错误一旦报出来往往已经迟了。我见过一次非常典型的报错一个feature-order为了拿到用户昵称去import了feature-mine的页面而feature-mine又为了进入“订单列表”而import了feature-order构建直接卡死查了四五个小时才找到是谁先开的头。所以我在团队里的死规矩是依赖只能从上往下沉。上层是业务 feature下层是基础能力 commonfeature 之间不允许互相直接依赖。确实有跨模块的数据需求时就抽象成 service 接口放到 common 层由某个 feature 实现再通过统一的依赖注入或容器去获取。这样无论模块边界怎么切依赖图永远是张有向无环图你甚至可以画出来贴在显示器边上谁去越界一目了然。3. 一套可落地的目录结构模板理论讲完直接给模板。这个结构我经过两个商业项目的验证直接复制到 DevEco Studio 的工程根目录再把代码慢慢挪进去过程中会有阵痛但挪完之后的收益非常大。它不是一个官方标准而是一个基于“能力边界 单向依赖”的实践模板。3.1 顶层工程视角一个典型的鸿蒙应用顶层目录设计如下AppScope/ # 应用全局配置如 app.json5 entry/ # 应用入口只做启动、路由挂载、全局容器管理 common/ ├── utils/ # 纯工具函数不依赖任何单模块业务 ├── network/ # 网络层封装通用请求response 模型 ├── storage/ # 首选项、数据库封装 ├── ui/ # 基础组件如按钮、空态、骨架屏 └── constants/ # 全局常量、枚举、通道名称 features/ ├── feature-home/ ├── feature-order/ ├── feature-mine/ ├── feature-login/ └── feature-commodity/ services/ # 业务服务接口被动依赖实现方 ├── authService.ts └── cartService.ts libs/ # 第三方 static library 或本地管理的 HAR可能有人会问为什么把services单独提出来而不是放进 common我的做法是common层保持“不戴业务帽子”而services这一层专门放业务模块之间共同依赖的接口。比如登录态、购物车数量它们是跨模块的高频业务直接放 common 会把业务味道注入基础层长期下去 common 会变成大杂烩。3.2 一个 Feature 模块内部怎么组织每一个 feature 内部也有一套自己的结构而不是让它继续长成大文件夹。以feature-order为例feature-order/ ├── src/main/ets/ │ ├── api/ # 对外暴露的接口和门面类 │ ├── pages/ # 页面列表 │ ├── components/ # 仅本模块使用的组件 │ ├── viewmodel/ # 状态和状态管理 │ ├── model/ # 数据模型 │ └── service/ # 本模块内部的网络请求、缓存逻辑 └── src/main/resources/ # 本模块自己的字符串、颜色、图片等这样一个 feature 里的.ets文件量级通常控制在几十个到一百多个。页面的 UI 操作尽量只依赖 viewmodelmodel 是纯数据类型不要直接在里面塞业务代码。外部如果要跳转到这个模块的某个页面通过api里暴露的路由常量去发起而不是直接import feature-order的页面类。说得直白点feature 模块的外部可见面只保留api目录和路由表其他目录全部私有这才是“自治”的意义。3.3 公共能力下沉common 层应该放什么、不该放什么公共层不是垃圾桶随便什么东西都往里丢才叫下沉。我给自己定的清单是该放时间格式化、字符串处理、设备信息、网络请求封装、基础存储封装、主题 token、通用弹窗组件。不该放某个页面特有的组件、只被单一业务使用的请求、带本地业务语义的数据结构比如“订单状态枚举”放feature-order内部放 common 会耦合订单语义。资源也要拆分。鸿蒙的resources目录在模块内各自维护这非常重要。entry和各个 feature 都有自己的string.json、颜色、图标。遇到重名资源时构建期会有优先级覆盖为了不出现“开发同事设置了主色结果把首页图标也改了”的诡异现象公共用色和字号放 common 层的 design token各 feature 里尽量引用这些规范资源而不是重新定义一套。3.4 资源与国际化文件的拆分策略资源拆分最容易漏的是国际化。我的做法是common层只维护通用文案比如“确定”“取消”“网络错误”各业务 feature 维护自己的高频文案而国家地区的差异基准放在最外层配置里。这样业务文案改动不用惊动基础库的国际化配置文件新增一种语言时也只改 common 层就能覆盖大部分页面剩下遗漏的再按 feature 逐个补齐。很多项目拆模块失败不是因为代码难拆而是资源文件互相引用到最后变成了蜘蛛网所以拆分时把资源的边界同步定下来才是完整结构拆分的最后一块拼图。4. 跨模块通信拆完之后最头疼的问题模块拆分完成后开发者最大的不适是“我明明要在订单页展示购物车数量但我又不能直接 import 购物车模块的东西”。跨模块通信一旦没有统一套路大家就会通过全局变量和硬依赖临时解决问题没过多久又变回一个逻辑上的大泥球。4.1 路由解耦Navigation 路由表的用法鸿蒙 NEXT 时代页面导航基本已经是 Navigation 的天下。我推荐的做法是每个 feature 模块在自己的api里定义一个路由表例如OrderRoutes.ts里面是跳转页面需要的常量字符串和参数名。在工程容器层entry 的某个初始化文件把所有 feature 的路由 builder 统一注册到 Navigation 的 routeTable 上。页面跳转时只传路由名和参数不 touch 页面类本身。这样做的两个收益是业务模块彻底不感知目标页面在哪个 feature只要路由名约定好未来页面搬家或换成重定向都不需要改动调用方路由表集中注册可以在编译期跑一个静态检查防止重复路由名也便于将来做路由拦截或登录态判断。4.2 状态共享别再让每个模块都去引用 ViewModel跨页面共享状态比如登录态、购物车角标是很多人的第一反应是“我把 ViewModel 放到公共层不就行了”。这个方案带来的是状态源不清晰谁能改谁能读全局状态多到一定程度你根本不知道从哪个页面改乱了。我的实践方案是公共层只放一个状态容器接口由业务模块或专门的服务模块去实现。比如购物车数量在 services 里定义CartService包含getCount()、addToCart()等方法feature-order调用它来显示数量具体实现由feature-home的 viewmodel 内部维护并通过容器注册。这样状态的归属方明确又不用把整个 ViewModel 暴露给所有模块。还有一部分轻量级的 UI 状态可以用系统提供的StorageLink或AppStorage来解决。但我对这件事的态度是能用 AppStorage 放几个简单字段不建议把所有业务状态都塞进去一旦塞进去的库多了调试成本会急剧上升。它适合放“主题模式”“用户 ID”这种全局小数据不适合放复杂列表和业务快照。4.3 事件总线 vs 接口注入我的选择标准跨模块动作也需要区分两个方向。一个方向是“通知”比如退出登录以后各个模块要清除自身状态另一个方向是“请求”比如结算页要拿到收货地址。我自己的选择标准很简单如果是网页页面发生时不必关心结果、也没有返回值的事件用系统 Emitter 或轻量事件总线做低耦合广播如果调用方需要对方返回数据或必须知道执行结果用接口注入。定义一个AddressProvider接口由负责地址的模块实现并注册调用方通过服务容器获取实现并调用。这样编译期仍无具体依赖运行期却有明确交互协议。需要特别警惕的是“为了跨模块方便在接口方法里塞一堆基础对象”导致服务接口长达十几个参数。我见过一个OrderCreateService接口有二十个参数最后谁都不愿意调用它。接口的粒度最好贴近业务语义参数可以聚合为 model 对象返回值也尽量封闭成自己的类型。5. 拆分过程中的依赖治理与编译优化很多团队拆完了结构却发现构建时间反而更长了还有人会见到一些莫名其妙的产物重复。这通常是依赖和载体选型没合理导致的。模块拆分的收益不会凭空产生它需要依赖治理作为前提。5.1 依赖环为什么会出现怎么用依赖检查工具发现依赖环的前期征兆往往是“某个模块页面调另一个模块页面”越来越多。你可以在工程根目录跑一个脚本把每个模块的build-profile.json5里的 dependencies 读出来生成一张模块依赖表人工画一遍依赖图。一旦发现 A 的 dependencies 里出现 BB 的 dependencies 里又出现 A就当天的核心问题处理掉。更正规一点的做法是在 CI 里加一个检查任务解析模块间的依赖关系如果检测到环就让构建失败。工具上可以直接用现有 build 插件或者写一个简单的 Python 脚本去扫build-profile.json5。别觉得写脚本麻烦我算过所有人手工维护依赖规则的效果远不如一次构建失败来得有威慑力。这部分投入我觉得完全值得。5.2 按需加载HSP 延迟加载的实践要点用 HSP 最大的收获取决于你有没有真正“按需”。有些团队为了统一风格把所有页面模块都挂在入口模块下启动时全部加载那和没拆没有区别。正确的做法是entry模块里只启动feature-home和通用的基础 HAR订单、优惠券等低频页面全部通过动态加载的方式在跳转时拉取。HSP 的按需加载能力会依赖具体的 API 接口不同 API 版本命名可能有差异所以这里我不贴具体接口只说落地上要抓的两条一是不要在entry的初始化方法里提前加载所有动态模块二是要给每个动态模块设计“加载中/失败重试”这一层的 UI 体验否则切到订单页会冻住一两秒让用户以为是卡死了。实测下来一个总包五十多兆的 App把十个不常用 feature 转成动态 HSP 以后首包能瘦五到八兆冷启动时间也能有可感知的改善。5.3 拆完之后的编译时间对比与缓存利用有人以为模块越多编译越快真实情况是模块边界合理的时候才会快。我经历的一版重构后模块从 1 个增加到 7 个首次全量编译反而比以前多了两分钟因为新增了很多模块配置和依赖解析。但增量编译肉眼可见地变快以前改一个详情页要带着整个 entry 重编现在只重编feature-commodity和依赖它的 final assemble一半时间省掉了。这里还涉及一个大家容易轻视的地方DevEco Studio 的构建缓存。模块拆开之后尽量保持模块内的改动是局部的这样构建缓存命中的概率更高。尤其避免“小改动触碰大量公共接口”否则会让上游所有模块失效重编。所以我会给团队立规矩公共 HAR 的接口变化要集中发布不要一天改三次业务 feature 的api目录变更同样要走 commit 评审这类变更是“重编译炸弹”。6. 拆分的分阶段实施路径避免一次性重构最后聊聊“怎么落地”。我最担心的是有人看完前几节当天就把工程全量拆了个遍一周之后因为回归测试过不了、业务又急着发版不得不回滚。真实项目里正确路径是逐步进行用滚雪球的方式把结构盘活。6.1 第一步从“入口模块瘦身”开始先用一个版本周期的时间把entry里和“启动”无关的代码全部识别出来。这些代码包括已经被多个页面复用的工具类、独立完整的业务页面集合、不依赖全局状态的组件。行动时只做“物理搬移”而不要顺手重构把页面完整复制到 feature 模块里再在 entry 保留一个路由转发入口引导完成、页面跳转正常后再删掉旧文件。这一步尽量不做逻辑修改回归面会小很多。6.2 第二步先拆边界清晰的基础能力第二步挑最稳的“基础能力”开刀。网络封装、存储封装、日志封装这些模块几乎没有业务依赖把它们抽成 HAR 的回报最大风险又最小。抽完以后记得写清楚这个 HAR 的对外 API 文档哪怕只是简单的 README。基础层接口文档缺失是拆完以后团队误用和重复封装的源头。6.3 第三步按业务稳定性评估 feature 拆分节奏第三步才轮到 feature 拆分而拆分顺序不要按“页面上离用户最近的先拆”要按“业务变更频率”判断。频繁改、频繁出问题的模块比如运营活动页是最值得拆的因为它最容易从并行开发中获益稳定的模块可以后面再拆甚至只要边界清晰晚拆也不至于伤筋动骨。这个顺序可以让团队在早期就感受到拆分收益——哪怕只是“改活动页不用再全量编译”协作体验的提升也会鼓励大家把后续迁移坚持下去。6.4 第四步回归验证与团队约定每一步拆分后都要有明确的回归标准原页面功能测试通过、构建产物体积变化在预期内、依赖图无新增环、资源同名冲突不超过 5 处。拆分过程中文档比代码更重要。我会在工程根目录放一份ARCHITECTURE.md里面写清模块清单、依赖方向、路由注册位置、服务容器用法。这份文档不用很长但它能阻止下一个新人把代码写回 entry 模块也能在团队扩张时让大家用同一套标准做结构演进。最后再分享一个小技巧拆完以后别急着开启新功能先用两天时间试着只改“订单模块里的一个按钮文案”从头走到尾感受一下编译、回归和发布链路是否顺滑。如果这一步本身很痛说明拆分粒度或依赖边界还没有到位再回头调一调总比后面带病跑要划算得多。
返回列表