[UWP]一种利用Behavior 将StateTrigger集中管理的方案
不做开篇废话,我们发现:
AdaptiveTrigger 不够好
我们知道,UWP可以在一个页面适应不同尺寸比例的屏幕。一般来说这个功能是通过官方推荐的AdaptiveTrigger 进行的。
比如这样:
<VisualState x:Name="NarrowView">
<VisualState.StateTriggers>
<AdaptiveTrigger MinWindowWidth="800" MinWindowHeight="600"/>
</VisualState.StateTriggers>
</VisualState>
我们可以看到这样的的Trigger制定了最小值,隐含了条件“当满足长宽都大于于这个条件时,这个状态会被触发;但如果有更严格的条件被触发,那么优先触发更严格的那个状态"
这听上去是个高大上,暗含模式匹配概念的好主意。但是如果你对其中的条件哪怕有一点拿不住,这样的trigger往往会造成开发中的混乱。
AdaptiveTrigger模糊的命中规则
比如下面的例子
例:
A:
<AdaptiveTrigger MinWindowWidth="800" MinWindowHeight="600"/>
B:
<AdaptiveTrigger MinWindowWidth="600" MinWindowHeight="800"/>
这两个货究竟谁会优先被触发?你得手动实验。
而且由于你不知道到Windows Runtime内部是怎么管理这些小玩意(本地代码),有时候你只需要简单的通过长宽比较判断的横竖屏切换竟然要烧脑一番。再加上VisualState元素里往往含有巨多的动画和属性设置,我们很难将所有的Trigger拉到一起进行有效的管理,这对页面的构建可能会产生很大的阻碍。
AdaptiveTrigger只订阅窗口大小切换VisualState是不够的
当窗口的大小不敏感,对窗体内部的一些元素大小敏感的时候,只针对窗口的大小监视显然也是不够的,我们需要更多的逻辑扩展。
Page | ||
Content(Size变化无法被AdaptiveTrigger订阅) | ||
比如我有一个页面“Page”,里面有一个从服务器端获取内容的控件“Content”。这时候我设置了两个VisualStateGroup: PageLayoutGroup 和 ContentLayoutGroup,分别应付外层Page的长宽变化,和内部Content的长宽变化(内容要访问服务器Render到界面以后才会知道Size). 这时候想用AdaptiveTrigger来控制ContentLayoutGroup 的State切换就玩不转了。
我们需要针对任意控件的属性监控来切换State
于是你可能想实现一个自己的StateTrigger。这时候,更大的坑出现了,我们来分析。
自定义StateTrigger的限制与风险
我们来看一个我Appconsult同事跟我们一起讨论用的例子代码:
public class SizeTrigger : StateTriggerBase
{
public SizeTrigger()
{
Window.Current.SizeChanged += Current_SizeChanged;
}
private void Current_SizeChanged(object sender, WindowSizeChangedEventArgs e)
{
Debug.WriteLine($"CurrentSize_Changed: {DateTime.Now}");
SizeObject _size = new SizeObject();
_size.width = e.Size.Width;
_size.height = e.Size.Height;
dynamic result = SetTrigger(Orientation, _size);
SetActive(result);
}
}
限制1:无法精确控制生存期
这位同事说第一次他首先想到这样做。当然他知道这里可以用WeakEventListener,但是测试嘛,先跑通看看。
但是他发现"OMG 为什么我都navigate到 Page2了这个事件还会触发到这个实例来"。
就算实现了WeakEventListener,无法控制生存期,能免得了MemoryLeak 免不了Exception啊。难道要加大号的TryCatch?那也是个消耗内!
后来他实测了WeakEventListener,果然开始一段时间事件还是丢到了未注销的订阅里面。然后他发现了新bug:当NavigationCacheMode 打开的时候 ,当离开这个页面到page2 一段时间再回来这个页面,弱引用已经被释放掉了,订阅size的功能被取消了,没有重新新订阅的机会。
所以, StateTriggerBase 没有提供明确的Onload/OnUnload 生存期注入点,成为这一类扩展的巨大限制。
于是我们顺理成章的建议,我们可以绑控件,不绑Window.Current嘛,
"给你的SizeTrigger加一个Panel属性 绑定到你得RootGrid上面,你订阅这货怎么样?"
结果遇到了第二个限制:
限制2:在VisualGroup属性内产生的Trigger,绑定其他元素经常失效或造成Xaml设计器崩溃
这点老司机们往往会有体会,当你声明对象的父节点有一层或者基层不是DP/FrameworkElements的时候,运行时可能无法得到正确的绑定上下文(在某几个版本的Windows Runtime出现过 我没有Check 最新版本) 当SizeTrigger拿不到绑定值的时候,SizeTrigger是无法订阅目标变化的。
同时我也提出提出第三个不爽的地方:
限制3:分散的逻辑仍然难以整体控制
相关的非此即彼的几个Trigger,把他们写成若干个逻辑分散的Trigger实现,还要他们分别埋在不同的State里面,生产力提高了吗?
这时候我就拿 Greater Share的代码出来给他们看我的behavior方案了
用Behavior解决问题
不是我藏私,是我写Greater Share代码的时候觉得分散管理生产力低下,一周前就写了一个自用,谁想到扩展StaeTriggerBase会有那么多坑啊(逃
Behavior设计思路
实现我们的Behavior首先要利用下面两个类型的特性
- Behavior
- 绑定友好,能够拿到各种绑定上下文
- 具有完整的 OnAttach/OnDetatch 生存期支持
- 能够附加在任何DepenedencyObject上 获取其状态和事件。
- StateTrigger
- 不含逻辑,简单根据属性的True/False进行判断是否命中
我原本就是为了生产力来设计这个Behavior。
思路是:
如果分散的逻辑很麻烦,我干啥不设计一个超然的管理器来管理多个StateTrigger呢?
集中控制,我让谁上谁就上。
这样一想就会发现,AdaptiveTrigger也一定有一个傀儡师在操控吧?
Behavior运行流程:
- 获取监视目标
- 获取可以操控的StateTrigger
- 订阅监视目标感兴趣值的变化
- 根据值判断哪个State更合适,用代码激活它
代码
大概是这个样子
public class StateTriggerActiveReadingBehavior : Behavior<Panel>
{
long NarrowTriggerPropertyReg;
long WideTriggerPropertyReg;
protected override void OnAttached() //订阅
{
AssociatedObject.SizeChanged += AssociatedObject_SizeChanged;
NarrowTriggerPropertyReg = RegisterPropertyChangedCallback(NarrowTriggerProperty, (o, a) => RefreshState());
WideTriggerPropertyReg = RegisterPropertyChangedCallback(WideTriggerProperty, (o, a) => RefreshState());
base.OnAttached();
}
private void AssociatedObject_SizeChanged(object sender, SizeChangedEventArgs e)
{
RefreshState();
}
private void RefreshState() //判断条件,选一个Trigger状态来激活。
{
//if (true)
//{
// WideTrigger.IsActive = false;
// NarrowTrigger.IsActive = true;
//}
}
protected override void OnDetaching() //注销
{
base.OnDetaching();
AssociatedObject.SizeChanged -= AssociatedObject_SizeChanged;
UnregisterPropertyChangedCallback(NarrowTriggerProperty, NarrowTriggerPropertyReg);
UnregisterPropertyChangedCallback(WideTriggerProperty, WideTriggerPropertyReg);
}
public StateTrigger NarrowTrigger //窄状态
{
get { return (StateTrigger)GetValue(NarrowTriggerProperty); }
set { SetValue(NarrowTriggerProperty, value); }
}
public static readonly DependencyProperty NarrowTriggerProperty =
DependencyProperty.Register(nameof(NarrowTrigger), typeof(StateTrigger), typeof(StateTriggerActiveReadingBehavior), new PropertyMetadata(null));
public StateTrigger WideTrigger //宽状态
{
get { return (StateTrigger)GetValue(WideTriggerProperty); }
set { SetValue(WideTriggerProperty, value); }
}
public static readonly DependencyProperty WideTriggerProperty =
DependencyProperty.Register(nameof(WideTrigger), typeof(StateTrigger), typeof(StateTriggerActiveReadingBehavior), new PropertyMetadata(null));
}
调用的时候只需绑定两个属性就可以了
<Interactivity:Interaction.Behaviors>
<Glue:StateTriggerActiveReadingBehavior
x:Name="StateTriggerActiveReadingBehavior"
WideTrigger="{Binding ElementName=wideTrigger}"
NarrowTrigger="{Binding ElementName=narrowTrigger}"/>
</Interactivity:Interaction.Behaviors>
可以规避那么多坑是我始料未及的,我们来Review一下我们刚才提到的各种问题
- AdaptiveTrigger模糊命中不确定 (完美规避)
- AdaptiveTrigger不能订阅任意来源的状态变化 (完美规避)
- CustomeTrigger生存期不能控制,容易造成未捕获异常和内存泄漏(完美规避)
- CustomeTrigger绑定不便或容易造成异常(完美规避)
- CustomeTrigger逻辑分散不集中造成生产力低下(完美规避)
此外利用了绑定技术还降低了另一种“GotToStateActionBehavior”方案对于Magic String名称的依赖,似乎还不错?
希望这种VisualState的控制模式对大家的开发有所启发帮助。
另外完整的代码在这里