Skip to content

Instantly share code, notes, and snippets.

@SteffenBlake
Last active August 3, 2023 04:57
Show Gist options
  • Save SteffenBlake/ace74a893d0b16c30a7eb2a42d6d9230 to your computer and use it in GitHub Desktop.
Save SteffenBlake/ace74a893d0b16c30a7eb2a42d6d9230 to your computer and use it in GitHub Desktop.
PropertyWatcherBase
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Runtime.CompilerServices;
using System.Transactions;
namespace Assets.Scripts.Imports
{
internal static class ExpressionExtensions
{
/// <summary>
/// Serialized a Lambda Member Expression into a stringified form
/// E.g. "m => m.Foo.Bar" will become "Foo.Bar"
/// </summary>
public static string GetFullMemberName(this LambdaExpression memberSelector)
{
var currentExpression = memberSelector.Body;
if (currentExpression is not MemberExpression memberExpression)
{
throw new Exception("Member Expressions only!");
}
var name = memberExpression.Member.Name;
while (memberExpression.Expression is MemberExpression next)
{
memberExpression = next;
name = memberExpression.Member.Name + "." + name;
}
return name;
}
}
/// <summary>
/// Transaction for caching of Events, with baked in callback
/// Will throw an exception if disposed of before <see cref="Commit"/> has been called
/// </summary>
public class PropertyChangeTransaction : IDisposable
{
public PropertyChangeTransaction(Action callback)
{
_callback = callback;
}
private readonly Action _callback;
public bool Committed { get; private set; }
public void Commit()
{
Committed = true;
_callback();
}
public void Dispose()
{
if (!Committed)
{
throw new TransactionException("Attempted to dispose of an uncommitted Transaction");
}
}
}
/// <summary>
/// Custom Delegate for Property Change Events, utilizing "in structs" for minimum memory usage and maximum performance
/// </summary>
public delegate void ReadonlyDelegateHandler<T>(in T propertyName);
/// <summary>
/// Base class for Property Watchers, inherit from this class and utilize Full Properties to
/// Leverage the <see cref="Mutate{T}"/> and <see cref="BindChild{T}"/> methods
/// </summary>
/// <typeparam name="TSelf"></typeparam>
public abstract class PropertyWatcherBase<TSelf>
where TSelf : PropertyWatcherBase<TSelf>
{
/// <summary>
/// Lower level Event for when a Property has been changed.
/// Best practice to utilize <see cref="BindTo"/> instead
/// </summary>
private event ReadonlyDelegateHandler<string> PropertyChanged;
private bool _inTransaction;
/// <summary>
/// Opens a Disposable Transaction for caching of Property Change Events
/// Utilize <see cref="PropertyChangeTransaction.Commit"/> to finalize the transaction
/// And trigger all distinct events at once.
/// </summary>
public PropertyChangeTransaction OpenTransaction()
{
if (_inTransaction)
{
throw new TransactionException("Attempted to open a nested transaction");
}
_inTransaction = true;
return new PropertyChangeTransaction(CloseTransaction);
}
private readonly HashSet<string> _changedProperties = new ();
private void CloseTransaction()
{
_inTransaction = false;
// Copy out events in case downstream event needs to open a transaction
var cachedProperties = _changedProperties.ToList();
_changedProperties.Clear();
foreach (var prop in cachedProperties)
{
PropertyChanged?.Invoke(prop);
}
}
protected void BindChild<T>(ref T target, T value, [CallerMemberName] string name = null)
where T : PropertyWatcherBase<T>
{
if (name == null)
{
throw new ArgumentException(nameof(name));
}
target = value;
target.PropertyChanged += (in string propertyName) =>
{
var prop = name;
var full = $"{name}.{propertyName}";
if (_inTransaction)
{
_changedProperties.Add(prop);
_changedProperties.Add(full);
}
else
{
PropertyChanged?.Invoke(prop);
PropertyChanged?.Invoke(full);
}
};
}
protected void Mutate<T>(ref T target, T value, [CallerMemberName] string name = null)
{
if (name == null)
{
throw new ArgumentException(nameof(name));
}
if (target?.Equals(value) ?? value == null)
{
return;
}
target = value;
if (_inTransaction)
{
_changedProperties.Add(name);
}
else
{
PropertyChanged?.Invoke(name);
}
}
public void BindTo(Action method)
{
PropertyChanged += (in string _) =>
{
method();
};
}
public void BindTo<T>(Expression<Func<TSelf, T>> selector, Action method)
{
BindTo(selector, (in T _) => method());
}
public void BindTo<T>(Expression<Func<TSelf, T>> selector, ReadonlyDelegateHandler<T> method)
{
var fmn = selector.GetFullMemberName();
var compiled = selector.Compile();
var concrete = (TSelf)this;
PropertyChanged += (in string prop) =>
{
if (prop != fmn)
{
return;
}
method(compiled(concrete));
};
}
}
}
@SteffenBlake
Copy link
Author

SteffenBlake commented Jul 30, 2023

Example Implementation:

public class GameState : PropertyWatcherBase<GameState>
{
    // PlayerState is also a "child" PropertyWatcherBase object that we are injecting into the constructor
    public GameState(PlayerState playerState)
    {
        PlayerState = playerState;
    }
    
    private PlayerState player;
    public PlayerState Player
    {
        get => player;
        // Note that Child watchers should be private set
        private set => BindChild(ref player, value);
    }

    private int someInt;
    public int SomeInt
    {
        get => someInt;
        set => Mutate(ref someInt, value);
    }
}

Example Subscription to Events:

GameState.Bind(g => g.SomeInt, OnSomeInt);

private void OnSomeInt(in int value)
{
    ...
}

Example Mutation (Yep, its that easy!):

// This wont fire off events if `SomeInt` is already equal to 4
GameState.SomeInt = 4;

Example Transaction:

using var txn = GameState.OpenTransaction();
// The events will hold off and not fire while inside a transaction...
GameState.Value1 = something;
GameState.Value2 = somethingElse;
GameState.Value3 = anotherThing;
// Invoking Commit will cause all distinct events to now fire off
// repeats will not occur within the scope of the txn
txn.Commit();

Notes

  • IL2CPP compatible!
  • in variables reduce memory allocation for high performance!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment