-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Improve TypedBinding performance by ~29% #33656
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -146,6 +146,12 @@ public TypedBinding(Func<TSource, (TProperty value, bool success)> getter, Actio | |
| BindableProperty _targetProperty; | ||
| List<WeakReference<Element>> _ancestryChain; | ||
| bool _isBindingContextRelativeSource; | ||
| BindingMode _cachedMode; | ||
| bool _isSubscribed; | ||
| bool _isTSource; // cached type check result | ||
| object _cachedDefaultValue; // cached default value | ||
| bool _hasDefaultValue; | ||
| bool _skipConvert; // true when TProperty matches property.ReturnType | ||
|
|
||
| // Applies the binding to a previously set source and target. | ||
| internal override void Apply(bool fromTarget = false) | ||
|
|
@@ -283,6 +289,13 @@ internal override void Unapply(bool fromBindingContextChanged = false) | |
| if (_handlers != null) | ||
| Unsubscribe(); | ||
|
|
||
| _isSubscribed = false; | ||
| _cachedMode = BindingMode.Default; | ||
| _hasDefaultValue = false; | ||
| _cachedDefaultValue = null; | ||
| _skipConvert = false; | ||
| _isTSource = false; | ||
|
|
||
| #if (!DO_NOT_CHECK_FOR_BINDING_REUSE) | ||
| _weakSource.SetTarget(null); | ||
| _weakTarget.SetTarget(null); | ||
|
|
@@ -294,24 +307,58 @@ internal override void Unapply(bool fromBindingContextChanged = false) | |
| // ApplyCore 100000 (w/o INPC, w/o unnapply) : 20ms. | ||
| internal void ApplyCore(object sourceObject, BindableObject target, BindableProperty property, bool fromTarget, SetterSpecificity specificity) | ||
| { | ||
| var isTSource = sourceObject is TSource; | ||
| if (!isTSource && sourceObject is not null) | ||
| // Use cached type check after first apply (source type doesn't change) | ||
| var isTSource = _isTSource; | ||
| if (!_hasDefaultValue) | ||
| { | ||
| isTSource = sourceObject is TSource; | ||
| _isTSource = isTSource; | ||
| if (!isTSource && sourceObject is not null) | ||
| { | ||
| BindingDiagnostics.SendBindingFailure(this, "Binding", $"Mismatch between the specified x:DataType ({typeof(TSource)}) and the current binding context ({sourceObject.GetType()})."); | ||
| } | ||
| } | ||
|
Comment on lines
+310
to
+320
|
||
|
|
||
| // Use cached mode if available, otherwise compute and cache it | ||
| var mode = _cachedMode; | ||
| if (mode == BindingMode.Default) | ||
| { | ||
| BindingDiagnostics.SendBindingFailure(this, "Binding", $"Mismatch between the specified x:DataType ({typeof(TSource)}) and the current binding context ({sourceObject.GetType()})."); | ||
| mode = this.GetRealizedMode(property); | ||
| _cachedMode = mode; | ||
| } | ||
|
|
||
| var mode = this.GetRealizedMode(property); | ||
| if ((mode == BindingMode.OneWay || mode == BindingMode.OneTime) && fromTarget) | ||
| return; | ||
|
|
||
| var needsGetter = (mode == BindingMode.TwoWay && !fromTarget) || mode == BindingMode.OneWay || mode == BindingMode.OneTime; | ||
|
|
||
| if (isTSource && (mode == BindingMode.OneWay || mode == BindingMode.TwoWay) && _handlers != null) | ||
| // Only subscribe once per binding lifetime | ||
| if (!_isSubscribed && isTSource && (mode == BindingMode.OneWay || mode == BindingMode.TwoWay) && _handlers != null) | ||
| { | ||
| Subscribe((TSource)sourceObject); | ||
| _isSubscribed = true; | ||
| } | ||
|
|
||
| if (needsGetter) | ||
| { | ||
| var value = FallbackValue ?? property.GetDefaultValue(target); | ||
| // Use cached default value | ||
| object value; | ||
| if (FallbackValue != null) | ||
| { | ||
| value = FallbackValue; | ||
| } | ||
| else if (_hasDefaultValue) | ||
| { | ||
| value = _cachedDefaultValue; | ||
| } | ||
| else | ||
| { | ||
| value = property.GetDefaultValue(target); | ||
| _cachedDefaultValue = value; | ||
| _skipConvert = typeof(TProperty) == property.ReturnType && Converter == null; | ||
| _hasDefaultValue = true; | ||
| } | ||
|
|
||
| if (isTSource) | ||
| { | ||
| try | ||
|
|
@@ -328,7 +375,8 @@ internal void ApplyCore(object sourceObject, BindableObject target, BindableProp | |
| BindingDiagnostics.SendBindingFailure(this, sourceObject, target, property, "Binding", $"Exception thrown from getter: {ex.Message}"); | ||
| } | ||
| } | ||
| if (!BindingExpressionHelper.TryConvert(ref value, property, property.ReturnType, true)) | ||
| // Skip TryConvert when types match and no converter | ||
| if (!_skipConvert && !BindingExpressionHelper.TryConvert(ref value, property, property.ReturnType, true)) | ||
| { | ||
| BindingDiagnostics.SendBindingFailure(this, sourceObject, target, property, "Binding", BindingExpression.CannotConvertTypeErrorMessage, value, property.ReturnType); | ||
| return; | ||
|
|
@@ -520,15 +568,22 @@ public PropertyChangedProxy(Func<TSource, object> partGetter, string propertyNam | |
| Listener = new BindingExpression.WeakPropertyChangedProxy(); | ||
| //avoid GC collection, keep a ref to the OnPropertyChanged handler | ||
| handler = new PropertyChangedEventHandler(OnPropertyChanged); | ||
| //cache the Apply delegate to avoid allocation on every property change | ||
| _applyAction = () => _binding.Apply(false); | ||
| } | ||
|
|
||
| readonly Action _applyAction; | ||
|
|
||
| void OnPropertyChanged(object sender, PropertyChangedEventArgs e) | ||
| { | ||
| if (!string.IsNullOrEmpty(e.PropertyName) && string.CompareOrdinal(e.PropertyName, PropertyName) != 0) | ||
| return; | ||
|
|
||
| // Note: sender is typically a ViewModel (INotifyPropertyChanged), not a BindableObject, | ||
| // so (sender as BindableObject)?.Dispatcher usually returns null. | ||
| // DispatchIfRequired handles null dispatcher via EnsureDispatcher fallback. | ||
| IDispatcher dispatcher = (sender as BindableObject)?.Dispatcher; | ||
| dispatcher.DispatchIfRequired(() => _binding.Apply(false)); | ||
| dispatcher.DispatchIfRequired(_applyAction); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| using System; | ||
| using System.ComponentModel; | ||
| using System.Runtime.CompilerServices; | ||
| using BenchmarkDotNet.Attributes; | ||
| using Microsoft.Maui.Controls; | ||
| using Microsoft.Maui.Controls.Internals; | ||
| using Microsoft.Maui.Dispatching; | ||
|
|
||
| namespace Microsoft.Maui.Benchmarks | ||
| { | ||
| [MemoryDiagnoser] | ||
| public class BindingComparisonBenchmarker | ||
| { | ||
| public class NotifyingObject : INotifyPropertyChanged | ||
| { | ||
| string _name = "Initial"; | ||
| public string Name | ||
| { | ||
| get => _name; | ||
| set { _name = value; OnPropertyChanged(); } | ||
| } | ||
|
|
||
| public event PropertyChangedEventHandler PropertyChanged; | ||
| void OnPropertyChanged([CallerMemberName] string name = null) => | ||
| PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); | ||
| } | ||
|
|
||
| public class MyObject : BindableObject | ||
| { | ||
| public static readonly BindableProperty NameProperty = | ||
| BindableProperty.Create(nameof(Name), typeof(string), typeof(MyObject)); | ||
|
|
||
| public string Name | ||
| { | ||
| get => (string)GetValue(NameProperty); | ||
| set => SetValue(NameProperty, value); | ||
| } | ||
| } | ||
|
|
||
| class BenchmarkDispatcher : IDispatcher | ||
| { | ||
| public bool IsDispatchRequired => false; | ||
| public int ManagedThreadId => Environment.CurrentManagedThreadId; | ||
| public bool Dispatch(Action action) { action(); return true; } | ||
| public bool DispatchDelayed(TimeSpan delay, Action action) { action(); return true; } | ||
| public IDispatcherTimer CreateTimer() => throw new NotImplementedException(); | ||
| } | ||
|
|
||
| class BenchmarkDispatcherProvider : IDispatcherProvider | ||
| { | ||
| readonly BenchmarkDispatcher _dispatcher = new(); | ||
| public IDispatcher GetForCurrentThread() => _dispatcher; | ||
| } | ||
|
|
||
| NotifyingObject SourceBinding; | ||
| NotifyingObject SourceTypedBinding; | ||
| NotifyingObject SourceSourceGenBinding; | ||
| MyObject TargetSetValue; | ||
| MyObject TargetBinding; | ||
| MyObject TargetTypedBinding; | ||
| MyObject TargetSourceGenBinding; | ||
|
|
||
| [GlobalSetup] | ||
| public void Setup() | ||
| { | ||
| DispatcherProvider.SetCurrent(new BenchmarkDispatcherProvider()); | ||
|
|
||
| TargetSetValue = new MyObject(); | ||
|
|
||
| SourceBinding = new NotifyingObject { Name = "Initial" }; | ||
| TargetBinding = new MyObject(); | ||
| TargetBinding.SetBinding(MyObject.NameProperty, new Microsoft.Maui.Controls.Binding("Name", source: SourceBinding)); | ||
|
|
||
| SourceTypedBinding = new NotifyingObject { Name = "Initial" }; | ||
| TargetTypedBinding = new MyObject(); | ||
| TargetTypedBinding.SetBinding(MyObject.NameProperty, new TypedBinding<NotifyingObject, string>( | ||
| o => (o.Name, true), | ||
| null, | ||
| handlers: new[] { Tuple.Create<Func<NotifyingObject, object>, string>(o => o, "Name") } | ||
| ) | ||
| { Source = SourceTypedBinding }); | ||
|
|
||
| SourceSourceGenBinding = new NotifyingObject { Name = "Initial" }; | ||
| TargetSourceGenBinding = new MyObject(); | ||
| TargetSourceGenBinding.SetBinding(MyObject.NameProperty, static (NotifyingObject o) => o.Name, source: SourceSourceGenBinding, mode: BindingMode.OneWay); | ||
| } | ||
|
|
||
| [GlobalCleanup] | ||
| public void Cleanup() | ||
| { | ||
| DispatcherProvider.SetCurrent(null); | ||
| } | ||
|
|
||
| static int _counter; | ||
|
|
||
| [Benchmark(Baseline = true)] | ||
| public void SetValue() | ||
| { | ||
| TargetSetValue.SetValue(MyObject.NameProperty, (++_counter).ToString()); | ||
| } | ||
|
|
||
| [Benchmark] | ||
| public void Binding() | ||
| { | ||
| SourceBinding.Name = (++_counter).ToString(); | ||
| } | ||
|
|
||
| [Benchmark] | ||
| public void TypedBinding() | ||
| { | ||
| SourceTypedBinding.Name = (++_counter).ToString(); | ||
| } | ||
|
|
||
| [Benchmark] | ||
| public void SourceGeneratedBinding() | ||
| { | ||
| SourceSourceGenBinding.Name = (++_counter).ToString(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The field
_isTSourceshould be reset inUnapply()along with the other cached values. Add_isTSource = false;to properly reset the cached type check result.