KotinPoet详细使用指南(下)

714 阅读7分钟

前言

上一篇文章里我们基本上讲明白了KotlinPoet是如何生成.kt源文件的。KotlinPoet给Kotlin中的每个实体都创建了Model,文件-FileSpec、类、接口和对象-TypeSpec、类型别名-TypeAliasSpec、属性-PropertySpec、方法和构造方法-FunSpec、参数-ParameterSpec和注解-AnnotationSpec

但是KotlinPoet并没有给方法体提供Model。而是直接使用字符串代替代码块,于是字符串中的占位符逻辑和应用就是很重要的一环。

代码和控制流

用%S占位字符串

在我们构造代码块的时候,可以用%S作为一个字符串的占位符:

fun main(args: Array<String>) {
  val helloWorld = TypeSpec.classBuilder("HelloWorld")
    .addFunction(whatsMyNameYo("slimShady"))
    .addFunction(whatsMyNameYo("eminem"))
    .addFunction(whatsMyNameYo("marshallMathers"))
    .build()

  val kotlinFile = FileSpec.builder("com.example.helloworld", "HelloWorld")
    .addType(helloWorld)
    .build()

  kotlinFile.writeTo(System.out)
}

private fun whatsMyNameYo(name: String): FunSpec {
  return FunSpec.builder(name)
    .returns(String::class)
    .addStatement("return %S", name)
    .build()
}

会生成(如果return语句就是方法体的第一行,就会写成kotlin中一行方法的形式):

class HelloWorld {
  fun slimShady(): String = "slimShady"

  fun eminem(): String = "eminem"

  fun marshallMathers(): String = "marshallMathers"
}

用%P占位字符串模板

占位符%S处理转义字符的时候,会进行变化来让转义字符表达字符本来的意思。例如美元符号$,在Kotlin的字符串中被用来当作串模版使用,如果在Kotlin字符串中需要使用$作为字符,就需要写成${'$'},占位符%S会自动进行这种处理:

val stringWithADollar = "Your total is " + "$" + "50"
val funSpec = FunSpec.builder("printTotal")
  .returns(String::class)
  .addStatement("return %S", stringWithADollar)
  .build()

会生成:

fun printTotal(): String = "Your total is ${'$'}50"

如果我们不需要这种处理,需要使用%P%P不会处理符号的转义:

val amount = 50
val stringWithADollar = "Your total is " + "$" + "amount"
val funSpec = FunSpec.builder("printTotal")
  .returns(String::class)
  .addStatement("return %P", stringWithADollar)
  .build()

会生成:

fun printTotal(): String = "Your total is $amount"

同时%P还可以当作代码块CodeBlock(此处的CodeBlockKotlinPoet中的类)的占位符:

val file = FileSpec.builder("com.example", "Digits")
  .addFunction(
    FunSpec.builder("print")
      .addParameter("digits", IntArray::class)
      .addStatement("println(%P)", buildCodeBlock {
        val contentToString = MemberName("kotlin.collections", "contentToString")
        add("These are the digits: ${digits.%M()}", contentToString)
      })
      .build()
  )
  .build()
println(file)

会生成:

package com.example

import kotlin.IntArray
import kotlin.collections.contentToString

fun print(digits: IntArray) {
  println("""These are the digits: ${digits.contentToString()}""")
}

用 %T占位类

可以用%T来进行类型引用,KotlinPoet可以为被%T引用的类自动生成import 语句:

val today = FunSpec.builder("today")
  .returns(Date::class)
  .addStatement("return %T()", Date::class)
  .build()

val helloWorld = TypeSpec.classBuilder("HelloWorld")
  .addFunction(today)
  .build()

val kotlinFile = FileSpec.builder("com.example.helloworld", "HelloWorld")
  .addType(helloWorld)
  .build()

kotlinFile.writeTo(System.out)

会生成以下.kt文件,并包含必要的import

package com.example.helloworld

import java.util.Date

class HelloWorld {
  fun today(): Date = Date()
}

上面我们通过传递Date::class引用了一个在我们生成代码时就可用的类。同样我们可以通过ClassName引用一个生成代码时不存在的类:

val hoverboard = ClassName("com.mattel", "Hoverboard")

val tomorrow = FunSpec.builder("tomorrow")
  .returns(hoverboard)
  .addStatement("return %T()", hoverboard)
  .build()

KotlinPoet会导入那个尚不存在的类并生成:

package com.example.helloworld

import com.mattel.Hoverboard

class HelloWorld {
  fun tomorrow(): Hoverboard = Hoverboard()
}

ClassNameKotlinPoet中经常会用到的很重要的类,可以识别任何被声明的类。ClassName还能识别数组类型、参数化类型、通配符类型、lambda 类型和类型变量。对每一种类型,KotlinPoet中都有用于构建的类:

import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy

val hoverboard = ClassName("com.mattel", "Hoverboard")
val list = ClassName("kotlin.collections", "List")
val arrayList = ClassName("kotlin.collections", "ArrayList")
val listOfHoverboards = list.parameterizedBy(hoverboard)
val arrayListOfHoverboards = arrayList.parameterizedBy(hoverboard)

val thing = ClassName("com.misc", "Thing")
val array = ClassName("kotlin", "Array")
val producerArrayOfThings = array.parameterizedBy(WildcardTypeName.producerOf(thing))

val beyond = FunSpec.builder("beyond")
  .returns(listOfHoverboards)
  .addStatement("val result = %T()", arrayListOfHoverboards)
  .addStatement("result += %T()", hoverboard)
  .addStatement("result += %T()", hoverboard)
  .addStatement("result += %T()", hoverboard)
  .addStatement("return result")
  .build()

val printThings = FunSpec.builder("printThings")
  .addParameter("things", producerArrayOfThings)
  .addStatement("println(things)")
  .build()

KotlinPoet 会分解每种类型并在可能的情况下导入其组件:

package com.example.helloworld

import com.mattel.Hoverboard
import com.misc.Thing
import kotlin.Array
import kotlin.collections.ArrayList
import kotlin.collections.List

class HelloWorld {
  fun beyond(): List<Hoverboard> {
    val result = ArrayList<Hoverboard>()
    result += Hoverboard()
    result += Hoverboard()
    result += Hoverboard()
    return result
  }

  fun printThings(things: Array<out Thing>) {
    println(things)
  }
}

可空类型

KotlinPoet支持可为空的类型。使用copy(``nullable = true``)方法可把TypeName转化成可空的类型(注意可空是配置在类型上的,而不是参数/属性上):

val java = PropertySpec.builder("java", String::class.asTypeName().copy(nullable = true))
  .mutable()
  .addModifiers(KModifier.PRIVATE)
  .initializer("null")
  .build()

val helloWorld = TypeSpec.classBuilder("HelloWorld")
  .addProperty(java)
  .addProperty("kotlin", String::class, KModifier.PRIVATE)
  .build()

会生成:

class HelloWorld {
  private var java: String? = null

  private val kotlin: String
}

用%M占位参数

与类型一样,KotlinPoet 也为成员(函数和属性)提供了一个特殊的占位符%M,如果代码中需要访问顶级成员和在对象内部声明的成员时,就需要使用%M。用%M引用成员时,需要一个MemberName作为占位符的参数,KotlinPoet 会自动处理import

val createTaco = MemberName("com.squareup.tacos", "createTaco")
val isVegan = MemberName("com.squareup.tacos", "isVegan")
val file = FileSpec.builder("com.squareup.example", "TacoTest")
  .addFunction(
    FunSpec.builder("main")
      .addStatement("val taco = %M()", createTaco)
      .addStatement("println(taco.%M)", isVegan)
      .build()
  )
  .build()
println(file)

上面的代码会生成:

package com.squareup.example

import com.squareup.tacos.createTaco
import com.squareup.tacos.isVegan

fun main() {
  val taco = createTaco()
  println(taco.isVegan)
}

%M也可以用来引用扩展函数和扩展属性。不过需要确保导入的成员不会发生简单名称(不包括namespace的名称)冲突,否则导入将失败并且代码生成器输出将无法通过编译。不过KotlinPoet提供了FileSpec.addAliasedImport()用于为冲突创建别名导入:

val createTaco = MemberName("com.squareup.tacos", "createTaco")
val createCake = MemberName("com.squareup.cakes", "createCake")
val isTacoVegan = MemberName("com.squareup.tacos", "isVegan")
val isCakeVegan = MemberName("com.squareup.cakes", "isVegan")
val file = FileSpec.builder("com.squareup.example", "Test")
  .addAliasedImport(isTacoVegan, "isTacoVegan")
  .addAliasedImport(isCakeVegan, "isCakeVegan")
  .addFunction(
    FunSpec.builder("main")
      .addStatement("val taco = %M()", createTaco)
      .addStatement("val cake = %M()", createCake)
      .addStatement("println(taco.%M)", isTacoVegan)
      .addStatement("println(cake.%M)", isCakeVegan)
      .build()
  )
  .build()
println(file)

KotlinPoet 会给2个同名的isVegan成员生成别名导入:

package com.squareup.example

import com.squareup.cakes.createCake
import com.squareup.tacos.createTaco
import com.squareup.cakes.isVegan as isCakeVegan
import com.squareup.tacos.isVegan as isTacoVegan

fun main() {
  val taco = createTaco()
  val cake = createCake()
  println(taco.isTacoVegan)
  println(cake.isCakeVegan)
}

参数名称和运算符

MemberName还支持运算符(或运算符重载),可以使用MemberName(String, KOperator)MemberName(ClassName, KOperator)来导入和引用运算符。

val taco = ClassName("com.squareup.tacos", "Taco")
val meat = ClassName("com.squareup.tacos.ingredient", "Meat")
val iterator = MemberName("com.squareup.tacos.internal", KOperator.ITERATOR)
val minusAssign = MemberName("com.squareup.tacos.internal", KOperator.MINUS_ASSIGN)
val file = FileSpec.builder("com.example", "Test")
  .addFunction(
    FunSpec.builder("makeTacoHealthy")
      .addParameter("taco", taco)
      .beginControlFlow("for (ingredient %M taco)", iterator)
      .addStatement("if (ingredient is %T) taco %M ingredient", meat, minusAssign)
      .endControlFlow()
      .addStatement("return taco")
      .build()
  )
  .build()
println(file)

KotlinPoet 将导入并使用重载之后的运算符:

package com.example

import com.squareup.tacos.Taco
import com.squareup.tacos.ingredient.Meat
import com.squareup.tacos.internal.iterator
import com.squareup.tacos.internal.minusAssign

fun makeTacoHealthy(taco: Taco) {
  for (ingredient in taco) {
    if (ingredient is Meat) taco -= ingredient
  }
  return taco
}

用%N占位名称

一般来说,我们生成的代码需要相互引用或者自我引用。KotlinPoet中使用%N来引用在KotlinPoet中声明的另一个实体。下面例子中一个方法引用了另一个方法:

fun byteToHex(b: Int): String {
  val result = CharArray(2)
  result[0] = hexDigit((b ushr 4) and 0xf)
  result[1] = hexDigit(b and 0xf)
  return String(result)
}

fun hexDigit(i: Int): Char {
  return (if (i < 10) i + '0'.toInt() else i - 10 + 'a'.toInt()).toChar()
}

在生成上面的代码时,我们需要使用%NhexDigit()方法作为参数传递给byteToHex() 方法:

val hexDigit = FunSpec.builder("hexDigit")
  .addParameter("i", Int::class)
  .returns(Char::class)
  .addStatement("return (if (i < 10) i + '0'.toInt() else i - 10 + 'a'.toInt()).toChar()")
  .build()

val byteToHex = FunSpec.builder("byteToHex")
  .addParameter("b", Int::class)
  .returns(String::class)
  .addStatement("val result = CharArray(2)")
  .addStatement("result[0] = %N((b ushr 4) and 0xf)", hexDigit)
  .addStatement("result[1] = %N(b and 0xf)", hexDigit)
  .addStatement("return String(result)")
  .build()

%N另一个功能是会使用双勾号``自动转义包含非法标识符字符的名称。比如下面的例子使用了Kotlin中的关键字package作为方法名:

val taco = ClassName("com.squareup.tacos", "Taco")
val packager = ClassName("com.squareup.tacos", "TacoPackager")
val file = FileSpec.builder("com.example", "Test")
  .addFunction(
    FunSpec.builder("packageTacos")
      .addParameter("tacos", LIST.parameterizedBy(taco))
      .addParameter("packager", packager)
      .addStatement("packager.%N(tacos)", packager.member("package"))
      .build()
  )
  .build()

%N会转义名称,确保输出能通过编译:

package com.example

import com.squareup.tacos.Taco
import com.squareup.tacos.TacoPackager
import kotlin.collections.List

fun packageTacos(tacos: List<Taco>, packager: TacoPackager) {
  packager.`package`(tacos)
}

用%L占位字面量(Literals)

一般来说KotlinPoet中的字符串模板效果都很好。但考虑到我们有时候可能需要在生成的代码中带一些字面量的内容, KotlinPoet 提供了一种类似 String.format()的语法。用%L作为字面量的占位符,工作起来就像String.format()中的%s

private fun computeRange(name: String, from: Int, to: Int, op: String): FunSpec {
  return FunSpec.builder(name)
    .returns(Int::class)
    .addStatement("var result = 0")
    .beginControlFlow("for (i in %L until %L)", from, to)
    .addStatement("result = result %L i", op)
    .endControlFlow()
    .addStatement("return result")
    .build()
}

%L会不经过任何转义直接将字面量提供给输出文件。%L的参数可以是字符串、原始类型或者之前提到过的一些 KotlinPoet 类型。

代码块中占位符传递参数

代码块有三种方式给占位符传递参数,每个代码块只能使用一种。

相对位置

按顺序给每个占位符传递参数值。下面每个例子,都会生成代码“I ate 3 tacos”:

CodeBlock.builder().add("I ate %L %L", 3, "tacos")

指定位置

在占位符前放一个从1开始的整数索引,用指定要使用第几个参数。

CodeBlock.builder().add("I ate %2L %1L", "tacos", 3)

命名参数

使用语法%argumentName:X,其中X是对应需要使用的占位符。CodeBlock.addNamed()中需要传入<名称,值>这样的包含所有使用到的参数的映射。参数名称只能使用a-zA-Z0-9,和_并且以小写字母开头。

val map = LinkedHashMap<String, Any>()
map += "food" to "tacos"
map += "count" to 3
CodeBlock.builder().addNamed("I ate %count:L %food:L", map)

别名(Type Aliases)

KotlinPoet使用TypeAliasSpec.builder创建别名,支持三种别名类型,简单类型、泛型类型和lambda类型:

val k = TypeVariableName("K")
val t = TypeVariableName("T")

val fileTable = Map::class.asClassName()
  .parameterizedBy(k, Set::class.parameterizedBy(File::class))

val predicate = LambdaTypeName.get(
  parameters = arrayOf(t),
  returnType = Boolean::class.asClassName()
)
val helloWorld = FileSpec.builder("com.example", "HelloWorld")
  .addTypeAlias(TypeAliasSpec.builder("Word", String::class).build())
  .addTypeAlias(
    TypeAliasSpec.builder("FileTable", fileTable)
      .addTypeVariable(k)
      .build()
  )
  .addTypeAlias(
    TypeAliasSpec.builder("Predicate", predicate)
      .addTypeVariable(t)
      .build()
  )
  .build()

会生成:

package com.example

import java.io.File
import kotlin.Boolean
import kotlin.String
import kotlin.collections.Map
import kotlin.collections.Set

typealias Word = String

typealias FileTable<K> = Map<K, Set<File>>

typealias Predicate<T> = (T) -> Boolean

可调用的引用(Callable References)

KotlinPoet中也支持对构造函数、函数和属性的可调用引用:

  • ClassName.constructorReference()调用构造函数
  • MemberName.reference()调用函数和属性
val helloClass = ClassName("com.example.hello", "Hello")
val worldFunction: MemberName = helloClass.member("world")
val byeProperty: MemberName = helloClass.nestedClass("World").member("bye")

val factoriesFun = FunSpec.builder("factories")
  .addStatement("val hello = %L", helloClass.constructorReference())
  .addStatement("val world = %L", worldFunction.reference())
  .addStatement("val bye = %L", byeProperty.reference())
  .build()

FileSpec.builder("com.example", "HelloWorld")
  .addFunction(factoriesFun)
  .build()

会产生:

package com.example

import com.example.hello.Hello

fun factories() {
  val hello = ::Hello
  val world = Hello::world
  val bye = Hello.World::bye
}

如果顶级类或成员之间有名称上的冲突,可能需要别名导入(见%M一节)。

kotlin-reflect包

为了能够生成K``Type和它的实现类,以及获得kotlin内置反射API没办法获取到的信息,KotlinPoet依赖了kotlin-reflect包。 kotlin-reflect可以读取类信息以获得额外的信息。比如说KotlinPoet 能够从一个带泛型的KType实现类中读取类型参数(一般用于泛型)和它们的型变Variance,也就是in,out和*)。

kotlin-reflect 是一个相对比较大的依赖,所以有时候我们为了节省空间或者简化proguard/R8混淆设置会希望能够从最终执行文件中删掉它(例如一个生成Kotlin代码的Gradle插件)。删掉它是完全可行的,而且删掉之后依然可以使用大部分KotlinPoet的API:

dependencies {
  implementation("com.squareup:kotlinpoet:<version>") {
    exclude(module = "kotlin-reflect")
  }
}

KotlinPoet中依赖kotlin-reflect的主要API是KType.asTypeName()typeNameOf<T>()。如果在没有kotlin-reflect依赖的情况调用的话,很显然会崩溃。

可以替换为显式传递类型参数的方式,并且注释清楚:

// Replace
// kotlin-reflect needed
val typeName = typeNameOf<List<Int?>>()

// With
// kotlin-reflect not needed
val typeName =
  List::class.asClassName().parameterizedBy(Int::class.asClassName().copy(nullable = true))