一、什么是元服务

元服务(原名为原子化服务)是HarmonyOS提供的一种面向未来的服务提供方式,是有独立入口、免安装、可为用户提供一个或多个便捷服务的新型应用程序形态。以线上购物商城为例:

  • 传统购物应用:需要先安装应用,打开应用查找商品,加入购物车,然后完成支付。
  • 调整为包含“商品浏览”、“购物车”、“支付”等多个服务的元服务:无需安装,通过丰富入口直达服务页面。例如:将心仪商品页添加桌面,实时掌握商品价格变动。等到秒杀时间点,直达购物车进行结算。

元服务基于HarmonyOS API开发,支持运行在1+8+N设备上,供用户在合适的场景、合适的设备上便捷使用。元服务相对于传统方式的需要安装的应用形态更加轻量,同时提供更丰富的入口、更精准的分发。

二、元服务特点

随处可及

  • 服务发现:元服务可在负一屏、应用市场等入口发现并使用。
  • 智能推荐:元服务可以基于合适场景被主动推荐给用户使用。

服务直达

  • 元服务支持免安装使用。
  • 服务卡片:支持用户无需打开元服务便可获取服务内重要信息的展示和动态变化,如天气、关键事务备忘、热点新闻列表。

跨设备

  • 元服务支持运行在1+8+N设备上,如手机、平板等设备。
  • 支持跨设备分享:例如接入华为分享后,用户可分享元服务给好友,好友确认后打开分享的服务。
  • 支持跨端迁移:例如手机上未完成的邮件,迁移到平板继续编辑。
  • 支持多端协同:例如手机用作文档翻页和批注,配合智慧屏显示完成分布式办公;手机作为手柄,与智慧屏配合玩游戏。

三、典型应用场景

3.1.负一屏

打开负一屏搜索页,输入关键字,搜索获取对应的元服务。

HarmonyOS元服务_ide

3.2.桌面

用户可以将元服务的卡片添加到桌面,便可在桌面随时随地查看元服务的重要信息,点击卡片即可直达所需服务。

HarmonyOS元服务_ide_02

3.3.碰一碰/扫一扫

用户首次 "碰一碰" 或者 "扫一扫" 识别设备上的NFC标签,系统引导用户连接设备,连接成功后,再次 "碰一碰" 或者 "扫一扫" 即可直接使用相应的元服务。

HarmonyOS元服务_ide_03

再次碰一碰,如下:

HarmonyOS元服务_自定义_04

四、创建元服务项目

4.1.创建元服务项目的步骤

1)登录AppGallery Connect, 点击“我的应用”。首次进入需要签协议

HarmonyOS元服务_字符串_05

 

HarmonyOS元服务_ide_06

2)在“HarmonyOS”页签,“类型”选择“元服务”,可以查看创建的元服务。

HarmonyOS元服务_自定义_07

3)创建应用

HarmonyOS元服务_自定义_08

注意:

  • 软件包类型选择“APP(HarmonyOS应用)”。
  • 支持设备选择手机。
  • 应用名称自定义,这里填的是ArkTSAtomicService。
  • 应用分类根据服务的内容选择“应用”或者“游戏”。分类设置后不支持修改。
  • 默认语言元服务展示相关信息的默认语言。
  • 是否原子化服务,选择“是”。

4)填写信息,点击保存

HarmonyOS元服务_字符串_09

5)返回应用列表,在“HarmonyOS应用”页签可以查看创建的元服务。

HarmonyOS元服务_字符串_10

4.2.创建一个新的元服务工程

1)打开DevEco Studio,菜单选择“File > New > Create Project”,创建一个新工程。

HarmonyOS元服务_ide_11

2)工程名称自定义,填写的是ArkTSAtomicService。

HarmonyOS元服务_字符串_12

3)接下来就可以开发代码。

HarmonyOS元服务_字符串_13

4)卡片说明

ArkTS卡片创建完成后,工程中会新增如下卡片相关文件:卡片生命周期管理文件(EntryFormAbility.ts)、卡片页面文件(WidgetCard.ets)和卡片配置文件(form_config.json)。

HarmonyOS元服务_自定义_14

4.3.配置卡片的配置文件

卡片相关的配置文件主要包含FormExtensionAbility的配置和卡片的配置两部分:

1)卡片需要在module.json5配置文件中的extensionAbilities标签下,配置FormExtensionAbility相关信息。FormExtensionAbility需要填写metadata元信息标签,其中键名称为固定字符串“ohos.extension.form”,资源为卡片的具体配置信息的索引。配置示例如下:

{
  "module": {
    ...
    "extensionAbilities": [
      {
        "name": "EntryFormAbility",
        "srcEntrance": "./ets/entryformability/EntryFormAbility.ts",
        "label": "$string:EntryFormAbility_label",
        "description": "$string:EntryFormAbility_desc",
        "type": "form",
        "metadata": [
          {
            "name": "ohos.extension.form",
            "resource": "$profile:form_config"
          }
        ]
      }
    ]
  }
}

2)卡片的具体配置信息。上面FormExtensionAbility的元信息(“metadata”配置项)中,可以指定卡片具体配置信息的资源索引。如当resource指定为$profile:form_config时,会使用开发视图的resources/base/profile/目录下的form_config.json作为卡片profile配置文件。内部字段结构说明如下表所示。

form_config.json配置文件说明如下:

属性名称

含义

数据类型

是否可缺省

name

表示卡片的类名,字符串最大长度为127字节。

字符串


description

表示卡片的描述。取值可以是描述性内容,也可以是对描述性内容的资源索引,以支持多语言。字符串最大长度为255字节。该属性将显示在卡片预览界面上,以便用户识别不同卡片。

字符串

可缺省,缺省为空。

src

表示卡片对应的UI代码的完整路径。当为ArkTS卡片时,完整路径需要包含卡片文件的后缀,如:“./ets/widget/pages/WidgetCard.ets”。当为JS卡片时,完整路径无需包含卡片文件的后缀,如:“./js/widget/pages/WidgetCard”

字符串


uiSyntax

表示该卡片的类型,当前支持如下两种类型:- arkts:当前卡片为ArkTS卡片。- hml:当前卡片为JS卡片。

字符串

可缺省,缺省值为hml

window

用于定义与显示窗口相关的配置。

对象

可缺省

isDefault

表示该卡片是否为默认卡片,每个UIAbility有且只有一个默认卡片。- true:默认卡片。- false:非默认卡片。

布尔值


colorMode

表示卡片的主题样式,取值范围如下:- auto:自适应。- dark:深色主题。- light:浅色主题。

字符串

可缺省,缺省值为“auto”。

supportDimensions

表示卡片支持的外观规格,取值范围:- 1 * 2:表示1行2列的二宫格。- 2 * 2:表示2行2列的四宫格。- 2 * 4:表示2行4列的八宫格。- 4 * 4:表示4行4列的十六宫格。

字符串数组


defaultDimension

表示卡片的默认外观规格,取值必须在该卡片supportDimensions配置的列表中。

字符串


updateEnabled

表示卡片是否支持周期性刷新(包含定时刷新和定点刷新),取值范围:- true:表示支持周期性刷新,可以在定时刷新(updateDuration)和定点刷新(scheduledUpdateTime)两种方式任选其一,当两者同时配置时,定时刷新优先生效。- false:表示不支持周期性刷新。

布尔类型


scheduledUpdateTime

表示卡片的定点刷新的时刻,采用24小时制,精确到分钟。> 说明:> updateDuration参数优先级高于scheduledUpdateTime,两者同时配置时,以updateDuration配置的刷新时间为准。

字符串

可缺省,缺省时不进行定点刷新。

updateDuration

表示卡片定时刷新的更新周期,单位为30分钟,取值为自然数。当取值为0时,表示该参数不生效。当取值为正整数N时,表示刷新周期为30*N分钟。> 说明:> updateDuration参数优先级高于scheduledUpdateTime,两者同时配置时,以updateDuration配置的刷新时间为准。

数值

可缺省,缺省值为“0”。

formConfigAbility

表示卡片的配置跳转链接,采用URI格式。

字符串

可缺省,缺省值为空。

formVisibleNotify

标识是否允许卡片使用卡片可见性通知。

字符串

可缺省,缺省值为空。

metadata

表示卡片的自定义信息,包含customizeData数组标签。

对象

可缺省,缺省值为空。

配置示例如下:

{
  "forms": [
    {
      "name": "widget",
      "description": "This is a service widget.",
      "src": "./ets/widget/pages/WidgetCard.ets",
      "uiSyntax": "arkts",
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": true,
      "updateEnabled": false,
      "scheduledUpdateTime": "10:30",
      "updateDuration": 1,
      "defaultDimension": "2*2",
      "supportDimensions": [
        "2*2"
      ]
    },
    {
      "name": "WidgetCard",
      "description": "This is a service widget.",
      "src": "./ets/widget/pages/WidgetCard.ets",
      "uiSyntax": "arkts",
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": false,
      "updateEnabled": false,
      "scheduledUpdateTime": "10:30",
      "updateDuration": 1,
      "defaultDimension": "2*2",
      "supportDimensions": [
        "2*2"
      ]
    }
  ]
}

4.4.卡片生命周期管理

创建ArkTS卡片,需实现FormExtensionAbility生命周期接口。在EntryFormAbility.ts中,实现FormExtensionAbility生命周期接口,其中在onAddForm的入参want中可以通过FormParam取出卡片的相关信息。

import FormExtensionAbility from '@ohos.app.form.FormExtensionAbility';
import formInfo from '@ohos.app.form.formInfo';
import formBindingData from '@ohos.app.form.formBindingData';
import formProvider from '@ohos.app.form.formProvider';
export default class EntryFormAbility extends FormExtensionAbility {

  /**
   * 卡片提供方接收创建卡片的通知接口。
   * @param want 当前ExtensionAbility相关的Want类型信息,包括卡片ID、卡片名称、卡片样式等。这些卡片信息必须作为持久数据进行管理,以便后续更新和删除卡片。
   * @returns formBindingData.FormBindingData对象,卡片要显示的数据。
   */
  onAddForm(want) {
    //在入参want中可以取出卡片的唯一标识,formId
    let formId:string = want.parameters[formInfo.FormParam.IDENTITY_KEY];

    //使用方创建卡片时触发,提供方需要返回卡片数据绑定类
    let obj = {"title": "titleOnAddForm", "detail": "detailOnAddForm"};

    /**
     * 创建一个FormBindingData对象。
     * js卡片要展示的数据。可以是包含若干键值对的Object或者 json 格式的字符串。其中图片数据以'formImages'作为标识,内容为图片标识与图片文件描述符的键值对{'formImages': {'key1': fd1, 'key2': fd2}}
     */
    let formData = formBindingData.createFormBindingData(obj);

    return formData;
  }

  /**
   * 卡片提供方接收临时卡片转常态卡片的通知接口。
   * @param formId 请求转换为常态的卡片标识。
   */
  onCastToNormalForm(formId) {
    //使用方将临时卡片转换为常态卡片触发,需要方需要做相应的处理
    console.info(`[EntryFormAbility] onCastToNormalForm, formId: ${formId}`)
  }

  /**
   * 卡片提供方接收更新卡片的通知接口。获取最新数据后调用formProvider的updateForm接口刷新卡片数据。
   * @param formId 请求转换为常态的卡片标识。
   */
  onUpdateForm(formId) {
    // 若卡片支持 定时更新、定点更新、卡片使用方主动请求更新功能,则提供方需要重写该方法以支持数据更新
    console.info('[EntryFormAbility] onUpdateForm');

    let obj = { 'title': 'titleOnUpdateForm', 'detail': 'detailOnUpdateForm'};

    //创建一个FormBindingData对象。
    let formData = formBindingData.createFormBindingData(obj);

    //更新指定的卡片
    formProvider.updateForm(formId, formData).catch((err) => {
      if(err) {
        //异常分支
        console.error(`[EntryFormAbility] Failed to updateForm. Code: ${err.code}, message: ${err.message}`);
        return;
      }
    });
  }

  onChangeFormVisibility(newStatus) {
    // Called when the form provider receives form events from the system.
    // 需要配置formVisibleNotify为true,且为系统应用才会回调
    console.info('[EntryFormAbility] onChangeFormVisibility');
  }

  onFormEvent(formId, message) {
    // Called when a specified message event defined by the form provider is triggered.
    // 若卡片支持触发事件,则需要重写该方法并实现对事件的触发
    console.info('[EntryFormAbility] onFormEvent');
  }

  onRemoveForm(formId) {
    // Called to notify the form provider that a specified form has been destroyed.
    // 当对应的卡片删除时触发的回调,入参是被删除的卡片ID
    console.info('[EntryFormAbility] onRemoveForm');
  }

  onConfigurationUpdate(config) {
    // 当系统配置信息置更新时触发的回调
    console.info('[EntryFormAbility] configurationUpdate:' + JSON.stringify(config));
  }

  onAcquireFormState(want) {
    // Called to return a {@link FormState} object.
    // 卡片提供方接收查询卡片状态通知接口,默认返回卡片初始状态。
    return formInfo.FormState.READY;
  }

}

注意:FormExtensionAbility进程不能常驻后台,即在卡片生命周期回调函数中无法处理长时间的任务,在生命周期调度完成后会继续存在5秒,如5秒内没有新的生命周期回调触发则进程自动退出。针对可能需要5秒以上才能完成的业务逻辑,建议拉起主应用进行处理,处理完成后使用updateForm通知卡片进行刷新。

五、元服务项目案例

5.1.创建元服务项目

1)创建Index.ets,实现元服务主页,效果如下:

HarmonyOS元服务_ide_15

 代码如下:

import { CardComponent } from '../components/CardComponent'
import { StudyRecordComponent } from '../components/StudyRecordComponent'
@CustomDialog
struct CustomDialogExample {
  @Link textValue: string

  //使用 CustomDialogController 类显示自定义弹出窗口。
  controller: CustomDialogController

  build(){
    Column(){
      //弹窗提示信息
      Text(this.textValue)
        .textAlign(TextAlign.Center)
        .margin({top:20,bottom:20})

      //确定按钮
      Text("确定")
        .textAlign(TextAlign.Center)
        .margin({top:20})
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .onClick( () => {
          //关闭自定义弹窗
          this.controller?.close();
        })
    }
    .margin({ bottom: 20})
    .width("100%")
  }

}

@Entry
@Component
struct Index {
  // 加载视频
  @State videoSrc: Resource = $rawfile('hello.mp4')//默认的视频播放
  // 视频未播放时的预览图片路径。
  @State previewUri: Resource = $r("app.media.hello")
  // 视频播放速度
  @State curRate: PlaybackSpeed = PlaybackSpeed.Speed_Forward_1_00_X

  // 设置视频控制器
  controller: VideoController = new VideoController()

  @State ic_new: Resource = $r("app.media.ic_new")

  @State anyArray: Object[]=[{ name:'你好', type:1 },{ name:'谢谢', type:0 }]

  //自定义弹窗提示信息
  @State textValue: string=''

  //使用CustomDialogController类来显示自定义弹出窗口
  dialogController: CustomDialogController = new CustomDialogController({
    builder: CustomDialogExample({
      textValue: $textValue,
    }),
    autoCancel: true,
    alignment: DialogAlignment.Center,
    offset: { dx: 0, dy: -20 },
    gridCount: 4,
    customStyle: false
  })

  build() {
    Flex({direction:FlexDirection.Column, alignItems:ItemAlign.Center}) {
      Row(){
        Text("每日挑战")
          .fontSize(20)
          .fontColor(Color.White)
          .width("50%")

        Row(){
          Flex({justifyContent:FlexAlign.End}){
            // 换一批 图标
            Image(this.ic_new).width(20).height(20).margin({right:5})
            // 换一批 文字
            Text("换一批")
              .fontSize(20)
              .fontSize(20)
              .fontColor(Color.White)
              .onClick(() => {
                this.anyArray = [{ name: "吃", type: 1},{ name: "厉害", type:0}]
              })
          }.width("50%")
        }
      }
      .padding(10)
      .backgroundColor("#6EA689")

      //视频播放区域
      Column(){
        Flex({justifyContent:FlexAlign.SpaceBetween}){
          Text("根据视频所示,选择正确的答案").fontSize(12).fontColor(Color.White)
          Text("70%的人回答错误了").fontSize(12).fontColor(Color.White)
        }
        .padding(10)
        .margin({top:5})

        //视频
        Video({
          src:this.videoSrc, //加载的视频
          previewUri: this.previewUri, //视频未播放时的预览图片路径。
          currentProgressRate: this.curRate, // 视频播放倍速。
          controller: this.controller // 设置视频控制器
        })
          .width("100%")
          .height(180)
          .borderRadius(10)
          .borderRadius(10)
          .margin({bottom:10})

        //按钮,根据视频收拾猜测后,点击答案
        Flex({justifyContent:FlexAlign.SpaceAround}){
          //遍历anyArray
          ForEach(this.anyArray, (item) =>{
            Text(item.name)
              .textAlign(TextAlign.Center)
              .fontColor(Color.White)
              .width("30%")
              .height(30)
              .backgroundColor("#95B58C")
              .borderRadius(8)
              .onClick(()=>{
                if (item.type == 1) {
                  //如果不是undefined,则赋值
                  if (this.dialogController != undefined) {
                    this.dialogController.open() //打开弹窗
                    this.textValue = "恭喜你!回答正确" //设置弹窗信息
                  }
                }else {
                  //如果不是undefined,则赋值
                  if (this.dialogController != undefined) {
                    this.dialogController.open() //打开弹窗
                    this.textValue = "很遗憾,下次继续努力" //设置弹窗信息
                  }
                }
              })
          })
        }
        .margin({left:10, right:10, bottom:10})
        
      }
      .backgroundColor("#6EA689")
      .padding({right:10, left:10})
      .width("100%")
      .width("100%")


      CardComponent({CourseName:"课程",CourseDescription:"初级课程,易上手",ButtonName:"学习", url:"pages/CourseList"})

      CardComponent({CourseName:"测试",CourseDescription:"检测学习,记忆更牢固",ButtonName:"去闯关",url:"pages/PassingTest"})
      //学习记录
      StudyRecordComponent()

    }
    .height("100%")
    .width("100%")
    .backgroundColor("#ECEFF7")
  }
}

2)在pages包下创建CourseList.ets,实现课程列表页面,效果如下:

HarmonyOS元服务_ide_16

 案例代码如下:

import router from '@ohos.router';
import { arrImage } from '../common/json'

@Entry
@Component
struct CourseList {
  //获取数据信息
  @State arrImage: Object[] = arrImage

  //Scroller控制滑动
  scroller:Scroller = new Scroller();
  build() {
    Scroll(this.scroller){
      Column(){
        Row(){
          Text("初级课程").fontSize(20).fontColor(Color.White).width("90%").fontWeight(FontWeight.Bold)
        }.width("100%").height(50).backgroundColor("#6EA689").padding({left:20})

        //wrap:Flex 容器的元素排列在多行或多列中,子项允许超过容器
        Flex({ wrap: FlexWrap.Wrap,justifyContent: FlexAlign.SpaceBetween }){
          ForEach(this.arrImage, (item) => {
            Column(){
              Image(item.image).width("100%").height(120)
              Text(item.name).lineHeight(30).fontColor(Color.White).fontWeight(FontWeight.Medium)
            }
            .width("45%")
            .height(150)
            .backgroundColor("#6EA689")
            .margin(5)
            .borderRadius(10)
            .onClick(()=>{
              //点击后进行页面跳转
              router.pushUrl({
                url: 'pages/details',//跳转的页面
                params: {//传递参数
                  text: item.name,
                  image: item.image,
                  video: item.video
                }
              })
            })
          })
        }.width("100%").padding(10).backgroundColor("#FAFBFB")
      }.width("100%").height("100%").backgroundColor("#ECEFF7")
    }
    .scrollable(ScrollDirection.Vertical) //滚动方向:垂直滚动。
    .scrollBar(BarState.On) //滚动条常驻显示屏。
    .scrollBarColor(Color.Gray) //设置滚动条颜色
    .scrollBarWidth(5) //滚动条宽度
    .edgeEffect(EdgeEffect.Spring) //滚动到边沿后回弹
  }
}

3)在pages包下创建details.ets实现课程视频播放页面,效果如下:

HarmonyOS元服务_ide_17

案例代码如下:

import router from '@ohos.router'
import promptAction from '@ohos.promptAction'
@Entry
@Component
struct Details {
  //课程数量
  @StorageLink('course') course:number = 0

  //视频播放源的路径
  @State videoSrc: Resource = router.getParams()['video']
  //视频未播放时的预览图片路径。
  @State previewUri: Resource = router.getParams()['image']
  //视频播放倍速
  @State curRate: PlaybackSpeed = PlaybackSpeed.Speed_Forward_1_00_X
  //设置视频控制器
  controller: VideoController = new VideoController()

  //获取视频
  @State text: string = router.getParams()["text"]

  //点赞状态
  @State goodStatus:boolean = true
  //点赞数
  @State goodNumber:number = 0

  //收藏状态
  @State startStatus:boolean = true
  //收藏数
  @State startNumber:number=0

  //转发状态
  @State shareStatus:boolean=true
  //转发数量
  @State shareNumber:number=0

  build() {
    Flex({direction:FlexDirection.Column}) {
      Video({
        src: this.videoSrc,//视频播放源的路径
        previewUri:this.previewUri,//视频未播放时的预览图片路径。
        currentProgressRate: this.curRate,//视频播放倍速
        controller: this.controller //设置视频控制器
      })
        .width("100%")
        .height(180)
        .borderRadius(10)
        .margin({top:10})
        .onFinish(() => { //播放结束时触发该事件
          this.course += 1
          console.log("视频播放结束时调用,在播放课时新增1");
        })

      Text(`当前示例:${this.text}`).fontSize(20).margin({top:10,bottom:10})

      Row(){
        Row(){
          //三目运算符,如果点赞了,则需要切换图片
          Image(this.goodStatus?$r("app.media.icon_good"): $r("app.media.active_icon_good"))
            .width(24).height(24)
          Text(`${this.goodNumber}`).height(25)
        }
        .width("20%")
        .onClick(() => {
          //点击后修改状态
          this.goodStatus = !this.goodStatus;
          //点击后true,则点击数量增加,否则
          this.goodStatus ? this.goodNumber--:this.goodNumber++;
          promptAction.showToast({
            message: this.goodStatus? '取消点赞':'点赞成功',
            duration: 2000,
          })
        })

        //收藏
        Row(){
          //三目运算符,如果点赞了,则需要切换图片
          Image(this.startStatus?$r("app.media.icon_star"): $r("app.media.active_icon_star"))
            .width(24).height(24)
          Text(`${this.startNumber}`).height(25)
        }
        .width("20%")
        .onClick(() => {
          //点击后修改状态
          this.startStatus = !this.startStatus;
          //点击后true,则点击数量增加,否则
          this.startStatus ? this.startNumber--:this.startNumber++;
          promptAction.showToast({
            message: this.goodStatus? '取消收藏':'收藏成功',
            duration: 2000,
          })
        })

        //转发
        Row(){
          //三目运算符,如果点赞了,则需要切换图片
          Image(this.shareStatus?$r("app.media.icon_share"): $r("app.media.active_icon_share"))
            .width(24).height(24)
          Text(`${this.shareNumber}`).height(25)
        }
        .width("20%")
        .onClick(() => {
          //点击后修改状态
          this.shareStatus = !this.shareStatus;
          //点击后true,则点击数量增加,否则
          this.shareStatus ? this.shareNumber--:this.shareNumber++;
          promptAction.showToast({
            message: this.shareStatus? '取消转发':'转发成功',
            duration: 2000,
          })
        })
      }.width("100%")
    }
    //容器整体宽高
    .width('100%')
    .height('100%')
    .backgroundColor("#ECEFF7")
    .padding({left:10, right:10})
  }
}

4)在pages包下创建PassingTest.ets实现去闯关页面,效果如下:

HarmonyOS元服务_ide_18

案例代码如下:

import promptAction from '@ohos.promptAction'
import { arrImage } from '../common/json'

@Entry
@Component
struct PassingTest {
  //视频播放源的路径
  @State videoSrc: Resource = $rawfile("hello.mp4")
  //视频未播放时的预览图片路径。
  @State previewUri: Resource = $r("app.media.hello")
  //视频播放倍速
  @State curRate: PlaybackSpeed = PlaybackSpeed.Speed_Forward_1_00_X
  //设置视频控制器
  controller: VideoController = new VideoController()

  //已完成得到题目
  @State onActive : number = 1
  //总的题目数组
  @State arrImage: Object[]=arrImage

  build() {
    Flex({direction:FlexDirection.Column}) {
      Row(){
        //完成任务占比
        Text( `题目:${this.onActive}/${this.arrImage.length}`).fontSize(28).fontWeight(FontWeight.Bold)
      }
      .width("100%")
      .height(50)
      .alignItems(VerticalAlign.Center)
      .justifyContent(FlexAlign.Center)


      Video({
        src: this.videoSrc,//视频播放源的路径
        previewUri:this.previewUri,//视频未播放时的预览图片路径。
        currentProgressRate: this.curRate,//视频播放倍速
        controller: this.controller //设置视频控制器
      })
        .width("100%")
        .height(180)
        .borderRadius(10)


      Text("在下列选项中选择正确答案?").width("100%").fontSize(20).margin({top:10, bottom:10})

      //遍历输出答案
      Flex({wrap:FlexWrap.Wrap}){
        ForEach(this.arrImage[this.onActive-1]['answer'], (item:string)=>{
          Text(item)
            .height(40)
            .width(90)
            .borderRadius(5)
            .fontSize(20)
            .fontColor(Color.White)
            .backgroundColor("#7BAB85")
            .textAlign(TextAlign.Center)
            .margin({left:10,top:5,right:10,bottom:5})
            .onClick(() => {
              console.log(`本次选择的答案: ${item}`)
              if(this.onActive>=this.arrImage.length){
                promptAction.showToast({
                  message: "闯关已结束",
                  duration: 2000,
                })
              }

              if(item==this.arrImage[this.onActive-1]['name']){
                promptAction.showToast({
                  message: "答对了,请继续下一题",
                  duration: 2000
                })
              }else {
                promptAction.showToast({
                  message: "很遗憾,答错了!!!,继续努力哦",
                  duration: 2000
                })
              }
            })

        })
      }


    }
    //容器整体宽高
    .width('100%')
    .height('100%')
    .backgroundColor("#ECEFF7")
    .padding({left:10, right:10})
  }
}

5.2.组件封装

1)在components包下创建CardComponent.ets,实现主页Index.ets页面的课程、测试卡片,效果如下:

HarmonyOS元服务_字符串_19

案例代码如下:

import router from '@ohos.router'
@Entry
@Component
export struct CardComponent {
  //课程名
  CourseName:string
  //课程描述
  CourseDescription:string
  //按钮名称
  ButtonName:string

  //点击后跳转的页面
  url:string

  build() {
    Flex({justifyContent:FlexAlign.SpaceBetween, alignItems:ItemAlign.Center}){
      Column({space:5}){
        Text(this.CourseName).width("50%").fontSize(20).fontWeight(FontWeight.Bold)
        Text(this.CourseDescription).width("70%").fontSize(16).fontWeight(FontWeight.Bold).fontColor('#c4c2cf')
      }.alignItems(HorizontalAlign.Start)

      Button(this.ButtonName).width("30%").backgroundColor("#fecc5b")
        .onClick(()=>{
          router.pushUrl({url:this.url})
        })
    }
    .height(100)
    .width("90%")
    .backgroundColor(Color.White)
    .borderRadius(15)
    .padding({left:10, right:10})
    .margin({top:20})
  }
}

2)创建StudyRecordComponent.ets,封装组件,实现学习记录页面

HarmonyOS元服务_字符串_20

案例代码如下:

@Component
export struct StudyRecordComponent {
  //获取记录时间
  @State dayTime:string = "";
  @StorageLink("course") course: number = 0

  aboutToAppear(){
    //创建时间对象
    let date = new Date();
    this.dayTime = date.getFullYear() + "年" + (date.getMonth()+1) + "月" + date.getDate() + "日"
  }

  build() {
    Flex(){
      Column(){
        Row(){
          Text("学习记录").fontSize(20).fontWeight(FontWeight.Bold)
          Text(`${this.dayTime}`).width("50%").height(30).textAlign(TextAlign.End)
        }
        .height("50%")
        .width("100%")
        .alignItems(VerticalAlign.Center)
        .justifyContent(FlexAlign.SpaceBetween)
        .padding({left:10,right:10})

        Row(){
          Text(`已学${this.course}课程`).width("50%").height(20).fontSize(16).fontWeight(FontWeight.Bold).fontColor(Color.White)
          Text(`已学${this.course}分钟`).width("50%").height(20).fontColor(Color.White).textAlign(TextAlign.End)
        }
        .backgroundColor("#6EA689")
        .width("100%")
        .height("50%")
        .alignItems(VerticalAlign.Center)
        .justifyContent(FlexAlign.SpaceBetween)
        .padding({left:10,right:10})
        .borderRadius({bottomLeft:15,bottomRight:15})
      }.width("100%").height("100%")
    }
    .height(100)
    .width("90%")
    .backgroundColor(Color.White)
    .borderRadius(15)
    .margin({top:20})
  }
}

5.3.准备测试数据

创建common目录,创建json.ets,准备假数据,用于测试使用:

export const arrImage:Object[]= [
  {
    name:'你好',
    image:$r("app.media.hello"),
    video:$rawfile('hello.mp4'),
    content:'一手食指指向对方。一手握拳,向上伸出拇指。',
    answer:['你好','出生','爱','晚饭','中午','明天'],
  },
  {
    name:'谢谢',
    image:$r('app.media.thank'),
    video:$rawfile('thank.mp4'),
    content:'一手伸出拇指,弯曲两下,表示向人感谢。',
    answer:['谢谢','不用谢','爱','晚饭','中午','明天'],

  },
  {
    name:'爱',
    image:$r('app.media.love'),
    video:$rawfile('love.mp4'),
    content:'一手轻轻抚摩另一手拇指指背,表示一种“怜爱”的感情',
    answer:['谢谢','出生','爱','晚饭','中午','明天'],

  },
  {
    name:'喜欢',
    image:$r('app.media.live'),
    video:$rawfile('like.mp4'),
    content:'一手拇、食指微曲,指尖抵于颌下,头微微点动一下。    ',
    answer:['喜欢','出生','爱','晚饭','中午','明天'],

  },
  {
    name:'不喜欢',
    image:$r('app.media.dislike'),
    video:$rawfile('dislike.mp4'),
    content:'一手伸直,左右摆动几下。    一手拇、食指微曲,指尖抵于颌下,头微微点动一下。    ',
    answer:['不喜欢','喜欢','爱','晚饭','中午','明天'],

  },
  {
    name:'饭',
    image:$r('app.media.eat'),
    video:$rawfile('eat.mp4'),
    content:'(一)一手拇、食指相对,中间留有米粒大小距离。(二)一手伸食、中指象征筷子,作吃饭动作。',
    answer:['饭','没有饭','爱','晚饭','中午','明天'],

  },
  {
    name:'快',
    image:$r('app.media.quick'),
    video:$rawfile('quick.mp4'),
    content:'一手拇、食指相捏,很快地从一侧向另一侧作快速挥动,象征物体运动速度很快。',
    answer:['快','慢','爱','晚饭','中午','明天'],

  },
  {
    name:'慢',
    image:$r('app.media.slow'),
    video:$rawfile('show.mp4'),
    content:'一手掌心向下,慢慢地上下微动几下,象征物体运动速度缓慢。',
    answer:['慢','不慢','爱','晚饭','中午','明天'],

  },
  {
    name:'没关系',
    image:$r('app.media.matter'),
    video:$rawfile('matter.mp4'),
    content:'一手拇、食、中指捻动,连续几次。    两手拇、食指搭成圆圈,互相套环。    ',
    answer:['没关系','睡觉','爱','晚饭','中午','明天'],

  },
  {
    name:'厉害',
    image:$r('app.media.powerful'),
    video:$rawfile('powerful.mp4'),
    content:'一手打手指字母“L”的指式,并绕脸部转一圈。同时面部作出严厉的表情。    ',
    answer:['厉害','不厉害','帅','晚饭','中午','明天'],

  },
];

5.4.服务卡片的开发入门

1)直接运行

直接运行,在模拟器上就打开了,能看到页面。但按返回退出后,除了历史菜单中就找不到入口了。但通过设置->应用和服务->服务管理里能找到刚才创建的元服务。

HarmonyOS元服务_字符串_21

而这时候我们都还没有创建服务卡片,元服务的工程结构中,比普通应用程序多了entryformability和widget两个文件夹,entryformability中的EntryFormAbility定义了服务卡片,继承自FormExtensionAbility,该模块提供了卡片扩展相关接口。而我们的主界面是UIAbility类型的,UIAbility组件是一种包含UI界面的应用组件,主要用于和用户交互。widget目录下就是对服务卡片界面的布局代码。

2)应用程序添加卡片

对于应用程序,添加服务卡片,直接右击entry,选择New->ServiceWidget。

HarmonyOS元服务_字符串_22

 默认是2x2大小的卡片,可以同时添加其他尺寸的。

HarmonyOS元服务_字符串_23

WidgetCard.ets,案例代码如下:

@Entry
@Component
struct WidgetCard {
  @State flag:boolean = true;
  //数据信息
  @State arrImage:Object[] = [
    {
      name:'你好',
      image:$r("app.media.hello"),
      content:'一手食指指向对方。一手握拳,向上伸出拇指。',
      video:$rawfile('hello.mp4'),
      anyArray:[{
        name:'你好',
        type:1
      },{
        name:'谢谢',
        type:0
      }]
    },
    {
      name:'谢谢',
      image:$r('app.media.thank'),
      content:'一手伸出拇指,弯曲两下,表示向人感谢。',
      video:$rawfile('thank.mp4'),
      anyArray:[{
        name:'不好',
        type:0
      },{
        name:'谢谢',
        type:1
      }]
    },
    {
      name:'爱',
      image:$r('app.media.love'),
      content:'一手轻轻抚摩另一手拇指指背,表示一种“怜爱”的感情',
      video:$rawfile('love.mp4'),
      anyArray:[{
        name:'没有',
        type:0
      },{
        name:'爱',
        type:1
      }]
    },
    {
      name:'喜欢',
      image:$r('app.media.live'),
      video:$rawfile('like.mp4'),
      content:'一手拇、食指微曲,指尖抵于颌下,头微微点动一下。    ',
      anyArray:[{
        name:'没有',
        type:0
      },{
        name:'喜欢',
        type:1
      }]
    },
    {
      name:'不喜欢',
      image:$r('app.media.dislike'),
      video:$rawfile('dislike.mp4'),
      content:'一手伸直,左右摆动几下。    一手拇、食指微曲,指尖抵于颌下,头微微点动一下。    ',
      anyArray:[{
        name:'好晕',
        type:0
      },{
        name:'不喜欢',
        type:1
      }]
    },
    {
      name:'饭',
      image:$r('app.media.eat'),
      video:$rawfile('eat.mp4'),
      content:'(一)一手拇、食指相对,中间留有米粒大小距离。(二)一手伸食、中指象征筷子,作吃饭动作。',
      anyArray:[{
        name:'可爱',
        type:0
      },{
        name:'饭',
        type:1
      }]
    },
    {
      name:'快',
      image:$r('app.media.quick'),
      video:$rawfile('quick.mp4'),
      content:'一手拇、食指相捏,很快地从一侧向另一侧作快速挥动,象征物体运动速度很快。',
      anyArray:[{
        name:'慢',
        type:0
      },{
        name:'快',
        type:1
      }]
    },
    {
      name:'慢',
      image:$r('app.media.slow'),
      video:$rawfile('show.mp4'),
      content:'一手掌心向下,慢慢地上下微动几下,象征物体运动速度缓慢。',
      anyArray:[{
        name:'去',
        type:0
      },{
        name:'慢',
        type:1
      }]
    },
    {
      name:'没关系',
      image:$r('app.media.matter'),
      video:$rawfile('matter.mp4'),
      content:'一手拇、食、中指捻动,连续几次。    两手拇、食指搭成圆圈,互相套环。    ',
      anyArray:[{
        name:'人类',
        type:0
      },{
        name:'没关系',
        type:1
      }]
    },
    {
      name:'厉害',
      image:$r('app.media.powerful'),
      video:$rawfile('powerful.mp4'),
      content:'一手打手指字母“L”的指式,并绕脸部转一圈。同时面部作出严厉的表情。    ',
      anyArray:[{
        name:'好帅',
        type:0
      },{
        name:'厉害',
        type:1
      }]
    },
  ];

  //索引
  @State onActive:number=0

  //不透明度角度
  @State opacityAngle: number =  0.8

  /*
   * The max lines.
   */
  readonly MAX_LINES: number = 1;

  /*
   * The title.
   */
  readonly TITLE: string = 'Hello World';

  /*
   * The action type.
   */
  readonly ACTION_TYPE: string = 'router';

  /*
   * The ability name.
   */
  readonly ABILITY_NAME: string = 'EntryAbility';

  /*
   * The message.
   */
  readonly MESSAGE: string = 'add detail';

  /*
   * The with percentage setting.
   */
  readonly FULL_WIDTH_PERCENT: string = '100%';

  /*
   * The height percentage setting.
   */
  readonly FULL_HEIGHT_PERCENT: string = '100%';

  build() {
    Stack(){
      if (this.flag){
        Image(this.arrImage[this.onActive]['image'])
          .width(this.FULL_WIDTH_PERCENT)
          .height(this.FULL_HEIGHT_PERCENT)
          .objectFit(ImageFit.Cover)
          .opacity(this.opacityAngle)
          .transition({ type: TransitionType.Insert, translate: { x: 0, y: 0 } })
          .transition({ type: TransitionType.Delete, opacity: 0, scale: { x: 0, y: 0 } })

      }else {
        Image(this.arrImage[this.onActive]['image'])
          .width(this.FULL_WIDTH_PERCENT) //宽度
          .height(this.FULL_HEIGHT_PERCENT) //高度
          .objectFit(ImageFit.Cover) //保持长宽比放大或缩小,使图像的两边都大于或等于显示边界。
          .opacity(this.opacityAngle) //通过设置透明比率来让PixelMap达到对应的透明效果
          // 设置组件插入显示和删除隐藏的过渡效果。Insert指定当前的Transition动效生效在组件的插入显示场景
          // translate 设置组件转场时的平移效果,为插入时起点和删除时终点的
          .transition({ type:TransitionType.Insert, translate: { x:0,y:0 }})
          //scale设置组件转场时的缩放效果,为插入时起点和删除时终点的值。
          //opacity 设置组件转场时的透明度效果,为插入时起点和删除时终点的值 默认值:1 取值范围: [0, 1]
          .transition({ type:TransitionType.Delete, opacity:0, scale: { x:0,y:0 }} )
      }

      //换一批 按钮
      Flex({ direction: FlexDirection.Row, justifyContent:FlexAlign.Center, alignItems:ItemAlign.Center}){
        Image($r("app.media.ic_new"))
          .width(11)
          .height(11)
          //.margin({left:10, top:3, right:5})

        Text("换一批").fontSize(8).width(40).fontColor(Color.White).textAlign(TextAlign.Center)
      }
      .backgroundColor("#286955")
      .width("45%")
      .height(20)
      .borderRadius(10)
      .padding({left:5, right:5})
      .onClick(()=>{
        //显式动画(animateTo),闭包内的变化均会触发动画,包括由数据变化引起的组件的增删、组件属性的变化等,可以做较为复杂的动画。
        //duration动画持续时间,单位为毫秒。 默认值:1000,第二个参数为动画的闭包函数。
        animateTo({ duration:1000}, ()=>{
          //指定显示动效的闭包函数,在闭包函数中导致的状态变化系统会自动插入过渡动画。
          this.flag = !this.flag; // 切换显示状态
          this.onActive = (this.onActive+1) % this.arrImage.length; // 切换显示的图片索引
        })
      })
      .position({x:10, y:10})

      //答案按钮:
      Flex({direction: FlexDirection.Row, justifyContent:FlexAlign.SpaceBetween}){
        ForEach(this.arrImage[this.onActive]['anyArray'], (item)=>{
          Button(item.name, {type:ButtonType.Normal})
            .fontSize(8)
            .borderRadius(10)
            .height(20)
            .width("45%")
            .backgroundColor("#286955")
            .onClick(()=>{
              if(item.type == 1){
                animateTo({ duration:1000 }, () => {
                  this.flag = !this.flag; // 切换显示状态
                  this.onActive = (this.onActive+1) % this.arrImage.length; // 切换显示的图片索引
                })
              }

            })

        })

      }
      .margin({top:110, left:10, right:10})

      Text(this.arrImage[this.onActive]['content'])
        .fontSize(6)
        .opacity(0.66) //通过设置透明比率来让PixelMap达到对应的透明效果
        .margin({ top: 6})
        .textOverflow({ overflow: TextOverflow.Ellipsis}) //设置字体溢出模式时调用。TextOverflow.Ellipsis如果文本太长,无法显示的文本应以省略号代替。
        .fontColor(Color.Black)
        .maxLines(this.MAX_LINES) //在设置最大文本行数时调用。
    }
    .width(this.FULL_WIDTH_PERCENT)
    .height(this.FULL_HEIGHT_PERCENT)
    .onClick(() => {
      //postCardAction()接口用于卡片内部和提供方应用间的交互
      postCardAction(this,{
        "action": this.ACTION_TYPE,//"router":跳转到提供方应用的指定UIAbility。
        "abilityName": this.ABILITY_NAME, //"router" / "call" 类型时跳转的UIAbility名,必填。
        "params":{"message": this.MESSAGE} // 当前action携带的额外参数,内容使用JSON格式的键值对形式。"call"类型时需填入参数'method',且类型需要为string类型,用于触发UIAbility中对应的方法,必填。
      })
    })
  }
}

3)在entryformability包下创建EntryFormAbility.ets

提供了卡片扩展相关接口

import FormExtensionAbility from '@ohos.app.form.FormExtensionAbility';
import formInfo from '@ohos.app.form.formInfo';
import formBindingData from '@ohos.app.form.formBindingData';
import formProvider from '@ohos.app.form.formProvider';
export default class EntryFormAbility extends FormExtensionAbility {

  /**
   * 卡片提供方接收创建卡片的通知接口。
   * @param want 当前ExtensionAbility相关的Want类型信息,包括卡片ID、卡片名称、卡片样式等。这些卡片信息必须作为持久数据进行管理,以便后续更新和删除卡片。
   * @returns formBindingData.FormBindingData对象,卡片要显示的数据。
   */
  onAddForm(want) {
    //在入参want中可以取出卡片的唯一标识,formId
    let formId:string = want.parameters[formInfo.FormParam.IDENTITY_KEY];

    //使用方创建卡片时触发,提供方需要返回卡片数据绑定类
    let obj = {"title": "titleOnAddForm", "detail": "detailOnAddForm"};

    /**
     * 创建一个FormBindingData对象。
     * js卡片要展示的数据。可以是包含若干键值对的Object或者 json 格式的字符串。其中图片数据以'formImages'作为标识,内容为图片标识与图片文件描述符的键值对{'formImages': {'key1': fd1, 'key2': fd2}}
     */
    let formData = formBindingData.createFormBindingData(obj);

    return formData;
  }

  /**
   * 卡片提供方接收临时卡片转常态卡片的通知接口。
   * @param formId 请求转换为常态的卡片标识。
   */
  onCastToNormalForm(formId) {
    //使用方将临时卡片转换为常态卡片触发,需要方需要做相应的处理
    console.info(`[EntryFormAbility] onCastToNormalForm, formId: ${formId}`)
  }

  /**
   * 卡片提供方接收更新卡片的通知接口。获取最新数据后调用formProvider的updateForm接口刷新卡片数据。
   * @param formId 请求转换为常态的卡片标识。
   */
  onUpdateForm(formId) {
    // 若卡片支持 定时更新、定点更新、卡片使用方主动请求更新功能,则提供方需要重写该方法以支持数据更新
    console.info('[EntryFormAbility] onUpdateForm');

    let obj = { 'title': 'titleOnUpdateForm', 'detail': 'detailOnUpdateForm'};

    //创建一个FormBindingData对象。
    let formData = formBindingData.createFormBindingData(obj);

    //更新指定的卡片
    formProvider.updateForm(formId, formData).catch((err) => {
      if(err) {
        //异常分支
        console.error(`[EntryFormAbility] Failed to updateForm. Code: ${err.code}, message: ${err.message}`);
        return;
      }
    });
  }

  onChangeFormVisibility(newStatus) {
    // Called when the form provider receives form events from the system.
    // 需要配置formVisibleNotify为true,且为系统应用才会回调
    console.info('[EntryFormAbility] onChangeFormVisibility');
  }

  onFormEvent(formId, message) {
    // Called when a specified message event defined by the form provider is triggered.
    // 若卡片支持触发事件,则需要重写该方法并实现对事件的触发
    console.info('[EntryFormAbility] onFormEvent');
  }

  onRemoveForm(formId) {
    // Called to notify the form provider that a specified form has been destroyed.
    // 当对应的卡片删除时触发的回调,入参是被删除的卡片ID
    console.info('[EntryFormAbility] onRemoveForm');
  }

  onConfigurationUpdate(config) {
    // 当系统配置信息置更新时触发的回调
    console.info('[EntryFormAbility] configurationUpdate:' + JSON.stringify(config));
  }

  onAcquireFormState(want) {
    // Called to return a {@link FormState} object.
    // 卡片提供方接收查询卡片状态通知接口,默认返回卡片初始状态。
    return formInfo.FormState.READY;
  }

}

5.5.运行部署

重新部署项目,元服务都应该有卡片入口的,怎么找到卡片入口呢?可以通过Ctrl+鼠标向上滑操作即能进入桌面菜单,选择服务卡片,就能找到刚才创建的元服务的服务卡片了。鸿蒙开发文档都没有讲清楚

HarmonyOS元服务_字符串_24

点击进入服务卡片,如下:

HarmonyOS元服务_ide_25

点击,将卡片添加到桌面,如下:

HarmonyOS元服务_自定义_26

添加到桌面,预览效果如下:

HarmonyOS元服务_字符串_27

六、上架及部署

开发、调试完HarmonyOS应用/元服务,就可以在AppGallery Connect申请上架,华为审核通过后,用户即可在华为应用市场获取您的HarmonyOS应用/元服务。HarmonyOS会通过数字证书与Profile文件等签名信息来保证应用的完整性,需要上架的HarmonyOS应用/元服务都必须通过签名校验,所以上架前,您需要先完成签名操作。操作流程如下:

HarmonyOS元服务_自定义_28

6.1.生成密钥和和证书请求文件

在申请数字证书和Profile文件前,首先需要通过DevEco Studio来生成密钥和证书请求文件。

  • 密钥:包含非对称加密中使用的公钥和私钥,存储在密钥库文件中,格式为.p12,公钥和私钥对用于数字签名和验证。
  • 证书请求文件:格式为.csr,全称为Certificate Signing Request,包含密钥对中的公钥和公共名称、组织名称、组织单位等信息,用于向AGC申请数字证书。

1)在顶部菜单栏选择“Build > Generate Key and CSR”。

HarmonyOS元服务_字符串_29

说明:如果本地已有对应的密钥,无需新生成密钥,可在“Generate Key and CSR”界面点击下方的“Skip”跳过密钥生成过程,直接使用已有密钥生成证书请求文件。

2)生成证书及密钥请求文件

HarmonyOS元服务_ide_30

3)在“Create Key Store”界面,填写密钥库信息后,点击“OK”。

  • Key store file:设置密钥库文件存储路径,并填写p12文件名。
  • Password:设置密钥库密码,必须由大写字母、小写字母、数字和特殊符号中的两种以上字符的组合,长度至少为8位。请记住该密码,后续签名配置需要使用。
  • Confirm password:再次输入密钥库密码。

HarmonyOS元服务_ide_31

4)在“Generate Key and CSR”界面继续填写密钥信息后,点击“Next”。

  • Alias:密钥的别名信息,用于标识密钥名称。请记住该别名,后续签名配置需要使用。
  • Password:密钥对应的密码,与密钥库密码保持一致,无需手动输入。
  • Validity:证书有效期,建议设置为25年及以上,覆盖应用/元服务的完整生命周期。
  • Certificate:输入证书基本信息,如组织、城市或地区、国家码等。

HarmonyOS元服务_ide_32

5)在“Generate Key and CSR”界面设置CSR文件存储路径和CSR文件名,点击“Finish”。

HarmonyOS元服务_自定义_33

CSR文件创建成功后,将在存储路径下获取生成密钥库文件(.p12)和证书请求文件(.csr)。

HarmonyOS元服务_ide_34

6.2.申请发布证书

发布证书是由AGC颁发的、为元服务配置签名信息的数字证书,可保障软件代码完整性和发布者身份真实性。证书格式为.cer,包含公钥、证书指纹等信息。

1)登录AppGallery Connect,选择“用户与访问”

HarmonyOS元服务_自定义_35

2)在左侧导航栏点击“证书管理”,进入“证书管理”页面,点击“新增证书”。

HarmonyOS元服务_字符串_36

3)在弹出的“新增证书”窗口填写要申请的证书信息,点击“提交”。

HarmonyOS元服务_自定义_37

说明如下:

参数

说明

证书名称

不超过100个字符。

证书类型

选择“发布证书”。

选取证书请求文件(CSR)

上传生成密钥和证书请求文件时获取的.csr文件。

4)证书申请成功后,“证书管理”页面展示证书名称等信息。点击“下载”,将生成的证书保存至本地,供后续签名使用。

HarmonyOS元服务_ide_38

6.3.申请发布Profile

发布Profile格式为.p7b,包含元服务的包名、数字证书信息、元服务允许申请的证书权限列表,以及允许元服务调试的设备列表(如果元服务类型为Release类型,则设备列表为空)等内容。每个元服务包中必须包含一个Profile文件。

6.1.前提条件

需要已在AGC创建元服务

6.2.操作步骤

说明:一个元服务最多可申请100个Profile文件。

1)登录AppGallery Connect,选择“我的项目”。

HarmonyOS元服务_字符串_39

2)找到您的项目,点击您创建的元服务。

HarmonyOS元服务_字符串_40

3)在左侧导航栏选择“HarmonyOS应用 > HAP Provision Profile管理”,进入“管理HAP Provision Profile”页面,点击右上角“添加”。
4)在弹出的“HarmonyAppProvision信息”窗口中添加Profile,完成后点击“提交”。

HarmonyOS元服务_自定义_41

说明如下:

参数

说明

名称

不超过100字符。

类型

选择“发布”。

选择证书

点击“选择”,选择申请发布证书中生成的证书。



 


说明


升级元服务时,您可以选择当前在架元服务的发布证书,以继承已上架元服务的数据与权限。



 



申请权限

  • 如您的元服务需要使用ACL的方式申请的权限:请提供APP ID发送到agconnect@huawei.com,申请开通“受限ACL权限(HarmonyOS API9及以上)”配置项后,在此配置项中申请权限。
  • 如您的元服务无需上述权限:选择“受限权限(HarmonyOS API9以下)”,根据需要配置权限。



 


说明


请确保您此处申请的权限与软件包内配置的权限一致。





6)发布Profile申请成功后,“管理HAP Provision Profile”页面展示Profile名称、类型等信息。点击“下载”,将生成的Profile保存至本地,供后续签名使用。

HarmonyOS元服务_自定义_42

6.3.配置签名信息

使用制作的私钥(.p12)文件、在AGC申请的证书文件和Profile(.p7b)文件,在DevEco Studio配置工程的签名信息,以构建携带发布签名信息的APP。

  1. 打开DevEco Studio,在顶部菜单栏选择“File > Project Structure”,进入“Project Structure”界面。
  2. 导航选择“Project”,点击“Signing Configs”页签,取消“Automatically generate signature”勾选项,然后配置工程的签名信息,完成后点击“OK”。

如下图所示:

HarmonyOS元服务_字符串_43

6.4.编译打包元服务

1)软件包规范

在正式打包元服务前,请确保您已了解元服务软件包规范。

规范

说明

APP包大小

不超过4GB。

HAP包大小

  • Stage模型
  • 元服务内任意类型的单个包大小都不超过2MB。
  • 单个包加上其采用dependency方式依赖的动态共享包(Harmony Shared Package, HSP),总大小不超过2MB。
  • 元服务内同一设备类型下所有包大小总和不得超过10MB。
  • FA模型:单个HAP包不得超过10MB。

HAP包数量

  • Stage模型:同一设备类型下仅允许有一个entry包,可以有0-N个feature包或HSP包。
  • FA模型:同一设备类型下仅允许有一个entry包,不能有任何feature包或HSP包。

HAP包类型

一个元服务包内所有HAP包都必须是免安装类型,即:

  • 如元服务为Stage模型,需保证AppScope/app.json5文件的“bundleType”字段值为“atomicService”。
  • 如元服务为FA模型,需保证每个HAP包的src/main/config.json文件中“installationFree”字段值均为“true”。

APP包名

  • 必须为以点号(.)分隔的字符串,且至少包含三段,每段中仅允许使用英文字母、数字、下划线(_),如“harmony_11.huawei.com ”。首段以英文字母开头,非首段以数字或英文字母开头,每一段以数字或者英文字母结尾,如“harmony99.huawei.11_com”。
    不允许多个点号(.)连续出现,如“harmony..huawei.com ”。
  • 长度为7~128个字符,且不可包含敏感词,不能将保留字符作为独立段呈现。以保留字符harmony为例,包名不能为harmony.huawei.com、com.harmony.huawei、com.huawei.harmony。保留字符包括如下:
  • oh
  • ohos
  • harmony
  • harmonyos
  • openharmony
  • system

2)操作步骤

打开DevEco Studio,在顶部菜单栏选择“Build > Build Hap(s)/APP(s) > Build APP(s)”。

HarmonyOS元服务_ide_44

等待编译构建。编译完成后,将在工程目录“build > outputs > default”目录下,获取可用于发布的元服务包。(我这里之前的项目打包超过10MB了)

HarmonyOS元服务_字符串_45

6.5.上架元服务

获取到元服务软件包后,您可将元服务提交至AGC申请上架。

1)配置服务信息

登录AppGallery Connect,选择“我的元服务”。

HarmonyOS元服务_ide_46

2)系统跳转到“HarmonyOS”页签,并自动筛选出您名下所有的元服务。点击待发布的元服务名称,进入“应用信息”页面。

HarmonyOS元服务_自定义_47

3)如果您尚未签署华为智慧分发平台合作协议,此时会弹出华为智慧分发平台合作协议对话框,您需按提示进行协议签署。否则,页面将跳转回AGC首页,您将无法继续发布元服务。

HarmonyOS元服务_自定义_48

4)在“应用信息”页面配置元服务的“基本信息”,具体要求如下表

HarmonyOS元服务_字符串_49

5)在“可本地化基础信息”区域,配置元服务发布后向用户呈现的信息,具体请参见配置可本地化基础信息

6)根据元服务提供的功能和内容,在“应用分类”区域选择元服务归属的类别,具体请参见设置应用分类

7)配置元服务的“开发者服务信息”,详情请参见配置开发者服务信息

8)配置完成后点击“下一步”,在弹窗中点击“确认”,进入“准备提交”页面,开始设置版本信息。

6.6.配置版本信息

1)配置元服务的发布国家或地区,目前仅支持发布到中国大陆地区。

HarmonyOS元服务_ide_50

2)设置是否为开放式测试版本。

HarmonyOS元服务_字符串_51

3)在“软件版本”下点击“软件包管理”后,在弹窗中点击“上传”。

HarmonyOS元服务_自定义_52

4)在“上传包”窗口中点击“+”,上传元服务的软件包。上传成功后,您可在“软件包管理”窗口中执行如下操作:

  • 点击文件名称下,查看软件包详细信息,如包名、SHA256、软件大小。请确认软件包信息与您需要发布的版本一致。
  • 点击“操作”栏的“调试”或“测试”链接,对软件包分别进行云调试或云测试,可及早发现并解决问题,提高服务审核通过率。具体操作请参考云测试云调试操作指南。
  • 点击“操作”栏的“删除”,可删除不需要的软件包。仅允许删除与草稿态版本关联的软件包。

HarmonyOS元服务_ide_53

5)选择应用内资费类型,即用户在使用元服务过程中的付费类型,如因使用道具、开通会员等进行的付费。支持多选。

HarmonyOS元服务_ide_54

6)在“内容分级”区域,点击“设置”,按实际情况填写调查问卷,填写完成将获取当前元服务的年龄分级结果。具体请参见设置内容分级

7)如果涉及以下场景,请填写元服务的“应用隐私说明”,否则直接进行下一步。

  • 如检测到软件包涉及获取敏感隐私权限,您需为每个敏感权限项填写相应的权限说明,每条权限说明最大支持500字符

HarmonyOS元服务_自定义_55

 如检测到软件包涉及获取受限权限,您需为每个受限权限项填写相应的权限说明,并上传视频说明使用场景。

  • 权限说明:每条权限说明最大支持500字符。
  • 使用场景视频:支持MOV或MP4格式,大小500MB以内。仅支持上传一个视频。

HarmonyOS元服务_字符串_56

8)在“隐私声明”区域提供隐私声明链接。

  • 隐私政策网址:该网站将供用户访问,从而了解服务是如何处理敏感的用户数据和设备数据。
  • 隐私权利:提供用户实施其权利的相关网站,例如:删除、修改、导出个人数据的入口。

10)在“版权信息”区域上传元服务上架所需的资质材料。

  • 承诺函:必选。点击“承诺函”链接,仔细阅读合规承诺书后,勾选“我已阅读并同意《承诺函》”。
  • 电子版权证书:可选。仅支持PDF格式,大小不超过5MB。请勿上传非PDF格式的文件或是将非PDF格式的文件的扩展名改为PDF。
  • 应用版权证书或代理证书:可选。支持JPG、PNG、BMP格式的资质文件。默认展示五个图片上传框,您可点击虚线框内的“+”号继续添加。最多添加10张图片,每张图片不超过15MB。

11)如选择分发到路由器设备,您还可设置是否勾选“必须联网才能使用”。勾选以后,用户在无网络环境下将无法正常使用该元服务的核心功能,如即时通信、在线直播、网游等。

HarmonyOS元服务_字符串_57

12)如选择分发到路由器设备,您还可设置是否勾选“必须联网才能使用”。勾选以后,用户在无网络环境下将无法正常使用该元服务的核心功能,如即时通信、在线直播、网游等。

HarmonyOS元服务_自定义_58

13)配置上架时间。您可以选择“审核通过立即上架”,也可以选择“指定时间”。

HarmonyOS元服务_ide_59

14)点击“提交审核”,在弹出的窗口中确认版本号无误后,点击“确认”。提交成功后,在“版本信息”页面“状态”中可查看审核状态。

HarmonyOS元服务_字符串_60

15)对于配置为“指定时间”上架的元服务,审核通过之后、指定上架时间到达之前,您可随时手动发布版本上线:在版本信息页面右上角点击“手动发布”,在确认提示框点击“确认”即可。手动发布一般在几分钟内生效。

HarmonyOS元服务_字符串_61

16)分发元服务

元服务上架审核通过后,您会收到邮件通知,但此时还无法搜索到上架的元服务,需要华为进行配置后,才能让元服务露出。当前,元服务分发的主要渠道有:

  • 应用市场:具备搜索能力,在搜索结果的“服务”页签露出,一些精选的元服务将在专栏中推荐露出,后续是元服务主要分发渠道。
  • 负一屏:具备搜索能力,但后续仅展示精品元服务。
  • 服务中心:具备搜索能力,但会逐步日落,融合至负一屏。HarmonyOS 3.0版本如果无法通过对角线滑动打开服务中心,请参见手机没有服务中心怎么处理。HarmonyOS 4.0版本服务中心下线。

以上渠道配置完成不会有通知信息,一般会在1~3个工作日内完成,请耐心等待。如果需求比较紧急,可以通过在线提交工单与客服联系。提交工单后,可登录华为开发者联盟官网,鼠标置于右上角头像处,在下拉框内点击“我的客服”查看工单处理进展。