DLL с UserControl на Avalonia C#

Пишу приложение с открытой архитектурой. Плагины с заданным интерфейсом в виде DLL находятся в отдельной папке и при старте приложения загружаются и регистрируются в контейнере DI. Обычная DLL с функциями подключается и работает. Но заказчику нужно, чтобы каждый плагин имел свою View-шку с произвольным набором кнопок, селекторов, полей ввода и т.п. Подскажите, как создать такую DLL-ку и вывести в основном окне приложения UserControl из этой DLL. За рабочий пример буду очень признателен.


Ответы (1 шт):

Автор решения: EvgeniyZ

Покажу простой пример на WPF, под Avalonia адаптируете самостоятельно.

  • Создам основной WPF проект, назову его Shell

    • В этот проект я добавлю NuGet: CommunityToolkit.MVVM, чтобы не писать команды и INPC ручками. И также добавлю Microsoft.Extensions.DependencyInjection (DI контейнер).

    • Далее добавлю стандартный класс MainViewModel (VM для основного окна).

    • Удалю из App.xaml строку запуска (StartupUri), ибо окно будем запускать вручную, через DI.

    • В App.xaml.cs переопределю OnStartup, инициализировав там контейнер и запуск приложения.

      protected override void OnStartup(StartupEventArgs e)
      {
          base.OnStartup(e);
          var services = new ServiceCollection();
      
          services.AddSingleton<MainWindow>();
          services.AddSingleton<MainViewModel>();
      
          var serviceProvider = services.BuildServiceProvider();
          var mainWindow = serviceProvider.GetRequiredService<MainWindow>();
          mainWindow.DataContext = serviceProvider.GetRequiredService<MainViewModel>();
          mainWindow.Show();
      }
      
    • Запускаем, должно появиться пустое окно. Поздравляю, с подготовкой основного приложения закончили.

  • Теперь нам нужен другой проект, который будет содержать в себе все интерфейсы (контракты) между всеми проектами. У меня решение зовется SomeApp, назову проект с контактами SomeApp.Contracts. Это простая библиотека классов.

    • В этом проекте переименовываем стартовый Class1 в IHostApi и меняем его на interface. Это будет интерфейс, через который плагин будет иметь нужные механизмы (отправить лог, записать в файл, вывести сообщение, перейти на страницу, и т.д.). Для примера я пропишу вывод сообщения.

      public interface IHostApi
      {
          void ShowMessage(string text);
      }
      
    • Теперь создадим еще один интерфейс, IPlugin. В нем мы пропишем все то, чего плагин должен реализовать. Если так подумать, то в плагине обычно есть имя, описание. Также он должен вернуть объект View для отображения. Собственно это и просим в интерфейсе.

      public interface IPlugin
      {
          string Name { get; }
          string Description { get; }
          object CreateView(IHostApi hostApi);
      }
      
  • Вернемся теперь к основному проекту, а именно в App.xaml.cs. Наша цель - загружать .dll плагинов из указанной папки и, как пример, регистрировать их в контейнере.

    • В зависимости проекта добавим проект Contracts.

    • Далее создадим метод загрузки плагинов:

      private List<IPlugin> LoadPluginInstances(string folder)
      {
          var result = new List<IPlugin>();
          if (!Directory.Exists(folder)) return result;
      
          foreach (var dll in Directory.GetFiles(folder, "*.dll"))
          {
              try
              {
                  var asm = Assembly.LoadFrom(dll);
                  var pluginTypes = asm.GetTypes()
                      .Where(type => typeof(IPlugin).IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface);
      
                  foreach (var pluginType in pluginTypes)
                  {
                      if (Activator.CreateInstance(pluginType) is IPlugin pluginInstance)
                          result.Add(pluginInstance);
                  }
              }
              catch (Exception ex)
              {
                  Console.WriteLine($"Ошибка загрузки {dll}: {ex.Message}");
              }
          }
          return result;
      }
      
    • Теперь допишем в OnStartup регистрацию этих плагинов:

      var services = new ServiceCollection();
      
      var plugins = LoadPluginInstances("Plugins");
      foreach (var plugin in plugins)
      {
          services.AddSingleton(plugin);
      }
      

      Обратите внимание, что плагины я сразу инициализирую, после чего они висят как синглтоны в контейнере. Это не очень оптимально, лучше дать контейнеру самому выбирать когда и что загружать. Также стоит подумать, нужны ли плагины в контейнере, может лучше сделать отдельный сервис, который будет их хранить, загружать, управлять? Решение этих вопросов оставлю вам...

    • Переходим к MainViewModel. Давайте в ней получим все зарегистрированные плагины, выведем на экран список плагинов, кнопками, а по клику будем отображать View конкретного плагина. Получаем в итоге такое:

      public partial class MainViewModel(IEnumerable<IPlugin> plugins, IHostApi hostApi) : ObservableObject
      {
          [ObservableProperty]
          private List<IPlugin> _plugins = [.. plugins];
      
          [ObservableProperty]
          private object? _currentView;
      
          [RelayCommand]
          public void OpenPlugin(IPlugin plugin)
          {
              CurrentView = plugin.CreateView(hostApi);
          }
      }
      

      В IEnumerable<IPlugin> контейнер подставит все зарегистрированные в нем объекты с типом IPlugin.

    • Пропишем теперь простой вид окна:

      <Grid>
          <Grid.RowDefinitions>
              <RowDefinition Height="*"/>
              <RowDefinition Height="Auto"/>
          </Grid.RowDefinitions>
          <ContentControl Grid.Row="0" Content="{Binding CurrentView}" />
      
          <ItemsControl Grid.Row="1" ItemsSource="{Binding Plugins}">
              <ItemsControl.ItemsPanel>
                  <ItemsPanelTemplate>
                      <WrapPanel/>
                  </ItemsPanelTemplate>
              </ItemsControl.ItemsPanel>
              <ItemsControl.ItemTemplate>
                  <DataTemplate>
                      <Button Content="{Binding Name}"
                              Command="{Binding DataContext.OpenPluginCommand, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=ItemsControl}}"
                              CommandParameter="{Binding}"
                              Margin="5"/>
                  </DataTemplate>
              </ItemsControl.ItemTemplate>
          </ItemsControl>
      </Grid>
      
    • Ну и последнее, что нам осталось сделать, так это реализовать и зарегистрировать IHostApi. Я создам просто класс, в котором пропишу следующее:

      public class HostApi : IHostApi
      {
          public void ShowMessage(string text)
          {
              MessageBox.Show(text);
          }
      }
      

      А в контейнер зарегистрирую так: services.AddTransient<IHostApi, HostApi>();.

  • Теперь сам плагин. Делаем очередной проект, но на этот раз не простая библиотека классов, а "Библиотека классов WPF" (у вас авалония, там найдете аналогичное). Это нужно для того, чтобы мы могли использовать пользовательские элементы управления (User Control). Мой проект будет называться SomeApp.Plugin.Calculator.

    • В этом проекте создаем View (User Control) с нужным видом. У меня будет просто TextBlock с большим текстом, ничего более.

    • Далее создаем класс, который реализует IPlugin (название любое, у меня Plugin). Наследуем класс от интерфейса и реализуем все то, что он требует. В CreateView мы должны вернуть View плагина.

      public class Plugin : IPlugin
      {
          public string Name => "Калькулятор";
          public string Description => "Простой калькулятор в плагине.";
      
          public object CreateView(IHostApi hostApi)
          {
              hostApi.ShowMessage("Сообщение через HostApi.");
              return new View();
          }
      }
      
    • Собираем, закидываем эту .dll в папку Plugins рядом с основным проектом и запускаем.

Result

Как видите, есть одна кнопка с названием плагина. По нажатию на кнопку страница из плагина загрузилась и отобразилась. Также есть сообщение, которое мы отправили через HostApi. Собственно, вот и все. Самая простая реализация плагинов готова, остальное уже тонкие реализации под конкретный проект.

На что стоит обратить внимание:

  • Управление плагинами стоит отдать отдельному сервису, а не грузить их вот так нагло в контейнер.
  • Плагины делать синглтонами не лучшая идея. Но тут зависит от задач.
  • Метод CreateView сейчас возвращает object. Лучше сделать дополнительный интерфейс, который ограничит возвращаемый тип.
  • Внутри плагина вы можете также делать VM слои, просто укажите DataContext View слою при его создании.
  • Вы можете пробросить DI в плагин. Для этого дополните IPlugin дополнительным методом, через который плагин сможет регистрировать свои типы в контейнере (включая V и VM), а в CreateView создавайте не сами, а через контейнер (ActivatorUtilities). Ну и в зависимости не весь пакет контейнера, а Microsoft.Extensions.DependencyInjection.Abstractions. НО! Помните про безопасность. Если плагины пишете не вы, то стоит задуматься, что люди могут "дергать" в вашем приложении (по этой причине в примере используется IHostApi).
  • Если вы в плагин добавили сторонний пакет, то его нужно также добавить и основному проекту, помните про это.
→ Ссылка