HarmonyOS 7.0 尝鲜:轻触即定位,把分享变成一次直觉操作

刚参加完 HDC2026 回来,有个演示视频让我印象挺深:手机轻触电脑或平板屏幕,系统不止完成跨设备分享,还能识别目标窗口和触碰坐标,把手机里的图片、素材精准插到 PC 或平板的指定位置。在这里插入图片描述

以前聊“碰一碰分享”,更多是说“把文件从 A 设备送到 B 设备”。但 HarmonyOS 7.0(26.0.0)开始,轻碰事件里带的坐标信息把这事儿往前推了一步:它不光知道“传给谁”,还知道“碰到了哪里”。这意味着应用可以把一次分享变成一个明确的业务动作——比如把图片插到文档光标附近、把素材投到画布坐标点、或者替换窗口里某个被碰区域的内容。

这篇文章我们就来体验一下碰一碰·精准分享:先跑通基础的碰一碰文件分享,再在 PC/平板接收侧读轻碰坐标,最后把手机分享过来的图片插到目标位置。

1. 从 HDC2026 的演示说起:碰一碰不只是传文件

HDC2026 现场那个“碰一碰精准分享”的演示,交互链路非常短,用户不需要经历传统文件流转里的“发送、接收、打开、拖拽、插入”,实现了我想把这张图放这儿,手机触碰对应位置就可以分享了。

从开发角度看,背后其实是两段能力拼起来的:

  • 碰一碰文件分享:用 Share Kit 把手机端图片、视频、文件传到目标设备
  • 轻碰坐标获取:在 PC、二合一设备或平板侧,从回调事件里拿到基于屏幕左上角的 screenXscreenY
let receivableTargetInfo = receivableTarget.getInfo();
let screenX = receivableTargetInfo.coordinate?.screenX;
let screenY = receivableTargetInfo.coordinate?.screenY;

拿到坐标之后,分享就不再是“扔进下载目录”。它可以进入编辑器、文档、画板、白板、看板这些更具体的业务位置。

2. 工程利旧:画板应用迭代升级思路

在这里插入图片描述

我们基于一个6.0版本的demo进行更新适配,最终完成我们的迭代体验。

entry/src/main/ets
├── controller
│   └── KnockController.ets         # 碰一碰分享与接收核心控制器
├── entryability
│   └── EntryAbility.ets            # 应用入口、页面加载、窗口断点更新
├── model
│   └── FileData.ets                # 文件数据模型和示例资源
├── pages
│   └── Index.ets                   # 首页、文件初始化、选择列表、监听注册
└── utils
    ├── BreakpointSystem.ets        # 响应式断点工具
    └── FileUtil.ets                # MIME 判断、视频缩略图生成

要按 HarmonyOS 7.0(26.0.0)来做精准坐标插图,得关注三个改造点。

第一,版本能力要对齐。原示例配置里可能还是 6.0.0(20),要用 26.0.0 的轻碰坐标能力,需要把 SDK、DevEco Studio 和设备系统能力都切到对应版本。

第二,要支持平板,需要在模块设备类型里补上 tablet

"deviceTypes": [
  "phone",
  "2in1",
  "tablet"
]

第三,业务层要从“接收后展示文件列表”升级成“接收后按坐标插入”。这就需要在接收端维护一个目标区域,比如文档编辑区、画布区域,或者一组可插入的容器。

我这次实操的最小闭环是:

手机端选图
  -> 轻碰 PC/平板屏幕
  -> Share Kit 传图
  -> PC/平板 dataReceive 回调
  -> 读 screenX/screenY
  -> 文件落盘到沙箱
  -> 把屏幕坐标换算成窗口/画布坐标
  -> 在指定位置插图片

3. 跑通基础链路:文件从手机分享到 PC/平板

在这里插入图片描述

“精准插入”的前提,是文件的可靠传输。

3.1 先把资源变成可分享的 URI

在 rawfile 或 media 资源里的图片和视频,不能直接拿来跨设备分享。页面启动时,Index.ets 会先执行 handleFileInit(),把资源复制到应用沙箱目录,再用 fileUri.getUriFromPath() 转成 Share Kit 能用的 URI。

核心代码是这段:

let url = this.context.filesDir + '/' + item.url;
file = fileIo.openSync(url, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
fileIo.writeSync(file.fd, (this.context.resourceManager.getRawFileContentSync(item.url) as Uint8Array).buffer);
item.url = fileUri.getUriFromPath(url);

缩略图也一样:

let url = this.context.filesDir + '/' + item.thumbnail;
file = fileIo.openSync(url, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
fileIo.writeSync(file.fd, (this.context.resourceManager.getMediaContentSync(IMAGE_DATA[item.index].id) as Uint8Array).buffer);
item.thumbnail = fileUri.getUriFromPath(url);

有个容易踩的坑:分享文件时别把工程资源名直接塞给 SharedRecord.uri。Share Kit 需要的是接收端能处理的文件 URI,所以示例先把资源写入沙箱,再转 fileUri

3.2 页面勾选文件,AppStorage 存分享状态

页面把文件列表和已选文件索引放在 AppStorage 里:

@StorageLink('KnockFileShare_videoDataList') dataList: FileData[] = VIDEO_SOURCES;
@StorageLink('KnockFileShare_fileShare') fileShare: number[] = [];

勾选时只存文件下标:

Checkbox({ name: index + '', group: 'checkboxGroup' })
  .onChange((value: boolean) => {
    if (value) {
      this.fileShare.push(index);
    } else {
      this.fileShare.splice(this.fileShare.indexOf(index), 1);
    }
  })

这么做的优势是,页面只负责 UI 状态,真正分享时由 KnockController 统一读 fileSharevideoDataList。这个分层很适合继续扩展——后面要加坐标,只要在控制器回调里加就行。

3.3 注册 knockShare 监听

普通设备可以直接注册 knockShare

public immersiveListening() {
  if (canIUse('SystemCapability.Collaboration.HarmonyShare')) {
    harmonyShare.on('knockShare', (target: harmonyShare.SharableTarget) => {
      this.immersiveCallback(target);
    });
  }
}

PC/二合一侧建议绑定 windowId,让系统知道当前窗口参与轻碰:

window.getLastWindow(this.context).then((data) => {
  let mainWindowID: number = data.getWindowProperties().id;
  harmonyShare.on('knockShare', { windowId: mainWindowID }, (target: harmonyShare.SharableTarget) => {
    this.immersiveCallback(target);
  });
});

3.4 组装 SharedData 并分享

轻碰触发后,发送端从 AppStorage 里拿到已选文件,逐个转成 SharedRecord,再组装成 SharedData

public immersiveCallback(target: harmonyShare.SharableTarget) {
  let fileShare = AppStorage.get('KnockFileShare_fileShare') as number[];
  let videoDataList = AppStorage.get('KnockFileShare_videoDataList') as FileData[];
  if (!fileShare || fileShare.length === 0) {
    return;
  }

  let shareData: systemShare.SharedData =
    new systemShare.SharedData(this.getShareRecord(videoDataList[fileShare[0]]));

  for (let i = 1; i < fileShare.length; i++) {
    shareData.addRecord(this.getShareRecord(videoDataList[fileShare[i]]));
  }

  target.share(shareData);
}

每个文件记录这样生成:

getShareRecord(data: FileData): systemShare.SharedRecord {
  let suffix = '.' + data.url.split('.').pop();
  let utd = uniformTypeDescriptor.getUniformDataTypeByFilenameExtension(suffix);
  return {
    utd: utd,
    uri: data.url,
    thumbnailUri: data.thumbnail,
    title: data.name,
    description: data.description
  };
}

这一步有两个重点:

  • uri 是真实文件 URI
  • utd 建议通过扩展名推导

做到这儿,碰一碰文件分享的发送端就跑通了。

4. 接收端落盘:dataReceive 是精准插图的入口

在这里插入图片描述

PC/二合一或平板要实现“收到文件并插到指定位置”,入口是 harmonyShare.on('dataReceive')

接收监听是这样注册的:

harmonyShare.on('dataReceive', {
  windowId: mainWindowID,
  capabilities: [
    {
      'utd': uniformTypeDescriptor.UniformDataType.MEDIA,
      'maxSupportedCount': 5
    },
    {
      'utd': uniformTypeDescriptor.UniformDataType.FILE,
      'maxSupportedCount': 5
    }
  ]
}, (receiveTarget: harmonyShare.ReceivableTarget) => {
  receiveTarget.receive(fileUri.getUriFromPath(this.context.filesDir), {
    onDataReceived: (shareData: systemShare.SharedData) => {
      // 处理收到的数据
    },
    onResult(resultCode: harmonyShare.ShareResultCode) {
      // 处理接收结果
    }
  });
});

capabilities 表示接收端支持什么类型、最多收几个。比如这里只声明支持媒体和普通文件,最多 5 个。如果只做“手机传一张图到 PC 指定位置”,可以收窄成图片能力:

capabilities: [
  {
    utd: uniformTypeDescriptor.UniformDataType.IMAGE,
    maxSupportedCount: 1
  }
]

接收后,通过 shareData.getRecords() 拿到文件记录。原示例会按类型处理缩略图,然后追加到页面列表:

let shareRecords = shareData.getRecords();
let videoDataList = AppStorage.get('KnockFileShare_videoDataList') as FileData[];
shareRecords.forEach(async (record: systemShare.SharedRecord) => {
  if (!record.uri) {
    return;
  }

  let fileName = record.uri.split('/').pop()?.split('.')[0];
  let thumbPath: string = videoDataList[0].thumbnail;

  if (record.uri.endsWith('mp4') || record.uri.endsWith('mkv')) {
    thumbPath = record.uri.slice(0, record.uri.lastIndexOf('.')) + 'thumb.png';
    let result = await new FileUtil().getVideoThumbnail(record.uri, thumbPath);
    if (!result) {
      thumbPath = videoDataList[0].thumbnail;
    }
  } else if (record.uri.endsWith('png') || record.uri.endsWith('jpg') || record.uri.endsWith('jpeg')) {
    thumbPath = record.uri;
  }

  videoDataList.push({
    url: record.uri,
    name: fileName,
    description: record.description,
    thumbnail: thumbPath,
    index: videoDataList.length
  });
});

如果目标是“文档指定位置插图”,这段逻辑可以拆成两步:

  1. 文件落盘和类型处理照常保留
  2. 不再只是 videoDataList.push(),而是调用 insertImageAt(uri, x, y)

也就是说,dataReceive 是文件进应用的入口,coordinate 是业务落位的入口。两者合在一起,才是精准分享。

5. 精准定位:从 screenX/screenY 到指定位置插图

现在进入 26.0.0 的重点。

从 26.0.0 版本开始,手机与 PC/二合一、手机与平板触发轻碰事件时,在 PC/二合一或平板侧可以从回调事件里获取轻碰位置。坐标以屏幕左上角为原点:

let receivableTargetInfo = receivableTarget.getInfo();
let screenX = receivableTargetInfo.coordinate?.screenX;
let screenY = receivableTargetInfo.coordinate?.screenY;

发送侧也能从 SharableTarget 获取信息:

let sharableTargetInfo = sharableTarget.getInfo();
let screenX = sharableTargetInfo.coordinate?.screenX;
let screenY = sharableTargetInfo.coordinate?.screenY;

但在“手机到 PC 指定位置插图”这个场景里,我更关心接收侧坐标,因为最终插入动作发生在 PC/平板侧。

5.1 在 dataReceive 回调里先缓存落点

可以把接收回调改成这样:

private dataReceiveCallback = (receivableTarget: harmonyShare.ReceivableTarget) => {
  let targetInfo = receivableTarget.getInfo();
  let screenX = targetInfo.coordinate?.screenX;
  let screenY = targetInfo.coordinate?.screenY;

  if (screenX === undefined || screenY === undefined) {
    // 兜底策略:插到当前光标、画布中心或最近一次触点
    screenX = this.lastInsertX;
    screenY = this.lastInsertY;
  }

  receivableTarget.receive(this.sandboxUri, {
    onDataReceived: (sharedData: systemShare.SharedData) => {
      this.handleReceivedData(sharedData, screenX, screenY);
    },
    onResult: (resultCode: harmonyShare.ShareResultCode) => {
      if (resultCode === harmonyShare.ShareResultCode.SHARE_SUCCESS) {
        // 接收成功,刷新 UI 或上报埋点
      }
    }
  });
}

这里把坐标和 sharedData 绑在同一个处理函数里,避免后面异步处理时丢掉落点。

5.2 屏幕坐标不能直接当组件坐标用

screenX/screenY 是屏幕坐标,图片要插入的是窗口内部的某个区域。中间至少要做一次坐标转换:

screenX/screenY
  -> 减去窗口左上角 windowLeft/windowTop
  -> 得到窗口内坐标 windowX/windowY
  -> 减去编辑区左上角 editorLeft/editorTop
  -> 得到编辑区坐标 editorX/editorY

伪代码可以这样写:

interface Point {
  x: number;
  y: number;
}

interface Rect {
  left: number;
  top: number;
  width: number;
  height: number;
}

function toEditorPoint(screenX: number, screenY: number, windowRect: Rect, editorRect: Rect): Point {
  const windowX = screenX - windowRect.left;
  const windowY = screenY - windowRect.top;
  return {
    x: windowX - editorRect.left,
    y: windowY - editorRect.top
  };
}

换算完还要做边界判断:

function isInRect(point: Point, rect: Rect): boolean {
  return point.x >= 0 &&
    point.y >= 0 &&
    point.x <= rect.width &&
    point.y <= rect.height;
}

这一步决定业务体验。坐标落在编辑区,就插图片;落在工具栏,就弹导入菜单;落在侧边栏,就加到素材库。

5.3 把收到的图片插到指定位置

收到文件后,先取记录:

private handleReceivedData(sharedData: systemShare.SharedData, screenX: number, screenY: number) {
  let records = sharedData.getRecords();
  records.forEach((record: systemShare.SharedRecord) => {
    if (!record.uri) {
      return;
    }
    this.insertImageFromKnock(record.uri, screenX, screenY);
  });
}

然后做坐标转换并插入:

private insertImageFromKnock(uri: string, screenX: number, screenY: number) {
  const editorPoint = toEditorPoint(screenX, screenY, this.windowRect, this.editorRect);

  if (!isInRect(editorPoint, this.editorRect)) {
    this.addToMaterialPanel(uri);
    return;
  }

  this.images.push({
    uri,
    x: editorPoint.x,
    y: editorPoint.y,
    width: 160,
    height: 120
  });
}

如果是 ArkUI 页面,最简演示可以用 Stack 承载图片,通过 position() 或相对布局放到指定位置:

Stack() {
  ForEach(this.images, (item: InsertedImage) => {
    Image(item.uri)
      .width(item.width)
      .height(item.height)
      .position({ x: item.x, y: item.y })
  })
}
.width('100%')
.height('100%')

这样,一个“手机轻碰 PC 指定位置插图”的最小闭环就跑通了。

5.4 整理核心调用链

把它放回当前工程,核心调用链可以整理成:

Index.aboutToAppear()
  -> handleFileInit()
  -> KnockController.getInstance(context)
  -> 二合一/平板注册 knockShare + dataReceive

手机端勾选图片
  -> fileShare 存已选 index
  -> 轻碰触发 knockShare
  -> SharedData 带图片 URI
  -> target.share(shareData)

PC/平板侧接收
  -> dataReceive 回调
  -> receivableTarget.getInfo()
  -> 读 coordinate.screenX / screenY
  -> receive(sandboxUri)
  -> onDataReceived(sharedData)
  -> getRecords()
  -> 坐标转换
  -> insertImageFromKnock(uri, x, y)

这条链路比单纯文件分享多了三步:读坐标、转坐标、执行业务插入。
在实操的过程中,需要注意:
第一,坐标可能为空。

this.dataReceiveScreenX = receivableTargetInfo.coordinate?.screenX;
this.dataReceiveScreenY = receivableTargetInfo.coordinate?.screenY;

真实业务不能假设它永远有值。比较稳的兜底是:没有坐标时插当前光标位置、编辑区中心,或者回退成普通文件接收。

第二,坐标是屏幕坐标。 屏幕坐标要经过窗口偏移、组件偏移、滚动偏移,才能变成业务坐标。文档编辑器尤其要留意滚动位置。

第三,能力声明要收敛。 接收端如果只做图片插入,就不要上来就声明支持所有文件:

capabilities: [
  {
    utd: uniformTypeDescriptor.UniformDataType.IMAGE,
    maxSupportedCount: 1
  }
]

这样用户体验更明确,异常处理也简单。

总结

最后总结一下这次实操:

碰一碰精准分享不是一个单点 API,而是一套新的跨设备交互方式。它的基础是文件分享,核心是目标识别和坐标,最终价值落在业务动作上。

如果说传统分享解决的是“内容怎么跨设备传输”,那 HarmonyOS 7.0 的轻碰坐标能力解决的就是“内容应该如何场景化传递”。这也是我看完 HDC2026 演示后,第一时间想把它跑通的原因。

Logo

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

更多推荐