โดยปกติการส่งข้อมูลใน Composable Function จะส่งผ่าน Function Parameter ในรูปแบบ Explicit Parameter หรือ Function Parameter แบบนี้

@Composable
fun MainScreen() { 
    val backgroundColor: Color = /* ... */
    WelcomeCard(backgroundColor = backgroundColor)
    /* ... */
}

@Composable
fun WelcomeCard(backgroundColor: Color) { /* ... */ }

แต่ในการทำงานจริงจะพบว่านักพัฒนาต้องสร้าง Composable Function ซ้อนกันหลายชั้นมาก และทำให้การส่งข้อมูลบางอย่างด้วยวิธีแบบนี้ไม่ใช่เรื่องสะดวกซักเท่าไร เพราะหน้าที่มี UI และการทำงานที่ซับซ้อนก็มักจะมาพร้อมกับข้อมูลและคำสั่งต่าง ๆ ที่ต้องเรียกใช้งานเยอะมากขึ้น

ซึ่งแน่นอนว่าการส่งข้อมูลผ่าน Function Parameter หรือ Explicit Parameter จะเป็นวิธีที่ตรงไปตรงมาและอ่านได้ง่ายที่สุด แต่ก็จะมีข้อมูลบางประเภทที่สามารถเปลี่ยนไปใช้วิธีที่เรียกว่า Composition Local ได้เช่นกัน

บทความในชุดเดียวกัน

Composition Local คือ?

เป็นวิธีส่งข้อมูลจาก Composable ชั้นบนสุดไปชั้นล่างสุด (ตาม UI Tree) ในแบบ Implicit เพื่อทำให้ Composable ชั้นล่างสามารถเรียกใช้งานข้อมูลตัวนั้นได้ทันที โดยไม่ต้องส่งผ่าน Explicit Parameter หลายชั้นให้เสียเวลา

@Composable
fun MainScreen(/* ... */) {
    val backgroundColor: Color = /* ... */
    AppTheme(background = backgroundColor) {
        WelcomeCard()
        /* ... */
    }
}

@Composable
fun WelcomeCard() {
    val backgroundColor: Color = AppTheme.current.background
    /* ... */
}

จากโค้ดตัวอย่างข้างบนนี้จะเห็นว่านักพัฒนาสามารถส่งค่า backgroundColor ไปให้ Composable ที่อยู่ใน UI Tree ชั้นล่างผ่านการกำหนดค่าไว้ใน AppTheme แทนที่จะส่งผ่าน Function Parameter หรือ Explicit Parameter

โดย AppTheme ที่ว่านี้ได้ใช้วิธีที่เรียกว่า Composition Local ซึ่งเป็นวิธีเดียวกับ MaterialTheme ใช้อยู่นั่นเอง

ประโยชน์จากการใช้ Composition Local

การใช้ Composition Local จะทำให้ Composable ที่เรียกใช้ข้อมูลสามารถนำไปใช้งานในสภาพแวดล้อมที่แตกต่างกันได้ ซึ่งสภาพแวดล้อมที่ว่าก็คือการกำหนดค่าใน Composition Local ที่แตกต่างกันไปตามการใช้งานนั่นเอง

object Colors {
  val white = Color(0xFFFFFFFF)
  val blueGray50 = Color(0xFFECEFF1)
}

@Composable
fun MainScreen() {
    AppTheme(background = Colors.white) {
        WelcomeCard()
        /* ... */
    }
}

@Composable
fun LandingScreen() {
    AppTheme(background = Colors.blueGray50) {
        WelcomeCard()
        /* ... */
    }
}

@Composable
fun WelcomeCard() {
    Box(modifier = Modifier.fillMaxSize()
        .background(color = AppTheme.current.background)
    ) {
        /* ... */
    }
}

จากโค้ดตัวอย่างจะเห็นว่า WelcomeCard ถูกนำไปใช้งานใน MainScreen และ LandingScreen ซึ่งทั้งคู่ได้กำหนดสีพื้นหลังแตกต่างกัน หรือก็คือ WelcomeCard จะมีสีพื้นหลังที่เปลี่ยนไปตาม Composable ที่เรียกใช้งานนั่นเอง

หรือจะหุ้มด้วย Composable เพื่อให้นำไปใช้งานได้ง่ายขึ้นแบบนี้ก็ได้

object Colors {
  val white = Color(0xFFFFFFFF)
  val blueGray50 = Color(0xFFECEFF1)
}

@Composable
fun WhiteBackgroundTheme(
    content: @Composable () -> Unit
) {
    AppTheme(background = Colors.white) {
        content()
    }
}

@Composable
fun BlueGrayBackgroundTheme(
    content: @Composable () -> Unit
) {
    AppTheme(background = Colors.blueGray50) {
        content()
    }
}

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

สิ่งที่ควรรู้ก่อนสร้าง Composition Local ขึ้นมาใช้งาน

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

การเรียกข้อมูลที่อยู่ใน Composition Local

จะเรียกผ่าน current ของ Composition Local ตัวนั้นเสมอ เช่น LocalContext.current หรือ MaterialTheme.current เป็นต้น ซึ่งเป็นรูปแบบที่ Jetpack Compose กำหนดไว้

ไม่ควรใช้กับข้อมูลที่เปลี่ยนแปลงบ่อย

เมื่อข้อมูลที่ส่งผ่าน Composition Local เกิดการเปลี่ยนแปลง จะทำให้ Composable ทั้งหมดถูก Recomposition ซึ่งจะส่งผลต่อประสิทธิภาพ (Performance) ในการทำงาน

ไม่ควรใช้กับข้อมูลที่มีขนาดใหญ่

เพราะการส่งข้อมูลขนาดใหญ่ผ่าน Composition Local จะส่งผลต่อประสิทธิภาพ (Performance) ในการทำงาน

ไม่ควรใช้กับข้อมูลจำพวก UI State และ App Data

โดยอ้างอิงจากบทความมาจัดการกับข้อมูลภายในแอปให้ถูกต้องกันเถอะ ที่ UI State และ App Data จะเกิดจากการทำงานของ ViewModel ตาม Business Logic ของแอป

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

ซึ่งข้อมูลจำพวกนี้ไม่ได้เกี่ยวกับการทำงานของ Composable โดยตรงและมีโอกาสเปลี่ยนแปลงบ่อย ดังนั้นควรส่งผ่าน Explicit Parameter ดีกว่า

Composable ที่อยู่ภายใต้ UI Tree ของ Composition Local จะเข้าถึงข้อมูลนั้นได้เสมอ

เมื่อทุกตัวสามารถเรียกใช้ข้อมูลจาก Composition Local ได้ จึงควรเป็นข้อมูลที่สามารถเรียกใช้งานจาก Composable ใด ๆ ก็ได้ ไม่เหมาะกับข้อมูลที่ใช้กับ Composable แค่บางตัวแบบเฉพาะเจาะจง

Composition Local ควรจะมี Default Value ที่เหมาะสม

เพราะ Composition Local ถูกเรียกใช้งานจาก Composable ตัวไหนก็ได้ จึงมีโอกาสที่ Composable ตัวนั้นถูกเรียกใช้งานจากที่อื่นโดยไม่ได้กำหนดค่าที่ต้องการให้กับ Composition Local

@Composable
fun MainScreen() {
    // val backgroundColor: Color = /* ... */
    // AppTheme(background = backgroundColor) {
    WelcomeCard()
    // }
}

@Composable
fun WelcomeCard() {
    // Default value
    val backgroundColor: Color = AppTheme.current.background
    /* ... */
}

ดังนั้นการมี Default Value ที่เหมาะสมจะช่วยลดความซับซ้อนและความผิดพลาดจากการนำ Composable ตัวนั้นไปใช้งาน

ตัวอย่างข้อมูลที่เหมาะกับการใช้ Composition Local

  • Theme - เพื่อให้ทั้งแอปเปลี่ยนโทนสีพร้อมกันทั้งหมด
  • Language - เพื่อให้ทั้งแอปเปลี่ยนภาษาพร้อมกันทั้งหมด

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

ตัวอย่าง Composition Local พื้นฐานที่มีให้ใช้งาน

  • LocalContext (Android) – เพื่อเรียกใช้งาน Context ใน Composable
  • LocalClipboardManager (Common) – เพื่อเรียกใช้งาน ClipboardManager
  • LocalInspectionMode (Common) – เพื่อเช็คว่า Composable กำลังแสดงผลบน Layout Preview หรือ Inspectable Mode
  • LocalLifecycleOwner (Android) – เพื่อเรียกใช้งาน LifecycleOwner หรือ Lifecycle

ที่เจ้าของบล็อกพิมพ์ต่อท้ายว่า Android หรือ Common ก็เพราะว่า Jetpack Compose สามารถใช้ร่วมกับ Kotlin Multiplatform ได้ด้วย ดังนั้นคำสั่งที่เป็น Common ก็คือใช้งานได้บนทุก Platform ส่วนคำสั่งที่เป็น Android ก็คือใช้งานได้เฉพาะบนแอนดรอยด์เท่านั้น

คำสั่งสำหรับการสร้าง Composition Local

สำหรับคำสั่งสร้าง Composition Local จะมีอยู่ 2 คำสั่งด้วยกัน

  • compositionLocalOf – เมื่อข้อมูลใน Composition Local มีการเปลี่ยนแปลง จะเกิด Recomposition เฉพาะ Composable ที่เรียกใช้งาน Composition Local เท่านั้น
  • staticCompositionLocalOf – เมื่อข้อมูลใน Composition Local มีการเปลี่ยนแปลง จะเกิด Recomposition กับ Composable ทุกตัวที่อยู่ภายใน Composition Local ตัวนั้น

ถ้าเป็นข้อมูลที่ไม่มีการเปลี่ยนแปลงในระหว่างการทำงานการสร้างด้วย staticCompositionLocalOf จะทำให้ได้ประสิทธิภาพในการทำงานได้ดีที่สุด แต่นอกเหนือจากนั้นให้สร้างด้วย compositionLocalOf แทนดีกว่า

การสร้าง Composition Local เพื่อใช้งานเอง

สมมติว่าผู้เขียนต้องการสร้าง Composition Local สำหรับแสดง Snackbar จาก Composable ตัวไหนก็ได้ เพราะติดปัญหาว่าว่า Snackbar จะต้องถูกสร้างไว้ใน Scaffold ที่อยู่ชั้นบนสุดของ UI Tree แบบนี้

@Composable
fun SupermarketApp() {
    val snackbarHostState = remember { SnackbarHostState() }
    Scaffold( 
        snackbarHost = { SnackbarHost(snackbarHostState) },
    ) { 
        /* ... */
    }
}

@Composable
fun OrderSummaryScreen() {
    Column {
        /* ... */
        Button(
            onClick = {
                // Show message to user with Snackbar
            }
        ) { /* ... */ }
    }
}

โดย CheckoutScreen เป็นเสมือนหน้าหนึ่งใน Flow ทั้งหมดที่อยู่ภายใน SupermarketApp และจำเป็นต้องเรียกคำสั่งใน snackbarHostState เพื่อแสดง Snackbar ในบางเงื่อนไข

สร้าง Composition Local

เนื่องจากข้อมูลของ SnackbarHostState มีโอกาสเปลี่ยนแปลงตามการใช้งาน ดังนั้นเจ้าของบล็อกจึงเลือกที่จะสร้าง Composition Local โดยใช้คำสั่ง compositionLocalOf แทน

val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> {
    throw IllegalStateException("No SnackbarHostState provided")
}

และกำหนดให้ Default Value เป็น IllegalStateException ดังนั้นถ้ามีการเรียกใช้ Composition Local ตัวนี้โดยไม่ได้กำหนดค่า SnackbarHostState ก่อน ก็จะโยน IllegalStateException ออกมาแทน

นำ Composition Local ที่ได้ไปใช้งาน

การนำ Composition Local ไปใช้งานก็จะต้องกำหนดผ่าน CompositionLocalProvider แบบนี้

CompositionLocalProvider(
    values = arrayOf(
        /* Composition Local */ provides /* Value */
    )
) { /* Content */ }

โดยให้เรียก Composition Local แล้วคั่นด้วยคำสั่ง provides พร้อมกับค่าที่ต้องการกำหนดให้กับ Composition Local นั้น ๆ ได้เลย

val snackbarHostState: SnackbarHostState = /* ... */

CompositionLocalProvider(
    values = arrayOf(
        LocalSnackbarHostState provides snackbarHostState
    )
) { /* Content */ }

นอกจากนี้จะเห็นว่า values มีลักษณะเป็น Array นั่นหมายความว่านักพัฒนาสามารถสร้าง Composition Local หลายตัวแล้วนำมากำหนดในนี้พร้อมกันทั้งหมดได้เลย

เมื่อนำคำสั่งดังกล่าวไปใช้งานจริงก็จะได้ออกมาเป็นแบบนี้

@Composable
fun SupermarketApp() {
    val snackbarHostState = remember { SnackbarHostState() }
    CompositionLocalProvider(
        values = arrayOf(
            LocalSnackbarHostState provides snackbarHostState
        )
    ) {
        Scaffold( 
            snackbarHost = { SnackbarHost(snackbarHostState) },
        ) { 
            /* ... */
        }
    }
}

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

@Composable
fun AppLocalProvider(
    snackbarHostState: SnackbarHostState,
    content: @Composable () -> Unit,
) {
    CompositionLocalProvider(
        values = arrayOf(
            LocalSnackbarHostState provides snackbarHostState
        )
    ) {
        content()
    }
}

@Composable
fun SupermarketApp() {
    val snackbarHostState = remember { SnackbarHostState() }
    AppLocalProvider(snackbarHostState = snackbarHostState) {
        Scaffold( 
            snackbarHost = { SnackbarHost(snackbarHostState) },
        ) { 
            /* ... */
        }
    }
}

เพียงเท่านี้ Composable ที่อยู่ภายใต้ UI Tree ของ AppLocalProvider ก็จะเรียกใช้งาน LocalSnackbarHostState จากที่ไหนก็ได้แล้ว

@Composable
fun OrderSummaryScreen() {
    val coroutineScope = rememberCoroutineScope()
    Column {
        /* ... */
        Button(
            onClick = {
                // Show message to user with Snackbar
            }
        ) { /* ... */ }
    }
}

และถึงแม้จะบอกว่าเรียกใช้งาน Snackbar "ที่ไหนก็ได้" แต่จริง ๆ แล้วต้องอยู่ใน Coroutine Scope นะ 😅

สรุป

Composition Local เป็นหนึ่งในเครื่องมือสำคัญที่จะช่วยให้นักพัฒนาใช้งาน Jetpack Compose ได้อย่างมีประสิทธิภาพมากขึ้น ช่วยลดข้อมูลที่จะต้องส่งผ่าน Explicit Parameter หรือ Function Parameter ให้น้อยลง

แต่การใช้ Composition Local ก็จะเหมาะกับข้อมูลบางประเภทเท่านั้น ไม่ได้เหมาะกับข้อมูลส่วนใหญ่ ดังนั้นควรพิจารณาเรื่องนี้ให้ดี เพราะการใช้กับข้อมูลที่ไม่เหมาะสมจะกลายเป็นผลเสียต่อการทำงานมากกว่าผลดี

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