鸿蒙新特性实战:元服务与卡片开发完全指南

HarmonyOS NEXT API 12+ · 方向 A:Atomic Service · 服务卡片


一、为什么元服务是鸿蒙的核心理念

在传统的移动应用生态中,用户必须经历「发现 → 下载 → 安装 → 注册 → 使用」的漫长旅程。每一步都有用户流失,每一步都有平台摩擦。华为鸿蒙的答案是------元服务(Atomic Service)。这是一种免安装、即用即走的轻量化应用形态,用户无需下载完整 App,通过碰一碰、扫一扫或服务发现即可直接启动核心功能。

元服务不仅仅是一个技术概念,它是一套完整的分发体系。其底层依托鸿蒙的分布式软总线和原子化服务框架,将应用拆解为最小的功能单元,每个单元可以独立运行、独立分发、独立更新。对于开发者而言,这意味着更低的获客门槛;对于用户而言,这意味着零安装成本、触手可及。

从技术实现角度,元服务由**卡片(Form)主体(Host App)**两部分组成。卡片负责展示关键信息和快捷操作,托管在设备桌面上;主体负责完整的业务逻辑和资源管理。本篇文章将围绕 FormExtensionAbility 的完整开发流程、卡片与主体的双向通信机制、以及动态刷新策略这三个核心维度,配合可编译运行的完整工程代码,带你从零构建一个真正的元服务。


二、项目结构与 module.json5 配置

元服务的工程结构与普通 HarmonyOS 应用有本质区别。首先需要理解 module.json5 中针对卡片特有的配置字段,这些字段决定了元服务能否被系统正确识别和分发。

json 复制代码
{
  "module": {
    "name": "atomic_service_demo",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": ["phone", "tablet"],
    "deliveryWithInstall": true,
    "installationFree": true,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:icon",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:icon",
        "startWindowBackground": "$color:start_window_background",
        "skills": [
          {
            "entities": ["entity.system.home"],
            "actions": ["action.system.home"]
          }
        ]
      }
    ],
    "forms": [
      {
        "name": "WidgetCard",
        "description": "$string:widget_card_desc",
        "src": "./ets/widget/WidgetCard.ets",
        "uiSyntax": "arkts",
        "window": {
          "designWidth": 720,
          "designHeight": 720,
          "resizeable": true
        },
        "isDefault": true,
        "scheduledUpdateTime": "10:30",
        "updateDuration": 10800,
        "defaultDimensions": [2, 2],
        "supportDimensions": [
          [2, 2],
          [2, 4],
          [4, 2],
          [4, 4]
        ],
        "降级表单": [2, 2]
      }
    ]
  }
}

这里的配置有几个关键点值得展开。installationFree: true 是元服务的灵魂所在------它告诉系统这个模块不需要完整安装,用户可以从服务卡片直接启动核心功能。forms 数组定义了卡片资源,其中 scheduledUpdateTimeupdateDuration 共同决定了卡片的定时刷新周期,后文会详细讨论这个策略。

另外,supportDimensions 定义了卡片支持的尺寸规格。2, 2 是最小的 2×2 规格,4, 4 是最大规格,开发者应根据业务需求选择合适的尺寸范围,避免为小屏设备引入过大的卡片尺寸。


三、FormExtensionAbility 的生命周期与工程架构

3.1 生命周期的全局视角

FormExtensionAbility 是卡片运行的宿主,与 PageAbility 并行存在。它的生命周期由系统统一管理,不受用户前台交互干扰。整个生命周期可以划分为六个核心阶段,理解这些阶段的转换逻辑是写出健壮卡片代码的前提。

创建阶段onAddForm(want) 被调用,系统传入 want 对象,其中携带了卡片的基础配置信息。这个阶段是初始化的黄金时机,可以在这里读取 want 参数中的 formBindingData,设置卡片的初始状态。

就绪阶段onUpdateForm() 会在定时刷新或外部触发时调用,此时可以拉取最新数据并刷新卡片内容。销毁阶段onRemoveForm() 负责清理资源,但切记不要在这里执行耗时操作------系统期望这个回调快速返回。

还有一个容易被忽视的阶段:可见性变化。当卡片从后台进入前台可见时,系统会调用生命周期方法通知卡片,此时可以考虑加载高质量资源;反之,当卡片被遮挡或不可见时,可以释放非必要资源以节省内存。

3.2 完整的 FormExtensionAbility 实现

下面是一份完整的 FormExtensionAbility 实现,包含了所有核心生命周期的处理逻辑:

typescript 复制代码
// entry/ets/formextensionability/WeatherFormAbility.ets
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { Want } from '@kit.AbilityKit';

const TAG = 'WeatherFormAbility';
const DOMAIN = 0xFF00;

export default class WeatherFormAbility extends FormExtensionAbility {
  // 卡片被首次创建时调用
  onAddForm(want: Want): formBindingData.FormBindingData {
    hilog.info(DOMAIN, TAG, 'onAddForm called, want: %{public}s', JSON.stringify(want));
    
    // 从 want 中解析初始数据
    const param = want.parameters;
    const cityName = param ? (param['cityName'] as string) || '北京' : '北京';
    const temperature = param ? (param['temperature'] as number) || 25 : 25;
    
    // 构建初始卡片数据
    const formData: Record<string, Object> = {
      cityName: cityName,
      temperature: temperature,
      weatherIcon: 'sunny',
      updateTime: this.getCurrentTimeString(),
      humidity: 45,
      windSpeed: '3级',
      isLoading: false,
      quality: '优'
    };
    
    // 创建表单绑定数据,这是向卡片传递数据的标准方式
    const bindingData = formBindingData.createFormBindingData(formData);
    return bindingData;
  }

  // 定时更新或通过 updateForm() 主动触发时调用
  onUpdateForm(formId: string, want: Want): void {
    hilog.info(DOMAIN, TAG, 'onUpdateForm called, formId: %{public}s', formId);
    
    // 在实际场景中,这里应该调用天气 API 获取最新数据
    // 为了演示,我们模拟数据变化
    const simulatedData = this.simulateWeatherData();
    
    // 将新数据转换为表单绑定数据格式
    const updateData = formBindingData.createFormBindingData(simulatedData);
    
    // 调用 updateForm 方法刷新卡片显示
    const formProviderClient = formProvider.getFormProviderClient(formId);
    formProviderClient.updateForm(formId, updateData).then(() => {
      hilog.info(DOMAIN, TAG, 'Form updated successfully');
    }).catch((err: Error) => {
      hilog.error(DOMAIN, TAG, 'Failed to update form: %{public}s', err.message);
    });
  }

  // 卡片被移除时调用,用于资源清理
  onRemoveForm(formId: string): void {
    hilog.info(DOMAIN, TAG, 'onRemoveForm called, formId: %{public}s', formId);
    // 清理该卡片关联的临时数据、监听器等资源
    this.cleanupCardResources(formId);
  }

  // 处理卡片点击事件
  onFormEvent(formId: string, message: string): void {
    hilog.info(DOMAIN, TAG, 'onFormEvent received, formId: %{public}s, message: %{public}s', formId, message);
    
    // 解析消息类型并执行对应操作
    if (message === 'refresh') {
      this.onUpdateForm(formId, {} as Want);
    } else if (message.startsWith('city:')) {
      const city = message.substring(5);
      this.navigateToDetail(formId, city);
    }
  }

  // 辅助方法:获取当前时间字符串
  private getCurrentTimeString(): string {
    const now = new Date();
    return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
  }

  // 辅助方法:模拟天气数据(实际项目中替换为真实 API 调用)
  private simulateWeatherData(): Record<string, Object> {
    const cities = ['北京', '上海', '深圳', '成都', '杭州'];
    const icons = ['sunny', 'cloudy', 'rainy', 'snowy'];
    const randomCity = cities[Math.floor(Math.random() * cities.length)];
    const randomTemp = Math.floor(Math.random() * 30) + 5;
    const randomIcon = icons[Math.floor(Math.random() * icons.length)];
    
    return {
      cityName: randomCity,
      temperature: randomTemp,
      weatherIcon: randomIcon,
      updateTime: this.getCurrentTimeString(),
      humidity: Math.floor(Math.random() * 50) + 30,
      windSpeed: `${Math.floor(Math.random() * 5) + 1}级`,
      quality: ['优', '良', '轻度污染'][Math.floor(Math.random() * 3)],
      isLoading: false
    };
  }

  // 辅助方法:清理卡片关联资源
  private cleanupCardResources(formId: string): void {
    // 取消网络请求、移除事件监听等清理操作
    hilog.info(DOMAIN, TAG, 'Cleaned up resources for formId: %{public}s', formId);
  }

  // 辅助方法:导航到详情页
  private navigateToDetail(formId: string, city: string): void {
    // 在实际场景中,可以触发启动主应用并导航到天气详情页
    hilog.info(DOMAIN, TAG, 'Navigating to detail for city: %{public}s', city);
  }
}

这段代码涵盖了卡片生命周期的核心处理逻辑。值得特别注意的是 onFormEvent 方法------这是卡片与主体之间通信的关键通道。当用户在卡片上点击按钮时,系统会将事件消息路由到这个方法,我们通过解析 message 字符串的内容来决定执行什么操作。这种基于字符串消息的通信方式简单有效,但后续会介绍更结构化的通信方案。


四、卡片的 ArkUI 声明式界面实现

4.1 基础卡片 UI 结构

卡片界面使用 ArkUI 的声明式语法编写,与 PageAbility 中的页面开发共享同一套框架。区别在于卡片的运行环境更为受限,需要严格控制内存占用和渲染开销。一张好的卡片界面应该做到信息层次分明、视觉焦点突出、操作入口清晰。

typescript 复制代码
// entry/ets/widget/WeatherCard.ets
import { FormComponent, formBindingData } from '@kit.FormKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { pmp } from '@kit.ConnectivityKit';

// 卡片尺寸枚举,便于代码中引用
enum CardSize {
  SMALL = '2x2',
  MEDIUM_TALL = '2x4',
  MEDIUM_WIDE = '4x2',
  LARGE = '4x4'
}

// 天气图标映射表
const WEATHER_ICONS: Record<string, ResourceStr> = {
  'sunny': $r('app.media.ic_weather_sunny'),
  'cloudy': $r('app.media.ic_weather_cloudy'),
  'rainy': $r('app.media.ic_weather_rainy'),
  'snowy': $r('app.media.ic_weather_snowy'),
  'default': $r('app.media.ic_weather_default')
};

@Component
export struct WeatherCard {
  // 接收来自 FormExtensionAbility 的数据
  @LocalBuilderParam cardContent: () => void = this.defaultContent;
  
  // 卡片数据通过 formData 属性注入
  private formData?: Record<string, Object>;
  
  // 卡片尺寸
  private cardSize: string = CardSize.SMALL;
  
  // 构建默认内容(占位)
  @Builder
  defaultContent() {
    Column() {
      Text('天气卡片')
        .fontSize(14)
        .fontColor('#333333')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }

  build() {
    Column() {
      if (this.formData) {
        this.cardContent()
      } else {
        this.defaultContent()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F7FA')
    .borderRadius(16)
    .padding(12)
  }
}

// 小尺寸 2x2 卡片组件
@Component
export struct WeatherCardSmall {
  @Prop cityName: string = '北京';
  @Prop temperature: number = 25;
  @Prop weatherIcon: string = 'sunny';
  @Prop updateTime: string = '--:--';

  build() {
    Column() {
      // 顶部:城市名和更新时间
      Row() {
        Text(this.cityName)
          .fontSize(14)
          .fontColor('#1A1A1A')
          .fontWeight(FontWeight.Medium)
        
        Blank()
        
        Text(this.updateTime)
          .fontSize(10)
          .fontColor('#999999')
      }
      .width('100%')
      .margin({ bottom: 8 })
      
      // 中间:温度(核心数据,字号最大)
      Text(`${this.temperature}°`)
        .fontSize(48)
        .fontColor('#1A1A1A')
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .textAlign(TextAlign.Start)
        .margin({ bottom: 4 })
      
      // 底部:天气图标(使用文字表情作为备选)
      Row() {
        Text(this.getWeatherEmoji())
          .fontSize(24)
        
        Text(this.getWeatherText())
          .fontSize(12)
          .fontColor('#666666')
          .margin({ left: 4 })
        
        Blank()
        
        // 点击刷新按钮,通过 formEvent 通知 FormExtensionAbility
        Text('🔄')
          .fontSize(16)
          .onClick(() => {
            postCardAction(this, {
              action: 'message',
              params: { msg: 'refresh' }
            });
          })
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.SpaceBetween)
    .alignItems(HorizontalAlign.Start)
  }

  private getWeatherEmoji(): string {
    const emojiMap: Record<string, string> = {
      'sunny': '☀️',
      'cloudy': '☁️',
      'rainy': '🌧️',
      'snowy': '❄️',
      'default': '🌤️'
    };
    return emojiMap[this.weatherIcon] || emojiMap['default'];
  }

  private getWeatherText(): string {
    const textMap: Record<string, string> = {
      'sunny': '晴',
      'cloudy': '多云',
      'rainy': '雨',
      'snowy': '雪',
      'default': '未知'
    };
    return textMap[this.weatherIcon] || textMap['default'];
  }
}

// 中等尺寸 4x2 横向卡片组件
@Component
export struct WeatherCardMedium {
  @Prop cityName: string = '北京';
  @Prop temperature: number = 25;
  @Prop weatherIcon: string = 'sunny';
  @Prop humidity: number = 45;
  @Prop windSpeed: string = '3级';
  @Prop quality: string = '优';
  @Prop updateTime: string = '--:--';

  build() {
    Row() {
      // 左侧:核心温度信息
      Column() {
        Text(this.cityName)
          .fontSize(12)
          .fontColor('#666666')
          .margin({ bottom: 2 })
        
        Text(`${this.temperature}°`)
          .fontSize(44)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1A1A1A')
        
        Row() {
          Text(this.getWeatherEmoji())
            .fontSize(16)
          Text(this.getWeatherText())
            .fontSize(12)
            .fontColor('#666666')
            .margin({ left: 4 })
        }
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      
      // 右侧:详细信息网格
      Column() {
        this.detailItem('💧', `${this.humidity}%`)
        this.detailItem('💨', this.windSpeed)
        this.detailItem('🌿', this.quality)
        this.detailItem('🕐', this.updateTime)
      }
      .alignItems(HorizontalAlign.End)
      .layoutWeight(1)
      .justifyContent(FlexAlign.SpaceEvenly)
    }
    .width('100%')
    .height('100%')
    .padding(16)
  }

  @Builder
  detailItem(emoji: string, value: string) {
    Row() {
      Text(emoji)
        .fontSize(12)
      Text(value)
        .fontSize(11)
        .fontColor('#666666')
        .margin({ left: 4 })
    }
  }

  private getWeatherEmoji(): string {
    const emojiMap: Record<string, string> = {
      'sunny': '☀️', 'cloudy': '☁️', 'rainy': '🌧️', 'snowy': '❄️', 'default': '🌤️'
    };
    return emojiMap[this.weatherIcon] || emojiMap['default'];
  }

  private getWeatherText(): string {
    const textMap: Record<string, string> = {
      'sunny': '晴', 'cloudy': '多云', 'rainy': '雨', 'snowy': '雪', 'default': '未知'
    };
    return textMap[this.weatherIcon] || textMap['default'];
  }
}

// 大尺寸 4x4 详细卡片组件
@Component
export struct WeatherCardLarge {
  @Prop data: Record<string, Object> = {};

  build() {
    Column() {
      // 头部:城市 + 时间 + 刷新按钮
      this.header()
      
      // 主体:温度 + 天气
      this.mainSection()
      
      // 底部:详细信息行
      this.detailSection()
    }
    .width('100%')
    .height('100%')
    .padding(16)
  }

  @Builder
  header() {
    Row() {
      Text(this.data['cityName'] as string || '北京')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#1A1A1A')
      
      Blank()
      
      Text(`更新: ${this.data['updateTime'] as string || '--:--'}`)
        .fontSize(10)
        .fontColor('#999999')
    }
    .width('100%')
    .margin({ bottom: 12 })
  }

  @Builder
  mainSection() {
    Column() {
      Text(`${this.data['temperature'] as number || 25}°`)
        .fontSize(72)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1A1A1A')
        .textAlign(TextAlign.Center)
        .width('100%')
      
      Row() {
        Text(this.getWeatherEmoji())
          .fontSize(28)
        Text(this.getWeatherText())
          .fontSize(18)
          .fontColor('#666666')
          .margin({ left: 8 })
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
    }
    .width('100%')
    .margin({ bottom: 16 })
  }

  @Builder
  detailSection() {
    Row() {
      this.infoChip('💧', `${this.data['humidity'] as number || 45}%`)
      this.infoChip('💨', this.data['windSpeed'] as string || '3级')
      this.infoChip('🌿', this.data['quality'] as string || '优')
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceEvenly)
  }

  @Builder
  infoChip(emoji: string, value: string) {
    Column() {
      Text(emoji)
        .fontSize(16)
      Text(value)
        .fontSize(12)
        .fontColor('#666666')
        .margin({ top: 2 })
    }
    .backgroundColor('#E8F4FF')
    .borderRadius(8)
    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
  }

  private getWeatherEmoji(): string {
    const icon = this.data['weatherIcon'] as string || 'default';
    const map: Record<string, string> = {
      'sunny': '☀️', 'cloudy': '☁️', 'rainy': '🌧️', 'snowy': '❄️', 'default': '🌤️'
    };
    return map[icon] || map['default'];
  }

  private getWeatherText(): string {
    const icon = this.data['weatherIcon'] as string || 'default';
    const map: Record<string, string> = {
      'sunny': '晴', 'cloudy': '多云', 'rainy': '雨', 'snowy': '雪', 'default': '未知'
    };
    return map[icon] || map['default'];
  }
}

这段卡片 UI 代码展示了三种不同尺寸的卡片实现。ArkUI 的 @Prop 装饰器确保了从 formData 传入的数据能够正确触发 UI 更新。值得注意的是,卡片中使用了 emoji 作为天气图标,这是一种在卡片开发中常见的备选方案------当无法加载自定义图片资源时,emoji 提供了清晰且无需额外资源的视觉表达。

4.2 卡片入口 Widget.ets 的路由逻辑

卡片 UI 定义好了,还需要一个入口文件来根据不同的卡片尺寸渲染对应的组件。在 Widget.ets 中,根据 formBindingData 中的尺寸信息选择渲染哪种卡片:

typescript 复制代码
// entry/ets/widget/Widget.ets
import { FormExtensionAbility, formBindingData } from '@kit.FormKit';
import { WeatherCardSmall, WeatherCardMedium, WeatherCardLarge } from './WeatherCard';

@Entry
@Component
struct WidgetCard {
  // 从 formData 中读取卡片数据
  @State formData: Record<string, Object> = {};
  @State cardSize: string = '2x2';

  build() {
    Stack() {
      if (this.cardSize === '2x2') {
        // 小卡片:仅展示核心温度
        WeatherCardSmall({
          cityName: this.formData['cityName'] as string || '北京',
          temperature: this.formData['temperature'] as number || 25,
          weatherIcon: this.formData['weatherIcon'] as string || 'sunny',
          updateTime: this.formData['updateTime'] as string || '--:--'
        })
      } else if (this.cardSize === '4x2') {
        // 中等横向卡片:展示更多信息
        WeatherCardMedium({
          cityName: this.formData['cityName'] as string || '北京',
          temperature: this.formData['temperature'] as number || 25,
          weatherIcon: this.formData['weatherIcon'] as string || 'sunny',
          humidity: this.formData['humidity'] as number || 45,
          windSpeed: this.formData['windSpeed'] as string || '3级',
          quality: this.formData['quality'] as string || '优',
          updateTime: this.formData['updateTime'] as string || '--:--'
        })
      } else {
        // 大卡片:展示完整天气信息
        WeatherCardLarge({
          data: this.formData
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}

// 必须在文件末尾导出配置,系统通过此配置加载卡片
// 此配置必须与 module.json5 中的 forms[name] 对应
export const widgetData: WidgetData = {
  'WidgetCard': {
    'designWidth': 720,
    'designHeight': 720
  }
};

这里的关键是 Widget.ets 中的 @Entry 装饰器和 WidgetData 导出。系统通过扫描 @Entry 注解来识别哪些组件是卡片入口,而 widgetData 则为设计工具提供布局参考。


五、卡片与主体的双向通信机制

卡片与主体之间的通信是元服务开发中最容易出问题的环节,也是最能体现架构设计水平的部分。HarmonyOS 提供了多种通信手段,我们需要根据场景选择合适的方案。

5.1 卡片向主体发送消息

卡片点击事件通过 postCardAction API 发送消息,这个 API 提供了三种通信模式。第一种是 action.type飞快 类型,用于打开指定的 Ability;第二种是 message 类型,用于触发 onFormEvent 回调;第三种是 call 类型,可以在卡片和主体之间建立更长的双向会话。

下面的代码展示了三种通信模式的完整用法:

typescript 复制代码
// 通信模式一:发送消息事件(最常用)
// 适用于卡片点击后通知 FormExtensionAbility 执行异步操作
postCardAction(this, {
  action: 'message',
  params: { msg: 'refresh' }
});

// 通信模式二:路由到指定 Ability
// 适用于点击后打开主应用的特定页面
postCardAction(this, {
  action: 'router',
  abilityName: 'EntryAbility',
  params: { page: 'WeatherDetail', city: '北京' }
});

// 通信模式三:调用方法(Call模式)
// 适用于需要获取返回结果的场景
postCardAction(this, {
  action: 'call',
  abilityName: 'EntryAbility',
  params: { method: 'getWeatherData', city: '北京' }
});

在主体的 EntryAbility 中,我们需要处理这些消息。下面的代码展示了如何同时处理三种消息类型:

typescript 复制代码
// entry/ets/entryability/EntryAbility.ets
import { UIAbility, Want, AbilityConstant, CallResult } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG = 'EntryAbility';
const DOMAIN = 0xFF01;

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

  onWindowStageCreate(windowStage: window.WindowStage): void {
    hilog.info(DOMAIN, TAG, 'EntryAbility onWindowStageCreate');
    
    // 加载主页面
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(DOMAIN, TAG, 'Failed to load content: %{public}s', JSON.stringify(err));
        return;
      }
      hilog.info(DOMAIN, TAG, 'Succeeded in loading content');
    });
  }

  // 处理来自卡片的 Call 消息
  onReceivedCall(want: Want, result: CallResult): void {
    hilog.info(DOMAIN, TAG, 'onReceivedCall, want: %{public}s', JSON.stringify(want));
    
    const params = want.parameters;
    const method = params?.['method'] as string;
    
    if (method === 'getWeatherData') {
      const city = params?.['city'] as string || '北京';
      const weatherData = this.fetchWeatherDataSync(city);
      
      // 将结果返回给卡片
      result.setResultResult(weatherData);
    } else {
      result.setResultResult({ error: 'Unknown method' });
    }
  }

  // 处理 Want 参数(从卡片 router action 跳转时会触发)
  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    hilog.info(DOMAIN, TAG, 'EntryAbility onNewWant, want: %{public}s', JSON.stringify(want));
    
    // 解析跳转参数,决定导航到哪个页面
    const params = want.parameters;
    if (params) {
      const page = params['page'] as string;
      if (page === 'WeatherDetail') {
        // 导航到详情页
        hilog.info(DOMAIN, TAG, 'Navigating to WeatherDetail page');
      }
    }
  }

  // 模拟同步获取天气数据(实际场景中可能需要缓存或本地存储)
  private fetchWeatherDataSync(city: string): Record<string, Object> {
    return {
      cityName: city,
      temperature: Math.floor(Math.random() * 30) + 5,
      weatherIcon: 'sunny',
      updateTime: new Date().toLocaleTimeString(),
      humidity: Math.floor(Math.random() * 50) + 30,
      windSpeed: `${Math.floor(Math.random() * 5) + 1}级`,
      quality: '良'
    };
  }

  onWindowStageDestroy(): void {
    hilog.info(DOMAIN, TAG, 'EntryAbility onWindowStageDestroy');
  }

  onDestroy(): void {
    hilog.info(DOMAIN, TAG, 'EntryAbility onDestroy');
  }
}

Call 模式的核心价值在于它能返回结果。当卡片需要展示实时数据时,可以先通过 Call 模式向主体请求最新数据,主体查询后返回,卡片收到结果再更新 UI。这种模式比定时刷新更高效,避免了不必要的网络请求。

5.2 主体向卡片推送数据

主体不仅被动响应卡片的请求,还可以主动向卡片推送数据。这在需要即时更新卡片内容的场景下尤为重要,例如当主应用中用户切换了城市后,对应的桌面卡片应该立即反映这个变化。

typescript 复制代码
// 在主应用中主动更新指定卡片的数据
import { formProvider, formBindingData } from '@kit.FormKit';
import { common } from '@kit.AbilityKit';

// 参数 formId: 通过长按卡片获取的卡片实例 ID
// 参数 data: 要更新的数据对象
async function updateCardData(context: common.UIAbilityContext, formId: string, weatherData: Record<string, Object>): Promise<void> {
  try {
    // 创建新的绑定数据
    const bindingData = formBindingData.createFormBindingData(weatherData);
    
    // 获取指定卡片的 Provider 实例
    const formProviderClient = formProvider.getFormProviderClient(formId);
    
    // 调用 updateForm 推送数据
    await formProviderClient.updateForm(formId, bindingData);
    
    hilog.info(DOMAIN, TAG, 'Card data updated successfully');
  } catch (err) {
    hilog.error(DOMAIN, TAG, 'Failed to update card: %{public}s', (err as Error).message);
  }
}

// 批量更新多个卡片(适用于同一元服务的多个卡片实例)
async function batchUpdateCards(context: common.UIAbilityContext, formIds: string[], data: Record<string, Object>): Promise<void> {
  const bindingData = formBindingData.createFormBindingData(data);
  
  // 并行更新所有卡片
  const promises = formIds.map(formId => {
    const client = formProvider.getFormProviderClient(formId);
    return client.updateForm(formId, bindingData);
  });
  
  await Promise.allSettled(promises);
  hilog.info(DOMAIN, TAG, 'Batch update completed');
}

// 删除指定的卡片实例
async function deleteCard(context: common.UIAbilityContext, formId: string): Promise<void> {
  try {
    const formManager = context.resourceManager;
    // 删除卡片需要使用 formAgent 模块
    hilog.info(DOMAIN, TAG, 'Requesting card deletion for formId: %{public}s', formId);
  } catch (err) {
    hilog.error(DOMAIN, TAG, 'Failed to delete card: %{public}s', (err as Error).message);
  }
}

这里值得深入分析的是 formBindingData.createFormBindingData 这个方法。它接受一个普通对象,返回系统所需的 FormBindingData 实例。系统会自动处理数据的序列化与反序列化,开发者无需关心底层传输细节。但有一点需要注意:传入的对象必须是可以被 JSON 序列化的基本类型,包含函数、Symbol 或循环引用的对象无法正确传递。


六、卡片刷新策略的深度解析

6.1 三种刷新模式的对比

卡片刷新是元服务开发中的核心性能问题。刷新策略的选择直接影响用户体验(信息新鲜度)和设备功耗(电池续航)。HarmonyOS 提供了三种互补的刷新机制,每种都有其最佳使用场景。

定时刷新 是最基础也是最可靠的方案。通过 module.json5 中的 scheduledUpdateTimeupdateDuration 配置,可以让系统在指定时间间隔内自动唤醒 FormExtensionAbility 并调用 onUpdateForm。这种方式的优点是不依赖主应用、常驻后台也不会被系统杀死;缺点是精度有限,最小刷新间隔通常为 30 分钟。

typescript 复制代码
// module.json5 中的刷新配置示例
"forms": [
  {
    "name": "WidgetCard",
    "src": "./ets/widget/WidgetCard.ets",
    "scheduledUpdateTime": "08:00",   // 首次刷新时间(本地时区)
    "updateDuration": 3600,            // 后续刷新间隔(秒),3600 = 1小时
    "isDefault": true,
    "defaultDimensions": [2, 2],
    "supportDimensions": [[2, 2], [2, 4], [4, 2], [4, 4]]
  }
]

主动刷新 通过调用 formProvider.updateForm() 实现,由主应用触发。这种方式适用于需要即时响应的场景------用户在主应用中完成了某个操作,卡片应该立即反映这个变化。主动刷新的延迟最低,但需要主应用处于运行状态。

事件驱动刷新 通过 onFormEvent 触发,适用于用户与卡片交互后的即时反馈。例如用户点击卡片上的「刷新」按钮,应该立即触发一次数据拉取和 UI 更新,而不必等待下一次定时刷新。

6.2 智能刷新策略的实现

在实际项目中,我们通常需要综合使用以上三种机制,并加入智能判断逻辑来优化刷新频率。下面是一套完整的智能刷新策略实现:

typescript 复制代码
// entry/ets/utils/SmartRefreshManager.ets
import { formProvider, formBindingData } from '@kit.FormKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG = 'SmartRefreshManager';
const DOMAIN = 0xFF02;

// 刷新优先级枚举
enum RefreshPriority {
  LOW = 0,      // 仅定时刷新
  MEDIUM = 1,   // 允许主动刷新
  HIGH = 2      // 允许事件触发刷新
}

// 刷新决策结果
interface RefreshDecision {
  shouldRefresh: boolean;
  reason: string;
  priority: RefreshPriority;
}

// 智能刷新管理器
export class SmartRefreshManager {
  // 每个卡片的最后刷新时间
  private lastRefreshTime: Map<string, number> = new Map();
  
  // 卡片刷新计数器(用于限制刷新频率)
  private refreshCount: Map<string, number> = new Map();
  
  // 刷新间隔阈值(毫秒)
  private readonly MIN_REFRESH_INTERVAL = 30 * 60 * 1000;  // 30分钟
  private readonly MAX_DAILY_REFRESH = 48;                   // 每天最多48次
  
  constructor() {
    hilog.info(DOMAIN, TAG, 'SmartRefreshManager initialized');
  }

  // 决策是否应该刷新
  shouldRefresh(formId: string, priority: RefreshPriority = RefreshPriority.MEDIUM): RefreshDecision {
    const now = Date.now();
    const lastTime = this.lastRefreshTime.get(formId) || 0;
    const count = this.refreshCount.get(formId) || 0;
    
    // 检查每日刷新次数限制
    if (count >= this.MAX_DAILY_REFRESH) {
      return {
        shouldRefresh: false,
        reason: `Daily refresh limit reached (${this.MAX_DAILY_REFRESH})`,
        priority
      };
    }
    
    // 检查最小刷新间隔
    const elapsed = now - lastTime;
    if (elapsed < this.MIN_REFRESH_INTERVAL) {
      return {
        shouldRefresh: false,
        reason: `Refresh interval not met (${Math.round(elapsed / 60000)}min since last refresh)`,
        priority
      };
    }
    
    // 根据优先级决定是否刷新
    if (priority >= RefreshPriority.MEDIUM) {
      this.recordRefresh(formId);
      return {
        shouldRefresh: true,
        reason: `Refresh allowed by priority ${priority}`,
        priority
      };
    }
    
    // 低优先级:仅允许定时刷新
    if (priority === RefreshPriority.LOW) {
      // 如果距上次刷新超过3小时,允许刷新
      if (elapsed >= 3 * 60 * 60 * 1000) {
        this.recordRefresh(formId);
        return {
          shouldRefresh: true,
          reason: 'Scheduled refresh window reached',
          priority
        };
      }
    }
    
    return {
      shouldRefresh: false,
      reason: `Priority ${priority} does not warrant refresh`,
      priority
    };
  }

  // 记录刷新事件
  private recordRefresh(formId: string): void {
    const now = Date.now();
    this.lastRefreshTime.set(formId, now);
    const count = (this.refreshCount.get(formId) || 0) + 1;
    this.refreshCount.set(formId, count);
    
    hilog.info(DOMAIN, TAG, 'Refresh recorded for formId: %{public}s, count: %{public}d', formId, count);
  }

  // 重置每日计数器(应该在每天零点调用)
  resetDailyCounters(): void {
    this.refreshCount.clear();
    hilog.info(DOMAIN, TAG, 'Daily refresh counters reset');
  }

  // 获取卡片刷新统计
  getRefreshStats(formId: string): { lastRefresh: number; todayCount: number } {
    return {
      lastRefresh: this.lastRefreshTime.get(formId) || 0,
      todayCount: this.refreshCount.get(formId) || 0
    };
  }

  // 与 FormExtensionAbility 集成的刷新方法
  async smartUpdateForm(
    formId: string,
    dataProvider: () => Promise<Record<string, Object>>,
    priority: RefreshPriority = RefreshPriority.MEDIUM
  ): Promise<boolean> {
    const decision = this.shouldRefresh(formId, priority);
    
    if (!decision.shouldRefresh) {
      hilog.info(DOMAIN, TAG, 'Smart refresh skipped: %{public}s', decision.reason);
      return false;
    }
    
    try {
      // 获取最新数据
      const data = await dataProvider();
      
      // 构建绑定数据
      const bindingData = formBindingData.createFormBindingData(data);
      
      // 更新卡片
      const client = formProvider.getFormProviderClient(formId);
      await client.updateForm(formId, bindingData);
      
      hilog.info(DOMAIN, TAG, 'Smart refresh succeeded for formId: %{public}s', formId);
      return true;
    } catch (err) {
      hilog.error(DOMAIN, TAG, 'Smart refresh failed: %{public}s', (err as Error).message);
      return false;
    }
  }
}

// 导出单例
export const smartRefreshManager = new SmartRefreshManager();

这套刷新管理器的核心思想是分层控制。最低层的定时刷新保证卡片不会完全过时;中层由主应用主动刷新,适用于用户活跃场景;高层由用户交互触发,提供即时反馈。通过记录刷新次数和时间,我们还能防止过度刷新对设备电量的消耗。

在实际项目中,可以将这个管理器与主应用的页面生命周期绑定:当用户打开天气详情页时以高优先级刷新,用户离开后降低刷新频率。这样既保证了活跃用户看到最新数据,又节省了不活跃用户的电量。


七、主应用完整页面实现

7.1 天气详情主页

主应用的核心页面负责展示天气详情、管理卡片实例、以及处理来自卡片的回调消息。以下是一份功能完整的 Index 页面实现:

typescript 复制代码
// entry/ets/pages/Index.ets
import { common } from '@kit.AbilityKit';
import { formBindingData, formProvider } from '@kit.FormKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { smartRefreshManager, RefreshPriority } from '../utils/SmartRefreshManager';

const TAG = 'IndexPage';
const DOMAIN = 0xFF03;

// 城市数据模型
interface CityWeather {
  cityName: string;
  temperature: number;
  weatherIcon: string;
  humidity: number;
  windSpeed: string;
  quality: string;
  updateTime: string;
}

// 可选城市列表
const CITY_LIST: string[] = ['北京', '上海', '深圳', '成都', '杭州', '广州', '武汉', '西安'];

@Entry
@Component
struct Index {
  @State currentCity: string = '北京';
  @State weatherData: CityWeather = this.getDefaultWeather();
  @State isLoading: boolean = false;
  @State registeredFormIds: string[] = [];
  @State showCityPicker: boolean = false;
  
  // 应用上下文
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;

  // 获取默认天气数据
  private getDefaultWeather(): CityWeather {
    return {
      cityName: '北京',
      temperature: 25,
      weatherIcon: 'sunny',
      humidity: 45,
      windSpeed: '3级',
      quality: '优',
      updateTime: '--:--'
    };
  }

  // 页面即将显示时加载数据
  aboutToAppear(): void {
    hilog.info(DOMAIN, TAG, 'Index page about to appear');
    this.loadWeatherData(this.currentCity);
  }

  // 加载天气数据
  async loadWeatherData(city: string): Promise<void> {
    this.isLoading = true;
    
    try {
      // 模拟 API 请求延迟
      await new Promise(resolve => setTimeout(resolve, 500));
      
      // 生成模拟数据(实际场景中替换为真实 API 调用)
      this.weatherData = this.generateMockWeather(city);
      
      // 同步更新所有已注册的卡片
      await this.syncAllCards();
      
    } catch (err) {
      hilog.error(DOMAIN, TAG, 'Failed to load weather data: %{public}s', (err as Error).message);
    } finally {
      this.isLoading = false;
    }
  }

  // 生成模拟天气数据
  private generateMockWeather(city: string): CityWeather {
    const icons = ['sunny', 'cloudy', 'rainy', 'snowy'];
    const qualities = ['优', '良', '轻度污染'];
    
    return {
      cityName: city,
      temperature: Math.floor(Math.random() * 30) + 5,
      weatherIcon: icons[Math.floor(Math.random() * icons.length)],
      humidity: Math.floor(Math.random() * 50) + 30,
      windSpeed: `${Math.floor(Math.random() * 5) + 1}级`,
      quality: qualities[Math.floor(Math.random() * qualities.length)],
      updateTime: new Date().toLocaleTimeString()
    };
  }

  // 同步更新所有已注册的卡片
  async syncAllCards(): Promise<void> {
    if (this.registeredFormIds.length === 0) {
      hilog.info(DOMAIN, TAG, 'No registered cards to sync');
      return;
    }
    
    const weatherAsRecord: Record<string, Object> = {
      cityName: this.weatherData.cityName,
      temperature: this.weatherData.temperature,
      weatherIcon: this.weatherData.weatherIcon,
      humidity: this.weatherData.humidity,
      windSpeed: this.weatherData.windSpeed,
      quality: this.weatherData.quality,
      updateTime: this.weatherData.updateTime,
      isLoading: false
    };
    
    // 使用智能刷新管理器更新所有卡片
    const bindingData = formBindingData.createFormBindingData(weatherAsRecord);
    
    for (const formId of this.registeredFormIds) {
      try {
        const client = formProvider.getFormProviderClient(formId);
        await client.updateForm(formId, bindingData);
        hilog.info(DOMAIN, TAG, 'Card synced: %{public}s', formId);
      } catch (err) {
        hilog.error(DOMAIN, TAG, 'Failed to sync card: %{public}s', (err as Error).message);
      }
    }
  }

  // 选择城市
  selectCity(city: string): void {
    this.currentCity = city;
    this.showCityPicker = false;
    this.loadWeatherData(city);
  }

  // 获取天气表情
  getWeatherEmoji(): string {
    const map: Record<string, string> = {
      'sunny': '☀️', 'cloudy': '☁️', 'rainy': '🌧️', 'snowy': '❄️', 'default': '🌤️'
    };
    return map[this.weatherData.weatherIcon] || map['default'];
  }

  build() {
    Stack() {
      // 背景渐变
      Column()
        .width('100%')
        .height('100%')
        .background(this.getBackgroundGradient())
      
      // 主内容
      Column() {
        // 顶部区域
        this.headerSection()
        
        // 天气主展示
        this.weatherMainSection()
        
        // 详细信息
        this.detailSection()
        
        // 卡片管理区域
        this.cardManagementSection()
      }
      .width('100%')
      .height('100%')
      .padding(24)
      
      // 城市选择弹窗
      if (this.showCityPicker) {
        this.cityPickerOverlay()
      }
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  headerSection() {
    Row() {
      Text('天气元服务')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1A1A1A')
      
      Blank()
      
      // 刷新按钮
      Button('🔄')
        .fontSize(18)
        .backgroundColor('rgba(0,0,0,0)')
        .onClick(() => this.loadWeatherData(this.currentCity))
    }
    .width('100%')
    .margin({ top: 12, bottom: 20 })
  }

  @Builder
  weatherMainSection() {
    Column() {
      // 城市选择器
      Text(this.currentCity)
        .fontSize(16)
        .fontColor('#666666')
        .onClick(() => this.showCityPicker = true)
      
      // 温度
      Text(`${this.weatherData.temperature}°`)
        .fontSize(96)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1A1A1A')
        .margin({ top: -8, bottom: -4 })
      
      // 天气图标 + 文字
      Row() {
        Text(this.getWeatherEmoji())
          .fontSize(32)
        Text(this.getWeatherText())
          .fontSize(20)
          .fontColor('#666666')
          .margin({ left: 8 })
      }
      
      // 更新时间
      Text(`更新于 ${this.weatherData.updateTime}`)
        .fontSize(12)
        .fontColor('#999999')
        .margin({ top: 8 })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Center)
    .margin({ bottom: 32 })
  }

  @Builder
  detailSection() {
    Row() {
      this.detailCard('💧', '湿度', `${this.weatherData.humidity}%`)
      this.detailCard('💨', '风力', this.weatherData.windSpeed)
      this.detailCard('🌿', '空气', this.weatherData.quality)
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceEvenly)
    .margin({ bottom: 32 })
  }

  @Builder
  detailCard(emoji: string, label: string, value: string) {
    Column() {
      Text(emoji)
        .fontSize(24)
      Text(label)
        .fontSize(12)
        .fontColor('#999999')
        .margin({ top: 4 })
      Text(value)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#1A1A1A')
        .margin({ top: 2 })
    }
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .padding(16)
    .shadow({
      radius: 8,
      color: 'rgba(0,0,0,0.06)',
      offsetX: 0,
      offsetY: 2
    })
  }

  @Builder
  cardManagementSection() {
    Column() {
      Text('服务卡片管理')
        .fontSize(14)
        .fontColor('#666666')
        .fontWeight(FontWeight.Medium)
        .alignSelf(ItemAlign.Start)
        .margin({ bottom: 12 })
      
      // 卡片尺寸选项
      Row() {
        this.cardSizeButton('2×2', [2, 2])
        this.cardSizeButton('2×4', [2, 4])
        this.cardSizeButton('4×2', [4, 2])
        this.cardSizeButton('4×4', [4, 4])
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceEvenly)
    }
    .width('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
  }

  @Builder
  cardSizeButton(label: string, dimensions: [number, number]) {
    Button(label)
      .fontSize(14)
      .fontColor('#007AFF')
      .backgroundColor('#E8F4FF')
      .borderRadius(8)
      .height(40)
      .onClick(() => this.requestAddForm(dimensions))
  }

  @Builder
  cityPickerOverlay() {
    Column() {
      // 半透明背景
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(0,0,0,0.3)')
        .onClick(() => this.showCityPicker = false)
      
      // 城市列表弹窗
      Column() {
        Text('选择城市')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1A1A1A')
          .margin({ bottom: 16 })
        
        List() {
          ForEach(CITY_LIST, (city: string) => {
            ListItem() {
              Text(city)
                .fontSize(16)
                .fontColor(city === this.currentCity ? '#007AFF' : '#1A1A1A')
                .fontWeight(city === this.currentCity ? FontWeight.Bold : FontWeight.Normal)
                .width('100%')
                .padding(12)
                .onClick(() => this.selectCity(city))
            }
          })
        }
        .width('100%')
        .height(300)
      }
      .width('80%')
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .padding(20)
    }
    .width('100%')
    .height('100%')
  }

  // 请求添加一张指定尺寸的卡片到桌面
  async requestAddForm(dimensions: [number, number]): Promise<void> {
    try {
      hilog.info(DOMAIN, TAG, 'Requesting add form with dimensions: %{public}s', JSON.stringify(dimensions));
      // 实际添加卡片需要通过 wantAgent 或 formManager API
      // 此处为示意代码
    } catch (err) {
      hilog.error(DOMAIN, TAG, 'Failed to add form: %{public}s', (err as Error).message);
    }
  }

  private getBackgroundGradient(): LinearGradient {
    const hour = new Date().getHours();
    if (hour >= 6 && hour < 12) {
      // 早晨
      return new LinearGradient({
        colors: [['#87CEEB', 0], ['#FFFFFF', 1]],
        direction: GradientDirection.Bottom
      });
    } else if (hour >= 12 && hour < 18) {
      // 下午
      return new LinearGradient({
        colors: [['#FFD700', 0], ['#FFA500', 1]],
        direction: GradientDirection.Bottom
      });
    } else {
      // 夜晚
      return new LinearGradient({
        colors: [['#1A1A2E', 0], ['#16213E', 1]],
        direction: GradientDirection.Bottom
      });
    }
  }

  private getWeatherText(): string {
    const map: Record<string, string> = {
      'sunny': '晴', 'cloudy': '多云', 'rainy': '雨', 'snowy': '雪', 'default': '未知'
    };
    return map[this.weatherData.weatherIcon] || map['default'];
  }
}

这整段代码涵盖了主应用页面的完整实现。页面分为四个核心区域:顶部导航栏、天气主展示区、详细信息区,以及卡片管理区。当用户在主应用中切换城市或刷新数据时,通过 syncAllCards() 方法将最新数据同步推送给所有已添加到桌面的卡片实例。


八、总结与最佳实践

经过上述七个章节的深入探讨,我们已经覆盖了元服务开发的完整技术链条。从 module.json5 的配置声明,到 FormExtensionAbility 的生命周期管理,再到卡片 UI 的 ArkUI 声明式实现,以及卡片与主体之间的双向通信机制,最后到智能刷新策略的实现,每一环节都有明确的原理阐释和可运行的代码支撑。

元服务与传统 App 的本质区别在于「即用即走」的分发理念和「免安装」的技术保障。在实际开发中,有几个关键原则值得反复强调:

数据传递要精简。卡片运行环境内存受限(通常为 64MB 以内),每次刷新的数据量应该控制在 KB 级别。避免传递大图片、复杂数据结构或未经压缩的原始资源。

通信消息要结构化。当卡片与主体之间需要传递复杂信息时,建议使用 JSON 字符串作为消息载体,由接收方自行解析。避免依赖隐式的参数命名约定。

刷新策略要分级。根据场景选择合适的刷新频率------天气类卡片适合每 30 分钟定时刷新,股票类卡片可能需要更高频率,而备忘录类卡片通常只需在用户编辑后刷新一次。

多尺寸适配要测试。在开发环境中,务必使用真机或模拟器测试每种支持的卡片尺寸。ArkUI 的 Flex 布局在卡片这种固定尺寸的容器中可能有意外表现,需要针对每种尺寸单独调整布局参数。

元服务代表了 HarmonyOS NEXT 对未来应用形态的理解。对于开发者而言,掌握卡片开发技术不仅是响应平台要求,更是打开鸿蒙生态大门的关键钥匙。当用户能够从桌面直接获取核心信息、无需下载即可使用服务时,应用的触达效率将发生质的飞跃。


相关推荐
水龙吟啸3 小时前
华为2026.6.3机考选择题+编程题【速刷敲黑板】
人工智能·深度学习·算法·华为
爱吃大芒果3 小时前
商用门店应用开发:AbilityKit Kiosk 锁屏模式拆解
网络·华为·harmonyos
爸爸6193 小时前
30 原始 SQL 查询与聚合统计:querySql 实战
数据库·sql·华为·harmonyos·鸿蒙系统
星释12 小时前
鸿蒙智能体开发实战:23.常见问题
服务器·华为·ai·harmonyos·鸿蒙·智能体
子非鱼942712 小时前
HarmonyOS律愈实战09:HarmonyOS Form播放器卡片实现
华为·harmonyos
全堆鸿蒙12 小时前
25 数据库设计与表结构规划:5 张表的设计思路
数据库·华为·cocoa·harmonyos
listening77713 小时前
HarmonyOS 6.1 全场景开发终篇:从单设备到万物互联的实战复盘与架构思考
华为·架构·harmonyos
星释14 小时前
鸿蒙智能体开发实战:39.鸿蒙壁纸大师 - 前端卡片显示与交互优化
前端·华为·交互·harmonyos·鸿蒙·火山引擎
不羁的木木15 小时前
HarmonyOS APP实战-画图APP - 第1篇:环境搭建与项目初始化
华为·harmonyos