Circuit 에서는 기존의 Android 프로젝트와 통합을 위한 여러 interop(상호운용성) 기능들을 지원한다. 그중에 공식문서에 언급된 몇가지 함수들을 소개해보려고 한다.
이번 글에서 다룰 내용에 대한 토이 앱을 간단하게 만들어보았다.
https://github.com/easyhooon/CircuitNavigationResult
본론에 들어가기 앞서, 공식 문서의 CircuitX, Interop 문서에 관련 내용이 서술되어 있으며, 이들을 사용하기 위해선 circuitx-android 의존성을 추가해주어야한다.
dependencies {
implementation("com.slack.circuit:circuitx-android:<version>")
}
Circuit 은 기본적으로 Single Activity + Navigation 구조를 기반으로 하기 때문에, Multi Activity 구조의 기존 프로젝트에 적용하려면, Main 이 되는 Activity 를 제외한 모든 Activity 를 Composable Screen 으로 변경해야 한다는 부담이 발생할 수 있다.
ViewModel 과 Navigation 도 Circuit Presenter 와 Navigation 으로 migration 해줘야한다.
이때, Circuit 에서 제공하는 rememberAndroidScreenAwareNavigator
와 AndroidScreen
을 활용하면 그 부담을 크게 줄일 수 있는데, 이는 Circuit 의 navigator 를 그대로 사용하면서, Circuit 을 도입하지 않은 기존의 Activity 로 이동할 수 있는 기능을 지원한다.
정확히는 Circuit 의 Presenter 에서
startActivity
함수를 우회해서 호출할 수 있는 기능을 지원하는 것으로,startActivity
함수는 원하는 Activity 로 이동하는 기능 뿐만 아니라, 시스템에서 제공하는 다양한 Intent 액션 기능(웹 브라우저 열기, 전화 걸기, 이메일 작성, 공유하기, 플레이 스토어로 이동 등 )을 지원한다.
이를 어떻게 구현 할 수 있는지, 토이 앱의 코드를 확인해보도록 하겠다.
setContent {
CircuitNavigationResultTheme {
val backStack = rememberSaveableBackStack(root = HomeScreen)
// AndroidScreenAwareNavigator 도입
val navigator = rememberAndroidScreenAwareNavigator(
delegate = rememberCircuitNavigator(backStack),
starter = SecondScreen.buildAndroidStarter(this),
)
CircuitCompositionLocals(circuit) {
Scaffold { innerPadding ->
ContentWithOverlays {
NavigableCircuitContent(
navigator = navigator,
backStack = backStack,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
)
}
}
}
}
}
// SecondActivity
@AndroidEntryPoint
class SecondActivity : ComponentActivity() {
// argument 고유 key 관리
companion object {
const val KEY_TEXT: String = "text"
}
// 전달받은 argument
private val initialText by lazy { intent.getStringExtra(KEY_TEXT)!! }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
CircuitNavigationResultTheme {
// Compose 의 Navigation 도 그대로 사용중
val navController = rememberNavController()
Scaffold { innerPadding ->
NavHost(
navController = navController,
startDestination = "home",
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
...
}
}
}
}
}
}
// SecondScreen
import androidx.annotation.UiContext
import com.slack.circuitx.android.AndroidScreen
import com.slack.circuitx.android.AndroidScreenStarter
@Parcelize
@JvmInline
// SecondActivity 로 전달할 데이터를 생성자 파라미터로 정의
value class SecondScreen(val text: String) : AndroidScreen {
companion object {
fun buildAndroidStarter(@UiContext context: Context): AndroidScreenStarter =
AndroidScreenStarter { screen ->
if (screen is SecondScreen) {
val intent = Intent(context, SecondActivity::class.java)
context.startActivity(intent.putExtra(SecondActivity.KEY_TEXT, screen.text))
true
} else {
false
}
}
}
}
// HomePresenter
class HomePresenter @AssistedInject constructor(
@Assisted private val navigator: Navigator,
) : Presenter<HomeScreen.State> {
@Composable
override fun present(): HomeScreen.State {
...
return HomeScreen.State(
text = text,
) { event ->
when (event) {
...
// SecondScreen(SecondActivity) 로 이동
is HomeScreen.Event.NavigateToSecondActivity -> navigator.goTo(SecondScreen("second"))
...
}
}
}
...
}
Circuit 은 Compose driven architecture 를 표방하며, Compose-first 이지만, XML 기반의 View 로 만들어진 화면에서도, AndroidView 를 도입하여 사용할 수 있다.
@Composable
fun CounterViewComposable(state: CounterScreen.State, modifier: Modifier = Modifier) {
val eventSink by rememberUpdatedState(state.eventSink)
AndroidView(
modifier = modifier.fillMaxSize(),
factory = { context ->
CounterView(context).apply {
setOnIncrementClickListener { eventSink(CounterScreen.Event.Increment) }
setOnDecrementClickListener { eventSink(CounterScreen.Event.Decrement) }
}
},
update = { view -> view.setState(state.count) },
)
}
전체적인 코드를 파악해보고 싶다면, circuit github 내에 sample 앱 중, interop 프로젝트를 확인해보면 도움이 될 듯 하다.
Circuit 의 기존 Android 프로젝트와의 interop(상호운용) 을 위한 지원 기능들을 살펴보고, 토이 앱의 코드를 확인해보며 실제 사용 방법을 알아볼 수 있었다.
기존의 프로젝트를 Circuit 으로 migration 하는 경우, XML 기반의 View 를 Compose 로 migration 하는 것 처럼, 두 방식이 공존하는 과도기를 거치게 된다.
이때 필요한 것이 상호운용성 API 들인데, Circuit 의 경우, 단계적인 migration 을 할 수 있게끔 여러 기능을 제공하는 것을 확인할 수 있었다.
사실 AndroidScreenAwareNavigator
를 사용해보면서, 구현이 살짝 복잡하단 생각이 들었다.
Presenter 에 Hilt 를 통해 Application Context 를 생성자로 주입받은 후에, Event 를 처리하는 람다 블럭 영역에서 Intent 를 직접 사용해도 되지 않나? 라는 생각이 들긴했다.
하지만 이는 AAC ViewModel 에서 처럼, Presenter 가 Android 플랫폼 의존성을 최소화할 수 있도록 제공되는 우회 전략이라고 추측한다.
-> Presenter 에 context 를 주입하여 사용하는 것이 과연 맞는 구현 방식인 것인지 조금 찜찜하여, 레퍼런스를 찾아봤는데, Circuit 을 직접 개발하신 Zac Sweers 님의 CatchUp 프로젝트에서도 Presenter 에 Application Context 를 생성자 주입하여 사용하는 것을 확인할 수 있었다.
다만, Intent 를 Presenter 에서 직접 사용하는 케이스는 찾을 수 없었다.
Presenter 에서 context 를 필요로 한다면, Application Context 를 생성자에 주입하여 사용하되, 이 context 를 통해 Android 플랫폼 관련 기능을 직접 구현 하는 것 자체는 지양하는 것을 파악할 수 있었다.
만약 프로젝트를 KMP 로 migration 할 계획이 있다면, commonMain 에 들어갈 presenter 내에 Android 진영의 context 를 주입할 수 없기 때문에, 사용을 지양하도록 해야한다.
Presenter 내에서 Intent 를 통한 startActivity 를 직접 호출하는 경우
Calling startActivity() from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag. Is this really what you want?
라는 에러가 발생하는 것을 확인할 수 있었다. 이에 대한 구글링을 해본 결과, 에러 메세지의 내용 처럼 ViewModel 이나 Presenter 같은 Activity 가 아닌 곳에서 startActivity 를 호출하는 것을 방지하는 에러로, intent Flag 를 추가해도 에러가 사라지지 않는 것을 확인 할 수 있었다.
따라서 Circuit 을 사용할 때, AndroidScreenAwareNavigator
를 통해 startActivity 호출을 우회하여 구현 하는 것은 권장하는 방식이자 필수라는 것을 확인할 수 있었다.
@Parcelize
@JvmInline
value class ShareScreen(val imageUri: String) : AndroidScreen {
companion object {
fun buildAndroidStarter(@UiContext context: Context): AndroidScreenStarter =
AndroidScreenStarter { screen ->
if (screen is ShareScreen) {
context.shareImage(Uri.parse(screen.imageUri))
true
} else {
false
}
}
}
}
// Presenter 에서 직접 호출 불가(위의 에러 발생)
fun Context.shareImage(imageUri: Uri) {
try {
ShareCompat.IntentBuilder(this)
.setStream(imageUri)
.setType("image/png")
.startChooser()
} catch (e: Exception) {
Timber.e("Failed to share image: ${e.message}")
}
}
// HomePresenter
fun shareBandalart(bitmap: ImageBitmap) {
clearShareState()
context.bitmapToFileUri(bitmap)?.let { uri ->
navigator.goTo(ShareScreen(uri.toString()))
}
}
이번 글을 작성하기 위해 토이 앱을 개발하던 중, 공식 문서의 예제 코드에 문제점을 발견하였다.
이를 수정하기 위한 PR 을 올려, Circuit 의 컨트리뷰터가 될 수 있었다.
비록 Circuit Navigation 공식 문서내에 예제 코드의 변수명을 통일하는 간단한 작업이었지만, 평소에 관심있던, 전세계의 사람들이 사용하는 오픈소스에 기여할 수 있어서 뿌듯하다. 컨트리뷰션은 언제나 짜릿해
레퍼런스)
https://slackhq.github.io/circuit/circuitx/
https://slackhq.github.io/circuit/interop/
https://github.com/jisungbin/dog-browser-circuit