精准碰一碰把"分享给哪个应用"继续推进到了"落到页面哪个位置"。这一步看起来只是多拿两个坐标,真正接入编辑器、画布或看板后,却会遇到一个很容易被忽略的问题:系统告诉应用的是屏幕触点,业务真正需要的是内容坐标。窗口可能被移动、分屏、缩放,页面内部还可能存在标题栏、画布偏移、平移和缩放。直接把 screenX、screenY 交给业务,坐标值完全合法,落点却可能偏出几百像素。
本文用 TapCanvas 演示工程拆解这条换算链。示例任务为 TAP-COORD-0076,事件 ID 为 tap-e41。示例数据用于说明工程方法,不冒充实机验证结果;设备支持范围、能力申请和最终接口签名应以当前官方文档与 SDK 为准。

一、接收成功不等于落点正确
TapCanvas 的页面叫 PreciseDropPage。右侧是可缩放的海报画布,用户把手机中的图片碰到平板或 PC 屏幕,希望素材落在触点附近。初版实现监听接收事件,取出坐标后调用 insertAsset(x, y)。在全屏、100% 缩放、画布从左上角开始时,这段代码看起来没有问题。
窗口进入分屏后,异常就出现了。屏幕触点为 (1486, 742),应用窗口实际位于 (620, 140),尺寸为 1320 × 980。如果业务仍把 (1486, 742) 当成窗口内坐标,横向值已经超过窗口宽度。更隐蔽的情况是数值仍落在画布边界内,但对应的是另一块内容区域,图片会被插到错误图层,用户只看到"碰一下,素材跳到了别处"。
这不是 Share Kit 传错了位置,而是应用省略了坐标空间转换。官方精准分享能力让接收端能够获得触碰位置信息;应用还需要结合当前窗口、页面布局和业务视图完成解释。系统坐标是事实,业务落点是应用决策,两者之间不能用一次减法草率连接。
本例把整个过程记录成五个事实:接收时的屏幕坐标、窗口矩形 revision、页面密度、画布视口 revision、最终内容坐标。只要其中一个事实来自旧布局,本次落点就拒绝提交,而不是猜一个大概位置。
二、先固定本次演示的坐标合同
本次演示统一使用如下数据。接收时间显示为 13:18,电量 73%。系统触点是 (1486, 742) px;接收窗口矩形为 left=620、top=140、width=1320、height=980,窗口 revision 为 17。页面密度按 1.25 px/vp 记录,因此窗口局部坐标是 (866, 602) px,换算后为 (692.8, 481.6) vp。
画布在窗口内容区的原点是 (40, 96) vp,当前平移为 (-80, -32) vp,缩放值为 1.20,视图 revision 为 23。换算后的画布内容坐标为 (610.67, 348.00),提交时按业务策略显示为 (611, 348)。画布内容边界是 1600 × 900,该点处于有效区域,所以状态进入 ACCEPTED。
这里特意保留小数到提交前。触点坐标、窗口边界和画布变换都可能产生小数;如果每一步都取整,误差会随着缩放放大。只有业务真正要把素材吸附到像素网格或卡片槽位时,才做最后一次舍入。
状态机固定为:RECEIVED → WINDOW_MAPPED → VIEW_MAPPED → ACCEPTED。拒绝分支包括 STALE_WINDOW_REV、STALE_VIEW_REV、OUT_OF_WINDOW、OUT_OF_CANVAS 和 EXCLUDED_ZONE。拒绝不是失败兜底,而是精准交互的一部分:宁可提示用户重新触碰,也不要把内容插到错误对象上。
为了避免讨论时把"坐标"混成一个概念,工程里给四个空间明确命名。screenPx 是显示设备物理像素空间;windowPx 是应用窗口局部像素空间;localVp 是 ArkUI 页面布局空间;contentPoint 是画布自己的逻辑空间。变量名必须带单位或空间后缀,禁止出现含义不明的 x、y 跨层传递。代码评审看到 screenX 直接进入 insertAsset,就能立即判定缺少转换。
revision 也不是简单自增计数。窗口位置、窗口尺寸、显示设备、密度任一变化,都生成新的 window revision;画布原点、平移、缩放、滚动或文档切换任一变化,都生成新的 view revision。动画过程可以产生多个中间 revision,但只有布局进入稳定态的 revision 才允许接收精准落点。这样做会牺牲极少数动画中的触碰,却能把不可重复的漂移变成明确拒绝。
三、把屏幕坐标换算成窗口局部坐标
这段代码解决什么问题。 它把接收事件携带的屏幕像素坐标,与事件发生时记录的窗口矩形绑定,先验证 revision,再得到窗口局部坐标;任何旧窗口快照都不会继续参与计算。
arkts
interface ScreenPoint {
xPx: number
yPx: number
}
interface WindowSnapshot {
leftPx: number
topPx: number
widthPx: number
heightPx: number
density: number
revision: number
}
interface LocalPoint {
xVp: number
yVp: number
}
class WindowCoordinateGate {
map(point: ScreenPoint, eventRevision: number,
current: WindowSnapshot): LocalPoint {
if (eventRevision !== current.revision) {
throw new Error('STALE_WINDOW_REV')
}
const xPx = point.xPx - current.leftPx
const yPx = point.yPx - current.topPx
if (xPx < 0 || yPx < 0 ||
xPx >= current.widthPx || yPx >= current.heightPx) {
throw new Error('OUT_OF_WINDOW')
}
return {
xVp: xPx / current.density,
yVp: yPx / current.density
}
}
}
这里不直接读取"此刻"的窗口矩形,而是要求协调器提供与接收事件配套的快照。原因是窗口变化和分享回调来自不同事件源:用户碰下去之后,系统分栏比例可能恰好变化,回调抵达时再读取窗口,会把旧触点和新布局拼在一起。
状态从 RECEIVED 进入 WINDOW_MAPPED。易错点有三个。第一,不要假定屏幕坐标单位和 ArkUI 布局单位相同;本例明确先在像素域做窗口裁剪,再除以快照中的 density。第二,窗口矩形必须包含左上偏移,不能只拿宽高。第三,右边界和下边界使用半开区间,避免恰好位于边界的点进入下游。
实际项目还要记录窗口来源。多窗口、外接屏或跨屏场景下,显示设备和窗口属于同一份快照才有意义。若业务无法证明两者一致,应把事件标为不可精确落位,退化成"打开接收面板,让用户确认",而不是偷偷使用主屏参数。
还要注意安全区域不等于窗口偏移。状态栏、导航条、圆角和应用内容区可能影响可交互范围,但它们不一定改变系统报告的窗口矩形。正确做法是先完成屏幕到窗口的几何变换,再让页面布局快照描述内容区起点。把安全区 inset 既扣在窗口层、又扣在页面层,会出现稳定的双重偏移,这类错误在全屏设备上不明显,到分屏或横屏才暴露。
四、窗口坐标还不是画布坐标
页面中通常有标题栏、工具条、侧边栏和滚动容器。PreciseDropPage 的画布原点是 (40, 96) vp,而内容又经过平移、缩放。窗口局部点必须先减去画布原点和当前平移,再除以缩放值,才能得到海报内容中的逻辑位置。
这段代码解决什么问题。 它把窗口局部坐标投影到画布内容坐标,并同时检查画布 revision、缩放范围、业务边界和排除区域。
arkts
interface CanvasSnapshot {
originXVp: number
originYVp: number
panXVp: number
panYVp: number
zoom: number
contentWidth: number
contentHeight: number
revision: number
}
interface ContentPoint {
x: number
y: number
}
class CanvasCoordinateGate {
map(local: LocalPoint, eventRevision: number,
canvas: CanvasSnapshot): ContentPoint {
if (eventRevision !== canvas.revision) {
throw new Error('STALE_VIEW_REV')
}
if (!Number.isFinite(canvas.zoom) || canvas.zoom < 0.5 || canvas.zoom > 4) {
throw new Error('INVALID_ZOOM')
}
const x = (local.xVp - canvas.originXVp - canvas.panXVp) / canvas.zoom
const y = (local.yVp - canvas.originYVp - canvas.panYVp) / canvas.zoom
if (x < 0 || y < 0 || x >= canvas.contentWidth || y >= canvas.contentHeight) {
throw new Error('OUT_OF_CANVAS')
}
return { x, y }
}
}
代入本次数据,x=(692.8-40-(-80))/1.20=610.67,y=(481.6-96-(-32))/1.20=348.00。状态进入 VIEW_MAPPED。这里的 panX、panY 定义为内容向屏幕方向的平移,所以公式中直接减去;如果项目使用"相机位置"语义,符号可能相反,必须用一组可手算的黄金向量锁定定义。
排除区不应该只用一个大矩形描述。工具条虽然覆盖在画布上方,但视觉上仍属于页面,触点落入工具条时不能插图。本例把工具条、缩放手柄和只读图层分别记录为命中区域,映射完成后逐一判断。这样的拒绝理由能在诊断页显示,也方便后续统计是布局问题还是用户确实碰错位置。
五、事件接收与业务提交必须分层
Share Kit 的职责是把分享数据和接收上下文交给应用;Window Kit 提供当前窗口事实;画布模块维护自己的视图变换。把三者都写进页面回调,会让页面销毁、窗口变化和异步文件解析互相缠绕。本例用 PreciseDropCoordinator 串联,但不让任何一层越权。
这段代码解决什么问题。 它建立一次性事件快照,按照固定顺序完成窗口映射、画布映射、排除区检查和幂等提交,并把拒绝原因写入统一诊断记录。
arkts
interface TapReceiveEvent {
eventId: string
taskId: string
screen: ScreenPoint
windowRevision: number
viewRevision: number
assetUri: string
}
class PreciseDropCoordinator {
constructor(
private windowGate: WindowCoordinateGate,
private canvasGate: CanvasCoordinateGate,
private repository: DropRepository) {}
async accept(event: TapReceiveEvent,
win: WindowSnapshot, canvas: CanvasSnapshot): Promise<void> {
if (await this.repository.hasEvent(event.eventId)) {
throw new Error('DUPLICATE_EVENT')
}
const local = this.windowGate.map(event.screen, event.windowRevision, win)
const content = this.canvasGate.map(local, event.viewRevision, canvas)
if (this.repository.isExcluded(content)) {
throw new Error('EXCLUDED_ZONE')
}
await this.repository.commit({
eventId: event.eventId,
assetUri: event.assetUri,
x: Math.round(content.x),
y: Math.round(content.y)
})
}
}
状态只有在 repository.commit() 成功后才进入 ACCEPTED。文件接收完成但落点未提交时,UI 显示 REVIEW_REQUIRED,而不是伪装成成功。幂等键使用系统事件和业务任务组合得到的稳定 ID;本文重点不是重复消费,但仍保留最小防线,避免用户重新触碰后产生两份素材。
容易出错的地方是把页面当前状态直接传入异步函数。文件解析可能持续数百毫秒,等到提交时,画布已缩放或切换文档。工程上要么在接收时冻结 revision,并在提交前再次校验;要么重新计算落点并要求用户确认。不能在没有说明的情况下把旧触点套到新画布。
提交过程也需要事务语义。素材文件写入缓存、生成缩略图、创建画布节点和更新文档索引可能分成多个异步步骤。DropRepository.commit 应先准备临时节点,全部成功后再把节点挂入文档;任一步失败,都删除临时文件并释放 URI。否则页面可能没有看到素材,文档索引里却留下半条记录,下次打开时才暴露损坏。
本文的 eventId=tap-e41 既用于幂等,也用于回滚定位。提交表先写 PREPARING,成功后改为 COMMITTED;重启时发现长期停留在 PREPARING 的记录,可以安全清理。若同一事件再次抵达并且已有 COMMITTED,返回已有节点位置,而不是重复插入。这里的幂等是落点链的保护网,不改变触点坐标的有效性判断。

图中的 DevEco Studio 画面是与本文数据对应的演示配图,不作为真实 IDE 或设备测试证据。中间代码突出 windowRevision=17、viewRevision=23 与换算结果 (611, 348);右侧模拟器保持窗口在屏幕中的偏移,底部 HiLog 显示同一任务 TAP-COORD-0076。
六、页面生命周期要管理监听与快照
坐标算法正确,不代表页面生命周期就安全。页面反复进入时重复注册接收监听,会让同一个事件被多个协调器处理;页面离开后保留旧监听,又会拿着已销毁画布的 revision 继续提交。本例把监听、窗口变化监听和画布快照订阅当成一个租约。
这段代码解决什么问题。 它确保接收监听与页面可用期成对存在,页面离开时先失效 generation,再注销监听,迟到回调只能被拒绝。
arkts
@Entry
@Component
struct PreciseDropPage {
@State status: string = 'READY'
private generation: number = 0
private receiver?: TapReceiverLease
aboutToAppear(): void {
const current = ++this.generation
this.receiver = TapReceiverLease.open(async (event: TapReceiveEvent) => {
if (current !== this.generation) {
return
}
this.status = 'RECEIVED'
await DropRuntime.accept(event)
if (current === this.generation) {
this.status = 'ACCEPTED'
}
})
}
aboutToDisappear(): void {
this.generation++
this.receiver?.close()
this.receiver = undefined
this.status = 'CLOSED'
}
}
先递增 generation,再执行 close(),是为了封住注销过程中的竞态。某些回调已经进入事件队列,即使监听注销成功,也可能稍后执行;它看到 generation 不一致就立即返回。资源释放要成对:注册/注销、窗口监听 on/off、文件句柄打开/关闭、临时 URI 授权申请/释放,都应该由同一所有者负责。
实际工程不能把能力监听的接口名、事件名写死在业务层。本文的 TapReceiverLease 是应用适配层,具体接入需按当前 Share Kit 文档实现。这样 API 发生变更时,只调整适配层,坐标合同、诊断字段和业务测试向量仍可复用。
七、运行页只展示可验证的事实

运行页顶部显示 13:18、5G、Wi-Fi、信号与 73% 电量。任务卡显示 TAP-COORD-0076 和事件 tap-e41;屏幕触点为 (1486, 742) px,窗口矩形为 (620, 140, 1320, 980),换算后的窗口局部点为 (866, 602) px。画布上的红色圆圈落在 (611, 348),状态为 ACCEPTED。
页面没有把"收到文件"写成"精准落位完成"。四段状态分别可见,用户和开发者都能知道停在哪一步。若 windowRevision 不一致,页面显示 REVIEW_REQUIRED 并提供重新触碰按钮;若点落在工具条,则显示 EXCLUDED_ZONE,不会把素材强行吸附到最近位置。
演示中一次采集了 6 个事件,其中 4 个接受、1 个因旧窗口 revision 拒绝、1 个因超出画布拒绝。这个统计不用于宣称系统性能,而是验证应用自己的门禁路径是否都可观察。验收时更重要的不是"成功率越高越好",而是每个拒绝能否解释、能否恢复、是否没有产生半提交数据。
八、诊断页把坐标链完整摊开

详情页时间仍为 13:18,电量仍为 73%。它按顺序展示:screen(1486,742)、window local(866,602)、local vp(692.8,481.6)、canvas origin(40,96)、pan(-80,-32)、zoom 1.20、content(610.67,348.00),以及最终提交 (611,348)。windowRevision=17、viewRevision=23 同时标记为 MATCH。
红色箭头只标两个关键点:屏幕到窗口的偏移扣除,以及窗口到画布的逆变换。诊断图不把每个数值都圈起来,因为标注过多会掩盖因果链。真正要看的,是每一步输入来自哪份快照、输出进入哪个坐标空间。
异常恢复也在这页说明。STALE_WINDOW_REV 不重算旧事件,要求用户重碰;OUT_OF_CANVAS 可以打开预览,让用户手动选择落点;EXCLUDED_ZONE 保留接收素材,但不写入文档;文件解析失败则回滚临时资源。不同原因对应不同动作,不能统一弹一句"分享失败"。
诊断页还应展示"本次使用哪一份布局",而不是只展示最终数值。窗口 revision 17 对应窗口矩形和 density,视图 revision 23 对应画布原点、pan、zoom 和文档 ID。若研发只截到一张错误落点图片,也能用两个 revision 在 HiLog 中还原当时快照。没有版本号的坐标日志,通常只能证明曾经算出一个数字,无法证明数字来自同一时刻。
用户恢复路径要尽量短。旧 revision 被拒绝时,保留已接收素材的临时预览,提示"窗口刚刚变化,请再次碰触确定位置";落在排除区时,可让用户点选附近画布位置;落在窗口外则不提供自动修正,因为应用无法确认目标窗口。恢复动作与拒绝原因一一对应,可以避免所有异常都退化成重新传输文件。
九、验证矩阵要覆盖移动、缩放与迟到回调
只测全屏是不够的。坐标门禁至少要覆盖:窗口左上角不为零、分栏比例变化、系统显示缩放变化、画布缩放和平移、标题栏高度变化、页面滚动、接收期间窗口移动、接收后立即退出页面、同一事件重复抵达、触点落在边界和排除区。
自动化测试可以从纯函数开始。为 WindowCoordinateGate 和 CanvasCoordinateGate 准备黄金向量,输入固定快照,断言输出或拒绝码。本文主向量必须得到 (610.67,348.00);把窗口 revision 从 17 改成 18,应得到 STALE_WINDOW_REV;把屏幕 X 改成 1940,应在窗口或画布边界被拒绝,具体取决于快照。
UI 测试再验证生命周期:进入页面一次只存在一个监听;离开页面后迟到回调不改变状态;重新进入后 generation 增加,新事件可以正常提交。最后才在支持设备上验证真实碰一碰链路。模拟器可以验证坐标算法和页面状态,但不能冒充精准碰一碰实机能力测试。
日志要保持短而可关联。本例使用 taskId、eventId、两个 revision、拒绝码和最终坐标,不记录用户文件内容。诊断数据如果要持久化,应设置保留期限并去除敏感 URI。坐标本身也可能透露用户操作区域,不应无限期收集。
性能测试不能只看一次映射耗时。真正可能阻塞交互的是接收回调里同步解析大文件、创建 PixelMap 或刷新整棵画布树。坐标换算应保持纯计算,文件准备放到受控异步队列;UI 只在提交完成时插入一个节点。高频窗口变化期间可以合并快照更新,却不能合并不同 eventId 的业务提交。
为了验证红线,建议增加一次故意失败的演练:触碰后立刻拖动分栏,使 window revision 从 17 变为 18。期望结果不是素材跟着窗口漂移,而是 STALE_WINDOW_REV,文档节点数保持不变,临时素材仍可人工确认。这个测试比"正常碰一下成功"更能证明门禁确实存在。
十、边界与工程取舍
精准碰一碰最有价值的地方,是把跨设备分享从"打开一个入口"变成"对准一个对象"。但系统提供触点后,应用仍要承担坐标解释、版本一致性、拒绝策略和生命周期管理。本文的核心不是一套万能公式,而是一条可审计的转换链:屏幕坐标不能越级进入业务,窗口事实和画布事实必须来自明确 revision。
这条链也便于团队分工。能力接入负责保留原始事件,窗口模块负责稳定快照,画布模块负责可逆变换,仓储层负责原子提交,页面只展示事实。任何一层都不需要猜测另一层的隐含状态,问题发生时也能沿 revision 和 eventId 定位到具体边界。
当应用无法获得可靠窗口快照、显示设备发生切换、布局正在动画中,或者画布变换尚未稳定时,最稳妥的策略是拒绝精准提交,退化到人工确认。不要为了让演示显得顺滑而吞掉不确定性。精准交互的底线不是"每次都落下去",而是"落下去时能证明位置正确"。
官方参考: