在 WPF 最主要的就是渲染,因为 WPF 是一个界面框架。想用一篇博客就能告诉大家完整的 WPF 渲染原理是不可能的。本文告诉大家 WPF 从开发者告诉如何画图像到在屏幕显示的过程。本文是从一个很高的地方来看渲染的过程,在本文之后会添加很多博客来告诉大家渲染的细节。

从 WPF 画图像到屏幕显示是比较复杂的,本渣也不敢说这就是 WPF 的做法,但是看了很多博客,好像都是这么说的,看了代码,好像 WPF 是这样写的。

本文将会分为三个不同的层来讲,第一层就是 WPF 的总体结构,第二层是消息循环相关,第三层是在 dx 渲染到屏幕。

WPF 组成

因为 WPF 是一个 UI 框架,作为一个框架最主要的是交互和显示。本文只告诉大家渲染的原理。但是本文不会告诉大家任何关于渲染的算法,只是告诉大家渲染的过程如何从 WPF 元素显示到屏幕。

下面的图片是从WPF Architecture 找到

WPF 有三个主要的模块 PresentationFramework、 PresentationCore 和基础层

在 WPF 最顶层,也就是给开发者使用的元素,元素的显示就是使用 DrawingContext 告诉 WPF 需要如何渲染。最简单的方法也就是继承 FrameworkElement 然后重写 OnRender 方法,通过 OnRender 方法画出最基础的界面。这就是在框架的顶层,在这的上面就不属于底层框架的。如在显示上面封装的 Image 等这些。

那么在调用 OnRender 画出内容,是不是 WPF 会立刻显示,如果大家有看 WPF 使用 Direct2D1 画图入门就会发现在渲染是调用 CompositionTarget.Rendering 才知道是什么时候渲染,因为 WPF 是分开渲染和交互,实际的 OnRender 画出的内容的代码是指导渲染,也就是告诉 WPF 如何渲染。那么 WPF 在什么时候渲染就是代码不知道的。为什么 WPF 需要这样做还需要从 WPF 的体系结构开始说起。

我在下面偷了一张图,图片是从Overview of Windows Presentation Foundation (WPF) Architecture找到,在 WPF 可以分为三层。第一层就是 WPF 的托管层,这一层的代码都是托管代码。第二层就是 WPF 的非托管层,包括刚才告诉大家的模块。最后一层就是系统核心元素层。下面简单介绍一下 WPF 的体系结构

如果觉得对 WPF 的体系结构已经足够了解,那么请跳到下一节。

托管层

在托管层最重要的就是 Presentation Framework、Presentation Core 和 Window Base ,很多小伙伴说的 WPF 只是包含这几个模块,因为其他的模块几乎都不会知道。也就是基本使用的类都在下面三个 dll 可以找到

  • PresentationFramework.dll 提供最顶层封装,包括应用的窗口、控制的 Panel 和 Styles 这些类都是在这可以找到,包括交互的控制,动画和几乎可以用到的界面的控件

  • PresentationCore.dll 提供底层的 WPF 功能,如2d、3d和 geometry 这些类,在 PresentationCore 是对 MIL 和托管中间封装,包括提供了 UI Element 和 Visual 这些类,在显示模块包含视觉树和显示指令,也就是刚才说的 OnRender 重写方法。

  • WindowsBase.dll 提供最底层的基础类,包括调度对象 Dispatcher objects 和依赖属性

非托管层

非托管层用来进行高性能的 DX 渲染和连接非托管层

  • milCore.dll 用来作为 WPF 的组合引擎,这时一个使用本地代码编译的库,包含最主要的媒体集成层 Media Integration Layer (MIL) 的基础支持,作用是封装 Dx 的接口支持 2D 和 3D 的渲染,使用本地代码编译是为了获得最好的性能,而且用在 WPF 上层和底层的 DirextX 和 User32 的接口之间。值的一说的是在 Windows Vista 的桌面窗口管理器(Desktop Windows Manager,DWM)就是使用milcore.dll渲染桌面的。

  • WindowsCodecs.dll 这是另一个底层的图片支持代码,用来支持 WPF 旋转、放大图片等,这是一个使用本地代码编译的,提供了很多图片的加密解密,可以让 WPF 把图片画在屏幕

核心系统层

这一层就是系统的核心,如 User32、GDI、Device Drivers,显卡等,这些组合在程序里是最底层的接口

  • User32 提供内存和进程分割,这是一个通用的 API 不止是 WPF 使用,这个库决定一个应用可以在屏幕的哪里显示,以及如何显示,也就是窗口显示的最底层的代码就在这。但是这个代码只提供让窗口在哪里显示,如何显示就需要其他组件的支持

  • DirectX 这就是 WPF 渲染的最底层的库,可以渲染 WPF 的几乎所有控件,需要注意 WPF 使用的是 Dx9 或 Dx12 fl9 因此没有充分使用 Dx 进行绘制出现代化的界面。但在纯 2D 的层面,这部分的性能差异基本可以忽略不计

  • GDI 这是通用图形绘制接口,不依赖显卡,是进行 CPU 渲染的接口,提供了绘制原语和提高一些绘制质量,如一些 WMF 图片格式等的绘制质量

  • CLR 因为 WPF 现在还运行在 dotnet framework 或 dotnet core 所以需要使用运行时。在 运行时 里提供了通用的类和方法,用来方便开发者开发,大概有 30 万 API 可以使用。当然,如果使用了 .NET Native 或者其他 AOT 技术,此时也可以说不需要 CLR 的支持(尽管不够严谨)

  • Device Drivers 设备驱动程序是在系统特殊的用来驱动一些硬件的驱动

在下面使用到的代码实际都是从最上层拿到的,只有最上层的代码,微软才会开放。而关键的 milCore 代码 我还拿不到,只能通过 WinDbg 拿到调用的堆栈。现在还没有完全知道 milCore 的过程,所以也不会在本文告诉大家 我大概读了,但是内容太多就不放在本文。现在 WPF 全部代码开源,欢迎小伙伴改代码

本文的顺序是从消息调度到开发者使用 OnRender 方法给绘制原语,再到如何把绘制原语给渲染线程的过程。从渲染线程调用 milCore ,在通过 milCore 调用 DirectX 的过程就先简单说过。从 DirectX 绘制完成到屏幕显示的过程也是简单告诉大家。

消息循环

在 WPF 中也是使用消息循环,因为在之前的很多程序都是需要自己写消息循环才可以收到用户的交互,这里消息循环就是 Windows 会向 WPF 发送一些消息,而且 WPF 也可以给自己发消息,通过消息就可以判断当前软件需要做哪些任务

在处理消息的最主要的类是 HwndSubclass 类,这个类在创建应用就会执行 Attach 函数,这个函数内容请看代码

    internal IntPtr Attach(IntPtr hwnd)
    {
      // 忽略代码
      return this.CriticalAttach(hwnd);
    }

从上面代码可以看到主要的是 CriticalAttach 函数,关于 CriticalAttach 函数请看代码

    internal IntPtr CriticalAttach(IntPtr hwnd)
    {
      // 忽略代码
      NativeMethods.WndProc newWndProc = new NativeMethods.WndProc(this.SubclassWndProc); // 创建处理消息
      IntPtr windowLongPtr = UnsafeNativeMethods.GetWindowLongPtr(new HandleRef((object) this, hwnd), -4);
      this.HookWindowProc(hwnd, newWndProc, windowLongPtr);
      return (IntPtr) this._gcHandle;
    }

上面代码的 NativeMethods.WndProc 其实是一个委托,也就是传入的 SubclassWndProc 才是核心的逻辑。这里 SubclassWndProc 就是主要处理消息,但是在上面代码是 HookWindowProc 注册。在大家也许都很熟悉 GetWindowLongPtr 函数里面将会拿到拿到窗口。在拿到 hwnd 句柄以及实际执行核心逻辑 SubclassWndProc 方法和窗口,调用进了 HookWindowProc 函数,也就是说 HookWindowProc 就是入口,下面来看一下 HookWindowProc 代码

      private void HookWindowProc(IntPtr hwnd, NativeMethods.WndProc newWndProc, IntPtr oldWndProc)
        {
            _hwndAttached = hwnd;
            _hwndHandleRef = new HandleRef(null,_hwndAttached);
            _bond = Bond.Attached;
 
            _attachedWndProc = newWndProc;
            _oldWndProc = oldWndProc;

            // 这有下面的代码才是获得消息
            IntPtr oldWndProc2 = (IntPtr)UnsafeNativeMethods.CriticalSetWindowLong(_hwndHandleRef, NativeMethods.GWL_WNDPROC, _attachedWndProc);
 
            // 跟踪这个窗口,可以在 CLR 关闭,撤掉管理的窗口代码
            ManagedWndProcTracker.TrackHwndSubclass(this, _hwndAttached);
        }

上面代码有核心的逻辑在注释告诉了大家,就是 UnsafeNativeMethods.CriticalSetWindowLong 这个函数,这个函数将需要传入 NativeMethods.GWL_WNDPROC 常量,这个常量的值是 -4 表示获得窗口过程的地址,或代表窗口过程的地址的句柄。什么是窗口过程,其实就是处理消息的地方

下面是 CriticalSetWindowLong 的代码,可以看到实际是调用 SetWindowLongPtrWndProc 方法

       internal static IntPtr CriticalSetWindowLong(HandleRef hWnd, int nIndex, NativeMethods.WndProc dwNewLong)
        {
            int errorCode;
            IntPtr retVal;
 
            retVal = NativeMethodsSetLastError.SetWindowLongPtrWndProc(hWnd, nIndex, dwNewLong);
            errorCode = Marshal.GetLastWin32Error();
 
            return retVal;
        }

所以核心就是 SetWindowLongPtrWndProc 调用 SetWindowLongWrapper 设置获得消息

那么消息是如何作为事件?在收到一个 Windows 消息,如何封装为一个 dotnet 的事件。此时请看传入的 SubclassWndProc 的方法

       internal IntPtr SubclassWndProc(IntPtr hwnd, Int32 msg, IntPtr wParam, IntPtr lParam)
        {
           // 忽略的代码

        	// 获得当前线程的 dispatcher 也就是不是主线程也可以处理
                Dispatcher dispatcher = Dispatcher.FromThread(Thread.CurrentThread);
                
                  
           // 忽略的代码
 
                    
                    DispatcherOperationCallbackParameter param = _paramDispatcherCallbackOperation;
                    _paramDispatcherCallbackOperation = null;
                    param.hwnd = hwnd;
                    param.msg = msg;
                    param.wParam = wParam;
                    param.lParam = lParam;
                    // 插入处理的消息
                    object result = dispatcher.Invoke(
                             DispatcherPriority.Send,
                             _dispatcherOperationCallback,
                             param);
            var retval = param.retVal;
            return retval;
        }

也就是调用的 SubclassWndProc 实际上就是封装消息,然后调用 dispatcher 执行。那么 _dispatcherOperationCallback 有是如何做的?实际上 这个也是调用这个弱引用委托,请看代码,下面的代码是去掉判断参数

      private object DispatcherCallbackOperation(object o)
        {
            DispatcherOperationCallbackParameter param = (DispatcherOperationCallbackParameter)o;
            param.handled = false;
            param.retVal = IntPtr.Zero; // 把返回值设置为 空,把是否处理设置为 false ,在下面调用函数,如果可以处理就设置 param.handled 为 true
            HwndWrapperHook hook = _hook.Target as HwndWrapperHook;
            param.retVal = hook(param.hwnd, param.msg, param.wParam, param.lParam, ref param.handled);
            return param;
        }

那么这个 Handle 有什么用?这个属性就是在为 false 会在 SubclassWndProc 使用下面代码

            // 如果窗口无法处理这个消息,把他发到下一个链,因为 Windows 消息是可以传到下一个窗口
           if(!handled)
            {
                retval = CallOldWindowProc(oldWndProc, hwnd, message, wParam, lParam);
            }

那么这个处理 _hook 是怎么传过来的,这个_hook 是一个弱引用,具体的传送过来需要从 Dispatcher 开始说,请看下面

Dispatcher 调度

关于调度需要从 PushFrame 开始说,请看深入了解 WPF Dispatcher 的工作原理(PushFrame 部分) - walterlv,walterlv大佬已经告诉大家底层的原理我就可以不再这里详细告诉大家

在 Dispatcher 的构造可以看到下面代码

         MessageOnlyHwndWrapper window = new MessageOnlyHwndWrapper();

创建 MessageOnlyHwndWrapper 可以收到消息,并且把消息放在 Dispatcher 用来处理,那么 MessageOnlyHwndWrapper 为什么可以收到消息? 可以看到 MessageOnlyHwndWrapper 的代码很简单

    internal class MessageOnlyHwndWrapper : HwndWrapper
    {
        public MessageOnlyHwndWrapper() : base(0, 0, 0, 0, 0, 0, 0, "", NativeMethods.HWND_MESSAGE, null)
        {
        }
    }

就需要看一下 HwndWrapper 类的代码,这个类的构造函数的关键代码

        public HwndWrapper()//太多参数,这里就不写了
        {
        	 _wndProc = new SecurityCriticalData<HwndWrapperHook>(new HwndWrapperHook(WndProc));

        	 // 还记得刚才说的消息,就是在这里创建 HwndSubclass 可以直接拿到消息
        	 HwndSubclass hwndSubclass = new HwndSubclass(_wndProc.Value);
        }

所以在上面说的 _hook 弱引用就是 传入的_wndProc ,那么这个 _wndProc 的关键代码就是WndProc,在 HwndWrapper 的函数

       private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            IntPtr result = IntPtr.Zero;
            WindowMessage message = (WindowMessage)msg;
        
            // 遍历所有的 _hooks 处理消息

                foreach(HwndWrapperHook hook in _hooks.Value)
                {
                    result = hook(hwnd, msg, wParam, lParam, ref handled);
 
                    if(handled)
                    {
                        break;
                    }
                }
 
            return result;
        }

所以这个方法也就是没有做具体处理,是通过AddHook函数添加的处理

       public void AddHook(HwndWrapperHook hook)
        {
            //VerifyAccess();
            if(_hooks == null)
            {
                _hooks = new SecurityCriticalDataClass<WeakReferenceList>(new WeakReferenceList());
            }
 
            _hooks.Value.Insert(0, hook);
        }

因为垃圾微软的代码,在 Dispatcher 的构造函数才调用 AddHook ,也就是在构造函数创建了 MessageOnlyHwndWrapper 在这个类初始化,具体处理是在初始化之后才加上,所以可以看到这个类有很多没有用的代码,因为初始化创建的值都是空,在创建之后才加上

 private Dispatcher()
 {
 	       MessageOnlyHwndWrapper window = new MessageOnlyHwndWrapper();
 	       _hook = new HwndWrapperHook(WndProcHook);
 	       window.AddHook(_hook);
 }

最核心的处理消息就是 Dispatcher 的 WndProcHook 这个方法实际上只是调用 ProcessQueue 方法。

在 SubclassWndProc 调用的dispatcher.Invoke( DispatcherPriority.Send,_dispatcherOperationCallback,param)就是调用WndProcHook 函数传入参数

如果收到了画窗口的消息,就会把这个消息发送给 DWM 桌面窗口管理器,通过DWM把窗口画的内容画到屏幕。但实际上是不是每次都有画窗口的消息?其实只有窗口创建以及最小化切换等才会收到 WM_Paint 消息,因为 WPF 是基于 DirectX 的,这里有复杂的机制告诉 DWM 绘制

想了解 DirectX 的绘制之前,需要聊聊 WPF 的最底层指导渲染的方法

RenderContent

在使用绘制原语之后,也就是调用 RenderContext 的各个基础函数进行 Visual 的绘制时,最底层的函数是 UIElement.RenderContent 请看代码

    internal override void RenderContent(RenderContext ctx, bool isOnChannel)
    {
        DUCE.Channel channel = ctx.Channel;

        DUCE.IResource drawingContent = (DUCE.IResource) this._drawingContent;
        drawingContent.AddRefOnChannel(channel);

        DUCE.CompositionNode.SetContent(this._proxy.GetHandle(channel), drawingContent.GetHandle(channel), channel);
        this.SetFlags(channel, true, VisualProxyFlags.IsContentConnected);
     
    }

通过 DUCE.CompositionNode.SetContent 调用 channel.SendCommand 就可以发送到渲染线程

      internal static unsafe void SetContent(DUCE.ResourceHandle hCompositionNode, DUCE.ResourceHandle hContent, DUCE.Channel channel)
      {
        DUCE.MILCMD_VISUAL_SETCONTENT visualSetcontent;
        visualSetcontent.Type = MILCMD.MilCmdVisualSetContent;
        visualSetcontent.Handle = hCompositionNode;
        visualSetcontent.hContent = hContent;

        // 通过下面代码发送到另一个线程
        channel.SendCommand((byte*) &visualSetcontent, sizeof (DUCE.MILCMD_VISUAL_SETCONTENT));
      }

上面代码的 DUCE 是 Dynamic Unified Composition Engine 的意思,翻译过来的含义大概是 动态统一合成引擎

那么这个渲染是如何触发,实际上在Dispatcher.ProcessQueue调用,在上面已经有告诉大家 ProcessQueue 是在 WndProcHook 触发的

       private IntPtr WndProcHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
           if(message == _msgProcessQueue)
            {
                ProcessQueue();
            }
         }

这里 _msgProcessQueue 就是在 Dispatcher 静态构造函数的自定义消息

      static Dispatcher()
        {
            _msgProcessQueue = UnsafeNativeMethods.RegisterWindowMessage("DispatcherProcessQueue");
           // 下面还有很多代码
        }
 

那么这个消息是怎么发送,在UIElement.InvalidateVisual函数会调用MediaContext.PostRender这里就发送自定义消息,于是就可以开始渲染

在消息循环是会不断获取消息,这里说的渲染是包括两个方面,一个是 WPF 把内容画到窗口,也就是上面说的自定义消息,还有另一个就是把窗口内容画在屏幕。这两个都是依靠 Windows 消息,只是第一个消息是 WPF 自己发给自己,也就是自己玩的。从 Dispatcher 拿到自定义的消息,就开始执行视觉树的对象,调用对应的绘制,这里是收集到绘制原语,也就是告诉显卡(或者准确说是 WPF 框架)可以怎么画。在底层是通过 System.Windows.Media.Composition.DUCE 的 Channel 把数据发送到渲染线程,渲染线程就是使用 Dx 进行绘制。使用 Dx 画的方法是 MilCore 从渲染线程连接到 Dx 画出来的

在渲染线程收集到的都是绘制原语,绘制原语就是在 Visual 底层调用的 DrawingContext 传入的方法

这一部分没有完全跟源代码,如果有哪些地方和实际不相同,请告诉我

这里收集到的绘制原语有部分和 DirectX 的渲染图元数据相同,但有很大部分都是不相同的,这部分就需要在框架层进行处理。渲染线程拿到了绘制原语就可以进行绘制,绘制的过程需要进行处理图片和一些基础形状,大概的过程请看下面

在绘制的时候需要使用 MIL 解码一些图片和一些形状才可以用到 dx 进行渲染

基本上的 WPF 对图片的解码都是调用 WIC 机制进行图片解码,这意味着需要用户本机安装有对应的图片解码器。当然手动解码绘制到 WriteableBitmap 也是可以的,这部分都算业务层的逻辑

在 DirectX 术语里面,上面的逻辑就属于应用阶段。而在 WPF 层面,在准备好了数据调用 Draw Call 之后的部分就交给了 DirectX 了,不属于 WPF 框架内容。这时到了 Dx 才会使用显卡进行渲染,并且绘制的是窗口指针。也就是窗口绘制完成在屏幕还是无法看到的

在窗口画完之后,会通过 WM_PAINT 告诉 DWM 桌面窗口管理器可以画出窗口。但是现代的应用是不需要在窗口刷新的过程通过 windows 消息发送到 DWM 才进行窗口刷新。只有在窗口存在部分不可见,如窗口有一部分在屏幕之外,从屏幕外移到屏幕内,或者窗口最小化到显示才需要通过 windows 告诉 DWM 刷新。

那么这里 DWM 是什么?请看下面

桌面窗口管理

在 Windows 系统,很重要的就是 DWM(Desktop Window Manager)可以把窗口画到屏幕,并且支持窗口做半透明和其他的对其他窗口的视觉处理。

需要知道,发送 WM_PAINT 消息只能通过系统发送而不能通过应用发送,也就是上面说的通过 WM_PAINT 告诉 DWM 可以画出窗口,不是软件主动告诉系统,而是系统会不断刷新。

对于不可见的窗口,在 DWM 是不会发送 WM_PAINT 到这个窗口。

详细的 WM_PAINT 请看 WMPAINT详解和WMERASEBKGND - CSDN博客

如果系统没有发送 WM_PAINT 到应用,屏幕怎么知道窗口需要刷新?实际通过Drawing Without the WM_PAINT Message 的方法就可以做到。

在 Windows 8 之后就无法手动设置关闭 DWM 的合成,只有在 windows 7 和以前的系统才可以设置关闭合成。通过 DWM 合成技术可以将每个绘制的窗口认为是一个位图,通过对位图处理添加阴影等,做出好看界面。

更多请看 Desktop Window Manager

而在 Win10 系统,其实是在 Win8.1 加入了 DirectComposition 技术,允许 DWM 分层可以细化到应用级,这意味着原先每个应用都是一层,而现在可以做到一个应用拥有多层。这样做的优势请看 为何使用 DirectComposition 使用方法请看 WPF 使用 Composition API 做高性能渲染 用 DirectX 配合 Composition API 可以作出无敌的渲染性能

从控件画出来到屏幕显示

虽然上面写了很多,但是好多小伙伴都不会仔细去看,所以本渣就在这里重新把过程说一遍。需要知道,这里说的省略很多细节,上面的也是有很多细节没有告诉大家。

在渲染的时候,是需要通过多个方式把渲染的任务放在 Dispather 里,在 WPF 应用内是可以通过 InvalidateVisual 的方法通知,而系统也在不断发送消息告诉一个应用开始渲染。

在 Dispatcher 收到消息之后就可以把渲染任务放在队列,按照优先级一个个出队

这时在 Dispatcher 内部通过渲染的调用就会通过 DispatcherOperation 执行对应的任务

渲染的任务是通过mscorlib.dll!System.Threading.ExecutionContext.Run(System.Threading.ExecutionContext executionContext, System.Threading.ContextCallback callback, object state)触发,从 mscorlib 再次调进 WindowBase ,再从 WindowBase 的 WindowsBase.dll!System.Windows.Threading.ExceptionWrapper.InternalRealCall(System.Delegate callback, object args, int numArgs) 调用 ` PresentationFramework.dll!System.Windows.Window.ShowHelper(object booleanBox)` 也就是显示的最近的代码

总的渲染结构请看下图

渲染需要经过消息循环和 Dispatcher 循环,也就是渲染的方法不是直接通过控件调用渲染。控件是通过发送消息经过消息循环再调用到控件的 OnRender 方法。再 OnRender 方法里,经过 Drawing 方法输出绘制原语到渲染线程。渲染线程经过 MIL 和 Dx 渲染界面到窗口。屏幕管理更新窗口让用户在屏幕可以看到

关于后半部分还有 DirectX 的戏份,这部分就先不多聊啦

关于渲染性能请看 WPF Drawing Performance

课件 WPF 渲染原理

更多 WPF 渲染请看 渲染相关

参见:

一站式WPF–Window(一) - 周永恒 - 博客园

WPF起步(上) — WPF是如何把图像画到屏幕上 - CSDN博客

深入了解 WPF Dispatcher 的工作原理(PushFrame 部分) - walterlv

WPF 使用 Direct2D1 画图入门

Overview of Windows Presentation Foundation (WPF) Architecture

WPF Architecture

WPF Drawing Performance

WM_PAINT详解和WM_ERASEBKGND - CSDN博客

Sharing Message Loops Between Win32 and WPF

1.3 WPF体系结构 - 51CTO.COM

WM_PAINT message

Custom Window Frame Using DWM

Performance Considerations and Best Practices

Drawing Without the WM_PAINT Message

Win32知识之窗口绘制窗口第一讲

win32程序之窗口程序以及消息机制

WPF 使用 Composition API 做高性能渲染

Differences between WPF and Silverlight Rendering Stacks


本文会经常更新,请阅读原文: https://blog.lindexi.com/post/WPF-%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

如果你想持续阅读我的最新博客,请点击 RSS 订阅,推荐使用RSS Stalker订阅博客,或者前往 CSDN 关注我的主页

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名林德熙(包含链接: https://blog.lindexi.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系

无盈利,不卖课,做纯粹的技术博客

以下是广告时间

推荐关注 Edi.Wang 的公众号

欢迎进入 Eleven 老师组建的 .NET 社区

以上广告全是友情推广,无盈利