鸿蒙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 工程树、前台可拖拽的浅色应用窗口同时入镜。这四张图也已上传图床,社区发文可直接引用公网地址。

鸿蒙PC 真机完整桌面:接力舞台,Muse 画布与 Atlas 阅读器两个面板之间是剪贴板通道

图 1 鸿蒙PC 真机完整桌面:接力舞台壳层,顶部是「接力 RELAY」品牌区与四个导航项(接力舞台 / 剪贴板 / 接力日志 / 投递规则),右上角显示「5 次接力」与「3 条暂存」,主体是左右两块面板与中间的剪贴板通道

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

鸿蒙PC 真机完整桌面:剪贴板暂存页,三条记录与右侧类型分布

图 2 鸿蒙PC 真机完整桌面:「剪贴板」页,左侧按时间倒序列出三条暂存记录(封面 · 雾中礁石、HarmonyOS 栅格规范、章节摘录),右侧给出暂存总数 3、已投递 1、五类内容的分布条形与一条使用建议

图 2 回答的是「半路放一放」这件事。注意每条记录右侧只有两个动作:再次投递、删除;页头只有一个「清空剪贴板」。这三个动作恰好构成剪贴板中转的最小闭环——能存、能取、能清。右上角的使用建议里写着「剪贴板只保留最近 8 条。再次投递会把内容复制到 Atlas 阅读器,源窗口不会丢失内容」,这句话是写进 UI 的契约,也是第八章 addClipEntry 里那个 index < 7 循环上限的来源。桌面用户很容易连续拖十几次,如果不限流,中转区会退化成一条需要不断滚动清理的时间线。

鸿蒙PC 真机完整桌面:接力日志页,五次投递的源、目标、耗时与结果

图 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 里写死的演示值,不是采样结果,第十章会解释为什么这么处理,以及真要测该测什么。

鸿蒙PC 真机完整桌面:投递规则页,五条规则开关与三种落点反馈

图 4 鸿蒙PC 真机完整桌面:「投递规则」页,左上显示「3 / 5 条已启用」,五条规则(允许跨窗口复制文本、图片投递需要二次确认、大文件必须进入暂存区、边缘自动滚动、仅同应用允许移动)逐条带开关;右上「投递模式」三张卡片(复制 / 移动 / 引用),右下「拖拽反馈」三张卡片(影子 / 缩略图 / 行内指示),底部是一条安全边界说明

图 4 是本文最想讲的一屏。它把「交互策略」从代码里提到了界面上:同一个拖拽动作,开不开 R2(图片二次确认),走的根本是两条数据流;模式选「复制」还是「移动」,决定源面板那张卡还在不在;反馈选「影子」「缩略图」「行内指示」,决定拖动浮层是完整跟随、缩小、还是干脆只留一条插入线。底部那条黄色说明写得很老实:「本实验台仅模拟窗口间的数据接力,不读取真实剪贴板,也不向其他应用写入内容。」——这句话是本文的诚实底线,第八章会具体交代真实系统剪贴板该怎么接。

三、鸿蒙PC 的 2in1 工程基线:把目标态钉死在配置里

在写任何交互代码之前,先看这个工程的配置。下面每一项都能在工程文件里查到,不是推测:

项实际值证据
应用名 / 包名「接力 · Relay」 / com.relay.dropstudioAppScope/app.json5
vendor / 版本Relay Lab / 1.0.0(versionCode 1000000)AppScope/app.json5
设备形态deviceTypes: ['2in1']entry/src/main/module.json5
SDKcompatibleSdkVersion / targetSdkVersion 均为 26.0.0build-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.homemodule.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 上实测可走通):

  1. DevEco Studio 新建一个 2in1 空工程,compatibleSdkVersion 设 26.0.0,语言选 ArkTS,Stage 模型;
  2. 把 module.json5 的 deviceTypes 改成 ['2in1'],并在 abilities[0].skills 里保留 entity.system.home + action.system.home;
  3. 按上面的目录把 13 个 .ets 拷进 entry/src/main/ets/,main_pages.json 只留 pages/Index;
  4. 配好签名(鸿蒙PC 的默认调试证书即可),hvigor 构建出签名 HAP,安装;
  5. 打开后按「拖一张卡片 → 拖到中间暂存 → 去剪贴板再次投递 → 开 R2 再拖一张图片看二次确认」这条路径走一遍,四个视图都会留下痕迹。

构建与运行时踩过的坑,按「最容易卡住」的顺序列在这里:

#现象原因处理
1浮层一出现,拖拽就卡死在第二帧浮层挡住了手势响应链给浮层加 .hitTestBehavior(HitTestMode.None)
2浮层越拖越快,飞出一屏把 offsetX 当增量用了改成 dragStartX + offsetX(累计量)
3切走窗口再回来,浮层还挂在鼠标上手势被中断时没走 onActionEndonActionCancel 也要接 dragFinish
4同一个内容第二次拖入,目标列表不出现新卡片ForEach key 重复,被当成同一项copyItem 加序号后缀,保证 id 唯一
5窗口拖窄后两块面板错位面板宽度写成了常量改 panelW() 公式 + onAreaChange 回写
6stageW 变成 NaN,整块舞台塌掉newArea.width 是 Length,直接算会得 NaNtoString() 后 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 上把它跑起来,欢迎拿着那十条坑来对照——它们大多不是「编译不过」,而是「编译过了,但交互不对」,后者才是桌面交互真正花时间的地方。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐