09 May 2020

จัดการกับ State Changes ใน Custom View อย่างไรให้ครอบคลุม (รวมไปถึง Inherited Custom View)

Updated on

State Changes ถือว่าเป็นเรื่องพื้นฐานที่นักพัฒนาแอนดรอยด์ควรจัดการเรื่องนี้ไว้ในแอปของตัวเองทุกครั้ง และในวันนี้เจ้าของบล็อกจะมาเล่าวิธีการจัดการกับ State Changes ในระดับของ View บน Custom View อย่างถูกต้องกัน

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

โดยปกติแล้ว View ที่นักพัฒนาใช้งานกันอยู่นั้นจะจัดการกับ State Changes   ให้ในตัวอยู่แล้ว แต่สำหรับนักพัฒนาที่สร้าง Custom View ขึ้นมาใช้งานเอง ก็อาจจะต้องจัดการ State Changes บางส่วนด้วยตัวเอง 

ยกตัวอย่างเช่น เจ้าของบล็อกสร้าง Custom View ขึ้นมาเพื่อใช้งานเองโดยมีโค้ดหน้าตาแบบนี้

// AwesomeView.kt
class AwesomeView : FrameLayout {
    private var title: String? = null
    private var description: String? = null
    private var dividerColorResId: Int = 0
    ...
}

จะเห็นว่า Custom View ดังกล่าวมี State ของตัวเองอยู่ 3 ตัวด้วยกันคือ title, description และ dividerColorResId และถ้าไม่ทำอะไรกับมันเลย ค่าเหล่านี้ก็สามารถโดนเคลียร์หายไปได้เมื่อเกิด Configuration Changes ในแอป

นั่นจึงทำให้นักพัฒนาต้องเพิ่มคำสั่งสำหรับจัดการกับ State Changes ให้กับค่าเหล่านี้ ซึ่งจะต้องเพิ่มคำสั่งเข้าไปดังนี้

// AwesomeView.kt
class AwesomeView : FrameLayout {
    private var title: String? = null
    private var description: String? = null
    private var dividerColorResId: Int = 0
    ...

    override fun onSaveInstanceState(): Parcelable? {
        val superState = super.onSaveInstanceState()
        val state = SavedState(superState)
        state.title = this.title
        state.description = this.description
        state.dividerColorResId = this.dividerColorResId
        return state
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        when (state) {
            is SavedState -> {
                super.onRestoreInstanceState(state.superState)
                this.title = state.title
                this.description = state.description
                this.dividerColorResId = state.dividerColorResId
                // Restore view's state here
            }
            else -> {
                super.onRestoreInstanceState(state)
            }
        }
    }

    internal class SavedState : BaseSavedState {
        var title: String? = null
        var description: String? = null
        var dividerColorResId: Int = 0

        constructor(superState: Parcelable?) : super(superState)

        constructor(source: Parcel) : super(source) {
            title = source.readString()
            description = source.readString()
            dividerColorResId = source.readInt()
        }

        override fun writeToParcel(out: Parcel, flags: Int) {
            super.writeToParcel(out, flags)
            out.writeString(title)
            out.writeString(description)
            out.writeInt(dividerColorResId)
        }

        companion object {
            @JvmField
            val CREATOR: Parcelable.Creator<SavedState> = object : Parcelable.Creator<SavedState> {
                override fun createFromParcel(source: Parcel): SavedState {
                    return SavedState(source)
                }

                override fun newArray(size: Int): Array<SavedState> {
                    return newArray(size)
                }
            }
        }
    }
}

ซึ่งจะเห็นได้ว่า View ก็จะมี onSaveInstanceState() กับ onRestoreInstanceState(...) ให้ใช้เหมือนกับ Activity เพื่อให้นักพัฒนาสามารถ Save/Restore State ของ Custom View ผ่าน Override Method ทั้ง 2 ตัวนี้นั่นเอง เพียงแค่มีคำสั่งข้างในนั้นที่แตกต่างออกไป

หัวใจสำคัญก็คือการสร้าง SavedState จาก BaseSavedState เพื่อใช้เก็บ State ของ Custom View และนอกจากคำสั่งสำหรับเก็บ/อ่านค่าสำหรับตัวแปรแต่ละตัวแล้ว จะต้องสร้างสิ่งที่เรียกว่า Creator ไว้ด้วย เพื่อให้ระบบแอนดรอยด์สามารถ Restore ค่ากลับมาได้ในกรณีที่ถูกคืน Memory ในระดับ Application Process

โดยคำสั่งข้างต้นนี้สามารถใช้งานได้กับ Custom View ทั่วๆไปโดยไม่เกิดปัญหาอะไร

ถ้าเป็น Inherited Custom View ที่แต่ละ Hierarchy ก็มี State ของตัวเองล่ะ?

โดยปกติแล้วนักพัฒนาส่วนใหญ่จะสร้าง Custom View ขึ้นมาเพื่อใช้งานด้วยจุดประสงค์เดียวเท่านั้น จึงทำให้ Custom View ที่ใช้งานกันมักจะสร้างจาก ViewGroup และทำงานจบในตัว จึงสามารถใช้ BaseSavedState เพื่อจัดการกับ State ได้

แต่สำหรับ Custom View จำพวก UI Library ที่มีรูปแบบของ UI ที่หลากหลาย มักจะทำ Inherited Custom View โดยแยก Custom View เป็น Hierarchy เพื่อให้โค้ดสามารถ Reusable และ Maintain ได้ง่าย ยกตัวอย่างเช่น UI Library ตัวหนึ่งของเจ้าของบล็อกที่ได้ออกแบบความสัมพันธ์ของ Custom View แต่ละตัวไว้แบบนี้


ด้วยความสัมพันธ์แบบนี้จึงทำให้ Custom View ที่นักพัฒนานำไปใช้งานจริงนั้นเป็น Inherited Custom View ที่อยู่ชั้นล่างสุดของ Hierarchy และถ้าอยากจะสร้าง Custom View ในรูปแบบอื่นๆนอกเหนือจากที่มีอยู่ ก็สร้าง Custom View ตัวใหม่ขึ้นมาโดยสืบทอดจาก AnimatedCustomView เหมือนกับตัวอื่นๆนั่นเอง

จึงเลี่ยงไม่ได้ที่ Custom View แต่ละตัวนั้นจะมี State เป็นของตัวเอง ทำให้นักพัฒนาต้องจัดการกับ State Changes ใน Custom View แต่ละตัวด้วย

และปัญหาที่จะเกิดขึ้นก็คือ คำสั่งก่อนหน้านี้ไม่ได้รองรับ Inherited Custom View น่ะสิ!

ปัญหาจากการใช้ BaseSavedState กับ Inherited Custom View

เพื่อให้เห็นภาพมากขึ้น เจ้าของบล็อกจะทดสอบด้วยการสร้าง Inherited Custom View ขึ้นมา โดยมีรูปแบบง่ายๆตามนี้


โดยให้ BaseView เป็นทำหน้าที่ดูแล title กับ description

// BaseView.kt
abstract class BaseView : FrameLayout {
    private var title: String? = null
    private var description: String? = null

    ...

    override fun onSaveInstanceState(): Parcelable? {
        val superState = super.onSaveInstanceState()
        val state = SavedState(superState)
        state.title = this.title
        state.description = this.description
        return state
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        when (state) {
            is SavedState -> {
                super.onRestoreInstanceState(state.superState)
                this.title = state.title
                this.description = state.description
                // Restore view's state here
            }
            else -> {
                super.onRestoreInstanceState(state)
            }
        }
    }

    internal class SavedState : BaseSavedState {
        var title: String? = null
        var description: String? = null

        constructor(superState: Parcelable?) : super(superState)

        constructor(source: Parcel) : super(source) {
            title = source.readString()
            description = source.readString()
        }

        override fun writeToParcel(out: Parcel, flags: Int) {
            super.writeToParcel(out, flags)
            out.writeString(title)
            out.writeString(description)
        }

        companion object {
            @JvmField
            val CREATOR: Parcelable.Creator<SavedState> = object : Parcelable.Creator<SavedState> {
                override fun createFromParcel(source: Parcel): SavedState {
                    return SavedState(source)
                }

                override fun newArray(size: Int): Array<SavedState> {
                    return newArray(size)
                }
            }
        }
    }
}

ส่วน AwesomeView ก็จะดูแลแค่ dividerColorResId เท่านั้น

// AwesomeView.kt
class AwesomeView : BaseView {
    private var dividerColorResId: Int = 0

    ...

    override fun onSaveInstanceState(): Parcelable? {
        val superState = super.onSaveInstanceState()
        val state = SavedState(superState)
        state.dividerColorResId = this.dividerColorResId
        return state
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        when (state) {
            is SavedState -> {
                super.onRestoreInstanceState(state.superState)
                this.dividerColorResId = state.dividerColorResId
                // Restore view's state here
            }
            else -> {
                super.onRestoreInstanceState(state)
            }
        }
    }

    internal class SavedState : BaseSavedState {
        var dividerColorResId: Int = 0

        constructor(superState: Parcelable?) : super(superState)

        constructor(source: Parcel) : super(source) {
            dividerColorResId = source.readInt()
        }

        override fun writeToParcel(out: Parcel, flags: Int) {
            super.writeToParcel(out, flags)
            out.writeInt(dividerColorResId)
        }

        companion object {
            @JvmField
            val CREATOR: Parcelable.Creator<SavedState> = object : Parcelable.Creator<SavedState> {
                override fun createFromParcel(source: Parcel): SavedState {
                    return SavedState(source)
                }

                override fun newArray(size: Int): Array<SavedState> {
                    return newArray(size)
                }
            }
        }
    }
}

เพื่อให้ Custom View ทั้ง 2 ตัวสามารถจัดการกับ State Changes ในตัวเองได้ จะต้องมีการสร้าง SavedState แยกของใครของมัน

และเมื่อนำ AwesomeView ไปใช้งาน ก็จะดูเหมือนกับว่าใช้งานได้ปกติ และตอน Configuration Changes ก็สามารถจัดการกับ State Changes ได้อย่างถูกต้อง จนกระทั่ง Application Process ถูกทำลาย ซึ่งสามารถทดสอบได้ด้วยการย่อแอปแล้วกดปุ่ม Terminate selected Android Application 


โดยปกติแล้ว ไม่ใช่แค่ Activity หรือ Fragment เท่านั้นที่จะโดนระบบทำลายเพื่อคืน Memory แต่รวมไปถึง Application Process ซึ่งถือเป็นหนึ่งใน State Changes เช่นกัน ดังนั้นเมื่อแอปเปิดขึ้นมาใหม่อีกครั้งก็ควรจะกลับมาทำงานต่อได้ปกติ แต่ทว่า...


แอปที่ควรจะเปิดขึ้นมาแล้วทำงานต่อจากเดิมได้ ก็ดันพังขึ้นมาทันที พร้อมกับข้อความใน Logcat ที่มีใจความสำคัญว่า

android.os.BadParcelableException: ClassNotFoundException when unmarshalling: com.akexorcist.example.customviewtest.BaseView$SavedState

ข้อความดังกล่าวสามารถสรุปได้ว่า "ในระหว่างที่ทำการ Unmarshalling ข้อมูลที่เก็บไว้ใน Parcel กลับออกมานั้น หาคลาสที่ชื่อว่า BaseView.SavedState ไม่เจอ" 

จึงทำให้การใช้ BaseSavedState กับ Parcelable.Creator จะไม่สามารถใช้กับ Inherited Custom View ได้ นั่นก็เพราะว่า SavedState ใน AwesomeView จะต้องกำหนด ClassLoader ของ SavedState ใน BaseView  ด้วย เพื่อให้แอปสามารถคืนค่ากลับไปยังคลาส SavedState ที่ถูกต้อง ซึ่งแน่นอนว่าการ Creator ที่ถูกสร้างด้วย Parcelable.Creator ไม่สามารถกำหนด ClassLoader ได้นั่นเอง 

ในขณะเดียวกันการใช้ BaseSavedState เพื่อให้สามารถกำหนด ClassLoader ได้นั้น ก็จะมีเฉพาะใน API Level 24 ขึ้นไปเท่านั้น 

การจัดการกับ State Changes ใน Custom View ให้รองรับ Inherited Custom View

ด้วยข้อจำกัดของ BaseSavedState และ Parcelable.Creator จึงทำให้นักพัฒนาต้องเปลี่ยนไปใช้คลาสอื่นๆแทน ดังนี้

• สร้าง SavedState จาก AbsSavedState ของ Android Jetpack  แทนการใช้ BaseSavedState
• สร้าง Creator จาก Parcelable.ClassLoaderCreator แทนการใช้ Parcelable.Creator

เปลี่ยน BaseSavedState ไปใช้เป็น AbsSavedState ของ Android Jetpack

จากเดิมจะสร้าง SavedState ด้วยการใช้ BaseSavedState แบบนี้

internal class SavedState : BaseSavedState {
    var title: String? = null
    var description: String? = null

    constructor(superState: Parcelable?) : super(superState)

    constructor(source: Parcel) : super(source) {
        title = source.readString()
        description = source.readString()
    }

    override fun writeToParcel(out: Parcel, flags: Int) {
        super.writeToParcel(out, flags)
        out.writeString(title)
        out.writeString(description)
    }
}

ก็ให้เปลี่ยนไปใช้เป็น AbsSavedState ที่รองรับ Inherited Custom View ได้ 

แต่ทว่า AbsSavedState ที่อยู่ใน Android Framework (android.view.AbsSavedState) จะติดปัญหาว่า Constructor ที่รองรับ ClassLoader ได้นั้น มีเฉพาะ API Level 24 ขึ้นไป จึงแนะนำให้ใช้เป็น AbsSavedState ใน Android Jetpack (androidx.customview.view.AbsSavedState) แทน ซึ่งจัดการเรื่อง Backward Compatibility ให้เรียบร้อยแล้ว โดยจะอยู่ใน CustomView Library ของ Android Jetpack

androidx.customview:customview:<LATEST_VERSION>

ถ้าใช้ AppCompat Library ก็ไม่จำเป็นต้องเพิ่มเข้าไป เพราะข้างใน AppCompat Library จะมี CustomView Library ให้อยู่แล้ว

ในการใช้ AbsSavedState จะมีรูปแบบคำสั่งเหมือนกับ BaseSavedState ทั้งหมด

internal class SavedState : AbsSavedState {
    var title: String? = null
    var description: String? = null

    constructor(superState: Parcelable) : super(superState)

    constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) {
        title = source.readString()
        description = source.readString()
    }

    override fun writeToParcel(out: Parcel, flags: Int) {
        super.writeToParcel(out, flags)
        out.writeString(title)
        out.writeString(description)
    }

    ...
}

และเนื่องจากเวลาสร้าง ClassLoader จะใช้ทั้ง Parcel และ ClassLoader เสมอ จึงสามารถลบ Constructor ที่มีแค่ Parcel อย่างเดียวออกไปได้เลย เพราะไม่ได้ใช้แล้ว

เปลี่ยน Parcelable.Creator ไปใช้เป็น Parcelable.ClassLoaderCreator

จากเดิมจะสร้าง Creator ด้วยการใช้ Parcelable.Creator แบบนี้

@JvmField
val CREATOR: Parcelable.Creator<SavedState> = object : Parcelable.Creator<SavedState> {
    override fun createFromParcel(source: Parcel): SavedState {
        return SavedState(source)
    }

    override fun newArray(size: Int): Array<SavedState> {
        return newArray(size)
    }
}

ก็ให้เปลี่ยนไปใช้ Parcelable.ClassLoaderCreator แทน ซึ่งจะเป็นการสร้าง Creator ที่รองรับ ClassLoader ด้วย เพื่อให้สามารถคืนค่ากลับมาได้ถูกตัว

@JvmField
val CREATOR: Parcelable.ClassLoaderCreator<SavedState> = object : Parcelable.ClassLoaderCreator<SavedState> {
    override fun createFromParcel(source: Parcel, loader: ClassLoader): SavedState {
        return SavedState(source, loader)
    }

    override fun createFromParcel(source: Parcel): SavedState {
        return SavedState(source, null)
    }

    override fun newArray(size: Int): Array<SavedState> {
        return newArray(size)
    }
}

จะเห็นว่าใน Override Method ที่ชื่อว่า createFromParcel(...) ทั้ง 2 ตัว จะทำการสร้าง SavedState โดยมีการโยน ClassLoader เข้าไปด้วยเสมอ (ถ้าไม่มีก็โยน Null เข้าไปแทน) ซึ่งเป็น Constructor ที่ได้เตรียมไว้ใน SavedState นั่นเอง

ทวนคำสั่งทั้งหมดอีกครั้ง

เพื่อให้ SavedState ของ Custom View ทั้งหมด สามารถจัดการกับ State Changes ได้อย่างถูกต้อง เจ้าของบล็อกจึงเปลี่ยนคำสั่งของ SavedState จากของเดิมที่อยู่ใน BaseView ให้ใช้ AbsSavedState และ Parcelable.ClassLoaderCreator แทน

// BaseView.kt
abstract class BaseView : FrameLayout {
    private var title: String? = null
    private var description: String? = null
    ...

    override fun onSaveInstanceState(): Parcelable? {
        val superState: Parcelable? = super.onSaveInstanceState()
        superState?.let {
            val state = SavedState(superState)
            state.title = this.title
            state.description = this.description
            return state
        } ?: run {
            return superState
        }
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        when (state) {
            is SavedState -> {
                super.onRestoreInstanceState(state.superState)
                this.title = state.title
                this.description = state.description
                // Restore view's state here
            }
            else -> {
                super.onRestoreInstanceState(state)
            }
        }
    }

    internal class SavedState : AbsSavedState {
        var title: String? = null
        var description: String? = null

        constructor(superState: Parcelable) : super(superState)

        constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) {
            title = source.readString()
            description = source.readString()
        }

        override fun writeToParcel(out: Parcel, flags: Int) {
            super.writeToParcel(out, flags)
            out.writeString(title)
            out.writeString(description)
        }

        companion object {
            @JvmField
            val CREATOR: Parcelable.ClassLoaderCreator<SavedState> = object : Parcelable.ClassLoaderCreator<SavedState> {
                override fun createFromParcel(source: Parcel, loader: ClassLoader): SavedState {
                    return SavedState(source, loader)
                }

                override fun createFromParcel(source: Parcel): SavedState {
                    return SavedState(source, null)
                }

                override fun newArray(size: Int): Array<SavedState> {
                    return newArray(size)
                }
            }
        }
    }
}

รวมไปถึงใน AwesomeView ด้วยเช่นกัน (มี Custom View กี่ตัวใน Hierarchy ก็เปลี่ยนให้ครบทั้งหมดซะ)

// AwesomeView.kt
class AwesomeView : BaseView {
    private var dividerColorResId: Int = 0
    ...

    override fun onSaveInstanceState(): Parcelable? {
        val superState: Parcelable? = super.onSaveInstanceState()
        superState?.let {
            val state = SavedState(superState)
            state.dividerColorResId = this.dividerColorResId
            return state
        } ?: run {
            return superState
        }
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        when (state) {
            is SavedState -> {
                super.onRestoreInstanceState(state.superState)
                this.dividerColorResId = state.dividerColorResId
                // Restore view's state here
            }
            else -> {
                super.onRestoreInstanceState(state)
            }
        }
    }

    internal class SavedState : AbsSavedState {
        var dividerColorResId: Int = 0

        constructor(superState: Parcelable) : super(superState)

        constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) {
            dividerColorResId = source.readInt()
        }

        override fun writeToParcel(out: Parcel, flags: Int) {
            super.writeToParcel(out, flags)
            out.writeInt(dividerColorResId)
        }

        companion object {
            @JvmField
            val CREATOR: Parcelable.ClassLoaderCreator<SavedState> = object : Parcelable.ClassLoaderCreator<SavedState> {
                override fun createFromParcel(source: Parcel, loader: ClassLoader): SavedState {
                    return SavedState(source, loader)
                }

                override fun createFromParcel(source: Parcel): SavedState {
                    return SavedState(source, null)
                }

                override fun newArray(size: Int): Array<SavedState> {
                    return newArray(size)
                }
            }
        }
    }
}

เพียงเท่านี้ Custom View ที่เป็น Inherited Custom View ก็สามารถจัดการกับ State Changes ได้ ไม่ว่าจะเป็นตอน Configuration Changes หรือ Save/Restore Application Process ก็ตาม

สรุป

การใช้ BaseSavedState กับ Parcelable.Creator นั้นสามารถใช้งานได้ดีกับ Custom View ทั่วๆไป แต่เมื่อใดก็ตามที่จำเป็นต้องทำ Inherited Custom View (อย่างกรณีที่เจ้าของบล็อกที่ทำเป็น UI Library แล้วต้องการทำให้โค้ดสามารถ Maintain ได้ง่ายขึ้น) ก็อาจจะใช้ไม่ได้อีกต่อไป จึงต้องเปลี่ยนมาใช้ AbsSavedState และ Parcelable.ClassLoaderCreator แทนเพื่อให้รองรับกับ Custom View ในรูปแบบดังกล่าว และที่สำคัญควรใช้ AbsSavedState ที่อยู่ใน CustomView Library ที่อยู่ใน Android Jetpack ซะ จะได้ไม่ต้องเขียนโค้ดสำหรับ Backward Compatible เอง

แต่ถ้าจะให้ดี แนะนำให้เปลี่ยนมาใช้ AbsSavedState และ Parcelable.ClassLoaderCreator ไปเลยดีกว่า โค้ดใน Custom View ทั้งหมดจะได้ใช้เป็นรูปแบบเดียวกัน

สำหรับโค้ดตัวอย่างที่ใช้อธิบายในบทความนี้ สามารถเข้าไปดูโค้ดทั้งหมดกันได้ที่ Handle State Changes in Custom View [GitHub]

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