HarmonyOS 动态卡片(form)踩坑合集:从 H5 加桌到 fd 生命周期
这篇把最近做动态卡片踩的 15 个坑归档一下,从布局崩溃、fd 生命周期、跨进程数据,到 form_config 限制、安全加固,基本每个点我都真崩过/真查过文档/真找过 issue。写出来给后面做同类需求的同学避坑。 最终落地是单 widget 条目 + LocalStorage 数据分支渲染(理由见陷阱 9)。数据流: 卡片进程 经验:复杂布局不要玩花的,用 Stack + 绝对 调用端: FormExtension 里读 解决:业务数据走 点号 / 冒号 / 连字符全不行,只能字母数字下划线。 现象:加卡 25 秒后图片消失。 原因: 解决: Preferences 每个进程有自己的内存缓存。主进程写完 flush 到磁盘,但 FormExtension(独立进程)的缓存没变, 这个坑排查了好几天,主进程确认写入了但 form 进程死活读不到,最后翻 issue 才发现缓存问题。 修复:实例字段持有 fd,下次重建前 close 旧的,系统 callback 触发后统一 close(那时 fd 已被 IPC dup 到 form 进程,主进程可以释放): 这个也排查了好久。CDN 返回的 Content-Type 是 卡片点击后跳转 URL 是 H5 传入并持久化到 Preferences 的。如果不校验 scheme, 纵深防御,不依赖下游 WebView 自己做白名单。 需求:动态卡片没数据来源时不应让用户从系统小组件选择器裸加。 试过的招: 最终:合并回单 widget 条目,卡片组件内按 LocalStorage 是否有业务数据分支: 桌面卡片下方会显示一行"app 名字"标签(来自 想改这个标签只能动 app 名字本身,但那会影响桌面图标、应用商店展示等所有地方,一般不动。 hvigor 把所有 @Entry 文件打进同一作用域。解决:不同 @Entry 用不同常量前缀,或合并成一个 @Entry。 H5 传的图比例不可控 → 用 项目里的 直接把对象字面量塞进函数参数不行,即便有 重点:HAP 更新不会自动重载卡片渲染进程,旧卡片一直跑旧代码。由轻到重: 强制重启卡片渲染进程(推荐) 系统自动拉起新进程,加载最新 HAP。已有卡片会重新走 强制重启自己的 atomic service 进程(FormExtension 代码改了时) 删卡重加(最保险) 桌面长按卡片 → 删除 → 从入口重新添加。 PowerShell 重定向的 hilog 是 UTF-16 编码,Python 读要显式 decode: 鸿蒙动态卡片能做,但坑比想象多: 大部分坑官方文档里要么没写要么一笔带过,只能靠踩。希望对你有帮助。 如果有更多经验或纠错欢迎评论交流。HarmonyOS 动态卡片(form)踩坑合集:从 H5 加桌到 fd 生命周期
环境:HarmonyOS 5.0 / API 12 / ArkTS / 原子化服务
场景:H5 活动页点击按钮 → 底部弹 Dialog → 加一张带网络图+标题+logo+跳转链接的卡片到桌面;点击卡片回跳到 H5 页
时间:2026-04目录
一、整体架构
H5 页面
│ window.jsBridge.addDesktopCard(JSON.stringify(payload))
▼
承载 WebView 的页面.handleAddDesktopCard
│ 下载 imageUrl + logoUrl 到沙盒(卡片进程无法直接加载 http)
▼
底部加卡 Dialog
│ AddFormMenuItem.formBindingData 注入 LocalStorage + formImages 传 fd
│ callback → 写 Preferences(跨进程持久化)
▼
系统 AddFormMenuItem 处理 → 创建 form
▼
FormExtensionAbility.onAddForm(form 进程)
│ 读 Preferences → openSync 拿新 fd → formProvider.updateForm
▼
卡片组件(com.ohos.formrenderservice 渲染进程)
│ @LocalStorageProp 订阅 title / imageName / logoName / subTitle / jumpUrl
│ Stack + position 绝对定位渲染
│ Image('memory://imageName') / Image('memory://logoName')
▼
用户点击卡片 → postCardAction / FormLink(action=router)
▼
主 UIAbility.onNewWant → 路由控制器
│ 校验 jumpUrl scheme 白名单(http/https)
│ 路由到 WebView 页面打开 jumpUrl二、15 个核心陷阱
1. 卡片渲染进程的布局组件禁区(会崩整个进程)
com.ohos.formrenderservice 跑的是裁剪版 ArkTS 运行时。有些组件一旦踩到会崩整个进程,所有卡片(含别的 app 的)一起变白。组件/用法 表现 结论 Blank().layoutWeight(1)进程崩 → 所有卡片白屏 ❌ 禁用 Flex({ justifyContent: FlexAlign.End })进程崩 ❌ 禁用 Stack.alignContent(Alignment.Bottom)(modifier / 参数式)不按预期定位(表现像 TopStart) ⚠️ 不稳定 Column + layoutWeight(1)短卡片上被压成 0 高 ⚠️ 谨慎 Stack() + child.position({x, y}) 绝对定位✅ 完全按坐标 ✅ 推荐 position 最稳。坐标从 onSizeChange 拿到的 cardHeight / cardWidth 按比例算。2. AddFormMenuItem.parameters 的自定义 key 被系统过滤
AddFormMenuItem({
bundleName: '...',
abilityName: 'EntryFormAbility',
parameters: {
// 这些自定义 key 会被系统过滤掉
my_card_title: 'xxx',
my_image_url: 'https://...'
}
}, 'id', { ... })want.parameters 只有 ohos.extra.param.key.* 系统字段,自定义 key 全丢。formBindingData 选项注入 LocalStorage;同步用 callback 写 Preferences 给 FormExtension 跨进程读取。3. @LocalStorageProp 的 key 命名限制
// ❌ 编译报错:Cannot use the key! The value of key can only consist of letters, digits and underscores
@LocalStorageProp('my.card.title') cardTitle: string = ''
// ✅
@LocalStorageProp('my_card_title') cardTitle: string = ''4. memory:// 的 fd 25 秒生命周期
formrenderservice 是共享进程,约 25 秒空闲回收formImages: { imageName: fd } 的 fd 随进程销毁失效Image('memory://imageName') 加载不到onUpdateForm 触发时,用路径重新 openSync 拿新 fd → formProvider.updateForm 推新 formImagesonUpdateForm(formId: string) {
const config = readFromPreferences(formId)
if (!config) return
const file = fileIo.openSync(config.localImagePath, fileIo.OpenMode.READ_ONLY)
try {
const formImages: Record<string, number> = {}
const imageName = `img_${Date.now()}`
formImages[imageName] = file.fd
const data: Record<string, Object> = {
imgName: imageName,
formImages: formImages,
// ... other fields
}
formProvider.updateForm(formId, formBindingData.createFormBindingData(data))
} finally {
fileIo.closeSync(file.fd)
}
}5. SPUtils 跨进程缓存不同步
getObject 返回旧值。// FormExtension 读取前必须:
SPUtils.removePreferencesFromCacheSync() // 清缓存
const config = SPUtils.getObject<...>(KEY, {}) // 强制从磁盘读6. fd 泄漏:Dialog 多次 build 累积
@ComponentV2
export struct AddCardDialog {
// ...
private getCardFormBindingData(): formBindingData.FormBindingData {
const data: Record<string, Object> = {}
// ...
// ❌ 每次 build 都 open 一次不关 — 多次重渲染后 fd 累积,可能 EMFILE
const file = fileIo.openSync(localPath, fileIo.OpenMode.READ_ONLY)
const formImages: Record<string, number> = {}
formImages['img'] = file.fd
data['formImages'] = formImages
return formBindingData.createFormBindingData(data)
}
}private cardBindingFile: fileIo.File | undefined = undefined
private closeCardBindingFile(): void {
if (this.cardBindingFile) {
try { fileIo.closeSync(this.cardBindingFile.fd) } catch (_e) {}
this.cardBindingFile = undefined
}
}
private getCardFormBindingData(): formBindingData.FormBindingData {
this.closeCardBindingFile() // 先关旧的
this.cardBindingFile = fileIo.openSync(localPath, fileIo.OpenMode.READ_ONLY)
// ... 构造 data 用 this.cardBindingFile.fd
return formBindingData.createFormBindingData(data)
}
// AddFormMenuItem callback 里:
callback: (error, formId) => {
this.closeCardBindingFile() // 系统已消费,可以关了
// ...
}7. http.request 缺 expectDataType 导致静默失败
// ❌ 默认行为依赖 Content-Type,CDN 返回不规范时 result 退化成 string
const response = await request.request(url, {
readTimeout: 10000,
connectTimeout: 10000
})
if (!(response.result instanceof ArrayBuffer)) {
// 静默走不到这里,图片链路永远失败
}
// ✅ 显式声明
const response = await request.request(url, {
readTimeout: 10000,
connectTimeout: 10000,
expectDataType: http.HttpDataType.ARRAY_BUFFER
})application/octet-stream 而不是 image/* 时,不加 expectDataType 就翻车。8. WebView 加载 URL 的开放重定向风险
javascript: / file: / 钓鱼域名都能塞进来:// 点击卡片 → postCardAction → Ability.onNewWant
handleWant(want: Want) {
const rawJumpUrl = want?.parameters?.jumpUrl
const jumpUrl = typeof rawJumpUrl === 'string' ? rawJumpUrl.trim() : ''
if (!jumpUrl) return
// 🔑 scheme 白名单
const lower = jumpUrl.toLowerCase()
if (!lower.startsWith('https://') && !lower.startsWith('http://')) {
console.warn('jumpUrl rejected by scheme check:', jumpUrl)
return
}
const param: Record<string, string> = { 'url': jumpUrl }
router.pushOrReplace(WebPage, param)
}9. form_config.json 没法隐藏系统选择器
isDefault: false — 只是不作默认尺寸,仍显示在选择器formProvider.deleteForm — 这个 API 根本不存在(provider 端不能删卡,只有 host 端能)@Entry
@Component
struct MyCard {
@LocalStorageProp('dimension') dimension: string = '0'
@LocalStorageProp('my_card_title') cardTitle: string = ''
@LocalStorageProp('imgName') cardImageName: string = ''
@State cardHeight: number = 0
build() {
FormLink(...) {
Row() {
if (this.cardHeight > 0) {
if (this.cardTitle || this.cardImageName) {
this.dynamicCardLayout() // 从 H5 加的卡
} else {
this.defaultCardLayout() // 从选择器加的卡
}
}
}
}
.onSizeChange((_o, n) => { this.cardHeight = n.height as number })
}
}10. 卡片下方的 app 名字标签是系统强制的
EntryAbility_label),不受 form_config 的 displayName 控制。displayName 只在系统小组件选择器里用。11. 多个 @Entry 文件的顶层 const 冲突
// widgetA.ets
const OPEN = 'open'
@Entry @Component struct WidgetA { ... }
// widgetB.ets
const OPEN = 'open' // ❌ 编译报 Cannot redeclare block-scoped variable 'OPEN'
@Entry @Component struct WidgetB { ... }12. ImageFit 选型
模式 效果 适用 Cover保比例 + 裁剪 图片比例接近卡片 Fill拉伸铺满(可能变形) 比例不可控时首选 Contain保比例 + 留白 不能裁剪任何内容 Fill(轻微变形换完整铺满)。13. onSizeChange 守卫不能删
@State cardHeight: number = 0
@Builder content() {
// 用到 cardHeight 的表达式(例如 cardHeight * 0.4 做绝对定位)
// cardHeight 在 onSizeChange 触发前是 0,导致异常
}
build() {
FormLink(...) {
Row() {
if (this.cardHeight > 0) { // 🔑 必须守卫
this.content()
}
}
}
.onSizeChange((_o, n) => { this.cardHeight = n.height as number })
}14. LogUtil 默认构建过滤日志
LogUtil.i() 受 Logger.isPrint 控制,default build 里通常是 false,关键日志筛不到。解决:LogUtil.infoForce)console.log 输出到 com.ohos.formrenderservice tag,不在 app bundleName 下,用 app 过滤器筛不到15. ArkTS 对象字面量的类型检查
// ❌ arkts-no-untyped-obj-literals
router.push(page, { url } as Record<string, string>)
// ✅ 先声明变量
const param: Record<string, string> = { 'url': url }
router.push(page, param)as Xxx 强转也不行。三、布局最佳实践
Stack + 绝对 position 模板
Stack() {
// 背景图(最底层)
Image(this.cardImageName ? ('memory://' + this.cardImageName) : $r('app.media.fallback'))
.width('100%').height('100%')
.objectFit(ImageFit.Fill)
// 左上角 logo(绝对定位)
Image(this.cardLogoName ? ('memory://' + this.cardLogoName) : $r('app.media.default_logo'))
.width(14).height(14)
.borderRadius(7)
.position({ x: 10, y: 6 })
// 中下方文字块(按 cardHeight 比例放)
Column() {
Text(this.cardTitle)
.fontWeight(FontWeight.Medium).fontSize(13).fontColor(Color.White)
Text(this.cardSubTitle)
.fontWeight(FontWeight.Regular).fontSize(10).fontColor('#CCFFFFFF')
.margin({ top: 2 })
}
.width('100%')
.padding({ left: 10, right: 10 })
.alignItems(HorizontalAlign.Start)
.position({ x: 0, y: this.cardHeight * 0.4 })
}
.width('100%').height('100%')
.borderRadius(12)
.clip(true)FormExtension 双 fd 注入(背景 + logo)
private async updateCard(formId: string, bgPath: string, logoPath: string): Promise<void> {
let bgFile: fileIo.File | undefined
let logoFile: fileIo.File | undefined
try {
const data: Record<string, Object> = {
my_card_title: this.title,
my_card_sub_title: this.subTitle,
}
const formImages: Record<string, number> = {}
if (isValidFile(bgPath)) {
bgFile = fileIo.openSync(bgPath, fileIo.OpenMode.READ_ONLY)
const imageName = `img_${Date.now()}`
formImages[imageName] = bgFile.fd
data['imgName'] = imageName
}
if (isValidFile(logoPath)) {
logoFile = fileIo.openSync(logoPath, fileIo.OpenMode.READ_ONLY)
const logoName = `logo_${Date.now()}`
formImages[logoName] = logoFile.fd
data['logoName'] = logoName
}
if (Object.keys(formImages).length > 0) {
data['formImages'] = formImages
}
await formProvider.updateForm(formId, formBindingData.createFormBindingData(data))
} finally {
// 两个 fd 都要 close
try { if (bgFile) fileIo.closeSync(bgFile.fd) } catch (_e) {}
try { if (logoFile) fileIo.closeSync(logoFile.fd) } catch (_e) {}
}
}四、关键 API 速查
API 作用 注意 @kit.ArkUI.AddFormMenuItem系统"添加桌面卡片"按钮 自定义 parameters 会被过滤 @kit.FormKit.formBindingData.createFormBindingData(data)构造初始卡片数据 data 可含 formImages: {name: fd}@kit.FormKit.formProvider.updateForm(formId, bindData)外部更新卡片 LocalStorage 只能在 FormExtension 调 @kit.FormKit.FormExtensionAbility卡片生命周期入口 onAddForm / onUpdateForm / onFormEvent / onRemoveForm @LocalStorageProp(key)卡片组件订阅 LocalStorage key 只能字母数字下划线 Image('memory://xxx')用 fd 机制加载图片 依赖 formImages 中的 fd FormLink({ action, abilityName, params })卡片内点击区 默认 action=router postCardAction(this, {...})主动触发 card action 参数经 want.parameters fileIo.openSync/closeSync文件 fd 管理 卡片进程无法直接打开 http URL http.request(url, opts)下载图片 必须带 expectDataTypePreferences removePreferencesFromCacheSync清内存缓存 跨进程读前必须调 五、调试与刷新卡片的办法
刷新桌面卡片(HAP 更新后加载新代码)
hdc shell "aa force-stop com.ohos.formrenderservice"onAddForm / onUpdateForm,新 fd 重新注入。hdc shell "aa force-stop com.<your.bundle.name>"onAddForm 必定触发,fd 保证是新的。抓日志(UTF-16 编码坑)
with open('log.txt', 'rb') as f:
data = f.read()
try:
text = data.decode('utf-16')
except UnicodeError:
text = data.decode('utf-8', errors='ignore')CLI 构建 + 安装
export DEVECO_SDK_HOME="/path/to/openHarmony"
export HOS_SDK_HOME="$DEVECO_SDK_HOME/sdk/default"
export PATH="$DEVECO_SDK_HOME/tools/node:$DEVECO_SDK_HOME/tools/hvigor/bin:$DEVECO_SDK_HOME/tools/ohpm/bin:$HOS_SDK_HOME/openharmony/toolchains:$PATH"
node "$DEVECO_SDK_HOME/tools/hvigor/bin/hvigorw.js" assembleHap --mode module -p product=default
# 注意:hdc install 拼接 CWD,必须 cd 到项目根目录再用相对路径
cd "/path/to/your-project"
hdc install -r "entry/build/default/outputs/default/entry-default-signed.hap"总结