HarmonyOS 6.1.1 Map Kit 双新特性实战:本地生活美食探店场景下的 reliability 相关性评分与地图长按事件监听全解析
一、技术前言:HarmonyOS ArkUI 与 Map Kit 在本地生活场景的深度融合

HarmonyOS 6.1.1 是华为鸿蒙生态中一次面向"空间智能"与"语义化检索"的重要迭代。在这一版本中,Map Kit(地图服务套件)不再只是简单地承载地图渲染与定位能力,而是向"理解用户意图、量化结果质量、丰富交互维度"三个方向纵深演进。对于本地生活类应用——尤其是美食探店这类对位置精度、搜索相关性、地图交互手感都极其敏感的场景——新版本的两大特性几乎是为这个赛道量身定制的。本文将以一个名为"食遇记·附近美食探店"的完整 ArkUI 应用为例,深度拆解这两大新特性的落地全过程。

第一个核心特性来自 site 模块的 searchByText 接口。在 6.1.1 之前,开发者调用 searchByText 搜索周边 POI 时,拿到的是一串 Site 结果数组,其中包含名称、地址、距离等字段,但结果之间"谁更相关、谁更无关"只能靠开发者自行估算距离来粗略判断。6.1.1 在 Site 类型上新增了 reliability 字段,这是一个取值范围为 [0,1] 的浮点数,1 表示完全相关,0 表示几乎无关。它由 Map Kit 的后端检索引擎综合计算得出,考量因素包括关键字匹配度、POI 权重、距离衰减、用户行为热度等多个维度。这意味着开发者终于有了一个官方的、量化的"搜索结果质量标尺",可以在 UI 层直接用分数条、等级标签、排序策略来呈现给终端用户,极大提升搜索结果的透明度和可信度。

第二个核心特性来自 MapEventManager 的事件体系扩展。6.1.1 之前,地图上的 Marker(自定义标注)只支持普通的 click 单击监听,POI(地图原生兴趣点)也只支持单击。但在真实探店场景中,用户往往需要"长按"一个店铺标记来触发收藏、备注、导航选点等操作——长按是一种比单击更具"确认意图"的交互手势,能有效避免误触。6.1.1 为此新增了两组共四个接口:onMarkerLongClick / offMarkerLongClick 用于订阅和取消订阅地图 Marker 的长按事件;onPoiLongClick / offPoiLongClick 用于订阅和取消订阅地图 POI 的长按事件。长按回调分别返回 map.Marker 和 mapCommon.Poi 对象,开发者可以从中取出坐标、ID、名称等信息,进而驱动收藏弹窗、导航跳转、事件日志记录等业务逻辑。

HarmonyOS ArkUI 框架是这一切的承载基座。它采用声明式 UI 范式,通过 @Entry / @Component / @State / @Builder 等装饰器,让开发者可以用接近自然语言的方式描述界面结构与状态驱动关系。ArkUI 的状态管理 V2 体系(@Observed + @ObjectLink + @State)能精确控制刷新粒度,避免整页重绘。MapComponent 作为 ArkUI 中的地图容器组件,通过 mapOptions 初始化参数和 mapCallback 异步回调完成地图引擎的加载与控制器获取,再经由 MapComponentController 桥接到 Marker、Polyline、Camera 等地图对象,最后通过 getEventManager() 拿到 MapEventManager 注册各类交互事件。这条链路从组件挂载到事件回调,构成了"地图即 UI、交互即数据"的完整闭环。

美食探店场景的特殊性在于:它既需要"广覆盖的检索"(周边 5 公里内所有本帮菜馆),又需要"精准的语义匹配"(用户搜"本帮菜"时,一家"本帮酱料专卖店"虽然关键字命中,但并非餐饮店,reliability 会很低),还需要"丰富的地图交互"(长按收藏、长按导航、长按写食记)。这三层需求恰好与 6.1.1 的两大新特性形成一一对应的落地映射。本文的应用正是围绕这三层需求构建了一个包含美食、地图、搜索、我的四个 Tab 的完整探店原型,将 reliability 量化为分数条与等级标签,将长按事件量化为可滚动的事件日志流,为读者提供一份从 API 签名到 UI 呈现的端到端参考实现。

此外,本应用还采用了一套贴合美食行业气质的浅色主题:奶白底色 #FBF5EC 营造温暖食欲氛围,番茄红 #D8402C 作为主色调呼应"番茄=美食"的视觉联想,暖橙 #F08A24 作为辅助色用于次要操作与中等等级标识,再以营业中状态绿 #3F9B54 点缀营业状态。整套配色不仅是视觉设计,更与功能语义深度绑定——reliability 高相关用番茄红、中相关用暖橙、低相关用浅驼色;评分≥4.6 用番茄红、≥4.2 用暖橙。颜色即信息,这是本地生活应用设计的高级技巧。下文将逐段拆解代码实现。

二、应用整体架构流程图
在深入代码之前,先用一张 Mermaid 流程图展示"食遇记"应用的整体架构与数据流转关系,帮助读者建立全局认知。
从流程图可以看出,应用以 Page1132 为入口,生命周期 aboutToAppear 触发地图回调初始化,随后在异步回调内依次完成控制器获取、事件管理器获取、Marker 群添加、双长按监听注册这条关键链路。UI 层则通过 currentTab 状态切换四个 Tab 页面,地图 Tab 承载长按事件日志流,搜索 Tab 承载 reliability 分数条展示,弹窗系统以 Stack 叠加层方式覆盖在主内容之上。整个架构清晰分层,数据流单向可追溯。
三、颜色系统与主题设计
3.1 颜色接口定义
/** 主题色板接口:集中声明页面所有颜色字段(奶白底+番茄红+暖橙浅色系) */
interface ColorPalette {
bg: string; // 页面奶白底色
card: string; // 卡片纯白底色
chip: string; // 胶囊与输入框底色
title: string; // 主标题深可可色
sub: string; // 副文本暖棕色
text3: string; // 弱文本浅驼色
red: string; // 番茄红主色
redD: string; // 深番茄红
redL: string; // 浅番茄红(渐变浅端)
orange: string; // 暖橙辅助色
green: string; // 营业中状态绿
line: string; // 分隔线米色
tabOn: string; // 底部 Tab 激活色
mask: string; // 弹窗遮罩色
codeBg: string; // 代码预览卡深底色(浅色主题也保留深底放代码文本)
}
这段代码定义了一个 ColorPalette 接口,将应用中所有颜色字段集中声明。采用接口而非直接使用字面量散落各处,是 ArkUI 工程化的重要实践:它强制开发者在修改颜色时只改一处常量,避免"改了主色忘改副色"的不一致问题。每个字段都有明确的语义注释,bg 是页面底色、card 是卡片底色、chip 是胶囊和输入框的底色,这种命名方式让颜色与用途绑定,而非与色值绑定——即使将来把番茄红换成其他红,字段名 red 依然成立。
字段类型全部为 string,因为 ArkUI 的 fontColor、backgroundColor、linearGradient 等属性均接受字符串格式的颜色值(包括 #RRGGBB 和 rgba() 形式)。值得注意的是 mask 字段使用的是 rgba(51,36,28,0.52) 半透明格式,而 codeBg 用的是 #2E1E16 深色格式——这说明接口设计兼顾了不同颜色表达形式,只要 string 能承载即可,体现了类型的包容性。
最后可以看到 redD(深番茄红)和 redL(浅番茄红)两个派生色,它们与主色 red 构成一个三级色阶。这在渐变 Banner(linearGradient 从 redL 到 card)和等级标签颜色映射中都会用到。一个完整的主题色板通常包含主色、深色、浅色三态,这里的设计正好契合了这一规范。
3.2 浅色主题色板常量
/** 浅色主题色板常量(食遇记 · 奶白底 + 番茄红 + 暖橙) */
const COLORS: ColorPalette = {
bg: '#FBF5EC',
card: '#FFFFFF',
chip: '#F3E9DA',
title: '#33241C',
sub: '#8C7565',
text3: '#BCA693',
red: '#D8402C',
redD: '#A02A18',
redL: '#F6C9BE',
orange: '#F08A24',
green: '#3F9B54',
line: '#ECDFCB',
tabOn: '#D8402C',
mask: 'rgba(51,36,28,0.52)',
codeBg: '#2E1E16'
};
COLORS 常量是整个应用的颜色中枢。bg #FBF5EC 是带有微暖色调的奶白底,比纯白 #FFFFFF 更有食欲氛围,同时不失清爽——这是美食类应用常用的"米汤色"。card #FFFFFF 用于卡片承载内容,与 bg 形成微弱对比让卡片"浮起来"。chip #F3E9DA 比 bg 更深一档,用于胶囊按钮和输入框底色,形成"底中有底"的层次感。
标题色 title #33241C 是接近深可可的暖黑,比纯黑 #000000 更柔和,与奶白底搭配不会产生刺眼的高对比;副文本 sub #8C7565 是暖棕色,用于次要信息;text3 #BCA693 是浅驼色,用于最弱层级文本如距离、时间。这三档文本色构成清晰的视觉层级,引导用户从标题到副文本再到弱信息的阅读节奏。
主色 red #D8402C 是典型的番茄红,饱和度高但不刺眼,用于主操作按钮、激活态 Tab、高相关等级标签等关键触点。redD #A02A18 是深番茄红,可用于 hover 或 pressed 态(本应用未深入使用但保留扩展空间)。redL #F6C9BE 是浅番茄红,用作渐变 Banner 的浅端,与 card 白色过渡形成柔和的"番茄奶昔"效果。orange #F08A24 暖橙用于次要操作和中相关等级,green #3F9B54 营业中绿用于营业状态——这三种功能色与主色形成完整的"等级-状态"语义体系。mask 用 rgba 半透明遮罩,codeBg #2E1E16 是深棕黑底,专门用于代码预览卡——浅色主题中保留一块深底放代码文本,是代码可读性的常见做法,因为等宽字体在深底浅字下对比更强。
四、常量定义与 Mock 数据
4.1 Tab 导航与筛选标签
/** Tab 元数据接口:底部导航图标 + 标签 */
interface TabMeta {
icon: string; // Tab 图标 emoji
label: string; // Tab 标签文案
}
/** 底部导航 Tab 常量列表(4 Tab 单排) */
const TAB_LIST: TabMeta[] = [
{ icon: '🍜', label: '美食' },
{ icon: '🗺', label: '地图' },
{ icon: '🔍', label: '搜索' },
{ icon: '👤', label: '我的' }
];
/** 头部横滑筛选 chips 文案(美食口味/品类筛选) */
const CATE_TAGS: string[] = ['全部', '本帮菜', '川湘火锅', '日料刺身', '烘焙甜品', '深夜食堂', '新店速报', '人均50内'];
TabMeta 接口为底部导航定义了数据结构:icon 存 emoji 图标,label 存中文标签。使用 emoji 而非图片资源有两个好处:一是无需引入图片资源文件,降低应用体积;二是 emoji 是矢量字符,在不同分辨率屏幕上始终清晰。TAB_LIST 常量定义了4个 Tab:美食、地图、搜索、我的,这正是本地生活探店应用的标准信息架构——美食是内容消费入口、地图是空间发现入口、搜索是主动检索入口、我的是个人中心入口。4个 Tab 单排排列在底部,是移动端最常见的导航模式。
CATE_TAGS 数组定义了头部横滑筛选 chips 的8个文案:从"全部"到"人均50内",覆盖了品类维度(本帮菜、川湘火锅、日料刺身、烘焙甜品)、场景维度(深夜食堂)、时效维度(新店速报)和价格维度(人均50内)。这种多维筛选是美食探店应用的特色——用户往往不是按单一维度筛选,而是"今晚想吃人均50以内的深夜食堂"这种组合需求。chips 横滑设计能在有限屏幕宽度内容纳全部选项,同时保持可扩展性——将来新增"素食友好""亲子餐厅"等标签只需在数组中追加元素,UI 自动适配。
4.2 城市中心点与地图标注数据
/** 城市中心点(上海人民广场,地图初始化中心与 Map Kit 搜索 location 参数) */
const CITY_CENTER: mapCommon.LatLng = { latitude: 31.2304, longitude: 121.4737 };
/** 地图标注点接口(美食店铺 Marker 群,长按事件的数据来源) */
interface SpotItem {
name: string; // 店铺名称
lat: number; // 纬度
lng: number; // 经度
tag: string; // 品类标签
}
/** 美食店铺标注点 Mock 数据(6 个,围绕城市中心点 ±0.02 度散布) */
const MARKER_SPOTS: SpotItem[] = [
{ name: '食遇记·豫园老字号菜馆', lat: 31.2271, lng: 121.4891, tag: '本帮菜' },
{ name: '食遇记·云南南路小吃街', lat: 31.2252, lng: 121.4781, tag: '小吃' },
{ name: '食遇记·天钥桥路火锅楼', lat: 31.2150, lng: 121.4620, tag: '火锅' },
{ name: '食遇记·巨鹿路日料亭', lat: 31.2189, lng: 121.4545, tag: '日料' },
{ name: '食遇记·张杨路烘焙坊', lat: 31.2358, lng: 121.4900, tag: '烘焙' },
{ name: '食遇记·定西路深夜食堂', lat: 31.2130, lng: 121.4550, tag: '烧鸟' }
];
CITY_CENTER 使用 mapCommon.LatLng 类型定义了城市中心点坐标——上海人民广场(纬度 31.2304,经度 121.4737)。这个常量承担双重职责:一是作为 MapComponent 初始化时 mapOptions.position.target 的值,让地图加载后自动居中到这个坐标;二是作为 site.searchByText 的 location 参数,让搜索以这个点为中心向四周辐射。一个常量打通"地图渲染"和"POI 检索"两个场景,体现了常量复用的设计思想。
SpotItem 接口定义了地图标注点的数据结构,包含名称、纬度、经度、品类四个字段。MARKER_SPOTS 数组提供了6条 Mock 数据,每条都是一家虚拟美食店铺,坐标围绕 CITY_CENTER 上下浮动约 ±0.02 度(约2公里范围)。这些店铺覆盖了本帮菜、小吃、火锅、日料、烘焙、烧鸟六个品类,品类分布丰富。每条店铺名都以"食遇记·“前缀,既体现应用品牌,也方便在地图长按事件日志中一眼识别。这些数据将在 setupMapCallback 中被遍历,逐条调用 addMarker 添加到地图上,成为长按 Marker 事件的数据来源——可以说,MARKER_SPOTS 是地图 Tab 的"数据底座”。
4.3 精选好店与功能清单数据
/** 精选好店接口(美食 Tab 头部精选大卡) */
interface FoodRec {
icon: string; // 店铺 emoji
name: string; // 店铺名
dist: string; // 距离文本
cate: string; // 品类文本
avg: string; // 人均文本
rating: number; // 评分(用于颜色映射)
}
/** 精选好店 Mock 数据(3 条,头部渐变大卡下方列表) */
const FOOD_RECS: FoodRec[] = [
{ icon: '🍜', name: '南翔小笼馒头店', dist: '420m', cate: '本帮点心', avg: '人均 45 元', rating: 4.7 },
{ icon: '🍲', name: '天钥桥老火锅', dist: '1.1km', cate: '川湘火锅', avg: '人均 98 元', rating: 4.5 },
{ icon: '🍣', name: '鹤桥日料亭', dist: '1.6km', cate: '日料刺身', avg: '人均 156 元', rating: 4.6 }
];
/** 我的页功能清单条目接口 */
interface FuncItem {
icon: string; // 功能图标
label: string; // 功能名
value: string; // 状态/数值文本
}
/** 我的页功能清单 Mock 数据(8 条,美食探店语义) */
const FUNC_LIST: FuncItem[] = [
{ icon: '📍', label: '探店足迹', value: '86 家 · 覆盖 12 个商圈' },
{ icon: '📝', label: '食记草稿', value: '3 篇待发布' },
{ icon: '🌶', label: '口味偏好', value: '微辣 · 少糖 · 忌香菜' },
{ icon: '⭐', label: '收藏店铺', value: '28 家' },
{ icon: '🎟', label: '美食优惠券', value: '6 张可用' },
{ icon: '🚫', label: '店铺黑名单', value: '2 家' },
{ icon: '🔔', label: '新店上新提醒', value: '已开启' },
{ icon: '⚙', label: '账号设置', value: '美食家 · Lv.6' }
];
FoodRec 接口为美食 Tab 头部的精选好店大卡定义数据结构,rating 字段是 number 类型而非 string,因为后续需要用它做颜色映射(ratingColor 函数依据评分数值返回不同颜色),所以必须是数值类型。FOOD_RECS 数组3条数据覆盖了本帮、火锅、日料三个品类,评分4.5-4.7属于高分区段,颜色会映射到番茄红——这是有意为之,头部精选卡只展示高分好店,视觉上强化"推荐"心智。
FuncItem 接口定义了"我的"页面功能清单的条目结构,每条包含图标、功能名、状态/数值文本。FUNC_LIST 数组8条数据覆盖了探店足迹、食记草稿、口味偏好、收藏店铺、优惠券、黑名单、上新提醒、账号设置——这几乎是一个完整美食探店用户的全生命周期功能图谱。注意 value 字段不是简单的开关状态,而是带数值和描述的复合文本(如"86 家 · 覆盖 12 个商圈"),这让功能清单不只是入口,还是信息展示位——用户在"我的"页面一眼就能看到自己的探店成就和当前状态,无需点进去查看,这是高级信息架构设计。
五、辅助函数:颜色映射策略
5.1 reliability 相关性分数映射
/** 相关性等级接口(分数条旁的标签) */
interface ScoreLevel {
label: string; // 等级文案
color: string; // 等级颜色
}
/**
* reliability 相关性分数 → 等级标签/颜色映射
* 取值 [0,1]:≥0.8 高相关 / ≥0.5 中相关 / 其余低相关(Map Kit 6.1.1 新字段)
*/
function reliabilityScore(score: number): ScoreLevel {
if (score >= 0.8) {
return { label: '高相关', color: COLORS.red };
}
if (score >= 0.5) {
return { label: '中相关', color: COLORS.orange };
}
return { label: '低相关', color: COLORS.text3 };
}
reliabilityScore 函数是 Map Kit 6.1.1 reliability 新特性在 UI 层落地的核心映射器。它接收一个 [0,1] 范围的浮点数,返回一个包含 label 和 color 的 ScoreLevel 对象。分档策略采用双阈值三档制:≥0.8 归为"高相关"配番茄红、≥0.5 归为"中相关"配暖橙、其余归为"低相关"配浅驼色。这种分档设计让用户无需阅读具体数值,通过颜色和标签就能快速判断结果质量。
阈值选择 0.8 和 0.5 是有讲究的。0.8 作为高档线意味着"非常匹配用户意图"——在美食搜索场景中,0.8 以上的结果通常就是用户想找的品类(如搜"本帮菜"命中"南翔小笼馒头店"这种核心本帮餐厅)。0.5 作为中档线意味着"部分匹配"——可能品类对但距离较远,或距离近但品类是边缘匹配。低于0.5则是"弱匹配或误匹配"——如搜"本帮菜"命中"本帮酱料专卖店"(非餐饮),reliability 会非常低。这套阈值体系与后端检索引擎的相关性算法分布大致对应,能让 UI 呈现与算法意图保持一致。
返回 ScoreLevel 对象而非单独返回颜色或标签,是因为 UI 层需要同时使用两者——Progress 分数条用 color,Text 标签用 label 和 color。封装成对象一次返回,调用方一次取用即可,避免重复调用函数造成不一致。在 tabSearch 的渲染中,reliabilityScore 会被调用两次(一次给标签、一次给分数条),虽然看似有性能损耗,但因为结果是纯函数(相同输入相同输出),实际开销极低,可读性收益远大于性能损耗。
5.2 评分与营业状态颜色映射
/** 店铺评分颜色映射:≥4.6 口碑爆棚红 / ≥4.2 值得一试橙 / 其余浅驼 */
function ratingColor(rating: number): string {
if (rating >= 4.6) {
return COLORS.red;
}
if (rating >= 4.2) {
return COLORS.orange;
}
return COLORS.text3;
}
/** 营业状态颜色映射:营业中绿 / 其余浅驼 */
function openColor(status: string) {
if (status === '营业中') {
return COLORS.green;
}
return COLORS.text3;
}
ratingColor 函数将店铺评分数值映射为颜色,分档阈值 4.6 和 4.2 同样基于美食行业常识:4.6 以上属于口碑爆棚级别(大众点评黑珍珠餐厅起步分),用番茄红强化"必吃"心智;4.2 以上属于值得一试级别(多数优质餐厅的水平),用暖橙提示"可以尝试";4.2 以下用浅驼色弱化呈现,避免低分店喧宾夺主。这套映射在 FOOD_RECS 精选好店卡和 SHOP_LIST 全部店铺列表中都会用到,确保评分颜色全应用统一。
openColor 函数更简单,只判断"营业中"和其他状态两种情况,营业中返回绿色、其他返回浅驼。这是二元状态映射——因为本应用只区分"营业中/休息中",未细分"即将打烊""24小时"等中间态。但函数结构为未来扩展留了空间,只需追加 else if 分支即可。绿色 #3F9B54 是低饱和的功能绿,与暖色调主题不冲突,同时保留"通行/可用"的语义直觉。
六、数据模型:@Observed 状态管理
6.1 店铺条目 ShopItem
/** 店铺条目(美食 Tab 推荐列表,@Observed 支持备注编辑刷新) */
@Observed export class ShopItem {
icon: string; // 店铺 emoji 图标
name: string; // 店名
cate: string; // 品类
rating: number; // 评分
avg: number; // 人均(元)
status: string; // 营业状态
note: string; // 用户备注(可编辑)
constructor(icon: string, name: string, cate: string, rating: number,
avg: number, status: string, note: string) {
this.icon = icon;
this.name = name;
this.cate = cate;
this.rating = rating;
this.avg = avg;
this.status = status;
this.note = note;
}
}
ShopItem 类用 @Observed 装饰器修饰,这是 ArkUI 状态管理 V1 的核心装饰器之一。@Observed 会让该类的实例属性变为可观察的——当属性值变化时,绑定了该实例的 UI 组件会自动刷新。本应用中 ShopItem 的 note(备注)字段是可编辑的,用户通过编辑弹窗修改备注后,列表中对应卡片的备注文本需要立即更新,@Observed 保证了这一点。
类定义了7个字段覆盖店铺的完整画像:emoji 图标、店名、品类、评分、人均、营业状态、备注。constructor 显式赋值所有字段,这是 ArkUI @Observed 类的必要写法——@Observed 会在构造时劫持属性,所以必须在 constructor 内完成初始化赋值,不能依赖类属性默认值。export 关键字使 ShopItem 可被其他文件引用,体现了模块化设计。
字段类型上,rating 和 avg 是 number,因为需要参与数值比较(ratingColor 判断)和格式化展示(toFixed(1) 保留一位小数)。status 是 string,因为营业状态的值是离散文案而非数值。note 是 string,存储用户自由编辑的备注文本,是整个类中最活跃的字段——编辑弹窗 saveShop/updateShop 方法都围绕它展开。@Observed 让 note 的修改能精准刷新到 UI,无需手动触发重绘。
6.2 搜索结果 SearchRecord 与 reliability 数据载体
/** 搜索结果条目(★ Map Kit 6.1.1 reliability 字段数据载体) */
@Observed export class SearchRecord {
name: string; // 地点名称(site.name)
address: string; // 格式化地址(site.formatAddress)
distance: number; // 直线距离米(site.distance)
reliability: number; // ★ 相关性分数(site.reliability,[0,1])
time: string; // 记录时间文案
constructor(name: string, address: string, distance: number,
reliability: number, time: string) {
this.name = name;
this.address = address;
this.reliability = reliability;
this.distance = distance;
this.time = time;
}
}
SearchRecord 类是 site.searchByText 返回 Site 对象在 UI 层的映射载体。它从 Site 中提取了5个关键字段:name(地点名称)、address(格式化地址)、distance(直线距离米)、reliability(相关性分数)、time(记录时间文案)。其中 reliability 字段带 ★ 注释,明确标注这是 6.1.1 新增字段的承载点——这是整个应用对 reliability 特性的数据层落地。
字段注释采用"site.xxx"格式,清晰标注每个字段的来源是 Site 对象的哪个属性,方便开发者对照 Map Kit 官方文档。reliability 字段注释特别说明取值范围 [0,1],1 为完全相关——这是 6.1.1 新特性最核心的语义信息。@Observed 修饰保证了当 searchRecords 数组被替换或元素属性变化时,搜索结果列表 UI 会自动刷新,无需手动调用 invalidate。
在 runSearch 方法中,遍历 site.searchByText 返回的 sites 数组,对每个 Site 对象构造一个 SearchRecord 实例,使用 s.reliability ?? 0 进行空值合并——这是因为 reliability 是新增字段,老版本 SDK 返回的 Site 可能没有这个字段(undefined),?? 0 保证向下兼容,让旧数据也能展示为"低相关"而非崩溃。这种防御式编程在对接新 API 时尤为重要。
6.3 长按事件日志 EventLog
/** 长按事件日志条目(★ MapEventManager 长按监听数据载体) */
@Observed export class EventLog {
type: string; // 事件类型:'Marker' / 'POI'
name: string; // Marker ID 或 POI 名称
lat: number; // 纬度
lng: number; // 经度
time: string; // 事件时间文案
constructor(type: string, name: string, lat: number, lng: number, time: string) {
this.type = type;
this.name = name;
this.lat = lat;
this.lng = lng;
this.time = time;
}
}
EventLog 类是 MapEventManager 长按事件回调在 UI 层的数据载体。type 字段区分两种事件来源:‘Marker’ 表示来自 onMarkerLongClick 回调(用户长按了地图上的自定义标注),‘POI’ 表示来自 onPoiLongClick 回调(用户长按了地图上的原生兴趣点)。name 字段对两种类型的语义不同——Marker 事件存的是 marker.getId() 返回的 ID(如 “#0”),POI 事件存的是 poi.name 兴趣点名称(如"豫园")。这种"同字段不同语义"的设计通过 type 字段做联合区分,避免了为两种事件定义两个类,简化了数据结构。
lat 和 lng 记录事件发生的地理坐标——Marker 事件通过 marker.getPosition() 获取,POI 事件通过 poi.position 获取。这些坐标在 UI 中用 toFixed(4) 保留4位小数展示,精度约11米,足够标识一条探店事件。time 字段存储"刚刚"这样的相对时间文案,简化了时间格式化逻辑——在真实应用中应替换为真实时间戳并做相对时间计算。
@Observed 修饰让 EventLog 实例的属性变化能驱动 UI 刷新。但本应用的事件日志刷新策略是 unshift 新条目到数组头部后整体替换数组引用(详见后文 setupMapCallback 分析),所以 @Observed 在这里更多是"防御性"标注——保证未来如果需要修改单条日志的属性(如标记已读),UI 仍能正确刷新。
七、组件主体:状态声明与地图初始化
7.1 状态变量声明
@Entry
@Component
struct Page1132 {
/** 当前选中 Tab 索引 */
@State currentTab: number = 0;
/** 头部筛选 chips 选中索引 */
@State cateIdx: number = 0;
/** 收藏店铺弹窗开关 */
@State addModal: boolean = false;
/** 编辑备注弹窗开关 */
@State editModal: boolean = false;
/** 删除收藏确认弹窗开关 */
@State delModal: boolean = false;
/** 当前编辑的店铺索引 */
@State editIdx: number = 0;
/** 当前删除的店铺索引 */
@State delIdx: number = 0;
/** 店铺列表数据 */
@State shopList: Array<ShopItem> = SHOP_LIST;
/** 我的页功能清单数据 */
@State funcList: FuncItem[] = FUNC_LIST;
Page1132 是应用的入口组件,用 @Entry 和 @Component 装饰。@Entry 标识这是页面级组件(会被编译为页面路由入口),@Component 标识这是一个自定义组件。struct 关键字是 ArkUI 声明式语法的特色——组件用 struct 而非 class 定义,因为 ArkUI 组件是"描述性"的而非"实例化"的,struct 更贴合"模板"语义。
@State 装饰的状态变量是组件刷新的驱动力。currentTab 控制四个 Tab 页面切换,初始值 0 表示默认显示美食 Tab。cateIdx 控制头部筛选 chips 的选中态,初始 0 对应"全部"。addModal/editModal/delModal 三个布尔值分别控制三个弹窗的显隐——这种"每个弹窗一个布尔开关"的设计简单直观,适合弹窗数量少的场景;如果弹窗很多,可以考虑用枚举或 currentModal: string | null 统一管理。
editIdx 和 delIdx 记录当前操作的目标店铺索引,配合 editNote(编辑备注输入值)一起工作。shopList 是 @Observed ShopItem 数组,作为美食 Tab 全部店铺列表的数据源,初始值 SHOP_LIST。funcList 是 FuncItem 数组,作为"我的"页功能清单数据源。这两个数组用 @State 修饰,意味着数组整体替换会触发刷新——shopList = shopList.slice() 就是利用这一点强制刷新列表。
7.2 Map Kit 状态声明
// --- Map Kit 状态(6.1.1 特性:搜索 reliability + 长按事件) ---
/** 地图初始化参数(非可选并给默认值,避免组件参数传 undefined) */
private mapOptions: mapCommon.MapOptions = {
position: { target: CITY_CENTER, zoom: 13 }
};
/** 地图初始化回调(setupMapCallback 中赋值) */
private mapCallback?: AsyncCallback<map.MapComponentController>;
/** 地图控制器(回调中获取,添加 Marker 用) */
private mapController?: map.MapComponentController;
/** 地图事件管理器(回调中获取,长按监听注册用) */
private mapEventManager?: map.MapEventManager;
/** Marker 长按监听开关 */
@State markerListenOn: boolean = true;
/** POI 长按监听开关 */
@State poiListenOn: boolean = true;
/** 长按事件日志流(unshift 置顶) */
@State eventLogs: Array<EventLog> = EVENT_LOGS;
/** 搜索关键字输入值 */
@State queryInput: string = '本帮菜';
/** 搜索状态文案 */
@State searchState: string = '待搜索 · 演示数据';
/** 搜索结果列表(site.searchByText 结果数据源) */
@State searchRecords: Array<SearchRecord> = SEARCH_RECORDS;
/** 收藏弹窗:店名输入 */
@State formName: string = '';
/** 收藏弹窗:品类输入 */
@State formCate: string = '';
/** 收藏弹窗:地址输入 */
@State formAddr: string = '';
/** 编辑弹窗:备注输入 */
@State editNote: string = '';
这一组状态变量专门服务于 Map Kit 两大新特性。mapOptions 是 private(非 @State),因为地图初始化参数在组件生命周期内不变,无需触发刷新。它使用 mapCommon.MapOptions 类型,position.target 设为 CITY_CENTER(上海人民广场),zoom 13 是城市级地图的标准缩放级别——既能看到街区细节,又不会过于贴近单点。给默认值而非 undefined 是为了避免 MapComponent 组件参数传 undefined 导致初始化异常。
mapCallback、mapController、mapEventManager 三个变量都用 private 修饰(非 @State),因为它们是地图引擎的句柄而非 UI 状态——它们变化不需要触发刷新,但需要被组件方法访问。mapCallback 用 ? 标记可选,因为它在 setupMapCallback 中才赋值;mapController 和 mapEventManager 同理,只在异步回调内才被赋值。markerListenOn 和 poiListenOn 是 @State 布尔值,控制两个 Toggle 开关的激活态,初始都是 true(监听默认开启)。
eventLogs 是 @State EventLog 数组,是长按事件日志流的数据源。初始值 EVENT_LOGS 提供了2条演示数据,让用户进入地图 Tab 就能看到日志流的样子。queryInput 是搜索框的值,初始"本帮菜"是一个有代表性的关键字——能命中本帮餐厅,也能命中"本帮酱料专卖店"这种低相关结果,正好展示 reliability 的分档价值。searchState 是搜索状态文案,初始"待搜索 · 演示数据"诚实告知用户当前是演示态。searchRecords 是搜索结果数据源,初始 SEARCH_RECORDS 提供6条覆盖高/中/低三档 reliability 的数据。formName/formCate/formAddr/editNote 四个变量分别承载收藏弹窗和编辑弹窗的输入值。
7.3 地图初始化回调 setupMapCallback
/**
* 地图初始化:controller → eventManager → Marker 群 → 6.1.1 双长按监听
* 必须在 mapCallback 的 err 为空分支内注册监听(controller 就绪后才有管理器)
*/
setupMapCallback() {
this.mapCallback = async (err: BusinessError, mapController: map.MapComponentController) => {
if (err) {
console.error(`Map init failed, code: ${err.code}, message: ${err.message}`);
return;
}
this.mapController = mapController;
this.mapEventManager = mapController.getEventManager();
// 批量添加美食店铺 Marker(addMarker 返回 Promise,逐个 await + try-catch)
for (const spot of MARKER_SPOTS) {
const markerOptions: mapCommon.MarkerOptions = {
position: { latitude: spot.lat, longitude: spot.lng },
clickable: true,
visible: true,
rotation: 0,
zIndex: 0,
alpha: 1,
anchorU: 0.5,
anchorV: 1,
draggable: false,
flat: false
};
try {
await this.mapController.addMarker(markerOptions);
} catch (e) {
console.error(`addMarker failed: ${(e as BusinessError).message}`);
}
}
// ★ 6.1.1 新特性·事件一:监听地图标记 Marker 的长按
this.mapEventManager.onMarkerLongClick((marker: map.Marker) => {
const pos: mapCommon.LatLng = marker.getPosition();
this.eventLogs.unshift(new EventLog('Marker', `#${marker.getId()}`,
pos.latitude, pos.longitude, '刚刚'));
});
// ★ 6.1.1 新特性·事件二:监听地图 POI 的长按(参数是 mapCommon.Poi)
this.mapEventManager.onPoiLongClick((poi: mapCommon.Poi) => {
this.eventLogs.unshift(new EventLog('POI', poi.name,
poi.position.latitude, poi.position.longitude, '刚刚'));
});
};
}
setupMapCallback 是整个 Map Kit 集成的核心方法。它构造一个 AsyncCallback 函数赋值给 this.mapCallback,这个回调会在 MapComponent 初始化完成后被调用。回调参数是 (err, mapController) 双参形式——err 是 BusinessError 类型,如果地图初始化失败会携带错误码和消息;mapController 是 MapComponentController 类型,是操作地图的句柄。方法用 async 关键字修饰,因为内部要 await addMarker 异步操作。
回调开头先判断 err 是否存在,如果存在就 console.error 打印错误信息并 return——这是典型的错误短路模式,避免在控制器未就绪时继续执行后续逻辑。成功分支内,依次将 mapController 赋给 this.mapController(保存句柄供其他方法使用),再通过 mapController.getEventManager() 获取 MapEventManager——这是访问 6.1.1 长按事件接口的必经入口。getEventManager() 不接受参数,返回与该地图实例绑定的事件管理器实例。
紧接着是 Marker 群添加逻辑。for…of 遍历 MARKER_SPOTS 数组(6条店铺数据),对每条构造 markerOptions 对象。markerOptions 的字段非常完整:position 设定坐标、clickable: true 允许点击、visible: true 默认可见、rotation 0 不旋转、zIndex 0 默认层级、alpha 1 完全不透明、anchorU 0.5 和 anchorV 1 将锚点设在图标底部中心(让标注像图钉一样"钉"在坐标点)、draggable: false 不可拖动、flat: false 不平贴地图(保持立体感)。每个 addMarker 都 try-catch 包裹,避免单个失败中断整批添加——这是批量异步操作的稳健实践。
最后是 6.1.1 双长按监听注册,这是本应用最核心的技术点。onMarkerLongClick 接收一个回调函数,参数是 map.Marker 类型——当用户长按地图上的 Marker 时,该回调被触发,开发者可从 marker 对象调用 getPosition() 获取坐标、getId() 获取 ID。本应用在回调内构造一个 EventLog(‘Marker’, #${marker.getId()}, lat, lng, ‘刚刚’) 并 unshift 到 eventLogs 数组头部,让新事件出现在日志流顶部。onPoiLongClick 的回调参数是 mapCommon.Poi 类型,与 Marker 不同——POI 是地图原生兴趣点,有 name 和 position 属性,无需调用 getPosition() 方法而是直接读 poi.position。这两个接口的参数类型差异体现了 Map Kit 对"自定义标注"和"原生兴趣点"的区分设计——它们是两个独立的实体体系,各有各的事件、属性和操作方法。
7.4 长按监听开关切换
/** Marker 长按监听开关切换(off 不传参 = 清除该类型全部订阅) */
toggleMarkerListen() {
if (!this.mapEventManager) {
return;
}
if (this.markerListenOn) {
this.mapEventManager.offMarkerLongClick();
} else {
this.mapEventManager.onMarkerLongClick((marker: map.Marker) => {
const pos: mapCommon.LatLng = marker.getPosition();
this.eventLogs.unshift(new EventLog('Marker', `#${marker.getId()}`,
pos.latitude, pos.longitude, '刚刚'));
});
}
this.markerListenOn = !this.markerListenOn;
}
/** POI 长按监听开关切换(off 不传参 = 清除该类型全部订阅) */
togglePoiListen() {
if (!this.mapEventManager) {
return;
}
if (this.poiListenOn) {
this.mapEventManager.offPoiLongClick();
} else {
this.mapEventManager.onPoiLongClick((poi: mapCommon.Poi) => {
this.eventLogs.unshift(new EventLog('POI', poi.name,
poi.position.latitude, poi.position.longitude, '刚刚'));
});
}
this.poiListenOn = !this.poiListenOn;
}
toggleMarkerListen 和 togglePoiListen 两个方法实现了长按监听的开/关切换逻辑。两个方法结构对称:先判空 mapEventManager(如果事件管理器未就绪直接 return,避免空指针),然后根据当前 markerListenOn/poiListenOn 的布尔值决定是 off 还是 on。如果当前是开启状态(true),调用 off 接口清除订阅;如果当前是关闭状态(false),调用 on 接口重新订阅。最后取反布尔值更新状态,驱动 Toggle 开关 UI 刷新。
offMarkerLongClick 和 offPoiLongClick 不接受参数——这是 6.1.1 接口的设计决策:一个 off 调用会清除该类型的全部订阅回调,不支持只移除特定回调。这种"全清"语义简化了接口设计,但要求开发者在多回调场景下自行管理回调引用(如果需要细粒度取消)。本应用每个类型只注册一个回调,所以"全清"正好够用。
值得注意的是,重新订阅时 onMarkerLongClick/onPoiLongClick 传入的回调与 setupMapCallback 中注册的是完全相同的代码——这是因为 off 清除了原回调,必须重新传入新回调才能恢复监听。这种设计下,如果回调逻辑很长,建议抽取为类方法或私有函数避免重复代码。本应用为了可读性直接复制了回调代码,是一种"用冗余换清晰"的取舍。在真实工程中,可以改为 private markerHandler = (marker: map.Marker) => {...} 箭头函数属性,在 on/off 中引用同一函数。
7.5 搜索方法 runSearch
/**
* ★ 6.1.1 新特性·搜索:关键字搜索 searchByText → Site 数组
* 读取 Site.reliability 相关性分数(可选字段,?? 兜底 0)
* 无 AGC 配置/无网络时抛 BusinessError,catch 保留 Mock 数据保证演示链路
*/
async runSearch() {
this.searchState = '搜索中…';
const params: site.SearchByTextParams = {
query: this.queryInput,
location: CITY_CENTER,
radius: 5000,
language: 'zh'
};
try {
const result: site.SearchByTextResult = await site.searchByText(params);
const sites: Array<site.Site> = result.sites ?? [];
if (sites.length === 0) {
this.searchState = '无结果 · 保留演示数据';
return;
}
const records: Array<SearchRecord> = [];
for (const s of sites) {
records.push(new SearchRecord(
s.name ?? '未命名地点',
s.formatAddress ?? '暂无地址',
s.distance ?? 0,
s.reliability ?? 0,
'刚刚'));
}
this.searchRecords = records;
this.searchState = `找到 ${sites.length} 家店`;
} catch (e) {
const err = e as BusinessError;
this.searchState = `搜索失败(${err.code}) · 保留演示数据`;
}
}
runSearch 是 6.1.1 搜索新特性的 API 调用入口。方法是 async,因为 site.searchByText 返回 Promise。方法开头先将 searchState 设为"搜索中…",让用户立即看到搜索进行中的反馈——这是异步操作的标准 UX 实践,避免用户以为点击没反应。
构造 params 对象使用 site.SearchByTextParams 类型,包含4个字段:query 是搜索关键字(来自 queryInput 状态)、location 是搜索中心点(CITY_CENTER 上海人民广场)、radius 是搜索半径 5000 米(5公里覆盖一个街区级探店范围)、language 是 ‘zh’ 中文结果。这4个参数共同决定了搜索的范围和语义——Map Kit 后端会以 location 为圆心、radius 为半径,检索匹配 query 关键字的 POI,并按相关性排序返回。
try-catch 包裹 site.searchByText 调用。成功分支内,先从 result.sites 取出 Site 数组(用 ?? [] 兜底空值),如果数组为空就更新状态为"无结果 · 保留演示数据"并 return——注意这里没有清空 searchRecords,而是保留之前的演示数据,让用户在无网络或无结果时仍能看到 UI 样子。如果数组非空,遍历每个 Site 构造 SearchRecord 实例。这里的关键是 s.reliability ?? 0——reliability 是 6.1.1 新增字段,老版本 SDK 返回的 Site 可能没有这个属性(undefined),用 ?? 0 兜底为 0,让旧数据也能在 UI 上展示为"低相关"分数条而非崩溃。s.name ?? ‘未命名地点’ 和 s.formatAddress ?? ‘暂无地址’ 同理兜底。
catch 分支捕获 BusinessError,将错误码拼接到 searchState 中——如"搜索失败(11004) · 保留演示数据"。这种错误文案既告知了用户失败事实,又保留了演示数据让 UI 不会空白,是演示类应用的友好降级策略。在真实生产应用中,catch 分支应该做更精细的错误分类处理(网络错误提示重试、权限错误提示授权、关键字错误提示修改等),但本应用聚焦特性演示,统一降级即可。构造好的 records 数组整体赋值给 this.searchRecords,触发 @State 刷新搜索结果列表 UI。
7.6 弹窗业务方法
/** 打开编辑备注弹窗(回填当前店铺备注) */
openEditShop(idx: number) {
this.editIdx = idx;
this.editNote = this.shopList[idx].note;
this.editModal = true;
}
/** 保存收藏店铺(空名兜底默认演示店) */
saveShop() {
const name = this.formName === '' ? '食遇记·新收藏店' : this.formName;
const cate = this.formCate === '' ? '待分类' : this.formCate;
const addr = this.formAddr === '' ? '上海市黄浦区(地图选点)' : this.formAddr;
this.shopList.unshift(new ShopItem('⭐', name, cate, 4.0, 60, '营业中', addr));
this.formName = '';
this.formCate = '';
this.formAddr = '';
this.addModal = false;
}
/** 保存编辑备注(整体刷新数组引用以刷新列表) */
updateShop() {
if (this.editIdx >= 0 && this.editIdx < this.shopList.length) {
if (this.editNote !== '') {
this.shopList[this.editIdx].note = this.editNote;
}
this.shopList = this.shopList.slice();
}
this.editModal = false;
}
/** 删除收藏店铺(确认弹窗回调) */
delShop() {
if (this.delIdx >= 0 && this.delIdx < this.shopList.length) {
this.shopList.splice(this.delIdx, 1);
}
this.delModal = false;
}
这四个方法构成了弹窗系统的业务逻辑层。openEditShop 接收店铺索引 idx,将其存入 editIdx,并从 shopList[idx].note 回填到 editNote——这是"编辑"的核心交互:弹窗打开时输入框必须显示当前值,让用户基于现有内容修改而非从空白开始。最后设 editModal = true 打开弹窗。
saveShop 处理收藏新店铺。三个输入值 formName/formCate/formAddr 都做空字符串兜底——如果用户没填店名,默认"食遇记·新收藏店";没填品类默认"待分类";没填地址默认"上海市黄浦区(地图选点)"。这种兜底策略让用户即使直接点"收藏"也能生成一条有效记录,降低操作门槛。构造 ShopItem 时 icon 固定为 ⭐(收藏标记),rating 默认 4.0(中等评分,不偏不倚),avg 60(中档人均),status “营业中”。unshift 到 shopList 头部让新店铺出现在列表顶部。最后清空三个表单字段并关闭弹窗,为下次打开留出干净状态。
updateShop 处理编辑备注保存。先做边界检查 editIdx 是否在 shopList 长度范围内(防止越界),再判断 editNote 非空才赋值(避免空备注覆盖原备注)。关键的一行是 this.shopList = this.shopList.slice()——slice() 不传参数会返回数组的浅拷贝新引用,赋值给 @State shopList 会触发列表整体刷新。这是因为 @Observed ShopItem 的 note 属性变化虽然能驱动该卡片刷新,但 @State 数组本身引用不变时,ArkUI 有时无法可靠触发 ForEach 的整体重算,slice() 强制换引用是最稳妥的刷新手段。
delShop 处理删除收藏。同样做边界检查,然后 splice(delIdx, 1) 删除一条,关闭弹窗。splice 直接修改原数组,@State 数组的元素增减能被 ArkUI 检测到触发 ForEach 重新渲染。这里没有 slice() 换引用,因为 splice 已经是结构性变化,ForEach 能正确识别增删。但 updateShop 是属性变化(note 字符串替换),ForEach 可能不识别,所以需要 slice()——这是 ArkUI 状态管理的一个微妙之处。
7.7 生命周期与主构建
/** 生命周期:初始化地图回调(监听注册在 mapCallback 内完成) */
aboutToAppear() {
this.setupMapCallback();
}
/** 页面主构建:Stack 包裹主内容与三层弹窗 */
build() {
Stack() {
Column() {
this.headerMain()
Divider().strokeWidth(1).color(COLORS.line)
Scroll() {
Column() {
if (this.currentTab === 0) {
this.tabFood()
} else if (this.currentTab === 1) {
this.tabMap()
} else if (this.currentTab === 2) {
this.tabSearch()
} else {
this.tabMine()
}
}
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
this.tabBar()
}
.width('100%')
.height('100%')
if (this.addModal) {
this.panelAdd(() => {
this.addModal = false;
})
}
if (this.editModal) {
this.panelEdit(() => {
this.editModal = false;
})
}
if (this.delModal) {
this.panelDel(() => {
this.delModal = false;
})
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
aboutToAppear 是 ArkUI 组件的生命周期回调,在组件创建后、build() 调用前执行。本应用在此调用 setupMapCallback() 完成地图回调的构造和赋值——时机选择很关键:必须在 build() 之前把 mapCallback 准备好,因为 build() 内的 MapComponent 组件需要读取 this.mapCallback 作为初始化参数。如果延后到 aboutToAppear 之后(如 onAppear),MapComponent 已经用 undefined 初始化完毕,回调再赋值也不会被调用。
build() 方法是组件的 UI 描述入口。最外层是 Stack 容器——Stack 是层叠布局,子元素从下往上堆叠,后声明的在上层。本应用用 Stack 实现"主内容在下、弹窗在上"的层叠结构。Stack 内首先是 Column 包裹的主内容:headerMain 头部、Divider 分隔线、Scroll 可滚动内容区、tabBar 底部导航。Divider 用 line 米色,视觉上分隔头部和内容。
Scroll 内的 Column 根据 currentTab 的值条件渲染四个 Tab 页面——if/else if/else 是 ArkUI 的条件渲染语法,currentTab 变化时只有对应分支的 Builder 会被调用,其他 Tab 不渲染。这是"单页面切换 Tab"模式(而非用 Tabs 组件),好处是完全控制切换逻辑和动画,灵活性高。Scroll 设 layoutWeight(1) 占满头部和底部之间的剩余高度,scrollBar 关闭让滚动条不可见但滚动功能保留。
Stack 内主内容之后是三个条件渲染的弹窗——if (this.addModal) / if (this.editModal) / if (this.delModal)。每个弹窗接收一个 () => void 类型的 onClose 回调,用于点击遮罩或取消按钮时关闭。三个弹窗互斥(同时只会有一个 modal 为 true),但代码没有强制互斥逻辑,依赖业务调用顺序保证。Stack 让弹窗自动覆盖在主内容之上,弹窗内的 modalOverlay 遮罩层用半透明 rgba 色填充全屏,模拟原生模态对话框效果。
八、Builder 函数群:UI 组件的声明式构建
8.1 头部渐变 Banner headerMain
/** 头部:渐变 Banner(评分+探店动态)+ 筛选 chips 横滑 */
@Builder
headerMain() {
Column({ space: 12 }) {
// 顶部渐变 Banner:评分环 + 探店动态文案 + 快捷入口
Column({ space: 10 }) {
Row({ space: 12 }) {
Column({ space: 2 }) {
Text('4.6').fontSize(30).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('本周综合评分').fontSize(10).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
Column({ space: 6 }) {
Row({ space: 6 }) {
Text('📍').fontSize(14)
Text('附近营业中 26 家 · 最近 320m').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
}
Row({ space: 6 }) {
Text('📝').fontSize(12)
Text('今日探店 3 家 · 人均 68 元').fontSize(11).fontColor(COLORS.sub)
}
Row({ space: 6 }) {
Text('📔').fontSize(12)
Text('收藏店铺 28 家 · 食记 12 篇').fontSize(11).fontColor(COLORS.sub)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Row({ space: 8 }) {
Text('📸 探店打卡').fontSize(12).fontColor(COLORS.bg).fontWeight(FontWeight.Bold)
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.borderRadius(16).backgroundColor(COLORS.red)
Text('🗺 地图找店').fontSize(12).fontColor(COLORS.red)
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.borderRadius(16).backgroundColor(COLORS.card)
.onClick(() => { this.currentTab = 1; })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.padding(14)
.borderRadius(14)
.linearGradient({
angle: 135,
colors: [[COLORS.redL, 0.0], [COLORS.card, 0.65]]
})
// 筛选 chips 横滑
Scroll() {
Row({ space: 8 }) {
ForEach(CATE_TAGS, (tag: string, idx: number) => {
Text(tag)
.fontSize(11)
.fontColor(this.cateIdx === idx ? COLORS.bg : COLORS.sub)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.cateIdx === idx ? COLORS.red : COLORS.chip)
.onClick(() => { this.cateIdx = idx; })
}, (tag: string) => tag)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.padding({ left: 14, right: 14, top: 12, bottom: 8 })
.width('100%')
}
headerMain 是应用头部的完整 Builder。@Builder 装饰器标识这是一个 UI 构建函数,可在 build() 内像组件一样调用。整个头部由两部分组成:渐变 Banner 和横滑筛选 chips。
渐变 Banner 是一个 Column,内部分两层。第一层是 Row 包裹的"评分+动态"信息行:左侧 Column 展示"4.6 本周综合评分"(大号粗体数字+小号副文本,形成数字仪表盘效果),右侧 Column 用 layoutWeight(1) 占满剩余宽度,展示三行探店动态——营业中数量、今日探店数据、收藏数据,每行用 emoji 图标+文本组成。第二层是两个快捷入口胶囊:"探店打卡"用番茄红底白字(主操作),"地图找店"用白底番茄红字(次操作,点击切换到地图 Tab)。整个 Banner 用 linearGradient 渐变背景,从 redL 浅番茄红(0% 位置)过渡到 card 白色(65% 位置),角度 135 度——这种"浅到白"的对角渐变让 Banner 既有色彩感又不失清爽,番茄红的暖意从左上角晕染开来。
横滑筛选 chips 是一个 Scroll 容器,内部 Row 横向排列 ForEach 渲染的8个 Text 胶囊。Scroll 设 scrollable(Horizontal) 横向滚动、scrollBar(Off) 隐藏滚动条。每个 chip 的 fontColor 和 backgroundColor 根据 cateIdx === idx 判断:选中态用番茄红底+奶白字(强对比高亮),未选中用 chip 米色底+sub 棕色字(弱对比常态)。onClick 切换 cateIdx,触发 ForEach 重新渲染高亮态。这是筛选组件的标准模式——状态驱动样式,无需手动操作 DOM。
8.2 美食 Tab tabFood
/** 美食 Tab:探店数据三宫格 + 精选好店 + 全部店铺列表(业务主 Tab) */
@Builder
tabFood() {
Column({ space: 10 }) {
// 区块标题行:更多入口
Row() {
Text('附近好店').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Blank()
Text('收藏新店铺 +').fontSize(11).fontColor(COLORS.red)
.onClick(() => { this.addModal = true; })
}
.width('100%')
// 探店数据三宫格
Row({ space: 8 }) {
this.statCell('3 家', '今日已探店')
this.statCell('26 家', '营业中好店')
this.statCell('68 元', '今日人均')
}
.width('100%')
// 精选好店大卡(3 条)
ForEach(FOOD_RECS, (rec: FoodRec) => {
Row({ space: 10 }) {
Text(rec.icon).fontSize(26)
Column({ space: 4 }) {
Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(`${rec.cate} · ${rec.avg}`).fontSize(11).fontColor(COLORS.sub)
Row({ space: 6 }) {
Text(rec.dist).fontSize(10).fontColor(COLORS.text3)
Text(`评分 ${rec.rating.toFixed(1)}`).fontSize(10).fontColor(ratingColor(rec.rating))
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 4 }) {
Text('去探店').fontSize(11).fontColor(COLORS.bg).fontWeight(FontWeight.Bold)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12).backgroundColor(COLORS.red)
.onClick(() => { this.currentTab = 1; })
}
}
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
.width('100%')
}, (rec: FoodRec) => rec.name)
// 全部店铺列表(长按 Marker 的数据同源)
Row() {
Text('全部店铺(地图 Marker 同源)').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
}
.width('100%')
ForEach(this.shopList, (shop: ShopItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text(shop.icon).fontSize(22)
Column({ space: 3 }) {
Text(shop.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(`${shop.cate} · 人均 ${shop.avg} 元`).fontSize(11).fontColor(COLORS.sub)
Text(`备注:${shop.note}`).fontSize(10).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 6 }) {
Text(shop.rating.toFixed(1)).fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(ratingColor(shop.rating))
Text('评分').fontSize(9).fontColor(COLORS.text3)
}
}
.width('100%')
Row({ space: 8 }) {
Text(shop.status).fontSize(10).fontColor(openColor(shop.status))
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(10).backgroundColor(COLORS.chip)
Blank()
Text('编辑').fontSize(10).fontColor(COLORS.orange)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(10).backgroundColor(COLORS.chip)
.onClick(() => { this.openEditShop(idx); })
Text('删除').fontSize(10).fontColor(COLORS.red)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(10).backgroundColor(COLORS.chip)
.onClick(() => { this.delIdx = idx; this.delModal = true; })
}
.width('100%')
}
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
.width('100%')
}, (shop: ShopItem) => shop.name)
// 探店小贴士卡
this.tipsCard()
}
.width('100%')
}
tabFood 是美食 Tab 的完整 Builder,是业务主 Tab。整体结构分5层:标题行+收藏入口、探店数据三宫格、精选好店卡列表、全部店铺列表、探店小贴士。标题行用 Row+Blank 实现两端对齐——"附近好店"在左、"收藏新店铺 +"在右,点击右侧打开收藏弹窗。Blank 组件是 ArkUI 的弹性占位符,自动填充剩余空间,是实现两端对齐的惯用手段。
三宫格通过三次调用 statCell Builder 实现,展示"今日已探店/营业中好店/今日人均"三个核心指标。statCell 是可复用的统计单元格 Builder,体现了 @Builder 的复用价值——一次定义多次调用。精选好店用 ForEach 渲染 FOOD_RECS(3条),每张卡左侧 emoji 大图标、中间店名+品类+距离+评分、右侧"去探店"红色按钮(点击切换到地图 Tab)。评分颜色用 ratingColor(rec.rating) 动态映射,让4.7/4.6的高分呈现番茄红,强化推荐心智。
全部店铺列表是美食 Tab 的核心内容区,ForEach 遍历 this.shopList(7条 ShopItem 数据)。每张店铺卡分上下两部分:上部是 emoji+店名+品类+备注+评分(右侧大号数字+小号"评分"标签),下部是营业状态胶囊+编辑/删除按钮。营业状态用 openColor 映射颜色(营业中绿/休息中驼),编辑按钮点击调用 openEditShop(idx) 打开编辑弹窗,删除按钮点击设 delIdx 并打开删除确认弹窗。备注文本"备注:${shop.note}"会随 updateShop 方法的 slice() 刷新而更新——这里 @Observed ShopItem 的 note 属性变化和数组引用替换双重保障了 UI 同步。
8.3 数据统计单元格与探店小贴士
/** 数据统计小单元格(三宫格通用,浅色白底) */
@Builder
statCell(value: string, label: string) {
Column({ space: 4 }) {
Text(value).fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.red)
Text(label).fontSize(10).fontColor(COLORS.sub)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(10)
.backgroundColor(COLORS.card)
}
/** 探店小贴士卡(主列表底部) */
@Builder
tipsCard() {
Column({ space: 6 }) {
Text('💡 探店小贴士').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('· 排队超过 30 分钟的热门店,建议先收藏错峰前往')
.fontSize(10).fontColor(COLORS.sub).width('100%')
Text('· 人均低于 50 元的小馆子,先翻最新 3 条食记再决定')
.fontSize(10).fontColor(COLORS.sub).width('100%')
}
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.chip)
.width('100%')
.alignItems(HorizontalAlign.Start)
}
statCell 是一个带参数的 @Builder,接收 value 和 label 两个 string 参数。这是 ArkUI @Builder 的高级用法——支持参数化复用。Builder 内部是 Column 布局:value 用17号粗体番茄红(数字突出),label 用10号棕色(说明文字)。layoutWeight(1) 让单元格在三宫格 Row 中均分宽度,padding 上下10、borderRadius 10、card 白底构成一个清爽的统计卡片。这个 Builder 在 tabFood 和 tabMine 中都被调用,体现了"一次定义多处复用"的工程价值。
tipsCard 是探店小贴士卡片,用 chip 米色底(比 card 更深一档)与店铺卡形成层次区分。内部三条 Text:标题"💡 探店小贴士"+两条贴士文案。贴士内容是真实的探店经验——“排队超30分钟先收藏错峰”“人均50以下先翻食记”,这种内容型卡片增加了应用的"专业感"和"陪伴感",让用户感觉应用不仅提供数据还提供智慧。alignItems(HorizontalAlign.Start) 让文本左对齐,符合阅读习惯。
8.4 地图 Tab tabMap 与长按事件特性
/** 地图 Tab:★ Map Kit 6.1.1 长按事件监听特性页 */
@Builder
tabMap() {
Column({ space: 10 }) {
// 特性说明卡
Column({ space: 4 }) {
Text('🗺 Map Kit 6.1.1 · 长按事件监听').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('长按地图上的美食店铺 Marker 或 POI 地点,事件将记录到下方日志流')
.fontSize(10).fontColor(COLORS.sub)
}
.padding(10)
.borderRadius(10)
.backgroundColor(COLORS.chip)
.width('100%')
// 监听开关行:Marker 长按 / POI 长按
Row({ space: 12 }) {
Row({ space: 6 }) {
Toggle({ type: ToggleType.Switch, isOn: this.markerListenOn })
.selectedColor(COLORS.red)
.width(36)
.height(20)
.onChange(() => { this.toggleMarkerListen(); })
Text('Marker长按').fontSize(11).fontColor(COLORS.sub)
}
Row({ space: 6 }) {
Toggle({ type: ToggleType.Switch, isOn: this.poiListenOn })
.selectedColor(COLORS.red)
.width(36)
.height(20)
.onChange(() => { this.togglePoiListen(); })
Text('POI长按').fontSize(11).fontColor(COLORS.sub)
}
}
.width('100%')
// ★ MapComponent 本体(layoutWeight(1) 占满剩余高度)
MapComponent({ mapOptions: this.mapOptions, mapCallback: this.mapCallback })
.layoutWeight(1)
.width('100%')
.borderRadius(12)
// 长按事件日志流(固定高度可滚动,新事件置顶)
Column({ space: 6 }) {
Row() {
Text('长按事件日志流').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Blank()
Text(`共 ${this.eventLogs.length} 条`).fontSize(10).fontColor(COLORS.text3)
}
.width('100%')
Scroll() {
Column({ space: 6 }) {
ForEach(this.eventLogs, (log: EventLog) => {
Row({ space: 8 }) {
Text(log.type === 'Marker' ? '📍' : '🍽')
.fontSize(12)
Column({ space: 2 }) {
Row({ space: 6 }) {
Text(log.type).fontSize(10).fontColor(log.type === 'Marker' ? COLORS.red : COLORS.orange)
Text(log.name).fontSize(11).fontColor(COLORS.title)
Text(log.time).fontSize(9).fontColor(COLORS.text3)
}
Text(`${log.lat.toFixed(4)}, ${log.lng.toFixed(4)}`)
.fontSize(9).fontColor(COLORS.text3).fontFamily('monospace')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.borderRadius(8)
.backgroundColor(COLORS.card)
.width('100%')
}, (log: EventLog) => `${log.type}-${log.name}-${log.time}`)
}
}
.scrollBar(BarState.Off)
.height(120)
.width('100%')
}
.padding(10)
.borderRadius(12)
.backgroundColor(COLORS.chip)
.width('100%')
}
.width('100%')
.height('100%')
}
tabMap 是 6.1.1 长按事件特性的核心展示页。整体从上到下分四层:特性说明卡、监听开关行、MapComponent 本体、事件日志流。特性说明卡用 chip 米色底承载标题和说明文案,让用户进入页面就明白这个 Tab 演示的是什么特性。监听开关行是两个 Toggle Switch——markerListenOn 控制 Marker 长按监听、poiListenOn 控制 POI 长按监听,Toggle 的 selectedColor 设为番茄红与主题统一,onChange 调用 toggleMarkerListen/togglePoiListen 方法切换订阅状态。
MapComponent 是 Map Kit 在 ArkUI 中的容器组件,接收 mapOptions 和 mapCallback 两个参数——mapOptions 在组件初始化时一次性传入(城市中心点+zoom 13),mapCallback 是初始化完成后的异步回调(在 setupMapCallback 中构造)。layoutWeight(1) 让地图占满开关行和日志流之间的所有剩余高度,这是地图 Tab 视觉重点。borderRadius(12) 让地图圆角化,与卡片风格统一。当用户长按地图上的 Marker 或 POI 时,6.1.1 新接口 onMarkerLongClick/onPoiLongClick 的回调会触发,构造 EventLog 并 unshift 到 eventLogs 头部。
事件日志流是一个固定高度 120 的 Scroll 容器,内部 ForEach 渲染 eventLogs 数组。每条日志用 Row 布局:左侧 emoji 图标(Marker 用 📍、POI 用 🍽,通过 log.type 判断)、右侧 Column 展示类型标签+名称+时间(一行)和坐标(一行)。类型标签颜色用 log.type 判断——Marker 番茄红、POI 暖橙,与 reliability 等级颜色体系呼应。坐标用 toFixed(4) 保留4位小数+monospace 等宽字体,呈现"技术日志"的精确感。新事件 unshift 到数组头部,出现在列表顶部——这是"最新优先"的信息架构,符合事件流类 UI 的惯例。
8.5 搜索 Tab tabSearch 与 reliability 特性
/** 搜索 Tab:★ Map Kit 6.1.1 reliability 相关性分数特性页 */
@Builder
tabSearch() {
Column({ space: 10 }) {
// 特性说明卡
Column({ space: 4 }) {
Text('🔍 searchByText · reliability 相关性评分').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('Site 新增 reliability 字段([0,1],1 为完全相关),衡量结果与关键字关联程度')
.fontSize(10).fontColor(COLORS.sub)
}
.padding(10)
.borderRadius(10)
.backgroundColor(COLORS.chip)
.width('100%')
// 搜索框 + 触发按钮
Row({ space: 8 }) {
TextInput({ text: this.queryInput, placeholder: '输入关键字,如:本帮菜' })
.layoutWeight(1)
.height(38)
.fontSize(12)
.fontColor(COLORS.title)
.placeholderColor(COLORS.text3)
.backgroundColor(COLORS.card)
.onChange((v: string) => { this.queryInput = v; })
Button('搜索')
.height(38)
.fontSize(12)
.backgroundColor(COLORS.red)
.onClick(() => { this.runSearch(); })
}
.width('100%')
// 搜索状态文案
Text(this.searchState).fontSize(10).fontColor(COLORS.text3).width('100%')
// 搜索结果列表(reliability 分数条 + 等级标签,List 子项必须用 ListItem 包裹)
List({ space: 8 }) {
ForEach(this.searchRecords, (rec: SearchRecord) => {
ListItem() {
Column({ space: 6 }) {
Row() {
Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(reliabilityScore(rec.reliability).label)
.fontSize(10)
.fontColor(reliabilityScore(rec.reliability).color)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(8)
.backgroundColor(COLORS.card)
}
.width('100%')
Text(rec.address).fontSize(11).fontColor(COLORS.sub).width('100%')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// ★ reliability 分数条:0~1 映射为线性进度 + 数值文本
Row({ space: 8 }) {
Progress({ value: rec.reliability * 100, total: 100, type: ProgressType.Linear })
.layoutWeight(1)
.height(6)
.color(reliabilityScore(rec.reliability).color)
Text(`reliability ${rec.reliability.toFixed(2)}`)
.fontSize(10)
.fontColor(COLORS.sub)
.fontFamily('monospace')
}
.width('100%')
Row({ space: 10 }) {
Text(`直线距离 ${(rec.distance / 1000).toFixed(2)}km`).fontSize(10).fontColor(COLORS.text3)
Text(rec.time).fontSize(10).fontColor(COLORS.text3)
}
.width('100%')
}
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
.width('100%')
}
}, (rec: SearchRecord) => `${rec.name}-${rec.reliability}`)
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.width('100%')
// 双特性代码预览卡(体现技术点)
this.codePreviewCard()
}
.width('100%')
.height('100%')
}
tabSearch 是 6.1.1 reliability 特性的核心展示页。整体分5层:特性说明卡、搜索框+按钮、搜索状态文案、搜索结果列表、代码预览卡。搜索框用 TextInput 组件,text 参数绑定 queryInput 状态实现受控输入——onChange 回调将输入值同步到 queryInput,按钮点击调用 runSearch 触发 site.searchByText。searchState 文案动态显示搜索进度(“搜索中…”/“找到 X 家店”/“搜索失败(code)”),让用户始终知道当前状态。
搜索结果列表用 List 组件(而非 Scroll+Column),因为 List 是 ArkUI 专为长列表设计的虚拟化容器——只渲染可见区域的 ListItem,大数据量下性能优于 Scroll。每个 ListItem 内是一个 Column 卡片,包含4行信息:第一行是店名(layoutWeight 占满+maxLines 1+textOverflow Ellipsis 省略号)和 reliability 等级标签(reliabilityScore 返回的 label 和 color);第二行是地址(同样省略号处理);第三行是 reliability 分数条——这是本特性的视觉核心。
分数条用 Progress 组件,type 设为 Linear 线性进度条,value 是 rec.reliability * 100(将 [0,1] 映射到 [0,100] 百分比),total 100。color 用 reliabilityScore(rec.reliability).color 动态着色——高相关番茄红、中相关暖橙、低相关浅驼。分数条右侧是"reliability 0.96"这样的数值文本,用 monospace 等宽字体呈现,强化"技术指标"的精确感。这种"分数条+数值文本"的组合让用户既能直观感受分数高低(条长度),又能看到精确数值(文本),是数据可视化的人性化设计。第四行是距离(米转公里保留2位小数)和时间文案。
8.6 代码预览卡 codePreviewCard
/** 双特性代码预览卡(深色底 monospace 展示 6.1.1 新调用) */
@Builder
codePreviewCard() {
Column({ space: 6 }) {
Text('⌨️ Map Kit 6.1.1 双新特性调用').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column({ space: 4 }) {
Text('const res = await site.searchByText(params)')
.fontSize(9).fontColor(COLORS.red).fontFamily('monospace')
Text('const score = res.sites[0].reliability // [0,1]')
.fontSize(9).fontColor(COLORS.red).fontFamily('monospace')
Text('manager.onMarkerLongClick(mk => {...}) // 24+')
.fontSize(9).fontColor(COLORS.orange).fontFamily('monospace')
Text('manager.onPoiLongClick(poi => {...}) // 24+')
.fontSize(9).fontColor(COLORS.orange).fontFamily('monospace')
}
.padding(10)
.borderRadius(8)
.backgroundColor(COLORS.codeBg)
.width('100%')
}
.padding(10)
.borderRadius(10)
.backgroundColor(COLORS.chip)
.width('100%')
}
codePreviewCard 是一个技术展示型卡片,用深色底(codeBg #2E1E16 深棕黑)+ monospace 等宽字体呈现 6.1.1 两大特性的核心 API 调用代码。四行代码分两组着色:前两行是搜索特性(site.searchByText + reliability 读取)用番茄红,后两行是事件特性(onMarkerLongClick + onPoiLongClick)用暖橙——颜色分组与全应用的颜色语义体系一致。这种"在应用内展示自身技术栈代码"的设计,既是技术演示也是教学,让开发者用户能直接看到 API 签名。注释"// 24+"表示 API level 24+(对应 HarmonyOS 6.1.1),是版本兼容性的重要提示。
8.7 我的 Tab tabMine 与底部导航
/** 我的 Tab:食客卡 + 数据三宫格 + 功能清单 */
@Builder
tabMine() {
Column({ space: 10 }) {
// 食客渐变大卡
Column({ space: 8 }) {
Row({ space: 12 }) {
Text('🍜').fontSize(34)
Column({ space: 3 }) {
Text('资深吃货 · 金筷子').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('探店 92 折券每月 2 张 · 新店优先内测').fontSize(11).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Divider().strokeWidth(1).color(COLORS.line)
Row() {
Text('本月探店 9 家').fontSize(11).fontColor(COLORS.sub)
Blank()
Text('累计获赞 328').fontSize(11).fontColor(COLORS.red)
}
.width('100%')
}
.padding(14)
.borderRadius(14)
.linearGradient({
angle: 135,
colors: [[COLORS.redL, 0.0], [COLORS.card, 0.72]]
})
.width('100%')
// 数据三宫格
Row({ space: 8 }) {
this.statCell('86 家', '累计探店')
this.statCell('42 篇', '发布食记')
this.statCell('126 元', '本月省下')
}
.width('100%')
// 功能清单
ForEach(this.funcList, (item: FuncItem) => {
Row({ space: 10 }) {
Text(item.icon).fontSize(18)
Text(item.label).fontSize(13).fontColor(COLORS.title).layoutWeight(1)
Text(item.value).fontSize(11).fontColor(COLORS.sub)
Text('›').fontSize(14).fontColor(COLORS.text3)
}
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
.width('100%')
}, (item: FuncItem) => item.label)
// 版本脚注
Text('食遇记 v1.13.2 · Map Kit 6.1.1 双新特性演示').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
}
/** 底部导航 Tab 栏(4 Tab 单排) */
@Builder
tabBar() {
Row() {
ForEach(TAB_LIST, (tab: TabMeta, idx: number) => {
Column({ space: 3 }) {
Text(tab.icon).fontSize(18)
Text(tab.label).fontSize(10)
.fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
}
.layoutWeight(1)
.onClick(() => { this.currentTab = idx; })
}, (tab: TabMeta) => tab.label)
}
.padding({ top: 8, bottom: 8 })
.width('100%')
.backgroundColor(COLORS.card)
}
tabMine 是个人中心页,分4层:食客渐变大卡、数据三宫格、功能清单、版本脚注。食客卡用 linearGradient 渐变背景(redL 到 card,角度135,0.72 位置过渡——比头部 Banner 的 0.65 更晚过渡,让红色占比稍多,突出"个人荣誉"感)。卡片上部是 🍜 大 emoji + “资深吃货·金筷子"称号 + 会员权益文案,下部用 Divider 分隔后展示"本月探店 9 家"和"累计获赞 328”(番茄红突出)。整个食客卡是用户身份与成就的视觉化呈现。
数据三宫格复用 statCell,展示"累计探店/发布食记/本月省下"三个累计指标——注意这里与美食 Tab 的三宫格指标不同(美食 Tab 是"今日"维度,我的 Tab 是"累计"维度),同一组件不同数据呈现不同语义。功能清单 ForEach 渲染 FUNC_LIST 8条,每条 Row 布局:emoji 图标+功能名(layoutWeight 占满)+状态/数值文本+›箭头。›箭头是常见的"可点击进入"视觉暗示,源自 iOS 设置页的设计语言。版本脚注"食遇记 v1.13.2 · Map Kit 6.1.1 双新特性演示"用9号浅驼字,低调告知版本信息。
tabBar 是底部导航栏,用 Row+ForEach 渲染 TAB_LIST 4个 Tab。每个 Tab 是 Column 布局:emoji 图标(18号)+中文标签(10号),label 的 fontColor 根据 currentTab === idx 判断——激活态用 tabOn 番茄红、未激活用 text3 浅驼。onClick 切换 currentTab,触发主内容区条件渲染切换页面。整个 tabBar 用 card 白底,padding 上下8,与主内容区视觉分隔。
8.8 弹窗系统:遮罩层与三个弹窗
/** 弹窗遮罩层(点击空白处关闭) */
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
/** 收藏店铺弹窗:店名 + 品类 + 地址输入 */
@Builder
panelAdd(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('收藏新店铺').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
TextInput({ placeholder: '店铺名称', text: this.formName })
.height(38)
.fontSize(12)
.fontColor(COLORS.title)
.placeholderColor(COLORS.text3)
.backgroundColor(COLORS.chip)
.onChange((v: string) => { this.formName = v; })
TextInput({ placeholder: '品类(如:本帮菜)', text: this.formCate })
.height(38)
.fontSize(12)
.fontColor(COLORS.title)
.placeholderColor(COLORS.text3)
.backgroundColor(COLORS.chip)
.onChange((v: string) => { this.formCate = v; })
TextInput({ placeholder: '地址(可留空地图选点)', text: this.formAddr })
.height(38)
.fontSize(12)
.fontColor(COLORS.title)
.placeholderColor(COLORS.text3)
.backgroundColor(COLORS.chip)
.onChange((v: string) => { this.formAddr = v; })
Row({ space: 10 }) {
Button('取消')
.layoutWeight(1)
.fontSize(12)
.backgroundColor(COLORS.chip)
.fontColor(COLORS.sub)
.onClick(() => { onClose(); })
Button('收藏')
.layoutWeight(1)
.fontSize(12)
.backgroundColor(COLORS.red)
.onClick(() => { this.saveShop(); })
}
.width('100%')
}
.padding(16)
.borderRadius(14)
.backgroundColor(COLORS.card)
.width('82%')
}
.width('100%')
.height('100%')
}
modalOverlay 是弹窗遮罩层 Builder,接收 onClose 回调。它是一个填满全屏的空 Column,backgroundColor 设为 mask 半透明色(rgba(51,36,28,0.52)),onClick 触发 onClose 关闭弹窗——这是"点击遮罩关闭"的标准交互,让用户无需找关闭按钮,点击空白处即可退出弹窗。遮罩层在视觉上 dim 主内容,让弹窗成为视觉焦点。
panelAdd 是收藏店铺弹窗,用 Stack 层叠遮罩层和弹窗内容。弹窗内容是 Column 布局:标题"收藏新店铺"+三个 TextInput(店名/品类/地址)+取消/收藏按钮行。三个 TextInput 都用 chip 米色底(与主背景区分),text 参数绑定 formName/formCate/formAddr 状态实现受控输入,onChange 同步输入值。按钮行用 Row+layoutWeight(1) 让两个按钮均分宽度——取消用 chip 米底 sub 棕字(次要操作),收藏用 red 番茄红底(主操作)。整个弹窗 width 82%居中,padding 16,borderRadius 14,card 白底——比主内容卡片更大的圆角强化"浮层"感。Stack 内先声明 modalOverlay(在下层),后声明内容 Column(在上层),利用 Stack 后声明在上的规则实现层叠。
/** 编辑备注弹窗:回填当前店铺备注 */
@Builder
panelEdit(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('编辑店铺备注').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(this.editIdx < this.shopList.length ? this.shopList[this.editIdx].name : '')
.fontSize(11)
.fontColor(COLORS.sub)
.width('100%')
TextInput({ placeholder: '输入新备注', text: this.editNote })
.height(38)
.fontSize(12)
.fontColor(COLORS.title)
.placeholderColor(COLORS.text3)
.backgroundColor(COLORS.chip)
.onChange((v: string) => { this.editNote = v; })
Row({ space: 10 }) {
Button('取消')
.layoutWeight(1)
.fontSize(12)
.backgroundColor(COLORS.chip)
.fontColor(COLORS.sub)
.onClick(() => { onClose(); })
Button('保存')
.layoutWeight(1)
.fontSize(12)
.backgroundColor(COLORS.red)
.onClick(() => { this.updateShop(); })
}
.width('100%')
}
.padding(16)
.borderRadius(14)
.backgroundColor(COLORS.card)
.width('82%')
}
.width('100%')
.height('100%')
}
/** 删除确认弹窗:店铺名 + 确认/取消 */
@Builder
panelDel(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('删除收藏店铺').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(this.delIdx < this.shopList.length
? `确定删除「${this.shopList[this.delIdx].name}」吗?` : '确定删除吗?')
.fontSize(12)
.fontColor(COLORS.sub)
.width('100%')
Row({ space: 10 }) {
Button('取消')
.layoutWeight(1)
.fontSize(12)
.backgroundColor(COLORS.chip)
.fontColor(COLORS.sub)
.onClick(() => { onClose(); })
Button('删除')
.layoutWeight(1)
.fontSize(12)
.backgroundColor(COLORS.red)
.onClick(() => { this.delShop(); })
}
.width('100%')
}
.padding(16)
.borderRadius(14)
.backgroundColor(COLORS.card)
.width('82%')
}
.width('100%')
.height('100%')
}
panelEdit 是编辑备注弹窗,结构比 panelAdd 简单——只有标题、当前店名展示(只读 Text)、备注输入框、取消/保存按钮。当前店名用条件表达式 this.editIdx < this.shopList.length ? this.shopList[this.editIdx].name : ‘’ 做边界保护,避免 editIdx 越界时 shopList[this.editIdx] 报错。这种防御式写法在异步弹窗场景下尤为重要——用户可能打开弹窗后切换 Tab 导致 shopList 变化,editIdx 可能失效。editNote 的 TextInput 同样受控,保存按钮调用 updateShop 方法。
panelDel 是删除确认弹窗,是破坏性操作的二次确认机制。弹窗内容只有标题、确认文案、取消/删除按钮。确认文案用模板字符串拼接当前店名——“确定删除「南翔小笼馒头店」吗?”,让用户明确知道要删的是哪一家,避免误删。删除按钮用番茄红底(与收藏/保存按钮一致),但从 UX 角度,破坏性操作通常应该用更醒目的红色——这里复用 COLORS.red 已经足够。三个弹窗共享 modalOverlay 遮罩层和相同的视觉结构(width 82%/padding 16/borderRadius 14/card 白底),保证了弹窗系统的视觉一致性。
九、两大新特性落地对比
| 对比维度 | reliability 相关性分数(搜索特性) | 长按事件监听(事件特性) |
|---|---|---|
| 所属模块 | site 模块 | map 模块 MapEventManager |
| 核心 API | site.searchByText(params) | onMarkerLongClick / onPoiLongClick |
| 新增字段/接口 | Site.reliability: number([0,1]) | onMarkerLongClick / offMarkerLongClick / onPoiLongClick / offPoiLongClick |
| 回调参数类型 | 无回调,返回 Promise | map.Marker / mapCommon.Poi |
| 数据获取方式 | s.reliability ?? 0(空值合并兜底) | marker.getPosition() / poi.position |
| UI 呈现形态 | Progress 线性分数条 + 等级标签 + 数值文本 | 事件日志流(emoji+类型+名称+坐标+时间) |
| 颜色映射策略 | ≥0.8 番茄红 / ≥0.5 暖橙 / 其余浅驼 | Marker 番茄红 / POI 暖橙 |
| 用户交互价值 | 让搜索结果质量透明化,辅助筛选决策 | 提供确认意图更强的交互手势,触发收藏/导航 |
| 防御式编程 | reliability 字段 ?? 0 兜底,老 SDK 兼容 | mapEventManager 判空,addMarker 逐个 try-catch |
| 异常处理 | catch BusinessError,保留 Mock 演示数据 | 单个 Marker 失败不中断整批添加 |
| 状态管理 | @State searchRecords + @Observed SearchRecord | @State eventLogs + @Observed EventLog |
| 刷新策略 | 整体替换 searchRecords 数组引用 | unshift 新条目到数组头部 |
| 版本要求 | HarmonyOS 6.1.1+(API 24+) | HarmonyOS 6.1.1+(API 24+) |
| 行业场景价值 | 美食搜索区分"本帮菜馆"与"本帮酱料专卖店" | 长按地图店铺 Marker 触发收藏/食记撰写 |
十、总结与工程启示
本文以"食遇记·附近美食探店"应用为载体,完整拆解了 HarmonyOS 6.1.1 Map Kit 两大新特性在本地生活美食探店场景的端到端落地过程。从 site.searchByText 返回的 Site.reliability 相关性分数,到 MapEventManager 的 onMarkerLongClick / onPoiLongClick 长按事件监听,再到 ArkUI 声明式 UI 的颜色系统、状态管理、Builder 复用、弹窗系统,整个应用展示了一套"新技术落地"的完整工程范式。
reliability 字段的价值在于将"搜索结果质量"从开发者主观估算变为后端引擎量化计算。在美食探店场景中,用户搜索"本帮菜"时,Map Kit 后端会综合关键字匹配度、POI 权重、距离衰减、用户行为热度等多维因素,给出一个 [0,1] 的相关性分数——"南翔小笼馒头店"可能得到 0.96(高相关),"本帮酱料专卖店"可能只有 0.11(低相关,非餐饮)。开发者无需自行实现相关性算法,直接读取字段即可在 UI 层用分数条+等级标签呈现给用户,极大降低了"搜索结果排序与筛选"的开发成本。本应用用 reliabilityScore 函数将分数三档映射(高/中/低),用 Progress 组件渲染分数条,用颜色体系(番茄红/暖橙/浅驼)让分数高低一目了然,是这一新特性的标准 UI 落地模式。
长按事件监听的价值在于为地图交互增加了一种"确认意图"的手势维度。在探店场景中,用户单击地图标记可能是误触或随意浏览,但长按一定是明确指向某家店铺——可能是想收藏、想写食记、想发起导航。6.1.1 的 onMarkerLongClick 和 onPoiLongClick 让开发者能精确捕获这种意图,触发对应的业务流程。本应用将长按事件记录到事件日志流(unshift 置顶+固定高度可滚动),既演示了事件数据的结构(type/name/坐标/时间),又为真实业务(长按收藏、长按导航)提供了扩展起点。offMarkerLongClick / offPoiLongClick 的"全清"语义也通过 Toggle 开关得到了演示,让用户能动态控制是否接收长按事件。
从工程角度,本应用还有几处值得借鉴的实践。第一是颜色系统的接口化设计——ColorPalette 接口集中声明所有颜色字段,COLORS 常量提供具体值,全应用通过 COLORS.xxx 引用,保证主题一致性且易维护。第二是 @Observed + @State 的组合使用——ShopItem/SearchRecord/EventLog 三个数据类用 @Observed 修饰支持属性级刷新,shopList/searchRecords/eventLogs 三个数组用 @State 修饰支持引用级刷新,updateShop 中的 slice() 换引用是属性变化触发列表刷新的兜底手段。第三是防御式编程——reliability ?? 0 兜底老 SDK 兼容、mapEventManager 判空避免空指针、addMarker 逐个 try-catch 避免单点失败中断整批、editIdx 边界检查防止数组越界,这些细节让应用在面对异常输入和环境变化时保持稳健。第四是 Builder 复用——statCell 和 modalOverlay 两个带参 Builder 在多处复用,减少了代码冗余,也保证了视觉一致性。
最后,本应用的颜色语义体系值得品味。番茄红不只是一个"主色",它同时承载着"主操作按钮"“激活态 Tab”“高相关等级”“高分评分"四重语义;暖橙同时承载"次要操作”“中相关等级”“中档评分"三重语义;浅驼同时承载"弱文本”“低相关等级”"低分评分"三重语义。这种"颜色即信息"的设计让用户无需阅读文字,通过颜色就能快速判断操作优先级、结果质量、评分高低——在信息密集的本地生活应用中,这种视觉化语义体系能显著降低用户的认知负荷。HarmonyOS 6.1.1 的两大新特性配上这套颜色语义体系,让"食遇记"不仅是一个技术演示,更是一个有设计语言的完整产品原型。希望本文的逐行拆解能帮助开发者快速掌握这两大新特性,在自己的本地生活应用中落地出同样优秀的产品体验。
附录:DevEco Studio 创建新项目与查看 SDK 版本
本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。
一、创建新项目
1.1 进入欢迎界面
启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:
- 新建项目:从头创建新项目
- 打开项目:打开本地已有项目
- 克隆仓库:从 Git 等版本控制拉取代码
点击 “新建项目” 按钮,进入项目创建向导。

1.2 选择项目模板
在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:
| 类型 | 说明 |
|---|---|
| 应用(Application) | 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期 |
| 元服务(Atomic Service) | 开发轻量级的原子化服务,无需安装即可使用 |
选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

1.3 配置项目信息
点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:
| 配置项 | 示例值 | 说明 |
|---|---|---|
| 项目名称(Project name) | rollboat |
应用的项目名称,建议使用英文命名 |
| 包名(Bundle name) | com.rollboat.myapplication |
应用唯一标识,采用反向域名格式 |
| 保存路径(Save location) | D:\CodeFactory\rollboat |
项目本地存储路径,避免使用中文和空格 |
| 兼容 SDK(Compatible SDK) | 6.1.1(24) |
目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异 |
| 模块名称(Module name) | entry |
主模块名称,默认 entry 为应用入口模块 |
| 设备类型(Device types) | ☑ Phone | 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV |
右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

1.4 完成创建
确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 Hvigor 构建初始化(
Build Init)
构建日志中显示 “退出代码为 0” 表示项目初始化成功。

1.5 项目结构概览
创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:
rollboat/
├── .hvigor/ # Hvigor 构建工具缓存
├── .idea/ # IDE 配置文件
├── AppScope/ # 应用级全局配置
│ └── app.json5
├── entry/ # 主模块(入口模块)
│ ├── src/main/ets/
│ │ ├── entryability/ # Ability 生命周期管理
│ │ │ └── EntryAbility.ets
│ │ └── pages/ # UI 页面
│ │ └── Index.ets # 首页(默认 Hello World)
│ ├── src/main/resources/ # 资源文件
│ ├── module.json5 # 模块配置
│ └── build-profile.json5 # 构建配置
├── oh_modules/ # OHPM 依赖包
├── build-profile.json5 # 工程构建配置
├── hvigorfile.ts # Hvigor 构建脚本
└── oh-package.json5 # 包管理配置
核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:
@Entry
@Component
struct Index {
@State message: string = 'Hello World';
build() {
RelativeContainer() {
Text(this.message)
.id('HelloWorld')
.fontSize($r('app.float.page_text_font_size'))
.fontWeight(FontWeight.Bold)
.alignRules({
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
.onClick(() => {
this.message = 'Welcome';
})
}
.height('100%')
.width('100%')
}
}
| 关键语法 | 作用 |
|---|---|
@Entry |
标记为页面入口,可用于路由跳转 |
@Component |
声明为自定义组件 |
@State |
状态变量,数据变更时自动触发 UI 刷新 |
RelativeContainer |
相对布局容器,替代传统线性布局 |
.onClick() |
点击事件,此处点击后文本变为 “Welcome” |
打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

二、查看 SDK 版本
2.1 查看 HarmonyOS SDK
DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:
文件 → 设置 → HarmonyOS SDK(或快捷键
Ctrl + Alt + S搜索 “HarmonyOS SDK”)
在设置面板中,可以看到当前已安装的 SDK 版本信息:
| 名称 | 阶段 | 状态 |
|---|---|---|
| HarmonyOS 6.1.1 | Release | ✅ 已安装 |
界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

2.2 查看 ArkUI-X SDK(跨平台扩展)
如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:
文件 → 设置 → 语言和框架 → ArkUI-X
在这里可以查看已安装和可选的 ArkUI-X SDK 版本:
| 版本 | SDK 版本号 | 阶段 | 状态 |
|---|---|---|---|
| API Version 24 | 6.1.1.100 | Release | ✅ 已安装 |
| API Version 23 | 6.1.0.28 | Beta1 | 未安装 |
| API Version 22 | 6.0.2.112 | Release | 未安装 |
安装路径示例:D:\DevTools\ArkUI-X\sdk
说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

三、小结
| 步骤 | 操作 | 关键点 |
|---|---|---|
| 创建项目 | 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 | 使用 Stage 模型 + ArkTS 语言 |
| 查看 SDK | 设置 → HarmonyOS SDK | SDK 已内置,无需手动安装 |
| 跨平台扩展 | 设置 → ArkUI-X | 根据需要安装对应 API 版本 |
至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。
本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。
更多推荐

所有评论(0)