Android
Kotlin
Xamarin.Android
GooglePlay

Xamarin.Android 起動速度改善の記録 (追記あり)

Xamarin で作成した成果物といえば、やはり起動時間が気になるものです。

手もとの Xamarin.Android 製の小品を速度改善(しようとした)記録、および最終的に Kotlin へ移植してどうなったか、を記憶が新しいうちに記述してみたいと思います。

作業の経緯

対象アプリケーションはXamarin.Androidで作成した、個人ユーザ向けの単純なツール系アプリケーションです。 Xamarinを選択したのは、ほかプロジェクトでどっぷり.NET環境漬けであったことと、複数プラットフォーム展開できたらいいな、という軽い下心でした。:smirk:
しかしながら、実際にリリースをしてみると、並み居る競合アプリと闘いながら Google Play ランキング上位を目指していくにはもうゴリゴリにネイティブ対応していくしかありませんでした。:sob:
iOSでも同等のゴリゴリが発生するかと考えると、一旦複数プラットフォーム対応は忘れ、Android版を最適化して利用者の満足度向上を狙うことにしました。

元プロジェクトが抱えていた問題

単純なツール系アプリケーションと書きましたが、それだけに、使いたいときにすぐに起動できるようでないと、競合アプリに簡単にユーザを奪われてしまいます。 ある程度機能が落ち着くと、動いたら問題ないけれど起動がもっさりというご指摘を受けるレビューが入りはじめ、頭を抱えていました。
また、Xamarin (Mono) の宿命的に、インストールサイズが大きいことも(利用者には見えにくいですが)気になる点でした。

アプリケーション自体の動作速度に関しては、Xamarin.Android の段階でできる限りの手を打ったため、Android Studioで作成したものと遜色なくなったと考えていましたが、自プログラムが動き出す前までの時間はいかんともしがたい・・・

Xamarin.Android で手を尽くした最終版の起動速度

APKファイル
サイズ(MB)
インストール後
サイズ(MB)
起動時間 (sec)
(起動+初期化)
26.1 67.80 0.524+0.644=1.168

上表で起動としている部分は、アプリケーション側に処理が渡る前の所要時間で、ライブラリのロード・ランタイムの初期化にあたります。 この部分はまだアプリケーションに処理が渡っていないため、どうにも打ち手がありません。
この0.5秒をどうしても短くしたかったので、移植となりました。

Xamarin.Androidで取った対策

移植を考える前に、Xamarin.Androidで実施した対策を以下に挙げてみます。

アプリケーション側での対策(K.U.F.U:工夫)

とにかく、ユーザに操作画面をいち早く提示することを最優先に組みなおしました。
初期化処理をすべて洗い出して複数の段階に分け、画面表示に必要なものだけを優先、その他はすべて別スレッドで後回しとしました。 マネタイズの要となるアドネットワーク系の初期化処理すらもばっさりと後回しです。
また、(よせばいいのに)画面の表示内容をカスタマイズできるようにしていたため、表示にあたってはどうしても画面への描き直しが発生します。 画面の初期表示内容は、一度準備できたら画像キャッシュファイルとして保存しておき、2回目以降の起動はその画像をベタ描いて済ませるようにしました。
JSONを読み出す局面も、シンプルなJSONであれば極力 JSON.NET を利用せずに直接読むようにしました。

この段階をベースとして、まずはXamarinでできることを切り込んでいきました。(実際には並行・試行錯誤しながらリリース・テストを繰り返しましたが、ここではステップを整理して記述しています)

アプリケーション内でできるだけ工夫した初期段階での起動速度
起動で2秒は明らかに遅いと感じるレベルです。さすがにこの段階ではリリースできません。

APKファイル
サイズ(MB)
インストール後
サイズ(MB)
起動時間 (sec)
(起動+初期化)
71.0 89.21 0.781+0.977=1.758

APK内で大きなサイズを占めているのはやはりassembliesディレクトリです。
Q01-01.png
中身はご存知の通り.NET系のランタイムや参照ライブラリです。
Q01-02.png

リンカでバイナリサイズの縮小

まず当たり前の第一歩で、リンカによって不要コードを削除してもらいます。対象は「SDKおよびユーザー アセンブリ」で、利用していない部分をばっさり刈り込んでもらいます。
Q0010.png

リンカ適用後のサイズと起動時間

APKファイル
サイズ(MB)
インストール後
サイズ(MB)
起動時間 (sec)
(起動+初期化)
19.5 37.39 0.739+0.913=1.652

APK内のassembliesディレクトリのサイズが劇的に縮小していることがわかります。
Q02-01.png
各々のアセンブリも、不要コード部分が削除されています。
Q02-02.png
例えば、Monoランタイム (Mono.Android.dll) は 23.5MB→1.1MB にまで縮小しています。
ただし、起動時間はほとんど変わっていません。

ProGuardの適用

Monoの性質上、難読化を利用することはできませんので、コード削減機能の効果だけを狙います。

ProGuard 利用後のサイズと起動時間

APKファイル
サイズ(MB)
インストール後
サイズ(MB)
起動時間 (sec)
(起動+初期化)
19.2 36.27 0.786+0.921=1.707

前段のリンカで十分削減されているためか、あまり変化はありません。
Q03-02.png

前段のAPKと比較しても、ProGuardが手を出せるところはほとんどなかったことが見て取れます。(リソースは除外しています) 起動時間にもほとんど変化はありません。

AOT+LLVMの適用

前項目までが通常のコストで対処できる範囲かと思います。
本アプリでは、利用している Visual Studio が幸いにもEnterpriseであったため、このオプションを取ることができました。
Q0020.png
事前コンパイルでネイティブバイナリを作成してAPKに含めることで、アプリケーション起動時のJIT時間を短くする効果が期待できます。 といいながら、JITとあるように、現在はAndroid端末側の実行環境も変わっていっていますので、将来的にはAOTも不要になるかもしれませんね。

APK内の様子は以下のようになりました。
Q04-01.png
ABI ごとのフォルダ内に(参照しているライブラリも含め)ネイティブバイナリが生成されているのは圧巻ですらあります。
Q04-02.png
これは速度アップしないわけはないですよね。もちろんバイナリの分だけAPKサイズは増加しました。

APKファイル
サイズ(MB)
インストール後
サイズ(MB)
起動時間 (sec)
(起動+初期化)
26.1 67.80 0.524+0.644=1.168

アプリケーション自体の初期化処理も、何も変更していないのにちゃっかり速度改善できているようです。

前述のように、ネイティブバイナリは指定した ABI ごとに作成されます。
このため、APK スプリットで ABI ごとの APK を作り分け、バージョン番号も別に付ける必要があります。

ここまでのまとめ

以上からすると、Enterpriseで許されたAOTを使わない限り、起動速度の改善はできそうにありません。
というよりも、元の状態でも(与えられた環境の中では)ベストの速度が出ており、それ以上なんとかしようとすると、AOTのような最後の手段に頼ることになる、と考えたほうがよさそうです。

Kotlinへの移植

元が Xamarin.Android で実質的にネイティブであったため、ほぼ単純に C# コードを Kotlin に置き換えていくことで作業が進みました。  ただ、.NETの Decimal, ロケールの扱い, LINQ は便利だったんだなあ、と感じる移植ではありました。

さて、移植完了して起動時間はどのようになったでしょうか!

APKファイル
サイズ(MB)
インストール後
サイズ(MB)
起動時間 (sec)
(起動+初期化)
7.3 20.80 0.267+0.655=0.922

微妙 (´・ω・`)・・・ サイズは1/3になり、速度は確かに Xamarin.Android + AOT より 0.24秒(20%ほど)早くなっています。 起動1秒を切ったことで、少なくともモタつき感はなくなりましたので、本プロジェクト的には満足です。

しかし、起動時間20%増し程度のハンデであれば、サーバ向け通信が発生するようなアプリケーションならば通信での所要時間に紛れてしまって目立たなくなりそうです。 ある程度規模が大きく、バックエンド側との通信が発生するようなアプリ(ほとんどの業務アプリ)の場合は Xamarin.Android + AOT であればネイティブと十分戦える と言えそうです。

移行後の課題

本アプリでは、いろいろな設定状態を JSON として SharedPreference に保存していたのですが、JSON からのデシリアライズが重く、設定画面のタブを切り替えるのすら一呼吸が発生しました。(Jackson利用)

本来でしたら、設定のシリアライズ・デシリアライズともそう何度も実施しないようにすべきなのですが、本アプリの場合、アプリ本体とほぼ同機能(場合によっては状態も同期する)を持つウィジェットがあり、設定項目は早々に反映・展開をしてしまいたいのです。
移植前は深く考えずに JSON.NET を利用していたのですが、意外にも優秀だった模様です。

対策としてはJacksonのObjectMapperをシングルトンにし、早い段階でバックグラウンドの別スレッドで一旦デシリアライズをやらせておくことで、ObjectMapper内に解釈用のキャッシュを残すようにしました。

移植版としての考慮事項

開発環境を完全に変更しているため、新バージョンは実質的に移植版となります。
あたり前ですが、パッケージ名と署名さえ同一であれば、Google Play Console においても特に問題なく、更新版のAPKとして指定することができました。
実は誤って APK 署名用キーの組織名にカンマを含めてしまっていたのですが、Visual Studio, Android Studio ともに問題なく通って事なきを得ました。

アプリケーションのショートカット

Xamarin.Android 製アプリの場合、AndroidManifest.xml のアクティビティ名などの前にMD5名が追加されて生成されます。
(例)md5607a3be7d1a244472ecefb215d000000.MainActivity
既存ユーザのデスクトップ上のショートカットアイコンは、このMD5名つきのアクティビティを指していますので、新プロジェクトにも activity-alias として旧名称のエイリアスを用意しました。 これを用意しないと、アップデートの際にショートカットアイコンが消えてなくなることになります。

現物はこちら

よろしければお試しを (;・∀・)
電卓-カシオ式 マルチ計算機 あまり計算・割引・消費税・時間計算対応
https://play.google.com/store/apps/details?id=jp.co.conduits.calcbas


(2018/10/22追記 ココカラ)
恐れ多くも atsushieno 様に twitter にて言及いただきました。
ありがとうございます。:scream_cat: :bow_tone1:

Xamarin.Androidのproguardサポートについて
http://atsushieno.hatenablog.com/entry/2014/12/03/010216
ご紹介いただいた記事を改めて読み直してみましたが、簡単なオプション(に一見見えるようになっている)でとりあえず動かせる&効果が出る、詰めようと思えばさらに詰められるようになっているところが本当に助かります。:joy:

こちらもとってもいいことを聞きました!
早速 csproj に <AndroidPackageNamingPolicy>Lowercase</AndroidPackageNamingPolicy> を加えてみると、最終的に生成された AndroidManifest.xml 内で md5 だった部分がすべてパッケージ名に戻りました。なるほど!

Android Callable Wrapper Naming
https://developer.xamarin.com/guides/android/advanced_topics/java-integration/android_callable_wrappers/#Android_Callable_Wrapper_Naming
にもあるように、各アクティビティ(やウィジェットのブロードキャストレシーバ)に明示的に Name アノテーションを付けると、名称をコントロールできていました。
しかし、アクティビティやレシーバそれぞれに追記が必要でしたし、パッケージ名を省略した書き方もできません。

AndroidManifest.xml のようなつもりでパッケージ名を省略して書くと・・・

TestWidget.cs
[BroadcastReceiver(Name= ".TestWidget", Label = "@string/widget_name")]

The Name property must be a fully qualified 'package.TypeName' value, and no package was found for '/TestWidget'
怒られてしまいます。
<AndroidPackageNamingPolicy/> ならば、一括で制御できるのでこれはいいですね :sunny:

余談ですが・・・
Android Activity tag android:name MD5 changed in Manifest file without changing any code
https://stackoverflow.com/questions/51184082/android-activity-tag-androidname-md5-changed-in-manifest-file-without-changing
このように Xamarin 15.7 までは(もちろんもう治っています)md5 の生成にアプリバージョン番号まで含んでいたようで・・・
アプリバージョンアップのたびに md5 が変わる → アクティビティ名やらレシーバ名が変わる → アイコンやウィジェットが毎回消えてなくなる → ユーザレビューで怒られる (´・ω・`) という流れにはまっておりました。

(2018/10/22追記 ココマデ)