阅读 3709

为数不多的人知道的 Kotlin 技巧以及 原理解析

Google 引入 Kotlin 的目的就是为了让 Android 开发更加方便,自从官宣 Kotlin 成为了 Android 开发的首选语言之后,已经有越来越多的人开始使用 Kotlin。

结合着 Kotlin 的高级函数的特性可以让代码可读性更强,更加简洁,但是呢简洁的背后是有代价的,使用不当对性能可能会有损耗,这块往往很容易被我们忽略,这就需要我们去研究 kotlin 语法糖背后的魔法,当我们在开发的时候,选择合适的语法糖,尽量避免这些错误,关于 Kotlin 性能损失那些事,可以看一下我另外两篇文章。

这两篇文章都分析了 Kotlin 使用不当对性能的影响,不仅如此 Kotlin 当中还有很多让人傻傻分不清楚的语法糖例如 run, with, let, also, apply 等等,这篇文章将介绍一种简单的方法来区分它们以及如何选择使用。

通过这篇文章你将学习到以下内容,文中会给出相应的答案

  • 如何使用 plus 操作符对集合进行操作?
  • 当获取 Map 值为空时,如何设置默认值?
  • require 或者 check 函数做什么用的?
  • 如何区分 run, with, let, also and apply 以及如何使用?
  • 如何巧妙的使用 in 和 when 关键字?
  • Kotlin 的单例有几种形式?
  • 为什么 by lazy 声明的变量只能用 val?

plus 操作符

在 Java 中算术运算符只能用于基本数据类型,+ 运算符可以与 String 值一起使用,但是不能在集合中使用,在 Kotlin 中可以应用在任何类型,我们来看一个例子,利用 plus (+) 和 minus (-) 对 Map 集合做运算,如下所示。

fun main() {
    val numbersMap = mapOf("one" to 1, "two" to 2, "three" to 3)

    // plus (+)
    println(numbersMap + Pair("four", 4)) // {one=1, two=2, three=3, four=4}
    println(numbersMap + Pair("one", 10)) // {one=10, two=2, three=3}
    println(numbersMap + Pair("five", 5) + Pair("one", 11)) // {one=11, two=2, three=3, five=5}

    // minus (-)
    println(numbersMap - "one") // {two=2, three=3}
    println(numbersMap - listOf("two", "four")) // {one=1, three=3}
}
复制代码

其实这里用到了运算符重载,Kotlin 在 Maps.kt 文件里面,定义了一系列用关键字 operator 声明的 Map 的扩展函数。

用 operator 关键字声明 plus 函数,可以直接使用 + 号来做运算,使用 operator 修饰符声明 minus 函数,可以直接使用 - 号来做运算,其实我们也可以在自定义类里面实现 plus (+) 和 minus (-) 做运算。


data class Salary(var base: Int = 100){
    override fun toString(): String = base.toString()
}

operator fun Salary.plus(other: Salary): Salary = Salary(base + other.base)
operator fun Salary.minus(other: Salary): Salary = Salary(base - other.base)

val s1 = Salary(10)
val s2 = Salary(20)
println(s1 + s2) // 30
println(s1 - s2) // -10
复制代码

Map 集合的默认值

在 Map 集合中,可以使用 withDefault 设置一个默认值,当键不在 Map 集合中,通过 getValue 返回默认值。

val map = mapOf(
        "java" to 1,
        "kotlin" to 2,
        "python" to 3
).withDefault { "?" }

println(map.getValue("java")) // 1
println(map.getValue("kotlin")) // 2
println(map.getValue("c++")) // ?
复制代码

源码实现也非常简单,当返回值为 null 时,返回设置的默认值。

internal inline fun <K, V> Map<K, V>.getOrElseNullable(key: K, defaultValue: () -> V): V {
    val value = get(key)
    if (value == null && !containsKey(key)) {
        return defaultValue()
    } else {
        @Suppress("UNCHECKED_CAST")
        return value as V
    }
}
复制代码

但是这种写法和 plus 操作符在一起用,有一个 bug ,看一下下面这个例子。

val newMap = map + mapOf("python" to 3)
println(newMap.getValue("c++")) // 调用 getValue 时抛出异常,异常信息:Key c++ is missing in the map.
复制代码

这段代码的意思就是,通过 plus(+) 操作符合并两个 map,返回一个新的 map, 但是忽略了默认值,所以看到上面的错误信息,我们在开发的时候需要注意这点。

使用 require 或者 check 函数作为条件检查

// 传统的做法
val age = -1;
if (age <= 0) {
    throw IllegalArgumentException("age must  not be negative")
}

// 使用 require 去检查
require(age > 0) { "age must be negative" }

// 使用 checkNotNull 检查
val name: String? = null
checkNotNull(name){
    "name must not be null"
}
复制代码

那么我们如何在项目中使用呢,具体的用法可以查看我 GitHub 上的项目 DataBindingDialog.kt 当中的用法。

如何区分和使用 run, with, let, also, apply

感谢大神 Elye 的这篇文章提供的思路 Mastering Kotlin standard functions

run, with, let, also, apply 都是作用域函数,这些作用域函数如何使用,以及如何区分呢,我们将从以下三个方面来区分它们。

  • 是否是扩展函数。
  • 作用域函数的参数(this、it)。
  • 作用域函数的返回值(调用本身、其他类型即最后一行)。

是否是扩展函数

首先我们来看一下 with 和 T.run,这两个函数非常的相似,他们的区别在于 with 是个普通函数,T.run 是个扩展函数,来看一下下面的例子。

val name: String? = null
with(name){
    val subName = name!!.substring(1,2)
}

// 使用之前可以检查它的可空性
name?.run { val subName = name.substring(1,2) }?:throw IllegalArgumentException("name must not be null")
复制代码

在这个例子当中,name?.run 会更好一些,因为在使用之前可以检查它的可空性。

作用域函数的参数(this、it)

我们在来看一下 T.run 和 T.let,它们都是扩展函数,但是他们的参数不一样 T.run 的参数是 this, T.let 的参数是 it。

val name: String? = "hi-dhl.com"

// 参数是 this,可以省略不写
name?.run {
    println("The length  is ${this.length}  this 是可以省略的 ${length}")
}

// 参数 it
name?.let {
    println("The length  is  ${it.length}")
}

// 自定义参数名字
name?.let { str ->
    println("The length  is  ${str.length}")
}
复制代码

在上面的例子中看似 T.run 会更好,因为 this 可以省略,调用更加的简洁,但是 T.let 允许我们自定义参数名字,使可读性更强,如果倾向可读性可以选择 T.let。

作用域函数的返回值(调用本身、其他类型)

接下里我们来看一下 T.let 和 T.also 它们接受的参数都是 it, 但是它们的返回值是不同的 T.let 返回最后一行,T.also 返回调用本身。


var name = "hi-dhl"

// 返回调用本身
name = name.also {
    val result = 1 * 1
    "juejin"
}
println("name = ${name}") // name = hi-dhl

// 返回的最后一行
name = name.let {
    val result = 1 * 1
    "hi-dhl.com"
}
println("name = ${name}") // name = hi-dhl.com

复制代码

从上面的例子来看 T.also 似乎没有什么意义,细想一下其实是非常有意义的,在使用之前可以进行自我操作,结合其他的函数,功能会更强大。

fun makeDir(path: String) = path.let{ File(it) }.also{ it.mkdirs() }
复制代码

当然 T.also 还可以做其他事情,比如利用 T.also 在使用之前可以进行自我操作特点,可以实现一行代码交换两个变量,在后面会有详细介绍

T.apply 函数

通过上面三个方面,大致了解函数的行为,接下来看一下 T.apply 函数,T.apply 函数是一个扩展函数,返回值是它本身,并且接受的参数是 this。

// 普通方法
fun createInstance(args: Bundle) : MyFragment {
    val fragment = MyFragment()
    fragment.arguments = args
    return fragment
}
// 改进方法
fun createInstance(args: Bundle) 
              = MyFragment().apply { arguments = args }
              
              
// 普通方法
fun createIntent(intentData: String, intentAction: String): Intent {
    val intent = Intent()
    intent.action = intentAction
    intent.data=Uri.parse(intentData)
    return intent
}
// 改进方法,链式调用
fun createIntent(intentData: String, intentAction: String) =
        Intent().apply { action = intentAction }
                .apply { data = Uri.parse(intentData) }
复制代码

汇总

以表格的形式汇总,更方便去理解

函数 是否是扩展函数 函数参数(this、it) 返回值(调用本身、最后一行)
with 不是 this 最后一行
T.run this 最后一行
T.let it 最后一行
T.also it 调用本身
T.apply this 调用本身

使用 T.also 函数交换两个变量

接下来演示的是使用 T.also 函数,实现一行代码交换两个变量?我们先来回顾一下 Java 的做法。

int a = 1;
int b = 2;

// Java - 中间变量
int temp = a;
a = b;
b = temp;
System.out.println("a = "+a +" b = "+b); // a = 2 b = 1

// Java - 加减运算
a = a + b;
b = a - b;
a = a - b;
System.out.println("a = " + a + " b = " + b); // a = 2 b = 1
        
// Java - 位运算
a = a ^ b;
b = a ^ b;
a = a ^ b;
System.out.println("a = " + a + " b = " + b); // a = 2 b = 1

// Kotlin
a = b.also { b = a }
println("a = ${a} b = ${b}") // a = 2 b = 1

复制代码

来一起分析 T.also 是如何做到的,其实这里用到了 T.also 函数的两个特点。

  • 调用 T.also 函数返回的是调用者本身。
  • 在使用之前可以进行自我操作。

也就是说 b.also { b = a } 会先将 a 的值 (1) 赋值给 b,此时 b 的值为 1,然后将 b 原始的值(2)赋值给 a,此时 a 的值为 2,实现交换两个变量的目的。

in 和 when 关键字

使用 in 和 when 关键字结合正则表达式,验证用户的输入,这是一个很酷的技巧。

// 使用扩展函数重写 contains 操作符
operator fun Regex.contains(text: CharSequence) : Boolean {
  return this.containsMatchIn(text)
}

// 结合着 in 和 when 一起使用
when (input) {
  in Regex("[0–9]") -> println("contains a number")
  in Regex("[a-zA-Z]") -> println("contains a letter")
}
复制代码

in 关键字其实是 contains 操作符的简写,它不是一个接口,也不是一个类型,仅仅是一个操作符,也就是说任意一个类只要重写了 contains 操作符,都可以使用 in 关键字,如果我们想要在自定义类型中检查一个值是否在列表中,只需要重写 contains() 方法即可,Collections 集合也重写了 contains 操作符。

val input = "kotlin"

when (input) {
    in listOf("java", "kotlin") -> println("found ${input}")
    in setOf("python", "c++") -> println("found ${input}")
    else -> println(" not found ${input}")
}
复制代码

Kotlin 的单例三种写法

我汇总了一下目前 Kotlin 单例总共有三种写法:

  • 使用 Object 实现单例。
  • 使用 by lazy 实现单例。
  • 可接受参数的单例(来自大神 Christophe Beyls)。

使用 Object 实现单例

代码:

object WorkSingleton
复制代码

Kotlin 当中 Object 关键字就是一个单例,比 Java 的一坨代码看起来舒服了很多,来看一下编译后的 Java 文件。

public final class WorkSingleton {
   public static final WorkSingleton INSTANCE;

   static {
      WorkSingleton var0 = new WorkSingleton();
      INSTANCE = var0;
   }
}
复制代码

通过 static 代码块实现的单例,优点:饿汉式且是线程安全的,缺点:类加载时就初始化,浪费内存。

使用 by lazy 实现单例

利用伴生对象 和 by lazy 也可以实现单例,代码如下所示。

class WorkSingleton private constructor() {

    companion object {

        // 方式一
        val INSTANCE1 by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) { WorkSingleton() }

        // 方式二 默认就是 LazyThreadSafetyMode.SYNCHRONIZED,可以省略不写,如下所示
        val INSTANCE2 by lazy { WorkSingleton() }
    }
}
复制代码

lazy 的延迟模式有三种:

  • 上面代码所示 mode = LazyThreadSafetyMode.SYNCHRONIZED,lazy 默认的模式,可以省掉,这个模式的意思是:如果有多个线程访问,只有一条线程可以去初始化 lazy 对象。

  • 当 mode = LazyThreadSafetyMode.PUBLICATION 表达的意思是:对于还没有被初始化的 lazy 对象,可以被不同的线程调用,如果 lazy 对象初始化完成,其他的线程使用的是初始化完成的值。

  • mode = LazyThreadSafetyMode.NONE 表达的意思是:只能在单线程下使用,不能在多线程下使用,不会有锁的限制,也就是说它不会有任何线程安全的保证以及相关的开销。

通过上面三种模式,这就可以理解为什么 by lazy 声明的变量只能用 val,因为初始化完成之后它的值是不会变的。

可接受参数的单例

但是有的时候,希望在单例实例化的时候传递参数,例如:

Singleton.getInstance(context).doSome()
复制代码

上面这两种形式都不能满足,来看看大神 Christophe Beyls 在这篇文章给出的方法 Kotlin singletons with argument 代码如下。

class WorkSingleton private constructor(context: Context) {
    init {
        // Init using context argument
    }

    companion object : SingletonHolder<WorkSingleton, Context>(::WorkSingleton)
}


open class SingletonHolder<out T : Any, in A>(creator: (A) -> T) {
    private var creator: ((A) -> T)? = creator
    @Volatile
    private var instance: T? = null

    fun getInstance(arg: A): T {
        val i = instance
        if (i != null) {
            return i
        }

        return synchronized(this) {
            val i2 = instance
            if (i2 != null) {
                i2
            } else {
                val created = creator!!(arg)
                instance = created
                creator = null
                created
            }
        }
    }
}
复制代码

有没有感觉这和 Java 中双重校验锁的机制很像,在 SingletonHolder 类中如果已经初始化了直接返回,如果没有初始化进入 synchronized 代码块创建对象,利用了 Kotlin 伴生对象提供的非常强大功能,它能够像其他任何对象一样从基类继承,从而实现了与静态继承相当的功能。 所以我们将 SingletonHolder 作为单例类伴随对象的基类,在单例类上重用并公开 getInstance()函数。

参数传递给 SingletonHolder 构造函数的 creator,creator 是一个 lambda 表达式,将 WorkSingleton 传递给 SingletonHolder 类构造函数。

并且不限制传入参数的类型,凡是需要传递参数的单例模式,只需将单例类的伴随对象继承于 SingletonHolder,然后传入当前的单例类和参数类型即可,例如:

class FileSingleton private constructor(path: String) {

    companion object : SingletonHolder<FileSingleton, String>(::FileSingleton)

}
复制代码

总结

到这里就结束了,Kotlin 的强大不止于此,后面还会分享更多的技巧,在 Kotlin 的道路上还有很多实用的技巧等着我们一起来探索。

例如利用 Kotlin 的 inline、reified、DSL 等等语法, 结合着 DataBinding、LiveData 等等可以设计出更加简洁并利于维护的代码,更多技巧可以查看我 GitHub 上的项目 JDataBinding

计划建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,正在逐渐增加 Jetpack 新成员,仓库持续更新,可以前去查看:AndroidX-Jetpack-Practice, 如果这个仓库对你有帮助,请帮我点个赞,我会陆续完成更多 Jetpack 新成员的项目实践。

参考链接

结语

致力于分享一系列 Android 系统源码、逆向分析、算法、翻译相关的文章,目前正在翻译一系列欧美精选文章,请持续关注,除了翻译还有对每篇欧美文章思考,如果对你有帮助,请帮我点个赞,感谢!!!期待与你一起成长。

算法

由于 LeetCode 的题库庞大,每个分类都能筛选出数百道题,由于每个人的精力有限,不可能刷完所有题目,因此我按照经典类型题目去分类、和题目的难易程度去排序

  • 数据结构: 数组、栈、队列、字符串、链表、树……
  • 算法: 查找算法、搜索算法、位运算、排序、数学、……

每道题目都会用 Java 和 kotlin 去实现,并且每道题目都有解题思路,如果你同我一样喜欢算法、LeetCode,可以关注我 GitHub 上的 LeetCode 题解:Leetcode-Solutions-with-Java-And-Kotlin,一起来学习,期待与你一起成长

Android 10 源码系列

正在写一系列的 Android 10 源码分析的文章,了解系统源码,不仅有助于分析问题,在面试过程中,对我们也是非常有帮助的,如果你同我一样喜欢研究 Android 源码,可以关注我 GitHub 上的 Android10-Source-Analysis,文章都会同步到这个仓库

Android 应用系列

精选译文

工具系列

逆向系列