launch 創(chuàng)建子協(xié)程
通過launch
在一個協(xié)程中啟動子協(xié)程,可以根據(jù)業(yè)務(wù)需求創(chuàng)建一個或多個子協(xié)程:
fun launchTest3() {
print("start")
GlobalScope.launch {
delay(1000)
print("CoroutineScope.launch")
//在協(xié)程內(nèi)創(chuàng)建子協(xié)程
launch {
delay(1500)//1.5秒無阻塞延遲(默認單位為毫秒)
print("launch 子協(xié)程")
}
}
print("end")
}
打印數(shù)據(jù)如下:
launch3.gif
async
async
類似于launch
,都是創(chuàng)建一個不會阻塞當前線程的新的協(xié)程。它們區(qū)別在于:async
的返回是Deferred
對象,可通過Deffer.await()
等待協(xié)程執(zhí)行完成并獲取結(jié)果,而 launch
不行。常用于并發(fā)執(zhí)行-同步等待和獲取返回值的情況。
public fun CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred
- context:?協(xié)程的上下文,同
launch
。 - start: ?協(xié)程啟動模式,同
launch
。 - block:??協(xié)程代碼,同
launch
。 - Deferred:?協(xié)程構(gòu)建函數(shù)的返回值,繼承自
Job
,一個有結(jié)果的Job
,可通過Deffer.await()
等待協(xié)程執(zhí)行完成并獲取結(jié)果。
await 獲取返回值
//獲取返回值
fun asyncTest1() {
print("start")
GlobalScope.launch {
val deferred: Deferred<String> = async {
//協(xié)程將線程的執(zhí)行權(quán)交出去,該線程繼續(xù)干它要干的事情,到時間后會恢復至此繼續(xù)向下執(zhí)行
delay(2000)//2秒無阻塞延遲(默認單位為毫秒)
print("asyncOne")
"HelloWord"//這里返回值為HelloWord
}
//等待async執(zhí)行完成獲取返回值,此處并不會阻塞線程,而是掛起,將線程的執(zhí)行權(quán)交出去
//等到async的協(xié)程體執(zhí)行完畢后,會恢復協(xié)程繼續(xù)往下執(zhí)行
val result = deferred.await()
print("result == $result")
}
print("end")
}
上面例子中返回對象Deferred
, 通過函數(shù)await()
獲取結(jié)果值。打印數(shù)據(jù)如下:
async1.gif
注意:await()
不能在協(xié)程之外調(diào)用,因為它需要掛起直到計算完成,而且只有協(xié)程可以以非阻塞的方式掛起。所以把它放到協(xié)程中。
async 并發(fā)
當在協(xié)程作用域中使用async
函數(shù)時可以創(chuàng)建并發(fā)任務(wù):
fun asyncTest2() {
print("start")
GlobalScope.launch {
val time = measureTimeMillis {//計算執(zhí)行時間
val deferredOne: Deferred<Int> = async {
delay(2000)
print("asyncOne")
100//這里返回值為100
}
val deferredTwo: Deferred<Int> = async {
delay(3000)
print("asyncTwo")
200//這里返回值為200
}
val deferredThr: Deferred<Int> = async {
delay(4000)
print("asyncThr")
300//這里返回值為300
}
//等待所有需要結(jié)果的協(xié)程完成獲取執(zhí)行結(jié)果
val result = deferredOne.await() + deferredTwo.await() + deferredThr.await()
print("result == $result")
}
print("耗時 $time ms")
}
print("end")
}
打印數(shù)據(jù)如下:
async2.gif
上面的代碼就是一個簡單的并發(fā)示例,async
是不阻塞線程的,也就是說上面三個async{}
異步任務(wù)是同時進行的。通過await()
方法可以拿到async
協(xié)程的執(zhí)行結(jié)果,可以看到兩個協(xié)程的總耗時是遠少于9秒的,總耗時基本等于耗時最長的協(xié)程。
1.
Deferred
集合還可以使用awaitAll()
等待全部完成;2.如果
Deferred
不執(zhí)行await()
則async
內(nèi)部拋出的異常不會被logCat
或tryCatch
捕獲, 但是依然會導致作用域取消和異常崩潰; 但當執(zhí)行await時異常信息會重新拋出。3.惰性并發(fā),如果將
async
函數(shù)中的啟動模式設(shè)置為CoroutineStart.LAZY
懶加載模式時則只有調(diào)用Deferred
對象的await
時(或者執(zhí)行async.satrt()
)才會開始執(zhí)行異步任務(wù)。
launch
構(gòu)建器適合執(zhí)行 "一勞永逸" 的工作,意思就是說它可以啟動新協(xié)程而不需要結(jié)果返回;async
構(gòu)建器可啟動新協(xié)程并允許您使用一個名為await
的掛起函數(shù)返回result
,并且支持并發(fā)。另外launch
和async
之間的很大差異是它們對異常的處理方式不同。如果使用async
作為最外層協(xié)程的開啟方式,它期望最終是通過調(diào)用 await
來獲取結(jié)果 (或者異常),所以默認情況下它不會拋出異常。這意味著如果使用 async
啟動新的最外層協(xié)程,而不使用await
,它會靜默地將異常丟棄。
2.Job & Deferred
反觀線程,java平臺上很明確地給出了線程的類型Thread
,我們也需要一個這樣的類來描述協(xié)程,它就是Job
。它的API設(shè)計與Java的Thread
殊途同歸。
Job
Job
是協(xié)程的句柄。如果把門和門把手比作協(xié)程和Job
之間的關(guān)系,那么協(xié)程就是這扇門,Job
就是門把手。意思就是可以通過Job
實現(xiàn)對協(xié)程的控制和管理。
從上面可以知道Job
是launch
構(gòu)建協(xié)程返回的一個協(xié)程任務(wù),完成時是沒有返回值的??梢园?code>Job看成協(xié)程對象本身,封裝了協(xié)程中需要執(zhí)行的代碼邏輯,協(xié)程的操作方法都在Job
身上。Job
具有生命周期并且可以取消,它也是上下文元素,繼承自CoroutineContext
。
這里列舉Job
幾個比較有用的函數(shù):
public interface Job : CoroutineContext.Element {
//活躍的,是否仍在執(zhí)行
public val isActive: Boolean
//啟動協(xié)程,如果啟動了協(xié)程,則為true;如果協(xié)程已經(jīng)啟動或完成,則為false
public fun start(): Boolean
//取消Job,可通過傳入Exception說明具體原因
public fun cancel(cause: CancellationException? = null)
//掛起協(xié)程直到此Job完成
public suspend fun join()
//取消任務(wù)并等待任務(wù)完成,結(jié)合了[cancel]和[join]的調(diào)用
public suspend fun Job.cancelAndJoin()
//給Job設(shè)置一個完成通知,當Job執(zhí)行完成的時候會同步執(zhí)行這個函數(shù)
public fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle
}
與Thread
相比,Job
同樣有join()
,調(diào)用時會掛起(線程的join()
則會阻塞線程),直到協(xié)程完成;它的cancel()
可以類比Thread
的interrupt()
,用于取消協(xié)程;isActive
則是可以類比Thread
的isAlive()
,用于查詢協(xié)程是否仍在執(zhí)行。
Job
是一個接口類型,它具有以下三種狀態(tài):
狀態(tài) | 說明 |
---|---|
isActive |
活躍的。當Job 處于活動狀態(tài)時為true ,如果Job 已經(jīng)開始,但還沒有完成、也沒有取消或者失敗,則是處于active 狀態(tài)。 |
isCompleted |
已完成。當Job 由于任何原因完成時為true ,已取消、已失敗和已完成Job 都是被視為完成狀態(tài)。 |
isCancelled |
已退出。當Job 由于任何原因被取消時為true ,無論是通過顯式調(diào)用cancel 或這因為它已經(jīng)失敗亦或者它的子或父被取消,都是被視為已退出狀態(tài)。 |
這里模擬一個無限循環(huán)的協(xié)程,當協(xié)程是活躍狀態(tài)時每秒鐘打印兩次消息,1.2秒后取消協(xié)程:
fun jobTest() = runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default){
var nextPrintTime = startTime
var i = 0
while (isActive) {//當job是活躍狀態(tài)繼續(xù)執(zhí)行
if (System.currentTimeMillis() >= nextPrintTime) {//每秒鐘打印兩次消息
print("job: I'm sleeping ${i++} ...")
nextPrintTime += 500
}
}
}
delay(1200)//延遲1.2s
print("等待1.2秒后")
//job.join()
//job.cancel()
job.cancelAndJoin()//取消任務(wù)并等待任務(wù)完成
print("協(xié)程被取消并等待完成")
}
join()
是一個掛起函數(shù),它需要等待協(xié)程的執(zhí)行,如果協(xié)程尚未完成,join()
立即掛起,直到協(xié)程完成;如果協(xié)程已經(jīng)完成,join()
不會掛起,而是立即返回。打印數(shù)據(jù)如下:
join.gif
Job
還可以有層級關(guān)系,一個Job
可以包含多個子Job
,當父Job
被取消后,所有的子Job
也會被自動取消;當子Job
被取消或者出現(xiàn)異常后父Job
也會被取消。具有多個子 Job
的父Job
會等待所有子Job
完成(或者取消)后,自己才會執(zhí)行完成。
總的來說:它的作用是Job
實例作為協(xié)程的唯一標識,用于處理協(xié)程,并且負責管理協(xié)程的生命周期。
Deferred
Deferred
繼承自Job
,具有與Job
相同的狀態(tài)機制。它是async
構(gòu)建協(xié)程返回的一個協(xié)程任務(wù),可通過調(diào)用await()
方法等待協(xié)程執(zhí)行完成并獲取結(jié)果。不同的是Job
沒有結(jié)果值,Deffer
有結(jié)果值。
public interface Deferred<out T> : Job {
//等待協(xié)程執(zhí)行完成并獲取結(jié)果
public suspend fun await(): T
}
await()
:?等待協(xié)程執(zhí)行完畢并返回結(jié)果,如果異常結(jié)束則會拋出異常;如果協(xié)程尚未完成,則掛起直到協(xié)程執(zhí)行完成。T
:????這里多了一個泛型參數(shù)T
,它表示返回值類型,通過await()
函數(shù)可以拿到這個返回值。
上面已有Deferred
代碼演示,這里就不再重復實踐。
3.作用域
通常我們提到的域
,都是用來描述范圍的,域
既有約束作用又有提供額外能力的作用。
協(xié)程作用域(CoroutineScope
)其實就是為協(xié)程定義的作用范圍 ,為了確保所有的協(xié)程都會被追蹤,Kotlin 不允許在沒有使用CoroutineScope
的情況下啟動新的協(xié)程。CoroutineScope
可被看作是一個具有超能力的ExecutorService
的輕量級版本。它能啟動新的協(xié)程,同時這個協(xié)程還具備上面所說的suspend
和resume
的優(yōu)勢。
每個協(xié)程生成器launch
、async
等都是CoroutineScope
的擴展,并繼承了它的coroutineContext
自動傳播其所有元素和取消。協(xié)程作用域本質(zhì)是一個接口:
public interface CoroutineScope {
//此域的上下文。Context被作用域封裝,用于在作用域上擴展的協(xié)程構(gòu)建器的實現(xiàn)。
public val coroutineContext: CoroutineContext
}
因為 啟動協(xié)程需要作用域 ,但是作用域又是在協(xié)程創(chuàng)建過程中產(chǎn)生的,這似乎是一個“先有雞后有蛋還是先有蛋后有雞”的問題。
常用作用域
官方庫給我們提供了一些作用域可以直接來使用:
runBlocking
:頂層函數(shù),它的第二個參數(shù)為接收者是CoroutineScope
的函數(shù)字面量,可啟動協(xié)程。但是它會阻塞當前線程,主要用于測試。GlobalScope
:全局協(xié)程作用域,通過GlobalScope
創(chuàng)建的協(xié)程不會有父協(xié)程,可以把它稱為根協(xié)程
。它啟動的協(xié)程的生命周期只受整個應(yīng)用程序的生命周期的限制,且不能取消,在運行時會消耗一些內(nèi)存資源,這可能會導致內(nèi)存泄露,所以仍不適用于業(yè)務(wù)開發(fā)。coroutineScope
:創(chuàng)建一個獨立的協(xié)程作用域,直到所有啟動的協(xié)程都完成后才結(jié)束自身。它是一個掛起函數(shù),需要運行在協(xié)程內(nèi)或掛起函數(shù)內(nèi)。當這個作用域中的任何一個子協(xié)程失敗時,這個作用域失敗,所有其他的子程序都被取消。為并行分解工作而設(shè)計的。supervisorScope
:與coroutineScope
類似,不同的是子協(xié)程的異常不會影響父協(xié)程,也不會影響其他子協(xié)程。(作用域本身的失敗(在block
或取消中拋出異常)會導致作用域及其所有子協(xié)程失敗,但不會取消父協(xié)程。)MainScope
:為UI組件創(chuàng)建主作用域。一個頂層函數(shù),上下文是SupervisorJob() + Dispatchers.Main
,說明它是一個在主線程執(zhí)行的協(xié)程作用域,通過cancel
對協(xié)程進行取消。推薦使用。
fun scopeTest() {
//創(chuàng)建一個根協(xié)程
GlobalScope.launch {//父協(xié)程
launch {//子協(xié)程
print("GlobalScope的子協(xié)程")
}
launch {//第二個子協(xié)程
print("GlobalScope的第二個子協(xié)程")
}
}
//為UI組件創(chuàng)建主作用域
val mainScope = MainScope()
mainScope.launch {//啟動協(xié)程
//todo
}
}
注意:MainScope
作用域的好處就是方便地綁定到UI組件的聲明周期上,在Activity銷毀的時候mainScope.cancel()
取消其作用域。
Lifecycle的協(xié)程支持
Android 官方對協(xié)程的支持是非常友好的,KTX 為 Jetpack 的Lifecycle
相關(guān)組件提供了已經(jīng)綁定UV聲明周期的作用域供我們直接使用:
lifecycleScope
:Lifecycle Ktx
庫提供的具有生命周期感知的協(xié)程作用域,與Lifecycle
綁定生命周期,生命周期被銷毀時,此作用域?qū)⒈蝗∠?。會與當前的UI組件綁定生命周期,界面銷毀時該協(xié)程作用域?qū)⒈蝗∠?,不會造成協(xié)程泄漏,推薦使用。viewModelScope
:與lifecycleScope
類似,與ViewModel
綁定生命周期,當ViewModel
被清除時,這個作用域?qū)⒈蝗∠?。推薦使用。
在build.gradle
添加Lifecycle相應(yīng)基礎(chǔ)組件后,再添加以下組件即可:
// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
// 只有Lifecycles(沒有 ViewModel 和 LiveData)
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0"
因為Activity
實現(xiàn)了LifecycleOwner
這個接口,而lifecycleScope
則正是它的拓展成員,可以在Activity中直接使用lifecycleScope
協(xié)程實例:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
btn_data.setOnClickListener {
lifecycleScope.launch {//使用lifecycleScope創(chuàng)建協(xié)程
//協(xié)程執(zhí)行體
}
}
}
}
在ViewModel
中使用創(chuàng)建協(xié)程:
class MainViewModel : ViewModel() {
fun getData() {
viewModelScope.launch {//使用viewModelScope創(chuàng)建協(xié)程
//執(zhí)行協(xié)程
}
}
}
注意:VIewModel 的作用域會在它的 clear 函數(shù)調(diào)用時取消。
分類和行為規(guī)則
官方框架在實現(xiàn)復合協(xié)程的過程中也提供了作用域,主要用于明確父子關(guān)系,以及取消或者異常處理等方面的傳播行為。該作用域分為以下三種:
- 頂級作用域 :沒有父協(xié)程的協(xié)程所在的作用域為頂級作用域。
- 協(xié)同作用域 :協(xié)程中啟動新的協(xié)程,新協(xié)程為所在協(xié)程的子協(xié)程,這種情況下,子協(xié)程所在的作用域默認為協(xié)同作用域。此時子協(xié)程拋出的未捕獲異常,都將傳遞給父協(xié)程處理,父協(xié)程同時也會被取消。
- 主從作用域 :與協(xié)同作用域在協(xié)程的父子關(guān)系上一致,區(qū)別在于,處于該作用域下的協(xié)程出現(xiàn)未捕獲的異常時,不會將異常向上傳遞給父協(xié)程。除了三種作用域中提到的行為以外,父子協(xié)程之間還存在以下規(guī)則:
- 父協(xié)程被取消,則所有子協(xié)程均被取消。由于協(xié)同作用域和主從作用域中都存在父子協(xié)程關(guān)系,因此此條規(guī)則都適用。
- 父協(xié)程需要等待子協(xié)程執(zhí)行完畢之后才會最終進入完成狀態(tài),不管父協(xié)程自身的協(xié)程體是否已經(jīng)執(zhí)行完。
- 子協(xié)程會繼承父協(xié)程的協(xié)程上下文中的元素,如果自身有相同
key
的成員,則覆蓋對應(yīng)的key
,覆蓋的效果僅限自身范圍內(nèi)有效。
4.調(diào)度器
在上面介紹協(xié)程概念的時候,協(xié)程的掛起與恢復在哪掛起,什么時候恢復,為什么能切換線程,這因為調(diào)度器的作用:它確定相應(yīng)的協(xié)程使用那些線程來執(zhí)行。
CoroutineDispatcher
調(diào)度器指定指定執(zhí)行協(xié)程的目標載體,它確定了相關(guān)的協(xié)程在哪個線程或哪些線程上執(zhí)行。可以將協(xié)程限制在一個特定的線程執(zhí)行,或?qū)⑺峙傻揭粋€線程池,亦或是讓它不受限地運行。
協(xié)程需要調(diào)度的位置就是掛起點的位置,只有當掛起點正在掛起的時候才會進行調(diào)度,實現(xiàn)調(diào)度需要使用協(xié)程的攔截器。調(diào)度的本質(zhì)就是解決掛起點恢復之后的協(xié)程邏輯在哪里運行的問題。調(diào)度器也屬于協(xié)程上下文一類,它繼承自攔截器:
public abstract class CoroutineDispatcher :
AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {
//詢問調(diào)度器是否需要分發(fā)
public open fun isDispatchNeeded(context: CoroutineContext): Boolean = true
//將可運行塊的執(zhí)行分派到給定上下文中的另一個線程上。這個方法應(yīng)該保證給定的[block]最終會被調(diào)用。
public abstract fun dispatch(context: CoroutineContext, block: Runnable)
//返回一個continuation,它封裝了提供的[continuation],攔截了所有的恢復。
public final override fun interceptContinuation(continuation: Continuation<T>): Continuation
它是所有協(xié)程調(diào)度程序?qū)崿F(xiàn)擴展的基類(我們很少會自己自定義調(diào)度器)??梢允褂?code>newSingleThreadContext和newFixedThreadPoolContext
創(chuàng)建私有線程池。也可以使用asCoroutineDispatcher
擴展函數(shù)將任意java.util.concurrent.Executor
轉(zhuǎn)換為調(diào)度程序。
調(diào)度器模式
Kotlin 提供了四個調(diào)度器,您可以使用它們來指定應(yīng)在何處運行協(xié)程:
調(diào)度器模式 | 說明 | 適用場景 |
---|---|---|
Dispatchers.Default |
默認調(diào)度器,非主線程。CPU 密集型任務(wù)調(diào)度器,適合處理后臺計算。 |
通常處理一些單純的計算任務(wù),或者執(zhí)行時間較短任務(wù)比如:Json 的解析,數(shù)據(jù)計算等。 |
Dispatchers.Main |
UI 調(diào)度器, Andorid 上的主線程。 |
調(diào)度程序是單線程的,通常用于UI 交互,刷新等。 |
Dispatchers.Unconfined |
一個不局限于任何特定線程的協(xié)程調(diào)度程序,即非受限調(diào)度器。 | 子協(xié)程切換線程代碼會運行在原來的線程上,協(xié)程在相應(yīng)的掛起函數(shù)使用的任何線程中繼續(xù)。 |
Dispatchers.IO |
IO 調(diào)度器,非主線程,執(zhí)行的線程是IO 線程。 |
適合執(zhí)行IO 相關(guān)操作,比如:網(wǎng)絡(luò)處理,數(shù)據(jù)庫操作,文件讀寫等。 |
所有的協(xié)程構(gòu)造器(如launch
和async
)都接受一個可選參數(shù),即 CoroutineContext
,該參數(shù)可用于顯式指定要創(chuàng)建的協(xié)程和其它上下文元素所要使用的CoroutineDispatcher
。
fun dispatchersTest() {
//創(chuàng)建一個在主線程執(zhí)行的協(xié)程作用域
val mainScope = MainScope()
mainScope.launch {
launch(Dispatchers.Main) {//在協(xié)程上下參數(shù)中指定調(diào)度器
print("主線程調(diào)度器")
}
launch(Dispatchers.Default) {
print("默認調(diào)度器")
}
launch(Dispatchers.Unconfined) {
print("任意調(diào)度器")
}
launch(Dispatchers.IO) {
print("IO調(diào)度器")
}
}
}
打印數(shù)據(jù)如下:
image.png
withContext
在 Andorid 開發(fā)中,我們常常在子線程中請求網(wǎng)絡(luò)獲取數(shù)據(jù),然后切換到主線程更新UI。官方為我們提供了一個withContext
頂級函數(shù),在獲取數(shù)據(jù)函數(shù)內(nèi),調(diào)用withContext(Dispatchers.IO)
來創(chuàng)建一個在IO
線程池中運行的塊。您放在該塊內(nèi)的任何代碼都始終通過IO
調(diào)度器執(zhí)行。由于withContext
本身就是一個suspend
函數(shù),它會使用協(xié)程來保證主線程安全。
//用給定的協(xié)程上下文調(diào)用指定的掛起塊,掛起直到它完成,并返回結(jié)果。
public suspend fun withContext(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
): T
- context:?協(xié)程的上下文,同上(調(diào)度器也屬于上下文一類)。
- block:??協(xié)程執(zhí)行體,同上。
block
中的代碼會被調(diào)度到上面指定的調(diào)度器上執(zhí)行,并返回結(jié)果值。
這個函數(shù)會使用新指定的上下文的dispatcher
,將block
的執(zhí)行轉(zhuǎn)移到指定的線程中。它會返回結(jié)果, 可以和當前協(xié)程的父協(xié)程存在交互關(guān)系, 主要作用為了來回切換調(diào)度器 。
GlobalScope.launch(Dispatchers.Main) {//開始協(xié)程:主線程
val result: User = withContext(Dispatchers.IO) {//網(wǎng)絡(luò)請求(IO 線程)
userApi.getUserSuspend("FollowExcellence")
}
tv_title.text = result.name //更新 UI(主線程)
}
在主線程中啟動一個協(xié)程,然后再通過withContext(Dispatchers.IO)
調(diào)度到IO
線程上去做網(wǎng)絡(luò)請求,獲取結(jié)果返回后,主線程上的協(xié)程就會恢復繼續(xù)執(zhí)行,完成UI的更新。
由于withContext
可讓在不引入回調(diào)的情況下控制任何代碼行的線程池,因此可以將其應(yīng)用于非常小的函數(shù),如從數(shù)據(jù)庫中讀取數(shù)據(jù)或執(zhí)行網(wǎng)絡(luò)請求。一種不錯的做法是使用withContext
來確保每個函數(shù)都是主線程安全的,那么可以從主線程調(diào)用每個函數(shù)。調(diào)用方也就無需再考慮應(yīng)該使用哪個線程來執(zhí)行函數(shù)了。您可以使用外部 withContext
來讓 Kotlin 只切換一次線程,這樣可以在多次調(diào)用的情況下,以盡可能避免了線程切換所帶來的性能損失。
-
Android
+關(guān)注
關(guān)注
12文章
3935瀏覽量
127339 -
JAVA
+關(guān)注
關(guān)注
19文章
2966瀏覽量
104701 -
編程
+關(guān)注
關(guān)注
88文章
3614瀏覽量
93685 -
ui
+關(guān)注
關(guān)注
0文章
204瀏覽量
21368 -
kotlin
+關(guān)注
關(guān)注
0文章
60瀏覽量
4187
發(fā)布評論請先 登錄
相關(guān)推薦
評論