ลองเล่น Compose Multiplatform บน Kotlin Multiplatform (Part 2)

Rawipol Wajanavisit
5 min readAug 6, 2023

--

จาก Part 1 เราได้ทำการ setup environment และ clone ตัว template มารอง Run เรียบร้อยแล้ว ในบทความนี้จะพามาทำความเข้าใจ Project Structure และทดลองพัฒนาแอพพลิชั่นง่ายๆขึ้นมากัน

ก่อนอื่นให้สลับ view ของ project hierarchy ทางด้านซ้ายของ Android Studio ให้เป็น Project

Project Structure

โปรเจค Kotlin Multiplatform จะประกอบไปด้วย 3 Module หลัก ประกอบไปด้วย

  • shared คือ Kotlin module เป็นที่อยู่ของ logic ที่จะใช้แชร์ร่วมกันระหว่าง iOS และ Android โดยโค๊ดที่ใช้แชร์ร่วมกันนั้นจะใช้ Gradle เป็น build system
  • androidApp คือ Kotlin module ที่ใช้ build Android application ใช้ Gradle เป็น build system เช่นกัน โดยที่ androidApp module นั้นจะใช้ shared module เป็น Android library
  • iosApp คือ Xcode project ใช้ในการ build iOS application โดยจะใช้ shared module เป็น iOS regular framework หรือ CocoaPods dependency ก็ได้ ซึ่งใน template compose-multiplatform นั้นจะใช้เป็น CocoaPods dependency นั่นเอง
ตัวอย่าง iOS framework distribution ที่มีให้เลือกในหน้าหน้าต่าง New Project

ถัดมาให้ลองแตก shared module > src จะพบกับ source sets 3 ตัวได้แก่ androidMain, commonMain และ iosMain

Source set คือ กลุ่มของ logical ไฟล์ ใต้ Gradle systemโดยที่แต่ละกลุ่มจะมี dependency เป็นของตัวเอง ใน Kotlin Multiplatform แต่ละ source set นั้นจะ target ไปตามแต่ละ platform โดยเราจะโฟกัสไปที่ commonMain ก่อน จะเป็นที่ที่จะใส่โค๊ดที่จะใช้ share ร่วมกันลงไป

การจัดการ dependency สำหรับ multiplatform

สามารถเพิ่ม dependency หรือ library ได้ที่ shared > src > build.gradle.kts โดยใต้ sourceSets scope จะสังเกตุว่ามีการแยกตัวแปรตาม sourcSet มาให้อยู่แล้ว เราสามารถนำ dependency ที่ต้องการมาเพิ่มตาม sourceSet ที่ถูกต้องได้เลย

เริ่มพัฒนา Compose platform กัน

1. ทดลอง render ภาพจาก url ผ่าน internet

ขั้นตอนแรกให้ทำการเพิ่ม dependency สำหรับ render ภาพก่อนโดยเราจะใช้เป็น Kamel ในการ load ภาพ โดยให้เพิ่ม dependency ไปที่ commonMain ใน build.gradle.kts แต่แค่นี้ยังไม่พอ Kamel จำเป็นต้องใช้ Network library อย่าง ktor-client ด้วย ดังนั้นให้เพิ่ม dependency ตามโค๊ดด้านล่าง และกด Sync now

 sourceSets {
val commonMain by getting {
dependencies {
...
implementation("media.kamel:kamel-image:0.6.0")
implementation("io.ktor:ktor-client-core:2.3.2")
}
}
val androidMain by getting {
dependencies {
...
implementation("io.ktor:ktor-client-android:2.3.2")
}
}
val iosMain by creating {
...
dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.2")
}
}
}

ไปที่ commonMain > kotlin > App.kt แล้วทำการเปลี่ยน Image component ที่แสดงรูปภาพเดิมให้ใช้ KamelImage แสดงภาพผ่าน url แทน ดังต่อไปนี้

@Composable
fun App() {
MaterialTheme {
var greetingText by remember { mutableStateOf("Hello, World!") }
var showImage by remember { mutableStateOf(false) }
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = {
greetingText = "Hello, ${getPlatformName()}"
showImage = !showImage
}) {
Text(greetingText)
}
AnimatedVisibility(showImage) {
KamelImage(
asyncPainterResource("https://i.pravatar.cc/300"),
null
)
}
}
}
}

ลองกด Run บน iOS และ Android จะพบว่าบน iOS แสดงผลได้ไม่มีปัญหา แต่บน Android ภาพจะไม่ขึ้นนั่นเป็นเพราะ Android จำเป็นต้องไปเพิ่ม permission เพื่อขอใช้งาน Internet เสียก่อน ให้เพิ่ม permission ที่ androidApp > src > AndroidManifest.xml ดังนี้

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<application
...>
...
</application>

</manifest>

เมื่อ Run จะได้ผลลัพธ์ดังนี้

2. สร้าง ViewModel และต่อ API

การ call API เราจะทำผ่าน ktor-client เช่นเดิม แต่จำเป็นเป็นต้องมี dependency จาก ktor เพิ่มเติม และนอกจากนี้จำเป็นจะต้องมีตัว parse json เป็น model ที่เราเตรียมไว้โดยใช้ kotlinx-serialization

สุดท้ายเราจะ implement architecture pattern ที่ Google แนะนำอย่าง MVVM กันด้วย moko-mvvm โดยให้เพิ่ม dependency ที่ build.gradle.kts ดังนี้

val commonMain by getting {
dependencies {
...
implementation("io.ktor:ktor-client-content-negotiation:2.3.2")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
api("dev.icerock.moko:mvvm-core:0.16.1")
api("dev.icerock.moko:mvvm-compose:0.16.1")
}
}

และเพิ่ม plugin kotlin(“plugin.serialization”) ด้านบนของไฟล์

plugins {
...
kotlin("plugin.serialization") version "1.9.0"
}

ขั้นตอนต่อไปให้ไปสร้างไฟล์ model สำหรับการ parsing json ที่ได้จาก api
ให้ไปที่ src > commonMain > kotlin แล้วสร้าง package ใหม่ขึ้นมาชื่อ model
สร้างไฟล์ Product.kt และ Rating.kt และใส่โค๊ดต่อไปนี้ลงไป

// model/Product.kt
package model

import kotlinx.serialization.Serializable

@Serializable
data class Product(
val category: String?,
val description: String?,
val id: Int?,
val image: String?,
val price: Double?,
val rating: Rating?,
val title: String?
)
// model/Rating.kt
package model

import kotlinx.serialization.Serializable

@Serializable
data class Rating(
val count: Int?,
val rate: Double?
)

หลังจากนั้นให้สร้างไฟล์ AppViewModel.kt ขึ้นมา

import dev.icerock.moko.mvvm.viewmodel.ViewModel
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import model.Product

data class AppUiState(
val products: List<Product> = emptyList(),
)

class AppViewModel : ViewModel() {
private val _uiState = MutableStateFlow<AppUiState>(AppUiState())
val uiState = _uiState.asStateFlow()

private val httpClient = HttpClient {
install(ContentNegotiation) {
json()
}
}

init {
fetchData()
}

override fun onCleared() {
httpClient.close()
}

fun fetchData() {
viewModelScope.launch {
val products = getProducts()
_uiState.update {
it.copy(products = products)
}
}
}

private suspend fun getProducts(): List<Product> {
val response = httpClient
.get("https://fakestoreapi.com/products")
.body<List<Product>>()
return response
}

}

อธิบายโค๊ด
- AppViewModel จะ inherit ViewModel class จาก moko library
- สร้าง classAppUiState ขึ้นมาเพื่อใช้เป็น model ในการ bind กับ UI
- สร้าง StateFlow ของ AppUiState ขึ้นมาเพื่อให้ UI observe
- สร้าง instance ของ HttpClient ขึ้นมาเพื่อใช้ call api
- init scope จะถูกเรียกเมื่อ ViewModel ถูกสร้าง ในที่นี้จะให้เรียก function fetchData เพื่อยิง api
- onCleared เป็น callback ที่จะถูกเรียกเมื่อ compose ถูก destroy ในที่นี้จึงให้สั่ง close ตัว httpClient
- fetchData จะเรียก function getProductsใน coroutine scope ที่ moko ViewModel เตรียมไว้ให้ และทำการ update data ที่ได้ใส่ StateFlow
- getProducts คือ function สำหรับยิง api ในที่นี้จะเป็นการยิงแบบ http get และทำการ parse response body เป็น List<Product>

สามารถอ่านเรื่อง coroutines เพิ่มเติมได้ที่
https://developer.android.com/kotlin/coroutines

อ่านเรื่อง StateFlow และ Flow API ได้ที่
https://developer.android.com/kotlin/flow/stateflow-and-sharedflow

3. Binding data

หลังจากเรามี ViewModel แล้วขั้นตอนสุดท้ายคือสร้าง view layer แล้วนำ uiState ที่เราสร้างเอาไว้แล้ว bind เข้า UI ที่สร้างขึ้นมาด้วย Compose

ให้นำโค๊ดด้านล่างนี้นำไปแทนที่ไฟล์ App.kt ได้เลย

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ShoppingCart
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.icerock.moko.mvvm.compose.getViewModel
import dev.icerock.moko.mvvm.compose.viewModelFactory
import io.kamel.image.KamelImage
import io.kamel.image.asyncPainterResource
import model.Product

@Composable
fun App() {
MaterialTheme {
val viewModel = getViewModel(Unit, viewModelFactory { AppViewModel() })
ProductListingPage(viewModel)
}
}

@Composable
fun ProductListingPage(viewModel: AppViewModel) {
val uiState by viewModel.uiState.collectAsState()

Scaffold(
topBar = {
TopAppBar(
elevation = 4.dp,
title = {
Text(
text = "Shopping App"
)
},
backgroundColor = Color.White
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {},
backgroundColor = MaterialTheme.colors.primary,
shape = AbsoluteRoundedCornerShape(16.dp)
) {
Icon(
imageVector = Icons.Outlined.ShoppingCart,
contentDescription = "Add FAB",
tint = Color.White,
)
}
}
) {
AnimatedVisibility(uiState.products.isNotEmpty()) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(8.dp),
modifier = Modifier.fillMaxSize().background(Color(0xFFECEFF1)),
content = {
items(uiState.products) {
ProductCell(it)
}
}
)
}
}
}

@Composable
fun ProductCell(product: Product) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = AbsoluteRoundedCornerShape(8.dp)
) {
Column {
KamelImage(
asyncPainterResource(product.image ?: ""),
"${product.id}",
contentScale = ContentScale.Inside,
modifier = Modifier.fillMaxWidth().aspectRatio(1.0f).padding(8.dp)
)
Column(
modifier = Modifier.fillMaxWidth().padding(8.dp)
) {
Text(
text = product.title ?: "",
maxLines = 3,
minLines = 3,
overflow = TextOverflow.Ellipsis,
style = TextStyle(
fontFamily = FontFamily.SansSerif
)
)
Text(
text = "$${product.price.toString()}",
modifier = Modifier.fillMaxWidth().absolutePadding(top = 8.dp),
color = MaterialTheme.colors.error,
style = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 16.sp
)
)
}
}

}
}

expect fun getPlatformName(): String

เมื่อลอง Run ก็จะได้ผลลัพธ์ดังภาพนี้ เป็นอันเสร็จสมบูรณ์

บทความนี้จะไม่ได้ลงรายละเอียดเรื่องการเขียน Compose หากต้องการศึกษาเรื่องการเขียน Compose เพิ่มเติมสามารถเข้าไปศึกษาต่อได้ที่
https://developer.android.com/jetpack/compose

หวังว่าบทความนี้จะเป็นประโยชน์แก่ผู้ที่สนใจไม่มากก็น้อย หากมีข้อสงสัยประการใดสามารถทิ้งคอมเม้นถามไว้ได้เลย ขอบคุณครับ

--

--