HarmonyOS 动态卡片(form)踩坑合集:从 H5 加桌到 fd 生命周期

环境:HarmonyOS 5.0 / API 12 / ArkTS / 原子化服务
场景:H5 活动页点击按钮 → 底部弹 Dialog → 加一张带网络图+标题+logo+跳转链接的卡片到桌面;点击卡片回跳到 H5 页
时间:2026-04

这篇把最近做动态卡片踩的 15 个坑归档一下,从布局崩溃、fd 生命周期、跨进程数据,到 form_config 限制、安全加固,基本每个点我都真崩过/真查过文档/真找过 issue。写出来给后面做同类需求的同学避坑。


目录


一、整体架构

最终落地是单 widget 条目 + LocalStorage 数据分支渲染(理由见陷阱 9)。数据流:

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}) 绝对定位✅ 完全按坐标✅ 推荐

经验:复杂布局不要玩花的,用 Stack + 绝对 position 最稳。坐标从 onSizeChange 拿到的 cardHeight / cardWidth 按比例算。

2. AddFormMenuItem.parameters 的自定义 key 被系统过滤

调用端:

AddFormMenuItem({
  bundleName: '...',
  abilityName: 'EntryFormAbility',
  parameters: {
    // 这些自定义 key 会被系统过滤掉
    my_card_title: 'xxx',
    my_image_url: 'https://...'
  }
}, 'id', { ... })

FormExtension 里读 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 秒生命周期

现象:加卡 25 秒后图片消失。

原因

  • 卡片进程 formrenderservice 是共享进程,约 25 秒空闲回收
  • formImages: { imageName: fd } 的 fd 随进程销毁失效
  • Image('memory://imageName') 加载不到

解决

  • 持久化物理路径到 Preferences(而不是 fd)
  • FormExtension 的 onUpdateForm 触发时,用路径重新 openSync 拿新 fd → formProvider.updateForm 推新 formImages
onUpdateForm(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 跨进程缓存不同步

Preferences 每个进程有自己的内存缓存。主进程写完 flush 到磁盘,但 FormExtension(独立进程)的缓存没变getObject 返回旧值。

// FormExtension 读取前必须:
SPUtils.removePreferencesFromCacheSync()  // 清缓存
const config = SPUtils.getObject<...>(KEY, {})  // 强制从磁盘读

这个坑排查了好几天,主进程确认写入了但 form 进程死活读不到,最后翻 issue 才发现缓存问题。

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)
  }
}

修复:实例字段持有 fd,下次重建前 close 旧的,系统 callback 触发后统一 close(那时 fd 已被 IPC dup 到 form 进程,主进程可以释放):

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
})

这个也排查了好久。CDN 返回的 Content-Type 是 application/octet-stream 而不是 image/* 时,不加 expectDataType 就翻车。

8. WebView 加载 URL 的开放重定向风险

卡片点击后跳转 URL 是 H5 传入并持久化到 Preferences 的。如果不校验 scheme,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)
}

纵深防御,不依赖下游 WebView 自己做白名单。

9. form_config.json 没法隐藏系统选择器

需求:动态卡片没数据来源时不应让用户从系统小组件选择器裸加。

试过的招:

  • isDefault: false — 只是不作默认尺寸,仍显示在选择器
  • formProvider.deleteForm这个 API 根本不存在(provider 端不能删卡,只有 host 端能)
  • 两个独立 widget 条目(正常卡 + 动态卡) — 动态卡照样出现在选择器
  • onAddForm 里 setTimeout 检测配置不存在就 deleteForm — 上面说了 API 不存在

最终合并回单 widget 条目,卡片组件内按 LocalStorage 是否有业务数据分支:

  • 从选择器裸加 → LocalStorage 空 → 走默认样式
  • 从 H5 Dialog 加 → LocalStorage 有数据 → 走动态样式
@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 名字标签是系统强制的

桌面卡片下方会显示一行"app 名字"标签(来自 EntryAbility_label),不受 form_config 的 displayName 控制displayName 只在系统小组件选择器里用。

想改这个标签只能动 app 名字本身,但那会影响桌面图标、应用商店展示等所有地方,一般不动。

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 { ... }

hvigor 把所有 @Entry 文件打进同一作用域。解决:不同 @Entry 用不同常量前缀,或合并成一个 @Entry。

12. ImageFit 选型

模式效果适用
Cover保比例 + 裁剪图片比例接近卡片
Fill拉伸铺满(可能变形)比例不可控时首选
Contain保比例 + 留白不能裁剪任何内容

H5 传的图比例不可控 → 用 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)卡片组件订阅 LocalStoragekey 只能字母数字下划线
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)下载图片必须带 expectDataType
Preferences removePreferencesFromCacheSync清内存缓存跨进程读前必须调

五、调试与刷新卡片的办法

刷新桌面卡片(HAP 更新后加载新代码)

重点:HAP 更新不会自动重载卡片渲染进程,旧卡片一直跑旧代码。由轻到重:

  1. 强制重启卡片渲染进程(推荐)

    hdc shell "aa force-stop com.ohos.formrenderservice"

    系统自动拉起新进程,加载最新 HAP。已有卡片会重新走 onAddForm / onUpdateForm,新 fd 重新注入。

  2. 强制重启自己的 atomic service 进程(FormExtension 代码改了时)

    hdc shell "aa force-stop com.<your.bundle.name>"
  3. 删卡重加(最保险)

    桌面长按卡片 → 删除 → 从入口重新添加。onAddForm 必定触发,fd 保证是新的。

  4. 重启设备(终极手段)

抓日志(UTF-16 编码坑)

PowerShell 重定向的 hilog 是 UTF-16 编码,Python 读要显式 decode:

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"

总结

鸿蒙动态卡片能做,但坑比想象多:

  • 布局端:只能用 Stack + 绝对 position,其它 Flex/Blank 用法都可能崩进程
  • 数据传递:自定义 parameters 会被过滤,靠 formBindingData + 跨进程 Preferences
  • 图片:卡片进程不能加载 http,必须下载到本地用 fd 注入;fd 25 秒回收,要在 onUpdateForm 重新 openSync
  • 安全:H5 传入的 URL 必须做 scheme 白名单,不然开放重定向
  • 代码健壮性:fd 配对 close,http.request 带 expectDataType,FormExtension 入口 try/catch
  • 刷新卡片:HAP 更新不会自动重载,force-stop formrenderservice 或删卡重加

大部分坑官方文档里要么没写要么一笔带过,只能靠踩。希望对你有帮助。


如果有更多经验或纠错欢迎评论交流。

标签: none

添加新评论