「从 Excel 复制的表格粘贴时格式散架。我们希望它作为表格贴进去。」「在我们应用里复制的内容贴进 Word 时变成怪东西。」「我们希望能用拖放接收文件。」——在关于业务应用改动的咨询对话里,围绕复制粘贴和拖放(D&D)的请求是常客。
恰恰因为这些是「人人都当空气的功能」,它们实际如何工作却出人意料地鲜为人知。若把剪贴板想成「放进一份数据的盒子」,就解释不了为什么同一次复制按粘贴到哪里结果不同,以及为什么关闭源应用后粘贴就停了。真正的剪贴板是一种机制:把同一内容同时放成多种格式,让粘贴一侧挑一种它懂的格式。
而拖放,归根到底是通过 COM 接口交接与剪贴板完全相同的数据表示(IDataObject)的 OLE 数据传输。换句话说,复制粘贴和 D&D 是同胞:正确理解一个,另一个就在眼前。
本文面向中小企业的 IT 人员和 Windows 应用开发者。它把剪贴板格式如何工作、粘贴一侧和复制一侧的做法、正确监视剪贴板的方式、剪贴板历史、云同步和 RDP 的管理策略,以及 OLE 拖放的结构与陷阱,收进同一幅图。
1. 先说结论
- 剪贴板是同一桌面(窗口站)上的应用共享的单一区域,坐在那里的不是「一份数据」,而是同一内容同时的多种格式。RDP 等不同会话原本有不同的剪贴板;重定向功能才把两者桥接起来。因为目标挑一种它懂的格式,同一次复制按粘贴到哪里结果不同。12
- 对文本使用 CF_UNICODETEXT。CF_TEXT 是 ANSI 且依赖代码页,在日语系统上是乱码的温床。系统在两者之间隐式转换,但规范一侧是 Unicode。3
- 文件以 CF_HDROP(双 NUL 终止的路径数组)旅行,带格式的文本使用已注册格式「HTML Format」。HTML Format 有不寻常的结构:带字节偏移量头部的 UTF-8 文本。45
- 「关闭源应用后就再也贴不上」的真正原因是延迟渲染。它是一种不放置有效载荷、只放置「被问到时再生成」承诺的机制;若在退出时跳过物化(响应 WM_RENDERALLFORMATS,或对 OLE 调用 OleFlushClipboard),粘贴就会停。26
- 把粘贴的数据当作来自外部的不可信输入。微软自己直白写了「剪贴板数据不受信任。仔细解析它」。7
- 监视剪贴板,AddClipboardFormatListener + WM_CLIPBOARDUPDATE 是唯一选项。不要用轮询,也不要用旧的 SetClipboardViewer(查看器链)。还提供了不让秘密进入历史和同步的已注册格式(ExcludeClipboardContentFromMonitorProcessing 等)。81
- 剪贴板历史(Win+V)和云同步是 IT 管理事项。可以用 GPO / Intune(Policy CSP)的 AllowClipboardHistory 和 AllowCrossDeviceClipboard 控制它们,RDP 剪贴板重定向有自己的专用策略。91011
- 拖放是 COM。与剪贴板相同的 IDataObject 经 DoDragDrop 循环在 IDropSource(拖动源)和 IDropTarget(放置目标)之间交接。RegisterDragDrop 需要用 OleInitialize(STA)初始化。1213
- 不能从普通权限的文件资源管理器放到提升后的应用上。UIPI(按完整性级别的消息阻止)是原因,这是设计时就应知道的约束。14
下面从剪贴板基础往上走。
2. 剪贴板真正是什么 ── 不是「一份数据」,而是「同一内容的多种格式」
剪贴板是共享同一桌面的每个应用都能到达的公共数据共享机制(更精确地说,它按窗口站:不同的用户会话或 RDP 会话各有自己的剪贴板。复制粘贴能跨 RDP 工作,是因为重定向功能把两者桥接起来——第 7 章)。第一条原则是它由用户驱动:官方设计立场是不要在用户背后放入或取出数据。1
重要的是,一次复制并不放置「一份数据」。复制的窗口清空剪贴板,然后连续放置几种格式,从能力更强的格式到能力更弱的格式表达同一内容。2 例如,在电子表格中复制一张表时,概念上剪贴板上同时有类似下面的东西。
| 优先级 | 格式 | 内容 |
|---|---|---|
| 1 | 应用私有格式 | 包含公式和格式的完整内部表示(用于贴回同一应用) |
| 2 | HTML Format | 保持表格结构和格式的 HTML 片段 |
| 3 | CSV | 单元格分隔的文本 |
| 4 | CF_UNICODETEXT | 制表符分隔的纯文本 |
| 5 | 图像格式 | 表格外观的位图 |
粘贴一侧从这份列表中挑一种它懂的格式并取出。贴进 Word 得到带格式的表格;贴进记事本得到制表符分隔的文本——因为两者选了不同格式。「结果取决于贴到哪里」不是缺陷;它是这一设计的正常后果。
flowchart TB
accTitle: 为何同一次复制按粘贴到哪里结果不同
accDescr: 复制一侧把同一内容以多种格式放到剪贴板上,粘贴一侧挑一种它懂的格式,因此 Word 得到带格式的表格,记事本得到制表符分隔的文本
copy["复制:电子表格"] --> cb["剪贴板(多种格式)"]
cb --> rich["更富的格式"]
cb --> plain["更素的格式"]
rich --> f1["应用私有"]
rich --> f2["HTML Format"]
plain --> f3["CSV"]
plain --> f4["CF_UNICODETEXT"]
f2 -->|"Word"| word["带格式的表格"]
f4 -->|"记事本"| notepad["制表符分隔的文本"]
反过来说,开篇的投诉——「格式散架」「贴进来怪东西」——几乎都归结为一侧如何选择格式,或另一侧如何提供它们的问题。第 4 章讲粘贴一侧;第 5 章讲复制一侧。
3. 标准格式与已注册格式 ── CF_UNICODETEXT、CF_HDROP、HTML Format
3.1. 标准格式 ── 文本用 Unicode 一侧
操作系统预先定义的格式叫标准格式。在业务应用中不断露面的是以下这些。3
| 格式 | 值 | 内容 |
|---|---|---|
| CF_TEXT | 1 | ANSI 文本(依赖代码页) |
| CF_UNICODETEXT | 13 | Unicode 文本。这是文本的规范格式 |
| CF_HDROP | 15 | 文件路径列表(HDROP 句柄) |
| CF_DIB | 8 | 与设备无关的位图 |
| CF_LOCALE | 16 | 与文本关联的区域标识符 |
CF_TEXT 和 CF_UNICODETEXT 由系统隐式互相转换(合成格式)。字符代码转换使用与 CF_LOCALE 关联的代码页。3 依赖那种转换会丢掉 ANSI 无法表示的字符(例如仅 Unicode 的符号和组合字符),因此规则是把应用读写统一到 CF_UNICODETEXT(.NET 中是 DataFormats.UnicodeText)。
flowchart TB
accTitle: CF_UNICODETEXT 与 CF_TEXT 之间的隐式转换
accDescr: 应用只读写 CF_UNICODETEXT;系统用 CF_LOCALE 代码页隐式转换合成 CF_TEXT。ANSI 无法表示的字符在该转换中被丢掉
apprw["应用读写"] --> uni["CF_UNICODETEXT"]
uni <-->|"CF_LOCALE 转换"| ansi["CF_TEXT(ANSI)"]
ansi -.-> loss["无法表示的字符被丢掉"]
3.2. CF_HDROP ── 文件以「路径列表」旅行
CF_HDROP 用于在文件资源管理器中复制文件,或拖放文件时。有效载荷不是文件本身;它是一块内存,排布「双 NUL 终止」数组:DROPFILES 结构头部之后,用 NUL 字符分隔的完整路径字符串,末尾是空字符串。头部的 pFiles 是路径列表的起始偏移,fWide 说明字符串是否为 Unicode。4
[DROPFILES header: pFiles=start offset of the path list, fWide=1(Unicode)]
C:\data\a.txt(NUL)C:\data\b.txt(NUL)(NUL)
在原生代码中用 DragQueryFile 一次取出一个;在 .NET 中经 DataFormats.FileDrop 作为 string[] 接收。「旅行的只是路径,不是文件本身」这一事实会在第 8、9 章的 D&D 中再次重要。
flowchart TB
accTitle: CF_HDROP 的内存块布局
accDescr: DROPFILES 结构位于全局内存开头;pFiles 是路径列表的起始偏移,fWide 说明是否为 Unicode。完整路径随后以 NUL 分隔,块以空字符串(双 NUL)结束。旅行的只是路径,不是文件本身
hdr["DROPFILES(pFiles / fWide)"] --> p1["C:\\data\\a.txt + NUL"]
p1 --> p2["C:\\data\\b.txt + NUL"]
p2 --> tail["空字符串(双 NUL)"]
hdr -.-> note["只旅行路径,不是文件"]
3.3. 已注册格式 ── RegisterClipboardFormat 与「HTML Format」
对标准格式无法表达的数据,应用可以挑一个名字并注册自己的格式。把名字传给 RegisterClipboardFormat 会得到一个格式 ID;从不同应用以同一名字注册返回同一 ID,因此一旦就名字达成一致,就可以在应用之间共享数据。1 在自己的应用套件之间传递结构化数据时,使用不会碰撞的名字,例如 KomuraSoft.Report.RowData。
代表性的已注册格式是用于带格式文本的 「HTML Format」(与 RTF 并列的两大富文本格式之一)。有效载荷是 UTF-8 文本,但有不寻常的结构:前面附上列出字节偏移量的头部。5
Version:0.9
StartHTML:<byte offset of the start of the whole HTML>
EndHTML:<byte offset of the end of the whole HTML>
StartFragment:<byte offset of the start of the fragment>
EndFragment:<byte offset of the end of the fragment>
<html><body>
<!--StartFragment--><b>bold</b> fragment text<!--EndFragment-->
</body></html>
每个偏移量是从数据开头算起的字节位置,包括头部本身;通常做法是预留固定宽度(例如 10 位)并在建好正文后把测得的值写回去。StartFragment/EndFragment 以字节(不是字符)标记「用户实际选中的片段」的起止。在包含日语的 UTF-8 中,字符数和字节数会分叉,因此若把这个偏移计算弄错,贴进另一应用会丢掉开头或结尾。若自己生成 HTML Format,必须用编码为 UTF-8 之后测得的字节位置填写头部。5
flowchart TB
accTitle: HTML Format 头部与偏移量的关系
accDescr: 头部的 StartHTML 和 EndHTML 指向整个 HTML,StartFragment 和 EndFragment 指向用户选中的片段,两者都是从数据开头算起的字节位置。因为 UTF-8 中字符数和字节数会分叉,用编码后测得的字节位置填写头部
header["头部(字节偏移)"] --> html["整个 HTML"]
html --> frag["选中的片段"]
header -.-> byte["偏移是 UTF-8 之后的字节"]
CSV(.NET 中是 DataFormats.CommaSeparatedValue)也常用于表格数据。对与 Excel 的互操作,同时提供 HTML Format(带格式)、CSV(仅值)和 CF_UNICODETEXT(制表符分隔)意味着不必挑选单一粘贴目标。
4. 粘贴一侧的做法 ── 格式优先级与校验
4.1. 从富格式往下看
剪贴板上的格式按复制一侧放置它们的顺序排列(也就是从更有表现力到更弱)。粘贴一侧的基线是在你能处理的格式中,从信息最多的那个开始看。在 Win32 中,要么用 EnumClipboardFormats 枚举并用你认出的第一种格式,要么把自己的优先级列表传给 GetPriorityClipboardFormat 让它选择。2
在 .NET 中分支大致如下。
// Pasting a table: look from rich to plain
var data = Clipboard.GetDataObject();
if (data is null) return;
// Advertising a format does not guarantee the payload is a string. Use this
// branch only when the type also checks out; otherwise fall through to the next candidate
if (data.GetDataPresent(DataFormats.Html)
&& data.GetData(DataFormats.Html) is string html)
{
// Validate the HTML Format header, then import as a table
}
else if (data.GetDataPresent(DataFormats.CommaSeparatedValue))
{
// Import as CSV
}
else if (data.GetDataPresent(DataFormats.UnicodeText))
{
// Import as tab-separated text
}
这就是对开篇投诉「粘贴 Excel 表格会散架」的回答。只读纯文本的应用永远收不到表格结构。你把格式列表往下接受多远,是粘贴一侧的设计决定。
flowchart TB
accTitle: 从富格式往下看的粘贴分支
accDescr: 若存在 HTML Format 且有效载荷也是字符串,作为表格导入;否则尝试 CSV;若那也没有,落到制表符分隔的文本。若候选都没有,拒绝
startsel["开始粘贴"] --> h{"HTML Format + 字符串?"}
h -->|"是"| useh["校验头部 → 表格"]
h -->|"否"| c{"有 CSV?"}
c -->|"是"| usec["作为 CSV 导入"]
c -->|"否"| t{"UnicodeText?"}
t -->|"是"| uset["制表符分隔的文本"]
t -->|"否"| giveup["拒绝"]
4.2. 粘贴的数据是外部输入
容易漏掉,但剪贴板内容是来自外部的数据,你不知道哪个应用放上去的。微软也在 OLE 剪贴板文档中警告:「剪贴板数据不受信任。在应用中使用之前仔细解析它」。7
- 校验 HTML Format 头部偏移不指向缓冲区外(发出损坏头部的应用确实存在)。
- 作为数字、日期或代码导入的值应经过与屏幕输入相同的校验。
- 对巨量数据加上防御。即便有人粘贴几百兆的图像或数百万行文本,也不要阻塞 UI,超过上限就拒绝。一个注意:.NET 的 GetData 在你调用它的那一刻就把整个有效载荷物化成托管字符串(延迟渲染作为其中一部分运行),因此把大小检查放在 GetData 之后不是防御。在 Win32 中,对 GetClipboardData 返回的 HGLOBAL 检查 GlobalSize,确实能在「不要进入作为托管字符串的转换和解析」这一阶段给出防御,但对延迟渲染格式 GetClipboardData 本身会启动渲染,因此仍无法阻止复制源一侧的物化。为避免 UI 冻结,把获取移出 UI 线程(即便如此,因为 .NET 的 Clipboard 要求 STA,要在设为 STA 的专用线程上做,而不是在 Task.Run 的线程池线程(MTA)上——第 5.1 节)。
「无论经哪条路径从外部到达的值,使用前都要校验」这一想法,与「不要直接使用 QR 码的解码值」中铺开的相同。认为粘贴因为是用户动作就安全,是事故的起点。
flowchart TB
accTitle: 使用粘贴数据之前先校验
accDescr: 从剪贴板取出的数据按格式是否存在、有效载荷类型、大小上限和内容校验的顺序走;任何一项失败就拒绝或落到下一个候选格式
present["格式存在?"] --> type["有效载荷类型 OK?"]
type --> size["大小在上限内?"]
size --> content["校验内容"]
content --> ok["导入"]
type -.->|"类型不对"| rej["拒绝 / 下一格式"]
size -.->|"太大"| rej
content -.->|"无效"| rej
5. 复制一侧的做法 ── 同时提供多种格式,以及延迟渲染
5.1. 同时放置多种格式
复制一侧的做法是 4.1 的反向:同时提供富格式和纯格式。用 WinForms/WPF 的 DataObject 可以几行写完。15
// WinForms (System.Windows.Forms). WPF is the same shape with System.Windows DataObject/Clipboard
var data = new DataObject();
data.SetData(DataFormats.Html, htmlFormatText); // HTML Format string including the header
data.SetData(DataFormats.CommaSeparatedValue, csv); // CSV
data.SetData(DataFormats.UnicodeText, plainText); // Plain text
Clipboard.SetDataObject(data, copy: true); // copy:true = keep after the app exits
两点注意。第一,.NET 的 Clipboard 类只能从 STA 线程使用。15 WinForms/WPF 的 UI 线程因 [STAThread] 而是 STA,所以这通常不是问题,但从后台线程触碰会失败(STA/MTA 基础见「COM STA/MTA 基础」)。第二,copy: true 的含义与下一小节的延迟渲染绑在一起。
5.2. 延迟渲染 ── 为何「关闭源就贴不上」
每次用多种格式构建大有效载荷是浪费的,因此剪贴板有一种叫延迟渲染的机制。把 NULL 作为数据句柄传给 SetClipboardData,登记的不是有效载荷,而只是「被问到时再生成」的承诺;当有人请求该格式时,WM_RENDERFORMAT 到达复制源,这时才生成数据。2
这一设计的后果就是开篇的「关闭源应用后就再也贴不上」。退出前,复制源收到 WM_RENDERALLFORMATS,负责物化每一个尚未渲染的格式;不那样做就退出,格式就丢了。2
flowchart TB
accTitle: 延迟渲染以及关闭后再粘贴失败的原因
accDescr: 复制源用 NULL 句柄只登记承诺,并经 WM_RENDERFORMAT 按需物化。退出时它负责用 WM_RENDERALLFORMATS 物化每一种格式;跳过则格式丢失
promise["SetClipboardData NULL = 承诺"] --> req["粘贴一侧请求它"]
req --> render["WM_RENDERFORMAT → 现在构建"]
promise --> quit["复制源即将退出"]
quit -->|"RENDERALLFORMATS"| ok["退出后仍能粘贴"]
quit -->|"跳过物化"| lost["关闭后格式丢失"]
在 OLE 剪贴板(用 OleSetClipboard 放置 IDataObject 的风格)上,这一关系更清楚。剪贴板持有的只是指向数据对象的指针,在应用退出时调用 OleFlushClipboard 把数据物化到剪贴板上,因此退出后仍能粘贴。6 .NET 的 Clipboard.SetDataObject(data, copy: true) 就是指定这种「退出后仍保留」行为的。
在 Excel 中复制大范围并试图退出时,提示「剪贴板上有大量信息。是否希望稍后仍能把这些信息粘贴到另一程序?」正是是否运行这次物化(flush)的确认。若在自己的应用中使用延迟渲染,记住退出时的物化是同一套的一部分。延迟渲染是性能优化,因为渲染请求在消息处理内部同步运行,生成耗时很长的数据有冻结 UI 的权衡。2
6. 监视剪贴板的做法 ── 侦听器、重试与历史排除
6.1. 使用 AddClipboardFormatListener
「希望检测条码阅读器的值或从业务系统的复制并自动导入」这类需求,需要监视剪贴板变化。历史上有三种方法;今天正确答案只有一种。8
| 方法 | 评估 |
|---|---|
| 用定时器读取(轮询) | 浪费,且可能漏掉更新。不要用 |
| SetClipboardViewer(查看器链) | 链中一个应用的缺陷会弄坏整条链。只为向后兼容保留 |
| AddClipboardFormatListener | 推荐。WM_CLIPBOARDUPDATE 到达已登记的窗口 |
flowchart TB
accTitle: 监视剪贴板的流程
accDescr: 句柄创建时用 AddClipboardFormatListener 登记,无论哪个应用复制都会到达 WM_CLIPBOARDUPDATE。用重试读取,句柄销毁时用 RemoveClipboardFormatListener 对称注销
created["AddClipboardFormatListener"] --> wait["等待"]
anyapp["某个应用复制"] --> notify["WM_CLIPBOARDUPDATE"]
wait --> notify
notify --> readtry["带重试读取(6.2)"]
readtry --> wait
destroyed["RemoveClipboardFormatListener"] -.->|"注销"| created
// Minimal WinForms implementation
public partial class MainForm : Form
{
[DllImport("user32.dll", SetLastError = true)]
static extern bool AddClipboardFormatListener(IntPtr hwnd);
[DllImport("user32.dll", SetLastError = true)]
static extern bool RemoveClipboardFormatListener(IntPtr hwnd);
const int WM_CLIPBOARDUPDATE = 0x031D;
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
AddClipboardFormatListener(Handle);
}
protected override void OnHandleDestroyed(EventArgs e)
{
// Unregister symmetrically to match handle destruction / recreation
RemoveClipboardFormatListener(Handle);
base.OnHandleDestroyed(e);
}
protected override void WndProc(ref Message m)
{
if (m.Msg == WM_CLIPBOARDUPDATE)
{
// Read Clipboard.GetDataObject() here and import if the format is one you need
}
base.WndProc(ref m);
}
}
6.2. 打不开时重试
同一时间只有一个窗口能打开剪贴板;另一进程打开着时,OpenClipboard 失败。2 WM_CLIPBOARDUPDATE 刚到之后,复制源或另一监视者往往仍在操作,因此暂时的读取失败是日常事件。始终加上几次带短暂等待(几十毫秒)的重试。注意,让你指定重试次数和间隔的 .NET Clipboard 重载只存在于写入一侧,SetDataObject。读取一侧(GetDataObject 等)没有等价物,因此自己写捕获–等待–重试——WinForms 上是 ExternalException,WPF 上是 COMException。
flowchart TB
accTitle: 剪贴板读取重试流程
accDescr: 同一时间只有一个窗口能打开剪贴板,因此变化通知刚到后的读取可能因与另一进程竞态而失败。发生异常时等待几十毫秒再重试;碰到上限就这次放弃,等下一次更新再捡
upd["WM_CLIPBOARDUPDATE"] --> tryread["尝试读取"]
tryread -->|"成功"| useok["导入(第 4 章检查)"]
tryread -->|"占用中"| waitretry["等待几十毫秒"]
waitretry -->|"重试"| tryread
waitretry -->|"上限"| giveup2["这次放弃"]
6.3. 不让它进入历史和同步 ── 对处理秘密的复制功能的关照
Windows 有剪贴板历史(Win+V)和跨设备同步(云剪贴板),应用放置的数据默认都在两者范围内。把密码或账号等秘密放上复制功能的应用还要放置把内容排除出历史和同步的已注册格式。1
- ExcludeClipboardContentFromMonitorProcessing:放置它,该次复制的内容既不进入历史也不进入同步。
- CanIncludeInClipboardHistory(DWORD 0):只抑制历史。
- CanUploadToCloudClipboard(DWORD 0):只抑制跨设备同步。
密码管理器复制的密码不留在 Win+V 上,原因就是这一机制。把名字传给 RegisterClipboardFormat 得到格式 ID,并与普通数据并排放置,因此值得在任何处理秘密的业务应用中实现。
7. 从 IT 角度看剪贴板 ── 历史、云同步与 RDP 控制
稍微离开开发,这里是对管理员重要的几点。剪贴板历史积累最近的复制,云剪贴板在用同一 Microsoft 账户 / Microsoft Entra 账户登录的设备之间同步复制。10 方便的同时,它也产生残留和溢出:从业务系统复制的个人信息积在历史里,工作 PC 上复制的内容同步到个人 PC。
在组织中控制这一点使用的两条策略如下。
| 控制什么 | GPO(计算机配置 > 管理模板 > 系统 > OS 策略) | Policy CSP(Intune) | 默认 |
|---|---|---|---|
| 剪贴板历史 | Allow Clipboard History | Experience/AllowClipboardHistory | 允许 |
| 跨设备同步 | Allow Clipboard synchronization across devices | Privacy/AllowCrossDeviceClipboard | 允许 |
两者从 Windows 10 版本 1809 起可用;禁用它们后设置应用中对应项变灰,策略立即生效。910
另一常客是 RDP(远程桌面)剪贴板重定向。默认情况下,本地 PC 与远程会话之间可以复制粘贴,因此它可能成为把秘密带离服务器的路径。「不允许剪贴板重定向」策略(注册表值 fDisableClip)可以双向阻断。11 较新的 Windows Server / Windows 11 版本还增加了更细的策略,例如把服务器到客户端方向限制为仅文本。是彻底禁止还是分阶段收紧,是运维与安全的平衡。
flowchart TB
accTitle: 剪贴板内容可以沿哪些路径扩散,以及控制点
accDescr: 复制的内容默认在历史和云同步范围内,在 RDP 上经重定向旅行到另一会话。每条路径都可以用策略控制,应用一侧可以用排除格式把自己排除出历史和同步
cb["剪贴板"] --> hist["历史(Win+V)"]
cb --> cloud["云同步"]
cb --> rdp["RDP 重定向"]
hist -.-> p1["AllowClipboardHistory"]
cloud -.-> p2["AllowCrossDeviceClipboard"]
rdp -.-> p3["fDisableClip"]
cb -.-> p4["应用排除格式(6.3)"]
8. 拖放是 COM ── IDataObject + IDropSource + IDropTarget
8.1. 与剪贴板相同的数据,不同的搬运方式
OLE 拖放以下面三个角色运行。12
| 角色 | 谁实现 | 工作 |
|---|---|---|
| IDataObject | 拖动源 | 正在搬运的有效载荷。与剪贴板相同的多格式数据对象 |
| IDropSource | 拖动源 | 决定拖动继续还是取消,以及光标反馈 |
| IDropTarget | 放置目标 | 在 DragEnter/DragOver/DragLeave/Drop 中声明接受/拒绝,并接收放置 |
拖动源调用 DoDragDrop,拖动循环开始,鼠标进入放置目标窗口时通知该 IDropTarget;放置时交出 IDataObject。官方文档也说「D&D 提供与剪贴板复制粘贴完全相同的功能。若应用已经实现复制粘贴,增量很小」。12 换句话说,第 2 到第 5 章构建的多格式 DataObject 原样成为 D&D 有效载荷。
flowchart TB
accTitle: OLE 拖放的流程
accDescr: 拖动源把 IDataObject 放进有效载荷并调用 DoDragDrop 启动拖动循环;放置目标的 IDropTarget 在 DragEnter 和 DragOver 中声明接受/拒绝,在 Drop 时从 IDataObject 挑一种格式并取出
src["IDataObject + IDropSource"] -->|"DoDragDrop"| loop["拖动循环"]
loop -->|"鼠标进入"| enter["DragEnter/Over:Effect"]
enter -->|"按钮抬起"| drop["IDropTarget.Drop"]
drop --> data["挑一种格式并取出"]
8.2. 需要 OleInitialize(STA)
将成为放置目标的窗口用 RegisterDragDrop 登记,这里有一颗经典陷阱。若用 CoInitialize/CoInitializeEx 初始化 COM,RegisterDragDrop 总会以 E_OUTOFMEMORY 失败;必须用 OleInitialize 初始化。13 OleInitialize 把 COM 初始化为 STA,因为 D&D 是植根于窗口和消息泵这一 STA 世界的功能。调用线程还必须在跑消息泵;跳过它,其他应用会在拖动期间挂起。13 这里的背景正是「COM STA/MTA 基础」中的线程模型讨论。
在 WinForms/WPF 应用中,框架包办 OLE 初始化和接口实现,因此开发者只需写事件。
// WinForms: accept dropped files
listView1.AllowDrop = true;
listView1.DragEnter += (s, e) =>
{
// Also check that the source allows Copy (some sources only allow Move/Link)
e.Effect = e.Data.GetDataPresent(DataFormats.FileDrop)
&& (e.AllowedEffect & DragDropEffects.Copy) == DragDropEffects.Copy
? DragDropEffects.Copy // Accept: receive as a copy
: DragDropEffects.None; // Do not accept
};
listView1.DragDrop += (s, e) =>
{
// Drag data is also untrusted input. Even if it advertises FileDrop, the payload
// can be null or a different type, and GetData itself can fail
object data;
try { data = e.Data.GetData(DataFormats.FileDrop); }
catch (COMException) { return; }
if (data is not string[] paths) return;
foreach (var path in paths)
{
// Validate the path before importing (Section 9.3)
}
};
WPF 中形态相同:用元素上的 AllowDrop="True" 和 DragOver/Drop 事件接收,用 e.Data.GetData(DataFormats.FileDrop) 取出路径数组。在每一次 DragEnter/DragOver 上声明接受/拒绝(Effect)是 IDropTarget 的约定;跳过它会得到光标停在「不允许」上永不改变的缺陷。
9. D&D 陷阱 ── 提升、移动与路径校验
9.1. 不能放到以管理员身份提升的应用上
从文件资源管理器把文件放到用「以管理员身份运行」启动的应用上,什么都不发生——这不是实现缺陷,是操作系统行为。UIPI(用户界面特权隔离)默认阻止从较低完整性进程向较高完整性窗口的消息,因此来自普通权限(中等完整性)文件资源管理器的放置通知永远到不了提升后的应用。14
flowchart TB
accTitle: UIPI 如何阻止放到提升后的应用上
accDescr: 从中等完整性文件资源管理器到高完整性提升应用的放置通知默认被 UIPI 阻止,永远到达不了。把 UI 留在普通权限并隔离特权工作,放置就会到达
explorer["资源管理器(中等)"] -->|"放置通知"| uipi{"UIPI"}
uipi -->|"被阻止"| elevated["提升后的应用:没有放置"]
uipi -->|"通过"| normal["普通 UI:放置到达"]
normal -.->|"委托特权工作"| broker["隔离的提升进程"]
用 ChangeWindowMessageFilterEx 单独允许 WM_DROPFILES 等特定消息的变通办法广为人知,14 但那放行的是较旧的(WM_DROPFILES)放置通知;它不解决 OLE D&D 整体。实务指导很清楚:停止把应用设计成一直以提升权限运行。只把需要提升的工作隔离到另一个进程,UI 本身可以留在普通权限并接收 D&D(隔离设计在「在 Windows 应用中把「仅需管理员权限的处理」分离出来的具体写法」中有详细说明)。
9.2. DragDropEffects 的含义 ── Move 是「原件消失」的契约
DragDropEffects 上的 Copy/Move/Link 不是装饰;它们是拖动源与放置目标之间的契约。拖动源在 DoDragDrop 中声明它允许的效果集合,放置目标挑选实际效果,当 Move 成功时,拖动源删除数据(文件)——这是约定。若接收一侧不加思索地返回 Move,就会得到「我放下去原文件却消失了」的事故。对业务应用的导入用途,接收一侧声明 Copy 是安全默认。
flowchart TB
accTitle: DragDropEffects 契约 ── Move 删除原件
accDescr: 拖动源在 DoDragDrop 中声明允许的效果集合,放置目标挑选实际效果。Move 成功时拖动源删除文件,因此对导入,接收一侧应声明 Copy
srcdecl["源:允许的效果"] --> tgtsel["目标:挑选 Effect"]
tgtsel -->|"Copy"| copyok["原件留下(导入)"]
tgtsel -->|"Move"| moveact["源删除文件"]
9.3. 校验放下的路径
CF_HDROP/FileDrop 中旅行的只是路径(第 3.2 节)。导入前,把它经过与粘贴相同的不可信输入校验。
- 文件还是文件夹:作为规格决定放下整个文件夹时发生什么(递归导入,还是拒绝)。
- OneDrive 占位符:路径可能存在而文件本体不在本地——按需文件。打开它的那一刻下载开始,离线则失败。行为与对策见「OneDrive「按需文件」与业务应用」。
- 长路径与异常路径:超过 MAX_PATH 的路径、网络(UNC)路径以及可移动介质上的路径,只有在确认下游处理能对付它们之后才接受。
- 数量与总大小:为使放下数千个文件不冻结 UI,把导入做成异步,并加上上限和进度显示。
10. 小结
- 剪贴板是在同一桌面(窗口站)共享的单一区域中把同一内容同时放成多种格式的机制。粘贴一侧挑选格式,因此同一次复制结果不同。
- 文本是 CF_UNICODETEXT,文件是 CF_HDROP,带格式的文本是已注册格式 HTML Format(字节偏移头部 + UTF-8)。
- 粘贴一侧从富到素看,并把有效载荷当作外部输入。复制一侧同时提供多种格式,若使用延迟渲染则还要实现退出时物化(WM_RENDERALLFORMATS / OleFlushClipboard)。
- 监视是 AddClipboardFormatListener + WM_CLIPBOARDUPDATE。用重试应对 OpenClipboard 竞态,用 ExcludeClipboardContentFromMonitorProcessing 等不让秘密进入历史和同步。
- IT 可以用 GPO / Intune 控制剪贴板历史、云同步和 RDP 重定向。默认全部允许,因此在处理秘密的环境中要刻意决定。
- D&D 是 COM:IDropSource/IDropTarget 交接与剪贴板相同的 IDataObject。RegisterDragDrop 需要 OleInitialize(STA)。
- 放到提升后应用上会被 UIPI 阻止。DragDropEffects 上的 Move 是「原件消失」的契约;导入前校验放下的路径。
复制粘贴和 D&D 对用户来说应像空气一样的功能。正因为如此,「贴不上」「散架」「消失了」才那么伤体验——而一个提供多种格式并正确处理放置的应用,本身就会让日常操作更顺。希望这在你决定先修什么时是有用的材料。
相关文章
- COM / ActiveX / OCX 是什么 - 区别与关系整理
- COM STA/MTA 基础 - 线程模型与避免 Hang 的思路
- 今天的 Windows 外壳集成 ── 上下文菜单、文件关联,以及 Windows 11 改了什么
- C# 操作 Excel 时 EXCEL.EXE 残留的问题 ── COM 引用释放模式与替换判断
- Windows 应用 UX 设计 - 按使用场景划分的优先级
- OneDrive「按需文件」与业务应用 ── 占位符打破的假设以及如何应对
相关咨询领域
小村软件有限公司承接业务应用中复制粘贴和拖放支持的设计与实现(提供多种格式、Excel 互操作、导入放下的文件)、「粘贴时散架」或「复制消失」等问题的根因调查、监视剪贴板的输入自动化,以及不让机密数据进入历史和同步的实现。涉及 COM 和 OLE 下层的案例,即便从隔离症状开始也欢迎。
参考链接
-
Microsoft Learn, Clipboard Formats. 关于窗口能把同一信息放成多种剪贴板格式;经 RegisterClipboardFormat 的已注册格式(注册同一名字返回同一值,因此应用可以共享);合成格式;以及用 ExcludeClipboardContentFromMonitorProcessing、CanIncludeInClipboardHistory 和 CanUploadToCloudClipboard 把内容排除出剪贴板历史 / 云同步。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Clipboard Operations. 关于同一时间只有一个窗口能打开剪贴板;复制时从更有表现力到更弱放置格式;粘贴时用 EnumClipboardFormats / GetPriorityClipboardFormat 选择格式;向 SetClipboardData 传入 NULL 的延迟渲染以及 WM_RENDERFORMAT / WM_RENDERALLFORMATS 的职责;以及延迟渲染的权衡。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Microsoft Learn, Standard Clipboard Formats. 关于标准格式 CF_TEXT(ANSI)、CF_UNICODETEXT、CF_HDROP、CF_DIB 和 CF_LOCALE 的定义,以及系统使用与 CF_LOCALE 关联的代码页隐式转换 CF_TEXT 和 CF_UNICODETEXT。 ↩ ↩2 ↩3
-
Microsoft Learn, Shell Clipboard Formats. 关于 CF_HDROP 由 DROPFILES 结构加上双 NUL 终止的完整路径字符串数组组成;用 DragQueryFile 检索各个路径;以及 CFSTR_ 外壳格式需要经 RegisterClipboardFormat 注册。 ↩ ↩2
-
Microsoft Learn, HTML Clipboard Format. 关于已注册名字是「HTML Format」;带 Version、StartHTML、EndHTML、StartFragment 和 EndFragment 等字节偏移的头部结构;编码始终是 UTF-8;以及 StartFragment/EndFragment 注释约定。 ↩ ↩2 ↩3
-
Microsoft Learn, OleFlushClipboard function (ole2.h). 关于 OleSetClipboard 使剪贴板只持有指向数据对象的指针;OleFlushClipboard 把数据物化到剪贴板上,使应用退出后仍能粘贴;以及退出时不需要保留时用 OleSetClipboard(NULL) 清空剪贴板。 ↩ ↩2
-
Microsoft Learn, OleGetClipboard function (ole2.h). 关于如何从剪贴板获得 IDataObject,以及剪贴板数据不受信任、应用使用前应仔细解析的警告。 ↩ ↩2
-
Microsoft Learn, Using the clipboard. 关于比较监视剪贴板的三种方式(查看器窗口、序列号和格式侦听器);新程序应使用经 AddClipboardFormatListener 的侦听器;查看器链在链维护不完整时脆弱;以及序列号不是应轮询的东西。 ↩ ↩2
-
Microsoft Learn, Policy CSP - Experience. 关于用 Experience/AllowClipboardHistory 策略允许或拒绝剪贴板历史;从 Windows 10 版本 1809 起可用;默认允许;以及 GPO 映射在「系统 > OS 策略」下,更改立即生效。 ↩ ↩2
-
Microsoft Learn, Policy CSP - Privacy. 关于用 Privacy/AllowCrossDeviceClipboard 策略允许或拒绝跨设备剪贴板同步;同步发生在用同一 Microsoft 账户 / Microsoft Entra 账户登录的设备之间;以及默认允许。 ↩ ↩2 ↩3
-
Microsoft Learn, Policy CSP - ADMX_TerminalServer. 关于 TS_CLIENT_CLIPBOARD(「不允许剪贴板重定向」,注册表值 fDisableClip)能禁止远程桌面会话中本地与远程之间的剪贴板共享,以及默认允许重定向。 ↩ ↩2
-
Microsoft Learn, Drag and Drop (COM). 关于 OLE 拖放以 IDropSource(拖动源)、IDropTarget(放置目标)和 DoDragDrop(OLE 提供的循环)三者运行;提供与剪贴板复制粘贴相同的功能,因此已经实现复制粘贴的应用只需很小增量;以及反馈的种类。 ↩ ↩2 ↩3
-
Microsoft Learn, RegisterDragDrop function (ole2.h). 关于用 IDropTarget 登记放置目标窗口;若用 CoInitialize/CoInitializeEx 初始化 COM 总会以 E_OUTOFMEMORY 失败,因此需要 OleInitialize;以及调用线程未跑消息泵时拖动源应用会挂起。 ↩ ↩2 ↩3
-
Microsoft Learn, ChangeWindowMessageFilterEx function (winuser.h). 关于 UIPI 是默认阻止接收来自较低完整性发送方的消息的安全机制,以及用消息筛选器(MSGFLT_ALLOW)按窗口允许特定消息。 ↩ ↩2 ↩3
-
Microsoft Learn, How to add data to the Clipboard (Windows Forms). 关于用 DataObject 和 Clipboard.SetDataObject 同时放置多种格式的数据;以多种格式添加以便其他应用能识别;以及 Clipboard 类只能从 STA 线程使用,因此需要 [STAThread]。 ↩ ↩2
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
委托开发 Windows 应用程序前该梳理的事项
在委托外包开发 Windows 应用程序之前,梳理现有软件改造、设备联动、COM/ActiveX、发布与更新、维护等需要注意的要点。
「无响应」的真正含义 ── Windows 如何判定应用已挂起,以及如何设计不挂起的应用
Windows 的「无响应」是操作系统判定窗口已 5 秒未取出消息并换成幽灵窗口的机制。本文说明该判定的内部、挂起的经典原因、把重活移出 UI 线程的设计,以及调查挂起的步骤。
Windows 应用的任务栏托盘常驻与 Toast 通知 —— NotifyIcon 的坑与 AppNotification 的选型
本文整理了将业务 Windows 应用常驻在任务栏托盘(通知区域)并通过 Toast 通知告知用户的实现要点。内容涵盖 NotifyIcon 的正确用法与「关闭后驻留托盘」的设计、资源管理器重启后的重新注册、三种 Toast API(Windows App SDK AppN...
WinForms/WPF应用的多语言化 ── resx、附属程序集与区域文化切换的实务
本文从实务角度系统梳理Windows桌面应用多语言化的要点:CurrentCulture与CurrentUICulture的区别、resx与附属程序集构成的资源机制、WinForms的Localizable属性、WPF的现实方案选择、运行时语言切换,以及排版、格式、RTL等内容。
Windows 业务应用程序的打印与 PDF 输出 ── System.Drawing.Printing / WPF / 报表库的取舍选型
本文按需求整理成判断表,梳理 PrintDocument 的 WinForms 打印、WPF 的 FlowDocument/FixedDocument 打印,以及 PDF 输出的可选方案。并从实务角度讲解分页控制、DPI 偏差等实现陷阱,以及从 Windows 服务打印、依...
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
ActiveX 迁移
整理保留、包装或替换 COM / ActiveX / OCX 资产的阶段性判断的主题页面。
UI 线程 & 计时器
整理 WPF / WinForms UI 线程、异步流程、Dispatcher 使用、计时器判断的主题页面。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
支持包含常驻处理、设备联动、运行日志与可维护结构的 Windows 桌面应用程序。
既有资产活用 & 迁移支持
在持续活用 COM / ActiveX / OCX 资产、原生代码与 32 位依赖的同时,协助规划阶段性的迁移。
常见问题
汇总了咨询这一主题时常见的问题。
- 为什么从 Excel 复制的表格粘贴进我的应用时格式会散架?
- 剪贴板并不持有「一份数据」。同一内容会同时放成几种格式(源应用的私有格式、HTML Format、CSV、Unicode 文本等),目标应用挑一种它懂的格式并取出。格式散架时,典型原因是目标只读纯文本(CF_UNICODETEXT)。若还想要表格结构,实现粘贴一侧时优先 HTML Format 或 CSV。反过来,若希望其他应用能从你自己应用里的复制正确粘贴,复制时同时提供富格式和纯格式。
- 为什么关闭我复制来源的应用后就再也贴不上?
- 因为源在使用延迟渲染。处理大数据的应用在复制时不放置有效载荷;它们只在剪贴板上登记一个「被问到时再生成」的承诺。若源随后退出而没有响应关闭时的 WM_RENDERALLFORMATS 把数据物化,任何尚未渲染的格式都会丢失。使用 OLE 剪贴板(IDataObject)的应用可以在关闭时调用 OleFlushClipboard 把数据物化,使退出后仍能粘贴。
- 我自己的应用如何监视剪贴板变化?
- 当前推荐的方法是用 AddClipboardFormatListener 把窗口登记为侦听器,并处理每次内容变化时到达的 WM_CLIPBOARDUPDATE 消息。用定时器轮询内容既浪费工作又可能漏掉更新,基于 SetClipboardViewer 的旧查看器链只为向后兼容保留,因为链中一个应用的缺陷会弄坏整条链。还要注意,读取时的 OpenClipboard 可能因另一进程持有剪贴板而失败,因此若希望读取稳定,要实现带短暂等待的重试。
- 有没有办法不让密码等秘密进入剪贴板历史(Win+V)?
- 有两根杠杆,一根在应用一侧,一根在策略一侧。在应用一侧,若复制时还放置已注册格式 ExcludeClipboardContentFromMonitorProcessing,该内容既不进入历史也不进入跨设备同步。也可以用 CanIncludeInClipboardHistory(仅历史)和 CanUploadToCloudClipboard(仅同步)分别控制。这是密码管理器使用的机制。若希望对整个组织关掉,可以用组策略或 Intune(Policy CSP)的 AllowClipboardHistory 和 AllowCrossDeviceClipboard 禁用历史和云同步本身。
- 为什么不能把文件拖放到以管理员身份运行的应用上?
- 因为名为 UIPI(用户界面特权隔离)的安全机制会阻止从较低完整性进程向较高完整性窗口投递消息。文件资源管理器以普通权限(中等完整性)运行,因此拖放通知永远到不了提升后应用的窗口。用 ChangeWindowMessageFilterEx 单独允许 WM_DROPFILES 等消息的变通办法广为人知,但它只适用于较旧的放置通知。真正的修复是停止把应用设计成一直以提升权限运行,只把需要提升的工作隔离到另一个进程。