Angular Signals 移植到 .NET 和 C#





5.00/5 (2投票s)
介绍一个允许在 .NET MVVM 框架中使用 Angular Signals 移植版本的库。
- Github: GitHub - SignalsDotnet
- Nuget: GitHub - fedeAlterio/SignalsDotnet
理解问题
假设我们正在使用 MVVM 模式,在一个通用的基于 XAML 的 UI 框架中创建一个用户注册视图。实际上,我们需要创建一个 UserRegistrationView
和一个 UserRegistrationViewModel
。
UserRegistrationViewModel
public class UserRegistrationViewModel
{
public string Name { get; set; }
public string Surname { get; set; }
public string FullName => $"{Name} {Surname}";
}
UserRegistrationView
<StackPanel HorizontalAlignment="Stretch"
VerticalAlignment="Center">
<TextBox Text="{Binding Name, Mode=TwoWay}" />
<TextBox Text="{Binding Surname, Mode=TwoWay}" />
<TextBlock Text="{Binding FullName}"/>
</StackPanel>
上面的代码包含两个错误
- 每当用户在
Name
或Surname
TextBox
中输入内容时,Name
和Surname
属性都会正确更新,但是没有任何机制可以告知 UIFullName
属性已更改,因此绑定到它的TextBlock
将不会更新,并且将始终为空。 - 同样,如果我们通过代码设置
ViewModel
的Name
和Surname
属性,UI 也不会更新。
当然,这个问题有几种不同的解决方法。
第一种解决方案:在 Setter 中手动触发 PropertyChanged 事件
public class UserRegistrationViewModel : ViewModelBase
{
string _name;
public string Name
{
get => _name;
set
{
if(Set(ref _name, value))
RaisePropertyChanged(nameof(FullName));
}
}
string _surname;
public string Surname
{
get => _surname;
set
{
if(Set(ref _surname, value))
RaisePropertyChanged(nameof(FullName));
}
}
public string FullName => $"{Name} {Surname}";
}
这是一个经典解决方案。在 Name
属性的 setter 中,我们设置了后端字段,并为 Name
和 FullName
属性触发了 PropertyChanged
事件。
优点
- 解决了这个问题
缺点
- 增加了大量样板代码,这些代码不代表我们的业务逻辑。
Name
和Surname
属性现在引用FullName
,即使根本没有理由这样做。- 只有当我们能够修改属性的 setter 时,才能采用这种方法,如果我们依赖于其他类的属性,这可能会成为一个问题。
- 如果我们依赖于
ObservableCollection
的元素,则无法进行类似的设置。
第二种解决方案:ReactiveX & ReactiveUI
public class UserRegistrationViewModel : ViewModelBase
{
public UserRegistrationViewModel()
{
this.WhenAnyValue(@this => @this.Name, @this => @this.Surname)
.Subscribe(_ => RaisePropertyChanged(nameof(FullName)));
}
string _name;
public string Name
{
get => _name;
set => Set(ref _name, value);
}
string _surname;
public string Surname
{
get => _surname;
set => Set(ref _surname, value);
}
public string FullName => $"{Name} {Surname}";
}
优点
- 解决了问题。
Name
和Surname
属性不再依赖于FullName
。- 我们可以对属性使用所有 ReactiveX 操作符!
- 也适用于我们不拥有的属性或嵌套属性(但要注意一些边缘情况)。
- 对于
ObservableCollection
也可以采用类似的方法。
缺点
- 在构造函数中需要进行一些小的手动设置。例如,如果我们想让
FullName
属性依赖于第三个属性,我们必须记得在WhenAnyValue
中也添加它。 - 在比这更复杂的情况下,设置需要一些对反应式操作符及其所有边缘情况的非平凡知识。
注意:上述解决方案并非 ReactiveUI 推荐的方法。FullName
属性确实应该使用 Select
操作符在 WhenAnyValue
上计算,并转换为 ObservableForPropertyHelper
。这在简单情况下效果很好,但在属性依赖于 ObservableCollection
中包含的元素时可能会很混乱。在我看来,纯函数式方法在某些情况下存在其缺点。
信号
public class UserRegistrationViewModel
{
public UserRegistrationViewModel()
{
Fullname = Signal.Computed(() => $"{Name.Value} {Surname.Value}");
}
public Signal<string?> Name { get; } = new();
public Signal<string?> Surname { get; } = new();
public IReadOnlySignal<string?> Fullname { get; }
}
<StackPanel HorizontalAlignment="Stretch"
VerticalAlignment="Center">
<TextBox Text="{Binding Name.Value, Mode=TwoWay}" />
<TextBox Text="{Binding Surname.Value, Mode=TwoWay}" />
<TextBlock Text="{Binding Fullname.Value}"/>
</StackPanel>
什么是 Signal?
Signal<T>
是T
的包装器。它包含一个Value
属性,该属性返回T
的实际值。当设置Value
时,会触发PropertyChanged
事件。这是 UI 应该绑定的属性。CollectionSignal<TObservableCollection>
是一个 Signal,其Value
是一个ObservableCollection
(或者更广泛地说,实现了INotifyCollectionChanged
的东西)。它会监听Value
属性的变化,以及ObservableCollection
的修改。- 计算型
Signal
是一个只读Signal
,它返回由函数计算的值。它会自动检测函数体中访问的所有signal
的变化(也适用于CollectionSignal
),当其中一个发生变化时,函数会被重新计算,并且PropertyChanged
会为我们自动触发。Value
不能手动设置。 - 所有信号都实现
IObservable<T>
,因此我们仍然可以根据需要应用 ReactiveX 操作符。 - 计算型信号会弱引用其他信号,以避免内存泄漏。
复杂示例
考虑这种情况。我们有一些城市,城市里有一些房子,每栋房子有一些房间,每个房间里有一些人,每个人都有年龄。这里没有任何东西是不可变的,所以人可以进出房间,房子可以建造和拆除,城市可以出现和消失,人会变老,所有的集合甚至可能是 null
。
问题:在 UI 中表示最年轻的人,以及他们的城市、房子、房间和年龄。
使用 Signals 的解决方案
public class Person
{
public Signal<int> Age { get; } = new();
}
public class Room
{
public CollectionSignal<ObservableCollection<Person>> People { get; } = new();
}
public class House
{
public CollectionSignal<ObservableCollection<Room>> Roooms { get; } = new();
}
public class City
{
public CollectionSignal<ObservableCollection<House>> Houses { get; } = new();
}
public class YoungestPeopleViewModel
{
public YoungestPeopleViewModel()
{
YoungestPerson = Signal.Computed(() =>
{
var people = from city in Cities.Value.EmptyIfNull()
from house in city.Houses.Value.EmptyIfNull()
from room in house.Roooms.Value.EmptyIfNull()
from person in room.People.Value.EmptyIfNull()
select new PersonCoordinates(person, room, house, city);
var youngestPerson = people.DefaultIfEmpty()
.MinBy(x => x?.Person.Age.Value);
return youngestPerson;
});
}
public IReadOnlySignal<PersonCoordinates?> YoungestPerson { get; set; }
public CollectionSignal<ObservableCollection<City>> Cities { get; } = new();
}
public record PersonCoordinates(Person Person, Room Room, House House, City City);
它是如何工作的?
基本上,Signals
属性 Value
的 getter(不是 setter!)会触发一个 static
事件,通知有人刚刚请求了该信号。计算型信号在执行计算函数之前会使用此事件。计算型信号会注册到该事件(过滤掉其他线程的通知),并通过这种方式,它们知道在函数返回时,哪些信号刚刚被访问过。
此时,它会订阅所有这些信号的变化,以了解何时应该重新计算值。当任何信号改变时,它会重复相同的逻辑,并跟踪在计算下一个值之前访问了哪些信号(等等)。
历史
- 2023年12月27日:初始版本