☰
Avalonia与ReactiveUI实战:跨平台MVVM开发的核心技巧
2026/10/12 5:38:55 网站建设 项目流程

简介:这是一份面向Avalonia跨平台UI开发者的ReactiveUI+MVVM示例工程,通过可运行的Demo展示如何在ViewModel中管理可观察属性、命令和变更通知,解决传统代码后置逻辑臃肿、难以维护的问题。包内完整包含App入口、主窗口XAML、代码后置、MainViewModel与Model类,并附带ReactiveUI依赖及相关构建配置。压缩包共2000个文件,约223.6MB,主要文件类型为dll、xml、class等编译产物,同时包含cs源码、txt说明、png图标及各平台依赖库,覆盖桌面端和移动端构建场景,便于对照源码结构学习;已有1381人学习下载。Demo中使用了ReactiveObject声明可观察属性、ReactiveCommand绑定按钮等界面事件、WhenAnyValue监听多个属性变化,并演示了通过ObserveOn与SubscribeOn控制异常处理线程、结合Rx实现动态UI响应的写法。通过该Demo,开发者可以掌握在Avalonia中落地MVVM的完整流程,理解如何将业务逻辑从视图中剥离,适合正在选型MVVM框架或从传统绑定转向响应式编程的.NET开发者,也可作为团队内部培训的参考工程。 如果你最近在做.NET桌面开发,大概已经被Avalonia刷过一轮存在感——一个能用XAML写界面、却可以跑在Windows、macOS、Linux甚至浏览器里的跨平台UI框架。界面语法看着像WPF,上手门槛不高,但真正进项目之后你会发现,MVVM那套“属性通知+命令绑定”写起来仍然很啰嗦。我的解法是引入ReactiveUI:它把MVVM里的属性变化、命令异步、页面生命周期全部用数据流串起来,让ViewModel变成一条干净、可测试的数据管道。这篇主要把我用Avalonia + ReactiveUI做实际项目的完整思路和踩坑记录写下来,适合正在选型MVVM框架、或者对ReactiveUI有兴趣但不知道从哪下手的.NET开发。

1. 项目概述与整体设计思路

1.1 为什么选这套组合

先说选型。Avalonia本身不绑定任何MVVM框架,它只要求你的ViewModel实现了INotifyPropertyChanged,所以CommunityToolkit.Mvvm、Prism甚至手写通知都行。我最开始用的是CommunityToolkit,后来切到ReactiveUI,核心原因有三个。

第一,Avalonia和ReactiveUI的配合深度远超其他框架。ReactiveUI为Avalonia专门提供了一套View集成扩展,比如ReactiveUserControl 这种泛型基类,自带DataContext设置和WhenActivated生命周期钩子,不需要你像WPF时代那样写一堆适配代码。第二,ReactiveCommand天然支持async/await,还能把异步执行中的异常、并发状态、CanExecute变化全部流式暴露出来,做搜索、登录、导入这类操作时比普通ICommand舒服太多了。第三,它基于Reactive Extensions(Rx)设计,事件、命令、属性变化都能统一成IObservable,这种一致性让跨平台项目里的状态管理非常干净。

当然,代价是学习曲线确实比CommunityToolkit陡。Rx里的IObservable、Subject、Scheduler、Disposable这些概念,第一次接触的人会绕晕。我的建议是别想着一步到位,先掌握属性、命令、WhenActivated这三个点,就能覆盖大部分日常开发。

1.2 把界面理解成数据流

用ReactiveUI写MVVM,本质上要换一种思考方式:ViewModel不是一个“状态容器”,而是一条持续流动的数据管道。用户在界面上敲下的每个字符、按下的每个按钮,都是上游输入流;ViewModel对这些流做变换、过滤、合并,最后输出给界面的状态流——比如列表数据、加载中标志、错误提示。

拿我这次项目里的搜索页举例。搜索框每敲一个字,就是一个字符串流;这个流经过防抖、去重、异步调用远程接口之后,变成用户列表流;再把这个结果流绑定到ListBox上,同时把“加载中”状态流绑定到一个Loading动画。用ReactiveUI的写法,整个过程可以声明式地描述出来,比命令式写法少一半代码,而且状态变化完全可预测,不会出现某个字段忘记通知、UI不刷新的问题。

2. 环境准备与项目初始化

2.1 从模板搭建Avalonia项目

开始之前先确认环境。跑Avalonia 11需要.NET SDK 8.0或更高版本,我这边用的是.NET 8。然后安装项目模板:

dotnet new install Avalonia.Templates

我个人推荐直接用MVVM模板起步,它会顺便生成一个ViewModelBase、一个ViewLocator和一个干净的目录结构,省掉最前面那堆重复劳动:

dotnet new avalonia.mvvm -o SocialApp cd SocialApp

然后引入ReactiveUI相关包:

dotnet add package ReactiveUI dotnet add package ReactiveUI.Fody

ReactiveUI.Fody是编译时织入工具,用来把属性通知里的样板代码自动生成,属于可选项。如果不想引入Fody,就在属性setter里显式写RaiseAndSetIfChanged,后面我会演示两种写法。

模板生成的项目结构大致是这样的:

目录/文件职责
App.axaml / App.axaml.cs应用入口,负责加载全局资源、注册服务
Program.cs桌面平台启动入口
MainWindow.axaml主窗口,承载页面内容
ViewModels/存放各页面的ViewModel
Views/存放View(UserControl或Window)
ViewLocator.cs根据ViewModel类型自动匹配对应View的映射器

这里有个细节值得注意:Avalonia的XAML语法跟WPF非常像,DataContext、绑定、样式系统几乎一脉相承,所以从WPF转过来几乎没有学习成本。但Avalonia是真正的跨平台,同一个ViewModel可以跑在Windows桌面、Linux桌面,甚至通过WebAssembly跑进浏览器里,界面渲染逻辑基本不用改。

2.2 用依赖注入把ViewModel和View串起来

在ReactiveUI里,View和ViewModel的关联通常靠两个机制:一个是XAML里的DataContext绑定,另一个是ViewLocator的约定映射。实际项目中我两个都靠,但服务注册和页面导航用依赖注入统一管理,这样替换实现、写单元测试都方便。

Avalonia 11之后内部已经集成了Microsoft.Extensions.DependencyInjection,不需要额外引第三方容器。我在App.axaml.cs里这样注册:

public override void Initialize() { AvaloniaXamlLoader.Load(this); var services = new ServiceCollection(); services.AddSingleton<HttpClient>(); services.AddSingleton<UserService>(); services.AddSingleton<MainWindow>(); services.AddSingleton<MainWindowViewModel>(); services.AddSingleton<SearchViewModel>(); services.AddSingleton<IScreen>(s => s.GetRequiredService<MainWindowViewModel>()); ServiceProvider = services.BuildServiceProvider(); }

然后MainWindow的DataContext直接从容器里拿:

public MainWindow() { InitializeComponent(); DataContext = App.ServiceProvider.GetRequiredService<MainWindowViewModel>(); }

模板自带的ViewLocator长这样,它本质上是一个IDataTemplate,在运行时根据ViewModel的类型名找到对应的View:

public class ViewLocator : IDataTemplate { public Control? Build(object? data) { var name = data.GetType().FullName!.Replace("ViewModel", "View"); var type = Type.GetType(name); if (type != null) return (Control)Activator.CreateInstance(type); return new TextBlock { Text = "Not Found: " + name }; } public bool Match(object? data) => data is ViewModelBase; }

可能有人会问,为什么不直接在XAML里new一个ViewModel?原因是ReactiveUI的ViewModel通常依赖服务、调度器,导航时还需要注册路由目标,全部手工new的话,后面做依赖替换和单元测试会非常痛苦。

3. ViewModel层:ReactiveUI核心API实战

3.1 ViewModelBase与生命周期钩子

不管页面多简单,我习惯给ViewModel准备一个基类。这个基类承担了所有页面共用的能力:响应式属性基础、路由支持、生命周期激活器。

public class ViewModelBase : ReactiveObject, IRoutableViewModel { protected ViewModelBase(IScreen hostScreen) { HostScreen = hostScreen; } public ViewModelActivator Activator { get; } = new ViewModelActivator(); public IScreen HostScreen { get; } public string UrlPathSegment { get; } = Guid.NewGuid().ToString().Substring(0, 5); }

这里有两个关键点。第一,继承ReactiveObject。这是ReactiveUI里所有响应式属性的基石,它已经完整实现了INotifyPropertyChanged和INotifyPropertyChanging,不用自己写通知。第二,引入ViewModelActivator。这个对象配合WhenActivated使用,是ReactiveUI最有价值的生命周期机制。

WhenActivated和WPF里的Loaded/Unloaded类似,但它做的是“订阅管理”。你在这个方法里注册的所有订阅,都会在View销毁时自动清除,不需要手动取消。比如页面上有个轮询后台任务的Timer,你只需要在WhenActivated里启动,页面关闭后订阅自动释放,不会留下定时器泄漏的问题。这在跨平台应用里特别重要,因为不同平台对窗口销毁的时机处理差异很大。

3.2 响应式属性 + 命令 + 异步操作的完整套路

下面写一个最典型的“搜索用户列表”ViewModel,把ReactiveUI的核心API全部串进去。这个场景覆盖了响应式属性、异步命令、可观测结果、加载状态和错误处理,可以说是一个小型项目的缩影。

public class SearchViewModel : ViewModelBase { private readonly UserService _userService; private readonly ObservableAsPropertyHelper<bool> _isLoading; private readonly ObservableAsPropertyHelper<IEnumerable<UserItem>> _users; private string _searchText; public SearchViewModel(IScreen hostScreen, UserService userService) : base(hostScreen) { _userService = userService; var canSearch = this.WhenAnyValue( x => x.SearchText, text => !string.IsNullOrWhiteSpace(text)); SearchCommand = ReactiveCommand.CreateFromTask<string, IEnumerable<UserItem>>( keyword => _userService.SearchAsync(keyword), canSearch); _isLoading = SearchCommand.IsExecuting .ToProperty(this, x => x.IsLoading); _users = SearchCommand .Select(list => (IEnumerable<UserItem>)list) .ToProperty(this, x => x.Users); SearchCommand.ThrownExceptions .Subscribe(ex => InteractionMessage = ex.Message); } public string SearchText { get => _searchText; set => this.RaiseAndSetIfChanged(ref _searchText, value); } public bool IsLoading => _isLoading.Value; public IEnumerable<UserItem> Users => _users.Value; public ReactiveCommand<string, IEnumerable<UserItem>> SearchCommand { get; } public string InteractionMessage { get; private set; } }

这里拆开说几个重点。

第一,RaiseAndSetIfChanged替代了手写OnPropertyChanged。它会把值写入字段,再检查是否真的变化了,只有真正变化时才触发通知,对性能也是一种隐形的优化。如果你觉得这个写法还是啰嗦,可以用Fody的[Reactive]特性直接标在自动属性上,编译时自动生成同样的逻辑。

第二,ReactiveCommand.CreateFromTask用来创建异步命令。普通ICommand只能同步执行,而ReactiveCommand天然支持async/await,并且自动管理CanExecute。比如我传入的canSearch是个IObservable ,当搜索框是空白时按钮自动置灰,搜索框有文字时自动可用。这种联动用传统方式写,要在属性setter里手动给命令做CanExecute变更通知,非常容易漏。

第三,ThrownExceptions一定要订阅。异步命令内部抛出的异常不会直接冒泡到UI线程,而是进入ThrownExceptions流。如果你不订阅,ReactiveUI会把异常交给全局异常处理器,程序直接崩掉;订阅之后,你就能把异常转成界面提示文本,或者写进日志。我习惯每创建一个ReactiveCommand,后面立刻跟一行ThrownExceptions.Subscribe,哪怕是暂时打日志,也不能让异常裸奔。

第四,ObservableAsPropertyHelper用来把IObservable的值“投影”成普通属性。上面IsLoading就是由SearchCommand.IsExecuting这个Observable投影出来的,后面绑定到Loading动画上非常自然。它和普通属性的区别是:普通属性是“手动推值”,这个是“响应式拉值”,数据永远和上游流保持一致。

3.3 用Fody简化属性定义的取舍

如果引了ReactiveUI.Fody,上文冗长的属性定义可以压缩成一个特性:

[Reactive] public string SearchText { get; set; }

Fody在编译时会自动生成RaiseAndSetIfChanged调用的IL代码。要不要用,看团队习惯。我的个人意见是:小项目、原型验证、personal project用Fody省事,代码量少、可读性好。大型团队协作反而建议显式写RaiseAndSetIfChanged,因为断点、调用堆栈可读性更强,调试时你能清清楚楚看到每步操作触发了什么,出了问题也更容易定位。

提示:引入ReactiveUI.Fody之后,记得确认.csproj里已经包含了<PackageReference Include="ReactiveUI.Fody" PrivateAssets="all" />,并且项目里存在一个FodyWeavers.xml,里面写了<ReactiveUI />。少了这一步,特性不会生效,属性也不会发通知,查起来很隐蔽。

4. View层的绑定与交互实现

4.1 XAML绑定与类型安全绑定

ViewModel写完之后,View层决定响应式链路能不能走通。最基础的方式是XAML里直接绑定,WPF开发者都能看懂:

<StackPanel Margin="20" Spacing="16"> <TextBox Text="{Binding SearchText}" Watermark="输入用户名搜索"/> <Button Content="搜索" Command="{Binding SearchCommand}" IsEnabled="{Binding !IsLoading}" /> <ListBox Items="{Binding Users}" /> </StackPanel>

Avalonia的绑定语法跟WPF大同小异,这里还顺带用了Avalonia支持的反向布尔绑定!IsLoading,加载期间按钮自动禁用,不用单独写转换器。

但ReactiveUI更推荐另一种方式:代码后置里的类型安全绑定。两者的根本差异在于:XAML绑定是“运行时反射找路径”,写错属性名不会编译报错,而且经常是静默不刷新;类型安全绑定是“编译期表达式树”,属性名写错了直接编译失败。

public partial class SearchView : ReactiveUserControl<SearchViewModel> { public SearchView() { InitializeComponent(); this.WhenActivated(disposables => { this.Bind(ViewModel, vm => vm.SearchText, v => v.SearchTextBox.Text) .DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.SearchCommand, v => v.SearchButton) .DisposeWith(disposables); this.OneWayBind(ViewModel, vm => vm.Users, v => v.UserList.Items) .DisposeWith(disposables); }); } }

这里有个必须注意的细节:View要继承ReactiveUserControl<TViewModel>,而不是普通的UserControl。ReactiveUI会通过这个泛型基类自动设置DataContext,并且保证WhenActivated的触发时机和页面生命周期对齐。如果你忘了改成这个基类,后面所有ReactiveUI的绑定API都会失效。

我承认,把绑定逻辑写进code-behind看起来有点“传统”,但它赢在编译期检查和可重构性上。ViewModel属性改名,IDE会连View一起改,不用全局搜索{Binding xxx}排查哪里漏了。

4.2 把UI事件变成可观察流

有些交互没法靠XAML绑定搞定,比如“输入框内容变化后延迟300毫秒再搜索”。传统MVVM里你得给TextBox挂TextChanged事件,再往ViewModel塞一个方法,既不MVVM也不好测。ReactiveUI的解法是把事件变成流,然后用Rx操作符处理。

this.WhenActivated(disposables => { Observable.FromEventPattern<TextChangedEventArgs>( h => SearchTextBox.TextChanged += h, h => SearchTextBox.TextChanged -= h) .Throttle(TimeSpan.FromMilliseconds(300)) .Select(_ => SearchTextBox.Text) .Subscribe(text => ViewModel.SearchText = text) .DisposeWith(disposables); });

这段代码做了三件事:把TextChanged事件转成IObservable流,用Throttle实现防抖(用户连续输入时只在停顿后触发一次),再把最终文本推给ViewModel.SearchText。整个订阅生命周期受WhenActivated管控,页面销毁自动取消订阅,不会产生事件泄漏。

这种“事件转流”的方式特别适合高频交互,比如实时搜索、滚动加载、鼠标位置追踪。Rx操作符本身就是为这类场景设计的,Debounce、Throttle、Switch、DistinctUntilChanged用起来非常顺手。

5. 常见问题与调试技巧实录

5.1 绑定了却不刷新,先查WhenActivated有没有生效

新手最容易踩的坑是:XAML里绑定了属性,逻辑也写对了,但界面就是不更新。这个问题的排查顺序我一般是固定的:

现象可能原因排查方式
界面打开后数据是空的View没有继承ReactiveUserControl 或 IViewFor检查View基类
属性更新了但界面不跟着变setter没有走RaiseAndSetIfChanged,或Fody没生效断点看属性setter
WhenActivated里的绑定没执行DataContext不是ViewModel,或该View没被ViewLocator识别在WhenActivated里断点
页面关闭后后台还在跑任务订阅没有用DisposeWith挂到disposables检查WhenActivated返回前是否都断开了

这三个条件缺一个,ReactiveUI的链路就会静默失效。遇到界面不刷新,先别怀疑Avalonia,按这个顺序查,大概率两分钟解决。

5.2 ThrownExceptions不订阅,程序直接崩

这是ReactiveUI项目里最常见的线上事故来源。异步命令里抛异常,你以为try/catch能接住,但ReactiveCommand会把异常直接抛给Rx的异常管道,不进调用栈。如果不订阅ThrownExceptions,它就会被转到全局异常处理器,程序直接退出。

我的习惯是每条命令创建完立刻加订阅:

command.ThrownExceptions .Subscribe(ex => Log.Error(ex, "Command failed"));

哪怕暂时不处理用户提示,也先订阅上记录日志,避免“莫名其妙崩溃”的尴尬。等产品需要提示反馈了,再在里面加InteractionMessage赋值或者弹窗逻辑。

5.3 集合更新用SourceList而不是ObservableCollection

如果你需要在列表里频繁增删改,ReactiveUI官方推荐用SourceList配合DynamicData,而不是ObservableCollection。ObservableCollection有两个问题:一是后台线程修改集合会直接抛异常,二是它只能做“加了/删了”的通知,做不了筛选、排序、去重。

我现在的搜索列表就是通过SourceList + Connect() + Filter() + Sort()链出来的:

var _usersSource = new SourceList<UserItem>(); _usersSource.Connect() .Filter(filterPredicate) .Sort(SortExpressionComparer<UserItem>.Descending(u => u.CreatedAt)) .Bind(out var users) .Subscribe(); _usersSource.Edit(list => { list.Clear(); list.AddRange(result); });

这套组合来处理后台数据的增删改查非常顺手。即使数据量到上万条,界面依然能保持流畅,因为DynamicData内部做了批量通知优化。如果只是搜索这种一次性替换结果,用3.2里的IEnumerable绑定就够了,SourceList适合列表持续变化、需要筛选排序的场景。

5.4 单元测试里的调度器替换技巧

ReactiveUI的代码高度依赖调度器,尤其是RxApp.MainThreadScheduler。如果不处理,单测里涉及异步命令、Timer的代码根本跑不稳。我惯用的方式是在测试初始化里替换调度器:

[Fact] public async Task SearchCommand_Should_Populate_Users() { RxApp.MainThreadScheduler = Scheduler.Immediate; var vm = new SearchViewModel( new TestHostScreen(), new FakeUserService()); await vm.SearchCommand.Execute("Alice"); Assert.NotEmpty(vm.Users); }

RxApp.MainThreadScheduler = Scheduler.Immediate这行是关键,它把所有调度任务改成同步执行,测试代码就不再受线程调度影响。更复杂的场景可以用TestScheduler配合AdvanceBy精确控制时间,但日常业务测试用Scheduler.Immediate已经能解决九成问题。

结尾

如果你准备在下一个Avalonia项目里试着上ReactiveUI,我的建议是别急着把整套Rx都学完。先抓住三件事:属性用RaiseAndSetIfChanged,命令用ReactiveCommand.CreateFromTask,订阅放在WhenActivated里。这三个点用顺之后,再接触ObservableAsPropertyHelper、WhenAnyValue、SourceList这些进阶工具,你会突然发现它们都是在解决你写代码时迟早会撞上的真实痛点。

我自己用这套组合一年多的最大体会是,跨平台项目最怕的不是界面差异,而是状态管理失控——页面关了订阅还在跑、异步命令重复点击、列表数据变来变去没人知道谁改的。ReactiveUI把这些都拉到一条明确的管道里,该释放的释放,该取消的取消,该更新的更新。如果你肯花一周时间跨过Rx的概念门槛,后面省下来的调试时间会远超这个投入。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询