作为C#开发者,你是否经常被复杂的回调逻辑搞得头疼?是否在写数据处理管道时觉得代码冗长难维护?今天我要和你分享一个让代码瞬间变优雅的秘密武器:C#内置委托Action和Func。
通过一个真实的数据处理项目案例,我将向你展示如何用寥寥几行委托代码,就能构建出可维护、可扩展的企业级数据处理管道。相信我,掌握这个技能后,你的代码质量将有质的飞跃!
在日常开发中,我们经常遇到这样的场景:
传统写法往往是:
c#// 传统方式:代码冗长、耦合严重
public void ProcessUserData()
{
var users = ReadCsvFile();
foreach(var user in users)
{
if(user.Age >= 18) // 硬编码过滤条件
{
user.Email = CleanEmail(user.Email); // 硬编码转换逻辑
WriteToDatabase(user); // 硬编码输出方式
}
}
}
这样的代码存在致命问题:
Action和Func委托就是为了解决这类问题而生:

c#public class UserRecord
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public int Age { get; set; }
public override string ToString()
=> $"Id={Id}, Name={Name}, Email={Email}, Age={Age}";
}
c#using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace AppDataPipeline
{
public class DataPipeline
{
public static void Process(
Func<IEnumerable<UserRecord>> loader, // 数据加载器
Func<UserRecord, bool>? filter, // 过滤条件
Func<UserRecord, UserRecord>? transform, // 数据转换
Action<UserRecord> action, // 最终动作
Action<string>? logger = null) // 日志记录
{
logger?.Invoke("Pipeline started.");
var records = loader() ?? Enumerable.Empty<UserRecord>();
var total = 0;
var passed = 0;
foreach (var rec in records)
{
total++;
logger?.Invoke($"Loaded: {rec}");
// 应用过滤器
if (filter != null && !filter(rec))
{
logger?.Invoke($"Filtered out: {rec}");
continue;
}
passed++;
// 应用转换器
var newRec = transform != null ? transform(rec) : rec;
logger?.Invoke($"Transformed: {newRec}");
// 执行最终动作
action(newRec);
}
logger?.Invoke($"Pipeline finished. Total={total}, Passed={passed}");
}
}
}
c#using CsvHelper;
using System.Globalization;
namespace AppDataPipeline
{
internal class Program
{
static async Task Main(string[] args)
{
var csvPath = "users_example.csv";
// 数据加载器:使用 Func 委托
Func<IEnumerable<UserRecord>> loader = () => ReadUsersFromCsv(csvPath);
// 过滤器:只保留成年用户
Func<UserRecord, bool> filter = u => u.Age >= 18;
// 转换器:数据清洗和标准化
Func<UserRecord, UserRecord> transform = u => {
var email = u.Email ?? "";
// 修复常见邮箱格式错误
if (email.Contains("[at]"))
email = email.Replace("[at]", "@");
if (string.IsNullOrWhiteSpace(email))
email = $"{u.Name.ToLowerInvariant()}@example.local";
// 标准化姓名格式
var name = u.Name?.Trim() ?? "";
name = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(name.ToLowerInvariant());
return new UserRecord
{
Id = u.Id,
Name = name,
Email = email,
Age = u.Age
};
};
// 输出动作:写入数据库
Action<UserRecord> writeDb = u =>
Console.WriteLine($"[DB] Writing user: {u}");
// 日志记录
Action<string> logger = msg =>
Console.WriteLine($"[LOG] {DateTime.Now:HH:mm:ss} - {msg}");
// 一行代码搞定整个管道!
DataPipeline.Process(loader, filter, transform, writeDb, logger);
}
private static IEnumerable<UserRecord> ReadUsersFromCsv(string path)
{
using var reader = new StreamReader(path);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
var records = csv.GetRecords<UserRecord>().ToList();
return records;
}
}
}

有段时间我非常喜欢这么干
在做桌面端项目的时候,越来越多的团队开始尝试用 Vue3 来写 UI 界面——毕竟前端生态太香了,组件库丰富、开发效率高。但问题来了:Winform + WebView2 加载本地 Vue3 项目,路子走错了就是一堆报错。
常见的踩坑路径大概是这样的:打包好 Vue3 的 dist 目录,直接用 file:// 协议加载 index.html,结果页面一片空白,控制台疯狂报跨域错误;或者资源路径全部 404,vite.config.ts 里的 base 没配对,白忙活半天。
更头疼的是,有些项目里用到了 localStorage、fetch 请求本地接口,file:// 协议下这些东西要么受限要么直接不可用。
本文会把两种主流方案——file:// 协议直接加载和虚拟域名映射——从原理到代码完整走一遍,踩坑点逐一标出,读完你可以直接拿去用。
Vue3 用 Vite 打包后,dist 目录里的资源引用路径默认是绝对路径(/assets/index.xxx.js)。在浏览器里用 file:// 打开,这个 / 会被解析成文件系统根目录,路径完全对不上。
除此之外,file:// 协议本身有几个硬伤:
file:// 下不同文件之间被视为不同源,fetch、XMLHttpRequest 跨文件请求直接被拦截file:// 协议不支持 Service Worker 注册file:// 下的存储 API 有额外限制file:// 下的跨域请求会被直接拒绝WebView2 底层是 Chromium,上述限制同样存在。所以 file:// 方案能用,但适用场景有限,只适合纯静态、无接口调用的轻量页面。
WebView2 提供了一个非常实用的 API:SetVirtualHostNameToFolderMapping,可以把一个虚拟域名(如 app.local)映射到本地文件夹。这样 Vue3 项目就能以 https://app.local/ 的形式加载,彻底绕开 file:// 的各种限制,同源策略正常工作,fetch 请求也不再受阻。
csharp// Form1.cs
using Microsoft.Web.WebView2.Core;
using System;
using System.IO;
using System.Windows.Forms;
public partial class Form1 : Form
{
private Microsoft.Web.WebView2.WinForms.WebView2 webView2;
public Form1()
{
InitializeComponent();
InitializeWebView2();
}
private async void InitializeWebView2()
{
webView2 = new Microsoft.Web.WebView2.WinForms.WebView2();
webView2.Dock = DockStyle.Fill;
this.Controls.Add(webView2);
// 初始化 WebView2 环境
// 指定用户数据目录,避免多实例冲突
var userDataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MyApp", "WebView2Data"
);
var env = await CoreWebView2Environment.CreateAsync(
browserExecutableFolder: null,
userDataFolder: userDataFolder
);
await webView2.EnsureCoreWebView2Async(env);
// 构建 Vue3 dist 目录的 file:// 路径
// 推荐将 dist 目录放在应用程序目录下
string distPath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory,
"wwwroot", "dist", "index.html"
);
string fileUrl = new Uri(distPath).AbsoluteUri;
// 结果类似:file:///C:/MyApp/wwwroot/dist/index.html
webView2.CoreWebView2.Navigate(fileUrl);
}
}
路径中含中文或空格:Uri 类会自动做 URL 编码,但建议把 dist 目录放在纯英文路径下,避免潜在的编码问题。
file:// 下 fetch 本地 JSON 文件失败:Chromium 默认阻止 file:// 下的跨文件 fetch。
适用场景总结:纯展示型页面、无接口调用、无 Service Worker 需求的轻量项目,file:// 方案足够用,配置简单,部署也方便,说明了就是一个简单的HTML页面。
这是更稳健的方案,尤其适合有接口调用、需要完整浏览器特性支持的项目。
虚拟域名方案下,Vue3 的 base 可以保持默认(/),因为加载方式已经是标准的 HTTP 形式:
typescript// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: '/', // 保持默认,或根据实际路由需求调整
build: {
outDir: 'dist',
}
})
如果用了 Vue Router 的 history 模式,还需要注意路由配置,后面会提到。
csharp// Form1.cs
using Microsoft.Web.WebView2.Core;
using System;
using System.IO;
using System.Windows.Forms;
public partial class Form1 : Form
{
private Microsoft.Web.WebView2.WinForms.WebView2 webView2;
// 虚拟域名,可以自定义,建议用 .local 后缀
private const string VirtualHost = "app.local";
public Form1()
{
InitializeComponent();
InitializeWebView2Async();
}
private async void InitializeWebView2Async()
{
webView2 = new Microsoft.Web.WebView2.WinForms.WebView2();
webView2.Dock = DockStyle.Fill;
this.Controls.Add(webView2);
var userDataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MyApp", "WebView2Data"
);
var env = await CoreWebView2Environment.CreateAsync(
browserExecutableFolder: null,
userDataFolder: userDataFolder
);
await webView2.EnsureCoreWebView2Async(env);
// 获取 Vue3 dist 目录的绝对路径
string distFolder = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory,
"wwwroot", "dist"
);
// 核心 API:将虚拟域名映射到本地文件夹
// CoreWebView2HostResourceAccessKind.Allow:允许从该虚拟主机访问资源
webView2.CoreWebView2.SetVirtualHostNameToFolderMapping(
VirtualHost,
distFolder,
CoreWebView2HostResourceAccessKind.Allow
);
// 导航到虚拟域名下的 index.html
webView2.CoreWebView2.Navigate($"https://{VirtualHost}/index.html");
}
}

去年帮朋友调试一个人脸识别系统。代码逻辑完美,模型也没问题。 但准确率?惨不忍睹。
花了三个小时排查,最后发现——图像读取时用错了参数,BGR通道被当成RGB处理了。这种低级错误,我在不同项目里见过至少五次。
图像读取真的只是cv2.imread()这么简单吗?
今天咱们就把OpenCV的图像基础操作彻底掰开了讲。不只是告诉你怎么用,更要说清楚为什么这么用,以及那些文档里不会告诉你的坑。
看完这篇,你能掌握:
很多人的OpenCV第一课是这样的:
pythonimport cv2
img = cv2.imread('photo.jpg')
看起来没毛病?但这行代码在生产环境里可能引发三个问题:
None我在一个医疗影像处理项目中,就因为没检查imread()返回值,导致后续处理全崩。调试了半天才发现——文件根本没读进来。
pythoncv2.imread(filename, flags=cv2.IMREAD_COLOR)
这个flags参数才是关键。常用的有四种模式:
1. cv2.IMREAD_COLOR(默认值,等于1)
读取彩色图像,忽略Alpha通道。
输出形状:(height, width, 3),BGR顺序。
2. cv2.IMREAD_GRAYSCALE(等于0)
强制转为灰度图。
输出形状:(height, width),单通道。
3. cv2.IMREAD_UNCHANGED(等于-1) 保留所有通道,包括Alpha透明通道。 PNG图像必用这个模式。
4. cv2.IMREAD_ANYDEPTH(等于2) 读取16位、32位深度图像。 医疗影像、科研数据处理专用。
我做过一个测试,用不同模式读取同一张4K分辨率的PNG图(含透明通道):
pythonimport cv2
import time
def test_read_modes(image_path):
# 模式1:默认彩色模式
start = time.time()
img1 = cv2.imread(image_path) # 丢失透明通道
t1 = time.time() - start
# 模式2:保留所有通道
start = time.time()
img2 = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
t2 = time.time() - start
# 模式3:灰度模式
start = time.time()
img3 = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
t3 = time.time() - start
print(f"默认模式:{img1.shape} | 耗时:{t1:.4f}s")
print(f"完整模式:{img2.shape} | 耗时:{t2:.4f}s")
print(f"灰度模式:{img3.shape} | 耗时:{t3:.4f}s")
# 实测结果(3840×2160分辨率):
# 默认模式:(2160, 3840, 3) | 耗时:0.0823s
# 完整模式:(2160, 3840, 4) | 耗时:0.1057s
# 灰度模式:(2160, 3840) | 耗时:0.0341s
看到没?灰度模式比彩色模式快2.4倍。
如果你的业务场景不需要颜色信息(比如边缘检测、OCR文字识别),直接用灰度模式能大幅提升处理速度。
Windows下这个问题特别常见。传统解决办法是用cv2.imdecode()配合numpy:
pythonimport cv2
import numpy as np
def imread_chinese(file_path, flags=cv2.IMREAD_COLOR):
"""支持中文路径的图像读取"""
# 先用numpy读取二进制数据
with open(file_path, 'rb') as f:
data = f.read()
# 转换为numpy数组
img_array = np.frombuffer(data, dtype=np.uint8)
# 解码为图像
img = cv2.imdecode(img_array, flags)
return img
# 使用示例
img = imread_chinese('fl.jpg')
if img is not None:
cv2.imshow('Processed Image', img)
cv2.waitKey(0) # 等待按键
cv2.destroyAllWindows() # 关闭窗口
else:
print("无法读取图像。")
这个方法我在所有Windows项目里都会封装一遍。虽然多了几行代码,但能避免90%的路径问题。
永远不要假设图像读取成功。
这是我血泪教训总结的最佳实践:
pythondef safe_imread(image_path, flags=cv2.IMREAD_COLOR):
"""安全的图像读取函数"""
# 检查文件是否存在
if not os.path.exists(image_path):
raise FileNotFoundError(f"图像文件不存在:{image_path}")
# 尝试读取
img = cv2.imread(image_path, flags)
# 验证读取结果
if img is None:
# 尝试中文路径方案
try:
img = imread_chinese(image_path, flags)
except Exception as e:
raise ValueError(f"无法读取图像:{image_path},错误:{str(e)}")
# 验证图像尺寸合理性
if img.size == 0:
raise ValueError(f"读取到空图像:{image_path}")
return img
多写几行判断代码,能帮你省下几个小时的调试时间。
做工控上位机或实时监控系统的朋友,大概都踩过这个坑:传感器数据以每秒几百甚至上千个点的频率涌进来,界面上的波形图一开始还挺流畅,跑了十几分钟之后开始掉帧,跑半小时内存涨到几百 MB,严重的时候直接 UI 线程假死,客户当场就皱眉头了。
这不是 ScottPlot 的锅,也不是 WinForms 太老了——根本原因在于高频数据场景下,刷新策略与内存模型的设计没有匹配上采集节奏。数据进来的速度远超渲染消化的速度,缓冲区无限增长,GC 压力越来越大,最终把整个应用拖垮。
本文会从问题根源出发,给出三个渐进式的工程解法,覆盖从"基础可用"到"生产级稳定"的完整路径。读完之后,你手里会有:
测试环境:Windows 11,.NET 8,ScottPlot 5.0.36,采集频率模拟 1000 Hz,数据类型 double。
很多初学者的第一版代码大概长这样:在采集回调里直接 formsPlot.Refresh(),每来一个数据点就刷新一次图表。1000 Hz 的采集意味着每秒要触发 1000 次 UI 重绘。WinForms 的 UI 线程根本扛不住——它的正常渲染帧率大约在 30~60 FPS,超出的请求会堆积在消息队列里,最终导致整个界面失去响应。
渲染不是越频繁越好,超出显示器刷新率的重绘都是无效消耗。
另一个常见问题是用 List<double> 直接累积所有历史数据,然后每次刷新都把整个列表传给 AddSignal()。运行一小时,1000 Hz 的数据量大约是 360 万个 double,占用接近 28 MB——这还只是原始数据,ScottPlot 内部渲染时还会有额外的对象分配。更糟糕的是,每次 Refresh() 都可能触发一次全量数组拷贝,GC 频繁介入,停顿时间肉眼可见。
采集线程(通常是串口回调、定时器线程或异步 I/O 线程)直接操作 UI 控件,轻则抛出 InvalidOperationException,重则数据竞争导致波形撕裂甚至程序崩溃。这个问题在小数据量时偶尔不出现,但高频场景下几乎必现。
在正式写代码之前,先把几个设计原则立好:
AddSignal() 而非 AddScatter(),前者针对等时间间隔数据做了专项优化,渲染复杂度远低于后者。这是最容易落地的方案,适合数据量中等(<500 Hz)、对架构复杂度要求不高的场景。
核心思路:用一个固定大小的 double[] 数组模拟环形缓冲区,采集线程只做写入,System.Windows.Forms.Timer 按 33ms(约 30 FPS)间隔驱动 UI 刷新。
csharp// 线程安全的环形缓冲区
public class CircularBuffer
{
private readonly double[] _buffer;
private int _writeIndex = 0;
private readonly object _lock = new object();
public int Capacity { get; }
public CircularBuffer(int capacity)
{
Capacity = capacity;
_buffer = new double[capacity];
}
/// <summary>写入新数据点,自动覆盖最旧的数据</summary>
public void Write(double value)
{
lock (_lock)
{
_buffer[_writeIndex % Capacity] = value;
_writeIndex++;
}
}
/// <summary>获取当前缓冲区快照(按时间顺序)</summary>
public double[] GetSnapshot()
{
lock (_lock)
{
int count = Math.Min(_writeIndex, Capacity);
int startIndex = _writeIndex > Capacity ? _writeIndex % Capacity : 0;
double[] snapshot = new double[count];
for (int i = 0; i < count; i++)
{
snapshot[i] = _buffer[(startIndex + i) % Capacity];
}
return snapshot;
}
}
}
csharpnamespace AppScottPlot14
{
public partial class Form1 : Form
{
// 缓冲区容量 = 采样率 × 显示窗口秒数,此处保留 5 秒数据
private readonly CircularBuffer _circularBuffer = new CircularBuffer(5000);
private ScottPlot.Plottables.Signal? _signal;
private System.Windows.Forms.Timer _renderTimer = new System.Windows.Forms.Timer();
private Random _rng = new Random(); // 模拟采集数据源
public Form1()
{
InitializeComponent();
InitPlot();
StartAcquisition();
StartRenderTimer();
}
private void InitPlot()
{
formsPlot1.Plot.Axes.Left.Label.FontName= "Microsoft YaHei";
formsPlot1.Plot.Axes.Right.Label.FontName= "Microsoft YaHei";
formsPlot1.Plot.Axes.Top.Label.FontName= "Microsoft YaHei";
formsPlot1.Plot.Axes.Bottom.Label.FontName= "Microsoft YaHei";
formsPlot1.Plot.Font.Set("Microsoft YaHei");
formsPlot1.Plot.Title("实时波形监控");
formsPlot1.Plot.XLabel("采样点");
formsPlot1.Plot.YLabel("幅值");
// 预先用空数组创建 Signal,后续只更新数据引用
_signal = formsPlot1.Plot.Add.Signal(Array.Empty<double>());
}
private void StartAcquisition()
{
// 用后台线程模拟 1000 Hz 采集
Task.Run(() =>
{
while (true)
{
double value = Math.Sin(Environment.TickCount64 / 200.0) + _rng.NextDouble() * 0.1;
_circularBuffer.Write(value);
Thread.Sleep(1); // 模拟 1ms 采集间隔
}
});
}
private void StartRenderTimer()
{
_renderTimer.Interval = 33; // ~30 FPS
_renderTimer.Tick += (s, e) =>
{
// 从缓冲区取快照,不阻塞采集线程
double[] snapshot = _circularBuffer.GetSnapshot();
if (snapshot.Length == 0) return;
// 更新 Signal 数据并刷新
_signal!.Data = new ScottPlot.DataSources.SignalSourceDouble(snapshot, 1);
formsPlot1.Plot.Axes.AutoScale();
formsPlot1.Refresh();
};
_renderTimer.Start();
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_renderTimer.Stop();
_renderTimer.Dispose();
base.OnFormClosed(e);
}
}
}

踩坑预警:GetSnapshot() 里有一次数组分配,30 FPS 下每秒会产生 30 次短生命周期对象。如果 GC 压力仍然明显,可以改为传入预分配的 Span<double> 来避免堆分配——这就引出了方案三的进阶优化。
做了一个 WinForms 桌面应用,在自己的开发机上跑得好好的,发给客户一装——界面全乱了。WebView2 嵌入的网页内容缩成一团,或者撑破了整个窗口边界,按钮和输入框的位置也不对。改一圈,下次换个分辨率又出问题。
这个问题在工控软件、企业内部工具、数据看板类应用里尤其常见。现实是:开发机通常是 1920×1080 的标准分辨率,而客户现场可能是 1366×768 的老屏幕,也可能是 2560×1440 的高清屏,甚至是 125%、150% 缩放比的 HiDPI 环境。
统计显示,在企业级桌面应用的用户反馈中,界面适配问题占比超过 30%,而其中相当大一部分来自分辨率与 DPI 缩放的处理不当。
读完这篇文章,你将掌握三个可以直接落地的方案:
WinForms 的控件定位默认是绝对坐标系——每个控件的 Location 和 Size 是写死的像素值。这在固定分辨率的时代没有问题,但在多分辨率、多 DPI 的现代环境下,这种设计天然脆弱。
咱们来看一个典型的错误场景:
csharp// ❌ 常见错误:硬编码尺寸和位置
webView21.Location = new Point(10, 50);
webView21.Size = new Size(800, 500);
这段代码在 1920×1080 的屏幕上没问题,但在 1366×768 的屏幕上,WebView2 直接超出窗口范围。在 150% DPI 缩放下,实际渲染尺寸会被系统拉伸,导致控件模糊或错位。
WebView2 不是普通的 WinForms 控件,它本质上是一个托管的 Chromium 渲染进程,通过 WebView2CompositionControl 或 WebView2 宿主接口嵌入到窗体中。这带来了几个额外的复杂性:
CoreWebView2 在 EnsureCoreWebView2Async() 完成之前不可用,如果在初始化完成前就调整尺寸,可能导致内容渲染异常。Handle 强绑定,窗口大小变化时需要显式通知渲染层更新。很多开发者的第一反应是"用 AutoScaleMode 不就行了",或者"设置 Anchor = AnchorStyles.All 不就解决了"。这两种思路方向是对的,但如果不理解背后的机制,依然会踩坑。
误解一:AutoScaleMode.Dpi 会自动处理所有缩放问题。
实际情况:AutoScaleMode.Dpi 只处理控件的初始化缩放,不处理运行时窗口大小变化时的动态适配。
误解二:给 WebView2 设置 Dock = DockStyle.Fill 就万事大吉。
实际情况:Dock = Fill 确实能让 WebView2 填满父容器,但如果父容器本身的布局有问题,或者 WebView2 初始化时机不对,依然会出现空白区域或渲染错位。
理解 WinForms 布局,需要区分三个层次:
第一层:容器级布局,通过 TableLayoutPanel、FlowLayoutPanel 或 SplitContainer 来管理控件的相对位置关系。这一层决定了控件之间的空间分配逻辑。
第二层:控件级适配,通过 Anchor 和 Dock 属性控制单个控件如何响应父容器的尺寸变化。Anchor 适合需要保持边距的场景,Dock 适合需要完全填充的场景。
第三层:DPI 感知,通过应用程序清单(app.manifest)和 AutoScaleMode 配置,控制系统如何处理高 DPI 显示器上的渲染行为。
这三层必须协同工作,缺一不可。
WebView2 控件的渲染区域由两部分决定:WinForms 控件的 Bounds,以及 WebView2 宿主的内部视口大小。在大多数情况下,这两者是自动同步的,但在以下场景下需要手动干预:
这是最推荐的基础方案,适合大多数桌面应用场景。
csharpusing Microsoft.Web.WebView2.WinForms;
namespace AppWebView202604
{
public partial class FrmMain : Form
{
private TableLayoutPanel _mainLayout;
private Panel _toolbarPanel;
private WebView2 _webView;
private Panel _statusPanel;
public FrmMain()
{
InitializeComponent();
BuildLayout();
_ = InitWebViewAsync();
}
private void BuildLayout()
{
// 主布局:三行,工具栏 / 内容区 / 状态栏
_mainLayout = new TableLayoutPanel
{
Dock = DockStyle.Fill,
RowCount = 3,
ColumnCount = 1,
Padding = Padding.Empty,
Margin = Padding.Empty
};
// 行高配置:工具栏固定40px,内容区自动填充,状态栏固定24px
_mainLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40F));
_mainLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
_mainLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 24F));
_toolbarPanel = new Panel { Dock = DockStyle.Fill, BackColor = Color.FromArgb(240, 240, 240) };
_webView = new WebView2 { Dock = DockStyle.Fill };
_statusPanel = new Panel { Dock = DockStyle.Fill, BackColor = Color.FromArgb(0, 122, 204) };
_mainLayout.Controls.Add(_toolbarPanel, 0, 0);
_mainLayout.Controls.Add(_webView, 0, 1);
_mainLayout.Controls.Add(_statusPanel, 0, 2);
this.Controls.Add(_mainLayout);
}
private async Task InitWebViewAsync()
{
// 等待 CoreWebView2 初始化完成,再进行后续操作
await _webView.EnsureCoreWebView2Async(null);
_webView.CoreWebView2.Navigate("https://www.ia2025.com");
}
}
}

关键点:TableLayoutPanel 的 SizeType.Percent 行会自动吸收窗口尺寸变化,WebView2 的 Dock = Fill 确保它始终填满分配给它的单元格。