鸿蒙-服务卡片

Form Kit(卡片开发框架)提供了一种在桌面、锁屏等系统入口嵌入显示应用信息的开发框架和API,可以将应用内用户关注的重要信息或常用操作抽取到服务卡片(以下简称"卡片")上,通过将卡片添加到桌面上,以达到信息展示、服务直达的便捷体验效果。

卡片使用场景

  • 支持设备类型:卡片可以在手机、平板等设备上使用。
  • 支持开发卡片应用类型:应用和元服务内均支持开发卡片。
  • 支持卡片使用位置:用户可以在桌面、锁屏等系统应用上添加使用,暂不支持在普通应用内嵌入显示卡片。

亮点/特征

  • 信息呈现:将应用/元服务的重要信息以卡片形式展示在桌面,同时支持信息定时更新能力,用户可以随时查看关注的信息。
  • 服务直达:通过点击卡片内按钮,就可以实现功能快捷操作,也支持点击后跳转到应用/元服务对应功能页,实现功能服务一步直达的效果。

开发模式

应用运行模式选择

当前系统中应用开发模型支持Stage和FA两种方式,所以Form Kit也同时支持开发者使用Stage模型和FA模型来开发卡片应用,但更推荐使用Stage模型。

UI开发范式选择

  • Stage模型支持两种卡片UI开发方式,可以基于声明式范式ArkTS语言开发卡片(简称ArkTS卡片)、也可以基于类Web范式JS语言开发卡片(简称JS卡片)。
  • FA模型仅支持基于类Web范式JS语言开发JS卡片。

ArkTS卡片的优势

卡片作为应用的一个快捷入口,ArkTS卡片相较于JS卡片具备如下几点优势:

  • 统一开发范式,提升开发体验和开发效率。

    提供ArkTS卡片能力后,统一了卡片和页面的开发范式,页面的布局可以直接复用到卡片布局中,提升开发体验和开发效率。

    图3 卡片工程结构对比

  • 增强了卡片的能力,使卡片更加万能。

    • 新增了动效的能力:ArkTS卡片开放了属性动画显式动画的能力,使卡片的交互更加友好。
    • 新增了自定义绘制的能力:ArkTS卡片开放了Canvas画布组件的能力,卡片可以使用自定义绘制的能力构建更多样的显示和交互效果。
    • 允许卡片中运行逻辑代码:开放逻辑代码运行后很多业务逻辑可以在卡片内部自闭环,拓宽了卡片的业务适用场景。

ArkTS卡片的约束

ArkTS卡片相较于JS卡片具备了更加丰富的能力,但也增加了使用卡片进行恶意行为的风险。由于ArkTS卡片显示在使用方应用中,使用方应用一般为桌面应用,为确保桌面的使用体验以及功耗相关考虑,对ArkTS卡片的能力做了以下约束:

  • 当导入模块时,仅支持导入标识"支持在ArkTS卡片中使用"的模块。
  • 支持导入HAR静态共享包,不支持导入HSP动态共享包。
  • 不支持使用native语言开发。
  • 仅支持声明式范式的部分组件、事件、动效、数据管理、状态管理和API能力。
  • 卡片的事件处理和使用方的事件处理是独立的,建议在使用方支持左右滑动的场景下卡片内容不要使用左右滑动功能的组件,以防手势冲突影响交互体验。

除此之外,当前ArkTS卡片还存在如下约束:

  • 暂不支持极速预览。
  • 暂不支持断点调试能力。
  • 暂不支持Hot Reload热重载。
  • 暂不支持setTimeOut。

新建卡片步骤

  1. 新建卡片

  2. 进行配置

  3. 多了三个相关的文件

卡片的基本布局

卡片的生命周期

js 复制代码
const TAG: string = 'EntryFormAbility';
const DOMAIN_NUMBER: number = 0xFF00;
 
export default class EntryFormAbility extends FormExtensionAbility {
  onAddForm(want: Want): formBindingData.FormBindingData {
    hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onAddForm');
    hilog.info(DOMAIN_NUMBER, TAG, want.parameters?.[formInfo.FormParam.NAME_KEY] as string);
 
    // ...
    // 卡片使用方创建卡片时触发,提供方需要返回卡片数据绑定类
    let obj: Record<string, string> = {
      'title': 'titleOnAddForm',
      'detail': 'detailOnAddForm'
    };
    let formData: formBindingData.FormBindingData = formBindingData.createFormBindingData(obj);
    return formData;
  }
 
  onCastToNormalForm(formId: string): void {
    // 卡片使用方将临时卡片转换为常态卡片触发,提供方需要做相应的处理。
    // 1、临时卡、常态卡是卡片使用方的概念。
    // 2、临时卡是短期存在的,在特定事件或用户行为后显示,完成后自动消失。
    // 3、常态卡是持久存在的,在用户未进行清除或更改的情况下,会一直存在,平时开发的功能卡片属于常态卡。
    // 4、目前手机上没有地方会使用临时卡。
    hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onCastToNormalForm');
  }
 
  onUpdateForm(formId: string): void {
    // 若卡片支持定时更新/定点更新/卡片使用方主动请求更新功能,则提供方需要重写该方法以支持数据更新
    hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onUpdateForm');
    let obj: Record<string, string> = {
      'title': 'titleOnUpdateForm',
      'detail': 'detailOnUpdateForm'
    };
    let formData: formBindingData.FormBindingData = formBindingData.createFormBindingData(obj);
    formProvider.updateForm(formId, formData).catch((error: BusinessError) => {
      hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] updateForm, error:' + JSON.stringify(error));
    });
  }
 
  onChangeFormVisibility(newStatus: Record<string, number>): void {
    // 卡片使用方发起可见或者不可见通知触发,提供方需要做相应的处理,仅系统应用生效
    hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onChangeFormVisibility');
  }
 
  onFormEvent(formId: string, message: string): void {
    // 若卡片支持触发事件,则需要重写该方法并实现对事件的触发
    hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onFormEvent');
    // ...
  }
 
  onRemoveForm(formId: string): void {
    // 删除卡片实例数据
    hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onRemoveForm');
    // 删除之前持久化的卡片实例数据
    // 此接口请根据实际情况实现,具体请参考:FormExtAbility Stage模型卡片实例
  }
 
  onConfigurationUpdate(config: Configuration) {
    // 当前formExtensionAbility存活时更新系统配置信息时触发的回调。
    // 需注意:formExtensionAbility创建后10秒内无操作将会被清理。
    hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onConfigurationUpdate:' + JSON.stringify(config));
  }
 
  onAcquireFormState(want: Want) {
    // 卡片提供方接收查询卡片状态通知接口,默认返回卡片初始状态。
    return formInfo.FormState.READY;
  }
}

卡片开发事件

  • router事件:可以使用router事件跳转到指定UIAbility,并通过router事件刷新卡片内容。
  • call事件:可以使用call事件拉起指定UIAbility到后台,并通过call事件刷新卡片内容。
  • message事件:可以使用message拉起FormExtensionAbility,并通过FormExtensionAbility刷新卡片内容。

postCardAction核心事件

语法:postCardAction(component: Object, action: Object): void 参数:

参数名 类型 必填 说明
component Object 当前自定义组件的实例,通常传入this。
action Object action的具体描述,详情见下表。

action参数说明:

参数名 类型 必填 取值说明
action string action的类型,支持三种预定义的类型:- router:跳转到提供方应用的指定UIAbility。- message:自定义消息,触发后会调用提供方FormExtensionAbility的onFormEvent()生命周期回调。- call:后台启动提供方应用。触发后会拉起提供方应用的指定UIAbility(仅支持launchType为singleton的UIAbility,即启动模式为单实例的UIAbility),但不会调度到前台。提供方应用需要具备后台运行权限(ohos.permission.KEEP_BACKGROUND_RUNNING)。
bundleName string action为router / call 类型时跳转的包名。
moduleName string action为router / call 类型时跳转的模块名。
abilityName string action为router / call 类型时跳转的UIAbility名。
uri11+ string action为router 类型时跳转的UIAbility的统一资源标识符。uri和abilityName同时存在时,abilityName优先。
params Object 当前action携带的额外参数,内容使用JSON格式的键值对形式。

说明

"action"为"call" 类型时,"params"需填入参数'method',且类型需为string类型,用于触发UIAbility中对应的方法。

js 复制代码
Button('跳转')
  .width('40%')
  .height('20%')
  .onClick(() => {
    postCardAction(this, {
      action: 'router',
      bundleName: 'com.example.myapplication',
      abilityName: 'EntryAbility',
      params: {
        message: 'testForRouter' // 自定义要发送的message
      }
    });
  })

Button('拉至后台')
  .width('40%')
  .height('20%')
  .onClick(() => {
    postCardAction(this, {
      action: 'call',
      bundleName: 'com.example.myapplication',
      abilityName: 'EntryAbility',
      params: {
        method: 'fun', // 自定义调用的方法名,必填
        message: 'testForCall' // 自定义要发送的message
      }
    });
  })

Button('URI跳转')
  .width('40%')
  .height('20%')
  .onClick(() => {
    postCardAction(this, {
      action: 'router',
      uri: 'example://uri.ohos.com/link_page',
      params: {
        message: 'router msg for dynamic uri deeplink' // 自定义要发送的message
      }
    });
  })

router拉起应用至前台

js 复制代码
//src/main/ets/widgeteventroutercard/pages/WidgetEventRouterCard.ets
  Button() {
  Text($r('app.string.ButtonB_label'))
 .onClick(() => {
  postCardAction(this, {
  action: 'router',
  abilityName: 'EntryAbility',
  params: { targetPage: 'funB' }
  });

处理router事件 在UIAbility中接收router事件并获取参数,根据传递的params不同,选择拉起不同的页面。

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

const TAG: string = 'EntryAbility';
const DOMAIN_NUMBER: number = 0xFF00;

export default class EntryAbility extends UIAbility {
  private selectPage: string = '';
  private currentWindowStage: window.WindowStage | null = null;

  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 获取router事件中传递的targetPage参数
    hilog.info(DOMAIN_NUMBER, TAG, `Ability onCreate: ${JSON.stringify(want?.parameters)}`);
    if (want?.parameters?.params) {
      // want.parameters.params 对应 postCardAction() 中 params 内容
      let params: Record<string, Object> = JSON.parse(want.parameters.params as string);
      this.selectPage = params.targetPage as string;
      hilog.info(DOMAIN_NUMBER, TAG, `onCreate selectPage: ${this.selectPage}`);
    }
  }

  // 如果UIAbility已在后台运行,在收到Router事件后会触发onNewWant生命周期回调
  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    hilog.info(DOMAIN_NUMBER, TAG, `Ability onNewWant: ${JSON.stringify(want?.parameters)}`);
    if (want?.parameters?.params) {
      // want.parameters.params 对应 postCardAction() 中 params 内容
      let params: Record<string, Object> = JSON.parse(want.parameters.params as string);
      this.selectPage = params.targetPage as string;
      hilog.info(DOMAIN_NUMBER, TAG, `onNewWant selectPage: ${this.selectPage}`);
    }
    if (this.currentWindowStage !== null) {
      this.onWindowStageCreate(this.currentWindowStage);
    }
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    // Main window is created, set main page for this ability
    let targetPage: string;
    // 根据传递的targetPage不同,选择拉起不同的页面
    switch (this.selectPage) {
      case 'funA':
        targetPage = 'pages/FunA'; //与实际的UIAbility页面路径保持一致
        break;
      case 'funB':
        targetPage = 'pages/FunB'; //与实际的UIAbility页面路径保持一致
        break;
      default:
        targetPage = 'pages/Index'; //与实际的UIAbility页面路径保持一致
    }
    if (this.currentWindowStage === null) {
      this.currentWindowStage = windowStage;
    }
    windowStage.loadContent(targetPage, (err, data) => {
      if (err.code) {
        hilog.error(DOMAIN_NUMBER, TAG, 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
        return;
      }
      hilog.info(DOMAIN_NUMBER, TAG, 'Succeeded in loading the content. Data: %{public}s', JSON.stringify(data) ?? '');
    });
  }
}

创建跳转后的UIAbility页面

在pages文件夹下新建FunA.ets和FunB.ets,构建页面布局。

js 复制代码
//src/main/ets/pages/FunA.ets
@Entry
@Component
struct FunA {
  @State message: string = 'Hello World';

  build() {
    RelativeContainer() {
      Text(this.message)
        .id('HelloWorld')
        .fontSize(50)
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
    }
    .height('100%')
    .width('100%')
  }
}

注册UIAbility页面

打开main_pages.json,将新建的FunA.ets和FunB.ets正确注册在src数组中。

js 复制代码
//src/main/resources/base/profile/main_pages.json
{
  "src": [
    "pages/Index",
    "pages/FunA",
    "pages/FunB"
  ]
}

call拉其应用至后台

在卡片中使用postCardAction接口的call能力,能够将卡片提供方应用的指定的UIAbility拉到后台。同时,call能力提供了调用应用指定方法、传递数据的功能,使应用在后台运行时可以通过卡片上的按钮执行不同的功能。

步骤

在卡片页面中布局两个按钮,点击其中一个按钮时调用postCardAction向指定UIAbility发送call事件,并在事件内定义需要调用的方法和传递的数据。需要注意的是,method参数为必选参数,且类型需要为string类型,用于触发UIAbility中对应的方法。

js 复制代码
 //src/main/ets/widgeteventcallcard/pages/WidgetEventCallCardCard.ets
 @Entry
 @Component
 struct WidgetEventCallCard {
   @LocalStorageProp('formId') formId: string = '12400633174999288';
 
   build() {
     Column() {
       //...
       Row() {
         Column() {
           Button() {
           //...
           }
           //...
           .onClick(() => {
             postCardAction(this, {
               action: 'call',
               abilityName: 'WidgetEventCallEntryAbility', // 只能跳转到当前应用下的UIAbility,与module.json5中定义保持
               params: {
                 formId: this.formId,
                 method: 'funA' // 在EntryAbility中调用的方法名
               }
             });
           })
 
           Button() {
           //...
           }
           //...
           .onClick(() => {
             postCardAction(this, {
               action: 'call',
               abilityName: 'WidgetEventCallEntryAbility', // 只能跳转到当前应用下的UIAbility,与module.json5中定义保持
               params: {
                 formId: this.formId,
                 method: 'funB', // 在EntryAbility中调用的方法名
                 num: 1 // 需要传递的其他参数
               }
             });
           })
         }
       }.width('100%').height('80%')
       .justifyContent(FlexAlign.Center)
     }
     .width('100%')
     .height('100%')
     .alignItems(HorizontalAlign.Center)
   }
 }

创建指定的UIAbility

在UIAbility中接收call事件并获取参数,根据传递的method不同,执行不同的方法。其余数据可以通过readString方法获取。需要注意的是,UIAbility需要onCreate生命周期中监听所需的方法。

js 复制代码
//src/main/ets/widgeteventcallcard/WidgetEventCallEntryAbility/WidgetEventCallEntryAbility.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { rpc } from '@kit.IPCKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
  
const TAG: string = 'WidgetEventCallEntryAbility';
const DOMAIN_NUMBER: number = 0xFF00;
const CONST_NUMBER_1: number = 1;
const CONST_NUMBER_2: number = 2;
  
class MyParcelable implements rpc.Parcelable {
  num: number;
  str: string;
  
  constructor(num: number, str: string) {
    this.num = num;
    this.str = str;
  }
  
  marshalling(messageSequence: rpc.MessageSequence): boolean {
    messageSequence.writeInt(this.num);
    messageSequence.writeString(this.str);
    return true;
  }
  
  unmarshalling(messageSequence: rpc.MessageSequence): boolean {
    this.num = messageSequence.readInt();
    this.str = messageSequence.readString();
      return true;
  }
}
  
export default class WidgetEventCallEntryAbility extends UIAbility {
  // 如果UIAbility第一次启动,在收到call事件后会触发onCreate生命周期回调
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    try {
      // 监听call事件所需的方法
      this.callee.on('funA', (data: rpc.MessageSequence) => {
        // 获取call事件中传递的所有参数
        hilog.info(DOMAIN_NUMBER, TAG, `FunACall param:  ${JSON.stringify(data.readString())}`);
        promptAction.showToast({
          message: 'FunACall param:' + JSON.stringify(data.readString())
        });
        return new MyParcelable(CONST_NUMBER_1, 'aaa');
      });
      this.callee.on('funB', (data: rpc.MessageSequence) => {
        // 获取call事件中传递的所有参数
        hilog.info(DOMAIN_NUMBER, TAG, `FunBCall param:  ${JSON.stringify(data.readString())}`);
        promptAction.showToast({
          message: 'FunBCall param:' + JSON.stringify(data.readString())
        });
        return new MyParcelable(CONST_NUMBER_2, 'bbb');
      });
    } catch (err) {
      hilog.error(DOMAIN_NUMBER, TAG, `Failed to register callee on. Cause: ${JSON.stringify(err as BusinessError)}`);
    }
  }
  
  // 进程退出时,解除监听
  onDestroy(): void | Promise<void> {
    try {
      this.callee.off('funA');
      this.callee.off('funB');
    } catch (err) {
      hilog.error(DOMAIN_NUMBER, TAG, `Failed to register callee off. Cause: ${JSON.stringify(err as BusinessError)}`);
    }
  }
}

配置后台运行权限

call事件含有约束限制:提供方应用需要在module.json5顶层对象module下添加后台运行权限

js 复制代码
//src/main/module.json5
"requestPermissions":[
   {
     "name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
   }
 ]

配置指定的UIAbility

在module.json5顶层对象module的abilities数组内添加WidgetEventCallEntryAbility对应的配置信息。

js 复制代码
//src/main/module.json5
"abilities": [
 {
   "name": 'WidgetEventCallEntryAbility',
   "srcEntry": './ets/widgeteventcallcard/WidgetEventCallEntryAbility/WidgetEventCallEntryAbility.ets',
   "description": '$string:WidgetEventCallCard_desc',
   "icon": "$media:app_icon",
   "label": "$string:WidgetEventCallCard_label",
   "startWindowIcon": "$media:app_icon",
   "startWindowBackground": "$color:start_window_background"
 }
]

卡片定时刷新

定时刷新:表示在一定时间间隔内调用onUpdateForm的生命周期回调函数自动刷新卡片内容。可以在form_config.json配置文件的updateDuration字段中进行设置。例如,可以将updateDuration字段的值设置为2,表示刷新时间设置为每小时一次。

js 复制代码
{
  "forms": [
    {
      "name": "UpdateDuration",
      "description": "$string:widget_updateduration_desc",
      "src": "./ets/updateduration/pages/UpdateDurationCard.ets",
      "uiSyntax": "arkts",
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": true,
      "updateEnabled": true,
      "scheduledUpdateTime": "10:30",
      "updateDuration": 2,
      "defaultDimension": "2*2",
      "supportDimensions": [
        "2*2"
      ]
    }
  ]
}

说明

  1. 在使用定时刷新时,需要在form_config.json配置文件中设置updateEnabled字段为true,以启用周期性刷新功能。

  2. 为减少卡片被动周期刷新进程启动次数,降低卡片刷新功耗,应用市场在安装应用时可以为该应用配置刷新周期,

    也可以为已经安装的应用动态配置刷新周期,用来限制卡片刷新周期的时长,以达到降低周期刷新进程启动次数的目的。

    ● 当配置了updateDuration(定时刷新)后,若应用市场动态配置了该应用的刷新周期,

    卡片框架会将form_config.json文件中配置的刷新周期与应用市场配置的刷新周期进行比较,取较长的刷新周期做为该卡片的定时刷新周期。

    ● 若应用市场未动态配置该应用的刷新周期,则以form_config.json文件中配置的刷新周期为准。

    ● 若该卡片取消定时刷新功能,该规则将无效。

    ● 卡片定时刷新的更新周期单位为30分钟。应用市场配置的刷新周期范围是1~336,即最短为半小时(1 * 30min)刷新一次,最长为一周(336 * 30min)刷新一次。

    ● 该规则从API11开始生效。若小于API11,则以form_config.json文件中配置的刷新周期为准。

约束限制

  1. 定时刷新有配额限制,每张卡片每天最多通过定时方式触发刷新50次,定时刷新次数包含卡片配置项updateDuration和调用setFormNextRefreshTime方法两种方式,当达到50次配额后,无法通过定时方式再次触发刷新,刷新次数会在每天的0点重置。
  2. 当前定时刷新使用同一个计时器进行计时,因此卡片定时刷新的第一次刷新会有最多30分钟的偏差。比如第一张卡片A(每隔半小时刷新一次)在3点20分添加成功,定时器启动并每隔半小时触发一次事件,第二张卡片B(每隔半小时刷新一次)在3点40分添加成功,在3点50分定时器事件触发时,卡片A触发定时刷新,卡片B会在下次事件(4点20分)中才会触发。
  3. 定时刷新在卡片可见情况下才会触发,在卡片不可见时仅会记录刷新动作和刷新数据,待可见时统一刷新布局。
  4. 如果使能了卡片代理刷新,定时刷新和下次刷新不生效。

卡片定点刷新

定点刷新:表示在每天的某个特定时间点自动刷新卡片内容。可以在form_config.json配置文件中的scheduledUpdateTime字段中进行设置。例如,可以将刷新时间设置为每天的上午10点30分。

js 复制代码
{
  "forms": [
    {
      "name": "ScheduledUpdateTime",
      "description": "$string:widget_scheupdatetime_desc",
      "src": "./ets/scheduledupdatetime/pages/ScheduledUpdateTimeCard.ets",
      "uiSyntax": "arkts",
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": true,
      "updateEnabled": true,
      "scheduledUpdateTime": "10:30",
      "updateDuration": 0,
      "defaultDimension": "2*2",
      "supportDimensions": [
        "2*2"
      ]
    }
  ]
}

在触发定点刷新后,系统会调用FormExtensionAbility的onUpdateForm生命周期回调,在回调中,可以使用updateForm进行提供方刷新卡片。onUpdateForm生命周期回调的使用请参见卡片生命周期管理

说明

当同时配置了定时刷新updateDuration和定点刷新scheduledUpdateTime时,定时刷新的优先级更高且定点刷新不会执行。如果想要配置定点刷新,则需要将updateDuration配置为0。

约束限制

  1. 定点刷新在卡片可见情况下才会触发,在卡片不可见时仅会记录刷新动作和刷新数据,待可见时统一刷新布局。
相关推荐
nashane4 小时前
HarmonyOS 6学习:CapsLock键失效诊断与长截图完整实现指南
学习·华为·harmonyos
richard_yuu6 小时前
鸿蒙心理测评模块实战|PHQ-9/GAD7双量表答题、实时计分与结果本地化存储
华为·harmonyos
不爱吃糖的程序媛9 小时前
2026年Electron 鸿蒙PC环境搭建指南
人工智能·华为·harmonyos
nashane9 小时前
HarmonyOS 6学习:长截图功能开发中的滚动拼接与权限处理实战
人工智能·华为·harmonyos
大师兄666810 小时前
从零开发一个 HarmonyOS 输入法——KikaInputMethod 完整拆解
harmonyos·服务卡片·harmonyos6·formkit
Python私教16 小时前
鸿蒙 NEXT 也能接 MCP?用 ArkTS 跑通 AI Agent 工具链
人工智能·华为·harmonyos
Swift社区18 小时前
分布式能力在鸿蒙 PC 上到底怎么用?
分布式·华为·harmonyos
nashane1 天前
HarmonyOS 6学习:外接键盘CapsLock与长截图功能的实战调试与完整解决方案
学习·华为·计算机外设·harmonyos
aqi001 天前
一文理清 HarmonyOS 6.0.2 涵盖的十个升级点
android·华为·harmonyos·鸿蒙·harmony