该应用已上架鸿蒙应用商店,欢迎各位下载尝鲜、吐槽!拜谢!
系列第 14 篇。上一篇解决的是 Stage 生命周期如何把前后台信号传给听书页面;这一篇换到工程组织层面,讨论一个更长期的问题:当三国志 App 的人物、事件、地图、听书、收藏、备份都堆到一起后,哪些代码应该留在 entry,哪些应该下沉到共享库,哪些适合成为业务模块?

首页模块入口

一、真实问题背景:页面能跑不等于工程边界清晰

《耳畔三国·将星落》最早可以只靠一个入口页面完成原型:首页、人物、事件、地图、收藏、听书都写在一起,调试速度很快。但当功能进入第二轮维护后,问题开始变得明显:

问题 单体写法的表现 长期风险
页面入口和业务 UI 混在一起 entry 既负责启动又承载复杂页面 后续接入备份、评论、生命周期时入口层越来越重
模型和主题散落在页面里 PersonAudioRecord、主题 token 被多处引用 新功能扩展时容易复制类型
mock 内容和页面渲染耦合 数据数组跟 UI 状态写在同一层 后续替换本地内容源成本高
依赖方向不清楚 公共类型反过来引用业务页面 多模块构建容易出现循环依赖

所以第 14 篇不讲单个功能,而是复盘当前项目为什么拆成 entrylibrary1library2,以及这个拆分对后续维护到底有没有价值。

本文基于当前 HarmonyOS NEXT / ArkTS 工程实测,源码对象集中在:

build-profile.json5
entry/oh-package.json5
library1/Index.ets
library2/Index.ets
entry/src/main/ets/pages/Index.ets
library2/src/main/ets/pages/MainFrame.ets

先用 rg 定位真实依赖关系:

rg -n "from 'library1'|from 'library2'|export \{|MainFrame|MockRecords" entry library1 library2 -g "*.ets"

本项目的关键命中结果是:

entry/src/main/ets/pages/Index.ets:1:import { MainFrame } from 'library2';
library2/Index.ets:1:export { MainFrame } from './src/main/ets/pages/MainFrame';
library2/Index.ets:2:export { MockRecords } from './src/main/ets/data/MockRecords';
library2/src/main/ets/pages/MainFrame.ets:1:import { AppColors, AppFontSize, AppRadius, AppSpacing, AppTheme, AudioRecord, Faction, FavoriteRecord, HistoryEvent, MapMarker, NoteRecord, Person } from 'library1';
library2/src/main/ets/data/MockRecords.ets:1:import { AudioRecord, Faction, FavoriteRecord, HistoryEvent, MapMarker, NoteRecord, Person } from 'library1';
library1/Index.ets:1:export { AppColors, AppFontSize, AppRadius, AppSpacing, AppTheme } from './src/main/ets/theme/AppTheme';
library1/Index.ets:2:export { Person, HistoryEvent, Faction, FavoriteRecord, NoteRecord, AudioRecord, MapMarker, ArticleRecord } from './src/main/ets/models/RecordsModels';

这说明当前依赖方向是:

entry -> library2 -> library1

只要这条方向保持单向,入口、业务页面、公共模型就不会互相拖拽。

二、模块清单:build-profile 只公开 modules,不公开签名材料

项目根目录的 build-profile.json5 同时包含签名配置和模块列表。公开文章里只适合展示模块声明,不应该粘贴签名证书路径、密码、profile 等敏感字段。

可公开的模块声明是这一段:

"modules": [
  {
    "name": "entry",
    "srcPath": "./entry",
    "targets": [
      {
        "name": "default",
        "applyToProducts": [
          "default"
        ]
      }
    ]
  },
  {
    "name": "library1",
    "srcPath": "./library1"
  },
  {
    "name": "library2",
    "srcPath": "./library2",
    "targets": [
      {
        "name": "default",
        "applyToProducts": [
          "default"
        ]
      }
    ]
  }
]

我把三个模块拆成下面的职责:

模块 当前职责 不应该放什么
entry 应用入口、Ability、备份扩展、最终页面挂载 大量业务 UI、mock 内容、主题模型
library1 主题 token、数据模型、路由常量等基础能力 依赖页面、调用系统 UI 能力、引用 library2
library2 三国志业务页面、mock 数据、听书/收藏/地图交互 应用签名配置、Ability 生命周期入口

这不是为了“模块多就高级”,而是为了让每层变更的理由不同。

三、entry:只做入口挂载,不承载业务页面细节

entry/src/main/ets/pages/Index.ets 现在非常薄:

import { MainFrame } from 'library2';

@Entry
@Component
struct Index {
  build() {
    Column() {
      MainFrame();
    }
    .width('100%')
    .height('100%')
  }
}

这一层的价值不是代码多,而是边界清楚。entry 负责让应用启动起来,并把真实主界面挂进去。备份扩展、生命周期回调、模块元信息也属于入口模块;但人物列表怎么筛选、听书如何分段、收藏怎么持久化,不应该反向塞回 entry

如果以后要加启动页、隐私弹窗、账号态或应用级路由,entry 可以继续承接入口级逻辑;如果只是业务 Tab 里的页面状态,仍然应该留在 library2

四、library2:业务功能模块承载页面和本地内容

entry 依赖 library2 的方式写在 entry/oh-package.json5

{
  "name": "entry",
  "version": "1.0.0",
  "description": "Records of the Three Kingdoms entry module.",
  "dependencies": {
    "library2": "file:../library2"
  }
}

library2 自己再依赖 library1

{
  "name": "library2",
  "version": "1.0.0",
  "description": "Records of the Three Kingdoms feature module.",
  "main": "Index.ets",
  "dependencies": {
    "library1": "file:../library1"
  }
}

library2/Index.ets 对外只暴露业务入口:

export { MainFrame } from './src/main/ets/pages/MainFrame';
export { MockRecords } from './src/main/ets/data/MockRecords';

MainFrame.ets 里,业务页面使用 library1 的模型、主题和数据类型:

import {
  AppColors,
  AppFontSize,
  AppRadius,
  AppSpacing,
  AppTheme,
  AudioRecord,
  Faction,
  FavoriteRecord,
  HistoryEvent,
  MapMarker,
  NoteRecord,
  Person
} from 'library1';
import { ArticleRecord } from 'library1';
import { MockRecords } from '../data/MockRecords';

这说明 library2 的职责是“把公共模型变成可交互页面”。例如首页快捷入口、人物详情、事件索引、地图、听书、收藏和设置入口都属于这一层。它可以引用 AppGalleryKit、CoreSpeechKit、AVSessionKit 这类业务能力,但不应该让 library1 反过来知道这些页面实现。

五、library1:公共模型和主题的稳定层

library1/Index.ets 的导出非常关键:

export { AppColors, AppFontSize, AppRadius, AppSpacing, AppTheme } from './src/main/ets/theme/AppTheme';
export {
  Person,
  HistoryEvent,
  Faction,
  FavoriteRecord,
  NoteRecord,
  AudioRecord,
  MapMarker,
  ArticleRecord
} from './src/main/ets/models/RecordsModels';
export { Routes, RouteParams } from './src/main/ets/routes/Routes';

这里导出的都是“业务页面会用,但不依赖具体页面”的对象。比如 AudioRecord 只是描述听书条目,不关心 TTS 如何播放;AppTheme 只是提供色彩、间距、字号,不关心首页卡片如何排版;Routes 只是路由常量,不关心页面跳转按钮放在哪里。

AudioRecord 为例:

export class AudioRecord {
  id: string = '';
  targetId: string = '';
  title: string = '';
  durationText: string = '';
  durationSeconds: number = 0;
  listenedSeconds: number = 0;

  constructor(id: string, targetId: string, title: string, durationText: string,
    listenedSeconds: number, durationSeconds: number = 0) {
    this.id = id;
    this.targetId = targetId;
    this.title = title;
    this.durationText = durationText;
    this.durationSeconds = durationSeconds;
    this.listenedSeconds = listenedSeconds;
  }
}

这个类型可以被听书页、收藏页、备份边界和后续内容扩展共同引用。它放在 library1,比放在 MainFrame.ets 里更稳定。

六、模块依赖图:单向依赖比模块数量更重要

下面这张图是本篇的核心,不是为了展示目录很多,而是确认依赖不会绕回来。

模块依赖关系

当前结构可以用一句话概括:

entry 只知道 library2,library2 只知道 library1,library1 不知道上层模块。

这种单向依赖带来三个好处:

好处 具体体现
入口层稳定 EntryAbility、备份扩展、最终挂载不会被页面细节污染
公共层可复用 模型、主题、路由可以被业务模块和后续测试复用
业务层可扩展 MainFrame 可以继续增长功能,但不影响 entry 的启动职责

反过来,如果 library1 开始 import library2,或者公共模型里开始调用页面方法,就说明拆分边界已经失效。

七、调试命令:用源码和构建一起验边界

检查多模块拆分时,我不会只看目录,而是用命令确认三件事。

第一,确认模块注册:

rg -n '"name": "entry"|"name": "library1"|"name": "library2"|srcPath' build-profile.json5

第二,确认包依赖方向:

rg -n '"library1"|"library2"|dependencies' entry/oh-package.json5 library2/oh-package.json5

第三,确认 ArkTS import 没有反向依赖:

rg -n "from 'library1'|from 'library2'" entry library1 library2 -g "*.ets"

如果要做构建验证,可以继续跑 Hvigor:

$env:DEVECO_SDK_HOME = 'D:\HuaweiDevelopFormalStudy\DevEco Studio\sdk'
$env:Path = 'D:\HuaweiDevelopFormalStudy\DevEco Studio\jbr\bin;D:\HuaweiDevelopFormalStudy\DevEco Studio\sdk\default\openharmony\toolchains;D:\HuaweiDevelopFormalStudy\DevEco Studio\tools\node;' + $env:Path
& 'D:\HuaweiDevelopFormalStudy\DevEco Studio\tools\hvigor\bin\hvigorw.bat' assembleHap --mode module -p product=default --no-daemon

本篇只新增文档和发布素材,不改 ArkTS 源码;如果你在项目中移动真实代码,构建验证就必须执行。

八、问题复盘:拆分不是越早越好,也不是越多越好

这次复盘后,我会把模块拆分的判断标准归纳成四条:

判断问题 适合拆到哪里
这个对象是否不依赖页面生命周期? library1
这个对象是否承载具体业务交互? library2
这个对象是否只在应用启动、Ability 或扩展声明里有意义? entry
这个对象是否包含签名、发布、构建环境信息? 只留配置,不进入公共文章正文

当前项目选择 library1 放模型和主题,是因为这些对象被首页、详情、收藏、听书、地图共同消费。选择 library2MainFrameMockRecords,是因为它们已经包含业务组织方式:哪些 Tab、哪些内容、哪些交互入口,都不是纯模型。

如果在很早期就强行拆十几个模块,反而会让每次改一个字段都跨模块跳转。对这个三国志 App 来说,三层已经够用:入口层、公共基础层、业务功能层。

九、失败模式:这些拆法会让维护更难

失败模式 表面收益 实际问题
把所有页面都放进 entry 初期路径短 Ability、页面、数据、主题全部耦合
MainFrame 下沉到 library1 看起来复用更多 公共库反而依赖业务 UI,边界倒置
每个 Tab 拆一个模块 模块名更细 当前规模下跨模块改动成本大于收益
公开文章粘贴完整 build-profile 代码看起来完整 容易泄露签名路径和密码字段
公共模型直接调用系统 Kit 写业务方便 后续测试和复用会被系统能力绑定

最容易被忽略的是第四点。很多工程文章为了完整,直接贴完整配置文件;但 HarmonyOS 项目的构建配置可能包含证书路径、profile 和签名密码。公开分享时必须裁剪,只讲模块结构,不暴露签名材料。

十、验收清单

验收项 通过标准
模块声明 build-profile.json5 中存在 entrylibrary1library2
依赖方向 entry -> library2 -> library1,没有反向 import
入口页面 entry/src/main/ets/pages/Index.ets 只挂载 MainFrame
公共导出 library1/Index.ets 导出主题、模型和路由
业务导出 library2/Index.ets 导出 MainFrame 和业务数据入口
敏感信息 公开文章不粘贴签名密码和证书路径
构建验证 移动源码后 Hvigor 能通过
后续扩展 新功能能判断放入入口层、公共层还是业务层

十一、边界与后续演进

当前拆分仍然不是终点。

如果后续把内容数据从 mock 改成更完整的本地 JSON 或数据库,MockRecords 可以继续留在 library2,但数据加载器可能需要单独抽出来。原因是数据加载器会连接内容源、缓存和搜索索引,它不一定属于页面。

如果后续增加可复用组件库,例如统一空状态、列表项、详情页标题栏,可以考虑放到 library1/src/main/ets/components。但前提是组件不依赖 MainFrame 的内部状态,否则仍然应该留在 library2

如果后续引入更多原子服务或独立入口,entry 的职责会更重要:它要决定不同入口挂载哪个业务能力,而不是让每个业务模块自己操作 Ability。

十二、小结

这次多模块拆分的核心结论是:模块边界要服务真实变更,不要只服务目录美观。

在当前三国志 App 里,合理的边界是:

entry: 应用入口、Ability、扩展声明、最终挂载
library1: 主题 token、数据模型、路由常量等稳定基础层
library2: 业务页面、本地内容、听书/收藏/地图/评论等交互能力

只要依赖方向保持 entry -> library2 -> library1,后续做资源体系、搜索、内容模型扩展、深浅色跟随系统时,代码就有明确落点。

下一篇会继续换到资源体系,讨论应用图标、启动图、功能图和宣发截图如何保持一致:哪些图片应该进 resources/base/media,哪些只适合放在 doc/generated_images 做发布素材。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐