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>
这个提交包含在:
XuqmGroup 2026-06-18 15:58:53 +08:00
父节点 1f1c0b4018
当前提交 b7c7a2c385
共有 7 个文件被更改,包括 295 次插入41 次删除

查看文件

@ -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。
主线程只读文件字节(<10msPBKDF2 + 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文件变更时自动重新解密
*
* 作用避免每次冷启动都执行 PBKDF2120,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-coreTokenStore / 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 2IO 线程完成所有重活 ─────────────────────────────────
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"
}
}