2026-05-26
C#
0

🤔 你是否遇到过这些困境?

在实际项目里,有一类问题几乎每个 C# 开发者都踩过坑——程序集加载时需要做一些全局性的初始化工作,但你不知道该把这段逻辑放在哪里。

比如注册全局的序列化器、初始化日志框架、预热缓存、设置默认编码……这些事情必须在任何业务代码执行之前完成,但又没有一个"天然"的入口。于是你开始在 Main() 里堆代码,或者在每个类的静态构造函数里写初始化,结果维护成本越来越高,初始化顺序也越来越难以控制。

C# 9.0 引入的 [ModuleInitializer] 特性,正是为了解决这个问题而生的。它提供了一种在程序集层面、任何用户代码执行之前自动触发初始化的机制,干净、优雅、无侵入。

读完本文,你将掌握:

  • ModuleInitializer 的底层运行机制
  • 它与静态构造函数、AppDomain 事件的本质区别
  • 3 个可直接落地的实战场景与完整代码

1️⃣ 问题深度剖析:初始化的"无主之地"

传统方案的痛点

[ModuleInitializer] 出现之前,C# 开发者通常有以下几种选择来处理全局初始化:

方案一:在 Main() 中集中初始化

csharp
static void Main(string[] args) { // 各种初始化逻辑堆在这里 LogManager.Initialize(); SerializerRegistry.RegisterDefaults(); CacheWarmup.Run(); // ... 然后才是真正的业务逻辑 }

这种方式最直接,但问题也最明显——它只适用于有 Main() 的可执行程序。类库项目根本没有入口点,你无法强制库的使用者在调用你的 API 之前先执行某段初始化代码。

方案二:静态构造函数(Static Constructor)

csharp
public static class MyLibrary { static MyLibrary() { // 初始化逻辑 } }

静态构造函数的触发时机是"第一次访问该类型时",这意味着它是懒触发的,无法保证在程序集加载后立即执行。如果初始化逻辑涉及跨类型的依赖,顺序就很难控制,线程安全问题也随之而来。

方案三:约定俗成的"Init"方法

csharp
// 要求使用者手动调用 MyLibrary.Initialize();

这是最脆弱的方案。一旦使用者忘记调用,程序可能在运行时才出现莫名其妙的错误,排查成本极高。

这三种方案都有一个共同的缺陷:初始化逻辑与调用者耦合,或者依赖运行时的某个特定时机,缺乏一种真正意义上"程序集自治"的初始化能力。


2️⃣ 核心要点提炼:ModuleInitializer 的底层机制

什么是 Module?

要理解 ModuleInitializer,首先要明白 .NET 里"模块(Module)"的概念。在 .NET 的 PE 文件结构中,一个程序集(Assembly)可以包含一个或多个模块(Module),通常情况下一个程序集就是一个模块。模块是 IL 代码的物理载体。

在 IL 层面,每个模块都可以有一个特殊的方法叫做 .cctor(模块级静态构造函数,也称 Module Initializer)。这个方法由 CLR 在模块被加载时自动调用,早于任何其他代码

C# 9.0 的 [ModuleInitializer] 特性,正是将这个底层的 IL 机制暴露给了 C# 开发者。

使用规则与约束

[ModuleInitializer] 的使用有明确的编译器约束,必须满足以下条件:

  • 方法必须是 static
  • 方法必须没有参数,也没有返回值(void
  • 方法不能是泛型方法,也不能包含在泛型类中
  • 方法必须可以从模块内部访问(internalpublic 均可)
  • 不能是 extern 方法
csharp
using System.Runtime.CompilerServices; namespace AppModuleInitializer { internal static class AppInitializer { [ModuleInitializer] internal static void Initialize() { // 这里的代码会在程序集加载后、任何其他代码执行前自动运行 Console.WriteLine("模块初始化器已触发"); } } internal class Program { static void Main(string[] args) { Console.WriteLine("Hello, World!"); } } }

image.png

执行时机与顺序

[ModuleInitializer] 的执行时机非常早,具体顺序如下:

  1. CLR 加载程序集
  2. 触发所有 [ModuleInitializer] 标记的方法(按编译顺序)
  3. 触发各类型的静态构造函数(懒触发)
  4. 执行 Main() 或其他入口代码

如果同一个程序集中存在多个 [ModuleInitializer] 方法,它们的执行顺序由编译器决定(通常按照源码中的定义顺序),不建议依赖多个初始化器之间的执行顺序

2026-05-26
C#
0

🎯 你是否遇到过这些"灵异"问题?

在用 WinForms 集成 WebView2 的项目里,有一类问题特别折磨人——

同一台机器上运行两个集成了 WebView2 的程序,其中一个崩了,另一个也跟着挂掉;或者用户换了账号登录,上一个用户的 Cookie、缓存还赖着不走;再或者,单元测试跑着跑着,突然报一个"用户数据目录被锁定"的异常,重启才能恢复。

这些问题,根源几乎都指向同一个地方:UserDataFolder(用户数据目录)的管理混乱

WebView2 的 UserDataFolder 存储了 Cookie、缓存、IndexedDB、LocalStorage 等所有浏览器状态数据。默认情况下,多个 WebView2 实例会争抢同一个目录,轻则数据污染,重则进程互锁崩溃。

读完这篇文章,你将掌握:

  • 为什么默认配置是一颗定时炸弹,以及背后的锁机制原理
  • 三种渐进式隔离策略,从单实例到多用户多租户场景全覆盖
  • 可直接落地的代码模板,含完整注释,拿来即用

🔍 问题深度剖析:默认行为为何是个陷阱?

WebView2 的数据目录到底存了什么?

WebView2 底层复用了 Chromium 的用户配置文件机制。一个典型的 UserDataFolder 结构大致如下:

%AppData%\Local\EBWebView\ ├── Default\ │ ├── Cookies ← SQLite 数据库,存储所有 Cookie │ ├── Cache\ ← HTTP 缓存 │ ├── Local Storage\ ← localStorage 数据 │ ├── IndexedDB\ ← IndexedDB 数据 │ └── ... ├── Crashpad\ ← 崩溃转储 └── lockfile ← 进程独占锁

注意最后那个 lockfileChromium 使用文件锁确保同一个 UserDataFolder 同一时间只能被一个进程独占访问。 这是浏览器防止数据损坏的保护机制,但在多实例场景下,它就变成了灾难的来源。

默认路径的三大隐患

隐患一:多实例互锁

当你的程序启动第二个 WebView2 控件(或第二个程序实例),而两者指向同一个 UserDataFolder 时,第二个进程会因为拿不到文件锁而初始化失败,抛出 WebView2RuntimeNotFoundException 或无声地卡死。

隐患二:数据污染与泄漏

在多用户切换场景(如医疗、工控的操作员切换),如果不隔离目录,用户 A 的登录态、表单数据会被用户 B 直接读取。这不仅是体验问题,在某些行业里是合规红线。

隐患三:测试环境污染生产数据

开发阶段的调试页面、测试账号的 Cookie,会和生产环境的数据混在一起。这玩意儿排查起来极其隐蔽,往往要折腾半天才能定位。

2026-05-26
Python
0

🎯 你的 GUI 代码,是不是也烂成这样?

做工控上位机开发这几年,见过太多"意大利面条式"的 Tkinter 代码——几千行堆在一个类里,if/elif 嵌套七八层,按钮回调函数里藏着设备通信逻辑,界面刷新和业务判断搅在一起。

改个需求,牵一发动全身。

更头疼的是,这类系统往往跑在 24 小时不停机的工业现场。一个状态判断漏掉,设备就可能进入未定义行为。这不是"代码不好看"的问题,是实实在在的生产风险。

状态机(State Machine) 是解决这类问题的工业级方案。在嵌入式、PLC 编程领域,它是标准范式;但在 Python 上位机开发圈子里,用得规范的并不多。

读完这篇文章,你将掌握:

  • 有限状态机的核心设计思路,以及它与 Tkinter 事件驱动模型的天然契合点
  • 从"裸写 if/else"到"工业级状态机"的三个渐进式重构方案
  • 一套可直接复用的状态机框架模板,适配点胶机、CNC、测试台等常见上位机场景

🔍 问题深度剖析:状态混乱从哪里来

传统写法的三个致命伤

先看一段典型的"野生"上位机代码:

python
def on_start_button_click(self): if self.is_running: messagebox.showwarning("警告", "设备正在运行") return if not self.is_connected: messagebox.showerror("错误", "设备未连接") return if self.is_paused: self.resume_device() self.is_paused = False self.is_running = True self.start_btn.config(text="运行中", state="disabled") self.pause_btn.config(state="normal") else: self.start_device() self.is_running = True self.start_btn.config(text="运行中", state="disabled") ...

第一个问题:状态分散。 is_runningis_connectedis_paused 三个布尔变量共同描述系统状态,理论上有 8 种组合,但实际上合法的只有 3-4 种。剩下那几种"非法组合"没有任何防护,一旦时序出问题,系统就进入未定义状态。

第二个问题:转换逻辑与 UI 逻辑耦合。 业务判断和按钮状态更新混在同一个函数里。哪天要加一个"急停"按钮,你得把每一个回调函数翻一遍。

第三个问题:不可测试。 这样的代码没有办法写单元测试——状态散落在 UI 组件属性里,离开 Tkinter 主循环根本跑不起来。

一个中等规模的工控项目(约 5000 行 Tkinter 代码),维护新增功能的时间往往占整个项目周期的 40%~55%,其中大部分时间花在"理清状态关系"上。这个数字在引入状态机后,可以压缩到 15%~20%。


💡 核心要点提炼:状态机为什么适合上位机

有限状态机的本质

有限状态机(FSM,Finite State Machine)由三个要素构成:状态(State)、事件(Event)、转换(Transition)

用一句话描述:系统在某个状态下,接收到某个事件,执行某个动作,然后迁移到下一个状态。

这和 Tkinter 的事件驱动模型几乎是同构的——Tkinter 的主循环本身就是一个事件泵,按钮点击、定时器触发、串口数据到达,都是"事件"。把状态机叠加在 Tkinter 事件系统上,二者天然契合,不需要引入额外的线程复杂度。

工控场景的状态建模原则

以一台自动化测试台为例,它的完整生命周期大概是这样的:

空闲(IDLE) → 初始化(INITIALIZING) → 就绪(READY) → 运行(RUNNING) → 暂停(PAUSED) → 完成(FINISHED) → 空闲(IDLE) ↓ 错误(ERROR) → 空闲(IDLE)

关键设计原则:

  • 单一真相来源:系统状态只存在于状态机对象中,UI 组件的显示状态是它的"投影",而非独立变量。
  • 显式转换表:所有合法的状态迁移必须事先声明,未声明的迁移一律拒绝执行,这是工业安全的基本要求。
  • 进入/退出动作分离:每个状态的"进入动作"(如使能某些按钮)和"退出动作"(如清理资源)独立定义,不混入转换逻辑。

🚀 解决方案设计:三步走,从混乱到工业级

方案一:枚举 + 字典,最小化状态机

适用场景: 小型上位机工具,状态数量 ≤ 5,团队不想引入复杂框架。

这是最轻量的改造方式,核心思路是用 Enum 替代多个布尔变量,用字典声明合法转换。

python
import tkinter as tk from tkinter import ttk, messagebox from enum import Enum, auto class DeviceState(Enum): IDLE = auto() CONNECTING = auto() READY = auto() RUNNING = auto() ERROR = auto() # 合法转换表:{当前状态: [可到达的下一状态]} VALID_TRANSITIONS = { DeviceState.IDLE: [DeviceState.CONNECTING], DeviceState.CONNECTING: [DeviceState.READY, DeviceState.ERROR], DeviceState.READY: [DeviceState.RUNNING, DeviceState.IDLE], DeviceState.RUNNING: [DeviceState.READY, DeviceState.ERROR], DeviceState.ERROR: [DeviceState.IDLE], } class SimpleStateMachine: def __init__(self, initial_state: DeviceState): self._state = initial_state self._listeners = [] # 状态变化监听器 @property def state(self): return self._state def transition(self, new_state: DeviceState) -> bool: """尝试状态迁移,非法迁移返回 False""" if new_state not in VALID_TRANSITIONS.get(self._state, []): print(f"[FSM] 非法迁移: {self._state.name} -> {new_state.name}") return False old_state = self._state self._state = new_state self._notify(old_state, new_state) return True def add_listener(self, callback): self._listeners.append(callback) def _notify(self, old, new): for cb in self._listeners: cb(old, new) class TestBenchApp(tk.Tk): def __init__(self): super().__init__() self.title("测试台控制面板 v1.0") self.geometry("400x300") # 状态机是核心,UI 是它的"显示层" self.fsm = SimpleStateMachine(DeviceState.IDLE) self.fsm.add_listener(self._on_state_changed) self._build_ui() self._refresh_ui(DeviceState.IDLE, DeviceState.IDLE) def _build_ui(self): frame = ttk.LabelFrame(self, text="设备控制", padding=15) frame.pack(fill="both", expand=True, padx=20, pady=20) self.status_var = tk.StringVar(value="● 空闲") ttk.Label(frame, textvariable=self.status_var, font=("微软雅黑", 12)).pack(pady=10) btn_frame = ttk.Frame(frame) btn_frame.pack() self.connect_btn = ttk.Button(btn_frame, text="连接设备", command=self._on_connect) self.connect_btn.grid(row=0, column=0, padx=5, pady=5) self.start_btn = ttk.Button(btn_frame, text="开始测试", command=self._on_start) self.start_btn.grid(row=0, column=1, padx=5, pady=5) self.stop_btn = ttk.Button(btn_frame, text="停止", command=self._on_stop) self.stop_btn.grid(row=0, column=2, padx=5, pady=5) self.reset_btn = ttk.Button(btn_frame, text="复位", command=self._on_reset) self.reset_btn.grid(row=1, column=1, padx=5, pady=5) def _on_state_changed(self, old_state, new_state): """状态变化时,统一刷新 UI——这是唯一的 UI 更新入口""" self._refresh_ui(old_state, new_state) def _refresh_ui(self, old_state, new_state): """根据当前状态,声明式地配置所有 UI 组件""" state = self.fsm.state # 状态 -> UI 配置的映射,清晰且易于维护 config_map = { DeviceState.IDLE: ("● 空闲", "normal", "disabled", "disabled", "disabled"), DeviceState.CONNECTING: ("◌ 连接中…", "disabled", "disabled", "disabled", "disabled"), DeviceState.READY: ("● 就绪", "disabled", "normal", "disabled", "normal"), DeviceState.RUNNING: ("▶ 运行中", "disabled", "disabled", "normal", "disabled"), DeviceState.ERROR: ("✖ 错误", "disabled", "disabled", "disabled", "normal"), } label, c_btn, s_btn, st_btn, r_btn = config_map[state] self.status_var.set(label) self.connect_btn.config(state=c_btn) self.start_btn.config(state=s_btn) self.stop_btn.config(state=st_btn) self.reset_btn.config(state=r_btn) # --- 事件处理:只负责触发状态迁移,不直接操作 UI --- def _on_connect(self): if self.fsm.transition(DeviceState.CONNECTING): # 模拟异步连接,实际项目中用 threading 或 after() self.after(1500, self._connect_done) def _connect_done(self): self.fsm.transition(DeviceState.READY) def _on_start(self): self.fsm.transition(DeviceState.RUNNING) def _on_stop(self): self.fsm.transition(DeviceState.READY) def _on_reset(self): self.fsm.transition(DeviceState.IDLE) if __name__ == "__main__": app = TestBenchApp() app.mainloop()

image.png

踩坑预警: _refresh_ui 里的 config_map 必须覆盖所有状态,漏掉任何一个状态都会在运行时抛 KeyError。建议在开发阶段加一个断言:assert set(config_map.keys()) == set(DeviceState)

2026-05-25
C#
0

🎯 开头:那些年我踩过的异步坑

说实话,异步编程这东西就像开车——大部分人都会踩油门刹车,但真到了复杂路况,翻车的可不少。我见过太多项目因为一个看似简单的.Result调用导致整个UI卡死,也见过库代码因为没处理好上下文同步导致死锁,最惨的一次是生产环境直接挂了两小时。

根据��在实际项目中的观察,至少60%的异步相关bug都源于对线程上下文和阻塞调用的误解。这玩意儿不像空指针异常那么显眼,它藏得很深,往往在压测或生产环境才暴露。

读完这篇文章,你会彻底搞懂:

  • 为什么界面代码和库代码的异步写法要完全不同
  • 如何避免死锁这个异步编程的头号杀手
  • 3种渐进式的异步安全模式,拿来就能用

咱们先从一个真实的翻车现场说起。

💥 问题深度剖析:死锁是怎么发生的

经典死锁场景重现

先看一段代码,这是我在代码审查中见过无数次的写法:

csharp
// ❌ 危险!这段代码会在WPF/WinForms中直接死锁 public class UserService { public User GetUserData(int userId) { // 开发者想:反正要等结果,直接.Result不就完了? return GetUserDataAsync(userId).Result; } private async Task<User> GetUserDataAsync(int userId) { await Task.Delay(1000); // 模拟网络请求 return new User { Id = userId, Name = "张三" }; } } // 在UI线程调用 private void Button_Click(object sender, EventArgs e) { var service = new UserService(); var user = service.GetUserData(1); // 💀 界面直接冻结 UserLabel.Text = user.Name; }

这段代码为啥会死锁? 让我用最直白的方式解释底层机制:

  1. UI线程调用GetUserData(),遇到.Result后进入同步等待状态(线程被阻塞)
  2. GetUserDataAsync()执行到await Task.Delay(1000)时,捕获了当前的同步上下文(SynchronizationContext)
  3. await后面的代码会被调度回UI线程执行
  4. 但此时UI线程正在.Result那里死等,根本腾不出手来执行后续代码
  5. 形成完美闭环:UI线程等异步完成,异步等UI线程空闲 → 死锁

我在实际项目中测试过,这种死锁在WPF应用中出现概率接近100%,而且调试器都不会给你明确提示,只会看到界面卡住,CPU占用趋近于0。

常见误解与隐藏成本

很多开发者会说:"我用.Wait()代替.Result不就行了?" 不好意思,一样死。还有人尝试.GetAwaiter().GetResult(),结果发现只是换了个死法。

真正的成本在哪里?我统计过一个中型WPF项目的重构数据:

  • 修复异步死锁问题耗时:37人时
  • 引发的生产事故:2次(平均每次影响200+用户)
  • 性能回归测试:80+测试用例需要重写

这还不包括用户投诉和信任损失。

🧠 核心要点提炼

在深入解决方案之前,咱们先把几个核心概念理清楚:

1️⃣ 同步上下文(SynchronizationContext)的真面目

这玩意儿就像是一个"线程调度员":

  • UI应用(WPF/WinForms):有专属的UI同步上下文,确保UI操作都在UI线程执行
  • ASP.NET Framework(老版本):有HttpContext同步上下文,用于管理请求上下文
  • ASP.NET Core / 控制台应用默认没有同步上下文(这很重要!)

2️⃣ ConfigureAwait的黄金法则

csharp
await SomeMethodAsync().ConfigureAwait(false);

这行代码的意思是:"执行完异步操作后,不要回到原来的同步上下文"。记住这个原则:

代码类型推荐做法原因
库代码全部用ConfigureAwait(false)避免捕获上下文,防止死锁,提升性能
UI代码默认不加(或显式true需要回到UI线程更新界面
ASP.NET Core可用可不用(没有上下文)建议加上以保持习惯

3️⃣ 阻塞调用的三大原罪

永远不要在异步代码路径上使用:

  • .Result / .Wait()
  • .GetAwaiter().GetResult()(在有同步上下文的环境)
  • Task.WaitAll() / Task.WaitAny()

这些操作会把异步链条"斩断",引发各种诡异问题。

2026-05-25
C#
0

🎯 当数据有"方向性",普通图表就不够用了

在环境监测、气象分析、工厂排风系统这类项目里,有一类数据天然带有方向属性——风向与风速。把这类数据塞进折线图或柱状图,信息会严重失真:风从北偏东 30° 吹来,和从南偏西 30° 吹来,在折线图上可能长得一模一样。

风向玫瑰图(Wind Rose) 是气象和环保领域的标准可视化方案,它用极坐标展示各方向的风频和风速分布,一张图就能回答"这个地区主导风向是哪里、强风集中在哪个扇区"这两个核心问题。

LiveCharts 2 提供了 PolarChart 控件,配合 PolarLineSeriesPolarBarSeries,可以在 WinForms 项目里直接实现专业级风向玫瑰图。

读完本文,你将掌握:

  • PolarChart 的基础搭建与坐标系配置
  • ✅ 用 PolarBarSeries 实现标准风向玫瑰图
  • ✅ 多风速等级分层叠加与自定义配色
  • ✅ 动态数据刷新与角度映射的常见陷阱

🔍 问题深度剖析:极坐标图的三个认知误区

误区一:极坐标图只是"圆形的柱状图"

很多开发者第一次接触极坐标图,觉得它不过是把柱状图弯成圆形。这个理解是错的。极坐标图的核心在于角度维度本身携带语义——0° 代表北、90° 代表东、180° 代表南、270° 代表西,这个映射关系是数据本身的物理含义,不是视觉装饰。

普通柱状图的 X 轴是有序的离散类别,而极坐标图的角度轴是循环连续的,360° 和 0° 是同一个方向。这个"循环性"决定了极坐标图在方向性数据上无可替代。

误区二:LiveCharts 2 的角度从"北"开始

这是工程实践中最常见的坑。LiveCharts 2 的极坐标系默认从 0° = 右侧(东方向)开始,逆时针增加,而气象标准是从 0° = 北方向开始,顺时针增加。

如果不做角度映射转换,直接把气象数据塞进去,北风会显示在东边,整张图的方向全部错位。后面的代码会专门处理这个映射。

误区三:风向玫瑰图只需要一个系列

标准的风向玫瑰图会按风速等级分层叠加,比如把风速分为 0~3m/s、3~6m/s、6~9m/s、>9m/s 四个等级,每个等级用不同颜色堆叠在同一扇区上。这样不仅能看出主导风向,还能看出强风集中在哪个方向——这对工厂选址、排污扩散评估至关重要。单系列的风向玫瑰图丢失了这个维度。


💡 核心要点提炼

LiveCharts 2 的极坐标图使用 PolarChart 控件,核心系列类型有两种:

  • PolarLineSeries<T>:极坐标折线/面积图,适合展示雷达图类数据
  • PolarBarSeries<T>:极坐标柱状图,适合风向玫瑰图

数据类型使用 ObservableValue 或直接用 double,数组的索引对应角度位置。

角度轴(PolarAxis)配置是关键,需要手动设置标签来对应方向名称(N/NE/E/SE/S/SW/W/NW),同时通过 InitialRotation 属性调整起始角度,让 0 索引对应北方。

PolarBarSeries 的堆叠通过 StackGroup 属性实现,同一 StackGroup 的多个系列会在同一角度位置垂直叠加,这是实现多风速等级分层的核心机制。


🛠️ 方案一:基础风向玫瑰图

场景描述

16 个方向(N、NNE、NE……每 22.5° 一个扇区),展示各方向的风频百分比。这是最基础的风向玫瑰图形态。

csharp
using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; namespace AppLiveChart18 { public partial class Form1 : Form { public Form1() { InitializeComponent(); InitWindRoseChart(); } private void InitWindRoseChart() { Text = "风向玫瑰图 - 基础版(16方向风频)"; Size = new System.Drawing.Size(700, 700); // 16个方向的风频数据(单位:%,总和约为100) // 顺序:N, NNE, NE, ENE, E, ESE, SE, SSE, // S, SSW, SW, WSW, W, WNW, NW, NNW var windFrequency = new double[] { 12.5, // N 6.3, // NNE 8.1, // NE 4.2, // ENE 5.8, // E 3.1, // ESE 4.7, // SE 5.2, // SSE 9.6, // S 7.3, // SSW 11.2, // SW 6.8, // WSW 8.4, // W 4.1, // WNW 5.9, // NW 6.8 // NNW }; var windSeries = new PolarLineSeries<double> { Values = windFrequency, Name = "风频(%)", IsClosed = true, // 闭合曲线,形成玫瑰花瓣形状 Fill = new SolidColorPaint(new SKColor(33, 150, 243, 100)), Stroke = new SolidColorPaint(new SKColor(33, 150, 243)) { StrokeThickness = 2f }, GeometrySize = 0, // 隐藏数据点圆点 LineSmoothness = 0 // 折线模式,玫瑰图不需要平滑 }; // 角度轴:16个方向标签 // ✅ InitialRotation = -90 让索引0(N)对应图表顶部(北方) var angleAxis = new PolarAxis { Labels = new[] { "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW" }, // LiveCharts 2 默认从右侧(东)开始, // 旋转 -90° 让 N 对应顶部 LabelsRotation = -90 }; // 径向轴:风频百分比 var radiusAxis = new PolarAxis { MinLimit = 0, MaxLimit = 15, Labeler = v => $"{v:F0}%" }; var chart = new PolarChart { Dock = DockStyle.Fill, Series = new ISeries[] { windSeries }, AngleAxes = new[] { angleAxis }, RadiusAxes = new[] { radiusAxis } }; Controls.Add(chart); } } }

image.png

踩坑预警

LabelsRotation = -90 这一行是关键。不加这个配置,图表的 0 索引(北风)会出现在右侧(东方向),整张图顺时针偏转 90°,方向全部错位。这个问题在调试时很难直觉发现,因为图形本身看起来"正常",只是方向标签对不上。