หมดปัญหาวุ่นวายกับ Background Task ด้วย WorkManager

หลังจาก Architecture Components ได้เปิดตัวในงาน Google I/O 2017 ล่าสุดในงาน Google I/O 2018 ก็ได้เปิดตัวน้องใหม่ในวงการเพิ่มเข้ามาอีกหลายๆตัว ซึ่งหนึ่งในนั้นคือ Component ที่มีชื่อว่า WorkManager

WorkManager ถูกเพิ่มเข้ามาเป็นส่วนหนึ่งของ Architecture Components ที่จะช่วยแก้ปัญหาเวลาที่นักพัฒนาอยากจะสร้าง Background Task ซักตัวเพื่อให้ทำงานตามที่ต้องการ อาจจะสั่งให้ทำงานทันทีหรือทำงานเมื่อถึงเวลาที่กำหนด

ก่อน WorkManager จะถือกำเนิด เค้าใช้วิธีไหนกันล่ะ?

เวลาที่นักพัฒนาอยากจะสร้าง Background Task ซักตัวเพื่อให้ทำงานตามที่ต้องการ เช่น แจ้งเตือนผู้ใช้เมื่อถึงเวลาที่กำหนด, Upload รูปขึ้น Web Server หรืออะไรก็ตามที่อยากจะสั่งไว้ล่วงหน้าหรือว่าใช้เวลาในการทำงานนานๆ นักพัฒนาก็จะต้องมานั่งคิดก่อนว่าจะใช้ API ของแอนดรอยด์ตัวไหนดี

  • Thread
  • Executor
  • Service
  • AsyncTask
  • Handler และ Looper
  • JobScheduler
  • GcmNetworkManager
  • SyncAdapter
  • Loader
  • AlarmManager

จะใช้ Thread แบบดิบๆก็ได้ แต่ทว่าๆเค้าไม่นิยมกันเพราะจัดการยาก ถ้าเป็น Task แบบง่ายๆก็ต้องเป็น AsyncTask หรืออยากจะตั้งเวลาทำงานก็ต้องใช้ JobScheduler แต่เพื่อให้รองรับกับเวอร์ชันเก่าๆด้วยก็เลยต้องใช้ AlarmManager แทน หรือไม่ก็ใช้ Firebase JobDispatcher ไปเลย แต่ Firebase JobDispatcher ก็จะใช้ได้กับเครื่องที่มี Google Play Services เท่านั้น ไหนจะต้องรับมือกับ Power Management ที่เพิ่มเข้ามาในแอนดรอยด์เวอร์ชันใหม่ๆอีก

  • Doze Mode ใน API 23 (Marshmallow 6.0)
  • App Standby ใน API 23 (Marshmallow 6.0)
  • Limited Implicit Broadcasts ใน API 26 (Oreo 8.0)
  • Background Service Limitations ใน API 26 (Oreo 8.0)
  • Release Cached Wakelocks ใน API 26 (Oreo 8.0)
  • App Standby Buckets ใน API 28 (P)
  • Background Restricted Apps ใน API 28 (P)

ทำไมมันเยอะแยะขนาดนี้!!

นอกเหนือจากปัญหา Lifecycle ที่เข้าใจยากแล้ว ก็มีเรื่อง Background Task นี่แหละ ที่น้อยคนจะเข้าใจมันจริงๆและสามารถจัดการได้อย่างถูกต้อง จึงทำให้ทีม Architecture Components สร้าง WorkManager ขึ้นมาเพื่อแก้ปัญหานี้นี่เอง

โดย WorkManager จะช่วยตัดปัญหาเหล่านี้ออกไปทั้งหมด ไม่ต้องมานั่งคิดว่าจะใช้ตัวไหนสำหรับโค้ดแบบไหน เมื่อใดก็ตามที่อยากจะสร้าง Background Task ให้ทำอะไรบางอย่าง สามารถใช้ WorkManager แทนได้เลย โดยมั่นใจได้ว่า Task ที่สร้างขึ้นมานั้นจะทำงานแน่นอน

จะ Task หรือ Job ก็เหมือนกันแหละ ในบทความนี้ขอใช้เป็นคำว่า Task ทั้งหมดนะ

คุณสมบัติของ WorkManager

  • มั่นใจได้ว่า Task จะได้ทำงานอย่างแน่นอน และทำงานตามเงื่อนไขที่กำหนดไว้
  • จัดการกับ Background Restriction หรือ Doze Mode ที่ถูกเพิ่มเข้ามาในแอนดรอยด์เวอร์ชันใหม่ๆให้เรียบร้อยแล้ว
  • Backward Compatible ไปจนถึง API 14 และไม่จำเป็นต้องมี Google Play Services ก็ได้ (ถ้ามีก็จะเรียกใช้งาน)
  • สามารถเช็ค Task ที่อยู่ใน WorkManager ได้
  • สามารถกำหนดให้ Task ทำงานต่อเนื่องกันได้
  • ถูกสร้างขึ้นมาเพื่อใช้งานแทน Firebase JobDispatcher

พูดไปก็อาจจะนึกไม่ออกว่ามันดียังไง ช่วยให้ชีวิตสะดวกยังไง ดังนั้นมาลองเขียนโค้ดกันดูดีกว่า

โครงสร้างพื้นฐานของ WorkManager

ในการใช้งาน WorkManager จะประกอบไปด้วยการทำงานทั้งหมด 3 ส่วนด้วยกัน

  • Worker — ใช้สร้าง Background Task ที่ต้องการให้ทำงานบางอย่าง
  • WorkRequest — เป็นตัวกำหนดเงื่อนไขหรือช่วงเวลาที่ Worker จะทำงาน
  • WorkManager — เป็นตัวสั่งให้ Worker ทำงาน

เพิ่ม WorkManager เข้าไปในโปรเจค

WorkManager จะไม่ได้รวมอยู่ใน Core ของ Architecture Components ดังนั้นเมื่ออยากจะใช้ก็ต้องเพิ่มเข้ามาเองนะ โดยตอนที่เจ้าของบล็อกเขียนบทความนี้ก็เป็นเวอร์ชัน 2.0.1 แล้ว

// สำหรับ Java
implementation "androidx.work:work-runtime:2.7.0"

// สำหรับ Kotlin
implementation "androidx.work:work-runtime-ktx:2.7.0"

// ถ้าต้องการใช้กับ RxJava 2
implementation "androidx.work:work-rxjava2:2.7.0"

// ถ้าต้องการเขียนเทส
androidTestImplementation "androidx.work:work-testing:2.7.0"

โดย WorkManager มีให้เลือกทั้ง Java และ Kotlin และถ้าอยากจะให้ทำงานร่วมกับ RxJava 2 หรือเขียนเทสด้วยก็สามารถเพิ่มเข้าไปได้ตามใจชอบ ทีมพัฒนาแยกออกมาเป็น Dependency คนละชุดไว้แล้ว

ลองสร้าง Worker ขึ้นมาซักตัว

สมมติว่าเจ้าของบล็อกอยากจะสร้าง Task สำหรับ Upload ภาพขึ้น Web Server ก็จะต้องสร้างคลาส Worker ขึ้นมาแบบนี้

// PhotoUploadWorker.kt
class PhotoUploadWorker(
    context: Context, 
    parameters: WorkerParameters
) : Worker(context, parameters) {
    override fun doWork(): Result {
        uploadPhotoToWebServer()
        return Result.success()
    }

    private fun uploadPhotoToWebServer() {
        /* ... */
    }
}

คลาส Worker จะมี Override Method ที่ชื่อว่า doWork() เพื่อให้นักพัฒนาสามารถใส่คำสั่งอะไรก็ได้ที่เป็น Synchronous ลงไป

ใช่ครับ โค้ดที่เป็น Synchronous ครับ อ่านไม่ผิดหรอก เพราะใน doWork() นี้จะทำงานอยู่บน Background Thread โดยอัตโนมัติ ไม่ต้องไปนั่งสร้าง Background Thread เองให้เสียเวลา

เมื่อทำคำสั่งอะไรก็ตามใน doWork() เสร็จ จะต้องส่งผลลัพธ์เป็นคลาสที่ชื่อว่า Result ด้วย เพื่อบอกว่าคำสั่งที่ทำงานไปเนี่ย มันสำเร็จหรือไม่

Result.success()
Result.success(outputData: Data)
Result.failure()
Result.failure(outputData: Data)
Result.retry()

ถ้าทำงานสำเร็จก็ส่งกลับไปเป็น Success แต่ถ้าไม่สำเร็จก็เป็น Failure แต่ถ้ามีปัญหาบางอย่างที่ทำให้ทำงานไม่ได้และอยากจะให้ทำงานใหม่ในภายหลังก็ให้ส่งเป็น Retry แทน

ดังนั้นเวลาใช้งานจริงๆก็จะออกมาประมาณนี้

// PhotoUploadWorker.kt
class PhotoUploadWorker : Worker() {
    override fun doWork(): Result {
        try {
            val result = uploadPhotoToWebServer()
            return when (result.status) {
                UploadResult.SUCCESS -> Result.success()
                UploadResult.SERVICE_UNAVAILABLE -> Result.retry()
                else -> Result.failure()
            }
        } catch (e: Exception) {
            /* ... */
        }
        return Result.failure()
    }
    /* ... */
}

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

เมื่อสร้าง Worker เสร็จเรียบร้อยแล้วก็ถึงเวลาของ WorkRequest กันต่อ

ถึงเวลาของ WorkRequest

WorkRequest จะถูกเรียกใช้งานที่ Component ปลายทางคู่กับ WorkManager น่ะแหละ โดยจะเป็นตัวกำหนดว่า Worker ตัวไหนจะให้ทำงานด้วยเงื่อนไขอะไร หรือจะตั้งเวลาในการทำงานก็ได้ โดย WorkRequest จะมีอยู่ 2 แบบด้วยกัน

  • OneTimeWorkRequest
  • PeriodicWorkRequest

OneTimeWorkRequest นั้นจะเหมาะกับ Task ที่ทำทีเดียวแล้วจบ ซึ่ง Task สำหรับ Upload รูปภาพขึ้น Web Server ที่เจ้าของบล็อกยกตัวอย่างก็จะใช้ WorkRequest แบบนี้

ส่วน PeriodicWorkRequest จะเหมาะกับสั่ง Task ให้ทำงานเป็นระยะๆ อย่างเช่นคอย Sync ข้อมูลจาก Web Server ทุกๆ 2 ชั่วโมง หรือส่ง Log ที่บันทึกเก็บไว้ในเครื่องขึ้น Web Server ทุกๆ 24 ชั่วโมงเป็นต้น

โดย WorkRequest จะถูกสร้างจากโค้ดด้วย Builder แบบนี้

// One-time
val oneTimeRequest = OneTimeWorkRequestBuilder<PhotoUploadWorker>().build()

// Periodic
val periodicRequest = PeriodicWorkRequestBuilder<DatabaseSyncWorker>(2, TimeUnit.HOURS).build()

ใน Builder ของ WorkRequest แต่ละตัวจะสามารถกำหนดค่าต่างๆได้ว่าจะให้ทำงานอะไรยังไงบ้าง โดยจะมีสิ่งที่เรียกว่า Constaints ให้กำหนดด้วย

กำหนดเงื่อนไขในการทำงานด้วย Constaints

ถ้าคุ้นเคยกันดีกับ JobScheduler ก็อาจจะเข้าใจกันทันที แต่สำหรับผู้ที่หลงเข้ามาอ่านที่ยังไม่เคยสัมผัส คลาส Constaints เอาไว้กำหนดว่าจะให้ Task นั้นๆจะทำงานก็ต่อเมื่อเงื่อนไขตรงกับที่กำหนดไว้เท่านั้น

val constraint = Constraints.Builder().apply {
    setRequiredNetworkType(NetworkType.CONNECTED)
}.build()

จากตัวอย่างข้างบนนี้ เจ้าของบล็อกสร้าง Constaints ขึ้นมาโดยกำหนดเงื่อนไขไว้ว่าจะให้ทำงานก็ต่อเมื่อเชื่อมต่ออินเตอร์เน็ตอยู่เท่านั้น เพียงเท่านี้ก็ไม่ต้องมานั่งเขียนเช็คเองใน Task แล้วว่าต่ออินเตอร์เน็ตหรือยังนะ เพราะว่าเดี๋ยว WorkManager จะเรียก Task ของเจ้าของบล็อกแค่ตอนที่ต่ออินเตอร์เน็ตแล้วเท่านั้น

โดย Constraints จะมีเงื่อนไขให้กำหนดทั้งหมดดังนี้

addContentUriTrigger(uri: Uri, triggerForDescendants: Boolean)
setRequiredNetworkType(networkType: NetworkType
setRequiresCharging(isRequired: Boolean)
setRequiresDeviceIdle(isRequired: Boolean)
setRequiresStorageNotLow(isRequired: Boolean)
setRequiresBatteryNotLow(isRequired: Boolean)

กำหนดตามความเหมาะสมของแต่ละ Task ละกันเนอะ

สำหรับการกำหนดค่าเหล่านี้ลงใน WorkRequest จะทำใน Builder ทั้งหมด

val constraint = Constraints.Builder().apply {
    setRequiredNetworkType(NetworkType.CONNECTED)
}.build()

val request = OneTimeWorkRequestBuilder<PhotoUploadWorker>().apply {
    setConstraints(constraint)
    setInitialDelay(10, TimeUnit.MINUTES)
}.build()

ใช้คำสั่ง setInitialDelay(...) เพื่อตั้งเวลาได้ด้วยนะ ไม่ต้องเขียน AlarmManager อีกต่อไป

และถ้าเป็น PeriodicWorkRequest ก็จะสร้างคล้ายๆกัน แต่ต้องกำหนดด้วยว่าจะให้ทำงานทุกๆระยะเวลาเท่าไร

val constraint = Constraints.Builder().apply {
    setRequiredNetworkType(NetworkType.CONNECTED)
    setRequiresBatteryNotLow(true)
}.build()

val request = PeriodicWorkRequestBuilder<DatabaseSyncWorker>(2, TimeUnit.HOURS).apply {
    setConstraints(constraint)
}.build()

โดยที่ PeriodicWorkRequest จะสั่งให้ทำงานได้บ่อยสุดคือทุกๆ 15 นาทีเท่านั้น (ตามเงื่อนไขของ JobSchduler) ถ้าเป็น Task ที่ต้องทำงานบ่อยกว่านั้น อย่างเช่น Location Tracking ก็แนะนำให้ใช้ Foreground Service แทนนะ

เรียกใช้งานผ่าน WorkManager

เมื่อสร้าง WorkRequest แบบที่ต้องการเรียบร้อยแล้ว ก็สั่งงานผ่านคลาส WorkManager ได้เลย

val context: Context = /* ... */
val request = OneTimeWorkRequestBuilder<PhotoUploadWorker>().apply {
    ...
}.build()

WorkManager.getInstance(context).enqueue(request)

เพียงเท่านี้ Task ก็พร้อมทำงานแล้ว ที่เหลือก็เป็นหน้าที่ของ WorkManager ที่จะไปสั่งให้ Task ทำงานตามช่วงเวลาหรือเงื่อนไขที่เจ้าของบล็อกกำหนดไว้ใน WorkRequest นั่นเอง

แล้วจะส่งข้อมูลเข้าไปใน Worker และผลลัพธ์ที่ได้จะส่งออกมายังไง?

โค้ดตัวอย่างข้างบนนี้อาจจะดูเหมือนว่าจะเสร็จสมบูรณ์แล้ว แต่ทว่ามันยังขาดข้อมูลที่ส่งเข้าไปและข้อมูลที่จะส่งออกมาอยู่ ซึ่งใน WorkManager จะใช้วิธีส่งข้อมูลผ่านคลาสที่ชื่อว่า Data ครับ (เป็นชื่อคลาสที่ซ้ำได้ง่ายมากกกกก)

Input Data

การสร้างคลาส Data ขึ้นมาเพื่อโยนเข้าไปใน Worker จะสร้างแบบนี้

val data = Data.Builder().apply {
    putString("file_path", "/path/to/file")
    putString("user_id", "Akexorcist")
    putString("token", "1AG7zd8as013jkdfjl2h4...")
}.build()

เอ้ย ยังกะ Bundle เลย!!

Data ถูกสร้างขึ้นมาด้วยแนวคิดเดียวกับ Bundle เลยครับ แต่ว่าจะถูกจำกัดขนาดไว้ที่ 10KB เท่านั้น เพื่อไม่ให้ WorkManager ต้องบวมตายเพราะข้อมูลขนาดใหญ่ๆ

แต่เขียนแบบนั้นยังไม่เท่พอ เพราะว่าจริงๆแล้วสามารถเขียนแบบนี้ได้อีกด้วย

val data = workDataOf(
    "file_path" to "/path/to/file",
    "user_id" to "Akexorcist",
    "token" to "1AG7zd8as013jkdfjl2h4..."
)

โดยคำสั่ง workDataOf(...) จะแปลงข้อมูลข้างในให้กลายเป็นคลาส Data ทันที

เวลาจะส่ง Data เข้าไปใน Worker ก็จะทำผ่าน WorkRequest แบบนี้

val data = workDataOf(
    "file_path" to "/path/to/file",
    "user_id" to "Akexorcist",
    "token" to "1AG7zd8as013jkdfjl2h4..."
)

val request = OneTimeWorkRequestBuilder<PhotoUploadWorker>().apply {
    setInputData(data)
    /* ... */
}.build()

และเวลาที่ Worker ต้องการดึงข้อมูลจาก Data เพื่อเอาไปใช้งาน ให้ดึงจาก inputData ได้เลย

// PhotoUploadWorker.kt
class PhotoUploadWorker(
    context: Context, 
    parameters: WorkerParameters
) : Worker(context, parameters) {
    override fun doWork(): Result {
        val filePath = inputData.getString("file_path")
        val userId = inputData.getString("user_id")
        val token = inputData.getString("token")
        /* ... */
    }
    /* ... */
}

Output Data

เมื่อ Worker ทำงานเสร็จแล้ว และอยากจะส่งผลลัพธ์ออกไปด้วย ก็ให้จะส่งผ่าน Data เช่นกัน โดยโยนเข้าไปในคำสั่ง Result.success(...) และ Result.failure(...) ได้เลย

class PhotoUploadWorker : Worker() {
    override fun doWork(): Result {
        /* ... */
        val result = uploadPhotoToWebServer()
        val outputData = workDataOf(
            "url" to result.url,
            "timestamp" to  result.timestamp
        )
        return Result.success(outputData)
    }
    /* ... */
}

Worker Status

กลับมาฝั่ง Component ที่เรียกใช้งาน WorkManager หลังจากที่สั่งให้ Worker ทำงานแล้ว และต้องการรู้สถานะในการทำงาน จะสามารถทำได้ 2 วิธีด้วยกันคือ ListenableFuture กับ LiveData

val context: Context = /* ... */
WorkManager.getInstance().getWorkInfoById(id: UUID) : ListenableFuture<WorkInfo>
WorkManager.getInstance().getWorkInfoByIdLiveData(id: UUID) : LiveData<WorkInfo>

ซึ่ง ID ที่ว่านี้จะได้จาก WorkRequest ที่จะสร้าง UUID ขึ้นมาให้ด้วยเพื่อไม่ให้ไปซ้ำกับตัวอื่นๆ และเจ้าของบล็อกจะขอพูดถึงแค่คำสั่งที่เป็น LiveData เท่านั้นนะ

จะเห็นว่าคำสั่ง getWorkInfoByIdLiveData(id) ส่งค่าออกมาเป็น LiveData<WorkInfo> โดยคลาส WorkInfo จะมีข้อมูลดังนี้

  • state — สถานะการทำงานของ WorkRequest ณ ตอนนั้น
  • id — UUID ของ WorkRequest
  • outputData — Data ที่ Worker ส่งออกมาตอนที่ทำงานเสร็จแล้ว
  • tags — ชื่อ Tag ของ WorkRequest

ดังนั้นผู้ที่หลงเข้ามาอ่านจึง Observe ได้ตลอดเวลาว่า WorkRequest ตัวนั้นๆอยู่ในสถานะไหน และทำงานเสร็จหรือยัง ถ้าทำงานเสร็จแล้วก็สามารถดึงผลลัพธ์มาใช้งานต่อได้เลย

// MainActivity.kt
class MainActivity : AppCompatActivity() {
    /* ... */
    public doSomething() {
        val request = OneTimeWorkRequestBuilder<PhotoUploadWorker>().apply {
            /* ... */
        }.build()

        WorkManager.getInstance().enqueue(request)
        WorkManager.getInstance().getWorkInfoByLiveData(request.id)
            .observe(this, Observer { info: WorkInfo ->
                if (info.state == State.SUCCEEDED) {
                    val url = info.outputData.getString("url")
                    val timestamp = info.outputData.getLong("timestamp", -1)
                    /* ... */
                }
            })
    }
}

โดย State ใน WorkStatus จะมีทั้งหมดดังนี้

  • ENQUEUED — รอคิวเพื่อถูกเรียกให้ทำงานอยู่
  • RUNNING — กำลังทำงานอยู่
  • SUCCEEDED — ทำงานเสร็จแล้วและได้ผลลัพธ์เป็น Success
  • FAILED — ทำงานเสร็จแล้วและได้ผลลัพธ์เป็น Failure
  • BLOCKED — การทำงานถูกหยุดชั่วคราว
  • CANCELLED — การทำงานถูกยกเลิกกลางคัน

ในกรณีที่อยากจะรู้ว่า Task ทำงานเสร็จแล้วหรือยัง โดยไม่สนว่าจะเป็น Success หรือ Failure สามารถเช็คแบบนี้ได้เหมือนกันนะ

WorkManager.getInstance().getWorkInfoByLiveData(request.id)
    .observe(this, Observer { info: WorkInfo ->
        if (info.state.isFinished) {
            /* ... */
        }
    })

นั่นหมายความว่า WorkManager ออกแบบมาให้ Observe การทำงานแล้วดักข้อมูลที่ต้องการไปใช้ แทนที่จะทำเป็น Callback (เพราะคุณก็รู้ว่า Callback โคตรไม่ถูกคอกับ Lifecycle เลย)

WorkRequest สั่งให้ทำงานพร้อมกันแบบ Parallel ได้

ในกรณีที่จำเป็นต้อง Upload ไฟล์ภาพหลายๆไฟล์พร้อมๆกัน สามารถสร้าง WorkRequest หลายๆตัวแล้วสั่งให้ WorkManager เรียกทำงานพร้อมๆกันได้เลยนะ

val uploadRequest1 = OneTimeWorkRequestBuilder<PhotoUploadWorker>().build()
val uploadRequest2 = OneTimeWorkRequestBuilder<PhotoUploadWorker>().build()
val uploadRequest3 = OneTimeWorkRequestBuilder<PhotoUploadWorker>().build()
WorkManager.getInstance().enqueue(listOf(uploadRequest1, uploadRequest2, uploadRequest3))

โคตรหล่อออออออออออออ

จะทำ Chaining WorkRequest ก็ได้เหมือนกันนะ

การทำ Chain จะทำได้เฉพาะ OneTimeWorkRequest เท่านั้น ไม่สามารถทำกับ PeriodicWorkRequest ได้

ความหล่อของ WorkManager ยังไม่จบเพียงเท่านี้ เพราะว่าความโหดร้ายของ Background Task ในชีวิตจริงนั้นคือ Logic อันยุ่งยากซับซ้อนที่จะต้องรับมือ

จะเกิดอะไรขึ้นถ้าไฟล์ภาพนั้นใหญ่เกินจำเป็น?

ดังนั้นเจ้าของบล็อกจึงออกแบบเพิ่มเติมว่าจะต้องย่อขนาดภาพไม่ให้ใหญ่เกินไปด้วย โดยตั้งใจว่าจะทำให้เป็นไฟล์ภาพ JPG ที่มี Quality แค่ 80% เท่านั้น

โดยปกติแล้วในขั้นตอนนี้ส่วนมากมักจะทำในทันทีก่อนที่จะสั่ง Upload ไฟล์นั้นขึ้น Web Server แต่เอาเข้าจริงคำสั่งย่อขนาดภาพก็เป็นคำสั่งที่ใช้เวลานานเหมือนกันนะ ดังนั้นก็ต้องทำเป็น Background Task ด้วยสิ

// PhotoCompressWorker.kt
class PhotoCompressWorker(
    context: Context, 
    parameters: WorkerParameters
) : Worker(context, paramteres) {
    override fun doWork(): Result {
        val originalFilePath = inputData.getString("file_path")
        return if (originalFilePath != null) {
            val compressedPath = compressPhoto()
            val outputData = workDataOf(
                "file_path" to compressedPath
            )
            Result.success(outputData)
        } else {
            Result.failure()
        }
    }

    private fun compressPhoto(): String {
        /* ... */
    }
}

ประกอบกับ Task สำหรับ Upload รูปภาพขึ้น Server ที่เจ้าของบล็อกทำไว้แล้วตั้งแต่แรก (แอบดัดแปลงนิดหน่อย)

// PhotoUploadWorker.kt
class PhotoUploadWorker(
    context: Context,
    parameters: WorkerParameters
) : Worker(context, parameters) {
    override fun doWork(): Result {
        val inputData = getInputData()
        val filePath = inputData.getString("file_path")
        val userId = inputData.getString("user_id")
        val token = inputData.getString("token")
        val result = uploadPhotoToWebServer(filePath, userId, token)
        return when (result.status) {
            UploadResult.SUCCESS -> {
                val outputData = workDataOf(
                    "url" to result.url,
                    "timestamp" to result.timestamp
                )
                Result.success(outputData)
            }
            else -> Result.failure()
        }
    }

    private fun uploadPhotoToWebServer(filePath: String, userId: String, token: String): UploadResult {
        /* ... */
    }
}

และเวลาสร้าง WorkRequest ให้กับ Worker ทั้ง 2 ตัวนี้ สามารถกำหนด Contraints แยกกันตามความเหมาะสมได้เลย

// Compress
val compressData = workDataOf(
    "file_path" to "/path/to/file"
)
val compressConstraints = Constraints.Builder().apply {
    setRequiresStorageNotLow(true)
}.build()
val compressRequest = OneTimeWorkRequestBuilder<PhotoCompressWorker>().apply {
    setInputData(compressData)
    setConstraints(compressConstraints)
}.build()

// Upload
val uploadData = workDataOf(
    "user_id" to "Akexorcist",
    "token" to "1AG7zd8as013jkdfjl2h4..."
)
val uploadConstraints = Constraints.Builder().apply {
    setRequiredNetworkType(NetworkType.CONNECTED)
}.build()
val uploadRequest = OneTimeWorkRequestBuilder<PhotoUploadWorker>().apply {
    setInputData(uploadData)
    setConstraints(uploadConstraints)
}.build()

ตอน Compress ก็สนใจแค่ว่า Storage ภายในเครื่องเหลือเพียงพอหรือป่าว ส่วนตอน Upload ก็สนแค่ว่าเครื่องนั้นๆต่ออินเตอร์เน็ตอยู่หรือป่าว เดี๋ยวตอนที่ Task เหล่านี้ทำงาน WorkManager จะจัดการให้เอง

และความเท่ของ WorkManager ก็คือเจ้าของบล็อกสามารถสั่งให้มันทำงานต่อเนื่องกันแบบนี้ได้เลย

WorkManager.getInstance().beginWith(compressRequest)
        .then(uploadRequest)
        .enqueue()

ลักษณะโค้ดก็จะให้กลิ่นอายของ Compose ใน ReactiveX มากๆ ต่างกันแค่ว่า Data Flow ของ WorkManager จะใช้เป็นคลาส Data เสมอ ไม่ได้เป็น Data Class ใดๆก็ได้แบบ ReactiveX

ซึ่งการทำ Chain จะต้องเริ่มจาก Begin เสมอแล้วอยากจะให้ Chain ยาวแค่ไหนก็ตามใจเลย ก็ใช้ Then ต่อท้ายไปเรื่อยๆ

WorkManager.getInstance().beginWith(compressRequest)
        .then(uploadRequest)
        .then(serviceLoggingRequest)
        .then(clearTemporaryPhotoRequest)
        .enqueue()

ด้วยการที่ WorkRequest ของแต่ละตัวแยกกัน ทำให้เจ้าของบล็อกสามารถ Observe การทำงานของแต่ละตัวแยกจากกันได้เลย เวลาจะ Debug การทำงานของ WorkRequest แต่ละตัวก็ทำได้ง่ายมากๆ

ผู้ที่หลงเข้ามาอ่านอาจจะสงสัยว่าทำไมทีม Architecture Components ถึงเลือกที่จะสร้างคลาส Data มาเป็นตัวกลางแทนที่จะให้นักพัฒนาสร้าง Model Class ของตัวเองแบบ ReactiveX ต้องลองมาดูข้อดีของการที่ใช้คลาส Data เป็นตัวกลางกันก่อนครับ

Data Flow ตอนทำ Chain ใน WorkManager

หัวใจสำคัญในการทำงานของ Chain ใน WorkManager ก็คือ Data จาก WorkRequest จากตัวก่อนหน้าจะถูกส่งไปให้ WorkRequest ตัวถัดไปทันที

แล้วจะเกิดอะไรขึ้นเมื่อ Worker ตัวถัดไปต้องการข้อมูลบางอย่างที่ Worker ตัวก่อนหน้าไม่มีหรือไม่จำเป็นต้องใช้ล่ะ?

ลองย้อนกลับไปดูโค้ดที่เจ้าของบล็อกยกตัวอย่างก่อนหน้านี้กัน

// Compress
val compressData = workDataOf(
    "file_path" to "/path/to/file"
)
val compressConstraints = Constraints.Builder().apply {
    setRequiresStorageNotLow(true)
}.build()
val compressRequest = OneTimeWorkRequestBuilder<PhotoCompressWorker>().apply {
    setInputData(compressData)
    setConstraints(compressConstraints)
}.build()

// Upload
val uploadData = workDataOf(
    "user_id" to "Akexorcist",
    "token" to "1AG7zd8as013jkdfjl2h4..."
)
val uploadConstraints = Constraints.Builder().apply {
    setRequiredNetworkType(NetworkType.CONNECTED)
}.build()
val uploadRequest = OneTimeWorkRequestBuilder<PhotoUploadWorker>().apply {
    setInputData(uploadData)
    setConstraints(uploadConstraints)
}.build()

เจ้าของบล็อกสร้าง compressData เพื่อโยนให้ PhotoCompressWorker และสร้าง uploadData ให้ PhotoUploadWorker

อ้าว!! ไม่ใช่ว่า Output Data ของ PhotoCompressWorker จะกลายเป็น Input Data ให้ PhotoUploadWorker หรือหรอก?

นั่นก็ถูกต้องเช่นกัน เพราะความเจ๋งของ WorkManager คือมันจะจับ Output Data ของ PhotoCompressWorker รวมกับ uploadData แล้วค่อยโยนเข้าไปให้ PhotoUploadWorker ครับ

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

แต่ถ้าเกิดว่า Worker ตัวก่อนหน้าดันส่ง Data ที่มี Key เหมือนกันกับที่ส่งมาทาง WorkRequest ผลที่ได้ก็คือ Key ตัวนั้นจะโดนแทนที่ด้วยค่าที่มาจาก WorkRequest แทน

ถ้าเป็นไปได้ก็ให้แยก Key กันให้ชัดเจนนะ

เมื่อต้องทำ Chain แบบซับซ้อน

อยากจะผสมกันระหว่าง Chain และ Parallel เข้าด้วยกันก็ไม่มีปัญหา เพราะ WorkManager ออกแบบมาให้รองรับไว้หมดแล้ว

ยกตัวอย่างเช่น Upload รูปภาพขึ้น Web Server หลายๆรูปพร้อมกัน โดยจะต้องย่อขนาดภาพก่อนด้วย

ด้วยความสามารถของ WorkManager จึงทำให้เจ้าของบล็อกสามารถสั่งงานแบบนี้ได้เลย

val compressRequest1 = OneTimeWorkRequestBuilder<PhotoCompressWorker>().build()
val compressRequest2 = OneTimeWorkRequestBuilder<PhotoCompressWorker>().build()
val compressRequest3 = OneTimeWorkRequestBuilder<PhotoCompressWorker>().build()
val uploadRequest = OneTimeWorkRequestBuilder<PhotoUploadWorker>().build()
WorkManager.getInstance().beginWith(compressRequest1, compressRequest2, compressRequest3)
        .then(uploadRequest)
        .enqueue()

แต่เดี๋ยวก่อน จำได้มั้ยว่าถ้า Data ที่มีชื่อ Key เหมือนกันจะทำให้ข้อมูลถูกทับกัน ดังนั้นในกรณีนี้จะทำให้ Output Data จาก PhotoCompressWorker ทับกันขึ้นอยู่กับว่าตัวไหนทำงานเสร็จหลังสุด และกลายเป็นว่าภาพที่ถูก Upload ขึ้น Web Server ก็จะมีเพียงแค่ภาพเดียวเท่านั้น

เพื่อแก้ปัญหานี้ทีมพัฒนาจึงใส่ความสามารถที่เรียกว่า InputMerger เข้ามาให้ด้วย

แก้ปัญหาข้อมูลทับกันด้วย InputMerger

InputMerger นั้นมีอยู่ 2 แบบด้วยกันคือ

  • ArrayCreatingInputMerger — ทำข้อมูลที่ให้กลายเป็น Array ซะ
  • OverwritingInputMerger — ปล่อยให้ข้อมูลมันทับกันน่ะแหละ

โดยปกติแล้ว WorkRequest ทุกตัวจะกำหนดไว้เป็น OverwritingInputMerger นั่นเอง จึงเป็นที่มาว่าทำไมข้อมูลถึงทับกัน ดังนั้นเพื่อแก้ปัญหานี้เจ้าของบล็อกจึงต้องกำหนดเป็น ArrayCreatingInputMerger แทนครับ

ArrayCreatingInputMerger จะเปลี่ยนข้อมูลใน Data ให้กลายเป็น Array แทน เพื่อไม่ให้ข้อมูลทับซ้อนกัน

แต่เงื่อนไขสำคัญของ ArrayCreatingInputMerger ก็คือ Key ที่มีชื่อซ้ำกันจะต้องเป็นข้อมูลประเภทเดียวกันเท่านั้น

เมื่อใดก็ตามที่พิเรนกำหนด Key เดียวกันแต่เป็นคนละประเภทกัน ก็พังทันทีเลยจ้า

java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.Class java.lang.Object.getClass()' on a null object reference
at androidx.work.ArrayCreatingInputMerger.merge(ArrayCreatingInputMerger.java:53)
at androidx.work.impl.WorkerWrapper.run(WorkerWrapper.java:120)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
at java.lang.Thread.run(Thread.java:764)

เพื่อแก้ปัญหาข้อมูลทับกัน ดังนั้นเจ้าของบล็อกก็เลยกำหนดให้ PhotoUploadWorker ใช้ ArrayCreatingInputMerger แทน

val uploadRequest = OneTimeWorkRequestBuilder<PhotoUploadWorker>().apply {
    /* ... */
    setInputMerger(ArrayCreatingInputMerger::class)
}.build()

เนื่องจาก ArrayCreatingInputMerger เป็นการทำให้ Data ในทุก Key เป็น Array ดังนั้นเวลาดึงค่าไปใช้งานก็จะต้องดึงเป็นแบบ Array แทนด้วย ไม่ว่าจะเป็น Data ที่ส่งมาจากการ Chain หรือว่า Data ที่กำหนดผ่าน WorkRequest ก็ตาม

// PhotoUploadWorker.kt
class PhotoUploadWorker(
    context: Context, 
    parameters: WorkerParameters
): Worker(context, parameters) {
    override fun doWork(): Result {
        val filePathList = inputData.getStringArray("file_path")
        val userId = inputData.getStringArray("user_id")[0]
        val token = inputData.getStringArray("token")[0]
        /* ... */
    }
    /* ... */
}

เนื่องจาก filePathList เป็นข้อมูลจาก PhotoCompressWorker หลายๆตัวรวมกัน ดังนั้นเจ้าของบล็อกจะวนลูปสั่งงานเอา ส่วน userId และ token เป็นค่าที่ส่งเข้ามาทาง WorkRequest โดยตรง ดังนั้นดึงจาก Index 0 เพื่อเอาไปใช้งานก็พอ (ส่งมาแค่ตัวเดียว แต่ ArrayCreatingInputMerger มันแปลงให้กลายเป็น Array ทั้งหมด)

// PhotoUploadWorker.kt
class PhotoUploadWorker(
    context: Context, 
    parameters: WorkerParameters
): Worker(context, parameters) {
    override fun doWork(): Result {
        val filePathList = inputData.getStringArray("file_path")
        val userId = inputData.getStringArray("user_id")?.get(0)
        val token = inputData.getStringArray("token")?.get(0)
        var success = emptyArray<String>()
        var failure = emptyArray<String>()
        filePathList?.forEach { filePath: String ->
            val result = uploadPhotoToWebServer(filePath, userId, token)
            when (result.status) {
                UploadResult.SUCCESS -> success += filePath
                else -> failure += filePath
            }
        }
        val outputData = workDataOf(
            "success" to success,
            "failure" to failure,
            "all_uploaded" to failure.isEmpty()
        )
        return if (success.isNotEmpty()) {
            Result.success(outputData)
        } else {
            Result.failure(outputData)
        }
    }
    /* ... */
}

เพื่อให้รองรับการ Upload ภาพหลายๆภาพขึ้น Server ก็เลยต้องมีการปรับเปลี่ยนโค้ดเล็กน้อย เพราะอาจจะมีบางภาพที่ Upload ไม่สำเร็จ

การยกเลิก WorkRequest ที่กำลังทำงานอยู่

สมมติว่ากำลัง Upload ภาพขึ้น Server อยู่ แล้วผู้ใช้กดยกเลิกกลางคัน เจ้าของบล็อกก็สั่งให้ WorkManager หยุด Task นั้นๆได้ทันทีโดยอ้างอิงจาก UUID ของ WorkRequest นั้นๆ

WorkManager.getInstance().cancelWorkById(uploadRequest.id)

ในกรณีที่ WorkRequest นั้นๆยังไม่ได้ทำงานหรือกำลังทำงานอยู่ ก็จะถูกยกเลิกทันที แต่ถ้าเกิดทำงานเสร็จแล้ว คำสั่งนี้ก็จะไม่มีผลอะไร

ถึงแม้ว่า WorkRequest จะมี UUID เอาไว้ให้ใช้ในการเช็คสถานะหรือยกเลิกการทำงาน แต่จะให้เก็บ UUID เอาไว้ทุกครั้งก็กระไรอยู่ เพราะว่า UUID นั้นถูกสร้างขึ้นมาด้วย WorkRequest ที่ผู้ที่หลงเข้ามาอ่านไม่สามารถรู้ได้ว่า Task นั้นมันคืออะไรกันแน่ รู้แค่ว่ามันเป็น ID ที่ไม่ซ้ำกัน

1c59db46-2821-4e54-9fa5-64219e2721a2
45d3f71a-f919-4816-92ad-e41c7298adab
30d88bad-24f2-4b86-82ac-25081d917ffc

เพื่อให้นักพัฒนาจัดการกับ Task หลายๆตัวได้ง่ายขึ้น ทีมพัฒนาจึงทำให้ใส่ Tag ใน WorkRequest แต่ละตัวได้ด้วย

แปะ Tag ให้กับ WorkRequest ชีวิตจะได้ไม่ยุ่งยาก

จากโค้ดตัวอย่างก่อนหน้า

val compressRequest1 = OneTimeWorkRequestBuilder<PhotoCompressWorker>().build()
val compressRequest2 = OneTimeWorkRequestBuilder<PhotoCompressWorker>().build()
val compressRequest3 = OneTimeWorkRequestBuilder<PhotoCompressWorker>().build()
val uploadRequest = OneTimeWorkRequestBuilder<PhotoUploadWorker>().build()

จะเห็นว่าเจ้าของบล็อกมี Task อยู่ทั้งหมด 4 ตัว โดยแบ่งออกเป็น 2 ชุด

เพื่อให้จัดการทีหลังได้ง่าย เจ้าของบล็อกจึงใส่ Tag ไว้ที่ Task แต่ละตัวแบบนี้

val compressRequest1 = OneTimeWorkRequestBuilder<PhotoCompressWorker>().apply {
    /* ... */
    addTag("compress_photo")
    addTag("photo_uploader")
}.build()
val compressRequest2 = OneTimeWorkRequestBuilder<PhotoCompressWorker>().apply {
    /* ... */
    addTag("compress_photo")
    addTag("photo_uploader")
}.build()
val compressRequest3 = OneTimeWorkRequestBuilder<PhotoCompressWorker>().apply {
    /* ... */
    addTag("compress_photo")
    addTag("photo_uploader")
}.build()
val uploadRequest = OneTimeWorkRequestBuilder<PhotoUploadWorker>().apply {
    /* ... */
    addTag("upload_photo_to_server")
    addTag("photo_uploader")
}.build()
/* ... */

สมมติว่าอยากจะยกเลิก Task ที่เป็นการ Compress รูปภาพทั้งหมดก็เพียงแค่ใช้คำสั่ง

WorkManager.getInstance().cancelAllWorkByTag("compress_photo")

หรือจะยกเลิกเฉพาะตอน Upload รูปภาพก็ใช้คำส่ัง

WorkManager.getInstance().cancelAllWorkByTag("upload_photo_to_server")

และถ้าจำเป็นต้องยกเลิก Task ชุดนี้ทั้งหมดเลยก็ใช้คำสั่ง

WorkManager.getInstance().cancelAllWorkByTag("photo_uploader")

อยากจะเช็คผลลัพธ์การทำงานของ Task ตอน Compress รูปภาพทั้งหมดก็ทำได้เช่นกัน

// MainActivity.kt
class MainActivity: AppCompatActivity() {
    /* ... */
    private fun doSomething() {
        WorkManager.getInstance().getWorkInfosByTagLiveData("compress_photo")
            .observe(this, Observer { infoList: List<WorkInfo> ->
                infoList.forEach { info: WorkInfo ->
                    if (info.state.isFinished) {
                        /* ... */
                    }
                }
            })
    }
}

โดยคำสั่ง getWorkInfosByTagLiveData(...) จะส่งผลลัพธ์ออกมาเป็น LiveData<List<WorkInfo>> เพื่อให้ผู้ที่หลงเข้ามาอ่าน Observe และจัดการได้ตามต้องการเลย

เห็นมั้ย การใช้ Tag กับ WorkRequest แต่ละตัวนั้นช่วยให้ชีวิตเจ้าของบล็อกง่ายขึ้นเยอะ

ไม่อยากให้ Task ทำงานทับซ้อนกัน Unique Work ช่วยคุณได้

การทำงานที่ผ่านมาของ WorkManager นั้นขาดอยู่อย่างหนึ่งคือกรณีที่อยากจะให้ Task ทำงานเพียงแค่ชุดเดียวใน ณ เวลานั้น ยกตัวอย่างเช่น Task ที่ทำหน้าที่ Sync ฐานข้อมูลในเครื่อง ถ้าเกิดเผลอสร้าง Task ขึ้นมาหลายๆตัวในช่วงเวลาเดียวกัน แล้วดันไปแก้ข้อมูลพร้อมๆกันก็อาจจะทำให้เกิดปัญหาได้ ดังนั้น Task แบบนี้จำเป็นต้องทำงานแค่ตัวเดียวเท่านั้น (ณ ช่วงเวลานั้น)

ดังนั้น WorkManager จึงมีสิ่งที่เรียกว่า Unique Work เพื่อกำหนดให้ Task ทำงานเพียงแค่ชุดเดียวเท่านั้น ถ้ามี Task ที่เหมือนกันถูกสร้างขึ้นมา ผู้ที่หลงเข้ามาอ่านสามารถกำหนดได้ว่าจะยกเลิก Task ชุดเก่าไปเลยเพื่อให้ Task ชุดใหม่ทำงานแทน หรือว่าจะรอให้ Task ชุดเก่าทำงานจนเสร็จก่อน Task ใหม่ถึงจะเริ่มทำงาน

Unique Work จะรองรับเฉพาะ OneTimeWorkRequest เท่านั้นนะ

จะมี WorkRequest เพียงตัวเดียวหรือต้องการทำ Chain เยอะแค่ไหน ในคลาส WorkManager จะต้องใช้คำสั่ง beginUniqueWork(...) เท่านั้น

beginUniqueWork(uniqueWorkName, existingWorkPolicy, work)
beginUniqueWork(uniqueWorkName, existingWorkPolicy, listOf(work1, work2, /* ... */, workN))

ยกตัวอย่างเช่น

val syncDatabaseRequest = OneTimeWorkRequestBuilder<SyncDatabaseWorker>().build()
WorkManager.getInstance().beginUniqueWork("sync_database", ExistingWorkPolicy.KEEP, syncDatabaseRequest)
        .enqueue()
  • uniqueWorkName — ชื่อของ Unique Work สามารถตั้งได้ตามใจชอบ เอาไว้สั่งยกเลิกหรือเช็คสถานะการทำงานในทีหลัง (คล้ายๆกับ Tag)
  • existingWorkPolicy — เงื่อนไขในการทำงานถ้าเกิดมี Task อีกชุดถูกสร้างขึ้นมาเหมือนกัน
  • work : OneTimeWorkRequest ที่ต้องการให้ทำงานเป็น Unique Work จะตัวเดียวหรือหลายตัวก็ได้

สำหรับ ExistingWorkPolicy สามารถกำหนดได้ทั้งหมดดังนี้

  • APPEND — รอจนกว่าชุดเก่าจะทำงานเสร็จ แล้วชุดใหม่ค่อยทำงานต่อ
  • KEEP — ชุดเก่าทำงานต่อไป ไม่ต้องไปสนใจชุดใหม่
  • REPLACE — ยกเลิกชุดเก่า แล้วให้ชุดใหม่ทำงานแทนทันที

โดยคำสั่ง beginUniqueWork(...) นั้นรองรับการใช้คำสั่ง then(...) เหมือนกัน ดังนั้นอยากจะทำ Chain ยังไงก็แล้วแต่เลยครับ แต่อย่าลืมจบท้ายด้วยคำสั่ง enqueue() ก็พอ

WorkManager กับการเขียนเทส

สำหรับการเขียนเทสนั้นก็เป็นอีกอย่างหนึ่งที่เจ้าของบล็อกประทับใจสุดๆเลย เพราะปกติแล้วการจะเขียนเทสให้กับ Background Service ซักตัวหนึ่งมันไม่ใช่เรื่องง่ายซักเท่าไร

ในการเทสการทำงานของ Worker นั้นจะดูกันที่ State และ Output Data ที่ได้จาก Worker นั้นๆว่าเป็นไปตามเงื่อนไขที่ต้องการหรือป่าว

โดยการเทสจะเป็นแบบ Instrumentation Test ที่ต้องมีการเตรียมการทำงานต่างๆของ WorkManager ให้พร้อมกับการเขียนเทส

// PhotoUploadInstrumentedTest.kt
@RunWith(AndroidJUnit4::class)
class PhotoUploadInstrumentedTest {
    @Before
    fun setup() {
        val context = InstrumentationRegistry.getTargetContext()
        val config = Configuration.Builder()
            .setMinimumLoggingLevel(Log.DEBUG)
            .setExecutor(SynchronousExecutor())
            .build()
        WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
    }
    /* ... */
}

สามารถใช้คำสั่งตามตัวอย่างข้างบนเพื่อใช้ Setup การทำงานของ WorkManager ใน Instrumentation Test ได้เลย ซึ่งจะเห็นว่ามีการเพิ่ม Configuration ให้กับ WorkManager เพื่อให้ Executor ทำงานเป็นแบบ Synchronous ซึ่งจะช่วยให้ผู้ที่หลงเข้ามาอ่านสามารถสั่งให้ WorkManager ทำงานแล้วรอผลลัพธ์ที่ส่งออกมาจาก Worker ได้ทันที

และอย่าลืมทำให้ Class ต่างๆที่ถูกเรียกใช้งานใน​ Worker สามารถจำลองการทำงานได้ด้วยนะ (เช่น ควรทำให้คำสั่งอัปโหลดรูปภาพสามารถจำลองการทำงานได้โดยไม่ต้องอัปโหลดรูปภาพจริงๆ และกำหนดได้ว่าจะให้ส่งผลลัพธ์กลับมาเป็น Success หรือ Failure เพื่อใช้ในการเทส)

สำหรับการเขียนเทสให้กับ Worker ที่ต้องการ จะอยู่ในโค้ดลักษณะแบบนี้

// PhotoUploadInstrumentedTest.kt
@RunWith(AndroidJUnit4::class)
class PhotoUploadInstrumentedTest {
    /* ... */
    @Test
    fun testUploadWorker() {
        val data = workDataOf(
            /* ... */
        )
        val request = OneTimeWorkRequestBuilder<PhotoUploadWorker>()
            .setInputData(data)
            .build()
        WorkManager.getInstance().enqueue(request).result.get()
        val workInfo = WorkManager.getInstance().getWorkInfoById(request.id).get()
        val outputData = workInfo.outputData

        assertThat(workInfo.state, `is`(WorkInfo.State.SUCCEEDED))

        val expectedData = workDataOf(
            /* ... */
        )
        assertThat(outputData, `is`(expectedData))
    }
}

สมมติว่าเจ้าของบล็อกต้องการเขียนเทสให้กับ PhotoUploadWorker ก็สร้าง Input Data และ OneTimeWorkRequest ขึ้นมา จากนั้นก็สั่งให้ทำงานด้วยคำสั่ง enqueue()

ถึงแม้ว่าจะสั่งด้วย enqueue() ก็ตาม แต่เนื่องจากเจ้าของบล็อกกำหนดให้ Worker ทำงานด้วย SynchronousExecutor ไว้ ดังนั้นจะต้องใช้คำสั่ง request.get() เพื่อให้ Worker ทำงานจนกว่าจะเสร็จ จากนั้นจึงเช็คการทำงานของ Worker จาก WorkInfo เพื่อดูว่ามี State เป็นไปตามที่คาดหวังไว้มั้ย และ OutputData ตรงกับที่ต้องการหรือป่าว

สรุป

WorkManager ก็เป็น API อีกตัวหนึ่งที่ไม่ควรพลาด เพราะมันจะช่วยให้นักพัฒนาหมดปัญหาจาก Background Task แบบเดิมๆไปในทันที ในตอนใช้งาน WorkManager จะเห็นว่านักพัฒนาไม่จำเป็นต้องสนใจเลยว่าจะจัดการกับ Power Management ในแอนดรอยด์เวอร์ชันใหม่ๆยังไง เพราะว่า WorkManager จัดการให้หมดแล้วนั่นเอง

และสิ่งที่นักพัฒนาต้องรู้ก็คือการทำงานแบบไหนถึงควรจะใช้ WorkManager เพราะมันไม่ได้ตอบโจทย์การทำงานทุกแบบ บางอย่างก็ยังจำเป็นต้องใช้วิธีแบบเดิมๆ เช่น Foreground Service เป็นต้น และต้องเข้าใจวิธีการทำ Chaining เพื่อให้สามารถนำไปใช้งานจริงได้ รวมไปถึงลักษณะข้อมูลใน Data เมื่อทำ Chaining ด้วย

นอกจากจะช่วยให้ทำงานได้ง่ายแล้ว WorkManager ยังช่วยควบคุมรูปแบบของโค้ดในส่วนนี้ให้ง่ายขึ้นด้วย จากเดิมที่จะต้องเขียนเช็คอะไรมากมายก็เหลือน้อยลง ทำงานร่วมกับ LiveData เพื่อให้ใช้ Observe แทนการใช้ Callback เพื่อแก้ปัญหาจาก Lifecycle โดยที่โค้ดในส่วนของ Worker กับตอนเรียกใช้งานผ่านคลาส WorkManager แทบจะเป็นอิสระต่อจากกัน และมีคลาส Data เป็นตัวกลางในการรับส่งข้อมูลที่ยืดหยุ่นมากพอจะนำไปใช้งานแบบซับซ้อนได้

และที่สำคัญ เขียนเทสได้ด้วยนะจ๊ะ

ถ้าเป็นไปได้ก็ลองใช้เถอะนะ ของเค้าดีจริง ถ้าตรงไหนไม่ดีก็ Feedback บอกทีมพัฒนาได้เลย

แหล่งข้อมูลอ้างอิง