54. 包体积监控体系建设
·
54. 包体积监控体系建设
摘要:本文深入讲解Android包体积监控体系的完整建设方案,从自动化分析、CI/CD集成、趋势监控到告警机制,通过系统化的监控手段确保APK体积持续可控。文章涵盖自动化工具开发、数据可视化、问题归因等核心技术,并提供完整的监控平台搭建方案和最佳实践。
关键词:#包体积监控 #CI/CD #数据可视化 #自动化分析 #质量把关
1. 监控体系架构
1.1 整体架构设计
1.2 核心功能模块
/**
* 包体积监控系统
* 核心管理类
*/
class ApkSizeMonitoringSystem(
private val config: MonitorConfig
) {
data class MonitorConfig(
val projectName: String,
val storageBackend: StorageBackend,
val alertChannels: List<AlertChannel>,
val thresholds: SizeThresholds
)
data class SizeThresholds(
val totalSizeLimit: Long = 50 * 1024 * 1024, // 50MB
val increaseLimitPer: Long = 2 * 1024 * 1024, // 单次增长2MB
val dailyIncreaseLimit: Long = 5 * 1024 * 1024, // 日增长5MB
val weeklyIncreaseLimit: Long = 10 * 1024 * 1024 // 周增长10MB
)
private val analyzer = ApkAnalyzer()
private val storage = config.storageBackend
private val alertManager = AlertManager(config.alertChannels)
/**
* 分析APK并记录数据
*/
suspend fun analyzeAndRecord(
apkFile: File,
buildInfo: BuildInfo
): AnalysisResult = withContext(Dispatchers.IO) {
// 1. 分析APK
val analysisResult = analyzer.analyze(apkFile)
// 2. 对比历史数据
val comparison = compareWithHistory(analysisResult, buildInfo)
// 3. 检测异常
val issues = detectIssues(comparison)
// 4. 存储数据
storage.save(
ApkSizeRecord(
timestamp = System.currentTimeMillis(),
buildInfo = buildInfo,
sizeData = analysisResult,
issues = issues
)
)
// 5. 发送告警
if (issues.isNotEmpty()) {
alertManager.sendAlerts(issues, comparison)
}
// 6. 生成报告
val report = generateReport(analysisResult, comparison, issues)
AnalysisResult(
sizeData = analysisResult,
comparison = comparison,
issues = issues,
report = report
)
}
/**
* 对比历史数据
*/
private suspend fun compareWithHistory(
current: SizeData,
buildInfo: BuildInfo
): Comparison {
// 获取上一次构建的数据
val previous = storage.getLatest(buildInfo.branch) ?: return Comparison.empty()
// 获取基准数据(主分支最新)
val baseline = if (buildInfo.branch != "main") {
storage.getLatest("main")
} else null
return Comparison(
previousBuild = previous,
currentBuild = current,
baseline = baseline,
totalDiff = current.totalSize - previous.sizeData.totalSize,
resourceDiff = current.resourceSize - previous.sizeData.resourceSize,
dexDiff = current.dexSize - previous.sizeData.dexSize,
nativeDiff = current.nativeSize - previous.sizeData.nativeSize
)
}
/**
* 检测异常
*/
private fun detectIssues(comparison: Comparison): List<SizeIssue> {
val issues = mutableListOf<SizeIssue>()
val thresholds = config.thresholds
// 1. 检查总体积超限
if (comparison.currentBuild.totalSize > thresholds.totalSizeLimit) {
issues.add(
SizeIssue(
type = IssueType.SIZE_LIMIT_EXCEEDED,
severity = Severity.ERROR,
message = "APK总体积超过限制",
details = "当前: ${formatSize(comparison.currentBuild.totalSize)}, " +
"限制: ${formatSize(thresholds.totalSizeLimit)}"
)
)
}
// 2. 检查单次增长
if (comparison.totalDiff > thresholds.increaseLimitPer) {
issues.add(
SizeIssue(
type = IssueType.RAPID_INCREASE,
severity = Severity.WARNING,
message = "体积增长过快",
details = "增长: ${formatSize(comparison.totalDiff)}, " +
"限制: ${formatSize(thresholds.increaseLimitPer)}"
)
)
}
// 3. 检查资源文件异常增长
if (comparison.resourceDiff > thresholds.increaseLimitPer / 2) {
issues.add(
SizeIssue(
type = IssueType.RESOURCE_INCREASE,
severity = Severity.WARNING,
message = "资源文件增长异常",
details = "资源增长: ${formatSize(comparison.resourceDiff)}"
)
)
}
// 4. 检查DEX文件异常增长
if (comparison.dexDiff > thresholds.increaseLimitPer / 3) {
issues.add(
SizeIssue(
type = IssueType.DEX_INCREASE,
severity = Severity.WARNING,
message = "代码体积增长异常",
details = "DEX增长: ${formatSize(comparison.dexDiff)}"
)
)
}
return issues
}
/**
* 生成分析报告
*/
private fun generateReport(
sizeData: SizeData,
comparison: Comparison,
issues: List<SizeIssue>
): String {
return buildString {
appendLine("========== APK体积分析报告 ==========")
appendLine()
appendLine("【体积概览】")
appendLine("总体积: ${formatSize(sizeData.totalSize)}")
appendLine("资源: ${formatSize(sizeData.resourceSize)}")
appendLine("DEX: ${formatSize(sizeData.dexSize)}")
appendLine("Native: ${formatSize(sizeData.nativeSize)}")
appendLine()
if (comparison.previousBuild != null) {
appendLine("【对比上次构建】")
appendLine("体积变化: ${formatSizeDiff(comparison.totalDiff)}")
appendLine("资源变化: ${formatSizeDiff(comparison.resourceDiff)}")
appendLine("DEX变化: ${formatSizeDiff(comparison.dexDiff)}")
appendLine("Native变化: ${formatSizeDiff(comparison.nativeDiff)}")
appendLine()
}
if (issues.isNotEmpty()) {
appendLine("【发现问题】")
issues.forEach { issue ->
appendLine("${issue.severity.emoji} ${issue.message}")
appendLine(" ${issue.details}")
}
appendLine()
}
appendLine("=====================================")
}
}
private fun formatSize(bytes: Long): String {
return when {
bytes >= 1024 * 1024 -> "%.2f MB".format(bytes / (1024.0 * 1024.0))
bytes >= 1024 -> "%.2f KB".format(bytes / 1024.0)
else -> "$bytes B"
}
}
private fun formatSizeDiff(bytes: Long): String {
val sign = if (bytes > 0) "+" else ""
return "$sign${formatSize(bytes)}"
}
}
/**
* 数据模型
*/
data class SizeData(
val totalSize: Long,
val resourceSize: Long,
val dexSize: Long,
val nativeSize: Long,
val assetSize: Long,
val otherSize: Long,
val details: Map<String, Long>
)
data class BuildInfo(
val branch: String,
val commit: String,
val buildType: String, // debug/release
val versionName: String,
val versionCode: Int,
val buildTime: Long
)
data class ApkSizeRecord(
val timestamp: Long,
val buildInfo: BuildInfo,
val sizeData: SizeData,
val issues: List<SizeIssue>
)
data class Comparison(
val previousBuild: ApkSizeRecord?,
val currentBuild: SizeData,
val baseline: ApkSizeRecord?,
val totalDiff: Long,
val resourceDiff: Long,
val dexDiff: Long,
val nativeDiff: Long
) {
companion object {
fun empty() = Comparison(
previousBuild = null,
currentBuild = SizeData(0, 0, 0, 0, 0, 0, emptyMap()),
baseline = null,
totalDiff = 0,
resourceDiff = 0,
dexDiff = 0,
nativeDiff = 0
)
}
}
data class SizeIssue(
val type: IssueType,
val severity: Severity,
val message: String,
val details: String
)
enum class IssueType {
SIZE_LIMIT_EXCEEDED,
RAPID_INCREASE,
RESOURCE_INCREASE,
DEX_INCREASE,
NATIVE_INCREASE
}
enum class Severity(val emoji: String) {
ERROR("🚫"),
WARNING("⚠️"),
INFO("ℹ️")
}
data class AnalysisResult(
val sizeData: SizeData,
val comparison: Comparison,
val issues: List<SizeIssue>,
val report: String
)
2. APK自动化分析
2.1 深度分析器实现
/**
* APK深度分析器
* 提取APK的详细体积信息
*/
class ApkAnalyzer {
/**
* 分析APK文件
*/
fun analyze(apkFile: File): SizeData {
var totalSize = 0L
var resourceSize = 0L
var dexSize = 0L
var nativeSize = 0L
var assetSize = 0L
var otherSize = 0L
val details = mutableMapOf<String, Long>()
ZipFile(apkFile).use { zip ->
zip.entries().asSequence().forEach { entry ->
if (entry.isDirectory) return@forEach
val size = entry.size
totalSize += size
details[entry.name] = size
// 分类统计
when {
entry.name.startsWith("res/") -> resourceSize += size
entry.name.endsWith(".dex") -> dexSize += size
entry.name.startsWith("lib/") -> nativeSize += size
entry.name.startsWith("assets/") -> assetSize += size
else -> otherSize += size
}
}
}
return SizeData(
totalSize = totalSize,
resourceSize = resourceSize,
dexSize = dexSize,
nativeSize = nativeSize,
assetSize = assetSize,
otherSize = otherSize,
details = details
)
}
/**
* 分析模块贡献度
*/
fun analyzeModuleContribution(apkFile: File): Map<String, ModuleContribution> {
val contributions = mutableMapOf<String, ModuleContribution>()
// 分析DEX文件中的类
analyzeDexClasses(apkFile).forEach { (className, size) ->
val moduleName = extractModuleName(className)
val current = contributions.getOrPut(moduleName) {
ModuleContribution(moduleName, 0, 0, 0, 0)
}
contributions[moduleName] = current.copy(dexSize = current.dexSize + size)
}
// 分析资源文件
analyzeResources(apkFile).forEach { (resourceName, size) ->
val moduleName = extractModuleFromResource(resourceName)
val current = contributions.getOrPut(moduleName) {
ModuleContribution(moduleName, 0, 0, 0, 0)
}
contributions[moduleName] = current.copy(resourceSize = current.resourceSize + size)
}
return contributions
}
/**
* 分析DEX类
*/
private fun analyzeDexClasses(apkFile: File): Map<String, Long> {
val classMap = mutableMapOf<String, Long>()
ZipFile(apkFile).use { zip ->
zip.entries().asSequence()
.filter { it.name.matches(Regex("classes\\d*\\.dex")) }
.forEach { entry ->
val dexData = zip.getInputStream(entry).readBytes()
// 使用dexlib2解析DEX文件
parseDexFile(dexData).forEach { (className, size) ->
classMap[className] = size
}
}
}
return classMap
}
/**
* 解析DEX文件
*/
private fun parseDexFile(dexData: ByteArray): Map<String, Long> {
// 简化实现,实际应使用dexlib2库
// implementation("com.android.tools.smali:dexlib2:2.5.2")
val classMap = mutableMapOf<String, Long>()
try {
val dexFile = DexFileFactory.loadDexFile(
dexData,
Opcodes.getDefault()
)
dexFile.classes.forEach { classDef ->
val className = classDef.type
// 估算类的大小
val size = estimateClassSize(classDef)
classMap[className] = size
}
} catch (e: Exception) {
Log.e("ApkAnalyzer", "Failed to parse DEX", e)
}
return classMap
}
/**
* 估算类的大小
*/
private fun estimateClassSize(classDef: ClassDef): Long {
var size = 0L
// 字段
size += classDef.fields.count() * 20L
// 方法
classDef.methods.forEach { method ->
val implementation = method.implementation
if (implementation != null) {
size += implementation.instructions.count() * 10L
}
}
return size
}
/**
* 分析资源文件
*/
private fun analyzeResources(apkFile: File): Map<String, Long> {
val resourceMap = mutableMapOf<String, Long>()
ZipFile(apkFile).use { zip ->
zip.entries().asSequence()
.filter { it.name.startsWith("res/") }
.forEach { entry ->
resourceMap[entry.name] = entry.size
}
}
return resourceMap
}
/**
* 提取模块名称
*/
private fun extractModuleName(className: String): String {
// 从类名提取模块名
// 例如: com.example.app.feature.home.HomeActivity -> feature-home
val parts = className.split(".")
return if (parts.size >= 4) {
parts[3] // feature name
} else {
"unknown"
}
}
/**
* 从资源路径提取模块名
*/
private fun extractModuleFromResource(resourceName: String): String {
// 简化实现,实际需要分析资源ID映射
return "unknown"
}
data class ModuleContribution(
val moduleName: String,
val dexSize: Long,
val resourceSize: Long,
val nativeSize: Long,
val assetSize: Long
) {
val totalSize: Long
get() = dexSize + resourceSize + nativeSize + assetSize
}
}
2.2 差异分析
/**
* APK差异分析器
* 对比两个版本的APK差异
*/
class ApkDiffAnalyzer {
data class DiffResult(
val addedFiles: List<FileInfo>,
val removedFiles: List<FileInfo>,
val modifiedFiles: List<ModifiedFileInfo>,
val totalSizeDiff: Long,
val summary: String
)
data class FileInfo(
val path: String,
val size: Long
)
data class ModifiedFileInfo(
val path: String,
val oldSize: Long,
val newSize: Long,
val sizeDiff: Long
)
/**
* 对比两个APK
*/
fun diff(oldApk: File, newApk: File): DiffResult {
val oldFiles = extractFileList(oldApk)
val newFiles = extractFileList(newApk)
// 找出新增文件
val addedFiles = newFiles.filterKeys { it !in oldFiles }
.map { FileInfo(it.key, it.value) }
.sortedByDescending { it.size }
// 找出删除文件
val removedFiles = oldFiles.filterKeys { it !in newFiles }
.map { FileInfo(it.key, it.value) }
.sortedByDescending { it.size }
// 找出修改文件
val modifiedFiles = newFiles.filterKeys { it in oldFiles }
.mapNotNull { (path, newSize) ->
val oldSize = oldFiles[path] ?: return@mapNotNull null
if (oldSize != newSize) {
ModifiedFileInfo(
path = path,
oldSize = oldSize,
newSize = newSize,
sizeDiff = newSize - oldSize
)
} else null
}
.sortedByDescending { kotlin.math.abs(it.sizeDiff) }
val totalSizeDiff = newApk.length() - oldApk.length()
val summary = generateSummary(
addedFiles,
removedFiles,
modifiedFiles,
totalSizeDiff
)
return DiffResult(
addedFiles = addedFiles,
removedFiles = removedFiles,
modifiedFiles = modifiedFiles,
totalSizeDiff = totalSizeDiff,
summary = summary
)
}
/**
* 提取文件列表
*/
private fun extractFileList(apkFile: File): Map<String, Long> {
val fileMap = mutableMapOf<String, Long>()
ZipFile(apkFile).use { zip ->
zip.entries().asSequence()
.filter { !it.isDirectory }
.forEach { entry ->
fileMap[entry.name] = entry.size
}
}
return fileMap
}
/**
* 生成摘要
*/
private fun generateSummary(
addedFiles: List<FileInfo>,
removedFiles: List<FileInfo>,
modifiedFiles: List<ModifiedFileInfo>,
totalSizeDiff: Long
): String {
return buildString {
appendLine("========== APK差异分析 ==========")
appendLine()
appendLine("总体积变化: ${formatSizeDiff(totalSizeDiff)}")
appendLine()
if (addedFiles.isNotEmpty()) {
appendLine("【新增文件】(${addedFiles.size}个)")
addedFiles.take(10).forEach { file ->
appendLine(" + ${file.path} (${formatSize(file.size)})")
}
if (addedFiles.size > 10) {
appendLine(" ... 还有${addedFiles.size - 10}个文件")
}
appendLine()
}
if (removedFiles.isNotEmpty()) {
appendLine("【删除文件】(${removedFiles.size}个)")
removedFiles.take(10).forEach { file ->
appendLine(" - ${file.path} (${formatSize(file.size)})")
}
if (removedFiles.size > 10) {
appendLine(" ... 还有${removedFiles.size - 10}个文件")
}
appendLine()
}
if (modifiedFiles.isNotEmpty()) {
appendLine("【修改文件】(${modifiedFiles.size}个)")
modifiedFiles.take(10).forEach { file ->
appendLine(" ~ ${file.path} (${formatSizeDiff(file.sizeDiff)})")
}
if (modifiedFiles.size > 10) {
appendLine(" ... 还有${modifiedFiles.size - 10}个文件")
}
appendLine()
}
appendLine("================================")
}
}
private fun formatSize(bytes: Long): String {
return when {
bytes >= 1024 * 1024 -> "%.2f MB".format(bytes / (1024.0 * 1024.0))
bytes >= 1024 -> "%.2f KB".format(bytes / 1024.0)
else -> "$bytes B"
}
}
private fun formatSizeDiff(bytes: Long): String {
val sign = if (bytes > 0) "+" else ""
return "$sign${formatSize(bytes)}"
}
}
3. 数据存储和查询
3.1 存储后端接口
/**
* 存储后端接口
* 支持多种存储方式
*/
interface StorageBackend {
/**
* 保存记录
*/
suspend fun save(record: ApkSizeRecord)
/**
* 获取最新记录
*/
suspend fun getLatest(branch: String): ApkSizeRecord?
/**
* 获取时间范围内的记录
*/
suspend fun getRecords(
branch: String,
startTime: Long,
endTime: Long
): List<ApkSizeRecord>
/**
* 获取指定版本的记录
*/
suspend fun getByVersion(versionCode: Int): ApkSizeRecord?
/**
* 删除旧记录
*/
suspend fun cleanup(beforeTime: Long)
}
/**
* 本地JSON文件存储实现
*/
class LocalJsonStorage(
private val storageDir: File
) : StorageBackend {
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
override suspend fun save(record: ApkSizeRecord) = withContext(Dispatchers.IO) {
val branch = record.buildInfo.branch.replace("/", "_")
val file = File(storageDir, "$branch/${record.timestamp}.json")
file.parentFile.mkdirs()
file.writeText(json.encodeToString(record))
}
override suspend fun getLatest(branch: String): ApkSizeRecord? = withContext(Dispatchers.IO) {
val branchDir = File(storageDir, branch.replace("/", "_"))
if (!branchDir.exists()) return@withContext null
val latestFile = branchDir.listFiles()
?.filter { it.extension == "json" }
?.maxByOrNull { it.nameWithoutExtension.toLongOrNull() ?: 0 }
?: return@withContext null
json.decodeFromString(latestFile.readText())
}
override suspend fun getRecords(
branch: String,
startTime: Long,
endTime: Long
): List<ApkSizeRecord> = withContext(Dispatchers.IO) {
val branchDir = File(storageDir, branch.replace("/", "_"))
if (!branchDir.exists()) return@withContext emptyList()
branchDir.listFiles()
?.filter { it.extension == "json" }
?.mapNotNull { file ->
try {
val record: ApkSizeRecord = json.decodeFromString(file.readText())
if (record.timestamp in startTime..endTime) record else null
} catch (e: Exception) {
null
}
}
?.sortedBy { it.timestamp }
?: emptyList()
}
override suspend fun getByVersion(versionCode: Int): ApkSizeRecord? = withContext(Dispatchers.IO) {
storageDir.walkTopDown()
.filter { it.extension == "json" }
.mapNotNull { file ->
try {
val record: ApkSizeRecord = json.decodeFromString(file.readText())
if (record.buildInfo.versionCode == versionCode) record else null
} catch (e: Exception) {
null
}
}
.firstOrNull()
}
override suspend fun cleanup(beforeTime: Long) = withContext(Dispatchers.IO) {
storageDir.walkTopDown()
.filter { it.extension == "json" }
.forEach { file ->
val timestamp = file.nameWithoutExtension.toLongOrNull() ?: 0
if (timestamp < beforeTime) {
file.delete()
}
}
}
}
/**
* MySQL数据库存储实现
*/
class MySQLStorage(
private val dataSource: DataSource
) : StorageBackend {
override suspend fun save(record: ApkSizeRecord) = withContext(Dispatchers.IO) {
dataSource.connection.use { conn ->
val sql = """
INSERT INTO apk_size_records
(timestamp, branch, commit, build_type, version_name, version_code,
total_size, resource_size, dex_size, native_size, asset_size, other_size,
details, issues)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
conn.prepareStatement(sql).use { stmt ->
stmt.setLong(1, record.timestamp)
stmt.setString(2, record.buildInfo.branch)
stmt.setString(3, record.buildInfo.commit)
stmt.setString(4, record.buildInfo.buildType)
stmt.setString(5, record.buildInfo.versionName)
stmt.setInt(6, record.buildInfo.versionCode)
stmt.setLong(7, record.sizeData.totalSize)
stmt.setLong(8, record.sizeData.resourceSize)
stmt.setLong(9, record.sizeData.dexSize)
stmt.setLong(10, record.sizeData.nativeSize)
stmt.setLong(11, record.sizeData.assetSize)
stmt.setLong(12, record.sizeData.otherSize)
stmt.setString(13, Json.encodeToString(record.sizeData.details))
stmt.setString(14, Json.encodeToString(record.issues))
stmt.executeUpdate()
}
}
}
override suspend fun getLatest(branch: String): ApkSizeRecord? = withContext(Dispatchers.IO) {
dataSource.connection.use { conn ->
val sql = """
SELECT * FROM apk_size_records
WHERE branch = ?
ORDER BY timestamp DESC
LIMIT 1
""".trimIndent()
conn.prepareStatement(sql).use { stmt ->
stmt.setString(1, branch)
val rs = stmt.executeQuery()
if (rs.next()) {
parseRecord(rs)
} else null
}
}
}
override suspend fun getRecords(
branch: String,
startTime: Long,
endTime: Long
): List<ApkSizeRecord> = withContext(Dispatchers.IO) {
dataSource.connection.use { conn ->
val sql = """
SELECT * FROM apk_size_records
WHERE branch = ? AND timestamp BETWEEN ? AND ?
ORDER BY timestamp ASC
""".trimIndent()
conn.prepareStatement(sql).use { stmt ->
stmt.setString(1, branch)
stmt.setLong(2, startTime)
stmt.setLong(3, endTime)
val records = mutableListOf<ApkSizeRecord>()
val rs = stmt.executeQuery()
while (rs.next()) {
records.add(parseRecord(rs))
}
records
}
}
}
override suspend fun getByVersion(versionCode: Int): ApkSizeRecord? = withContext(Dispatchers.IO) {
dataSource.connection.use { conn ->
val sql = """
SELECT * FROM apk_size_records
WHERE version_code = ?
LIMIT 1
""".trimIndent()
conn.prepareStatement(sql).use { stmt ->
stmt.setInt(1, versionCode)
val rs = stmt.executeQuery()
if (rs.next()) {
parseRecord(rs)
} else null
}
}
}
override suspend fun cleanup(beforeTime: Long) = withContext(Dispatchers.IO) {
dataSource.connection.use { conn ->
val sql = "DELETE FROM apk_size_records WHERE timestamp < ?"
conn.prepareStatement(sql).use { stmt ->
stmt.setLong(1, beforeTime)
stmt.executeUpdate()
}
}
}
private fun parseRecord(rs: ResultSet): ApkSizeRecord {
return ApkSizeRecord(
timestamp = rs.getLong("timestamp"),
buildInfo = BuildInfo(
branch = rs.getString("branch"),
commit = rs.getString("commit"),
buildType = rs.getString("build_type"),
versionName = rs.getString("version_name"),
versionCode = rs.getInt("version_code"),
buildTime = rs.getLong("timestamp")
),
sizeData = SizeData(
totalSize = rs.getLong("total_size"),
resourceSize = rs.getLong("resource_size"),
dexSize = rs.getLong("dex_size"),
nativeSize = rs.getLong("native_size"),
assetSize = rs.getLong("asset_size"),
otherSize = rs.getLong("other_size"),
details = Json.decodeFromString(rs.getString("details"))
),
issues = Json.decodeFromString(rs.getString("issues"))
)
}
}
4. CI/CD集成
4.1 Gradle Plugin实现
/**
* APK体积监控Gradle插件
*/
class ApkSizeMonitorPlugin : Plugin<Project> {
override fun apply(project: Project) {
val extension = project.extensions.create(
"apkSizeMonitor",
ApkSizeMonitorExtension::class.java
)
project.afterEvaluate {
configureMonitoring(project, extension)
}
}
private fun configureMonitoring(
project: Project,
extension: ApkSizeMonitorExtension
) {
val android = project.extensions.getByType(AppExtension::class.java)
android.applicationVariants.all { variant ->
// 为每个variant创建监控任务
val taskName = "monitorApkSize${variant.name.capitalize()}"
val monitorTask = project.tasks.register(
taskName,
ApkSizeMonitorTask::class.java
) {
it.variantName.set(variant.name)
it.apkFile.set(variant.outputs.first().outputFile)
it.config.set(extension)
}
// 在打包完成后自动执行
variant.assembleProvider.configure {
it.finalizedBy(monitorTask)
}
}
}
}
/**
* 插件配置扩展
*/
open class ApkSizeMonitorExtension {
var enabled: Boolean = true
var projectName: String = ""
var storageType: String = "json" // json, mysql
var storageConfig: Map<String, String> = emptyMap()
var sizeLimit: Long = 50 * 1024 * 1024 // 50MB
var increaseLimit: Long = 2 * 1024 * 1024 // 2MB
var alertChannels: List<String> = listOf("console")
var failOnSizeExceeded: Boolean = true
}
/**
* APK体积监控Task
*/
abstract class ApkSizeMonitorTask : DefaultTask() {
@get:Input
abstract val variantName: Property<String>
@get:InputFile
abstract val apkFile: RegularFileProperty
@get:Input
abstract val config: Property<ApkSizeMonitorExtension>
@TaskAction
fun monitor() {
val conf = config.get()
if (!conf.enabled) {
println("APK体积监控已禁用")
return
}
val apk = apkFile.get().asFile
if (!apk.exists()) {
println("APK文件不存在: ${apk.absolutePath}")
return
}
println("========== APK体积监控 ==========")
println("Variant: ${variantName.get()}")
println("APK: ${apk.name}")
// 创建监控系统
val storage = createStorage(conf)
val monitoringSystem = ApkSizeMonitoringSystem(
ApkSizeMonitoringSystem.MonitorConfig(
projectName = conf.projectName,
storageBackend = storage,
alertChannels = createAlertChannels(conf),
thresholds = ApkSizeMonitoringSystem.SizeThresholds(
totalSizeLimit = conf.sizeLimit,
increaseLimitPer = conf.increaseLimit
)
)
)
// 执行分析
val buildInfo = BuildInfo(
branch = getGitBranch(),
commit = getGitCommit(),
buildType = variantName.get(),
versionName = getVersionName(),
versionCode = getVersionCode(),
buildTime = System.currentTimeMillis()
)
runBlocking {
val result = monitoringSystem.analyzeAndRecord(apk, buildInfo)
// 打印报告
println(result.report)
// 检查是否需要失败构建
if (conf.failOnSizeExceeded && result.issues.any { it.severity == Severity.ERROR }) {
throw GradleException("APK体积超过限制!")
}
}
println("================================")
}
private fun createStorage(config: ApkSizeMonitorExtension): StorageBackend {
return when (config.storageType) {
"json" -> {
val dir = File(project.buildDir, "apk-size-history")
LocalJsonStorage(dir)
}
"mysql" -> {
// 创建MySQL连接
val dataSource = HikariDataSource(HikariConfig().apply {
jdbcUrl = config.storageConfig["jdbcUrl"]
username = config.storageConfig["username"]
password = config.storageConfig["password"]
})
MySQLStorage(dataSource)
}
else -> throw IllegalArgumentException("不支持的存储类型: ${config.storageType}")
}
}
private fun createAlertChannels(config: ApkSizeMonitorExtension): List<AlertChannel> {
return config.alertChannels.map { channel ->
when (channel) {
"console" -> ConsoleAlertChannel()
"email" -> EmailAlertChannel(config.storageConfig)
"wechat" -> WeChatAlertChannel(config.storageConfig)
"dingtalk" -> DingTalkAlertChannel(config.storageConfig)
else -> ConsoleAlertChannel()
}
}
}
private fun getGitBranch(): String {
return execCommand("git rev-parse --abbrev-ref HEAD")
}
private fun getGitCommit(): String {
return execCommand("git rev-parse HEAD")
}
private fun getVersionName(): String {
val android = project.extensions.getByType(AppExtension::class.java)
return android.defaultConfig.versionName ?: "unknown"
}
private fun getVersionCode(): Int {
val android = project.extensions.getByType(AppExtension::class.java)
return android.defaultConfig.versionCode ?: 0
}
private fun execCommand(command: String): String {
return try {
Runtime.getRuntime().exec(command)
.inputStream.bufferedReader().readText().trim()
} catch (e: Exception) {
"unknown"
}
}
}
// build.gradle.kts使用示例
plugins {
id("com.android.application")
id("com.example.apk-size-monitor")
}
apkSizeMonitor {
enabled = true
projectName = "MyApp"
storageType = "json"
sizeLimit = 50 * 1024 * 1024 // 50MB
increaseLimit = 2 * 1024 * 1024 // 2MB
alertChannels = listOf("console", "wechat")
failOnSizeExceeded = true
}
4.2 Jenkins集成
// Jenkinsfile
pipeline {
agent any
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Build') {
steps {
sh './gradlew assembleRelease'
}
}
stage('APK Size Analysis') {
steps {
script {
// 执行体积监控
sh './gradlew monitorApkSizeRelease'
// 读取分析报告
def report = readFile('build/reports/apk-size/report.txt')
println(report)
// 发布到归档
archiveArtifacts artifacts: 'build/reports/apk-size/**/*'
// 发布趋势图
publishHTML([
reportDir: 'build/reports/apk-size',
reportFiles: 'trend.html',
reportName: 'APK Size Trend'
])
}
}
}
stage('Quality Gate') {
steps {
script {
// 检查体积是否超限
def sizeReport = readJSON file: 'build/reports/apk-size/data.json'
def totalSize = sizeReport.totalSize
def sizeLimit = 50 * 1024 * 1024 // 50MB
if (totalSize > sizeLimit) {
error("APK体积超过限制!当前: ${totalSize/1024/1024}MB, 限制: ${sizeLimit/1024/1024}MB")
}
}
}
}
}
post {
always {
// 清理工作空间
cleanWs()
}
failure {
// 发送失败通知
emailext(
subject: "构建失败 - APK体积超限",
body: "请查看详情: ${env.BUILD_URL}",
to: "dev-team@example.com"
)
}
}
}
5. 告警通知
5.1 告警管理器
/**
* 告警管理器
* 支持多种通知渠道
*/
class AlertManager(
private val channels: List<AlertChannel>
) {
/**
* 发送告警
*/
suspend fun sendAlerts(
issues: List<SizeIssue>,
comparison: Comparison
) {
if (issues.isEmpty()) return
val message = buildAlertMessage(issues, comparison)
channels.forEach { channel ->
try {
channel.send(message)
} catch (e: Exception) {
Log.e("AlertManager", "Failed to send alert via $channel", e)
}
}
}
/**
* 构建告警消息
*/
private fun buildAlertMessage(
issues: List<SizeIssue>,
comparison: Comparison
): AlertMessage {
val title = when {
issues.any { it.severity == Severity.ERROR } -> "🚫 APK体积告警 - 错误"
issues.any { it.severity == Severity.WARNING } -> "⚠️ APK体积告警 - 警告"
else -> "ℹ️ APK体积提示"
}
val content = buildString {
appendLine("【体积变化】")
appendLine("总体积: ${formatSize(comparison.currentBuild.totalSize)}")
appendLine("变化: ${formatSizeDiff(comparison.totalDiff)}")
appendLine()
appendLine("【发现问题】")
issues.forEach { issue ->
appendLine("${issue.severity.emoji} ${issue.message}")
appendLine(" ${issue.details}")
}
}
return AlertMessage(
title = title,
content = content,
severity = issues.maxByOrNull { it.severity }?.severity ?: Severity.INFO
)
}
private fun formatSize(bytes: Long): String {
return "%.2f MB".format(bytes / (1024.0 * 1024.0))
}
private fun formatSizeDiff(bytes: Long): String {
val sign = if (bytes > 0) "+" else ""
return "$sign${formatSize(bytes)}"
}
}
/**
* 告警渠道接口
*/
interface AlertChannel {
suspend fun send(message: AlertMessage)
}
data class AlertMessage(
val title: String,
val content: String,
val severity: Severity
)
/**
* 控制台告警
*/
class ConsoleAlertChannel : AlertChannel {
override suspend fun send(message: AlertMessage) {
println("""
|
|========== ${message.title} ==========
|${message.content}
|==================================
|
""".trimMargin())
}
}
/**
* 邮件告警
*/
class EmailAlertChannel(
private val config: Map<String, String>
) : AlertChannel {
override suspend fun send(message: AlertMessage) = withContext(Dispatchers.IO) {
val props = Properties().apply {
put("mail.smtp.host", config["smtpHost"])
put("mail.smtp.port", config["smtpPort"])
put("mail.smtp.auth", "true")
put("mail.smtp.starttls.enable", "true")
}
val session = Session.getInstance(props, object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication {
return PasswordAuthentication(
config["username"],
config["password"]
)
}
})
val mimeMessage = MimeMessage(session).apply {
setFrom(InternetAddress(config["from"]))
addRecipient(Message.RecipientType.TO, InternetAddress(config["to"]))
subject = message.title
setText(message.content)
}
Transport.send(mimeMessage)
}
}
/**
* 企业微信告警
*/
class WeChatAlertChannel(
private val config: Map<String, String>
) : AlertChannel {
override suspend fun send(message: AlertMessage) = withContext(Dispatchers.IO) {
val webhookUrl = config["webhookUrl"] ?: return@withContext
val payload = mapOf(
"msgtype" to "markdown",
"markdown" to mapOf(
"content" to """
## ${message.title}
${message.content}
""".trimIndent()
)
)
val client = OkHttpClient()
val request = Request.Builder()
.url(webhookUrl)
.post(Json.encodeToString(payload).toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Failed to send WeChat alert: ${response.code}")
}
}
}
}
/**
* 钉钉告警
*/
class DingTalkAlertChannel(
private val config: Map<String, String>
) : AlertChannel {
override suspend fun send(message: AlertMessage) = withContext(Dispatchers.IO) {
val webhookUrl = config["webhookUrl"] ?: return@withContext
val payload = mapOf(
"msgtype" to "markdown",
"markdown" to mapOf(
"title" to message.title,
"text" to message.content
)
)
val client = OkHttpClient()
val request = Request.Builder()
.url(webhookUrl)
.post(Json.encodeToString(payload).toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Failed to send DingTalk alert: ${response.code}")
}
}
}
}
6. 数据可视化
6.1 Web可视化平台
/**
* APK体积可视化Web服务
* 使用Ktor框架
*/
fun Application.apkSizeVisualization(storage: StorageBackend) {
routing {
// 首页 - 体积趋势图
get("/") {
val branch = call.request.queryParameters["branch"] ?: "main"
val html = generateTrendPage(storage, branch)
call.respondText(html, ContentType.Text.Html)
}
// API - 获取历史数据
get("/api/records") {
val branch = call.request.queryParameters["branch"] ?: "main"
val days = call.request.queryParameters["days"]?.toIntOrNull() ?: 30
val endTime = System.currentTimeMillis()
val startTime = endTime - days * 24 * 3600 * 1000
val records = storage.getRecords(branch, startTime, endTime)
call.respond(records)
}
// API - 获取模块分析
get("/api/modules") {
val versionCode = call.request.queryParameters["version"]?.toIntOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest)
val record = storage.getByVersion(versionCode)
?: return@get call.respond(HttpStatusCode.NotFound)
// 分析模块贡献
// ...
call.respond(emptyMap<String, Any>())
}
// 静态资源
static("/static") {
resources("static")
}
}
}
/**
* 生成趋势页面
*/
suspend fun generateTrendPage(
storage: StorageBackend,
branch: String
): String {
val records = storage.getRecords(
branch,
System.currentTimeMillis() - 30 * 24 * 3600 * 1000,
System.currentTimeMillis()
)
return """
<!DOCTYPE html>
<html>
<head>
<title>APK体积监控</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 20px;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 30px;
}
.chart-container {
position: relative;
height: 400px;
margin-bottom: 40px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat-card {
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #007bff;
}
.stat-label {
font-size: 14px;
color: #666;
margin-top: 5px;
}
</style>
</head>
<body>
<div class="container">
<h1>APK体积监控 - $branch</h1>
<div class="stats">
<div class="stat-card">
<div class="stat-value">${formatSize(records.lastOrNull()?.sizeData?.totalSize ?: 0)}</div>
<div class="stat-label">当前体积</div>
</div>
<div class="stat-card">
<div class="stat-value">${records.size}</div>
<div class="stat-label">构建次数(30天)</div>
</div>
<div class="stat-card">
<div class="stat-value">${calculateTrend(records)}</div>
<div class="stat-label">平均增长</div>
</div>
</div>
<div class="chart-container">
<canvas id="trendChart"></canvas>
</div>
<div class="chart-container">
<canvas id="compositionChart"></canvas>
</div>
</div>
<script>
// 趋势图数据
const trendData = {
labels: ${records.map { formatDate(it.timestamp) }},
datasets: [{
label: '总体积 (MB)',
data: ${records.map { it.sizeData.totalSize / (1024.0 * 1024.0) }},
borderColor: 'rgb(75, 192, 192)',
tension: 0.1
}]
};
// 创建趋势图
new Chart(document.getElementById('trendChart'), {
type: 'line',
data: trendData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: '体积变化趋势'
}
}
}
});
// 组成图数据
const latestRecord = ${Json.encodeToString(records.lastOrNull())};
const compositionData = {
labels: ['资源', 'DEX', 'Native', 'Assets', '其他'],
datasets: [{
data: [
latestRecord.sizeData.resourceSize / (1024 * 1024),
latestRecord.sizeData.dexSize / (1024 * 1024),
latestRecord.sizeData.nativeSize / (1024 * 1024),
latestRecord.sizeData.assetSize / (1024 * 1024),
latestRecord.sizeData.otherSize / (1024 * 1024)
],
backgroundColor: [
'rgb(255, 99, 132)',
'rgb(54, 162, 235)',
'rgb(255, 205, 86)',
'rgb(75, 192, 192)',
'rgb(153, 102, 255)'
]
}]
};
// 创建组成图
new Chart(document.getElementById('compositionChart'), {
type: 'doughnut',
data: compositionData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'APK组成分析'
}
}
}
});
</script>
</body>
</html>
""".trimIndent()
}
private fun formatSize(bytes: Long): String {
return "%.2f MB".format(bytes / (1024.0 * 1024.0))
}
private fun formatDate(timestamp: Long): String {
return SimpleDateFormat("MM/dd HH:mm").format(Date(timestamp))
}
private fun calculateTrend(records: List<ApkSizeRecord>): String {
if (records.size < 2) return "N/A"
val first = records.first().sizeData.totalSize
val last = records.last().sizeData.totalSize
val diff = last - first
val days = (records.last().timestamp - records.first().timestamp) / (24 * 3600 * 1000)
val avgPerDay = if (days > 0) diff / days else 0
return formatSize(avgPerDay) + "/天"
}
7. 最佳实践
7.1 监控指标体系
| 指标类型 | 指标名称 | 阈值示例 | 说明 |
|---|---|---|---|
| 绝对值 | APK总体积 | <50MB | 应用总体积上限 |
| 增量 | 单次增长 | <2MB | 单次提交增长上限 |
| 增量 | 日增长 | <5MB | 每日累计增长上限 |
| 增量 | 周增长 | <10MB | 每周累计增长上限 |
| 组成 | 资源占比 | <40% | 资源文件占比上限 |
| 组成 | DEX占比 | <30% | 代码文件占比上限 |
| 组成 | Native占比 | <25% | so库占比上限 |
7.2 实施步骤
-
第一阶段:基础监控
- 集成自动化分析工具
- 建立数据存储
- 配置基础告警
-
第二阶段:趋势分析
- 搭建可视化平台
- 建立趋势监控
- 优化告警规则
-
第三阶段:深度分析
- 模块贡献度分析
- 自动问题归因
- 优化建议生成
-
第四阶段:持续优化
- 定期review数据
- 优化阈值配置
- 完善监控体系
总结
完善的包体积监控体系是保障APK体积持续可控的关键。通过自动化分析、CI/CD集成、数据可视化和告警机制,可以及时发现和解决体积问题,确保应用体验。
本文数据:所有方案来自真实项目实践,经过脱敏处理。
更多推荐


所有评论(0)