Composition Local ใน Jetpack Compose
โดยปกติการส่งข้อมูลใน 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 ได้เช่นกัน
บทความในชุดเดียวกัน
- Stateless & Stateful Composable
- State Hoisting
- Slot API
- Shape
- UI Preview
- Composition Local [Now Reading]
- Adoption Strategy
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 ของแอป
ซึ่งข้อมูลจำพวกนี้ไม่ได้เกี่ยวกับการทำงานของ 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
ใน ComposableLocalClipboardManager
(Common) – เพื่อเรียกใช้งานClipboardManager
LocalInspectionMode
(Common) – เพื่อเช็คว่า Composable กำลังแสดงผลบน Layout Preview หรือ Inspectable ModeLocalLifecycleOwner
(Android) – เพื่อเรียกใช้งานLifecycleOwner
หรือLifecycle
ที่เจ้าของบล็อกพิมพ์ต่อท้ายว่า Android หรือ Common ก็เพราะว่า Jetpack Compose สามารถใช้ร่วมกับ Kotlin Multiplatform ได้ด้วย ดังนั้นคำสั่งที่เป็น Common ก็คือใช้งานได้บนทุก Platform ส่วนคำสั่งที่เป็น Android ก็คือใช้งานได้เฉพาะบนแอนดรอยด์เท่านั้น
Override ค่าใน Composable ที่ต้องการด้วย Composition Local
เนื่องจากการใช้ Composition Local จะส่งค่าให้ Composable ข้างในแบบ Implicit Parameter จึงทำให้นักพัฒนาสามารถกำหนดค่าที่อยู่ใน Composable ในบางสถานการณ์ได้
ยกตัวอย่างเช่น เจ้าของบล็อกสร้าง Composable ไว้แบบนี้
@Composable
fun TextLabel(label: String) {
Box(/* ... */) {
Text(text = label)
}
}
จะเห็นว่า Composable ตัวนี้รับ Parameter แค่ label
เท่านั้น ดังนั้นถ้าจะทำให้ Composable ตัวนี้กำหนดค่าอย่างอื่นได้ด้วย เช่น ขนาดตัวอักษร หรือสีตัวหนังสือ เป็นต้น โดยปกติแล้วจะต้องแก้ให้ TextLabel
รับ Parameter เหล่านี้เพิ่มเข้ามาด้วย
@Composable
fun TextLabel(
label: String,
color: Color,
fontSize: TextUnit,
) {
Box(/* ... */) {
Text(
text = label,
color = color,
fontSize = fontSize,
)
}
}
แน่อนว่าเราทำแบบนี้ได้ก็ต่อเมื่อเราเป็นสร้าง Composable ตัวนี้ขึ้นมาเอง
แต่ในบางครั้งนักพัฒนาอาจจะเจอสถานการณ์คล้ายกันแต่เป็น Composable ที่อยู๋ใน Library และไม่สามารถแก้ไขโค้ดได้โดยตรง ดังนั้นนักพัฒนาสามารถใช้ Composition Local เพื่อแก้ปัญหาแบบนี้ได้
เพราะถ้าลองกดเข้าไปดูคำสั่งใน Text
จะเห็นว่านักพัฒนาสามารถกำหนดค่าที่เรียกว่า TextStyle
ได้ และค่าดังกล่าวมี Default Value เป็น LocalTextStyle.current
@Composable
fun Text(
/* ... */
style: TextStyle = LocalTextStyle.current
) { /* ... */ }
จึงทำให้นักพัฒนาสามารถกำหนดค่า TextStyle
ให้กับ Text
โดยใช้ Composition Local ในลักษณะแบบนี้ได้
val label: String = /* ... */
val color: Color = /* ... */
val fontSize: TextUnit = /* ... */
CompositionLocalProvider(
values = arrayOf(
LocalTextStyle provides TextStyle.Default.copy(
color = color,
fontSize = fontSize,
)
)
) {
TextLabel(label = label)
}
เพียงเท่านี้นักพัฒนาก็สามารถกำหนดค่าให้กับ Text
ที่อยู่ใน TextLabel
โดยไม่ต้องแก้ไขโค้ดหรือก๊อปโค้ดมาแก้อีกต่อไป เพียงแค่ใช้ความสามารถของ Composition Local แทน
การสร้าง Composition Local เพื่อใช้งานเอง
คำสั่งที่ควรรู้ในการสร้าง Composition Location ขึ้นมาเอง
สำหรับคำสั่งสร้าง Composition Local จะมีอยู่ 2 คำสั่งด้วยกัน
compositionLocalOf
– เมื่อข้อมูลใน Composition Local มีการเปลี่ยนแปลง จะเกิด Recomposition เฉพาะ Composable ที่เรียกใช้งาน Composition Local เท่านั้นstaticCompositionLocalOf
– เมื่อข้อมูลใน Composition Local มีการเปลี่ยนแปลง จะเกิด Recomposition กับ Composable ทุกตัวที่อยู่ภายใน Composition Local ตัวนั้น
ถ้าเป็นข้อมูลที่ไม่มีการเปลี่ยนแปลงในระหว่างการทำงานการสร้างด้วย staticCompositionLocalOf
จะทำให้ได้ประสิทธิภาพในการทำงานได้ดีที่สุด แต่นอกเหนือจากนั้นให้สร้างด้วย compositionLocalOf
แทนดีกว่า
ตัวอย่างสมมติ
สมมติว่าผู้เขียนต้องการสร้าง 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 ก็จะเหมาะกับข้อมูลบางประเภทเท่านั้น ไม่ได้เหมาะกับข้อมูลส่วนใหญ่ ดังนั้นควรพิจารณาเรื่องนี้ให้ดี เพราะการใช้กับข้อมูลที่ไม่เหมาะสมจะกลายเป็นผลเสียต่อการทำงานมากกว่าผลดี