Architecture: MVVM, Clean Architecture, Multi-Module
UI: Compose, SharedTransition, Design System
Testing & Code Quality: JUnit, MockK, Turbine, Spotless, MockAPI.io
@Serializable
data class Detail(val article: Article) : RouteScreen() {
companion object {
val typeMap = mapOf(typeOf<Detail>() to ArticlesType)
}
}
object ArticlesType : NavType<Article>(isNullableAllowed = false) {
override fun put(bundle: Bundle, key: String, value: Article) {
bundle.putParcelable(key, value)
}
override fun get(bundle: Bundle, key: String): Article? =
BundleCompat.getParcelable(bundle, key, Article::class.java)
override fun parseValue(value: String): Article =
Json.decodeFromString(Uri.decode(value))
override fun serializeAsValue(value: Article): String =
Uri.encode(Json.encodeToString(value))
}์ฅ์ : ์ปดํ์ผ ํ์์ ํ์
์ฒดํฌ ๊ฐ๋ฅ (ํ์
์์ ์ฑ), ๊ฐ์ฒด ์ง๋ ฌํ๋ฅผ ํตํด route์ ์ง์ encode ๊ฐ๋ฅ
๋จ์ : ๊ฐ์ฒด๋ง๋ค NavType ํด๋์ค๋ฅผ ๋ฐ๋ก ๊ตฌํํด์ผ ํจ, ์ ์ง๋ณด์, ์ฝ๋๋ ์ฆ๊ฐ
์ฅ์ : NavType์ ๋ง๋ค ํ์ ์์, ๊ฒฝ๋ก๊ฐ ๊ฐ๋ณ๊ณ ๋ช
ํํจ, Deep link ์ง์์ด ์ฌ์
๋จ์ : ํ๋ฉด์์ ๋ฐ์ดํฐ๋ฅผ ๋ค์ ์กฐํํด์ผ ํจ, (cache๊ตฌํ or API)
์ฅ์ : NavArgs ์์ด ๋ณต์กํ ๊ฐ์ฒด ์ ๋ฌ ๊ฐ๋ฅ, ๋น ๋ฆ (๋ฉ๋ชจ๋ฆฌ์์ ์ง์ ๊ฐ์ ธ์ด)
๋จ์ : ViewModel ์๋ช
์ฃผ๊ธฐ๋ฅผ ์ ๊ด๋ฆฌํด์ผ ํจ, ํ๋ฉด ๋ณต์, deep link ์ฒ๋ฆฌ ์ด๋ ค์
NavHost(navController = navHostController, startDestination = "home") {
composable("home") {
HomeScreen(
onNavigateToDetail = { id, title ->
navHostController.navigate("detail/$id/$title")
}
)
}
composable(
route = "detail/{id}/{title}",
arguments = listOf(
navArgument("id") { type = NavType.IntType },
navArgument("title") { type = NavType.StringType }
)
) {
val id = it.arguments?.getInt("id")
val title = it.arguments?.getString("title")
DetailScreen(id = id, title = title)
}
}์ฅ์ : ์ค์ ์ด ๊ฐ๋จํ๊ณ ์ง๊ด์ , ๊ธฐ๋ณธ ํ์
์ NavType ์๋ ์ง์
๋จ์ : ๋ณต์กํ ๊ฐ์ฒด๋ ์ ๋ฌํ ์ ์์
๐ ์ํฉ์ ๋ฐ๋ผ ์ถ์ฒ๋๋ ๋ฐฉ์
- ๋จ์ ํ์ (String, Int ๋ฑ) ์ ๋ฌ -> ๊ธฐ๋ณธ NavArgs ์ฌ์ฉ
- ๊ฐ์ฒด ์ ๋ฌ & deep link ํ์ -> NavType + Serializable ์ฌ์ฉ
- ID ๊ธฐ๋ฐ ์กฐํ ๊ตฌ์กฐ -> ID๋ง ๋๊ธฐ๊ณ ViewModel ์์ ์กฐํ (์บ์ or API)
์ฅ์ : ๊ฐ๋จํ๊ณ ์ง๊ด์
๋จ์ : ํ ์คํธ ์ Dispatcher.IO ๋์ฒด ์ด๋ ค์
๋์: Dispatchers.setMain(...) ๊ฐ์ ๋ณ๋ ์ธํ ํ์
์ฅ์ : ์ฝ๋ ๊น๋, Retrofit + suspend ์กฐํฉ์์ ๋ด๋ถ์ ์ผ๋ก Dispatcher ์ฌ์ฉํ์ฌ ๋ณ๋ ์ ์ ํ์ ์์
๋จ์ : Room, ๋น-Retrofit ์์ ์ ๊ฐ์ด ์ธ ๊ฒฝ์ฐ MainThread ์์ ์คํ -> ANR
์ฐธ๊ณ ์คํ์์ค : DroidKnights โ ๋๋ถ๋ถ์ suspend ํธ์ถ์์ Dispatcher๋ฅผ ์๋ต
internal class ArticlesRepositoryImpl @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher
) {
override fun getArticles(): Flow<List<Article>> =
flow {
emit(api.getArticles())
}.flowOn(ioDispatcher)
}์ฅ์ : Dispatcher ์ฃผ์ ์ผ๋ก ํ ์คํธ ์ ์ ์ฐํ๊ฒ ๋ณ๊ฒฝ ๊ฐ๋ฅ
๋จ์ : ์ฝ๋ ์ด๊ธฐ ์ธํ ํ์
์ฐธ๊ณ ์คํ์์ค : Now in Android, Skydoves โ Repository์์ Dispatcher ์ฃผ์ ํ flowOn(...) ์ฒ๋ฆฌ
๐ ์ํฉ์ ๋ฐ๋ผ ์ถ์ฒ ๋ฐฉ์
1. ๋น ๋ฅด๊ฒ ์ฑ ๊ตฌ์ฑ / Dispatcher ์ดํด ๋ฎ์ -> 1๋ฒ ๋ฐฉ๋ฒ ViewModel์์ Dispatchers.IO
2. ๋๋ถ๋ถ Retrofit์ด๊ณ , UI์ ์ง์ค -> 2๋ฒ Dispatcher ์๋ต
3. ํ
์คํธ, ๋ฉํฐ๋ชจ๋, ์ํคํ
์ฒ ์ค์ -> โข Dispatcher ์ฃผ์
+ flowOn ์ฒ๋ฆฌ
4. (์ถ์ฒ) ์ค๋ฌด์ฉ + Dispatcher ์ฃผ์
์ถ๊ฐ + ํ์ฑ ์ต์ ํ
ViewModel.launch { // ์ ์ ์์ MainThread
โโโ repository.getArticles() // Flow ์์๋จ
โโโ flowOn(IO) // API ํธ์ถ IO์์ ์ํ
โโโ .map { sort }
โโโ .flowOn(Default) // ์ ๋ ฌ, ํ์ฑ์ Default์์ ์ํ
โโโ .collect { _uiState.value = it } // ์ต์ข
UI ๋ฐ์์ Main (๊ธฐ๋ณธ)
}
Retrofit ์ ๋ฌธ์ ์
- try/catch + null ์ฒ๋ฆฌ ์ง์ฅ
- ๊ณตํต ์๋ฌ ์ฒ๋ฆฌ
- ๋ฐ๋ณต์ ์ธ if (isSuccessful) ์ฒดํฌ
// Retrofit์ ๊ธฐ๋ณธ Call<T> โ ApiResponse<T>๋ก ์๋ ๋ํ
addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
// ๊ธฐ๋ณธ ํํ
suspend fun getArticles(): Response<List<Article>>
// Sandwich๊ฐ ๋ง๋ ํํ
suspend fun getArticles(): ApiResponse<List<Article>>
์๋ต์ ๋ฐ์์ง๋ง ์๋ต์ฝ๋๊ฐ 200์ด ์๋ ๊ฒฝ์ฐ
์) HTTP 4xx, 5xx
์๋ฒ ์ฐ๊ฒฐ ์คํจ, ์๋ต ํ์ฑ ์คํจ
์) ์ธํฐ๋ท ์์ (UnknownHostException)
ํ์์์ (SocketTimeoutException)
JSON ํ์ฑ ์คํจ (JsonParseException)
apiService.getSomething().onException {
if (exception is UnknownHostException) {
// ๋คํธ์ํฌ ๋๊น์ผ ๊ฒฝ์ฐ
} else if (exception is SocketTimeoutException) {
// ์๋ต์ด ๋๋ฌด ๋ฆ๋ ๊ฒฝ์ฐ
}
}
ApiResponse๋ Sandwich ๋ผ์ด๋ธ๋ฌ๋ฆฌ ํ์ ์ผ๋ก, data๊ณ์ธต์ ์ํจ
domain ๊ณ์ธต์ ์ธ๋ถ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ ์์กดํ๋ฉด ์๋จ.
APIResponse ๋ก ๋ฐ์๊ฑธ ๋ณ๋์ Result๋ก ๋ง๋ค๊ฑฐ๋ ์์ ๊ฐ์ฒด๋ก ๋๊ฒจ์ค์ผ ํ๊ธฐ ๋๋ฌธ์ ํด๋ฆฐ์ํคํ ์ฒ์์๋ ์ ์ฌ์ฉํ์ง ์๋๋ค. ํด๋ฆฐ์ํคํ ์ฒ๋ฅผ ์ฌ์ฉํ์ง ์๊ณ ๋น ๋ฅด๊ฒ ์ฑ์ ๊ฐ๋ฐํ๊ธฐ์๋ ์ข์ ์ ์๋ค.
Jetpack Compose ๊ธฐ๋ฐ ์ฑ์์ ๊ณตํต ๋์์ธ ๋ฌถ์(์์, ๋ฐฐ๊ฒฝ, ํ ๋ง)์ ์ ์ญ์ ์ผ๋ก ๊ด๋ฆฌํ๊ณ ,
๋คํฌ/๋ผ์ดํธ ๋ชจ๋ ๋์ ๋ฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ, ํ ์คํธ, ์ฌ์ฌ์ฉ์ฑ์ ํฅ์์ํค๊ธฐ ์ํ ๋์์ธ ์์คํ ๋ชจ๋
์ฑ ์ ๋ฐ์ ์ฌ์ฉ๋๋ ์์๊ฐ์ ์ ์ํ ๋ฐ์ดํฐ ํด๋์ค์ ๋๋ค.
defaultLightColors(),defaultDarkColors()๋ฅผ ํตํด ๋คํฌ/๋ผ์ดํธ ํ ๋ง๋ฅผ ์๋ ๋ถ๊ธฐ
๋ฐฐ๊ฒฝ ์์๊ณผ elevation ์ ๋ณด๋ฅผ ๋ฌถ์ ํ ๋ง ์ ์ฉ ํด๋์ค์ ๋๋ค. ๋ผ์ดํธ/๋คํฌ ๋ชจ๋์ ๋ฐ๋ผ ๋ฐฐ๊ฒฝ์ ์๋ ์ค์
์ฑ ์ ์ฒด ๋๋ ํน์ ํ๋ฉด์ ํ ๋ง๋ฅผ ์ ์ฉํ๋ ๋ํผ ํจ์ CompositionLocalProvider ๋ฅผ ์ฌ์ฉํด ์์/๋ฐฐ๊ฒฝ์ ํ์ Composable ์ฃผ์ ์) ArticlesTheme.colors.backgroundLight ๋ก Composable ์ด๋์๋ ์ ์ญ์ผ๋ก ์ ๊ทผ ๊ฐ๋ฅ
val LocalBackgroundTheme = staticCompositionLocalOf { ArticleBackground() }ArticleBackground๋ ๊ฑฐ์ ๊ณ ์ ๊ฐ (์: ํ ๋ง์ฉ ๋ฐฐ๊ฒฝ) ๋ณ๊ฒฝ๋ ์ผ์ด ๊ฑฐ์ ์๊ณ , ๋ณ๊ฒฝ๋๋๋ผ๋ UI๋ฅผ ๋ฆฌ๋ ๋๋งํ์ง ์์๋ ๋๋ ๊ฒฝ์ฐ Recomposition ์ต์ ํ๋จ โ ์ฑ๋ฅ ํฅ์
val LocalColors = compositionLocalOf<ArticleColors> {
error("No colors provided")
}ArticleColors๋ ๋คํฌ/๋ผ์ดํธ ๋ฑ์ผ๋ก ๋ฐ๋ ์ ์๋ ๋์ ๊ฐ ์ด ๊ฐ์ด ๋ณ๊ฒฝ๋๋ฉด ํด๋น ๊ฐ์ ์ฌ์ฉํ ์ปดํฌ์ ๋ธ๋ค์ด ์๋์ผ๋ก Recomposition ๋จ UI๊ฐ ๋์ ์ผ๋ก ๋ฐ์ํด์ผ ํ๋ ๊ฒฝ์ฐ ์ฌ์ฉ
// Local ์ ์
val LocalWindowSizeClass = staticCompositionLocalOf<WindowSizeClass> {
error("WindowSizeClass not provided")
}// CompositionLocalProvider ์ค์
val windowSizeClass = calculateWindowSizeClass(LocalContext.current as Activity)
CompositionLocalProvider(LocalWindowSizeClass provides windowSizeClass) {
AppContent()
}// ๋ฐ์ํ ๊ณ์ฐ
@Composable
fun rememberResponsiveGridCells(): GridCells {
return when (LocalWindowSizeClass.current.widthSizeClass) {
WindowWidthSizeClass.Compact -> GridCells.Fixed(2)
WindowWidthSizeClass.Medium -> GridCells.Adaptive(160.dp)
WindowWidthSizeClass.Expanded -> GridCells.Adaptive(200.dp)
else -> GridCells.Fixed(2)
}
}// ์ฌ์ฉ ์์
LazyVerticalGrid(columns = rememberResponsiveGridCells()) { ... }SharedTransitionLayout
โโโ NavHost
โโโ composable<Home>
โ โโโ HomeScreen(animatedVisibilityScope, sharedTransitionScope)
โโโ composable<Detail>
โโโ DetailScreen(animatedVisibilityScope, sharedTransitionScope)core:navigation ์ LocalSharedTransitionScope ๋ฅผ ์ ์ํ๊ณ feature๊ฐ ์ฌ์ฉ. feature ๋ชจ๋ ๊ฐ SharedTransitionScope, AnimatedVisibilityScope ์ธ์๋ฅผ ๋ฐ์ง ์์ ๋ถํ์ํ ์์กด์ฑ์ ์ค์ธ๋ค. (๊ด์ฌ์ฌ ๋ถ๋ฆฌ)
SharedTransitionLayout
โโโ CompositionLocalProvider(LocalSharedTransitionScope)
โโโ NavHost
โโโ composable<Home>
โ โโโ CompositionLocalProvider(LocalAnimatedVisibilityScope)
โ โโโ HomeScreen() // ๋ด๋ถ์์ Local*.current ๋ก ์ค์ฝํ ์ ๊ทผ
โโโ composable<Detail>
โโโ CompositionLocalProvider(LocalAnimatedVisibilityScope)
โโโ DetailScreen()SpotLess ์ฝ๋ ํฌ๋ฉงํฐ ๋๊ตฌ Kotlin ๋ฑ ๋ค์ํ ์ธ์ด์์ ์๋์ผ๋ก ์ฝ๋ฉ ์คํ์ผ์ ํต์ผํ๋ค.
class SpotlessConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
...
}
}Spotless ์ค์ ์ ๊ณตํตํํด์ Convention Plugin ์ผ๋ก ์ ์ ๋ชจ๋ ๋ชจ๋์ ์ค๋ณต์ ์ ๋ ๋์ plugin ํ๋๋ง apply ํ๋ฉด ๋๋ค.
์ ์ฒด ๊ฒ์ฌ & ์์ ./gradlew spotlessCheck ./gradlew spotlessApply
๋ชจ๋ ๋จ์ ๊ฒ์ฌ & ์์ ./gradlew :feature:home:spotlessCheck ./gradlew :feature:home:spotlessApply
๊ฐ๋ณ๋ก ์์ ํฌ๋ฉง ์๋ ์ ์ฉ or Action Save As ํ์ฉ CI ์์๋ spotlessCheck ์ ์ฉ
์ฑ ์ ๋ถ๋ฆฌ : core:datastore ๋ ๊ตฌํ ๋ฐ DI ์ ๊ณต์ ์ฑ ์, core:data ์ง์ ๋ฐ์ธ๋ฉํด์ ์ฌ์ฉํ๋ ์ฑ ์
์์กด์ฑ ์ญ์ : core:data ๋ core:datastore ์ ์ถ์ํ์ ์ ์์กดํ๋ฉฐ ์ค์ ๊ตฌํ์ datastore์ ์์น
DefaultLikedPreferencesDataStore ์์ @ApplicationContext Hilt๊ฐ ์ฃผ์ ๊ฐ๋ฅ ํ์ง๋ง,
Kotlin ์ปดํ์ผ ํ์์ Context.dataStore์ ํ์ฅ ํ๋กํผํฐ๋ฅผ ์ฝ๋๋ก ์์ฑํ๊ธฐ ๋๋ฌธ์ ์์ฑ์ ๋ด๋ถ์์ ์ฌ์ฉ๋ถ๊ฐ.
์ด๊ธฐํ ํ์ด๋ฐ, ์ปดํ์ผํ์ ์์ ์ฑ์ ๊ณ ๋ คํ์ฌ DataStore ๋ hilt๋ฅผ ํตํด provide ๋ก ์ฃผ์ ํ๋๊ฒ ์ข๋ค.
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
private const val LIKE_DATA_STORE_NAME = "LIKE_PREFERENCES"
private val Context.likeDataStore by preferencesDataStore(LIKE_DATA_STORE_NAME) //์ปดํ์ผ ํ์์ ์์ฑ
@Provides
@Singleton
fun provideLikeDataStore(
@ApplicationContext context: Context
) : DataStore<Preferences> = context.likeDataStore
}