WPF 剪贴板操作
WPF 剪贴板操作
简介
WPF 通过 System.Windows.Clipboard 类提供剪贴板读写能力,支持文本、图片、文件列表和自定义数据格式。剪贴板是 Windows 操作系统中进程间数据交换的基础设施,WPF 对其进行了封装,提供了类型安全的 API。
理解 WPF 剪贴板操作不仅限于简单的复制粘贴文本。在实际项目中,你可能需要处理以下场景:
- 富文本编辑器中复制粘贴带格式的文本(RTF、HTML、XAML)
- 表格数据与 Excel 之间的导入导出
- 图片编辑器中的复制粘贴图片
- 自定义业务对象的跨窗口/跨应用传递
- 监听剪贴板变化实现联动更新
WPF 的 Clipboard 类底层基于 Windows OLE(Object Linking and Embedding)剪贴板机制,因此存在一些固有的限制和注意事项,如 STA 线程要求、COM 异常处理等。
基础文本操作
复制和粘贴文本
// 写入纯文本
Clipboard.SetText("Hello WPF");
// 读取纯文本
if (Clipboard.ContainsText())
{
string text = Clipboard.GetText();
Debug.WriteLine($"剪贴板内容: {text}");
}
// 读取指定格式的文本
if (Clipboard.ContainsText(TextDataFormat.Html))
{
string html = Clipboard.GetText(TextDataFormat.Html);
}TextDataFormat 枚举
TextDataFormat 定义了多种文本格式:
| 格式 | 说明 |
|---|---|
Text | 纯文本(默认) |
UnicodeText | Unicode 文本 |
Rtf | 富文本格式 |
Html | HTML 格式 |
CommaSeparatedValue | CSV 格式 |
Xaml | XAML 格式 |
安全获取文本(处理剪贴板占用)
剪贴板是系统全局资源,任何时刻只有一个进程能打开剪贴板。如果另一个程序正在使用剪贴板,调用 Clipboard.GetText() 会抛出 COMException(RPC 服务器不可用)。必须处理这种异常:
public static class ClipboardHelper
{
/// <summary>
/// 安全获取剪贴板文本,带重试机制
/// </summary>
public static string? SafeGetText(int maxRetries = 3, int retryDelayMs = 100)
{
for (int i = 0; i < maxRetries; i++)
{
try
{
if (Clipboard.ContainsText())
return Clipboard.GetText();
return null;
}
catch (COMException ex)
{
Debug.WriteLine($"剪贴板访问失败(第 {i + 1} 次): {ex.Message}");
Thread.Sleep(retryDelayMs);
}
}
Debug.WriteLine("剪贴板访问重试耗尽");
return null;
}
/// <summary>
/// 安全设置剪贴板文本,带重试机制
/// </summary>
public static bool SafeSetText(string text, int maxRetries = 3, int retryDelayMs = 100)
{
for (int i = 0; i < maxRetries; i++)
{
try
{
Clipboard.SetText(text);
return true;
}
catch (COMException ex)
{
Debug.WriteLine($"剪贴板写入失败(第 {i + 1} 次): {ex.Message}");
Thread.Sleep(retryDelayMs);
}
}
return false;
}
/// <summary>
/// 清空剪贴板
/// </summary>
public static bool SafeClear(int maxRetries = 3)
{
for (int i = 0; i < maxRetries; i++)
{
try
{
Clipboard.Clear();
return true;
}
catch (COMException)
{
Thread.Sleep(100);
}
}
return false;
}
}富文本复制粘贴
RTF 格式
// 写入 RTF 格式文本
Clipboard.SetData(DataFormats.Rtf, @"{\rtf1\ansi Hello \b World\b0}");
// 读取 RTF
if (Clipboard.ContainsData(DataFormats.Rtf))
{
var rtf = Clipboard.GetData(DataFormats.Rtf) as string;
// 将 RTF 加载到 RichTextBox
richTextBox.Rtf = rtf ?? "";
}HTML 格式
// 从浏览器复制的内容通常包含 HTML 格式
if (Clipboard.ContainsData(DataFormats.Html))
{
var html = Clipboard.GetData(DataFormats.Html) as string;
// HTML 格式包含 CF_HTML 头部信息,需要解析
// 格式:StartHTML:...EndHTML:...<html>...</html>
}XAML 格式
WPF 控件之间的复制粘贴可以使用 XAML 格式,保留排版信息:
// 写入 XAML 格式
var richTextBox = new RichTextBox();
richTextBox.Document = new FlowDocument(new Paragraph(new Run("Hello WPF")));
Clipboard.SetData(DataFormats.Xaml, XamlWriter.Save(richTextBox.Document));
// 读取 XAML 格式
if (Clipboard.ContainsData(DataFormats.Xaml))
{
var xaml = Clipboard.GetData(DataFormats.Xaml) as string;
if (xaml != null)
{
var document = (FlowDocument)XamlReader.Parse(xaml);
richTextBox.Document = document;
}
}图片操作
复制图片到剪贴板
// 从文件加载图片并复制到剪贴板
public static void CopyImageToClipboard(string filePath)
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = new Uri(filePath, UriKind.Absolute);
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
Clipboard.SetImage(bitmap);
}
// 从 BitmapSource 复制(注意:Clipboard.SetImage 只接受 BitmapSource)
public static void CopyBitmapSource(BitmapSource source)
{
if (source == null) return;
Clipboard.SetImage(source);
}
// 从 System.Drawing.Bitmap 转换后复制
public static void CopyDrawingBitmap(System.Drawing.Bitmap bitmap)
{
var hBitmap = bitmap.GetHbitmap();
try
{
var source = Imaging.CreateBitmapSourceFromHBitmap(
hBitmap, IntPtr.Zero, Int32Rect.Empty,
BitmapSizeOptions.FromEmptyOptions());
source.Freeze();
Clipboard.SetImage(source);
}
finally
{
DeleteObject(hBitmap);
}
}
[System.Runtime.InteropServices.DllImport("gdi32.dll")]
private static extern bool DeleteObject(IntPtr hObject);从剪贴板获取图片并保存
public static void SaveClipboardImage(string savePath)
{
if (!Clipboard.ContainsImage())
{
Debug.WriteLine("剪贴板中没有图片");
return;
}
var image = Clipboard.GetImage();
if (image == null) return;
using var stream = new FileStream(savePath, FileMode.Create);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(image));
encoder.Save(stream);
Debug.WriteLine($"图片已保存到: {savePath}");
}
// 支持多种格式保存
public static void SaveClipboardImage(string savePath, string format)
{
if (!Clipboard.ContainsImage()) return;
var image = Clipboard.GetImage();
if (image == null) return;
BitmapEncoder encoder = format.ToLowerInvariant() switch
{
"png" => new PngBitmapEncoder(),
"jpg" or "jpeg" => new JpegBitmapEncoder { QualityLevel = 90 },
"bmp" => new BmpBitmapEncoder(),
_ => new PngBitmapEncoder()
};
using var stream = new FileStream(savePath, FileMode.Create);
encoder.Frames.Add(BitmapFrame.Create(image));
encoder.Save(stream);
}文件操作
复制文件路径
// 复制单个文件路径
var files = new StringCollection { @"C:\Data\report.xlsx" };
Clipboard.SetFileDropList(files);
// 复制多个文件路径
var multipleFiles = new StringCollection
{
@"C:\Data\report.xlsx",
@"C:\Data\chart.png",
@"C:\Data\summary.pdf"
};
Clipboard.SetFileDropList(multipleFiles);获取复制的文件
// 检查是否包含文件
if (Clipboard.ContainsFileDropList())
{
var files = Clipboard.GetFileDropList();
foreach (string file in files)
{
Debug.WriteLine($"文件: {file}");
if (File.Exists(file))
{
var info = new FileInfo(file);
Debug.WriteLine($" 大小: {info.Length / 1024.0:F1} KB");
Debug.WriteLine($" 修改时间: {info.LastWriteTime}");
}
}
}实现拖放文件导入
public partial class FileDropPanel : UserControl
{
public FileDropPanel()
{
InitializeComponent();
// 允许拖放
AllowDrop = true;
DragEnter += OnDragEnter;
Drop += OnDrop;
}
private void OnDragEnter(object sender, DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop))
e.Effects = DragDropEffects.Copy;
else
e.Effects = DragDropEffects.None;
}
private void OnDrop(object sender, DragEventArgs e)
{
if (e.Data.GetData(DataFormats.FileDrop) is string[] files)
{
foreach (var file in files)
{
Debug.WriteLine($"导入文件: {file}");
ProcessFile(file);
}
}
}
private void ProcessFile(string filePath)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
switch (ext)
{
case ".csv":
ImportCsv(filePath);
break;
case ".xlsx":
ImportExcel(filePath);
break;
case ".json":
ImportJson(filePath);
break;
default:
Debug.WriteLine($"不支持的文件格式: {ext}");
break;
}
}
}自定义数据格式
DataObject 多格式写入
DataObject 是 WPF 中剪贴板数据的核心容器。它允许同时存储多种格式的数据,粘贴方可以根据自己的需求选择最适合的格式:
// 同时写入多种格式
var dataObject = new DataObject();
// 纯文本格式(方便粘贴到记事本等)
dataObject.SetText("PLC-001: 主控PLC (在线)", TextDataFormat.Text);
// 自定义格式(应用间传递结构化数据)
dataObject.SetData("MyApp.DeviceData", new DeviceClipData
{
DeviceId = "PLC-001",
Name = "主控PLC",
Status = DeviceStatus.Online
});
// HTML 格式(方便粘贴到邮件、Word 等)
dataObject.SetData(DataFormats.Html,
"<table><tr><td>PLC-001</td><td>主控PLC</td><td>在线</td></tr></table>");
// CSV 格式(方便粘贴到 Excel)
dataObject.SetText("PLC-001,主控PLC,在线", TextDataFormat.CommaSeparatedValue);
// 写入剪贴板
Clipboard.SetDataObject(dataObject, true); // true = 应用退出后保留剪贴板内容自定义数据类
/// <summary>
/// 设备剪贴板数据(必须是可序列化的)
/// </summary>
[Serializable]
public class DeviceClipData
{
public string DeviceId { get; set; } = "";
public string Name { get; set; } = "";
public DeviceStatus Status { get; set; }
public double Temperature { get; set; }
public DateTime CaptureTime { get; set; } = DateTime.Now;
}
public enum DeviceStatus { Online, Offline, Error, Maintenance }
public static class DeviceClipboard
{
public const string FormatName = "MyApp.DeviceData";
/// <summary>
/// 复制设备数据到剪贴板
/// </summary>
public static void Copy(DeviceClipData data)
{
var dataObject = new DataObject();
dataObject.SetData(FormatName, data);
dataObject.SetData(DataFormats.Text, FormatAsText(data));
Clipboard.SetDataObject(dataObject, true);
}
/// <summary>
/// 从剪贴板粘贴设备数据
/// </summary>
public static DeviceClipData? Paste()
{
if (!Clipboard.ContainsData(FormatName))
return null;
return Clipboard.GetData(FormatName) as DeviceClipData;
}
/// <summary>
/// 检查剪贴板是否包含设备数据
/// </summary>
public static bool CanPaste() => Clipboard.ContainsData(FormatName);
private static string FormatAsText(DeviceClipData data) =>
$"{data.DeviceId}\t{data.Name}\t{data.Status}\t{data.Temperature:F1}°C\t{data.CaptureTime:yyyy-MM-dd HH:mm:ss}";
}使用 XML 序列化的自定义格式
对于复杂的自定义数据,使用 XML 序列化可以确保跨版本兼容:
public static class XmlClipboardHelper
{
public static void SetXmlData<T>(T data, string formatName)
{
var serializer = new XmlSerializer(typeof(T));
using var writer = new StringWriter();
serializer.Serialize(writer, data);
var dataObject = new DataObject();
dataObject.SetData(formatName, writer.ToString());
Clipboard.SetDataObject(dataObject, true);
}
public static T? GetXmlData<T>(string formatName)
{
if (!Clipboard.ContainsData(formatName))
return default;
var xml = Clipboard.GetData(formatName) as string;
if (string.IsNullOrEmpty(xml)) return default;
var serializer = new XmlSerializer(typeof(T));
using var reader = new StringReader(xml);
return (T?)serializer.Deserialize(reader);
}
}命令绑定(ApplicationCommands)
WPF 内置了标准的复制粘贴命令,可以与键盘快捷键(Ctrl+C、Ctrl+V、Ctrl+X)自动关联:
基础命令绑定
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
// 绑定标准命令
CommandBindings.Add(new CommandBinding(ApplicationCommands.Copy, OnCopy, CanCopy));
CommandBindings.Add(new CommandBinding(ApplicationCommands.Paste, OnPaste, CanPaste));
CommandBindings.Add(new CommandBinding(ApplicationCommands.Cut, OnCut, CanCut));
}
private void CanCopy(object sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute = DataContext is MainViewModel vm && vm.SelectedItem != null;
}
private void OnCopy(object sender, ExecutedRoutedEventArgs e)
{
if (DataContext is MainViewModel vm && vm.SelectedItem != null)
{
DeviceClipboard.Copy(vm.SelectedItem.ToClipData());
Debug.WriteLine("已复制到剪贴板");
}
}
private void CanPaste(object sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute = DeviceClipboard.CanPaste();
}
private void OnPaste(object sender, ExecutedRoutedEventArgs e)
{
var data = DeviceClipboard.Paste();
if (data != null && DataContext is MainViewModel vm)
{
vm.ImportDevice(data);
}
}
private void CanCut(object sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute = DataContext is MainViewModel vm && vm.SelectedItem != null;
}
private void OnCut(object sender, ExecutedRoutedEventArgs e)
{
if (DataContext is MainViewModel vm && vm.SelectedItem != null)
{
DeviceClipboard.Copy(vm.SelectedItem.ToClipData());
vm.DeleteSelected();
}
}
}XAML 中的命令绑定
<Window x:Class="MyApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<Window.CommandBindings>
<CommandBinding Command="ApplicationCommands.Copy"
Executed="OnCopy" CanExecute="CanCopy" />
<CommandBinding Command="ApplicationCommands.Paste"
Executed="OnPaste" CanExecute="CanPaste" />
</Window.CommandBindings>
<Grid>
<!-- 菜单栏 -->
<Menu>
<MenuItem Header="编辑">
<MenuItem Header="复制" Command="ApplicationCommands.Copy" InputGestureText="Ctrl+C" />
<MenuItem Header="粘贴" Command="ApplicationCommands.Paste" InputGestureText="Ctrl+V" />
<MenuItem Header="剪切" Command="ApplicationCommands.Cut" InputGestureText="Ctrl+X" />
</MenuItem>
</Menu>
<!-- 工具栏按钮也会自动启用/禁用 -->
<ToolBar>
<Button Command="ApplicationCommands.Copy" Content="复制" />
<Button Command="ApplicationCommands.Paste" Content="粘贴" />
</ToolBar>
</Grid>
</Window>自定义 RoutedCommand
public static class CustomCommands
{
public static readonly RoutedCommand CopyAsCsv = new RoutedCommand(
"CopyAsCsv", typeof(CustomCommands));
static CustomCommands()
{
CopyAsCsv.InputGestures.Add(
new KeyGesture(Key.C, ModifierKeys.Control | ModifierKeys.Shift));
}
}
// 在 Window 中绑定
CommandBindings.Add(new CommandBinding(
CustomCommands.CopyAsCsv,
OnCopyAsCsv,
CanCopyAsCsv));
private void CanCopyAsCsv(object sender, CanExecuteRoutedEventArgs e) =>
e.CanExecute = DataContext is MainViewModel vm && vm.Items.Count > 0;
private void OnCopyAsCsv(object sender, ExecutedRoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
{
var csv = CsvExporter.Export(vm.Items);
ClipboardHelper.SafeSetText(csv);
}
}剪贴板监听
Windows 消息监听
WPF 没有直接提供剪贴板变化事件。需要通过 Windows 消息 WM_CLIPBOARDUPDATE 监听:
public class ClipboardMonitor : IDisposable
{
private const int WM_CLIPBOARDUPDATE = 0x031D;
private HwndSource? _hwndSource;
private IntPtr _hwnd;
private bool _isDisposed;
public event Action<string?>? ClipboardTextChanged;
public void Start(Window window)
{
_hwndSource = PresentationSource.FromVisual(window) as HwndSource;
if (_hwndSource == null) return;
_hwnd = _hwndSource.Handle;
_hwndSource.AddHook(WndProc);
// 注册剪贴板监听
NativeMethods.AddClipboardFormatListener(_hwnd);
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WM_CLIPBOARDUPDATE)
{
try
{
var text = Clipboard.ContainsText() ? Clipboard.GetText() : null;
ClipboardTextChanged?.Invoke(text);
}
catch (COMException)
{
// 剪贴板被占用时忽略
}
handled = true;
}
return IntPtr.Zero;
}
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
if (_hwndSource != null && _hwnd != IntPtr.Zero)
{
NativeMethods.RemoveClipboardFormatListener(_hwnd);
_hwndSource.RemoveHook(WndProc);
}
}
}
internal static class NativeMethods
{
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool AddClipboardFormatListener(IntPtr hwnd);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool RemoveClipboardFormatListener(IntPtr hwnd);
}使用示例:
public partial class MainWindow : Window
{
private readonly ClipboardMonitor _clipboardMonitor = new();
public MainWindow()
{
InitializeComponent();
Loaded += OnLoaded;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
_clipboardMonitor.Start(this);
_clipboardMonitor.ClipboardTextChanged += OnClipboardTextChanged;
}
private void OnClipboardTextChanged(string? text)
{
if (!string.IsNullOrEmpty(text))
{
Debug.WriteLine($"剪贴板内容变更: {text.Substring(0, Math.Min(50, text.Length))}...");
// 执行联动逻辑
}
}
protected override void OnClosed(EventArgs e)
{
_clipboardMonitor.Dispose();
base.OnClosed(e);
}
}MVVM 友好的剪贴板服务
在 MVVM 架构中,ViewModel 不应直接调用 Clipboard 类(因为它依赖 UI 线程)。应该封装为服务接口:
/// <summary>
/// 剪贴板服务接口
/// </summary>
public interface IClipboardService
{
bool ContainsText();
string? GetText();
bool SetText(string text);
bool ContainsImage();
BitmapSource? GetImage();
void SetImage(BitmapSource image);
bool ContainsData(string format);
object? GetData(string format);
bool SetData(string format, object data);
bool ContainsFileDropList();
StringCollection? GetFileDropList();
}
/// <summary>
/// 剪贴板服务实现
/// </summary>
public class ClipboardService : IClipboardService
{
public bool ContainsText() => Clipboard.ContainsText();
public string? GetText()
{
try { return Clipboard.GetText(); }
catch (COMException) { return null; }
}
public bool SetText(string text)
{
try { Clipboard.SetText(text); return true; }
catch (COMException) { return false; }
}
public bool ContainsImage() => Clipboard.ContainsImage();
public BitmapSource? GetImage()
{
try { return Clipboard.GetImage(); }
catch (COMException) { return null; }
}
public void SetImage(BitmapSource image)
{
try { Clipboard.SetImage(image); }
catch (COMException) { /* 忽略 */ }
}
public bool ContainsData(string format) => Clipboard.ContainsData(format);
public object? GetData(string format)
{
try { return Clipboard.GetData(format); }
catch (COMException) { return null; }
}
public bool SetData(string format, object data)
{
try
{
var dataObject = new DataObject();
dataObject.SetData(format, data);
Clipboard.SetDataObject(dataObject, true);
return true;
}
catch (COMException) { return false; }
}
public bool ContainsFileDropList() => Clipboard.ContainsFileDropList();
public StringCollection? GetFileDropList()
{
try { return Clipboard.GetFileDropList(); }
catch (COMException) { return null; }
}
}
/// <summary>
/// 测试用 Mock 实现
/// </summary>
public class MockClipboardService : IClipboardService
{
public string? LastSetText { get; private set; }
public bool ContainsTextResult { get; set; } = false;
public string? GetTextResult { get; set; } = null;
public bool ContainsText() => ContainsTextResult;
public string? GetText() => GetTextResult;
public bool SetText(string text) { LastSetText = text; return true; }
public bool ContainsImage() => false;
public BitmapSource? GetImage() => null;
public void SetImage(BitmapSource image) { }
public bool ContainsData(string format) => false;
public object? GetData(string format) => null;
public bool SetData(string format, object data) => true;
public bool ContainsFileDropList() => false;
public StringCollection? GetFileDropList() => null;
}在 DI 容器中注册:
// 注册剪贴板服务
services.AddSingleton<IClipboardService, ClipboardService>();
// ViewModel 中使用
public class MainViewModel
{
private readonly IClipboardService _clipboard;
public MainViewModel(IClipboardService clipboard)
{
_clipboard = clipboard;
CopyCommand = new RelayCommand(ExecuteCopy, CanExecuteCopy);
PasteCommand = new RelayCommand(ExecutePaste, CanExecutePaste);
}
private void ExecuteCopy()
{
if (SelectedItem != null)
{
_clipboard.SetText(SelectedItem.ToString());
}
}
}线程安全与注意事项
STA 线程要求
Clipboard 操作必须在 STA(Single Thread Apartment)线程上执行。WPF 的 UI 线程默认是 STA 线程,但如果在后台线程中需要访问剪贴板,必须切换到 UI 线程:
// 在后台线程中安全访问剪贴板
Task.Run(() =>
{
// 后台线程中不能直接调用 Clipboard
// 需要通过 Dispatcher 切换到 UI 线程
Application.Current.Dispatcher.Invoke(() =>
{
var text = Clipboard.GetText();
ProcessClipboardData(text);
});
});应用退出后保留剪贴板内容
// SetDataObject 的第二个参数控制退出后是否保留
Clipboard.SetDataObject(dataObject, copy: true); // 退出后保留
Clipboard.SetDataObject(dataObject, copy: false); // 退出后清除安全注意事项
// 避免将敏感信息写入剪贴板
public void CopyPassword(string password)
{
// 不好:密码写入剪贴板后可能被其他程序读取
Clipboard.SetText(password);
// 好:使用安全剪贴板或加密
var encrypted = Encrypt(password);
Clipboard.SetData("MyApp.EncryptedData", encrypted);
// 或者使用临时剪贴板,一段时间后自动清除
Clipboard.SetText(password);
Task.Delay(TimeSpan.FromSeconds(30)).ContinueWith(_ =>
{
Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.GetText() == password)
Clipboard.Clear();
});
});
}常见问题与排错
COMException: RPC 服务器不可用
这是最常见的剪贴板异常,表示剪贴板被其他程序占用。解决方案:
- 封装重试逻辑(如上文
ClipboardHelper.SafeGetText) - 避免频繁读写剪贴板
- 确保每次操作后及时释放剪贴板
后台线程调用 Clipboard 失败
System.Threading.ThreadStateException: 当前线程不在单线程单元中,无法添加 OLE 初始化的控件。解决方案:使用 Dispatcher.Invoke 切换到 UI 线程。
剪贴板数据格式不匹配
当从外部应用粘贴数据时,格式可能与预期不同。建议总是检查格式是否存在:
// 总是先检查格式
if (Clipboard.ContainsData(DataFormats.Text))
{
var text = Clipboard.GetText();
}
else if (Clipboard.ContainsData(DataFormats.Html))
{
var html = Clipboard.GetData(DataFormats.Html) as string;
}