[Do it! 깡샘의 안드로이드 앱 프로그래밍 with 코틀린] 액티비티 컴포넌트

안드로이드 앱 프로그래밍에서 액티비티 컴포넌트와 인텐트, 액티비티 생명주기, ANR 문제와 코루틴을 정리했다. 인텐트 필터 설정, 인텐트의 action과 data 프로퍼티 사용법, 액티비티의 상태 변화와 생명주기 콜백 함수, 액티비티 종료 시 데이터 저장 및 복원 방법을 설명합니다. 또한, ANR 문제 해결을 위해 코루틴을 사용하여 비동기 처리를 구현하는 방법을 제시하며, 코루틴의 장점과 스코프, 디스패처 설정 등을 다룹니다.
DriedPollack's avatar
Aug 14, 2024
[Do it! 깡샘의 안드로이드 앱 프로그래밍 with 코틀린] 액티비티 컴포넌트

🌼인텐트 이해하기

💡인텐트란?

  • 안드로이드 앱은 모두 4개의 컴포넌트로 개발하는데 이떄 핵심 클래스가 Intent다. 인텐트는 한마디로 컴포넌트를 실행하려고 시스템에 전달하는 메시지다.
    • 한 앱에 MainActivityDetailActivity가 있다고 가정해 보자. MainActivity가 실행되고 나서 DetailActivity로 화면을 전환한다면 DetailActivity 클래스의 객체를 생성해서 실행을 하면 될 것이라고 생각할 수 있다.
    • 하지만 DetailActivity가 안드로이드의 컴포넌트 클래스라면 개발자가 코드에서 직접 실행할 수 없다. 컴포넌트 클래스는 시스템이 생성해서 실행하는 클래스이므로 개발자가 작성하는 코드로 생명주기를 관리할 수 없다.
    • 결국 MainActivity 클래스에서 DetailActivity 클래스를 실행하려면 시스템에 인텐트를 전달해 줘야 한다. 그러면 시스템에서 인텐트의 정보를 분석해서 그에 맞는 컴포넌트를 실행해 준다.
    • 이러한 인텐트의 중재 역할은 같은 앱의 컴포넌트 뿐만 아니라 외부 앱의 컴포넌트와 연동할 때도 마찬가지다.
  • 액티비티는 매니페스트 파일에 등록해야 한다. 액티비티 클래스 하나당 <activity>태그 하나로 등록해야 하며, 이때 액티비티의 클래스 이름을 지정하는 name 속성은 생략할 수 없다.
    • // MainActivity와 DetailActivity 등록 <activity android:name=".DetailActivity" android:exported="true" /> <activity android:name=".MainActivity" android:exported="true" > <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity>
  • MainActivity에서 DetailActivity를 실행하고자 인텐트를 시스템에 전달하는 코드는 다음과 같다.
    • // 인텐트를 시스템에 전달 val intent: Intent = Intent(this, DetailActivity::class.java) startActivity(intent)

💡인텐트 엑스트라 데이터

  • 인텐트에 컴포넌트 실행을 요청할 때 데이터를 함께 전달하려면 엑스트라 데이터를 이용해야 한다. 엑스트라 데이터는 인텐트에 담는 부가 정보다.
  • MainActivity에서 인텐트에 엑스트라 데이터를 추가해서 전달하고 DetailActivity에서 인텐트에 담긴 엑스트라 데이터를 가져오는 구조다. 인텐트에 엑스트라 데이터를 추가하는 함수는 putExtra()다.
    • putExtra()의 첫 매개변수는 데이터의 식별자고 두 번째 매개변수는 전달할 데이터다.
      • // 엑스트라 데이터 추가 val intent: Intent = (this, DetailActivity::class.java) intent.putExtra("data1", "hello") intent.putExtra("data2", 10) startActivity(intent)
    • 인텐트로 실행된 컴포넌트에서 엑스트라 데이터를 가져오려면 인텐트 객체의 프로퍼티(intent)를 이용하면 된다.
      • // 엑스트라 데이터 가져오기 val data1 = intent.getStringExtra("data1") val data2 = intent.getIntExtra("data2", 0)

💡액티비티 화면 되돌리기 - ActivityResultLauncher

  • 액티비티는 화면을 구성하는 컴포넌트다. 화면을 전환했다가 다시 돌아왔을 때 사후 처리를 해야 할 수도, 하지 않을 수도 있다.
    • 예를 들어 카카오톡의 프로필 설정 화면에서 갤러리 앱의 목록을 띄운 후 다시 설정 화면으로 돌아올 때는 갤러리에서 사용자가 선택한 사진을 프로필 사진으로 등록해야 한다.
  • 사후 처리 여부에 따라 인텐트로 액티비티를 시작하는 방법은 다음 3가지가 있다.
    • public void startactivity(Intent intent) : 사후 처리가 필요 없을 때 사용
    • public void startActivityForResult(Intent intent, int requestCode) : 사후처리가 필요할 때 사용(권장되지 않음)
    • ActivityResultLauncher : 사후 처리가 필요할 때 사용(권장됨)
  • 다음은 액티비티에서 결과를 되돌리는 코드다. 사용자가 뒤로가기 버튼을 누르지 않고 자동으로 화면을 되돌릴 때는 finish() 함수를 이용한다.
    • // 결과와 화면 되돌리기 intent.putExtra("resultData","world") setResult(RESULT_OK, intent) finish
  • ActivityResultLauncher를 이용하려면 먼저 Contract객체가 필요하다.
    • ContractActivityResultLauncher로 실행될 요청을 처리하는 역할을 한다. 즉, ActivityResultLauncher로 인텐트를 발생시켜 액티비티를 실행할 때 실제 인텐트를 발생시키는 역할을 한다.
    • ContractActivityResultContract를 상속받은 서브 클래스다.
      • PickContact : 선택한 연락처의 Uri 획득
      • RequestPermission : 권한 요청, 허락 여부 파악
      • RequestMultiplePermissions : 여러 권한을 동시에 요청
      • StartActivityForResult : 인텐트 발생, 액티비티 실행 결과 획득
      • TakePicturePreview : 사진 촬영 후 비트맵 획득
      • TakePicture : 사진 촬영, 저장, 비트맵 획득
    • ActivityResultLauncherregisterForActivityResult() 함수로 만드는 객체이며 매개변수로 실제 작업자인 Contract 객체와 결과를 처리하는 Callback 객체를 등록받는다.
    • launchActivityResultLauncher의 함수이며 launch 함수를 호출하는 순간 ActivityResultLauncher에 등록된 Contract 객체가 실행된다.
      • // ActivityResultLauncher 생성 val requestLauncher: ActivityResultLauncher<Intent> = registerForActivityResult( ActivityResultContracts.StartActivityForResult()) // Contract // Callback { val resultData = it.data?getStringExtra("result") binding.mainResultView.text="result : $resultData" }
    • 이렇게 만든 ActivityResultLauncher 객체를 launch() 함수로 실행해 주면 된다.
      • // ActivityResultLauncher 실행 val intent: Intent = Intent(this, DetailActivity::class.java) requestLauncher.launch(intent)

💡인텐트 필터

  • 인텐트는 실행할 컴포넌트 정보를 어떻게 설정하는지에 따라 다음 2가지로 나뉜다.
    • 명시적 인텐트 : 클래스 타입 레퍼런스 정보를 활용한 인텐트
    • 암시적 인텐트 : 인텐트 필터 정보를 활용한 인텐트
  • 인텐트를 시스템에 전달할 때 클래스 타입 레퍼런스를 이용하는 것을 명시적 인텐트라고 하며 내부 앱의 컴포넌트를 요청하는 인텐트 객체를 만들 때 사용한다.
  • 그런데 외부 앱의 컴포넌트는 클래스 타입 레퍼런스를 활용할 수 없으므로 암시적 인텐트를 이용한다.
    • 암시적 인텐트는 매니페스트 파일에 선언된 인텐트 필터를 이용한다.
      • // 암시적 인텐트 <activity android:name=".OneActivity"/> <activity android:name=".TwoActivity" android:exported="true"> <intent-filter> <action android:name="ACTION_EDIT"/> <category android:name="android.intent.category.DEFAULT"/> </intent-filter> </activity>
    • 위 코드의 OneActivity처럼 매니페스트 파일에 어떤 액티비티를 등록할 때 name 속성만 지정하면 해당 액티비티는 명시적 인텐트로만 실행할 수 있다.
    • 안드로이드 시스템은 매니페스트 파일의 내용을 참조하여 앱을 실행한다. 그런데 개발자가 <activity android:name=”.OneActivity”/>처럼 액티비티의 클래스 이름만 설정하면 시스템에는 이 이름만 등록한다. 따라서 코드에서 인텐트에 클래스의 이름 정보를 지정해 줘야 등록된 정보와 일치하는지 비교해서 실행할 수 있다.
  • 따라서 앱 내부에서만 이용하는 컴포넌트라면 android:name 속성만 선언하면 된다. 그런데 어떤 컴포넌트를 외부에서도 인텐트로 실행할 수 있어야 한다면 해당 컴포넌트가 있는 앱의 메니페스트 파일에 암시적으로 실행할 수 있게 <intent-filter>를 설정해 줘야 한다.
    • <intent-filter><activity>, <service>, <receiver>등 컴포넌트 등록 태그 하위에 작성할 수 있다. <intent-filter> 태그는 컴포넌트를 암시적 인텐트로 실행할 때만 추가하며 반드시 사용할 필요는 없다.
  • 인텐트 필터 하위에는 <action>, <catagory>, <data> 태그를 이용해 정보를 설정할 수도 있다.
    • <action> : 컴포넌트의 기능을 나타내는 문자열이다.
    • <catagory> : 컴포넌트가 포함되는 범주를 나타내는 문자열이다.
    • <data> : 컴포넌트에 필요한 데이터 정보다.
    • // 외부 앱과 연동하는 인텐트 필터 설정(메인 액티비티 외) <activity android:name=".TwoActivity" android:exported="true"> <intent-filter> <action android:name="ACTION_EDIT"/> <category android:name="android.intent.category.DEFAULT"/> <data android:scheme="http"/> </intent-filter>> </activity>
  • 다음은 인텐트의 action과 data 프로퍼티에 실행 대상인 컴포넌트 정보를 지정하는 코드다.
    • // 인텐트의 프로퍼티를 이용하는 방법 val intent = Intent() intent.action = "ACTION_EDIT" intent.data = Uri.parse("http://www.google.com") startActivity(intent) // 인텐트의 생성자를 이용하는 방법 val intent = Intent("ACTION_EDIT", Uri.parse("http://www.google.com")) startActivity(intent)
  • 그런데 매니페스트 파일에는 <category> 태그의 name 속성값을 android.intent.catagory.DERAULT로 선언했는데 인텐트 정보에는 카테고리 정보를 설정하지 않았다. 이처럼 카테고리 정보를 전달하지 않으면 기본이 DEFAULT 값으로 지정된다.
    • 물론 매니페스트 파일에서 카테고리 정보에 DEFAULT가 아닌 다른 문자열을 지정했다면 인텐트에도 해당 문자열을 그대로 지정해야 한다.
    • // mimeType 설정 <activity android:name=".TwoActivity"> <intent-filter> <action android:name="ACTION_EDIT"/> <category android:name="android.intent.category.DEFAULT"/> <data android:mineTpe="image/*"/> </intent-filter> </activity> // 타입 정보 설정 val intent = Intent("ACTION_EDIT") intent.type = "image/*" startActivity(intent)

💡액티비티 인텐트 동작 방식

  • 만약에 인텐트로 실행할 액티비티가 시스템에 없거나 n개라면 다음과 같은 상황이 발생한다.
    • 없을 때 : 인텐트를 시작한 곳에 오류가 발생
    • 1개일 때 : 정상적으로 실행
    • n개일 때 : 사용자 선택으로 하나만 실행
      • // 해당 액티비티가 없을 때 예외 처리 val intent = Intent("ACTION_HELLO") try{ startActivity(intent) }catch(e:Exception){ Toast.makeText(this, "no app...", Toast.LENGTH_SHORT).show() } // 액티비티가 여러 개일때 val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:37.7749,12734194")) startActivity(intent)
  • 만약 액티비티가 여러 개 있더라도 사용자에게 묻지 않고 특정 앱의 액티비티를 실행하고 싶다면 해당 앱의 패키지명을 지정하면 된다.
    • // 패키지 지정하기 val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:37.7749,12734194")) intent.setPackage("com.google.android.apps.maps") startActivity(intent)
 

🌼액티비티 생명주기

💡액티비티의 상태

  • 생명주기란 액티비티가 생성되어 소멸하기까지의 과정을 말하며, Activity 클래스는 액티비티가 상태 변화를 알아차릴 수 있는 여러 가지 콜백 함수를 제공한다.
  • 액티비티의 상태는 다음 3가지로 구분할 수 있다.
    • 활성 : 액티비티 화면이 출력되고 있고 사용자가 이벤트를 발생시킬 수 있는 상태
    • 일시 정지 : 액티비티의 화면이 출력되고 있지만 사용자가 이벤트를 발생시킬 수 업슨ㄴ 상태
    • 비활성 : 액티비티의 화면이 출력되고 있지 않는 상태
    • notion image
  • 활성 상태에서 처름 실행된 액티비티는 onCreate() → onStart → onResume() 함수까지 호출된다. 그리고 setContentView() 함수로 출력한 내용이 액티비티 화면에 나온다.
  • 일시 정지 상태는 onPause() 함수까지 호출된 상태다. 일시 정지 상태의 액티비티가 다시 포커스를 업어 사용자 이벤트를 처리할 수 있으면 onResume() 함수가 자동으로 호출된다.
  • 비활성 상태의 예시는 홈버튼을 눌러서 화면에 보이던 액티비티가 보이지 않게 되는 경우가 있다. 비활성 상태가 되면 onPause() → onStop() 함수까지 호출된다. 그리고 다시 액티비티를 화면에 보이면 onRestart() → onStart() → onResume() 함수까지 호출되어 활성 상태가 된다.
  • 액티비티가 종료된다는 것은 onDestroy()까지 호출되었다는 의미다. 사용자가 뒤로가기 버튼을 눌러 액티비티를 벗어나거나 코드에서 finish() 함수를 호출하는 상황이다.

💡액티비티의 상태 저장

  • 일반적으로 액티비티가 종료되면 객체가 소멸하므로 액티비티의 데이터는 모두 사라진다. 그런데 액티비티가 종료될 때 유지해야 할 데이터를 저장했다가 다시 실행할 때 복원할 수 있다.
  • 액티비티 화면이 회전할 때 여러 생명주기 함수가 호출되는 순서를 생각해 보자.
    • onCreate() 부터 onResume() 까지 실행된 상태에서 화면을 회전하면 onPause() → onStop() → onSaveInstanceState() → onDestroy() 까지 호출되고 액티비티는 종료된다. 이때 액티비티에서 발생한 모든 데이터는 사라진다.
    • 그리고 다시 액티비티 객체가 자동으로 생성되어 onCreate() → onStart() → onRestoreInstanceState() → onResume() 까지 호출되면서 화면에 액티비티가 출력된다.
  • 만약 액티비티를 종료할 때 저장했다가 복원해야 할 데이터가 있다면 Bundle 객체에 담아 주면 된다. onCreate(), onSaveInstanceState(), onRestoreInstanceState() 함수는 매개변수를 가지며 모두 Bundle 객체다.
    • // 번틀 객체 사용 override fun onCreate(savedInstanceState: Bundle?){ super.onCreate(savedInstanceState) } override fun onRestoreInstanceState(savedInstanceState: Bundle?){ super.onRestoreInstanceState(savedInstanceState) } override fun onSaveInstanceState(savedInstanceState: Bundle?){ super.onSaveInstanceState(savedInstanceState) }
  • onSaveInstanceState()onStop() 함수 다음에 호출되므로 이 함수가 호출된다는 것은 액티비티가 종료된다는 의미다. 그러므로 개발자가 onSaveInstanceState() 함수의 매개변수로 전달되는 Bundle에 데이터를 담아 주면 자동으로 데이터를 파일로 저장해 준다.
    • // 번틀에 데이터 저장 override fun onSaveInstanceState(savedInstanceState: Bundle?){ super.onSaveInstanceState(savedInstanceState) outState.putString("data1","hello") outState.putInt("data2",10) }
  • 액티비티가 다시 생성되어 실행될 때 캐싱 파일이 있다면 그 내용을 읽어서 번들 객체에 담아 onCreate(), onRestoreInstanceState() 함수의 매개변수로 전달해 준다.
    • // 번틀에 저장된 데이터 가져오기 override fun onRestoreInstanceState(savedInstanceState: Bundle){ super.inRestoreInstanceState(savedInstanceState) val data1 = savedInstanceState.getString("data1") val data2 = savedInstanceState.getInt("data2") }
      💡
      화면을 회전해도 에디트 텍스트에 입력한 데이터는 내부에 저장하도록 개선되었으므로 에디트 텍스트에 입력한 글은 저장과 복원을 신경쓰지 않아도 된다.
 

🌼액티비티 ANR 문제와 코루틴

💡ANR 문제란?

  • ANR은 액티비티가 응답하지 않는 오류 상황을 의미한다.
    • 액티비티로 구성한 앱 화면은 사용자 이벤트에 빠르게 반응해야 한다. 그런데 액티비티가 사용자 이벤트에 5초 이내에 반응하지 않으면 ANR 오류가 발생한다.
    • 이처럼 액티비티에서 사용자 이벤트를 처리하지 못하는 이유는 애기비티를 실행한 시스템에서 발생한 수행 흐름에서 이벤트를 처리하기 때문이다. 즉, 시스템의 수행 흐름에서 시간이 오래 걸리는 작업이 끝나지 않으면 사요자 이벤트에 반응하지 못하는 것이다.
  • 시스템에서 액티비티를 실행하는 수행 흐름을 메인 스레드 또는 UI 스레드라고 한다.
  • 액티비티에서 시간이 오래 걸리는 대표적인 작업은 서버와 통신하는 네트워크다. 모바일에서는 네트워크가 불안정할 때가 많은데, 그런 상황에서 앱이 실행되면 네트워크에 접속을 시도하느라 시간이 오래 걸릴 수 있다.
💡
물론 앱은 대부분 네트워크 통신을 지원하는 전문 라이브러리(Volley 또는 Retrofit2)를 사용해 만들며, 이 라이브러리는 내부적으로 ANR 문제를 고려해 작성되므로 이를 이용할 경우 개발자가 ANR 문제를 고려하지 않아도 된다.
  • ANR 문제를 해결하는 방법은 액티비티를 실행한 메인 스레드 이외에 실행 흐름을 따로 만들어서 시간이 오래 걸리는 작업을 담당하게 하면 된다. 그러면 개발자가 만든 스레드가 시간이 오래 걸리는 작업을 수행 중이더라도 메인 스레드는 언제든지 이벤트를 처리할 수 있어서 ANR이 발생하지 않는다.
  • 하지만 이 방법으로 대터하면 ANR 오류는 해결되지만 화면을 변경할 수 없다는 문제가 생긴다. 왜냐하면 화면 변경은 개발자가 만든 스레드에서는 할 수 없고 액티비티를 출력한 메인 스레드에서만 할 수 있기 때문이다.
💡
시간이 오래 걸리는 작업을 서비스 컴포넌트로 작성할 수도 있다. 하지만 단순히 시간이 오래 걸린다고 무조건 서비스 컴포넌트로 작성하는 것은 좋지 않다. 서비스는 제약이 많아서 주의해야 할 컴포넌트이므로, 아무리 시간이 오래 걸린다고 해도 해당 작업이 대부분 화면과 관련이 있다면 액티비티에서 담당하는 것이 맞다.

💡코루틴으로 ANR 오류 해결

  • 코루틴이란 비동기 경량 스레드라고 요약할 수 있다. 코틀린에서는 1.3버전부터 공식으로 지원한다. 코루틴은 수행 흐름을 여러 갈래로 만들어 여러 작업을 함께 처리하는데, 결국 비동기 처리 방식과 같다.
  • 일반적으로 비동기 처리라면 스레드를 생각하기 쉬운데, 스레드는 성능에 문제가 많고 자유롭게 제어할 수 없거나 구현하기도 복잡하다. 따라서 요즘은 스레드를 이용하지 않고 RX 프로그래밍이나 코루틴으로 비동기 처리를 구현한다.
  • 코루틴의 장점은 다음과 같다.
    • 경량이다.
    • 메모리 누수가 적다.
    • 취소 등 다양한 기능을 지원한다.
    • 많은 제트팩 라이브러리에 적용되어 있다.
  • 안드로이드 앱에서 코루틴을 사용하려면 빌드 그래들 파일의 dependencies 항목에 다음처럼 등록해야 한다.
    • // 코루틴 등록 implementation("org.jetbrains.kotlin:kotlinx-coroutines-android:1.7.3")
  • 시간이 오래 걸리는 작업을 가정해서 코루틴을 알아보자. 화면에서 버튼을 클릭하면 작업이 수행되고, 결괏값이 텍스트 뷰에 출력되는 예시다.
    • // 시간이 오래 걸리는 작업 예 var sum = 0L var time = measureTimeMillis{ for(i in 1..2_000_000_000){ sum += i } } Log.d("kkang","time: $time") binding.resultView.text = "sum: $sum"
  • 이를 해결하고자 스레드-핸들러 구조(API 레벨 30에서 deprecated됨)로 다시 작성한다.
    • // 스레드-핸들러 구조로 작성한 소스 val handler = object: Handler(){ override fun handleMessage(msg: Message){ super.handleMessage(msg) binding.resultView.text = "sum : ${msg.arg1}" } } thread{ var sum = 0L var time = measureTimeMillis{ for(i in 1..2_000_000_000){ sum += i } val message = Message() mesage.arg1 = sum.toInt() handler.sendMessage(message) } Log.d("kkang","time: $time") }
  • 해당 코드를 코루틴으로 변경해보자.
    • 코루틴을 구동하려면 먼저 스코프를 준비해야 한다. 그리고 스코프에서 코루틴을 구동한다.
    • 스코프는 성격이 같은 코루틴을 묶는 개념으로 이해하면 된다. 한 스코프에 여러 코루틴을 구동할 수 있으며 한 애플리케이션에 여러 스코프를 만들 수도 있다.
    • 코루틴 스코프는 CoroutineScope를 구현한 클래스의 객체이며 직접 구현할 수도 있고 GlobalScope, ActorScope, ProducerScope 등 코틀린 언어가 제공하는 스코프를 이용할 수도 있다.
    • // 코루틴으로 작성한 소스 val channel = Channel<Int>() // 백그라운드에서 동작(시간이 오래 걸리는 작업) val backgroundScope = CoroutineScope(Dispatchers.Default + Job()) backgroundScope.launch{ var sum = 0L var time = measureTimeMillis{ for(i in 1..2_000_000_000){ sum += i } } Log.d("kkang","time: $time") channel.send(sum.toInt()) } // 메인 스레드에서 동작(화면에 결괏값 표시) val mainScope = GlobalScope.launch(Dispatchers.Main){ channel.consumeEach{ binding.resultView.text = "sum: $it" } }
    • 위 코드에서 주목할 점은 스코프를 만들면서 지정한 디스패처다. 디스패처는 이 스코프에서 구동한 코루틴이 어디에서 동작해야 하는지를 나타낸다.
      • Dispatchers.Main : 액티비티의 메인 스레드에서 동작하는 코루틴을 만든다.
      • Dispatchers.IO : 파일에 읽거나 쓰기 또는 네트워크 작업 등에 최적화되어있다.
      • Dispatchers.Default : CPU를 많이 사용하는 작업을 백그라운등서 실행한다.
    • Channel은 큐 알고리즘과 비슷하며 send() 함수로 데이터를 전달하면 그 데이터를 받는 코루틴에서는 receive()consumeEach() 등의 함수로 데이터를 전달받는다.
 

🏁결론

안드로이드 앱 프로그래밍에서 액티비티 컴포넌트와 인텐트, 액티비티 생명주기, ANR 문제와 코루틴을 정리했다. 인텐트 필터 설정, 인텐트의 action과 data 프로퍼티 사용법, 액티비티의 상태 변화와 생명주기 콜백 함수, 액티비티 종료 시 데이터 저장 및 복원 방법을 설명합니다. 또한, ANR 문제 해결을 위해 코루틴을 사용하여 비동기 처리를 구현하는 방법을 제시하며, 코루틴의 장점과 스코프, 디스패처 설정 등을 다룹니다.
Share article

More articles

See more posts

👨🏻‍💻DriedPollack's Blog