Managed Stripping Levelを変更する隙にRoslyn Analyzerを導入した話

はじめに

こんにちは、クラスター株式会社でソフトウェアエンジニアをしているhomulerです。 (入社の経緯は4コマになっているので、ぜひそちらも参照ください)

clusterのアプリはUnityで作られているのですが、先日、apkのサイズ対策も兼ねて、ビルド時の設定の一つであるManaged Stripping Levelをlowからmediumに変更しました。必要なコードがstripされてしまう可能性があるため、リリース済みのアプリのManaged Stripping Levelを変更するのは怖い面もありますが、大きな問題もなく実施できました。

この記事では、Roslyn Analyzerの導入など、安全に設定変更するために行ったことについて紹介します。

きっかけ

まず、Managed Stripping Levelを変更することになった事情について触れます。

クラスターは開発者が増えてきたこともあり、機能開発が加速しており、それに伴ってapkのサイズの増加ペースも加速していました。apkのサイズが150MBを超えるとgoogle playにアップロードできなくなるため、定期的にサイズ削減は行われていたのですが、近いうちにこの制限を超えてしまうことは明らかでした。 したがって、抜本的な対策が必要になったのですが、開発に一定時間かかる見込みであり、一方で「apkのサイズを増加させてしまうが、できるだけ早くリリースしたい改修・機能」もあり、抜本的な対策の前に数MBのサイズ削減を行いたい需要がありました。

そこで、どのような対策が有効かを調べるために、過去のバージョンでのapkのサイズの内訳を調査したところ、画像等のリソースの増加ペースとコードの増加ペースがほぼ同じぐらいであることが分かりました。

libil2cpp.soのサイズを減らす方法はいくつか考えられましたが、当時のManaged Stripping Levelがlowで、不要なコードがそれなりに含まれているという状況で、かつmediumに変更すると6MB程度サイズが減ることが分かったため、Managed Stripping Levelの変更を試すことにしました。

ただし、この時点では設定を変更しただけで、削減された6MBの中には必要なコードも含まれており、アプリは正常に動作しない状態です(後述するように、最終的には3MB程度の削減)。

Managed Stripping Levelを変更する際の課題

既に触れましたが、Managed Stripping Levelのレベルを上げるとstripされるコードの量が増えるため、アプリが正常に動くとは限りません。

それぞれのレベルの仕様については、Managed code strippingUnity linkerの公式ドキュメントに書かれています。clusterの場合は、以下のような課題がありました。

  1. Reflection APIを使っているコードが動かなくなる
  2. 既存のワールドが動作しなくなる
  3. 並行開発中のコードが壊れる

Reflection APIを使っているコードが動かなくなる

これについては、一般的に発生する問題です。 例えば、Activator.CreateInstanceを呼び出してインスタンス化している場合、対象のコンストラクタがstripされていると実行時にエラーになります。

これは、コンストラクタにPreserveAttributeをつけることで対策できます。ただし、実行せずに対象を完全に見つけ切るのは難しい問題でもあります。

clusterではDIフレームワークとしてZenjectを使っていて、Zenjectを使ってインスタンス化しているクラスが影響を受けることは分かっていました。その他、コードベース上でReflection APIの利用箇所をgrepして(主にシリアライズ関連の処理が該当)、残りは動作確認&QAで見つけるという方針で進めました。見逃すものもあるかもしれませんが、致命的な問題であれば見つかるはずという想定です。

実際には、これらに加えて、PostProcessEffectRendererのコンストラクタも対策する必要がありましたが、リリース前に行っているbug bashで見つけることができました。

既存のワールドが動作しなくなる

これは、アプリのコードでは直接使っていないものの、ユーザーが作成したコンテンツ(UGC)が参照しているUnityのComponent等がstripされることで発生する問題です。

clusterでは、ユーザーがCluster Creator Kitを使ってワールドを作成し、アップロード・公開することができます。ワールド内ではUnityのアセットも使われますが、必要なComponent等がclusterのアプリに含まれていない場合、動作しません。

Managed Stripping Levelがlowであってもstripされることがあるため、既に必要なComponentが含まれるように設定されてはいますが、漏れている可能性もあるので、こちらもチェックが必要です。

対策としては、link.xmlに対象のAssemblyやクラス等を記述します。

<linker>
  <assembly fullname="Unity.TextMeshPro" preserve="all"/>
  <assembly fullname="Unity.Timeline" preserve="all"/>
  ...
</linker>

対象を特定する上でgrep等は不要で、仕様で動作保証しているComponentが含まれていればよい一方で、動作するかどうかはワールドに依存するので、完全な動作確認が難しいです。

ただし、現状はどのComponentが動作するかの仕様についてあいまいな部分があるため、今回の対応では、動作確認に割く時間を減らすために、安全側に倒して広めにComponentが含まれるようにしています。

ちなみに、link.xmlを編集 → ビルド → 動作確認を繰り返すのは大変なので、ビルドに含まれているシンボルのdiffを確認して、link.xmlの変更が反映されているかチェックしていました。

strings libil2cpp.sym.so | cut -c -20 | sort | uniq > symlist.txt

簡易的ですが、stripされているか確認する対象が決まっている場合は、libil2cpp.sym.soの中身を見れば高い確度をもって確認できると思います。また、ビルドの前後のdiffを見れば、何がstripされているか調べる目的でも使うことができます。

並行開発中のコードが壊れる

これは、主に「Reflection APIを使っているコードが動かなくなる」問題への対応に関係しています。

コード上で何らかの対策が必要な場合、その時点で存在するコードに対策がされたとしても、後から追加されるコードで対策される保証がありません。そのため、同時にリリースされる別の機能が動作しないということが起こり得ます。また、仮にリリース時点で問題がなくても、UnityEditor上では動作するが実機では動かないコードが増えてしまうと、将来の開発コストが増大します。

こちらは、Roslyn Analyzerを導入することで対策することにしました。

Roslyn Analyzerを導入する

Roslyn APIsとは.NET Compiler Platform SDKの通称ですが、これらを使ってコードの構文解析や意味解析を行うことで、コード上の問題等を検出し、UnityEditor上やIDE上でメッセージを表示することができます。

これにより、コードを書いている際に問題に気づくことができ、開発効率の向上が期待できます(ただし、コンパイル時間が延びる可能性がある点に注意)。

元々、レビューコスト等の面でRoslyn Analyzerを導入したいという話はあったものの、実際の導入には至っていませんでした。 Roslyn Analyzerで解決できる課題かどうか、あるいはRoslyn Analyzerで解決すべき課題かどうかはケースバイケースですが、DeNAさんの「Unityプロジェクト向けRoslynアナライザの作りかた」の記事で触れられている例とほぼ同じで、どんぴしゃりだと思い、このタイミングで導入してみることにしました。

(白状すると、apkサイズを縮小するという目的からみると効果は大きくないかもしれないと薄々感じてはいましたが、トータルでみるとやる価値があると思っていたのは、こういう事情もあります)

先ほど触れたように、clusterにおいてstripされる典型的な例として、Zenjectを使ってインスタンス化しているクラスのコンストラクタがあります。

public sealed class ApiCraftRepository
{
    readonly ApiClient apiClient;

    // コンストラクタにPreserveAttributeが必要
    ApiCraftRepository(ApiClient apiClient)
    {
        this.apiClient = apiClient;
    }
}

stripされてしまうコンストラクタにPreserveAttribute(実際には、Zenjectが提供しているInjectAttribute)を強制したいのですが、全てのコンストラクタにつける必要はないため、Zenjectによってインスタンス化されるクラスに限定したいです。 そこで、ZenjectのBindやToメソッドの型引数に使われているクラスに限定して、コンストラクタのattributeをチェックするRoslyn Analyzerを実装しました。

以下はAnalyzerのコードの抜粋です(一部チェック処理等を省略しています)。

static IEnumerable<Diagnostic> AnalyzeBindMethodInvocation(IInvocationOperation invocation)
{
    var methodSymbol = invocation.TargetMethod;

    // GenericなBindメソッドは、いずれも型パラメータを1つだけ取る
    if (methodSymbol.TypeArguments.First() is INamedTypeSymbol concreteType && ShouldReport(concreteType))
    {
        return new[]
        {
            // メソッド名の位置でメッセージを表示する
            Diagnostic.Create(Rule, GetMethodNameLocation(invocation), concreteType.Name),
        };
    }
    return null;
}

static bool ShouldReport(INamedTypeSymbol type)
{
    if (type.TypeKind != TypeKind.Class)
    {
        return false;
    }
    if (IsMonoBehaviour(type))
    {
        return false;
    }
    return !HasPreservedConstructors(type);
}


static bool HasPreservedConstructors(INamedTypeSymbol type)
{
    return type.Constructors.Any(ctor =>
    {
        // PreserveAttributeでも問題ないが、Semantics上の理由でInjectAttributeのみをチェック
        return ctor.GetAttributes().Any(x => x.AttributeClass?.Name == "InjectAttribute");
    });
}

ただし、以下のような場合にはPreserveAttributeをつける必要がないため、偽陽性が発生することもあります。

var foo = new Foo();
Container.Bind<Foo>().FromInstance(foo).AsSingle();

しかし、今回のケースでは偽陰性をできるだけなくすことが重要で、かつclusterのコードでは偽陽性となるケースが数個しかなく、warning pragmaでエラーを抑止可能なので許容しています。

静的解析で厳密に検査するのは難しいこともあるので、自分たちのコードに合わせた実装にしています。Roslyn AnalyzerをOSSとして公開する場合などは、事情が違ってくるかもしれません。

その他、いくつか必要なコンストラクタがstripされてしまうケースがありましたが、ケースに合わせたRoslyn Analyzerを実装することで、新規のコードでも必要なコードがstripされないように対策できました。

Roslyn Analyzerの運用上の問題

一つ問題があり、Roslyn Analyzerの実行対象のAssemblyとは異なるAssemblyに含まれる、privateなメソッド等を読むことができませんでした。 clusterの場合、ZenjectのInstallerとその他のコードでAssemblyが分かれており、Installerのコードを対象にRoslyn Analyzerを実行した時に、インジェクト対象のクラスのコンストラクタがprivateだと正しく判定できません。 Unityでコンパイルする時に、MetadataImportOptionsがPublicになっていることが原因のようですが、Roslyn APIsを使って自力でコンパイルする以外の回避策が不明だったため、設計的にイマイチな面もありますが、コード規約上もコンストラクタをpublicにするということにして対処しています。

結果とまとめ

今回は、Managed Stripping Levelを変更するに当たり行った対策などについて紹介してきました。

Managed Stripping Levelをlowからmediumに変えただけの状態では6MB程度の削減でしたが、もろもろ対策した結果、主にUnityのComponentが広く含まれるようになった影響により、最終的には3MB程度の削減効果となりました。 apkサイズがギリギリの状況だったので、それでも大きな効果ではあったのですが、6MB減っていた時点ではもっと期待していたのも事実で、もう少し攻めたかった気持ちもあります。

ただ、副次的にRoslyn Analyzerを導入できたことで、今後、サードパーティーのアナライザの追加をしたり、自前のアナライザを実装をする際のハードルを大きく下げることができました。

Managed Stripping Levelを変更するケースは多くないかもしれませんが、Roslyn Analyzerを導入したいけど色々な事情でできていないという人のヒントになれば幸いです。

クラスター社では、機能開発を進めつつも、生産性を向上させるための改善活動が日々行われています。この記事を読んで興味がわいた方は、ぜひ下記リンクからご応募ください。

recruit.cluster.mu