一、技术前言:HarmonyOS ArkUI 与 Map Kit 在本地生活场景的深度融合

在这里插入图片描述

HarmonyOS 6.1.1 是华为鸿蒙生态中一次面向"空间智能"与"语义化检索"的重要迭代。在这一版本中,Map Kit(地图服务套件)不再只是简单地承载地图渲染与定位能力,而是向"理解用户意图、量化结果质量、丰富交互维度"三个方向纵深演进。对于本地生活类应用——尤其是美食探店这类对位置精度、搜索相关性、地图交互手感都极其敏感的场景——新版本的两大特性几乎是为这个赛道量身定制的。本文将以一个名为"食遇记·附近美食探店"的完整 ArkUI 应用为例,深度拆解这两大新特性的落地全过程。

在这里插入图片描述
第一个核心特性来自 site 模块的 searchByText 接口。在 6.1.1 之前,开发者调用 searchByText 搜索周边 POI 时,拿到的是一串 Site 结果数组,其中包含名称、地址、距离等字段,但结果之间"谁更相关、谁更无关"只能靠开发者自行估算距离来粗略判断。6.1.1 在 Site 类型上新增了 reliability 字段,这是一个取值范围为 [0,1] 的浮点数,1 表示完全相关,0 表示几乎无关。它由 Map Kit 的后端检索引擎综合计算得出,考量因素包括关键字匹配度、POI 权重、距离衰减、用户行为热度等多个维度。这意味着开发者终于有了一个官方的、量化的"搜索结果质量标尺",可以在 UI 层直接用分数条、等级标签、排序策略来呈现给终端用户,极大提升搜索结果的透明度和可信度。

在这里插入图片描述
第二个核心特性来自 MapEventManager 的事件体系扩展。6.1.1 之前,地图上的 Marker(自定义标注)只支持普通的 click 单击监听,POI(地图原生兴趣点)也只支持单击。但在真实探店场景中,用户往往需要"长按"一个店铺标记来触发收藏、备注、导航选点等操作——长按是一种比单击更具"确认意图"的交互手势,能有效避免误触。6.1.1 为此新增了两组共四个接口:onMarkerLongClick / offMarkerLongClick 用于订阅和取消订阅地图 Marker 的长按事件;onPoiLongClick / offPoiLongClick 用于订阅和取消订阅地图 POI 的长按事件。长按回调分别返回 map.Marker 和 mapCommon.Poi 对象,开发者可以从中取出坐标、ID、名称等信息,进而驱动收藏弹窗、导航跳转、事件日志记录等业务逻辑。

在这里插入图片描述
HarmonyOS ArkUI 框架是这一切的承载基座。它采用声明式 UI 范式,通过 @Entry / @Component / @State / @Builder 等装饰器,让开发者可以用接近自然语言的方式描述界面结构与状态驱动关系。ArkUI 的状态管理 V2 体系(@Observed + @ObjectLink + @State)能精确控制刷新粒度,避免整页重绘。MapComponent 作为 ArkUI 中的地图容器组件,通过 mapOptions 初始化参数和 mapCallback 异步回调完成地图引擎的加载与控制器获取,再经由 MapComponentController 桥接到 Marker、Polyline、Camera 等地图对象,最后通过 getEventManager() 拿到 MapEventManager 注册各类交互事件。这条链路从组件挂载到事件回调,构成了"地图即 UI、交互即数据"的完整闭环。

在这里插入图片描述
美食探店场景的特殊性在于:它既需要"广覆盖的检索"(周边 5 公里内所有本帮菜馆),又需要"精准的语义匹配"(用户搜"本帮菜"时,一家"本帮酱料专卖店"虽然关键字命中,但并非餐饮店,reliability 会很低),还需要"丰富的地图交互"(长按收藏、长按导航、长按写食记)。这三层需求恰好与 6.1.1 的两大新特性形成一一对应的落地映射。本文的应用正是围绕这三层需求构建了一个包含美食、地图、搜索、我的四个 Tab 的完整探店原型,将 reliability 量化为分数条与等级标签,将长按事件量化为可滚动的事件日志流,为读者提供一份从 API 签名到 UI 呈现的端到端参考实现。

在这里插入图片描述
此外,本应用还采用了一套贴合美食行业气质的浅色主题:奶白底色 #FBF5EC 营造温暖食欲氛围,番茄红 #D8402C 作为主色调呼应"番茄=美食"的视觉联想,暖橙 #F08A24 作为辅助色用于次要操作与中等等级标识,再以营业中状态绿 #3F9B54 点缀营业状态。整套配色不仅是视觉设计,更与功能语义深度绑定——reliability 高相关用番茄红、中相关用暖橙、低相关用浅驼色;评分≥4.6 用番茄红、≥4.2 用暖橙。颜色即信息,这是本地生活应用设计的高级技巧。下文将逐段拆解代码实现。

在这里插入图片描述

二、应用整体架构流程图

在深入代码之前,先用一张 Mermaid 流程图展示"食遇记"应用的整体架构与数据流转关系,帮助读者建立全局认知。

0 美食

1 地图

2 搜索

3 我的

应用入口 @Entry Page1132

aboutToAppear 生命周期

setupMapCallback 初始化地图回调

mapCallback 异步回调

err 是否为空?

console.error 打印错误并返回

获取 mapController

getEventManager 获取事件管理器

循环 addMarker 添加6个美食店铺标注

onMarkerLongClick 注册Marker长按监听

onPoiLongClick 注册POI长按监听

build 主构建 Stack布局

headerMain 头部渐变Banner+筛选chips

Scroll 内容区

currentTab 索引

tabFood 推荐列表+三宫格

tabMap MapComponent+事件日志流

tabSearch searchByText+reliability分数条

tabMine 食客卡+功能清单

tabBar 底部4Tab导航

长按Marker/POI 触发回调

eventLogs.unshift 置顶事件日志

Scroll刷新日志流UI

runSearch 调用site.searchByText

读取Site.reliability字段

reliabilityScore映射等级颜色

Progress分数条+等级标签渲染

弹窗系统 Stack叠加层

panelAdd 收藏店铺

panelEdit 编辑备注

panelDel 删除确认

从流程图可以看出,应用以 Page1132 为入口,生命周期 aboutToAppear 触发地图回调初始化,随后在异步回调内依次完成控制器获取、事件管理器获取、Marker 群添加、双长按监听注册这条关键链路。UI 层则通过 currentTab 状态切换四个 Tab 页面,地图 Tab 承载长按事件日志流,搜索 Tab 承载 reliability 分数条展示,弹窗系统以 Stack 叠加层方式覆盖在主内容之上。整个架构清晰分层,数据流单向可追溯。

三、颜色系统与主题设计

3.1 颜色接口定义

/** 主题色板接口:集中声明页面所有颜色字段(奶白底+番茄红+暖橙浅色系) */
interface ColorPalette {
  bg: string;      // 页面奶白底色
  card: string;    // 卡片纯白底色
  chip: string;    // 胶囊与输入框底色
  title: string;   // 主标题深可可色
  sub: string;     // 副文本暖棕色
  text3: string;   // 弱文本浅驼色
  red: string;     // 番茄红主色
  redD: string;    // 深番茄红
  redL: string;    // 浅番茄红(渐变浅端)
  orange: string;  // 暖橙辅助色
  green: string;   // 营业中状态绿
  line: string;    // 分隔线米色
  tabOn: string;   // 底部 Tab 激活色
  mask: string;    // 弹窗遮罩色
  codeBg: string;  // 代码预览卡深底色(浅色主题也保留深底放代码文本)
}

这段代码定义了一个 ColorPalette 接口,将应用中所有颜色字段集中声明。采用接口而非直接使用字面量散落各处,是 ArkUI 工程化的重要实践:它强制开发者在修改颜色时只改一处常量,避免"改了主色忘改副色"的不一致问题。每个字段都有明确的语义注释,bg 是页面底色、card 是卡片底色、chip 是胶囊和输入框的底色,这种命名方式让颜色与用途绑定,而非与色值绑定——即使将来把番茄红换成其他红,字段名 red 依然成立。

字段类型全部为 string,因为 ArkUI 的 fontColor、backgroundColor、linearGradient 等属性均接受字符串格式的颜色值(包括 #RRGGBB 和 rgba() 形式)。值得注意的是 mask 字段使用的是 rgba(51,36,28,0.52) 半透明格式,而 codeBg 用的是 #2E1E16 深色格式——这说明接口设计兼顾了不同颜色表达形式,只要 string 能承载即可,体现了类型的包容性。

最后可以看到 redD(深番茄红)和 redL(浅番茄红)两个派生色,它们与主色 red 构成一个三级色阶。这在渐变 Banner(linearGradient 从 redL 到 card)和等级标签颜色映射中都会用到。一个完整的主题色板通常包含主色、深色、浅色三态,这里的设计正好契合了这一规范。

3.2 浅色主题色板常量

/** 浅色主题色板常量(食遇记 · 奶白底 + 番茄红 + 暖橙) */
const COLORS: ColorPalette = {
  bg: '#FBF5EC',
  card: '#FFFFFF',
  chip: '#F3E9DA',
  title: '#33241C',
  sub: '#8C7565',
  text3: '#BCA693',
  red: '#D8402C',
  redD: '#A02A18',
  redL: '#F6C9BE',
  orange: '#F08A24',
  green: '#3F9B54',
  line: '#ECDFCB',
  tabOn: '#D8402C',
  mask: 'rgba(51,36,28,0.52)',
  codeBg: '#2E1E16'
};

COLORS 常量是整个应用的颜色中枢。bg #FBF5EC 是带有微暖色调的奶白底,比纯白 #FFFFFF 更有食欲氛围,同时不失清爽——这是美食类应用常用的"米汤色"。card #FFFFFF 用于卡片承载内容,与 bg 形成微弱对比让卡片"浮起来"。chip #F3E9DA 比 bg 更深一档,用于胶囊按钮和输入框底色,形成"底中有底"的层次感。

标题色 title #33241C 是接近深可可的暖黑,比纯黑 #000000 更柔和,与奶白底搭配不会产生刺眼的高对比;副文本 sub #8C7565 是暖棕色,用于次要信息;text3 #BCA693 是浅驼色,用于最弱层级文本如距离、时间。这三档文本色构成清晰的视觉层级,引导用户从标题到副文本再到弱信息的阅读节奏。

主色 red #D8402C 是典型的番茄红,饱和度高但不刺眼,用于主操作按钮、激活态 Tab、高相关等级标签等关键触点。redD #A02A18 是深番茄红,可用于 hover 或 pressed 态(本应用未深入使用但保留扩展空间)。redL #F6C9BE 是浅番茄红,用作渐变 Banner 的浅端,与 card 白色过渡形成柔和的"番茄奶昔"效果。orange #F08A24 暖橙用于次要操作和中相关等级,green #3F9B54 营业中绿用于营业状态——这三种功能色与主色形成完整的"等级-状态"语义体系。mask 用 rgba 半透明遮罩,codeBg #2E1E16 是深棕黑底,专门用于代码预览卡——浅色主题中保留一块深底放代码文本,是代码可读性的常见做法,因为等宽字体在深底浅字下对比更强。

四、常量定义与 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[] = ['全部', '本帮菜', '川湘火锅', '日料刺身', '烘焙甜品', '深夜食堂', '新店速报', '人均50内'];

TabMeta 接口为底部导航定义了数据结构:icon 存 emoji 图标,label 存中文标签。使用 emoji 而非图片资源有两个好处:一是无需引入图片资源文件,降低应用体积;二是 emoji 是矢量字符,在不同分辨率屏幕上始终清晰。TAB_LIST 常量定义了4个 Tab:美食、地图、搜索、我的,这正是本地生活探店应用的标准信息架构——美食是内容消费入口、地图是空间发现入口、搜索是主动检索入口、我的是个人中心入口。4个 Tab 单排排列在底部,是移动端最常见的导航模式。

CATE_TAGS 数组定义了头部横滑筛选 chips 的8个文案:从"全部"到"人均50内",覆盖了品类维度(本帮菜、川湘火锅、日料刺身、烘焙甜品)、场景维度(深夜食堂)、时效维度(新店速报)和价格维度(人均50内)。这种多维筛选是美食探店应用的特色——用户往往不是按单一维度筛选,而是"今晚想吃人均50以内的深夜食堂"这种组合需求。chips 横滑设计能在有限屏幕宽度内容纳全部选项,同时保持可扩展性——将来新增"素食友好""亲子餐厅"等标签只需在数组中追加元素,UI 自动适配。

4.2 城市中心点与地图标注数据

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

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

/** 美食店铺标注点 Mock 数据(6 个,围绕城市中心点 ±0.02 度散布) */
const MARKER_SPOTS: SpotItem[] = [
  { name: '食遇记·豫园老字号菜馆', lat: 31.2271, lng: 121.4891, tag: '本帮菜' },
  { name: '食遇记·云南南路小吃街', lat: 31.2252, lng: 121.4781, tag: '小吃' },
  { name: '食遇记·天钥桥路火锅楼', lat: 31.2150, lng: 121.4620, tag: '火锅' },
  { name: '食遇记·巨鹿路日料亭', lat: 31.2189, lng: 121.4545, tag: '日料' },
  { name: '食遇记·张杨路烘焙坊', lat: 31.2358, lng: 121.4900, tag: '烘焙' },
  { name: '食遇记·定西路深夜食堂', lat: 31.2130, lng: 121.4550, tag: '烧鸟' }
];

CITY_CENTER 使用 mapCommon.LatLng 类型定义了城市中心点坐标——上海人民广场(纬度 31.2304,经度 121.4737)。这个常量承担双重职责:一是作为 MapComponent 初始化时 mapOptions.position.target 的值,让地图加载后自动居中到这个坐标;二是作为 site.searchByText 的 location 参数,让搜索以这个点为中心向四周辐射。一个常量打通"地图渲染"和"POI 检索"两个场景,体现了常量复用的设计思想。

SpotItem 接口定义了地图标注点的数据结构,包含名称、纬度、经度、品类四个字段。MARKER_SPOTS 数组提供了6条 Mock 数据,每条都是一家虚拟美食店铺,坐标围绕 CITY_CENTER 上下浮动约 ±0.02 度(约2公里范围)。这些店铺覆盖了本帮菜、小吃、火锅、日料、烘焙、烧鸟六个品类,品类分布丰富。每条店铺名都以"食遇记·“前缀,既体现应用品牌,也方便在地图长按事件日志中一眼识别。这些数据将在 setupMapCallback 中被遍历,逐条调用 addMarker 添加到地图上,成为长按 Marker 事件的数据来源——可以说,MARKER_SPOTS 是地图 Tab 的"数据底座”。

4.3 精选好店与功能清单数据

/** 精选好店接口(美食 Tab 头部精选大卡) */
interface FoodRec {
  icon: string;    // 店铺 emoji
  name: string;    // 店铺名
  dist: string;    // 距离文本
  cate: string;    // 品类文本
  avg: string;     // 人均文本
  rating: number;  // 评分(用于颜色映射)
}

/** 精选好店 Mock 数据(3 条,头部渐变大卡下方列表) */
const FOOD_RECS: FoodRec[] = [
  { icon: '🍜', name: '南翔小笼馒头店', dist: '420m', cate: '本帮点心', avg: '人均 45 元', rating: 4.7 },
  { icon: '🍲', name: '天钥桥老火锅', dist: '1.1km', cate: '川湘火锅', avg: '人均 98 元', rating: 4.5 },
  { icon: '🍣', name: '鹤桥日料亭', dist: '1.6km', cate: '日料刺身', avg: '人均 156 元', rating: 4.6 }
];

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

/** 我的页功能清单 Mock 数据(8 条,美食探店语义) */
const FUNC_LIST: FuncItem[] = [
  { icon: '📍', label: '探店足迹', value: '86 家 · 覆盖 12 个商圈' },
  { icon: '📝', label: '食记草稿', value: '3 篇待发布' },
  { icon: '🌶', label: '口味偏好', value: '微辣 · 少糖 · 忌香菜' },
  { icon: '⭐', label: '收藏店铺', value: '28 家' },
  { icon: '🎟', label: '美食优惠券', value: '6 张可用' },
  { icon: '🚫', label: '店铺黑名单', value: '2 家' },
  { icon: '🔔', label: '新店上新提醒', value: '已开启' },
  { icon: '⚙', label: '账号设置', value: '美食家 · Lv.6' }
];

FoodRec 接口为美食 Tab 头部的精选好店大卡定义数据结构,rating 字段是 number 类型而非 string,因为后续需要用它做颜色映射(ratingColor 函数依据评分数值返回不同颜色),所以必须是数值类型。FOOD_RECS 数组3条数据覆盖了本帮、火锅、日料三个品类,评分4.5-4.7属于高分区段,颜色会映射到番茄红——这是有意为之,头部精选卡只展示高分好店,视觉上强化"推荐"心智。

FuncItem 接口定义了"我的"页面功能清单的条目结构,每条包含图标、功能名、状态/数值文本。FUNC_LIST 数组8条数据覆盖了探店足迹、食记草稿、口味偏好、收藏店铺、优惠券、黑名单、上新提醒、账号设置——这几乎是一个完整美食探店用户的全生命周期功能图谱。注意 value 字段不是简单的开关状态,而是带数值和描述的复合文本(如"86 家 · 覆盖 12 个商圈"),这让功能清单不只是入口,还是信息展示位——用户在"我的"页面一眼就能看到自己的探店成就和当前状态,无需点进去查看,这是高级信息架构设计。

五、辅助函数:颜色映射策略

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.red };
  }
  if (score >= 0.5) {
    return { label: '中相关', color: COLORS.orange };
  }
  return { label: '低相关', color: COLORS.text3 };
}

reliabilityScore 函数是 Map Kit 6.1.1 reliability 新特性在 UI 层落地的核心映射器。它接收一个 [0,1] 范围的浮点数,返回一个包含 label 和 color 的 ScoreLevel 对象。分档策略采用双阈值三档制:≥0.8 归为"高相关"配番茄红、≥0.5 归为"中相关"配暖橙、其余归为"低相关"配浅驼色。这种分档设计让用户无需阅读具体数值,通过颜色和标签就能快速判断结果质量。

阈值选择 0.8 和 0.5 是有讲究的。0.8 作为高档线意味着"非常匹配用户意图"——在美食搜索场景中,0.8 以上的结果通常就是用户想找的品类(如搜"本帮菜"命中"南翔小笼馒头店"这种核心本帮餐厅)。0.5 作为中档线意味着"部分匹配"——可能品类对但距离较远,或距离近但品类是边缘匹配。低于0.5则是"弱匹配或误匹配"——如搜"本帮菜"命中"本帮酱料专卖店"(非餐饮),reliability 会非常低。这套阈值体系与后端检索引擎的相关性算法分布大致对应,能让 UI 呈现与算法意图保持一致。

返回 ScoreLevel 对象而非单独返回颜色或标签,是因为 UI 层需要同时使用两者——Progress 分数条用 color,Text 标签用 label 和 color。封装成对象一次返回,调用方一次取用即可,避免重复调用函数造成不一致。在 tabSearch 的渲染中,reliabilityScore 会被调用两次(一次给标签、一次给分数条),虽然看似有性能损耗,但因为结果是纯函数(相同输入相同输出),实际开销极低,可读性收益远大于性能损耗。

5.2 评分与营业状态颜色映射

/** 店铺评分颜色映射:≥4.6 口碑爆棚红 / ≥4.2 值得一试橙 / 其余浅驼 */
function ratingColor(rating: number): string {
  if (rating >= 4.6) {
    return COLORS.red;
  }
  if (rating >= 4.2) {
    return COLORS.orange;
  }
  return COLORS.text3;
}

/** 营业状态颜色映射:营业中绿 / 其余浅驼 */
function openColor(status: string) {
  if (status === '营业中') {
    return COLORS.green;
  }
  return COLORS.text3;
}

ratingColor 函数将店铺评分数值映射为颜色,分档阈值 4.6 和 4.2 同样基于美食行业常识:4.6 以上属于口碑爆棚级别(大众点评黑珍珠餐厅起步分),用番茄红强化"必吃"心智;4.2 以上属于值得一试级别(多数优质餐厅的水平),用暖橙提示"可以尝试";4.2 以下用浅驼色弱化呈现,避免低分店喧宾夺主。这套映射在 FOOD_RECS 精选好店卡和 SHOP_LIST 全部店铺列表中都会用到,确保评分颜色全应用统一。

openColor 函数更简单,只判断"营业中"和其他状态两种情况,营业中返回绿色、其他返回浅驼。这是二元状态映射——因为本应用只区分"营业中/休息中",未细分"即将打烊""24小时"等中间态。但函数结构为未来扩展留了空间,只需追加 else if 分支即可。绿色 #3F9B54 是低饱和的功能绿,与暖色调主题不冲突,同时保留"通行/可用"的语义直觉。

六、数据模型:@Observed 状态管理

6.1 店铺条目 ShopItem

/** 店铺条目(美食 Tab 推荐列表,@Observed 支持备注编辑刷新) */
@Observed export class ShopItem {
  icon: string;    // 店铺 emoji 图标
  name: string;    // 店名
  cate: string;    // 品类
  rating: number;  // 评分
  avg: number;     // 人均(元)
  status: string;  // 营业状态
  note: string;    // 用户备注(可编辑)

  constructor(icon: string, name: string, cate: string, rating: number,
    avg: number, status: string, note: string) {
    this.icon = icon;
    this.name = name;
    this.cate = cate;
    this.rating = rating;
    this.avg = avg;
    this.status = status;
    this.note = note;
  }
}

ShopItem 类用 @Observed 装饰器修饰,这是 ArkUI 状态管理 V1 的核心装饰器之一。@Observed 会让该类的实例属性变为可观察的——当属性值变化时,绑定了该实例的 UI 组件会自动刷新。本应用中 ShopItem 的 note(备注)字段是可编辑的,用户通过编辑弹窗修改备注后,列表中对应卡片的备注文本需要立即更新,@Observed 保证了这一点。

类定义了7个字段覆盖店铺的完整画像:emoji 图标、店名、品类、评分、人均、营业状态、备注。constructor 显式赋值所有字段,这是 ArkUI @Observed 类的必要写法——@Observed 会在构造时劫持属性,所以必须在 constructor 内完成初始化赋值,不能依赖类属性默认值。export 关键字使 ShopItem 可被其他文件引用,体现了模块化设计。

字段类型上,rating 和 avg 是 number,因为需要参与数值比较(ratingColor 判断)和格式化展示(toFixed(1) 保留一位小数)。status 是 string,因为营业状态的值是离散文案而非数值。note 是 string,存储用户自由编辑的备注文本,是整个类中最活跃的字段——编辑弹窗 saveShop/updateShop 方法都围绕它展开。@Observed 让 note 的修改能精准刷新到 UI,无需手动触发重绘。

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.reliability = reliability;
    this.distance = distance;
    this.time = time;
  }
}

SearchRecord 类是 site.searchByText 返回 Site 对象在 UI 层的映射载体。它从 Site 中提取了5个关键字段:name(地点名称)、address(格式化地址)、distance(直线距离米)、reliability(相关性分数)、time(记录时间文案)。其中 reliability 字段带 ★ 注释,明确标注这是 6.1.1 新增字段的承载点——这是整个应用对 reliability 特性的数据层落地。

字段注释采用"site.xxx"格式,清晰标注每个字段的来源是 Site 对象的哪个属性,方便开发者对照 Map Kit 官方文档。reliability 字段注释特别说明取值范围 [0,1],1 为完全相关——这是 6.1.1 新特性最核心的语义信息。@Observed 修饰保证了当 searchRecords 数组被替换或元素属性变化时,搜索结果列表 UI 会自动刷新,无需手动调用 invalidate。

在 runSearch 方法中,遍历 site.searchByText 返回的 sites 数组,对每个 Site 对象构造一个 SearchRecord 实例,使用 s.reliability ?? 0 进行空值合并——这是因为 reliability 是新增字段,老版本 SDK 返回的 Site 可能没有这个字段(undefined),?? 0 保证向下兼容,让旧数据也能展示为"低相关"而非崩溃。这种防御式编程在对接新 API 时尤为重要。

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

EventLog 类是 MapEventManager 长按事件回调在 UI 层的数据载体。type 字段区分两种事件来源:‘Marker’ 表示来自 onMarkerLongClick 回调(用户长按了地图上的自定义标注),‘POI’ 表示来自 onPoiLongClick 回调(用户长按了地图上的原生兴趣点)。name 字段对两种类型的语义不同——Marker 事件存的是 marker.getId() 返回的 ID(如 “#0”),POI 事件存的是 poi.name 兴趣点名称(如"豫园")。这种"同字段不同语义"的设计通过 type 字段做联合区分,避免了为两种事件定义两个类,简化了数据结构。

lat 和 lng 记录事件发生的地理坐标——Marker 事件通过 marker.getPosition() 获取,POI 事件通过 poi.position 获取。这些坐标在 UI 中用 toFixed(4) 保留4位小数展示,精度约11米,足够标识一条探店事件。time 字段存储"刚刚"这样的相对时间文案,简化了时间格式化逻辑——在真实应用中应替换为真实时间戳并做相对时间计算。

@Observed 修饰让 EventLog 实例的属性变化能驱动 UI 刷新。但本应用的事件日志刷新策略是 unshift 新条目到数组头部后整体替换数组引用(详见后文 setupMapCallback 分析),所以 @Observed 在这里更多是"防御性"标注——保证未来如果需要修改单条日志的属性(如标记已读),UI 仍能正确刷新。

七、组件主体:状态声明与地图初始化

7.1 状态变量声明

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

Page1132 是应用的入口组件,用 @Entry 和 @Component 装饰。@Entry 标识这是页面级组件(会被编译为页面路由入口),@Component 标识这是一个自定义组件。struct 关键字是 ArkUI 声明式语法的特色——组件用 struct 而非 class 定义,因为 ArkUI 组件是"描述性"的而非"实例化"的,struct 更贴合"模板"语义。

@State 装饰的状态变量是组件刷新的驱动力。currentTab 控制四个 Tab 页面切换,初始值 0 表示默认显示美食 Tab。cateIdx 控制头部筛选 chips 的选中态,初始 0 对应"全部"。addModal/editModal/delModal 三个布尔值分别控制三个弹窗的显隐——这种"每个弹窗一个布尔开关"的设计简单直观,适合弹窗数量少的场景;如果弹窗很多,可以考虑用枚举或 currentModal: string | null 统一管理。

editIdx 和 delIdx 记录当前操作的目标店铺索引,配合 editNote(编辑备注输入值)一起工作。shopList 是 @Observed ShopItem 数组,作为美食 Tab 全部店铺列表的数据源,初始值 SHOP_LIST。funcList 是 FuncItem 数组,作为"我的"页功能清单数据源。这两个数组用 @State 修饰,意味着数组整体替换会触发刷新——shopList = shopList.slice() 就是利用这一点强制刷新列表。

7.2 Map Kit 状态声明

  // --- 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 formCate: string = '';
  /** 收藏弹窗:地址输入 */
  @State formAddr: string = '';
  /** 编辑弹窗:备注输入 */
  @State editNote: string = '';

这一组状态变量专门服务于 Map Kit 两大新特性。mapOptions 是 private(非 @State),因为地图初始化参数在组件生命周期内不变,无需触发刷新。它使用 mapCommon.MapOptions 类型,position.target 设为 CITY_CENTER(上海人民广场),zoom 13 是城市级地图的标准缩放级别——既能看到街区细节,又不会过于贴近单点。给默认值而非 undefined 是为了避免 MapComponent 组件参数传 undefined 导致初始化异常。

mapCallback、mapController、mapEventManager 三个变量都用 private 修饰(非 @State),因为它们是地图引擎的句柄而非 UI 状态——它们变化不需要触发刷新,但需要被组件方法访问。mapCallback 用 ? 标记可选,因为它在 setupMapCallback 中才赋值;mapController 和 mapEventManager 同理,只在异步回调内才被赋值。markerListenOn 和 poiListenOn 是 @State 布尔值,控制两个 Toggle 开关的激活态,初始都是 true(监听默认开启)。

eventLogs 是 @State EventLog 数组,是长按事件日志流的数据源。初始值 EVENT_LOGS 提供了2条演示数据,让用户进入地图 Tab 就能看到日志流的样子。queryInput 是搜索框的值,初始"本帮菜"是一个有代表性的关键字——能命中本帮餐厅,也能命中"本帮酱料专卖店"这种低相关结果,正好展示 reliability 的分档价值。searchState 是搜索状态文案,初始"待搜索 · 演示数据"诚实告知用户当前是演示态。searchRecords 是搜索结果数据源,初始 SEARCH_RECORDS 提供6条覆盖高/中/低三档 reliability 的数据。formName/formCate/formAddr/editNote 四个变量分别承载收藏弹窗和编辑弹窗的输入值。

7.3 地图初始化回调 setupMapCallback

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

setupMapCallback 是整个 Map Kit 集成的核心方法。它构造一个 AsyncCallback 函数赋值给 this.mapCallback,这个回调会在 MapComponent 初始化完成后被调用。回调参数是 (err, mapController) 双参形式——err 是 BusinessError 类型,如果地图初始化失败会携带错误码和消息;mapController 是 MapComponentController 类型,是操作地图的句柄。方法用 async 关键字修饰,因为内部要 await addMarker 异步操作。

回调开头先判断 err 是否存在,如果存在就 console.error 打印错误信息并 return——这是典型的错误短路模式,避免在控制器未就绪时继续执行后续逻辑。成功分支内,依次将 mapController 赋给 this.mapController(保存句柄供其他方法使用),再通过 mapController.getEventManager() 获取 MapEventManager——这是访问 6.1.1 长按事件接口的必经入口。getEventManager() 不接受参数,返回与该地图实例绑定的事件管理器实例。

紧接着是 Marker 群添加逻辑。for…of 遍历 MARKER_SPOTS 数组(6条店铺数据),对每条构造 markerOptions 对象。markerOptions 的字段非常完整:position 设定坐标、clickable: true 允许点击、visible: true 默认可见、rotation 0 不旋转、zIndex 0 默认层级、alpha 1 完全不透明、anchorU 0.5 和 anchorV 1 将锚点设在图标底部中心(让标注像图钉一样"钉"在坐标点)、draggable: false 不可拖动、flat: false 不平贴地图(保持立体感)。每个 addMarker 都 try-catch 包裹,避免单个失败中断整批添加——这是批量异步操作的稳健实践。

最后是 6.1.1 双长按监听注册,这是本应用最核心的技术点。onMarkerLongClick 接收一个回调函数,参数是 map.Marker 类型——当用户长按地图上的 Marker 时,该回调被触发,开发者可从 marker 对象调用 getPosition() 获取坐标、getId() 获取 ID。本应用在回调内构造一个 EventLog(‘Marker’, #${marker.getId()}, lat, lng, ‘刚刚’) 并 unshift 到 eventLogs 数组头部,让新事件出现在日志流顶部。onPoiLongClick 的回调参数是 mapCommon.Poi 类型,与 Marker 不同——POI 是地图原生兴趣点,有 name 和 position 属性,无需调用 getPosition() 方法而是直接读 poi.position。这两个接口的参数类型差异体现了 Map Kit 对"自定义标注"和"原生兴趣点"的区分设计——它们是两个独立的实体体系,各有各的事件、属性和操作方法。

7.4 长按监听开关切换

  /** 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 和 togglePoiListen 两个方法实现了长按监听的开/关切换逻辑。两个方法结构对称:先判空 mapEventManager(如果事件管理器未就绪直接 return,避免空指针),然后根据当前 markerListenOn/poiListenOn 的布尔值决定是 off 还是 on。如果当前是开启状态(true),调用 off 接口清除订阅;如果当前是关闭状态(false),调用 on 接口重新订阅。最后取反布尔值更新状态,驱动 Toggle 开关 UI 刷新。

offMarkerLongClick 和 offPoiLongClick 不接受参数——这是 6.1.1 接口的设计决策:一个 off 调用会清除该类型的全部订阅回调,不支持只移除特定回调。这种"全清"语义简化了接口设计,但要求开发者在多回调场景下自行管理回调引用(如果需要细粒度取消)。本应用每个类型只注册一个回调,所以"全清"正好够用。

值得注意的是,重新订阅时 onMarkerLongClick/onPoiLongClick 传入的回调与 setupMapCallback 中注册的是完全相同的代码——这是因为 off 清除了原回调,必须重新传入新回调才能恢复监听。这种设计下,如果回调逻辑很长,建议抽取为类方法或私有函数避免重复代码。本应用为了可读性直接复制了回调代码,是一种"用冗余换清晰"的取舍。在真实工程中,可以改为 private markerHandler = (marker: map.Marker) => {...} 箭头函数属性,在 on/off 中引用同一函数。

7.5 搜索方法 runSearch

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

runSearch 是 6.1.1 搜索新特性的 API 调用入口。方法是 async,因为 site.searchByText 返回 Promise。方法开头先将 searchState 设为"搜索中…",让用户立即看到搜索进行中的反馈——这是异步操作的标准 UX 实践,避免用户以为点击没反应。

构造 params 对象使用 site.SearchByTextParams 类型,包含4个字段:query 是搜索关键字(来自 queryInput 状态)、location 是搜索中心点(CITY_CENTER 上海人民广场)、radius 是搜索半径 5000 米(5公里覆盖一个街区级探店范围)、language 是 ‘zh’ 中文结果。这4个参数共同决定了搜索的范围和语义——Map Kit 后端会以 location 为圆心、radius 为半径,检索匹配 query 关键字的 POI,并按相关性排序返回。

try-catch 包裹 site.searchByText 调用。成功分支内,先从 result.sites 取出 Site 数组(用 ?? [] 兜底空值),如果数组为空就更新状态为"无结果 · 保留演示数据"并 return——注意这里没有清空 searchRecords,而是保留之前的演示数据,让用户在无网络或无结果时仍能看到 UI 样子。如果数组非空,遍历每个 Site 构造 SearchRecord 实例。这里的关键是 s.reliability ?? 0——reliability 是 6.1.1 新增字段,老版本 SDK 返回的 Site 可能没有这个属性(undefined),用 ?? 0 兜底为 0,让旧数据也能在 UI 上展示为"低相关"分数条而非崩溃。s.name ?? ‘未命名地点’ 和 s.formatAddress ?? ‘暂无地址’ 同理兜底。

catch 分支捕获 BusinessError,将错误码拼接到 searchState 中——如"搜索失败(11004) · 保留演示数据"。这种错误文案既告知了用户失败事实,又保留了演示数据让 UI 不会空白,是演示类应用的友好降级策略。在真实生产应用中,catch 分支应该做更精细的错误分类处理(网络错误提示重试、权限错误提示授权、关键字错误提示修改等),但本应用聚焦特性演示,统一降级即可。构造好的 records 数组整体赋值给 this.searchRecords,触发 @State 刷新搜索结果列表 UI。

7.6 弹窗业务方法

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

  /** 保存收藏店铺(空名兜底默认演示店) */
  saveShop() {
    const name = this.formName === '' ? '食遇记·新收藏店' : this.formName;
    const cate = this.formCate === '' ? '待分类' : this.formCate;
    const addr = this.formAddr === '' ? '上海市黄浦区(地图选点)' : this.formAddr;
    this.shopList.unshift(new ShopItem('⭐', name, cate, 4.0, 60, '营业中', addr));
    this.formName = '';
    this.formCate = '';
    this.formAddr = '';
    this.addModal = false;
  }

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

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

这四个方法构成了弹窗系统的业务逻辑层。openEditShop 接收店铺索引 idx,将其存入 editIdx,并从 shopList[idx].note 回填到 editNote——这是"编辑"的核心交互:弹窗打开时输入框必须显示当前值,让用户基于现有内容修改而非从空白开始。最后设 editModal = true 打开弹窗。

saveShop 处理收藏新店铺。三个输入值 formName/formCate/formAddr 都做空字符串兜底——如果用户没填店名,默认"食遇记·新收藏店";没填品类默认"待分类";没填地址默认"上海市黄浦区(地图选点)"。这种兜底策略让用户即使直接点"收藏"也能生成一条有效记录,降低操作门槛。构造 ShopItem 时 icon 固定为 ⭐(收藏标记),rating 默认 4.0(中等评分,不偏不倚),avg 60(中档人均),status “营业中”。unshift 到 shopList 头部让新店铺出现在列表顶部。最后清空三个表单字段并关闭弹窗,为下次打开留出干净状态。

updateShop 处理编辑备注保存。先做边界检查 editIdx 是否在 shopList 长度范围内(防止越界),再判断 editNote 非空才赋值(避免空备注覆盖原备注)。关键的一行是 this.shopList = this.shopList.slice()——slice() 不传参数会返回数组的浅拷贝新引用,赋值给 @State shopList 会触发列表整体刷新。这是因为 @Observed ShopItem 的 note 属性变化虽然能驱动该卡片刷新,但 @State 数组本身引用不变时,ArkUI 有时无法可靠触发 ForEach 的整体重算,slice() 强制换引用是最稳妥的刷新手段。

delShop 处理删除收藏。同样做边界检查,然后 splice(delIdx, 1) 删除一条,关闭弹窗。splice 直接修改原数组,@State 数组的元素增减能被 ArkUI 检测到触发 ForEach 重新渲染。这里没有 slice() 换引用,因为 splice 已经是结构性变化,ForEach 能正确识别增删。但 updateShop 是属性变化(note 字符串替换),ForEach 可能不识别,所以需要 slice()——这是 ArkUI 状态管理的一个微妙之处。

7.7 生命周期与主构建

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

  /** 页面主构建:Stack 包裹主内容与三层弹窗 */
  build() {
    Stack() {
      Column() {
        this.headerMain()
        Divider().strokeWidth(1).color(COLORS.line)
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.tabFood()
            } else if (this.currentTab === 1) {
              this.tabMap()
            } else if (this.currentTab === 2) {
              this.tabSearch()
            } else {
              this.tabMine()
            }
          }
          .padding({ left: 14, right: 14, top: 12, bottom: 12 })
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        this.tabBar()
      }
      .width('100%')
      .height('100%')

      if (this.addModal) {
        this.panelAdd(() => {
          this.addModal = false;
        })
      }
      if (this.editModal) {
        this.panelEdit(() => {
          this.editModal = false;
        })
      }
      if (this.delModal) {
        this.panelDel(() => {
          this.delModal = false;
        })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }

aboutToAppear 是 ArkUI 组件的生命周期回调,在组件创建后、build() 调用前执行。本应用在此调用 setupMapCallback() 完成地图回调的构造和赋值——时机选择很关键:必须在 build() 之前把 mapCallback 准备好,因为 build() 内的 MapComponent 组件需要读取 this.mapCallback 作为初始化参数。如果延后到 aboutToAppear 之后(如 onAppear),MapComponent 已经用 undefined 初始化完毕,回调再赋值也不会被调用。

build() 方法是组件的 UI 描述入口。最外层是 Stack 容器——Stack 是层叠布局,子元素从下往上堆叠,后声明的在上层。本应用用 Stack 实现"主内容在下、弹窗在上"的层叠结构。Stack 内首先是 Column 包裹的主内容:headerMain 头部、Divider 分隔线、Scroll 可滚动内容区、tabBar 底部导航。Divider 用 line 米色,视觉上分隔头部和内容。

Scroll 内的 Column 根据 currentTab 的值条件渲染四个 Tab 页面——if/else if/else 是 ArkUI 的条件渲染语法,currentTab 变化时只有对应分支的 Builder 会被调用,其他 Tab 不渲染。这是"单页面切换 Tab"模式(而非用 Tabs 组件),好处是完全控制切换逻辑和动画,灵活性高。Scroll 设 layoutWeight(1) 占满头部和底部之间的剩余高度,scrollBar 关闭让滚动条不可见但滚动功能保留。

Stack 内主内容之后是三个条件渲染的弹窗——if (this.addModal) / if (this.editModal) / if (this.delModal)。每个弹窗接收一个 () => void 类型的 onClose 回调,用于点击遮罩或取消按钮时关闭。三个弹窗互斥(同时只会有一个 modal 为 true),但代码没有强制互斥逻辑,依赖业务调用顺序保证。Stack 让弹窗自动覆盖在主内容之上,弹窗内的 modalOverlay 遮罩层用半透明 rgba 色填充全屏,模拟原生模态对话框效果。

八、Builder 函数群:UI 组件的声明式构建

8.1 头部渐变 Banner headerMain

  /** 头部:渐变 Banner(评分+探店动态)+ 筛选 chips 横滑 */
  @Builder
  headerMain() {
    Column({ space: 12 }) {
      // 顶部渐变 Banner:评分环 + 探店动态文案 + 快捷入口
      Column({ space: 10 }) {
        Row({ space: 12 }) {
          Column({ space: 2 }) {
            Text('4.6').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('附近营业中 26 家 · 最近 320m').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            }
            Row({ space: 6 }) {
              Text('📝').fontSize(12)
              Text('今日探店 3 家 · 人均 68 元').fontSize(11).fontColor(COLORS.sub)
            }
            Row({ space: 6 }) {
              Text('📔').fontSize(12)
              Text('收藏店铺 28 家 · 食记 12 篇').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.red)
          Text('🗺 地图找店').fontSize(12).fontColor(COLORS.red)
            .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.redL, 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.red : COLORS.chip)
              .onClick(() => { this.cateIdx = idx; })
          }, (tag: string) => tag)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .padding({ left: 14, right: 14, top: 12, bottom: 8 })
    .width('100%')
  }

headerMain 是应用头部的完整 Builder。@Builder 装饰器标识这是一个 UI 构建函数,可在 build() 内像组件一样调用。整个头部由两部分组成:渐变 Banner 和横滑筛选 chips。

渐变 Banner 是一个 Column,内部分两层。第一层是 Row 包裹的"评分+动态"信息行:左侧 Column 展示"4.6 本周综合评分"(大号粗体数字+小号副文本,形成数字仪表盘效果),右侧 Column 用 layoutWeight(1) 占满剩余宽度,展示三行探店动态——营业中数量、今日探店数据、收藏数据,每行用 emoji 图标+文本组成。第二层是两个快捷入口胶囊:"探店打卡"用番茄红底白字(主操作),"地图找店"用白底番茄红字(次操作,点击切换到地图 Tab)。整个 Banner 用 linearGradient 渐变背景,从 redL 浅番茄红(0% 位置)过渡到 card 白色(65% 位置),角度 135 度——这种"浅到白"的对角渐变让 Banner 既有色彩感又不失清爽,番茄红的暖意从左上角晕染开来。

横滑筛选 chips 是一个 Scroll 容器,内部 Row 横向排列 ForEach 渲染的8个 Text 胶囊。Scroll 设 scrollable(Horizontal) 横向滚动、scrollBar(Off) 隐藏滚动条。每个 chip 的 fontColor 和 backgroundColor 根据 cateIdx === idx 判断:选中态用番茄红底+奶白字(强对比高亮),未选中用 chip 米色底+sub 棕色字(弱对比常态)。onClick 切换 cateIdx,触发 ForEach 重新渲染高亮态。这是筛选组件的标准模式——状态驱动样式,无需手动操作 DOM。

8.2 美食 Tab tabFood

  /** 美食 Tab:探店数据三宫格 + 精选好店 + 全部店铺列表(业务主 Tab) */
  @Builder
  tabFood() {
    Column({ space: 10 }) {
      // 区块标题行:更多入口
      Row() {
        Text('附近好店').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Blank()
        Text('收藏新店铺 +').fontSize(11).fontColor(COLORS.red)
          .onClick(() => { this.addModal = true; })
      }
      .width('100%')
      // 探店数据三宫格
      Row({ space: 8 }) {
        this.statCell('3 家', '今日已探店')
        this.statCell('26 家', '营业中好店')
        this.statCell('68 元', '今日人均')
      }
      .width('100%')
      // 精选好店大卡(3 条)
      ForEach(FOOD_RECS, (rec: FoodRec) => {
        Row({ space: 10 }) {
          Text(rec.icon).fontSize(26)
          Column({ space: 4 }) {
            Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text(`${rec.cate} · ${rec.avg}`).fontSize(11).fontColor(COLORS.sub)
            Row({ space: 6 }) {
              Text(rec.dist).fontSize(10).fontColor(COLORS.text3)
              Text(`评分 ${rec.rating.toFixed(1)}`).fontSize(10).fontColor(ratingColor(rec.rating))
            }
          }
          .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.red)
              .onClick(() => { this.currentTab = 1; })
          }
        }
        .padding(12)
        .borderRadius(12)
        .backgroundColor(COLORS.card)
        .width('100%')
      }, (rec: FoodRec) => rec.name)
      // 全部店铺列表(长按 Marker 的数据同源)
      Row() {
        Text('全部店铺(地图 Marker 同源)').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      }
      .width('100%')
      ForEach(this.shopList, (shop: ShopItem, idx: number) => {
        Column({ space: 8 }) {
          Row({ space: 10 }) {
            Text(shop.icon).fontSize(22)
            Column({ space: 3 }) {
              Text(shop.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
              Text(`${shop.cate} · 人均 ${shop.avg}`).fontSize(11).fontColor(COLORS.sub)
              Text(`备注:${shop.note}`).fontSize(10).fontColor(COLORS.text3)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column({ space: 6 }) {
              Text(shop.rating.toFixed(1)).fontSize(16).fontWeight(FontWeight.Bold)
                .fontColor(ratingColor(shop.rating))
              Text('评分').fontSize(9).fontColor(COLORS.text3)
            }
          }
          .width('100%')
          Row({ space: 8 }) {
            Text(shop.status).fontSize(10).fontColor(openColor(shop.status))
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10).backgroundColor(COLORS.chip)
            Blank()
            Text('编辑').fontSize(10).fontColor(COLORS.orange)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10).backgroundColor(COLORS.chip)
              .onClick(() => { this.openEditShop(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%')
      }, (shop: ShopItem) => shop.name)
      // 探店小贴士卡
      this.tipsCard()
    }
    .width('100%')
  }

tabFood 是美食 Tab 的完整 Builder,是业务主 Tab。整体结构分5层:标题行+收藏入口、探店数据三宫格、精选好店卡列表、全部店铺列表、探店小贴士。标题行用 Row+Blank 实现两端对齐——"附近好店"在左、"收藏新店铺 +"在右,点击右侧打开收藏弹窗。Blank 组件是 ArkUI 的弹性占位符,自动填充剩余空间,是实现两端对齐的惯用手段。

三宫格通过三次调用 statCell Builder 实现,展示"今日已探店/营业中好店/今日人均"三个核心指标。statCell 是可复用的统计单元格 Builder,体现了 @Builder 的复用价值——一次定义多次调用。精选好店用 ForEach 渲染 FOOD_RECS(3条),每张卡左侧 emoji 大图标、中间店名+品类+距离+评分、右侧"去探店"红色按钮(点击切换到地图 Tab)。评分颜色用 ratingColor(rec.rating) 动态映射,让4.7/4.6的高分呈现番茄红,强化推荐心智。

全部店铺列表是美食 Tab 的核心内容区,ForEach 遍历 this.shopList(7条 ShopItem 数据)。每张店铺卡分上下两部分:上部是 emoji+店名+品类+备注+评分(右侧大号数字+小号"评分"标签),下部是营业状态胶囊+编辑/删除按钮。营业状态用 openColor 映射颜色(营业中绿/休息中驼),编辑按钮点击调用 openEditShop(idx) 打开编辑弹窗,删除按钮点击设 delIdx 并打开删除确认弹窗。备注文本"备注:${shop.note}"会随 updateShop 方法的 slice() 刷新而更新——这里 @Observed ShopItem 的 note 属性变化和数组引用替换双重保障了 UI 同步。

8.3 数据统计单元格与探店小贴士

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

  /** 探店小贴士卡(主列表底部) */
  @Builder
  tipsCard() {
    Column({ space: 6 }) {
      Text('💡 探店小贴士').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Text('· 排队超过 30 分钟的热门店,建议先收藏错峰前往')
        .fontSize(10).fontColor(COLORS.sub).width('100%')
      Text('· 人均低于 50 元的小馆子,先翻最新 3 条食记再决定')
        .fontSize(10).fontColor(COLORS.sub).width('100%')
    }
    .padding(12)
    .borderRadius(12)
    .backgroundColor(COLORS.chip)
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

statCell 是一个带参数的 @Builder,接收 value 和 label 两个 string 参数。这是 ArkUI @Builder 的高级用法——支持参数化复用。Builder 内部是 Column 布局:value 用17号粗体番茄红(数字突出),label 用10号棕色(说明文字)。layoutWeight(1) 让单元格在三宫格 Row 中均分宽度,padding 上下10、borderRadius 10、card 白底构成一个清爽的统计卡片。这个 Builder 在 tabFood 和 tabMine 中都被调用,体现了"一次定义多处复用"的工程价值。

tipsCard 是探店小贴士卡片,用 chip 米色底(比 card 更深一档)与店铺卡形成层次区分。内部三条 Text:标题"💡 探店小贴士"+两条贴士文案。贴士内容是真实的探店经验——“排队超30分钟先收藏错峰”“人均50以下先翻食记”,这种内容型卡片增加了应用的"专业感"和"陪伴感",让用户感觉应用不仅提供数据还提供智慧。alignItems(HorizontalAlign.Start) 让文本左对齐,符合阅读习惯。

8.4 地图 Tab tabMap 与长按事件特性

  /** 地图 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.red)
            .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.red)
            .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.red : COLORS.orange)
                    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 是 6.1.1 长按事件特性的核心展示页。整体从上到下分四层:特性说明卡、监听开关行、MapComponent 本体、事件日志流。特性说明卡用 chip 米色底承载标题和说明文案,让用户进入页面就明白这个 Tab 演示的是什么特性。监听开关行是两个 Toggle Switch——markerListenOn 控制 Marker 长按监听、poiListenOn 控制 POI 长按监听,Toggle 的 selectedColor 设为番茄红与主题统一,onChange 调用 toggleMarkerListen/togglePoiListen 方法切换订阅状态。

MapComponent 是 Map Kit 在 ArkUI 中的容器组件,接收 mapOptions 和 mapCallback 两个参数——mapOptions 在组件初始化时一次性传入(城市中心点+zoom 13),mapCallback 是初始化完成后的异步回调(在 setupMapCallback 中构造)。layoutWeight(1) 让地图占满开关行和日志流之间的所有剩余高度,这是地图 Tab 视觉重点。borderRadius(12) 让地图圆角化,与卡片风格统一。当用户长按地图上的 Marker 或 POI 时,6.1.1 新接口 onMarkerLongClick/onPoiLongClick 的回调会触发,构造 EventLog 并 unshift 到 eventLogs 头部。

事件日志流是一个固定高度 120 的 Scroll 容器,内部 ForEach 渲染 eventLogs 数组。每条日志用 Row 布局:左侧 emoji 图标(Marker 用 📍、POI 用 🍽,通过 log.type 判断)、右侧 Column 展示类型标签+名称+时间(一行)和坐标(一行)。类型标签颜色用 log.type 判断——Marker 番茄红、POI 暖橙,与 reliability 等级颜色体系呼应。坐标用 toFixed(4) 保留4位小数+monospace 等宽字体,呈现"技术日志"的精确感。新事件 unshift 到数组头部,出现在列表顶部——这是"最新优先"的信息架构,符合事件流类 UI 的惯例。

8.5 搜索 Tab tabSearch 与 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.red)
          .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 是 6.1.1 reliability 特性的核心展示页。整体分5层:特性说明卡、搜索框+按钮、搜索状态文案、搜索结果列表、代码预览卡。搜索框用 TextInput 组件,text 参数绑定 queryInput 状态实现受控输入——onChange 回调将输入值同步到 queryInput,按钮点击调用 runSearch 触发 site.searchByText。searchState 文案动态显示搜索进度(“搜索中…”/“找到 X 家店”/“搜索失败(code)”),让用户始终知道当前状态。

搜索结果列表用 List 组件(而非 Scroll+Column),因为 List 是 ArkUI 专为长列表设计的虚拟化容器——只渲染可见区域的 ListItem,大数据量下性能优于 Scroll。每个 ListItem 内是一个 Column 卡片,包含4行信息:第一行是店名(layoutWeight 占满+maxLines 1+textOverflow Ellipsis 省略号)和 reliability 等级标签(reliabilityScore 返回的 label 和 color);第二行是地址(同样省略号处理);第三行是 reliability 分数条——这是本特性的视觉核心。

分数条用 Progress 组件,type 设为 Linear 线性进度条,value 是 rec.reliability * 100(将 [0,1] 映射到 [0,100] 百分比),total 100。color 用 reliabilityScore(rec.reliability).color 动态着色——高相关番茄红、中相关暖橙、低相关浅驼。分数条右侧是"reliability 0.96"这样的数值文本,用 monospace 等宽字体呈现,强化"技术指标"的精确感。这种"分数条+数值文本"的组合让用户既能直观感受分数高低(条长度),又能看到精确数值(文本),是数据可视化的人性化设计。第四行是距离(米转公里保留2位小数)和时间文案。

8.6 代码预览卡 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.red).fontFamily('monospace')
        Text('const score = res.sites[0].reliability // [0,1]')
          .fontSize(9).fontColor(COLORS.red).fontFamily('monospace')
        Text('manager.onMarkerLongClick(mk => {...})  // 24+')
          .fontSize(9).fontColor(COLORS.orange).fontFamily('monospace')
        Text('manager.onPoiLongClick(poi => {...})     // 24+')
          .fontSize(9).fontColor(COLORS.orange).fontFamily('monospace')
      }
      .padding(10)
      .borderRadius(8)
      .backgroundColor(COLORS.codeBg)
      .width('100%')
    }
    .padding(10)
    .borderRadius(10)
    .backgroundColor(COLORS.chip)
    .width('100%')
  }

codePreviewCard 是一个技术展示型卡片,用深色底(codeBg #2E1E16 深棕黑)+ monospace 等宽字体呈现 6.1.1 两大特性的核心 API 调用代码。四行代码分两组着色:前两行是搜索特性(site.searchByText + reliability 读取)用番茄红,后两行是事件特性(onMarkerLongClick + onPoiLongClick)用暖橙——颜色分组与全应用的颜色语义体系一致。这种"在应用内展示自身技术栈代码"的设计,既是技术演示也是教学,让开发者用户能直接看到 API 签名。注释"// 24+"表示 API level 24+(对应 HarmonyOS 6.1.1),是版本兼容性的重要提示。

8.7 我的 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('探店 92 折券每月 2 张 · 新店优先内测').fontSize(11).fontColor(COLORS.sub)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')
        Divider().strokeWidth(1).color(COLORS.line)
        Row() {
          Text('本月探店 9 家').fontSize(11).fontColor(COLORS.sub)
          Blank()
          Text('累计获赞 328').fontSize(11).fontColor(COLORS.red)
        }
        .width('100%')
      }
      .padding(14)
      .borderRadius(14)
      .linearGradient({
        angle: 135,
        colors: [[COLORS.redL, 0.0], [COLORS.card, 0.72]]
      })
      .width('100%')
      // 数据三宫格
      Row({ space: 8 }) {
        this.statCell('86 家', '累计探店')
        this.statCell('42 篇', '发布食记')
        this.statCell('126 元', '本月省下')
      }
      .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.13.2 · Map Kit 6.1.1 双新特性演示').fontSize(9).fontColor(COLORS.text3)
    }
    .width('100%')
  }

  /** 底部导航 Tab 栏(4 Tab 单排) */
  @Builder
  tabBar() {
    Row() {
      ForEach(TAB_LIST, (tab: TabMeta, idx: number) => {
        Column({ space: 3 }) {
          Text(tab.icon).fontSize(18)
          Text(tab.label).fontSize(10)
            .fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
        }
        .layoutWeight(1)
        .onClick(() => { this.currentTab = idx; })
      }, (tab: TabMeta) => tab.label)
    }
    .padding({ top: 8, bottom: 8 })
    .width('100%')
    .backgroundColor(COLORS.card)
  }

tabMine 是个人中心页,分4层:食客渐变大卡、数据三宫格、功能清单、版本脚注。食客卡用 linearGradient 渐变背景(redL 到 card,角度135,0.72 位置过渡——比头部 Banner 的 0.65 更晚过渡,让红色占比稍多,突出"个人荣誉"感)。卡片上部是 🍜 大 emoji + “资深吃货·金筷子"称号 + 会员权益文案,下部用 Divider 分隔后展示"本月探店 9 家"和"累计获赞 328”(番茄红突出)。整个食客卡是用户身份与成就的视觉化呈现。

数据三宫格复用 statCell,展示"累计探店/发布食记/本月省下"三个累计指标——注意这里与美食 Tab 的三宫格指标不同(美食 Tab 是"今日"维度,我的 Tab 是"累计"维度),同一组件不同数据呈现不同语义。功能清单 ForEach 渲染 FUNC_LIST 8条,每条 Row 布局:emoji 图标+功能名(layoutWeight 占满)+状态/数值文本+›箭头。›箭头是常见的"可点击进入"视觉暗示,源自 iOS 设置页的设计语言。版本脚注"食遇记 v1.13.2 · Map Kit 6.1.1 双新特性演示"用9号浅驼字,低调告知版本信息。

tabBar 是底部导航栏,用 Row+ForEach 渲染 TAB_LIST 4个 Tab。每个 Tab 是 Column 布局:emoji 图标(18号)+中文标签(10号),label 的 fontColor 根据 currentTab === idx 判断——激活态用 tabOn 番茄红、未激活用 text3 浅驼。onClick 切换 currentTab,触发主内容区条件渲染切换页面。整个 tabBar 用 card 白底,padding 上下8,与主内容区视觉分隔。

8.8 弹窗系统:遮罩层与三个弹窗

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

  /** 收藏店铺弹窗:店名 + 品类 + 地址输入 */
  @Builder
  panelAdd(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('收藏新店铺').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        TextInput({ placeholder: '店铺名称', text: this.formName })
          .height(38)
          .fontSize(12)
          .fontColor(COLORS.title)
          .placeholderColor(COLORS.text3)
          .backgroundColor(COLORS.chip)
          .onChange((v: string) => { this.formName = v; })
        TextInput({ placeholder: '品类(如:本帮菜)', text: this.formCate })
          .height(38)
          .fontSize(12)
          .fontColor(COLORS.title)
          .placeholderColor(COLORS.text3)
          .backgroundColor(COLORS.chip)
          .onChange((v: string) => { this.formCate = 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.red)
            .onClick(() => { this.saveShop(); })
        }
        .width('100%')
      }
      .padding(16)
      .borderRadius(14)
      .backgroundColor(COLORS.card)
      .width('82%')
    }
    .width('100%')
    .height('100%')
  }

modalOverlay 是弹窗遮罩层 Builder,接收 onClose 回调。它是一个填满全屏的空 Column,backgroundColor 设为 mask 半透明色(rgba(51,36,28,0.52)),onClick 触发 onClose 关闭弹窗——这是"点击遮罩关闭"的标准交互,让用户无需找关闭按钮,点击空白处即可退出弹窗。遮罩层在视觉上 dim 主内容,让弹窗成为视觉焦点。

panelAdd 是收藏店铺弹窗,用 Stack 层叠遮罩层和弹窗内容。弹窗内容是 Column 布局:标题"收藏新店铺"+三个 TextInput(店名/品类/地址)+取消/收藏按钮行。三个 TextInput 都用 chip 米色底(与主背景区分),text 参数绑定 formName/formCate/formAddr 状态实现受控输入,onChange 同步输入值。按钮行用 Row+layoutWeight(1) 让两个按钮均分宽度——取消用 chip 米底 sub 棕字(次要操作),收藏用 red 番茄红底(主操作)。整个弹窗 width 82%居中,padding 16,borderRadius 14,card 白底——比主内容卡片更大的圆角强化"浮层"感。Stack 内先声明 modalOverlay(在下层),后声明内容 Column(在上层),利用 Stack 后声明在上的规则实现层叠。

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

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

panelEdit 是编辑备注弹窗,结构比 panelAdd 简单——只有标题、当前店名展示(只读 Text)、备注输入框、取消/保存按钮。当前店名用条件表达式 this.editIdx < this.shopList.length ? this.shopList[this.editIdx].name : ‘’ 做边界保护,避免 editIdx 越界时 shopList[this.editIdx] 报错。这种防御式写法在异步弹窗场景下尤为重要——用户可能打开弹窗后切换 Tab 导致 shopList 变化,editIdx 可能失效。editNote 的 TextInput 同样受控,保存按钮调用 updateShop 方法。

panelDel 是删除确认弹窗,是破坏性操作的二次确认机制。弹窗内容只有标题、确认文案、取消/删除按钮。确认文案用模板字符串拼接当前店名——“确定删除「南翔小笼馒头店」吗?”,让用户明确知道要删的是哪一家,避免误删。删除按钮用番茄红底(与收藏/保存按钮一致),但从 UX 角度,破坏性操作通常应该用更醒目的红色——这里复用 COLORS.red 已经足够。三个弹窗共享 modalOverlay 遮罩层和相同的视觉结构(width 82%/padding 16/borderRadius 14/card 白底),保证了弹窗系统的视觉一致性。

九、两大新特性落地对比

对比维度 reliability 相关性分数(搜索特性) 长按事件监听(事件特性)
所属模块 site 模块 map 模块 MapEventManager
核心 API site.searchByText(params) onMarkerLongClick / onPoiLongClick
新增字段/接口 Site.reliability: number([0,1]) onMarkerLongClick / offMarkerLongClick / onPoiLongClick / offPoiLongClick
回调参数类型 无回调,返回 Promise map.Marker / mapCommon.Poi
数据获取方式 s.reliability ?? 0(空值合并兜底) marker.getPosition() / poi.position
UI 呈现形态 Progress 线性分数条 + 等级标签 + 数值文本 事件日志流(emoji+类型+名称+坐标+时间)
颜色映射策略 ≥0.8 番茄红 / ≥0.5 暖橙 / 其余浅驼 Marker 番茄红 / POI 暖橙
用户交互价值 让搜索结果质量透明化,辅助筛选决策 提供确认意图更强的交互手势,触发收藏/导航
防御式编程 reliability 字段 ?? 0 兜底,老 SDK 兼容 mapEventManager 判空,addMarker 逐个 try-catch
异常处理 catch BusinessError,保留 Mock 演示数据 单个 Marker 失败不中断整批添加
状态管理 @State searchRecords + @Observed SearchRecord @State eventLogs + @Observed EventLog
刷新策略 整体替换 searchRecords 数组引用 unshift 新条目到数组头部
版本要求 HarmonyOS 6.1.1+(API 24+) HarmonyOS 6.1.1+(API 24+)
行业场景价值 美食搜索区分"本帮菜馆"与"本帮酱料专卖店" 长按地图店铺 Marker 触发收藏/食记撰写

十、总结与工程启示

本文以"食遇记·附近美食探店"应用为载体,完整拆解了 HarmonyOS 6.1.1 Map Kit 两大新特性在本地生活美食探店场景的端到端落地过程。从 site.searchByText 返回的 Site.reliability 相关性分数,到 MapEventManager 的 onMarkerLongClick / onPoiLongClick 长按事件监听,再到 ArkUI 声明式 UI 的颜色系统、状态管理、Builder 复用、弹窗系统,整个应用展示了一套"新技术落地"的完整工程范式。

reliability 字段的价值在于将"搜索结果质量"从开发者主观估算变为后端引擎量化计算。在美食探店场景中,用户搜索"本帮菜"时,Map Kit 后端会综合关键字匹配度、POI 权重、距离衰减、用户行为热度等多维因素,给出一个 [0,1] 的相关性分数——"南翔小笼馒头店"可能得到 0.96(高相关),"本帮酱料专卖店"可能只有 0.11(低相关,非餐饮)。开发者无需自行实现相关性算法,直接读取字段即可在 UI 层用分数条+等级标签呈现给用户,极大降低了"搜索结果排序与筛选"的开发成本。本应用用 reliabilityScore 函数将分数三档映射(高/中/低),用 Progress 组件渲染分数条,用颜色体系(番茄红/暖橙/浅驼)让分数高低一目了然,是这一新特性的标准 UI 落地模式。

长按事件监听的价值在于为地图交互增加了一种"确认意图"的手势维度。在探店场景中,用户单击地图标记可能是误触或随意浏览,但长按一定是明确指向某家店铺——可能是想收藏、想写食记、想发起导航。6.1.1 的 onMarkerLongClick 和 onPoiLongClick 让开发者能精确捕获这种意图,触发对应的业务流程。本应用将长按事件记录到事件日志流(unshift 置顶+固定高度可滚动),既演示了事件数据的结构(type/name/坐标/时间),又为真实业务(长按收藏、长按导航)提供了扩展起点。offMarkerLongClick / offPoiLongClick 的"全清"语义也通过 Toggle 开关得到了演示,让用户能动态控制是否接收长按事件。

从工程角度,本应用还有几处值得借鉴的实践。第一是颜色系统的接口化设计——ColorPalette 接口集中声明所有颜色字段,COLORS 常量提供具体值,全应用通过 COLORS.xxx 引用,保证主题一致性且易维护。第二是 @Observed + @State 的组合使用——ShopItem/SearchRecord/EventLog 三个数据类用 @Observed 修饰支持属性级刷新,shopList/searchRecords/eventLogs 三个数组用 @State 修饰支持引用级刷新,updateShop 中的 slice() 换引用是属性变化触发列表刷新的兜底手段。第三是防御式编程——reliability ?? 0 兜底老 SDK 兼容、mapEventManager 判空避免空指针、addMarker 逐个 try-catch 避免单点失败中断整批、editIdx 边界检查防止数组越界,这些细节让应用在面对异常输入和环境变化时保持稳健。第四是 Builder 复用——statCell 和 modalOverlay 两个带参 Builder 在多处复用,减少了代码冗余,也保证了视觉一致性。

最后,本应用的颜色语义体系值得品味。番茄红不只是一个"主色",它同时承载着"主操作按钮"“激活态 Tab”“高相关等级”“高分评分"四重语义;暖橙同时承载"次要操作”“中相关等级”“中档评分"三重语义;浅驼同时承载"弱文本”“低相关等级”"低分评分"三重语义。这种"颜色即信息"的设计让用户无需阅读文字,通过颜色就能快速判断操作优先级、结果质量、评分高低——在信息密集的本地生活应用中,这种视觉化语义体系能显著降低用户的认知负荷。HarmonyOS 6.1.1 的两大新特性配上这套颜色语义体系,让"食遇记"不仅是一个技术演示,更是一个有设计语言的完整产品原型。希望本文的逐行拆解能帮助开发者快速掌握这两大新特性,在自己的本地生活应用中落地出同样优秀的产品体验。

附录:DevEco Studio 创建新项目与查看 SDK 版本

本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。


一、创建新项目

1.1 进入欢迎界面

启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:

  • 新建项目:从头创建新项目
  • 打开项目:打开本地已有项目
  • 克隆仓库:从 Git 等版本控制拉取代码

点击 “新建项目” 按钮,进入项目创建向导。

在这里插入图片描述

1.2 选择项目模板

在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:

类型 说明
应用(Application) 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期
元服务(Atomic Service) 开发轻量级的原子化服务,无需安装即可使用

选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

在这里插入图片描述

1.3 配置项目信息

点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:

配置项 示例值 说明
项目名称(Project name) rollboat 应用的项目名称,建议使用英文命名
包名(Bundle name) com.rollboat.myapplication 应用唯一标识,采用反向域名格式
保存路径(Save location) D:\CodeFactory\rollboat 项目本地存储路径,避免使用中文和空格
兼容 SDK(Compatible SDK) 6.1.1(24) 目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异
模块名称(Module name) entry 主模块名称,默认 entry 为应用入口模块
设备类型(Device types) ☑ Phone 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV

右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

在这里插入图片描述

1.4 完成创建

确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:

  1. 生成项目骨架(Stage 模型目录结构)
  2. 执行 ohpm install 安装依赖
  3. 运行 Hvigor 构建初始化(Build Init

构建日志中显示 “退出代码为 0” 表示项目初始化成功。

在这里插入图片描述

1.5 项目结构概览

创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:

rollboat/
├── .hvigor/                   # Hvigor 构建工具缓存
├── .idea/                     # IDE 配置文件
├── AppScope/                  # 应用级全局配置
│   └── app.json5
├── entry/                     # 主模块(入口模块)
│   ├── src/main/ets/
│   │   ├── entryability/      # Ability 生命周期管理
│   │   │   └── EntryAbility.ets
│   │   └── pages/             # UI 页面
│   │       └── Index.ets      # 首页(默认 Hello World)
│   ├── src/main/resources/    # 资源文件
│   ├── module.json5           # 模块配置
│   └── build-profile.json5    # 构建配置
├── oh_modules/                # OHPM 依赖包
├── build-profile.json5        # 工程构建配置
├── hvigorfile.ts              # Hvigor 构建脚本
└── oh-package.json5           # 包管理配置

核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';

  build() {
    RelativeContainer() {
      Text(this.message)
        .id('HelloWorld')
        .fontSize($r('app.float.page_text_font_size'))
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onClick(() => {
          this.message = 'Welcome';
        })
    }
    .height('100%')
    .width('100%')
  }
}
关键语法 作用
@Entry 标记为页面入口,可用于路由跳转
@Component 声明为自定义组件
@State 状态变量,数据变更时自动触发 UI 刷新
RelativeContainer 相对布局容器,替代传统线性布局
.onClick() 点击事件,此处点击后文本变为 “Welcome”

打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:

文件 → 设置 → HarmonyOS SDK(或快捷键 Ctrl + Alt + S 搜索 “HarmonyOS SDK”)

在设置面板中,可以看到当前已安装的 SDK 版本信息:

名称 阶段 状态
HarmonyOS 6.1.1 Release ✅ 已安装

界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

在这里插入图片描述

2.2 查看 ArkUI-X SDK(跨平台扩展)

如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:

文件 → 设置 → 语言和框架 → ArkUI-X

在这里可以查看已安装和可选的 ArkUI-X SDK 版本:

版本 SDK 版本号 阶段 状态
API Version 24 6.1.1.100 Release ✅ 已安装
API Version 23 6.1.0.28 Beta1 未安装
API Version 22 6.0.2.112 Release 未安装

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

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

在这里插入图片描述


三、小结

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

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


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

Logo

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

更多推荐