7.29:树形;

三、多棵树只遍历第一棵

画布上可以有多个不相干的顶层形状(多棵独立树),但原先查父节点、加载数据时只取了第一棵树:

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.Epsilonfloat 能表示的最小正值(约 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
相关推荐
cm04Z9c912 小时前
C#摸鱼实录——IoC与DI案例详解
开发语言·c#
geovindu7 小时前
CSharp:Chain of Responsibility Pattern
开发语言·后端·设计模式·c#·.net·责任链模式·行为模式
颜x小9 小时前
[C#]泛型类与泛型方法
开发语言·c++·c#
caishenzhibiao10 小时前
顺势交易矩阵主图 同花顺期货通指标
java·c语言·c#
TDengine (老段)10 小时前
TDengine Node.js 与 C# 连接器 — Web 服务与 .NET 集成
大数据·数据库·node.js·c#·.net·时序数据库·tdengine
河西石头11 小时前
再谈C#的抽象类和接口:从“适配器”到“毛坯房”的设计哲学
开发语言·c#·接口·依赖注入·抽象类·依赖倒挂
颜x小11 小时前
[C#]——接口与继承
开发语言·c++·c#
颜x小1 天前
[C#] C++与c#语法对比
开发语言·c++·c#
weixin_423652131 天前
C#使用JObject
开发语言·c#