文章目录
- 前言
- FormExtensionAbility:卡片的"幕后大脑"
- 配置卡片的元数据
- 卡片 UI:ArkTS 写法和普通页面不太一样
- 处理卡片的交互事件
- 数据更新:定时 + 事件驱动双保险
- 实战:日程卡片
- 几点踩坑经验
前言
桌面卡片(Widget)是 HarmonyOS 里特别讨喜的一个功能。用户不用打开 App 就能在桌面上看到关键信息——天气、日程、股票行情,一眼就够。对开发者来说,这也是一个很好的 App 曝光入口。
但卡片的开发跟普通页面有不少区别,特别是数据更新机制和交互方式。今天咱们从零搭一个天气卡片 + 日程卡片,把整个流程跑通。
FormExtensionAbility:卡片的"幕后大脑"
每个卡片背后都有一个FormExtensionAbility,它就是卡片的服务端。系统通过这个 Ability 来管理卡片的生命周期——创建、更新、销毁。
先看看它的核心回调:
import{formBindingData,FormExtensionAbility}from'@kit.FormKit';exportclassWeatherFormAbilityextendsFormExtensionAbility{// 卡片被添加到桌面时触发onAddForm(want:Want){constformId=want.parameters?.['formId']asstring;console.info(`卡片创建:${formId}`);returnthis.buildFormData(formId);}// 卡片需要更新时触发(定时/手动)onUpdateForm(formId:string){console.info(`卡片更新:${formId}`);returnthis.buildFormData(formId);}// 卡片被删除时触发onRemoveForm(formId:string){console.info(`卡片删除:${formId}`);}// 卡片从不可见变为可见时触发onCastToNormalForm(formId:string){returnthis.buildFormData(formId);}privatebuildFormData(formId:string):formBindingData.FormBindingData{constdata={temperature:'26°C',weather:'晴',city:'杭州',updateTime:newDate().toLocaleTimeString()};returnformBindingData.createFormBindingData(data);}}几个关键的生命周期节点要搞清楚:
onAddForm:用户把卡片拖到桌面上时调用,返回初始数据onUpdateForm:定时触发或手动触发的数据更新onRemoveForm:用户删除卡片时调用,做清理工作onCastToNormalForm:临时卡片转为常态卡片时调用(比如卡片从后台回前台)
配置卡片的元数据
卡片的大小、更新频率这些信息要在form_config.json里配置:
{"forms":[{"name":"WeatherForm","displayName":"天气卡片","description":"实时天气信息","src":"./ets/entryformability/EntryFormAbility.ets","uiSyntax":"arkts","window":{"designWidth":720,"autoDesignWidth":true},"colorMode":"auto","isDynamic":true,"isDefault":true,"updateEnabled":true,"scheduledUpdateTime":"08:00","updateDuration":1,"defaultDimension":"2*2","supportDimensions":["2*2","2*4","4*4"]}]}几个参数要注意:
isDynamic: true:启用动态卡片(ArkTS 卡片),支持交互updateEnabled: true:开启定时更新scheduledUpdateTime:定时更新时间,系统会在接近这个时间时触发更新supportDimensions:卡片支持的尺寸,多配几个让用户有选择
卡片 UI:ArkTS 写法和普通页面不太一样
卡片 UI 用的是 ArkTS,但跟普通页面有一些限制——不能用弹窗、不能用动画、组件类型也有限制。不过常用的 Text、Image、Column、Row、Button 都有。
来写天气卡片的 UI:
// WeatherFormWidget.ets@Entry@Componentstruct WeatherFormWidget{@StorageProp('temperature')temperature:string='--°C';@StorageProp('weather')weather:string='加载中';@StorageProp('city')city:string='';@StorageProp('updateTime')updateTime:string='';build(){Column(){Row(){Image($r('app.media.weather_sunny')).width(40).height(40)Column(){Text(this.city).fontSize(14).fontColor('#666666')Text(this.temperature).fontSize(36).fontWeight(FontWeight.Bold).fontColor('#333333')}.margin({left:12})}.width('100%')Row(){Text(this.weather).fontSize(14).fontColor('#888888')Blank()Text(`更新于${this.updateTime}`).fontSize(12).fontColor('#AAAAAA')}.width('100%').margin({top:12})// 按钮:点击刷新天气Button('刷新天气').fontSize(12).height(28).width(80).margin({top:8}).onClick(()=>{// 通过 postCardAction 发送事件给 FormExtensionAbilitypostCardAction(this,{action:'message',params:{action:'refresh'}});})}.width('100%').height('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(16)}}注意@StorageProp装饰器——它从formBindingData传过来的数据里取值。postCardAction则是卡片 UI 跟 FormExtensionAbility 通信的桥梁。
处理卡片的交互事件
卡片里点击按钮后,事件会传到 FormExtensionAbility 的onFormEvent回调:
exportclassWeatherFormAbilityextendsFormExtensionAbility{// ... 其他回调onFormEvent(formId:string,message:string){constparams=JSON.parse(message)asRecord<string,string>;if(params['action']==='refresh'){// 模拟获取最新天气数据constnewData=this.fetchWeatherData();constformData=formBindingData.createFormBindingData(newData);this.context.updateForm(formId,formData);}}privatefetchWeatherData():Record<string,string>{// 实际项目中这里应该调网络接口return{temperature:`${Math.floor(Math.random()*15+20)}°C`,weather:['晴','多云','阴','小雨'][Math.floor(Math.random()*4)],city:'杭州',updateTime:newDate().toLocaleTimeString()};}}卡片支持的交互方式有限,主要是通过postCardAction发送message事件。不能直接调异步接口——onFormEvent里不能写async/await,所以网络请求需要换个方式处理。
数据更新:定时 + 事件驱动双保险
卡片的数据更新有两种方式:
定时更新由系统控制,在form_config.json里配好scheduledUpdateTime就行,系统到点会调onUpdateForm。但别指望精确到分钟——系统会综合考虑电量和性能,可能延迟触发。
事件驱动适合需要实时更新的场景,比如日程提醒快到了:
// 在 EntryAbility 或其他地方主动触发卡片更新import{formProvider}from'@kit.FormKit';asyncfunctionrefreshAllForms(context:Context){try{// 获取所有活跃的卡片constformInfos=awaitformProvider.getFormsInfo(context);for(constformofformInfos){if(form.name==='WeatherForm'){constdata=formBindingData.createFormBindingData({temperature:'28°C',weather:'多云转晴',city:'杭州',updateTime:newDate().toLocaleTimeString()});awaitformProvider.updateForm(form.formId,data);}}}catch(err){console.error(`刷新卡片失败:${JSON.stringify(err)}`);}}实战:日程卡片
再写一个日程卡片,支持显示今日待办和点击标记完成:
// ScheduleFormWidget.etsinterfaceScheduleItem{id:string;title:string;time:string;done:boolean;}@Entry@Componentstruct ScheduleFormWidget{@StorageProp('schedules')schedulesJson:string='[]';getschedules():ScheduleItem[]{try{returnJSON.parse(this.schedulesJson)asScheduleItem[];}catch(e){return[];}}build(){Column(){Row(){Text('今日日程').fontSize(16).fontWeight(FontWeight.Bold)Blank()Text(`${this.schedules.filter(s=>!s.done).length}项待办`).fontSize(12).fontColor('#FF6B00')}.width('100%')ForEach(this.schedules.slice(0,3),(item:ScheduleItem)=>{Row(){Text(item.done?'✓':'○').fontSize(16).fontColor(item.done?'#4CAF50':'#999999').margin({right:8})Column(){Text(item.title).fontSize(14).fontColor(item.done?'#BBBBBB':'#333333').decoration({type:item.done?TextDecorationType.LineThrough:TextDecorationType.None})Text(item.time).fontSize(11).fontColor('#AAAAAA')}.alignItems(HorizontalAlign.Start).layoutWeight(1)}.width('100%').margin({top:8}).onClick(()=>{postCardAction(this,{action:'message',params:{action:'toggle',scheduleId:item.id}});})})}.width('100%').height('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(16)}}对应的 FormExtensionAbility 处理标记完成的事件:
exportclassScheduleFormAbilityextendsFormExtensionAbility{privateschedules:ScheduleItem[]=[{id:'1',title:'产品评审会',time:'09:30',done:false},{id:'2',title:'提交周报',time:'14:00',done:false},{id:'3',title:'健身',time:'18:30',done:true},];onAddForm(want:Want){returnthis.buildData();}onFormEvent(formId:string,message:string){constparams=JSON.parse(message)asRecord<string,string>;if(params['action']==='toggle'){constid=params['scheduleId'];consttarget=this.schedules.find(s=>s.id===id);if(target){target.done=!target.done;this.context.updateForm(formId,this.buildData());}}}privatebuildData():formBindingData.FormBindingData{returnformBindingData.createFormBindingData({schedules:JSON.stringify(this.schedules)});}}几点踩坑经验
卡片开发最容易踩的坑是数据传递方式。formBindingData只支持 JSON 可序列化的数据,复杂对象、函数什么的传不过去。我一开始想把整个数据对象传过去,结果卡片上显示的全是 undefined,排查了好久。
另一个坑是卡片 UI 的组件限制。不是所有 ArkUI 组件都能在卡片里用,像Dialog、Sheet这些弹窗类组件不行,Canvas也有限制。写之前先看看官方文档的支持列表,别写到一半发现组件不能用。
最后一个建议:卡片的数据尽量做本地缓存。onAddForm触发时如果网络不好,用户看到的就是空白卡片,体验很差。先展示缓存数据,后台再异步更新。