在Invoke或者BeginInvoke的使用中无一例外地使用了委托Delegate,至于委托的本质请参考我的另一随笔:对.net事件的看法。

一、为什么Control类提供了Invoke和BeginInvoke机制?

关于这个问题的最主要的原因已经是dotnet程序员众所周知的,我在此费点笔墨再次记录到自己的日志,以便日后提醒一下自己。

1、windows程序消息机制

Windows GUI程序是基于消息机制的,有个主线程维护着一个消息泵。这个消息泵让windows程序生生不息。

Windows GUI程序的消息循环

Windows程序有个消息队列,窗体上的所有消息是这个队列里面消息的最主要来源。这里的while循环使用了GetMessage()这个方法,这是个阻塞方法,也就是队列为空时方法就会被阻塞,从而这个while循环停止运动,这避免了一个程序把cpu无缘无故地耗尽,让其它程序难以得到响应。当然在某些需要cpu最大限度运动的程序里面就可以使用另外的方法,例如某些3d游戏或者及时战略游戏中,一般会使用PeekMessage()这个方法,它不会被windows阻塞,从而保证整个游戏的流畅和比较高的帧速。

这个主线程维护着整个窗体以及上面的子控件。当它得到一个消息,就会调用DispatchMessage方法派遣消息,这会引起对窗体上的窗口过程的调用。窗口过程里面当然是程序员提供的窗体数据更新代码和其它代码。

2、dotnet里面的消息循环

public static void Main(string[] args)
{
Form f = new Form();
Application.Run(f);
}

Dotnet窗体程序封装了上述的while循环,这个循环就是通过Application.Run方法启动的。

3、线程外操作GUI控件的问题

如果从另外一个线程操作windows窗体上的控件,就会和主线程产生竞争,造成不可预料的结果,甚至死锁。因此windows GUI编程有一个规则,就是只能通过创建控件的线程来操作控件的数据,否则就可能产生不可预料的结果。

因此,dotnet里面,为了方便地解决这些问题,Control类实现了ISynchronizeInvoke接口,提供了Invoke和BeginInvoke方法来提供让其它线程更新GUI界面控件的机制。

public interface ISynchronizeInvoke
{
[HostProtection(SecurityAction.LinkDemand, Synchronization=true, ExternalThreading=true)]
IAsyncResult BeginInvoke(Delegate method, object[] args);
object EndInvoke(IAsyncResult result);
object Invoke(Delegate method, object[] args);
bool InvokeRequired { get; }
}

如果是从创建控件的线程外操作windows窗体控件,那么就需要使用Invoke或者BeginInvoke方法,通过一个委托把调用封送到控件所属的线程上执行。

二、消息机制---线程间和进程间通信机制

1、window消息发送

Windows消息机制是windows平台上的线程或者进程间通信机制之一。Windows消息值其实就是定义的一个数据结构,最重要的是消息的类型,它就是一个整数;然后就是消息的参数。消息的参数可以表示很多东西。

Windows提供了一些api用来向一个线程的消息队列发送消息。因此,一个线程可以向另一个线程的消息队列发送消息从而告诉对方做什么,这样就完成了线程间的通信。有些api发送消息需要一个窗口句柄,这种函数可以把消息发送到指定窗口的主线程消息队列;而有些则可以直接通过线程句柄,把消息发送到该线程消息队列中。

用消息机制通信

SendMessage是windows api,用来把一个消息发送到一个窗口的消息队列。这个方法是个阻塞方法,也就是操作系统会确保消息的确发送到目的消息队列,并且该消息被处理完毕以后,该函数才返回。返回之前,调用者将会被暂时阻塞。

PostMessage也是一个用来发送消息到窗口消息队列的api函数,但这个方法是非阻塞的。也就是它会马上返回,而不管消息是否真的发送到目的地,也就是调用者不会被阻塞。

2、Invoke and BeginInvoke

Invoke or BeginInvoke

Invoke或者BeginInvoke方法都需要一个委托对象作为参数。委托类似于回调函数的地址,因此调用者通过这两个方法就可以把需要调用的函数地址封送给界面线程。这些方法里面如果包含了更改控件状态的代码,那么由于最终执行这个方法的是界面线程,从而避免了竞争条件,避免了不可预料的问题。如果其它线程直接操作界面线程所属的控件,那么将会产生竞争条件,造成不可预料的结果。

使用Invoke完成一个委托方法的封送,就类似于使用SendMessage方法来给界面线程发送消息,是一个同步方法。也就是说在Invoke封送的方法被执行完毕前,Invoke方法不会返回,从而调用者线程将被阻塞。

使用BeginInvoke方法封送一个委托方法,类似于使用PostMessage进行通信,这是一个异步方法。也就是该方法封送完毕后马上返回,不会等待委托方法的执行结束,调用者线程将不会被阻塞。但是调用者也可以使用EndInvoke方法或者其它类似WaitHandle机制等待异步操作的完成。

但是在内部实现上,Invoke和BeginInvoke都是用了PostMessage方法,从而避免了SendMessage带来的问题。而Invoke方法的同步阻塞是靠WaitHandle机制来完成的。

3、使用场合问题

如果你的后台线程在更新一个UI控件的状态后不需要等待,而是要继续往下处理,那么你就应该使用BeginInvoke来进行异步处理。

如果你的后台线程需要操作UI控件,并且需要等到该操作执行完毕才能继续执行,那么你就应该使用Invoke。否则,在后台线程和主截面线程共享某些状态数据的情况下,如果不同步调用,而是各自继续执行的话,可能会造成执行序列上的问题,虽然不发生死锁,但是会出现不可预料的显示结果或者数据处理错误。

可以看到ISynchronizeInvoke有一个属性,InvokeRequired。这个属性就是用来在编程的时候确定,一个对象访问UI控件的时候是否需要使用Invoke或者BeginInvoke来进行封送。如果不需要那么就可以直接更新。在调用者对象和UI对象同属一个线程的时候这个属性返回false。在后面的代码分析中我们可以看到,Control类对这一属性的实现就是在判断调用者和控件是否属于同一个线程的。

三、Delegate.BeginInvoke

通过一个委托来进行同步方法的异步调用(有点绕嘴),也是.net提供的异步调用机制之一。但是Delegate.BeginInvoke方法是从ThreadPool取出一个线程来执行这个方法,以获得异步执行效果的。也就是说,如果采用这种方式提交多个异步委托,那么这些调用的顺序无法得到保证。而且由于是使用线程池里面的线程来完成任务,使用频繁,会对系统的性能造成影响。

Delegate.BeginInvoke同样也是将一个委托方法封送到其它线程,从而通过异步机制执行一个方法。调用者线程则可以在完成封送以后去继续它的工作。但是这个方法封送到的最终执行线程是运行库从ThreadPool里面选取的一个线程。

这里需要纠正一个误区,那就是Control类上的异步调用BeginInvoke并没有开辟新的线程完成委托任务,而是让界面控件的所属线程完成委托任务的。看来异步操作就是开辟新线程的说法不一定准确。

四、用Reflector察看一些相关代码

1、Control.BeginInvoke and Control.Invoke

public IAsyncResult BeginInvoke(Delegate method, params object[] args)
{
using (new MultithreadSafeCallScope())
{
return (IAsyncResult) this.FindMarshalingControl().MarshaledInvoke(this, method, args, false);
}
}
public object Invoke(Delegate method, params object[] args)
{
using (new MultithreadSafeCallScope())
{
return this.FindMarshalingControl().MarshaledInvoke(this, method, args, true);
}
}

这里的FindMarshalingControl方法通过一个循环向上回溯,从当前控件开始回溯父控件,直到找到最顶级的父控件,用它作为封送对象。例如,我们调用窗体上一个进度条的Invoke方法封送委托,但是实际上会回溯到主窗体,通过这个控件对象来封送委托。因为主窗体是主线程消息队列相关的,发送给主窗体的消息才能发送到界面主线程消息队列。

我们可以看到Invoke和BeginInvoke方法使用了同样的实现,只是MarshaledInvoke方法的最后一个参数值不一样。

2、MarshaledInvoke

private object MarshaledInvoke(Control caller, Delegate method, object[] args, bool synchronous)
{
int num;
if (!this.IsHandleCreated)
{
throw new InvalidOperationException(SR.GetString("ErrorNoMarshalingThread"));
}
if (((ActiveXImpl) this.Properties.GetObject(PropActiveXImpl)) != null)
{
IntSecurity.UnmanagedCode.Demand();
}
bool flag = false;
if ((SafeNativeMethods.GetWindowThreadProcessId(new HandleRef(this, this.Handle), out num) == SafeNativeMethods.GetCurrentThreadId()) && synchronous)
{
flag = true;
}
ExecutionContext executionContext = null;
if (!flag)
{
executionContext = ExecutionContext.Capture();
}
ThreadMethodEntry entry = new ThreadMethodEntry(caller, method, args, synchronous, executionContext);
lock (this)
{
if (this.threadCallbackList == null)
{
this.threadCallbackList = new Queue();
}
}
lock (this.threadCallbackList)
{
if (threadCallbackMessage == )
{
threadCallbackMessage = SafeNativeMethods.RegisterWindowMessage(Application.WindowMessagesVersion + "_ThreadCallbackMessage");
}
this.threadCallbackList.Enqueue(entry);
}
if (flag)
{
this.InvokeMarshaledCallbacks();
}
else
{ //终于找到你了,PostMessage
UnsafeNativeMethods.PostMessage(new HandleRef(this, this.Handle), threadCallbackMessage, IntPtr.Zero, IntPtr.Zero);
}
if (!synchronous) //如果是异步,那么马上返回吧
{
return entry;
}
if (!entry.IsCompleted) //同步调用没结束,阻塞起来等待吧
{
this.WaitForWaitHandle(entry.AsyncWaitHandle);
}
if (entry.exception != null)
{
throw entry.exception;
}
return entry.retVal;
}

怎么样,我们终于看到PostMessage了吧?通过windows消息机制实现了封送。而需要封送的委托方法作为消息的参数进行了传递。关于其它的代码这里不作进一步解释。

3、InvokeRequired

public bool InvokeRequired
{
get
{
using (new MultithreadSafeCallScope())
{
HandleRef ref2;
int num;
if (this.IsHandleCreated)
{
ref2 = new HandleRef(this, this.Handle);
}
else
{
Control wrapper = this.FindMarshalingControl();
if (!wrapper.IsHandleCreated)
{
return false;
}
ref2 = new HandleRef(wrapper, wrapper.Handle);
}
int windowThreadProcessId = SafeNativeMethods.GetWindowThreadProcessId(ref2, out num);
int currentThreadId = SafeNativeMethods.GetCurrentThreadId();
return (windowThreadProcessId != currentThreadId);
}
}
}

终于看到了,这是在判断windows窗体线程和当前的调用者线程是否是同一个,如果是同一个就没有必要封送了,直接访问这个GUI控件吧。否则,就不要那么直接表白了,就需要Invoke或者BeginInvoke做媒了。

出处:https://www.cnblogs.com/worldreason/archive/2008/06/09/1216127.html

=============================================================================

再发一个SendMessage的使用实例代码,也是从网上找来的。

进程间通讯,其实用SendMessage就可以实现,刚找了一下以前做的东西,正好是进程间通讯的,主要是主程序调用校验程序,校验程序返回校验结果。

using System;
using System.Collections.Generic;
using System.Text;
using System.Runtime.InteropServices;
using System.Diagnostics; namespace SendMsg
{
#region 结构体定义
[StructLayout(LayoutKind.Sequential)]
public struct COPYDATASTRUCT
{
public int dwData;
public int cbData;
public int lpData;
} //定义要传递的Struct
[StructLayout(LayoutKind.Sequential)]
struct SendMsgInfo
{
/// <summary>
/// 参数(单号)
/// </summary>
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = )]
public string AccessionNum;
/// <summary>
/// 参数(原文)
/// </summary>
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = )]
public string ReportTxt;
/// <summary>
/// 参数(Ris用户)
/// </summary>
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = )]
public string RisUid;
/// <summary>
/// 调用方法名
/// </summary>
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = )]
public string FunctionNum;
/// <summary>
/// 端口
/// </summary>
public int Handle;
/// <summary>
/// 返回值
/// </summary>
public bool Result;
/// <summary>
/// Err信息
/// </summary>
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = )]
public string ResultMsg;
}
#endregion class CaSendMsg
{
Process pro;
const int WM_COPYDATA = 0x004A; private string _winFrmName = @"校验程序";
private string _path = @"校验.exe";
private int _interval = ;
private int _maxTimes = ; /// <summary>
/// 窗体名称
/// </summary>
public string WinFrmName
{
set { _winFrmName = value; }
get { return _winFrmName; }
} /// <summary>
/// 程序路径
/// </summary>
public string Path
{
set { _path = value; }
get { return _path; }
} /// <summary>
/// 等待轮序频率 秒/次
/// </summary>
public int Interval
{
set { _interval = value; }
get { return _interval; }
} /// <summary>
/// 上限次数
/// </summary>
public int MaxTimes
{
set { _maxTimes = value; }
get { return _maxTimes; }
} [DllImport("user32", EntryPoint = "SendMessageA")]
public static extern int SendMessage(int hWnd, int wMsg, int wParam, ref COPYDATASTRUCT lParam); [DllImport("User32.dll", EntryPoint = "FindWindow")]
public static extern int FindWindow(string lpClassName, string
lpWindowName); /// <summary>
/// ca校验
/// </summary>
/// <param name="h"></param>
/// <returns></returns>
public bool CACheck(SendMsgInfo h)
{
int hWnd = GetWinFrmHwnd();
if (hWnd <= )
return false;
return CACheck(h, hWnd);
} /// <summary>
/// 获取程序句柄
/// </summary>
/// <returns></returns>
private int GetWinFrmHwnd()
{
int hWnd = FindWindow(null, _winFrmName);
if (hWnd <= )
{
hWnd = StartNewPro(_path);
}
return hWnd;
} /// <summary>
/// 开启新的校验进程
/// </summary>
/// <param name="path"></param>
/// <returns></returns>
private int StartNewPro(string path)
{
try
{
pro = new Process();
pro.StartInfo.FileName = path;
pro.Start();
return pro.MainWindowHandle.ToInt32();
}
catch
{
return ;
}
} public bool CACheck(SendMsgInfo info, int hWnd)
{
try
{
int size = Marshal.SizeOf(typeof(SendMsgInfo));
byte[] Bytes = new byte[size];
//根据定义的尺寸分配内存块
GCHandle GC = GCHandle.Alloc(Bytes, GCHandleType.Pinned);
IntPtr ptr1 = GC.AddrOfPinnedObject();
//获得Struct对应的IntPtr
Marshal.StructureToPtr(info, ptr1, false);
COPYDATASTRUCT SendData = new COPYDATASTRUCT();
SendData.lpData = ptr1.ToInt32();
SendData.cbData = size; if (hWnd > )
{
SendMessage(hWnd, WM_COPYDATA, , ref (SendData));
} return true;
}
catch
{
return false;
}
} public bool getCaResult(System.Windows.Forms.Message m)
{
COPYDATASTRUCT RecvData = (COPYDATASTRUCT)m.GetLParam(typeof(COPYDATASTRUCT));
SendMsgInfo info = (SendMsgInfo)Marshal.PtrToStructure((IntPtr)RecvData.lpData, typeof(SendMsgInfo));
//h.ResultMsg;
return info.Result;
} }
}

接收消息

        //接收消息
protected override void DefWndProc(ref System.Windows.Forms.Message m)
{
switch (m.Msg)
{
case SendMsg.WM_COPYDATA:
COPYDATASTRUCT RecvData = (COPYDATASTRUCT)m.GetLParam(typeof(COPYDATASTRUCT));
SendMsgInfo h = (SendMsgInfo)Marshal.PtrToStructure((IntPtr)RecvData.lpData, typeof(SendMsgInfo)); textBox1.Text = h.MessageType.ToString();
textBox2.Text = h.MessageText; //返回校验结果
SendMsgInfo returnInfo = new SendMsgInfo();
returnInfo.Handle = h.Handle;
returnInfo.MessageNum = h.MessageNum;
returnInfo.Result = true;
returnInfo.ResultMsg = "";
SendMsg c = new SendMsg();
c.Send(returnInfo, returnInfo.Handle);
break;
default:
base.DefWndProc(ref m);
break;
}
}

出处:https://bbs.csdn.net/topics/390650236  请查看 #20 给出的代码

https://blog.csdn.net/barenk/article/details/78686981

=====================================================================================

再给一个使用的示例,不同之处是参考传递的消息的结构,

namespace CshapMessage
{
/// <summary>
/// MainWindow.xaml 的交互逻辑
/// </summary>
public partial class MainWindow : Window
{
IntPtr hwnd; const int WM_COPYDATA = 0x004A; public struct COPYDATASTRUCT
{
public IntPtr dwData;
public int cData;
[MarshalAs(UnmanagedType.LPStr)]
public string lpData;
} [DllImport("User32.dll")]
public static extern int SendMessage(int hwnd, int msg, int wParam, ref COPYDATASTRUCT IParam);
[DllImport("User32.dll")]
public static extern int FindWindow(string lpClassName, string lpWindowName); public MainWindow()
{
InitializeComponent();
this.Title = "CshapMessage";
this.Loaded += MainWindow_Loaded;
this.Closed += MainWindow_Closed;
} private void MainWindow_Closed(object sender, EventArgs e)
{
try
{
HwndSource.FromHwnd(hwnd).RemoveHook(WndProc);
}
catch (Exception) { }
} private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
hwnd = new WindowInteropHelper(this).Handle;
HwndSource.FromHwnd(hwnd).AddHook(new HwndSourceHook(WndProc));
} /// <summary>
/// 向C++程序 CshapMessage发送消息
/// </summary>
/// <param name="nMessgeId"></param>
/// <param name="strSend"></param>
/// <returns></returns>
private bool CshapSendMessage(int nMessgeId, String strSend)
{
int WINDOW_HANDLE = FindWindow(null, "VcMessage");//VcMessage为向C++程序发送的窗口名称
if (WINDOW_HANDLE != )
{
COPYDATASTRUCT cdata;
cdata.dwData = (IntPtr);//这里可以传入一些自定义的数据,但只能是4字节整数
cdata.lpData = strSend;//消息字符串
cdata.cData = System.Text.Encoding.Default.GetBytes(strSend).Length+;//注意,这里的长度是按字节来算的 SendMessage(WINDOW_HANDLE, WM_COPYDATA, , ref cdata);
}
else
{
return false;
}
return true;
} private void button_Click(object sender, RoutedEventArgs e)
{
String strSend = "C#发送的信息";
int nMessageId = ; if (CshapSendMessage(nMessageId,strSend))
{
MessageBox.Show("发送消息成功");
}
else
{
MessageBox.Show("消息发送失败,请打开VcMessage程序");
}
} //接收消息。
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WM_COPYDATA)
{
COPYDATASTRUCT cdata = new COPYDATASTRUCT();
Type mytype = cdata.GetType();
cdata = (COPYDATASTRUCT)Marshal.PtrToStructure(lParam, mytype);
switch (cdata.dwData.ToInt32())
{
case :
{
string strRecv = cdata.lpData;
break;
}
default:
break;
}
} return IntPtr.Zero;
}
}
}

出处:https://blog.csdn.net/Sayesan/article/details/88314972

https://blog.csdn.net/yl2isoft/article/details/20222679

C#中的WinForm的消息机制简述,及消息机制下Invoke,和BeginInvoke的使用和区别的更多相关文章

  1. [转载]Winform中Control的Invoke与BeginInvoke方法

    转自http://www.cppblog.com/baby-fly/archive/2010/04/01/111245.html 一.为什么 Control类提供了 Invoke和 BeginInvo ...

  2. WinForm二三事(三)Control.Invoke&Control.BeginInvoke

    http://www.cnblogs.com/yuyijq/archive/2010/01/11/1643802.html 这个系列从2009年写到2010年,差点又成太监文.随着WPF/Silver ...

  3. C#中Invoke 和 BeginInvoke 的区别

    Control.Invoke 方法 (Delegate) :在拥有此控件的基础窗口句柄的线程上执行指定的委托. Control.BeginInvoke 方法 (Delegate) :在创建控件的基础句 ...

  4. C#中Control的Invoke和BeginInvoke是相对于支线线程

    近日,被Control的Invoke和BeginInvoke搞的头大,就查了些相关的资料,整理如下. Control的Invoke和BeginInvoke 是相对于支线线程(因为一般在支线线程中调用, ...

  5. 学习ActiveMQ(六):JMS消息的确认与重发机制

    当我们发送消息的时候,会出现发送失败的情况,此时我们需要用到activemq为我们提供了消息重发机制,进行消息的重新发送.那么我们怎么知道消息有没有发送失败呢?activemq还有消息确认机制,消费者 ...

  6. c#中退出WinForm程序包括有很多方法,如:this.Close(); Application.Exit();Application.ExitThread(); System.Environment.Exit(0);

    本文实例总结了C#中WinForm程序退出方法技巧.分享给大家供大家参考.具体分析如下: 在c#中退出WinForm程序包括有很多方法,如:this.Close(); Application.Exit ...

  7. android 进程间通信 messenger 是什么 binder 跟 aidl 区别 intent 进程间 通讯? android 消息机制 进程间 android 进程间 可以用 handler么 messenger 与 handler 机制 messenger 机制 是不是 就是 handler 机制 或 , 是不是就是 消息机制 android messenge

    韩梦飞沙  韩亚飞  313134555@qq.com  yue31313  han_meng_fei_sha messenger 是什么 binder 跟 aidl 区别 intent 进程间 通讯 ...

  8. StringBoot集成Rabbit Redis和ack机制双重保险,保障消息一定能够正确的消费

    转: StringBoot集成Rabbit,根据业务返回ACK 原文链接 : http://www.jianshu.com/p/baed9ec92410 为了维护消息的有效性,当消费消息时候处理失败时 ...

  9. Spark checkpoint机制简述

    本文主要简述spark checkpoint机制,快速把握checkpoint机制的来龙去脉,至于源码可以参考我的下一篇文章. 1.Spark core的checkpoint 1)为什么checkpo ...

随机推荐

  1. RobotFrameWork基本语法练习

    1.基本使用如下图 2.运行日志如下(可对照查看语句输出) Starting test: Test.Test Suite.test_case1 20180810 15:48:58.525 : INFO ...

  2. window安装mysql8.0解决大部分客户端无法连接问题登陆问题

    https://blog.csdn.net/u013308810/article/details/80114021

  3. JavaSE面试题:类初始化和实例初始化等

    类初始化过程 1.一个类要创建实例需要先加载并初始化该类 main方法所在的类需要先加载和初始化 2.一个子类要初始化需要先初始化父类 3.一个类初始化就是执行<clinit>()方法 & ...

  4. c++ pipe实现父子进程通信

    1.父子进程通信pipe编程流程 -创建管道 -设置进程的输出到管道 -创建进程 -关闭管道写句柄 -读管道读句柄,把数据读到一个buffer里 2.注意事项 -读管道数据的时候,一定要关闭写句柄: ...

  5. 一秒可生成500万ID的分布式自增ID算法—雪花算法 (Snowflake,Delphi 版)

    概述 分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的. 有些时候我们希望能使用一种 ...

  6. Django-05-视图函数

    http请求中产生两个核心对象: http请求:HttpRequest对象 http响应:HttpResponse对象 所在位置:django.http 之前我们用到的参数request就是HttpR ...

  7. RabbitMQ之消息模式

    目的: 消息如何保证100%的投递 幂等性概念 Confirm确认消息 Return返回消息 自定义消费者 前言: 想必知道消息中间件RabbitMQ的小伙伴,对于引入中间件的好处可以起到抗高并发,削 ...

  8. golang --写test测试用例

    安装gotests插件自动生成测试代码: go get -u -v github.com/cweill/gotests/... 如何编写测试用例 由于go test命令只能在一个相应的目录下执行所有文 ...

  9. c# 拼接字符串根据逗号切割 后转换成集合或数组

    版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明.本文链接:https://blog.csdn.net/qq_27559331/article/d ...

  10. HTML页面导出PDF——高清版

    需要做一个导出PDF的功能,网上找有很多,但是一般导出来的都是比较模糊的那种,下面这个是高清版的,导出的PDF都是几M,跟正常手动导出的差不多,很清晰. 首先用到的JS有: <script ty ...