[Circuit] Interop

이지훈·2024년 12월 27일
0

Circuit

목록 보기
7/9
post-thumbnail

서론

Circuit 에서는 기존의 Android 프로젝트와 통합을 위한 여러 interop(상호운용성) 기능들을 지원한다. 그중에 공식문서에 언급된 몇가지 함수들을 소개해보려고 한다.

이번 글에서 다룰 내용에 대한 토이 앱을 간단하게 만들어보았다.
https://github.com/easyhooon/CircuitNavigationResult

본론에 들어가기 앞서, 공식 문서의 CircuitX, Interop 문서에 관련 내용이 서술되어 있으며, 이들을 사용하기 위해선 circuitx-android 의존성을 추가해주어야한다.

dependencies {
  implementation("com.slack.circuit:circuitx-android:<version>")
}

본론

Circuit 을 Multi Activity 기반 프로젝트에 부분 도입하기

Circuit 은 기본적으로 Single Activity + Navigation 구조를 기반으로 하기 때문에, Multi Activity 구조의 기존 프로젝트에 적용하려면, Main 이 되는 Activity 를 제외한 모든 Activity 를 Composable Screen 으로 변경해야 한다는 부담이 발생할 수 있다.

ViewModel 과 Navigation 도 Circuit Presenter 와 Navigation 으로 migration 해줘야한다.

이때, Circuit 에서 제공하는 rememberAndroidScreenAwareNavigatorAndroidScreen 을 활용하면 그 부담을 크게 줄일 수 있는데, 이는 Circuit 의 navigator 를 그대로 사용하면서, Circuit 을 도입하지 않은 기존의 Activity 로 이동할 수 있는 기능을 지원한다.

정확히는 Circuit 의 Presenter 에서 startActivity 함수를 우회해서 호출할 수 있는 기능을 지원하는 것으로, startActivity 함수는 원하는 Activity 로 이동하는 기능 뿐만 아니라, 시스템에서 제공하는 다양한 Intent 액션 기능(웹 브라우저 열기, 전화 걸기, 이메일 작성, 공유하기, 플레이 스토어로 이동 등 )을 지원한다.

이를 어떻게 구현 할 수 있는지, 토이 앱의 코드를 확인해보도록 하겠다.

MainActivity

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(Circuit 이 적용되지 않은 Activity)

// 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 가 아닌 환경에 도입하기

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 호출을 우회하여 구현 하는 것은 권장하는 방식이자 필수라는 것을 확인할 수 있었다.

ShareScreen in Bandalart

@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()))
    }
}

P.S

이번 글을 작성하기 위해 토이 앱을 개발하던 중, 공식 문서의 예제 코드에 문제점을 발견하였다.
이를 수정하기 위한 PR 을 올려, Circuit 의 컨트리뷰터가 될 수 있었다.

비록 Circuit Navigation 공식 문서내에 예제 코드의 변수명을 통일하는 간단한 작업이었지만, 평소에 관심있던, 전세계의 사람들이 사용하는 오픈소스에 기여할 수 있어서 뿌듯하다. 컨트리뷰션은 언제나 짜릿해

레퍼런스)
https://slackhq.github.io/circuit/circuitx/
https://slackhq.github.io/circuit/interop/
https://github.com/jisungbin/dog-browser-circuit

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글