给一个极简日记 app 加桌面服务卡片(Form Widget)时撞到一个很矛盾的事:用户长按桌面添加卡片的瞬间,卡片内容应该已经是今天的日记进度 ------走数据库查 session + 本地数据这个过程不少于几十毫秒。但 onAddForm 这个回调要求同步返回 ,没法 await 异步 DB 查询。查了半天官方文档 + 其他开发者的坑,才摸出"先返回默认数据 → 异步 updateForm"这个标准解法。记录一下,顺便分享给有需要的朋友。
一、FormExtensionAbility 的同步/异步矛盾
鸿蒙的桌面服务卡片靠 FormExtensionAbility 实现。生命周期回调有几个:
typescript
import FormExtensionAbility from '@ohos.app.form.FormExtensionAbility'
import { formBindingData, formProvider } from '@kit.FormKit'
export default class EntryFormAbility extends FormExtensionAbility {
onAddForm(want: Want): formBindingData.FormBindingData {
// 👈 必须同步返回一个 FormBindingData
// 用户添加卡片的瞬间触发
}
onUpdateForm(formId: string): void {
// 系统刷新或主包主动触发
}
onRemoveForm(formId: string): void {
// 用户移除卡片
}
}
关键矛盾:
onAddForm 是同步函数 ,返回类型是 formBindingData.FormBindingData(不是 Promise)。意味着不能在里面 await DB 查询。
但真实的卡片数据往往需要异步才能拿到:
relationalStore的 query 是 Promisepreferences的get是 PromisefileIo读文件也可以是 Promise
朴素尝试:
typescript
// 这写法不对
async onAddForm(want: Want) { // ❌ 编译器会报错或 runtime 行为未定义
const data = await this.entryDao.getByDate(today)
return formBindingData.createFormBindingData(JSON.stringify(data))
}
就算编译器勉强让 async 通过,鸿蒙系统也不会等------卡片立刻需要内容显示在桌面上。
二、标准解法:先返回默认数据 + 异步 updateForm
真正的做法是两段:
typescript
export default class EntryFormAbility extends FormExtensionAbility {
private entryDao: EntryDao = new EntryDao()
private wordStatsDao: WordStatsDao = new WordStatsDao()
onAddForm(want: Want): formBindingData.FormBindingData {
// 第一段:立刻返回默认数据,让卡片有东西显示
const defaultData: FormData = {
date: '',
progress: 0,
isComplete: false,
words: [] as string[],
recentWords: [] as string[],
}
const formData = formBindingData.createFormBindingData(JSON.stringify(defaultData))
// 第二段:异步跑一次 updateForm,拉真实数据
const formId = want.parameters?.['ohos.extra.param.key.form_identity'] as string
if (formId) {
this.updateFormAsync(formId) // 👈 不 await,fire and forget
}
return formData // ← 同步返回默认数据
}
onUpdateForm(formId: string): void {
this.updateFormAsync(formId)
}
private async updateFormAsync(formId: string): Promise<void> {
try {
await DbHelper.getInstance().init(this.context)
const data = await this.getFormData()
const bindingData = formBindingData.createFormBindingData(JSON.stringify(data))
await formProvider.updateForm(formId, bindingData)
} catch (e) {
// 异步失败不致命------下个 update cycle 还会再跑
}
}
private async getFormData(): Promise<FormData> {
const today = DateUtils.today()
const entry = await this.entryDao.getByDate(today)
// ... 真实的 DB 查询
}
}
流程:
arduino
用户长按桌面 → 选"添加卡片" → 系统调 onAddForm
↓
onAddForm 立刻返回默认 FormData(空字段,progress=0)
↓
卡片显示在桌面(显示的是默认数据,有点"占位态")
↓
updateFormAsync 开始跑(在同一个 FormExtensionAbility 进程里)
↓
DB 查到今日数据
↓
formProvider.updateForm(formId, newData) → 卡片内容刷新
用户看到的是:"添加卡片时它是空的 → 一两秒后内容自动填上"。可以接受。
为什么不用 Loading 态 :桌面卡片的尺寸很小(2x2 / 2x4),没有 Loading UI 的空间。默认数据直接是"占位态"(空日期、progress=0、空词列表),用户看到觉得是"今天还没写"而不是"加载中"。
三、从 want.parameters 拿 formId
onAddForm 的参数 want 里带了 formId,需要从 parameters 里提取:
typescript
const formId = want.parameters?.['ohos.extra.param.key.form_identity'] as string
几个坑:
key 是常量字符串 'ohos.extra.param.key.form_identity' 。全写、全小写、带点号。鸿蒙的常量 key 都是这种长字符串格式。可以用 formInfo.FormParam.IDENTITY_KEY 这个常量代替裸字符串(更可读,但本质一样)。
parameters 可能是 undefined 。用 ?. 可选链保护。真实场景下 parameters 一般不会缺,但真缺了(API bug / 边界情况)不要让整个 app 崩。
as string 强转 。want.parameters 的类型是 Record<string, Object>,取出来的值类型是 Object。要转成 string 才能传给 formProvider。
formId 可能是空 。拿到空 formId 后调 updateForm 会报错------所以上面代码有 if (formId) 保护。
四、formProvider.updateForm 刷新
异步跑完数据查询,调 formProvider.updateForm 把新数据推给卡片:
typescript
import { formProvider } from '@kit.FormKit'
await formProvider.updateForm(formId, bindingData)
要点:
formId 和 onAddForm 时拿到的是同一个。系统会把这个数据推给 formId 对应的卡片实例。如果用户有多个卡片(同一个 app,不同 widget 实例),每个 formId 不一样。
updateForm 是异步的,必须 await。不 await 的话,updateForm 还没完就被 GC 掉的边界 case 有可能数据漏推。
失败不致命 :try/catch 吞错。桌面系统有自己的 update cycle(默认 30 分钟一次),这次没推成功下次系统还会再触发 onUpdateForm。
五、onUpdateForm 复用同一个 updateFormAsync
系统会定期调 onUpdateForm(鸿蒙的 default refresh period 是 30 分钟)。或者主包想主动刷新卡片,可以用 formProvider.requestForm(formId) 主动触发。
无论哪种触发,最终都走到同一个 updateFormAsync:
typescript
onUpdateForm(formId: string): void {
this.updateFormAsync(formId)
}
优点:onAddForm 和 onUpdateForm 共享同一套数据查询逻辑,不用写两遍。
注意 :onUpdateForm 的签名也是 同步 void ,不能直接 async。做法还是同步调用一个异步函数但不 await。
六、LocalStorage + @LocalStorageProp 做卡片 UI
拿到 bindingData 之后,卡片 UI 怎么消费?用 LocalStorage + @LocalStorageProp:
卡片 UI(在 widget/pages/Widget2x2.ets):
typescript
@Entry
@Component
struct Widget2x2 {
@LocalStorageProp('date') date: string = ''
@LocalStorageProp('progress') progress: number = 0
@LocalStorageProp('isComplete') isComplete: boolean = false
@LocalStorageProp('words') words: string[] = []
build() {
Column() {
Text(this.date).fontSize(14)
if (this.isComplete) {
Row() {
ForEach(this.words, (word: string) => {
Text(word).fontSize(12)
})
}
} else {
Text(`${this.progress}/6`).fontSize(32)
}
}
}
}
@LocalStorageProp 是 ArkUI 把 formBindingData 里的字段反应式绑定到卡片 UI 的。字段名要和 FormBindingData 的 JSON key 对齐。
formProvider.updateForm 推新数据过来时,@LocalStorageProp 自动触发 UI 重渲染------卡片内容无缝刷新。
踩坑清单
onAddForm必须同步返回 FormBindingData,不能 async- 标准解法:同步返回默认数据 + 异步
updateFormAsync+formProvider.updateForm - 卡片的 "占位态"直接设计成"真实的空状态"(空日期、progress=0),不要搞 Loading UI(卡片尺寸不够)
- formId 从
want.parameters?.['ohos.extra.param.key.form_identity']提取,可选链 + as string 转 updateFormAsync用 try/catch 吞错,失败也不致命(系统有 30 分钟 update cycle 兜底)- onAddForm / onUpdateForm 共享同一个 updateFormAsync 函数
- 卡片 UI 用 @LocalStorageProp 绑定 FormBindingData 的字段,字段名要对齐 JSON key
尾声
这个 pattern 的本质是同步/异步接口的桥接。鸿蒙的卡片系统设计时考虑的是"系统桌面要立刻有内容显示",所以逼着应用必须同步返回数据。但真实应用的数据往往是异步的(在数据库/网络里)。
类似矛盾在初始化场景里也常见:
- UIAbility 的
onCreate要同步 OK,但真实数据加载是异步的 @Entry的 build() 要同步出一个 UI,但真实数据从 Store 异步拉
万能解法 :先给占位态同步返回,然后异步触发更新。接受"卡片有半秒钟是空的"这个妥协,比强行让系统等异步要务实得多。