Cover Photo by PixaBay from Pexels
จากที่เกริ่นไว้ใน Part แรกว่า JetPack Compose เป็นเรื่องที่ตั้งใจจะเขียนไว้ตอนแรกแต่เนื่องจากว่าใน Sample project มีจุดน่าสนใจหลายอย่าง เลยต้องแตกออกมาเป็นอีกหัวข้อนึง เรื่อง JetPack Compose ที่จะเขียนในวันนี้ อาจจะไม่ครอบคลุมเนื้อหาทั้งหมด เพราะตัว JetPack Compose เองก็มีเนื้อหาเยอะมาก รวมถึง API เองก็ยังไม่ได้อยู่ในสถานะ Stable เลยคิดว่าน่าจะมี Breaking change พอสมควร เนื้อหาที่จะเขียนในนี้จึงขอ Scope เนื้อหาเอาไว้คร่าวๆละ Sample App ละกัน
เริ่มต้นสร้าง Project
- Jetpack Compose นั้น สามารถใช้งานได้บน Android Studio 4.2 (Preview) ขึ้นไปเท่านั้น เพราะฉะนั้นก่อนเริ่มสร้าง Project เราควรมั่นใจก่อนว่า Version ของ Android Studio เราถูกต้อง
- เมื่อกด New Project เราสามารถเลือก Empty Compose Activity ได้เลย
ใน app/build.gradleจะเห็นว่าเรามี Compose lib อยู่ข้างในด้วย
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.ui:ui-tooling:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
-
androidx.compose.ui:uiและandroidx.ui:ui-toolingคือ JetPack Compose ที่เราจะใช้ -
androidx.compose.materialคือ Google Material Design system Library ที่ถูกสร้างครอบ JetPack Compose อีกที
หน้าตา Code หลังจากที่ New Project สำเร็จ

จุดที่เราสามารถสังเกตได้ชัดๆคือใน Activity ของเรานั้นไม่มีการ เรียก setContentView(R.layout.activity_main) อีกต่อไปแล้ว กลับกัน กับเรียก setContent แทนที่เป็น Extension Function ที่รับ lamda ที่มี @Composable ประกาศไว้ข้างหน้า

สิ่งที่ set เข้าไปใน lamda ของ setContent ก็คือ MyApplicationTheme ซึ้งถ้าเราไปดู เจ้าตัว MyApplicationTheme นั้นก็เป็น Function ที่มี @Composable ไว้เช่นกัน

Composable
-
@Composableคือ Annotation พื้นฐานสำหรับ JetPack Compose ที่ไว้ประกาศไว้หน้า Function เพื่อเป็นการบอกว่า Function นี้จะเป็น Block code ที่ไว้สำหรับ Built ด้วย Compose พูดง่ายๆคือเป็น UI Function ละกัน
Concept ของ UI เปลี่ยนไป
ใน JetPack Compose Concept ของ UI นั้นจะไม่เหมือนกับ Android View System ดั้งเดิม คือ ทุกๆ Component นั้นเป็น Composable function ไม่ใช่ Object ที่สืบทอดมาจาก View การเปลี่ยนแปลงการแสดงผลของ UI นั้นจะเกิดขึ้นเมื่อ Function นั้นมีการ "Re-composition" เกิดขึ้น
ถึงตรงนี้หลายคนอาจจะสงสัยว่าเมื่อไหร่ที่ Function จะทำการ Re-composition? โดยปกติแล้ว Composable Function จะทำการ Re-composition ก็ต่อเมื่อ State ของตัวมันเองเปลี่ยนไป โดยเรื่อง State จะกล่าวถัดไป
Unidirectional Data Flow
Unidirectional Data Flow หรือ Single Directional Data Flow
ย้อนกลับไปตอนที่เรายังเขียน Code เป็นแบบ MVC Pattern
- ใน MVC เมื่อมี
Actionบางอย่างเกิดขึ้นเช่น User เปิดแอพ, User คลิกปุ่ม ฯลฯActionจะถูกส่งไปให้ Controller เป็นคนจัดการ - Controller จะทำการจัดการกับ
Actionนั้นตามแต่ว่า Action นั้นคืออะไร เช่น User เปิดแอพ -> Controller ดึงข้อมูลจาก network เมื่อ Controller ได้ข้อมูลมาแล้ว นำข้อมูลไป parse เป็น model แล้วแสดงผลที่ View ให้ User เห็น

Flow การทำงานนี้คอนข้างเรียบง่ายถ้าเป็นแอพขนาดเล็ก แต่ถ้าหากว่าแอพเราใหญ่ขึ้น Flow การทำงานก็จะซับซ้อนขึ้น เช่น เมื่อ User เปิดแอพ เราต้อง Fetch ข้อมูลจาก API มาจาก n Endpoints และแสดงผลใน View n Views
ขอบคุณทีม Facebook Engineer ที่ได้ คิดใหม่และกลายเป็นที่มาของ Flux, Redux ฯลฯ ในปัจจุบันฝั่ง web App Development

Image from Hacker Way: Rethinking Web App Development at Facebook
ใครสนใจสามารถอ่านได้ที่ Flux documentation
สาเหตุที่จำเป็นต้องเกริ่นถึงเรื่องนี้เพราะตัว JetPack Compose เองมีสิ่งนึงที่ Build-in เข้ามาใน Library เลย นั่นก็คือ State
State
State เป็นเสมือนค่าสถานะของ Application ที่เปลี่ยนแปลงได้ตามเวลา ถ้าเราลองมองภาพกว้างๆ จะเห็นว่า Android App ของเราเองก็ทำการ แสดง State ต่างๆ ให้ User เห็นอยู่แล้ว เช่น:
- State ที่ทำการแสดง Snackbar ให้ User เห็นเมื่อไม่มี Internet
- State ที่ทำการแสดง Blog Post รวมถึง Comment ที่เกี่ยวข้อง
- State ที่ทำการแสดง Ripple animation บนปุ่มเมื่อ User กด จะเห็นว่าแอพเราเองก็แสดง State ต่างๆ ให้ user เห็นผ่านทาง View อยู่แล้ว
แล้วเมื่อไหร่ที่ State ของหน้าแอพเรามันเปลี่ยนแปลงไปล่ะ?
โดยปกติ State ของแอพเรามักจะเปลี่ยนแปลงเนื่องจากมี Action หรือ Event บางอย่างมากระทำให้ State เปลี่ยนแปลงไป
- Event ในที่นี้อาจจะเป็น Action จากการกดปุ่ม หรือข้อมูลที่เกิดจากการ Fetch มาจาก Backend
- Update state class หรือ function ที่ทำหน้าที่เปลี่ยนแปลง State
- Display state UI ที่ทำหน้าที่ Display ให้ User เห็น ตามแต่ละ State ที่เปลี่ยนแปลงไป
พูดอีกอย่างก็คือ เราจะแสดง UI ตาม State ที่เปลี่ยนแปลงไป ในขณะเดียวกัน State ก็อาจจะเปลี่ยนแปลงได้จากการที่ได้รับ Event บางอย่างมาจาก UI

ยกตัวอย่าง:
class MainActivity : AppCompatActivity() {
private lateinit var binding: MainActivityBinding
var name = ""
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.textUpdateButton.setOnClickListener {
name = randomDisplayText()
updateHello()
}
}
private fun randomDisplayText(): String {
return when((Math.random() * (4 - 1) + 1).toInt()) {
1 -> "Android"
2 -> "JetPack"
3 -> "Hilt"
4 -> "People"
else -> "World"
}
}
private fun updateHello() {
binding.helloText.text = "Hello, $name"
}
}
Code ข้อบนชุดนี้คือ เราจะทำการ Update ค่าที่นำมาแสดงของ helloText เมื่อ User มีการกดปุ่มเพื่อ random โดยตัวอย่างโค๊ตข้างบนสามารถทำงานได้ถูกต้อง 100% ทีนี้ปัญหาคืออะไร? 🤔
เราลองนึกถึงแอพที่ Scale ใหญ่ขึ้น หน้าจอมีความซับซ้อนมากขึ้น การเขียนโค๊ตลักษณะนี้อาจจะก่อให้เกิดปัญหาหลายอย่างเช่นการ Test เนื่องจากว่าค่าที่นำมาแสดงผลอยู่ภายใน View (State ของ helloText อยู่ใน View) การ test นั้นเป็นเรื่องค่อนข้างยาก หรือแอพมี Events มากขึ้นแล้วเราต้อง Update UI หรือ ค่า State ต่างๆ มันเป็นเรื่องง่ายมากที่เราจะลืมมาแก้ไข/เปลี่ยนแปลง Code ชุดนี้ รวมขึ้น Code Complexity ที่จะเกิดขึ้นในอนาคต
ใช้ ViewModel และ LiveData ช่วยในการจัดการ State
class MainViewModel: ViewModel() {
// LiveData holds state which is observed by the UI
// (state flows down from ViewModel)
private val _name = MutableLiveData("")
val name: LiveData<String> = _name
// onNameChanged is an event we're defining that the UI can invoke
// (events flow up from UI)
fun onNameChanged() {
_name.value = randomDisplayText()
}
private fun randomDisplayText(): String {
return when((Math.random() * (4 - 1) + 1).toInt()) {
1 -> "Android"
2 -> "JetPack"
3 -> "Hilt"
4 -> "People"
else -> "World"
}
}
}
class MainActivity : AppCompatActivity() {
val helloViewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.textUpdateButton.setOnClickListener {
helloViewModel.onNameChanged()
}
helloViewModel.name.observe(this) { name ->
binding.helloText.text = "Hello, $name"
}
}
}
จะเห็นว่าสิ่งที่เกิดขึ้นใน Code ชุดข้างบนคือ เราให้ การกดปุ่มจะไป update ค่าของ LiveData แทนที่จะ Update UI ตรงๆ ทำการ observe LiveData ตัวนั้นเพื่อทำการเปลี่ยนแปลงค่าใน helloText
- Event/Action ในที่นี้ Event หรือ Action คือ การที่ User กดปุ่ม
-
State Updating เกิดขึ้นที่
onNameChangedเมื่อค่าของ LiveData เปลี่ยนแปลงไป เรียกว่าเราได้ทำการ Hoist State เอาไว้ที่ LiveData ของ ViewModel ของเรา (State Hoisting) -
State Displaying เกิดขึ้น Observer เมื่อทำการเปลี่ยนแปลงค่าของ
helloText
ลอง Implement Code แบบเดียวกันด้วย JetPack Compose
ผมจะลองสร้าง Sample Code ขึ้นมาให้หน้าตาและการทำงานมีลักษณะดังนี้

โดยทุกๆครั้งที่ User กดปุ่ม เราจะทำการ random ข้อความขึ้นมาใหม่และแสดงที่ Text ตัวแอพจะแสดง Icon อยู่ทางขวาจะ random ขึ้นมาเหมือนกันแต่แค่ครั้งเดียวหลังจากที่เปิดแอพใหม่ โดยเราจะมี ViewModel อยู่แล้ว หน้าตาแบบนี้
class MainViewModel: ViewModel() {
// LiveData holds state which is observed by the UI
// (state flows down from ViewModel)
private val _name = MutableLiveData("")
val name: LiveData<String> = _name
// onNameChanged is an event we're defining that the UI can invoke
// (events flow up from UI)
fun onNameChanged() {
_name.value = randomDisplayText()
}
}
พร้อมกับ Function สำหรับ Random Text
fun randomDisplayText(): String {
return when((Math.random() * (4 - 1) + 1).toInt()) {
1 -> "Android"
2 -> "JetPack"
3 -> "Hilt"
4 -> "People"
else -> "World"
}
}
สร้าง UI ด้วย Column และ Row
Column และ Row เป็น Layout พื้นฐานของ Compose โดย
-
Columnจะแสดง Child เรียงเป็นแนวตั้ง คล้ายกับ LinearLayout ที่มีorientation="vertical" -
Rowจะแสดง Child เรียงเป็นแนวนอน คล้ายกับ LinearLayout ที่มีorientation="horizontal"
เราจะให้ Text และ Button อยู่ใน Column (กรอบสีแดง)เนื่องจากเรียงจากบนลงล่างและให้ตัว Column และ Icon อยู่ภายใน Row (กรอบสีน้ำเงิน)

สร้าง Composable Function ในที่นี้ให้ชื่อว่า MyScreen
@Composable
fun MyScreen() {
}
เพิ่ม Row โดยกำหนด Modifier ให้ แสดงผลเต็มทางแนวนอน และมี padding เป็น 16dp
@Composable
fun MyScreen() {
+ Row(modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)){
+ }
}
เพิ่ม Column โดยกำหนด weight ให้ Column = 1
@Composable
fun MyScreen() {
Row(modifier = Modifier
.fillMaxWidth()
.padding(16.dp)) {
+ Column(modifier = Modifier.weight(1f)) {
+ }
}
}
เพิ่ม Icon ในเคสนี้เราต้องการให้ Icon ถูก random ขึ้นมาเฉพาะครั้งแรกที่เปิดแอพเท่านั้น
@Composable
fun MyScreen() {
Row(modifier = Modifier
.fillMaxWidth()
.padding(16.dp)) {
Column(modifier = Modifier.weight(1f)) {
}
+ MyRandomIcon(asset = randomIconAsset())
}
}
+ @Composable
+ fun MyRandomIcon(asset: VectorAsset) = Icon(asset = asset)
+ fun randomIconAsset(): VectorAsset {
+ return when((Math.random() * (14 - 1) + 1).toInt()) {
+ 1 -> Icons.Default.Cloud
+ 2 -> Icons.Default.CloudQueue
+ 3 -> Icons.Default.CloudUpload
+ 4 -> Icons.Default.CloudOff
+ 5 -> Icons.Default.CloudDone
+ 6 -> Icons.Default.CloudDownload
+ 7 -> Icons.Default.WbCloudy
+ 8 -> Icons.Default.Wifi
+ 9 -> Icons.Default.WifiCalling
+ 10 -> Icons.Default.WifiLock
+ 11 -> Icons.Default.WifiProtectedSetup
+ 12 -> Icons.Default.WifiOff
+ 13 -> Icons.Default.WifiTethering
+ 14 -> Icons.Default.PortableWifiOff
+ else -> Icons.Default.FavoriteBorder
+ }
+ }
เพิ่ม Text และ Button เข้าไปที่ Column
@Composable
fun MyScreen() {
Row(modifier = Modifier
.fillMaxWidth()
.padding(16.dp)) {
Column(modifier = Modifier.weight(1f)) {
+ Text("")
+ Button(onClick = {}) {
+ Text(text = "Click Me")
+ }
}
MyRandomIcon(asset = randomIconAsset())
}
}
เพิ่ม MyScreen เข้าไปที่ onCreate ของ Activity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
Surface(color = MaterialTheme.colors.background) {
MyScreen(
text = "", {}
)
}
}
}
}
Preview
เราสามารถ Preview หน้าตา UI ที่เราสร้างได้ด้วย @Preview ให้เราทำการสร้าง Function แยก ชื่อว่า PreviewMyScreen เพื่อใช้สำหรับ Preview อย่างเดียว
+ @Preview(showBackground = true)
+ @Composable
+ fun PreviewMyScreen() {
+ ComposeStatePlaygroundTheme {
+ MyScreen()
+ }
+ }
showBackground = true เพื่อแสดง Background ของแอพ
ทำการ Build app 1 ครั้งเพื่อ Preview
หน้าตาที่ได้:

เรายังสามารถทำการกดปุ่ม Interactive เพื่อทำการลองเล่นหน้าตา UI ของเราก่อนที่จะ Run ลงเครื่องจริงๆ ได้ด้วย


เนื่องจากเจ้า JetPack Compose นั้นเป็น Declarative UI โดยเจ้า Declarative UI นั้นตัว Code เองคือตัวอธิบายว่าหน้าตา UI จะเป็นอย่างไร
เมื่อ User กดปุ่ม เราต้องการให้ Text แสดงค่า random Text จาก function ที่เราเตรียมไว้ หรือพูดง่ายๆก็คือ Update State ของ Text นั่นเอง
State และ MutableState
เป็น Build-in Interface ของ JetPack Compose โดยปกติ Composable Function จะทำการ "subscribe" กับ value ของ State<T> เมื่อ value มีการเปลี่ยนแปลง Composable Function จะทำการ Recompose ตัวเองเพื่อ Update UI ใหม่
@Stable
interface State<T> {
val value: T
}
โดย value สามารถเปลี่ยนแปลงได้ผ่านทางการเรียก MutableState<T> อีกที
@Stable
interface MutableState<T> : State<T> {
override var value: T
....
}
เพิ่ม State ให้ Function MyScreen
@Composable
fun MyScreen() {
+ var text: String by remember { mutableStateOf(randomDisplayText())}
Row(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text(text)
Button(onClick = {
+ text = randomDisplayText()
}) {
Text(text = "Click Me")
}
}
MyRandomIcon(asset = randomIconAsset())
}
}
Function mutableStateOf จะทำการ return ค่า MutableState โดยในที่นี้เราจะกำหนดค่าเริ่มต้นให้กับ State เลย ด้วยการเรียก randomDisplayText() และเมื่อมีการกดปุ่มเราจะทำการ update ค่าของ text อีกครั้งด้วยการเรียก text = randomDisplayText()
อีกจุดนึงที่สังเกตเห็นได้คือมีการเรียกใช้ function remember ซึ่งจะขออธิบายในลำดับถัดไป
ลองรันแอพ:
ทุกๆครั้งที่เรากดปุ่ม Text ของเราอัพเดทถูกต้อง แต่จะเห็นว่า Icon ก็โดนเปลี่ยนไปด้วย???? 🤨🤨🤨
อย่างที่บอกไว้ก่อนหน้านั้น เนื่องจากว่า Composable function จะ "subscribe" ตัวเองกับ State เมื่อ State เปลี่ยน (ในที่นี้คือ text) function จะมีการ Recompose เพื่อแสดงผลใหม่ ทำให้ ตัว Function เองโดนเรียกซ้ำทำให้ function randomIconAsset โดนเรียกซ้ำอีกที
ลองใส่ Log ที่ MyScreen()
Log.d("MyComposeApp", "MyScreen is called")

สังเกตุได้ว่าค่าที่ Log ไม่ถูก Print เมื่อค่าที่ Random ได้ยังเป็นค่าเดิม
เอ๊ะแล้วแบบนี้มันแตกต่างกับ randomDisplayText() ยังไง 🤔??
Memory ใน Function
จุดที่แตกต่างสำคัญเลยระหว่าง randomDisplayText randomIconAsset ที่ชัดเจนเลยคือ:
-
randomDisplayTextเป็น Initiate value ของ State (เราเรียกrandomDisplayTextในmutableStateOf) -
mutableStateOfถูก wrap ด้วยrememberComposable function จะมีความสามารถในการจดจำค่าก่อนหน้าว่าเป็นค่าอะไร เมื่อเกิดการ Recomposition ขึ้น ตัว composable function จะนำค่าที่จดจำอยู่มาแสดงแทน ในกรณีของเรา เรามีการจดจำ "State<String>" ซึ่งvalueของState<String>ก็คือtextนั่นเอง
var text: String by remember { mutableStateOf(randomDisplayText())}
สามารถเขียนในอีกแบบได้เป็น
var text :MutableState<String> = remember { mutableStateOf(randomDisplayText())}
...
+ Text(text = text.value)
Button(onClick = {
+ text.value = randomDisplayText()
}) {
Text(text = "Click Me")
}
แก้ไม่ให้ Icon Update ทุกครั้ง
วิธีการแก้ในเคสนี้เราสามารถทำได้สองแบบ
แบบที่ 1:
เราสามารถใช้เจ้า remember กับ randomIconAsset ได้ เพื่อให้ค่าที่ถูก random มาครั้งแรก ถูกจดจำไว้:
val asset by remember { randomIconAsset() }
MyRandomIcon(asset = asset)
แบบที่ 2:
เราสามารถทำให้ MyRandomIcon ไม่มีการ Share State กันระหว่างตัวมันเองกับ MyScreen โดยเราสามารถทำได้โดยย้าย randomIconAsset ไปไว้ใน MyRandomIcon แทน
@Composable
fun MyRandomIcon() {
Icon(asset = randomIconAsset())
}
State hoisting
จาก Code ชุดข้างบน เราจะเห็นว่าตัว Function MyScreen เองนั้นเป็นคนถือ State เอาไว้ (Stateful) ในเคสนี้นั้นเราต้องการให้ ViewModel เป็นคนถือ State แทน เรียกว่า hoist/lift State ไปไว้ที่ LiveData ของ ViewModel แทน
เมื่อ User กดปุ่ม เราจะ Update value ใน LiveData ที่อยู่ใน ViewModel เท่ากับเราต้องทำการ
- Bind ค่าที่อยู่ใน LiveData กับ Text
- Bind action ของการกดปุ่มเข้ากับ
onNameChangedfunction ของ ViewModel
ทำการเพิ่ม Parameter ใน Function เป็น String และ lambda สำหรับการแสดงผลและการกดปุ่มและนำ State ของ Function ออก
@Composable
fun MyScreen(
+ text: String,
+ onButtonClick: () -> Unit
) {
- var text : MutableState<String> = remember { mutableStateOf(randomDisplayText()) }
Row(modifier = Modifier
.fillMaxWidth()
.padding(16.dp)) {
Column(modifier = Modifier.weight(1f)) {
+ Text(text)
+ Button(onClick = onButtonClick) {
Text(text = "Click Me")
}
}
val asset by remember { randomIconAsset() }
MyRandomIcon(asset = asset)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewMyScreen() {
ComposeStatePlaygroundTheme {
+ MyScreen("Hello Android") {}
}
}
จาก Code ชุดข้างบนจะทำให้ function MyScreen ไม่มีการถือ State เอาไว้ สำหรับเปลี่ยนแปลงค่า displaying Text (Stateless)
observeAsState
JetPack Compose ได้สร้าง extension function มาให้เราแล้วชื่อ observeAsState เพื่อทำการ Convert LiveData ให้เป็น State
ใน onCreate ทำการ convert LiveData ใน ViewModel ให้เป็น State
setContent {
MyAppTheme {
Surface(color = MaterialTheme.colors.background) {
+ val text by viewModel.name.observeAsState()
MyScreen(
+ text = text,
+ onButtonClick = viewModel::onNameChanged)
}
}
}
ลองรันแอพอีกครั้ง

แน่นอนว่าการทำงานยังเหมือนเดิม สิ่งที่แตกต่างคือ State นั้นอยู่ที่ LiveData ของ ViewModel แทน
จริงๆเรายังสามารถให้ State อยู่ที่ ViewModel ได้โดยตรงด้วยโดย:
เอา LiveData ออก
- private val _name = MutableLiveData("")
- val name: LiveData<String> = _name
เปลี่ยนเป็น State แทน
+ import androidx.compose.runtime.mutableStateOf
+ import androidx.compose.runtime.setValue
+ import androidx.compose.runtime.getValue
class MainViewModel: ViewModel() {
...
+ var name: String by mutableStateOf("")
+ private set
แก้ไข function onNameChanged
fun onNameChanged() {
- _name.value = randomDisplayText()
+ name = randomDisplayText()
}
ใส่ name เข้าไปตรงๆที่ MyScreen:
Surface(color = MaterialTheme.colors.background) {
- val text by viewModel.name.observeAsState()
MyScreen(
- text,
+ viewModel.name,
viewModel::onNameChanged)
}
ผลลัพธ์ที่ได้ก็ยังคงเหมือนเดิม
เราสามารถเรียกใช้ mutableStateOf ใน ViewModel ได้เมื่อเรามั่นใจว่า View นั้นเป็น Compose แต่ถ้าเรายังต้องใช้ ViewModel contact กับ Android View System เดิม ใช้ LiveData หรือ State/Share Flow น่าจะเหมาะสมกว่า
สำหรับคนที่สนใจ แนะนำว่าสามารถศึกษาเพิ่มเติมได้จาก JetPack Compose - Pathway ในบทความหน้าจะลองยกตัวอย่าง Compose UI ที่ซับซ้อนขึ้นเช่นการใช้ LazyColumnFor (RecycelerView ในโลกของ JetPack Compose) รวถึงการใช้ State/ShareFlow หรือหากท่านใดสนใจ สามารถดู source code ตัวอย่างได้จากเจ้าของ Blog ทางนี้เลย





Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.