前言
在上一篇# 鸿蒙项目实战:手把手带你从零架构 WanAndroid 鸿蒙版 文章中,讲解了如何从零搭建项目架构。在本篇中,我将基于上一篇基础上带你手把手带你实现 WanAndroid 布局与交互!
本文所需要掌握的知识点:
话不多说!直接开始!
1、UI 渲染的"分水岭" ------ @ComponentV2 与 @Builder
首页是 App 的门面,它集成了沉浸式状态栏、无限循环 Banner、以及高频率刷新的分页列表。但在写代码之前,我们先给首页做了一次"拆解手术"。
在 ArkUI V2 提供的工具箱里,有两把利刃:@ComponentV2 和 @Builder。如果说 @ComponentV2 是拥有独立灵魂(生命周期和状态)的"战士",那么 @Builder 就是轻量灵活的"皮肤"。在 WanAndroid 实战中,如何通过这两者的组合,既能保证代码的整洁,又能避开 UI 渲染优化的"雷区"?
让我们从这一行 @ComponentV2 开始,重新定义鸿蒙 UI 开发。

如图所示
- 红框部分(Banner+列表容器) :涉及网络请求、独立状态,必须是
@ComponentV2 - 蓝框部分(对应的Item) :纯展示,高频重复,推荐用
@Builder减重
在鸿蒙 UI 的世界里,逻辑自治 交给组件
ComponentV2,轻量渲染 交给构建函数@Builder。只有分清了职责,你的代码才不会在长列表滚动时'喘粗气'。
OK!现在我们就从上到下来逐步拆解,看看该页面如何实现的!
1.1 首页Banner功能实现
我们先来看看官方组件介绍

如图所示
- 这是华为关于 Swiper组件 可专门实现Banner的效果
- 它支持(
if/else、ForEach、LazyForEach和Repeat)方式渲染,
这里就以LazyForEach和Repeat渲染方式为例,毕竟这两个都属于懒加载渲染,而它们使用的方式有很大的区别。
官方推荐用
Repeat进行懒加载渲染,而Repeat必须得结合V2装饰器使用,因此我们的鸿蒙项目,不要用V1!不要用V1!不要用V1!
1.1.1 LazyForEach 传统且严谨的"监听器模式"
在鸿蒙 ArkUI 的早期体系中,实现长列表或大数据的懒加载,LazyForEach 是唯一的主角。它的核心思想是通过观察者模式来精准控制每一项的增量刷新。
TS
//----------以下代码在 BasicDataSource.ts
// 1. 定义一个基础的数据源类,处理监听器逻辑
export class BasicDataSource implements IDataSource {
private listeners: DataChangeListener[] = [];
public totalCount(): number {
return 0;
}
public getData(index: number): Any {
return undefined;
}
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener);
}
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const pos = this.listeners.indexOf(listener);
if (pos >= 0) {
this.listeners.splice(pos, 1);
}
}
// 通知LazyForEach组件需要重载所有子组件
notifyDataReload(): void {
this.listeners.forEach(listener => {
listener.onDataReloaded();
});
}
// 通知LazyForEach组件需要在index对应索引处添加子组件
notifyDataAdd(index: number): void {
this.listeners.forEach(listener => {
listener.onDataAdd(index);
});
}
// 通知LazyForEach组件在index对应索引处数据有变化,需要重建该子组件
notifyDataChange(index: number): void {
this.listeners.forEach(listener => {
listener.onDataChange(index);
});
}
// 通知LazyForEach组件需要在index对应索引处删除该子组件
notifyDataDelete(index: number): void {
this.listeners.forEach(listener => {
listener.onDataDelete(index);
});
}
// 通知LazyForEach组件将from索引和to索引处的子组件进行交换
notifyDataMove(from: number, to: number): void {
this.listeners.forEach(listener => {
listener.onDataMove(from, to);
});
}
notifyDatasetChange(operations: DataOperation[]): void {
this.listeners.forEach(listener => {
listener.onDatasetChange(operations);
});
}
}
//----------以下代码在 HomeComp.ts 里
class MyBannerDataSource extends BasicDataSource {
private list: BannerResult[] = [];
totalCount(): number {
return this.list.length
}
getData(index: number): BannerResult {
return this.list[index]
}
setData(list: BannerResult[]) {
this.list = list;
this.notifyDataReload(); // 关键:数据变了,通知 Swiper 刷新
}
}
//-------------以下代码在 export struct HomeComp { 里
@ComponentV2
export struct HomeComp {
private bannerData: MyBannerDataSource = new MyBannerDataSource()
@Builder
swiperBuilder() {
Swiper(this.swiperController) {
LazyForEach(this.bannerData, (item: BannerResult) => {
Image(item.imagePath).width('100%').height(160)
})
}
//--------------------此处省略关于Swiper 组件属性设置代码
}
}
代码逻辑拆解:
-
契约定义(IDataSource):
LazyForEach并不直接接受普通的 Array 数组,它需要一个实现了IDataSource接口的数据结构。这就像 Android 中RecyclerView/ListView的Adapter必须实现特定的方法一样。 -
基座封装(BasicDataSource):
- 为了避免在每个页面重复编写重复的监听器管理代码,我们封装了一个
BasicDataSource。 - 它的作用是存储
DataChangeListener。当数据发生变动时,UI 组件会通过这些监听器捕捉到信号。
- 为了避免在每个页面重复编写重复的监听器管理代码,我们封装了一个
-
数据下发(notifyDataReload):
- 在
MyBannerDataSource中,当网络请求拿到新的 Banner 列表后,我们不能简单地赋值。 - 必须手动调用
notifyDataReload(),这会触发内部监听器的onDataReloaded()回调。 这正是通知 UI 组件(如 Swiper)重新加载数据的关键阀门。
- 在
看到这里,你可能会想:我只是想给 Banner 赋个值,为什么要写这么多类和方法? :(
别急!这正是 ArkUI V2 推出 Repeat 的初衷。在讲完这个【麻烦】的传统方案后,稍后我会展示如何用一行代码实现同样的懒加载效果。
1.1.2 Repeat 告别繁琐,回归声明式开发
如果说 LazyForEach 是在写逻辑,那么 ArkUI V2 推出的 Repeat 就是在写UI。这种全新的列表渲染组件,彻底终结了开发者在鸿蒙上写 IDataSource 的痛苦体验。
TS
@ComponentV2
export struct HomeComp {
@Local listBanner: BannerResult[] = []
@Builder
swiperBuilder() {
Swiper(this.swiperController) {
// LazyForEach(this.bannerData, (item: BannerResult) => {
// Image(item.imagePath).width('100%').height(160)
// })
Repeat<BannerResult>(this.listBanner).each((ri: RepeatItem<BannerResult>) => {
Image(ri.item.imagePath).width('100%').height(160)
})
}
}
}
代码逻辑拆解:
-
原生数组支持: 不同于
LazyForEach需要封装复杂的数据结构,Repeat直接支持标准的Array数组。 你看,代码里直接传入this.listBanner即可,再也不用去写什么BasicDataSource了。:) -
响应式自动刷新: 得益于 V2 的状态管理 ,我们将
listBanner声明为@Local。 当网络请求返回并给数组赋值时,Repeat会自动感知数据变化并触发 UI 刷新, 整个过程不需要手动调用任何notify方法。 -
包装对象 RepeatItem: 在
.each回调中,Repeat并没有直接返回原始数据,而是返回了一个名为RepeatItem的包装对象。ri.item:这才是我们真正的实体数据(如BannerResult)。ri.index:原生提供的索引属性。即使在复杂的增量更新场景下,它也能精准追踪当前项的下标。
作为从 Android 转型过来的开发者,我最深刻的感受是:LazyForEach 像是在用 Java/Kotlin 手写 BaseAdapter,而 Repeat 则更像是使用 Jetpack Compose 或 Flutter。它抹平了底层的通知细节,让我们能把精力 100% 放在业务交互上。
OK!既然Banner功能实现了,接下来就是列表了!
1.2 首页列表功能实现
我们继续看官方介绍:

如图所示
- 这是官方关于列表功能的组件 List
- 它支持(
ForEach、LazyForEach和Repeat)方式渲染
在 WanAndroid 首页的开发中,我们不仅仅是简单地把数据显示出来,更需要考虑布局的灵活性 与性能优化 。通过 ArkUI V2 的 Repeat 组件,我们可以像搭积木一样高效地构建复杂列表。
TS
@ComponentV2
export struct HomeComp {
private swiperController: SwiperController = new SwiperController();
@Local listBanner: BannerResult[] = []
@Local listArticle: HomeListArticleResultDatas[] = []
@Builder
contentView() {
List({ scroller: this.scrollerForList, space: 8 }) {
ListItem() {
this.swiperBuilder()
}
Repeat<HomeListArticleResultDatas>(this.listArticle)
.each((ri: RepeatItem<HomeListArticleResultDatas>) => {
ListItem() {
this.listItemView(ri.item)
}
}).templateId((item: HomeListArticleResultDatas, index: number): string => {
return index % 2 == 0 ? 'A' : ''
}).template('A', (ri: RepeatItem<HomeListArticleResultDatas>) => {
ListItem() {
this.listItemAView(ri.item)
}
})
}
.width('100%')
.alignListItem(ListItemAlign.Center) //交叉轴方向的布局方式 对齐方式
.height('100%') // 这里的 100% 会自动扣除父容器 Tabs 占用的底部高度
.layoutWeight(1) // 确保 List 占据剩余所有空间
.edgeEffect(EdgeEffect.Spring) // 加上回弹效果更流畅
}
@Styles
listItemStyle(){
.margin({ left: 8, right: 8 })
.borderRadius(10)
.backgroundColor(0xFFFFFF)
.padding(10)
}
@Builder
listItemAView(itemData: HomeListArticleResultDatas) {
Column() {
// 1. 顶部:作者和日期
Row() {
Text(Text(itemData.shareUser ? itemData.shareUser : itemData.author))
.fontColor($r('app.color.common_color_grey_d2'))
.fontSize(13)
Blank() // 自动填满中间,将日期挤到右边
Text(itemData.niceShareDate)
.fontColor($r('app.color.common_color_grey_d2'))
.fontSize(13)
}.width('100%')
// 2. 中间:标题
Text(itemData.title)
.fontColor($r('app.color.common_color_black_1c'))
.fontSize(18)
.width('100%')
.margin({ top: 13 })
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.listItemStyle()
}
@Builder
listItemView(itemData: HomeListArticleResultDatas) {
Column() {
Text(itemData.title)
.width('100%')
.fontSize(16)
.textAlign(TextAlign.Center)
}.listItemStyle()
.height(100)
.justifyContent(FlexAlign.Center)
}
@Builder
swiperBuilder() {
Swiper(this.swiperController) {
Repeat<BannerResult>(this.listBanner).each((ri: RepeatItem<BannerResult>) => {
Image(ri.item.imagePath).width('100%').height(160)
})
}
}
}
代码逻辑深度拆解:
1. 混合布局策略:Banner 与列表的"无缝嵌套"
在代码中,我们并没有将 Swiper(轮播图)放在 List 之外,而是将其作为第一个 ListItem 插入。
- 优势:这样可以保证整个首页在滚动时具有一致的连贯性。
- 实现 :
List容器会自动处理内部不同类型的ListItem,将 Banner 视作列表的"头部"。
2. Repeat 的"进阶玩法":多模板渲染(Template)
在 Android 开发中,我们通过 getItemViewType 来处理多种布局;在 Repeat 中,这一逻辑变得更加直观:
templateId:这是路由开关。代码中根据index % 2 == 0来切换模板 ID,这意味着你可以根据数据类型(比如:置顶文章、带图文章、纯文字文章)动态选择样式。template:这是样式的具体实现。通过.template('A', ...)定义特定 ID 对应的 UI 结构。- 性能收益 :
Repeat会为每个template创建独立的复用池。当列表滚动时,系统会优先复用相同 ID 的节点,极大减少了组件重新创建的开销。
3. @Styles:消除重复的"样式代码"
观察 listItemAView 和 listItemView,你会发现它们有共同的边距、圆角和背景色。
- 做法 :我们提取了一个
@Styles装饰的方法listItemStyle()。 - 价值:它不属于组件,也不产生额外的嵌套层级,仅仅是一组属性的集合。
在 Android 中,我们习惯了用 SwipeRefreshLayout 配合 RecyclerView。在鸿蒙 ArkUI V2 的世界里,虽然 UI 渲染变得声明式了,但'下拉、请求、刷新 UI'这一套经典动作依然是首页的刚需。本节我们将探讨如何利用社区优秀的插件实现丝滑的刷新效果,并聊聊在鸿蒙中如何优雅地处理分页状态。
1.3 下拉刷新与分页加载
在上一篇架构篇中,我们初步集成了刷新组件并引入了统一版本管理,但在实际开发 WanAndroid 首页的过程中,我发现之前的方案存在两个严重的"硬伤"。为了追求更纯粹的 V2 开发体验,我决定在 UI 实战篇进行一次彻底的"架构排毒"。
1.3.1 刷新插件的"断舍离":为什么弃用 @abner/refresh_v2?

如图所示
这是关于@abner/refresh_v2使用方式。在首页实战中,它的局限性开始显现:
-
V2 兼容性差 :该插件目前的文档说明显示其核心逻辑深度依赖于
LazyForEach和ForEach。对于我们想要全面拥抱的Repeat渲染方式,它支持得并不优雅。 -
UI 侵入性过强 :它要求开发者强制传入特定的数据源格式,且刷新头、加载底的 UI 定制化过于沉重,导致我们的
HomeComp代码中混入了很多插件特有的配置,违背了我们这个项目的初衷 。
为了解决上述问题,我重新调研并集成了 @zhongrui/pull_to_refresh_v2
- 解耦更彻底 :它通过
contentView的方式承载 UI,不再强行接管你的数据源,让你可以自由地在内部使用Repeat。:)
但当我尝试更换刷新插件时,更大的槽点来了:(
1.3.2 parameterFile 的"甜蜜陷阱":无法执行的 ohpm install
在上一篇架构优化中,我们为了统一管理依赖版本,在工程级的 oh-package.json5 中配置了 parameterFile
json
{
"modelVersion": "6.0.1",
"description": "Please describe the basic information.",
"parameterFile": "./oh-package-parameter.json5",// 关联创建的文件 记得Sync Now哟
"dependencies": {
},
"devDependencies": {
"@ohos/hypium": "1.0.24",
"@ohos/hamock": "1.0.0"
}
}
现象:当我试图使用 ohpm install <pkg> 命令安装新的三方库(比如这次的刷新插件)时,控制台直接报错 :<
TS
ohpm ERROR: Run install command failed
Error: 00622008 Forbidden Install Error
Error Message: The "ohpm install <pkg>" command cannot be executed
when the "parameterFile" configuration exists in the project-level oh-package.json5 file.
官方解释:
根据华为官方文档《场景三》解释,一旦配置了 parameterFile,工程将不再支持指定包名的命令行安装:(
槽点: 虽然理解官方是为了保证大型项目中版本的强统一性,避免由于命令行误操作导致的版本冲突,但这种剥夺命令行安装权利 的做法对于习惯了 npm i 或 ohpm i 的开发者来说极度不友好。这意味着你无法快速尝试一个新的库,必须反复进行"复制、粘贴、全局同步"的机械动作。 :(
槽点吐槽完了,我们回归主题
1.3.3 刷新组件集成

如图所示
- 在工程根目录我们手动创建的
oh-package-parameter.json5里,修改刷新组件的版本号:"refresh_v2": "^2.0.8" - 在
common/oh-package.json5文件里,手动复制粘贴"@zhongrui/pull_to_refresh_v2"刷新组件,并指定版本号"@param:parameter.refresh_v2",记得Sync Now哟
1.3.4 刷新与加载实现
既然已经解决了插件兼容性和依赖安装的"巨坑",接下来我们把重心放在 PullToRefreshLayout 的具体实现上。不同于之前的组件,这个插件的设计理念更加贴合 ArkUI V2 的组件化思维。
scss
@ComponentV2
export struct HomeComp {
/*swiper控制器*/
private swiperController: SwiperController = new SwiperController();
private scrollerForList: Scroller = new Scroller();
@Local listBanner: BannerResult[] = []
@Local listArticle: HomeListArticleResultDatas[] = []
/*RefreshLayout控制器*/
private controller: RefreshController = new RefreshController()
build() {
Column() {
//统一标题
CommonHeader({ title: "首页" })
PullToRefreshLayout({
contentView: () => {
this.contentView()
},
viewKey: "HomeComp", //必传,记录刷新时间的key
controller: this.controller,
scroller: this.scrollerForList,
onRefresh: () => {
LogUtil.debug("HomeComp onRefresh")
this.onRefreshData()
},
onLoad: () => {
LogUtil.debug("HomeComp onLoad")
this.onLoadData()
},
onCanPullLoad: () => {
return this.scrollerForList.isAtEnd()
}
}).layoutWeight(1)
}.width('100%').height('100%')
}
@Builder
contentView() {
List({ scroller: this.scrollerForList, space: 8 }) {
ListItem() {
this.swiperBuilder()
}
Repeat<HomeListArticleResultDatas>(this.listArticle)
.each((ri: RepeatItem<HomeListArticleResultDatas>) => {
ListItem() {
this.listItemView(ri.item)
}
}).templateId((item: HomeListArticleResultDatas, index: number): string => {
return index % 2 == 0 ? 'A' : ''
}).template('A', (ri: RepeatItem<HomeListArticleResultDatas>) => {
ListItem() {
this.listItemAView(ri.item)
}
})
}
.width('100%')
.alignListItem(ListItemAlign.Center) //交叉轴方向的布局方式 对齐方式
.height('100%') // 这里的 100% 会自动扣除父容器 Tabs 占用的底部高度
.layoutWeight(1) // 确保 List 占据剩余所有空间
.edgeEffect(EdgeEffect.Spring) // 加上回弹效果更流畅
}
}
代码逻辑拆解:
-
三位一体的控制器体系
在
HomeComp中,刷新逻辑由三个核心对象协同完成:RefreshController:这是插件的指令中枢,负责手动触发刷新动画或停止刷新/加载状态 。Scroller:列表滚动实例。插件通过它感知列表的滚动位置,从而决定何时触发"上拉加载" 。
-
UI 零侵入:
contentView回调模式这是我最推崇该插件的一点。它通过
@BuilderParam接收一个contentView回调 。这意味着:- 它不接管你的数据源。
- 它不干涉你用
LazyForEach还是Repeat。 - 这种设计实现了 UI 容器与业务内容的完美解耦 。
-
精准触发:
onCanPullLoad判定为了避免在列表未滑到底部时误触发加载逻辑,我们在这里加了一层保护:
TS/*根据当前列表滑动距离或者其他业务逻辑判断是否可以上拉,true:可以上拉加载,false:不能上拉*/ onCanPullLoad: () => { //判断列表是否滑到底部 return this.scrollerForList.isAtEnd() }
OK,到这里我们的首页 HomeCompUI方面画得差不多了,但它并不是一个独立的页面,它是嵌套在 MainPage 里的一个'租客'。这时候,传统的组件生命周期够用吗?如果用户切换了底部的 Tab,我们该如何感知并刷新数据?
接下来,我们将通过对生命周期 的深度拆解,帮你理清 UIAbility、页面、组件这三者之间的层级关系,带你找回在 Android 中掌控 Activity 那种游刃有余的感觉。
2、生命周期的多维解析:谁才是真正的"发令枪"?
在 Android 开发中,我们习惯了 Activity 的 onCreate 或 onResume 一把梭。但在鸿蒙(HarmonyOS)的多维体系下,如果你不理清生命周期的层级,就很容易遇到"数据不刷新"、"动画在后台空跑"或者"重复请求网络"的尴尬。
我们要讲解网络请求,必须先弄清楚:在哪个层级、哪个时机触发请求,才是最优雅的。
2.1 UIAbility 生命周期:全局的"大管家"
这是最顶层的生命周期,由系统直接调度。它处理的是 App 的生存状态。
- 主要阶段 :
onCreate(创建)、onForeground(切前台)、onBackground(切后台)、onDestroy(销毁) - 实战定位:它不参与具体的 UI 业务,主要负责全局配置(如:沉浸式状态栏适配、窗口避让逻辑等)。
- 温馨提示 :关于这一层的详细解释,我在鸿蒙ArkUI:状态管理、应用结构、路由全解析中已经深度解析过,本篇主要关注业务层,不再赘述。
2.2 Navigation 与 HMRouter 生命周期深度对比

如图所示
为了方便大家在实战中不找错钩子,我根据这两张流程图,总结出了它们最核心的两个差异点:
2.2.1 启动阶段的"前哨站":onPrepare 与 onReady
-
原生 Navigation(图左) :启动非常直接,从组件的
aboutToAppear直接跨入onWillAppear。 -
HMRouter(图右) :在最顶部多出了
onPrepare。- 核心价值 :这是
HMRouter的杀手锏。它在页面还没创建、UI 还没加载时就触发。 - 实战场景 :最适合做路由拦截 。比如用户点进"我的订单",你在
onPrepare里判断没登录,直接重定向到登录页,用户甚至感知不到中间页面的加载。
TS@ObservedV2 @HMLifecycle({ lifecycleName: MainLifeCycle.lifeCycleName }) export class MainLifeCycle implements IHMLifecycle { static readonly lifeCycleName="MainLifeCycle" onPrepare(ctx: HMLifecycleContext): void { LogUtil.debug("MainLifeCycle onPrepare") if (!UserStorageUtil.isLogin()) { HMRouterMgr.replace({ pageUrl: FeatureHomePath.LOGIN, param: { "from": "MainLifeCycle" } }); } } - 核心价值 :这是
2.2.2 执行顺序的"罗生门":aboutToAppear 的位置
这是最容易翻车的地方,请仔细看图:
-
原生 Navigation(图左) :组件的
aboutToAppear是在整个NavDestination流程之外的,它是组件挂载的起点。 -
HMRouter(图右) :它将自定义页面的生命周期(绿虚线部分)揉进了自己的流程中。
- 细节 :在 HMRouter 流程中,
aboutToAppear发生在onWillShow之后、onShown之前。 - 结论:HMRouter的页面是对NavDestination页面的封装,实际上是NavDestination的子组件页面,所以先执行NavDestination的生命周期再执行HMRouter自定义页面的aboutToAppear
- 细节 :在 HMRouter 流程中,
2.2.3 身份的觉醒:你是"页面"还是"组件"?
在理清了执行顺序后,我们需要明确一个关键的架构概念:在 HMRouter 体系下,@ComponentV2 会根据其装饰器的不同,拥有不同的生命周期作用域。
1. "页面(Page)":拥有完整的"页面状态"感知
当一个 @ComponentV2 被 @HMRouter({ pageUrl: '...' }) 修饰时,它在系统眼中就是一个独立的页面 。
-
能力 :它不仅拥有组件基础的生命周期,还能直接绑定
IHMLifecycle。TS@HMRouter({ pageUrl: ProductPhonePath.MAIN, lifecycle: MainLifeCycle.lifeCycleName }) @ComponentV2 export struct MainPage { -
特权 :它能感知"页面"层级的细微变化,比如:页面是被覆盖了(
onInactive)、还是彻底隐藏了(onHidden)。TS@ObservedV2 @HMLifecycle({ lifecycleName: MainLifeCycle.lifeCycleName }) export class MainLifeCycle implements IHMLifecycle { static readonly lifeCycleName="MainLifeCycle" onPrepare(ctx: HMLifecycleContext): void { LogUtil.debug("MainLifeCycle onPrepare") // if (!UserStorageUtil.isLogin()) { // HMRouterMgr.replace({ pageUrl: FeatureHomePath.LOGIN, param: { "from": "NetWork" } }); // } } onShown(ctx: HMLifecycleContext): void { //HMRouterMgr.getCurrentParam() 获取页面传递的参数 LogUtil.debug("MainLifeCycle onShown" + JSON.stringify(HMRouterMgr.getCurrentParam())) } onInactive(ctx: HMLifecycleContext, inactiveReason?: NavDestinationActiveReason | undefined): void { LogUtil.debug("MainLifeCycle onInactive") } onHidden(ctx: HMLifecycleContext): void { LogUtil.debug("MainLifeCycle onHidden") } }
2. "组件(Component)":仅拥有基础的"挂载"生命周期
像我们的 HomeComp,由于没有被 @HMRouter 修饰,它在系统眼中只是一个普通的自定义组件。
- 能力 :它拥有官方定义的
aboutToAppear和aboutToDisappear。 - 局限性 :自定义组件的生命周期只管"出生(挂载)"和"死亡(销毁)" 。它听不到页面的呼唤 ------当用户在 Tabs 之间切换,或者从详情页返回首页时,由于
HomeComp始终被挂载在内存中,它的aboutToAppear不会重复触发。
2.3 自定义组件生命周期:最基础的"砖瓦"
虽然我们在 2.2.3 节中通过"身份觉醒"意识到 HomeComp 无法直接感知页面状态,但它作为由 @ComponentV2 装饰的自定义组件,依然拥有鸿蒙原生赋予的生命周期。这些钩子函数决定了组件从"实例化"到"销毁"的完整物理过程。

如图所示:
aboutToAppear:组件的"准入证"
-
执行时机 :在创建自定义组件的新实例后,执行其
build()函数之前执行。 -
实战意义 :这是进行同步状态初始化的最佳时机。
- 在
HomeComp中,我们在这里初始化了添加了感知页面的lifecycleOwner。
TS@ComponentV2 export struct HomeComp { private lifecycleOwner = HMRouterMgr.getCurrentLifecycleOwner(); // 组件生命周期 aboutToAppear() { this.onRefreshData() //组件绑定 page 生命周期 测试 this.lifecycleOwner?.addObserver(HMLifecycleState.onHidden, this.onHidden) this.lifecycleOwner?.addObserver(HMLifecycleState.onShown, this.onShow) } onShow() { LogUtil.debug("HomeComp onShow") } onHidden() { LogUtil.debug("HomeComp onHidden") } - 在
aboutToDisappear:组件的"告别仪式"
-
执行时机:在自定义组件析构销毁之前执行。
-
实战意义 :这是防止内存泄漏的最后一道防线。
- 避坑指南 :如果你在组件内开启了定时器,或者像我们一样给
lifecycleOwner添加了观察者(Observer),务必在这里执行removeObserver,否则组件虽然在视觉上消失了,逻辑却可能在后台常驻。
TSaboutToDisappear(): void { this.lifecycleOwner?.removeObserver(HMLifecycleState.onHidden, this.onHidden) this.lifecycleOwner?.removeObserver(HMLifecycleState.onShown, this.onShow) } - 避坑指南 :如果你在组件内开启了定时器,或者像我们一样给
容易混淆的"页面钩子":onPageShow 与 onPageHide
很多开发者会看到这两个方法。
- 残酷的现实 :这两个钩子仅对被
@Entry装饰的页面组件生效。 - 实战结论 :对于像
HomeComp以及被@HMRouter({ pageUrl: '...' })修饰组件引用的结构,即使你写了这两个方法,系统也永远不会回调它们。
到这一步,我们已经从 UI 渲染(第 1 部分)讲到了生命周期调度(第 2 部分)。'发令枪'什么时候响,我们已经清清楚楚。
接下来,我们要进入最硬核的 3. 首页功能逻辑闭环 。正式编写 getHomeBanner 和 getHomeArticleList,看看网络请求是如何在这些生命周期的交织中,有条不紊地填充我们的首页。
3、首页功能逻辑闭环
在这一章节中,我们将 UI 渲染、生命周期感知以及网络请求正式串联起来。首页 HomeComp 作为一个"非路由页面"的自定义组件,通过一种"自给自足"的模式完成了业务逻辑的闭环。
HomeComp.ets 省略部分UI代码
TS
@ComponentV2
export struct HomeComp {
@Local page: number = 0
private swiperController: SwiperController = new SwiperController();
private scrollerForList: Scroller = new Scroller();
@Local listBanner: BannerResult[] = []
@Local listArticle: HomeListArticleResultDatas[] = []
/*RefreshLayout控制器*/
private controller: RefreshController = new RefreshController()
private hasNextPage: boolean = false
private lifecycleOwner = HMRouterMgr.getCurrentLifecycleOwner();
// 组件生命周期
aboutToAppear() {
this.onRefreshData()
//组件绑定 page 生命周期 测试
this.lifecycleOwner?.addObserver(HMLifecycleState.onHidden, this.onHidden)
this.lifecycleOwner?.addObserver(HMLifecycleState.onShown, this.onShow)
}
build() {
Column() {
//统一标题
CommonHeader({ title: "首页" })
PullToRefreshLayout({
contentView: () => {
this.contentView()
},
viewKey: "HomeComp",
controller: this.controller,
scroller: this.scrollerForList,
onRefresh: () => {
LogUtil.debug("HomeComp onRefresh")
this.onRefreshData()
},
onLoad: () => {
LogUtil.debug("HomeComp onLoad")
this.onLoadData()
},
onCanPullLoad: () => {
return this.scrollerForList.isAtEnd()
}
}).layoutWeight(1)
}.width('100%').height('100%')
}
private onRefreshData() {
this.page = 0
this.getHomeArticleList()
this.getHomeBanner()
}
private onLoadData() {
if (!this.hasNextPage) {
ToastUtil.showShort("没有更多数据了!")
this.controller.loadError()
return
}
this.page++
this.getHomeArticleList()
}
private getHomeArticleList() {
LogUtil.debug('HomeCont Child getHomeArticleList');
HomeApi.getHomeList(this.page).then((homeArticleList: HomeListArticleResult) => {
LogUtil.debug("HomeCont getHomeArticleList:" + JSON.stringify(homeArticleList))
if (this.page == 0) {
this.listArticle = homeArticleList.datas
} else {
this.listArticle = this.listArticle.concat(homeArticleList.datas)
}
this.hasNextPage = homeArticleList.curPage < homeArticleList.pageCount;
this.controller.refreshSuccess()
this.controller.loadSuccess()
})
}
private getHomeBanner() {
HomeApi.homeBannerLst().then((listBanner: BannerResult[]) => {
LogUtil.debug("HomeCont getHomeBanner:" + JSON.stringify(listBanner))
this.listBanner = listBanner
//this.bannerData.setData(listBanner)
})
}
}
UserApi.ets
TS
export class UserApi {
/**
* 获取用户信息
* @returns
*/
@GET("/user/lg/userinfo/json")
static async getUserInfo(): Promise<UserInfoResult> {
return {} as UserInfoResult
}
/**
* 退出登录
* @returns
*/
@GET("/user/logout/json")
static async loginOut(): Promise<Any> {
return null
}
/**
* 我的收藏文章列表
* @param page
*/
@GET("/lg/collect/list/{page}/json")
static async getCollectList(@Path("page") page: number): Promise<CollectListResult> {
return {} as CollectListResult
}
}
3.1 核心逻辑拆解
1. 分页控制与"追加"逻辑
在 getHomeArticleList 方法中,我们处理了典型的分页加载逻辑:
- 刷新重置 :当
page == 0时,直接覆盖listArticle数据 。 - 增量追加 :当
page > 0时,使用concat方法将新请求的文章拼接到现有列表末尾 。 - 状态判定 :通过
curPage < pageCount计算出hasNextPage,从而控制上拉加载组件是否还能继续触发请求。
2. 刷新组件的状态回馈
请求结束后,必须通过 this.controller 发出通知:
refreshSuccess():告知下拉刷新动画结束 。loadSuccess()/loadError():告知上拉加载完成或失败。- 这种显式的状态控制,保证了异步网络请求与 UI 刷新效果的同步性。
3. 生命周期驱动的"发令枪"
我们在 aboutToAppear 中调用了 onRefreshData() 进行首次数据获取 。 同时,通过观察 HMLifecycleState.onShown ,我们可以确保:
- 当用户在底部 Tabs 切换回来时,如果有特定的业务需求(如自动静默刷新),组件能够感知到。
- 这是子组件"穿透"宿主页面感知的标准实践 。
3.2 API 定义的艺术:声明式请求
在 UserApi.ets 中,你会看到一种非常"Android 味道"的写法,在上一篇文章中有详解:
- 装饰器模式 :通过
@GET和@Path装饰器,我们将 URL 路径与方法参数解耦 。 - 异步 Promise :统一返回
Promise对象,配合async/await或.then(),让网络逻辑的调用像同步代码一样整洁。
这一章的内容完成了首页从"画出来"到"活过来"的跨越。下一章的收藏页实战,我们将把架构深度推向极致!
4、收藏页实战:标准路由页面的 IHMLifecycle (ViewModel) 全流程
在完成首页的"组件自治"开发后,我们进入鸿蒙 V2 开发的"正统频道"。本章将以"我的收藏"页面为例,展示当一个组件通过 @HMRouter 被定义为标准页面 后,如何利用 IHMLifecycle 实现真正的逻辑与 UI 物理隔离。
4.1 架构升级:Page 与 LifeCycle 的深度解耦
在 CollectListPage 中,我们采用了 LifeCycle + Page 的标准解耦模式。这种模式的核心在于通过注解将 UI 页面与逻辑处理器(ViewModel)进行强绑定。
-
Page (UI 层) :负责声明式布局,通过
@HMRouter声明路由地址并通过lifecycle:指定关联的生命周期处理器。TS@HMRouter({ pageUrl: FeatureUserPath.COLLECT, lifecycle: CollectListLifeCycle.lifeCycleName // 通过属性绑定处理器 }) @ComponentV2 export struct CollectListPage { ... } -
LifeCycle (逻辑层) :承担 ViewModel 职责,通过
@HMLifecycle声明并实现IHMLifecycle接口,负责持有数据状态和执行异步请求。TS@ObservedV2 @HMLifecycle({ lifecycleName: CollectListLifeCycle.lifeCycleName }) // 定义生命周期处理器 export class CollectListLifeCycle implements IHMLifecycle { ... }
4.2 ViewModel 的灵魂:@ObservedV2 与 @Trace
在 CollectListLifeCycle 中,有两个至关重要的注解,它们是实现数据驱动 UI 的核心:
TS
@ObservedV2 // 关键:让类具备可观察能力
@HMLifecycle({ lifecycleName: CollectListLifeCycle.lifeCycleName })
export class CollectListLifeCycle implements IHMLifecycle {
@Trace listArticle: CollectListResultDatas[] = [] // 关键:追踪变量变化
@Trace requestTrigger: number = 0; // 触发种子
}
-
@ObservedV2:这是 V2 状态管理的"入场券"。没有它,类就是一个普通的 TS 类;加上它,框架才会对其进行代理,使其属性具备响应式能力。
-
@Trace :明确告知框架哪些属性需要被监听。当
@Trace装饰的变量改变时,会自动触发与之绑定的 UI 组件增量刷新。
4.3 ViewModel 获取:@Computed 的性能优化
在 CollectListPage 中,如何拿到关联的 LifeCycle 实例?我提供了一种利用 @Computed 注解的高性能获取方式:
TS
// 获取 'viewModel' 方式一
@Computed
private get viewModel(): CollectListLifeCycle {
return HMRouterMgr.getCurrentLifecycleOwner()?.getLifecycle() as CollectListLifeCycle
}
4.3.1 为什么这里要用 @Computed?
根据官方文档说明,@Computed 是 V2 状态管理中用于计算属性的利器:
- 被动触发与缓存 :计算属性的结果会被缓存 。只有当它依赖的状态变量(如路由栈变化导致的
CurrentLifecycleOwner变更)发生改变时,它才会重新计算 。 - 减少冗余计算 :在
build()过程中,如果我们多次引用this.viewModel,使用@Computed可以确保不会反复执行复杂的查找逻辑,从而提升 UI 刷新性能。
因此推荐使用该方式获取'ViewModel'
此外,为了进一步简化调用,我们也在 common 库中封装了泛型方法:
TS
// common 库封装
export function getViewModel<T extends IHMLifecycle>(): T | undefined {
const lifecycle = HMRouterMgr.getCurrentLifecycleOwner()?.getLifecycle();
return lifecycle as T; // 安全的泛型转换
}
// 页面调用
private viewModel = getViewModel<CollectListLifeCycle>()
4.4 逻辑闭环:异步请求如何驱动 UI 刷新?
当网络请求在 LifeCycle 中异步完成后,UI 层的刷新组件(RefreshController)需要感知结果。这里展示两种实战思路:
方案 A:回调函数(Callback)模式
在调用请求方法时传入回调函数,请求结束后直接在回调中关闭 UI 动画。
TS
/**
* 该方法 通过 onFinished 触发 UI
* @param page
* @param onFinished
*/
collectArticleList(onFinished?: (success: boolean, hasMore: boolean) => void) {
UserApi.getCollectList(this.page).then((collectList: CollectListResult) => {
if (this.page == 0) {
this.listArticle = collectList.datas
} else {
this.listArticle = this.listArticle.concat(collectList.datas)
}
this.hasMore = collectList.curPage < collectList.pageCount;
onFinished?.(true, this.hasMore)
}).catch((error: Any) => {
onFinished?.(false, true)
ToastUtil.showShort(error?.toString())
})
}
// CollectListPage.ets
this.viewModel.collectArticleList( (success, hasMore) => {
this.stopRefreshOrLoadAnim(success, hasMore)
})
方案 B:状态监听(Monitor)模式(高级进阶)
这是本章最硬核的技巧。在 LifeCycle 中定义一个自增的种子 requestTrigger。 在 Page 层,我们通过 @Monitor 盯住这个种子:
kotlin
@ObservedV2
@HMLifecycle({ lifecycleName: CollectListLifeCycle.lifeCycleName })
export class CollectListLifeCycle implements IHMLifecycle {
// 用于触发监听的自增种子(如果状态值没变,但请求结束了,靠它触发 UI)
@Trace requestTrigger: number = 0;
onShown(ctx: HMLifecycleContext): void {
this.page=0
this.collectArticleList2()
}
/**
* 该方法 通过 requestTrigger+@Monitor 触发 UI
* @param page
*/
collectArticleList2() {
this.loadStatus = 'LOADING'
UserApi.getCollectList(this.page).then((collectList: CollectListResult) => {
if (this.page == 0) {
this.listArticle = collectList.datas
} else {
this.listArticle = this.listArticle.concat(collectList.datas)
}
this.hasMore = collectList.curPage < collectList.pageCount;
this.loadStatus = 'SUCCESS';
}).catch((error: Any) => {
this.loadStatus = 'FAILED';
ToastUtil.showShort(error?.toString())
}).finally(() => {
this.requestTrigger++; // 每次请求结束自增,确保 Monitor 能抓到
})
}
}
// CollectListPage.ets
@Monitor('viewModel.requestTrigger')
onDataRequestFinished() {
// 只要 requestTrigger 变了(无论数据变没变),都会触发此方法
const isSuccess = this.viewModel.loadStatus === 'SUCCESS';
this.stopRefreshOrLoadAnim(isSuccess, this.viewModel.hasMore);
}
妙处 :每次请求结束 requestTrigger++。这种"事件种子"模式实现了 UI 与逻辑的完全异步通知,避免了回调地狱
5、结语
OK!恭喜你读到了这里!本篇实战到此结束。从架构集成到首页、收藏页的逻辑闭环,这几篇文章记录了我从 Android 转战鸿蒙的过程。虽然 WanAndroid 的功能还没完全搬空,但核心的 UI 渲染、多维生命周期感知、以及基于 HMRouter 的 MVVM 模式已经全部交代清楚了。
接下来的日子要投入到忙碌的工作中了,鸿蒙的生态还在快速演进,希望这几篇实战笔记能像"火种"一样,帮你照亮刚入坑时的迷茫。代码不止,架构不息,我们江湖再见!
文章讲解的Demo已在 GitHub 开源,包含完整的架构封装与业务实现,欢迎大家 Star 与 Fork,我们一起完善它!
- WanAndroid 鸿蒙版仓库 :github.com/Huangqianku...