鸿蒙项目首页启动链路与 ArkUI 架构学习总结
日期:2026-05-26
主题:公司鸿蒙项目启动入口、Ability 生命周期、HMRouter 首页承接、ArkUI 状态管理 V2、首页分层架构理解
一、今天主要解决了什么问题
今天的重点不是继续写聊天 demo,而是回到公司真实项目里,先把应用是怎么启动、页面是怎么被加载、首页是怎么被路由找到的这条主线理清楚。
一开始看项目的时候,代码量比较大,页面、模块、路由、业务层、组件层都分散在不同目录里。如果直接看首页 UI,很容易只看到一堆组件和业务代码,但是不知道它们到底是从哪里被启动起来的。
所以今天先从项目入口开始拆,最终确认了当前项目的启动链路:
text
entry/src/main/module.json5
↓
EntryAbility.ets
↓
onCreate()
↓
onWindowStageCreate()
↓
windowStage.loadContent('pages/MainPage')
↓
entry/src/main/ets/pages/MainPage.ets
↓
HMNavigation
↓
homePageUrl: LushuPageConstant.HomePage
↓
yuanzhou_home/src/main/ets/pages/MainTabPage.ets
↓
首页 Tab 组件
也就是说,module.json5 里配置的 srcEntry 只是找到真正的 Ability 入口,真正把页面加载出来的是 EntryAbility 里的 onWindowStageCreate(),再通过 windowStage.loadContent('pages/MainPage') 进入主页面。之后 MainPage.ets 里面不是直接写首页 UI,而是通过 HMNavigation 和 homePageUrl 交给 HMRouter 去找真实首页。
今天已经把这个链路跑通了,并且通过替换首页组件,让首页显示出了一个简单的"你好"。虽然 UI 很简单,但是这个结果很关键,因为它证明了:
text
1. 应用入口没有找错;
2. EntryAbility 能正常加载 MainPage;
3. MainPage 里的 HMNavigation 是生效的;
4. LushuPageConstant.HomePage 对应的首页路由是有效的;
5. 自己替换进去的新首页组件已经成功接管首页位置。
这一步跑通之后,后面不管是还原首页,还是做公司真实需求,都不会再停留在"这个页面到底从哪里来"的阶段。
二、module.json5 和 srcEntry 的作用
在 HarmonyOS 项目里,module.json5 是模块配置文件。今天重点关注的是里面的 srcEntry:
json
{
"module": {
"srcEntry": "./ets/entryability/EntryAbility.ets"
}
}
这行配置的意思是:当前模块真正的 Ability 入口文件是:
text
entry/src/main/ets/entryability/EntryAbility.ets
以前容易误解为页面入口就是某个 pages/Index.ets,但公司项目不是这样。真实启动流程是先启动 Ability,再由 Ability 创建窗口,再加载页面。
可以理解成:
text
module.json5 负责告诉系统:入口 Ability 在哪里
EntryAbility 负责告诉系统:窗口创建后加载哪个页面
MainPage 负责告诉路由容器:默认首页路由是哪一个
HMRouter 负责根据 pageUrl 找到真正的业务页面
这个理解很重要,因为以后遇到任何鸿蒙项目,不能一上来就找 @Entry 页面,而是要先看 module.json5 和 Ability 配置。
三、EntryAbility 生命周期理解
今天重点看了 EntryAbility.ets,核心关注两个生命周期方法:
ts
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// Ability 创建时执行
}
onWindowStageCreate(windowStage: window.WindowStage): void {
// WindowStage 创建时执行
windowStage.loadContent('pages/MainPage')
}
结合华为官方文档的描述,UIAbility 启动时系统会依次触发 onCreate()、onWindowStageCreate()、onForeground() 等生命周期回调。onCreate() 更偏向 Ability 初始化阶段,onWindowStageCreate() 则是在窗口舞台创建完成之后执行,一般会在这里加载入口页面。
今天在公司项目里看到的情况也正好对应这个过程:
text
onCreate()
- 初始化全局配置
- 初始化登录态
- 初始化缓存、主题、网络
- 初始化 HMRouterMgr
- 初始化埋点、隐私、卡片等全局能力
onWindowStageCreate()
- 初始化窗口
- 设置窗口相关配置
- 初始化页面转场动画
- windowStage.loadContent('pages/MainPage')
这里要注意一个细节:
onCreate() 并不直接显示 UI,它更像是应用启动时的全局初始化入口。真正把页面挂到窗口上的是 onWindowStageCreate()。
所以以后分析启动问题,可以按这个顺序排查:
text
1. module.json5 的 srcEntry 是否正确;
2. EntryAbility 是否正常执行 onCreate;
3. onWindowStageCreate 是否执行;
4. loadContent 的页面路径是否正确;
5. 被加载的 MainPage 是否有 @Entry;
6. MainPage 内部的路由容器是否正确配置首页路由。
四、MainPage 为什么不是直接首页
项目里 EntryAbility 加载的是:
ts
windowStage.loadContent('pages/MainPage')
从名字看,容易以为 MainPage 就是首页 UI。但实际看下来,MainPage 更像是一个应用级导航容器页面。
它里面核心不是首页业务,而是类似这样的结构:
ts
@Entry
@ComponentV2
struct MainPage {
build() {
HMNavigation({
homePageUrl: LushuPageConstant.HomePage
})
}
}
这里的关键是:
text
MainPage 只是入口容器;
首页真实内容由 homePageUrl 决定;
HMNavigation 会根据 pageUrl 找到被 @HMRouter 注册的页面。
也就是说,MainPage 不直接关心首页长什么样,它只告诉路由框架:
text
默认首页是 LushuPageConstant.HomePage
然后真正的首页页面,是 yuanzhou_home/src/main/ets/pages/MainTabPage.ets 通过 @HMRouter 注册上的。
这就是公司项目和普通 demo 最大的区别:
普通 demo 可能是:
text
EntryAbility -> Index.ets -> UI
公司项目是:
text
EntryAbility -> MainPage -> HMNavigation -> HMRouter -> 业务模块页面
这种方式更适合大型项目,因为页面分布在不同模块里,不能靠一个固定的 pages/Index 管全部页面。
五、HMRouter 首页承接关系
首页路由的核心是:
ts
@HMRouter({ pageUrl: LushuPageConstant.HomePage, singleton: true })
@ComponentV2
export struct MainTabPage {
build() {
// Tabs 首页容器
}
}
LushuPageConstant.HomePage 对应的是一个固定的页面地址,例如:
text
page://Lushu/HomePage
整个过程可以理解成:
text
HMNavigation
↓
homePageUrl: page://Lushu/HomePage
↓
HMRouter 路由表中查找
↓
找到 @HMRouter({ pageUrl: page://Lushu/HomePage }) 注册的页面
↓
渲染 MainTabPage
今天实际操作里,我把原来首页 Tab 中的内容替换成了自己的新组件,然后页面成功显示"你好"。这个结果说明:
text
MainTabPage 的路由注册是生效的;
MainPage 的 homePageUrl 没有问题;
自定义组件能被 MainTabPage 正常挂载。
这里也踩到了一个关键点:
同一个 pageUrl 不要同时注册两个页面。
如果原来的 MainTabPage 已经注册了:
ts
@HMRouter({ pageUrl: LushuPageConstant.HomePage })
就不要再新建一个页面也注册同一个 pageUrl,否则可能出现路由冲突,或者框架只识别其中一个,结果不稳定。
更稳的做法是:
text
保留原来的 MainTabPage 路由注册;
只替换 MainTabPage 里首页 TabContent 的组件;
用自己的 DemoTabHomeComp 接管首页区域。
这样既不会破坏启动链路,也方便回退。
六、为什么今天只显示"你好"也是有价值的
今天最后页面只显示了一个"你好",但这不是无效工作。
因为在一个大型项目里,最难的第一步不是写 UI,而是先确认自己写的东西能不能进入真实页面链路。
"你好"跑通,代表下面这条链路已经完全打通:
text
module.json5
↓
EntryAbility
↓
MainPage
↓
HMNavigation
↓
LushuPageConstant.HomePage
↓
MainTabPage
↓
自定义首页组件
↓
屏幕显示
后面无论是写首页 UI,还是做真实需求,都可以复用这个思路:
text
先找入口
再找路由
再找页面
再找组件
再找 ViewModel / Controller / Biz
最后再改 UI 或业务逻辑
这个思路比直接在项目里乱搜页面名更靠谱。
七、ArkUI 声明式 UI 的理解
HarmonyOS 使用 ArkUI 声明式 UI 开发框架。官方文档里对 ArkUI 的定位是:它是一套用于构建应用界面的声明式 UI 框架,提供简洁的 UI 语法、丰富的 UI 组件以及预览能力。
今天实际看到的页面代码基本都是这种形式:
ts
@ComponentV2
export struct DemoTabHomeComp {
build() {
Column() {
Text('你好')
.fontSize(20)
.fontColor(Color.Black)
}
.width('100%')
.height('100%')
}
}
这种写法和传统命令式 UI 不一样。它不是一步步调用 API 去创建 View、设置属性、添加到父容器,而是在 build() 里声明页面结构:
text
Column 里面有什么;
Text 显示什么;
组件宽高是多少;
字体颜色是多少;
点击事件绑定什么函数。
可以理解为:
text
开发者描述 UI 应该长什么样;
ArkUI 框架根据状态变化自动重新渲染对应区域。
今天用到或者看到比较多的组件有:
text
Column 纵向布局
Row 横向布局
Stack 层叠布局
Scroll 滚动容器
Tabs 标签页容器
Text 文本组件
Image 图片组件
Grid 网格组件
ForEach 列表渲染
这些组件基本构成了首页 UI 的基础。
八、@Entry、@ComponentV2、build 的关系
今天看到的主页面里有 @Entry,业务组件里更多是 @ComponentV2。
可以这样理解:
text
@Entry
表示这个自定义组件是页面入口组件,一个页面文件通常只有一个 @Entry。
@ComponentV2
表示这是一个 ArkUI 自定义组件,可以被其他组件引用和组合。
build()
是组件的 UI 描述函数,页面最终显示什么,主要由 build 返回的组件树决定。
例如:
ts
@Entry
@ComponentV2
struct MainPage {
build() {
HMNavigation({
homePageUrl: LushuPageConstant.HomePage
})
}
}
这里 MainPage 是被 windowStage.loadContent('pages/MainPage') 加载出来的入口页面,所以它需要 @Entry。
而业务首页组件类似:
ts
@ComponentV2
export struct DemoTabHomeComp {
build() {
Text('你好')
}
}
它本身不是页面入口,只是被 MainTabPage 组合进去,所以不需要 @Entry。
今天一个重要收获是:
text
不是每个能显示 UI 的文件都需要 @Entry;
只有被 loadContent 作为页面入口加载的页面才需要 @Entry;
普通业务组件用 @ComponentV2 即可。
九、@Builder 的作用
公司项目里的页面通常不会把所有 UI 都堆在 build() 里面,而是大量使用 @Builder 拆分 UI。
例如首页可以拆成:
ts
build() {
Stack() {
this.topBgBuilder()
this.contentBuilder()
}
}
@Builder
topBgBuilder() {
Column()
.height(220)
.backgroundColor('#4B8DFF')
}
@Builder
contentBuilder() {
Scroll() {
Column() {
this.headerBuilder()
this.searchBuilder()
this.serviceBuilder()
this.hotRouteBuilder()
}
}
}
@Builder 的价值不是复杂,而是让页面结构更清楚。
如果所有 UI 都写在 build() 中,首页这种复杂页面会很快变成几百行嵌套代码,不好维护。拆成 Builder 后,可以按模块读代码:
text
topBgBuilder 顶部背景
headerBuilder 顶部定位栏
searchBuilder 搜索框
serviceBuilder 常用服务
hotRouteBuilder 热门路线
公司项目里的首页也是类似思路,大组件负责拼装,小 Builder 负责局部 UI。
十、状态管理 V2:@ObservedV2 和 @Trace
今天项目里重点看到的是状态管理 V2,尤其是:
ts
@ObservedV2
export class TabHomeViewModel {
@Trace recommendTabs: RecommendTabInfo[] = []
@Trace mustPlay: MustPlayList[] = []
@Trace currentFirstLevelId: string = ''
}
官方文档里有一个关键点:@ObservedV2 本身只是表示这个 class 可以被观察,它自己没有属性级观察能力;真正让属性变化能被 UI 感知的是 @Trace。
所以可以这样记:
text
@ObservedV2 标记这个类是可观察对象;
@Trace 标记这个类里的某个字段需要被观察;
字段变化后,绑定这个字段的 UI 会刷新。
错误理解是:
text
只要 class 加了 @ObservedV2,里面所有字段都会自动刷新 UI。
正确理解是:
text
class 加 @ObservedV2 只是前提;
真正需要刷新的字段还要加 @Trace。
例如:
ts
@ObservedV2
export class DemoHomeViewModel {
@Trace locationName: string = '中国香港'
@Trace serviceList: string[] = []
}
UI 中使用:
ts
Text(this.viewModel.locationName)
ForEach(this.viewModel.serviceList, (item: string) => {
Text(item)
})
当 Controller 里修改:
ts
this.viewModel.locationName = '深圳'
this.viewModel.serviceList = ['机票', '酒店', '火车票']
绑定了这些字段的 UI 就会自动更新。
十一、为什么数组最好重新赋值
今天结合之前聊天 demo 的经验,也再次确认了一个点:数组更新时,最好用"新数组赋值"的方式触发刷新。
比如不推荐只这样写:
ts
this.viewModel.serviceList.push({ title: '更多' })
更稳的方式是:
ts
this.viewModel.serviceList = this.viewModel.serviceList.concat([{ title: '更多' }])
或者接口返回后直接整体赋值:
ts
this.viewModel.serviceList = data.serviceList
这样做的原因是:
text
状态管理关注的是被观察字段的变化;
直接重新给 @Trace 字段赋值,更容易触发依赖这个字段的 UI 更新;
复杂对象或数组内部原地修改,有时候不如整体替换直观稳定。
所以后面做首页数据接入时,建议保持这个风格:
ts
this.viewModel.recommendTabs = result.recommendTabs ?? []
this.viewModel.hotRouteList = result.hotRouteList ?? []
this.viewModel.discoveryList = result.discoveryList ?? []
不要在 UI 层或者 Controller 里大量 push。
十二、@Local 的理解
公司项目里组件内部经常能看到:
ts
@Local viewModel: TabHomeViewModel = new TabHomeViewModel()
@Local 可以理解为组件内部自己的状态变量。它通常适合这种场景:
text
这个状态只属于当前组件;
不需要父组件传入;
不需要向外同步;
组件自己创建、自己使用。
在今天的首页替换里,也可以这样写:
ts
@ComponentV2
export struct DemoTabHomeComp {
@Local viewModel: DemoHomeViewModel = new DemoHomeViewModel()
controller: DemoHomeController = new DemoHomeController(this.viewModel)
}
这里 viewModel 是当前首页组件自己的状态中心,所以用 @Local 比较合适。
如果某个字段是父组件传进来的,比如当前 Tab 下标,就更可能用 @Param 之类的方式,而不是组件内部自己 new。
十三、V1 和 V2 状态装饰器不能乱混用
今天看项目时要特别注意一个坑:状态管理有 V1 和 V2 两套装饰器。
常见 V1:
text
@State
@Prop
@Link
@Observed
@ObjectLink
@Track
常见 V2:
text
@ComponentV2
@Local
@Param
@ObservedV2
@Trace
@Monitor
官方文档也强调了 V1 和 V2 的混用限制。简单记就是:
text
@ComponentV2 里优先使用 V2 装饰器;
@ObservedV2 搭配 @Trace;
不要在 @ObservedV2 里混用 @Track;
不要把 V1 的观察模型和 V2 的观察模型随便混在一起。
项目里如果已经是:
ts
@ComponentV2
export struct TabHomeComp {}
那么 ViewModel 就应该优先使用:
ts
@ObservedV2
export class TabHomeViewModel {
@Trace xxx: string = ''
}
不要写成:
ts
@Observed
export class TabHomeViewModel {
@Track xxx: string = ''
}
否则后面可能出现编译问题、刷新问题或者状态观察不符合预期。
十四、公司项目首页的分层理解
今天除了启动链路,另一个重点是理解首页的分层。
公司项目不是把 UI、请求、点击事件、数据结构都写在一个页面里,而是拆成了类似这样的结构:
text
pages
MainTabPage.ets
components
TabHomeComp.ets
controller
TabHomeController.ets
viewmodel
TabHomeViewModel.ets
biz
HomeBiz.ets
model
TabHomeModel.ets
每一层职责不同:
text
Page 层
负责页面承接、路由注册、Tabs 容器。
Component 层
负责 UI 展示,用 @Builder 拆分页面结构。
ViewModel 层
负责页面状态,用 @ObservedV2 和 @Trace 保存可刷新数据。
Controller / Presenter 层
负责生命周期、点击事件、接口调度、路由跳转、埋点等页面逻辑。
Biz 层
负责业务请求封装,屏蔽具体接口细节。
Model 层
负责类型定义、接口返回结构、页面数据结构。
这套结构的好处是:
text
UI 不直接请求接口;
接口不直接改页面;
点击事件不散落在 UI 里;
状态集中在 ViewModel;
业务逻辑集中在 Controller;
数据类型集中在 Model。
十五、首页数据流应该怎么走
今天梳理出的首页数据流是:
text
TabHomeComp.aboutToAppear()
↓
TabHomeController.aboutToAppear()
↓
reloadData()
↓
HomeBiz.getTabHomeData(param)
↓
接口返回首页数据
↓
Controller 解析并赋值 ViewModel
↓
ViewModel 中 @Trace 字段变化
↓
UI 自动刷新
仿写的时候,可以先做一个缩小版:
text
DemoTabHomeComp.aboutToAppear()
↓
DemoHomeController.aboutToAppear()
↓
DemoHomeController.reloadData()
↓
DemoHomeBiz.getHomeData()
↓
返回 mock 数据
↓
this.viewModel.serviceList = data.serviceList
↓
页面刷新
对应代码大概是:
ts
export class DemoHomeController {
private viewModel: DemoHomeViewModel
private biz: DemoHomeBiz = new DemoHomeBiz()
constructor(viewModel: DemoHomeViewModel) {
this.viewModel = viewModel
}
async aboutToAppear(): Promise<void> {
await this.reloadData()
}
async reloadData(): Promise<void> {
const data = await this.biz.getHomeData()
this.viewModel.locationName = data.locationName
this.viewModel.serviceList = data.serviceList
this.viewModel.hotRouteList = data.hotRouteList
}
}
这个结构很适合后面接真实需求。
十六、为什么 UI 不能直接调接口
如果在 UI 组件里直接写:
ts
aboutToAppear() {
Api.get(...).then((res) => {
this.serviceList = res.data
})
}
短期看也能跑,但问题很多:
text
1. 页面越来越大,UI 和业务逻辑混在一起;
2. 接口参数散落在多个组件里;
3. 错误处理、loading、重试逻辑不好统一;
4. 后面换接口或加埋点,需要到 UI 里到处改;
5. 不符合公司项目已有架构。
更合理的写法是:
text
UI:只负责展示和绑定点击事件;
Controller:负责收到点击后做什么;
Biz:负责请求哪个业务接口;
ViewModel:负责保存最后给 UI 用的数据。
所以首页组件里最好只保留这种调用:
ts
async aboutToAppear(): Promise<void> {
await this.controller.aboutToAppear()
}
.onClick(() => {
this.controller.onClickSearch()
})
真正的请求、判断、路由跳转都放进 Controller。
十七、今天实践出来的替换策略
一开始有两种替换首页的方案:
text
方案一:新建页面,注册同一个 HomePage 路由,替换原 MainTabPage。
方案二:保留 MainTabPage,只替换里面的首页 Tab 组件。
今天更推荐方案二,因为它更稳:
text
保留原路由;
保留 MainTabPage;
保留底部 Tabs;
只替换首页内容组件;
出问题时容易恢复。
实际操作可以理解成:
text
原来:
LushuPageConstant.HomePage
↓
MainTabPage
↓
TabHomeComp
现在:
LushuPageConstant.HomePage
↓
MainTabPage
↓
DemoTabHomeComp
这样既验证了首页挂载链路,也没有破坏整个应用入口。
十八、后面做真实需求时的分析模板
今天这套思路后面可以直接复用。以后接到需求,不要上来就改代码,先按下面模板分析:
text
1. 这个需求影响哪个页面?
2. 这个页面的 pageUrl 是什么?
3. 页面是由 @Entry 加载,还是由 @HMRouter 注册?
4. 这个页面在哪个模块?
5. 页面入口文件在哪里?
6. 页面 UI 组件在哪里?
7. ViewModel 是哪个?
8. Controller / Presenter 是哪个?
9. Biz 或接口封装在哪里?
10. 数据来源是接口、缓存、本地 mock,还是上个页面传参?
11. 是否涉及登录态、隐私协议、地区、会员、埋点?
12. 修改点是 UI、交互、接口、状态,还是路由?
这样能避免盲目搜索和乱改。
十九、今天学到的关键知识点总结
今天学到的内容可以压缩成下面几句话:
text
1. module.json5 通过 srcEntry 指定真正的 Ability 入口。
2. EntryAbility 的 onCreate 负责全局初始化,不直接显示页面。
3. onWindowStageCreate 负责窗口创建后的页面加载。
4. windowStage.loadContent('pages/MainPage') 加载的是应用导航容器。
5. MainPage 通过 HMNavigation 和 homePageUrl 指向真实首页路由。
6. 首页真实页面由 @HMRouter({ pageUrl }) 注册。
7. MainTabPage 是首页 Tabs 容器,不一定直接写首页业务 UI。
8. 替换首页时,优先替换 TabContent 里的组件,而不是重复注册同一个路由。
9. ArkUI 是声明式 UI,build() 用来描述组件树。
10. @Entry 是页面入口组件,普通业务组件不需要加。
11. @ComponentV2 用来声明 V2 自定义组件。
12. @Builder 用来拆分复杂 UI,提升页面可读性。
13. @ObservedV2 标记 class 可观察,但本身不观察字段。
14. @Trace 才是让字段变化触发 UI 更新的关键。
15. 数组更新时,整体赋值比原地 push 更稳定。
16. V1 和 V2 状态装饰器不要乱混用。
17. 公司项目页面要按 Page / Component / ViewModel / Controller / Biz / Model 分层理解。
18. UI 不直接请求接口,Controller 调 Biz,Biz 返回数据,Controller 更新 ViewModel,UI 自动刷新。
二十、后续可以继续做什么
目前首页替换已经跑通,后续如果继续练首页,可以按这个顺序做:
text
第一步:把"你好"替换成首页基础布局
- Stack
- 顶部背景
- Scroll
- Column 内容区
第二步:抽 ViewModel
- locationName
- searchPlaceholder
- serviceList
- hotRouteList
第三步:抽 Controller
- aboutToAppear
- reloadData
- onClickSearch
- onClickService
第四步:抽 Biz
- getHomeData
- 先返回 mock 数据
第五步:UI 绑定 ViewModel
- ForEach 服务列表
- ForEach 热门路线
第六步:再做 UI 还原
- 背景图
- 搜索框
- 服务图标
- 热门路线卡片
- 发现列表
第七步:最后再接真实接口和路由跳转
如果接到的是公司真实需求,就不需要继续为了练习完整复原首页,而是把今天学到的方法用到真实需求里:
text
找入口 -> 找路由 -> 找页面 -> 找组件 -> 找 ViewModel -> 找 Controller -> 找 Biz -> 最小范围修改
参考文档
-
华为开发者文档:UIAbility 组件生命周期
developer.huawei.com/consumer/cn... -
华为开发者文档:ArkUI 介绍
developer.huawei.com/consumer/cn... -
华为开发者文档:状态管理 V2 版本
developer.huawei.com/consumer/cn... -
华为开发者文档:状态管理 V1 和 V2 混用指导
developer.huawei.com/consumer/cn... -
华为开发者文档:@Monitor 状态变量修改监听
developer.huawei.com/consumer/cn...
最后总结
今天最大的收获不是写出了多少页面,而是把公司鸿蒙项目的主线摸清楚了。
以前看页面时,可能只知道某个 .ets 文件能显示 UI,但不知道它是怎么被启动、怎么被路由找到、怎么和 Ability、MainPage、HMNavigation 连接起来的。今天通过入口分析和首页替换,已经确认了从 module.json5 到真实首页组件的完整链路。
同时也进一步理解了公司项目为什么要拆分 ViewModel、Controller、Biz 和 Model。大型项目里,页面不能只靠一个组件文件硬写,必须把 UI、状态、业务、请求、类型定义拆开,否则后面需求一多就很难维护。
后续做真实需求时,可以继续沿用今天这套分析方法:先把链路看明白,再按架构改代码,不直接在 UI 里堆逻辑。