บทความนี้เป็นเรื่องของการ Save และ Restore UI State บน Android โดยเจ้าของบล็อกเขียนเพื่อเพิ่มเติมเนื้อหาจากที่พี่เนย NuuNeoI เคยเขียนให้อ่านกันใน Best Practices ของการ Save/Restore State ของ Activity และ Fragment เพื่อให้ผู้ที่หลงเข้ามาอ่านหลายๆคนได้เข้าใจมากขึ้น

Best Practices ของการ Save/Restore State ของ Activity และ Fragment (StatedFragment deprecated แล้วจ้า)
รอบที่แล้วเรานำเสนอ วิธีการ Save/Restore Fragment State ด้วย StatedFragment ที่เราเขียนขึ้นมาไป ได้รับการตอบรับเยอะมาก ต้องขอขอบพระคุณทุกท่านครับอย่างไรก็ตาม StatedFragment เป็นการ Break P

บทความในซีรีย์เดียวกัน

UI State คือ?

ตัวแปรใด ๆ ก็ตามที่ประกาศไว้ใน Activity, Fragment หรือ View เพื่อเก็บค่าต่าง ๆ ที่เกี่ยวข้องกับการแสดงบนหน้าจอจะเรียกว่า UI State ทั้งหมด

ยกตัวอย่างเช่น แอปมีการเรียกข้อมูลจาก Web Service เพื่อดึงข้อมูลของผู้ใช้มาแสดงที่หน้า Profile ของแอป

ข้อมูลที่ได้จาก Web Service ก็จะถือว่าเป็น UI State เช่นกัน เพราะว่าข้อมูลดังกล่าวถูกนำไปแสดงผลบนหน้าจอ รวมไปถึงตัวแปรที่เก็บสถานะของการโหลดข้อมูลจาก Web Service ด้วยเช่นกัน (ถ้ามี)

ทำไมนักพัฒนาต้องคอย Save และ Restore UI State ด้วย?

เนื่องจากแอนดรอยด์นั้นถูกออกแบบมาให้ทำงานได้ต่อเนื่องและรวดเร็วที่สุดเท่าที่ทำได้ รวมไปถึงรองรับการทำงานในหลากหลายรูปแบบ จึงทำให้ทีมพัฒนาแอนดรอยด์ได้ออกแบบ System Bahavior ที่เรียกว่า System-initiated UI State Dismissal ขึ้นมา โดยประกอบไปด้วย 2 รูปแบบดังนี้

  • Application Destruction
  • Configuration Changes

Application Destruction (ขอเรียกสั้น ๆ ว่า App Destruction)

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

โดยแอปดังกล่าวจะถูก Android System ทำลายในระดับ Application Process แต่ก่อนที่จะถูกทำลายนั้น แอปสามารถฝาก UI State ล่าสุดให้ Android System เก็บไว้ชั่วคราวได้ และเมื่อผู้ใช้สลับกลับมาที่แอปนี้อีกครั้ง Android System ก็จะสร้าง Application Process ขึ้นมาใหม่ทั้งหมด พร้อมกับส่ง UI State ที่ฝากไว้กลับไปให้ด้วย เพื่อให้แอปทำงานต่อจากเดิมได้โดยใช้ข้อมูลจาก UI State ที่เคยฝากไว้

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

Configuration Changes (ขอเรียกสั้น ๆ ว่า Config Changes)

ระบบแอนดรอยด์ออกแบบมาให้รองรับการใช้งานหลากหลายรูปแบบ เช่น รองรับการหมุนหน้าจอ หรือฟีเจอร์อย่าง Multi-Window ซึ่งรูปแบบเหล่านี้จะเรียกว่า Configuration และเมื่อ Configuration เหล่านี้มีการเปลี่ยนแปลงในระหว่างการใช้งาน (Runtime Changes) ก็จะเกิดสิ่งที่เรียกว่า Config Changes ขึ้น โดย Activity ที่กำลังทำงานอยู่ (และต่อจากนี้) จะถูก Recreate ใหม่เพื่ออัปเดตค่า Configuration เข้าไปใน Context

สำหรับเงื่อนไขในการเกิด Configuration Changes นั้นมีได้มากมาย ไม่ได้มีแค่การหมุนหน้าจอเท่านั้น สามารถดูเรื่องราวของ Config Changes แบบเต็ม ๆ ได้ในบทความ Configuration Changes เรื่องสำคัญที่ Android Dev ไม่ควรพลาด

Configuration Changes เรื่องสำคัญที่ Android Dev ไม่ควรพลาด
อาจจะเคยได้ยินคำว่า Configuration Changes กันมาบ้าง แต่รู้หรือไม่ว่าจริง ๆ แล้วมันคืออะไร และทำไมถึงสำคัญมากขนาดที่ว่าถ้าไม่จัดการอย่างถูกต้องก็จะทำให้แอปมีปัญหาไปเลยก็เป็นได้

ความเหมือนที่แตกต่างกัน

ถึงแม้ว่า System-initiated UI State Dismissal จะถูกแบ่งออกเป็น App Destruction และ Config Changes ก็ตาม แต่ผลกระทบที่เกิดขึ้นกับนักพัฒนานั้นจะคล้าย ๆ กันตรงที่ Activity จะทำการเคลียร์ค่าต่างๆของ View หรือ Fragment ที่อยู่ใน Activity นั้นๆทิ้ง รวมไปถึงตัวมันเองด้วย แต่ในระหว่างนั้น Activity ก็จะทำการเก็บ UI State ไว้ด้วย

จากนั้นก็จะสร้าง Activity ขึ้นมาเพื่อแทนที่ของเก่า แล้วดึง UI State ที่เก็บไว้กลับมาเพื่อคืนค่าทั้งหมดให้กับ View หรือ Fragment ที่เคยอยู่ใน Activity ตัวก่อนหน้า ซึ่งขั้นตอนดังกล่าวจะไม่รวมไปถึง UI State ที่นักพัฒนาสร้างขึ้นมาใช้งานเอง ไม่ว่าจะเป็นของ Activity, Fragment หรือ Custom View ก็ตาม

ดังนั้นเพื่อให้แอปกลับมาทำงานต่อจากเดิมได้อย่างถูกต้อง นักพัฒนาก็จะต้องทำการเก็บค่าของตัวแปรต่าง ๆ ที่เป็น UI State ด้วยตัวเองทุกครั้ง

แต่จุดที่แตกต่างระหว่าง App Destruction กับ Config Changes ก็คือ

  • Application Destruction เกิดขึ้นในระดับ Application Process
  • Configuration Changes เกิดขึ้นในระดับ Activity และ Context

โดย App Destruction จะทำให้ Application Process ถูกทำลาย ซึ่งจะส่งผลไปถึงโค้ดที่เขียนไว้ทั้งหมด แม้กระทั่ง Singleton หรือ Static ก็ตาม ในขณะที่ Config Changes จะมีผลกับ Global Variable ที่อยู่ใน Activity, Fragment และ View เท่านั้น

การทำให้ UI State รองรับกับ App Destruction และ Config Changes

ในคลาส Activity จะมี Override Method อยู่ 2 ตัวที่เป็นตัวสำคัญสำหรับการเก็บ UI State เมื่อเกิดเงื่อนไขดังกล่าว ซึ่งก็คือ onSaveInstanceState(outState: Bundle) และ onRestoreInstanceState(savedInstanceState: Bundle)

// MainActivity.kt
class MainActivity : AppCompatActivity() {
    /* ... */

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
    }

    override fun onRestoreInstanceState(savedInstanceState: Bundle) {
        super.onRestoreInstanceState(savedInstanceState)
    }
}

Method ทั้ง 2 จะถูกเรียกในตอนที่ App Destruction หรือ Config Changes ทำงานนั่นเอง โดยที่ onSaveInstanceState(outState: Bundle) จะใช้เก็บ UI State ส่วน onRestoreInstanceState(savedInstanceState: Bundle) มีไว้คืนค่า UI State หลังจาก Activity ถูกสร้างขึ้นมาใหม่เพื่อให้ทำงานต่อจากเดิมได้ทันทีนั่นเอง

ภาพข้างบนนี้ไม่ใช่ Activity Lifecycle นะ แต่ภาพนี้เป็นลำดับการทำงานของ Activity เมื่อเกิด App Destruction หรือ Config Changes จึงไม่มี Start และ Stop อยู่ในภาพ

โดยนักพัฒนาจะต้องเก็บข้อมูลของ UI State ไว้ใน outState ตอนที่ onSaveInstanceState(outState: Bundle) ทำงาน และตอนที่ onRestoreInstanceState(savedInstanceState: Bundle) ทำงาน ก็ให้คืนค่ากลับมาจาก savedInstanceState นั่นเอง

จะเห็นว่าการเก็บ UI State ในนี้ได้นั้นจะต้องอยู่ในรูปของ Bundle เท่านั้น นั่นหมายความว่าข้อมูลเหล่านั้นจะต้องอยู่ในรูปของ Primitive Data, Parcelable หรือ Serializable เท่านั้น ซึ่งเป็นไปตาม Best Practice ของ UI State ที่ควรเก็บข้อมูลที่อยู่ในรูปแบบที่เรียบง่ายที่สุดและขนาดไม่ใหญ่จนเกินไป

ในขั้นตอนนี้ข้อมูลที่เก็บไว้ใน Bundle จะมีข้อมูลกี่ชุดก็ได้ แต่รวมแล้วห้ามเกิน 1MB เด็ดขาด

โดย Bundle ที่เก็บข้อมูลของ UI State ทั้งหมดไว้ก็จะถูกเก็บไว้ที่ Android System ชั่วคราวเพื่อรอให้ Activity Recreate ขึ้นมาใหม่อีกครั้ง แล้วส่ง Bundle ตัวนั้นกลับมาให้ Activity ตัวใหม่ทั้งใน onCreate(savedInstanceState: Bundle?) และ onRestoreInstanceState(savedInstanceState: Bundle)

นั่นหมายความว่า savedInstanceState ที่อยู่ใน onCreate(...) และ onRestoreInstanceState(...) เป็นตัวเดียวกันนั่นเอง  แต่การคืนค่า UI State ควรทำใน onRestoreInstanceState(...) เพียงอย่างเดียว (เพราะนั่นคือหน้าที่ของ Method นี้)

ส่วน savedInstanceState ใน onCreate(...) จะใช้สำหรับเช็คว่า Activity ตัวนี้ถูกเรียก onCreate(...) เพราะว่าถูกสร้างขึ้นมาใหม่จริง ๆ หรือว่าถูกสร้างขึ้นมาจากการ Restore จาก Android System

// MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */
        if(savedInstanceState == null) {
            // Fresh New
        } else {
            // Restore from Android System
        }
    }
}

ดังนั้นเวลาเกิด App Destruction หรือ Config Changes สิ่งที่นักพัฒนาต้องจัดการกับ UI State เพื่อไม่ให้หายไป ก็จะออกมาเป็นโค้ดในรูปแบบนี้

// MainActivity.kt
class MainActivity : AppCompatActivity() {
    private var name: String? = null

    /* ... */

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.apply {
            putString(EXTRA_NAME, name)
        }
    }

    override fun onRestoreInstanceState(savedInstanceState: Bundle) {
        super.onRestoreInstanceState(savedInstanceState)
        savedInstanceState.apply {
            name = getString(EXTRA_NAME)
        }
    }
}

แต่ในการใช้งานจริงมักจะสร้าง UI State เป็นลักษณะ Data Class มากกว่า ดังนั้นจะต้องทำ Data Class ดังกล่าวให้เป้น Parcelable ด้วย เพื่อให้เก็บลงใน Bundle ได้ง่าย

ปัญหาของ Asynchronous Callback กับ Config Changes

สำหรับ UI State อาจจะจัดการไม่ยากซักเท่าไร แต่สิ่งที่ได้รับผลกระทบจาก Config Changes มากที่สุดนั้นจะเป็นการทำงานที่เป็นแบบ Asynchronous Callback ทั้งหมดที่มีการเรียกใช้งานภายใน Activity นั้นๆ

ทั้งนี้ก็เพราะว่า Event Listener ใด ๆ ก็ตามที่สร้างไว้ใน Activity ไม่ถือว่าเป็น UI State และไม่สามารถเก็บลงใน Bundle ได้เลย ดังนั้นในตอนที่เกิด Config Changes จะทำให้ Event Listener ที่สร้างขึ้นมาสำหรับ Asynchronous Callback ถูกเคลียร์หายไปพร้อม ๆ กับ Activity ทันที

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

class MainActivity : AppCompatActivity() {
    private val textViewName: TextView = /* ... */
    private val api = /* ... */
    
    private var profile: Profile? = null

    fun getUserProfile(userId: String) {
		api.getUserProfile(userId) { profile: Profile ->
            this.profile = profile
            textViewName.text = profile.name
        }
    }
}

ในคำสั่ง getUserProfile(userId: String) จะเป็นการเรียกข้อมูลของผู้ใช้จาก Web Service ซึ่งต้องใช้ระยะเวลาในการทำงาน จึงนิยมสร้างเป็น Asynchronous Callback เพื่อให้ข้อมูลส่งกลับมาในรูปแบบของ Event Listener แล้วจึงนำข้อมูลที่ได้ไปใช้งาน

แต่ในระหว่างที่รอข้อมูลจาก Web Service นั้น ถือมี Config Changes เกิดขึ้น จะทำให้ Event Listener ตัวนั้นหายไปพร้อม ๆ กับ Activity ทันที (ในตอนที่มีการ Recreate) และเมื่อ Web Service ส่งข้อมูลกลับมาให้หลังจากนั้น ก็จะไม่เกิดอะไรขึ้น เพราะ Event Listener ได้หายไปแล้ว

ส่วน App Destruction จะไม่ส่งผลอะไรกับ Asynchronous Callback มากนัก เพราะจะเกิดขึ้นเฉพาะตอนที่แอปไม่ได้ถูกใช้งานเป็นระยะเวลานานเท่านั้น ซึ่งการทำงานที่ต้องใช้ Asynchronous Callback จะใช้เวลาในการทำงานน้อยกว่า 10 วินาทีอยู่แล้ว

วิธีแก้ปัญหา Asynchronous Callback กับ Config Changes

การแก้ปัญหาดังกล่าวนั้นมีหลายวิธี ขึ้นอยู่กับว่านักพัฒนาออกแบบโครงสร้างของโค้ดอย่างไร

  • ใช้ ViewModel เป็นตัวกลางและเก็บข้อมูลแทน Activity (แนะนำวิธีนี้ที่สุด)
  • ใช้ RxLifecycle เข้ามาช่วยจัดการ
  • ใช้ AsyncTaskLoader ที่สามารถผูกตัวเองเข้ากับ Activity ได้โดยไม่สนใจว่าจะมีการ Recreate Activity นั้น ๆ ขึ้นมาใหม่
  • ใช้ Event Bus แทน Asynchronous Callback
  • ฯลฯ

ViewModel ก็ยังคงถูกทำลายเมื่อเกิด App Destruction

ถึงแม้ว่า ViewModel จะถูกออกแบบมาให้ทำงานได้อยู่ถึงแม้ว่าจะเกิด Config Changes ก็ตาม แต่เมื่อใดก็ตามที่เกิด App Destruction ก็จะถูกเคลียร์ทิ้งไม่ต่างอะไรกับตัวแปรที่เป็น Singleton หรือ Static อยู่ดี

โดยนักพัฒนาส่วนใหญ่มักจะเก็บ UI State ไว้ใน ViewModel แทน ดังนั้นถ้าเกิด Config Changes ก็จะไม่มีปัญหาอะไร เพราะ ViewModel สามารถทำงานได้ปกติ แต่ถ้าเกิด App Desctruction ก็สามารถทำให้ค่าต่าง ๆ ที่เก็บไว้ใน ViewModel นั้นหายไปได้ รวมไปถึง UI State ด้วยนั่นเอง

ดังนั้นนอกจากจะเก็บ​ UI State แล้ว ก็ควร Save และ Restore ให้ถูกต้อง โดยในปัจจุบันนี้ก็ได้มี SavedStateHandle ที่จะช่วยให้จัดการกับ UI State ที่อยู่ใน ViewModel ได้ง่ายขึ้นแล้วด้วย

สรุป

เพื่อประสบการณ์ในการใช้งานที่ต่อเนื่องและลื่นไหลบนแอนดรอยด์ จึงทำให้ผู้พัฒนาแอนดรอยด์ได้ออกแบบ System Behavior ที่เรียกว่า System-initiated UI State Dismissal ขึ้นมา ดังนั้นนักพัฒนาควรจัดการ UI State ในแอปของตัวเองให้สอดคล้องกับการทำงานดังกล่าวด้วย

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