のほほん停留所

びぼうろく

C#でMVVMのボイラープレートコードを自動生成する

この記事はApplibot Advent Calendar 2024の24日目の記事です。

来年からUnityでゲーム開発を始めるので、急いで勉強中の井原です。これまではiOSアプリ開発をメインに業務を行っていましたが、UnityでもiOSアプリ開発の知見を活かせないか検討しています。今回はその一部について触れます。

iOS開発におけるMVVM

以前に開発していたiOSアプリでは「MVVM + Clean Architecture」を採用し、各レイヤーをRxSwiftで繋いでいました。しかし、レイヤーを細かく分けることでボイラープレートコードが多く発生してしまいました。

import RxRelay
import RxSwift

protocol ViewModelType {
    var event1: Observable<Void> { get }
    var event2: Observable<Void> { get }
    ...
    var eventN: Observable<Void> { get }
}

// ボイラープレートコードを多数含むViewModel
class ViewModel: ViewModelType {
    private let _event1 = PublishRelay<Void>()
    var event1: Observable<Void> { _event1.asObservable() }

    private let _event2 = PublishRelay<Void>()
    var event: Observable2<Void> { _event2.asObservable() }

    ...

    private let _eventN = PublishRelay<Void>()
    var eventN: Observable<Void> { _eventN.asObservable() }

    func doSomething() {
        _event1.accept(())
    }
}

SwiftではpropertyWrapperという機能を活用して、ボイラープレートコードを排除することができます。

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0258-property-wrappers.md

typealias PublishWrapper<T> = RelayWrapper<Observable<T>, T>

@propertyWrapper
struct RelayWrapper<Wrapped, Element> {

    let wrappedValue: Wrapped
    let accept: (Element) -> Void

    init(wrapped: Wrapped, accept: @escaping (Element) -> Void) {
        self.wrappedValue = wrapped
        self.accept = accept
    }
}

extension RelayWrapper where Wrapped == Observable<Element> {
    init() {
        let relay = PublishRelay<Element>()
        self.init(wrapped: relay.asObservable(), accept: { relay.accept($0) })
    }
}

// propertyWrapperを活用したViewModel
class ViewModel: ViewModelType {
    @PublishWrapper()
    var event1: Observable<Void>

    @PublishWrapper()
    var event: Observable2<Void>

    ...

    @PublishWrapper()
    var eventN: Observable<Void>
    
    func do() {
        _event1.accept(())
    }
}

C#でMVVMを実装してみる

C#でもiOS開発と同様に「MVVM + Clean Architecture」を実装してみます。

using R3

interface IViewModel
{
    Observable<Unit> Event1 { get; }
    Observable<Unit> Event2 { get; }
    ...
    Observable<Unit> EventN { get; }
}

// ボイラープレートコードを多数含むViewModel
class ViewModel: IViewModel
{
    private readonly Subject<Unit> _event1 = new();
    Observable<Unit> Event1 => _event1.AsObservable();
    
    private readonly Subject<Unit> _event2 = new();
    Observable<Unit> Event2 => _event2.AsObservable();

    ...

    private readonly Subject<Unit> _eventN = new();
    Observable<Unit> EventN => _eventN.AsObservable();
}

同様にボイラープレートコードが多数生まれてしまうので、C#においてもボイラープレートコードを排除していきます。最初はAttributeを活用して実現できないか検討しましたが、Genericsを活用できなかったため、メタプログラミングでコード生成を行うことにしました。

プロジェクトのセットアップなどは @amenone_games さんのブログが参考になりました。 https://qiita.com/amenone_games/items/762cbea245f95b212cfa

using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.DotnetRuntime.Extensions;
using Microsoft.CodeAnalysis.Text;

namespace ObservableWrapper;

[Generator(LanguageNames.CSharp)]
public class SourceGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 自動生成対象のプロパティに付与するObservableWrapperAttributeのコード生成
        context.RegisterPostInitializationOutput(static x => SetAttribute(x));

        // ObservableWrapperAttributeが付与されたプロパティを対象としたコード生成
        var provider = context.SyntaxProvider.ForAttributeWithMetadataName
            (
                context,
                "ObservableWrapperGenerator.ObservableWrapperAttribute",
                static (node, _) => node is VariableDeclaratorSyntax,
                static (cont, _) => cont
            )
            .Combine(context.CompilationProvider);

        context.RegisterSourceOutput(
            context.CompilationProvider.Combine(provider.Collect()),
            static (sourceProductionContext, t) =>
            {
                var (compilation, list) = t;

                var typeMetas = new List<SubjectTypeMeta>();

                foreach (var (x, y) in list)
                {
                    var typeMeta = SubjectTypeMeta.TryCreate(x.TargetSymbol, x.TargetNode);
                    if (typeMeta != null) typeMetas.Add(typeMeta);
                }
                
                var generatedClassNames = new List<string>();

                foreach(var typeMeta in typeMetas)
                {
                    var fullClassName = typeMeta.GetFullClassName();
                    
                    if (generatedClassNames.Contains(fullClassName)) continue;
                    
                    var commonTypeMetas = typeMetas
                        .Where(x => x.GetFullClassName() == fullClassName)
                        .ToList();

                    var builder = new StringBuilder();
                    var fileName = SubjectEmit.Emit(builder, commonTypeMetas);

                    if (fileName != null)
                    {
                        // メタプログラミングで取得した情報を元にコード生成
                        sourceProductionContext.AddSource(
                            $"{fileName}.g.cs",
                            SourceText.From(builder.ToString(), Encoding.UTF8)
                        );
                        
                        generatedClassNames.Add(fullClassName);
                    }

                    builder.Clear();
                }
            });
    }

    // 自動生成対象のプロパティに付与するObservableWrapperAttributeのコード生成
    private static void SetAttribute(IncrementalGeneratorPostInitializationContext context)
    { 
        const string attributeText = """
                                   using System;

                                   namespace ObservableWrapperGenerator
                                   {
                                      [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
                                      sealed class ObservableWrapperAttribute : Attribute {
                                          public ObservableWrapperAttribute() {}
                                      }
                                   }
                                   """;                
        context.AddSource
        (
            "ObservableWrapperAttribute.cs",
            SourceText.From(attributeText, Encoding.UTF8)
        );
    }
}

// コード生成の対象
// ViewModel.cs
public partial class ViewModel: IViewModel
{
    @ObservableWrapper
    private readonly Subject<Unit> _event1 = new();
    
    @ObservableWrapper
    private readonly Subject<Unit> _event2 = new();

    ...

    @ObservableWrapper
    private readonly Subject<Unit> _eventN = new();
}

// コード生成物
// ViewModel.g.cs
public partial class VideModel
{
    public Observable<Unit> Event1 => _event1.AsObservable();
    public Observable<Unit> Event2 => _event2.AsObservable();
    ...
    public Observable<Unit> EventN => _event3.AsObservable();
}

以下、サンプルコードになります。 https://github.com/Nonchalant/ObservableWrapper-CSharp

まとめ

メタプログラミングを活用することで、コードの情報を効率的に取得できるため、アイデア次第で様々な使い道があると感じました。ただし、生成するコードが増えるとパフォーマンスの低下が懸念されるため、細かなチューニングが必要になると思います。この記事がコード生成に関する参考になれば幸いです。