【HarmonyOS】元服务入门详解 (一)

【HarmonyOS】元服务入门详解 (一)

一、前言

首先元服务并非是小程序小游戏,也不是之前的快应用。元服务是使用ArkTS语言开发。共同点是与他们相同都是免安装的小安装包应用。

它与其他轻应用的共同点是免安装特性,但在技术实现和生态定位上有本质区别:

1、开发语言 :采用ArkTS(鸿蒙生态主推的声明式语言) 2、包体特性 :严格限制安装包大小(通常≤10MB),实现秒开体验 3、API体系 :使用独立的元服务API集,而非传统应用的系统API 4、分发方式 :通过"服务找人"模式,依托系统级卡片、小艺搜索等入口触达用户 5、体验优势:无需安装即可使用核心功能,支持跨设备流转

元服务因为限制安装包大小,并且使用单独的元服务API集,非应用开发直接使用系统API。正因为以上两点,做到了免安装的效果。元服务作为免安装的轻量服务形态,在用户触达和体验上有显著优势。

并且在开发模式上与应用也不同,需要先在AGC平台创建后,才能在IDE进行项目创建进行包名映射绑定。

元服务通过"服务找人"这个概念,使用系统级别的卡片入门,小艺搜索入门,提供系统级别的"小程序"体验。让用户可以在手机上,不用安装App,就能使用功能。

以本文将开发的"舒尔特方格"元服务为例,用户可以通过桌面卡片直接启动游戏,或通过小艺搜索"舒尔特方格"快速调用,无需经历传统应用的下载安装流程。

二、元服务开发前置步骤

1、AGC平台创建元服务项目

元服务开发需先在华为应用市场开发者平台(AGC)完成项目注册,步骤如下:

  1. 登录AGC平台,进入"我的项目"
  2. 点击"新增项目",选择"元服务"类型
  3. 填写基本信息(应用名称、包名等),包名需牢记(如"com.atomicservice.691757860316xxxxx", 现在元服务的包名都是固定格式,最后都是appid。所以一定要先在AGC上创建项目)

2、 IDE创建元服务项目

使用DevEco Studio创建元服务项目:

  1. 新建项目时选择"元服务应用"模板
  2. 输入项目名称(如ShulteTable),选择保存路径
  3. 填写AGC注册的包名,确保与平台一致
  4. 选择设备类型(建议先选"手机"),点击"完成"

项目结构说明:

less 复制代码
ShulteTable/
├─ entry/                  // 主模块
│  ├─ src/main/ets/        // ArkTS代码
│  │  ├─ entryability/     // 入口能力
│  │  ├─ pages/            // 页面
│  │  └─ widget/           // 卡片
└─ agconnect-services.json // AGC配置文件

3. 元服务图标设计

元服务需准备两类图标: (1)应用图标 :1024x1024px,用于服务入口。可使用工具进行裁剪: 在entry文件夹,右键New,选择Image Asset:

(2)卡片图标:根据卡片尺寸设计(如2x2卡片为128x128px)

图标需符合鸿蒙设计规范,建议使用圆角设计,放置在entry/src/main/resources/base/media目录下。

三、元服务UI开发

以舒尔特方格游戏为例,讲解元服务UI开发核心要点。 基于ArkTS的UI开发(页面路由、组件布局、状态管理)

1. 页面路由配置

main_pages.json中配置页面路由:

json 复制代码
{
  "src": [
    "pages/HomePage",
    "pages/GamePage",
    "pages/UserAgreement"
  ]
}

2. 首页实现(HomePage.ets)

首页包含开始游戏按钮、近期分数和操作指南:

typescript 复制代码
@Entry
@Component
struct HomePage {
  @State recentScores: ScoreItem[] = [];
  
  aboutToAppear() {
    this.loadScores(); // 加载历史记录
  }
  
  loadScores() {
    // 从本地存储获取数据
    const scores = AppStorage.Get('scores') || [];
    this.recentScores = scores.slice(0, 5);
  }
  
  build() {
    Column() {
      // 标题
      Text('舒尔特方格')
        .fontSize(32)
        .fontWeight(FontWeight.Bold)
        .margin(20)
      
      // 开始按钮
      Button('开始游戏')
        .width('80%')
        .height(50)
        .backgroundColor('#007DFF')
        .fontColor('#FFFFFF')
        .onClick(() => router.pushUrl({ url: 'pages/GamePage' }))
        .margin(10)
      
      // 近期分数
      Column() {
        Text('近期成绩')
          .fontSize(18)
          .margin(10)
        
        List() {
          ForEach(this.recentScores, (item) => {
            ListItem() {
              Row() {
                Text(`${item.size}x${item.size}`)
                  .width(60)
                Text(item.time)
                  .flexGrow(1)
                Text(item.date)
                  .width(80)
              }
              .padding(10)
            }
          })
        }
      }
      .backgroundColor('#FFFFFF')
      .borderRadius(10)
      .padding(5)
      .margin(10)
      .width('90%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

interface ScoreItem {
  size: number;
  time: string;
  date: string;
}

3. 游戏页面实现(GamePage.ets)

核心游戏逻辑包含网格生成、计时和点击判断:

typescript 复制代码
@Entry
@Component
struct GamePage {
  @State gridSize: number = 5;
  @State numbers: number[] = [];
  @State current: number = 1;
  @State timer: string = '00:00';
  @State timerId: number = 0;
  
  aboutToAppear() {
    this.initGrid();
  }
  
  initGrid() {
    // 生成1~n²的随机序列
    const total = this.gridSize * this.gridSize;
    this.numbers = Array.from({ length: total }, (_, i) => i + 1)
      .sort(() => Math.random() - 0.5);
    this.current = 1;
  }
  
  startTimer() {
    const startTime = Date.now();
    this.timerId = setInterval(() => {
      const elapsed = Math.floor((Date.now() - startTime) / 1000);
      this.timer = `${Math.floor(elapsed / 60).toString().padStart(2, '0')}:${
        (elapsed % 60).toString().padStart(2, '0')
      }`;
    }, 1000);
  }
  
  build() {
    Column() {
      // 计时器
      Text(`用时: ${this.timer}`)
        .fontSize(20)
        .margin(10)
      
      // 网格选择
      Scroll({ direction: Axis.Horizontal }) {
        Row() {
          ForEach([3, 4, 5, 6, 7], (size) => {
            Button(`${size}x${size}`)
              .onClick(() => this.gridSize = size)
              .margin(5)
              .backgroundColor(this.gridSize === size ? '#007DFF' : '#F5F5F5')
          })
        }
        .padding(5)
      }
      
      // 游戏网格
      Grid() {
        ForEach(this.numbers, (num) => {
          GridItem() {
            Button(num.toString())
              .width('100%')
              .height('100%')
              .backgroundColor(num === this.current ? '#007DFF' : 
                num < this.current ? '#90EE90' : '#EEEEEE')
              .onClick(() => {
                if (num === this.current) {
                  if (this.current === this.gridSize * this.gridSize) {
                    // 游戏结束
                    clearInterval(this.timerId);
                    this.saveScore(); // 保存成绩
                  } else {
                    this.current++;
                  }
                }
              })
          }
        })
      }
      .columnsTemplate(Array(this.gridSize).fill('1fr').join(' '))
      .rowsTemplate(Array(this.gridSize).fill('1fr').join(' '))
      .width('90%')
      .height('60%')
    }
    .width('100%')
    .padding(10)
  }
}

四、元服务卡片开发

元服务卡片可直接在桌面显示关键信息,支持快速交互。 桌面卡片的创建与数据交互。

1. 卡片配置(widget.json)

json 复制代码
{
  "forms": [
    {
      "name": "ShulteWidget",
      "description": "舒尔特方格快捷卡片",
      "src": "./ShulteWidget.ets",
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": true,
      "updateEnabled": true,
      "scheduledUpdateTime": "00:00",
      "updateDuration": 1
    }
  ]
}

2. 卡片实现(ShulteWidget.ets)

typescript 复制代码
@Entry
@Component
struct ShulteWidget {
  private formProvider: FormProvider = new FormProvider();
  
  build() {
    Column() {
      Text('舒尔特方格')
        .fontSize(16)
        .margin(5)
      
      Button('快速开始')
        .width('80%')
        .height(30)
        .fontSize(14)
        .backgroundColor('#007DFF')
        .onClick(() => {
          // 打开元服务
          this.formProvider.startAbility({
            bundleName: 'com.example.shultetable',
            abilityName: 'EntryAbility'
          });
        })
      
      Text('最佳记录: 01:25')
        .fontSize(12)
        .margin(5)
        .fontColor('#666666')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
    .padding(10)
  }
}

3. 卡片数据更新

通过FormExtensionAbility实现卡片数据刷新:

typescript 复制代码
export default class ShulteFormAbility extends FormExtensionAbility {
  onUpdate(formId: string) {
    // 获取最新成绩
    const bestScore = AppStorage.Get('bestScore') || '00:00';
    
    // 更新卡片数据
    this.updateForm(formId, {
      'bestScore': bestScore
    });
  }
}

五、源码示例

typescript 复制代码
// pages/HomePage.ets
import router from '@ohos.router';
import promptAction from '@ohos.promptAction';

@Entry
@Component
struct HomePage {
  @State recentScores: ScoreItem[] = [];
  @State isLoading: boolean = true;

  aboutToAppear() {
    this.loadRecentScores();
  }

  // 加载最近5次游戏记录
  loadRecentScores() {
    // 模拟从本地存储加载数据
    setTimeout(() => {
      // 示例数据,实际应从AppStorage获取
      this.recentScores = [
        { id: 1, time: '01:25', date: '2025-07-14', gridSize: 5 },
        { id: 2, time: '01:40', date: '2025-07-13', gridSize: 5 },
        { id: 3, time: '01:55', date: '2025-07-12', gridSize: 5 },
        { id: 4, time: '02:10', date: '2025-07-10', gridSize: 5 },
        { id: 5, time: '02:30', date: '2025-07-09', gridSize: 5 },
      ];
      this.isLoading = false;
    }, 500);
  }

  // 格式化日期显示
  formatDate(dateStr: string): string {
    const date = new Date(dateStr);
    return `${date.getMonth() + 1}/${date.getDate()}`;
  }

  // 导航到游戏页面
  navigateToGame() {
    router.pushUrl({
      url: 'pages/GamePage'
    });
  }

  @Builder TitleView(){
    // 顶部导航栏
    Row() {
      Button() {
        // Image($r('app.media.back_arrow')) // 需要准备返回箭头图标
        //   .width(24)
        //   .height(24)
      }
      .onClick(() => router.back())
      .margin({ left: 15 })

      Text('专注力训练')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#007DFF')
        .margin({ left: 20 })
    }
    .width('100%')
    .height(50)
    .backgroundColor('#FFFFFF')
  }

  /**
   * 用户头像view
   */
  @Builder UserInfoView(){
    Column(){
      // 用户头像
      Image($r("app.media.icon"))
        .width(px2vp(200))
        .height(px2vp(200))

      // 昵称

      // 设置按钮
      Button("设置")
        .onClick(()=>{
          router.pushUrl({
            url: 'pages/AuthPage'
          })
        })
    }
    .margin({ bottom: 30, top: 20 })
  }

  build() {

    Column(){
      this.TitleView()

      Column() {

        this.UserInfoView()


        // 开始游戏按钮
        Button('开始游戏')
          .width('80%')
          .height(50)
          .backgroundColor('#007DFF')
          .fontColor('#FFFFFF')
          .fontSize(18)
          .onClick(() => this.navigateToGame())
          .margin({ bottom: 30, top: 20 })
          .borderRadius(25)
          .hoverEffect(HoverEffect.Auto)

        // 近期分数卡片
        Column() {
          Text('近期分数')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .margin({ left: 15, top: 10, bottom: 10 })
            .width('100%')

          if (this.isLoading) {
            LoadingProgress()
              .color('#007DFF')
              .width(50)
              .height(50)
              .margin({ top: 20, bottom: 20 })
          } else if (this.recentScores.length === 0) {
            Text('暂无游戏记录')
              .fontSize(16)
              .fontColor('#999999')
              .margin({ top: 20, bottom: 20 })
          } else {
            List() {
              ForEach(this.recentScores, (score: ScoreItem) => {
                ListItem() {
                  Row() {
                    Text(`${score.gridSize}x${score.gridSize}`)
                      .fontSize(16)
                      .width(70)
                      .textAlign(TextAlign.Center)
                      .fontColor('#007DFF')
                      .backgroundColor('#E6F4FF')
                      .margin({ right: 10 })
                      .padding(5)
                      .borderRadius(8)

                    Column() {
                      Text(score.time)
                        .fontSize(18)
                        .fontWeight(FontWeight.Bold)
                        .width('100%')
                        .textAlign(TextAlign.Start)

                      Text(this.formatDate(score.date))
                        .fontSize(12)
                        .fontColor('#999999')
                        .width('100%')
                        .textAlign(TextAlign.Start)
                    }
                    .width('100%')
                  }
                  .width('100%')
                  .height(60)
                  .padding({ left: 10, right: 10 })
                }
              })
            }
            .width('100%')
            .height(220)
            .margin({ bottom: 20 })
          }
        }
        .width('90%')
        .backgroundColor('#FFFFFF')
        .borderRadius(15)
        .margin({ bottom: 20 })

        // 游戏操作指南卡片
        Column() {
          Text('游戏操作指南')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .margin({ left: 15, top: 10, bottom: 10 })
            .width('100%')

          Column() {
            GuideItem({
              index: 1,
              content: '选择网格大小'
            })
            GuideItem({
              index: 2,
              content: '点击"开始游戏"按钮'
            })
            GuideItem({
              index: 3,
              content: '按照数字顺序点击网格中的数字'
            })
            GuideItem({
              index: 4,
              content: '完成所有数字点击后,游戏结束并显示用时'
            })
            GuideItem({
              index: 5,
              content: '点击"重置游戏"可重新开始'
            })
          }
          .width('100%')
          .padding(15)
        }
        .width('90%')
        .backgroundColor('#FFFFFF')
        .borderRadius(15)
        .margin({ bottom: 30 })
      }
      .width('100%')
      .height('100%')
      .padding({  left: 15, right: 15 })
      .backgroundColor('#F8F9FA')
    }
    .height(px2vp(2000))
    .width("100%")

    // Scroll(){
    //
    // }
    // .width("100%")
    // .height("100%")
    // .scrollable(ScrollDirection.Vertical)
  }
}

// 指南项组件
@Component
struct GuideItem {
  index: number = 0;
  content: string = '';

  build() {
    Row() {
      Text(`${this.index}`)
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .width(28)
        .height(28)
        .textAlign(TextAlign.Center)
        .backgroundColor('#007DFF')
        .fontColor('#FFFFFF')
        .borderRadius(14)
        .margin({ right: 10 })

      Text(this.content)
        .fontSize(16)
        .width('90%')
    }
    .width('100%')
    .margin({ bottom: 15 })
  }
}

// 分数项接口
interface ScoreItem {
  id: number;
  time: string;
  date: string;
  gridSize: number;
}
typescript 复制代码
import promptAction from '@ohos.promptAction';

@Entry
@Component
struct GamePage {

  // 网格数
  @State gridSize: number = 2; // 默认5x5网格

  @State gridData: number[] = [];
  @State currentNumber: number = 1;
  @State isGameStarted: boolean = false;
  @State isGameFinished: boolean = false;
  @State timer: string = '00:00';
  @State startTime: number = 0;
  @State timerId: number = 0;
  @State bestTime: string = '00:00';

  aboutToAppear() {
    this.initGrid();

    const savedBestTime: string = AppStorage.get('bestTime') ?? "00:00";
    if (savedBestTime) {
      this.bestTime = savedBestTime;
    }
  }

  /**
   * 初始化网格数据
   */
  initGrid() {
    const totalCells = this.gridSize * this.gridSize;
    this.gridData = [];
    for (let i = 1; i <= totalCells; i++) {
      this.gridData.push(i);
    }
    // 打乱顺序
    this.gridData.sort(() => Math.random() - 0.5);
    this.currentNumber = 1;
    this.isGameFinished = false;

    console.log("wppDebug", " list: " + JSON.stringify(this.gridData));
  }

  // 开始游戏
  startGame() {
    this.isGameStarted = true;
    this.startTime = Date.now();
    this.timerId = setInterval(() => {
      const elapsedTime = Date.now() - this.startTime;
      const seconds = Math.floor(elapsedTime / 1000) % 60;
      const minutes = Math.floor(elapsedTime / 60000);
      this.timer = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
    }, 1000);
  }

  // 处理格子点击
  handleCellClick(number: number) {
    if (!this.isGameStarted || this.isGameFinished) {
      return;
    }

    if (number === this.currentNumber) {
      if (number === this.gridSize * this.gridSize) {
        // 游戏完成
        this.isGameFinished = true;
        clearInterval(this.timerId);
        this.checkBestTime();
        promptAction.showToast({ message: '恭喜,你完成了游戏!' });
      } else {
        this.currentNumber++;
      }
    }
  }

  // 检查是否是最佳时间
  checkBestTime() {
    const currentTime = this.timer;
    if (this.bestTime === '00:00' || this.compareTime(currentTime, this.bestTime)) {
      this.bestTime = currentTime;

      AppStorage.set('bestTime', currentTime);
    }
  }


  compareTime(time1: string, time2: string): number {
      // 假设日期为同一天(如2000-01-01)
      const date1 = new Date(`2000-01-01T${time1}:00`);
      const date2 = new Date(`2000-01-01T${time2}:00`);

      // 比较时间戳
      return date1.getTime() - date2.getTime();
    }


  // 重置游戏
  resetGame() {
    clearInterval(this.timerId);
    this.initGrid();
    this.isGameStarted = false;
    this.timer = '00:00';
  }

  // 改变网格大小
  changeGridSize(size: number) {
    if (this.isGameStarted && !this.isGameFinished) {
      promptAction.showToast({ message: '请先完成当前游戏' });
      return;
    }
    this.gridSize = size;
    this.initGrid();
  }

  // 按钮格子数
  @State buttonList: Array<string> = ['2x2', '3x3', '4x4', '5x5', '6x6', '7x7']; //, '8x8', '9x9'

  @Builder ItemButtonView(){
    Button('5x5')
      .onClick(() => this.changeGridSize(5))
      .margin(5)
      .enabled(!this.isGameStarted || this.isGameFinished)
      .backgroundColor(this.gridSize === 5 ? '#007DFF' : '#F5F5F5')
      .fontColor(this.gridSize === 5 ? '#FFFFFF' : '#000000')
  }

  /**
   * 格子列表
   */
  @Builder ButtonListView(){
    Scroll(){
      Row() {
        ForEach(this.buttonList, (item: string, index: number) => {
          Button(item)
            .onClick(() => this.changeGridSize(index + 2))
            .margin(5)
            .enabled(!this.isGameStarted || this.isGameFinished)
            .backgroundColor(this.gridSize === (index + 2) ? '#007DFF' : '#F5F5F5')
            .fontColor(this.gridSize === (index + 2) ? '#FFFFFF' : '#000000')
            .height(px2vp(200))

        }, (item: string) => item)
      }
      .width(px2vp(1800))
    }
    .margin(5)
    .margin({ bottom: 20 })
    .width("100%")
    .scrollable(ScrollDirection.Horizontal)
    .height(px2vp(210))
  }

  build() {
    Column() {
      // 标题
      Text('专注力训练')
        .fontSize(30)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 20, bottom: 10 })

      // 计时器和最佳时间
      Row() {
        Text(`当前时间: ${this.timer}`)
          .fontSize(18)
          .margin({ right: 20 })
        Text(`最佳时间: ${this.bestTime}`)
          .fontSize(18)
      }
      .margin({ bottom: 20 })

      // 网格大小选择
      this.ButtonListView()

      // 开始/重置按钮
      Button(this.isGameStarted ? '重置游戏' : '开始游戏')
        .onClick(() => {
          if (this.isGameStarted) {
            this.resetGame();
          } else {
            this.startGame();
          }
        })
        .width('50%')
        .margin({ bottom: 20 })

      // 游戏网格
      Grid() {
        ForEach(this.gridData, (number: number) => {
          GridItem() {
            Button(`${number}`)
              .width('100%')
              .height('100%')
              .backgroundColor(
                this.isGameFinished
                  ? '#90EE90'
                  : number < this.currentNumber
                  ? '#90EE90'
                  : number === this.currentNumber
                    ? '#007DFF'
                    : '#F5F5F5'
              )
              .fontColor(
                number === this.currentNumber || number < this.currentNumber
                  ? '#FFFFFF'
                  : '#000000'
              )
              .onClick(() => this.handleCellClick(number))
              .enabled(this.isGameStarted && !this.isGameFinished)
          }
        })
      }
      .columnsTemplate(new Array(this.gridSize).fill('1fr').join(' '))
      .rowsTemplate(new Array(this.gridSize).fill('1fr').join(' '))
      .columnsGap(1)
      .rowsGap(1)
      .width('95%')
      .height('60%')
      .margin({ bottom: 20 })
    }
    .width('100%')
    .height('100%')
    .padding(15)
  }
}    
相关推荐
coder_pig8 小时前
跟🤡杰哥一起学Flutter (三十五、玩转Flutter滑动机制📱)
android·flutter·harmonyos
睿麒12 小时前
鸿蒙app 开发中的Record<string,string>的用法和含义
华为·harmonyos
cainiao08060515 小时前
华为HarmonyOS 5.0深度解析:跨设备算力池技术白皮书(2025全场景智慧中枢)
华为·harmonyos
万少15 小时前
04-自然壁纸实战教程-搭建基本工程
前端·harmonyos·客户端
yrjw17 小时前
FileSaver是一个为HarmonyOS ArkTS应用设计的开源库,提供便捷的文件保存功能。主要特性包括:支持将图片保存至系统相册和应用沙盒存储,支持多种
harmonyos
xo1988201118 小时前
鸿蒙选择本地视频文件,并获取首帧预览图
华为·harmonyos
在人间耕耘20 小时前
HarmonyOS组件/模板集成创新活动-开发者工具箱
华为·harmonyos
二流小码农20 小时前
鸿蒙开发:一键更新,让应用无需提交应用市场即可下载安装
android·ios·harmonyos
xyccstudio21 小时前
鸿蒙选择本地视频文件,并获取首帧预览图
harmonyos