Managed Code Strippingの挙動の検証と2020 LTSで利用できる新しいアノテーション属性の紹介

Unityエンジニアの向井です。

この記事では、Unityのアプリビルド時の最適化のひとつであるManaged Code Strippingについてその基本的な機能の紹介と、実際にビルド後のクラス情報を出力しながらその挙動について確認します。また、このManaged Code Strippingによって発生する問題の1例と、それを解決する方法についても触れます。

記事後半では、Unity 2020 LTSで導入された新しいManaged Code Strippingのアノテーション属性と、これを用いてどのようにマークを行えるのかについて紹介します。

※ この記事で紹介する手法やツールの悪用はお控えください。

Managed Code Strippingとは?

https://docs.unity3d.com/ja/2020.3/Manual/ManagedCodeStripping.html

Managed Code Strippingは、使用していないC#コードをビルド時に削除することでアプリサイズを削減する最適化です。この最適化ではプロジェクト中のアセンブリを静的解析して一連のルールによって、どの型やメソッド、プロパティなどが不要かを判断します。

このルールはPlayer SettingsのManaged Stripping Levelで設定できます。Unity 2020.3.30f1時点ではDisabled・Low・Medium・Highが選択でき、Disabledはこの最適化が無効になります。(ただしil2cppがスクリプトバックエンドの場合は Disabled を選択できません)

それ以外を選択すると最適化が有効になります。Low・Medium・Highの順に、より積極的に不要なコードを見つけて削除を行います。

この最適化を行うことでアプリサイズの削減や実行時のメモリ使用量、アプリの起動時間などの削減が期待できます。一方で本来必要な型やメソッドが削除される場合があります。

Managed Code Strippingの挙動を確認する

まず、Managed Code Strippingの挙動を確認してみましょう。Managed Code Strippingの大まかな挙動については、こちらのドキュメントに記載されています。該当部分を抜粋します。

UnityLinker は、プロジェクト内のすべてのアセンブリを分析します。最初に、最上層、ルートタイプ、メソッド、プロパティ、フィールドなどをマークします。例えば、シーン内のゲームオブジェクトに加える MonoBehaviour 派生クラスはルートタイプです。次に、UnityLinker は、識別するためにマークしたルートを分析し、これらのルートが依存しているすべてのマネージコードをマーキングします。この静的分析が完了した段階で、マーキングされていない残りのコードはアプリケーションコードを通した実行パスによってアクセスできなくなり、アセンブリから削除されます。

つまり MonoBehaviour の派生クラスを起点として、そのクラスが利用しているクラスを分析しているようです。

この挙動を確かめるために、下記のようなクラスが入ったプロジェクトをビルドして、Managed Code Strippingが適用された後のクラス情報をビルドされたアプリのdllから確認します。

public class TestBehaviour : MonoBehaviour
{
    void Start()
    {
        // 自身のフィールドを操作
        UsedField = 30;

        // UsedInTestBehaviourClassを利用する
        var cls = new UsedInTestBehaviourClass();
        cls.UsedField = 20;
        // NotUsedFieldはどこからも参照されない
    }

    // 空のメソッドを定義
    void Update()
    {
    }

    // どこからもよんでいないメソッドを定義
    public void NotUsedMethod()
    {
        Debug.Log("Called NotUsedMethod");
    }

    // 空メソッドを定義
    void Update()
    {
    }

    // どこからもよんでいないメソッドを定義
    public void NotUsedMethod()
    {
        Debug.Log("Called NotUsedMethod");
    }

    // どこからもよんでいないプライベートメソッドを定義
    private void NotPrivateUsedMethod()
    {
        Debug.Log("Called NotUsedMethod");
    }

    public int UsedField = 10;
    public int NotUsedField = 20;
    private int NotUsedPrivateField = 30;
    [SerializeField]
    private int NotUsedPrivateSerializedField = 40;
}

// 上記のTestBehaviourから利用されるクラス
public class UsedInTestBehaviourClass
{
    // どこからも呼び出されていないメソッドを定義
    public void NotUsedMethod()
    {
        UnityEngine.Debug.Log("Called NotUsedMethod");
    }

    public int UsedField;
    public int NotUsedField;
}

// どこからも利用されないクラス
public class NotUsedClass
{
    public NotUsedClass()
    {
        UsedField = 10;
        NotUsedField = 20;

        var cls = new UsedInUnusedClass();
        cls.TestLog();
    }

    public int UsedField;
    public int NotUsedField;
}

// NotUsedClassからのみ利用されるクラス
public class UsedInUnusedClass
{
    public void TestLog()
    {
        UnityEngine.Debug.Log("Method");
    }
}

上記のクラスは、下記のような関係を持ちます。

  • TestBehaviour はこのプロジェクトで唯一の MonoBehaviour を継承している
  • UsedInTestBehaviourClassTestBehaviour のみから参照されている
  • NotUsedClass はこのプロジェクトのどこからも利用されていない
  • UsedInUnusedClassNotUsedClass からのみ利用されている

そのため TestBehaviour を起点に UsedInTestBehaviourClass は残り、 NotUsedClassUsedInUnusedClass は削除されるはずです。

アプリビルド後のクラス情報を確認するためにプラットフォーム設定をAndroidにし、ビルド後に生成されるapkからdllを抽出して、残ったクラス情報を確認します。(AndroidにしていますがStandalone・iOSなどでも構いません)

具体的には上記のクラスが入ったプロジェクトを下記の構成でビルドしてapkを作成します。

ビルドで生成されたapkはzipで固められているのでzip拡張子をつけた上で適当な圧縮・解凍ツールで展開することで下図のように展開できます。

apk展開後のフォルダ構成

展開後のフォルダ内のassets/bin/Data/Managed下にあるdllから実際にアプリ内に残ったクラス・メソッド・プロパティを確認します。今回はasmdefを切っていないため、実装したクラスは全て Assembly-Csharp.dll というdllに含められます。

assets/bin/Data/Managed下に展開されているdllの一覧

dll内のクラスの確認には今回はdnSpyを用いました。解析したいdllを開くことで下図の右画面のように、dll内に定義されたクラスを確認できます。

dnSpyを用いたdllの解析

Assembly-Csharp.dll 内に残ったクラスの一覧を下記に示します。ただし下記クラスの名前空間は省略しています。(名前空間は InAssemblyCsharp )

public class TestBehaviour : MonoBehaviour
{
    private void Start()
    {
        this.UsedProperty = 30;
        new UsedInTestBehaviourClass().UsedProperty = 20;
    }

    // 空だったUpdateメソッドは消えている

    // public/privateに関わらず、利用されていないメソッドは
    // そのまま残っている
    public void NotUsedMethod()
    {
        Debug.Log("Called NotUsedMethod");
    }

    private void NotPrivateUsedMethod()
    {
        Debug.Log("Called NotUsedMethod");
    }

    public int UsedProperty = 10;
    public int NotUsedProperty = 20;
    private int NotUsedPrivateProperty = 30;
    [SerializeField]
    private int NotUsedPrivateSerializedProperty = 40;
}

public class UsedInTestBehaviourClass
{
    // 利用していないプロパティとメソッドが消えている
    public int UsedProperty;
}

この結果をまとめると、概ね下記の通りです。

  • MonoBehaviour を継承したクラスはそのまま残るが、空で定義されているメソッドや利用していないプロパティなどは削除される。
    • Update メソッドは削除されている。ただしメソッドが空でない場合はコード上の呼び出しがなくても削除されない
      • メソッド呼び出しが、UnityEventなどコード以外から呼び出される可能性があるから?
    • プロパティはpublic/privateに限らずそのまま残る
      • これも上記と同じでコード以外の呼び出しが可能なため?
  • MonoBehaviour を継承したクラスが利用していないクラスやメソッド、フィールドは削除される
    • たとえば UsedInUnusedClassUnusedClass が利用しているが、 UnusedClass を利用している MonoBehaviour がいないので削除されている
    • UsedInTestBehaviourClass 内のどこからも利用されていない NotUsedMethod は、メソッドが空でなくても削除される

ドキュメント通り、MonoBehaviour が利用されていないクラスやメソッド、プロパティが 削除対象に入るといった理解で大きく齟齬はないかと思います。

ただし、Managed Code Strippingについてその詳細な挙動が解説されたドキュメントは確認できないため、もしかすると複雑なケースや他バージョンでは上記の限りでない可能性はあるのでその点ご了承ください。

本来必要な型やメソッドが削除されて困る件

一見不要なコードが削除されてとても便利な最適化ですが、この最適化によって困る場合があります。

わかりやすい例の1つとして、リフレクションを利用した際に、本来必要なクラスが削除されるケースを紹介します。

下記のリフレクションによるクラスインスタンスの生成とメソッド呼び出しを例にします。

var assembly = Assembly.GetExecutingAssembly();
var type = assembly.GetType("Foo");
var method = type.GetMethod("Debug");
var instance = Activator.CreateInstance(type);
// FooクラスのDebugメソッドを
// リフレクション経由で呼び出す
method?.Invoke(instance, null);

// Fooクラスは以下の通り
public class Foo
{
    public void Debug()
    {
        UnityEngine.Debug.Log("Call Foo.Debug");
    }
}

このコードはエディタ実行時は正常に動作しますが、Managed Code StrippingをHighにしたアプリでは正常に動作しません

具体的には Foo クラスが不要と判断されて最適化時に省かれ、 assemly.GetType("Foo") の戻り値の型情報が null となりインスタンス生成および GetMethod の呼び出しに失敗します。

リフレクションによるインスタンスの生成やメソッド呼び出しは文字列を介するため( GetTypeGetMethod の引数に型やメソッド名を文字列で指定するため)、C#的には参照が全くないといった状況となり不要と判断されます。

リフレクションを利用するライブラリの例としてDIフレームワークやシリアライゼーションライブラリがあります。このようなライブラリを利用しつつCode Strippingを有効にする場合、これらの問題に対処する必要があります。

(もちろんこれらに限らずリフレクションを利用する実装全般に対して注意が必要ですが、典型例としてこの手の種類のライブラリを上げています。)

link.xmlを用いて必要な型やメソッド、プロパティを明記する

先述のとおり MonoBehaviour から利用していなく不要と判断されたクラスやメソッド、フィールドは削除されます。

これを解決する方法の1つとして、link.xmlというファイルを Assets 配下に配置する方法があります( Assets 配下であればどこでもよく、また複数ある場合は統合されます)。ここに指定したクラスはManaged Code Strippingによる削除対象から除外されるようにマークされます。

試しに NotUsedClass をlink.xmlに登録してビルドしてみます。 下記のようにlink.xmlを記述し、 Assets 直下に配置します。

<linker>
  <assembly fullname="Assembly-CSharp">
      <type fullname="NotUsedClass" preserve="all"/>
  </assembly>
</linker>

ビルド後のクラス一覧を確認します。

public class NotUsedClass
{
    public NotUsedClass()
    {
        this.UsedProperty = 30;
        new UsedInUnusedClass().TestLog();
    }

    public int UsedProperty = 10;
    // 使ってないプロパティも残る
    public int NotUsedProperty = 20;
}

// NotUsedClassが利用しているクラスも
// (当然だけど)のこる
public class UsedInUnusedClass
{
    public void TestLog()
    {
        Debug.Log("Method");
    }
}

このように、指定したクラスがビルド後に残ることが確認できました。

ちなみにAssets配下にlink.xmlを配置する方法以外に、AddressablesパッケージのLinkXmlGeneratorを用いるとアプリのビルド時にlink.xmlを追記できます。

またIUnityLinkerProcessor実装することで、こちらもアプリビルド時にlink.xmlを追記できます。

Preserve属性を用いる

Preserve属性をクラスやメソッド、フィールド、プロパティにつけると、Managed Code Strippingの対象から外れるようにマークされます。先程の NotUsedClass に対して Preserve をつけると、link.xmlに設定したのと同様に NotUsedClass が除外対象から外れます。

[Preserve]
public class NotUsedClass
{
    public NotUsedClass()
    {
        this.UsedProperty = 30;
        new UsedInUnusedClass().TestLog();
    }

    public int UsedProperty = 10;
    // 使ってないプロパティも残る
    public int NotUsedProperty = 20;
}

// NotUsedClassが利用しているクラスものこる
public class UsedInUnusedClass
{
    public void TestLog()
    {
        Debug.Log("Method");
    }
}

Unity 2020 LTSに入ったコードストリッピングのアノテーション属性について

https://blog.unity.com/ja/technology/tales-from-the-optimization-trenches-better-managed-code-stripping-with-unity-2020-lts

Unity 2020 LTSではlink.xmlやLinkXmlGeneratorを用いる以外の方法として、2020 LTSで追加されたアノテーション属性をC#コードに指定することで、より簡単にマークを行うことができるようになりました。マークを行うことでManaged Code Strippingの対象からその型を外すことができます。

2020 LTSで追加されたアノテーションでは「指定した型やインターフェイスを実装するクラスをまとめてマークする」などといった操作が行え、必要なクラスやメソッドをより簡単かつ正確にマークすることができるようになりました。

Unity2020 LTSでは、下記の5つの属性が提供されるようになりました。

  • RequiredMemberAttribute
    • 指定したメンバーがマークされる
  • RequireImplementorsAttribute
    • インターフェイス型をマークすると、そのインターフェイスを実装するすべての型がマークされる
  • RequireDerivedAttribute
    • 型がマークされると、その型を継承するすべての型もマークされる
  • RequiredInterfaceAttribute
    • インターフェイスがマークされると、そのインターフェイスを実装するすべてのインターフェイス実装がマークされる
  • RequireAttributeUsageAttribute
    • ある属性の型がマークされるとその型のすべてのカスタム属性もマークされる

ドキュメントのコードを抜粋しつつ、いくつかの属性を簡単に紹介します。

RequireMemberAttribute

まず RequireMemberAttribute ですが、名前の通り指定したメンバーがマークされます。下記のように UsedFoo クラスの利用されていない FieldRequireMember を指定することで削除対象から外れます。

public class NewBehaviourScript : MonoBehaviour
{
    void Start()
    {
        new UsedFoo();
    }
}

class UsedFoo
{
    // 使われていないが削除されない
    [RequiredMember]
    public int Field;
}


class UnusedFoo
{
    // ただしそもそもUnusedFooを使ってない場合は
    // マークされていても削除される
    // (クラス自体が削除される)
    [RequiredMember]
    public int Field;
}

ただし、UnusedFoo のようにどこからも利用されていないクラスの場合は、 RequireMember を指定していても削除される点に注意が必要です。

RequireImplementorsAttributeとRequireDerivedAttribute

次に RequireImplementorsAttributeRequireDerivedAttribute です。この属性をそれぞれインターフェイスと型につけるとそれを実装・継承したクラスはすべてマークされます。

RequireImplementorsAttribute の利用方法を示します。 IFoo をマークしているため、 IFoo を実装するすべてのクラスは削除対象から外れます。そのため UnusedFoo のようにどこからも利用していないクラスも削除されません。

public class NewBehaviourScript : MonoBehaviour
{
    void Start()
    {
        // Fooを参照
        IFoo ifoo = new Foo();
    }
}

[RequireImplementors]
interface IFoo {}

// こっちは普通に残る
class Foo : IFoo {}

// IFooを実装しているので残る
class UnusedFoo : IFoo
{
    // ただし利用していないメンバーは削除対象
    public static void UnusedMethod() {}
}

// ただし指定してもIBarをそもそも利用してないと削除される
[RequireImplementors]
interface IBar {}
class UnusedBar : IBar {}

ただしRequireMember と同様ですが、 IBar のようにそのインターフェイスをどこからも利用されてない場合は、そのインターフェイスもインターフェイスを実装するクラスも削除される点に注意が必要です。

RequireDerivedAttribute は、上記のクラス版のような挙動をします。利用方法については、こちらのドキュメントで確認ください。

ライブラリやフレームワークの設計では、フレームワーク側がインターフェイスや基底クラスを用意し、開発者がその具体クラスを実装、それをリフレクションなどを介してインスタンス化するといった実装が比較的多く感じます。インターフェイスに基底クラスに属性を設定するだけで具体クラスすべてにマークが入るこれらの属性は、かなり便利なのではないでしょうか。

RequireInterfaceAttribute

RequireInterfaceAttribute は、 RequireImplementorsAttribute と名前が似ていますが、こちらの属性はクラスにアタッチしますが、パラメータにインターフェイスの型情報を指定します。

下記のように Foo クラスに対して IFoo を指定すると、IFoo はコード中で利用していませんが、 RequiredInterfaceIFoo を指定しているためインターフェイスの情報自体は削除対象から外れます。

public class NewBehaviourScript : MonoBehaviour
{
    void Start()
    {
        new Foo();
    }
}

interface IUnused {}
interface IFoo {}

// IFooは残るが、IUnusedは消える
[RequiredInterface(typeof(IFoo))]
class Foo : IFoo, IUnused {}

まとめ

Unityの最適化の1つであるManaged Code Strippingについて、ビルドしたアプリからクラスを抽出しながらこの挙動を把握しつつ、Unity 2020 LTS以前のマーク方法と、Unity 2020 LTS以降で利用できるアノテーション属性によるマーク方法を紹介しました。

Managed Code Strippingはビルドしないとその動作が確認できず、またその解析も標準ツールがないのでなかなかとっつきにくい機能ではないか、と個人的に感じています。

今回紹介した解析や2020 LTSのアノテーション属性の紹介が、少しでも参考になれば幸いです。


関連記事一覧

  1. この記事へのコメントはありません。