MAUIアプリで項目の多い設定画面を実装したのですが、リフレクションでの読み書きが精神衛生上悪かったので、ソースジェネレーターを使って静的コードを生やすことにしました。

ソースジェネレーターの環境構築や機能の解説は、先人の方々の記事をご覧ください。熟読推奨です。
2022年(2024年)のC# Incremental Source Generator開発手法
【C#】Incremental Source Generator入門

背景

下記のような設定項目を定義したレコードがあるとします。

public partial record QuickUploadSettings
{
    /// <summary>
    /// アップロード完了後の動作
    /// <para>Return to the list screen after uploading</para>
    /// </summary>
    [EnhancedDisplay("Action after uploading", "Sets the action after the upload button is pressed.")]
    [SettingPageItemType(SettingPageItemType.Picker)]
    public required AfterUploadOperation AfterUploadOperation { get; set; }


 // テキスト共有
    const string TextSharing = "Text Sharing";
    /// <summary>
    /// テキスト共有時にタイトルプロパティに値を設定する
    /// </summary>
    [EnhancedDisplay("Set to Title property", "Title property is set when sharing text.", GroupName = TextSharing)]
    [SettingPageItemType(SettingPageItemType.Checkbox)]
    public required bool TextToTitle { get; set; }

    /// <summary>
    /// テキストプロパティに値を設定する
    /// </summary>
    [EnhancedDisplay("Set to Text property", /*"Text property is set when sharing text.",*/ GroupName = TextSharing)]
    [SettingPageItemType(SettingPageItemType.Checkbox)]
    public required bool TextToText { get; set; }

    /// <summary>
    /// URLプロパティに値を設定する
    /// </summary>
    [EnhancedDisplay("Set to URL property", /*"URK property is set when sharing text."*/ GroupName = TextSharing)]
    [SettingPageItemType(SettingPageItemType.Checkbox)]
    public required bool TextToUrl { get; set; }

// 以下省略。設定項目となるメンバーがたくさんある想定。
}



これをUIに起こすためにバインディング用のモデルクラスに変換し、ObservableCollection<T>に追加します↓

// UIバインディング用のモデルクラスに変換するために、設定値や属性で定義したメタデータをリフレクションで取得することになる。
internal static void LoadSettings(ObservableCollection<SettingItemGroup> settings, object settingsClass)
{
    var propertyGroups = ReflectionCache.GetProperties(settingsClass.GetType())
        .Select(x => (property: x, display: x.GetEnhancedDisplay()))
        .Where(x => x.display.IsVisible)
        .GroupBy(x => x.display.GroupName);

    bool firstGroup = true;
    foreach(var propertyGroup in propertyGroups)
    {
        var settingItems = propertyGroup.Select(settingsClass, static (x, instance) => SettingPageItemFactory.Create(x.property, x.display, x.property.GetValue(instance))).ToArray();
        var group = new SettingItemGroup(propertyGroup.Key, settingItems, firstGroup);
        settings.Add(group);
        firstGroup = false;
    }
}

file static class SettingPageItemFactory
{
    public static SettingPageItem Create(PropertyInfo property, EnhancedDisplayAttribute display, object value)
    {
        return property.GetSettingPageItemType() switch
        {
            SettingPageItemType.Checkbox => new SettingCheckbox(property,display, (bool)value),
            SettingPageItemType.Switch => new SettingSwitch(property, display, (bool)value),
            SettingPageItemType.Picker => new SettingPicker(property, display, value),
            SettingPageItemType.Action => new SettingAction(property, display),
            _ => throw new NotImplementedException(),
        };
    }
}



設定値を保存するときも読み込み時と同様、リフレクションで設定項目に値をセットします。

internal static bool SaveSettings(ObservableCollection<SettingItemGroup> settings, object settingsClass)
{
    var allItems = settings.SelectMany(x => x).ToDictionary(x => x.SettingPropertyName, x => x);
    bool isChanged = false;
    foreach(var items in allItems.Values)
    {
        if(!items.EqualsBeforeValue())
        {
            isChanged = true;
        }
    }

    foreach(var property in ReflectionCache.GetProperties(settingsClass.GetType()).Where(x => x.GetEnhancedDisplay().IsVisible))
    {
        var currentValue = allItems[property.Name].CurentValue;
        property.SetValue(settingsClass, currentValue);
    }

    return isChanged;
}

問題点

設定項目のメタデータは簡潔に設定したいので属性を使いたいのですが、このような設計では項目数が多くなると必然的にリフレクションで読み書きすることになります。また、設定項目とその設定値をMemberInfoやobjectで扱うことになるので、バインディング用のモデルクラスへ渡す時にダウンキャストが必要になるか、モデルクラスに渡すデータをobjectで保有することになってしまいます。型安全性が失われ、objectを値型でやり取りしようものなら、各所でボックス化しまくってパフォーマンスも悪化します。設定項目が少ないのであれば、この程度のリフレクション・ボックス化コストは無視できますが、規模が大きくなってくるとこの影響が顕著に現れます。

改善

設定項目の読み込みと保存処理にリフレクションを使用せず、ソースジェネレーターを活用して全てのメンバーの読み書き処理をコンパイル時に生成します。流れとしてはSettingsStoreという属性が付与されたrecordを検出し、リフレクションで設定項目となるメンバーを走査して各メタデータと設定値を取得します。そして、取得した情報をソース生成のためのコンテキスト(GenerationContext)に整形してからGenerateメソッドでコード生成を行う感じです。

[SettingsStore] // コード生成のトリガーとなる属性を付与
public partial record QuickUploadSettings
[Generator(LanguageNames.CSharp)]
public sealed class SettingsStoreGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // ソース生成先の名前空間を取得
        var namespaceProvider = context.AnalyzerConfigOptionsProvider.Select((options, _) =>
        {
            options.GlobalOptions.TryGetValue("build_property.RootNamespace", out var rootNamespace);
            return rootNamespace ?? string.Empty;
        });

        // SettingsStoreAttributeが付与されたrecordを取得してGenerationContextに整形
        var settingsSyntax = context.SyntaxProvider.ForAttributeWithMetadataName("SampleApp.Core.SettingsStoreAttribute",
            predicate: static (node, _) => node is RecordDeclarationSyntax,
            transform: static (ctx, _) => Transform(ctx));

        var combined = namespaceProvider.Combine(settingsSyntax.Collect());
        context.RegisterSourceOutput(combined, (context, source) => Generate(context, source));
    }

    static GenerationContext Transform(GeneratorAttributeSyntaxContext ctx)
    {
        // recordのメンバーを走査して、設定項目一覧のシンボルを取得
        var symbol = (INamedTypeSymbol)ctx.TargetSymbol;
        var members = symbol.GetMembers()
            .Where(x => 
                x.DeclaredAccessibility is Accessibility.Public &&
                x.Kind is SymbolKind.Property && 
                !x.IsStatic && 
                !x.IsImplicitlyDeclared) // recordで暗黙的に生成されるEqualityContractプロパティを除外
            .ToList();

        // 各メンバーの属性情報から必要なメタデータを取得
        var settingItemContexts = new List<SettingItem>(members.Count);
        foreach(var member in members)
        {
            var propertySymbol = member as IPropertySymbol;
            var propertyType = propertySymbol.Type;
            var enhancedDisplayAttribute = member.GetAttributes().FirstOrDefault(x => x.AttributeClass?.ToDisplayString() == "SampleApp.Core.EnhancedDisplayAttribute");
            var itemTypeAttribute = member.GetAttributes().FirstOrDefault(x => x.AttributeClass?.ToDisplayString() == "SampleApp.Core.SettingPageItemTypeAttribute");
            var itemTypeArg = itemTypeAttribute.ConstructorArguments[0];
            var itemTypeName = itemTypeArg.Type.GetMembers().OfType<IFieldSymbol>().FirstOrDefault(f => f.HasConstantValue && Equals(f.ConstantValue, itemTypeArg.Value))?.Name;
            var displayConstructorArgs = enhancedDisplayAttribute.ConstructorArguments;
            var displayNamedArgs = enhancedDisplayAttribute.NamedArguments;
            const int KnownColorBlack = 35; // System.Drawing.KnownColor.Black
            bool isVisible = displayNamedArgs.FirstOrDefault(x => x.Key == "IsVisible").Value.Value is bool visible ? visible : true;
            if(!isVisible)
                continue;

            settingItemContexts.Add(new SettingItem()
            {
                PropertyName = member.Name,
                SettingPageItemClass = IsEnumType(propertyType) && itemTypeName == "Picker" ? $"Setting{itemTypeName}<{propertyType.ToDisplayString()}>" : $"Setting{itemTypeName}",
                IsAction = itemTypeName == "Action",
                Title = displayConstructorArgs[0].Value?.ToString(),
                Description = displayConstructorArgs[1].Value?.ToString(),
                GroupName = displayNamedArgs.FirstOrDefault(x => x.Key == "GroupName").Value.Value?.ToString(),
                DescriptionKnownColorInt = Convert.ToInt32(displayNamedArgs.FirstOrDefault(x => x.Key == "DescriptionKnownColor").Value.Value ?? KnownColorBlack),
                IsDescriptionBold = displayNamedArgs.FirstOrDefault(x => x.Key == "IsDescriptionBold").Value.Value is bool visible2 ? visible2 : false,
            });
        }

        // 設定画面表示用に、GroupNameでグループ化
        var settingItemGroups = settingItemContexts
            .GroupBy(x => x.GroupName)
            .Select(g => new SettingItemGroup()
            {
                GroupName = g.Key,
                SettingItems = new EquatableArray<SettingItem>(g.ToArray()),
            })
            .ToArray();

        // GenerationContextに整形して返す
        return new GenerationContext()
        {
            ClassName = symbol.Name,
            SettingItemGroups = settingItemGroups,
        };
    }

    static void Generate(SourceProductionContext context, (string namespaceName, ImmutableArray<GenerationContext> generationContexts) source)
    {
        (string namespaceName, ImmutableArray<GenerationContext> generationContexts) = source;
        // 各グループ・設定項目からコード生成
        foreach(var generationContext in generationContexts)
        {
            var className = generationContext.ClassName;
            var classVariableName = char.ToLowerInvariant(className[0]) + className.Substring(1);

            var sb = new StringBuilder();
            sb.AppendLine($$"""
// <auto-generated />
using System.Collections.ObjectModel;

namespace {{namespaceName}};

public static partial class {{className}}Store
{
    public static void Load(ObservableCollection<SettingItemGroup> settings, {{className}} {{classVariableName}})
    {
""");
            bool firstItemGroup = true;
            foreach(var group in generationContext.SettingItemGroups)
            {
                sb.AppendLine($$"""
        settings.Add(new SettingItemGroup("{{group.GroupName}}", new SettingPageItem[]
        {
""");
                foreach(var item in group.SettingItems)
                {
                    var constructorArg = item.IsAction ? string.Empty : $"{classVariableName}.{item.PropertyName}";
                    sb.AppendLine($$"""
            new {{item.SettingPageItemClass}}({{constructorArg}})
            {
                SettingPropertyName = nameof({{className}}.{{item.PropertyName}}),
                Title = "{{item.Title}}",
                Description = "{{item.Description}}",
                DescriptionKnownColor = (global::System.Drawing.KnownColor){{item.DescriptionKnownColorInt}},
                IsDescriptionBold = {{item.IsDescriptionBold.ToString().ToLower()}},
            },
""");
                }

                sb.AppendLine($$"""
        },firstItemGroup: {{(firstItemGroup ? "true" : "false")}}));
""");
                firstItemGroup = false;
            }

            sb.AppendLine($$"""
    }

    public static bool Save(ObservableCollection<SettingItemGroup> settings, {{className}} {{classVariableName}})
    {
        var allItems = settings.SelectMany(x => x).ToDictionary(x => x.SettingPropertyName, x => x);
        bool isChanged = false;
        foreach(var item in allItems.Values)
        {
            if(!item.EqualsBeforeValue())
            {
                isChanged = true;
            }
        }
""");
            var allSettings = generationContext.SettingItemGroups.SelectMany(x => x.SettingItems).ToList();
            foreach(var item in allSettings.Where(x => !x.IsAction))
            {
                sb.AppendLine($$"""
        {{classVariableName}}.{{item.PropertyName}} = (allItems[nameof({{className}}.{{item.PropertyName}})] as {{item.SettingPageItemClass}}).GetCurrentValue();
""");
            }

            sb.AppendLine($$"""
        return isChanged;
    }

    public static void EnterValue(ObservableCollection<SettingItemGroup> settings, {{className}} {{classVariableName}})
    {
        var allItems = settings.SelectMany(x => x).ToDictionary(x => x.SettingPropertyName, x => x);
""");
            foreach(var item in allSettings.Where(x => !x.IsAction))
            {
                sb.AppendLine($$"""
        (allItems[nameof({{className}}.{{item.PropertyName}})] as {{item.SettingPageItemClass}}).EnterValue({{classVariableName}}.{{item.PropertyName}});
""");
            }

            sb.AppendLine($$"""
    }
}
""");
            context.AddSource($"{generationContext.ClassName}.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
        }
    }

    static bool IsEnumType(ITypeSymbol typeSymbol)
    {
        if(typeSymbol.TypeKind == TypeKind.Enum)
            return true;

        // Nullable<Enum> 対応
        if(typeSymbol.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
            typeSymbol is INamedTypeSymbol named &&
            named.TypeArguments.Length == 1 &&
            named.TypeArguments[0].TypeKind == TypeKind.Enum)
            return true;

        return false;
    }

    record GenerationContext
    {
        public string ClassName { get; set; }
        public EquatableArray<SettingItemGroup> SettingItemGroups { get; set; }
    }

    record SettingItemGroup
    {
        public string GroupName { get; set; }
        public EquatableArray<SettingItem> SettingItems { get; set; }
    }

    record SettingItem
    {
        public bool IsAction { get; set; }
        public string PropertyName { get; set; }
        public string SettingPageItemClass { get; set; }
        public string Title { get; set; }
        public string Description { get; set; }
        public string GroupName { get; set; }

        public int DescriptionKnownColorInt { get; set; }
        public bool IsDescriptionBold { get; set; }
    }

    readonly struct EquatableArray<T> : IEquatable<EquatableArray<T>>, IEnumerable<T> where T : IEquatable<T>
    {
        readonly T[]? array;
        public EquatableArray() { array = []; }
        public EquatableArray(T[] array) { this.array = array; }
        public static implicit operator EquatableArray<T>(T[] array) => new EquatableArray<T>(array);
        public int Length => array?.Length ?? 0;
        public ReadOnlySpan<T> AsSpan() => array is null ? ReadOnlySpan<T>.Empty : array.AsSpan();
        public ReadOnlySpan<T>.Enumerator GetEnumerator() => AsSpan().GetEnumerator();
        IEnumerator<T> IEnumerable<T>.GetEnumerator() => (array ?? []).AsEnumerable().GetEnumerator();
        IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)(array ?? [])).GetEnumerator();

        public bool Equals(EquatableArray<T> other)
        {
            var thisArray = array ?? [];
            var otherArray = other.array ?? [];
            if(thisArray.Length != otherArray.Length)
                return false;
            for(int i = 0; i < thisArray.Length; i++)
            {
                if(!thisArray[i].Equals(otherArray[i]))
                    return false;
            }
            return true;
        }

        public override bool Equals(object? obj)
        {
            return obj is EquatableArray<T> other && Equals(other);
        }

        public override int GetHashCode()
        {
            var currentArray = array ?? [];
            unchecked
            {
                int hashCode = 17;
                foreach(var value in currentArray)
                {
                    hashCode = hashCode * 31 + value.GetHashCode();
                }
                return hashCode;
            }
        }
    }
}

実稼働アプリで動いているコードの抜粋なのでアプリ固有のロジックが含まれていますが、今回のようなユースケースでのソースジェネレーターの使い方として、ある程度参考になるかと思います。構文木から取得したレコードのメンバー情報と、それに付随するシンボル情報を使って属性にアクセスし、コード生成に必要な値を収集、整形しています。ここで割と重要なのが、IncrementalValuesProvider<T>およびIncrementalValueProvider<T>として渡すオブジェクト(今回の場合、namespaceProviderの名前空間文字列とGenerationContext)のEqualsが値比較されるようにしておくことです。Incremental Generatorでは、Incremental Generatorのパイプラインに流れるオブジェクトが前回のものと等価であるかをチェックしており、等価の場合に後続のソース生成処理をスキップする仕様になっています。そのため、Equals内で参照比較されていると、当然パイプラインに流れるオブジェクトは毎回異なるものとして判定されるので常にソース生成が走ってしまい、パフォーマンスが悪化します。このことから、生成処理に渡すオブジェクトは全メンバーが値ベースで比較されるrecordがおすすめです。メンバーで持つコレクションもT[]やList<T>とかは当然アウトなので、EquatableArray<T>のような、コレクションを値オブジェクトとして扱える自作ラッパーで包むといいです。また、各オブジェクトが値比較される前提かつ数が少ないのであればValueTuple<T>にしてもいいでしょう。

属性をソース生成のトリガーにしない場合は、CreateSyntaxProviderで得られるSyntaxTreeをこねくり回すことで対象の構文を取得することが可能ですが、属性でマークされた構文ノードが欲しい場合は、今回のようにForAttributeWithMetadataNameメソッドを使うと楽に取得できます。他にも特定のユースケースで使えそうなプロバイダーがいくつかあるので調べてみてください。

コード生成部分ではグループ化された設定項目を必要な箇所でループし、StringBuilderでコードを組み立てています。デバッグ時のテキストビジュアライザーや生成結果を確認しながら実装を進めていくことなるので、生文字リテラルを活用してインデントを揃えておくと可読性が向上して良い感じになります。また、ライブラリ開発者以外は必須ではないですが、global::SampleApp.Generators.SampleAttributesのように、globalエイリアスを用いることで予期しない名前空間の衝突を避けられます。

生成結果

このSettingsStoreGeneratorによって生成されたコードが下記になります。

// <auto-generated />
using System.Collections.ObjectModel;

namespace SampleApp.Core;

public static partial class QuickUploadSettingsStore
{
    public static void Load(ObservableCollection<SettingItemGroup> settings, QuickUploadSettings quickUploadSettings)
    {
        settings.Add(new SettingItemGroup("", new SettingPageItem[]
        {
            new SettingPicker<SampleApp.Core.AfterUploadOperation>(quickUploadSettings.AfterUploadOperation)
            {
                SettingPropertyName = nameof(QuickUploadSettings.AfterUploadOperation),
                Title = "Action after uploading",
                Description = "Sets the action after the upload button is pressed.",
                DescriptionKnownColor = (global::System.Drawing.KnownColor)35,
                IsDescriptionBold = false,
            },
        },firstItemGroup: true));
        settings.Add(new SettingItemGroup("Text Sharing", new SettingPageItem[]
        {
            new SettingCheckbox(quickUploadSettings.TextToTitle)
            {
                SettingPropertyName = nameof(QuickUploadSettings.TextToTitle),
                Title = "Set to Title property",
                Description = "Title property is set when sharing text.",
                DescriptionKnownColor = (global::System.Drawing.KnownColor)35,
                IsDescriptionBold = false,
            },
            new SettingCheckbox(quickUploadSettings.TextToText)
            {
                SettingPropertyName = nameof(QuickUploadSettings.TextToText),
                Title = "Set to Text property",
                Description = "",
                DescriptionKnownColor = (global::System.Drawing.KnownColor)35,
                IsDescriptionBold = false,
            },
            // 以下省略
        },firstItemGroup: false));

        // 以下省略
    }

    public static bool Save(ObservableCollection<SettingItemGroup> settings, QuickUploadSettings quickUploadSettings)
    {
        var allItems = settings.SelectMany(x => x).ToDictionary(x => x.SettingPropertyName, x => x);
        bool isChanged = false;
        foreach(var item in allItems.Values)
        {
            if(!item.EqualsBeforeValue())
            {
                isChanged = true;
            }
        }

        // 一部省略
        quickUploadSettings.AfterUploadOperation = (allItems[nameof(QuickUploadSettings.AfterUploadOperation)] as SettingPicker<SampleApp.Core.AfterUploadOperation>).GetCurrentValue();
        quickUploadSettings.TextToTitle = (allItems[nameof(QuickUploadSettings.TextToTitle)] as SettingCheckbox).GetCurrentValue();
        quickUploadSettings.TextToText = (allItems[nameof(QuickUploadSettings.TextToText)] as SettingCheckbox).GetCurrentValue();
        quickUploadSettings.UrlOrWebToTitle = (allItems[nameof(QuickUploadSettings.UrlOrWebToTitle)] as SettingCheckbox).GetCurrentValue();
        quickUploadSettings.AddUrlOrWebToBlock = (allItems[nameof(QuickUploadSettings.AddUrlOrWebToBlock)] as SettingPicker<SampleApp.Core.WebPageBlockType>).GetCurrentValue();
        quickUploadSettings.ImageToTitle = (allItems[nameof(QuickUploadSettings.ImageToTitle)] as SettingCheckbox).GetCurrentValue();
        quickUploadSettings.AddImageToBlock = (allItems[nameof(QuickUploadSettings.AddImageToBlock)] as SettingCheckbox).GetCurrentValue();
        quickUploadSettings.KindleToBlock = (allItems[nameof(QuickUploadSettings.KindleToBlock)] as SettingPicker<SampleApp.Core.KindleBlockType>).GetCurrentValue();
        return isChanged;
    }

    public static void EnterValue(ObservableCollection<SettingItemGroup> settings, QuickUploadSettings quickUploadSettings)
    {
        var allItems = settings.SelectMany(x => x).ToDictionary(x => x.SettingPropertyName, x => x);
        // 一部省略
        (allItems[nameof(QuickUploadSettings.AfterUploadOperation)] as SettingPicker<SampleApp.Core.AfterUploadOperation>).EnterValue(quickUploadSettings.AfterUploadOperation);
        (allItems[nameof(QuickUploadSettings.TextToTitle)] as SettingCheckbox).EnterValue(quickUploadSettings.TextToTitle);
        (allItems[nameof(QuickUploadSettings.TextToText)] as SettingCheckbox).EnterValue(quickUploadSettings.TextToText);
        (allItems[nameof(QuickUploadSettings.UrlOrWebToTitle)] as SettingCheckbox).EnterValue(quickUploadSettings.UrlOrWebToTitle);
        (allItems[nameof(QuickUploadSettings.AddUrlOrWebToBlock)] as SettingPicker<SampleApp.Core.WebPageBlockType>).EnterValue(quickUploadSettings.AddUrlOrWebToBlock);
        (allItems[nameof(QuickUploadSettings.ImageToTitle)] as SettingCheckbox).EnterValue(quickUploadSettings.ImageToTitle);
        (allItems[nameof(QuickUploadSettings.AddImageToBlock)] as SettingCheckbox).EnterValue(quickUploadSettings.AddImageToBlock);
        (allItems[nameof(QuickUploadSettings.KindleToBlock)] as SettingPicker<SampleApp.Core.KindleBlockType>).EnterValue(quickUploadSettings.KindleToBlock);
    }
}

設定クラス(ここではQuickUploadSettings)のメンバーが増減する度にIncremental GeneratorによってQuickUploadSettingsStoreが自動的に更新され、リフレクションを一切使用せずにLoad, Save, EnterValueメソッドで設定項目の読み込みと保存、UIバインディング用モデルクラスへの値設定が行えるようになりました。特にボックス化も発生していたEnterValueの速度が改善したのが大きく、JIT最適化により読み込みと保存も改善(特に2回目以降)しています。このあたりのパフォーマンス差に関しては、可能ならBenchmarkDotNetで正確に検証してみるとより良いと思います。

全ての設定項目のメンバーの読み書きを静的コード生成できるようになったので、下記のようにUIバインディング用のモデルクラスもobjectを排除して型安全に実装できるようになりました。

public abstract class SettingPageItem : NotifyPropertyChangedBase
{
    public abstract SettingPageItemType ItemType { get; }
    public required string SettingPropertyName { get; init; }
    public required string Title { get; init; }
    public required string Description
    {
        get => field;
        set => OnPropertyChanged(ref field, value);
    }
    public abstract bool EqualsBeforeValue();

    // 以下省略
}

public class SettingCheckbox(bool isChecked) : SettingPageItem
{
    public override SettingPageItemType ItemType => SettingPageItemType.Checkbox;
    bool _isChecked = isChecked;
    public bool IsChecked
    {
        get => _isChecked;
        set => OnPropertyChanged(ref _isChecked, value);
    }
    // 以下省略
}

public class SettingPicker<T> : SettingPageItem where T : struct, Enum
{
    public SettingPicker(T selectedItem) 
    {
        var selectedItemName = selectedItem.GetEnhancedDisplayName();
        this.PickerItems = FastEnum.GetValues<T>().Select(x => x.GetEnhancedDisplayName()).ToArray();
        this.SelectedItem = selectedItemName;
        this.BeforeValue = selectedItemName;
    }

    public override SettingPageItemType ItemType => SettingPageItemType.Picker;

    public IEnumerable<string> PickerItems
    {
        get => field;
        set => OnPropertyChanged(ref field, value);
    }

    public string SelectedItem
    {
        get => field;
        set => OnPropertyChanged(ref field, value);
    }
    // 以下省略
}



最終的に、下記のように設定値をモデルクラスとバインディングさせてUIコントロールを生成しました。XAML嫌いなので100% C#コードで作ってます。

static View CreateSettingValueControl(SettingPageItem settingItem, Grid parentGrid)
{
    return settingItem.ItemType switch
    {
        SettingPageItemType.Switch => new Switch().Margin(0, 0, 18, 10).IsToggled(nameof(SettingSwitch.IsToggled), BindingMode.TwoWay, settingItem, fallbackValue: false)/*.Invoke(parentGrid, (x, grid) => grid.TapGesture(x, x => x.IsToggled = !x.IsToggled))*/,
        SettingPageItemType.Checkbox => new CheckBox().Margin(0, 0, OperatingSystem.IsAndroid() ? 18 : 24, 10).IsChecked(nameof(SettingCheckbox.IsChecked), BindingMode.TwoWay, settingItem, fallbackValue: false)/*.Invoke(parentGrid, (x, grid) => grid.TapGesture(x, x => x.IsChecked = !x.IsChecked))*/,
        SettingPageItemType.Picker => new Picker().Margin(0, 0, 5, 10).ItemsSource(nameof(SettingPicker<>.PickerItems), settingItem).SelectedItem(nameof(SettingPicker<>.SelectedItem), BindingMode.TwoWay, settingItem)/*.Invoke(parentGrid, (x, grid) => grid.TapGesture(x, x => x.Open()))*/,
        SettingPageItemType.Action => new Label().Margin(0, 0, 23, 0).Icon(MaterialOutlinedIcons.NavigateNext).IconSize(MauiFontSizeHelper.GetSize<Label>(MauiFontSize.Subtitle)).FontBold(),
        _ => null,
    };
}

さいごに

一般的なC#開発者が明示的にRoslynに触れる機会はあまり無いので、ソースジェネレーターに忌避感ありそうですが、Incremental Source Generatorに進化してコード生成のパフォーマンスが改善し、便利なAPIが使えるようになっています。生文字リテラル($$"""…""")もC#11から導入されて生成コードのインデントを揃えやすくなり、可読性が向上したので実装の敷居は割と下がってきていると思います。属性ベースで静的コードを生やせるようになると、今回のようなパフォーマンス改善や機能拡張、AOT対応(面倒なので現実的かは別として)など、幅が広がるので慣れておくと良さそうです。

ちなみに少し気になって、リフレクションのパフォーマンスをベンチマークしてみたら、.NET7以降かなり速くなっていて、最新の.NET10でも改善されていました。最新の.NETを使っているなら、リフレクションコストは過度に心配しなくていいかも?言語機能より、MAUIのボトルネックの方が大きそう..。

https://github.com/kosaku-hayashi/BenchmarkTest