Android コールド スタート最適化の 3 つの小さなケース

著者: Zhuo Xiuwu K

バックグラウンド

アプリのコールド スタート時間を改善するには、通常のビジネス側での時間のかかるコードの最適化に加えて、起動時間をさらに短縮するために、純粋な技術テストでいくつかの最適化の検討を行う必要があります。今回はクラスのプリロードからスタートし、Retrofit、ARouterのさらなる最適化を行います。テスト データから判断すると、これらの最適化方法の利点は限られており、ミッドレンジ マシンでは合計 50 ミリ秒を超える利点は得られない可能性があります。ただし、コールド スタート シーンを最適化し、ユーザーにより良いエクスペリエンスを提供するには、収益性の最適化 どの方法も試してみる価値があります。

クラスのプリロード

クラスの完全なロード プロセスには、少なくともロード、リンク、初期化が含まれており、クラスのロードはプロセス内で 1 回だけトリガーされます。したがって、コールド スタート シナリオの場合、クラス ロード プロセスを非同期でロードできます。クラスの起動フェーズ中にメインスレッドでトリガーされるため、元のプロセスがメインスレッドでこのクラスにアクセスするときに、クラスのロードプロセスはトリガーされません。

ClassLoaderの実装をフックする

Android システムでは、クラスのロードは PathClassLoader を通じて実装されており、クラス ロードの親クラスの委任メカニズムに基づいて、フック PathClassLoader を通じてそのデフォルトの親を変更できます。

まず、PathClassLoader から継承した MonitorClassLoader を作成し、その内部で時間のかかるクラスのロードを記録します。

class MonitorClassLoader(
    dexPath: String,
    parent: ClassLoader, private val onlyMainThread: Boolean = false,
) : PathClassLoader(dexPath, parent) {

    val TAG = "MonitorClassLoader"

    override fun loadClass(name: String?, resolve: Boolean): Class<*> {
    val begin = SystemClock.elapsedRealtimeNanos()
    if (onlyMainThread && Looper.getMainLooper().thread!=Thread.currentThread()){
        return super.loadClass(name, resolve)
    }
    val clazz = super.loadClass(name, resolve)
    val end = SystemClock.elapsedRealtimeNanos()
    val cost = end - begin
    if (cost > 1000_000){
        Log.e(TAG, "加载 ${clazz} 耗时 ${(end - begin) / 1000} 微秒 ,线程ID ${Thread.currentThread().id}")
    } else {
        Log.d(TAG, "加载 ${clazz} 耗时 ${(end - begin) / 1000} 微秒 ,线程ID ${Thread.currentThread().id}")
    }
    return  clazz;

}
}

その後、アプリケーション接続フェーズでアプリケーション インスタンスの classLoader に対応する親ポインターを反映して置き換えることができます。

コアコードは次のとおりです。

    companion object {
        @JvmStatic
        fun hook(application: Application, onlyMainThread: Boolean = false) {
            val pathClassLoader = application.classLoader
            try {
                val monitorClassLoader = MonitorClassLoader("", pathClassLoader.parent, onlyMainThread)
                val pathListField = BaseDexClassLoader::class.java.getDeclaredField("pathList")
                pathListField.isAccessible = true
                val pathList = pathListField.get(pathClassLoader)
                pathListField.set(monitorClassLoader, pathList)

                val parentField = ClassLoader::class.java.getDeclaredField("parent")
                parentField.isAccessible = true
                parentField.set(pathClassLoader, monitorClassLoader)
            } catch (throwable: Throwable) {
                Log.e("hook", throwable.stackTraceToString())
            }
        }
    }

主なロジックは次のとおりです

  • リフレクションは元の pathClassLoader の pathList を取得します。
  • MonitorClassLoader を作成し、正しい pathList を設定するように反映します。
  • リフレクションは、MonitorClassLoader インスタンスを指す元の pathClassLoader の親を置き換えます。

このようにして、起動フェーズで読み込みクラスを取得します。

JVMTIに基づいて実装

Hook ClassLoader ソリューションの実装に加えて、JVMTI を介してクラス読み込み監視を実装することもできます。

ClassPrepare Callback を登録すると、各クラスの Prepare フェーズ中にコールバックをトリガーできます。

もちろん、このソリューションは Hook ClassLoader よりもはるかに面倒ですが、JVMTI に基づいて、他の多くのより強力な機能を実行できます。

クラスのプリロードの実装

現在、アプリケーションは通常マルチモジュールであるため、さまざまなビジネス モジュールによって継承できる抽象インターフェイスを設計し、さまざまなビジネス モジュールによってプリロードする必要があるクラスを定義できます。

/**
 * 资源预加载接口
 */
public interface PreloadDemander {
    /**
     * 配置所有需要预加载的类
     * @return
     */
    Class[] getPreloadClasses();
}

次に、起動フェーズ中にすべての Demander インスタンスを収集し、プリロードをトリガーします。

/**
 * 类预加载执行器
 */
object ClassPreloadExecutor {


    private val demanders = mutableListOf<PreloadDemander>()

    fun addDemander(classPreloadDemander: PreloadDemander) {
        demanders.add(classPreloadDemander)
    }

    /**
     * this method shouldn't run on main thread
     */
    @WorkerThread fun doPreload() {
        for (demander in localDemanders) {
            val classes = demander.preloadClasses
            classes.forEach {
                val classLoader = ClassPreloadExecutor::class.java.classLoader
                Class.forName(it.name, true, classLoader)
    			}
			}
    }
    
}

所得

最初のバージョンは約 90 のクラスで構成されています。端末モデルのテスト データは、これらのクラスのロードに約 30 ミリ秒の CPU 時間がかかることを示しています。異なるクラスのロード時間の違いは、主にクラスの複雑さによって生じます。継承システムやフィールド属性、数量など、および静的メンバー変数の即時初期化、静的コード ブロックの実行などの時間のかかるクラス初期化フェーズ。

プログラムの最適化を考える

現在のソリューション構成の特定のクラス リストは手動構成によるものですが、このソリューションの欠点は、クラスのリストを開発して保守する必要があり、バージョンの急速な反復変更の場合には保守コストが比較的高くなるということです。また、一部の大規模アプリでは、AB の実験条件が非常に多く、ユーザーごとにクラスの読み込みに違いが生じる可能性があります。

前のセクションでは、カスタム ClassLoader を使用すると、起動フェーズ中にメイン スレッドのクラス リストを手動で収集できることを紹介しました。このクラスがロードされていないことが判明した場合、端末が起動するたびにロードされたクラスを自動的に収集できますか?既存のリストにリストが追加され、次回の起動時にプリロードされます。もちろん、プリロード リストのサイズの制御、プリロード リストに追加されるクラスの最小時間のしきい値、削除戦略など、特定の戦略を詳細に設計する必要があります。

ServiceMethod の事前解析インジェクションのレトロフィット

バックグラウンド

Retrofit は現在最も一般的に使用されているネットワーク ライブラリ フレームワークであり、その注釈ベースのネットワーク リクエスト メソッドとアダプター設計モードにより、ネットワーク リクエストの呼び出しメソッドが大幅に簡素化されます。ただし、APT のようなメソッドを使用してコンパイル時にリクエスト コードを生成するのではなく、実行時の分析を使用します。

Retrofit.create(final Class service) 関数を呼び出すと、抽象インターフェイスの動的プロキシ インスタンスが生成されます。

インターフェイスのすべての関数呼び出しは、動的プロキシ オブジェクトの呼び出し関数に転送され、最後にloadServiceMethod(method).invokeを呼び出します。

loadServiceMethod関数では、関数アノテーション、パラメータアノテーション、パラメータの型、戻り値の型など、元の関数のさまざまなメタ情報を解析し、最終的にServiceMethodインスタンスを生成する必要があります。関数は実際にこれをトリガーし、生成された ServiceMethod 呼び出し関数を呼び出します。

ソース コードの実装から、ServiceMethod のインスタンスがキャッシュされており、各 Method が ServiceMethod に対応していることがわかります。

時間のかかるテスト

ここでは、単純なサービス メソッドをシミュレートし、archiveStat を呼び出して、最初の呼び出しとその後の呼び出しに時間がかかることを観察します。ここでの呼び出しはまだネットワーク リクエストをトリガーしておらず、Call オブジェクトを返していることに注意してください。

テスト結果によると、最初の呼び出しがトリガーされるまでに 1.7 ミリ秒かかりますが、後続の呼び出しには約 50 マイクロ秒しかかかりません。

最適化

インターフェイス関数への最初の呼び出しで ServiceMethod インスタンスの生成をトリガーする必要があるため、このプロセスには時間がかかるため、最適化のアイデアは比較的単純です。起動フェーズ中に呼び出される関数を収集し、事前に ServiceMethod インスタンスを生成します。そしてそれらをキャッシュに書き込みます。

serviceMethodCache 自体の型は ConcurrentHashMap であるため、同時実行は安全です。

ただし、ソースコード内でServiceMethodキャッシュを判定する場合、serviceMethodCacheがロック用のLockオブジェクトとして使用されるため、複数のスレッドが同時にトリガーされた場合に、異なるメソッド呼び出しがトリガーされた場合にロック待ちが発生するという問題が発生します。初めて。

まず第一に、なぜここでロックする必要があるのか​​を理解する必要があります。その目的は、parseAnnotations が優れた操作であるためでもあります。ここでは、putIfAbsent のような完全にアトミックな操作を実現することです。しかし実際には、異なる Method に対応する ServiceMethod インスタンスは異なるため、ここでのロックは対応する Method タイプをロック オブジェクトとして使用できます。このシナリオでのロックの競合を避けるために、ソース コードの実装を変更できます。

もちろん、この最適化シナリオでは、ServiceMethod.parseAnnotations はロックフリーであり、結局のところ純粋な関数であるため、ソース コードを変更せずに実際に実現できます。したがって、非同期スレッドで parseAnnotations を呼び出して ServiceMethod インスタンスを生成し、それをリフレクションを通じて Retrofit インスタンスの serviceMethodCache に書き込むことができます。これの問題は、異なるスレッドがメソッド解析インジェクションを同時にトリガーする可能性があることですが、serviceMethodCache 自体はスレッドセーフであるため、もう 1 回解析を行うだけで、最終結果には影響しません。

ServiceMethod.parseAnnotations はパッケージ レベルのプライベートであるため、現在のプロジェクトに同じパッケージを作成して、この関数を直接呼び出すことができます。コアの実装コードは次のとおりです

package retrofit2

import android.os.Build
import timber.log.Timber
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.lang.reflect.Modifier

object RetrofitPreloadUtil {
    private var loadServiceMethod: Method? = null
    var initSuccess: Boolean = false
    //    private var serviceMethodCacheField:Map<Method,ServiceMethod<Any>>?=null
    private var serviceMethodCacheField: Field? = null

    init {
        try {
            serviceMethodCacheField = Retrofit::class.java.getDeclaredField("serviceMethodCache")
            serviceMethodCacheField?.isAccessible = true
            if (serviceMethodCacheField == null) {
                for (declaredField in Retrofit::class.java.declaredFields) {
                    if (Map::class.java.isAssignableFrom(declaredField.type)) {
                        declaredField.isAccessible =true
                        serviceMethodCacheField = declaredField
                        break
                    }
                }
            }
            loadServiceMethod = Retrofit::class.java.getDeclaredMethod("loadServiceMethod", Method::class.java)
            loadServiceMethod?.isAccessible = true
        } catch (e: Exception) {
            initSuccess = false
        }
    }

    /**
     * 预加载 目标service 的 相关函数,并注入到对应retrofit实例中
     */
    fun preloadClassMethods(retrofit: Retrofit, service: Class<*>, methodNames: Array<String>) {
        val field = serviceMethodCacheField ?: return
        val map = field.get(retrofit) as MutableMap<Method,ServiceMethod<Any>>

        for (declaredMethod in service.declaredMethods) {
            if (!isDefaultMethod(declaredMethod) && !Modifier.isStatic(declaredMethod.modifiers)
                && methodNames.contains(declaredMethod.name)) {
                try {
                    val parsedMethod = ServiceMethod.parseAnnotations<Any>(retrofit, declaredMethod) as ServiceMethod<Any>
                    map[declaredMethod] =parsedMethod
                } catch (e: Exception) {
                    Timber.e(e, "load method $declaredMethod for class $service failed")
                }
            }
        }

    }

    private fun isDefaultMethod(method: Method): Boolean {
        return Build.VERSION.SDK_INT >= 24 && method.isDefault;
    }

}

プリロードリストコレクション

最適化計画が実施された後は、起動フェーズ中にメインスレッドで行われる Retrofit ServiceMethod 呼び出しのリストを収集する必要もあります。ここでは、バイトコード インストルメンテーションの方法が採用され、LancetX フレームワークが使用されます。変形。

現在、リストの構成は事前に収集され、構成センターで構成され、実行時に構成に書き込まれた構成に従ってプリロードされます。Retrofit 関数を事前に解析する必要があることをマークする注釈を提供するなど、他の構成スキームもここで提供できます。

その後、コンパイル時にプリロードする必要があるすべてのサービスと関数を収集し、対応するリストを生成しますが、この解決策には一定の開発コストが必要であり、ビジネス モジュールのコードを修正する必要があります。メリットを検証中のため、まだ実装されていません。

所得

アプリは起動フェーズでプリロードのために約 20 のメソッドを収集しますが、これは 10 ~ 20 ミリ秒増加することが予想されます。

ARルーター

バックグラウンド

ARouter フレームワークは、ルーティング登録ジャンプと SPI 機能を提供します。コールド スタート速度を最適化するために、一部のサービス インスタンスを起動フェーズ中にプリロードして、対応するインスタンス オブジェクトを生成できます。

ARouter の登録情報はプリコンパイル段階で (APT に基づいて) 生成され、コンパイル段階ではマッピング関係に対応するインジェクション コードが ASM を通じて生成されます。

実行時に Service インスタンスを取得する場合を例に挙げると、インスタンスを取得するためにナビゲーション関数が呼び出されるとき、最終的には完了関数が呼び出されます。

初めて呼び出されたときは、対応する RouteMeta インスタンスが生成されていないため、登録するために addRouteGroupDynamic 関数が呼び出されます。

addRouteGroupDynamic は、プリコンパイル段階で生成された対応するサービス登録クラスを作成し、loadInto 関数を呼び出して登録します。ただし、一部のビジネス モジュールがより多くの登録情報を提供するため、ここでのloadIntoにはさらに時間がかかります。

Service インスタンスの取得プロセスは、loadInto 情報の登録、Service インスタンスの反映生成、init 関数の呼び出しが完了するまでのプロセス全体となります。補完機能は同期しているため、マルチスレッド登録による起動時間の短縮はできません。

最適化

ここでの最適化は実際には Retroift Service の登録メカニズムと似ており、異なる Service を登録する場合、実際には対応するメタ情報クラス (IRouteGroup) が異なるため、対応する IRouteGroup をロックするだけで済みます。

完了プロセスの後半では、init 関数が複数回呼び出されるのを避けるために、Provider インスタンス用に生成されたプロセスも個別にロックする必要があります。

所得

オフラインで収集されたデータによると、20 個以上のプリロードされたサービス メソッドが構成されており、期待されるリターンは 10 ~ 20 ミリ秒です (ミッドレンジ マシンの場合)。

誰もがパフォーマンスの最適化を包括的かつ明確に理解できるように、関連する学習ルートとコア ノート (基礎となるロジックに戻る) を用意しました。https://qr18.cn/FVlo89

パフォーマンスの最適化に関するコアノート:https://qr18.cn/FVlo89

起動の最適化

メモリの最適化

UIの

最適化 ネットワークの最適化

ビットマップの最適化と画像圧縮の最適化マルチスレッド同時実行の最適化とデータ伝送効率の最適化ボリュームパッケージの最適化https://qr18.cn/FVlo89




「Android パフォーマンス監視フレームワーク」:https://qr18.cn/FVlo89

「Androidフレームワーク学習マニュアル」:https://qr18.cn/AQpN4J

  1. ブート初期化プロセス
  2. 起動時に Zygote プロセスを開始する
  3. 起動時に SystemServer プロセスを開始する
  4. バインダードライバー
  5. AMS起動プロセス
  6. PMSの起動プロセス
  7. ランチャーの起動プロセス
  8. Android の 4 つの主要コンポーネント
  9. Androidシステムサービス - 入力イベントの配信処理
  10. Android の基盤となるレンダリング画面更新メカニズムのソース コード分析
  11. Android のソースコード解析の実践

おすすめ

転載: blog.csdn.net/weixin_61845324/article/details/131435954