クラシルアプリの起動時間最適化
はじめに
クラシルは、レシピアプリのリーディングカンパニーとして「80億人に1日3回の幸せを届ける」ことを目指しています。そして、この目標を達成するためには、アプリのユーザー体験が非常に重要です。ユーザー体験をさらに向上させるために、本記事で記述している様々な方法を通じて、クラシル iOSアプリの起動時間を90パーセンタイルの統計に基づき、40%短縮することに成功しました。この最適化は、ユーザーの体験を向上させるだけでなく、長期的にはアプリ全体の指標に大きな改善をもたらすでしょう。
なぜ起動の最適化が重要なのか?
起動とは
起動とは、ユーザーがアプリのアイコンをクリックしてから、アプリの最初のフレームが表示されるまでの時間を指します。このプロセスはユーザーの第一印象に直接影響します。起動時間の最適化は、単に数百ミリ秒の待機時間を短縮するだけでなく、ユーザーの全体的な体験やアプリの成功にも影響を与えます。
起動速度を改善するメリット
ユーザーのデバイスには通常、同種のアプリが複数インストールされており、どのアプリを使用するか選ぶ際には、内容がニーズに合っているかどうかに加えて、パフォーマンスも重要な要素です。もしアプリの起動時間が長すぎたり、明らかにカクついたりする場合、ユーザーは競合他社のアプリを使用する傾向があります。
研究によると、起動時間が2秒を超えると、ユーザーの離脱率が著しく増加することが示されています。現代のユーザーはアプリの応答速度に非常に高い期待を持っています。ref: https://www.instabug.com/blog/performance-monitoring-and-user-experience-for-mobile-app-growth
起動の種類
iOSにおける起動プロセスは、以下の3つの種類に分けられます:
- Cold Start:コールドスタートは、アプリのどの部分もメモリに常駐していないときに発生し、起動サイクルの中で最も時間がかかるものです。iOSはアプリのすべてのリソース、ライブラリ、サービスを最初から読み込む必要があります。このプロセスは、ユーザーがアプリを初めて開くときやデバイスが再起動した後に発生します。
- Warm Start:ウォームスタートは、iOSがキャッシュを利用してアプリを起動させる仕組みです。システムは、ユーザーがアプリを再度起動する際に、いくつかの一般的な動的ライブラリやサービスをキャッシュし、読み込み時間を短縮します。この時、アプリのプロセスは存在しませんが、キャッシュがあるため、起動時間は著しく短縮されます。
- Resume(バックグラウンドからの復帰):ユーザーがバックグラウンドのアプリから前面に戻ると、アプリはバックグラウンドからアクティブな状態に復帰します。プロセスがまだ存在するため、復帰時間は最も短くなります。
起動最適化の最終目標
起動の種類に応じて異なる最適化目標を設定する必要があります。ウォームスタートは最も現れる起動プロセスであり、ウォームスタートを再現するための要件は比較的安定しています(同じデバイスの状態で、各ウォームスタート間の数値は相対的に安定しています)。私たちが設定した目標は、ウォームスタートを400ms以内にすることです。これは理想的な最適化目標であり、この時間はシステムがLaunch Screenまたは起動アニメーションを表示する時間と一致します。理論的には、この時間枠内で初期化作業をすべて完了できれば、ユーザーは起動の遅延を感じることはありません。
経験の共有:複数のアプリのパフォーマンス最適化において、起動時間の短縮は通常、ユーザーの保持率や使用頻度の向上をもたらします。ref: https://studiomosaicapps.com/2023/12/13/from-launch-to-loyalty-optimizing-your-apps-load-time-for-success/
起動の各段階
iOSの起動プロセスは複数の段階に分かれており、各段階の時間が最終的な起動時間に影響を与えます。特に初期フレームのレンダリング前の段階の時間を圧縮する必要があります。
起動の主な段階は以下の通りです:
- dyldの読み込み:dyldは動的リンカーであり、アプリに必要な動的ライブラリを読み込む役割を担っています。これは起動プロセスの重要なボトルネックであり、特にコールドスタート時に顕著です。
- ランタイムの初期化:この段階では、iOSのランタイム環境がObjective-CとSwiftのランタイム環境を初期化し、アプリに必要なオブジェクトやクラスを読み込みます。
- UIKitの初期化:ランタイムの読み込みが完了すると、システムはUIKitの読み込みを開始します。UIKitのさまざまなレンダリング用の型がここで事前処理される可能性があります。
- アプリの初期化:アプリのビジネスロジックが実行され始め、UIApplicationインスタンスの初期化、設定ファイルの読み込み、AppDelegateのライフサイクルメソッドの実行などが行われます。通常、これは私たちのコードが起動時間に影響を与える最初のタイミングです。
- 初期フレームのレンダリング:GPUにより、提出されたトランザクションがレンダリングされ、ユーザーはレンダリングが完了した後に画面上でUIを見ることができます。
私たちの最適化目標は、初期フレームのレンダリング前のすべての段階を圧縮し、ユーザーができるだけ早く初期画面を見られるようにすることです。
必要なツール —— Instrumentsの使用
起動時間の最適化において、Instrumentsは開発者にとって最も重要なツールの一つです。これを使用することで、アプリの起動プロセスの各段階で何が起こっているのかを詳細に理解し、起動時間に影響を与えるボトルネックを正確に特定できます。InstrumentsはCPU、メモリ、スレッドなどのさまざまなパフォーマンス分析を提供し、起動最適化に最も役立つテンプレートはApp Launchテンプレートです。
InstrumentsのApp Launchテンプレート
App Launchテンプレートは、アプリの起動パフォーマンスを分析するために特別に設計されたツールであり、起動プロセス全体の包括的なビューを提供し、開発者が各段階の実行状況を明確に把握できるようにします。
使用手順
- Instrumentsを起動し、App Launchテンプレートを選択:
- Xcodeを開き、メニューからXcode -> Open Developer Tool -> Instrumentsを選択します。
- Instruments内で、App Launchテンプレートを選択します。これは起動最適化に最も適したツールであり、アプリ起動時のdyldによる動的ライブラリの読み込み活動を追跡し、メインスレッドや他の重要なスレッドの動作を確認します。
- 実際のデバイスを選択:
- 実際のデバイスを使用することが非常に重要です。シミュレーターの起動動作は実際のデバイスとは大きく異なり、シミュレーターはシンボル化された呼び出しスタック情報を提供できないため、詳細な呼び出しスタックを見ることができません。したがって、必ず実際のデバイスでパフォーマンス分析を行ってください。
- 起動プロセスを録画:
-
左上の赤い録画ボタンをクリックし、実際のデバイスでアプリを起動します。Instrumentsはアプリの起動プロセスを録画し始め、デフォルトの録画時間は20秒です。停止ボタンをクリックすることで手動で録画を終了できます。
-
録画が完了すると、Instrumentsは起動中の詳細なデータビューを自動的に生成します。
起動データの分析
録画が完了すると、アプリ起動時の各重要な段階を示す非常に詳細なインターフェースが表示されます。以下は重要な分析ポイントです:
- dyldの活動:
-
dyldはiOSの動的リンカーであり、起動プロセス中にアプリに必要な動的ライブラリを読み込む役割を担っています。この段階はコールドスタート中に最も時間がかかる部分であり、特にアプリが多くの動的ライブラリに依存している場合、dyldの読み込み時間は著しく増加します。
-
Instruments内では、dyldの活動は上部のdyld Activityエリアに表示されます。各動的ライブラリの読み込み時間を明確に確認でき、これらのデータに基づいて最適化が必要かどうかを判断できます。例えば、動的ライブラリが多すぎると起動時間が遅くなるため、ライブラリの数を減らすか、mergeable library技術を使用することを検討できます。
- メインスレッドの活動:
-
メインスレッドの活動は起動プロセスのもう一つの重要なポイントであり、メインスレッドをブロックする操作は起動時間に著しく影響を与えます。Instruments内で、アプリ名に対応する部分を展開し、Main Threadを見つけてクリックすると、起動の各時間点でのメインスレッドの活動状況を確認できます。
-
特に注意が必要なのは灰色の領域で、これはメインスレッドがブロックされていることを示しています。通常、これは何らかの操作が完了するのを待っているとき(I/O操作やネットワークリクエストなど)に発生します。メインスレッドが長時間ブロックされると起動時間が延びるため、これらの待機時間を注意深く分析し、最適化できるポイントを見つける必要があります。
最適化の提案:メインスレッドでの時間のかかるタスクの実行をできるだけ避け、リソースの読み込みやデータベースのクエリなどはGCDのDispatchQueue.global()やOperationQueue、Taskを使用してバックグラウンドスレッドで処理することができます。
- コールツリー:
- 下部左側のCall Treeエリアでは、各スレッドの呼び出しスタックを展開し、起動プロセス中の具体的な関数呼び出しを見ることができます。この情報は、各関数呼び出しの具体的な所用時間を知るのに役立ちます。特に多くの時間を消費する呼び出しを見つけることができます。
-
Call Tree設定の提案:
-
Separated by Threadをチェック:呼び出しスタックをスレッドごとに分離し、メインスレッドと他のスレッドの操作をより簡単に識別できるようにします。
-
Invert Call Treeをチェック:最も時間がかかる呼び出しを最上部に表示し、起動プロセス中のパフォーマンスボトルネックを一目で見つけやすくします。
-
Hide System Librariesをチェック:システムライブラリの呼び出しスタックを隠し、アプリ自身のコードの分析に集中できるようにします。
-
- 下部右側のエリア:
-
このエリアには、選択した時間帯内で最も時間を消費した呼び出しスタックが表示されます。これらの呼び出しスタックのコンテキストを観察することで、起動プロセス中に不必要な操作が多くの時間を占めているかどうかを判断できます。
-
例えば、特定のサードパーティライブラリが起動時に大量のオブジェクトを初期化しているために起動時間が増加している場合、これらの初期化操作を分析し、アプリが実際に使用される時に実行するように遅延させることを検討できます。
最適化効果の検証
起動最適化が完了した後、効果を検証することが非常に重要です。以下のツールを使用して検証できます:
-
Xcode Organizer Metrics:これは実際のユーザーデバイス上の起動データを提供します。ユーザーが分析データの共有に同意した場合、Metricsは大規模なユーザーの起動時間を確認するのに役立ちます。
-
Firebase Performance:Firebase Performanceはアプリのパフォーマンスを監視するための補助ツールとして使用でき、Metricsとは若干異なりますが、複数のチャネルから最適化効果を検証できます。
具体的な最適化プロセス
次に、起動プロセスの各段階に対する具体的な最適化案を示します:
dyldの最適化
動的ライブラリと静的ライブラリの違い
動的ライブラリはアプリ起動時に読み込まれ、静的ライブラリはコンパイル時に実行可能ファイルに直接リンクされます。各動的ライブラリはdyldによって読み込まれる必要があるため、動的ライブラリが多すぎると起動時間が著しく増加します。
Appleの推奨:Appleは、アプリが使用する動的ライブラリの数は6個を超えないようにすることを推奨しています。この数を超えると、起動時間が増加します。ref: https://bpoplauschi.github.io/2021/10/25/Advanced-static-vs-dynamic-libraries-and-frameworks.html
最適化のテクニック:
-
動的ライブラリの数を減らす:動的ライブラリを少なくすることで、起動時間を大幅に短縮できます。複数の動的ライブラリを統合するか、できるだけ静的ライブラリを使用することができます。
-
mergeable library技術:Xcode 15から導入されたマージ可能ライブラリ技術(mergeable library)を使用すると、開発者は複数の動的ライブラリを1つに統合し、リリース時には静的ライブラリの特性を持たせて起動時間を短縮できます。デバッグ中は動的ライブラリの特性を持たせてコンパイル時間を短縮します。 (クラシルの動的ライブラリの数はまだ許容範囲内なので、今回の最適化ではこの技術を使用しませんでした。)
-
動的ライブラリの遅延読み込みを避ける:遅延読み込み(例えば、
dlopen
を使用する)は、起動時のメモリ使用量を減らすのに役立つように見えるかもしれませんが、dyld3がキャッシュを十分に活用できず、起動時間を最適化できなくなるため、できるだけ遅延読み込み技術を避けるべきです。
ランタイム初期化の最適化
Objective-Cのloadメソッド
- Objective-Cでは、
+load
メソッドはクラスが読み込まれるときに実行されます。+load
に過剰な初期化ロジックを置くと、起動時間が著しく延びます。最適化方法は、不要な初期化ロジックを実際の使用時に実行するように遅延させることです。
経験の共有:あるサードパーティライブラリが
+load
メソッド内で実行したメソッドスウィズリングが150msを超えるブロックを引き起こした事例がありました。このような問題は遅延初期化によって解決できます。特にサードパーティライブラリはこの問題を避けるべきです。
Swiftの最適化
- Swiftでは、大量のグローバル変数の初期化ロジックを避けるべきです。そうでないと、ランタイム初期化時に実行されてしまいます。
アプリケーション初期化の最適化
AppDelegate内に過剰なコードを避ける
-
didFinishLaunchingWithOptions
はアプリ起動時の主要なコールバックであり、多くの開発者はここでさまざまな初期化操作を実行することが一般的です。例えば、サードパーティライブラリの初期化やネットワークリクエストなどです。しかし、これらの操作はメインスレッドをブロックし、起動時間を延ばす可能性があります。最適化提案:メインスレッドに関与しないタスクをバックグラウンドスレッドに移動させることです。例えば、
DispatchQueue.global()
を使用して非同期タスクを実行したり、OperationQueue
を使用して複数のバックグラウンドタスクを管理したり、Task
を使用することができます。didFinishLaunchingWithOptions
内で実行されるすべてのタスクを徹底的に管理し、専用のマネージャーによって管理することが理想的な管理方法です。各タスクには対応する優先度があり、マネージャーが優先度に基づいてタスクの実行順序を割り当てます。 -
UIの早すぎる初期化:
didFinishLaunchingWithOptions
内でRootWindow
をカスタム作成する場合、RootWindow
の初期化時に非初期画面レンダリングに必要なUIクラスオブジェクトが必要以上に早く読み込まれる可能性があります。もしこれらのUIクラスに初期化時に作成する必要があるサブUI要素が存在する場合、不必要なパフォーマンス損失を引き起こす可能性があります。過剰なUIコンポーネントの読み込みを避け、アプリ起動時に複雑なUIロジックを早すぎるタイミングで導入しないようにするべきです。ViewControllerに関しては、可能な限りlazy var
を使用してUI要素をマークし、viewDidLoad
後にのみ実際に読み込まれるようにするべきです。いくつかの例:
-
Lottieアニメーションの読み込み遅延:Lottieアニメーションを使用したコンポーネントが
lazy
修飾子を使用していなかったため、そのコンポーネントがVC作成時に自動的に読み込まれました。不幸なことに、そのVCはRootWindow内にありますが、ユーザーの初期画面に表示されないUIでした。そのため、この読み込みは無駄になってしまい、余計なカクつきも招いていました。
LottieはAirbnb社が開発した、JSONを使用してアニメーションをレンダリングするオープンソースのアニメーションライブラリです。JSONは通常大きいため、アニメーションファイルを初めて読み込む際に、I/Oと解析のためにカクつきが発生する可能性があります。
- フォントの読み込み問題:特定のUILabelがAttributedStringを使用し、カスタムフォントを使用しているため、フォントライブラリを読み込む必要がありました。しかし、分析の結果、そのUILabelもlazyを使用せずにVCの初期化段階で過早に作成されていたため、不必要なパフォーマンス損失が発生しました。
-
Lottieアニメーションの読み込み遅延:Lottieアニメーションを使用したコンポーネントが
まとめ
起動の最適化は長期的かつ継続的なプロセスであり、継続的な監視と反復を通じて、今後の各バージョンで高性能な起動体験を維持することができます。
経験の共有:継続的な最適化の鍵は、各段階のパフォーマンスボトルネックを分析し、アプリの異なるバージョンで一貫した最適化目標を維持することです。
ref:
Discussion