## 協程取消
### 協程取消
在項目開發的過程中,當進入一個需要網絡請求的界面中時,在該界面請求2秒,用戶沒有看到界面加載的數據就關閉了當前的界面,此時對應的網絡請求任務就需要關閉掉,這個網絡請求的線程也需要關閉。
同樣的道理,在協程程序中,如果開啟了一個協程來進行網絡請求或者數據加載,當退出該界面時,該界面的數據還未加載完成,此時就需要取消協程。在Kotlin中是通過cancel()方法將協程取消的。接下來我們通過一個案例來演示如何取消協程,具體代碼如下所示。
```
import kotlinx.coroutines.experimental.delay
import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.runBlocking
fun main(args: Array<String>): Unit = runBlocking {
val job = launch {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
}
delay(2000L)
println("協程取消前:isActive=${job.isActive} isCompleted=${job.isCompleted}")
job.cancel() //取消協程
println("協程取消后:isActive=${job.isActive} isCompleted=${job.isCompleted}")
}
```
運行結果:
```
I'm sleeping 0…
I'm sleeping 1…
I'm sleeping 2…
I'm sleeping 3…
協程取消前:isActive=true isCompleted=false
協程取消后:isActive=false isCompleted=true
```
上述代碼中,第7行的repeat()方法表示的是重復1000次來打印“I'm sleeping$i…”,在第15行通過job.cancel()來取消協程,在取消協程的前后分別打印了job中任務的狀態,根據該程序的運行結果可知,協程在取消之前isActive的值為true,isCompleted的值為false,表示該協程在活動中。由于在協程取消時,會出現兩種情況,一種是正在取消,此時打印出的isActive的值為false,isCompleted的值為false;另一種是已經取消,此時打印出的isActive的值為false,isCompleted的值為true。這兩種情況都表示協程取消成功。
>[info] **注意**
上述程序的運行結果中,協程取消后的信息有兩種情況,具體如下。
第1種,正在取消協程時,運行結果為:
協程取消后:`isActive=false isCompleted=false
`
第2種,已經取消協程時,運行結果為:
協程取消后:`isActive=false isCompleted=true
`
#### **多學一招**:cancelAndJoin()函數與不可取消代碼塊
1. cancelAndJoin()函數與finally代碼塊
協程中的cancel()函數和join()函數是可以進行合并的,合并之后是一個cancelAndJoin()函數,這個函數用于取消協程。接下來我們通過一個案例來演示cancelAndJoin()函數取消協程以及在協程中使用try…finally代碼塊。具體代碼如下所示。
```
import kotlinx.coroutines.experimental.cancelAndJoin
import kotlinx.coroutines.experimental.delay
import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.runBlocking
fun main(args: Array<String>): Unit = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
} finally {
println("之前最終執行的代碼")
delay(1000L)
println("之后最終執行的代碼")
}
}
delay(2000L)
job.cancelAndJoin()//取消協程
}
```
運行結果:
```
I'm sleeping 0…
I'm sleeping 1…
I'm sleeping 2…
I'm sleeping 3…
```
之前最終執行的代碼
根據上述代碼的運行結果可知,沒有打印第16行代碼需要打印的數據,這是由于當程序輸出“I'm sleeping 3…”時,當前程序耗時是1500ms,主線程的延遲時間是2000ms,此時程序會繼續執行finally中的代碼。當執行完第14行代碼時,程序需要延遲的時間為1000ms,此時主線程的延遲時間已經到了,主線程會繼續運行第20行代碼取消協程,由于協程結束時,守護線程也就結束,因此finally中的代碼不會繼續執行。
2. 不可取消代碼塊
如果想讓【文件9-14】中的程序不受協程結束的影響,繼續執行finally中的代碼,則需要在finally中通過withContext{}代碼塊來實現,這個代碼塊稱為不可取消的代碼塊,具體代碼如下所示:
```
// 不可取消的代碼塊
withContext(NonCancellable){
println(" 之前最終執行的代碼")
delay(1000L)
println(" 之后最終執行的代碼")
}
```
### 協程取消失效
一般情況下,一個協程需要通過cancel()方法來取消,這種取消方式只適用于在協程代碼中有掛起函數的程序。由于掛起函數在掛起時也就是等待時,該協程已經回到了線程池中,等待時間結束之后會重新從線程池中恢復出來,雖然可以通過cancel()方法取消這些掛起函數,但是在協程中調用某些循環輸出數據的函數時,通過cancel()方法是取消不了這個協程的。接下來我們通過一個案例來演示通過cancel()方法無法取消的協程,具體代碼如下所示。
~~~
import kotlinx.coroutines.experimental.*
fun main(args: Array<String>): Unit = runBlocking {
val job = launch(CommonPool) {
//程序運行時當前的時間
var nextTime = System.currentTimeMillis()
while (true) {
/* if(!isActive) return@launch //返回當前協程*/
try {
yield()
}catch (e:CancellationException){
println("異常名稱=${e.message}")
return@launch
}
//每一次循環的時間
var currentTime = System.currentTimeMillis()
if (currentTime > nextTime) {
println("當前時間:${System.currentTimeMillis()}")
nextTime += 1000L
}
}
}
delay(2000L) //使程序延遲2秒鐘
println("協程取消前:isActive=${job.isActive}")
job.cancel() //取消協程
job.join()
println("協程取消后:isActive=${job.isActive}")
}
~~~
運行結果:
```
當前時間:1531983528698
當前時間:1531983529698
當前時間:1531983530698
協程取消前:isActive=true
當前時間:1531983531698
當前時間:1531983532698
……
```
上述協程代碼中,通過while循環每隔1000ms打印一次當前時間,如果通過cancel()方法來取消這個協程時,會發現該協程并沒有停止,一直處于存活狀態,并無限循環地打印數據,因此第23行代碼中協程取消后的狀態就不能打印了。如果協程的循環代碼中沒有掛起函數,則該程序是不能直接通過cancel()方法來取消的。
有一些協程中有循環代碼且沒有掛起函數的程序,如果想取消協程,則需要對這個協程中的Job任務狀態進行判斷。如果協程取消失效后,則可以通過以下兩種方案來繼續取消協程。
方案一:通過對isActive值的判斷來取消協程}/pa
如果想要在結束協程時結束協程中的循環操作,則需要在循環代碼中通過isActive的值來判斷當前協程的狀態,如果isActive的值為false,則表示當前協程處于結束狀態,此時返回當前協程即可,具體代碼如下所示:
```
//判斷當前協程狀態
if(!isActive) return@launch //返回當前協程
```
上述代碼需要添加在【文件9-15】中的第11行代碼上方。此時運行該文件中的程序,運行結果如下所示。
```
當前時間:1531984991424
當前時間:1531984992423
當前時間:1531984993423
協程取消前:isActive=true
協程取消后:isActive=false
```
方案二:使用yield()掛起函數來取消協程
除了上述解決方案之外,還可以在循環代碼中調用yield()掛起函數來結束協程中的循環操作,因為調用cancel()函數來結束協程時,yield()會拋出一個異常,這個異常的名稱是Cancellation Exception,拋出這個異常之后協程中的循環操作就結束了,同時在循環代碼中通過try…catch來捕獲這個異常并打印異常名稱,當捕獲到這個異常之后將協程返回即可。具體代碼如下:
```
try {
yield()
}catch (e:CancellationException){
println(" 異常名稱=${e.message}")
return@launch
}
```
上述代碼需要添加在【文件9-15】中的第11行代碼上方。此時運行該文件中的程序,運行結果如下所示。
```
當前時間:1531985095581
當前時間:1531985096581
當前時間:1531985097581
協程取消前:isActive=true
異常名稱=Job was cancelled normally
協程取消后:isActive=false
```
### 定時取消
一般情況下,在掛起函數delay()中傳遞的時間到了之后會通過cancel()方法來取消協程,例如當打開一個應用的界面時,此時程序需要發送網絡請求來獲取界面中的數據,如果網絡很慢、沒有網絡或者服務器有問題,請求3、4秒還沒有請求到數據,則用戶可能會沒有耐心而將該界面關閉,此時后臺請求的任務就斷掉了,這樣的用戶體驗很差。通常我們會給網絡請求設置一個超時的時間,對于協程來說也是一樣的,對于后臺的耗時任務一般是需要設置一個時間的上限,時間到了之后就可以將這個協程取消。在協程中可以通過withTimeout()函數來限制取消協程的時間。接下來我們通過一個案例來演示如何通過withTimeout()函數在限制時間內取消協程,具體代碼如【文件9-16】所示。
```
import kotlinx.coroutines.experimental.delay
import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.runBlocking
import kotlinx.coroutines.experimental.withTimeout
fun main(args: Array<String>): Unit = runBlocking {
val job = launch {
withTimeout(2000L) {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
}
}
job.join()
}
```
運行結果:
```
I'm sleeping 0…
I'm sleeping 1…
I'm sleeping 2…
I'm sleeping 3…
```
上述代碼中,通過withTimeout()函數來設置超過指定時間后協程會自動取消,withTimeout()函數中傳遞的2000L表示2秒。根據程序中的邏輯代碼可知,每打印一行數據程序都會延遲500ms,打印4行數據后,程序的延遲時間一共為2000ms,等到下一次打印數據時,已經超過了協程的限制時間2秒,此時協程會自動取消,不再繼續打印數據。
### 掛起函數的執行順序
如果想要在協程中按照順序執行程序中的代碼,則只需要使用正常的順序來調用即可,因為協程中的代碼與常規的代碼一樣,默認情況下是按照順序執行的。接下來我們通過執行兩個掛起函數來演示協程中程序默認執行的順序,具體代碼如下所示。
```
import kotlinx.coroutines.experimental.delay
import kotlinx.coroutines.experimental.runBlocking
import kotlin.system.measureTimeMillis
fun callMethod(): Unit = runBlocking {
val time = measureTimeMillis {
val a = doJob1()
val b = doJob2()
println("a=$a b=$b")
}
println("執行時間=$time")
}
suspend fun doJob1(): Int { //掛起函數doJob1()
println("do job1")
delay(1000L)
println("job1 done")
return 1
}
suspend fun doJob2(): Int { //掛起函數doJob2()
println("do job2")
delay(1000L)
println("job2 done")
return 2
}
fun main(args: Array<String>) {
callMethod()
}
```
運行結果:
```
do job1
job1 done
do job2
job2 done
a=1 b=2
執行時間=2051
```
上述代碼中,創建了兩個掛起函數,分別是doJob1()和doJob2(),運行每個函數時都通過delay()函數使程序延遲了1秒。在callMethod()方法中,通過measureTimeMillis()函數來獲取程序運行兩個掛起函數所耗費的時間。根據程序的運行結果可知,兩個掛起函數的執行與其在程序中的調用順序是一致的,運行兩個掛起函數耗費的時間是2051,由此可以看出同步執行程序是比較耗時的。
### 通過async啟動協程
上一小節的DoJob.kt文件中的代碼是同步執行的,這樣執行比較耗時。為了使程序執行不耗費很多時間,可以使用異步任務來執行程序。從概念上講,異步任務就如同啟動一個單獨的協程,它是一個與其他所有協程同時工作的輕量級線程。前幾節中的協程就屬于一個異步任務,除了通過launch()函數來啟動協程之外,還可以通過async()函數來啟動協程,不同之處在于launch()函數返回的是一個Job任務并且不帶任何結果值,而async()函數返回的是一個Deferred(也是Job任務,可以進行取消),這是一個輕量級的非阻塞線程,它有返回結果,可以使用.await()延期值來獲取。接下來我們通過異步代碼來執行上面的代碼,修改后的代碼如下所示。
```
import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.experimental.runBlocking
import kotlin.system.measureTimeMillis
fun asyncCallMethod(): Unit = runBlocking {
val time = measureTimeMillis {
val a = async { doJob1() } //通過async函數啟動協程
val b = async { doJob2() }
println("a=${a.await()} b=${b.await()}")
}
println("執行時間=$time")
}
fun main(args: Array<String>) {
asyncCallMethod()
}
```
運行結果:
```
do job1
do job2
job2 done
job1 done
a=1 b=2
執行時間=1045
```
上述代碼中的函數doJob1()與doJob2()是前面示例中創建的,在此處不重復寫一遍了,在程序中通過await()函數分別獲取函數doJob1()與doJob2()的返回值。根據程序的運行結果可知,通過async()函數異步啟動協程,程序的運行順序不是默認的順序,是隨機的,并且根據程序的執行時間與前面示例中程序的執行時間對比可知,異步運行協程比同步運行要節省較多時間。
一般情況下,通過launch()函數啟動沒有返回值的協程,通過async()函數啟動有返回值的協程。
### 協程上下文和調度器
在Kotlin中,協程的上下文使用CoroutineContext表示,協程上下文是由一組不同的元素組成,其中主要元素是前面學到的協程的Job與本小節要學習的調度器。協程上下文中包括協程調度程序(又稱協程調度器),協程調度器可以將協程執行限制在一個特定的線程中,也可以給它分派一個線程池或者可以不做任何限制無約束地運行。所有協程調度器都接收可選的CoroutineContext參數,該參數可用于為新協程和其他上下文元素顯示指定調度器。接下來我們通過一個案例來演示協程的上下文和調度器,具體代碼如下所示。
```
import kotlinx.coroutines.experimental.*
fun main(args: Array<String>): Unit = runBlocking {
val list = ArrayList<Job>()
list += launch(Unconfined) { //主協程的上下文
println("Unconfined執行的線程=${Thread.currentThread().name}")
}
list += launch(coroutineContext) { //使用的是父協程的上下文
println("coroutineContext執行的線程=${Thread.currentThread().name}")
}
list += launch(CommonPool) { //線程池中的線程
println("CommonPool執行的線程=${Thread.currentThread().name}")
}
list += launch(newSingleThreadContext("new thread")) { //運行在新線程中
println("新線程執行的線程=${Thread.currentThread().name}")
}
list.forEach{
it.join()
}
}
```
運行結果:
```
Unconfined執行的線程=main
coroutineContext執行的線程=main
新線程執行的線程=new thread
CommonPool執行的線程=ForkJoinPool.commonPool-worker-1
```
根據該程序的運行結果可知,啟動協程時,launch()函數中傳遞Unconfined主協程上下文時,程序執行的是主線程,傳遞coroutineContext父協程上下文時,程序執行的也是主線程,傳遞CommonPool線程池時,程序執行的是某一個線程,傳遞newSingleThreadContext("new thread")新線程時,程序執行的是新線程new thread。
>[info] 注意
上述程序中,由于執行的是4個協程,而協程是一種輕量級線程,多線程的執行順序是不固定的,因此上述程序執行的先后順序是不固定的。
### 父子協程
當使用coroutineContext(協程上下文)來啟動另一個協程時,新協程的Job就變成父協程工作的一個子任務,當父協程被取消時,它的所有子協程也被遞歸地取消。接下來我們通過一個案例來演示取消父協程時,與其對應的子協程也會被取消,具體代碼如下所示。
```
import kotlinx.coroutines.experimental.delay
import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.runBlocking
fun main(args: Array<String>) :Unit= runBlocking{
val request = launch {
//父協程
val job1 = launch {
println("啟動協程1")
delay(1000L)
println("協程1執行完成")
}
//子協程,使用的上下文是request對應的協程上下文
val job2 = launch (coroutineContext){
println("啟動協程2")
delay(1000L)
println("協程2執行完成")
}
}
delay(500L)
request.cancel()
delay(2000L)
}
```
運行結果:
```
啟動協程1
啟動協程2
協程1執行完成
```
上述代碼中,首先通過launch()函數啟動了一個request協程,接著通過coroutineContext協程上下文啟動了一個子協程job2,主線程中通過delay()函數一共延遲了2500ms,而開啟的兩個協程通過delay()函數一共延遲了2000ms,根據程序的運行結果可知,當通過cancel()方法取消主協程request時,子協程job2也自動取消了,因此運行結果沒有打印“協程2執行完成”。
>[info] 注意
由于第20行代碼中的cancel()方法的返回值是boolean類型,而main()函數不需要返回值,因此在這行代碼下方任意輸出一段字符串即可,不然程序會報錯。
- 前言
- Kotlin簡介
- IntelliJ IDEA技巧總結
- idea設置類注釋和方法注釋模板
- 像Android Studion一樣創建工程
- Gradle
- Gradle入門
- Gradle進階
- 使用Gradle創建一個Kotlin工程
- 環境搭建
- Androidstudio平臺搭建
- Eclipse的Kotlin環境配置
- 使用IntelliJ IDEA
- Kotlin學習路線
- Kotlin官方中文版文檔教程
- 概述
- kotlin用于服務器端開發
- kotlin用于Android開發
- kotlin用于JavaScript開發
- kotlin用于原生開發
- Kotlin 用于數據科學
- 協程
- 多平臺
- 新特性
- 1.1的新特性
- 1.2的新特性
- 1.3的新特性
- 開始
- 基本語法
- 習慣用法
- 編碼規范
- 基礎
- 基本類型
- 包與導入
- 控制流
- 返回與跳轉
- 類與對象
- 類與繼承
- 屬性與字段
- 接口
- 可見性修飾符
- 擴展
- 數據類
- 密封類
- 泛型
- 嵌套類
- 枚舉類
- 對象
- 類型別名
- 內嵌類
- 委托
- 委托屬性
- 函數與Lambda表達式
- 函數
- Lambda表達式
- 內聯函數
- 集合
- 集合概述
- 構造集合
- 迭代器
- 區間與數列
- 序列
- 操作概述
- 轉換
- 過濾
- 加減操作符
- 分組
- 取集合的一部分
- 取單個元素
- 排序
- 聚合操作
- 集合寫操作
- List相關操作
- Set相關操作
- Map相關操作
- 多平臺程序設計
- 平臺相關聲明
- 以Gradle創建
- 更多語言結構
- 解構聲明
- 類型檢測與轉換
- This表達式
- 相等性
- 操作符重載
- 空安全
- 異常
- 注解
- 反射
- 作用域函數
- 類型安全的構造器
- Opt-in Requirements
- 核心庫
- 標準庫
- kotlin.test
- 參考
- 關鍵字與操作符
- 語法
- 編碼風格約定
- Java互操作
- Kotlin中調用Java
- Java中調用Kotlin
- JavaScript
- 動態類型
- kotlin中調用JavaScript
- JavaScript中調用kotlin
- JavaScript模塊
- JavaScript反射
- JavaScript DCE
- 原生
- 并發
- 不可變性
- kotlin庫
- 平臺庫
- 與C語言互操作
- 與Object-C及Swift互操作
- CocoaPods集成
- Gradle插件
- 調試
- FAQ
- 協程
- 協程指南
- 基礎
- 取消與超時
- 組合掛起函數
- 協程上下文與調度器
- 異步流
- 通道
- 異常處理與監督
- 共享的可變狀態與并發
- Select表達式(實驗性)
- 工具
- 編寫kotlin代碼文檔
- 使用Kapt
- 使用Gradle
- 使用Maven
- 使用Ant
- Kotlin與OSGI
- 編譯器插件
- 編碼規范
- 演進
- kotlin語言演進
- 不同組件的穩定性
- kotlin1.3的兼容性指南
- 常見問題
- FAQ
- 與Java比較
- 與Scala比較(官方已刪除)
- Google開發者官網簡介
- Kotlin and Android
- Get Started with Kotlin on Android
- Kotlin on Android FAQ
- Android KTX
- Resources to Learn Kotlin
- Kotlin樣品
- Kotlin零基礎到進階
- 第一階段興趣入門
- kotlin簡介和學習方法
- 數據類型和類型系統
- 入門
- 分類
- val和var
- 二進制基礎
- 基礎
- 基本語法
- 包
- 示例
- 編碼規范
- 代碼注釋
- 異常
- 根類型“Any”
- Any? 可空類型
- 可空性的實現原理
- kotlin.Unit類型
- kotlin.Nothing類型
- 基本數據類型
- 數值類型
- 布爾類型
- 字符型
- 位運算符
- 變量和常量
- 語法和運算符
- 關鍵字
- 硬關鍵字
- 軟關鍵字
- 修飾符關鍵字
- 特殊標識符
- 操作符和特殊符號
- 算術運算符
- 賦值運算符
- 比較運算符
- 邏輯運算符
- this關鍵字
- super關鍵字
- 操作符重載
- 一元操作符
- 二元操作符
- 字符串
- 字符串介紹和屬性
- 字符串常見方法操作
- 字符串模板
- 數組
- 數組介紹創建及遍歷
- 數組常見方法和屬性
- 數組變化以及下標越界問題
- 原生數組類型
- 區間
- 正向區間
- 逆向區間
- 步長
- 類型檢測與類型轉換
- is、!is、as、as-運算符
- 空安全
- 可空類型變量
- 安全調用符
- 非空斷言
- Elvis操作符
- 可空性深入
- 可空性和Java
- 函數
- 函數式編程概述
- OOP和FOP
- 函數式編程基本特性
- 組合與范疇
- 在Kotlin中使用函數式編程
- 函數入門
- 函數作用域
- 函數加強
- 命名參數
- 默認參數
- 可變參數
- 表達式函數體
- 頂層、嵌套、中綴函數
- 尾遞歸函數優化
- 函數重載
- 控制流
- if表達式
- when表達式
- for循環
- while循環
- 循環中的 Break 與 continue
- return返回
- 標簽處返回
- 集合
- list集合
- list集合介紹和操作
- list常見方法和屬性
- list集合變化和下標越界
- set集合
- set集合介紹和常見操作
- set集合常見方法和屬性
- set集合變換和下標越界
- map集合
- map集合介紹和常見操作
- map集合常見方法和屬性
- map集合變換
- 集合的函數式API
- map函數
- filter函數
- “ all ”“ any ”“ count ”和“ find ”:對集合應用判斷式
- 別樣的求和方式:sumBy、sum、fold、reduce
- 根據人的性別進行分組:groupBy
- 扁平化——處理嵌套集合:flatMap、flatten
- 惰性集合操作:序列
- 區間、數組、集合之間轉換
- 面向對象
- 面向對象-封裝
- 類的創建及屬性方法訪問
- 類屬性和字段
- 構造器
- 嵌套類(內部類)
- 枚舉類
- 枚舉類遍歷&枚舉常量常用屬性
- 數據類
- 密封類
- 印章類(密封類)
- 面向對象-繼承
- 類的繼承
- 面向對象-多態
- 抽象類
- 接口
- 接口和抽象類的區別
- 面向對象-深入
- 擴展
- 擴展:為別的類添加方法、屬性
- Android中的擴展應用
- 優化Snackbar
- 用擴展函數封裝Utils
- 解決煩人的findViewById
- 擴展不是萬能的
- 調度方式對擴展函數的影響
- 被濫用的擴展函數
- 委托
- 委托類
- 委托屬性
- Kotlin5大內置委托
- Kotlin-Object關鍵字
- 單例模式
- 匿名類對象
- 伴生對象
- 作用域函數
- let函數
- run函數
- with函數
- apply函數
- also函數
- 標準庫函數
- takeIf 與 takeUnless
- 第二階段重點深入
- Lambda編程
- Lambda成員引用高階函數
- 高階函數
- 內聯函數
- 泛型
- 泛型的分類
- 泛型約束
- 子類和子類型
- 協變與逆變
- 泛型擦除與實化類型
- 泛型類型參數
- 泛型的背后:類型擦除
- Java為什么無法聲明一個泛型數組
- 向后兼容的罪
- 類型擦除的矛盾
- 使用內聯函數獲取泛型
- 打破泛型不變
- 一個支持協變的List
- 一個支持逆變的Comparator
- 協變和逆變
- 第三階段難點突破
- 注解和反射
- 聲明并應用注解
- DSL
- 協程
- 協程簡介
- 協程的基本操作
- 協程取消
- 管道
- 慕課霍丙乾協程筆記
- Kotlin與Java互操作
- 在Kotlin中調用Java
- 在Java中調用Kotlin
- Kotlin與Java中的操作對比
- 第四階段專題練習
- 朱凱Kotlin知識點總結
- Kotlin 基礎
- Kotlin 的變量、函數和類型
- Kotlin 里那些「不是那么寫的」
- Kotlin 里那些「更方便的」
- Kotlin 進階
- Kotlin 的泛型
- Kotlin 的高階函數、匿名函數和 Lambda 表達式
- Kotlin協程
- 初識
- 進階
- 深入
- Kotlin 擴展
- 會寫「18.dp」只是個入門——Kotlin 的擴展函數和擴展屬性(Extension Functions / Properties)
- Kotlin實戰-開發Android