この記事は CyberAgent Developers Advent Calendar 2024  21日目の記事です。

私は最近「Unity 6から追加されたAndroidに関するAPIの紹介」という記事を別のアドベントカレンダーで執筆したのですが、その際にUnity 6で追加されたAPIの一覧を把握する必要がありました。

流石に目grepでAPIの一覧を把握するの無理な話なので、UnityのC#コードを解析してAPI一覧を抽出し、バージョン間のAPIの差分を取ることでAPIの変更差分を特定しました。

この記事では、上の記事を書く際に実際に行ったUnityの各バージョンにおけるAPIを Unity-Technologies/UnityCsReference から抽出し、各バージョンのAPI一覧の差分を取ることでAPIの変更を把握する方法について紹介します。

この記事を執筆した際に利用したコードは、下記レポジトリにて公開いたします。

yucchiy/UnityApiAnalyzer

Unity-Technologies/UnityCsReference とは

Unityを構成するソースコードのうち、C#部分のソースコードのみを切り出したレポジトリです。

Unityのエンジンコードは大きくわけてC++の実装と、それをラップするようなC#の実装が存在します。このC#コード部分が「Unity-Technologies/UnityCsReference(以後UnityCsReferenceと記載します)」に格納されています。

UnityCsReferenceは各Unityのバージョンごとに、そのバージョン名をタグ名としてタグが振られています。そのためタグをチェックアウトすることで、タグに紐づくバージョンのUnityのC#コードが確認できます。

また、このレポジトリのC#コードは、.NETのソリューションで管理されているため、Visual Studioのプロジェクトとして普通に開くことができます。

API一覧を抽出する方法

UnityCsReferenceを利用することでUnityのC#コードが確認できることがわかったので、このC#コードから、API一覧を抽出する方法を考えます。

ここでAPI一覧は、publicなメンバーとします。

たとえばsedやgrepなどで正規表現を駆使してAPI一覧を抽出する…といったやり方もあると思いますが、前述したとおりUnityCsReferenceには有効な .NETプロジェクトが内包されているので、今回は「Microsoft.CodeAnalysis.Workspaces.MSBuild」を用いてUnityCsReferenceを解析してみます。

Microsoft.CodeAnalysis.Workspaces.MSBuild によるコード解析

「Microsoft.CodeAnalysis.Workspaces.MSBuild」は、MSBuildによって .NETプロジェクトを読み込み、ソースコードでプロジェクトファイルを操作できるパッケージです。

また、読み込んだプロジェクトのソースコードをRoslynを介してコード解析できます。

今回行いたい実装も、このパッケージを用いてUnityCsReferenceのプロジェクトを開き、Roslyn経由でAPIを抽出することで実現できます。

このパッケージを利用するには、下記のパッケージをインストールが必要です。

NuGetに下記の参照を追加することで、これらのパッケージをインストールできます。

<Project Sdk="Microsoft.NET.Sdk">
  <!-- ... -->
  <ItemGroup>
    <!-- 下記を追記 -->
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" />
    <PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.12.0" />
    <!-- ... -->
  </ItemGroup>
  <!-- ... -->
</Project>

UnityCSReferenceの .NETソリューションは Proojects/CSharp/UnityReferenceSource.sln に格納されています。

配置されているソリューションは下記のように読み込むことができます。

// ワークスペースを作成
using var workspace = MSBuildWorkspace.Create();

// repository.Pathを用いるとUnityCsReferenceのリポジトリのフルパスが取得できるとする。
// Projects/CSharp/UnityReferenceSource.slnがソリューションファイル
var solutionPath = Path.Combine(repository.Path.ToString(), "Projects", "CSharp", "UnityReferenceSource.sln");

// ソリューションを開く
var solution = await workspace.OpenSolutionAsync(solutionPath, null, null, token);

solution.Projectsからソリューションに紐づくプロジェクトの情報が取得できます。下記にプロジェクトの一覧を出力するコードを示します。

// solution.Projectsに、ソリューションに含まれるプロジェクトの情報が格納されている
Console.WriteLine($"Solution loaded: {solution.Projects.Count()} projects");
foreach (var project in solution.Projects)
{
    Console.WriteLine($"- {project.Name}");
}
// Solution loaded: 7 projects
// - UnityEngine
// - UnityEditor
// - Unity.CecilTools.gen
// - Unity.SerializationLogic.gen
// - ScriptCompilationBuildProgram.Data.gen
// - PlayerBuildProgramLibrary.Data.gen
// - BeeBuildProgramCommon.Data.gen

今回興味があるのは「UnityEngine」と「UnityEditor」になるので、これらのプロジェクトに絞って解析を行うことにします。

// solution.Projectsに、ソリューションに含まれるプロジェクトの情報が格納されている
Console.WriteLine($"Solution loaded: {solution.Projects.Count()} projects");
foreach (var project in solution.Projects)
{
    if (project.Name == "UnityEngine" || project.Name == "UnityEditor")
    {
        // ここでプロジェクトを解析してAPI一覧を取得する
        await AnalyzeProjectAsync(project, token);
    }
}

プロジェクト内のコードは、プロジェクトをコンパイルし、それより生成されたRoslynのシンタックスツリーを用いて解析できます。シンタックスツリー取得までの実装を下記に示します。

private async Task AnalyzeProjectAsync(Project project, CancellationToken token)
{
    // プロジェクト内のコードをコンパイルする
    var compilation = await project.GetCompilationAsync(token);
    if (compilation == null)
    {
        throw new Exception("Failed to compile project.");
    }

    // compilation.SyntaxTreesにシンタックスツリーが格納されている
    foreach (var tree in compilation.SyntaxTrees)
    {
        // これ以降、実際にコード解析を行う
    }
}

最後に、シンタックスツリーからAPI一覧を抽出します。

シンタックスツリーはソースコードの構造のみを保持していますが、解析にはセマンティックモデルを用いたほうが都合が良いので、シンタックスツリーからセマンティックモデルを取得し、そこからAPI一覧を抽出します。

// 先程のコードのシンタックスツリーを取得したところから
foreach (var tree in compilation.SyntaxTrees)
{
    // セマンティックモデルを取得
    var semanticModel = compilation.GetSemanticModel(tree);

    // publicなクラスの一覧を取得
    var typeSymbols = tree.GetRoot()
        .DescendantNodes()
        // シンタックスツリーからクラスの宣言部分を取得
        .OfType<ClassDeclarationSyntax>()
        // クラスの宣言から対応するシンボル情報を取得
        .Select(x => semanticModel.GetDeclaredSymbol(x))
        .OfType<INamedTypeSymbol>()
        // publicなクラスのみを取得
        .Where(x => x.DeclaredAccessibility == Accessibility.Public)
        .ToArray();
    
    // クラスごとにメンバーを取得
    foreach (var typeSymbol in typeSymbols)
    {
        // このpublicMembersに、typeSymbolで表される型のpublicなメンバーが入る
        var publicMembers = typeSymbol.GetMembers()
            .Where(x => x.DeclaredAccessibility == Accessibility.Public)
            .ToArray();
    }
}

あとは publicMembers に格納されたメンバー情報を出力すれば、APIの一覧が書き出せることになります。

今回は「Documentation Comment ID」をAPIの識別子として利用して書き出してみます。GetDocumentationCommentIdを用いるとこのIDが取得できます。

foreach (var member in publicMembers)
{
    var id = member.GetDocumentationCommentId();
    Console.WriteLine(id);
}

これによって、下記のようにAPI一覧を書き出せます。


P:UnityEngine.Android.AndroidGame.GameMode
M:UnityEngine.Android.AndroidGame.get_GameMode
M:UnityEngine.Android.AndroidGame.SetGameState(System.Boolean,UnityEngine.Android.AndroidGameState)
M:UnityEngine.Android.AndroidGame.SetGameState(System.Boolean,UnityEngine.Android.AndroidGameState,System.Int32,System.Int32)
T:UnityEngine.Android.AndroidGame.Automatic
M:UnityEngine.Android.AndroidGame.Automatic.SetGameState(UnityEngine.Android.AndroidGameState)
T:UnityEngine.Android.AndroidGame
T:UnityEngine.Android.AndroidGame.Automatic
P:UnityEngine.Android.AndroidGame.GameMode
M:UnityEngine.Android.AndroidGame.get_GameMode
M:UnityEngine.Android.AndroidGame.SetGameState(System.Boolean,UnityEngine.Android.AndroidGameState)
M:UnityEngine.Android.AndroidGame.SetGameState(System.Boolean,UnityEngine.Android.AndroidGameState,System.Int32,System.Int32)

バージョン間のAPI差分の取得

特定のバージョンのAPI一覧の取得ができたので、これを利用してバージョン間のAPI差分を計算してみます。

といっても上記でバージョンにおける「Documentation Comment ID」の取得ができていれば、IDの集合に対しての差集合を取るだけです。

membersAmembersB という変数に、それぞれのバージョンのAPIの「Documentation Comment ID」が格納されているならば、下記のコードでAPI差分が取得できます。

Console.WriteLine($"Added members:");
foreach (var member in membersA.Except(membersB))
{
    Console.WriteLine($"- {member}");
}

Console.WriteLine($"Removed members:");
foreach (var member in membersB.Except(membersA))
{
    Console.WriteLine($"- {member}");
}

たとえば membersA6000.13f1membersB2022.3.54f1 のメンバー一覧が格納されているならば、下記のような出力が得られます。


Added members:
- M:UnityEngine.AI.NavMesh.GetAreaNames
- M:UnityEngine.AI.NavMesh.IsLinkActive(UnityEngine.AI.NavMeshLinkInstance)
- M:UnityEngine.AI.NavMesh.SetLinkActive(UnityEngine.AI.NavMeshLinkInstance,System.Boolean)
- M:UnityEngine.AI.NavMesh.IsLinkOccupied(UnityEngine.AI.NavMeshLinkInstance)
- M:UnityEngine.AI.NavMesh.IsLinkValid(UnityEngine.AI.NavMeshLinkInstance)
- M:UnityEngine.AI.NavMesh.GetLinkOwner(UnityEngine.AI.NavMeshLinkInstance)
// ...
Removed members:
- M:UnityEngine.UIElements.BaseTreeViewController.CollapseItemByIndex(System.Int32,System.Boolean)
- M:UnityEngine.UIElements.BaseTreeViewController.CollapseItem(System.Int32,System.Boolean)
- M:UnityEngine.UIElements.BaseTreeView.TryRemoveItem(System.Int32)
- M:UnityEngine.UIElements.BaseTreeView.CollapseItem(System.Int32,System.Boolean)
- M:UnityEngine.UIElements.BaseTreeView.ExpandItem(System.Int32,System.Boolean)
// ...

まとめ

UnityCsReferenceを用いてUnityのC#コードを解析し、API一覧を抽出する方法を紹介しました。また、API一覧の差分を取る方法についても説明しました。

UnityCsReferenceのソリューションをMicrosoft.CodeAnalysis.Workspaces.MSBuildを用いて解析する手法は、もしかすると今回やりたいことに対して少しやり過ぎかもしれないですが、Unityプロジェクト(に限らず)を解析する手段として応用の幅は広いです。

たとえばUnityプロジェクトに対してソリューションを作った状態であればUnityを起動せずともC#コードの解析を行えたり、今回のようにUnityCsReferenceを解析して取得したUnity APIの情報を用いて自動生成を行うなど、用途はさまざまです。

実際最近弊社からリリースした「BuildMagic」というアプリビルドのためのライブラリでは、Unityの各種設定を行うAPIを呼び出すビルドタスクの自動生成に UnityCsReferenceの解析結果を利用しています

すこしニッチな記事となってしまいましたが、何かのお役に立てると幸いです。