一、前言:HarmonyOS ArkUI 与 Map Kit 的演进

在这里插入图片描述

HarmonyOS 的 ArkUI 框架自诞生以来,一直以声明式开发范式为核心,通过 @Component@State@Builder 等装饰器让开发者能够以接近自然语言的方式描述界面结构与状态流转。与传统的命令式 UI 编写相比,ArkUI 的最大优势在于"状态驱动视图"——当 @State 修饰的变量发生变化时,框架会自动 diff 并刷新与之绑定的组件树,开发者无需手动调用 setTextsetBackgroundColor 之类的指令。这种范式在多 Tab、多弹窗、多数据流的复杂业务页面中尤其能体现价值,因为它把"什么时候刷新"这件最容易出错的事情交给了框架,开发者只需关注"数据从哪来、往哪去"。

在这里插入图片描述
Map Kit 是 HarmonyOS 官方提供的地图能力套件,涵盖 MapComponent 地图组件、map.MapComponentController 地图控制器、map.MapEventManager 地图事件管理器以及 site 模块的地点搜索服务。它不是一个简单的"在页面上贴一张地图图片"的工具,而是一套完整的"地图即交互画布"体系:开发者可以在地图上批量添加 Marker(标记点)、监听用户与地图的每一次手势交互、发起关键字搜索并拿到结构化的地点结果。在 6.1.1 版本中,Map Kit 围绕"搜索结果可信度"与"手势交互维度"两个方向进行了能力补强,使得地图从"只读展示"真正迈向"可对话的探索界面"。

在这里插入图片描述
第一个新特性来自 site 模块的 searchByText 接口:其返回的 Site 类型新增了 reliability 字段。这是一个取值范围为 [0, 1] 的浮点数,1 表示搜索结果与关键字完全相关,数值越低则关联程度越弱。在以往的实现中,开发者拿到搜索结果列表后只能"照单全收"地展示,无法判断第 3 条结果和第 6 条结果哪个更贴近用户意图,更无法在 UI 上做出视觉区分。有了 reliability 之后,我们可以为每条结果打上"高相关/中相关/低相关"的等级标签,用线性进度条把分数可视化,甚至把低分结果折叠或弱化展示,从而让搜索结果列表从"信息罗列"升级为"信息筛选"。

在这里插入图片描述
第二个新特性来自 MapEventManager:它新增了 onMarkerLongClick / offMarkerLongClick(地图标记长按监听)和 onPoiLongClick / offPoiLongClick(地图 POI 长按监听)两对方法。在此之前,地图交互主要依赖点击(onMarkerClickonMapClick),长按这一手势虽然用户在日常 App 中极其常见(长按复制、长按删除、长按弹出菜单),但在地图场景中一直缺少原生支持。6.1.1 补齐了这块短板后,用户长按地图上的某家馆子 Marker,或长按地图上任意一个 POI 兴趣点,都能触发回调并拿到精确的经纬度与名称,这为"地图即操作面板"提供了基础。

在这里插入图片描述
将这两项特性放到"精品咖啡门店探索服务"这一具体行业中,价值会立刻显现。咖啡馆漫游类应用的核心矛盾是:城市里的咖啡馆数量庞大、命名混乱(“湖滨银泰手冲馆”“龙翔桥烘焙坊”“凤起路植咖所”……),用户搜索"咖啡馆"三个字时返回的结果可能掺杂大量茶咖融合店、烘焙工厂店等弱相关条目;同时,用户在地图上看到密集的 Marker 时,又希望能快速对某一家"长按收藏"“长按记录”。reliability 解决的是"搜索结果该不该信"的问题,长按监听解决的是"地图上的点该怎么用"的问题,二者一前一后,恰好覆盖了"找馆子—看馆子—记馆子"的完整探索链路。

在这里插入图片描述
本文将以一个名为"拾豆·独立咖啡馆漫游"的深色主题应用为载体,完整拆解如何在一个 ArkUI 页面中同时落地这两项 6.1.1 新特性。应用采用烘焙棕(#231B13)+ 奶泡米(#F5EAD8)+ 焦糖金(#D99A4E)的深色色系,包含咖啡、地图、搜索、我的四个 Tab,地图 Tab 专门承载长按事件日志流,搜索 Tab 专门承载 reliability 分数条。下面我们从整体架构开始,逐段拆解实现。

在这里插入图片描述

二、整体架构与数据流转

2.1 架构总览

页面入口 aboutToAppear

setupMapCallback 地图回调装配

MapComponent 初始化回调触发

err 是否为空?

console.error 打印错误并返回

获取 mapController

getEventManager 获取事件管理器

循环 MARKER_SPOTS addMarker 批量打点

onMarkerLongClick 注册标记长按监听

onPoiLongClick 注册 POI 长按监听

四个 Tab 就绪待交互

咖啡 Tab 口碑馆列表

地图 Tab 长按事件日志流

搜索 Tab searchByText + reliability

我的 Tab 会员卡与功能清单

用户长按 Marker/POI

eventLogs.unshift 置顶日志

日志流自动刷新

用户输入关键字点搜索

site.searchByText 异步调用

读取 site.reliability 分数

reliabilityScore 映射等级颜色

分数条 + 标签渲染结果列表

收藏/编辑/删除弹窗系统

2.2 模块职责划分

从上面的流程图可以看出,整个页面的代码组织遵循"颜色常量 → 数据模型 → 辅助函数 → 组件主体 → Builder 群"的五段式结构。颜色常量集中在一个 ColorPalette 接口与 COLORS 常量中,保证深色主题的可维护性——任何一处配色调整只需改一个地方。数据模型部分用 @Observed 装饰的 CafeItemSearchRecordEventLog 三个类分别承载收藏列表、搜索结果、长按事件三类业务数据,@Observed 使得它们在被 @State 数组引用时具备深层属性变化的响应能力。辅助函数 reliabilityScoreseatsColor 把"分数→颜色"的映射逻辑抽离成纯函数,避免在 Builder 中堆砌大量 if-else。组件主体 Page1146 承载所有状态与方法,Builder 群则按 Tab 和弹窗职责拆分成独立的 @Builder 函数。

这种分段式架构的好处在于:当 6.1.1 的新特性需要调整时,开发者能立刻定位到对应区块——reliability 的读取逻辑只在 runSearch 方法和 reliabilityScore 函数中,长按监听的注册与切换只在 setupMapCallbacktoggleMarkerListentogglePoiListen 三个方法中,互不干扰,回归测试的范围也因此可控。

三、颜色系统与常量定义

3.1 深色主题色板接口与常量

/** 主题色板接口:集中声明页面所有颜色字段(烘焙棕+奶泡米+焦糖金深色系) */
interface ColorPalette {
  bg: string;       // 页面底色(烘焙棕深底)
  card: string;     // 卡片底色(深咖)
  chip: string;     // 胶囊与输入框底色(咖啡渣棕)
  title: string;    // 主标题色(奶泡米)
  sub: string;      // 次级文本色(燕麦奶色)
  text3: string;    // 弱文本色(浅烘焙棕)
  accent: string;   // 行业主色(焦糖金)
  accentD: string;  // 主色深(深焦糖,渐变起点)
  accentL: string;  // 主色浅(奶金高亮)
  second: string;   // 副色(奶泡米黄)
  danger: string;   // 警示色(低相关/删除)
  info: string;     // 信息色(湖蓝)
  line: string;     // 分割线色
  tabOn: string;    // 底部 Tab 选中色
  mask: string;     // 弹窗遮罩色
  codeBg: string;   // 代码预览卡底色(深咖啡黑)
}

/** 深色主题色板常量(拾豆 · 烘焙棕 + 奶泡米 + 焦糖金) */
const COLORS: ColorPalette = {
  bg: '#231B13',
  card: '#2D2318',
  chip: '#392C1E',
  title: '#F5EAD8',
  sub: '#C6AE8F',
  text3: '#8A7458',
  accent: '#D99A4E',
  accentD: '#8F5C22',
  accentL: '#F7E3C2',
  second: '#F0DFB8',
  danger: '#DF6B54',
  info: '#7FA8D9',
  line: '#453624',
  tabOn: '#D99A4E',
  mask: 'rgba(18,12,6,0.68)',
  codeBg: '#1C1209'
};

这段代码定义了整个应用的颜色中枢。第一段 interface ColorPalette 是一个接口声明,它把页面里用到的所有颜色字段集中到一个类型里,这样做的好处是双重的:其一,当后续在 Builder 中写 COLORS.xxx 时,IDE 能给出自动补全和类型校验,拼错字段名会在编译期报错而不是跑到运行时才表现为"颜色没生效";其二,如果未来要做多主题切换(比如浅色模式、节日限定主题),只需再实现一个 ColorPalette 的对象即可,所有引用 COLORS 的地方天然兼容。

第二段 const COLORS: ColorPalette = {...} 是真正的色值实现。这套深色色系围绕"烘焙棕→奶泡米→焦糖金"三个基调展开:bg#231B13)是最深的烘焙棕作为页面底色,模拟咖啡豆烘焙到深度的色泽;card#2D2318)比底色略亮一档用于卡片,让卡片能从背景中"浮"出来;chip#392C1E)再亮一档用于胶囊标签和输入框底色。文本层用 title#F5EAD8 奶泡米)做主标题、sub#C6AE8F 燕麦奶色)做次级文本、text3#8A7458 浅烘焙棕)做弱文本,形成三级灰阶。accent#D99A4E 焦糖金)是行业主色,所有可点击的主操作按钮、选中态、分数条高相关色都用它,保证视觉焦点的一致性。danger#DF6B54)用于低相关标签和删除按钮,info#7FA8D9 湖蓝)用于"编辑"这种次级操作。mask 用半透明的深棕做弹窗遮罩,codeBg#1C1209)是最深的咖啡黑,专门给代码预览卡营造终端感。整个色板的设计哲学是"每一档颜色都有明确语义,不靠随手取色"。

3.2 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[] = ['全部', '手冲', '意式', '自烘焙', '宠物友好', '深夜营业', '有插座', '豆票可用'];

/** 城市中心点(地图初始化中心,Map Kit 搜索 location 参数:杭州) */
const CITY_CENTER: mapCommon.LatLng = { latitude: 30.2741, longitude: 120.1551 };

这里定义了三个全局常量。TAB_LISTTabMeta 接口约束每条元素的 iconlabel 字段,4 个 Tab 分别是咖啡、地图、搜索、我的,图标用 emoji 而非图片资源,好处是零加载开销、天然支持深色模式下的颜色继承(emoji 是矢量字形),缺点是无法做精细的描边样式,但对于这类轻量应用足够。CATE_TAGS 是头部横滑筛选胶囊的文案数组,包含"手冲"“意式”“自烘焙”“宠物友好”"深夜营业"等咖啡馆行业常见的筛选维度,这些标签目前是纯展示态(点击只切换选中样式),后续可扩展为对 cafeList 的过滤条件。

CITY_CENTER 是整段代码中与 Map Kit 关联最紧密的常量,类型是 mapCommon.LatLng——这是 Map Kit 提供的经纬度类型,包含 latitude(纬度)和 longitude(经度)两个字段。这里的坐标 30.2741, 120.1551 是杭州市中心,它会在两处被使用:一是作为 MapOptions.position.target 让地图初始化时聚焦杭州;二是作为 site.SearchByTextParams.location 让关键字搜索以杭州为中心、5 公里为半径圈定范围。把城市中心点抽成常量的好处是,若未来要做多城市切换,只需改这一处,地图初始化和搜索范围会同步跟随。

3.3 地图标注点与业务数据模型

/** 地图标注点接口(独立咖啡馆 Marker 群,长按事件的数据来源) */
interface SpotItem {
  name: string;  // 馆子名称
  lat: number;   // 纬度
  lng: number;   // 经度
  tag: string;   // 馆子特色标签
}

/** 咖啡馆标注点 Mock 数据(6 个,围绕杭州城市中心点 ±0.02 度散布) */
const MARKER_SPOTS: SpotItem[] = [
  { name: '拾豆·湖滨银泰店', lat: 30.259, lng: 120.165, tag: '手冲' },
  { name: '拾豆·龙翔桥烘焙坊', lat: 30.266, lng: 120.162, tag: '自烘焙' },
  { name: '拾豆·武林夜巷店', lat: 30.272, lng: 120.158, tag: '深夜' },
  { name: '拾豆·凤起路植咖所', lat: 30.279, lng: 120.152, tag: '宠物' },
  { name: '拾豆·南山路湖畔店', lat: 30.255, lng: 120.15, tag: '湖景' },
  { name: '拾豆·文化广场乐咖', lat: 30.282, lng: 120.161, tag: '黑胶' }
];

SpotItem 是地图标注点的数据结构,MARKER_SPOTS 是 6 家咖啡馆的 Mock 数据。这些坐标都围绕杭州城市中心点 30.2741, 120.1551 做 ±0.02 度的散布,这个偏移量在地图上大约对应 2 公里左右的实际距离,6 个点散开后在 zoom: 13 的缩放级别下恰好都能落在屏幕可见区域内,不会太挤也不会太散。这组数据是地图 Tab 中 onMarkerLongClick 事件的数据来源——每个 Marker 的位置由这里的 lat/lng 决定,用户长按某个 Marker 时,回调里能拿到 Marker 的 ID 和位置,但拿不到馆子名称(Marker 本身不存储业务名),所以日志流里 Marker 事件显示的是 #id 而非名称;而 POI 长按事件则能直接拿到 poi.name,因为 POI 是地图底图自带的兴趣点,本身带名称属性。这种差异在后面的事件日志 Builder 中会有视觉区分。

接下来是三个 @Observed 数据模型类,它们分别对应三个 Tab 的核心数据:

/** 咖啡馆条目(咖啡 Tab 收藏列表) */
@Observed export class CafeItem {
  icon: string;   // 馆子 emoji 图标
  name: string;   // 馆名
  bean: string;   // 招牌豆文案
  price: string;  // 手冲价文本
  seats: number;  // 座位数
  note: string;   // 用户备注(可编辑)

  constructor(icon: string, name: string, bean: string, price: string,
    seats: number, note: string) {
    this.icon = icon;
    this.name = name;
    this.bean = bean;
    this.price = price;
    this.seats = seats;
    this.note = note;
  }
}

CafeItem 是咖啡 Tab 收藏列表的单元数据,用 @Observed 装饰是为了让 note 字段在被编辑后能触发列表项的局部刷新。@Observed 配合 @State 数组使用时有一个关键点:直接修改数组元素的属性(如 this.cafeList[idx].note = '新备注')不会让 @State 感知到变化,必须配合数组引用的整体替换(this.cafeList = this.cafeList.slice())才能触发刷新,这一点在后面的 updateCafe 方法中会体现。bean 字段存储招牌豆文案(如"埃塞·耶加雪菲"),price 存手冲价,seats 存座位数,note 是用户自定义备注。构造函数显式接收所有字段,保证每个 CafeItem 实例在创建时就是完整的。

/** 搜索结果条目(★ 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.distance = distance;
    this.reliability = reliability;
    this.time = time;
  }
}

SearchRecord 是搜索 Tab 的核心数据载体,它最关键的字段是 reliability——这就是 6.1.1 新特性的落点。当 site.searchByText 返回 Site 数组后,我们会把每个 Sitereliability 字段读出来,存进 SearchRecord,再在 UI 上用分数条和等级标签展示。name 对应 site.nameaddress 对应 site.formatAddressdistance 对应 site.distance(直线距离,单位米)。这里所有字段都做了空值兜底(在 runSearch 中用 ?? 运算符),因为 6.1.1 虽然新增了 reliability,但它依然是可选字段,老版本或某些地区可能不返回该值,用 ?? 0 兜底为 0 分能保证 UI 不崩。Mock 数据中故意覆盖了 0.970.90.760.580.360.14 六档分数,从高相关到低相关,让分数条的视觉差异在演示阶段就一目了然。

/** 长按事件日志条目(★ 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 是地图 Tab 长按事件日志流的单元数据。type 字段区分 'Marker''POI' 两种事件来源——这对应 6.1.1 的两对新监听方法,Marker 事件来自 onMarkerLongClick(用户长按我们主动添加的咖啡馆标注),POI 事件来自 onPoiLongClick(用户长按地图底图自带的兴趣点,如商场、地铁站)。name 字段对两种事件的含义不同:Marker 事件存的是 #id(因为 Marker 没有名称属性,只有 ID),POI 事件存的是 poi.name(POI 自带名称)。这种异构数据用同一个类承载的设计取舍是:保持日志流渲染逻辑的统一,用 type 字段在 UI 上做图标和颜色区分即可,避免为两种事件写两套列表。Mock 数据预置了 2 条演示日志,让用户进入地图 Tab 时就能看到日志流的形态,而不是面对空列表。

3.4 辅助映射函数

/** 相关性等级接口(分数条旁的标签) */
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.accent };
  }
  if (score >= 0.5) {
    return { label: '中相关', color: COLORS.second };
  }
  return { label: '低相关', color: COLORS.danger };
}

/** 座位数颜色映射:≥14 宽敞主色 / ≥8 适中副色 / 其余紧张警示色 */
function seatsColor(seats: number): string {
  if (seats >= 14) { return COLORS.accent; }
  if (seats >= 8) { return COLORS.second; }
  return COLORS.danger;
}

这两个纯函数把"数值→颜色/标签"的映射逻辑从 Builder 中抽离出来,是代码可读性的关键一环。reliabilityScore 接收一个 [0, 1] 的分数,返回 ScoreLevel 对象(含 labelcolor)。阈值的设定是有行业考量的:0.8 以上意味着搜索结果与关键字高度匹配,用焦糖金(accent)突出;0.50.8 之间是中等相关,用奶泡米黄(second)做中性表达;低于 0.5 则用警示橙红(danger)提示用户"这条结果可能不是你要找的"。这套阈值不是 Map Kit 官方硬性规定,而是开发者根据业务经验设定的分档,未来可以根据实际数据分布调整。

seatsColor 是咖啡馆座位数的颜色映射,14 座以上用主色表示"宽敞"、8 座以上用副色表示"适中"、不足 8 座用警示色表示"紧张"。这个函数在咖啡 Tab 的口碑馆卡片和收藏列表中都会被调用,把数字座位数转成颜色信号,让用户一眼就能判断"这家馆子现在去有没有位子坐"。两个函数都是无副作用的纯函数,输入相同输出必然相同,便于测试和复用。

四、主组件状态与 Map Kit 初始化

4.1 组件状态声明

/** 主页面 */
@Entry
@Component
struct Page1146 {
  /** 当前选中 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 cafeList: Array<CafeItem> = CAFE_LIST;
  /** 我的页功能清单数据 */
  @State funcList: FuncItem[] = FUNC_LIST;

  // --- Map Kit 状态(6.1.1 特性:搜索 reliability + 长按事件) ---
  /** 地图初始化参数(非可选并给默认值,避免组件参数传 undefined) */
  private mapOptions: mapCommon.MapOptions = {
    position: { target: CITY_CENTER, zoom: 13 }
  };
  /** 地图初始化回调(aboutToAppear 中赋值) */
  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 formAddr: string = '';
  /** 编辑弹窗:备注输入 */
  @State editNote: string = '';

这是整个组件的状态中枢。@Entry 表示这是页面入口组件,@Component 表示它是一个 ArkUI 组件。状态分为三类:第一类是 UI 交互态,包括 currentTab(当前 Tab)、cateIdx(筛选选中)、三个弹窗开关 addModal/editModal/delModal、两个弹窗操作索引 editIdx/delIdx;第二类是业务数据,包括 cafeList(收藏列表)、funcList(功能清单)、eventLogs(长按日志)、searchRecords(搜索结果);第三类是 Map Kit 专属状态,包括 mapOptions(地图初始化参数)、mapCallback(初始化回调)、mapController(地图控制器)、mapEventManager(事件管理器)、两个监听开关 markerListenOn/poiListenOn、搜索输入 queryInput 和搜索状态 searchState

需要特别注意的是 mapOptionsmapCallbackmapControllermapEventManager 这四个字段都用 private 而非 @State 修饰。原因在于:它们是 Map Kit 的内部控制对象,变化不需要触发 UI 刷新——mapController 在回调中赋值后只供方法调用(如 addMarker),mapEventManager 只用于注册和注销监听,它们本身不是"给用户看的视图数据"。把它们设为 private 能避免 ArkUI 框架对它们做无谓的响应式追踪,减少性能开销。而 markerListenOnpoiListenOn@State,是因为它们绑定到了 Toggle 开关的 isOn 属性,开关状态需要实时反映给用户。mapOptions 在声明时就给了默认值(position: { target: CITY_CENTER, zoom: 13 }),这一点很关键——MapComponent 接收的 mapOptions 参数不能是 undefined,若用 ? 修饰又不给默认值,组件首次渲染时可能传入 undefined 导致地图初始化异常,显式赋默认值是防御性编程的体现。

4.2 地图回调装配与双长按监听注册

  /**
   * 地图初始化: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, '刚刚'));
      });
    };
  }

这是整个应用的"心脏"方法,所有 Map Kit 6.1.1 新特性的注册都集中在这里。方法名为 setupMapCallback,它的职责不是"调用"地图初始化,而是"装配"回调函数——把这个异步回调赋值给 this.mapCallback,真正的调用时机是 MapComponent 组件在渲染时发现 mapCallback 不为空后主动触发。这种"先装配回调、后由组件触发"的设计,把初始化时机的控制权交给了 Map Kit 框架,开发者只需保证回调里拿到了 controller 就能开始后续操作。

回调函数是 async 的,参数是 (err, mapController),这是 AsyncCallback 的标准签名。第一件事是判断 err:如果地图初始化失败(比如设备无 Google 服务、AGC 配置缺失),直接 console.error 打印错误码和消息后 return,绝不在错误状态下继续往下走——这是异步回调里最重要的防御,否则后续的 getEventManager 会拿到 undefined 导致崩溃。err 为空时,把 mapController 存到 this.mapController,再调用 mapController.getEventManager() 拿到事件管理器存到 this.mapEventManager。这两行是后续所有操作的基础:没有 controller 就不能加 Marker,没有 eventManager 就不能注册长按监听。

接下来是一个 for...of 循环,遍历 MARKER_SPOTS 的 6 家咖啡馆,为每家构造 mapCommon.MarkerOptions 并调用 mapController.addMarkerMarkerOptions 的字段含义:position 是经纬度(必填);clickable: true 让 Marker 可被点击/长按(如果设为 false,onMarkerLongClick 不会触发);visible: true 让 Marker 默认可见;rotation 是旋转角度(0 表示不旋转);zIndex 是层级(0 是最底层);alpha 是透明度(1 表示完全不透明);anchorU/anchorV 是锚点比例(0.5, 1 表示锚点在图片底部中央,让 Marker 的"针尖"对准坐标点);draggable: false 禁止拖拽;flat: false 让 Marker 保持竖立朝向(不贴地)。addMarker 返回 Promise,所以用 await 逐个等待,每个包了 try-catch——即使某个 Marker 添加失败(比如坐标非法),也不会中断后续 Marker 的添加,这是批量操作的容错最佳实践。

最后两段是 6.1.1 的双新特性核心。this.mapEventManager.onMarkerLongClick(...) 注册标记长按监听,回调参数是 map.Marker 类型,通过 marker.getPosition() 拿到经纬度、marker.getId() 拿到 Marker 的 ID,然后 this.eventLogs.unshift(new EventLog(...)) 把事件塞到日志流顶部。unshift 而非 push 的用意是让最新事件显示在列表最上方,符合"最新消息置顶"的用户习惯。this.mapEventManager.onPoiLongClick(...) 注册 POI 长按监听,回调参数是 mapCommon.Poi 类型,它自带 nameposition 属性,所以直接读 poi.namepoi.position.latitude/longitude 即可。两种事件用同一个 EventLog 类承载,靠 type 字段区分,这种设计让日志流的渲染逻辑保持统一。

4.3 长按监听开关切换

  /** 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;
  }

这两个方法分别对应地图 Tab 中两个 Toggle 开关的 onChange 回调,演示了 6.1.1 新增的 offMarkerLongClickoffPoiLongClick 两个注销方法。方法的逻辑是"反转当前状态":如果当前监听是开启的(markerListenOn === true),调用 offMarkerLongClick() 注销监听;如果当前是关闭的,重新调用 onMarkerLongClick(...) 注册监听。最后 this.markerListenOn = !this.markerListenOn 反转开关状态,让 Toggle 的视觉态同步更新。

值得深入理解的是 offMarkerLongClick() 不传参的语义。在 Map Kit 6.1.1 的设计中,off 系列方法如果不传回调引用,表示"清除该类型的全部订阅"——这与你可能熟悉的 Web removeEventListener 不同,后者必须传回要移除的函数引用。这种设计取舍的原因是:地图事件监听通常一个类型只注册一个回调,批量清除比精确移除更符合实际使用场景,也避免了"忘记保存回调引用导致无法移除"的常见 bug。两个方法开头都有 if (!this.mapEventManager) return 的守卫,因为用户可能在地图初始化完成前就拨动开关,此时 mapEventManager 还是 undefined,直接调用方法会抛错,提前 return 是必要的防御。

4.4 searchByText 与 reliability 读取

  /**
   * ★ 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}) · 保留演示数据`;
    }
  }

这是搜索 Tab 的核心方法,完整演示了 site.searchByText 的调用流程和 reliability 字段的读取。方法先用 this.searchState = '搜索中…' 把状态文案改为"搜索中",让用户在等待异步返回时有明确的视觉反馈——别小看这一行,异步操作没有 loading 态是用户体验的头号杀手。接着构造 site.SearchByTextParams 参数对象:query 是搜索关键字(从 queryInput 读,默认"咖啡馆");location 是搜索中心点(用前面定义的 CITY_CENTER 杭州坐标);radius 是搜索半径(5000 米,即 5 公里,覆盖一个城市的核心商圈足够);language 是返回结果语言('zh' 中文)。

try 块中调用 await site.searchByText(params),返回 site.SearchByTextResult 类型,它的 sites 字段是 Array<site.Site>。这里用 result.sites ?? [] 做空值兜底——即使接口返回了 nullundefined,也会被转成空数组,后续的 for...of 不会因遍历非可迭代对象而崩溃。如果 sites.length === 0,把状态设为"无结果 · 保留演示数据"并 return,不覆盖已有的 searchRecords,这样用户搜索无结果时不会看到空列表,而是保留之前的演示数据,体验更友好。

核心在 for...of 循环里:遍历每个 site.Site 对象 s,构造 SearchRecord。这里每一行都在读 Site 的字段并用 ?? 兜底:s.name ?? '未命名地点'(地点名可能为空)、s.formatAddress ?? '暂无地址'(地址可能为空)、s.distance ?? 0(距离可能为空)、s.reliability ?? 0(★ 6.1.1 新字段,相关性分数可能为空,兜底为 0 分)。s.reliability 就是这次新特性要读取的关键字段——在 6.1.1 之前,Site 类型没有这个字段,开发者只能拿 name/address/distance 三件套;6.1.1 之后,多了一个"可信度"维度,让搜索结果有了"质量分"。最后 this.searchRecords = records 整体替换数组引用,触发列表刷新;this.searchState 显示返回条数。

catch 块捕获 BusinessError,把错误码拼进状态文案。site.searchByText 在没有 AGC(AppGallery Connect)配置、没有网络、或 quota 超限时会抛 BusinessError,这里不覆盖 searchRecords,保留 Mock 数据,保证演示链路不中断。这种"失败保留旧数据 + 错误码透传"的容错策略,比直接清空列表或弹个 Toast 更优雅,用户既能看到失败原因,又不至于面对空白界面。

4.5 生命周期与其他业务方法

  /** 打开编辑备注弹窗(回填当前馆子备注) */
  openEditCafe(idx: number) {
    this.editIdx = idx;
    this.editNote = this.cafeList[idx].note;
    this.editModal = true;
  }

  /** 保存收藏馆子(空名兜底默认演示馆子) */
  saveCafe() {
    const name = this.formName === '' ? '拾豆·新收藏馆' : this.formName;
    const addr = this.formAddr === '' ? '杭州市上城区(地图选点)' : this.formAddr;
    this.cafeList.unshift(new CafeItem('⭐', name, '待定豆单', '待询价', 8, addr));
    this.formName = '';
    this.formAddr = '';
    this.addModal = false;
  }

  /** 保存编辑备注(整体刷新数组引用以刷新列表) */
  updateCafe() {
    if (this.editIdx >= 0 && this.editIdx < this.cafeList.length) {
      if (this.editNote !== '') {
        this.cafeList[this.editIdx].note = this.editNote;
      }
      this.cafeList = this.cafeList.slice();
    }
    this.editModal = false;
  }

  /** 删除收藏馆子(确认弹窗回调) */
  delCafe() {
    if (this.delIdx >= 0 && this.delIdx < this.cafeList.length) {
      this.cafeList.splice(this.delIdx, 1);
    }
    this.delModal = false;
  }

  /** 生命周期:初始化地图回调(监听注册在 mapCallback 内完成) */
  aboutToAppear() {
    this.setupMapCallback();
  }

这一组方法处理收藏列表的增删改和生命周期。openEditCafe 在打开编辑弹窗前先把当前馆子的备注回填到 editNote,这样弹窗的 TextInput 能显示已有备注,用户是在"修改"而非"重写"。saveCafe 保存新收藏时做了空名兜底:如果用户没填馆名,默认存"拾豆·新收藏馆";没填地址,默认存"杭州市上城区(地图选点)"。这种兜底让用户即使直接点"收藏"也能得到一条有效数据,降低操作门槛。

updateCafe 是体现 @Observed + @State 刷新机制的关键方法。它先做边界校验(editIdx 在合法范围内),再判断 editNote 非空时把新备注写进 this.cafeList[this.editIdx].note。但光修改属性还不够——前面提过,@State 数组直接修改元素属性不会触发刷新,所以必须有 this.cafeList = this.cafeList.slice() 这一行:slice() 不传参会返回数组的浅拷贝(新引用),赋值给 this.cafeList 后 ArkUI 框架检测到引用变化,才会重新渲染列表。delCafesplice 删除指定索引的元素,splice 会原地修改数组并改变长度,能被 @State 感知到,所以不需要额外 sliceaboutToAppear 是组件生命周期钩子,在组件出现前调用 setupMapCallback 装配地图回调——注意这里只是"装配",地图真正初始化发生在 MapComponent 渲染时。

五、UI 构建与 Builder 群

5.1 页面主构建 Stack 结构

  /** 页面主构建:Stack 包裹主内容与三层弹窗 */
  build() {
    Stack() {
      Column() {
        this.headerMain()
        Divider().strokeWidth(1).color(COLORS.line)
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.tabCafe()
            } 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)
  }

build 方法是组件的视图入口,整体结构是 Stack 包裹 Column 主内容 + 三个条件渲染的弹窗。Stack 是层叠布局,后写的子元素会叠在先写的上面——所以主内容 Column 在最底层,三个弹窗根据各自的状态变量(addModal/editModal/delModal)决定是否叠在主内容之上。这种"Stack + 条件弹窗"是 ArkUI 实现模态弹窗的经典模式:弹窗自带半透明遮罩(后面会看到 modalOverlay),遮罩盖住主内容制造"主内容变暗、弹窗居中"的模态效果。

主内容 Column 从上到下是:headerMain(头部渐变 Banner + 筛选 chips)、一条 Divider 分割线、一个 Scroll 包裹的 Tab 内容区、tabBar(底部导航)。ScrolllayoutWeight(1) 占满中间剩余高度,scrollBar(BarState.Off) 隐藏滚动条让界面更干净。Tab 内容区用 if-else if-else 根据 currentTab 渲染对应的 @Builder——这种条件渲染比 Tabs 组件更灵活,因为四个 Tab 的布局结构差异极大(地图 Tab 有 MapComponent 需要固定高度,搜索 Tab 有 List 列表,咖啡 Tab 是纯卡片流),用 Tabs 组件反而会被它的统一 TabContent 结构束缚。三个弹窗都接收一个 onClose 回调(() => this.xxxModal = false),这是让弹窗内部能"自己关闭自己"的关键——弹窗的遮罩点击和取消按钮都调用 onClose,把关闭逻辑解耦到调用方。

5.2 头部渐变 Banner 与筛选 chips

  /** 头部:渐变 Banner(本周漫游馆数+累计杯数)+ 筛选 chips 横滑 */
  @Builder
  headerMain() {
    Column({ space: 12 }) {
      // 顶部渐变 Banner:漫游馆数 + 杯数进度 + 到店打卡入口
      Column({ space: 10 }) {
        Row({ space: 12 }) {
          Column({ space: 2 }) {
            Text('7 家').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('本周 11 杯 · 手冲占 4 杯').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            }
            Row({ space: 6 }) {
              Text('🪑').fontSize(12)
              Text('最近馆子 300m · 余座 6 席').fontSize(11).fontColor(COLORS.sub)
            }
            Row({ space: 6 }) {
              Text('🌰').fontSize(12)
              Text('本周新豆 耶加雪菲 · 到货 3 天').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.accent)
          Text('🗺 地图找馆').fontSize(12).fontColor(COLORS.accent)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .borderRadius(16).backgroundColor(COLORS.chip)
            .onClick(() => { this.currentTab = 1; })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .padding(14)
      .borderRadius(14)
      .linearGradient({
        angle: 135,
        colors: [[COLORS.accentD, 0.0], [COLORS.card, 0.7]]
      })
      // 筛选 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.accent : 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 是整个应用的视觉门面,分上下两部分:渐变 Banner 和横滑筛选 chips。Banner 用 linearGradient 做了 135 度对角渐变,从 accentD(深焦糖 #8F5C22)渐变到 card(深咖 #2D2318),模拟咖啡从浓缩到稀释的色泽过渡。Banner 左侧大字号显示"7 家本周漫游馆数",右侧三行小字分别显示杯数进度、最近馆子距离与余座、本周新豆到货信息——把一个咖啡馆漫游用户最关心的四件事(去了几家、喝了几杯、最近哪家、有什么新豆)压缩在一张卡里,信息密度高但不显杂乱,靠的是 Column({ space: 6 }) 的等间距纵向排布和主次文本的字号灰阶区分。

Banner 下方是两个快捷入口胶囊:“到店打卡"用主色背景突出(白字焦糖金底),“地图找馆"用 chip 底色弱化(焦糖金字咖啡渣底),后者还绑了 onClick 跳转到地图 Tab。这种"一主一次"的视觉权重区分,引导用户优先点主操作。筛选 chips 用 Scroll 横滑 + ForEach 渲染 CATE_TAGS,每个 chip 的选中态靠三元判断 this.cateIdx === idx ? COLORS.bg : COLORS.sub 切换文字色、this.cateIdx === idx ? COLORS.accent : COLORS.chip 切换背景色,选中时是"深底金字”、未选时是"渣底燕麦色字”。scrollable(ScrollDirection.Horizontal) 让 Scroll 支持横向滑动,scrollBar(BarState.Off) 隐藏滚动条保持整洁。

5.3 咖啡 Tab:口碑馆、速览与收藏列表

  /** 咖啡 Tab:口碑馆 + 本周速览 + 收藏列表(业务主 Tab) */
  @Builder
  tabCafe() {
    Column({ space: 10 }) {
      // 区块标题行:更多入口
      Row() {
        Text('本周口碑馆').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Blank()
        Text('收藏新馆子 +').fontSize(11).fontColor(COLORS.accent)
          .onClick(() => { this.addModal = true; })
      }
      .width('100%')
      // 口碑馆大卡(3 条精选)
      ForEach(CAFE_RECS, (rec: CafeRec) => {
        Row({ space: 10 }) {
          Text(rec.icon).fontSize(26)
          Column({ space: 4 }) {
            Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text(`${rec.bean} · ${rec.price}`).fontSize(11).fontColor(COLORS.sub)
            Row({ space: 6 }) {
              Text(rec.dist).fontSize(10).fontColor(COLORS.text3)
              Text(`余座 ${rec.seats}`).fontSize(10).fontColor(seatsColor(rec.seats))
            }
          }
          .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.accent)
              .onClick(() => { this.currentTab = 1; })
          }
        }
        .padding(12)
        .borderRadius(12)
        .backgroundColor(COLORS.card)
        .width('100%')
      }, (rec: CafeRec) => rec.name)

咖啡 Tab 是业务主 Tab,结构分四块:口碑馆大卡、本周速览、收藏列表、豆票贴士。区块标题行用 Row + Blank() 实现"标题左对齐、操作右对齐"的经典布局——Blank() 是个弹性占位组件,它会吃掉中间所有剩余空间,把两侧内容推到两端。"收藏新馆子 +"用主色字 + onClick 打开 addModal 弹窗。

口碑馆大卡用 ForEach 渲染 CAFE_RECS(3 条精选),每张卡是 Row 横向布局:左侧大 emoji 图标、中间馆名+豆品+距离余座、右侧"去打卡"按钮。中间的 ColumnlayoutWeight(1) 吃掉剩余宽度,让右侧按钮自然靠右。余座数用 seatsColor(rec.seats) 函数映射颜色——14 座以上是宽敞的金色、8 座以上是适中的奶黄色、不足 8 座是紧张的警示红,用户扫一眼就能判断"这家现在去挤不挤"。"去打卡"按钮的 onClick 跳转到地图 Tab,把"看到口碑馆想去"到"去地图找位置"的链路打通。

      // 本周速览(横排三列小卡)
      Row({ space: 8 }) {
        ForEach(BREW_STATS, (stat: BrewStat) => {
          Column({ space: 4 }) {
            Text(stat.icon).fontSize(16)
            Text(stat.value).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.accent)
            Text(stat.label).fontSize(10).fontColor(COLORS.sub)
          }
          .layoutWeight(1)
          .padding({ top: 10, bottom: 10 })
          .borderRadius(12)
          .backgroundColor(COLORS.card)
        }, (stat: BrewStat) => stat.label)
      }
      .width('100%')
      // 全部咖啡馆列表(长按 Marker 的数据同源)
      Row() {
        Text('全部咖啡馆(地图 Marker 同源)').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      }
      .width('100%')
      ForEach(this.cafeList, (cafe: CafeItem, idx: number) => {
        Column({ space: 8 }) {
          Row({ space: 10 }) {
            Text(cafe.icon).fontSize(22)
            Column({ space: 3 }) {
              Text(cafe.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
              Text(`招牌豆 ${cafe.bean} · ${cafe.price}`).fontSize(11).fontColor(COLORS.sub)
              Text(`备注:${cafe.note}`).fontSize(10).fontColor(COLORS.text3)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column({ space: 6 }) {
              Text(`${cafe.seats}`).fontSize(16).fontWeight(FontWeight.Bold)
                .fontColor(seatsColor(cafe.seats))
              Text('个座位').fontSize(9).fontColor(COLORS.text3)
            }
          }
          .width('100%')
          Row({ space: 8 }) {
            Text('编辑').fontSize(10).fontColor(COLORS.info)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10).backgroundColor(COLORS.chip)
              .onClick(() => { this.openEditCafe(idx); })
            Text('删除').fontSize(10).fontColor(COLORS.danger)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10).backgroundColor(COLORS.chip)
              .onClick(() => { this.delIdx = idx; this.delModal = true; })
          }
          .justifyContent(FlexAlign.End)
          .width('100%')
        }
        .padding(12)
        .borderRadius(12)
        .backgroundColor(COLORS.card)
        .width('100%')
      }, (cafe: CafeItem) => cafe.name)

本周速览是三列横排小卡,用 Row + ForEach 渲染 BREW_STATS,每张小卡用 layoutWeight(1) 等分宽度,展示图标、数值(主色加粗)、标签三行。收藏列表是咖啡 Tab 的主体,用 ForEach 遍历 this.cafeList@State 数组,支持增删改刷新)。每张收藏卡分上下两部分:上半是馆子信息(图标、馆名、豆品价、备注、座位数),下半是"编辑"和"删除"两个操作胶囊。"编辑"用 COLORS.info 湖蓝字表示次级操作,onClickopenEditCafe(idx) 打开编辑弹窗;"删除"用 COLORS.danger 警示红字,onClick 先存 delIdx 再开 delModal 确认弹窗——删除操作永远要二次确认,这是防误删的铁律。两行操作用 justifyContent(FlexAlign.End) 靠右对齐,符合"操作按钮在右"的习惯。

5.4 地图 Tab:MapComponent 与长按事件日志流

  /** 地图 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.accent)
            .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.accent)
            .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)

地图 Tab 是 6.1.1 长按事件特性的展示页。顶部特性说明卡告诉用户这个 Tab 在做什么——“长按地图上的馆子 Marker 或 POI 地点,事件将记录到下方日志流”,这种"特性说明卡"在搜索 Tab 也有,是让用户理解新能力的好习惯。监听开关行有两个 Toggle 开关,分别绑定 markerListenOnpoiListenOn 两个 @StateselectedColor(COLORS.accent) 把开关的激活色设为焦糖金,与主题统一。onChange 调用对应的 toggleMarkerListentogglePoiListen 方法,实现"开关—注册/注销"的双向绑定。

MapComponent 是 Map Kit 的核心组件,接收两个参数:mapOptions(初始化参数,含中心点和缩放级别)和 mapCallback(初始化回调,在 aboutToAppear 中装配)。layoutWeight(1) 让地图占满中间剩余高度——这是地图 Tab 能正常显示的关键,MapComponent 必须有确定的高度才能渲染,layoutWeight(1)Column 中会吃掉所有剩余空间,保证地图有足够的高度。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.accent : COLORS.second)
                    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%')
  }

日志流是长按事件的可视化出口。标题行显示"长按事件日志流"和总条数,Blank() 把条数推到右侧。日志列表用 Scroll 包裹 ForEachheight(120) 固定高度让日志流成为"可滚动的小窗",不会因为事件越积越多把地图挤没。每条日志是一个 Row:左侧用 emoji 区分类型(📍 Marker、🏷 POI),右侧 Column 显示类型标签、名称/ID、时间,以及经纬度。类型标签的颜色也按类型区分——Marker 事件用主色焦糖金、POI 事件用副色奶泡米黄,让用户扫一眼就能区分"这条是我加的标注触发的"还是"地图自带 POI 触发的"。经纬度用 fontFamily('monospace') 等宽字体显示,toFixed(4) 保留 4 位小数,等宽字体让数字对齐,读起来像终端日志,强化"这是事件流"的语义。ForEach 的第三个参数是键值生成器 ${log.type}-${log.name}-${log.time},保证每条日志有唯一 key,框架 diff 时能正确识别新增/删除。

5.5 搜索 Tab: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.accent)
          .onClick(() => { this.runSearch(); })
      }
      .width('100%')
      // 搜索状态文案
      Text(this.searchState).fontSize(10).fontColor(COLORS.text3).width('100%')

搜索 Tab 是 6.1.1 reliability 特性的展示页。顶部特性说明卡点明这个 Tab 在做什么——“Site 新增 reliability 字段([0,1],1 为完全相关)”,让用户理解分数的含义。搜索框用 TextInput + Button 横排,TextInputlayoutWeight(1) 吃掉剩余宽度,onChange 把输入值同步到 queryInput 状态。搜索按钮 onClickrunSearch() 触发异步搜索。Text(this.searchState) 显示搜索状态文案——“待搜索 · 演示数据”“搜索中…”“返回 N 条结果”“搜索失败(code) · 保留演示数据”,这四种状态覆盖了搜索的全生命周期,让用户始终知道"现在在干什么、结果如何"。

      // 搜索结果列表(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.chip)
              }
              .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%')
  }

搜索结果列表是 reliability 特性的视觉核心。用 List 而非 Scroll + Column 是因为列表项的复用性更好——List 内部的 ListItem 在数据多时会自动复用,性能优于 ForEach 全量渲染。每条结果是一个 ListItem 包裹的 Column,分四层:第一层是馆名 + 等级标签,馆名用 maxLines(1) + textOverflow(Ellipsis) 单行省略号,等级标签用 reliabilityScore(rec.reliability) 函数拿到 labelcolor,动态渲染"高相关/中相关/低相关"三种文案和对应颜色;第二层是地址,同样单行省略;第三层是核心的分数条——Progress 组件 type: ProgressType.Linear 线性进度条,valuerec.reliability * 100(把 0~1 映射到 0~100),color 用等级颜色,旁边配 reliability 0.97 这样的等宽字体数值文本;第四层是距离和时间。

分数条的设计是整个搜索 Tab 的点睛之笔。在 6.1.1 之前,搜索结果只能靠名称和地址让用户自行判断相关性,用户看到"湖滨银泰手冲馆"和"咖啡豆烘焙工厂店(堂食位少)“两条结果时,并不知道后者其实和"咖啡馆"关键字弱相关。有了分数条,0.97 的金色满条和 0.14 的红色短条形成强烈视觉对比,用户一眼就能筛选掉低分结果,搜索效率显著提升。codePreviewCard 在列表底部,用深咖啡黑底 + 等宽字体展示四行关键调用代码,让用户在 UI 上直接看到"这背后调了什么 API”,起到"特性自证"的作用。

5.6 我的 Tab:会员卡、权益与功能清单

  /** 我的 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('集章 28 家馆 · 每 10 杯手冲免费 1 杯').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('消耗豆票 6 张').fontSize(11).fontColor(COLORS.accent)
        }
        .width('100%')
      }
      .padding(14)
      .borderRadius(14)
      .linearGradient({
        angle: 135,
        colors: [[COLORS.accentD, 0.0], [COLORS.card, 0.75]]
      })
      .width('100%')
      // 会员权益卡(三条权益说明行)
      Column({ space: 8 }) {
        Text('🎟 银豆漫游权益').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Row({ space: 8 }) {
          Text('·').fontSize(12).fontColor(COLORS.accent)
          Text('每周四豆单更新,凭漫游卡可优先试饮新款豆').fontSize(11).fontColor(COLORS.sub)
        }
        .width('100%')
        Row({ space: 8 }) {
          Text('·').fontSize(12).fontColor(COLORS.accent)
          Text('合作烘焙坊豆子 92 折,现磨挂耳全国包邮').fontSize(11).fontColor(COLORS.sub)
        }
        .width('100%')
        Row({ space: 8 }) {
          Text('·').fontSize(12).fontColor(COLORS.accent)
          Text('生日当月赠手冲入门课堂体验课一节').fontSize(11).fontColor(COLORS.sub)
        }
        .width('100%')
      }
      .padding(12)
      .borderRadius(12)
      .backgroundColor(COLORS.card)
      .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)
    }
    .width('100%')
  }

我的 Tab 是用户中心,分会员卡、权益卡、功能清单三块。会员卡用与头部 Banner 相同的 135 度渐变(accentDcard),保持视觉语言统一,顶部是大 emoji + 会员名 + 集章进度,中间 Divider 分割,底部是"本月打卡 9 家"和"消耗豆票 6 张"的左右对齐数据行。权益卡列出三条银豆会员权益,每条用主色 · 项目符号 + 副色说明文案,让权益一目了然。功能清单用 ForEach 渲染 FUNC_LIST(8 条),每条是"图标 + 功能名 + 状态值 + 箭头"的典型设置项布局,layoutWeight(1) 让功能名吃掉中间空间,状态值和箭头自然靠右。这种布局在所有"我的"类页面都是通用的,用户一看就懂。

5.7 底部 Tab 栏与代码预览卡

  /** 双特性代码预览卡(深咖啡底 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 result = await site.searchByText(params)')
          .fontSize(9).fontColor(COLORS.accent).fontFamily('monospace')
        Text('const score = site.reliability  // 馆子相关性')
          .fontSize(9).fontColor(COLORS.accent).fontFamily('monospace')
        Text('eventManager.onMarkerLongClick(cb)  // 24+')
          .fontSize(9).fontColor(COLORS.second).fontFamily('monospace')
        Text('eventManager.onPoiLongClick(cb)     // 24+')
          .fontSize(9).fontColor(COLORS.second).fontFamily('monospace')
      }
      .padding(10)
      .borderRadius(8)
      .backgroundColor(COLORS.codeBg)
      .width('100%')
    }
    .padding(10)
    .borderRadius(10)
    .backgroundColor(COLORS.chip)
    .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)
  }

codePreviewCard 是搜索 Tab 底部的代码自证卡,用 codeBg#1C1209 深咖啡黑)做底,模拟终端代码块。四行代码分两组着色:前两行(searchByTextreliability)用主色焦糖金,对应搜索特性;后两行(onMarkerLongClickonPoiLongClick)用副色奶泡米黄,对应长按事件特性。fontFamily('monospace') 等宽字体让代码对齐,// 24+ 注释表示这是 6.1.1(API 24+)新增。这张卡的作用是"特性透明化"——让用户在 UI 上直接看到背后的调用,既是文档也是验证。

tabBar 是底部导航,用 Row + ForEach 渲染 TAB_LIST,每个 Tab 用 layoutWeight(1) 等分宽度,Column 纵向排布图标和标签。选中态靠 this.currentTab === idx 三元判断:选中时标签用 tabOn(焦糖金),未选用 text3(浅烘焙棕弱色)。onClickcurrentTab 触发主内容区切换。整个 Tab 栏用 card 深咖底,与页面底色 bg 形成层次。

5.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.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.accent)
            .onClick(() => { this.saveCafe(); })
        }
        .width('100%')
      }
      .padding(16)
      .borderRadius(14)
      .backgroundColor(COLORS.card)
      .width('82%')
    }
    .width('100%')
    .height('100%')
  }

弹窗系统由一个共享的 modalOverlay 遮罩和三个业务面板组成。modalOverlay 是个全屏 Column,背景色 maskrgba(18,12,6,0.68) 半透明深棕),onClickonClose——点击遮罩空白处关闭弹窗,这是模态弹窗的标准交互。panelAddStack 把遮罩和内容卡叠在一起,内容卡 width('82%') 居中显示(Stack 默认居中),card 深咖底 + borderRadius(14) 圆角。卡内是标题 + 两个 TextInput(馆名、地址)+ 取消/收藏两个按钮。两个按钮都用 layoutWeight(1) 等分宽度,取消用 chip 底 + sub 字(次级操作),收藏用 accent 主色底(主操作)。saveCafe() 调用前已做空名兜底。

panelEditpanelDel 结构类似:panelEdit 回填当前馆子备注到 TextInput,保存调 updateCafe()panelDel 显示馆名确认文案,删除调 delCafe()danger 警示色按钮。三个面板都接收 onClose 回调,让外部 build 方法控制弹窗的显隐开关,弹窗内部只管"取消就关、操作就执行对应方法",职责分离清晰。

六、特性对比与总结

6.1 6.1.1 双新特性对比

特性维度 site.searchByText 的 reliability 字段 MapEventManager 长按监听
所属模块 site 地点搜索模块 map.MapEventManager 事件管理器
能力定位 搜索结果质量评估 地图手势交互扩展
数据形态 浮点数 [0,1],可选字段 回调函数,Marker/POI 两类
新增 API Site.reliability(字段) onMarkerLongClick / offMarkerLongClick / onPoiLongClick / offPoiLongClick(四方法)
典型用途 结果筛选、分级展示、弱相关过滤 长按收藏、长按记录、长按弹菜单
空值处理 ?? 0 兜底为 0 分 注册前判断 mapEventManager 非空
触发时机 searchByText 返回后读取 用户长按地图 Marker/POI 时回调
UI 呈现 线性分数条 + 等级标签 + 颜色 日志流 unshift 置顶 + 类型图标
兜底策略 Mock 数据覆盖高/中/低三档分数 Mock 日志预置 2 条演示事件
容错设计 catch BusinessError 保留旧数据 off 不传参清除全部订阅
行业价值 解决"咖啡馆搜索结果鱼龙混杂" 解决"地图上的馆子怎么快速记"

6.2 与旧版能力对比

对比项 6.1.1 之前 6.1.1 之后
搜索结果可信度 无法判断,照单全收 reliability 分数量化关联程度
结果 UI 区分 仅名称地址,无视觉分级 分数条 + 高/中/低三色标签
地图手势维度 仅点击(onMarkerClick 点击 + 长按(Marker/POI 双通道)
长按交互 不支持,需自定义手势 原生 onMarkerLongClick / onPoiLongClick
监听注销 offMarkerClick 新增 offMarkerLongClick / offPoiLongClick
POI 交互 仅点击 POI 长按 POI 拿名称 + 经纬度
业务表达力 “找得到但不知道靠不靠谱” “找得到且知道多靠谱 + 能长按操作”

6.3 总结

本文以"拾豆·独立咖啡馆漫游"这一精品咖啡门店探索应用为载体,完整拆解了 HarmonyOS 6.1.1 Map Kit 的两项新特性如何在 ArkUI 页面中落地。第一项特性是 site.searchByText 返回的 Site 类型新增 reliability 相关性分数字段,取值 [0,1],我们把它读取后用 reliabilityScore 函数映射为"高/中/低"三档等级标签和对应颜色,再用 Progress 线性进度条把分数可视化,让用户在搜索结果列表中一眼就能区分"这家馆子和’咖啡馆’关键字强相关"与"那家只是挂了个咖啡名的烘焙工厂店"。整个读取链路做了三层兜底:result.sites ?? [] 兜底空数组、s.reliability ?? 0 兜底空字段、catch 保留 Mock 数据兜底接口失败,保证演示链路在任何异常下都不中断。

第二项特性是 MapEventManager 新增的 onMarkerLongClick / offMarkerLongClickonPoiLongClick / offPoiLongClick 两对长按监听方法。我们在地图初始化回调(mapCallbackerr 为空分支)中,先获取 mapControllermapEventManager,再批量 addMarker 添加 6 家咖啡馆标注,最后注册两类长按监听——Marker 长按拿 marker.getId()getPosition(),POI 长按拿 poi.namepoi.position,统一塞进 EventLog 日志流并 unshift 置顶显示。两个 Toggle 开关分别绑定 toggleMarkerListentogglePoiListen 方法,演示了 off 不传参清除全部订阅的注销语义。

从架构层面看,整个页面采用"颜色常量集中管理 → @Observed 数据模型分类承载 → 纯函数抽离映射逻辑 → @State 状态驱动视图 → @Builder 按 Tab/弹窗职责拆分"的五段式组织。颜色系统用 ColorPalette 接口 + COLORS 常量保证深色主题(烘焙棕 + 奶泡米 + 焦糖金)的可维护性;三个 @Observed 类(CafeItemSearchRecordEventLog)分别对应收藏、搜索、事件三类业务数据;reliabilityScoreseatsColor 两个纯函数把"数值→颜色"的映射从 Builder 中抽离,保证 UI 代码的整洁;四个 Tab 用 if-else 条件渲染而非 Tabs 组件,照顾了地图 Tab 需要固定高度、搜索 Tab 需要 List 复用、咖啡 Tab 是纯卡片流的异构布局需求;弹窗系统用 Stack + 共享 modalOverlay 遮罩 + 三个业务面板的统一模式,onClose 回调让显隐控制权归调用方。

从行业落地价值看,这两项特性恰好覆盖了咖啡馆漫游类应用的核心痛点。reliability 解决的是"搜索结果该不该信"——精品咖啡行业门店命名混乱、业态交叉(手冲馆、烘焙坊、宠物咖、深夜馆、茶咖融合店),用户搜"咖啡馆"时返回的结果质量参差不齐,有了分数就能在 UI 上做分级展示和弱相关过滤,把"信息罗列"升级为"信息筛选"。长按监听解决的是"地图上的点该怎么用"——用户在地图上看到密集 Marker 时,点击只能看基本信息,长按则能触发收藏、记录、弹菜单等深度操作,把地图从"只读展示"推向"可对话的探索画布"。两者一前一后,恰好覆盖"找馆子—看馆子—记馆子"的完整探索链路。

最后从工程实践角度,这套代码在容错和可维护性上做了充分考量:mapOptions 显式赋默认值避免 undefined 传入 MapComponentaddMarker 逐个 try-catch 保证单个失败不中断批量、searchByTextcatch 保留旧数据而非清空列表、off 不传参的批量注销避免回调引用丢失、@Observed + slice() 的数组引用替换保证属性修改能触发刷新、ForEach 的 key 生成器保证 diff 正确性。这些细节单独看都不复杂,但叠加起来构成了一个在异常环境下依然稳定运行的健壮页面,这也是把新特性从"能跑通的 Demo"推向"可交付的方案"的关键所在。

附录: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 将自动执行以下操作:

  1. 生成项目骨架(Stage 模型目录结构)
  2. 执行 ohpm install 安装依赖
  3. 运行 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 版本编写,不同版本界面可能存在细微差异。

Logo

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

更多推荐