HarmonyOS 6.1.1 Map Kit 长按监听 × reliability 相关性评分:宠物医疗门店检索场景下用 ArkUI 解决搜索结果可信度量化与地图地点留痕两大痛点
一、技术前言

HarmonyOS 的 ArkUI 框架是华为面向全场景分布式应用打造的声明式 UI 开发范式,其核心思想是用 @Entry、@Component、@State、@Builder、@Observed 等一组装饰器,让开发者以接近自然语言的方式声明界面在任何状态下的最终形态,再由框架的差分渲染引擎自动计算最小变更集合并完成真实组件树的更新。与传统的命令式 UI 框架相比,声明式范式最大的优势在于"界面是什么"与"界面怎么变"被彻底解耦——开发者只需要描述界面的目标形态,状态变量一旦变更,所有依赖它的 UI 片段都会自动重新渲染。在宠物医疗门店这类信息密度高、状态联动频繁的场景里,这种范式尤其具有优势:当前选中 Tab 的切换、收藏门店弹窗的开关、编辑备注的回填、搜索结果 reliability 分数的实时刷新、长按事件日志流的 unshift 置顶,都需要多源数据同步,声明式绑定让这些联动变得可控且不易出错。

Map Kit 是 HarmonyOS 提供的原生地图服务 SDK,它把"地图渲染 + 地理检索 + 交互事件"三件套以组件化、API 化的方式开放给 ArkUI 应用。在 ArkUI 侧,MapComponent 是一个可以直接放入 build() 树的原生地图组件,通过 mapOptions 声明初始化的视角与缩放层级,通过 mapCallback 回调拿到 MapComponentController 控制器实例;在服务侧,site 模块提供 searchByText(关键字检索)、searchByTextParams(检索参数封装)、Site(地点结果模型)等一系列地理检索能力,mapCommon 与 map 模块则提供坐标点 LatLng、MarkerOptions、Marker、Poi、MapEventManager 等地图对象与事件管理器。这种"组件 + 控制器 + 服务模块"的三层架构让地图能力可以无缝嵌入 ArkUI 的响应式体系,是宠物门店地图标注、长按交互、关键字搜索等场景的基础设施。

HarmonyOS 6.1.1 在 site 模块的 searchByText 接口上做了一项重要的能力增强——返回结果 Site 类型新增了 reliability 字段。reliability 是一个取值范围为 [0, 1] 的相关性分数,1 表示该结果与用户输入的关键字完全相关,0 表示完全不相关。在过去,地图搜索接口只返回地点名称、地址、距离这些"硬信息",开发者拿到结果列表后通常只能按距离排序展示,无法判断"这家店到底是不是真的宠物医院"。而 reliability 字段的出现,让应用可以量化每条搜索结果与"宠物医院"这一关键字的关联程度——例如"南山路宠安宠物医院"的 reliability 可能高达 0.93(高相关),而"宠物摄影工作室(非医疗)"的 reliability 只有 0.06(低相关)。这个分数让应用可以做两件事:一是按 reliability 倒序排序,把真正相关的门店顶到最上方;二是用分数条 + 等级标签的方式可视化呈现,让用户一眼看出哪些结果可信、哪些需要谨慎对待。

HarmonyOS 6.1.1 在 MapEventManager 上同步做了另一项能力增强——新增 onMarkerLongClick / offMarkerLongClick(地图标记长按监听)和 onPoiLongClick / offPoiLongClick(地图 POI 长按监听)两组事件接口。在此之前,地图侧只能监听 Marker 与 POI 的单击(onMarkerClick / onPoiClick),而长按作为一种"更重意图"的交互手势一直没有原生支持。6.1.1 补齐了这一空白:onMarkerLongClick 的回调参数是 map.Marker,可通过 marker.getId() 拿到标记 ID、通过 marker.getPosition() 拿到经纬度;onPoiLongClick 的回调参数是 mapCommon.Poi,包含 poi.name(POI 名称)与 poi.position(POI 坐标)。offMarkerLongClick 与 offPoiLongClick 不传参即可清除该类型的全部订阅。在宠物门店场景中,这一对手势的价值在于"长按留痕"——用户在地图上看到某家门店或某个 POI,长按即可把它的坐标与名称记录到事件日志流,相当于一种快速的地图标记收藏,省去了打开门店详情页的跳转成本。

宠物医疗与护理服务是一个高度依赖"地理位置 + 可信度"的行业。养宠用户的核心诉求集中在三个场景:其一是"就近找医院"——宠物突发疾病时,距离往往比品牌更重要,520m 的 24h 急诊店比 3km 的三甲宠物医院更救命;其二是"判断这家店是否真的是医院"——市场上宠物洗护、宠物摄影、宠物用品批发等非医疗门店与真正的宠物医院混在一起,用户搜索"宠物医院"时常常被无关结果干扰,reliability 评分恰好能解决这个"可信度量化"问题;其三是"快速记下地图上的某个点"——用户在地图浏览时看到一家感兴趣的门店或一个 POI,希望能长按留个记号稍后再看,Marker/POI 长按监听恰好能解决这个"地点留痕"问题。本应用以"萌宠指南 · 宠物医院门店"为产品定位,用奶油底 #FDF7F1 + 蜜桃粉 #E85D8A + 草木绿 #5DA83F 的浅色主题营造温暖、亲近、值得托付的视觉气质,通过 4 个 Tab(门店 / 地图 / 搜索 / 我的)完整覆盖宠物医疗门店场景的发现、定位、检索、管理闭环。
从工程实现看,本应用展示了 ArkUI 声明式范式在地图场景下的多项最佳实践。MapComponent 通过 mapOptions 声明初始视角与缩放、通过 mapCallback 异步拿控制器,控制器就绪后才能调用 getEventManager() 拿事件管理器并注册长按监听——这条"组件 → 控制器 → 事件管理器"的链路必须严格顺序执行,任何环节空值都会导致后续 API 失败,因此在 mapCallback 的 err 为空分支内完成全部注册是最稳妥的做法。批量 addMarker 用 for-of + await + try-catch 逐个添加,避免单个 Marker 失败影响整批。reliability 字段是可选字段,用 ?? 0 兜底保证空值不污染分数条;searchByText 在无 AGC 配置或无网络时会抛 BusinessError,catch 分支保留 Mock 数据让演示链路不中断。@Observed 类(PetItem、SearchRecord、EventLog)配合 @State 数组实现列表响应式刷新,unshift 置顶让最新的长按事件出现在日志流最上方。这些都是地图 + 检索场景下值得反复复用的工程范式。

二、应用整体架构流程
本应用采用典型的"单页面多 Tab + 弹窗层叠"架构。顶层是一个 Stack 容器,底层是主内容列(头部渐变 Banner + 横滑筛选 chips + 滚动区 + 底部 Tab 栏),顶层根据三个弹窗状态变量(addModal、editModal、delModal)叠加不同类型的全屏弹窗。四个 Tab 的内容通过 if-else 条件分支按需渲染,避免同时构建四个完整 Tab 带来的性能开销。地图 Tab 与搜索 Tab 分别承载 6.1.1 的两大新特性:地图 Tab 注册 Marker/POI 长按监听并把事件写入日志流,搜索 Tab 调用 site.searchByText 并把 reliability 分数渲染成分数条。下面用流程图展示整体架构与数据流。
三、颜色系统与主题色板
应用首先定义了一个 ColorPalette 接口,集中声明页面所有用到的颜色字段;随后以一个 COLORS 常量实例化这个接口,作为全应用唯一的颜色取值来源。这种"接口约束 + 常量实例"的写法既保证了类型安全,又让主题切换成为可能——未来如果要支持夜间深色主题,只需要再定义一个 COLORS_DARK 常量即可。
/** 主题色板接口:集中声明页面所有颜色字段(奶油底+蜜桃粉+草木绿浅色系) */
interface ColorPalette {
bg: string; // 页面奶油底色
card: string; // 卡片纯白底色
chip: string; // 胶囊与输入框底色
title: string; // 主标题深玫瑰棕
sub: string; // 副文本暖灰粉
text3: string; // 弱文本浅粉灰
peach: string; // 蜜桃粉主色
peachD: string; // 深蜜桃粉
peachL: string; // 浅蜜桃粉(渐变浅端)
grass: string; // 草木绿辅助色
red: string; // 低相关/删除警示红
line: string; // 分隔线奶粉灰
tabOn: string; // 底部 Tab 激活色
mask: string; // 弹窗遮罩色
codeBg: string; // 代码预览卡深底色(浅色主题也保留深底放代码文本)
}
/** 浅色主题色板常量(萌宠指南 · 奶油底 + 蜜桃粉 + 草木绿) */
const COLORS: ColorPalette = {
bg: '#FDF7F1',
card: '#FFFFFF',
chip: '#F6E9E2',
title: '#3A2A2F',
sub: '#8C7078',
text3: '#BCA3AB',
peach: '#E85D8A',
peachD: '#C13A67',
peachL: '#FBDDE8',
grass: '#5DA83F',
red: '#E5484D',
line: '#F0DFD7',
tabOn: '#E85D8A',
mask: 'rgba(58,42,47,0.5)',
codeBg: '#33202A'
};
ColorPalette 接口定义了 15 个颜色字段,每个字段对应一种语义化用途。bg 是页面整体背景色(奶油底 #FDF7F1),这种带极淡暖黄的米色调在浅色主题中能显著降低纯白底的刺眼感,营造温暖、柔软、亲近宠物的视觉气质;card 是卡片背景色(纯白 #FFFFFF),与奶油底形成"卡片漂浮在底色之上"的层次感;chip 是更小一级的胶囊/标签背景色(奶粉灰 #F6E9E2),用于筛选 chips、按钮次操作底色、输入框底色等位置。三者形成背景的层次递进:奶油底 → 纯白卡 → 奶粉灰胶囊,视觉重量由轻到重。
title、sub、text3 是三档文字色——title 是深玫瑰棕 #3A2A2F(带一点玫瑰红的深棕色,比纯黑更柔和,呼应蜜桃粉主色),sub 是暖灰粉 #8C7078(中等对比的副文本色),text3 是浅粉灰 #BCA3AB(弱化的辅助说明色),用于时间戳、距离文本、版本脚注等低优先级信息。三档文字色覆盖了从主标题到辅助说明的完整信息层级,确保任何文字都能找到合适的对比度。
peach 蜜桃粉 #E85D8A 是全应用的主色(品牌色),高频出现在选中态、主操作按钮、渐变 Banner 起始色、reliability 高相关标签、底部 Tab 激活色等位置;peachD 深蜜桃粉 #C13A67 用于需要更深一档的强调;peachL 浅蜜桃粉 #FBDDE8 用于渐变 Banner 的浅端,让渐变有"由浅到深"的视觉张力。grass 草木绿 #5DA83F 是辅助色,用于 24h 急诊标记、医生充足、reliability 中相关、编辑按钮等"正向但非品牌主色"的语义场景;red 警示红 #E5484D 用于删除按钮、reliability 低相关标签等"警示/不推荐"语义。line 是分割线与边框色(奶粉灰 #F0DFD7),tabOn 是底部 Tab 激活色(与主色同为蜜桃粉),mask 是弹窗遮罩色(半透明玫瑰棕 rgba(58,42,47,0.5),与标题色同源保证遮罩与文字色系一致),codeBg 是代码预览块的黑底色(#33202A 深玫瑰黑),即便浅色主题也保留深底放代码文本以突出代码可读性。整套色板通过 COLORS 常量集中管理,所有 Builder 与组件都通过 COLORS.xxx 引用,保证主题一致性。
四、常量定义与 Mock 数据
4.1 Tab 元数据与筛选标签
/** Tab 元数据接口:底部导航图标 + 标签 */
interface TabMeta {
icon: string; // Tab 图标 emoji
label: string; // Tab 标签文案
}
/** 底部导航 Tab 常量列表(4 Tab 单排) */
const TAB_LIST: TabMeta[] = [
{ icon: '🐾', label: '门店' },
{ icon: '🗺', label: '地图' },
{ icon: '🔍', label: '搜索' },
{ icon: '👤', label: '我的' }
];
/** 头部横滑筛选 chips 文案(宠物服务项目筛选) */
const CATE_TAGS: string[] = ['全部', '疫苗注射', '体检套餐', '洗护美容', '牙科洁齿', '24h急诊', '寄养酒店', '行为训练'];
TabMeta 接口定义了底部导航每一项的图标与标签两个字段。TAB_LIST 常量数组按顺序声明了四个 Tab:门店(爪印图标)、地图(地图图标)、搜索(放大镜图标)、我的(人像图标)。使用 emoji 作为图标的好处是无需引入图片资源,跨设备显示一致且体积为零;缺点是无法精细控制颜色,但通过 fontColor 在选中/未选中态之间切换可以部分弥补。CATE_TAGS 是头部横滑筛选 chips 的文案列表,覆盖了宠物医疗与护理服务的八大常见项目——从疫苗注射、体检套餐到洗护美容、牙科洁齿,再到 24h 急诊、寄养酒店、行为训练,构成一个完整的服务分类体系,用户点击后会高亮对应 chip 并可触发对应类型的服务过滤。
4.2 城市中心点与地图标注点
/** 城市中心点(杭州西湖湖滨周边,地图初始化中心与 Map Kit 搜索 location 参数) */
const CITY_CENTER: mapCommon.LatLng = { latitude: 30.2741, longitude: 120.1551 };
/** 地图标注点接口(宠物门店 Marker 群,长按事件的数据来源) */
interface SpotItem {
name: string; // 门店名称
lat: number; // 纬度
lng: number; // 经度
tag: string; // 门店服务标签
}
/** 宠物门店标注点 Mock 数据(6 个,围绕城市中心点 ±0.02 度散布) */
const MARKER_SPOTS: SpotItem[] = [
{ name: '萌宠指南·南山路宠安医院', lat: 30.2541, lng: 120.1430, tag: '24h急诊' },
{ name: '萌宠指南·湖墅南路喵星诊所', lat: 30.2862, lng: 120.1530, tag: '疫苗' },
{ name: '萌宠指南·文三路萌宠中心', lat: 30.2790, lng: 120.1390, tag: '体检' },
{ name: '萌宠指南·凤起路美容会所', lat: 30.2655, lng: 120.1640, tag: '洗护' },
{ name: '萌宠指南·湖滨宠物牙科', lat: 30.2585, lng: 120.1630, tag: '牙科' },
{ name: '萌宠指南·河坊街宠物诊所', lat: 30.2545, lng: 120.1600, tag: '寄养' }
];
CITY_CENTER 是城市中心点坐标,定位在杭州西湖湖滨周边(纬度 30.2741、经度 120.1551)。这个坐标在应用中承担双重职责:一是作为 MapComponent 初始化时 mapOptions.position.target 的中心点,让地图一打开就聚焦在杭州核心城区;二是作为 site.searchByText 检索参数 SearchByTextParams.location 的基准点,让搜索结果以这个坐标为圆心、5000 米为半径圈定范围。一份常量复用于两处,保证地图视角与检索范围的一致性。
SpotItem 接口定义了地图标注点的四字段结构:门店名称、纬度、经度、服务标签。MARKER_SPOTS 常量数组放了 6 条 Mock 数据,围绕城市中心点 ±0.02 度散布——这个散布范围对应杭州城区几条主要街道(南山路、湖墅南路、文三路、凤起路、湖滨、河坊街),每条数据带一个服务标签(24h 急诊/疫苗/体检/洗护/牙科/寄养),覆盖了宠物门店的主要服务类型。这 6 条数据是地图 Tab MapComponent 上 addMarker 的数据来源,也是长按 Marker 事件的触发对象——用户长按地图上任何一个宠物门店 Marker,都会触发 onMarkerLongClick 回调并把该 Marker 的 ID 与坐标写入事件日志流。
4.3 精选门店与功能清单
/** 精选门店接口(门店 Tab 头部精选大卡) */
interface PetRec {
icon: string; // 门店 emoji
name: string; // 门店名
dist: string; // 距离文本
service: string; // 服务项文本
doctors: number; // 在职医生数
}
/** 精选门店 Mock 数据(3 条,头部渐变大卡下方列表) */
const PET_RECS: PetRec[] = [
{ icon: '🐾', name: '南山路宠安宠物医院', dist: '520m', service: '疫苗 · 体检', doctors: 9 },
{ icon: '🏥', name: '凤起路瑞鹏医院', dist: '1.2km', service: '内外科 · 影像', doctors: 12 },
{ icon: '🐱', name: '湖墅南路喵星诊所', dist: '1.6km', service: '疫苗 · 驱虫', doctors: 4 }
];
/** 我的页功能清单条目接口 */
interface FuncItem {
icon: string; // 功能图标
label: string; // 功能名
value: string; // 状态/数值文本
}
/** 我的页功能清单 Mock 数据(8 条,宠物护理语义) */
const FUNC_LIST: FuncItem[] = [
{ icon: '🐱', label: '宠物档案', value: '2 只 · 咪咪/旺财' },
{ icon: '💉', label: '疫苗提醒', value: '下次 3 月 18 日' },
{ icon: '🩺', label: '体检记录', value: '年度 1 次' },
{ icon: '⭐', label: '收藏门店', value: '5 家' },
{ icon: '📦', label: '服务订单', value: '本月 2 单' },
{ icon: '💳', label: '会员卡', value: '金卡 · 8.5 折' },
{ icon: '🚑', label: '急诊绿通', value: '已开通' },
{ icon: '⚙', label: '偏好设置', value: '洗狗优先' }
];
PetRec 接口定义了精选门店的五字段结构:图标 emoji、门店名、距离文本、服务项文本、在职医生数。PET_RECS 常量数组放了 3 条精选门店,覆盖了宠物医疗的三种典型业态——综合型医院(南山路宠安,520m,9 位医生)、内外科旗舰院(凤起路瑞鹏,1.2km,12 位医生)、专科诊所(湖墅南路喵星,1.6km,4 位医生,仅猫科)。这三条数据在门店 Tab 头部以渐变大卡形式呈现,是用户进入应用第一眼看到的推荐内容,doctors 字段会通过 doctorColor 函数映射成草木绿(充足)/蜜桃粉(常规)/弱文本(偏少)三档颜色,让医生数量一眼可读。
FuncItem 接口定义了功能清单条目的三字段结构:图标、功能名、状态/数值文本。FUNC_LIST 常量数组放了 8 条功能清单,覆盖了宠物护理用户的核心功能入口——宠物档案、疫苗提醒、体检记录、收藏门店、服务订单、会员卡、急诊绿通、偏好设置。每条数据带一个 emoji 图标(猫/注射器/听诊器/星/包裹/卡/救护车/齿轮)和一个状态文本(“2 只 · 咪咪/旺财”、“下次 3 月 18 日”、"金卡 · 8.5 折"等),让用户一眼看到当前状态而无需点进详情页。这 8 条数据在"我的"Tab 以功能清单行的形式呈现,点击任一行可跳转对应功能页(本应用演示版本未实现跳转,仅展示清单形态)。
五、辅助函数:状态到颜色的映射
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.peach };
}
if (score >= 0.5) {
return { label: '中相关', color: COLORS.grass };
}
return { label: '低相关', color: COLORS.red };
}
ScoreLevel 接口定义了相关性等级的两字段结构:等级文案与等级颜色。reliabilityScore 是把 Site.reliability 分数(取值 [0, 1])映射成等级标签与颜色的工具函数——分数 ≥0.8 返回"高相关"+蜜桃粉(品牌主色,表示完全可信的宠物医院结果);分数 ≥0.5 返回"中相关"+草木绿(辅助正向色,表示部分相关,可能是综合宠物服务但非纯医疗);分数 <0.5 返回"低相关"+警示红(表示与"宠物医院"关键字关联度低,可能是宠物用品批发、宠物摄影等非医疗门店)。这套阈值划分对应了宠物医疗场景的真实诉求:0.8 以上的结果通常是正规宠物医院,0.5~0.8 之间可能是带医疗服务的综合宠物店,0.5 以下基本可以判定为非医疗门店。这个函数是搜索 Tab reliability 分数条与等级标签的核心渲染依据,在 tabSearch 中被多次调用——一次决定等级标签的文案与颜色,一次决定 Progress 进度条的颜色,形成"标签 + 分数条"的双重视觉编码。
5.2 医生数量与急诊状态映射
/** 医生数量颜色映射:≥8 位充足草木绿 / ≥4 位常规蜜桃粉 / 其余偏少弱文本 */
function doctorColor(doctors: number): string {
if (doctors >= 8) {
return COLORS.grass;
}
if (doctors >= 4) {
return COLORS.peach;
}
return COLORS.text3;
}
/** 24h 急诊颜色映射:有急诊草木绿 / 其余弱文本 */
function erColor(emergency: boolean): string {
if (emergency) {
return COLORS.grass;
}
return COLORS.text3;
}
doctorColor 是把门店在职医生数量映射成颜色的工具函数——≥8 位返回草木绿(充足,代表这家医院医生资源丰富,可以应对复杂病例)、≥4 位返回蜜桃粉(常规,代表正常的宠物医院配置)、其余返回弱文本色(偏少,提醒用户这家店医生资源可能紧张)。这套阈值划分同样对应宠物医疗场景的真实诉求:8 位医生以上的医院通常能覆盖内外科、影像、牙科等多个专科,4 位左右的诊所是常规配置,4 位以下可能是单人坐诊的小型诊所。在门店 Tab 的精选大卡与全部门店列表中,这个函数被用于渲染医生数字号的颜色——一眼看出哪家店医生充足。
erColor 是把 24h 急诊布尔值映射成颜色的工具函数——有急诊返回草木绿(正向,代表这家店支持 24 小时急诊,宠物突发疾病可随时就诊)、无急诊返回弱文本色(普通,代表仅日间门诊)。在门店 Tab 的全部门店列表中,这个函数被用于渲染"24h 急诊 / 仅日间"标签的颜色——有急诊的门店用草木绿突出,无急诊的用弱文本色弱化,让用户在宠物突发疾病时能快速筛选出可用的急诊店。
六、数据模型层
6.1 门店条目 PetItem
/** 门店条目(门店 Tab 推荐列表,@Observed 支持备注编辑刷新) */
@Observed export class PetItem {
icon: string; // 门店 emoji 图标
name: string; // 门店名
service: string; // 服务项
doctors: number; // 在职医生数
emergency: boolean; // 是否 24h 急诊
note: string; // 用户备注(可编辑)
constructor(icon: string, name: string, service: string,
doctors: number, emergency: boolean, note: string) {
this.icon = icon;
this.name = name;
this.service = service;
this.doctors = doctors;
this.emergency = emergency;
this.note = note;
}
}
/** 门店列表 Mock 数据(7 条,杭州街区宠物门店) */
const PET_LIST: Array<PetItem> = [
new PetItem('🐾', '南山路宠安宠物医院', '疫苗·体检', 9, true, '24h 急诊·有核磁'),
new PetItem('🐱', '湖墅南路喵星诊所', '疫苗·驱虫', 4, false, '仅猫科·需预约'),
new PetItem('🐶', '文三路萌宠中心', '洗护·美容', 6, false, '洗狗排队 2 天'),
new PetItem('🦷', '湖滨宠物牙科诊所', '洁齿·拔牙', 3, false, '可拍牙片'),
new PetItem('🏥', '凤起路瑞鹏医院', '内外科·影像', 12, true, '夜间急诊 21 点起'),
new PetItem('✂', '河坊街美容会所', '造型·染色', 5, false, '网红造型出片'),
new PetItem('🏨', '文二路寄养酒店', '寄养·训练', 7, true, '独立监控可探视')
];
PetItem 是门店条目的数据模型类,使用 @Observed 装饰器声明——@Observed 让类的实例在被 @State 数组包装时,其属性变更能被 ArkUI 框架感知并触发依赖 UI 的重新渲染。类定义了六字段:icon(门店 emoji 图标)、name(门店名)、service(服务项)、doctors(在职医生数)、emergency(是否 24h 急诊)、note(用户备注)。note 字段是可编辑字段,用户通过编辑备注弹窗修改后需要触发列表刷新——这就是 @Observed 的核心价值。
PET_LIST 常量数组放了 7 条门店 Mock 数据,覆盖了杭州街区的 7 种典型宠物门店——综合医院(南山路宠安,9 位医生,24h 急诊,有核磁)、猫科专科(湖墅南路喵星,4 位医生,仅猫科需预约)、洗护美容(文三路萌宠,6 位医生,洗狗排队 2 天)、牙科专科(湖滨牙科,3 位医生,可拍牙片)、内外科旗舰(凤起路瑞鹏,12 位医生,夜间急诊 21 点起)、美容造型(河坊街会所,5 位医生,网红造型出片)、寄养酒店(文二路寄养,7 位医生,独立监控可探视)。每条数据的 note 字段都带了一句真实备注,模拟用户自己写下的备忘——这是 panelEdit 弹窗的编辑对象。
6.2 搜索结果条目 SearchRecord(reliability 数据载体)
/** 搜索结果条目(★ Map Kit 6.1.1 reliability 字段数据载体) */
@Observed export class SearchRecord {
name: string; // 地点名称(site.name)
address: string; // 格式化地址(site.formatAddress)
distance: number; // 直线距离米(site.distance)
reliability: number; // ★ 相关性分数(site.reliability,[0,1])
time: string; // 记录时间文案
constructor(name: string, address: string, distance: number,
reliability: number, time: string) {
this.name = name;
this.address = address;
this.distance = distance;
this.reliability = reliability;
this.time = time;
}
}
/** 搜索结果 Mock 数据(6 条,reliability 覆盖高/中/低三档) */
const SEARCH_RECORDS: Array<SearchRecord> = [
new SearchRecord('南山路宠安宠物医院', '杭州市上城区南山路 182 号', 480, 0.93, '刚刚'),
new SearchRecord('凤起路瑞鹏宠物医院', '杭州市拱墅区凤起路 334 号', 1050, 0.85, '刚刚'),
new SearchRecord('文三路萌宠诊疗中心', '杭州市西湖区文三路 477 号', 1490, 0.68, '4 分钟前'),
new SearchRecord('湖墅南路宠物诊所', '杭州市拱墅区湖墅南路 271 号', 2210, 0.49, '7 分钟前'),
new SearchRecord('宠物医院用品批发部', '杭州市滨江区江南大道 588 号', 3670, 0.26, '10 分钟前'),
new SearchRecord('宠物摄影工作室(非医疗)', '杭州市余杭区文一西路 969 号', 5480, 0.06, '14 分钟前')
];
SearchRecord 是搜索结果条目的数据模型类,同样使用 @Observed 装饰器声明。类定义了五字段:name(地点名称,对应 site.name)、address(格式化地址,对应 site.formatAddress)、distance(直线距离米,对应 site.distance)、reliability(相关性分数,对应 site.reliability)、time(记录时间文案)。其中 reliability 是 HarmonyOS 6.1.1 Site 类型新增字段的数据载体——取值 [0, 1],1 表示完全相关。这个字段在搜索 Tab 的 runSearch 方法中被读取(s.reliability ?? 0 兜底),在 tabSearch 中被 reliabilityScore 函数映射成等级标签与分数条颜色,是整个 reliability 特性的核心数据载体。
SEARCH_RECORDS 常量数组放了 6 条搜索结果 Mock 数据,刻意覆盖了 reliability 的高/中/低三档——0.93(南山路宠安,高相关,正规宠物医院)、0.85(凤起路瑞鹏,高相关,综合医院)、0.68(文三路萌宠诊疗,中相关,诊疗中心)、0.49(湖墅南路宠物诊所,低相关临界,规模较小)、0.26(宠物医院用品批发部,低相关,实际是批发部而非医院)、0.06(宠物摄影工作室,极低相关,完全非医疗)。这组数据的设计意图是让用户在搜索 Tab 一眼看到 reliability 分数条与等级标签的差异化呈现——从蜜桃粉的高相关到草木绿的中相关再到警示红的低相关,形成一条清晰的"可信度光谱"。距离字段也从 480m 递增到 5480m,覆盖了从极近到较远的完整距离区间。
6.3 长按事件日志条目 EventLog
/** 长按事件日志条目(★ MapEventManager 长按监听数据载体) */
@Observed export class EventLog {
type: string; // 事件类型:'Marker' / 'POI'
name: string; // Marker ID 或 POI 名称
lat: number; // 纬度
lng: number; // 经度
time: string; // 事件时间文案
constructor(type: string, name: string, lat: number, lng: number, time: string) {
this.type = type;
this.name = name;
this.lat = lat;
this.lng = lng;
this.time = time;
}
}
/** 长按事件日志 Mock 数据(2 条,演示日志流形态) */
const EVENT_LOGS: Array<EventLog> = [
new EventLog('POI', '断桥残雪', 30.2621, 120.1497, '演示事件'),
new EventLog('Marker', '#0', 30.2541, 120.1430, '演示事件')
];
EventLog 是长按事件日志条目的数据模型类,同样使用 @Observed 装饰器声明。类定义了五字段:type(事件类型,取值 'Marker' 或 'POI',分别对应 onMarkerLongClick 与 onPoiLongClick 两种长按事件)、name(Marker ID 或 POI 名称,Marker 事件取 #${marker.getId()},POI 事件取 poi.name)、lat(纬度)、lng(经度)、time(事件时间文案)。这个类是 MapEventManager 6.1.1 双长按监听特性的数据落地载体——每次长按事件触发,都会 new 一个 EventLog 实例并 unshift 到 eventLogs 数组头部,让最新事件出现在日志流最上方。
EVENT_LOGS 常量数组放了 2 条 Mock 数据,分别演示 Marker 长按与 POI 长按两种事件类型——POI 类型以"断桥残雪"为例(杭州西湖著名景点,演示地图 POI 长按),Marker 类型以 #0 为例(第 0 个门店 Marker,演示地图标记长按)。这两条数据在应用启动时作为初始日志显示在地图 Tab 的事件日志流,用户实际长按地图后会通过 unshift 在它们上方插入新事件,形成"演示 → 实时"的日志流形态。
七、组件主体结构
7.1 状态变量声明
/** 1137 萌宠指南 · 宠物医院门店主页面 */
@Entry
@Component
struct Page1137 {
/** 当前选中 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 petList: Array<PetItem> = PET_LIST;
/** 我的页功能清单数据 */
@State funcList: FuncItem[] = FUNC_LIST;
Page1137 是应用的主页面组件,使用 @Entry 装饰器标记为应用入口,@Component 装饰器声明为一个 ArkUI 组件。组件内部通过一系列 @State 装饰器声明响应式状态变量,这些变量的任何变更都会自动触发依赖它们的 UI 部分重新渲染。currentTab 控制四个 Tab 的切换,初始值 0 表示默认进入门店 Tab。cateIdx 记录头部筛选 chips 的选中索引,控制哪个 chip 高亮。
三个弹窗开关 addModal、editModal、delModal 分别控制收藏门店、编辑备注、删除确认三个弹窗的显示与隐藏,配合 editIdx、delIdx 记录当前操作的目标门店索引。两个数据数组 petList(门店列表,7 条 Mock)与 funcList(功能清单,8 条 Mock)用 @State 修饰,确保数组内容变更时对应列表自动刷新——这一点对 petList 的 unshift(新增收藏)与 splice(删除收藏)操作尤其重要,没有 @State 包装的话,数组变更不会触发 ForEach 重新渲染。
7.2 Map Kit 状态声明(6.1.1 双新特性)
// --- Map Kit 状态(6.1.1 特性:搜索 reliability + 长按事件) ---
/** 地图初始化参数(非可选并给默认值,避免组件参数传 undefined) */
private mapOptions: mapCommon.MapOptions = {
position: { target: CITY_CENTER, zoom: 13 }
};
/** 地图初始化回调(setupMapCallback 中赋值) */
private mapCallback?: AsyncCallback<map.MapComponentController>;
/** 地图控制器(回调中获取,添加 Marker 用) */
private mapController?: map.MapComponentController;
/** 地图事件管理器(回调中获取,长按监听注册用) */
private mapEventManager?: map.MapEventManager;
/** Marker 长按监听开关 */
@State markerListenOn: boolean = true;
/** POI 长按监听开关 */
@State poiListenOn: boolean = true;
/** 长按事件日志流(unshift 置顶) */
@State eventLogs: Array<EventLog> = EVENT_LOGS;
/** 搜索关键字输入值 */
@State queryInput: string = '宠物医院';
/** 搜索状态文案 */
@State searchState: string = '待搜索 · 演示数据';
/** 搜索结果列表(site.searchByText 结果数据源) */
@State searchRecords: Array<SearchRecord> = SEARCH_RECORDS;
/** 收藏弹窗:门店名输入 */
@State formName: string = '';
/** 收藏弹窗:服务项输入 */
@State formService: string = '';
/** 收藏弹窗:地址输入 */
@State formAddr: string = '';
/** 编辑弹窗:备注输入 */
@State editNote: string = '';
Map Kit 部分声明了应用最核心的状态。mapOptions 是 mapCommon.MapOptions 实例,用 private 修饰(不参与响应式),声明地图初始化的目标坐标(CITY_CENTER)与缩放层级(zoom: 13,街道级视野)。mapCallback 是 AsyncCallback<map.MapComponentController> 类型的可选回调,初始未赋值,会在 setupMapCallback 方法中被赋值,在 MapComponent 初始化完成时被框架调用,传入控制器实例或错误对象。mapController 与 mapEventManager 都是可选实例,只有在 mapCallback 的 err 为空分支内才会被赋值——前者用于 addMarker 批量标注,后者用于 onMarkerLongClick / onPoiLongClick 长按监听注册。这条"组件 → 控制器 → 事件管理器"的链路必须严格顺序执行,任何环节空值都会导致后续 API 失败。
markerListenOn 与 poiListenOn 两个布尔 @State 控制两种长按监听的开关状态,初始都为 true(应用启动即注册监听)。用户在地图 Tab 可以通过两个 Toggle 开关手动切换监听状态——切换时调用 toggleMarkerListen 或 togglePoiListen 方法,关闭时调用 offMarkerLongClick / offPoiLongClick 清除订阅,开启时重新调用 onMarkerLongClick / onPoiLongClick 注册。eventLogs 是长按事件日志流的 @State 数组,初始为 EVENT_LOGS(2 条演示数据),每次长按事件触发都会 unshift 一个新 EventLog 到数组头部。queryInput 是搜索关键字输入值(初始"宠物医院"),searchState 是搜索状态文案(初始"待搜索 · 演示数据"),searchRecords 是搜索结果列表(初始为 SEARCH_RECORDS Mock,调用 runSearch 后被真实结果替换)。三个表单字段 formName、formService、formAddr 用于收藏弹窗的双向绑定,editNote 用于编辑弹窗的备注双向绑定。
7.3 生命周期与页面主构建
/** 生命周期:初始化地图回调(监听注册在 mapCallback 内完成) */
aboutToAppear() {
this.setupMapCallback();
}
/** 页面主构建:Stack 包裹主内容与三层弹窗 */
build() {
Stack() {
Column() {
this.headerMain()
Divider().strokeWidth(1).color(COLORS.line)
Scroll() {
Column() {
if (this.currentTab === 0) {
this.tabPet()
} 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 执行前调用。这里调用 this.setupMapCallback() 完成地图回调的初始化——把异步回调函数赋值给 this.mapCallback,让 MapComponent 在渲染时能拿到这个回调并在地图初始化完成后调用它。把监听注册放在 mapCallback 内部而非 aboutToAppear 中直接注册,是因为监听注册依赖 mapController 与 mapEventManager,而这两个实例只能在 mapCallback 内拿到——aboutToAppear 执行时地图还没初始化,控制器与事件管理器都还是 undefined,直接注册会抛空引用异常。
build 是组件的主构建方法,使用 Stack 作为根容器。Stack 是 ArkUI 的层叠容器,子元素按声明顺序从底层到顶层叠加。这里先放主内容 Column(头部 + 分割线 + 滚动区 + 底部 Tab 栏),再根据三个弹窗状态变量条件性叠加三个弹窗——if (this.addModal) 叠加收藏弹窗、if (this.editModal) 叠加编辑弹窗、if (this.delModal) 叠加删除弹窗。弹窗在 Stack 顶层,会覆盖整个屏幕并接收点击事件,实现"弹窗显示时主内容不可交互"的标准弹窗行为。滚动区内的 Column 通过 if-else 按 currentTab 渲染对应 Tab 内容,避免同时构建四个完整 Tab 带来的性能开销。整个 Stack 的背景色设为 COLORS.bg(奶油底),奠定全应用的浅色主题基调。
八、6.1.1 双新特性落地:地图初始化与监听注册
8.1 setupMapCallback:控制器 → 事件管理器 → Marker → 双长按监听
/**
* 地图初始化: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, '刚刚'));
});
};
}
这是整个应用最核心的方法,集中体现了 HarmonyOS 6.1.1 Map Kit 双长按监听特性的完整使用方式。方法把一个 async 异步回调函数赋值给 this.mapCallback,这个回调会在 MapComponent 初始化完成时被框架调用,参数是错误对象 err 与控制器实例 mapController。回调首先检查 err——如果非空(地图初始化失败),打印错误码与消息后直接 return,不执行后续逻辑,避免在控制器未就绪时调用 API 抛二次异常。
err 为空分支内是核心逻辑。先把 mapController 保存到 this.mapController,再调用 mapController.getEventManager() 拿到事件管理器保存到 this.mapEventManager——这条"控制器 → 事件管理器"的链路是 6.1.1 长按监听注册的前置条件,没有事件管理器就无法调用 onMarkerLongClick / onPoiLongClick。接着用 for-of 遍历 MARKER_SPOTS(6 个宠物门店标注点)逐个调用 addMarker 添加地图标记——addMarker 返回 Promise,用 await 等待完成,用 try-catch 包裹捕获单个 Marker 失败的 BusinessError,避免一个失败影响整批。markerOptions 声明了 Marker 的全部属性:position(坐标)、clickable: true(可点击)、visible: true(可见)、rotation: 0(不旋转)、zIndex: 0(层级)、alpha: 1(不透明)、anchorU: 0.5 + anchorV: 1(锚点在图标底部中央,让图标尖端对准坐标点)、draggable: false(不可拖动)、flat: false(不贴地)。
批量 Marker 添加完成后,注册 6.1.1 的两个新长按监听。onMarkerLongClick 的回调参数是 map.Marker,通过 marker.getPosition() 拿到经纬度坐标,通过 marker.getId() 拿到 Marker 的 ID,然后 new 一个 EventLog(type 为 'Marker',name 为 #${marker.getId()})并 unshift 到 eventLogs 数组头部——日志流会实时显示这条新事件,时间文案为"刚刚"。onPoiLongClick 的回调参数是 mapCommon.Poi,通过 poi.name 拿到 POI 名称,通过 poi.position.latitude / poi.position.longitude 拿到坐标,同样 new 一个 EventLog(type 为 'POI',name 为 poi.name)并 unshift 置顶。这两个监听一旦注册就持续生效,直到调用 offMarkerLongClick / offPoiLongClick 清除订阅——这就是下一节的开关切换逻辑。
8.2 toggleMarkerListen 与 togglePoiListen:监听开关切换
/** Marker 长按监听开关切换(off 不传参 = 清除该类型全部订阅) */
toggleMarkerListen() {
if (!this.mapEventManager) {
return;
}
if (this.markerListenOn) {
this.mapEventManager.offMarkerLongClick();
} else {
this.mapEventManager.onMarkerLongClick((marker: map.Marker) => {
const pos: mapCommon.LatLng = marker.getPosition();
this.eventLogs.unshift(new EventLog('Marker', `#${marker.getId()}`,
pos.latitude, pos.longitude, '刚刚'));
});
}
this.markerListenOn = !this.markerListenOn;
}
/** POI 长按监听开关切换(off 不传参 = 清除该类型全部订阅) */
togglePoiListen() {
if (!this.mapEventManager) {
return;
}
if (this.poiListenOn) {
this.mapEventManager.offPoiLongClick();
} else {
this.mapEventManager.onPoiLongClick((poi: mapCommon.Poi) => {
this.eventLogs.unshift(new EventLog('POI', poi.name,
poi.position.latitude, poi.position.longitude, '刚刚'));
});
}
this.poiListenOn = !this.poiListenOn;
}
这两个方法分别对应地图 Tab 上 Marker 长按开关与 POI 长按开关的 Toggle.onChange 回调。方法首先做空值保护——如果 mapEventManager 为空(地图未初始化或初始化失败),直接 return 不执行任何操作,避免空引用异常。然后根据 markerListenOn / poiListenOn 的当前状态做相反操作:当前为 true(监听开启)则调用 offMarkerLongClick / offPoiLongClick 关闭监听,当前为 false(监听关闭)则调用 onMarkerLongClick / onPoiLongClick 重新注册监听。最后翻转 @State 布尔值,触发 Toggle 开关的视觉更新。
值得注意的细节是 offMarkerLongClick 与 offPoiLongClick 都不传参——这是 6.1.1 API 的设计约定:不传参即清除该类型的全部订阅,传回调函数则只清除指定的那一个订阅。本应用每个类型只注册了一个回调,所以直接清除全部订阅即可。重新注册时回调函数体与 setupMapCallback 内的初始注册完全一致——都是 new EventLog 并 unshift 置顶,保证关闭再开启后行为不变。这种"开关即清除/重注册"的设计让用户可以按需关闭不需要的监听(例如只关心 Marker 长按、不关心 POI 长按时关掉 POI 监听),减少不必要的事件触发。
8.3 runSearch:searchByText 调用与 reliability 读取
/**
* ★ 6.1.1 新特性·搜索:关键字搜索 searchByText → Site 数组
* 读取 Site.reliability 相关性分数(可选字段,?? 兜底 0)
* 无 AGC 配置/无网络时抛 BusinessError,catch 保留 Mock 数据保证演示链路
*/
async runSearch() {
this.searchState = '搜索中…';
const params: site.SearchByTextParams = {
query: this.queryInput,
location: CITY_CENTER,
radius: 5000,
language: 'zh'
};
try {
const result: site.SearchByTextResult = await site.searchByText(params);
const sites: Array<site.Site> = result.sites ?? [];
if (sites.length === 0) {
this.searchState = '无结果 · 保留演示数据';
return;
}
const records: Array<SearchRecord> = [];
for (const s of sites) {
records.push(new SearchRecord(
s.name ?? '未命名地点',
s.formatAddress ?? '暂无地址',
s.distance ?? 0,
s.reliability ?? 0,
'刚刚'));
}
this.searchRecords = records;
this.searchState = `返回 ${sites.length} 家门店`;
} catch (e) {
const err = e as BusinessError;
this.searchState = `搜索失败(${err.code}) · 保留演示数据`;
}
}
这是 HarmonyOS 6.1.1 site.searchByText reliability 特性的完整使用方法。方法是 async 异步函数,由搜索 Tab 的"搜索"按钮 onClick 触发。方法首先把 searchState 设为"搜索中…“,让用户立即看到状态反馈(避免用户以为按钮没响应)。然后构造检索参数 SearchByTextParams——query 取自 queryInput(用户在搜索框输入的关键字,默认"宠物医院”)、location 取自 CITY_CENTER(城市中心点,作为检索圆心)、radius: 5000(5 公里检索半径,覆盖城市核心城区)、language: 'zh'(返回中文结果)。
接着用 try-catch 包裹 await site.searchByText(params) 调用。searchByText 返回 SearchByTextResult 对象,其 sites 字段是 Array<site.Site>——用 ?? [] 兜底空值,避免 sites 为 undefined 时 .length 抛异常。如果 sites.length === 0(无结果),把 searchState 设为"无结果 · 保留演示数据"并 return,让 Mock 数据继续显示而不是清空列表。如果有结果,用 for-of 遍历每个 Site,构造 SearchRecord 实例——s.name ?? '未命名地点'(地点名兜底"未命名地点")、s.formatAddress ?? '暂无地址'(格式化地址兜底"暂无地址")、s.distance ?? 0(直线距离兜底 0)、s.reliability ?? 0(★ 6.1.1 新字段,相关性分数兜底 0,避免可选字段为空时分数条异常)。构造完成后赋值给 this.searchRecords 触发列表刷新,并设置 searchState 为"返回 N 家门店"。
catch 分支处理搜索失败——searchByText 在无 AGC(AppGallery Connect)配置或无网络时会抛 BusinessError,这里捕获后把 searchState 设为"搜索失败(code) · 保留演示数据",让 Mock 数据继续显示。这种"失败保留 Mock"的设计保证了演示链路不中断——即便在没有 AGC 配置的开发环境,应用也能正常展示 reliability 分数条与等级标签的视觉效果,只是数据是 Mock 而非真实检索结果。整个方法体现了 6.1.1 reliability 特性的核心落地:读取 Site.reliability 字段、?? 0 兜底、写入 SearchRecord 数据模型、驱动分数条与等级标签渲染。
8.4 门店 CRUD:收藏、编辑、删除
/** 打开编辑备注弹窗(回填当前门店备注) */
openEditPet(idx: number) {
this.editIdx = idx;
this.editNote = this.petList[idx].note;
this.editModal = true;
}
/** 保存收藏门店(空名兜底默认演示门店) */
savePet() {
const name = this.formName === '' ? '萌宠指南·新收藏门店' : this.formName;
const service = this.formService === '' ? '洗护·美容' : this.formService;
const addr = this.formAddr === '' ? '杭州市上城区(地图选点)' : this.formAddr;
this.petList.unshift(new PetItem('⭐', name, service, 5, false, addr));
this.formName = '';
this.formService = '';
this.formAddr = '';
this.addModal = false;
}
/** 保存编辑备注(整体刷新数组引用以刷新列表) */
updatePet() {
if (this.editIdx >= 0 && this.editIdx < this.petList.length) {
if (this.editNote !== '') {
this.petList[this.editIdx].note = this.editNote;
}
this.petList = this.petList.slice();
}
this.editModal = false;
}
/** 删除收藏门店(确认弹窗回调) */
delPet() {
if (this.delIdx >= 0 && this.delIdx < this.petList.length) {
this.petList.splice(this.delIdx, 1);
}
this.delModal = false;
}
openEditPet 是打开编辑备注弹窗的入口方法,接收目标门店索引 idx,先把 editIdx 设为该索引、把 editNote 回填为当前门店的备注(让弹窗的 TextInput 显示原有备注,用户可在此基础上修改),最后把 editModal 设为 true 弹出弹窗。这种"先回填再弹出"的设计让编辑体验更友好——用户看到的是已有备注而非空白输入框。
savePet 是收藏新门店的保存方法,对三个表单字段做空值兜底——formName 为空时默认"萌宠指南·新收藏门店"、formService 为空时默认"洗护·美容"、formAddr 为空时默认"杭州市上城区(地图选点)"。然后 new 一个 PetItem(图标 ⭐、医生数 5、无急诊)并 unshift 到 petList 头部(让新收藏出现在列表最上方),最后清空三个表单字段并关闭弹窗。
updatePet 是保存编辑备注的方法,先做边界检查(editIdx 在数组范围内),再把 editNote 写入目标门店的 note 字段(非空才写)。关键的技巧是 this.petList = this.petList.slice()——slice() 不传参会创建数组的浅拷贝,赋值给 this.petList 等于把数组引用整体替换,这会触发 @State 的引用变更检测,强制 ForEach 重新渲染整个列表。这是因为 PetItem 的 note 属性变更虽然能被 @Observed 感知,但在某些 ArkUI 版本中元素级刷新不够稳定,整体刷新数组引用是最可靠的刷新技巧。
delPet 是删除收藏门店的方法,同样做边界检查后调用 splice(this.delIdx, 1) 移除目标门店。splice 直接修改原数组,会触发 @State 数组的变更检测,列表自动刷新。最后关闭删除确认弹窗。三个 CRUD 方法配合 panelAdd / panelEdit / panelDel 三个弹窗,构成了门店列表的完整增删改闭环。
九、头部区域 headerMain
/** 头部:渐变 Banner(宠物档案+疫苗动态)+ 筛选 chips 横滑 */
@Builder
headerMain() {
Column({ space: 12 }) {
// 顶部渐变 Banner:档案数量 + 疫苗/服务动态 + 预约入口
Column({ space: 10 }) {
Row({ space: 12 }) {
Column({ space: 2 }) {
Text('2').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('下次疫苗 3 月 18 日 · 猫三联').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
}
Row({ space: 6 }) {
Text('🏥').fontSize(12)
Text('最近门店 520m · 24h 急诊').fontSize(11).fontColor(COLORS.sub)
}
Row({ space: 6 }) {
Text('🛁').fontSize(12)
Text('本月洗护 2 次 · 消费 358 元').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.peach)
Text('🗺 地图找店').fontSize(12).fontColor(COLORS.peach)
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.borderRadius(16).backgroundColor(COLORS.card)
.onClick(() => { this.currentTab = 1; })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.padding(14)
.borderRadius(14)
.linearGradient({
angle: 135,
colors: [[COLORS.peachL, 0.0], [COLORS.card, 0.65]]
})
// 筛选 chips 横滑
Scroll() {
Row({ space: 8 }) {
ForEach(CATE_TAGS, (tag: string, idx: number) => {
Text(tag)
.fontSize(11)
.fontColor(this.cateIdx === idx ? COLORS.bg : COLORS.sub)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.cateIdx === idx ? COLORS.peach : COLORS.chip)
.onClick(() => { this.cateIdx = idx; })
}, (tag: string) => tag)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.padding({ left: 14, right: 14, top: 12, bottom: 8 })
.width('100%')
}
headerMain 构建头部区域,分为渐变 Banner 与筛选 chips 横滑两块。渐变 Banner 用 linearGradient 声明 135 度角渐变,从浅蜜桃粉 #FBDDE8(0% 位置)渐变到纯白 #FFFFFF(65% 位置),形成"左上浅粉 → 右下纯白"的对角渐变,营造温暖柔软的品牌质感。Banner 内部分两块:上方是档案数量 + 疫苗/服务动态行——左侧大号"2"+ "宠物档案"标签(数字 30px 加粗深玫瑰棕,标签 10px 暖灰粉),右侧三行动态信息(疫苗提醒 3 月 18 日猫三联、最近门店 520m 24h 急诊、本月洗护 2 次消费 358 元);下方是双胶囊按钮行——"📅 预约服务"蜜桃粉底奶油色字(主操作按钮)+ "🗺 地图找店"纯白底蜜桃粉字(次操作按钮,onClick 切换到地图 Tab)。
筛选 chips 横滑用 Scroll 包裹 Row + ForEach,scrollable(ScrollDirection.Horizontal) 声明横向滚动、scrollBar(BarState.Off) 隐藏滚动条。ForEach 遍历 CATE_TAGS(8 个服务项目标签)生成胶囊,每个胶囊的选中态视觉反馈有双重——选中时背景蜜桃粉 + 文字奶油色,未选中时背景奶粉灰 + 文字暖灰粉。点击任一胶囊设置 cateIdx = idx 切换选中态。这种横滑 chips 的设计让 8 个服务项目能在有限宽度内全部呈现,用户横滑即可浏览全部选项,是宠物服务分类筛选的标准交互模式。
十、门店 Tab tabPet
/** 数据统计小单元格(三宫格通用,浅色白底) */
@Builder
statCell(value: string, label: string) {
Column({ space: 4 }) {
Text(value).fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.peach)
Text(label).fontSize(10).fontColor(COLORS.sub)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(10)
.backgroundColor(COLORS.card)
}
/** 门店 Tab:服务数据三宫格 + 精选门店 + 全部门店列表(业务主 Tab) */
@Builder
tabPet() {
Column({ space: 10 }) {
// 区块标题行:更多入口
Row() {
Text('附近门店').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Blank()
Text('收藏新门店 +').fontSize(11).fontColor(COLORS.peach)
.onClick(() => { this.addModal = true; })
}
.width('100%')
// 服务数据三宫格
Row({ space: 8 }) {
this.statCell('2 只', '宠物档案')
this.statCell('6 次', '本月服务')
this.statCell('358 元', '本月消费')
}
.width('100%')
// 精选门店大卡(3 条)
ForEach(PET_RECS, (rec: PetRec) => {
Row({ space: 10 }) {
Text(rec.icon).fontSize(26)
Column({ space: 4 }) {
Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(`${rec.service} · 医生 ${rec.doctors} 位`).fontSize(11).fontColor(COLORS.sub)
Row({ space: 6 }) {
Text(rec.dist).fontSize(10).fontColor(COLORS.text3)
Text(`医生${rec.doctors >= 8 ? '充足' : '常规'}`).fontSize(10).fontColor(doctorColor(rec.doctors))
}
}
.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.peach)
.onClick(() => { this.currentTab = 1; })
}
}
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
.width('100%')
}, (rec: PetRec) => rec.name)
// 全部门店列表(长按 Marker 的数据同源)
Row() {
Text('全部门店(地图 Marker 同源)').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
}
.width('100%')
ForEach(this.petList, (pet: PetItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text(pet.icon).fontSize(22)
Column({ space: 3 }) {
Text(pet.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(`${pet.service} · ${pet.emergency ? '24h 急诊' : '日间门诊'}`).fontSize(11).fontColor(COLORS.sub)
Text(`备注:${pet.note}`).fontSize(10).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 6 }) {
Text(`${pet.doctors}`).fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(doctorColor(pet.doctors))
Text('位医生').fontSize(9).fontColor(COLORS.text3)
}
}
.width('100%')
Row({ space: 8 }) {
Text(pet.emergency ? '24h急诊' : '仅日间').fontSize(10).fontColor(erColor(pet.emergency))
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(10).backgroundColor(COLORS.chip)
Blank()
Text('编辑').fontSize(10).fontColor(COLORS.grass)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(10).backgroundColor(COLORS.chip)
.onClick(() => { this.openEditPet(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%')
}, (pet: PetItem) => pet.name)
// 养宠小贴士卡
this.tipsCard()
}
.width('100%')
}
statCell 是三宫格统计单元格的通用 Builder,接收数值 value 与标签 label 两个参数。单元格内是 Column 布局——大号数值(17px 加粗蜜桃粉)+ 小号标签(10px 暖灰粉),layoutWeight(1) 让三个单元格等宽分布,borderRadius(10) + 纯白背景形成"浮在奶油底上的小卡片"视觉效果。这个 Builder 在门店 Tab 与"我的"Tab 都被复用,体现 ArkUI 的 Builder 复用能力。
tabPet 构建门店 Tab,这是应用的业务主 Tab,分四块内容。第一块是区块标题行——"附近门店"加粗深玫瑰棕标题 + Blank() 占位 + "收藏新门店 +“蜜桃粉文字按钮(onClick 打开 panelAdd 弹窗)。第二块是服务数据三宫格——三个 statCell 分别显示"2 只宠物档案”、“6 次本月服务”、“358 元本月消费”,让用户一眼掌握自己的养宠消费概况。第三块是精选门店大卡列表——ForEach 遍历 PET_RECS(3 条精选门店),每条卡是 Row 布局:左侧大号 emoji 图标(26px)+ 中间门店名/服务项/距离+医生充足状态(doctorColor 映射颜色)+ 右侧"预约"蜜桃粉按钮(onClick 切换到地图 Tab)。第四块是全部门店列表——标题"全部门店(地图 Marker 同源)"明确告知这份数据与地图 Tab 的 6 个 Marker 是同一份 Mock 来源,ForEach 遍历 petList(7 条门店),每条卡是 Column 布局:上方主信息行(emoji + 门店名 + 服务项/急诊状态 + 备注 + 医生数+位医生标签)+ 下方操作行(24h急诊/仅日间标签 + 编辑按钮草木绿 + 删除按钮警示红)。列表底部是 tipsCard 养宠小贴士卡。
值得强调的是"全部门店(地图 Marker 同源)“这一设计。门店 Tab 的全部门店列表与地图 Tab 的 6 个 Marker 共享同一份 Mock 数据源(虽然字段结构不同,但门店名称与坐标对应同一组真实门店),这形成了一条"列表 ↔ 地图"的双向数据链路——用户在门店 Tab 看到的"南山路宠安宠物医院”,在地图 Tab 上也有对应的 Marker 标注,长按该 Marker 会触发 onMarkerLongClick 把它的坐标写入事件日志流。这种数据同源设计让两个 Tab 不是割裂的列表与地图,而是同一份门店数据的两种视图。
十一、地图 Tab tabMap(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.peach)
.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.peach)
.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.peach : COLORS.grass)
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,这是 6.1.1 长按事件特性的核心展示页,分四块内容。第一块是特性说明卡——奶粉灰背景的小卡片,标题"🗺 Map Kit 6.1.1 · 长按事件监听"加粗深玫瑰棕,副标题"长按地图上的宠物门店 Marker 或 POI 地点,事件将记录到下方日志流"暖灰粉说明文字,让用户一眼明白这个 Tab 的核心交互。第二块是监听开关行——两个 Toggle 开关分别控制 Marker 长按与 POI 长按监听的开启/关闭,selectedColor 设为蜜桃粉与主题统一,onChange 分别调用 toggleMarkerListen / togglePoiListen 方法切换监听状态。开关旁的标签"Marker长按"与"POI长按"用暖灰粉文字,与开关形成"开关 + 文字"的标准交互单元。
第三块是 MapComponent 本体——这是 Map Kit 的原生地图组件,通过 mapOptions 声明初始视角(CITY_CENTER 中心 + zoom: 13 街道级),通过 mapCallback 接收初始化完成回调。layoutWeight(1) 让地图占满剩余高度,borderRadius(12) 圆角与卡片视觉统一。地图上会显示 6 个宠物门店 Marker(由 setupMapCallback 内的 addMarker 批量添加),用户长按任一 Marker 或 POI 都会触发 6.1.1 新增的长按监听回调,把事件写入下方日志流。这块是整个 Tab 的视觉中心,也是 6.1.1 双长按特性的交互落地点。
第四块是长按事件日志流——奶粉灰背景的卡片,固定高度 120px 可滚动。顶部标题行"长按事件日志流" + Blank() + "共 N 条"计数(this.eventLogs.length 随增减实时更新)。主体是 Scroll + Column + ForEach 遍历 eventLogs 数组,每条日志是一个 Row——左侧 emoji(📍 Marker 类型用定位图标,🐾 POI 类型用爪印图标)+ 右侧 Column(上方行:类型标签蜜桃粉/草木绿双色编码 + 名称 + 时间;下方行:经纬度等宽字体小号显示,toFixed(4) 保留 4 位小数)。ForEach 的 key 生成函数用 ${log.type}-${log.name}-${log.time} 三字段拼接,保证每条日志的唯一性。新事件通过 unshift 置顶,让最新的长按事件出现在日志流最上方,符合"最新优先"的信息呈现原则。
整个 tabMap 的设计体现了"说明 → 开关 → 地图 → 日志"的完整交互闭环:用户先看到特性说明明白这个 Tab 在演示什么,再通过开关控制监听状态,然后在地图上长按触发事件,最后在日志流看到事件被记录。这是一个完整的"演示 6.1.1 长按监听特性"的教学型交互流。
十二、搜索 Tab tabSearch(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.peach)
.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,这是 6.1.1 reliability 相关性分数特性的核心展示页,分五块内容。第一块是特性说明卡——标题"🔍 searchByText · reliability 相关性评分"加粗深玫瑰棕,副标题"Site 新增 reliability 字段([0,1],1 为完全相关),衡量结果与关键字关联程度"暖灰粉说明,让用户一眼明白这个 Tab 在演示 6.1.1 的 reliability 新字段。第二块是搜索框 + 触发按钮行——TextInput 绑定 queryInput(默认"宠物医院"),onChange 实时更新输入值;Button 蜜桃粉底,onClick 调用 runSearch 方法发起检索。这种"输入框 + 按钮"的分离设计让用户可以自由编辑关键字而不触发即时搜索,只有点"搜索"才真正调用 site.searchByText。
第三块是搜索状态文案——Text(this.searchState) 显示当前搜索状态,可能是"待搜索 · 演示数据"(初始)、“搜索中…”(调用中)、“返回 N 家门店”(成功)、“无结果 · 保留演示数据”(空结果)、“搜索失败(code) · 保留演示数据”(异常)。这五种状态覆盖了检索的完整生命周期,让用户始终知道当前发生了什么。第四块是搜索结果列表——List + ForEach 遍历 searchRecords,每条结果用 ListItem 包裹(ArkUI 的 List 子项必须用 ListItem 包裹,否则会告警),内部是 Column 布局,分四层信息。
第一层是主信息行——门店名(加粗深玫瑰棕,maxLines(1) + textOverflow(Ellipsis) 单行省略)+ reliability 等级标签("高相关"蜜桃粉 / "中相关"草木绿 / "低相关"警示红,由 reliabilityScore(rec.reliability) 函数映射)。等级标签用纯白背景的小胶囊呈现,与门店名形成"主信息 + 可信度标签"的双重视觉编码。第二层是地址行——rec.address 暖灰粉单行省略,让用户知道门店的具体位置。第三层是 ★ reliability 分数条——这是 6.1.1 特性的核心可视化呈现,Progress 线性进度条把 rec.reliability * 100 映射成 0~100 的进度值,color 取 reliabilityScore(rec.reliability).color(与等级标签同色,形成"标签 + 分数条"的同色编码),右侧 Text 显示"reliability 0.93"等宽字体的精确数值。这条分数条让用户一眼看出每条结果与"宠物医院"关键字的关联程度——蜜桃粉长条代表高相关可信结果,草木绿中条代表部分相关需斟酌,警示红短条代表低相关可能非医疗门店。
第四层是距离与时间行——直线距离 X.XXkm(把米转公里保留两位小数)+ 时间文案,都用弱文本色呈现低优先级信息。ForEach 的 key 生成函数用 ${rec.name}-${rec.reliability} 拼接,保证每条结果的唯一性。第五块是 codePreviewCard 双特性代码预览卡——在列表底部展示 6.1.1 两大新特性的调用代码,让用户从数据呈现回到代码理解,形成"特性 → 调用 → 数据 → 代码"的完整认知闭环。
整个 tabSearch 的设计体现了"说明 → 输入 → 状态 → 结果 → 代码"的完整检索闭环:用户先看到特性说明明白在演示 reliability,再输入关键字触发搜索,看搜索状态变化,在结果列表看到 reliability 分数条与等级标签的可视化呈现,最后在代码预览卡看到底层调用代码。这是一个完整的"演示 6.1.1 reliability 特性"的教学型交互流,与地图 Tab 的长按事件演示形成 6.1.1 双特性的完整闭环。
12.1 代码预览卡 codePreviewCard
/** 双特性代码预览卡(深色底 monospace 展示 6.1.1 新调用) */
@Builder
codePreviewCard() {
Column({ space: 6 }) {
Text('⌨️ Map Kit 6.1.1 双新特性调用').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column({ space: 4 }) {
Text('const res = await site.searchByText(params)')
.fontSize(9).fontColor(COLORS.peach).fontFamily('monospace')
Text('res.sites[0].reliability // 0~1 分')
.fontSize(9).fontColor(COLORS.peach).fontFamily('monospace')
Text('manager.onMarkerLongClick(cb) // 24+')
.fontSize(9).fontColor(COLORS.grass).fontFamily('monospace')
Text('manager.onPoiLongClick(cb) // 24+')
.fontSize(9).fontColor(COLORS.grass).fontFamily('monospace')
}
.padding(10)
.borderRadius(8)
.backgroundColor(COLORS.codeBg)
.width('100%')
}
.padding(10)
.borderRadius(10)
.backgroundColor(COLORS.chip)
.width('100%')
}
codePreviewCard 是双特性代码预览卡,用奶粉灰背景的小卡片承载深玫瑰黑(COLORS.codeBg)的代码块。顶部标题"⌨️ Map Kit 6.1.1 双新特性调用"加粗深玫瑰棕,内部代码块分四行——前两行是 reliability 搜索特性(蜜桃粉文字):const res = await site.searchByText(params) 与 res.sites[0].reliability // 0~1 分;后两行是长按事件特性(草木绿文字):manager.onMarkerLongClick(cb) // 24+ 与 manager.onPoiLongClick(cb) // 24+。两特性用蜜桃粉与草木绿双色编码,与全应用的主题色系一致——蜜桃粉对应搜索 reliability(品牌主色),草木绿对应长按事件(辅助正向色),让用户从颜色就能区分两个特性。代码用 fontFamily('monospace') 等宽字体呈现,9px 字号紧凑显示,模拟代码编辑器的视觉感。这个卡片让搜索 Tab 不只是展示检索结果,还把底层调用代码呈现给用户,是教学型博文的典型设计。
十三、我的 Tab tabMine
/** 我的 Tab:萌宠会员卡 + 数据三宫格 + 功能清单 */
@Builder
tabMine() {
Column({ space: 10 }) {
// 萌宠会员渐变大卡
Column({ space: 8 }) {
Row({ space: 12 }) {
Text('🐾').fontSize(34)
Column({ space: 3 }) {
Text('萌宠会员 · 金卡').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('服务 8.5 折 · 每年 1 次免费体检').fontSize(11).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Divider().strokeWidth(1).color(COLORS.line)
Row() {
Text('本月服务 2 次').fontSize(11).fontColor(COLORS.sub)
Blank()
Text('已省 89 元').fontSize(11).fontColor(COLORS.peach)
}
.width('100%')
}
.padding(14)
.borderRadius(14)
.linearGradient({
angle: 135,
colors: [[COLORS.peachL, 0.0], [COLORS.card, 0.72]]
})
.width('100%')
// 数据三宫格
Row({ space: 8 }) {
this.statCell('2 只', '宠物档案')
this.statCell('5 家', '收藏门店')
this.statCell('8920 元', '累计消费')
}
.width('100%')
// 功能清单
ForEach(this.funcList, (item: FuncItem) => {
Row({ space: 10 }) {
Text(item.icon).fontSize(18)
Text(item.label).fontSize(13).fontColor(COLORS.title).layoutWeight(1)
Text(item.value).fontSize(11).fontColor(COLORS.sub)
Text('›').fontSize(14).fontColor(COLORS.text3)
}
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
.width('100%')
}, (item: FuncItem) => item.label)
// 版本脚注
Text('萌宠指南 v1.9.4 · Map Kit 6.1.1 双新特性演示').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
}
tabMine 构建"我的"Tab,分四块内容。第一块是萌宠会员渐变大卡——135 度对角渐变(浅蜜桃粉 0% → 纯白 72%),与头部 Banner 同款渐变风格,营造品牌一致性。卡片内分三部分:上方是会员身份行——大号爪印 emoji(34px)+ "萌宠会员 · 金卡"加粗深玫瑰棕 + "服务 8.5 折 · 每年 1 次免费体检"暖灰粉副标题;中间是分割线 Divider(奶粉灰 1px);下方是消费动态行——"本月服务 2 次"暖灰粉 + Blank() 占位 + "已省 89 元"蜜桃粉(突出节省金额,激励会员价值感)。
第二块是数据三宫格——三个 statCell 分别显示"2 只宠物档案"、“5 家收藏门店”、“8920 元累计消费”,与门店 Tab 的三宫格形成数据呼应(门店 Tab 显示本月数据,"我的"Tab 显示累计数据)。第三块是功能清单——ForEach 遍历 funcList(8 条功能项),每条是 Row 布局:emoji 图标(18px)+ 功能名(加粗深玫瑰棕,layoutWeight(1) 占满中间空间)+ 状态文本(暖灰粉)+ 右箭头 ›(弱文本色,暗示可点击跳转)。8 条功能覆盖宠物档案、疫苗提醒、体检记录、收藏门店、服务订单、会员卡、急诊绿通、偏好设置,构成完整的用户中心入口。第四块是版本脚注——"萌宠指南 v1.9.4 · Map Kit 6.1.1 双新特性演示"9px 弱文本色,明确告知应用版本与演示的技术特性,是教学型应用的典型署名。
十四、底部 Tab 栏 tabBar
/** 底部导航 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 构建底部 4 Tab 单排导航。ForEach 遍历 TAB_LIST 生成四个 Tab 项,每项是一个 Column(图标 + 标签),layoutWeight(1) 等宽分布。选中态(currentTab === idx)的视觉反馈有两重:标签色 COLORS.tabOn 蜜桃粉(未选中弱文本色浅粉灰);图标始终保持 18px(本应用未对图标做选中态放大处理,保持简洁)。点击任一 Tab 设置 currentTab = idx 切换内容区。整个 tabBar 以纯白卡片背景呈现,与上方滚动区视觉分离。这种"4 Tab 单排 + 选中态文字色切换"的设计是移动端导航的标准范式,简洁且信息密度合适。
十五、弹窗系统
15.1 全屏遮罩 modalOverlay
/** 弹窗遮罩层(点击空白处关闭) */
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
modalOverlay 是弹窗系统的公共遮罩 Builder,接收一个 onClose 回调。它是一个全屏 Column,以 COLORS.mask(半透明玫瑰棕 rgba(58,42,47,0.5),与标题色同源保证遮罩与文字色系一致)覆盖整个屏幕。整个 Column 的 onClick 绑定 onClose 回调,实现"点击遮罩关闭弹窗"的标准交互。这个遮罩会被三个弹窗(panelAdd / panelEdit / panelDel)复用,保证弹窗系统的视觉与行为一致性。
15.2 收藏门店弹窗 panelAdd
/** 收藏门店弹窗:门店名 + 服务项 + 地址输入 */
@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.formService })
.height(38)
.fontSize(12)
.fontColor(COLORS.title)
.placeholderColor(COLORS.text3)
.backgroundColor(COLORS.chip)
.onChange((v: string) => { this.formService = 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.peach)
.onClick(() => { this.savePet(); })
}
.width('100%')
}
.padding(16)
.borderRadius(14)
.backgroundColor(COLORS.card)
.width('82%')
}
.width('100%')
.height('100%')
}
panelAdd 是收藏新门店的弹窗面板,用 Stack 先叠加 modalOverlay 作为遮罩,再叠加一个 Column 作为面板本体(宽 82%、圆角 14、纯白卡片背景)。面板内含:标题"收藏新门店"加粗深玫瑰棕 + 三个 TextInput 输入框(门店名称、服务项、地址,分别绑定 formName / formService / formAddr,奶粉灰背景与卡片形成层次)+ 底部按钮行("取消"奶粉灰底暖灰粉字调用 onClose 关闭 + "收藏"蜜桃粉底调用 savePet 保存)。三个输入框都用 onChange 实时更新对应的 @State 字段,“收藏"按钮点击后由 savePet 方法做空值兜底并 unshift 到 petList 头部。这个弹窗是用户主动收藏新门店的入口,与门店 Tab 的”+收藏新门店"按钮联动。
15.3 编辑备注弹窗 panelEdit
/** 编辑备注弹窗:回填当前门店备注 */
@Builder
panelEdit(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('编辑门店备注').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(this.editIdx < this.petList.length ? this.petList[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.peach)
.onClick(() => { this.updatePet(); })
}
.width('100%')
}
.padding(16)
.borderRadius(14)
.backgroundColor(COLORS.card)
.width('82%')
}
.width('100%')
.height('100%')
}
panelEdit 是编辑备注的弹窗面板,结构与 panelAdd 类似但内容不同。面板内含:标题"编辑门店备注" + 当前门店名显示(this.petList[this.editIdx].name,做边界检查后显示,让用户知道在编辑哪家店)+ 一个 TextInput 输入框(绑定 editNote,placeholder"输入新备注",回填的初始值由 openEditPet 方法设置)+ 底部按钮行(“取消” + "保存"蜜桃粉底调用 updatePet)。updatePet 方法会把 editNote 写入目标门店的 note 字段,并用 slice() 整体刷新数组引用强制列表重新渲染。这个弹窗与门店 Tab 全部门店列表的"编辑"按钮联动——点击"编辑"调用 openEditPet(idx) 回填备注后弹出。
15.4 删除确认弹窗 panelDel
/** 删除确认弹窗:门店名 + 确认/取消 */
@Builder
panelDel(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('删除收藏门店').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(this.delIdx < this.petList.length
? `确定删除「${this.petList[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.delPet(); })
}
.width('100%')
}
.padding(16)
.borderRadius(14)
.backgroundColor(COLORS.card)
.width('82%')
}
.width('100%')
.height('100%')
}
panelDel 是删除确认弹窗,结构简洁。面板内含:标题"删除收藏门店" + 确认文案(做边界检查后显示"确定删除「门店名」吗?",让用户明确知道要删除哪一家)+ 底部按钮行(“取消” + "删除"警示红底 COLORS.red 调用 delPet)。"删除"按钮用警示红底与"取消"的奶粉灰底形成强对比,提示用户这是一个不可逆操作。delPet 方法会用 splice 移除目标门店并关闭弹窗。这个弹窗与门店 Tab 全部门店列表的"删除"按钮联动——点击"删除"设置 delIdx 并弹出确认弹窗,避免误删。三个弹窗的视觉一致性通过相同的 modalOverlay 遮罩 + 纯白卡片背景 + 圆角 14 + 宽 82% + 按钮样式实现,差异通过内容区分。
十六、Map Kit 6.1.1 双新特性 API 对比
| 特性类别 | API 名称 | 所属模块 | 参数 | 返回/回调 | 作用 |
|---|---|---|---|---|---|
| 搜索·相关性 | site.searchByText |
@kit.MapKit 的 site |
SearchByTextParams(query/location/radius/language) |
Promise<SearchByTextResult> |
按关键字检索周边地点 |
| 搜索·新字段 | Site.reliability |
site.Site 类型字段 |
无(读取字段) | number([0,1]) |
量化结果与关键字关联程度 |
| 事件·Marker 长按 | MapEventManager.onMarkerLongClick |
map.MapEventManager |
(marker: map.Marker) => void 回调 |
无(事件订阅) | 监听地图标记长按手势 |
| 事件·Marker 长按解除 | MapEventManager.offMarkerLongClick |
map.MapEventManager |
无参(清除全部订阅) | 无 | 取消 Marker 长按监听 |
| 事件·POI 长按 | MapEventManager.onPoiLongClick |
map.MapEventManager |
(poi: mapCommon.Poi) => void 回调 |
无(事件订阅) | 监听地图 POI 长按手势 |
| 事件·POI 长按解除 | MapEventManager.offPoiLongClick |
map.MapEventManager |
无参(清除全部订阅) | 无 | 取消 POI 长按监听 |
十七、reliability 三档映射规则
| 分数区间 | 等级标签 | 映射颜色 | 语义解读 | 典型场景 |
|---|---|---|---|---|
≥0.8 |
高相关 | 蜜桃粉 #E85D8A |
完全可信的宠物医院结果 | “南山路宠安宠物医院”(0.93) |
≥0.5 且 <0.8 |
中相关 | 草木绿 #5DA83F |
部分相关,可能是综合宠物服务 | “文三路萌宠诊疗中心”(0.68) |
<0.5 |
低相关 | 警示红 #E5484D |
关联度低,可能非医疗门店 | “宠物摄影工作室”(0.06) |
十八、四个 Tab 功能维度对比
| 维度 | 门店 Tab | 地图 Tab | 搜索 Tab | 我的 Tab |
|---|---|---|---|---|
| 布局方式 | 三宫格 + 精选大卡 + 列表 + 小贴士 | 说明卡 + 开关行 + 地图 + 日志流 | 说明卡 + 搜索框 + List 列表 + 代码卡 | 渐变大卡 + 三宫格 + 功能清单行 |
| 数据模型 | PetItem(@Observed) |
EventLog(@Observed) |
SearchRecord(@Observed) |
FuncItem(普通接口) |
| 字段数 | 6 字段 | 5 字段 | 5 字段 | 3 字段 |
| 核心操作 | 收藏/编辑/删除门店 | Marker/POI 长按监听开关 | 关键字搜索触发 | 功能入口跳转 |
| 6.1.1 特性 | 无(业务主 Tab) | 长按事件特性 | reliability 相关性特性 | 无(用户中心) |
| 数据量 | 7 条门店 + 3 条精选 | 2 条初始日志 + 实时 unshift | 6 条搜索结果 | 8 条功能项 |
| 特殊组件 | statCell 复用 + tipsCard |
MapComponent + Toggle + Progress 无 |
List/ListItem + Progress 分数条 |
Divider + 渐变大卡 |
| 状态颜色 | doctorColor + erColor 映射 |
Marker 蜜桃粉 / POI 草木绿双色 | reliabilityScore 三色映射 |
会员节省金额蜜桃粉 |
十九、总结
本应用以"萌宠指南 · 宠物医院门店"为产品定位,完整呈现了一个 HarmonyOS ArkUI 宠物医疗门店应用的典型架构与 6.1.1 Map Kit 双新特性的完整落地。从产品维度看,四个 Tab(门店 / 地图 / 搜索 / 我的)覆盖了宠物医疗门店用户的核心使用闭环——门店发现(精选大卡 + 全部门店列表 + 养宠小贴士)、地图定位(MapComponent 标注 + 长按事件留痕)、可信检索(searchByText + reliability 分数量化)、个人管理(萌宠会员卡 + 功能清单),形成完整的"发现 - 定位 - 检索 - 管理"价值链。
从技术维度看,本应用最大的技术亮点是 HarmonyOS 6.1.1 Map Kit 两大新特性的完整落地。其一是 site.searchByText 返回的 Site 类型新增 reliability 字段——这个取值 [0, 1] 的相关性分数让应用可以量化每条搜索结果与"宠物医院"关键字的关联程度。在宠物医疗场景中,这解决了"搜索结果混入非医疗门店"的长期痛点:过去用户搜"宠物医院"常常看到宠物用品批发部、宠物摄影工作室等无关结果,却无法判断哪些是真医院;reliability 分数让应用可以做两件事——按分数倒序排序把真医院顶到最前,用分数条 + 等级标签可视化呈现让用户一眼看出可信度。应用通过 reliabilityScore 函数把分数映射成"高相关蜜桃粉 / 中相关草木绿 / 低相关警示红"三档颜色,配合 Progress 线性进度条形成"标签 + 分数条"的双重视觉编码,是 reliability 特性的标准落地范式。
其二是 MapEventManager 新增的 onMarkerLongClick / offMarkerLongClick 与 onPoiLongClick / offPoiLongClick 双长按监听。在此之前,地图侧只能监听 Marker 与 POI 的单击,长按这一"更重意图"的手势一直没有原生支持,6.1.1 补齐了这一空白。在宠物门店场景中,长按监听的价值在于"地点留痕"——用户在地图上看到某家门店或某个 POI,长按即可把它的坐标与名称记录到事件日志流,相当于一种快速的地图标记收藏,省去了打开门店详情页的跳转成本。应用通过 EventLog 数据模型把长按事件持久化到日志流,Marker 事件用蜜桃粉编码、POI 事件用草木绿编码,形成"两种类型双色区分"的视觉体系。offMarkerLongClick / offPoiLongClick 不传参即清除全部订阅的设计,让用户可以按需关闭不需要的监听,减少不必要的事件触发。
从工程维度看,本应用展示了 ArkUI 声明式范式在地图场景下的多项最佳实践。MapComponent 通过 mapOptions 声明初始视角与缩放、通过 mapCallback 异步拿控制器,控制器就绪后才能调用 getEventManager() 拿事件管理器并注册长按监听——这条"组件 → 控制器 → 事件管理器"的链路必须严格顺序执行,因此在 mapCallback 的 err 为空分支内完成全部注册是最稳妥的做法。批量 addMarker 用 for-of + await + try-catch 逐个添加,避免单个 Marker 失败影响整批。reliability 字段是可选字段,用 ?? 0 兜底保证空值不污染分数条;searchByText 在无 AGC 配置或无网络时会抛 BusinessError,catch 分支保留 Mock 数据让演示链路不中断。@Observed 类(PetItem、SearchRecord、EventLog)配合 @State 数组实现列表响应式刷新,slice() 创建新数组引用强制整体刷新是处理元素属性变更的可靠技巧,unshift 置顶让最新的长按事件出现在日志流最上方。
从设计维度看,奶油底 + 蜜桃粉 + 草木绿的三色主题体系贯穿全应用——奶油底 #FDF7F1 作为背景营造温暖柔软的宠物友好视觉气质,蜜桃粉 #E85D8A 作为品牌主色在渐变 Banner、选中态、主操作按钮、reliability 高相关标签、底部 Tab 激活色等位置高频出现,草木绿 #5DA83F 作为辅助正向色在 24h 急诊标记、医生充足、reliability 中相关、POI 长按事件编码等位置视觉跳出。整套配色通过 ColorPalette 接口 + COLORS 常量集中管理,保证全应用颜色一致性与可维护性。渐变 Banner(linearGradient 135 度对角)在头部与"我的"Tab 顶部营造品牌质感,奶粉灰胶囊与纯白卡片的层次递进让信息密度合适,半透明玫瑰棕遮罩 + 圆角卡片弹窗形成一致的弹窗视觉语言。reliability 分数条用三色编码把抽象的相关性分数变成直观的可视化光谱,是数据可视化的优秀实践。
从生态维度看,本应用选择真实的杭州街区(南山路、湖墅南路、文三路、凤起路、湖滨、河坊街、文二路)作为门店地址,让演示更贴近真实使用场景。Map Kit 的 MapComponent 让原生 ArkUI 页面可以直接嵌入完整地图内核,在不跳转外部应用的前提下渲染地图、标注 Marker、监听长按手势,并通过 MapComponentController 与 MapEventManager 进行双向通信——这种"原生组件 + 地图服务"的混合架构是宠物门店地图场景的理想形态:原生侧负责门店列表管理、弹窗交互、数据持久化,地图侧负责地理渲染、Marker 标注、POI 检索,两者通过 6.1.1 的双长按监听与 reliability 字段桥接交互与可信度。HarmonyOS 6.1.1 的这两项 Map Kit 新特性正是这种混合架构的关键能力增强,让原生侧能完整感知地图侧的长按手势并量化检索结果的可信度,是宠物医疗门店应用从"能搜能看"到"可留痕可量化"演进的核心支撑。
附录:DevEco Studio 创建新项目与查看 SDK 版本
本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。
一、创建新项目
1.1 进入欢迎界面
启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:
- 新建项目:从头创建新项目
- 打开项目:打开本地已有项目
- 克隆仓库:从 Git 等版本控制拉取代码
点击 “新建项目” 按钮,进入项目创建向导。

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

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

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

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

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

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

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

所有评论(0)