函数类型

在 Java 代码中,我们设置一个 View 的点击事件时,以下部分是必需的:

  1. // 声明一个 OnClickListener 接口,接口中声明点击事件的方法 onClick
  2. public interface OnClickListener {
  3. void onClick(View v)
  4. }
  5. class View {
  6. // 在 View 类中声明一个成员变量 mListener
  7. private OnClickListener mListener;
  8. // 通过 set 方法设置一个实例化的 OnClickListener 对象给 mListener 变量
  9. public void setOnClickListener(OnClickListener listener) {
  10. mListener = listener;
  11. }
  12. // 在手势回调方法 onTouchEvent 中执行 mListener 的 onClick 方法
  13. protected onTouchEvent(MotionEvent event) {
  14. // ...
  15. if(mListener != null) {
  16. mListener.onClick(this);
  17. }
  18. }
  19. }
  20. // 使用时
  21. View view = new View(context);
  22. view.setOnClickListener(new OnClickListener() {
  23. void onClick(View v) {
  24. // do something
  25. }
  26. });

可以看出,我们实际上只是想定义一个点击事件的回调方法 onClick,可以通过 set 方法被外界进行设置,然后在点击事件发生时,进行调用。

但是因为在 Java 中函数并不是一等公民,所有的函数都必须在类或接口中声明,所以我们不得不将 onClick 方法声明到了 OnClickListener 接口中。并且在调用 set 方法时,我们还需要将抽象的接口使用匿名内部类实例进行具体实现,作为入参传入才行。

对于这个过程,Kotlin 认为真正有价值的是 onClick 这个函数本身,所以 Kotlin 中规定了函数类型。其声明的基本规则如下:

  1. (String, Int) -> Unit

函数类型主要由 -> 进行分隔,左边是入参类型的声明,使用括号包裹,右边是返回值类型的声明。左边如果有多个参数,使用逗号将不同入参的类型分开;若没有入参,则使用空括号。右边表示返回值的类型,若此函数没有返回值,则声明为 Unit

所以上述设置点击事件的过程,如果使用 Kotlin 代码来写,就可以写作下面的样子:

  1. class View {
  2. private var onClick: (View) -> Unit
  3. fun setOnClickListener(listener: (View) -> Unit) {
  4. onClick = listener
  5. }
  6. protected fun onTouchEvent(event: MotionEvent) {
  7. if(onClick != null) {
  8. onClick(this)
  9. }
  10. }
  11. }
  12. // 使用时
  13. val view = View(context)
  14. view.setOnClickListener(object: ((View) -> Unit) {
  15. override fun invoke(v: View) {
  16. // do something
  17. }
  18. })
  19. // 或者
  20. fun clickCallback(view: View) {
  21. // do something
  22. }
  23. view.setOnClickListener(::clickCallback)

上面的例子中,相比于 Java 代码,省去了声明接口的步骤,节约了一部分的代码量。但是在使用时,这里提供了两种方式,一种是和 Java 代码一样的思路,使用匿名内部类实例(如果使用 AndroidStudio 将 Java 代码转换成 Kotlin 代码就会出现这种场景);另一种是先定义一个函数,然后通过 className::methodName 的形式将某个类中定义的某个函数传入(顶层函数可以省略className)。这两种使用的方式都不太方便,所以我们需要使用Lambda表达式进一步简化使用。

Lambda表达式

在 Java 代码中,如果 JDK 版本设置为 1.8 那么也可以使用 Java 提供的 Lambda 表达式。上面的点击事件示例在使用时,可以简化为以下代码:

  1. View view = new View(context);
  2. view.setOnClickListener(v -> {
  3. // do something
  4. });

Java 的 Lambda表达式只是个语法糖,本质上还是定义了一个 OnClickListener 接口的匿名内部类实例作为入参传给了 set 方法。并且,这种写法还有更多的限制:定义 OnClickListener 接口的时候要求只能有一个抽象方法(即示例中的 onClick 方法),这个方法必须没有返回值(返回值为 void)。

在 Kotlin 中,我们可以使用 Kotlin 的 Lambda 表达式将 Kotlin 代码进行简化,直接将匿名内部类中定义的 onClick 方法体作为入参传入:

  1. view.setOnClickListener({ v ->
  2. // do something
  3. })

若 Lambda 表达式是入参的最后一项的话,那么还可以将其移到括号外面,语法定义如下:

  1. obj.setParamAndCallback(param, { innerParam ->
  2. // ...
  3. })
  4. // 等同于
  5. obj.setParamAndCallback(param) { innerParam ->
  6. // ...
  7. }
  8. // 若方法只有一个入参,这个入参为 Lambda 表达式,也就是 param 为空的情况
  9. obj.setCallback() { innerParam ->
  10. // ...
  11. }
  12. // 这种情况还可以进一步将小括号省略掉,直接在函数名后面写 Lambda 表达式的函数体
  13. obj.setCallback { innerParam ->
  14. // ...
  15. }

所以,上述的设置点击事件方法的使用过程,最后可以将Kotlin代码简化为:

  1. view.setOnClickListener { v ->
  2. // do something
  3. }
  4. // 如果你没有使用 Lambda 表达式中的入参变量还可以用下划线进行替换
  5. view.setOnClickListener { _ ->
  6. // do something
  7. }
  8. // 入参全部没有使用的情况下,可以不写入参和箭头,直接这么写
  9. view.setOnClickListener {
  10. // do something
  11. }
  12. // 入参只有一个时,也可以不写这一个入参和箭头,在使用这个入参时,直接用 it 代替
  13. view.setOnClickListener {
  14. Log.d("TAG", "组件的哈希值为:${it.hashcode}")
  15. // do something
  16. }

高阶函数

如果一个函数接收另一个函数作为参数,或者返回值类型为另一个函数,那么该函数就被称为「高阶函数」。

下面的例子中我们定义了一个高阶函数 num1Andnum2 ,接收三个参数,前两个为数字,第三个参数是个函数,用于对前两个数字做具体的运算:

  1. fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int): Int {
  2. val result = operation(num1, num2)
  3. return result
  4. }

可以看到我们在这个高阶函数的内部调用了传入的 operation 函数,并将其运算的结果进行了返回。这样,这个函数的具体实现逻辑就可以由传入的参数 operation 来决定了。这个高阶函数的具体使用情况如下:

  1. fun plus(num1: Int, num2: Int): Int {
  2. return num1 + num2
  3. }
  4. fun minus(num1: Int, num2: Int): Int {
  5. return num1 - num2
  6. }
  7. // 使用情况
  8. val num1 = 100
  9. val num2 = 80
  10. val result1 = num1Andnum2(num1, num2, ::plus) // 结果为180
  11. val result2 = num1Andnum2(num1, num2, ::minus) // 结果为20

如果使用 Kotlin 的 Lambda 表达式写法,我们还可以将使用情况简化为下面的代码:

  1. val num1 = 100
  2. val num2 = 80
  3. val result1 = num1Andnum2(num1, num2) { n1, n2 ->
  4. n1 + n2
  5. }
  6. val result2 = num1Andnum2(num1, num2) { n1, n2 ->
  7. n1 - n2
  8. }

注意使用Lambda表达式的时候,会将最后一行代码的结果作为返回值自动返回,所以不需要显式的书写 return 语句。

在Lambda表达式中使用预设的上下文

Kotlin 提供了 apply 函数,可以将一个自定义的 Lambda 传入,并以当前调用 apply 的对象作为上下文,最后将这个上下文对象返回回来。这是如何实现的呢?接下来我们会配合扩展函数给 StringBuilder 类实现一个类似的 build 方法,具体的代码如下:

  1. fun StringBuilder.build(block: StringBuilder.() -> Unit): StringBuilder {
  2. block()
  3. return this
  4. }

这里我们给 StringBuilder 类定义了一个 build 扩展函数,这个扩展函数接受一个函数类型的参数,并且返回值也是 StringBuilder 。注意,这个函数类型的声明方式和我们前面学习的语法有所不同:它在函数类型前面加上了 StringBuilder. 的语法结构。这是什么意思呢?其实这才是定义高阶函数的完整语法规则,在函数类型的前面加上类名,就表示这个函数类型是定义在哪个类当中的,这样在传入Lambda表达式的时候,就会自动拥有 StringBuilder 的上下文。

高阶函数的实现原理

Kotlin的编译器会将上述的例子,编译成Java支持的语法结构,大致的内容如下:

  1. public static int num1AndNum2(int num1, int num2, Function operation) {
  2. int result = (int) operation.invoke(num1, num2);
  3. return result;
  4. }
  5. public static void main() {
  6. int num1 = 100;
  7. int num2 = 80;
  8. int result = num1AndNum2(num1, num2, new Function() {
  9. @Override
  10. public Integer invoke(Integer n1, Integer n2) {
  11. return n1 + n2;
  12. }
  13. });
  14. }

可以看到,这里 num1AndNum2 函数的第三个参数变成了一个 Function 接口,这是一种Kotlin内置的接口,里面有个待实现的 invoke() 函数,而num1AndNum2 函数实际上就是调用了Function 接口的 invoke() 函数,并把两个数字传了进去。

在调用 num1AndNum2 函数的时候,之前的Lambda表达式在这里变成了Function接口的匿名类实现,然后在invoke()函数中实现了n1+n2的逻辑,并将结果进行返回。

所以在Kotlin中每次我们调用一次Lambda表达式,都会创建一个新的匿名内部类实例,这当然会造成额外的内存和性能开销。

为了解决这个问题,Kotlin 提供了内联函数的功能,他可以使Lambda表达式带来的运行时开销完全消除。

内联函数

内联函数的用法非常简单,只需要在定义高阶函数时加上 inline 关键字即可,如下所示:

  1. inline fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int): Int {
  2. val result = operation(num1, num2)
  3. return result
  4. }
  5. // 使用时
  6. val num1 = 100
  7. val num2 = 80
  8. val result = num1Andnum2(num1, num2) { n1, n2 ->
  9. n1 + n2
  10. }

内联函数原理

Kotlin编译器在编译时,会将内联函数入参传入的Lambda表达式直接代入到高阶函数的函数体中,并且最终将 inline 关键字修饰的高阶函数的函数体部分的代码直接拷贝到使用这个函数的地方。例如上面的例子在编译后大概的代码如下:

  1. public static void main() {
  2. int num1 = 100;
  3. int num2 = 80;
  4. int result = num1 + num2;
  5. }

经过这样的变换,很明显可以消除运行时创建 Function 接口的匿名类实例带来的开销

但是这样的变换带来的问题也很明显:内联方式只是粗暴的从内联函数中将函数体的代码拷贝至调用处,如果调用的地方很多,拷贝的内联函数的代码就也会很多,这势必会造成字节码的代码量膨胀,最后打包的体积变大

所以最适合内联函数做优化的场景其实是:内联函数被调用的地方比较少,但在每次调用的地方可能存在循环调用的情况。具体的代码场景如下:

  1. fun main() {
  2. val num1 = 100
  3. val num2 = 80
  4. val result1 = num1AndNum2(num1. num2)
  5. val result2 = num1AndNum2(num1. num2)
  6. val result3 = num1AndNum2(num1. num2)
  7. // ...多处调用,不适合的场景
  8. for(i in 1..1000) {
  9. val result4 = num1AndNum2(num1. num2)
  10. // ...循环中调用,且调用的地方较少,适合的场景
  11. }
  12. }

noinline和crossinline

noinline

接下来我们要讨论一些更加特殊的情况。比如,一个高阶函数如果接收了两个或者更多的函数类型的参数,这时如果我们给高阶函数加上了 inline 关键字,那么Kotlin编译器会自动将所有引用的Lambda表达式全部进行内联。

但是,如果我们只想内联其中的一个Lambda表达式该怎么办?这时就可以使用 noinline 关键字了,语法规则如下:

  1. inline fun inlineTest(block1: () -> Unit, noinline block2: () -> Unit) {}

可以看到原本我们定义了一个内联函数 inlineTest() ,他有两个函数类型的参数,默认情况下,这两个Lambda表达式都会被内联,但是由于我们对第二个参数 block2 前面添加了 noinline 关键字,那么现在就只对第一个参数 block1 进行内联了。这便是 noinline 关键字的作用了。

了解了 noinline 关键字的作用,我们又会想,什么样的特殊场景下才会使用这个关键字呢?

要知道我们在内联函数传入的参数,都不是真正的参数,因为最后他们都会被编译器给替换掉。所以如果我们希望将某个入参作为函数类型的变量来使用的时候,就不应该将它们内联,而是使用 noinline 关键字进行修饰。例如下面的例子:

  1. inline fun inlineTest(block1: () -> Unit, noinline block2: () -> Unit) {
  2. return block2
  3. }

可以看出,我们将高阶函数的第二个函数类型的参数 block2 作为返回值进行了返回。如果使用内联函数的话,block2 这个变量会被编译器进行替换,所以也无法将其作为返回值进行返回。

另外内联函数和非内联函数还有一个重要的区别,那就是内联函数所引用的Lambda表达式是可以直接使用 **return** 关键字来进行高阶函数的返回的;而非内联函数只能进行局部返回。什么是局部返回?我们可以看一下下面的例子:

  1. fun printString(str: String, block: (String) -> Unit) {
  2. println("printString begin")
  3. block(str)
  4. println("printString end")
  5. }
  6. fun main() {
  7. println("main start")
  8. val str = ""
  9. printString(str) { s ->
  10. println("lambda start")
  11. if(s.isEmpty()) return@printString
  12. println(s)
  13. println("lambda end")
  14. }
  15. println("main end")
  16. }

上述代码主要需要注意的是 return@printString 关键字的写法。这表示局部返回,也就是说在Lambda表达式中的return只能将当前Lambda作用域的代码进行返回,而不影响整个高阶函数的作用域执行。所以上述代码的执行结果是:

  1. main start
  2. printString begin
  3. lambda start
  4. printString end
  5. main end

如果我们将上面的高阶函数改成内联函数,那么情况就不一样了,更改后的代码如下:

  1. inline fun printString(str: String, block: (String) -> Unit) {
  2. println("printString begin")
  3. block(str)
  4. println("printString end")
  5. }
  6. fun main() {
  7. println("main start")
  8. val str = ""
  9. printString(str) { s ->
  10. println("lambda start")
  11. if(s.isEmpty()) return
  12. println(s)
  13. println("lambda end")
  14. }
  15. println("main end")
  16. }

由于内联函数的实现原理,编译器会将代码最终拷贝至main() 函数中,所以在执行到return 关键字的时候,实际上会返回main()函数并终止后续的打印逻辑。最后的执行结果如下:

  1. main start
  2. printString begin
  3. lambda start

crossinline

前面我们说到了内联函数中的 return 关键字可以直接返回调用高阶函数处的函数作用域。但是也有这样一种复杂情况,例如:

  1. inline fun hello(block: () -> Unit) {
  2. println("hello")
  3. runOnUiThread {
  4. block()
  5. }
  6. }
  7. // 使用时
  8. fun main() {
  9. hello {
  10. println("Bye!")
  11. return
  12. }
  13. }

上面的代码中,我们定义了一个内联函数,与之前的情况不同的是,我们在内联函数的函数体中使用了 runOnUiThread() 函数,并将内联函数的参数 block 作为 runOnUiThread() 函数的Lambda表达式函数体进行了执行。这就造成了困惑。

原本我们在内联函数 hello() 的Lambda表达式中使用 return 关键字是想要返回调用 hello()main() 函数作用域。但是由于我们在高阶函数中使用了runOnUiThread() 函数,切断了Lambda表达式 block 与高阶函数 hello() 之间的联系。

所以,当我们在内联高阶函数中创建了另外的Lambda表达式或者使用匿名类实现的时候,记得在使用的参数前面加上 crossinline 关键字。

crossinline 关键字表示向编译器保证,我们定义的Lambda表达式中不会出现 return 进行返回,而是遵从非内联函数一样的规则,可以使用 return@hello 关键字进行局部返回。总体来说, crossinline 关键字除了在返回上有所区别外,它还是保留了 inline 关键字的其他所有特性。

所以,综上所述, crossinline 关键字主要是让我们能在内联高阶函数中,能够嵌套使用传入的Lambda表达式。