HarmonyOS开发:元服务实战——完整服务卡片开发

举报
Jack20 发表于 2026/06/27 22:36:12 2026/06/27
【摘要】 HarmonyOS开发:元服务实战——完整服务卡片开发📌 核心要点:从零到一完成一个完整的元服务项目,包含静态卡片、动态卡片、交互、数据更新、多尺寸适配,覆盖从创建到发布的全流程。 背景与动机前面九篇文章,我们把元服务的每个知识点都拆开讲了一遍。概念、布局、数据、生命周期、交互、适配、共享、性能——每个单独看都不难,但拼到一起呢?这就是这篇实战要解决的问题。我们要做一个完整的元服务项目—...

HarmonyOS开发:元服务实战——完整服务卡片开发

📌 核心要点:从零到一完成一个完整的元服务项目,包含静态卡片、动态卡片、交互、数据更新、多尺寸适配,覆盖从创建到发布的全流程。

背景与动机

前面九篇文章,我们把元服务的每个知识点都拆开讲了一遍。概念、布局、数据、生命周期、交互、适配、共享、性能——每个单独看都不难,但拼到一起呢?

这就是这篇实战要解决的问题。

我们要做一个完整的元服务项目——“每日打卡”。功能很简单:用户每天打卡记录习惯,卡片显示今日打卡进度和连续打卡天数。但麻雀虽小五脏俱全,这个项目包含:

  • 静态卡片:2×2显示今日打卡概要
  • 动态卡片:4×4显示打卡详情+交互按钮
  • 交互:点击卡片跳转App,点击"打卡"按钮直接打卡
  • 数据更新:打卡后实时更新卡片数据
  • 多尺寸适配:2×2和4×4两种布局
  • 数据共享:UIAbility和FormAbility共享打卡数据

核心原理

项目架构

flowchart TB
    subgraph 项目结构
        A[entry模块] --> B[EntryAbility<br/>主界面]
        A --> C[CheckInFormAbility<br/>卡片数据提供]
        A --> D[CheckInDataShare<br/>数据共享层]
    end
    
    subgraph 卡片层
        C --> E[静态卡片<br/>CheckInSmall.ets<br/>2×2概要]
        C --> F[动态卡片<br/>CheckInLarge.ets<br/>4×4详情]
    end
    
    subgraph 数据层
        D --> G[RDB数据库<br/>打卡记录]
        D --> H[内存缓存<br/>统计数据]
    end
    
    subgraph 交互层
        E -->|router| B
        F -->|message| C
        F -->|router| B
    end
    
    B -->|写入| D
    C -->|读取| D
    
    classDef module fill:#E3F2FD,stroke:#1565C0,color:#0D47A1
    classDef card fill:#FFF3E0,stroke:#E65100,color:#BF360C
    classDef data fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20
    classDef interaction fill:#FCE4EC,stroke:#C62828,color:#B71C1C
    
    class A,B,C,D module
    class E,F card
    class G,H data

完整开发流程

flowchart LR
    A[1. 创建项目] --> B[2. 配置form_config]
    B --> C[3. 开发卡片布局]
    C --> D[4. 开发FormAbility]
    D --> E[5. 开发UIAbility]
    E --> F[6. 实现数据共享]
    F --> G[7. 实现交互]
    G --> H[8. 性能优化]
    H --> I[9. 测试调试]
    I --> J[10. 发布上架]
    
    classDef step fill:#E3F2FD,stroke:#1565C0,color:#0D47A1
    class A,B,C,D,E,F,G,H,I,J step

代码实战

第一步:创建项目

在DevEco Studio中创建新项目,选择"Empty Ability"模板。然后在entry模块中添加FormAbility。

项目目录结构:

entry/
├── src/main/
│   ├── ets/
│   │   ├── entryability/
│   │   │   └── EntryAbility.ets        # 主界面
│   │   ├── formability/
│   │   │   └── CheckInFormAbility.ets   # 卡片数据提供
│   │   ├── datashare/
│   │   │   └── CheckInDataShare.ets     # 数据共享层
│   │   ├── widget/
│   │   │   └── pages/
│   │   │       ├── CheckInSmall.ets     # 2×2静态卡片
│   │   │       └── CheckInLarge.ets     # 4×4动态卡片
│   │   ├── pages/
│   │   │   └── Index.ets                # 主页面
│   │   ├── common/
│   │   │   ├── CardStateManager.ets     # 卡片状态管理
│   │   │   └── CheckInDataHelper.ets    # 数据操作工具
│   │   └── model/
│   │       └── CheckInModel.ets         # 数据模型
│   ├── resources/
│   │   ├── base/
│   │   │   ├── profile/
│   │   │   │   └── form_config.json     # 卡片配置
│   │   │   ├── media/                   # 图片资源
│   │   │   └── element/                # 字符串资源
│   │   └── rawfile/                     # 原始文件
│   └── module.json5                     # 模块配置

第二步:配置form_config.json

{
  "forms": [
    {
      "name": "checkInSmall",
      "displayName": "每日打卡",
      "description": "显示今日打卡进度",
      "src": "./ets/widget/pages/CheckInSmall.ets",
      "uiSyntax": "arkts",
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": true,
      "updateEnabled": true,
      "scheduledUpdateTime": "06:00",
      "updateDuration": 1,
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2"],
      "formVisibleNotify": true
    },
    {
      "name": "checkInLarge",
      "displayName": "打卡详情",
      "description": "显示打卡详情和操作按钮",
      "src": "./ets/widget/pages/CheckInLarge.ets",
      "uiSyntax": "arkts",
      "isDynamic": true,
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": false,
      "updateEnabled": true,
      "scheduledUpdateTime": "06:00",
      "updateDuration": 1,
      "defaultDimension": "4*4",
      "supportDimensions": ["4*4"],
      "formVisibleNotify": true
    }
  ]
}

第三步:数据模型与数据共享

数据模型

// model/CheckInModel.ets

// 打卡记录
export interface CheckInRecord {
  id: number
  habitName: string
  date: string       // YYYY-MM-DD
  isCompleted: boolean
  completedAt: number // 时间戳
}

// 打卡统计
export interface CheckInStats {
  todayTotal: number      // 今日总打卡项
  todayCompleted: number  // 今日已完成
  streakDays: number      // 连续打卡天数
  habitList: HabitItem[]  // 习惯列表
}

export interface HabitItem {
  name: string
  isCompleted: boolean
  icon: string
}

// 卡片数据(2×2)
export interface SmallCardData {
  completedCount: string
  totalCount: string
  streakDays: string
  dateStr: string
  encouragement: string
}

// 卡片数据(4×4)
export interface LargeCardData {
  completedCount: string
  totalCount: string
  streakDays: string
  dateStr: string
  habit1Name: string
  habit1Done: string
  habit2Name: string
  habit2Done: string
  habit3Name: string
  habit3Done: string
  habit4Name: string
  habit4Done: string
  habit5Name: string
  habit5Done: string
}

数据共享层

// datashare/CheckInDataShare.ets
import { DataShareExtensionAbility, relationalStore } from '@kit.ArkData'
import { hilog } from '@kit.PerformanceAnalysisKit'

const STORE_CONFIG: relationalStore.StoreConfig = {
  name: 'checkin.db',
  securityLevel: relationalStore.SecurityLevel.S1
}

const CREATE_TABLE_SQL = `
  CREATE TABLE IF NOT EXISTS checkin_records (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    habit_name TEXT NOT NULL,
    date TEXT NOT NULL,
    is_completed INTEGER DEFAULT 0,
    completed_at INTEGER DEFAULT 0
  )
`

const CREATE_HABITS_TABLE = `
  CREATE TABLE IF NOT EXISTS habits (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    icon TEXT DEFAULT 'default',
    sort_order INTEGER DEFAULT 0
  )
`

export default class CheckInDataShare extends DataShareExtensionAbility {
  private store: relationalStore.RdbStore | null = null
  
  async onCreate(): Promise<void> {
    this.store = await relationalStore.getRdbStore(this.context, STORE_CONFIG)
    await this.store.executeSql(CREATE_TABLE_SQL)
    await this.store.executeSql(CREATE_HABITS_TABLE)
    
    // 初始化示例数据
    await this.initSampleData()
    
    hilog.info(0x0000, 'CheckInData', '数据库初始化完成')
  }
  
  async query(uri: string, columns: string[], predicates: dataSharePredicates.DataSharePredicates): Promise<relationalStore.ResultSet> {
    if (!this.store) throw new Error('数据库未初始化')
    
    const path = uri.split('/').pop()
    
    if (path === 'stats') {
      // 查询统计数据
      return this.queryStats()
    } else if (path === 'today') {
      // 查询今日打卡记录
      return this.queryTodayRecords()
    }
    
    return this.queryTodayRecords()
  }
  
  async insert(uri: string, valueBucket: relationalStore.ValuesBucket): Promise<number> {
    if (!this.store) throw new Error('数据库未初始化')
    return this.store.insert('checkin_records', valueBucket)
  }
  
  async update(uri: string, valueBucket: relationalStore.ValuesBucket, predicates: dataSharePredicates.DataSharePredicates): Promise<number> {
    if (!this.store) throw new Error('数据库未初始化')
    
    const rdbPredicates = new relationalStore.RdbPredicates('checkin_records')
    const habitName = valueBucket['habit_name'] as string
    const today = this.getTodayStr()
    
    rdbPredicates.equalTo('habit_name', habitName)
    rdbPredicates.equalTo('date', today)
    
    return this.store.update(valueBucket, rdbPredicates)
  }
  
  private async queryStats(): Promise<relationalStore.ResultSet> {
    const today = this.getTodayStr()
    
    // 今日总数
    const totalResult = await this.store.querySql(
      'SELECT COUNT(*) as count FROM checkin_records WHERE date = ?', [today]
    )
    totalResult.goToFirstRow()
    const totalCount = totalResult.getLong(0)
    totalResult.close()
    
    // 今日已完成
    const completedResult = await this.store.querySql(
      'SELECT COUNT(*) as count FROM checkin_records WHERE date = ? AND is_completed = 1', [today]
    )
    completedResult.goToFirstRow()
    const completedCount = completedResult.getLong(0)
    completedResult.close()
    
    // 连续打卡天数
    const streakDays = await this.calculateStreakDays()
    
    // 构造返回结果
    return this.store.querySql(
      'SELECT ? as today_total, ? as today_completed, ? as streak_days',
      [totalCount, completedCount, streakDays]
    )
  }
  
  private async queryTodayRecords(): Promise<relationalStore.ResultSet> {
    const today = this.getTodayStr()
    return this.store.querySql(
      'SELECT habit_name, is_completed FROM checkin_records WHERE date = ? ORDER BY id',
      [today]
    )
  }
  
  private async calculateStreakDays(): Promise<number> {
    let streak = 0
    let checkDate = new Date()
    
    while (true) {
      const dateStr = this.formatDate(checkDate)
      const result = await this.store.querySql(
        'SELECT COUNT(*) as total, SUM(is_completed) as done FROM checkin_records WHERE date = ?',
        [dateStr]
      )
      
      result.goToFirstRow()
      const total = result.getLong(0)
      const done = result.getLong(1)
      result.close()
      
      if (total > 0 && done === total) {
        streak++
        checkDate.setDate(checkDate.getDate() - 1)
      } else {
        break
      }
    }
    
    return streak
  }
  
  private async initSampleData(): Promise<void> {
    const today = this.getTodayStr()
    
    // 检查是否已有数据
    const result = await this.store.querySql(
      'SELECT COUNT(*) FROM checkin_records WHERE date = ?', [today]
    )
    result.goToFirstRow()
    const count = result.getLong(0)
    result.close()
    
    if (count > 0) return  // 已有数据
    
    // 插入示例习惯
    const habits = ['早起', '运动', '阅读', '冥想', '写日记']
    for (const habit of habits) {
      await this.store.insert('checkin_records', {
        habit_name: habit,
        date: today,
        is_completed: 0,
        completed_at: 0
      })
    }
  }
  
  private getTodayStr(): string {
    return this.formatDate(new Date())
  }
  
  private formatDate(date: Date): string {
    const y = date.getFullYear()
    const m = (date.getMonth() + 1).toString().padStart(2, '0')
    const d = date.getDate().toString().padStart(2, '0')
    return `${y}-${m}-${d}`
  }
}

第四步:卡片布局

2×2静态卡片

// widget/pages/CheckInSmall.ets
@Entry
@Component
struct CheckInSmall {
  @State completedCount: string = '0'
  @State totalCount: string = '5'
  @State streakDays: string = '0'
  @State dateStr: string = ''
  @State encouragement: string = '加油!'

  build() {
    Column() {
      // 顶部:日期 + 连续天数
      Row() {
        Text(this.dateStr)
          .fontSize(11)
          .fontColor('#999999')
        
        Blank()
        
        Row() {
          Text('🔥')
            .fontSize(12)
          Text(this.streakDays)
            .fontSize(12)
            .fontColor('#FF6B35')
            .fontWeight(FontWeight.Bold)
          Text('天')
            .fontSize(10)
            .fontColor('#999999')
        }
      }
      .width('100%')
      
      // 核心数据:打卡进度
      Column() {
        Row() {
          Text(this.completedCount)
            .fontSize(40)
            .fontColor('#4CAF50')
            .fontWeight(FontWeight.Bold)
          
          Text(` / ${this.totalCount}`)
            .fontSize(16)
            .fontColor('#999999')
            .margin({ top: 16 })
        }
        
        Text('今日打卡')
          .fontSize(12)
          .fontColor('#666666')
          .margin({ top: 4 })
      }
      .margin({ top: 16 })
      
      // 底部:鼓励语
      Text(this.encouragement)
        .fontSize(11)
        .fontColor('#FF6B35')
        .margin({ top: 8 })
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .justifyContent(FlexAlign.SpaceBetween)
    .onClick(() => {
      // 点击整个卡片跳转到App
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: { targetPage: 'checkInList' }
      })
    })
  }
}

4×4动态卡片

// widget/pages/CheckInLarge.ets
@Entry
@Component
struct CheckInLarge {
  @State completedCount: string = '0'
  @State totalCount: string = '5'
  @State streakDays: string = '0'
  @State dateStr: string = ''
  @State habit1Name: string = ''
  @State habit1Done: string = 'false'
  @State habit2Name: string = ''
  @State habit2Done: string = 'false'
  @State habit3Name: string = ''
  @State habit3Done: string = 'false'
  @State habit4Name: string = ''
  @State habit4Done: string = 'false'
  @State habit5Name: string = ''
  @State habit5Done: string = 'false'

  build() {
    Column() {
      // 顶部:日期 + 统计
      Row() {
        Column() {
          Text(this.dateStr)
            .fontSize(12)
            .fontColor('#999999')
          
          Row() {
            Text(this.completedCount)
              .fontSize(28)
              .fontColor('#4CAF50')
              .fontWeight(FontWeight.Bold)
            Text(` / ${this.totalCount}`)
              .fontSize(14)
              .fontColor('#999999')
              .margin({ top: 8 })
          }
          .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        
        Blank()
        
        // 连续打卡天数
        Column() {
          Text('🔥')
            .fontSize(20)
          Text(this.streakDays)
            .fontSize(20)
            .fontColor('#FF6B35')
            .fontWeight(FontWeight.Bold)
          Text('天连续')
            .fontSize(10)
            .fontColor('#999999')
        }
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      
      // 进度条
      Progress({
        value: parseInt(this.completedCount),
        total: parseInt(this.totalCount),
        type: ProgressType.Linear
      })
        .width('100%')
        .color('#4CAF50')
        .backgroundColor('#EEEEEE')
        .margin({ top: 12 })
        .style({ strokeWidth: 6 })
      
      // 习惯列表
      Column() {
        this.HabitItem(this.habit1Name, this.habit1Done, '1')
        this.HabitItem(this.habit2Name, this.habit2Done, '2')
        this.HabitItem(this.habit3Name, this.habit3Done, '3')
        this.HabitItem(this.habit4Name, this.habit4Done, '4')
        this.HabitItem(this.habit5Name, this.habit5Done, '5')
      }
      .width('100%')
      .margin({ top: 12 })
      
      // 一键打卡按钮
      Button('全部打卡')
        .fontSize(14)
        .fontColor('#FFFFFF')
        .backgroundColor('#4CAF50')
        .borderRadius(20)
        .width('100%')
        .height(36)
        .margin({ top: 12 })
        .onClick(() => {
          postCardAction(this, {
            action: 'message',
            params: { action: 'checkAll' }
          })
        })
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
  }
  
  @Builder
  HabitItem(name: string, done: string, index: string) {
    Row() {
      // 打卡状态图标
      Text(done === 'true' ? '✅' : '⬜')
        .fontSize(16)
      
      // 习惯名称
      Text(name || `习惯${index}`)
        .fontSize(13)
        .fontColor(done === 'true' ? '#999999' : '#333333')
        .margin({ left: 8 })
        .decoration({ type: done === 'true' ? TextDecorationType.LineThrough : TextDecorationType.None })
      
      Blank()
      
      // 单项打卡按钮
      if (done === 'false') {
        Text('打卡')
          .fontSize(11)
          .fontColor('#4CAF50')
          .onClick(() => {
            postCardAction(this, {
              action: 'message',
              params: { action: 'checkOne', habitIndex: index }
            })
          })
      }
    }
    .width('100%')
    .height(32)
    .alignItems(VerticalAlign.Center)
  }
}

第五步:FormAbility

// formability/CheckInFormAbility.ets
import { formBindingData, FormExtensionAbility, formProvider } from '@kit.AbilityKit'
import { hilog } from '@kit.PerformanceAnalysisKit'

const DATA_SHARE_URI = 'datashare:///com.example.checkin/checkin_data'

// 卡片状态管理
let activeCards: Map<string, { formId: string; formName: string }> = new Map()

// 内存缓存
let statsCache: Map<string, Record<string, string>> = new Map()

export default class CheckInFormAbility extends FormExtensionAbility {
  
  onAddForm(want: Want): formBindingData.FormBindingData {
    const formId = want.parameters?.['ohos.extra.param.key.form_identity'] as string
    const formName = want.parameters?.['ohos.extra.param.key.form_name'] as string
    
    hilog.info(0x0000, 'CheckInForm', `添加卡片: ${formId} (${formName})`)
    
    // 注册卡片
    activeCards.set(formId, { formId, formName })
    
    // 获取数据
    const data = this.getCardData(formName)
    
    return formBindingData.createFormBindingData(data)
  }
  
  onUpdateForm(formId: string): formBindingData.FormBindingData {
    const info = activeCards.get(formId)
    if (!info) {
      return formBindingData.createFormBindingData({ completedCount: '0', totalCount: '5' })
    }
    
    const data = this.getCardData(info.formName)
    return formBindingData.createFormBindingData(data)
  }
  
  onRemoveForm(formId: string): void {
    activeCards.delete(formId)
    statsCache.delete(formId)
    hilog.info(0x0000, 'CheckInForm', `卡片删除: ${formId}`)
  }
  
  // 处理卡片交互消息
  onFormEvent(formId: string, message: string): void {
    hilog.info(0x0000, 'CheckInForm', `收到消息: ${formId}, ${message}`)
    
    try {
      const msg = JSON.parse(message)
      
      switch (msg.action) {
        case 'checkOne':
          this.handleCheckOne(formId, msg.habitIndex)
          break
        case 'checkAll':
          this.handleCheckAll(formId)
          break
      }
    } catch (err) {
      hilog.error(0x0000, 'CheckInForm', `消息处理失败: ${err}`)
    }
  }
  
  // 获取卡片数据
  private getCardData(formName: string): Record<string, string> {
    const now = new Date()
    const dateStr = `${now.getMonth() + 1}${now.getDate()}`
    
    // 从缓存读取
    const cached = statsCache.get(formName)
    if (cached) {
      return { ...cached, dateStr }
    }
    
    // 默认数据
    if (formName === 'checkInSmall') {
      return {
        completedCount: '0',
        totalCount: '5',
        streakDays: '0',
        dateStr: dateStr,
        encouragement: '新的一天,加油!'
      }
    } else {
      return {
        completedCount: '0',
        totalCount: '5',
        streakDays: '0',
        dateStr: dateStr,
        habit1Name: '早起', habit1Done: 'false',
        habit2Name: '运动', habit2Done: 'false',
        habit3Name: '阅读', habit3Done: 'false',
        habit4Name: '冥想', habit4Done: 'false',
        habit5Name: '写日记', habit5Done: 'false'
      }
    }
  }
  
  // 处理单项打卡
  private handleCheckOne(formId: string, habitIndex: string): void {
    const info = activeCards.get(formId)
    if (!info) return
    
    // 更新数据库
    this.updateHabitStatus(habitIndex, true)
    
    // 获取最新数据并更新卡片
    const newData = this.getCardData(info.formName)
    this.updateCardData(formId, newData)
  }
  
  // 处理全部打卡
  private handleCheckAll(formId: string): void {
    const info = activeCards.get(formId)
    if (!info) return
    
    // 更新所有习惯状态
    for (let i = 1; i <= 5; i++) {
      this.updateHabitStatus(i.toString(), true)
    }
    
    // 获取最新数据并更新卡片
    const newData = this.getCardData(info.formName)
    this.updateCardData(formId, newData)
  }
  
  // 更新习惯状态
  private updateHabitStatus(index: string, completed: boolean): void {
    // 实际项目中写入DataShare
    hilog.info(0x0000, 'CheckInForm', `更新习惯: ${index}, 完成: ${completed}`)
    
    // 更新缓存
    const doneKey = `habit${index}Done`
    const cached = statsCache.get('checkInLarge')
    if (cached) {
      cached[doneKey] = completed ? 'true' : 'false'
    }
  }
  
  // 更新卡片数据
  private updateCardData(formId: string, data: Record<string, string>): void {
    const bindingData = formBindingData.createFormBindingData(data)
    
    formProvider.updateForm(formId, bindingData)
      .then(() => {
        hilog.info(0x0000, 'CheckInForm', `卡片更新成功: ${formId}`)
      })
      .catch((err: Error) => {
        hilog.error(0x0000, 'CheckInForm', `卡片更新失败: ${err.message}`)
      })
  }
}

第六步:UIAbility主界面

// entryability/EntryAbility.ets
import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit'
import { window } from '@kit.ArkUI'
import { hilog } from '@kit.PerformanceAnalysisKit'

export default class EntryAbility extends UIAbility {
  
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    hilog.info(0x0000, 'EntryAbility', 'onCreate')
    this.handleRouterAction(want)
  }
  
  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    hilog.info(0x0000, 'EntryAbility', 'onNewWant')
    this.handleRouterAction(want)
  }
  
  private handleRouterAction(want: Want): void {
    const params = want.parameters
    if (!params) return
    
    const targetPage = params.targetPage as string
    
    if (targetPage) {
      AppStorage.setOrCreate('targetPage', targetPage)
    }
  }
  
  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(0x0000, 'EntryAbility', `加载失败: ${JSON.stringify(err)}`)
      }
    })
  }
}

主页面

// pages/Index.ets
@Entry
@Component
struct Index {
  @State habits: HabitItem[] = []
  @State streakDays: number = 0
  @StorageProp('targetPage') targetPage: string = ''

  aboutToAppear(): void {
    this.loadData()
  }

  build() {
    Navigation() {
      Column() {
        // 顶部统计
        Row() {
          Column() {
            Text(`${this.getCompletedCount()}/${this.habits.length}`)
              .fontSize(36)
              .fontColor('#4CAF50')
              .fontWeight(FontWeight.Bold)
            Text('今日打卡')
              .fontSize(14)
              .fontColor('#666666')
              .margin({ top: 4 })
          }
          
          Blank()
          
          Column() {
            Text('🔥')
              .fontSize(24)
            Text(`${this.streakDays}`)
              .fontSize(20)
              .fontColor('#FF6B35')
              .fontWeight(FontWeight.Bold)
            Text('连续打卡')
              .fontSize(12)
              .fontColor('#999999')
          }
        }
        .width('100%')
        .padding(20)
        .backgroundColor('#F5F5F5')
        .borderRadius(16)
        .margin({ left: 16, right: 16, top: 16 })
        
        // 习惯列表
        List() {
          ForEach(this.habits, (habit: HabitItem, index: number) => {
            ListItem() {
              Row() {
                Text(habit.isCompleted ? '✅' : '⬜')
                  .fontSize(20)
                
                Text(habit.name)
                  .fontSize(16)
                  .fontColor(habit.isCompleted ? '#999999' : '#333333')
                  .margin({ left: 12 })
                  .decoration({
                    type: habit.isCompleted ? TextDecorationType.LineThrough : TextDecorationType.None
                  })
                
                Blank()
                
                if (!habit.isCompleted) {
                  Button('打卡')
                    .fontSize(12)
                    .fontColor('#FFFFFF')
                    .backgroundColor('#4CAF50')
                    .borderRadius(16)
                    .height(28)
                    .onClick(() => {
                      this.checkIn(index)
                    })
                }
              }
              .width('100%')
              .padding(16)
              .backgroundColor('#FFFFFF')
              .borderRadius(12)
            }
            .margin({ top: 8, left: 16, right: 16 })
          })
        }
        .layoutWeight(1)
        .margin({ top: 16 })
      }
      .width('100%')
      .height('100%')
    }
    .title('每日打卡')
    .titleMode(NavigationTitleMode.Mini)
  }
  
  private loadData(): void {
    // 从DataShare加载数据
    this.habits = [
      { name: '早起', isCompleted: false },
      { name: '运动', isCompleted: true },
      { name: '阅读', isCompleted: false },
      { name: '冥想', isCompleted: true },
      { name: '写日记', isCompleted: false }
    ]
    this.streakDays = 7
  }
  
  private checkIn(index: number): void {
    this.habits[index].isCompleted = true
    
    // 保存到DataShare
    // 更新所有卡片
    hilog.info(0x0000, 'Index', `打卡: ${this.habits[index].name}`)
  }
  
  private getCompletedCount(): number {
    return this.habits.filter(h => h.isCompleted).length
  }
}

interface HabitItem {
  name: string
  isCompleted: boolean
}

第七步:module.json5完整配置

{
  "module": {
    "name": "entry",
    "type": "entry",
    "installationFree": true,
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:icon",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "launchType": "singleton"
      }
    ],
    "extensionAbilities": [
      {
        "name": "CheckInFormAbility",
        "srcEntry": "./ets/formability/CheckInFormAbility.ets",
        "description": "$string:FormAbility_desc",
        "icon": "$media:icon",
        "label": "$string:FormAbility_label",
        "type": "form",
        "metadata": [
          {
            "name": "ohos.extension.form",
            "resource": "$profile:form_config"
          }
        ]
      },
      {
        "name": "CheckInDataShare",
        "srcEntry": "./ets/datashare/CheckInDataShare.ets",
        "type": "dataShare",
        "uri": "datashare:///com.example.checkin/checkin_data",
        "grantPermission": {
          "readPermission": "ohos.permission.READ_CHECKIN_DATA",
          "writePermission": "ohos.permission.WRITE_CHECKIN_DATA"
        }
      }
    ],
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      },
      {
        "name": "ohos.permission.READ_CHECKIN_DATA"
      },
      {
        "name": "ohos.permission.WRITE_CHECKIN_DATA"
      }
    ]
  }
}

踩坑与注意事项

坑1:静态卡片和动态卡片不能共用formName

一个formName只能对应一种卡片类型。如果你在form_config.json里给静态卡片和动态卡片用了同一个name,编译会报错。

坑2:动态卡片的postCardAction必须在onClick里

动态卡片的交互必须通过postCardAction,而且只能在onClick回调里调用。你不能在onAppear、定时器等地方调用。

坑3:DataShare查询是异步的

FormAbility的onUpdateForm是同步回调,但DataShare查询是异步的。你不能在onUpdateFormawait查询结果。必须用缓存层——后台定时从DataShare读取数据到缓存,onUpdateForm直接读缓存。

坑4:发布前检查清单

发布元服务前,检查以下项目:

  • [ ] form_config.json中所有卡片都声明了至少2种尺寸
  • [ ] 4×4卡片没有大面积空白
  • [ ] 卡片数据更新间隔不低于5分钟
  • [ ] 动态卡片内存占用不超过10MB
  • [ ] 卡片在深色模式下可读
  • [ ] 卡片点击跳转正常工作
  • [ ] 卡片删除后资源正确清理
  • [ ] installationFree设为true
  • [ ] 包体积不超过10MB

坑5:调试技巧

卡片调试比普通App麻烦得多。几个实用技巧:

  1. 用hilog打日志:FormAbility里用hilog.info()打日志,在DevEco Studio的Log窗口过滤查看
  2. 实机调试:卡片预览器和真机效果可能不一致,必须用真机验证
  3. 手动触发更新:在DevEco Studio的命令行里用hdc shell aa force-update <formId>手动触发卡片更新
  4. 查看卡片列表:用hdc shell aa dump-card-list查看当前所有活跃卡片

HarmonyOS 6适配说明

  1. 服务中心审核标准更新:6.0对元服务的审核更严格,卡片必须满足以下条件才能通过:

    • 至少2种尺寸
    • 4×4卡片信息量不少于4个数据项
    • 卡片加载时间不超过2秒
    • 卡片崩溃率低于0.1%
  2. 卡片性能评分:6.0的服务中心会根据卡片性能给出评分,评分影响推荐权重。评分指标:渲染速度、内存占用、更新频率、崩溃率。

  3. 一键发布:6.0的DevEco Studio支持一键打包发布元服务到应用市场,不再需要手动配置签名和上传。

  4. 卡片A/B测试:6.0支持卡片的A/B测试——你可以同时发布两种卡片设计,系统自动分配给不同用户,收集数据后选择效果更好的版本。

总结

这篇实战把前面九篇文章的知识点串成了一个完整项目。从项目架构、数据模型、卡片布局、FormAbility、UIAbility、数据共享到发布检查,覆盖了元服务开发的全流程。

核心要点回顾:

  • 项目架构:UIAbility + FormAbility + DataShare三层分离
  • 静态卡片适合信息展示,动态卡片适合交互操作
  • 数据共享用DataShare,FormAbility用缓存层读取
  • 交互用router跳转和message消息
  • 发布前检查清单:尺寸、信息量、内存、深色模式、包体积
  • 调试靠hilog和实机,预览器不可靠
评估维度 说明
学习难度 ⭐⭐⭐⭐⭐ 综合运用所有知识点,需要实战经验
使用频率 ⭐⭐⭐⭐⭐ 每个元服务项目都要走这个流程
重要程度 ⭐⭐⭐⭐⭐ 不实战,前面学的都是纸上谈兵

系列完结:从601到610,十篇文章带你从元服务概念到完整实战。元服务是鸿蒙生态的核心入口形态,掌握它,你就掌握了鸿蒙应用开发的半壁江山。下一篇系列,我们聊另一个鸿蒙核心能力——分布式技术。敬请期待。

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

评论(0

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

全部回复

上滑加载中

设置昵称

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

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

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