三、多棵树只遍历第一棵
画布上可以有多个不相干的顶层形状(多棵独立树),但原先查父节点、加载数据时只取了第一棵树:
csharp
// 原来的写法:只查第一棵
var root = 树列表[0];
从 root 出发往下找;
第二棵、第三棵树里的形状不管怎么点都找不到父节点,加载数据时也被跳过。
改成遍历全部,找到即停:
csharp
// 修复后:挨个树查
foreach (var root in 树列表):
从 root 出发往下找;
if (找到了) break; // 找到就停,不用继续
画布找父节点、按坐标找父节点、三个页面的数据加载------一共 5 个地方,全部改成这层循环。
一句话:集合操作不能假设只有一个元素,永远遍历全部。
四、画布点击 → 右侧属性面板同步
为什么不通
链路断了三处:
① 数据流向是单向的。画布把焦点形状写到一个属性上,但这个属性和 VM 之间是单向绑定------画布能读到 VM 的值,画布改了 VM 收不到。
xml
<!-- 原来:单向,画布变了 VM 不知道 -->
画布.焦点形状 = "{Binding VM的当前焦点}"
<!-- 改后:双向 -->
画布.焦点形状 = "{Binding VM的当前焦点, Mode=双向}"
② 形状选中态变了没人通知。形状对象自己的"是否选中"属性变了,画布不知道,自然不会更新暴露的焦点属性。
csharp
// 补上订阅
当某形状的 "是否选中" 属性变化:
if (变了 且 新值是"选中"):
画布.对外焦点属性 = 这个形状;
③ 事件订阅注销无效。形状集合增删时,画布订阅了变更事件来重新绑定,但用的是匿名函数:
csharp
// 订阅时
集合.变更事件 += (s, e) => { ... }; // 匿名函数,用完即丢
// 取消订阅时
集合.变更事件 -= (s, e) => { ... }; // 这是另一个匿名函数!删不掉
改用一个类字段先把函数引用存住,订阅和取消用的是同一个对象引用,确保能正确解绑。
一句话:双向绑定 + 状态监听 + 引用正确注销,三步打通画布到面板的路。
五、点击画布 → 自动展开编辑树
用户在画布上点了某个 Component,期望左侧的树形列表自动展开到对应节点。
实现链路
画布点击
→ 焦点形状更新(用到了上面修好的双向绑定)
→ VM 当前焦点变化
→ View 层监听到变化
→ 从根出发 DFS 搜索形状在树里的路径
→ 沿路径从外到内逐层展开节点
→ 目标节点滚动到屏幕可见区
关键环节
DFS 找路径:从所有根节点出发往下递归搜,每进一层就把当前节点加入路径,找到目标返回完整路径,找不到就回溯弹出继续搜兄弟。
找路径(目标, 根节点列表):
foreach 根 in 根节点列表:
路径 = [根]
if 递归搜索(根, 目标, 路径): return 路径
return 没找到
递归搜索(当前, 目标, 路径):
if 当前就是目标: return 找到了
foreach 子节点 in 当前.子节点:
路径.加入(子节点)
if 递归搜索(子节点, 目标, 路径): return 找到了
路径.移除最后() // 回溯
return 没找到
逐层展开:拿到路径后,从树控件根容器开始,逐层强制刷新布局(等虚拟化容器生成),拿到当前层的 UI 容器 → 设置展开 → 把容器作为下一层的父容器,继续循环。
延迟执行:整个逻辑放在界面后台优先级队列里,等当前帧渲染完再跑,避免操作还没出生的 UI 容器。
一句话:找路径用 DFS + 回溯,展开用容器逐层递进,执行放后台等布局完。
六、界面小优化
- 检测区域节点选中态加蓝色高亮
- 去掉冗余的悬浮提示
- 部分控件显隐条件更严谨
总结
| 板块 | 核心思路 |
|---|---|
| 多树遍历 | 不能只取 0,遍历全部直到找到 |
| 面板同步 | 双向绑定 + 状态监听 + 引用正确注销 |
| 编辑树自动展开 | DFS 找路径 + 逐层展开容器 + 后台延迟执行 |
语法概念与编程模式总结
一、整数溢出与类型提升
语法关键词:int long 隐式转换 溢出 类型提升
三个 int 相乘,结果还是 int。超出 int.MaxValue(约 21 亿)时发生溢出,值翻转成负数或小正数。
csharp
// 危险写法:int × int × int → int,可能溢出
var result = a * b * c;
// 安全写法:先把第一个操作数转 long,后续自动按 long 算
var result = (long)a * b * c;
原理 :C# 的乘法运算符遵循"操作数类型决定结果类型"。只要参与运算的任一方是 long,整个表达式自动提升为 long 运算,不再溢出。
一句话:大数相乘前先
(long)一把,编译器自动帮你升级全场。
二、常量定义:硬编码 vs 平台常量
语法关键词:const static readonly int.MaxValue
反面案例:手算一个近似值写死:
csharp
static readonly long LIMIT = 2 * 980 * 980 * 980; // 拍脑袋算的
问题:
- 和真实上限有偏差,可能提前拦截也可能漏网
- 过一段时间连自己都忘了这个数怎么来的
正解:直接引用框架提供的常量:
csharp
static readonly long LIMIT = int.MaxValue; // 2,147,483,647
int.MaxValue 是 .NET 内置常量,它的值等于数组的最大长度上限。用平台常量而不是手算,含义清楚且不会出错。
一句话:能用
int.MaxValue/double.Epsilon这类内置常量,就别手写魔数。
三、输入联动校验:从「改了不管」到「改了全管」
语法关键词:partial 方法 属性 setter if 链 限制/回退
当一个类的两个属性互相约束(比如 A 必须小于 B,且 A 和 B 的差值不能超过 C),用户修改其中一个时,另一个和差值都要联动。
模式 :在属性的 partial 变更方法里写校验链:
属性 A 被修改:
① if (A 和 B 的关系不合法): 强制修正 A
② if (根据 A 算出来的派生值超限): 强制回退
③ 更新和 A 关联的其他字段
用 partial 方法的好处:它是源代码生成器(如 MVVM 工具包的 [ObservableProperty])自动生成的钩子,你不用写完整属性,只写变更逻辑即可。
一句话:改一个属性,带着它约束的所有属性一起算,用
partial方法做钩子,校验链写在 if-return 结构里。
四、相等比较的精度陷阱
语法关键词:float.Epsilon == Math.Abs 精度匹配
csharp
// 反例:用浮点精度去拦截整数变化
if (Math.Abs(newVal - oldVal) <= float.Epsilon) return;
两个 int 的差值最小是 1,而 float.Epsilon 是 float 能表示的最小正值(约 1.4×10⁻⁴⁵)。整数值的差值永远不可能 ≤ Epsilon,这行代码是死代码。
float.Epsilon 的正确用途是判断两个 float 是否在浮点误差范围内相等,不能用来比较 int。
| 类型 | 比较方式 |
|---|---|
int |
a == b |
float / double |
Math.Abs(a - b) < 1e-6 |
| 引用类型 | ReferenceEquals(a, b) 或 a?.Equals(b) |
一句话:int 用
==,float 用Math.Abs,别把浮点精度套在整数上。
五、事件驱动 + 保存时机
语法关键词:event EventHandler ?.Invoke 订阅/发布
当多个不相关的模块需要对同一个动作做出反应,用事件解耦:
触发方:干完活 → 发事件
接收方:订阅事件 → 收到通知 → 做自己的事
csharp
// 定义事件(发信号的一方)
public event EventHandler? 动作完成;
// 触发了就发(? 确保没订阅者时不报错)
动作完成?.Invoke(this, EventArgs.Empty);
// 接收方订阅
发送方.动作完成 += (_, _) => { 干活(); };
注意 ?.Invoke :如果没有任何订阅者,Invoke 会抛 NullReferenceException,?. 是空条件运算符------左边为 null 就不执行右边。
一句话:发方定义
event+?.Invoke,收方+=订阅,各干各的互不污染。
六、深拷贝 vs 浅拷贝
语法关键词:DeepCopy 序列化 反射 GetProperty SetValue
浅拷贝只复制引用,新旧对象指向同一块内存:
csharp
新对象.子属性 = 旧对象.子属性; // 只拷了指针,两个对象共享一个子对象
深拷贝会递归复制所有嵌套对象:
csharp
新对象.子属性 = 深拷贝(旧对象.子属性); // 全新的独立副本
实现方式之一是通过 JSON 序列化再反序列化------对象变字符串再变回来,天然断开所有引用关系。另一种是遍历属性逐个复制。
csharp
// 反射拿属性:不需要硬编码属性名,运行时动态获取
var prop = 源对象.GetType().GetProperty("属性名");
var value = prop?.GetValue(源对象);
prop?.SetValue(目标对象, value);
GetType().GetProperty() 不依赖具体类型,对任何有该属性的对象都能用。
一句话:浅拷贝 = 共享引用,深拷贝 = 完全独立。序列化/反射是两种常见实现方式。
七、集合遍历:不要假设集合只有一个元素
语法关键词:foreach [0] FirstOrDefault break
常见错误:默认取第一个元素:
csharp
var root = 列表[0]; // 索引越界风险
var root = 列表.FirstOrDefault(); // 拿到 null 风险
处理(root);
正确写法 :遍历所有,需要找到目标时用 break 提前退出:
csharp
foreach (var root in 列表):
处理(root);
找到目标就 break;
break 跳出当前循环,return 跳出整个方法,视场景选用。遍历所有元素时不需要任何退出。
一句话:永远用
foreach遍历集合,别拍脑袋取[0]。找目标用break提前停。
八、数据绑定方向:OneWay vs TwoWay
语法关键词:Binding Mode OneWay TwoWay OneTime
xml
<!-- 单向:VM → View,View 改了 VM 不更新 -->
属性="{Binding VM属性, Mode=OneWay}"
<!-- 双向:VM ↔ View,任何一端改另一端都更新 -->
属性="{Binding VM属性, Mode=TwoWay}"
| 方向 | VM→View | View→VM | 典型场景 |
|---|---|---|---|
OneWay |
✔ | ✘ | 只读展示 |
TwoWay |
✔ | ✔ | 输入框、选项、画布选中 |
OneTime |
初始化一次 | ✘ | 静态标题 |
画布选中状态的场景:用户在画布上点了一个东西,View 变了,VM 需要知道------所以必须 TwoWay。
一句话:View 能改的用
TwoWay,只读展示用OneWay。
九、属性变更通知
语法关键词:INotifyPropertyChanged PropertyChanged nameof
当一个对象的某个属性变了,需要通知别人:
csharp
// 监听端
对象.PropertyChanged += (sender, e) => {
if (e.PropertyName == "关心的属性名"):
做响应;
};
nameof 是编译时常量,比手写字符串安全------改了属性名编译直接报错:
csharp
// 推荐:改名自动报错
e.PropertyName == nameof(某类.某属性)
// 不推荐:硬编码字符串,改名不漏报运行时才崩
e.PropertyName == "某属性"
一句话:属性监听用
PropertyChanged,属性名用nameof,编译期就帮你检查。
十、事件泄漏:匿名函数的坑
语法关键词:匿名函数 lambda 闭包 += -= 引用相等
csharp
// Bug:订阅和取消是两不同的匿名函数
集合.变更事件 += (s, e) => { ... }; // 函数A
集合.变更事件 -= (s, e) => { ... }; // 函数B ≠ 函数A,删不掉
每次 (s, e) => { ... } 都会创建一个全新的委托实例。-= 用另一个实例去删,运行时比对发现不是同一个引用,删不掉。
正确做法:把引用存为字段:
csharp
private NotifyCollectionChangedEventHandler _handler;
// 存引用
_handler = (s, e) => { ... };
集合.变更事件 += _handler;
// 用同一个引用删
集合.变更事件 -= _handler;
_handler = null;
一句话:需要取消订阅时,lambda 必须用字段存引用。临时匿名导致事件泄漏。
十一、DFS 路径查找 + 回溯
语法关键词:DFS 递归 路径列表 回溯 List.Add List.RemoveAt
在树结构中找某节点到根节点的完整路径:
找路径(目标, 根列表):
foreach 根 in 根列表:
path = [根]
if 递归(根, 目标, path): return path
递归(当前节点, 目标, path):
if 当前就是目标: return true
foreach 子 in 当前.子节点:
path.Add(子) // 进入
if 递归(子, 目标, path): return true
path.RemoveAt(最后) // 回溯:此路不通,擦掉重走
return false
核心是 Add(进入)+ RemoveAt(退出)这对操作,每深入一层就加一个,走不通就删掉退回上一层换条路走。路径列表在整个递归过程中复用一个对象。
一句话:树搜索路径 = DFS 递归 + Add 进入 + RemoveAt 回溯。
十二、UI 虚拟化与延迟执行
语法关键词:Dispatcher BeginInvoke DispatcherPriority UpdateLayout ItemContainerGenerator
UI 控件(如 TreeView)为性能会虚拟化:只渲染屏幕上可见的节点。没渲染的节点拿不到 UI 容器。
操作虚拟化节点的正确姿势:
① Dispatcher.BeginInvoke(干活, 后台优先级)
→ 不阻塞当前帧渲染,等 UI 画完再干
② container.UpdateLayout()
→ 强制控件完成布局,让容器生成出来
③ container.ItemContainerGenerator.ContainerFromItem(数据对象)
→ 拿到对应的 UI 容器,如果 null 说明还没渲染
④ 对容器操作(展开、滚动等)
DispatcherPriority.Background 是最低优先级,确保当前所有 UI 操作完成后再执行。
一句话:操作虚拟化的 UI 节点:先
UpdateLayout等容器出生,再用ContainerFromItem拿引用。
总结
| 概念 | 核心语法/模式 |
|---|---|
| 防溢出 | (long) 类型提升 |
| 常量定义 | int.MaxValue 替代魔数 |
| 联动校验 | partial 方法 + if 链 |
| 相等比较 | 整数 ==,浮点 Math.Abs |
| 模块解耦 | event + ?.Invoke |
| 深拷贝 | JSON序列化 / 反射 GetProperty + SetValue |
| 遍历集合 | foreach 替代 [0] |
| 数据绑定 | TwoWay vs OneWay |
| 属性监听 | PropertyChanged + nameof |
| 防事件泄漏 | lambda 用字段存引用,+= 和 -= 同一个对象 |
| 树搜索路径 | DFS 递归 + Add + RemoveAt 回溯 |
| 虚拟化容器 | UpdateLayout + ContainerFromItem + 后台 Dispatcher |