Avalonia 制作复杂布局动画

Avalonia 制作复杂布局动画
简单了解 Avalonia 的动画系统Avalonia 提供三种类型的动画类型描述用例关键帧动画使用多个关键帧在时间轴上改变一个或多个属性。由样式选择器触发的复杂、多步骤动画。控件过渡在属性值变化时对单个属性进行动画处理。为属性变化不透明度、颜色、大小提供平滑的视觉反馈。组合动画在渲染线程上运行的代码驱动动画。从 C# 控制的高性能或程序化动画。此外页面过渡 还会在控件如TransitioningContentControl和Carousel中切换内容时产生动画。更详细的内容不再赘述如不了解请直接查阅 Avalonia 在线文档。二、布局动画的困局二.1 布局动画六要素一个控件在画布上的位置和尺寸由四个角的坐标与宽高决定即Width、Height、Canvas.Left、Canvas.Top、Canvas.Right、Canvas.Bottom。可能你会想Grid.Row、DockPanel.Dock等属性不也能起到类似作用吗为什么没有把它们包含在内——因为它们对动画的支持非常有限几乎无法用来实现平滑的布局动画。二.2 布局系统简述创建控件时并不强制要求提供上述“布局动画六要素”。例如在Grid、DockPanel、StackPanel等布局容器中你只需要提供一些布局相关的附加信息容器便会自行计算最终的布局结果。这些计算结果最终都会反映到控件的Bounds属性上而不是直接反映在Width、Height、Canvas.Left等属性上。二.3 困局最直观的想法往往是直接对Width、Height使用控件过渡就可以实现动画了。然而一旦真正尝试就会发现效果并不理想。很多人可能就卡在这一步连如何控制Canvas.Left、Canvas.Top、Canvas.Right、Canvas.Bottom都还没来得及思考。正如布局系统简述所说布局动画六要素并非在所有情况下都是确定的。从 C# 代码中创建动画固然是一个解决方案但这会失去.axaml文件的灵活性——只要对.axaml的改动稍大一些就必须同步修改创建动画的 C# 代码。同时Avalonia 框架动画系统默认提供的各种工具很可能也需要你重新实现一遍。这些因素使布局动画的开发成本和门槛都被抬高到了一个不合理的高度。三、布局参考系Layout Reference Frame三.1 概念为动画目标引入一个参考对象和动画目标容器。动画目标容器与动画目标的布局动画六要素可以从参考对象获取或由同级的动画目标容器结合参考对象计算得出。其结构如下根容器参考对象动画目标容器动画目标下图是一张简略的导图三.2 设计思路三.2.1 解构既然我们的目的是获取布局动画六要素那就可以先解构原先惯用的布局设计思路。常见的.axaml文件通常利用各种布局控件来控制布局例如UserControl xmlnshttps://github.com/avaloniaui xmlns:xhttp://schemas.microsoft.com/winfx/2006/xaml xmlns:dhttp://schemas.microsoft.com/expression/blend/2008 xmlns:mchttp://schemas.openxmlformats.org/markup-compatibility/2006 mc:Ignorabled d:DesignWidth800 d:DesignHeight450 x:ClassWCKYWCKF.Avalonia.Extension.Sample.Views.UserControl1 Grid RowDefinitionsAuto,* ColumnDefinitions*,* DockPanel Grid.Row0 Grid.Column0 Grid.ColumnSpan2 Button DockPanel.DockRight/Button TextBlock DockPanel.DockLeft TextWCKYWCKF.Avalonia.Extension.Sample.Views.UserControl1/TextBlock /DockPanel TextBox Grid.Row1 Grid.Column0 HorizontalAlignmentStretch VerticalAlignmentStretch/TextBox Image Grid.Row1 Grid.Column1 Source../Assets/启动时背景图.png/Image /Grid /UserControl这种设计可以拆解为两部分布局数据与业务内容。业务内容依赖布局数据来呈现。基于这种解构我们可以把上面的代码重新设计成如下形式UserControl xmlnshttps://github.com/avaloniaui xmlns:xhttp://schemas.microsoft.com/winfx/2006/xaml xmlns:dhttp://schemas.microsoft.com/expression/blend/2008 xmlns:mchttp://schemas.openxmlformats.org/markup-compatibility/2006 mc:Ignorabled d:DesignWidth800 d:DesignHeight450 x:ClassWCKYWCKF.Avalonia.Extension.Sample.Views.UserControl2 Panel Grid RowDefinitionsAuto,* ColumnDefinitions*,* IsHitTestVisibleFalse Grid.Styles Style SelectorControl Setter PropertyIsHitTestVisible ValueFalse/Setter /Style /Grid.Styles DockPanel NameLRF_DockPanel Grid.Row0 Grid.Column0 Grid.ColumnSpan2 Control NameLRF_Button DockPanel.DockRight Width{Binding ElementNameButtonLayer,PathBounds.Width} Height{Binding ElementNameButtonLayer,PathBounds.Height} /Control Control NameLRF_TextBlock DockPanel.DockLeft/Control /DockPanel Control NameLRF_TextBox Grid.Row1 Grid.Column0/Control Control NameLRF_Image Grid.Row1 Grid.Column1/Control /Grid Canvas ZIndex10 Canvas Canvas.Left{Binding ElementNameLRF_DockPanel,PathBounds.Left} Canvas.Top{Binding ElementNameLRF_DockPanel,PathBounds.Top} Width{Binding ElementNameLRF_DockPanel,PathBounds.Width} Button NameButtonLayer Canvas.Left{Binding ElementNameLRF_Button,PathBounds.Left} Canvas.Top{Binding ElementNameLRF_Button,PathBounds.Top} /Button TextBlock NameTextBlockLayer Canvas.Left{Binding ElementNameLRF_TextBlock,PathBounds.Left} Canvas.Top{Binding ElementNameLRF_TextBlock,PathBounds.Top} Width{Binding ElementNameLRF_TextBlock,PathBounds.Width} TextWCKYWCKF.Avalonia.Extension.Sample.Views.UserControl2 /TextBlock /Canvas TextBox Canvas.Left{Binding ElementNameLRF_TextBox,PathBounds.Left} Canvas.Top{Binding ElementNameLRF_TextBox,PathBounds.Top} Width{Binding ElementNameLRF_TextBox,PathBounds.Width} Height{Binding ElementNameLRF_TextBox,PathBounds.Height} /TextBox Image Canvas.Left{Binding ElementNameLRF_Image,PathBounds.Left} Canvas.Top{Binding ElementNameLRF_Image,PathBounds.Top} Width{Binding ElementNameLRF_Image,PathBounds.Width} Height{Binding ElementNameLRF_Image,PathBounds.Height} Source../Assets/启动时背景图.png /Image /Canvas /Panel /UserControl这两段代码在布局呈现上效果完全相同但重写之后我们获得了明确的布局动画六要素。三.2.2 动画目标容器出于性能考虑以及减少布局动画对文字排版等方面的负面影响建议引入一个中间层 —— 动画目标容器。最佳实践是动画通常直接作用在动画目标容器上而不是动画目标本身。可以启用动画目标容器的ClipToBoundsTrue当容器的宽高发生变化时会对动画目标产生遮罩裁剪的效果以此来实现流畅的动画。除非设计上确实需要否则不要将动画直接作用在动画目标上。如果必须这么做请至少权衡以下几点如果动画目标内部包含大量子控件布局计算的耗时可能会影响动画流畅度严重时还会因 UI 线程阻塞导致应用卡顿。不过从 Avalonia V12 开始渲染性能有指数级提升这种瓶颈通常很难遇到。文字排版控件在宽高不足时可能发生换行目前还没有特别高性能的文字动画方案。一般情况下这种换行会影响动画的最终效果。如果动画直接作用在动画目标上动画目标容器就变得非必要了。三.2.3 动画目标出于性能以及动画对文字布局的影响宽高更新的发起者通常不应该是动画执行器本身除非设计上有特殊要求。三.2.4 参考目标参考目标只负责提供布局动画六要素不参与输入事件或命中测试更不应将业务内容放置在参考目标中。参考关系不一定总是动画目标向参考目标单向参考比如上面解构中的示例。参考关系应根据实际提供布局动画六要素的主体灵活调整。四、破局引入布局参考系后我们解决了 Avalonia 布局动画的最大障碍获得了布局动画六要素。从 0 到 1 的跨越已经完成接下来就是释放开发者创造力的时刻了。且让笔者先来当一回排头兵。接下来的 Demo 中我将尽量用简练的语言讲解设计思路。四.1 什么样的 Demo 一个带侧边栏和主内容的应用要求如下侧边栏可以被隐藏隐藏时要有动画过渡。主内容在侧边栏隐藏后需要占据原先侧边栏的区域且要有动画过渡。四.2 代码Window xmlnshttps://github.com/avaloniaui xmlns:xhttp://schemas.microsoft.com/winfx/2006/xaml xmlns:vmusing:WCKYWCKF.Avalonia.Extension.Sample.ViewModels xmlns:dhttp://schemas.microsoft.com/expression/blend/2008 xmlns:mchttp://schemas.openxmlformats.org/markup-compatibility/2006 xmlns:viewsclr-namespace:WCKYWCKF.Avalonia.Extension.Sample.Views xmlns:uhttps://irihi.tech/ursa xmlns:iconparkhttps://irihi.tech/iconica/iconpark mc:Ignorabled d:DesignWidth1200 d:DesignHeight450 x:ClassWCKYWCKF.Avalonia.Extension.Sample.Views.MainWindow x:DataTypevm:MainWindowViewModel TitleWCKYWCKF.Avalonia.Extension.Sample WindowStartupLocationCenterScreen Background{DynamicResource HomeBrush} Window.Styles Style SelectorCanvas#SidebarLayer1.IsOpen Setter PropertyOpacity Value1.0 / Setter PropertyWidth Binding PathBounds.Width ElementNameLRF_Sidebar / /Setter Setter PropertyHeight Binding PathBounds.Height ElementNameLRF_Sidebar / /Setter Setter PropertyIsVisible ValueTrue / /Style Style SelectorCanvas#SidebarLayer1:not(.IsOpen) Setter PropertyOpacity Value0.0 / Setter PropertyWidth Value0.0 / Setter PropertyHeight Value0.0 / Style.Animations Animation Duration0:0:0.8 KeyFrame Cue0% Setter PropertyIsVisible ValueTrue / /KeyFrame KeyFrame Cue100% Setter PropertyIsVisible ValueFalse / /KeyFrame /Animation /Style.Animations Setter PropertyIsVisible ValueFalse / /Style /Window.Styles Panel Grid ColumnDefinitions3*,7* Control NameLRF_Sidebar Grid.Column0 / Control NameLRF_ArticleView Grid.Column1 / /Grid views:StartPage ZIndex10 / Canvas HorizontalAlignmentStretch VerticalAlignmentStretch Canvas NameSidebarLayer1 HorizontalAlignmentLeft VerticalAlignmentTop ClipToBoundsTrue Classes.IsOpen{Binding #ToggleSwitch.IsChecked} Canvas.Transitions Transitions DoubleTransition Property{x:Static Canvas.OpacityProperty} Duration0:0:0.8 EasingCubicEaseInOut / DoubleTransition Property{x:Static Canvas.WidthProperty} Duration0:0:0.8 EasingCubicEaseInOut / DoubleTransition Property{x:Static Canvas.HeightProperty} Duration0:0:0.8 EasingCubicEaseInOut / /Transitions /Canvas.Transitions Border NameSidebarLayer2 Canvas.Left0 Canvas.Top0 ClassesShadow Theme{DynamicResource CardBorder} HorizontalAlignmentLeft VerticalAlignmentStretch Border.Width MultiBinding Converter{x:Static views:AnimationValueConverter.GetWidthByMargin} Binding PathBounds.Width ElementNameLRF_Sidebar / Binding PathMargin ElementNameSidebarLayer2 / /MultiBinding /Border.Width Border.Height MultiBinding Converter{x:Static views:AnimationValueConverter.GetHeightByMargin} Binding PathBounds.Height ElementNameLRF_Sidebar / Binding PathMargin ElementNameSidebarLayer2 / /MultiBinding /Border.Height u:NavMenu HorizontalAlignmentStretch u:NavMenu.Header StackPanel VerticalAlignmentStretch StackPanel.Styles Style SelectorTextBlock Setter PropertyTheme Value{DynamicResource TitleTextBlock} / Setter PropertyTextWrapping ValueWrap / Setter PropertyHorizontalAlignment ValueCenter / /Style /StackPanel.Styles TextBlock ClassesH1 TextWCKYWCKF / TextBlock ClassesH5 TextAvalonia.Extension.Sample / /StackPanel /u:NavMenu.Header /u:NavMenu /Border /Canvas ToggleSwitch NameToggleSwitch IsCheckedTrue ZIndex10 Margin{Binding #SidebarLayer2.Margin} CornerRadius{Binding #SidebarLayer2.CornerRadius} Theme{DynamicResource ButtonToggleSwitch} VerticalAlignmentTop HorizontalAlignmentLeft VerticalContentAlignmentCenter HorizontalContentAlignmentCenter ToggleSwitch.OnContent iconpark:MenuFoldOne / /ToggleSwitch.OnContent ToggleSwitch.OffContent iconpark:MenuUnfoldOne / /ToggleSwitch.OffContent /ToggleSwitch Canvas NameArticleViewLayer ClipToBoundsTrue Canvas.Left{Binding ElementNameSidebarLayer1,PathWidth} Canvas.Transitions Transitions DoubleTransition Property{x:Static Canvas.HeightProperty} Duration0:0:0.8 EasingCubicEaseInOut / /Transitions /Canvas.Transitions Canvas.Width MultiBinding Converter{x:Static views:AnimationValueConverter.GetWidthSum} MultiBinding Converter{x:Static views:AnimationValueConverter.GetWidthNegate} Binding PathBounds.Width ElementNameLRF_Sidebar / Binding PathWidth ElementNameSidebarLayer1 / /MultiBinding Binding PathBounds.Width ElementNameLRF_ArticleView / /MultiBinding /Canvas.Width Canvas.Height Binding PathBounds.Height ElementNameLRF_ArticleView / /Canvas.Height Border NameArticleViewLayer2 ClassesShadow Theme{DynamicResource CardBorder} Classes.IsOpen{Binding #ToggleSwitch.IsChecked} Border.Width MultiBinding Converter{x:Static views:AnimationValueConverter.GetWidthByMargin} MultiBinding Converter{x:Static views:AnimationValueConverter.GetWidthSum} MultiBinding Converter{x:Static views:AnimationValueConverter.GetWidthByIsVisible} Binding PathBounds.Width ElementNameLRF_Sidebar / Binding PathWidth ElementNameSidebarLayer1 / /MultiBinding Binding PathBounds.Width ElementNameLRF_ArticleView / /MultiBinding Binding PathMargin ElementNameArticleViewLayer2 / /MultiBinding /Border.Width Border.Height MultiBinding Converter{x:Static views:AnimationValueConverter.GetHeightByMargin} Binding PathBounds.Height ElementNameLRF_ArticleView / Binding PathMargin ElementNameArticleViewLayer2 / /MultiBinding /Border.Height Grid RowDefinitions*,* views:UserControl1 Grid.Row0/views:UserControl1 views:UserControl2 Grid.Row1/views:UserControl2 /Grid /Border /Canvas /Canvas /Panel /Windowusing System; using System.Collections.Generic; using System.Globalization; using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Data.Converters; using CommunityToolkit.Diagnostics; namespace WCKYWCKF.Avalonia.Extension.Sample.Views; public class AnimationValueConverter { public static readonly AttachedPropertyIDictionaryAvaloniaProperty, ValueBeforeAnimationCacheItem? BeforeAnimationCacheProperty AvaloniaProperty.RegisterAttachedAnimationValueConverter, Control, IDictionaryAvaloniaProperty, ValueBeforeAnimationCacheItem?(BeforeAnimationCache); public static IMultiValueConverter GetWidthSum { get { return field ?? new FuncMultiValueConverterobject,object?(Convert); object? Convert(IReadOnlyListobject? arg) { return arg.Select(double (item) { return item switch { double value value, Thickness value -(value.Left value.Right), _ 0 }; }) .Sum(); } } } public static IMultiValueConverter GetWidthNegate { get { return field ?? new FuncMultiValueConverterobject,object?(Convert); object? Convert(IReadOnlyListobject? arg) { Guard.IsTrue(arg.Count 2); Guard.IsOfTypedouble(arg[0] ?? ThrowHelper.ThrowArgumentNullExceptiondouble()); Guard.IsOfTypedouble(arg[1] ?? ThrowHelper.ThrowArgumentNullExceptiondouble()); var countValue (double)arg[0]!; var variableValue (double)arg[1]!; return countValue - variableValue; } } } public static IMultiValueConverter GetWidthByIsVisible { get { return field ?? new FuncMultiValueConverterobject,object?(Convert); object? Convert(IReadOnlyListobject? arg) { Guard.IsTrue(arg.Count 2); Guard.IsOfTypedouble(arg[0] ?? ThrowHelper.ThrowArgumentNullExceptiondouble()); Guard.IsOfTypedouble(arg[1] ?? ThrowHelper.ThrowArgumentNullExceptiondouble()); var countValue (double)arg[0]!; var variableValue (double)arg[1]!; return countValue variableValue ? countValue : 0; } } } public static IMultiValueConverter GetValueBeforeAnimation { get { return field ?? new FuncMultiValueConverterobject,object?(Convert); object? Convert(IReadOnlyListobject? arg) { Guard.IsTrue(arg.Count 4); var trigger arg[0]; var target arg[2] as Control; var property arg[3] as AvaloniaProperty; Guard.IsNotNull(target); Guard.IsNotNull(property); var value target.GetValue(property); var cache GetBeforeAnimationCache(target); if (cache is null) { cache new DictionaryAvaloniaProperty, ValueBeforeAnimationCacheItem(); SetBeforeAnimationCache(target, cache); } if (!cache.TryGetValue(property, out var cacheValue)) { cache[property] new ValueBeforeAnimationCacheItem { CurrentValue value }; } else { if (!target.IsAnimating(property)) { if (!CustomEquals(cacheValue.CurrentValue, value)) { if (cacheValue.IsAnimating !CustomEquals(cacheValue.TriggerValue, trigger)) { cacheValue.CurrentValue value; cacheValue.OldValue cacheValue.AnimatingValue; } else { cacheValue.CurrentValue value; cacheValue.AnimatingValue cacheValue.OldValue; } cacheValue.TriggerValue trigger; } } else { cacheValue.AnimatingValue value; // if (property Layoutable.WidthProperty) // cacheValue.DebugList.Add(((double)(cacheValue.OldValue ?? double.NaN), (double)(cacheValue.CurrentValue ?? double.NaN), (double)(cacheValue.AnimatingValue ?? double.NaN))); } return cacheValue.OldValue; } return value; } } } public static IMultiValueConverter GetWidthByMargin { get { return field ?? new FuncMultiValueConverterobject,double(Convert); double Convert(IReadOnlyListobject? arg) { Guard.IsTrue(arg.Count 2); Guard.IsTrue(arg[0] is double); var width (double)arg[0]!; Guard.IsTrue(arg[1] is Thickness); var margin (Thickness)arg[1]!; width - margin.Left margin.Right; width Math.Max(0, width); return width; } } } public static IMultiValueConverter GetHeightByMargin { get { return field ?? new FuncMultiValueConverterobject,double(Convert); double Convert(IReadOnlyListobject? arg) { Guard.IsTrue(arg.Count 2); Guard.IsTrue(arg[0] is double); var height (double)arg[0]!; Guard.IsTrue(arg[1] is Thickness); var margin (Thickness)arg[1]!; height - margin.Top margin.Bottom; height Math.Max(0, height); return height; } } } }四.3 讲解对于.axaml部分的讲解我会直接引用对应的局部代码或控件名称C# 部分则会直接引用方法名。四.3.1 为什么使用控件过渡Control Transitions而不是关键帧动画Keyframe Animations关键在于动画被打断后的接续。使用关键帧动画意味着你需要自己处理动画的起始值这并不简单而且相当麻烦。对 Avalonia 了解不够深入或经验不足的开发者经常会有这样的直觉“这样写就能拿到动画的起始值”Animation Duration0:0:0.8 KeyFrame Cue0% Setter PropertyHeight Value{Binding ElementNameLRF_Target,PathHeight}/Setter /KeyFrame /Animation但实际上这行不通因为这样会让动画的首个值不断变化。最直观的影响就是动画会变得很奇怪尤其是在设置了缓动函数时整体效果会像弹簧一样。控件过渡则自动处理了这些问题具体实现细节这里不展开。总而言之它让动画即使在中途被打断也能非常流畅地与下一个动画衔接而不会让控件在起点和终点之间闪来闪去写过关键帧动画的开发者大多都见过这种闪动。四.3.2 MultiBinding 下居然还可以套 MultiBinding可以的MultiBinding本身就继承自BindingBase。通过嵌套MultiBinding并配合值转换器可以实现许多巧妙的用法。四.3.3 为什么绑定到控件时的写法是 ElementName 而不是 #Name只是因为这样写 Rider 会有智能提示不用手动输入完整名称。四.3.4 在控制 SidebarLayer1 的可见性时为什么要用关键帧动画先理解 SidebarLayer1 可见性变化的需求打开时动画一开始就应当可见。关闭时动画完全结束后才变为不可见。要实现这样的时序控制我们需要对属性设置的时机进行精细操作。然而 Avalonia 并没有直接提供“动画结束后回调”之类的机制。在代码里控制当然可行但那就偏离了本文的主题失去了.axaml的灵活性。在讲解本段之前需要先了解 Avalonia 的属性系统和样式系统中一个关键点——样式化属性的设置是有优先级的详见Animation -1, // Highest priority LocalValue 0, StyleTrigger, Template, Style, Inherited, Unset int.MaxValue, // Lowest priority还有一个动画系统的知识bool类型的属性也可以参与动画。从上面可以看出动画执行器设置的值优先级最高。同时属性系统只会使用优先级最高的值。因此为了实现“关闭时动画结束后才不可见”我们可以利用关键帧动画另辟蹊径就像这样Style.Animations Animation Duration0:0:0.8 KeyFrame Cue0% Setter PropertyIsVisible ValueTrue / /KeyFrame KeyFrame Cue100% Setter PropertyIsVisible ValueFalse / /KeyFrame /Animation /Style.Animations Setter PropertyIsVisible ValueFalse /四.3.5 为什么用Style SelectorCanvas#SidebarLayer1:not(.IsOpen)而不是Style SelectorCanvas#SidebarLayer1.IsClose当时我也尝试过直接写Style SelectorCanvas#SidebarLayer1.IsClose设想中这应该没有问题但实际效果却事与愿违。Style SelectorCanvas#SidebarLayer1.IsOpen和.IsClose中的Setter与Animations之间总会间隔较长的时间——动画和属性值没有被及时执行和设置。对此笔者有一些推断尚未通过研究 Avalonia 源码来验证Classes.IsOpen{Binding #ToggleSwitch.IsChecked} Classes.IsClose{Binding !#ToggleSwitch.IsChecked}根据 UI 线程的执行特点上述两条绑定会先后执行。每次执行都会触发一轮样式系统和属性系统的更新例如重新应用样式、设置属性等。检查Style SelectorCanvas#SidebarLayer1.IsOpen检查Style SelectorCanvas#SidebarLayer1.IsClose然后才会处理下一条绑定。这意味着当Canvas#SidebarLayer1.IsOpen不成立时.IsClose并不会立刻成立而是要等待下一轮样式和属性系统的更新这对动画设计来说是灾难性的。而只要使用:not()选择器——Style SelectorCanvas#SidebarLayer1:not(.IsOpen)——就可以保证在.IsOpen不成立时样式立刻被命中从而确保动画被及时执行。因此在所有类似的场景下都应该优先使用:not()语法。四.3.6 Demo 结语其余部分并不难理解Demo 中较为特殊的地方都已经解释过了。更多细节请直接阅读代码。五、WCKYWCKF.Avalonia.Extension 仓库这个仓库包含如下内容WCKYWCKF.Avalonia.Extension 一套拓展当前有一套 Ursa.Avalonia 的 Form 的 MVVM 扩展并且仍在开发更多内容笔者常用的内容都会加入到该仓库中。WCKYWCKF.Avalonia.Extension.RxUI 一套 WCKYWCKF.Avalonia.Extension 的 ReactiveUI 实现。WCKYWCKF.Avalonia.Animations 一套值转换器用于布局动画。未来可能会开发一些自带布局动画的布局容器加入到该项目中。WCKYWCKF.Avalonia.Extension.Sample 一套示例应用内容有笔者写的技术文章、此仓库的内容展示与教程文档。WCKYWCKF.Avalonia.Extension.Sample 项目的构建需要 Avalonia Pro License 和 铱泓科技的 Mantra 托管源的授权账号求购请尝试通过以下方式联系Avalonia Pro LicenseAvalonia 官网铱泓科技的 Mantra社交媒体六、结语作为 Avalonia 中文社区的一员不断壮大的 Avalonia 是我们共同的愿景。对我个人而言这第一篇技术文章只是第一步所有因此文受益的人都将迈出第二步、第三步……七、版权作者望晨空忧许可证本文章采用 知识共享 署名—非商业性使用 4.0 国际版CC BY-NC 4.0进行许可笔者鼓励转载、改编为教学视频等有利于技术传播的行为但请勿用于商业用途例如将本文内容纳入付费教程并请保留作者署名。分类: Avalonia, .NET标签: .NET, Avalonia免责声明本内容来自平台创作者博客园系信息发布平台仅提供信息存储空间服务。好文要顶 关注我 收藏该文 微信分享望晨空忧粉丝 - 0 关注 - 0加关注10升级成为会员posted 2026-05-29 20:40 望晨空忧 阅读(302) 评论(0) 收藏 举报