MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Kotlin App Bundle优化包体积方案

2022-06-164.6k 阅读

一、Kotlin App Bundle 简介

Kotlin App Bundle 是 Google 推出的一种发布格式,它允许开发者将应用的所有编译版本打包成一个单一的文件。与传统的 APK 相比,App Bundle 包含了应用的所有代码和资源,但并不会将它们预先打包成特定设备的 APK。相反,Google Play 会根据用户设备的配置(如屏幕密度、CPU 架构等),从 App Bundle 中提取并生成最优的 APK 进行下载安装。这种方式大大减少了用户下载的 APK 体积,提高了应用的安装成功率和更新速度。

二、包体积增大的常见原因

  1. 资源文件冗余
    • 图片资源:在开发过程中,为了适配不同分辨率的设备,我们通常会提供多个不同密度的图片资源,如 drawable - ldpidrawable - mdpidrawable - hdpi 等。然而,如果不进行合理管理,可能会出现部分资源在某些设备上永远不会被使用,但却依然包含在包中的情况。例如,对于只支持高清及以上屏幕的应用,drawable - ldpidrawable - mdpi 中的图片资源就属于冗余资源。
    • 字符串资源:多语言支持也是导致资源文件增大的一个因素。如果应用支持多种语言,res/values - xx 目录下会有大量的字符串文件。有时可能会在不同语言目录中重复定义一些通用字符串,或者包含一些永远不会在特定语言环境下使用的字符串。
  2. 代码冗余
    • 未使用的代码:随着项目的不断迭代,可能会遗留一些未使用的代码,如不再调用的函数、废弃的类等。这些代码虽然不会影响应用的正常运行,但却会增加包体积。例如,某个功能模块被新的实现方式替代,但旧的实现代码没有及时清理。
    • 依赖库引入过多:为了实现各种功能,我们会引入大量的依赖库。然而,有些依赖库可能包含了许多我们并不需要的功能和代码。比如,引入一个通用的网络请求库,但其可能包含了对多种协议和复杂缓存策略的支持,而我们的应用只使用了简单的 HTTP 请求功能。
  3. 编译配置不合理
    • 构建类型:不同的构建类型(如 debugrelease)配置不同。如果在 release 构建类型中没有进行优化配置,可能会导致包体积增大。例如,debug 构建类型通常会包含更多的日志输出代码和调试信息,在 release 构建时如果没有去除这些代码,就会增加包体积。
    • 混淆配置不当:混淆是一种优化代码的技术,它通过缩短类名、方法名等方式来减小代码体积。如果混淆配置不当,可能无法充分发挥混淆的作用,甚至会因为错误的混淆导致应用出现运行时错误。例如,没有正确配置保留某些需要反射调用的类或方法,使得它们在混淆后无法正常使用,同时又不能被混淆以减小体积。

三、优化图片资源

  1. 压缩图片
    • 使用工具压缩:可以使用各种图片压缩工具,如 TinyPNG、ImageOptim 等。这些工具可以在不明显损失图片质量的前提下,大幅减小图片文件的大小。在 Kotlin 项目中,可以通过脚本集成这些工具。例如,使用 Gradle 插件 gradle - plugin - tinypng,在 build.gradle 文件中添加如下配置:
plugins {
    id 'com.tinify.tinify - plugin' version '1.7.0'
}
tinify {
    key = 'YOUR_API_KEY'
    input = fileTree(dir: 'src/main/res/drawable', include: ['*.png', '*.jpg'])
    output = file('src/main/res/drawable - optimized')
    overwrite = true
}
  • 代码中压缩:在加载图片时,也可以在代码中进行压缩。Kotlin 中可以使用 Android 的 BitmapFactory 类来实现。例如:
fun decodeSampledBitmapFromResource(res: Resources, resId: Int, reqWidth: Int, reqHeight: Int): Bitmap? {
    val options = BitmapFactory.Options()
    options.inJustDecodeBounds = true
    BitmapFactory.decodeResource(res, resId, options)
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
    options.inJustDecodeBounds = false
    return BitmapFactory.decodeResource(res, resId, options)
}
private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
    val (height: Int, width: Int) = options.run { outHeight to outWidth }
    var inSampleSize = 1
    if (height > reqHeight || width > reqWidth) {
        val halfHeight: Int = height / 2
        val halfWidth: Int = width / 2
        while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
            inSampleSize *= 2
        }
    }
    return inSampleSize
}
  1. 选择合适的图片格式
    • WebP 格式:WebP 是一种现代的图片格式,它在提供与 JPEG、PNG 相当的图片质量的同时,文件大小通常更小。Android 从 Android 4.0(API 级别 14)开始支持 WebP 格式。在 Kotlin 项目中,可以将图片转换为 WebP 格式。例如,在 res/drawable 目录下添加 WebP 格式的图片,Android 系统会自动根据设备支持情况选择加载 WebP 图片。如果设备不支持 WebP,会回退到加载其他格式(如 PNG)的图片。可以通过以下方式在代码中判断设备是否支持 WebP:
fun isWebPSupported(): Boolean {
    val canDecodeWebP = try {
        val clazz = Class.forName("android.graphics.ImageDecoder")
        val method = clazz.getMethod("isFormatSupported", FileDescriptor::class.java)
        method.invoke(null, FileDescriptor()) as Boolean
    } catch (e: Exception) {
        false
    }
    return canDecodeWebP
}
  • 矢量图:对于一些简单的图标和图形,使用矢量图(如 SVG)是更好的选择。矢量图可以无损缩放,不会因为分辨率的变化而失真,并且文件大小通常比位图小得多。在 Android 中,可以使用 VectorDrawable 来支持矢量图。在 res/drawable 目录下创建一个 XML 文件,如 ic_vector.xml
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:viewportWidth="24.0"
    android:viewportHeight="24.0">
    <path
        android:fillColor="#FF0000"
        android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10 -4.48 10 -10S17.52,2 12,2zm0,18c-4.41,0 -8 -3.59 -8 -8s3.59 -8 8 -8 8,3.59 8,8 -3.59,8 -8,8zm0 -14c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4 -1.79 4 -4 -1.79 -4 -4 -4zm4.24,12.24l-1.41,1.41 -3.54 -3.54 -1.41,1.41 3.54,3.54 -3.54,3.54 1.41,1.41 3.54 -3.54 3.54,3.54 1.41 -1.41 -3.54 -3.54 3.54 -3.54z"/>
</vector>

然后在布局文件中使用:

<ImageView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:src="@drawable/ic_vector"/>
  1. 移除冗余图片资源
    • 分析工具:使用 Android Profiler 中的 APK Analyzer 可以查看 APK 中包含的资源文件,并分析哪些资源是冗余的。在 Android Studio 中,打开 Build -> Analyze APK,选择生成的 APK 文件,APK Analyzer 会展示 APK 的内容结构。通过查看不同密度的图片资源目录,可以发现哪些图片在某些设备上不会被使用。例如,如果发现 drawable - ldpi 目录中的图片在应用实际运行中从未被加载,可以考虑移除这些图片。
    • 自动化脚本:可以编写自动化脚本在构建过程中移除冗余资源。例如,使用 Python 编写一个脚本来扫描 res 目录,根据项目的设备支持范围,删除不需要的图片资源目录。以下是一个简单的示例脚本:
import os
import shutil

# 定义支持的屏幕密度
supported_densities = ['hdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi']

res_dir ='src/main/res'
for root, dirs, files in os.walk(res_dir):
    for dir in dirs:
        if dir.startswith('drawable - ') and dir.split('-')[-1] not in supported_densities:
            dir_path = os.path.join(root, dir)
            shutil.rmtree(dir_path)

四、优化代码

  1. 清理未使用的代码
    • 使用 Android Studio 自带工具:Android Studio 提供了查找未使用代码的功能。在项目导航栏中,选择 Analyze -> Run Inspection by Name,然后在弹出的对话框中输入 Unused declaration,点击 OK。Android Studio 会分析项目中的代码,并标记出未使用的函数、类、变量等。例如,对于以下代码:
class UnusedClass {
    fun unusedFunction() {
        // 此函数从未被调用
    }
}

Android Studio 会将 UnusedClassunusedFunction 标记为未使用,可以根据实际情况进行删除。

  • 使用反射相关工具:对于一些通过反射调用的代码,手动查找未使用代码可能比较困难。可以使用工具如 ReflectInsight 来分析反射调用的情况,从而确定哪些代码实际上是未使用的。该工具可以扫描项目中的字节码,分析反射调用的目标类和方法,帮助开发者清理那些看似未使用但实际上可能通过反射被调用的代码。
  1. 优化依赖库
    • 检查依赖树:使用 Gradle 的 dependencyInsight 命令可以查看项目的依赖树,了解每个依赖库及其传递依赖。在项目根目录下的终端中执行 ./gradlew dependencyInsight --dependency <dependency - name>,例如 ./gradlew dependencyInsight --dependency okhttp,可以查看 okhttp 及其传递依赖的详细信息。通过分析依赖树,可以发现一些不必要的依赖。例如,如果某个依赖库引入了一个大的通用库,但我们只需要其中的一小部分功能,可以尝试寻找更轻量级的替代库。
    • 排除传递依赖:有时一个依赖库会引入一些我们不需要的传递依赖。可以在 build.gradle 文件中使用 exclude 关键字来排除这些传递依赖。例如:
implementation('com.example.library:library - core:1.0.0') {
    exclude group: 'com.unwanted.dependency', module: 'unwanted - module'
}
  1. 代码混淆
    • 启用混淆:在 Kotlin 项目中,默认情况下,release 构建类型是启用混淆的。在 build.gradle 文件中,可以看到如下配置:
buildTypes {
    release {
        minifyEnabled true
        proguardFiles getDefaultProguardFile('proguard - android - optimize.txt'), 'proguard - rules.pro'
    }
}

minifyEnabled true 表示启用混淆,proguardFiles 指定了混淆规则文件。proguard - android - optimize.txt 是 Android 提供的默认混淆规则文件,proguard - rules.pro 是我们可以自定义混淆规则的文件。

  • 编写混淆规则:对于一些特殊情况,如使用反射、JNI 或者某些第三方库需要保留特定的类和方法,需要编写自定义混淆规则。例如,如果应用使用了 Gson 库进行 JSON 解析,并且在代码中使用反射来创建对象,需要保留 Gson 相关的类和方法。在 proguard - rules.pro 文件中添加如下规则:
-keep class com.google.gson.** { *; }

对于 JNI 方法,需要保留 JNI 相关的类和方法:

-keepclasseswithmembernames class * {
    native <methods>;
}

五、优化资源配置

  1. 多语言资源优化
    • 按需加载字符串资源:对于多语言支持,可以采用按需加载的方式。例如,在应用启动时,根据用户的系统语言设置,动态加载相应的字符串资源。可以通过创建一个 StringResourceLoader 类来实现:
class StringResourceLoader(private val context: Context) {
    private lateinit var resources: Resources
    fun loadStrings(locale: Locale) {
        val config = Configuration(context.resources.configuration)
        config.setLocale(locale)
        resources = context.createConfigurationContext(config).resources
    }
    fun getString(resId: Int): String {
        return resources.getString(resId)
    }
}

在应用启动时调用 loadStrings 方法加载所需语言的字符串资源,然后在需要获取字符串的地方调用 getString 方法。

  • 合并通用字符串:在不同语言的字符串资源文件中,可能存在一些通用的字符串。可以将这些通用字符串提取到一个公共的 strings.xml 文件中,然后在其他语言的字符串文件中通过 translatable="false" 来引用。例如,在 res/values/strings.xml 中定义:
<string name="app_name">MyApp</string>

res/values - fr/strings.xml 中:

<string name="app_name" translatable="false">@string/app_name</string>
  1. 移除无用的资源文件
    • 查找无用资源:使用 Android Studio 的 Analyze -> Run Inspection by Name,输入 Unused resources,可以查找项目中未使用的资源文件,如布局文件、样式文件等。例如,如果有一个布局文件 unused_layout.xml 从未在任何地方被引用,可以将其删除。
    • 自动化移除:可以编写 Gradle 插件来自动化移除无用资源。通过自定义 Gradle 任务,在构建过程中扫描项目资源,删除未使用的资源文件。以下是一个简单的 Gradle 任务示例:
task removeUnusedResources(type: Delete) {
    def unusedResources = []
    // 扫描资源文件,判断是否使用,这里简化示例,实际需要更复杂逻辑
    fileTree(dir:'src/main/res', include: ['**/*.xml']).forEach { file ->
        if (!isResourceUsed(file)) {
            unusedResources.add(file)
        }
    }
    delete unusedResources
}
def isResourceUsed(file) {
    // 实际判断逻辑,例如检查是否在代码或其他布局中引用
    return false
}

六、其他优化措施

  1. 使用动态加载
    • 插件化技术:通过插件化技术,可以将应用的部分功能模块以插件的形式动态加载。例如,应用的一些非核心功能,如广告模块、第三方登录模块等,可以做成插件。在 Kotlin 中,可以使用一些插件化框架,如 VirtualAPK。首先,在主项目的 build.gradle 文件中添加依赖:
implementation 'com.didi.virtualapk:virtualapk - core:0.9.1'

然后创建插件项目,在插件项目的 build.gradle 文件中配置为插件类型:

apply plugin: 'com.didi.virtualapk.plugin'

在主项目中,通过 PluginManager 来加载插件:

val pluginPath = Environment.getExternalStorageDirectory().absolutePath + "/plugin.apk"
val plugin = PluginManager.getInstance(this).loadPlugin(pluginPath)
val activityInfo = plugin.getActivityInfo("com.example.plugin.PluginActivity")
startActivity(Intent(this, activityInfo.name))

这样,插件在不需要时不会占用应用的包体积,只有在需要时才会被下载和加载。

  • 动态 Feature 模块:Android 支持动态 Feature 模块,这些模块可以在应用安装后按需下载。在 Kotlin 项目中,可以创建动态 Feature 模块。在 settings.gradle 文件中添加:
include ':feature - module'
project(':feature - module').projectDir = new File(settingsDir, '../feature - module')

feature - modulebuild.gradle 文件中配置为动态 Feature 模块:

apply plugin: 'com.android.feature'
android {
    // 配置模块相关信息
}

在主应用中,可以通过 PlayCore 库来下载和安装动态 Feature 模块:

val appUpdateManager = AppUpdateManagerFactory.create(this)
val appUpdateInfoTask = appUpdateManager.appUpdateInfo
appUpdateInfoTask.addOnSuccessListener { appUpdateInfo ->
    if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE
        && appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)) {
        appUpdateManager.startUpdateFlowForResult(
            appUpdateInfo,
            AppUpdateType.FLEXIBLE,
            this,
            UPDATE_REQUEST_CODE
        )
    }
}
  1. 优化编译配置
    • 构建变体配置:根据不同的构建变体(如 releasedebug)进行不同的配置。在 build.gradle 文件中,可以对 release 变体进行更多的优化,如关闭调试信息、启用更多的压缩等。例如:
buildTypes {
    release {
        minifyEnabled true
        shrinkResources true
        debuggable false
        signingConfig signingConfigs.release
    }
}

shrinkResources true 表示启用资源收缩,会移除未使用的资源。debuggable false 关闭调试功能,减小包体积。

  • Gradle 缓存优化:Gradle 会缓存依赖库和构建结果等信息。合理配置 Gradle 缓存可以提高构建速度和减小项目占用空间。可以在 gradle.properties 文件中配置:
org.gradle.caching=true
org.gradle.cache.directory=~/.gradle/caches

org.gradle.caching=true 启用 Gradle 缓存,org.gradle.cache.directory 指定缓存目录。同时,可以定期清理 Gradle 缓存,在项目根目录下执行 ./gradlew cleanBuildCache 命令来清理缓存。

通过以上这些针对 Kotlin App Bundle 的包体积优化方案,可以显著减小应用的下载和安装包体积,提高应用的性能和用户体验。在实际项目中,需要综合运用这些方法,并根据项目的具体情况进行调整和优化。