액티비티(Activity)는 안드로이드의 주요 컴포넌트 중 하나로 애플리케이션에서 필수적으로 사용된다. 2007년, 안드로이드가 등장한 뒤로 액티비티간에 데이터(인텐트)를 전달하고, 결과를 처리할 때 개발자는 startActivityForResult()의 호출과 onActivityResult(requestCode, resultCode, data) 콜백호출을 다뤄왔다.
새로운 액티비티 결과(Activity Result) API는 지금까지 해오던 방법을 개선하여 완전히 새로운 액티비티 결과 처리 방법을 제공한다.
기존 방법으로 액티비티 결과 전달받기
예를 들어 카메라나 갤러리 애플리케이션에게 사진을 요청하고 해당 앱으로부터 결과를 받아 처리하기 위해서는 액티비티 또는 프레그먼트내의 onActivityResult() 메서드를 다음과 같이 다루게 된다.
// 다른 액티비티 호출 startActivityForResult(Intent(...)) // 다른 액티비티로부터 온 결과 다루기 class MainActivity : AppCompatActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if(requestCode == REQ_PHOTO && resultCode == RESULT_OK){ ... }else if(requestCode == REQ_VIDEO && resultCode == RESULT_OK){ ... } } }
startAcitivtyForResult 호출에 의해 새로운 액티비티가 시작된 후 메모리 부족(예: 카메라) 문제 등으로 이전 액티비티가 종료(Destroyed)될 수 있다. 안드로이드는 이런 특징을 가지고 있기 때문에 startActivityForResult(…) 메서드의 매개변수에 콜백 인자를 넘기지 않고 이를 디커플링하여 액티비티 내의 onActivityResult(…) 에서 액티비티 결과를 처리하도록 하고 있다.
새로운 API로 액티비티 결과 처리하기
AndroidX의 Activity 1.2.0-alpha02 와 Fragment 1.3.0-alpha02 부터는 새로운 방식의 액티비티 결과 API를 제공한다.
의존성 추가하기
글을 작성하는 지금 시점에는 alpha04가 최신 버전이다.
implementation "androidx.activity:activity:1.2.0-alpha04" implementation "androidx.fragment:fragment:1.3.0-alpha04"
현재는 alpha 스테이지이기 때문에 프로덕트에 적용하는 것은 추천하지 않는다.
새로운 버전을 확인하고 싶다면 공식 릴리즈 문서를 참고하자.
액티비티 결과 처리하기
NumberActivity에서 입력받은 정수를 MonthActivity로 전달하여 월(month)을 나타내고,
MonthActivity를 종료할 때 월 문자열을 NumberActivity로 전달하여 토스트로 화면에 띄우는 예제를 새로운 API를 사용하여 만들어보자.
예제코드는 github에서 다운로드 가능
새로운 액티비티 결과 처리 프로세스는 다음과 같이 3단계로 요약할 수 있다.
Contract 정의하기
Contract를 만들기 위해서는 ActivityResultContract<I, O>라는 추상 클래스를 상속한 서브클래스를 생성해야 한다. ActivityResultContract는 다음 두가지 추상 메서드를 가지고 있다.
abstract class ActivityResultContract<I, O> { abstract Intent createIntent(@NonNull Context context, I input); abstract O parseResult(int resultCode, @Nullable Intent intent); }
- createIntent 메서드 : 다른 액티비티를 호출하기 위한 Intent를 생성한다. 제네릭 타입 I가 intent를 생성하기 위한 매개변수 타입으로 전달된다. (startActivityForResult 메서드 호출을 대체한다.)
- parseResult 메서드 : 액티비티로 전달받은 결과 데이터를 제너릭 O타입으로 변환한다. (onActivityResult 콜백 메서드 처리를 대체한다.)
MonthActivity를 실행하기 위한 Contract는 다음과 같이 만들수 있다.
class MonthActivityContract : ActivityResultContract<Int, String>() { override fun createIntent(context: Context, input: Int): Intent { val intent = Intent(context, MonthActivity::class.java) intent.putExtra("input", input) return intent } override fun parseResult(resultCode: Int, intent: Intent?): String? { return when (resultCode) { Activity.RESULT_OK -> intent?.getStringExtra("result") else -> null } } }
Contract 등록하기
Contract를 정의했다면 resgisterForActivityResult 메서드 호출을 통해 다음과 같이 액티비티 결과 콜백을 등록 할 수 있다.
private val launcher: ActivityResultLauncher<Int> = registerForActivityResult(MonthActivityContract()) { result: String? -> result?.let { Toast.makeText(this, it, Toast.LENGTH_LONG).show() } }
registerForActivityResult 메서드를 호출하면 ActivityResultLauncher를 인스턴스를 반환하게 된다.
Launcher로 액티비티 실행하기
이제 Contract를 등록하고 난 뒤 얻은 ActivityResultLauncher 인스턴스로 액티비티를 실행할 수 있게 된다. ActivityResultLauncher의 launch 메서드를 호출하여 Contract에 정의된 인텐트를 통해 Activity를 호출하게 되는데, 다음의 예제코드에서는 EditText에서 입력받은 정수를 Intent의 Extra로 전달하기 위해 launch 메서드의 매개변수로 전달하고 있다.
val input = findViewById<EditText>(R.id.input) btn.setOnClickListener { var number: Int try { number = input.text.toString().toInt() launcher.launch(number) } catch (e: NumberFormatException) { Toast.makeText(this, "Please enter a number between 1 and 12", Toast.LENGTH_SHORT).show() } }
Note : 프레그먼트 또는 액티비티가 생성되기 전에 registerForActivityResult()를 호출해도 안전하지만, 프래그먼트 또는 액티비티의 Lifecycle 상태가 CREATED가 되기 전까지는 ActivityResultLauncher를 시작할 수 없다.
기 정의된 Contract 사용하기
ActivityResultContracts를 사용하면 이미 정의된 Contract들을 사용할 수 있다.
예를 들어 사진을 불러와 ImageView에 적용하고 싶다면, ActivityResultContracts.GetContent()를 호출할 수 있다.
class PictureActivity : AppCompatActivity() { lateinit var image: ImageView private val launcher = registerForActivityResult(ActivityResultContracts.GetContent()) { result: Uri? -> Glide.with(image).load(result).into(image) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_picture) image = findViewById(R.id.image) findViewById<Button>(R.id.choose).setOnClickListener { launcher.launch("image/*") } } }
GetContent 이외에 현재 기 정의된 Contract는 다음과 같다.
- GetContent : 사용자가 선택한 콘텐트의 Uri를 반환한다.
- GetMultipleContent : 사용자가 선택한 1개 이상의 콘텐츠들이 List<Uri>형태로 반환된다.
- TakePicturePreview : 사진을 찍고 Bitmap을 반환한다.
- TakePicture : 촬영한 사진을 지정한 경로에 저장하고 Bitmap을 반환한다.
- TakeVideo : 촬영한 비디오를 지정한 경로에 저장하고 썸네일을 Bitmap으로 반환한다.
- CreateDocument : 새로운 문서 작성하고 해당 경로를 Uri형태로 반환한다.
- OpenDocument : 사용자가 선택한 문서의 Uri를 반환한다.
- OpenMultipleDocuments : 사용자가 선택한 1개 이상의 문서들이 List<Uri> 형태로 반환된다.
- OpenDocumentTree : 사용자가 선택한 디렉토리의 Uri를 반환한다.
- PickContact : 사용자가 선택한 연락처의 Uri를 반환한다.
- RequestPermission : 단일 권한을 요청하고, 승인 여부를 반환한다.
- RequestMultiplePermissions : 다중 권한을 요청하고, 승인 여부를 Map<String,Boolean>형태로 반환한다.
- StartActivityForResult : 요청한 인텐트를 통해 액티비티를 실행하고, 액티비티 결과를 ActivityResult로 래핑하여 반환한다.
액티비티 결과 API 내부 살펴보기
액티비티가 결과처리가 어떤식으로 내부에서 일어나는지 간단히 살펴보자.
가장 먼저 registerForActivityResult 메서드의 호출이 어떤식으로 프레임워크 내부에서 살펴보았다.
public class ComponentActivity extends androidx.core.app.ComponentActivity implements LifecycleOwner, ViewModelStoreOwner, HasDefaultViewModelProviderFactory, SavedStateRegistryOwner, OnBackPressedDispatcherOwner, ActivityResultRegistryOwner, ActivityResultCaller { private ActivityResultRegistry mActivityResultRegistry = new ActivityResultRegistry() { ... }; @NonNull @Override public final <I, O> ActivityResultLauncher<I> registerForActivityResult( @NonNull final ActivityResultContract<I, O> contract, @NonNull final ActivityResultRegistry registry, @NonNull final ActivityResultCallback<O> callback) { return registry.register( "activity_rq#" + mNextLocalRequestCode.getAndIncrement(), this, contract, callback); } @NonNull @Override public final <I, O> ActivityResultLauncher<I> registerForActivityResult( @NonNull ActivityResultContract<I, O> contract, @NonNull ActivityResultCallback<O> callback) { return registerForActivityResult(contract, mActivityResultRegistry, callback); } ... }
해당 메서드는 ActivityResultCaller 인터페이스의 메서드로 ComponentActivity가 이를 구현하였다. registerForActivityResult(ActivityResultContract, ActivityResultCallback) 를 호출하게 되면 ComponentActivity 내부에 있는 ActivityResultRegistry 인스턴스에 Contract가 등록되는 것을 확인할 수 있다.
@NonNull public final <I, O> ActivityResultLauncher<I> register( @NonNull final String key, @NonNull final LifecycleOwner lifecycleOwner, @NonNull final ActivityResultContract<I, O> contract, @NonNull final ActivityResultCallback<O> callback) { final int requestCode = registerKey(key); mKeyToCallback.put(key, new CallbackAndContract<>(callback, contract)); Lifecycle lifecycle = lifecycleOwner.getLifecycle(); final ActivityResult pendingResult = mPendingResults.getParcelable(key); if (pendingResult != null) { mPendingResults.remove(key); if (lifecycle.getCurrentState().isAtLeast(Lifecycle.State.STARTED)) { callback.onActivityResult(contract.parseResult( pendingResult.getResultCode(), pendingResult.getData())); } else { lifecycle.addObserver(new LifecycleEventObserver() { @Override public void onStateChanged( @NonNull LifecycleOwner lifecycleOwner, @NonNull Lifecycle.Event event) { if (Lifecycle.Event.ON_CREATE.equals(event)) { callback.onActivityResult(contract.parseResult( pendingResult.getResultCode(), pendingResult.getData())); } } }); } } lifecycle.addObserver(new LifecycleEventObserver() { @Override public void onStateChanged(@NonNull LifecycleOwner lifecycleOwner, @NonNull Lifecycle.Event event) { if (Lifecycle.Event.ON_DESTROY.equals(event)) { unregister(key); } } }); return new ActivityResultLauncher<I>() { @Override public void launch(I input, @Nullable ActivityOptionsCompat options) { invoke(requestCode, contract, input, options); } @Override public void unregister() { ActivityResultRegistry.this.unregister(key); } }; }
그리고 ActivityResultRegistry.registry 메서드 내부를 다시 한번 살펴보니 mKeyToCallback이라는 HashMap을 가지고 있고, 자동 생성된 유니크한 키값으로 ActivityResultContract와 ActivityResultCallback를 하나로 묶은 CallbackAndContract 인스턴스를 매핑한것을 확인할 수 있다.
ComponentActivity 내부에서 이런방식으로 CallbackAndContract를 관리하고 있다가 실제로 다른 액티비티로 부터 돌아와 액티비티가 재개되기 전에 onActivityResult가 호출되면 적절한 유효성 검사를 통해 CallbackAndContract 인스턴스를 참조하게 된다. ComponentActivity의 onActivityResult부분을 확인해보자.
public class ComponentActivity extends androidx.core.app.ComponentActivity implements ...{ ... protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { if (!mActivityResultRegistry.dispatchResult(requestCode, resultCode, data)) { super.onActivityResult(requestCode, resultCode, data); } } ... }
ComponentActivity 내부에서 가지고 있는 ActivityResultRegistry의 dispatchResult 메서드 호출을 통해 새로운 API로 등록된 액티비티 결과인지 확인하게 된다. dispatchResult 내부를 좀 더 살펴보자.
public abstract class ActivityResultRegistry { ... @MainThread public final boolean dispatchResult(int requestCode, int resultCode, @Nullable Intent data) { String key = mRcToKey.get(requestCode); if (key == null) { return false; } doDispatch(key, resultCode, data, mKeyToCallback.get(key)); return true; } private <O> void doDispatch(String key, int resultCode, @Nullable Intent data, @Nullable CallbackAndContract<O> callbackAndContract) { if (callbackAndContract != null && callbackAndContract.mCallback != null) { ActivityResultCallback<O> callback = callbackAndContract.mCallback; ActivityResultContract<?, O> contract = callbackAndContract.mContract; callback.onActivityResult(contract.parseResult(resultCode, data)); } else { mPendingResults.putParcelable(key, new ActivityResult(resultCode, data)); } } ... }
dispatchResult가 호출되었을 때 mKeyToCallback로부터 CallbackAndContract를 가져와 ActivityResultCallback의 onActivityResult를 호출하는 것을 확인할 수 있다. 이는 최초에 우리가 액티비티 결과 콜백이 호출되기를 기대하며 registerForActivityResult의 매개변수로 전달한 ActivityResultCallback이다.
마찬가지로 권한 요청 처리에 대한 응답 또한 ComponetActivity에서 onActivityResult와 동일한 방식으로 처리하고 있다. 다음 코드를 확인해보자.
public class ComponentActivity extends androidx.core.app.ComponentActivity implements ...{ public void onRequestPermissionsResult( int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { if (!mActivityResultRegistry.dispatchResult(requestCode, Activity.RESULT_OK, new Intent() .putExtra(EXTRA_PERMISSIONS, permissions) .putExtra(EXTRA_PERMISSION_GRANT_RESULTS, grantResults))) { if (Build.VERSION.SDK_INT >= 23) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); } } } }
Conclusion
정리를 하자면, 새로운 Activity Result API를 사용할 때 얻을 수 있는 장점은 크게 3가지로 요약할 수 있다.
- 디커플링 및 관심사 분리 : 기존 액티비티 또는 프레그먼트의 onActivityResult에서 if와 else if로 도배되던 비즈니스 로직들이 콜백메서드 또는 분리된 클래스 단위로 쪼개어져서 관리될 수 있다. 이는 코드의 가독성을 높이고, 유닛테스트를 수월하게 하며, 유지보수측면에서도 많은 도움이 된다.
- Type-Safety : ActivityResultContract는 입력 데이터와 출력 데이터의 타입을 강제하기 때문에 잘못된 타입으로 캐스팅하는 사소한 실수를 미연에 방지시켜준다.
- NPE 방지 : Intent로 부터 데이터를 얻으려고 할 때 NullPointerException이 발생하는 경험을 누구나 한번쯤은 해보았을 것이다. 새로운 API는 NPE가 발생할 확률을 줄여줄 것이다.
안드로이드 개발에서 가장 기본이 되던 API에 추가적인 변경사항이 생기는 것으로 초보 개발자들에게는 혼란을 야기하고, 숙련된 개발자들은 환호성을 지르게 될것같다.
아직 라이브러리가 알파스테이지 이므로 startActivityForResult()와 onActivityResult()의 존폐가 어떨지는 확실하지 않으나, 현재시점에서 @Deprecated 애노테이션이 붙은 것으로 봤을 때 개발자들은 필수적으로 이 새로운 액티비티 결과 API를 다룰줄 알아야 한다고 생각한다.
2개의 댓글
chu · 2020년 5월 5일 11:11 오후
감사합니다 잘 읽었습니다 🙂
오타 : // 다른 액비티로 부터 온 결과 다루기 -> // 다른 액티비티로 부터 온 결과 다루기
Charlezz · 2020년 5월 5일 11:25 오후
수정했습니다 감사합니다 ㅎㅎ