From a0b6ec3dfdf63e6d1b6a0b01ae53f62d4e788de1 Mon Sep 17 00:00:00 2001 From: bd_ Date: Tue, 2 Jul 2024 11:35:50 +0900 Subject: [PATCH] feat: add extract-observe API, respond to animation mode changes (#279) --- CHANGELOG.md | 2 + Editor/ChangeStream/ListenerSet.cs | 36 ++-- Editor/ChangeStream/ObjectWatcher.cs | 155 ++++++++++-------- Editor/ChangeStream/PropertyMonitor.cs | 149 +++++++++++++++++ Editor/ChangeStream/PropertyMonitor.cs.meta | 3 + Editor/ChangeStream/ShadowGameObject.cs | 21 ++- Editor/ChangeStream/memo.txt | 4 + Editor/ChangeStream/memo.txt.meta | 3 + .../ComputeContext/GlobalQueries.cs | 9 +- .../ComputeContext/SingleObjectQueries.cs | 99 +++++------ Editor/RQ-Unity.meta | 3 - RQ-Core/ComputeContext.cs | 4 +- RQ-Core/assembly-info.cs | 3 +- .../RQ-EditorTests/ShadowHierarchyTest.cs | 141 ++++++++++------ ...ev.ndmf.reactive-query.tests.editor.asmdef | 20 --- ...mf.reactive-query.tests.editor.asmdef.meta | 3 - UnitTests~/nadena.dev.ndmf.UnitTests.asmdef | 8 +- 17 files changed, 418 insertions(+), 245 deletions(-) create mode 100644 Editor/ChangeStream/PropertyMonitor.cs create mode 100644 Editor/ChangeStream/PropertyMonitor.cs.meta create mode 100644 Editor/ChangeStream/memo.txt create mode 100644 Editor/ChangeStream/memo.txt.meta delete mode 100644 Editor/RQ-Unity.meta delete mode 100644 UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef delete mode 100644 UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 6268734c..e03c28cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- [#279] Added an `Observe` overload which checks for changes to an extracted value, to help respond to animation mode + changes ### Fixed diff --git a/Editor/ChangeStream/ListenerSet.cs b/Editor/ChangeStream/ListenerSet.cs index 0ee71cb7..1479f07b 100644 --- a/Editor/ChangeStream/ListenerSet.cs +++ b/Editor/ChangeStream/ListenerSet.cs @@ -1,6 +1,7 @@ #region using System; +using UnityEditor; #endregion @@ -8,22 +9,19 @@ namespace nadena.dev.ndmf.rq.unity.editor { internal class Listener : IDisposable { - private ListenerSet _owner; internal Listener _next, _prev; - private readonly ListenerSet.Invokee _callback; - private readonly WeakReference _param; + private readonly ListenerSet.Filter _filter; + private readonly WeakReference _ctx; internal Listener( - ListenerSet owner, - ListenerSet.Invokee callback, - object param + ListenerSet.Filter filter, + ComputeContext ctx ) { - _owner = owner; _next = _prev = this; - _callback = callback; - _param = new WeakReference(param); + _filter = filter; + _ctx = ctx == null ? null : new WeakReference(ctx); } public void Dispose() @@ -35,12 +33,12 @@ public void Dispose() } _next = _prev = null; - _param.SetTarget(null); + _ctx.SetTarget(null); } internal void MaybePrune() { - if (!_param.TryGetTarget(out _)) + if (!_ctx.TryGetTarget(out var ctx) || ctx.IsInvalidated) { Dispose(); } @@ -49,22 +47,28 @@ internal void MaybePrune() // Invoked under lock(_owner) internal void MaybeFire(T info) { - if (!_param.TryGetTarget(out var target) || _callback(target, info)) + if (!_ctx.TryGetTarget(out var ctx) || ctx.IsInvalidated) { Dispose(); } + else if (_filter(info)) + { + ctx.Invalidate(); + EditorApplication.delayCall += SceneView.RepaintAll; + Dispose(); + } } } internal class ListenerSet { - public delegate bool Invokee(object target, T info); + public delegate bool Filter(T info); private Listener _head; public ListenerSet() { - _head = new Listener(this, (object _, T _) => false, null); + _head = new Listener(_ => false, null); _head._next = _head._prev = _head; } @@ -73,9 +77,9 @@ public bool HasListeners() return _head._next != _head; } - public IDisposable Register(Invokee callback, object param) + public IDisposable Register(Filter filter, ComputeContext ctx) { - var listener = new Listener(this, callback, param); + var listener = new Listener(filter, ctx); listener._next = _head._next; listener._prev = _head; diff --git a/Editor/ChangeStream/ObjectWatcher.cs b/Editor/ChangeStream/ObjectWatcher.cs index 705d60c8..213d5122 100644 --- a/Editor/ChangeStream/ObjectWatcher.cs +++ b/Editor/ChangeStream/ObjectWatcher.cs @@ -1,6 +1,7 @@ #region using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Threading; @@ -58,8 +59,9 @@ internal sealed class ObjectWatcher // Listeners come in two flavors: object listeners (asset/component watches as well as parent watches), and // component search listeners, which can be local or recursive. - public static ObjectWatcher Instance { get; private set; } = new ObjectWatcher(); - internal ShadowHierarchy Hierarchy = new ShadowHierarchy(); + public static ObjectWatcher Instance { get; } = new(); + internal ShadowHierarchy Hierarchy = new(); + internal PropertyMonitor PropertyMonitor = new(); private readonly SynchronizationContext _syncContext = SynchronizationContext.Current; private readonly int threadId = Thread.CurrentThread.ManagedThreadId; @@ -73,31 +75,22 @@ private static void Init() SceneManager.sceneLoaded += (_, _) => Instance.Hierarchy.InvalidateAll(); SceneManager.sceneUnloaded += _ => Instance.Hierarchy.InvalidateAll(); SceneManager.activeSceneChanged += (_, _) => Instance.Hierarchy.InvalidateAll(); + Instance.PropertyMonitor.MaybeStartRefreshTimer(); } - public ImmutableList MonitorSceneRoots(out IDisposable cancel, Action callback, T target) - where T : class + public ImmutableList MonitorSceneRoots(ComputeContext ctx) { ImmutableList rootSet = GetRootSet(); // TODO scene load callbacks - cancel = Hierarchy.RegisterRootSetListener((t, e) => + var cancel = Hierarchy.RegisterRootSetListener(_ => { ImmutableList newRootSet = GetRootSet(); - if (!newRootSet.SequenceEqual(rootSet)) - { - InvokeCallback(callback, t); + return !newRootSet.SequenceEqual(rootSet); + }, ctx); - return true; - } - else - { - return false; - } - }, target); - - cancel = CancelWrapper(cancel); + BindCancel(ctx, cancel); return rootSet; } @@ -125,63 +118,82 @@ private ImmutableList GetRootSet() return roots.ToImmutable(); } - public void MonitorObjectPath(out IDisposable cancel, Transform t, Action callback, T target) - where T : class + public void MonitorObjectPath(Transform t, ComputeContext ctx) { - cancel = Hierarchy.RegisterGameObjectListener(t.gameObject, (t, e) => + var cancel = Hierarchy.RegisterGameObjectListener(t.gameObject, e => { switch (e) { case HierarchyEvent.PathChange: case HierarchyEvent.ForceInvalidate: - InvokeCallback(callback, t); return true; default: return false; } - }, target); + }, ctx); + Hierarchy.EnablePathMonitoring(t.gameObject); + BindCancel(ctx, cancel); + } + + private void BindCancel(ComputeContext ctx, IDisposable cancel) + { cancel = CancelWrapper(cancel); + ctx.OnInvalidate.ContinueWith(_ => cancel.Dispose()); } - public void MonitorObjectProps(out IDisposable cancel, UnityObject obj, Action callback, T target) - where T : class + public R MonitorObjectProps(T obj, ComputeContext ctx, Func extract, Func compare, + bool usePropMonitor) + where T : UnityObject { - cancel = default; + var curVal = extract(obj); + + if (obj == null) return curVal; + if (compare == null) compare = EqualityComparer.Default.Equals; if (obj is GameObject go) { - cancel = Hierarchy.RegisterGameObjectListener(go, (t, e) => + var cancel = Hierarchy.RegisterGameObjectListener(go, e => { switch (e) { case HierarchyEvent.ObjectDirty: case HierarchyEvent.ForceInvalidate: - InvokeCallback(callback, t); - return true; + return obj == null || !compare(curVal, extract(obj)); default: return false; } - }, target); + }, ctx); + + BindCancel(ctx, cancel); } else { - cancel = Hierarchy.RegisterObjectListener(obj, (t, e) => + var cancel = Hierarchy.RegisterObjectListener(obj, e => { switch (e) { case HierarchyEvent.ObjectDirty: case HierarchyEvent.ForceInvalidate: - InvokeCallback(callback, t); - return true; + return obj == null || !compare(curVal, extract(obj)); default: return false; } - }, target); + }, ctx); + + BindCancel(ctx, cancel); + + if (usePropMonitor) + { + var propsListeners = PropertyMonitor.MonitorObjectProps(obj); + propsListeners.Register(_ => obj == null || !compare(curVal, extract(obj)), ctx); + + BindCancel(ctx, cancel); + } } - cancel = CancelWrapper(cancel); + return curVal; } private static void InvokeCallback(Action callback, object t) where T : class @@ -196,19 +208,31 @@ private static void InvokeCallback(Action callback, object t) where T : cl } } - public C[] MonitorGetComponents(out IDisposable cancel, GameObject obj, Action callback, T target, - Func get0, bool includeChildren) where T : class + + private static bool InvokeCallback(Func callback, object t) where T : class { - cancel = default; + try + { + return callback((T)t); + } + catch (Exception e) + { + Debug.LogException(e); + return true; + } + } + public C[] MonitorGetComponents(GameObject obj, ComputeContext ctx, + Func get0, bool includeChildren) where C : Component + { Func get = () => get0().Where(c => - (c as Component)?.hideFlags == 0 && - (c as Component)?.gameObject.hideFlags == 0 + c?.hideFlags == 0 && + c?.gameObject.hideFlags == 0 ).ToArray(); C[] components = get(); - Hierarchy.RegisterGameObjectListener(obj, (t, e) => + var cancel = Hierarchy.RegisterGameObjectListener(obj, e => { if (e == HierarchyEvent.ChildComponentsChanged && !includeChildren) return false; @@ -217,56 +241,38 @@ public C[] MonitorGetComponents(out IDisposable cancel, GameObject obj, Ac case HierarchyEvent.ChildComponentsChanged: case HierarchyEvent.SelfComponentsChanged: case HierarchyEvent.ForceInvalidate: - if (obj != null && components.SequenceEqual(get())) - { - return false; - } - else - { - InvokeCallback(callback, t); - return true; - } + return obj == null || !components.SequenceEqual(get()); default: return false; } - }, target); + }, ctx); if (includeChildren) Hierarchy.EnableComponentMonitoring(obj); - cancel = CancelWrapper(cancel); + BindCancel(ctx, cancel); return components; } - public C MonitorGetComponent(out IDisposable cancel, GameObject obj, Action callback, T target, - Func get) where T : class + public C MonitorGetComponent(GameObject obj, ComputeContext ctx, + Func get) where C : Component { - cancel = default; - C component = get(); - Hierarchy.RegisterGameObjectListener(obj, (t, e) => + var cancel = Hierarchy.RegisterGameObjectListener(obj, e => { switch (e) { case HierarchyEvent.SelfComponentsChanged: case HierarchyEvent.ChildComponentsChanged: case HierarchyEvent.ForceInvalidate: - if (obj != null && ReferenceEquals(component, get())) - { - return false; - } - else - { - InvokeCallback(callback, t); - return true; - } + return obj == null || !ReferenceEquals(component, get()); default: return false; } - }, target); + }, ctx); - cancel = CancelWrapper(cancel); + BindCancel(ctx, cancel); return component; } @@ -275,9 +281,9 @@ class WrappedDisposable : IDisposable { private readonly int _targetThread; private readonly SynchronizationContext _syncContext; - private IDisposable _orig; + private IDisposable[] _orig; - public WrappedDisposable(IDisposable orig, SynchronizationContext syncContext) + public WrappedDisposable(IDisposable[] orig, SynchronizationContext syncContext) { _orig = orig; _targetThread = Thread.CurrentThread.ManagedThreadId; @@ -292,20 +298,25 @@ public void Dispose() if (Thread.CurrentThread.ManagedThreadId == _targetThread) { - _orig.Dispose(); + DoDispose(); } else { var orig = _orig; - _syncContext.Post(_ => orig.Dispose(), null); + _syncContext.Post(_ => DoDispose(), null); } _orig = null; } } + + private void DoDispose() + { + foreach (var orig in _orig) orig.Dispose(); + } } - private IDisposable CancelWrapper(IDisposable orig) + private IDisposable CancelWrapper(params IDisposable[] orig) { return new WrappedDisposable(orig, _syncContext); } diff --git a/Editor/ChangeStream/PropertyMonitor.cs b/Editor/ChangeStream/PropertyMonitor.cs new file mode 100644 index 00000000..1b444059 --- /dev/null +++ b/Editor/ChangeStream/PropertyMonitor.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; +using UnityEngine; +using UnityEngine.Profiling; +using Stopwatch = System.Diagnostics.Stopwatch; +using Object = UnityEngine.Object; + +namespace nadena.dev.ndmf.rq.unity.editor +{ + internal class PropertyMonitor + { + private static readonly long RECHECK_TIMESLICE = 2 * (Stopwatch.Frequency / 1000); + private Task _activeRefreshTask = Task.CompletedTask; + private Task _pendingRefreshTask = Task.CompletedTask; + + internal void MaybeStartRefreshTimer() + { + _activeRefreshTask = Task.Factory.StartNew( + CheckAllObjectsLoop, + CancellationToken.None, + TaskCreationOptions.None, + TaskScheduler.FromCurrentSynchronizationContext() + ); + } + + public enum PropertyMonitorEvent + { + PropsUpdated + } + + private class Registration + { + internal readonly ListenerSet _listeners = new(); + internal readonly Object _obj; + + public Registration(Object obj) + { + _obj = obj; + } + } + + private readonly SortedDictionary _registeredObjects = new(); + + public ListenerSet MonitorObjectProps(Object obj) + { + if (_registeredObjects.TryGetValue(obj.GetInstanceID(), out var reg)) return reg._listeners; + + reg = new Registration(obj); + + _registeredObjects.Add(obj.GetInstanceID(), reg); + + return reg._listeners; + } + + private async Task CheckAllObjectsLoop() + { + while (true) + { + await CheckAllObjects(); + await NextFrame(); + } + } + + public async Task CheckAllObjects() + { + try + { + Profiler.BeginSample("PropertyMonitor.CheckAllObjects"); + var toRemove = new List(); + var sw = new Stopwatch(); + sw.Start(); + + + foreach (var pair in _registeredObjects.ToList()) + { + var (instanceId, reg) = pair; + + // Wake up all listeners to see if their monitored value has changed + reg._listeners.Fire(PropertyMonitorEvent.PropsUpdated); + + if (!reg._listeners.HasListeners() || reg._obj == null) toRemove.Add(instanceId); + + if (sw.ElapsedTicks > RECHECK_TIMESLICE) + { + Profiler.EndSample(); + await Yield(); + + Profiler.BeginSample("PropertyMonitor.CheckAllObjects.Continued"); + sw.Restart(); + } + } + + foreach (var id in toRemove) _registeredObjects.Remove(id); + + Profiler.EndSample(); + } + catch (Exception e) + { + Debug.LogException(e); + } + } + + private static async Task Yield() + { + var tcs = new TaskCompletionSource(); + EditorApplication.delayCall += () => { tcs.SetResult(true); }; + + await tcs.Task; + } + + private static async Task NextFrame() + { + var tcs = new TaskCompletionSource(); + + // Waking up the editor application every frame (forcing frame processing) can be heavyweight, so + // only do it if the editor is focused (so the user is actively interacting) or we're in animation mode + // (which could change things without triggering change events) + if (EditorApplication.isFocused || AnimationMode.InAnimationMode()) + { + EditorApplication.CallbackFunction cf = default; + + cf = () => + { + tcs.SetResult(true); + EditorApplication.update -= cf; + }; + + EditorApplication.update += cf; + } + else + { + // Wait for focus + Action focusFunc = default; + focusFunc = _ => + { + tcs.SetResult(true); + EditorApplication.focusChanged -= focusFunc; + }; + EditorApplication.focusChanged += focusFunc; + } + + await tcs.Task; + } + } +} \ No newline at end of file diff --git a/Editor/ChangeStream/PropertyMonitor.cs.meta b/Editor/ChangeStream/PropertyMonitor.cs.meta new file mode 100644 index 00000000..9abaae68 --- /dev/null +++ b/Editor/ChangeStream/PropertyMonitor.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f0a5c06b9c284af1a4bdbf6faffeb357 +timeCreated: 1719803736 \ No newline at end of file diff --git a/Editor/ChangeStream/ShadowGameObject.cs b/Editor/ChangeStream/ShadowGameObject.cs index 2d28408e..fc42db88 100644 --- a/Editor/ChangeStream/ShadowGameObject.cs +++ b/Editor/ChangeStream/ShadowGameObject.cs @@ -55,25 +55,28 @@ internal class ShadowHierarchy int lastPruned = Int32.MinValue; - internal IDisposable RegisterRootSetListener(ListenerSet.Invokee invokee, object target) + internal IDisposable RegisterRootSetListener(ListenerSet.Filter filter, ComputeContext ctx) { - return _rootSetListener.Register(invokee, target); + return _rootSetListener.Register(filter, ctx); } - internal IDisposable RegisterGameObjectListener(GameObject targetObject, - ListenerSet.Invokee invokee, - object target) + internal IDisposable RegisterGameObjectListener( + GameObject targetObject, + ListenerSet.Filter filter, + ComputeContext ctx + ) { if (targetObject == null) return new NullDisposable(); ShadowGameObject shadowObject = ActivateShadowObject(targetObject); - return shadowObject._listeners.Register(invokee, target); + return shadowObject._listeners.Register(filter, ctx); } internal IDisposable RegisterObjectListener(UnityObject targetComponent, - ListenerSet.Invokee invokee, - object target) + ListenerSet.Filter filter, + ComputeContext ctx + ) { if (targetComponent == null) return new NullDisposable(); @@ -83,7 +86,7 @@ internal IDisposable RegisterObjectListener(UnityObject targetComponent, _otherObjects[targetComponent.GetInstanceID()] = shadowComponent; } - return shadowComponent._listeners.Register(invokee, target); + return shadowComponent._listeners.Register(filter, ctx); } internal class NullDisposable : IDisposable diff --git a/Editor/ChangeStream/memo.txt b/Editor/ChangeStream/memo.txt new file mode 100644 index 00000000..1797f16c --- /dev/null +++ b/Editor/ChangeStream/memo.txt @@ -0,0 +1,4 @@ +class ComputeContext { + T ObserveState(Object obj, Func func); + Transform ObserveTransform(GameObject object); +} diff --git a/Editor/ChangeStream/memo.txt.meta b/Editor/ChangeStream/memo.txt.meta new file mode 100644 index 00000000..037da1c3 --- /dev/null +++ b/Editor/ChangeStream/memo.txt.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: fe235f4ce89f481c9edb788a51d0e150 +timeCreated: 1719808215 \ No newline at end of file diff --git a/Editor/PreviewSystem/ComputeContext/GlobalQueries.cs b/Editor/PreviewSystem/ComputeContext/GlobalQueries.cs index a9cc0c09..cba8441e 100644 --- a/Editor/PreviewSystem/ComputeContext/GlobalQueries.cs +++ b/Editor/PreviewSystem/ComputeContext/GlobalQueries.cs @@ -17,14 +17,7 @@ public static partial class ComputeContextQueries /// public static ImmutableList GetSceneRoots(this ComputeContext ctx) { - var invalidate = ctx.Invalidate; - var onInvalidate = ctx.OnInvalidate; - - var roots = ObjectWatcher.Instance.MonitorSceneRoots(out var dispose, _ => invalidate(), - onInvalidate); - onInvalidate.ContinueWith(_ => dispose.Dispose()); - - return roots; + return ObjectWatcher.Instance.MonitorSceneRoots(ctx); } /// diff --git a/Editor/PreviewSystem/ComputeContext/SingleObjectQueries.cs b/Editor/PreviewSystem/ComputeContext/SingleObjectQueries.cs index d967e393..807b13f5 100644 --- a/Editor/PreviewSystem/ComputeContext/SingleObjectQueries.cs +++ b/Editor/PreviewSystem/ComputeContext/SingleObjectQueries.cs @@ -17,7 +17,9 @@ public static partial class ComputeContextQueries /// Monitors a given Unity object for changes, and recomputes when changes are detected. /// /// This will recompute when properties of the object change, when the object is destroyed, or (in the case of - /// a GameObject), when the children of the GameObject changed. + /// a GameObject), when the children of the GameObject changed. However, it will only respond to changes which + /// are recorded in the Undo system; in particular, it will not respond to animation previews. This is provided + /// to deal with cases where asset changes can't be reported in any other way. /// /// /// @@ -25,15 +27,30 @@ public static partial class ComputeContextQueries /// public static T Observe(this ComputeContext ctx, T obj) where T : Object { - var invalidate = ctx.Invalidate; - var onInvalidate = ctx.OnInvalidate; - - ObjectWatcher.Instance.MonitorObjectProps(out var dispose, obj, a => a(), invalidate); - onInvalidate.ContinueWith(_ => dispose.Dispose()); + ObjectWatcher.Instance.MonitorObjectProps(obj, ctx, _ => 0, (_, _) => false, false); return obj; } + /// + /// Monitors a given Unity object for changes, and recomputes when changes are detected. The `extract` function + /// is used to extract the specific information of interest from the object, and the `compare` function (or, + /// if not provided, the default equality comparer) is used to determine if the extracted information has changed. + /// + /// + /// + /// + /// + /// + /// + /// + public static R Observe(this ComputeContext ctx, T obj, Func extract, + Func compare = null) + where T : Object + { + return ObjectWatcher.Instance.MonitorObjectProps(obj, ctx, extract, compare, true); + } + /// /// Observes the full path from the scene root to the given transform. The calling computation will be /// re-executed if any of the objects in this path are reparented. @@ -43,11 +60,7 @@ public static T Observe(this ComputeContext ctx, T obj) where T : Object /// An enumerable of transforms in the path, starting from the leaf. public static IEnumerable ObservePath(this ComputeContext ctx, Transform obj) { - var invalidate = ctx.Invalidate; - var onInvalidate = ctx.OnInvalidate; - - ObjectWatcher.Instance.MonitorObjectPath(out var dispose, obj, i => i(), invalidate); - onInvalidate.ContinueWith(_ => dispose.Dispose()); + ObjectWatcher.Instance.MonitorObjectPath(obj, ctx); return FollowPath(obj); @@ -70,7 +83,12 @@ public static Transform ObserveTransformPosition(this ComputeContext ctx, Transf { foreach (var node in ctx.ObservePath(t)) { - ctx.Observe(node); + ctx.Observe(node, obj => (obj.localPosition, obj.localRotation, obj.localScale), (a, b) => + { + return Vector3.Distance(a.Item1, b.Item1) > 0.0001f || + Quaternion.Angle(a.Item2, b.Item2) > 0.0001f || + Vector3.Distance(a.Item3, b.Item3) > 0.0001f; + }); } return t; @@ -84,7 +102,7 @@ public static Transform ObserveTransformPosition(this ComputeContext ctx, Transf /// public static bool ActiveInHierarchy(this ComputeContext ctx, GameObject obj) { - ObservePath(ctx, obj.transform); + foreach (var node in ObservePath(ctx, obj.transform)) ctx.Observe(node, n => n.gameObject.activeSelf); return obj.activeInHierarchy; } @@ -96,61 +114,39 @@ public static bool ActiveInHierarchy(this ComputeContext ctx, GameObject obj) /// public static bool ActiveAndEnabled(this ComputeContext ctx, Behaviour c) { - return ActiveInHierarchy(ctx, c.gameObject) && ctx.Observe(c).enabled; + return ActiveInHierarchy(ctx, c.gameObject) && ctx.Observe(c, c2 => c2.enabled); } public static C GetComponent(this ComputeContext ctx, GameObject obj) where C : Component { - var invalidate = ctx.Invalidate; - var onInvalidate = ctx.OnInvalidate; + if (obj == null) return null; - var c = ObjectWatcher.Instance.MonitorGetComponent(out var dispose, obj, a => a(), invalidate, + return ObjectWatcher.Instance.MonitorGetComponent(obj, ctx, () => obj != null ? obj.GetComponent() : null); - onInvalidate.ContinueWith(_ => dispose.Dispose()); - - return c; } public static Component GetComponent(this ComputeContext ctx, GameObject obj, Type type) { if (obj == null) return null; - var invalidate = ctx.Invalidate; - var onInvalidate = ctx.OnInvalidate; - - var c = ObjectWatcher.Instance.MonitorGetComponent(out var dispose, obj, a => a(), invalidate, - () => obj != null ? obj.GetComponent(type) : (Component)null); - onInvalidate.ContinueWith(_ => dispose.Dispose()); - - return c; + return ObjectWatcher.Instance.MonitorGetComponent(obj, ctx, + () => obj != null ? obj.GetComponent(type) : null); } public static C[] GetComponents(this ComputeContext ctx, GameObject obj) where C : Component { if (obj == null) return Array.Empty(); - var invalidate = ctx.Invalidate; - var onInvalidate = ctx.OnInvalidate; - - var c = ObjectWatcher.Instance.MonitorGetComponents(out var dispose, obj, a => a(), invalidate, + return ObjectWatcher.Instance.MonitorGetComponents(obj, ctx, () => obj != null ? obj.GetComponents() : Array.Empty(), false); - onInvalidate.ContinueWith(_ => dispose.Dispose()); - - return c; } public static Component[] GetComponents(this ComputeContext ctx, GameObject obj, Type type) { if (obj == null) return Array.Empty(); - var invalidate = ctx.Invalidate; - var onInvalidate = ctx.OnInvalidate; - - var c = ObjectWatcher.Instance.MonitorGetComponents(out var dispose, obj, a => a(), invalidate, + return ObjectWatcher.Instance.MonitorGetComponents(obj, ctx, () => obj != null ? obj.GetComponents(type) : Array.Empty(), false); - onInvalidate.ContinueWith(_ => dispose.Dispose()); - - return c; } public static C[] GetComponentsInChildren(this ComputeContext ctx, GameObject obj, bool includeInactive) @@ -158,15 +154,8 @@ public static C[] GetComponentsInChildren(this ComputeContext ctx, GameObject { if (obj == null) return Array.Empty(); - var invalidate = ctx.Invalidate; - var onInvalidate = ctx.OnInvalidate; - - var c = ObjectWatcher.Instance.MonitorGetComponents(out var dispose, obj, a => a(), invalidate, - () => { return obj != null ? obj.GetComponentsInChildren(includeInactive) : Array.Empty(); }, - true); - onInvalidate.ContinueWith(_ => dispose.Dispose()); - - return c; + return ObjectWatcher.Instance.MonitorGetComponents(obj, ctx, + () => obj != null ? obj.GetComponentsInChildren(includeInactive) : Array.Empty(), true); } public static Component[] GetComponentsInChildren(this ComputeContext ctx, GameObject obj, Type type, @@ -174,15 +163,9 @@ public static Component[] GetComponentsInChildren(this ComputeContext ctx, GameO { if (obj == null) return Array.Empty(); - var invalidate = ctx.Invalidate; - var onInvalidate = ctx.OnInvalidate; - - var c = ObjectWatcher.Instance.MonitorGetComponents(out var dispose, obj, a => a(), invalidate, + return ObjectWatcher.Instance.MonitorGetComponents(obj, ctx, () => obj != null ? obj.GetComponentsInChildren(type, includeInactive) : Array.Empty(), true); - onInvalidate.ContinueWith(_ => dispose.Dispose()); - - return c; } } } \ No newline at end of file diff --git a/Editor/RQ-Unity.meta b/Editor/RQ-Unity.meta deleted file mode 100644 index ca32b978..00000000 --- a/Editor/RQ-Unity.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: b2611461f0a3404db655377ab50d678c -timeCreated: 1717367851 \ No newline at end of file diff --git a/RQ-Core/ComputeContext.cs b/RQ-Core/ComputeContext.cs index 2c3015da..2b9fecb6 100644 --- a/RQ-Core/ComputeContext.cs +++ b/RQ-Core/ComputeContext.cs @@ -25,7 +25,9 @@ public sealed class ComputeContext /// guarantee that the underlying computation (e.g. ReactiveValue) is going to be recomputed. /// internal Task OnInvalidate { get; } - + + public bool IsInvalidated => OnInvalidate.IsCompleted; + internal ComputeContext() { Invalidate = () => _invalidater.TrySetResult(null); diff --git a/RQ-Core/assembly-info.cs b/RQ-Core/assembly-info.cs index b88f465f..5da224c4 100644 --- a/RQ-Core/assembly-info.cs +++ b/RQ-Core/assembly-info.cs @@ -4,4 +4,5 @@ #endregion -[assembly: InternalsVisibleTo("nadena.dev.ndmf")] \ No newline at end of file +[assembly: InternalsVisibleTo("nadena.dev.ndmf")] +[assembly: InternalsVisibleTo("nadena.dev.ndmf.UnitTests")] \ No newline at end of file diff --git a/UnitTests~/RQ-EditorTests/ShadowHierarchyTest.cs b/UnitTests~/RQ-EditorTests/ShadowHierarchyTest.cs index 84bdc1d6..2bd74def 100644 --- a/UnitTests~/RQ-EditorTests/ShadowHierarchyTest.cs +++ b/UnitTests~/RQ-EditorTests/ShadowHierarchyTest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using nadena.dev.ndmf.rq; using nadena.dev.ndmf.rq.unity.editor; using NUnit.Framework; using UnityEngine; @@ -40,24 +41,54 @@ public void TestBasic() var gameObject = c(new GameObject("tmp")); - var target = new object(); + var ctx = new ComputeContext(); bool wasFired = false; + bool doInvalidate = false; - shadow.RegisterGameObjectListener(gameObject, (o, e) => + shadow.RegisterGameObjectListener(gameObject, e => { - Assert.AreEqual(target, o); Assert.AreEqual(HierarchyEvent.ObjectDirty, e); wasFired = true; - return false; - }, target); + return doInvalidate; + }, ctx); shadow.FireObjectChangeNotification(gameObject.GetInstanceID()); Assert.IsTrue(wasFired); + Assert.IsFalse(ctx.IsInvalidated); wasFired = false; + doInvalidate = true; shadow.FireObjectChangeNotification(gameObject.GetInstanceID()); Assert.IsTrue(wasFired); + Assert.IsTrue(ctx.IsInvalidated); + + wasFired = false; + shadow.FireObjectChangeNotification(gameObject.GetInstanceID()); + Assert.IsFalse(wasFired); + } + + [Test] + public void ListenerDeregisteredWhenContextInvalidated() + { + var shadow = new ShadowHierarchy(); + + var gameObject = c(new GameObject("tmp")); + + var ctx = new ComputeContext(); + bool wasFired = false; + + shadow.RegisterGameObjectListener(gameObject, e => + { + wasFired = true; + return true; + }, ctx); + + ctx.Invalidate(); + + shadow.FireObjectChangeNotification(gameObject.GetInstanceID()); + + Assert.IsFalse(wasFired); } [Test] @@ -67,13 +98,13 @@ public void ListenerDeregisteredAfterTrueReturn() var gameObject = c(new GameObject("tmp")); int count = 0; - var target = new object(); + var ctx = new ComputeContext(); - shadow.RegisterGameObjectListener(gameObject, (o, e) => + shadow.RegisterGameObjectListener(gameObject, (e) => { count++; return true; - }, target); + }, ctx); shadow.FireObjectChangeNotification(gameObject.GetInstanceID()); shadow.FireObjectChangeNotification(gameObject.GetInstanceID()); @@ -83,11 +114,11 @@ public void ListenerDeregisteredAfterTrueReturn() void MakeListener__WhenTargetGCd_ListenerIsRemoved(ShadowHierarchy h, GameObject gameObject, bool[] wasFired) { - h.RegisterGameObjectListener(gameObject, (o,e ) => + h.RegisterGameObjectListener(gameObject, e => { wasFired[0] = true; return false; - }, new object()); + }, new ComputeContext()); } [Test] public void WhenTargetGCd_ListenerIsRemoved() @@ -96,7 +127,6 @@ public void WhenTargetGCd_ListenerIsRemoved() var gameObject = c(new GameObject("tmp")); - var target = new object(); bool[] wasFired = {false}; // Ensure we don't have extra references on the stack still by creating the target object in a separate @@ -118,16 +148,14 @@ public void WhenDisposed_ListenerIsRemoved() var gameObject = c(new GameObject("tmp")); - var target = new object(); + var ctx = new ComputeContext(); bool wasFired = false; - var listener = shadow.RegisterGameObjectListener(gameObject, (o, e) => + var listener = shadow.RegisterGameObjectListener(gameObject, e => { - Assert.AreEqual(target, e); - Assert.AreEqual(HierarchyEvent.ObjectDirty, o); wasFired = true; return false; - }, target); + }, ctx); listener.Dispose(); shadow.FireObjectChangeNotification(gameObject.GetInstanceID()); @@ -142,10 +170,10 @@ public void PathNotifications_GeneratedWhenImmediateParentChanged() var p1 = c("p1"); var p2 = c("p2"); - var target = new object(); + var target = new ComputeContext(); bool wasFired = false; - shadow.RegisterGameObjectListener(p2, (o, e) => + shadow.RegisterGameObjectListener(p2, e => { if (e == HierarchyEvent.PathChange) { @@ -171,12 +199,12 @@ public void PathNotifications_GeneratedWhenGrandparentChanged() var p2 = c("p2"); var p3 = c("p3"); - var target = new object(); + var target = new ComputeContext(); bool wasFired = false; p1.transform.SetParent(p2.transform); - shadow.RegisterGameObjectListener(p1, (o, e) => + shadow.RegisterGameObjectListener(p1, e => { if (e == HierarchyEvent.PathChange) { @@ -199,14 +227,15 @@ public void ComponentChangeNotifications_GeneratedWhenObjectItselfChanges() { var shadow = new ShadowHierarchy(); var obj = c("obj"); - + + ComputeContext ctx = new ComputeContext(); List events = new List(); - shadow.RegisterGameObjectListener(obj, (o, e) => + shadow.RegisterGameObjectListener(obj, e => { events.Add(e); return false; - }, events); + }, ctx); shadow.FireStructureChangeEvent(obj.GetInstanceID()); @@ -223,13 +252,14 @@ public void ComponentChangeNotifications_GeneratedWhenChildChanges() child.transform.SetParent(parent.transform); + ComputeContext ctx = new ComputeContext(); List events = new List(); - shadow.RegisterGameObjectListener(parent, (o, e) => + shadow.RegisterGameObjectListener(parent, e => { events.Add(e); return false; - }, events); + }, ctx); shadow.EnableComponentMonitoring(parent); shadow.FireStructureChangeEvent(child.GetInstanceID()); @@ -248,13 +278,14 @@ public void ComponentChangeNotifications_FiredAfterReparents() p3.transform.SetParent(p2.transform); + ComputeContext ctx = new ComputeContext(); List events = new List(); - shadow.RegisterGameObjectListener(p1, (o, e) => + shadow.RegisterGameObjectListener(p1, e => { events.Add(e); return false; - }, events); + }, ctx); shadow.EnableComponentMonitoring(p1); @@ -282,13 +313,14 @@ public void ComponentChangeNotification_FiredAfterReorderEvent() c1.transform.SetParent(p.transform); + ComputeContext ctx = new ComputeContext(); List events = new List(); - shadow.RegisterGameObjectListener(p, (o, e) => + shadow.RegisterGameObjectListener(p, e => { events.Add(e); return false; - }, events); + }, ctx); shadow.EnableComponentMonitoring(p); @@ -310,22 +342,23 @@ public void OnDestroy_NotificationsBlasted() o2.transform.SetParent(o1.transform); o3.transform.SetParent(o2.transform); + ComputeContext ctx = new ComputeContext(); List<(int, HierarchyEvent)> events = new List<(int, HierarchyEvent)>(); - shadow.RegisterGameObjectListener(o1, (o, e) => + shadow.RegisterGameObjectListener(o1, e => { - events.Add(((int) o, e)); + events.Add((1, e)); return false; - }, 1); - shadow.RegisterGameObjectListener(o2, (o, e) => + }, ctx); + shadow.RegisterGameObjectListener(o2, e => { - events.Add(((int) o, e)); + events.Add((2, e)); return false; - }, 2); - shadow.RegisterGameObjectListener(o3, (o, e) => + }, ctx); + shadow.RegisterGameObjectListener(o3, e => { - events.Add(((int) o, e)); + events.Add((3, e)); return false; - }, 3); + }, ctx); shadow.EnableComponentMonitoring(o1); @@ -339,11 +372,11 @@ public void OnDestroy_NotificationsBlasted() Assert.Contains((3, HierarchyEvent.ForceInvalidate), events); } - private ListenerSet.Invokee AddToList(List<(int, HierarchyEvent)> events) + private ListenerSet.Filter AddToList(List<(int, HierarchyEvent)> events, int o) { - return (o, e) => + return e => { - events.Add(((int) o, e)); + events.Add((o, e)); return false; }; } @@ -353,6 +386,8 @@ public void OnReparentDestroyedObject_NotificationsBlasted() { var shadow = new ShadowHierarchy(); + var ctx = new ComputeContext(); + var o1 = c("o1"); var o2 = c("o2"); var o3 = c("o3"); @@ -361,9 +396,9 @@ public void OnReparentDestroyedObject_NotificationsBlasted() o3.transform.SetParent(o2.transform); List<(int, HierarchyEvent)> events = new List<(int, HierarchyEvent)>(); - shadow.RegisterGameObjectListener(o1, AddToList(events), 1); - shadow.RegisterGameObjectListener(o2, AddToList(events), 2); - shadow.RegisterGameObjectListener(o3, AddToList(events), 3); + shadow.RegisterGameObjectListener(o1, AddToList(events, 1), ctx); + shadow.RegisterGameObjectListener(o2, AddToList(events, 2), ctx); + shadow.RegisterGameObjectListener(o3, AddToList(events, 3), ctx); shadow.EnableComponentMonitoring(o1); @@ -381,6 +416,8 @@ public void OnReparentDestroyedObject_NotificationsBlasted() public void OnInvalidateAll_EverythingIsInvalidated() { var shadow = new ShadowHierarchy(); + + var ctx = new ComputeContext(); var o1 = c("o1"); var o2 = c("o2"); @@ -390,9 +427,9 @@ public void OnInvalidateAll_EverythingIsInvalidated() o3.transform.SetParent(o2.transform); List<(int, HierarchyEvent)> events = new List<(int, HierarchyEvent)>(); - shadow.RegisterGameObjectListener(o1, AddToList(events), 1); - shadow.RegisterGameObjectListener(o2, AddToList(events), 2); - shadow.RegisterGameObjectListener(o3, AddToList(events), 3); + shadow.RegisterGameObjectListener(o1, AddToList(events, 1), ctx); + shadow.RegisterGameObjectListener(o2, AddToList(events, 2), ctx); + shadow.RegisterGameObjectListener(o3, AddToList(events, 3), ctx); shadow.InvalidateAll(); shadow.FireObjectChangeNotification(o1.GetInstanceID()); // should be ignored @@ -408,11 +445,13 @@ public void ComponentMonitoringTest() { var shadow = new ShadowHierarchy(); + var ctx = new ComputeContext(); + var o1 = c("o1"); var component = o1.AddComponent(); List<(int, HierarchyEvent)> events = new List<(int, HierarchyEvent)>(); - shadow.RegisterObjectListener(component, AddToList(events), 1); + shadow.RegisterObjectListener(component, AddToList(events, 1), ctx); shadow.FireObjectChangeNotification(component.GetInstanceID()); @@ -424,6 +463,8 @@ public void ComponentReorder_TriggersStructureChange() { var shadow = new ShadowHierarchy(); + var ctx = new ComputeContext(); + var o1 = c("o1"); var o2 = c("o2"); @@ -435,8 +476,8 @@ public void ComponentReorder_TriggersStructureChange() o2.transform.SetParent(o1.transform); List<(int, HierarchyEvent)> events = new List<(int, HierarchyEvent)>(); - shadow.RegisterGameObjectListener(o1, AddToList(events), 1); - shadow.RegisterGameObjectListener(o2, AddToList(events), 2); + shadow.RegisterGameObjectListener(o1, AddToList(events, 1), ctx); + shadow.RegisterGameObjectListener(o2, AddToList(events, 2), ctx); shadow.EnableComponentMonitoring(o1); diff --git a/UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef b/UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef deleted file mode 100644 index 98223188..00000000 --- a/UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "nadena.dev.ndmf.reactive-query.tests.editor", - "rootNamespace": "", - "references": [ - "nadena.dev.ndmf.reactive-query.core", - "nadena.dev.ndmf.reactive-query.tests", - "nadena.dev.ndmf" - ], - "includePlatforms": [ - "Editor" - ], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": false, - "precompiledReferences": [], - "autoReferenced": false, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": false -} \ No newline at end of file diff --git a/UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef.meta b/UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef.meta deleted file mode 100644 index f8fe9ee0..00000000 --- a/UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 8c5771c0a6864b6b85da6f23b1b831aa -timeCreated: 1717367926 \ No newline at end of file diff --git a/UnitTests~/nadena.dev.ndmf.UnitTests.asmdef b/UnitTests~/nadena.dev.ndmf.UnitTests.asmdef index e661e73e..effd4324 100644 --- a/UnitTests~/nadena.dev.ndmf.UnitTests.asmdef +++ b/UnitTests~/nadena.dev.ndmf.UnitTests.asmdef @@ -2,10 +2,10 @@ "name": "nadena.dev.ndmf.UnitTests", "rootNamespace": "", "references": [ - "GUID:62ced99b048af7f4d8dfe4bed8373d76", - "GUID:b93f844d45cfcc44fa2b0eed5c9ec6bb", - "GUID:901e56b065a857d4483a77f8cae73588", - "GUID:fe747755f7b44e048820525b07f9b956" + "nadena.dev.ndmf", + "nadena.dev.ndmf.vrchat", + "nadena.dev.ndmf.runtime", + "nadena.dev.ndmf.reactive-query.core" ], "includePlatforms": [ "Editor"