기타 고려사항

뷰에서 Compose로의 이전은 순전히 UI와 관련이 있지만, 안전하고 점진적인 이전을 진행하려면 여러 가지를 고려해야 합니다. 이 페이지에서는 뷰 기반 앱을 Compose로 이전할 때 유의해야 하는 몇 가지 고려사항을 알아봅니다.

앱 테마 이전

Android 앱 테마 설정에 권장되는 디자인 시스템은 Material Design입니다.

뷰 기반 앱은 다음 세 가지 버전의 Material을 사용할 수 있습니다.

  • AppCompat 라이브러리를 사용하는 Material Design 1(Theme.AppCompat.*)
  • MDC-Android 라이브러리를 사용하는 Material Design 2 (Theme.MaterialComponents.*)
  • MDC-Android 라이브러리를 사용하는 Material Design 3 (Theme.Material3.*)

Compose 앱은 다음 두 가지 버전의 Material을 사용할 수 있습니다.

  • Compose Material 라이브러리를 사용하는 Material Design 2(androidx.compose.material.MaterialTheme)
  • Compose Material 3 라이브러리를 사용하는 Material Design 3(androidx.compose.material3.MaterialTheme)

앱의 디자인 시스템이 지원하는 경우 최신 버전(Material 3)을 사용하는 것이 좋습니다. 뷰 및 Compose를 위한 이전 가이드가 준비되어 있습니다.

Compose에서 새 화면을 만들 때는 사용 중인 Material Design 버전과 관계없이 먼저 MaterialTheme을 적용한 후에 Compose Material 라이브러리에서 UI를 내보내는 컴포저블을 적용해야 합니다. Material 구성요소(Button, Text 등)는 설정된 MaterialTheme에 종속되며 동작도 이 항목이 없으면 정의되지 않습니다.

모든 Jetpack Compose 샘플MaterialTheme을 기반으로 빌드된 맞춤 Compose 테마를 사용합니다.

자세한 내용은 Compose의 디자인 시스템Compose로 XML 테마 이전을 참고하세요.

앱에 탐색 구성요소를 사용하는 경우 Compose를 통해 이동 - 상호 운용성Jetpack Navigation을 Navigation Compose로 이전을 참고하세요.

혼합 Compose/뷰 UI 테스트

앱의 일부를 Compose로 이전한 후에 어떤 것도 손상되지 않았는지 테스트해 보는 것이 중요합니다.

활동 또는 프래그먼트에 Compose를 사용한다면 ActivityScenarioRule 대신 createAndroidComposeRule을 사용해야 합니다. createAndroidComposeRuleActivityScenarioRuleComposeTestRule과 통합하므로 Compose와 뷰 코드를 동시에 테스트할 수 있습니다.

class MyActivityTest {
    @Rule
    @JvmField
    val composeTestRule = createAndroidComposeRule<MyActivity>()

    @Test
    fun testGreeting() {
        val greeting = InstrumentationRegistry.getInstrumentation()
            .targetContext.resources.getString(R.string.greeting)

        composeTestRule.onNodeWithText(greeting).assertIsDisplayed()
    }
}

테스트에 관한 자세한 내용은 Compose 레이아웃 테스트를 참고하세요. UI 테스트 프레임워크와의 상호 운용성에 관한 자세한 내용은 Espresso와의 상호 운용성UiAutomator와의 상호 운용성을 참고하세요.

Compose를 기존 앱 아키텍처와 통합

UDF(단방향 데이터 흐름) 아키텍처 패턴은 Compose에서 원활하게 작동합니다. 앱에서 UDF 대신 MVP(Model View Presenter) 같은 다른 유형의 아키텍처 패턴을 사용하는 경우에는 Compose 채택 전이나 채택 과정에서 UI의 관련 부분을 UDF로 이전하는 것이 좋습니다.

Compose에서 ViewModel 사용

아키텍처 구성요소 ViewModel 라이브러리를 사용하는 경우 viewModel() 함수를 호출하여 컴포저블에서 ViewModel에 액세스할 수 있습니다(Compose 및 기타 라이브러리 참고).

Compose를 채택할 때 서로 다른 컴포저블에 동일한 ViewModel 유형을 사용하는 것에 주의해야 합니다. ViewModel 요소가 뷰 수명 주기 범위를 따르기 때문입니다. 이 범위는 호스트 활동, 프래그먼트 또는 탐색 그래프(탐색 라이브러리가 사용되는 경우)가 됩니다.

예를 들어 활동에서 컴포저블이 호스팅되면 viewModel()은 항상 동일한 인스턴스를 반환하고 이 인스턴스는 활동이 끝날 때만 지워집니다. 다음 예에서는 동일한 GreetingViewModel 인스턴스가 호스트 활동 아래에 있는 모든 컴포저블에 재사용되므로 동일한 사용자('user1')에게 인사말이 두 번 표시됩니다. 생성된 첫 번째 ViewModel 인스턴스가 다른 컴포저블에 재사용됩니다.

class GreetingActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MaterialTheme {
                Column {
                    GreetingScreen("user1")
                    GreetingScreen("user2")
                }
            }
        }
    }
}

@Composable
fun GreetingScreen(
    userId: String,
    viewModel: GreetingViewModel = viewModel(  
        factory = GreetingViewModelFactory(userId)  
    )
) {
    val messageUser by viewModel.message.observeAsState("")
    Text(messageUser)
}

class GreetingViewModel(private val userId: String) : ViewModel() {
    private val _message = MutableLiveData("Hi $userId")
    val message: LiveData<String> = _message
}

class GreetingViewModelFactory(private val userId: String) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return GreetingViewModel(userId) as T
    }
}

또한 탐색 그래프에 따라 ViewModel 요소의 범위가 지정되므로 탐색 그래프에서 대상에 해당하는 컴포저블은 ViewModel의 다른 인스턴스를 갖습니다. 이 경우 ViewModel은 관련 대상의 수명 주기로 범위가 국한되고 대상이 백스택에서 삭제되면 지워집니다. 다음 예에서는 사용자가 프로필 화면으로 이동하면 GreetingViewModel의 새 인스턴스가 생성됩니다.

@Composable
fun MyApp() {
    NavHost(rememberNavController(), startDestination = "profile/{userId}") {
        /* ... */
        composable("profile/{userId}") { backStackEntry ->
            GreetingScreen(backStackEntry.arguments?.getString("userId") ?: "")
        }
    }
}

상태 정보 소스

Compose를 UI의 특정 부분에 채택할 경우 Compose와 뷰 시스템 코드 간에 데이터를 공유해야 할 수 있습니다. 가능한 경우 두 플랫폼 모두에 적용된 UDF 권장사항을 따르는 또 다른 클래스에 공유 상태를 캡슐화하는 것이 좋습니다. 예를 들면 공유된 데이터의 스트림을 노출해 데이터 업데이트를 내보내는 ViewModel에 캡슐화하는 것이 좋습니다.

하지만 이 방법은 공유할 데이터가 변경 가능한 요소이거나 UI 요소와 긴밀히 연결된 경우에는 가능하지 않을 수도 있습니다. 이 경우 어느 한 시스템이 정보 소스여야 하고, 그 시스템이 데이터 업데이트를 다른 시스템과 공유해야 합니다. 일반적으로 정보 소스는 UI 계층 구조의 루트에 더 가까운 요소가 소유해야 합니다.

정보 소스로서의 Compose

SideEffect 컴포저블을 사용하여 Compose 상태를 비 Compose 코드에 게시할 수 있습니다. 이 경우 정보 소스는 상태 업데이트를 전송하는 컴포저블에 보관됩니다.

예를 들어 애널리틱스 라이브러리를 사용하면 커스텀 메타데이터(이 예에서는 사용자 속성)를 이후의 모든 애널리틱스 이벤트에 연결하여 사용자 인구를 분류할 수 있습니다. 현재 사용자의 사용자 유형을 애널리틱스 라이브러리에 전달하려면 SideEffect를 사용하여 값을 업데이트합니다.

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

자세한 내용은 Compose의 부수 효과를 참고하세요.

정보 소스로서의 뷰 시스템

뷰 시스템이 상태를 소유하고 이를 Compose와 공유하는 경우 mutableStateOf 객체에 상태를 래핑하여 Compose에서 상태가 스레드로부터 안전하도록 만들어야 합니다. 이 접근 방법을 사용하는 경우 구성 가능한 함수는 더 이상 정보 소스를 갖지 않기 때문에 단순해집니다. 하지만 뷰 시스템이 변경 가능한 상태와, 그 상태를 사용하는 뷰를 업데이트해야 합니다.

다음 예에서 CustomViewGroup은 내부에 TextField 컴포저블이 있는 TextViewComposeView를 포함하고 있습니다. TextView는 사용자가 TextField에 입력하는 내용을 표시해야 합니다.

class CustomViewGroup @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : LinearLayout(context, attrs, defStyle) {

    // Source of truth in the View system as mutableStateOf
    // to make it thread-safe for Compose
    private var text by mutableStateOf("")

    private val textView: TextView

    init {
        orientation = VERTICAL

        textView = TextView(context)
        val composeView = ComposeView(context).apply {
            setContent {
                MaterialTheme {
                    TextField(value = text, onValueChange = { updateState(it) })
                }
            }
        }

        addView(textView)
        addView(composeView)
    }

    // Update both the source of truth and the TextView
    private fun updateState(newValue: String) {
        text = newValue
        textView.text = newValue
    }
}

공유된 UI 이전

Compose로 점진적으로 이전하는 경우 공유된 UI 요소를 Compose와 뷰 시스템에 모두 사용해야 할 수 있습니다. 예를 들어 앱에 맞춤 CallToActionButton 구성요소가 있으면 Compose와 뷰 기반 화면에 모두 이 구성요소를 사용해야 할 수 있습니다.

Compose에서 공유된 UI 요소는 그 요소가 XML을 사용하여 스타일이 지정된 것인지 아니면 맞춤 뷰인지에 관계없이 앱 전체에서 재사용할 수 있는 컴포저블이 됩니다. 예를 들어 맞춤 클릭 유도 문구 Button 구성요소와 관련해 CallToActionButton 컴포저블을 만들 수 있습니다.

뷰 기반 화면에서 컴포저블을 사용하려면 AbstractComposeView에서 확장되는 맞춤 뷰 래퍼를 만들어야 합니다. 재정의된 Content 컴포저블의 경우 생성한 컴포저블을 아래 예에서와 같이 Compose 테마에 래핑된 상태로 둡니다.

@Composable
fun CallToActionButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.secondary
        ),
        onClick = onClick,
        modifier = modifier,
    ) {
        Text(text)
    }
}

class CallToActionViewButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {

    var text by mutableStateOf("")
    var onClick by mutableStateOf({})

    @Composable
    override fun Content() {
        YourAppTheme {
            CallToActionButton(text, onClick)
        }
    }
}

컴포저블 매개변수가 맞춤 뷰 내에서 변경 가능한 변수가 되는 것을 알 수 있습니다. 이렇게 하면 맞춤 CallToActionViewButton 뷰가 기존 뷰처럼 확장 및 사용 가능하게 됩니다. 아래의 뷰 결합에서 예시를 살펴보세요.

class ViewBindingActivity : ComponentActivity() {

    private lateinit var binding: ActivityExampleBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityExampleBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.callToAction.apply {
            text = getString(R.string.greeting)
            onClick = { /* Do something */ }
        }
    }
}

맞춤 구성요소에 변경 가능한 상태가 포함된 경우 상태 정보 소스를 참고하세요.

프레젠테이션에서 분할 상태 우선순위 지정

기본적으로 View는 스테이트풀(Stateful)입니다. View는 표시할 내용뿐만 아니라 그 내용을 표시할 방법을 설명하는 필드를 관리합니다. View를 Compose로 변환할 때는 상태 호이스팅에서 설명한 것처럼 단방향 데이터 흐름이 되도록 렌더링되는 데이터를 분리해야 합니다.

예를 들어, View에는 뷰가 표시되는지, 표시되지 않는지, 사라졌는지를 나타내는 visibility 속성이 있습니다. 이 속성은 View의 고유한 속성입니다. 코드의 다른 부분에서 View의 공개 상태를 변경할 수도 있지만, View 자체만 현재 공개 상태를 알 수 있습니다. View가 표시되도록 하는 로직은 오류가 발생하기 쉽고 종종 View 자체에 연결됩니다.

반면, Compose를 사용하면 Kotlin의 조건부 로직을 사용하여 완전히 다른 컴포저블을 쉽게 표시할 수 있습니다.

@Composable
fun MyComposable(showCautionIcon: Boolean) {
    if (showCautionIcon) {
        CautionIcon(/* ... */)
    }
}

설계상 CautionIcon은 자신이 표시되고 있는 이유를 알 필요도 없고 관리할 필요도 없으며, 컴포지션 내에 위치하는지 여부를 나타내는 visibility에 관한 개념도 없습니다.

상태 관리와 프레젠테이션 로직을 명확하게 구분하여 상태를 UI로 변환하는 것처럼 콘텐츠 표시 방식을 더 자유롭게 변경할 수 있습니다. 상태 소유권이 더 유연하므로 필요한 경우 상태를 호이스팅할 수 있으면 컴포저블의 재사용 가능성이 커집니다.

구성요소의 캡슐화 및 재사용 촉진

View 요소는 종종 자신이 Activity, Dialog, Fragment 내에 있는지 또는 다른 View 계층 구조 내에 있는지 알 수 있습니다. 이러한 요소는 정적 레이아웃 파일에서 확장되는 경우가 많으므로 View의 전체 구조는 매우 견고합니다. 그 결과 결합이 더 긴밀해지고 View를 변경하거나 재사용하기가 더 어려워집니다.

예를 들어, 맞춤 View가 특정 ID를 가진 특정 유형의 하위 뷰를 갖는다고 가정하고 어떤 작업의 응답으로 속성을 직접 변경할 수 있습니다. 이는 이러한 View 요소를 함께 긴밀히 결합합니다. 맞춤 View는 하위 요소를 찾지 못하면 다운되거나 손상될 수 있고 하위 요소는 상위 맞춤 View가 없어서 재사용되지 못할 가능성이 있습니다.

재사용 가능한 컴포저블을 사용하는 Compose에서는 거의 문제가 되지 않습니다. 상위 요소는 상태와 콜백을 쉽게 지정할 수 있으므로, 재사용 가능한 컴포저블의 정확한 사용 위치를 모르더라도 이 컴포저블을 작성할 수 있습니다.

@Composable
fun AScreen() {
    var isEnabled by rememberSaveable { mutableStateOf(false) }

    Column {
        ImageWithEnabledOverlay(isEnabled)
        ControlPanelWithToggle(
            isEnabled = isEnabled,
            onEnabledChanged = { isEnabled = it }
        )
    }
}

위의 예에서는 세 부분이 모두 더 캡슐화되어 덜 결합됩니다.

  • ImageWithEnabledOverlay는 현재 isEnabled 상태만 알면 됩니다. ControlPanelWithToggle의 존재 여부와 제어 방법은 알 필요가 없습니다.

  • ControlPanelWithToggleImageWithEnabledOverlay가 있는지 알 수 없습니다. isEnabled를 표시하는 방법은 0개, 1개 또는 그 이상 있을 수 있으며 ControlPanelWithToggle은 변경할 필요가 없습니다.

  • ImageWithEnabledOverlay 또는 ControlPanelWithToggle이 얼마나 깊이 중첩되었는지는 상위 요소에 중요하지 않습니다. 이러한 하위 요소는 변경사항을 애니메이션 처리하거나 콘텐츠를 바꾸거나 다른 하위 요소에 콘텐츠를 전달할 수 있습니다.

이 패턴을 컨트롤 반전이라고 하며, CompositionLocal 문서에서 자세히 알아볼 수 있습니다.

화면 크기 변경 처리

다양한 창 크기에 따라 다른 리소스를 사용하는 것은 반응형 View 레이아웃을 만드는 주요 방법 중 하나입니다. 정규화된 리소스는 여전히 화면 수준 레이아웃을 결정하는 옵션이지만, Compose를 사용하면 일반적인 조건부 로직으로 코드에서 레이아웃을 전체적으로 훨씬 더 쉽게 변경할 수 있습니다. 자세한 내용은 창 크기 클래스 사용을 참고하세요.

또한 Compose에서 제공하는 적응형 UI 빌드 기법에 관한 자세한 내용은 다양한 화면 크기 지원을 참고하세요.

뷰를 사용한 중첩 스크롤

양방향으로 중첩되어 있으며 스크롤 가능한 뷰 요소와 컴포저블 간에 중첩 스크롤 상호 운용성을 사용 설정하는 방법에 관한 자세한 내용은 중첩 스크롤 상호 운용성을 참고하세요.

RecyclerView의 Compose

RecyclerView의 컴포저블은 RecyclerView 버전 1.3.0-alpha02부터 뛰어난 성능을 발휘합니다. 이러한 이점을 확인하려면 1.3.0-alpha02 버전 이상의 RecyclerView를 사용해야 합니다.

WindowInsets 뷰와의 상호 운용성

화면에 동일한 계층 구조에 뷰와 Compose 코드가 모두 있는 경우 기본 인셋을 재정의해야 할 수 있습니다. 이 경우 어떤 뷰가 인셋을 사용해야 하고 어떤 뷰가 인셋을 무시해야 하는지 명시적으로 지정해야 합니다.

예를 들어 가장 바깥쪽 레이아웃이 Android 뷰 레이아웃인 경우 뷰 시스템에서 인셋을 사용하고 Compose에서는 무시해야 합니다. 또는 최외곽 레이아웃이 컴포저블인 경우 Compose에서 인셋을 사용하고 AndroidView 컴포저블을 적절하게 패딩해야 합니다.

기본적으로 각 ComposeViewWindowInsetsCompat 소비 수준에서 모든 인셋을 사용합니다. 이 기본 동작을 변경하려면 ComposeView.consumeWindowInsetsfalse로 설정합니다.

자세한 내용은 Compose의 WindowInsets 문서를 참고하세요.