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.ContentProvider
import android.content.ContentValues import android.content.ContentValues
import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.bugcollect.BugCollect 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) 之后运行此时 * 新版 sdk-core XuqmMergedProvider 已通过反射调用 BugCollect.startCrashCapture()
* XuqmSDK.appKey 已由本地 config 文件同步设置 * Provider 检查 XuqmSDK.isInitialized() 来判断是否需要补充初始化
* *
* 职责分离 * 逻辑
* Phase 1同步只注册 UncaughtExceptionHandler把崩溃落盘 * - sdk-core 已初始化 说明合并 Provider 已处理跳过
* 不需要上传 URL注册与 URL 完全解耦 * - sdk-core 未初始化 旧版 sdk-core 场景执行原有逻辑
* Phase 2异步等待 XuqmSDK.awaitInitialization() 完成
* 远端配置下发 bugCollectApiUrl 后再上传积压的崩溃文件
*/ */
internal class BugCollectInitProvider : ContentProvider() { internal class BugCollectInitProvider : ContentProvider() {
override fun onCreate(): Boolean { override fun onCreate(): Boolean {
val ctx = context?.applicationContext ?: return true val ctx = context?.applicationContext ?: return true
// ── Phase 1注册崩溃拦截器只需 appKey,无需 SDK 完全初始化)────────── // 合并 Provider 已完成初始化sdk-core 新版),跳过
// SDK 可能正在后台初始化中,直接读取 config 文件获取 appKey if (XuqmSDK.isInitialized()) {
val appKey = if (XuqmSDK.isInitialized()) { Log.d("BugCollect", "Already initialized by merged provider, skipping")
XuqmSDK.appKey return true
} else {
XuqmSDK.readAppKey(ctx)
} }
// 旧版 sdk-core 路径:手动读取 config + 注册崩溃拦截
val appKey = XuqmSDK.readAppKey(ctx)
if (appKey.isNullOrBlank()) { if (appKey.isNullOrBlank()) {
Log.w("BugCollect", "BugCollectInitProvider: no appKey available, skipping") Log.w("BugCollect", "BugCollectInitProvider: no appKey available, skipping")
return true return true
@ -50,16 +42,6 @@ internal class BugCollectInitProvider : ContentProvider() {
getUserId = { XuqmSDK.getUserId() ?: XuqmSDK.deviceId }, 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 return true
} }

查看文件

@ -1,9 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application> <application>
<!--
合并启动入口:替代 XuqmInitializerProvider + BugCollectInitProvider + LicenseInitializerProvider。
主线程只读文件字节(<10msPBKDF2 + IO 线
initOrder=50 尽早执行,但不阻塞主线程。
-->
<provider <provider
android:name="com.xuqm.sdk.internal.XuqmInitializerProvider" android:name="com.xuqm.sdk.internal.XuqmMergedProvider"
android:authorities="${applicationId}.xuqm-init" android:authorities="${applicationId}.xuqm-init"
android:exported="false" android:exported="false"
android:initOrder="90" /> android:initOrder="50" />
</application> </application>
</manifest> </manifest>

查看文件

@ -120,7 +120,7 @@ object XuqmSDK {
return return
} }
// 异步TokenStore / DeviceId / ApiClient / 远端配置 // 异步TokenStore / DeviceId / ApiClient / 远端配置
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO + kotlinx.coroutines.SupervisorJob()).launch { sdkScope.launch {
runCatching { runCatching {
initialize(ctx, configFile.appKey, configFile.serverUrl, logLevel) initialize(ctx, configFile.appKey, configFile.serverUrl, logLevel)
}.onFailure { e -> }.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手动初始化 * 方式 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/ * 搜索顺序对齐 RN SDK src/assets/config/
* 1. assets/config/config.xuqmconfig config.xuqm * 1. assets/config/config.xuqmconfig config.xuqm
* 2. assets/config/\*.xuqmconfig取第一个 * 2. assets/config/*.xuqmconfig取第一个
* 3. assets/xuqm/config.xuqm旧路径兼容 * 3. assets/xuqm/config.xuqm旧路径兼容
* 4. assets/xuqm/\*.xuqmconfig旧路径兼容 * 4. assets/xuqm/*.xuqmconfig旧路径兼容
*
* 缓存策略
* - [readRawBytes]只读取文件原始字节不做解密主线程安全<5ms
* - [readWithCache]先查 ConfigCache内存+磁盘miss 时解密并写缓存
* - [read]原始读取+解密无缓存兼容旧调用
*/ */
internal object ConfigFileReader { internal object ConfigFileReader {
@ -19,6 +24,49 @@ internal object ConfigFileReader {
private val CONFIG_DIRS = listOf("config", "xuqm") private val CONFIG_DIRS = listOf("config", "xuqm")
private val gson = Gson() 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? { fun read(context: Context): ConfigFile? {
return try { return try {
val path = findConfigPath(context) ?: run { val path = findConfigPath(context) ?: run {
@ -62,4 +110,3 @@ internal object ConfigFileReader {
return null return null
} }
} }

查看文件

@ -8,15 +8,16 @@ import android.util.Log
import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmSDK
/** /**
* Auto-initializes XuqmSDK at app startup when a config file is present. * 已废弃 [XuqmMergedProvider] 替代
* Registered in sdk-core AndroidManifest so any module (im/update/push/license) *
* triggers one-shot initialization without explicit init calls from the app. * 保留此类仅为向后兼容旧宿主可能在 AndroidManifest 中手动注册
* 新版 sdk-core AndroidManifest 已注册 XuqmMergedProvider
* Provider 不会自动注册需宿主显式声明才会生效
*/ */
@Deprecated("Use XuqmMergedProvider instead", ReplaceWith("XuqmMergedProvider"))
class XuqmInitializerProvider : ContentProvider() { class XuqmInitializerProvider : ContentProvider() {
override fun onCreate(): Boolean { override fun onCreate(): Boolean {
val ctx = context?.applicationContext ?: return true val ctx = context?.applicationContext ?: return true
// 同步阶段只做最小工作:读取 config 文件设置 appKey约 5ms
// TokenStore / DeviceId / ApiClient 等重初始化移到后台线程
runCatching { runCatching {
if (!XuqmSDK.isInitialized()) { if (!XuqmSDK.isInitialized()) {
XuqmSDK.autoInitializeAsync(ctx) 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"
}
}