我们如何在不减少功能的前提下,将安卓应用体积缩减 60%

新兴市场的用户常常在下载过程中放弃安装。我们的安装完成率已降至 68% 。Google Play 商店的数据显示,APK 体积每增加 6 MB,安装量就会下降 1%。面对数百万潜在用户,这些百分比直接转化为实实在在的用户流失与收入损失

在进行任何优化之前,我们必须先搞清楚空间都被什么占用了。我们使用 Android Studio 自带的 APK 分析器 对应用进行了拆解:

初始 APK 体积构成(145 MB)

erlang 复制代码
Total APK Size: 145 MB  
├── res/ (resources) → 68 MB (47%)  
│ ├── drawable/ → 52 MB  
│ ├── raw/ → 12 MB  
│ └── other → 4 MB  
├── lib/ (native libs) → 38 MB (26%)  
├── assets/ → 24 MB (17%)  
├── classes.dex → 12 MB (8%)  
└── other → 3 MB (2%)

主要发现:

  • 图片占用 52 MB ------ 大多是未优化的 PNG 图片
  • 原生库占用 38 MB ------ 不必要地包含了所有 ABI 架构
  • assets 目录占用 24 MB ------ 包含教程视频和字体文件
  • DEX 文件体积合理 ------ 代码并非体积过大的主要原因

策略 1:图片优化(节省 38 MB)

图片是占用空间最大的部分,达到 52 MB。以下是我们采取的优化措施:

步骤 1:将 PNG 转换为 WebP

WebP 在画质相近的情况下,比 PNG 拥有更好的压缩率。

转换前:

bash 复制代码
res/drawable-xxhdpi/  
├── splash_background.png → 2.8 MB  
├── hero_banner.png → 1.9 MB  
├── onboarding_1.png → 1.5 MB  
└── ...

转换后:

erlang 复制代码
res/drawable-xxhdpi/  
├── splash_background.webp → 420 KB (85% reduction)  
├── hero_banner.webp → 380 KB (80% reduction)  
├── onboarding_1.webp → 290 KB (81% reduction)  
└── ...

优化脚本:

bash 复制代码
# Convert all PNGs to WebP using cwebp  
find app/src/main/res -name "*.png" | while read file; do  
    output="${file%.png}.webp"  
    cwebp -q 80 "$file" -o "$output"  

    # Only keep WebP if it's smaller  
    if [ $(stat -f%z "$output") -lt $(stat -f%z "$file") ]; then  
        rm "$file"  
        echo "Converted: $file → $output"  
    else  
        rm "$output"  
        echo "Kept PNG: $file (WebP wasn't smaller)"  
    fi  
done

gradle中配置:

groovy 复制代码
// Enable WebP conversion in build.gradle  
android {  
    buildTypes {  
        release {  
            // Convert eligible drawables to WebP  
            crunchPngs = true  
        }  
    }  
}

效果:drawable 目录体积从 52 MB 降至 31 MB(节省 21 MB)


步骤 2:移除不必要的屏幕密度资源

我们此前打包了全系列密度的图片资源(mdpi、hdpi、xhdpi、xxhdpi、xxxhdpi)。数据分析显示:

  • 78% 的用户使用 xxhdpi 或 xhdpi 屏幕
  • 18% 的用户使用 hdpi 屏幕
  • 仅 4% 的用户使用其他密度屏幕

解决方案:仅打包 xxhdpi 密度的图片,由 Android 系统自动向下适配缩放

groovy 复制代码
android {  
    defaultConfig {  
        // *Limit densities in release builds*  
        resConfigs "xxhdpi", "xhdpi"  
    }  
}

步骤 3:使用矢量图(Vector Drawables)

图标和简单图形非常适合使用矢量图。

转换前(PNG):

bash 复制代码
res/  
├── drawable-mdpi/ic_home.png → 2 KB  
├── drawable-hdpi/ic_home.png → 4 KB  
├── drawable-xhdpi/ic_home.png → 6 KB  
├── drawable-xxhdpi/ic_home.png → 9 KB  
└── drawable-xxxhdpi/ic_home.png → 12 KB  
Total: 33 KB per icon ×* 85 icons* = 2.8 MB

转换后(矢量图):

xml 复制代码
<!-- res/drawable/ic_home.xml → 1.2 KB -->  
<vector xmlns:android="http://schemas.android.com/apk/res/android"  
    android:width="24dp"  
    android:height="24dp"  
    android:viewportWidth="24"  
    android:viewportHeight="24"
>  
    <path  
    android:fillColor="@color/icon_color"  
    android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>  
</vector>

转换脚本:

bash 复制代码
# *Convert PNG icons to vectors using svg2android*  
for file in res/drawable-*/ic_*.png; do  
    # Extract icon to SVG first (manual or using tools)  
    # Then convert to Android Vector  
    svg2android input.svg -o res/drawable/ic_name.xml  
done

效果:额外节省 2.3 MB


高分辨率营销横幅很少被查看,却一直打包在安装包中。

修改前:

kotlin 复制代码
// All banners bundled in APK  
class BannerView : View {  
    init {  
        setImageResource(R.drawable.banner_campaign_march)  
    }  
}

修改后:

kotlin 复制代码
class BannerView : View {  
    fun loadBanner(campaignId: String) {  
    // Download on-demand with caching  
    Glide.with(context)  
        .load("$CDN_BASE_URL/banners/$campaignId.webp")  
        .diskCacheStrategy(DiskCacheStrategy.ALL)  
        .into(imageView)  
    }  
}

效果:额外节省 5.7 MB,图片优化总计:共节省 38 MB(从 52 MB 降至 14 MB)


策略 2:原生库优化(节省 22 MB

原生库(.so 文件)占用了 38 MB。我们之前为所有 ABI 架构都打包了库文件。

步骤 1:ABI 拆分 我们使用 App Bundle 为不同架构分发独立的 APK。

优化前(整包 APK):

vbnet 复制代码
lib/  
├── armeabi-v7a/  
│ ├── libnative.so → 8 MB  
│ └── libthirdparty.so → 4 MB  
├── arm64-v8a/  
│ ├── libnative.so → 10 MB  
│ └── libthirdparty.so → 5 MB  
├── x86/  
│ ├── libnative.so → 6 MB  
│ └── libthirdparty.so → 3 MB  
└── x86_64/  
├── libnative.so → 7 MB  
└── libthirdparty.so → 4 MB  
Total: 47 MB (duplicated across ABIs)

优化后(App Bundle):

groovy 复制代码
// build.gradle  
android {  
    bundle {  
        abi {  
            enableSplit = true  
        }  
        density {  
            enableSplit = true  
        }  
        language {  
            enableSplit = true  
        }  
    }  
}

效果:用户只会下载对应自己设备架构的 ABI 版本,平均节省约 30 MB


步骤 2:移除未使用的原生库

我们对第三方库进行了排查,发现了多个未被使用的原生依赖库。

arduino 复制代码
// build.gradle  
android {  
    packagingOptions {  
        // Exclude unused native libraries  
        exclude 'lib/*/libcrashlytics.so' // Using Java version  
        exclude 'lib/*/libsqlite.so' // Using Room instead  
        exclude 'lib/*/libRSSupport.so' // Not using RenderScript  
    }  
}

效果:额外节省 4 MB


步骤 3:优化 ExoPlayer 原生库

我们之前打包了所有 ExoPlayer 扩展库,其中包含了并未使用的模块。

优化前:

arduino 复制代码
implementation 'com.google.android.exoplayer:exoplayer:2.x.x'

优化后:

arduino 复制代码
// Only include needed components  
implementation 'com.google.android.exoplayer:exoplayer-core:2.x.x'  
implementation 'com.google.android.exoplayer:exoplayer-dash:2.x.x'  
implementation 'com.google.android.exoplayer:exoplayer-hls:2.x.x'  
// Exclude unused  
// exoplayer-smoothstreaming (not used)  
// exoplayer-rtsp (not used)

效果:额外节省 3 MB, 原生库优化总计:共节省 22 MB(从 38 MB 降至 16 MB)

策略 3:资源文件优化(节省 18 MB)

assets 文件夹中包含了 24 MB 的教程视频、字体和 JSON 文件。

步骤 1:将教程视频迁移到 CDN

教程视频很少被观看,却每次都要随安装包一起下载。

优化前:

复制代码
assets/  
├── tutorial_1.mp4 → 8 MB  
├── tutorial_2.mp4 → 7 MB  
└── tutorial_3.mp4 → 6 MB

优化后:

kotlin 复制代码
object TutorialManager {  
    private const val CDN_BASE = "https://cdn.example.com/tutorials"  

    suspend fun downloadTutorial(tutorialId: Int): File {  
        val cacheFile = File(context.cacheDir, "tutorial_$tutorialId.mp4")  

        if (cacheFile.exists()) return cacheFile  

        // Download on-demand  
        val url = "$CDN_BASE/tutorial_$tutorialId.mp4"  
        downloadFile(url, cacheFile)  

        return cacheFile  
    }  
}

效果:节省 21 MB(视频从 APK 中移除)


步骤 2:优化字体文件 我们内置了 6 种字重的自定义字体,但实际只使用了 3 种。

优化前:

scss 复制代码
assets/fonts/  
├── CustomFont-Thin.ttf → 240 KB  
├── CustomFont-Light.ttf → 245 KB  
├── CustomFont-Regular.ttf → 250 KB  
├── CustomFont-Medium.ttf → 252 KB  
├── CustomFont-Bold.ttf → 258 KB  
└── CustomFont-Black.ttf → 260 KB  
Total: 1.5 MB

优化后:

scss 复制代码
assets/fonts/  
├── CustomFont-Regular.ttf → 250 KB  
├── CustomFont-Medium.ttf → 252 KB  
└── CustomFont-Bold.ttf → 258 KB  
Total: 760 KB

效果:节省 740 KB


步骤 3:压缩 JSON 配置文件

大型 JSON 配置文件可以进行压缩。

优化前:

kotlin 复制代码
// Reading uncompressed JSON  
val json = context.assets.open("config.json")  
    .bufferedReader()  
    .use { it.readText() }

优化后:

kotlin 复制代码
// Store as compressed GZIP  
val json = GZIPInputStream(context.assets.open("config.json.gz"))  
    .bufferedReader()  
    .use { it.readText() }
bash 复制代码
# Compress JSON files  
gzip -9 app/src/main/assets/config.json

效果:节省 1.2 MB(JSON 文件压缩率约 70%)

资源文件优化总计:共节省 18 MB(从 24 MB 降至 6 MB)


策略 4:代码优化(节省 4 MB)

尽管 DEX 文件仅占 12 MB,我们仍然找到了可优化的空间。

步骤 1:启用 R8 全量模式

R8 是 Android 的代码缩减与混淆工具,全量模式会进行更激进的优化。

ini 复制代码
// gradle.properties  
android.enableR8.fullMode=true
diff 复制代码
// proguard-rules.pro  
-allowaccessmodification  
-repackageclasses

节省了2.1MB


步骤 2:移除未使用的依赖库

我们对所有依赖项进行了排查,发现其中有多个已经不再使用。节省了1.8MB

arduino 复制代码
dependencies {  
implementation 'com.squareup.picasso:picasso:2.x.x' // Replaced by Glide  
implementation 'com.jakewharton:butterknife:10.x.x' // Using view binding now  
implementation 'com.google.code.gson:gson:2.x.x' // Using Moshi  
// ... 15 more unused dependencies  
}

步骤 3:使用 Android App Bundle 特性

App Bundle 支持功能模块按需分发

less 复制代码
// Create dynamic feature module for rarely-used features  
dynamicFeatures = [':premium_features']
kotlin 复制代码
// Load feature on-demand  
val splitInstallManager = SplitInstallManagerFactory.create(context)  
val request = SplitInstallRequest.newBuilder()  
    .addModule("premium_features")  
    .build()  
  
splitInstallManager.startInstall(request)

效果:将额外功能迁移至按需加载模块

代码优化总计:共节省 4 MB(从 12 MB 降至 8 MB)


策略 5:资源优化(节省 5 MB)

除图片外,我们还存在其他资源使用低效的问题。

步骤 1:移除未使用的资源

Android Lint 可以检测出未被使用的资源。节省2.3MB

bash 复制代码
# Run lint to find unused resources  
./gradlew lintRelease  
  
# Enable resource shrinking  
android {  
    buildTypes {  
        release {  
            shrinkResources true  
            minifyEnabled true  
        }  
    }  
}

步骤 2:本地化优化

我们支持了 40 种语言,但 85% 的用户仅使用其中 5 种语言。

解决方案:仅打包主流语言,其他语言支持按需下载。

arduino 复制代码
android {  
    defaultConfig {  
        // Keep only top 5 languages in base APK  
        resConfigs "en", "es", "pt", "de", "fr"  
    }  
}

实现语言包按需下载:

kotlin 复制代码
class LanguageManager(private val context: Context) {  
  
    suspend fun downloadLanguage(languageCode: String) {  
        val languageResources = downloadFromCDN("languages/$languageCode.xml")  
        installLanguageResources(languageResources)  
    }  
}

效果:节省 2.7 MB(保留 5 种语言,其余按需下载)

资源优化总计:节省 5 MB


优化结果

安装包体积减少 60% 最终 APK 构成(58 MB)

erlang 复制代码
Total APK Size: 58 MB (was 145 MB) → 60% reduction  
├── res/ (resources) → 14 MB (was 68 MB) → 79% reduction  
├── lib/ (native libs) → 16 MB (was 38 MB) → 58% reduction  
├── assets/ → 6 MB (was 24 MB) → 75% reduction  
├── classes.dex → 8 MB (was 12 MB) → 33% reduction  
└── other

我们使用的工具与脚本

apk分析脚本

bash 复制代码
#!/bin/bash  
# analyze_apk.sh - Generate detailed APK size report  
  
APK_PATH=$1  
OUTPUT_DIR="apk_analysis"  
mkdir -p $OUTPUT_DIR  
# Extract APK  
unzip -q $APK_PATH -d $OUTPUT_DIR/extracted  
# Analyze each component  
echo "Component Size Analysis" > $OUTPUT_DIR/report.txt  
echo "======================" >> $OUTPUT_DIR/report.txt  
du -sh $OUTPUT_DIR/extracted/res >> $OUTPUT_DIR/report.txt  
du -sh $OUTPUT_DIR/extracted/lib >> $OUTPUT_DIR/report.txt  
du -sh $OUTPUT_DIR/extracted/assets >> $OUTPUT_DIR/report.txt  
du -sh $OUTPUT_DIR/extracted/*.dex >> $OUTPUT_DIR/report.txt  
# Find largest files  
echo "\nTop 50 Largest Files:" >> $OUTPUT_DIR/report.txt  
find $OUTPUT_DIR/extracted -type f -exec du -h {} + | \  
sort -rh | head -50 >> $OUTPUT_DIR/report.txt  
cat $OUTPUT_DIR/report.txt

图片优化插件

kotlin 复制代码
/ ImageOptimizationPlugin.kt  
class ImageOptimizationPlugin : Plugin<Project> {  
    override fun apply(project: Project) {  
        project.afterEvaluate {  
            project.tasks.register("optimizeImages") {  
                doLast {  
                    val resDir = File(project.projectDir, "src/main/res")  
                    resDir.walk()  
                    .filter { it.extension == "png" }  
                    .forEach { pngFile ->  
                        optimizePngToWebP(pngFile)  
                    }  
                }  
            }  
        }  
    }  

    private fun optimizePngToWebP(pngFile: File) {  
        val webpFile = File(pngFile.parent, "${pngFile.nameWithoutExtension}.webp")  

        // Convert using cwebp  
        val process = Runtime.getRuntime().exec(  
        arrayOf("cwebp", "-q", "80", pngFile.absolutePath, "-o", webpFile.absolutePath)  
        )  
        process.waitFor()  

        // Keep smaller file  
        if (webpFile.length() < pngFile.length()) {  
            pngFile.delete()  
            println("Converted: ${pngFile.name} → ${webpFile.name} " +  
            "(${formatBytes(pngFile.length() - webpFile.length())} saved)")  
        } else {  
            webpFile.delete()  
        }  
    }  
}

依赖项统计工具

kotlin 复制代码
// DependencyAnalyzer.kt  
object DependencyAnalyzer {  

    fun analyzeDependencies(project: Project): Map<String, Long> {  
        val dependencySizes = mutableMapOf<String, Long>()  

        project.configurations  
            .getByName("releaseRuntimeClasspath")  
            .resolvedConfiguration  
            .resolvedArtifacts  
            .forEach { artifact ->  
                val size = artifact.file.length()  
                dependencySizes[artifact.name] = size  
            }  

        return dependencySizes.toList()  
        .sortedByDescending { it.second }  
        .toMap()  
    }  

    fun printReport(dependencies: Map<String, Long>) {  
        println("Dependency Size Report")  
        println("=====================")  
        dependencies.forEach { (name, size) ->  
            println("${name.padEnd(50)} ${formatBytes(size)}")  
        }  
        println("\nTotal: ${formatBytes(dependencies.values.sum())}")  
    }  
}

包大小监控

groovy 复制代码
// Track APK size in CI/CD  
task trackApkSize {  
    doLast {  
        def apkFile = file("${buildDir}/outputs/apk/release/app-release.apk")  
        def sizeMB = apkFile.length() / (1024 * 1024)  

        println "APK Size: ${sizeMB.round(2)} MB"  

        // Fail if size exceeds threshold  
        if (sizeMB > 70) {  
            throw new GradleException("APK size (${sizeMB}MB) exceeds 70MB threshold!")  
        }  

        // Log to CI system  
        println "##vso[task.setvariable variable=APK_SIZE]${sizeMB}"  
    }  
}  
  
// Run after build  
assembleRelease.finalizedBy trackApkSize

体积监控面板我们搭建了一个面板,用于长期跟踪 APK 体积变化:

kotlin 复制代码
data class ApkSizeMetrics(  
    val version: String,  
    val totalSize: Long,  
    val resourceSize: Long,  
    val nativeLibSize: Long,  
    val dexSize: Long,  
    val assetSize: Long,  
    val timestamp: Long  
)  
  
class ApkSizeTracker {  
    fun trackRelease(apkFile: File, version: String) {  
        val metrics = analyzeApk(apkFile)  

        // Send to analytics  
        analytics.logEvent("apk_released") {  
            param("version", version)  
            param("total_size_mb", metrics.totalSize / 1_048_576.0)  
            param("resource_size_mb", metrics.resourceSize / 1_048_576.0)  
        }  

        // Alert if size increased significantly  
        val previousSize = getPreviousReleaseSize()  
        val increase = metrics.totalSize - previousSize  
        val percentIncrease = (increase.toDouble() / previousSize) * 100  

        if (percentIncrease > 5.0) {  
            alertTeam("APK size increased by ${percentIncrease.round(1)}%!")  
        }  
    }  
}

CI/CD 中的自动化检查

yaml 复制代码
# .github/workflows/size-check.yml

name: APK Size Check

  


on: [pull_request]

jobs:

  check-size:

    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v2

      

      - name: Build APK

        run: ./gradlew assembleRelease

      

      - name: Analyze Size

        run: |

          SIZE=$(stat -f%z app/build/outputs/apk/release/app-release.apk)

          SIZE_MB=$((SIZE / 1048576))

          echo "APK Size: ${SIZE_MB}MB"

          

          # Compare with base branch

          BASE_SIZE=58

          if [ $SIZE_MB -gt $((BASE_SIZE + 5)) ]; then

            echo "::error::APK size increased by more than 5MB!"

            exit 1

          fi

      

      - name: Comment PR

        uses: actions/github-script@v5

        with:

          script: |

            github.rest.issues.createComment({

              issue_number: context.issue.number,

              owner: context.repo.owner,

              repo: context.repo.repo,

              body: `📊 APK Size: ${process.env.SIZE_MB}MB`

            })

核心要点

  • 应用体积直接影响用户获取 ------每增加 6MB,安装量约下降 1%
  • 图片通常是体积过大的元凶 ------ 从图片优化入手,见效最快
  • App Bundle 必不可少,应作为所有新应用的默认分发方式
  • 资源按需加载效果显著 ------ 用户更偏爱更快的安装速度
  • 自动化体积监控 ------ 在 CI/CD 中防止应用体积 "膨胀"
  • 在低端设备上测试 ------ 这类用户对体积最敏感
  • 定期审计至关重要 ------ 每季度安排一次依赖库审查
相关推荐
千里马学框架1 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台1 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone1 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc1 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo2 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077002 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼2 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone2 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen2 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone2 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui