65.9K
CodeProject 正在变化。 阅读更多。
Home

Composite WPF (CAL, Prism) 入门:第一部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (42投票s)

2009年6月11日

CPOL

17分钟阅读

viewsIcon

182984

downloadIcon

2828

一篇展示CompositeWPF极简实现的article。

CALDemoSS.jpg

引言

如果您是WPF应用程序开发人员,您现在可能已经听说过CAL。它似乎有许多人们称呼它的名字:CompositeWPF、Composite Application Library、Composite Application Guidance (CAG) 和 Prism。坦白说,这有点疯狂;从现在开始,它就叫CAL。不像那些实际上做同一件事的各种技术越来越多,我们这里有一种技术却有很多不同的名字!

无论如何,我已经参与了两个大型Composite WPF应用程序。一个是我日常工作中为我的雇主开发的,一个使用了WPF/WCF/CAL的智能桌面客户端应用程序;另一个是为我自己的项目开发的。然而,根据我自己的经验以及与其他开发人员的交流,我认为微软有时真是自损。CAL非常棒,而且文档也非常好并且在不断改进,但StockTraderRI一开始确实有点让人难以接受。人们正在用CAL做一些很棒的事情(例如Daniel Vaughan的Calcium),但它们并不是真正地从基础入手;它们本身就是复杂的框架,所以我认为我应该写一个真正简化到极致的演示应用程序。

我将省略那些冗长的解释,只说它主要是一个包含以下功能集的库:

  • 模块化
  • 依赖注入容器 (IoC)
  • ObjectBuilder2
  • 事件聚合
  • 运行时发现/静态/显式模块枚举

在CAL出现之前,我已经在前面提到的两个应用程序上开始工作了。我们使用了更传统的分层方法来构建它们,利用了WPF命令、.NET事件、依赖属性、数据模板等……所有WPF和.NET提供的常规功能,以构建一个功能性的应用程序。然而,在大型商业应用程序中,事情会很快变得非常复杂,而这正是CAL可以真正帮助简化整个编程模型的地方。

当时,我正和几位承包商一起为我的雇主工作,我实在记不清是哪位“发现”了CAL并将其介绍给大家。我们粗略地看了一下,引起了一些共鸣,并决定需要进一步研究。于是,我们继续阅读Composite Application Guidance文档,并查看了StockTrader参考实现及其附带的快速入门。哦,我们多么高兴地鼓掌,几乎处于狂喜状态,办公室里的人们都欣喜若狂地蹦跳着,脸上挂着傻笑……

总而言之,这幅图景似乎是相当有利的。CAL并非适用于所有场景,所以您需要做一些功课,看看它是否真的会在长远上使您受益。要真正掌握它,您需要预先进行大量的思考。这些思考中的一些就是我写这篇文章的原因。所以,尽管我上面说了……我的废话就到此为止!(希望如此)

我将要讲的内容

  • 创建Shell的基础知识
  • 依赖注入/控制反转
  • 事件聚合
  • MVP
  • 实现模块的一些基础知识
  • 导航
  • 在开发环境中编译您的应用程序

在第二部分,我将扩展应用程序,使其变得更有趣,并且我还将介绍如何在CAL风格中动态设置应用程序的皮肤。

正如您从演示代码中看到的,演示代码同时存在于多个解决方案和一个统一的解决方案中,以便于理解。演示应用程序由以下部分组成:

  • Shell
  • ModuleA
  • ModuleB
  • PageManager
  • Navigator
  • StatusBar

VisualStudioSS.jpg

我这样做是为了说明一个开发团队如何协调他们的工作,以贡献于最终的应用程序。这是CAL能提供巨大帮助的一点;在CAG中,他们甚至提到了让离岸团队负责模块等工作。这需要大量的协调才能有条不紊地实现,但确实如此。在如何构建事物方面需要有非常强的一致性,并且需要整体指导,以确保不同团队以共同的愿景来开发应用程序。不同的开发人员/团队非常非常容易采用不同的方法,从而基本上损害了CAL提供的优势。

依赖注入/控制反转

这就像生活中的许多事情一样。您在无意识中去做,然后突然有人称之为“某某某”,您就说……“哇……是吗???”嗯,依赖注入就是其中之一。它听起来很大、成熟、而且“高级”,但它实际上不过是有一个类没有无参构造函数——您注入一个这个类运行所依赖的东西。好吧,这是一个非常简单的解释,甚至没有考虑到Unity Container,但它确实是问题的关键。我甚至做了一张图!!!这是一个关于Unity Container究竟在做什么的非常高层次的概述。就使用基本的MVP模式而言,当一个模块被发现时,它的Initialize()方法会被调用;这反过来会调用一个可选的RegisterViewsAndServices()方法,该方法在DI容器(Unity)中注册模块的视图和服务。然后,当一个对象从容器中解析出来时,它要么创建一个该对象的实例并注入构造函数中找到的依赖项,要么如果正在解析的对象已在容器中注册为ContainerControlledLiftTimeManager,则返回一个单例实例。

IUnityContainerInjection.png

事件聚合、命令、共享服务 - 通信

在我看来,这简直太棒了。您可以在CAG和Martin Fowler的网站上阅读更多关于这方面的内容。它基本上是一种提供松散耦合(该死,我试图写这篇文档时不使用这个词!!,我输了!)通信的方式,用于通信之间没有直接联系的实体;这也称为间接。事件聚合器管理事件和订阅者的列表,并负责将这些事件转发给任何已订阅特定事件的人。这绝不是在所有情况下都最好且唯一可行的通信方式。很容易误用。例如,如果您需要一个响应,那么这不是事件聚合器设计的目的;它不能强制执行这种概念,并且不应该在这种情况下使用。事件聚合器本质上是一种“发送即忘”的通信方式。

模块通信的其他方式是应用程序中的共享服务。这些很可能是应用程序中其他非UI模块,许多其他模块会将其作为服务使用。您可以将逻辑和基于任务的内容封装到这个“服务提供商”模块中,然后通过在UnityContainer中注册其内部类型以及相应的接口来公开它。

另一种通信方式是通过命令。CAL的DelegateCommand<>的工作方式与标准的WPF命令非常相似。然而,为了评估CanExecute,您必须手动调用命令上的RaiseCanExecute()方法。您可以为Presenter创建命令,或者您可以利用全局命令。在本演示中,这些全局可用的命令将驻留在模块的Core中,任何订阅这些命令的都将在此处注册自己并等待执行。

基础设施 / 共享核心

StockTrader参考实现应用程序有一个基础设施库的概念,其中包含构成应用程序的所有共享/通用对象。这些对象包括DI接口、事件定义、事件负载、命令、全局命令等。相比之下,我的演示没有使用这种方法。对于大型应用程序,由多个开发人员构建/依赖的这个单一共享库很容易变成一个难以管理的庞大库;相反,我的演示模块提供了一个核心库,以防它们需要向其他模块提供对其内部的访问。当一个模块构建完成后,它将被复制到复合位置(应用程序可执行的位置)和一个通用的Int目录中;在这里,您可以静态引用它(核心而非模块),以便通过将其接口放在构造函数上并请求Unity注入来“访问”其内部,或者为了订阅或发布事件。

一个模块完全封装是很有可能的,以至于它可能只需要订阅或发布事件聚合器事件,在这种情况下,使用这种方法,它甚至不会有一个核心,因为没有其他模块需要直接与它交互,它也不需要与任何其他模块直接交互。您可以在演示代码中的Navigator模块中看到这一点。

Shell

本演示中的Shell是主区域容器。这实际上可以移到一个模块中,或者包含在应用程序的主可执行文件中。对于此演示,并为了保持简单(毕竟这是入门),这是一个应用程序主可执行文件中的视图。这是定义该视图并创建区域并将其注册到RegionManager的XAML。

<Window 
    x:Class="JamSoft.CALDemo.Shell"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:cal="http://www.codeplex.com/CompositeWPF"
    Title="JamSoft Composite Application Library Demo"
    Background="DarkGray"
    Width="600" 
    Height="600">
     <Grid>
         <Grid.ColumnDefinitions>
             <ColumnDefinition Width="*" />
             <ColumnDefinition Width="*" />
         </Grid.ColumnDefinitions>
         <Grid.RowDefinitions>
             <RowDefinition Height="35" />
             <RowDefinition Height="*" />
             <RowDefinition Height="25" />
         </Grid.RowDefinitions>
        
         <ItemsControl cal:RegionManager.RegionName="NavigatorRegion"
                      HorizontalAlignment="Stretch"
                      VerticalAlignment="Stretch"
                      Grid.Column="0"
                      Grid.ColumnSpan="1"
                      Grid.Row="0"
                      Grid.RowSpan="1" />
        
         <ContentControl cal:RegionManager.RegionName="ToolBarRegion" 
                        HorizontalAlignment="Stretch" 
                        VerticalAlignment="Stretch" 
                        Grid.Column="1" 
                        Grid.ColumnSpan="1" 
                        Grid.Row="0" 
                        Grid.RowSpan="1" />

         <Border x:Name="MainRegionBorder" 
                BorderBrush="Black" 
                BorderThickness="2" 
                Grid.Row="1" 
                Grid.RowSpan="1" Grid.ColumnSpan="2" 
                CornerRadius="3" 
                Margin="5" 
                Padding="5">
            
             <ContentControl cal:RegionManager.RegionName="MainRegion"
                            HorizontalAlignment="Stretch" 
                            VerticalAlignment="Stretch" 
                            Grid.Column="0" 
                            Grid.ColumnSpan="2" 
                            Grid.Row="1" 
                            Grid.RowSpan="1" />
         </Border>
        
         <ContentControl cal:RegionManager.RegionName="StatusBarRegion"
                        HorizontalAlignment="Stretch"
                        VerticalAlignment="Stretch"
                        Grid.Column="0"
                        Grid.ColumnSpan="2"
                        Grid.Row="2"
                        Grid.RowSpan="1"/>
     </Grid>
 </Window>

可执行文件也是应用程序的核心部分,它通过JamSoftBootstrapper来配置容器。该类继承自UnityBootstrapper。它的任务是创建和配置容器,并将Shell视图注册到容器中。我不会详细介绍这一点,因为正如您所期望的那样,这里有很多事情可以做,远远超出了本文的范围。这里可以包含各种各样的区域适配器;例如,在CodePlex CompositeWPFContrib项目中有一个相当不错的WindowRegionAdaptor,可用于弹出窗口和启动具有自己区域和视图的其他窗口。这实际上是CAL框架的一个重要部分;与任何优秀的框架一样,它是可定制和可扩展的。如果您愿意,您甚至可以用您选择的容器替换Unity容器。

此特定引导程序的代码是完全标准的,如下所示:

internal class JamSoftBootstrapper : UnityBootstrapper
{
    protected override IModuleEnumerator GetModuleEnumerator()
    {
        return new DirectoryLookupModuleEnumerator("Modules");
    }

    protected override void ConfigureContainer()
    {
        Container.RegisterType<IShellView, Shell>();

        base.ConfigureContainer();
    }

    protected override DependencyObject CreateShell()
    {
        ShellPresenter presenter = Container.Resolve<shellpresenter>();
        IShellView view = presenter.View;
        view.ShowView();
        return view as DependencyObject;
    }
}

这通过App.xaml.cs文件中的几行代码启动:

public App()
{
    JamSoftBootstrapper bootStrapper = new JamSoftBootstrapper();
    bootStrapper.Run();
}

值得在此指出的是,在App.xaml文件中,您会注意到正常的StartupUri没有关联的值,因为它已经被移交给ShellPresenter了。

<Application 
    x:Class="JamSoft.CALDemo.App"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Application.Resources>
         
    </Application.Resources>
</Application>

IShellView是Shell在容器中注册的接口;它非常简单,如下所示:

public interface IShellView
{
    void ShowView();
}

ShellPresenter也同样简单,如下所示:

public class ShellPresenter
{
    public IShellView View { get; private set; }


    public ShellPresenter(IShellView view)
    {
        View = view;
    }
}

运行时模块发现

这是CAL非常出色的一点。它能够在运行时发现模块。还有其他几种方法,您也可以通过代码或配置文件中的各种方法手动添加它们。我真的很喜欢使用DirectoryLookupModuleEnumerator的动态方法。

基本上,您的应用程序启动,Shell完成它的工作,其中一部分就是查找和初始化模块。在演示代码中,我使用了上面提到的IModuleEnumerator。它基本上会查看指定目录中的所有DLL,并加载任何实现了IModule并且带有ModuleAttribute的类。

[Module(ModuleName="ModuleA")]

枚举过程完成后,它将根据ModuleDependencyAttribute确定模块依赖项。

[ModuleDependency("PageManagerModule")]

完成所有这些之后,它会处理这些模块,注入构造函数中的任何内容(至少是IUnityContainer,以便注册类型),然后运行Initialize()方法来准备模块使用。

MVP

有很多这样的变体,例如MVC、MVVM等等……总之,它实际上非常简单。您有一个视图(V)、一个模型(M)和一个表示器(P);很多时候,M + P是同一件事。选择哪种取决于您;但是,您不希望在您的表示器中有一个非常复杂的模型,例如。那也完全违背了这些构造的意义,它们是为了让生活更轻松而不是更难。将此作为您结构的依据是很棒的,考虑到WPF数据绑定功能;您可以得到一组非常强大的类,代码量却很少。另外,视图类中的代码隐藏越少越好。

您可以在这两个代码片段中看到它的作用;第一个是实现在一个视图上的,该视图实际上是一个UserControl。

public interface IStatusBarView
{
    IStatusBarPresentationModel Model { set; }
}

然后,我们有PresenterModel。

public StatusBarPresentationModel(IUnityContainer container, 
                                          IEventAggregator eventAggregator, 
                                          IStatusBarView view)
{
    _container = container;
    _eventAggregator = eventAggregator;

    _view = view;
    _view.Model = this;

    _eventAggregator.GetEvent<AppStatusMessageEvent>().Subscribe(
          OnAppStatusChanged, ThreadOption.UIThread, true);

    AppStatusMessage = "Ready...";
}

这里发生的事情是,IStatusBarView有一个与我们的表示模型实现的接口类型相同的属性。因此,当我们的表示模型从容器中解析出来时,容器会在注入表示器中所需的其他部分时创建一个视图实例。然后,表示器将自己设置为视图的模型。

使这一切魔法生效的部分是视图代码隐藏中Model属性的setter。

public IStatusBarPresentationModel Model
{
    set 
    {
        this.DataContext = value;
    }
}

现在,我们表示器上的所有可绑定部分都可以通过视图的DataContext访问,准备好用作WPF绑定。CAG中确实有一些关于这个的精彩信息,所以我这里不再详细介绍。

ModuleA

是的,我一夜没睡都在想这个名字……不过,对这个演示来说,它基本上无关紧要。好的,我们这里有两个项目:

  • JamSoft.CALDemo.Modules.ModuleA
  • JamSoft.CALDemo.Modules.ModuleA.Core

在主模块程序集中,我们有以下部分:

  • ModuleAModule.cs
  • ModuleAPresenterModel.cs
  • ModuleAView.xaml
  • ModuleAView.xaml.cs

在这个模块的核心中,我们有:

  • IModuleAPresenterModel.cs
  • IModuleAView.cs

这两个接口实际上可以保留在主模块程序集中。我们知道这一点,因为我们也知道应用程序中的其他模块没有请求IModuleAPresenterModel(您可以在Navigator中看到一个功能性的版本)。但是,这提供了一个简单明了的演示,说明如何在没有基础设施库的情况下将这些事物整合在一起。

反过来,Module A对自己的Core和PageManager Core有静态引用。这是为了让ModuleAPresenter能够通过请求容器注入IPageManager的引用来获取PageManager的引用,以便将自己注册为应用程序中一个可用的页面。然后,Navigator模块也引用PageManager Core,以便获取ObservableCollection<IPage>的引用,然后将这些页面在Navigator的ListBox中提供,以便在应用程序中进行导航。

Page Manager - 导航

当您查看代码时,您会发现PageManager模块被设置为在启动时加载,这样我们就可以确保Presenter可以注册为IPage对象的实例并添加到Page Manager。这并非绝对必要,因为在需要注册页面的模块中注册模块依赖项将确保PageManager模块在任何声明了对其依赖的模块初始化之前进行初始化。Navigator模块对PageManager声明了模块依赖项,因为它是在应用程序中用于控制Shell视图中MainRegion的服务。Navigator是一个ItemsControl区域,其中包含一个ListBox。然后,ListBox项模板用于样式化项,使其看起来像用户可以单击以导航到应用程序中各个页面的传统按钮。

当一个页面注册自己时,我们还有一个float值,用于在ObservableCollection<IPage>中排序这些页面,以便我们可以在运行时控制按钮的顺序,请记住,模块枚举的顺序不能依赖于决定ListBox中的排序。以下代码片段显示了PageManager类:

public class PageManager : IPageManager, INotifyPropertyChanged
{
    private ObservableCollection<IPage> _pages = 
            new ObservableCollection<IPage>();
    private IPage _currentPage;
    private readonly IEventAggregator _eventAggregator;

    public PageManager(IEventAggregator eventAggregator)
    {
        _eventAggregator = eventAggregator;
        _eventAggregator.GetEvent<PageRequestEvent>().Subscribe(
         OnPageSelected, ThreadOption.UIThread);
    }

    private void OnPageSelected(IPage page)
    {
        _currentPage = page;
        _eventAggregator.GetEvent<PageSelectedEvent>().Publish(page);
    }

     public ObservableCollection<IPage> Pages
    {
        get 
        {
            SortPages();
            return _pages;
        }
    }

    public IPage CurrentPage
    {
        get { return _currentPage; }
    }

    private void SortPages()
    {
        List<IPage> p = _pages.ToList<IPage>();
        p.Sort(new PagePositionComparer());
        _pages.Clear();
        foreach (IPage page in p)
        {
            _pages.Add(page);
        }
    }

    public IPage GetPage(string pageId)
    {
        IPage selPage = null;
        for(int i = 0; i < Pages.Count(); i++)
        {
            if (Pages[i].ID == pageId)
            {
                selPage = Pages[i];
            }
        }
        return selPage;
    }

    public event PropertyChangedEventHandler PropertyChanged;

    private void NotifyPropertyChanged(String info)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(info));
        }
    }
}

以下代码显示了MainRegionController类:

public class MainRegionController : IMainRegionController
{
    private readonly IUnityContainer _container;
    private readonly IRegionManager _regionManager;
    private readonly IEventAggregator _eventAggregator;

    private object _currentView;
    private IRegion _mainRegion;


    public MainRegionController(IUnityContainer container,
                                IRegionManager regionManager,
                                IEventAggregator eventAggregator)
    {
        _container = container;
        _regionManager = regionManager;
        _eventAggregator = eventAggregator;


        Initialize();
    }


    private void Initialize()
    {
        _eventAggregator.GetEvent<PageSelectedEvent>().Subscribe(
                         PageSelected, ThreadOption.UIThread, true);
        _mainRegion = _regionManager.Regions["MainRegion"];
    }

    private void PageSelected(IPage page)
    {
        if (page != null)
        {
            ShowPage(page);
        }
    }

    private void ShowPage(IPage page)
    {
        object newView = page.View;
        
        if (_currentView!=null)
            _mainRegion.Remove(_currentView);

        if (newView != null)
        {
            _mainRegion.Add(newView);
            _mainRegion.Activate(newView);
        }

        _currentView = newView;
    }
}

为了使这一点完整,我们还需要查看NavigatorPresentationModel.cs,以了解这些PageRequestEvent来自哪里:

public class NavigatorPresentationModel : INavigatorPresentationModel
{
    private readonly INavigatorView _view;
    private readonly IPageManager _pageManger;
    private readonly IEventAggregator _eventAggregator;


    public NavigatorPresentationModel(IEventAggregator eventAggregator,
                                      IPageManager pageManger, 
                                      INavigatorView view)
    {

        _eventAggregator = eventAggregator;
        _pageManger = pageManger;


        _view = view;
        _view.ItemChangeRequest += 
             new EventHandler<PageEventArgs>(view_ItemChangeRequest);
        _view.Model = this;

        _eventAggregator.GetEvent<PageSelectedEvent>().Subscribe(
                         OnPageSelected, ThreadOption.UIThread);
    }

    private void OnPageSelected(IPage page)
    {
        _view.SelectedItem = page;
    }

    void view_ItemChangeRequest(object sender, PageEventArgs e)
    {
        OnItemChangeRequest(e.Page);
    }

    private void OnItemChangeRequest(IPage page)
    {
        _eventAggregator.GetEvent<PageRequestEvent>().Publish(page);
    }

    public INavigatorView View
    {
        get { return _view; }
    }

    public ObservableCollection<IPage> Pages
    {
        get { return _pageManger.Pages ; }
    }
}

因此,总而言之,Navigator发布了在PageManager的Core程序集中定义的事件,而PageManager又订阅了该事件。这反过来选择页面,然后将另一个事件触发给MainRegionController,后者执行Shell视图中定义的MainRegion的实际视图切换。

ModuleB

它只包含一个表示器和一个视图以及使之成为模块的相关组件。它对PageManager Core有静态引用,以便将自己注册为应用程序中的一个页面。然而,ModuleB能够将一个字符串消息发布到StatusBar模块。

因此,ModuleB对以下Core有三个静态程序集引用:

  • JamSoft.CALDemo.Modules.ModuleB.Core
  • JamSoft.CALDemo.Modules.PageManager.Core
  • JamSoft.CALDemo.Modules.StatusBar.Core

在状态栏Core中,我们有一个单独的CompositeWpfEvent定义如下:

public class AppStatusMessageEvent : CompositeWpfEvent<string>
{
}

StatusBarPresentationModel订阅此事件,以便使用EventAggregator更新状态栏消息。

_eventAggregator.GetEvent<AppStatusMessageEvent>().Subscribe(
         OnAppStatusChanged, ThreadOption.UIThread, true);

这会调用以下代码来更新消息变量:

private void OnAppStatusChanged(string message)
{
    AppStatusMessage = message;
}

public string AppStatusMessage 
{
    get { return _appStatusMessage; }
    set 
    { 
        _appStatusMessage = value;
        NotifyPropertyChanged("AppStatusMessage");
    }
}

当您在视图中提供的TextBox中输入文本然后按按钮时,文本将“ping”到一个负载为字符串的CompositeWpfEvent。然后,它会被转发到已订阅的模块(StatusBar),每个模块都可以使用它来执行所需的操作。在StatusBar的情况下,它只是在StatusBarItem中的TextBlock中将文本打印到屏幕上。很简单。

将所有内容编译在一起

这一切是如何组织的?处理这个问题有几种方法。一体化解决方案既简单又方便,但如果您在一个开发团队中工作,那不是最好的解决方案。

编译后的演示代码组织成以下目录结构:

  • bin\
  • bin\Debug
  • bin\Debug\External
  • bin\Debug\Internal
  • bin\Debug\Modules
  • bin\Release
  • bin\Release\External
  • bin\Release\Internal
  • bin\Release\Modules
  • Ext (用于第三方控件/CAL - 基本上,您不拥有的源代码)
  • Int (您可以静态引用的核心程序集,或者您自己创建的内容)
  • ModuleA
  • ModuleB
  • Navigator
  • PageManager
  • Shell
  • StatusBar

当您构建应用程序时,各种程序集通过post-build事件分发到bin\xxxx目录中的各个位置。外部内容,如第三方控件DLL和CAL本身,会进入External目录;所有核心和其他DLL(您自己的控件源代码)会进入Internal目录,而模块本身则进入Modules目录,以便DirectoyLookupModuleEnumerator在运行时找到它们。

然后,可执行文件将被放置在根目录(bin\xxxx\)中,以及一个包含定义以控制目录探针以查找各种核心程序集的配置文件。

<configuration>
  <runtime>
    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
      <probing privatePath="Internal;External"/>
    </assemblyBinding>
  </runtime>
</configuration>

另一种实现此目标的方法是使用环境变量。您在磁盘上的解决方案结构可能使得这成为一种更易于管理的方法。一旦设置了环境变量,您就不依赖于完整路径,也不依赖于Visual Studio中的post-build事件编辑器提供的宏。

暂时就这样

我想我暂时就写到这里。这确实只是触及了表面,在某种程度上,这正是本文的初衷。在下一篇文章中,我将扩展这个应用程序,并构建一些更有趣的功能。在第二部分,我还将介绍我构建的一个模块,它能够以与CAL发现模块非常相似的方式动态发现样式DLL。然后,您的用户就可以在运行时选择他们喜欢的皮肤。

进一步阅读和有用资源

© . All rights reserved.