基于HarmonyOS API 24实战解析气象监测应用在 WeatherApp 的 build 方法中,整体采用了 Stack 作为根容器,允许其子组件按照声明的顺序层层叠加
一、应用概述与设计理念
在当今万物互联的时代,气象信息服务已经成为人们日常生活中不可或缺的一部分。从早晨出门前的穿衣决策,到周末出游的路线规划,再到农业生产中的防灾减灾,精准、实时的气象数据都在发挥着至关重要的作用。本文将深入剖析一款基于 HarmonyOS ArkTS 开发的气象监测应用,该应用不仅提供了传统天气预报应用所具备的基础功能,更在数据可视化、多维度信息展示以及交互体验方面进行了深度优化,充分展现了 HarmonyOS 原生应用开发的强大能力。

这款气象监测应用在整体架构上采用了典型的标签页导航模式,将复杂的气象信息按照功能维度划分为五个核心模块:实况天气、七日预报、气象预警、监测站点以及个人中心。这种模块化的信息组织方式,既保证了信息展示的全面性,又避免了单一页面信息过载的问题。用户可以根据自己的关注焦点,快速在不同模块之间切换,获得针对性的气象服务。在视觉设计层面,应用选用了天空蓝(#0288D1)作为主色调,搭配预警红(#D32F2F)作为强调色,整体色调清新明快,同时能够在紧急预警场景下迅速抓住用户的注意力,体现了功能性应用在设计美学与实用价值之间的精妙平衡。
从技术架构的角度来看,这款应用完整展示了 ArkTS 声明式 UI 开发的核心范式。通过结构化的类型定义、模块化的 Builder 函数以及响应式的状态管理,代码呈现出高度的可读性和可维护性。开发者可以在不深入框架底层实现的前提下,通过阅读代码直观地理解每个 UI 组件的职责和数据流向。这种声明式的编程模型,与传统的命令式 UI 开发相比,大幅降低了复杂界面状态同步的心智负担,使得开发者能够将更多精力投入到业务逻辑和用户体验的打磨上。同时,应用大量使用了 ArkUI 框架提供的布局容器和装饰器,如 Column、Row、Stack、Scroll 以及 ForEach 等,构建出了层次清晰、适应性强的界面结构。
在实际应用场景中,这款气象监测应用可以服务于多种用户群体。对于普通市民而言,实况天气和七日预报模块能够满足日常出行和生活的基本需求;对于户外工作者和农业生产者,气象预警模块提供的实时灾害预警信息具有重要的参考价值;而对于城市管理者和环保部门的工作人员,监测站点模块展示的分布式环境数据则有助于他们掌握城市不同区域的环境质量状况。此外,"我的"模块中集成的预警发布功能,也使得这款应用不仅是一个信息消费端,更是一个具备一定信息生产和管理能力的平台级应用,体现了从单向信息传递到双向交互管理的演进思路。
总的来说,这款气象监测应用是 HarmonyOS ArkTS 开发范式的优秀实践案例。它涵盖了类型安全的数据建模、模块化的组件设计、响应式的状态驱动、丰富的动画与视觉效果等多个关键技术点。接下来,我们将从最基础的类型定义开始,逐层深入剖析这款应用的完整实现细节,揭示每一个代码片段背后的设计考量和技术原理。
二、类型定义与数据建模:构建强类型的气象数据结构
在任何一款高质量的应用程序中,数据模型的设计都是整个架构的基石。良好的数据建模不仅能够确保类型安全,减少运行时错误,还能显著提升代码的可读性和可维护性。这款气象监测应用在开头就定义了一组精心设计的 TypeScript 接口,为应用中的各种气象实体提供了清晰的结构约束。这种强类型的设计方式是 ArkTS 开发的重要特征,它使得开发者能够在编译阶段就发现潜在的数据类型错误,而不是等到应用运行时才暴露问题。
首先,应用定义了 WeatherMeta 接口,用于描述七日天气预报中每一天的数据结构。这个接口包含了日期(date)、星期(weekday)、最高温度(tempHigh)、最低温度(tempLow)、天气状况(weather)、天气图标(icon)、风向风力(wind)、湿度(humidity)以及空气质量指数(aqi)等十个属性。这种全面的字段覆盖,确保了每一条预报数据都能携带足够丰富的信息,支撑起后续复杂的 UI 展示需求。通过接口约束,开发者在构造预报数据时必须提供所有这些字段,避免了因数据缺失导致的界面渲染异常。
interface WeatherMeta {
date: string
weekday: string
tempHigh: number
tempLow: number
weather: string
icon: string
wind: string
humidity: number
aqi: number
}

HourlyMeta 接口则聚焦于逐小时预报场景。它包含小时(hour)、温度(temp)、图标(icon)和天气描述(weather)四个字段。相比于 WeatherMeta,HourlyMeta 的结构更加精简,这是因为在小时级别的展示中,用户最关心的核心信息是温度变化和天气状况,而湿度、风向等详细数据通常会在更高层级的视图中呈现。这种根据展示粒度来设计数据结构的理念,体现了开发者在信息架构方面的深思熟虑。
interface HourlyMeta {
hour: string
temp: number
icon: string
weather: string
}

AlertMeta 接口是专门为气象预警信息设计的。它不仅包含了预警的标题(title)、内容(content)、影响区域(area)和发布时间(time)等文本信息,还特别设计了 level(预警等级)和 color(展示颜色)两个字段。这种设计使得不同级别的预警(如蓝色、黄色、橙色、红色)可以在 UI 层面通过不同的颜色进行视觉区分,帮助用户快速识别预警的紧急程度。id 字段的存在则为每一条预警提供了唯一的标识符,便于后续的编辑、删除等操作。
interface AlertMeta {
id: number
level: string
title: string
content: string
area: string
time: string
color: string
}

StationMeta 接口描述的是环境监测站点的数据结构。它包含站点编号(id)、名称(name)、所属区域(district)、温度(temp)、湿度(humidity)、风向(wind)、空气质量指数(aqi)以及环境状况评级(status)等字段。这个接口的设计充分考虑了分布式监测场景的需求,每个站点都是一个独立的数据实体,可以单独展示其监测指标,也可以作为整体环境监测网络的一部分进行汇总分析。
interface StationMeta {
id: number
name: string
district: string
temp: number
humidity: number
wind: string
aqi: number
status: string
}

CityMeta 接口用于描述城市天气列表中的简短短数据。它仅包含城市名称(name)、温度(temp)、天气状况(weather)和图标(icon)四个字段。这种极简的设计非常适合在列表或卡片中进行横向对比展示,用户可以在一屏之内快速浏览多个城市的天气概况,而不会被冗余信息干扰。
interface CityMeta {
name: string
temp: number
weather: string
icon: string
}

最后,ColorPalette 接口定义了应用的全局色彩规范。从主色调(primary)、主色浅变体(primaryLight)、主色深变体(primaryDark),到强调色(accent)、背景色(bg)、卡片背景色(cardBg),再到不同层级的文本颜色(textPrimary、textSecondary、textHint)以及语义化颜色(success、warning、danger),这个接口几乎涵盖了应用 UI 中可能出现的所有颜色场景。通过接口定义色彩令牌,开发者可以确保整个应用的视觉风格高度统一,同时在需要调整主题时,只需修改一处常量配置,即可实现全局生效。
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
orange: string;
}

三、设计令牌与常量配置:统一视觉语言与交互规范
在完成了数据模型的定义之后,应用紧接着配置了一系列全局常量和设计令牌。这些常量不仅是简单的数值或字符串集合,更是整个应用视觉语言和交互规范的集中体现。通过将颜色、标签配置等抽取为独立的常量对象,开发者建立了一个可复用、可维护的设计系统,这是专业级应用开发区别于临时性脚本的重要标志。
COLORS 常量对象是应用色彩系统的核心实现。它完整实例化了 ColorPalette 接口中定义的所有颜色属性。主色调采用了天空蓝(#0288D1),这个颜色选择非常契合气象应用的主题,能够给用户带来清爽、专业的视觉感受。主色浅变体(#4FC3F7)和深变体(#01579B)则用于构建渐变色背景和层次化的色彩对比。预警红(#D32F2F)作为强调色,专门用于突出显示气象预警信息和危险操作按钮,在用户界面中形成了强烈的视觉锚点。背景色(#F0F7FC)是一种非常浅淡的蓝白色,既保持了整体的色调统一,又通过微妙的色差与纯白色的卡片背景(#FFFFFF)形成了自然的层级分隔。
const COLORS: ColorPalette = {
primary: '#0288D1',
primaryLight: '#4FC3F7',
primaryDark: '#01579B',
accent: '#D32F2F',
accentLight: '#FFCDD2',
bg: '#F0F7FC',
cardBg: '#FFFFFF',
textPrimary: '#013A63',
textSecondary: '#5C7B8A',
textHint: '#B0C4DE',
border: '#E1F0FA',
success: '#66BB6A',
warning: '#FFA726',
danger: '#EF5350',
white: '#FFFFFF',
orange: '#FF8F00'
};

文本颜色体系的设计同样值得称道。textPrimary(#013A63)是一种接近黑色的深蓝色,用于显示最重要的标题和核心信息;textSecondary(#5C7B8A)是一种灰蓝色,用于显示次要描述和辅助信息;textHint(#B0C4DE)则是一种非常浅淡的蓝灰色,专门用于占位符文字和极次要的提示信息。这种三层级文本颜色的设计,完美对应了信息架构中的主次关系,使得用户在面对大量文本时,能够自然地根据颜色深浅判断信息的重要性。边框颜色(#E1F0FA)的选择也非常考究,它足够浅淡,不会在视觉上产生强烈的分割线感,同时又足以界定不同组件的边界。
const TAB_CONFIG: Record<string, string> = {
'today': '实况',
'forecast': '预报',
'alert': '预警',
'station': '站点',
'mine': '我的'
}

TAB_CONFIG 常量则定义了底部导航栏的配置映射。它使用 Record<string, string> 类型,将每一个标签的标识符映射到对应的中文显示文本。这种配置化的设计使得标签栏的维护变得极为简单:如果需要修改某个标签的显示名称,或者调整标签的顺序,只需修改这一处常量定义即可,无需深入到 UI 构建逻辑中去查找和替换字符串。同时,这种键值对的结构也为后续可能的国际化扩展预留了空间,可以通过更换不同的语言映射表来实现多语言切换。
四、数据层构建:硬编码数据集与模拟业务场景
在真实的生产环境中,应用的数据通常来自于后端 API 接口。但在开发和演示阶段,使用结构化的硬编码数据是一种高效且可靠的方案。这款气象监测应用精心构造了五组硬编码数据集,完整模拟了一个真实气象应用所需的各种业务场景。这些数据不仅数量充足,而且内容合理,能够充分检验各个 UI 模块的展示效果和边界处理能力。
HOURLY_DATA 数组包含了从早上 8 点到晚上 7 点共 12 个小时的逐小时预报数据。数据展示了温度从 18°C 逐渐上升到 27°C 再回落到 19°C 的完整日变化曲线,天气状况也经历了从晴到多云、阴、小雨、阵雨,最后又回到多云和晴的复杂变化。这种丰富多样的数据组合,为后续温度柱状图的渲染提供了理想的测试素材。每条数据都包含天气 emoji 图标和中文描述,使得 UI 展示既直观又生动。
const HOURLY_DATA: HourlyMeta[] = [
{ hour: '08时', temp: 18, icon: '☀', weather: '晴' },
{ hour: '09时', temp: 20, icon: '☀', weather: '晴' },
{ hour: '10时', temp: 22, icon: '🌤', weather: '多云' },
{ hour: '11时', temp: 24, icon: '🌤', weather: '多云' },
{ hour: '12时', temp: 26, icon: '🌤', weather: '多云' },
{ hour: '13时', temp: 27, icon: '⛅', weather: '阴' },
{ hour: '14时', temp: 26, icon: '🌧', weather: '小雨' },
{ hour: '15时', temp: 24, icon: '🌧', weather: '小雨' },
{ hour: '16时', temp: 23, icon: '🌦', weather: '阵雨' },
{ hour: '17时', temp: 22, icon: '🌦', weather: '阵雨' },
{ hour: '18时', temp: 20, icon: '🌤', weather: '多云' },
{ hour: '19时', temp: 19, icon: '🌙', weather: '晴' }
]
FORECAST_DATA 数组提供了连续七天的天气预报信息,从今天开始一直延续到下周一。数据中包含了温度、天气、风向、湿度和空气质量等多维度指标,且各天的数据之间存在合理的逻辑关联。例如,今天多云转雨,明天小雨,后天阴天,随后天气转晴,气温回升,到下周一又出现雷阵雨。这种连贯的天气变化叙事,使得演示数据看起来真实可信,而不是一堆随机生成的数字。
const FORECAST_DATA: WeatherMeta[] = [
{ date: '04-16', weekday: '今天', tempHigh: 27, tempLow: 18, weather: '多云转雨', icon: '🌧', wind: '东南风3级', humidity: 72, aqi: 56 },
{ date: '04-17', weekday: '明天', tempHigh: 25, tempLow: 16, weather: '小雨', icon: '🌧', wind: '东风4级', humidity: 85, aqi: 42 },
{ date: '04-18', weekday: '后天', tempHigh: 23, tempLow: 14, weather: '阴', icon: '☁', wind: '北风3级', humidity: 68, aqi: 65 },
{ date: '04-19', weekday: '周五', tempHigh: 26, tempLow: 15, weather: '晴', icon: '☀', wind: '西北风2级', humidity: 55, aqi: 38 },
{ date: '04-20', weekday: '周六', tempHigh: 28, tempLow: 17, weather: '晴', icon: '☀', wind: '南风2级', humidity: 52, aqi: 35 },
{ date: '04-21', weekday: '周日', tempHigh: 29, tempLow: 19, weather: '多云', icon: '🌤', wind: '南风3级', humidity: 60, aqi: 48 },
{ date: '04-22', weekday: '周一', tempHigh: 26, tempLow: 18, weather: '雷阵雨', icon: '⛈', wind: '西南风3级', humidity: 78, aqi: 55 }
]
ALERT_LIST 数组模拟了四条不同等级、不同类型、不同影响区域的气象预警信息。其中包括暴雨黄色预警、大风蓝色预警、高温橙色预警和雷电黄色预警。每条预警都包含完整的元数据,尤其是 color 字段分别为每条预警指定了对应的颜色代码,使得 UI 层可以根据预警级别自动渲染不同的视觉样式。预警内容文本也足够详细,能够完整展示多行文本的排版效果。
const ALERT_LIST: AlertMeta[] = [
{ id: 1, level: '黄色', title: '暴雨黄色预警', content: '预计未来6小时内,本市部分地区降雨量将达50毫米以上,请注意防范', area: '全市', time: '2024-04-16 14:00', color: '#FFA726' },
{ id: 2, level: '蓝色', title: '大风蓝色预警', content: '预计未来24小时内,本市将出现5-6级偏北风,阵风可达7-8级', area: '北部山区', time: '2024-04-16 10:30', color: '#42A5F5' },
{ id: 3, level: '橙色', title: '高温橙色预警', content: '预计明日最高气温将达37℃以上,请做好防暑降温', area: '城区', time: '2024-04-15 16:00', color: '#FF7043' },
{ id: 4, level: '黄色', title: '雷电黄色预警', content: '预计今夜到明晨将有雷电活动,伴有短时强降水', area: '中南部', time: '2024-04-15 18:00', color: '#FFA726' }
]
STATION_LIST 数组定义了六个分布在不同城区的环境监测站点,包括城东、城西、城南、城北、中心和滨海监测站。每个站点都有独立的温度、湿度、风向和空气质量数据,且这些数据在站点之间存在合理的差异,体现了真实环境中不同区域气候条件的差异性。status 字段则直接给出了环境质量的评级(优或良),可以作为快速参考指标。
const STATION_LIST: StationMeta[] = [
{ id: 1, name: '城东监测站', district: '浦东新区', temp: 26, humidity: 72, wind: '东南风3级', aqi: 56, status: '良' },
{ id: 2, name: '城西监测站', district: '长宁区', temp: 25, humidity: 68, wind: '东风2级', aqi: 42, status: '优' },
{ id: 3, name: '城南监测站', district: '徐汇区', temp: 27, humidity: 75, wind: '南风3级', aqi: 65, status: '良' },
{ id: 4, name: '城北监测站', district: '杨浦区', temp: 24, humidity: 70, wind: '北风2级', aqi: 38, status: '优' },
{ id: 5, name: '中心监测站', district: '黄浦区', temp: 26, humidity: 73, wind: '东南风3级', aqi: 51, status: '良' },
{ id: 6, name: '滨海监测站', district: '浦东新区', temp: 23, humidity: 80, wind: '东风4级', aqi: 35, status: '优' }
]
CITY_LIST 数组则提供了六个中国主要城市的实时天气简况,包括上海、北京、广州、深圳、杭州和成都。这个数据集覆盖了中国的华东、华北、华南和西南地区,展示了不同地理区域的气候差异。每个城市的数据结构简洁明了,适合在横向滚动的城市列表中进行快速浏览。
const CITY_LIST: CityMeta[] = [
{ name: '上海', temp: 26, weather: '多云', icon: '🌤' },
{ name: '北京', temp: 22, weather: '晴', icon: '☀' },
{ name: '广州', temp: 30, weather: '阵雨', icon: '🌧' },
{ name: '深圳', temp: 29, weather: '多云', icon: '🌤' },
{ name: '杭州', temp: 25, weather: '阴', icon: '☁' },
{ name: '成都', temp: 21, weather: '小雨', icon: '🌧' }
]
五、工具函数层:数据转换与业务逻辑的封装
在数据集之上,应用封装了一组纯函数工具,用于处理数据转换和业务逻辑计算。这些函数虽然代码量不大,但充分体现了函数式编程的思想:给定相同的输入,总是返回相同的输出,且不产生副作用。这种设计使得这些工具函数极易进行单元测试,也便于在不同的 UI 组件之间复用。
getAqiColor 函数接收一个空气质量指数(AQI)数值,返回对应的展示颜色。当 AQI 小于等于 50 时,返回代表"优"的绿色(#66BB6A);当 AQI 在 51 到 100 之间时,返回代表"良"的橙色(#FFA726);当 AQI 超过 100 时,返回代表污染较重的红色(#EF5350)。这种颜色映射完全符合中国环境空气质量指数的技术规范,使得用户能够仅凭颜色就快速判断空气质量的优劣程度。
function getAqiColor(aqi: number): string {
if (aqi <= 50) {
return COLORS.success
}
if (aqi <= 100) {
return COLORS.warning
}
return COLORS.danger
}
getAqiLevel 函数则是 getAqiColor 的文本对应版本,它将数值型的 AQI 转换为人类可读的评级文字。当 AQI 小于等于 50 时返回"优",51 到 100 返回"良",101 到 150 返回"轻度",超过 150 返回"中度"。这两个函数配合使用,可以在 UI 中同时展示空气质量的颜色标识和文字说明,提供双重信息通道。
function getAqiLevel(aqi: number): string {
if (aqi <= 50) {
return '优'
}
if (aqi <= 100) {
return '良'
}
if (aqi <= 150) {
return '轻度'
}
return '中度'
}
getMaxTemp 和 getMinTemp 函数分别用于计算逐小时温度数据中的最高值和最低值。这两个函数都采用了经典的遍历算法:初始化一个极值变量,然后依次遍历数组中的每个元素,更新极值。虽然 ArkTS 标准库中已经提供了 Math.max 等内置函数,但手动实现遍历逻辑使得代码的自描述性更强,也更容易被初学者理解。这两个函数的返回值被用于实况天气页面的顶部汇总信息中,让用户一眼就能了解当天的温度变化区间。
function getMaxTemp(): number {
let maxVal: number = 0
for (const h of HOURLY_DATA) {
if (h.temp > maxVal) {
maxVal = h.temp
}
}
return maxVal
}
function getMinTemp(): number {
let minVal: number = 100
for (const h of HOURLY_DATA) {
if (h.temp < minVal) {
minVal = h.temp
}
}
return minVal
}
getAlertCount 函数简单地返回当前预警列表的长度。这个看似微不足道的封装函数,实际上在多处被调用:底部导航栏的预警标签角标、"我的"页面中的预警统计、预警页面的标题栏等。如果没有这个封装函数,每一处需要显示预警数量的地方都要直接访问 ALERT_LIST.length,一旦数据存储方式发生变化,就需要在多处进行修改。通过引入这个中间层,开发者将数据访问的具体实现与使用方解耦,提高了代码的灵活性和可维护性。
function getAlertCount(): number {
return ALERT_LIST.length
}
六、入口组件与状态管理:应用的核心骨架
WeatherApp 是整个应用的入口组件,它使用了 @Entry 和 @Component 两个装饰器进行标记。@Entry 装饰器表明这是一个页面级的组件,是应用导航栈中的一个独立页面;@Component 装饰器则表明这是一个可复用的 ArkTS 组件。这两个装饰器的组合使用,是 HarmonyOS 应用开发中定义页面入口的标准方式。
在组件内部,定义了五个 @State 装饰的状态变量,它们是驱动整个应用 UI 响应式更新的核心数据源。currentTab 用于记录当前选中的底部标签页,初始值为 'today',即默认展示实况天气页面。showAddDialog、showEditDialog 和 showDeleteDialog 三个布尔值状态分别控制三个不同弹窗的显示与隐藏。这种将每个弹窗的显隐状态独立管理的设计,避免了使用一个枚举类型来管理所有弹窗状态可能带来的复杂条件判断。targetAlert 则用于存储当前正在被编辑或删除的预警对象,它是连接预警列表与操作弹窗的数据桥梁。
@Entry
@Component
struct WeatherApp {
@State currentTab: string = 'today'
@State showAddDialog: boolean = false
@State showEditDialog: boolean = false
@State showDeleteDialog: boolean = false
@State targetAlert: AlertMeta = ALERT_LIST[0]
build 方法是每个 ArkTS 组件必须实现的核心方法,它描述了组件的 UI 结构。在 WeatherApp 的 build 方法中,整体采用了 Stack 作为根容器。Stack 是一种层叠布局容器,允许其子组件按照声明的顺序层层叠加。选择 Stack 作为根容器的原因在于,弹窗需要覆盖在整个页面内容之上,而 Stack 天然支持这种层叠覆盖的视觉效果。
在 Stack 内部,首先是占据整个屏幕的主体内容区域。这个区域使用了 Column 纵向布局,从上到下依次排列了顶部标题栏、可切换的内容区域和底部导航栏。顶部标题栏是一个 Row 横向布局,左侧显示应用名称"气象监测"及天气图标,右侧显示当前定位城市"上海"。标题栏设置了 52 的高度,并应用了从深蓝到天空蓝的线性渐变背景,营造出一种天空的视觉效果,与应用的气象主题高度契合。
build() {
Stack() {
Column() {
Row() {
Text('🌤 气象监测')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Column().layoutWeight(1)
Text('📍 上海')
.fontSize(13)
.fontColor('#FFFFFFCC')
}
.width('100%')
.height(52)
.padding({ left: 20, right: 20 })
.linearGradient({ angle: 135, colors: [[COLORS.primaryDark, 0.0], [COLORS.primary, 1.0]] })
中间的内容区域使用了 Column().layoutWeight(1) 的设置,这意味着它会占据标题栏和底部导航栏之间所有的剩余空间。这个 Column 内部通过条件渲染(if-else)来切换不同的标签页内容。当 currentTab 的值发生变化时,ArkTS 的响应式系统会自动重新计算这部分 UI,展示对应的内容 Builder。这种基于状态的条件渲染,是实现单页面多标签切换的经典模式。
Column() {
if (this.currentTab === 'today') {
this.todayContent()
} else if (this.currentTab === 'forecast') {
this.forecastContent()
} else if (this.currentTab === 'alert') {
this.alertContent()
} else if (this.currentTab === 'station') {
this.stationContent()
} else {
this.mineContent()
}
}
.layoutWeight(1)
.backgroundColor(COLORS.bg)
在 Stack 的顶层,同样通过条件渲染控制三个弹窗的显示。只有当对应的状态变量为 true 时,弹窗 Builder 才会被渲染。由于 Stack 的后进先出特性,后声明的弹窗组件会覆盖在先声明的主体内容之上,同时配合半透明黑色背景(rgba(0,0,0,0.5)),实现了典型的模态弹窗效果。
if (this.showAddDialog) {
this.addDialog()
}
if (this.showEditDialog) {
this.editDialog()
}
if (this.showDeleteDialog) {
this.deleteDialog()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
七、底部导航栏:标签切换的交互中枢
底部导航栏是这款应用中用户交互最频繁的组件之一,它承担了五个主要功能模块之间的导航职责。bottomTabBar 方法使用了 @Builder 装饰器,这意味着它是一个可复用的 UI 构建函数,可以在 build 方法中像普通组件一样通过 this.bottomTabBar() 的方式调用。
在布局结构上,bottomTabBar 使用了一个 Row 横向布局作为容器,其高度固定为 56,宽度占满屏幕。背景色设置为纯白色,顶部有一条 1 像素宽的边框,颜色为 COLORS.border,这种设计在视觉上将导航栏与上方的内容区域进行了柔和的分隔,同时避免了使用浓重阴影带来的视觉压迫感。
@Builder
bottomTabBar() {
Row() {
ForEach(Object.keys(TAB_CONFIG), (key: string) => {
Column() {
Text(this.getTabIcon(key))
.fontSize(20)
Text(TAB_CONFIG[key])
.fontSize(11)
.margin({ top: 2 })
.fontColor(this.currentTab === key ? COLORS.primary : COLORS.textHint)
}
.layoutWeight(1)
.height(56)
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.currentTab = key
})
})
}
.width('100%')
.height(56)
.backgroundColor(COLORS.white)
.border({ width: { top: 1 }, color: COLORS.border })
}
导航栏中的每个标签项通过 ForEach 循环动态生成。ForEach 接收两个参数:第一个是数据源数组,这里是 TAB_CONFIG 对象的所有键;第二个是回调函数,用于定义每个数据项对应的 UI 结构。在回调函数中,每个标签项都是一个 Column 纵向布局,内部包含上下排列的图标 Text 和标签名 Text。图标通过调用 getTabIcon 方法动态获取,而标签名则直接从 TAB_CONFIG 映射表中读取。
标签文字的颜色通过条件表达式 this.currentTab === key ? COLORS.primary : COLORS.textHint 进行动态设置。当某个标签被选中时,其文字颜色变为主色调天空蓝;未选中时则显示为浅淡的提示色。这种视觉反馈机制让用户能够清晰地感知自己当前所处的功能模块。每个标签项还绑定了 onClick 点击事件,当用户点击时,将 currentTab 状态更新为对应的键值,触发响应式的 UI 重绘。
getTabIcon 方法为每个标签定义了选中态和未选中态两套图标。例如,"实况"标签在未选中时显示太阳图标 '🌤',选中时显示温度计图标 '🌡';"预报"标签在未选中时显示日历图标 '📆',选中时显示另一款日历图标 '📅'。这种图标的变化不仅提供了视觉反馈,还通过不同的语义图标强化了标签的功能属性,提升了整体的交互品质。
getTabIcon(key: string): string {
if (key === 'today') {
return this.currentTab === key ? '🌡' : '🌤'
}
if (key === 'forecast') {
return this.currentTab === key ? '📅' : '📆'
}
if (key === 'alert') {
return this.currentTab === key ? '⚠' : '🔔'
}
if (key === 'station') {
return this.currentTab === key ? '📡' : '📶'
}
return this.currentTab === key ? '👤' : '🙆'
}
八、实况天气模块:信息密度与视觉层次的完美平衡
实况天气模块(todayContent)是用户打开应用后首先看到的页面,它承担着在最短时间内传递最关键气象信息的重任。这个模块的设计充分体现了信息架构中的"渐进式披露"原则:最核心、最概括的信息以最大的视觉体量展示在页面的最上方,而次要信息则以较小的卡片依次排列在下方。
整个 todayContent 被包裹在一个 Scroll 滚动容器中,这是因为该模块包含的内容较多,一屏可能无法完全展示。Scroll 的 scrollBar 属性被设置为 BarState.Off,即隐藏滚动条,这种设计在移动端非常常见,能够提供更清爽的界面视觉效果,同时不影响用户的滑动操作。
@Builder
todayContent() {
Scroll() {
Column() {
页面最上方是实时天气大卡片,这是整个应用中视觉冲击力最强的元素。卡片宽度占屏幕的 92%,通过 borderRadius(20) 设置了 20 像素的圆角,配合 shadow 阴影效果,在浅蓝色的页面背景上形成了明显的悬浮感。卡片的背景采用了从深蓝到浅蓝的 135 度线性渐变,这种渐变方向营造出光线从左上角照射过来的自然感。卡片内部的所有文本颜色都采用了半透明白色(#FFFFFFCC)或纯白色,在蓝色渐变背景上保证了极佳的可读性。
卡片中央最显眼的位置是当前的实时温度"26°C",其中温度数字使用了 72 的超大字号和粗体,单位"°C"则稍小一些(24 号字),且底部有 20 像素的向下外边距,使得数字在视觉上更加居中突出。温度下方依次排列着天气状况描述"多云转小雨"、湿度风向 AQI 汇总信息"湿度 72% · 东南风3级 · AQI 56",以及体感温度、紫外线强度和能见度三个辅助指标。这些信息的排布遵循了从核心到外围、从概括到详细的自然阅读顺序。
Column() {
Text('上海 · 浦东新区')
.fontSize(14)
.fontColor('#FFFFFFCC')
Row() {
Text('26')
.fontSize(72)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('°C')
.fontSize(24)
.fontColor('#FFFFFFCC')
.margin({ bottom: 20 })
}
Text('多云转小雨')
.fontSize(18)
.fontColor(COLORS.white)
.margin({ top: 4 })
Text('湿度 72% · 东南风3级 · AQI 56')
.fontSize(12)
.fontColor('#FFFFFFCC')
.margin({ top: 8 })
Row() {
Column() {
Text('体感 25°')
.fontSize(12)
.fontColor('#FFFFFFCC')
}
.margin({ right: 20 })
Column() {
Text('紫外线 弱')
.fontSize(12)
.fontColor('#FFFFFFCC')
}
.margin({ right: 20 })
Column() {
Text('能见度 10km')
.fontSize(12)
.fontColor('#FFFFFFCC')
}
}
.margin({ top: 12 })
}
.width('92%')
.padding({ top: 28, bottom: 28 })
.borderRadius(20)
.linearGradient({ angle: 135, colors: [[COLORS.primaryDark, 0.0], [COLORS.primaryLight, 1.0]] })
.alignItems(HorizontalAlign.Center)
.margin({ top: 12, bottom: 16 })
.shadow({ radius: 12, color: '#1A0288D1', offsetY: 6 })
在大卡片下方是"24小时预报"模块。这个模块同样采用了白色圆角卡片作为容器,顶部有一个 Row 横向布局的标题栏,左侧是标题文字,右侧通过 layoutWeight(1) 的占位 Column 将最高最低温度信息推到最右边。温度曲线部分使用了横向滚动的 Scroll 容器,内部通过 ForEach 循环渲染每个小时的温度柱状图。
每个小时的展示单元都是一个 Column,从上到下依次为天气图标、温度数值、温度柱状条和时间标签。温度柱状条的高度通过计算表达式 40 * ((hour.temp - 15) / 15) 动态确定,这是一种简单的数据到视觉高度的线性映射。当温度大于等于 25°C 时,柱状条和温度数字都使用橙色,否则使用蓝色系,这种颜色编码让用户能够一眼识别出高温时段。
Scroll() {
Row() {
ForEach(HOURLY_DATA, (hour: HourlyMeta) => {
Column() {
Text(hour.icon)
.fontSize(20)
Text(hour.temp + '°')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(hour.temp >= 25 ? COLORS.orange : COLORS.primary)
.margin({ top: 4 })
Column()
.width(4)
.height(40 * ((hour.temp - 15) / 15))
.backgroundColor(hour.temp >= 25 ? COLORS.orange : COLORS.primaryLight)
.borderRadius(2)
.margin({ top: 4 })
Text(hour.hour)
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 4 })
}
.margin({ right: 8 })
.alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 4, right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(120)
"城市天气"模块位于 24 小时预报的下方,展示了六个主要城市的天气简况。每个城市项都是一个 Row 横向布局,从左到右依次为天气图标、城市名称、弹性占位空间、天气状况描述和温度数值。这种横向排列方式非常适合列表的快速扫视,用户可以在很短时间内对比不同城市的温度和天气差异。每个城市项都设置了独立的白色背景和圆角,以及轻微的阴影效果,在视觉上形成了清晰的条目边界。
ForEach(CITY_LIST, (city: CityMeta) => {
Row() {
Text(city.icon)
.fontSize(24)
Text(city.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 10 })
Column().layoutWeight(1)
Text(city.weather)
.fontSize(12)
.fontColor(COLORS.textSecondary)
Text(' ' + city.temp + '°')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.margin({ left: 8 })
}
.width('92%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.backgroundColor(COLORS.white)
.borderRadius(10)
.margin({ bottom: 6 })
.alignItems(VerticalAlign.Center)
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
})
九、七日预报模块:纵向时间轴与数据可视化的结合
七日预报模块(forecastContent)采用了经典的纵向列表布局,从上到下依次展示从今天到下周一共七天的天气预报信息。这种布局方式与用户在日历或日程应用中培养的使用习惯高度一致,自然易懂。
模块的顶部是一个标题"7日天气预报",使用了 18 号粗体字和主文本颜色,左侧留有 16 像素的外边距,与下方的预报列表形成对齐关系。标题下方的预报列表通过 ForEach 循环遍历 FORECAST_DATA 数组,为每一天调用 forecastItem Builder 方法生成对应的预报条目。
@Builder
forecastContent() {
Scroll() {
Column() {
Text('7日天气预报')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 16, left: 16, bottom: 12 })
.alignSelf(ItemAlign.Start)
ForEach(FORECAST_DATA, (weather: WeatherMeta) => {
this.forecastItem(weather)
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
forecastItem 是七日预报模块中最复杂的 UI 构建函数,它展示了如何在有限的空间内高密度地呈现多维气象信息。每个预报条目都是一个白色圆角卡片,内部采用了嵌套的多层布局结构。最外层是 Column,内部首先是一个 Row,这个 Row 从左到右依次包含了日期信息列、天气图标列、天气描述列和温度范围列。
日期信息列是一个 Column,包含星期(如"今天"、“明天”)和具体日期(如"04-16")两行文本。这个列设置了固定的 70 像素宽度,并且通过 alignItems(HorizontalAlign.Start) 将文本左对齐,保证了所有预报条目的日期信息在垂直方向上严格对齐,形成了清晰的视觉引导线。天气图标使用了 28 号字体大小,在 40 像素的固定宽度内居中显示,确保了不同 emoji 图标在视觉上的统一占位。
@Builder
forecastItem(weather: WeatherMeta) {
Column() {
Row() {
Column() {
Text(weather.weekday)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(weather.date)
.fontSize(11)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.width(70)
.alignItems(HorizontalAlign.Start)
Text(weather.icon)
.fontSize(28)
.width(40)
.textAlign(TextAlign.Center)
Column() {
Text(weather.weather)
.fontSize(13)
.fontColor(COLORS.textPrimary)
Text(weather.wind)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 8 })
温度范围的可视化是这个预报条目中最具设计感的部分。开发者使用了一个 Stack 层叠容器来构建温度条:底层是一个占满 80 像素宽度的灰色圆角条(COLORS.border),上层是一个宽度为 60% 的渐变色圆角条。这个渐变条从左侧的浅蓝色(COLORS.primaryLight)平滑过渡到右侧的橙色(COLORS.orange),巧妙地用颜色编码了温度从低到高的变化趋势。在温度条下方,最低温度和最高温度分别用蓝色和橙色显示,并且分别位于条的左右两端,与上方渐变色条的方向形成呼应。
Column() {
Stack({ alignContent: Alignment.Start }) {
Column()
.width('100%')
.height(6)
.backgroundColor(COLORS.border)
.borderRadius(3)
Column()
.width('60%')
.height(6)
.linearGradient({ angle: 0, colors: [[COLORS.primaryLight, 0.0], [COLORS.orange, 1.0]] })
.borderRadius(3)
}
.width(80)
.height(6)
Row() {
Text(weather.tempLow + '°')
.fontSize(12)
.fontColor(COLORS.primary)
Column().layoutWeight(1)
Text(weather.tempHigh + '°')
.fontSize(12)
.fontColor(COLORS.orange)
.fontWeight(FontWeight.Bold)
}
.width(80)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.width(90)
}
.width('100%')
.alignItems(VerticalAlign.Center)
在主要预报信息的下方,还有一个 Row 横向布局用于展示湿度和 AQI 信息。湿度使用提示色(textHint)显示,而 AQI 则调用了前面定义的 getAqiColor 和 getAqiLevel 工具函数,根据具体的 AQI 数值动态设置颜色和文字说明。这种将辅助信息放在次要位置的设计,避免了主信息区域过于拥挤,同时保证了需要详细数据的用户能够方便地获取这些信息。
Row() {
Text('湿度 ' + weather.humidity + '%')
.fontSize(10)
.fontColor(COLORS.textHint)
Column().layoutWeight(1)
Text('AQI ' + weather.aqi + ' ' + getAqiLevel(weather.aqi))
.fontSize(10)
.fontColor(getAqiColor(weather.aqi))
}
.width('100%')
.margin({ top: 6 })
}
.width('92%')
.padding(12)
.backgroundColor(COLORS.white)
.borderRadius(12)
.margin({ bottom: 6 })
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
}
十、气象预警模块:紧急信息的高可见性设计
气象预警模块(alertContent)是这款应用中信息优先级最高的功能区域。预警信息往往涉及用户的生命财产安全,因此在 UI 设计上必须确保信息的高可见性、高可读性和快速可处理性。这个模块的设计充分体现了这些原则。
页面顶部的标题栏采用了 Row 横向布局,左侧是"气象预警"标题,右侧通过 layoutWeight(1) 的弹性占位,将预警数量信息推到了最右边。预警数量使用了红色(COLORS.danger)显示,与普通的辅助信息形成了鲜明的对比,起到了视觉警示的作用。整个标题栏宽度为 92%,与下方的预警卡片保持了一致的边距。
@Builder
alertContent() {
Scroll() {
Column() {
Row() {
Text('气象预警')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text(getAlertCount() + '条预警')
.fontSize(13)
.fontColor(COLORS.danger)
}
.width('92%')
.margin({ top: 16, bottom: 12 })
ForEach(ALERT_LIST, (alert: AlertMeta) => {
this.alertCard(alert)
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
alertCard Builder 是预警模块的核心组件,它负责渲染单条预警信息的卡片。每张预警卡片都是一个白色圆角矩形,内部通过精心的布局设计将预警的关键信息进行了层次化的组织。卡片顶部是一个 Row,左侧是一个正方形色块,内部居中显示警告符号 '⚠'。这个色块的背景颜色直接使用了预警数据中的 color 字段,因此不同级别的预警会显示不同的颜色:黄色预警显示橙黄色,蓝色预警显示蓝色,橙色预警显示橙红色。这种颜色与预警级别的直接绑定,使得用户即使在快速滑动浏览时,也能够通过色块的颜色快速判断预警的紧急程度。
色块右侧是一个 Column,包含预警标题和预警等级标签行。预警标题使用了 15 号粗体字,在视觉上非常醒目。标题下方的 Row 中,预警等级标签(如"黄色预警")被设计成了一个带背景色的小徽章样式:使用了对应的颜色作为背景,白色文字,4 像素的圆角,以及 6 像素的水平内边距。这种徽章式的设计是移动端展示标签类信息的经典模式,既紧凑又清晰。徽章旁边是受影响区域的信息,使用次级文本颜色显示。
@Builder
alertCard(alert: AlertMeta) {
Column() {
Row() {
Stack() {
Text('⚠')
.fontSize(24)
.fontColor(COLORS.white)
}
.width(44)
.height(44)
.borderRadius(10)
.backgroundColor(alert.color)
Column() {
Text(alert.title)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row() {
Text(alert.level + '预警')
.fontSize(11)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(alert.color)
.fontColor(COLORS.white)
.borderRadius(4)
Text(' ' + alert.area)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
预警卡片的中间部分是预警的详细内容描述。这段文本使用了 12 号字体和次级文本颜色,同时设置了 18 像素的行高。行高的设置非常重要,因为预警内容往往包含多行文本,适当的行高可以显著提升长文本的阅读舒适度。文本顶部有 10 像素的外边距,与上方的标题区域形成了自然的段落间距。
卡片底部是一个操作栏 Row,左侧显示预警的发布时间,使用最浅的提示色(textHint)和 10 号小字,体现了这是次要的元信息。右侧通过弹性占位将"编辑"和"解除"两个操作按钮推到了最右边。"编辑"按钮使用了主色调(COLORS.primary),"解除"按钮使用了危险色(COLORS.danger)。两个按钮都绑定了 onClick 事件:点击"编辑"时,将当前预警对象赋值给 targetAlert 状态,然后显示编辑弹窗;点击"解除"时,同样设置 targetAlert,然后显示删除确认弹窗。这种数据传递模式确保了弹窗能够准确知道要操作的是哪一条预警。
Text(alert.content)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.lineHeight(18)
.margin({ top: 10 })
Row() {
Text('发布时间:' + alert.time)
.fontSize(10)
.fontColor(COLORS.textHint)
Column().layoutWeight(1)
Text('编辑')
.fontSize(11)
.fontColor(COLORS.primary)
.onClick(() => {
this.targetAlert = alert
this.showEditDialog = true
})
Text(' 解除')
.fontSize(11)
.fontColor(COLORS.danger)
.onClick(() => {
this.targetAlert = alert
this.showDeleteDialog = true
})
}
.width('100%')
.margin({ top: 8 })
}
.width('92%')
.padding(14)
.backgroundColor(COLORS.white)
.borderRadius(14)
.margin({ bottom: 8 })
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
}
十一、监测站点模块:分布式数据的网格化展示
监测站点模块(stationContent)展示了城市不同区域的环境监测数据,这是一种典型的分布式数据展示场景。用户需要同时查看多个站点的信息,并在不同站点之间进行对比分析。这个模块的设计充分考虑了这种多实体并列展示的需求。
页面顶部的标题是"监测站点",后面通过字符串拼接的方式动态显示了当前站点的总数(STATION_LIST.length)。这种动态计数的方式,使得即使后续站点数据发生变化,标题也能自动更新,无需手动修改。标题使用了与预报模块相同的样式规范,保持了整体视觉风格的一致性。
@Builder
stationContent() {
Scroll() {
Column() {
Text('监测站点 (' + STATION_LIST.length + ')')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 16, left: 16, bottom: 12 })
.alignSelf(ItemAlign.Start)
ForEach(STATION_LIST, (station: StationMeta) => {
每个站点卡片内部可以细分为三个主要区域:头部信息区、核心指标区和风向信息区。头部信息区是一个 Row,左侧是一个 40x40 像素的圆角正方形,背景色为浅蓝色(COLORS.primaryLight),内部居中显示雷达站图标 '📡'。这个色块的设计与预警卡片中的色块形成了统一的视觉语言,都是使用图标加背景色的组合来标识条目类型。色块右侧是站点的名称和所属区域,名称使用 14 号粗体,区域使用 11 号次级文本颜色。
头部信息区最右侧是环境质量状态标签,如"优"或"良"。这个标签的样式设计非常精致:使用了 11 号白色文字,8 像素的水平内边距,3 像素的垂直内边距,6 像素的圆角,以及动态设置的背景色。背景色通过调用 getAqiColor(station.aqi) 函数获取,因此"优"状态的标签显示绿色,"良"状态显示橙色。这种将数据值直接映射为视觉样式的做法,极大地提升了信息的传达效率。
Column() {
Row() {
Stack() {
Text('📡')
.fontSize(20)
}
.width(40)
.height(40)
.borderRadius(10)
.backgroundColor(COLORS.primaryLight)
Column() {
Text(station.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(station.district)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text(station.status)
.fontSize(11)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(getAqiColor(station.aqi))
.borderRadius(6)
}
.width('100%')
.alignItems(VerticalAlign.Center)
核心指标区采用了三等分的 Row 布局,每个指标占据 layoutWeight(1) 的等宽空间。三个指标分别是温度、湿度和 AQI,每个指标都是一个 Column,上下排列着数值和标签。温度使用橙色显示,湿度使用主色调蓝色显示,AQI 则使用动态颜色。三个指标列都设置了 alignItems(HorizontalAlign.Center),使得数值和标签在每个列内都居中对齐,形成了整齐的三列网格效果。这种等分布局在移动端展示少量核心指标时非常有效,既充分利用了屏幕宽度,又保证了信息的均衡分布。
Row() {
Column() {
Text(station.temp + '°C')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.orange)
Text('温度')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text(station.humidity + '%')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text('湿度')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text(station.aqi.toString())
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(getAqiColor(station.aqi))
Text('AQI')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.margin({ top: 10 })
卡片最底部是风向信息,使用 alignSelf(ItemAlign.Start) 左对齐,与上方居中对齐的指标区形成了微妙的对齐变化,增加了视觉节奏感。
Text('风向:' + station.wind)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 6 })
.alignSelf(ItemAlign.Start)
}
.width('92%')
.padding(14)
.backgroundColor(COLORS.white)
.borderRadius(14)
.margin({ bottom: 8 })
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
十二、个人中心模块:功能入口与操作聚合
"我的"模块(mineContent)是应用中功能入口的聚合页面,它不仅展示了用户相关的摘要信息,还提供了发布预警、站点管理、预警历史、数据报表、通知设置和关于我们等多个功能入口。这种设计符合移动端应用个人中心页的经典模式:顶部是用户摘要信息,中部是高频操作按钮,下方是功能列表。
页面顶部是一个用户信息卡片,采用了 Row 横向布局。左侧是一个 60x60 像素的圆形头像区域(通过 borderRadius(30) 实现正圆),背景色为浅蓝色,内部居中显示气象图标 '🌡'。右侧是一个 Column,包含三行文本:机构名称"气象监测中心"、站点数量信息"6个监测站点 · 实时在线"、以及今日预警发布数量"今日已发布 X 条预警"。其中预警数量使用了红色显示,起到了信息突出的效果。整个用户信息卡片设置了白色背景、16 像素圆角和轻微阴影,与页面的浅蓝色背景形成了清晰的层级关系。
@Builder
mineContent() {
Scroll() {
Column() {
Row() {
Stack() {
Text('🌡')
.fontSize(32)
}
.width(60)
.height(60)
.borderRadius(30)
.backgroundColor(COLORS.primaryLight)
Column() {
Text('气象监测中心')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('6个监测站点 · 实时在线')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
Text('今日已发布 ' + getAlertCount() + ' 条预警')
.fontSize(12)
.fontColor(COLORS.danger)
.margin({ top: 2 })
}
.margin({ left: 16 })
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('92%')
.padding(16)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ top: 16, bottom: 16 })
.alignItems(VerticalAlign.Center)
.shadow({ radius: 4, color: '#0D000000', offsetY: 2 })
在用户信息卡片下方,是一个醒目的红色操作按钮"+ 发布新预警"。这个按钮使用了危险色(COLORS.danger,即红色)作为背景色,这看似与"危险"的语义不符,但实际上在气象预警场景中,发布预警是一个需要引起高度注意的操作,红色的使用恰恰强化了这种操作的严肃性和重要性。按钮宽度占满 92%,高度 40 像素,圆角 10 像素,整体呈现为通栏按钮的样式,非常易于点击操作。按钮的 onClick 事件将 showAddDialog 状态设置为 true,从而触发发布预警弹窗的显示。
Button('+ 发布新预警')
.fontSize(14)
.fontColor(COLORS.white)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.height(40)
.width('92%')
.margin({ bottom: 16 })
.onClick(() => {
this.showAddDialog = true
})
按钮下方是一个功能列表,包含了五个功能入口:站点管理、预警历史、数据报表、通知设置和关于我们。这个列表通过 ForEach 循环一个字符串数组动态生成。每个列表项都是一个 Row 横向布局,左侧是功能名称,右侧通过弹性占位后是一个向右的箭头符号 '›'。这种左右分置、右侧带箭头的列表项设计,是移动端表示"可点击进入下一级页面"的标准交互语言,用户看到这种样式的列表项,会自然地理解为点击后会跳转到对应的功能详情页。每个列表项都有独立的白色背景、12 像素圆角和轻微阴影,在视觉上彼此分离,便于点击操作。
ForEach(['站点管理', '预警历史', '数据报表', '通知设置', '关于我们'], (item: string) => {
Row() {
Text(item)
.fontSize(14)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('›')
.fontSize(20)
.fontColor(COLORS.textHint)
}
.width('92%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.backgroundColor(COLORS.white)
.borderRadius(12)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
十三、弹窗系统:模态交互的三种典型模式
弹窗是现代移动应用中不可或缺的交互组件,它能够在不离开当前页面的前提下,承载表单填写、信息确认等临时性任务。这款气象监测应用实现了三种典型的弹窗模式:创建弹窗(addDialog)、编辑弹窗(editDialog)和删除确认弹窗(deleteDialog)。这三个弹窗虽然功能不同,但在整体结构上都遵循了统一的设计规范,体现了组件化设计中的"变与不变"原则。
三个弹窗的共同结构特征是:最外层都是一个占满全屏的 Column,背景色为半透明的黑色(rgba(0,0,0,0.5)),通过 justifyContent(FlexAlign.Center) 和 alignItems(HorizontalAlign.Center) 将内部的弹窗卡片在屏幕中央居中显示。这种全屏遮罩加居中卡片的模式,是模态弹窗最经典的视觉呈现方式,遮罩层阻隔了用户对底层内容的操作,强制用户将注意力集中在弹窗任务上。弹窗卡片本身都是白色背景、大圆角(20 像素)的矩形,内部包含标题、内容区和操作按钮区。
@Builder
addDialog() {
Column() {
Column() {
Text('发布预警')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ bottom: 16 })
发布预警弹窗(addDialog)是一个表单类的创建弹窗。它的内容区包含三个表单项,每个表单项都是一个 Row 横向布局:左侧是字段标签(如"预警等级"、“预警区域”、“预警内容”),右侧通过弹性占位后是一个占位提示文本(如"请选择等级"、“请输入区域”)。每个表单项下方都有一条 1 像素宽的底部边框,使用 COLORS.border 颜色,这种细线分隔的方式在移动端表单设计中非常常见,既起到了分隔作用,又不会过于抢眼。由于这是一个演示性质的应用,表单项目前使用的是静态文本占位,在真实场景中,这些位置通常会替换为 TextInput 输入框或 Picker 选择器。
Row() {
Text('预警等级')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text('请选择等级')
.fontSize(13)
.fontColor(COLORS.textHint)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('预警区域')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text('请输入区域')
.fontSize(13)
.fontColor(COLORS.textHint)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('预警内容')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text('请输入内容')
.fontSize(13)
.fontColor(COLORS.textHint)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
弹窗底部的操作按钮区是一个 Row,内部包含左右两个按钮。左侧的"取消"按钮使用了白色背景、灰色文字和灰色边框的次级按钮样式,点击后将 showAddDialog 设置为 false,关闭弹窗。右侧的"发布"按钮使用了红色背景、白色文字的强调按钮样式,同样点击后关闭弹窗。两个按钮都设置了 layoutWeight(1),使得它们在弹窗宽度内等分空间,中间通过 12 像素的左外边距分隔。这种左右对称的双按钮布局是移动端弹窗的标准范式,"取消"在左、"确认"在右的顺序也符合大多数用户的操作习惯。
Row() {
Button('取消')
.fontSize(14)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showAddDialog = false
})
Button('发布')
.fontSize(14)
.fontColor(COLORS.white)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
this.showAddDialog = false
})
}
.width('100%')
.margin({ top: 16 })
}
.width('80%')
.padding(20)
.backgroundColor(COLORS.white)
.borderRadius(20)
.onClick(() => {})
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
编辑预警弹窗(editDialog)的结构与发布弹窗非常相似,但内容区改为展示当前选中预警的只读信息。弹窗标题为"编辑预警",内容区依次显示预警标题、预警等级和影响区域。与发布弹窗不同的是,编辑弹窗展示的是 this.targetAlert 中的实际数据,例如预警标题直接显示为 this.targetAlert.title,预警等级则以彩色徽章的形式展示,徽章的背景色使用了 this.targetAlert.color。这种设计让用户在编辑前能够清晰地确认自己正在操作的是哪一条预警。操作按钮区的左侧仍是"取消"按钮,右侧则变为了主色调的"保存"按钮,语义上从"创建"转变为了"更新"。
@Builder
editDialog() {
Column() {
Column() {
Text('编辑预警')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ bottom: 16 })
Row() {
Text('预警标题')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text(this.targetAlert.title)
.fontSize(13)
.fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('预警等级')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text(this.targetAlert.level + '预警')
.fontSize(12)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor(this.targetAlert.color)
.fontColor(COLORS.white)
.borderRadius(6)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('影响区域')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text(this.targetAlert.area)
.fontSize(13)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
Row() {
Button('取消')
.fontSize(14)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showEditDialog = false
})
Button('保存')
.fontSize(14)
.fontColor(COLORS.white)
.backgroundColor(COLORS.primary)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
this.showEditDialog = false
})
}
.width('100%')
.margin({ top: 16 })
}
.width('80%')
.padding(20)
.backgroundColor(COLORS.white)
.borderRadius(20)
.onClick(() => {})
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
删除确认弹窗(deleteDialog)采用了与前两个弹窗不同的设计风格,它更侧重于信息的强调和情感的传达。弹窗顶部是一个绿色的大号对勾图标,放置在一个圆形的浅绿色背景中。这个对勾图标在视觉上产生了一定的认知冲突:在删除确认的语境下,用户预期看到的可能是警告图标或问号,但这里使用对勾可能是为了表达"确认操作已就绪"或"操作可安全执行"的积极暗示。弹窗标题"解除预警确认"使用了粗体字,下方的描述文本详细说明了将要执行的操作及其后果:"确定要解除「XXX」吗?解除后该预警将从列表中移除。"这段描述文本设置了 textAlign(TextAlign.Center) 和 20 像素的行高,确保了在多行展示时的居中效果和阅读舒适度。
操作按钮区中,左侧仍是次级样式的"取消"按钮,右侧则变为了绿色的"确认解除"按钮。这里使用绿色而不是红色,可能是为了将"解除预警"这个操作与"删除"的负面语义进行区分:解除预警意味着危险状态的结束,是一个正面的管理行为,因此用绿色更能传达这种积极的语义。三个弹窗虽然功能各异,但都在弹窗卡片上绑定了一个空的 onClick 事件,这是为了防止点击弹窗内部时事件冒泡到下方的遮罩层,避免意外关闭弹窗。
@Builder
deleteDialog() {
Column() {
Column() {
Stack() {
Text('✓')
.fontSize(40)
.fontColor(COLORS.success)
}
.width(64)
.height(64)
.borderRadius(32)
.backgroundColor('#E8F5E9')
.margin({ bottom: 16 })
Text('解除预警确认')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('确定要解除「' + this.targetAlert.title + '」吗?解除后该预警将从列表中移除。')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.textAlign(TextAlign.Center)
.lineHeight(20)
.margin({ top: 12 })
Row() {
Button('取消')
.fontSize(14)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showDeleteDialog = false
})
Button('确认解除')
.fontSize(14)
.fontColor(COLORS.white)
.backgroundColor(COLORS.success)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
this.showDeleteDialog = false
})
}
.width('100%')
.margin({ top: 20 })
}
.width('76%')
.padding(24)
.backgroundColor(COLORS.white)
.borderRadius(20)
.onClick(() => {})
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
十四、各模块特性对比分析
为了更清晰地理解这款气象监测应用不同功能模块之间的差异和特点,下面通过一个详细的对比表格,从数据模型、布局结构、核心交互、视觉特征、信息密度、使用场景、技术复杂度和用户目标等八个维度进行系统性的比较分析。
| 对比维度 | 实况天气模块 | 七日预报模块 | 气象预警模块 | 监测站点模块 | 个人中心模块 |
|---|---|---|---|---|---|
| 数据模型 | HourlyMeta + CityMeta |
WeatherMeta |
AlertMeta |
StationMeta |
静态字符串数组 |
| 布局结构 | 纵向滚动 + 横向滚动混合 | 纯纵向列表 | 纵向卡片列表 | 纵向卡片 + 三等分网格 | 用户信息卡片 + 功能列表 |
| 核心交互 | 横向滑动查看小时预报 | 纵向滑动浏览日期 | 点击编辑/解除按钮 | 纯信息浏览 | 点击发布按钮 / 功能列表项 |
| 视觉特征 | 渐变背景大卡片 + 柱状图 | 温度渐变条 + 图标 | 彩色预警级别色块 | 彩色 AQI 状态标签 | 圆形头像 + 通栏红色按钮 |
| 信息密度 | 极高(多维度数据聚合) | 高(每天含7个字段) | 中高(标题+内容+元信息) | 中等(3个核心指标) | 低(摘要+入口列表) |
| 使用场景 | 查看当前实时天气 | 规划未来一周行程 | 获取灾害预警信息 | 了解区域环境差异 | 管理预警和功能设置 |
| 技术复杂度 | 高(含动态高度计算) | 高(含渐变进度条) | 中(含条件渲染弹窗) | 中(含动态颜色映射) | 低(静态结构为主) |
| 用户目标 | 快速获取当前气象概况 | 预判未来天气变化 | 及时知晓紧急预警 | 对比不同站点数据 | 执行管理操作和配置 |
从上表可以看出,这款应用在五个模块之间实现了良好的功能分工和视觉差异化。实况天气模块以高密度的信息聚合和强烈的视觉冲击(渐变卡片)为核心,目标是让用户在打开应用的瞬间就能获得最关键的信息。七日预报模块则通过纵向时间轴和温度可视化条,帮助用户建立对未来天气变化的直观认知。气象预警模块采用了最强烈的色彩对比(红/橙/蓝/黄色块),确保紧急信息不会被忽视。监测站点模块通过网格化的数据排列,支持用户进行跨区域的对比分析。个人中心模块则以最低的信息密度,提供了清晰的功能入口导航。五个模块各司其职,共同构成了一个完整的气象信息服务闭环。
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// 248.ets - 气象监测APP
// 主题色:天空蓝 #0288D1 + 预警红 #D32F2F
// 布局:实时天气大卡片 + 24小时温度曲线 + 7日预报列表 + 预警通知
// ==================== 类型定义 ====================
interface WeatherMeta {
date: string
weekday: string
tempHigh: number
tempLow: number
weather: string
icon: string
wind: string
humidity: number
aqi: number
}
interface HourlyMeta {
hour: string
temp: number
icon: string
weather: string
}
interface AlertMeta {
id: number
level: string
title: string
content: string
area: string
time: string
color: string
}
interface StationMeta {
id: number
name: string
district: string
temp: number
humidity: number
wind: string
aqi: number
status: string
}
interface CityMeta {
name: string
temp: number
weather: string
icon: string
}
// ==================== 设计令牌 ====================
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
orange: string;
}
const COLORS: ColorPalette = {
primary: '#0288D1',
primaryLight: '#4FC3F7',
primaryDark: '#01579B',
accent: '#D32F2F',
accentLight: '#FFCDD2',
bg: '#F0F7FC',
cardBg: '#FFFFFF',
textPrimary: '#013A63',
textSecondary: '#5C7B8A',
textHint: '#B0C4DE',
border: '#E1F0FA',
success: '#66BB6A',
warning: '#FFA726',
danger: '#EF5350',
white: '#FFFFFF',
orange: '#FF8F00'
};
const TAB_CONFIG: Record<string, string> = {
'today': '实况',
'forecast': '预报',
'alert': '预警',
'station': '站点',
'mine': '我的'
}
// ==================== 硬编码数据 ====================
const HOURLY_DATA: HourlyMeta[] = [
{ hour: '08时', temp: 18, icon: '☀', weather: '晴' },
{ hour: '09时', temp: 20, icon: '☀', weather: '晴' },
{ hour: '10时', temp: 22, icon: '🌤', weather: '多云' },
{ hour: '11时', temp: 24, icon: '🌤', weather: '多云' },
{ hour: '12时', temp: 26, icon: '🌤', weather: '多云' },
{ hour: '13时', temp: 27, icon: '⛅', weather: '阴' },
{ hour: '14时', temp: 26, icon: '🌧', weather: '小雨' },
{ hour: '15时', temp: 24, icon: '🌧', weather: '小雨' },
{ hour: '16时', temp: 23, icon: '🌦', weather: '阵雨' },
{ hour: '17时', temp: 22, icon: '🌦', weather: '阵雨' },
{ hour: '18时', temp: 20, icon: '🌤', weather: '多云' },
{ hour: '19时', temp: 19, icon: '🌙', weather: '晴' }
]
const FORECAST_DATA: WeatherMeta[] = [
{ date: '04-16', weekday: '今天', tempHigh: 27, tempLow: 18, weather: '多云转雨', icon: '🌧', wind: '东南风3级', humidity: 72, aqi: 56 },
{ date: '04-17', weekday: '明天', tempHigh: 25, tempLow: 16, weather: '小雨', icon: '🌧', wind: '东风4级', humidity: 85, aqi: 42 },
{ date: '04-18', weekday: '后天', tempHigh: 23, tempLow: 14, weather: '阴', icon: '☁', wind: '北风3级', humidity: 68, aqi: 65 },
{ date: '04-19', weekday: '周五', tempHigh: 26, tempLow: 15, weather: '晴', icon: '☀', wind: '西北风2级', humidity: 55, aqi: 38 },
{ date: '04-20', weekday: '周六', tempHigh: 28, tempLow: 17, weather: '晴', icon: '☀', wind: '南风2级', humidity: 52, aqi: 35 },
{ date: '04-21', weekday: '周日', tempHigh: 29, tempLow: 19, weather: '多云', icon: '🌤', wind: '南风3级', humidity: 60, aqi: 48 },
{ date: '04-22', weekday: '周一', tempHigh: 26, tempLow: 18, weather: '雷阵雨', icon: '⛈', wind: '西南风3级', humidity: 78, aqi: 55 }
]
const ALERT_LIST: AlertMeta[] = [
{ id: 1, level: '黄色', title: '暴雨黄色预警', content: '预计未来6小时内,本市部分地区降雨量将达50毫米以上,请注意防范', area: '全市', time: '2024-04-16 14:00', color: '#FFA726' },
{ id: 2, level: '蓝色', title: '大风蓝色预警', content: '预计未来24小时内,本市将出现5-6级偏北风,阵风可达7-8级', area: '北部山区', time: '2024-04-16 10:30', color: '#42A5F5' },
{ id: 3, level: '橙色', title: '高温橙色预警', content: '预计明日最高气温将达37℃以上,请做好防暑降温', area: '城区', time: '2024-04-15 16:00', color: '#FF7043' },
{ id: 4, level: '黄色', title: '雷电黄色预警', content: '预计今夜到明晨将有雷电活动,伴有短时强降水', area: '中南部', time: '2024-04-15 18:00', color: '#FFA726' }
]
const STATION_LIST: StationMeta[] = [
{ id: 1, name: '城东监测站', district: '浦东新区', temp: 26, humidity: 72, wind: '东南风3级', aqi: 56, status: '良' },
{ id: 2, name: '城西监测站', district: '长宁区', temp: 25, humidity: 68, wind: '东风2级', aqi: 42, status: '优' },
{ id: 3, name: '城南监测站', district: '徐汇区', temp: 27, humidity: 75, wind: '南风3级', aqi: 65, status: '良' },
{ id: 4, name: '城北监测站', district: '杨浦区', temp: 24, humidity: 70, wind: '北风2级', aqi: 38, status: '优' },
{ id: 5, name: '中心监测站', district: '黄浦区', temp: 26, humidity: 73, wind: '东南风3级', aqi: 51, status: '良' },
{ id: 6, name: '滨海监测站', district: '浦东新区', temp: 23, humidity: 80, wind: '东风4级', aqi: 35, status: '优' }
]
const CITY_LIST: CityMeta[] = [
{ name: '上海', temp: 26, weather: '多云', icon: '🌤' },
{ name: '北京', temp: 22, weather: '晴', icon: '☀' },
{ name: '广州', temp: 30, weather: '阵雨', icon: '🌧' },
{ name: '深圳', temp: 29, weather: '多云', icon: '🌤' },
{ name: '杭州', temp: 25, weather: '阴', icon: '☁' },
{ name: '成都', temp: 21, weather: '小雨', icon: '🌧' }
]
function getAqiColor(aqi: number): string {
if (aqi <= 50) {
return COLORS.success
}
if (aqi <= 100) {
return COLORS.warning
}
return COLORS.danger
}
function getAqiLevel(aqi: number): string {
if (aqi <= 50) {
return '优'
}
if (aqi <= 100) {
return '良'
}
if (aqi <= 150) {
return '轻度'
}
return '中度'
}
function getMaxTemp(): number {
let maxVal: number = 0
for (const h of HOURLY_DATA) {
if (h.temp > maxVal) {
maxVal = h.temp
}
}
return maxVal
}
function getMinTemp(): number {
let minVal: number = 100
for (const h of HOURLY_DATA) {
if (h.temp < minVal) {
minVal = h.temp
}
}
return minVal
}
function getAlertCount(): number {
return ALERT_LIST.length
}
// ==================== 入口组件 ====================
@Entry
@Component
struct WeatherApp {
@State currentTab: string = 'today'
@State showAddDialog: boolean = false
@State showEditDialog: boolean = false
@State showDeleteDialog: boolean = false
@State targetAlert: AlertMeta = ALERT_LIST[0]
build() {
Stack() {
Column() {
Row() {
Text('🌤 气象监测')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Column().layoutWeight(1)
Text('📍 上海')
.fontSize(13)
.fontColor('#FFFFFFCC')
}
.width('100%')
.height(52)
.padding({ left: 20, right: 20 })
.linearGradient({ angle: 135, colors: [[COLORS.primaryDark, 0.0], [COLORS.primary, 1.0]] })
Column() {
if (this.currentTab === 'today') {
this.todayContent()
} else if (this.currentTab === 'forecast') {
this.forecastContent()
} else if (this.currentTab === 'alert') {
this.alertContent()
} else if (this.currentTab === 'station') {
this.stationContent()
} else {
this.mineContent()
}
}
.layoutWeight(1)
.backgroundColor(COLORS.bg)
this.bottomTabBar()
}
.width('100%')
.height('100%')
if (this.showAddDialog) {
this.addDialog()
}
if (this.showEditDialog) {
this.editDialog()
}
if (this.showDeleteDialog) {
this.deleteDialog()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
@Builder
bottomTabBar() {
Row() {
ForEach(Object.keys(TAB_CONFIG), (key: string) => {
Column() {
Text(this.getTabIcon(key))
.fontSize(20)
Text(TAB_CONFIG[key])
.fontSize(11)
.margin({ top: 2 })
.fontColor(this.currentTab === key ? COLORS.primary : COLORS.textHint)
}
.layoutWeight(1)
.height(56)
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.currentTab = key
})
})
}
.width('100%')
.height(56)
.backgroundColor(COLORS.white)
.border({ width: { top: 1 }, color: COLORS.border })
}
getTabIcon(key: string): string {
if (key === 'today') {
return this.currentTab === key ? '🌡' : '🌤'
}
if (key === 'forecast') {
return this.currentTab === key ? '📅' : '📆'
}
if (key === 'alert') {
return this.currentTab === key ? '⚠' : '🔔'
}
if (key === 'station') {
return this.currentTab === key ? '📡' : '📶'
}
return this.currentTab === key ? '👤' : '🙆'
}
// ========== 实况Tab ==========
@Builder
todayContent() {
Scroll() {
Column() {
// 实时天气大卡片
Column() {
Text('上海 · 浦东新区')
.fontSize(14)
.fontColor('#FFFFFFCC')
Row() {
Text('26')
.fontSize(72)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('°C')
.fontSize(24)
.fontColor('#FFFFFFCC')
.margin({ bottom: 20 })
}
Text('多云转小雨')
.fontSize(18)
.fontColor(COLORS.white)
.margin({ top: 4 })
Text('湿度 72% · 东南风3级 · AQI 56')
.fontSize(12)
.fontColor('#FFFFFFCC')
.margin({ top: 8 })
// 体感温度
Row() {
Column() {
Text('体感 25°')
.fontSize(12)
.fontColor('#FFFFFFCC')
}
.margin({ right: 20 })
Column() {
Text('紫外线 弱')
.fontSize(12)
.fontColor('#FFFFFFCC')
}
.margin({ right: 20 })
Column() {
Text('能见度 10km')
.fontSize(12)
.fontColor('#FFFFFFCC')
}
}
.margin({ top: 12 })
}
.width('92%')
.padding({ top: 28, bottom: 28 })
.borderRadius(20)
.linearGradient({ angle: 135, colors: [[COLORS.primaryDark, 0.0], [COLORS.primaryLight, 1.0]] })
.alignItems(HorizontalAlign.Center)
.margin({ top: 12, bottom: 16 })
.shadow({ radius: 12, color: '#1A0288D1', offsetY: 6 })
// 24小时温度曲线
Column() {
Row() {
Text('24小时预报')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('最高 ' + getMaxTemp() + '° 最低 ' + getMinTemp() + '°')
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.width('100%')
.margin({ bottom: 12 })
// 温度曲线(柱状图模拟)
Scroll() {
Row() {
ForEach(HOURLY_DATA, (hour: HourlyMeta) => {
Column() {
Text(hour.icon)
.fontSize(20)
Text(hour.temp + '°')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(hour.temp >= 25 ? COLORS.orange : COLORS.primary)
.margin({ top: 4 })
Column()
.width(4)
.height(40 * ((hour.temp - 15) / 15))
.backgroundColor(hour.temp >= 25 ? COLORS.orange : COLORS.primaryLight)
.borderRadius(2)
.margin({ top: 4 })
Text(hour.hour)
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 4 })
}
.margin({ right: 8 })
.alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 4, right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(120)
}
.width('92%')
.padding(16)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ bottom: 12 })
.shadow({ radius: 4, color: '#0D000000', offsetY: 2 })
// 城市天气
Text('城市天气')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 16, bottom: 8 })
.alignSelf(ItemAlign.Start)
ForEach(CITY_LIST, (city: CityMeta) => {
Row() {
Text(city.icon)
.fontSize(24)
Text(city.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 10 })
Column().layoutWeight(1)
Text(city.weather)
.fontSize(12)
.fontColor(COLORS.textSecondary)
Text(' ' + city.temp + '°')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.margin({ left: 8 })
}
.width('92%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.backgroundColor(COLORS.white)
.borderRadius(10)
.margin({ bottom: 6 })
.alignItems(VerticalAlign.Center)
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
// ========== 预报Tab ==========
@Builder
forecastContent() {
Scroll() {
Column() {
Text('7日天气预报')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 16, left: 16, bottom: 12 })
.alignSelf(ItemAlign.Start)
ForEach(FORECAST_DATA, (weather: WeatherMeta) => {
this.forecastItem(weather)
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
@Builder
forecastItem(weather: WeatherMeta) {
Column() {
Row() {
Column() {
Text(weather.weekday)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(weather.date)
.fontSize(11)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.width(70)
.alignItems(HorizontalAlign.Start)
Text(weather.icon)
.fontSize(28)
.width(40)
.textAlign(TextAlign.Center)
Column() {
Text(weather.weather)
.fontSize(13)
.fontColor(COLORS.textPrimary)
Text(weather.wind)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 8 })
// 温度范围条
Column() {
Stack({ alignContent: Alignment.Start }) {
Column()
.width('100%')
.height(6)
.backgroundColor(COLORS.border)
.borderRadius(3)
Column()
.width('60%')
.height(6)
.linearGradient({ angle: 0, colors: [[COLORS.primaryLight, 0.0], [COLORS.orange, 1.0]] })
.borderRadius(3)
}
.width(80)
.height(6)
Row() {
Text(weather.tempLow + '°')
.fontSize(12)
.fontColor(COLORS.primary)
Column().layoutWeight(1)
Text(weather.tempHigh + '°')
.fontSize(12)
.fontColor(COLORS.orange)
.fontWeight(FontWeight.Bold)
}
.width(80)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.width(90)
}
.width('100%')
.alignItems(VerticalAlign.Center)
// AQI和湿度
Row() {
Text('湿度 ' + weather.humidity + '%')
.fontSize(10)
.fontColor(COLORS.textHint)
Column().layoutWeight(1)
Text('AQI ' + weather.aqi + ' ' + getAqiLevel(weather.aqi))
.fontSize(10)
.fontColor(getAqiColor(weather.aqi))
}
.width('100%')
.margin({ top: 6 })
}
.width('92%')
.padding(12)
.backgroundColor(COLORS.white)
.borderRadius(12)
.margin({ bottom: 6 })
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
}
// ========== 预警Tab ==========
@Builder
alertContent() {
Scroll() {
Column() {
Row() {
Text('气象预警')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text(getAlertCount() + '条预警')
.fontSize(13)
.fontColor(COLORS.danger)
}
.width('92%')
.margin({ top: 16, bottom: 12 })
ForEach(ALERT_LIST, (alert: AlertMeta) => {
this.alertCard(alert)
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
@Builder
alertCard(alert: AlertMeta) {
Column() {
Row() {
Stack() {
Text('⚠')
.fontSize(24)
.fontColor(COLORS.white)
}
.width(44)
.height(44)
.borderRadius(10)
.backgroundColor(alert.color)
Column() {
Text(alert.title)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row() {
Text(alert.level + '预警')
.fontSize(11)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(alert.color)
.fontColor(COLORS.white)
.borderRadius(4)
Text(' ' + alert.area)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
Text(alert.content)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.lineHeight(18)
.margin({ top: 10 })
Row() {
Text('发布时间:' + alert.time)
.fontSize(10)
.fontColor(COLORS.textHint)
Column().layoutWeight(1)
Text('编辑')
.fontSize(11)
.fontColor(COLORS.primary)
.onClick(() => {
this.targetAlert = alert
this.showEditDialog = true
})
Text(' 解除')
.fontSize(11)
.fontColor(COLORS.danger)
.onClick(() => {
this.targetAlert = alert
this.showDeleteDialog = true
})
}
.width('100%')
.margin({ top: 8 })
}
.width('92%')
.padding(14)
.backgroundColor(COLORS.white)
.borderRadius(14)
.margin({ bottom: 8 })
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
}
// ========== 站点Tab ==========
@Builder
stationContent() {
Scroll() {
Column() {
Text('监测站点 (' + STATION_LIST.length + ')')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 16, left: 16, bottom: 12 })
.alignSelf(ItemAlign.Start)
ForEach(STATION_LIST, (station: StationMeta) => {
Column() {
Row() {
Stack() {
Text('📡')
.fontSize(20)
}
.width(40)
.height(40)
.borderRadius(10)
.backgroundColor(COLORS.primaryLight)
Column() {
Text(station.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(station.district)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text(station.status)
.fontSize(11)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(getAqiColor(station.aqi))
.borderRadius(6)
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Column() {
Text(station.temp + '°C')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.orange)
Text('温度')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text(station.humidity + '%')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text('湿度')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text(station.aqi.toString())
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(getAqiColor(station.aqi))
Text('AQI')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.margin({ top: 10 })
Text('风向:' + station.wind)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 6 })
.alignSelf(ItemAlign.Start)
}
.width('92%')
.padding(14)
.backgroundColor(COLORS.white)
.borderRadius(14)
.margin({ bottom: 8 })
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
// ========== 我的Tab ==========
@Builder
mineContent() {
Scroll() {
Column() {
Row() {
Stack() {
Text('🌡')
.fontSize(32)
}
.width(60)
.height(60)
.borderRadius(30)
.backgroundColor(COLORS.primaryLight)
Column() {
Text('气象监测中心')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('6个监测站点 · 实时在线')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
Text('今日已发布 ' + getAlertCount() + ' 条预警')
.fontSize(12)
.fontColor(COLORS.danger)
.margin({ top: 2 })
}
.margin({ left: 16 })
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('92%')
.padding(16)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ top: 16, bottom: 16 })
.alignItems(VerticalAlign.Center)
.shadow({ radius: 4, color: '#0D000000', offsetY: 2 })
Button('+ 发布新预警')
.fontSize(14)
.fontColor(COLORS.white)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.height(40)
.width('92%')
.margin({ bottom: 16 })
.onClick(() => {
this.showAddDialog = true
})
ForEach(['站点管理', '预警历史', '数据报表', '通知设置', '关于我们'], (item: string) => {
Row() {
Text(item)
.fontSize(14)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('›')
.fontSize(20)
.fontColor(COLORS.textHint)
}
.width('92%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.backgroundColor(COLORS.white)
.borderRadius(12)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
// ==================== 弹窗 ====================
@Builder
addDialog() {
Column() {
Column() {
Text('发布预警')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ bottom: 16 })
Row() {
Text('预警等级')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text('请选择等级')
.fontSize(13)
.fontColor(COLORS.textHint)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('预警区域')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text('请输入区域')
.fontSize(13)
.fontColor(COLORS.textHint)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('预警内容')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text('请输入内容')
.fontSize(13)
.fontColor(COLORS.textHint)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
Row() {
Button('取消')
.fontSize(14)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showAddDialog = false
})
Button('发布')
.fontSize(14)
.fontColor(COLORS.white)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
this.showAddDialog = false
})
}
.width('100%')
.margin({ top: 16 })
}
.width('80%')
.padding(20)
.backgroundColor(COLORS.white)
.borderRadius(20)
.onClick(() => {})
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
editDialog() {
Column() {
Column() {
Text('编辑预警')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ bottom: 16 })
Row() {
Text('预警标题')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text(this.targetAlert.title)
.fontSize(13)
.fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('预警等级')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text(this.targetAlert.level + '预警')
.fontSize(12)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor(this.targetAlert.color)
.fontColor(COLORS.white)
.borderRadius(6)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('影响区域')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text(this.targetAlert.area)
.fontSize(13)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
Row() {
Button('取消')
.fontSize(14)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showEditDialog = false
})
Button('保存')
.fontSize(14)
.fontColor(COLORS.white)
.backgroundColor(COLORS.primary)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
this.showEditDialog = false
})
}
.width('100%')
.margin({ top: 16 })
}
.width('80%')
.padding(20)
.backgroundColor(COLORS.white)
.borderRadius(20)
.onClick(() => {})
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
deleteDialog() {
Column() {
Column() {
Stack() {
Text('✓')
.fontSize(40)
.fontColor(COLORS.success)
}
.width(64)
.height(64)
.borderRadius(32)
.backgroundColor('#E8F5E9')
.margin({ bottom: 16 })
Text('解除预警确认')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('确定要解除「' + this.targetAlert.title + '」吗?解除后该预警将从列表中移除。')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.textAlign(TextAlign.Center)
.lineHeight(20)
.margin({ top: 12 })
Row() {
Button('取消')
.fontSize(14)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showDeleteDialog = false
})
Button('确认解除')
.fontSize(14)
.fontColor(COLORS.white)
.backgroundColor(COLORS.success)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
this.showDeleteDialog = false
})
}
.width('100%')
.margin({ top: 20 })
}
.width('76%')
.padding(24)
.backgroundColor(COLORS.white)
.borderRadius(20)
.onClick(() => {})
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}
十五、总结与展望
通过对这款 HarmonyOS ArkTS 气象监测应用的全面剖析,我们可以深刻感受到 ArkTS 声明式 UI 开发框架在构建复杂移动端应用时的强大能力和优雅表达。从类型安全的数据建模开始,开发者通过精心设计的接口定义,为整个应用建立了坚实的数据基础。WeatherMeta、HourlyMeta、AlertMeta、StationMeta 和 CityMeta 这五个核心接口,分别对应了不同的业务实体和展示粒度,它们不仅是 TypeScript 类型系统的约束,更是业务领域知识的代码化表达。这种在编码初期就投入精力进行数据建模的做法,是专业软件开发的重要标志,它能够有效预防后续开发中因数据结构混乱而导致的各种 bug。

在 UI 构建层面,这款应用充分展示了 ArkTS 组件化开发的优势。通过 @Builder 装饰器定义的 todayContent、forecastContent、alertContent、stationContent 和 mineContent 等方法,将庞大的页面拆分为多个独立、可维护的 UI 单元。每个 Builder 方法内部又可以进一步调用更细粒度的 Builder(如 forecastItem 和 alertCard),形成了清晰的组件层次结构。这种自顶向下、逐层细化的开发方式,使得即使面对复杂的界面需求,开发者也能够保持清晰的思路,逐步构建出完整的用户界面。ForEach 循环与数据数组的绑定,则消除了传统命令式开发中手动管理列表项的繁琐,大幅提升了列表类界面的开发效率。
状态管理是这款应用的另一大技术亮点。通过 @State 装饰器定义的 currentTab、showAddDialog、showEditDialog 等状态变量,构成了驱动 UI 更新的单一数据源。当用户点击底部导航栏时,只需修改 currentTab 的值,框架就会自动重新计算并渲染对应的内容区域;当用户点击操作按钮时,只需修改弹窗相关的布尔状态,弹窗就会以动画的形式出现或消失。这种响应式的编程模型,让开发者从手动操作 DOM 的繁琐工作中解放出来,将更多的注意力集中在业务逻辑和用户体验的打磨上。同时,应用中的工具函数层(getAqiColor、getAqiLevel 等)与 UI 层的清晰分离,也体现了良好的代码分层思想。
在数据层面,当前使用的硬编码数据可以替换为真实的后端 API 接口,并通过 @State 结合异步请求状态(加载中、加载成功、加载失败)来丰富 UI 的反馈机制。在交互层面,实况天气模块的温度柱状图可以升级为更平滑的贝塞尔曲线图,七日预报模块可以加入手势左右滑动切换日期的交互,预警模块可以加入下拉刷新和推送通知的集成。在视觉层面,应用可以引入深色模式(Dark Mode)支持,通过定义一套暗色版的 ColorPalette 来适配夜间使用场景。在架构层面,随着功能的增长,可以考虑引入状态管理库(如 AppStorage 或自定义 Store)来管理跨组件共享的状态,进一步降低组件之间的耦合度。总之,这款应用已经搭建了一个非常扎实的基础框架,为后续的迭代演进预留了充足的空间。
更多推荐
所有评论(0)