鸿蒙原生地图新字段与长按事件在社区公益捐助驿站场景中的落地实践:用 Map Kit 6.1.1 reliability 评分与 Marker/POI 长按监听解决驿站精准检索与就近收藏难题
一、技术前言

HarmonyOS ArkUI 是华为面向全场景推出的声明式 UI 开发范式,核心语言为 ArkTS。它在 TypeScript 基础上扩展了状态管理装饰器(如 @Entry、@Component、@State、@Observed、@Builder),开发者只需描述界面"是什么样子",框架就会根据状态变化自动驱动组件刷新。相较于传统命令式写法,ArkUI 在多 Tab 切换、列表增删、弹窗显隐这类高频交互场景下,能显著降低状态同步的心智负担,让一屏多态的复杂业务页面也能用清晰的 Builder 函数群来组织。本篇所讲的应用正是用 ArkUI 单文件结构搭建出 4 个布局完全不同的 Tab 页面,充分体现了这套范式在业务拆分上的灵活性。
Map Kit 是 HarmonyOS 官方地图能力套件,对外暴露 MapComponent 地图组件、map.MapComponentController 控制器、map.MapEventManager 事件管理器以及 site 检索模块等核心 API。开发者可以在 ArkUI 页面中直接嵌入 MapComponent,通过初始化回调拿到控制器,再调用 addMarker 添加标注、调用 getEventManager 拿到事件管理器,进而注册各类地图交互监听。它把"地图渲染—标注管理—手势事件—POI 检索"串成一条完整链路,是构建位置类业务页面的基础设施。本篇应用围绕城市公益驿站的展示、检索与收藏,几乎全部围绕 Map Kit 的能力展开。

HarmonyOS 6.1.1 在 Map Kit 的 site 模块为 searchByText 返回的 Site 类型新增了 reliability 字段。这是一个取值范围为 [0, 1] 的相关性分数,1 表示完全相关,0 表示几乎无关。在此之前,开发者拿到 searchByText 的结果列表后,只能靠名称和地址文字经验性判断哪条结果更贴合关键字,既不精准也难以量化展示。reliability 字段补上了这一环:它由检索引擎基于关键字匹配度、地理距离、POI 热度等多维信号综合打分,调用方可以直接把它作为排序依据、过滤阈值,甚至在前端以分数条和等级标签的形式呈现给用户,让搜索结果"可解释、可比对、可筛选"。本篇应用的搜索 Tab 正是围绕这一字段构建了一套分数条 + 等级标签的展示方案。
同一版本中,MapEventManager 新增了 onMarkerLongClick / offMarkerLongClick 与 onPoiLongClick / offPoiLongClick 四个 API,分别用于监听地图上 Marker 标注的长按和地图原生 POI 地点的长按。长按作为一种"重手势",天然适合承载"收藏、举报、查看详情、加入行程"等较重操作,而此前 ArkUI 地图侧的交互主要停留在单击与拖拽层面。新增的长按监听让地图页具备了类似原生 App 的右键菜单能力:用户长按一个驿站 Marker 即可触发收藏,长按一处 POI 即可记录该地点坐标,事件流以置顶 unshift 的方式实时沉淀到日志列表中。本篇应用的地图 Tab 完整演示了这套监听的注册、开关切换与事件流渲染。
社区公益捐助驿站是连接城市居民闲置物资与困难群体需求的中间节点。一个典型的使用流程是:居民打开应用首页看到附近驿站、按"旧衣/图书/玩具"筛选合适点位、在地图上长按感兴趣的驿站做收藏、按距离和开放时间择优前往捐物,并累计志愿时长与公益积分。这一流程对地图能力的要求集中在两点——“按关键字精准检索驿站"和"在地图上长按点位快速收藏”,恰好对应 Map Kit 6.1.1 的 reliability 字段和长按事件两项新特性,因此本篇应用并不是把新特性当摆设,而是用它们去真实解决"搜索结果不可比""地图交互太轻"两个长期痛点。

本篇博文将完整拆解"益点·公益驿站地图"这个单文件 ArkUI 应用的实现细节,从颜色系统、常量数据、辅助函数、数据模型,到地图回调初始化、搜索调用、弹窗系统、4 个 Tab 的 Builder 函数逐段展开,并在最后给出能力对比表与落地总结,帮助读者把这套"检索评分 + 长按收藏"的模式迁移到自己的位置类业务中。


二、整体架构流程图
三、颜色系统与主题常量
3.1 颜色系统接口与浅色主题常量
interface ColorPalette {
bg: string; // 页面底色(暖阳白)
card: string; // 卡片底色(纯白)
chip: string; // 胶囊与输入框底色(米杏色)
title: string; // 主标题色(深咖)
sub: string; // 次级文本色(暖灰)
text3: string; // 弱文本色(浅暖灰)
accent: string; // 行业主色(公益绿)
accentD: string; // 主色深(深公益绿)
accentL: string; // 主色浅(渐变起点淡绿)
second: string; // 副色(暖橙)
danger: string; // 警示色(低相关/删除/急缺)
info: string; // 信息色(天蓝)
line: string; // 分割线色
tabOn: string; // 底部 Tab 选中色
mask: string; // 弹窗遮罩色
codeBg: string; // 代码预览卡底色(深林绿)
}
const COLORS: ColorPalette = {
bg: '#FBF7EF',
card: '#FFFFFF',
chip: '#F2ECDF',
title: '#3A3428',
sub: '#7C7260',
text3: '#A89D8A',
accent: '#3E9B5F',
accentD: '#2B7444',
accentL: '#DFEFE2',
second: '#F08A3C',
danger: '#D95848',
info: '#4E86C9',
line: '#EAE2D2',
tabOn: '#3E9B5F',
mask: 'rgba(60,50,35,0.42)',
codeBg: '#22301F'
};
这段代码先声明了一个 ColorPalette 接口,把页面用到的全部颜色字段集中收敛。接口的意义在于"契约":任何后续维护者想新增颜色,必须先在接口里补上字段声明,再在常量里赋值,避免散落硬编码。bg 是页面底色暖阳白 #FBF7EF,奠定整个应用温润不刺眼的基调;card 是纯白卡片底,与暖白底形成微弱对比,让卡片有"浮起来"的层次感;chip 是米杏色,专门给胶囊按钮、输入框底、筛选标签这种"次级交互元素"使用,避免它们与白卡抢视觉焦点。
主色 accent 是公益绿 #3E9B5F,对应"环保、可生长、可持续"的公益心智;accentD 是更深的公益绿,用于需要强调却不喧宾夺主的场景;accentL 是淡绿 #DFEFE2,专门作为渐变 Banner 的起点色,配合 linearGradient 营造从淡绿过渡到纯白的柔和顶部效果。副色 second 是暖橙 #F08A3C,与公益绿形成补色关系,用在志愿岗招募、贴士提醒这类需要"提一下神"的次级强调上。danger 是警示红 #D95848,既给低相关性分数打标,又给删除按钮、急缺志愿岗标签使用,让"需要警惕"的信息一色识别。
值得留意的是 mask 用了 rgba(60,50,35,0.42) 这种半透明深咖色而非纯黑,目的是让弹窗遮罩与暖阳白底色在色相上保持一致,遮罩盖下来时不会突兀地"变灰",而是像一层深色纱帘。codeBg 用了深林绿 #22301F,这是浅色主题里一个反差设计:代码预览卡用深底浅字,既符合开发者审美,又让"特性速览"这一信息块在浅色页面中具备强辨识度。整个色板共 15 个字段,覆盖底色、卡片、文本三级、主副警示三色、分割线、Tab、遮罩、代码底等全部场景,做到"页面任何一处上色都能从色板找到出处"。
3.2 Tab 元数据与筛选常量
interface TabMeta {
icon: string; // Tab 图标 emoji
label: string; // Tab 标签文案
}
const TAB_LIST: TabMeta[] = [
{ icon: '📦', label: '驿站' },
{ icon: '🗺', label: '地图' },
{ icon: '🔍', label: '搜索' },
{ icon: '👤', label: '我的' }
];
const CATE_TAGS: string[] = ['全部', '旧衣', '图书', '玩具', '小家电', '周末开放', '志愿岗位', '可兑绿植'];
const CITY_CENTER: mapCommon.LatLng = { latitude: 28.2282, longitude: 112.9388 };
TabMeta 接口定义了底部导航每项的图标与文案,4 项分别对应"驿站(业务主 Tab)/地图(Map Kit 交互页)/搜索(reliability 特性页)/我的(公益人中心)",单排排列。用 emoji 作为图标是为了在单文件演示中规避图片资源依赖,同时 emoji 在鸿蒙系统字体下渲染稳定、色彩自带语义。"驿站"用包裹图标暗示物资收纳,"地图"用地图图标直接表意,"搜索"用放大镜,"我的"用人形,整套图标语义清晰。
CATE_TAGS 是头部横滑筛选 chips 的文案列表,覆盖了公益捐助的真实品类:"旧衣、图书、玩具、小家电"是常见可捐物资;"周末开放"是时间维度筛选;"志愿岗位"是行为维度筛选;"可兑绿植"是积分兑换维度筛选。一个 chips 数组就同时承载了物资、时间、行为、激励四种筛选意图,体现出公益场景的多维度特性。CITY_CENTER 是地图初始化中心点和搜索 location 参数,定位长沙(纬度 28.2282、经度 112.9388),后续 6 个 Mock 驿站标注都围绕这个中心点 ±0.02 度散布,搜索半径设为 5000 米,形成"城市中心—周边驿站—长按收藏"的地理叙事。
3.3 驿站标注点与附近驿站 Mock 数据
interface SpotItem {
name: string; // 驿站名称
lat: number; // 纬度
lng: number; // 经度
tag: string; // 驿站类型标签
}
const MARKER_SPOTS: SpotItem[] = [
{ name: '益点·五一广场驿站', lat: 28.228, lng: 112.936, tag: '综合' },
{ name: '益点·芙蓉中路驿站', lat: 28.236, lng: 112.942, tag: '旧衣' },
{ name: '益点·湘江风光带驿站', lat: 28.221, lng: 112.93, tag: '图书' },
{ name: '益点·火车站东广场驿站', lat: 28.225, lng: 112.955, tag: '24h' },
{ name: '益点·韶山北路驿站', lat: 28.215, lng: 112.948, tag: '小家电' },
{ name: '益点·开福寺驿站', lat: 28.242, lng: 112.934, tag: '玩具' }
];
SpotItem 接口定义了地图标注点的四个字段:名称、纬度、经度、类型标签。6 条 Mock 数据围绕长沙城市中心点散布,纬度范围 28.215~28.242、经度范围 112.93~112.955,覆盖了五一广场、芙蓉中路、湘江风光带、火车站东广场、韶山北路、开福寺等长沙真实地标。这种"以真实地标命名 + 微抖动坐标"的 Mock 策略,让演示数据既贴近真实地理分布,又不会与线上 POI 完全重名,便于在地图缩放时观察标注分布。
类型标签覆盖了"综合、旧衣、图书、24h、小家电、玩具"六种驿站定位,体现出公益驿站的多元化——既有综合型驿站,也有专项物资驿站,还有 24 小时无人值守驿站。这套标注数据是地图 Tab Marker 群的唯一数据源,后续在 setupMapCallback 中会被循环 addMarker 添加到地图上,并且是 Marker 长按事件的实际触发对象,数据与交互是同源的。
四、数据模型与辅助函数
4.1 相关性分数与志愿岗颜色映射
interface ScoreLevel {
label: string; // 等级文案
color: string; // 等级颜色
}
function reliabilityScore(score: number): ScoreLevel {
if (score >= 0.8) {
return { label: '高相关', color: COLORS.accent };
}
if (score >= 0.5) {
return { label: '中相关', color: COLORS.second };
}
return { label: '低相关', color: COLORS.danger };
}
function volColor(vol: number): string {
if (vol >= 4) { return COLORS.accent; }
if (vol >= 1) { return COLORS.second; }
return COLORS.danger;
}
reliabilityScore 是把 Map Kit 6.1.1 新增的 reliability 分数映射成"等级标签 + 等级颜色"的纯函数。它的阈值设计很有讲究:≥0.8 判为高相关,用公益绿,意思是这条搜索结果与关键字高度贴合,可以放心前往;0.5~0.8 之间判为中相关,用暖橙,意思是部分匹配,需要结合名称地址二次判断;<0.5 判为低相关,用警示红,提示用户这条结果可能是"废品回收站"这类同名却非公益的地点,应慎重。三档阈值把 0~1 的连续分数离散化成可读性极强的标签,让用户一眼分辨结果可信度。
这个函数是纯函数,输入相同输出必然相同,不依赖任何外部状态,因此在 ForEach 渲染列表项时可以放心反复调用,不会引发副作用。在搜索 Tab 的每一项结果里,它会被调用两次:一次取 label 渲染等级标签,一次取 color 给分数条着色,保证"标签文字与进度条颜色"始终同步。这种"把分数语义集中收口到一个函数"的设计,后续要调整阈值(比如把高相关门槛从 0.8 调到 0.75)只需改一处,全页同步生效。
volColor 是志愿岗数量的颜色映射函数,逻辑与 reliabilityScore 同构但语义不同:≥4 岗用公益绿表示"急招",≥1 岗用暖橙表示"招募中",0 岗用警示红表示"已满"。这套映射在驿站 Tab 的附近驿站列表、全部驿站列表、收藏列表三处复用,让"志愿岗"这一信息维度在全页保持一致的色彩语义。两个函数共同体现了"业务语义→视觉颜色"的统一收口原则,是公益应用可读性的基础。
4.2 数据模型:驿站条目与搜索结果
@Observed export class CharityItem {
icon: string;
name: string;
goods: string;
open: string;
vol: number;
note: string;
constructor(icon: string, name: string, goods: string, open: string,
vol: number, note: string) {
this.icon = icon;
this.name = name;
this.goods = goods;
this.open = open;
this.vol = vol;
this.note = note;
}
}
const CHARITY_LIST: Array<CharityItem> = [
new CharityItem('📦', '五一广场驿站', '旧衣 · 图书', '08:00-21:00', 3, '顺路捐衣点'),
new CharityItem('🌱', '湘江风光带驿站', '旧衣 · 电子产品', '09:00-20:00', 1, '江边跑步经过'),
new CharityItem('📚', '开福寺图书驿站', '图书 · 文具', '10:00-18:00', 0, '童书最紧缺'),
new CharityItem('🧥', '火车站东广场驿站', '旧衣 · 棉被', '07:00-22:00', 5, '冬衣急缺'),
new CharityItem('💡', '韶山北路驿站', '小家电 · 灯具', '09:00-19:00', 2, '周末整理岗'),
new CharityItem('🌿', '芙蓉中路绿植站', '绿植 · 花盆', '10:00-17:00', 0, '可领养多肉'),
new CharityItem('🧸', '德雅路儿童站', '玩具 · 绘本', '周末开放', 4, '娃的旧玩具去处')
];
CharityItem 用 @Observed 装饰器声明为可观察类,这是 ArkUI 响应式的关键。当一个 @Observed 类的实例被存进 @State 数组、且实例的属性被修改时,框架能够感知到属性级变化并精准刷新对应列表项,而不必整列重渲。本应用中,用户在编辑备注弹窗里改了某个驿站的 note 字段,对应那一条卡片的备注文本会立即更新,靠的就是 @Observed 的属性级观测。同时为了兼容数组本身的增删(如收藏新驿站、删除驿站),updateCharity 中又用了 slice() 整体替换数组引用,双管齐下确保刷新。
7 条 Mock 数据覆盖了综合、旧衣、图书、24h、小家电、绿植、玩具七种驿站定位,备注字段"顺路捐衣点、江边跑步经过、童书最紧缺、冬衣急缺、周末整理岗、可领养多肉、娃的旧玩具去处"极具生活气息,让演示数据具备真实可读性。这些备注是用户自定义的"为什么收藏这个驿站"的私人标签,编辑弹窗会回填当前备注供修改,是收藏列表的核心交互点。
4.3 搜索结果数据载体与 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;
}
}
const SEARCH_RECORDS: Array<SearchRecord> = [
new SearchRecord('五一广场公益驿站', '长沙市芙蓉区黄兴中路 87 号', 520, 0.97, '刚刚'),
new SearchRecord('湘江风光带捐物点', '长沙市天心区湘江中路 108 号', 1210, 0.9, '刚刚'),
new SearchRecord('开福寺图书捐赠驿站', '长沙市开福区开福寺路 36 号', 1980, 0.72, '刚刚'),
new SearchRecord('火车站东广场爱心屋', '长沙市芙蓉区车站中路 409 号', 2460, 0.56, '刚刚'),
new SearchRecord('韶山北路社区回收角', '长沙市雨花区韶山北路 355 号', 3140, 0.38, '刚刚'),
new SearchRecord('旧货回收废品站(非公益)', '长沙市开福区营盘路 118 号', 4370, 0.15, '刚刚')
];
SearchRecord 是搜索结果的数据载体,每个字段都对应 site.Site 的一个返回属性:name 对应 site.name、address 对应 site.formatAddress、distance 对应 site.distance,最关键的 reliability 对应 6.1.1 新增的 site.reliability。这个字段是本篇应用的核心技术点,所以注释里用"★"标记,提醒读者它是新特性数据。Mock 数据的 6 条覆盖了高、中、低三档相关性:0.97 和 0.9 是高相关,对应"公益驿站、捐物点"这类与关键字"公益驿站"高度贴合的地点;0.72 和 0.56 是中相关,对应"图书捐赠驿站、爱心屋"这类部分匹配的地点;0.38 和 0.15 是低相关,对应"社区回收角、废品站"这类同名却非公益的地点。
特别值得注意的是最后一条"旧货回收废品站(非公益)“的 reliability 只有 0.15,名称里还明示了"非公益”。这条数据演示了 reliability 字段的真实价值——在搜索"公益驿站"时,废品回收站虽然名字里也带"回收",但因为不是公益属性,引擎会给出极低的相关性分数,前端据此把它标红警示,避免用户白跑一趟。这种"用分数筛选掉伪公益地点"的能力,在 6.1.1 之前是做不到的,因为旧版 searchByText 不返回相关性信号,开发者无法判断结果是否真的贴合公益意图。
4.4 长按事件日志数据载体
@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;
}
}
const EVENT_LOGS: Array<EventLog> = [
new EventLog('POI', '黄兴路步行街', 28.226, 112.938, '演示事件'),
new EventLog('Marker', '#0', 28.228, 112.936, '演示事件')
];
EventLog 是地图 Tab 长按事件日志流的数据载体,type 字段区分事件来自 Marker 长按还是 POI 长按,name 字段对于 Marker 是 #0 这样的 ID 标识、对于 POI 是地点名称,lat/lng 是事件发生坐标,time 是时间文案。这个类用 @Observed 装饰,配合 @State eventLogs 数组,能在新事件 unshift 进数组时自动刷新日志流列表。
两条 Mock 数据演示了日志流的初始形态:一条 POI 事件(黄兴路步行街,是长沙知名商圈 POI)和一条 Marker 事件(#0,对应第一个添加的驿站标注)。这套数据让地图 Tab 一打开就有日志可看,不至于空白。实际运行时,用户长按地图上的任意 Marker 或 POI,事件会以 unshift 方式置顶进入日志流,最新的永远在最上方,配合固定 120 高度的可滚动 Scroll,形成"实时事件流"的视觉体验。type 字段同时驱动日志项的图标(Marker 用 📍、POI 用 🏷)和文字颜色(Marker 用公益绿、POI 用暖橙),让两种事件一眼可分。
五、组件主体与地图初始化
5.1 状态变量与 Map Kit 状态声明
@Entry
@Component
struct Page1149 {
@State currentTab: number = 0;
@State cateIdx: number = 0;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = 0;
@State delIdx: number = 0;
@State charityList: Array<CharityItem> = CHARITY_LIST;
@State funcList: FuncItem[] = FUNC_LIST;
// --- Map Kit 状态(6.1.1 特性:搜索 reliability + 长按事件) ---
private mapOptions: mapCommon.MapOptions = {
position: { target: CITY_CENTER, zoom: 13 }
};
private mapCallback?: AsyncCallback<map.MapComponentController>;
private mapController?: map.MapComponentController;
private mapEventManager?: map.MapEventManager;
@State markerListenOn: boolean = true;
@State poiListenOn: boolean = true;
@State eventLogs: Array<EventLog> = EVENT_LOGS;
@State queryInput: string = '公益驿站';
@State searchState: string = '待搜索 · 演示数据';
@State searchRecords: Array<SearchRecord> = SEARCH_RECORDS;
@State formName: string = '';
@State formAddr: string = '';
@State editNote: string = '';
这是主组件 Page1149 的状态声明区。前 8 个 @State 是 UI 通用状态:currentTab 控制底部 Tab 切换、cateIdx 控制头部筛选 chips 选中、addModal/editModal/delModal 三个布尔控制三层弹窗显隐、editIdx/delIdx 记录当前正在编辑或删除的驿站索引、charityList 是收藏驿站列表数据、funcList 是我的页功能清单。这些状态全部用 @State 装饰,意味着任何一处修改都会驱动依赖它的组件重新渲染。
mapOptions 是地图初始化参数,私有属性(不带 @State),因为它只在 MapComponent 构造时传入一次,不需要响应式刷新。position.target 设为城市中心点 CITY_CENTER,zoom 设为 13,这是城市级缩放级别,既能看到 6 个驿站标注的分布,又不至于太远失去细节。mapCallback 是异步初始化回调,aboutToAppear 时才赋值;mapController 和 mapEventManager 是回调里拿到的控制器和管理器,都是可选类型(?),因为回调未执行时它们不存在。
markerListenOn 和 poiListenOn 是两个长按监听开关,默认 true(开),用 @State 装饰,因为它们要驱动地图 Tab 两个 Toggle 开关的视觉状态。eventLogs 是长按事件日志流,@State 装饰,unshift 新事件时刷新。queryInput 是搜索框输入值,searchState 是搜索状态文案(如"搜索中…"“返回 N 条结果”“搜索失败”),searchRecords 是搜索结果列表。formName/formAddr/editNote 是三个弹窗的输入值。整个状态区把"UI 状态 + Map Kit 状态 + 弹窗表单状态"分门别类,配合注释里的分隔线,可读性很强。
5.2 地图初始化回调与双长按监听注册
setupMapCallback() {
this.mapCallback = async (err: BusinessError, mapController: map.MapComponentController) => {
if (err) {
console.error(`Map init failed, code: ${err.code}, message: ${err.message}`);
return;
}
this.mapController = mapController;
this.mapEventManager = mapController.getEventManager();
// 批量添加公益驿站 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 方法把一个 async 回调赋给 this.mapCallback,这个回调会在 MapComponent 初始化完成后被框架调用,参数是 (err, mapController)。第一行就做错误兜底:if (err) 判断初始化是否失败,失败则打日志直接 return,绝不继续往下走——这是地图初始化的"安全闸",保证后续 getEventManager、addMarker、onMarkerLongClick 都只在 controller 就绪的前提下执行。
成功分支里,先把 mapController 存到 this 上,再调用 mapController.getEventManager() 拿到事件管理器。这一步是后续注册长按监听的前提——事件管理器必须从已就绪的控制器获取,不能凭空 new。拿到管理器后,进入 Marker 批量添加循环:遍历 MARKER_SPOTS 6 个驿站点,为每个点构造 MarkerOptions,position 设为该驿站的经纬度,clickable: true 让标注可点击,visible: true 让标注默认可见,anchorU: 0.5 和 anchorV: 1 把锚点设在图标底部中央(让标注像图钉一样"钉"在坐标点上),draggable: false 禁止拖拽,flat: false 让标注始终朝上不随地图旋转。
每个 addMarker 都是 Promise 返回,所以循环用 await 串行执行,并包了 try-catch。这种"逐个 await + try-catch"的策略比 Promise.all 慢一些,但能保证一个失败不影响其他添加,容错性更强。6 个标注全部添加完毕后,进入双长按监听注册:onMarkerLongClick 的回调参数是 map.Marker,通过 marker.getPosition() 拿坐标、marker.getId() 拿 ID,组装成 EventLog 并 unshift 进日志流;onPoiLongClick 的回调参数是 mapCommon.Poi,直接读 poi.name 和 poi.position,同样 unshift 进日志流。两个监听一注册,用户长按地图上的驿站标注或原生 POI,事件就会实时沉淀到日志列表,配合 @State eventLogs 的响应式刷新,日志流会立即新增一条置顶项。
5.3 监听开关切换方法
toggleMarkerListen() {
if (!this.mapEventManager) {
return;
}
if (this.markerListenOn) {
this.mapEventManager.offMarkerLongClick();
} else {
this.mapEventManager.onMarkerLongClick((marker: map.Marker) => {
const pos: mapCommon.LatLng = marker.getPosition();
this.eventLogs.unshift(new EventLog('Marker', `#${marker.getId()}`,
pos.latitude, pos.longitude, '刚刚'));
});
}
this.markerListenOn = !this.markerListenOn;
}
togglePoiListen() {
if (!this.mapEventManager) {
return;
}
if (this.poiListenOn) {
this.mapEventManager.offPoiLongClick();
} else {
this.mapEventManager.onPoiLongClick((poi: mapCommon.Poi) => {
this.eventLogs.unshift(new EventLog('POI', poi.name,
poi.position.latitude, poi.position.longitude, '刚刚'));
});
}
this.poiListenOn = !this.poiListenOn;
}
这两个方法分别控制 Marker 长按和 POI 长按的开关。toggleMarkerListen 的逻辑是:先做 mapEventManager 空值守卫(如果管理器还没拿到,直接 return,避免后续调空对象方法报错);然后判断 markerListenOn 当前状态,如果当前是开(true),就调用 offMarkerLongClick() 关闭监听;如果当前是关(false),就调用 onMarkerLongClick() 重新注册监听,回调代码与初始化时完全一致;最后翻转 markerListenOn 布尔值,驱动 Toggle 开关的视觉状态刷新。
offMarkerLongClick() 不传任何参数,意思是清除该类型的全部订阅。这是 Map Kit 6.1.1 事件 API 的统一设计——off 系列方法都是"一键清空",不支持按回调引用精确解绑。这种设计简化了 API 表面,但要求开发者如果注册了多个不同回调想精确移除某一个,需要自己在外部维护回调引用并配合开关状态管理。本应用场景下每个监听只有一个回调,所以"清空全部"足够用。
togglePoiListen 的结构与 toggleMarkerListen 完全同构,区别只在于调用 offPoiLongClick / onPoiLongClick 以及回调参数是 mapCommon.Poi。两个方法的存在让用户可以在地图 Tab 上独立控制 Marker 长按和 POI 长按的开关,比如只关心驿站标注的长按事件时关掉 POI 监听,避免长按地图空白处的 POI 也往日志流灌数据。这种"双开关独立控制"的设计在演示场景下很有用,也方便开发者调试时单独验证某一种监听是否生效。
5.4 searchByText 调用与 reliability 读取
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}) · 保留演示数据`;
}
}
这是搜索 Tab 的核心方法。先把 searchState 设为"搜索中…“给用户即时反馈,然后构造 SearchByTextParams:query 是搜索框输入的关键字(默认"公益驿站”),location 是城市中心点坐标,radius 是搜索半径 5000 米(5 公里,覆盖城市核心区),language 是中文。这四个参数构成了一个完整的"以城市中心为圆心、5 公里为半径、中文关键字"的圆形搜索请求。
try 块里调用 await site.searchByText(params),拿到 SearchByTextResult。result.sites ?? [] 是空数组兜底——如果接口返回的 sites 字段为 undefined,就用空数组接住,避免后续 for...of 报错。如果数组长度为 0,把状态文案设为"无结果 · 保留演示数据"并 return,演示数据不丢,用户依然能在列表里看到 Mock 结果,体验不断档。
非空分支进入循环,遍历每个 site.Site,构造 SearchRecord。这里的关键是 s.reliability ?? 0——reliability 是 6.1.1 新增的可选字段,旧版引擎或某些边缘场景下可能返回 undefined,用 ?? 空值合并操作符兜底为 0,保证后续 reliabilityScore(0) 能正常映射出"低相关"等级,不会因为字段缺失而崩。同样地,name、formatAddress、distance 也都做了 ?? 兜底,体现"对外部数据永不信任"的防御式编程。
构造完 records 数组后,整体赋值给 this.searchRecords,触发 @State 刷新,搜索列表重新渲染。状态文案设为"返回 N 条结果",让用户知道搜索的实际产出。catch 块兜住所有异常(如无 AGC 配置、无网络、配额超限),把错误转成 BusinessError,状态文案设为"搜索失败(code) · 保留演示数据",既暴露了错误码便于排查,又用"保留演示数据"安抚用户,不至于让搜索 Tab 变空白。这种"成功替换、失败保留 Mock"的策略,让演示链路在任何环境下都不中断,是工程化演示代码的典范。
5.5 弹窗业务方法与生命周期
openEditCharity(idx: number) {
this.editIdx = idx;
this.editNote = this.charityList[idx].note;
this.editModal = true;
}
saveCharity() {
const name = this.formName === '' ? '益点·新收藏驿站' : this.formName;
const addr = this.formAddr === '' ? '长沙市芙蓉区(地图选点)' : this.formAddr;
this.charityList.unshift(new CharityItem('⭐', name, '旧衣 · 图书', '09:00-20:00', 2, addr));
this.formName = '';
this.formAddr = '';
this.addModal = false;
}
updateCharity() {
if (this.editIdx >= 0 && this.editIdx < this.charityList.length) {
if (this.editNote !== '') {
this.charityList[this.editIdx].note = this.editNote;
}
this.charityList = this.charityList.slice();
}
this.editModal = false;
}
delCharity() {
if (this.delIdx >= 0 && this.delIdx < this.charityList.length) {
this.charityList.splice(this.delIdx, 1);
}
this.delModal = false;
}
aboutToAppear() {
this.setupMapCallback();
}
openEditCharity 是打开编辑备注弹窗的方法:先记录编辑索引 editIdx,再回填当前驿站的备注到 editNote(让弹窗的 TextInput 显示现有备注供修改),最后把 editModal 设为 true 弹出弹窗。这种"先填数据后开弹窗"的顺序,保证弹窗一打开就有正确内容,避免出现"弹窗先开、数据后到"的闪烁。
saveCharity 是收藏新驿站的方法:做了空名兜底,如果用户没填驿站名,用默认名"益点·新收藏驿站";如果没填地址,用默认地址"长沙市芙蓉区(地图选点)“。然后 unshift 一条新的 CharityItem 到列表头部,让最新收藏置顶显示。新增的驿站默认物资是"旧衣 · 图书”、开放时间"09:00-20:00"、志愿岗 2、备注就是地址。最后清空表单、关闭弹窗,完成一次完整的"输入—校验—插入—复位"流程。
updateCharity 是保存编辑备注的方法:先做索引边界校验(editIdx >= 0 && < length),再判断新备注非空才赋值,最后用 slice() 整体替换数组引用。slice() 这一步是关键——虽然 @Observed 类的属性修改能触发属性级刷新,但数组本身没变,有些场景下框架可能不会重新 diff,用 slice() 强制生成新数组引用,保证 @State 一定感知到变化。delCharity 用 splice 删除指定索引项,同样触发数组引用变化。aboutToAppear 是组件生命周期钩子,在组件即将出现时调用 setupMapCallback,把地图回调初始化好,等待 MapComponent 渲染时调用。
六、页面主构建与 Builder 函数群
6.1 build 主构建与三层弹窗
build() {
Stack() {
Column() {
this.headerMain()
Divider().strokeWidth(1).color(COLORS.line)
Scroll() {
Column() {
if (this.currentTab === 0) {
this.tabCharity()
} else if (this.currentTab === 1) {
this.tabMap()
} else if (this.currentTab === 2) {
this.tabSearch()
} else {
this.tabMine()
}
}
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
this.tabBar()
}
.width('100%')
.height('100%')
if (this.addModal) {
this.panelAdd(() => {
this.addModal = false;
})
}
if (this.editModal) {
this.panelEdit(() => {
this.editModal = false;
})
}
if (this.delModal) {
this.panelDel(() => {
this.delModal = false;
})
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
build 是 ArkUI 组件的入口方法,返回一个 Stack。Stack 是层叠布局容器,本应用用它来叠加"主内容层"和"弹窗层"。Stack 的第一个子组件是一个 Column,纵向排列 headerMain(头部)、Divider(分割线)、Scroll(内容滚动区)、tabBar(底部 Tab)。Scroll 里包了一个 Column,根据 currentTab 的值条件渲染 4 个 Tab 之一——这种 if/else if/else 的条件分支是 ArkUI 在 build 方法内的标准 Tab 切换写法,每次 currentTab 变化,框架会精确替换对应分支的组件树。
Scroll 设了 layoutWeight(1),让它占满除头部和底部 Tab 之外的剩余高度;scrollBar(BarState.Off) 隐藏滚动条,避免在浅色主题里出现一道灰色滚动条破坏视觉。Stack 的后三个子组件是三个条件弹窗:if (this.addModal) 才渲染收藏弹窗、if (this.editModal) 才渲染编辑弹窗、if (this.delModal) 才渲染删除弹窗。每个弹窗都接收一个 onClose 回调,用于点击遮罩或取消按钮时把对应布尔设回 false,关闭弹窗。
这种"条件渲染弹窗"的设计,让弹窗在不显示时根本不进入组件树,避免无意义的组件驻留。三个弹窗互斥显示(同一时刻最多一个开),因为它们的开关是独立的 @State 布尔,互不影响。整个 Stack 的背景色是 COLORS.bg 暖阳白,确保弹窗关闭时露出的是温暖底色而非系统默认色,主题一致性强。
6.2 头部渐变 Banner 与筛选 chips
@Builder
headerMain() {
Column({ space: 12 }) {
Column({ space: 10 }) {
Row({ space: 12 }) {
Column({ space: 2 }) {
Text('36 件').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('志愿时长 58 小时 · 一星公益人').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
}
Row({ space: 6 }) {
Text('📦').fontSize(12)
Text('本月旧衣 8 件 · 图书 12 本').fontSize(11).fontColor(COLORS.sub)
}
Row({ space: 6 }) {
Text('🌱').fontSize(12)
Text('公益积分 1240 · 可兑绿植 1 盆').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.accent)
Text('🗺 地图找驿站').fontSize(12).fontColor(COLORS.accent)
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.borderRadius(16).backgroundColor(COLORS.chip)
.onClick(() => { this.currentTab = 1; })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.padding(14)
.borderRadius(14)
.linearGradient({
angle: 135,
colors: [[COLORS.accentL, 0.0], [COLORS.card, 0.7]]
})
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.accent : 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,纵向分两部分:渐变 Banner 和筛选 chips 横滑条。Banner 是一个 Column,内层先是一个 Row,左列是大字号"36 件"和"累计捐赠"文案,右列是三行小信息——志愿时长、本月捐物、公益积分。这种"大数字 + 多行小信息"的排版,让最重要的累计数据一眼可见,次级信息错落有行。linearGradient 给 Banner 加了 135 度渐变,从淡绿 accentL 过渡到纯白 card,0.7 的位置点让淡绿只占左上一小片,整体偏白偏干净,符合浅色主题的克制审美。
Banner 第二行是两个快捷入口胶囊:"登记捐物"用主色绿底白字,是主行动按钮,点击意图是跳转捐物登记;"地图找驿站"用米杏底绿字,点击 this.currentTab = 1 切到地图 Tab。两个胶囊颜色对比明确——主行动用主色填充,次级行动用底色描边,符合"主次分明"的设计原则。justifyContent(FlexAlign.SpaceBetween) 让两个胶囊分居两端,视觉平衡。
筛选 chips 是一个横向 Scroll,内层 Row 用 ForEach 渲染 CATE_TAGS 8 个胶囊。选中态用主色绿底白字、未选中态用米杏底暖灰字,cateIdx 驱动选中样式。点击切换 cateIdx 即可改变选中态,由于 chips 是横滑的,8 个胶囊不必挤在一屏,超出部分横向滚动查看。整个 headerMain 用 @Builder 装饰,意味着它是一个可复用的构建函数,每次 currentTab 或 cateIdx 变化时框架会自动重新调用它刷新依赖部分。
6.3 驿站 Tab:附近驿站 + 爱心速览 + 收藏列表
@Builder
tabCharity() {
Column({ space: 10 }) {
Row() {
Text('附近爱心驿站').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Blank()
Text('收藏新驿站 +').fontSize(11).fontColor(COLORS.accent)
.onClick(() => { this.addModal = true; })
}
.width('100%')
ForEach(CHARITY_RECS, (rec: CharityRec) => {
Row({ space: 10 }) {
Text(rec.icon).fontSize(26)
Column({ space: 4 }) {
Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(`${rec.goods} · ${rec.open}`).fontSize(11).fontColor(COLORS.sub)
Row({ space: 6 }) {
Text(rec.dist).fontSize(10).fontColor(COLORS.text3)
Text(`招募志愿 ${rec.vol} 人`).fontSize(10).fontColor(volColor(rec.vol))
}
}
.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.accent)
.onClick(() => { this.currentTab = 1; })
}
}
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
.width('100%')
}, (rec: CharityRec) => rec.name)
// 爱心速览(横排三列小卡)
Row({ space: 8 }) {
ForEach(GOOD_STATS, (stat: GoodStat) => {
Column({ space: 4 }) {
Text(stat.icon).fontSize(16)
Text(stat.value).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.accent)
Text(stat.label).fontSize(10).fontColor(COLORS.sub)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(12)
.backgroundColor(COLORS.card)
}, (stat: GoodStat) => stat.label)
}
.width('100%')
Row() {
Text('全部驿站(地图 Marker 同源)').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
}
.width('100%')
ForEach(this.charityList, (charity: CharityItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text(charity.icon).fontSize(22)
Column({ space: 3 }) {
Text(charity.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(`可捐 ${charity.goods} · ${charity.open}`).fontSize(11).fontColor(COLORS.sub)
Text(`备注:${charity.note}`).fontSize(10).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 6 }) {
Text(`${charity.vol}`).fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(volColor(charity.vol))
Text('个志愿岗').fontSize(9).fontColor(COLORS.text3)
}
}
.width('100%')
Row({ space: 8 }) {
Text('编辑').fontSize(10).fontColor(COLORS.info)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(10).backgroundColor(COLORS.chip)
.onClick(() => { this.openEditCharity(idx); })
Text('删除').fontSize(10).fontColor(COLORS.danger)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(10).backgroundColor(COLORS.chip)
.onClick(() => { this.delIdx = idx; this.delModal = true; })
}
.justifyContent(FlexAlign.End)
.width('100%')
}
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
.width('100%')
}, (charity: CharityItem) => charity.name)
Column({ space: 6 }) {
Text('💡 旧衣捐赠贴士').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.second)
Text('八成新以上旧衣将定向捐往山区小学,请提前清洗晾干并打包;贴身衣物与破损衣物建议投入环保再生回收箱处理。')
.fontSize(10).fontColor(COLORS.sub).lineHeight(16)
}
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.chip)
.width('100%')
}
.width('100%')
}
tabCharity 是驿站 Tab,业务主 Tab,纵向分五部分:附近驿站标题行、附近驿站大卡列表、爱心速览三列小卡、全部驿站标题、全部驿站收藏列表、旧衣捐赠贴士卡。第一行标题行用 Row + Blank() 把"附近爱心驿站"标题和"收藏新驿站 +“入口分居两端,Blank() 是弹性占位组件,吃掉中间空间让两端对齐。点击”+"入口把 addModal 设为 true,弹出收藏新驿站弹窗。
附近驿站大卡用 ForEach(CHARITY_RECS) 渲染 3 条精选驿站。每条卡片是 Row 结构:左侧大 emoji 图标、中间驿站名+物资+开放时间+距离+志愿岗、右侧"去捐物"按钮。志愿岗数量用 volColor(rec.vol) 映射颜色,≥4 岗公益绿、≥1 岗暖橙、0 岗警示红,让"急招/招募中/已满"一眼可分。点击"去捐物"按钮切到地图 Tab,让用户在地图上找到该驿站并长按收藏。
爱心速览是横排三列小卡,ForEach(GOOD_STATS) 渲染"累计捐物、志愿时长、公益积分"三项指标。每列用 layoutWeight(1) 等分宽度,Column 纵排图标+数值+标签,数值用公益绿加粗,让数据醒目。全部驿站列表用 ForEach(this.charityList) 渲染 7 条收藏驿站,每条卡片显示驿站名、可捐物资、开放时间、备注、志愿岗数、编辑按钮、删除按钮。编辑按钮点击调用 openEditCharity(idx) 弹出编辑备注弹窗,删除按钮点击设置 delIdx 并打开 delModal 删除确认弹窗。列表末尾是旧衣捐赠贴士卡,用米杏底色和暖橙标题,内容是"八成新以上旧衣捐往山区小学"的实用提示,lineHeight(16) 增加行高让多行文字易读。
6.4 地图 Tab:MapComponent 与长按事件日志流
@Builder
tabMap() {
Column({ space: 10 }) {
Column({ space: 4 }) {
Text('🗺 Map Kit 6.1.1 · 长按事件监听').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('长按地图上的驿站 Marker 或 POI 地点,事件将记录到下方日志流')
.fontSize(10).fontColor(COLORS.sub)
}
.padding(10)
.borderRadius(10)
.backgroundColor(COLORS.chip)
.width('100%')
Row({ space: 12 }) {
Row({ space: 6 }) {
Toggle({ type: ToggleType.Switch, isOn: this.markerListenOn })
.selectedColor(COLORS.accent)
.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.accent)
.width(36)
.height(20)
.onChange(() => { this.togglePoiListen(); })
Text('POI长按').fontSize(11).fontColor(COLORS.sub)
}
}
.width('100%')
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.accent : COLORS.second)
Text(log.name).fontSize(11).fontColor(COLORS.title)
Text(log.time).fontSize(9).fontColor(COLORS.text3)
}
Text(`${log.lat.toFixed(4)}, ${log.lng.toFixed(4)}`)
.fontSize(9).fontColor(COLORS.text3).fontFamily('monospace')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.borderRadius(8)
.backgroundColor(COLORS.card)
.width('100%')
}, (log: EventLog) => `${log.type}-${log.name}-${log.time}`)
}
}
.scrollBar(BarState.Off)
.height(120)
.width('100%')
}
.padding(10)
.borderRadius(12)
.backgroundColor(COLORS.chip)
.width('100%')
}
.width('100%')
.height('100%')
}
tabMap 是地图 Tab,也是 6.1.1 长按事件特性的演示页。纵向分四部分:特性说明卡、监听开关行、MapComponent 本体、长按事件日志流。特性说明卡用米杏底,标题"Map Kit 6.1.1 · 长按事件监听"配一句说明"长按地图上的驿站 Marker 或 POI 地点,事件将记录到下方日志流",让用户一进 Tab 就知道这页在演示什么。
监听开关行是两个 Row,每个 Row 里有一个 Toggle(开关)和说明文字。第一个 Toggle 绑定 markerListenOn,onChange 调用 toggleMarkerListen();第二个 Toggle 绑定 poiListenOn,onChange 调用 togglePoiListen()。selectedColor(COLORS.accent) 让开关打开时滑块是公益绿,与主题统一。两个开关独立控制 Marker 长按和 POI 长按,用户可以按需关闭某一种监听。
MapComponent({ mapOptions: this.mapOptions, mapCallback: this.mapCallback }) 是地图本体,传入初始化参数和回调。layoutWeight(1) 让它占满中间剩余高度,borderRadius(12) 加圆角让它与卡片风格统一。地图渲染完成后,框架会调用 mapCallback,在回调里完成 controller 获取、事件管理器获取、Marker 批量添加、双长按监听注册。整个 MapComponent 既是地图展示组件,又是长按事件的触发源——用户长按地图上的驿站 Marker 触发 onMarkerLongClick,长按原生 POI 触发 onPoiLongClick。
日志流是一个固定 120 高度的可滚动 Scroll,内层 Column 用 ForEach(this.eventLogs) 渲染每条日志。每条日志是一个 Row:左侧 emoji 图标(Marker 用 📍、POI 用 🏵),右侧 Column 显示类型标签+名称+时间、坐标。类型标签颜色用 log.type === 'Marker' ? COLORS.accent : COLORS.second 区分,让两种事件一眼可分。坐标用 fontFamily('monospace') 等宽字体显示,经纬度保留 4 位小数,符合坐标展示的工程审美。日志流的 key 函数是 ${log.type}-${log.name}-${log.time},保证每条日志有唯一 key,unshift 新事件时框架能正确 diff。整个日志流配合"新事件置顶"的策略,让用户长按地图后立即看到日志新增一条,反馈即时。
6.5 搜索 Tab: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.accent)
.onClick(() => { this.runSearch(); })
}
.width('100%')
Text(this.searchState).fontSize(10).fontColor(COLORS.text3).width('100%')
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.chip)
}
.width('100%')
Text(rec.address).fontSize(11).fontColor(COLORS.sub).width('100%')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Progress({ value: rec.reliability * 100, total: 100, type: ProgressType.Linear })
.layoutWeight(1)
.height(6)
.color(reliabilityScore(rec.reliability).color)
Text(`reliability ${rec.reliability.toFixed(2)}`)
.fontSize(10)
.fontColor(COLORS.sub)
.fontFamily('monospace')
}
.width('100%')
Row({ space: 10 }) {
Text(`直线距离 ${(rec.distance / 1000).toFixed(2)}km`).fontSize(10).fontColor(COLORS.text3)
Text(rec.time).fontSize(10).fontColor(COLORS.text3)
}
.width('100%')
}
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
.width('100%')
}
}, (rec: SearchRecord) => `${rec.name}-${rec.reliability}`)
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.width('100%')
this.codePreviewCard()
}
.width('100%')
.height('100%')
}
tabSearch 是搜索 Tab,6.1.1 reliability 特性的演示页。纵向分五部分:特性说明卡、搜索框+按钮、状态文案、搜索结果列表、双特性代码预览卡。特性说明卡说明"Site 新增 reliability 字段([0,1],1 为完全相关)",让用户知道这页展示的是新字段能力。搜索框 TextInput 绑定 queryInput,onChange 实时更新输入值;搜索按钮点击调用 runSearch() 发起 site.searchByText 调用。
状态文案 Text(this.searchState) 显示搜索进度,如"搜索中…"“返回 N 条结果”“搜索失败(code) · 保留演示数据”,让用户随时知道搜索在干什么。搜索结果列表用 List + ForEach,每项用 ListItem 包裹(这是 ArkUI 的硬约束——List 的直接子组件必须是 ListItem,否则会报错)。每条结果卡片是 Column,纵向四行:第一行是地点名+等级标签,地点名用 layoutWeight(1) 占满左侧、maxLines(1) + textOverflow(Ellipsis) 防止长名换行破坏布局,等级标签调用 reliabilityScore(rec.reliability) 拿到标签文字和颜色,用米杏底色小胶囊形式显示。
第二行是格式化地址,同样 maxLines(1) 单行省略。第三行是 reliability 分数条——这是本 Tab 的视觉核心:Progress 组件 value 是 rec.reliability * 100(把 0~1 映射到 0~100),total 是 100,type 是 ProgressType.Linear 线性进度条,color 是 reliabilityScore(rec.reliability).color(高相关绿、中相关橙、低相关红),height(6) 让进度条纤细优雅。旁边配 Text 显示 reliability 0.97 这样的数值,用 fontFamily('monospace') 等宽字体,toFixed(2) 保留两位小数。分数条+数值+标签三者协同,让 reliability 这一抽象分数变得直观可比。
第四行是直线距离和记录时间,距离用 (rec.distance / 1000).toFixed(2)km 把米转成公里保留两位小数,让距离感更直觉。列表底部调用 this.codePreviewCard() 渲染双特性代码预览卡,把本页用到的 6.1.1 新调用以代码形式速览,强化技术点。整个搜索 Tab 把 reliability 字段从"看不见的引擎信号"变成"用户可读可比的视觉指标",是本特性落地的关键展示。
6.6 我的 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('累计志愿 58 小时 · 捐物 36 件').fontSize(11).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Divider().strokeWidth(1).color(COLORS.line)
Row() {
Text('本月捐物 20 件').fontSize(11).fontColor(COLORS.sub)
Blank()
Text('公益积分 1240').fontSize(11).fontColor(COLORS.accent)
}
.width('100%')
}
.padding(14)
.borderRadius(14)
.linearGradient({
angle: 135,
colors: [[COLORS.accentL, 0.0], [COLORS.card, 0.75]]
})
.width('100%')
Column({ space: 8 }) {
Text('🌱 一星公益人权益').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Row({ space: 8 }) {
Text('·').fontSize(12).fontColor(COLORS.second)
Text('志愿服务优先排班,志愿时长可兑换纪念徽章').fontSize(11).fontColor(COLORS.sub)
}
.width('100%')
Row({ space: 8 }) {
Text('·').fontSize(12).fontColor(COLORS.second)
Text('公益积分可在合作驿站兑换绿植与帆布袋').fontSize(11).fontColor(COLORS.sub)
}
.width('100%')
Row({ space: 8 }) {
Text('·').fontSize(12).fontColor(COLORS.second)
Text('年度捐赠明细电子感谢证书自动生成').fontSize(11).fontColor(COLORS.sub)
}
.width('100%')
}
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
.width('100%')
ForEach(this.funcList, (item: FuncItem) => {
Row({ space: 10 }) {
Text(item.icon).fontSize(18)
Text(item.label).fontSize(13).fontColor(COLORS.title).layoutWeight(1)
Text(item.value).fontSize(11).fontColor(COLORS.sub)
Text('›').fontSize(14).fontColor(COLORS.text3)
}
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
.width('100%')
}, (item: FuncItem) => item.label)
}
.width('100%')
}
tabMine 是我的 Tab,纵向分三部分:公益人渐变大卡、权益卡、功能清单。公益人卡用与头部 Banner 同款的 linearGradient(135 度,淡绿到纯白),视觉上呼应头部形成主题统一。卡片左上是 🌱 emoji 头像、右上是公益人等级"益点公益人 · 一星"和累计数据,中间一道分割线,下方是本月捐物和公益积分两端对齐。整张卡是一张"身份名片",让用户一进我的 Tab 就看到自己的公益画像。
权益卡是三条权益说明行,每行用暖橙 · 起头,文字内容是"志愿服务优先排班"“公益积分兑换绿植帆布袋”“年度捐赠电子感谢证书”。这三条权益对应一星公益人的实际福利,让用户知道"做公益能换到什么",激励持续参与。功能清单用 ForEach(this.funcList) 渲染 8 条功能项:捐物记录、志愿时长、公益积分、感谢证书、收藏驿站、志愿排班、常去驿站、偏好设置。每条是 Row 结构——左 emoji、中功能名、右状态值、最右 › 箭头。› 用浅暖灰,暗示"可点击进入详情",虽然本演示未实现点击跳转,但视觉上保留了入口预期。
6.7 代码预览卡与底部 Tab 栏
@Builder
codePreviewCard() {
Column({ space: 6 }) {
Text('⌨️ Map Kit 6.1.1 双新特性速览').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column({ space: 4 }) {
Text('const result = await site.searchByText(params)')
.fontSize(9).fontColor(COLORS.accentL).fontFamily('monospace')
Text('const score = site.reliability // 驿站相关性')
.fontSize(9).fontColor(COLORS.accentL).fontFamily('monospace')
Text('eventManager.onMarkerLongClick(cb) // 24+')
.fontSize(9).fontColor(COLORS.second).fontFamily('monospace')
Text('eventManager.onPoiLongClick(cb) // 24+')
.fontSize(9).fontColor(COLORS.second).fontFamily('monospace')
}
.padding(10)
.borderRadius(8)
.backgroundColor(COLORS.codeBg)
.width('100%')
}
.padding(10)
.borderRadius(10)
.backgroundColor(COLORS.chip)
.width('100%')
}
@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)
}
codePreviewCard 是双特性代码预览卡,用深林绿底 codeBg + monospace 字体,把 6.1.1 的四条新调用以代码片段形式速览:前两条(searchByText、reliability)用淡绿字色属于搜索特性,后两条(onMarkerLongClick、onPoiLongClick)用暖橙字色属于事件特性,// 24+ 注释暗示这是 API 24 以上版本支持。这种"在 UI 里直接贴代码"的设计,让技术读者一眼对照特性调用,也强化了本应用"演示新特性"的定位。
tabBar 是底部导航栏,Row + ForEach(TAB_LIST) 渲染 4 个 Tab。每个 Tab 是 Column——上 emoji 图标、下文字标签,layoutWeight(1) 等分宽度,onClick 切换 currentTab。选中态文字用 COLORS.tabOn(公益绿)、未选中用 COLORS.text3(浅暖灰),让当前 Tab 一眼可辨。整个 Tab 栏纯白底 card,与页面暖白底形成微对比,悬浮于内容之上。
6.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.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.accent)
.onClick(() => { this.saveCharity(); })
}
.width('100%')
}
.padding(16)
.borderRadius(14)
.backgroundColor(COLORS.card)
.width('82%')
}
.width('100%')
.height('100%')
}
modalOverlay 是弹窗遮罩层 Builder,接收一个 onClose 回调。它是一个填满全屏的 Column,背景色 COLORS.mask(半透明深咖),onClick 调用 onClose()——点击遮罩任意空白处即可关闭弹窗,符合移动端弹窗的标准交互习惯。这个遮罩 Builder 是三个弹窗共用的底层组件,体现了 Builder 的复用价值。
panelAdd 是收藏新驿站弹窗,Stack 层叠遮罩和弹窗内容。内容 Column 宽 82% 居中显示,纵向排列标题、驿站名输入框、地址输入框、取消/收藏按钮行。两个 TextInput 都绑定了 text 参数(this.formName 和 this.formAddr),让弹窗打开时输入框显示当前表单值;onChange 实时更新对应 @State。按钮行用 Row,取消按钮米杏底暖灰字、收藏按钮公益绿底白字,主次分明。点击收藏调用 saveCharity() 完成收藏并关闭弹窗,点击取消直接 onClose() 关闭。
panelEdit 和 panelDel 的结构与 panelAdd 同构,区别在于内容:编辑弹窗显示当前驿站名(只读)+ 新备注输入框,保存调用 updateCharity();删除弹窗显示"确定删除「驿站名」吗?" + 取消/删除按钮,删除用警示红底色,确认调用 delCharity()。三个弹窗都用 Stack 包裹遮罩和内容,遮罩 onClick 关闭、内容 onClick 不冒泡(Stack 默认子组件事件独立),保证点击弹窗内部不会误关闭。整套弹窗系统通过 modalOverlay + 三个 panelXxx Builder + 三个 @State 布尔 + 三个业务方法,构成了一个轻量但完整的模态交互层。
七、能力对比与总结
7.1 Map Kit 6.1.1 双新特性对比表
| 能力维度 | site.searchByText reliability 字段 | MapEventManager 长按事件 |
|---|---|---|
| 所属模块 | site 检索模块 | map.MapEventManager 事件管理器 |
| API 形态 | Site 类型新增 reliability: number 字段 | onMarkerLongClick / offMarkerLongClick / onPoiLongClick / offPoiLongClick 四个方法 |
| 数据类型 | 数值,取值 [0,1],1 为完全相关 | 回调函数,参数为 map.Marker 或 mapCommon.Poi |
| 触发时机 | searchByText 返回结果时随 Site 一并返回 | 用户长按地图 Marker 或 POI 时异步触发 |
| 解决痛点 | 搜索结果不可比、无法判断与关键字关联程度 | 地图交互仅单击拖拽、缺少"重手势"操作入口 |
| 本应用落地 | 搜索 Tab 分数条 + 高/中/低等级标签 | 地图 Tab 长按日志流 + 双 Toggle 独立开关 |
| 视觉呈现 | Progress 线性条 + monospace 数值 + 胶囊标签 | unshift 置顶日志列表 + 图标区分类型 |
| 兜底策略 | s.reliability ?? 0 空值合并为 0 |
if (!this.mapEventManager) return 空管理器守卫 |
| 解除方式 | 不涉及(字段随结果返回) | offMarkerLongClick() / offPoiLongClick() 不传参清空全部 |
| 业务价值 | 避免用户白跑去伪公益地点 | 让地图驿站可长按收藏、记录坐标 |
7.2 4 个 Tab 能力分工对比表
| Tab | 主能力 | 使用的 Map Kit API | 核心数据 | 主要交互 |
|---|---|---|---|---|
| 驿站 | 附近驿站 + 收藏列表 | 无直接调用(与地图 Marker 同源数据) | CharityItem 列表 + CharityRec 精选 | 收藏新驿站、编辑备注、删除 |
| 地图 | MapComponent + 长按事件 | MapComponent / addMarker / onMarkerLongClick / onPoiLongClick | SpotItem 标注 + EventLog 日志 | 长按 Marker/POI 记录事件、Toggle 切换监听 |
| 搜索 | searchByText + reliability | site.searchByText / Site.reliability | SearchRecord 结果列表 | 关键字输入、分数条渲染 |
| 我的 | 公益人画像 + 权益 + 功能清单 | 无 | FuncItem 功能列表 | 查看权益、进入功能项 |
7.3 落地总结
本篇应用以"益点·公益驿站地图"为业务载体,把 HarmonyOS 6.1.1 Map Kit 的两项新特性——site.searchByText 返回的 reliability 相关性字段、MapEventManager 的 Marker/POI 长按监听——完整落地到一个 4 Tab 的单文件 ArkUI 页面中。整体设计围绕"检索评分 + 长按收藏"两条主线展开:搜索 Tab 用 reliability 字段把搜索结果按高/中/低三档可视化,配合分数条和等级标签让用户一眼判断每条结果与"公益驿站"关键字的关联程度,避免被同名废品站误导;地图 Tab 用长按事件让用户在地图上长按驿站 Marker 或原生 POI 即可记录坐标,事件实时沉淀到日志流,为后续收藏、行程规划积累数据。
从工程实现看,本应用展示了几个值得借鉴的实践。一是地图初始化的"安全闸"设计:mapCallback 的第一行就做 if (err) return 错误兜底,保证 controller 未就绪时绝不执行后续 getEventManager / addMarker / onMarkerLongClick,从源头杜绝空引用。二是 reliability 字段的 ?? 0 兜底:新字段虽然是 6.1.1 引入,但调用方无法保证引擎一定返回有效值,用空值合并操作符兜底为 0,让 reliabilityScore 函数总能映射出"低相关"等级,字段缺失不崩。三是 @Observed + slice() 双管刷新:CharityItem 用 @Observed 装饰支持属性级刷新,updateCharity 又用 slice() 整体替换数组引用强制 @State 感知,兼容了属性级和引用级两种刷新路径。四是"成功替换、失败保留 Mock"的演示策略:runSearch 在 catch 块里用"保留演示数据"文案安抚用户,让搜索 Tab 在无 AGC 配置或无网络的环境下依然可看可演示,不断档。
从业务价值看,reliability 字段解决了公益场景下"同名却非公益"的长期痛点——搜索"公益驿站"时,废品回收站、旧货市场等同名地点会拿到极低分数,前端据此标红警示,避免用户白跑一趟。长按事件则补齐了地图侧"重手势"的空白——以往用户在地图上只能单击或拖拽,长按是更"重"的操作,天然适合承载收藏、举报、查看详情等较重业务动作,本应用用长按 Marker 记录驿站坐标、长按 POI 记录任意地点,把地图从"展示工具"升级为"交互入口"。从主题设计看,暖阳白 + 公益绿 + 暖橙的浅色主题与公益场景的"温暖、可持续、希望"心智高度契合,色板 15 个字段覆盖全场景,任何一处上色都有出处,体现了主题系统的工程化程度。
展望后续,这套"检索评分 + 长按收藏"的模式可继续扩展。reliability 字段可结合阈值过滤实现"自动筛掉低相关结果",或在结果排序中作为权重因子与距离联合排序,让"又近又相关"的结果优先展示。长按事件可结合弹窗系统,让长按 Marker 直接弹出收藏表单(地址自动回填 POI 名称和坐标),把"长按—收藏"两步缩为一步,提升交互密度。整个应用已为这些扩展预留了空间——panelAdd 弹窗的 formAddr 字段就支持回填地图选点,searchRecords 数组就支持按 reliability 排序,只需少量增改即可演进。这体现了"演示代码 + 工程骨架"的双重定位:既是新特性的可读示例,也是真实业务的可扩展起点。
附录:DevEco Studio 创建新项目与查看 SDK 版本
本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。
一、创建新项目
1.1 进入欢迎界面
启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:
- 新建项目:从头创建新项目
- 打开项目:打开本地已有项目
- 克隆仓库:从 Git 等版本控制拉取代码
点击 “新建项目” 按钮,进入项目创建向导。

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

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

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

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

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

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

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




所有评论(0)