Kotlin 有许许多多非常棒的特性,比如空安全,智能类型转换,字符串插入等等。但是我发现开发者最喜欢的可能就是数据类型,也就是今天的主角Data class。数据类是如此的受欢迎,以至于经常能看到那些不需要数据类的地方,也用了数据类。
这边文章将通过实验的形式,让我们更好理解大量使用数据类究竟对包大小有多少影响。实验的方式是,保证编译通过的前提下删除所有的数据类。最终将实验的结果分享给大家。在实验的过程中,应用虽然会被破坏,但这没有关系,因为我们只关心数据类对于应用包大小的影响。
数据类的功能
在开发的过程中,我们经常需要创建一些类去存储数据。而在Kotlin我们可以通过声明数据类,同时还能额外获取一些其他的功能。
- component1(), component2() … componentX(),可以解析赋值操作成(val (name, age) = person);
- copy(), 会自动创建copy函数;
- toString(),会将类名和所有的成员拼接起来;
- equals() & hashCode()。
虽然我们享受到了很多额外的功能,但是我们为此又付出了什么代价呢?在构建发布包的时候,我们可能会用到像R8,Proguard,DexGuard等等这些优化器,这些优化器在构建的过程中会删除掉 一些不用 的方法,这也就意味着它们会优化数据类。
下面就列举一下哪些会被删除:
- component1(), component2() … componentX(), subject to the condition that destructuring assignment is not used (but, even if destructuring assignment is present, in the case of more aggressive optimisation settings, these methods can be replaced with direct reference to the class field);
- copy(),如果没被用到也会被删除。
以下是不会被删除的方法:
- toString(), 优化器并不知道这个方法可能在什么地方调用(比如,日志打印),类似这个方法也不会被混淆;
- equal() & hashCode(), 如果删除这两个方法,APP的功能都有可能会被影响。
因此toString(), equals(), hashCode() 通常都保留在了发布版本的构建包里面。
Scale of the Changes
为了去测算应用级的数据类对于APP到底能产生多大的影响,我们提出一个假设:并不是所有的数据类都是必须的,有的可以用普通的类替换掉。因为发布版本的APP会优化掉数据类的componentX()和copy()方法,将数据类转换成普通类就可以浓缩成以下这样:
data class SomeClass(val text: String) {
- override fun toString() = ...
- override fun hashCode() = ...
- override fun equals() = ...
}
但是这个操作不能手动执行,唯一的方法就是像如下的方式重新定义项目里面的数据类:
data class SomeClass(val text: String) {
+ override fun toString() = super.toString()
+ override fun hashCode() = super.hashCode()
+ override fun equals() = super.equals()
}
手动处理项目中7749个数据类。
就APP的一个仓库就已经发展到这种地步了,我不知道到底要修改这7749个数据类的哪些才能取测算出数据类对于一个APP的影响程度。所以我决定改变所有的。
编译插件
想要手动修改如此大体量的文件显然不现实,所以这时候我想起来了编译插件——非常好的方式虽然目前还没有相关的文档。我们已经在之前的一片文章里面跟大家分享过如何编译一个自己的编译插件 Fixing serialization of Kotlin Objects onece and for all。但这个插件是用来生成方法的,而我们现在需要的是删除方法。
我们在github上找到一个免费的插件Sekret,这个插件可以通过注解的形式隐藏toString()方法里面的属性。所以我们基于这个插件来做。
从创建项目结构的角度来看,插件的方式并没有产生任何改变。以下是我们需要的:
- Gradle Plugin 集成简单;
- 编译器插件,通过Gradle插件进行连接;
- 一个示例工程,可以进行各个各样的测试。
Gradle插件里面最重要的部分就是KotlinGradleSubplugin声明。这个子插件通过SeriviceLocator进行连接,通过基础的Gradle插件我们可以配置KotlinGradleSubplugin,这样一来我们可以配置编译器插件的行为。
@AutoService(KotlinGradleSubplugin::class)
class DataClassNoStringGradleSubplugin : KotlinGradleSubplugin<AbstractCompile> {
// Check if the main Gradle plugin is applied
override fun isApplicable(project: Project, task: AbstractCompile): Boolean =
project.plugins.hasPlugin(DataClassNoStringPlugin::class.java)
override fun apply(
project: Project,
kotlinCompile: AbstractCompile,
javaCompile: AbstractCompile?,
variantData: Any?,
androidProjectHandler: Any?,
kotlinCompilation: KotlinCompilation<KotlinCommonOptions>?
): List<SubpluginOption> {
// Compiler plugin options are defined with help of DataClassNoStringExtension
val extension =
project
.extensions
.findByType(DataClassNoStringExtension::class.java)
?: DataClassNoStringExtension()
val enabled = SubpluginOption("enabled", extension.enabled.toString())
return listOf(enabled)
}
override fun getCompilerPluginId(): String = "data-class-no-string"
// This is the compiler plugin artefact and should be available in any Maven repository specified in the target project
override fun getPluginArtifact(): SubpluginArtifact =
SubpluginArtifact("com.cherryperry.nostrings", "kotlin-plugin", "1.0.0")
}
一个插件编译器有两个重要的组成部分:ComponentRegistrar和CommandLineProcessor。前者负责将逻辑部分整合到我们的编译阶段;后者用来处理插件的参数。我这里不会详细的描述它们,但是你们可以到这个仓库查看具体的实现DataClassNoString,在这里我想说明一下,我们跟另外一篇文章的用法不一样(文章链接,我们将注册ClassBuilderInterceptorExtension,而不是ExpressionCodegenExtension
ClassBuilderInterceptorExtension.registerExtension(
project = project,
extension = DataClassNoStringClassGenerationInterceptor()
)
此时,有必要防止编译器创建某些方法。为此我们会用到DelegatingClassBuilder,它将所有的调用委托给ClassBuilder的原始调用,同时也允许我们重新定义newMethod的行为。如果我们尝试创建toString(),equals(),hashcode()
,将会返回一个空的MethodVisitor。编译器会为这些方法编写代码,但是不会被写到创建的类里面去。
class DataClassNoStringClassBuilder(
val classBuilder: ClassBuilder
) : DelegatingClassBuilder() {
override fun getDelegate(): ClassBuilder = classBuilder
override fun newMethod(
origin: JvmDeclarationOrigin,
access: Int,
name: String,
desc: String,
signature: String?,
exceptions: Array<out String>?
): MethodVisitor {
return when (name) {
"toString",
"hashCode",
"equals" -> EmptyVisitor
else -> super.newMethod(origin, access, name, desc, signature, exceptions)
}
}
private object EmptyVisitor : MethodVisitor(Opcodes.ASM5)
}
因此,我们干预了创建类的过程,并且完全排除了上面提到的方法。你可以在在sample工程里面的代码来确认是不是这些方法都被排除了。还可以通过检查jar/dex的字节码来确认是不是没有这些方法。
class AppTest {
data class Sample(val text: String)
@Test
fun `toString method should return default string`() {
val sample = Sample("test")
assertEquals(
"${sample.javaClass.name}@${Integer.toHexString(System.identityHashCode(sample))}",
sample.toString()
)
}
@Test
fun `hashCode method should return identityHashCode`() {
val sample = Sample("test")
assertEquals(System.identityHashCode(sample), sample.hashCode())
}
@Test
fun `equals method should return true only for itself`() {
val sample = Sample("test")
assertEquals(sample, sample)
assertNotEquals(Sample("test"), sample)
}
}
所有的代码都可以在DataClassNoString这个示例仓库里面找到,同时你还可以看到插件是如何集成的。
结果
为了进行比较,我们将用Bumble和Badoo的发行版。以下的结果是通过DiffUse工具得到的,这个工具可以详细的输出两个apk文件的详细信息,如dex和资源文件的大小,代码行数,方法数以及类文件的数量。
Application | Bumble | Bumble (after) | Diff | Badoo | Badoo (after) | Diff |
---|---|---|---|---|---|---|
Data classes | 4026 | - | - | 2894 | - | - |
DEX size (zipped) | 12.4 MiB | 11.9 MiB | -510.1 KiB | 15.3 MiB | 14.9 MiB | -454.1 KiB |
DEX size (unzipped) | 31.7 MiB | 30 MiB | -1.6 MiB | 38.9 MiB | 37.6 MiB | -1.4 MiB |
Strings in DEX | 188969 | 179197 | -9772 | 244116 | 232114 | -12002 |
Methods | 292465 | 277475 | -14990 | 354218 | 341779 | -12439 |
通过分析dex文件中被删除的字符串来发现来确定数据类的数量。
数据类的toString()
方法的实现都是以类名的简写,一个括号和数据类的第一个属性开头。没有一个数据类是没有属性字段的。
通过结果我们可以得出一下的结论,平均来说每个数据类在压缩情况下要占120个字节,而非压缩情况下占用400个字节。乍一看感觉不是很多,所以我决定看下在整个应用里面能有什么样的一个效果。显然,在整个项目里面数据类占用了整个dex文件体积的4%左右。
还有一点值的说明的是,因为我们使用的是是MVI架构,相比其他架构我们更倾向于使用数据类,所以对于你们自身的应用可能影响没那么大。
使用Data Class
我并不是要大家不要再用数据类了,但是在我们考虑是否需要用数据类的时候,你需要综合各方面的考量。以下的这几个问题就很值得在使用数据类之前想一想:
equals()和hashCode()
是否必要,如果是则可以考虑用数据类,但是不要忘了toString()
不会被混淆。- 是否需要多个构造函数,如果仅仅是因为这个而用数据类那并不是最好的选择。
toString()
是否必要?业务逻辑不太依赖此方法的实现,有时候可以通过IDE的手段来重新创建此方法。- 是否需要将一个简单的DTO发送到另一层,或者需要保存一些配置信息?如果前面的问题无关的,则用一个简单的类就能适用这种场景了。
我们不可能在项目里面完全不用数据类,并且上面的插件破坏了应用。为了测算大量数据类产生的影响方法都没删除了。我们的案例里面,数据类占用了dex文件大小的4%。
如果你想测算你数据类在你项目里面占用了多大的空间,你可以用我们的插件自己做一下。如果你也进行了相同的实验,请随时分享你的反馈!