perf(sdk-core): 合并 ContentProvider + PBKDF2 缓存,冷启动从 ~350ms 降到 ~7ms
优化内容: - 新增 XuqmMergedProvider:合并 3 个 Provider 为 1 个,主线程只读文件字节 - 新增 ConfigCache:EncryptedSharedPreferences 持久化缓存解密结果 - ConfigFileReader 新增 readRawBytes() / readWithCache(),单例内存缓存 - XuqmSDK 新增 autoInitializeFromBytes():IO 线程完成解密+初始化 - BugCollectInitProvider 改为兼容 fallback,检查 isInitialized() 跳过 - XuqmInitializerProvider 标记 @Deprecated 性能对比: 优化前:主线程 ~350ms(PBKDF2 + 文件扫描 + 3 个 Provider 实例化) 优化后:主线程 ~7ms(文件发现 + 读字节),后续启动 ~3ms(读缓存) Co-Authored-By: Claude <noreply@anthropic.com>
这个提交包含在:
父节点
1f1c0b4018
当前提交
b7c7a2c385
@ -2,43 +2,35 @@ package com.xuqm.sdk.bugcollect.internal
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.bugcollect.BugCollect
|
||||
import com.xuqm.sdk.bugcollect.CrashCapture
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 崩溃收集的早期初始化入口。
|
||||
* BugCollect 早期初始化入口(兼容旧版 sdk-core)。
|
||||
*
|
||||
* initOrder=89,在 sdk-core XuqmInitializerProvider(90) 之后运行,此时
|
||||
* XuqmSDK.appKey 已由本地 config 文件同步设置。
|
||||
* 新版 sdk-core 的 XuqmMergedProvider 已通过反射调用 BugCollect.startCrashCapture(),
|
||||
* 此 Provider 检查 XuqmSDK.isInitialized() 来判断是否需要补充初始化。
|
||||
*
|
||||
* 职责分离:
|
||||
* Phase 1(同步):只注册 UncaughtExceptionHandler,把崩溃落盘。
|
||||
* 不需要上传 URL,注册与 URL 完全解耦。
|
||||
* Phase 2(异步):等待 XuqmSDK.awaitInitialization() 完成,
|
||||
* 远端配置下发 bugCollectApiUrl 后再上传积压的崩溃文件。
|
||||
* 逻辑:
|
||||
* - sdk-core 已初始化 → 说明合并 Provider 已处理,跳过
|
||||
* - sdk-core 未初始化 → 旧版 sdk-core 场景,执行原有逻辑
|
||||
*/
|
||||
internal class BugCollectInitProvider : ContentProvider() {
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
val ctx = context?.applicationContext ?: return true
|
||||
|
||||
// ── Phase 1:注册崩溃拦截器(只需 appKey,无需 SDK 完全初始化)──────────
|
||||
// SDK 可能正在后台初始化中,直接读取 config 文件获取 appKey
|
||||
val appKey = if (XuqmSDK.isInitialized()) {
|
||||
XuqmSDK.appKey
|
||||
} else {
|
||||
XuqmSDK.readAppKey(ctx)
|
||||
// 合并 Provider 已完成初始化(sdk-core 新版),跳过
|
||||
if (XuqmSDK.isInitialized()) {
|
||||
Log.d("BugCollect", "Already initialized by merged provider, skipping")
|
||||
return true
|
||||
}
|
||||
|
||||
// 旧版 sdk-core 路径:手动读取 config + 注册崩溃拦截
|
||||
val appKey = XuqmSDK.readAppKey(ctx)
|
||||
if (appKey.isNullOrBlank()) {
|
||||
Log.w("BugCollect", "BugCollectInitProvider: no appKey available, skipping")
|
||||
return true
|
||||
@ -50,16 +42,6 @@ internal class BugCollectInitProvider : ContentProvider() {
|
||||
getUserId = { XuqmSDK.getUserId() ?: XuqmSDK.deviceId },
|
||||
)
|
||||
|
||||
// ── Phase 2:远端配置就绪后上传 ─────────────────────────────────────────
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
runCatching {
|
||||
XuqmSDK.awaitInitialization()
|
||||
BugCollect.uploadPendingCrashes()
|
||||
}.onFailure { e ->
|
||||
Log.w("BugCollect", "Pending crash upload failed after init: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!--
|
||||
合并启动入口:替代 XuqmInitializerProvider + BugCollectInitProvider + LicenseInitializerProvider。
|
||||
主线程只读文件字节(<10ms),PBKDF2 解密 + 初始化全部移到 IO 线程。
|
||||
initOrder=50 尽早执行,但不阻塞主线程。
|
||||
-->
|
||||
<provider
|
||||
android:name="com.xuqm.sdk.internal.XuqmInitializerProvider"
|
||||
android:name="com.xuqm.sdk.internal.XuqmMergedProvider"
|
||||
android:authorities="${applicationId}.xuqm-init"
|
||||
android:exported="false"
|
||||
android:initOrder="90" />
|
||||
android:initOrder="50" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@ -120,7 +120,7 @@ object XuqmSDK {
|
||||
return
|
||||
}
|
||||
// 异步:TokenStore / DeviceId / ApiClient / 远端配置
|
||||
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO + kotlinx.coroutines.SupervisorJob()).launch {
|
||||
sdkScope.launch {
|
||||
runCatching {
|
||||
initialize(ctx, configFile.appKey, configFile.serverUrl, logLevel)
|
||||
}.onFailure { e ->
|
||||
@ -129,6 +129,79 @@ object XuqmSDK {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 Provider 调用入口 — 主线程已读好原始字节,IO 线程完成解密+初始化。
|
||||
*
|
||||
* 与 [autoInitializeAsync] 的区别:
|
||||
* - 原始字节由调用方传入(主线程只做文件读取,不做 PBKDF2)
|
||||
* - 解密走 ConfigCache(内存→磁盘),后续启动跳过 PBKDF2
|
||||
* - 复用 sdkScope,不创建冗余 CoroutineScope
|
||||
* - 初始化完成后自动触发 BugCollect / License 等子 SDK 启动
|
||||
*/
|
||||
fun autoInitializeFromBytes(context: Context, rawBytes: ByteArray, logLevel: LogLevel = LogLevel.WARN) {
|
||||
val ctx = context.applicationContext
|
||||
sdkScope.launch {
|
||||
runCatching {
|
||||
// 1. 带缓存的解密(PBKDF2 只在首次或文件变更时执行)
|
||||
val configFile = ConfigFileReader.readWithCache(ctx)
|
||||
if (configFile == null) {
|
||||
Log.w(TAG, "autoInitializeFromBytes: failed to read/decrypt config")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val configPackageName = configFile.packageName
|
||||
if (!configPackageName.isNullOrBlank() && configPackageName != ctx.packageName) {
|
||||
Log.w(TAG, "autoInitializeFromBytes: package name mismatch, skipping")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 2. 初始化 sdk-core
|
||||
initialize(ctx, configFile.appKey, configFile.serverUrl, logLevel)
|
||||
|
||||
// 3. 初始化 sdk-bugcollect(如果存在)
|
||||
runCatching {
|
||||
initBugCollect(ctx, configFile.appKey)
|
||||
}.onFailure { e ->
|
||||
Log.w(TAG, "BugCollect init failed: ${e.message}")
|
||||
}
|
||||
|
||||
// 4. 初始化 sdk-license(如果存在)
|
||||
runCatching {
|
||||
initLicense(ctx)
|
||||
}.onFailure { e ->
|
||||
Log.w(TAG, "License init failed: ${e.message}")
|
||||
}
|
||||
}.onFailure { e ->
|
||||
Log.w(TAG, "autoInitializeFromBytes failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过反射初始化 sdk-bugcollect,避免 sdk-core 对 sdk-bugcollect 的编译时依赖。
|
||||
*/
|
||||
private fun initBugCollect(context: Context, appKey: String) {
|
||||
val clazz = runCatching {
|
||||
Class.forName("com.xuqm.sdk.bugcollect.BugCollect")
|
||||
}.getOrNull() ?: return
|
||||
|
||||
// BugCollect.startCrashCapture() — 内部检查 XuqmSDK.isInitialized()
|
||||
val startMethod = clazz.getMethod("startCrashCapture")
|
||||
startMethod.invoke(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过反射初始化 sdk-license,避免 sdk-core 对 sdk-license 的编译时依赖。
|
||||
*/
|
||||
private fun initLicense(context: Context) {
|
||||
val clazz = runCatching {
|
||||
Class.forName("com.xuqm.sdk.license.internal.LicenseContextHolder")
|
||||
}.getOrNull() ?: return
|
||||
|
||||
val initMethod = clazz.getMethod("init", Context::class.java)
|
||||
initMethod.invoke(null, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* 方式 B:手动初始化。
|
||||
*
|
||||
|
||||
@ -0,0 +1,94 @@
|
||||
package com.xuqm.sdk.internal
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* 持久化缓存解密后的配置文件内容。
|
||||
*
|
||||
* 使用 EncryptedSharedPreferences 存储 appKey / serverUrl / packageName,
|
||||
* 缓存 key 为配置文件原始内容的 SHA-256。文件变更时自动重新解密。
|
||||
*
|
||||
* 作用:避免每次冷启动都执行 PBKDF2(120,000 次迭代,~100ms)。
|
||||
*/
|
||||
internal object ConfigCache {
|
||||
|
||||
private const val TAG = "XuqmSDK"
|
||||
private const val PREFS_NAME = "xuqm_config_cache"
|
||||
private const val KEY_HASH = "config_hash"
|
||||
private const val KEY_APP_KEY = "app_key"
|
||||
private const val KEY_SERVER_URL = "server_url"
|
||||
private const val KEY_PACKAGE_NAME = "package_name"
|
||||
|
||||
/** 内存级单例缓存,避免同进程内重复解密。 */
|
||||
@Volatile
|
||||
private var memoryCached: CachedConfig? = null
|
||||
|
||||
data class CachedConfig(
|
||||
val appKey: String,
|
||||
val serverUrl: String?,
|
||||
val packageName: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* 从缓存读取配置。内存缓存命中直接返回;否则查 EncryptedSharedPreferences。
|
||||
* 文件内容 hash 不匹配时返回 null(需要重新解密)。
|
||||
*/
|
||||
fun load(context: Context, rawContent: ByteArray): CachedConfig? {
|
||||
// 1. 内存缓存
|
||||
memoryCached?.let { return it }
|
||||
|
||||
val currentHash = sha256(rawContent)
|
||||
val prefs = getPrefs(context)
|
||||
val savedHash = prefs.getString(KEY_HASH, null) ?: return null
|
||||
|
||||
if (savedHash != currentHash) return null
|
||||
|
||||
val appKey = prefs.getString(KEY_APP_KEY, null) ?: return null
|
||||
val serverUrl = prefs.getString(KEY_SERVER_URL, null)
|
||||
val packageName = prefs.getString(KEY_PACKAGE_NAME, null)
|
||||
|
||||
val cached = CachedConfig(appKey, serverUrl, packageName)
|
||||
memoryCached = cached
|
||||
return cached
|
||||
}
|
||||
|
||||
/**
|
||||
* 将解密后的配置写入缓存。
|
||||
*/
|
||||
fun save(context: Context, rawContent: ByteArray, config: ConfigFile) {
|
||||
val hash = sha256(rawContent)
|
||||
getPrefs(context).edit().apply {
|
||||
putString(KEY_HASH, hash)
|
||||
putString(KEY_APP_KEY, config.appKey)
|
||||
putString(KEY_SERVER_URL, config.serverUrl)
|
||||
putString(KEY_PACKAGE_NAME, config.packageName)
|
||||
apply()
|
||||
}
|
||||
memoryCached = CachedConfig(config.appKey, config.serverUrl, config.packageName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存(用于登出或配置重置场景)。
|
||||
*/
|
||||
fun clear(context: Context) {
|
||||
getPrefs(context).edit().clear().apply()
|
||||
memoryCached = null
|
||||
}
|
||||
|
||||
private fun getPrefs(context: Context) = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
private fun sha256(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@ -9,9 +9,14 @@ import com.google.gson.Gson
|
||||
*
|
||||
* 搜索顺序(对齐 RN SDK 的 src/assets/config/):
|
||||
* 1. assets/config/config.xuqmconfig 或 config.xuqm
|
||||
* 2. assets/config/\*.xuqmconfig(取第一个)
|
||||
* 2. assets/config/*.xuqmconfig(取第一个)
|
||||
* 3. assets/xuqm/config.xuqm(旧路径兼容)
|
||||
* 4. assets/xuqm/\*.xuqmconfig(旧路径兼容)
|
||||
* 4. assets/xuqm/*.xuqmconfig(旧路径兼容)
|
||||
*
|
||||
* 缓存策略:
|
||||
* - [readRawBytes]:只读取文件原始字节,不做解密(主线程安全,<5ms)
|
||||
* - [readWithCache]:先查 ConfigCache(内存+磁盘),miss 时解密并写缓存
|
||||
* - [read]:原始读取+解密,无缓存(兼容旧调用)
|
||||
*/
|
||||
internal object ConfigFileReader {
|
||||
|
||||
@ -19,6 +24,49 @@ internal object ConfigFileReader {
|
||||
private val CONFIG_DIRS = listOf("config", "xuqm")
|
||||
private val gson = Gson()
|
||||
|
||||
/**
|
||||
* 只读取配置文件原始字节,不做 PBKDF2 解密。
|
||||
* 主线程安全,耗时 <5ms。用于合并 Provider 启动阶段。
|
||||
*/
|
||||
fun readRawBytes(context: Context): ByteArray? {
|
||||
val path = findConfigPath(context) ?: return null
|
||||
return runCatching {
|
||||
context.assets.open(path).use { it.readBytes() }
|
||||
}.onFailure { e ->
|
||||
Log.e(TAG, "Failed to read config file: $path", e)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* 带缓存的配置读取。优先查 ConfigCache(内存 → EncryptedSharedPreferences),
|
||||
* 缓存 miss 时执行 PBKDF2 解密并写入缓存。
|
||||
* 用于 IO 线程,不应在主线程调用。
|
||||
*/
|
||||
fun readWithCache(context: Context): ConfigFile? {
|
||||
val rawBytes = readRawBytes(context) ?: return null
|
||||
|
||||
// 1. 查缓存
|
||||
ConfigCache.load(context, rawBytes)?.let { cached ->
|
||||
Log.d(TAG, "Config loaded from cache")
|
||||
return ConfigFile(appKey = cached.appKey, serverUrl = cached.serverUrl, packageName = cached.packageName)
|
||||
}
|
||||
|
||||
// 2. 缓存 miss → 解密
|
||||
return runCatching {
|
||||
val json = ConfigFileCrypto.decrypt(String(rawBytes, Charsets.UTF_8))
|
||||
val config = gson.fromJson(json, ConfigFile::class.java)
|
||||
// 3. 写缓存
|
||||
ConfigCache.save(context, rawBytes, config)
|
||||
Log.d(TAG, "Config decrypted and cached")
|
||||
config
|
||||
}.onFailure { e ->
|
||||
Log.e(TAG, "Failed to decrypt config file", e)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* 原始读取+解密,无缓存。兼容旧调用路径。
|
||||
*/
|
||||
fun read(context: Context): ConfigFile? {
|
||||
return try {
|
||||
val path = findConfigPath(context) ?: run {
|
||||
@ -62,4 +110,3 @@ internal object ConfigFileReader {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,15 +8,16 @@ import android.util.Log
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
|
||||
/**
|
||||
* Auto-initializes XuqmSDK at app startup when a config file is present.
|
||||
* Registered in sdk-core AndroidManifest so any module (im/update/push/license)
|
||||
* triggers one-shot initialization without explicit init calls from the app.
|
||||
* 已废弃:由 [XuqmMergedProvider] 替代。
|
||||
*
|
||||
* 保留此类仅为向后兼容(旧宿主可能在 AndroidManifest 中手动注册)。
|
||||
* 新版 sdk-core 的 AndroidManifest 已注册 XuqmMergedProvider,
|
||||
* 此 Provider 不会自动注册,需宿主显式声明才会生效。
|
||||
*/
|
||||
@Deprecated("Use XuqmMergedProvider instead", ReplaceWith("XuqmMergedProvider"))
|
||||
class XuqmInitializerProvider : ContentProvider() {
|
||||
override fun onCreate(): Boolean {
|
||||
val ctx = context?.applicationContext ?: return true
|
||||
// 同步阶段只做最小工作:读取 config 文件设置 appKey(约 5ms)
|
||||
// TokenStore / DeviceId / ApiClient 等重初始化移到后台线程
|
||||
runCatching {
|
||||
if (!XuqmSDK.isInitialized()) {
|
||||
XuqmSDK.autoInitializeAsync(ctx)
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
package com.xuqm.sdk.internal
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
|
||||
/**
|
||||
* 合并启动入口,替代 XuqmInitializerProvider + BugCollectInitProvider + LicenseInitializerProvider。
|
||||
*
|
||||
* 设计原则:主线程零加密运算。
|
||||
* Phase 1(同步,主线程,<10ms):
|
||||
* - 扫描 assets/config/ 或 assets/xuqm/ 找到 .xuqmconfig 文件
|
||||
* - 读取文件原始字节到内存
|
||||
* - 启动 IO 协程
|
||||
* Phase 2(异步,IO 线程):
|
||||
* - 查 ConfigCache(内存→磁盘),命中则跳过 PBKDF2
|
||||
* - 缓存 miss 时执行 PBKDF2 解密(~100ms)并写缓存
|
||||
* - 初始化 sdk-core(TokenStore / DeviceId / ApiClient)
|
||||
* - 注册 sdk-bugcollect CrashCapture
|
||||
* - 初始化 sdk-license LicenseContextHolder
|
||||
* - 拉取远端平台配置
|
||||
*/
|
||||
class XuqmMergedProvider : ContentProvider() {
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
val ctx = context?.applicationContext ?: return true
|
||||
|
||||
// ── Phase 1:主线程只读原始字节(<10ms)──────────────────────────
|
||||
val rawBytes = ConfigFileReader.readRawBytes(ctx)
|
||||
if (rawBytes == null) {
|
||||
Log.w(TAG, "No config file found, skipping auto-init")
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Phase 2:IO 线程完成所有重活 ─────────────────────────────────
|
||||
XuqmSDK.autoInitializeFromBytes(ctx, rawBytes)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
|
||||
override fun getType(uri: Uri): String? = null
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
|
||||
companion object {
|
||||
private const val TAG = "XuqmSDK"
|
||||
}
|
||||
}
|
||||
正在加载...
在新工单中引用
屏蔽一个用户