PAYATHON 2026

如何处理关联类中的 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 负责订阅关系和缓存更新

各对象的职责可以这样划分:

  1. Manager、Player 只报告自身属性的变化。
  2. Team 管理子对象的订阅与退订。
  3. 所有影响总成本的修改都调用同一个重算方法。
  4. 总成本更新后,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 会进行整数除法,需要确认这是否符合业务要求。

备注:内容仅供参考。