どうやってアプリ起動時間を6秒から1.6秒に短縮したか

どこの開発チームでもやっているように, アプリの起動と読込み時間を改善することは わしら Android開発チームでもひとつの優先事項でした.

デバイスメーカーはさらに速く流れるような操作を提供し続けており, ユーザーは一層のスピード向上を期待しています.

最近, 新アプリを「Dependency Injection」や 「Reactive Programming」のようなモダンなパターンを利用して書き直しました. これによって メンテナンス性が向上し, よりモダン & モジュール化 されましたが, いくつかの調整が必要となりました.

リリース時, 起動時間は Nexus5 上で 5, 6 秒かかっていました. これはわしらの目標である「2秒以下」を超えていました. それでパフォーマンス改善を行うことになりました.

主な低速化の原因が 「リフレクション」であることが分かりました. これとその他の細かいことを改善することで, 起動時間を 1.6 秒に短縮することができました.

どのようなことを行ったか

まず, アプリの起動時間を Android のトレース機能を使って起動時間の計測です. Application クラスのコンストラクタから画面に表示されるプログレスインジケータの終了までを計測しました.

traceview_profile

Profiling with Traceview and dmtracedump | Android Developers

最大のパフォーマンス低下の原因を見つけるために出力された分析用トレースファイルたちを集め DDMS に読み込ませていましたが, 最終的には 簡単な方法でボトルネックを特定できトレースしながらパフォーマンスを比較できる「NimbleDroid」に切り替えました.

Home_-_NimbleDroid___Automated__Comprehensive_Performance_Analysis_for_Every_Build_of_Your_App

Home - NimbleDroid | Automated, Comprehensive Performance Analysis for Every Build of Your App

簡単な改善

最初に見つけた主な速度低下の原因は, 多量のクラス, メモリを大量に消費する jar リソースの読み込み JodaTime (特定済みの問題有り) でした.

Joda Time's Memory Issue in Android

Groovy のクロージャを使っていましたが 他にこれを使っていなかったので, 取り除くことにしました. 昔ながらの Java7 の構文に戻って. Groovy で作成されたコードを取り除きました. 他の選択肢を探していますが Java の 匿名クラスを見るための IDE補助機能は優先ではありません.

次に, RxJava が1秒程度 遅延させていることを見つけました. これについては, 次のリリースでの修正されます. ありがとうございます.

NewThreadWorker.tryEnableCancelPolicy doing costly reflection on Android · Issue #3119 · ReactiveX/RxJava

次に, アプリに組み込まれているいくつかの サードパーティの 統計クライアントが アプリの起動時にブロッキングしているコールがありました. どのようにインスタンス化され動作しているか ベンダーと協力をいただきながら起動時の機能改善を行いました.

また, 我々のコード自体にも技術的問題を見つけました.
md5の計算処理や 一般的な多すぎる処理で起動オブジェクトをブロックをしていました.

これらを改善したところで, 起動時間は以前の半分の 3.2 秒に短縮できました.

次は データフローの最適化です. これは, わしらのアプリのような高データ集約型のアプリには特に重要な意味を持ちます.

データストア処理

データ処理の全てが非同期であるため、我々は最初のプレゼンテーションとデータ層との間の抽象化として機能するように、単一のコンテンツマネージャとしていました。これによりカプセル化することはできましたが、徐々にデータが永続ストレージから必要とされる場合に必ずゴッドクラスをインスタンス化することになる、というスローダウンを引き起こしていました。

God object - Wikipedia, the free encyclopedia

この場合、Analytics の設定値は、アプリの起動時にディスクからの取得を必要としているのですが, SSL対応ネットワーククライアントのインスタンス化をを待たなければなりませんでした.

アプリに機能追加されるにつれ, さらに依存関係が加わり, コンテントマネージャーの起動時間は増加し, 初期インストール時と同じくらい時間がかかっていました.

単一のコンテントマネージャーとするよりも, 独立したデータストアとする方針にすることで, 素早くキャッシュされたデータを読み込むことが可能となりました.

これは, ディスクからUIへのデータの処理フローを最適化する FacebookのAndroidのチームによって行われた最近の研究と似ています.

Improving Facebook's performance on Android with FlatBuffers | Engineering Blog | Facebook Code

まず, 最初は, ディスクとネットワークDAOを利用して個々のシングルトンのデータ・ストアとしているコンテンツマネージャを壊すことにしました。こんなかんじで、コンテンツマネージャから ConfigNetworkDAO と ConfigDiskDAO を利用して ConfigStore を立ち上げていました.


@Singleton
public class ConfigStore extends Store {
   @Inject
   public ConfigStore(AppConfigParser parser,
                      final ConfigDiskDAO loader,
                      final Lazy<ConfigNetworkDAO> fetcher) {
       super(loader,fetcher,parser);
}

重要なオフライン時または最初のロード後に - 時間のかかるネットワーククライアントを利用し、私たちが実際にネットワークの操作を行うまでそれをインスタンス化しないために許可されている Dagger の時間のかかるインスタンス化を改善することにしました。このアプリでは, バックグラウンドサービスを使用してデータをダウンロードすることがメインで ここで処理するデータはほとんどなく、ネットワークを利用した通信よりもディスクストレージからUIにロードされます. 結果, UIに ディスクからのデータ処理経路を最適化することができ、大きくスピードを取り戻すことができました。

リフレクションの削除

トップストーリーのデータをパースする際に 永続ストレージとネットワークのどちらから取得しているかに関係なく 700ミリ秒以上かかっていることを発見しました.
このアプリのような大量のデータドリブンなアプリでのAndroid 標準の Gson 処理パフォーマンスがどれだけ貧弱であるかを知った時驚きました. 起動時のトレースにより「リフレクションタイプアダプター」のコールが原因であることが分かりました.

NYTimes - NimbleDroid | Automated, Comprehensive Performance Analysis for Every Build of Your App

Gsonからのリフレクションコールを最小限にしようとしましたが, 実行可能な方法は 苦労して独自でタイプアダプタを書くことだけでした. リフレクションを使わずかつ起動時間をかけずにシリアライズする技術をみつけることは長く無駄でした. モデルにコードを追加することしか選択肢が残っていなかったので, 簡単な「Gson with custon type adapter」に戻ることにしました.

TypeAdapter (Gson 2.3.1 API)

タイプアダプターを書くことで10倍のパースパフォーマンスの改善をすることができました.

開発者の負荷を最小限にするために以下の「Immutable library」を利用することにしました.

JSON serialization

これはコンパイル時にデータモデル用のカスタムタイプアダプターを提供してくれると同時に AutoValue 同様の不変性も提供してくれます.


@Value.Immutable
@Gson.TypeAdapters
public abstract class AppConfig {
  public abstract Optional<DeviceGroups> deviceGroups();
  public abstract Marketing marketing();
  public abstract List<OverrideCondition> overrides();
  public abstract LinkedHashMap<String, List<Integer>> imageCropMappings();
}

public AppConfigParser() {
  gson = new GsonBuilder()
    .registerTypeAdapterFactory(new GsonAdaptersAppConfig()) //auto generated adapter
    .create();
}

public void parse() {
  reader = new InputStreamReader(source.inputStream(), UTF_8);
  AppConfig appConfig = gson.fromJson(reader, AppConfig.class);
}

現在では, データフローは以下のようになっています:

バックグランドサービスが 起動時間のかかるネットワーククライアントである RxStore をコールして新しいデータをJSONでダウンロードします. これは, スケージュル, またはプッシュアラートから実行されます. その後, ディスクにJSONデータを流しこみます. 単一のオブジェクトとしてJSONを保存するのではなく, ストリーミングAPIを使います. なぜなら メモリー上での1M以上のインスタンス化を避けることができるからです.

UIがデータを必要とすれば、メモリ内のキャッシュ(guava)とディスクにバックアップされたデータストアから不変のデータを取得します。

CachesExplained - guava-libraries - Explanation for how to use Guava caches. - Guava: Google Core Libraries for Java 1.6+ - Google Project Hosting

ディスクにデータが存在しないとき, または最終保存したデータのフォーマットが変更したときのみネットワーククライアントを起動します. データは永続ストレージからUIに一方向で流れ, ネットワークから UI には決して流れません.
この一方向のデータフローは、データストアへの要求の99パーセントは、ディスク以外にアクセスする必要がないことを意味します。

私たちは、このようなディスクストレージのためのデータをシリアライズするFlatBuffersのような他の方法を模索していきますが、現在の結果に満足しています。リニューアル後は、ユーザーに 2秒未満でページの完全なビューを提供しています。

結論

Androidでは, リフレクションは 重大なパフォーマンス問題を引き起こすことがある (とくに大きいデータ・ドリブンなアプリ) . このことから, アプリ起動時に できるだけそれはさけるべきです.


こういう記事が日本でももっと増えるといいですね.

Improving Startup Time in the NYTimes Android App - The New York Times


関連ワード:  AndroidAndroidStudioおすすめアプリツール今さら聞けない評判開発