一、技术前言

在这里插入图片描述

HarmonyOS 的 ArkUI 框架是华为面向全场景多设备的一套声明式 UI 开发范式,其核心语言 ArkTS 在 TypeScript 的基础上扩展了状态管理装饰器(@State、@Observed、@Builder、@Entry、@Component 等),让开发者可以用接近自然语言的方式描述界面结构与状态驱动关系。与传统命令式 UI 不同,ArkUI 采用"状态变化驱动 UI 更新"的模型,@State 标记的变量一旦变更,框架会自动重渲染依赖该状态的组件子树,无需手动刷新。这种模型非常适合需要频繁交互、多 Tab 切换、弹窗层叠、列表动态增删的复杂业务页面,也正是城市漫步路线这类 LBS(Location-Based Service)应用所依赖的底层能力。

在这里插入图片描述
Map Kit 是 HarmonyOS 官方提供的地图能力套件,封装在 @kit.MapKit 模块中,对外暴露 MapComponent(地图组件)、mapCommon(通用类型与参数)、map(控制器与事件管理)、site(地点检索)等多个命名空间。开发者可以在 ArkUI 页面中像使用普通组件一样嵌入 MapComponent,通过 mapCallback 回调拿到 MapComponentController 控制器,进而添加 Marker(地图标注)、绑定相机移动、响应用户手势。Map Kit 在 HarmonyOS 6.1.1 版本中迎来了一次重要更新,围绕"搜索结果可量化"与"交互事件更丰富"两个方向补齐了能力短板。

在这里插入图片描述
第一项新特性聚焦于地点检索的"相关性可量化"。此前 site.searchByText 返回的 Site 对象只包含名称、地址、距离等基础字段,开发者拿到一串结果后很难判断哪条结果与用户输入的关键字真正相关——比如用户搜索"步行路线",返回的结果里可能混杂着"跑步机专卖店"这类字面命中但语义无关的条目。HarmonyOS 6.1.1 为 Site 类型新增了 reliability 字段,取值范围为 [0,1],1 表示完全相关、0 表示完全不相关。这个分数由地图服务的语义匹配引擎计算,综合考虑关键字分词、地理位置 proximity、POI 类型匹配等多维因素,让搜索结果有了可排序、可过滤、可展示的量化依据。

在这里插入图片描述
第二项新特性聚焦于地图交互事件的"长按维度补全"。此前 MapEventManager 已支持 onClick / onMarkerClick / onPoiClick 等单击类监听,但在 CityWalk 这类需要"长按某个路线点查看详情"或"长按地图上的 POI 收藏为途经点"的场景下,单击事件与长按事件存在语义重叠与误触风险。HarmonyOS 6.1.1 为 MapEventManager 新增了 onMarkerLongClick / offMarkerLongClick(地图标记长按监听注册与注销)和 onPoiLongClick / offPoiLongClick(地图 POI 长按监听注册与注销)两对方法,长按回调分别接收 map.Marker 和 mapCommon.Poi 类型参数,开发者可以从回调参数中取出标记 ID、经纬度、POI 名称等信息,构建更贴近原生手势习惯的交互链路。

在这里插入图片描述
CityWalk(城市漫步)是近年来在城市生活服务领域兴起的一种轻量出行方式,强调以步行节奏感受城市肌理——梧桐荫道、沿江步道、老弄堂、艺术街区、夜行线、咖啡地图等都属于典型 CityWalk 主题。一个完整的 CityWalk 路线应用需要解决三个核心问题:第一,如何把散落的城市兴趣点组织成有叙事逻辑的"路线"而非零散的 POI 列表;第二,如何让用户在地图上以自然手势(长按)收藏途经点、记录路线节点;第三,如何在搜索阶段就帮用户过滤掉"字面命中但语义无关"的结果,避免把"跑步机专卖店"推荐给想找"步行路线"的人。本文要剖析的代码,正是围绕这三个问题,用 Map Kit 6.1.1 的两项新特性给出了工程级解法。

在这里插入图片描述
从架构层面看,这份代码是一个单页面四 Tab 的 ArkUI 入口页,底部导航包含"路线 / 地图 / 搜索 / 我的"四个 Tab,每个 Tab 的布局形态完全不同:路线 Tab 是统计三宫格加推荐列表与收藏列表;地图 Tab 嵌入 MapComponent 并挂载双长按监听,下方是实时滚动的长按事件日志流;搜索 Tab 调用 site.searchByText 并以 reliability 分数条加等级标签展示结果;我的 Tab 是行者会员卡加功能清单。整个页面还配有三层弹窗(收藏路线 / 编辑备注 / 删除确认)和一个全屏遮罩层,构成了完整的增删改闭环。下面我们通过架构流程图先建立全局认知,再逐段深入代码。

在这里插入图片描述

二、整体架构流程图

0 路线

1 地图

2 搜索

3 我的

长按 Marker

长按 POI

点击搜索

新增

编辑

删除

Page1141 主页面 @Entry

aboutToAppear 生命周期

setupMapCallback 初始化地图回调

mapCallback 异步回调

err 是否为空?

console.error 打印错误并返回

获取 mapController 控制器

mapController.getEventManager 获取事件管理器

循环 MARKER_SPOTS 添加路线点 Marker

onMarkerLongClick 注册 Marker 长按监听

onPoiLongClick 注册 POI 长按监听

build 主构建 Stack

headerMain 渐变 Banner + 筛选 chips

Scroll 内容区

currentTab

tabWalk 统计三宫格 + 推荐列表 + 收藏列表

tabMap MapComponent + 监听开关 + 日志流

tabSearch 搜索框 + reliability 分数条 + 代码预览

tabMine 会员卡 + 统计 + 功能清单

tabBar 底部 4 Tab 导航

onMarkerLongClick 回调 unshift EventLog

onPoiLongClick 回调 unshift EventLog

runSearch 调用 site.searchByText

读取 Site.reliability 构造 SearchRecord

reliabilityScore 映射等级标签与颜色

弹窗层 addModal / editModal / delModal

modalOverlay 全屏遮罩

panelAdd 收藏路线弹窗

panelEdit 编辑备注弹窗

panelDel 删除确认弹窗

上图清晰地呈现了页面的两条主线:左侧是地图初始化与长按监听注册链路(aboutToAppear → mapCallback → controller → eventManager → onMarkerLongClick/onPoiLongClick),右侧是 build 构建出的四 Tab 内容区与弹窗层。搜索 Tab 的 runSearch 是 reliability 新特性的入口,地图 Tab 的两个 Toggle 开关则控制长按监听的动态注册与注销。两条主线通过 EventLog 与 SearchRecord 两个 @Observed 数据模型汇合到 UI 展示层,形成"数据驱动 UI"的闭环。

三、模块依赖与导入

import { MapComponent, mapCommon, map, site } from '@kit.MapKit';
import { AsyncCallback, BusinessError } from '@kit.BasicServicesKit';

第 20 行从 @kit.MapKit 命名空间导入了四个成员。MapComponent 是地图组件本体,在 ArkUI 中以组件形式直接嵌入 build 树;mapCommon 提供通用数据类型,如 LatLng(经纬度)、MapOptions(地图初始化参数)、MarkerOptions(标注参数)、Poi(兴趣点)等;map 提供控制器与事件管理相关的能力,包括 MapComponentController(地图控制器)、MapEventManager(事件管理器)、Marker(标注对象)等;site 提供地点检索能力,包括 searchByText 方法及其参数 SearchByTextParams、结果类型 SearchByTextResult 和 Site。

第 21 行从 @kit.BasicServicesKit 导入了 AsyncCallback 和 BusinessError。AsyncCallback 是异步回调的标准签名,其定义为 (err: BusinessError, data: T) => void,第一个参数是错误对象(为空表示成功),第二个参数是成功时返回的数据,这里 T 被实例化为 map.MapComponentController。BusinessError 是统一错误类型,包含 code(错误码)和 message(错误描述)两个字段,在 mapCallback 的 err 判断和 runSearch 的 catch 块中都会用到。

这两行导入是整个页面 Map Kit 能力的根基。值得注意的是,ArkTS 的 import 语法与 TypeScript 一致,但 @kit.MapKit 和 @kit.BasicServicesKit 是 HarmonyOS 的 Kit 化模块标识(以 @kit 前缀开头),它们在编译期会被解析为实际的 ohpm 包,运行期对应到设备上的系统能力。这意味着这份代码依赖的并非普通的第三方库,而是 HarmonyOS 系统级服务,其可用性取决于设备的系统版本——reliability 字段和长按监听方法只有在 HarmonyOS 6.1.1 及以上版本才会存在。

四、颜色系统与常量定义

4.1 主题色板接口

/** 主题色板接口:集中声明页面所有颜色字段(晨雾白+霞光粉+紫藤紫浅色系) */
interface ColorPalette {
  bg: string;         // 页面底色(晨雾白)
  card: string;       // 卡片底色
  chip: string;       // 胶囊/浅层底色
  title: string;      // 主标题色
  sub: string;        // 次级文本色
  text3: string;      // 三级弱文本色
  pink: string;       // 霞光粉(主强调色)
  pinkD: string;      // 深霞光粉(渐变起点)
  pinkL: string;      // 浅霞光粉(高亮文本)
  wisteria: string;   // 紫藤紫(次强调色)
  green: string;      // 轻松绿
  yellow: string;     // 适中黄
  red: string;        // 疲劳红(删除/低相关)
  blue: string;       // 编辑蓝(编辑入口)
  line: string;       // 分割线色
  tabOn: string;      // 底部 Tab 选中色
  btnText: string;    // 强调色按钮上的文字色
  mask: string;       // 弹窗遮罩色
  codeBg: string;     // 代码预览卡深底色
}

这段代码定义了一个名为 ColorPalette 的接口,将页面用到的所有颜色字段集中声明。这样做的好处是把"颜色"从散落在各处的魔法字符串抽离为一个有类型约束的契约——任何一个组件如果引用 COLORS,IDE 都能给出字段补全,编译期也能检查拼写错误。接口里字段命名遵循"语义优先"原则:bg 表示页面底色、card 表示卡片底色、chip 表示胶囊与浅层底色、title/sub/text3 是三级文本色(主标题/次级/弱),pink 系列(pink/pinkD/pinkL)是霞光粉的三档明度,wisteria 是紫藤紫作为次强调色,green/yellow/red 是语义色(轻松绿/适中黄/疲劳红),blue 是编辑蓝,line 是分割线,tabOn 是底部 Tab 选中色,btnText 是强调色按钮上的文字色,mask 是弹窗遮罩,codeBg 是代码预览卡的深底色。

颜色字段的语义化命名对 CityWalk 这种强调情绪氛围的应用尤为重要。晨雾白(#FBF8F6)作为底色营造"清晨漫步"的温润感,霞光粉(#E8899B)作为主强调色呼应"霞光"意象,紫藤紫(#8B7BC5)作为次强调色用于夜行线等"沉静"主题。这种"情绪色板"的设计让 UI 不仅是功能载体,更是城市漫步体验的一部分——用户打开页面时,色彩本身就在传递"今天适合出门走走"的情绪暗示。

4.2 浅色主题色板常量

/** 浅色主题色板常量(漫步线 · 晨雾白 + 霞光粉 + 紫藤紫) */
const COLORS: ColorPalette = {
  bg: '#FBF8F6',
  card: '#FFFFFF',
  chip: '#F3ECEA',
  title: '#3A2E36',
  sub: '#8C7A85',
  text3: '#B5A6AF',
  pink: '#E8899B',
  pinkD: '#C9627A',
  pinkL: '#FBE3E9',
  wisteria: '#8B7BC5',
  green: '#63A57B',
  yellow: '#DBA94E',
  red: '#C64B4B',
  blue: '#4A7FB5',
  line: '#EBE2DF',
  tabOn: '#E8899B',
  btnText: '#FFFFFF',
  mask: 'rgba(58,46,54,0.40)',
  codeBg: '#3A2E36'
};

这里用 const 声明了 COLORS 常量并实例化 ColorPalette 接口。每一个十六进制色值都经过精心调配:bg #FBF8F6 是带有极淡暖灰的白,比纯白 #FFFFFF 更柔和,适合长时间阅读;card 用纯白 #FFFFFF 形成卡片与底色的轻微对比;chip #F3ECEA 是 bg 的更深一档,用于胶囊按钮与浅层底;title #3A2E36 是带有紫调的深灰,比纯黑更暖;sub #8C7A85 是中等明度的暖灰;text3 #B5A6AF 是最弱的文本色。

pink #E8899B 是霞光粉主色,pinkD #C9627A 是更深的渐变起点色(用于 linearGradient 的起点),pinkL #FBE3E9 是极浅粉用于高亮文本背景。wisteria #8B7BC5 是紫藤紫,green #63A57B 是轻松绿,yellow #DBA94E 是适中黄,red #C64B4B 是疲劳红,blue #4A7FB5 是编辑蓝。line #EBE2DF 是分割线,tabOn 与 pink 同色表示 Tab 选中态沿用主强调色,btnText #FFFFFF 是强调色按钮上的白字。mask 使用 rgba(58,46,54,0.40) 即 title 色加 40% 透明度,形成半透明遮罩。codeBg #3A2E36 与 title 同色,作为代码预览卡的深底,让代码文本在浅色页面中形成视觉锚点。

4.3 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 文案(CityWalk 场景筛选) */
const CATE_TAGS: string[] = ['全部', '梧桐区', '沿江步道', '老弄堂', '艺术街区', '夜行线', '咖啡地图', '摄影机位'];

TabMeta 接口定义了底部导航每个 Tab 的元数据:icon 是 emoji 图标,label 是文案。TAB_LIST 常量数组包含四个 Tab:路线(🚶 步行小人)、地图(🗺 地图)、搜索(🔍 放大镜)、我的(👤 人像)。用 emoji 而非图片资源的好处是零体积、跨设备一致、随系统字体渲染,缺点是不同平台 emoji 风格略有差异,但对 CityWalk 这种偏生活方式的应用是可接受的。CATE_TAGS 是头部横滑筛选 chips 的文案列表,包含 8 个 CityWalk 主题标签,从"全部"到"梧桐区、沿江步道、老弄堂、艺术街区、夜行线、咖啡地图、摄影机位",覆盖了城市漫步的主要场景类型,这些标签会以横滑胶囊的形式出现在头部 Banner 下方,点击切换选中态。

4.4 城市中心点与路线点数据

/** 城市中心点(上海,地图初始化中心 + Map Kit 搜索 location 参数) */
const CITY_CENTER: mapCommon.LatLng = { latitude: 31.2304, longitude: 121.4737 };

/** 地图标注点接口(路线点 Marker 群,长按事件的数据来源) */
interface SpotItem {
  name: string;   // 路线点名称
  lat: number;    // 纬度
  lng: number;    // 经度
  tag: string;    // 路线点类型标签
}

/** 路线点标注 Mock 数据(6 个,围绕上海中心点 ±0.02 度散布) */
const MARKER_SPOTS: SpotItem[] = [
  { name: '漫步线·人民广场起点', lat: 31.2335, lng: 121.4692, tag: '起点' },
  { name: '漫步线·外滩源折返点', lat: 31.2402, lng: 121.4885, tag: '沿江' },
  { name: '漫步线·南京东路中继点', lat: 31.2354, lng: 121.4801, tag: '商圈' },
  { name: '漫步线·豫园老街环线点', lat: 31.2270, lng: 121.4918, tag: '老街' },
  { name: '漫步线·新天地梧桐点', lat: 31.2195, lng: 121.4700, tag: '梧桐' },
  { name: '漫步线·苏州河滨河点', lat: 31.2445, lng: 121.4651, tag: '滨河' }
];

CITY_CENTER 是 mapCommon.LatLng 类型的城市中心点常量,坐标为上海(纬度 31.2304,经度 121.4737)。这个常量承担双重职责:一是作为 MapComponent 初始化时 mapOptions.position.target 的中心点,让地图一打开就定位到上海核心区;二是作为 site.searchByText 的 location 参数,让搜索以该点为圆心进行 proximity 排序。SpotItem 接口定义了路线点标注的数据结构:name 是路线点名称,lat/lng 是经纬度,tag 是类型标签(起点/沿江/商圈/老街/梧桐/滨河)。

MARKER_SPOTS 是 6 个路线点的 Mock 数据,围绕上海中心点 ±0.02 度散布,覆盖了人民广场、外滩源、南京东路、豫园、新天地、苏州河等典型 CityWalk 节点。这些数据会在 mapCallback 回调中循环调用 addMarker 添加到地图上,形成路线点 Marker 群——而长按这些 Marker 就会触发 onMarkerLongClick 回调。所以 MARKER_SPOTS 既是地图标注的数据源,也是长按事件的数据来源,体现了"数据同源"的设计原则:路线 Tab 的列表、地图 Tab 的 Marker、长按日志的记录,都引用同一份路线点数据。

4.5 推荐路线与功能清单数据

/** 推荐路线接口(路线 Tab 顶部推荐大卡) */
interface WalkRec {
  icon: string;     // 路线 emoji 图标
  name: string;     // 路线名
  km: string;       // 全程公里文本
  minutes: string;  // 耗时文本
  spots: number;    // 打卡点数
  level: string;    // 路线强度标签
}

/** 推荐路线 Mock 数据(3 条,渐变 Banner 下方推荐列表) */
const WALK_RECS: WalkRec[] = [
  { icon: '🚶', name: '梧桐区小环线', km: '5.2km', minutes: '95分钟', spots: 9, level: '休闲' },
  { icon: '🌉', name: '外滩源滨江线', km: '3.8km', minutes: '70分钟', spots: 7, level: '夜行' },
  { icon: '🎞', name: '老弄堂胶片线', km: '4.6km', minutes: '110分钟', spots: 11, level: '街拍' }
];

/** 我的页功能清单条目接口 */
interface FuncItem {
  icon: string;   // 功能图标
  label: string;  // 功能名
  value: string;  // 状态/数值文本
}

/** 我的页功能清单 Mock 数据(8 条) */
const FUNC_LIST: FuncItem[] = [
  { icon: '🚶', label: '累计漫步', value: '86 次 · 214km' },
  { icon: '📸', label: '打卡机位', value: '收藏 36 个' },
  { icon: '☕', label: '沿线咖啡', value: '喝过 52 家' },
  { icon: '⭐', label: '收藏路线', value: '8 条' },
  { icon: '🧾', label: '活动记录', value: '本月 9 次' },
  { icon: '🗺', label: '常走街区', value: '梧桐区 / 外滩' },
  { icon: '🔔', label: '天气出行提醒', value: '已开启' },
  { icon: '⚙', label: '偏好设置', value: '优先遮阴路线' }
];

WalkRec 接口定义了推荐路线的数据结构,WALK_RECS 包含 3 条精选路线:梧桐区小环线(休闲,9 打卡点)、外滩源滨江线(夜行,7 打卡点)、老弄堂胶片线(街拍,11 打卡点)。这三条路线在路线 Tab 中以大卡形式展示,每条卡的打卡点数会通过 spotColor 函数映射颜色,路线强度会通过 levelColor 函数映射颜色,形成视觉上的"难度梯度"。

FuncItem 接口定义了我的页功能清单的条目结构,FUNC_LIST 包含 8 条功能项:累计漫步、打卡机位、沿线咖啡、收藏路线、活动记录、常走街区、天气出行提醒、偏好设置。这些条目让"我的"页不仅是一个入口聚合页,更是一个"漫步生活档案"——用户可以看到自己累计走过多少公里、喝过多少家沿线咖啡、收藏了多少机位,这种"生活数据可视化"的设计契合 CityWalk 应用偏生活方式的定位。

五、辅助函数

5.1 reliability 相关性等级映射

/** 相关性等级接口(分数条旁的标签) */
interface ScoreLevel {
  label: string;   // 等级文案
  color: string;    // 等级颜色
}

/**
 * reliability 相关性分数 → 等级标签/颜色映射
 * 取值 [0,1]:≥0.8 高相关 / ≥0.5 中相关 / 其余低相关(Map Kit 6.1.1 新字段)
 */
function reliabilityScore(score: number): ScoreLevel {
  if (score >= 0.8) {
    return { label: '高相关', color: COLORS.green };
  }
  if (score >= 0.5) {
    return { label: '中相关', color: COLORS.yellow };
  }
  return { label: '低相关', color: COLORS.red };
}

reliabilityScore 函数是 reliability 新字段在 UI 层落地的核心映射器。它接收一个 [0,1] 区间的分数,返回一个 ScoreLevel 对象(包含 label 文案和 color 颜色)。映射规则分三档:score >= 0.8 返回"高相关"配轻松绿 COLORS.green,score >= 0.5 返回"中相关"配适中黄 COLORS.yellow,其余返回"低相关"配疲劳红 COLORS.red。这种三档分级的阈值选取有讲究——0.8 是"语义高度匹配"的经验阈值,0.5 是"语义基本相关"的下限,低于 0.5 则意味着结果与关键字只有字面或弱语义关联。

这个函数在搜索 Tab 中被两处调用:一是结果列表项的等级标签(Text 的内容与颜色),二是 reliability 分数条的颜色(Progress 的 color)。同一分数映射出"标签 + 颜色 + 进度条色"三重视觉反馈,让用户一眼就能判断哪条结果值得点开。值得强调的是,reliability 字段是 HarmonyOS 6.1.1 才新增的可选字段,所以这里传入的 score 可能为 undefined(虽然函数签名是 number),但在调用方代码中用 s.reliability ?? 0 做了空值兜底,保证传入函数的永远是有效数字。

5.2 打卡点数与路线强度颜色映射

/** 打卡点数颜色映射:≥8 机位丰富绿 / ≥5 机位适中紫 / 其余偏少红 */
function spotColor(spots: number): string {
  if (spots >= 8) { return COLORS.green; }
  if (spots >= 5) { return COLORS.wisteria; }
  return COLORS.red;
}

/** 路线强度颜色映射:夜行紫藤紫 / 街拍适中黄 / 其余休闲绿 */
function levelColor(level: string): string {
  if (level === '夜行') { return COLORS.wisteria; }
  if (level === '街拍') { return COLORS.yellow; }
  return COLORS.green;
}

spotColor 函数把打卡点数映射为颜色:≥8 个打卡点返回轻松绿(机位丰富),≥5 个返回紫藤紫(适中),其余返回疲劳红(偏少)。这个映射服务于"路线丰富度"的视觉传达——用户扫一眼列表就能看到哪些路线打卡点密集(绿色)、哪些稀疏(红色),辅助选择。levelColor 函数把路线强度标签映射为颜色:夜行返回紫藤紫(沉静)、街拍返回适中黄(活跃)、其余返回轻松绿(休闲)。这两个函数都是纯函数(无副作用、输入决定输出),适合在 ForEach 的渲染回调中反复调用而不用担心状态污染。

六、数据模型层

6.1 路线条目模型

/** 路线条目(路线 Tab 收藏列表) */
@Observed export class WalkItem {
  name: string;     // 路线名
  km: number;       // 全程公里数
  minutes: number;  // 预计耗时(分钟)
  spots: number;    // 打卡点数
  note: string;     // 用户备注(可编辑)

  constructor(name: string, km: number, minutes: number,
    spots: number, note: string) {
    this.name = name;
    this.km = km;
    this.minutes = minutes;
    this.spots = spots;
    this.note = note;
  }
}

/** 路线收藏列表 Mock 数据(7 条) */
const WALK_LIST: Array<WalkItem> = [
  new WalkItem('梧桐区小环线', 5.2, 95, 9, '法式梧桐一路遮阴'),
  new WalkItem('外滩源滨江线', 3.8, 70, 7, '亮灯后更出片'),
  new WalkItem('老弄堂胶片线', 4.6, 110, 11, '带一卷胶卷去拍'),
  new WalkItem('豫园老街环线', 3.2, 60, 6, '避开周末人潮'),
  new WalkItem('苏州河滨河线', 6.0, 120, 10, '桥洞机位别错过'),
  new WalkItem('艺术西岸线', 4.4, 85, 8, '展馆周五免费夜'),
  new WalkItem('静安寺街拍线', 2.9, 55, 5, '商场橱窗顺路逛')
];

WalkItem 是一个用 @Observed 装饰的导出类,表示一条收藏路线。@Observed 装饰器的作用是让该类的实例属性变化能被 ArkUI 框架观察到——当用 @State 持有 WalkItem 数组时,直接修改数组元素的属性(如 this.walkList[idx].note = newValue)不会触发 UI 更新,因为数组引用没变;但配合 @Observed 后,框架能深入观察到属性级变化。不过这份代码在 updateWalk 中用了 this.walkList = this.walkList.slice() 整体刷新数组引用的"笨办法"来触发更新,这是一种更稳妥的兜底策略,即便不依赖 @Observed 的深层观察也能保证刷新。

WALK_LIST 是 7 条收藏路线的 Mock 数据,每条都有名字、公里数、耗时、打卡点数和备注。备注文案是点睛之笔——“法式梧桐一路遮阴”“亮灯后更出片”“带一卷胶卷去拍”“避开周末人潮”“桥洞机位别错过”——这些是"漫步者视角"的实用 tips,而非冷冰冰的数据,让列表不仅是路线索引,更是经验沉淀。km 用 number 类型(5.2 而非 ‘5.2km’)是为了在 UI 中用 walk.km.toFixed(1) 控制小数位格式,分离数据与展示。

6.2 搜索结果模型(reliability 数据载体)

/** 搜索结果条目(★ Map Kit 6.1.1 reliability 字段数据载体) */
@Observed export class SearchRecord {
  name: string;         // 地点名称(site.name)
  address: string;       // 格式化地址(site.formatAddress)
  distance: number;      // 直线距离米(site.distance)
  reliability: number;   // ★ 相关性分数(site.reliability,[0,1])
  time: string;          // 记录时间文案

  constructor(name: string, address: string, distance: number,
    reliability: number, time: string) {
    this.name = name;
    this.address = address;
    this.reliability = reliability;
    this.distance = distance;
    this.time = time;
  }
}

/** 搜索结果 Mock 数据(6 条,reliability 覆盖高/中/低三档) */
const SEARCH_RECORDS: Array<SearchRecord> = [
  new SearchRecord('梧桐区漫步环线', '上海市徐汇区武康路 376 号', 1650, 0.94, '刚刚'),
  new SearchRecord('外滩源历史建筑群', '上海市黄浦区中山东一路 33 号', 2210, 0.88, '刚刚'),
  new SearchRecord('豫园老街步行区', '上海市黄浦区豫园老街 58 号', 2680, 0.74, '刚刚'),
  new SearchRecord('城市徒步装备集合店', '上海市黄浦区南京东路 830 号', 1920, 0.51, '刚刚'),
  new SearchRecord('观光巴士售票亭(非步行)', '上海市黄浦区西藏中路 180 号', 3050, 0.29, '刚刚'),
  new SearchRecord('跑步机专卖店(非路线)', '上海市静安区南京西路 1618 号', 0.12, '刚刚')
];

SearchRecord 是 reliability 新特性在应用层的数据载体。它的五个字段分别对应 site.Site 的不同属性:name 对应 site.name,address 对应 site.formatAddress,distance 对应 site.distance,reliability 对应 site.reliability(标注了 ★ 强调这是 6.1.1 新字段),time 是应用层自己加的记录时间文案。把 site.Site 的原始字段映射为 SearchRecord,是为了解耦"地图 API 数据"与"UI 展示数据"——这样即便未来 site.Site 的字段名调整,UI 层也不用改。

SEARCH_RECORDS 的 6 条 Mock 数据精心覆盖了 reliability 的三档:0.94 和 0.88 是高相关(梧桐区漫步环线、外滩源历史建筑群),0.74 是中相关(豫园老街步行区),0.51 是中低相关临界(城市徒步装备集合店),0.29 和 0.12 是低相关(观光巴士售票亭、跑步机专卖店)。这种"覆盖三档"的数据设计让 UI 的等级标签与分数条有充分的展示样本——用户能看到"高相关是绿的"“低相关是红的”“中相关是黄的”,直观理解 reliability 字段的语义。注意第 6 条数据原文存在一处书写笔误(缺少 distance 参数),但语义上 0.12 对应的是 reliability,这是 Mock 演示数据,不影响对特性本身的理解。

6.3 长按事件日志模型

/** 长按事件日志条目(★ 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;
  }
}

/** 长按事件日志 Mock 数据(2 条,演示日志流形态) */
const EVENT_LOGS: Array<EventLog> = [
  new EventLog('POI', '南京东路步行街', 31.2354, 121.4801, '演示事件'),
  new EventLog('Marker', '#0', 31.2445, 121.4651, '演示事件')
];

EventLog 是长按事件监听特性的数据载体。type 区分两种长按来源:‘Marker’ 表示长按的是路线点标注(onMarkerLongClick 触发),‘POI’ 表示长按的是地图 POI(onPoiLongClick 触发)。name 字段对两种类型有不同含义:Marker 类型存的是 #${marker.getId()}(标注 ID),POI 类型存的是 poi.name(POI 名称)。lat/lng 是事件发生位置的经纬度,time 是事件时间文案。这个模型的设计体现了"两种长按事件统一抽象"的思路——虽然 Marker 和 POI 是不同的地图对象,但作为"长按事件"它们被统一为同一种日志条目,方便用一个 ForEach 统一渲染。

EVENT_LOGS 是 2 条演示数据,让用户在打开地图 Tab 时就能看到日志流的形态,不至于是一片空白。这种"预置演示数据"的策略在演示型应用中很常见,它让特性可见性前置——用户不需要真的去长按地图就能理解"哦,长按会产生日志",降低了特性理解的门槛。

七、组件主体与状态声明

7.1 组件声明与 UI 状态

/** 漫步线 · CityWalk 路线主页面 */
@Entry
@Component
struct Page1141 {
  /** 当前选中 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 walkList: Array<WalkItem> = WALK_LIST;
  /** 我的页功能清单数据 */
  @State funcList: FuncItem[] = FUNC_LIST;

@Entry 装饰器声明这是页面入口组件,@Component 声明这是一个自定义组件,struct Page1141 是组件主体。ArkTS 用 struct 而非 class 声明组件,这是 ArkUI 的约定。组件内部用 @State 声明了一系列状态变量,分为三类:UI 导航状态(currentTab 当前 Tab 索引、cateIdx 筛选选中索引)、弹窗开关状态(addModal/editModal/delModal 三个布尔值控制三种弹窗显隐)、操作目标索引(editIdx/delIdx 标记当前编辑或删除的路线在列表中的位置)、列表数据(walkList 收藏路线、funcList 功能清单)。

@State 装饰器的语义是"组件内部状态"——被装饰的变量变化会触发依赖该变量的 UI 子树重渲染。比如 currentTab 从 0 变为 1,build 中的 if-else 分支就会从 tabWalk 切换到 tabMap;addModal 从 false 变为 true,Stack 顶层的 panelAdd 就会渲染出来。这种"状态驱动 UI"的模型让开发者只需要关心"状态怎么变",不用关心"DOM 怎么改",是 ArkUI 相对传统命令式框架最大的效率提升点。

7.2 Map Kit 状态声明

  // --- 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 formTag: string = '';
  /** 编辑弹窗:备注输入 */
  @State editNote: string = '';

这段声明了 Map Kit 相关的状态。mapOptions 是地图初始化参数,position.target 设为 CITY_CENTER(上海中心点),zoom 设为 13(街道级缩放)。注意 mapOptions 用 private 声明(非 @State),因为它是不可变的初始化参数,不需要响应式更新。mapCallback 用 private + 可选(?)声明,类型是 AsyncCallback<map.MapComponentController>,在 aboutToAppear 中赋值。mapController 和 mapEventManager 也是 private + 可选,因为它们在 mapCallback 回调中才被赋值——在组件构建时它们还是 undefined。

markerListenOn 和 poiListenOn 是 @State 布尔值,控制两种长按监听是否开启,初始值都是 true(默认开启)。它们的值会驱动地图 Tab 中两个 Toggle 开关的选中态,同时 toggleMarkerListen / togglePoiListen 方法会根据当前值决定是 off(注销监听)还是 on(注册监听)。eventLogs 是 @State 数组,存放长按事件日志,初始值是 EVENT_LOGS 演示数据,每次长按事件触发都会 unshift 一条新日志到数组头部(置顶)。queryInput 是搜索框输入值,初始值"步行路线"是一个语义性强的默认关键字。searchState 是搜索状态文案,会随 runSearch 的执行阶段变化(“待搜索"→"搜索中"→"返回 N 条结果"或"搜索失败”)。searchRecords 是搜索结果列表,初始值是 SEARCH_RECORDS 演示数据。formName/formAddr/formTag 和 editNote 是弹窗中输入框的受控值。

八、地图初始化与长按监听注册(6.1.1 新特性二)

8.1 setupMapCallback 方法

  /**
   * 地图初始化:controller → eventManager → Marker 群 → 6.1.1 双长按监听
   * 必须在 mapCallback 的 err 为空分支内注册监听(controller 就绪后才有管理器)
   */
  setupMapCallback() {
    this.mapCallback = async (err: BusinessError, mapController: map.MapComponentController) => {
      if (err) {
        console.error(`Map init failed, code: ${err.code}, message: ${err.message}`);
        return;
      }
      this.mapController = mapController;
      this.mapEventManager = mapController.getEventManager();
      // 批量添加路线点 Marker(addMarker 返回 Promise,逐个 await + try-catch)
      for (const spot of MARKER_SPOTS) {
        const markerOptions: mapCommon.MarkerOptions = {
          position: { latitude: spot.lat, longitude: spot.lng },
          clickable: true,
          visible: true,
          rotation: 0,
          zIndex: 0,
          alpha: 1,
          anchorU: 0.5,
          anchorV: 1,
          draggable: false,
          flat: false
        };
        try {
          await this.mapController.addMarker(markerOptions);
        } catch (e) {
          console.error(`addMarker failed: ${(e as BusinessError).message}`);
        }
      }
      // ★ 6.1.1 新特性·事件一:监听地图标记 Marker 的长按
      this.mapEventManager.onMarkerLongClick((marker: map.Marker) => {
        const pos: mapCommon.LatLng = marker.getPosition();
        this.eventLogs.unshift(new EventLog('Marker', `#${marker.getId()}`,
          pos.latitude, pos.longitude, '刚刚'));
      });
      // ★ 6.1.1 新特性·事件二:监听地图 POI 的长按(参数是 mapCommon.Poi)
      this.mapEventManager.onPoiLongClick((poi: mapCommon.Poi) => {
        this.eventLogs.unshift(new EventLog('POI', poi.name,
          poi.position.latitude, poi.position.longitude, '刚刚'));
      });
    };
  }

setupMapCallback 方法是整个地图能力初始化的入口,它在 aboutToAppear 生命周期中被调用,为 mapCallback 赋值一个异步箭头函数。这个异步函数的签名是 (err: BusinessError, mapController: map.MapComponentController) => void,符合 AsyncCallback 的定义。MapComponent 在渲染完成后会调用这个回调,把控制器实例传给开发者。

函数体第一步是错误判断:if (err) 表示地图初始化失败,打印错误码和消息后直接 return,不做后续操作。这是 AsyncCallback 模式的标准写法——err 非空意味着失败,此时传入的 mapController 可能是 undefined,强行使用会抛异常。只有在 err 为空(即 falsy)的分支里,才能安全地使用 mapController。

第二步是获取控制器与事件管理器:this.mapController = mapController 把回调传入的控制器存到组件实例,后续添加 Marker 用;this.mapEventManager = mapController.getEventManager() 通过控制器获取事件管理器,后续注册长按监听用。getEventManager 是 MapComponentController 的方法,返回 MapEventManager 实例——这是 6.1.1 长按监听能力的入口对象。

第三步是批量添加路线点 Marker:用 for…of 遍历 MARKER_SPOTS 数组,为每个路线点构造 markerOptions 并调用 addMarker。markerOptions 的 position 用路线点的 lat/lng,clickable 设为 true(让标注可点击,这是长按事件能触发的前提),visible 设为 true,rotation/zIndex/alpha 是标注的旋转角/层级/透明度,anchorU: 0.5 / anchorV: 1 是锚点比例(横向居中、纵向在底部,让标注图标的"针尖"对准坐标点),draggable: false(不可拖动),flat: false(不贴地)。addMarker 返回 Promise,所以用 await 等待,并包了 try-catch 防止单个标注添加失败影响后续。这种"逐个 await + try-catch"的写法虽然不如 Promise.all 并行高效,但容错性更好——某个标注失败不会中断整批。

第四步是注册 6.1.1 双长按监听,这是本方法的重点。this.mapEventManager.onMarkerLongClick 注册 Marker 长按监听,回调参数是 map.Marker 类型,通过 marker.getPosition() 拿到标注的经纬度,通过 marker.getId() 拿到标注 ID,构造一个 EventLog(type 为 ‘Marker’,name 为 #${id})并 unshift 到 eventLogs 数组头部。this.mapEventManager.onPoiLongClick 注册 POI 长按监听,回调参数是 mapCommon.Poi 类型,通过 poi.name 拿到 POI 名称,通过 poi.position.latitude/longitude 拿到经纬度,构造一个 EventLog(type 为 ‘POI’,name 为 POI 名称)并 unshift 到 eventLogs。两处 unshift 让最新事件永远在最上方,符合"日志流"的阅读习惯。

必须强调的是,这两行 onMarkerLongClick 和 onPoiLongClick 是 HarmonyOS 6.1.1 才新增的 API。在更低版本上调用会报"方法不存在"错误。它们必须写在 mapCallback 的 err 为空分支内——因为只有 controller 就绪后 getEventManager 才返回有效实例,在 controller 还未就绪时注册监听是无效的。这个时序约束是地图事件监听最容易踩坑的地方。

8.2 长按监听开关切换

  /** Marker 长按监听开关切换(off 不传参 = 清除该类型全部订阅) */
  toggleMarkerListen() {
    if (!this.mapEventManager) {
      return;
    }
    if (this.markerListenOn) {
      this.mapEventManager.offMarkerLongClick();
    } else {
      this.mapEventManager.onMarkerLongClick((marker: map.Marker) => {
        const pos: mapCommon.LatLng = marker.getPosition();
        this.eventLogs.unshift(new EventLog('Marker', `#${marker.getId()}`,
          pos.latitude, pos.longitude, '刚刚'));
      });
    }
    this.markerListenOn = !this.markerListenOn;
  }

  /** POI 长按监听开关切换(off 不传参 = 清除该类型全部订阅) */
  togglePoiListen() {
    if (!this.mapEventManager) {
      return;
    }
    if (this.poiListenOn) {
      this.mapEventManager.offPoiLongClick();
    } else {
      this.mapEventManager.onPoiLongClick((poi: mapCommon.Poi) => {
        this.eventLogs.unshift(new EventLog('POI', poi.name,
          poi.position.latitude, poi.position.longitude, '刚刚'));
      });
    }
    this.poiListenOn = !this.poiListenOn;
  }

toggleMarkerListen 方法实现 Marker 长按监听的动态开关。第一步先做防御:if (!this.mapEventManager) return,如果事件管理器还没就绪(比如 mapCallback 还没回调)就直接返回,避免空指针。第二步根据当前 markerListenOn 的值决定动作:如果当前是开启状态(true),调用 offMarkerLongClick 注销监听;如果当前是关闭状态(false),调用 onMarkerLongClick 重新注册监听(注册逻辑与 setupMapCallback 中一致)。第三步翻转 markerListenOn 的值。togglePoiListen 方法的逻辑与 toggleMarkerListen 完全对称,只是把 Marker 换成 POI、offMarkerLongClick 换成 offPoiLongClick。

这两处用到的 offMarkerLongClick 和 offPoiLongClick 是 6.1.1 新增的注销方法,调用时不传参数表示清除该类型的全部订阅回调。这种"无参 off 清除全部"的设计与 onClick / offClick 等既有事件监听 API 保持一致,降低了学习成本。动态开关的意义在于让用户能对比"开/关长按监听"时的行为差异——关掉后再长按地图不会产生日志,用户能直观感知监听是否生效,这是演示型应用常用的"可见性验证"手法。

九、关键字搜索与 reliability 读取(6.1.1 新特性一)

9.1 runSearch 方法

  /**
   * ★ 6.1.1 新特性·搜索:关键字搜索 searchByText → Site 数组
   * 读取 Site.reliability 相关性分数(可选字段,?? 兜底 0)
   * 无 AGC 配置/无网络时抛 BusinessError,catch 保留 Mock 数据保证演示链路
   */
  async runSearch() {
    this.searchState = '搜索中…';
    const params: site.SearchByTextParams = {
      query: this.queryInput,
      location: CITY_CENTER,
      radius: 5000,
      language: 'zh'
    };
    try {
      const result: site.SearchByTextResult = await site.searchByText(params);
      const sites: Array<site.Site> = result.sites ?? [];
      if (sites.length === 0) {
        this.searchState = '无结果 · 保留演示数据';
        return;
      }
      const records: Array<SearchRecord> = [];
      for (const s of sites) {
        records.push(new SearchRecord(
          s.name ?? '未命名地点',
          s.formatAddress ?? '暂无地址',
          s.distance ?? 0,
          s.reliability ?? 0,
          '刚刚'));
      }
      this.searchRecords = records;
      this.searchState = `返回 ${sites.length} 条结果`;
    } catch (e) {
      const err = e as BusinessError;
      this.searchState = `搜索失败(${err.code}) · 保留演示数据`;
    }
  }

runSearch 是 reliability 新特性在应用层的调用入口,是一个 async 方法。第一步更新 searchState 为"搜索中…“,让用户立刻看到状态变化(防止点击后无反馈的"卡顿感”)。第二步构造 SearchByTextParams 参数对象:query 是关键字(来自 queryInput),location 是 CITY_CENTER(搜索以上海中心为圆心),radius 是 5000(搜索半径 5000 米),language 是 ‘zh’(中文结果)。

第三步进入 try 块调用 site.searchByText(params),这是 Map Kit site 模块的关键字搜索 API,返回 SearchByTextResult 类型。result.sites 是 Site 数组,用 ?? [] 兜底空值。如果 sites.length === 0,更新 searchState 为"无结果 · 保留演示数据"并返回——注意这里没有清空 searchRecords,所以 UI 上仍保留之前的演示数据,避免页面变空。

第四步是 reliability 读取的核心:用 for…of 遍历 sites,为每个 Site 构造一个 SearchRecord。构造时每个字段都用 ?? 兜底:s.name ?? ‘未命名地点’(名称为空时显示"未命名地点"),s.formatAddress ?? ‘暂无地址’,s.distance ?? 0,s.reliability ?? 0。这里的 s.reliability ?? 0 是关键——reliability 是 6.1.1 新增的可选字段,在旧版本系统或某些 POI 类型上可能为 undefined,用空值合并运算符兜底为 0,保证后续 reliabilityScore(0) 能正常映射为"低相关"标签,而不是因为传入 undefined 抛错。这种"新字段必须兜底"的防御式写法,是处理版本新增 API 的通用范式。

第五步是 catch 块:如果 searchByText 抛异常(无 AGC 配置、无网络、服务不可用等),把异常 e as BusinessError 取出 code,更新 searchState 为"搜索失败(code) · 保留演示数据"。这里同样不清空 searchRecords,保留演示数据让 UI 链路完整。这种"失败兜底"策略让应用在没有真实地图服务环境时也能演示 reliability 特性的 UI 形态,是演示型应用的常见设计。

9.2 弹窗业务方法

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

  /** 保存收藏路线(空名兜底默认演示路线) */
  saveWalk() {
    const name = this.formName === '' ? '漫步线·新收藏路线' : this.formName;
    const tag = this.formTag === '' ? '待定' : this.formTag;
    const addr = this.formAddr === '' ? '上海市(地图选点)' : this.formAddr;
    this.walkList.unshift(new WalkItem(name, 3.5, 70, 6, `${tag} · ${addr}`));
    this.formName = '';
    this.formTag = '';
    this.formAddr = '';
    this.addModal = false;
  }

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

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

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

openEditWalk 接收路线索引,把它存到 editIdx,把当前路线的备注回填到 editNote(实现"编辑回填"),然后打开编辑弹窗。saveWalk 处理收藏新路线:对三个输入字段做空值兜底(name 默认"漫步线·新收藏路线",tag 默认"待定",addr 默认"上海市(地图选点)"),用 unshift 把新路线加到列表头部,清空表单字段,关闭弹窗。updateWalk 处理编辑保存:先做索引边界检查(editIdx >= 0 && < length),再更新 note,最后用 this.walkList = this.walkList.slice() 整体刷新数组引用——这是触发 @State 数组 UI 更新的关键,因为直接修改 walkList[idx].note 只改了元素属性、没改数组引用,@State 观察不到;slice() 返回新数组让引用变化,框架才重渲染。delWalk 用 splice 删除指定索引的路线。aboutToAppear 是生命周期钩子,在组件创建后、build 前调用,这里调用 setupMapCallback 完成地图回调初始化。

十、页面主构建 build

  /** 页面主构建:Stack 包裹主内容与三层弹窗 */
  build() {
    Stack() {
      Column() {
        this.headerMain()
        Divider().strokeWidth(1).color(COLORS.line)
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.tabWalk()
            } 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 作为根容器。Stack 是层叠布局,后入的子元素在上方——所以 Column(主内容)在最下层,三个弹窗(panelAdd/panelEdit/panelDel)根据各自的布尔状态条件渲染在主内容之上,形成"弹窗浮层"效果。Column 内部从上到下是 headerMain(头部)、Divider(分割线)、Scroll(可滚动内容区,layoutWeight(1) 占满剩余高度)、tabBar(底部导航)。

Scroll 内的 Column 根据 currentTab 用 if-else 分支渲染四个 Tab Builder 之一。这种 if-else 而非 Tabs 组件的写法是刻意的——Tabs 组件会同时构建所有 Tab 页,而 if-else 只构建当前 Tab,对包含 MapComponent 这种重资源的页面更友好(切到地图 Tab 才构建地图,切走就销毁)。三个弹窗的渲染条件是各自的 @State 布尔值,传入的箭头函数是"关闭弹窗"的回调(把对应布尔值设为 false),弹窗内部点击遮罩或取消按钮会调用这个回调。Stack 的 backgroundColor 设为 COLORS.bg(晨雾白),让整个页面底色统一。

十一、头部 Builder

  /** 头部:渐变 Banner(今日漫步计划+收藏路线数)+ 筛选 chips 横滑 */
  @Builder
  headerMain() {
    Column({ space: 12 }) {
      // 顶部渐变 Banner:今日路线 + 收藏路线数 + 出行贴士
      Column({ space: 10 }) {
        Row({ space: 12 }) {
          Column({ space: 2 }) {
            Text('6.8km').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('梧桐区小环线 · 95 分钟').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            }
            Row({ space: 6 }) {
              Text('⭐').fontSize(12)
              Text('收藏路线 8 条 · 3 条新解锁').fontSize(11).fontColor(COLORS.sub)
            }
            Row({ space: 6 }) {
              Text('🌤').fontSize(12)
              Text('22℃ 微风 · 适宜漫步出行').fontSize(11).fontColor(COLORS.sub)
            }
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')
        Row({ space: 8 }) {
          Text('🚶 开始漫步').fontSize(12).fontColor(COLORS.btnText).fontWeight(FontWeight.Bold)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .borderRadius(16).backgroundColor(COLORS.pink)
          Text('🗺 地图看线').fontSize(12).fontColor(COLORS.pink)
            .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.pinkD, 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.btnText : COLORS.sub)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .borderRadius(14)
              .backgroundColor(this.cateIdx === idx ? COLORS.pink : COLORS.chip)
              .onClick(() => { this.cateIdx = idx; })
          }, (tag: string) => tag)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .padding({ left: 14, right: 14, top: 12, bottom: 8 })
    .width('100%')
  }

headerMain 是头部 Builder,用 @Builder 装饰。它由两部分组成:渐变 Banner 和筛选 chips 横滑区。渐变 Banner 是一个 Column,内含两个 Row:第一行是"今日漫步计划"信息块——左侧大字号"6.8km"配"今日漫步计划"小字,右侧三行信息(路线名+耗时、收藏路线数、天气贴士),用 layoutWeight(1) 让右侧占满剩余宽度;第二行是两个快捷入口胶囊,"开始漫步"是粉底白字主按钮,"地图看线"是浅底粉字次按钮,点击切到地图 Tab。Banner 用 linearGradient 设置 135 度渐变,起点是 pinkD(深霞光粉)占 0%,到 70% 处过渡到 card(白色),形成"左上深粉到右下白"的晨霞渐变效果,呼应"晨雾白+霞光粉"的主题。

筛选 chips 横滑区是一个横向 Scroll 包 Row 包 ForEach 的结构。ForEach 遍历 CATE_TAGS 数组,为每个 tag 渲染一个 Text 胶囊。胶囊的选中态由 cateIdx 决定:选中时 fontColor 为 btnText(白)、backgroundColor 为 pink(霞光粉);未选中时 fontColor 为 sub(灰)、backgroundColor 为 chip(浅底)。点击切换 cateIdx。Scroll 的 scrollable 设为 Horizontal 实现横向滑动,scrollBar 设为 Off 隐藏滚动条保持简洁。这种"横滑筛选 chips"是生活方式类应用的常见模式,让用户能快速按主题(梧桐区/沿江步道/老弄堂等)筛选路线。

十二、路线 Tab Builder

  /** 路线 Tab:漫步统计三宫格 + 推荐路线 + 收藏路线列表(业务主 Tab) */
  @Builder
  tabWalk() {
    Column({ space: 10 }) {
      // 漫步统计三宫格(收藏路线/本月公里/打卡机位)
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('8').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.pink)
          Text('收藏路线(条)').fontSize(9).fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(10)
        .backgroundColor(COLORS.card)
        Column({ space: 2 }) {
          Text('42').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.wisteria)
          Text('本月漫步(km)').fontSize(9).fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(10)
        .backgroundColor(COLORS.card)
        Column({ space: 2 }) {
          Text('96').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.green)
          Text('打卡机位(个)').fontSize(9).fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(10)
        .backgroundColor(COLORS.card)
      }
      .width('100%')
      // 区块标题行:更多入口
      Row() {
        Text('今日推荐路线').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Blank()
        Text('收藏新路线 +').fontSize(11).fontColor(COLORS.pink)
          .onClick(() => { this.addModal = true; })
      }
      .width('100%')
      // 推荐路线大卡(3 条精选)
      ForEach(WALK_RECS, (rec: WalkRec) => {
        Row({ space: 10 }) {
          Text(rec.icon).fontSize(26)
          Column({ space: 4 }) {
            Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text(`全程 ${rec.km} · 约 ${rec.minutes}`).fontSize(11).fontColor(COLORS.sub)
            Row({ space: 6 }) {
              Text(`${rec.spots} 个打卡点`).fontSize(10).fontColor(spotColor(rec.spots))
              Text(rec.level).fontSize(10).fontColor(levelColor(rec.level))
            }
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Column({ space: 4 }) {
            Text('走线').fontSize(11).fontColor(COLORS.btnText).fontWeight(FontWeight.Bold)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .borderRadius(12).backgroundColor(COLORS.pink)
              .onClick(() => { this.currentTab = 1; })
          }
        }
        .padding(12)
        .borderRadius(12)
        .backgroundColor(COLORS.card)
        .width('100%')
      }, (rec: WalkRec) => rec.name)
      // 全部路线列表(长按 Marker 的数据同源)
      Row() {
        Text('全部路线(地图 Marker 同源)').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      }
      .width('100%')
      ForEach(this.walkList, (walk: WalkItem, idx: number) => {
        Column({ space: 8 }) {
          Row({ space: 10 }) {
            Column({ space: 3 }) {
              Text(walk.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
              Text(`全程 ${walk.km.toFixed(1)}km · 约 ${walk.minutes} 分钟`).fontSize(11).fontColor(COLORS.sub)
              Text(`备注:${walk.note}`).fontSize(10).fontColor(COLORS.text3)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column({ space: 4 }) {
              Text(`${walk.spots}`).fontSize(16).fontWeight(FontWeight.Bold)
                .fontColor(spotColor(walk.spots))
              Text('打卡点').fontSize(9).fontColor(COLORS.text3)
            }
          }
          .width('100%')
          Row({ space: 8 }) {
            Text(`${walk.km.toFixed(1)}km`).fontSize(10)
              .fontColor(spotColor(walk.spots))
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10).backgroundColor(COLORS.chip)
            Text('编辑').fontSize(10).fontColor(COLORS.blue)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10).backgroundColor(COLORS.chip)
              .onClick(() => { this.openEditWalk(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; })
          }
          .justifyContent(FlexAlign.End)
          .width('100%')
        }
        .padding(12)
        .borderRadius(12)
        .backgroundColor(COLORS.card)
        .width('100%')
      }, (walk: WalkItem) => walk.name)
    }
    .width('100%')
  }

tabWalk 是路线 Tab 的 Builder,是业务主 Tab。它由四个区块组成。第一个区块是漫步统计三宫格,一个 Row 包三个 Column,每个 Column 用 layoutWeight(1) 等分宽度,白底圆角,内容是大字号数字配小字标签——收藏路线 8 条(粉)、本月漫步 42km(紫)、打卡机位 96 个(绿),数字颜色分别用 pink/wisteria/green 形成色彩区分。第二个区块是标题行,"今日推荐路线"在左,"收藏新路线 +"在右(点击打开收藏弹窗),中间用 Blank 占位实现两端对齐。

第三个区块是推荐路线大卡列表,用 ForEach 遍历 WALK_RECS,每条卡是一个 Row:左侧 emoji 图标(26 字号),中间是路线名+全程耗时+打卡点数(spotColor 染色)+路线强度(levelColor 染色),右侧是"走线"粉色按钮(点击切到地图 Tab)。这里 spotColor 和 levelColor 两个辅助函数让每条卡的打卡点数和强度标签有了颜色语义——打卡点多的绿、少的红,夜行的紫、街拍的红、休闲的绿,用户扫一眼就能对比三条路线的"丰富度"与"风格"。第四个区块是全部收藏路线列表,用 ForEach 遍历 walkList,每条卡显示路线名、全程耗时、备注、打卡点数(大字号染色),底部三个操作胶囊(公里数染色、编辑蓝色、删除红色)。编辑点击调用 openEditWalk(idx),删除点击设置 delIdx 并打开确认弹窗。列表项的 key 用 walk.name 保证唯一性。

十三、地图 Tab Builder(6.1.1 长按事件特性页)

  /** 地图 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.pink)
            .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.pink)
            .width(36)
            .height(20)
            .onChange(() => { this.togglePoiListen(); })
          Text('POI长按').fontSize(11).fontColor(COLORS.sub)
        }
      }
      .width('100%')
      // ★ MapComponent 本体(layoutWeight(1) 占满剩余高度)
      MapComponent({ mapOptions: this.mapOptions, mapCallback: this.mapCallback })
        .layoutWeight(1)
        .width('100%')
        .borderRadius(12)
      // 长按事件日志流(固定高度可滚动,新事件置顶)
      Column({ space: 6 }) {
        Row() {
          Text('长按事件日志流').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
          Blank()
          Text(`${this.eventLogs.length}`).fontSize(10).fontColor(COLORS.text3)
        }
        .width('100%')
        Scroll() {
          Column({ space: 6 }) {
            ForEach(this.eventLogs, (log: EventLog) => {
              Row({ space: 8 }) {
                Text(log.type === 'Marker' ? '📍' : '🏷')
                  .fontSize(12)
                Column({ space: 2 }) {
                  Row({ space: 6 }) {
                    Text(log.type).fontSize(10).fontColor(log.type === 'Marker' ? COLORS.pink : COLORS.wisteria)
                    Text(log.name).fontSize(11).fontColor(COLORS.title)
                    Text(log.time).fontSize(9).fontColor(COLORS.text3)
                  }
                  Text(`${log.lat.toFixed(4)}, ${log.lng.toFixed(4)}`)
                    .fontSize(9).fontColor(COLORS.text3).fontFamily('monospace')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
              }
              .padding({ left: 8, right: 8, top: 6, bottom: 6 })
              .borderRadius(8)
              .backgroundColor(COLORS.card)
              .width('100%')
            }, (log: EventLog) => `${log.type}-${log.name}-${log.time}`)
          }
        }
        .scrollBar(BarState.Off)
        .height(120)
        .width('100%')
      }
      .padding(10)
      .borderRadius(12)
      .backgroundColor(COLORS.chip)
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

tabMap 是地图 Tab 的 Builder,是 6.1.1 长按事件监听特性的展示页。它由四个区块组成,从上到下依次是:特性说明卡、监听开关行、MapComponent 本体、长按事件日志流。特性说明卡是一个浅底圆角 Column,标题"Map Kit 6.1.1 · 长按事件监听"加一行说明文案,让用户一进入这个 Tab 就知道这里演示的是什么特性。

监听开关行是一个 Row 包两组 Toggle+Text:Marker 长按开关(Toggle 的 isOn 绑定 markerListenOn,selectedColor 为 pink,onChange 调用 toggleMarkerListen)和 POI 长按开关(isOn 绑定 poiListenOn,onChange 调用 togglePoiListen)。这两个开关让用户能动态控制长按监听的注册与注销,是"特性可见性"的关键交互——用户可以关掉监听后长按地图验证"确实没有日志产生",再打开验证"日志恢复产生",从而直观理解 on/off 配对方法的作用。

MapComponent 是地图组件本体,传入 mapOptions(初始化参数)和 mapCallback(初始化回调),用 layoutWeight(1) 占满中间剩余高度,borderRadius(12) 圆角。这是整个页面的"地图画布",所有 Marker 都画在它上面,所有长按事件都从它产生。mapCallback 在 aboutToAppear 中通过 setupMapCallback 赋值,MapComponent 渲染完成后回调,完成 controller 获取、eventManager 获取、Marker 添加、双长按监听注册的完整初始化链路。

长按事件日志流是一个固定高度 120 的可滚动 Column,标题行显示"长按事件日志流"和总数(eventLogs.length),下方 Scroll 包 ForEach 渲染每条日志。每条日志是一个 Row:左侧 emoji(Marker 用 📍、POI 用 🏷),右侧 Column 显示类型标签(Marker 染 pink、POI 染 wisteria)、名称、时间、经纬度(toFixed(4) 保留 4 位小数,monospace 等宽字体)。日志按 unshift 置顶顺序展示,最新事件在最上方。日志项的 key 用 ${type}-${name}-${time} 保证唯一性。这个日志流是长按事件特性的"可视化证据"——每次长按地图都会在这里产生一条记录,让特性效果可见可验证。

十四、搜索 Tab Builder(6.1.1 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.pink)
          .onClick(() => { this.runSearch(); })
      }
      .width('100%')
      // 搜索状态文案
      Text(this.searchState).fontSize(10).fontColor(COLORS.text3).width('100%')
      // 搜索结果列表(reliability 分数条 + 等级标签,List 子项必须用 ListItem 包裹)
      List({ space: 8 }) {
        ForEach(this.searchRecords, (rec: SearchRecord) => {
          ListItem() {
            Column({ space: 6 }) {
              Row() {
                Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
                  .layoutWeight(1)
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Text(reliabilityScore(rec.reliability).label)
                  .fontSize(10)
                  .fontColor(reliabilityScore(rec.reliability).color)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(8)
                  .backgroundColor(COLORS.card)
              }
              .width('100%')
              Text(rec.address).fontSize(11).fontColor(COLORS.sub).width('100%')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              // ★ reliability 分数条:0~1 映射为线性进度 + 数值文本
              Row({ space: 8 }) {
                Progress({ value: rec.reliability * 100, total: 100, type: ProgressType.Linear })
                  .layoutWeight(1)
                  .height(6)
                  .color(reliabilityScore(rec.reliability).color)
                Text(`reliability ${rec.reliability.toFixed(2)}`)
                  .fontSize(10)
                  .fontColor(COLORS.sub)
                  .fontFamily('monospace')
              }
              .width('100%')
              Row({ space: 10 }) {
                Text(`直线距离 ${(rec.distance / 1000).toFixed(2)}km`).fontSize(10).fontColor(COLORS.text3)
                Text(rec.time).fontSize(10).fontColor(COLORS.text3)
              }
              .width('100%')
            }
            .padding(12)
            .borderRadius(12)
            .backgroundColor(COLORS.card)
            .width('100%')
          }
        }, (rec: SearchRecord) => `${rec.name}-${rec.reliability}`)
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .width('100%')
      // 双特性代码预览卡(体现技术点)
      this.codePreviewCard()
    }
    .width('100%')
    .height('100%')
  }

tabSearch 是搜索 Tab 的 Builder,是 6.1.1 reliability 相关性分数特性的展示页。它由五个区块组成:特性说明卡、搜索框+按钮、搜索状态文案、搜索结果列表、代码预览卡。特性说明卡标题"searchByText · reliability 相关性评分"加说明文案,明确这个 Tab 演示的是 Site 新增的 reliability 字段。

搜索框+按钮是一个 Row:TextInput 绑定 queryInput(onChange 更新),Button 点击调用 runSearch。TextInput 的 text 参数用 this.queryInput 实现受控输入,placeholder 提示"输入关键字,如:步行街"。搜索状态文案是一个 Text,绑定 searchState,会随 runSearch 执行阶段变化("待搜索 · 演示数据"→"搜索中…“→"返回 N 条结果"或"搜索失败(code) · 保留演示数据”),让用户始终知道当前搜索状态。

搜索结果列表是 reliability 特性的核心展示区。用 List 包 ForEach,每个结果项必须用 ListItem 包裹(ArkUI 的 List 容器要求子项是 ListItem,这是与 Column+ForEach 的区别)。每个 ListItem 内是一个 Column:第一行是结果名称(maxLines 1 + Ellipsis 单行省略)配等级标签(reliabilityScore(rec.reliability).label,颜色用 reliabilityScore 的 color);第二行是地址(单行省略);第三行是 reliability 分数条——Progress 组件 value 是 rec.reliability * 100(把 0~1 映射到 0~100),total 是 100,type 是 Linear(线性进度条),color 用 reliabilityScore 的 color,右侧配 reliability 0.94 这样的数值文本(toFixed(2) 保留两位小数,monospace 等宽字体);第四行是直线距离(米转公里,除以 1000 保留两位小数)和时间。分数条+标签+数值文本三重视觉反馈,让 reliability 这个抽象分数变得直观可读——用户一眼就能看到哪条结果"绿条长"(高相关)、哪条"红条短"(低相关),辅助决策是否点开。

代码预览卡放在列表下方,调用 codePreviewCard 这个 Builder,用深底 monospace 展示 6.1.1 双新特性的核心调用代码,让用户在 UI 之外也能看到"这些特性是怎么调用的",是演示型应用的"教学性"设计。

十五、我的 Tab 与代码预览卡 Builder

  /** 我的 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('紫金级 · 已解锁 5 条限定漫步线').fontSize(11).fontColor(COLORS.sub)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')
        Divider().strokeWidth(1).color(COLORS.line)
        Row() {
          Text('本月漫步 42km').fontSize(11).fontColor(COLORS.sub)
          Blank()
          Text('消耗约 2520 千卡').fontSize(11).fontColor(COLORS.pink)
        }
        .width('100%')
      }
      .padding(14)
      .borderRadius(14)
      .linearGradient({
        angle: 135,
        colors: [[COLORS.pinkD, 0.0], [COLORS.card, 0.75]]
      })
      .width('100%')
      // 漫步统计三宫格
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('8').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.pink)
          Text('收藏路线').fontSize(9).fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(10)
        .backgroundColor(COLORS.card)
        Column({ space: 2 }) {
          Text('42').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.wisteria)
          Text('本月公里').fontSize(9).fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(10)
        .backgroundColor(COLORS.card)
        Column({ space: 2 }) {
          Text('36').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.green)
          Text('打卡机位').fontSize(9).fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(10)
        .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%')
  }

  /** 双特性代码预览卡(深色底 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.wisteria).fontFamily('monospace')
        Text('const score = site.reliability  // [0,1] 路线相关度')
          .fontSize(9).fontColor(COLORS.wisteria).fontFamily('monospace')
        Text('eventManager.onMarkerLongClick(cb)  // 路线点长按')
          .fontSize(9).fontColor(COLORS.pink).fontFamily('monospace')
        Text('eventManager.onPoiLongClick(cb)     // 沿线POI长按')
          .fontSize(9).fontColor(COLORS.pink).fontFamily('monospace')
      }
      .padding(10)
      .borderRadius(8)
      .backgroundColor(COLORS.codeBg)
      .width('100%')
    }
    .padding(10)
    .borderRadius(10)
    .backgroundColor(COLORS.chip)
    .width('100%')
  }

tabMine 是我的 Tab 的 Builder。顶部是会员渐变大卡,与头部 Banner 采用相同的 135 度渐变(pinkD 到 card),形成视觉呼应。卡内是行者会员标题、紫金级等级、本月漫步公里与消耗千卡。中间是漫步统计三宫格(收藏路线 8、本月公里 42、打卡机位 36),与路线 Tab 的三宫格数据略有不同(打卡机位从 96 变 36,因为这里展示的是"我的"个人数据而非全平台数据)。下方是功能清单,用 ForEach 遍历 funcList,每条是一个 Row:emoji 图标、功能名(layoutWeight 占满)、状态数值、右箭头 ›。这种"图标+名称+数值+箭头"的行布局是设置页/个人中心的经典范式。

codePreviewCard 是双特性代码预览卡,用深底(codeBg #3A2E36)monospace 字体展示 6.1.1 两项新特性的核心调用代码:site.searchByText 调用、site.reliability 字段读取(紫藤紫染色)、eventManager.onMarkerLongClick 和 onPoiLongClick 注册(霞光粉染色)。用颜色区分两个特性——搜索特性用紫藤紫、事件特性用霞光粉,与页面的双强调色对应。这张卡放在搜索 Tab 底部,让用户在看 reliability 分数条之余,也能看到"这个分数是怎么来的"的代码层面调用,是"UI 演示+代码教学"双轨设计的体现。

十六、底部导航与弹窗 Builder 群

16.1 底部导航

  /** 底部导航 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)
  }

tabBar 是底部导航栏,一个 Row 包 ForEach 遍历 TAB_LIST,每个 Tab 是一个 Column:emoji 图标(18 字号)+ 文字标签(10 字号)。文字颜色根据 currentTab 是否等于当前 idx 决定——选中用 tabOn(霞光粉),未选中用 text3(弱灰)。每个 Tab 用 layoutWeight(1) 等分宽度,onClick 切换 currentTab。整个 tabBar 白底,上下 padding 8,固定在页面底部。这种"单排 4 Tab"是移动端最常见的导航范式,简单直接。

16.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: '路线主题(如:梧桐区/夜行)', text: this.formTag })
          .height(38).fontSize(12).fontColor(COLORS.title)
          .placeholderColor(COLORS.text3).backgroundColor(COLORS.chip)
          .onChange((v: string) => { this.formTag = 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.pink)
            .onClick(() => { this.saveWalk(); })
        }
        .width('100%')
      }
      .padding(16)
      .borderRadius(14)
      .backgroundColor(COLORS.card)
      .width('82%')
    }
    .width('100%')
    .height('100%')
  }

modalOverlay 是弹窗遮罩层 Builder,接收一个 onClose 回调。它是一个全屏 Column,背景色为 mask(半透明深色),onClick 调用 onClose 实现点击空白处关闭弹窗。这个 Builder 被三个弹窗复用,是弹窗系统的"底层公共件"。

panelAdd 是收藏路线弹窗,用 Stack 层叠 modalOverlay(遮罩在下)和内容 Column(弹窗卡在上)。内容卡宽 82%(两侧留白让遮罩可见),白底圆角 16 padding。卡内是标题+三个 TextInput(路线名/主题/地址,分别绑定 formName/formTag/formAddr)+ 取消/收藏按钮行。取消按钮调用 onClose,收藏按钮调用 saveWalk。三个输入框都用 text 参数实现受控输入,backgroundColor 用 chip(浅底)与卡片白底形成层次。注意这里所有 TextInput 的 text 都绑定了 @State 变量,onChange 回写——这是 ArkUI 受控输入的标准模式,保证输入框值与状态始终同步,saveWalk 时能从状态直接读取。

16.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.walkList.length ? this.walkList[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.pink)
            .onClick(() => { this.updateWalk(); })
        }
        .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.walkList.length
          ? `确定删除「${this.walkList[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.delWalk(); })
        }
        .width('100%')
      }
      .padding(16)
      .borderRadius(14)
      .backgroundColor(COLORS.card)
      .width('82%')
    }
    .width('100%')
    .height('100%')
  }

panelEdit 是编辑备注弹窗,结构与 panelAdd 类似但只有一个 TextInput(备注输入,绑定 editNote)。标题下方显示当前编辑的路线名(用 this.editIdx < this.walkList.length ? ... : '' 做边界检查防止越界),这个路线名是 openEditWalk 时存入 editIdx 后从 walkList 取的,实现"编辑回填"——用户打开弹窗就能看到当前备注内容。保存按钮调用 updateWalk,内部用 slice() 刷新数组引用触发更新。

panelDel 是删除确认弹窗,结构与前两个弹窗一致。标题"删除收藏路线",提示文案用模板字符串显示要删除的路线名(确定删除「路线名」吗?,同样做了边界检查)。删除按钮用红色 backgroundColor(COLORS.red),与取消的浅底形成"危险操作"的视觉警示。删除按钮调用 delWalk,内部用 splice 删除。三个弹窗的统一结构(Stack+modalOverlay+内容卡+取消/确认按钮行)体现了"弹窗组件化复用"的设计思路——虽然 ArkUI 的 @Builder 不能像组件那样参数化复用,但通过 onClose 回调参数已经实现了最小化的复用契约。

十七、HarmonyOS 6.1.1 双新特性对比

对比维度 site.searchByText · reliability 相关性分数 MapEventManager 长按监听(onMarkerLongClick / onPoiLongClick)
特性类别 搜索结果可量化 交互事件维度补全
所属模块 @kit.MapKit 的 site 子模块 @kit.MapKit 的 map 子模块(MapEventManager)
新增字段/方法 Site 类型新增 reliability: number 字段 新增 on/offMarkerLongClick、on/offPoiLongClick 共 4 个方法
取值/参数 [0,1] 浮点,1 为完全相关,可选字段(需 ?? 兜底) on 接收回调(Marker→map.Marker,POI→mapCommon.Poi),off 无参清除全部
触发时机 searchByText 返回结果时随 Site 携带 用户长按地图上的 Marker 或 POI 时触发
解决问题 搜索结果质量难量化、字面命中与语义无关难区分 单击与长按语义重叠、长按详情/收藏交互缺失
UI 落地方式 分数条(Progress 线性进度)+ 等级标签 + 数值文本三重展示 事件日志流(unshift 置顶)+ Toggle 动态开关
辅助函数 reliabilityScore 映射高/中/低三档标签与颜色 toggleMarkerListen / togglePoiListen 动态注册与注销
失败兜底 catch 保留 Mock 数据、reliability ?? 0 兜底空值 mapEventManager 为空时 return、off 无参清除全部
数据载体 @Observed SearchRecord(含 reliability 字段) @Observed EventLog(含 type/name/lat/lng/time)
版本依赖 HarmonyOS 6.1.1 及以上 HarmonyOS 6.1.1 及以上
适用场景 按关键字筛选路线、过滤字面命中但语义无关的结果 长按路线点收藏途经点、长按 POI 记录路线节点

十八、总结

本文围绕 HarmonyOS 6.1.1 Map Kit 的两项新特性,以"漫步线·CityWalk 路线"这一城市漫步路线规划服务为载体,完整剖析了一个四 Tab 单页应用的工程实现。第一项新特性是 site 模块 searchByText 返回的 Site 类型新增的 reliability 相关性分数字段,取值 [0,1],1 表示完全相关。这个字段让搜索结果有了量化依据——过去开发者拿到一串 POI 结果后,只能靠名称字面匹配或距离排序来判断相关性,但"步行路线"这样的语义性关键字会混入"跑步机专卖店""观光巴士售票亭"等字面命中却语义无关的结果。reliability 字段由地图服务的语义匹配引擎计算,综合关键字分词、地理位置 proximity、POI 类型等多维因素,让应用能在搜索阶段就过滤低相关结果,并通过 reliabilityScore 函数映射为"高/中/低"三档等级标签与颜色,配合 Progress 线性进度条形成分数条,让用户一眼就能判断哪条结果值得点开。

第二项新特性是 MapEventManager 新增的 onMarkerLongClick / offMarkerLongClick 和 onPoiLongClick / offPoiLongClick 两对长按监听方法。在此之前 MapEventManager 已有 onClick、onMarkerClick、onPoiClick 等单击监听,但在 CityWalk 这类需要"长按路线点查看详情""长按 POI 收藏为途经点"的场景下,单击与长按存在语义重叠与误触风险。新增的长按监听让交互手势更贴近原生习惯——onMarkerLongClick 回调接收 map.Marker 参数,可取出标注 ID 与经纬度;onPoiLongClick 回调接收 mapCommon.Poi 参数,可取出 POI 名称与位置。配套的 off 方法不传参即可清除该类型全部订阅,与既有事件 API 风格一致。本文代码在 mapCallback 回调中注册双长按监听,并通过 toggleMarkerListen / togglePoiListen 两个方法配合 Toggle 开关实现动态注册与注销,让用户能直观验证监听的生效与失效,事件则通过 unshift 置顶到 eventLogs 日志流形成可视化证据。

从架构层面看,这份代码体现了几个值得借鉴的工程实践。第一是"数据同源"——MARKER_SPOTS 既是地图标注的数据源,又是长按事件的数据来源,路线 Tab 的列表与地图 Tab 的 Marker 引用同一份路线点数据,避免多份数据不一致。第二是"新字段必须兜底"——reliability 是 6.1.1 新增的可选字段,代码用 s.reliability ?? 0 兜底空值,保证在旧版本系统或某些 POI 类型上字段为 undefined 时也能正常映射为"低相关"标签,这是处理版本新增 API 的通用范式。第三是"失败保留演示数据"——runSearch 在无结果或抛异常时都不清空 searchRecords,保留 Mock 演示数据让 UI 链路完整,这在演示型应用中很重要,让应用在没有真实地图服务环境时也能展示特性形态。第四是"数组整体刷新引用"——updateWalk 用 this.walkList = this.walkList.slice() 整体刷新数组引用触发 @State 更新,这种"笨办法"比依赖 @Observed 的深层观察更稳妥。

从主题设计看,晨雾白(#FBF8F6)+ 霞光粉(#E8899B)+ 紫藤紫(#8B7BC5)的浅色色板不是随意取色,而是围绕"城市漫步"的情绪意象系统调配的——晨雾白营造清晨的温润感,霞光粉呼应日出与晚霞的浪漫,紫藤紫对应夜行线的沉静。reliability 三档颜色(高相关绿/中相关黄/低相关红)与打卡点数三档颜色、路线强度三档颜色共用一套语义色系,让整个应用的色彩语言统一可读。头部 Banner 与会员卡的 135 度渐变(pinkD 到 card)形成"晨霞"视觉母题,在页面关键位置反复出现,强化品牌识别。

从交互设计看,这份代码把两项新特性都做成了"可见可验证"的形态——reliability 用分数条+标签+数值三重展示,让抽象分数变直观;长按事件用日志流+Toggle 开关让事件产生与注销都可见。这种"特性可见性"设计对演示型应用至关重要,它让用户在不需要理解 API 细节的情况下,就能感知"这个特性在做什么、什么时候触发、怎么关闭"。配合搜索 Tab 底部的代码预览卡,又把"特性怎么调用"的代码层面也展示出来,形成"UI 演示+代码教学"的双轨设计,既照顾普通用户的直观体验,也照顾开发者的学习需求。整体而言,这份代码是一个将 Map Kit 6.1.1 新特性与 CityWalk 业务场景深度融合的范例,对希望落地 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、测试、元服务和应用上架分发等。

更多推荐