互动卡片(Live Form / LiveCard)为服务卡片带来了质的飞跃——从"信息的静态展示"进化到了"可交互、可动效、可感知"的全新维度。本文以开源项目 LiveCard(包名 com.example.livecard)为蓝本,从 LiveFormExtensionAbility 生命周期出发,深入剖析互动卡片的整体架构设计。

LiveCard 项目包含四种互动卡片:睡眠卡片(三叶草动画 + 睡眠健康数据)、快递卡片(陀螺仪驱动的憨憨动画)、运动卡片(卡路里燃烧动画与状态切换)、音乐卡片(专辑封面同步动画 + 音乐播放)。全文代码均取自该实战项目。

在这里插入图片描述

互动卡片 vs 传统卡片

module.json5 中,我们可以同时声明两种卡片类型的 ExtensionAbility:

[entry/src/main/module.json5]

{
  "extensionAbilities": [
    {
      "name": "EntryFormAbility",
      "srcEntry": "./ets/entryformability/EntryFormAbility.ets",
      "type": "form",            // 传统卡片
      "metadata": [
        {
          "name": "ohos.extension.form",
          "resource": "$profile:form_config"
        }
      ]
    },
    {
      "name": "SleepLiveCardAbility",
      "srcEntry": "./ets/livecardability/SleepLiveCardAbility.ets",
      "type": "liveForm"         // 互动卡片
    }
  ]
}

传统卡片对应 FormExtensionAbility,而互动卡片对应 LiveFormExtensionAbility。二者最核心的差异体现在三个层面:

生命周期模型:传统卡片通过 onAddFormonUpdateFormonFormEvent 等回调与卡片交互,本质上是一种"事件驱动"的轻量模型。而互动卡片通过 onLiveFormCreate 获得一个 UIExtensionContentSession,拥有独立的 UI 渲染能力,本质上是一个"UIExtension"的增强形态。

渲染能力:传统卡片的 UI 由 ArkTS widget 页面渲染,不支持帧动画、传感器数据、实时媒体播放等能力。互动卡片则可以运行完整的帧动画(如睡眠卡片的憨憨动画)、集成陀螺仪传感器(如快递卡片)、使用 AVPlayer 播放音乐等。

通信方式:传统卡片通过 postCardAction 向指定的 Ability 发送事件,接收方在 onFormEvent 中处理。互动卡片除了支持 postCardAction,还能通过 LocalStorage 与主 Ability 共享数据,并通过 callee.on('cardAction', ...) 注册方法调用处理。

从代码结构上看,传统卡片的 UI 页面位于 widget/pages/ 目录下,而互动卡片的 UI 页面位于 livecardability/pages/ 目录下——这不仅仅是路径的差异,更代表了两种完全不同的运行环境。

LiveFormExtensionAbility 生命周期

onLiveFormCreate 的同步调用约束

LiveFormExtensionAbility 的核心生命周期方法是 onLiveFormCreate,它在用户将卡片添加到桌面时被调用。该方法接收两个参数:LiveFormInfo(携带卡片元数据)和 UIExtensionContentSession(用于加载卡片 UI)。

在 LiveCard 项目中,我们可以看到同步与异步两种写法:

[entry/src/main/ets/livecardability/SleepLiveCardAbility.ets]

export class SleepLiveCardAbility extends LiveFormExtensionAbility {
  onLiveFormCreate(liveFormInfo: LiveFormInfo, session: UIExtensionContentSession): void {
    let storage: LocalStorage = new LocalStorage();
    storage.setOrCreate('context', this.context);
    storage.setOrCreate('session', session);
    let formId: string = liveFormInfo.formId;
    storage.setOrCreate('formId', formId);
    let borderRadius: number = liveFormInfo.borderRadius;
    storage.setOrCreate('borderRadius', borderRadius);
    let formRect: formInfo.Rect = liveFormInfo.rect;
    storage.setOrCreate('formRect', formRect);
    try {
      session.loadContent('livecardability/pages/SleepLiveCard', storage);
    } catch (error) {
      Logger.error(TAG, `loadContent catch error, code: ${error.code}, message: ${error.message}`);
    }
  }
}

而音乐卡片则是异步版本:

[entry/src/main/ets/livecardability/MusicLiveCardAbility.ets]

export class MusicLiveCardAbility extends LiveFormExtensionAbility {
  async onLiveFormCreate(liveFormInfo: LiveFormInfo, session: UIExtensionContentSession): Promise<void> {
    // ... 初始化 LocalStorage ...
    session.loadContent('livecardability/pages/MusicLiveCard', storage);
    
    // 异步数据加载
    let actionData = MusicFileStore.getTriggerAction(this.context);
    let songRdbHelper = SongRdbHelper.getInstance(this.context);
    let initSongs: SongItem[] = await songRdbHelper.queryAllSongs();
    // ... 继续初始化 ...
  }
}

这里有一个重要的设计考虑:虽然 onLiveFormCreate 可以声明为 async,但 session.loadContent() 的调用应该尽早执行,不应被 await 阻塞。音乐卡片的做法是正确的——先调用 loadContent 加载 UI,再执行异步数据初始化,数据通过 LocalStorage 传递给卡片组件。

LiveFormInfo 的数据结构

LiveFormInfo 包含了互动卡片运行所需的核心参数:

  • formId:卡片的唯一标识,后续所有 formProvider.updateForm() 操作都需要它
  • borderRadius:卡片的圆角值,用于与卡片 UI 保持一致
  • rectformInfo.Rect 类型,包含 widthheightlefttop,标识卡片在桌面的位置和尺寸

这些参数通过 LocalStorage 传递到卡片组件中,使得组件能够自适应桌面布局。

sceneAnimationParams 机制

abilityName 的配置与系统激活流程

form_config.json 中,每个卡片的配置都包含 sceneAnimationParams 字段:

[entry/src/main/resources/base/profile/form_config.json]

{
  "forms": [
    {
      "name": "SleepCard",
      "src": "./ets/widget/pages/SleepCard.ets",
      "isDynamic": true,
      "sceneAnimationParams": {
        "abilityName": "SleepLiveCardAbility"
      }
    },
    {
      "name": "DeliveryCard",
      "sceneAnimationParams": {
        "abilityName": "DeliveryLiveCardAbility",
        "triggerTypes": ["shake"]
      }
    }
  ]
}

sceneAnimationParams 是传统卡片与互动卡片之间的"桥梁"。它的工作流程如下:

  1. 用户将卡片添加到桌面,系统首先加载传统卡片(widget/pages/SleepCard.ets),这是一个普通的 Form 卡片
  2. 当用户点击卡片或满足触发条件时,系统检测 sceneAnimationParams.abilityName 配置
  3. 系统激活对应的 LiveFormExtensionAbility(即 SleepLiveCardAbility
  4. onLiveFormCreate 被调用,互动卡片 UI 通过 UIExtensionContentSession 加载
  5. 此时桌面上的卡片从"静态 widget"无缝切换为"互动卡片"

这个机制实现了一种"渐进式增强"的卡片体验——用户看到的是同一张卡片,但系统在后台完成了从轻量 widget 到全功能 UIExtension 的升级。

对于快递卡片,triggerTypes 还包含 "shake",这意味着除了点击触发外,系统还可以通过"摇一摇"传感器事件来激活互动状态。

多卡片协作架构

LiveCard 项目中存在四种完全不同的互动卡片,它们如何共享同一个主 Ability 却又各自独立运行?答案藏在架构设计中。

单 Ability + 多 Extension 模式

┌─────────────────────────────────────────────┐
│              EntryAbility                    │
│  (LiveCardAbility / UIAbility)              │
│                                              │
│  ├─ CardActionHandler (callee 注册)          │
│  ├─ MediaService (全局音乐服务)              │
│  └─ FormUtils (数据管理)                     │
├─────────────────────────────────────────────┤
│                                              │
│  ExtensionAbilities (各自独立进程上下文)      │
│                                              │
│  SleepLiveCardAbility  ─── SleepLiveCard.ets │
│  DeliveryLiveCardAbility ─── DeliveryLiveCard│
│  ExerciseLiveCardAbility ─── ExerciseLiveCard│
│  MusicLiveCardAbility   ─── MusicLiveCard    │
└─────────────────────────────────────────────┘

每个 LiveFormExtensionAbility 都是独立的 Extension,拥有自己的 Context。它们通过 LocalStorage 与各自的卡片页面通信,互不干扰。

跨进程通信的三条路径

卡片与主 Ability 之间的通信主要通过以下三种方式:

路径一:postCardAction + CALL 模式

[entry/src/main/ets/utils/ActionUtils.ets]

public playByAction(component: object, type: PlayActionType, formId: string): void {
  postCardAction(component, {
    action: FormCarAction.CALL,
    abilityName: ENTRY_ABILITY,  // "LiveCardAbility"
    params: {
      method: 'cardAction',
      actionType: CardActionType.PLAY_ACTION,
      playActionType: type,
      formId: formId,
    },
  });
}

主 Ability 通过 this.callee.on('cardAction', CardActionHandler.getHandler()) 注册处理方法,CardActionHandler 根据 actionType 分发到不同的处理逻辑。

路径二:postCardAction + MESSAGE 模式

public requestOverFlow(component: object, widthRatio: number, heightRatio: number, duration: number): void {
  postCardAction(component, {
    action: FormCarAction.MESSAGE,
    abilityName: ENTRY_FORM_ABILITY,  // "EntryFormAbility"
    params: {
      message: 'requestOverflow',
      widthRatio: widthRatio,
      heightRatio: heightRatio,
      duration: duration
    },
  });
}

MESSAGE 消息发送到 EntryFormAbility,在 onFormEvent 回调中处理。这主要用于触发卡片的"溢出动画"(overflow animation)。

路径三:formProvider.updateForm 推送数据

public async updateExerciseCardState(context: Context, state: number): Promise<void> {
  let formList: FormInfo[] = await FormRdbHelper.getInstance(context).queryFormByName('ExerciseCard');
  formList.forEach((formInfo) => {
    class ExerciseUpdateData {
      public currentState: number = state;
      public calories: number = 0;
    }
    this.updateForm(formInfo.formId, new ExerciseUpdateData());
  });
}

主 Ability 通过 formProvider.updateForm() 主动向所有同名卡片推送数据更新。卡片组件上使用 @LocalStorageProp@LocalStorageLink 装饰器接收这些数据。

数据持久化的统一入口

虽然每种卡片各有独立的数据存储(ExerciseFileStoreMusicFileStoreFormRdbHelper),但所有数据操作都通过 FormUtils 这个统一入口类对外暴露。主页面、卡片、Extension Ability 都通过 FormUtils 来读写数据,保持了数据层的整洁。

关键配置解读

form_config.json 中的关键配置项

isDynamic: true

启用卡片的动态能力。这是互动卡片的基础开关,设置为 true 后,卡片才能接收 formProvider.updateForm() 的更新。

supportDimensions: ["2*4"]

定义卡片支持的网格尺寸。睡眠卡片和音乐卡片使用 2*4(两列四行),快递卡片和运动卡片使用 2*2。这决定了卡片在桌面的布局占比。

defaultDimension: "2*4"

卡片首次添加到桌面时的默认尺寸。

updateEnabled: false

禁用系统的自动定时更新机制。LiveCard 项目中的卡片通过 formProvider.updateForm() 手动触发数据更新,因此不需要系统级的定时刷新。

sceneAnimationParams.abilityName

如前所述,这是连接传统 widget 与互动卡片 Extension 的桥梁,指定了激活哪一个 LiveFormExtensionAbility

module.json5 中的关键配置项

type: "liveForm"

这是将 ExtensionAbility 标记为互动卡片类型的关键。没有这个字段,系统不会将其识别为 LiveForm,onLiveFormCreate 也不会被调用。

"metadata" 的差异:传统卡片需要 metadata 字段指定 ohos.extension.form 资源配置,而互动卡片不需要——因为互动卡片的配置直接在 form_config.json 中通过 sceneAnimationParams 引用。

@Entry({ useSharedStorage: true })

互动卡片页面组件上的装饰器参数。useSharedStorage: true 表示使用共享存储,这是与 LiveFormExtensionAbility 共享 LocalStorage 数据的前提。

源码走读:SleepLiveCardAbility 初始化流程

最后,我们以睡眠卡片为例,完整走通一条初始化链路。

Step 1:传统卡片加载

用户添加卡片,系统加载 widget/pages/SleepCard.ets。这个页面是静态的,显示憨憨和三叶草的静态图片,通过 isSleep 状态切换"睡觉"和"起床"两种样式。

用户点击卡片时,调用 ActionUtils.requestOverFlow() 通过 MESSAGE 通知 EntryFormAbility

.onClick(() => {
  if (this.isSleep) {
    ActionUtils.requestOverFlow(this, LiveCardScale.SLEEP_WIDTH,
      LiveCardScale.SLEEP_HEIGHT, LIVE_CARD_DURATION);
  } else {
    ActionUtils.jumpAppPage(this, 'SleepReport');
  }
})

Step 2:系统激活 LiveFormExtensionAbility

EntryFormAbility.onFormEvent 接收到 requestOverflow 消息后,调用 formProvider.requestOverflow() 触发溢出动画。同时,因为 form_config.jsonSleepCard 配置了 sceneAnimationParams.abilityName: "SleepLiveCardAbility",系统自动激活 SleepLiveCardAbility

Step 3:onLiveFormCreate 执行

onLiveFormCreate(liveFormInfo: LiveFormInfo, session: UIExtensionContentSession): void {
  // 1. 创建 LocalStorage,建立数据通道
  let storage: LocalStorage = new LocalStorage();
  storage.setOrCreate('context', this.context);
  storage.setOrCreate('session', session);
  
  // 2. 从 LiveFormInfo 提取卡片元数据
  let formId: string = liveFormInfo.formId;
  storage.setOrCreate('formId', formId);
  let borderRadius: number = liveFormInfo.borderRadius;
  storage.setOrCreate('borderRadius', borderRadius);
  let formRect: formInfo.Rect = liveFormInfo.rect;
  storage.setOrCreate('formRect', formRect);
  
  // 3. 加载互动卡片 UI 页面
  session.loadContent('livecardability/pages/SleepLiveCard', storage);
}

Step 4:互动卡片 UI 启动

SleepLiveCard 组件通过 @LocalStoragePropLocalStorage 中获取 formIdformRectborderRadius

aboutToAppear 中初始化帧动画的累积时间表,并在 1 秒后通过 formProvider.updateForm() 通知传统卡片更新状态:

aboutToAppear(): void {
  this.initCumulativeTimes();
  setTimeout(() => {
    let formMsg: formBindingData.FormBindingData = formBindingData.createFormBindingData({
      isSleep: false
    });
    formProvider.updateForm(this.formId, formMsg);
  }, 1000);
}

Step 5:帧动画驱动

卡片通过 setInterval 每 16ms(约 60fps)更新一帧,根据经过时间计算当前应显示的帧索引。帧列表通过 FrameItemweight 属性支持"停留时长权重",使某些关键帧可以多停留一段时间,形成更自然的动画节奏。

startImageSync(): void {
  this.animStartTime = Date.now();
  this.imageSyncTimer = setInterval(() => {
    const elapsed = Date.now() - this.animStartTime;
    // 根据经过时间计算帧索引
    const newHanhanFrame = this.getFrameByElapsed(elapsed, this.hanhanCumulativeTime, ...);
    const newCloverFrame = this.getFrameByElapsed(elapsed, this.cloverCumulativeTime, ...);
    // 更新状态,驱动 UI 刷新
    this.currentHanhanFrameIndex = newHanhanFrame;
    this.currentCloverFrameIndex = newCloverFrame;
  }, 16);
}

当动画播放完毕(达到 LIVE_CARD_DURATION = 3500ms),憨憨从"睡眠"状态切换为"起床"状态,同时通过 formProvider.updateForm() 反向同步传统卡片的状态——实现了互动卡片与传统卡片的数据联动。

总结

互动卡片的架构设计呈现了 HarmonyOS 服务卡片从"静态展示"到"动态交互"的演进路径。通过 LiveFormExtensionAbility + UIExtensionContentSession 的组合,开发者获得了完整的 UI 渲染能力;而 sceneAnimationParams 机制则巧妙地解决了传统 widget 与互动卡片之间的衔接过渡问题。LiveCard 项目展示的四种卡片各具特色,在共享同一套架构基础的同时,通过 postCardAction 通信、formProvider.updateForm 数据推送、以及 callee 方法注册等机制,实现了丰富的交互体验。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐