一、技术前言

在这里插入图片描述

HarmonyOS 的 ArkUI 框架是华为为全场景多设备开发提供的声明式 UI 框架,其核心语言 ArkTS 在 TypeScript 基础上做了静态类型增强和运行时优化,使开发者可以以一种更接近自然思维的方式来描述界面结构与状态之间的映射关系。ArkUI 的组件化思想十分彻底,每一个页面都是一个 struct 组件,通过 @Entry 注解标记为入口,通过 @Component 声明为可复用组件,再配合 @State、@Prop、@Link、@Observed 等装饰器实现从组件内局部状态到跨组件共享状态的完整响应式体系。这种架构使得在构建复杂业务场景——例如需要同时展示实时油价、地图标注、搜索结果、用户中心的综合性应用——时,仍能保持代码结构清晰、状态变更可追溯、界面刷新精准。

在这里插入图片描述
Map Kit 是 HarmonyOS 官方地图能力套件,其 MapComponent 组件可以在 ArkUI 页面中像普通 UI 组件一样被声明和使用,但底层承载的是完整的地图渲染引擎。开发者通过 mapCommon.MapOptions 配置地图初始位置与缩放层级,通过 AsyncCallback 回调拿到 MapComponentController 控制器实例,进而执行添加标注(addMarker)、移动镜头(moveCamera)、设置地图样式等操作。更关键的是,MapComponentController 暴露了 getEventManager 方法,返回一个 MapEventManager 实例,它承担了所有地图交互事件的订阅与分发职责,包括标记点击、POI 点击、镜头移动等,而在 HarmonyOS 6.1.1 版本中,这套事件体系迎来了两项重要的长按能力扩展。

在这里插入图片描述
在 HarmonyOS 6.1.1 中,MapEventManager 新增了 onMarkerLongClick 与 offMarkerLongClick 两个方法,用于监听和取消监听地图上 Marker 标记的长按手势。回调参数是一个 map.Marker 对象,通过它可以拿到标记的 id 和经纬度位置,开发者据此可以实现长按标记弹出详情、触发收藏、记录轨迹等交互。与之配套的还有 onPoiLongClick 与 offPoiLongClick,用于监听地图上 POI(Point of Interest,兴趣点)的长按。POI 是地图引擎内置的兴趣点数据,与开发者手动添加的 Marker 不同,POI 由地图服务端提供,涵盖了城市中的各类地点。长按 POI 时回调参数是 mapCommon.Poi 对象,包含 POI 名称和位置坐标。这两组方法的 API 形态与已有的 onClick 系列保持一致,学习成本低,但语义上明确区分了"短按"和"长按"两种用户意图,为更精细的地图交互打开了空间。

在这里插入图片描述
在搜索能力侧,site 模块提供了 searchByText 方法,开发者传入关键字、中心点坐标、搜索半径和语言,即可获得一个 SearchByTextResult,其中包含一个 Site 数组。每个 Site 对象携带 name(地点名称)、formatAddress(格式化地址)、distance(与中心点的直线距离)等字段。在 HarmonyOS 6.1.1 中,Site 类型新增了 reliability 字段——这是一个取值范围为 [0,1] 的浮点数,1 表示搜索结果与关键字完全相关,0 表示完全不相关。reliability 的引入解决了过去搜索结果排序不透明、低相关结果混入列表导致用户困惑的问题,开发者可以据此做结果过滤、排序、可视化展示,让用户一眼判断哪些结果是真正想要的。需要注意的是 reliability 是可选字段,调用时建议使用空值合并运算符(??)做兜底,避免运行时出现 undefined。

在这里插入图片描述
将上述两大新特性放到"汽车能源补给服务"这一行业场景中,其价值尤为突出。加油站类应用的用户痛点在于:城市中加油站密度高、品牌多、油号差异大,用户需要在地图上快速锁定最近的、最便宜的最少排队的油站;同时搜索"加油站"时往往会混入"加油卡办理点""加油设备维修中心"等非油站结果,这些结果对用户毫无价值甚至造成误导。借助 reliability 字段,应用可以以分数条和等级标签直观展示每条结果的相关程度,帮助用户在结果列表中快速甄别;借助长按 Marker 和长按 POI 事件,应用可以让用户通过长按地图上的油站标注来触发收藏、备注、记录坐标等深度操作,而短按仍可用于常规的查看或导航,形成一套清晰的"短按看、长按管"的交互范式。

在这里插入图片描述
本文将以一个名为"油站通·加油站实时油价"的深色主题应用为载体,完整拆解从颜色系统、数据模型、地图初始化、双长按监听注册、searchByText 调用到 reliability 可视化、四 Tab 布局、弹窗 CRUD 的全部实现细节,力求让读者理解每一行代码背后的设计意图与技术要点,并能在自己的项目中复用这套 Map Kit 6.1.1 新特性的工程实践。

在这里插入图片描述

二、整体架构流程图

渲染错误: Mermaid 渲染失败: Parse error on line 24: ... M --> T{用户输入关键字点击搜索] T --> U[runSea -----------------------^ Expecting 'DIAMOND_STOP', 'TAGEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'SQE'

三、颜色系统与常量定义

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

interface ColorPalette {
  bg: string;      // 页面沥青黑底色
  card: string;    // 卡片深棕黑底色
  chip: string;    // 胶囊与输入框底色
  title: string;   // 主标题暖白
  sub: string;     // 副文本沙棕
  text3: string;   // 弱文本暗棕
  orange: string;  // 燃油橙主色
  orangeD: string; // 深燃油橙(渐变深端)
  orangeL: string; // 浅燃油橙点缀色
  silver: string;  // 金属银辅助色
  green: string;   // 排队畅通绿
  red: string;     // 排队拥堵/删除警示红
  line: string;    // 分隔线暗棕
  tabOn: string;   // 底部 Tab 激活色
  mask: string;    // 弹窗遮罩色
  codeBg: string;  // 代码预览卡深底色
}

const COLORS: ColorPalette = {
  bg: '#14110C',
  card: '#1E1A13',
  chip: '#2A241A',
  title: '#F4EFE6',
  sub: '#C2B49E',
  text3: '#83745E',
  orange: '#FF7A1A',
  orangeD: '#C2550A',
  orangeL: '#FFD9B8',
  silver: '#C6CBD4',
  green: '#4FBF6E',
  red: '#FF6B5E',
  line: '#33291B',
  tabOn: '#FF7A1A',
  mask: 'rgba(8,6,3,0.7)',
  codeBg: '#0E0B06'
};

这段代码定义了整个应用的颜色中枢。首先声明了一个 ColorPalette 接口,将所有页面用到的颜色字段集中约束为类型安全契约——这意味着任何一处颜色引用都会获得编译期类型检查,拼错字段名会在编译时报错而非运行时静默失败。接口注释详细标注了每个色板的语义用途,从页面背景色到弹窗遮罩色,覆盖了深色主题应用所需的全部色彩维度。

COLORS 常量是该接口的具体实现,采用沥青黑(#14110C)作为页面底色,这是该应用视觉身份的根基——它不是纯黑,而是一种带有极微暖调的深棕黑,模拟夜间加油站沥青路面的质感,长时间观看不易造成视觉疲劳。燃油橙(#FF7A1A)作为主强调色,用于按钮、激活态 Tab、油价强调等高优先级元素,呼应加油站灯光的暖橙色调。金属银(#C6CBD4)作为辅助色,用于中等等级标签、平价油价标识等中性元素,模拟加油站设备的金属质感。

值得注意的设计细节有几处:orangeD(深燃油橙 #C2550A)专门用于渐变深端,与主橙色搭配在 linearGradient 中营造从深到浅的立体过渡;mask 使用了 rgba 半透明黑色而非纯色,确保弹窗弹出时背景内容仍可隐约可见,符合深色主题应用中常见的"深度感"设计语言;green 和 red 不是标准的纯绿纯红,而是经过调和的 #4FBF6E 和 #FF6B5E,它们与暖色调底色搭配时不会显得突兀,分别用于排队畅通(少于5分钟)和排队拥堵(15分钟以上)的语义标识。text3(#83745E)作为弱文本色,用于时间戳、辅助说明等低优先级信息,与 bg 底色的对比度经过调校,保证可读性的同时不喧宾夺主。

3.2 Tab 元数据与业务常量

interface TabMeta {
  icon: string;   // Tab 图标 emoji
  label: string;  // Tab 标签文案
}

const TAB_LIST: TabMeta[] = [
  { icon: '⛽', label: '油站' },
  { icon: '🗺', label: '地图' },
  { icon: '🔍', label: '搜索' },
  { icon: '👤', label: '我的' }
];

const CATE_TAGS: string[] = ['全部', '中石化', '中石油', '民营站', '92号', '95号', '98号', '附洗车'];

const CITY_CENTER: mapCommon.LatLng = { latitude: 23.1291, longitude: 113.2644 };

TAB_LIST 定义了底部导航的四个 Tab,每个 Tab 由 emoji 图标和中文标签组成。这里使用 emoji 而非图标字体或图片资源,是出于演示场景的简洁性考虑——emoji 内置于系统字体,无需额外资源加载,且深色背景下燃油泵、地图、放大镜、人形图标自带色彩辨识度。在实际生产应用中,可替换为 SVG 或 iconfont 以获得更精确的视觉控制和品牌一致性。

CATE_TAGS 是头部横滑筛选条的数据源,涵盖了油站品牌(中石化/中石油/民营站)、油号(92/95/98号)和附加服务(附洗车)三个维度的筛选标签。这种"品牌+油号+服务"的多维筛选是加油站场景的特色——不同于餐饮或商超场景的单维度分类,加油用户往往同时关心品牌(不同品牌油价差异可达0.3元/升)、油号(车辆适用的油号固定)和增值服务(洗车优惠)。数组化设计的好处是筛选条数量可动态调整,未来增加"柴油""充电桩"等标签只需扩展数组。

CITY_CENTER 定义了地图初始化的中心点坐标,这里选取广州越秀公园周边(纬度23.1291,经度113.2644),既是广东省会核心商圈,也是加油站密度较高的典型区域。该常量同时服务于两个用途:作为 MapOptions 的 position.target 初始化地图视角,以及作为 site.searchByText 的 location 参数定义搜索的中心点。将城市中心坐标集中为常量,便于未来切换城市或支持多城市时统一修改。

3.3 加油站标注点与推荐数据

interface SpotItem {
  name: string;   // 油站名称
  lat: number;    // 纬度
  lng: number;    // 经度
  tag: string;    // 油站类型标签
}

const MARKER_SPOTS: SpotItem[] = [
  { name: '油站通·体育西路中石化站', lat: 23.1321, lng: 113.2812, tag: '中石化' },
  { name: '油站通·中山五路中石油站', lat: 23.1251, lng: 113.2620, tag: '中石油' },
  { name: '油站通·工业大道北民营站', lat: 23.1108, lng: 113.2520, tag: '民营低价' },
  { name: '油站通·环市东路加油站', lat: 23.1450, lng: 113.2780, tag: '24小时' },
  { name: '油站通·康王中路加油站', lat: 23.1189, lng: 113.2460, tag: '附洗车' },
  { name: '油站通·科韵路加油站', lat: 23.1252, lng: 113.2840, tag: '近高速' }
];

SpotItem 接口定义了地图标注点的数据结构,包含名称、经纬度和类型标签三个字段。MARKER_SPOTS 数组提供了6个模拟加油站坐标,围绕 CITY_CENTER 中心点在 ±0.02 度(约2.2公里)范围内散布,形成一个城市中心区域的加油站群落。每个站点的 tag 字段携带品牌或服务特征,如"中石化"“民营低价”“24小时”“附洗车”"近高速"等,这些标签在后续的油站列表中会复用,实现地图标注与列表数据的"同源"语义。

经纬度数据的设计意图在于:6个点位的经纬度跨度合理,既保证在初始缩放级别13下可见,又保证彼此不重叠遮挡。纬度范围 23.1108~23.1450 跨度约0.034度(约3.8公里),经度范围 113.2460~113.2840 跨度约0.038度(约3.9公里,受纬度影响略有缩放),与5公里搜索半径的尺度相匹配。实际项目中这些数据应由后端接口返回,这里使用 Mock 数据是为了演示前端链路完整性,不依赖网络与后端。

3.4 推荐油站与功能清单数据

interface StationRec {
  icon: string;   // 油站 emoji
  name: string;   // 油站名
  dist: string;   // 距离文本
  p92: number;    // 92 号油价(元/L)
  p95: number;    // 95 号油价(元/L)
  queue: number;  // 排队分钟数
}

const STATION_RECS: StationRec[] = [
  { icon: '⛽', name: '体育西路中石化站', dist: '650m', p92: 7.12, p95: 7.58, queue: 4 },
  { icon: '🛢', name: '工业大道北民营站', dist: '1.4km', p92: 6.86, p95: 7.31, queue: 2 },
  { icon: '⛽', name: '中山五路中石油站', dist: '1.8km', p92: 7.08, p95: 7.52, queue: 12 }
];

interface FuncItem {
  icon: string;   // 功能图标
  label: string;  // 功能名
  value: string;  // 状态/数值文本
}

const FUNC_LIST: FuncItem[] = [
  { icon: '⛽', label: '本月加油', value: '4 次 · 152L' },
  { icon: '🎁', label: '积分商城', value: '2140 分可兑' },
  { icon: '💳', label: '油卡余额', value: '320.00 元' },
  { icon: '⭐', label: '收藏油站', value: '6 座' },
  { icon: '🧾', label: '发票管理', value: '本月 4 张' },
  { icon: '📈', label: '加油记录', value: '共 386 次' },
  { icon: '🔔', label: '油价涨跌提醒', value: '已开启' },
  { icon: '⚙', label: '偏好设置', value: '92号优先' }
];

STATION_RECS 是油站 Tab 头部精选大卡的数据源,3条数据各代表一种典型场景:第1条是距离最近且排队较短的中石化品牌站(650m,排队4分钟),第2条是民营站中价格最低的代表(6.86元/L,低于7元大关),第3条是排队较长的中石油品牌站(12分钟,提示用户避开高峰)。p92 和 p95 字段分别代表92号和95号油价,这两个油号覆盖了中国绝大多数家用车的燃油需求。

FUNC_LIST 是"我的"Tab 的功能清单,8条功能覆盖了加油用户的完整生命周期:加油统计(本月加油)、积分激励(积分商城)、支付(油卡余额)、收藏管理(收藏油站)、发票(发票管理)、历史记录(加油记录)、价格提醒(油价涨跌提醒)、个性化(偏好设置)。每条都采用 icon + label + value 的三段式结构,左侧 emoji 图标做视觉识别,中间功能名,右侧状态值,是移动端设置/功能列表的经典布局。这种结构化的功能清单设计便于后续扩展——新增功能只需往数组追加一项,ForEach 会自动渲染。

四、辅助函数与数据模型

4.1 reliability 相关性等级映射函数

interface ScoreLevel {
  label: string;  // 等级文案
  color: string;  // 等级颜色
}

function reliabilityScore(score: number): ScoreLevel {
  if (score >= 0.8) {
    return { label: '高相关', color: COLORS.orange };
  }
  if (score >= 0.5) {
    return { label: '中相关', color: COLORS.silver };
  }
  return { label: '低相关', color: COLORS.red };
}

reliabilityScore 是整个应用中直接服务于 Map Kit 6.1.1 reliability 新特性的核心辅助函数。它接收一个 [0,1] 范围的浮点数,返回一个包含等级文案和颜色值的 ScoreLevel 对象。这个映射逻辑的设计体现了对搜索结果相关性的三段式分级思想:大于等于0.8判定为"高相关",用燃油橙强调,表示该结果与关键字几乎完全匹配,是用户最想看到的;大于等于0.5判定为"中相关",用金属银中性标识,表示结果与关键字有一定关联但并非完全匹配,用户可酌情参考;低于0.5判定为"低相关",用警示红标记,提示用户该结果可能并非想要的油站。

阈值0.8和0.5的选择并非随意。在实际 searchByText 返回的 Site 数组中,reliability 通常呈偏态分布——真正匹配的油站可靠性普遍在0.8以上,而"加油卡办理点""维修中心"等擦边结果的可靠性往往低于0.3。将"高相关"阈值设在0.8可以精准筛出真正的油站,将"中相关"阈值设在0.5则保留了部分可能有用但匹配度一般的结果(如带"加油站"字样的复合地点)。这种分级比单纯的二值过滤(“保留/丢弃”)更符合用户认知,让用户自行决定是否参考中等相关结果。

函数返回的是 ScoreLevel 对象而非字符串,这样调用方可以同时获取文案和颜色,避免了"先取文案再switch取颜色"的冗余代码。在搜索 Tab 中,分数条旁的等级标签和分数条颜色都来自这个函数的返回值,保证了文案与颜色的一致性。使用 interface ScoreLevel 而非内联对象类型,也使该函数的契约更清晰,未来若需扩展(如增加图标字段)只需修改接口定义。

4.2 排队与油价颜色映射函数

function queueColor(mins: number): string {
  if (mins < 5) {
    return COLORS.green;
  }
  if (mins < 15) {
    return COLORS.silver;
  }
  return COLORS.red;
}

function fuelColor(p92: number): string {
  if (p92 <= 6.90) {
    return COLORS.green;
  }
  if (p92 <= 7.15) {
    return COLORS.silver;
  }
  return COLORS.red;
}

queueColor 函数将排队分钟数映射为颜色:少于5分钟为绿色(畅通),5到14分钟为银色(一般),15分钟及以上为红色(拥堵)。这个阈值设计基于加油场景的实际体验——5分钟内的排队几乎无需等待,用户可以直接前往;5到15分钟是可接受范围但需要权衡;超过15分钟则明显影响出行计划,应提示用户考虑其他站点。颜色三态(绿/银/红)与红绿灯语义一致,用户无需阅读文字即可感知排队状态。

fuelColor 函数将92号油价映射为颜色:低于或等于6.90元为绿色(低价),6.91到7.15元为银色(平价),高于7.15元为红色(高价)。这个阈值反映了当前国内92号汽油的常见价格区间——民营站价格常低于7元,品牌站价格多在7元上下波动,高价站往往超过7.15元。通过颜色编码油价,用户可以在油站列表中快速扫描出最便宜的选项,符合加油用户"省钱优先"的核心诉求。

两个函数的设计模式一致:早返回(early return)的三段判断,简洁且无副作用。它们被设计为纯函数,输入相同输出相同,不依赖任何外部状态,便于单元测试和复用。在油站 Tab 的列表渲染中,queueColor 用于排队分钟数的颜色,fuelColor 用于92号油价胶囊的颜色,使得每条油站卡片都能在视觉上传达排队与价格的双维度信息。

4.3 @Observed 数据模型类

@Observed export class StationItem {
  icon: string;
  name: string;
  p92: number;
  p95: number;
  queue: number;
  note: string;

  constructor(icon: string, name: string, p92: number,
    p95: number, queue: number, note: string) {
    this.icon = icon;
    this.name = name;
    this.p92 = p92;
    this.p95 = p95;
    this.queue = queue;
    this.note = note;
  }
}

@Observed export class SearchRecord {
  name: string;
  address: string;
  distance: number;
  reliability: number;
  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;
  }
}

@Observed export class EventLog {
  type: string;
  name: string;
  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;
  }
}

这里定义了三个 @Observed 装饰的类,分别承载油站条目、搜索结果、长按事件日志三种核心业务数据。@Observed 是 ArkUI 状态管理体系的装饰器,它使类的实例成为"可观察对象"——当实例的属性被修改时,绑定该属性的 UI 组件会自动刷新。这与 @State 的局部状态不同,@Observed 通常配合 @State 或 @ObjectLink 实现跨组件的响应式数据流。

StationItem 是油站列表的数据载体,包含图标、名称、92号油价、95号油价、排队分钟数、备注六个字段。备注字段是可编辑的,用户在编辑弹窗中修改备注后,由于类被 @Observed 标记,绑定的列表项会自动刷新备注显示。这种设计避免了手动调用刷新方法的繁琐,是 ArkUI 响应式编程的典型体现。

SearchRecord 是 searchByText 搜索结果的数据载体,最核心的字段是 reliability——它直接对应 Map Kit 6.1.1 Site 类型新增的 reliability 字段。在搜索 Tab 中,每个 SearchRecord 实例会被渲染为一条带分数条和等级标签的结果卡,reliability 值决定了分数条的长度(value: rec.reliability * 100)和颜色(reliabilityScore 返回的 color)。distance 字段保留了与中心点的直线距离(米),在UI中以公里为单位展示(rec.distance / 1000),方便用户感知远近。

EventLog 是长按事件日志的数据载体,type 字段区分"Marker"(手动添加的标注长按)和"POI"(地图内置兴趣点长按)两种事件类型,name 字段对 Marker 记录其id(如"#0"),对 POI 记录其名称(如"广州塔"),lat 和 lng 记录事件发生时的经纬度坐标,time 是时间文案。该类的实例在 onMarkerLongClick 和 onPoiLongClick 回调中被创建并 unshift 到日志数组顶部,形成"最新事件置顶"的日志流。

4.4 Mock 数据与初始状态

const STATION_LIST: Array<StationItem> = [
  new StationItem('⛽', '体育西路中石化站', 7.12, 7.58, 4, '92 号直降 0.3 元'),
  new StationItem('⛽', '中山五路中石油站', 7.08, 7.52, 12, '积分双倍加速'),
  new StationItem('🛢', '工业大道北民营站', 6.86, 7.31, 2, '低价·仅限现金'),
  new StationItem('⛽', '环市东路加油站', 7.15, 7.61, 18, '24 小时营业'),
  new StationItem('🚿', '康王中路加油站', 7.22, 7.69, 6, '加油送自动洗车'),
  new StationItem('⛽', '科韵路加油站', 7.10, 7.55, 0, '近高速入口免排队'),
  new StationItem('🛢', '琶洲大桥西站', 6.98, 7.44, 8, '23 点后每升减 0.2')
];

const SEARCH_RECORDS: Array<SearchRecord> = [
  new SearchRecord('体育西路加油站', '广州市天河区体育西路 75 号', 540, 0.95, '刚刚'),
  new SearchRecord('中山五路中石油加油站', '广州市越秀区中山五路 219 号', 920, 0.88, '刚刚'),
  new SearchRecord('工业大道北加油站', '广州市海珠区工业大道北 70 号', 1560, 0.66, '4 分钟前'),
  new SearchRecord('环市东路加油站(24小时)', '广州市越秀区环市东路 328 号', 2130, 0.51, '7 分钟前'),
  new SearchRecord('加油站设备维修中心', '广州市荔湾区康王中路 486 号', 3480, 0.24, '11 分钟前'),
  new SearchRecord('加油卡办理咨询点(非油站)', '广州市白云区机场路 1600 号', 5210, 0.08, '16 分钟前')
];

const EVENT_LOGS: Array<EventLog> = [
  new EventLog('POI', '广州塔', 23.1060, 113.3245, '演示事件'),
  new EventLog('Marker', '#0', 23.1321, 113.2812, '演示事件')
];

STATION_LIST 提供了7条油站 Mock 数据,覆盖了品牌站(中石化/中石油)、民营站(🛢图标)、特色站(洗车🚿)等多种类型,油价从6.86元到7.22元跨度,排队从0到18分钟跨度,备注字段模拟了真实场景中的促销信息(直降0.3元)、积分活动(双倍加速)、营业时间(24小时)、增值服务(送洗车)、地理位置(近高速)和时段优惠(23点后减0.2)。这种数据多样性确保了列表渲染时各种边界情况都能被测试到。

SEARCH_RECORDS 是最关键的演示数据,6条结果精心覆盖了 reliability 的三档范围:前两条 reliability 为0.95和0.88,属于"高相关"区间,是真正的加油站;第三条0.66和第四条0.51属于"中相关"区间,是带"加油站"字样的复合地点;第五条0.24和第六条0.08属于"低相关"区间,分别是"加油站设备维修中心"和"加油卡办理咨询点(非油站)"——这些结果名称中包含"加油"二字但实际并非油站,正是 reliability 字段要解决的问题。通过这组数据,用户可以直观看到分数条从满到空的渐变,以及等级标签从"高相关"到"低相关"的变化,理解 reliability 的语义价值。

EVENT_LOGS 提供了2条初始日志,一条 POI 类型(广州塔),一条 Marker 类型(#0),用于演示日志流的渲染形态,让用户在尚未触发任何长按事件前就能看到日志流的样式。实际使用中,当用户长按地图标注或 POI 后,新事件会通过 unshift 插入数组顶部,推动这些初始日志向下滚动。

五、组件主体状态与地图初始化

5.1 状态变量声明

@Entry
@Component
struct Page1134 {
  @State currentTab: number = 0;
  @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 stationList: Array<StationItem> = STATION_LIST;
  @State funcList: FuncItem[] = FUNC_LIST;

  private mapOptions: mapCommon.MapOptions = {
    position: { target: CITY_CENTER, zoom: 13 }
  };
  private mapCallback?: AsyncCallback<map.MapComponentController>;
  private mapController?: map.MapComponentController;
  private mapEventManager?: map.MapEventManager;
  @State markerListenOn: boolean = true;
  @State poiListenOn: boolean = true;
  @State eventLogs: Array<EventLog> = EVENT_LOGS;
  @State queryInput: string = '加油站';
  @State searchState: string = '待搜索 · 演示数据';
  @State searchRecords: Array<SearchRecord> = SEARCH_RECORDS;
  @State formName: string = '';
  @State formFuel: string = '';
  @State formAddr: string = '';
  @State editNote: string = '';

@Entry 和 @Component 装饰器将 struct Page1134 标记为应用入口组件。@State 装饰的状态变量是 ArkUI 响应式体系的核心——任何 @State 变量被赋新值时,引用该变量的 UI 部分会自动重新渲染。这里声明的状态变量可分为四组:第一组是 UI 导航状态(currentTab 当前Tab、cateIdx 筛选索引);第二组是弹窗开关(addModal/editModal/delModal 三个布尔值控制三种弹窗的显示,editIdx/delIdx 记录操作目标索引);第三组是列表数据(stationList 油站列表、funcList 功能清单、eventLogs 日志流、searchRecords 搜索结果);第四组是表单输入(queryInput 搜索框、formName/formFuel/formAddr 收藏表单、editNote 备注编辑)。

mapOptions、mapCallback、mapController、mapEventManager 四个变量声明为 private 而非 @State,因为它们是地图引擎的控制器对象,不需要触发 UI 刷新——地图内部的渲染由 Map Kit 引擎自行管理,UI 层只需在初始化时拿到控制器并注册监听,之后控制器引用本身不会变化。mapOptions 初始化为以 CITY_CENTER 为中心、zoom 13 的配置,这个缩放级别在城市区域可显示约2-3公里范围,恰好覆盖6个 Mock 标注点的散布范围。

markerListenOn 和 poiListenOn 被声明为 @State,因为它们驱动 Toggle 开关的选中状态,开关切换时 UI 需要刷新。这两个布尔值与 mapEventManager 配合,实现"开关关闭时取消监听、开关打开时重新注册监听"的动态控制,是 Map Kit 6.1.1 双长按特性的可视化管理入口。

5.2 地图初始化回调与双长按监听注册

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();
    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}`);
      }
    }
    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.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 参数——如果地图初始化失败(例如设备无 Google Services、网络异常),直接 console.error 并 return,避免后续对未初始化的控制器操作导致崩溃。这种"先判错再使用"的防御式编程是异步回调中必须遵循的规范。

回调主体分三步执行。第一步,将传入的 mapController 保存到 this.mapController 实例属性,并通过 mapController.getEventManager() 获取 MapEventManager 实例保存到 this.mapEventManager。这一步是后续所有事件监听注册的前提——没有事件管理器就无法调用 onMarkerLongClick 等方法。第二步,遍历 MARKER_SPOTS 数组,为每个加油站标注点调用 mapController.addMarker 添加到地图上。addMarker 是异步返回 Promise 的方法,使用 for…of 配合 await 逐个添加,确保前一个标注添加成功后再处理下一个;每个 addMarker 调用包裹在 try-catch 中,单个标注失败不会中断整批添加流程。MarkerOptions 配置了 position(经纬度)、clickable(可点击)、visible(可见)、anchorU/anchorV(锚点0.5/1,即标注底部中心对齐到坐标点)等参数,draggable: false 表示标注不可拖动,flat: false 表示标注始终竖直面对镜头。

第三步是本文的核心——注册两项 HarmonyOS 6.1.1 新增长按监听。onMarkerLongClick 接收一个回调函数,参数是 map.Marker 对象,当用户长按地图上的标注时触发。回调内通过 marker.getPosition() 获取标注的经纬度,通过 marker.getId() 获取标注id,构造一个 type 为 ‘Marker’、name 为 ‘#id’ 的 EventLog 实例,unshift 到 eventLogs 数组顶部。onPoiLongClick 的回调参数是 mapCommon.Poi 对象,包含 name(POI名称)和 position(经纬度),构造 type 为 ‘POI’、name 为 poi.name 的 EventLog 实例同样 unshift 到日志顶部。两个回调都使用 unshift 而非 push,保证最新事件显示在日志流最上方,符合"最新优先"的信息呈现原则。由于 eventLogs 是 @State 数组,unshift 后 UI 会自动刷新,日志流实时滚动。

5.3 双长按监听开关切换

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

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

这两个方法实现了 Marker 长按和 POI 长按监听的可视化开关切换。toggleMarkerListen 首先做空值检查——如果 mapEventManager 尚未初始化(地图未就绪),直接返回避免空指针。然后根据当前 markerListenOn 状态决定操作:若为 true(当前已注册监听),调用 offMarkerLongClick() 取消监听;若为 false(当前未注册监听),调用 onMarkerLongClick() 重新注册监听,回调逻辑与 setupMapCallback 中保持完全一致。最后将 markerListenOn 取反,驱动 Toggle 开关 UI 刷新。

这里值得注意的技术细节是 offMarkerLongClick() 不传任何参数。根据 Map Kit 6.1.1 的 API 设计,off 系列方法不传参时表示清除该类型的全部订阅。如果应用注册了多个 Marker 长按回调,需传入特定回调函数引用才能精确移除某一个。本应用场景中每种长按只注册一个回调,因此无参 off 是最简洁的清理方式。

togglePoiListen 的逻辑结构与 toggleMarkerListen 完全对称,只是方法名和回调参数类型不同(mapCommon.Poi 而非 map.Marker)。这种对称设计降低了维护成本——若未来需增加新的长按类型监听开关,可复制此模式快速实现。需要注意的是,重复调用 onMarkerLongClick 会叠加多个回调而非覆盖,因此正确的做法是先 off 再 on,本代码通过 toggle 模式(先 off 再 toggle 布尔值,下次 toggle 时才 on)避免了叠加问题。

六、searchByText 搜索与 reliability 读取

6.1 搜索方法实现

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 是调用 Map Kit site 模块 searchByText 接口的异步方法,它完整展示了 HarmonyOS 6.1.1 reliability 新字段在实际工程中的消费方式。方法首先将 searchState 置为"搜索中…“给用户即时反馈,然后构造 SearchByTextParams 参数对象:query 是用户输入的关键字(默认"加油站”),location 是搜索中心点(复用 CITY_CENTER 常量),radius 是搜索半径5000米(5公里,覆盖城市日常出行范围),language 是 ‘zh’ 中文。

参数构造完成后,调用 site.searchByText(params) 发起搜索,该方法返回 Promise,使用 await 等待结果。整个调用包裹在 try-catch 中——这是处理 Map Kit 异步调用的标准模式。正常流程中,从 result.sites 取出 Site 数组,使用空值合并运算符 ?? 兜底为空数组,因为 sites 字段在某些边缘情况下可能为 undefined。如果数组为空,更新 searchState 为"无结果"提示并返回,保留之前的演示数据不动,保证用户始终能看到列表形态。

非空结果的处理是本方法的核心。遍历 sites 数组,对每个 Site 对象 s 构造一个 SearchRecord 实例。这里对每个字段都使用 ?? 兜底:s.name 为空时显示"未命名地点",s.formatAddress 为空时显示"暂无地址",s.distance 为空时取0,s.reliability 为空时取0。reliability 的兜底尤为重要——作为 HarmonyOS 6.1.1 新增字段,旧版本 Map Kit 或某些搜索结果可能不返回该字段,?? 0 确保了向后兼容,不会因字段缺失而崩溃。构造完成的 records 数组赋值给 this.searchRecords,触发搜索列表 UI 刷新;searchState 更新为"返回 N 座油站"告知用户结果数量。

catch 分支处理搜索失败的情况,将 e 断言为 BusinessError 类型(Map Kit 的标准错误类型),读取 err.code 错误码,更新 searchState 为"搜索失败(code)"。常见的失败原因包括:未配置 AGC(AppGallery Connect)项目的地图服务、网络不通、配额超限等。失败时保留演示数据不清空,使用户仍能体验功能形态,这种"失败降级"策略对演示类应用尤为重要。

6.2 CRUD 弹窗业务方法

openEditStation(idx: number) {
  this.editIdx = idx;
  this.editNote = this.stationList[idx].note;
  this.editModal = true;
}

saveStation() {
  const name = this.formName === '' ? '油站通·新收藏油站' : this.formName;
  const fuel = this.formFuel === '' ? '92号' : this.formFuel;
  const addr = this.formAddr === '' ? '广州市天河区(地图选点)' : this.formAddr;
  this.stationList.unshift(new StationItem('⭐', name, 7.09, 7.54, 5, `常用${fuel} · ${addr}`));
  this.formName = '';
  this.formFuel = '';
  this.formAddr = '';
  this.addModal = false;
}

updateStation() {
  if (this.editIdx >= 0 && this.editIdx < this.stationList.length) {
    if (this.editNote !== '') {
      this.stationList[this.editIdx].note = this.editNote;
    }
    this.stationList = this.stationList.slice();
  }
  this.editModal = false;
}

delStation() {
  if (this.delIdx >= 0 && this.delIdx < this.stationList.length) {
    this.stationList.splice(this.delIdx, 1);
  }
  this.delModal = false;
}

openEditStation 是打开编辑备注弹窗的入口,接收油站索引 idx 作为参数。它依次完成三件事:将 editIdx 置为目标索引(供弹窗回填油站名用),将 editNote 回填为当前油站的备注内容(让用户看到原备注再修改),将 editModal 置为 true 触发编辑弹窗显示。这种"先回填再弹窗"的模式保证了用户打开弹窗时看到的是当前油站的真实数据,而非空白表单。

saveStation 处理收藏新油站的提交。它对三个表单字段做空白兜底:油站名为空时默认"油站通·新收藏油站",常用油号为空时默认"92号",地址为空时默认"广州市天河区(地图选点)"。这种兜底设计确保即使用户直接点击收藏按钮不填任何内容,也能生成一条合理的油站记录,提升了表单的容错性。新油站使用 ⭐ 图标标识其为用户收藏,油价默认7.09/7.54(参考价格),排队5分钟(中等),备注字段组合常用油号和地址信息。unshift 到列表顶部使新收藏立即可见。最后清空三个表单字段并关闭弹窗,为下次收藏做好准备。

updateStation 是编辑备注的保存逻辑。它先做索引边界检查(editIdx 在合法范围内),再判断 editNote 非空(避免空备注覆盖原备注),然后赋值。关键的一行是 this.stationList = this.stationList.slice()——虽然 StationItem 是 @Observed 类,修改 note 属性会触发对应列表项刷新,但为了确保整个列表的渲染一致性(@State 数组引用变更更可靠),这里通过 slice() 创建数组副本重新赋值,强制 ArkUI 重新渲染整个列表。这种做法在备注更新后视觉反馈更确定,避免了某些情况下 @Observed 属性刷新不及时的边缘问题。

delStation 是删除油站的执行方法。同样做索引边界检查后,调用 splice(delIdx, 1) 从数组中移除目标项。splice 会原地修改数组并触发 @State 刷新,删除后列表自动收缩。最后关闭删除确认弹窗。这三个方法配合 panelAdd/panelEdit/panelDel 三个弹窗 Builder,构成了完整的油站收藏 CRUD 闭环。

6.3 生命周期与主构建

aboutToAppear() {
  this.setupMapCallback();
}

build() {
  Stack() {
    Column() {
      this.headerMain()
      Divider().strokeWidth(1).color(COLORS.line)
      Scroll() {
        Column() {
          if (this.currentTab === 0) {
            this.tabFuel()
          } 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() 完成地图回调的注册——之所以在此时机而非构造函数中调用,是因为 aboutToAppear 时组件的 this 上下文已完全就绪,且 MapComponent 的 build 中会引用 this.mapCallback,必须确保在 build 前完成赋值。

build 方法是组件的渲染入口,采用 Stack 作为根容器。Stack 是层叠布局容器,其子元素按声明顺序从下到上层叠。这里 Stack 包裹两大部分:底部是 Column 主内容(headerMain 头部 + Divider 分隔线 + Scroll 可滚动内容区 + tabBar 底部导航),顶部是三个条件渲染的弹窗(panelAdd/panelEdit/panelDel)。这种"主内容在下、弹窗在上"的层叠结构是弹窗系统的标准实现——弹窗显示时覆盖在主内容之上,关闭时从 Stack 中移除,不影响下层交互。

主内容 Column 内部的布局逻辑清晰:headerMain 渲染头部渐变 Banner 和筛选 chips,Divider 做视觉分隔,Scroll 包裹可滚动的内容区并 layoutWeight(1) 占满中间剩余空间,tabBar 固定在底部。内容区内部根据 currentTab 的值条件渲染四个 Tab 之一——if-else 链而非 Tabs 组件的选择,是为了让每个 Tab 的布局可以完全独立定制(Tabs 组件会强制统一切换动画,不适合差异化布局场景)。三个弹窗的条件渲染使用 if (this.xxxModal) 包裹,弹窗关闭时条件为 false,对应 Builder 不再渲染,从 Stack 中移除。

七、头部渐变 Banner 与筛选条

7.1 头部主构建

@Builder
headerMain() {
  Column({ space: 12 }) {
    Column({ space: 10 }) {
      Row({ space: 12 }) {
        Column({ space: 2 }) {
          Text('58%').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('可续航约 312km').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
          }
          Row({ space: 6 }) {
            Text('⛽').fontSize(12)
            Text('最近油站 650m · 排队 4 分钟').fontSize(11).fontColor(COLORS.sub)
          }
          Row({ space: 6 }) {
            Text('💴').fontSize(12)
            Text('92 号今日均价 7.12 元/L').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.orange)
        Text('🗺 地图找站').fontSize(12).fontColor(COLORS.orange)
          .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.orangeD, 0.0], [COLORS.card, 0.7]]
    })

headerMain 是整个应用的视觉焦点,采用顶部渐变 Banner 设计。外层 Column space 12 控制内部元素间距,内层 Column space 10 是渐变卡主体。渐变卡的第一行是油箱余量展示:左侧大号"58%"数字配"油箱余量"小字,采用 Column 左对齐布局形成主副标题结构;右侧是三行信息,分别展示可续航里程(312km)、最近油站距离与排队(650m/4分钟)、92号今日均价(7.12元),每行用 emoji 图标做视觉前缀增强辨识度。右侧 Column layoutWeight(1) 占满剩余宽度,使左右两栏在 Row 中合理分配空间。

渐变卡的第二行是两个操作入口:左侧"⛽ 一键加油"是主操作按钮,背景燃油橙、文字沥青黑,视觉最突出;右侧"🗺 地图找站"是次操作按钮,背景 chip 色、文字燃油橙,onClick 切换到地图 Tab(currentTab = 1)。两个按钮通过 justifyContent(FlexAlign.SpaceBetween) 分两端对齐,形成清晰的"主次操作并排"布局。按钮的 padding 和 borderRadius 16 使其呈胶囊形态,符合移动端按钮的触控友好设计。

linearGradient 是渐变效果的关键属性。angle: 135 指定渐变方向为左上到右下135度角,colors 数组定义了渐变色标:从 orangeD(#C2550A 深燃油橙,0%位置)到 card(#1E1A13 卡片深棕黑,70%位置)。这种从橙色到深色的渐变模拟了加油站灯光从亮到暗的视觉过渡,呼应"燃油"主题。70%位置即过渡到深色,使卡片大部分区域保持深色背景,仅左上角泛着橙色光晕,视觉上既有焦点又不失沉稳。

7.2 筛选 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.orange : 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%')
}

筛选 chips 条是 headerMain 的下半部分,采用横向 Scroll 包裹 Row 实现可横滑的胶囊筛选条。Scroll 的 scrollable(ScrollDirection.Horizontal) 指定横向滚动,scrollBar(BarState.Off) 隐藏滚动条保持视觉干净。Row 内部通过 ForEach 渲染 CATE_TAGS 数组的8个标签,每个标签是 Text 组件配 padding 和 borderRadius 14 形成胶囊形态。

标签的选中态通过三元表达式动态控制:fontColor 在选中时为 COLORS.bg(沥青黑,在橙色背景上高对比),未选中时为 COLORS.sub(沙棕,在 chip 背景上柔和);backgroundColor 在选中时为 COLORS.orange(燃油橙),未选中时为 COLORS.chip(深胶囊底色)。onClick 将 cateIdx 置为当前索引,触发 @State 刷新,所有标签的选中态重新计算。这种"单一状态变量驱动多个标签选中态"的模式简洁高效,是单选筛选条的标准实现。

ForEach 的第三个参数是键值生成器 (tag: string) => tag,使用标签文案本身作为 key。这种 key 设计在标签文案唯一时有效(CATE_TAGS 中无重复),保证 ForEach 在数据变化时能精准识别哪一项需要更新。对于静态筛选条来说,这种 key 足够;若未来标签可动态增删且可能重名,应改用更稳定的 id 字段作为 key。

八、油站 Tab 详细实现

8.1 数据统计三宫格与推荐油站大卡

@Builder
statCell(value: string, label: string) {
  Column({ space: 4 }) {
    Text(value).fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.orange)
    Text(label).fontSize(10).fontColor(COLORS.sub)
  }
  .layoutWeight(1)
  .padding({ top: 10, bottom: 10 })
  .borderRadius(10)
  .backgroundColor(COLORS.card)
}

@Builder
tabFuel() {
  Column({ space: 10 }) {
    Row() {
      Text('附近油站').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Blank()
      Text('收藏新油站 +').fontSize(11).fontColor(COLORS.orange)
        .onClick(() => { this.addModal = true; })
    }
    .width('100%')
    Row({ space: 8 }) {
      this.statCell('4 次', '本月加油')
      this.statCell('152 L', '本月油量')
      this.statCell('0.3 元', '会员每升优惠')
    }
    .width('100%')

statCell 是一个可复用的 @Builder 函数,用于渲染数据统计三宫格的单元格。它接收 value(数值文本)和 label(标签文本)两个参数,内部 Column 上大下小,数值用 fontSize 17 加粗燃油橙强调,标签用 fontSize 10 沙棕弱化。layoutWeight(1) 使三个单元格在 Row 中均分宽度,padding 和 borderRadius 10 形成圆角卡片,backgroundColor 为 card 深棕黑。这种 Builder 复用模式避免了重复代码——油站 Tab 和我的 Tab 都有三宫格统计区,通过 statCell 统一了视觉规范。

tabFuel 是油站 Tab 的主构建,Column space 10 控制内部间距。第一行是区块标题行:左侧"附近油站"标题,Blank() 占位撑开,右侧"收藏新油站 +"橙色文字按钮,onClick 打开收藏弹窗。Blank() 是 ArkUI 的弹性空白组件,在 Row 中撑开左右两端实现 SpaceBetween 效果,比 justifyContent 更灵活(可与多个元素混用)。第二行是三宫格统计区,调用 statCell 三次分别展示本月加油次数、本月油量、会员优惠,三个数据构成用户加油消费的核心画像。

8.2 推荐油站大卡与全部油站列表

    ForEach(STATION_RECS, (rec: StationRec) => {
      Row({ space: 10 }) {
        Text(rec.icon).fontSize(26)
        Column({ space: 4 }) {
          Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
          Text(`92# ${rec.p92.toFixed(2)} · 95# ${rec.p95.toFixed(2)} 元/L`).fontSize(11).fontColor(COLORS.sub)
          Row({ space: 6 }) {
            Text(rec.dist).fontSize(10).fontColor(COLORS.text3)
            Text(`排队 ${rec.queue} 分钟`).fontSize(10).fontColor(queueColor(rec.queue))
          }
        }
        .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.orange)
            .onClick(() => { this.currentTab = 1; })
        }
      }
      .padding(12)
      .borderRadius(12)
      .backgroundColor(COLORS.card)
      .width('100%')
    }, (rec: StationRec) => rec.name)

这段代码渲染推荐油站大卡列表,ForEach 遍历 STATION_RECS 数组的3条数据。每条卡片是一个 Row:左侧 emoji 图标(fontSize 26 大号),中间 Column 展示油站名(13号加粗暖白)、油价信息(92# 和 95# 用模板字符串拼接,toFixed(2) 保留两位小数)、距离与排队(距离用 text3 弱化,排队用 queueColor 动态着色),右侧"导航"按钮(橙色背景,onClick 切换到地图 Tab)。

设计意图上,推荐油站大卡是油站 Tab 的"头部精选"——不同于下方的全部油站列表,这里的3条是经过算法筛选的推荐站点(最近、最便宜、排队最短等维度),卡片视觉更突出(emoji 更大、导航按钮更显眼),引导用户优先考虑。导航按钮的 onClick 切换到地图 Tab,实现了"列表-地图"的跨 Tab 跳转,是列表项与地图联动的常见交互模式。

技术要点在于 toFixed(2) 的使用——油价是浮点数,直接拼接会显示 7.1 而非 7.10,toFixed(2) 保证两位小数格式。queueColor 函数的调用体现了"数据驱动颜色"的设计——同一行代码根据 rec.queue 的值返回不同颜色,使排队信息在视觉上即时可感知。

8.3 全部油站列表与编辑删除操作

    Row() {
      Text('全部油站(地图 Marker 同源)').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
    }
    .width('100%')
    ForEach(this.stationList, (station: StationItem, idx: number) => {
      Column({ space: 8 }) {
        Row({ space: 10 }) {
          Text(station.icon).fontSize(22)
          Column({ space: 3 }) {
            Text(station.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text(`92# ${station.p92.toFixed(2)} · 95# ${station.p95.toFixed(2)} 元/L`).fontSize(11).fontColor(COLORS.sub)
            Text(`备注:${station.note}`).fontSize(10).fontColor(COLORS.text3)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Column({ space: 6 }) {
            Text(`${station.queue}`).fontSize(16).fontWeight(FontWeight.Bold)
              .fontColor(queueColor(station.queue))
            Text('分钟排队').fontSize(9).fontColor(COLORS.text3)
          }
        }
        .width('100%')
        Row({ space: 8 }) {
          Text(`92# ${station.p92.toFixed(2)}`).fontSize(10).fontColor(fuelColor(station.p92))
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .borderRadius(10).backgroundColor(COLORS.chip)
          Blank()
          Text('编辑').fontSize(10).fontColor(COLORS.silver)
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .borderRadius(10).backgroundColor(COLORS.chip)
            .onClick(() => { this.openEditStation(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%')
    }, (station: StationItem) => station.name)
    this.tipsCard()
  }
  .width('100%')
}

这是油站 Tab 最长的列表部分。ForEach 遍历 this.stationList(@State 数组,CRUD 操作后自动刷新),每个油站渲染为一个 Column 卡片。卡片上半部分是油站主信息:emoji 图标、名称、油价、备注、排队分钟数(大号显示,颜色由 queueColor 动态决定)。下半部分是操作行:左侧 92# 油价胶囊(颜色由 fuelColor 动态决定,低价绿/平价银/高价红),Blank() 撑开,右侧"编辑"和"删除"两个胶囊按钮。

编辑按钮的 onClick 调用 this.openEditStation(idx),传入当前项索引,打开编辑弹窗回填备注。删除按钮的 onClick 先将 delIdx 赋值为当前索引,再置 delModal 为 true 打开删除确认弹窗——这种"先存索引再开弹窗"的两步操作是列表项删除的标准模式,因为弹窗是独立的 Builder,需要通过实例变量传递目标索引。

ForEach 的键值生成器使用 station.name 作为 key,这在站名唯一时有效。列表末尾调用 this.tipsCard() 渲染加油小贴士卡片,提供民营站小票提示和夜间错峰加油建议,是业务知识的轻量呈现。整个油站 Tab 通过"标题行+三宫格+推荐大卡+全部列表+小贴士"的分层结构,完整展示了加油站用户的核心决策信息。

九、地图 Tab 与双长按事件特性

9.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%')
    Row({ space: 12 }) {
      Row({ space: 6 }) {
        Toggle({ type: ToggleType.Switch, isOn: this.markerListenOn })
          .selectedColor(COLORS.orange)
          .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.orange)
          .width(36)
          .height(20)
          .onChange(() => { this.togglePoiListen(); })
        Text('POI长按').fontSize(11).fontColor(COLORS.sub)
      }
    }
    .width('100%')

tabMap 是地图 Tab 的主构建,也是 Map Kit 6.1.1 双长按事件特性的演示主场。第一部分是特性说明卡,用 chip 背景色的小卡片醒目告知用户当前页面的技术特性——"Map Kit 6.1.1 · 长按事件监听"标题加"长按地图上的加油站 Marker 或 POI 地点,事件将记录到下方日志流"副标题,让用户在操作前就理解页面能力。这种"特性前置说明"的设计在演示型应用中尤为重要,降低了用户理解新功能的成本。

第二部分是两个 Toggle 开关,分别控制 Marker 长按和 POI 长按监听的启用/关闭。Toggle 的 type 为 Switch(开关样式),isOn 绑定 markerListenOn / poiListenOn 状态变量,selectedColor 设为 COLORS.orange 使开关激活态呈燃油橙。onChange 回调调用 toggleMarkerListen / togglePoiListen 方法,在方法内部完成 off/on 的切换和状态取反。两个开关并排在 Row 中,用户可以独立控制两种长按监听的启用状态——这种细粒度控制让用户能分别测试 Marker 和 POI 两种事件,理解它们的差异。

9.2 MapComponent 本体与日志流

    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.orange : COLORS.silver)
                  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%')
}

MapComponent 是 Map Kit 在 ArkUI 中的组件形式,接收 mapOptions(初始化配置)和 mapCallback(初始化回调)两个参数。layoutWeight(1) 使地图占据上方剩余空间,borderRadius(12) 圆角与卡片风格统一。地图组件的渲染由 Map Kit 引擎内部管理,UI 层只需声明配置和回调,无需手动处理渲染循环。地图加载完成后,aboutToAppear 中设置的 mapCallback 被调用,在回调内完成 controller 获取、Marker 添加、双长按监听注册,形成完整的初始化链路。

地图下方是长按事件日志流,固定高度120px可内部滚动。日志流顶部是标题行,左侧"长按事件日志流"标题,右侧"共 N 条"数量统计(动态绑定 eventLogs.length,新增事件自动更新)。Scroll 内部 ForEach 遍历 eventLogs 数组,每条日志渲染为一个 Row 卡片:左侧 emoji(Marker 用 📍,POI 用 ⛽),右侧 Column 展示事件类型标签(Marker 用橙色,POI 用银色)、名称、时间,以及经纬度坐标(toFixed(4) 保留四位小数,fontFamily monospace 等宽字体对齐)。

日志流的 key 生成器使用 ${log.type}-${log.name}-${log.time} 组合键,保证每条日志的唯一性。由于新事件通过 unshift 插入顶部,日志流会自动滚动到最新事件(如果用户未手动滚动)。这种"实时事件流"的呈现方式,让用户每次长按地图后都能立即看到反馈,是双长按特性的直观演示。固定高度120px而非 layoutWeight,是为了给地图留出足够显示空间——地图是本 Tab 的主体,日志流是辅助,空间分配需有主次。

十、搜索 Tab 与 reliability 可视化

10.1 搜索框与状态文案

@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.orange)
        .onClick(() => { this.runSearch(); })
    }
    .width('100%')
    Text(this.searchState).fontSize(10).fontColor(COLORS.text3).width('100%')

tabSearch 是搜索 Tab 的主构建,也是 Map Kit 6.1.1 reliability 新特性的可视化演示页。顶部同样是特性说明卡,标题"searchByText · reliability 相关性评分"明确告知技术点,副标题解释 reliability 字段的取值范围和语义。这种"说明卡+操作区+结果区"的三段式布局,使技术演示页的结构清晰可读。

搜索区是 Row 包裹的 TextInput 和 Button:TextInput 绑定 queryInput 状态,onChange 实时更新状态值;Button 的 onClick 调用 this.runSearch() 触发异步搜索。TextInput 的 text 参数使用 this.queryInput 而非 placeholder,确保组件受控——状态值变化时输入框内容同步更新。backgroundColor 为 card 深棕黑,与页面底色形成层次。搜索状态文案 Text 绑定 searchState,在搜索中、搜索成功、搜索失败时分别显示不同文案,给用户清晰的进度反馈。

10.2 搜索结果列表与 reliability 分数条

    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 })
            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 组件包裹 ForEach,每个 SearchRecord 渲染为一个 ListItem 卡片。卡片内容分四层:第一层是地点名称和等级标签的 Row——名称用 layoutWeight(1) 占满左侧,maxLines(1) 配 textOverflow(Ellipsis) 保证单行省略号;等级标签来自 reliabilityScore(rec.reliability),文案和颜色动态映射,背景为 card 色形成标签胶囊。第二层是地址文本,同样单行省略。第三层是 reliability 分数条——Progress 组件 value 为 rec.reliability * 100(将01映射到0100百分比),type 为 Linear 线性进度条,height 6 保持纤细,color 由 reliabilityScore 返回的颜色决定;右侧是数值文本"reliability 0.95",toFixed(2) 保留两位小数,fontFamily monospace 等宽字体。第四层是直线距离和时间——距离从米转换为公里(/1000),toFixed(2) 保留两位。

分数条的设计是 reliability 可视化的精髓。Progress 的 value 直接绑定 rec.reliability * 100,这意味着 reliability 为0.95的结果分数条几乎填满,0.08的结果分数条只有细丝——用户一眼就能判断哪些结果是高相关的。颜色同步映射:高相关橙色(醒目)、中相关银色(中性)、低相关红色(警示)。分数条旁的数值文本提供精确读数,满足需要量化信息的用户。这种"分数条+颜色+数值+标签"的四维可视化,使 reliability 字段的语义最大化呈现,是 Map Kit 6.1.1 新特性在前端层面的典型消费方式。

List 组件使用 ListItem 包裹每条结果,这是 ArkUI 的强制要求——List 的直接子元素必须是 ListItem,否则渲染报错。ForEach 的 key 使用 ${rec.name}-${rec.reliability} 组合键,保证结果项的唯一性。列表 layoutWeight(1) 占满剩余空间,scrollBar 隐藏保持视觉干净。列表末尾调用 this.codePreviewCard() 渲染代码预览卡,展示双新特性的调用代码片段,是技术演示页的"自证"元素。

十一、我的 Tab 与代码预览卡

11.1 燃油会员卡与功能清单

@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('加油每升立减 0.15 元 · 洗车 8 折').fontSize(11).fontColor(COLORS.sub)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      Divider().strokeWidth(1).color(COLORS.line)
      Row() {
        Text('本月加油 152L').fontSize(11).fontColor(COLORS.sub)
        Blank()
        Text('已省 36.80 元').fontSize(11).fontColor(COLORS.orange)
      }
      .width('100%')
    }
    .padding(14)
    .borderRadius(14)
    .linearGradient({
      angle: 135,
      colors: [[COLORS.orangeD, 0.0], [COLORS.card, 0.75]]
    })
    .width('100%')
    Row({ space: 8 }) {
      this.statCell('386 次', '累计加油')
      this.statCell('4217 L', '累计油量')
      this.statCell('2140 分', '积分余额')
    }
    .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('油站通 v3.2.1 · Map Kit 6.1.1 双新特性演示').fontSize(9).fontColor(COLORS.text3)
  }
  .width('100%')
}

tabMine 是"我的"Tab 的主构建。顶部是燃油会员渐变大卡,与头部 Banner 采用相同的 linearGradient 渐变风格(orangeD 到 card,135度角),形成视觉呼应。大卡内部是会员信息:左侧 ⛽ emoji 大号(fontSize 34),中间会员等级标题和权益说明,Divider 分隔后是本月加油统计和已省金额(橙色强调)。这种渐变会员卡是会员体系的视觉载体,提升用户归属感。

中部是三宫格统计区,复用 statCell Builder 展示累计加油次数、累计油量、积分余额三个全生命周期数据,与油站 Tab 的"本月"数据形成"月度-累计"的层次。下部是功能清单 ForEach 渲染 FUNC_LIST 的8条功能项,每条 Row 包含 emoji 图标、功能名(layoutWeight 撑开)、状态值、右箭头"›"。这是设置/功能列表的经典布局,箭头暗示可点击进入二级页面。

版本脚注"油站通 v3.2.1 · Map Kit 6.1.1 双新特性演示"用 fontSize 9 text3 弱化,既标注了版本号,又点明了技术特性,是演示型应用的"签名"。

11.2 代码预览卡

@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.orange).fontFamily('monospace')
      Text('site.reliability // 相关性 0~1 分')
        .fontSize(9).fontColor(COLORS.orange).fontFamily('monospace')
      Text('manager.onMarkerLongClick(cb) // 24+')
        .fontSize(9).fontColor(COLORS.silver).fontFamily('monospace')
      Text('manager.onPoiLongClick(cb)    // 24+')
        .fontSize(9).fontColor(COLORS.silver).fontFamily('monospace')
    }
    .padding(10)
    .borderRadius(8)
    .backgroundColor(COLORS.codeBg)
    .width('100%')
  }
  .padding(10)
  .borderRadius(10)
  .backgroundColor(COLORS.chip)
  .width('100%')
}

codePreviewCard 是搜索 Tab 底部的代码预览卡,用 monospace 等宽字体在深色 codeBg 背景上展示 Map Kit 6.1.1 双新特性的核心调用代码。四行代码分两组着色:前两行(site.searchByText 和 site.reliability)用燃油橙,代表搜索侧新特性;后两行(onMarkerLongClick 和 onPoiLongClick)用金属银,代表事件侧新特性。这种颜色分组帮助用户快速区分两大特性的归属。

注释 // 24+ 表示这些 API 需要 API 24及以上(对应 HarmonyOS 6.1.1),是版本兼容性的提示。代码预览卡的作用是"自证"——让用户在看到 reliability 分数条和长按日志流的同时,也能看到背后的代码调用,理解功能与技术实现的对应关系。这种"功能展示+代码暴露"的双重呈现,是技术演示型应用区别于普通功能型应用的设计特色。

十二、底部导航与弹窗系统

12.1 底部 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)
}

tabBar 是底部导航栏的实现,ForEach 渲染 TAB_LIST 的4个 Tab。每个 Tab 是一个 Column,上方 emoji 图标(fontSize 18),下方中文标签(fontSize 10),标签颜色根据 currentTab 是否等于当前索引动态切换——选中态为 tabOn(燃油橙),未选中态为 text3(暗棕弱化)。layoutWeight(1) 使4个 Tab 均分宽度,onClick 切换 currentTab 触发内容区条件渲染刷新。

底部栏使用 card 深棕黑背景,与页面 bg 底色有微弱层次区分,padding 上下8保证触控区域足够大。这种"底部单排4 Tab"是最经典的移动端导航模式,适合功能数量适中的应用。ForEach 的 key 使用 tab.label,因为标签文案唯一。整个 tabBar 没有使用 Tabs 组件而是手动实现,是为了让4个 Tab 的内容区布局可以完全独立定制——Tabs 组件会强制统一切换动画和布局约束,不适合本应用4个 Tab 差异化布局的需求。

12.2 弹窗遮罩与收藏油站弹窗

@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: '常用油号(如:92号)', text: this.formFuel })
        .height(38)
        .fontSize(12)
        .fontColor(COLORS.title)
        .placeholderColor(COLORS.text3)
        .backgroundColor(COLORS.chip)
        .onChange((v: string) => { this.formFuel = 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.orange)
          .onClick(() => { this.saveStation(); })
      }
      .width('100%')
    }
    .padding(16)
    .borderRadius(14)
    .backgroundColor(COLORS.card)
    .width('82%')
  }
  .width('100%')
  .height('100%')
}

modalOverlay 是弹窗遮罩层 Builder,接收一个 onClose 回调函数作为参数。它是一个占满全屏的 Column,背景色为 mask(rgba 半透明黑色),onClick 调用 onClose 关闭弹窗——这种"点击空白处关闭"的交互是弹窗系统的标准模式,符合移动端用户习惯。onClose 参数是回调函数类型 () => void,体现了 ArkUI Builder 支持函数参数的特性,使弹窗的关闭逻辑可由调用方定制。

panelAdd 是收藏油站弹窗,采用 Stack 层叠 modalOverlay 和内容 Column。内容 Column 宽度82%居中显示(Stack 默认居中),padding 16、borderRadius 14、card 背景色形成卡片形态。内部包含标题"收藏新油站"和三个 TextInput(油站名称、常用油号、地址),每个 TextInput 绑定对应 @State 变量,onChange 实时更新状态。底部是"取消"和"收藏"两个按钮,layoutWeight(1) 均分宽度——取消按钮背景 chip 色,收藏按钮背景燃油橙(主操作强调)。收藏按钮 onClick 调用 this.saveStation() 执行收藏逻辑,saveStation 内部会关闭弹窗。

这种"Builder 函数接收 onClose 回调"的弹窗模式,使弹窗的显示/隐藏由调用方控制(通过 @State 布尔值条件渲染),而弹窗内部的关闭操作(点击遮罩、点击取消按钮)通过 onClose 回调通知调用方关闭,实现了显示逻辑与内部交互逻辑的解耦。三个 TextInput 的 text 参数都绑定 @State 变量,确保弹窗打开时表单初始状态正确,关闭再打开时表单状态已重置(saveStation 末尾清空了三个字段)。

12.3 编辑备注与删除确认弹窗

@Builder
panelEdit(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column({ space: 12 }) {
      Text('编辑油站备注').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Text(this.editIdx < this.stationList.length ? this.stationList[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.orange)
          .onClick(() => { this.updateStation(); })
      }
      .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.stationList.length
        ? `确定删除「${this.stationList[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.delStation(); })
      }
      .width('100%')
    }
    .padding(16)
    .borderRadius(14)
    .backgroundColor(COLORS.card)
    .width('82%')
  }
  .width('100%')
  .height('100%')
}

panelEdit 是编辑备注弹窗,结构上复用了 panelAdd 的 Stack 层叠模式。不同之处在于:标题下方显示当前编辑的油站名称(通过 this.stationList[this.editIdx].name 获取,做边界检查避免索引越界),TextInput 绑定 editNote 状态(在 openEditStation 中已回填原备注)。"保存"按钮 onClick 调用 this.updateStation(),该方法会修改对应油站的 note 属性并通过 slice() 刷新数组引用,触发列表重新渲染。

panelDel 是删除确认弹窗,最简洁的一个。标题"删除收藏油站",中间是确认文案"确定删除「油站名」吗?"(同样做边界检查),底部"取消"和"删除"按钮。"删除"按钮背景为 red(#FF6B5E 警示红),视觉上提示该操作不可逆,符合删除类操作的视觉规范。onClick 调用 this.delStation(),该方法 splice 移除目标项后关闭弹窗。

三个弹窗的共性设计:都使用 Stack 层叠 modalOverlay 和内容;内容宽度82%居中;padding 16、borderRadius 14、card 背景色统一;按钮 Row 用 layoutWeight 均分;取消按钮 chip 色,主操作按钮 orange 或 red 强调。这种统一的视觉规范使三个弹窗形成一致的视觉语言,用户在任意弹窗中都能快速识别操作区域。边界检查(this.editIdx < this.stationList.length)是防御式编程的体现——即使 delIdx 或 editIdx 因某种原因越界(例如列表数据已变更但索引未更新),也不会导致崩溃,而是显示兜底文案或空字符串。

十三、Map Kit 6.1.1 双新特性对比

维度 reliability 相关性字段 长按事件监听
所属模块 site 模块(搜索能力) MapEventManager(事件能力)
API 形态 Site.reliability 字段(只读属性) onMarkerLongClick / offMarkerLongClick、onPoiLongClick / offPoiLongClick 方法
数据类型 浮点数,取值范围 [0,1] 回调函数,参数 map.Marker 或 mapCommon.Poi
触发时机 searchByText 返回结果时携带 用户长按地图标注或 POI 时触发
可选性 可选字段,需 ?? 兜底 注册/取消成对,off 不传参清全部
本应用消费方式 分数条+等级标签三态可视化 日志流 unshift 置顶记录
用户价值 甄别搜索结果真伪,过滤擦边结果 长按深度操作,短按常规查看
兼容性策略 s.reliability ?? 0 向后兼容 off 前需判空 mapEventManager
视觉编码 高相关橙/中相关银/低相关红 Marker 📍橙 / POI ⛽银
业务场景 搜索"加油站"过滤维修中心等非油站 长按油站标注触发收藏/备注/记录坐标

十四、结尾总结

本文围绕一个"油站通·加油站实时油价"的 HarmonyOS ArkUI 应用,完整拆解了 Map Kit 6.1.1 两项新特性的工程落地。第一项是 site 模块 searchByText 返回的 Site 类型新增的 reliability 相关性字段,它是一个取值 [0,1] 的浮点数,1 表示完全相关,用于量化搜索结果与关键字的关联程度。在本应用中,reliability 通过 reliabilityScore 辅助函数映射为"高相关/中相关/低相关"三态标签,配合 Progress 线性进度条和数值文本,构成"分数条+颜色+数值+标签"的四维可视化体系,让用户在搜索"加油站"时能一眼甄别出"加油卡办理点""维修中心"等擦边低相关结果,避免误导。reliability 字段是可选的,代码中使用 s.reliability ?? 0 做空值兜底,保证向后兼容不崩溃。

第二项是 MapEventManager 新增的 onMarkerLongClick / offMarkerLongClick 和 onPoiLongClick / offPoiLongClick 四个方法,分别用于监听和取消监听地图标注(Marker)和地图内置兴趣点(POI)的长按手势。Marker 长按回调参数是 map.Marker 对象,可通过 getPosition() 和 getId() 获取标注的经纬度和id;POI 长按回调参数是 mapCommon.Poi 对象,包含 name 和 position 字段。在本应用中,两类长按事件都被构造为 EventLog 实例 unshift 到日志流顶部,形成"最新置顶"的实时事件流,用户长按地图后立即看到反馈。监听的注册必须在 mapCallback 的 err 为空分支内完成(controller 就绪后才有 eventManager),off 系列方法不传参表示清除该类型全部订阅,重复 on 会叠加回调因此需配合 toggle 模式避免叠加。

在架构层面,本应用采用 @Entry @Component struct 作为入口,通过 @State 管理四组状态变量(导航状态、弹窗开关、列表数据、表单输入),通过 @Observed 装饰 StationItem / SearchRecord / EventLog 三个数据类实现响应式数据流。四个 Tab(油站/地图/搜索/我的)的布局完全差异化,通过 currentTab 索引条件渲染,而非使用 Tabs 组件统一切换——这种选择是为了让每个 Tab 的布局可以独立定制,不受 Tabs 组件的统一动画约束。弹窗系统采用 Stack 层叠 modalOverlay 和内容面板的模式,三个弹窗(panelAdd/panelEdit/panelDel)通过 @State 布尔值条件渲染,关闭逻辑通过 onClose 回调函数参数交由调用方控制,实现了显示逻辑与内部交互逻辑的解耦。

颜色系统采用集中式色板设计,ColorPalette 接口约束所有颜色字段,COLORS 常量提供沥青黑(#14110C)+ 燃油橙(#FF7A1A)+ 金属银(#C6CBD4)的深色主题实现。辅助函数 reliabilityScore、queueColor、fuelColor 都采用早返回三段判断的纯函数模式,将数值映射为颜色和文案,驱动 UI 的动态着色。地图初始化采用 aboutToAppear 生命周期钩子调用 setupMapCallback 的模式,回调内完成 controller 获取、Marker 批量添加(for…of + await + try-catch)、双长按监听注册三步链路,是 Map Kit 集成的标准初始化范式。

从业务场景看,本应用选择了"汽车能源补给服务"这一与 Map Kit 新特性高度契合的行业。加油站场景的核心痛点是结果真伪甄别(reliability 解决)和地图深度操作(长按事件解决)。reliability 让用户在搜索结果中快速过滤非油站,长按事件让用户通过长按标注触发收藏、备注、记录坐标等深度操作,形成"短按看、长按管"的交互范式。深色主题(沥青黑+燃油橙+金属银)呼应加油站夜间场景的视觉质感,linearGradient 渐变 Banner 和会员卡模拟加油站灯光从亮到暗的过渡,整体视觉与行业主题高度统一。这套工程实践可直接复用于其他需要 Map Kit 搜索和长按交互的 HarmonyOS 应用,具有良好的参考价值。

附录: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、测试、元服务和应用上架分发等。

更多推荐