This content originally appeared on DEV Community and was authored by Sewon Ann
안드로이드는 intent 를 이용해 다른 앱의 기능을 유연하게 호출하고, 결과를 받아올 수 있다. 이렇게 결과를 받아오는 데 사용하는 함수인 startActivityForResult
와 onActivityResult
는 편리해 보이지만, 여러 가지 함정을 가지고 있었다. 이 글에선 새로운 표준이 된 Activity Result API
와 ActivityResultContract
에 대해 깊이 파헤쳐 보고, 사용 시 주의할 점까지 함께 알아보겠다.
ActivityResultContract
소개
ActivityResultContract 는 다른 액티비티를 실행하고, 그로부터 결과를 받아오는 작업을 추상화한 클래스다. 이 클래스는 두 가지 타입, 즉 입력(Input)과 출력(Output)을 제네릭으로 받는다. createIntent
메서드를 통해 입력을 바탕으로 인텐트를 생성하고, parseResult
메서드를 통해 onActivityResult
콜백으로부터 받은 결과를 출력 타입으로 변환하는 역할을 한다.
과거의 유산: onActivityResult
ActivityResultContract
가 등장하기 전에는 다른 액티비티로부터 결과를 받기 위해 startActivityForResult
와 onActivityResult
를 사용했다.
Activity
에서 onActivityResult
사용하기
class OldActivity : AppCompatActivity() {
val REQUEST_CODE = 123
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
val intent = Intent(this, OtherActivity::class.java)
startActivityForResult(intent, REQUEST_CODE)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_CODE && resultCode == Activity.RESULT_OK) {
// 결과 처리
}
}
}
이 방식은 requestCode
를 사용해 여러 결과를 구분해야 했고, onActivityResult
메서드 내부에 복잡한 조건문이 생길 수 있다는 단점이 있었다. 또한, Activity
클래스에 직접 의존하기 때문에 유닛 테스트가 어려워지는 문제도 있었다.
Fragment
에서 onActivityResult
사용하기
Fragment
역시 onActivityResult
를 사용했다.
class OldFragment : Fragment() {
override fun onStart() {
super.onStart()
val intent = Intent(requireContext(), OtherActivity::class.java)
startActivityForResult(intent, REQUEST_CODE)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_CODE && resultCode == Activity.RESULT_OK) {
// 결과 처리
}
}
}
Fragment
역시 Activity
와 마찬가지로 onActivityResult
를 오버라이드해야 했으며, 이는 비슷한 단점들을 야기했다.
ActivityResultContract
의 내부 동작: Registry
를 통한 추상화
많은 개발자가 ActivityResultContract
가 완전히 새로운 방식으로 동작한다고 생각하지만, 사실 내부적으로는 여전히 startActivityForResult
와 onActivityResult
를 사용한다. ActivityResultContract
는 이 두 메서드를 개발자가 직접 호출하는 대신, ActivityResultRegistry 를 통해 이를 추상화하고 관리한다.
ActivityResultRegistry
는 생명주기에 안전한 방식으로 결과를 전달하기 위해 내부적으로 리스너와 키를 사용한다. registerForActivityResult
를 호출하면 다음 세 가지 과정이 진행된다.
- 키 생성:
ActivityResultRegistry
는 내부적으로 고유한 키(예:"activity_rq#1"
)를 생성한다. 참고: ComponentActivity#registerForActivityResult - 리스너 등록: 이 고유한 키와 함께, 개발자가 제공한 콜백(
ActivityResultCallback
)이ActivityResultRegistry
에 등록된다. 참고: ActivityResultRegistry#register
이제 ActivityResultLauncher
의 launch
메서드가 호출되면, ActivityResultRegistry
는 준비된 인텐트를 startActivityForResult
를 이용해 실행한다. ( 참고 : ComponentActivity.activityResultRegistry) 액티비티가 종료되어 결과가 반환되면, onActivityResult
를 통해 전달된 결과를 ActivityResultRegistry
가 가로챈다. (참고: ComponentActivity#onActivityResult)
마지막으로, ActivityResultRegistry
는 처음 등록했던 고유한 키를 이용해 등록된 콜백을 찾아 onResult
를 실행하는 방식으로 결과를 전달한다. 이 모든 과정이 ActivityResultContract
뒤에 숨겨져 있어 개발자는 복잡한 콜백 처리를 직접 할 필요가 없다.
이 과정을 sequence diagram 으로 표현하면 다음과 같다.
ActivityResultContract
사용 시 주의할 점
ActivityResultContract
는 분명 강력한 도구이지만, 잘못 사용하면 예상치 못한 버그를 유발할 수 있다. 가장 중요한 원칙은 registerForActivityResult()
는 항상 같은 순서로 호출되어야 한다는 점이다.
Activity
에서 사용할 때
Activity
에서 registerForActivityResult
를 호출할 때는 클래스 인스턴스가 생성되는 시점에 멤버 변수로 등록하는 것을 권장한다.
class NewActivity : AppCompatActivity() {
// 인스턴스가 생성될 때 초기화
private val someActivityResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
// 결과 처리
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
someActivityResultLauncher.launch(intent)
}
}
만약 경우에 따라 호출 순서가 달라진다면 어떤 일이 일어날까? 이런 식으로 짜면 안되지만 하여간 다음 코드를 상상해보자.
class NewActivity : AppCompatActivity() {
lateinit var someActivityResultLauncher : ActivityResultLauncher<Intent>
lateinit var otherActivityResultLauncher : ActivityResultLauncher<Intent>
init {
val isAm = ZonedDateTime.now().hour < 12
if( isAm) {
someActivityResultLauncher = registerForActivityResult(..) { }
otherActivityResultLauncher = registerForActivityResult(...) { }
} else {
otherActivityResultLauncher = registerForActivityResult(...) { }
someActivityResultLauncher = registerForActivityResult(...) { }
}
}
}
오전에 액티비티가 생성되어 some > other 순으로 register 되었다. 이 경우 some 의 내부 key는 activity_req#0
, other 는 activity_req#1
이 된다. some 을 launch 해서 다른 OtherActivity 가 호출되었다. 그 사이에 오후가 되었다. 그 사이에 사용자가 화면을 회전해서 NewActivity
는 재생성되어야 한다. 이제 OtherActivity가 재생성되면서 결과값이 전달된다. 그런데 이번엔 other 가 #0, some 이 #1 이 되어버렸다. 결국 some 의 callback 이 호출되어야 하는데, 엉뚱하게 other 의 callback 이 호출되어 버린다. 만약 두 callback 의 인자 타입이 다를 경우엔 class cast exception 이 날 수도 있고, 아니면 가져온 결과값으로 엉뚱한 일이 벌어질 수 있다.
여기서 의문이 한가지 들 수 있다. startActivityForResult()
에 전달되는 request code 는 랜덤하게 생성되는데, 액티비티가 재생성되었을 때 onActivityResult 에서 이전 request code 를 어떻게 알고 callback 을 호출하는걸까? 이 부분은 ActivityResultRegistry
가 재생성 시 복구되면서 기존 내부 key 와 request code 간 매핑을 복구하기 때문에 가능핟.
ActivityResultRegistry
는 내부적으로 launcher 들에 부여된 key 를 Int 타입인 request code 로 반환하는 테이블을 갖고 있으며 ( 참고: ActivityResultRegistiry#bindRcKey) , 액티비티가 재생성될 때 이 테이블을 복구한다. 따라서 액티비티가 재생성되어도 이전에 호출했던 request code 를 가지고 어떤 콜백을 호출해야 할 지 잘 알아낼 수 있다. ( 참고 : ActivityResultRegistry#onRestoreInstanceState)
Fragment
에서 사용할 때
Fragment
역시 Activity
와 동일한 문제가 발생할 수 있다. 추가적으로 Fragment
가 destroy
된 후 다시 create
될 때, 이전에 등록했던 콜백 정보가 사라져 콜백이 제대로 전달되지 않을 수 있다. 이는 Fragment
가 destroy
될 때 ActivityResultRegistry
에 등록된 정보가 제거되기 때문이다.
lifecycleOwner 가 DESTROY 될 때 등록된 contract, callback 을 unregister 하는 동작은 어차피 ActivityResultRegistry
가 수행하기 때문에 Activity
나 Fragment
나 동일하다. 하지만 나는 한번 destroy 된 Activity
인스턴스가 다시 create 되는 경우를 보지 못했는데, Fragment
에선 이런 경우를 종종 봤다. 따라서, Activity
에선 객체 초기화 시 register 를 권장했지만 Fragment
에선 오히려 onCreate
에서 register 를 권장한다. 이래야 destroy 후 re-create 시 다시 ActivityResultRegistry
에 Launcher
가 등록되기 때문이다.
따라서 Fragment
인스턴스 초기화 시점보다는 onCreate
콜백 내에서 registerForActivityResult
를 호출하는 것이 더 안전하다.
class NewFragment : Fragment() {
private lateinit var someActivityResultLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Fragment가 재생성될 때마다 콜백이 다시 등록되도록 보장
someActivityResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
// 결과 처리
}
}
fun someButtonClick() {
someActivityResultLauncher.launch(Intent(requireContext(), OtherActivity::class.java))
}
}
Composable
에서 사용할 때
Jetpack Compose에서는 rememberLauncherForActivityResult 를 사용한다. 이 함수 역시 내부적으로 activityResultRegistry.register
를 사용하므로, Activity
나 Fragment
와 크게 다르지 않다.
@Composable
fun MyScreen() {
val someActivityResultLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = { result ->
// 결과 처리
}
)
Button(onClick = {
someActivityResultLauncher.launch(Intent(context, OtherActivity::class.java))
}) {
Text("Launch Activity")
}
}
다만 내부 key 가 UUID.randomUUID().toString()
라서, Activity
에서 activity_req#1
와 같은 형태로 등록 순서에 영향을 받는게 아닌, 랜덤한 고유값이 부여되는 부분이 다르다.
rememberLauncherForActivityResult
도 마찬가지로 조건문 안에 등록하면 안 된다. remember
가 있는 함수는 컴포저블의 재구성(recomposition) 과정에서 remember
의 키가 변경되거나 조건에 따라 호출되지 않을 경우, 인스턴스가 새롭게 생성되거나 아예 생성되지 않을 수 있기 때문이다.
또한, rememberLauncherForActivityResult
는 가급적 최상위 컴포저블에서 사용하는 것을 권장한다. 하위 컴포저블에서 사용할 경우, 상위 컴포저블의 상태 변화나 조건문에 따라 컴포저블의 위치 등이 바뀔 경우 액티비티 재생성 시 다른 composable 로 인식되어 이전 composable 의 복구가 이뤄지지 않는다면 결과 콜백이 호출되지 않기 때문이다.
캡슐화 관점에서 기능을 사용하고자 하는 composable 내부에 rememberLauncherForActivityResult
를 사용하면 참 편리하겠지만, 해당 composable 이 어떤 맥락에서, 어떤 경우에 호출되는지 통재하기가 어렵기 때문에 이렇게 구현하는 건 위험할 수 있다. 예를들어 게시판을 만드는데 댓글 composable 에 이미지 첨부를 위해 갤러리를 호출하고, 결과를 받아오는 기능까지 넣어두면 참 멋질 것 같다. 하지만 이 댓글 composable 이 어떤 식으로 호출될지 composable 작성자가 컨트롤 할 수 없고, 이러다보면 액티비티 재생성 시 호출 위치가 바뀌어버릴 경우 찾기 어려운 오류가 발생할 것이다. 반대로 composable 을 사용하는 개발자 입장에서도 이 composable을 사용하는데 어떤 주의사항이 있는지 일일이 파악하기가 쉽지 않아, 이런 경우 애초에 rememberLauncherForActivityResult
를 안쓰는게 낫다.
따라서 가급적 최상위 composable 에만 사용하는게 유지보수 관점에서 더 나은 선택이 될 것이라 생각한다.
맺음말
ActivityResultContract
는 코드를 간결하게 만들고, 타입 안정성을 제공하며, 테스트 용이성을 높여준다. 하지만 주의깊게 사용하지 않을 경우 찾기 어려운 이상동작이 발생할 수 있으므로 잘 알고 사용해야 한다.
This content originally appeared on DEV Community and was authored by Sewon Ann