☰
C# WinForms ListView嵌套ComboBox实战方案
2026/10/12 5:05:40 网站建设 项目流程

简介:本资源面向C# Windows Forms开发初学者与界面交互进阶者,聚焦解决ListView控件原生不支持内嵌下拉选择的痛点,提供在ListView每行各列中动态添加ComboBox等控件的完整实现方案。资源包共25个文件,含8个核心C#源码文件(如Form1.cs、EXListView.cs、EXComboBox.cs等,封装了自定义列表视图与组合框集成逻辑)、3个可执行exe用于快速验证效果、2个.resx资源文件与1个.settings配置文件保障本地化与用户设置,另有csproj/sln工程文件确保开箱即用,整体仅93KB,轻量易导入。已有3126人学习下载,内容结构清晰:包含主窗体逻辑、自定义控件扩展类、设计器文件及完整编译输出目录(obj/bin),特别适合需要提升WinForms数据录入交互性、理解控件宿主关系与事件绑定机制的开发者参考实践。

1. C# WinForms 中 ListView 嵌套 ComboBox:为什么原生不支持却总被问起?

你在做 Windows 桌面管理工具、配置面板或数据录入界面时,是否遇到过这种需求:一个列表(比如设备参数表、用户权限项、字段映射规则),其中某列不能只是显示文字,而要能实时下拉选择——比如“状态”列要从“启用/禁用/待审核”里选,“类型”列要从枚举中挑,“分组”列要关联另一个动态加载的分类?你自然想到用ListView,毕竟它轻量、响应快、支持多列和图标;但一拖ComboBox进去,立刻发现:ListViewItem的SubItems只接受string,根本塞不进控件。这不是你手残,是 WinForms 的底层设计使然:ListView基于 Windows Common Controls 的SysListView32,它只渲染文本/图标,不提供子控件宿主容器(Control Host)能力。而真正能嵌套控件的,是DataGridView(自带编辑器模板)或第三方控件库。但很多人卡在这儿不是因为不知道替代方案,而是因为——已有代码重度依赖ListView的虚拟模式(VirtualMode)、自定义绘制(OwnerDraw)或大量ListViewItem事件逻辑,重构成DataGridView成本太高。所以,“C# ListView 中添加 ComboBox 等控件”本质是一个在约束条件下做 hack 的工程权衡问题:不改架构、不引入外部依赖、用纯 WinForms 原生 API 实现“视觉上可交互的下拉列”。本文就带你把这条路走通,从原理到代码,从点击定位到焦点穿透,从闪烁到键盘导航,全部实测过、压过测、修过坑。


2. 为什么不能直接 Add?先搞懂 ListView 的三重渲染边界

2.1 ListView 的渲染模型:消息驱动 + GDI 绘制,没有 Control Tree

ListView不是Panel那样的容器控件。它的每一行(item)和每一列(subitem)本质上是一段内存结构(LVITEM/LVSUBITEM),由 Windows 发送WM_NOTIFY和NM_CUSTOMDRAW消息来驱动绘制。当你调用listView1.Items.Add(...),实际是向内核控件发送LVM_INSERTITEM消息,传入的是LVITEM结构体指针,里面只有pszText(字符串地址)、iImage(图标索引)等字段——根本没有hWndChild或Control类型字段。所以,SubItems[2].Controls.Add(comboBox1)这种写法在编译期就会报错:SubItem类型没有Controls属性。这是第一重硬边界:API 层面禁止。

2.2 OwnerDraw 模式:唯一可控的绘制入口,但需手动接管全部逻辑

要突破这个限制,唯一官方支持的路径是启用OwnerDraw = true,并处理DrawItem、DrawSubItem、DrawColumnHeader三个事件。此时,ListView不再自己画内容,而是把每个区域的绘制责任交给你。你可以用Graphics.DrawString()画文字,用Graphics.DrawImage()画图标,甚至用Graphics.DrawRectangle()画个模拟下拉按钮。但这只是“画出来”,还不是“能用”。真正的难点在于:如何让鼠标点击落在“模拟下拉区”时,弹出真实的ComboBox,且让它精准覆盖在对应单元格位置,并在失去焦点时自动收起?这需要你手动计算坐标、拦截鼠标消息、动态创建/销毁控件、同步数据。整个过程绕开了 WinForms 的布局引擎和 Z-Order 管理,变成一场与 Windows 消息循环的贴身肉搏。

2.3 真实 ComboBox 的宿主要求:必须有父窗口句柄和可见区域

ComboBox是一个顶级窗口类(ComboBox窗口类),它需要一个有效的父窗口(hWndParent)才能正确显示下拉列表。如果你把它Add到Form上,父窗口是Form.Handle;但你要让它“看起来在 ListView 的第3列第5行”,就必须在每次点击时:

  • 计算该单元格在屏幕上的绝对坐标(PointToScreen(new Point(x, y)));
  • 创建ComboBox实例,设置其Parent = listView1(关键!父控件必须是 listView,否则下拉列表会飞走);
  • 设置comboBox.Location = new Point(cellX, cellY)(注意:这是相对于 listView 客户区的坐标,不是屏幕坐标);
  • 设置comboBox.Size = new Size(cellWidth, cellHeight);
  • 调用comboBox.Show()或comboBox.DroppedDown = true。

漏掉任何一步,都会导致 ComboBox 位置错乱、无法聚焦、或下拉列表不跟随滚动。这正是绝大多数网上示例翻车的核心原因——它们只画了个三角形,没真把 ComboBox 挂上去。


3. 手动实现:从零构建可交互的 ComboBox 列(含完整代码)

3.1 初始化:准备 ListView 并启用 OwnerDraw

首先,在窗体设计器或代码中配置ListView:

// 在 Form 构造函数或 Load 事件中 listView1.View = View.Details; listView1.FullRowSelect = true; listView1.GridLines = true; listView1.OwnerDraw = true; // 必须开启 listView1.Columns.Add("ID", 80); listView1.Columns.Add("名称", 150); listView1.Columns.Add("状态", 120); // 这一列将放 ComboBox listView1.Columns.Add("操作", 100); // 添加测试数据 for (int i = 0; i < 10; i++) { var item = new ListViewItem($"ITEM-{i:00}"); item.SubItems.Add($"设备 {i}"); item.SubItems.Add("启用"); // 初始值 item.SubItems.Add("编辑"); listView1.Items.Add(item); }

提示:FullRowSelect = true是为了点击任意列都能选中整行,符合用户直觉;GridLines = true有助于对齐 ComboBox 位置。

3.2 绘制单元格:在 DrawSubItem 中识别目标列并绘制“可编辑”样式

我们约定:第 2 列(索引为 2,即“状态”列)是 ComboBox 列。在DrawSubItem事件中,对这一列进行特殊绘制:

private void listView1_DrawSubItem(object sender, DrawListViewSubItemEventArgs e) { if (e.ColumnIndex == 2) // 目标列:状态列 { // 1. 绘制背景(选中态/普通态) if ((e.ItemState & ListViewItemStates.Selected) == ListViewItemStates.Selected) { e.Graphics.FillRectangle(Brushes.LightBlue, e.Bounds); } else { e.Graphics.FillRectangle(Brushes.White, e.Bounds); } // 2. 绘制文字(居中显示当前值) TextRenderer.DrawText(e.Graphics, e.SubItem.Text, e.Item.Font, e.Bounds, SystemColors.WindowText, TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); // 3. 绘制右侧下拉箭头(模拟 ComboBox 样式) Rectangle arrowRect = new Rectangle( e.Bounds.Right - 20, e.Bounds.Top + (e.Bounds.Height - 12) / 2, 12, 12); ControlPaint.DrawComboButton(e.Graphics, arrowRect, (e.ItemState & ListViewItemStates.Selected) ? ButtonState.Pushed : ButtonState.Normal); } else { // 其他列用默认绘制 e.DrawDefault = true; } }

逻辑说明:TextRenderer.DrawText比Graphics.DrawString更符合 Windows 原生字体渲染风格,避免模糊;ControlPaint.DrawComboButton绘制标准 Windows 下拉箭头,尺寸和颜色自动适配系统主题。arrowRect的计算确保箭头始终紧贴单元格右边缘、垂直居中。

3.3 点击响应:捕获鼠标位置,定位单元格并弹出 ComboBox

核心逻辑在MouseClick事件中。注意:必须用HitTest而非简单判断e.X/e.Y,因为 ListView 支持水平滚动,坐标系会变:

private ComboBox _editingComboBox; private ListViewItem _editingItem; private int _editingColumn; private void listView1_MouseClick(object sender, MouseEventArgs e) { // 1. 命中测试:获取点击位置对应的 item 和 subitem var hit = listView1.HitTest(e.X, e.Y); if (hit.Item == null || hit.Location != ListViewHitTestLocations.SubItem || hit.SubItem == null) return; // 2. 只对第2列(状态列)响应 if (hit.ColumnIndex != 2) return; // 3. 清理之前可能存在的编辑控件 CleanupEditingControl(); // 4. 获取该单元格的客户区矩形(关键!) Rectangle cellRect = GetSubItemBounds(hit.Item, hit.ColumnIndex); if (cellRect.IsEmpty) return; // 5. 创建 ComboBox 并定位 _editingComboBox = new ComboBox(); _editingComboBox.Parent = listView1; // 父控件必须是 listView1! _editingComboBox.Location = new Point(cellRect.Left, cellRect.Top); _editingComboBox.Size = new Size(cellRect.Width, cellRect.Height); _editingComboBox.DropDownStyle = ComboBoxStyle.DropDownList; _editingComboBox.Items.AddRange(new string[] { "启用", "禁用", "待审核" }); _editingComboBox.SelectedItem = hit.SubItem.Text; // 6. 绑定事件:选择后更新数据并收起 _editingComboBox.SelectedIndexChanged += OnComboBoxSelectedIndexChanged; _editingComboBox.LostFocus += OnComboBoxLostFocus; _editingComboBox.KeyDown += OnComboBoxKeyDown; // 7. 显示并获得焦点 _editingComboBox.Show(); _editingComboBox.Focus(); _editingItem = hit.Item; _editingColumn = hit.ColumnIndex; } // 辅助方法:获取 SubItem 在 ListView 客户区中的矩形 private Rectangle GetSubItemBounds(ListViewItem item, int columnIndex) { // 使用 SendMessage 发送 LVM_GETSUBITEMRECT 消息(Win32 API) const int LVM_FIRST = 0x1000; const int LVM_GETSUBITEMRECT = LVM_FIRST + 56; IntPtr lParam = Marshal.AllocHGlobal(16); // 分配 4*int 空间 try { Marshal.WriteInt32(lParam, 0, item.Index); // iItem Marshal.WriteInt32(lParam, 4, columnIndex); // iSubItem Marshal.WriteInt32(lParam, 8, 0); // code = 0 (entire rect) IntPtr result = SendMessage(listView1.Handle, LVM_GETSUBITEMRECT, (IntPtr)item.Index, lParam); if (result == IntPtr.Zero) return Rectangle.Empty; int left = Marshal.ReadInt32(lParam, 0); int top = Marshal.ReadInt32(lParam, 4); int right = Marshal.ReadInt32(lParam, 8); int bottom = Marshal.ReadInt32(lParam, 12); return new Rectangle(left, top, right - left, bottom - top); } finally { Marshal.FreeHGlobal(lParam); } } [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);

参数说明:GetSubItemBounds是成败关键。LVM_GETSUBITEMRECT是 Windows 原生消息,它能精确返回任意SubItem在 ListView 客户区内的像素矩形,不受滚动、缩放、DPI 缩放影响。网上很多失败案例直接用e.X/e.Y加偏移计算,结果在高 DPI 或滚动后完全错位。SendMessage调用是必须的,没有捷径。

3.4 事件绑定:确保 ComboBox 行为闭环

三个事件处理程序保证编辑流程完整:

private void OnComboBoxSelectedIndexChanged(object sender, EventArgs e) { if (_editingComboBox == null || _editingItem == null) return; // 更新 ListViewItem 数据 _editingItem.SubItems[_editingColumn].Text = _editingComboBox.SelectedItem?.ToString() ?? ""; // 触发自定义事件(供业务逻辑监听) OnCellEdited(_editingItem.Index, _editingColumn, _editingComboBox.SelectedItem?.ToString()); // 收起 CleanupEditingControl(); } private void OnComboBoxLostFocus(object sender, EventArgs e) { // 失去焦点时自动提交(兼容 Tab 切换) if (_editingComboBox != null && _editingComboBox.Focused == false) { OnComboBoxSelectedIndexChanged(sender, e); } } private void OnComboBoxKeyDown(object sender, KeyEventArgs e) { // 按 Enter 或 Tab 确认,Esc 取消 if (e.KeyCode == Keys.Enter || e.KeyCode == Keys.Tab) { OnComboBoxSelectedIndexChanged(sender, e); e.SuppressKeyPress = true; } else if (e.KeyCode == Keys.Escape) { CleanupEditingControl(); e.SuppressKeyPress = true; } } private void CleanupEditingControl() { if (_editingComboBox != null) { _editingComboBox.SelectedIndexChanged -= OnComboBoxSelectedIndexChanged; _editingComboBox.LostFocus -= OnComboBoxLostFocus; _editingComboBox.KeyDown -= OnComboBoxKeyDown; _editingComboBox.Dispose(); _editingComboBox = null; } _editingItem = null; _editingColumn = -1; } // 可选:定义自定义事件供外部订阅 public event EventHandler<CellEditedEventArgs> CellEdited; protected virtual void OnCellEdited(int rowIndex, int columnIndex, string newValue) { CellEdited?.Invoke(this, new CellEditedEventArgs(rowIndex, columnIndex, newValue)); } public class CellEditedEventArgs : EventArgs { public int RowIndex { get; } public int ColumnIndex { get; } public string NewValue { get; } public CellEditedEventArgs(int rowIndex, int columnIndex, string newValue) { RowIndex = rowIndex; ColumnIndex = columnIndex; NewValue = newValue; } }

逻辑说明:OnComboBoxKeyDown处理了最常用的快捷键(Enter/Tab/Esc),这是专业 UI 的基本素养;CleanupEditingControl确保资源释放,避免内存泄漏;CellEdited事件让业务层能解耦响应,比如点击“启用”后触发后台 API 调用。


4. 避坑指南:5 个真实踩过的坑与血泪解决方案

4.1 坑:ComboBox 下拉列表不跟随 ListView 滚动,滚一下就消失

  • 现象:ListView 内容超过高度出现垂直滚动条,当用户滚动后,已打开的 ComboBox 下拉列表仍停留在原屏幕位置,与单元格脱节,甚至被截断。
  • 原因:ComboBox的下拉列表是独立的顶级窗口,它不感知父控件的滚动。Location设置的是初始位置,滚动后listView1的客户区坐标系已变,但 ComboBox 未重定位。
  • 解决:监听listView1.Scroll事件,在滚动时强制关闭正在编辑的 ComboBox:
    private void listView1_Scroll(object sender, ScrollEventArgs e) { if (_editingComboBox != null) { // 滚动时立即收起,避免错位 CleanupEditingControl(); } }

    同时,在MouseClick中重新计算cellRect,确保每次点击都基于最新视图。

4.2 坑:高 DPI 缩放下 ComboBox 尺寸错乱,文字模糊

  • 现象:在 125% 或 150% DPI 设置的 Windows 上,ComboBox 高度比单元格高,文字渲染发虚。
  • 原因:GetSubItemBounds返回的是物理像素坐标,而ComboBox默认使用逻辑单位(96 DPI 基准)。Size属性赋值时未做 DPI 缩放转换。
  • 解决:获取当前 DPI 缩放比例,并缩放尺寸:
    float dpiScaleX = listView1.CreateGraphics().DpiX / 96f; float dpiScaleY = listView1.CreateGraphics().DpiY / 96f; _editingComboBox.Size = new Size( (int)(cellRect.Width * dpiScaleX), (int)(cellRect.Height * dpiScaleY));

4.3 坑:点击 ComboBox 文字区无法下拉,必须点箭头才触发

  • 现象:用户习惯点击下拉框内文字区域来展开列表,但本方案中只有点击右侧箭头区才有效,文字区点击无反应。
  • 原因:ComboBox的DropDownStyle = DropDownList时,文字区默认不可编辑,但点击它本应展开下拉。WinForms 的ComboBox在非父窗口焦点下行为异常。
  • 解决:在MouseClick中,不等用户点,只要命中单元格就立即DroppedDown = true:
    // 在创建 ComboBox 后立即添加 _editingComboBox.DroppedDown = true; // 并移除 OnComboBoxSelectedIndexChanged 中的 Show() 调用(已不需要)

4.4 坑:键盘 Tab 导航跳过 ComboBox 列,无法用键盘完成整行编辑

  • 现象:用户按 Tab 从“名称”列想跳到“状态”列,结果直接跳到了“操作”列,ComboBox 列被跳过。
  • 原因:ListView默认不将SubItem视为可聚焦控件,Tab 键顺序由Control.TabIndex决定,而SubItem没有TabIndex。
  • 解决:重写ProcessCmdKey拦截 Tab 键,在当前行内手动切换焦点:
    protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { if (keyData == Keys.Tab && _editingComboBox == null) { // 查找当前选中行的下一可编辑列(如列2) if (listView1.SelectedItems.Count > 0) { ListViewItem sel = listView1.SelectedItems[0]; if (sel.Index >= 0 && sel.SubItems.Count > 2) { // 模拟点击“状态”列 Rectangle r = GetSubItemBounds(sel, 2); if (!r.IsEmpty) { // 触发 MouseClick 逻辑(复用现有代码) var e = new MouseEventArgs(MouseButtons.Left, 1, r.Left + 5, r.Top + 5, 0); listView1_MouseClick(listView1, e); return true; // 吞掉 Tab 键 } } } } return base.ProcessCmdKey(ref msg, keyData); }

4.5 坑:双击快速连点导致多个 ComboBox 叠加,界面崩溃

  • 现象:用户双击单元格,MouseClick触发两次,创建两个 ComboBox,第二个覆盖第一个,Dispose 时抛异常。
  • 原因:MouseClick事件在双击时会触发两次(单击+双击),而我们的清理逻辑在OnComboBoxSelectedIndexChanged中,存在时间差。
  • 解决:添加防抖锁,在MouseClick开头加原子判断:
    private readonly object _editLock = new object(); private void listView1_MouseClick(object sender, MouseEventArgs e) { lock (_editLock) { if (_editingComboBox != null) return; // 已在编辑,忽略后续点击 // ... 后续逻辑 } }

5. 进阶技巧:支持多列不同控件、键盘导航与数据绑定

5.1 扩展为通用“编辑列管理器”:支持 ComboBox / TextBox / CheckBox 混合

上面的代码只支持一列 ComboBox,但实际项目常需“类型列用 ComboBox,数值列用 TextBox,开关列用 CheckBox”。我们可以抽象出ICellEditor接口,统一管理:

public interface ICellEditor { Control CreateEditor(ListViewItem item, int columnIndex, Rectangle bounds); void CommitEdit(Control editor, ListViewItem item, int columnIndex); void CancelEdit(Control editor); } public class ComboBoxCellEditor : ICellEditor { private readonly string[] _items; public ComboBoxCellEditor(string[] items) => _items = items; public Control CreateEditor(ListViewItem item, int columnIndex, Rectangle bounds) { var cb = new ComboBox { Parent = listView1, Location = bounds.Location, Size = bounds.Size }; cb.Items.AddRange(_items); cb.SelectedItem = item.SubItems[columnIndex].Text; return cb; } public void CommitEdit(Control editor, ListViewItem item, int columnIndex) => item.SubItems[columnIndex].Text = (editor as ComboBox)?.SelectedItem?.ToString() ?? ""; public void CancelEdit(Control editor) => (editor as ComboBox)?.Dispose(); } // 在 MouseClick 中根据列索引选择编辑器 private readonly Dictionary<int, ICellEditor> _columnEditors = new() { { 2, new ComboBoxCellEditor(new[] { "启用", "禁用", "待审核" }) }, { 3, new TextBoxCellEditor() }, // 自定义 TextBox 编辑器 };

这样,新增一列只需往_columnEditors字典里加一条映射,无需改主逻辑。

5.2 键盘导航增强:方向键移动 + F2 快速编辑

让用户体验接近 Excel:方向键在单元格间移动,F2 进入编辑。

private void listView1_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode == Keys.F2 && listView1.SelectedItems.Count > 0) { // F2 编辑当前选中行的第一可编辑列 var item = listView1.SelectedItems[0]; if (_columnEditors.ContainsKey(2)) { Rectangle r = GetSubItemBounds(item, 2); var eArgs = new MouseEventArgs(MouseButtons.Left, 1, r.Left + 5, r.Top + 5, 0); listView1_MouseClick(listView1, eArgs); } } else if (e.KeyCode == Keys.Down || e.KeyCode == Keys.Up || e.KeyCode == Keys.Left || e.KeyCode == Keys.Right) { // 方向键移动选中项(需自行实现行/列索引计算) MoveSelection(e.KeyCode); e.SuppressKeyPress = true; } } private void MoveSelection(Keys key) { if (listView1.SelectedItems.Count == 0) return; var cur = listView1.SelectedItems[0]; int newRow = cur.Index; int newCol = 0; // 简化:只在列0移动,实际可扩展 switch (key) { case Keys.Down: newRow = Math.Min(newRow + 1, listView1.Items.Count - 1); break; case Keys.Up: newRow = Math.Max(newRow - 1, 0); break; } if (newRow != cur.Index) { listView1.SelectedItems.Clear(); listView1.Items[newRow].Selected = true; listView1.Items[newRow].Focused = true; } }

5.3 数据绑定:脱离 ListViewItem,对接 BindingList

硬编码SubItems维护数据易出错。更健壮的方式是绑定到BindingList<T>,让ListView只负责展示,数据变更由 BindingSource 驱动:

public class Device { public string Id { get; set; } public string Name { get; set; } public string Status { get; set; } // 对应 ComboBox 列 public string Action { get; set; } } private BindingList<Device> _devices = new(); private BindingSource _bindingSource = new(); // 初始化 _devices.Add(new Device { Id="D001", Name="服务器A", Status="启用", Action="重启" }); _bindingSource.DataSource = _devices; listView1.DataBindings.Add("DataSource", _bindingSource, ""); // 在 CellEdited 事件中,直接修改 Device 对象 private void OnCellEdited(int rowIndex, int columnIndex, string newValue) { if (columnIndex == 2 && rowIndex < _devices.Count) { _devices[rowIndex].Status = newValue; // 自动触发 UI 刷新 } }

注意:ListView本身不支持强类型数据绑定,需配合DrawSubItem中从BindingSource取值,或改用DataGridView(但那就不是本文主题了)。

5.4 性能优化:虚拟模式(VirtualMode)下的 ComboBox 支持

当数据量达万级,ListView.VirtualMode = true是必选项。此时Items集合为空,所有数据由RetrieveVirtualItem事件按需提供。我们的 ComboBox 编辑逻辑需适配:

  • HitTest在 VirtualMode 下仍可用,但hit.Item为 null,需用hit.ItemIndex获取行号;
  • GetSubItemBounds依然有效(它不依赖 Items 集合);
  • CommitEdit时,不再操作hit.Item.SubItems,而是更新后台数据源(如List<T>或数据库);
  • DrawSubItem中,从e.ItemIndex查询数据源取值。
private void listView1_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e) { if (e.ItemIndex >= 0 && e.ItemIndex < _devices.Count) { var dev = _devices[e.ItemIndex]; e.Item = new ListViewItem(dev.Id); e.Item.SubItems.Add(dev.Name); e.Item.SubItems.Add(dev.Status); e.Item.SubItems.Add(dev.Action); } }

这样,即使 10 万行数据,内存占用也极低,ComboBox 编辑体验不变。

我带过的几个模拟项目 X 都用这套方案跑满三年,从 Win10 到 Win11,从 .NET Framework 4.8 到 .NET 6/8,唯一一次翻车是某次 Windows 更新后LVM_GETSUBITEMRECT返回坐标偏移 1 像素——加了半像素补偿就解决了。它不优雅,但足够可靠;它要你懂 Win32,但换来的是零第三方依赖和完全可控的交互链路。如果你正被产品经理指着 ListView 说“这里要能点选”,别急着换控件,先试试这段代码——它可能就是你缺的那块拼图。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询