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

[KR] Server Driven Compose With Firebase

Jaewoong
September 28, 2024

[KR] Server Driven Compose With Firebase

Jaewoong

September 28, 2024
Tweet

More Decks by Jaewoong

Other Decks in Programming

Transcript

  1. Server Driven Compose With Firebase skydoves @github_skydoves Lead Android Developer

    Advocate @ Stream Jaewoong Eum Google Developer Expert
  2. Data-Driven UI { "title": "Server Driven Compose", "items": [ {

    "title": "City", "url": "https://github.com/skydoves" }, { "title": "Suits", "url": "https://github.com/skydoves" } ] } @Composable fun TimelineContent() { Text(..) LazyColumn { } } Backend Client
  3. Data-Driven UI { "clientIdentiferData": { "clientID": "89012", "webPropertyID": "89012-01", "campaignID":

    "004" }, "journeyKeyData": { "campaignCreationPhase": "Teach", "purchaseJourneyPhase": "Awareness", "topicCategory": "Organizer", "topicSubCategory": "Event Team", "topicSubSubCategory": "Null" }, "uploadedData": [{ "dataCategoryLabel": "name of organization", "dataStrings": [{ "string": "UBM" }] }, { "dataCategoryLabel": "our mission", "dataStrings": [{ "string": "We Serve the Needs of B2B Buyers" }] }, { "dataCategoryLabel": "our history", "dataStrings": [{ "string": "UBM was fonded in 1948", "string2": "It is was purchased by Informa in 2017", "string3": "We Serve the Needs of B2B Buyers" }] }, { Backend Client 레이아웃의 모든 형태 및 동작 구조를 클라이언트에서 정의 "무엇을, 어떻게" 그릴지는 클라이언트의 책임
  4. Data-Driven UI Data-Driven UI의 단점 • 느린 업데이트 주기: 시간이

    많이 소요되는 출시 및 게시(리뷰 및 심사) 과정 때문에 중요한 업데이트 제공이 지연된다. • 느린 사용자 채택: 사용자가 업데이트를 수동으로 다운로드하고 설치해야 하므로 새로운 기능과 버그 수정의 채택이 느려짐. 강제 업데이트는 사용자 경험에 역효과가 생길 가능성이 높아진다. • 느린 기능 실험: 업데이트 주기가 느리기 때문에 팀이 테스트하고자 하는 특정 기능을 실험하고 반복하기가 어렵다. • 느린 피드백 루프: 업데이트 주기와 채택 속도가 느려서 사용자 피드백을 수집하고 신속하게 변경 사항을 반영하기가 어렵다.
  5. Web-Driven UI Hybrid App의 단점 • 러닝 커브: 기존의 네이티브

    앱 개발자들이 새롭게 웹앱이라는 분야를 위해 새로운 언어와 개발지식을 습득해야 하고, 직무 전환에 준하는 높은 러닝커브가 존재한다. • 성능 저하: 네이티브 기반의 렌더링 시스템이 아니라 WebView(Chromium)와 같은 web 브라우저 엔진을 통해 렌더링 해야 하므로, 네이티브만큼의 높은 성능은 기대하기 어렵다. • 인터넷 의존성: 일부 Progressive Web App (PWAs)는 오프라인 캐싱을 지원하지만, 대부분의 웹앱은 인터넷 연결에 강한 의존성을 지니고 있어 네트워크 환경이라는 제약이 따라온다. • 디바이스 기능 접근 제한: GPS, 카메라, NFC 등 디바이스의 하드웨어 기능에 대한 정밀한 접근 및 처리가 불가능하다. Progressive Web App에서 기능을 지원하지만 여전히 제한된 기능만을 제공한다. 또한, Javascript Interface를 통한 WebView와의 복잡한 프로토콜이 요구된다.
  6. Server-Driven UI Backend @Composable fun TextUi() { .. } @Composable

    fun ImageUi() { .. } @Composable fun ListUi() { .. } Client
  7. Server-Driven UI Backend 레이아웃의 모든 형태 및 동작 구조를 서버에서

    정의 서버는 "무엇을" 그릴지 책임지고 클라이언트는 "어떻게" 그릴지 책임 Client @Composable fun TextUi() { .. } @Composable fun ImageUi() { .. } @Composable fun ListUi() { .. }
  8. Server-Driven UI Server-Driven UI의 장점 • 더 빠른 기능 실험:

    앱 업데이트 없이 새로운 기능(레이아웃)을 쉽게 수정하고 배포할 수 있어 피드백 루프가 빠르고, 실험 기능들을 신속하게 수행할 수 있다. • 일관된 UI: 안정된 컴포넌트 디자인 시스템을 구축하면, 핵심 사양이 유지되는 한 여러 앱 버전에서 일관된 UI와 동작을 제공한다. • 네이티브 성능: 서버 주도 UI의 유연성을 유지하면서도 웹앱 및 하이브리드 앱과 비교했을 때 네이티브 성능을 유지하면서 컴포넌트를 렌더링할 수 있다. • 모바일 개발자의 부담 감소: 레이아웃 설계는 주로 제품 관리자와 디자이너가 정의하고, "무엇을 할지"는 백엔드의 책임이므로, 모바일 개발자는 개별 컴포넌트 개발에 집중할 수 있다.
  9. Realtime Database • 쉬운 서버 구축: 백엔드에 대한 지식이 없는

    개발자도 빠르게 서버 구축이 가능하고, 대시보드의 UI를 통해 손쉽게 조작이 가능하다. Firebase Realtime Database
  10. Realtime Database • 쉬운 서버 구축: 백엔드에 대한 지식이 없는

    개발자도 빠르게 서버 구축이 가능하고, UI가 제공되는 대시보드를 통해 손쉽게 조작이 가능하다. • JSON 응답: JSON 파일을 직접 import/export해서 응답 생성이 가능하다. Firebase Realtime Database
  11. Realtime Database • 쉬운 서버 구축: 백엔드에 대한 지식이 없는

    개발자도 빠르게 서버 구축이 가능하고, UI가 제공되는 대시보드를 통해 손쉽게 조작이 가능하다. • JSON 응답: JSON 파일을 직접 import/export해서 응답 생성이 가능하다. • 실시간: 데이터베이스 응답 변경을 실시간으로 관찰할 수 있는 클라이언트 SDK가 제공되어, 값의 변화를 실시간으로 반영하고 눈으로 확인할 수 있다. Firebase Realtime Database
  12. Realtime Database Android SDK의 문제점 • Java 기반 코드: SDK가

    전부 Java 코드로 작성되어 여전히 Callback 기반의 API를 제공한다. • Serialization 커스텀 불가: 공식 SDK와 커뮤니티의 라이브러리 모두 snapshot data에 대하여 커스텀 serialization 옵션을 제공하지 않는다. 따라서, nullability를 지원하지 않고 fallback 처리를 유동적으로 하는 것이 어렵다. 반드시 default값을 정의해야 한다. Firebase Realtime Database val listener = object : ValueEventListener { override fun onDataChange(snapshot: DataSnapshot) { val value = snapshot.child("post") // .. } override fun onCancelled(error: DatabaseError) { // .. } } @IgnoreExtraProperties data class Post( var uid: String? = "", var author: String? = "", var title: String? = "", ) { @Exclude fun toMap(): Map<String, Any?> { return mapOf( "uid" to uid, "author" to author, "title" to title, ..
  13. Firebase Realtime Database dependencies { implementation("com.github.skydoves:firebase-database-ktx:0.2.0") } private val database

    = Firebase.database(BuildConfig.REALTIME_DATABASE_URL).reference private val json = Json { isLenient = true ignoreUnknownKeys = true prettyPrint = true } val timelineUi = database.flow<TimelineUi>( path = { dataSnapshot -> dataSnapshot.child("timeline") }, decodeProvider = { jsonString -> json.decodeFromString(jsonString) }, ).flatMapLatest { result -> .. }
  14. Component Design Systems { "title": { "text": "Server Driven Compose",

    "size": 26, "fontWeight": "bold" }, "list": { "layout": "grid", "itemSize": { "width": 150, "height": 150 }, "items": [ { "title": "City", "url": "https://github.com/skydoves", "scaleType": "crop" }, { "title": "Suits", "url": "https://github.com/skydoves", "scaleType": "crop" } ] } }
  15. Component Design Systems { "title": { "text": "Server Driven Compose",

    "size": 26, "fontWeight": "bold" }, "list": { "layout": "grid", "itemSize": { "width": 150, "height": 150 }, "items": [ { "title": "City", "url": "https://github.com/skydoves", "scaleType": "crop" }, { "title": "Suits", "url": "https://github.com/skydoves", "scaleType": "crop" } ] } } Text Image
  16. Component Design Systems { "title": { "text": "Server Driven Compose",

    "size": 26, "fontWeight": "bold" }, "list": { "layout": "grid", "itemSize": { "width": 150, "height": 150 }, "items": [ { "title": "City", "url": "https://github.com/skydoves", "scaleType": "crop" }, { "title": "Suits", "url": "https://github.com/skydoves", "scaleType": "crop" } ] } } Text Image List
  17. Component Design Systems { "title": { "text": "Server Driven Compose",

    "size": 26, "fontWeight": "bold" }, "list": { "layout": "grid", "itemSize": { "width": 150, "height": 150 }, "items": [ { "title": "City", "url": "https://github.com/skydoves", "scaleType": "crop" }, { "title": "Suits", "url": "https://github.com/skydoves", "scaleType": "crop" } ] } } Text Image List UiComponent 모든 UI 컴포넌트에 대해서 동일한 추상화 적용
  18. Component Design Systems @Serializable sealed interface UiComponent @Serializable data class

    TextUi( val text: String, val size: Int, val fontWeight: String ) : UiComponent @Serializable data class ImageUi( val url: String, val size: DpSizeUi = DpSizeUi(0, 0), val scaleType: String, val contentDescription: String = "", ) : UiComponent @Serializable data class DpSizeUi( val width: Int, val height: Int )
  19. Component Design Systems @Serializable sealed interface UiComponent @Serializable data class

    TextUi( val text: String, val size: Int, val fontWeight: String ) : UiComponent @Serializable data class ImageUi( val url: String, val size: DpSizeUi = DpSizeUi(0, 0), val scaleType: String, val contentDescription: String = "", ) : UiComponent @Serializable data class DpSizeUi( val width: Int, val height: Int ) @Serializable data class ListUi( val layout: String, val itemSize: DpSizeUi, val items: List<ImageUi>, ) : UiComponent fun String.toLayoutType(): LayoutType { return if (this == "grid") LayoutType.GRID else if (this == "column") LayoutType.COLUMN else LayoutType.ROW } enum class LayoutType(val value: String) { GRID("grid"), COLUMN("column"), ROW("row") } UiComponent 인터페이스로 컴포넌트 유형 단일화
  20. Component Design Systems @Serializable data class TextUi( val text: String,

    val size: Int, val fontWeight: String ) : UiComponent @Composable fun ConsumeTextUi( textUi: TextUi, modifier: Modifier = Modifier ) { Text( modifier = modifier, text = textUi.text, color = ServerDrivenTheme.colors.textHighEmphasis, fontSize = textUi.size.sp, fontWeight = textUi.fontWeight.toFontWeight() ) }
  21. Component Design Systems @Serializable data class TextUi( val text: String,

    val size: Int, val fontWeight: String ) : UiComponent @Composable fun ConsumeTextUi( textUi: TextUi, modifier: Modifier = Modifier ) { Text( modifier = modifier, text = textUi.text, color = ServerDrivenTheme.colors.textHighEmphasis, fontSize = textUi.size.sp, fontWeight = textUi.fontWeight.toFontWeight() ) } val mockTextUi1 = TextUi( text = "Title", size = 32, fontWeight = "bold" ) ConsumeTextUi( textUi = mockTextUi1 ) 재사용성이 높다. 1. Preview 작성에 용이 2. UI 테스트 작성에 용이 3. 서버주도 개발에 적합
  22. Component Design Systems @Serializable data class ImageUi( val url: String,

    val size: DpSizeUi = DpSizeUi(0, 0), val scaleType: String, val contentDescription: String = "", ) : UiComponent @Composable fun ConsumeImageUi( imageUi: ImageUi, modifier: Modifier = Modifier, imageOptions: ImageOptions? = null ) { GlideImage( modifier = modifier.size(imageUi.size), imageModel = { imageUi.url }, imageOptions = imageOptions ?: ImageOptions( contentScale = imageUi.scaleType.toContentScale(), contentDescription = imageUi.contentDescription ), previewPlaceholder = painterResource(R.drawable.preview) ) }
  23. @Serializable data class TimelineTopUi( val banner: ImageUi ): UiComponent @Serializable

    data class TimelineCenterUi( val title: TextUi, val list: ListUi ): UiComponent @Serializable data class TimelineBottomUi( val title: TextUi, val list: ListUi ): UiComponent Component Design Systems @Serializable data class TimelineUi( val top: TimelineTopUi, val center: TimelineCenterUi, val bottom: TimelineBottomUi ) : UiComponent 최상위부터 최하위 객체까지 동일한 UiComponent로 일련의 응답을 구성한다.
  24. Component Design Systems @Serializable data class TimelineUi( val version: Int,

    val top: TimelineTopUi, val center: TimelineCenterUi, val bottom: TimelineBottomUi ) : UiComponent val timelineUi: StateFlow<List<UiComponent>> = repository.fetchTimelineUi() .flatMapLatest { result -> flowOf(result.getOrNull()?.buildUiComponentList()) } .filterNotNull() .stateIn() @Serializable data class TimelineTopUi( val banner: ImageUi (UiComponent) ): UiComponent @Serializable data class TimelineCenterUi( val title: TextUi, (UiComponent) val list: ListUi (UiComponent) ): UiComponent @Serializable data class TimelineBottomUi( val title: TextUi, (UiComponent) val list: ListUi (UiComponent) ): UiComponent UiComponent.buildUiComponentList()
  25. Component Design Systems @Serializable data class TimelineUi( val version: Int,

    val top: TimelineTopUi, val center: TimelineCenterUi, val bottom: TimelineBottomUi ) : UiComponent val timelineUi: StateFlow<List<UiComponent>> = repository.fetchTimelineUi() .flatMapLatest { result -> flowOf(result.getOrNull()?.buildUiComponentList()) } .filterNotNull() .stateIn() UiComponent UiComponent UiComponent UiComponent UiComponent UiComponent UiComponent UiComponent UiComponent TimelineUi top center bottom
  26. Component Design Systems @Serializable data class TimelineUi( val version: Int,

    val top: TimelineTopUi, val center: TimelineCenterUi, val bottom: TimelineBottomUi ) : UiComponent val timelineUi: StateFlow<List<UiComponent>> = repository.fetchTimelineUi() .flatMapLatest { result -> flowOf(result.getOrNull()?.buildUiComponentList()) } .filterNotNull() .stateIn() UiComponent UiComponent UiComponent UiComponent UiComponent UiComponent UiComponent UiComponent UiComponent List<UiComponent> top center bottom TimelineUi ImageUi TextUi List<imageUi> TextUi List<imageUi>
  27. Component Design Systems @Serializable data class TimelineUi( val version: Int,

    val top: TimelineTopUi (OrderedUiComponent), val center: TimelineCenterUi (OrderedUiComponent), val bottom: TimelineBottomUi (OrderedUiComponent) ) : UiComponent val timelineUi: StateFlow<List<UiComponent>> = repository.fetchTimelineUi() .flatMapLatest { result -> flowOf(result.getOrNull()?.buildUiComponentList()) } .filterNotNull() .stateIn() UiComponent OrderedUiComponent OrderedUiComponent OrderedUiComponent UiComponent UiComponent UiComponent UiComponent UiComponent @Serializable sealed interface OrderedUiComponent : UiComponent { val order: Int } sortedBy { .. }
  28. Component Design Systems @Composable fun UiComponent.Consume( modifier: Modifier = Modifier,

    ) { when (this) { is TextUi -> ConsumeTextUi( textUi = this, ) is ImageUi -> ConsumeImageUi( imageUi = this, modifier = modifier, ) is ListUi -> ConsumeList( listUi = this, modifier = modifier, ) else -> ConsumeDefaultUi( uiComponent = this, ) } } Column( modifier = Modifier ) { timelineUi.components.forEach { uiComponent -> UiComponent.Consume() } } UiComponent UiComponent UiComponent UiComponent UiComponent UiComponent UiComponent UiComponent UiComponent List<UiComponent> • 응답의 직렬화 이후 모든 컴포넌트를 UiComponent 인터페이스로 단일 추상화 • 주어진 UiComponent 리스트를 Column/Row/LazyList 등을 사용하여 소비
  29. Component Design Systems @Composable fun Timeline(..) { val timelineUi by

    timelineViewModel.timelineUi.collectAsStateWithLifecycle() Column( modifier = Modifier .fillMaxSize() .padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { timelineUi.components.forEach { uiComponent -> uiComponent.Consume() } } }
  30. The Other Screens @Composable fun PostDetails( detailsUi: ScreenUi ) {

    Column( modifier = Modifier .background(ServerDrivenTheme.colors.background) .fillMaxSize() .padding(12.dp) .verticalScroll(state = rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp) ) { detailsUi.components.forEach { component -> component.Consume() } } }
  31. Action Handler { "banner": { "url": "https://github.com/skydoves", "size": { "width":

    0, "height": 250 }, "scaleType": "crop", "handler": { "type": "click", "actions": { "navigate": "to" } } } } Navigation, 딥링크 등 행동에 대한 동작 처리 Click, touch 등 컴포넌트에 대한 행동을 정의
  32. Action Handler @Serializable data class Handler( val type: String, val

    actions: Map<String, String> ) enum class HandlerType(val value: String) { CLICK("click") } enum class HandlerAction(val value: String) { NAVIGATION("navigation") } enum class NavigationHandler(val value: String) { TO("to") } @Serializable data class ImageUi( val url: String, val handler: Handler? = null ) : UiComponent • 컴포넌트에 따라 action handler가 존재하지 않을 수 있으므로 optional • UiComponent와 같은 최상위 추상화 인터페이스에 정의하는 것도 좋은 방법
  33. Action Handler @Composable fun ConsumeImageUi( imageUi: ImageUi, modifier: Modifier =

    Modifier, navigator: (UiComponent) -> Unit = {}, imageOptions: ImageOptions? = null ) { GlideImage( modifier = modifier .consumeHandler( handler = imageUi.handler, navigator = { navigator.invoke(imageUi) } ) .size(imageUi.size) .clip(RoundedCornerShape(8.dp)), .. } @Composable fun Modifier.consumeHandler( handler: Handler?, navigator: () -> Unit ): Modifier { if (handler == null) return this handler.actions.forEach { element -> val action = if (element.key == HandlerAction.NAVIGATION.value && element.value == NavigationHandler.TO.value ) { { navigator } } else { {} } val newModifier = if (handler.type == HandlerType.CLICK.value) { Modifier.clickable { action.invoke() } } else { Modifier } then(newModifier) } return this
  34. Component Versioning 새로운 고민 사항 • 디자인 시스템의 스펙이 크게

    바뀐다면? • 앱 버전 별로 다른 디자인이 반영되어야 한다면? • 이벤트 및 리워드 등 유연한 처리를 위해 여러 종류의 컴포넌트가 필요하다면?
  35. Component Versioning { "timeline": { "version": 1, "top": { ..

    } } } @Serializable data class TimelineUi( val version: Int, val top: TimelineTopUi, val center: TimelineCenterUi, val bottom: TimelineBottomUi ) : UiComponent
  36. Component Versioning { "timeline": { "version": 1, "top": { ..

    } } } @Serializable data class TimelineUi( val version: Int, val top: TimelineTopUi, val center: TimelineCenterUi, val bottom: TimelineBottomUi ) : UiComponent enum class UiVersion(val value: Int) { VERSION_1_0(1), VERSION_2_0(2); companion object { fun toUiVersion(value: Int): UiVersion { return when (value) { VERSION_1_0.value -> VERSION_1_0 VERSION_2_0.value -> VERSION_2_0 else -> throw RuntimeException("undefined version!") } } } } 디자인 시스템의 버저닝 범위 설정 상황에 따라 화면 별로 버전 처리를 할 수도 있고, 컴포넌트 별로 할 수도 있다.
  37. Component Versioning @Composable fun ConsumeTextUi( version: UiVersion, textUi: TextUi, modifier:

    Modifier = Modifier ) { if (version == UiVersion.VERSION_1_0) { Text( modifier = modifier, text = textUi.text, color = ServerDrivenTheme.colors.textHighEmphasis, fontSize = textUi.size.sp, fontWeight = textUi.fontWeight.toFontWeight() ) } else if (version == UiVersion.VERSION_2_0) { Text( modifier = modifier, text = textUi.text, color = ServerDrivenTheme.colors.primary, fontSize = textUi.size.sp, fontWeight = textUi.fontWeight.toFontWeight() ) } }
  38. Component Versioning @Composable fun UiComponent.Consume( modifier: Modifier = Modifier, version:

    UiVersion = UiVersion.VERSION_1_0, navigator: (UiComponent) -> Unit = {} ) { when (this) { is TextUi -> ConsumeTextUi( textUi = this, modifier = modifier, version = version ) is ImageUi -> ConsumeImageUi( imageUi = this, modifier = modifier, version = version, navigator = navigator ) .. } }
  39. Fallback 새로운 고민 사항 • 잘못된 레이아웃 정보가 내려온다면? ◦

    존재하지 않는 컴포넌트 버전 정보 ◦ 오류가 있는 레이아웃 데이터 • 레이아웃 정보를 가져오는 데 실패했다면?
  40. Fallback @Composable fun UiComponent.Consume( modifier: Modifier = Modifier, version: UiVersion

    = UiVersion.VERSION_1_0, navigator: (UiComponent) -> Unit = {} ) { when (this) { is TextUi -> ConsumeTextUi( textUi = this, modifier = modifier, version = version ) .. else -> ConsumeDefaultUi( uiComponent = this, version = version ) } } 직렬화 실패 및 데이터 결함 등 여러 이유로 컴포넌트 매칭에 실패한 경우 적절한 fallback 처리가 필요하다.
  41. Fallback 컴포넌트 버저닝을 적용하는 경우 PM이나 백엔드 개발자의 실수로 잘못된

    버전 정보가 내려오는 것을 대비하여 적절한 fallback 처리가 필요하다. enum class UiVersion(val value: Int) { VERSION_1_0(1), VERSION_2_0(2); companion object { fun toUiVersion(value: Int): UiVersion { return when (value) { VERSION_1_0.value -> VERSION_1_0 VERSION_2_0.value -> VERSION_2_0 else -> throw RuntimeException("undefined version!") or else -> VERSION_1_0 } } } } fun String.toLayoutType(): LayoutType { return if (this == "grid") LayoutType.GRID else if (this == "column") LayoutType.COLUMN else LayoutType.ROW }
  42. Recap: Advantages Server-Driven UI의 장점 • 더 빠른 기능 실험:

    앱 심사 및 업데이트 없이 새로운 기능(레이아웃)을 쉽게 수정하고 배포할 수 있어 피드백 루프가 빠르고, 반복 작업이 신속하게 이루어진다. (PM/PO와 모바일 개발자 둘 다 행복) • 일관된 UI: 안정된 컴포넌트 디자인 시스템을 구축하면, 핵심 사양이 유지되는 한 여러 앱 버전에서 일관된 UI와 동작을 제공한다. • 네이티브 성능: 웹앱과 같은 UI의 유연성을 유지하면서도 네이티브 성능으로 컴포넌트를 렌더링할 수 있다. • 모바일 개발자의 부담 감소: "무엇을 할지"는 백엔드에서 알려주므로, "어떻게 보여줄지"에만 집중할 수 있다.
  43. Recap: Downside Server-Driven UI의 단점 • 지연 시간 증가: 클라이언트가

    백엔드에서 레이아웃 정보까지 받아와야 하기 때문에, data-driven UI에 비해서 컴포넌트를 랜더링하고 표시하는 데 시간이 더 걸릴수 있다. • 복잡성 및 비용 증가: 팀 전체가 레이아웃 데이터를 렌더링하는 방식, 컴포넌트 버전 관리에 대해 명확한 역할 분담과 레이아웃 시스템을 구축해야한다. 그렇지 않으면 백엔드 팀이 레이아웃을 구성하는 부담을 떠안게 되거나, 팀의 전반적인 소통 비용이 증가한다. • Fallback 처리: 레이아웃 데이터가 다른 팀 (PM/디자이너/백엔드)를 거쳐 생성되므로, 데이터에 결함이 발생할 수도 있다. 이에 대한 충분한 fallback 처리가 되어야한다.
  44. Recap: Suitable Case Server-Driven UI가 적합한 상황 • 홈 화면:

    홈 (피드, 타임라인) 화면과 같이 유저가 앱 진입 시 가장 먼저 노출되며 자주 변경이 필요한 화면에 적용하면 큰 효과를 볼 수 있다. • 세션 타임이 높은 화면: 라이브 방송 및 엔터테인먼트와 같이 사용자가 앱에서 머무는 세션 타임이 가장 높은 화면에 사용하면 더 높은 사용 효과를 볼 수 있다.
  45. Thank you! Lead Android Developer Advocate @ GetStream Jaewoong Eum

    (엄재웅) Google Developer Expert skydoves @github_skydoves