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