前提
UnityのIL2CPPを使ったビルドにおいて、Dictionary<TKey, TValue>を使うとすごい勢いでバイナリサイズが大きくなっていく。
iOSにおいてはUniversalBinaryで出力を余儀なくされることが多いのと、実行バイナリがストア上では暗号化される問題もあり、ひとつの組み合わせで100KB前後の水準で、最終的なダウンロードサイズが増大する。
"Generics使わなきゃいいじゃん"みたいなのは思考停止の原人の所業なので、きちんと型を大切にしながらIL2CPPによる生成コードサイズを削るにはどうすればいいのかを考えるのが、現代に生きる文明人に求められる仕事になる。
追い込まれた時こそ、先に踏み込んで行く必要がある。
分析
まずは何故Dictionaryがこれほどまでに膨らむかを考える。Dictionaryは比較的高級なコンテナクラスなので、内部実装として多くのものを抱え込んでいる。
corefx/Dictionary.cs at master · dotnet/corefx · GitHub
さもありなんという感じである。(Unityの実装はこれではないが、参考までに。。。)
これを全部のTKey, TValueの組み合わせでジェネリクスのインスタンス化されてしまったらなすすべがないが、IL2CPPは型パラメータが参照型な場合に限っては生成したコードを共用してくれる仕組みが入っている。つまり、できる限りコードが共用されるような使い方をすれば、Dictionaryを使ってもバイナリサイズへのインパクトを少なくすることができる。
対策
値型を使う限り、どこかでコードサイズが膨らむのは避けられない。ただ、それはDictionaryみたいな高級なクラスで行われるべきではない、という理論で対策していく。 要するに任意の値型を参照型として扱えるようにして、Dictionary<TKey, TValue>に関しては実行コードが極力共有されるようにすればいい。 そうとわかれば割と簡単で、以下のような実装をしてみればいい。
public class Boxing<TValue> { public TValue Value { get; set; } public Boxing(TValue value) { this.Value = value; } public override int GetHashCode() { return Value.GetHashCode(); } public override bool Equals(object obj) { if (obj == null || !(obj is Boxing<TValue>)) { return false; } return Value.Equals(((Boxing<TValue>)obj).Value); } public static implicit operator TValue(Boxing<TValue> boxing) { return d.Value; } public static implicit operator Boxing<TValue>(TValue value) { return new Boxing<TValue>(value); } }
要するに、手動Boxingをできるようにする。 この方法が良いのは、相互に暗黙的キャスト演算子を定義しておくことで、変更自体は最小の量にして、リスクを下げることができること、Dictionaryとしての機能自体は今まで通りほぼフルスペックで使えること。 一応記しておくと、適用するには
Dictionary<SomeStruct, SomeClass> dictionary;
みたいな定義をまるっと
Dictionary<Boxing<SomeStruct>, SomeClass> dictionary;
に置き換えていけばいい。大体はいけるが、たまに暗黙的キャストで解決できない場合とか、もろもろあるので、適宜やっていく。 凄くダーティーハックな感じはするが、実際大規模プロダクトだとこれだけでMB単位でのコード削減が見込めるので、費用対効果はすごく良い。
トレードオフ
楽ではあるが、都度都度Boxingが走ることになるので、負荷的にはやはり厳しいものはある。 1フレームで多数呼び出すような場合なら、そのままにするなり、せめてenumをintにキャストして扱うなどの対処ですましておくのが良い。 また、Dictionary以外で、たとえばインターフェース制約がかかってる型パラメータに対して適用する場合は、当然それ用にBoxingクラスを実装する必要が出てくる。
そもそもなんで共用できるのか?むしろなぜ共用できない場合があるのか?
なんでかといえば、要するにそのジェネリクスのインスタンスのスタックサイズに差が生まれるからだ。 値型はスタックに配置するときに直接配置されるため、それぞれの型ごとにスタック上のサイズが決まる。一方で、参照型はスタック上ではほぼポインタとしてしか扱われないので、どんな型だろうと占有するスタックサイズは同一になる。 なので、値型/参照型の組み合わせによって、インスタンス化後に生成される実行コードそのものが別のものにならなければならないことになってくる。
CLRでの実行時は、この差はジェネリクスのインスタンス化時にJITコンパイルすることによって解決されているので、ビルドサイズの問題には結び付かない。しかし、IL2CPP(または、AOT)の場合には、コンパイル時にこれらをすべてインスタンス化しておかなければならないため、この問題が表面化する。
結論
こんなことしないで済むのが一番なんで、早めにビルドサイズには気を使って、やっていきましょう。