HarmonyOS 鸿蒙Next中如何实现服务卡片

HarmonyOS 鸿蒙Next中如何实现服务卡片 遇见的问题:

  • 不知道如何创建FormExtensionAbility
  • 卡片数据如何从数据库获取
  • 卡片如何自动更新
  • 点击卡片如何跳转到应用
3 回复

解决方案

1. 技术架构

┌────────────────────────────────────┐
│  FeedingFormAbility                 │
│  (FormExtensionAbility)             │
│  - onAddForm: 创建卡片              │
│  - onUpdateForm: 更新卡片           │
│  - onFormEvent: 处理点击            │
└────────────────────────────────────┘
               ↕
┌────────────────────────────────────┐
│  FormDataService                   │
│  - 管理所有卡片ID                   │
│  - 从数据库获取数据                 │
│  - 更新所有卡片                     │
└────────────────────────────────────┘
               ↕
┌────────────────────────────────────┐
│  FeedingWidgetCard.ets             │
│  - 卡片UI页面                       │
│  - 使用@LocalStorageProp绑定数据    │
└────────────────────────────────────┘

2. 完整实现代码

步骤1: 配置form_config.json

位置: entry/src/main/resources/base/profile/form_config.json

{
  "forms": [
    {
      "name": "FeedingWidget",
      "description": "喂养记录卡片",
      "src": "./ets/widget/pages/FeedingWidgetCard.ets",
      "uiSyntax": "arkts",
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": true,
      "updateEnabled": true,
      "scheduledUpdateTime": "10:30",
      "updateDuration": 1,
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2", "2*4", "4*4"],
      "formConfigAbility": "ability://entry/EntryAbility"
    }
  ]
}

关键配置:

  • uiSyntax: "arkts": 必须指定UI语法
  • updateEnabled: true: 启用自动更新
  • updateDuration: 1: 每1小时更新一次
  • formConfigAbility: 点击跳转的Ability

步骤2: 注册FormExtensionAbility

位置: entry/src/main/module.json5

{
  "module": {
    "extensionAbilities": [
      {
        "name": "FeedingFormAbility",
        "srcEntry": "./ets/entryformability/FeedingFormAbility.ets",
        "type": "form",
        "exported": true,
        "description": "喂养记录卡片",
        "metadata": [
          {
            "name": "ohos.extension.form",
            "resource": "$profile:form_config"
          }
        ]
      }
    ]
  }
}

步骤3: 创建FormExtensionAbility

位置: entry/src/main/ets/entryformability/FeedingFormAbility.ets

import { FormExtensionAbility, formInfo, formBindingData } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { FormDataService } from '../services/FormDataService';

const TAG = 'FeedingFormAbility';

export default class FeedingFormAbility extends FormExtensionAbility {
  /**
   * 创建卡片时调用
   */
  onAddForm(want: Want): formBindingData.FormBindingData {
    const formId = want.parameters?.[formInfo.FormParam.IDENTITY_KEY] as string;
    console.info(TAG, `创建卡片, formId: ${formId}`);
    
    // 注册卡片ID
    FormDataService.getInstance().registerForm(formId);
    
    // 获取卡片数据
    const formData = this.getFormData();
    
    return formBindingData.createFormBindingData(formData);
  }
  
  /**
   * 更新卡片时调用
   */
  onUpdateForm(formId: string): void {
    console.info(TAG, `更新卡片, formId: ${formId}`);
    
    // 异步更新卡片数据
    FormDataService.getInstance().updateForm(formId);
  }
  
  /**
   * 移除卡片时调用
   */
  onRemoveForm(formId: string): void {
    console.info(TAG, `移除卡片, formId: ${formId}`);
    
    // 注销卡片ID
    FormDataService.getInstance().unregisterForm(formId);
  }
  
  /**
   * 卡片事件处理
   */
  onFormEvent(formId: string, message: string): void {
    console.info(TAG, `卡片事件, formId: ${formId}, message: ${message}`);
    
    if (message === 'router') {
      // 跳转到应用内页面
      // 实际跳转由点击卡片时触发
    }
  }
  
  /**
   * 获取卡片数据
   */
  private getFormData(): Record<string, Object> {
    // 这里返回默认数据
    // 实际数据由FormDataService异步更新
    return {
      hasData: false,
      todayCount: 0,
      milkCount: 0,
      sleepCount: 0,
      diaperCount: 0,
      latestType: '',
      latestTime: '',
      latestIcon: ''
    };
  }
}

步骤4: 创建卡片数据服务

位置: entry/src/main/ets/services/FormDataService.ets

import { formProvider } from '@kit.FormKit';
import { FeedingRecordDao } from '../database/FeedingRecordDao';
import { FeedingRecord, FeedingType } from '../models/FeedingRecord';

const TAG = 'FormDataService';

/**
 * 卡片数据服务
 * 管理所有卡片的数据更新
 */
export class FormDataService {
  private static instance: FormDataService;
  private formIds: Set<string> = new Set();
  private feedingDao: FeedingRecordDao = new FeedingRecordDao();
  
  private constructor() {}
  
  static getInstance(): FormDataService {
    if (!FormDataService.instance) {
      FormDataService.instance = new FormDataService();
    }
    return FormDataService.instance;
  }
  
  /**
   * 注册卡片
   */
  registerForm(formId: string): void {
    this.formIds.add(formId);
    console.info(TAG, `注册卡片: ${formId}, 总数: ${this.formIds.size}`);
    
    // 立即更新数据
    this.updateForm(formId);
  }
  
  /**
   * 注销卡片
   */
  unregisterForm(formId: string): void {
    this.formIds.delete(formId);
    console.info(TAG, `注销卡片: ${formId}, 总数: ${this.formIds.size}`);
  }
  
  /**
   * 更新所有卡片
   */
  async updateAllForms(): Promise<void> {
    console.info(TAG, `更新所有卡片, 总数: ${this.formIds.size}`);
    
    for (const formId of this.formIds) {
      await this.updateForm(formId);
    }
  }
  
  /**
   * 更新指定卡片
   */
  async updateForm(formId: string): Promise<void> {
    try {
      // 从数据库获取今日数据
      const formData = await this.getFormData();
      
      // 创建更新数据
      const formBindingData = {
        data: JSON.stringify(formData)
      };
      
      // 更新卡片
      await formProvider.updateForm(formId, formBindingData);
      
      console.info(TAG, `卡片更新成功: ${formId}`);
    } catch (err) {
      console.error(TAG, `更新卡片失败: ${JSON.stringify(err)}`);
    }
  }
  
  /**
   * 获取卡片数据
   */
  private async getFormData(): Promise<Record<string, Object>> {
    try {
      // 获取今日开始时间
      const today = new Date();
      today.setHours(0, 0, 0, 0);
      const todayStart = today.getTime();
      
      // 查询今日所有记录
      const todayRecords = await this.feedingDao.findByDateRange(
        todayStart,
        Date.now()
      );
      
      if (todayRecords.length === 0) {
        return {
          hasData: false,
          todayCount: 0,
          milkCount: 0,
          sleepCount: 0,
          diaperCount: 0,
          latestType: '',
          latestTime: '',
          latestIcon: ''
        };
      }
      
      // 统计各类型数量
      const milkCount = todayRecords.filter(r => 
        r.type === FeedingType.BREAST || r.type === FeedingType.BOTTLE
      ).length;
      
      const sleepCount = todayRecords.filter(r => 
        r.type === FeedingType.SLEEP
      ).length;
      
      const diaperCount = todayRecords.filter(r => 
        r.type === FeedingType.DIAPER
      ).length;
      
      // 获取最新记录
      const latest = todayRecords[0];
      
      return {
        hasData: true,
        todayCount: todayRecords.length,
        milkCount: milkCount,
        sleepCount: sleepCount,
        diaperCount: diaperCount,
        latestType: this.getTypeName(latest.type),
        latestTime: this.formatTime(latest.recordTime),
        latestIcon: this.getTypeIcon(latest.type)
      };
      
    } catch (err) {
      console.error(TAG, `获取卡片数据失败: ${JSON.stringify(err)}`);
      return {
        hasData: false,
        todayCount: 0,
        milkCount: 0,
        sleepCount: 0,
        diaperCount: 0,
        latestType: '',
        latestTime: '',
        latestIcon: ''
      };
    }
  }
  
  /**
   * 获取类型名称
   */
  private getTypeName(type: FeedingType): string {
    const typeMap: Record<FeedingType, string> = {
      [FeedingType.BREAST]: '母乳喂养',
      [FeedingType.BOTTLE]: '奶瓶喂养',
      [FeedingType.SLEEP]: '睡眠',
      [FeedingType.DIAPER]: '换尿布',
      [FeedingType.SOLID_FOOD]: '辅食',
      [FeedingType.MEDICINE]: '用药'
    };
    return typeMap[type] || '';
  }
  
  /**
   * 获取类型图标
   */
  private getTypeIcon(type: FeedingType): string {
    const iconMap: Record<FeedingType, string> = {
      [FeedingType.BREAST]: '🍼',
      [FeedingType.BOTTLE]: '🍼',
      [FeedingType.SLEEP]: '😴',
      [FeedingType.DIAPER]: '🧷',
      [FeedingType.SOLID_FOOD]: '🥄',
      [FeedingType.MEDICINE]: '💊'
    };
    return iconMap[type] || '📝';
  }
  
  /**
   * 格式化时间
   */
  private formatTime(timestamp: number): string {
    const now = Date.now();
    const diff = now - timestamp;
    const minutes = Math.floor(diff / (1000 * 60));
    const hours = Math.floor(diff / (1000 * 60 * 60));
    const days = Math.floor(diff / (1000 * 60 * 60 * 24));
    
    if (minutes < 1) {
      return '刚刚';
    } else if (minutes < 60) {
      return `${minutes}分钟前`;
    } else if (hours < 24) {
      return `${hours}小时前`;
    } else {
      return `${days}天前`;
    }
  }
}

步骤5: 创建卡片UI页面

位置: entry/src/main/ets/widget/pages/FeedingWidgetCard.ets

@Entry
@Component
struct FeedingWidgetCard {
  // 绑定卡片数据
  @LocalStorageProp('hasData') hasData: boolean = false;
  @LocalStorageProp('todayCount') todayCount: number = 0;
  @LocalStorageProp('milkCount') milkCount: number = 0;
  @LocalStorageProp('sleepCount') sleepCount: number = 0;
  @LocalStorageProp('diaperCount') diaperCount: number = 0;
  @LocalStorageProp('latestType') latestType: string = '';
  @LocalStorageProp('latestTime') latestTime: string = '';
  @LocalStorageProp('latestIcon') latestIcon: string = '';
  
  build() {
    Column() {
      if (this.hasData) {
        // 有数据时显示
        this.buildDataView();
      } else {
        // 无数据时显示
        this.buildEmptyView();
      }
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#FF9472')
    .backgroundImage($r('app.media.widget_bg'), ImageRepeat.NoRepeat)
    .backgroundImageSize(ImageSize.Cover)
    .borderRadius(16)
    .onClick(() => {
      // 点击跳转到应用
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: {
          page: 'pages/FeedingRecordPage'
        }
      });
    })
  }
  
  /**
   * 有数据视图
   */
  @Builder
  buildDataView() {
    Column({ space: 12 }) {
      // 标题栏
      Row() {
        Text('喂养记录')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White);
        
        Blank();
        
        Text('🍼')
          .fontSize(20);
      }
      .width('100%')
      
      // 统计卡片
      Row({ space: 8 }) {
        this.buildStatCard('📊', this.todayCount.toString(), '今日');
        this.buildStatCard('🍼', this.milkCount.toString(), '喂奶');
        this.buildStatCard('😴', this.sleepCount.toString(), '睡眠');
        this.buildStatCard('🧷', this.diaperCount.toString(), '尿布');
      }
      .width('100%')
      
      // 最新记录
      Column({ space: 4 }) {
        Text('最新记录')
          .fontSize(12)
          .fontColor('rgba(255, 255, 255, 0.8)')
          .alignSelf(ItemAlign.Start);
        
        Row({ space: 8 }) {
          Text(this.latestIcon)
            .fontSize(20);
          
          Column({ space: 2 }) {
            Text(this.latestType)
              .fontSize(14)
              .fontWeight(FontWeight.Medium)
              .fontColor(Color.White);
            
            Text(this.latestTime)
              .fontSize(12)
              .fontColor('rgba(255, 255, 255, 0.8)');
          }
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%')
        .padding(12)
        .backgroundColor('rgba(255, 255, 255, 0.2)')
        .borderRadius(8)
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }
  
  /**
   * 空数据视图
   */
  @Builder
  buildEmptyView() {
    Column({ space: 12 }) {
      Text('👶')
        .fontSize(48);
      
      Text('暂无记录')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor(Color.White);
      
      Text('点击添加喂养记录')
        .fontSize(14)
        .fontColor('rgba(255, 255, 255, 0.8)');
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
  
  /**
   * 统计卡片
   */
  @Builder
  buildStatCard(icon: string, value: string, label: string) {
    Column({ space: 4 }) {
      Text(icon).fontSize(20);
      
      Text(value)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White);
      
      Text(label)
        .fontSize(10)
        .fontColor('rgba(255, 255, 255, 0.8)');
    }
    .width('25%')
    .padding(8)
    .backgroundColor('rgba(255, 255, 255, 0.2)')
    .borderRadius(8)
  }
}

步骤6: 在应用中触发卡片更新

// 添加记录后更新所有卡片
import { FormDataService } from '../services/FormDataService';

async function addFeedingRecord(record: FeedingRecord): Promise<void> {
  // 保存到数据库
  await feedingDao.insert(record);
  
  // 更新所有卡片
  await FormDataService.getInstance().updateAllForms();
}

3. 运行效果

cke_4099.png

关键要点

1. 配置文件必须正确

form_config.json:

  • uiSyntax: "arkts" - 必须指定
  • formConfigAbility - 点击跳转的Ability

module.json5:

  • type: "form" - 扩展类型
  • exported: true - 必须导出

2. 数据绑定

使用@LocalStorageProp绑定卡片数据:

@LocalStorageProp('todayCount') todayCount: number = 0;

3. 更新机制

自动更新:

  • 系统定时更新(updateDuration)
  • 定点更新(scheduledUpdateTime)

手动更新:

await formProvider.updateForm(formId, formBindingData);

4. 点击跳转

postCardAction(this, {
  action: 'router',
  abilityName: 'EntryAbility',
  params: {
    page: 'pages/FeedingRecordPage'
  }
});

常见问题

Q1: 卡片无法添加到桌面?

检查配置

更多关于HarmonyOS 鸿蒙Next中如何实现服务卡片的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


鸿蒙Next中实现服务卡片主要通过ArkTS语言开发。使用FormExtensionAbility作为卡片入口,在module.json5中配置卡片信息。卡片UI通过ArkUI组件构建,支持静态和动态数据展示。可通过FormProvider更新卡片数据,使用formBindingData对象管理状态。卡片尺寸在配置文件中定义,支持2x2、2x4等规格。

在HarmonyOS Next中,服务卡片(Form)的开发主要围绕FormExtensionAbility展开。以下是针对你问题的具体实现方案:

1. 创建FormExtensionAbilitymodule.json5配置文件的extensionAbilities模块下声明。

{
  "extensionAbilities": [{
    "name": "FormAbility",
    "srcEntry": "./ets/FormAbility/FormAbility.ts",
    "label": "$string:form_label",
    "description": "$string:form_desc",
    "type": "form",
    "metadata": [{
      "name": "ohos.extension.form",
      "resource": "$profile:form_config"
    }]
  }]
}

resources/base/profile/目录下创建form_config.json文件,定义卡片的具体配置(如名称、尺寸、窗口类型等)。

对应的Ability类需继承FormExtensionAbility并实现生命周期回调:

import FormExtensionAbility from '@ohos.app.form.FormExtensionAbility';

export default class FormAbility extends FormExtensionAbility {
  onAddForm(want) {
    // 创建卡片时调用,返回卡片数据
    let formData = {};
    return formData;
  }
  // 其他生命周期:onCastToNormalForm, onUpdateForm, onChangeFormVisibility, onRemoveForm
}

2. 从数据库获取卡片数据onAddFormonUpdateForm回调中,使用相关数据管理API查询。

  • 若使用关系型数据库,可通过@ohos.data.relationalStore获取RDB连接并查询。
  • 若使用对象型数据库,可通过@ohos.data.objectStore访问。
  • 也可通过@ohos.data.preferences获取轻量级键值数据。

示例(RDB查询):

import relationalStore from '@ohos.data.relationalStore';
// ... 建立连接后
let predicates = new relationalStore.RdbPredicates('TABLE_NAME');
let resultSet = await rdbStore.query(predicates, ['column1', 'column2']);
// 将resultSet数据转换为卡片数据格式

3. 卡片自动更新

  • 定时更新:在form_config.json中配置updateDuration字段(例如设为1小时),系统会按周期触发onUpdateForm回调。
  • 主动更新:在应用内或卡片Provider中,调用formProvider.updateForm方法强制更新指定卡片。
  • 结合数据管理:可在onUpdateForm中重新查询数据库,获取最新数据并返回。

4. 点击卡片跳转应用 在卡片布局的.hml.ets文件中,为需要点击的区域绑定routerEvent事件。

// 卡片Provider中
import formProvider from '@ohos.app.form.formProvider';
import Want from '@ohos.app.ability.Want';

// 定义跳转目标Ability的Want信息
let targetWant: Want = {
  bundleName: 'com.example.myapp',
  abilityName: 'EntryAbility'
};
// 在事件回调中触发跳转
formProvider.setRouterEvent(this.formId, targetWant);

关键点总结

  • 卡片配置(form_config.json)是定义外观和基本行为的核心。
  • FormExtensionAbility的生命周期方法承载了卡片的数据逻辑。
  • 数据获取依赖于标准的数据管理API,与普通应用开发方式一致。
  • 更新机制由系统调度(定时)或应用触发(主动)。
  • 路由事件通过formProvider.setRouterEvent实现跳转。

请根据实际业务需求,在对应的生命周期回调中实现数据获取、更新和事件处理逻辑。

回到顶部