Avalonia与ReactiveUI实战:跨平台MVVM开发的核心技巧 简介这是一份面向Avalonia跨平台UI开发者的ReactiveUIMVVM示例工程通过可运行的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 ExtensionsRx设计事件、命令、属性变化都能统一成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.FodyReactiveUI.Fody是编译时织入工具用来把属性通知里的样板代码自动生成属于可选项。如果不想引入Fody就在属性setter里显式写RaiseAndSetIfChanged后面我会演示两种写法。模板生成的项目结构大致是这样的目录/文件职责App.axaml / App.axaml.cs应用入口负责加载全局资源、注册服务Program.cs桌面平台启动入口MainWindow.axaml主窗口承载页面内容ViewModels/存放各页面的ViewModelViews/存放ViewUserControl或WindowViewLocator.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.AddSingletonHttpClient(); services.AddSingletonUserService(); services.AddSingletonMainWindow(); services.AddSingletonMainWindowViewModel(); services.AddSingletonSearchViewModel(); services.AddSingletonIScreen(s s.GetRequiredServiceMainWindowViewModel()); ServiceProvider services.BuildServiceProvider(); }然后MainWindow的DataContext直接从容器里拿public MainWindow() { InitializeComponent(); DataContext App.ServiceProvider.GetRequiredServiceMainWindowViewModel(); }模板自带的ViewLocator长这样它本质上是一个IDataTemplate在运行时根据ViewModel的类型名找到对应的Viewpublic 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 ObservableAsPropertyHelperbool _isLoading; private readonly ObservableAsPropertyHelperIEnumerableUserItem _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.CreateFromTaskstring, IEnumerableUserItem( keyword _userService.SearchAsync(keyword), canSearch); _isLoading SearchCommand.IsExecuting .ToProperty(this, x x.IsLoading); _users SearchCommand .Select(list (IEnumerableUserItem)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 IEnumerableUserItem Users _users.Value; public ReactiveCommandstring, IEnumerableUserItem 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 IncludeReactiveUI.Fody PrivateAssetsall /并且项目里存在一个FodyWeavers.xml里面写了ReactiveUI /。少了这一步特性不会生效属性也不会发通知查起来很隐蔽。4. View层的绑定与交互实现4.1 XAML绑定与类型安全绑定ViewModel写完之后View层决定响应式链路能不能走通。最基础的方式是XAML里直接绑定WPF开发者都能看懂StackPanel Margin20 Spacing16 TextBox Text{Binding SearchText} Watermark输入用户名搜索/ Button Content搜索 Command{Binding SearchCommand} IsEnabled{Binding !IsLoading} / ListBox Items{Binding Users} / /StackPanelAvalonia的绑定语法跟WPF大同小异这里还顺带用了Avalonia支持的反向布尔绑定!IsLoading加载期间按钮自动禁用不用单独写转换器。但ReactiveUI更推荐另一种方式代码后置里的类型安全绑定。两者的根本差异在于XAML绑定是“运行时反射找路径”写错属性名不会编译报错而且经常是静默不刷新类型安全绑定是“编译期表达式树”属性名写错了直接编译失败。public partial class SearchView : ReactiveUserControlSearchViewModel { 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要继承ReactiveUserControlTViewModel而不是普通的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.FromEventPatternTextChangedEventArgs( 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没生效断点看属性setterWhenActivated里的绑定没执行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 SourceListUserItem(); _usersSource.Connect() .Filter(filterPredicate) .Sort(SortExpressionComparerUserItem.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的概念门槛后面省下来的调试时间会远超这个投入。本文还有配套的精品资源点击获取