การ Save และ Restore UI State ที่อยู่ใน ViewModel

ด้วยการมาของ Modern Android Development จึงทำให้นักพัฒนาเปลี่ยนมาใช้ Component ต่างๆที่อยู่ใน Android Jetpack กันมากขึ้นเรื่อยๆ เพื่อลดภาระที่ไม่จำเป็นในการพัฒนาแอปแอนดรอยด์ให้น้อยลง

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

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

ด้วยความสามารถดังกล่าวจึงทำให้ ViewModel กลายเป็น Component หลักที่ใช้ในการเก็บค่าตัวแปรและคำนวณ Logic ต่างๆให้กับ Activity หรือ Fragment

จึงเป็นเรื่องปกติที่จะเจอค่าต่างๆที่เก็บไว้ใน ViewModel แบบนี้

data class User(val id: String?, val name: String?)
data class Product(val id: String?, val name: String?, val price: Double)

class HomeViewModel : ViewModel() {

    private var currentUser: User? = /* ... */
    
    val selectedProductsLiveData: LiveData<List<Product>> = /* ... */
    
    /* ... */
}

จะเห็นว่า selectedProductsLiveData ถูกสร้างขึ้นมาเพื่อเก็บข้อมูลที่จะนำไปใช้งานใน Activity หรือ Fragment ส่วน currentUser จะเก็บไว้ใช้งานแค่ใน ViewModel เท่านั้น ซึ่งเป็นรูปแบบที่เห็นกันได้อยู่บ่อยๆ

แต่ ViewModel ก็ยังคงถูกทำลายถ้า Application Process ถูกทำลาย

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

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

และแน่นอนว่าถ้า App Process ถูกทำลาย ต่อให้เป็น ViewModel ก็อยู่ไม่รอดเช่นกัน เพราะสุดท้ายแล้ว ViewModel ก็ไม่ต่างอะไรกับ Singleton หรือ Static ตัวอื่นๆที่อยู่ภายใต้ App Process

สามารถดูรายละเอียดเพิ่มเติมเกี่ยวกับ App Process ถูกทำลายโดยระบบแอนดรอยด์ได้ที่ ทำไมจึงไม่ควรเก็บข้อมูลทิ้งไว้ใน Singleton หรือ Static Variable

ทำไมจึงไม่ควรเก็บข้อมูลทิ้งไว้ใน Singleton หรือ Static Variable
ถึงแม้ว่าจะเป็นหัวข้อที่นักพัฒนาหลายๆคนนั้นรู้จักกันดีอยู่แล้วว่า Model ต่างๆที่ใช้ภายในแอป ไม่ควรเก็บไว้ในรูปของ Static Instance หรือว่า Singleton แต่ทว่าก็อาจจะมีบางคนที่ไม่เข้าใจว่าทำไมถึงทำแบบนั้นไม่ได้ล่ะ? ดังนั้นมาดูกันว่าทำไมเราถึงไม่ควรทำเช่นนั้น

ดังนั้นเพื่อให้ค่าต่างๆที่เก็บไว้ใน ViewModel ไม่ถูกเคลียร์หายไปในตอนที่ App Process ถูกทำลาย นักพัฒนาก็ยังคงจะต้องทำการ Save และ Restore ค่าเหล่านั้นอยู่ดี

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

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

การจัดการกับ UI State ใน ViewModel

ในยุคแรกๆของ ViewModel นั้นจะค่อนข้างลำบากตรงที่นักพัฒนาไม่สามารถ Save/Restore ให้กับค่าต่างๆที่อยู่ใน ViewModel ได้โดยตรง สุดท้ายก็ต้องฝากให้ Activity หรือ Fragment เป็นคนจัดการให้อยู่ดี

แต่สำหรับโปรเจคที่ใช้ Jetpack Fragments ตั้งแต่ 1.2.0 ขึ้นไป จะมีคลาส SavedStateHandle เพิ่มเข้ามาเพื่อช่วยจัดการเรื่องนี้โดยเฉพาะ เพื่อเป็นตัวเก็บข้อมูลต่างๆที่ใช้งานใน ViewModel และรองรับการ Save/Restore ให้โดยอัตโนมัติ

Jetpack Fragment 1.2.0 จะอยู่ใน Jetpack AppCompat 1.3.0 ขึ้นไป

โดยจะต้องใช้ร่วมกับ SavedStateViewModelFactory เพื่อสร้าง ViewModel ให้รองรับกับ SavedStateHandle ได้

การเพิ่ม SavedStateHandle ใน ViewModel

เพียงแค่เพิ่ม SavedStateHandle เข้าไปใน Constructor ของ ViewModel แบบนี้

// HomeViewModel.kt
class HomeViewModel(private val state: SavedStateHandle) : ViewModel() {
    /* ... */
}

และเวลา Activity หรือ Fragment เรียกใช้งาน ViewModel ใช้ Lazy โดยเรียกผ่าน viewModels() แบบนี้ได้เลย

// HomeActivity.kt
class HomeActivity : AppCompatActivity() {
    private val viewModel: HomeViewModel by viewModels()
    /* ... */
}

// HomeFragment.kt
class HomeFragment : Fragment() {
    private val viewModel: HomeViewModel by viewModels()
    /* ... */
}

โดยคำสั่งที่อยู่ข้างใน viewModels() จะสร้าง ViewModel ด้วย SavedStateViewModelFactory ให้อยู่แล้ว นักพัฒนาจึงไม่ต้องทำอะไรเพิ่มเลย

การเก็บข้อมูลและคำสั่งของ SavedStateHandle

ผู้ที่หลงเข้ามาอ่านจะต้องใช้ SavedStateHandle เป็นตัวกลางเพื่อเก็บข้อมูลที่อยู่ใน ViewModel แทนรูปแบบเดิมๆ

SavedStateHandle มีเบื้องหลังในการเก็บข้อมูลเป็น Bundle ทำให้การกำหนด/เรียกข้อมูลจะอยู่ในลักษณะของ Key-value และค่าที่จะเก็บไว้ใน SavedStateHandle ก็ควรจะเป็น Primitive Data Type หรือ Parcelable นั่นเอง

โดยจะมีคำสั่งให้เรียกใช้งานดังนี้

fun set(key: String, value: T)
fun get(key: String): T
fun getLiveData(key: String): MutableLiveData<T>
fun getLiveData(key: String, initialValue: T): MutableLiveData<T>
fun contains(key: String): Boolean
fun remove(key: String): T?
fun keys(): Set<String>
  • set กำหนดค่าลงใน Key ที่กำหนด
  • get ดึงค่าที่เก็บไว้ใน Key นั้นๆออกมาโดยตรง
  • getLiveData ดึงค่าที่เก็บไว้ใน Key นั้นๆออกมาเป็น LiveData และสามารถกำหนดค่าเริ่มต้นสำหรับกรณีที่ไม่มีข้อมูลอยู่ใน Key ตัวนั้นได้ด้วย
  • contains เช็คว่ามีค่าใดๆอยู่ใน Key ตัวนั้นหรือป่าว
  • remove ลบค่าที่อยู่ใน Key นั้นๆทิ้ง และจะส่งค่าล่าสุดที่อยู่ใน Key นั้นๆออกมาให้ด้วย
  • keys ดึง Key ทั้งหมดที่เก็บไว้ออกมา

จะเห็นว่าการกำหนดค่า/เรียกข้อมูลจาก SavedStateHandle จะใช้ Key เป็นตัวระบุทั้งหมดเหมือนกับ Bundle เลย

การใช้งาน SavedStateHandle เพื่อเก็บค่าต่างๆ

ทีนี้มาดูกันต่อ จากเดิมที่นักพัฒนาเก็บค่าต่างๆไว้ใน ViewModel แบบนี้

// HomeViewModel.kt
class HomeViewModel : ViewModel() {

    private var currentUser: User? = /* ... */

    val selectedProductsLiveData: LiveData<List<Product>> = /* ... */
    
    /* ... */
}

เพื่อให้ค่าต่างๆถูกเก็บไว้ใน SavedStateHandle จะต้องเปลี่ยนมาใช้รูปแบบนี้แทน

// HomeViewModel.kt
class HomeViewModel(state: SavedStateHandle) : ViewModel() {

    private var currentUser: User?
        get() = state.get("user")
        set(value) = state.set("user", value)

    val selectedProductsLiveData: MutableLiveData<List<Product>> = 
        state.getLiveData("selected_products")
            
    /* ... */
}
ขอใช้ชื่อตัวแปรว่า state เพื่อความกระชับของโค้ด

จะเห็นว่า currentUser ที่เป็นข้อมูลปกติไม่ใช่ LiveData จะใช้วิธีกำหนด Getter และ Setter เพื่อให้ใช้ค่าที่อยู่ข้างใน SavedStateHandle โดยตรงเลย และกำหนด Key เป็น user

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

// HomeViewModel.kt
private var currentUser: User?
    get() = state.get("user")
    set(value) = state.set("user", value)

fun updateUser(user: User) {
    currentUser = user
}

fun getUser(): User? = currentUser

ส่วน selectedProductsLiveData ที่เก็บค่าในรูปแบบของ MutableLiveData ก็สามารถดึงค่าจาก SavedStateHandle ออกมาเป็น MutableLiveData ได้ทันที โดยกำหนด Key เป็น selected_products

และเวลากำหนดค่าลงใน selected_products ก็ใช้คำสั่ง setValue หรือ postValue ได้ตามปกติ

val selectedProductsLiveData: MutableLiveData<List<Product>> = 
    state.getLiveData("selected_products")

fun updateSelectedProducts(products: List<Product>) {
    state.value = products
}

ฝั่ง Activity หรือ Fragment ก็สามารถ Observe ค่าที่อยู่ใน selectedProductsLiveData ได้ตามปกติเช่นกัน

เพียงเท่านี้ค่าใน ViewModel ก็จะอยู่รอดปลอดภัยถึงแม้ว่า App Process จะถูกทำลายก็ตาม

เวลาจัดการข้อมูลที่เป็น Array หรือ List จะลำบากอยู่หน่อยๆ

จากตัวอย่างจะเห็นว่า selectedProductsLiveData มีข้อมูลเป็น List<Product>

ถ้าผู้ที่หลงเข้ามาอ่านกำหนดค่าเป็น List ทั้งก้อนเลยก็คงไม่มีปัญหาอะไร แต่ถ้าอยากจะเพิ่มข้อมูลเข้าไปทีละตัวล่ะ?

// HomeViewModel.kt
val selectedProductsLiveData: MutableLiveData<List<Product>> = 
    state.getLiveData("selected_products")

fun addSelectedProducts(product: Product) {
    // How?
}

เพราะข้อมูลดังกล่าวอยู่ในรูปของ LiveData และการกำหนดค่านั้นควรจะกำหนดค่าลงไปใน SavedStateHandle โดยตรง ไม่ใช่กำหนดค่าลงใน selectedProductsLiveData

ดังนั้นในกรณีนี้ เจ้าของบล็อกขอแนะนำวิธีแบบนี้แทน

// HomeViewModel.kt
val selectedProductsLiveData: LiveData<List<Product>> =
    state.getLiveData("selected_products")
    
private var selectedProducts: MutableList<Product>
    get() = state.get("selected_products") ?: mutableListOf()
    set(value) = state.set("selected_products", value)
    
fun addSelectedProducts(product: Product) {
    val products: MutableList<Product> = selectedProducts
    products.add(product)
    selectedProducts = products
    selectedProductsLiveData.po
}

โดยทำการสร้าง selectedProducts ขึ้นมาเป็น MutableList<Product> เพื่อให้แก้ไขข้อมูลที่อยู่ใน SavedStateHandle ได้ง่าย

ด้วยวิธีนี้ เมื่อค่าใน selectedProducts ถูกแก้ไข ก็จะทำให้ selectedProductsLiveData ที่เป็น LiveData ทำการอัปเดตค่าไปให้ Activity หรือ Fragment ได้ทันที

แล้ว Dependency Injection ล่ะ?

ถึงจุดนี้ผู้ที่หลงเข้ามาอ่านอาจจะสงสัยว่าเกี่ยวอะไรด้วย

แต่ถ้าดูดีๆก็จะพบว่า SavedStateHandle กลายเป็นหนึ่งใน Parameter ของ Constructor ซึ่งเป็นที่ๆ Dependency Injection จะส่งค่าต่างๆเข้ามาเหมือนกันนั่นเอง

เราจะใช้วิธี Inject แบบเดิม แล้วเพิ่ม SavedStateHandle เข้ามาได้มั้ยนะ?

สำหรับ Koin

โชคดีที่ทีมพัฒนาของ Koin ได้ทำให้รองรับกับ SavedStateHandle เป็นที่เรียบร้อยแล้ว

สมมติว่ามี ViewModel ที่ต้องการใช้ SavedStateHandle และ Inject ค่าอื่นๆเข้ามาด้วยแบบนี้

// HomeViewModel.kt
class HomeViewModel(
    private val state: SavedStateHandle,
    private val repository: Repository, 
    private val localStorage: LocalStorage
) : ViewModel() {
    /* ... */
}

ในตอนสร้าง Module ก็กำหนดค่าเป็น get() เหมือนกับตัวอื่นๆได้ตามปกติเลย

module {
    /* ... */
    viewModel { HomeViewModel(get(), get(), get()) }
}

และเวลาเรียกใช้ใน Activity หรือ Fragment ก็ให้กำหนดค่า state ในคำสั่ง viewModel() ด้วย โดยกำหนดค่าเป็น emptyState() เพิ่มเข้าไป

// HomeActivity.kt
class HomeActivity : AppCompatActivity() {
    private val viewModel: HomeViewModel by viewModel(state = emptyState())
    /* .... */    
}

สำหรับ Dagger และ Hilt

ขอแนะนำบทความ Saving UI state with ViewModel SavedState and Dagger [Medium] เลยฮะ

Saving UI state with ViewModel SavedState and Dagger
Deep dive into an advanced usage of the new ViewModel SavedState module that covers UI state persistence not only during configuation changes, but also after a process stop in combination with Dagger

สรุป

อย่างที่บอกไปก่อนหน้านี้ว่า โดยลำพังแล้วการเก็บข้อมูลไว้ใน ViewModel ให้อยู่รอดจาก Configuration Changes ก็เพียงพอสำหรับแอปส่วนใหญ่แล้ว แต่ถ้าต้องการให้รองรับตอนที่ App Process ถูกทำลายด้วย ก็ให้ใช้ SavedStateHandle ได้เลย

เพียงเท่านี้ค่าต่างๆที่เก็บไว้ใน ViewModel ก็สามารถถูกจัดการให้เรียบร้อย โดยไม่ต้องรบกวน Activity หรือ Fragment อีกต่อไปแล้ว เย้!

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