第31篇:桌面小组件开发

📚 本篇导读

桌面小组件(Form卡片)是HarmonyOS的特色功能,可以在桌面直接显示应用信息,提供快捷操作入口。本篇教程将实现天气卡片、任务提醒卡片等实用小组件。

本篇将实现

  • 🌤️ 天气卡片(显示当前天气和温度)
  • 📋 任务提醒卡片(显示待办任务)
  • 🌾 节气卡片(显示当前节气和农事建议)
  • 📊 数据统计卡片(显示地块和作物统计)
  • 🔄 卡片更新机制(定时更新、手动刷新)

🎯 学习目标

完成本篇教程后,你将掌握:

  1. HarmonyOS Form卡片的开发流程
  2. 卡片生命周期管理
  3. 卡片数据更新机制
  4. 卡片与主应用的交互
  5. 卡片布局设计和适配
  6. 卡片性能优化

一、Form卡片基础

1.1 Form卡片概述

Form卡片是一种轻量级的UI展示形式,具有以下特点:

  • 独立运行:卡片在桌面独立显示,不依赖主应用
  • 快速访问:用户无需打开应用即可查看信息
  • 定时更新:支持定时刷新数据
  • 交互能力:支持点击跳转和简单交互

1.2 卡片尺寸规格

HarmonyOS支持多种卡片尺寸:

尺寸类型 宽度 高度 适用场景
1x2 2列 1行 简单信息展示
2x2 2列 2行 常用卡片尺寸
2x4 4列 2行 详细信息展示
4x4 4列 4行 复杂内容展示

1.3 技术架构

Form卡片系统
├── FormExtensionAbility
│   ├── onAddForm(创建卡片)
│   ├── onUpdateForm(更新卡片)
│   ├── onFormEvent(处理事件)
│   └── onRemoveForm(删除卡片)
│
├── 卡片页面(ETS)
│   ├── 天气卡片
│   ├── 任务卡片
│   └── 节气卡片
│
└── 数据服务
    ├── 数据获取
    ├── 数据缓存
    └── 定时更新

二、配置Form卡片能力

2.1 module.json5配置

entry/src/main/module.json5 中配置Form扩展能力:

{
  "module": {
    "extensionAbilities": [
      {
        "name": "EntryFormAbility",
        "srcEntry": "./ets/entryformability/EntryFormAbility.ets",
        "type": "form",
        "exported": true,
        "metadata": [
          {
            "name": "ohos.extension.form",
            "resource": "$profile:form_config"
          }
        ]
      }
    ]
  }
}

2.2 创建form_config.json

entry/src/main/resources/base/profile/form_config.json 中定义卡片配置:

{
  "forms": [
    {
      "name": "WeatherWidget",
      "description": "天气卡片",
      "src": "./ets/widget/pages/WeatherCard.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"]
    },
    {
      "name": "TaskWidget",
      "description": "任务提醒卡片",
      "src": "./ets/widget/pages/TaskCard.ets",
      "uiSyntax": "arkts",
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": false,
      "updateEnabled": true,
      "updateDuration": 1,
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2", "2*4"]
    },
    {
      "name": "SolarTermWidget",
      "description": "节气卡片",
      "src": "./ets/widget/pages/SolarTermCard.ets",
      "uiSyntax": "arkts",
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": false,
      "updateEnabled": true,
      "scheduledUpdateTime": "08:00",
      "updateDuration": 24,
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2"]
    }
  ]
}

三、实现FormExtensionAbility

3.1 基础框架

文件位置entry/src/main/ets/entryformability/EntryFormAbility.ets

import { formBindingData, FormExtensionAbility, formInfo, formProvider } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG = 'EntryFormAbility';
const DOMAIN = 0x0000;

export default class EntryFormAbility extends FormExtensionAbility {
  /**
   * 创建卡片时调用
   * @param want 卡片信息
   * @returns FormBindingData 卡片数据
   */
  onAddForm(want: Want) {
    hilog.info(DOMAIN, TAG, '[onAddForm] Form added');
    
    try {
      // 获取卡片名称
      const formName = want.parameters?.['ohos.extra.param.key.form_name'] as string;
      hilog.info(DOMAIN, TAG, `[onAddForm] Form name: ${formName}`);

      // 根据卡片类型返回不同的数据
      let formData = {};
      
      switch (formName) {
        case 'WeatherWidget':
          formData = this.getWeatherData();
          break;
        case 'TaskWidget':
          formData = this.getTaskData();
          break;
        case 'SolarTermWidget':
          formData = this.getSolarTermData();
          break;
        default:
          formData = { message: '未知卡片类型' };
      }

      return formBindingData.createFormBindingData(formData);
    } catch (error) {
      hilog.error(DOMAIN, TAG, `[onAddForm] Error: ${JSON.stringify(error)}`);
      return formBindingData.createFormBindingData({});
    }
  }

  /**
   * 卡片更新时调用
   * @param formId 卡片ID
   */
  onUpdateForm(formId: string) {
    hilog.info(DOMAIN, TAG, `[onUpdateForm] Form ${formId} updated`);
    
    try {
      // 获取更新的数据
      const formData = this.getWeatherData(); // 示例:更新天气数据
      
      // 更新卡片
      formProvider.updateForm(formId, formBindingData.createFormBindingData(formData));
    } catch (error) {
      hilog.error(DOMAIN, TAG, `[onUpdateForm] Error: ${JSON.stringify(error)}`);
    }
  }

  /**
   * 卡片事件处理
   * @param formId 卡片ID
   * @param message 事件消息
   */
  onFormEvent(formId: string, message: string) {
    hilog.info(DOMAIN, TAG, `[onFormEvent] Form ${formId}, message: ${message}`);
    
    try {
      const eventData = JSON.parse(message);
      
      // 处理不同的事件
      switch (eventData.action) {
        case 'refresh':
          this.onUpdateForm(formId);
          break;
        case 'openApp':
          // 打开主应用
          break;
        default:
          hilog.warn(DOMAIN, TAG, `Unknown event: ${eventData.action}`);
      }
    } catch (error) {
      hilog.error(DOMAIN, TAG, `[onFormEvent] Error: ${JSON.stringify(error)}`);
    }
  }

  /**
   * 卡片删除时调用
   * @param formId 卡片ID
   */
  onRemoveForm(formId: string) {
    hilog.info(DOMAIN, TAG, `[onRemoveForm] Form ${formId} removed`);
    // 清理资源
  }

  /**
   * 临时卡片转为正式卡片
   * @param formId 卡片ID
   */
  onCastToNormalForm(formId: string) {
    hilog.info(DOMAIN, TAG, `[onCastToNormalForm] Form ${formId} cast to normal`);
  }

  /**
   * 获取卡片状态
   * @param want 卡片信息
   * @returns FormState 卡片状态
   */
  onAcquireFormState(want: Want) {
    return formInfo.FormState.READY;
  }

  /**
   * 获取天气数据
   */
  private getWeatherData(): Record<string, Object> {
    return {
      temperature: '22°C',
      weather: '晴',
      location: '北京',
      humidity: '65%',
      wind: '东南风 2级',
      updateTime: new Date().toLocaleTimeString('zh-CN', {
        hour: '2-digit',
        minute: '2-digit'
      })
    };
  }

  /**
   * 获取任务数据
   */
  private getTaskData(): Record<string, Object> {
    return {
      totalTasks: 5,
      completedTasks: 2,
      todayTasks: [
        { title: '浇水施肥', time: '09:00' },
        { title: '检查病虫害', time: '14:00' },
        { title: '记录生长情况', time: '17:00' }
      ]
    };
  }

  /**
   * 获取节气数据
   */
  private getSolarTermData(): Record<string, Object> {
    return {
      currentTerm: '立春',
      nextTerm: '雨水',
      daysToNext: 12,
      suggestion: '适宜播种春季作物'
    };
  }
}

四、实现天气卡片

4.1 卡片页面

文件位置entry/src/main/ets/widget/pages/WeatherCard.ets

@Entry
@Component
struct WeatherCard {
  @LocalStorageProp('temperature') temperature: string = '--°C';
  @LocalStorageProp('weather') weather: string = '--';
  @LocalStorageProp('location') location: string = '--';
  @LocalStorageProp('humidity') humidity: string = '--';
  @LocalStorageProp('wind') wind: string = '--';
  @LocalStorageProp('updateTime') updateTime: string = '--';

  build() {
    Column() {
      // 头部:位置和更新时间
      Row() {
        Text(`📍 ${this.location}`)
          .fontSize(14)
          .fontColor('#333333')
          .layoutWeight(1)

        Text(this.updateTime)
          .fontSize(12)
          .fontColor('#999999')
      }
      .width('100%')
      .margin({ bottom: 12 })

      // 主要信息:温度和天气
      Row() {
        Column() {
          Text(this.temperature)
            .fontSize(48)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FF6B35')

          Text(this.weather)
            .fontSize(16)
            .fontColor('#666666')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        // 天气图标
        Text(this.getWeatherIcon(this.weather))
          .fontSize(64)
      }
      .width('100%')
      .margin({ bottom: 16 })

      // 详细信息
      Row() {
        Column({ space: 4 }) {
          Text('💧 湿度')
            .fontSize(12)
            .fontColor('#999999')
          Text(this.humidity)
            .fontSize(14)
            .fontColor('#333333')
        }
        .layoutWeight(1)

        Column({ space: 4 }) {
          Text('🌬️ 风力')
            .fontSize(12)
            .fontColor('#999999')
          Text(this.wind)
            .fontSize(14)
            .fontColor('#333333')
        }
        .layoutWeight(1)
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .onClick(() => {
      // 点击卡片打开主应用
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: { page: 'weather' }
      });
    })
  }

  /**
   * 根据天气获取对应的图标
   */
  getWeatherIcon(weather: string): string {
    const iconMap: Record<string, string> = {
      '晴': '☀️',
      '多云': '⛅',
      '阴': '☁️',
      '雨': '🌧️',
      '雪': '❄️',
      '雾': '🌫️'
    };
    return iconMap[weather] || '🌤️';
  }
}

五、实现任务提醒卡片

5.1 卡片页面

文件位置entry/src/main/ets/widget/pages/TaskCard.ets

interface Task {
  title: string;
  time: string;
}

@Entry
@Component
struct TaskCard {
  @LocalStorageProp('totalTasks') totalTasks: number = 0;
  @LocalStorageProp('completedTasks') completedTasks: number = 0;
  @LocalStorageProp('todayTasks') todayTasks: Task[] = [];

  build() {
    Column() {
      // 头部:标题和统计
      Row() {
        Text('📋 今日任务')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .layoutWeight(1)

        Text(`${this.completedTasks}/${this.totalTasks}`)
          .fontSize(14)
          .fontColor('#4CAF50')
      }
      .width('100%')
      .margin({ bottom: 12 })

      // 进度条
      Progress({ value: this.completedTasks, total: this.totalTasks, type: ProgressType.Linear })
        .width('100%')
        .height(6)
        .color('#4CAF50')
        .backgroundColor('#E0E0E0')
        .margin({ bottom: 16 })

      // 任务列表
      if (this.todayTasks.length > 0) {
        Column({ space: 8 }) {
          ForEach(this.todayTasks.slice(0, 3), (task: Task, index: number) => {
            this.buildTaskItem(task, index);
          })
        }
        .width('100%')
      } else {
        Column() {
          Text('✅')
            .fontSize(32)
            .margin({ bottom: 8 })
          Text('暂无待办任务')
            .fontSize(14)
            .fontColor('#999999')
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .onClick(() => {
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: { page: 'tasks' }
      });
    })
  }

  @Builder
  buildTaskItem(task: Task, index: number) {
    Row() {
      Text(`${index + 1}`)
        .fontSize(12)
        .fontColor('#FFFFFF')
        .width(20)
        .height(20)
        .textAlign(TextAlign.Center)
        .backgroundColor('#2196F3')
        .borderRadius(10)
        .margin({ right: 8 })

      Column({ space: 2 }) {
        Text(task.title)
          .fontSize(14)
          .fontColor('#333333')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Text(task.time)
          .fontSize(12)
          .fontColor('#999999')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
    .padding(8)
    .backgroundColor('#F5F5F5')
    .borderRadius(8)
  }
}

六、实现节气卡片

6.1 卡片页面

文件位置entry/src/main/ets/widget/pages/SolarTermCard.ets

@Entry
@Component
struct SolarTermCard {
  @LocalStorageProp('currentTerm') currentTerm: string = '--';
  @LocalStorageProp('nextTerm') nextTerm: string = '--';
  @LocalStorageProp('daysToNext') daysToNext: number = 0;
  @LocalStorageProp('suggestion') suggestion: string = '--';

  build() {
    Column() {
      // 当前节气
      Column({ space: 8 }) {
        Text('🌾 当前节气')
          .fontSize(14)
          .fontColor('#999999')

        Text(this.currentTerm)
          .fontSize(32)
          .fontWeight(FontWeight.Bold)
          .fontColor('#4CAF50')
      }
      .width('100%')
      .margin({ bottom: 16 })

      // 下一个节气
      Row() {
        Column({ space: 4 }) {
          Text('下一节气')
            .fontSize(12)
            .fontColor('#999999')
          Text(this.nextTerm)
            .fontSize(16)
            .fontColor('#333333')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Column({ space: 4 }) {
          Text('距离')
            .fontSize(12)
            .fontColor('#999999')
          Text(`${this.daysToNext}`)
            .fontSize(16)
            .fontColor('#FF9800')
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#F5F5F5')
      .borderRadius(8)
      .margin({ bottom: 16 })

      // 农事建议
      Column({ space: 8 }) {
        Text('💡 农事建议')
          .fontSize(12)
          .fontColor('#999999')
          .width('100%')

        Text(this.suggestion)
          .fontSize(14)
          .fontColor('#333333')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .width('100%')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .onClick(() => {
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: { page: 'solarTerm' }
      });
    })
  }
}

七、卡片数据更新

7.1 定时更新

在 FormExtensionAbility 中实现定时更新:

import { formProvider } from '@kit.FormKit';

/**
 * 更新所有卡片
 */
async updateAllForms(): Promise<void> {
  try {
    // 获取所有卡片ID
    const formIds = await formProvider.getAllFormsInfo();

    for (const formInfo of formIds) {
      await this.updateFormById(formInfo.formId);
    }
  } catch (error) {
    hilog.error(DOMAIN, TAG, `[updateAllForms] Error: ${JSON.stringify(error)}`);
  }
}

/**
 * 更新指定卡片
 */
async updateFormById(formId: string): Promise<void> {
  try {
    // 获取最新数据
    const formData = this.getWeatherData();

    // 更新卡片
    await formProvider.updateForm(formId, formBindingData.createFormBindingData(formData));

    hilog.info(DOMAIN, TAG, `[updateFormById] Form ${formId} updated successfully`);
  } catch (error) {
    hilog.error(DOMAIN, TAG, `[updateFormById] Error: ${JSON.stringify(error)}`);
  }
}

7.2 手动刷新

在卡片中添加刷新按钮:

@Entry
@Component
struct WeatherCard {
  // ... 其他代码

  build() {
    Column() {
      // 头部添加刷新按钮
      Row() {
        Text(`📍 ${this.location}`)
          .fontSize(14)
          .fontColor('#333333')
          .layoutWeight(1)

        // 刷新按钮
        Text('🔄')
          .fontSize(16)
          .onClick(() => {
            postCardAction(this, {
              action: 'message',
              params: { action: 'refresh' }
            });
          })
      }
      .width('100%')

      // ... 其他内容
    }
  }
}

7.3 后台更新服务

创建后台任务定期更新卡片数据:

import { backgroundTaskManager } from '@kit.BackgroundTasksKit';

/**
 * 启动后台更新任务
 */
async startBackgroundUpdate(): Promise<void> {
  try {
    // 申请长时任务
    const bgMode = backgroundTaskManager.BackgroundMode.DATA_TRANSFER;
    await backgroundTaskManager.requestSuspendDelay('formUpdate', () => {
      // 更新卡片数据
      this.updateAllForms();
    });
  } catch (error) {
    hilog.error(DOMAIN, TAG, `[startBackgroundUpdate] Error: ${JSON.stringify(error)}`);
  }
}

八、卡片与主应用交互

8.1 从卡片跳转到主应用

在卡片中使用 postCardAction 跳转:

// 跳转到指定页面
postCardAction(this, {
  action: 'router',
  abilityName: 'EntryAbility',
  params: {
    page: 'weather',
    data: { location: this.location }
  }
});

8.2 在主应用中处理跳转

EntryAbility.ets 中处理卡片跳转:

onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  hilog.info(0x0000, 'EntryAbility', 'onNewWant called');

  // 获取卡片传递的参数
  const params = want.parameters;
  if (params) {
    const page = params['page'] as string;
    const data = params['data'];

    // 根据参数跳转到对应页面
    if (page === 'weather') {
      // 跳转到天气页面
      router.pushUrl({
        url: 'pages/Weather/WeatherPage',
        params: data
      });
    } else if (page === 'tasks') {
      // 跳转到任务页面
      router.pushUrl({
        url: 'pages/Task/TaskListPage'
      });
    }
  }
}

九、卡片性能优化

9.1 数据缓存

避免频繁请求数据,使用缓存机制:

class FormDataCache {
  private static cache: Map<string, { data: any, timestamp: number }> = new Map();
  private static CACHE_DURATION = 5 * 60 * 1000; // 5分钟

  /**
   * 获取缓存数据
   */
  static get(key: string): any | null {
    const cached = this.cache.get(key);
    if (!cached) return null;

    const now = Date.now();
    if (now - cached.timestamp > this.CACHE_DURATION) {
      this.cache.delete(key);
      return null;
    }

    return cached.data;
  }

  /**
   * 设置缓存数据
   */
  static set(key: string, data: any): void {
    this.cache.set(key, {
      data: data,
      timestamp: Date.now()
    });
  }
}

9.2 减少更新频率

合理设置更新间隔,避免过于频繁:

{
  "updateEnabled": true,
  "updateDuration": 1,  // 最小1小时
  "scheduledUpdateTime": "10:30"  // 定时更新
}

9.3 优化布局性能

  • 减少嵌套层级
  • 使用固定尺寸
  • 避免复杂动画
  • 优化图片资源

十、实操练习

10.1 添加卡片到桌面

  1. 长按桌面空白处,进入编辑模式
  2. 点击添加小组件,找到"高高种地"
  3. 选择卡片类型(天气、任务、节气)
  4. 调整卡片位置和大小

10.2 测试卡片功能

  1. 查看数据显示,验证信息正确性
  2. 点击刷新按钮,测试手动更新
  3. 点击卡片,测试跳转功能
  4. 等待自动更新,验证定时刷新

10.3 调试卡片

使用DevEco Studio的卡片调试功能:

  1. 运行配置中选择"Form"
  2. 选择要调试的卡片
  3. 查看日志输出
  4. 断点调试代码

十一、常见问题

11.1 卡片不显示

问题:添加卡片后显示空白

解决方案

  1. 检查 form_config.json 配置
  2. 验证卡片页面路径
  3. 确认数据绑定正确
  4. 查看日志错误信息

11.2 卡片不更新

问题:卡片数据长时间不刷新

解决方案

  1. 检查 updateEnabled 配置
  2. 验证更新逻辑实现
  3. 确认后台任务权限
  4. 测试手动刷新功能

11.3 卡片点击无响应

问题:点击卡片没有跳转

解决方案

  1. 检查 postCardAction 调用
  2. 验证 abilityName 正确
  3. 确认主应用处理逻辑
  4. 查看跳转参数传递

11.4 卡片布局错乱

问题:不同尺寸下布局异常

解决方案

  1. 使用响应式布局
  2. 适配不同尺寸规格
  3. 测试各种屏幕密度
  4. 优化布局代码

十二、本篇小结

本篇教程实现了完整的桌面小组件功能,包括:

天气卡片:显示实时天气信息
任务卡片:展示待办任务列表
节气卡片:提供节气和农事建议
数据更新:定时和手动刷新机制
交互功能:卡片点击跳转到主应用

核心技术点

  • FormExtensionAbility 生命周期
  • Form卡片配置和开发
  • 卡片数据绑定和更新
  • 卡片与主应用交互
  • 后台任务和定时更新

最佳实践

  • 合理设置更新频率
  • 使用数据缓存机制
  • 优化卡片性能
  • 提供友好的交互体验
  • 适配多种卡片尺寸

下一篇预告
第32篇将讲解应用测试、优化与调试,包括单元测试、性能优化、内存管理等内容。


📖 参考资料

Logo

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

更多推荐