鸿蒙万能卡片开发实战:手把手实现一个天气服务卡片,支持多尺寸+动态刷新+跨设备
鸿蒙NEXT开发实战系列| 第27篇 | 实战篇 适合人群了解元服务基础的开发者 ⏰阅读时间约20分钟 | 开发环境DevEco Studio 5.0上一篇鸿蒙NEXT开发实战系列-26-实战篇-跨设备分布式数据同步实战下一篇鸿蒙NEXT开发实战系列-28-实战篇-新闻资讯卡片开发实战前言万能卡片是 HarmonyOS 最具特色的功能之一它可以让用户在桌面上直接查看应用的关键信息无需打开应用。今天我们来实战开发一个天气服务卡片这个项目将带你掌握✅ 服务卡片的完整配置流程✅ FormExtensionAbility 生命周期管理✅ 多尺寸卡片适配2×2 和 2×4✅ 定时刷新与数据绑定✅ 跨设备数据同步一、项目结构总览1.1 创建元服务项目首先在 DevEco Studio 中创建一个新的元服务项目选择Empty Ability模板。WeatherCard/ ├── entry/ │ └── src/ │ └── main/ │ ├── ets/ │ │ ├── entryability/ │ │ │ └── EntryAbility.ets # 入口Ability │ │ ├── pages/ │ │ │ └── Index.ets # 主页面 │ │ ├── weathercard/ │ │ │ ├── WeatherCard2x2.ets # 2×2卡片 │ │ │ ├── WeatherCard2x4.ets # 2×4卡片 │ │ │ └── WeatherFormAbility.ets # FormExtensionAbility │ │ ├── services/ │ │ │ └── WeatherService.ets # 天气数据服务 │ │ └── common/ │ │ └── WeatherData.ets # 数据模型 │ ├── resources/ │ │ └── base/ │ │ ├── profile/ │ │ │ ├── form_config.json # 卡片配置 │ │ │ └── weather_config.json # 天气配置 │ │ └── media/ # 图标资源 │ └── module.json5 # 模块配置 └── oh-package.json51.2 核心概念说明在开始编码前先了解几个核心概念概念说明FormExtensionAbility卡片扩展能力管理卡片生命周期form_config.json卡片配置文件定义尺寸、刷新规则等formBindingData卡片数据绑定用于向卡片传递数据Want用于启动卡片和传递参数二、卡片配置详解2.1 配置 form_config.json在resources/base/profile/目录下创建form_config.json{ forms: [ { name: weather_2x2, description: 天气卡片 2×2, src: ./ets/weathercard/WeatherCard2x2.ets, uiSyntax: arkts, window: { designWidth: 720, autoDesignWidth: true }, isDefault: true, colorMode: auto, supportDimensions: [2*2], defaultDimension: 2*2, updateEnabled: true, scheduledUpdateTime: 30:00, updateDuration: 30, formConfigAbility: pages/Index, metadata: { customData: weather_2x2_custom_data } }, { name: weather_2x4, description: 天气卡片 2×4, src: ./ets/weathercard/WeatherCard2x4.ets, uiSyntax: arkts, window: { designWidth: 720, autoDesignWidth: true }, isDefault: false, colorMode: auto, supportDimensions: [2*4], defaultDimension: 2*4, updateEnabled: true, scheduledUpdateTime: 30:00, updateDuration: 30, formConfigAbility: pages/Index, metadata: { customData: weather_2x4_custom_data } } ] }配置项说明name: 卡片名称需要在代码中引用src: 卡片 UI 页面路径supportDimensions: 支持的尺寸2*2表示 2 行 2 列updateDuration: 刷新间隔单位为分钟30分钟scheduledUpdateTime: 定时刷新时间点2.2 配置 module.json5在module.json5中注册 FormExtensionAbility{ module: { name: entry, type: entry, description: $string:module_desc, mainElement: EntryAbility, deviceTypes: [phone, tablet, 2in1], deliveryWithInstall: true, installationFree: true, pages: $profile:main_pages, abilities: [ { name: EntryAbility, srcEntry: ./ets/entryability/EntryAbility.ets, description: $string:EntryAbility_desc, icon: $media:icon, label: $string:EntryAbility_label, startWindowIcon: $media:icon, startWindowBackground: $color:start_window_background, exported: true, skills: [ { entities: [entity.system.home], actions: [action.system.home] } ] }, { name: WeatherFormAbility, srcEntry: ./ets/weathercard/WeatherFormAbility.ets, description: 天气服务卡片, icon: $media:icon, label: 天气卡片, type: form, metadata: [ { name: ohos.extension.form, resource: $profile:form_config } ] } ], requestPermissions: [ { name: ohos.permission.INTERNET } ] } }三、数据模型与服务3.1 定义天气数据模型创建ets/common/WeatherData.ets// 天气数据模型 export interface WeatherData { // 城市名称 cityName: string // 当前温度 temperature: number // 天气状况 condition: string // 天气图标 icon: string // 湿度 humidity: number // 风速 windSpeed: string // 最高温 highTemp: number // 最低温 lowTemp: number // 更新时间 updateTime: string // 未来几天预报 forecast: ForecastData[] } export interface ForecastData { day: string icon: string highTemp: number lowTemp: number } // 卡片数据结构 export interface CardData { // 基础数据 title: string detail: string // 天气相关数据 cityName: string temperature: string condition: string humidity: string windSpeed: string highTemp: string lowTemp: string updateTime: string forecast: ForecastData[] }3.2 天气数据服务创建ets/services/WeatherService.etsimport { WeatherData, ForecastData, CardData } from ../common/WeatherData import { preferences } from kit.ArkData import { distributedKVStore } from kit.DistributedService const STORE_NAME WeatherStore const KEY_WEATHER_DATA weather_data export class WeatherService { private static instance: WeatherService | null null private kvStore: distributedKVStore.SingleKVStore | null null // 单例模式 static getInstance(): WeatherService { if (!WeatherService.instance) { WeatherService.instance new WeatherService() } return WeatherService.instance } // 初始化分布式数据库 async initKVStore(context: Context): Promisevoid { try { const kvManager distributedKVStore.createKVManager({ bundleName: com.example.weathercard, context: context }) const options: distributedKVStore.Options { createIfMissing: true, encrypt: false, backup: false, autoSync: true, kvStoreType: distributedKVStore.KVStoreType.SINGLE_VERSION } this.kvStore await kvManager.getKVStore(STORE_NAME, options) } catch (err) { console.error(Failed to create KVStore: ${err}) } } // 获取天气数据 async getWeatherData(): PromiseWeatherData { // 模拟获取天气数据 // 实际项目中应调用天气 API const weatherData: WeatherData { cityName: 北京, temperature: 25, condition: 晴, icon: sunny, humidity: 60, windSpeed: 3级, highTemp: 28, lowTemp: 18, updateTime: this.formatTime(new Date()), forecast: [ { day: 明天, icon: cloudy, highTemp: 26, lowTemp: 17 }, { day: 后天, icon: rainy, highTemp: 22, lowTemp: 15 }, { day: 大后天, icon: sunny, highTemp: 27, lowTemp: 19 } ] } // 保存到本地存储 await this.saveWeatherData(weatherData) return weatherData } // 保存天气数据 private async saveWeatherData(data: WeatherData): Promisevoid { try { // 保存到 Preferences const context getContext(this) as Context const prefs await preferences.getPreferences(context, weather_prefs) await prefs.put(KEY_WEATHER_DATA, JSON.stringify(data)) await prefs.flush() // 同步到分布式数据库 if (this.kvStore) { await this.kvStore.put(KEY_WEATHER_DATA, JSON.stringify(data)) } } catch (err) { console.error(Failed to save weather data: ${err}) } } // 获取卡片数据 getCardData(data: WeatherData, dimension: string): CardData { if (dimension 2*2) { return { title: data.cityName, detail: ${data.temperature}° ${data.condition}, cityName: data.cityName, temperature: ${data.temperature}°, condition: data.condition, humidity: ${data.humidity}%, windSpeed: data.windSpeed, highTemp: ${data.highTemp}°, lowTemp: ${data.lowTemp}°, updateTime: data.updateTime, forecast: data.forecast } } else { return { title: ${data.cityName} ${data.temperature}°, detail: data.condition, cityName: data.cityName, temperature: ${data.temperature}°, condition: data.condition, humidity: ${data.humidity}%, windSpeed: data.windSpeed, highTemp: ${data.highTemp}°, lowTemp: ${data.lowTemp}°, updateTime: data.updateTime, forecast: data.forecast } } } // 格式化时间 private formatTime(date: Date): string { const hours date.getHours().toString().padStart(2, 0) const minutes date.getMinutes().toString().padStart(2, 0) return ${hours}:${minutes} } }四、FormExtensionAbility 实现4.1 创建卡片扩展能力创建ets/weathercard/WeatherFormAbility.etsimport { formBindingData, formInfo, formProvider } from kit.FormKit import { Want } from kit.AbilityKit import { WeatherService } from ../services/WeatherService import { WeatherData, CardData } from ../common/WeatherData const TAG WeatherFormAbility export default class WeatherFormAbility extends FormExtensionAbility { // 卡片被创建时调用 onCreate(want: Want): formBindingData.FormBindingData { console.info(TAG, Form onCreate) const formId want.parameters?.[formInfo.FormParam.IDENTITY_KEY] as string const formName want.parameters?.[formInfo.FormParam.NAME_KEY] as string const dimension want.parameters?.[formInfo.FormParam.DIMENSION_KEY] as number console.info(TAG, Form created: ${formId}, name: ${formName}, dimension: ${dimension}) // 返回初始数据 return this.getDefaultFormData(formName, dimension) } // 卡片被销毁时调用 onDestroy(formId: string): void { console.info(TAG, Form onDestroy: ${formId}) } // 卡片可见时调用 onVisibilityChange(newStatus: Recordstring, number): void { console.info(TAG, Form visibility changed) } // 卡片事件被触发时调用 onFormEvent(formId: string, message: string): void { console.info(TAG, Form event: ${formId}, message: ${message}) // 处理卡片事件如点击刷新按钮 if (message refresh) { this.refreshFormData(formId) } } // 卡片更新时调用 onUpdate(formId: string): void { console.info(TAG, Form onUpdate: ${formId}) this.refreshFormData(formId) } // 获取默认表单数据 private getDefaultFormData(formName: string, dimension: number): formBindingData.FormBindingData { const dimStr dimension 1 ? 2*2 : 2*4 const data: CardData { title: 加载中..., detail: 获取天气信息, cityName: 加载中, temperature: --°, condition: 获取中, humidity: --%, windSpeed: --, highTemp: --°, lowTemp: --°, updateTime: --:--, forecast: [] } return formBindingData.createFormBindingData(data) } // 刷新表单数据 private async refreshFormData(formId: string): Promisevoid { try { const weatherService WeatherService.getInstance() const weatherData await weatherService.getWeatherData() const formInfoObj await formProvider.getFormInfo(formId) const dimension formInfoObj?.formConfigProperties?.supportDimensions?.[0] || 2*2 const cardData weatherService.getCardData(weatherData, dimension) await formProvider.updateForm(formId, { data: JSON.stringify(cardData) }) console.info(TAG, Form updated: ${formId}) } catch (err) { console.error(TAG, Failed to update form: ${err}) } } }五、卡片 UI 实现5.1 2×2 天气卡片创建ets/weathercard/WeatherCard2x2.etsimport { formBindingData } from kit.FormKit import { CardData } from ../common/WeatherData Entry Component struct WeatherCard2x2 { State cityName: string 加载中 State temperature: string --° State condition: string 获取中 State humidity: string --% State highTemp: string --° State lowTemp: string --° State updateTime: string --:-- State icon: string ☀️ aboutToAppear() { // 监听卡片数据变化 formBindingData.onFormDataChanged(this.onDataChanged.bind(this)) } onDataChanged(data: CardData) { if (data) { this.cityName data.cityName || 未知 this.temperature data.temperature || --° this.condition data.condition || 未知 this.humidity data.humidity || --% this.highTemp data.highTemp || --° this.lowTemp data.lowTemp || --° this.updateTime data.updateTime || --:-- this.icon this.getWeatherIcon(data.condition) } } getWeatherIcon(condition: string): string { switch (condition) { case 晴: return ☀️ case 多云: return ⛅ case 阴: return ☁️ case 雨: return ️ case 雪: return ❄️ default: return ️ } } build() { Column() { // 城市名称和温度 Row() { Text(this.cityName) .fontSize(14) .fontColor(#FFFFFF) .opacity(0.9) Blank() Text(this.updateTime) .fontSize(10) .fontColor(#FFFFFF) .opacity(0.7) } .width(100%) .padding({ left: 12, right: 12, top: 12 }) // 主要温度显示 Row() { Text(this.icon) .fontSize(36) Column() { Text(this.temperature) .fontSize(42) .fontWeight(FontWeight.Light) .fontColor(#FFFFFF) Text(this.condition) .fontSize(12) .fontColor(#FFFFFF) .opacity(0.9) } .alignItems(HorizontalAlign.Start) .margin({ left: 8 }) } .width(100%) .padding({ left: 12, right: 12, top: 8 }) // 湿度和高低温 Row() { Text( ${this.humidity}) .fontSize(11) .fontColor(#FFFFFF) .opacity(0.8) Blank() Text(${this.highTemp}/${this.lowTemp}) .fontSize(11) .fontColor(#FFFFFF) .opacity(0.8) } .width(100%) .padding({ left: 12, right: 12, bottom: 12 }) } .width(100%) .height(100%) .backgroundColor(#4FC3F7) .borderRadius(16) .alignItems(HorizontalAlign.Start) } }5.2 2×4 天气卡片创建ets/weathercard/WeatherCard2x4.etsimport { formBindingData } from kit.FormKit import { CardData, ForecastData } from ../common/WeatherData Entry Component struct WeatherCard2x4 { State cityName: string 加载中 State temperature: string --° State condition: string 获取中 State humidity: string --% State windSpeed: string -- State highTemp: string --° State lowTemp: string --° State updateTime: string --:-- State icon: string ☀️ State forecast: ForecastData[] [] aboutToAppear() { formBindingData.onFormDataChanged(this.onDataChanged.bind(this)) } onDataChanged(data: CardData) { if (data) { this.cityName data.cityName || 未知 this.temperature data.temperature || --° this.condition data.condition || 未知 this.humidity data.humidity || --% this.windSpeed data.windSpeed || -- this.highTemp data.highTemp || --° this.lowTemp data.lowTemp || --° this.updateTime data.updateTime || --:-- this.icon this.getWeatherIcon(data.condition) this.forecast data.forecast || [] } } getWeatherIcon(condition: string): string { switch (condition) { case 晴: return ☀️ case 多云: return ⛅ case 阴: return ☁️ case 雨: return ️ case 雪: return ❄️ default: return ️ } } getForecastIcon(condition: string): string { switch (condition) { case sunny: return ☀️ case cloudy: return ⛅ case rainy: return ️ default: return ️ } } Builder ForecastItem(item: ForecastData) { Column() { Text(item.day) .fontSize(11) .fontColor(#FFFFFF) .opacity(0.9) Text(this.getForecastIcon(item.icon)) .fontSize(20) .margin({ top: 4, bottom: 4 }) Text(${item.highTemp}°) .fontSize(11) .fontColor(#FFFFFF) .fontWeight(FontWeight.Medium) Text(${item.lowTemp}°) .fontSize(11) .fontColor(#FFFFFF) .opacity(0.7) } .width(33%) .alignItems(HorizontalAlign.Center) } build() { Column() { // 顶部区域城市和更新时间 Row() { Text(this.cityName) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(#FFFFFF) Blank() Text(更新于 ${this.updateTime}) .fontSize(10) .fontColor(#FFFFFF) .opacity(0.7) } .width(100%) .padding({ left: 16, right: 16, top: 16 }) // 中部区域天气详情 Row() { Column() { Text(this.icon) .fontSize(48) Text(this.condition) .fontSize(12) .fontColor(#FFFFFF) .margin({ top: 4 }) } .alignItems(HorizontalAlign.Center) .width(30%) Column() { Text(this.temperature) .fontSize(56) .fontWeight(FontWeight.Light) .fontColor(#FFFFFF) Row() { Text( ${this.humidity}) .fontSize(11) .fontColor(#FFFFFF) .opacity(0.8) Text( ) Text( ${this.windSpeed}) .fontSize(11) .fontColor(#FFFFFF) .opacity(0.8) } .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) .width(40%) Column() { Text(↑ ${this.highTemp}) .fontSize(12) .fontColor(#FFFFFF) .fontWeight(FontWeight.Medium) Text(↓ ${this.lowTemp}) .fontSize(12) .fontColor(#FFFFFF) .opacity(0.8) .margin({ top: 8 }) } .width(30%) .alignItems(HorizontalAlign.Center) } .width(100%) .padding({ left: 16, right: 16, top: 12 }) // 分隔线 Divider() .color(#FFFFFF) .opacity(0.3) .margin({ left: 16, right: 16, top: 12 }) // 底部区域未来天气预报 Row() { ForEach(this.forecast, (item: ForecastData) { this.ForecastItem(item) }, (item: ForecastData) item.day) } .width(100%) .padding({ left: 16, right: 16, top: 12, bottom: 16 }) } .width(100%) .height(100%) .linearGradient({ direction: GradientDirection.Bottom, colors: [[#4FC3F7, 0.0], [#29B6F6, 1.0]] }) .borderRadius(16) .alignItems(HorizontalAlign.Start) } }六、主页面与卡片管理6.1 入口页面创建ets/pages/Index.etsimport { formProvider } from kit.FormKit import { abilityDelegatorRegistry } from kit.TestKit import { promptAction } from kit.ArkUI Entry Component struct Index { State message: string 天气服务卡片示例 build() { Column() { Text(this.message) .fontSize(28) .fontWeight(FontWeight.Bold) .margin({ bottom: 20 }) // 添加2×2卡片按钮 Button(添加 2×2 天气卡片) .width(80%) .height(50) .fontSize(16) .margin({ bottom: 16 }) .onClick(() { this.addForm(weather_2x2) }) // 添加2×4卡片按钮 Button(添加 2×4 天气卡片) .width(80%) .height(50) .fontSize(16) .margin({ bottom: 16 }) .onClick(() { this.addForm(weather_2x4) }) // 刷新所有卡片按钮 Button(刷新所有卡片) .width(80%) .height(50) .fontSize(16) .backgroundColor(#4FC3F7) .onClick(() { this.refreshAllForms() }) Text(提示点击按钮添加天气卡片到桌面) .fontSize(12) .fontColor(#666666) .margin({ top: 30 }) } .width(100%) .height(100%) .justifyContent(FlexAlign.Center) .backgroundColor(#F5F5F5) } // 添加表单卡片 async addForm(formName: string) { try { const formId await formProvider.requestForm({ want: { bundleName: com.example.weathercard, abilityName: WeatherFormAbility, parameters: { ohos.extra.param.key.form_name: formName } } }) promptAction.showToast({ message: 卡片已添加到桌面 }) } catch (err) { console.error(Failed to add form: ${err}) promptAction.showToast({ message: 添加卡片失败 }) } } // 刷新所有表单 async refreshAllForms() { try { // 获取所有表单ID const formIds await formProvider.getAllFormsInfo() for (const formInfo of formIds) { await formProvider.updateForm(formInfo.formId, { data: JSON.stringify({}) }) } promptAction.showToast({ message: 卡片已刷新 }) } catch (err) { console.error(Failed to refresh forms: ${err}) promptAction.showToast({ message: 刷新失败 }) } } }七、定时刷新机制7.1 自动刷新配置HarmonyOS 提供了两种定时刷新方式方式一固定间隔刷新在form_config.json中配置updateDuration单位分钟{ updateDuration: 30 }方式二指定时间点刷新在form_config.json中配置scheduledUpdateTime{ scheduledUpdateTime: 08:00 }7.2 手动刷新实现在卡片页面中添加刷新按钮// 在卡片 UI 中添加刷新按钮 Row() { Text(更新于 ${this.updateTime}) .fontSize(10) .fontColor(#FFFFFF) .opacity(0.7) // 刷新按钮 Text() .fontSize(14) .onClick(() { formBindingData.sendFormMessage(this.formId, refresh) }) }7.3 后台定时任务创建后台服务定期更新天气数据// 在 WeatherFormAbility 中添加定时任务 import { backgroundTaskManager } from kit.BackgroundTasksKit // 启动后台任务 async startBackgroundTask() { const wantAgentInfo: backgroundTaskManager.WantAgentInfo { wants: [{ bundleName: com.example.weathercard, abilityName: WeatherFormAbility }], actionType: backgroundTaskManager.OperationType.START_SERVICE, requestCode: 0, actionFlags: [backgroundTaskManager.WantAgentFlags.UPDATE_PRESENT_FLAG] } const wantAgent await wantAgent.getWantAgent(wantAgentInfo) const delayTime 30 * 60 * 1000 // 30分钟 await backgroundTaskManager.startBackgroundRunning(this.context, { title: 天气数据更新, wantAgent: wantAgent }) }八、跨设备数据同步8.1 分布式数据同步利用分布式 KVStore 实现跨设备数据同步import { distributedKVStore } from kit.DistributedService // 监听数据变化 kvStore.on(dataChange, distributedKVStore.SubscribeType.SUBSCRIBE_TYPE_ALL, (data) { console.info(Data changed: ${JSON.stringify(data)}) // 更新卡片数据 this.refreshFormData(data) }) // 跨设备同步数据 async syncDataToOtherDevices(data: WeatherData) { if (this.kvStore) { await this.kvStore.put(KEY_WEATHER_DATA, JSON.stringify(data)) // 数据会自动同步到其他设备 } }8.2 设备信息获取import { deviceManager } from kit.DistributedService // 获取当前设备信息 const deviceInfos await deviceManager.getAvailableDeviceListSync() for (const device of deviceInfos) { console.info(Device: ${device.deviceName}, ID: ${device.networkId}) }九、常见问题与解决方案9.1 卡片不显示问题添加卡片后桌面上没有显示解决方案检查module.json5中 FormExtensionAbility 配置是否正确确认form_config.json路径和配置是否正确检查卡片页面路径是否正确9.2 数据不更新问题卡片数据不刷新解决方案确认updateDuration或scheduledUpdateTime已配置检查网络权限ohos.permission.INTERNET是否添加验证formProvider.updateForm是否正确调用9.3 多尺寸适配问题问题不同尺寸卡片显示异常解决方案使用百分比布局而非固定尺寸使用dimension参数判断当前卡片尺寸针对不同尺寸编写独立的 UI 组件十、总结通过本教程你已经掌握了卡片配置form_config.json的完整配置生命周期管理FormExtensionAbility 的各个回调多尺寸适配2×2 和 2×4 两种尺寸的实现数据绑定使用formBindingData传递数据定时刷新自动和手动两种刷新方式跨设备同步利用分布式 KVStore 同步数据项目源码完整的天气卡片项目代码已在文中给出可直接复用到你的项目中。下一篇预告我们将开发一个新闻资讯卡片实现图文混排、滑动列表等高级功能。系列文章推荐鸿蒙NEXT开发实战系列-25-实战篇-元服务入门与环境搭建鸿蒙NEXT开发实战系列-26-实战篇-跨设备分布式数据同步实战鸿蒙NEXT开发实战系列-28-实战篇-新闻资讯卡片开发实战鸿蒙NEXT开发实战系列-29-实战篇-健康数据卡片开发实战标签服务卡片天气卡片FormKit鸿蒙开发万能卡片ArkUI跨设备分布式提示如果你在开发过程中遇到问题欢迎在评论区留言交流