新兴市场的用户常常在下载过程中放弃安装。我们的安装完成率已降至 68% 。Google Play 商店的数据显示,APK 体积每增加 6 MB,安装量就会下降 1%。面对数百万潜在用户,这些百分比直接转化为实实在在的用户流失与收入损失。
在进行任何优化之前,我们必须先搞清楚空间都被什么占用了。我们使用 Android Studio 自带的 APK 分析器 对应用进行了拆解:
初始 APK 体积构成(145 MB)
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 拥有更好的压缩率。
转换前:
res/drawable-xxhdpi/
├── splash_background.png → 2.8 MB
├── hero_banner.png → 1.9 MB
├── onboarding_1.png → 1.5 MB
└── ...
转换后:
res/drawable-xxhdpi/
├── splash_background.webp → 420 KB (85% reduction)
├── hero_banner.webp → 380 KB (80% reduction)
├── onboarding_1.webp → 290 KB (81% reduction)
└── ...
优化脚本:
# 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中配置:
// 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 系统自动向下适配缩放
android {
defaultConfig {
// *Limit densities in release builds*
resConfigs "xxhdpi", "xhdpi"
}
}
步骤 3:使用矢量图(Vector Drawables)
图标和简单图形非常适合使用矢量图。
转换前(PNG):
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
转换后(矢量图):
<!-- 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>
转换脚本:
# *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
高分辨率营销横幅很少被查看,却一直打包在安装包中。
修改前:
// All banners bundled in APK
class BannerView : View {
init {
setImageResource(R.drawable.banner_campaign_march)
}
}
修改后:
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):
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):
// build.gradle
android {
bundle {
abi {
enableSplit = true
}
density {
enableSplit = true
}
language {
enableSplit = true
}
}
}
效果:用户只会下载对应自己设备架构的 ABI 版本,平均节省约 30 MB
步骤 2:移除未使用的原生库
我们对第三方库进行了排查,发现了多个未被使用的原生依赖库。
// 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 扩展库,其中包含了并未使用的模块。
优化前:
implementation 'com.google.android.exoplayer:exoplayer:2.x.x'
优化后:
// 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
优化后:
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 种。
优化前:
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
优化后:
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 配置文件可以进行压缩。
优化前:
// Reading uncompressed JSON
val json = context.assets.open("config.json")
.bufferedReader()
.use { it.readText() }
优化后:
// Store as compressed GZIP
val json = GZIPInputStream(context.assets.open("config.json.gz"))
.bufferedReader()
.use { it.readText() }
# 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 的代码缩减与混淆工具,全量模式会进行更激进的优化。
// gradle.properties
android.enableR8.fullMode=true
// proguard-rules.pro
-allowaccessmodification
-repackageclasses
节省了2.1MB
步骤 2:移除未使用的依赖库
我们对所有依赖项进行了排查,发现其中有多个已经不再使用。节省了1.8MB
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 支持功能模块按需分发。
// Create dynamic feature module for rarely-used features
dynamicFeatures = [':premium_features']
// 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
# Run lint to find unused resources
./gradlew lintRelease
# Enable resource shrinking
android {
buildTypes {
release {
shrinkResources true
minifyEnabled true
}
}
}
步骤 2:本地化优化
我们支持了 40 种语言,但 85% 的用户仅使用其中 5 种语言。
解决方案:仅打包主流语言,其他语言支持按需下载。
android {
defaultConfig {
// Keep only top 5 languages in base APK
resConfigs "en", "es", "pt", "de", "fr"
}
}
实现语言包按需下载:
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)
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分析脚本
#!/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
图片优化插件
/ 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()
}
}
}
依赖项统计工具
// 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())}")
}
}
包大小监控
// 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 体积变化:
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 中的自动化检查
# .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 中防止应用体积 “膨胀”
- 在低端设备上测试 —— 这类用户对体积最敏感
- 定期审计至关重要 —— 每季度安排一次依赖库审查