【从零开始撸一个App】Kotlin

工欲善其事必先利其器。像我们从零开始撸一个App的话,选择最合适的语言是首要任务。如果你跟我一样对Java蹒跚的步态和僵硬的语法颇感无奈,那么Kotlin在很大程度上不会令你失望。虽然为了符合JVM规范和兼容Java,它引入了一些较为复杂的概念和语法,很多同学就是因此放弃入门。其实越深入进去,就会越欲罢不能。除了Android开发,博主也常在后端使用Kotlin编码,有时因为某些原因同时使用Java混编。总的来说,能减少代码量,提高生产效率,似乎代码结构也更清晰了。如果你没有Kotlin的经验,但是比较过Java和C#,你就明白我的意思了,甚至Kotlin有些地方比C#还方便。可以说Kotlin既有C#便捷的语法,亦背靠Java平台良好的生态,那么你还在犹豫什么?

基础

var:可变,是一个可变变量。可知var类型属性不能设置为延迟加载属性,因为在lazy中并没有setValue(…)方法。在DI场景下,常与lateinit搭配使用,可参看Kotlin中lateinit变量在字节码层面上的解释
val:不可变,一个只读变量。另外还有const val,只允许在top-level级别和object中使用。它们的区别如下:

  • const val 可见性为public final static,可以直接访问。
  • val 可见性为private final static,并且val 会生成方法getNormalObject(),通过方法调用访问。

Unit:当一个函数没有返回值的时候,我们用Unit来表示这个特征,同Java中的void。

open:在java中允许创建任意的子类并重写方法任意的方法,除非显示的使用了final关键字进行标注。而在Kotlin的世界里面则不是这样,在Kotlin中它所有的类默认都是final的,那么就意味着不能被继承,而且在类中所有的方法也是默认是final的,那么就是Kotlin的方法默认也不能被重写。为类增加open,class就可以被继承了;为方法增加open,那么方法就可以被重写了。

inlineKotlin 内联函数 inline。它会将代码块拷贝到调用的地方,减少了调用层数和额外对象的产生。
crossinline:这是因inline的副作用而引入的关键字。由于inline会将代码拷贝到调用的地方,如果代码里面有return,那么目标代码(调用者)的逻辑可能就被破坏了。用crossinline修饰相应的lambda,将return返回到对应标签[,而不是返回到整个方法]。
reified:为了应对Java伪泛型导致的代码冗余问题。可参看使用Kotlin Reified 让泛型更简单安全。这主要是应对Java中的泛型擦除。Java中的泛型是伪泛型,即它的泛型只存在于编译期,在生成的字节码文件中是不包含任何泛型信息的(不过至少在编译期就能及早发现类型不匹配的问题),在编译后的字节码文件中,就已经被替换为原来的原始类型(Raw Type/Object)了,并且在相应的地方插入了强制转型代码,是为类型擦除。因此对于运行期的Java语言来说,ArrayList<int>与ArrayList<String>就是同一个类型。相对的,C#中使用的泛型,就是真泛型,其泛型无论在程序源码中、编译后的IL中或是运行期的CLR中都是切实存在的,List<int>与List<String>就是两个不同的类型,它们有自己的虚方法表和类型数据。
下面是我封装RabbitMQ消费端监听的代码(感兴趣的同学可以参看本人博文RabbitMQ入门指南获取更多信息):

    /**
     * 从指定队列获取消息,并定义回调(for kotlin)
     *
     * @param queue the name of the queue from where receive messages
     * @param block callback when a message arrived
     */
    inline fun <reified T> receive(queue: String, crossinline block: (T) -> Boolean) {
        factory.newConnection().use {
            val conn = it.get()
            val channel = conn.createChannel()
            channel.basicConsume(queue, false, object : DefaultConsumer(channel) {
                override fun handleDelivery(consumerTag: String?, envelope: Envelope, properties: AMQP.BasicProperties?, body: ByteArray) {
                    try {
                        val message = JSON.parseObject(String(body), object : TypeReference<T>() {})
                        val done = block(message)
                        if (done) {
                            channel.basicAck(envelope.deliveryTag, false)
                        } else {
                            //若失败则重新投递一次,否则丢弃或投递到死信队列(若配置了的话)
                            channel.basicNack(envelope.deliveryTag, false, !envelope.isRedeliver)
                        }
                    } catch (e: Exception) {
                        _logger.error("处理消息-${String(body)}时发生错误-${e.message}")
                        throw e
                    }
                }
            })
        }
    }

注意Java编译器不支持inlinereified等关键字,所以如果要使用Java调用,还需要另外写for java的版本。

field:用于属性取值/赋值逻辑(如果显式定义的话),类似于C#属性中的value关键字,防止访问器的自递归而导致程序崩溃的 StackOverflowError异常,参看kotlin学习—Field

this@ClassName:匿名内部类对象引用[包含它的]外部对象。

by:修饰属性和字段,提供若干效用,可参看Kotlin by
在监听值更改的场景中,by 的作用类似于 C# 属性的 set 方法。当然, kotlin 自己也有 get/set 语法。
更进一步,如果一个对象有 getValue() [和 setValue()] 方法,则这个对象就可以作为 by 后面的表达式。
还可以在类定义时使用,可以将某实例的所有的 public 方法委托该类[,似乎这些方法就是在这个类中定义的]。这应该是组合的形态,但我们也可用它实现某种语法程度的“多继承”,以后面协程部分的代码片段为例:

class BasicCorotineActivity : AppCompatActivity(), CoroutineScope by MainScope() {}

其中CoroutineScope是interface,MainScope()返回的是CoroutineScope的实现类ContextScope实例。也就是说,BasicCorotineActivity实现了接口CoroutineScope,但BasicCorotineActivity本身不实现其中的方法,而是委托给MainScope()返回的对象帮它实现。这减少了代码冗余,从写法上看,也似乎BasicCorotineActivity同时继承了AppCompatActivity类和CoroutineScope实例:)

在kotlin中interface不仅可以声明函数,还可以对函数进行实现。与类唯一不同的是它们是无状态的,所以属性需要子类去重写。类需要去负责保存接口属性的状态。

Elvis操作符:?: ,类似js中的 | ,若前者为null则取后者。

Kotlin并非一门纯粹的语言,它在语法部分常考虑到Java的兼容和可转换性,为此增添了不少让新手困惑的语法和关键字。如对一个属性或一个主构造器的参数进行注解时,Kotlin元素将会生成对应的多个Java元素,因此在Java字节码中该注解有多个可能位置。如果要精确指定该如何生成该注解,可使用以下语法:

class Example(@field:Ann val foo,    // annotate Java field
              @get:Ann val bar,      // annotate Java getter
              @param:Ann val quux)   // annotate Java constructor parameter

更多可参看Kotlin编码窍门之注解(Annotations)

companion objectobject:Kotlin 移除了 static 的概念,这两者转换成Java后都有静态单例的模式,容易让人困惑它们的区别。其实从使用场景分析就比较明了了,前者作为一个类的静态内部单例类[对象]使用(companion就是伴侣的意思),后者就是一个静态单例类[对象],不需要外围类的存在(没有companion嘛)。
在companion object场景下我们常使用@JvmStatic@JvmField以便将它们修饰的方法和字段[在外部Java代码看来]暴露为类的子级,可参看微知识#1 Kotlin 的 @JvmStatic 和 @JvmField 注解
相关概念:@JvmOverloads
object关键字还可用于创建接口或者抽象类的匿名对象。

Kotlin允许你在文件中定义顶级的函数和属性。

Kotlin除了有扩展方法,还有扩展属性,参看Kotlin的扩展属性和扩展方法

Kotlin的函数参数是只读的。

重温一下表达式与语句的区别。表达式有值,并能作为另一个表达式的一部分来使用;而语句没有返回值。Java 中的控制结构皆为语句。而在 Kotlin 中,除了循环体结构外,大多数控制结构都是表达式。


lambda

Kotlin中的语法糖特别的多,比如lambda表达式,作为参数传递就有几种不同的写法:

  1. 普通方式:button.setOnClickListener({strInfo: String -> Unit})
  2. 如果最后一个参数是传递的lambda表达式,可以在圆括号之外指定:button.setOnClickListener(){strInfo: String -> Unit}
  3. 如果函数的参数只有一个[或者其它参数都有默认值],并且这个参数是lambda,就可以省略圆括号:button.setOnClickListener{strInfo: String -> Unit}
  4. 甚至可以省略为:button.setOnClickListener{strInfo}

以上面例子为例,如果setOnClickListener接受的参数不是lambda类型而是一个interface,该interface下只有一个方法,那么同样可以使用上述语法[,似乎setOnClickListener接受的参数就是lambda类型]。此类interface常用@FunctionalInterface修饰。(其实这应该就是java的特性,如RxJava中的subscribe(Consumer<? super T> onNext),在别人调用它的时候就可以直接传lambda表达式)。
注意,用kotlin自己写的interface并不支持此特性

在调用时将lambda方法体移至括号外面应该是为了代码的可读性,使得更贴近代码逻辑块而非单个参数的感觉。可参看Kotlin系列之let、with、run、apply、also函数的使用中这些扩展函数的签名定义,顺便了解下这些函数的使用场景。


协程Coroutine

首先我们要知道一点,协程这个概念现在有点被滥用了,市面上流行的语言似乎都想把协程纳入自己的特性里。如果你对协程还不了解,请参看博主写的再谈协程或其它资料。博主认为真正的协程是如Go那样的实现。Kotlin虽然也有协程,但更类似于C#里的async/await,是在多线程层面的语法处理。更深入的分析可参看Kotlin 协程真的比 Java 线程更高效吗?

suspend:关键字,它一般标识在一个函数的开头,用于表示该函数是个耗时操作。这个关键字主要作用就是为了作一个提醒,并不会因为添加了这个关键字就会该函数立即跑到一个子线程上。是否切换线程仍是由launchwithContextasync决定的。当然了,有时候我们必须在函数前面加上suspend,如果函数内部调用了其它suspend函数的话。

如果使用retrofit2封装网络请求的话,接口定义,原本每个函数应该返回的是Call<>(若有返回的话)类型。或者可以使用Jake Wharton写的CoroutineCallAdapterFactory组件,它使得函数支持Deferred<>返回值,简化协程+retrofit2的开发。不过从Retrofit 2.6.0起,Retrofit内置了对suspend关键字的支持,可以以更纯粹的方式定义函数,如:

@GET("users/{id}")
suspend fun user(@Path("id") id: Long): User

若要将传统的回调封装成协程模式,可使用suspendCoroutinesuspendCancellableCoroutine,如下所示:

    suspend fun MqttAsyncClient.aPublish(payload: MqttMessage, topic: String): IMqttToken =
        suspendCoroutine { cont ->
            publish(topic, payload, null, object: MqttActionListener {
                override fun onSuccess(asyncActionToken: IMqttToken) {
                    cont.resume(asyncActionToken)
                }

                override fun onFailure(asyncActionToken: IMqttToken?, exception: Throwable) {
                    cont.resumeWithException(exception)
                }
            })
        }

尽可能使用suspendCancellableCoroutine而不是suspendCoroutine,因为使用前者则协程的取消是可控的。Kotlin没有检查异常,但是我们仍然需要在try-catch中处理所有可能的异常。否则,该应用程序将崩溃。但是suspendCancellableCoroutine取消抛出的异常CancellationException是个意外,它并不会导致程序崩溃。

惯常用CoroutineScope.launch创建协程(当然还有runBlockingwithContextasync等),它会返回一个Job对象,便于在外部对协程进行控制。

  • job.join():阻塞当前线程,直到job执行完毕。这是一个 suspend 函数,所以一般在 Coroutine 内调用,阻塞当前所在Coroutine。
  • job.cancel():取消job,执行后该job就进入cancelling状态,但是否真的取消了需要看job自身实现。Coroutine标准库中定义的 suspend function 都是支持取消操作的(比如 delay)。自定义job的时候可以通过 isActive 属性来判断当前任务是否被取消了,如果发现被取消了则停止继续执行。如果自定义job没有相应的处理逻辑,那么就算调用job.cancel(),也并不能取消它的执行。
  • SupervisorJob(parent: Job? = null):返回一个job实例,里面的子Job不相互影响,一个子Job失败了,不影响其他子Job的执行。parent参数用于关联自己本身的父job。如果研究协程源码的话,会常看到ContextScope(SupervisorJob() + Dispatchers.Main)的写法(如ViewModel.viewModelScope的实现),这里的 + 号是CoroutineContext对操作符plus的重载,前后两者都是CoroutineContext的子类。

Dispatchers

  • Dispatchers.Main 调用程序在Android的主线程中
  • Dispatchers.IO 适合主线程之外的执行磁盘或者网络io操作,例如文件的读取与写入,任何的网络请求
  • Dispatcher.Default 适合主线程之外的,cpu的操作,例如json数据的解析,以及列表的排序

注意,By default, the maximum number of threads used by this dispatcher is equal to the number of CPU cores, but is at least two. Dispatchers.IO的调度/执行线程同Dispatcher.Default一样,它们使用同一个线程池,但是遇到IO操作,Dispatchers.IO会另外创建线程用于处理IO过程(而Dispatcher.Default不会,也就是同一个线程即干计算的活,也干搬运的活)。
Dispatchers.IO能创建的线程数:The number of threads used by tasks in this dispatcher is limited by the value of “kotlinx.coroutines.io.parallelism” (IO_PARALLELISM_PROPERTY_NAME) system property. It defaults to the limit of 64 threads or the number of cores (whichever is larger).


后记拾遗

当使用公有属性时,有时会抛出“Smartcast is impossible because propery has open or custom getter”的编译时错误,究其原因是编译器分析代码发现每次get属性时返回的对象可能不是同一个。解决方法很简单,只要定义一个临时变量指向某次get获得的值即可。可参看Smartcast is impossible because propery has open or custom getter

Java泛型擦除导致的问题。如下代码可正常运行:

    private fun  getToken(): Token? {
        val preference = TaoismApplication.appContext.sharedPreferences(SharedPreference.SESSION)
        val json = preference.getString(SharedPreference.TOKEN, "")
        if (!json.isNullOrBlank()) {
            return Gson().fromJson<Token>(json, object : TypeToken<Token>() {}.type)
        } else {
            return null
        }
    }

由于代码中有较多getXXX(),抽取模板代码:

    private fun <T : Any?> get(key: String): T? {
        val preference = TaoismApplication.appContext.sharedPreferences(SharedPreference.SESSION)
        val json = preference.getString(key, "")
        if (!json.isNullOrBlank()) {
            return Gson().fromJson<T>(json, object : TypeToken<T>() {}.type)
        } else {
            return null
        }
    }

调用get<Token>(SharedPreference.TOKEN)报错:java.lang.ClassCastException: com.google.gson.internal.LinkedTreeMap cannot be cast to com.xxx.xxx.Token
so,只能将类型信息显式传入,改造方法签名为get(key: String, typeToken: Type)

kotlin异常:Kotlin 的异常都是 Unchecked exception。若在函数上注解了@Throws,则编译成Java代码会变成符合Java模式的checked exception,即在方法定义上会显式声明可能抛出的异常类型,需要在调用链路上处理。对于Kotlin自身来说@Throws并没有太多意义,it is only for Java developer to know that they need to handle that exception.参看[Kotlin] Try catch & Throw

使用intellij idea进行kotlin和java混合开发,最好将kotlin文件和java文件分各自文件夹存放,否则运行时可能会报找不到类的错误(因为编译时会将不是属于该文件夹的且没有被其它文件引用的代码文件忽略)。如下:


参考资料

Kotlin协程 —— 今天说说 launch 与 async
Kotlin之美——DSL篇

posted @ 2020-10-10 15:32  莱布尼茨  阅读(1828)  评论(0编辑  收藏  举报