最近在群里被问得最多的问题,不是 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 三种载体的定位和差异
我先用一个表格把三者的核心差异列出来,表格里说的是我在实际工程中感知到的用法差异,和官方文档的描述方向一致,但也希望大家结合自己的项目版本去核对:
| 载体 | 产物形式 | 加载方式 | 典型场景 |
|---|---|---|---|
| Module | APK/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 依赖 B,B 又依赖 A”的环。鸿蒙工程的 Gradle 构建确实能检测到循环依赖并报错,但这类错误一旦报出来往往已经迟了。我见过一次非常典型的报错:一个feature-order为了拿到用户昵称去import了feature-mine的页面,而feature-mine又为了进入“订单列表”而import了feature-order,构建直接卡死,查了四五个小时才找到是谁先开的头。
所以我在团队里的死规矩是:依赖只能从上往下沉。上层是业务 feature,下层是基础能力 common,feature 之间不允许互相直接依赖。确实有跨模块的数据需求时,就抽象成 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 操作尽量只依赖 viewmodel;model 是纯数据类型,不要直接在里面塞业务代码。外部如果要跳转到这个模块的某个页面,通过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 里出现 B,B 的 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 模块,也能在团队扩张时让大家用同一套标准做结构演进。
最后再分享一个小技巧:拆完以后别急着开启新功能,先用两天时间试着只改“订单模块里的一个按钮文案”,从头走到尾,感受一下编译、回归和发布链路是否顺滑。如果这一步本身很痛,说明拆分粒度或依赖边界还没有到位,再回头调一调,总比后面带病跑要划算得多。