一、技术前言

在这里插入图片描述
HarmonyOS ArkUI 框架是华为为鸿蒙生态打造声明式 UI 开发范式,其核心思想是通过 @Component@Entry@State@Builder 等装饰器将界面结构、状态管理与渲染逻辑统一到 ArkTS 语言体系中。与传统命令式 UI 不同,ArkUI 采用状态驱动渲染——当 @State 修饰的变量发生变化时,框架自动 diff 并更新与之绑定的 UI 子树,开发者无需手动操作 DOM 或调用 invalidate。这种模式在复杂业务页面(如多 Tab 导航、地图嵌入、弹窗叠加)中极大降低了状态同步的心智负担,使开发者可以将精力集中于业务数据流本身。

在这里插入图片描述
Map Kit 是 HarmonyOS 提供的位置服务核心能力集,通过 MapComponent 组件可以将原生地图视图直接嵌入 ArkUI 页面树。MapComponent 接收 mapOptions(初始化参数,包含 position.target 中心点坐标和 zoom 缩放级别)和 mapCallback(初始化回调函数)两个核心入参。回调函数签名为 AsyncCallback<MapComponentController>,在地图引擎完成底层渲染后异步触发,开发者在该回调中才能安全获取 MapComponentController 控制器实例,进而调用 addMarkergetEventManager 等方法。这一异步约束保证了所有地图操作都在引擎就绪后执行,避免了空指针和竞态条件。

在这里插入图片描述
site 模块是 Map Kit 的地点搜索能力入口,其中 searchByText 方法基于关键字进行周边 POI 检索。开发者构造 SearchByTextParams 对象(包含 query 搜索词、location 中心坐标、radius 搜索半径、language 语言代码),调用后返回 SearchByTextResult,其 sites 数组中每个 Site 对象携带 name(地点名)、formatAddress(格式化地址)、distance(直线距离)等字段。在 HarmonyOS 6.1.1 版本中,Site 类型新增 reliability 字段——一个取值范围为 [0, 1] 的浮点数,1 表示与搜索关键字完全相关,0 表示完全不相关。这一字段的加入使搜索结果从"有无"维度升级为"好坏"维度,开发者可以据此做结果排序、过滤、可视化分级展示,显著提升用户在信息密集场景下的决策效率。

在这里插入图片描述
MapEventManager 是 Map Kit 的事件管理器,通过 MapComponentController.getEventManager() 获取。它提供 onMarkerClick/offMarkerClick(标记点击)、onPoiClick/offPoiClick(POI 点击)、onCamera/offCamera(相机变化)等常规事件监听接口。HarmonyOS 6.1.1 在此基础上新增 onMarkerLongClick/offMarkerLongClick(地图标记长按监听)和 onPoiLongClick/offPoiLongClick(POI 长按监听)。长按事件回调分别接收 map.MarkermapCommon.Poi 参数对象,前者可通过 getPosition() 获取标记经纬度、getId() 获取标记唯一标识,后者直接暴露 name(POI 名称)和 position(经纬度)。长按交互模式在移动端天然适合"查看详情"“标记收藏”"触发上下文菜单"等操作,相比点击可以承载更丰富的语义意图。

在这里插入图片描述
社区药店导航场景是 Map Kit 新特性的典型落地领域。用户在城市中寻找附近药店时,往往面临"搜索结果太多但不知哪家真正相关""地图上药店密密麻麻但难以快速选取目标"两大痛点。reliability 相关性评分让搜索结果从模糊列表变为可量化排序的精选清单——0.96 分的药店排在首位,0.10 分的药品批发市场自动沉底;onMarkerLongClick 长按事件则让用户在地图上直接长按药店标记即可触发收藏、导航或详情查看,无需先点击标记再找按钮,交互路径缩短一半。两者结合,构建出"搜索可量化、地图可长按、日志可追溯"的完整药店发现闭环。

在这里插入图片描述
本文将以一个完整的"康民药图·附近药店导航"应用为载体,逐段剖析如何将 HarmonyOS 6.1.1 的 reliability 字段与 MapEventManager 双长按监听落地到社区医药健康服务的实际业务中。应用采用浅色主题(药白底 #F4FAF7 + 薄荷绿 #2FAE7E + 信任蓝 #2E6FD8),包含药店、地图、搜索、我的四个 Tab 页面,每个页面布局完全不同,通过 @Builder 函数群实现高度组件化拆分。弹窗系统采用 Stack 全屏遮罩 + 三层 if 条件渲染实现收藏、编辑、删除三种模态面板。

在这里插入图片描述

二、整体架构流程图

长按事件日志流

弹窗系统

reliability搜索链路

四Tab业务流

6.1.1长按监听注册

Marker批量添加

MapComponent初始化

页面入口

err 非空

err 为空

aboutToAppear 生命周期

setupMapCallback 构造回调

MapComponent 渲染

mapCallback 触发

console.error 日志

获取 MapComponentController

getEventManager 获取事件管理器

遍历 MARKER_SPOTS

构造 MarkerOptions

await addMarker 逐个添加

6个药店标记全部就绪

onMarkerLongClick 注册

onPoiLongClick 注册

药店Tab 推荐列表+收藏管理

地图Tab MapComponent+日志流

搜索Tab searchByText+reliability

我的Tab 家庭健康卡+功能清单

构造 SearchByTextParams

await site.searchByText

读取 Site.reliability 字段

reliabilityScore 分数映射

分数条+等级标签渲染

panelAdd 收藏药店

panelEdit 编辑备注

panelDel 删除确认

Marker长按 → EventLog unshift

POI长按 → EventLog unshift

事件日志流列表渲染

三、逐段代码分析

3.1 模块导入与颜色系统

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

逐行解释:

第 1 行从 @kit.MapKit 命名空间导入四个核心模块。MapComponent 是地图渲染组件,作为 ArkUI 内置组件在 build() 中直接使用,接收 mapOptionsmapCallback 参数。mapCommon 是通用类型命名空间,包含 LatLng(经纬度坐标)、MapOptions(地图初始化参数)、MarkerOptions(标记配置)、Poi(POI 数据结构)等类型定义。map 是控制器与事件命名空间,包含 MapComponentController(地图控制器)、MapEventManager(事件管理器)、Marker(标记对象)等。site 是地点搜索命名空间,包含 searchByText 方法及 SearchByTextParamsSearchByTextResultSite 等类型。四大模块各司其职:MapComponent 负责渲染、mapCommon 提供数据契约、map 提供运行时控制、site 提供搜索能力。

第 2 行从 @kit.BasicServicesKit 导入 AsyncCallbackBusinessErrorAsyncCallback<T> 是 HarmonyOS 异步回调的通用泛型签名,MapComponentmapCallback 参数即为此类型,形如 (err: BusinessError, controller: T) => voidBusinessError 是标准业务错误对象,携带 code(错误码)和 message(错误描述),在 try-catch 块和回调 err 分支中统一使用。这两个类型的导入贯穿整个地图初始化和搜索调用的错误处理链路。

设计意图: ArkTS 采用 import { ... } from '@kit.XXX' 的静态导入语法,编译期确定依赖关系。Map Kit 的四个子模块(MapComponentmapCommonmapsite)按职责拆分,开发者只需按需导入,编译器可做 Tree-shaking 优化。BasicServicesKit 的异步回调类型与 Map Kit 解耦,体现了鸿蒙系统服务层的模块化设计理念。

/** 主题色板接口:集中声明页面所有颜色字段(药白底+薄荷绿+信任蓝浅色系) */
interface ColorPalette {
  bg: string;      // 页面药白底色
  card: string;    // 卡片纯白底色
  chip: string;    // 胶囊与输入框底色
  title: string;   // 主标题深墨绿
  sub: string;     // 副文本灰绿
  text3: string;   // 弱文本浅灰绿
  mint: string;    // 薄荷绿主色
  mintD: string;   // 深薄荷绿
  mintL: string;   // 浅薄荷绿(渐变浅端)
  blue: string;    // 信任蓝辅助色
  orange: string;  // 库存预警橙
  red: string;     // 低相关/删除警示红
  line: string;    // 分隔线淡绿灰
  tabOn: string;   // 底部 Tab 激活色
  mask: string;    // 弹窗遮罩色
  codeBg: string;  // 代码预览卡深底色(浅色主题也保留深底放代码文本)
}

/** 浅色主题色板常量(康民药图 · 药白底 + 薄荷绿 + 信任蓝) */
const COLORS: ColorPalette = {
  bg: '#F4FAF7',
  card: '#FFFFFF',
  chip: '#E5F2EB',
  title: '#1C332A',
  sub: '#557766',
  text3: '#8FA9A0',
  mint: '#2FAE7E',
  mintD: '#1E7D58',
  mintL: '#D8F1E6',
  blue: '#2E6FD8',
  orange: '#E8862E',
  red: '#E5484D',
  line: '#DCEBE3',
  tabOn: '#2FAE7E',
  mask: 'rgba(28,51,42,0.5)',
  codeBg: '#12241C'
};

逐行解释:

ColorPalette 接口定义了 16 个颜色字段,覆盖页面背景、卡片底色、文本层级(三级)、主辅强调色、功能色(预警橙、警示红)、分隔线、Tab 激活色、弹窗遮罩、代码块底色。interface 声明使每个颜色字段在 TypeScript 层面获得类型约束,任何拼写错误或遗漏字段在编译期即被捕获。字段命名采用语义化约定(bg 背景、title 主标题、sub 副文本、text3 三级文本),而非 color1/color2 这种无意义编号,提升代码可读性。

COLORS 常量以 ColorPalette 接口为类型约束实例化。三个核心色:药白底 #F4FAF7(略带绿色的极浅底,比纯白更护眼,适合长时间阅读药品信息)、薄荷绿 #2FAE7E(主操作色,按钮、Tab激活、高相关标签)、信任蓝 #2E6FD8(辅助色,中相关标签、编辑操作、夜间窗口标记)。三个文本层级形成视觉信息梯度:title 深墨绿 #1C332A 用于药店名等关键信息,sub 灰绿 #557766 用于营业时间等辅助信息,text3 浅灰绿 #8FA9A0 用于距离、时间戳等弱信息。

设计意图: 颜色集中管理是大型前端项目的工程化共识。社区医药健康场景需要传递"安全"“信任”"自然"的心理感受——薄荷绿暗示草本药物与健康,信任蓝暗示医保可靠与专业保障,药白底比纯白减少视觉疲劳。codeBg 字段在浅色主题下仍保留深底色 #12241C,这是因为代码文本在深色背景上辨识度更高,即使整体浅色也不妥协代码可读性。mask 使用 rgba 半透明而非纯黑,叠加深墨绿色调使遮罩与主题色系协调。

3.2 常量定义与 Mock 数据

/** 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[] = ['全部', '24小时', '医保定点', '夜间窗口', '连锁药房', '中医坐诊', '送药上门', '处方药'];

/** 城市中心点(成都天府广场周边,地图初始化中心与 Map Kit 搜索 location 参数) */
const CITY_CENTER: mapCommon.LatLng = { latitude: 30.5728, longitude: 104.0668 };

逐行解释:

TabMeta 接口定义底部导航的元数据结构,icon 使用 emoji 字符串而非图片资源,减少包体积且无需适配多分辨率。TAB_LIST 数组包含 4 个 Tab:药店(业务主 Tab)、地图(Map Kit 长按特性页)、搜索(reliability 特性页)、我的(个人健康中心)。ForEach 渲染时以 tab.label 作为 key,保证 Tab 切换时组件复用。

CATE_TAGS 定义 8 个筛选标签,覆盖社区药店的核心服务维度:24小时 应对急症夜间购药、医保定点 关系到报销、夜间窗口 是 24 小时药店的子集但更聚焦窗口服务、中医坐诊 对应中药调理需求、送药上门 服务行动不便人群、处方药 需要执业药师在岗。这些标签横滑展示,选中态用薄荷绿底色区分。

CITY_CENTERmapCommon.LatLng 类型,包含成都天府广场坐标(纬度 30.5728,经度 104.0668)。该常量被两处复用:MapComponentmapOptions.position.target 作为地图初始化中心点,site.searchByTextparams.location 作为搜索圆心。复用同一坐标保证地图视野与搜索范围在地理上一致。

设计意图: 常量集中声明是数据与视图分离的基础。CITY_CENTER 复用尤其重要——地图初始化中心和搜索中心使用同一坐标,用户在地图 Tab 看到的视野范围与在搜索 Tab 查询的地理范围完全吻合,避免了"地图显示 A 区域但搜索返回 B 区域"的体验割裂。

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

/** 药店标注点 Mock 数据(6 个,围绕城市中心点 ±0.02 度散布) */
const MARKER_SPOTS: SpotItem[] = [
  { name: '康民药图·春熙路大药房', lat: 30.5756, lng: 104.0816, tag: '24小时' },
  { name: '康民药图·人民中路药房', lat: 30.5750, lng: 104.0656, tag: '医保定点' },
  { name: '康民药图·建设路连锁药店', lat: 30.5800, lng: 104.0770, tag: '连锁' },
  { name: '康民药图·桐梓林中医馆', lat: 30.5612, lng: 104.0620, tag: '中医坐诊' },
  { name: '康民药图·一环路北药房', lat: 30.5831, lng: 104.0552, tag: '夜间窗口' },
  { name: '康民药图·天府大道药店', lat: 30.5580, lng: 104.0730, tag: '处方药' }
];

逐行解释:

SpotItem 接口是地图 Marker 的数据契约,包含名称、经纬度、服务标签。MARKER_SPOTS 数组含 6 个药店标注点,围绕 CITY_CENTER 在 ±0.02 度范围内散布。这个范围在成都市区约 2.2 公里跨度,覆盖了从春熙路到桐梓林的商圈密集区。每个药店携带不同的服务标签(24小时、医保定点、连锁、中医坐诊、夜间窗口、处方药),与头部筛选 chips 形成语义呼应。

这些标注点数据在 setupMapCallback 中被遍历,逐个调用 addMarker 添加到地图上。当用户长按某个 Marker 时,onMarkerLongClick 回调通过 marker.getId() 获取标记标识(通常为添加顺序的索引 #0#1…),结合 getPosition() 的经纬度写入事件日志流。

设计意图: Mock 数据以成都真实地名构造(春熙路、人民中路、建设路、桐梓林、一环路北、天府大道),使演示效果贴近真实业务。±0.02 度散布策略保证 6 个标记在 zoom: 13 缩放级别下全部可见——该缩放级别约对应 2 公里视野,标记不会重叠也不会超出屏幕。服务标签的多样性确保筛选 chips 不是空壳,每个标签都对应至少一个真实药店。

3.3 辅助函数: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.mint };
  }
  if (score >= 0.5) {
    return { label: '中相关', color: COLORS.blue };
  }
  return { label: '低相关', color: COLORS.red };
}

逐行解释:

ScoreLevel 接口定义等级映射的返回结构,包含文案标签和颜色两个字符串字段。reliabilityScore 函数接收 score 参数(即 Site.reliability 的值),通过两段 if 阈值判断返回对应的 { label, color } 对象。

阈值设计:≥0.8 为"高相关"配薄荷绿主色(表示推荐),≥0.5 为"中相关"配信任蓝辅助色(表示可选),<0.5 为"低相关"配警示红(表示不推荐)。这种三分法将 [0, 1] 连续区间离散化为三个语义档位,与用户认知中的"好/中/差"三档对齐。

设计意图: reliability 是 6.1.1 新增的连续浮点字段,直接展示数字(如 0.71)对普通用户无意义。本函数将数值映射为颜色+文案的视觉信号,是"技术字段→用户体验"的转换桥梁。函数返回对象而非单独返回标签或颜色,是因为分数条组件需要同时使用两者(标签放右侧,颜色用于 Progress 条和标签文字着色)。0.8 和 0.5 的阈值可根据业务调优——药店搜索可能要求更高精度,可调整为 0.85 和 0.6。

/** 常备药数量颜色映射:≥200 种齐全薄荷绿 / ≥100 种尚可信任蓝 / 其余偏少橙 */
function stockColor(meds: number): string {
  if (meds >= 200) {
    return COLORS.mint;
  }
  if (meds >= 100) {
    return COLORS.blue;
  }
  return COLORS.orange;
}

/** 夜间窗口颜色映射:有夜间窗口信任蓝 / 其余弱文本 */
function nightColor(night: boolean): string {
  if (night) {
    return COLORS.blue;
  }
  return COLORS.text3;
}

逐行解释:

stockColor 函数将常备药 SKU 数量映射为颜色:≥200 种薄荷绿(库存齐全,推荐)、≥100 种信任蓝(尚可,可选)、<100 种预警橙(偏少,需注意)。nightColor 函数将是否有夜间窗口映射为二值颜色:有则信任蓝(强调可用性),无则弱文本灰绿(视觉淡化)。

两个函数都采用与 reliabilityScore 相同的阈值判断结构,形成全应用一致的"数值→颜色"映射范式。stockColor 在推荐药店大卡和全部药店列表中复用,nightColor 在药店列表的夜间窗口标签中使用。

设计意图: 辅助函数群将业务规则从 UI 渲染逻辑中剥离。stockColor(260) 返回薄荷绿意味着"260 种常备药属于齐全级别"这一业务判断,不在 Builder 函数中内联 if-else,使 UI 代码只关注布局和样式。三个映射函数共用阈值判断模式,代码风格统一,便于后续维护时批量调整阈值逻辑。

3.4 数据模型:@Observed 响应式类

/** 药店条目(药店 Tab 推荐列表,@Observed 支持备注编辑刷新) */
@Observed export class PharmItem {
  icon: string;     // 药店 emoji 图标
  name: string;     // 药店名
  openUntil: string; // 营业至时间
  night: boolean;   // 是否有夜间窗口
  meds: number;     // 常备药 SKU 数
  note: string;     // 用户备注(可编辑)

  constructor(icon: string, name: string, openUntil: string,
    night: boolean, meds: number, note: string) {
    this.icon = icon;
    this.name = name;
    this.openUntil = openUntil;
    this.night = night;
    this.meds = meds;
    this.note = note;
  }
}

逐行解释:

@Observed 装饰器将 PharmItem 类标记为可观察对象。在 ArkUI V2 渲染模型中,@State 修饰的数组本身是响应式的(数组增删触发更新),但数组元素对象的属性变更默认不触发渲染。@Observed 通过 Proxy 拦截属性 set 操作,当 pharmList[0].note = '新备注' 被执行时,框架检测到变化并通知绑定了该属性的 UI 组件更新。

export 关键字使该类可被其他文件导入复用。构造函数接收 6 个参数初始化全部字段。note 字段是用户可编辑的备注文本,是 @Observed 的核心使用场景——编辑弹窗修改 note 后,列表项的备注行自动刷新。

设计意图: @Observed + @State 数组的组合是 ArkUI 管理对象数组状态的标准范式。没有 @Observed,修改 pharmList[idx].note 后必须手动触发数组引用替换(如 this.pharmList = this.pharmList.slice())才能刷新,代码冗余且性能差。@Observed 让对象级修改自动反映到视图,是"数据驱动"理念的底层支撑。

/** 搜索结果条目(★ 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;
  }
}

逐行解释:

SearchRecordsite.searchByText 结果的数据载体类。每个字段都映射 Site 类型的对应属性:names.nameaddresss.formatAddressdistances.distancereliabilitys.reliabilitytime 是本地生成的记录时间文案(如"刚刚"“3 分钟前”),非 Site 字段。

@Observed 装饰使搜索结果列表在 searchRecords 被整体替换时自动触发列表重渲染。reliability 字段以注释 标注,表明这是 6.1.1 新增字段的承载点。Mock 数据中 6 条记录的 reliability 值覆盖高/中/低三档(0.96、0.9、0.71、0.56、0.22、0.1),确保 UI 展示时三种颜色和标签都能呈现。

设计意图:Site 对象转换为 SearchRecord 而非直接使用 Site,有两个好处:一是解耦——UI 层不依赖 site.Site 的具体 API,字段重命名或增删不影响渲染逻辑;二是扩展——time 等非 Site 字段可自由添加。@Observed 保证 reliability 变化(如用户手动调参重搜)时分数条和标签实时更新。

/** 长按事件日志条目(★ MapEventManager 长按监听数据载体) */
@Observed export class EventLog {
  type: string;   // 事件类型:'Marker' / 'POI'
  name: string;   // Marker ID 或 POI 名称
  lat: number;    // 纬度
  lng: number;    // 经度
  time: string;   // 事件时间文案

  constructor(type: string, name: string, lat: number, lng: number, time: string) {
    this.type = type;
    this.name = name;
    this.lat = lat;
    this.lng = lng;
    this.time = time;
  }
}

逐行解释:

EventLog 是长按事件日志流的数据载体。type 区分事件来源——'Marker' 表示长按的是通过 addMarker 添加的药店标记,'POI' 表示长按的是地图引擎内置的 POI 地点(如商圈、地标)。name 字段对 Marker 存储 marker.getId() 返回的标记 ID(如 #0),对 POI 存储 poi.name 返回的地点名称(如"天府广场")。lat/lng 记录长按位置的精确经纬度,time 存储事件发生时间文案。

@Observed 使 eventLogs 数组的 unshift 操作(新事件置顶)自动触发日志流列表的增量渲染——新日志条目从顶部插入,已有条目向下平移,动画由框架 ForEach diff 驱动。

设计意图: 统一 Marker 和 POI 两种长按事件到同一数据结构,简化日志流渲染逻辑。ForEach 只需判断 log.type 选择 emoji 图标和标签颜色,无需区分数据源。lat/lng 保留精度到 4 位小数(约 11 米),足以在地图上定位药店入口。Mock 数据预置 2 条演示事件,让用户进入地图 Tab 即可见到日志流形态。

3.5 组件状态与 Map Kit 初始化参数

@Entry
@Component
struct Page1136 {
  /** 当前选中 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 pharmList: Array<PharmItem> = PHARM_LIST;
  /** 我的页功能清单数据 */
  @State funcList: FuncItem[] = FUNC_LIST;

逐行解释:

@Entry 标记此结构为页面入口组件,@Component 声明这是一个 ArkUI 组件。@State 修饰的变量是组件的响应式状态,值变化时触发依赖该变量的 UI 子树重新渲染。

前 8 个状态变量分三组:Tab 导航组(currentTab 控制当前显示的 Tab 页面、cateIdx 控制头部筛选选中态)、弹窗组(addModal/editModal/delModal 三个布尔开关分别控制三种模态弹窗的显示与隐藏、editIdx/delIdx 记录当前操作的目标药店索引)、数据组(pharmList 药店列表、funcList 功能清单)。

设计意图: 弹窗使用独立布尔变量而非枚举,因为三种弹窗可能同时处于不同生命周期阶段(如关闭删除弹窗的同时打开编辑弹窗)。editIdx/delIdx 与弹窗开关分离存储,避免弹窗关闭后丢失目标索引——用户点击"删除"时先设置 delIdx 再打开 delModal,即使弹窗被遮罩关闭,索引仍保留供下次使用。pharmList 初始化为 PHARM_LIST 常量,后续 unshift/splice 操作直接修改该状态数组。

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

逐行解释:

Map Kit 相关状态分四层:初始化层(mapOptions 地图参数、mapCallback 回调函数、mapController 控制器、mapEventManager 事件管理器)、长按事件层(markerListenOn/poiListenOn 监听开关、eventLogs 日志流)、搜索层(queryInput 关键字、searchState 状态文案、searchRecords 结果列表)、弹窗表单层(formName/formMeds/formAddr 收藏表单、editNote 编辑备注)。

mapOptions 初始化为 { position: { target: CITY_CENTER, zoom: 13 } }zoom: 13 是街道级缩放,约 2 公里视野范围。mapCallback/mapController/mapEventManager 使用 ? 可选类型修饰,因为它们在组件构造时为 undefined,仅在 aboutToAppear 调用 setupMapCallback 后赋值。private 修饰符限制这些变量仅在组件内部访问。

markerListenOn/poiListenOn 默认为 true,因为 setupMapCallback 中会默认注册两种长按监听。eventLogs 初始化为 2 条 Mock 演示数据,searchRecords 初始化为 6 条覆盖高/中/低三档 reliability 的 Mock 数据。queryInput 默认 '药店',用户可直接点搜索按钮测试。

设计意图: Map Kit 的四层状态严格区分"引擎资源"(controller/eventManager,非响应式 private)与"业务数据"(监听开关/日志/搜索结果,@State 响应式)。引擎资源只在初始化和事件回调中使用,无需触发 UI 更新;业务数据直接驱动 UI 渲染,必须响应式。这种分层避免了将 mapController 设为 @State 后,控制器对象深层属性变化触发不必要的 diff。

3.6 地图回调与长按监听注册(6.1.1 特性二核心)

  /**
   * 地图初始化: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 方法构造地图初始化回调函数并赋值给 this.mapCallback。回调是 async 函数,因为内部使用 await this.mapController.addMarker()

回调首行检查 err 参数——如果地图引擎初始化失败(如设备无 Google Play 服务、AGC 配置缺失),err 非空,console.error 输出错误码和消息后 return,阻止后续依赖 mapController 的操作执行。这是防御性编程的标准模式。

err 为空时,mapController 参数已就绪。将其赋值给 this.mapController 供后续 addMarker 使用,同时调用 mapController.getEventManager() 获取事件管理器赋值给 this.mapEventManagergetEventManager()MapComponentController 的实例方法,返回 MapEventManager 实例——所有事件监听(点击、长按、相机变化)都通过该管理器注册和注销。

Marker 批量添加使用 for...of 遍历 MARKER_SPOTS 数组。每次构造 MarkerOptions 对象,包含 12 个配置项:position(经纬度坐标)、clickable: true(可点击,是长按的前提)、visible: true(可见)、rotation: 0(无旋转)、zIndex: 0(层级)、alpha: 1(不透明)、anchorU: 0.5/anchorV: 1(锚点在图标底部中心,使标记尖对准坐标点)、draggable: false(不可拖拽)、flat: false(非贴地,保持图标始终面向屏幕)。

await this.mapController.addMarker(markerOptions) 是异步操作,返回 Promise<map.Marker>。逐个 await 而非 Promise.all 并行,是因为 Marker 添加涉及底层 GL 资源分配,串行更安全。每个 addMarker 包裹 try-catch,单个 Marker 失败不影响其他添加——catch 中将 e 断言为 BusinessError 并输出错误消息。

6.1.1 特性核心: Marker 全部添加后,注册两个长按监听。onMarkerLongClick 接收回调函数,参数类型为 map.Marker。当用户长按地图上任何通过 addMarker 添加的标记时触发。回调内通过 marker.getPosition() 获取标记经纬度(mapCommon.LatLng 类型),marker.getId() 获取标记唯一 ID(通常是添加顺序的整数索引)。构造 EventLog 实例,type'Marker'name`#${marker.getId()}`(如 #0#1),unshifteventLogs 数组顶部实现最新事件置顶。

onPoiLongClick 的回调参数类型为 mapCommon.Poi,与 Marker 不同。POI(Point of Interest)是地图引擎内置的兴趣点——商圈、地标、政府机构等,无需开发者手动添加。poi.name 直接暴露地点名称字符串,poi.positionmapCommon.LatLng 类型含 latitude/longitude。长按地图上非 Marker 区域的 POI 地点时触发,构造 EventLogtype'POI'namepoi.name(如"天府广场")。

设计意图: 这是全应用的技术核心。setupMapCallback 的执行顺序严格遵循"controller 就绪 → eventManager 获取 → Marker 添加 → 监听注册"的依赖链。监听注册必须在 Marker 添加之后,否则用户长按 Marker 时监听尚未就绪。两种长按监听使用不同的回调参数类型(map.Marker vs mapCommon.Poi),体现了 Map Kit 对"自定义标记"和"内置 POI"两类地图实体的区分设计。unshift 而非 push 使最新事件出现在列表顶部,符合时间倒序的日志浏览习惯。

3.7 长按监听开关切换

  /** 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 长按监听的开/关切换。首行检查 this.mapEventManager 是否存在——如果地图引擎未初始化成功(如用户未进入地图 Tab 就操作开关),mapEventManagerundefined,直接 return 避免空指针。

核心逻辑是 if-else 分支:当前 markerListenOntrue(监听已开启),调用 offMarkerLongClick() 注销监听;当前为 false(监听已关闭),重新调用 onMarkerLongClick() 注册回调。最后翻转 markerListenOn 状态值触发 Toggle 组件视觉更新。

offMarkerLongClick() 不传参的设计是 6.1.1 的 API 约定——不传参表示清除该事件类型的全部订阅,传参则清除指定回调。本应用只注册了一个回调,不传参等同于清除全部,简化了调用。重新注册时回调函数体与 setupMapCallback 中完全一致,这是必要的重复——闭包捕获 this,每次注册都是独立的作用域。

togglePoiListen 方法结构与 toggleMarkerListen 完全对称,区别仅在于调用 offPoiLongClick()/onPoiLongClick(),回调参数为 mapCommon.Poi。两个方法独立开关,互不影响——用户可以只开 Marker 长按、只开 POI 长按、或两者都开/都关。

设计意图: 监听开关让用户控制事件日志的产生来源,避免不需要的长按事件污染日志流。off 不传参清除全部订阅是 API 简化设计——在不需要精细控制单个回调的场景下,一键清空比逐个注销更实用。两个开关独立控制体现了 Map Kit 对 Marker 和 POI 两类实体的解耦——它们是不同的地图对象,拥有独立的事件通道。

3.8 searchByText 搜索实现(6.1.1 特性一核心)

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

逐行解释:

runSearch 是 6.1.1 reliability 特性的核心实现方法,声明为 async 因为调用 await site.searchByText()

方法首行将 searchState 设为 '搜索中…',使搜索状态文案立即更新,给用户即时反馈。随后构造 SearchByTextParams 参数对象:query 取用户输入的 this.queryInput(默认 '药店')、location 复用 CITY_CENTER 常量(与地图中心一致)、radius: 5000(搜索半径 5000 米,覆盖中心城区)、language: 'zh'(返回中文地名)。

try 块中 await site.searchByText(params) 发起异步搜索调用。返回值类型为 SearchByTextResult,其 sites 属性是 Array<site.Site>。使用 ?? [] 空值合并运算符兜底——如果 result.sitesundefinednull,赋空数组避免后续遍历报错。

空结果检查:如果 sites.length === 0,更新 searchState'无结果 · 保留演示数据'return,不修改 searchRecords,用户仍可见之前的 Mock 演示数据。这是优雅降级策略——无结果不等于清空屏幕。

非空结果时,遍历 sites 数组,将每个 Site 对象转换为 SearchRecord。四个字段逐一映射并兜底:s.name ?? '未命名地点's.formatAddress ?? '暂无地址's.distance ?? 0s.reliability ?? 0。其中 s.reliability ?? 0 是关键——reliability 是 6.1.1 新增的可选字段,旧版 API 或某些 POI 类型可能不返回该字段,?? 0 将其兜底为 0 分(低相关),避免 undefined 进入分数条渲染导致 Progress 组件异常。

转换完成后,整体替换 this.searchRecords 触发列表重渲染,更新 searchState`返回 ${sites.length} 家药店` 展示结果数量。

catch 块捕获搜索异常——无 AGC 配置、无网络、定位权限缺失等场景都会抛 BusinessError。将 e 断言为 BusinessError,更新 searchState`搜索失败(${err.code}) · 保留演示数据`,不修改 searchRecords,保留之前的演示数据保证 UI 不空白。

设计意图: 本方法是 reliability 字段从"引擎返回"到"UI 展示"的完整链路。?? 0 兜底是工程化关键——6.1.1 新字段在旧版设备或特定 POI 类型上可能缺失,直接使用 s.reliability 会在 Progress({ value: undefined * 100 }) 处崩溃。catch 保留 Mock 数据的策略使应用在无网络/无 AGC 的演示环境中仍可展示完整 UI,这对技术演示和离线体验至关重要。location 复用 CITY_CENTER 保证搜索范围与地图视野一致——用户在地图 Tab 看到的药店与在搜索 Tab 查到的药店处于同一地理区域。

3.9 弹窗业务方法

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

  /** 保存收藏药店(空名兜底默认演示药店) */
  savePharm() {
    const name = this.formName === '' ? '康民药图·新收藏药店' : this.formName;
    const meds = this.formMeds === '' ? '感冒灵' : this.formMeds;
    const addr = this.formAddr === '' ? '成都市锦江区(地图选点)' : this.formAddr;
    this.pharmList.unshift(new PharmItem('⭐', name, '21:00', false, 200, `常备${meds} · ${addr}`));
    this.formName = '';
    this.formMeds = '';
    this.formAddr = '';
    this.addModal = false;
  }

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

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

逐行解释:

四个方法覆盖药店列表的增删改查全生命周期。openEditPharm(idx) 接收药店索引,将其存入 editIdx,同时从 pharmList[idx].note 回填当前备注到 editNote,使弹窗打开时输入框已显示原有备注文本。最后设置 editModal = true 打开编辑弹窗。

savePharm 处理收藏新药店。三个输入字段都做空值兜底——formName 为空则用默认药店名、formMeds 为空则默认"感冒灵"、formAddr 为空则默认"成都市锦江区(地图选点)"。构造 PharmItem 实例时 note 字段组合 meds 和 addr 为 `常备${meds} · ${addr}` 模板字符串。unshift 将新药店插入列表顶部,随后清空三个表单字段并关闭弹窗。

updatePharm 保存编辑备注。边界检查 editIdx 在有效范围内,且 editNote 非空时,直接修改 this.pharmList[this.editIdx].note = this.editNote。由于 PharmItem 标记了 @Observed,对象属性变更会自动触发绑定该属性的 UI 组件更新。但额外执行 this.pharmList = this.pharmList.slice() 整体替换数组引用——这是双重保险策略,确保即使 @Observed 的 Proxy 代理因 ArkUI 版本差异未完全生效,数组引用变更也能强制 ForEach 重新 diff。

delPharm 删除药店。边界检查 delIdx 有效后,splice(this.delIdx, 1) 从数组中移除一个元素,@State 数组的变更自动触发列表重新渲染。

设计意图: 增删改方法是 @Observed + @State 响应式范式的实操展示。savePharm 的空值兜底保证用户不填任何字段也能创建有效药店条目,降低操作门槛。updatePharmslice() 双保险策略在 @Observed 与数组引用替换两种刷新机制间做冗余,是生产环境中的稳健做法。delPharm 先检查索引范围再 splice,防止竞态条件下删除已不存在的索引导致数组越界。

3.10 生命周期与主构建

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

  /** 页面主构建:Stack 包裹主内容与三层弹窗 */
  build() {
    Stack() {
      Column() {
        this.headerMain()
        Divider().strokeWidth(1).color(COLORS.line)
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.tabPharm()
            } 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,确保 build() 渲染 MapComponentmapCallback 已就绪。

build() 方法是组件的渲染入口,返回 UI 描述。最外层 Stack 是层叠布局容器——子元素按声明顺序从底层到顶层叠加。Stack 的第一个子元素是主内容 Column(纵向排列头部、分割线、可滚动内容区、底部 Tab 栏),后续三个 if 条件块是弹窗面板,只有对应布尔状态为 true 时才渲染。

主内容 Column 的结构:headerMain() 头部渐变 Banner + 筛选 chips、Divider 分割线、Scroll 可滚动内容区(layoutWeight(1) 占满剩余高度)、tabBar() 底部导航栏。Scroll 内部的 Column 使用 if-else if-else 链根据 currentTab 值渲染对应 Tab 的 Builder 函数——0 药店、1 地图、2 搜索、3 我的。

三层弹窗使用箭头函数传递关闭回调 () => { this.addModal = false; },使弹窗内部的遮罩层点击和取消按钮都能关闭弹窗。三个 if 语句独立判断,理论上三种弹窗可同时渲染(虽然业务逻辑上不会同时触发)。

设计意图: Stack 层叠布局是实现弹窗遮罩的关键——弹窗面板的 modalOverlay 是全屏半透明遮罩,必须覆盖在主内容之上,Stack 的层叠特性天然满足这一需求。if 条件渲染而非 visibility 属性控制,使弹窗关闭时完全从渲染树移除,不占用布局空间和渲染资源。aboutToAppear 中初始化 mapCallback 而非在 build() 中内联,是因为 mapCallback 是异步回调函数对象,需要在组件生命周期中只构造一次。

3.11 头部 Builder

  /** 头部:渐变 Banner(附近药店数+夜间窗口)+ 筛选 chips 横滑 */
  @Builder
  headerMain() {
    Column({ space: 12 }) {
      // 顶部渐变 Banner:药店数量 + 夜间窗口动态 + 常备药入口
      Column({ space: 10 }) {
        Row({ space: 12 }) {
          Column({ space: 2 }) {
            Text('7').fontSize(30).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text('附近药店').fontSize(10).fontColor(COLORS.sub)
          }
          .alignItems(HorizontalAlign.Start)
          Column({ space: 6 }) {
            Row({ space: 6 }) {
              Text('📍').fontSize(14)
              Text('最近药店 380m · 步行 5 分钟').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            }
            Row({ space: 6 }) {
              Text('🌙').fontSize(12)
              Text('夜间窗口营业中 3 家').fontSize(11).fontColor(COLORS.sub)
            }
            Row({ space: 6 }) {
              Text('💊').fontSize(12)
              Text('常备药齐全 5 家 · 医保定点 4 家').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.mint)
          Text('🗺 地图找药').fontSize(12).fontColor(COLORS.mint)
            .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.mintL, 0.0], [COLORS.card, 0.65]]
      })

逐行解释:

@Builder 装饰器声明 headerMain 为构建函数——可像组件一样在 build() 中以 this.headerMain() 调用,实现 UI 片段复用。Builder 内部 Column({ space: 12 }) 是纵向布局容器,子元素间距 12vp。

渐变 Banner 区域是嵌套 Column,内含两行。第一行 Row 左右分栏:左侧大数字 7(30vp 加粗深墨绿)+ 小标签"附近药店"(10vp 灰绿),右侧三行信息——最近药店 380m 步行 5 分钟(加粗)、夜间窗口营业中 3 家、常备药齐全 5 家。layoutWeight(1) 让右侧占满剩余宽度。第二行双胶囊按钮:左侧薄荷绿实底"常备药清单"按钮(白字)、右侧白底"地图找药"按钮(薄荷绿字,onClick 切换到地图 Tab)。

.linearGradient({ angle: 135, colors: [[COLORS.mintL, 0.0], [COLORS.card, 0.65]] }) 是线性渐变,135 度从左上到右下,0% 位置浅薄荷绿 #D8F1E6,65% 位置纯白 #FFFFFF。这种渐变使 Banner 从左上的薄荷绿淡入到右下的纯白,营造清新药草氛围。

      // 筛选 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.mint : COLORS.chip)
              .onClick(() => { this.cateIdx = idx; })
          }, (tag: string) => tag)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .padding({ left: 14, right: 14, top: 12, bottom: 8 })
    .width('100%')
  }

逐行解释:

筛选 chips 区域是横向滚动的 Scroll,内含 Row 包裹 8 个 Text 胶囊。ForEach 遍历 CATE_TAGS 数组,每个 chip 的 fontColorbackgroundColor 根据 cateIdx === idx 三元判断——选中态为薄荷绿底白字,未选中态为浅绿底灰绿字。onClick 设置 cateIdx 为当前索引,触发 @State 更新重新渲染所有 chip 的选中态。

scrollable(ScrollDirection.Horizontal) 启用横向滚动,scrollBar(BarState.Off) 隐藏滚动条保持简洁。8 个 chips 在窄屏上无法全部显示,横滑可浏览全部筛选维度。

设计意图: 头部是应用的视觉门面。渐变 Banner 以药店数量"7"的大数字锚定注意力,右侧三行信息快速传达距离/夜间/库存三大决策因子。双胶囊按钮提供"常备药清单"和"地图找药"两个快捷入口,后者直接跳转到地图 Tab。筛选 chips 横滑覆盖 8 个服务维度,选中态颜色变化提供即时视觉反馈。整个头部以薄荷绿渐变为主调,与药白底页面形成层次区分。

3.12 药店 Tab

  /** 数据统计小单元格(三宫格通用,浅色白底) */
  @Builder
  statCell(value: string, label: string) {
    Column({ space: 4 }) {
      Text(value).fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.mint)
      Text(label).fontSize(10).fontColor(COLORS.sub)
    }
    .layoutWeight(1)
    .padding({ top: 10, bottom: 10 })
    .borderRadius(10)
    .backgroundColor(COLORS.card)
  }

逐行解释:

statCell 是可复用的三宫格统计单元格 Builder。接收 value(数值文本,17vp 加粗薄荷绿)和 label(标签文本,10vp 灰绿)两个参数。layoutWeight(1) 使三个 cell 在 Row 中等宽分配。白底圆角 10vp 胶囊样式。此 Builder 在药店 Tab 和我的 Tab 中复用,避免重复编写相同的 Column 结构。

  /** 药店 Tab:购药数据三宫格 + 推荐药店 + 全部药店列表(业务主 Tab) */
  @Builder
  tabPharm() {
    Column({ space: 10 }) {
      // 区块标题行:更多入口
      Row() {
        Text('身边药房').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Blank()
        Text('收藏新药店 +').fontSize(11).fontColor(COLORS.mint)
          .onClick(() => { this.addModal = true; })
      }
      .width('100%')
      // 购药数据三宫格
      Row({ space: 8 }) {
        this.statCell('5 单', '本月购药')
        this.statCell('3 次/日', '用药提醒')
        this.statCell('42 元', '本月省下')
      }
      .width('100%')

逐行解释:

tabPharm 是药店 Tab 的主 Builder。区块标题行 Row:左侧"身边药房"15vp 加粗深墨绿,Blank() 占据中间弹性空间将右侧元素推到行尾,右侧"收藏新药店 +"11vp 薄荷绿可点击文本,onClick 打开收藏弹窗。

购药数据三宫格调用三次 statCell,分别展示本月购药 5 单、用药提醒 3 次/日、本月省下 42 元。三个数据维度覆盖购药频率、用药节律、经济价值,是社区药店用户的核心关注指标。

      // 推荐药店大卡(3 条)
      ForEach(PHARM_RECS, (rec: PharmRec) => {
        Row({ space: 10 }) {
          Text(rec.icon).fontSize(26)
          Column({ space: 4 }) {
            Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text(`营业至 ${rec.openUntil} · 常备药 ${rec.meds}`).fontSize(11).fontColor(COLORS.sub)
            Row({ space: 6 }) {
              Text(rec.dist).fontSize(10).fontColor(COLORS.text3)
              Text(`库存${rec.meds >= 200 ? '齐全' : '尚可'}`).fontSize(10).fontColor(stockColor(rec.meds))
            }
          }
          .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.mint)
              .onClick(() => { this.currentTab = 1; })
          }
        }
        .padding(12)
        .borderRadius(12)
        .backgroundColor(COLORS.card)
        .width('100%')
      }, (rec: PharmRec) => rec.name)

逐行解释:

推荐药店大卡使用 ForEach 渲染 PHARM_RECS 数组的 3 条记录。每张卡片是 Row 布局:左侧药店 emoji 图标(26vp),中间 Column 展示药店名(13vp 加粗)、营业时间+常备药数(11vp)、距离+库存状态(10vp,库存颜色由 stockColor(rec.meds) 动态映射),右侧"导航"薄荷绿按钮(onClick 跳转地图 Tab)。

stockColor(rec.meds) 调用辅助函数,≥200 种返回薄荷绿(“齐全”),≥100 种返回信任蓝(“尚可”)。这一行代码体现了数据→颜色映射函数在 UI 中的直接应用。ForEach 的 key 函数 (rec: PharmRec) => rec.name 以药店名作为唯一标识,列表更新时框架据此 diff 复用组件。

      // 全部药店列表(长按 Marker 的数据同源)
      Row() {
        Text('全部药店(地图 Marker 同源)').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      }
      .width('100%')
      ForEach(this.pharmList, (pharm: PharmItem, idx: number) => {
        Column({ space: 8 }) {
          Row({ space: 10 }) {
            Text(pharm.icon).fontSize(22)
            Column({ space: 3 }) {
              Text(pharm.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
              Text(`营业至 ${pharm.openUntil} · ${pharm.night ? '有夜间窗口' : '无夜间窗口'}`).fontSize(11).fontColor(COLORS.sub)
              Text(`备注:${pharm.note}`).fontSize(10).fontColor(COLORS.text3)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column({ space: 6 }) {
              Text(`${pharm.meds}`).fontSize(16).fontWeight(FontWeight.Bold)
                .fontColor(stockColor(pharm.meds))
              Text('常备药').fontSize(9).fontColor(COLORS.text3)
            }
          }
          .width('100%')
          Row({ space: 8 }) {
            Text(pharm.night ? '夜间窗口' : '仅日间').fontSize(10).fontColor(nightColor(pharm.night))
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10).backgroundColor(COLORS.chip)
            Blank()
            Text('编辑').fontSize(10).fontColor(COLORS.blue)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10).backgroundColor(COLORS.chip)
              .onClick(() => { this.openEditPharm(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%')
      }, (pharm: PharmItem) => pharm.name)
      // 用药小贴士卡
      this.tipsCard()
    }
    .width('100%')
  }

逐行解释:

全部药店列表渲染 this.pharmList 状态数组(7 条 Mock 数据)。每张卡片是嵌套 Column:第一行 Row 左侧 emoji 图标(22vp),中间 Column 三行文本——药店名(13vp 加粗)、营业时间+夜间窗口有无(11vp,pharm.night 三元判断)、用户备注(10vp),右侧常备药数量(16vp 加粗,颜色由 stockColor(pharm.meds) 动态映射)+ "常备药"小标签(9vp)。

第二行操作行:左侧"夜间窗口/仅日间"胶囊(颜色由 nightColor(pharm.night) 映射),Blank() 推右,"编辑"胶囊(信任蓝字,onClick 调用 openEditPharm(idx) 打开编辑弹窗),"删除"胶囊(警示红字,onClick 设置 delIdx 并打开删除弹窗)。

ForEach 的 key 函数 (pharm: PharmItem) => pharm.name 以药店名为标识。由于 PharmItem 标记了 @Observednote 属性修改后自动触发备注行刷新;但列表增删(unshift/splice)依赖 @State pharmList 的数组变更检测。

底部调用 tipsCard() 渲染用药小贴士卡。标题"全部药店(地图 Marker 同源)"强调药店列表与地图 Marker 的数据同源性——用户在药店 Tab 看到的药店与在地图 Tab 长按的 Marker 来自同一数据源体系。

设计意图: 药店 Tab 是业务主页面,采用"数据总览→推荐精选→全部列表→小贴士"的渐进式信息架构。三宫格统计放在最前满足用户"快速了解整体情况"的需求;推荐大卡用渐变和大字号吸引注意力;全部列表提供完整浏览能力,每条卡片的编辑/删除操作直连弹窗系统;底部小贴士卡提供健康知识延伸。stockColornightColor 在本 Tab 中高频调用,体现了辅助函数对 UI 代码简化的价值。

3.13 地图 Tab:长按事件特性页

  /** 地图 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.mint)
            .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.mint)
            .width(36)
            .height(20)
            .onChange(() => { this.togglePoiListen(); })
          Text('POI长按').fontSize(11).fontColor(COLORS.sub)
        }
      }
      .width('100%')

逐行解释:

tabMap 是 6.1.1 长按事件特性的展示页面。顶部特性说明卡两行文本:标题"Map Kit 6.1.1 · 长按事件监听"(13vp 加粗)、说明"长按地图上的药店 Marker 或 POI 地点,事件将记录到下方日志流"(10vp 灰绿)。浅绿底圆角卡片样式。

监听开关行包含两个 Toggle 开关组件,type: ToggleType.Switch 渲染为 iOS 风格滑动开关。isOn 绑定 markerListenOn/poiListenOn 状态,selectedColor(COLORS.mint) 使开启态滑块为薄荷绿。onChange 分别调用 toggleMarkerListen()/togglePoiListen() 切换监听注册状态。每个 Toggle 旁配 11vp 标签文案"Marker长按"/“POI长按”。

      // ★ MapComponent 本体(layoutWeight(1) 占满剩余高度)
      MapComponent({ mapOptions: this.mapOptions, mapCallback: this.mapCallback })
        .layoutWeight(1)
        .width('100%')
        .borderRadius(12)

逐行解释:

MapComponent 是 Map Kit 的核心渲染组件,接收两个参数:mapOptions(在 aboutToAppear 中初始化为 { position: { target: CITY_CENTER, zoom: 13 } })和 mapCallback(在 setupMapCallback 中构造的异步回调函数)。layoutWeight(1) 使地图占满说明卡和日志流之间的全部剩余高度,borderRadius(12) 使地图四角圆角与卡片风格统一。

这是整个地图 Tab 的视觉主体。地图渲染完成后,mapCallback 异步触发,在其中获取控制器、添加 6 个药店 Marker、注册 Marker 和 POI 长按监听。用户在地图上长按药店标记或 POI 地点时,对应监听回调将事件写入 eventLogs 数组。

      // 长按事件日志流(固定高度可滚动,新事件置顶)
      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.mint : COLORS.blue)
                    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%')
  }

逐行解释:

长按事件日志流区域是固定高度 120vp 的可滚动 Scroll。标题行显示"长按事件日志流"加粗标题和 `共 ${this.eventLogs.length} 条` 动态计数(10vp 弱文本)。

ForEach 遍历 this.eventLogs 数组渲染每条日志。每条日志是一个 Row:左侧 emoji 图标根据 log.type 三元判断——Marker 用 📍 图钉(暗示自定义标记),POI 用 💊 药丸(暗示药店相关 POI)。右侧 Column 两行:第一行三列横排——类型标签(10vp,Marker 薄荷绿/POI 信任蓝)、名称(11vp 深墨绿,Marker 显示 #0 等 ID,POI 显示"天府广场"等地名)、时间(9vp 弱文本);第二行经纬度坐标(9vp 弱文本,monospace 等宽字体,toFixed(4) 保留 4 位小数)。

ForEach 的 key 函数 `${log.type}-${log.name}-${log.time}` 组合三个字段为唯一标识,保证每条日志的 diff 精确性。unshift 新事件置顶后,ForEach 自动在最上方插入新条目。

设计意图: 地图 Tab 是 6.1.1 长按事件特性的完整展示页。三个区块从上到下形成"说明→操作→反馈"的交互闭环:特性说明卡告诉用户能做什么,监听开关让用户控制做什么,日志流展示做了什么。MapComponent 居中占满剩余空间保证地图视野充足。日志流固定 120vp 高度且可内部滚动,避免事件过多时撑高整个 Tab 导致地图被压缩。每条日志的坐标用 monospace 等宽字体,保证经纬度数字对齐,便于用户对比多个事件的位置。

3.14 搜索 Tab:reliability 特性页

  /** 搜索 Tab:★ Map Kit 6.1.1 reliability 相关性分数特性页 */
  @Builder
  tabSearch() {
    Column({ space: 10 }) {
      // 特性说明卡
      Column({ space: 4 }) {
        Text('🔍 searchByText · reliability 相关性评分').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Text('Site 新增 reliability 字段([0,1],1 为完全相关),衡量结果与关键字关联程度')
          .fontSize(10).fontColor(COLORS.sub)
      }
      .padding(10)
      .borderRadius(10)
      .backgroundColor(COLORS.chip)
      .width('100%')
      // 搜索框 + 触发按钮
      Row({ space: 8 }) {
        TextInput({ text: this.queryInput, placeholder: '输入关键字,如:药店' })
          .layoutWeight(1)
          .height(38)
          .fontSize(12)
          .fontColor(COLORS.title)
          .placeholderColor(COLORS.text3)
          .backgroundColor(COLORS.card)
          .onChange((v: string) => { this.queryInput = v; })
        Button('搜索')
          .height(38)
          .fontSize(12)
          .backgroundColor(COLORS.mint)
          .onClick(() => { this.runSearch(); })
      }
      .width('100%')
      // 搜索状态文案
      Text(this.searchState).fontSize(10).fontColor(COLORS.text3).width('100%')

逐行解释:

tabSearch 是 6.1.1 reliability 特性的展示页面。顶部特性说明卡标题"searchByText · reliability 相关性评分",说明"Site 新增 reliability 字段([0,1],1 为完全相关),衡量结果与关键字关联程度"。

搜索框区域 RowTextInput 输入框(text 绑定 queryInputplaceholder 提示语、onChange 更新 queryInput 值、layoutWeight(1) 占满剩余宽度)+ Button 薄荷绿搜索按钮(onClick 调用 this.runSearch() 发起搜索)。

搜索状态文案 Text(this.searchState) 显示当前搜索状态——待搜索、搜索中、返回 N 家药店、搜索失败(code) 等,10vp 弱文本,给用户搜索进度的实时反馈。

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

逐行解释:

搜索结果列表使用 List 组件(区别于药店 Tab 的 ForEach 直接渲染,List 的子项必须用 ListItem 包裹,这是 ArkUI 的组件约束)。List({ space: 8 }) 设置子项间距 8vp,layoutWeight(1) 占满剩余高度可滚动。

每条搜索结果卡片 ListItem 内是 Column 四行布局。第一行名称行:左侧药店名(13vp 加粗深墨绿,maxLines(1) 单行+textOverflow({ overflow: TextOverflow.Ellipsis }) 超出省略号截断),右侧 reliability 等级标签(由 reliabilityScore(rec.reliability) 返回的 label/color 动态生成——高相关薄荷绿/中相关信任蓝/低相关警示红)。这一行调用 reliabilityScore 两次,分别取 labelcolor

第二行地址(11vp 灰绿,同样单行省略截断)。第三行 reliability 分数条——核心可视化组件:Progress 线性进度条(value: rec.reliability * 100 将 0~1 映射为 0~100 百分比,total: 100type: ProgressType.Linearheight(6) 6vp 细条,color(reliabilityScore(rec.reliability).color) 颜色随等级变化),右侧 `reliability ${rec.reliability.toFixed(2)}` 数值文本(10vp 灰绿,monospace 等宽字体,toFixed(2) 保留两位小数)。第四行距离和时间(10vp 弱文本,距离由米转千米 (rec.distance / 1000).toFixed(2)km)。

ForEach 的 key 函数 `${rec.name}-${rec.reliability}` 组合名称和分数为唯一标识。reliability 参与 key 是因为同一地点在不同搜索中可能返回不同分数,key 需包含分数才能正确 diff。

      // 双特性代码预览卡(体现技术点)
      this.codePreviewCard()
    }
    .width('100%')
    .height('100%')
  }

逐行解释:

搜索 Tab 底部调用 codePreviewCard() 渲染双特性代码预览卡,展示 6.1.1 新增的四个 API 调用代码片段。整个 tabSearch 的布局结构为:说明卡→搜索框→状态文案→结果列表(layoutWeight(1) 占满)→代码预览卡。

设计意图: 搜索 Tab 是 reliability 特性的端到端展示页。Progress 分数条是该特性的核心可视化载体——用户一眼可见 0.96 的薄荷绿满条(高相关推荐)与 0.10 的警示红短条(低相关不推荐)的视觉对比,远比纯数字直观。reliabilityScore 函数在每张卡片中被调用三次(标签文案、标签颜色、进度条颜色),体现了辅助函数对 UI 代码的集中简化——如需调整阈值,只改函数即可。List + ListItem 的组件约束保证了列表的滚动性能和虚拟化复用。代码预览卡放在搜索 Tab 而非地图 Tab,是因为 reliability 是搜索特性,代码片段应与搜索场景同页展示。

3.15 我的 Tab

  /** 我的 Tab:家庭健康卡 + 数据三宫格 + 功能清单 */
  @Builder
  tabMine() {
    Column({ space: 10 }) {
      // 家庭健康渐变大卡
      Column({ space: 8 }) {
        Row({ space: 12 }) {
          Text('💊').fontSize(34)
          Column({ space: 3 }) {
            Text('康民家庭卡 · 三代共享').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text('会员药 95 折 · 免费用药档案管理').fontSize(11).fontColor(COLORS.sub)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')
        Divider().strokeWidth(1).color(COLORS.line)
        Row() {
          Text('本月购药 5 单').fontSize(11).fontColor(COLORS.sub)
          Blank()
          Text('已省 42.60 元').fontSize(11).fontColor(COLORS.mint)
        }
        .width('100%')
      }
      .padding(14)
      .borderRadius(14)
      .linearGradient({
        angle: 135,
        colors: [[COLORS.mintL, 0.72], [COLORS.card, 0.72]]
      })
      .width('100%')

逐行解释:

tabMine 是个人中心页面。顶部家庭健康卡是渐变大卡:Row 左侧大号药丸 emoji(34vp),右侧标题"康民家庭卡 · 三代共享"(15vp 加粗)+ 副标题"会员药 95 折 · 免费用药档案管理"(11vp 灰绿)。Divider 分隔线后,底部行展示本月购药 5 单(左)和已省 42.60 元(右,薄荷绿强调)。

linearGradientcolors: [[COLORS.mintL, 0.72], [COLORS.card, 0.72]] 两段都在 0.72 位置,形成硬切而非渐变——实际上这会渲染为薄荷绿底色而非渐变效果,这是因为两个色标位置相同,颜色直接从 mintL 跳变到 card。这是渐变参数的特殊用法。

      // 数据三宫格
      Row({ space: 8 }) {
        this.statCell('58 单', '累计购药')
        this.statCell('7 家', '收藏药店')
        this.statCell('3 位', '家庭成员')
      }
      .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.6.3 · Map Kit 6.1.1 双新特性演示').fontSize(9).fontColor(COLORS.text3)
    }
    .width('100%')
  }

逐行解释:

数据三宫格复用 statCell 展示累计购药 58 单、收藏药店 7 家、家庭成员 3 位。

功能清单 ForEach 渲染 this.funcList 的 8 条功能项。每行 Row:emoji 图标(18vp)、功能名(13vp 深墨绿,layoutWeight(1) 占满中间)、状态/数值(11vp 灰绿)、右箭头 ›(14vp 弱文本,暗示可点击进入详情)。8 项功能覆盖用药档案、常备药清单、购药订单、收藏药店、用药提醒、医保码、健康档案、偏好设置,是社区药店用户的完整健康管理入口。

底部版本脚注"康民药图 v1.6.3 · Map Kit 6.1.1 双新特性演示"(9vp 弱文本),标注应用版本和特性来源。

设计意图: 我的 Tab 是用户个人数据中心。家庭健康卡用渐变大卡承载会员身份和省钱数据,三宫格量化用户与平台的累计交互,功能清单提供 8 个细分入口。statCell 的复用减少代码量。版本脚注标注技术来源,便于演示场景的技术交流。

3.16 弹窗系统

  /** 弹窗遮罩层(点击空白处关闭) */
  @Builder
  modalOverlay(onClose: () => void) {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.mask)
      .onClick(() => { onClose(); })
  }

逐行解释:

modalOverlay 是弹窗遮罩层 Builder,接收 onClose 回调函数参数。全屏 Column,背景色 COLORS.maskrgba(28,51,42,0.5) 半透明深墨绿),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.formMeds })
          .height(38)
          .fontSize(12)
          .fontColor(COLORS.title)
          .placeholderColor(COLORS.text3)
          .backgroundColor(COLORS.chip)
          .onChange((v: string) => { this.formMeds = 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.mint)
            .onClick(() => { this.savePharm(); })
        }
        .width('100%')
      }
      .padding(16)
      .borderRadius(14)
      .backgroundColor(COLORS.card)
      .width('82%')
    }
    .width('100%')
    .height('100%')
  }

逐行解释:

panelAdd 是收藏药店弹窗。Stack 层叠布局:底层 modalOverlay(onClose) 全屏遮罩(点击关闭),上层 Column 弹窗面板(width('82%') 居中宽度,backgroundColor(COLORS.card) 白底,borderRadius(14) 圆角)。

面板内容:标题"收藏新药店"(15vp 加粗),三个 TextInput 输入框(药店名称、常备药、地址,38vp 高度,12vp 字号,浅绿底,onChange 分别更新 formName/formMeds/formAddr),底部双按钮行——"取消"浅绿底灰字(onClick 调用 onClose() 关闭)、"收藏"薄荷绿底(onClick 调用 savePharm() 保存)。两个按钮 layoutWeight(1) 等宽分配。

  /** 编辑备注弹窗:回填当前药店备注 */
  @Builder
  panelEdit(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('编辑药店备注').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Text(this.editIdx < this.pharmList.length ? this.pharmList[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.mint)
            .onClick(() => { this.updatePharm(); })
        }
        .width('100%')
      }
      .padding(16)
      .borderRadius(14)
      .backgroundColor(COLORS.card)
      .width('82%')
    }
    .width('100%')
    .height('100%')
  }

逐行解释:

panelEdit 是编辑备注弹窗。结构与 panelAdd 对称。面板内容:标题"编辑药店备注",当前药店名展示(边界检查 editIdx < pharmList.length 防止索引越界,Text 展示 pharmList[editIdx].name),备注输入框(text: this.editNote 回填当前备注,onChange 更新 editNote),双按钮——"取消"关闭弹窗、"保存"调用 updatePharm() 保存备注。

  /** 删除确认弹窗:药店名 + 确认/取消 */
  @Builder
  panelDel(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('删除收藏药店').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Text(this.delIdx < this.pharmList.length
          ? `确定删除「${this.pharmList[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.delPharm(); })
        }
        .width('100%')
      }
      .padding(16)
      .borderRadius(14)
      .backgroundColor(COLORS.card)
      .width('82%')
    }
    .width('100%')
    .height('100%')
  }

逐行解释:

panelDel 是删除确认弹窗。面板内容:标题"删除收藏药店",确认文案(边界检查后展示 `确定删除「${pharmList[delIdx].name}」吗?`,索引越界时回退为"确定删除吗?"),双按钮——"取消"浅绿底、"删除"警示红底(backgroundColor(COLORS.red)onClick 调用 delPharm() 执行删除)。

设计意图: 三个弹窗共享 modalOverlay + Stack 层叠 + Column 面板的统一结构,差异仅在面板内容。onClose 回调参数使弹窗关闭逻辑可复用——遮罩点击和取消按钮都调用同一回调。82% 宽度的面板在两侧留出 9% 间距,视觉上比全宽更有"浮层"感。删除按钮使用警示红是危险操作的标准视觉警示,与收藏和保存的薄荷绿形成对比,降低误操作概率。

四、HarmonyOS 6.1.1 双新特性深度解析

4.1 reliability 相关性评分机制

HarmonyOS 6.1.1 在 site 模块的 Site 类型上新增 reliability 字段,这是 Map Kit 搜索能力从"定性返回"到"定量评估"的质变升级。在 6.1.1 之前,searchByText 返回的 Site 数组仅保证"包含与关键字相关的地点",但相关性程度不可知——排在第一位的药店可能与搜索词"药店"高度匹配,也可能只是因为距离近而优先返回。reliability 字段的加入使每个结果都携带 [0, 1] 区间的浮点分数,1 表示完全相关,0 表示完全不相关,开发者据此可实现结果排序、阈值过滤、可视化分级展示。

在本应用中,reliability 的消费链路是:site.searchByText(params) 返回 SearchByTextResult → 遍历 result.sites 数组 → 读取 s.reliability ?? 0(空值合并兜底)→ 构造 SearchRecord 实例存储分数 → reliabilityScore(score) 函数映射为等级标签和颜色 → Progress 分数条 + Text 等级标签渲染到 UI。这条链路中,?? 0 兜底是工程化关键——reliability 是新增可选字段,旧版 API 或某些 POI 类型可能不返回该字段,直接使用会导致 Progress({ value: undefined * 100 }) 崩溃。

reliabilityScore 函数的阈值设计(≥0.8 高相关薄荷绿 / ≥0.5 中相关信任蓝 / <0.5 低相关警示红)是业务可调的。药店搜索场景中,0.8 阈值意味着只有与"药店"高度匹配的结果才标绿推荐——名为"春熙路康民大药房"的结果(0.96)会标绿,名为"药品批发市场(不对个人)"的结果(0.22)会标红,名为"药店货架设备供应商"的结果(0.10)也会标红。用户一眼可辨别哪些是真正可购药的药店、哪些是产业链上下游非零售终端。

reliability 的价值在信息密集场景中尤为突出。当 searchByText 以"药店"为关键字在成都中心城区搜索时,5 公里半径内可能返回数十个结果——如果全部平铺展示,用户需要在列表中逐一甄别哪家是零售药店、哪家是批发商、哪家是设备供应商。reliability 分数使结果可按相关性排序,高分在前低分沉底,配合分数条可视化,用户决策成本从"逐条阅读判断"降为"扫一眼颜色"。

4.2 MapEventManager 长按事件监听机制

HarmonyOS 6.1.1 在 MapEventManager 上新增四个方法:onMarkerLongClickoffMarkerLongClickonPoiLongClickoffPoiLongClick。前两者管理自定义 Marker(通过 MapComponentController.addMarker 添加的标记)的长按监听,后两者管理地图引擎内置 POI(兴趣点,如商圈、地标、政府机构)的长按监听。这四个方法的加入使 Map Kit 的事件体系从"仅点击"扩展到"点击+长按",交互维度翻倍。

在本应用中,长按监听的注册时机严格在 mapCallbackerr 为空分支内——即地图引擎初始化成功、MapComponentController 就绪后。注册顺序为:先 getEventManager() 获取管理器,再批量 addMarker 添加 6 个药店标记,最后注册两种长按监听。这一顺序确保监听注册时 Marker 已全部在位——用户长按任何药店标记都能立即触发回调,不会因 Marker 尚未添加而长按无响应。

onMarkerLongClick 的回调参数是 map.Marker 类型,通过 marker.getPosition() 获取经纬度、marker.getId() 获取标记 ID。onPoiLongClick 的回调参数是 mapCommon.Poi 类型,直接暴露 poi.name(地点名称字符串)和 poi.position(经纬度)。两种回调的参数差异体现了 Marker 与 POI 的本质区别——Marker 是开发者添加的自定义标记,需要通过方法调用获取属性;POI 是引擎内置的地图实体,属性直接暴露在对象上。

offMarkerLongClick()offPoiLongClick() 不传参时清除该类型的全部订阅,传参时清除指定回调。本应用使用不传参模式,因为每个事件类型只注册了一个回调,一键清空比逐个注销更简洁。toggleMarkerListen/togglePoiListen 方法实现开关切换:当前为开则调 off 注销、当前为关则调 on 重新注册,最后翻转状态布尔值。重新注册时的回调函数体与初始注册完全一致,这是必要的代码重复——每次 on 调用都需传入完整的回调函数。

长按事件的实际价值在于承载"次要操作"意图。点击通常表示"选中查看",长按则暗示"需要更多操作"——在本应用中,长按 Marker 或 POI 将事件写入日志流,可扩展为"长按收藏药店"“长按发起导航”"长按查看详情"等操作。相比点击+按钮的交互路径(先点击标记弹出信息窗,再点击信息窗中的按钮),长按将操作路径缩短为一步,在移动端单手操作场景下体验提升显著。

五、双新特性与传统方案对比表格

对比维度传统方案(6.1.1 之前)HarmonyOS 6.1.1 新方案提升效果
搜索结果相关性仅返回结果列表,相关性不可知Site.reliability 字段提供 [0,1] 量化分数从"有无"到"好坏",决策效率提升
结果排序依据默认按距离排序可按 reliability 分数排序高相关结果优先展示
结果可视化纯文本列表分数条+等级标签三色可视化视觉辨识速度提升
低质结果处理无法区分,全部展示reliability <0.5 标红沉底降噪效果显著
地图标记交互仅 onMarkerClick 点击新增 onMarkerLongClick 长按交互维度翻倍
POI 交互仅 onPoiClick 点击新增 onPoiLongClick 长按POI 操作路径缩短
事件注销粒度off 需逐个注销回调off 不传参一键清空全部订阅API 调用更简洁
回调参数类型Marker 和 POI 参数一致Marker(map.Marker) vs POI(mapCommon.Poi) 区分类型安全更严格
字段空值处理字段必选,缺失即报错reliability 可选字段,?? 兜底 0 分优雅降级不崩溃
日志可追溯性长按无监听,事件丢失长按事件 unshift 置顶日志流交互全程可追溯

六、总结

本文以"康民药图·附近药店导航"应用为载体,完整剖析了 HarmonyOS 6.1.1 Map Kit 的两大新特性在社区医药健康服务场景中的落地实践。site.searchByText 返回的 Site 类型新增 reliability 相关性字段,使搜索结果从模糊的"有无"维度升级为可量化的"好坏"维度——0.96 分的零售药店标绿推荐,0.10 分的设备供应商标红沉底,用户在信息密集的药店搜索场景中可一眼辨别可购药终端。reliabilityScore 辅助函数将连续浮点分数离散化为高/中/低三档颜色标签,配合 Progress 线性分数条实现"数值→颜色→进度条"的三级视觉编码,是技术字段转化为用户体验的完整桥梁。

MapEventManager 新增的 onMarkerLongClick/offMarkerLongClickonPoiLongClick/offPoiLongClick 长按监听,使地图交互从"仅点击"扩展到"点击+长按"双维度。自定义 Marker(通过 addMarker 添加的药店标记)和内置 POI(引擎预置的兴趣点)拥有独立的长按事件通道,回调参数分别为 map.MarkermapCommon.Poi,前者通过 getPosition()/getId() 获取属性,后者直接暴露 name/position。长按事件 unshift 到日志流数组顶部实现最新置顶,用户在地图上每次长按都可追溯。监听开关通过 Toggle 组件控制 on/off 注册切换,off 不传参一键清空全部订阅的 API 设计大幅简化了注销逻辑。

从工程角度,本应用展示了 ArkUI 状态管理的完整范式——@State 修饰响应式状态变量、@Observed 标记可观察对象类实现属性级响应式更新、@Builder 构造可复用 UI 片段、@Entry/@Component 声明页面入口组件。颜色系统通过 ColorPalette 接口 + COLORS 常量集中管理 16 个语义化颜色字段,三个辅助函数(reliabilityScore/stockColor/nightColor)将"数值→颜色"的业务规则从 UI 代码中剥离。弹窗系统采用 Stack 层叠布局 + if 条件渲染 + modalOverlay 遮罩层的三层结构,三种弹窗(收藏/编辑/删除)共享统一骨架,差异仅在面板内容。mapCallback/mapController/mapEventManager 作为非响应式 private 资源与业务数据状态分层管理,避免了引擎资源深层属性变化触发不必要的 UI diff。

HarmonyOS 6.1.1 的双新特性对社区药店导航场景的赋能是实质性的。reliability 解决了"搜索结果太多但不知哪家真正相关"的信息过载痛点,onMarkerLongClick/onPoiLongClick 解决了"地图上药店密密麻麻但难以快速选取目标"的交互路径过长痛点。两者结合构建出"搜索可量化、地图可长按、日志可追溯"的完整药店发现闭环——用户在搜索 Tab 通过分数条筛选高相关药店,在地图 Tab 长按标记或 POI 记录操作日志,在药店 Tab 管理收藏和备注,四个 Tab 各司其职又数据同源。浅色主题的药白底+薄荷绿+信任蓝三色体系在视觉上传递了"安全"“信任”"自然"的医药健康心理感受,与社区药店导航的业务定位高度契合。

附录: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.1Release✅ 已安装

界面顶部提示:“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 246.1.1.100Release✅ 已安装
API Version 236.1.0.28Beta1未安装
API Version 226.0.2.112Release未安装

安装路径示例:D:\DevTools\ArkUI-X\sdk

说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

在这里插入图片描述


三、小结

步骤操作关键点
创建项目欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成使用 Stage 模型 + ArkTS 语言
查看 SDK设置 → HarmonyOS SDKSDK 已内置,无需手动安装
跨平台扩展设置 → ArkUI-X根据需要安装对应 API 版本

至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。


本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。

Logo

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

更多推荐