本文基于 HarmonyOS 7(API 26)的《基于ArkTS脚本的应用Skill开发指导》与官方新能力一览整理。文中代码是为说明问题自写的完整示例,不是官方示例的搬运;API 名称、标签与版本号等事实性信息均标注官方出处;涉及真机表现的部分已明确标注,未做任何实测数据编造。


HarmonyOS 7 应用 Skill 化实战

引子:图标是一扇门,但门不会自己开

V哥认识一个做本地餐饮应用的朋友,功能做得很全:扫码点单、排队取餐、会员储值,一个不落。有天V哥问他:"用户找你们’排队取餐’这个功能,要点几步?"他掰指头数:解锁、找图标、进首页、过广告位、点 tabBar、再进二级页——五步。

而用户的表达其实只有四个字:“帮我取餐。”

这事在 HarmonyOS 7(API 26)之前无解,应用只能在图标里等用户来点。7.0 把这个前提改了:应用内业务能力可以以 Skill 的形式开放给系统智能体调用,用户说一句"帮我取餐",系统自己找到你的能力、自己调(官方新能力一览)。V哥的原话概括就是标题那半句——应用从"被打开",变成了"被调用"

别小看这一个词的差别。被打开,流量入口是图标和应用市场;被调用,流量入口变成了系统级智能分发。这期V哥把 Skill 化的完整路径拆开讲:机制、目录、契约、代码,最后给一份上线前自检清单。


一、先想明白:Skill 到底开放了什么

先看官方的关键表述(基于ArkTS脚本的应用Skill开发指导):

从 API 版本 26.0.0 开始,Ability Kit 支持将应用内业务能力以 Skill 形式开放给系统智能体调用。Skill 提供一种声明式的能力外化机制……运行时,系统智能体依据描述文件完成"意图—能力"的语义匹配,并将结果转化为面向用户的自然语言回复。

V哥从这段话里读出三个重点,比 API 本身重要:

重点意思对开发者的直接影响
声明式你不写"怎么被调用",只声明"能做什么、什么时候调、参数什么样"主体工作量在写契约,不在写代码
薄封装不改造既有业务实现,入口脚本只是"参数适配器"老业务一行不动,加个壳就能上
语义匹配系统靠你的描述文件猜用户意图,匹配上了才调你契约写得糙 = 永远匹配不上

第三条是很多人会漏的。图标时代,你的应用名称写错顶多搜索排名靠后;Skill 时代,描述文件就是你在意图分发池里的全部简历——系统智能体不进你的代码,只看这份简历决定调不调你。

还有一个硬边界先说在前:这套机制仅支持 Stage 模型,FA 模型不可用(官方原文)。存量 FA 模型工程得先迁移,这不是 Skill 的坑,是前置条件。


二、Skill 的解剖图:一个目录、两份文件、一处注册

官方把一个 Skill 的物理形态规定得很死,V哥画成一张链路图:

Skill 化的意图分发链路

落到工程里,就是在模块(entry)下建一个 skills/ 目录,里面每个 Skill 一个文件夹(官方开发指导):

entry/
├── skills/                              <- 固定值:本模块所有 Skill 的根目录
│   └── vge-org-milktea-assistant/       <- Skill 名,须与 SKILL.md 的 name 一致
│       ├── scripts/
│       │   └── MilkteaSkill.ets         <- 入口脚本(薄适配层)
│       └── SKILL.md                     <- 描述文件(意图匹配的唯一依据)
└── src/main/
    ├── ets/service/MilkTeaService.ets   <- 应用既有业务(被脚本调用,零改动)
    └── module.json5                     <- 在这里注册 skillProfiles

三处名字必须完全一致:目录名 = SKILL.md 的 name = module.json5 里 skillProfiles[].name。官方还专门提醒,为防命名冲突,Skill 名推荐用公司或组织名做前缀——V哥示例里的 vge-org- 就是这个用途。

注册写进 module.json5skillProfiles 标签(官方新增标签):

{
  "module": {
    "skillProfiles": [
      {
        "name": "vge-org-milktea-assistant",   // 与目录名、SKILL.md 的 name 三处一致
        "abilityName": "EntryAbility",          // Skill 绑定到哪个 Ability 的运行上下文
        "srcEntries": [                         // 入口脚本路径
          "../../skills/vge-org-milktea-assistant/scripts/MilkteaSkill.ets"
        ],
        "version": "1.0.0"
      }
    ],
    "requestPermissions": [
      { "name": "ohos.permission.INTERNET" }    // Skill 运行需要的权限照常声明
    ]
  }
}

注意 abilityName 这一项:Skill 不是悬浮的,它绑定在指定 Ability 的运行上下文里执行。权限也是老规矩——Skill 要联网就声明联网,该最小化就最小化。


三、动手:把"点奶茶"递给小艺

V哥的示例应用是奶茶点单,既有业务里已经有 MilkTeaService(下单、查单)。现在要开放两个能力给系统智能体:点单orderMilkTea)和查取餐进度queryQueue)。

入口脚本:一个只做"翻译"的类

入口脚本以 export default 导出一个类,类里每个 public async 方法对应 SKILL.md 声明的一项能力,方法名必须与契约里的 functionName 严格一致,第一个参数固定是 ArkTSScriptInfo(官方约定)。V哥的写法:

// MilkteaSkill.ets —— 薄适配层:只翻译参数,不装业务
import { scriptManager } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
// 应用既有业务模块,Skill 化前后零改动
import { MilkTeaService, OrderResult } from '../../../src/main/ets/service/MilkTeaService';

export default class MilkteaSkill {

  // 点单:品名必填,糖度、冰量可选(与 SKILL.md 的 args Schema 对应)
  public async orderMilkTea(info: scriptManager.ArkTSScriptInfo, ...argv: string[]): Promise<void> {
    const drink: string = argv.length > 0 ? argv[0].trim() : '';
    const sugar: string = argv.length > 1 ? argv[1].trim() : '五分甜';
    const ice: string = argv.length > 2 ? argv[2].trim() : '少冰';

    // ① 前置校验:品名为空直接走参数错误分支,不进业务
    if (drink.length === 0) {
      await this.report(info, {
        code: -1,
        result: {
          type: 'result', status: 'failed',
          errCode: 'ERR_INVALID_PARAMS',
          errMsg: 'drink name is empty',
          suggestion: 'V哥没听清品名,你想喝哪杯?'
        }
      });
      return;
    }

    // ② 调既有业务:入口脚本不写业务逻辑
    try {
      const order: OrderResult = MilkTeaService.createOrder(drink, sugar, ice);
      // ③ 按契约把业务结果装进回包
      await this.report(info, {
        code: 0,
        result: {
          type: 'result', status: 'success',
          data: {
            orderNo: order.orderNo,
            pickupCode: order.pickupCode,
            etaMinutes: order.etaMinutes
          }
        }
      });
    } catch (e) {
      const err = e as BusinessError;
      // 业务异常统一映射到内部错误分支
      await this.report(info, {
        code: -1,
        result: {
          type: 'result', status: 'failed',
          errCode: 'ERR_INTERNAL',
          errMsg: err.message,
          suggestion: '下单失败了,稍后再试试'
        }
      });
    }
  }

  // 查询取餐进度(契约结构同理,略)

  // ④ 唯一回包出口:只管上报,不参与结果构造
  private async report(info: scriptManager.ArkTSScriptInfo,
                       result: scriptManager.ExecuteResult): Promise<void> {
    try {
      await scriptManager.completeArkTSScriptInApp(info.context, info.requestCode, result);
    } catch (e) {
      const err = e as BusinessError;
      console.error(`completeArkTSScriptInApp failed, code: ${err.code}, message: ${err.message}`);
    }
  }
}

四个环节对应官方规定的四步:解析校验入参 → 调既有业务 → 按契约构造 ExecuteResult → 经 completeArkTSScriptInApp 回传。核心接口就三个:ExecuteResult(脚本执行结果)、ArkTSScriptInfo(系统传进来的脚本上下文)、completeArkTSScriptInApp(上报结果),都挂在 @ohos.app.ability.scriptManager 下(接口参考)。

V哥最想强调的是那句注释——唯一的回包出口。官方示例把 completeArkTSScriptInApp 收进一个 report 私有方法,V哥照做了,理由很实际:回包点一散,某个分支忘了报、报错了没人知道,系统智能体只会觉得"这个 Skill 失联了",下次直接不调你。

SKILL.md:系统智能体的"招聘简历"

SKILL.md 分三段:元数据、触发场景、能力契约(官方开发指导 · 第 4 节)。V哥按自己的示例写了一份:

---
name: vge-org-milktea-assistant
description: 提供奶茶点单与取餐进度查询能力,响应"点一杯奶茶"、"帮我点单"、
  "还有多久能取餐"等指令
---

## 触发场景

当用户明确表达**点奶茶**或**查询取餐进度**时调用。典型话术:
- "点一杯芝士葡萄,五分甜少冰"
- "帮我点单,要三分甜的珍珠奶茶"
- "我的奶茶做到哪了"、"还要等多久"

不调用的情况:
- 用户说"附近的奶茶店有哪些"——意图是搜索店铺,本 Skill 只管下单和进度。
- 用户说"这杯奶茶多少钱"——意图是查价格,应走商品查询能力。
- 用户说"取消订单"——当前版本不支持,避免误触发。

### 场景1:点单(orderMilkTea)

执行参数:
exec-cli(command: ohos-arkTSScript --skillName 'vge-org-milktea-assistant'
  --scriptPath 'scripts/MilkteaSkill.ets' --functionName 'orderMilkTea'
  --args '{ "arg1": "芝士葡萄", "arg2": "五分甜", "arg3": "少冰" }')

(JSON Schema:arg1 品名为必填,arg2 糖度、arg3 冰量为可选枚举,此处略)

执行返回值:
(先列成功/参数非法/门店未命中/内部错误四组示例,再用 oneOf 收口,此处略)

三段各有各的命门:

  • 元数据name 三处一致不赘述;description 是系统做初次筛选的依据——写"奶茶相关服务"这种糊涂话,筛选这关就过不去。
  • 触发场景:官方明确建议除了列典型话术,还要写**“不调用的情况”**来划清能力边界。V哥的理解:不写边界,相邻意图全算你头上,误触发一多,用户和小艺对你的信任一起掉。
  • 能力契约:每项能力一个 exec-cli 调用示例加 JSON Schema。anyOf(二选一必填)、oneOf(多种回包分支互斥)都是标准 JSON Schema 玩法,跟后端同学对过接口规范的会有熟悉感。

四、契约质量 = 分发质量:V哥的三条判断

写完代码只是及格线,Skill 化真正的功夫在契约上。V哥给三条判断:

① 典型话术要覆盖"同义改写",不是罗列功能。 “点一杯”“来一杯”"帮我带一杯"是三个说法一个意图,全写进典型话术,匹配面才够宽。只写功能名的契约,跟简历只写岗位名不写经历的候选人一样——不是不能干,是没人敢调。

② 边界条款是防误触发的保险丝。 官方建议每条典型话术配"不调用的情况",V哥把它当测试用例来写:把容易混淆的相邻意图一条条列出来、明确踢出去。误触发一次,用户说"这 App 抢答";漏触发一次,用户说"这 App 失聪"——但误触发的伤害更大,因为它透支的是对整个入口的信任。

suggestion 字段要写人话。 回包里的 suggestion 是直接给用户看的。V哥见过把内部异常码直接怼进去的写法,用户看到的回复是"ERR_TIMEOUT_504"——这不叫失败提示,这叫劝退。错误分支的 suggestion 按"用户下一步能干什么"来写。


五、上线前自检清单

V哥把整条链路压成一张自检表,六项全勾再提交:

#检查项挂了会怎样
1Stage 模型?FA 模型直接不可用,先迁移编译期就过不去
2目录名 = SKILL.md name = skillProfiles[].name注册失败或匹配不上,无报错
3方法名与契约 functionName 严格一致?签名首参 ArkTSScriptInfo调用静默失败
4每个能力方法都有回包?所有分支都走 report 出口?系统侧"Skill 失联",被降权
5exec-cliskillName/scriptPath/functionName 与工程一致?匹配上了也调不通
6触发场景含"不调用的情况"?suggestion 是人话?误触发 / 回复劝退

开发完成后走官方的真机测试流程调试(真机测试),别只跑模拟器。


六、V哥的收尾判断:红利窗口就是现在

回看整套机制,V哥的总结是:Skill 化把"应用分发"的粒度从 App 降到了功能。以前用户装你的应用才能用你的功能;现在你的功能直接进意图分发池,被系统智能体按需调用。入口前置了,露出变短了,长尾功能第一次有机会被"说"出来。

代价是新增了一种工程资产——契约文件。它不是写完就完的:话术要随用户表达演进、边界要随能力扩展修订。V哥把它类比成"给智能体维护的 API 文档",版本化管起来,和代码一个待遇。

7.0 刚发,意图分发池里占位的人还不多。第一期的上架审核那篇V哥写过一句话:合规是门票。这一期补上后半句——Skill 化是新的门票,门开着的窗口不会一直开着。


参考与出处

本文涉及的机制、接口与配置项来自以下官方文档:


最后一句:图标时代你的功能在等人点,意图时代你的功能在被点名——把 SKILL.md 当简历写,把薄脚本当翻译写,小艺念到名字的那一刻,就是你长尾功能翻身的那一天。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐