所有菜单项都是"死的"。收藏夹点了没反应,浏览记录点了没反应,连客服中心都跳不过去。页面长得像那么回事,但本质上是一张静态图片。这次优化的核心不只是"加上点击跳转",而是重新审视了整个页面的组件化策略——哪些东西应该抽成@Builder,@Builder怎么支持交互,分组逻辑怎么调整才合理。
完整效果
在这里插入图片描述

优化前后的结构对比

先看整体变化。上一版的代码结构是这样的:

Column() {
  Scroll() {
    Column() {
      Header
      User card(无onClick)
      Orders(OrderIcon无onClick)
      Menu group 1(MenuRow无action参数,Divider内联)
      Menu group 2(MenuRow无action参数,Divider内联)
    }
  }
}

新版的结构:

Column() {
  Scroll() {
    Column() {
      Header
      User card(有onClick → MessagePage)
      Orders(OrderIcon有onClick → MessagePage)
      Menu group 1(Menu有action参数,Div()独立Builder)
      Menu group 2(Menu有action参数,Div()独立Builder,新增消息中心和关于)
    }
  }
}

在这里插入图片描述

表面上看只是加了onClick,但背后的改动涉及三个层面:组件接口设计(@Builder参数)、代码组织(Div抽取)、交互架构(路由覆盖)。

@Builder Menu 的回调参数机制

上一版的问题

上一版的菜单行Builder:

@Builder MenuRow(label: string, icon: string) {
  Row() {
    Text(icon).fontSize(18).margin({ right: 14 })
    Text(label).fontSize(14).fontColor(T1).layoutWeight(1)
    SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(12).fontColor(['#DDD'])
  }.width('100%').padding({ left: 18, right: 18, top: 14, bottom: 14 })
}

两个参数:图标和文字。调用时只能传静态文本:

this.MenuRow('收藏夹', '❤️')
this.MenuRow('浏览记录', '🕐')

如果要给每个菜单行加不同的点击事件,有两个选择:

选择A:复制粘贴。 把MenuRow的内容复制到每个菜单行里,直接在Row上加onClick。代码量翻倍,改样式要改8个地方。

选择B:在MenuRow外部包一层。 每个菜单行外面再套一个Row,把onClick绑在外层。但这样会导致嵌套层级变深,点击区域计算出错(内层Row的padding和外层Row的onClick区域可能不一致)。

两个选择都不好。

新版的解法

@Builder Menu(label: string, icon: string, action: () => void) {
  Row() {
    Text(icon).fontSize(18).margin({ right: 14 })
    Text(label).fontSize(14).fontColor(T1).layoutWeight(1)
    SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(12).fontColor(['#DDD'])
  }.width('100%').padding({ left: 18, right: 18, top: 14, bottom: 14 }).onClick(action)
}

在这里插入图片描述

第三个参数action: () => void是关键。这是一个函数参数,调用时传入箭头函数:

this.Menu('收藏夹', '❤️', () => { router.pushUrl({ url: 'pages/MessagePage' }) })
this.Menu('客服中心', '💬', () => { router.pushUrl({ url: 'pages/ServiceChat' }) })

每个菜单项传入不同的跳转逻辑,Builder本身不需要知道"跳到哪里"——它只负责渲染UI和绑定点击事件,具体跳转逻辑由调用方决定。这就是UI和逻辑的解耦

@Builder函数参数的延迟执行特性

ArkUI的@Builder函数参数和普通TypeScript函数参数有一个关键区别:延迟执行

普通函数调用:

function doSomething(callback: () => void) {
  callback()  // 立即执行
}
doSomething(() => { console.log('hello') })  // 打印 'hello'

@Builder调用:

@Builder Menu(label: string, icon: string, action: () => void) {
  Row() { ... }.onClick(action)  // 不会立即执行action
}
this.Menu('收藏夹', '❤️', () => { router.pushUrl(...) })  // 不会立即跳转

action不会在this.Menu()调用时执行,而是在用户点击Row时执行。这是因为@Builder的函数参数被"捕获"到了onClick的回调里,变成了事件处理函数。

这个行为很重要:如果@Builder的函数参数是立即执行的,那么页面一创建就会跳转——用户还没看到页面就被导航走了。延迟执行保证了"点击才触发"的交互逻辑。

@Builder函数参数的限制

@Builder的函数参数不支持可选参数和默认值。以下写法是错误的:

// 错误:ArkUI不支持可选参数默认值
@Builder Menu(label: string, icon: string, action: () => void = () => {}) {
  ...
}

如果某个菜单行不需要点击事件,只能传空函数:

this.Menu('设置', '⚙️', () => {})  // 空函数,点击无反应

这是ArkUI的语法限制。@Builder本质上是UI模板,不是完整的函数,它的参数列表比TypeScript函数更严格。在实际开发中,几乎所有菜单行都需要点击事件,所以这个限制的影响不大。

@Builder Div 的抽取

上一版的重复

上一版的菜单组:

Column({ space: 0 }) {
  this.MenuRow('收藏夹', '❤️')
  Divider().strokeWidth(0.5).color('#F0EEF4').margin({ left: 52 })
  this.MenuRow('浏览记录', '🕐')
  Divider().strokeWidth(0.5).color('#F0EEF4').margin({ left: 52 })
  this.MenuRow('地址管理', '📍')
  Divider().strokeWidth(0.5).color('#F0EEF4').margin({ left: 52 })
  this.MenuRow('优惠券', '🎫')
}

Divider出现了三次,每次都是一模一样的代码。如果要改分割线的颜色(比如从#F0EEF4改成#E0E0E0),需要改三个地方。漏改一个,页面上就会出现两种颜色的分割线——用户可能注意不到,但作为开发者自己知道"这里有个不一致"。

新版的抽取

@Builder Div() {
  Divider().strokeWidth(0.5).color('#F0EEF4').margin({ left: 52 })
}

调用时:

Column({ space: 0 }) {
  this.Menu('收藏夹', '❤️', () => { ... })
  this.Div()
  this.Menu('浏览记录', '🕐', () => { ... })
  this.Div()
  this.Menu('地址管理', '📍', () => { ... })
  this.Div()
  this.Menu('优惠券', '🎫', () => { ... })
}

在这里插入图片描述

分割线的样式只在一个地方定义。改颜色、改粗细、改左边距,只需要改@Builder Div一处。这是"单一职责原则"在UI层面的体现——每个设计决策只有一个定义位置。

为什么不用 space 属性

Column有space属性可以自动在子元素之间加间距,但不能用它来替代Div。因为space只能加空白间距,不能加分割线。如果用space,菜单行之间只有空白,没有视觉分隔。而且space的间距是均匀的,但我们需要的是"菜单行-分割线-菜单行"的三明治结构。

菜单分组的逻辑重构

上一版的分组

  • 第一组:收藏夹、浏览记录、地址管理、优惠券
  • 第二组:客服中心、设置

新版的分组

  • 第一组:收藏夹、浏览记录、地址管理、优惠券
  • 第二组:客服中心、消息中心、设置、关于

分组依据

分组不是随意的,而是基于用户心智模型。用户打开"我的"页面,脑子里的思考路径是:

  1. “我想看看我的订单” → 订单区域
  2. “我想看看我收藏的东西” → 第一组(交易相关)
  3. “我想找客服/改设置” → 第二组(系统相关)

第一组的四个功能都和"购物"直接相关:收藏夹是想买的东西,浏览记录是看过的东西,地址管理是买东西需要的,优惠券是买东西能省钱的。它们共享一个心智模型:“和买东西有关”。

第二组的四个功能是"管理账号":客服是遇到问题找人帮忙,消息是看系统通知,设置是改账号参数,关于是看版本信息。它们共享另一个心智模型:“和账号管理有关”。

如果把"消息中心"放到第一组,用户会觉得困惑——消息中心和"购物"有什么关系?放第二组更自然,因为消息中心是"系统通知",属于账号管理的范畴。

分组的数量控制

为什么第一组是4项而不是5项或3项?这是移动端设计的经验值:

  • 3项以下:太少,浪费屏幕空间,用户觉得"这里没什么东西"
  • 4-5项:刚好,一屏能看完,不需要滚动
  • 6项以上:太多,需要滚动,用户可能看不到底部的项

4项是"个人中心菜单"的常见数量。如果以后要加新功能(比如"会员中心"),可以考虑把它放到第一组,变成5项——还在舒适区内。如果超过5项,就需要考虑拆分成更多组,或者用"查看更多"的折叠设计。

导航路由的完整覆盖

路由映射表

区域 菜单项 跳转目标 状态
用户卡片 Hi, 用户 MessagePage 占位
订单 待付款 MessagePage 占位
订单 待发货 MessagePage 占位
订单 待收货 MessagePage 占位
订单 待评价 MessagePage 占位
订单 售后 MessagePage 占位
第一组 收藏夹 MessagePage 占位
第一组 浏览记录 MessagePage 占位
第一组 地址管理 MessagePage 占位
第一组 优惠券 MessagePage 占位
第二组 客服中心 ServiceChat 真实页面
第二组 消息中心 MessagePage 占位
第二组 设置 MessagePage 占位
第二组 关于 MessagePage 占位

14个可点击区域,只有1个跳转到真实功能页面(客服中心→ServiceChat),其他13个都跳转到MessagePage作为占位。

占位导航的价值

你可能会问:既然都是占位,为什么不等页面开发完再加路由?原因有三个:

1. 验证交互流程。 即使目标页面是占位的,用户点击后能看到"页面跳转了",就能感知到"这个入口是活的"。在产品评审时,可交互的原型比静态页面更有说服力。

2. 发现导航问题。 如果等到所有页面都开发完再加路由,可能会发现"这个页面的返回按钮不对"、“那个页面的参数传递有问题”。提前加上路由,能在开发早期发现导航架构的问题。

3. 建立路由清单。 路由表本身就是一份"页面清单"——哪些页面需要开发、每个页面的路由路径是什么、需要传递什么参数。这份清单比任何文档都准确,因为它就是代码本身。

路由路径的命名规范

路由路径用的是pages/xxx格式,比如pages/ServiceChatpages/MessagePage。这个命名有隐含的规范:

  • pages/前缀:表示这是页面级组件,不是普通组件
  • PascalCase命名:ServiceChatMessagePage,和组件名一致
  • 语义化命名:ServiceChat表示客服聊天,MessagePage表示消息页面

如果以后要重构路由(比如改成pages/service/chat),只需要改路由配置文件,不需要改每个页面的pushUrl调用——因为路由路径是字符串,可以在一个地方统一管理。

用户卡片的交互设计

渐变头像的点击区域

用户卡片是一个Row,整个Row都绑定了onClick:

Row() {
  Stack() { ... }  // 头像
  Column() { ... }  // 文字信息
  SymbolGlyph(...)  // 右箭头
}.width('100%').padding(18).onClick(() => { router.pushUrl({ url: 'pages/MessagePage' }) })

点击区域是整个Row(包括头像、文字、箭头),而不是只有箭头。这是移动端的常见模式——卡片级别的点击比元素级别的点击更容易操作。用户不需要精确点击箭头,随便点卡片的任何位置都能触发跳转。

右箭头的暗示

SymbolGlyph的chevron_right(›)是一个"可导航"的视觉暗示。它告诉用户"这里有下一级页面"。如果去掉这个箭头,用户可能不知道卡片可以点击。箭头的存在降低了用户的认知负担——不需要思考"这里能不能点",看到箭头就知道"能点"。

订单区域的交互变化

OrderIcon 的 onClick

上一版的OrderIcon是纯展示的:

@Builder OrderIcon(label: string, icon: string) {
  Column({ space: 6 }) {
    Text(icon).fontSize(24)
    Text(label).fontSize(11).fontColor(T2)
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
}

新版加了onClick:

@Builder OrderIcon(label: string, icon: string) {
  Column({ space: 6 }) {
    Text(icon).fontSize(24)
    Text(label).fontSize(11).fontColor(T2)
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
  .onClick(() => { router.pushUrl({ url: 'pages/MessagePage' }) })
}

五个订单图标都能点击了。虽然都跳转到MessagePage(占位),但用户能感知到"这些图标是可交互的"。

订单标题行的"全部 ›"

Row() {
  Text('我的订单').fontSize(16).fontWeight(FontWeight.Bold).fontColor(T1)
  Blank()
  Text('全部 ›').fontSize(12).fontColor(T3)
}.width('100%').margin({ bottom: 14 }).onClick(() => { router.pushUrl({ url: 'pages/MessagePage' }) })

"全部 ›"是一个导航入口,点击后应该跳转到全部订单列表页。这里把onClick绑在整个Row上,而不是只绑在"全部 ›"文字上。原因是:用户可能点击"我的订单"文字区域(比如想看看有没有新订单),也应该触发跳转。绑在整个Row上更宽容,减少"点错位置没反应"的挫败感。

三个 @Builder 的协作模式

经过这次优化,ProfilePage形成了三个@Builder的协作体系:

@Builder OrderIcon(label: string, icon: string)  // 纯展示+简单点击
@Builder Menu(label: string, icon: string, action: () => void)  // 展示+自定义交互
@Builder Div()  // 纯装饰

三个Builder的分工:

Builder 职责 参数 交互
OrderIcon 订单状态图标 icon + label 固定跳转
Menu 功能菜单行 icon + label + action 自定义跳转
Div 分割线

OrderIcon的跳转是固定的(都跳MessagePage),所以不需要action参数。Menu的跳转各不相同,所以需要action参数。Div没有任何交互,只是装饰元素。

这种分层是ArkUI组件化的基本模式:按交互复杂度分层。纯展示的用最简单的Builder,有交互的加回调参数,纯装饰的用无参数Builder。每一层的复杂度刚好满足需求,不多不少。

和 @Component 的区别

@Builder和@Component都能封装UI,但它们的定位不同:

  • @Builder:UI模板,无状态,轻量,适合"样式片段"的复用
  • @Component:完整组件,有状态(@State),适合"功能模块"的复用

ProfilePage的Menu是@Builder而不是@Component,因为它不需要自己的状态——它只是渲染一行菜单,点击时执行调用方传入的函数。如果Menu需要管理自己的状态(比如"已读/未读"、“选中/未选中”),那就应该用@Component。

用@Builder而不是@Component的好处是:更轻量,创建和销毁的开销更小,渲染性能更好。对于菜单行这种高频创建的UI片段,@Builder是更合适的选择。

踩坑记录

@Builder函数参数的this绑定。 @Builder的action参数是一个箭头函数() => { router.pushUrl(...) },箭头函数会捕获外层的this。但如果在action里访问this.xxx(xxx是@State变量),可能拿到的不是最新值。因为@Builder的函数参数在创建时就被捕获了,闭包里的this指向创建时的组件实例。如果@State变量在创建后被修改了,闭包里的this可能还是旧值。解决办法是在action内部通过路由参数传递数据,而不是直接读取@State。

Divider左边距和Menu padding的配合。 Divider的margin({ left: 52 })需要和Menu的padding({ left: 18 })配合。Menu的padding是18px,图标字号18px(emoji实际渲染宽度约11px),图标和文字之间有margin({ right: 14 })。所以文字从18 + 11 + 14 ≈ 43px处开始。Divider的margin是52px,比文字起始位置多了约9px——这是分割线和文字之间的"呼吸间距"。如果这个间距太小(比如改成45px),分割线会紧贴文字;太大(比如60px),分割线会向右偏移,和文字不对齐。

Column({ space: 0 }) 的必要性。 菜单组用Column({ space: 0 })而不是Column()space: 0确保子元素之间没有额外间距——间距完全由Menu的padding和Div的margin控制。如果用Column(),ArkUI可能会根据默认样式添加微小的间距,导致菜单行之间的距离不一致。space: 0是"显式声明无间距"的防御性写法。

scrollBar(BarState.Off) 的选择。 个人中心的滚动条隐藏了,因为内容不会太长(通常一屏能看完)。但如果菜单项增多(比如加了会员中心、积分商城等),页面可能需要滚动。这时隐藏滚动条会导致用户不知道"下面还有内容"。更好的方案是:内容短时隐藏滚动条,内容长时显示滚动条。但这需要动态计算内容高度,实现成本较高,这里为了简洁省略了。


Logo

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

更多推荐