
菜单是交互密度最高却最少被认真设计的组件——它出现在长按、点更多按钮、工具栏右键等各种入口,承载高频操作,但大多数应用里的菜单就是一排文字的堆叠。健康菜谱助手的菜单体系覆盖三个场景:菜谱卡片的快捷操作、列表项长按的上下文菜单、工具栏的批量操作。这篇复盘 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)