【Jack实战】如何用 Share Kit 接入碰一碰和 AI 隔空传送

大家好,我是鸿蒙Jack。本期以我的《时光旅记》APP 为例,聊一下我怎么把 Share Kit 里的 HarmonyShare 能力接进来,让用户通过"碰一碰"和"一抓一放"的 AI 隔空传送,把照片、旅行计划链接、海拔打卡图这些内容快速传到附近设备。

这个能力的产品感很强。以前分享一张旅行照片,用户通常要点分享按钮、等面板弹出、再选择设备或应用。现在在支持的场景里,用户只要进入内容页,设备轻贴一下,或者对着附近设备做一个"抓取再释放"的手势,就能完成跨设备分享。对《时光旅记》这种以照片和旅行内容为核心的应用来说,这个体验很贴合。

我在《时光旅记》里的使用场景

《时光旅记》里有两类内容特别适合跨设备快速分享。

第一类是"当下正在看的内容"。比如用户打开一个瞬间详情页,当前照片就是最明确的分享对象;打开海拔打卡页,屏幕上的打卡卡片就是分享对象;打开图片工厂,当前编辑结果就是分享对象。这种场景不需要再让用户选一次文件,页面自己知道应该分享什么。

第二类是"可跳转的内容"。比如旅行计划详情页,分享出去的不是一张本地图片,而是一条 App Linking 链接。对方设备收到后,如果装了《时光旅记》,可以直接回到旅行计划;没装应用时,也能通过网页或应用市场链路继续承接。

我没有在每个页面里重新写一遍 harmonyShare.on('knockShare')harmonyShare.on('gesturesShare')。页面只负责提供一个 getShareData() 回调,真正的监听注册、窗口 ID 获取、旧监听清理、owner 防串页,都放进 XHarmonyShareKit

碰一碰和 AI 隔空传送的关系

这两个能力都在 @kit.ShareKit 里,最终都走 systemShare.SharedData

knockShare 是设备轻贴触发。用户进入可分享页面后,系统识别到碰一碰事件,应用收到 SharableTarget,然后调用 target.share(shareData)

gesturesShare 是隔空传送触发。用户通过"一抓一放"手势完成中近距离跨设备传送,应用同样收到 SharableTarget,也同样调用 target.share(shareData)

所以我把它们看成两个触发入口,后面的数据构造完全复用。
#mermaid-svg-fkKzxUYTT1qezGjd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fkKzxUYTT1qezGjd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fkKzxUYTT1qezGjd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fkKzxUYTT1qezGjd .error-icon{fill:#552222;}#mermaid-svg-fkKzxUYTT1qezGjd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fkKzxUYTT1qezGjd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fkKzxUYTT1qezGjd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fkKzxUYTT1qezGjd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fkKzxUYTT1qezGjd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fkKzxUYTT1qezGjd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fkKzxUYTT1qezGjd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fkKzxUYTT1qezGjd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fkKzxUYTT1qezGjd .marker.cross{stroke:#333333;}#mermaid-svg-fkKzxUYTT1qezGjd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fkKzxUYTT1qezGjd p{margin:0;}#mermaid-svg-fkKzxUYTT1qezGjd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fkKzxUYTT1qezGjd .cluster-label text{fill:#333;}#mermaid-svg-fkKzxUYTT1qezGjd .cluster-label span{color:#333;}#mermaid-svg-fkKzxUYTT1qezGjd .cluster-label span p{background-color:transparent;}#mermaid-svg-fkKzxUYTT1qezGjd .label text,#mermaid-svg-fkKzxUYTT1qezGjd span{fill:#333;color:#333;}#mermaid-svg-fkKzxUYTT1qezGjd .node rect,#mermaid-svg-fkKzxUYTT1qezGjd .node circle,#mermaid-svg-fkKzxUYTT1qezGjd .node ellipse,#mermaid-svg-fkKzxUYTT1qezGjd .node polygon,#mermaid-svg-fkKzxUYTT1qezGjd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fkKzxUYTT1qezGjd .rough-node .label text,#mermaid-svg-fkKzxUYTT1qezGjd .node .label text,#mermaid-svg-fkKzxUYTT1qezGjd .image-shape .label,#mermaid-svg-fkKzxUYTT1qezGjd .icon-shape .label{text-anchor:middle;}#mermaid-svg-fkKzxUYTT1qezGjd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fkKzxUYTT1qezGjd .rough-node .label,#mermaid-svg-fkKzxUYTT1qezGjd .node .label,#mermaid-svg-fkKzxUYTT1qezGjd .image-shape .label,#mermaid-svg-fkKzxUYTT1qezGjd .icon-shape .label{text-align:center;}#mermaid-svg-fkKzxUYTT1qezGjd .node.clickable{cursor:pointer;}#mermaid-svg-fkKzxUYTT1qezGjd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fkKzxUYTT1qezGjd .arrowheadPath{fill:#333333;}#mermaid-svg-fkKzxUYTT1qezGjd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fkKzxUYTT1qezGjd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fkKzxUYTT1qezGjd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fkKzxUYTT1qezGjd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fkKzxUYTT1qezGjd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fkKzxUYTT1qezGjd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fkKzxUYTT1qezGjd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fkKzxUYTT1qezGjd .cluster text{fill:#333;}#mermaid-svg-fkKzxUYTT1qezGjd .cluster span{color:#333;}#mermaid-svg-fkKzxUYTT1qezGjd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fkKzxUYTT1qezGjd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fkKzxUYTT1qezGjd rect.text{fill:none;stroke-width:0;}#mermaid-svg-fkKzxUYTT1qezGjd .icon-shape,#mermaid-svg-fkKzxUYTT1qezGjd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fkKzxUYTT1qezGjd .icon-shape p,#mermaid-svg-fkKzxUYTT1qezGjd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fkKzxUYTT1qezGjd .icon-shape .label rect,#mermaid-svg-fkKzxUYTT1qezGjd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fkKzxUYTT1qezGjd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fkKzxUYTT1qezGjd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fkKzxUYTT1qezGjd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 瞬间照片/本地文件
旅行计划
海拔打卡/图片工厂
用户进入可分享页面
页面生成 shareOwnerId
XHarmonyShareKit.registerKnockShare
XHarmonyShareKit.registerGesturesShare
harmonyShare.on knockShare
window.getLastWindow 获取窗口 ID
harmonyShare.on gesturesShare
触发 SharableTarget
页面 getShareData
分享内容类型
createShareDataFromFilePath / Uri
createHyperlinkShareData
buildImageShareDataFromPixelMap
target.share SharedData
系统完成跨设备分享

页面生命周期对应的时序如下:
ShareUtil SharableTarget harmonyShare ArkUI Window XHarmonyShareKit 可分享页面 ShareUtil SharableTarget harmonyShare ArkUI Window XHarmonyShareKit 可分享页面 #mermaid-svg-luqR0UQsgzz1BYJD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-luqR0UQsgzz1BYJD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-luqR0UQsgzz1BYJD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-luqR0UQsgzz1BYJD .error-icon{fill:#552222;}#mermaid-svg-luqR0UQsgzz1BYJD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-luqR0UQsgzz1BYJD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-luqR0UQsgzz1BYJD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-luqR0UQsgzz1BYJD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-luqR0UQsgzz1BYJD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-luqR0UQsgzz1BYJD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-luqR0UQsgzz1BYJD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-luqR0UQsgzz1BYJD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-luqR0UQsgzz1BYJD .marker.cross{stroke:#333333;}#mermaid-svg-luqR0UQsgzz1BYJD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-luqR0UQsgzz1BYJD p{margin:0;}#mermaid-svg-luqR0UQsgzz1BYJD .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-luqR0UQsgzz1BYJD text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-luqR0UQsgzz1BYJD .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-luqR0UQsgzz1BYJD .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-luqR0UQsgzz1BYJD .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-luqR0UQsgzz1BYJD .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-luqR0UQsgzz1BYJD #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-luqR0UQsgzz1BYJD .sequenceNumber{fill:white;}#mermaid-svg-luqR0UQsgzz1BYJD #sequencenumber{fill:#333;}#mermaid-svg-luqR0UQsgzz1BYJD #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-luqR0UQsgzz1BYJD .messageText{fill:#333;stroke:none;}#mermaid-svg-luqR0UQsgzz1BYJD .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-luqR0UQsgzz1BYJD .labelText,#mermaid-svg-luqR0UQsgzz1BYJD .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-luqR0UQsgzz1BYJD .loopText,#mermaid-svg-luqR0UQsgzz1BYJD .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-luqR0UQsgzz1BYJD .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-luqR0UQsgzz1BYJD .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-luqR0UQsgzz1BYJD .noteText,#mermaid-svg-luqR0UQsgzz1BYJD .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-luqR0UQsgzz1BYJD .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-luqR0UQsgzz1BYJD .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-luqR0UQsgzz1BYJD .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-luqR0UQsgzz1BYJD .actorPopupMenu{position:absolute;}#mermaid-svg-luqR0UQsgzz1BYJD .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-luqR0UQsgzz1BYJD .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-luqR0UQsgzz1BYJD .actor-man circle,#mermaid-svg-luqR0UQsgzz1BYJD line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-luqR0UQsgzz1BYJD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} registerKnockShare(ownerId, getShareData)off('knockShare')on('knockShare', callback)registerGesturesShare(ownerId, uiContext, getShareData)getLastWindow(hostContext)windowIdoff('gesturesShare', { windowId })on('gesturesShare', { windowId }, callback)用户碰一碰或一抓一放getShareData()构造 SharedDatasystemShare.SharedDatashare(shareData)aboutToDisappear / onBackPressoff('knockShare')off('gesturesShare', { windowId })

为什么我做 ownerId 防串页

HarmonyShare 是事件监听型能力。页面 A 注册了分享监听,页面 B 又注册了一次,如果不处理好旧监听,就容易出现"明明在 B 页,分享出去的是 A 页内容"的问题。

所以我在每个页面创建一个唯一 shareOwnerId,注册时记录当前 owner 和 binding version。回调触发时再检查一遍:如果 version 或 owner 不匹配,直接丢弃这次回调。页面退出时也带着 owner 注销,避免误注销别的页面刚注册好的监听。

项目里的 owner 生成类似这样:

ts 复制代码
function buildMomentDetailShareOwnerId(): string {
  return 'moment-detail-page-' + Date.now().toString() + '-' + Math.floor(Math.random() * 1000000).toString();
}

这个设计看起来小,但它解决的是跨页面能力最常见的问题:监听生命周期不是跟着 UI 自动走的,必须自己把边界守住。

SharedData 怎么构造

Share Kit 真正发送的是 systemShare.SharedData。我在《时光旅记》里主要用了三种构造方式。

瞬间照片这种已有本地路径或 URI 的内容,直接根据文件扩展名推断 UTD:

ts 复制代码
return ShareUtil.createShareDataFromFilePath(
  currentItem.localPath,
  shareTitle,
  '来自时光印记的瞬间照片'
);

旅行计划这种内容,走链接分享。这里 utd 要是 general.hyperlink,也就是 utd.UniformDataType.HYPERLINK

ts 复制代码
return ShareUtil.createHyperlinkShareData(
  this.currentShareLink,
  this.getPlanShareTitle(),
  this.getPlanShareDescription(),
  thumbnailUri
);

海拔打卡、图片工厂、创意工坊这种屏幕上实时合成出来的内容,我先用 componentSnapshot.getSync() 拿到 PixelMap,再打包成 PNG 写入分享缓存目录,最后生成文件型 SharedData

ts 复制代码
let pixelMap = componentSnapshot.getSync(ALTITUDE_SHARE_CANVAS_ID);
try {
  return await ShareUtil.buildImageShareDataFromPixelMap(
    this.getUIContext(),
    pixelMap,
    'altitude_checkin_share.png',
    '我的海拔打卡',
    '快来看看我的当前海拔!'
  );
} finally {
  pixelMap.release();
}

这里的 finally 不能省。PixelMap 是图像资源,分享构造成功或失败都应该释放。

完整代码

下面这份代码按《时光旅记》当前实现整理,包含 HarmonyShare 封装、分享数据工具,以及几个典型业务页的接入方式。实际页面里还有大量业务 UI,这里只保留和碰一碰、AI 隔空传送相关的主链路。

ShareUtil.ets

ts 复制代码
import { common } from '@kit.AbilityKit';
import { uniformTypeDescriptor as utd } from '@kit.ArkData';
import { fileIo as fs, fileUri } from '@kit.CoreFileKit';
import { image } from '@kit.ImageKit';
import { systemShare } from '@kit.ShareKit';
import { ImageProcessingUtil } from '../../utils/ImageProcessingUtil';

const SHARE_CACHE_DIRECTORY: string = 'time-imprint/share-cache';

export class ShareUtil {
  static getUtdTypeByExtension(extension: string): string {
    let normalizedExtension: string = ShareUtil.normalizeExtension(extension);
    let belongsTo: string = ShareUtil.getUtdCategoryByExtension(normalizedExtension);
    try {
      return utd.getUniformDataTypeByFilenameExtension(normalizedExtension, belongsTo);
    } catch (_error) {
      return belongsTo;
    }
  }

  static createShareDataFromFilePath(
    filePath: string,
    title: string,
    description: string = '分享图片'
  ): systemShare.SharedData {
    return ShareUtil.createShareDataFromUri(
      fileUri.getUriFromPath(filePath),
      filePath,
      title,
      description
    );
  }

  static createShareDataFromUri(
    uriValue: string,
    fileNameOrPath: string,
    title: string,
    description: string = '分享图片'
  ): systemShare.SharedData {
    return new systemShare.SharedData({
      utd: ShareUtil.getUtdTypeByExtension(ShareUtil.extractExtension(fileNameOrPath)),
      uri: uriValue,
      title: title,
      description: description
    });
  }

  static createHyperlinkShareData(
    link: string,
    title: string,
    description: string = '分享链接',
    thumbnailUri: string = ''
  ): systemShare.SharedData {
    let record: systemShare.SharedRecord = {
      utd: utd.UniformDataType.HYPERLINK,
      content: link,
      title: title,
      description: description
    };
    if (thumbnailUri.trim().length > 0) {
      record.thumbnailUri = thumbnailUri;
    }
    return new systemShare.SharedData(record);
  }

  static async buildImageShareDataFromPixelMap(
    uiContext: UIContext,
    pixelMap: image.PixelMap,
    fileName: string,
    title: string,
    description: string = '分享图片',
    format: string = 'image/png',
    quality: number = 100
  ): Promise<systemShare.SharedData> {
    let filePath: string = await ShareUtil.savePixelMapToShareFile(
      uiContext.getHostContext() as common.UIAbilityContext,
      pixelMap,
      fileName,
      format,
      quality
    );
    return ShareUtil.createShareDataFromFilePath(filePath, title, description);
  }

  static async savePixelMapToShareFile(
    context: common.UIAbilityContext,
    pixelMap: image.PixelMap,
    fileName: string,
    format: string = 'image/png',
    quality: number = 100
  ): Promise<string> {
    let buffer: ArrayBuffer = await ImageProcessingUtil.packPixelMap(pixelMap, format, quality);
    let extension: string = format.indexOf('png') >= 0 ? 'png' : 'jpg';
    return await ShareUtil.writeBufferToShareFile(context, buffer, fileName, extension);
  }

  static async writeBufferToShareFile(
    context: common.UIAbilityContext,
    buffer: ArrayBuffer,
    fileName: string,
    fallbackExtension: string = 'png'
  ): Promise<string> {
    let shareDirectory: string = await ShareUtil.ensureShareCacheDirectory(context);
    let shareFilePath: string = shareDirectory + '/' + ShareUtil.buildShareFileName(fileName, fallbackExtension);
    await ImageProcessingUtil.writeBufferToFile(shareFilePath, buffer);
    return shareFilePath;
  }

  static async ensureShareCacheDirectory(context: common.UIAbilityContext): Promise<string> {
    return await ShareUtil.ensureShareDirectory(context);
  }

  static async showSharePanel(
    uiContext: UIContext,
    shareData: systemShare.SharedData,
    anchor?: string
  ): Promise<void> {
    let context = uiContext.getHostContext() as common.UIAbilityContext;
    let controller: systemShare.ShareController = new systemShare.ShareController(shareData);
    let previewMode: systemShare.SharePreviewMode = systemShare.SharePreviewMode.DETAIL;
    let records: Array<systemShare.SharedRecord> = shareData.getRecords();
    if (records.length > 0 && records[0].utd === utd.UniformDataType.HYPERLINK) {
      previewMode = systemShare.SharePreviewMode.DEFAULT;
    }
    let options: systemShare.ShareControllerOptions = {
      selectionMode: systemShare.SelectionMode.SINGLE,
      previewMode: previewMode
    };
    if (anchor !== undefined && anchor.length > 0) {
      options.anchor = anchor;
    }
    await controller.show(context, options);
  }

  private static async ensureShareDirectory(context: common.UIAbilityContext): Promise<string> {
    let shareDirectory: string = context.filesDir + '/' + SHARE_CACHE_DIRECTORY;
    if (!fs.accessSync(shareDirectory)) {
      await fs.mkdir(shareDirectory, true);
    }
    return shareDirectory;
  }

  private static buildShareFileName(fileName: string, fallbackExtension: string): string {
    let sanitizedName: string = ShareUtil.sanitizeFileName(fileName);
    let dotIndex: number = sanitizedName.lastIndexOf('.');
    let baseName: string = sanitizedName;
    let extension: string = ShareUtil.normalizeExtension(fallbackExtension).substring(1);
    if (dotIndex > 0 && dotIndex < sanitizedName.length - 1) {
      baseName = sanitizedName.substring(0, dotIndex);
      extension = ShareUtil.normalizeExtension(sanitizedName.substring(dotIndex + 1)).substring(1);
    }
    if (baseName.length === 0) {
      baseName = 'shared_image';
    }
    return baseName + '_' + Date.now().toString() + '.' + extension;
  }

  private static sanitizeFileName(fileName: string): string {
    return fileName.trim().replace(/[\\/:*?"<>|]/g, '_');
  }

  private static extractExtension(fileNameOrPath: string): string {
    let dotIndex: number = fileNameOrPath.lastIndexOf('.');
    if (dotIndex < 0 || dotIndex >= fileNameOrPath.length - 1) {
      return '.png';
    }
    return fileNameOrPath.substring(dotIndex);
  }

  private static normalizeExtension(extension: string): string {
    let normalizedValue: string = extension.trim().toLowerCase();
    if (normalizedValue.length === 0) {
      return '.png';
    }
    if (!normalizedValue.startsWith('.')) {
      return '.' + normalizedValue;
    }
    return normalizedValue;
  }

  private static getUtdCategoryByExtension(extension: string): string {
    if (['.png', '.jpg', '.jpeg', '.gif', '.heic', '.bmp', '.webp'].indexOf(extension) >= 0) {
      return utd.UniformDataType.IMAGE;
    }
    if (['.mp4', '.mov', '.m4v', '.avi'].indexOf(extension) >= 0) {
      return utd.UniformDataType.VIDEO;
    }
    return utd.UniformDataType.FILE;
  }
}

XHarmonyShareKit.ets

ts 复制代码
import { BusinessError } from '@kit.BasicServicesKit';
import { harmonyShare, systemShare } from '@kit.ShareKit';
import { window } from '@kit.ArkUI';
import { ShareUtil } from './ShareUtil';

export class XHarmonyShareKit {
  private static knockShareOwnerId: string = '';
  private static gesturesShareOwnerId: string = '';
  private static knockShareBindingVersion: number = 0;
  private static gesturesShareBindingVersion: number = 0;

  static registerKnockShare(ownerId: string, getShareData: () => Promise<systemShare.SharedData>): void {
    const normalizedOwnerId: string = ownerId.trim();
    if (normalizedOwnerId.length === 0) {
      console.warn('[XHarmonyShare] registerKnockShare skipped: empty ownerId');
      return;
    }
    const bindingVersion: number = ++XHarmonyShareKit.knockShareBindingVersion;
    XHarmonyShareKit.knockShareOwnerId = normalizedOwnerId;
    try {
      try {
        harmonyShare.off('knockShare');
      } catch (_error) {
      }
      harmonyShare.on('knockShare', async (sharableTarget: harmonyShare.SharableTarget) => {
        if (XHarmonyShareKit.knockShareBindingVersion !== bindingVersion ||
          XHarmonyShareKit.knockShareOwnerId !== normalizedOwnerId) {
          return;
        }
        try {
          const shareData: systemShare.SharedData = await getShareData();
          sharableTarget.share(shareData);
          console.info('[XHarmonyShare] 碰一碰分享成功');
        } catch (error) {
          console.error('[XHarmonyShare] 碰一碰分享失败:', error);
        }
      });
      console.info('[XHarmonyShare] 碰一碰分享监听已注册 owner=' + normalizedOwnerId);
    } catch (error) {
      let err = error as BusinessError;
      console.error('[XHarmonyShare] 注册碰一碰分享失败, code:', err.code, 'message:', err.message);
    }
  }

  static registerGesturesShare(
    ownerId: string,
    uiContext: UIContext,
    getShareData: () => Promise<systemShare.SharedData>
  ): void {
    if (!canIUse('SystemCapability.Collaboration.HarmonyShare')) {
      console.warn('[XHarmonyShare] 当前设备不支持手势分享功能');
      return;
    }
    const normalizedOwnerId: string = ownerId.trim();
    if (normalizedOwnerId.length === 0) {
      console.warn('[XHarmonyShare] registerGesturesShare skipped: empty ownerId');
      return;
    }
    const bindingVersion: number = ++XHarmonyShareKit.gesturesShareBindingVersion;
    XHarmonyShareKit.gesturesShareOwnerId = normalizedOwnerId;

    try {
      window.getLastWindow(uiContext.getHostContext()).then((data) => {
        if (XHarmonyShareKit.gesturesShareBindingVersion !== bindingVersion ||
          XHarmonyShareKit.gesturesShareOwnerId !== normalizedOwnerId) {
          return;
        }
        try {
          const mainWindowID: number = data.getWindowProperties().id;
          try {
            harmonyShare.off('gesturesShare', { windowId: mainWindowID });
          } catch (_error) {
          }
          harmonyShare.on('gesturesShare', { windowId: mainWindowID }, async (target: harmonyShare.SharableTarget) => {
            if (XHarmonyShareKit.gesturesShareBindingVersion !== bindingVersion ||
              XHarmonyShareKit.gesturesShareOwnerId !== normalizedOwnerId) {
              return;
            }
            try {
              const shareData: systemShare.SharedData = await getShareData();
              target.share(shareData);
              console.info('[XHarmonyShare] 手势分享成功');
            } catch (error) {
              console.error('[XHarmonyShare] 手势分享失败:', error);
            }
          });
          console.info('[XHarmonyShare] 手势分享监听已注册 owner=' + normalizedOwnerId);
        } catch (err) {
          let error = err as BusinessError;
          console.error('[XHarmonyShare] 获取窗口属性失败, code:', error.code, 'message:', error.message);
        }
      }).catch((error: BusinessError) => {
        console.error('[XHarmonyShare] 获取窗口失败, code:', error.code, 'message:', error.message);
      });
    } catch (error) {
      let err = error as BusinessError;
      console.error('[XHarmonyShare] 注册手势分享失败, code:', err.code, 'message:', err.message);
    }
  }

  static unregisterKnockShare(ownerId?: string): void {
    const normalizedOwnerId: string = ownerId !== undefined ? ownerId.trim() : '';
    if (normalizedOwnerId.length > 0 && XHarmonyShareKit.knockShareOwnerId !== normalizedOwnerId) {
      console.info(
        '[XHarmonyShare] 跳过注销碰一碰分享,owner不匹配 current=' +
        XHarmonyShareKit.knockShareOwnerId + ' request=' + normalizedOwnerId
      );
      return;
    }
    XHarmonyShareKit.knockShareBindingVersion = XHarmonyShareKit.knockShareBindingVersion + 1;
    XHarmonyShareKit.knockShareOwnerId = '';
    try {
      harmonyShare.off('knockShare');
      console.info('[XHarmonyShare] 碰一碰分享监听已注销');
    } catch (error) {
      let err = error as BusinessError;
      console.error('[XHarmonyShare] 注销碰一碰分享失败, code:', err.code, 'message:', err.message);
    }
  }

  static unregisterGesturesShare(ownerId: string, uiContext: UIContext): void {
    if (!canIUse('SystemCapability.Collaboration.HarmonyShare')) {
      return;
    }
    const normalizedOwnerId: string = ownerId.trim();
    if (normalizedOwnerId.length > 0 && XHarmonyShareKit.gesturesShareOwnerId !== normalizedOwnerId) {
      console.info(
        '[XHarmonyShare] 跳过注销手势分享,owner不匹配 current=' +
        XHarmonyShareKit.gesturesShareOwnerId + ' request=' + normalizedOwnerId
      );
      return;
    }
    const unregisterVersion: number = XHarmonyShareKit.gesturesShareBindingVersion + 1;
    XHarmonyShareKit.gesturesShareBindingVersion = unregisterVersion;
    XHarmonyShareKit.gesturesShareOwnerId = '';

    try {
      window.getLastWindow(uiContext.getHostContext()).then((data) => {
        if (XHarmonyShareKit.gesturesShareBindingVersion !== unregisterVersion ||
          XHarmonyShareKit.gesturesShareOwnerId.length > 0) {
          return;
        }
        try {
          const mainWindowID: number = data.getWindowProperties().id;
          harmonyShare.off('gesturesShare', { windowId: mainWindowID });
          console.info('[XHarmonyShare] 手势分享监听已注销');
        } catch (err) {
          let error = err as BusinessError;
          console.error('[XHarmonyShare] 注销手势分享失败, code:', error.code, 'message:', error.message);
        }
      }).catch((error: BusinessError) => {
        console.error('[XHarmonyShare] 获取窗口失败, code:', error.code, 'message:', error.message);
      });
    } catch (error) {
      let err = error as BusinessError;
      console.error('[XHarmonyShare] 注销手势分享失败, code:', err.code, 'message:', err.message);
    }
  }

  static createShareData(sandboxFilePath: string, title: string, description?: string): systemShare.SharedData {
    console.info('[XHarmonyShare] 分享文件路径:', sandboxFilePath);
    return ShareUtil.createShareDataFromFilePath(sandboxFilePath, title, description || '分享图片');
  }
}

index.ets

ts 复制代码
export * from './XHarmonyShareKit';
export * from './ShareUtil';

瞬间照片分享接入

ts 复制代码
import { systemShare } from '@kit.ShareKit';
import { ShareUtil, XHarmonyShareKit } from '../../kit/harmonyshare';

function buildMomentDetailShareOwnerId(): string {
  return 'moment-detail-page-' + Date.now().toString() + '-' + Math.floor(Math.random() * 1000000).toString();
}

@Component
export struct MomentDetailShareDemo {
  @State imageUri: string = '';
  imageIndex: number = 0;
  imageUris: Array<string> = [];
  imagePaths: Array<string> = [];
  momentTitle: string = '旅行瞬间';
  private readonly shareOwnerId: string = buildMomentDetailShareOwnerId();

  aboutToAppear(): void {
    this.unregisterShareCapabilities();
    XHarmonyShareKit.registerKnockShare(this.shareOwnerId, () => this.buildCurrentMomentShareData());
    XHarmonyShareKit.registerGesturesShare(
      this.shareOwnerId,
      this.getUIContext(),
      () => this.buildCurrentMomentShareData()
    );
  }

  aboutToDisappear(): void {
    this.unregisterShareCapabilities();
  }

  build(): void {
    Column({ space: 12 }) {
      Image(this.imageUri)
        .width('100%')
        .height(320)
        .objectFit(ImageFit.Cover)

      Button('系统分享')
        .onClick(() => {
          void this.shareCurrentMomentPhoto();
        })
    }
    .padding(16)
  }

  private unregisterShareCapabilities(): void {
    XHarmonyShareKit.unregisterKnockShare(this.shareOwnerId);
    XHarmonyShareKit.unregisterGesturesShare(this.shareOwnerId, this.getUIContext());
  }

  private async buildCurrentMomentShareData(): Promise<systemShare.SharedData> {
    let safeIndex: number = Math.max(0, Math.min(this.imageIndex, this.imageUris.length - 1));
    let shareTitle: string = this.momentTitle.length > 0 ? this.momentTitle : '瞬间照片';
    let localPath: string = safeIndex < this.imagePaths.length ? this.imagePaths[safeIndex] : '';
    if (localPath.length > 0) {
      return ShareUtil.createShareDataFromFilePath(
        localPath,
        shareTitle,
        '来自时光印记的瞬间照片'
      );
    }
    let uri: string = safeIndex < this.imageUris.length ? this.imageUris[safeIndex] : this.imageUri;
    if (uri.length > 0) {
      return ShareUtil.createShareDataFromUri(
        uri,
        uri,
        shareTitle,
        '来自时光印记的瞬间照片'
      );
    }
    throw new Error('current moment photo uri missing');
  }

  private async shareCurrentMomentPhoto(): Promise<void> {
    try {
      let shareData: systemShare.SharedData = await this.buildCurrentMomentShareData();
      await ShareUtil.showSharePanel(this.getUIContext(), shareData);
    } catch (_error) {
      this.getUIContext().getPromptAction().showToast({ message: '当前图片暂时无法分享' });
    }
  }
}

旅行计划链接分享接入

ts 复制代码
import { systemShare } from '@kit.ShareKit';
import { ShareUtil, XHarmonyShareKit } from '../../kit/harmonyshare';

function buildTravelPlanDetailShareOwnerId(): string {
  return 'travel-plan-detail-page-' + Date.now().toString() + '-' + Math.floor(Math.random() * 1000000).toString();
}

@Component
export struct TravelPlanLinkShareDemo {
  @State currentShareLink: string = '';
  @State planTitle: string = '五一杭州旅行';
  @State planDescription: string = '行程、清单、票据和预算都在这里';
  @State thumbnailUri: string = '';
  private readonly shareOwnerId: string = buildTravelPlanDetailShareOwnerId();

  aboutToAppear(): void {
    this.unregisterShareCapabilities();
    XHarmonyShareKit.registerKnockShare(this.shareOwnerId, () => this.buildTravelPlanShareData());
    XHarmonyShareKit.registerGesturesShare(
      this.shareOwnerId,
      this.getUIContext(),
      () => this.buildTravelPlanShareData()
    );
  }

  aboutToDisappear(): void {
    this.unregisterShareCapabilities();
  }

  build(): void {
    Column({ space: 12 }) {
      Text(this.planTitle)
        .fontSize(22)
        .fontWeight(FontWeight.Bold)

      Button('分享旅行计划')
        .onClick(() => {
          void this.shareTravelPlanLink();
        })
    }
    .padding(16)
  }

  private unregisterShareCapabilities(): void {
    XHarmonyShareKit.unregisterKnockShare(this.shareOwnerId);
    XHarmonyShareKit.unregisterGesturesShare(this.shareOwnerId, this.getUIContext());
  }

  private hasActiveShareLink(): boolean {
    return this.currentShareLink.trim().length > 0;
  }

  private async buildTravelPlanShareData(): Promise<systemShare.SharedData> {
    if (!this.hasActiveShareLink()) {
      throw new Error('分享链接暂未就绪,请先点击分享按钮生成链接');
    }
    return ShareUtil.createHyperlinkShareData(
      this.currentShareLink,
      this.planTitle,
      this.planDescription,
      this.thumbnailUri
    );
  }

  private async shareTravelPlanLink(): Promise<void> {
    if (!this.hasActiveShareLink()) {
      this.getUIContext().getPromptAction().showToast({ message: '请先生成有效的分享链接' });
      return;
    }
    try {
      let shareData: systemShare.SharedData = await this.buildTravelPlanShareData();
      await ShareUtil.showSharePanel(this.getUIContext(), shareData);
    } catch (_error) {
      this.getUIContext().getPromptAction().showToast({ message: '暂时无法打开分享面板' });
    }
  }
}

海拔打卡卡片分享接入

ts 复制代码
import { componentSnapshot } from '@kit.ArkUI';
import { systemShare } from '@kit.ShareKit';
import { ShareUtil, XHarmonyShareKit } from '../../kit/harmonyshare';

const ALTITUDE_SHARE_CANVAS_ID: string = 'altitudeShareCanvas';

function buildAltitudeShareOwnerId(): string {
  return 'altitude-checkin-page-' + Date.now().toString() + '-' + Math.floor(Math.random() * 1000000).toString();
}

@Component
export struct AltitudeShareDemo {
  @State altitude: number = 0;
  @State locationName: string = '定位中';
  private readonly shareOwnerId: string = buildAltitudeShareOwnerId();

  aboutToAppear(): void {
    this.unregisterShareCapabilities();
    XHarmonyShareKit.registerKnockShare(this.shareOwnerId, () => this.buildAltitudeShareData());
    XHarmonyShareKit.registerGesturesShare(
      this.shareOwnerId,
      this.getUIContext(),
      () => this.buildAltitudeShareData()
    );
  }

  aboutToDisappear(): void {
    this.unregisterShareCapabilities();
  }

  build(): void {
    Column({ space: 16 }) {
      Column({ space: 8 }) {
        Text(this.locationName)
          .fontSize(16)
        Text(`${this.altitude.toFixed(1)} m`)
          .fontSize(34)
          .fontWeight(FontWeight.Bold)
      }
      .id(ALTITUDE_SHARE_CANVAS_ID)
      .width('100%')
      .padding(24)
      .backgroundColor('#F3F4F6')
      .borderRadius(18)

      Button('分享打卡图')
        .onClick(() => {
          void this.shareAltitudeCard();
        })
    }
    .padding(16)
  }

  private unregisterShareCapabilities(): void {
    XHarmonyShareKit.unregisterKnockShare(this.shareOwnerId);
    XHarmonyShareKit.unregisterGesturesShare(this.shareOwnerId, this.getUIContext());
  }

  private async buildAltitudeShareData(): Promise<systemShare.SharedData> {
    let pixelMap = componentSnapshot.getSync(ALTITUDE_SHARE_CANVAS_ID);
    try {
      return await ShareUtil.buildImageShareDataFromPixelMap(
        this.getUIContext(),
        pixelMap,
        'altitude_checkin_share.png',
        '我的海拔打卡',
        '快来看看我的当前海拔!'
      );
    } finally {
      pixelMap.release();
    }
  }

  private async shareAltitudeCard(): Promise<void> {
    try {
      let shareData: systemShare.SharedData = await this.buildAltitudeShareData();
      await ShareUtil.showSharePanel(this.getUIContext(), shareData);
    } catch (_error) {
      this.getUIContext().getPromptAction().showToast({ message: '当前打卡图暂时无法分享' });
    }
  }
}

接入时我会注意的几个点

gesturesShare 最好先用 canIUse('SystemCapability.Collaboration.HarmonyShare') 判断能力。不是所有设备都支持 AI 隔空传送,能力不可用时不要影响页面正常分享。

碰一碰和隔空传送触发后,要尽快构造并调用 target.share(shareData)。如果分享内容依赖网络下载,建议先分享核心数据,复杂预览图可以后补;否则用户会看到等待或失败。

页面退出、返回、应用退后台时要注销监听。官方文档也强调离开可分享页面要 off('gesturesShare')。我在项目里至少保证 aboutToDisappear() 和自定义返回逻辑都会调用 unregisterShareCapabilities()

如果当前页面没有可分享内容,不要静默失败。当前代码里大多是 throw new Error(...) 后记录日志;如果要把体验继续打磨,可以在碰一碰场景里使用 sharableTarget.clarifyNonShare() 告诉用户"请在有照片或已生成分享链接后再试"。

临时分享文件要放在应用沙箱的分享缓存目录。比如海拔打卡、图片工厂这类由 PixelMap 生成的图片,我会写到 filesDir/time-imprint/share-cache,避免污染业务原始文件。

小结

这次接入后,《时光旅记》的分享链路变得更自然了。用户在看照片,就分享当前照片;在看旅行计划,就传旅行计划链接;在海拔打卡或图片创作页,就传当前生成的卡片或成片。

对开发来说,关键不是把 harmonyShare.on() 写出来,而是把"当前页面到底有什么可分享内容"抽成稳定的 getShareData()。碰一碰、AI 隔空传送、系统分享面板都复用这份数据,页面就不会被三套分享逻辑拖乱。

相关推荐
●VON9 小时前
鸿蒙Flutter实战:24小时新建标签提示组件
android·flutter·华为·harmonyos·鸿蒙
●VON9 小时前
鸿蒙Flutter实战:MultiProvider多状态管理架构实践
flutter·华为·架构·harmonyos·鸿蒙
不羁的木木10 小时前
HarmonyOS防窥保护实战:3步接入Device Security Kit保护用户隐私
华为·harmonyos
中国云报10 小时前
百年名校焕新光智底座,华为“领航”光智共融
华为
Swift社区11 小时前
鸿蒙 App 如何实现增量构建?
华为·harmonyos
●VON11 小时前
鸿蒙Flutter实战:放弃sqflite选纯Dart JSON文件存储
flutter·华为·json·harmonyos·鸿蒙
想你依然心痛11 小时前
HarmonyOS 6(API 23)智能体驱动的沉浸式AR应急指挥调度中心
华为·ar·harmonyos·智能体
科技快报11 小时前
打造标杆版本、加速设备上量,开源鸿蒙迈向产业规模化新阶段
华为·开源·harmonyos