Skip to content

Feature Request: Add a mixin that automatically cleans up frequent node _ExitTree() logic. #210

Description

@willnationsdev

I semi-recently started trying LogicBlocks in a project, and so I tried copying the App.cs from the GameDemo project (and its related classes/states/etc.) into my own project to learn how it works. However, I really disliked the usability/maintenance cost of having to add in & keep updated all the .Stop(), .Dispose() and event unsubscription logic in the _ExitTree() / OnExitTree() method based on whatever things were initialized in Initialize(), _Ready() / OnReady(), Setup(), and OnResolved().

To avoid having to deal with any of that, I thought to write an incremental source generator to solve the problem automatically (which I now have working). This issue is mainly to check whether there'd be any interest in me porting it into a full PR (though, I think the exact approach may have to be adjusted slightly to account for how this codebase builds):

Using a new CleanupSourceGenerator, I use RegisterPostInitializationOutput(...) to define a new IAutoCleanup mixin:

using Chickensoft.Introspection;
using Godot;

namespace Chickensoft.AutoInject;

[Mixin]
public interface IAutoCleanup : IMixin<IAutoCleanup>
{
    public readonly ref struct CleanScope
    {
        public void Dispose()
        {
        }
    }

    CleanScope Clean() => new();

    void ExitTreeCleanup() { }

    void IMixin<IAutoCleanup>.Handler()
    {
        if (this is not Node node)
        {
            return;
        }

        node.__SetupNotificationStateIfNeeded();

        var what = MixinState.Get<NotificationState>().Notification;

        if (what == Node.NotificationExitTree)
        {
            ExitTreeCleanup();
        }
    }
}

public static class AutoCleanupExtensions
{
    public static IAutoCleanup.CleanScope Clean<T>(this T instance) where T : IAutoCleanup
    {
        return ((IAutoCleanup)instance).Clean();
    }
}

Then the source generator identifies any classes with [Meta] attributes containing the IAutoCleanup interface (or a derived interface of it), and scans their various Godot/AutoInject-specific initialization methods (not the constructor or _Init though). If the developer invokes the this.Clean() method to create a using block of any kind, then each statement of that block is scanned and statically evaluated to see whether it matches one of 3 criteria:

  1. Is it a property/field initialization from either a new() expression or an invocation? If so, and the datatype of the property/field is disposable AND it is NOT a type extending Godot.GodotObject (or its derivatives), then it generates a .Dispose() call.
    • Added an MSBuild configuration property that lets developers add names of additional disposable types to be ignored as well.
  2. Is it a .Start() call on a property field? If so, generate the corresponding .Stop() call.
    • I've designed these method pairs to be configurable from MSBuild options in the future.
  3. Is it an event subscription via +=? If so, generate the corresponding -= unsubscribe operation.

That makes the following App logic from GameDemo (tweaked to include the using statement)...

[Meta(typeof(IAutoNode, IAutoCleanup))]
[ClassDiagram(UseVSCodePaths = true)]
public partial class App : CanvasLayer, IApp
{
  public override void _Notification(int what) => this.Notify(what);

  public IAppRepo AppRepo { get; set; } = default!;
  public IAppLogic AppLogic { get; set; } = default!;
  public LogicBlock.Binding AppBinding { get; set; } = default!;

  public void Initialize()
  {
    using var _ = this.Clean();

    Instantiator = new Instantiator(GetTree());
    AppRepo = new AppRepo();
    AppLogic = new AppLogic();
    AppLogic.Set(AppRepo);
    AppLogic.Set(new AppLogic.Data());

    Menu.NewGame += OnNewGame;
    Menu.LoadGame += OnLoadGame;
    Menu.DeleteGame += OnDeleteGame;
    AnimationPlayer.AnimationFinished += OnAnimationFinished;

    this.Provide();
  }

  public void OnReady()
  {
    // Tell our type type resolver about the Godot-specific converters.
    GodotSerialization.Setup();
    LogicBlockSerialization.Setup();

    using (this.Clean())
    {
      AppBinding = AppLogic.Bind()
        OnOutput(blah blah);  // ...omitted long chain of output handlers.

      // Enter the first state to kick off the binding side effects.
      AppLogic.Start<AppLogicState.SplashScreen>();
    }
  }
}

...generate the following partial declaration, providing an explicit implementation of the ExitTreeCleanup() method from the mixin:

// <auto-generated />
#nullable enable

namespace GameDemo;

partial class App : global::Chickensoft.AutoInject.IAutoCleanup
{
  void global::Chickensoft.AutoInject.IAutoCleanup.ExitTreeCleanup()
  {
    // OnReady
    AppLogic.Stop();
    AppBinding.Dispose();

    // Initialize
    AppRepo.Dispose();
    Menu.NewGame -= OnNewGame;
    Menu.LoadGame -= OnLoadGame;
    Menu.DeleteGame -= OnDeleteGame;
    AnimationPlayer.AnimationFinished -= OnAnimationFinished;
  }
}

It runs the operations for a single method in-order, but processes each method grouping in reverse order. And if a Dispose() would be detected, but a Start()/Stop() pair already exists for the same member property/field, then the dispose is skipped (like how AppLogic isn't disposed above, despite being a disposable type).

Sound like something you'd possibly be interested in making core?

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions