
์ฌ๋ฌ๋ถ์ Service๋ฅผ ํตํด ์ด๋๊น์ง ์ฌ์ฉํด๋ณด์ จ๋์? ๐ค
์ ๋ ๋ณดํต Service๋ฅผ ์ด์ฉํด์
๋ฐฑ๊ทธ๋ผ์ด๋์์ ํ์ผ ๋ค์ด๋ก๋ ๐ฅ, ์
๋ก๋ ๐ค, ์์
์ฌ์ ๐ต ๊ฐ์
UI ์์ด ์กฐ์ฉํ ๋์ํ๋ ์์
๋ค์ ํ์ฉํด์์ต๋๋ค.
๋ฌผ๋ก ForegroundService๋ฅผ ํ์ฉํด์
์๋ฆผ(Notification)์ ํตํด ์ฌ์ฉ์์๊ฒ ์ํ๋ฅผ ๋ณด์ฌ์ฃผ๊ฑฐ๋,
๋ฒํผ ์ ์ด ๋ฑ ๊ฐ๋จํ UI ์ธํฐ๋์
์ ๋ค๋ค๋ณธ ๊ฒฝํ๋ ์์์ต๋๋ค. ๐งญ
์๋ฅผ ๋ค์ด,
๊ทธ๋ฐ๋ฐ ์ด๋ฒ์ ์ข ๋ฌ๋์ต๋๋ค.
์ด๋ฒ ํ๋ก์ ํธ์์๋ Service๋ฅผ ํตํด Overlay๋ฅผ ๋์ฐ๊ณ ,
๊ทธ ์์์ ์ฌ์ฉ์์ ์ค์๊ฐ์ผ๋ก ์ํธ์์ฉํ ์ ์๋ UI๋ฅผ ๋ง๋ค์ด์ผ ํ๊ฑฐ๋ ์. ๐ถโ๐ซ๏ธ
๊ฒ๋ค๊ฐ ๊ทธ UI๋ ๋จ์ํ View๊ฐ ์๋๋ผ
์ ๊ฐ ์ง์ ๊ตฌํํ๋ฉด์ ์๋ฃ๊ฐ ๋ถ์กฑํด ๊ฝค ๋ง์ ์ํ์ฐฉ์ค๋ฅผ ๊ฒช์๊ณ ,
์ฌ๋ฌ ๊ฐ์ง ๋ฐฉ๋ฒ์ ์๋ํด๋ณด๋ฉฐ ํ๋์ฉ ์ ๋ต์ ์ฐพ์๊ฐ์ต๋๋ค. ๐
๊ทธ ๊ณผ์ ์ ์ ๋ฆฌํด๋๋ฉด
๋น์ทํ ๊ตฌ์กฐ๋ฅผ ๊ณ ๋ฏผํ๋ ๋ถ๋ค๊ป ๋์์ด ๋ ๊ฒ ๊ฐ์,
์๋์ ๊ฐ์ ๋ด์ฉ์ ์ค์ฌ์ผ๋ก ํ์ด๋ณด๋ ค ํฉ๋๋ค. ๐
Android์ ์ผ๋ฐ์ ์ธ UI ๊ตฌ์ฑ์ Activity๋ Fragment๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ํ์ง๋ง,
Service์์ Overlay๋ฅผ ๋์ฐ๊ธฐ ์ํด์ WindowManager๋ฅผ ์ง์ ๋ค๋ค์ผ ํฉ๋๋ค.
์ฌ๊ธฐ์ Jetpack Compose๋ฅผ ์น๊ธฐ ์ํด์ ComposeView๋ฅผ ํ์ฉํ๊ฒ ๋์ฃ .
๊ทธ๋ผ ๋ณธ๊ฒฉ์ ์ผ๋ก Service ์์ Compose ๊ธฐ๋ฐ UI๋ฅผ ๋์ฐ๋ ๊ตฌ์กฐ๋ฅผ ์ดํด๋ณผ๊ฒ์. ๐งฑ
@AndroidEntryPoint
class ComposeOverlayService : Service() {
@Inject
lateinit var composeWindow: ComposeWindow
override fun onCreate() {
super.onCreate()
composeWindow.create()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
composeWindow.reconfigureContent()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
composeWindow.destroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
ComposeOverlayService๋ Jetpack Compose๋ก ๋ง๋ UI๋ฅผ
Overlay ํํ๋ก Window์ ๋์ฐ๊ธฐ ์ํ Android Service์
๋๋ค.
์ด ํด๋์ค์๋ @AndroidEntryPoint๋ฅผ ๋ถ์ฌ
Hilt๋ฅผ ํตํด ์์กด์ฑ์ ์ฃผ์
๋ฐ์ ์ ์๋ ์ง์
์ ์์ ๋ช
์ํ๊ณ ,
ComposeWindow๋ผ๋ ๋ณ๋ ํด๋์ค๋ฅผ ์ฃผ์
๋ฐ์
Overlay UI ์์ฑ ๋ฐ WindowManager ๊ด๋ จ ์ฒ๋ฆฌ๋ฅผ ์์ํฉ๋๋ค.
Service๋ ์ ์ฒด ๊ตฌ์กฐ์์ ์๋ช ์ฃผ๊ธฐ๋ง ๊ด๋ฆฌํฉ๋๋ค.
Overlay๊ฐ ํญ์ ๋ ์์ด์ผ ํ๋ ํน์ฑ์,
onStartCommand()์์๋ START_STICKY๋ฅผ ๋ฐํํด
์๋น์ค๊ฐ ๊ฐ์ ์ข
๋ฃ๋ผ๋ ์๋์ผ๋ก ๋ณต๊ตฌ๋๋๋ก ์ค์ ํฉ๋๋ค.
์ฆ, ์ด ๊ตฌ์กฐ๋
๐ UI ๋ก์ง์ ComposeWindow์ ์์ํ๊ณ
๐ Service๋ ์ปจํธ๋กค๋ฌ ์ญํ ๋ง ์ํํ๋
๊น๋ํ ์ฑ
์ ๋ถ๋ฆฌ ๊ตฌ์กฐ์
๋๋ค.
์๋๋ ์ค์ Overlay UI๋ฅผ Window์ ๋์ฐ๊ธฐ ์ํด ์ฌ์ฉํ
ComposeWindow ์ถ์ ํด๋์ค์ ๊ทธ ๊ตฌํ์ฒด ์ฝ๋์
๋๋ค.
ComposeWindow.ktabstract class ComposeWindow(
private val context: Context,
private val lifecycleOwner: MyLifecycleOwner,
) {
protected val windowManager: WindowManager =
context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
protected val params by lazy { createLayoutParams() }
protected val composeView by lazy { createComposeView() }
protected val handler = Handler(Looper.getMainLooper())
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
fun create() {
lifecycleOwner.run {
onCreate()
configureComposeView()
onStart()
showOverlay()
onResume()
}
}
fun destroy() {
if (composeView.isAttachedToWindow) {
windowManager.removeView(composeView)
}
scope.cancel()
lifecycleOwner.run {
onPause()
onStop()
onDestroy()
}
}
fun reconfigureContent() {
handler.post {
configureComposeView()
}
}
protected fun setVisibility(visible: Boolean) {
if (composeView.isVisible == visible) return
composeView.isVisible = visible
}
private fun createComposeView(): ComposeView = ComposeView(context).apply {
pivotX = 0f
pivotY = 0f
setViewTreeLifecycleOwner(lifecycleOwner)
setViewTreeViewModelStoreOwner(lifecycleOwner)
setViewTreeSavedStateRegistryOwner(lifecycleOwner)
setViewTreeOnBackPressedDispatcherOwner(lifecycleOwner)
}
protected abstract fun configureComposeView()
protected abstract fun showOverlay()
protected abstract fun createLayoutParams(): WindowManager.LayoutParams
protected fun defaultOverlayFlags() = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
}
class ComposeWindowImpl @Inject constructor(
val context: Context,
lifecycleOwner: MyLifecycleOwner,
) : ComposeWindow(context, lifecycleOwner) {
override fun configureComposeView() {
composeView.setContent {
SampleTheme {
SampleScreen(
composeView,
windowManager,
params,
onShowWindow = ::setVisibility,
)
}
}
}
override fun showOverlay() {
if (!composeView.isAttachedToWindow) {
windowManager.addView(composeView, params)
}
}
override fun createLayoutParams() = WindowManager.LayoutParams(
200,
100,
WindowManager.LayoutParams.TYPE_CAMERA_OVERLAY,
defaultOverlayFlags(),
PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.TOP or Gravity.LEFT
x = 0
y = 0
}
}
@Module
@InstallIn(SingletonComponent::class)
object WindowModule {
@Provides
@Singleton
fun provideComposeWindow(
@ApplicationContext context: Context,
lifecycleOwner: MyLifecycleOwner,
): ComposeWindow {
return ComposeWindowImpl(
context,
lifecycleOwner,
)
}
}
์ ์ฝ๋๋ Service์์ ๋์ฐ๋ Overlay UI๋ฅผ Compose ๊ธฐ๋ฐ์ผ๋ก ๊ตฌ์ฑํ๊ธฐ ์ํ
ํต์ฌ์ ์ธ ์ปดํฌ๋ํธ ๊ตฌ์กฐ์
๋๋ค.
ComposeWindow (์ถ์ ํด๋์ค)create(), destroy())์ ์ค์ฌ์ผ๋กComposeView ์์ฑ, WindowManager ๋ฑ๋ก/์ ๊ฑฐ, ์ฌ๊ตฌ์ฑ ๋ฑ์ ๊ธฐ๋ฅ์ ์ ๊ณตํฉ๋๋ค. ComposeView๋ฅผ ์์ฑํ๋ฉฐ, ์ด๋ ๋ค์๊ณผ ๊ฐ์ ์ค์ ์ ์๋์ผ๋ก ํด์ค๋๋ค:setViewTreeLifecycleOwner(lifecycleOwner)
setViewTreeViewModelStoreOwner(lifecycleOwner)
setViewTreeSavedStateRegistryOwner(lifecycleOwner)
setViewTreeOnBackPressedDispatcherOwner(lifecycleOwner)
ComposeWindowImpl (๊ตฌํ ํด๋์ค)ComposeWindow์ ์ค์ ๊ตฌํ์ฒด๋ก, Overlay UI๋ฅผ ๊ตฌ์ฒด์ ์ผ๋ก ๊ตฌ์ฑํ๊ณ ํ์ํ๋ ์ญํ ์ ํฉ๋๋ค. SampleScreen์ ComposeView์ ์ฐ๊ฒฐํ๊ณ , Window์ ์ถ๊ฐํ๊ฑฐ๋ ์ ๊ฑฐํ๋ ๊ณผ์ ์ ๋ด๋นํฉ๋๋ค. LayoutParams๋ ์ด ํด๋์ค์์ ์ ์ํฉ๋๋ค.Jetpack Compose๋ฅผ Activity๋ Fragment๊ฐ ์๋ ํ๊ฒฝ (์: Service, WindowManager Overlay ๋ฑ)์์ ์ฌ์ฉํ๋ ค๋ฉด
๋ค์ ๋ค ๊ฐ์ง ์ค์ ์ ๋ฐ๋์ ์๋์ผ๋ก ํด์ฃผ์ด์ผ ํฉ๋๋ค.
์ด ์ค์ ๋ค์ Android View Hierarchy์์ Compose, ViewModel, Navigation, ์ํ ์ ์ฅ ๋ฑ์ ๊ธฐ๋ฅ์ด
์ ์์ ์ผ๋ก ์๋ํ๋๋ก ๋ณด์ฅํด์ค๋๋ค.
ComposeView๊ฐ Fragment๋ Activity๊ฐ ์๋ ํ๊ฒฝ(์: Service์ Overlay)์์ ์ฌ์ฉ๋ ๊ฒฝ์ฐ,
Compose ๊ด๋ จ ๊ธฐ๋ฅ๋ค์ด ๊ธฐ๋ณธ์ ์ผ๋ก ์ ๊ณต๋์ง ์๊ธฐ ๋๋ฌธ์ ๋ค์ ๋ค ๊ฐ์ง๋ฅผ ์๋์ผ๋ก ์ค์ ํด์ผ ํฉ๋๋ค:
LifecycleOwnerViewModelStoreOwnerSavedStateRegistryOwnerOnBackPressedDispatcherOwnerJetpack Compose๋ฅผ Activity/Fragment ์ธ๋ถ ํ๊ฒฝ(์: Service, Overlay)์์ ์ฌ์ฉํ ๋
๋ฐ๋์ ์๋์ผ๋ก ์ค์ ํด์ผ ํ๋ ViewTree ๊ด๋ จ ํจ์๋ค์ ์๋์์ ์์ธํ ๋ค๋ฃน๋๋ค.
Lifecycle.current ๋ฑ์ ํตํด ํ์ฌ Lifecycle์ ์ ๊ทผ ๊ฐ๋ฅLaunchedEffect, DisposableEffect, rememberUpdatedState ๋ฑ์ Lifecycle์ ์์กดviewModel() ๋๋ hiltViewModel() ์ฌ์ฉ ์ ํ์ํ ์ค์ rememberSaveable()์ด ๋ด๋ถ์ ์ผ๋ก ์ด Registry๋ฅผ ํตํด ์ํ๋ฅผ ์ ์ฅ/๋ณต์BackHandler { ... } ๋ฅผ Compose์์ ์ฌ์ฉํ๋ ค๋ฉด ์ด ์ค์ ์ด ํ์Jetpack Compose๋ฅผ Activity๋ Fragment ์ธ๋ถ ํ๊ฒฝ(์: Service, Overlay ๋ฑ)์์ ์ฌ์ฉํ ๋๋
๋ค์ ๋ค ๊ฐ์ง ์ค์ ์ ๋ฐ๋์ ์๋์ผ๋ก ์ ์ฉํด์ผ Compose ๊ธฐ๋ฅ์ด ์ ์์ ์ผ๋ก ์๋ํฉ๋๋ค:
setViewTreeLifecycleOwner(view, lifecycleOwner)
โ LaunchedEffect, DisposableEffect ๋ฑ ์๋ช
์ฃผ๊ธฐ ๊ธฐ๋ฐ ์ปดํฌ์ ๋ธ ์๋์ ์ํด ํ์
setViewTreeViewModelStoreOwner(view, storeOwner)
โ viewModel(), hiltViewModel() ์ฌ์ฉ ์ ViewModel ํ์ ๊ธฐ๋ฐ ์ ๊ณต
setViewTreeSavedStateRegistryOwner(view, owner)
โ rememberSaveable() ๋ฑ ์ํ ์ ์ฅ ๊ธฐ๋ฅ์ ๊ฐ๋ฅํ๊ฒ ํจ
setViewTreeOnBackPressedDispatcherOwner(view, owner)
โ BackHandler ์ฌ์ฉ์ ์ํ ๋ค๋ก ๊ฐ๊ธฐ ์ด๋ฒคํธ ์ฒ๋ฆฌ ์ง์
๐ก ์ด ๋ค ๊ฐ์ง๋ฅผ ๋น ์ง์์ด ์ค์ ํด์ผ Compose์ ์ฃผ์ ๊ธฐ๋ฅ๋ค์ด Activity ์์ด๋ ์ ๋๋ก ์๋ํ๋ค.
ํนํ Service ๊ธฐ๋ฐ Overlay UI ๊ตฌ์ฑ ์์๋ ํ์ ์ฒดํฌ ํญ๋ชฉ์
๋๋ค.
@Composable
fun SampleScreen(
composeView: ComposeView,
windowManager: WindowManager,
layoutParams: LayoutParams,
onShowWindow: (Boolean) -> Unit,
) {
val entryPoint = getEntryPoint(LocalContext.current)
val sampleViewModel: SampleViewModel = entryPoint.sampleViewModel()
SampleContent()
}
SampleScreen์ Overlay์ ๋์์ง Compose UI์ ์ง์
์ ์
๋๋ค.
โ ๏ธ ์ผ๋ฐ์ ์ธ Activity ํ๊ฒฝ์ด ์๋๊ธฐ ๋๋ฌธ์ hiltViewModel()์ ์ฌ์ฉํ ์ ์์ต๋๋ค.
๐ ๋์ , EntryPoint๋ฅผ ํตํด Hilt์์ ViewModel์ ์๋์ผ๋ก ์ฃผ์
๋ฐ์์ผ ํฉ๋๋ค.
์ด๋ Service, Dialog, WindowManager์ ๊ฐ์ด ComposeView๋ง ์กด์ฌํ๋ ํ๊ฒฝ์์ ViewModel์ ์์ ํ๊ฒ ์ฌ์ฉํ๋ ํ์ ๋ฐฉ์์
๋๋ค.
์ด ViewModel์ ํ์ฉํ์ฌ UI ์ํ๋ฅผ ๊ด๋ฆฌํ๊ฑฐ๋ ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค.
์ค์ UI๋ SampleContent()์์ ๊ทธ๋ฆฌ๋ฉฐ, ์ด ํจ์๋ ViewModel ๋๋ ์ธ๋ถ ์ํ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ๊ตฌ์ฑ๋ฉ๋๋ค.
composeView, windowManager, layoutParams, onShowWindow๋
Overlay ์ ์ด๋ฅผ ์ํ ํ๋ผ๋ฏธํฐ๋ก, ์ถํ ๊ธฐ๋ฅ ํ์ฅ์ ์ฌ์ฉ๋ฉ๋๋ค.
@Module
@InstallIn(SingletonComponent::class)
object ViewModelModule {
@Provides
@Singleton
fun provideSampleViewModel(
sampleRepository: SampleRepository,
): SampleViewModel {
return SampleViewModel(
sampleRepository,
)
}
}
@InstallIn(SingletonComponent::class)
@EntryPoint
interface ViewModelEntryPoint {
fun sampleViewModel(): SampleViewModel
}
fun getEntryPoint(context: Context): ViewModelEntryPoint {
return EntryPointAccessors.fromApplication(
context,
ViewModelEntryPoint::class.java,
)
}
Activity, Fragment ์์ด ViewModel์ ์ฌ์ฉํด์ผ ํ๋ ์ํฉ
(์: Service, WindowManager, Overlay)์์๋
hiltViewModel()์ ์ฌ์ฉํ ์ ์๊ธฐ ๋๋ฌธ์,
EntryPoint๋ฅผ ํตํด ViewModel์ ์๋์ผ๋ก ์ฃผ์
๋ฐ๋ ๊ตฌ์กฐ๋ฅผ ์ฌ์ฉํฉ๋๋ค.
ViewModelModule์ Hilt๊ฐ SampleViewModel์ ์์ฑํ ์ ์๋๋ก
๋ช
์์ ์ผ๋ก @Provides ํจ์๋ฅผ ์ ์ํ ๋ชจ๋์
๋๋ค.
์ด๋ฅผ ํตํด @Inject ์์ฑ์๋ฅผ ๊ฐ์ง ViewModel๋ Hilt๊ฐ ๊ด๋ฆฌํ ์ ์๊ฒ ๋ฉ๋๋ค.
ViewModelEntryPoint๋ ์ธ๋ถ์์ Hilt ์ปดํฌ๋ํธ ๋ด๋ถ์ ์ ๊ทผํ ์ ์๋๋ก ์ด์ด์ฃผ๋ ์ธํฐํ์ด์ค์
๋๋ค.
@EntryPoint์ @InstallIn(SingletonComponent::class)๋ก ๋ฑ๋ก๋ ์ด ์ธํฐํ์ด์ค๋ฅผ ํตํด
ViewModel์ ์์ ํ๊ฒ ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค.
getEntryPoint(context) ํจ์๋ EntryPointAccessors๋ฅผ ์ด์ฉํด
ApplicationContext๋ก๋ถํฐ Hilt EntryPoint ์ธ์คํด์ค๋ฅผ ํ๋ํฉ๋๋ค.
๐ ์ด ๊ตฌ์กฐ ๋๋ถ์ hiltViewModel() ์์ด๋
ComposeView + Service ๊ธฐ๋ฐ ํ๊ฒฝ์์๋ ViewModel์ ์์ ํ๊ฒ ์ฃผ์
๋ฐ์ ์ฌ์ฉํ ์ ์์ต๋๋ค.
class SampleViewModel @Inject constructor(
private val sampleRepository: SampleRepository,
) : ViewModel() {
}
์์ฑ์์ @Inject๋ฅผ ๋ถ์ฌ sampleRepository๋ฅผ ์ฃผ์
๋ฐ์ผ๋ฉฐ,
Hilt๊ฐ ์ด ViewModel์ ์์กด์ฑ์ ์๋์ผ๋ก ํด๊ฒฐํ ์ ์๋๋ก ๊ตฌ์ฑ๋์ด ์์ต๋๋ค.
์ผ๋ฐ์ ์ผ๋ก๋ @HiltViewModel์ ์ ์ธํ๊ณ hiltViewModel()๋ก ์ฃผ์
๋ฐ์ง๋ง,
์ด ํ๋ก์ ํธ๋ Activity๋ Fragment๊ฐ ์๋ ํ๊ฒฝ(์: Service, Overlay) ์ด๋ฏ๋ก
์ด๋ฌํ ๋ฐฉ์์ ์ฌ์ฉํ ์ ์์ต๋๋ค.
โ ๋ฐ๋ผ์ ์ด ViewModel์๋ @HiltViewModel์ ๋ถ์ด๋ฉด ์ ๋ฉ๋๋ค.
@HiltViewModel์ ๋ด๋ถ์ ์ผ๋ก ViewModelProvider.Factory์ Android์ LifecycleScope์ ์์กดํ๋๋ฐ,
ComposeView๋ง ์กด์ฌํ๋ ํ๊ฒฝ์์๋ ์ด๋ค์ด ์กด์ฌํ์ง ์๊ธฐ ๋๋ฌธ์
๋๋ค.
๋์ EntryPoint๋ฅผ ํตํด ViewModel์ ์๋์ผ๋ก ๊ฐ์ ธ์ ์ฌ์ฉํ๋ ๋ฐฉ์์ด ํ์ํฉ๋๋ค.
์์ ๊ฐ์ ๊ตฌ์กฐ๋ก ๊ฐ๋ฐ์ ํ๋ค๋ฉด ๊ธฐ์กด Compose ๊ฐ๋ฐ๊ณผ ํฐ ์ฐจ์ด์ ์์ด ์์ฐ์ค๋ฝ๊ฒ ๊ฐ๋ฐ์ด ๊ฐ๋ฅํ ๊ฒ์ ๋๋ค.
Service + Compose + ViewModel + Hilt๋ฅผ ํจ๊ป ์ฌ์ฉํ๋ ๊ณผ์ ์์
๊ฒช์๋ ์ํ์ฐฉ์ค๋ค์ ๊ฐ์ฅ ๊ฐ๋จํ๊ณ ๋ช
ํํ ์์ ๋ก ํ์ด๋ด๋ ค ํ์ต๋๋ค.
์ ๋ ์ค์ ๋ก ComposeOverlayService๋ฅผ ์ฌ์ฉํ ๊ตฌ์กฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก
Camera, Stream ์ ์ด, ์๋ฒ ํต์ ๋ฑ ๋ค์ํ ๊ธฐ๋ฅ์ ์์ ์ ์ผ๋ก ๊ตฌํํ ์ ์์์ต๋๋ค.
์ด ๊ธ์ด Service ์์ Compose UI๋ฅผ ๊ตฌํํ๋ ค๋ ๋ถ๋ค๊ป
๊ตฌ์กฐ์ ์ธ ์ดํด์ ์ค์ ์ ์ฉ์ ๋์์ด ๋์๊ธฐ๋ฅผ ๋ฐ๋๋๋ค.
์ ์ญ์ ์๋ฃ๊ฐ ๋ง์ง ์์ ์ฌ๋ฌ ๋ฒ ์ํ์ฐฉ์ค๋ฅผ ๊ฒช์์ง๋ง,
์ด ๊ตฌ์กฐ ๋๋ถ์ ์ ์ฐํ๊ณ ํ์ฅ ๊ฐ๋ฅํ UI๋ฅผ ์์ ์ ์ผ๋ก ์ด์ฉํ ์ ์์์ต๋๋ค.
์ฌ๋ฌ๋ถ์ ํ๋ก์ ํธ์์๋ ์ด ๋ฐฉ์์ด ์ข์ ์ถ๋ฐ์ ์ด ๋๊ธธ ๋ฐ๋๋๋ค. ๐