我们如何在不减少功能的前提下,将安卓应用体积缩减 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 中防止应用体积 "膨胀"
  • 在低端设备上测试 ------ 这类用户对体积最敏感
  • 定期审计至关重要 ------ 每季度安排一次依赖库审查
相关推荐
QING6182 小时前
使用ADB分析CPU性能 —— 基础指南
android·前端·app
大白要努力!2 小时前
Android图片预览功能实战:从需求到上线的完整方案
android·viewpager·图片预览·实战记录·photoview
如此风景5 小时前
kotlin协程学习小计
android·kotlin
轩情吖5 小时前
MySQL初识
android·数据库·sql·mysql·adb·存储引擎
Sun_gentle6 小时前
android studio创建flutter项目
android·flutter·android studio
我命由我123456 小时前
在 Android Studio 中,新建 AIDL 文件按钮是灰色
android·ide·android studio·安卓·android jetpack·android-studio·android runtime
音视频牛哥6 小时前
Android平台RTMP/RTSP超低延迟直播播放器开发详解——基于SmartMediaKit深度实践
android·人工智能·计算机视觉·音视频·rtmp播放器·安卓rtmp播放器·rtmp直播播放器
麻瓜生活睁不开眼6 小时前
Android 14 开机自启动第三方 APK 全流程踩坑与最终解决方案(含 RescueParty 避坑)
android·java·深度学习