มาลองเขียนโค้ดด้วย MVI (Model View Intent) Pattern และ Jetpack Compose สำหรับนักพัฒนา Android

Theerapong K
KBTG Life
Published in
7 min readDec 16, 2023

เนื่องจากในโลกของการพัฒนา Android Application มีการแนะนำ Pattern ของการพัฒนาด้วย Model View Intent เข้ามา ควบคู่กับการพัฒนา User Interface ด้วย Jetpack Compose ทำให้เกิดอาการอยากลองอยากทดสอบขึ้นมา ว่าหากเราต้องการพัฒนาแอปใน Pattern นี้จะต้องมีการเขียนโค้ดประมาณไหน และมีความเปลี่ยนแปลงจาก Model-View-ViewModel (MVVM) มากน้อยเพียงใด จึงเป็นที่มาของการทดลองนี้ ที่จะนำไปสู่การเข้าใจ MVI ที่ดียิ่งขึ้น

Model View Intent (MVI)

การทำงานของ MVI หลัก ๆ คือจะมีลักษณะการทำงานแบบ Unidirectional Flow of Data คือการทำงานของ Flow ของข้อมูลในแอปพลิเคชันที่สามารถทำนายและติดตาม Process ของการทำงาน หรือ Flow การทำงานของแอปพลิเคชันแบบทิศทางเดียว

ตัวอย่างเช่น เมื่อ User มีการกระทำ Action บางอย่างที่หน้าจอ Intent จะมีการส่งข้อมูลของ Action นั้นไปยัง Model → จากนั้น Model จะมีการทำ Process ของ Action นั้นของ User ซึ่งจะได้รับ State ใหม่กลับมาเพื่อนำไปอัพเดตที่ View -> Viewที่คอย Observe ค่าจากจาก State นั้นอยู่แล้วก็จะทำการอัพเดตให้กับ UI ตามข้อมูลของ State ที่ได้รับ

MVI Diagram Flow

Model: เป็น Layer ที่ทำหน้าที่ของการจัดการ Business Logic ประกอบด้วยพวก Data Source, Repository ที่ทำหน้าที่จัดการและประมวลผลเกี่ยวกับข้อมูล ซึ่งสามารถเปลี่ยนแปลงไปตามการกระทำของ User

View: เป็น Layer ของส่วนติดต่อผู้ใช้ UI (User Interface) เช่น Activity, Fragment โดยที่ View จะทำหน้าที่ในการแสดงผลข้อมูลที่ได้รับมาจาก Model Layer โดยมี View Model เป็นตัวกลางที่คอย Observe ค่าหรือ State ที่เปลี่ยนแปลง เพื่อส่งไปอัพเดตให้กับ UI

Intent: เป็น Layer ที่ทำหน้าที่เชื่อมต่อระหว่างการกระทำของ User (User Interaction) เช่น การกดปุ่ม หรือป้อนข้อความใน TextField และทำการส่ง Action ที่ User กระทำไปที่ View Model เพื่อให้ Model ทำ Logic บางอย่าง และส่ง State ที่เปลี่ยนแปลงไปมาให้ View แสดงผล

Let’s Apply!

มาดูไอเดียหรือตัวอย่างโจทย์ที่เราจะมาประยุกต์ใช้ MVI Pattern กันครับ ตัวแอปของเราจะมี Requirement และการทำงานกับข้อมูลแบบง่าย ๆ ด้วย CRUD (Create, Read, Update, Delete)

สมมุติว่าเรามี Requirement ฟีเจอร์ที่ต้องการประมาณนี้

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

ก่อนอื่นเรามาเริ่มสร้าง Class เพื่ออธิบายถึง State, Intent และ Effect ที่จะเกิดขึ้นภายในแอปหรือการแสดงผลในแต่ละเคส ดังนี้

  • ProductListIntent คือสิ่งที่ User กระทำหรือ Action ในหน้าจอที่ User ป้อนหรือส่ง Event เข้ามา เช่น การคลิกปุ่ม, การพิมพ์ข้อความ
  • ProductListUiState คือสถานะการแสดงผลของหน้าจอ เช่น เมื่อเวลาโหลดข้อมูลจาก API สำเร็จ, การแสดงผล Error ในหน้าจอ, การแสดง Loading
  • ProductListEffect คือสถานะการแสดงผลของ UI ที่จะกระทำหรือเกิดเหตุการณ์ขึ้นเพียงครั้งเดียว เช่น การแสดง Toast ข้อความ, การ Navigate ไปยังอีก Screen, การ Track Analytics เป็นต้น

การแสดงผลในแอป โดยมี State, Intent และ Effect ตาม Class ที่สร้างขึ้นจะมีหน้าตา ดังนี้

ProductListIntent ในแอปตัวอย่างนี้จะมี Intent หรือ User Action คร่าว ๆ เช่น การเพิ่ม ลบ อัพเดต แสดงผลข้อมูลสินค้า และการแสดง Dialog

sealed interface ProductListIntent {

//user click the add button
object AddProduct : ProductListIntent

//user click the delete product item on the dialog
data class DeleteProduct(val productId: Int) : ProductListIntent

//load the product from the API
object FetchProducts : ProductListIntent

//show dialog
data class ProductActionDialog(val product: ProductModel) : ProductListIntent

//update the product detail
data class UpdateProduct(val productId: Int) : ProductListIntent

//update the number of product
data class UpdateProductTotal(val productId: Int, val productOperator: ProductOperator) : ProductListIntent

}

ProductListUiState จะแสดงผล UI ตาม State เช่น เมื่อกำลังโหลดข้อมูลสินค้าก็แสดง Loading, การแสดงผลรายการสินค้าเมื่อได้รับข้อมูลจาก API Service ที่สำเร็จแบบมีข้อมูล หรือไม่มีข้อมูล และการแสดงผลข้อผิดพลาดเมื่อมี Error

sealed interface ProductListUiState {

//show loading the first time calling the API, example the skeleton loading
object ProductLoading : ProductListUiState

//show a load product error on the screen
data class ProductError(val error: Throwable?) : ProductListUiState

//show all the product lists on the screen.
data class Success(val productList: List<ProductModel>) : ProductListUiState

//show an empty product message on the screen
object EmptyProductList : ProductListUiState

}

ProductListEffect ของหน้าจอจะมีการแสดง Loading เมื่อทำการเพิ่ม ลบ อัพเดตสินค้า และแสดง Toast ข้อความเมื่อพบกรณีที่การทำงานผิดพลาดหรือมี Error

sealed interface ProductListEffect {

//initial state
object Idle : ProductListEffect

//when an action is taken, like adding, deleting, or updating the product, the loading progress bar is shown.
object ProcessLoading : ProductListEffect

//when an action like adding, deleting, or updating is successful, hide the dialog. and show the toast message.
data class ProcessSuccess(val operationType: OperationType) : ProductListEffect

//when an action is an error, it will show a toast message and then hide the dialog.
data class ProcessError(val error: Throwable) : ProductListEffect

}

enum class OperationType {
ADD_PRODUCT,
DELETE_PRODUCT,
UPDATE_PRODUCT_DETAIL,
UPDATE_PRODUCT_QUANTITY
}

ส่วนต่อมาคือ View Model ที่จะทำหน้าที่เป็นตัวกลางในการส่งต่อ User Intent เพื่อไปติดต่อกับ Model เช่น UseCase, Repository และนำผลลัพธ์ของ UI State ที่เปลี่ยนแปลงส่งกลับมาอัพเดตที่หน้าจอ UI การแสดงผล

สำหรับการจัดการ UI Event หรือ User Intent จะใช้ ShareFlowที่จะทำหน้าที่คล้าย SingleLiveEvent ยังสามารถส่งผลลัพธ์ของเหตุการณ์มาได้โดยไม่ต้องมี Subscriber เหมาะกับการส่งข้อมูลแบบ one-time events

สำหรับ UI State จะใช้ StateFlow ที่ทำหน้าที่เหมือน LiveData แต่จะต้องมีการกำหนด State เริ่มต้นเข้าไปด้วย ดังนั้นเราจะมี State ของ UI เสมอ และจะแสดงผลใน UI ด้วย State ล่าสุด

สามารถอ่านเพิ่มเติมความแตกต่างของ ShareFlowที่ และ StateFlow ได้ที่นี่

class ProductListViewModel(
private val getAllProductsUseCase: GetAllProductsUseCase,
private val addProductUseCase: AddProductUseCase,
private val deleteProductUseCase: DeleteProductUseCase,
private val updateProductUseCase: UpdateProductUseCase,
private val updateProductQuantityUseCase: UpdateProductQuantityUseCase,
) : ViewModel() {

private val userIntent = MutableSharedFlow<ProductListIntent>()

private val _productListUiState = MutableStateFlow<ProductListUiState>(ProductListUiState.ProductLoading)
val productListUiState get() = _productListUiState.asStateFlow()

private val _productListEffect = Channel<ProductListEffect>()
val productListEffect get() = _productListEffect.receiveAsFlow()

init {
handleIntent()
}

private fun handleIntent() {
viewModelScope.launch {
userIntent.collectLatest { event ->
when (event) {
is ProductListIntent.FetchProducts -> {
fetchProducts()
}
is ProductListIntent.FetchProductsWithError -> {
fetchProductsWithMockError()
}
is ProductListIntent.AddProduct -> {
addProduct()
}
is ProductListIntent.DeleteProduct -> {
deleteProduct(id = event.productId)
}
is ProductListIntent.ProductActionDialog -> {
showProductDialog(event)
}
is ProductListIntent.UpdateProduct -> {
updateProduct(productId = event.productId)
}
is ProductListIntent.UpdateProductTotal -> {
updateProductTotalNumber(
productId = event.productId,
productOperator = event.productOperator
)
}
else -> Unit
}
}
}
}

private fun fetchProductsWithMockError() {
getAllProductsUseCase.invoke(withMockError = true)...
}

private fun updateProduct(productId: Int) {
updateProductUseCase.invoke(productId = productId)...
}

private fun deleteProduct(id: Int) {
deleteProductUseCase.invoke(id = id)...
}

private fun addProduct() {
addProductUseCase.invoke()...
}

private fun fetchProducts() {
getAllProductsUseCase.invoke()
}

private fun updateProductQuantity(productId: Int, productOperator: ProductOperator) {
updateProductQuantityUseCase.invoke(productId = productId, productOperator = productOperator)...
}

fun sendIntent(productListIntent: ProductListIntent) {
viewModelScope.launch {
userIntent.emit(productListIntent)
}
}
}

หลังจากเสร็จสิ้นในส่วน ProductListViewModel ต่อไปจะเป็นการนำ State ที่เปลี่ยนแปลงจาก Model เพื่อนำไปแสดงผลในหน้าจอตาม State ที่ได้รับด้วย Jetpack Compose รวมถึงการที่ User จะทำการส่ง Event หรือ Action เพื่อไปติดต่อกับ Model

@Composable
fun ProductListRoute(
viewModel: ProductListViewModel = koinViewModel(),
onProductClick: (Int) -> Unit,
onAddProductSuccess: () -> Unit,
currentTopAppBarAction: TopAppBarActionEnum,
) {

val productListUiState by viewModel.productListUiState.collectAsStateWithLifecycle()
val productListEffect by viewModel.productListEffect.collectAsStateWithLifecycle(ProductListEffect.Idle)

LaunchedEffect(Unit) {
val intent = ProductListIntent.FetchProducts
viewModel.sendIntent(intent)
}

ProductListScreen(
isPullToRefresh = isPullToRefresh,
pullRefreshState = pullRefreshState,
onProductClick = { id ->
onProductClick.invoke(id)
},
productListUiState = productListUiState,
productListEffect = productListEffect,
onShowActionDialog = { product ->
val intent = ProductListIntent.ProductActionDialog(product = product)
viewModel.sendIntent(intent = intent)
},
onUpdateProductClick = { productId, operator ->
val intent = ProductListIntent.UpdateProductQuantity(
productId = productId,
productOperator = operator
)
viewModel.sendIntent(intent)
}
)

}

จะเห็นว่าหากเราต้องการส่ง Event ไปบอก View Model ว่ามีการกระทำหรือ Action อะไร สามารถทำได้ด้วยการเรียกผ่าน viewModel.sendIntent()

val intent = ProductListIntent.FetchProducts
viewModel.sendIntent(intent)

...

val intent = ProductListIntent.UpdateProductQuantity(
productId = productId,
productOperator = operator
)
viewModel.sendIntent(intent)

...

val intent = ProductListIntent.ProductActionDialog(product = product)
viewModel.sendIntent(intent = intent)

collectAsStateWithLifecycle ที่ใช้ในการแสดงผล UI จะคอย Observe ข้อมูลจาก State ล่าสุด พร้อมกับทำการ Re-composition หน้าจอทุกครั้งที่มีการเปลี่ยนแปลง และยัง Observe ข้อมูลภายใต้ Life Cycle Aware คือจะ Start และ Stop การ Observe ข้อมูล UI ตาม Life Cycle ของ Android

val productListUiState by viewModel.productListUiState.collectAsStateWithLifecycle()
val productListEffect by viewModel.productListEffect.collectAsStateWithLifecycle(ProductListEffect.Idle)

ต่อมาเป็นการแสดงผล UI ตาม State เช่น Loading, Error, Success

@Composable
fun ProductListScreen(
modifier: Modifier = Modifier,
productListUiState: ProductListUiState,
productListEffect: ProductListEffect,
onProductClick: (Int) -> Unit,
onUpdateProductClick: (productId: Int, ProductOperator) -> Unit,
) {

Box(
modifier = modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {

when (productListUiState) {
is ProductListUiState.ProductLoading -> {
LoadingDialog(loading = true)
}

is ProductListUiState.ProductError -> {
LoadingDialog(loading = false)
ShowProductError(error = productListUiState.error)
}

is ProductListUiState.Success -> {
LoadingDialog(loading = false)
ProductList(
productList = productListUiState.productList,
onProductClick = onProductClick,
onUpdateProductQuantityClick = onUpdateQuantitytClick
)
}

is ProductListUiState.EmptyProductList -> {
LoadingDialog(loading = false)
ShowProductEmpty()
}
}

...

}

}

กรณีที่เราเรียก API Service สำเร็จ ก็จะได้รับข้อมูลให้นำไปแสดงผลใน View Component โดยในที่นี้ได้ทำการแสดงผลแบบ Grid View แบ่งออกเป็น 2 คอลัมน์

@Composable
fun ProductList(
productList: List<ProductModel>,
onProductClick: (Int) -> Unit,
onShowActionDialog: (ProductModel) -> Unit,
onUpdateProductQuantityClick: (productId: Int, ProductOperator) -> Unit,
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2)
) {
items(productList) { product ->
ProductItem(
product = product,
onProductClick = onProductClick,
onShowActionDialog = onShowActionDialog,
onUpdateProductQuantityClick = onUpdateProductQuantityClick
)
}
}
}
@Composable
fun ProductItem(
modifier: Modifier = Modifier,
product: ProductModel,
onProductClick: (Int) -> Unit,
onShowActionDialog: (ProductModel) -> Unit,
onUpdateProductQuantityClick: (productId: Int, ProductOperator) -> Unit,
) {
Column(
modifier = Modifier.padding(8.dp)
) {
Card(
modifier = modifier
.fillMaxWidth()
.combinedClickable(onClick = {
onProductClick.invoke(product.id)
}, onLongClick = {
onShowActionDialog.invoke(product)
}),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = Color.White)
) {

Column(modifier = Modifier.fillMaxWidth()) {
Image(
painter = painterResource(id = R.drawable.baseline_photo_size_select_actual_96),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.height(124.dp)
)

Column(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
) {
Text(
text = product.name,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)

Spacer(modifier = Modifier.height(2.dp))

Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = product.formattedPrice,
fontSize = 12.sp,
fontWeight = FontWeight.Light
)
}

Spacer(modifier = Modifier.height(2.dp))

if (product.total <= 0) {
AddProductButton(
product = product,
onUpdateProductQuantityClick = onUpdateProductQuantityClick
)
} else {
ProductOperation(
product = product,
onUpdateProductQuantityClick = onUpdateProductQuantityClick
)
}

}

}
}
}

}

ยกตัวอย่าง UI Component ส่วนติดต่อผู้ใช้ภายในแอป การสร้าง UI ด้วย Compose จะทำให้เราสามารถสร้าง Component แบ่งเป็นส่วนย่อย ๆ และนำมาประกอบกันเป็น UI ที่เราต้องการ โดยแบ่ง Component ย่อย ๆ ของ UI ไว้คร่าว ๆ ดังนี้

กรณีที่จำนวนของสินค้าเป็น 0 จะแสดงปุ่มเครื่องหมาย +
การแสดงผลกรณีที่ยังไม่มีการเพิ่มสินค้า และเมื่อเพิ่มจำนวนสินค้า
ส่วนของหน้าจอ Product Detail
หน้าจอแสดง Dialog

และสุดท้ายตัวอย่างผลลัพธ์ของแอปที่ได้จากการทดลองใช้ MVI + Compose ก็ออกมาเป็นตามนี้ครับ

ตัวอย่างผลลัพธ์การแสดงผลของแอป

Conclusion

จากการทดลองประยุกต์ใช้ MVI Pattern และ Jetpack Compose ในการพัฒนา Android Application พบข้อเปลี่ยนแปลงและข้อดีคร่าว ๆ ดังนี้

  • สามารถติดตามการเปลี่ยนแปลงของข้อมูล UI ที่ง่ายมากขึ้น เช่น ในกรณีที่มีปุ่มมากมายในหน้าจอ เราสามารถติดตามว่าการกดปุ่มไหนหรือ Action ไหนจะไปทำการเปลี่ยนแปลงข้อมูลในโค้ดส่วนใด หรือที่เรียกว่า Unidirectional Flow of Data
  • หน้าจอมีการแสดงผลตาม UI State ที่กำหนด ทำให้เราเข้าไปแก้โค้ดส่วนของ UI ตาม State ที่ต้องการหากพบการแสดงผลผิดพลาด
  • การทำ Unit Test เนื่องจากแยกส่วนของ Logic ออกจาก Android Framework
  • การ Debug Code และหาข้อผิดพลาด เนื่องจากการทำงานของแต่ละ Event ที่ User ส่งเข้ามาจะถูกแยกตาม Case ของตัวเอง ทำให้การไล่ Debug หรือตามหาข้อผิดพลาดของโค้ดรวดเร็วและตรงจุดมากขึ้น

โดยรวมโครงสร้างของโปรเจคจะมีความคล้ายกับ MVVM แต่จะเพิ่มในส่วนของ State Management ที่จะช่วยให้เราสามารถไล่โค้ดของ Event ที่เกิดขึ้นว่าจะไปเปลี่ยนแปลง Model ที่ส่วนไหน และแสดงผลหน้าจอ UI ตาม State ที่ได้รับ ซึ่งหากพบว่า UI ส่วนใดที่แสดงผลไม่ถูกต้อง ก็สามารถไล่โค้ดเพื่อเข้าไปแก้ไขใน State นั้น ๆ ได้ รวมถึงการไล่ Debug ตามแต่ละเคสเช่นกัน และหากสนใจศึกษาเพิ่มเติม ลองดูโค้ดตัวอย่างกันได้ที่ลิงก์ด้านล่างนี้ครับ

สำหรับใครที่ชื่นชอบบทความนี้ อย่าลืมกดติดตาม Medium: KBTG Life เรามีสาระความรู้และเรื่องราวดีๆ จากชาว KBTG พร้อมเสิร์ฟให้ที่นี่ที่แรก

--

--