65.9K
CodeProject 正在变化。 阅读更多。
Home

纯 .NET C# DirectShow 过滤器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (76投票s)

2012年7月13日

CPOL

16分钟阅读

viewsIcon

543991

downloadIcon

37318

本文介绍了如何在 .NET 中创建 DirectShow 过滤器,其中包含 BaseClasses 和一些示例。

引言

在这篇文章中,我将介绍如何用纯 C# 创建 DirectShow 过滤器。我同样用纯 C# 制作了 BaseClasses 库和几个示例,以展示它们可以多么容易地被使用。我认为初级的多媒体开发者可以使用我的库,但要扩展它,你需要了解 COM、封送处理和线程。我建议你阅读我之前的文章,因为我不会重复其中描述的主要内容。

内容概述

过滤器的源代码包含两个项目:BaseClasses 和 ExampleFilters。BaseClasses 库包含以下类:

  • BaseEnum - 枚举的基类
  • EnumPins - IEnumPins 接口的实现。
  • EnumMediaTypes - IEnumMediaTypes 接口的实现。
  • BasePin - 引脚的基类。
  • BaseInputPin - 输入引脚的基类。
  • BaseOutputPin - 输出引脚的基类。
  • BaseFilter - 过滤器实现的基类。
  • TransformInputPin - 变换过滤器的输入引脚类。
  • TransformOutputPin - 变换过滤器的输出引脚类。
  • TransformFilter - 变换过滤器的基类。
  • TransInPlaceInputPin - 原地变换过滤器的输入引脚类。
  • TransInPlaceOutputPin - 原地变换过滤器的输出引脚类。
  • TransInPlaceFilter - 原地变换过滤器的基类。
  • RenderedInputPin - 渲染过滤器的输入引脚基类。
  • AMThread - 线程实现的基类。
  • ManagedThread - AMThread 的托管线程实现。
  • SourceStream - 基输出流引脚类。
  • BaseSourceFilter - 源过滤器实现的基类。
  • OutputQueue - 实现用于传递媒体样本的队列的类。
  • BasePropertyPage - 用于实现过滤器属性页的类。
  • PosPassThru - 处理查找命令,将它们传递给下一个过滤器的类。
  • RendererPosPassThru - 处理渲染过滤器查找命令的类。
  • RendererInputPin - 为 BaseRendererFilter 类实现输入引脚的类。
  • MessageDispatcher - 处理用户线程未阻塞通知并分发窗口消息的类。
  • AsyncStream - IAsyncReader 接口的 System.IO.Stream 实现。
  • COMStream - IStream 接口的 System.IO.Stream 实现。
  • PacketData - 用于描述队列媒体数据的基类。
  • PacketsQueue - PacketData 对象的队列类。
  • BitStreamReader - 用于从 System.IO.Stream 对象读取二进制数据的辅助类。它支持 IStreamIAsyncReader 接口。允许按位、按数组或按结构读取数据。类还支持 golomb SE 和 UE 值。读取通过缓存进行。
  • DemuxTrack - 实现分离器轨道的基类。
  • FileParser - 实现分离器文件解析支持的基类。
  • SplitterInputPin - 实现 BaseSplitterFilter 输入引脚的类。
  • SplitterOutputPin - 为 BaseSplitterFilterBaseFileSourceFilter 对象实现输出引脚。
  • BaseSplitter - 实现分离器或文件源实现的类核心功能的类。
  • BaseSplitterFilter - 实现具有单个输入引脚的分离器过滤器的基类,该引脚支持连接到 IAsyncReader,输出引脚在连接后创建。过滤器支持查找。
  • BaseFileSourceFilter - 实现文件源过滤器的基类,它没有输入引脚,但支持 IFileSourceFilter 接口,输出引脚在文件加载后创建。

解决方案中的另一个项目包含以下示例过滤器:

  • NullInPlaceFilter - 一个原地变换过滤器,可用作基类。
  • NullTransformFilter - 一个变换过滤器,可用作基类。
  • DumpFilter - 用于将传入数据保存到文件的过滤器。
  • TextOverFilter - 一个变换过滤器,在传入视频上显示叠加文本。
  • ImageSourceFilter - 推送源过滤器,将加载的图像文件显示为视频流。
  • ScreenCaptureFilter - 推送源过滤器,捕获显示并将其作为视频流提供。
  • VideoRotationFilter - 一个变换过滤器,将视频旋转 90 度。
  • WavDestFilter - 用于将传入的 PCM 数据保存到 WAV 文件的过滤器。
  • AudioChannelFilter - 一个变换过滤器,用于合并传入的 PCM 音频数据并将其发送到指定的通道。
  • InfTeeFilter - 将传入的样本传递给任何已连接输出引脚的过滤器。
  • NullRendererFilter - 实现为标准的“Null Renderer”过滤器的过滤器,它继承自基渲染过滤器并展示了需要覆盖的基本方法。
  • WAVESplitterFilter - 实现 WAVE 文件格式的分离器过滤器的过滤器。
  • WAVESourceFilter - 实现 WAVE 文件格式的源过滤器的过滤器。
  • NetworkSyncFilterNetworkSource 过滤器是实现组播视频流的示例。

此外,我还包含了带有示例应用程序的解决方案,向您展示了如何使用我的辅助类,并将过滤器嵌入到应用程序中使用,而无需注册。

  • DxCapture - 示例展示了如何构建视频捕获应用程序。
  • DxGrabber - 示例用法样本抓取器。
  • DxPlayer - 基本的音频视频播放器应用程序。
  • WavCapture - 示例演示了如何构建音频捕获应用程序,该示例嵌入了 wave out 过滤器。
  • WavExtract - 示例展示了如何从媒体文件中提取音频数据并将其保存为 WAVE 格式,该示例嵌入了 wave out 过滤器。
  • WavPlay - 示例用法嵌入的 WAVE 源过滤器来播放 .wav 音频文件。

BaseClasses

BaseClasses 还使用了我的类库和 COM 辅助对象中的一些内容。这些对象在我的先前文章中有部分描述。对于跟踪和调试,我建议使用 TRACETRACE_ENTERASSERT 函数;**不要**使用 Debug.Write 和抛出异常——具体原因在之前的文章中有描述。此外,我建议阅读我之前关于线程的描述(因为我不想在这里重复)。我的实现方法与 Microsoft 的原生方法类似,所以我将只描述区别。好的,如果你关注了所有这些方面,我们就开始回顾吧。

过滤器注册

过滤器注册过程与 .NET COM 对象类似。过滤器 DLL **应该**签名。你可以将类型库作为非托管资源嵌入到 DLL 中,但这并非强制。在示例过滤器中,注册是自动执行的,请参阅 *install.bat* 和 *uninstall.bat* 文件以及生成后事件。你可以在项目设置中指定注册,但这可能不起作用。我让过滤器注册非常简单。为此,有一个类属性,带有一个不同的构造函数:

// Class declaration
[AttributeUsage(AttributeTargets.Class)]
public class AMovieSetup : Attribute 
//.............
// Constructors
public AMovieSetup() 
public AMovieSetup(bool _register) 
public AMovieSetup(string _name) 
public AMovieSetup(Merit _merit) 
public AMovieSetup(Merit _merit, Guid _category) 
public AMovieSetup(string _name, Merit _merit) 
public AMovieSetup(string _name, Merit _merit, Guid _category) 

该属性应指定为要注册为 DirectShow 过滤器的类。类应继承自任何基过滤器类,例如 BaseFilterTransInPlaceFilterTransformFilterBaseSourceFilter。在属性中,你可以指定过滤器的 namemeritcategory。如果未指定名称,则将使用过滤器类中指定的名称。默认 Merit 值为 *DoNotUse*,默认注册 category 为 *AmLegacyFiltersCategory*。过滤器的另一个**必需**属性是 **Guid**,它将是过滤器的 CLSID。过滤器 BaseFilter 类中的以下例程展示了如何执行注册和注销:

[ComRegisterFunctionAttribute]
public static void RegisterFunction(Type _type) 
 
[ComUnregisterFunctionAttribute] 
public static void UnregisterFunction(Type _type)

过滤器声明示例

// Here name not specified and will be used name setted in constructor 
// Category will be used by default AmLegacyFilters an merit - do not use 
[ComVisible(true)] 
[Guid("eeb3eef7-0592-491b-b7d4-8c65763c79c6")] // Filter CLSID 
[AMovieSetup(true)] // We should register 
public class NullInPlaceFilter : TransInPlaceFilter 
{ 
    public NullInPlaceFilter() 
    : base("CSharp Null InPlace Filter") {} // this name will be used 
//.............. 
}

与原生 BaseClasses 的另一个区别是引脚访问。用于 Pins 属性和初始化的是 OnInitializePins 例程,它在 BaseFilter 类中是抽象的。

protected abstract int OnInitializePins();

除了引脚初始化,你还可以通过以下辅助例程动态操作引脚:

public int AddPin(BasePin _pin) 
public int RemovePin(BasePin _pin) 

注册过滤器在 GraphEdit 中的样子

要创建自己的过滤器,只需继承自任何基过滤器类,指定必需的属性并添加功能即可。

自定义过滤器注册

在某些情况下,可能需要执行自定义过滤器注册,无论是否包含基础注册。为此,添加了一些方法。例如,你可以使用这些方法来为你的分离器/源过滤器注册文件扩展名。

protected virtual int BeforeInstall(ref RegFilter2 _reginfo,ref IFilterMapper2 _mapper2)
{
    return NOERROR;
}

protected virtual int AfterInstall(HRESULT hr,ref RegFilter2 _reginfo, ref IFilterMapper2 _mapper2)
{
    return NOERROR;
}

protected virtual int BeforeUninstall(ref IFilterMapper2 _mapper2)
{
    return NOERROR;
}

protected virtual int AfterUninstall(HRESULT hr, ref IFilterMapper2 _mapper2)
{
    return NOERROR;
}

你可以在注册过滤器之前重新配置 _reginfo 变量,并在 BeforeInstall 方法中为某些字段分配内存。并在 AfterInstall 方法中释放分配的内存。如果 BeforeInstall 返回失败 - 过滤器未注册,AfterInstall 不会被调用。AfterInstall 接收注册的 HRESULT 值,因此你可以检查过滤器是否成功注册并执行其他注册功能。

属性页

创建过滤器属性页也非常简单。你应该创建一个 Window Form 并让它继承自 BasePropertyPage 类,而不是 Windows.Form。你创建的窗体也需要 Guid 属性,因为这也是 COM 对象。BasePropertyPage 类具有与原生 BaseClasses 中的基类相同的可覆盖方法。要将你的属性页分配给过滤器,你应该在你的过滤器上指定一个属性。该属性看起来像这样:

[ComVisible(true)]
[AttributeUsage(AttributeTargets.Class)]
public class PropPageSetup : Attribute
//........

public PropPageSetup(Guid _guid)
public PropPageSetup(Guid _guid1,Guid _guid2)
public PropPageSetup(Guid _guid1,Guid _guid2,Guid _guid3)
public PropPageSetup(Type _type)
public PropPageSetup(Type _type1, Type _type2)
public PropPageSetup(Type _type1,Type _type2,Type _type3)

该属性有几个构造函数。指定属性页到过滤器的示例:

[ComVisible(true)]
[Guid("701F5A6E-CE48-4dd8-A619-3FEB42E4AC77")] 
[AMovieSetup(true)] 
[PropPageSetup(typeof(AudioChannelForm),typeof(AboutForm))] 
public class AudioChannelFilter : TransformFilter, IAudioChannel 

AudioChannelFilter 的属性页外观

文件分离器和文件源实现

在这一部分,我将简要概述类库,因为原生 BaseClasses 中没有类似的东西,但这可以帮助你实现现有格式的解复用器,甚至创建你自己的媒体文件格式。解复用器的基类是 BaseSplitterFilterBaseFileSourceFilter,选择哪一个取决于你所需的功能。大多数情况下,你不需要修改这些基类。但是对于你的文件格式,你应该至少创建 2 个类,并让它们继承自 FileParserDemuxTrack

FileParser - 用于执行文件检查和初始化轨道的基类。在此,你应该重写至少两个方法。CheckFile - 在这里,你应该验证你是否可以解析给定的文件或流。**注意**,该方法在文件或流实现时都会被调用,如果是文件源,第一次调用时只初始化了 m_sFileName 变量,第二次(如果第一次失败)调用时则使用 m_Stream,这是一个 BitStreamReader 类的对象。另一个需要重写的方法是抽象的 LoadTracks - 在这里,你应该初始化轨道(继承自 DemuxTrack 的类)并将它们放入 m_Tracks 列表中。在此方法中,你还可以初始化文件持续时间(以纳秒为单位)(m_rtDuration 变量)。解析工作分为 2 种模式:单解析线程模型 - 存在一个主线程,并在其中执行解复用;多线程解析模型 - 在此,每个引脚负责解析数据。**注意**:访问位流以读取数据是线程安全的。要指定使用哪种模式,请在 FileParser 构造函数中指定布尔参数 - bRequireDemuxThread - true 表示单线程模型(默认)。关于这 2 种模式以及如何选择,我计划在另一篇文章中描述,这里只进行实现概述。

单线程模型 - 要使用此模型,你应该在 FileParser 类中重写另外 2 个方法。SeekToTime - 用于支持查找 - 在此方法中,你应该根据指定的时间设置读取位置。ProcessDemuxPackets - 用于解复用数据并将其放入轨道队列的主要方法。在此,你应该创建一个 PacketData 对象,用数据填充它,并通过调用 AddToCache 方法将其放入 DemuxTrack。**注意**:一旦队列已满,此方法将阻塞直到样本传递到过滤器。**注意**:如果存在关键的非阻塞来避免挂起,则 DemuxTrack 类中有一个 Reset 方法,并且所有线程都会因在 BaseSplitter 中设置退出事件而自动退出。但我建议你不要手动使用任何这些,因为这些都由类处理。

多线程模型 - 在此模型中,每个轨道读取和传递数据,并且可能不使用队列。要实现此模型,你应该在继承自 DemuxTrack 的类中重写至少 2 个方法:GetNextPacket - 返回填充的 PacketData 对象,或者在 EOS 的情况下返回 null;以及 SeekToTime - 与单模型中的目的相同,但在此处直接在每个轨道上执行查找。

DemuxTrack - 每个轨道的基类。你应该重写其抽象方法 GetMediaType。其他方法可以根据你选择的模型或特定的轨道实现进行重写。

PacketData - 用于指定轨道媒体数据的基类。它可以包含实际数据或指向文件位置的指针。无论如何,你可以为你自己的提供媒体数据的方式重写此类,在这种情况下,你应该重写 Dispose 方法来清理资源,以及可能在 DemuxTrack 类中重写 FillSampleBuffer 方法以从你的类填充媒体样本缓冲区。

PacketsQueue - 单线程模型中使用的样本队列类。该类允许从队列添加和删除 PacketData 对象,并发出队列已满或为空的信号。队列可以按时间戳对样本进行排序 - 当你在过滤器中执行解复用和解码时会用到此功能。默认缓存大小为 2 秒 - 在引脚激活后分配。

BitStreamReader - 用于从给定流读取数据的类。辅助类允许线程安全地按对象、位、字节或 golomb 值读取数据。

在示例中有 2 个过滤器向你展示了解复用器实现的基础知识:WAVESplitterFilterWAVESourceFilter。但它极大地简化了创建你自己的类,我计划在后续文章中更详细地描述这些类,例如,我制作了一个 Windows Media Splitter,在下一张图中可以看到(是的,单个文件中存在 4 个流 - 只是与我自己的多路复用器一起操作 - 但分离器可以轻松处理)。

将过滤器参数保存在注册表中

为了更好地根据之前设置的参数配置过滤器,存在两个辅助方法。它们允许你从系统注册表中读取或写入字符串或数值参数。这些方法位于 BaseFilter 类中。

// Retrieve value from registry for current filter
protected object GetFilterRegistryValue(string _name,object _default)

// Set registry value for current filter
protected bool SetFilterRegistryValue(string _name,object _value)

参数存储在过滤器注册表子键下,因此是个人过滤器数据。**注意**:所有设置在过滤器注销时都会被删除。

将过滤器参数保存在图文件中

除了将参数保存在注册表中,过滤器还允许从保存的图中持久化保存和加载数据。每个过滤器都支持 IPersistStream 接口,并提供一些辅助方法来使用它。

// Set or clear flag that properties are modified
protected HRESULT SetDirty(bool bDirty)
// Get's the size of persistent data
protected virtual long SizeMax()
// Write filter properties to the stream
protected virtual HRESULT WriteToStream(Stream _stream)
// Read filter properties from stream
protected virtual HRESULT ReadFromStream(Stream _stream)

要标记持久化数据已修改,你应该使用 SetDirty 方法。加载和保存信息的参数是 .NET 类型 System.IO.Stream。**注意**:如果你不重写 SizeMax 方法,则流写入可能会执行两次:第一次计算最大输出大小,第二次实际写入数据。在你的过滤器实现中,你应该至少重写 WriteToStreamReadFromStream 方法。

将过滤器嵌入到应用程序中

你可能不知道过滤器可以在应用程序内部创建并插入到过滤器图中。为此,过滤器不注册到注册表中,只有你的应用程序才能使用它。这也可以在 .NET 中实现。如果你的过滤器位于将注册为 COM 库的程序集中,则使你的过滤器带有 [ComVisible(false)] 属性,在应用程序中你可以不使用此属性。此外,我们不需要任何其他注册属性,因此可以删除所有这些,甚至 Guid 属性,因为过滤器注册将不会被调用。请记住,在这种情况下,持久化数据保存将无法工作,包括注册表值。如果需要属性页,它们应该注册为 COM,但我不能确定嵌入过滤器是否需要属性页,因为你可以在应用程序中手动配置所有设置。应用程序中过滤器声明的示例:

public class WAVESourceFilter : BaseSourceFilterTemplate<WaveParser>
{
    public WAVESourceFilter()
        : base("CSharp WAVE Source")
    {
    }
}

一旦过滤器类准备好,你就可以将其创建为特定的 .NET 对象。由于过滤器支持 IBaseFilter 接口,你可以将其插入到 Filter Graph 中并无问题地使用它。

public class WavPlayback : DSFilePlayback
{
    protected override HRESULT OnInitInterfaces()
    {
        // Create Filter
        DSBaseSourceFilter _source = new DSBaseSourceFilter(new WAVESourceFilter());
        // load the file
        _source.FileName = m_sFileName;
        // Add to the filter Graph
        _source.FilterGraph = m_GraphBuilder;
        // Render the output pin
        return _source.OutputPin.Render();
    }
}

在下载的示例应用程序中有 3 个示例(WavCapture、WavExtract 和 WavPlay),它们展示了如何在应用程序中使用嵌入的过滤器。

DirectShowNET 库

BaseClasses **不**使用 DirectShowNET 库,它包含一些相同的接口和结构,但封送方式不同。这是必要且非常重要的,因为在许多情况下,我们需要 IntPtr 而不是实际的接口,以避免我之前文章中描述的线程问题。通过我的魔法类(也来自之前的文章)VTableInterface 访问实际接口。我对其进行了一些修改并增加了更多功能。你也可以在过滤器中使用 DirectShowNET 库,但要记住可能出现的模糊问题。我将我的 directshow 辅助类(用于方便地构建过滤器图)作为单独的可下载内容放在本文中,而不是 DirectShowNET 库。

示例过滤器

NullInPlaceFilter

示例原地变换过滤器,它不修改数据,可以用作开发过滤器的起点。

NullTransformFilter

示例变换过滤器,它仅复制媒体样本而不进行修改。此示例可用作开发自己的变换过滤器的起点。

DumpFilter

示例过滤器,用于将传入数据写入指定文件。过滤器支持 IFileSinkFilterIAMFilterMiscFlags。过滤器接受任何传入类型。

TextOverFilter

示例变换过滤器,演示如何在视频流上绘制文本。接受的媒体类型为 RGB32,过滤器不执行间距校正,将通过 ColorSpace Converter 连接到视频渲染器。

VideoRotationFilter

示例变换过滤器,将视频旋转 90 度。接受的媒体类型为 RGB24,过滤器不执行间距校正,将通过 ColorSpace Converter 连接到视频渲染器。

ImageSourceFilter

示例实现推送源过滤器,将加载的图像文件作为视频流提供。过滤器支持 IFileSourceFilter 接口。输出宽度和高度根据加载的图像分辨率设置。默认 FPS 为 20。输出媒体格式为 RGB32。过滤器不执行间距校正,将通过 ColorSpace Converter 连接到视频渲染器。

ScreenCaptureFilter

示例推送源过滤器,通过 GDI 提供当前桌面图像的副本。输出宽度和高度设置为 640x480,过滤器执行缩放以适应该分辨率。默认 FPS 为 20。输出媒体格式为 RGB32。过滤器不执行间距校正,将通过 ColorSpace Converter 连接到视频渲染器。

WavDestFilter

示例过滤器,它将音频流写入 WAV 文件。过滤器支持 IFileSinkFilterIAMFilterMiscFlags。过滤器接受 PCM 媒体类型和 WaveFormatEx 格式类型。

AudioChannelFilter

示例变换过滤器,它将音频输出到指定的通道。过滤器支持 WAVE_FORMAT_PCM 格式的 PCM 输入和 8 或 16 位的 BitsPerSample。输出媒体格式为 WaveFormatExtensible,具有一个通道输出和扬声器配置。过滤器支持自定义接口,通过使用它可以指定输出通道。

public enum AudioChannel : int 
{ 
    FRONT_LEFT = 0x1, 
    FRONT_RIGHT = 0x2, 
    FRONT_CENTER = 0x4, 
    LOW_FREQUENCY = 0x8, 
    BACK_LEFT = 0x10, 
    BACK_RIGHT = 0x20, 
    SIDE_LEFT = 0x200, 
    SIDE_RIGHT = 0x400, 
} 
 
[ComVisible(true)] 
[System.Security.SuppressUnmanagedCodeSecurity, 
Guid("29D64CCD-D271-4390-8CF2-89D445E7814B"), 
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] 
public interface IAudioChannel 
{ 
    [PreserveSig] 
    int put_ActiveChannel([In] AudioChannel _channel); 
 
    [PreserveSig] 
    int get_ActiveChannel([Out] out AudioChannel _channel); 
}

InfTeeFilter

示例类似于 Microsoft InfTee 示例 DirectShow 过滤器。

Null Renderer Filter

过滤器继承自 BaseRendererFilter,并且工作方式与标准的“Null Renderer”相同 - 通过丢弃所有接收到的样本而无需显示或渲染它们。

WAVE Splitter Filter

分离器过滤器示例,它解析 WAVE 文件数据并将其传递给下游过滤器。过滤器有一个输入引脚,并支持连接到带有 IAsyncReader 接口的引脚(例如“File Source Async”过滤器)。输出引脚在输入引脚连接后创建。过滤器不会自动注册使用,因此您需要手动构建图。

WAVE Source Filter

源过滤器示例,它解析 WAVE 文件数据并将其传递给下游过滤器。过滤器支持 IFileSourceFilter 并且没有输入引脚。输出引脚在输入引脚连接后创建。过滤器不会自动注册使用,因此您需要手动构建图。

Network Sync 和 Source Filters

示例是 2 个过滤器:发送方和接收方,它们将压缩为 JPEG 的视频数据进行组播。一个 Sync 过滤器可以连接到多个源过滤器。Sync 过滤器继承自 BaseRendrerFilter 并支持 RGB24 输入。一旦图变为活动状态,sync 过滤器就开始组播传入的样本。可以通过 INetworkConfig 接口配置组播设置。

[ComVisible(true)]
[System.Security.SuppressUnmanagedCodeSecurity]
[Guid("96D8A0B7-8EE7-4325-98D4-BB7C66F27B1A")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface INetworkConfig
{
    string IP { get; set; }
    int Port { get; set; }
}

该接口允许指定组播 IP 地址和端口。在 GraphEdit 中,你可以使用属性页进行此操作。

源过滤器一旦添加到过滤器图中,就会在一个单独的线程中开始等待样本。仅当至少一个样本被接收后,输出引脚上的 MediaType 才可用。过滤器还支持 INetworkConfig 接口。**注意**,由于要发送的数据可能很大且在系统级别不受支持,过滤器可能无法在高分辨率下工作。

高级示例

我将作为单独的文章发布更多示例过滤器,因为它们需要审查代码。目前可用的示例包括:

历史

  • 2012-07-13 - 初始版本。
  • 2012-07-15 - 添加了属性页、OutputQueue 和 InfTeeFilter 示例。
  • 2012-08-09 - 进行了一些改进。
  • 2012-08-14 - 实现了 BaseRenderer、BaseSplitter 和 BaseFileSource,并添加了几个示例。
  • 2012-09-19 - 添加了应用程序示例。
  • 2012-10-13 - 解决了 .NET Framework 2.0 中丢失方法的问题。修改了类库以解决其他一些问题。
© . All rights reserved.