
一、页面设计思路
地图页的核心使命是:用可视化方式展示 56 个民族的地理分布。地图是民族文化的重要载体——傣族在云南、蒙古族在内蒙古、维吾尔族在新疆,地理环境塑造了每个民族独特的文化。
然而,真实地图组件(如 Map Kit)在纯 ArkTS 学习场景下有一定门槛,且很多开发者是在模拟器上运行,没有地图 SDK。因此我们采用了**“双视图策略”**:
- 列表视图(默认):省份卡片 + 民族标签,数据驱动,兼容性好
- 地图视图(可选):真实地图组件,按需加载,渐进增强
这种设计既保证了功能的完整性,又为后续接入真实地图留下了扩展空间。
二、数据结构设计
2.1 核心接口
地图页的数据模型围绕"省份-民族"映射展开:
interface ProvinceEthnicItem {
province: string; // 省份名称
region: string; // 所属地区(西南/北方/东北等)
ethnics: EthnicGroup[]; // 该省份的民族列表
ethnicCount: number; // 民族数量(用于排序)
}
interface RegionOption {
key: string; // 地区标识
zhLabel: string; // 中文标签
enLabel: string; // 英文标签
}
为什么需要 ethnicCount 而不是直接用 ethnics.length?因为:
- 排序时频繁访问
.length有微小性能开销 - 模板中直接使用预计算字段更清晰
- 后续如果要做缓存,字段独立更方便
2.2 地区划分策略
我们将全国省份划分为 5 大区域,符合中国地理文化分区常识:
private provinceRegionMap: Record<string, string> = {
// 西南:云南、四川、贵州、重庆、西藏
'云南': 'southwest', '四川': 'southwest', '贵州': 'southwest',
'重庆': 'southwest', '西藏': 'southwest',
// 北方:内蒙古、甘肃、宁夏、青海、河北、河南、北京、山西、陕西、天津
'内蒙古': 'north', '甘肃': 'north', '宁夏': 'north',
'青海': 'north', '河北': 'north', '河南': 'north',
'北京': 'north', '山西': 'north', '陕西': 'north', '天津': 'north',
// 东北:辽宁、吉林、黑龙江
'辽宁': 'northeast', '吉林': 'northeast', '黑龙江': 'northeast',
// 东南:福建、浙江、广东、广西、湖南、湖北、江西、安徽、海南、台湾、上海、江苏
'福建': 'southeast', '浙江': 'southeast', '广东': 'southeast',
'广西': 'southeast', '湖南': 'southeast', '湖北': 'southeast',
'江西': 'southeast', '安徽': 'southeast', '海南': 'southeast',
'台湾': 'southeast', '上海': 'southeast', '江苏': 'southeast',
// 西北:新疆
'新疆': 'northwest'
};
💡 设计思考:地区划分看似简单,实则是用户体验的关键。5 个分区不多不少——太多用户记不住,太少筛选意义不大。
三、数据构建算法
3.1 倒排索引构建
地图页的核心数据处理是:从"民族 → 省份"的正向关系,构建"省份 → 民族列表"的倒排索引。
private buildProvinceMap(): void {
const map: Record<string, Set<EthnicGroup>> = {};
// 第一步:遍历所有民族,构建倒排索引
for (const ethnic of ETHNIC_GROUPS) {
for (const province of ethnic.provinces) {
if (!map[province]) {
map[province] = new Set<EthnicGroup>();
}
map[province].add(ethnic);
}
}
// 第二步:转换为数组并按民族数量排序
this.provinceEthnicMap = Object.entries(map)
.map((entry: [string, Set<EthnicGroup>]): ProvinceEthnicItem => {
return {
province: entry[0],
region: this.provinceRegionMap[entry[0]] || 'other',
ethnics: Array.from(entry[1]).sort(
(a, b) => a.populationRank - b.populationRank
),
ethnicCount: entry[1].size
};
})
.sort((a, b) => b.ethnicCount - a.ethnicCount);
// 第三步:同步更新统计数据
this.statsTotalProvinces = this.provinceEthnicMap.length;
let max = 0;
for (let i = 0; i < this.provinceEthnicMap.length; i++) {
if (this.provinceEthnicMap[i].ethnicCount > max) {
max = this.provinceEthnicMap[i].ethnicCount;
}
}
this.statsMaxEthnics = max;
}
3.2 算法解析
| 步骤 | 数据结构 | 时间复杂度 | 说明 |
|---|---|---|---|
| 倒排构建 | Record<string, Set> | O(N × M) | N=民族数,M=平均省份数 |
| 转换排序 | Array<ProvinceEthnicItem> | O(K log K) | K=省份数 |
| 统计计算 | 遍历数组 | O(K) | 找最大值 |
为什么用 Set 而不是 Array?
- 自动去重:虽然理论上不会重复,但 Set 天然保证唯一性
- 语义明确:集合就是"不重复的元素",代码更自文档化
- 添加性能:Set 的 add 操作是 O(1) 均摊
排序策略的选择:
- 省份按民族数量降序排列——云南省(26 个民族)排第一,符合用户预期
- 每个省份内的民族按人口排名排列——人口多的民族在前,信息层级合理
四、统计概览组件
页面顶部的"数据概览"是地图页的信息仪表盘:
@Builder
buildStatsOverview(): void {
Row({ space: 16 }) {
// 总省份数
Column({ space: 4 }) {
Text(String(this.statsTotalProvinces))
.fontSize($r('app.float.font_size_xl'))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.primary_color'))
Text($r('app.string.map_provinces_label'))
.fontSize($r('app.float.font_size_xs'))
.fontColor($r('app.color.text_hint'))
}
// 最多元省份(民族数量)
Column({ space: 4 }) {
Text(String(this.statsMaxEthnics))
.fontSize($r('app.float.font_size_xl'))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.accent_color') || '#E74C3C')
Text($r('app.string.map_max_groups'))
.fontSize($r('app.float.font_size_xs'))
.fontColor($r('app.color.text_hint'))
}
// 总民族数
Column({ space: 4 }) {
Text(String(ETHNIC_GROUPS.length))
.fontSize($r('app.float.font_size_xl'))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.legendary_color') || '#F39C12')
Text($r('app.string.map_groups_label'))
.fontSize($r('app.float.font_size_xs'))
.fontColor($r('app.color.text_hint'))
}
}
.width('100%')
.padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
.padding({ top: $r('app.float.spacing_md'), bottom: $r('app.float.spacing_sm') })
}
设计要点
- 三栏等分布局:用
Row({ space: 16 })+ 自然宽度,简洁明了 - 颜色语义化:
- 蓝色(primary):省份数——基础信息
- 红色(accent):最多少数民族数——强调多样性
- 金色(legendary):总民族数——标志性数据
- 数据层级:数字大、标签小,信息主次分明
五、搜索与筛选
5.1 地区筛选器
横向滚动的地区标签,和列表页的筛选器模式一致:
@Builder
buildRegionFilter(): void {
Scroll() {
Row({ space: $r('app.float.spacing_sm') }) {
ForEach(this.regionOptions as Array<RegionOption>, (option: RegionOption) => {
Text(this.getLocalizedText(option.zhLabel, option.enLabel))
.fontSize($r('app.float.font_size_xs'))
.fontColor(this.selectedRegion === option.key
? $r('app.color.text_on_primary')
: $r('app.color.text_secondary'))
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.selectedRegion === option.key
? $r('app.color.primary_color')
: $r('app.color.card_background'))
.onClick(() => {
this.selectedRegion = option.key;
})
})
}
}
.scrollBar(BarState.Off)
.width('100%')
.height(36)
.padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
.margin({ top: $r('app.float.spacing_sm') })
}
5.2 搜索过滤逻辑
搜索支持两种维度:省份名称 和 民族名称:
private getFilteredList(): Array<ProvinceEthnicItem> {
let result = this.provinceEthnicMap;
// 地区筛选
if (this.selectedRegion !== 'all') {
result = result.filter(item => item.region === this.selectedRegion);
}
// 搜索过滤(省份名 + 民族名/英文名)
if (this.searchText.trim().length > 0) {
const query = this.searchText.trim().toLowerCase();
result = result.filter(item =>
item.province.includes(query) ||
item.ethnics.some(e =>
e.name.toLowerCase().includes(query) ||
e.nameEn.toLowerCase().includes(query)
)
);
}
return result;
}
搜索逻辑的设计考量:
| 搜索维度 | 场景示例 | 实现方式 |
|---|---|---|
| 省份名 | 输入"云南" | item.province.includes(query) |
| 民族中文名 | 输入"傣族" | e.name.includes(query) |
| 民族英文名 | 输入"Dai" | e.nameEn.includes(query) |
注意使用 Array.some() 而非 Array.every()——只要有一个民族匹配,整个省份卡片就应该显示。这是搜索体验的细节:用户搜"傣族",应该看到云南省卡片(里面有傣族),而不是什么都看不到。
六、省份卡片设计
6.1 卡片结构
每个省份卡片是一个"标题 + 标签云"的组合:
@Builder
buildProvinceCard(item: ProvinceEthnicItem): void {
Column({ space: $r('app.float.spacing_sm') }) {
// 省份标题行
Row() {
Text('\uD83C\uDFD9\u{FE0F}') // 🛐 地图定位图标
.fontSize($r('app.float.icon_size_sm'))
Text(item.province)
.fontSize($r('app.float.font_size_md'))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
// 民族数量徽章
Text(String(item.ethnicCount) + this.getLocalizedText('个民族', ' groups'))
.fontSize($r('app.float.font_size_xs'))
.fontColor($r('app.color.primary_color'))
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(10)
.backgroundColor('#E8F0FE')
}
.width('100%')
// 民族标签网格
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(item.ethnics as Array<EthnicGroup>, (ethnic: EthnicGroup) => {
Row({ space: 4 }) {
// 首字圆形头像
Text(ethnic.name.charAt(0))
.fontSize($r('app.float.font_size_xs'))
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width(18)
.height(18)
.borderRadius(9)
.backgroundColor(ethnic.emblemColor)
.textAlign(TextAlign.Center)
Text(this.getLocalizedText(ethnic.name, ethnic.nameEn))
.fontSize($r('app.float.font_size_xs'))
.fontColor($r('app.color.text_secondary'))
}
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.margin({ top: 4, right: 6 })
.borderRadius(14)
.backgroundColor($r('app.color.background_color'))
.onClick(() => {
router.pushUrl({
url: 'pages/EthnicDetailPage',
params: { ethnicId: ethnic.id }
});
})
})
}
.width('100%')
}
.width('90%')
.padding($r('app.float.spacing_md'))
.backgroundColor($r('app.color.card_background'))
.borderRadius($r('app.float.radius_lg'))
.alignItems(HorizontalAlign.Start)
}
6.2 标签云的视觉设计
民族标签采用了"小头像 + 名称"的胶囊样式:
- 圆形头像:取民族名称首字,背景用民族主题色(
emblemColor) - 文字标签:灰色背景,次级文字颜色
- 整体造型:圆角 14vp 的胶囊形状
这种设计比纯文字标签更有辨识度——每个民族的主题色就是它的视觉标识,用户扫一眼标签云就能感受到"多彩"的氛围。
6.3 点击交互
每个民族标签都是可点击的,点击后跳转到对应民族的详情页。这是地图页 → 详情页的信息流转路径。
注意 router.pushUrl 的错误处理:
router.pushUrl({ ... }).catch((err: Error) => {
console.error('[MapPage] navigate to detail failed:', JSON.stringify(err));
});
即使是简单的跳转,也需要捕获异常。在低内存设备上,页面跳转可能失败,优雅降级是必备素养。
七、视图切换设计
7.1 双视图架构
@State useRealMap: boolean = false; // 真实地图/列表视图切换
// build 方法中的核心逻辑
if (this.useRealMap) {
// 真实地图视图
EthnicMapComponent({
onProvinceClick: (provinceName: string, ethnics: EthnicGroup[]) => {
hilog.info(0x0001, 'MapPage', `Map province: ${provinceName}`);
}
})
.layoutWeight(1)
.width('100%')
} else {
// 省份列表视图
Scroll() {
Column({ space: $r('app.float.spacing_md') }) {
ForEach(this.getFilteredList() as Array<ProvinceEthnicItem>, (item: ProvinceEthnicItem) => {
this.buildProvinceCard(item)
})
}
}
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
7.2 切换按钮
@Builder
buildViewToggle(): void {
Row({ space: 8 }) {
// 列表模式按钮
Text('\u{1F4CA}') // 📊 图表图标
.fontSize(16)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(!this.useRealMap ? '#E8F0FE' : '#F5F5F5')
.fontColor(!this.useRealMap ? '#1677FF' : '#888888')
.onClick(() => { this.useRealMap = false; })
// 地图模式按钮
Text('\u{1F5FA}') // 🗺️ 地图图标
.fontSize(16)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.useRealMap ? '#E8F0FE' : '#F5F5F5')
.fontColor(this.useRealMap ? '#1677FF' : '#888888')
.onClick(() => { this.useRealMap = true; })
Blank()
Text(this.useRealMap
? $r('app.string.map_view_mode')
: $r('app.string.list_view_mode'))
.fontSize(12)
.fontColor('#888888')
}
.width('100%')
.padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
.margin({ top: $r('app.float.spacing_sm') })
}
设计哲学
- 渐进增强:列表视图是基础功能,地图视图是增强体验
- 条件渲染:用
if/else而非visibility——地图组件不渲染就不占资源 - 状态隔离:
useRealMap只是 UI 状态,不影响数据层,切换零成本
🎯 架构启示:复杂功能永远做"降级设计"。先确保基础体验可用,再逐步增强。这样即使高级功能出问题,核心流程依然通畅。
八、地图SDK集成的完整流程
前面讲的是列表视图,现在我们来深入讲一讲真实地图视图的实现。
「民族图鉴」使用的是鸿蒙系统的地图能力(Map Kit)。接入地图 SDK 的完整流程如下:
8.1 地图SDK接入前的准备
在写代码之前,需要先做几件事:
| 步骤 | 说明 | 去哪弄 |
|---|---|---|
| 注册开发者账号 | 华为开发者账号 | 华为开发者联盟官网 |
| 创建应用 | 在AppGallery Connect创建应用 | AppGallery Connect |
| 开通地图服务 | 开通Map Kit服务 | AppGallery Connect → 项目设置 → 开通服务 |
| 配置签名 | 配置应用签名证书指纹 | 项目设置 → 常规 → SHA256指纹 |
| 集成SDK | 在项目中引入地图SDK依赖 | build.gradle / oh-package.json5 |
这些是接入任何第三方SDK的标准流程。具体步骤可以参考华为官方文档,这里就不展开了。
8.2 地图组件的基础使用
鸿蒙地图组件的基本用法很简单——直接在页面里放一个 Map 组件:
// 引入地图模块
import map from '@ohos.map';
@Entry
@Component
struct MapPage {
private mapController: map.MapController = new map.MapController();
build() {
Column() {
Map({
controller: this.mapController,
// 初始地图状态:北京天安门附近
initialCameraPosition: {
target: { latitude: 39.9087, longitude: 116.3975 },
zoom: 5 // 缩放级别:5可以看到全国
}
})
.width('100%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
}
}
就这么几行,你就能看到一张地图了。
关键概念:
- MapController:地图控制器,通过它来操作地图(移动、缩放、添加Marker等)
- CameraPosition:地图相机位置,决定了地图显示哪里、放大多少
- Zoom 缩放级别:数字越大,地图越详细。1=世界,5=国家,10=城市,15=街道
8.3 地图的生命周期
地图组件也有生命周期,重要的几个回调:
Map({
controller: this.mapController,
onMapReady: () => {
// 地图准备就绪,可以添加Marker、设置样式了
console.log('map is ready');
this.addEthnicMarkers(); // 添加民族分布标记
},
onMapClick: (point: map.MapPoint) => {
// 点击地图空白处
console.log('map clicked:', point.latitude, point.longitude);
},
onCameraMove: (position: map.CameraPosition) => {
// 地图视角移动中
},
onCameraMoveEnd: (position: map.CameraPosition) => {
// 地图视角移动结束
// 可以在这里做"视野内的Marker加载"优化
}
})
onMapReady 是最重要的——地图没准备好之前,很多操作都不能做。
九、地图的基本操作
地图的基本操作包括:缩放、平移、旋转、倾斜。用户可以用手势操作,我们也可以通过代码控制。
9.1 缩放操作
// 放大一级
this.mapController.zoomIn();
// 缩小一级
this.mapController.zoomOut();
// 缩放到指定级别
this.mapController.setZoom(10);
// 获取当前缩放级别
const currentZoom = this.mapController.getZoom();
缩放级别的参考值:
| 级别 | 能看到什么 | 场景 |
|---|---|---|
| 1-4 | 世界地图、大洲 | 全球视野 |
| 5-6 | 全国范围 | 「民族图鉴」默认视图 |
| 7-9 | 省份、城市群 | 省一级分布 |
| 10-12 | 城市 | 城市级分布 |
| 13-15 | 街道、建筑 | 精确位置 |
「民族图鉴」用 5-6 级比较合适——能看到中国全境,同时各省份的分布也看得清。
9.2 平移(移动视角)
// 移动到指定位置(带动画)
this.mapController.moveCamera({
target: { latitude: 25.0389, longitude: 102.7183 }, // 昆明
zoom: 7,
duration: 500 // 动画时长,毫秒
});
// 获取当前相机位置
const position = this.mapController.getCameraPosition();
console.log('center:', position.target.latitude, position.target.longitude);
9.3 旋转与倾斜
// 设置旋转角度(0-360度,0是正北朝上)
this.mapController.setBearing(45);
// 设置倾斜角度(0-45度,0是垂直俯视)
this.mapController.setTilt(30);
对于「民族图鉴」来说,旋转和倾斜用得不多——默认的2D俯视图就挺好。但如果要做3D建筑展示,倾斜就有用了。
9.4 常用的地图设置
Map({
controller: this.mapController,
// 是否显示缩放按钮
zoomControlsEnabled: true,
// 是否显示指南针
compassEnabled: true,
// 是否允许缩放手势
zoomGesturesEnabled: true,
// 是否允许拖动手势
scrollGesturesEnabled: true,
// 是否允许旋转手势
rotateGesturesEnabled: false, // 「民族图鉴」禁用旋转,保持正北方
// 是否允许倾斜手势
tiltGesturesEnabled: false, // 「民族图鉴」禁用倾斜,保持2D视图
// 最小缩放级别
minZoom: 4,
// 最大缩放级别
maxZoom: 10,
// 地图类型:普通地图、卫星地图、夜景地图
mapType: map.MapType.NORMAL
})
「民族图鉴」禁用旋转和倾斜是有原因的——用户看民族分布图,正北方朝上最直观,转来转去反而容易晕。
💡 什么时候开启旋转/倾斜?
- 导航类应用:必须开,跟着车头方向转
- 3D展示类:开倾斜,有立体感
- 地图浏览类:建议关,保持稳定的视觉参考
功能不是越多越好,适合你的场景才是最好的。
十、Marker标记点的自定义与批量添加
地图上光有地图不行,还得有标记点(Marker)——告诉用户"这个地方有什么"。
10.1 Marker的基本添加
// 定义民族分布点的数据
interface EthnicMarker {
ethnicId: string;
ethnicName: string;
latitude: number;
longitude: number;
emblemColor: string;
}
// 批量添加Marker
private addEthnicMarkers(markers: EthnicMarker[]): void {
markers.forEach(marker => {
const markerOptions = new map.MarkerOptions()
.position({ latitude: marker.latitude, longitude: marker.longitude })
.title(marker.ethnicName) // 标题(点击显示)
.snippet('点击查看详情') // 副标题
.draggable(false); // 是否可拖拽
// 自定义Marker图标
// markerOptions.icon(BitmapDescriptorFactory.fromResource($r('app.media.ic_marker')));
this.mapController.addMarker(markerOptions);
});
}
10.2 自定义Marker样式
默认的 Marker 是红色图钉,不太好看。我们可以自定义:
// 自定义Marker:用民族主题色的圆形标记
private createCustomMarker(marker: EthnicMarker): map.MarkerOptions {
// 方法1:用自定义图片
// const icon = map.BitmapDescriptorFactory.fromResource($r('app.media.custom_marker'));
// 方法2:用Canvas绘制自定义Marker(更灵活)
// 这里用文字描述设计思路:
// - 圆形背景:民族主题色
// - 中间文字:民族首字
// - 外圈白色描边
const markerOptions = new map.MarkerOptions()
.position({ latitude: marker.latitude, longitude: marker.longitude })
.title(marker.ethnicName)
.snippet(`${marker.ethnicName}主要分布区`)
.icon(this.createMarkerIcon(marker))
.anchor(0.5, 1.0); // 锚点:底部中心点
return markerOptions;
}
// 创建Marker图标(示意,实际用Canvas或图片资源)
private createMarkerIcon(marker: EthnicMarker): map.BitmapDescriptor {
// 实际项目中可以用Canvas绘制自定义图标
// 这里简化处理,用默认图标
return map.BitmapDescriptorFactory.defaultMarker();
}
Marker设计的要点:
- 辨识度高:一眼就能看出是Marker
- 配色协调:和地图底色有对比,又不刺眼
- 大小合适:不能太大挡地图,也不能太小看不到
- 状态清晰:选中/未选中要有区别
10.3 批量添加的性能优化
56个民族,每个民族可能分布在多个省份,加起来可能有几百个Marker。一次性全加上会不会卡?
优化策略:
- 按需加载:只加当前视野内的Marker
// 地图移动结束后,更新视野内的Marker
onCameraMoveEnd: (position: map.CameraPosition) => {
this.updateVisibleMarkers(position.target);
}
private updateVisibleMarkers(center: map.LatLng): void {
// 1. 计算当前视野范围(简化:中心点周围一定经纬度范围内)
const latRange = 10; // 纬度范围
const lngRange = 10; // 经度范围
// 2. 筛选出视野内的民族分布点
const visibleMarkers = this.allMarkers.filter(m =>
Math.abs(m.latitude - center.latitude) < latRange &&
Math.abs(m.longitude - center.longitude) < lngRange
);
// 3. 移除不在视野内的,添加新进入视野的
// ... 具体实现
}
-
Marker复用:Marker对象池,避免频繁创建销毁
-
聚合(Cluster):Marker太多的时候,聚合成一个"数字气泡"
缩放级别低 → 很多Marker聚在一起 → 显示聚合点(显示数量)
缩放级别高 → Marker散开 → 显示单个Marker
Marker聚合是地图性能优化的利器,后面会专门讲。
十一、信息窗口(InfoWindow)的自定义
用户点Marker,会弹出一个信息窗口(InfoWindow),显示这个Marker的详细信息。
11.1 默认InfoWindow
默认的InfoWindow很简单——标题 + 副标题,两行文字。够用,但不好看。
11.2 自定义InfoWindow
我们可以自定义InfoWindow的样式,让它更符合「民族图鉴」的风格:
// 自定义InfoWindow组件
@Component
struct CustomInfoWindow {
@Prop ethnicName: string = '';
@Prop ethnicId: string = '';
@Prop emblemColor: string = '';
@Prop population: string = '';
build() {
Column({ space: 8 }) {
// 顶部:民族名称
Row() {
Circle({ width: 12, height: 12 })
.fill(this.emblemColor)
Text(this.ethnicName)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ left: 6 })
Blank()
Text('详情 →')
.fontSize(12)
.fontColor('#1677FF')
}
.width('100%')
// 底部:人口信息
Text(`人口:约${this.population}万人`)
.fontSize(12)
.fontColor('#666666')
.width('100%')
}
.width(200)
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(8)
.shadow({ radius: 8, color: '#1A000000', offsetX: 0, offsetY: 2 })
}
}
然后设置Marker的自定义InfoWindow:
// 设置自定义信息窗口适配器
this.mapController.setInfoWindowAdapter({
getInfoWindow: (marker: map.Marker): any => {
// 根据Marker获取民族数据
const ethnicData = this.getEthnicDataByMarker(marker);
// 返回自定义的InfoWindow组件
return CustomInfoWindow({
ethnicName: ethnicData.name,
ethnicId: ethnicData.id,
emblemColor: ethnicData.emblemColor,
population: ethnicData.population
});
},
onInfoWindowClick: (marker: map.Marker) => {
// 点击InfoWindow,跳转到详情页
const ethnicData = this.getEthnicDataByMarker(marker);
router.pushUrl({
url: 'pages/EthnicDetailPage',
params: { ethnicId: ethnicData.id }
});
}
});
自定义InfoWindow的设计要点:
- 内容精炼:空间有限,只放最关键的信息
- 可点击:让用户知道"点一下能看更多"
- 样式统一:和App整体风格一致
- 有指向:底部有个小三角指向Marker(地图SDK一般会自动加)
十二、民族分布热力图/区域着色
Marker是"点"的展示,但民族分布其实是"面"的概念——傣族分布在云南全省,不是只在昆明有一个点。
更高级的可视化方式是区域着色(Choropleth Map)——每个省份根据民族数量,填充不同深浅的颜色。
12.1 区域着色的设计思路
| 民族数量 | 颜色深浅 | 说明 |
|---|---|---|
| 20+ 个 | 最深 | 民族多样性最高 |
| 10-19 个 | 较深 | 民族多样性较高 |
| 5-9 个 | 中等 | 民族多样性一般 |
| 1-4 个 | 较浅 | 民族多样性较低 |
颜色从浅到深渐变,用户扫一眼就知道"哪里民族多,哪里民族少"。
12.2 技术实现方式
区域着色的核心是绘制多边形填充:
// 绘制省份区域(示意)
private drawProvincePolygon(provinceName: string, color: string): void {
// 1. 获取省份的边界坐标(GeoJSON格式的数据)
const coordinates = this.getProvinceBoundary(provinceName);
// 2. 创建多边形选项
const polygonOptions = new map.PolygonOptions()
.addPoints(coordinates) // 边界坐标点数组
.fillColor(color) // 填充颜色(带透明度)
.strokeColor('#FFFFFF') // 边框颜色
.strokeWidth(2) // 边框宽度
.clickable(true); // 是否可点击
// 3. 添加到地图
this.mapController.addPolygon(polygonOptions);
}
// 根据民族数量计算颜色
private getProvinceColor(ethnicCount: number, maxCount: number): string {
const ratio = ethnicCount / maxCount;
// 从浅到深的渐变色
// 实际项目用颜色插值算法
if (ratio > 0.8) return '#E74C3C80'; // 深红,半透明
if (ratio > 0.5) return '#F39C1280'; // 橙色
if (ratio > 0.2) return '#F1C40F80'; // 黄色
return '#2ECC7180'; // 绿色
}
12.3 热力图 vs 区域着色
还有一种可视化方式叫热力图(Heatmap)——用颜色渐变表示密度高低。
| 方式 | 特点 | 适用场景 |
|---|---|---|
| 区域着色 | 按行政区域填色,边界清晰 | 省级、市级统计数据 |
| 热力图 | 连续的颜色渐变,没有明确边界 | 点密度、人口分布 |
「民族图鉴」用区域着色更合适,因为:
- 民族分布数据是按省份统计的
- 行政边界清晰,用户更容易理解
- 和"省份卡片"的列表视图对应得上
12.4 数据从哪来?
区域着色需要两个关键数据:
- 省份边界坐标(GeoJSON):网上可以找到公开的中国行政区划边界数据
- 各省民族数量:我们的倒排索引已经算好了
把这两个数据结合起来,就能画出漂亮的区域着色图了。
💡 可视化的"度"
地图可视化很容易"用力过猛"——颜色太多、Marker太密、动画太炫……
记住,地图是背景,数据才是主角。
好的可视化,是让用户一眼看到数据规律,而不是被花哨的效果晃晕。
「民族图鉴」的原则:克制、清晰、信息优先。
十三、地图与列表的联动
地图视图和列表视图,不是割裂的——它们是同一份数据的两种展示方式。用户在地图上点了某个省,列表也应该定位到那个省;用户在列表里点了某个省,地图也应该飞到那个省。
这就是地图与列表的联动。
13.1 列表 → 地图
用户点击列表中的某个省份,地图飞到那个省:
// 点击省份卡片,地图定位到该省
private onProvinceCardClick(province: ProvinceEthnicItem): void {
if (!this.useRealMap) {
return; // 列表视图不用管
}
// 获取该省的中心坐标
const center = this.getProvinceCenter(province.province);
if (!center) return;
// 地图移动过去
this.mapController.moveCamera({
target: center,
zoom: 7,
duration: 500
});
// 可选:切换到地图视图
this.useRealMap = true;
}
13.2 地图 → 列表
用户点击地图上的Marker,列表滚动到对应的省份:
// 点击Marker,列表滚动到对应省份
private onMarkerClick(marker: map.Marker): void {
// 从Marker获取省份信息
const provinceName = this.getProvinceByMarker(marker);
if (!provinceName) return;
// 找到这个省在列表中的索引
const list = this.getFilteredList();
const index = list.findIndex(item => item.province === provinceName);
if (index < 0) return;
// 列表滚动到指定位置(Scroll组件的scrollTo方法)
this.listScroll.scrollTo({ index: index, smooth: true });
// 可选:高亮这个省份卡片
this.highlightedProvince = provinceName;
}
13.3 筛选的联动
地区筛选、搜索过滤,地图和列表也要同步:
// 筛选条件变化时,同时更新地图和列表
private onFilterChange(): void {
const filtered = this.getFilteredList();
// 1. 更新列表(自动,因为用了@State)
// 2. 更新地图上的Marker:只显示筛选后的省份
this.updateMapMarkers(filtered);
}
private updateMapMarkers(filteredList: ProvinceEthnicItem[]): void {
// 1. 移除所有旧Marker
this.mapController.clear();
// 2. 添加筛选后的Marker
filteredList.forEach(province => {
this.addProvinceMarkers(province);
});
}
联动的核心思想: 数据是唯一真相来源(Single Source of Truth)。
- 数据变了 → 地图和列表一起变
- 用户在地图上操作 → 改数据 → 列表自动更新
- 用户在列表上操作 → 改数据 → 地图自动更新
这样就不会出现"地图显示A,列表显示B"的尴尬情况。
十四、地图的性能优化
地图是个"吃性能"的组件。Marker加多了、多边形画多了,都可能卡顿。我们来聊聊怎么优化。
14.1 Marker聚合(Marker Cluster)
Marker太多的时候,密密麻麻一堆,既不好看又卡。这时候需要聚合——离得近的Marker合成一个"簇",显示数量,放大了再散开。
缩小时: 🧩 (12) 🧩 (8) 🧩 (15) ← 3个聚合点,共35个Marker
放大后: 📍📍📍 📍📍 📍📍📍📍 ← 散开成单个Marker
聚合的实现思路:
- 按网格聚合:把地图分成一个个格子,每个格子里的Marker聚合成一个点
- 按距离聚合:两个Marker距离小于阈值就聚合
- 层级控制:缩放级别越高,聚合越少;越低,聚合越多
鸿蒙地图SDK一般自带聚合能力,或者有第三方库可以用。
14.2 层级控制(LOD)
不同缩放级别,显示不同详细程度的内容。这叫细节层次(Level of Detail,LOD)。
| 缩放级别 | 显示内容 |
|---|---|
| 4-5级(全国) | 只显示省份区域着色,不显示Marker |
| 6-7级(大区) | 显示省份的聚合Marker |
| 8-9级(省份) | 显示每个民族的分布Marker |
| 10级以上(城市) | 显示更详细的点位信息 |
为什么要做层级控制?
- 减少同时渲染的Marker数量 → 更流畅
- 避免低级别下Marker太密看不清 → 更清晰
- 逐级加载,符合用户探索习惯 → 更自然
14.3 其他优化技巧
-
图片优化
- Marker图标用合适尺寸,不要太大
- 复用Bitmap,避免重复创建
- 小图标可以用Canvas绘制,不用图片
-
减少重绘
- 批量添加Marker,不要一个个加(减少地图重绘次数)
- 用
addMarkers()批量添加,不用循环addMarker()
-
及时清理
- 页面销毁时销毁地图控制器
- 切换视图时移除不需要的图层
- 避免内存泄漏
-
按需加载
- 只加载当前视野内的数据
- 滑动停止后再加载(防抖)
- 不要在
onCameraMove里做重操作
💡 性能优化的二八定律
80% 的性能问题,来自 20% 的代码。
地图优化也是一样——Marker数量、渲染频率,这两个是大头。
先优化这两个,大部分性能问题就解决了。
不要一上来就抠细枝末节,先抓主要矛盾。
十五、导航栏设计
地图页的导航栏右侧有个小细节——动态显示当前过滤结果的数量:
@Builder
buildNavBar(): void {
Row() {
Text('<')
.fontSize($r('app.float.font_size_xxl'))
.fontColor($r('app.color.text_primary'))
.onClick(() => { router.back(); })
Text($r('app.string.map_title'))
.fontSize($r('app.float.font_size_lg'))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.textAlign(TextAlign.Center)
Text(String(this.getFilteredList().length))
.fontSize($r('app.float.font_size_sm'))
.fontColor($r('app.color.text_hint'))
.margin({ right: $r('app.float.spacing_sm') })
}
.width('100%')
.height(48)
.padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
.alignItems(VerticalAlign.Center)
}
用户搜索或筛选时,右侧的数字实时变化,反馈"有多少个省份符合条件"。这是个微交互,但能有效降低用户的"不确定感"——我输入的关键词到底有没有结果?
九、性能优化要点
9.1 数据预处理
buildProvinceMap() 只在 aboutToAppear 时执行一次,数据计算和 UI 渲染分离:
- ❌ 不好的做法:在 build 方法里实时计算映射
- ✅ 好的做法:aboutToAppear 预处理,build 只负责渲染
9.2 过滤结果复用
getFilteredList() 在 build 中会被调用多次(导航栏数字 + ForEach)。当前实现每次都重新过滤,如果数据量很大可以考虑缓存:
// 优化方向:使用计算属性缓存
@State filteredList: Array<ProvinceEthnicItem> = [];
// searchText 或 selectedRegion 变化时重新计算
private updateFilteredList(): void {
// ... 过滤逻辑
this.filteredList = result;
}
对于 30 多个省份的规模,实时过滤性能足够。但如果数据量增长到几百条,就需要做缓存优化了。
9.3 Scroll 的弹性效果
Scroll() { ... }
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
EdgeEffect.Spring 提供了 iOS 风格的弹性滚动效果,手感更好。ArkUI 还支持:
| 效果 | 说明 |
|---|---|
EdgeEffect.Spring | 弹性回弹,iOS 风格 |
EdgeEffect.Fade | 渐变阴影,Android 风格 |
EdgeEffect.None | 无边界效果 |
十、架构思考
10.1 数据驱动视图
地图页是"数据驱动视图"的典型案例:
- 原始数据:ETHNIC_GROUPS(民族 → 省份)
- 数据转换:buildProvinceMap()(省份 → 民族列表)
- 数据过滤:getFilteredList()(按地区/搜索词筛选)
- 数据渲染:ForEach + buildProvinceCard
每一步都是纯数据操作,UI 只是数据的可视化呈现。这种思维方式是声明式 UI 的核心。
10.2 可扩展性
当前的列表视图是"保底方案",但架构上预留了地图组件的接入位置:
EthnicMapComponent是独立组件,可单独开发和测试useRealMap状态控制两种视图的切换- 点击回调
onProvinceClick统一了交互接口
未来接入真实地图时,只需要完善 EthnicMapComponent,页面其他代码完全不用动。这就是面向接口编程的威力。
十一、小结
| 技术点 | 关键方法/组件 | 难度 |
|---|---|---|
| 倒排索引构建 | buildProvinceMap() + Set + Record | ⭐⭐⭐ |
| 多维度搜索过滤 | getFilteredList() + Array.some() | ⭐⭐ |
| 标签云布局 | Flex + FlexWrap.Wrap | ⭐⭐ |
| 双视图切换 | if/else 条件渲染 + @State | ⭐⭐ |
| 统计概览 | Row + Column 组合布局 | ⭐ |
| 数据预处理 | aboutToAppear 生命周期 | ⭐⭐ |
地图页虽然没有使用真正的地图组件,但它演示了数据可视化的本质:将结构化数据以用户易于理解的方式呈现出来。省份卡片 + 标签云就是一种"轻量级可视化",它用最简单的布局组件实现了地理分布的直观表达。
下一篇我们将进入测验页的开发,看看如何用状态机思想实现一个完整的答题流程。
205

被折叠的 条评论
为什么被折叠?



