《第一行代码》Kotlin基础学习总结

803 阅读19分钟

作为一个Android菜鸡,很汗颜到现在还没学Kotlin,更汗颜神级教科书《第一行代码》竟然还没看过。借这个机会,我终于要“一雪前耻”。也很推荐有一定基础的Android开发同学可以通过《第一行代码》第3版中每一章的末尾去学习Kotlin。这篇文章简单总结一下我个人认为的,有Java基础的前提下,需要关注的Kotlin语法。

基本语法

变量修饰符

valvar可推导字段类型。const用于修饰编译时已知值的常量,但只能放于顶层或者object(匿名类、伴生类、单例类)中。

函数修饰符

不修饰默认是public级别。 跟java不同,protected只对当前类与子类可见,对同一包路径下的类不是可见。 没有default,多了internal,对同模块下的类可见。

类修饰符

data class,数据类,用data声明后,会根据主构造函数中的参数自动生成hashCode()equals()toString()方法:

data class Cellphone(val brand: String, val price: Double) 

object class,单例类,不用再写各种饿汉懒汉双重锁了,一个object就搞定:

object Singleton {
    fun test(){}
}

调用方式类似Java中的静态方法:Singleton.test()

构造函数

分主构造函数与次构造函数 与Java一样,写主构造函数时同样要调用父类的构造函数,所以这一行的作用就跟Java里的super()是一样的

class Student(val sno: String, var grade: Int, name: String, age: Int): Person(name, age)

注意构造函数中的参数。其中snograde被声明成val与var,nameage没有。那么Student中的函数是调用不到nameage的,但可以在构造函数、init函数以及定义成员变量时使用。

次构造函数必须主动(直接或间接地)调用主构造函数:

// 直接
constructor(name: String, age: Int) : this("", 0, name, age) {

}

// 间接,先调用的上一个次构造函数,再调用的主构造函数
constructor() : this("", 0) {

}

目前理解下来,次构造函数一方面是实现了多态,一方面是做到将构造函数中的参数赋初始值。但实际上使用的会很少,因为主构造函数支持设置默认值,并且调用构造函数时也支持用键值对的方式构造:

class Student(val sno: String = "123", val grade: Int = 3, name: String, age: Int): Person(name, age){}

Student(name = "xiaozhi", age = 18) // 这样构造时,sno与grade就使用了默认值

静态方法调用

kotlin没有java中的静态方法,如果想像Java中那样调的话,一个是用object将类修饰为单例,就可以调它下面的所有方法了。但若不想让所有方法都能这样调,只能借助伴生类来实现:

class Student {
    companion object {
        fun eatLunch() {

        }
    }
}

Student.eatLaunch()

但这种方式只是调用方式跟静态方法一样,还不是真正的静态方法,而且Java代码就没法调用Student.eatLaunch()了。这个时候我们可以将eatLaunch()加上JvmStatic注解,就真正成为静态方法了。

companion object {
    @JvmStatic
    fun eatLunch() {

    }
}

另外,顶层方法也是静态方法,即直接写在文件中的不放在class下的方法:

fun main() {
}

顶层方法在任何kotlin代码中都能直接调用,不用写类名。在Java代码中,调用的方式是文件名.main()。假设该文件名为LK,Kotlin会自动创建一个LK的Java类,而main函数就是其中的静态方法。

延迟初始化

因为kotlin默认参数与变量不为空,所以要声明一个空的成员变量只能这样声明:

private var str: String? = null

而且在使用时还必须str?.这样调用,很麻烦。所以有lateinit关键字可以帮我们延迟初始化:

private lateinit var str: String

我们只用保证调用前str被赋值了就可以了,若没赋值会抛出UninitializedPropertyAccessException异常。另外可以用::str.isInitialized判断是否已经完成了初始化。

内部类与嵌套类

kotlin中没有静态内部类,只有内部类与嵌套类。内部类用inner修饰,嵌套类则没有inner。 内部类与嵌套类的区别是,内部类会持有外部类的一个对象引用,所以内部类可以访问外部类的方法与属性,嵌套类则不行。

class OutClass {
    private val name = "name"

    fun outFunc() {

    }

    inner class InnerClass {
        private val sex = "sex"

        fun hello() {
            print(name)
            print(this@OutClass.name)
            print(this.sex)
            outFunc()
        }
    }

    class NestClass {
        fun hello() {

        }
    }
}

如上所示,InnerClass是内部类,NestClass是嵌套类。在InnerClass#hello()方法中,可以访问到OutClassname以及outFunc()方法。当内部类中有外部类的同名属性时,不指定this的情况下访问的是内部类自身的属性。指定this@外部类类名.属性时,访问的则是外部类的属性。NestClass无法访问到外部类中的属性与方法。

集合

kotlin支持listOfmutableListOfsetOfmutableSetOfmapOfmutableMapOf创建集合,并用for in 遍历集合。 map的访问与读取建议用map[key]=value的方式。

常用的函数式API

list.maxBy ({ str: String -> str.length })
list.maxBy (){ str: String -> str.length }
list.maxBy { str: String -> str.length }
list.maxBy { str -> str.length }
val maxIt = list.maxBy { it.length }

最后一行由上面几行简化而来,本质上是一个lambda表达式,得到最大长度的元素。 类似的还有

// 将每个元素做映射操作后得到新的集合
val newList = list.map { it.toUpperCase() }

// 过滤之后再做映射
list.filter { it.length > 5 }.map { it.toUpperCase() }

// 是否任意一个元素满足某个条件
val any = list.any { it.length > 5 }

// 是否所有元素满足某个条件
val all = list.all { it.length > 5 }

语法糖

判空

kotlin默认所有参数和变量都不为空,所以调用study方法时传null,编译器是会报红的

fun doStudy(study: Study) {
    study.doHomework()
    study.readBooks()
}

但如果给Study加一个?,就代表这个参数可以为空了。可以为空代表着这个函数可能会发生NPE,所以内部逻辑仍然需要我们进行处理,否则study.doHomework()也会报红。

fun doStudy(study: Study?) {
    study?.doHomework()  // 为空时什么也不做
    study?.readBooks()
}

结合let函数可以更加优雅,不需要每次使用参数的时候都写?

fun doStudy(study: Study?) {
    study?.let {
        it.doHomework()
        it.readBooks()
    }
}

还有?:的用法。左边不为空返回左边的值,为空返回右边的值。

fun getTextLength(text: String?) = text?.length ?: 0 // 若text为空,则text?.length返回空,函数返回0。否则返回text.length

标准函数

除了上面提到的let函数用于辅助判空外,还有常见的with,run,apply

with常用于频繁使用某个参数/变量时,将该参数/变量作为上下文,相当于放进去了一个this,从而减少代码上的直接使用:

with(Student()) {
    read()
    readBooks()
    doHomework()
}

run函数的作用于with是一样的,只是用法不同,同样最后一行作为返回值:

Student().run {
    read()
    readBooks()
    doHomework()
}

apply方法作用也是类似,不同的一点是它不是返回最后一行作为返回值,而是返回调用对象本身作为返回值,常见的用法是:

val intent = Intent().apply {
    putExtra("param1", "data1")
    putExtra("param2", "data2")
}

get()与set()

kotlin class会自动给成员变量赋予get与set方法,且调用时也提供了语法糖:

open class Person(name: String, age: Int) {
    var sex = 0
}

// 调用
person.sex = 1

sex变量自动生成了get与set方法。当我们想改默认生成的方法时,需要在变量下方重写get与set:

open class Person(name: String, age: Int) {
    var sex = 0
        get() = field
        set(value) {
            field = value
        }
}

这就是默认生成的逻辑。其中field就代指sex这个成员变量。注意在getset中不能显式调用到sex或者是this.sex,因为这样就相当于无限递归调用了。

函数

扩展函数

扩展函数即可以给任何一个类扩展任何函数,扩展方式定义格式为fun ClassName.MethodName()的方法:

fun String.lettersCount(): Int {
    var count = 0
    for (char in this) {
        if (char.isLetter()) {
            count++
        }
    }
    return count;
}

如上就定义了String类的扩展函数,建议都写在一个文件中,以顶层函数的形式存在,调用方式:"abc".lettersCount()。扩展函数中自动拥有该类实例的上下文,所以就不用传入String实例了,用的最多的地方就是工具类。

高阶函数

高阶函数即一个将函数作为参数的函数,如:

fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int): Int{
    val result = operation(num1, num2)
    return result
}

其中operation就是一个函数参数。调用方式:

num1AndNum2(1, 2) {
    n1, n2 -> n1 + n2
}

或者先声明一个plus函数,再将plus函数传入:

fun plus(num1: Int, num2: Int): Int {
    return num1+num2
}

num1AndNum2(1,2, ::plus)

当我们给函数参数前面加上ClassName.时,就赋予了函数参数的运行上下文:

fun StringBuilder.build(block: StringBuilder.() -> Unit): StringBuilder {
    block()
    return this
}

其中() -> Unit前面有StringBuilder.,即运行时的this就是StringBuilder实例,这样调用:

StringBuilder().build {
    append("2")
    append("3")
}

内联函数

还是拿高阶函数举例:

fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int): Int{
    val result = operation(num1, num2)
    return result
}

// 调用
var a = num1AndNum2(1, 2) {
    n1, n2 -> n1 + n2
}

在Java中是没有函数参数这个类型的,Kotlin在编译过程中,是将函数参数转换成了匿名类去实现。而每个匿名类都要占用内存,因此需要内联函数解决这个问题,即给函数声明inline

inline fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int): Int{
    val result = operation(num1, num2)
    return result
}

字面意思看,就是将调用高阶函数的地方,变成了内联的。调用时的逻辑在编译时会直接转换为: var a = 1+2,就没有高阶函数的运行时开销了。

另外,正因为内联函数在编译时会进行转换,所以它同非内联函数还有一个明显的区别:内联函数在lambda表达式中是可以return的。

fun main() {
    var a = num1AndNum2(1, 2) {
        n1, n2 -> n1 + n2
        return@num1AndNum2 0 // 若num1AndNum2是非内联函数,只能以return num1AndNum2的方式局部返回
        return // 若num1AndNum2是内联函数,这样return就是直接返回main函数
    }
}

noinline

当高阶函数中有多个函数参数,且这几个函数参数有些是需要内联,有些是不需要内联的,就需要借助noinline关键字了:

inline fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int, noinline operation2: (Int, Int) -> Int): Int{
    val result = operation(num1, num2)
    return result
}

crossinline

前面说了,内联函数是可以return的,那么这种情况下的内联函数就会报红:

inline fun errorInlineFunc(crossinline funcArgs: () -> Int): Int{
    Runnable {
        funcArgs()
    }
    return 1
}

之所以会报红,是因为我们在Runnablelambda表达式中使用了函数参数,而这个函数参数是可以return的。那么在Runnable匿名类中如果return,是无法return到外层调用函数的。

解决这个问题的方式就是给函数参数加crossinline关键字。crossinline关键字更像是一个约束,一旦声明,那么这个内联函数的函数参数就不可以使用return返回给外层调用函数了,但仍然可以进行局部返回:

errorInlineFunc { return } // 报错
errorInlineFunc { return@errorInlineFunc 0} // 允许局部返回

协程

协程构建

我们可以使用GlobalScope.launch创建一个协程:

fun main() {
    GlobalScope.launch {
        println("codes run in coroutine scope")
    }
}

GlobalScope.launch创建的协程,是会随着外部线程的结束而结束的。这里并不会打印出来这行代码,是因为协程还没来得及执行,外面线程就结束了。若外面线程挂起1000ms,就可以打印出来了:

fun main() {
    GlobalScope.launch {
        println("codes run in coroutine scope")
    }
    Thread.sleep(1000)
}

但我们如果用delay函数挂起协程,当然也就不会再打印了:

fun main() {
    GlobalScope.launch {
        delay(1000)
        println("codes run in coroutine scope")
    }
    Thread.sleep(1000)
}

这时我们可以使用runBlocking去创建协程,顾名思义runBlocking创建的协程是会挂起外部的线程的:

fun main() {
    runBlocking {
        println("codes run in coroutine scope")
    }
}

这样就会打印了。同时,我们可以在协程中创建N个子协程,因为创建协程的花销要比创建线程小很多,所以创建10万个都没有问题。这里用launch函数去创建子协程:

fun main() {
    runBlocking {
        launch {
            println("11")
            delay(1000)
            println("111")
        }

        launch {
            println("22")
            delay(1000)
            println("222")
        }
    }
}

需要注意,所有的子协程会随着父协程的结束而结束,且launch函数只能在协程的作用域中使用。若放到main函数中是没法调用launch函数的。那我们假设要把某个launch抽成一个函数,那么函数的声明肯定是不在协程作用域中的,该怎么声明呢?这里用coroutineScope函数:

fun launchFunc() {
    coroutineScope {
        launch {

        }
    }
}

coroutineScope函数可以让当前函数处于调用时的协程作用域中。但上面的代码其实是会报错的,原因是coroutineScope函数本身也是一个挂起函数,它要么需要处于携程作用域中,要么需要写在挂起函数中。所以我们可以给launchFunc函数声明为挂起函数,这样就不会报错了:

suspend fun launchFunc() {
    coroutineScope {
        launch {

        }
    }
}

因为launchFunc函数已经声明为挂起函数了,我们也可以在其中使用delay函数。所以coroutineScope函数与runBlocking函数是有一点像的,都是创建了一个协程并挂起了外部协程/线程。区别是前者挂起的是外部协程,后者挂起的是外部线程。runBlocking若在主线程使用,就会直接阻塞主线程,因此要谨慎使用。

上面说了四种协程构造器GlobalScope.launchrunBlockinglaunchcoroutineScopeGlobalScope.launchrunBlocking在任何地方都能调用,launch只能在协程作用域中调用,coroutineScope只能在协程作用域或者挂起函数中调用。

runBlocking因为会阻塞线程,所以不建议在实际项目中运用。GlobalScope.launch创建的是一个顶层协程,实际项目中用起来管理也比较麻烦:

val job = GlobalScope.launch {
    delay(1000)
    println("codes run in coroutine scope")
}
job.cancel()

每当页面回收时,都需要去调用job#cancel方法去取消每一个创建的Job。所以在创建协程时,比较实用的用法是这样的:

val job = Job()
CoroutineScope(job).launch { 
    launch { 
        
    }
    launch {

    }
}

job.cancel()

调用CoroutineScope(job)去创建一个CoroutineScope,然后再调用launch函数创建一个协程。在需要回收的时候,调用job.cancel()将所有子协程取消。

获取协程执行结果

在协程作用域下,我们可以使用async函数去新建一个子协程,并返回Deferred对象。Deferred对象执行await函数时,会挂起父协程,直到获取到子协程返回的结果:

val launchJob = CoroutineScope(job).launch {
    val deferred1 = async {
        1 + 1
    }.await()
    val deferred2 = async {
        1 + 1
    }.await()
    println("result:$deferred1 and $deferred2")
}

上图中deferred1deferred2println都是串行的。可以这样优化一下:

val launchJob = CoroutineScope(job).launch {
    val deferred1 = async {
        1 + 1
    }
    val deferred2 = async {
        2 + 2
    }
    println("result:${deferred1.await()} and ${deferred2.await()}")
}

优化后,deferred1deferred2就是并行的了,然后再执行println

另外还有withContext函数,它的作用类似于async+await,同样有挂起与获取返回结果的作用:

val withContext = withContext(Dispatchers.Default) {
    3 + 3
}
println("result:$withContext}")

不同的是,withContext函数必须要指明线程参数,即Dispatchers。线程参数用来指定所创建的协程分属于哪个线程。当我们进行网络请求时,即使是处于主线程下的协程中执行,也一样会报错,所以此时就需要指明Dispatchers.IO,代表是由子线程创建的高并发协程。Dispatchers.DEFAULT对应由子线程创建的低并发协程。Dispatchers.MAIN对应由主线程创建的协程。

事实上,除了coroutineScope构造器外,所有的构造器都可以指定线程参数,只不过只有withContext需要强行指定罢了。

使用协程简化回调的写法

在Java中写回调时,通常是这样的方式:

HttpUtil.sendHttpRequest(address, object : HttpCallbackListener {
    override fun onFinish(response: String) {
        // 得到服务器返回的具体内容
    }
    override fun onError(e: Exception) {
        // 在这里对异常情况进行处理
    }
})

但我们可以用suspendCoroutine包一下:

suspend fun request(address: String): String {
    return suspendCoroutine { continuation -> {
        HttpUtil.sendHttpRequest(address, object : HttpCallbackListener {
            override fun onFinish(response: String) {
                continuation.resume(response)
                // 得到服务器返回的具体内容
            }
            override fun onError(e: Exception) {
                // 在这里对异常情况进行处理
                continuation.resumeWithException(e)
            }
        })
    } }
}

suspendCoroutine是一个挂起函数且只能在协程作用域中使用,它会挂起当前协程并在一个普通线程中执行逻辑。上面代码中,在普通线程中发起一个网络请求得到响应后,再调用continuation.resume(response)或者continuation.resumeWithException(e)返回。那么调用处就可以这样使用:

try {
    val response = request("https://test.com")
} catch (e: Exception) {
    
}

这样就不用写回调了。类似的回调都可以用suspendCoroutine去包掉。

进阶语法

类型别名

typealias关键字允许我们给任意类型设置一个别名,常用于给函数类型设置别名,因为函数类型通常都比较长,写起来会比较麻烦:

private var callback: ((Boolean, List<String>) -> Unit)? = null

fun request(callback: (Boolean, List<String>) -> Unit, vararg permissions: String,) {
    this.callback = callback
    requestPermissions(permissions, 1)
}

如上代码我们可以用typealias进行优化:

typealias PermissionCallback = (Boolean, List<String>) -> Unit

class InvisibleFragment : Fragment() {
    private var callback: PermissionCallback? = null

    fun request(callback: PermissionCallback, vararg permissions: String) {
        this.callback = callback
        requestPermissions(permissions, 1)
    }
}

可以看到,原先的函数类型成了PermissionCallback,使用起来方便了很多。

密封类

当我们用when时,编译器要求我们必须写else,否则就会报红,密封类就可以解决这个问题:


interface MyResult

class SuccessResult: MyResult
class FailedResult: MyResult


fun handleResult(result: MyResult) = when(result) {
    is SuccessResult -> {
        print(11)
    }
    is FailedResult -> {
        print(22)
    }
    else -> {
        print(33)
    }
}

密封类要求类及其子类必须定义在同一文件中的顶层位置:

sealed class MyResult

class SuccessResult: MyResult()
class FailedResult: MyResult()

这样就不用写else了:

fun handleResult(result: MyResult) = when(result) {
    is SuccessResult -> {
        print(11)
    }
    is FailedResult -> {
        print(22)
    }
}

而且,当增加一个MyResult的子类时,when那边如果没有添加相应的分支,编译器就会报红,保证了我们逻辑的完整性。

重载运算符

任何类都可以重载运算符,从而使得+、-、*、/等运算法具有意义。

class Money(val value: Int) {
    operator fun plus(money: Money) : Money {
        return Money(this.value + money.value)
    }

    operator fun plus(value: Int) : Money {
        return Money(this.value + value)
    }
}

operator重载了+号,因此可以这样使用:

val money = Money(1) + 33
val money1 = Money(1) + Money(2)

类似的-对应的函数名为minus,*对应times,/对应div

委托

委托的关键字是by

委托类

class MySet<T>(helperSet: HashSet<T>): Set<T> by helperSet {
    override fun isEmpty(): Boolean {
        return false
    }

    fun helloWorld() {

    }
}

如上代码,MySet不必实现Set的所有接口,因为这些接口都委托给了helperSet去实现。当然,MySet可以拥有自己的方法并重写其中的方法。

委托属性

委托属性就是将该属性的get与set方法交由某个类去执行,基本语法结构如下:

var p by Delegate()

by是委托关键字,Delegate就是被委托类。Delegate必须按照要求重写getValuesetValue方法:

class Delegate {
    var propValue: Any? = null
    operator fun getValue(myClass: MyClass, prop: KProperty<*>): Any? { return propValue } 
    operator fun setValue(myClass: MyClass, prop: KProperty<*>, value: Any?) { propValue = value }
}

常见的场景是by lazylazy是返回被委托类的一个方法,类似的我们可以自己实现一个被委托类:

fun <T> later(block: () -> T): Later<T> {
    return Later(block)
}

class Later<T>(val block: () -> T) {
    private var value: Any? = null
    operator fun getValue(any: Any?, prop: KProperty<*>): T {
        if (value == null) {
            value = block()
        }
        return value as T
    }
}

// 调用
val abc by later {
    val abc = "123"
    abc
}

当某处代码用到abc时,实际调用的是LatergetValue方法,会执行后面的labmda表达式初始化去初始化abc

infix

infix用来将方法调用更语义化地表示出来,如"a" to "b"可以生成一个Pair,这个to就是一个infix方法。类似的我们可以有这样的定义:

// 跟to作用相同,生成一个Pair
infix fun <A, B> A.with(that: B) = Pair(this, that)

infix fun String.beginWith(str: String) : Boolean = startsWith(str)

infix fun <T> Collection<T>.has(ele: T) = contains(ele)

定义完之后就可以这样调用了:

val b = "abc" beginWith "a"
val b1 = listOf(1, 2, 3) has 1

另外infix有两个限制。一个是不能成为顶层函数,通常通过扩展函数的形式成为某个类的成员函数。二是必须且只能接收一个参数。

泛型

泛型的基本用法跟Java是差不多的,这里介绍比较常用的泛型特性,一个是泛型实化,一个是泛型协变/逆变。

泛型实化

Java中可以获取到MainActivity.class,但肯定不能获取到泛型T.class,Kotlin可以做到这一点。需要借助inlinereified来实现:

inline fun <reified T> getGenericType(obj: T) = T::class.java

getGenericType(1) 得到的会是java.lang.Integer

在实战中可以封装startActivity方法:

inline fun <reified T> startActivity(context: Context) {
    val intent = Intent(context, T::class.java)
    context.startActivity(intent)
}

startActivity<MainActivity>(this)

泛型协变与逆变

在Java中,List<Set>不是List<Collection>的子类,因为这样做会有类型转换问题:

private void handleList(List<Collection> collection) {
List<Set> set = (List<Set>)collection;
}

当往handleList方法传入List<List>时就会发生类型转换问题了。但在kotlin中却可以。只需要定义好泛型是out的,即对这个泛型,只能读,不能写,那么就能避免这个问题。kotlin中的List定义如下:

public interface List<out E> : Collection<E> {}

相反地,逆变可以让A继承于B,泛型<B>继承于泛型A,继承关系反了一下,用int去声明泛型就可以,如Comparable的声明:

interface Comparable<in T> { operator fun compareTo(other: T): Int }

总而言之,协变(out)与逆变(in),都是规范了泛型参数的输入或输出,防止代码出现类型转换异常。

Java与Kotlin代码间的互相转换

Java转Kotlin

两种方式。一种是将Java代码直接拷贝到任意一个Kotlin文件中。第二种方式是在AS中右键Java文件,选择Convert Java File to Kotlin File

Koltin转Java

Kotlin直接转Java,AS是不支持的,因为Kotlin应有很多语法糖AS识别不了。但我们可以点击Tools->Kotlin->show kotlin bytecode。查看Kotlin生成的字节码,再点击Decompile反编译,就能看到反编译后的Java代码。这有助于理解Kotlin语法糖背后的实现原理。

MVVM

书里还额外提到了MVVM架构,这里也记录一下分层模式。

  • UI层
  • ViewModel层:UI层调用,存放UI相关数据
  • Repository层:仓库层,是缓存数据与网络数据的中转站,由ViewModel调用
  • Network层:包装各种Service的接口,由仓库层调用
  • DAO层:存放本地缓存数据与持久化数据,由仓库层调用
  • Service:写网络请求并将响应封装为Model

《第一行代码》天气APP架构:

image.png

文章不足之处,还望大家多多海涵,多多指点,先行谢过!