Skip to content
/ EBind Public

πŸ”΅ .NET Data Binding we deserve: concise, fast, feature-rich

License

Notifications You must be signed in to change notification settings

SIDOVSKY/EBind

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

8 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation


EBind

CI nuget: EBind nuget: EBind.LinkerIncludeGenerator Codecov

var binding = new EBinding
{
    () => view.Text == vm.Text,
    () => view.Text == vm.Description.Title.Text,
    () => view.Text == (vm.Text ?? vm.FallbackText),
    () => view.Visible == !vm.TextVisible,
    () => view.Visible == (vm.TextVisible == vm.ImageVisible),
    () => view.Visible == (vm.TextVisible && vm.ImageVisible),
    () => view.Visible == (vm.TextVisible || vm.ImageVisible),
    () => view.FullName == $"{vm.FirstName} {vm.LastName}",
    () => view.FullName == vm.FirstName + " " + vm.LastName,
    () => view.Timestamp == Converter.DateTimeToEpoch(vm.DateTime),

    BindFlag.TwoWay,
    () => view.Text == vm.Text,
    () => view.SliderValueFloat == vm.AgeInt,

    BindFlag.OneTime | BindFlag.NoInitialTrigger,
    () => view.ShowImage(vm.ImageUri),
    () => Dispatcher.RunOnUiThread(() => view.ShowImage(vm.ImageUri)),

    BindFlag.OneTime,
    (view, nameof(view.Click), vm.OnViewClicked),
    (view, nameof(view.TextEditedEventHandler), () => vm.OnViewTextEdited(view.Text)),
    (view, "CustomEventForGesture", vm.OnViewClicked),

    (view, nameof(view.Click), vm.ViewClickCommand),
    (view, nameof(view.Click), () => vm.ViewClickCommand.TryExecute(view.Text)),

    new UserExtensions.CustomEBinding(),
};

binding.Dispose();

snippet source | anchor

Three types of bindings here

  • EQUALITY – bind a property or an expression of them to another property.
    Every change of every property on the right (vm.Text) leads to an assignment of the property on the left (view.Text).
    Binding between two properties may work in a Two-Way mode.
    () => view.Text == vm.Text,
  • ACTION – bind a method to its target and parameters.
    Every time vm.ImageUri is changed view.ShowImage is invoked with a new value.
    () => view.ShowImage(vm.ImageUri),
  • EVENT – bind a method or command to an event.
    Every time view.Click is raised vm.OnViewClicked is called.
    (view, nameof(View.Click), vm.OnViewClicked),

Key points

  • The main binding update trigger is INotifyPropertyChanged.PropertyChanged.
    Boilerplate code may be avoided with Fody.PropertyChanged.
    There are some platform-specific pre-configured triggers. Additional ones are easy to configure.

  • Bindings invoke immediately after the construction, except for the event binding.
    To skip initial invocation prepend BindFlag.NoInitialTrigger.

  • Each property and field in a binding expression triggers the binding update
    Triggers Highlight

  • Default binding mode is One-Way, Source-to-Target (right to left).
    This is the most common use-case. Explicit declaration of a two-way mode will help to avoid unreasonable performance loss.
    May be overridden in EBinding.DefaultConfiguration.DefaultFlag.

  • For Target-to-Source binding simply swap operands.
    It seems more natural than bringing in an additional BindFlag.OneWayToSource.

  • Expressions of any complexity are supported.
    Even MONSTERS like that:

    () => view.Text == new Func<string?>(() => new Dictionary<string, string?>((int)10.0){ { "Key", vm.Text } }["Key"])(),

    However, the more complex the expression, the longer it takes to create and invoke the binding from it. See benchmarks for the reference.

  • BindFlag applies to all subsequent bindings till the next flag.

Configuration and Extensibility

Pre-Configured Triggers

Xamarin.Android
View/Control Event Property
Android.Views.View Click
LongClick
FocusChange
Android.Widget.AdapterView ItemSelected SelectedItemPosition
ItemClick
ItemLongClick
Android.Widget.CalendarView DateChange Date
Android.Widget.CompoundButton CheckedChange Checked
Android.Widget.DatePicker DateChanged DateTime
Android.Widget.NumberPicker ValueChanged Value
Android.Widget.RatingBar RatingBarChange Rating
Android.Widget.SearchView QueryTextChange Query
Android.Widget.SeekBar ProgressChanged Progress
Android.Widget.TextView TextChanged Text
Android.Widget.TimePicker TimeChanged Hour (API 23+)
Minute (API 23+)
CurrentHour
CurrentMinute
Xamarin.iOS
View/Control Event Property
UIBarButtonItem Clicked
UIControl TouchUpInside
ValueChanged
UIDatePicker ValueChanged Date
UIPageControl ValueChanged CurrentPage
UISearchBar TextChanged Text
UISegmentedControl ValueChanged SelectedSegment
UISlider ValueChanged Value
UIStepper ValueChanged Value
UISwitch ValueChanged On
UITabBarController ViewControllerSelected SelectedIndex
UITextField EditingChanged Text
EditingDidBegin
EditingDidEnd
UITextView Changed Text
Xamarin.Forms

All views implement INotifyPropertyChanged so the main trigger is invoked for every bindable property change.

Member Triggers

In Configuration you may specify how to subscribe and unsubscribe for signals of property and field updates that are not tracked out of the box.
There are overloads for the property/field update handler as System.EventHandler, System.EventHandler<TEventArgs> or any other class:

EBinding.DefaultConfiguration.ConfigureTrigger<View, string>(
    v => v.Text,
    (v, h) => v.TextEditedEventHandler += h,
    (v, h) => v.TextEditedEventHandler -= h);

EBinding.DefaultConfiguration.ConfigureTrigger<View, View.TextEditedEventArgs, string>(
    v => v.Text,
    (v, h) => v.TextEditedGenericEventHandler += h,
    (v, h) => v.TextEditedGenericEventHandler -= h);

EBinding.DefaultConfiguration.ConfigureTrigger<View, Action<string>, string>(
    v => v.Text,
    trigger => _ => trigger(),
    (v, h) => v.TextEditedCustomEventHandler += h,
    (v, h) => v.TextEditedCustomEventHandler -= h);

snippet source | anchor

Event Triggers

You may configure your own triggers for event bindings under custom identifiers even if they represent a Pub-Sub pattern differently from C# events (IObservable, Add/RemoveListener methods).

EBinding.DefaultConfiguration.ConfigureTrigger<View, View.GestureRecognizer>(
    "CustomEventForGesture",
    trigger => new View.GestureRecognizer(trigger),
    (v, h) => v.AddGestureRecognizer(h),
    (v, h) => v.RemoveGestureRecognizer(h));

snippet source | anchor

The same identifier (event name) can be used with multiple classes.
For example on Xamarin.iOS a pre-defined Tap event can be used with both UIControl and UIView:

using EBind;
using static EBind.Platform.Configuration.ExtraEventNames;

var binding = new EBinding
{
    (uiButton, Tap, OnButtonClick),
    (uiImageView, Tap, OnImageClick),
};

Configuration of C# events is not required – they can be found by the name: nameof(obj.Event).
But it's recommended to specify subscription and unsubscription delegates to improve cold-start performance and avoid linker errors.

EBinding.DefaultConfiguration.ConfigureTrigger<View>(
    nameof(View.TextEditedEventHandler),
    (v, h) => v.TextEditedEventHandler += h,
    (v, h) => v.TextEditedEventHandler -= h);

EBinding.DefaultConfiguration.ConfigureTrigger<View, View.TextEditedEventArgs>(
    nameof(View.TextEditedGenericEventHandler),
    (v, h) => v.TextEditedGenericEventHandler += h,
    (v, h) => v.TextEditedGenericEventHandler -= h);

EBinding.DefaultConfiguration.ConfigureTrigger<View, Action<string>>(
    nameof(View.TextEditedCustomEventHandler),
    trigger => _ => trigger(),
    (v, h) => v.TextEditedCustomEventHandler += h,
    (v, h) => v.TextEditedCustomEventHandler -= h);

snippet source | anchor

Event triggers configured for a class are available to all its children unless they have their own variation defined.

Triggers may be overwritten by onward setups including the pre-defined ones.

Binding Dispatcher

Sometimes ViewModel properties are set from the background thread that leads to data-binding view update triggering in the non-ui thread.

Establishing a proper thread switching in place is not possible for some code. Also, the risk of missing a thread is not acceptable or not worth caring about for some projects, and delegation of this problem is a good deal.
In this case, data-binding as a point of integration may be a good place to switch threads between the ui-threaded View and the thread-insensitive ViewModel layers.

Dispatchers which force the UI thread are set up in Configuration:

EBinding.DefaultConfiguration.AssignmentDispatchDelegate = Dispatcher.RunOnUiThread;
EBinding.DefaultConfiguration.ActionDispatchDelegate = Dispatcher.RunOnUiThread;

For Xamarin platform Xamarin.Essentials.MainThread.BeginInvokeOnMainThread will be the best option most of the time.

Custom Bindings

EBinding collection initializer accepts any form of IEBinding.
By implementing this simple interface you can create your own binding types and adapters for data-binding components of other systems (e.g. Rx.Net), use them in the same collection initializer, and keep all bindings in one place.

Custom binding creation can be encapsulated inside an extension method Add(this EBinding, ...) so that the binding inputs are accepted in the collection initializer (supported since C# 6).

After a custom binding is created, it must be added to the collection via EBinding.Add(IEBinding) so that it can be disposed along with other bindings.

public class CustomEBinding : IEBinding
{
    public void Dispose()
    {
    }
}

public static void Add(this EBinding bindingHolder, CustomEBinding binding,
    [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0)
{
    try
    {
        if (binding == null)
            throw new ArgumentNullException(nameof(binding));

        if (bindingHolder.CurrentFlag == BindFlag.OneTime)
            throw new NotSupportedException();

        bindingHolder.Add(binding);
    }
    catch (Exception ex)
    {
        throw new Exception(
            $"Error in entry {bindingHolder.Count} at line #{sourceLineNumber}", ex);
    }
}

snippet source | anchor

It's recommended to decorate exceptions during binding creation with additional information about the binding position (EBinding.Count) and its line number (CallerLineNumberAttribute) as debuggers do not highlight that.

Exception screenshot

Location info in exception

Benchmarks

Environment
BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19041.572 (2004/?/20H1)
AMD Ryzen 5 1600, 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=5.0.202
  [Host]     : .NET Core 5.0.5 (CoreCLR 5.0.521.16609, CoreFX 5.0.521.16609), X64 RyuJIT
  DefaultJob : .NET Core 5.0.5 (CoreCLR 5.0.521.16609, CoreFX 5.0.521.16609), X64 RyuJIT
Comparison: Trigger πŸ”—
Method Mean Error StdDev Ratio Gen 0 Allocated
EBind 68.71 ns 0.422 ns 0.395 ns 1.00 0.0305 128 B
Mugen 206.29 ns 2.305 ns 2.156 ns 3.00 0.0172 72 B
XamarinFormsCompiled 351.32 ns 1.516 ns 1.418 ns 5.11 0.0267 112 B
MvvmLight 1,070.28 ns 3.529 ns 3.128 ns 15.58 0.1259 528 B
MvvmCross 1,368.35 ns 8.320 ns 7.376 ns 19.92 0.1678 704 B
ReactiveUI 3,054.42 ns 30.750 ns 28.763 ns 44.45 0.1831 777 B
PraeclarumBind 150,506.37 ns 372.197 ns 329.943 ns 2,190.78 0.9766 4415 B

sources

Comparison: Creation, One-Way πŸ”—
Method Mean Error StdDev Ratio Gen 0 Allocated
XamarinFormsCompiled 2.955 us 0.0117 us 0.0110 us 0.74 0.1831 768 B
EBind 3.994 us 0.0240 us 0.0213 us 1.00 0.5264 2232 B
MvvmLight 7.118 us 0.0506 us 0.0474 us 1.78 0.5951 2504 B
Mugen 8.075 us 0.1554 us 0.1790 us 2.01 0.4349 2014 B
MvvmCross 9.263 us 0.0583 us 0.0546 us 2.32 0.9155 3873 B
ReactiveUI 49.217 us 0.8883 us 0.8309 us 12.34 3.5400 14953 B
PraeclarumBind 300.919 us 0.6047 us 0.5657 us 75.32 2.4414 10634 B

sources

Comparison: Creation, Two-Way πŸ”—
Method Mean Error StdDev Ratio Gen 0 Allocated
XamarinFormsCompiled 3.058 us 0.0167 us 0.0156 us 0.62 0.1831 768 B
EBind 4.961 us 0.0309 us 0.0289 us 1.00 0.7553 3184 B
Mugen 7.669 us 0.0444 us 0.0370 us 1.55 0.4883 2064 B
MvvmLight 8.812 us 0.0465 us 0.0435 us 1.78 0.7782 3288 B
MvvmCross 13.041 us 0.0972 us 0.0909 us 2.63 1.0529 4449 B
ReactiveUI 80.447 us 0.4630 us 0.4331 us 16.22 6.4697 27237 B
PraeclarumBind 572.390 us 3.0060 us 2.8118 us 115.39 3.9063 19551 B

sources

Comparison: Cold Start πŸ”—

IterationCount=1 LaunchCount=100 RunStrategy=ColdStart

Type Method Mean Error StdDev Ratio Allocated
Creation EBind 13,948.6 us 411.78 us 1,214.14 us 1.00 3504 B
Creation MvvmLight 14,719.1 us 365.87 us 1,078.77 us 1.06 3216 B
Creation XamarinFormsCompiled 18,116.8 us 93.64 us 276.11 us 1.30 848 B
Creation MvvmCross 19,323.5 us 46.63 us 137.50 us 1.39 6144 B
Creation PraeclarumBind 21,130.5 us 70.75 us 208.60 us 1.52 12312 B
Creation Mugen 74,633.6 us 117.36 us 346.03 us 5.37 7472 B
Creation ReactiveUI 151,959.5 us 181.95 us 536.48 us 10.94 16304 B
Trigger EBind 705.5 us 3.10 us 9.15 us 0.05 224 B
Trigger MvvmCross 1,144.8 us 9.13 us 26.91 us 0.08 704 B
Trigger XamarinFormsCompiled 1,307.2 us 6.60 us 19.46 us 0.09 152 B
Trigger MvvmLight 2,010.1 us 13.96 us 41.15 us 0.14 608 B
Trigger ReactiveUI 3,354.7 us 9.44 us 27.84 us 0.24 792 B
Trigger PraeclarumBind 4,118.7 us 15.92 us 46.93 us 0.30 5000 B
Trigger Mugen 4,326.1 us 18.50 us 54.54 us 0.31 152 B
EBind Creation πŸ”—
Method Mean Error StdDev Gen 0 Allocated
(a, "nameof(a.Event)", Method) 337.5 ns 2.31 ns 1.93 ns 0.0858 360 B
a.Method() 3,256.9 ns 20.82 ns 18.46 ns 0.4349 1824 B
a.Prop == b.Prop // INPC 4,089.8 ns 54.06 ns 50.57 ns 0.5264 2232 B
a.Method(b.Prop.Method()) 4,406.1 ns 20.41 ns 17.05 ns 0.5493 2312 B
a.Prop == !b.Prop 4,611.5 ns 46.32 ns 43.33 ns 0.5646 2392 B
a.Prop == b.Prop // EventHandler 4,658.2 ns 46.37 ns 43.37 ns 0.5112 2152 B
a.Float == b.Int 4,785.8 ns 45.54 ns 38.03 ns 0.5798 2448 B
a.Enum == b.Enum 5,265.1 ns 22.89 ns 21.42 ns 0.5493 2328 B
a.Prop == Static.Method(b.Prop) 6,325.2 ns 67.12 ns 62.78 ns 0.6790 2864 B
a.Prop == (b.Prop && c.Prop) 6,423.7 ns 21.64 ns 18.07 ns 0.8087 3408 B
a.Prop == (b.Prop == c.Prop) 6,535.5 ns 49.44 ns 46.25 ns 0.8163 3432 B
a.Prop == (b.Prop || c.Prop) 6,653.2 ns 54.95 ns 51.40 ns 0.8163 3432 B
a.Prop == b.Method(c.Prop) 6,823.8 ns 60.00 ns 56.13 ns 0.7553 3184 B
a.Prop == (b.Prop ?? c.Prop) 7,271.6 ns 53.72 ns 50.25 ns 0.8392 3536 B
a.Prop == b.Method(c.Prop, d.Prop) 7,357.3 ns 50.22 ns 46.98 ns 0.8011 3360 B
a.Prop == b.Method(c.Prop, d.Prop, e.Prop) 7,552.6 ns 40.44 ns 37.83 ns 0.8392 3536 B
a.Prop == b.Prop + c.Prop 7,743.0 ns 77.14 ns 64.41 ns 0.8850 3736 B
a.Prop == $"{b.Prop}_{c.Prop}" 9,898.2 ns 20.60 ns 16.08 ns 0.9918 4176 B
a.Prop == (b.Prop + c.Prop).Method() 12,735.2 ns 71.11 ns 63.04 ns 0.9155 3880 B
Static.Method(() => Method(a.Prop)) 16,007.6 ns 131.27 ns 116.37 ns 1.2512 5344 B

sources

EBind Trigger πŸ”—
Method Mean Error StdDev Gen 0 Allocated
a.Prop == !b.Prop 69.32 ns 0.879 ns 0.822 ns 0.0305 128 B
a.Prop == b.Prop // INPC 72.64 ns 0.810 ns 0.758 ns 0.0305 128 B
a.Method() 79.61 ns 0.653 ns 0.611 ns 0.0248 104 B
a.Enum == b.Enum 80.15 ns 1.008 ns 0.943 ns 0.0362 152 B
a.Prop == b.Prop // EventHandler 82.12 ns 0.523 ns 0.489 ns 0.0076 32 B
a.Prop == (b.Prop || c.Prop) 85.83 ns 0.672 ns 0.628 ns 0.0362 152 B
a.Prop == (b.Prop && c.Prop) 88.38 ns 1.761 ns 1.884 ns 0.0362 152 B
a.Prop == (b.Prop == c.Prop) 104.50 ns 1.162 ns 1.087 ns 0.0362 152 B
a.Prop == (b.Prop ?? c.Prop) 115.92 ns 0.883 ns 0.826 ns 0.0134 56 B
a.Prop == b.Prop + c.Prop 122.91 ns 1.117 ns 1.045 ns 0.0200 84 B
a.Float == b.Int 128.55 ns 0.769 ns 0.719 ns 0.0362 152 B
a.Method(b.Prop.Method()) 128.60 ns 1.223 ns 1.144 ns 0.0324 136 B
a.Prop == Static.Method(b.Prop) 168.87 ns 2.003 ns 1.873 ns 0.0267 112 B
a.Prop == b.Method(c.Prop) 261.03 ns 1.913 ns 1.597 ns 0.0286 120 B
a.Prop == b.Method(c.Prop, d.Prop) 265.79 ns 3.130 ns 2.928 ns 0.0305 128 B
a.Prop == b.Method(c.Prop, d.Prop, e.Prop) 297.89 ns 2.282 ns 2.135 ns 0.0324 136 B
a.Prop == $"{b.Prop}_{c.Prop}" 351.95 ns 2.445 ns 2.042 ns 0.0362 152 B
a.Prop == (b.Prop + c.Prop).Method() 417.61 ns 3.072 ns 2.724 ns 0.0515 216 B

sources

Linking

The library is linker-safe internally, but some exposed APIs rely on Linq Expression trees and therefore the reflection which have always been hard to process for the mono linker.

Although linker can analyze expression trees and some reflection patterns pretty well, the following code units may not be mentioned in the code, appear unused and end up trimmed away:

  • Property setters
  • Events (which are not configured)

The most common solution for hinting the linker to keep a member is to imitate its usage with a dummy call and mark it with a [Preserve] attribute. Your project may already have a LinkerPleaseInclude.cs file for that purpose.

EBind.LinkerIncludeGenerator will generate such files for the mentioned members used in EBinding and there wont be any EBind-related linker issues in your project.
Adding its NuGet package is enough for the installation: NuGet

AOT Compilation PLATFORM

This library uses C# 9 function pointers to create fast delegates for property accessors. It's the safest solution for AOT compilation so far.
However, Xamarin.iOS AOT compiler used for device builds requires a direct indication of value-types as a generic type parameter for them.
All standard structs are pre-seeded.

If you came across an exception like that:

System.ExecutionEngineException:
  Attempting to JIT compile method 'object EBind.PropertyAccessors.PropertyAccessor`2<..., ...>:Get (object)' while running in aot-only mode.

please add a hint for the compiler:

EBind.Platform.AotCompilerHints.Include<MyStruct>(); // custom struct as a member
// or
EBind.Platform.AotCompilerHints.Include<MyStruct, PropertyType>(); // custom struct as a target

Contributions

If you've found an error, please file an issue.

Patches are encouraged and may be submitted by forking this project and submitting a pull request.
If your change is substantial, please raise an issue or start a discussion first.

Development

Requirements

  • C# 9 and .NET 5 workload
  • Xamarin.Android and Xamarin.iOS SDKs for device testing

Device Testing

Device tests may run manually from the test app UI or automatically with XHarness CLI.

Android:

xharness android test \
  --app="./EBind.Tests.Android/bin/Release/EBind.Tests.Android-Signed.apk" \
  --package-name="EBind.Tests.Android" \
  --instrumentation="ebind.tests.droid.xharness_instrumentation" \
  --device-arch="x86" \
  --output-directory="./EBind.Tests.Android/TestResults/xharness_android" \
  --verbosity

iOS:

xharness apple test \
  --app="./EBind.Tests.iOS/bin/iPhoneSimulator/Release/EBind.Tests.iOS.app" \
  --output-directory="./EBind.Tests.iOS/TestResults/xharness_apple" \
  --targets=ios-simulator-64 \
  --verbosity \
  -- -app-arg:xharness # to be passed in Application.Main(string[])

It's also a part of the github actions workflow. Check it out!

The Story

Once upon a time, there was a small but powerful library called Bind from the Praeclarum family.

No other library could be compared with it in terms of concision and beauty. Every developer was dreaming about using it in his project. But it had several problems and missed some optimizations. The bravest of us could dare to use it in production.

Beauty was stronger than fear and this is how another fork has begun.
Bugs were fixed, some optimizations were made but it was never enough. That library deserved to SHINE!

So...

  • Factory method Create replaced with a collection initializer to make bindings a separate block and utilize more syntax variations.
  • Binding chaining became redundant, operator && can be used in binding expressions now.
  • Added flags that alter binding behavior, as a part of the collection initializer.
  • Added support for binding properties/fields to functions (Action bindings).
  • Added support for binding functions and commands to events (Event bindings).
  • Binding triggers made configurable, all the basic ones for Xamarin are pre-defined.
  • Manual invalidation doesn't seem necessary anymore – removed.
  • Thread-safety improved by the means of dispatchers and concurrent collections.
  • Performance brought to another level with expression interpretation, fast delegates (incl. C# 9 function pointers), and a different architecture.
  • The library became more strict and informative in terms of exceptions and errors.
  • Added a Source Generator that hints the mono linker about code usage.
  • Unbind replaced by IDisposable, useful for aggregation (e.g. CompositeDisposable).

It ended up being completely rewritten with only some tests remained.

Credits

License

This project is licensed under the Apache License, Version 2.0 - see the LICENSE and NOTICE files for details.