如何处理关联类中的 PropertyChanged 事件?
让 Team 用实例方法订阅子对象的 PropertyChanged 事件即可,无须给每个 Manager、Player 添加指向 Team 的属性,也不必引入 Mediator。
当前代码的问题在于回调声明成了 static。C# 的事件处理方法可以是实例方法。订阅时,委托会保存目标对象,因此回调中的 this 就是订阅事件的那支球队。
先去掉事件处理方法上的 static
只需先修改事件处理方法:
private void ProcessChildPropertyChanged(
object? sender,
PropertyChangedEventArgs e)
{
if (string.IsNullOrEmpty(e.PropertyName) ||
e.PropertyName == nameof(Manager.Salary))
{
RecalculateCosts();
}
}
在 Team 的实例方法中订阅:
_manager.PropertyChanged += ProcessChildPropertyChanged;
这会把当前 Team 实例及其处理方法一起交给事件发布者。事件触发后:
sender是发生变化的Manager。this是订阅事件的Team。e.PropertyName表示发生变化的属性。
回调可以直接访问所属球队,无须再根据 sender 查找。如果同一个经理被多个球队订阅,各球队的实例处理方法都会分别执行。
这里应使用 PropertyChangedEventArgs 来检查具体属性。当 PropertyName 为 null 或空字符串时,按“所有属性均发生变化”处理。
让 Team 负责订阅关系和缓存更新
各对象的职责可以这样划分:
Manager、Player只报告自身属性的变化。Team管理子对象的订阅与退订。- 所有影响总成本的修改都调用同一个重算方法。
- 总成本更新后,
Team发出自己的PropertyChanged通知。
INotifyPropertyChanged 只约定一个通知事件,不会计算属性,也不会自动向父对象传播变化。这些依赖关系需要由代码维护。
下面的实现会在变更后立即重算,使用 decimal 表示金额,并允许经理暂时为空。球员集合由 Team 控制修改,避免调用方绕过订阅逻辑。
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged(
[CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(
this,
new PropertyChangedEventArgs(propertyName));
}
}
public abstract class SalariedPerson : ObservableObject
{
private decimal _salary;
public decimal Salary
{
get => _salary;
set
{
if (_salary == value)
return;
_salary = value;
OnPropertyChanged();
}
}
}
public sealed class Manager : SalariedPerson
{
}
public sealed class Player : SalariedPerson
{
}
public sealed class Team : ObservableObject
{
private decimal _costs;
private decimal _totalOperatingCost;
private Manager? _manager;
private readonly List<Player> _players = new List<Player>();
public Team()
{
Players = _players.AsReadOnly();
}
public ReadOnlyCollection<Player> Players { get; }
public decimal Costs
{
get => _costs;
set
{
if (_costs == value)
return;
_costs = value;
RecalculateCosts();
OnPropertyChanged();
}
}
public Manager? Manager
{
get => _manager;
set
{
if (ReferenceEquals(_manager, value))
return;
if (_manager != null)
_manager.PropertyChanged -= ChildPropertyChanged;
_manager = value;
if (_manager != null)
_manager.PropertyChanged += ChildPropertyChanged;
RecalculateCosts();
OnPropertyChanged();
}
}
public decimal TotalOperatingCost => _totalOperatingCost;
public bool AddPlayer(Player player)
{
if (player == null)
throw new ArgumentNullException(nameof(player));
if (_players.Contains(player))
return false;
_players.Add(player);
player.PropertyChanged += ChildPropertyChanged;
RecalculateCosts();
return true;
}
public bool RemovePlayer(Player player)
{
if (!_players.Remove(player))
return false;
player.PropertyChanged -= ChildPropertyChanged;
RecalculateCosts();
return true;
}
public void ClearPlayers()
{
if (_players.Count == 0)
return;
foreach (Player player in _players)
player.PropertyChanged -= ChildPropertyChanged;
_players.Clear();
RecalculateCosts();
}
private void ChildPropertyChanged(
object? sender,
PropertyChangedEventArgs e)
{
if (string.IsNullOrEmpty(e.PropertyName) ||
e.PropertyName == nameof(SalariedPerson.Salary))
{
RecalculateCosts();
}
}
private void RecalculateCosts()
{
decimal total =
_costs +
(_manager?.Salary ?? 0m) +
_players.Sum(player => player.Salary);
if (_totalOperatingCost == total)
return;
_totalOperatingCost = total;
OnPropertyChanged(nameof(TotalOperatingCost));
}
}
调用方式:
var manager = new Manager { Salary = 120_000m };
var player = new Player { Salary = 60_000m };
var team = new Team
{
Costs = 20_000m,
Manager = manager
};
team.AddPlayer(player);
Console.WriteLine(team.TotalOperatingCost); // 200000
manager.Salary = 132_000m;
Console.WriteLine(team.TotalOperatingCost); // 212000
team.RemovePlayer(player);
Console.WriteLine(team.TotalOperatingCost); // 152000
更换经理时,这段代码会退订旧经理;移除或清空球员时,也会退订相应球员。经理赋值统一经过 Manager 的 setter,构造函数中不再重复订阅。
代码假定模型只在单一线程上访问,且通知处理程序不会重入修改同一个模型。并发和批量更新需要另行设计。
集合变化与元素变化要分别处理
INotifyCollectionChanged 和 INotifyPropertyChanged 分别处理集合结构和元素属性的变化:
| 变化 | 通知来源 |
|---|---|
| 增加、删除、替换球员 | 集合的 CollectionChanged |
| 现有球员修改薪水 | 球员的 PropertyChanged |
ObservableCollection<Player> 会通知集合结构变化,但不会自动把 Player.Salary 的变化转发为集合通知。
如果需要让外部直接调用 Players.Add()、Players.Remove(),可以使用 ObservableCollection<Player>,由 Team 的实例方法处理 CollectionChanged:
- 新元素加入时,订阅其
PropertyChanged。 - 旧元素移除时,取消订阅。
- 替换元素时,同时处理旧元素和新元素。
- 集合结构变化后,重算总成本。
其中,清空集合和重复元素需要额外处理。
Clear() 通常产生 Reset 通知,不能依赖 OldItems 获得所有旧元素。可以另外记录已订阅的对象,在重置时退订,再根据当前集合重建订阅。
ObservableCollection<T> 允许重复元素。如果同一个 Player 实例出现多次,需要明确重复项的业务含义,并使用订阅计数,或者禁止重复加入。
前面的代码封装了普通 List<Player>,通过 AddPlayer、RemovePlayer 和 ClearPlayers 维护这些规则,无须继承集合类型。不过,ReadOnlyCollection<Player> 不提供集合增删通知。如果需要 WPF 列表自动同步,可以在内部使用 ObservableCollection<Player>,向外提供 ReadOnlyObservableCollection<Player>,并继续集中管理修改入口。
普通 List<T>、HashSet<T> 没有变更通知。若允许外部任意修改这些可变集合,就无法仅靠事件可靠地获知每次变化。因此,使用缓存时需要明确规定集合的修改入口。
计算昂贵时,可以先让缓存失效
上面的实现每次收到相关变更都会立即求和。连续修改大量球员的薪水时,可能反复遍历整个集合。
如果派生计算开销较大,可以改用脏标记:
private bool _costsDirty = true;
private decimal _cachedTotal;
private void InvalidateCosts()
{
_costsDirty = true;
OnPropertyChanged(nameof(TotalOperatingCost));
}
public decimal TotalOperatingCost
{
get
{
if (_costsDirty)
{
decimal total =
_costs +
(_manager?.Salary ?? 0m) +
_players.Sum(player => player.Salary);
_cachedTotal = total;
_costsDirty = false;
}
return _cachedTotal;
}
}
将各修改入口中的重算调用换成 InvalidateCosts(),就能把计算推迟到读取属性时。此时的通知表示“旧缓存已失效,请重新读取”。
如果 WPF 绑定每次收到通知后都立即读取属性,仍可能每次都触发重算。对于批量导入或密集修改,更适合设置批处理边界,在一批修改完成后统一使缓存失效、执行计算并发出通知。
单纯求和也可以通过维护薪水差额来增量更新。不过,PropertyChangedEventArgs 不包含旧值。要计算差额,需要保存上次薪水,或使用携带旧值和新值的专用事件。复杂数学模型是否适合增量计算,则取决于实际的依赖关系。
事件订阅也有引用,需要管理生命周期
即使子对象没有显式的 Team 属性,普通实例事件订阅仍会形成引用:
Manager → 事件委托 → Team
.NET 的垃圾回收器能够回收不可达的循环引用,父子对象之间存在引用环本身不会造成内存泄漏。
但如果其他长期存活的对象持有 Manager 或 Player,其事件订阅可能让 Team 继续存活。
除了在替换、删除时退订,球队停止使用而子对象仍可能存活时,也应统一解除订阅。可以通过 IDisposable 或明确的释放方法完成,调用方需要实际执行释放操作。
对于某些生命周期难以协调的订阅,可以使用 WPF 的弱事件机制。这是 WPF 层面的方案,集合增删时仍需维护订阅关系。
何时需要其他设计
当前模型使用实例事件订阅已经足够。规模扩大后,可以根据修改方式选择其他方案:
- 如果所有变更都经过
Team或应用服务,可以由这些入口直接更新缓存或使其失效,减少逐个对象订阅。 - 如果需要跨模块通知,可以考虑 Mediator 或事件总线。订阅生命周期和计算依赖仍需维护;Mediator 本身不要求自定义集合。
- 如果大量派生值形成复杂依赖图,可以集中管理依赖并批量计算,避免每次属性变化都沿事件链反复重算。
评估性能时,需要区分事件调用和计算本身的开销。普通事件通常在引发事件的线程上同步调用处理程序,不会自动开启多线程。单独测得的事件调用速度,也不能代表包含集合遍历、数学计算和界面更新后的性能。
如果要并行计算,应先明确如何保证输入一致。例如,可以对不可变快照进行计算,并在提交结果前检查版本,防止较早的计算覆盖较新的结果。
MonthlySalary 这类属性即使采用只读计算形式,绑定界面也不会自动知道它的依赖关系。修改 Salary 后,还应发出 MonthlySalary 的变更通知。若继续使用整数类型,Salary / 12 会进行整数除法,需要确认这是否符合业务要求。
备注:内容仅供参考。
