Skip to content
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

Enhance Dependency Injection guides #525

Open
duongphuhiep opened this issue Aug 2, 2024 · 7 comments
Open

Enhance Dependency Injection guides #525

duongphuhiep opened this issue Aug 2, 2024 · 7 comments

Comments

@duongphuhiep
Copy link

Hello,

The current example in the guide is not very helpful

https://docs.avaloniaui.net/docs/guides/implementation-guides/how-to-implement-dependency-injection

  1. The ServiceProvider is created and available only inside the function OnFrameworkInitializationCompleted(), while it should be generally accessible to create object for us anywhere. Or How to make the avalonia framework use our ServiceProvider when creating object?

  2. Is it really neccessary to Register the services (a.k.a the ServiceCollection) inside OnFrameworkInitializationCompleted()? We are registering the services, not actually create or use them, so why do we need to wait for OnFrameworkInitializationCompleted?

My suggesstion is

  • option 1) remove this part from the documentation
  • or option 2) make the example more useful
@thevortexcloud
Copy link
Contributor

thevortexcloud commented Sep 12, 2024

The ServiceProvider is created and available only inside the function OnFrameworkInitializationCompleted(), while it should be generally accessible to create object for us anywhere.

The original example code I wrote did in fact have a static service locator.

https://github.com/cerobe/avalonia-docs/blob/dcb0c65c17e452feb97258ac08ceb044e2f02b4b/docs/guides/implementation-guides/how-to-implement-dependency-injection.md

However, I don't recall why it was removed. The reason is probably somewhere in the code review. However that version of the example really only works properly on desktop apps due to the use of the Main method.

#359

How to make the avalonia framework use our ServiceProvider when creating object?

I am not sure I understand? The example shows exactly that. If you want Avalonia to resolve objects from your DI for you, you need to write more code to glue it all together. EG a custom view locator that returns view models from the DI container.

Generally using a static service locator is an anti pattern anyway.

Is it really neccessary to Register the services (a.k.a the ServiceCollection) inside OnFrameworkInitializationCompleted()?

Nope. It's just the easiest place that will work on every platform. You can do it in a few different places. EG in the AfterSetup() method.

We are registering the services, not actually create or use them

That's not true. A couple lines after you register the services shows the view model being resolved from the container and then passed to the main view.

@duongphuhiep
Copy link
Author

duongphuhiep commented Sep 12, 2024

  1. This is actually how Avalonia create object: a big new MyUserControl() via default contructor.
    image

This is why I asked: How to make Avalonia uses my ServiceProvider when creating object instead?
and yes, I've got the answer: a Custom view locator that resolve MyUserControl via my DI container. This way, I can inject things to MyUserControl, and it doesn't need a default constructor anymore.

  1. About services registration (a.k.a BuildDependencyGraph() in the following example): I didn't do it inside OnFrameworkInitializationCompleted() because I need to re-use the services registration in tests projects to inject Mock
public partial class App : Application
{
    public static readonly IServiceProvider ServiceProvider = BuildDependencyGraph().BuildServiceProvider();

    public static ServiceCollection BuildDependencyGraph()
    {
        ServiceCollection services = new();
        services.AddLoggingService();
        services.AddRouting();
        services.RegisterViewModels();
        services.RegisterViews();
        services.RegisterBusinessLogic();
        return services;
    }
    public override void OnFrameworkInitializationCompleted()
    {
        DisableAvaloniaDataAnnotationValidation();
    
        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
        {
            desktop.MainWindow = new MainWindow
            {
                DataContext = ServiceProvider.GetRequiredService<MainWindowViewModel>()
            };
        }
        else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
        {
            singleViewPlatform.MainView = new MainView
            {
                DataContext = ServiceProvider.GetRequiredService<MainViewModel>()
            };
        }
    
        base.OnFrameworkInitializationCompleted();
    }

and for tests:

[Fact]
public void WalletsViewModelTest()
{
    IWalletRepository mockRepository = Substitute.For<IWalletRepository>();
    ServiceCollection services = App.BuildDependencyGraph();
    services.AddSingleton(mockRepository); //inject a fake repository instead of using the real repository which access to the database
    IServiceProvider serviceProviderWithMockRepository = services.BuildServiceProvider();

    WalletsViewModel subjectUnderTest = serviceProviderWithMockRepository.GetRequiredService<WalletsViewModel>();
    subjectUnderTest.Wallets.ShouldNotBeNull();
}

In conclusion, I think that the docs would be more useful if it should reference to the "Custom view locator" section, telling that "ViewLocator" is the way to glue your IOC container (a.k.a ServiceProvider) to Avalonia.

Evens more useful if it can have some insight about "MV-first" approach in the "MVVM" pattern to make codes easier to test.

@thevortexcloud
Copy link
Contributor

thevortexcloud commented Sep 12, 2024

a Custom view locator that resolve MyUserControl via my DI container. This way, I can inject things to MyUserControl, and it doesn't need a default constructor anymore.

I would suggest avoiding injecting controls. They are not really meant to be used like that. The view will without warning, destroy and recreate them as needed. View models are what you use to persist state. I typically only set up view models for injection. In that situation, the example code in the docs is sufficient.

and for tests:

That test does not look like a valid unit test. It looks like an integration test instead. Properly designed unit tests should only be testing one specific bit of functionality. Meaning you should not be registering real services. Everything should be a fake except for the class you are testing.

"Custom view locator" section, telling that "ViewLocator" is the way to glue your IOC container (a.k.a ServiceProvider) to Avalonia.

The problem with view locators is they are not generally not trimming safe (due to the use of reflection). Which means they often don't work with AOT compiled apps. Which is why they were removed from the default templates. You could probably make a trim safe locator though.

AvaloniaUI/avalonia-dotnet-templates#177

The locator was just one example though. There are many other ways you can use your DI container with Avalonia. It really depends on how you structure your code and what you are trying to do. EG I have written a router that will automatically resolve a view model from the DI container when something tries to display it on screen.

@duongphuhiep
Copy link
Author

duongphuhiep commented Sep 12, 2024

I would suggest avoiding injecting controls. They are not really meant to be used like that. The view will without warning, destroy and recreate them as needed.

Do you means that after rendering <ContentControl Content="{Binding MyUserControlViewModel}" />, there is situation that Avalonia might destroy MyUserControl then ignore my ViewLocator and re-create it with the default constructor?

That test does not look like a valid unit test. It looks like an integration test instead.

You are right they are integration test of various ViewModels. Unit tests are rarely useful for frontend / rich interractive applications. The interesting parts which need to be tested are usually interraction between different VMs. Eg: When a "RelayCommand" is "Act" then

  • "Assert" that other VMs reacted accordingly,
  • "Assert" that the "mock" external service is called with the right inputs
  • "Assert" that the output of the external service is displayed / used in the right place (by the right VMs)

I sometimes write normal unit tests to cover some edge cases which is complicated to "Arrange". But I generally avoid to mock internal things of the application (We should only mock the external components.)

The problem with view locators is they are not generally not trimming safe (due to the use of reflection). Which means they often don't work with AOT compiled apps.

As long as the App works on Window, Linux, Android, and Web browser then it is good to me. In this example, I used ViewLocator and MS DI to resolve "View" and it appeared trimming safe and works on Web & Android, (not yet tested on Linux..)

There are many other ways you can use your DI container with Avalonia.

Can you please show me some examples? (except the ViewLocator or the "Service locator" anti-pattern. I don't know other ways to glue my DI container with Avalonia)

Thanks

@thevortexcloud
Copy link
Contributor

thevortexcloud commented Sep 13, 2024

Do you means that after rendering , there is situation that Avalonia might destroy MyUserControl then ignore my ViewLocator and re-create it with the default constructor?

It wont ignore the locator. But it will query the locator again for the control. If you have poorly configured services, it can result in hard to debug crashes/memory leaks/unexpected or undefined behaviour. Similarly, if you are resolving a view model from the DI container to assign to a control in the locator, it may end up creating an entirely new instance of the view model which results in a loss of state.

One other problem with adding a non default constructor for a control is it breaks the previewer. Although some people don't consider that an issue.

Can you please show me some examples? (except the ViewLocator or the "Service locator" anti-pattern. I don't know other ways to glue my DI container with Avalonia)

I might write up an example later. Most other use cases are somewhat advanced. But the limit is really your imagination due to Avalonia not using any DI out of the box. EG you can make a markup extension that resolves things from the static locator that will be accessible to your views via XAML.

@duongphuhiep
Copy link
Author

Thanks for your explanation. I will be careful for the situation which Avalonia might deliberately dispose a View then call the ViewLocator to re-create it again. I intended to borrow /experiment the store's pattern from the Web SPA's world (Vue's Pinia, Solidjs's store..) where (the whole) application state is kept in a "Reactive store" independent from the Views.

About the previewer, my current work around is to add some boilerplate codes to keep it working and I'm happy with it.

class MyMainViewModel(ChildViewModel childvm, IDep1 dep1, IDep2 dep2) {};

#region boilerplate codes to keep the Previewer works

class MyMainViewModelForDesigner(): MyMainViewModel {
    //add a default constructor
    public MyMainViewModelForDesigner(): base(new ChildViewModelForDesigner(), new Dep1ForDesigner(), null) {}
}

#endregion

About the last point

you can make a markup extension that resolves things from the static locator that will be accessible to your views via XAML.

Do you mean something like this?

<local:ResolveControl ControlType="local:MyUserControl" />
public class ResolveControlExtension
{
    public Type ControlType { get; set; }

    // note, this IServiceProvider parameter IS NOT related to your configured service provider, and only used for XAML related services
    public Control ProvideValue(IServiceProvider _)
    {
        if (!typeof(Control).IsAssignableFrom(ControlType))
            throw new InvalidOperationException($"The provided type {ControlType} is not a control");

        return (Control) App.ServiceProvider.GetService(ControlType);
    }
}

I'm new to Avalonia, and not sure if it is any diffrent from (or better? than) the ViewLocator technique:

<ContentControl Content="{Binding MyUserControlViewModel}" />
public class ViewLocator : IDataTemplate
{
    public Control Build(object? viewModel)
    {
        if (viewModel is MyUserControlViewModel)
        {
            return (Control)App.ServiceProvider.GetService<MyUserControl>();
        }
        ....
    }
}

Comparing

<local:ResolveControl ControlType="local:MyUserControl" />

vs

<ContentControl Content="{Binding MyUserControlViewModel}" />

Please correct me if I'm wrong, my guess is that Avalonia would handle them the same way, it might dispose the local:ResolveControl or the ContentControl anytime (for whatever reason), then re-create the ResolveControl with the Markup extension or re-create the ContentControl with the ViewLocator?

If my guess is right then there is not really a fundamentally different.

  • the first one (local:ResolveControl) is code-behind approach for peoples who practices DI but don't use MVVM.
  • the second one (ContentControl) is for MVVM practitioner.

@thevortexcloud
Copy link
Contributor

thevortexcloud commented Sep 13, 2024

About the previewer, my current work around is to add some boilerplate codes to keep it working and I'm happy with it.

There is a nicer way using a markup extension.

AvaloniaUI/Avalonia#13743 (reply in thread)

Please correct me if I'm wrong, my guess is that Avalonia would handle them the same way, it might dispose the local:ResolveControl or the ContentControl anytime (for whatever reason), then re-create the ResolveControl with the Markup extension or re-create the ContentControl with the ViewLocator?

It still uses the static locator. But the difference is it acts as a sort of abstraction around it, which makes it not as bad as directly using the locator and can be easily used to assign a value to another existing control. It was however just an example.

A much more useful and architectural approach that I mentioned earlier would be a custom router for a SPA. I shared some of the code for one I wrote that uses DI here the other day:

#539 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants