Upgrade to Pro — share decks privately, control downloads, hide ads and more …

DevFest 2024 Incheon / Songdo - Compose UI 조합 심화

Suhyeon Kim
December 21, 2024

DevFest 2024 Incheon / Songdo - Compose UI 조합 심화

Suhyeon Kim

December 21, 2024
Tweet

More Decks by Suhyeon Kim

Other Decks in Technology

Transcript

  1. 중복을 제거한 컴포넌트 분리 컴포넌트 조합 아이디어 1 — Slot

    패턴 컴포넌트 조합 아이디어 2 — Compound Component 패턴 UI Composition Best Practices 되돌아보기: 중복이 정말 나쁜가? Compose UI 조합 심화 | 목차
  2. 들어가기 전에… 용어 정리 이 글의 중요한 주제 중 하나인

    조합 즉, ‘Composition’은 Compose가 UI를 그리는 과정 중 하나가 아닌, 객체지향 프로그래밍에서 객체들을 조합하여 복잡한 기능을 구현하는 Composition을 의미한다. 상속을 통한 재사용과는 달리, 객체를 조합하여 구조를 설계하는 방식으로 유연성과 재사용성을 높일 수 있다. (참고: Effective Kotlin Item 36: Prefer composition over inheritance 또한, 본문에서 언급하는 ‘컴포넌트’는 Jetpack Compose의 UI 컴포넌트를 가리키며, 이는 하나의 컴포저블(Composable) 함수 단위로 정의된다.
  3. 중복을 제거한 컴포넌트 분리 “Don’t repeat yourself” (DRY), Every piece

    of knowledge must have a single, unambiguous, authoritative representation within a system. — The Pragmatic Programmer.
  4. @Composable fun UserProfile( user: User, isSelf: Boolean, ) { Column(...)

    { Row(...) { ProfileImage(...) Name(...) } Bio(...) if (isSelf) { Toolbox(...) } } }
  5. @Composable fun UserProfile( user: User, isSelf: Boolean, ) { Column(...)

    { Row(...) { ProfileImage(...) Name(...) } Bio(...) if (isSelf) { Toolbox(...) } } } 가장 직관적인 해결책 - 조건문
  6. 무분별한 조건문은 컴포넌트의 복잡도 증가로 이어진다 조건문을 통한 UI 구성

    방식은 초기에는 간단하고 실용적으로 보이지만, 사용자가 요구하는 UI 변경 사항이 늘어날수록 컴포넌트가 점차 복잡해지고 다른 조건들도 추가되기 쉽다. 조건문이 하나 추가된다는 것은, 컴포넌트가 커질수록 수많은 조건문이 추가될 가능성이 생긴다는 뜻이기 때문이다.
  7. @Composable fun UserProfile(...) { Column(...) { ... // 컴포넌트끼리 강하게

    결합하게 만드는 조건문 지옥 if (isSelf) { ... } if (isPremiumMember) { ... } if (shouldShowEmail) { ... } else {...} ... } }
  8. DRY 원칙의 함정 - 중복은 코드의 겉모습이 아닌, 코드 수정의

    이유가 같은지에 따라 판단 - 만약 두 코드가 동일한 이유로 수정되어야 한다면 중복이지만, 수정되는 이유가 다르면 중복이 아님
  9. DRY 원칙의 함정 - 중복은 코드의 겉모습이 아닌, 코드 수정의

    이유가 같은지에 따라 판단 - 만약 두 코드가 동일한 이유로 수정되어야 한다면 중복이지만, 수정되는 이유가 다르면 중복이 아님 - 컴포넌트 내 조건문 추가는 서로 다른 수정 이유를 갖는 컴포넌트들이 묶였다는 뜻 - 해당 로직은 재사용에 적합하지 않음
  10. DRY 원칙의 함정 - 중복은 코드의 겉모습이 아닌, 코드 수정의

    이유가 같은지에 따라 판단 - 만약 두 코드가 동일한 이유로 수정되어야 한다면 중복이지만, 수정되는 이유가 다르면 중복이 아님 - 컴포넌트 내 조건문 추가는 서로 다른 수정 이유를 갖는 컴포넌트들이 묶였다는 뜻 - 해당 로직은 재사용에 적합하지 않음 == 변경에 유연하게 대응할 수 있는 UI 구조 필요
  11. Slot Pattern - 특정 영역을 외부에서 자유롭게 구성할 수 있도록

    하는 디자인 패턴 - 컴포즈에서는 컴포저블 람다를 함수 파라미터로 활용하여 구현 - 부모 컴포넌트는 자식 컴포넌트의 구체적인 구현에 대한 의존성 없이 UI 구조를 정의하는 식으로 구현할 수 있음
  12. @Composable fun UserProfile( user: User, // 컴포저블 람다를 통해 UI

    자체를 파라미터로 받음 (Slot) bottomContent: @Composable () -> Unit, ) { Column(...) { ... // 어떤 UI 컴포넌트가 오더라도 UserProfile 컴포넌트는 알지 못함 bottomContent(...) } } Slot Pattern
  13. @Composable fun UserProfile( user: User, // 컴포저블 람다를 통해 UI

    자체를 파라미터로 받음 (Slot) bottomContent: @Composable () -> Unit, ) { Column(...) { ... // 어떤 UI 컴포넌트가 오더라도 UserProfile 컴포넌트는 알지 못함 bottomContent(...) } } Slot Pattern
  14. @Composable fun SelfProfile( user: User ) { UserProfile(user) { Toolbox(...)

    } } @Composable fun PublicProfile( user: User ) { UserProfile(user) } Slot Pattern
  15. @Composable fun SelfProfile( user: User ) { UserProfile(user) { Toolbox(...)

    } } @Composable fun PublicProfile( user: User ) { UserProfile(user) } Slot Pattern 내 프로필 UI 노출 요구사항이 조금 바뀌더라도 Slot으로 전달되는 컴포넌트만 수정하면 된다 (컴포넌트 간 결합 제거)
  16. Slot 패턴 활용 사례— Compose Material Design Components Compose Material

    Design Components는 대부분 Slot 패턴을 활용한다.
  17. Slot 패턴 활용 사례— Compose Material Design Components @Composable fun

    CenterAlignedTopAppBar( title: @Composable () -> Unit, navigationIcon: @Composable () -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, ...
  18. @Composable fun CenterAlignedTopAppBar( title: @Composable () -> Unit, navigationIcon: @Composable

    () -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, ... Slot 패턴 활용 사례— Compose Material Design Components 만약 String이였다면? 다른 UI 요소(아이콘, 도형)를 추가하기 위해 요구 사항을 만족하는 컴포넌트를 새로 만들어야 한다
  19. Slot 패턴을 쓰기 좋은 상황 - 컴포넌트의 유연함을 살려 다양한

    UI 요구 사항에 대응하는 경우 - 특히, 컴포넌트의 레이아웃 구조는 유지하면서 특정 영역의 콘텐츠만 변경해야 하는 경우
  20. Slot 패턴의 한계 - 요구 사항 추가 가정 (주니어 개발자

    속마음: 이럴 줄 알았으면 중복 제거 안 했지 =_=;;)
  21. @Composable fun UserProfile( user: User, // UI 변경 사항이 발생할

    때마다 Slot을 추가해야 하는가? topContent: @Composable () -> Unit, centerContent: @Composable () -> Unit, bottomContent: @Composable () -> Unit, ) { ... } Slot Pattern
  22. Compound Component Pattern - 하나의 컴포넌트를 여러 조각으로 나눈 후,

    외부에서 이들을 조합 - 부모 컴포넌트가 상태를 관리, 자식 컴포넌트는 상태를 받아 UI 렌더링 - 자식 컴포넌트들은 상태 관리 로직에서 분리되어 UI 표현에만 집중 - React에서는 Context API를 통해 구현하는 것이 일반적이지만, Compose에서는 Lambda Receiver를 활용하여 구현할 수 있음
  23. Compound Component Pattern 1. Scope 인터페이스 생성 2. 대상 리시버

    지정 @Composable fun UserProfile( user: User, content: @Composable UserProfileScope.() -> Unit, ) { val scope = remember(user) { DefaultUserProfileScope(user) } Column(...) { Row(...) {...} scope.content() } }
  24. Compound Component Pattern 1. Scope 인터페이스 생성 2. 대상 리시버

    지정 3. 자식 컴포넌트 (상태 접근) @Composable fun UserProfileScope.Bio(...) { Text(text = user.bio, ...) }
  25. Compound Component Pattern 1. Scope 인터페이스 생성 2. 대상 리시버

    지정 3. 자식 컴포넌트 (상태 접근) @Composable fun UserProfileScope.Bio(...) { Text(text = user.bio, ...) } @Composable fun OtherComponent( content: @Composable () -> Unit, ) { content() } @Composable fun OtherComponentPreview() { OtherComponent { Bio(...) } } content에 스코프 지정 안하면 컴파일 에러
  26. @Composable fun SelfProfile( user: User ) { UserProfile(user) { Bio(...)

    Toolbox(...) } } @Composable fun PublicProfile( user: User ) { UserProfile(user) { Location(...) Bio(...) } } Compound Component Pattern
  27. @Composable fun SelfProfile( user: User ) { UserProfile(user) { Bio(...)

    Toolbox(...) } } @Composable fun PublicProfile( user: User ) { UserProfile(user) { Location(...) Bio(...) } } Compound Component Pattern 새로운 요구 사항이 생겨도 자식 컴포넌트를 유연하게 조합 가능 자식 컴포넌트들은 부모 컴포넌트의 상태를 공유하면서도, 부모 컴포넌트에 직접 의존하지 않음
  28. Compound Component 패턴이 언제나 적합하진 않다 - 비즈니스 로직과 밀접하게

    연결된 경우 복잡도가 증가함에 따라 Scope 관리가 어려움 - UI는 동일하지만 UI에서 참조하는 클래스가 변경되는 경우 (Scope 인터페이스 수정, 관련된 모든 자식 컴포넌트에 영향)
  29. Compound Component 패턴이 언제나 적합하진 않다 - 비즈니스 로직과 밀접하게

    연결된 경우 복잡도가 증가함에 따라 Scope 관리가 어려움 - UI는 동일하지만 UI에서 참조하는 클래스가 변경되는 경우 (Scope 인터페이스 수정, 관련된 모든 자식 컴포넌트에 영향) - 부모의 상태가 변경되더라도 자식들은 그 변경을 감지할 수 없는 경우가 있을 수 있음 - 컴포넌트의 상태가 자주 변경될 가능성이 있는 경우 State Wrapping 고려 - 변경될 수 있는 상태를 들고 있는 Scope interface를 선언해야 하는 경우, 불안정한(Unstable) 상태로 인한 리컴포지션 트리거에 주의 @Stable interface UserProfileScope { val user: State<User> }
  30. Flyout Menu 패턴 없이 구현 ver. @Composable fun FlyoutMenu() {

    var isOpen by remember { mutableStateOf(false) } IconButton(onClick = { isOpen = !isOpen }) {...} DropdownMenu( expanded = isOpen, onDismissRequest = { isOpen = false } ) { DropdownMenuItem( onClick = { isOpen = false }, text = { Text("수정") } ) DropdownMenuItem( onClick = { isOpen = false }, text = { Text("삭제") } ) DropdownMenuItem( onClick = { isOpen = false }, text = { Text("공유") } ) } }
  31. Flyout Menu Compound Component 패턴 ver. @Stable interface FlyoutScope {

    fun toggleFlyout() } @Composable fun Flyout( content: @Composable FlyoutScope.() -> Unit, ) { var isOpen by remember { mutableStateOf(false) } val scope = remember { object : FlyoutScope { override fun toggleFlyout() { isOpen = !isOpen } } } IconButton(onClick = { scope.toggleFlyout() }) {...} DropdownMenu( expanded = isOpen, onDismissRequest = { scope.toggleFlyout() }, ) { scope.content() } }
  32. @Stable interface FlyoutScope { fun toggleFlyout() } @Composable fun Flyout(

    content: @Composable FlyoutScope.() -> Unit, ) { var isOpen by remember { mutableStateOf(false) } val scope = remember { object : FlyoutScope { override fun toggleFlyout() { isOpen = !isOpen } } } IconButton(onClick = { scope.toggleFlyout() }) {...} DropdownMenu( expanded = isOpen, onDismissRequest = { scope.toggleFlyout() }, ) { scope.content() } } Flyout Menu Compound Component 패턴 ver.
  33. Flyout Menu Compound Component 패턴 ver. @Composable fun FlyoutScope.MenuItem(text: String,

    onClick: () -> Unit) { DropdownMenuItem( onClick = { onClick(); toggleFlyout() }, text = { Text(text) } ) } @Composable fun FlyoutMenu() { Flyout { MenuItem("수정", onClick = { /* ... */ }) MenuItem("삭제", onClick = { /* ... */ }) MenuItem("공유", onClick = { /* ... */ }) } }
  34. @Composable fun FlyoutScope.MenuItem(text: String, onClick: () -> Unit) { DropdownMenuItem(

    onClick = { onClick(); toggleFlyout() }, text = { Text(text) } ) } @Composable fun FlyoutMenu() { Flyout { MenuItem("수정", onClick = { /* ... */ }) MenuItem("삭제", onClick = { /* ... */ }) MenuItem("공유", onClick = { /* ... */ }) } } 개발자는 Flyout 컴포넌트의 내부 구현이나 상태 관리 로직을 자세히 알 필요 없다 Flyout Menu Compound Component 패턴 ver.
  35. Compound Component 패턴 활용 사례 - Lazy lists - Compose는

    기본적으로 레이아웃 Scope를 지정한 다양한 컴포넌트를 제공한다. - 대표적인 예가 LazyColumn과 LazyRow와 같은 Lazy 컴포넌트다. - 이들은 LazyListScope를 활용하여 리스트 항목을 효율적으로 관리하고 구성한다.
  36. LazyColumn 활용 @Composable fun UserProfiles(users: List<User>) { LazyColumn { stickyHeader

    { HeaderTitle() } items(users) { user -> PublicProfile(user = user) } item { Footer() } } } LazyListScope는 item, items, stickyHeader 등 리스트를 구성하는 데 필요한 함수들을 제공한다. LazyColumn을 사용하는 개발자는 세부적인 상태 관리 로직에 대해 알지 못해도, 각 항목의 UI 구성에만 집중할 수 있다.
  37. Slot 패턴과 Compound Component 패턴 - 둘은 서로 배타적인 관계가

    아니라 상호 보완적인 관계 - Slot 패턴은 컴포넌트의 재사용성을 위한 기반을 마련, Compound Component 패턴은 이를 바탕으로 더욱 유연한 UI를 구축 가능 - Stateless 컴포넌트는 UI에 집중, Stateful은 상태 관리 로직 캡슐화 - Compound Component 패턴은 이러한 Stateful 컴포넌트를 효과적으로 관리하고 활용할 수 있는 구조를 제공한다.
  38. Best Practices — Stream Video SDK - 화상 통화, 오디오

    룸 및 라이브 스트리밍 앱을 구축하기 위한 SDK - 오픈 소스로 공개되어 있어 누구나 전체 코드 조회 가능
  39. Best Practices — Stream Video SDK - 화상 통화 기능의

    하단 액션 바 예시 - 음소거, 비디오 토글, 통화 종료 등의 옵션 제공 - 누구는 카메라 버튼을 왼쪽에 위치하고 싶어하고, - 누구는 마이크 버튼을 중앙에 위치하고 싶어하고, - 누구는 마이크 버튼이 없어야 한다고 생각할 수 있다. - 이러한 다양한 SDK 고객의 요구를 충족하기 위해 Slot 형태의 UI를 구성했다.
  40. @Composable public fun buildDefaultLobbyControlActions( call: Call, onCallAction: (CallAction) -> Unit,

    isCameraEnabled: Boolean = if (LocalInspectionMode.current) { true } else { call.camera.isEnabled.value }, isMicrophoneEnabled: Boolean = if (LocalInspectionMode.current) { true } else { call.microphone.isEnabled.value }, ): List<@Composable () -> Unit> { return listOf( { ToggleMicrophoneAction( isMicrophoneEnabled = isMicrophoneEnabled, onCallAction = onCallAction, ) }, { ToggleCameraAction( isCameraEnabled = isCameraEnabled, onCallAction = onCallAction, ) }, ) }
  41. @Composable public fun buildDefaultLobbyControlActions( call: Call, onCallAction: (CallAction) -> Unit,

    isCameraEnabled: Boolean = if (LocalInspectionMode.current) { true } else { call.camera.isEnabled.value }, isMicrophoneEnabled: Boolean = if (LocalInspectionMode.current) { true } else { call.microphone.isEnabled.value }, ): List<@Composable () -> Unit> { return listOf( { ToggleMicrophoneAction( isMicrophoneEnabled = isMicrophoneEnabled, onCallAction = onCallAction, ) }, { ToggleCameraAction( isCameraEnabled = isCameraEnabled, onCallAction = onCallAction, ) }, ) } 유연하게 UI 컴포넌트를 조합하고 확장 다양한 SDK 고객의 요구 충족 가능 👍
  42. Stream Video SDK 사례 정리 - UI 조합 패턴으로 공유

    상태와 개별 제어 동작을 UI 로직과 분리할 수 있다. - SDK 고객의 다양한 요구 사항을 충족하기 위해 유연한 설계가 가능하다. - 오픈소스 프로젝트를 통해 디자인 패턴과 소프트웨어 아키텍처를 비롯한 다양한 모범 사례를 무료로 보고 아이디어를 얻을 수 있다. (개발자 커뮤니티에 기여 👍)
  43. “Don’t repeat yourself” (DRY), Every piece of knowledge must have

    a single, unambiguous, authoritative representation within a system. — The Pragmatic Programmer. “중복은 코드의 겉모습이 아닌, 코드 수정의 이유가 같은지에 따라 판단” 되돌아보기: 조건문이 정말 나쁜가?
  44. 정말 나쁠까? 🤔 앞서 설명한 패턴을 익히는 학습 비용을 고려해보면...

    되돌아보기: 조건문이 정말 나쁜가? @Composable fun UserProfile( user: User, isSelf: Boolean, ) { Column(...) { ... if (isSelf) { Toolbox(...) } } }
  45. 되돌아보기: 조건문이 정말 나쁜가? 핵심은 조건문 자체를 피해야 하는 것이

    아니라, 조건문의 남용으로 인해 발생하는 복잡성 증가를 경계해야 한다는 것이다. 특히 UI behavior logic과 같은 경우나 애니메이션 상태를 관리할 때에는 굳이 복잡한 패턴을 적용하는 것보다 단순한 조건문을 사용하는 것이 더 효율적일 수 있다.
  46. 되돌아보기: 중복이 정말 나쁜가? UI 개발에서 지나치게 DRY 원칙에 집착하면

    오히려 코드의 복잡성을 높이고 유연성을 떨어뜨릴 수 있다. 예를 들어 나의 프로필과 다른 사람의 프로필 UI가 현재는 유사하지만, 미래의 비즈니스 요구사항 변경에 따라 두 UI가 완전히 다른 형태로 진화할 가능성도 있다.
  47. 결론 Slot 패턴과 Compound Component 패턴은 모든 상황에 적합한 해결책은

    아니다. 때론 단순한 조건문이나 중복을 허용하는 것이 더 나은 선택일 수도 있다. 각 패턴의 장단점을 정확히 이해하고, 컴포넌트의 역할과 책임, 예상되는 변경 사항을 고려하여 적절한 패턴을 선택해야 한다.