ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【共创稿事节】HarmonyOS 7.0 Menu菜单与上下文:从接入到生产验证

【共创稿事节】HarmonyOS 7.0 Menu菜单与上下文:从接入到生产验证

菜单是交互密度最高却最少被认真设计的组件——它出现在长按、点更多按钮、工具栏右键等各种入口,承载高频操作,但大多数应用里的菜单就是一排文字的堆叠。健康菜谱助手的菜单体系覆盖三个场景:菜谱卡片的快捷操作、列表项长按的上下文菜单、工具栏的批量操作。这篇复盘 Menu 从接入到治理的全过程:三种入口的分工、bindMenu 与 bindContextMenu 的选择、动态构建、以及回调引用过期状态的事故。

一、菜单的三个入口:位置决定形态

同一批菜单项,在不同入口的呈现形态不同。入口一是点击"更多"图标,菜单从按钮下方弹出,普通 bindMenu 即可;入口二是列表项长按,期望带预览图(用户长按时看到的是"这张菜谱卡片要被操作了"的视觉锚点),用 bindContextMenu 带 preview;入口三是工具栏的长按批量模式,菜单项是批量动作(多选、全选、导出),形态同入口二但菜单项不同。三入口共用同一份"操作定义"数据源(操作名、图标、回调、危险级),各自按入口特性选渲染方式——操作定义一处维护,形态随入口变化,这是菜单体系的骨架。最初三个入口各写各的菜单项,同一操作三处改名只改了一处,用户反馈"长按里叫收藏、更多里叫加入收藏",当周统一到数据源。

入口触发方式绑定方式预览
更多按钮点击bindMenu无
卡片长按长按bindContextMenu卡片截图预览
工具栏长按/点击bindContextMenu无

二、MenuBuilder:菜单项的声明式写法

bindMenu 接一个 @Builder,里面用 Menu 包 MenuItem 的结构声明。声明式的直接收益是菜单项能用条件渲染——按状态显隐、按角色过滤,都是 builder 里的 if,不需要命令式地"构造再填充"。写 builder 的规范:菜单项不超过六项,超过的按使用频率裁剪进二级;危险操作(删除)放最后并用红色文字;每项配图标(16vp,视觉统一);当前态不可用的项显示禁用态而不是隐藏——隐藏会让用户以为功能没了,禁用传达的是"现在不可用"。这四条是菜单可读性的底线,全应用菜单统一执行。

@Builder recipeMenu(recipe: RecipeItem) { Menu() { MenuItem({ startIcon: $r('app.media.ic_fav'), content: '收藏' }) .onClick(() => this.doFav(recipe)) MenuItem({ startIcon: $r('app.media.ic_plan'), content: '加入计划' }) MenuItem({ startIcon: $r('app.media.ic_share'), content: '分享' }) MenuSeparator() // 危险区隔离 MenuItem({ startIcon: $r('app.media.ic_del'), content: '删除' }) .contentFontColor('#E5484D') .onClick(() => this.confirmDelete(recipe)) } } // 绑定:更多按钮 Image($r('app.media.ic_more')) .bindMenu(this.recipeMenu(recipe))

三、bindContextMenu 与预览:长按的视觉锚点

长按菜单的体验关键是预览:用户长按卡片,菜单弹出同时卡片的预览图浮在菜单旁——确认"我在操作这个东西"。bindContextMenu 的 preview 参数三选一:MenuPreviewMode.NONE(无预览)、TARGET(当前组件截图)、CUSTOM(自定义 builder)。卡片菜单用 TARGET 自带截图,零成本拿到带圆角的卡片快照;自定义预览用在"预览要遮隐私"的场景(分享前预览里隐去价格)。预览的取舍:有预览的菜单确认感强,但多一层视觉层,菜单项少(两三项)时预览反而拖慢操作——轻量菜单(取消收藏这种单动作)不给预览,直接弹菜单。长按的手势时长系统默认 500 毫秒,菜谱卡片实测调成系统默认即可,自定义更短的时间会误触(用户只是想拖动卡片)。

RecipeCard() .bindContextMenu(this.recipeMenu(recipe), ResponseType.LongPress, { preview: MenuPreviewMode.TARGET, // 卡片快照作预览 offset: { x: 8, y: 8 } })

四、动态构建:按状态过滤的单一数据源

菜单项随状态变化:已收藏的菜谱菜单里"收藏"变"取消收藏";离线模式下"分享"变禁用;计划已满时"加入计划"禁用并带原因提示。实现走单一数据源:buildMenuItems(recipe) 纯函数返回菜单项数组(含 enabled、danger 标记),builder 消费数组渲染——菜单逻辑可单测,渲染层零逻辑。这个设计在"批量操作"场景收益最大:工具栏菜单的批量动作按当前选中数动态变化(选中 0 项时全禁用并显示"先选菜谱"),纯函数一处改,三个入口同步生效。反面教训是早期在 builder 里散写 if 判断,菜单逻辑与渲染混杂,同一个"是否可收藏"判断在三个入口写了三遍,改漏一处就是状态不一致。

function buildMenuItems(recipe: RecipeItem, offline: boolean): MenuItemDef[] { return [ { icon: 'ic_fav', text: recipe.faved ? '取消收藏' : '收藏', act: 'fav' }, { icon: 'ic_share', text: '分享', act: 'share', disabled: offline }, { icon: 'ic_del', text: '删除', act: 'del', danger: true } ] } // builder 消费数组,For Each 渲染,逻辑可测

五、真实事故:回调里的过期引用

长按菜单删除菜谱,确认后列表刷新了,但偶发删错条目——删的是"上一次长按的那一条"。排查:builder 闭包捕获的 recipe 参数来自长按时刻的列表项索引,列表异步刷新后索引易位,菜单回调执行时按索引取的数据已经是另一条。修复:菜单回调不再收索引,改收稳定的 recipeId,回调内按 id 现查数据(查不到就静默放弃并 toast)。更深一层的规范:菜单回调一律传"稳定键"(id),绝不传"位置"(index)——位置在列表动态变化时不可靠,id 才是数据的身份。这个规范和弹窗参数"只传键值"是同一条原则在不同组件上的投影:凡是异步延迟执行的回调,持久的键比易变的位置安全。

// 修复前:闭包捕获 index,列表刷新后错位 MenuItem({ content: '删除' }).onClick(() => this.delByIndex(idx)) // 修复后:传稳定 id,回调内现查 MenuItem({ content: '删除' }) .onClick(() => { const r = this.store.byId(recipe.id) if (!r) return this.toast('菜谱已不存在') this.confirmDelete(r) })

六、多级菜单与二级收敛:深度的克制

Menu 组件支持多级(MenuItemGroup 嵌套 submenu),但工程上二级是上限。项目里只有一个多级场景:分享菜单(分享到系统 / 复制链接 / 保存图片),最初做成二级子菜单,用户点击成本变高(先悬停再点击),改回一级平铺三项后操作步数从两步变一步。多级菜单的合理场景只有"分类明确的选项组"(排序方式:按时间/按名称),超过一层的嵌套都要重新审视——菜单是高频操作的快捷方式,每次展开都是成本,深度每加一层,到达率掉一截。规范:菜单层级 ≤1 级平铺,超过六项先裁剪,裁不掉的重新设计操作入口(部分操作降级到详情页内)。

七、位置与边界:菜单弹出的方向修正

菜单默认弹出方向是触发点右下,边界场景会出屏:列表底部的卡片长按,菜单往下弹一半在屏幕外。bindContextMenu 的 placement 参数支持 Bottom/Top/Right 等,但静态指定解决不了动态边界——菜单系统自带边界修正(出屏时自动翻转),实测多数场景够用;少数需要精确控制的(工具栏顶部按钮菜单必须向下弹,避免遮挡工具栏),静态 placement 加 offset 微调。另一个细节:菜单弹出后列表滚动了,菜单不会跟随(它锚定的是触发时刻的位置),滚动中菜单悬浮错位——处理是列表 onScroll 时强制关菜单(滚动即视为放弃操作),比错位悬浮体面。

调试菜单位置有个笨办法:把 placement 逐个枚举截图对比,比读文档想象可靠。屏幕坐标、组件坐标、弹出锚点三层坐标系叠加,文档读三遍不如真机点五遍。菜单位置类的问题统一放在真机回归里,模拟器的弹出位置和真机存在系统级偏差,以真机为准这条铁律在菜单上同样成立。

八、性能视角:菜单的构建时机

bindMenu 的 builder 不是打开时才构建——绑定即参与渲染树,长列表里一百张卡片各绑一份菜单 builder,菜单 DOM 的构建成本乘一百。优化两招:其一,菜单 builder 内容轻量化,只声明结构,图标资源用占位符懒加载(展开时才解析真图);其二,长列表卡片菜单收敛——列表滚动区域的卡片用系统自带的轻量菜单(纯文本项),重量级菜单(带预览、带图标)只给首页和详情页的卡片。实测一百项列表页的构建耗时从 180 毫秒降到 60 毫秒。菜单看似是"点开才存在"的浮层,声明成本却落在页面加载时——长列表场景把菜单当页面内容的一部分来优化,心态就对了。

九、菜单与手势的配合:快捷路径的取舍

菜单之外还有一层"手势直达"的快捷路径:列表项左滑出现收藏按钮、右滑出现删除——手势操作比菜单快一步,但两者并存时有语义重叠。取舍原则:一屏内高频操作(收藏)给手势直达,菜单里保留同名项兜底;低频或危险操作(删除)只进菜单,不给滑动手势(滑动误触的删除成本太高)。删除的菜单路径再套一层确认弹窗,双保险。手势与菜单的双轨并存要保证结果一致——同一收藏动作,手势执行和菜单执行的反馈动效、红点变化完全一致,用户不会感知到两条路径的差异。这类一致性靠"操作定义数据源"天然保证:两条路径调的是同一个 act 处理器。

十、无障碍与深浅色:菜单的底线适配

菜单适配清单:深浅色下菜单背景与文字走令牌(菜单是系统弹层,颜色适配半自动,自绘部分手动补);大字号下菜单项单行高度自适应,文字不截断;TalkBack 上每个菜单项的语义文本完整("删除,危险操作"),长按触发方式给无障碍替代路径——长按对部分用户不可达,列表项滑动操作或详情页内提供等价入口,菜单不能是唯一路径;焦点进菜单后项间导航顺序与视觉一致。适配里最容易被漏的是"替代路径":菜单里的删除,详情页里必须有同样的删除按钮——不是每个用户都能用长按。

十一、生产验证:菜单的四项回归

菜单回归固定四项:一、三入口一致性——同一操作在更多/长按/工具栏三处名称与图标一致;二、状态过滤——已收藏/离线/计划满三种状态下菜单项的显隐与禁用正确;三、回调时效——列表快速刷新后立即操作菜单,删除的条目正确(过期引用专项);四、边界与适配——列表首尾卡片长按菜单不出屏,大字号不截断,深色正常。四项里第三项是事故换来的专项:菜单回调的异步时效问题不测就一定漏,列表越复杂漏得越狠。

十二、总结:菜单体系的四条军规

收束成四条:一、操作定义单一数据源,三个入口共用,形态随入口变;二、长按菜单带预览给锚点,轻量菜单不带;三、回调只传稳定 id 不传 index,异步回调内现查数据;四、层级平铺不超过一级,超过六项先裁剪,菜单不能是功能的唯一路径。菜单的本质是"高频操作的快捷通道"——通道的意义在快,不在全。数据源统一、键值稳定、入口兜底,菜单就不再是"改一处漏两处"的重灾区。最后一条经验写给新接手的人:菜单改动的验证永远是三入口逐个点一遍,五分钟换来的确定性好过任何自信。

参考资料:

  • 华为开发者:bindMenu 与 bindContextMenu
  • 华为开发者:MenuPreviewMode 预览模式
  • 华为开发者:MenuItem 与 MenuSeparator

#鸿蒙 #HarmonyOS 7(API 26)

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进