Skip to content

warrenth/Mocksy

Repository files navigation

DeepDive

Architecture: MVVM, Clean Architecture, Multi-Module
UI: Compose, SharedTransition, Design System
Testing & Code Quality: JUnit, MockK, Turbine, Spotless, MockAPI.io

📷 Previews

drawing

s

1. core:navigation

1.1 멀티 모듈 Compose 에서 화면간 데이터 전달 방법. Navigation 사용법

1.1.1 객체를 Navigation에 직접 전달 (NavType 사용)

@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 클래스를 따로 구현해야 함, 유지보수, 코드량 증가

1.1.2 ID 값만 전달하고, Detail 화면에서 ViewModel로 다시 조회

장점: NavType을 만들 필요 없음, 경로가 가볍고 명확함, Deep link 지원이 쉬움
단점: 화면에서 데이터를 다시 조회해야 함, (cache구현 or API)

1.1.3 SharedViewModel 사용 (상태 공유)

장점: NavArgs 없이 복잡한 객체 전달 가능, 빠름 (메모리에서 직접 가져옴)
단점: ViewModel 생명주기를 잘 관리해야 함, 화면 복원, deep link 처리 어려움

1.1.4 단순한 데이터 (String, Int 등)는 기본 NavArgs 사용

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)

2. Core:Data

2.1 코루틴 Dispatcher 전략 트레이드 오프

2.1.1 ViewModel 에서 Dispatchers.IO 직접 사용

장점: 간단하고 직관적
단점: 테스트 시 Dispatcher.IO 대체 어려움
대안: Dispatchers.setMain(...) 같은 별도 세팅 필요

2.1.2 ViewModelScope에서 Dispatcher 생략 (launch {})

장점: 코드 깔끔, Retrofit + suspend 조합에선 내부적으로 Dispatcher 사용하여 별도 정의 필요 없음
단점: Room, 비-Retrofit 작업 을 같이 쓸 경우 MainThread 에서 실행 -> ANR
참고 오픈소스 : DroidKnights → 대부분의 suspend 호출에서 Dispatcher를 생략

2.1.3 Dispatcher 주입 + Repository 내부에서 flowOn(...) 사용

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 (기본)
}

2.2 sandwich

2.2.1 왜 sandwich 를 쓰는가?

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>>

2.2.2 suspendOnError 란?

응답은 받았지만 응답코드가 200이 아닌 경우
예) HTTP 4xx, 5xx

2.2.3 onException 란? 에러는 분기처리하고싶을때?

서버 연결 실패, 응답 파싱 실패
예) 인터넷 없음 (UnknownHostException)
타임아웃 (SocketTimeoutException)
JSON 파싱 실패 (JsonParseException)

apiService.getSomething().onException {
    if (exception is UnknownHostException) {
        // 네트워크 끊김일 경우
    } else if (exception is SocketTimeoutException) {
        // 응답이 너무 늦는 경우
    }
}

2.2.4 sandwich는 클린아키텍처 domain 계층에 의존성 생길까?

ApiResponse는 Sandwich 라이브러리 타입으로, data계층에 속함
domain 계층은 외부 라이브러리에 의존하면 안됨.
APIResponse 로 받은걸 별도의 Result로 만들거나 순수 객체로 넘겨줘야 하기 때문에 클린아키텍처에서는 잘 사용하지 않는다. 클린아키텍처를 사용하지 않고 빠르게 앱을 개발하기에는 좋을 수 있다.

2. Core:DesignSystem

2.1 디자인 시스템

Jetpack Compose 기반 앱에서 공통 디자인 묶음(색상, 배경, 테마)을 전역적으로 관리하고,
다크/라이트 모드 대응 및 미리보기, 테스트, 재사용성을 향상시키기 위한 디자인 시스템 모듈

2.1.1 ArticleColors

앱 전반에 사용되는 색상값을 정의한 데이터 클래스입니다. defaultLightColors(), defaultDarkColors()를 통해 다크/라이트 테마를 자동 분기

2.1.2 ArticlesBackground

배경 색상과 elevation 정보를 묶은 테마 전용 클래스입니다. 라이트/다크 모드에 따라 배경을 자동 설정

2.1.3 ArticleTheme

앱 전체 또는 특정 화면에 테마를 적용하는 래퍼 함수 CompositionLocalProvider 를 사용해 색상/배경을 하위 Composable 주입 예) ArticlesTheme.colors.backgroundLight 로 Composable 어디서든 전역으로 접근 가능

2.1.4 staticCompositionLocalOf, compositionLocalOf

val LocalBackgroundTheme = staticCompositionLocalOf { ArticleBackground() }

ArticleBackground는 거의 고정 값 (예: 테마용 배경) 변경될 일이 거의 없고, 변경되더라도 UI를 리렌더링하지 않아도 되는 경우 Recomposition 최적화됨 → 성능 향상

val LocalColors = compositionLocalOf<ArticleColors> {
    error("No colors provided")
}

ArticleColors는 다크/라이트 등으로 바뀔 수 있는 동적 값 이 값이 변경되면 해당 값을 사용한 컴포저블들이 자동으로 Recomposition 됨 UI가 동적으로 반응해야 하는 경우 사용

3. Core:DesignSystem/util

3.1 LocalWindowSizeClass 를 CompositionLocal + 공통 유틸 처리

// 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()) { ... }

4. Core:Feature

4.1 SharedTransition 정의 두가지 방법 (인자 전달 , 전역 스코프 정의)

4.1.1 인자 전달 방법

SharedTransitionLayout
└── NavHost
├── composable<Home>
│   └── HomeScreen(animatedVisibilityScope, sharedTransitionScope)
└── composable<Detail>
    └── DetailScreen(animatedVisibilityScope, sharedTransitionScope)

4.1.2 전역 스코프 CompositionLocalProvider

core:navigation 에 LocalSharedTransitionScope 를 정의하고 feature가 사용. feature 모듈 간 SharedTransitionScope, AnimatedVisibilityScope 인자를 받지 않아 불필요한 의존성을 줄인다. (관심사 분리)

SharedTransitionLayout
└── CompositionLocalProvider(LocalSharedTransitionScope)
└── NavHost
├── composable<Home>
│   └── CompositionLocalProvider(LocalAnimatedVisibilityScope)
│       └── HomeScreen() // 내부에서 Local*.current 로 스코프 접근
└── composable<Detail>
    └── CompositionLocalProvider(LocalAnimatedVisibilityScope)
        └── DetailScreen()

Spotless

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 적용

5. Core:DataStore

5.1 SOLID 원칙 적용

책임 분리 : core:datastore 는 구현 및 DI 제공의 책임, core:data 직접 바인딩해서 사용하는 책임
의존성 역전 : core:data 는 core:datastore 의 추상타입에 의존하며 실제 구현은 datastore에 위치

5.1.1 DataStoreModule 모듈 구성

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
}

About

Multi-Module, SharedTransition, DesignSystem, Navigation, Responsive UI

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •  

Languages