.NET 8 Windows平台多文件剪贴板延迟渲染实现难题
实现方案:自定义COM IDataObject实现延迟多文件剪贴板数据
核心思路
Windows剪贴板的多文件传输依赖CFSTR_FILEDESCRIPTORW(文件描述符组)和CFSTR_FILECONTENTS(文件内容)两个格式。当粘贴端请求多文件内容时,会通过FORMATETC.lindex指定要获取的文件索引。.NET封装的DataObject类没有暴露这个索引,所以必须直接实现完整的COM IDataObject接口,直接处理FORMATETC结构体才能解决多文件场景的问题。
实现步骤与代码
1. 定义必要的常量和结构体
对应Windows原生的文件描述符结构体定义:
using System.Runtime.InteropServices; using System.Drawing; // 剪贴板格式常量 public static class ClipboardFormats { public const string CFSTR_FILEDESCRIPTORW = "FileGroupDescriptorW"; public const string CFSTR_FILECONTENTS = "FileContents"; } // FILEDESCRIPTORW 结构体(匹配Win32定义) [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct FILEDESCRIPTORW { public uint dwFlags; public Guid clsid; public Size sizel; public Point pointl; public uint dwFileAttributes; public System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime; public System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime; public System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime; public uint nFileSizeHigh; public uint nFileSizeLow; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string cFileName; } // FILEGROUPDESCRIPTORW 结构体(匹配Win32定义) [StructLayout(LayoutKind.Sequential)] public struct FILEGROUPDESCRIPTORW { public uint cItems; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 1)] // 动态长度,用1占位 public FILEDESCRIPTORW[] fgd; }
2. 实现自定义IDataObject类
重点在GetData方法中解析FORMATETC.lindex,返回对应索引的文件内容流:
public class LazyMultiFileDataObject : System.Runtime.InteropServices.ComTypes.IDataObject { private readonly List<(string FileName, Func<Stream> GetContentStream)> _files = new(); public LazyMultiFileDataObject(List<(string, Func<Stream>)> files) { _files.AddRange(files); } public void GetData(ref System.Runtime.InteropServices.ComTypes.FORMATETC formatetc, out System.Runtime.InteropServices.ComTypes.STGMEDIUM medium) { medium = new System.Runtime.InteropServices.ComTypes.STGMEDIUM(); try { // 处理文件描述符组请求 var fdFormatId = (ushort)NativeMethods.RegisterClipboardFormat(ClipboardFormats.CFSTR_FILEDESCRIPTORW); if (formatetc.cfFormat == fdFormatId) { // 动态分配内存存储文件描述符组 int totalSize = Marshal.SizeOf<FILEGROUPDESCRIPTORW>() + (_files.Count - 1) * Marshal.SizeOf<FILEDESCRIPTORW>(); IntPtr fgdPtr = Marshal.AllocCoTaskMem(totalSize); var fgd = new FILEGROUPDESCRIPTORW { cItems = (uint)_files.Count, fgd = new FILEDESCRIPTORW[_files.Count] }; for (int i = 0; i < _files.Count; i++) { fgd.fgd[i] = new FILEDESCRIPTORW { cFileName = _files[i].FileName, dwFileAttributes = (uint)FileAttributes.Normal, ftLastWriteTime = DateTime.Now.ToFileTimeStruct() }; } Marshal.StructureToPtr(fgd, fgdPtr, false); medium.tymed = System.Runtime.InteropServices.ComTypes.TYMED.TYMED_HGLOBAL; medium.unionmember = fgdPtr; medium.pUnkForRelease = null; return; } // 处理文件内容请求,通过lindex获取对应文件索引 var fcFormatId = (ushort)NativeMethods.RegisterClipboardFormat(ClipboardFormats.CFSTR_FILECONTENTS); if (formatetc.cfFormat == fcFormatId && formatetc.lindex >= 0 && formatetc.lindex < _files.Count) { using var contentStream = _files[(int)formatetc.lindex].GetContentStream(); var buffer = new byte[contentStream.Length]; contentStream.Read(buffer, 0, buffer.Length); IntPtr hGlobal = Marshal.AllocCoTaskMem(buffer.Length); Marshal.Copy(buffer, 0, hGlobal, buffer.Length); medium.tymed = System.Runtime.InteropServices.ComTypes.TYMED.TYMED_HGLOBAL; medium.unionmember = hGlobal; medium.pUnkForRelease = null; return; } throw new NotImplementedException($"不支持的格式: {formatetc.cfFormat}"); } catch { ReleaseStgMedium(ref medium); throw; } } // 辅助方法:释放STGMEDIUM资源 private void ReleaseStgMedium(ref System.Runtime.InteropServices.ComTypes.STGMEDIUM medium) { if (medium.unionmember != IntPtr.Zero && medium.tymed == System.Runtime.InteropServices.ComTypes.TYMED.TYMED_HGLOBAL) { Marshal.FreeCoTaskMem(medium.unionmember); medium.unionmember = IntPtr.Zero; } } // 实现IDataObject的其他方法 public void GetDataHere(ref System.Runtime.InteropServices.ComTypes.FORMATETC formatetc, ref System.Runtime.InteropServices.ComTypes.STGMEDIUM medium) => throw new NotImplementedException(); public bool QueryGetData(ref System.Runtime.InteropServices.ComTypes.FORMATETC formatetc) { var fdFormat = (uint)NativeMethods.RegisterClipboardFormat(ClipboardFormats.CFSTR_FILEDESCRIPTORW); var fcFormat = (uint)NativeMethods.RegisterClipboardFormat(ClipboardFormats.CFSTR_FILECONTENTS); return (formatetc.cfFormat == fdFormat && formatetc.tymed == System.Runtime.InteropServices.ComTypes.TYMED.TYMED_HGLOBAL) || (formatetc.cfFormat == fcFormat && formatetc.lindex >= 0 && formatetc.lindex < _files.Count && formatetc.tymed == System.Runtime.InteropServices.ComTypes.TYMED.TYMED_HGLOBAL); } public void GetCanonicalFormatEtc(ref System.Runtime.InteropServices.ComTypes.FORMATETC formatetcIn, out System.Runtime.InteropServices.ComTypes.FORMATETC formatetcOut) { formatetcOut = formatetcIn; formatetcOut.dwAspect = System.Runtime.InteropServices.ComTypes.DVASPECT.DVASPECT_CONTENT; } public void SetData(ref System.Runtime.InteropServices.ComTypes.FORMATETC formatetc, ref System.Runtime.InteropServices.ComTypes.STGMEDIUM medium, bool release) => throw new NotImplementedException(); public System.Runtime.InteropServices.ComTypes.IEnumFORMATETC EnumFormatEtc(System.Runtime.InteropServices.ComTypes.DATADIR dir) { var formats = new List<System.Runtime.InteropServices.ComTypes.FORMATETC>(); var fdFormat = (ushort)NativeMethods.RegisterClipboardFormat(ClipboardFormats.CFSTR_FILEDESCRIPTORW); formats.Add(new System.Runtime.InteropServices.ComTypes.FORMATETC { cfFormat = fdFormat, tymed = System.Runtime.InteropServices.ComTypes.TYMED.TYMED_HGLOBAL, dwAspect = System.Runtime.InteropServices.ComTypes.DVASPECT.DVASPECT_CONTENT }); var fcFormat = (ushort)NativeMethods.RegisterClipboardFormat(ClipboardFormats.CFSTR_FILECONTENTS); for (int i = 0; i < _files.Count; i++) { formats.Add(new System.Runtime.InteropServices.ComTypes.FORMATETC { cfFormat = fcFormat, lindex = i, tymed = System.Runtime.InteropServices.ComTypes.TYMED.TYMED_HGLOBAL, dwAspect = System.Runtime.InteropServices.ComTypes.DVASPECT.DVASPECT_CONTENT }); } return new FormatEnum(formats.ToArray()); } public int DAdvise(ref System.Runtime.InteropServices.ComTypes.FORMATETC formatetc, System.Runtime.InteropServices.ComTypes.ADVF advf, System.Runtime.InteropServices.ComTypes.IAdviseSink adviseSink, out int connection) { connection = 0; return unchecked((int)0x80004001); // E_NOTIMPL } public void DUnadvise(int connection) => throw new NotImplementedException(); public int EnumDAdvise(out System.Runtime.InteropServices.ComTypes.IEnumSTATDATA ppenumAdvise) { ppenumAdvise = null; return unchecked((int)0x80004001); // E_NOTIMPL } } // 辅助类:FORMATETC枚举器 public class FormatEnum : System.Runtime.InteropServices.ComTypes.IEnumFORMATETC { private readonly System.Runtime.InteropServices.ComTypes.FORMATETC[] _formats; private int _currentIndex = 0; public FormatEnum(System.Runtime.InteropServices.ComTypes.FORMATETC[] formats) { _formats = formats; } public int Next(int celt, System.Runtime.InteropServices.ComTypes.FORMATETC[] rgelt, int[] pceltFetched) { int fetched = 0; while (_currentIndex < _formats.Length && fetched < celt) { rgelt[fetched] = _formats[_currentIndex]; fetched++; _currentIndex++; } pceltFetched?[0] = fetched; return fetched == celt ? 0 : unchecked((int)0x80004004); // S_FALSE } public int Skip(int celt) { _currentIndex += celt; return _currentIndex >= _formats.Length ? unchecked((int)0x80004004) : 0; } public int Reset() { _currentIndex = 0; return 0; } public void Clone(out System.Runtime.InteropServices.ComTypes.IEnumFORMATETC ppenum) { ppenum = new FormatEnum(_formats); ((FormatEnum)ppenum)._currentIndex = _currentIndex; } } // 原生Win32方法封装 internal static class NativeMethods { [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] public static extern uint RegisterClipboardFormat(string lpszFormat); [DllImport("ole32.dll")] public static extern int OleSetClipboard(System.Runtime.InteropServices.ComTypes.IDataObject pDataObj); [DllImport("ole32.dll")] public static extern int OleFlushClipboard(); } // DateTime扩展方法:转换为FILETIME结构体 internal static class DateTimeExtensions { public static System.Runtime.InteropServices.ComTypes.FILETIME ToFileTimeStruct(this DateTime dt) { long fileTime = dt.ToFileTime(); return Marshal.PtrToStructure<System.Runtime.InteropServices.ComTypes.FILETIME>(Marshal.AllocCoTaskMem(8)); } }
3. 剪贴板操作工具类
使用原生OleSetClipboard设置自定义数据对象:
public static class ClipboardHelper { public static void SetLazyMultiFileData(List<(string FileName, Func<Stream> GetContentStream)> files) { if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA) { throw new InvalidOperationException("剪贴板操作必须在STA线程中执行"); } var dataObj = new LazyMultiFileDataObject(files); NativeMethods.OleSetClipboard(dataObj); } public static void ClearClipboard() { NativeMethods.OleFlushClipboard(); } }
4. 使用示例
[STAThread] static void Main(string[] args) { // 模拟延迟加载的文件列表:文件名 + 内容生成委托 var files = new List<(string, Func<Stream>)> { ("文档1.txt", () => new MemoryStream(Encoding.UTF8.GetBytes("这是延迟加载的文档1内容"))), ("报表.xlsx", () => File.OpenRead(@"C:\Temp\模板.xlsx")), ("日志文件.log", () => new MemoryStream(Encoding.UTF8.GetBytes("这是延迟加载的日志内容"))) }; ClipboardHelper.SetLazyMultiFileData(files); Console.WriteLine("多文件已放入剪贴板,请尝试粘贴到资源管理器"); Console.ReadLine(); ClipboardHelper.ClearClipboard(); }
关键注意事项
- STA线程要求:所有剪贴板操作必须在STA(单线程单元)线程中执行,否则会出现COM交互错误。
- 内存管理:所有通过
Marshal.AllocCoTaskMem分配的内存必须正确释放,避免内存泄漏。 - 流兼容性:自定义内容流必须支持完整的读取操作,粘贴端会依赖流内容生成文件。
- 结构体一致性:
FILEDESCRIPTORW和FILEGROUPDESCRIPTORW的定义必须与Windows原生完全匹配,否则粘贴端无法解析文件描述符。
内容的提问来源于stack exchange,提问作者TheNextman
相关产品推荐
相关产品推荐

