zoukankan      html  css  js  c++  java
  • 与Wii控制手柄通信的托管代码库(转)

    2009-01-16 翻译

    HID Human Input Device     人工输入设备

    Wii Fit Balance Board       平衡板

    IR                          红外传感器

    Windows Driver Kit          Windows驱动开发包

    Wiimote                     Wii控制手柄

    Report                      报文

     

    2007-3-14 在Coding4Fun发布

     

    在本文中, Brian Peek演示了如何使用C#和VB.NET连接和使用任天堂控制手柄(Wiimote)。最终成果是一个可以在托管代码应用中方便使用的Wiimot托管代码API。

    Brian Peek

    ASPSOFT, Inc.

     

    难度: 中级

    时间: 1-3 小时

    花费: 小于$50 (如果已经有Wiimote则免费)

    软件: Visual C# or Visual Basic 2008 Express Editions

    硬件: Nintendo Wii控制手柄(Wiimote), 一个兼容的PC蓝牙适配器

    下载: CodePlex

     

    第0章       更新历史

    (最新版本已经为1.6, 以下为1.6的更新记录。译者注)

    •  
      • 新增对平衡板“重力中心”的计算(感谢Steven Battersby)
      • 结构声明为[Serializable] (Caio建议)
      • 电池(Battery)属性现在改为浮点型,能够反映电池剩余电量的百分比。
      • BatteryRaw为字节类型,原本用于保存电池属性。
      • WiimoteTest应用程序现在在启动时能够正确的读取扩展信息。
      • 在Wiimote对象中新增HIIDevicePath属性,以显示HID设备路径。
      • 写的时间延时由100毫秒改为50毫秒,这能够提高LED和滚轮的响应能力。

    6/15/08Version 1.5.2,平衡板能够真正的工作了……

    6/12/08 – 将Version 1.5.1 从CodePlex删除...事实证明该版本对大多数用户存在太多的BUG(尽管对我而言一直不错),期待1.5.2的很快到来...

    6/11/08Version 1.5.1在 CodePlex上发布。修改了一个关于平衡板的BUG。更正:  似乎很多人在使用该版本时发现仍然存在问题,原因是平衡板对响应时间的要求比较精确。等待1.5.2的到来

    6/9/08 – Version 1.5 在 CodePlex上发布。支持 Wii Fit Balance Board(平衡板)。

    6/3/08 –Version 1.4 发布并提供下载。最重要的改进是支持多个Wiimote。

    5/27/08 - Version 1.3发布并提供下载。改动量很大,一定要阅读内置的文档!

    1/29/08 - Version 1.2.1.0发布并提供下载。唯一的改进是对IR3和IR4的支持,因为我遇到了很多关于它们的问题。

    10/22/07 - Version 1.2.0.0发布并提供下载。修改了一些BUG并增加了新功能。源代码和二进制发布代码都在CodePlex上提供。同时提供了一个关于API使用的chm帮助文件。请留意库中包含的一个license文件详细描述的本软件的使用许可情况。使用许可对99%的用户没有任何变化,但由于我收到了大量的询问有关使用许可的电子邮件,因此附加了这个正式的说明。在正式发布中的docs目录下,readme.txt 和 license.txt有详细描述。

    6/12/07 - Version 1.1.0.0发布并提供下载。 修改了几个错误,新增了一个可选的写方法,这可能对使用蓝牙协议栈/适配器有麻烦的用户有所帮助。增加了对Vista/XP x64 的支持, 以及一个Microsoft Robotics Studio服务版本。更多信息见包含的 readme.txt的信息。另外,在我的新文档中描述了如何使用MSRS服务创建一个Wiimote遥控车。

    3/17/07 - Version 1.0.1.0发布并提供下载。 修正了API中关于校准数据的BUG。感谢James Darpinian 的指正!

     

    第1章       简介

    任天堂的Wii 控制手柄 (被称为Wiimote)是Wii系统中一个神奇的小控制器。它通过蓝牙系统与Wii连接, 因此能够与其它任何支持蓝牙的设备连接。

    如果你仅仅关注于如何使用这个库文件,而不关心其实现细节,可以直接跳转到API应用章节(第7章)。

    在阅读代码之前,有两个网站应当仔细的浏览一下。99%的Wiimote发送和接收数据的辨识工作由这两个网站完成。在网站上有对协议很详细的描述,我在此就不再重复。没有在这些网站上发贴的人们,本文中的功能根本不可能实现。

    第2章       开始连接

    这里可能是最关键的时刻,Wiimote不是能和地球上所有蓝牙设备和协议栈进行通信的, 如果下面几步不能通过,我也不能提供任何帮助。能够工作,或者不能,你只能进行祈祷了...

    1.启动你的蓝牙软件并开始搜索设备。

    2.按下Wiimote 的1 和 2 按键,会看到底部的LED开始闪烁。在完成之前不要松开按键。对于平衡板,打开下部的电池盖,按下一个红色的小的同步按钮。

    3.Wiimotes应当在设备列表中显示为Nintendo RVL-CNT-01。 平衡板显示为 Nintendo RVL-WBC-01。如果没找到,重新启动并再试一下。

    4.在向导中点击下一步。如果系统提示输入安全码或者PIN,不要输入或者选择跳过。

    5.如果被要求选择Wiimote 使用何种服务,选择键盘/鼠标/HID服务其中的一种。

    6.完成向导窗口。

    这就可以了。底部的LED继续闪烁,而且设备显示在蓝牙设备列表中。运行源代码中的应用程序,你应该可以看到显示的数字变化,这标志着成功的连接了Wiimote设备。如果没有变化或者出现错误, 只能重新试一下。要是还不行, 很不幸,你可能使用的是一种不兼容的设备或协议栈。(这个过程确实需要多次尝试,可以参考附录中译者的连接方法作为参考。)

    第3章       进入令人激动的HID和P/Invoke世界

    当Wiimote可以与你的PC匹配,它被视为一种HID兼容设备。因此,为了连接该设备,我们必须使用HID和设备管理WIN32 API。不幸的是,目前的.NET运行环境中没有内置支持这些API,因此需要进入P/Invoke的领域。这些API在Windows驱动开发包(WDK)中定义, 如果希望看到原始的C头文件或阅读API文档,需要下载并安装最新的WDK

    P/Invoke, 或许你已经有所了解,允许用户在.NET中直接调用Win32 API。这里的难点是找到合法的方法名称和格式定义,能够正确的将串行化数据并传递到Win32。对此P/Invoke wiki是一个很好的资源,几乎所有本项目用到的方法都在此能找到。在本项目中,所有的P/Invoke方法在HIDImports类中定义。

    与Wiimote通信的过程如下:

    1.从Windows得到GUID和HID类定义。

    2.得到HID类中所有设备的操作句柄。

    3.在设备列表中进行遍历以得到每个设备的详细信息。

    4.比较制造商ID和产品ID是否为已知的Wiimote制造商ID(VID)和产品ID(PID)。

    5.在找到设备后,创建FileStream对设备进行读写操作。

    6.清除设备列表。

     该步骤的实现代码如下(有省略)

    VB

    ' read/write handle to the device
    Private mHandle As SafeFileHandle
    
    ' a pretty .NET stream to read/write from/to
    Private mStream As FileStream
    Private found As Boolean = False
    Private guid As Guid
    Private index As UInteger = 0
    
    ' 1. get the GUID of the HID class
    HIDImports.HidD_GetHidGuid(guid)
    
    ' 2. get a handle to all devices that are part of the HID class
    Dim hDevInfo As IntPtr = HIDImports.SetupDiGetClassDevs(guid, Nothing, IntPtr.Zero, HIDImports.DIGCF_DEVICEINTERFACE) ' | HIDImports.DIGCF_PRESENT);
    
    ' create a new interface data struct and initialize its size
    Dim diData As HIDImports.SP_DEVICE_INTERFACE_DATA = New HIDImports.SP_DEVICE_INTERFACE_DATA()
    diData.cbSize = Marshal.SizeOf(diData)
    
    ' 3. get a device interface to a single device (enumerate all devices)
    Do While HIDImports.SetupDiEnumDeviceInterfaces(hDevInfo, IntPtr.Zero, guid, index, diData)
        ' create a detail struct and set its size
        Dim diDetail As HIDImports.SP_DEVICE_INTERFACE_DETAIL_DATA = New HIDImports.SP_DEVICE_INTERFACE_DETAIL_DATA()
        diDetail.cbSize = 5 'should be: (uint)Marshal.SizeOf(diDetail);, but that's the wrong size
    
        Dim size As UInt32 = 0
    
        ' get the buffer size for this device detail instance (returned in the size parameter)
        HIDImports.SetupDiGetDeviceInterfaceDetail(hDevInfo, diData, IntPtr.Zero, 0, size, IntPtr.Zero)
    
        ' actually get the detail struct
        If HIDImports.SetupDiGetDeviceInterfaceDetail(hDevInfo, diData, diDetail, size, size, IntPtr.Zero) Then
            ' open a read/write handle to our device using the DevicePath returned
            mHandle = HIDImports.CreateFile(diDetail.DevicePath, FileAccess.ReadWrite, FileShare.ReadWrite, IntPtr.Zero, FileMode.Open, HIDImports.EFileAttributes.Overlapped, IntPtr.Zero)
    
            ' 4. create an attributes struct and initialize the size
            Dim attrib As HIDImports.HIDD_ATTRIBUTES = New HIDImports.HIDD_ATTRIBUTES()
            attrib.Size = Marshal.SizeOf(attrib)
    
            ' get the attributes of the current device
            If HIDImports.HidD_GetAttributes(mHandle.DangerousGetHandle(), attrib) Then
                ' if the vendor and product IDs match up
                If attrib.VendorID = VID AndAlso attrib.ProductID = PID Then
                    ' 5. create a nice .NET FileStream wrapping the handle above
                    mStream = New FileStream(mHandle, FileAccess.ReadWrite, REPORT_LENGTH, True)
                Else
                    mHandle.Close()
                End If
            End If
        End If
    
        ' move to the next device
        index += 1
    Loop
    
    ' 6. clean up our list
    HIDImports.SetupDiDestroyDeviceInfoList(hDevInfo)

    C#

    // read/write handle to the device
    private SafeFileHandle mHandle;
    
    // a pretty .NET stream to read/write from/to
    private FileStream mStream;
    bool found = false;
    Guid guid;
    uint index = 0;
    
    // 1. get the GUID of the HID class
    HIDImports.HidD_GetHidGuid(out guid);
    
    // 2. get a handle to all devices that are part of the HID class
    IntPtr hDevInfo = HIDImports.SetupDiGetClassDevs(ref guid, null, IntPtr.Zero, HIDImports.DIGCF_DEVICEINTERFACE);// | HIDImports.DIGCF_PRESENT);
    
    // create a new interface data struct and initialize its size
    HIDImports.SP_DEVICE_INTERFACE_DATA diData = new HIDImports.SP_DEVICE_INTERFACE_DATA();
    diData.cbSize = Marshal.SizeOf(diData);
    
    // 3. get a device interface to a single device (enumerate all devices)
    while(HIDImports.SetupDiEnumDeviceInterfaces(hDevInfo, IntPtr.Zero, ref guid, index, ref diData))
    {
        // create a detail struct and set its size
        HIDImports.SP_DEVICE_INTERFACE_DETAIL_DATA diDetail = new HIDImports.SP_DEVICE_INTERFACE_DETAIL_DATA();
        diDetail.cbSize = 5; //should be: (uint)Marshal.SizeOf(diDetail);, but that's the wrong size
    
        UInt32 size = 0;
    
        // get the buffer size for this device detail instance (returned in the size parameter)
        HIDImports.SetupDiGetDeviceInterfaceDetail(hDevInfo, ref diData, IntPtr.Zero, 0, out size, IntPtr.Zero);
    
        // actually get the detail struct
        if(HIDImports.SetupDiGetDeviceInterfaceDetail(hDevInfo, ref diData, ref diDetail, size, out size, IntPtr.Zero))
        {
            // open a read/write handle to our device using the DevicePath returned
            mHandle = HIDImports.CreateFile(diDetail.DevicePath, FileAccess.ReadWrite, FileShare.ReadWrite, IntPtr.Zero, FileMode.Open, HIDImports.EFileAttributes.Overlapped, IntPtr.Zero);
    
            // 4. create an attributes struct and initialize the size
            HIDImports.HIDD_ATTRIBUTES attrib = new HIDImports.HIDD_ATTRIBUTES();
            attrib.Size = Marshal.SizeOf(attrib);
    
            // get the attributes of the current device
            if(HIDImports.HidD_GetAttributes(mHandle.DangerousGetHandle(), ref attrib))
            {
                // if the vendor and product IDs match up
                if(attrib.VendorID == VID && attrib.ProductID == PID)
                {
                    // 5. create a nice .NET FileStream wrapping the handle above
                    mStream = new FileStream(mHandle, FileAccess.ReadWrite, REPORT_LENGTH, true);
                }
                else
                    mHandle.Close();
            }
        }
    
        // move to the next device
        index++;
    }
    
    // 6. clean up our list
    HIDImports.SetupDiDestroyDeviceInfoList(hDevInfo);

    第4章       CreateFile 和 SafeFileHandles

    在看过上面的代码后,你可能注意到Wiimote的操作句柄是通过Win32的CreateFile方法打开的,而没有直接使用FileStream对象或者其它托管方式。这是由句柄创建方式的需要所决定的。diDetail结构中的 DevicePath 成员保存了一个非文件系统路径,Win32可以使用该句柄打开设备,而.NET仅允许文件系统路径,因此我们必须使用Win32方法。

    同时你可能注意到我们使用了SafeFileHandle 对象包装CreateFile调用返回的句柄。SafeFileHandle对象包装了本地(非托管的)的Win32句柄,允许安全的管理本地类型,并在应用退出时干净的关闭这些句柄。当然可以使用更容易的IntPtr,但我发现对本地类型这种方式是更为干净的处理方式。

    第5章       Wiimote I/O 和 HID 报文

    在HID世界中,数据以报文的方式发送和接收。简要的说,报文就是一个已定义长度的数据缓冲区,它带有的头信息决定了报文的内容。Wiimote接收和发送多种报文,都是22字节长,在上面提到的网站中有详细的描述。考虑到其数量和复杂性,如果你希望了解Wiimote的报文和数据内容,我建议你自行阅读wikis中的相关资料。

    现在我们得到了与Wiimote通信的 FileStream对象。因为报文会在同一时刻进行收发,所以必须采用异步I/O操作。在.NET中做到这点并不困难。在方法开始时进行一个异步读操作,并在缓冲区满后提供一个回调函数。在回调函数中,进行数据处理并重复调用该方法。

    VB 

    ' sure, we could find this out the hard way using HID, but trust me, it's 22
    Private Const REPORT_LENGTH As Integer = 22
    
    ' report buffer
    Private mBuff As Byte() = New Byte(REPORT_LENGTH - 1){}
    
    Private Sub BeginAsyncRead()
        ' if the stream is valid and ready
        If mStream.CanRead Then
            ' create a read buffer of the report size
            Dim buff As Byte() = New Byte(REPORT_LENGTH - 1){}
    
            ' setup the read and the callback
            mStream.BeginRead(buff, 0, REPORT_LENGTH, New AsyncCallback(AddressOf OnReadData), buff)
        End If
    End Sub
    
    Private Sub OnReadData(ByVal ar As IAsyncResult)
        ' grab the byte buffer
        Dim buff As Byte() = CType(ar.AsyncState, Byte())
    
        ' end the current read
        mStream.EndRead(ar)
    
        ' start reading again
        BeginAsyncRead()
    
        ' handle data....
    End Sub

    C#

    // sure, we could find this out the hard way using HID, but trust me, it's 22
    private const int REPORT_LENGTH = 22;
    
    // report buffer
    private byte[] mBuff = new byte[REPORT_LENGTH];
    
    private void BeginAsyncRead()
    {
        // if the stream is valid and ready
        if(mStream.CanRead)
        {
            // create a read buffer of the report size
            byte[] buff = new byte[REPORT_LENGTH];
    
            // setup the read and the callback
            mStream.BeginRead(buff, 0, REPORT_LENGTH, new AsyncCallback(OnReadData), buff);
        }
    }
    
    private void OnReadData(IAsyncResult ar)
    {
        // grab the byte buffer
        byte[] buff = (byte[])ar.AsyncState;
    
        // end the current read
        mStream.EndRead(ar);
    
        // start reading again
        BeginAsyncRead();
        
        // handle data....
    }

    第6章       完成!

    你可能不相信,但这些代码已经能够连接并与Wiimote通信。 接下来的代码包括解析接收的数据并向Wiimote发送格式正确的代码。正象上面提到的, 我没打算在此详细说明这些细节,网站上可以提供更好的说明。

    向Wiimote发送命令的方式如下:

    VB

    mStream.Write(mBuff, 0, REPORT_LENGTH)

    C#

    mStream.Write(mBuff, 0, REPORT_LENGTH);

    读取功能在上面的异步代码中完成。在收到22字节数据后, 调用OnReadData方法,然后正确的解析和使用这些数据。

     

    第7章       使用API

    如果不关心这些实现细节,可以直接跳转到本章,学习如何在你自己的应用程序中使用这些API。最简单的方法是通过源代码中带有的WiimoteTest 应用程序来了解如何实现这些工作。

     

    首先在源代码中添加WiimoteLib.dll 引用。然后,在应用程序中使用using/Imports 声明相关的命名空间。之后,就可以创建和使用一个Wiimote类。简单的初始化一个新的Wiimote类实例, 设置相应的事件处理以及希望返回数据的报文类型,并调用Connect方法。

    VB

    Imports WiimoteLib
    
    Private Sub Form1_Load(ByVal sender As Object, ByVal e As EventArgs)
        ' create a new instance of the Wiimote
        Dim wm As Wiimote = New Wiimote()
    
        ' setup the event to handle state changes
        AddHandler wm.WiimoteChanged, AddressOf wm_WiimoteChanged
    
        ' setup the event to handle insertion/removal of extensions
        AddHandler wm.WiimoteExtensionChanged, AddressOf wm_WiimoteExtensionChanged
    
        ' connect to the Wiimote
        wm.Connect()
    
        ' set the report type to return the IR sensor and accelerometer data (buttons always come back)
        wm.SetReportType(Wiimote.InputReport.IRAccel, True)
    End Sub
    
    
    Private Sub wm_WiimoteExtensionChanged(ByVal sender As Object, ByVal args As WiimoteExtensionChangedEventArgs)
        If args.Inserted Then
            wm.SetReportType(Wiimote.InputReport.IRExtensionAccel, True) ' return extension data
        Else
            wm.SetReportType(Wiimote.InputReport.IRAccel, True) ' back to original mode
        End If
    End Sub
    
    Private Sub wm_OnWiimoteChanged(ByVal sender As Object, ByVal args As WiimoteChangedEventArgs)
        ' current state information
        Dim ws As WiimoteState = args.WiimoteState
    
        ' write out the state of the A button    
        Debug.WriteLine(ws.ButtonState.A)
    End Sub

    C#

    using WiimoteLib;
    
    private void Form1_Load(object sender, EventArgs e)
    {
        // create a new instance of the Wiimote
        Wiimote wm = new Wiimote();
        
        // setup the event to handle state changes
        wm.WiimoteChanged += wm_WiimoteChanged;
        
        // setup the event to handle insertion/removal of extensions
        wm.WiimoteExtensionChanged += wm_WiimoteExtensionChanged;
        
        // connect to the Wiimote
        wm.Connect();
        
        // set the report type to return the IR sensor and accelerometer data (buttons always come back)
        wm.SetReportType(Wiimote.InputReport.IRAccel, true);
    }
    
    
    void wm_WiimoteExtensionChanged(object sender, WiimoteExtensionChangedEventArgs args)
    {
        if(args.Inserted)
            wm.SetReportType(Wiimote.InputReport.IRExtensionAccel, true);    // return extension data
        else
            wm.SetReportType(Wiimote.InputReport.IRAccel, true);            // back to original mode
    }
    
    void wm_WiimoteChanged(object sender, WiimoteChangedEventArgs args)
    {
        // current state information
        WiimoteState ws = args.WiimoteState;
    
        // write out the state of the A button    
        Debug.WriteLine(ws.ButtonState.A);
    }

    如果使用多个Wiimote,创建一个WiimoteCollection 对象,调用FindAllWiimotes方法进行初始化,对集合中的每个Wiimote对象作为一个单独的实例进行使用。

    VB

    Dim wc As New WiimoteCollection()
    wc.FindAllWiimotes()
    
    For Each wm As Wiimote In wc
        AddHandler wm.WiimoteChanged, AddressOf wm_WiimoteChanged
        AddHandler wm.WiimoteExtensionChanged, AddressOf wm_WiimoteExtensionChanged
        
        wm.Connect()
        wm.SetReportType(InputReport.IRAccel, True)
    Next wm

    C#

    WiimoteCollection wc = new WiimoteCollection();
    wc.FindAllWiimotes();
    
    foreach(Wiimote wm in wc)
    {
        wm.WiimoteChanged += wm_WiimoteChanged;
        wm.WiimoteExtensionChanged += wm_WiimoteExtensionChanged;
        
        wm.Connect();
        wm.SetReportType(InputReport.IRAccel, true);
    }

    有两种方式从API中取得数据:事件和轮询。在事件模式中,上面所示,首先订阅WiimoteChanged 事件。然后,当数据从Wiimote发送到PC时,一个事件对象将发送到应用程序中的事件处理器中。如果不使用事件模型,可以简单的在任何时刻从Wiimote类的WiimoteState属性中取得状态信息。

    报文类型

    目前托管库仅支持Wiimote报文中有限的几种类型, 不过现在实现的报文类型已经能够返回所有必要的数据,以及目前的扩展信息了。这些报文类型包括:

    • Buttons – 仅按钮数据
    • ButtonsAccel – 按钮和加速度传感器数据
    • IRAccel – 按钮,加速度传感器和IR数据
    • ButtonsExtension – 按钮和扩展数据
    • ExtensionAccel -按钮,加速度传感器和扩展数据
    • IRExtensionAccel -按钮,加速度传感器,IR和扩展数据

    报文类型可以通过SetReportType 方法设置,使用不同的报文类型,决定是否不停的发送数据,或只在控制器状态的改变时得到报文。

    第8章       扩展

    目前支持三种Wii扩展:Nunchuk, Classic Controller和Guitar Hero controller。如果希望使用这些扩展, 需要设置WiimoteExtensionChanged的事件处理器。当该事件被调用,可以通过检查事件变量确定是否有扩展插入或移除,以及插入扩展的类型。在事件处理器中,需要使用SetReportType改为支持扩展数据的报文类型,否则扩展数据不会返回。

    如果使用一种严格的轮询操作,应当检查WiimoteState 属性中的ExtensionExtensionType参数,以确定扩展模块的插入和移除。

    平衡板被视为一种带有扩展功能的Wiimote控制器。报文类型为内部设置,对该设备的任何报文类型改变将被忽略。平衡板的开关按钮对应Wiimote的A按钮, LED对应Wiimote的LED1。其它Wiimote属性被忽略。其它平衡板的信息在WiimoteState 对象中的BalanceBoard 结构中。

    第9章       帮助信息

    整个库的核心在WiimoteState对象中。在下载包中的chm帮助文件中可以查找全部可用的属性。 

    第10章       将来的工作

    目前为止,还不能支持Wiimote中的扬声器。我有可能在将来的更新中增加这些功能。另外还有几种报文类型没有实现,不过现在实现的报文类型已经能够返回所有必要的信息了。我还希望能够添加一个“高层”的功能,例如返回倾斜/翻转角度,IR传感器的鼠标位置等。留意本文和我的博客中关于本库的更新信息。

    第11章       结论

    到此,我们已经达到了目的。一个全功能的Wiimote托管代码库。尝试一下,将它集成到你现有的应用程序中,或者做些全新的东西!我期待你能够利用它进行创造性的应用...

    如果对库的使用有任何问题,或者有其它的功能需求(声音除外),可以直接联系我或在这个项目的专有论坛上发贴。

    第12章       链接

    第13章       作者介绍

    Brian是一名 Microsoft C# MVP,自从2000年.NET发布早期测试版以来一直致力于.NET的开发,他在利用微软的技术和平台提供解决方案方面有更长的历史。除了.NET,Brian在各种CPU上的C, C++和汇编语言方面也有深入的研究。他精通很多种技术, 包括WEB开发,文档图像,GIS, 图形,游戏开发和硬件接口。 Brian在卫生保健行业的应用开发有很深厚的背景,提供多种便携设备解决方案,包括平板电脑和PDA。此外,他还是New Riders出版社出版的"深入 ASP.NET"的合作者,目前正在合著一本名为"程序员,爱好者和游戏开发人员的10个.NET Coding4Fun项目 "的书籍,该书将于2008年由 O'Reilly出版社出版。 Brian同时为 MSDN的 Coding4Fun 网站按月提交文章。

    http://blogs.msdn.com/coding4fun/archive/2007/03/14/1879033.aspx

  • 相关阅读:
    js递归函数使用介绍
    js获取checkbox复选框获取选中的选项
    分享:Oracle 系统变量函数用法说明
    jQuery CSS()方法改变CSS样式实例解析
    jQuery添加/改变/移除CSS类
    php实现文件下载代码一例
    jquery 获取URL参数并转码的例子
    Python无限元素列表实例教程
    MSSQL数据导出到MYSQL
    .NET CORE控制器里的方法取传参的坑
  • 原文地址:https://www.cnblogs.com/vonly/p/4212135.html
Copyright © 2011-2022 走看看