Kotlin教程

概述

Kotlin的历史

Kotlin由世界上IDE做得最好的公司JetBrains开发,2010年面向大众推出,是一门年轻的、现代化的编程语言。Kotlin这个名字来自于JetBrains公司附近的一个岛屿,叫科特林岛。估计这帮人没事就去岛上游游泳,钓钓鱼,泡泡妹纸,顺便写写代码;慢慢就爱上了这个岛,用了它的名字。

JetBrains的IDE做的那么好,当然最懂开发者的尿性,它发明的语言就是以解决实际开发过程中的痛点和难点为目标的。Kotlin可以让你面向多个平台编写程序,你可以用它写服务端,前端,各系统的原生应用,Android应用。

Kotlin在很长一段时间内没有什么声音,直到2017年谷歌在I/O大会上宣布推荐Kotlin作为Android开发语言。一石激起千层浪,长江后浪推前浪,Java死在沙滩上。全世界的浪,哦不,开发者开始关注Kotlin,越来越多的公司和个人开始尝试使用Kotlin开发Android应用。

在2019年的I/O大会上,谷歌再次宣布Kotlin为Android开发的首选语言,并且Android官方的类库代码将逐渐切换为Kotlin实现。现在是学习Kotlin的最佳时刻,赶紧滴,再晚就上不了车了!

Kotlin的优势

从目前来看,Kotlin主要用来开发Android应用,并且已经成为事实上Android开发的首选语言,不管你用不用,学不学,都无法改变这个局面。根据个人经验,用Kotlin替代Java编写基于Spring技术栈的Web应用也非常的爽。一句话,用过都说好,一切能用Java编写的程序,Kotlin都能做得更好!

我是个乐观派,我认为Kotlin替代Java只是时间的问题;在Android开发领域已经成为现实,在Web开发领域,还需要更多人去实践和推广。

如果你是Android原生应用开发者,那Kotlin一定是最好的选择;如果你是Java Web开发者,不妨也尝试一下,说不定就喜欢上了呢!

对于Android开发,Kotlin拥有以下几个实实在在的好处:

  1. 语法极具表现力和可读性,这非常有助于我们构建大型的,可扩展的项目。我用过JavaScript,Go,Python,在使用的体验和舒服程度上,Kotlin无出其右
  2. 完全兼容Java,我们可以无缝使用现有的Java代码和类库
  3. 学习曲线非常平缓,在我学过的所有语言中,Kotlin是最容易上手的
  4. Kotlin能大大减少代码量,正常情况下能轻松减少30%;更少的代码意味着更低的Bug率

本教程的优势

Kotlin官方网站已经有教程,为什么重写一套?

Kotlin的官方教程重在详尽的讲述所有的语法和特性,有这样几个问题:

  1. 官方教程没有强调主次轻重之分,我们学东西的目的就是用最少的时间掌握对我们有用的知识点;本教程会侧重讲解开发中最实用的东西,而用不到的东西尽量少说甚至忽略
  2. 官方教程在语言上平淡枯燥,不够生动有趣;本教程由资深老司机编写,带你领略速度的激情
  3. 官方教程在用例上比较简洁,不够深入到实际的应用场景;本教程力求每个示例都来自于真实的开发场景

准备好了吗?赶紧上车吧?!

Hello Kotlin

Hello Kotlin

按照国际惯例,在开始一门语言的学习之前,先来一个Hello World!Kotlin版本的Hello World长这样:

fun main() {
    var s = "Hello Kotlin"
    println(s)
}

复制代码

这个Hello World足以展示出Kotlin的简洁和可读性了:

  1. fun来声明函数,短小精悍
  2. main函数不需要参数,这是应该的;可以想想我们什么时候用过main函数的参数
  3. Kotlin代码行尾没有分号,现在都9102年了,难道编译器还不能自动加分号吗?
  4. var声明变量,而且不需要指定变量类型,Kotlin会智能推断出类型;如果要声明常量可以用val

从上面4点可以看出Kotlin代码恰到好处,一点也不拖泥带水。我能用100个4个字的词夸它,你信不信?

IDE选择

就像作为一个剑客,必须要够贱,哦不,必须要有一把好剑一样。要想学好一门语言,一款称心如意的开发工具必不可少。好的IDE会让你事半功倍,兴趣盎然,斗志昂扬。Kotlin是JetBrains开发的,IDE当然要用JetBrains开发的宇宙最强的开发工具 - IDEA

TIP

IDEA分为社区版和专业版,社区版免费但功能有限,不过学Kotlin完全够用,专业版收费还挺贵。可以求助于万能的淘宝,买到便宜又实惠的激活码。公司有钱的话可以向公司申请购买正版软件,支持正版,远离盗版!

点击这里进入IDEA官网进行下载,下载的IDEA包含了Kotlin的所有东西,比如Kotlin编译器和语法提示插件,有了IDEA就能愉快的学习Kotlin了!

创建Kotlin工程

注意

本教程使用的IDEA版本为2018.3.5,请尽量不要比我的版本老。如果版本太老,出了幺蛾子,要自己负责任,毕竟都是成年人了。

接下来,我将使用一组图片描述如何使用IDEA创建Kotlin工程,一图胜千言。

创建好的工程界面应该是这样的:

src目录为源代码目录,我们在这个目录下面右键即可创建出kotlin file

友情提示

当工程第一次打开时,会尝试下载Gradle。如果下载失败,请到Gradle官网自行下载并解压,然后在第4步中选择Use local gradle distribution,选择刚刚解压的Gradle目录即可。

接下来,正式开始Kotlin的学习之旅吧。

变量与基本类型

变量声明

和Java不一样,Kotlin使用var声明变量,使用val声明不可被更改的变量,变量和类型之间使用:分割。比如:

var name: String = "lxj"
var age: Int = 10
val city = "武汉"
city = "北京" //编译报错

复制代码

Kotlin有强大的类型推断系统,能够根据变量的值推断出变量的类型,所以类型往往可以省略不写:

var name = "lxj"
var age = 10

复制代码

数字型

Kotlin的数字类型和Java非常像,提供了如下几种类型来表示数字:

TypeBit width
Byte8
Short16
Int32
Long64
Float32
Double64

我们可以用字面量来定义这些数据类型:

val money = 1_000_000L //极具可读性
val mode = 0x0F //16进制
val b = 0b00000001 //byte
val weight = 30.6f

复制代码

Kotlin还提供了这些数据类型之间相互转换的方法:

println(1.toByte())
println(1L.toInt())
println(1f.toInt())

复制代码

字符和布尔

Kotlin的字符和布尔,与Java一样。

var c = 'A'
var isLogin = false

复制代码

数组

数组在Kotlin中用Array表示,一般我们这样创建数组:

val arr = arrayOf(1, 2, 3)
arr[0] //获取第0个元素
arr.size //数组的长度
arrayOf("a", "b").forEach { //遍历数组并打印每个元素
    println(it)
}

复制代码

Kotlin的Array比Java的Array强大太多,支持很多高阶函数,功能几乎和集合一样;高阶函数的部分在后面的集合章节有更详细的讲述。

字符串

字符串类型是String,用双引号""表示:

val s = "abc"
s[0]
s.length
s.forEach { println(it) }

复制代码

Kotlin的字符串是现代化的字符串,支持原始字符串(raw string),用三个引号包起来:

println("""
    床前明月光,疑是地上霜;
    举头望明月,低头思故乡。
""".trimIndent())  //字符串的内容会原样输出

复制代码

同时Kotlin还支持字符串插值,可以将变量的值插入到字符串中:

var name = "李晓俊"
var age = 20
println("大家好,我叫$name,我今年${age}岁了。")

复制代码

区间

区间(Range)严格来说不属于基本类型,但重开一篇又感觉杀鸡焉用牛刀,所以就放在这了。

区间是用来表示范围的数据类型,比如从1到5,从AB。它写起来非常简单,要表示从1到5的范围,可以这样写:

var range = 1..5
var range2 = 'A'..'E'

复制代码

它还有函数形式的写法是1.rangeTo(5),它们是完全等同的,但1..5形式看起来简洁,使用得比较多。

区间实现了Iterable接口,所以是可迭代可遍历的,可以使用for..in或者forEach来遍历它:

for (i in 1..5){
    println(i)
}
(1..5).forEach {
    println(it)
}

复制代码

默认情况下区间是闭区间,也就说1..5是包含1-5的所有值,如果不想包含末尾值,可以使用until关键字实现:

for (i in 1 until 5){
    println(i) //将不会打印出5
}

复制代码

区间遍历时,值是一步一步向上增长的,如果希望每次走2步,可以使用step关键字实现:

for (i in 1..5 step 2){
    println(i) //将会打印出1,3,5
}

复制代码

默认的区间是递增遍历,如果你需要一个递减遍历的区间,可以使用downTo做到:

for (i in 5 downTo 1 step 2){
    println(i) //将会打印出5,3,1
}

复制代码

要判断一个数字是否在一个区间之内,需要使用in操作符,比如:

println(3 in 1..5) //true
复制代码

控制流程

If表达式

Kotlin没有三元运算符,因为它的if/else不仅是条件判断语句,也是一个表达式,有返回值,完全可以替代三元运算符。

var age = 30
var name = if (age > 30) "中年" else "青年"

复制代码

if/else的分支可以是代码块,最后的表达式作为该块的值:

var name = if (age > 30) {
    println("我是中年啦,体力不支了")
    "中年"
} else {
    println("我还是很年轻,精力很充沛哦")
    "青年"
}

复制代码

When表达式

Kotlin没有switch,也不需要,因为when表达式足够强大了。

var cup = 'A'
var say = when(cup){
    'A' -> "一般般啦"
    'B' -> "还不错哦"
    'C' -> "哇!哇!"
    'D' -> "我的天哪!"
    else -> "有点眼晕!"
}

复制代码

when的分支条件可以是任意表达式,而不只是常量。

var weight = 110
when(weight){
    // in可以判断一个值是否在一个区间之内
    in 100..110 -> println("正常")
    in 120..140 -> println("微胖")
}

复制代码

For循环

for 循环可以对任何提供迭代器(iterator)的对象进行遍历,比如数组和集合。对一个区间进行遍历:

for (i in 1..3) {
    println(i)
}
//向下递减遍历,每次减2
for (i in 10 downTo 0 step 2){
    println(i)
}

复制代码

如果要遍历一个数组,可以这么做:

var arr = arrayOf("A", "B", "C")
for (i in arr.indices){
    println(arr[i])
}

复制代码

在Kotlin中我们一般只用for循环遍历区间,而不去遍历数组和集合;因为数组和集合有更强大的forEach方法:

arr.forEach { println(it) }

复制代码

While循环

Kotlin仍然支持while循环和do..while循环。

var i = 5
while(i > 0){
    println(i)
    i--
}
do {
    //retry() //重试请求
} while (i > 0)

复制代码

中断与返回

一个典型的例子是在嵌套for循环中,如果想中断外层循环,可以这么做:

out@ for (i in 1..100) {
    for (j in 1..100) {
        if (j>10) break@out
    }
}

复制代码

再看一个容易让人迷惑的例子,在一个方法中的for循环内部进行返回,默认返回的是方法:

fun foo() {
    listOf(1, 2, 3, 4, 5).forEach {
        if (it == 3) return // 返回的是foo()的调用
        print(it)
    }
    println("这个打印根本到不了。")
}

复制代码

这个设计是Kotlin有意为之的,也是合理的,因为这种逻辑场景下我们大多数都希望直接返回函数调用。如果真的想返回forEach循环可以这么做:

fun foo() {
    listOf(1, 2, 3, 4, 5).forEach {
        if (it == 3) return@forEach // 返回的是foo()的调用
        print(it)
    }
    println("这个打印可以执行到。")
}
复制代码

函数

函数声明

Kotlin使用fun来声明函数,其中返回值用:表示,参数和类型之间也用:表示。

下面来声明一个函数,接收一个Int参数,并返回一个Int结果:

//声明一个方法,接收一个Int类型的参数,返回一个Int类型的值
fun makeMoney(initial: Int) : Int{
    println("make money 996!")
    return initial * 10
}
var money = makeMoney(10)//调用函数

复制代码

如果一个方法没有返回值,可以用Unit表示,等同于Java的void,比如这样写:

fun makeMoney(): Unit{
    println("work hard,make no money!")
}

复制代码

在Kotlin中如果一个方法的返回值是Unit,则可以省略不写:

fun makeMoney2(){
    println("work hard,make no money!")
}

复制代码

默认参数

默认参数是现代化编程语言必备的语法特色。你肯定和我一样,早就厌倦了Java的又臭又长的毫无意义的方法重载。假设我们要打印学生的信息,如果大部分学生的城市都是武汉,年龄都是18岁,那就可以用默认参数来定义:

fun printStudentInfo(name: String, age: Int = 18, city: String = "武汉"){
    println("姓名:$name 年龄:$age 城市:$city")
}
printStudentInfo(name = "李雷") //姓名:李雷 年龄:18 城市:武汉
printStudentInfo(name = "韩梅梅", age = 16) //姓名:韩梅梅 年龄:16 城市:武汉

复制代码

在调用多个参数的函数时,强烈建议像上面那样使用命名参数传递,这样更具可读性,而且不需要关心参数传递的顺序。比如:

printStudentInfo(age = 16, name = "韩梅梅")

复制代码

单表达式函数

如果函数有返回值并且只有单个表达式,可以省略大括号,加个=号,像这样简写:

fun square(p: Int) = p * p //无需写返回值类型,Kotlin会自动推断

复制代码

可变参数

Kotlin当然支持可变参数,使用vararg来声明可变参数。

//可变参数ts,是作为数组类型传入函数
fun <T> asList(vararg ts: T): List<T> {
    val result = ArrayList<T>()
    for (t in ts) // ts is an Array
        result.add(t)
    return result
}
val list = asList(1, 2, 3)

复制代码

函数式编程

Kotlin支持类似于JavaScript那样的函数式编程,函数可以赋值给一个变量,也可以作为参数传递。

//使用square变量记录匿名函数
var square = fun (p: Int): Int{
    return p * p
}
println(square(10))

//接收函数作为参数,onAnimationEnd是函数类型
fun doAnimation(duration: Long, onAnimationEnd: (time: Long)->Unit){
    //执行动画,动画执行结束后调用onAnimationEnd
    println("执行动画")
    onAnimationEnd(duration)
}
doAnimation(2000, { time ->
    println("animation end,time:$time")
})

复制代码

如果最后一个参数是函数的话,Kotlin有一种更简洁的写法,可以将函数代码块直接拿到大括号外面写:

doAnimation(2000) { time ->
     println("animation end,time:$time")
}

复制代码

像这种在大括号外面的函数,省略了参数和返回值类型,使用箭头链接方法体,写法极其简洁,又叫做Lambda表达式。

扩展函数

Kotlin的扩展函数是一个非常有特色并且实用的语法,可以让我们省去很多的工具类。它可以在不继承的情况下,增加一个类的功能,声明的语法是fun 类名.方法名()

比如:我们可以给String增加一个isPhone方法来判断自己是否是手机号:

fun String.isPhone(): Boolean{
    return length==11 //简单实现
}
"13899990000".isPhone() // true

复制代码

再比如给一个Int增加一个方法isEven来判断自己是否是偶数:

fun Int.isEven(): Boolean {
    return this % 2 == 0
}
1.isEven() // false

复制代码

有了扩展函数,我们几乎不用再写工具类,它比工具类调用起来会更简单,并且更自然,就好像是这个对象本身提供的方法一样。我们可以封装自己的扩展函数库,使用起来爽翻天。

中缀表达式

中缀表达式是一个特殊的函数,只不过调用的时候省略了.和小括号,多了个空格。它让Kotlin的函数更富有艺术感和魔力,使用infix来声明。

来看个例子,我们给String增加一个中缀方法爱(),这个方法接收一个String参数:

infix fun String.爱(p: String){
    println("这是一个中缀方法:${this}$p")
}
//调用中缀方法
"我""你" //这是一个中缀方法:我爱你

复制代码

是一个String对象,调用方法,传入这个参数。

如果将上面的方法增加一个String返回值:

infix fun String.爱(p: String): String{
    println("这是一个中缀方法:${this}$p")
}
//我们可以一直爱到底...
"我""爸爸""妈妈""奶奶"

复制代码

可见中缀表达式可以解放你的想象力,让方法调用看起来跟玩一样。你可以创造出很多有意思的东西。不过中缀表达式有一些限制:

  1. 它必须是一个类的成员函数或者扩展函数
  2. 只能接收一个参数
  3. 参数不能是可变参数,并且不能有默认值

Kotlin的中缀表达式可以让我们像说大白话一样进行编程(声明式编程),这种又叫做DSL。Kotlin标准库中的kotlinx.html大量使用了这种语法,我们可以这样写html:

html {
    body {
        div {

        }
    }
}

复制代码

htmlbodydiv都是一个中缀方法,你可以试着实现一个简单的方法。

局部函数

Kotlin支持局部函数,即在一个函数内部再创建一个函数:

fun add(p: Int){
    fun abs(s: Int): Int{
        return if(s<0) -s else s
    }
    var absP = abs(p)
}

复制代码

局部函数内声明的变量和数据都是局部作用域,出了局部函数就无法使用。

尾递归函数

有时候我们会写一些递归调用,在函数的最后一行进行递归的调用叫做尾递归。如果递归的次数过多,会造成栈溢出,因为每一次递归都会创建一个栈。Kotlin支持使用tailrec关键字来对尾递归进行自动优化,保证不会出现栈溢出。

我们只需要用tailrec修饰一个方法即可获得这种好处,无需额外写任何代码:

tailrec fun findGoodNumber(n: Int): Int{
    return if(n==100) n else findGoodNumber(n+1)
}

复制代码

像上面的函数,使用了tailrec修饰,Kotlin会进行编译优化,生成一个基于循环的实现。大概类似下面这样:

fun findGoodNumber(n: Int): Int{
    var temp = n
    while (temp!=100){
        temp ++
    }
    return temp
}

复制代码

Inline函数

我不打算直接解释什么叫内联函数,先看个例子。假设我们有一个User对象,需要对它进行一些列赋值之后,去调用它的say方法:

var user = User() // 创建User对象,不需要new关键字
user.age = 30
user.name = "李晓俊"
user.city = "武汉"
user.say()

复制代码

上面的代码看起来稍显啰嗦,不够简洁。使用apply内联函数改写为:

//使用apply内联函数进行改写
User().apply {
    age = 30
    name = "李晓俊"
    city = "武汉"
    say()
}

复制代码

是不是更加简洁明了了?

内联函数一般用来简化对某个对象进行一系列调用的场景。Kotlin提供了大量的内联函数:applyalsorunwith等,总结起来它们的作用大都是可以让我们对某个对象进行一顿操作然后返回这个对象或者Unit。并且内联函数不是真的函数调用,会被编译器编译为直接调用的代码,并不会有堆栈开销。

Kotlin的内联函数在项目中会被大量应用,用得最多的是withapply

类和继承

和Java一样,kotlin也使用class声明类,我们声明一个Student类并给它增加一些属性:

class Student {
    var age = 0
    var name = ""
}
//创建对象并修改属性
val stu = Student()
stu.name = "lxj"

复制代码

我们给Student类增加了非私有属性,Kotlin会自动生成属性的setter和getter,通过IDEA提供的工具Show Kotlin Bytecode查看生成的字节码即可得知。

构造函数

既然是类一定少不了构造函数,想通过构造函数给一个类的对象传参可以这样写:

class Student (age: Int, name: String) {
    var age = 0
    var name = ""
}

复制代码

在类名后面加个小括号就是构造函数了,但是类名后面的构造函数不能包含代码,那么如何将传入的agename赋值给自己的属性呢?

Kotlin提供了init代码块用来初始化一些字段和逻辑,init代码块是在创建对象时调用,我们可以在这里对属性进行赋值:

class Student (age: Int, name: String){
    var age = 0
    var name = ""
    //在init代码块中来初始化字段
    init {
        this.age = age
        this.name = name
    }
}

复制代码

但是这样的写法略显啰嗦,Kotlin作为一种现代化的编程语言,肯定有更简洁的写法。其实一个类的属性定义和构造传参通常可以简写为这样:

class Student (var age: Int, var name: String) //加个var关键字即可,如果你不想属性被更改就用val

复制代码

上面的写法要求我们创建对象时必须传递2个参数,如果希望传参是可选的,那么可以给属性设置默认值:

class Student (var age: Int = 0, var name: String = "")
val stu = Student() //不用传参也可以
val stu1 = Student(name = "lxj")
val stu2 = Student(age = 20, name = "lxj")

复制代码

TIP

像上面那样,如果给构造函数的所有参数都设置了默认值,Kotlin会额外生成一个无参构造,所有的字段将使用默认值。这对于有些需要通过类的无参构造来创建实例的框架非常有用。

一般来说,一个类是可以有多个构造函数的,那么Kotlin的类如何编写多个构造函数呢?

像上面那样直接在类名后面写的构造被成为主构造函数,它的完整语法其实要加个constructor关键字:

class Student constructor(var age: Int = 0, var name: String = "")

复制代码

不过Kotlin规定,如果主构造函数没有注解或者可见性修饰符(private/public)来修饰,constructor可以省略。

一个类除了可以有主构造函数,也可以有多个次构造函数。使用constructor关键字给Student类添加次构造函数:

class Student {
    var age = 0
    var name = ""
    //次构造函数不能通过var的方式去声明属性
    constructor(name: String){
        this.name = name
    }
    constructor(age: Int){
        this.age = age
    }
}

复制代码

如果一个类同时拥有主构造和次构造,那么次构造函数必须要调用主构造:

class Student (var age: Int, var name: String){
    constructor(name: String) : this(0, name) {
        //do something
    }
}

复制代码

继承

默认情况下,Kotlin的类是不能被继承的。如果希望一个类可以被其他类继承,需要用open来修饰,继承用冒号:表示:

open class People
class Student : People()

复制代码

如果子类和父类都有主构造,则子类必须调用父类的构造进行初始化:

open class People (var name: String)
class Student(name: String) : People(name)

复制代码

如果子类没有主构造,那么次构造必须使用super关键字来初始化父类:

open class People (var name: String)
class Student : People{
    constructor(name: String): super(name)
}

复制代码

既然是继承,那么就可能遇到属性覆盖和方法覆盖。

如果想对父类的属性覆盖,首先父类的属性要用open修饰,然后子类的属性要用override修饰:

open class People (open var name: String)
class Student : People{
    constructor(name: String): super(name)
    override var name: String = "lxj"
}

复制代码

如果想对父类的方法覆盖,那么道理是一样的:

open class People (open var name: String){
    open fun say(){
        println("i am a people.")
    }
}
class Student : People{
    constructor(name: String): super(name)
    override fun say() {
        println("i am a student.")
    }
}

复制代码

抽象类

使用abstract来声明一个抽象类,抽象类的抽象方法无需添加open即可被覆盖:

abstract class People{
    abstract fun say()
}
class Student : People() {
    override fun say() {
        println("i am a student.")
    }
}

复制代码

Getters 与 Setters

对于一个类的非私有属性,Kotlin都会生成默认的settergetter。当我们对一个对象的属性进行获取和赋值,就会调用默认的settergetter

class Student {
    var name: String = ""
}
val stu = Student()
stu.name = "lxj" //会调用name属性的setter方法
println(stu.name) //会调用name属性的getter方法

复制代码

我们可以这样自定义一个字段的settergetter

class Student {
    var name: String = ""
        //field是个特殊标识符,专门用在setter和getter中,表示当前字段
        get() = if(field.isEmpty()) "LXJ" else field
        set(value) {
            field = value.toLowerCase() //将名字变成小写
        }
}

复制代码

延迟初始化

你可能注意到我在定义类的属性时,经常给属性设置默认值:

class Student {
    var name: String = "" //如果不赋值,会编译报错
}

复制代码

这是因为Kotlin要求显式地对属性进行赋值,但很多时候我们不想一上来就给默认值,希望感情到了待会儿再初始化这个属性。那么可以使用lateinit来声明这个属性:

class Student {
    lateinit var name: String //告诉编译器待会儿初始化这个变量
}

复制代码

但是lateinit声明的变量有个不爽的地方,就是当你用到这个变量的时候,如果这个变量还没有被初始化,你将会收获一个异常:

kotlin.UninitializedPropertyAccessException: lateinit property name has not been initialized

复制代码

也许你希望的是,当你用到这个变量时,如果变量还没有被初始化,那应该得到一个null,而不应该报异常。

这个想法很美好,但是和Kotlin的类型系统相互冲突了。Kotlin中增加了可空类型和非空类型的定义,像上面那样我们声明一个name属性为String类型,是在告诉编译器name是非空类型,所以如果没有初始化,Kotlin不会给你一个null,而是直接GG

至于可空类型如何定义,你现在只需要简单的知道String?就是可空类型,后面我们会专门讨论可空类型的使用。

为了避免你在初始化一个变量之前就使用它而导致GG,Kotlin给每个变量增加了一个属性isInitialized来判断这个变量是否初始化过:

fun printName(){
    if(this::name.isInitialized){ //如果初始化过再使用,可避免GG
        println(name)
    }
}

复制代码

数据类

Java中有一个著名的名词叫JavaBean,就是一个用来描述数据的类。我们定义一个类Person用来描述人的信息,这个类就是一个JavaBean,Kotlin叫数据类。

先来定义一个普通的类:

class People(var name: String, var age: Int)

复制代码

上面的写法Kotlin会生成字段的settergetter;数据类还要求这个类有hashCode()equals()toString()方法,只需添加一个data关键字就变成数据类了:

data class People(var name: String, var age: Int)

复制代码

就是这么简洁,通过Show Kotlin Bytecode工具可以查看生成的字节码。

来一个Java版本的对比一下,就能感受到Kotlin的data class有多强大:

public class People {
    private String name;
    private int age;
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public int getAge() {
        return age;
    }
    public void setAge(int age) {
        this.age = age;
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        People people = (People) o;
        return age == people.age &&
                name.equals(people.name);
    }
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
    @Override
    public String toString() {
        return "People{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

复制代码

嵌套类和内部类

嵌套类就是一个嵌套在类中的类,但并不能访问外部类的任何成员,在实际开发中用的很少:

class Student {
    var name: String = ""

    class AddressInfo{
        var city: String = ""
        var province: String = ""
        fun printAddress(){
            //
        }
    }
}
//调用嵌套类对象的方法
Student.AddressInfo().printAddress()

复制代码

内部类使用inner class声明,可以访问外部类的成员:

class Student {
    var name: String = ""

    inner class AddressInfo{
        var city: String = ""
        var province: String = ""
        fun printAddress(){
            println(name)
        }
    }
}
//调用内部类对象的方法
Student().AddressInfo().printAddress()

复制代码

内部类在Android中用的比较多,比如在Activity中创建Adapter类,此时Adapter可以直接访问Activity的数据,非常方便。

枚举类

Kotlin的枚举和Java很像。直接看例子:

enum class Position{
    Left, Top, Right, Bottom
}
val position = Position.Left

复制代码

自定义枚举的值:

enum class Position(var posi: String){
    Left("left"), Top("top"), Right("right"), Bottom("bottom")
}
复制代码

接口

声明与实现

Kotlin的接口和Java8的接口很像,可以声明抽象方法和非抽象方法。

interface Animal{
    fun run()
    fun eat(){
        println("吃东西")
    }
}

复制代码

编写一个类实现接口:

class Dog : Animal{
    override fun run() {
        println("i can run")
    }
}

复制代码

接口继承

接口之间可以进行继承,语法和类的继承一样:

interface Animal{
    fun run()
    fun eat(){
        println("吃东西")
    }
}
interface LandAnimal : Animal{
    fun dig(){
        println("我会挖洞")
    }
}
class Dog : LandAnimal{
    override fun run() {
        println("i can run")
    }
}
Dog().dig()

复制代码

多继承

在大型项目中,我们的类可能会继承多个接口,而多个接口可能会存在重复的方法。比如:

interface A {
    fun eat()
    fun run(){
        println("A run")
    }
}

interface B {
    fun eat(){
        println("B eat")
    }
    fun run(){
        println("B run")
    }
}

复制代码

A和B接口有着重复的方法,A实现了run方法,B实现了eatrun方法。假设类C同时继承了A和B,那么它需要实现这两个方法。比如:

class C : A, B{
    override fun run() {
        println("c run")
    }
    override fun eat() {
        println("c eat")
    }
}

复制代码

如果类C在run方法中想调用父类对run的实现,应该怎么做呢?

你很快会猜到应该这样写:

class C : A, B{
    override fun run() {
        super.run() //会编译出错
        println("c run")
    }
    override fun eat() {
        println("c eat")
    }
}

复制代码

但是事与愿违,直接调用super.run()会编译报错,原因是A和B都实现了run,编译器搞不懂你的super.run()是要调用谁。所以需要明确指定我们要调用谁的实现,比如想调用A的实现,代码如下:

override fun run() {
    super<A>.run() //编译通过
    println("c run")
}

复制代码

如果C想在eat方法中调用父类的实现,则直接调用super.eat()即可,因为2个父类中只有B实现了eat方法,编译器能确定调用的是谁。比如:

override fun eat() {
    super.eat() //编译通过
    println("c eat")
}
复制代码

泛型

声明泛型

泛型可以大大提高程序的动态性和灵活性。在Kotlin中声明泛型和Java类似:

class MyNumber<T>(var n: T)
//传入参数,如果类型可以推断出来,则可以省略
MyNumber<Int>(1)
MyNumber(1) //Int可以不写
MyNumber<Float>(1.2f) //Float也可以不写

复制代码

泛型赋值

来看一个例子,这个例子说明了父类泛型并不能直接接收子类泛型:

var n1 = MyNumber<Int>(1)
var n2: MyNumber<Any> = n1 //编译报错,尽管Any是Int的父类,Any相当于Java的Object

复制代码

上面的例子在Java中也是无法编译通过的,在Java中需要这样做:

ANumber<? extends Object> n2 = n1;

复制代码

Kotlin提供了out关键字,out T表示可以接收T以及T的子类:

var n1 = MyNumber<Int>(1)
var n2: MyNumber<out Any> = n1 //编译通过

复制代码

再来看一个方法:

fun fill(dest: ArrayList<String>, value: String){
    dest.add(value)
}
fill(arrayListOf<String>(), "22") 
fill(arrayListOf<CharSequence>(), "22") //编译出错,尽管String是CharSequence的实现类

复制代码

上面的方法将一个String装入ArrayList<String>,但有时候我们希望fill方法也能接收泛型是String父类的集合,此时可以使用in String,表示接收String以及它的父类:

fun fill(dest: ArrayList<in String>, value: String){
    dest.add(value)
}
fill(arrayListOf<CharSequence>(), "22") //编译通过

复制代码

in关键字对应了Java中的ArrayList<? super String>

泛型通配符

在Java中如果我们希望一个泛型可以接收所有类型,一般可以使用通配符?

ANumber<?> n2 = new ANumber<Integer>(1);
n2 = new ANumber<Float>(1.2f);

复制代码

在Kotlin中用*表示通配符:

var n2: MyNumber<*> = MyNumber<Int>(1)
n2 = MyNumber(1000L)

复制代码

泛型函数

除了类上面可以声明泛型,函数也可以声明泛型:

fun <T, R> foo(t: T, r: R){
}
//调用函数
foo<Int, String>(1, "2")
复制代码

强大的object

object表达式

很多时候我们想对一个类进行轻微改动(比如重写或实现某个方法),但不想去声明一个子类。在Java里面一般会使用匿名内部类,在Kotlin中使用object关键字来声明匿名类的对象:

Collections.sort(listOf(1), object : Comparator<Int>{
    override fun compare(o1: Int, o2: Int): Int {
        return o1 - o2
    }
}) 

复制代码

有时候我们只需要一个临时对象,封装一些临时数据,而不想为这个对象单独去定义一个类。object也可以做到:

var obj = object  {
    var x: Int = 0
    var y: Int = 0
}
obj.x = 12
obj.y = 33

复制代码

在Java中,匿名内部类访问了局部变量会要求这个变量必须是final的,如果后面又需要对这个变量进行更改的话会非常不方便。在Kotlin中则没有这个限制:

fun calculateClickCount(view: View){
    var clickCount = 0 
    view.setOnClickListener(object : OnClickListener{
        override fun onClick(v: View){
            clickCount ++ //可以直接访问和修改局部变量
        }
    })
}

复制代码

单例声明

单例模式是一种非常有用的设计模式。在Java中实现单例并不是很简单,有时候还要考虑并发问题。成千上万富有智慧的Java程序员创造了多种定义单例的方式,甚至还起了个高大上的名字懒汉式饿汉式;在Java面试题中单例的实现方法出现的频率也非常高。

先看Java中一种典型的饿汉式定义单例的方式:

class HttpClient{
    private HttpClient(){}
    private static HttpClient instance = new HttpClient();
    public static HttpClient getInstance(){
        return instance;
    }
}

复制代码

好的编程语言会尽可能的帮程序员做事情,解放程序员的心智负担。在Kotlin中定义单例只需要使用object关键字声明即可,无需额外做任何事情:

object HttpClient{
    fun executeRequest(){
        //执行请求   
    }
}
//调用单例对象的方法,虽然看起来像静态调用,但实际上是对象调用
HttpClient.executeRequest()

复制代码

object声明单例不但简洁,而且线程安全,这一切由Kotlin的编译器技术来保证。如果你感兴趣底层是如何实现的,可以通过Show Kotlin Bytecode查看,会发现原来Kotlin帮我们干了Java版本的实现。

伴生对象

Kotlin中可以使用companion object声明一种特殊的内部类,而且内部类的类名可以省略,这个内部类的对象被称为伴生对象:

class HttpClient {
    //注意:伴生类并不能访问外部类的成员和方法
    companion object {
        fun create(){
        }
    }
}
//调用伴生对象的方法
HttpClient.create()

复制代码

伴生对象调用方法看起来像单例调用和静态调用,但并不是;还是内部类的实例对象调用。那这有什么卵用呢?

用处就是实现真正的静态调用。

Kotlin中并没有提供直接能进行静态调用的方法,对伴生类的成员和方法添加@JvmStatic注解,就可以实现真正的静态调用:

class HttpClient {
    //注意:伴生类并不能访问外部类的成员和方法
    companion object {
        @JvmStatic var DefaultMethod = "Get"
        @JvmStatic fun create(){
        }
    }
}
//真正的静态变量
HttpClient.DefaultMethod
//真正的静态方法调用
HttpClient.create()

复制代码

Kotlin为什么没有提供更简洁的静态调用呢?

它肯定可以做到,既然没有提供,我个人猜想是不提倡静态类的编写。因为它提供的单例调用和伴生对象调用在便利性上面和静态调用是一样的,调用者使用起来足够方便,没有必要要求一定是静态的内存分配。

集合与高阶函数

创建集合

Kotlin和大多数编程语言一样,有三种集合:List,Set,Map,但Kotlin的集合区分可变集合和不可变集合。

创建可变集合:

var arrayList = arrayListOf(1.2f, 2f)
var list = mutableListOf("a", "b")
list.add("c")
var set = mutableSetOf(1, 2, 2, 3)
set.add(1)
println(set) //[1, 2, 3]
var map = mutableMapOf<String, String>(
    "a" to "b",
    "c" to "d"
)

复制代码

创建不可变集合:

//不可变集合,没有add,remove,set之类的方法,不能修改元素
var list2 = listOf("a") 
list2[0]
var set2 = setOf("b")
var map2 = mapOf("a" to "b")

复制代码

高阶函数

Kotlin的集合提供了非常多强大的扩展函数,允许我们对集合数据进行各种增删改查,过滤和筛选。

  1. 遍历

    list.forEach { println(it) }
    //带索引遍历
    list.forEachIndexed { index, s -> println("$index - $s") }
    
    复制代码
  2. 查询

    list.find { it.contentEquals("a") } //找到第一个包含a的元素
    list.findLast { it.contentEquals("a") } //找到最后一个包含a的元素
    list.first { it.startsWith("a") } //找出第一个满足条件的,找不到抛出异常
    list.last { it.startsWith("a") } //找出最后一个满足条件的,找不到抛出异常
    
    复制代码
  3. 删除

    list.removeAll { it.startsWith("a") } //删除所有以a开头的元素 
    
    复制代码
  4. 过滤

    list.filter { it.contains("a") } //获取所有满足条件的元素
    list.filterNot { it.contains("a") } //获取所有不满足条件的元素
    
    复制代码
  5. reduce操作

    list.reduce { acc, s ->  acc + s }
    //反向reduce
    list.reduceRight { s, acc ->  acc + s}
    
    复制代码
  6. map操作

    list.map { println(it.toUpperCase()) }
    //flatMap
    list.flatMap { it.toUpperCase().toList() }
    
    复制代码
  7. 其他

    //打乱排序
    list.shuffle()
    //替换
    list.replaceAll { if(it=="a") "A" else it }
    list.any { it.contentEquals("a") } //只要有一个元素符合条件就返回true
    list.all { it.contentEquals("a") } //是否所有元素都符合条件
    
    复制代码

更多的高阶函数等待你去尝试,篇幅有限,我只能写到这了。

空安全

非空类型与可空类型

在很多编程语言中,如果我们访问了一个空引用,都会收获一个类似于NullPointerException的空指针异常。Kotlin的类型系统区分可空类型和非空类型,来尽力避免空指针异常。

定义一个非空类型和可空类型:

var name: String = "" //定义非空类型name
name = null //非空类型赋值null,编译出错

var name2: String? //定义可空类型
name2 = null  //可以赋值null

复制代码

对于非空类型,我们可以放心访问它的属性和方法,保证不会出现空指针。

name.length
name.slice(0..2)

复制代码

对于可空类型,直接访问它的属性和方法有可能收获空指针,而且编译器会直接报错;但我们还是需要访问。一般有两种方式来避免空指针:空检查和使用安全调用符?.

空检查

空检查很好理解,我们在Java中也是这样做的。

name2.length //直接访问,编译报错
if(name2!=null){
    name.length
}

复制代码

安全调用符

可以这样来访问成员:

name2?.length //如果name2为null,则返回null

复制代码

可以链式调用:

name2?.substring(3)?.length //只要有一个为null,就返回null
name2?.substring(2) //如果name2为null,则不会执行函数调用

复制代码

Elvis 操作符

当我们有一个可空类型时,经常会遇到这样的逻辑:如果它不是空,就用它;否则使用另外一个。

if/else写就是这样的:

val l = if(name2!=null) name2.length else -1

复制代码

用Elvis操作符?:可以简写为:

val l = name2?.length ?: -1

复制代码

它还可以用在很多这种类似逻辑的场景:

fun findFocusChild(view: View){
    val focusChild = view.getFocusChild() ?: return
    val visibility = focusChild.getVisibility() ?: throw IllegalArgumentException("not visible.")
}

复制代码

! ! 操作符

!!操作符也叫非空断言运算符。由上面得知,当我们访问一个可空类型的成员或者函数时,可以使用空检查或者安全调用符。但如果你非常确定这个变量一定不为空时,也可以使用!!来进行调用:

var name2: String? = null
//其他赋值逻辑
println(name2!!.length) // 这样也可以避免编译报错

复制代码

!!操作符的安全性完全由你自己的逻辑保证,编译器不会进行任何的非空判断。这意味着,如果你的逻辑不够严谨,也就是如果name2如果为空,你仍然会收获一个NPE。

而使用安全调用符则可以保证不出现NPE,!!在实际开发中用的很少,除非你能保证它不为空才可以用。

安全的类型转换

接收参数,处理参数,然后输出结果,这是我们软件开发的基本流程。但有时候接收的参数类型并不是很确定,比如我们本来想要对String进行操作,接收到的是Any参数,但我们以为接收的是一个String。代码如下:

var param: Any?  = getParam()
val s = param as String //as是类型转换标识符

复制代码

如果param真的是一个String,则程序正常工作。但如果它是一个Int呢?又或者它为空呢?这些情况就会导致类型转换失败,收获ClassCastException

我们无法保证接收的参数一切正常,但可以使用as?来进行安全的类型转换:

val s = param as? String 

复制代码

param不是String或者为空,变量s则为null,而程序并不会出现异常。

代理

代理

什么是代理?

代理就是你想去找老婆,但是你现在没有找老婆的功能(比如不认识女生,没有女生的联系方式),而媒婆有这个功能,那媒婆就是一个代理对象。当你要找老婆时,无需自己去实现找老婆的功能,直接调用媒婆的功能即可。

代理设计模式已经被广泛的应用在各个语言的程序当中,比如Java的Spring技术栈,Android的Retrofit网络框架。代理模式可以将调用主体和代理对象的职责分离,有助于项目的维护。

Kotlin中提供了by关键字,直接从语言层面支持的代理模式,无需我们额外编写任何代码。Kotlin的代理分两种:类代理和属性代理。

类代理

类代理也可以看做另一种实现继承的方式,因为它可以让一个类拥有另外一个类的功能。

先来定义一个接口,接口代表着一种能力:

//码农的功能
interface Coder {
    fun writeCode()
}

复制代码

现在有个类想拥有Coder的能力:

class Student : Coder

复制代码

而目前已经有别的类实现了Coder能力:

class A : Coder {
    override fun writeCode() {
        println("write code very happy!")
    }
}

复制代码

此时,Student类就没必要自己再实现一遍,可以将A的对象作为自己的代理对象,让代理对象帮助我们实现。使用by关键字就可以做到:

class Student(c: Coder) : Coder by c
//调用方法,实际上调用了代理的方法
Student(A()).writeCode() //write code very happy!

复制代码

当然如果你愿意,也可以选择覆盖代理对象的某个方法实现:

class Student(c: Coder) : Coder by c {
    override fun writeCode() {
        println("write code 996!")
    }
}
Student(A()).writeCode() //write code 996!

复制代码

但是如果代理对象的方法引用了它自己的属性,我们在自己类中覆盖这个属性则是不会生效的:

interface Coder {
    val company: String
    fun writeCode()
}
class A : Coder {
    override val company = "华为"
    override fun writeCode() {
        println("write code at $company!")
    }
}
class Student(c: Coder) : Coder by c {
    override val company = "阿里巴巴"
}
Student(A()).writeCode() //write code at 华为!

复制代码

其根本原因是最终调用的是代理对象的方法,并不是自己的方法,因此使用的变量仍然是代理对象自己的。

属性代理

属性代理可以让我们使用另外一个类对象来代理属性的Setter和Getter。

来看一个User类,它有一个name属性:

class User {
    var name: String 
}

复制代码

假设我们并不想去关心name属性的Getter逻辑和Setter逻辑(比如范围检查之类的逻辑),而是希望让别的代理类来做,此时就可以编写一个属性代理类。

属性代理类不需要实现任何接口,只需要提供getValue()setValue()方法即可,分别对应属性的Getter和Setter。比如:

class NameDelegate {
    private var _value = "defaultValue"
    //当访问属性的getter时调用
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        println("get -> $thisRef '${property.name}' ")
        return _value
    }
    //当访问属性的setter时调用
    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        //如果不为空就设置
        if(value.isNotEmpty()) _value = value
        println("set -> $value want set to '${property.name}' in $thisRef.")
    }
}

复制代码

NameDelegate的作用非常简单,只有传入的值不是空字符串才进行赋值,否则取值时就返回默认值,默认值目前是写死的,可以通过构造参数传入。

接下来使用这个属性代理,并对User对象的属性进行访问:

class User {
    var name: String by NameDelegate()
}
var user = User()
user.name = "123" //输出:set -> 123 want set to 'name' in User@3af49f1c.
user.name //输出:get -> User@3af49f1c 'name' 

复制代码

上面就是一个属性代理的基本使用,看起来好像跟直接重写属性的Setter和Getter并没有太大区别。那属性代理有什么好处呢?

答案是属性代理将对属性的访问逻辑抽成一个独立的类,便于复用。假设项目中有10个类的某个属性访问逻辑需要自定义时,用Setter和Getter需要在每个类中写一遍,而属性代理只需要写一次即可。

内置代理

标准库已经封装了几种代理,说说其中2个比较常用的:lazy代理Observable代理

lazy代理专门用于属性的延时初始化场景,比如有个集合不想一开始就初始化,等到我们第一次使用它时再进行初始化,好处是可以节省初始内存。lazy只能用在val变量上面,它接收一个函数,将函数的返回值作为属性的值。来看看如何使用:

class User {
    val age: Int by lazy {
        println("do something")
        10
    }
}
var user = User()
println(user.age) //只会打印一次 do something
println(user.age)

复制代码

值的延迟计算默认是线程安全的,如果你确定你是在单线程场景,可以给lazy传入一个参数来取消这个安全,获得一些性能上的提升:

class User {
    val age: Int by lazy(mode = LazyThreadSafetyMode.NONE) {
        println("do something")
        10
    }
}

复制代码

Observable代理一般用在我们想在属性值更改时执行一些逻辑的场景,它接收一个属性初始值和属性更改时的处理函数,每次属性被赋值时都会执行这个函数。

来看看Observable代理的用法:

class User {
    var age: Int by Delegates.observable(10){
        property, oldValue, newValue ->
        println("${property.name}的值从${oldValue}修改为$newValue")
    }
}
var user = User()
user.age = 11 //age的值从10修改为11
user.age = 15 //age的值从11修改为15

复制代码

在Android开发中,lazy代理用的会比较多。其实属性代理功能非常强大,可以用来实现MVVM架构,需要实现一个VM层将类的属性和UI映射起来,监听数据的属性变化,当值被更改时去更新对应UI。

Android官方为了方便大家开发,提供了Jetpack类库,其中的LiveData框架是用Java实现的一个MVVM框架,如果用Kotlin代理来做会更简单一些。

其他语法

This 表达式

在类中,this表示当前类对象的引用。在多层嵌套类中,我们可以使用this@类名来明确指定要访问的是哪个类的对象:

class A { 
    inner class B { 
        //注意:foo是一个Int的扩展方法
        fun Int.foo() { // 隐式标签 @foo
            val a = this@A // A 的 this
            val b = this@B // B 的 this
            val c = this // foo() 的接收者,一个Int对象
        }
    }
}

复制代码

is 与 !is 操作符

使用is来判断对象是否是某个类型;!is语气则相反。

var s: Any = "ss"
println(s is String)
println(s !is Int)

复制代码

异常

Kotlin的异常体系和Java类似,代码如下:

throw Exception("boom!") //抛出异常
try {
    // 一些代码
}
catch (e: SomeException) {
    // 处理程序
}
finally {
    // 可选的 finally 块
}

复制代码

所不同的是,Kotlin的try/catch是一个表达式,有返回值。它的返回值是try代码块中最后一个表达式的值,或者catch代码块中最后一个表达式的值。

val a: Int? = try { parseInt(input) } catch (e: NumberFormatException) { null }

复制代码

Kotlin的异常还有一个好处就是:在有异常抛出的方法中,无需在方法上面显式的再抛出异常。这在Java中是必须做的,有时候你调用了一个会抛出异常的方法,如果我们不try/catch就必须显式抛出。

Kotlin认为这种异常规范对于小项目有用,但对于大项目会导致生产力降低和代码质量下降。示例如下:

fun foo(s: String) { //方法上不在显式抛出异常
    if(s.isEmpty()) throw IllegalArgumentException("s can not be empty!")
}
复制代码

协程(Coroutine)

概念

什么是协程呢?

简单说,协程是比线程更轻量的,有状态,可暂停可恢复的任务单元。

如何理解任务单元呢?

拿做饭来说,将做饭当做一个任务。为了提高做饭的效率,我们会把做饭分成很多小的任务单元:洗菜,切菜,煮米饭,准备配料,炒菜。然后你们全体家庭成员共同上阵,你负责洗菜,爸爸负责煮米饭和准备配料,妈妈负责切菜和炒菜。这些任务有些是可以并行的,比如洗菜和煮米饭;有些是串行的,比如洗菜和切菜。你们一起工作,能大大提高做饭的效率。

对于操作系统而言,进程是运行每个程序的任务单元。每个应用程序都在自己的进程中运行,状态和数据相互隔离,稳定运行;一个程序崩溃了不会影响其他程序运行。这些程序是并发运行的。

对于进程而言,为了提高程序的运行速度,我们会将一些耗时的任务分离为更小的任务单元,就是线程。多个线程并发工作,能大大加快整体任务的执行速度。

既然进程和线程都能通过并发执行提高运行效率,那协程有什么优势呢?一般有2个:

  • 更小的内存开销。进程和线程的内存开销比较大;一般的电脑可以开1000000个协程也没太大问题,但是开10000个线程内存估计就爆掉了,而进程的内存开销更大
  • 没有上CPU下文切换带来的性能开销。线程和进程由CPU来调度执行,每个CPU会执行多个线程,每当切换新线程时,需要先存储当前线程的状态,再加载新线程的状态;在频繁的调度下,切换线程消耗CPU的很多性能。而协程由应用程序控制状态的切换,性能开销要小很多

虽然多线程也能很好进行并发编程,但协程的并发会消耗更少的资源,有更高的调度性能。这对于服务器处理高并发的场景会带来很大的优势。

协程是如何实现的

目前我所知道的支持协程的语言有Python, NodeJs,Go和Kotlin。简单讲,原理大都是OS Thread Pool配合状态机来实现的。协程底层仍然是靠线程池调度,靠状态机来维护状态。具体实现上每个语言都不尽相同,这些细节暂不深究。

拿上面做饭的例子来说,做饭被分割成了很多的task,这些task由你们全家人一起调度。那你们全家人就相当于线程池,这些task就好比是很多个协程。爸爸可能调度多个协程,因为可能很快完成自己的,接着去做别的。爸爸也可能中途暂停煮饭协程,先执行切菜的协程,然后再回头恢复煮饭的协程。

由于协程可暂停和可恢复的特性,能直接消除异步回调,让我们用同步写法编写异步执行代码。很多编程语言在处理异步任务结果时都采用Callback的方式,比如早期的JavaScript。当逻辑复杂的时候,很容易陷入回调地域,导致代码可读性差,可维护性低。来个Kotlin协程的代码示例:

fun main() {
    GlobalScope.launch { 
        var url = "http://www.lixiaojun.xin"
        //等待异步请求返回,无需Callback
        var result = request(url).await()
        println("请求结果为:$result")
    }
}

复制代码

综上所述,协程有以下几个有点:

  • 更少的资源消耗和更高的调度性能
  • 用同步的方式写异步代码,可读性更好
  • 协程比线程更容易使用,不需要关心过多的状态,直接编写逻辑即可

第一个协程

协程不属于Kotlin标准库,需要添加依赖才能使用。在build.gradle文件中添加协程的依赖:

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.0'
}

复制代码

编写一个协程程序,并在协程中延时1秒:

fun main() {
    // 在后台启动一个新的协程
    GlobalScope.launch {
        delay(1000L) // 挂起当前协程,但并不会阻塞程序,1秒后恢复执行
        println("World!") //延迟1秒后打印
    }
    println("Hello,") // 会立即打印,协程的delay并不会阻塞程序
    Thread.sleep(2000L) // 阻塞主线程 2 秒钟来保证 JVM 存活,否则的话协程还未恢复执行,进程就退出了
}
//输出
Hello,
World!

复制代码

可以看到开启协程很简单,我们不用关心哪个线程在调度协程,也不用关心协程的状态,只需要专心编写我们的异步逻辑即可。

delay是一个suspend关键字修饰的挂起函数,会暂停当前协程的执行,但并不阻塞主线程往下进行;等时间到,便恢复执行。

主协程

由于上面的协程无法阻塞住当前线程,我们使用Thread.sleep()来阻塞线程,使得协程有机会得到执行。Kotlin提供了一个特殊的主协程可以阻塞主线程:

fun main() = runBlocking { //开启主协程
    GlobalScope.launch { //开启子协程
        delay(1000L) // 挂起当前协程,但并不会阻塞程序,1秒后恢复执行
        println("World!") //延迟1秒后打印
    }
    println("Hello,") // 会立即打印,协程的delay并不会阻塞程序
}

复制代码

runBlocking开启的为主协程,由于GlobalScope.launch是在一个协程中开启协程,因此我们叫它子协程。

但是上面的World仍然不会得到执行,因为主协程瞬间就执行完毕,并不会等待GlobalScope开启的子协程执行完成才结束。主协程一旦结束,主线程就执行结束,整个程序就结束。

有两种方式可以让主协程等待子线程执行完成才结束:一种是使用delay函数挂起主协程,另一种是让子协程join主协程中。

先看第一种,使用delay函数挂起主协程,挂起的时间要大于子协程挂起的时间:

fun main() = runBlocking { 
    GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
    delay(2000) //挂起主协程,等待子协程执行完毕
}
//输出
Hello,
World!

复制代码

另外一种,使用一个变量记住GlobalScope.launch开启的协程的引用:

fun main() = runBlocking {
    val job = GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
    job.join() //等待子协程执行完才结束
}
//输出
Hello,
World!

复制代码

看起来,使用join方法更加优雅。

协程存活期

继续上面的例子,我们刚才得出GlobalScope.launch开启的子协程并不能阻塞主它的父协程。但仔细想想这不合理。

假设逻辑再复杂一些,在刚才的主协程中,我们开启5个子协程。那就必须手动持有5个子协程的引用,否则无法保证让每个协程得到执行。如果我们忘记持有某个协程的引用,那么这个协程的代码就报废了,因为无法得到执行。如果真的是这样的话,那出错的概率还是很大的。难道父协程不能自动的等所有子协程执行完毕才结束吗?其实是可以的。

为什么上面的例子不行呢?每个协程是有存活期的,在一个协程中开启的子协程的存活期一般不会超过其父协程的存活期。但是GlobalScope比较特殊,它开启的是顶级协程。顶级协程的存活期由整个应用程序管理,并不受主协程限制,相当于直辖市。顶级协程虽然在主协程内部开启,但是在存活期和作用域上和主协程平级,因此它无法阻塞主协程,需要我们手动的join或者delay主协程。

每个协程对象都是CoroutineScope实例,CoroutineScope有个launch方法用来在自己的作用域内开启一个受自己管辖的子协程。而且会自动的等所有子协程执行完毕才结束。将上面的例子稍做改动就可以:

fun main() = runBlocking {
    //去掉了GlobalScope
    val job = launch { //在自己的作用域内开启子协程
        delay(1000L)
        println("World!")
    }
    println("Hello,")
//    job.join() 无需join了
}
//输出
Hello,
World!

复制代码

Kotlin不建议我们直接使用GlobalScope开启顶级协程,通常应该直接使用launch方法在自己的作用域内开启子协程,这样不容易出错。

取消与超时

协程通常用来执行耗时操作。 在Android开发中,我们在一个界面开启协程进行耗时请求。假如此时用户关闭了界面,那么协程的执行结果已经不需要了,因此协程应该是可以被取消的。

协程提供了cancel()方法来取消:

fun main() = runBlocking {
    val job = launch {
        println("i am working...")
        delay(2000L)
        println("work done!") //将不会输出
    }
    delay(1000)
    job.cancel() //取消协程
}

复制代码

有时候耗时操作的时间是不确定的,比如在Android发起一个网络请求,我们并不确定它什么时候会返回,因此超时的处理是必要的。我们假设如果请求超过10秒钟未返回结果,用户已经没有耐心等待了,此时应该结束这个协程了。

使用withTimeout来开启带超时限制的协程:

withTimeout(5000){
    println("start request")
    delay(120000) //延时12秒,模拟巨慢的弱网环境
    println("get result!")
}

复制代码

协程的超时会抛出TimeoutCancellationException异常。如果你不喜欢抛出异常的方式,可以使用withTimeoutOrNull的方式开启协程,如果协程超时则返回null,这样就不再有异常了。

val result = withTimeoutOrNull(5000){
    println("start request")
    delay(120000) //延时12秒,模拟巨慢的弱网环境
    println("get result!")
}
println(result) //null

复制代码

suspend函数

使用suspend修饰的函数叫做挂起函数,delay就是一个挂起函数。由于我们不可能将所有异步逻辑都写到协程中,必然要重构和抽取。比如:

val job = launch { 
    //执行网络请求
    var result = doRequest() 
    println(result)
}
fun doRequest(): String{
    return "请求的结果"
}

复制代码

假设所有的耗时请求都抽取到doRequest方法中,但是普通的方法并不能挂起协程,所以doRequest()无法阻塞住println()。给函数添加suspend修饰符即可:

suspend fun doRequest(): String{
    delay(2000) //模拟请求耗时2秒
    return "请求的结果"
}

复制代码

协程的并发执行

如果协程内有多个耗时操作,默认情况下它们是顺序执行的。Kotlin提供了一个measureTimeMillis函数用来测量一段代码的执行时间:

suspend fun doRequest1(): Int{
    delay(2000)
    return 1
}
suspend fun doRequest2(): Int{
    delay(2000)
    return 2
}
val totalTime = measureTimeMillis {
    doRequest1()
    doRequest2()
}
println("totalTime: $totalTime") // totalTime: 4009

复制代码

为了提高执行效率,我们希望两个耗时操作是并发执行的。使用async就可以做到:

val totalTime = measureTimeMillis {
    val result1 = async { doRequest1() }
    val result2 = async { doRequest2() }
    println("result: ${result1.await() + result2.await()}") //result: 3
}
println("totalTime: $totalTime") //totalTime: 2032

复制代码

async开启一个特殊的协程,能够与其他协程并发工作。它返回一个Deferred对象,该对象可以通过await()来等待异步执行的结果;同时Deferred对象也是一个Job对象,可以cancel()掉。

上面的async代码块一旦执行,协程就开始工作了。有时候我们希望满足某些条件下,协程在开始工作。那么可以这样使用懒惰的async

val totalTime = measureTimeMillis {
    val result1 = async(start = CoroutineStart.LAZY) { doRequest1() } //只是创建协程对象,并未开始工作
    val result2 = async(start = CoroutineStart.LAZY) { doRequest2() } //只是创建协程对象,并未开始工作

    //满足条件了才执行
    result1.start() //协程开始执行
    result2.start() //协程开始执行
    println("result: ${result1.await() + result2.await()}")
}
println("totalTime: $totalTime")

复制代码

异常处理

协程中的逻辑有可能遇到异常,如果我们不处理,他们则默认向上传播给调度线程,从而导致程序崩溃:

fun main() = runBlocking {
    launch {
        throw ArrayIndexOutOfBoundsException()
    }
    launch {
        throw IllegalArgumentException()
    }
    println("start...")
}

复制代码

上面的程序在遇到第一个协程抛出的ArrayIndexOutOfBoundsException时就会终止执行。我们除了在每个协程代码块中进行try/catch之外,也可以设置一个全局的异常处理器。

由于协程最终由线程调度,所有未处理的异常最终都会抛给线程,因此给线程设置全局的异常处理器即可:

fun main() = runBlocking {
    Thread.setDefaultUncaughtExceptionHandler { t, e ->
        println("catch exception: $e")
    }
    GlobalScope.launch {
        throw ArrayIndexOutOfBoundsException()
    }.join()
    launch {
        throw IllegalArgumentException()
    }
    println("start...")
}
//输出
catch exception: java.lang.ArrayIndexOutOfBoundsException
start...
catch exception: java.lang.IllegalArgumentException

复制代码

协程并发安全问题

当我们使用多线程对同一个共享数据进行修改时,很可能遇到线程安全问题。协程本质上仍然由线程调度执行,所以协程并发执行时,也有和线程类似的安全问题。来看一段代码:

fun main() = runBlocking {
    var n = 0
    val list = mutableListOf<Job>()
    repeat(100) {
        list.add(GlobalScope.launch {
            repeat(100) { n++ }
        })
    }
    list.forEach {
        it.join()
    }
    println("n: $n")
}

复制代码

这段代码重复添加100个协程对象,每个协程执行100次++,共执行10000次++操作。运行结果很可能不是10000,可以多次运行看看:

n: 9495

复制代码

TIP

如果你的机器只有不超过2个CPU,你将总是看到10000。因为此时线程池只有一个线程来调度协程,不会出现并发安全问题。

在线程遇到安全问题时我们一般有2种处理方案:一种是加锁,另外一种是使用线程安全的数据结构。

加锁往往会降低效率,因此我们推荐采用第二种方案。JDK提供了大量线程安全的数据结构,我们使用AtomicInteger 来改写代码:

var n = AtomicInteger()
val list = mutableListOf<Job>()
repeat(100) {
    list.add(GlobalScope.launch {
        repeat(100) { n.incrementAndGet() }
    })
}
list.forEach {
    it.join()
}
println("n: $n")

复制代码

无论运行多少次,你将总是得到10000。

Kotlin官方文档为协程并发安全提供了多种解决方案,其中使用线程安全的数据结构是效率最高的方案,这些数据结构由JDK常年迭代进行超细粒度的优化,直接使用即可。

在Android开发中,协程一般用来代替线程执行耗时任务,更有用的是它可以用同步的方式编写异步代码,能够将复杂的异步逻辑变的极具可读性。具体使用时协程配合强大的高阶函数,已经成为事实上的线程调度的最佳方案,RxJava已经没有存在的必要。

标准库

标准库内容

Kotlin的标准库大致包含这样几个部分:

  • 数据类型和集合
  • JS和Native平台相关SDK
  • JDK扩展方法,JVM平台已经非常成熟,所以主要是对JDK进行扩展
  • 其他语言特色

本课程主要面向Android开发者,AndroidSDK基于JDK,所以主要学习下JDK扩展方法中比较重要的部分。

IO扩展

标准版给File类增加了很多实用的扩展,IO操作在实际开发中占相当大的比重。

  • append系列

    val file = File("F:\\kotlin_space\\KotlinCoroutine\\src\\main\\kotlin\\a.txt")
    file.appendText("""
            床前明月光,疑是地上霜;
            举头望明月,低头思故乡。
        """.trimIndent())
    file.appendBytes("哈哈".toByteArray())
    
    复制代码
  • buffer系列

    //读取每行内容并打印
    file.bufferedReader().lineSequence().forEach {
        println(it)
    }
    //向文件写入
    file.bufferedWriter().apply {
        write("呵呵")
        write("嘻嘻")
        flush()
    }
    
    复制代码
  • copy系列

    //拷贝文件
    file.copyTo(File("F:\\kotlin_space\\KotlinCoroutine\\src\\main\\kotlin\\a-copy.txt"))
    //递归拷贝目录
    File("F:\\kotlin_space\\KotlinCoroutine\\src\\main\\kotlin")
                .copyRecursively(File("F:\\kotlin_space\\KotlinCoroutine\\src\\main\\kotlin-copy"))
    
    复制代码
  • 删除系列

    //删除整个目录
    File("F:\\kotlin_space\\KotlinCoroutine\\src\\main\\kotlin-copy").deleteRecursively()
    
    复制代码
  • 读取系列

    println(file.readBytes()) //读取字节
    file.readLines().forEach { println(it) } //直接读取所有行并打印
    println(file.readText())//以文本方式读取整个内容
    
    复制代码
  • 写入系列

    file.writeBytes("床前明月光".toByteArray()) //写入字节
    file.writeText("疑是地上霜") //写入文本
    
    复制代码
  • 其他

    println(file.extension) //文件扩展名
    println(file.nameWithoutExtension)//文件名
    file.forEachLine { println(it) } //直接遍历每一行
    file.forEachBlock { buffer, bytesRead -> println(bytesRead) } //读取字节块
    
    复制代码

    String扩展

    String处理在开发中也是不可或缺。

    val s = "abcde"
    println(s.indices) //获取字符下标的Range对象
    s.all { it!='e' } //所有字符都满足给定条件才是true
    s.any { it=='a' } //只要有一个字符满足条件就是true
    println(s.capitalize()) //首字母大写
    println(s.decapitalize()) //首字母小写
    println(s.repeat(3)) //重复几次
    "[a-zA-Z]+".toRegex().find(s) //转正则
    //还有各种查找,替换,过滤,map,reduce等函数,就不一一展示了...
    
    复制代码

    Sequence类型

    Sequence翻译过来叫序列,是一种延迟计算的集合,它有着和List,Set,Map一样的高阶函数。来看看如何使用序列:

    val list = mutableListOf("a", "bc", "bcda", "feec", "aeb")
    list.asSequence().forEach { println(it) }
    
    复制代码

    List,Set,Map,String都有asSequence()方法直接转为一个序列,看起来和集合没两样。我们用list和序列分别执行相同的逻辑,并计算他们的耗时:

    //序列的版本
    println(measureTimeMillis { list.asSequence().first { it.contains("f")} }) //18
    //list的版本
    println(measureTimeMillis { list.first { it.contains("f")} }) //0
    
    复制代码

    结果发现list比序列快多了!稳住,别急!

    我们将数据量增大,并将逻辑复杂化来看看:

    val list2 = mutableListOf<String>().apply {
            repeat(1000000){ //将数据量增加到100万
                add("abcdefg"[Random.Default.nextInt(7)].toString())
            }
        }
    println(measureTimeMillis { list2.asSequence().map { "${it}-aaa" }.filter { it.startsWith("a") } }) //19
    println(measureTimeMillis { list2.map { "${it}-aaa" }.filter { it.startsWith("a") } }) //136
    
    复制代码

    可以看到序列的性能比list提高了86%?!

    所以,如果你的场景满足以下两个条件,应该优先使用序列,否则都使用集合:

    1. 数据量超级大,百万级别
    2. 对数据集进行频繁的操作

    然而Android开发属于客户端开发,基本不太可能遇到这么大的数据量。一般是后台对大数据集进行处理好,返给我们,客户端顶一般都会分页加载,一次加载20条。所以,Sequence在Android开发中基本没有用武之地?。

Gradle

Gradle简介

每种编程语言都有自己的包管理器,比如Python用的是pip,Dart用的是pub,NodeJs用的是npm。包管理器最显而易见的功能就是管理项目的依赖库,通俗的讲,就是让你方便的用别人的类库,你也可以分享自己的类库给别人用。

但Gradle的功能其实远不止包管理器,它还可以对代码进行混淆,压缩,动态构建;严格意义上讲,它应该属于项目构建工具。

JavaWeb技术栈的同学喜欢用Maven,但Gradle在构建速度和扩展性上都比Maven好,可以说是JVM平台项目的首选构建工具;做Android开发也是用这个构建工具。

Gradle不需要额外安装和下载,当你初次创建Kotlin工程时,IDEA会自动下载Gradle。

build.gradle文件

Gradle是通过build.gradle来配置项目的,这个文件在你创建工程时会自动生成,它的内容大致如下,注释都写在里面:

//构建项目首先会执行的部分
buildscript {
    ext.kotlin_version = '1.3.0'
    repositories {
        mavenCentral()
    }
    //添加Kotlin插件到classpath
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}
apply plugin: "kotlin" //使用Kotlin插件
apply plugin: "java"   //使用java插件
group 'com.lxj'
version '1.0-SNAPSHOT'

sourceCompatibility = 1.8

repositories {
    mavenCentral() //指定下载类库的仓库
}
//指定要依赖的三方库类库
dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}"

    testCompile group: 'junit', name: 'junit', version: '4.12'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.0.0'
}

复制代码

如果我们要依赖一个新的三方库,直接将类库加到dependencies下面即可。以网页解析库jsoup为例:

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}"

    testCompile group: 'junit', name: 'junit', version: '4.12'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.0.0'
    compile 'org.jsoup:jsoup:1.11.3' //jsoup
}

复制代码

然后刷新Gradle即可,如下图示:

Gradle的知识点非常多,要讲详细必须重开一套教程,这篇教程的重点在Kotlin内容的学习,Gradle的知识先简单了解即可。

爬虫项目实战

爬虫介绍

爬虫就是抓取某个或某些Url地址的数据,可供自己使用;如果数据有价值,也可以商用。

就像要把大象装冰箱一样,爬虫一般也有三个步骤:

  1. 抓取Url数据
  2. 解析数据
  3. 使用数据,具体怎么使用看你的需求

要爬取目标网站是:quotes.toscrape.com/,该网站是一个国外的网站,专门展示名人名言。简单一点,我们只爬取首页的数据。

首页有十条数据,我们需要爬取每条名言的作者,内容和标签。

抓取数据

抓取数据需要用到一个三方类库,就是上个小节提到的jsoup,它具有http请求和网页数据解析的双重功能。先将它添加到依赖库,然后创建crawler.kt文件来编写爬虫。

编写一个getHtml方法来抓取数据,抓取数据是耗时操作,我们使用协程实现:

suspend fun main() {
    val url = "http://quotes.toscrape.com/"
    //1.抓取数据
    val document = getHtml(url).await()
}

fun getHtml(url: String): Deferred<Document?> {
    return GlobalScope.async {
        Jsoup.connect(url).get()
    }
}

复制代码

解析数据

解析数据本质是解析html的结构结构,找到对应的标签,取出文本数据,这里需要你有一些基本的html知识。为了更好的分析目标元素的Dom结构,可以利用Chrome的开发者工具。

编写一个方法parseHtml来解析数据:

fun parseHtml(document: Document) {
    val elements = document.select("div.quote")
    elements.forEach {
        val content = it.selectFirst("span.text").html()
        val author = it.selectFirst("span>small.author").html()
        val tagEls = it.select("div.tags>a")
        tagEls.forEach { tag -> println(tag.html()) }
    }
}

复制代码

数据虽然解析出来了,但是这些数据是散乱的,不方便传输,处理以及下一步的使用。我们需要编写一个类来封装这些信息:

data class Quote(
        var content: String,
        var author: String,
        var tags: List<String>
){
    fun toJson() = """
        {
            "content": $content,
            "author": $author,
            "tags": [${tags.joinToString(separator = ", ")}]
        }
    """.trimIndent()
}

复制代码

改写parseHtml方法如下:

fun parseHtml(document: Document): List<Quote> {
    val elements = document.select("div.quote")
    val list = mutableListOf<Quote>()
    elements.forEach {
        val content = it.selectFirst("span.text").html()
        val author = it.selectFirst("span>small.author").html()
        val tagEls = it.select("div.tags>a")
        val tags = mutableListOf<String>()
        tagEls.forEach { tag -> tags.add(tag.html()) }
        list.add(Quote(content = content, author = author, tags = tags))
    }
    return list
}

复制代码

最终的main方法如下:

suspend fun main() {
    val url = "http://quotes.toscrape.com/"
    //1.抓取数据
    val document = getHtml(url).await()
    //2.解析数据
    if (document != null) {
        parseHtml(document)
    }
}

复制代码

使用数据

在企业级项目中我们在使用数据之前可能会将数据进行持久化存储,比如保存到Mysql。具体怎么使用,每个公司的需求都不一样,可以用图表展示,数据量大的话可以用大数据框架进行处理。我们这里只是简单的打印Json,编写一个方法printData

fun printData(quotes: List<Quote>){
    quotes.forEach { println(it.toJson()) }
}

复制代码

最终的main方法如下:

suspend fun main() {
    val url = "http://quotes.toscrape.com/"
    //1.抓取数据
    val document = getHtml(url).await()
    //2.解析数据
    if (document != null) {
        val quotes = parseHtml(document)
        //3.打印数据
        printData(quotes)
    }
}

复制代码

运行项目,将会打印出Json结构的数据:

{
    "author": Albert Einstein,
    "content": “The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”,
    "tags": [change, deep-thoughts, thinking, world]
}
{
    "author": J.K. Rowling,
    "content": “It is our choices, Harry, that show what we truly are, far more than our abilities.”,
    "tags": [abilities, choices]
}
...

复制代码

通过这个小小的爬虫项目,我们综合练习了数据类,Kotlin协程,使用Gradle添加三方库,集合和高阶函数,原生字符串等知识。

我们目前只爬取了网站首页的数据,如果你对爬虫感兴趣,思考一下,如何能爬取整个网站的数据呢?

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值