HarmonyOS开发:静态卡片开发——BasicCard实现

举报
Jack20 发表于 2026/06/27 22:26:04 2026/06/27
【摘要】 HarmonyOS开发:静态卡片开发——BasicCard实现📌 核心要点:静态卡片是元服务最常用的形态,通过form_config.json配置 + ArkTS布局 + FormBindingData数据驱动,实现轻量级信息展示。 背景与动机上一篇聊了元服务的概念和FormAbility的基本结构。现在该动手了——从零写一张静态卡片。为什么先学静态卡片?因为它是元服务开发的基本功。你做...

HarmonyOS开发:静态卡片开发——BasicCard实现

📌 核心要点:静态卡片是元服务最常用的形态,通过form_config.json配置 + ArkTS布局 + FormBindingData数据驱动,实现轻量级信息展示。

背景与动机

上一篇聊了元服务的概念和FormAbility的基本结构。现在该动手了——从零写一张静态卡片。

为什么先学静态卡片?因为它是元服务开发的基本功。你做10个元服务项目,8个用静态卡片就够了。天气、日程、待办、快递、步数……这些信息展示类场景,静态卡片完全hold住。

动态卡片虽然交互更丰富,但开发复杂度高、性能开销大、调试麻烦。先把静态卡片吃透,后面做动态卡片才能有的放矢。

那静态卡片开发到底分几步?

  1. 写form_config.json配置文件
  2. 写卡片布局(ArkTS)
  3. 写FormAbility提供数据
  4. 在module.json5中声明

四步走,缺一不可。但每一步都有坑,下面一个一个来。

核心原理

静态卡片的工作机制

静态卡片的核心机制就一句话:布局你写,渲染系统做,数据FormAbility供

flowchart TB
    subgraph 开发阶段
        A[form_config.json<br/>卡片声明配置] --> B[WidgetCard.ets<br/>卡片布局文件]
        B --> C[FormAbility.ets<br/>数据提供方]
        C --> D[module.json5<br/>注册声明]
    end
    
    subgraph 运行时
        E[用户添加卡片] --> F[系统读取form_config]
        F --> G[触发FormAbility.onAddForm]
        G --> H[获取FormBindingData]
        H --> I[系统渲染引擎<br/>合并布局+数据]
        I --> J[卡片显示在桌面]
        
        K[定时/手动更新] --> L[触发onUpdateForm]
        L --> M[获取新FormBindingData]
        M --> I
    end
    
    D --> E
    
    classDef devPhase fill:#E8EAF6,stroke:#283593,color:#1A237E
    classDef runtime fill:#E0F2F1,stroke:#00695C,color:#004D40
    classDef core fill:#FFF8E1,stroke:#F57F17,color:#E65100
    
    class A,B,C,D devPhase
    class E,F,G,H,I,J,K,L,M runtime

注意那个"系统渲染引擎"——这是静态卡片和动态卡片的本质区别。静态卡片的UI不是你的进程渲染的,是系统渲染引擎拿着你的布局文件和数据,在系统侧完成渲染。这意味着:

  • 卡片渲染不占你应用的内存
  • 即使你的应用进程被杀,卡片照样显示
  • 但你没法控制渲染时机,只能"喂数据"

form_config.json详解

这个配置文件是卡片的"身份证",告诉系统你提供什么卡片、支持什么尺寸、多久更新一次。

{
  "forms": [
    {
      // 卡片名称,FormAbility通过这个区分不同卡片
      "name": "todoCard",
      
      // 用户在卡片选择器看到的名称
      "displayName": "待办事项",
      
      // 卡片描述
      "description": "显示今日待办事项列表",
      
      // 卡片布局文件路径(相对于src/main/ets/)
      "src": "./ets/widget/pages/TodoCard.ets",
      
      // 语法类型,静态卡片用arkts
      "uiSyntax": "arkts",
      
      // 窗口设计宽度,影响布局计算
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      
      // 颜色模式:auto跟随系统,dark暗色,light亮色
      "colorMode": "auto",
      
      // 是否为默认卡片(长按桌面时优先展示)
      "isDefault": true,
      
      // 是否启用定时更新
      "updateEnabled": true,
      
      // 定时更新时间(24小时制,如"10:30"表示10:30触发更新)
      "scheduledUpdateTime": "08:00",
      
      // 更新持续时间(0表示只在scheduledUpdateTime更新一次,
      // 1表示从scheduledUpdateTime开始每小时更新一次)
      "updateDuration": 1,
      
      // 默认卡片尺寸
      "defaultDimension": "2*2",
      
      // 支持的卡片尺寸列表
      "supportDimensions": ["2*2", "2*4", "4*4"],
      
      // 卡片是否可见(开发调试时可设为false隐藏)
      "formVisibleNotify": true,
      
      // 元数据
      "metadata": [],
      
      // 深度链接,点击卡片时跳转的Ability
      "deepLink": "myapp://todo/today"
    }
  ]
}

每个字段都有讲究,但最容易搞错的是这几个:

  • updateDuration:不是"更新持续多久",是"从scheduledUpdateTime开始,每隔多久更新一次"。0=只更新一次,1=每小时更新一次。最大值是16,即每16小时更新一次。
  • supportDimensions:必须包含defaultDimension,否则编译报错。
  • designWidth:720是推荐值,别乱改。改了你的布局可能全乱。

代码实战

基础用法:最简待办卡片

先来个最简单的——一张2x2的待办事项卡片,显示今日待办数量和最近一条待办。

第一步:form_config.json

{
  "forms": [{
    "name": "todoCard",
    "displayName": "待办事项",
    "description": "今日待办一览",
    "src": "./ets/widget/pages/TodoCard.ets",
    "uiSyntax": "arkts",
    "window": { "designWidth": 720, "autoDesignWidth": true },
    "colorMode": "auto",
    "isDefault": true,
    "updateEnabled": true,
    "scheduledUpdateTime": "07:00",
    "updateDuration": 1,
    "defaultDimension": "2*2",
    "supportDimensions": ["2*2"]
  }]
}

第二步:卡片布局 TodoCard.ets

@Entry
@Component
struct TodoCard {
  // 这些变量名必须和FormBindingData的key一一对应
  @State todoCount: string = '0'
  @State latestTodo: string = '暂无待办'
  @State dateStr: string = ''

  build() {
    Column() {
      // 顶部:日期
      Row() {
        Text(this.dateStr)
          .fontSize(12)
          .fontColor('#999999')
      }
      .width('100%')
      .justifyContent(FlexAlign.Start)
      
      // 中部:待办数量
      Row() {
        Text(this.todoCount)
          .fontSize(32)
          .fontColor('#FF6B35')
          .fontWeight(FontWeight.Bold)
        
        Text('项待办')
          .fontSize(14)
          .fontColor('#666666')
          .margin({ left: 4, top: 14 })
      }
      .margin({ top: 12 })
      .alignItems(VerticalAlign.Bottom)
      
      // 底部:最近一条
      Text(this.latestTodo)
        .fontSize(12)
        .fontColor('#333333')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .margin({ top: 8 })
        .width('100%')
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
  }
}

第三步:FormAbility

import { formBindingData, FormExtensionAbility } from '@kit.AbilityKit'
import { hilog } from '@kit.PerformanceAnalysisKit'

export default class TodoFormAbility extends FormExtensionAbility {
  
  onAddForm(want: Want): formBindingData.FormBindingData {
    const now = new Date()
    const dateStr = `${now.getMonth() + 1}${now.getDate()}`
    
    // 模拟数据——实际项目从数据库或DataShare读取
    const todos = this.getTodayTodos()
    
    return formBindingData.createFormBindingData({
      dateStr: dateStr,
      todoCount: todos.length.toString(),
      latestTodo: todos.length > 0 ? todos[0] : '暂无待办'
    })
  }
  
  onUpdateForm(formId: string): formBindingData.FormBindingData {
    // 更新逻辑和创建一样,重新获取最新数据
    return this.onAddForm(null)
  }
  
  onRemoveForm(formId: string): void {
    hilog.info(0x0000, 'TodoForm', `卡片删除: ${formId}`)
  }
  
  private getTodayTodos(): string[] {
    return ['完成周报', '修复登录Bug', '评审设计稿']
  }
}

第四步:module.json5声明

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

四步走完,编译运行,长按桌面空白处,就能看到你的待办卡片了。

进阶用法:多尺寸卡片适配

实际产品中,用户可能选择不同尺寸的卡片。2x2显示概要,2x4显示列表,4x4显示详细信息。怎么用一套代码适配多种尺寸?

关键在于:form_config.json里声明多个尺寸,但布局文件只需要一个。你通过判断卡片宽度来切换布局。

{
  "forms": [{
    "name": "todoCard",
    "displayName": "待办事项",
    "src": "./ets/widget/pages/TodoCard.ets",
    "defaultDimension": "2*2",
    "supportDimensions": ["2*2", "2*4", "4*4"]
  }]
}
@Entry
@Component
struct TodoCard {
  @State todoCount: string = '0'
  @State latestTodo: string = '暂无待办'
  @State dateStr: string = ''
  @State todoList: string = ''  // 2x4和4x4用,逗号分隔
  @State detailInfo: string = '' // 4x4专用

  build() {
    Column() {
      // 通用顶部
      Row() {
        Text(this.dateStr)
          .fontSize(12)
          .fontColor('#999999')
      }
      .width('100%')
      
      // 根据宽度判断尺寸——2x2约150vp,2x4约300vp,4x4约300vp
      // 使用响应式布局
      Flex({ direction: FlexDirection.Column, justifyContent: FlexAlign.SpaceBetween }) {
        // 待办数量(所有尺寸都显示)
        Row() {
          Text(this.todoCount)
            .fontSize(32)
            .fontColor('#FF6B35')
            .fontWeight(FontWeight.Bold)
          Text('项待办')
            .fontSize(14)
            .fontColor('#666666')
            .margin({ left: 4, top: 14 })
        }
        
        // 最近待办(2x2显示)
        Text(this.latestTodo)
          .fontSize(12)
          .fontColor('#333333')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ top: 8 })
      }
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
  }
}

但说实话,这种"一个布局适配所有尺寸"的方式效果一般。更好的做法是:在form_config.json里为不同尺寸声明不同的布局文件

{
  "forms": [
    {
      "name": "todoCardSmall",
      "displayName": "待办概要",
      "src": "./ets/widget/pages/TodoCardSmall.ets",
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2"]
    },
    {
      "name": "todoCardMedium",
      "displayName": "待办列表",
      "src": "./ets/widget/pages/TodoCardMedium.ets",
      "defaultDimension": "2*4",
      "supportDimensions": ["2*4"]
    },
    {
      "name": "todoCardLarge",
      "displayName": "待办详情",
      "src": "./ets/widget/pages/TodoCardLarge.ets",
      "defaultDimension": "4*4",
      "supportDimensions": ["4*4"]
    }
  ]
}

这样每种尺寸有独立的布局文件,互不干扰,维护起来也清晰。代价是FormAbility里要根据formName返回不同数据。

完整示例:天气卡片(含图片资源)

静态卡片支持Image组件,你可以用图片让卡片更好看。但图片资源的使用有讲究——只能用本地资源,不能加载网络图片

// WeatherCard.ets —— 带图标的天气卡片
@Entry
@Component
struct WeatherCard {
  @State city: string = ''
  @State temperature: string = ''
  @State weatherDesc: string = ''
  @State weatherType: string = 'sunny' // sunny/cloudy/rainy/snowy
  @State updateTime: string = ''
  @State aqi: string = '' // 空气质量
  @State humidity: string = '' // 湿度

  build() {
    Column() {
      // 顶栏:城市 + 更新时间
      Row() {
        Text(this.city)
          .fontSize(14)
          .fontColor('#333333')
          .fontWeight(FontWeight.Bold)
        
        Blank()
        
        Text(this.updateTime)
          .fontSize(10)
          .fontColor('#AAAAAA')
      }
      .width('100%')
      
      // 主信息区:温度 + 天气图标
      Row() {
        Column() {
          Text(this.temperature)
            .fontSize(40)
            .fontColor('#FF6B35')
            .fontWeight(FontWeight.Bold)
          
          Text(this.weatherDesc)
            .fontSize(12)
            .fontColor('#666666')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        
        Blank()
        
        // 天气图标——只能用本地资源
        Image(this.getWeatherIcon(this.weatherType))
          .width(48)
          .height(48)
          .objectFit(ImageFit.Contain)
      }
      .width('100%')
      .layoutWeight(1)
      .alignItems(VerticalAlign.Center)
      
      // 底部信息栏:湿度 + 空气质量
      Row() {
        Row() {
          Text('💧')
            .fontSize(12)
          Text(this.humidity)
            .fontSize(11)
            .fontColor('#666666')
            .margin({ left: 2 })
        }
        
        Blank()
        
        Row() {
          Text('AQI ')
            .fontSize(11)
            .fontColor('#999999')
          Text(this.aqi)
            .fontSize(11)
            .fontColor(this.getAqiColor(this.aqi))
            .fontWeight(FontWeight.Bold)
        }
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#F8F9FA')
    .borderRadius(16)
  }
  
  // 根据天气类型返回图标资源
  private getWeatherIcon(type: string): Resource {
    const iconMap: Record<string, Resource> = {
      'sunny': $r('app.media.weather_sunny'),
      'cloudy': $r('app.media.weather_cloudy'),
      'rainy': $r('app.media.weather_rainy'),
      'snowy': $r('app.media.weather_snowy')
    }
    return iconMap[type] || $r('app.media.weather_sunny')
  }
  
  // 根据AQI返回颜色
  private getAqiColor(aqi: string): string {
    const val = parseInt(aqi)
    if (val <= 50) return '#4CAF50'   // 优
    if (val <= 100) return '#FFC107'  // 良
    if (val <= 150) return '#FF9800'  // 轻度污染
    return '#F44336'                   // 中度及以上
  }
}

对应的FormAbility:

export default class WeatherFormAbility extends FormExtensionAbility {
  
  onAddForm(want: Want): formBindingData.FormBindingData {
    const weatherData = this.fetchWeatherData()
    
    return formBindingData.createFormBindingData({
      city: weatherData.city,
      temperature: weatherData.temperature,
      weatherDesc: weatherData.weatherDesc,
      weatherType: weatherData.weatherType,
      updateTime: weatherData.updateTime,
      aqi: weatherData.aqi,
      humidity: weatherData.humidity
    })
  }
  
  onUpdateForm(formId: string): formBindingData.FormBindingData {
    return this.onAddForm(null)
  }
  
  onRemoveForm(formId: string): void {
    // 清理
  }
  
  private fetchWeatherData(): WeatherData {
    // 实际项目从缓存或DataShare读取
    return {
      city: '北京',
      temperature: '26°',
      weatherDesc: '晴',
      weatherType: 'sunny',
      updateTime: this.getCurrentTime(),
      aqi: '42',
      humidity: '45%'
    }
  }
  
  private getCurrentTime(): string {
    const now = new Date()
    return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`
  }
}

图片资源放在 src/main/resources/base/media/ 目录下,文件名对应 $r('app.media.xxx') 中的xxx。比如 weather_sunny.pngweather_cloudy.png 等。

踩坑与注意事项

坑1:@State变量名必须和数据key完全匹配

这是新手踩得最多的坑。你在FormAbility里返回的数据key叫todoCount,那卡片里的@State变量也必须叫todoCount,一个字母都不能差。

// ❌ 不匹配,卡片显示空白
// FormAbility返回: { count: '3' }
// 卡片里: @State todoCount: string = '0'

// ✅ 匹配,正常显示
// FormAbility返回: { todoCount: '3' }
// 卡片里: @State todoCount: string = '0'

而且类型也要匹配。FormAbility返回的是字符串,@State就得声明string。返回数字,@State就得声明number。类型不匹配,数据注入失败,卡片显示默认值。

坑2:卡片布局不支持所有ArkTS能力

静态卡片的布局能力是受限的。以下能力在静态卡片中不支持

  • @Builder 装饰器
  • @Extend 装饰器
  • @Styles 装饰器
  • 自定义组件(@Component
  • animateTo 动画
  • Canvas 绘制
  • Web 组件
  • LazyForEach
  • @Watch@Link@Provide@Consume

这意味着什么?你的卡片布局必须是一个扁平的结构,所有UI都写在一个build()方法里。不能抽取公共组件,不能用Builder简化代码。

// ❌ 静态卡片不能用自定义组件
@Component
struct WeatherIcon {  // 编译报错!
  build() { ... }
}

// ✅ 只能在build()里内联写
build() {
  Image($r('app.media.weather_sunny'))
    .width(48)
    .height(48)
}

坑3:图片资源只能用本地资源

静态卡片不支持网络图片。Image('https://xxx.com/icon.png') 在卡片里是显示不出来的。

你有两种选择:

  1. 用本地资源:把图片放在resources/base/media/下,用$r('app.media.xxx')引用
  2. 用系统图标:用$r('sys.media.xxx')引用系统资源

但注意,卡片图片资源的大小也有限制。单张图片建议不超过50KB,否则可能加载失败或导致卡片白屏。

坑4:卡片布局调试困难

静态卡片的布局调试是个老大难问题。你在DevEco Studio里预览的效果,和实际在桌面上的效果可能不一样。原因是:

  • 预览器用的是720设计宽度,桌面可能用不同的渲染参数
  • 桌面有圆角裁剪,预览器没有
  • 不同设备的DPI不同,字体和间距可能偏差

怎么办?实机调试,没有捷径。多跑几台设备,特别是不同DPI的设备,看看效果是否一致。

坑5:FormBindingData数据量控制

前面提过1KB限制。这里给个实际的参考——1KB大概能装多少数据?

// 这个数据大约800字节,接近1KB上限
const data = {
  title: '今日待办事项',           // ~20字节
  count: '5',                      // ~8字节
  item1: '完成周报编写和提交',      // ~25字节
  item2: '修复用户反馈的登录问题',   // ~28字节
  item3: '参加下午3点的设计评审',    // ~26字节
  item4: '更新项目文档',            // ~16字节
  item5: '准备明天的演示材料',       // ~22字节
  date: '2026年6月27日',           // ~18字节
  updateTime: '14:30'              // ~14字节
}
// 总计约177字节(UTF-8编码中文3字节/字)

中文比英文更费空间,一个汉字3字节。所以1KB大概能装300个汉字左右。够用了,但别浪费。

HarmonyOS 6适配说明

  1. 静态卡片布局引擎升级:6.0的静态卡片渲染引擎支持了更多布局能力,包括Flexwrap属性和Grid的自动布局。5.0里必须手算列数的Grid布局,6.0可以自动适配了。

  2. 图片资源管理变化:6.0对卡片图片资源的管理更加严格,图片必须放在resources/base/media/目录下,不支持从rawfile目录引用。5.0里能用$rawfile('xxx.png')的写法,6.0不行了。

  3. 卡片预览增强:6.0的DevEco Studio支持在预览器中直接预览卡片在不同尺寸下的效果,不用再每次都跑真机调试了。这是最大的开发体验提升。

  4. 深色模式适配:6.0对卡片的深色模式支持更完善。之前colorMode: "auto"经常出现深色模式下文字看不见的问题(黑底黑字),6.0修复了系统级的颜色适配逻辑,但你仍然需要在布局中用语义化颜色而非硬编码颜色值。

// ❌ 硬编码颜色,深色模式下可能看不见
.fontColor('#333333')
.backgroundColor('#FFFFFF')

// ✅ 使用系统资源颜色,自动适配深色模式
.fontColor($r('sys.color.ohos_id_color_text_primary'))
.backgroundColor($r('sys.color.ohos_id_color_background'))

总结

静态卡片开发的核心就是"配置+布局+数据"三件套。form_config.json管声明,ArkTS管展示,FormBindingData管数据。三者缺一不可,任何一环出问题,卡片都跑不起来。

关键要点回顾:

  • form_config.json是卡片的身份证,每个字段都有讲究
  • 卡片布局能力受限,不能用自定义组件、Builder、动画等
  • @State变量名必须和FormBindingData的key完全匹配
  • 图片只能用本地资源,不支持网络图片
  • 数据量不超过1KB,中文要特别注意空间
评估维度 说明
学习难度 ⭐⭐ 上手简单,但限制多,需要反复调试
使用频率 ⭐⭐⭐⭐⭐ 静态卡片是元服务最常用的形态
重要程度 ⭐⭐⭐⭐⭐ 不做静态卡片,元服务等于没做

下一篇我们聊动态卡片——当你需要实时交互和更丰富的UI时,动态卡片是怎么玩的。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。