【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 隔空传送、系统分享面板都复用这份数据,页面就不会被三套分享逻辑拖乱。

相关推荐
梦想不只是梦与想17 小时前
HarmonyOS应用分层架构设计
harmonyos·分层架构·一次开发,多端部署
MardaWang20 小时前
当滚动逃离了框架 ——HarmonyOS Web 与原生混排滚动的冲突本质与解法
harmonyos·arkts·鸿蒙·deveco studio
tsqtsqtsq030921 小时前
DevEco Studio 介绍
harmonyos
HwJack201 天前
【共创稿事节】HarmonyOS 7文旅展陈展厅大空间 3DGS 重建的分块策略与拼接踩坑
3d·华为·harmonyos
熊猫钓鱼>_>1 天前
Kotlin Multiplatform for OpenHarmony 实战:为 kotlin-inject 实现依赖注入适配
开发语言·华为·kotlin·ai编程·inject·鸿蒙·openharmony
m0_738185821 天前
Flutter 鸿蒙化实战:media_info 适配 OpenHarmony,媒体信息与缩略图
flutter·华为·harmonyos·鸿蒙·媒体
m0_738185821 天前
Flutter 鸿蒙化实战:just_audio 适配 OpenHarmony,功能强大的播放器
flutter·华为·harmonyos·鸿蒙
翼辉cto1 天前
Kotlin Multiplatform 三方库 SQLDelight 的 OpenHarmony 鸿蒙化适配实战
开发语言·kotlin·harmonyos
SuperHeroWu71 天前
TraeCode 国内版接入 DevEco CLI:用官方知识开发鸿蒙应用
ai编程·harmonyos·知识库·trae·aicoding·skills·deveco cli