
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
を継承しているUsedInTestBehaviourClass
はTestBehaviour
のみから参照されているNotUsedClass
はこのプロジェクトのどこからも利用されていないUsedInUnusedClass
はNotUsedClass
からのみ利用されている
そのため TestBehaviour
を起点に UsedInTestBehaviourClass
は残り、 NotUsedClass
と UsedInUnusedClass
は削除されるはずです。
アプリビルド後のクラス情報を確認するためにプラットフォーム設定をAndroidにし、ビルド後に生成されるapkからdllを抽出して、残ったクラス情報を確認します。(AndroidにしていますがStandalone・iOSなどでも構いません)
具体的には上記のクラスが入ったプロジェクトを下記の構成でビルドしてapkを作成します。
- ScriptingBackendにmonoを設定
- https://docs.unity3d.com/Manual/IL2CPP.html
- 「Player Settings > Configuration > Scripting Backend」
- プラットフォームにAndroidに設定
- Managed Stripping LevelをHighに設定
- https://docs.unity3d.com/ja/2020.3/Manual/ManagedCodeStripping.html
- 「Player Settings > Optimization > Managed Stripping Level」
ビルドで生成されたapkはzipで固められているのでzip拡張子をつけた上で適当な圧縮・解凍ツールで展開することで下図のように展開できます。

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

dll内のクラスの確認には今回はdnSpyを用いました。解析したいdllを開くことで下図の右画面のように、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
を継承したクラスが利用していないクラスやメソッド、フィールドは削除される- たとえば
UsedInUnusedClass
はUnusedClass
が利用しているが、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
の呼び出しに失敗します。
リフレクションによるインスタンスの生成やメソッド呼び出しは文字列を介するため( GetType
や GetMethod
の引数に型やメソッド名を文字列で指定するため)、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に入ったコードストリッピングのアノテーション属性について
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
クラスの利用されていない Field
も RequireMember
を指定することで削除対象から外れます。
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
次に RequireImplementorsAttribute
と RequireDerivedAttribute
です。この属性をそれぞれインターフェイスと型につけるとそれを実装・継承したクラスはすべてマークされます。
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
はコード中で利用していませんが、 RequiredInterface
で IFoo
を指定しているためインターフェイスの情報自体は削除対象から外れます。
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のアノテーション属性の紹介が、少しでも参考になれば幸いです。
この記事へのコメントはありません。