鸿蒙项目实战:手把手带你实现 WanAndroid 布局与交互

前言

在上一篇# 鸿蒙项目实战:手把手带你从零架构 WanAndroid 鸿蒙版 文章中,讲解了如何从零搭建项目架构。在本篇中,我将基于上一篇基础上带你手把手带你实现 WanAndroid 布局与交互!

本文所需要掌握的知识点:

  1. # 鸿蒙零基础语法入门:开启你的开发之旅
  2. # 鸿蒙 ArkUI 从零到精通:基础语法全解析
  3. # 鸿蒙ArkUI:状态管理、应用结构、路由全解析

话不多说!直接开始!

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/elseForEachLazyForEachRepeat)方式渲染,

这里就以LazyForEachRepeat渲染方式为例,毕竟这两个都属于懒加载渲染,而它们使用的方式有很大的区别。

官方推荐用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/ListViewAdapter 必须实现特定的方法一样。

  • 基座封装(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 ComposeFlutter。它抹平了底层的通知细节,让我们能把精力 100% 放在业务交互上。

OK!既然Banner功能实现了,接下来就是列表了!

1.2 首页列表功能实现

我们继续看官方介绍:

如图所示

  • 这是官方关于列表功能的组件 List
  • 它支持(ForEachLazyForEachRepeat)方式渲染

在 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:消除重复的"样式代码"

观察 listItemAViewlistItemView,你会发现它们有共同的边距、圆角和背景色。

  • 做法 :我们提取了一个 @Styles 装饰的方法 listItemStyle()
  • 价值:它不属于组件,也不产生额外的嵌套层级,仅仅是一组属性的集合。

在 Android 中,我们习惯了用 SwipeRefreshLayout 配合 RecyclerView。在鸿蒙 ArkUI V2 的世界里,虽然 UI 渲染变得声明式了,但'下拉、请求、刷新 UI'这一套经典动作依然是首页的刚需。本节我们将探讨如何利用社区优秀的插件实现丝滑的刷新效果,并聊聊在鸿蒙中如何优雅地处理分页状态。

1.3 下拉刷新与分页加载

在上一篇架构篇中,我们初步集成了刷新组件并引入了统一版本管理,但在实际开发 WanAndroid 首页的过程中,我发现之前的方案存在两个严重的"硬伤"。为了追求更纯粹的 V2 开发体验,我决定在 UI 实战篇进行一次彻底的"架构排毒"。

1.3.1 刷新插件的"断舍离":为什么弃用 @abner/refresh_v2?

如图所示

这是关于@abner/refresh_v2使用方式。在首页实战中,它的局限性开始显现:

  • V2 兼容性差 :该插件目前的文档说明显示其核心逻辑深度依赖于 LazyForEachForEach 。对于我们想要全面拥抱的 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 iohpm i 的开发者来说极度不友好。这意味着你无法快速尝试一个新的库,必须反复进行"复制、粘贴、全局同步"的机械动作。 :(

槽点吐槽完了,我们回归主题

1.3.3 刷新组件集成

如图所示

  1. 在工程根目录我们手动创建的oh-package-parameter.json5里,修改刷新组件的版本号:"refresh_v2": "^2.0.8"
  2. 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 开发中,我们习惯了 ActivityonCreateonResume 一把梭。但在鸿蒙(HarmonyOS)的多维体系下,如果你不理清生命周期的层级,就很容易遇到"数据不刷新"、"动画在后台空跑"或者"重复请求网络"的尴尬。

我们要讲解网络请求,必须先弄清楚:在哪个层级、哪个时机触发请求,才是最优雅的。

2.1 UIAbility 生命周期:全局的"大管家"

这是最顶层的生命周期,由系统直接调度。它处理的是 App 的生存状态。

  • 主要阶段onCreate(创建)、onForeground(切前台)、onBackground(切后台)、onDestroy(销毁)
  • 实战定位:它不参与具体的 UI 业务,主要负责全局配置(如:沉浸式状态栏适配、窗口避让逻辑等)。
  • 温馨提示 :关于这一层的详细解释,我在鸿蒙ArkUI:状态管理、应用结构、路由全解析中已经深度解析过,本篇主要关注业务层,不再赘述。

如图所示

为了方便大家在实战中不找错钩子,我根据这两张流程图,总结出了它们最核心的两个差异点:

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

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 修饰,它在系统眼中只是一个普通的自定义组件

  • 能力 :它拥有官方定义的 aboutToAppearaboutToDisappear
  • 局限性 :自定义组件的生命周期只管"出生(挂载)"和"死亡(销毁)" 。它听不到页面的呼唤 ------当用户在 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,否则组件虽然在视觉上消失了,逻辑却可能在后台常驻。
    TS 复制代码
          aboutToDisappear(): 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. 首页功能逻辑闭环 。正式编写 getHomeBannergetHomeArticleList,看看网络请求是如何在这些生命周期的交织中,有条不紊地填充我们的首页。

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 开源,包含完整的架构封装与业务实现,欢迎大家 StarFork,我们一起完善它!

相关推荐
TT_Close2 小时前
【Flutter×鸿蒙】一个"插队"技巧,解决90%的 command not found
flutter·harmonyos
是糖糖啊2 小时前
OpenClaw 从零到一实战指南(飞书接入)
前端·人工智能·后端
Despupilles2 小时前
第三篇、基本骨架结构
前端
LING2 小时前
RN容器启动优化实践
android·react native
swipe2 小时前
从原理到手写:彻底吃透 call / apply / bind 与 arguments 的底层逻辑
前端·javascript·面试
踩着两条虫2 小时前
从设计稿到代码:VTJ.PRO 的 AI 集成系统架构解析
前端·vue.js·人工智能
Mapmost2 小时前
从“雕琢”到“生成”:AIGC正在重塑数字孪生世界
前端
掘金一周2 小时前
Claude Code 换成了Kimi K2.5后,我再也回不去了 | 掘金一周 3.5
前端·人工智能·agent
JasonYin2 小时前
ViewModel 知识体系思维导图
前端