Loading [MathJax]/jax/output/CommonHTML/config.js
前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >.NET混合开发解决方案4 WebView2的线程模型

.NET混合开发解决方案4 WebView2的线程模型

作者头像
张传宁IT讲堂
发布于 2022-05-09 12:08:54
发布于 2022-05-09 12:08:54
3.5K00
代码可运行
举报
运行总次数:0
代码可运行

系列目录     【已更新最新开发文章,点击查看详细】

  WebView2控件基于组件对象模型(COM),必须在单线程单元(STA)线程上运行。

线程安全

  • WebView2必须在使用消息泵的UI线程上创建。所有回调都发生在该线程上,对WebView2的请求必须在该线程上完成。从另一个线程使用WebView2是不安全的。
  • 唯一的例外是CoreWebView2WebResourceRequest的Content属性。内容属性流是从后台线程读取的。流应该是灵活的,或者应该从后台STA创建,以防止UI线程的性能下降。
  • 对象属性是单线程的。例如,调用CoreWebView2CookieManager.CookiesAsync(null),从主线程以外的线程获取会成功(即返回cookie);但是在这样的调用之后尝试访问cookie的属性(例如c.Domain)将引发异常。

下面以真实项目案例(建筑工程施工图BIM人工智能审查系统)讲解WbView2控件如何实现与网页、宿主程序之间进行线程安全的互相通讯。

业务场景1

  项目的某个单体下有建筑、结构、给排水、电器、暖通 5个专业,【图纸信息】模型树中上传了4个模型,底部工具栏中有“查看智能审查结果”按钮。

(1)双击模型节点创建Tab页签,页签中使用WebView2控件加载网页,渲染对应的模型。

实现方式如下:

首先判断模型是否已经在Tab页中打开并加载,如果已经加载,则直接切换到对应的Tab页。如果未打开则创建新的Tab页,Tab页中创建WebView2控件,使用LoadWebBrowser()方法加载模型。

第2441行代码,将模型与对应的WebView2控件加入集合中,用于在下面的第2个业务场景中。

LoadWebBrowser()方法实现逻辑如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public void LoadWebBrowser(WebView2 webView2Control, string bimFaceFileId)
        {
            Node nodeSelected = advTree1.SelectedNode;
            string[] arrTzIdAndSclc = nodeSelected.Name.Split('|');
            string url = ConfigurationManager.AppSettings["BIMFaceReviewPath"];
            url += "?fileId=" + bimFaceFileId
                 + "&tzName=" + HttpUtility.UrlEncode(tzName) // 解决:图纸名称中包含#会截断url
                 + "&xmid=" + _xmid
                 + "&dtgcID=" + _dtgcId
                 + "&tzxxID=" + arrTzIdAndSclc[0]
                 + "&sclc_com=" + arrTzIdAndSclc[1]
                 + "&sczy_com=" + _sczy_com
                 + "&scyjbID=''"  // 意见表ID,这里取不到,设置一个空值。在新增意见的时候才会产生
                 + "&scjlbID=" + _scjlbID
                 + "&scr_sf=" + _scrsf
                 + "&scyjbh=" + _sclc_com
                 + "&gclb_com=" + _gclb_com
                 + "&tz_sczy_code=" + ((NodeTagObject)advTree1.SelectedNode.Tag).TZ_SCZY_Code
                 + "&drawingType=BIM"
                 + "&drawingType2=BIM"
                 + "&sclc_is_change=" + (arrTzIdAndSclc[1].ToInt32() == _sclc_com ? 0 : 1)
                 + "&bimAnnotationId=''";
            //20210621 add by zcn

            // 向网页注册C#对象,供JS调用
            webView2Control.CoreWebView2.AddHostObjectToScript("customWebView2HostObject", new CustomWebView2HostObject());
          webView2Control.Source = new Uri(url);
        }

其中  webView2Control.CoreWebView2.AddHostObjectToScript("customWebView2HostObject", new CustomWebView2HostObject()); 是向目标网页中注入宿主绑定对象,用于JS调用C#方法。用于在下面的第2个业务场景中。

(2)单击模型节点创建Tab页,页签中使用WebView2组件加载网页,渲染智能审查结果。

实现方式如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 查看智能审查引擎结果
private async void btnQueryAIReviewResult_Click(object sender, EventArgs e)
{
    //格式: project_id + dtgc_id + sclc + 工程类别,如:00004361-962-0-FJ
    string batchId = _xmid + "-" + _dtgcId + "-" + _sclc_com + "-" + _gclb_com;
    string aiResult;
    int flag = WebDAL.GetModelCheckProgress(batchId, out aiResult);
    if (flag == 2)
    {
        // 将结果页面集成到系统客户端进行展示
        tabControl_TZ.SelectedTab = tabPage_BIM;

        SimpleResult<int> sr = WebDAL.QueryAIReviewResultFromDB(_xmid, _dtgcId.ToInt32(), _sclc_com, _sczy_com);

        string urlParas = "&batch_id=" + batchId + "&operate_role=ST_ZJ&operator_id=" + Global.gstrUserID + "&operator_name=" + Global.gstrUserName + "&operate_major_code=" + _sczy_com + "&is_confirm=" + sr.ResultObject;

        #region 打开网页

        string nameForTab = batchId;

        #region  如果图纸已经打开,则直接切换到目标tab,无需再创建

        foreach (TabItem tItem in tabControl_BIMFACE.Tabs)
        {
            if (nameForTab == tItem.Name)
            {
                if (dicTzAndWebBrowsers.ContainsKey(nameForTab))
                {
                    tabControl_BIMFACE.SelectedTab = tItem;

                }
                else
                {
                    MessageBox2.ShowError("查看审查意见失败。集合中不存在 WebView2 对象。");
                }

                return;
            }
        }

        #endregion

        if (tabControl_BIMFACE.Tabs.Count > 15)
        {
            MessageBox2.ShowWarning("系统最多只允许打开15个页签。请关闭暂时不用的页签之后再打开新的图纸。");
            return;
        }

        #region 创建新的Tab页签,加载模型并弹出审查意见框

        WebView2 webView2Control = new WebView2();
        webView2Control.Dock = DockStyle.Fill;
        await webView2Control.EnsureCoreWebView2Async(null);

        TabControlPanel tabPanel = new TabControlPanel();
        tabPanel.Name = nameForTab;

        TabItem tabItem = tabControl_BIMFACE.CreateTab(nameForTab);
        tabItem.Name = nameForTab;
        tabItem.Text = "智能审查结果[" + _dtgcmc + "]";
        tabItem.AttachedControl = tabPanel;

        tabPanel.TabItem = tabItem;
        tabPanel.Dock = DockStyle.Fill;

        tabPanel.Controls.Add(webView2Control);

        tabControl_BIMFACE.Controls.Add(tabPanel);
        tabControl_BIMFACE.SelectedTab = tabItem;

        // 向网页注册C#对象,供JS调用
        webView2Control.CoreWebView2.AddHostObjectToScript("customWebView2HostObject", new CustomWebView2HostObject());
        webView2Control.Source = new Uri(aiResult + urlParas);

        #endregion

        dicTzAndWebBrowsers.Add(nameForTab, webView2Control);// 将图纸与浏览器对象加入集合

        #endregion

        LogUtils.Info("专家端审查模型-查看智能审查结果地址:" + aiResult + urlParas);
    }
    else if (flag == 0 || flag == 1)
    {
        MessageBox2.ShowWarning(aiResult);
    }
    else
    {
        // flag == 3 || flag == 4 或者 flag < 0 
        MessageBox2.ShowError(aiResult);
    }
}

业务场景2

审查专家手动审查模型时,填写完审查意见,点击【保存】按钮后,网页中js调用C#方法,将对应的模型节点的“蓝色加号”图标,修改为“黄色警告”图标,表示该模型有审查意见。

实现逻辑如下:

其中926行是获取注入的自定义宿主绑定对象,927行通过该对象调用C#方法来刷新专家审查意见。CustomWebView2HostObject 类的完整定义如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
 1 using System;
 2 using System.Runtime.InteropServices;
 3 
 4 using Zjgsgtsc.Sczj;
 5 
 6 namespace Zjgsgtsc.SczjWinFrom
 7 {
 8     /// <summary>
 9     /// 自定义宿主类,用于向网页注册C#对象,供JS调用
10     /// </summary>
11     [ClassInterface(ClassInterfaceType.AutoDual)]
12     [ComVisible(true)]
13     public class CustomWebView2HostObject
14     {
15         /// <summary>
16         /// (该方法供网页js调用)网页中保存审查意见后,刷新WinForm中的审查专家意见,以及设置图纸的节点的图标
17         /// </summary>
18         public string RefreshZJSCYJ(int dtgcID, int tzxxID, int sclc_com, string sc_action, string drawingType, string drawingType2)
19         {
20             /* WebView2 是运行在其他线程中的,所以必须使用跨线程的方式进行调用。
21             *  否则无法在目标窗体中创建对象,且访问控件的属性值并不是当前运行时的属性值。
22             */
23 
24             string name = dtgcID + "|" + sc_action;
25 
26             if (drawingType == "BIM")
27             {
28                 if (drawingType2 == "BIM")
29                 {
30                     name += "|BIM";
31 
32                     if (frmMain.DicXmDtAndBIMForm.ContainsKey(name))
33                     {
34                         var form = frmMain.DicXmDtAndBIMForm[name];
35                         form.BeginInvoke(new Action(() =>
36                         {
37                             form.SetNodeImage(tzxxID + "|" + sclc_com, 1);//设置图纸节点。标记为有审查意见
38 
39                             form.LoadYjxx(); //重新加载审查意见列表
40 
41                         }));
42                     }
43                     else
44                     {
45                         // 正常情况下,不会走到该逻辑中
46                         MessageBox2.ShowError("frmMain.DicXmDtAndBIMForm 集合中未找到 Tab 页签。");
47                     }
48                 }
49                 else
50                 {
51                     // 正常情况下,不会走到该逻辑中
52                     MessageBox2.ShowError("frmMain.DicXmDtAndBIMForm 集合中未找到 Tab 页签。");
53                 }
54             }
55 
56             return string.Empty;
57         }
58     }
59 }

重要提醒:

  • 主窗体中创建了多个Tab页,每个Tab页中包含一个模型与对应的WebView2控件。在某个模型网页中审查,点击保存按钮后需要转到Form窗体中找到对应的模型节点。所以首先找到该模型对应的WebView2组件,如34行代码。
  • 第35行,Form窗体程序运行在主线程(UI线程)中,WebView2 是运行在其他线程中的。form.BeginInvoke() 方法获取 创建控件(WebView2)的基础句柄所在的线程(主线程,UI线程),然后异步执行委托,委托中调用窗体中的业务方法实现审查意见列表的更新与节点图标的更换。
  • 自定义的 CustomWebView2HostObject 类,必须标记 [ClassInterface(ClassInterfaceType.AutoDual)]、[ComVisible(true)] 特性,否则JS无法访问到该类,如代码中11、12行。

重新进入

  回调(包括事件处理程序和完成处理程序)是连续运行的。运行事件处理程序并开始消息循环后,事件处理程序或完成回调不能以重入方式运行。如果WebView2应用程序试图在WebView2事件处理程序中同步创建嵌套的消息循环或模式UI,这种方法会导致尝试重新进入。WebView2不支持这种可重入性,它会无限期地将事件处理程序留在堆栈中。

例如,不支持以下编码方法:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
private void Btn_Click(object sender, EventArgs e)
{
   // 点击按钮时,向网页提交消息
   this.webView2Control.ExecuteScriptAsync("window.chrome.webview.postMessage(\"Open Dialog\");");
}

private void CoreWebView2_WebMessageReceived(object sender, CoreWebView2WebMessageReceivedEventArgs e)
{
   string msg = e.TryGetWebMessageAsString();
   if (msg == "Open Dialog")
   {
      Form1 form = new Form1(); // 当收到web消息时,创建一个包含新WebView2实例的新窗体。
      form.ShowDialog();        // 这将导致重入问题,并导致模式对话框中新创建的WebView2控件挂起。
   }
}

相反,请安排在完成事件处理程序后执行的相应工作,如以下代码所示:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
private void CoreWebView2_WebMessageReceived(object sender, CoreWebView2WebMessageReceivedEventArgs e)
{
   string msg = e.TryGetWebMessageAsString();
   if (msg == "Open Dialog")
   {
      // 在当前事件处理程序完成后显示一个模式对话框,以避免在WebView2事件处理程序中运行嵌套的消息循环导致潜在的重入问题
      System.Threading.SynchronizationContext.Current.Post((_) => {
         Form1 form = new Form1();
         form.ShowDialog();
         form.Closed();
      }, null);
   }
}

对于 WinForms 和 WPF 应用,若要获取用于调试的完整调用堆栈,必须为 WebView2 应用启用本机代码调试,如下所示:

  1. Visual Studio中打开 WebView2 项目。
  2. 在解决方案资源管理器中,右键单击 WebView2 项目,然后选择 “属性”。
  3. 选择 “调试 ”选项卡,然后选中 “启用本机代码调试 ”复选框,如下所示。

延期

  一些WebView2事件读取在相关事件参数上设置的值,或者在事件处理程序完成后启动一些操作。如果还需要运行异步操作,例如事件处理程序,请对关联事件的事件参数使用GetDeferral()方法。返回的延迟对象确保在请求延迟的complete方法之前,事件处理程序不会被认为是已完成的。

 例如,可以使用 NewWindowRequested 事件提供CoreWebView2对象,以便在事件处理程序完成时作为子窗口进行连接。但是,如果需要异步创建CoreWebView2,则应该在 NewWindowRequestedEventArgs 上调用 GetDeleral() 方法。异步创建 CoreWebView2对象 并在 NewWindowRequestedEventArgs上设置 NewWindow 属性后,对 GetDeferral() 方法返回的延迟对象调用Complete方法()。

  • C#语言中的延迟

  在 C# 中使用 Deferral 时,最佳做法是将其与using块一起使用。 即使在using块中间引发异常,该using块也可确保Deferral已完成。 相反,如果显式调用Complete()的代码,但在完成调用之前引发了异常,那么延迟直到一段时间后才完成,此时垃圾收集器最终会收集并处理延迟。在此期间,WebView2会等待应用程序代码处理事件。

  例如,不要执行以下操作,因为如果在调用 Complete之前出现异常, WebResourceRequested 则事件不会被视为“已处理”,并阻止 WebView2 呈现该 Web 内容。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
private async void WebView2WebResourceRequestedHandler(CoreWebView2 sender,CoreWebView2WebResourceRequestedEventArgs eventArgs)
{
   var deferral = eventArgs.GetDeferral();
   args.Response = await CreateResponse(eventArgs);
 // 不建议调用Complete,因为如果CreateResponse引发异常,则延迟不会完成。
   deferral.Complete();
}

请改用块 using ,如以下示例所示。 无论是否存在异常,该 using 块都可确保 Deferral 已完成。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
private async void WebView2WebResourceRequestedHandler(CoreWebView2 sender,
                           CoreWebView2WebResourceRequestedEventArgs eventArgs)
{// using块确保延迟完成,而不管是否存在异常。
   using (eventArgs.GetDeferral())
   {
      args.Response = await CreateResponse(eventArgs);
   }
}

延期阻止UI线程

  WebView2 依赖于 UI 线程的消息泵来运行事件处理程序回调和异步方法完成回调。 如果使用阻止消息泵的方法(例如 Task.Result 或 WaitForSingleObject),则 WebView2 事件处理程序和异步方法完成处理程序不会运行。 例如,以下代码未完成,因为 Task.Result 在等待 ExecuteScriptAsync 完成时停止消息泵。 由于消息泵被阻止, ExecuteScriptAsync 因此无法完成。

例如,以下代码不起作用,因为它使用 Task.Result

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
private void Button_Click(object sender, EventArgs e)
{
    string result = webView2Control.CoreWebView2.ExecuteScriptAsync("'test'").Result;
    MessageBox.Show(this, result, "Script Result");
}

相反,请使用异步await机制,例如async、await,不会阻止消息泵或 UI 线程。 例如:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
private async void Button_Click(object sender, EventArgs e)
{
    string result = await webView2Control.CoreWebView2.ExecuteScriptAsync("'test'");
    MessageBox.Show(this, result, "Script Result");
}

审图系统业务中创建WebView2控件并初始化CoreWebView2属性以及执行JS脚本时都是使用异步方式

系列目录     【已更新最新开发文章,点击查看详细】

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2022-04-28,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
Javascript权威指南
The not-a-number value has one unusual feature in JavaScript: it does not compare equal to any other value, including itself.
全栈程序员站长
2021/12/27
8780
技术 | 用二进制算法加速神经网络
The original article is published on Nervana site: Accelerating Neural Networks with Binary Arithmetic. Please go to Nervana Homepage to learn more on Intel Nervana's deep learning technologies. At Nervana we are deeply interested in algorithmic and hard
计算机视觉研究院
2018/04/17
6870
技术 | 用二进制算法加速神经网络
Data Representation - Floating Point Numbers
In the last episode we talked about the data representation of integer, a kind of fixed-point numbers. Today we’re going to learn about floating-point numbers.
零式的天空
2022/03/28
4190
JSON5 格式标准 Data Exchange Format 官方文档 中英双语
The JSON5 Data Interchange Format is a proposed extension to JSON that aims to make it easier for humans to write and maintain by hand. It does this by adding some minimal syntax features directly from ECMAScript 5.1.JSON5 数据交换格式是一个提议的 JSON 扩展,旨在通过直接添加一些来自 ECMAScript 5.1 的最小语法特性,使人类更容易手动编写和维护。
WTSolutions
2025/03/30
1740
java.lang.NumberFormatException: Infinite or NaN原因之浮点类型除数为0结果探究
在对Double类型的数据进行计算操作,将结果转化为BigDecimal时抛出了下面的异常,进行了Debug才发现了问题原因,同时也暴露出了自己在一些基础知识上还有些欠缺。
翎野君
2023/05/12
5330
前端学习之NaN浅析
  在学习Java集合的时候遇到了Float.isNaN(float)函数,点进去一看就不理解了,函数实现如下:
Jetpropelledsnake21
2019/02/15
1.3K0
DAY34:阅读算术指令
5.4.1. Arithmetic Instructions Table 2 gives the throughputs of the arithmetic instructions that are
GPUS Lady
2018/06/22
6220
Array Broadcasting in Numpy
Let’s explore a more advanced concept in numpy called broadcasting. The term broadcasting describes how numpy treats arrays with different shapes during arithmetic operations. Subject to certain constraints, the smaller array is “broadcast” across the larger array so that they have compatible shapes. Broadcasting provides a means of vectorizing array operations so that looping occurs in C instead of Python. It does this without making needless copies of data and usually leads to efficient algorithm implementations. There are also cases where broadcasting is a bad idea because it leads to inefficient use of memory that slows computation. This article provides a gentle introduction to broadcasting with numerous examples ranging from simple to involved. It also provides hints on when and when not to use broadcasting.
狼啸风云
2019/09/25
5100
Array Broadcasting in Numpy
Source Code Reading for Vue 3: How does `hasChanged` work?
Hey, guys! The next generation of Vue has released already. There are not only the brand new composition API, much more powerful and flexible reactivity system, first-class render function, but also the natural performance with building off the modern browsers.
^_^肥仔John
2021/11/11
4160
谁是代码界3%的王者?- 第四题BigDecimal问题简单解读
提到“在Java代码界,有些陷阱外表看起来是个青铜实际上是王者,据说97%工程师会被“秒杀””
明明如月学长
2021/08/31
3530
谁是代码界3%的王者?- 第四题BigDecimal问题简单解读
基础野:细说浮点数
Brief                                 本来只打算理解JS中0.1 + 0.2 == 0.30000000000000004的原因,但发现自己对计算机的数字表示和运算十分陌生,于是只好恶补一下。  本篇我们一起来探讨一下基础——浮点数的表示方式和加减乘除运算。   在深入前有两点我们要明确的:   1. 在同等位数的情况下,浮点数可表示的数值范围比整数的大;   2. 浮点数无法精确表示其数值范围内的所有数值,只能精确表示可用科学计数法m*2e表示的数值而已;     
^_^肥仔John
2018/01/18
2.5K1
基础野:细说浮点数
常用的数学函数以及浮点数处理函数
在编程中我们总要进行一些数学运算以及数字处理,尤其是浮点数的运算和处理,这篇文章主要介绍C语言下的数学库。而其他语言中的数学库函数的定义以及最终实现也是通过对C数学库的调用来完成的,其内容大同小异,因此就不在这里介绍了。 C语言标准库中的math.h定义了非常多的数学运算和数字处理函数。这些函数大部分都是在C89标准中定义的,而有些C99标准下的函数我会特殊的说明,同时因为不同的编译器下的C标准库中有些函数的定义有差别,我也会分别的说明。
欧阳大哥2013
2018/08/22
2.7K0
常用的数学函数以及浮点数处理函数
C# 7.0简而言之 -- 02. C#基础 (1)
语句1里面计算了表达式(expression) 12 * 30, 并把结果保存到了本地变量x里面, x是整型类型.
solenovex
2018/05/03
1.1K0
C# 7.0简而言之 -- 02. C#基础 (1)
gcc x64 asm 内联汇编尝试
asm volatile(assembler template : output : input : clobber);
战神伽罗
2019/07/24
3K0
gcc x64 asm 内联汇编尝试
IEEE二进制浮点数算术标准(IEEE 754)
IEEE二进制浮点数算术标准(IEEE 754)是20世纪80年代以来最广泛使用的浮点数运算标准,为许多CPU与浮点运算器所采用。这个标准定义了表示浮点数的格式(包括负零-0)与反常值(denormal number)),一些特殊数值(无穷(Inf)与非数值(NaN)),以及这些数值的“浮点数运算符”;它也指明了四种数值舍入规则和五种例外状况(包括例外发生的时机与处理方式)。
用户7886150
2021/02/12
1.5K0
【递归】:[转]Dijkstra was right — recursion should not be difficult
“ …and discovered to my surprise that 10 % of my audience had the greatest difficulty in coping with the concept of recursive procedures. I was surprised because I knew that the concept of recursion was not difficult.” — Dijkstra’s keynote address of 1 March 1999
WEBJ2EE
2021/02/26
5620
【递归】:[转]Dijkstra was right — recursion should not be difficult
日更系列之c++的to_string的浮点数精度问题
做了一个根据搜索词计算embedding向量的服务,但是算法同学发现新服务打分精度变低了,原来能保存到小数点后16位的,现在打分只有小数点后6位。
mariolu
2022/03/05
3.1K0
Talking Head Anime from a Single Image将人脸表情移植到动漫表情中
原文地址https://pkhungurn.github.io/talking-head-anime/
水球喵子
2020/03/20
3K0
Talking Head Anime from a Single Image将人脸表情移植到动漫表情中
C++中检查浮点数值有效性
今天在项目中检查到一个bug,程序会在某些情况下崩溃,最终认定是计算一个比值时,被除数和除数均为零,导致计算结果是个无效值,在后面的代码将使用这个无效值时导致了崩溃。需要对这个结果是否有效进行判断。
用户7886150
2021/02/10
1.1K0
Under the Hood: NaN of JS
如果你还不确定这两题的答案的话,请仔细阅读本文。 这两题的答案不会直接解释,请从文章中寻找答案。
有赞coder
2020/08/25
1.6K0
Under the Hood: NaN of JS
相关推荐
Javascript权威指南
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验