引言 Link to heading

ProGuard 是 Java 虚拟机(JVM)生态中用于代码优化、缩减和混淆的重要工具。它通过移除未使用的代码、优化字节码以及重命名类、字段和方法,来减小应用程序的体积并增加代码反编译的难度。在配置 ProGuard 进行发布构建时,尤其是在采用较新版本的 Java 和 Kotlin 时,可能会遇到特定的兼容性和配置问题。

本文记录了在一个使用 Compose Multiplatform (目标平台:Desktop)、Java 21Kotlin 2.1.20 的项目中,配置 ProGuard runRelease 任务时遇到的问题及其解决方案。

项目背景 Link to heading

  • 框架: Compose Multiplatform (Desktop)
  • 环境: Java 21, Kotlin 2.1.20
  • 构建任务: Gradle runRelease (启用 ProGuard)
  • 目标: 生成经过 ProGuard 处理的优化发布版本

在执行 gradlew runRelease 命令进行发布构建时,遇到了以下几个问题。

问题 1:ProGuard 版本与 Java 21 / Kotlin 2.1.20 不兼容 Link to heading

现象: 首次执行 runRelease 时,构建失败。ProGuard 报告错误,明确指出其内置版本不支持项目所使用的 Java 21。

分析与解决: 经查证,ProGuard 对较新的 Java 和 Kotlin 版本有特定的最低版本要求:

  • Java 21 字节码支持需要 ProGuard 7.4 或更高版本。
  • Kotlin 2.0 及更高版本(包括 2.1.20)生成的字节码(特别是 K2 编译器相关的特性)需要 ProGuard 7.5 或更高版本才能正确处理。

在遇到这个问题时,ProGuard 的最新稳定版本是 7.7.0。该版本同时满足了对 Java 21 和 Kotlin 2.1.20 的兼容性要求。因此,最直接且推荐的解决方案是升级到当时最新的 7.7.0 版本。

build.gradle.kts 文件中明确指定 ProGuard 版本:

// build.gradle.kts
compose.desktop {
    application {
        buildTypes {
            release {
                proguard {
                    // 指定支持 Java 21 和 Kotlin 2.1.20 的 ProGuard 版本
                    // 7.7.0 是当时最新的稳定版,满足 7.4+ (for Java 21) 和 7.5+ (for Kotlin 2.x) 的要求
                    version.set("7.7.0")
                }
            }
        }
    }
}

更新 ProGuard 版本后,基础的 Java 和 Kotlin 版本兼容性问题得以解决。

问题 2:处理 META-INF/MANIFEST.MF 重复资源警告 Link to heading

现象: 升级到 ProGuard 7.7.0 后,构建过程中出现关于 META-INF/MANIFEST.MF 资源文件重复的警告 (Note): Note: duplicate definition of resource file [META-INF/MANIFEST.MF]

分析与解决

  • 什么是 META-INF/MANIFEST.MF? 这个文件是 Java Archive (JAR) 文件中的标准元数据文件。它包含了关于该 JAR 包的信息,例如版本号、创建者、以及(对于可执行 JAR)主类 (Main-Class) 和类路径 (Class-Path) 等关键信息。
  • 为什么会重复? 当项目依赖多个库(JAR 文件)时,每个库通常都包含其自身的 META-INF/MANIFEST.MF 文件。构建工具(如 Gradle)和 ProGuard 在打包最终应用程序时,会将所有依赖项的内容合并。由于 META-INF/MANIFEST.MF 是一个标准路径下的标准文件,自然就会出现来自不同依赖项的同名文件冲突。
  • 为什么忽略通常是安全的 (对于桌面应用)? ProGuard 在遇到重复资源时,默认会保留第一个遇到的版本或根据特定策略合并,并发出警告。对于桌面应用,运行时环境(JVM)主要关心的是最终打包的可执行 JAR 的主 MANIFEST.MF 文件,用于确定入口点 (Main-Class) 等。来自依赖库的 MANIFEST.MF 文件中的大部分元数据(如库自身的版本信息)对应用程序的 实际执行逻辑 通常没有影响。因此,忽略关于依赖库 MANIFEST.MF 文件重复的警告,让 ProGuard 选择其中一个保留或丢弃,一般不会破坏应用程序的核心功能。

为抑制该警告,在项目根目录下创建 proguard-rules.pro 文件,并添加 -ignorewarnings 指令:

# proguard-rules.pro
-ignorewarnings

随后,在 build.gradle.kts 中配置 ProGuard 使用此规则文件:

// build.gradle.kts
compose.desktop {
    application {
        buildTypes {
            release {
                proguard {
                    configurationFiles.from(file("proguard-rules.pro")) // 应用规则文件
                    version.set("7.7.0")
                }
            }
        }
    }
}

配置后,资源重复警告不再干扰构建过程。

问题 3:ProGuard 优化导致的运行时 VerifyError Link to heading

现象: 解决了资源重复警告后,应用程序可以成功编译,但在启动时发生运行时错误。

分析与解决: 初步推测问题可能由 ProGuard 的代码缩减(shrinking)或优化(optimization)引起。通过在 proguard-rules.pro 中临时添加 -dontshrink-dontoptimize 进行测试:

# proguard-rules.pro (临时调试配置)
-ignorewarnings
-dontshrink     # 禁用代码缩减
-dontoptimize   # 禁用代码优化

在此配置下,应用程序可以正常运行,确认问题与 ProGuard 的处理过程相关。进一步测试发现,仅保留 -dontoptimize 时应用程序仍可运行,表明 代码优化(optimization) 是导致运行时失败的关键环节。

移除 -dontoptimize 以暴露具体错误。在运行时,JVM 在加载类之前会进行字节码验证 (Bytecode Verification),这是一个安全机制,用于确保加载的类文件符合 JVM 规范,不会执行非法操作(如访问私有成员、破坏类型安全、造成操作数栈溢出/下溢等)。如果验证失败,JVM 会抛出 VerifyError

此时,应用程序抛出以下异常:

Exception in thread "DefaultDispatcher-worker-1" java.lang.VerifyError: Bad type on operand stack
Type 'kotlin/jvm/functions/Function1' (current frame, stack[3]) is not assignable to 'kotlinx/coroutines/JobKt__JobKt$invokeOnCompletion$1'
  • 为什么会出现 VerifyError? VerifyError 表明 JVM 的字节码验证器检测到了无效或不一致的字节码。
  • 为什么 ProGuard 优化会导致此错误? ProGuard 的优化过程(如方法内联、代码重排、类型推断优化等)会直接修改类的字节码。虽然目标是提高效率,但对于复杂的代码结构(例如 Kotlin 协程生成的状态机、Lambda 表达式编译后的辅助类、或大量使用泛型和反射的代码),激进的优化有时可能产生不符合 JVM 严格规范的字节码序列。例如,它可能错误地改变了操作数栈上的类型,导致像上面错误信息所示的“操作数栈上的类型错误 (Bad type on operand stack)”,即栈顶元素的类型与指令期望的类型不匹配。这种不匹配破坏了类型安全,因此被验证器拒绝。

解决方案:为确保 ProGuard 不会错误地修改关键库的代码,需要配置 -keep 规则来保护相关类和成员。更新 proguard-rules.pro 文件,添加以下规则:

# proguard-rules.pro (最终配置)
-ignorewarnings

# 保留 kotlinx 和 androidx 包下的所有类及其成员,防止被移除或修改
# 这些库常包含复杂的字节码结构(协程、Compose UI)和反射使用
-keep class kotlinx.** { *; }
-keep class androidx.** { *; }

# 保留注解信息,许多现代框架(包括 Compose)依赖注解进行元编程或运行时处理
-keepattributes *Annotation*

# 保留泛型签名信息,避免因类型擦除导致反射或类型检查失败
-keepattributes Signature

# 显式保留 kotlinx 和 androidx 包内类的公共和受保护方法,确保反射调用不受影响
# 这有助于防止因方法签名改变或移除导致的 NoSuchMethodError 或 VerifyError
-keepclassmembers class kotlinx.** {
    public <methods>;
    protected <methods>;
}
-keepclassmembers class androidx.** {
    public <methods>;
    protected <methods>;
}

这些规则确保:

  1. kotlinx (包含协程库) 和 androidx (包含 Compose 库) 包中的类不会被 ProGuard 移除或混淆其内部结构。
  2. 重要的元数据(注解、泛型签名)得以保留。
  3. 这些库中可能通过反射调用的公共和受保护方法保持可访问性及其原始签名。

应用这些规则后,无需禁用优化 (-dontoptimize) 或缩减 (-dontshrink),应用程序即可在经过 ProGuard 处理后正确编译和运行。

最终配置概要 Link to heading

  • build.gradle.kts:
    compose.desktop {
        application {
            buildTypes {
                release {
                    proguard {
                        configurationFiles.from(file("proguard-rules.pro"))
                        version.set("7.7.0") // 确保使用兼容的 ProGuard 版本
                    }
                }
            }
        }
    }
    
  • proguard-rules.pro:
    # proguard-rules.pro
    -ignorewarnings
    
    # 保持 kotlinx 和 androidx 库的完整性
    -keep class kotlinx.** { *; }
    -keep class androidx.** { *; }
    
    # 保留必要的元数据属性
    -keepattributes *Annotation*
    -keepattributes Signature
    
    # 保护关键库中可能被反射调用的方法
    -keepclassmembers class kotlinx.** {
        public <methods>;
        protected <methods>;
    }
    -keepclassmembers class androidx.** {
        public <methods>;
        protected <methods>;
    }
    

总结与关键配置点 Link to heading

  1. ProGuard 版本:针对 Java 21 和 Kotlin 2.1.20,需确认 ProGuard 版本满足最低要求(Java 21 >= 7.4, Kotlin 2.x >= 7.5)。使用当时的最新稳定版(如 7.7.0)通常是最佳实践。
  2. 资源重复警告 (MANIFEST.MF):理解其来源(多依赖项包含标准文件)和忽略的安全性(依赖项的 manifest 对主程序执行逻辑影响小),使用 -ignorewarnings 处理。
  3. 运行时错误排查 (VerifyError):理解 VerifyError 是 JVM 字节码验证失败的信号,通常由 ProGuard 优化对复杂代码(如协程、Lambda)处理不当导致字节码不一致引起。使用 -dontshrink-dontoptimize 进行诊断。
  4. 保护关键库:对于使用 Kotlin Coroutines、Jetpack Compose 或其他依赖反射/复杂字节码的库,必须添加适当的 -keep 规则来保护其类、方法和元数据,防止 ProGuard 优化导致 VerifyError 或其他运行时异常。

通过上述步骤,可以有效解决在现代 Java/Kotlin 技术栈下使用 Compose Multiplatform 和 ProGuard 时可能遇到的配置问题,确保发布构建的顺利进行和应用程序的稳定运行。