3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ApplibotAdvent Calendar 2024

Day 24

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

Last updated at Posted at 2024-12-23

この記事は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という機能を活用して、ボイラープレートコードを排除することができます。

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

まとめ

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

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?