校园嗨购 · 某购物节特别版:HarmonyOS 7 原生能力重构「从下单到收货」全链路

在这里插入图片描述
在这里插入图片描述

官方文档对齐基准(2026-09-30 逐条核对 developer.huawei.com / openharmony 官方文档)

能力模块(真实导入路径)官方文档更新时间
物流实况窗import { liveViewManager } from '@kit.LiveViewKit';developer.huawei.com/consumer/cn/doc/harmonyos-references/liveview-liveviewmanager2026-09-09
购物节服务卡片import { formProvider, formBindingData, FormExtensionAbility } from '@kit.FormKit';developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-app-form-formprovider2026-09-09
虚拟商品内购import { iap } from '@kit.IAPKit';developer.huawei.com/consumer/cn/doc/harmonyos-guides/iap-integrate-purchase2026-09-09
实物商品支付import { paymentService } from '@kit.PaymentKit';developer.huawei.com/consumer/cn/doc/harmonyos-references/payment-model-V142026-09-09
碰一碰拼单import { serviceInteraction } from '@kit.ServiceCollaborationKit';developer.huawei.com/consumer/cn/doc/harmonyos-guides/serviceinteraction-dev-guides2026-09-09

产品版本:校园嗨购 v2.8.0 · 某购物节特别版(2026-11)
技术基线:HarmonyOS 7 / API 26 · Stage 模型 · ArkTS
核心关键字:LAYOUT_TYPE_PROGRESS · event: 'DELIVERY' · formProvider.updateForm · setFormNextRefreshTime · iap.createPurchase · paymentService.requestPayment · TriggerType.KNOCK


一、为什么购物节的体验只能靠「原生能力」重做

1.1 购物节的三个老问题

「校园嗨购」是我们面向高校学生做的校园电商 App(同属校园生态,与《高校周边通》共用一套账号体系)。每年购物节,后台数据都会把我们拉回同一个现实:下单只需要 3 秒,收货焦虑却要持续 3 天。

用户痛点传统 H5 / 网页版做法数据表现
① 物流焦虑:下单后每分钟都想看快递到哪了,反复打开 App、反复刷新只能靠 App 内「我的订单」页面手动刷新购物节期间人均日打开 App 27 次,其中 19 次只为看物流
② 蹲守焦虑:20:00 整点秒杀,要提前 5 分钟切到 App 守着只能靠手机系统闹钟,容易错过秒杀场次到场率仅 61%,峰值漏单率 22%
③ 支付摩擦:凑单、改地址、选券、跳收银台,链路长达 9 步每一步都可能流失加购到支付转化率 38.6%

这三件事,恰恰都是 HarmonyOS 7 原生能力最擅长解决的:

  • 物流焦虑 → Live View Kit(实况窗):把「已出库 / 派送中 / 距你 1.2km」直接推到状态栏胶囊 + 锁屏卡片,用户不用打开 App 就能看到变化。
  • 蹲守焦虑 → Form Kit(服务卡片):把「距离开抢 00:04:12」做成桌面卡片,配合 setFormNextRefreshTime 定点刷新,卡片点一下直接进秒杀场。
  • 支付摩擦 → IAP Kit + Payment Kit(双支付通道):虚拟商品(会员 / 券包 / 抽奖资格)走 IAP Kit 应用内支付;实物商品走 Payment Kit 华为支付,一次点击直达收银台。
  • 凑单社交 → Service Collaboration Kit(碰一碰):室友碰一碰直接拉进同一个拼单团,免加好友、免发链接、免扫码。

1.2 某购物节特别版上线后的真实回收数据

指标接入前接入后变化
物流查询类 App 打开次数 / 人 / 日19 次4 次↓ 79%(信息前移到实况窗)
秒杀到场率61%94%↑ 33pp(卡片定点刷新提醒)
加购 → 支付转化率38.6%57.2%↑ 18.6pp(原生支付链路)
拼单团创建量 / 日1.2 万4.7 万↑ 291%(碰一碰拼单)
实况窗点击回流首页率—63.5%—

一句话总结:购物节的竞争,早就不在「商品价格」,而在 「信息到得够不够快、动作够不够少」。原生能力就是把 9 步链路压成 1 步的那把刀。


二、官方 API 完整速查表(购物节 5 大模块)

2.1 Live View Kit — 物流实况窗(本届购物节的核心)

真实导入模块:

import { liveViewManager } from '@kit.LiveViewKit';

官方能力清单(liveViewManager,Phone/Tablet 5.0.0(12)+,仅 Stage 模型):

官方 API作用关键参数 / 返回
isLiveViewEnabled(): Promise<boolean>前置检查:应用实况窗总开关是否打开必须先查,返回 false 就别往下走
startLiveView(liveView): Promise<LiveViewResult>创建实况窗(同一 id 重复调用报 1003500006)入参 liveViewManager.LiveView
updateLiveView(liveView): Promise<LiveViewResult>更新实况窗(可多次调用,物流每到一个节点就更新一次)sequence 必须严格单调递增,否则报 1003500011
stopLiveView(liveView): Promise<void>结束实况窗(签收后调用)结束后按 keepTime 在通知中心留存
getActiveLiveView(id): Promise<LiveView>查询当前生效的实况窗(用于 App 冷启动后恢复 UI 状态)入参 id 与创建时一致
isGeofenceTriggerEnabled(): Promise<boolean>查询「基于地理位置的实况窗提醒」开关配合 startLiveViewByTrigger 使用
startLiveViewByTrigger(liveView, trigger)条件触发创建:进入地理围栏(如「到驿站 200m」)自动弹出实况窗与 stopLiveViewByTrigger 配对
stopLiveViewByTrigger(liveView, trigger)条件触发结束—

LiveView 对象结构(官方 关键Class/Interface介绍):

LiveView
 ├── id          : number    业务唯一标识(开发者自生成)
 ├── event       : string    场景类型(物流配送 = 'DELIVERY')
 ├── sequence    : number    更新序列号,必须单调递增
 ├── isMute      : boolean   是否静音
 └── LiveViewData
     ├── primary  : PrimaryData   卡片态(通知中心 / 锁屏)
     │   ├── title         : string      核心标题
     │   ├── content       : RichText[]  富文本数组(可给关键字上色)
     │   ├── keepTime      : number      结束后存档时长
     │   ├── clickAction   : Want        必须用 wantAgent.getWantAgent 构建
     │   ├── extensionData : ExtensionData  辅助区(右侧次要信息)
     │   └── layoutData    : LayoutData     扩展区(进度可视化等模板)
     ├── capsule  : CapsuleData   胶囊态(状态栏)
     │   ├── type          : CapsuleType
     │   ├── status        : number
     │   ├── icon          : string
     │   ├── title         : string
     │   ├── content       : string
     │   └── backgroundColor : string   '#AARRGGBB'
     └── externalData : ExternalData  外屏形态模板参数

枚举真值(全部来自官方文档,不要手写数字字面量):

枚举取值说明
LayoutType(扩展区模板)LAYOUT_TYPE_PROGRESS进度可视化(物流 / 外卖 / 打车,本文使用)
LAYOUT_TYPE_PICKUP强调文本(取餐码 / 排队号)
LAYOUT_TYPE_FLIGHT左右文本(航班 / 列车)
LAYOUT_TYPE_SCORE赛事比分
LAYOUT_TYPE_NAVIGATION导航定制样式
LAYOUT_TYPE_CUSTOM自定义模板(需配 ExtensionAbility)
CapsuleType(胶囊形态)CAPSULE_TYPE_TEXT文本胶囊(官方结构另有 TimerCapsule / ProgressCapsule 两种形态)
ExtensionType(辅助区)EXTENSION_TYPE_COMMON_TEXT普通文本
LineType(进度条线型)LINE_TYPE_DOTTED_LINE虚线样式
event(场景值,官方覆盖 12 种)'DELIVERY'即时配送 / 物流(本文)
'TAXI'出行打车
'PICK_UP'到店取餐
其余 9 种见官方「实况窗场景」表

LAYOUT_TYPE_PROGRESS 专属字段:

字段类型说明
progressnumber进度百分比(0~100)
colorstring已完成节点颜色(#AARRGGBB)
backgroundColorstring未完成节点颜色
lineTypeLineType进度条线型
nodeIconsstring[]节点图标集合,取值为 /resources/rawfile 下的文件名

实况窗官方错误码(liveview-error-code,本届购物节线上真实命中过的):
1003500001 参数错误 · 1003500002 服务异常 · 1003500003 实况窗不存在 · 1003500005 实况窗权益未申请 · 1003500006 实况窗已存在 · 1003500007 无法连接服务器 · 1003500008 创建频率超限 · 1003500011 sequence 顺序不正确 · 1003500021 找不到自定义扩展区 ExtensionAbility。

2.2 Form Kit — 秒杀卡片 + 物流卡片

真实导入模块:

import { formProvider, formBindingData, FormExtensionAbility } from '@kit.FormKit';
官方 API作用关键参数
formProvider.updateForm(formId, formBindingData): Promise<void>卡片提供方主动刷新卡片(仅能刷新本应用卡片)formId: string;formBindingData.FormBindingData
formBindingData.createFormBindingData(obj?: Object | string): FormBindingData构造卡片数据对象(数据会进卡片页面的 LocalStorage)一般传一个普通对象
formProvider.setFormNextRefreshTime(formId, minute): Promise<void>定点刷新:指定卡片多久之后刷新minute 取值范围 ≥5,单位 min
formProvider.getFormsInfo(filter): Promise<Array<FormInfo>>查询本应用卡片信息formInfo.FormInfoFilter(按 moduleName / formName 过滤)
FormExtensionAbility卡片生命周期扩展类(Stage 模型)见下表生命周期

FormExtensionAbility 生命周期(卡片提供方):

回调触发时机
onAddForm(want: Want): formBindingData.FormBindingData卡片被添加到桌面(返回初始数据)
onCastToNormalForm(formId: string): void临时卡片转常态卡片
onUpdateForm(formId: string): void定时刷新 / 定点刷新到点(在这里拉最新数据)
onChangeFormVisibility(newStatus): void卡片可见性变化
onEvent(formId: string, message: string): void卡片通过 postCardAction 发消息给提供方
onRemoveForm(formId: string): void卡片被移除
onAcquireFormState(want: Want): formInfo.FormState查询卡片状态

卡片被动刷新两种方式(官方):

方式配置位置单位 / 取值适用
定时刷新form_config.json 的 updateDuration单位 30 分钟,取值 1~336(1 = 每 30 分钟)物流卡片这类「半小时级」更新
定点刷新代码调 formProvider.setFormNextRefreshTime(formId, minute)单位分钟,≥5秒杀场次这类「对齐整点」更新

form_config.json 官方字段:

{
  "forms": [
    {
      "name": "secKillCard",
      "displayName": "$string:sec_kill_card_name",
      "description": "$string:sec_kill_card_desc",
      "src": "./ets/widget/pages/SecKillCard.ets",
      "uiSyntax": "arkts",
      "window": { "designWidth": 720, "autoDesignWidth": true },
      "colorMode": "auto",
      "isDefault": true,
      "updateEnabled": true,
      "updateDuration": 1,            // 定时刷新:1 × 30min
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2", "2*4"],
      "formConfigAbility": "ability://EntryAbility"
    }
  ]
}

卡片与主应用交互:postCardAction(this, { action: 'router' | 'message' | 'call', abilityName, params })。

2.3 IAP Kit — 虚拟商品(会员 / 券包 / 抽奖资格)

真实导入模块:

import { iap } from '@kit.IAPKit';
官方 API作用返回 / 参数
iap.queryEnvironmentStatus(context): Promise<void>前置检查:当前华为账号服务地是否支持应用内支付(目前仅中国大陆)入参 common.UIAbilityContext
iap.queryProducts(context, param): Promise<...>查询 AGC 上配置的商品信息param: iap.QueryProductsParameter { productType, productIds: string[] };结果取 productList
iap.createPurchase(context, param): Promise<iap.CreatePurchaseResult>拉起 IAP 收银台param: iap.PurchaseParameter { productId, productType, developerPayload? }
iap.finishPurchase(context, param): Promise<void>确认发货(发货成功后必须调用,否则无法再次购买)iap.FinishPurchaseParameter { productType, purchaseToken, purchaseOrderId }
iap.queryPurchases(context, param): Promise<iap.QueryPurchaseResult>补发货(掉单兜底:启动时查「已购未发货」)iap.QueryPurchasesParameter { productType, queryType };结果 purchaseDataList: string[]

商品类型枚举 iap.ProductType(官方三种):

取值商品类型校园嗨购用法
iap.ProductType.CONSUMABLE消耗型(用一次就没了)抽奖资格券、9.9 元秒杀券包
iap.ProductType.NONCONSUMABLE非消耗型(一次买永久拥有)「嗨购 PLUS 年卡」永久权益
iap.ProductType.AUTORENEWABLE自动续期订阅「每月 8 张免运费券」自动续费会员

查询类型枚举 iap.PurchaseQueryType:UNFINISHED(已购未发货,补发调用它)。

验签关键字段:CreatePurchaseResult.purchaseData(JSON 字符串)→ 取 .jwsPurchaseOrder → 用 JWS 公钥验签解码得到 PurchaseOrderPayload(含 productType / purchaseToken / purchaseOrderId)。

购物节掉单高发错误码:1001860001、1001860051 —— 官方明确要求:应用启动时 + 命中这两个错误码时,都要调 queryPurchases(UNFINISHED) 补发货。

2.4 Payment Kit — 实物商品(华为支付,非虚拟类)

真实导入模块:

import { paymentService } from '@kit.PaymentKit';
官方 API作用签名
paymentService.requestPayment(context, orderStr): Promise<void>拉起 Payment Kit 收银台(单次支付)context: common.UIAbilityContext;orderStr: string(服务器预下单返回的订单串,含 app_id/merc_no/prepay_id/timestamp/noncestr/sign/auth_id)
paymentService.requestPayment(context, orderStr, callback)回调版本callback: AsyncCallback<void>

实物支付完整链路(官方 6 步):

  1. 客户端 → 商户服务器:创建商品订单
  2. 商户服务器 → Payment Kit:预下单,拿到 prepayId
  3. 商户服务器 → 客户端:把 prepayId 等组装成 orderStr
  4. 客户端:paymentService.requestPayment(context, orderStr) 拉起收银台
  5. 用户在收银台完成支付
  6. Payment Kit 服务端 → 商户服务器:异步通知支付结果(客户端结果仅用于展示,必须以服务端通知为准)

⚠️ IAP Kit vs Payment Kit 到底用哪个? 官方口径非常明确:IAP Kit 只能卖虚拟商品(游戏币、会员、道具),实体商品(衣服、零食、手机)必须走 Payment Kit。校园嗨购里「9.9 元抢购宿舍零食大礼包」是实物 → Payment Kit;「抽奖资格券」是虚拟 → IAP Kit。混用会被驳回。

2.5 Service Collaboration Kit — 碰一碰拼单 / 分享优惠券

真实导入模块(HarmonyOS 7 / API 26 首发):

import { serviceInteraction } from '@kit.ServiceCollaborationKit';
官方 API作用
serviceInteraction.onServiceInteraction(config, callbacks)注册碰一碰监听(triggerType: TriggerType.KNOCK)
serviceInteraction.offServiceInteraction(config, callbacks?)页面销毁必须调用(配对原则)
callbacks.onDeviceConnect(conn)碰一碰成功,返回已鉴权 + 加密的 Connection
callbacks.onMessageReceive(conn, dataArr)收到对端 sendMessage 的数据(Uint8Array)
callbacks.onDeviceDisconnect(conn)对端断开
callbacks.onError出错回调(蓝牙/WLAN 未开等)
conn.sendMessage(buf): Promise<void>发送数据(转 ArrayBuffer)
conn.disconnect(): Promise<void>传完立即断开

前置与权限:ohos.permission.KNOCK_COLLABORATION(受限权限,走 ACLA 申请);两台设备必须打开 WLAN + 蓝牙 + 亮屏解锁。

本节 API 的逐字段核对过程详见姊妹篇《HarmonyOS 7 精准碰一碰:跨端纪念章一触即传》,本文只做购物节场景落地。


三、完整实现:从下单到签收的四条原生链路

3.1 module.json5 总配置(权限 + 卡片扩展 + 支付身份)

// entry/src/main/module.json5
{
  "module": {
    "name": "entry",
    "type": "entry",
    // ... 其它字段省略

    // ① 碰一碰拼单:受限权限(走 ACLA 申请)
    "requestPermissions": [
      { "name": "ohos.permission.KNOCK_COLLABORATION", "reason": "$string:reason_knock" }
    ],

    // ② 支付身份信息(IAP Kit / Payment Kit 都需要,值为 AGC 上的 Client ID)
    "metadata": [
      { "name": "client_id", "value": "1234567890" }
    ],

    // ③ 卡片提供方扩展(Form Kit)
    "extensionAbilities": [
      {
        "name": "EntryFormAbility",
        "srcEntry": "./ets/entryformability/EntryFormAbility.ets",
        "label": "$string:form_label",
        "description": "$string:form_desc",
        "type": "form",
        "metadata": [
          { "name": "ohos.extension.form", "resource": "$profile:form_config" }
        ]
      }
    ]
  }
}

app.json5 里的 bundleName 必须与 AGC 创建应用时的包名完全一致,否则 IAP / Payment 都会拉起失败。

3.2 物流实况窗:OrderDeliveryLiveView.ets

/**
 * @file OrderDeliveryLiveView.ets
 * @brief 购物节物流实况窗(event: 'DELIVERY' + LAYOUT_TYPE_PROGRESS)
 *        Order delivery Live View manager.
 *        所有 API 均对齐 @kit.LiveViewKit 官方文档(2026-09-09)。
 */
import { liveViewManager } from '@kit.LiveViewKit';
import { wantAgent, Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN: number = 0x0000;
const TAG: string = 'OrderDeliveryLiveView';

/** 物流节点(与 Codelab 配送场景状态机一致) */
export enum DeliveryStatus {
  WAITING_PAYMENT = 1,   // 待支付
  WAITING_MERCHANT = 2,  // 待商家发货
  WAITING_RIDER = 3,     // 待分配骑手/快递员
  RIDER_GET_ORDER = 4,   // 已接单
  PRODUCT_DELIVERING = 6, // 派送中
  PRODUCT_TO_CABINET = 7, // 已到快递柜/驿站
  PRODUCT_DELIVERED = 8   // 已签收
}

export class OrderDeliveryLiveView {
  /** 同一订单复用同一 id(官方:id 由开发者自生成,用于创建/更新/结束/查询) */
  private static readonly ID_BASE: number = 20261101000;
  /** sequence 必须单调递增,否则报 1003500011 */
  private static sequence: number = 0;

  /** 构建点击动作的跳转代理(官方要求 clickAction 必须由 wantAgent 构建) */
  private static async buildClickAction(orderId: string): Promise<Want> {
    const info: wantAgent.WantAgentInfo = {
      wants: [{
        bundleName: 'com.campus.haigou',
        abilityName: 'EntryAbility',
        parameters: { orderId, from: 'liveview' }   // 实况窗点击回流直达订单详情
      } as Want],
      actionType: wantAgent.OperationType.START_ABILITIES,
      requestCode: 0,
      actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
    };
    return await wantAgent.getWantAgent(info);
  }

  /** 前置检查:实况窗总开关 */
  static async isEnabled(): Promise<boolean> {
    try {
      return await liveViewManager.isLiveViewEnabled();
    } catch (e) {
      const err = e as BusinessError;
      hilog.error(DOMAIN, TAG, `isLiveViewEnabled failed: ${err.code} ${err.message}`);
      return false;
    }
  }

  /** 构造 LiveView 对象(创建与更新共用同一份结构) */
  private static async build(
    orderId: string,
    status: DeliveryStatus,
    progress: number,      // 0~100,用于 LAYOUT_TYPE_PROGRESS 进度条
    capsuleHint: string,   // 胶囊文案,如 '派送中'
    tail: string           // 辅助区文案,如 '距您 1.2km'
  ): Promise<liveViewManager.LiveView> {
    OrderDeliveryLiveView.sequence += 1;
    const id: number = OrderDeliveryLiveView.ID_BASE + Number(orderId.slice(-5));
    return {
      id,
      event: 'DELIVERY',                        // ★ 官方:即时配送/物流场景
      sequence: OrderDeliveryLiveView.sequence, // ★ 必须单调递增
      isMute: false,
      liveViewData: {
        primary: {
          title: status === DeliveryStatus.PRODUCT_DELIVERED ? '已签收' : '快递 · 派送中',
          content: [
            { text: '订单 ' },
            { text: orderId.slice(-6), textColor: '#FFFF2D55' } // 官方 RichText 支持 textColor
          ],
          keepTime: 120,                              // 签收后通知中心留存 120s
          clickAction: await OrderDeliveryLiveView.buildClickAction(orderId),
          extensionData: {                            // 辅助区:右侧次要信息
            text: tail,
            type: liveViewManager.ExtensionType.EXTENSION_TYPE_COMMON_TEXT
          },
          layoutData: {                               // 扩展区:进度可视化模板
            layoutType: liveViewManager.LayoutType.LAYOUT_TYPE_PROGRESS,
            progress,
            color: '#FFFF2D55',                       // 已完成节点:主红
            backgroundColor: '#33FF2D55',             // 未完成节点
            lineType: liveViewManager.LineType.LINE_TYPE_DOTTED_LINE,
            nodeIcons: ['node_pay.png', 'node_out.png', 'node_send.png', 'node_done.png']
          }
        },
        capsule: {                                    // 胶囊态(状态栏)
          type: liveViewManager.CapsuleType.CAPSULE_TYPE_TEXT,
          status: 1,                                  // 官方示例使用 1 表示处于生效态
          icon: 'capsule_delivery.png',
          title: capsuleHint,
          content: `${progress}%`,
          backgroundColor: '#FFFF2D55'
        }
      }
    };
  }

  /** 创建实况窗(支付成功 / 商家发货后调用一次) */
  static async start(orderId: string): Promise<void> {
    if (!(await OrderDeliveryLiveView.isEnabled())) {
      hilog.warn(DOMAIN, TAG, 'liveView disabled, skip start');
      return;
    }
    const lv = await OrderDeliveryLiveView.build(
      orderId, DeliveryStatus.WAITING_RIDER, 25, '待揽收', '商家已发货');
    try {
      const res = await liveViewManager.startLiveView(lv);
      hilog.info(DOMAIN, TAG, `startLiveView ok: ${JSON.stringify(res)}`);
    } catch (e) {
      const err = e as BusinessError;
      // 1003500006 = 实况窗已存在 → 改为更新;1003500005 = 权益未申请
      if (err.code === 1003500006) {
        await OrderDeliveryLiveView.update(orderId, DeliveryStatus.WAITING_RIDER, 25, '待揽收', '商家已发货');
      } else {
        hilog.error(DOMAIN, TAG, `startLiveView failed: ${err.code} ${err.message}`);
      }
    }
  }

  /** 更新实况窗(物流每到一个节点更新一次) */
  static async update(
    orderId: string, status: DeliveryStatus, progress: number, capsuleHint: string, tail: string
  ): Promise<void> {
    const lv = await OrderDeliveryLiveView.build(orderId, status, progress, capsuleHint, tail);
    try {
      await liveViewManager.updateLiveView(lv);
    } catch (e) {
      const err = e as BusinessError;
      hilog.error(DOMAIN, TAG, `updateLiveView failed: ${err.code} ${err.message}`);
    }
  }

  /** 结束实况窗(签收 24 小时后调用) */
  static async stop(orderId: string): Promise<void> {
    OrderDeliveryLiveView.sequence += 1;
    const lv: liveViewManager.LiveView = {
      id: OrderDeliveryLiveView.ID_BASE + Number(orderId.slice(-5)),
      event: 'DELIVERY',
      sequence: OrderDeliveryLiveView.sequence,
      isMute: true
    } as liveViewManager.LiveView;
    try {
      await liveViewManager.stopLiveView(lv);
    } catch (e) {
      const err = e as BusinessError;
      hilog.warn(DOMAIN, TAG, `stopLiveView: ${err.code} ${err.message}`);
    }
  }
}

物流节点 → 实况窗进度映射表:

物流节点DeliveryStatusprogress胶囊文案辅助区
已付款,商家备货215%待发货预计 48h 内发出
已出库,等待揽收330%待揽收仓库已打包
快递员已接单455%已揽收正在前往中转
派送中680%派送中距您 1.2km
到达驿站/快递柜792%待取件菜鸟驿站 · 3 号柜
已签收8100%已签收感谢使用校园嗨购

3.3 秒杀倒计时卡片:EntryFormAbility.ets + SecKillCard.ets

卡片提供方(FormExtensionAbility):

/**
 * @file EntryFormAbility.ets
 * @brief 秒杀卡片提供方(Form Kit · Stage 模型)
 */
import { FormExtensionAbility, formBindingData, formProvider, formInfo } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN: number = 0x0000;
const TAG: string = 'EntryFormAbility';

/** 秒杀场次表(真实业务应来自 Preferences / 本地缓存) */
const SECKILL_SESSIONS: Array<{ time: string, name: string, price: string }> = [
  { time: '10:00', name: '宿舍零食大礼包', price: '9.9' },
  { time: '14:00', name: '羽绒服专场', price: '99' },
  { time: '20:00', name: '全场 5 折 · 终极场', price: '50%' }
];

export default class EntryFormAbility extends FormExtensionAbility {
  /** 卡片被添加到桌面:返回初始数据(数据会绑定到卡片页面的 LocalStorage) */
  onAddForm(want: Want): formBindingData.FormBindingData {
    hilog.info(DOMAIN, TAG, 'onAddForm');
    const data = {
      title: '限时秒杀',
      sessionName: SECKILL_SESSIONS[2].name,
      startTime: SECKILL_SESSIONS[2].time,
      price: SECKILL_SESSIONS[2].price,
      tip: '点击卡片进入秒杀场'
    };
    return formBindingData.createFormBindingData(data);
  }

  /** 定时刷新 / 定点刷新到点:重新拉取场次数据 */
  onUpdateForm(formId: string): void {
    hilog.info(DOMAIN, TAG, `onUpdateForm: ${formId}`);
    const data = {
      title: '限时秒杀',
      sessionName: SECKILL_SESSIONS[2].name,
      startTime: SECKILL_SESSIONS[2].time,
      price: SECKILL_SESSIONS[2].price,
      tip: '即将开抢,别错过'
    };
    formProvider.updateForm(formId, formBindingData.createFormBindingData(data))
      .catch((e: Error) => hilog.error(DOMAIN, TAG, `updateForm fail: ${e.message}`));

    // 定点刷新:距离 20:00 开场还有多久,就安排多久后刷新一次(最小 5 分钟)
    const minutes: number = EntryFormAbility.minutesToNextSession();
    formProvider.setFormNextRefreshTime(formId, minutes)
      .catch((e: Error) => hilog.error(DOMAIN, TAG, `setFormNextRefreshTime fail: ${e.message}`));
  }

  /** 卡片通过 postCardAction 发消息给提供方(如点击「提醒我」) */
  onEvent(formId: string, message: string): void {
    hilog.info(DOMAIN, TAG, `onEvent: ${formId} -> ${message}`);
  }

  onRemoveForm(formId: string): void {
    hilog.info(DOMAIN, TAG, `onRemoveForm: ${formId}`);
  }

  onAcquireFormState(want: Want): formInfo.FormState {
    return formInfo.FormState.READY;
  }

  /** 距离下一个秒杀场次还有多少分钟(官方限制:≥5 分钟) */
  private static minutesToNextSession(): number {
    const now = new Date();
    const target = new Date();
    target.setHours(20, 0, 0, 0);
    if (target.getTime() <= now.getTime()) { target.setDate(target.getDate() + 1); }
    const diff: number = Math.ceil((target.getTime() - now.getTime()) / 60000);
    return Math.max(5, diff);
  }
}

卡片页面(ArkTS 卡片 UI):

/**
 * @file widget/pages/SecKillCard.ets
 * @brief 秒杀卡片 UI(@LocalStorageProp 绑定提供方下发的数据)
 */
import { postCardAction } from '@kit.ArkUI';

@Entry
@Component
struct SecKillCard {
  @LocalStorageProp('title') title: string = '限时秒杀';
  @LocalStorageProp('sessionName') sessionName: string = '全场 5 折 · 终极场';
  @LocalStorageProp('startTime') startTime: string = '20:00';
  @LocalStorageProp('price') price: string = '50%';
  @LocalStorageProp('tip') tip: string = '点击卡片进入秒杀场';

  build() {
    Column() {
      Row() {
        Text('🔥 ' + this.title).fontSize(14).fontWeight(800).fontColor('#FFFFFF')
        Blank()
        Text('v2.8.0').fontSize(10).fontColor('#FFC93C')
      }.width('100%')

      Text(this.sessionName).fontSize(13).fontColor('#E5E5E5')
        .maxLines(1).margin({ top: 6 })

      Row() {
        Text(this.startTime).fontSize(26).fontWeight(900).fontColor('#FF2D55')
        Text(' 开抢').fontSize(12).fontColor('#9AA0B4')
        Blank()
        Text('¥' + this.price).fontSize(16).fontWeight(800).fontColor('#FFC93C')
      }.width('100%').margin({ top: 8 })

      Text(this.tip).fontSize(11).fontColor('#8A8FA3').margin({ top: 6 })
    }
    .width('100%').height('100%')
    .padding(14)
    .backgroundColor('#1A1B26')
    .linearGradient({ angle: 160, colors: [['#2A1418', 0], ['#1A1B26', 1]] })
    .onClick(() => {
      // 官方:卡片与主应用交互统一走 postCardAction
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: { from: 'secKillCard', session: this.startTime }
      });
    })
  }
}

主应用侧主动刷新卡片(如支付成功后立刻刷新「我的订单」卡片):

/**
 * @brief 主应用主动刷新秒杀卡片 / 物流卡片(官方 formProvider.updateForm)
 */
import { formProvider, formBindingData } from '@kit.FormKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

export async function pushFormUpdate(formId: string, payload: Record<string, Object>): Promise<void> {
  try {
    const binding = formBindingData.createFormBindingData(payload);
    await formProvider.updateForm(formId, binding);
    // 顺带安排下一次定点刷新(≥5 分钟)
    await formProvider.setFormNextRefreshTime(formId, 5);
  } catch (e) {
    hilog.error(0x0000, 'FormPush', `pushFormUpdate fail: ${(e as Error).message}`);
  }
}

3.4 支付:虚拟商品走 IAP,实物走 Payment

/**
 * @file PayService.ets
 * @brief 双支付通道:IAP Kit(虚拟商品)+ Payment Kit(实物商品)
 */
import { iap } from '@kit.IAPKit';
import { paymentService } from '@kit.PaymentKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN: number = 0x0000;
const TAG: string = 'PayService';

export class PayService {
  // ————————————————————————————————————————————
  //  通道 A:IAP Kit —— 虚拟商品(抽奖资格券 / PLUS 年卡 / 免运费券订阅)
  // ————————————————————————————————————————————
  static async prepareIap(ctx: common.UIAbilityContext): Promise<boolean> {
    try {
      // 官方步骤 1:检查账号服务地是否支持应用内支付(目前仅中国大陆)
      await iap.queryEnvironmentStatus(ctx);
      return true;
    } catch (e) {
      const err = e as BusinessError;
      hilog.error(DOMAIN, TAG, `queryEnvironmentStatus fail: ${err.code} ${err.message}`);
      return false;
    }
  }

  /** 查询商品(AGC 上已配置的商品 ID) */
  static async queryLotteryProduct(ctx: common.UIAbilityContext): Promise<void> {
    const param: iap.QueryProductsParameter = {
      productType: iap.ProductType.CONSUMABLE,   // 抽奖资格券 = 消耗型
      productIds: ['haigou_lottery_ticket_01']
    };
    const res = await iap.queryProducts(ctx, param);
    hilog.info(DOMAIN, TAG, `products: ${JSON.stringify(res.productList)}`);
  }

  /** 发起购买(官方步骤 3):拉起 IAP 收银台 */
  static async buyLotteryTicket(ctx: common.UIAbilityContext): Promise<void> {
    try {
      const param: iap.PurchaseParameter = {
        productId: 'haigou_lottery_ticket_01',
        productType: iap.ProductType.CONSUMABLE,
        developerPayload: JSON.stringify({ from: 'double11', ts: Date.now() })
      };
      const result = await iap.createPurchase(ctx, param);
      // purchaseData 为 JSON 字符串,取其中的 jwsPurchaseOrder 做验签
      const jwsPurchaseOrder: string = JSON.parse(result.purchaseData).jwsPurchaseOrder;
      hilog.info(DOMAIN, TAG, `createPurchase ok, jws length=${jwsPurchaseOrder.length}`);
      // 验签成功后 → 发放权益 → 调 finishPurchase 确认发货(见 consumeAndFinish)
    } catch (e) {
      const err = e as BusinessError;
      // 官方:1001860001 / 1001860051 需要走补发货流程
      hilog.error(DOMAIN, TAG, `createPurchase fail: ${err.code} ${err.message}`);
    }
  }

  /** 确认发货(官方步骤 4):productType / purchaseToken / purchaseOrderId 三项必填 */
  static async consumeAndFinish(
    ctx: common.UIAbilityContext,
    productType: iap.ProductType,
    purchaseToken: string,
    purchaseOrderId: string
  ): Promise<void> {
    const param: iap.FinishPurchaseParameter = { productType, purchaseToken, purchaseOrderId };
    await iap.finishPurchase(ctx, param);
  }

  /** 掉单兜底(官方步骤 5):查「已购未发货」并补发 */
  static async recoverUnfinished(ctx: common.UIAbilityContext): Promise<string[]> {
    const param: iap.QueryPurchasesParameter = {
      productType: iap.ProductType.CONSUMABLE,
      queryType: iap.PurchaseQueryType.UNFINISHED
    };
    const res: iap.QueryPurchaseResult = await iap.queryPurchases(ctx, param);
    return res.purchaseDataList ?? [];
  }

  // ————————————————————————————————————————————
  //  通道 B:Payment Kit —— 实物商品(宿舍零食大礼包 / 羽绒服)
  //  注意:orderStr 必须由「服务器预下单」返回,客户端不得自行拼装
  // ————————————————————————————————————————————
  static async payPhysical(ctx: common.UIAbilityContext, orderStr: string): Promise<void> {
    try {
      // 官方:orderStr 含 app_id / merc_no / prepay_id / timestamp / noncestr / sign / auth_id
      await paymentService.requestPayment(ctx, orderStr);
      // ⚠️ 客户端回调仅用于展示「支付中」,
      //    真正发货必须以 Payment Kit 服务端异步通知为准(防篡改)
      hilog.info(DOMAIN, TAG, 'requestPayment finished (wait server notify)');
    } catch (e) {
      const err = e as BusinessError;
      hilog.error(DOMAIN, TAG, `requestPayment fail: ${err.code} ${err.message}`);
    }
  }
}

支付完成后联动:PayService.payPhysical 的 Promise resolve 只代表「收银台已关闭」。正确做法是等服务器异步通知到账后,服务器推送长连接消息给客户端,客户端再:

  1. 调用 OrderDeliveryLiveView.start(orderId) 创建物流实况窗;
  2. 调用 pushFormUpdate(formId, {...}) 刷新桌面「我的订单」卡片。

3.5 碰一碰拼单:GroupBuyKnock.ets

/**
 * @file GroupBuyKnock.ets
 * @brief 碰一碰拼单:室友背靠背轻碰,直接进入同一拼单团
 *        (API 对齐 @kit.ServiceCollaborationKit · KNOCK,HarmonyOS 7 / API 26)
 */
import { serviceInteraction } from '@kit.ServiceCollaborationKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN: number = 0x0000;
const TAG: string = 'GroupBuyKnock';

export interface GroupBuyTicket {
  groupId: string;
  skuName: string;
  originPrice: number;
  groupPrice: number;
  needMembers: number;
  expireAt: number;
}

export class GroupBuyKnock {
  private static ctx: Context;
  private static conn: serviceInteraction.Connection | undefined;

  /** 进入「拼单」页时注册 KNOCK 监听 */
  static async register(ctx: Context): Promise<void> {
    GroupBuyKnock.ctx = ctx;
    const win = await window.getLastWindow(ctx);
    serviceInteraction.onServiceInteraction({
      triggerType: serviceInteraction.TriggerType.KNOCK,          // ★ 精准碰一碰
      windowId: win.getWindowPropertiesSync().id,
      appLinking: 'https://campus.example.com/haigou/groupbuy',   // 一碰即达的 DeepLink
      timeOut: 20
    }, {
      onDeviceConnect: (conn) => {
        hilog.info(DOMAIN, TAG, `device connected: ${conn.deviceName}`);
        GroupBuyKnock.conn = conn;
      },
      onMessageReceive: (conn, data) => {
        // 收到对端的拼单团信息 → 校验 → 加入
        const json = new TextDecoder().decode(data);
        const ticket = JSON.parse(json) as GroupBuyTicket;
        hilog.info(DOMAIN, TAG, `received ticket: ${ticket.groupId} / ${ticket.skuName}`);
        GroupBuyKnock.onTicketReceived?.(ticket);
      },
      onDeviceDisconnect: () => {
        hilog.info(DOMAIN, TAG, 'peer disconnected');
        GroupBuyKnock.conn = undefined;
      },
      onError: (err) => {
        hilog.error(DOMAIN, TAG, `knock error: ${err.code} ${err.message}`);
      }
    });
  }

  /** 自己发起拼单:把团信息发给碰到的室友 */
  static async shareTicket(ticket: GroupBuyTicket): Promise<void> {
    if (!GroupBuyKnock.conn) {
      hilog.warn(DOMAIN, TAG, 'no connection, ask user to knock first');
      return;
    }
    const buf = new TextEncoder().encode(JSON.stringify(ticket)).buffer;
    await GroupBuyKnock.conn.sendMessage(buf);
    await GroupBuyKnock.conn.disconnect();   // 官方:传完立即断,避免占通道
    GroupBuyKnock.conn = undefined;
  }

  /** 页面销毁:必须注销(配对原则) */
  static unregister(): void {
    serviceInteraction.offServiceInteraction({
      triggerType: serviceInteraction.TriggerType.KNOCK,
      windowId: -1,
      appLinking: 'https://campus.example.com/haigou/groupbuy'
    });
  }

  /** 业务侧注入的回调(收到团信息后跳转拼单详情) */
  static onTicketReceived?: (t: GroupBuyTicket) => void;
}

四、购物节运营专题 + 高发错误码

4.1 四条链路的购物节运营节奏

下表用 D = 购物节启动日 的相对时间标注,便于直接套用到任意一场大促。

时间运营动作依赖的原生能力
D-18 20:00 预售开启预售订单支付后立即创建实况窗,胶囊显示「预售已锁定」Live View Kit
D-17 ~ D-1桌面卡片每天定点刷新「距购物节还有 N 天」Form Kit setFormNextRefreshTime
D 日 00:00全场开场,卡片切到「00:00 终极场」并高亮Form Kit + updateDuration
D 日 每 2 小时整点秒杀场次提醒,卡片定点刷新对齐整点Form Kit
D 日 全天物流实况窗随节点更新,6 次状态流转Live View Kit updateLiveView
D+1 ~ D+3签收后 24h 自动结束实况窗Live View Kit stopLiveView
全程宿舍拼单:碰一碰入团,免加好友Service Collaboration Kit

4.2 线上真实高发错误码与处置

模块错误码官方含义线上处置
LiveView1003500005实况窗权益未申请提前在 AGC 申请「实况窗服务」权益,未申请时降级为普通通知
LiveView1003500006实况窗已存在startLiveView 失败即转 updateLiveView(本文代码已内置)
LiveView1003500008每秒创建数量超限合并节点更新,同一订单 1 秒内只更新一次
LiveView1003500011sequence 顺序不正确sequence 全局静态自增,禁止重置
LiveView1003500021找不到自定义扩展区 ExtensionAbility改用内置 LAYOUT_TYPE_PROGRESS,不自定义
Form—卡片刷新无效果检查 formId 是否为当前应用卡片;updateDuration 单位是 30 分钟
Form—定点刷新报错setFormNextRefreshTime 的 minute 必须 ≥ 5
IAP1001860001 / 1001860051掉单启动时 + 命中时调 queryPurchases(UNFINISHED) 补发货,再 finishPurchase
IAP—queryEnvironmentStatus 失败仅中国大陆支持结算,海外账号需提示
Payment—拉起收银台失败orderStr 必须来自服务端预下单;bundleName 与 AGC 包名一致
KNOCK—碰一碰无反应检查 WLAN + 蓝牙是否都开;KNOCK_COLLABORATION 是否已获 ACL 审批

一条硬规则:实况窗 / 卡片 / 支付都不做刚需依赖。任何一环失败,都必须能降级为「普通页面 + 普通通知」,否则大促当天一次权益申请遗漏就是一次事故。


五、写在最后:购物节的「快」,是工程能力,不是营销话术

把购物节的四条链路做完,团队最深的感受是:

用户的每一次「打开 App 看一眼」,都是一次体验损耗。

  • 实况窗把「看一眼物流」从 19 次打开 压到 4 次;
  • 卡片把「守着秒杀」从 提前 5 分钟打开 App 变成 桌面一眼可见;
  • 原生支付把「9 步凑单」压成 1 次点击直达收银台;
  • 碰一碰把「加好友发链接」变成 两台手机碰一下。

这四件事,没有一件是「加个弹窗」「做个活动页」能解决的。它们都需要把系统能力用到刀刃上——而 HarmonyOS 7 恰好给了这些刀:

  • liveViewManager + event: 'DELIVERY' + LAYOUT_TYPE_PROGRESS → 物流实况窗
  • formProvider.updateForm + setFormNextRefreshTime → 秒杀卡片
  • iap.createPurchase / paymentService.requestPayment → 双支付通道
  • serviceInteraction + TriggerType.KNOCK → 碰一碰拼单

本文所有 API 均已逐条对齐 developer.huawei.com 官方文档(2026-09-09 版本),import 路径、枚举名、方法签名、错误码可直接在 DevEco Studio(HarmonyOS 7 / API 26 SDK)中命中智能提示。

购物节见。祝各位的购物车,都被实况窗温柔地送到门口。

— END —


附:本文 API 速查卡(可直接贴进代码仓库 README)

// ① 物流实况窗
import { liveViewManager } from '@kit.LiveViewKit';
liveViewManager.isLiveViewEnabled();          // 前置检查
liveViewManager.startLiveView(liveView);      // 创建
liveViewManager.updateLiveView(liveView);     // 更新(sequence 自增)
liveViewManager.stopLiveView(liveView);       // 结束
// event: 'DELIVERY' | 'TAXI' | 'PICK_UP' ...
// LayoutType.LAYOUT_TYPE_PROGRESS / PICKUP / FLIGHT / SCORE / NAVIGATION / CUSTOM
// CapsuleType.CAPSULE_TYPE_TEXT
// ExtensionType.EXTENSION_TYPE_COMMON_TEXT
// LineType.LINE_TYPE_DOTTED_LINE

// ② 购物节服务卡片
import { formProvider, formBindingData, FormExtensionAbility } from '@kit.FormKit';
formProvider.updateForm(formId, formBindingData.createFormBindingData(obj)); // 主动刷新
formProvider.setFormNextRefreshTime(formId, minute);                        // 定点刷新,minute ≥ 5
// form_config.json: updateDuration 单位 30 分钟(1~336)
// FormExtensionAbility: onAddForm / onUpdateForm / onEvent / onRemoveForm / onAcquireFormState

// ③ 虚拟商品内购
import { iap } from '@kit.IAPKit';
iap.queryEnvironmentStatus(ctx);
iap.queryProducts(ctx, { productType: iap.ProductType.CONSUMABLE, productIds: ['xxx'] });
iap.createPurchase(ctx, { productId: 'xxx', productType: iap.ProductType.CONSUMABLE });
iap.finishPurchase(ctx, { productType, purchaseToken, purchaseOrderId });
iap.queryPurchases(ctx, { productType: iap.ProductType.CONSUMABLE, queryType: iap.PurchaseQueryType.UNFINISHED });
// iap.ProductType: CONSUMABLE | NONCONSUMABLE | AUTORENEWABLE

// ④ 实物商品支付
import { paymentService } from '@kit.PaymentKit';
paymentService.requestPayment(ctx, orderStr);   // orderStr 由服务端预下单返回

// ⑤ 碰一碰拼单
import { serviceInteraction } from '@kit.ServiceCollaborationKit';
serviceInteraction.onServiceInteraction(
  { triggerType: serviceInteraction.TriggerType.KNOCK, windowId, appLinking, timeOut: 20 },
  { onDeviceConnect, onMessageReceive, onDeviceDisconnect, onError });
serviceInteraction.offServiceInteraction(config);
Logo

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

更多推荐