การใช้ CountDownTimer บนแอนดรอยด์เป็นเรื่องที่นักพัฒนาสามารถอาจจะต้องเจอกันบ้างเป็นบางครั้ง เพราะในบางทีแอปของผู้ที่หลงเข้ามาอ่านก็อาจจะต้องมีการนับเวลาถอยหลังด้วยจุดประสงค์ที่แตกต่างกันออกไป

บ้างก็ใช้เพื่อนับเวลาถอยหลังก่อนจะเปิดหน้าถัดไป บ้างก็ใช้เพื่อแสดง UI และหน่วงเวลาก่อนที่ผู้ใช้จะไปขั้นตอนต่อไป เป็นต้น

โดย CountDownTimer มีรูปแบบในการใช้งานที่เห็นแล้วเข้าใจได้ไม่ยาก

val millisInFuture = 10_000L
val countDownInterval = 100L
val countDownTimer = object : CountDownTimer(millisInFuture, countDownInterval) {
    override fun onTick(millisUntilFinished: Long) {
        // Do something
    }

    override fun onFinish() {
        // Do something
    }
}
countDownTimer.start()

ผู้ที่หลงเข้ามาอ่านจะต้องกำหนดค่าอยู่ทั้งหมด 2 ตัวด้วยกัน คือ millisInFuture คือ countDownInterval โดยที่จะส่งผลลัพธ์ในการทำงานกลับมาเป็น onTick กับ onFinish

จากตัวอย่างโค้ดข้างบน เจ้าของบล็อกกำหนดค่า millisInFuture เป็น 10 วินาที ส่วน countDownInterval เป็น 100 มิลลิวินาที นั่นหมายความว่า onTick จะทำงานทุก ๆ 100 มิลลิวินาที โดยแต่ละครั้งจะส่งค่ามาบอกว่าเหลือเวลาอีกกี่มิลลิวินาที และ onFinish ก็จะทำงานเมื่อครบ 10 วินาทีนั่นเอง

CountDownTimer นั้นไม่ได้ Lifecycle-friendly กับ Android Component

เพราะ CountDownTimer นั้นทำงานอยู่บน Handler ที่ไม่มี Lifecycle awareness จึงทำให้การนำไปใช้งานใน Activity หรือ Fragment ก็จะต้องจัดการกับ Lifecycle ให้เหมาะสมเอง

ยกตัวอย่างเช่น การใช้ CountDownTimer เพื่อนับเวลาถอยหลังก่อนที่จะเปิดไปหน้าถัดไป โดยให้ onTick คอยอัปเดตค่าเวลาที่นับถอยหลัง และ onFinish ใช้คำสั่ง startActivity เพื่อเปิดไปหน้าที่ต้องการ

จากภาพตัวอย่างข้างบนจะเห็นว่า ถ้าผู้ใช้กดปุ่ม Back ก่อนที่คำสั่ง onFinish จะทำงานเพื่อออกจากแอปหรือกลับไป Activity ก่อนหน้า เมื่อคำสั่ง onFinish ทำงาน ก็จะสั่งเปิด Activty ใหม่ขึ้นมาอยู่ดี ถึงแม้ว่า Activity ที่ CountDownTimer ทำงานอยู่จะถูกปิดไปแล้ว

ดังนั้นเพื่อแก้ปัญหานี้ นักพัฒนาก็อาจจะต้องเพิ่มโค้ดเพื่อจัดการกับ CountDownTimer ให้เข้ากับ Lifecycle ที่สามารถเกิดขึ้นได้ และแน่นอนว่าโค้ดที่ต้องจัดการกับ Lifecycle นั้นไม่ได้ออกมาเป็นโค้ดที่สวยหรืออ่านแล้วเข้าใจได้ง่ายซักเท่าไร

เปลี่ยนมาใช้ร่วมกับ LiveData กันดีกว่า

เพื่อแก้ปัญหาการทำงานของ CountDownTimer ให้เหมาะสมกับ Lifecycle แต่ในขณะเดียวกันก็ไม่อยากให้โค้ดเหล่านั้นไปรกอยู่ที่ Activity หรือ Fragment ดังนั้นขอแนะนำให้เปลี่ยนมาใช้ CountDownTimer ร่วมกับ LiveData กันดีกว่า

ทั้งนี้ก็เพราะว่า LiveData เป็น Data Holder ที่มีความสามารถของ Lifecycle Awareness อยู่แล้ว จึงทำให้ตัวมันเองสามารถมีโค้ดที่จัดการตามเงื่อนไขของ Lifecycle ได้ง่าย โดยที่โค้ดเหล่านั้นไม่ต้องไปกองไว้ที่ปลายทางที่เรียกใช้งานเลย

ดังนั้นจึงสามารถสร้าง LiveData ที่มี CountDownTimer อยู่ข้างในแบบนี้ได้เลย

// CountDownTimerLiveData.kt
class CountDownTimerLiveData(
    private val millisInFuture: Long,
    private val countDownInterval: Long
) : LiveData<Long>() {
    private val countDownTimer = object : CountDownTimer(millisInFuture, countDownInterval) {
        override fun onTick(millisUntilFinished: Long) {
            postValue(millisUntilFinished)
        }

        override fun onFinish() {
            postValue(0)
        }
    }

    override fun onActive() {
        countDownTimer.start()
        postValue(millisInFuture)
    }
}
โค้ดตัวอย่างนี้ยังทำงานได้ไม่สมบูรณ์​ ซึ่งเจ้าของบล็อกจะพูดถึงเรื่องนี้ในภายหลัง

ทั้งนี้ก็เพราะว่าข้างใน LiveData มี Override Method อย่าง onActive และ onInactive ที่จะทำงานตาม Lifecycle ของ Activity หรือ Fragment ที่ Observe ค่าจาก LiveData ตัวนั้น ๆ อยู่

นอกจากนี้ LiveData จะส่งค่าออกมาให้เฉพาะตอนที่ Activity หรือ Fragment ตัวนั้น ๆ Active ด้วย จึงมั่นใจได้ว่าโค้ดที่กำหนดไว้จะทำงานเฉพาะตอนที่หน้านั้น ๆ ถูกเปิดอยู่เท่านั้น และถ้าแอปถูกย่ออยู่ LiveData ก็จะเก็บข้อมูลไว้ให้แล้วค่อยส่งให้ทีหลังตอนที่เปิดหน้านั้นกลับขึ้นมาใหม่อีกครั้ง

อย่าลืมใช้ LiveData ร่วมกับ ViewModel

เพื่อให้ LiveData ทำงานได้อย่างมีประสิทธิภาพ ไม่เจอปัญหาเวลาเกิด Configuration Changes ดังนั้นควรเรียกใช้งานร่วมกับ ViewModel แทนที่จะเรียกใช้งานใน Activity หรือ Fragment ตรง ๆ

// CountDownTimerViewModel.kt
class CountDownTimerViewModel : ViewModel() {
    private val countDownTimerLiveData = CountDownTimerLiveData(10_000L, 100L)
    val countDownTimer: LiveData<Long> = countDownTimerLiveData
}

// MainActivity.kt
class MainActivity : AppCompatActivity() {
    private val viewModel: CountDownTimerViewModel by lazy {
        ViewModelProvider(this).get(CountDownTimerViewModel::class.java)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        viewModel.countDownTimber.observe(this) { millisUntilFinished ->
            // Do something
        }
    }
}

ปรับการทำงานของ CountDownTimer ใน LiveData ให้เหมาะสมกับการใช้งาน

เนื่องจาก LiveData เข้ามาช่วยเรื่อง Lifecycle Awareness เท่านั้น แต่รูปแบบการทำงานของ CountDownTimer ก็จะขึ้นความต้องการของผู้ที่หลงเข้ามาอ่านอยู่ดี ดังนั้นอยากได้แบบไหนก็จัดได้ตามใจชอบ

นับถอยหลังไปเรื่อย ๆ และเริ่มนับใหม่เมื่อ Active

จากตัวอย่างก่อนหน้า โค้ดใน CountDownTimerLiveData จะเป็นแบบนี้

// CountDownTimerLiveData.kt
class CountDownTimerLiveData(
    private val millisInFuture: Long,
    private val countDownInterval: Long
) : LiveData<Long>() {
    private val countDownTimer = object : CountDownTimer(millisInFuture, countDownInterval) {
        override fun onTick(millisUntilFinished: Long) {
            postValue(millisUntilFinished)
        }

        override fun onFinish() {
            postValue(0)
        }
    }

    override fun onActive() {
        countDownTimer.start()
        postValue(millisInFuture)
    }
}

เมื่อดูที่โค้ดก็จะพบว่า CountDownTimer จะทำงานทุกครั้งที่ Activity หรือ Fragment มีการ Active ดังนั้นทำให้ CountDownTimer เริ่มนับถอยหลังใหม่ทุกครั้งนั่นเอง

หยุดนับถอยหลังตอนที่ Inactive และนับต่อจากเดิม

ในกรณีที่ไม่ต้องการให้ CountDownTimer นับเวลาถอยหลังตอนที่ Inactive อยู่ และเมื่อกลับมา Active ต่อก็ให้นับต่อจากเดิม จะต้องมีสั่งให้ CountDownTimer หยุดทำงาน และการเก็บค่าเวลาที่เหลืออยู่ไว้ เพื่อใช้สร้างเป็น CountDownTimer ตัวใหม่ในภายหลัง

จึงได้โค้ดออกมาเป็นแบบนี้แทน

class CountDownTimerLiveData(
    private val millisInFuture: Long,
    private val countDownInterval: Long
) : LiveData<Long>() {
    private var countDownTimer: CountDownTimer? = null
    private var remainingDuration: Long = millisInFuture

    override fun onActive() {
        countDownTimer = object : CountDownTimer(remainingDuration, countDownInterval) {
            override fun onTick(millisUntilFinished: Long) {
                remainingDuration = millisUntilFinished
                postValue(millisUntilFinished)
            }

            override fun onFinish() {
                postValue(0)
            }
        }.apply {
            start()
        }
        postValue(millisInFuture)
    }

    override fun onInactive() {
        countDownTimer?.cancel()
    }
}

แน่นอนว่าคำสั่งแบบนี้จะทำงานแค่เพียงครั้งเดียวแล้วจบเลย เพราะจะไม่มีการเริ่มนับใหม่อีกครั้ง

อยากจะสั่งให้เริ่มนับถอยหลัง, หยุดนับถอยหลังชั่วคราว และเคลียร์ค่าเพื่อเริ่มต้นใหม่ได้

เนื่องจากโค้ดตัวอย่างทั้ง 2 ที่อธิบายไปในก่อนหน้านี้ จะทำงานทันทีที่ Activity หรือ Fragment มีสถานะเป็น Active แต่ในบางครั้งผู้ที่หลงเข้ามาอ่านก็อาจจะต้องการให้ CountDownTimer ทำงานในภายหลังแทน และสามารถหยุดชั่วคราว หรือเคลียร์ค่าเพื่อเริ่มนับถอยหลังใหม่ได้

ซึ่งแน่นอนว่าในกรณีแบบนี้ จะต้องเพิ่ม Method ให้กับ LiveData เข้าไปเพื่อให้สั่งงานตามที่ต้องการได้ จึงทำให้โค้ดกลายเป็นแบบนี้แทน

class CountDownTimerLiveData(
    private val millisInFuture: Long,
    private val countDownInterval: Long
) : LiveData<Long>() {
    private var countDownTimer: CountDownTimer? = null
    private var remainingDuration: Long = millisInFuture

    init {
        initCountDownTimer()
    }

    fun start() {
        countDownTimer?.start()
        postValue(millisInFuture)
    }

    fun stop() {
        countDownTimer?.cancel()
        initCountDownTimer()
    }

    fun reset() {
        remainingDuration = millisInFuture
        stop()
        postValue(millisInFuture)
    }

    private fun initCountDownTimer() {
        countDownTimer = object : CountDownTimer(remainingDuration, countDownInterval) {
            override fun onTick(millisUntilFinished: Long) {
                remainingDuration = millisUntilFinished
                postValue(millisUntilFinished)
            }

            override fun onFinish() {
                postValue(0)
            }
        }
    }
}

ดังนั้น LiveData แบบนี้จะไม่ทำงานโดยอัตโนมัติ ต้องสั่งงานผ่านคำสั่ง start, stop และ reset เท่านั้น

สรุป

ในการทำงานของแอนดรอยด์นั้นจะมีเรื่อง Lifecycle เข้ามาเกี่ยวข้องด้วยเสมอ ถึงแม้ว่าจะเป็นการทำงานของ Framework API แล้วก็ตาม แต่ผู้ที่หลงเข้ามาก็ต้องคำนึงถึงโค้ดที่จะต้องจัดการตาม Lifecycle ให้ถูกต้องด้วย

แต่การเพิ่มโค้ดให้ทำงานร่วมกับ Lifecycle ได้อย่างถูกต้องก็ไม่ใช่เรื่องง่ายซักเท่าไร อีกทั้งยังทำให้โค้ดรกมากขึ้นกว่าเดิม ดังนั้นเพื่อลดโค้ดที่จะไปปะปนกับโค้ดส่วนอื่น ๆ การใช้ LiveData จึงมีประโยชน์มาก ๆ สำหรับกรณีนี้ ยกตัวอย่างเช่นการใช้งานร่วมกับ CountDownTimer ตามบทความนี้นั่นเอง