Androidアプリの起動時間を60%改善する開発

はじめに

本記事は、NewsPicks Advent Calendar 2022 の 12/22 公開分の記事になります。

こんにちは、Androidチームのアーキテクトのko2icです。 久しぶりの投稿でございます。

なぜ久々なのかというと、ここ数ヶ月Androidの起動時間を爆速にすべく、奮闘していたからです。

そしてなぜ、高速化が必要なのか。これは、ユーザ目線で考えると当たり前でやるべきなのですが、それ以外にもKPIにとてもいい影響が出るからです。 ちなみに以前の会社では起動高速化したことで、広告売上が(インターステシャル広告をなくしても)2倍になりました。全員にとっていいことしかありません。

ちなみにIOS版の途中経過として、以前、iOSエンジニアの森崎が投稿しているので、以下もご覧ください。

tech.uzabase.com

結果から発表

どーん

Android起動時間の推移

どーん

遅いコールドスタートアップの割合の推移

どーん

遅いウォームスタートアップの割合の推移

まだまだ、爆速とは言えないけど、どんどん早くなっています。
たまに1秒以内になる場合もあるし、類似のアプリ(点線)に比べても、めちゃくちゃ遅かったのが、むしろ速くなってます。

何をしたか

計測した」 です。当たり前ですが、これが一番大事。

めちゃくちゃ遅いのは体感でわかっていました。 それを遅い部分を突き止めて、直していきます。

ちなみにiOSでは効果が大きかった不要コード・リソースの見直しをしても弊社のAndroidアプリの場合は、起動時間の高速化にほとんど繋がりませんでした。
画像は、サイズの推移です。まだまだサイズが大きすぎて恥ずかしいけど、晒しちゃいます。これだけ減らしても早くなりませんでした。

アプリのダウンロードサイズ

計測方法

まずは、AndroidManifest.xml でリリースビルドでも計測できるようにします。

・・・
<application
・・・
>

    <profileable android:shell="true"/>

Android Studio で、Profile起動した時の設定をします。「Edit Configurations...」の「Profiling」タブで「Start This recording on startup」で、「CPU activity~」で、「Java/Koltin〜」を選択しておきます。

Android StudioのProfile設定

実機に繋ぎ、Build VariantsをReleaseにして、メニューの「Run」 -> 「Profile」で起動すると画像のようになります。

端末で画面が表示された後に「Stop」を押下すると次のグラフができます。これは、高速化対応前の画像です。

横が時間軸です。横に長い部分が時間がかかっている処理です。緑の箇所はアプリ内で実装した箇所です。オレンジがSDKの処理です。

左にあるmainを開くとより細かく見れるのでどこで遅いかわかります。緑の部分を見ていくと良いでしょう。

計測の結果わかったこと

計測をしてわかったことは以下でした。

  1. 起動時にActivityを複数呼んでいて、それが遅い
  2. 余計な処理がいっぱいあり、それらが積み重なって遅い
  3. 必要な情報をキャッシュとしてJson形式で保存していてデシリアライズで遅い
  4. 3のデータのキャッシュ更新頻度が高くAPIアクセスが必要

3については、そもそものアプリの作りとして一番問題になっている箇所でした。必ずあるべきデータのはずなのに、存在しないときがあり、でもコードがスパゲッティで修正ができない状態でした。
単純に必要な情報をJsonではなく、Roomなどでオブジェクトとして入れることも可能ですが、安定してデータが取れない状態のまま実装してもまともに動かないことが明白でした。また、Jsonで入れている理由はおそらくオブジェクトのプロパティが追加・変更が多いため、Roomなどに入れるとマイグレーションが大変になるからなのかなと想像しています。

解決方法

起動時のActivityを極力減らす

  1. 起動時にActivityを複数呼んでいて、それが遅い.
  2. 余計な処理がいっぱいあり、それらが積み重なって遅い

元々は、MainActivityの前に別のActivityが呼ばれていました。複雑な処理でしたが、紐解いていくとシンプルに実装できるし、MainActivityに統合できました。

また、Pushタップ時の処理、ディープリンクの処理は、MainActivity(BottomNavigationのある画面)に必ず通るようになっていました。MainActivityは一番デザイン的にも複雑で重い画面なのでこれは無駄です。
なので、それぞれ必要な画面に直接遷移させるようにし、戻るボタンを押下したときだけMainActivityを起動するようにしました。

これらの修正で、1秒弱ほど早くなりました。

これ、簡単に書いてますが、起動時の処理をほぼ全部書き換えていますので、相当大変です。元々がスパゲッティなので、仕様を把握するのも大変です。
でも、古いアプリを高速化をしようと思ったら、避けては通れないと思います。
残念ながら、昔ながらのスパゲッティーなアプリの場合に限って言うと、少し修正して、KPIに影響のあるような大きく改善ができるならとっくに誰かがやっていたはずなのです。高速化にも銀の弾丸はないと考えています。

情報のライフサイクルによってAPIを分割する

必要な情報をJson形式で保存していてデシリアライズで遅い.
データのキャッシュ更新頻度が高くAPIアクセスが必要

問題のひとつとして、必要と思われる情報量が多いという点がありました。APIで一気に取得し、それをアプリ内DBに保存していました。ここにメスを入れました。

この記事では、必要な情報 = セッションユーザ情報とします。
セッションユーザ情報には、値が変わるタイミングが違うものが多々ありました。
たとえば、「ユーザ名」と「フォロワー数」が同じオブジェクトにあるイメージです(実際とは違いますが、わかりやすさのために書いてます)。
「情報のライフサイクルが違う」と私は表現しますが、ユーザ名は一度入力すると値が変わることはほとんどないが、フォロワー数は誰かにフォローされると値が変わります。
フォロワー数が変わるたびにキャッシュを更新していては、キャッシュの意味がなくなります。

なので、情報のライフサイクルが近いもの同士を別々のAPIで取得できるようにAPIを分割しました。

そして、それぞれのデータが更新されたかどうかがわかるような軽量なAPIを用意して、それを最初に呼び出すようにします。

こんなJsonがAPIのレスポンスにあるイメージです。

{
  "userLatestUpdatedAt": "2022-05-07T14:26:53Z",
  "followUpdatedAt": "2022-06-20T07:09:49Z"
}

これをアプリ内で保存し、アプリ内の日付とAPI取得時の日付を比べて、APIが新しくなっていれば、ユーザ情報のAPIを呼び出す。なければ、アプリ内のキャッシュを使う。

また、APIを分割したことで、キャッシュとしてアプリ内に保存するデータは、プロパティの追加/削減されやすいデータはJsonで保存することで、マイグレーションの心配を避け、それ以外はRoomなどで取得することで余計なデシリアライズを避けることが可能になりました。

どう進めたか

設計するのは簡単です。これをビジネスを止めずに実装していくか。これが一番の問題でした。
セッションユーザ情報は、ほとんど全ての画面に関係する情報です。一気に修正すると工数がかかってしまいますし、デグレが心配です。
一気に修正してmasterブランチにドーンと入れるとコンフリクトだらけになり、現実的ではありません。ということで少しづつmasterブランチに入れつつ動くようにする必要があります。

新しい方式と古い方式でのキャッシュを同期させる

まずは、新しい方式では、セッションユーザ情報の取得先を一つにします。AppStateだけにします。それを保持するAppStateHolderクラスが、Repositoryからセッション情報を取得し、observeされるStateFlow/SharedFlowに入れたりします。
Repository内でキャッシュし、キャッシュから取得しているのかAPIからの取得なのかわからないようにします。これで、新しい方式にするために、それぞれの画面が意識するのは、AppStateHolderクラスを使うという単純な修正だけになります。 古い方式では、以前のまま使います。古い方式でもキャッシュしてるので、そこのキャッシュを変更する箇所でAppStateHolderクラスを使って、キャッシュを更新します。新しい方式でキャッシュを変えるときは、DIを使って依存性逆転の原則を使って更新します。新しいコードから古いコードを使うときに依存性逆転の原則を使うのですが、詳しくは、以下の記事をご覧ください。 tech.uzabase.com

Adaptorをかます

旧と新で二つのDTOがある状態になります。新しい方式にする場合に、AppStateHolderから取得するようにします。それぞれの画面で取得するプロパティを修正すると修正量が多くなるので、Adaptorを用意します。
Adaptorクラスを用意するのもいいのですが、単純な変換なので、旧 -> 新、新 -> 旧のDTOに変換するメソッドを用意しました。

これで、全部を一気に変えずに、1画面1画面を修正していくことができるようになりました。この段階のリリースではパフォーマンス改善はほぼされないのですが、こまめにmasterに入れることができるので健全な状態を保つことができます。

で、ある程度できたら、根本となる実装を入れ替えることで、高速化完了になります。

副次的効果

  • 起動時のパフォーマンスが安定することで、指標が正確に出るようになりました

    以下の画像は、MainActivityの遅いレンダリングの推移です。(ちなみにフリーズしたフレームも同様の推移になってました)

    高速化を完全にリリースするまでは安定しなく、よくわからない推移をしてました。高速化リリース後は安定するようになってます。
    これにより、問題のあるリリースをした場合に気付けるようになります。

  • パフォーマンスが悪いところがすぐわかるようになりました

    いままでは、遅すぎてわからなかったが、パフォーマンスに影響を与えている箇所がすぐにわかるようになりました。たとえば、リフレクションを使っているところがあり、そこが遅くなっていることに気づいたりしました。

  • データが変わったときにすぐに反映することが簡単にできるようになった

    法人用のNewsPicksがあるのですが、その場合に管理画面から情報を変更することができます。今までは、一度端末をKillしないと反映しなかったものが、次にアプリを開いた時に反映することが簡単にできるようになりました。

  • コードの見通しが良くなった

    これは、古いアプリの場合は、大きな変更をしないと起動時間を早くすることが難しくなるためなのですが、弊社の場合、技術的負債の大きな部分だったので、これを解決できたのはすごく大きな意味を持っています。
    今後、起動周りでバグが出てもすぐわかるようになるし、さらに改善していくことが可能になりました。

終わりに

まだまだ、KPI的な効果は出ていませんが、高速化することで、副次的効果も含めていいことが必ず起こります。 この記事が他のアプリでの高速化のヒントになれば幸いです。

最後にお知らせです。 NewsPicksではまだまだ改善を続けていきます。一緒にアプリを改善したい方、募集しています!

tech.newspicks.com

Page top