54. 包体积监控体系建设

摘要:本文深入讲解Android包体积监控体系的完整建设方案,从自动化分析、CI/CD集成、趋势监控到告警机制,通过系统化的监控手段确保APK体积持续可控。文章涵盖自动化工具开发、数据可视化、问题归因等核心技术,并提供完整的监控平台搭建方案和最佳实践。

关键词:#包体积监控 #CI/CD #数据可视化 #自动化分析 #质量把关

1. 监控体系架构

1.1 整体架构设计

代码提交

CI构建

APK分析

体积数据采集

差异对比

问题检测

数据存储

数据分析

可视化展示

告警通知

体积大盘

趋势图表

模块分析

邮件通知

企业微信

钉钉通知

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 实施步骤

  1. 第一阶段:基础监控

    • 集成自动化分析工具
    • 建立数据存储
    • 配置基础告警
  2. 第二阶段:趋势分析

    • 搭建可视化平台
    • 建立趋势监控
    • 优化告警规则
  3. 第三阶段:深度分析

    • 模块贡献度分析
    • 自动问题归因
    • 优化建议生成
  4. 第四阶段:持续优化

    • 定期review数据
    • 优化阈值配置
    • 完善监控体系

总结

完善的包体积监控体系是保障APK体积持续可控的关键。通过自动化分析、CI/CD集成、数据可视化和告警机制,可以及时发现和解决体积问题,确保应用体验。


本文数据:所有方案来自真实项目实践,经过脱敏处理。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐