Kotlin Coroutines กับ RxJava สำหรับงานเครือข่ายและงานพื้นหลัง
Kotlin Coroutines กับ RxJava: เปรียบเทียบการยกเลิก การจัดการข้อผิดพลาด และรูปแบบการทดสอบสำหรับงานเครือข่ายและงานพื้นหลังในแอป Android จริง

ทำไมการเลือกนี้จึงสำคัญสำหรับการใช้งานเครือข่ายใน production
การทำงานเครือข่ายและงานพื้นหลังในแอป Android ของจริงมีมากกว่าแค่การเรียก API ครั้งเดียว มันรวมถึงการล็อกอินและการรีเฟรชโทเค็น หน้าจอที่อาจหมุนระหว่างการโหลด การซิงก์หลังผู้ใช้ทิ้งหน้าจอ การอัปโหลดรูป และงานที่รันเป็นรอบเวลาโดยไม่ให้เปลืองแบตเตอรี่มาก
บั๊กที่สร้างปัญหามากที่สุดมักไม่ใช่ปัญหาด้านไวยากรณ์ แต่ปรากฏเมื่องานอะซิงค์มีอายุการใช้งานยาวกว่าหน้า UI (leak), เมื่อการยกเลิกหยุด UI แต่ไม่หยุดคำขอจริง (ส่งทราฟฟิกเปล่าและ spinner ค้าง), เมื่อการ retry ทำให้คำขอเพิ่มขึ้น (โดนจำกัดอัตราหรือแบน), หรือเมื่อเลเยอร์ต่าง ๆ จัดการข้อผิดพลาดไม่เหมือนกันจนไม่มีใครทำนายได้ว่า ผู้ใช้จะเห็นอะไร
การตัดสินใจระหว่าง Kotlin Coroutines กับ RxJava มีผลต่อความน่าเชื่อถือในชีวิตประจำวัน:
- วิธีที่คุณจำลองงาน (one-shot vs streams)
- การแพร่ propagation ของการยกเลิก
- วิธีการนำข้อผิดพลาดมานำเสนอให้ UI
- การควบคุมเธรดสำหรับเครือข่าย ดิสก์ และ UI
- ความทดสอบได้ของการจัดการเวลา retry และกรณีมุม
รูปแบบด้านล่างเน้นสิ่งที่มักพังเมื่อต้องรับภาระหนักหรือเครือข่ายช้า: การยกเลิก, การจัดการข้อผิดพลาด, retry และ timeout, และนิสัยการทดสอบที่ป้องกัน regression ตัวอย่างสั้นและใช้งานได้จริง
แบบคิดหลัก: การเรียก suspend, สตรีม และ Flow
ความแตกต่างหลักระหว่าง Kotlin Coroutines กับ RxJava คือรูปร่างของงานที่คุณกำลังจำลอง
ฟังก์ชัน suspend แทนงานแบบหนึ่งครั้ง มันคืนค่าเดียวหรือโยนความล้มเหลวเพียงครั้งเดียว ซึ่งตรงกับการเรียกเครือข่ายส่วนใหญ่: ดึงโปรไฟล์, อัปเดตการตั้งค่า, อัปโหลดรูป โค้ดที่เรียกอ่านจากบนลงล่างจึงยังอ่านง่ายแม้คุณจะเพิ่ม logging, caching, และ branching
RxJava เริ่มด้วยการถามว่าคุณกำลังจัดการกับค่าเดียวหรือค่าหลายค่าตามเวลา Single คือผลลัพธ์แบบหนึ่งครั้ง (สำเร็จหรือผิดพลาด) ในขณะที่ Observable (หรือ Flowable) เป็นสตรีมที่สามารถส่งค่าหลายค่าแล้วจบหรือล้มเหลว เหมาะกับฟีเจอร์ที่เป็นเหตุการณ์จริง ๆ เช่น การเปลี่ยนแปลงข้อความ, ข้อความ websocket, หรือการ polling
Flow คือวิธีที่เป็นมิตรกับ coroutines เพื่อแทนสตรีม คิดว่าเป็นเวอร์ชันสตรีมของ coroutines ที่มีการยกเลิกแบบมีโครงสร้างและเข้ากับ API แบบ suspend ได้โดยตรง
กฎง่าย ๆ:
- ใช้
suspendสำหรับการร้องขอหนึ่งครั้งและตอบสนองหนึ่งครั้ง - ใช้
Flowสำหรับค่าที่เปลี่ยนตามเวลา - ใช้ RxJava เมื่อแอปของคุณพึ่งพาโอเปอเรเตอร์จำนวนมากและการประกอบสตรีมที่ซับซ้อน
เมื่อฟีเจอร์เติบโต ความอ่านง่ายมักพังก่อนเมื่อคุณบังคับแบบสตรีมกับการเรียกแบบหนึ่งครั้ง หรือพยายามปฏิบัติกับเหตุการณ์ต่อเนื่องเหมือนเป็นค่าคืนเดียว จงจับคู่ abstraction กับความเป็นจริงก่อน แล้วค่อยสร้างคอนเวนชันรอบ ๆ มัน
การยกเลิกในการปฏิบัติ (พร้อมตัวอย่างโค้ดสั้น ๆ)
การยกเลิกคือจุดที่โค้ดอะซิงค์จะปลอดภัยหรือกลายเป็นการพังแบบสุ่ม จุดมุ่งหมายชัดเจน: เมื่อผู้ใช้ทิ้งหน้าจอ งานที่เริ่มสำหรับหน้านั้นควรหยุดด้วย
กับ Kotlin Coroutines การยกเลิกถูกรวมอยู่ในโมเดล Job แทนงาน และด้วย structured concurrency คุณมักจะไม่ส่ง Job ข้ามไปมา คุณเริ่มงานภายใน scope (เช่น viewModelScope) เมื่อสโคปนั้นถูกยกเลิก ทุกอย่างภายในจะถูกยกเลิกด้วย
class ProfileViewModel(
private val api: Api
) : ViewModel() {
fun loadProfile() = viewModelScope.launch {
// If the ViewModel is cleared, this coroutine is cancelled,
// and so is the in-flight network call (if the client supports it).
val profile = api.getProfile() // suspend
// update UI state here
}
}
สองรายละเอียดเชิง production ที่สำคัญ:
- เรียกเครือข่ายผ่าน client ที่ยกเลิกได้ มิฉะนั้นคอร์รูทีนอาจหยุด แต่การเรียก HTTP อาจยังทำงานต่อ
- ใช้
withTimeout(หรือwithTimeoutOrNull) สำหรับคำขอที่ต้องไม่แขวน
RxJava ใช้การทิ้งอย่างชัดเจน คุณเก็บ Disposable สำหรับแต่ละ subscription หรือตั้งรวมใน CompositeDisposable เมื่อหน้าจอหายไปคุณ dispose แล้ว chain ควรหยุด
class ProfilePresenter(private val api: ApiRx) {
private val bag = CompositeDisposable()
fun attach() {
bag += api.getProfile()
.subscribe(
{ profile -\u003e /* render */ },
{ error -\u003e /* show error */ }
)
}
fun detach() {
bag.clear() // cancels in-flight work if upstream supports cancellation
}
}
กฎปฏิบัติเมื่อออกจากหน้าจอ: ถ้าคุณชี้ไม่ได้ว่าการยกเลิกเกิดขึ้นที่ไหน (การยกเลิกสโคปหรือ dispose()), ให้ถือว่างานจะยังทำงานต่อและแก้ไขก่อนปล่อย
การจัดการข้อผิดพลาดให้อ่านได้เข้าใจ
ความแตกต่างสำคัญระหว่าง Kotlin Coroutines กับ RxJava คือวิธีที่ข้อผิดพลาดถูกส่งผ่าน Coroutines ทำให้ความล้มเหลวดูเหมือนโค้ดปกติ: การเรียก suspend จะโยน และผู้เรียกตัดสินใจว่าจะทำอย่างไร Rx จะผลักความล้มเหลวผ่านสตรีม ซึ่งทรงพลัง แต่ก็ง่ายที่ปัญหาจะถูกซ่อนถ้าคุณไม่ระวัง
ใช้ข้อยกเว้นสำหรับข้อผิดพลาดที่ไม่คาดคิด (timeout, 500s, ปัญหา parsing) โมเดลข้อผิดพลาดเป็นข้อมูลเมื่อ UI ต้องการการตอบสนองเฉพาะ (รหัสผ่านผิด, “อีเมลถูกใช้งานแล้ว”) และคุณอยากให้เป็นส่วนหนึ่งของโดเมนโมเดล
รูปแบบ coroutine ง่าย ๆ จะเก็บ stack trace และอ่านได้:
suspend fun loadProfile(): Profile = try {
api.getProfile() // may throw
} catch (e: IOException) {
throw NetworkException("No connection", e)
}
runCatching และ Result มีประโยชน์เมื่อคุณต้องการคืนความสำเร็จหรือความล้มเหลวโดยไม่โยนจริง ๆ:
suspend fun loadProfileResult(): Result<Profile> =
runCatching { api.getProfile() }
ระวัง getOrNull() หากคุณไม่ได้จัดการความล้มเหลวด้วย มันอาจเปลี่ยนบั๊กจริงเป็นหน้าจอ “สถานะว่าง” โดยเงียบ ๆ
ใน RxJava ให้เส้นทางข้อผิดพลาดชัดเจน ใช้ onErrorReturn เฉพาะเมื่อเป็น fallback ที่ปลอดภัย ชอบ onErrorResumeNext เมื่อต้องเปลี่ยนแหล่งข้อมูล (เช่นไปหาข้อมูลจากแคช) สำหรับ retry ให้จำกัดกฎด้วย retryWhen เพื่อไม่ให้ retry ในกรณีเช่น “รหัสผ่านผิด”
ชุดนิสัยที่ป้องกันการกลืนข้อผิดพลาด:
- บันทึกหรือรายงานข้อผิดพลาดครั้งเดียว ใกล้กับบริบทที่มี
- รักษาข้อยกเว้นต้นทางเป็น
causeเมื่อห่อใหม่ - หลีกเลี่ยง fallback แบบจับทุกกรณีที่เปลี่ยนทุกข้อผิดพลาดเป็นค่าเริ่มต้น
- ทำให้ข้อผิดพลาดที่แสดงต่อผู้ใช้เป็นโมเดลที่มีชนิด ไม่ใช่สตริง
พื้นฐานการจัดเธรด: Dispatchers vs Schedulers
บั๊กอะซิงค์หลายรายการมาจากการจัดเธรด: ทำงานหนักบน main thread หรือติดต่อ UI จากเธรดพื้นหลัง Kotlin Coroutines กับ RxJava ต่างกันหลัก ๆ ในการบอกให้สวิตช์เธรด
กับ coroutines คุณมักเริ่มบนเธรดหลักสำหรับงาน UI แล้วกระโดดไปยัง dispatcher พื้นหลังสำหรับงานหนัก ตัวเลือกปกติคือ:
Dispatchers.Mainสำหรับการอัปเดต UIDispatchers.IOสำหรับ I/O ที่บล็อกเช่นเครือข่ายและดิสก์Dispatchers.Defaultสำหรับงาน CPU เช่นการแยก JSON, การเรียง, การเข้ารหัส
รูปแบบง่าย ๆ: ดึงข้อมูล แยกการประมวลผลออกจาก main แล้วเรนเดอร์
viewModelScope.launch(Dispatchers.Main) {
val json = withContext(Dispatchers.IO) { api.fetchProfileJson() }
val profile = withContext(Dispatchers.Default) { parseProfile(json) }
_uiState.value = UiState.Content(profile)
}
RxJava แสดงว่า “งานเกิดที่ไหน” ด้วย subscribeOn และ “ผลถูกรับที่ไหน” ด้วย observeOn ความประหลาดใจที่พบบ่อยคือคาดว่า observeOn จะมีผลกับ upstream แต่มันไม่ทำ subscribeOn กำหนดเธรดสำหรับแหล่งข้อมูลและโอเปอเรเตอร์ด้านบน และแต่ละ observeOn จะสลับเธรดจากจุดนั้นไป
api.fetchProfileJson()
.subscribeOn(Schedulers.io())
.map { json -\u003e parseProfile(json) } // still on io unless you change it
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ profile -\u003e render(profile) },
{ error -\u003e showError(error) }
)
กฎที่หลีกเลี่ยงความประหลาดใจ: เก็บงาน UI ไว้ในที่เดียว ใน coroutines กำหนดหรือเก็บสถานะ UI บน Dispatchers.Main ใน RxJava ใส่ observeOn(main) จุดเดียวสุดท้ายก่อนเรนเดอร์ และอย่าแยกระดับ observeOn หลายจุดถ้าไม่จำเป็น
ถ้าหน้าจอกระตุก ให้ย้ายการ parse และ mapping ออกจาก main thread ก่อน การเปลี่ยนแปลงครั้งเดียวนี้แก้ปัญหาจริง ๆ ได้เยอะ
Retry, timeout, และงานคู่ขนานสำหรับการเรียกเครือข่าย
เส้นทางที่ราบรื่นไม่ใช่ปัญหาใหญ่ ปัญหามาจากคำขอที่แขวน, retry ที่ทำให้แย่ลง, หรืองาน “ขนาน” ที่จริง ๆ แล้วไม่ได้ขนาน รูปแบบเหล่านี้มักตัดสินว่าทีมชอบ Kotlin Coroutines หรือ RxJava
Timeouts ที่ล้มเร็ว
กับ coroutines คุณสามารถใส่ขอบเขตเวลาเข้มงวดรอบ suspend call เก็บ timeout ใกล้จุดเรียกเพื่อให้แสดงข้อความ UI ที่ถูกต้องได้ง่าย
val user = withTimeout(5_000) {
api.getUser() // suspend
}
ใน RxJava คุณแนบออปเอตอร์ timeout กับสตรีม ซึ่งมีประโยชน์เมื่อพฤติกรรม timeout ควรเป็นส่วนหนึ่งของพายป์ไลนที่แชร์กัน
Retry โดยไม่ก่อความเสียหาย
retry เฉพาะเมื่อปลอดภัย กฎง่าย ๆ: retry กับคำขอที่เป็น idempotent (เช่น GET) ได้มากกว่า คำขอที่สร้างผลข้างเคียง (เช่น สร้างคำสั่งซื้อ) ให้จำกัดและเพิ่มดีเลย์หรือ jitter
เกราะป้องกันเริ่มต้นที่ดี:
- retry ใน timeout เครือข่ายและข้อผิดพลาดชั่วคราวของเซิร์ฟเวอร์
- อย่า retry ในข้อผิดพลาดการตรวจสอบความถูกต้อง (400s) หรือข้อผิดพลาด auth
- จำกัดจำนวน retry (บ่อยครั้ง 2–3) และบันทึกความล้มเหลวสุดท้าย
- ใช้ backoff delays เพื่อไม่ให้ทุบเซิร์ฟเวอร์
ใน RxJava, retryWhen ให้คุณระบุว่า “retry เฉพาะเมื่อข้อผิดพลาดชนิดนี้ และด้วยดีเลย์แบบนี้” ใน coroutines, Flow มี retry และ retryWhen ในขณะที่ฟังก์ชัน suspend ปกติมักใช้ลูปเล็ก ๆ พร้อม delay
เรียกพร้อมกันโดยไม่ทำให้โค้ดยุ่ง
Coroutines ทำงานขนานได้ตรงไปตรงมา: เริ่มคำขอสองตัว รอทั้งสอง
coroutineScope {
val profile = async { api.getProfile() }
val feed = async { api.getFeed() }
profile.await() to feed.await()
}
RxJava โดดเด่นเมื่อการรวมหลายแหล่งเป็นใจความหลักของ chain zip คือเครื่องมือทั่วไปสำหรับรอทั้งคู่ และ merge มีประโยชน์เมื่อคุณต้องการผลลัพธ์ทันทีที่มาถึง
สำหรับสตรีมใหญ่หรือเร็ว การ backpressure ยังสำคัญ Flowable ของ RxJava มีเครื่องมือ backpressure ที่ครบครัน Flow ของ coroutines จัดการหลายกรณีได้ดี แต่คุณอาจยังต้องการบัฟเฟอร์หรือนโยบาย drop หากเหตุการณ์ก้าวหน้ากว่า UI หรือการเขียนฐานข้อมูล
การทำงานร่วมกันและแนวทางการย้าย (โค้ดเบสผสม)
ทีมส่วนใหญ่ไม่ได้เปลี่ยนทันทีทีเดียว การย้ายแบบปฏิบัติจะคงความเสถียรของแอปในขณะที่ย้ายทีละโมดูล
ห่อ API ของ Rx ให้เป็นฟังก์ชัน suspend
ถ้าคุณมี Single<T> หรือ Completable อยู่แล้ว ให้ห่อด้วยการรองรับการยกเลิกเพื่อให้คอร์รูทีนที่ถูกยกเลิก dispose การสมัคร Rx ด้วย
suspend fun <T : Any> Single<T>.awaitCancellable(): T =
suspendCancellableCoroutine { cont -\u003e
val d = subscribe(
{ value -\u003e cont.resume(value) {} },
{ error -\u003e cont.resumeWithException(error) }
)
cont.invokeOnCancellation { d.dispose() }
}
นี่ช่วยหลีกเลี่ยงความผิดพลาดทั่วไป: ผู้ใช้ทิ้งหน้าจอ คอร์รูทีนถูกยกเลิก แต่การเรียกเครือข่ายยังทำงานต่อและอัปเดตสถานะที่แชร์ทีหลัง
เปิดเผยโค้ด coroutine ให้ผู้เรียก Rx
ระหว่างการย้าย บางเลเยอร์ยังคงคาดหวังประเภท Rx ห่อ suspend work ใน Single.fromCallable และบล็อกเฉพาะบนเธรดพื้นหลัง
fun loadProfileRx(api: Api): Single<Profile> =
Single.fromCallable {
runBlocking { api.loadProfile() } // ensure subscribeOn(Schedulers.io())
}
ทำให้ขอบเขตนี้เล็กและมีเอกสาร สำหรับโค้ดใหม่ ให้เรียก suspend API โดยตรงจาก coroutine scope
ที่ที่ Flow เหมาะ และที่ไม่เหมาะ
Flow สามารถแทนที่หลายกรณีของ Observable: สถานะ UI, การอัปเดตฐานข้อมูล, และสตรีมแบบ paging มันอาจไม่ตรงเมื่อคุณพึ่งพา hot streams, subjects, การ tuning backpressure ขั้นสูง, หรือชุดโอเปอเรเตอร์ที่ทีมคุ้นเคยแล้ว
กลยุทธ์การย้ายที่ลดความสับสน:
- แปลงโมดูลใบไม้ก่อน (network, storage) เป็น API แบบ
suspend - เพิ่มอะแดปเตอร์ขนาดเล็กที่ขอบโมดูล (Rx -> suspend, suspend -> Rx)
- แทน Rx streams ด้วย Flow เมื่อคุณควบคุมผู้บริโภคด้วย
- รักษาสไตล์อะซิงค์หนึ่งแบบต่อพื้นที่ฟีเจอร์
- ลบอะแดปเตอร์เมื่อผู้เรียกสุดท้ายย้ายเสร็จ
รูปแบบการทดสอบที่ใช้งานได้จริง
ปัญหาเรื่องเวลาและการยกเลิกคือที่ซ่อนบั๊กอะซิงค์ การทดสอบอะซิงค์ที่ดีทำให้เวลาเป็นไปตามที่คาดและผลลัพธ์ตรวจสอบง่าย นี่คืออีกพื้นที่ที่ Kotlin Coroutines กับ RxJava ให้ความรู้สึกต่างกัน แม้ว่าทั้งคู่จะทดสอบได้ดี
Coroutines: runTest, TestDispatcher และการควบคุมเวลา
สำหรับโค้ด coroutine ให้ใช้ runTest กับ test dispatcher เพื่อให้การทดสอบไม่พึ่งพาเธรดจริงหรือการหน่วงจริง เวลาเสมือนช่วยให้คุณทริกเกอร์ timeout, retry, และ debounce โดยไม่ต้อง sleep
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `emits Loading then Success`() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val repo = Repo(api = fakeApi, io = dispatcher)
val states = mutableListOf<UiState>()
val job = launch(dispatcher) { repo.loadProfile().toList(states) }
testScheduler.runCurrent() // run queued work
assert(states.first() is UiState.Loading)
testScheduler.advanceTimeBy(1_000) // trigger delay/retry windows
testScheduler.runCurrent()
assert(states.last() is UiState.Success)
job.cancel()
}
เพื่อทดสอบการยกเลิก ให้ยกเลิก Job ที่เก็บรวบรวม (หรือสโคปพาเรนต์) และตรวจว่าฟีค API ปลอมหยุดหรือไม่มีสถานะเพิ่มเติมถูกส่งออก
RxJava: TestScheduler, TestObserver, เวลาแบบกำหนดได้
การทดสอบ Rx มักรวม TestScheduler สำหรับเวลาและ TestObserver สำหรับการยืนยัน
@Test
fun `disposes on cancel and stops emissions`() {
val scheduler = TestScheduler()
val observer = TestObserver<UiState>()
val d = repo.loadProfileRx(scheduler)
.subscribeWith(observer)
scheduler.triggerActions()
observer.assertValueAt(0) { it is UiState.Loading }
d.dispose()
scheduler.advanceTimeBy(1, TimeUnit.SECONDS)
observer.assertValueCount(1) // no more events after dispose
}
เมื่อทดสอบเส้นทางข้อผิดพลาดในทั้งสองสไตล์ ให้มุ่งที่การแม็ปมากกว่าประเภทข้อยกเว้น ตรวจสอบสถานะ UI ที่คุณคาดหวังหลังจาก 401, timeout, หรือการตอบสนองที่ผิดรูป
ชุดการตรวจสอบเล็ก ๆ ครอบคลุม regression ส่วนใหญ่:
- สถานะ Loading และสถานะสุดท้าย (Success, Empty, Error)
- การทำความสะอาดการยกเลิก (job ถูกยกเลิก, disposable ถูก dispose)
- การแม็ปข้อผิดพลาด (โค้ด HTTP เป็นข้อความสำหรับผู้ใช้)
- ไม่มีการส่งซ้ำหลัง retry
- ลอจิกตามเวลาโดยใช้เวลาเสมือน ไม่ใช่ดีเลย์จริง
ความผิดพลาดทั่วไปที่ทำให้เกิดบั๊กใน production
ปัญหาใน production ส่วนใหญ่ไม่ได้มาจากการเลือก Kotlin Coroutines vs RxJava แต่เกิดจากนิสัยบางอย่างที่ทำให้งานทำงานนานกว่าที่คิด ทำงานซ้ำ หรือติดต่อ UI ในเวลาที่ไม่เหมาะสม
การรั่วไหลทั่วไปคือการเริ่มงานในสโคปผิด ถ้าคุณเริ่มคำขอเครือข่ายจากสโคปที่อายุยืนกว่าหน้าจอ (หรือสร้างสโคปเองแล้วไม่เคยยกเลิก) คำขออาจเสร็จหลังผู้ใช้ทิ้งหน้าและพยายามอัปเดตสถานะ ใน coroutines มันมักเป็นการใช้สโคปที่ยาวนานโดยดีฟอลต์ ใน RxJava มักเป็นการลืม dispose()
อีกอย่างคือ “fire and forget” สโคประดับกล็อบัลและ Disposable ที่ลืมมักดูเหมือนปกติจนงานสะสม หน้าจอแชทที่รีเฟรชทุกครั้งที่ resume อาจมีการรีเฟรชหลายงานหลังการนำทางซ้ำ ๆ แต่ละงานถือหน่วยความจำและแข่งเครือข่าย
Retry ก็ผิดได้ง่าย Retry ไม่จำกัด หรือ retry ไม่มีดีเลย์ สามารถสแปมแบ็กเอนด์และเปลืองแบตเตอรี โดยเฉพาะเมื่อข้อผิดพลาดเป็นถาวร เช่น 401 หลัง logout ทำให้ retry มีเงื่อนไข เพิ่ม backoff และหยุดเมื่อข้อผิดพลาดไม่ฟื้น
การจัดเธรดผิดพลาดทำให้เกิด crash ที่ยากจะทำซ้ำ คุณอาจ parse JSON บน main thread หรืออัปเดต UI จากพื้นหลังขึ้นอยู่กับตำแหน่งการสลับ dispatcher หรือ scheduler
การตรวจสอบด่วนที่จับปัญหาเหล่านี้ได้:
- ผูกงานกับ lifecycle owner และยกเลิกเมื่อ owner จบ
- ทำให้การทำความสะอาดชัดเจน: ยกเลิก Jobs หรือ clear Disposables ที่จุดเดียว
- กำหนดขีดจำกัดเข้มงวดสำหรับ retry (จำนวน, ดีเลย์, และข้อผิดพลาดที่เข้าเงื่อนไข)
- บังคับกฎเดียวสำหรับการอัปเดต UI (main thread เท่านั้น) ในการตรวจสอบโค้ด
- ถือว่าการซิงก์พื้นหลังเป็นระบบที่มีข้อจำกัด ไม่ใช่ฟังก์ชันสุ่ม
ถ้าคุณปล่อยแอป Android จากโค้ด Kotlin ที่สร้างโดยเครื่องมือ (เช่น AppMaster) กับโค้ดที่สร้างโดยอัตโนมัติ หลักการเดียวกันยังใช้ได้ คุณยังต้องมีคอนเวนชันที่ชัดเจนสำหรับสโคป การยกเลิก ขีดจำกัด retry และกฎเธรด
เช็กลิสต์ด่วนสำหรับการเลือก Coroutines, RxJava หรือทั้งสอง
เริ่มจากรูปร่างของงาน การเรียกเครือข่ายส่วนใหญ่เป็นแบบ one-shot แต่แอปยังมีสัญญาณต่อเนื่อง เช่น สถานะเชื่อมต่อ, สถานะการยืนยันตัวตน, หรืออัปเดตสด การเลือก abstraction ผิดตั้งแต่แรกมักแสดงผลเป็นการยกเลิกที่ยุ่งและเส้นทางข้อผิดพลาดที่อ่านยาก
วิธีตัดสินใจง่าย ๆ (และอธิบายให้ทีมฟัง):
- คำขอครั้งเดียว (ล็อกอิน, ดึงโปรไฟล์): ให้
suspendเป็นค่าดีฟอลต์ - สตรีมต่อเนื่อง (อีเวนต์, อัปเดตฐานข้อมูล): ให้
Flowหรือ RxObservable - การยกเลิกตาม lifecycle: coroutines ใน
viewModelScopeหรือlifecycleScopeมักง่ายกว่าการจัดการ disposables เอง - พึ่งพาโอเปอเรเตอร์สตรีมขั้นสูงและ backpressure มาก: RxJava อาจเหมาะกว่า โดยเฉพาะในโค้ดเบสเก่า
- retry และการแม็ปข้อผิดพลาดซับซ้อน: เลือกวิธีที่ทีมอ่านได้ง่าย
กฎปฏิบัติ: ถ้าหนึ่งหน้าจอทำคำขอเดียวและเรนเดอร์ผลเดียว coroutines ทำให้โค้ดใกล้เคียงกับฟังก์ชันปกติ หากคุณสร้างพายป์ไลน์ของเหตุการณ์จำนวนมาก (พิมพ์ข้อความ, debounce, cancel คำขอก่อนหน้า, รวม filter) RxJava หรือ Flow มักรู้สึกเป็นธรรมชาติมากกว่า
ความสม่ำเสมอชนะความสมบูรณ์แบบ สองรูปแบบที่ดีใช้ทั่วทั้งทีมง่ายต่อการดูแลกว่าห้ารูปแบบ “ดีที่สุด” ที่ใช้ไม่สอดคล้องกัน
ตัวอย่างสถานการณ์: ล็อกอิน, ดึงโปรไฟล์, และซิงก์พื้นหลัง
ฟลว์ที่พบบ่อย: ผู้ใช้แตะล็อกอิน เรียก endpoint ยืนยันตัวตน แล้วดึงโปรไฟล์สำหรับหน้าหลัก สุดท้ายเริ่มซิงก์พื้นหลัง นี่คือจุดที่ Kotlin Coroutines กับ RxJava ให้ความรู้สึกต่างกันในการบำรุงรักษา
เวอร์ชัน Coroutines (เชิงลำดับ + ยกเลิกได้)
ด้วย coroutines รูปร่าง “ทำสิ่งนี้ แล้วทำสิ่งนั้น” เป็นธรรมชาติ หากผู้ใช้ปิดหน้าจอ การยกเลิกสโคปจะหยุดงานที่กำลังทำ
suspend fun loginAndLoadProfile(): Result<Profile> = runCatching {
val token = api.login(email, password) // suspend
val profile = api.profile("Bearer $token")
syncManager.startSyncInBackground(token) // fire-and-forget
profile
}.recoverCatching { e -\u003e
throw when (e) {
is HttpException -\u003e when (e.code()) {
401 -\u003e AuthExpiredException()
in 500..599 -\u003e ServerDownException()
else -\u003e e
}
is IOException -\u003e NoNetworkException()
else -\u003e e
}
}
// UI layer
val job = viewModelScope.launch { loginAndLoadProfile() }
override fun onCleared() { job.cancel() }
เวอร์ชัน RxJava (เชน + การทิ้ง)
ใน RxJava ฟลว์เดียวกันจะเป็น chain การยกเลิกหมายถึง dispose โดยทั่วไปกับ CompositeDisposable
val d = api.login(email, password)
.flatMap { token -\u003e api.profile("Bearer $token").map { it to token } }
.doOnSuccess { (_, token) -\u003e syncManager.startSyncInBackground(token) }
.onErrorResumeNext { e: Throwable -\u003e
Single.error(
when (e) {
is HttpException -\u003e if (e.code() == 401) AuthExpiredException() else e
is IOException -\u003e NoNetworkException()
else -\u003e e
}
)
}
.subscribe({ (profile, _) -\u003e show(profile) }, { showError(it) })
compositeDisposable.add(d)
override fun onCleared() { compositeDisposable.clear() }
ชุดทดสอบขั้นต่ำที่นี่ควรครอบคลุมสามผลลัพธ์: สำเร็จ, ข้อผิดพลาดที่ถูกแม็ป (401, 500s, no network), และการยกเลิก/การทิ้ง
ขั้นตอนต่อไป: กำหนดคอนเวนชันและรักษาความสม่ำเสมอ
ทีมมักมีปัญหาเพราะรูปแบบต่างกันระหว่างฟีเจอร์ ไม่ใช่เพราะ Kotlin Coroutines vs RxJava ผิด ตกลงสั้น ๆ (แม้หน้าเดียว) จะช่วยได้มากในการรีวิวและทำให้พฤติกรรมคาดเดาได้
เริ่มด้วยการแยกชัด: งานหนึ่งครั้ง (การเรียกเครือข่ายคืนค่าเดียว) กับสตรีม (อัปเดตตามเวลา เช่น websocket, ตำแหน่ง, หรือการเปลี่ยนฐานข้อมูล) ตัดสินค่าดีฟอลต์สำหรับแต่ละแบบ และกำหนดว่าเมื่อไหร่อนุญาตให้เบี่ยงเบน
จากนั้นเพิ่ม helper เล็ก ๆ ที่ทำให้ทุกฟีเจอร์พฤติกรรมเหมือนกันเมื่อเครือข่ายพัง:
- ที่เดียวสำหรับแม็ปข้อผิดพลาด (โค้ด HTTP, timeout, ออฟไลน์) เป็นความล้มเหลุระดับแอปที่ UI เข้าใจ
- ค่า timeout ดีฟอลต์สำหรับคำขอเครือข่าย และวิธีที่ชัดเจนในการ override สำหรับงานยาว
- นโยบาย retry ที่ระบุว่าอะไรปลอดภัยให้ retry (เช่น GET vs POST)
- กฎการยกเลิก: อะไรหยุดเมื่อผู้ใช้ทิ้งหน้าจอ และอะไรสามารถดำเนินต่อได้
- กฎการล็อกเพื่อช่วยซัพพอร์ตโดยไม่รั่วข้อมูลลับ
คอนเวนชันการทดสอบสำคัญเท่า ๆ กัน ตกลงแนวทางมาตรฐานเพื่อให้การทดสอบไม่พึ่งพาเวลา/เธรดจริง สำหรับ coroutines มักใช้ test dispatcher และ structured scopes สำหรับ RxJava มักใช้ test schedulers และการทิ้งที่ชัดเจน เป้าหมายคือการทดสอบเร็วและกำหนดผลได้โดยไม่ต้อง sleep
ถ้าคุณอยากไปเร็วขึ้นโดยรวม AppMaster (appmaster.io) เป็นทางเลือกสำหรับการสร้าง API แบ็กเอนด์และแอป Kotlin โดยไม่ต้องเขียนทุกอย่างตั้งแต่ต้น แม้กับโค้ดที่สร้างโดยเครื่องมือ หลักการ production เดียวกันเกี่ยวกับการยกเลิก ข้อผิดพลาด retry และการทดสอบยังคงเป็นสิ่งที่ทำให้พฤติกรรมเครือข่ายคาดการณ์ได้
คำถามที่พบบ่อย
ตั้งค่าเป็น suspend โดยดีฟอลต์สำหรับคำร้องขอทีละครั้งที่ส่งค่ากลับแค่ครั้งเดียว เช่น การล็อกอินหรือการดึงโปรไฟล์ ใช้ Flow (หรือสตรีมของ Rx) เมื่อค่าจะเปลี่ยนตามเวลา เช่น ข้อความจาก websocket, สถานะการเชื่อมต่อ หรือการอัปเดตฐานข้อมูล.
ใช่ แต่ต้องใช้กับ HTTP client ที่รองรับการยกเลิกด้วย suspend จะยกเลิกคอร์รูทีนเมื่อสโคปถูกยกเลิก แต่การเรียก HTTP ที่อยู่เบื้องล่างต้องรองรับการยกเลิกด้วย มิฉะนั้นคำขออาจยังคงทำงานในพื้นหลังต่อไป.
ผูกงานกับสโคปตามวงจรชีวิต เช่น viewModelScope เพื่อให้ยกเลิกเมื่อตรรกะหน้าจอจบ หลีกเลี่ยงการรันงานในสโคปที่มีอายุยืนยาวหรือสโคประดับกล็อบัล เว้นแต่การทำงานนั้นเป็นของทั้งแอปจริง ๆ.
ใน coroutines ความล้มเหลวมักจะถูกโยนเป็นข้อยกเว้นและจัดการด้วย try/catch ใกล้ ๆ กับที่คุณจะแม็ปเป็นสถานะ UI ใน RxJava ข้อผิดพลาดจะถูกส่งผ่านสตรีม ดังนั้นให้เส้นทางข้อผิดพลาดชัดเจนและหลีกเลี่ยงโอเปอเรเตอร์ที่เปลี่ยนความล้มเหลวเป็นค่าเริ่มต้นโดยเงียบ ๆ.
ใช้ข้อยกเว้นสำหรับความล้มเหลวที่ไม่คาดคิด เช่น timeout, 500s, หรือปัญหา parsing ใช้ข้อมูลข้อผิดพลาดแบบมีชนิดเมื่อ UI ต้องการการตอบสนองเฉพาะ เช่น “รหัสผ่านผิด” หรือ “อีเมลถูกใช้งานแล้ว” เพื่อไม่ให้ต้องอาศัยการจับคู่สตริง.
วาง timeout ใกล้จุดเรียกเพื่อให้แสดงข้อความ UI ที่เหมาะสมได้ง่าย ใน coroutines ใช้ withTimeout สำหรับ suspend calls; ใน RxJava ใช้ออปเอตอร์ timeout เพื่อให้ behavior เป็นส่วนหนึ่งของพายป์ไลน.
retry เฉพาะเมื่อปลอดภัยเท่านั้น โดยปกติ retry กับคำขอที่เป็น idempotent (เช่น GET) มากกว่าคำขอที่มีผลข้างเคียง (เช่น สร้างคำสั่งซื้อ) จำกัดจำนวนครั้ง (โดยทั่วไป 2–3) และเพิ่มดีเลย์หรือ jitter อย่า retry บนข้อผิดพลาดการตรวจสอบความถูกต้องหรือการยืนยันตัวตน.
ความผิดพลาดในการจัดเธรดมักเกิดจากการทำงานหนักบน Main thread หรืออัปเดต UI จากเธรดพื้นหลัง ใน coroutines ใช้ Dispatchers เช่น Dispatchers.Main, Dispatchers.IO, Dispatchers.Default ใน RxJava ใช้ subscribeOn เพื่อกำหนดที่มาของงานและ observeOn เพื่อกำหนดที่สังเกตผล ให้มี observeOn(main) หนึ่งจุดสุดท้ายก่อนการเรนเดอร์.
ได้ แต่ควรทำให้ขอบเขตการเปลี่ยนแปลงแคบและระวังการยกเลิก แปลง Rx เป็น suspend ด้วยอะแดปเตอร์ที่ยกเลิกการสมัครเมื่อคอร์รูทีนถูกยกเลิก และเปิดเผยงานแบบ suspend ให้กับผู้เรียก Rx เฉพาะในจุดเชื่อมต่อที่จดเอกสารไว้ชัดเจน.
ใช้เวลาแบบเสมือน (virtual time) เพื่อให้การทดสอบไม่ต้องนอนรอหรือพึ่งพาเธรดจริง ใน coroutines ใช้ runTest กับ test dispatcher เพื่อควบคุมดีเลย์และการยกเลิก In RxJava ใช้ TestScheduler และตรวจสอบว่าไม่มีการปล่อยเหตุการณ์หลังจาก dispose().


