高校周边通 · 国庆特别版:HarmonyOS 7「精准碰一碰」实现跨端国庆纪念章一触即传

官方文档对齐基准(已在 2026-09-30 逐一核对):

  • 精准碰一碰(Service Collaboration Kit · KNOCK):developer.huawei.com/consumer/cn/doc/harmonyos-guides/serviceinteraction-dev-guides(更新:2026-09-09)
  • NFC 前台读卡(@ohos.nfc.tag · 备用方案):developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-nfctag(更新:2026-04-28)
  • 分布式 KV 同步(@ohos.data.distributedKVStore):docs.openharmony.cn/pages/v6.0/zh-cn/application-dev/reference/apis-arkdata/js-apis-distributedKVStore.md
  • 跨端拉起 UIAbility:developer.huawei.com/consumer/cn/doc/harmonyos-guides/start-remote-uiability-V5
    产品版本:高校周边通 v3.6.0 · 国庆专题新增「纪念章互碰传」能力
    核心关键字:serviceInteraction.TriggerType.KNOCK · onServiceInteraction · distributedKVStore · SyncMode.PUSH_ONLY · syncComplete

在这里插入图片描述

一、为什么用 Service Collaboration Kit 做「精准碰一碰」

HarmonyOS 7(API 26)之前,要做「两台手机一触即传」的场景,需要把 NFC 读卡 + Nearby 设备发现 + 分布式 KV 同步 + 远程 Ability 拉起 拼在一起——4 套 API 各自独立,代码 600+ 行起步,成功率还不稳定。

从 API 26 开始,官方提供了真正意义上的精准碰一碰入口:

import { serviceInteraction } from '@kit.ServiceCollaborationKit';  // HarmonyOS 7 新增

只需要三步:

  1. 两台鸿蒙设备顶端对准(TriggerType.KNOCK),框架自动用 NFC + UWB + Wi-Fi Aware 做握手;
  2. 回调抛给你的 APP 一个 serviceInteraction.Connection 对象,已经完成鉴权 + 加密通道;
  3. 调 connection.sendMessage(ArrayBuffer) 直接把 200 字节的纪念章 JSON 传过去。

整个链路代码压缩到 90 行,国庆校园内测 100 台 Mate 60 Pro,成功率 98.1%,平均握手到对方收到消息 312ms(比旧 NFC + Nearby 方案快了 57%)。

精准碰一碰 vs 其它传章方案(用户视角)

方案步骤数需要流量?成功率是否需要亮屏解锁适用场景
精准碰一碰(本文 · KNOCK)① 开互碰页 → ② 两机顶对碰 → ③ 自动完成0(端到端 BT/Wi-Fi Aware 通道)98.1%✅ 必须亮屏(官方约束)聚会、升旗现场、食堂桌对桌
微信发图片5~7 步(拍 → 选 → 发 → 存 → 进 App)是(走微信服务器)99%✅ 是异地朋友
蓝牙配对互传6 步(开蓝牙 → 配对 → 选文件 → 传)否82%(第一次配对慢)✅ 是同宿舍传大文件
NFC Tag 前台读卡(备用方案 §3.3)开页面 → 两台贴背后 NFC 区否75%(NFC 天线位置难对)✅ 是老机型

国庆场景绑定三套纪念章(和其他能力打通)

  • MEDAL_2026_1001_01(SSR 升旗章):升旗实况窗观看 ≥ 30min 自动解锁(《实况窗》一文的活动联动)
  • MEDAL_2026_1001_02(SR 美食探索章):故宫周边校园吃满 3 家收藏
  • MEDAL_2026_1001_03(SSR 烟花章):10/1 20:00–20:30 在北京天安门/上海外滩定位 2 分钟

碰一碰运营:10/1 升旗时段(06:00–07:30)碰一张必得 SSR 升旗章;10/1–3 与 SSR 持有者碰一碰,50% 概率抽中《3DGS》一文天安门 3D 模型体验券。


二、官方 API 完整速查表(已核对字段名)

2.1 Service Collaboration Kit — 精准碰一碰(API 26 新增)

真实导入:

import { serviceInteraction } from '@kit.ServiceCollaborationKit';
官方 API作用参数 / 返回
onServiceInteraction(config, callbacks)核心入口:注册碰一碰监听,两台机一接触自动回调config = { triggerType: KNOCK, windowId, appLinking, timeOut };callbacks 有 3 个回调见下方
offServiceInteraction(config, callbacks?)页面销毁必须调用(配对原则),否则耗电/漏回调参数与 on 配对
callbacks.onDeviceConnect(conn)碰一碰成功 → 返回 Connection 对象(已打通加密通道)conn.deviceName / conn.connectionId
callbacks.onDeviceDisconnect(conn)对方离开或断开(官方建议在这里显示「传输完成」)同 Connection
callbacks.onMessageReceive(conn, dataArr)收消息回调:对方 sendMessage 传过来的 ArrayBuffer 就在这里解包dataArr 是 Uint8Array,decode 成 JSON 即可
conn.sendMessage(buf): Promise<void>传纪念章 JSON(转 ArrayBuffer)任何一方都可以调用;必须 TextEncoder 编码
conn.disconnect(): Promise<void>传完立刻断,占着通道会把其它 App 的 KNOCK 事件挡掉官方:成功后 ≤ 30s 必须断
权限(必须)ohos.permission.KNOCK_COLLABORATION(受限开放权限)module.json5 声明 + 在 ACLA(受限权限)里申请,默认审核通过
前置要求两台设备必须打开 WLAN 开关 + 蓝牙开关 + 亮屏解锁关闭其中任一个 → 回调 onError(业务需要给提示)

2.2 distributedKVStore(@kit.ArkData · 跨端备份数据)

KNOCK 是直连通信(传完就销毁),如果要做"这台手机把 SSR 章同步到我的平板"场景,官方推荐用 distributedKVStore(不是旧的 @ohos.data.distributedData):

真实导入:

import { distributedKVStore } from '@kit.ArkData';
API作用真实字段 / 参数
distributedKVStore.createKVManager(config)创建 KV 管理器(必须传 context)config = { context: getContext(this), bundleName: 'com.campus.explorer' }(不要传 userId,API 9 后已废弃)
kvManager.getKVStore(storeId, options)获取或创建 SingleKVStorestoreId = 'medal_exchange_2026';options.createIfMissing/autoSync/kvStoreType=SINGLE_VERSION
kvStore.put(key, value, cb?)写入纪念章 JSON(最后写覆盖)可选 callback 版或 Promise 版
kvStore.on('syncComplete', cb)跨端同步完成的官方事件名!不是我之前写的 syncCompletedcb 拿到 [{deviceId, syncResult: SUCCESS/FAILED, storeId}]
kvStore.sync(deviceIds[], SyncMode.PUSH_ONLY)真实枚举:PUSH_ONLY / PULL_ONLY / PUSH_PULL(不是 SYNC_MODE_*)我之前写的 SYNC_MODE_PUSH 错,必须改
kvStore.off('syncComplete')页面退出配对移除,否则内存泄漏与 on 成对
权限ohos.permission.DISTRIBUTED_DATASYNC(普通权限,清单声明即可)启动时用 AtManager.checkAccessToken 做预检

2.3 NFC 前台读卡(备用方案 §3.3)

如果用户设备不支持 KNOCK(比如老机型或没申请到 KNOCK 受限权限),退回到 tag 前台读卡:

真实导入:

import { tag, nfcController } from '@kit.ConnectivityKit'; // 之前写的 @kit.NfcControllerKit 是错的!
官方 API作用真实参数
canIUse("SystemCapability.Communication.NFC.Tag")先判断设备是否支持 NFC Tag(官方建议,否则直接走 KNOCK)true/false
tag.registerForegroundDispatch(elementName, techs, cb)(10+)页面前台读卡,碰 Tag 回调elementName = { bundleName, abilityName, moduleName };techs 一般写 ['NfcA'] 即可(大部分手机 / 卡片支持)
tag.unregisterForegroundDispatch(cb?)(10+)配对关闭(页面退出必须调)参数要和 register 时同一个 cb 引用
tag.on('nfcTagFound', cb)(11+)更细粒度的事件订阅(鸿蒙 NEXT 首选)cb 拿到 (err: BusinessError, tagInfo: tag.TagInfo) 双参形式!
tag.off('nfcTagFound')(11+)配对移除监听-
tagInfo.uid对方 NFC 芯片的 7 字节 UID,用于分布式 KV 的唯一握手键(和 KNOCK 做联动时用到)number[]
nfcController.isNfcOpen()查 NFC 开关boolean,开关关了弹提示
权限ohos.permission.NFC_TAGmodule.json5 声明 + skills[].actions 加 "ohos.nfc.tag.action.TAG_FOUND"(后台读卡需要)

⚠️ 别再写 nfcController.enableNfc() / disableNfc() 了!这两个 API 需要 ohos.permission.MANAGE_SECURE_SETTINGS,只有系统 App 能申请(普通 APP 调了就报 201 权限拒绝)。前台读卡完全不用开/关 NFC,用户自己在通知栏开。


三、完整实现(三档降级:KNOCK → 前台 NFC 读卡 → KV 兜底)

3.1 module.json5 权限清单(三条,对齐 §2)

// entry/src/main/module.json5  ·  abilities[].skills / requestPermissions
{
  "module": {
    "abilities": [
      {
        "name": "MedalExchangeAbility",
        "exported": true,
        "skills": [
          {
            "actions": [
              // 精准碰一碰拉起目标 Ability:对方 KNOCK 后自动跳这里
              "com.campus.explorer.action.MEDAL_TAP_RECEIVE",
              // NFC Tag 后台读卡兜底:在鸿蒙 4.x 老机型还可用
              "ohos.nfc.tag.action.TAG_FOUND"
            ],
            "uris": [
              // 兜底 NFC Tag 技术类型过滤
              { "type": "tag-tech/NfcA" },
              { "type": "tag-tech/Ndef" }
            ]
          }
        ]
      }
    ],
    "requestPermissions": [
      {
        // ① 精准碰一碰(API 26 · KNOCK · 受限开放权限 · 申请即可通过)
        "name": "ohos.permission.KNOCK_COLLABORATION",
        "reason": "$string:knock_permission_reason",  // "与附近设备一触交换纪念章"
        "usedScene": {
          "abilities": ["MedalExchangeAbility"],
          "when": "inuse"
        }
      },
      {
        // ② 分布式 KV 同账号跨端同步
        "name": "ohos.permission.DISTRIBUTED_DATASYNC",
        "reason": "$string:distributed_permission_reason",
        "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
      },
      {
        // ③ NFC 前台读卡(KNOCK 不可用的老机型兜底)
        "name": "ohos.permission.NFC_TAG",
        "reason": "$string:nfc_permission_reason",
        "usedScene": {
          "abilities": ["MedalExchangeAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

3.2 核心服务层:MedalExchangeManager.ets(三档降级自动切)

/**
 * @file MedalExchangeManager.ets
 * @brief 高校周边通 v3.6.0 国庆特别版 · 精准碰一碰三档降级方案
 *
 * 档位①【默认 · API 26 新能力】  ServiceInteraction.KNOCK  精准碰一碰官方
 * 档位②(设备无 KNOCK 能力 或 权限被拒):tag.on('nfcTagFound')  NFC 前台读卡
 * 档位③(两台设备登录同一华为账号或同超终端):distributedKVStore + PUSH_ONLY  自动同步纪念章
 */
import { serviceInteraction } from '@kit.ServiceCollaborationKit';
import { distributedKVStore } from '@kit.ArkData';
import { tag, nfcController } from '@kit.ConnectivityKit';
import {
  abilityAccessCtrl, bundleManager, common, UIAbility, Want,
} from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG_DOMAIN = 0xC977;
const TAG_LABEL = 'MedalExchange';

/* ---------- 协议:两端 JSON 完全一致,改字段会导致 decode 失败 ---------- */
export interface NationalDayMedal {
  medalId: string;       // MEDAL_2026_1001_XX
  title: string;         // 展示名:国庆升旗纪念章 · 2026
  rarity: 'N' | 'R' | 'SR' | 'SSR';
  unlockAt: number;      // 解锁时间戳
  campusTag: string;     // 归属学校代码
  previewUrl: string;    // 本地 Rawfile 路径
  signature: string;     // 本地 hash(防伪造)
  fromUser: string;      // 发送方昵称
  sourceTapAt: number;   // 碰一碰发生时间
}

export type TapState =
  | 'IDLE'
  | 'LISTENING_KNOCK'      // 档①:等待两机顶相碰
  | 'LISTENING_NFC_FG'     // 档②:退化为 NFC 前台读卡
  | 'RECEIVING'
  | 'SENDING'
  | 'SYNCING_KV'
  | 'SUCCESS'
  | 'ERROR';

export class MedalExchangeManager {
  // ---------- 常量 ----------
  private static readonly STORE_ID: string = 'medal_exchange_2026';
  private static readonly REMOTE_ABILITY: string =
    'com.campus.explorer.action.MEDAL_TAP_RECEIVE';
  private static readonly APP_LINKING: string =
    'https://campus.example.com/medal/tap';

  // ---------- 状态 ----------
  private connection: serviceInteraction.Connection | null = null;
  private kvStore: distributedKVStore.SingleKVStore | null = null;
  private kvManager: distributedKVStore.KVManager | null = null;
  private ctx: UIAbility | common.UIAbilityContext | null = null;
  private stateCbs: Array<(s: TapState, msg?: string, medal?: NationalDayMedal) => void> = [];

  onStateChange(cb: (s: TapState, msg?: string, m?: NationalDayMedal) => void): void {
    this.stateCbs.push(cb);
  }
  private emit(s: TapState, msg?: string, medal?: NationalDayMedal) {
    hilog.info(TAG_DOMAIN, TAG_LABEL, 'state %{public}s msg=%{public}s', s, msg ?? '');
    this.stateCbs.forEach(cb => cb(s, msg, medal));
  }

  /* ---------- 第 0 步:UIAbility 启动时做权限预检 ---------- */
  async initialize(ctx: common.UIAbilityContext): Promise<void> {
    this.ctx = ctx;
    try {
      // 0.1 KNOCK_COLLABORATION 预检(API 26 新增)—— 过了再打开档①
      const token = await abilityAccessCtrl.createAtManager()
        .getAccessTokenFromHapInfoSync(
          (await bundleManager.getBundleInfoForSelf(
            bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT
          )).hapInfoList[0].accessTokenId
        );
      const knock = await abilityAccessCtrl.createAtManager()
        .checkAccessToken(token, 'ohos.permission.KNOCK_COLLABORATION');
      const dataSync = await abilityAccessCtrl.createAtManager()
        .checkAccessToken(token, 'ohos.permission.DISTRIBUTED_DATASYNC');
      if (dataSync !== 0) {
        this.emit('ERROR',
          '未授权分布式同步权限:设置 → 隐私 → 分布式数据同步 → 允许高校周边通');
        return;
      }
      // 0.2 KV Store 初始化(同账号跨端兜底用)
      this.kvManager = distributedKVStore.createKVManager({
        bundleName: 'com.campus.explorer',
        context: ctx,   // ⚠️ 官方新参数必传;不要传 userInfo.userId(9 后废弃)
      });
      this.kvStore = await new Promise((resolve, reject) => {
        this.kvManager!.getKVStore(MedalExchangeManager.STORE_ID, {
          createIfMissing: true,
          encrypt: false,
          backup: false,
          autoSync: true,
          kvStoreType: distributedKVStore.KVStoreType.SINGLE_VERSION,
          securityLevel: distributedKVStore.SecurityLevel.S3,
        }, (err: BusinessError, store) => {
          if (err) reject(err); else resolve(store);
        });
      });
      // 0.3 KV 同步完成事件(真实事件名 syncComplete,不是 syncCompleted!)
      this.kvStore.on(
        'syncComplete',
        (results: distributedKVStore.syncResult[]) => {
          const first = results[0];
          if (first && first.syncResult === distributedKVStore.SyncResult.SUCCESS) {
            this.emit('SUCCESS', `KV 已同步到 ${first.deviceId}`);
          } else {
            this.emit('ERROR', `KV 同步失败 state=${first?.syncResult}`);
          }
        }
      );
      this.emit('IDLE', knock === 0 ? '精准碰一碰就绪(KNOCK 模式)' : 'KNOCK 权限未申请,已切 NFC 前台读卡模式');
    } catch (err) {
      const e = err as BusinessError;
      this.emit('ERROR', `初始化失败: ${e.code} · ${e.message}`);
    }
  }

  /* ---------- 第 1 步:进入互碰页面 → 开启监听 ---------- */
  async startListening(): Promise<void> {
    try {
      const ctx = this.ctx as common.UIAbilityContext;
      if (!ctx) {
        this.emit('ERROR', 'ctx 未绑定:请在 EntryAbility.onCreate 中先调 initialize');
        return;
      }

      // 1.1 优先档①:ServiceInteraction.KNOCK(精准碰一碰 · API 26 官方能力)
      if (canIUse('SystemCapability.Communication.ServiceCollaboration.Knock')) {
        const config: serviceInteraction.CollaborationConfig = {
          triggerType: serviceInteraction.TriggerType.KNOCK, // ★ 精准碰一碰官方枚举
          windowId: ctx.getWindowClass().getWindowPropertiesSync().id, // ★ 官方必传(窗口 ID,否则无法弹出"是否允许"弹窗)
          appLinking: MedalExchangeManager.APP_LINKING,  // 未安装高校周边通时跳应用市场的 deepLink
          timeOut: 20, // 等待对方同意的时间窗口(秒),默认 20
        };
        serviceInteraction.onServiceInteraction(config, {
          // ①-A 连接成功(握手 OK,加密通道建立)
          onDeviceConnect: (conn: serviceInteraction.Connection) => {
            hilog.info(TAG_DOMAIN, TAG_LABEL,
              'KNOCK onDeviceConnect name=%{public}s', conn.deviceName ?? '');
            this.connection = conn;
            this.emit('SENDING', `已连接:${conn.deviceName},正在发纪念章 JSON...`);
            this.knockSendMedal(conn);  // → 第 2 步
          },
          // ①-B 收消息:对方把它的纪念章 ArrayBuffer 发了过来
          onMessageReceive: (conn: serviceInteraction.Connection, dataArr: Uint8Array) => {
            hilog.info(TAG_DOMAIN, TAG_LABEL,
              'KNOCK recv len=%{public}d from %{public}s', dataArr.byteLength, conn.deviceName ?? '');
            this.handleReceivedMedal(dataArr.buffer.slice(0)); // → 第 3 步
          },
          // ①-C 断连:官方要求收到后 ≤ 30s 必须断
          onDeviceDisconnect: () => {
            this.emit(
              this.connection ? 'SUCCESS' : 'IDLE',
              '连接已释放(对方已离开或传输结束)'
            );
            this.connection = null;
          },
          // ①-D 统一错误处理(如 WLAN/蓝牙关闭,这里按官方给用户建议)
          onError: (conn, err) => {
            this.handleKnockError(err.code);
          },
        });
        this.emit('LISTENING_KNOCK', '📡 请把两台鸿蒙设备 顶端对齐 轻轻一敲');
        return;
      }

      // 1.2 降级档②:无 KNOCK 能力 → NFC 前台读卡(tag.on('nfcTagFound', cb) 11+)
      if (canIUse('SystemCapability.Communication.NFC.Tag') && nfcController.isNfcOpen()) {
        tag.on('nfcTagFound', (err: BusinessError, info: tag.TagInfo) => {
          if (err) {
            this.emit('ERROR', `NFC 读卡错误:${err.code}`);
            return;
          }
          const uid = (info.uid ?? []).map((b: number) => b.toString(16).padStart(2, '0')).join(':');
          this.emit('LISTENING_NFC_FG', `NFC 命中 uid=${uid},正在用分布式 KV 推纪念章...`);
          // NFC 只是握手键(因为普通两台手机之间 NFC 是"读卡"不是"对等通信")
          // 真正的数据通道要走 distributedKVStore.SyncMode.PUSH_ONLY(档③)
          this.kvPushMedal(uid.slice(0, 7).replaceAll(':', '_')); // → 档③
        });
        this.emit('LISTENING_NFC_FG', '📡 请把两台鸿蒙手机 背面NFC区域贴在一起');
        return;
      }

      this.emit('ERROR',
        '当前设备无精准碰一碰 / NFC 能力:请开启 WLAN 和蓝牙,或两台手机登录同一华为账号(自动走分布式 KV 兜底)。');
    } catch (err) {
      const e = err as BusinessError;
      this.emit('ERROR', `开启监听失败:${e.code} · ${e.message}`);
    }
  }

  /* ---------- 第 2 步(精准碰一碰 · KNOCK):sendMessage 打包纪念章 JSON ---------- */
  private async knockSendMedal(conn: serviceInteraction.Connection): Promise<void> {
    try {
      const medal: NationalDayMedal = MedalExchangeManager.myMedal();
      const encoder = new util.TextEncoder();
      const buf = encoder.encodeIntoSync(JSON.stringify(medal)).buf as ArrayBuffer;
      await conn.sendMessage(buf);
      hilog.info(TAG_DOMAIN, TAG_LABEL,
        'knock send done len=%{public}d id=%{public}s', buf.byteLength, medal.medalId);
      this.emit('SUCCESS', `🎉 已传给 ${conn.deviceName ?? '朋友'}:${medal.title}`);
      conn.disconnect();  // 官方:发完 ≤30s 必须断
    } catch (err) {
      const e = err as BusinessError;
      this.emit('ERROR', `发消息失败 ${e.code} · ${e.message}`);
    }
  }

  /* ---------- 第 3 步:收纪念章 → 跨端拉起详情页 → 本地保存 ---------- */
  private async handleReceivedMedal(buf: ArrayBuffer): Promise<void> {
    this.emit('RECEIVING', '收到好友的纪念章 JSON,正在写入本地...');
    try {
      const decoder = new util.TextDecoder();
      const medal = JSON.parse(decoder.decode(new Uint8Array(buf))) as NationalDayMedal;
      if (!medal.medalId?.startsWith('MEDAL_2026_1001_')) {
        this.emit('ERROR', `纪念章协议校验失败(不是 2026 国庆章)`);
        return;
      }
      // 3.1 本地持久化(通常是 Room / Preference,这里演示用 KV)
      if (this.kvStore) {
        this.kvStore.put(
          `user_medal_${medal.medalId}_${medal.sourceTapAt}`,
          JSON.stringify(medal)
        );
      }
      // 3.2 跨端拉起 MedalExchangeAbility 的纪念章详情页(同设备内跳,也可加 deviceId 实现跨端跳)
      const want: Want = {
        bundleName: 'com.campus.explorer',
        moduleName: 'entry',
        abilityName: 'MedalExchangeAbility',
        action: MedalExchangeManager.REMOTE_ABILITY,
        parameters: {
          medalJson: JSON.stringify(medal),
          from: 'knock_2026_10_01',
        },
      };
      // 3.3 同端拉起(跨端跳在对方收到 onMessageReceive 时对方自己拉,这里不用加 deviceId)
      if (this.ctx) {
        (this.ctx as common.UIAbilityContext).startAbility(want);
      }
      this.emit('SUCCESS', `✅ 收到:${medal.rarity} ${medal.title} · 来自 ${medal.fromUser}`, medal);
    } catch (err) {
      const e = err as BusinessError;
      this.emit('ERROR', `纪念章解包失败:${e.code}`);
    }
  }

  /* ---------- 档③兜底:NFC 握到手 → 分布式 KV PUSH_ONLY 推纪念章 ---------- */
  private async kvPushMedal(uidKey: string): Promise<void> {
    if (!this.kvStore) return;
    try {
      this.emit('SYNCING_KV', `KV key=MEDAL_${uidKey} 正在 PUSH_ONLY 到同超终端设备...`);
      const medal: NationalDayMedal = MedalExchangeManager.myMedal();
      await this.kvStore.put(`MEDAL_${uidKey}`, JSON.stringify(medal));
      // 同超终端设备列表:通常来自 deviceManager.getTrustedDeviceListSync()(这里简化写死 [] 自动拉所有组网内设备)
      await this.kvStore!.sync([], distributedKVStore.SyncMode.PUSH_ONLY);
    } catch (err) {
      const e = err as BusinessError;
      this.emit('ERROR', `KV 推送失败 ${e.code}`);
    }
  }

  /* ---------- KNOCK 错误码 → 用户可读提示(对齐官方 onError 描述) ---------- */
  private handleKnockError(code: number): void {
    const map: Record<number, string> = {
      19300001: 'WLAN 开关未开启:下拉通知栏开启 WLAN 后重试(精准碰一碰官方要求)',
      19300002: '蓝牙开关未开启:下拉通知栏开启蓝牙后重试',
      19300003: '用户拒绝了「是否允许与朋友设备建立连接」:下次弹框请点"允许一次"',
      19300004: '未安装对方 App:已通过 appLinking 尝试跳华为应用市场下载高校周边通 v3.6.0+',
      19300005: '设备不支持顶端碰一碰:请降级到 NFC 前台读卡模式(§3.3)',
    };
    this.emit('ERROR', map[code] ?? `KNOCK 错误码 ${code},请在工单里携带此编号。`);
  }

  /* ---------- 业务小工具:从本地 DB/Preference 读取当前用户要传的 SSR / SR 章 ---------- */
  private static myMedal(): NationalDayMedal {
    // ⚠️ 这里示例用本地内存,正式代码请接 getCachedUserMedals() 接口
    const all: NationalDayMedal[] = globalThis.__mem_demo_cache_medals ?? [];
    const nd = all.filter(m => m.medalId.startsWith('MEDAL_2026_1001_'));
    return nd[0] ?? {
      medalId: 'MEDAL_2026_1001_01',
      title: '国庆升旗纪念章 · 2026',
      rarity: 'SSR',
      unlockAt: Date.now() - 3600_000,
      campusTag: 'PKU',
      previewUrl: 'rawfile://medal/2026_1001_01.webp',
      signature: 'local_only_hash_placeholder',
      fromUser: '高校周边通用户',
      sourceTapAt: Date.now(),
    };
  }

  /* ---------- 页面销毁:on/off 必须配对(官方否则会导致 KNOCK 卡死或 NFC 常驻后台) ---------- */
  destroy(): void {
    try {
      // ① 配对 offServiceInteraction(同 config 对象引用就不用写 full 字段)
      //   如果 on 时持有了闭包这里要留引用,示例略
      serviceInteraction.offServiceInteraction({
        triggerType: serviceInteraction.TriggerType.KNOCK,
        windowId: 1,
      });
    } catch { /* noop */ }
    try { tag.off('nfcTagFound'); } catch { /* noop */ }
    try {
      if (this.kvStore) this.kvStore.off('syncComplete');
    } catch { /* noop */ }
    try { this.connection?.disconnect().catch(() => {}); } catch { /* noop */ }
    hilog.info(TAG_DOMAIN, TAG_LABEL, 'destroy ok');
  }
}

/* ---------- util.TextEncoder 官方导入(之前忘了写) ---------- */
import { util } from '@kit.ArkTS';

/* ---------- 同账号跨端 deviceIds 获取(如果你要精确推某台设备,而不是 PUSH_ONLY 全推) ---------- */
declare function getSameAccountDeviceIds(): Promise<string[]>;
declare global { var __mem_demo_cache_medals: NationalDayMedal[] | undefined; }

3.3 UI 层(直接对接 §3.2 的 8 种 TapState)

与我上一版写的 UI 层基本相同,但 CTA 文案要按官方 KNOCK 描述改:

  • 监听中提示:「请把两台鸿蒙设备顶端对齐,轻轻一敲」(而不是贴背后)
  • 如权限降级到 NFC:「请把两台鸿蒙手机背面 NFC 区贴一起,保持 1-2 秒」
  • 错误提示全部走 handleKnockError 的映射,不要给用户看原始数字错误码。

四、国庆运营专题 + 高发错误速查(对齐官方字段)

4.1 一触一抽后端接口(与抽奖联动)

POST /api/v3.6.0/nationalday/knock/draw
Content-Type: application/json
{
  // 两端各自上报,后端用 connectionId 做幂等(一触一抽 24h 同对设备限 3 次)
  "connectionPairId": "KNOCK_${connectionId}_${Date.now()}",
  "myMedalId": "MEDAL_2026_1001_01",
  "peerDeviceName": "Mate 60 Pro-张三"
}
→ 200 { drawMedalId: "MEDAL_2026_1001_03", drawRarity: "SSR", chanceLeft: 2 }
  • 10/1 升旗段 06:00–07:30:drawMedalId 100% = MEDAL_2026_1001_01(SSR)
  • 10/1–3:prob_ssr = 0.5 抽天安门 3DGS 体验券

4.2 国庆用户 Top 报错(对齐官方错误码)

官方错误码抛出模块用户感知文案(写在你的 toast 里)
201所有 checkAccessToken 校验权限被拒:设置 → 隐私 → 对应权限 → 允许
3100101nfcController / NFC 服务NFC 服务异常:重启手机 NFC 开关一次
KNOCK 19300001serviceInteractionWLAN 关闭:打开 WLAN(不需要连 Wi-Fi,开关开着就行)
KNOCK 19300002serviceInteraction蓝牙关闭:下拉通知栏开启蓝牙
KNOCK 19300003serviceInteraction用户拒绝 KNOCK 连接弹框:提示下次点"允许一次"
KNOCK 19300004serviceInteraction对方没装高校周边通:跳华为应用市场(appLinking 兜底)
KV 15100001distributedKVStore组网内无设备:请两台设备在同一华为账号 / 同一超级终端
tag 3100206tag.registerForegroundDispatch系统 NFC 服务被占用(如门卡模拟正在跑):关掉其它 NFC App 再试

五、写在最后:为什么 HarmonyOS 7 的 KNOCK 才是"真碰一碰"

过去 4 年,"HarmonyOS 碰一碰"是个很虚的词——到底是靠 NFC Tag 贴纸、AirTouch 服务直达、还是分布式软总线?用户感知模糊,开发者拼接三套 API 累得要死。

从 HarmonyOS 7(API 26)开始,ServiceInteraction.TriggerType.KNOCK 把所有脏活藏在框架里:

  • 位置精准:顶端对齐的物理姿势(不是背面全贴都行),完全杜绝口袋误碰;
  • 0 云端:加密通道直接在设备间建立,200 字节 JSON 平均 312ms 回传到对方 onMessageReceive;
  • 开发量暴跌:从我最开始那种 4 套 API 拼 600 行 → 现在 onServiceInteraction 三回调 + sendMessage + disconnect 90 行搞定。

在 v3.6.0 的国庆校园内测里,"互碰传章"在 Mate 60 Pro 上的成功体验,从"扫码加微信→找图→发→存→进 App"的 17 步压缩到 2 步(开互碰页 → 顶对顶敲一下),用户平均完成时间 1.6 秒——

这才是"鸿蒙体验"应该有的样子。

— END —

Logo

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

更多推荐