鸿蒙PC 跨窗口拖拽与剪贴板接力实战:在 ArkUI 跨平台框架下拆解 2in1 桌面的命中测试、暂存中转与投递规则门禁
鸿蒙PC 跨窗口拖拽与剪贴板接力实战:在 ArkUI 跨平台框架下拆解 2in1 桌面的命中测试、暂存中转与投递规则门禁
欢迎加入开源鸿蒙PC社区: https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
前言:桌面操作系统的「基础交互」从来不是漂亮的动画,而是四件很朴素的事情:能把东西拖起来、能判断你拖到了哪里、能在半路把东西先放一放、能在放开手之前反悔。手机形态的鸿蒙应用很少需要正面回答这四个问题,因为屏幕里通常只有一个全屏页面;而在鸿蒙PC 的 2in1 桌面形态下,用户天然会同时开着多个窗口,把一段摘录从画布窗口递到阅读器窗口、把一张封面先暂存起来、把一个大文件先确认类型再落位。本文以鸿蒙PC 真机上运行的「接力 · Relay」跨窗口拖放实验台为样本,用纯原生 ArkTS 与 ArkUI 声明式 UI 把这条链路完整拆开:手势如何起步、命中测试为什么用卡片中心而不是指针位置、插入索引怎么算、剪贴板中转为什么必须限流、规则门禁如何在不改 UI 的前提下改变数据流,以及日志里那些毫秒数到底能不能当性能数据用。文章后半部分把 ArkUI 跨平台框架 作为对照轴,说明哪些能力是桌面形态特有的、哪些是可以被跨平台框架复用的,帮正在做鸿蒙PC 选型的团队把边界划清楚。全文引用的代码均来自配套工程
项目源码/Relay_ohos,已在鸿蒙PC 上构建出签名 HAP 并真机运行。
关键词:鸿蒙PC、鸿蒙 PC、开源鸿蒙PC、跨平台框架、ArkUI、ArkUI 跨平台框架、2in1 桌面形态、跨窗口拖拽、剪贴板接力、命中测试、插入索引、投递规则、状态分层、AtomGit。
一、先把三类事实分开:截图、工程、界面数字
写交互类技术文章有一个很容易犯的错误:把界面上那个看起来很专业的数字,直接当成自己的实测结论。所以在进入正文之前,先把本文用到的三类材料分清。
第一类是问题样本:本次附带的四张鸿蒙PC 真机完整桌面截图。四张图都拍到同一台鸿蒙PC 设备的完整桌面:底部有系统 Dock(文件管理、终端、浏览器、应用中心等图标一排),右下角有系统状态栏与「16:24 / 11月15日」时间与电源、音量图标,背景是 DevEco Code 开发环境(左侧工程树能看到 Relay_ohos / AppScope / entry / hvigor,编辑区停着 build-profile.json5、module.json5、oh-package.json5),前台是浅色的「接力」应用窗口。四张图分别对应接力舞台、剪贴板、接力日志、投递规则四个视图。它们说明的是本文要解决的问题形态,不是本文的构建日志。
第二类是配套工程:项目源码/Relay_ohos。它是一个纯原生 ArkTS + ArkUI 声明式 UI、Stage 模型的 2in1 桌面工程,应用名「接力 · Relay」,包名 com.relay.dropstudio,deviceTypes 只声明了 2in1,compatibleSdkVersion 与 targetSdkVersion 均为 26.0.0,oh-package.json5 的 dependencies 是空对象——零第三方依赖,没有引入任何跨平台框架或 UI 组件库。它已经在鸿蒙PC 上成功构建并安装运行,文中所有 DropStage.ets、Index.ets、ClipboardView.ets 的代码片段都是从该工程里原样摘出来的。
第三类是界面上的数字:截图里出现的「209 ms 平均耗时」「98.2% 成功率」「184 ms / 232 ms / 410 ms」这类数值,来自 Models.ets 与 Index.ets 里的确定性合成值,用于演示日志视图与统计视图的表达方式。它们不是本文在鸿蒙PC 上测出来的性能数据,也不是这套拖拽实现的真实耗时。这一点我会在第十章专门展开——把「演示值」当「实测值」写进文章,是这类博客最常见、也最容易被读者一眼看穿的地方。
顺带说明一次口径:本文讨论 ArkUI 跨平台框架,指的是 ArkUI 这套声明式 UI 框架本身具备的跨设备、跨形态能力(一套 .ets 声明式代码在手机、平板、2in1 桌面之间复用),而不是在说工程里额外接了某个第三方跨平台运行时。配套工程是原生 ArkUI 实现,没有接入任何第三方跨平台 UI 运行时。这个区别在交互文章里尤其重要:拖拽命中测试这一层,用不用跨平台框架,成本结构完全不同。
二、鸿蒙PC 真机完整界面截图:四张图逐张读
下面四张图全部来自鸿蒙PC 真机,且都是完整桌面截图而非应用窗口裁剪图。判断依据很直接:底部系统 Dock、右下角系统状态栏与日期、背景里的 DevEco Code 工程树、前台可拖拽的浅色应用窗口同时入镜。这四张图也已上传图床,社区发文可直接引用公网地址。

图 1 鸿蒙PC 真机完整桌面:接力舞台壳层,顶部是「接力 RELAY」品牌区与四个导航项(接力舞台 / 剪贴板 / 接力日志 / 投递规则),右上角显示「5 次接力」与「3 条暂存」,主体是左右两块面板与中间的剪贴板通道
图 1 是整个实验台的常态。左边「Muse 画布」标着「源窗口 · 内容提供方」,六张卡片分别是章节摘录、封面雾中礁石、品牌色暮蓝、HarmonyOS 栅格规范、route.json、批注第 118 页;右边「Atlas 阅读器」标着「目标窗口 · 接收投递」,三张卡片分别是草稿第一章、旧版封面、读者反馈.md;中间那条窄通道里竖排着「复制 / 移动」模式切换、一段虚线、一个箭头与 RELAY 字样,下面挂着「剪贴板暂存 3 条 · 点击展开」。这张图的重点不是好看,而是它把跨窗口数据接力的三个落点在一屏里摆清楚了:源面板、目标面板、以及它们之间的中转站。后面第五到第八章的代码,全部是在回答「松手那一刻,如何决定内容属于哪个落点」。

图 2 鸿蒙PC 真机完整桌面:「剪贴板」页,左侧按时间倒序列出三条暂存记录(封面 · 雾中礁石、HarmonyOS 栅格规范、章节摘录),右侧给出暂存总数 3、已投递 1、五类内容的分布条形与一条使用建议
图 2 回答的是「半路放一放」这件事。注意每条记录右侧只有两个动作:再次投递、删除;页头只有一个「清空剪贴板」。这三个动作恰好构成剪贴板中转的最小闭环——能存、能取、能清。右上角的使用建议里写着「剪贴板只保留最近 8 条。再次投递会把内容复制到 Atlas 阅读器,源窗口不会丢失内容」,这句话是写进 UI 的契约,也是第八章 addClipEntry 里那个 index < 7 循环上限的来源。桌面用户很容易连续拖十几次,如果不限流,中转区会退化成一条需要不断滚动清理的时间线。

图 3 鸿蒙PC 真机完整桌面:「接力日志」页,事件流按时间倒序展示「Muse 画布 → Atlas 阅读器」「Atlas 阅读器 → 剪贴板」等五次接力,每行带类型标签、模式、耗时条与成功状态;右侧给出总接力 5、平均耗时 209 ms、复制 1 / 移动 2、接力路径三段式示意图与 98.2% 成功率
图 3 回答的是「可回溯」。桌面场景下用户会反复挪东西,一旦出现「我刚才拖过去的那张封面去哪了」,没有日志就只能靠记忆。这里每一行都带了源、目标、类型、模式、耗时与结果,右侧还有一条「Muse 画布 → 剪贴板 → Atlas 阅读器」的三段式路径图,把中转关系画出来。必须强调:这张图里的 209 ms、98.2%、184/96/232/121/410 ms 全部是 Models.ets 里写死的演示值,不是采样结果,第十章会解释为什么这么处理,以及真要测该测什么。

图 4 鸿蒙PC 真机完整桌面:「投递规则」页,左上显示「3 / 5 条已启用」,五条规则(允许跨窗口复制文本、图片投递需要二次确认、大文件必须进入暂存区、边缘自动滚动、仅同应用允许移动)逐条带开关;右上「投递模式」三张卡片(复制 / 移动 / 引用),右下「拖拽反馈」三张卡片(影子 / 缩略图 / 行内指示),底部是一条安全边界说明
图 4 是本文最想讲的一屏。它把「交互策略」从代码里提到了界面上:同一个拖拽动作,开不开 R2(图片二次确认),走的根本是两条数据流;模式选「复制」还是「移动」,决定源面板那张卡还在不在;反馈选「影子」「缩略图」「行内指示」,决定拖动浮层是完整跟随、缩小、还是干脆只留一条插入线。底部那条黄色说明写得很老实:「本实验台仅模拟窗口间的数据接力,不读取真实剪贴板,也不向其他应用写入内容。」——这句话是本文的诚实底线,第八章会具体交代真实系统剪贴板该怎么接。
三、鸿蒙PC 的 2in1 工程基线:把目标态钉死在配置里
在写任何交互代码之前,先看这个工程的配置。下面每一项都能在工程文件里查到,不是推测:
| 项 | 实际值 | 证据 |
|---|---|---|
| 应用名 / 包名 | 「接力 · Relay」 / com.relay.dropstudio | AppScope/app.json5 |
| vendor / 版本 | Relay Lab / 1.0.0(versionCode 1000000) | AppScope/app.json5 |
| 设备形态 | deviceTypes: ['2in1'] | entry/src/main/module.json5 |
| SDK | compatibleSdkVersion / targetSdkVersion 均为 26.0.0 | build-profile.json5 |
| 运行时 | runtimeOS: 'HarmonyOS' | build-profile.json5 |
| 签名 | signingConfigs[0].type: 'HarmonyOS',证书路径位于 /storage/Users/currentUser/.ohos/config/ | build-profile.json5 |
| 入口能力 | EntryAbility 挂 entity.system.home + action.system.home | module.json5 |
| 依赖 | dependencies: {},零第三方库 | oh-package.json5 |
| 语言 / 框架 | 纯 ArkTS + ArkUI 声明式 UI,Stage 模型 | entry/src/main/ets/** |
这张表里有两行值得单独说。
第一行是 deviceTypes: ['2in1']。这是鸿蒙PC 应用的准入声明:只声明 2in1,意味着这个应用在应用市场里只会分发给桌面形态设备,它不需要为竖屏手机做兜底布局,也不需要处理折叠屏的形态切换。代价是:一旦你在大屏上把字号、间距、面板宽度写成死值,就没有「手机端还能看」这条后路。本文第四章的布局公式之所以必须用弹性计算,就是因为没有手机兜底这一层保险。
第二行是 entity.system.home + action.system.home。这两项让 EntryAbility 具备被系统当作桌面入口的能力。换句话说,这个应用在鸿蒙PC 上有机会出现在桌面或启动器的「可用入口」里,而不是只能从开发工具里 aa start 拉起来。对一个演示桌面交互的应用来说,这一点很关键:你要验证的是「用户在桌面上用起来是什么感觉」,而不是「IDEA 里点运行能不能起来」。
再看 EntryAbility,它极其克制:
// entry/src/main/ets/entryability/EntryAbility.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN = 0x0000;
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN, 'Relay', 'Ability onCreate');
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'Relay', 'Failed to load content: %{public}s', JSON.stringify(err));
return;
}
hilog.info(DOMAIN, 'Relay', 'Content loaded');
});
}
}
它引入了 window 这个 Kit,但只做了 loadContent。没有调用 setWindowLayoutFullScreen、没有设窗口最小尺寸、没有做窗口状态监听。这是一个刻意的取舍:本文要验证的是应用内「窗口之间的数据接力」这套交互模型,而不是窗口管理 API 本身。窗口的尺寸变化由 UI 层的 onAreaChange 兜住(第四章),窗口级的能力(最小尺寸、标题栏定制、多实例)则是另一个话题。把边界说清楚,比含糊地写「我们也支持窗口管理」要专业得多。
四、桌面窗口会变宽变窄:把布局写成公式而不是常量
手机应用可以假定宽度是一个常量,鸿蒙PC 应用不能。用户随手拖一下窗口边框,宽度就从 1280 变成 860,再从 860 变成最大化。如果左右两块面板的宽度写死成 520,窗口一缩就会出现右侧面板被裁掉一半的尴尬。这个工程的做法是把布局写成一组互逆的公式:
// entry/src/main/ets/components/DropStage.ets(节选)
@State private stageW: number = 1200;
private channelW(): number {
return 148;
}
private panelW(): number {
return (this.stageW - this.channelW() - 8) / 2;
}
private targetX(): number {
return this.panelW() + this.channelW() + 8;
}
private stageH(): number {
const rows = Math.max(this.sourceItems.length, Math.max(3, this.targetItems.length + 1));
return this.topH() + rows * this.rowH() + 18;
}
三个函数各自承担一件事:panelW() 把剩余宽度平分给左右两块面板,targetX() 从面板宽度反推右面板的绝对偏移,stageH() 用行数算整体高度。注意 stageH() 里那个 Math.max(3, ...)——它保证目标面板至少留三行高度,即使目标列表被清空,右侧也不会塌成一条缝,用户仍然有一个明确的投放目标区域。这是桌面交互里非常容易被忽略的一条:空态不是「没有内容」,空态是「一个还愿意接东西的地方」。
而 stageW 的初始值 1200 是兜底,真实值由挂载后的区域变化回写:
// entry/src/main/ets/components/DropStage.ets(节选)
.build()
.width('100%')
.height(this.stageH())
.clip(true)
.onAreaChange((oldArea: Area, newArea: Area) => {
const nextWidth = Number.parseFloat(newArea.width.toString());
if (nextWidth > 0) {
this.stageW = nextWidth;
}
})
这里有两个细节值得抄。第一,onAreaChange 的 newArea.width 是 Length 类型,可能是 '100%' 这样的字符串,所以必须先 toString() 再 Number.parseFloat,直接当数字用会得到 NaN;第二,加了 nextWidth > 0 的守卫,避免某些时序下拿到 0 宽度导致整块舞台塌成 0。桌面窗口的 resize 是高频事件,缺了这个守卫会看到界面「闪一下然后消失」。
这套布局是固定通道 + 弹性面板:中间那条 148 宽的通道是交互锚点(剪贴板热区、方向箭头、模式切换都在里面),宽度必须稳定,否则拖拽热区会随窗口抖动;两侧面板吃掉剩余空间。为什么不是「三栏等比」?因为中转区一旦变宽,用户拖动时「到底算进了剪贴板还是算进了目标面板」就会变得模糊——热区的稳定性优先于视觉上的对称。
五、拖拽引擎:从一个 PanGesture 到一次可解释的命中测试
图 1 里那些卡片是能拖的。这个工程没有使用系统级的拖拽 API,而是用 PanGesture 手写了一套应用内拖拽引擎。原因很实在:本文要演示的是「命中测试与落点决策」这套逻辑本身,用系统拖拽会把中间过程藏进框架,读者看不到 zoneOf 到底怎么判断。真实跨窗口/跨应用拖拽需要接 unifiedDataChannel 与 onDragStart 的 DragEvent,本文不给半成品代码——它在第十一章的能力表里被明确标注为「未实现」,原因见本章结尾。
先看卡片的两个手势。它们被一个 GestureGroup 并行包起来:
// entry/src/main/ets/components/DropStage.ets(DropCard 组件,节选)
.gesture(
GestureGroup(GestureMode.Parallel,
PanGesture({ fingers: 1, distance: 4 })
.onActionStart(() => this.dragBegin())
.onActionUpdate((event: GestureEvent) => this.dragUpdate(event.offsetX, event.offsetY))
.onActionEnd(() => this.dragFinish())
.onActionCancel(() => this.dragFinish()),
TapGesture({ count: 2 }).onAction(() => this.quickDrop())
)
)
这里有三个决定成败的参数:
GestureMode.Parallel。如果用默认的 Exclusive,拖拽和双击会互相抢手势,用户稍微抖一下就可能被判成拖拽,双击永远触发不了。并行模式下两者互不阻塞,短距离抖动由 distance: 4 阈值兜住。
distance: 4。这是拖拽的启动阈值,单位 vp。鸿蒙PC 上鼠标点击的抖动远小于手指,但 0 阈值会让「单击选中」变成「误拖」。4 vp 是一个在鼠标和触控板之间都还过得去的折中值。
onActionCancel 也调 dragFinish。这是很多人会漏的一行。手势被系统中断(比如窗口失焦、另一个手势抢占了响应链)时不会走 onActionEnd,如果不把 cancel 也接到结束流程,界面就会永久停在一个「浮层跟着鼠标跑」的僵尸态。桌面环境下手势中断比手机更常见——用户随时可能 Alt+Tab 切走。
再看浮层跟随的写法。这是整套拖拽里最容易写错的一处:
// entry/src/main/ets/components/DropStage.ets(节选)
private beginDrag(item: DropItem, x: number, y: number): void {
this.dragItem = item;
this.dragging = true;
this.dragStartX = x;
this.dragStartY = y;
this.dragX = x;
this.dragY = y;
this.hoverZone = '';
this.insertIndex = -1;
}
private updateDrag(dx: number, dy: number): void {
this.dragX = this.dragStartX + dx;
this.dragY = this.dragStartY + dy;
this.hoverZone = this.zoneOf(this.dragX, this.dragY);
// ...
}
关键在 this.dragX = this.dragStartX + dx 这一行。PanGesture 的 offsetX / offsetY 是相对手势起点的累计位移,不是每次回调之间的增量。所以正确写法是「起点 + 累计」,而不是「当前位置 + 增量」。如果你写成后者,浮层会以指数级速度飞出屏幕——因为它每一帧都在自己上一帧的位置上再加一次累计量。这是手写拖拽最经典的一类 bug,而且在手机上不明显(拖不了多远),在大屏上一拖就露馅。
而 dragStartX / dragStartY 是算出来的,不是从事件里取的:
// entry/src/main/ets/components/DropStage.ets(节选)
ForEach(this.sourceItems, (item: DropItem, index: number) => {
DropCard({
item: item,
dimmed: this.dragging && this.dragItem.id === item.id,
dragBegin: () => this.beginDrag(item, 12, this.topH() + index * this.rowH()),
// ...
})
.position({ x: 12, y: this.topH() + index * this.rowH() })
.width(this.panelW() - 24)
.height(this.cardH())
.zIndex(10)
}, (item: DropItem) => item.id)
因为卡片本身就是用 .position({ x, y }) 绝对定位的,它的坐标在渲染时已经确定了,所以拖拽起点可以直接用同一套公式算出来(12 是左边距,topH() + index * rowH() 是行偏移)。这样做的好处是浮层和原卡片的初始位置严格重合,不会出现「一按下去卡片先跳一下」的位移感。如果用 event.fingerList[0].localX 之类的指针坐标做起点,就得再减去指针在卡片内的偏移,多一次减法就多一处出错的可能。
拖拽中还有一个必须处理的细节:浮层不能吃掉手势。浮层是叠在最上面的一层 DropCard,zIndex(100):
// entry/src/main/ets/components/DropStage.ets(节选)
if (this.dragging) {
DropCard({ item: this.dragItem, floating: true })
.position({ x: this.dragX, y: this.dragY })
.width(this.panelW() - 24)
.height(this.cardH())
.zIndex(100)
.hitTestBehavior(HitTestMode.None)
.scale({ x: this.previewStyle === '缩略图' ? 0.82 : 1, y: this.previewStyle === '缩略图' ? 0.82 : this.previewStyle === '行内指示' ? 0.55 : 1 })
.opacity(this.previewStyle === '行内指示' ? 0.82 : 1)
}
hitTestBehavior(HitTestMode.None) 是这一层能存在的前提。不加它,浮层会盖在鼠标下面并把后续的手势事件截走,于是 PanGesture 的 onActionUpdate 再也收不到回调,拖拽在第二帧就卡死。这个坑在桌面端特别容易复现,因为鼠标位置天然就在浮层中心。
同一段代码还顺手实现了图 4 里那三种「拖拽反馈」:影子 是完整卡片跟手,缩略图 缩到 0.82,行内指示 把浮层压扁到 0.55 高度并降到 0.82 不透明度。三种样式共用同一个浮层实例,只改 scale 与 opacity——交互策略的差异化尽量落在表现层参数上,而不是复制三份代码。
六、命中测试:为什么用卡片中心,而不是鼠标位置
松手那一刻要回答一个问题:「现在是哪个落点?」这个工程的答案是 zoneOf:
// entry/src/main/ets/components/DropStage.ets(节选)
private zoneOf(x: number, y: number): string {
const centerX = x + (this.panelW() - 24) / 2;
const tx = this.targetX();
if (centerX >= tx && centerX <= tx + this.panelW() && y > 0 && y < this.stageH()) {
return 'target';
}
const cx = this.panelW() + 24;
if (centerX >= cx && centerX <= cx + this.channelW() - 48 &&
y > this.stageH() * 0.5 && y < this.stageH() * 0.5 + 92) {
return 'clipboard';
}
return '';
}
这段代码短,但有三处是设计出来的,不是随手写的。
第一处:centerX = x + (panelW - 24) / 2。 判定用的是被拖卡片的水平中心,不是指针位置。为什么?因为用户拖动时,视觉焦点在整张卡片上,不在鼠标那个像素点上。如果按指针判定,用户把卡片右侧边缘拖进目标面板、指针还在通道里时,界面会告诉他「你还在通道」,这与眼睛看到的完全不同。按卡片中心判定,视觉与逻辑就对齐了:卡片的「主体」进到哪个区域,就算投到哪个区域。这一条在宽卡片上的差别非常明显——图 1 里的卡片几乎是面板满宽,用指针判定会出现半张卡片已经压进目标面板却仍判为落空的情况。
第二处:y > 0 && y < stageH()。 目标面板的纵向判定给了整个舞台高度,而不是只给面板内部。这意味着卡片拖到目标面板顶部标题栏那一条时,仍然算在目标区。桌面用户拖东西喜欢「甩」到面板上方再放下,如果严格按内容区判定,这类高频动作会大量落空。
第三处:剪贴板热区是一个很窄的矩形——channelW() - 48 宽(即 148 减 48 等于 100),纵向只有 92 高,位置在整块舞台 50% 处往下。它对应图 1 里那个「剪贴板暂存」的小卡片。热区故意做小:如果通道整条都能暂存,用户想把卡片拖到目标面板时,路径上必然扫过通道,一松手就被误吞进剪贴板。窄热区 = 明确意图,这是「中转站」类交互的通用原则。
再看插入位置的算法,它同时决定了那条蓝色插入线的位置:
// entry/src/main/ets/components/DropStage.ets(节选)
private updateDrag(dx: number, dy: number): void {
this.dragX = this.dragStartX + dx;
this.dragY = this.dragStartY + dy;
this.hoverZone = this.zoneOf(this.dragX, this.dragY);
if (this.hoverZone === 'target') {
const relativeY = this.dragY - this.topH();
this.insertIndex = Math.max(0, Math.min(this.targetItems.length, Math.floor(relativeY / this.rowH())));
}
}
Math.floor(relativeY / rowH()) 把纵向位置换算成「第几行之前」,然后双向钳制到 [0, targetItems.length]。钳制这一步不能省:卡片被拖到面板顶部之上时 relativeY 是负数,floor 之后会得到 -1,插入线就跑到面板外面去了;拖到底部之下时会超出数组长度,插入时会越界。钳制之后,insertIndex 恒为一个合法的插入位点,后续的数组拼接不用再做防御。
落下时的三个分支非常直白:
// entry/src/main/ets/components/DropStage.ets(节选)
private endDrag(): void {
if (this.hoverZone === 'target') {
this.onDropItem(this.dragItem, 'target', this.insertIndex);
} else if (this.hoverZone === 'clipboard') {
this.onDropItem(this.dragItem, 'clipboard', -1);
} else {
this.onCancelDrop();
}
this.dragging = false;
this.dragItem = EMPTY_ITEM;
this.hoverZone = '';
this.insertIndex = -1;
}
注意 DropStage 自己不修改任何业务数据。它只把「谁、落到哪、插到第几位」通过 onDropItem 回调抛给父组件 Index,然后把自己内部的拖拽临时态清干净。这是本文最想强调的工程分层:DropStage 持有的是交互瞬时态(dragging / dragX / dragY / hoverZone / insertIndex),Index 持有的是业务持久态(sourceItems / targetItems / clipEntries / events / rules)。一旦这两类状态混在一个组件里,你会同时面对「拖拽中途改了源数组导致 ForEach 重排、正在拖的那张卡片被销毁」这种极难调试的问题。
七、落下之后:三种模式与一条不可变更新链
内容落进目标面板之后,真正写数据的是父组件的 deliverToTarget:
// entry/src/main/ets/pages/Index.ets(节选)
private deliverToTarget(item: DropItem, index: number, redeliver: boolean): void {
const nextItem = copyItem(item, 'D' + this.deliverySeq);
this.deliverySeq = this.deliverySeq + 1;
const insertAt = index < 0 ? this.targetItems.length : Math.max(0, Math.min(index, this.targetItems.length));
const next: DropItem[] = [];
for (let i = 0; i < this.targetItems.length; i++) {
if (i === insertAt) {
next.push(nextItem);
}
next.push(this.targetItems[i]);
}
if (insertAt >= this.targetItems.length) {
next.push(nextItem);
}
this.targetItems = next;
if (this.mode === '移动') {
this.sourceItems = this.sourceItems.filter((entry: DropItem) => entry.id !== item.id);
}
this.recordEvent(item, redeliver ? '再次投递' : this.mode, 'Atlas 阅读器', redeliver ? '剪贴板' : 'Muse 画布');
this.notify('已投递到 Atlas 阅读器 · ' + kindLabel(item.kind), item.tone);
}
这段代码有四个值得讲的点。
第一,copyItem(item, 'D' + this.deliverySeq) 生成带后缀的新 id。 同一张卡片可以被反复投递,如果直接沿用原 id,目标面板的 ForEach(..., (item) => item.id) 就会出现两个相同的 key。ArkUI 的 ForEach 在 key 重复时的行为是「只渲染第一个」,于是用户第二次投递时会发现「什么都没有发生」——但数据其实已经进去了,只是没渲染。这是声明式 UI 里最典型的静默失败,而且日志、状态栏计数都是对的,只有列表不对,非常容易误判成「点击没响应」。
第二,插入用的是手写循环而不是 splice。 splice 会原地修改 this.targetItems,而 ArkUI 的 @State 对数组的响应式判断在「原地修改」和「整体替换」两种写法上表现不同:整体替换一定会触发刷新,原地修改需要依赖框架的数组代理。这里的写法是整体替换——先构造 next,再 this.targetItems = next。多写七行,换来的是刷新行为确定。在桌面端高频交互场景里,「确定性」比「少写代码」值钱。
第三,移动 模式只在投递成功后移除源。 this.sourceItems.filter(...) 同样是整体替换。注意判断的是 item.id(源卡片原始 id),而插入到目标的是带 D 后缀的副本——如果反过来判断错,源面板那张卡片就删不掉。
第四,recordEvent 的 source 参数是条件表达式。 从剪贴板再次投递时,源标为「剪贴板」,从画布直接拖时源标为「Muse 画布」。这就是图 3 里为什么会出现「Atlas 阅读器 → 剪贴板」和「剪贴板 → Atlas 阅读器」两种反向记录——日志记录的是真实的数据来源,不是一个写死的常量。
三种投递模式(复制 / 移动 / 引用)在图 4 里是三个可点的卡片,但落到代码里只体现为一句 if (this.mode === '移动')。这里有一个必须讲清楚的诚实边界:引用 模式在 UI 上描述了「不复制内容本体,目标窗口保留一个指向原文的引用」,但在当前实现里,它和 复制 走的是同一条路径,只是不删源。这是实验台的一个已知简化——真正的引用语义需要目标面板持有源 id 并在源被修改时同步,那是一个数据一致性问题,不是拖拽问题。把简化写在文章里,比在 UI 上假装实现了要好。
八、剪贴板中转:把「等一下再放」变成一个有限容量的队列
图 2 那个页面背后只有两个函数:入队与再投递。
// entry/src/main/ets/pages/Index.ets(节选)
private addClipEntry(item: DropItem): void {
const entry: ClipEntry = {
id: this.clipSeq,
at: shortClock(),
used: false,
item: copyItem(item, 'C' + this.clipSeq)
};
this.clipSeq = this.clipSeq + 1;
const next: ClipEntry[] = [entry];
for (let index = 0; index < this.clipEntries.length && index < 7; index++) {
next.push(this.clipEntries[index]);
}
this.clipEntries = next;
this.recordEvent(item, '暂存', '剪贴板', 'Muse 画布');
this.notify('已暂存到剪贴板 · ' + kindLabel(item.kind), RelayColors.amber);
}
index < 7 加上新入队的那一条,正好是 UI 上承诺的「只保留最近 8 条」。写法是「新数组 = 新条目 + 旧数组前 7 条」,仍然是一次整体替换。这里没有用 unshift 再 slice,原因和上一章一致:一次构造、一次赋值,刷新行为确定。
ClipEntry 上还有一个 used 字段,它在再投递时被置为 true:
// entry/src/main/ets/pages/Index.ets(节选)
private redeliverClip(entry: ClipEntry): void {
this.deliverToTarget(entry.item, this.targetItems.length, true);
this.clipEntries = this.clipEntries.map((item: ClipEntry) => {
if (item.id === entry.id) {
return {
id: item.id,
at: item.at,
used: true,
item: item.item
};
}
return item;
});
}
注意这里 map 的返回值是新对象字面量,而不是 item.used = true 之后返回 item。后者在 ArkUI 里经常不刷新,因为对象引用没变,框架无法判断「这个条目变了」。图 2 里那条「· 已投递」的小字,就是 used === true 渲染出来的。这类「不可变更新」的写法在纯 ArkTS 工程里应该成为默认习惯——它本质上和 React 生态里那套 setState 不可变约定是同一个道理,只是 ArkUI 把它换成了 @State 赋值语义。
关于真实系统剪贴板,这里必须交代清楚:当前工程不读取系统剪贴板,也不写入系统剪贴板,图 2 里的「剪贴板」是应用内的内存态队列,UI 上那句「本应用为窗口交互演示原型,不读取系统剪贴板」就是为此而写。如果你要把这个实验台接到真实的系统剪贴板上,路径是明确的——@kit.BasicServicesKit 里的 pasteboard:
// 扩展方向:接入系统剪贴板(未包含在当前 HAP 内)
import { pasteboard } from '@kit.BasicServicesKit';
import { BusinessError } from '@kit.BasicServicesKit';
export async function writeTextToSystem(text: string): Promise<boolean> {
try {
const data = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
await pasteboard.getSystemPasteboard().setData(data);
return true;
} catch (error) {
const err = error as BusinessError;
console.error(`pasteboard write failed: ${err.code} ${err.message}`);
return false;
}
}
export async function readTextFromSystem(): Promise<string> {
try {
const data = await pasteboard.getSystemPasteboard().getData();
return data.getPrimaryText();
} catch (error) {
const err = error as BusinessError;
console.error(`pasteboard read failed: ${err.code} ${err.message}`);
return '';
}
}
这段代码是扩展方向,不在当前 HAP 里,编写本文时也未在真机上验证过它的读写时序;把它放出来是为了说明「内存态队列」和「系统剪贴板」是两个不同的东西,而不是暗示这个工程已经是系统级剪贴板工具。真要接,还要多想两件事:一是回环,用户在某些窗口里按 Ctrl+C 会往系统剪贴板写,你的应用监听到之后又写一遍,就可能进入循环;二是隐私,桌面形态下剪贴板里经常有密码、地址这类内容,图 4 里那条「图片投递需要二次确认」其实就是在应用内模拟这种「落位前先问一句」的克制。
九、投递规则门禁:让策略在数据流里生效,而不是在 UI 里打补丁
图 4 那五条开关里,只有第二条(R2 图片二次确认)是真正参与了数据流的,其余四条目前是策略占位。R2 的接入点只有一个:
// entry/src/main/ets/pages/Index.ets(节选)
private onDropItem(item: DropItem, zone: string, index: number): void {
if (zone === 'target') {
if (this.ruleEnabled('R2') && item.kind === 'image') {
this.pendingItem = item;
this.pendingIndex = index;
this.dialogMode = 'confirmImage';
return;
}
this.deliverToTarget(item, index, false);
} else if (zone === 'clipboard') {
this.addClipEntry(item);
}
}
这段代码的价值在于门禁的位置。它不是写在卡片上判断「这张是图片所以别让它拖」,而是写在落点回调里:拖拽照常进行、插入线照常显示、松手的那一刻才拦一次,拦下来之后把 pendingItem / pendingIndex / dialogMode 三个字段一存,弹窗就出来了。等到用户点「确认投递」,再走同一条 deliverToTarget:
// entry/src/main/ets/pages/Index.ets(节选)
private confirmPending(): void {
this.dialogMode = 'none';
this.deliverToTarget(this.pendingItem, this.pendingIndex, false);
this.pendingItem = EMPTY_ITEM;
}
private cancelPending(): void {
this.dialogMode = 'none';
this.pendingItem = EMPTY_ITEM;
this.notify('已取消本次图片投递', RelayColors.muted);
}
好处是什么?同一份拖拽引擎同时支持「直接落位」和「确认后落位」两条路径,切换只取决于一个布尔配置。 如果反过来,把规则判断写在 DropCard 的 dragBegin 里(比如图片卡片不允许拖动),你就得为「允许拖、但落位前确认」这种策略再开一条分支,很快会变成一堆 if 叠在 UI 层。规则本该是数据流的开关,不是组件的属性。
这里还能看到一处状态设计的取舍:pendingItem 的初始值是 EMPTY_ITEM,取消时又把它重置回 EMPTY_ITEM,而不是 undefined。原因是 ArkTS 的严格空检查下,DropItem | undefined 会在弹窗组件里引出一串非空断言;用一个全空对象当哨兵值,读取 pendingItem.payload 永远不会崩,代价是要接受一个约定——空 id 代表无待投递项。这类「哨兵值 vs 可空类型」的选择没有绝对答案,但一个工程里应该统一。
十、日志里那些毫秒数:为什么我坚持把它们标成演示值
图 3 里最容易被误读的就是「平均耗时 209 ms」和「成功率 98.2%」。看代码:
// entry/src/main/ets/pages/Index.ets(节选)
private recordEvent(item: DropItem, mode: string, target: string, source: string): void {
if (!this.logEnabled) {
return;
}
const event: RelayEvent = {
id: this.eventSeq,
time: clockText(),
source: source,
target: target,
kind: item.kind,
mode: mode,
ms: 80 + (this.eventSeq * 37 % 240),
result: 'ok',
tone: item.tone
};
// ...
}
ms 是 80 + (eventSeq * 37 % 240)——一个确定性伪随机,取值范围 80 到 319,随序号变化,看起来很像真实抖动,实际上是构造出来的。result 恒为 'ok',成功率那一栏的 98.2% 是 EventLogView.ets 里写死的字符串加一条固定宽度的进度条。time 用的是 clockText(),这个是真的(new Date() 格式化到秒),所以时间轴可信,耗时不可信。
为什么要这么处理?因为这个工程的目的是验证「跨窗口接力」这套交互模型与信息结构,日志视图要回答的是「一次接力应该记录哪些字段、这些字段怎么排列才便于回溯」,而不是「我的拖拽到底多快」。如果为了凑一个「真实数据」去接 hiTraceMeter 或 performance.now(),会引入另一条测量链路,反过来污染交互验证。用合成值把信息结构先定下来,再接真实采集,在工程节奏上是合理的;不合理的是把合成值当实测值写进文章和发版说明。
如果真要测这个链路,应该测的是三件事,而且都不等于「一次拖拽 209 ms」:
- 手势响应延迟:
onActionStart到浮层首次上屏之间的帧数,这条能反映浮层的渲染成本; - 落位到列表刷新的耗时:从
endDrag回调到目标面板新卡片完成首帧绘制,这条才是用户感知到的「投递快不快」; - 连续投递下的掉帧:连续拖十次,看
Systrace/Frame Profiler里的 P95 帧耗时是否被ForEach重建拖垮——卡片全部用绝对定位.position()而非List,一旦条目数上千,这里会成为瓶颈。当前工程的数据量是 6 + 3,属于交互验证规模,不是性能验证规模。
把这三条写清楚,比在图上标一个 209 ms 有用得多。
十一、焦点、快捷键与桌面习惯:还没做完的那部分
桌面交互绕不开键盘。图 1 里右上角有「帮助」入口,弹窗里写了两条快捷键:Esc 取消当前拖拽、⌘V 再次投递。必须诚实地说:这两条目前只是视觉展示,没有接 onKeyEvent。 工程里没有任何键盘事件绑定,弹窗里的 KbdKey 组件只是一个带阴影的装饰性按键图形。这一点如果不说,读者照着截图去找快捷键会找不到。
补法很清楚,也不复杂:在页面根节点挂 .onKeyEvent(),在 event.type === KeyType.Down 时按 event.keyCode 分发;Esc 对应 KeyCode.KEYCODE_ESCAPE,取消时把 dragging 复位、dragItem 置空;Ctrl/Cmd + V 对应 KeyCode.KEYCODE_V 且 event.getModifierKeyState(['Ctrl']) 为真,直接取剪贴板队列第一条走 deliverToTarget。桌面端还要额外考虑一件事:焦点在谁身上。如果焦点落在某个可编辑输入框里,Ctrl+V 的含义是「粘贴到输入框」,全局拦截会抢走这个语义。所以正确的做法不是无脑在根节点拦截,而是先判断焦点节点的类型,或者在拖拽进行中才启用 Esc 拦截。
顺带把其他几处「已实现 / 未实现」列清楚,避免读者照着文章推测功能:
| 能力 | 状态 | 说明 |
|---|---|---|
| 应用内拖拽(跨面板) | 已实现 | 手写 PanGesture 引擎,含浮层、命中测试、插入线 |
| 双击快速投递 | 已实现 | TapGesture({ count: 2 }),直投目标列表末尾 |
| 剪贴板中转(内存态) | 已实现 | 上限 8 条,支持再次投递与删除 |
| 投递规则 R2 门禁 | 已实现 | 图片落位前弹二次确认 |
| 其余四条规则 | 占位 | UI 可切换,未参与数据流 |
| 系统剪贴板读写 | 未实现 | 见第八章 pasteboard 扩展方向 |
| 系统级跨窗口/跨应用拖拽 | 未实现 | 需接 unifiedDataChannel 与 onDragStart 的 DragEvent |
| 窗口级 API(最小尺寸、标题栏、多实例) | 未实现 | EntryAbility 只做 loadContent |
| 键盘快捷键 | 未实现 | 弹窗内图标为视觉展示 |
| 四视图切换的状态保留 | 未实现 | Index 用 if/else 切视图,切走即重建,滚动位置不保留 |
最后一行是桌面应用里比较扎眼的一处。currentView() 用 if / else if / else 直接切四个 Scroll 容器,这在功能上没问题,但用户从接力日志切到剪贴板再切回来,滚动位置会回到顶部。桌面用户屏幕大、信息密度高,翻到一半被打回顶部是很明显的体验损失。改法是把四个视图放进 Tabs + TabContent,或者用 Swiper 保留子组件实例;再不然就在 onDidScroll 里记一下 scrollOffset 并在回来时恢复。这些都不难,难的是你得先意识到它是一个问题——桌面习惯的差异,往往就藏在这种「手机上没人会有意见」的细节里。
十二、ArkUI 跨平台框架视角:哪些层可以复用,哪些必须留在原生
写到这里可以把话题抬高一格。如果把本文这套「跨窗口接力」搬到 ArkUI 跨平台框架 的语境下讨论,结论是分层的,而不是一句「用框架就完了」或「框架不能用」。
第一层:数据模型与状态机,可以复用,而且应该复用。 DropItem、ClipEntry、RelayEvent、DropRule 这些结构,以及「命中 → 决策 → 落位 → 记账」这条状态机,本质是纯逻辑,和渲染引擎无关。它们能原封不动地活在任何声明式 UI 框架里,甚至能下沉到一个共享的 Kotlin / TypeScript 模块,让鸿蒙PC 端和别的端共用一套投递语义。这一层的价值最高,迁移成本最低。
第二层:声明式 UI 与手势组合,可以复用大部分,但有形态差异。 GestureGroup(GestureMode.Parallel, PanGesture, TapGesture) 这种写法在 ArkUI 内部是跨设备通用的——同一套代码在手机、平板、2in1 上都能跑。但参数要按形态调:distance: 4 在鼠标上合适,在触控板上可能要降到 2,在纯触摸屏上又要抬到 8。同理,onHover、右键菜单、光标样式这些只有在桌面形态下才存在的交互信号,跨平台框架要么提供抽象(比如统一的 pointer 事件),要么就得在原生层补。图 1 里那个 RelayBtn 组件的 onHover 换色就是纯粹为桌面写的——手机上永远触发不了,但它决定了鸿蒙PC 上按钮「有没有生命感」。
第三层:系统能力接入,留在原生。 系统剪贴板(pasteboard)、系统级拖拽(unifiedDataChannel)、窗口管理(window.WindowStage 的最小尺寸、窗口状态回调)、多实例与文件拖入,这些都必须走鸿蒙PC 原生的 Kit。跨平台框架能做的是「把调用点抽象成一个接口」,然后由各平台提供实现;它不能替你决定「拖到窗口边缘时该不该触发系统吸附」,那是平台交互规范的一部分。
所以对正在做鸿蒙PC 选型的团队,我给的建议是这个顺序:先用纯 ArkUI 把交互模型跑通(就像本文这个实验台),把状态分层、命中测试、门禁位置这些「想清楚比写代码更重要」的部分定下来;再评估哪些代码值得抽成跨平台共享层;最后才考虑是否引入跨平台框架来覆盖多端。 反过来做——先选框架再想交互——最常见的结局是:框架的抽象漏掉了桌面特有的 hover、右键、焦点、窗口 resize,你最后在框架里写满了平台判断分支,既没省事,也丢了原生性能。本文这个零依赖的 2in1 工程,恰好可以当作那条「基准线」:它有多简单,就能多清楚地看出跨平台框架到底帮你省掉了什么、又额外带来了什么。
十三、复现清单:从这个工程里拿走能直接用的部分
配套工程 项目源码/Relay_ohos 一共 13 个 .ets 文件、零第三方依赖,目录结构是标准的 Stage 模型:
entry/src/main/ets/
├── common/
│ ├── Models.ets # 数据模型 + 演示数据工厂 + 时间格式化
│ └── Theme.ets # 色板、字号、度量常量
├── components/
│ ├── RelayTopRail.ets # 顶部品牌区 + 四导航 + 计数器
│ ├── DropStage.ets # 拖拽引擎(DropCard + DropStage)
│ ├── ClipboardView.ets # 剪贴板页
│ ├── EventLogView.ets # 日志页
│ ├── RuleView.ets # 规则页
│ ├── RelayDialogs.ets # 弹窗 + Toast
│ ├── RelayControls.ets # 按钮 / 标签 / 区块标题 / 指标 / 键帽
│ └── RelayIcon.ets # 手绘矢量图标
├── entryability/EntryAbility.ets
└── pages/Index.ets # 唯一页面,持有全部业务状态
复现步骤(鸿蒙PC 上实测可走通):
- DevEco Studio 新建一个 2in1 空工程,
compatibleSdkVersion设26.0.0,语言选 ArkTS,Stage 模型; - 把
module.json5的deviceTypes改成['2in1'],并在abilities[0].skills里保留entity.system.home+action.system.home; - 按上面的目录把 13 个
.ets拷进entry/src/main/ets/,main_pages.json只留pages/Index; - 配好签名(鸿蒙PC 的默认调试证书即可),
hvigor构建出签名 HAP,安装; - 打开后按「拖一张卡片 → 拖到中间暂存 → 去剪贴板再次投递 → 开 R2 再拖一张图片看二次确认」这条路径走一遍,四个视图都会留下痕迹。
构建与运行时踩过的坑,按「最容易卡住」的顺序列在这里:
| # | 现象 | 原因 | 处理 |
|---|---|---|---|
| 1 | 浮层一出现,拖拽就卡死在第二帧 | 浮层挡住了手势响应链 | 给浮层加 .hitTestBehavior(HitTestMode.None) |
| 2 | 浮层越拖越快,飞出一屏 | 把 offsetX 当增量用了 | 改成 dragStartX + offsetX(累计量) |
| 3 | 切走窗口再回来,浮层还挂在鼠标上 | 手势被中断时没走 onActionEnd | onActionCancel 也要接 dragFinish |
| 4 | 同一个内容第二次拖入,目标列表不出现新卡片 | ForEach key 重复,被当成同一项 | copyItem 加序号后缀,保证 id 唯一 |
| 5 | 窗口拖窄后两块面板错位 | 面板宽度写成了常量 | 改 panelW() 公式 + onAreaChange 回写 |
| 6 | stageW 变成 NaN,整块舞台塌掉 | newArea.width 是 Length,直接算会得 NaN | toString() 后 parseFloat,并加 > 0 守卫 |
| 7 | 弹窗取消后再次拖同一张图,弹窗不弹 | pendingItem 没重置 | 取消时把 pendingItem 复位为 EMPTY_ITEM |
| 8 | 剪贴板里「已投递」标记不刷新 | map 里原地改了 used 字段 | 返回新对象字面量(不可变更新) |
| 9 | 插入线跑到面板外面 | 插入索引没钳制 | Math.max(0, Math.min(len, ...)) |
| 10 | 拖到目标面板标题栏那一条,判定为落空 | 纵向判定只覆盖内容区 | 纵向给整个 stageH() |
另外有一条坑不属于代码,属于形态:这个工程的字号从 5.5 到 21 vp 铺得很开(卡片正文 5.5、指标数字 18、页标题 13)。这套数值是照着「紧凑型桌面编辑器」的视觉尺度定的,在鸿蒙PC 的大窗口里读起来是正常的;但如果哪天把 deviceTypes 加上 phone,这些字号会小到不可读。2in1 专属工程可以享受「只为一种形态调字号」的自由,代价就是这层自由不能被随手挪用。
结语
把这条链路走完,回看开头那四件朴素的事,答案其实都落在同一句话上:桌面交互的复杂度不在手感,而在「多种落点 + 多种策略」的组合爆炸。
- 拖起来这件事,靠的是
GestureGroup(Parallel)+distance阈值 +onActionCancel兜底; - 判断拖到哪,靠的是「用卡片中心而非指针」+「纵向覆盖整个舞台」+「中转热区故意做窄」这三个反直觉决定;
- 半路先放一放,靠的是一个有上限的队列和三个动作(存、取、清);
- 放开手之前能反悔,靠的是把规则门禁放在落点回调里,而不是撒在 UI 各处。
这四件事都不需要跨平台框架才能做,但每一件都会被跨平台框架的抽象方式影响。所以我在本文里反复强调分层:状态机与数据模型值得共享,声明式手势与 UI 大部分可复用,系统能力(pasteboard、unifiedDataChannel、window)必须留在原生。ArkUI 跨平台框架的价值,是让你在「同一套声明式语法」下覆盖多设备形态;它的边界,是它永远不会替你决定鸿蒙PC 上「拖到边缘该不该吸附」「焦点在输入框时该不该抢快捷键」。 把这些边界写进设计文档,比在代码里写满平台判断要省事得多。
最后交代一句成本:这个实验台零依赖、13 个文件、纯 ArkTS,所有状态都在 Index 一个页面里,任何读者都可以在半小时内把四个视图翻完。它不完美——键盘没接、系统剪贴板没接、系统级拖拽没接、视图切换不保留滚动位置,这些都在第十一章的表格里写明了。但它在做一件更重要的事:把「跨窗口接力」这套交互从模糊的体验描述,变成一组可读、可改、可验证的公式与分支。 完整工程源码见 项目源码/Relay_ohos,代码托管统一走 AtomGit;如果你在自己的鸿蒙PC 上把它跑起来,欢迎拿着那十条坑来对照——它们大多不是「编译不过」,而是「编译过了,但交互不对」,后者才是桌面交互真正花时间的地方。
更多推荐




所有评论(0)