16 May 2018

[Android Code] หมดปัญหาวุ่นวายกับ 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 ที่ถูกเพิ่มเข้ามาในแอนดรอยด์เวอร์ชันใหม่ๆให้เรียบร้อยแล้ว
        • Backward Compatible และไม่จำเป็นต้องมี Google Play Services ก็ได้ (ถ้ามีก็จะเรียกใช้งาน)
        • สามารถเช็ค Task ที่อยู่ใน WorkManager ได้
        • สามารถกำหนดให้ Task ทำงานต่อเนื่องกันได้

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

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

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

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

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

        WorkManager จะไม่ได้รวมอยู่ใน Core ของ Architecture Components ดังนั้นเมื่ออยากจะใช้ก็ต้องเพิ่มเข้ามาเองนะ โดยตอนนี้ยังอยู่ในสถานะ Alpha อยู่

(Required for Java style)
implementation "android.arch.work:work-runtime:1.0.0-alpha01"

(Required for Kotlin style) 
implementation "android.arch.work:work-runtime-ktx:1.0.0-alpha01"

(Optional when Firebase JobDispatcher is using)
implementation "android.arch.work:work-firebase:1.0.0-alpha01"

(Optional for testing)
androidTestImplementation "android.arch.work:work-testing:1.0.0-alpha01"

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

        หมายเหตุ - WorkManager ยังไม่รองรับกับ Package Name ชุดใหม่ที่เป็น AndroidX นะ ถ้าอยากจะลองใช้ก็ให้ใช้กับ Android Support Library ชุดเก่าไปก่อน

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

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

// PhotoUploadWorker.kt

class PhotoUploadWorker : Worker() {
    override fun doWork(): WorkerResult {
        uploadPhotoToWebServer()
        return WorkerResult.SUCCESS
    }

    private fun uploadPhotoToWebServer() {
        ...
    }
}

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

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

        • WorkerResult.SUCCESS
        • WorkerResult.FAILURE
        • WorkerResult.RETRY

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

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

// PhotoUploadWorker.kt

class PhotoUploadWorker : Worker() {
    override fun doWork(): WorkerResult {
        try {
            val result = uploadPhotoToWebServer()
            return when (result.status) {
                UploadResult.SUCCESS -> WorkerResult.SUCCESS
                UploadResult.SERVICE_UNAVAILABLE -> WorkerResult.RETRY
                else -> WorkerResult.FAILURE
            }
        } catch (e: Exception) {
            ...
        }
        return WorkerResult.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 = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).build()

// Periodic
val periodicRequest = PeriodicWorkRequest.Builder(DatabaseSyncWorker::class.java, 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(androidx.work.NetworkType.CONNECTED)
}.build()

val request = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).apply {
    setConstraints(constraint)
    setInitialDelay(10, TimeUnit.MINUTES)
}.build()

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

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

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

val request = PeriodicWorkRequest.Builder(DatabaseSyncWorker::class.java, 2, TimeUnit.HOURS).apply {
    setConstraints(constraint)
}.build()

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

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

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

val request = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).apply {
    ...
}.build()

WorkManager.getInstance().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 ต้องบวมตายเพราะข้อมูลขนาดใหญ่ๆ

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

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

val request = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).apply {
    setInputData(data)
    ...
}.build()

        และเวลาที่ Worker ต้องการดึงข้อมูลจาก Data เพื่อเอาไปใช้งาน ก็จะต้องดึงผ่านคำสั่ง getInputData() แต่ถ้าเป็น Kotlin จะดึงจาก inputData ได้เลย

// PhotoUploadWorker.kt

class PhotoUploadWorker : Worker() {
    override fun doWork(): WorkerResult {
        val inputData = getInputData()
        val filePath = inputData.getString("file_path", null)
        val userId = inputData.getString("user_id", null)
        val token = inputData.getString("token", null)
        ...
    }
    ...
}

Output Data

        เมื่อ Worker ทำงานเสร็จแล้ว และอยากจะส่งผลลัพธ์ออกไปด้วย ก็จะส่งผ่าน Data เช่นกัน โดยจะต้องกำหนดไว้ในคำสั่ง setOutputData(data) แต่ถ้าเป็น Kotlin ก็กำหนดค่าให้กับตัวแปรที่ชื่อว่า outputData ได้เลย

class PhotoUploadWorker : Worker() {
    override fun doWork(): WorkerResult {
        ...
        val result = uploadPhotoToWebServer()
        outputData = Data.Builder().apply {
            putString("url", result.url)
            putLong("timestamp", result.timestamp)
        }.build()
        return WorkerResult.SUCCESS
        ...
    }
    ...
}

        กลับมาฝั่ง Component ที่เรียกใช้งาน WorkManager หลังจากที่สั่งให้ Worker ทำงานแล้ว และต้องการรู้สถานะในการทำงาน ในคลาส WorkManager ก็จะมีคำสั่งที่ชื่อว่า getStatusById(id)

WorkManager.getInstance().getStatusById(id: UUID) : LiveData<WorkStatus>

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

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

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

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

// MainActivity.kt

class MainActivity : AppCompatActivity() {
    ...
    public doSomething() {
        val request = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).apply {
            ...
        }.build()

        WorkManager.getInstance().enqueue(request)

        WorkManager.getInstance().getStatusById(request.id).observe(this, Observer {
            if (it != null && it.state == State.SUCCEEDED) {
                val url = it.outputData.getString("url", null)
                val timestamp = it.outputData.getLong("timestamp", -1)
                ...
            }
        })
    }
}

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

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

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

WorkManager.getInstance().getStatusById(request.id).observe(this, Observer {
    if (it != null && it.isFinished) {
        ...
    }
})

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

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

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

val uploadRequest1 = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).build()
val uploadRequest2 = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).build()
val uploadRequest3 = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).build()
WorkManager.getInstance().enqueue(uploadRequest1, uploadRequest2, uploadRequest3)


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

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

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

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

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

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

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

// PhotoCompressWorker.kt

class PhotoCompressWorker : Worker() {
    override fun doWork(): WorkerResult {
        val originalFilePath = inputData.getString("file_path", null)
        return if (originalFilePath != null) {
            val compressedPath = compressPhoto()
            outputData = Data.Builder().apply {
                putString("file_path", compressedPath)
            }.build()
            WorkerResult.SUCCESS
        } else {
            WorkerResult.FAILURE
        }
    }

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

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

// PhotoUploadWorker.kt

class PhotoUploadWorker : Worker() {
    override fun doWork(): WorkerResult {
        val inputData = getInputData()
        val filePath = inputData.getString("file_path", null)
        val userId = inputData.getString("user_id", null)
        val token = inputData.getString("token", null)
        val result = uploadPhotoToWebServer(filePath, userId, token)
        return when (result.status) {
            UploadResult.SUCCESS -> {
                outputData = Data.Builder().apply {
                    putString("url", result.url)
                    putLong("timestamp", result.timestamp)
                }.build()
                WorkerResult.SUCCESS
            }
            else -> WorkerResult.FAILURE
        }
    }

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

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

// Compress
val compressData = Data.Builder().apply {
    putString("file_path", "/path/to/file")
}.build()
val compressConstraints = Constraints.Builder().apply {
    setRequiresStorageNotLow(true)
}.build()
val compressRequest = OneTimeWorkRequest.Builder(PhotoCompressWorker::class.java).apply {
    setInputData(compressData)
    setConstraints(compressConstraints)
}.build()

// Upload
val uploadData = Data.Builder().apply {
    putString("user_id", "Akexorcist")
    putString("token", "1AG7zd8as013jkdfjl2h4...")
}
val uploadConstraints = Constraints.Builder().apply {
    setRequiredNetworkType(NetworkType.CONNECTED)
}.build()
val uploadRequest = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).apply {
    setConstraints(uploadConstraints)
}.build()

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

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

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


        ลักษณะโค้ดก็จะให้กลิ่นอายของ Compose ใน ReactiveX มากๆ ต่างกันแค่ว่า Data Flow ของ WorkManager จะเป็นคลาส Data เสมอ ไม่ได้เป็น Model 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 = Data.Builder().apply {
    putString("file_path", "/path/to/file")
}.build()
val compressConstraints = Constraints.Builder().apply {
    setRequiresStorageNotLow(true)
}.build()
val compressRequest = OneTimeWorkRequest.Builder(PhotoCompressWorker::class.java).apply {
    setInputData(compressData)
    setConstraints(compressConstraints)
}.build()

// Upload
val uploadData = Data.Builder().apply {
    putString("user_id", "Akexorcist")
    putString("token", "1AG7zd8as013jkdfjl2h4...")
}
val uploadConstraints = Constraints.Builder().apply {
    setRequiredNetworkType(NetworkType.CONNECTED)
}.build()
val uploadRequest = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).apply {
    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 = OneTimeWorkRequest.Builder(PhotoCompressWorker::class.java).build()
val compressRequest2 = OneTimeWorkRequest.Builder(PhotoCompressWorker::class.java).build()
val compressRequest3 = OneTimeWorkRequest.Builder(PhotoCompressWorker::class.java).build()
val uploadRequest = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).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 = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).apply {
    ...
    setInputMerger(ArrayCreatingInputMerger::class.java)
}.build()

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

// PhotoUploadWorker.kt

class PhotoUploadWorker : Worker() {
    override fun doWork(): WorkerResult {
        val filePath = inputData.getStringArray("file_path")
        val userId = inputData.getStringArray("user_id")[0]
        val token = inputData.getStringArray("token")[0]
        ...
    }
    ...
}

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

// PhotoUploadWorker.kt

class PhotoUploadWorker : Worker() {
    override fun doWork(): WorkerResult {
        val filePath = inputData.getStringArray("file_path")
        val userId = inputData.getStringArray("user_id")[0]
        val token = inputData.getStringArray("token")[0]
        var success = emptyArray<String>()
        var failure = emptyArray<String>()
        filePath.forEach {
            val result = uploadPhotoToWebServer(it, userId, token)
            when (result.status) {
                UploadResult.SUCCESS -> success += it
                else -> failure += it
            }
        }
        outputData = Data.Builder().apply {
            putStringArray("success", success)
            putStringArray("failure", failure)
            putBoolean("all_uploaded", failure.isEmpty())
        }.build()
        return if (success.isNotEmpty()) {
            WorkerResult.SUCCESS
        } else {
            WorkerResult.FAILURE
        }
    }
    ...
}

        เพื่อให้รองรับการ 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 = OneTimeWorkRequest.Builder(PhotoCompressWorker::class.java).build()
val compressRequest2 = OneTimeWorkRequest.Builder(PhotoCompressWorker::class.java).build()
val compressRequest3 = OneTimeWorkRequest.Builder(PhotoCompressWorker::class.java).build()
val uploadRequest = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).build()

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

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

val compressRequest1 = OneTimeWorkRequest.Builder(PhotoCompressWorker::class.java).apply {
    ...
    addTag("compress_photo")
    addTag("photo_uploader")
}.build()
val compressRequest2 = OneTimeWorkRequest.Builder(PhotoCompressWorker::class.java).apply {
    ...
    addTag("compress_photo")
    addTag("photo_uploader")
}.build()
val compressRequest3 = OneTimeWorkRequest.Builder(PhotoCompressWorker::class.java).apply {
    ...
    addTag("compress_photo")
    addTag("photo_uploader")
}.build()
val uploadRequest = OneTimeWorkRequest.Builder(PhotoUploadWorker::class.java).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().getStatusesByTag("compress_photo").observe(this, Observer {
      it?.forEach {
        if (it.state.isFinished) {
          ...
        }
      }
    })
  }
}

        โดยคำสั่ง getStatusesByTag(tag) จะส่งผลลัพธ์ออกมาเป็น LiveData<List<WorkStatus>> เพื่อให้ผู้ที่หลงเข้ามาอ่าน 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, work1, work2, ..., workN)

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

val syncDatabaseRequest = OneTimeWorkRequest.Builder(SyncDatabaseWorker::class.java).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 กับการเขียนเทส

        ไว้เดี๋ยวมาเติมทีหลัง พิมพ์จนนิ้วจะหักละ ขอพักก่อนนะ

สรุป

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

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

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

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

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

        • Schedule tasks with WorkManager [Android Developers]
        • WorkManager API Reference [Android Developers]
        • Android Jetpack: easy background processing with WorkManager (Google I/O '18) [YouTube]




เหล่าพันธมิตรแอนดรอยด์

Devahoy Layer Net NuuNeoI The Cheese Factory Somkiat CC Mart Routine Artit-K Arnondora Kamonway Try to be android developer Oatrice Benz Nest Studios Kotchaphan@Medium Jirawatee@Medium Travispea@Medium