Chào mừng độc giả Cafedev đến với mảng kiến thức mới về Kotlin và Coroutine context! Tại đây, chúng ta sẽ khám phá những khía cạnh thú vị của ngôn ngữ lập trình này khi kết hợp với Coroutine context và dispatchers. Cafedev luôn cam kết mang đến những thông tin chất lượng và độc đáo, giúp cộng đồng phát triển và chia sẻ kiến thức một cách sôi nổi. Hãy cùng nhau tìm hiểu về sức mạnh và linh hoạt của Kotlin khi sử dụng Coroutine context và dispatchers trong hành trình lập trình của bạn!

Coroutine luôn thực thi trong một ngữ cảnh(context) được biểu diễn bằng giá trị của kiểu CoroutineContext, được định nghĩa trong thư viện chuẩn của Kotlin.


Ngữ cảnh coroutine là một bộ sưu tập của các thành phần khác nhau. Các thành phần chính bao gồm Job của coroutine, mà chúng ta đã thấy trước đó, và bộ phận dispatcher, được covered trong phần này.

1. Dispatchers và luồng

Ngữ cảnh coroutine bao gồm một coroutine dispatcher (xem CoroutineDispatcher) quyết định luồng hoặc các luồng mà coroutine tương ứng sử dụng cho việc thực thi của nó. Coroutine dispatcher có thể hạn chế thực thi coroutine vào một luồng cụ thể, dispatch nó đến một pool luồng, hoặc để nó chạy không giới hạn.


Tất cả các coroutine builders như launch và async chấp nhận một tham số tùy chọn của kiểu CoroutineContext có thể được sử dụng để chỉ định một cách tường minh dispatcher cho coroutine mới và các thành phần ngữ cảnh khác.
Hãy thử ví dụ sau:

launch { // context of the parent, main runBlocking coroutine
    println("main runBlocking      : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
    println("Unconfined            : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Default) { // will get dispatched to DefaultDispatcher 
    println("Default               : I'm working in thread ${Thread.currentThread().name}")
}
launch(newSingleThreadContext("MyOwnThread")) { // will get its own new thread
    println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}")
}

Bạn có thể lấy mã nguồn đầy đủ tại đây.


Nó tạo ra đầu ra như sau (có thể theo thứ tự khác nhau):

Unconfined            : I'm working in thread main
Default               : I'm working in thread DefaultDispatcher-worker-1
newSingleThreadContext: I'm working in thread MyOwnThread
main runBlocking      : I'm working in thread main

Khi launch { ... } được sử dụng mà không có tham số, nó sẽ kế thừa ngữ cảnh (và do đó dispatcher) từ [CoroutineScope] mà nó được khởi chạy. Trong trường hợp này, nó kế thừa ngữ cảnh của coroutine runBlocking chính chạy trong luồng main.


Dispatchers.Unconfined là một dispatcher đặc biệt cũng có vẻ chạy trong luồng main, nhưng thực tế, đó là một cơ chế khác được giải thích sau này.


Dispatcher mặc định được sử dụng khi không có dispatcher nào được chỉ định một cách rõ ràng trong phạm vi. Nó được biểu diễn bởi Dispatchers.Default và sử dụng một pool luồng nền được chia sẻ.


newSingleThreadContext tạo ra một luồng cho coroutine chạy. Một luồng dành riêng là một nguồn lực rất đắt đỏ. Trong một ứng dụng thực tế, nó phải được giải phóng, khi không còn cần thiết nữa, bằng cách sử dụng hàm close, hoặc lưu trữ trong một biến cấp cao và tái sử dụng trong toàn bộ ứng dụng.

2. Dispatcher không giới hạn và có giới hạn

Coroutine dispatcher bắt đầu một coroutine trong luồng của người gọi, nhưng chỉ đến điểm treo đầu tiên. Sau khi treo, nó tiếp tục coroutine trong luồng được đầy đủ xác định bởi hàm treo đang được gọi. Dispatcher không giới hạn phù hợp cho các coroutine không tiêu thụ thời gian CPU hoặc cập nhật dữ liệu chia sẻ (như UI) giới hạn trong một luồng cụ thể.


Ngược lại, dispatcher được kế thừa từ CoroutineScope bên ngoài theo mặc định. Dispatcher mặc định cho coroutine runBlocking, cụ thể, giới hạn trong luồng gọi, vì vậy việc kế thừa nó có hiệu quả là hạn chế thực thi vào luồng này với lịch hàng đợi dự đoán.

launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
    println("Unconfined      : I'm working in thread ${Thread.currentThread().name}")
    delay(500)
    println("Unconfined      : After delay in thread ${Thread.currentThread().name}")
}
launch { // context of the parent, main runBlocking coroutine
    println("main runBlocking: I'm working in thread ${Thread.currentThread().name}")
    delay(1000)
    println("main runBlocking: After delay in thread ${Thread.currentThread().name}")
}

Bạn có thể lấy mã nguồn đầy đủ tại đây.


Tạo ra đầu ra:

Unconfined      : I'm working in thread main
main runBlocking: I'm working in thread main
Unconfined      : After delay in thread kotlinx.coroutines.DefaultExecutor
main runBlocking: After delay in thread main

Do đó, coroutine với ngữ cảnh được kế thừa từ runBlocking {...} tiếp tục thực thi trong luồng main, trong khi coroutine không giới hạn tiếp tục trong luồng mặc định của bộ thực thi mà hàm [delay] đang sử dụng.

Dispatcher không giới hạn là một cơ chế nâng cao có thể hữu ích trong một số trường hợp góc nhỏ nơi việc phân phối coroutine để thực thi sau này không cần thiết hoặc tạo ra các hiệu ứng phụ không mong muốn, vì một số hoạt động trong một coroutine phải được thực hiện ngay lập tức. Dispatcher không giới hạn không nên được sử dụng trong mã nguồn chung.

3. Debugging coroutines và threads

Coroutine có thể treo trên một luồng và tiếp tục trên một luồng khác. Ngay cả với một dispatcher có một luồng, nếu bạn không có các công cụ đặc biệt, việc xác định coroutine đang làm gì, ở đâu và khi nào có thể khó khăn.

3.1 Debugging với IDEA

Bộ Debug Coroutine của plugin Kotlin giúp đơn giản hóa việc gỡ lỗi coroutine trong IntelliJ IDEA.

Gỡ lỗi hoạt động cho các phiên bản 1.3.8 hoặc mới hơn của kotlinx-coroutines-core.
Cửa sổ Debug chứa tab Coroutines. Trong tab này, bạn có thể tìm thấy thông tin về cả coroutine đang chạy và coroutine đang treo. Các coroutine được nhóm theo dispatcher mà chúng đang chạy trên đó.



Với bộ gỡ lỗi coroutine, bạn có thể:
* Kiểm tra trạng thái của mỗi coroutine.
* Xem giá trị của các biến cục bộ và biến được captured cho cả coroutine đang chạy và coroutine đang treo.
* Xem toàn bộ ngăn xếp tạo coroutine, cũng như ngăn xếp cuộc gọi bên trong coroutine. Ngăn xếp bao gồm tất cả các frame với giá trị biến, kể cả những frame mà trong gỡ lỗi thông thường sẽ bị mất.
* Nhận báo cáo đầy đủ chứa trạng thái của mỗi coroutine và ngăn xếp của nó. Để có được nó, nhấp chuột phải trong tab Coroutines, sau đó nhấp Get Coroutines Dump.


Để bắt đầu gỡ lỗi coroutine, bạn chỉ cần đặt breakpoints và chạy ứng dụng trong chế độ debug.
Tìm hiểu thêm về gỡ lỗi coroutine trong hướng dẫn.

3.2 Debugging bằng cách sử dụng logging

Một phương pháp khác để gỡ lỗi ứng dụng với các luồng mà không cần Coroutine Debugger là in tên luồng trong tệp log trên mỗi lệnh log. Tính năng này được hỗ trợ một cách phổ quát bởi các framework logging. Khi sử dụng coroutine, chỉ tên luồng không cung cấp nhiều ngữ cảnh, vì vậy kotlinx.coroutines bao gồm các tiện ích gỡ lỗi để làm cho nó dễ dàng hơn.
Chạy mã sau với tùy chọn JVM -Dkotlinx.coroutines.debug:

val a = async {
    log("I'm computing a piece of the answer")
    6
}
val b = async {
    log("I'm computing another piece of the answer")
    7
}
log("The answer is ${a.await() * b.await()}")

Bạn có thể lấy mã nguồn đầy đủ tại đây.


Có ba coroutine. Coroutine chính (#1) trong runBlocking và hai coroutine tính giá trị deferred a (#2) và b (#3). Tất cả chúng đều thực thi trong ngữ cảnh của runBlocking và được giữ chặt trong luồng chính. Kết quả của đoạn mã này là:

[main @coroutine#2] I'm computing a piece of the answer
[main @coroutine#3] I'm computing another piece of the answer
[main @coroutine#1] The answer is 42

Hàm log in tên của luồng trong dấu ngoặc vuông, và bạn có thể thấy đó là luồng main với định danh của coroutine đang thực thi hiện tại được thêm vào nó. Định danh này được gán theo thứ tự cho tất cả các coroutine được tạo khi chế độ gỡ lỗi được bật.

Chế độ gỡ lỗi cũng được bật khi JVM chạy với tùy chọn -ea. Bạn có thể đọc thêm về các tiện ích gỡ lỗi trong tài liệu của thuộc tính [DEBUG_PROPERTY_NAME].

4. Chuyển đổi giữa các luồng

Chạy mã sau với tùy chọn JVM -Dkotlinx.coroutines.debug (xem debug):

import kotlinx.coroutines.*

fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")

fun main() {
//sampleStart
    newSingleThreadContext("Ctx1").use { ctx1 ->
        newSingleThreadContext("Ctx2").use { ctx2 ->
            runBlocking(ctx1) {
                log("Started in ctx1")
                withContext(ctx2) {
                    log("Working in ctx2")
                }
                log("Back to ctx1")
            }
        }
    }
//sampleEnd
}

Bạn có thể lấy mã nguồn đầy đủ tại đây.


Nó thể hiện một số kỹ thuật mới. Một là sử dụng [runBlocking] với ngữ cảnh được chỉ định một cách rõ ràng, và một cái khác là sử dụng hàm [withContext] để thay đổi ngữ cảnh của một coroutine trong khi vẫn ở trong cùng một coroutine, như bạn có thể thấy trong đầu ra dưới đây:

[Ctx1 @coroutine#1] Started in ctx1
[Ctx2 @coroutine#1] Working in ctx2
[Ctx1 @coroutine#1] Back to ctx1

Lưu ý rằng ví dụ này cũng sử dụng hàm use từ thư viện chuẩn Kotlin để giải phóng luồng được tạo với newSingleThreadContext khi chúng không còn cần thiết nữa.

5. Job trong ngữ cảnh(context)

Job của coroutine là một phần của ngữ cảnh của nó, và có thể được lấy từ đó bằng cách sử dụng biểu thức coroutineContext Job:

println("My job is ${coroutineContext[Job]}")

Bạn có thể lấy mã nguồn đầy đủ tại đây.


Trong chế độ gỡ lỗi, nó xuất ra một cái gì đó như sau:

My job is "coroutine#1":BlockingCoroutine{Active}@6d311334

Lưu ý rằng isActive trong CoroutineScope chỉ là một biện pháp tắt thuận tiện chocoroutineContext[Job]?.isActive == true.

6. Các con của một coroutine

Khi một coroutine được khởi chạy trong CoroutineScope của một coroutine khác,nó thừa kế ngữ cảnh của nó thông qua CoroutineScope.coroutineContext và Job của coroutine mới trở thành con của job của coroutine cha. Khi coroutine cha bị hủy, tất cả các con của nó cũng bị hủy theo đệ quy.


Tuy nhiên, mối quan hệ cha-con này có thể được ghi đè một cách rõ ràng theo một trong hai cách:


1. Khi một phạm vi khác được chỉ định một cách rõ ràng khi khởi chạy một coroutine (ví dụ, GlobalScope.launch),thì nó không thừa kế một Job từ phạm vi cha.

2. Khi một đối tượng Job khác được chuyển vào làm ngữ cảnh cho coroutine mới (như được hiển thị trong ví dụ dưới đây),thì nó ghi đè Job của phạm vi cha.


Ở cả hai trường hợp, coroutine được khởi chạy không liên kết với phạm vi mà nó được khởi chạy và hoạt động độc lập.

// launch a coroutine to process some kind of incoming request
val request = launch {
    // it spawns two other jobs
    launch(Job()) { 
        println("job1: I run in my own Job and execute independently!")
        delay(1000)
        println("job1: I am not affected by cancellation of the request")
    }
    // and the other inherits the parent context
    launch {
        delay(100)
        println("job2: I am a child of the request coroutine")
        delay(1000)
        println("job2: I will not execute this line if my parent request is cancelled")
    }
}
delay(500)
request.cancel() // cancel processing of the request
println("main: Who has survived request cancellation?")
delay(1000) // delay the main thread for a second to see what happens

Bạn có thể lấy mã nguồn đầy đủ tại đây.


Kết quả của đoạn mã này là:

job1: I run in my own Job and execute independently!
job2: I am a child of the request coroutine
main: Who has survived request cancellation?
job1: I am not affected by cancellation of the request

7. Trách nhiệm của coroutine cha

Một coroutine cha luôn đợi hoàn thành tất cả các con của mình. Một cha không cần phải theo dõi một cách rõ ràng tất cả các con mà nó khởi chạy, và nó không cần phải sử dụng Job.join để đợi chúng ở cuối:

// launch a coroutine to process some kind of incoming request
val request = launch {
    repeat(3) { i -> // launch a few children jobs
        launch  {
            delay((i + 1) * 200L) // variable delay 200ms, 400ms, 600ms
            println("Coroutine $i is done")
        }
    }
    println("request: I'm done and I don't explicitly join my children that are still active")
}
request.join() // wait for completion of the request, including all its children
println("Now processing of the request is complete")

Bạn có thể lấy mã nguồn đầy đủ tại đây.


Kết quả sẽ là:

request: I'm done and I don't explicitly join my children that are still active
Coroutine 0 is done
Coroutine 1 is done
Coroutine 2 is done
Now processing of the request is complete

8. Đặt tên cho coroutines để gỡ lỗi

Các ID được gán tự động là tốt khi coroutines thường xuyên ghi log và bạn chỉ cần tương quan các bản ghi logđến từ cùng một coroutine. Tuy nhiên, khi một coroutine liên quan đến xử lý của một yêu cầu cụ thểhoặc thực hiện một nhiệm vụ nền cụ thể nào đó, thì tốt hơn là đặt tên nó một cách rõ ràng cho mục đích gỡ lỗi.CoroutineName phần tử ngữ cảnh có cùng mục đích với tên luồng. Nó được bao gồm trong tên luồng đang thực hiện coroutine khi chế độ gỡ lỗi được bật.


Ví dụ dưới đây thể hiện khái niệm này:

log("Started main coroutine")
// run two background value computations
val v1 = async(CoroutineName("v1coroutine")) {
    delay(500)
    log("Computing v1")
    252
}
val v2 = async(CoroutineName("v2coroutine")) {
    delay(1000)
    log("Computing v2")
    6
}
log("The answer for v1 / v2 = ${v1.await() / v2.await()}")

Bạn có thể lấy mã nguồn đầy đủ tại đây.


Đầu ra mà nó tạo ra với tùy chọn JVM -Dkotlinx.coroutines.debug tương tự như:

[main @main#1] Started main coroutine
[main @v1coroutine#2] Computing v1
[main @v2coroutine#3] Computing v2
[main @main#1] The answer for v1 / v2 = 42

9. Kết hợp các phần tử ngữ cảnh

Đôi khi chúng ta cần xác định nhiều phần tử cho một ngữ cảnh coroutine. Chúng ta có thể sử dụng toán tử + cho điều đó.Ví dụ, chúng ta có thể khởi chạy một coroutine với dispatcher được chỉ định một cách rõ ràng và một tên được chỉ định một cách rõ ràng cùng một lúc:

launch(Dispatchers.Default + CoroutineName("test")) {
    println("I'm working in thread ${Thread.currentThread().name}")
}

Bạn có thể lấy mã nguồn đầy đủ tại đây.

Đầu ra của đoạn mã này với tùy chọn JVM -Dkotlinx.coroutines.debug là:
textI'm working in thread DefaultDispatcher-worker-1 @test#2

10. Phạm vi coroutine

Hãy kết hợp kiến thức của chúng ta về ngữ cảnh, các coroutine con và jobs lại với nhau. Giả sử ứng dụng của chúng tachứa một đối tượng có vòng đời, nhưng đối tượng đó không phải là một coroutine. Ví dụ, chúng ta đang viết ứng dụng Androidvà khởi chạy nhiều coroutine trong ngữ cảnh của một hoạt động Android để thực hiện các hoạt động không đồng bộ đểlấy và cập nhật dữ liệu, thực hiện các hiệu ứng hoạt hình, v.v. Tất cả những coroutine này phải bị hủy khi hoạt động bị hủybỏ để tránh rò rỉ bộ nhớ. Chúng ta, tất nhiên, có thể điều khiển ngữ cảnh và jobs thủ công để liên kết vòng đời của hoạt độngvà các coroutine của nó, nhưng kotlinx.coroutines cung cấp một trừu tượng đóng gói điều đó: [CoroutineScope]. Bạn đã nên quen với phạm vi coroutine vì tất cả các coroutine builders được khai báo như là các phần mở rộng trên nó.


Chúng ta quản lý vòng đời của các coroutine bằng cách tạo một phiên bản của [CoroutineScope] liên kết với vòng đời của hoạt động của chúng ta. Một CoroutineScope có thể được tạo ra bằng cách sử dụng các hàm thủ công [CoroutineScope()] hoặc [MainScope()]. Hàm trước tạo ra một phạm vi chung, trong khi hàm sau tạo ra một phạm vi cho ứng dụng UI và sử dụng [Dispatchers.Main] làm dispatcher mặc định:

class Activity {
    private val mainScope = MainScope()

    fun destroy() {
        mainScope.cancel()
    }
    // to be continued ...


Bây giờ, chúng ta có thể khởi chạy coroutine trong phạm vi của hoạt động này bằng cách sử dụng phạm vi đã được xác định. Cho ví dụ, chúng ta khởi chạy mười coroutine mà mỗi cái đều trì hoãn một thời gian khác nhau:

// class Activity continues
    fun doSomething() {
        // launch ten coroutines for a demo, each working for a different time
        repeat(10) { i ->
            mainScope.launch {
                delay((i + 1) * 200L) // variable delay 200ms, 400ms, ... etc
                println("Coroutine $i is done")
            }
        }
    }
} // class Activity ends


Trong hàm chính của chúng ta, chúng ta tạo hoạt động, gọi hàm kiểm thử doSomething, và hủy hoạt động sau 500ms. Điều này hủy bỏ tất cả các coroutine đã được khởi chạy từ doSomething. Chúng ta có thể thấy điều đó bởi vì sau khi hoạt động bị hủy, không còn thông báo nào được in ra nữa, ngay cả nếu chúng ta đợi một thời gian lâu hơn.

val activity = Activity()
activity.doSomething() // run test function
println("Launched coroutines")
delay(500L) // delay for half a second
println("Destroying activity!")
activity.destroy() // cancels all coroutines
delay(1000) // visually confirm that they don't work

Bạn có thể lấy mã nguồn đầy đủ tại đây.


Đầu ra của ví dụ này là:

Launched coroutines
Coroutine 0 is done
Coroutine 1 is done
Destroying activity!

Như bạn có thể thấy, chỉ có hai coroutine đầu tiên in ra một thông báo và những coroutine khác đều bị hủy bởi một lời gọi duy nhất của job.cancel() trong Activity.destroy().

Lưu ý rằng Android có hỗ trợ chính thức cho phạm vi coroutine trong tất cả các thực thể có vòng đời. Xem tài liệu tương ứng.

10.1 Dữ liệu cục bộ cho luồng

Đôi khi việc có khả năng truyền một số dữ liệu cục bộ cho hoặc giữa các coroutine là thuận tiện. Tuy nhiên, vì chúng không được ràng buộc với bất kỳ luồng cụ thể nào, điều này thường dẫn đến mã lặp nếu thực hiện thủ công.


Cho ThreadLocal, hàm mở rộng asContextElement ở đây để giúp đỡ. Nó tạo ra một phần tử ngữ cảnh bổ sung giữ giá trị của ThreadLocal đã cho và khôi phục nó mỗi khi coroutine chuyển đổi ngữ cảnh của mình.

Dễ dàng để thấy nó hoạt động trong thực tế:

threadLocal.set("main")
println("Pre-main, current thread: ${Thread.currentThread()}, thread local value: '${threadLocal.get()}'")
val job = launch(Dispatchers.Default + threadLocal.asContextElement(value = "launch")) {
    println("Launch start, current thread: ${Thread.currentThread()}, thread local value: '${threadLocal.get()}'")
    yield()
    println("After yield, current thread: ${Thread.currentThread()}, thread local value: '${threadLocal.get()}'")
}
job.join()
println("Post-main, current thread: ${Thread.currentThread()}, thread local value: '${threadLocal.get()}'")

Bạn có thể lấy mã nguồn đầy đủ tại đây.


Trong ví dụ này, chúng ta khởi chạy một coroutine mới trong một pool luồng nền bằng cách sử dụng [Dispatchers.Default],nó sẽ chạy trên một luồng khác từ pool luồng, nhưng nó vẫn giữ giá trị của biến cục bộ luồng mà chúng ta chỉ định bằng cách sử dụng threadLocal.asContextElement(value = "launch"),bất kể luồng nào coroutine được thực hiện. Do đó, đầu ra (với debug) là:

Pre-main, current thread: Thread[main @coroutine#1,5,main], thread local value: 'main'
Launch start, current thread: Thread[DefaultDispatcher-worker-1 @coroutine#2,5,main], thread local value: 'launch'
After yield, current thread: Thread[DefaultDispatcher-worker-2 @coroutine#2,5,main], thread local value: 'launch'
Post-main, current thread: Thread[main @coroutine#1,5,main], thread local value: 'main'

Dễ quên đặt phần tử ngữ cảnh tương ứng. Biến cục bộ cho luồng được truy cập từ coroutine có thể có giá trị không mong muốn, nếu luồng chạy coroutine khác nhau. Để tránh những tình huống như vậy, khuyến nghị sử dụng phương thức [ensurePresent]và thất bại nhanh chóng trong các trường hợp sử dụng không đúng.


ThreadLocal có hỗ trợ cấp độ ưu tiên cao và có thể sử dụng với bất kỳ nguyên tắc kotlinx.coroutines nào. Tuy nhiên, nó có một giới hạn quan trọng: khi một thread-local được thay đổi, giá trị mới không được truyền cho người gọi coroutine (do một phần tử context không thể theo dõi tất cả các truy cập đối tượng ThreadLocal), và giá trị cập nhật bị mất khi tạm dừng tiếp theo. Sử dụng withContext để cập nhật giá trị của thread-local trong một coroutine, xem asContextElement để biết thêm chi tiết.


Hoặc, một giá trị có thể được lưu trữ trong một hộp có thể thay đổi như class Counter(var i: Int), được lưu trữ trong một biến thread-local. Tuy nhiên, trong trường hợp này, bạn hoàn toàn chịu trách nhiệm đồng bộ hóa các sửa đổi có thể xảy ra đồng thời đối với biến trong hộp có thể thay đổi này.

Đối với việc sử dụng nâng cao, ví dụ như tích hợp với MDC logging, bối cảnh giao dịch hoặc bất kỳ thư viện nào khác sử dụng nội tại thread-locals để truyền dữ liệu, hãy xem tài liệu của interface ThreadContextElement để được triển khai.

Cảm ơn bạn đã dành thời gian cùng Cafedev khám phá sâu hơn về Kotlin và Coroutine context cùng dispatchers. Chúng ta đã có những hành trình thú vị và học hỏi từ sức mạnh của ngôn ngữ lập trình này. Cafedev cam kết tiếp tục đồng hành và chia sẻ kiến thức đa dạng để đưa cộng đồng lập trình đến những trải nghiệm mới. Hãy tiếp tục đồng hành cùng Cafedev để không chỉ nắm bắt xu hướng mới mà còn trải nghiệm sự đổi mới và sáng tạo trong thế giới của Kotlin!”

Các nguồn kiến thức MIỄN PHÍ VÔ GIÁ từ cafedev tại đây

Nếu bạn thấy hay và hữu ích, bạn có thể tham gia các kênh sau của Cafedev để nhận được nhiều hơn nữa:

Chào thân ái và quyết thắng!

Đăng ký kênh youtube để ủng hộ Cafedev nha các bạn, Thanks you!

BÌNH LUẬN

Vui lòng nhập bình luận của bạn
Vui lòng nhập tên của bạn ở đây