Harmony開發之服務卡片開發——解鎖原子化服務

引入:桌面卡片的便捷交互

當我們使用手機時,經常會發現一些應用在桌面上提供了小巧精緻的卡片,比如天氣卡片顯示實時温度、運動卡片展示今日步數、音樂卡片提供播放控制。這些就是HarmonyOS的服務卡片(Service Widget),它們無需打開完整應用就能提供核心信息並支持快捷操作,極大地提升了用户體驗和操作效率。

一、服務卡片核心概念

1.1 什麼是服務卡片?

服務卡片是HarmonyOS原子化服務的一種呈現形式,是界面展示的控件。它作為應用的重要入口,通過在外圍提供快捷訪問特定功能的能力,實現應用功能的原子化。

1.2 服務卡片的優勢

  • 免安裝使用:用户無需安裝完整應用即可使用核心功能
  • 即用即走:輕量化設計,快速響應
  • 多設備協同:卡片可在手機、平板、手錶等多設備間流轉
  • 動態更新:支持定時更新或數據驅動更新

1.3 卡片類型與規格

HarmonyOS支持多種規格的服務卡片,常見的有2x2、2x4、4x4等不同尺寸,開發者需要根據功能需求選擇合適的卡片規格。

二、卡片創建與配置

2.1 創建卡片工程

在DevEco Studio中創建服務卡片項目時,選擇"Service Widget"模板:

// 文件:entry/src/main/ets/entryability/EntryAbility.ets
import { UIAbility } from '@ohos.app.ability.UIAbility';
import { widgetManager } from '@ohos.app.ability.widgetManager';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    console.info('EntryAbility onCreate');
  }

  // 註冊卡片更新回調
  onAddForm(want: Want): FormBindingData {
    let formData: Record<string, Object> = {
      'title': '健康步數',
      'steps': '8,256',
      'target': '10,000',
      'progress': 82
    };
    return new FormBindingData(JSON.stringify(formData));
  }
}

2.2 卡片配置文件

// 文件:entry/src/main/resources/base/profile/form_config.json
{
  "forms": [
    {
      "name": "widget_steps",
      "description": "健康步數卡片",
      "src": "./ets/widgets/StepsWidget.ets",
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": true,
      "updateEnabled": true,
      "scheduledUpdateTime": "10:00",
      "updateDuration": 1,
      "defaultDimension": "2 * 2",
      "supportDimensions": ["2 * 2", "2 * 4"]
    }
  ]
}
// 文件:entry/src/main/module.json5
{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "formsEnabled": true,
        "forms": [
          {
            "name": "widget_steps",
            "description": "健康步數卡片",
            "src": "./ets/widgets/StepsWidget.ets",
            "window": {
              "designWidth": 720,
              "autoDesignWidth": true
            },
            "colorMode": "auto",
            "isDefault": true,
            "updateEnabled": true,
            "scheduledUpdateTime": "10:00",
            "updateDuration": 1,
            "defaultDimension": "2 * 2",
            "supportDimensions": ["2 * 2", "2 * 4"]
          }
        ]
      }
    ]
  }
}

三、卡片佈局開發

3.1 基礎卡片組件

// 文件:entry/src/main/ets/widgets/StepsWidget.ets
@Entry
@Component
struct StepsWidget {
  @State steps: string = '0'
  @State progress: number = 0

  build() {
    Column() {
      // 標題欄
      Row() {
        Image($r('app.media.ic_footprint'))
          .width(20)
          .height(20)
          .margin({ right: 8 })
        
        Text('今日步數')
          .fontSize(16)
          .fontColor('#000000')
          .fontWeight(FontWeight.Medium)
        
        Blank()
        
        Image($r('app.media.ic_refresh'))
          .width(16)
          .height(16)
          .onClick(() => {
            this.updateStepsData()
          })
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 8, bottom: 8 })

      // 進度區域
      Column() {
        Text(this.steps)
          .fontSize(24)
          .fontColor('#007DFF')
          .fontWeight(FontWeight.Bold)
          .margin({ bottom: 4 })
        
        Text('目標: 10,000步')
          .fontSize(12)
          .fontColor('#99000000')
          .margin({ bottom: 8 })
        
        // 進度條
        Stack() {
          // 背景條
          Row()
            .width('100%')
            .height(6)
            .backgroundColor('#20007DFF')
            .borderRadius(3)
          
          // 進度條
          Row()
            .width(`${this.progress}%`)
            .height(6)
            .backgroundColor('#007DFF')
            .borderRadius(3)
        }
        .width('100%')
        .height(6)
        
        Text(`${this.progress}%完成`)
          .fontSize(10)
          .fontColor('#99000000')
          .margin({ top: 4 })
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 12 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
  }

  // 更新步數數據
  updateStepsData() {
    // 模擬數據更新
    const newSteps = Math.floor(Math.random() * 10000).toLocaleString()
    const newProgress = Math.floor(Math.random() * 100)
    
    this.steps = newSteps
    this.progress = newProgress
  }
}

3.2 交互式卡片

// 文件:entry/src/main/ets/widgets/MusicWidget.ets
@Entry
@Component
struct MusicWidget {
  @State isPlaying: boolean = false
  @State currentSong: string = 'HarmonyOS主題曲'
  @State artist: string = '華為音樂'
  @State progress: number = 40

  build() {
    Column() {
      // 歌曲信息
      Row() {
        Image($r('app.media.ic_music_cover'))
          .width(40)
          .height(40)
          .borderRadius(8)
          .margin({ right: 12 })
        
        Column() {
          Text(this.currentSong)
            .fontSize(14)
            .fontColor('#000000')
            .fontWeight(FontWeight.Medium)
            .margin({ bottom: 2 })
          
          Text(this.artist)
            .fontSize(12)
            .fontColor('#99000000')
        }
        .alignItems(HorizontalAlign.Start)
        
        Blank()
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 12 })

      // 控制按鈕
      Row() {
        Image($r('app.media.ic_skip_previous'))
          .width(24)
          .height(24)
          .onClick(() => this.previousSong())
        
        Blank()
          .width(20)
        
        Image(this.isPlaying ? 
          $r('app.media.ic_pause') : 
          $r('app.media.ic_play'))
          .width(32)
          .height(32)
          .onClick(() => this.togglePlay())
        
        Blank()
          .width(20)
        
        Image($r('app.media.ic_skip_next'))
          .width(24)
          .height(24)
          .onClick(() => this.nextSong())
      }
      .width('100%')
      .padding({ bottom: 12 })
      .justifyContent(FlexAlign.Center)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
  }

  togglePlay() {
    this.isPlaying = !this.isPlaying
    // 實際開發中這裏應該控制音樂播放
  }

  previousSong() {
    // 上一首邏輯
  }

  nextSong() {
    // 下一首邏輯
  }
}

四、卡片更新機制

4.1 定時更新配置

{
  "forms": [
    {
      "name": "weather_widget",
      "updateEnabled": true,
      "scheduledUpdateTime": "08:00",
      "updateDuration": 1,
      "supportDimensions": ["2 * 2"]
    }
  ]
}

4.2 手動更新實現

// 文件:entry/src/main/ets/utils/WidgetUtils.ets
import { formHost } from '@ohos.app.ability.formHost';

export class WidgetUtils {
  // 更新指定卡片
  static async updateWidget(formId: string): Promise<void> {
    try {
      const formBindingData = {
        'temperature': '25°C',
        'weather': '晴朗',
        'location': '深圳市',
        'updateTime': new Date().toLocaleTimeString()
      };
      
      await formHost.updateForm(formId, {
        data: JSON.stringify(formBindingData)
      });
    } catch (error) {
      console.error('更新卡片失敗:', error);
    }
  }

  // 獲取所有卡片ID
  static async getAllFormIds(): Promise<string[]> {
    try {
      const formInfos = await formHost.getAllFormsInfo();
      return formInfos.map(info => info.formId);
    } catch (error) {
      console.error('獲取卡片信息失敗:', error);
      return [];
    }
  }
}

4.3 數據驅動更新

// 文件:entry/src/main/ets/widgets/WeatherWidget.ets
@Entry
@Component
struct WeatherWidget {
  @State temperature: string = '--'
  @State weather: string = '加載中...'
  @State updateTime: string = ''

  aboutToAppear() {
    this.fetchWeatherData()
  }

  build() {
    Column() {
      Text(this.temperature)
        .fontSize(28)
        .fontColor('#007DFF')
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 4 })
      
      Text(this.weather)
        .fontSize(14)
        .fontColor('#666666')
        .margin({ bottom: 8 })
      
      Text(`更新: ${this.updateTime}`)
        .fontSize(10)
        .fontColor('#999999')
    }
    .width('100%')
    .height('100%')
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
  }

  async fetchWeatherData() {
    try {
      // 模擬API調用
      const response = await this.mockWeatherAPI()
      this.temperature = response.temperature
      this.weather = response.weather
      this.updateTime = new Date().toLocaleTimeString()
    } catch (error) {
      console.error('獲取天氣數據失敗:', error)
    }
  }

  async mockWeatherAPI() {
    return new Promise<{temperature: string, weather: string}>((resolve) => {
      setTimeout(() => {
        resolve({
          temperature: '25°C',
          weather: '晴朗'
        })
      }, 1000)
    })
  }
}

五、卡片跳轉與交互

5.1 卡片跳轉配置

// 文件:entry/src/main/ets/widgets/NewsWidget.ets
@Entry
@Component
struct NewsWidget {
  @State newsItems: Array<{id: string, title: string, time: string}> = [
    {id: '1', title: 'HarmonyOS 4.0發佈新特性', time: '10:30'},
    {id: '2', title: '開發者大會即將舉行', time: '09:15'},
    {id: '3', title: '新版本開發工具更新', time: '昨天'}
  ]

  build() {
    Column() {
      Text('最新資訊')
        .fontSize(16)
        .fontColor('#000000')
        .fontWeight(FontWeight.Medium)
        .margin({ bottom: 8, left: 12, top: 12 })
      
      List({ space: 4 }) {
        ForEach(this.newsItems, (item: {id: string, title: string, time: string}) => {
          ListItem() {
            Row() {
              Column() {
                Text(item.title)
                  .fontSize(12)
                  .fontColor('#000000')
                  .maxLines(2)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                
                Text(item.time)
                  .fontSize(10)
                  .fontColor('#999999')
              }
              .alignItems(HorizontalAlign.Start)
              
              Blank()
              
              Image($r('app.media.ic_arrow_right'))
                .width(12)
                .height(12)
            }
            .width('100%')
            .padding({ left: 12, right: 12, top: 8, bottom: 8 })
            .onClick(() => {
              this.navigateToDetail(item.id)
            })
          }
        }, (item: {id: string, title: string, time: string}) => item.id)
      }
      .width('100%')
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
  }

  navigateToDetail(newsId: string) {
    // 跳轉到詳情頁
    let want = {
      deviceId: '', // 默認設備
      bundleName: 'com.example.newsapp',
      abilityName: 'NewsDetailAbility',
      parameters: {
        newsId: newsId
      }
    };
    
    // 使用featureAbility進行跳轉
    featureAbility.startAbility({ want: want })
      .then(() => {
        console.info('跳轉成功');
      })
      .catch((error) => {
        console.error('跳轉失敗:', error);
      });
  }
}

5.2 原子化服務配置

// 文件:entry/src/main/module.json5
{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "formsEnabled": true,
        "forms": [
          {
            "name": "news_widget",
            "description": "資訊卡片",
            "src": "./ets/widgets/NewsWidget.ets",
            "isDefault": true,
            "updateEnabled": true,
            "scheduledUpdateTime": "09:00",
            "defaultDimension": "2 * 4",
            "supportDimensions": ["2 * 4"]
          }
        ],
        "metadata": [
          {
            "name": "ohos.extension.form",
            "resource": "$profile:form_config"
          }
        ]
      }
    ],
    "distro": {
      "deliveryWithInstall": true,
      "moduleName": "entry",
      "moduleType": "entry"
    },
    "reqCapabilities": []
  }
}

六、多設備適配與流轉

6.1 響應式佈局設計

// 文件:entry/src/main/ets/widgets/UniversalWidget.ets
@Entry
@Component
struct UniversalWidget {
  @State deviceType: string = 'phone'

  aboutToAppear() {
    this.detectDeviceType()
  }

  build() {
    Column() {
      if (this.deviceType === 'watch') {
        this.buildWatchLayout()
      } else if (this.deviceType === 'tablet') {
        this.buildTabletLayout()
      } else {
        this.buildPhoneLayout()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
  }

  @Builder
  buildPhoneLayout() {
    Column() {
      Text('手機版卡片')
        .fontSize(16)
        .fontColor('#000000')
      // 手機專用佈局...
    }
    .padding(8)
  }

  @Builder
  buildTabletLayout() {
    Column() {
      Text('平板版卡片')
        .fontSize(20)
        .fontColor('#000000')
      // 平板專用佈局...
    }
    .padding(16)
  }

  @Builder
  buildWatchLayout() {
    Column() {
      Text('手錶版卡片')
        .fontSize(12)
        .fontColor('#000000')
      // 手錶專用佈局...
    }
    .padding(4)
  }

  detectDeviceType() {
    // 根據屏幕尺寸判斷設備類型
    const screenWidth = vp2px(display.getDefaultDisplaySync().width);
    if (screenWidth < 600) {
      this.deviceType = 'watch';
    } else if (screenWidth < 1200) {
      this.deviceType = 'phone';
    } else {
      this.deviceType = 'tablet';
    }
  }
}

6.2 卡片流轉處理

// 文件:entry/src/main/ets/entryability/EntryAbility.ets
import { distributedDeviceManager } from '@ohos.distributedDeviceManager';

export default class EntryAbility extends UIAbility {
  onConnect(want: Want): formBindingData.FormBindingData {
    // 處理卡片流轉連接
    console.info('卡片流轉連接建立');
    return this.handleFormRequest(want);
  }

  onDisconnect(want: Want): void {
    // 處理卡片流轉斷開
    console.info('卡片流轉連接斷開');
  }

  // 處理跨設備卡片請求
  handleFormRequest(want: Want): formBindingData.FormBindingData {
    const deviceId = want.parameters?.deviceId as string;
    const formId = want.parameters?.formId as string;
    
    console.info(`處理來自設備 ${deviceId} 的卡片請求`);
    
    // 根據設備能力返回不同的卡片數據
    return this.getAdaptiveFormData(deviceId);
  }

  getAdaptiveFormData(deviceId: string): formBindingData.FormBindingData {
    // 獲取設備信息並返回適配的數據
    const deviceInfo = this.getDeviceInfo(deviceId);
    
    let formData: Record<string, Object> = {};
    if (deviceInfo.deviceType === 'watch') {
      formData = this.getWatchFormData();
    } else {
      formData = this.getDefaultFormData();
    }
    
    return new formBindingData.FormBindingData(JSON.stringify(formData));
  }
}

七、調試與優化

7.1 卡片調試技巧

// 文件:entry/src/main/ets/utils/DebugUtils.ets
export class DebugUtils {
  // 卡片性能監控
  static startPerformanceMonitor(formId: string): void {
    const startTime = new Date().getTime();
    
    // 監控卡片加載性能
    setTimeout(() => {
      const loadTime = new Date().getTime() - startTime;
      console.info(`卡片 ${formId} 加載耗時: ${loadTime}ms`);
      
      if (loadTime > 1000) {
        console.warn('卡片加載時間過長,建議優化');
      }
    }, 0);
  }

  // 內存使用檢查
  static checkMemoryUsage(): void {
    const memoryInfo = process.getMemoryInfo();
    console.info(`內存使用情況: ${JSON.stringify(memoryInfo)}`);
    
    if (memoryInfo.availMem < 100 * 1024 * 1024) { // 100MB
      console.warn('可用內存不足,可能影響卡片性能');
    }
  }
}

7.2 性能優化建議

  1. 圖片資源優化:使用適當尺寸的圖片,避免過大資源
  2. 數據緩存:合理使用緩存減少網絡請求
  3. 佈局簡化:避免過於複雜的佈局層次
  4. 按需更新:只在必要時更新卡片內容

總結

服務卡片是HarmonyOS原子化服務的核心載體,通過提供輕量級、即用即走的用户體驗,極大地增強了應用的便捷性和實用性。本文從卡片創建、佈局開發、更新機制、交互跳轉到多設備適配等方面全面介紹了服務卡片的開發流程。

行動建議

  • 根據功能需求合理選擇卡片尺寸和更新策略
  • 注重卡片的視覺設計和用户體驗
  • 實現多設備適配,確保在不同設備上都有良好表現
  • 優化卡片性能,確保快速加載和流暢交互
  • 充分利用原子化服務的優勢,提供免安裝使用體驗

通過精心設計的服務卡片,可以為用户提供更加便捷高效的服務入口,增強應用的用户粘性和使用價值。