ArkTS 服务卡片开发全攻略:从零打造可交互的桌面 Widget

鸿蒙 Form Kit 实战 | 适用于 HarmonyOS NEXT / API 12+


前言

服务卡片(Form Kit)是鸿蒙应用中最具差异化的功能之一:用户无需打开 App,就能在桌面直接查看数据、触发操作。天气、步数、待办、快捷支付……这些都是服务卡片的典型场景。

然而,很多开发者卡在以下几个坑:

  • FormExtensionAbility 和 UIAbility 的职责混淆
  • 卡片数据如何从 App 传递过来?
  • 定时刷新与事件刷新怎么选?
  • 用户点击卡片后如何拉起应用特定页面?

本文用一个**「每日待办摘要卡片」**完整案例,带你把上面这些问题全部拿下。


一、服务卡片核心概念速览

1.1 卡片三要素

┌─────────────────────────────────────────┐
│  Form Provider(卡片提供方,你的 App)   │
│  ┌──────────────────────────────────┐   │
│  │  FormExtensionAbility            │   │
│  │  · onAddForm()  创建             │   │
│  │  · onUpdateForm() 刷新           │   │
│  │  · onFormEvent() 接收事件        │   │
│  └──────────────────────────────────┘   │
│  ┌──────────────────────────────────┐   │
│  │  ArkTS Card UI(.ets 文件)      │   │
│  │  · @Entry @Component             │   │
│  │  · postCardAction() 发送事件     │   │
│  └──────────────────────────────────┘   │
└─────────────────────────────────────────┘
            ↕ 系统框架调度
┌─────────────────────────────────────────┐
│  Form Host(卡片使用方,如桌面)        │
└─────────────────────────────────────────┘
角色 负责什么
FormExtensionAbility 卡片生命周期、数据更新逻辑(运行在后台进程)
卡片 UI 文件 界面渲染,只能用有限的 ArkUI 组件
Form Host 展示卡片,转发事件(系统桌面,无需开发)

⚠️ 注意:卡片 UI 运行在独立的沙箱进程,不能访问网络、不能使用 AppStorage,所有数据必须由 FormExtensionAbility 通过 formProvider.updateForm() 推送。

1.2 支持的卡片尺寸

规格 占用桌面格数 适用场景
1×2 数据展示
2×2 图文混排
2×4 列表/图表
4×4 特大 复杂交互

二、项目结构搭建

2.1 在 module.json5 中声明卡片

// entry/src/main/module.json5
{
  "module": {
    "extensionAbilities": [
      {
        "name": "TodoFormAbility",
        "srcEntry": "./ets/formability/TodoFormAbility.ets",
        "type": "form",
        "description": "每日待办摘要卡片",
        "formsEnabled": true,
        "forms": [
          {
            "name": "TodoSummaryForm",
            "displayName": "待办摘要",
            "description": "展示今日待办数量和进度",
            "src": "./ets/widget/pages/TodoFormPage.ets",
            "uiSyntax": "arkts",
            "window": {
              "designWidth": 720,
              "autoDesignWidth": true
            },
            "colorMode": "auto",
            "isDefault": true,
            "updateEnabled": true,
            "scheduledUpdateTime": "10:30",
            "updateDuration": 1,
            "defaultDimension": "2*2",
            "supportDimensions": ["1*2", "2*2", "2*4"]
          }
        ]
      }
    ]
  }
}

关键字段说明:

  • updateEnabled: true + updateDuration: 1:每 30 分钟主动刷新(1 单位 = 30 分钟)
  • scheduledUpdateTime: "10:30":每天 10:30 定时刷新
  • 两者共存时,系统取 更频繁 的策略

2.2 目录结构

entry/src/main/ets/
├── entryability/
│   └── EntryAbility.ets        # 主 Ability
├── formability/
│   └── TodoFormAbility.ets     # 卡片生命周期管理
├── widget/
│   └── pages/
│       └── TodoFormPage.ets    # 卡片 UI
├── service/
│   └── TodoService.ets         # 业务逻辑(App 进程)
└── pages/
    └── Index.ets               # 主页面

三、FormExtensionAbility:卡片后端

// entry/src/main/ets/formability/TodoFormAbility.ets
import FormExtensionAbility from '@ohos.app.form.FormExtensionAbility';
import formProvider from '@ohos.app.form.formProvider';
import formBindingData from '@ohos.app.form.formBindingData';
import Want from '@ohos.app.ability.Want';
import { TodoRepository } from '../service/TodoRepository';

export default class TodoFormAbility extends FormExtensionAbility {
  
  /**
   * 卡片首次添加到桌面时调用
   * 必须同步返回初始数据,否则卡片显示空白
   */
  onAddForm(want: Want): formBindingData.FormBindingData {
    console.info('[TodoFormAbility] onAddForm, formId:', want.parameters?.['ohos.extra.param.key.form_identity']);
    
    // 从本地存储读取待办数据(同步方式)
    const summary = TodoRepository.getSummarySync();
    
    return formBindingData.createFormBindingData({
      total: summary.total,
      done: summary.done,
      pending: summary.pending,
      lastUpdated: this.formatTime(new Date()),
      nextTodo: summary.nextTodo ?? '暂无待办'
    });
  }

  /**
   * 卡片触发定时/主动刷新时调用
   */
  onUpdateForm(formId: string): void {
    console.info('[TodoFormAbility] onUpdateForm, formId:', formId);
    
    // 异步获取最新数据后推送更新
    TodoRepository.getSummary().then(summary => {
      const bindingData = formBindingData.createFormBindingData({
        total: summary.total,
        done: summary.done,
        pending: summary.pending,
        lastUpdated: this.formatTime(new Date()),
        nextTodo: summary.nextTodo ?? '暂无待办'
      });
      
      formProvider.updateForm(formId, bindingData).catch((err: Error) => {
        console.error('[TodoFormAbility] updateForm failed:', err.message);
      });
    });
  }

  /**
   * 接收卡片内用户触发的 message 事件
   */
  onFormEvent(formId: string, message: string): void {
    console.info('[TodoFormAbility] onFormEvent, message:', message);
    
    try {
      const event: { action: string; todoId?: string } = JSON.parse(message);
      
      switch (event.action) {
        case 'refresh':
          // 手动触发刷新
          this.onUpdateForm(formId);
          break;
        case 'completeTodo':
          // 完成待办并更新卡片
          if (event.todoId) {
            TodoRepository.complete(event.todoId).then(() => {
              this.onUpdateForm(formId);
            });
          }
          break;
      }
    } catch (e) {
      console.error('[TodoFormAbility] parse message failed:', e);
    }
  }

  /**
   * 卡片被移除桌面时调用,做清理工作
   */
  onRemoveForm(formId: string): void {
    console.info('[TodoFormAbility] onRemoveForm:', formId);
    // 可在此清理与该 formId 相关的缓存
  }

  private formatTime(date: Date): string {
    const h = String(date.getHours()).padStart(2, '0');
    const m = String(date.getMinutes()).padStart(2, '0');
    return `${h}:${m} 更新`;
  }
}

四、TodoRepository:数据层

// entry/src/main/ets/service/TodoRepository.ets
import dataPreferences from '@ohos.data.preferences';
import common from '@ohos.app.ability.common';

export interface TodoSummary {
  total: number;
  done: number;
  pending: number;
  nextTodo: string;
}

const PREF_NAME = 'todo_data';

export class TodoRepository {
  private static context: common.Context;

  static init(ctx: common.Context): void {
    TodoRepository.context = ctx;
  }

  /** 同步读取(供 onAddForm 使用) */
  static getSummarySync(): TodoSummary {
    // FormExtensionAbility 生命周期极短,尽量用轻量同步方案
    // 实际项目中可读取 AppStorage 或本地缓存文件
    return {
      total: 5,
      done: 2,
      pending: 3,
      nextTodo: '完成周报'
    };
  }

  /** 异步读取(供 onUpdateForm 使用) */
  static async getSummary(): Promise<TodoSummary> {
    const pref = await dataPreferences.getPreferences(
      TodoRepository.context,
      PREF_NAME
    );
    
    const total = (await pref.get('total', 0)) as number;
    const done = (await pref.get('done', 0)) as number;
    const nextTodo = (await pref.get('nextTodo', '暂无待办')) as string;
    
    return { total, done, pending: total - done, nextTodo };
  }

  /** 标记完成(App 侧调用后,同步触发卡片刷新) */
  static async complete(todoId: string): Promise<void> {
    const pref = await dataPreferences.getPreferences(
      TodoRepository.context,
      PREF_NAME
    );
    const done = (await pref.get('done', 0) as number) + 1;
    await pref.put('done', done);
    await pref.flush();
  }
}

五、卡片 UI:TodoFormPage.ets

⚠️ 卡片 UI 限制:不能使用 @State@Link 等装饰器,只能用 @LocalStorageProp 接收数据;不能调用网络、文件等系统 API。

// entry/src/main/ets/widget/pages/TodoFormPage.ets
let storage = new LocalStorage();

@Entry(storage)
@Component
struct TodoFormPage {
  // 通过 LocalStorageProp 接收 FormBindingData 中的字段
  @LocalStorageProp('total') total: number = 0;
  @LocalStorageProp('done') done: number = 0;
  @LocalStorageProp('pending') pending: number = 0;
  @LocalStorageProp('lastUpdated') lastUpdated: string = '--:-- 更新';
  @LocalStorageProp('nextTodo') nextTodo: string = '暂无待办';

  /** 进度百分比 0-1 */
  get progress(): number {
    return this.total > 0 ? this.done / this.total : 0;
  }

  build() {
    Column({ space: 8 }) {
      // 顶部标题栏
      Row() {
        Image($r('app.media.ic_todo'))
          .width(20).height(20)
        Text('今日待办')
          .fontSize(14)
          .fontWeight(FontWeight.Medium)
          .fontColor('#1a1a1a')
          .margin({ left: 6 })
        Blank()
        Text(this.lastUpdated)
          .fontSize(11)
          .fontColor('#999999')
      }
      .width('100%')

      // 进度环
      Stack({ alignContent: Alignment.Center }) {
        Progress({
          value: this.done,
          total: this.total,
          type: ProgressType.Ring
        })
          .width(72).height(72)
          .color('#4CAF50')
          .style({ strokeWidth: 8 })
        
        Column() {
          Text(`${this.done}`)
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1a1a1a')
          Text(`/ ${this.total}`)
            .fontSize(12)
            .fontColor('#999999')
        }
      }
      .width(72).height(72)

      // 下一条待办
      Row() {
        Text('下一件:')
          .fontSize(12)
          .fontColor('#666666')
        Text(this.nextTodo)
          .fontSize(12)
          .fontColor('#333333')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .layoutWeight(1)
      }
      .width('100%')

      // 操作按钮区
      Row({ space: 8 }) {
        // 刷新按钮 → 触发 message 事件
        Button('刷新', { type: ButtonType.Normal })
          .height(28)
          .fontSize(12)
          .layoutWeight(1)
          .borderRadius(6)
          .backgroundColor('#F5F5F5')
          .fontColor('#666666')
          .onClick(() => {
            postCardAction(this, {
              action: 'message',
              params: { action: 'refresh' }
            });
          })

        // 打开 App 按钮 → 触发 router 事件
        Button('查看全部', { type: ButtonType.Normal })
          .height(28)
          .fontSize(12)
          .layoutWeight(1)
          .borderRadius(6)
          .backgroundColor('#4CAF50')
          .fontColor('#FFFFFF')
          .onClick(() => {
            postCardAction(this, {
              action: 'router',
              abilityName: 'EntryAbility',
              params: { page: 'todo_list' }
            });
          })
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
  }
}

六、postCardAction 三种事件类型详解

// 1. message 事件:触发 FormExtensionAbility.onFormEvent()
//    适合:卡片内部刷新、完成待办等不需要打开 App 的操作
postCardAction(this, {
  action: 'message',
  params: { action: 'completeTodo', todoId: '123' }
});

// 2. router 事件:拉起 UIAbility 并跳转到指定页面
//    适合:点击卡片打开 App 详情页
postCardAction(this, {
  action: 'router',
  abilityName: 'EntryAbility',    // 目标 Ability 名称
  params: { page: 'todo_detail', id: '123' }
});

// 3. call 事件:在后台调用 UIAbility(不切换前台)
//    适合:静默操作,如切换收藏状态
postCardAction(this, {
  action: 'call',
  abilityName: 'EntryAbility',
  params: {
    method: 'toggleFavorite',    // 对应 Caller.call() 的方法名
    params: JSON.stringify({ id: '123' })
  }
});
事件类型 App 是否到前台 处理位置 适用场景
message ❌ 不切换 FormExtensionAbility.onFormEvent() 卡片内部数据刷新
router ✅ 切换前台 EntryAbility.onNewWant() 打开 App 详情
call ❌ 不切换 UIAbility Callee 静默后台操作

七、EntryAbility 接收卡片跳转

// entry/src/main/ets/entryability/EntryAbility.ets
import UIAbility from '@ohos.app.ability.UIAbility';
import window from '@ohos.window';
import Want from '@ohos.app.ability.Want';

export default class EntryAbility extends UIAbility {
  
  // App 未启动时,从卡片冷启动进入
  onCreate(want: Want): void {
    this.handleFormWant(want);
  }

  // App 已在后台时,从卡片热启动进入
  onNewWant(want: Want): void {
    this.handleFormWant(want);
    // 如果窗口已存在,需要主动刷新页面
    // 通过 AppStorage 传参,Index 页面监听变化后路由
    AppStorage.setOrCreate<string>('formNavPage', want.parameters?.['page'] as string ?? '');
  }

  private handleFormWant(want: Want): void {
    const page = want.parameters?.['page'] as string;
    if (page) {
      console.info('[EntryAbility] from form card, target page:', page);
      // 存入 AppStorage,Index 页面通过 @StorageLink 响应跳转
      AppStorage.setOrCreate<string>('formNavPage', page);
    }
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index');
  }
}
// 在 Index.ets 主页响应卡片跳转
@Entry
@Component
struct Index {
  @StorageLink('formNavPage') targetPage: string = '';

  onPageShow(): void {
    if (this.targetPage === 'todo_list') {
      // 跳转到待办列表
      router.pushUrl({ url: 'pages/TodoList' });
      AppStorage.setOrCreate<string>('formNavPage', ''); // 清空,防止重复跳转
    }
  }

  build() {
    // 主页面内容...
    Column() {
      Text('主页').fontSize(24)
    }
  }
}

八、主动刷新卡片(App 侧推送)

当用户在 App 内新增/完成待办后,App 需要主动刷新桌面卡片:

// entry/src/main/ets/service/FormUpdateService.ets
import formProvider from '@ohos.app.form.formProvider';
import formBindingData from '@ohos.app.form.formBindingData';
import formInfo from '@ohos.app.form.formInfo';

export class FormUpdateService {
  
  /**
   * 刷新当前 App 的所有卡片
   * 调用时机:新增待办、完成待办、数据同步完成后
   */
  static async refreshAllForms(summary: {
    total: number;
    done: number;
    nextTodo: string;
  }): Promise<void> {
    
    const bindingData = formBindingData.createFormBindingData({
      total: summary.total,
      done: summary.done,
      pending: summary.total - summary.done,
      lastUpdated: FormUpdateService.formatTime(new Date()),
      nextTodo: summary.nextTodo
    });

    // 获取本应用所有已添加的卡片 ID
    const forms = await formProvider.getFormsInfo();
    
    const tasks = forms.map(form =>
      formProvider.updateForm(form.formId, bindingData)
        .catch(err => console.warn(`[FormUpdateService] update ${form.formId} failed:`, err))
    );
    
    await Promise.allSettled(tasks);
    console.info(`[FormUpdateService] refreshed ${forms.length} forms`);
  }

  private static formatTime(date: Date): string {
    return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')} 更新`;
  }
}

九、踩坑总结

现象 解法
卡片 UI 白屏 onAddForm 返回了 null 必须同步返回 createFormBindingData,即使是空数据也要给默认值
@State 无法在卡片中使用 编译报错 卡片 UI 只能用 @LocalStorageProp,数据从 FormBindingData 注入
updateForm 报 errCode 16501003 卡片已被用户移除 catch 住异常,从本地缓存删除该 formId
router 事件无响应 abilityName 拼写错误 必须与 module.json5 中 abilities[].name 完全一致
定时刷新不生效 updateDuration 填写了 0 最小值为 1(30 分钟),0 表示不刷新
卡片数据延迟显示 onAddForm 走了异步操作 onAddForm 必须同步返回,异步更新用 onUpdateForm + updateForm
进度条不更新 total 为 0 时除以 0 渲染前检查 total > 0,否则 value 传 0

十、完整开发检查清单

✅ module.json5 中正确声明 extensionAbility(type: "form")
✅ forms[].src 路径与实际文件路径一致
✅ onAddForm 同步返回有效的 FormBindingData
✅ 卡片 UI 只使用 @LocalStorageProp 接收数据
✅ postCardAction 的 action 字段拼写正确(message/router/call)
✅ updateForm 调用后 catch 了 errCode 16501003
✅ EntryAbility 实现了 onNewWant,处理卡片热启动跳转
✅ 卡片尺寸在 supportDimensions 中全部列出

小结

服务卡片的核心心智模型:FormExtensionAbility 是数据的搬运工,卡片 UI 只是展示面板。所有业务逻辑必须在 FormExtensionAbility 中完成,通过 formProvider.updateForm() 将数据推入卡片。

掌握这个模型后,再复杂的卡片交互都可以拆解为:

  1. 用户触发 postCardAction → 事件进入 onFormEvent
  2. 后台处理数据 → updateForm 推送结果
  3. 卡片 UI 自动更新渲染

下一篇我们将深入 ArkTS 分布式能力,探索多设备协同开发实战。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐