.NET Integrates ApplicationStartupManager Startup Framework for Large Applications

.NET Integrates ApplicationStartupManager Startup Framework for Large Applications

Imagine that the user double-clicks the desktop icon, but the application takes several minutes to start. Would the user's next step be to click Uninstall? To balance the startup process of large application software, which requires both executing complex startup logic and paying attention to startup performance, it is entirely reasonable to build a framework for this process.

Last updated 4/10/2022 8:38 AM
林德熙
34 min read
Category
.NET
Tags
.NET C#

For large-scale application software, especially client-side applications, the startup process involves executing a significant amount of logic, including initialization and registration of various modules. The startup process of large-scale applications is very complex, and client applications have performance requirements for startup, unlike server-side applications. Imagine a user double-clicks a desktop icon but has to wait several minutes for the application to launch—wouldn't their next action be to click "uninstall"? To balance the need for executing complex startup logic while focusing on startup performance in large-scale applications, it is entirely reasonable to create a framework for this process. The library my team developed for the startup process is the one I'm going to introduce in this article: the dotnetCampus.ApplicationStartupManager startup flow framework library open-sourced by our team.

Background

The origin of this library was a sharing session by the Visual Studio team. At that time, the experts told me that to optimize Visual Studio's startup performance, their team had set an interesting direction: to fully utilize CPU, memory, and disk during application startup. Of course, this was a joke; the intended meaning was that during the startup of a Visual Studio application, it should fully leverage the computer's performance. Coincidentally, my team also has many large applications with code MergeRequest counts exceeding tens of thousands. The logic complexity of these applications is extremely high. Originally, only a single thread was used to reduce the pitfalls caused by dependency complexity between modules. However, later, to optimize application startup performance, strategies to squeeze machine performance were considered, including multi-threading.

Nevertheless, when using multiple threads, many thread-related issues naturally arise. The biggest problem is how to handle the dependency relationships between various startup modules. Without a good framework to manage this, relying solely on individual developers' skills is completely unreliable—the optimization might work for this version, but what about the next?

Another very important aspect is how to monitor startup performance, such as analyzing the time consumption of each startup item. Before optimizing the performance of individual startup business modules, it is necessary to measure the performance of the startup modules. Interestingly, the startup modules are highly correlated with the user's environment—that is, the results measured in the lab have significant discrepancies from the results in actual user usage. This imposes an important requirement on the startup flow framework: the ability to easily monitor and measure the performance of each startup module.

Since multiple projects expected to adopt a startup flow framework, the framework needed to be abstract enough, ideally without coupling to any single project's functionality.

After about a year of development, the startup flow framework was officially put into use in 2019. Currently, it runs on nearly ten million devices.

The repository for this startup flow framework is now on GitHub, open-sourced under the most permissive MIT license (i.e., anyone can use it freely). Open-source address: https://github.com/dotnet-campus/dotnetCampus.ApplicationStartupManager

Features

The ApplicationStartupManager startup flow framework library open-sourced by my team offers the following selling points:

  • Automatically builds startup flow charts.
  • Supports high-performance asynchronous multi-threaded execution of startup tasks.
  • Supports automatic UI thread scheduling.
  • Dynamically allocates startup task resources.
  • Supports integration with precompilation frameworks.
  • Supports all .NET applications.
  • Startup flow time-consumption monitoring.

Startup Flow Chart

Between various startup task items, there are explicit or implicit dependencies, such as depending on the initialization of certain logic or modules, the registration of a service, or timing dependencies. After developers sort out the dependencies, they can determine the dependency relationships between each startup task, and a startup flow chart can be built based on these dependencies.

Assume the following startup task items, with mutual dependencies between them, as shown in the diagram below, where arrows indicate dependencies:

Startup flow chart 1

  • Startup Task A: The first startup task, such as initialization of logging or container.
  • Startup Task B: Some basic services, but they depend on the completion of Startup Task A to execute.
  • Startup Task C: Depends on the completion of Startup Task B.
  • Startup Task D: Another independent module, unrelated to B, C, E, but also depends on the completion of A.
  • Startup Task E: Depends on the completion of both B and C.
  • Startup Task F: Depends on the completion of both A and D.

The above startup tasks can form a directed acyclic startup flow chart. Each startup task can have its own previous or next tasks. Why must it be acyclic? If two startup tasks are mutually waiting for each other's dependencies, the startup will naturally fail. For example, if three tasks are interdependent, no matter which one starts first, the prerequisites of the first one are not met, and logically there is an unsatisfied prerequisite.

Startup flow chart 2

To better construct the startup flow chart, two virtual nodes are added logically: the start point and the end point. Every startup task depends on the virtual start point and follows the end point.

Additionally, specific business parties can define their own associated startup processes, i.e., preset startup nodes. Key startup process points will be depended upon by various startup items, thus artificially dividing the startup process into multiple stages.

For example, the startup process can be divided into the following stages:

  • Start Point: A virtual node representing application startup, used for building the startup flow chart.
  • Infrastructure: Indicates that basic services should be initialized before this point, such as logging, container, etc. Other startup tasks can depend on this node, assuming that after the infrastructure node, the infrastructure has been prepared.
  • Window Startup: Before the window initialization of a client program, UI preparation logic must be completed, such as style resources and necessary data preparation, or ViewModel injection. After the window startup, logic can be executed on UI elements, or UI-strongly-related logic can be registered. Alternatively, after window startup, those startup tasks that do not need to be executed before the main interface is shown can be executed, thus improving main interface display performance.
  • Application Startup: The startup logic is completed. Startup tasks after this node are those that can be executed slowly, such as triggering automatic updates or clearing log files.
  • End Point: A virtual node representing the full completion of the application startup process, used for building the startup flow chart.

Startup flow chart 3

As shown in the diagram, each startup task can depend on a specific startup task or on a key startup process point.

This logic prepares the ground for future optimizations and makes it easier for upper-layer business developers to create their business-layer startup tasks. It also allows upper-layer business developers to clearly understand where their new startup tasks should be placed, and provides a way to debug dependency relationships between startup tasks of various modules, identifying if there are circular dependencies.

High-Performance Asynchronous Multi-Threaded Execution of Startup Tasks

To better squeeze machine performance, multi-threading is necessary. After building the startup flow chart, startup tasks can be laid out in a tree structure, making multi-thread scheduling easier. Using .NET's Task-based scheduling, multi-threaded asynchronous waiting can be achieved, resolving thread-safety issues when multiple startup tasks depend on each other in a multi-threaded environment.

For example, using thread-pool Task scheduling, the startup task chains of different tasks can be logically assigned to different threads. The actual executing threads rely on thread-pool scheduling, and in practice, the thread pool might only use two actual threads.

Task scheduling diagram

During application startup, without understanding .NET's thread pool scheduling mechanism, there may be some controversy about enabling multi-threading. The core debate is: if an application fully uses CPU resources during startup, will it freeze the user's computer? Actually, this question is not easy to answer. If you have doubts, let me analyze it carefully. First, ask the question itself: if only one thread is used for startup, would it also freeze the user's computer? The answer is yes—it completely depends on the user's computer, including its configuration and their "demonic" environment, such as a low-end device with several Chinese antivirus software running simultaneously. At the moment of application startup, a lot of antivirus work will be executed, naturally freezing the computer. Moreover, is a frozen computer necessarily related to full CPU usage? The answer is absolutely not. During application startup, there will definitely be DLL loading, especially in cold startup scenarios. A large amount of file I/O will occupy disk read/write, especially on mechanical hard drives, naturally causing the computer to freeze. This process has little relationship with multi-threading; the performance gap between mechanical drives and CPU is what matters. Second, is the duration of freezing important? For example, a multi-threaded startup might cause a 500ms freeze, while single-threaded startup might take 4×500ms = 2 seconds. Is multi-threading worth it? This requires trade-offs. Different applications have different logic. For productivity tools, if I turn on the computer specifically to use that tool (e.g., Visual Studio for coding), there is no need for synchronous use during the process—freeze if it freezes. The last point is that using .NET's multi-threading does not at all equal fully occupying CPU resources; don't forget I/O asynchrony.

Of course, developers who adopt this application flow framework are not newcomers. They likely have some knowledge about threading and will choose appropriate ways to execute startup tasks. This also implies that integrating this startup flow framework has a certain learning curve.

Supporting Automatic UI Thread Scheduling

For client applications, there is naturally a special UI thread. During startup, many logic items need to run on the UI thread. Since different .NET application frameworks handle UI thread scheduling differently, the startup flow framework needs to perform some adaptation.

By marking a startup task as requiring execution on the UI thread, the framework will automatically schedule it to the UI thread.

By design, startup tasks are scheduled to run on non-UI threads by default.

Dynamic Resource Allocation for Startup Tasks

The actual duration of startup tasks on user machines often differs significantly from lab test results on developer or test machines. If startup tasks are executed in a fixed order, there will be much idle waiting time. This startup flow framework supports dynamic scheduling based on the actual time consumption of each startup task during the startup process.

The core method is the built startup flow chart, which supports waiting logic for each task. Based on the Task waiting mechanism, dynamic scheduling can be implemented, enabling multiple threads to be fully occupied with startup tasks in a tight timeframe. If the corresponding upper-layer business developers correctly use the Task mechanism (e.g., correct asynchronous waiting), significant startup time can be hidden.

Supporting Integration with Precompilation Frameworks

The startup process is performance-sensitive. How to collect startup tasks from various modules is a major issue. The startup part is performance-sensitive, making reflection unsuitable. Fortunately, dotnet campus had the technical reserves; in 2018, they open-sourced SourceFusion precompilation framework, and later in 2020, learning from the pitfalls of SourceFusion, they open-sourced dotnetCampus.Telescope precompilation framework. The new dotnetCampus.Telescope is also placed in the SourceFusion repository.

From the early development of the ApplicationStartupManager startup flow framework, integration with a precompilation framework was considered. By using precompilation, the ability to collect startup tasks without reflection is provided, greatly reducing the performance overhead of reflecting assemblies during startup.

Integrating with a precompilation framework essentially moves execution time from the user's side to the developer's compile time, where logic that originally ran on the user's end is executed at compile time on the developer's side. This reduces execution time on the user's side.

By adopting the precompilation framework, at compile time, all startup tasks from the project can be collected, including startup task types, delegate creation methods for startup tasks, and their attribute characteristics.

Startup Flow Time-Consumption Monitoring

For large applications, it is very important to monitor the actual running effect on user devices. Monitoring is crucial during startup. The greatest significance of monitoring includes:

First, it provides insight into the actual execution time of each startup task on user devices, providing data support for performance optimization in subsequent versions. Without data from diverse user devices, it's hard to identify true performance bottlenecks. For example, not only should we focus on the 95th percentile startup distribution (the startup time distribution for 95% of users), but also on the startup distribution between 95th and 99th percentiles to understand unique device environments and perform special optimizations.

Second, it enables version comparison and early warning. For large applications, there are usually mechanisms like canary releases and pre-release builds. By monitoring startup time during canary releases, an early warning system can be integrated to alert developers when a startup task's time consumption increases. This benefits the project's long-term development.

Finally, it can inform users which step is causing slow startup. This mechanism focuses on openness; for example, Visual Studio continuously informs you which plugin is causing slow startup.

Usage

After stripping away customization needs of individual projects, the startup flow framework library only contains core logic. This means that during usage, the specific business party still needs to add their own initialization logic and adapt it to the business's specifics. In other words, integrating the startup flow framework is not simply installing a library and calling an API; it requires some integration work based on the application's business needs. Fortunately, the startup flow framework is only suitable for large projects or projects expected to become large. Compared to other logic in large applications, the amount of code to integrate the startup flow framework is negligible. For small projects or non-multi-person collaboration projects, it is naturally unsuitable.

The overall design of the ApplicationStartupManager startup flow framework is high-performance, minimizing internal performance losses. However, adopting the startup flow framework itself introduces some framework performance overhead. If it's a small project or a non-multi-person collaboration project where startup tasks can be manually arranged, manual arrangement obviously achieves the highest performance.

The contradiction that the ApplicationStartupManager startup flow framework resolves is between project complexity plus multi-person communication, and startup performance. Adopting the startup flow framework shields upper-layer business developers from the details of the startup process, making it easier for them to add startup tasks based on business needs, and easier for startup module maintainers to locate and optimize performance.

As usual, the first step in using a .NET library is to install it via NuGet.

Step 1: Install the ApplicationStartupManager library via NuGet. If the project uses SDK-style project file format, you can add the following code to the csproj file to install:

<ItemGroup>
    <PackageReference Include="dotnetCampus.ApplicationStartupManager" Version="0.0.1-alpha01" />
</ItemGroup>

To easily show the effect of the ApplicationStartupManager startup flow framework library, I will use the example code from https://github.com/dotnet-campus/dotnetCampus.ApplicationStartupManager as a sample.

Create three projects as follows:

  • WPFDemo.Lib1: Represents various underlying component libraries, especially business components.
  • WPFDemo.Api: The API layer assembly of the application, where the startup flow framework logic will be deployed.
  • WPFDemo.App: The top-level application project, where the Main function resides; triggers the startup logic.

The approximate abstracted application model architecture is shown below. For simplicity, the Business layer and App layer are combined, and multiple Lib components are combined into one Lib1 project.

Project architecture diagram

After creating the projects and installing the NuGet packages, the next step is to build the application-specific startup framework logic in the API layer. Why do you need extra logic in the API layer after installing the NuGet package? Each application has its own unique logic, the required parameters for startup tasks differ, the logging methods can differ, and the startup nodes vary for different types of applications. All these require application-specific customization.

First, define the application-specific preset startup nodes:

/// <summary>
/// Contains preset startup nodes.
/// </summary>
public class StartupNodes
{
    /// <summary>
    /// Basic services (logging, exception handling, container, lifecycle management, etc.) should start before this node; other services should start after.
    /// </summary>
    public const string Foundation = "Foundation";

    /// <summary>
    /// Tasks that need to start before any Window is created should complete before this node.
    /// After this node, the UI will start.
    /// </summary>
    public const string CoreUI = "CoreUI";

    /// <summary>
    /// Tasks that need to start after the main <see cref="Window"/> is created should start after this node.
    /// The completion of this node indicates that the main UI has been initialized (but may not be displayed yet).
    /// </summary>
    public const string UI = "UI";

    /// <summary>
    /// The application has completed startup. If a window should be displayed, this window has been laid out, rendered, fully visible to the user, and can be interacted with.
    /// Modules not dependent on other services can start after this node.
    /// </summary>
    public const string AppReady = "AppReady";

    /// <summary>
    /// Any startup task that does not care about when it starts should be completed before this node.
    /// </summary>
    public const string StartupCompleted = "StartupCompleted";
}

After definition, the startup process can be divided into the following stages:

Startup stages diagram

Next, define a log type associated with the application business. Different applications log differently and use different underlying logging libraries.

/// <summary>
/// Project-related logger.
/// </summary>
public class StartupLogger : StartupLoggerBase
{
    public void LogInfo(string message)
    {
        Debug.WriteLine(message);
    }

    public override void ReportResult(IReadOnlyList<IStartupTaskWrapper> wrappers)
    {
        var stringBuilder = new StringBuilder();
        foreach (var keyValuePair in MilestoneDictionary)
        {
            stringBuilder.AppendLine($"{keyValuePair.Key} - [{keyValuePair.Value.threadName}] Start:{keyValuePair.Value.start} Elapsed:{keyValuePair.Value.elapsed}");
        }

        Debug.WriteLine(stringBuilder.ToString());
    }
}

In this example, logs are written to Debug.WriteLine output, and the logger also adds a LogInfo method.

Continue customizing the application-specific startup task parameters. For example, the sample project uses dotnetCampus.CommandLine for command-line parameter parsing, which might be needed by startup tasks, so it should be included as a property of the startup task parameters. The sample project also uses dotnetCampus.Configurations high-performance configuration library, also needed by startup tasks, so it is included in the startup task parameters.

The startup task parameter definition with added application-specific properties is as follows:

public class StartupContext : IStartupContext
{
    public StartupContext(IStartupContext startupContext, CommandLine commandLine, StartupLogger logger, FileConfigurationRepo configuration, IAppConfigurator configs)
    {
        _startupContext = startupContext;
        Logger = logger;
        Configuration = configuration;
        Configs = configs;
        CommandLine = commandLine;
        CommandLineOptions = CommandLine.As<Options>();
    }

    public StartupLogger Logger { get; }

    public CommandLine CommandLine { get; }

    public Options CommandLineOptions { get; }

    public FileConfigurationRepo Configuration { get; }

    public IAppConfigurator Configs { get; }

    public Task<string> ReadCacheAsync(string key, string @default = "")
    {
        return Configuration.TryReadAsync(key, @default);
    }

    private readonly IStartupContext _startupContext;
    public Task WaitStartupTaskAsync(string startupKey)
    {
        return _startupContext.WaitStartupTaskAsync(startupKey);
    }
}

To continue using the WaitStartupTaskAsync functionality, the constructor still includes IStartupContext to obtain the default startup task parameters provided by the framework. The Configuration and Configs properties above are from the dotnetCampus.Configurations high-performance configuration library, which supports reading and writing configuration files in COIN format.

After defining the startup task parameters, the base type for the application's startup tasks can be customized. Since the base type is tied to the startup task parameters, and those parameters vary per application, the base type will differ accordingly. Even if the difference is only in the parameters, using generics at the code level can solve the issue, but it would increase code volume in the business layer, so it's better to define at the application level.

/// <summary>
/// Represents a startup task strongly associated with the current business.
/// </summary>
public class StartupTask : StartupTaskBase
{
    protected sealed override Task RunAsync(IStartupContext context)
    {
        return RunAsync((StartupContext) context);
    }

    protected virtual Task RunAsync(StartupContext context)
    {
        return CompletedTask;
    }
}

As shown above, all business-layer startup tasks in the application should inherit StartupTask as their base class. After inheritance, they override the RunAsync method and execute business logic within it.

The design here makes RunAsync a virtual method rather than an abstract method because some application business may require placeholder startup tasks with no actual logic, used only to optimize startup flow arrangement. Additionally, it solves the problem for upper-layer business developers who might write only synchronous logic and don't know how to return a Task from RunAsync. They can naturally return the result of base.RunAsync(...), reducing awkward methods of returning a Task.

After customizing the base startup task type, a StartupManager type associated with the application business, derived from StartupManagerBase, must be written. This type should contain the logic for how to execute specific startup tasks. The code is as follows:

/// <summary>
/// Project-related startup manager, used to inject business-specific logic.
/// </summary>
public class StartupManager : StartupManagerBase
{
    public StartupManager(CommandLine commandLine, FileConfigurationRepo configuration, Func<Exception, Task> fastFailAction, IMainThreadDispatcher mainThreadDispatcher) : base(new StartupLogger(), fastFailAction, mainThreadDispatcher)
    {
        var appConfigurator = configuration.CreateAppConfigurator();
        Context = new StartupContext(StartupContext, commandLine, (StartupLogger) Logger, configuration, appConfigurator);
    }

    private StartupContext Context { get; }

    protected override Task<string> ExecuteStartupTaskAsync(StartupTaskBase startupTask, IStartupContext context, bool uiOnly)
    {
        return base.ExecuteStartupTaskAsync(startupTask, Context, uiOnly);
    }
}

The above code overrides ExecuteStartupTaskAsync to pass the business-specific StartupContext parameters when invoking specific startup tasks.

If the application has more requirements, more methods of StartupManagerBase can be overridden, including ExportStartupTasks to export all startup items, or AddStartupTaskMetadataCollector to define how to import startup information from managed assemblies, etc.

After the above steps, there is one more thing to complete: the newly created WPFDemo.Api project does not have WPF dependencies. However, in the application, some startup tasks need to run on the UI thread. Therefore, we define the UI thread dispatcher in the WPFDemo.App project, which has WPF dependencies.

class MainThreadDispatcher : IMainThreadDispatcher
{
    public async Task InvokeAsync(Action action)
    {
        await Application.Current.Dispatcher.InvokeAsync(action);
    }
}

After completing the above, the startup framework can be run in Program.cs's Main method. In the Program type of WPFDemo.App project, parse the command line first, then create the App and start the startup framework.

[STAThread]
static void Main(string[] args)
{
    var commandLine = CommandLine.Parse(args);

    var app = new App();

    // Start startup tasks
    StartStartupTasks(commandLine);

    app.Run();
}

Inside the StartStartupTasks method, use Task.Run to run the startup framework on a background thread, allowing the main thread (the UI thread of this application) to start UI-related logic.

private static void StartStartupTasks(CommandLine commandLine)
{
    Task.Run(() =>
    {
        // 1. Read application configuration
        // The application will decide startup behavior based on configuration.
        var configFilePath = "App.coin";
        var repo = ConfigurationFactory.FromFile(configFilePath);

        // 2. Integrate precompilation module to obtain startup tasks
        var assemblyMetadataExporter = new AssemblyMetadataExporter(BuildStartupAssemblies());

        // 3. Create startup framework and run it
        var startupManager = new StartupManager(commandLine, repo, HandleShutdownError, new MainThreadDispatcher())
            // 3.1 Import preset application startup nodes, necessary step; business startup tasks will base their order on these.
            .UseCriticalNodes
            (
                StartupNodes.Foundation,
                StartupNodes.CoreUI,
                StartupNodes.UI,
                StartupNodes.AppReady,
                StartupNodes.StartupCompleted
            )
            // 3.2 Export startup items from assemblies
            .AddStartupTaskMetadataCollector(() =>
                // This is all startup tasks collected by the precompilation module.
                assemblyMetadataExporter.ExportStartupTasks());
        startupManager.Run();
    });
}

In the sample application, some business logic depends on configuration to decide the startup process, so the configuration must be read first. Using the dotnetCampus.Configurations high-performance configuration library can minimize startup time consumed by configuration reading. The example above also integrates with the precompilation module. The precompilation module collects all startup tasks in the application, greatly reducing the time spent on collecting startup tasks, and eliminates the need for upper-layer business developers to manually register startup tasks.

The above code enables the startup framework to run after the Main function starts. However, the code won't compile yet because the AssemblyMetadataExporter logic (related to the precompilation module) has not been completed.

This does not mean the startup framework strongly depends on the precompilation module; it is optional. Any logic that can connect to the AddStartupTaskMetadataCollector method and pass the required startup tasks for the application will work. Any method, including reflection, is acceptable. The precompilation module is only used to optimize performance and reduce the time taken to collect startup tasks.

Next is the integration logic of the precompilation module. This article does not cover the principles of the Telescope precompilation module, only how to integrate it.

Like other .NET libraries, to integrate the precompilation module, first install the NuGet library. Install the dotnetCampus.Telescope library via NuGet. If using the new SDK-style project file, edit the csproj file to add the following code:

<ItemGroup>
    <PackageReference Include="dotnetCampus.TelescopeSource" Version="1.0.0-alpha02" />
</ItemGroup>

Unlike other libraries, because dotnetCampus.Telescope precompilation framework processes the project code itself, each project that uses precompilation must install this library. Therefore, it needs to be installed for all three projects, not automatically through reference dependencies.

After installation, create an AssemblyInfo.cs file in the project and add attributes to the assembly. Conventionally, the AssemblyInfo.cs file should be placed in the Properties folder. This folder has a special icon in Visual Studio.

In the AssemblyInfo.cs file, add the following code:

[assembly: dotnetCampus.Telescope.MarkExport(typeof(WPFDemo.Api.StartupTaskFramework.StartupTask), typeof(dotnetCampus.ApplicationStartupManager.StartupTaskAttribute))]

The above is the code to integrate the precompilation framework, very simple. By adding the dotnetCampus.Telescope.MarkExportAttribute to the assembly, you mark the types to be exported by precompilation. The two parameters passed are the base type to export and the attribute type that the exported classes inherit.

The above code indicates exporting all types that inherit from WPFDemo.Api.StartupTaskFramework.StartupTask and are marked with the dotnetCampus.ApplicationStartupManager.StartupTaskAttribute.

After marking, rebuild the code. A generated file AttributedTypesExport.g.cs will appear in the obj folder. For example, in the sample project, the generated file path is:

C:\lindexi\Code\ApplicationStartupManager\demo\WPFDemo\WPFDemo.Api\obj\Debug\net6.0\TelescopeSource.GeneratedCodes\AttributedTypesExport.g.cs

Suppose a startup task named Foo1Startup is defined as follows:

[StartupTask(BeforeTasks = StartupNodes.CoreUI, AfterTasks = StartupNodes.Foundation)]
public class Foo1Startup : StartupTask
{
    protected override Task RunAsync(StartupContext context)
    {
        context.Logger.LogInfo("Foo1 Startup");
        return base.RunAsync(context);
    }
}

Then the generated AttributedTypesExport.g.cs will contain the following code:

using dotnetCampus.ApplicationStartupManager;
using dotnetCampus.Telescope;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using WPFDemo.Api.StartupTaskFramework;

namespace dotnetCampus.Telescope
{
    public partial class __AttributedTypesExport__ : ICompileTimeAttributedTypesExporter<StartupTask, StartupTaskAttribute>
    {
        AttributedTypeMetadata<StartupTask, StartupTaskAttribute>[] ICompileTimeAttributedTypesExporter<StartupTask, StartupTaskAttribute>.ExportAttributeTypes()
        {
            return new AttributedTypeMetadata<StartupTask, StartupTaskAttribute>[]
            {
                new AttributedTypeMetadata<StartupTask, StartupTaskAttribute>(
                    typeof(WPFDemo.Api.Startup.Foo1Startup),
                    new StartupTaskAttribute()
                    {
                        BeforeTasks = StartupNodes.CoreUI,
                        AfterTasks = StartupNodes.Foundation
                    },
                    () => new WPFDemo.Api.Startup.Foo1Startup()
                ),
            };
        }
    }
}

This means the startup items in the assembly are automatically collected, generating the collection code.

In the startup framework module, create a type called AssemblyMetadataExporter to retrieve the collected types from AttributedTypesExport.g.cs. The method to obtain the __AttributedTypesExport__ generated type from Telescope is to call the FromAssembly method on AttributedTypes. The code is as follows:

IEnumerable<AttributedTypeMetadata<StartupTask, StartupTaskAttribute>> collection = AttributedTypes.FromAssembly<StartupTask, StartupTaskAttribute>(_assemblies);

The _assemblies parameter passed is the list of assemblies from which to collect startup tasks. Calling this code will retrieve the precompilation-collected types from each assembly.

Wrap the return value into StartupTaskMetadata and return it to the startup framework.

using System.Reflection;
using dotnetCampus.ApplicationStartupManager;
using dotnetCampus.Telescope;

namespace WPFDemo.Api.StartupTaskFramework
{
    public class AssemblyMetadataExporter
    {
        public AssemblyMetadataExporter(Assembly[] assemblies)
        {
            _assemblies = assemblies;
        }

        public IEnumerable<StartupTaskMetadata> ExportStartupTasks()
        {
            var collection = Export<StartupTask, StartupTaskAttribute>();
            return collection.Select(x => new StartupTaskMetadata(x.RealType.Name.Replace("Startup", ""), x.CreateInstance)
            {
                Scheduler = x.Attribute.Scheduler,
                BeforeTasks = x.Attribute.BeforeTasks,
                AfterTasks = x.Attribute.AfterTasks,
                // Categories = x.Attribute.Categories,
                CriticalLevel = x.Attribute.CriticalLevel,
            });
        }

        public IEnumerable<AttributedTypeMetadata<TBaseClassOrInterface, TAttribute>> Export<TBaseClassOrInterface, TAttribute>() where TAttribute : Attribute
        {
            return AttributedTypes.FromAssembly<TBaseClassOrInterface, TAttribute>(_assemblies);
        }

        private readonly Assembly[] _assemblies;
    }
}

Back in Program.cs, create a BuildStartupAssemblies method that lists the assemblies from which to collect startup tasks and passes them to AssemblyMetadataExporter.

class Program
{
    private static void StartStartupTasks(CommandLine commandLine)
    {
        Task.Run(() =>
        {
            var assemblyMetadataExporter = new AssemblyMetadataExporter(BuildStartupAssemblies());

            // Other logic omitted
        });
    }

    private static Assembly[] BuildStartupAssemblies()
    {
        // Initialize all modules collected by precompilation.
        return new Assembly[]
        {
            // WPFDemo.App
            typeof(Program).Assembly,
            // WPFDemo.Lib1
            typeof(Foo2Startup).Assembly,
            // WPFDemo.Api
            typeof(Foo1Startup).Assembly,
        };
    }
}

Using StartupManager's AddStartupTaskMetadataCollector, the exported startup tasks can be added to the startup framework.

var assemblyMetadataExporter = new AssemblyMetadataExporter(BuildStartupAssemblies());

var startupManager = new StartupManager(/* ignored code */)
    // Export startup items from assemblies
    .AddStartupTaskMetadataCollector(() => assemblyMetadataExporter.ExportStartupTasks());

startupManager.Run();

This completes all configuration logic for the application's startup framework. Next, each business module writes its startup logic.

Add startup task items for each business module to demonstrate the usage of the startup framework.

In WPFDemo.App, add MainWindowStartup to start the main window, with the following code:

using System.Threading.Tasks;
using dotnetCampus.ApplicationStartupManager;
using WPFDemo.Api.StartupTaskFramework;

namespace WPFDemo.App.Startup
{
    [StartupTask(BeforeTasks = StartupNodes.AppReady, AfterTasks = StartupNodes.UI, Scheduler = StartupScheduler.UIOnly)]
    internal class MainWindowStartup : StartupTask
    {
        protected override Task RunAsync(StartupContext context)
        {
            var mainWindow = new MainWindow();
            mainWindow.Show();

            return CompletedTask;
        }
    }
}

The above code uses the StartupTask attribute to indicate that this startup task must complete before AppReady and after UI, requiring scheduling on the UI thread. For main window display, other UI-related logic (e.g., ViewModel registration, style dictionary initialization) must complete before the window can be shown. Only after the main window is ready can the application be considered AppReady. Hence, the startup tasks can be arranged this way.

Next, add a business-related startup task, BusinessStartup, implementing business logic that requires adding a button to the main interface. The requirement is that BusinessStartup can only start after MainWindowStartup completes.

[StartupTask(BeforeTasks = StartupNodes.AppReady, AfterTasks = "MainWindowStartup", Scheduler = StartupScheduler.UIOnly)]
internal class BusinessStartup : StartupTask
{
    protected override Task RunAsync(StartupContext context)
    {
        if (Application.Current.MainWindow.Content is Grid grid)
        {
            grid.Children.Add(new Button()
            {
                HorizontalAlignment = HorizontalAlignment.Center,
                VerticalAlignment = VerticalAlignment.Bottom,
                Margin = new Thickness(10, 10, 10, 10),
                Content = "Click"
            });
        }

        return CompletedTask;
    }
}

In BusinessStartup, the AfterTasks is set to "MainWindowStartup", indicating that it can only execute after MainWindowStartup completes.

Additionally, dependencies can cross multiple projects. For example, in the infrastructure layer, there is LibStartup in the WPFDemo.Lib1 assembly representing initialization of a certain component. This component belongs to the infrastructure and is specified via BeforeTasks to run before the Foundation preset startup node.

[StartupTask(BeforeTasks = StartupNodes.Foundation)]
class LibStartup : StartupTask
{
    protected override Task RunAsync(StartupContext context)
    {
        context.Logger.LogInfo("Lib Startup");
        return base.RunAsync(context);
    }
}

As seen, in this framework design, RunAsync of StartupTask is a virtual method, making it convenient for business integration. For synchronous logic, the base method can be called to return a Task object.

The above code only sets BeforeTasks without setting AfterTasks, so AfterTasks will default to the virtual start point—meaning it does not need to wait for other startup items.

In the WPFDemo.Api assembly, there is OptionStartup that decides execution logic based on command-line arguments. This also belongs to infrastructure but depends on LibStartup's completion. The code is as follows:

[StartupTask(BeforeTasks = StartupNodes.Foundation, AfterTasks = "LibStartup")]
class OptionStartup : StartupTask
{
    protected override Task RunAsync(StartupContext context)
    {
        context.Logger.LogInfo("Command " + context.CommandLineOptions.Name);

        return CompletedTask;
    }
}

This allows OptionStartup to execute after LibStartup and before Foundation.

The startup flow chart for the above code is as follows, where LibStartup and OptionStartup do not require the UI thread; by default, they are scheduled to the thread pool.

Startup flow chart 4

Both BeforeTasks and AfterTasks can accept multiple startup items, separated by semicolons. Alternatively, you can use BeforeTaskList and AfterTaskList as arrays. For example, suppose there are Foo1Startup in WPFDemo.Api, and Foo2Startup and Foo3Startup in WPFDemo.Lib1. Foo3Startup depends on the completion of Foo1Startup and Foo2Startup. The code can be:

[StartupTask(BeforeTasks = StartupNodes.CoreUI, AfterTaskList = new[] { nameof(WPFDemo.Lib1.Startup.Foo2Startup), "Foo1Startup" })]
public class Foo3Startup : StartupTask
{
    protected override Task RunAsync(StartupContext context)
    {
        context.Logger.LogInfo("Foo3 Startup");
        return base.RunAsync(context);
    }
}

The above describes the method for applications to integrate the ApplicationStartupManager startup flow framework, as well as examples of how business parties write startup tasks. The above code is located in the example project of https://github.com/dotnet-campus/dotnetCampus.ApplicationStartupManager.

Keep Exploring

Related Reading

More Articles