HarmonyOS购物商城——个人中心页面优化
所有菜单项都是"死的"。收藏夹点了没反应,浏览记录点了没反应,连客服中心都跳不过去。页面长得像那么回事,但本质上是一张静态图片。这次优化的核心不只是"加上点击跳转",而是重新审视了整个页面的组件化策略——哪些东西应该抽成@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的间距是均匀的,但我们需要的是"菜单行-分割线-菜单行"的三明治结构。
菜单分组的逻辑重构
上一版的分组
- 第一组:收藏夹、浏览记录、地址管理、优惠券
- 第二组:客服中心、设置
新版的分组
- 第一组:收藏夹、浏览记录、地址管理、优惠券
- 第二组:客服中心、消息中心、设置、关于
分组依据
分组不是随意的,而是基于用户心智模型。用户打开"我的"页面,脑子里的思考路径是:
- “我想看看我的订单” → 订单区域
- “我想看看我收藏的东西” → 第一组(交易相关)
- “我想找客服/改设置” → 第二组(系统相关)
第一组的四个功能都和"购物"直接相关:收藏夹是想买的东西,浏览记录是看过的东西,地址管理是买东西需要的,优惠券是买东西能省钱的。它们共享一个心智模型:“和买东西有关”。
第二组的四个功能是"管理账号":客服是遇到问题找人帮忙,消息是看系统通知,设置是改账号参数,关于是看版本信息。它们共享另一个心智模型:“和账号管理有关”。
如果把"消息中心"放到第一组,用户会觉得困惑——消息中心和"购物"有什么关系?放第二组更自然,因为消息中心是"系统通知",属于账号管理的范畴。
分组的数量控制
为什么第一组是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/ServiceChat、pages/MessagePage。这个命名有隐含的规范:
pages/前缀:表示这是页面级组件,不是普通组件- PascalCase命名:
ServiceChat、MessagePage,和组件名一致 - 语义化命名:
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) 的选择。 个人中心的滚动条隐藏了,因为内容不会太长(通常一屏能看完)。但如果菜单项增多(比如加了会员中心、积分商城等),页面可能需要滚动。这时隐藏滚动条会导致用户不知道"下面还有内容"。更好的方案是:内容短时隐藏滚动条,内容长时显示滚动条。但这需要动态计算内容高度,实现成本较高,这里为了简洁省略了。
更多推荐

所有评论(0)