本文最后更新于:几秒前
前言 关于 Marshal,中文名 封送,指 .NET 运行时 (CLR) 与非托管代码 (Managed <-> Unmanaged) 交互 (Interoperability) 时双向传输对象的机制。
封送主要有自动和手动两种。如果我们直接在导入函数签名或结构体等中声明 非 Blittable 类型 ,在互操作时 CLR 会自动处理;相对的,如果我们声明为 IntPtr,特别是涉及到指针的,此时就需要我们调用 System.Runtime.InteropServices.Marshal 这个静态类中一系列的方法来手动管理内存分配以及封送。
在 Interop 中,我们通常会定义一大堆结构体,这其实是可以理解的。但是,如果我们遇到了以下特殊场景,就得用特殊手段了:(点击链接可以跳转到相应的部分)
空结构体的神奇用法 场景 :使用 WinForms 自带的方法将 LOGFONT 转为 Font。(在 自定义 Font/ColorDialog 等常用对话框样式 中提到过)示例代码 :链接
这是 LOGFONT 的定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode) ]public struct LOGFONT { public int lfHeight; public int lfWidth; public int lfEscapement; public int lfOrientation; public int lfWeight; public byte lfItalic; public byte lfUnderline; public byte lfStrikeOut; public byte lfCharSet; public byte lfOutPrecision; public byte lfClipPrecision; public byte lfQuality; public byte lfPitchAndFamily; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32) ] public string lfFaceName; }
如果你需要把它转换为 Font 类型使用,以及将 Font 转换为 LOGFONT,可以用以下方法:
1 2 3 4 5 6 7 8 9 10 11 12 public readonly Font ToFont () { using var tmp = Font.FromLogFont(this ); return new (tmp.FontFamily, tmp.SizeInPoints, tmp.Style, GraphicsUnit.Point, tmp.GdiCharSet, tmp.GdiVerticalFont); }public static LOGFONT FromFont (Font font ) { object lfobj = new LOGFONT(); font.ToLogFont(lfobj); return (LOGFONT)lfobj; }
可以看到,上述两个方法都没有用到 LOGFONT 的任何一个字段就完成了与 Font 的相互转换,那我们是否可以将字段全部省略呢?
但是省略之后,结构体就空了,也就是没有大小了,传入导入函数必定会出错。
如果你在写 StructLayout 特性时好奇过我们几乎不会用到的 Size 属性:
想必你已经突然意识到这个 Size 是干什么的了,也就是指定该结构的绝对大小,不受操作系统、字符集等的影响。有了这个,CLR 就会用我们定义的大小,而不是帮我们计算。
也就是说我们删除 LOGFONT 所有字段后再显式指定 Size 属性就行,只要大小对的上,传入到函数就基本不会有问题。
那 LOGFONT 的大小怎么获取呢?我们可以调用一下 Marshal.SizeOf 方法 (先别删除所有字段):
当然如果你也可以手动计算:5个int+8个byte+32个宽字符=4x5+1x8+2x32=92
。
除此之外如果你的 Visual Studio 安装了 C++ 以及 Windows SDK,你可以直接打开 LOGFONT 结构体所在头文件,并将光标悬停在类型名上,你就会发现:
于是我们就知道了 LOGFONT 的 Size 是 92,保险起见我们还是再指定字符集为 Unicode。完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode, Size = 92) ]public struct LOGFONT { public readonly Font ToFont () { using var tmp = Font.FromLogFont(this ); return new (tmp.FontFamily, tmp.SizeInPoints, tmp.Style, GraphicsUnit.Point, tmp.GdiCharSet, tmp.GdiVerticalFont); } public static LOGFONT FromFont (Font font ) { object lfobj = default (LOGFONT); font.ToLogFont(lfobj); return (LOGFONT)lfobj; } }
是不是感觉非常简洁?
不定义直接访问字段 场景 :使用 NMHDR 和 NMCUSTOMDRAW 控制 ListView 自绘行为 (在 从零开始让你的 WinForms 应用程序也用上原生深色主题 中提到过)示例代码 :链接
这是 NMHDR 和 NMCUSTOMDRAW 的定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 [StructLayout(LayoutKind.Sequential) ]public struct NMHDR { public IntPtr hWndFrom; public IntPtr idFrom; public int code; } [StructLayout(LayoutKind.Sequential) ]public struct NMCUSTOMDRAW { public NMHDR hdr; public int dwDrawStage; public IntPtr hdc; public RECT rc; public IntPtr dwItemSpec; public uint uItemState; public IntPtr lItemlParam; }
并通过以下代码来控制 ListView 自绘
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 protected override void WndProc (ref Message m ) { if (m.Msg == WM_NOTIFY && Marshal.PtrToStructure<NMHDR>(m.LParam).code == NM_CUSTOMDRAW) { var nmcd = Marshal.PtrToStructure<NMCUSTOMDRAW>(m.LParam); switch (nmcd.dwDrawStage) { case CDDS_PREPAINT: m.Result = new (CDRF_NOTIFYITEMDRAW); return ; case CDDS_ITEMPREPAINT: SetTextColor(nmcd.hdc, Color.White); m.Result = new (CDRF_DODEFAULT); return ; } } base .WndProc(ref m); }
可以看到我们定义的结构体字段还是比较多的,且只从非托管拷贝,读取其中字段,不需要我们创建实例。因此就显得不必大费周章地去定义结构体。
那有没有办法能够不定义,就能读取到字段呢?还真有。
如果我们知道了指向该结构体的指针 (m.LParam),并知道想要的字段在哪个地方 (偏移),以及字段的类型 (决定了要读多少内存),那么是不是就可以拿到字段的值了?
我们要获取的字段及类型有
int NMHDR.code
int NMCUSTOMDRAW.dwDrawStage
IntPtr NMCUSTOMDRAW.hdc
在 Marshal 类中,有这样的一些方法:(未列举未在本场景使用的方法)
1 2 3 public unsafe static int ReadInt32 (IntPtr ptr, int ofs ) ;public static IntPtr ReadIntPtr (IntPtr ptr, int ofs ) ;
这些方法允许我们传入指针,以及偏移,就能拿到相应的对象。这不就是我们想要的吗?
这里又有一个新问题,我们应该怎么样才能得到字段的偏移?
不用担心,Marshal 也有对应的方法 OffsetOf,以 NMHDR.code 为例:
同样的,像前面看 LOGFONT 的大小一样,Visual Studio 也能直接看到结构体的各字段偏移。我们还是先把光标悬停在 NMHDR 的类型名上,点击内存布局 (Memory Layout),会弹出一个标签页,如下:
这样一来,我们就得到了所需字段的偏移,于是我们现在就可以删除那两个结构体的定义了,完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 protected override void WndProc (ref Message m ) { if (m.Msg == WM_NOTIFY && Marshal.ReadInt32(m.LParam, 16 ) == NM_CUSTOMDRAW) { switch (Marshal.ReadInt32(m.LParam, 24 )) { case CDDS_PREPAINT: m.Result = new (CDRF_NOTIFYITEMDRAW); return ; case CDDS_ITEMPREPAINT: SetTextColor(Marshal.ReadIntPtr(m.LParam, 32 ), Color.White); m.Result = new (CDRF_DODEFAULT); return ; } } base .WndProc(ref m); }
总结
省略结构体字段时注意在 StructLayout 上指定 Size 属性,确保封送后内存大小与 API 定义一致,其值必须是字段完整前提下获取到的。
使用 Marshal.Read* 系列方法可以不用定义结构体类型就能拿到其中的字段,注意非托管类型与托管类型的映射。