1. 인텐트 이해하기
인텐트란
안드로이드 앱은 모두 4개의 컴포넌트로 개발하는데 이 때 핵심 클래스가 바로 Intent입니다. 인텐트는 한마디로 '컴포넌트를 실행하려고 시스템에 전달하는 메시지'라고 정의할 수 있습니다. 즉, 기능을 수행하는 함수를 제공하는 클래스가 아니라 데이터를 담는 클래스입니다. 이 데이터는 컴포넌트를 실행하는 정보이며 이 정보가 담긴 인텐트 객체를 시스템에 전달하면 컴포넌트가 실행됩니다.
한 앱에 MainActivity와 DetailActivity가 있다고 가정해봅시다. MainActivity가 실행되고 나서 DetailActivity로 화면을 전환한다면 DetailActivity 클래스의 객체를 생성해서 실행하면 될 것 같습니다. 하지만 DetailActivity가 안드로이드의 컴포넌트 클래스라면 개발자가 코드에서 직접 생성해서 실행할 수 없습니다. 컴포넌트 클래스는 시스템이 생성해서 실행하는 클래스이므로 개발자가 작성하는 코드로 생명주기를 관리할 수 없습니다.
결국 MainActivity 클래스에서 DetailActivity 클래스를 실행하려면 시스템에 인텐트를 전달해줘야 합니다. 그러면 시스템에서 인텐트의 정보를 분석해서 그에 맞는 컴포넌트를 실행해 줍니다.
이러한 인텐트의 중재 역할은 같은 앱의 컴포넌트뿐만 아니라 외부 앱의 컴포넌트와 연동할 때도 마찬가지입니다. 다음 그림에서 A 앱의 컴포넌트에서 인텐트를 시스템에 전달하고 그 정보를 분석하여 B앱의 컴포넌트를 실행할 수 있습니다.
액티비티는 메니페스트 파일에 등록해야 합니다. 액티비티 클래스 하나당 <activity> 태그 하나로 등록해야 하며, 이때 액티비티의 클래스 이름을 지정하는 name 속성은 생략할 수 없습니다.
<activity android:name=".DetailActivity"></activity>
<activity android:name=".MainActivity">
<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)
위 코드에서 startActivity() 함수가 인텐트를 시스템에 전달(시작)합니다.
인텐트 엑스트라 데이터
인텐트에 컴포넌트 실행을 요청할 때 데이터를 함께 전달하려면 엑스트라 데이터(extra data)를 이용해야 합니다. 엑스트라 데이터는 인텐트에 담는 부가 정보라 할 수 있습니다.
MailActivity에서 인텐트에 엑스트라 데이터를 추가해서 전달하고 DetailActivity에서 인텐트에 담긴 엑스트라 데이터를 꺼내쓰는 구조입니다. 인텐트에 엑스트라 데이터를 추가하는 함수는 putExtra() 입니다.
val intent: Intent = Intent(this, DetailActivity::class.java)
intent.putExtra("data1", "hello")
intent.putExtra("data2", 10)
startActivity(intent)
인텐트로 실행된 컴포넌트에서 엑스트라 데이터를 가져오려면 먼저 getIntent() 함수로 자신을 실행한 인텐트 객체를 얻어야 합니다. 그리고 인텐트 객체의 getIntExtra() 함수로 데이터를 가져오면 됩니다. 데이터를 가져오는 함수 또한 타입별로 여러 개 제공합니다.
val intent = intent
val data1 = intent.getStringExtra("data1")
val data2 = intent.getIntExtra("data2", 0)
액티비티 화면 되돌리기
액티비티는 화면을 구성하는 컴포넌트입니다. 따라서 한 액티비티에서 다른 액티비티를 인텐트로 실행하면 화면이 전환됩니다. 이때 고려할 사항이 있습니다. 어떤 액티비티가 다른 액티비티를 실행해 화면이 전환되었을 떄 의도에 따라 화면을 되돌리거나 되돌리지 않을 수도 있습니다.
startActivity() 함수는 화면을 되돌릴 필요가 없을 때 사용하며, startActivityForResult() 함수는 결과를 포함해 화면을 되돌릴 때 사용합니다.
startActivityForResult(intent, 10)
액티비티를 startActivityForResult() 함수로 시작할 때 두 번째 매개변수는 개발자가 정하는 요청 코드(requestCode)이며 인텐트를 식별하는 값입니다. 이 값은 startActivityForResult() 함수로 결과를 돌려받은 후 별도로 처리할 때 필요합니다.
다음 코드는 startActivityForResult() 함수로 시작한 액티비티에서 결과를 되돌리는 코드입니다. 사용자가 뒤로가기 버튼을 누르지 않고 자동으로 화면을 되돌릴 때는 finish() 함수를 이용합니다. finish() 함수는 현재 화면에 보이는 액티비티를 종료해 달라고 시스템에 요청합니다.
intent.putExtra("resultData", "world")
setResult(RESULT_OK, intent)
finish()
이렇게 하면 시스템은 이전 액티비티로 화면을 되돌립니다. finish() 함수를 호출하기 전에 결과 데이터를 인텐트 객체에 담을 수 있으며 이때 자신을 실행한 인텐트 객체에 엑스트라 데이터로 담으면 됩니다.
또한 setResult() 함수로는 결과를 어떻게 되돌릴지 지정할 수 있습니다. 요청을 제대로 처리했으면 RESULT_OK, 아니면 RESULT_CANCELED 등 상수를 지정합니다. 이 값을 결과 코드라고 합니다.
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if(requestCode == 10 && resultCode == Activity.RESULT_OK) {
val result = data?.getStringExtra("resultData")
}
}
위 코드는 처음 인텐트를 시작한 액티비티에 작성되었습니다. 결과가 되돌아와서 다시 자신이 화면에 출력되면 onActivityResult() 함수가 자동으로 호출됩니다.
- requestCode : 인텐트를 시작한 곳에서 인텐트를 구분하려고 설정한 요청 코드입니다.
- resultCode : 인텐트로 실행된 곳에서 돌려받은 결과 코드입니다.
- data : 인텐트 객체입니다. 이 객체에 결과 데이터가 있습니다.
인텐트 필터
인텐트는 실행할 컴포넌트 정보를 어떻게 설정하는지에 따라 다음과 같이 2가지로 나뉩니다.
- 명시적 인텐트 : 클래스 타입 레퍼런스 정로를 활용한 인텐트
- 암시적 인텐트 : 인텐트 필터 정보를 활용한 인텐트
앞의 코드처럼 클래스 타입 레퍼런스를 이용하는 것을 명시적 인텐트라고 하며 내부 앱의 컴포넌트를 요청하는 인텐트 객체를 만들 때 사용합니다. 그런데 외부 앱의 컴포넌트는 클래스 타입 레퍼런스를 활용할 수 없으므로 암시적 인텐트를 이용합니다.
암시적 인텐트는 매니페스트 파일에 선언된 인텐트 필터를 이용합니다.
<activity android:name=".OneActivity" />
<activity android:name=".TwoActivity">
<intent-filter>
<action android:name="ACTION_EDIT" />
</intent-filter>
</activity>
위 코드에서 OneActivity 처럼 매니페스트 파일에 어떤 액티비티를 등록할 때 name 속성만 지정하면 해당 액티비티는 명시적 인텐트로만 실행할 수 있습니다.
안드로이드 시스템은 매니페스트 파일의 내용을 참조하여 앱을 실행합니다. 그런데 개발자가 <activity android:name=".OneActivity" /> 처럼 액티비티의 클래스 이름만 설정하면 시스템에는 이 이름만 등록됩니다. 따라서 코드에서 인텐트에 클래스의 이름 정보(class type reference)를 지정해 줘야 등록된 정보와 일치하는지 비교해서 실행할 수 있습니다.
그러므로 앱 내부에서만 이용하는 컴포넌트라면 android:name 속성만 선언하면 됩니다. 그런데 어떤 컴포넌트를 외부에서도 인텐트로 실행할 수 있어야 한다면 해당 컴포넌트가 있는 앱의 매니페스트 파일에 암시적 인텐트로 실행할 수 있게 <intent-filter>를 설정해 줘야 합니다.
<intent-filter>는 <activity>, <service>, <receiver> 등 컴포넌트 등록 태그 하위에 작성할 수 있습니다. <intent-filter> 태그는 컴포넌트를 암시적 인텐트로 실행할 때만 추가하며 반드시 사용할 필요는 없습니다. 인텐트 필터를 추가하면 해당 컴포넌트의 클래스명과 인텐트 필터 정보가 시스템에 등록됩니다. 그리고 어디선가 이 컴포넌트를 실행하려면 시스템에 등록된 정보에 맞게 인텐트를 설정합니다.
만약 외부 앱과 연동하지 않는다면 굳이 암시적 인텐트를 이용할 필요가 있나요?
그렇긴 하지만 대부분 앱이 외부 앱과 연동합니다. 예를 들어 한 회사에서 여러 앱을 만든다면 같은 회사의 앱이라도 다른 앱이므로 화면이나 데이터를 공유할 때 암시적 인텐트로 앱을 연동해야 합니다. 그리고 구글이 제공하는 기본 앱과 연동하기 위해서라도 암시적 인텐트는 중요합니다. 주소록, 갤러리, 카메라, 전화 등 기본 앱을 연동하지 않고 앱을 만들기는 어렵습니다. 암시적 인텐트는 안드로이드 앱을 개발하면서 많이 사용할 수 밖에 없습니다.
인텐트 필터 하위에는 <action>, <category>, <data> 태그를 이용해 정보를 설정할 수 있습니다. 어떤 정보를 설정할 것인지는 개발자의 선택입니다.<action> 만 선언할 수도 있고 <data>를 함께 선언할 수도 있습니다. 각 태그에 설정하는 값은 다음과 같은 의미입니다.
- <action> : 컴포넌트 기능을 나타내는 문자열입니다.
- <category> : 컴포넌트가 포함되는 범주를 나타내는 문자열입니다.
- <data> : 컴포넌트에 필요한 데이터 정보입니다.
<action> 태그의 android:name 에 설정하는 값은 개발자가 임의로 지정하는 문자열이며 앱에서 유일하지 않아도 됩니다. 그런데 컴포넌트의 기능을 나타낼 것을 권장하고 있습니다. 예를 들어 컴포넌트가 데이터를 보여주는 기능을 한다는 의미로 액션 문자열을 android.intent.action.VIEW라고 선언할 수 있으며 데이터를 편집하는 기능을 한다는 의미로 android.intent.action.EDIT 라고 선언할 수 있습니다.
<category> 태그의 android:name 에 설정하는 문자열은 컴포넌트가 어느 범주에 포함되어야 하는지를 의미합니다. 개발자가 임의로 지정할 수도 있지만 대부분 플랫폼 API에서 제공하는 문자열을 이용합니다. 예를 들어 android.intent.category.LAUNCHER는 런처가 실행하는 컴포넌트라는 의미이고, android.intent.category.BROWSABLE은 브라우저가 실행하는 컴포넌트라는 의미입니다.
<data> 태그는 컴포넌트에서 어떤 성격의 데이터를 처리하는지를 나타냅니다.<data> 는 URL 형식으로 표현하며 <action> 이나 <category> 처럼 문자열 하나로 선언하지 않고 android:scheme, android:host, android:port, android:mimeType 등의 속성을 이용합니다. 이러한 속성을 모두 선언할 필요는 없으며 필요한 만큼 선언하면 됩니다. scheme 속성은 URL의 프로토콜명으로, 데이터 성격을 한정 지을 때 사용합니다. host는 URL의 도메인이며 port는 URL의 포트입니다. 그리고 mimeType은 데이터 타입으로 text/plain 이나 image/* 등을 선언할 수 있습니다.
인텐트 필터의 대표적인 예는 모듈을 만들 때 자동으로 만들어지는 MainActivity 입니다. 매니페스트 파일에 MainActivity 는 다음처럼 등록되어 있습니다.
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
MainActivity는 사용자가 런처 화면에서 앱의 아이콘 이미지를 터치했을 때 실행되는 액티비티입니다. 그런데 앱의 아이콘이 나열된 런처 화면은 사실 런처 앱의 액티비티입니다. 따라서 사용자가 아이콘을 터치해 앱을 실행하면 외부 앱(런처)에서 실행하는 것이므로 매니페스트 파일에 인텐트 필터를 등록해 암시적으로 실행되게 해야 합니다.
런처 앱은 시스템에서 LAUNCHER 범주의 정보를 가져와 앱 아이콘을 나열합니다. 따라서 앱의 첫 화면을 나타내는 액티비티에는 <action> 태그의 name 속성값을 android.intent.action.MAIN 으로 선언하고 <category> 태그의 name 속성값은 android.intent.category.LAUNCHER 로 선언한 인텐트 필터를 설정해야 합니다.
앱의 첫 화면인 MainActivity의 인텐트 필터로 설명했지만 다른 컴포넌트도 외부 앱과 연동하려면 인텐트 필터를 등록해야 합니다. 만약 TwoActivity를 다음처럼 등록했다면 인텐트 필터에 선언된 정보에 맞춰 액티비티를 실행해야 합니다.
<activity android:name=".TwoActivity">
<intent-filter>
<action android:name="ACTION_EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:name="http" />
</intent-filter>
</activity>
다음은 인텐트의 action과 data 프로퍼티에 실행 대상인 컴포넌트 정보를 지정하는 코드입니다. 이때 컴포넌트 정보는 매니페스트에 선언한 정보를 이용합니다. data 값은 URL 문자열인데 안드로이드에서 URL 문자열은 Uri 객체로 표현합니다.
val intent = Intent()
intent.action = "ACTION_EDIT"
intent.data = Uri.parse("http://www.google.com")
startActivity(intent)
앞의 코드는 action, data 정보를 인텐트의 각 프로퍼티에 설정한 것이며 다음처럼 생성자의 매개변수로 지정해도 됩니다.
val intent = Intent("ACTION_EDIT", Uri.parse("http://www.google.com"))
startActivity(intent)
그런데 매니페스트 파일에는 <category> 태그의 name 속성값을 android.intent.category.DAFAULT로 선언했는데 인텐트 정보에는 카테고리 정보를 설정하지 않았습니다. 이처럼 인테트에 카테고리 정보를 전달하지 않으면 기본이 DEFAULT값으로 지정됩니다. 물론 매니페스트 파일에서 카테고리 정보에 DEFAULT가 아닌 다른 문자열을 지정했다면 인텐트에도 해당 문자열을 그대로 지정해야 합니다.
다음은 <data> 태그의 mimeType 속성값을 설정한 예입니다.
<activity android:name=".TwoActivity">
<intent-filter>
<action android:name="ACTION_EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
이렇게 설정하면 이를 실행하는 인텐트에도 타입 정보를 설정해 줘야 합니다.
val intent = Intent("ACTION_EDIT")
intent.type = "image/*"
startActivity(intent)
액티비티 인텐트 동작 방식
만약에 인텐트로 실행할 액티비티가 시스템에 없다면 어떻게 될까요? 또는 실행할 액티비티가 1개 이상이라면 어떻게 될까요? 명시적 인텐트는 클래스 타입 레퍼런스 정보를 이용하므로 액티비티가 없거나 여러 개일 수 없습니다. 그런데 암시적 인텐트는 실행할 액티비티를 <action>, <category>, <data> 등의 문자열 정보로 나타내므로 없거나 여러 개일 수 있습니다. 다음은 실행할 액티비티가 없을 때, 1개일 때, 여러 개일 때 시스템이 어떻게 처리하는지를 나타냅니다.
- 없을 때 : 인텐트를 시작한 곳에 오류가 발생합니다.
- 1개일 때 : 문제없이 실행합니다.
- n개일 떄 : 사용자 선택으로 하나만 실행합니다.
다음 코드는 인텐트로 실행한 액티비티의 정보를 잘못 지정한 예입니다. 이 정보로 실행할 액티비티가 시스템에 없는 경우입니다.
val intent = Intent("ACTION_HELLO")
startActivity(intent)
이처럼 실행할 액티비티가 없으면 다음과 같은 오류가 발생합니다.
android.content.ActivityNotFoundException: No Activity found to handle Intent {act=ACTION_HELLO}
따라서 인텐트로 실행할 액티비티가 없을 수도 있는 상황을 고려해서 다음처럼 예외 처리를 해줘야 합니다.
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,127.4194"))
startActivity(intent)
그런데 사용자의 폰에 위와 같은 정보로 등록된 지도 앱이 여러 개 설치되었다면 연결 앱 선택 다이얼로그가 실행됩니다. 인텐트에 설정한 정보로 실행할 액티비티가 여러 개이면 다이얼로그를 띄워 사용자에게 어느 앱의 액티비티를 실행할 것인지를 묻고 선택한 액티비티를 실행합니다.
만약 액티비티가 여러 개 있더라도 사용자에게 묻지 않고 특정 앱의 액티비티를 실행하고 싶다면 해당 앱의 패키지명을 지정하면 됩니다.
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:37.7749,127.4194"))
intent.setPackage("com.google.android.apps.maps")
startActivity(intent)
패키지 공개 상태
안드로이드 11(API 레벨 30) 버전부터는 앱의 패키지 공개 상태를 지정하지 않으면 외부 앱의 패키지 정보에 접근할 수 없게 되었습니다. 이 내요은 지금까지 살펴봤던 인텐트를 이용해 외부 앱과 연동하는 부분에는 영향이 없으며, 외부 앱을 연동하더라도 패키지 정보를 활용하지 않는다면 아무런 문제가 없습니다. 다만 다음과 같은 함수를 사용할 때는 패키지 공개 상태에 따라 영향을 받습니다.
- PackageManager.getPackageInfo()
- PackageManager.queryIntentActivities()
- Intent.resolveActivity()
- PackageManager.getInstalledPackages()
- PackageManager.getInstalledApplications()
- bindService()
bindService()를 제외하고는 모두 외부 앱의 정보에 접근할 때 사용하는 함수입니다. 외부 앱의 정보를 가져오거나 폰에 설치된 모든 앱의 정보를 얻거나 인텐트로 실행할 컴포넌트가 있는지 판단할 때 사용합니다. bindService()는 서비스 컴포넌트와 관련된 함수이고 외부 앱의 서비스를 연동할 때 패키지 공개 상태의 영향을 받습니다.
다음은 PackageManager의 getPackageInfo() 함수를 이용해 매개변수에 지정한 패키지 문자열로 식별되는 앱의 정보를 가져오는 코드입니다.
val packageInfo = packageManager.getPackageInfo("com.example.test_outter", 0)
val versionName = packageInfo.versionName
이 코드는 안드로이드 10 버전까지 별문제 없이 실행되었지만 11버전부터는 다음과 같은 오류가 발생합니다.
Caused by: android.content.pm.PackageManager$NameNotFoundException: com.example.test_outter
이 오류는 안드로이드 11버전 이상이 설치된 폰에 적용된 패키지 공개 상태 필터링 때문에 발생합니다. 오류 없이 정상으로 실행하려면 매니페스트 파일에 외부 앱의 정보에 접근하겠다고 선언해야합니다.
<manifest ... 생략 ...>
<queries>
<package android:name="com.example.test_outter" />
</queries>
... 생략 ...
</manifest>
<queries> 태그 하위에 <package> 태그를 이용해 접근하고자 하는 앱의 패키지명을 선언하고 실행하면 오류 없이 앱의 정보에 접근할 수 있습니다. 만약 여러 앱에 접근해야 할 때는 <package> 태그를 여러 번 선언하면 되는데 <queries> 태그를 사용하지 않고 모든 외부 앱의 정보에 접근할 수 있도록 허용해달라는 의미로 다음처럼 <uses-permission>을 선언할 수도 있습니다. 하지만 될 수 있으면 <queries> 태그를 이용할 것은 권장합니다.
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
2. 액티비티 생명주기
액티비티의 상태
액티비티를 다룰 때 중요한 점은 생명주기를 이해하고 각 상황에 적절하게 대처하는 것입니다. 여기서 생명주기(Life Cycle)란 액티비티가 생성되어 소멸하기까지의 과정을 말하며, Activity 클래스는 액티비티가 상태 변화를 알아차릴 수 있는 여러 가지 콜백 함수를 제공합니다. 이 콜백 함수에 액티비티의 상태가 바뀔 때마다 앱이 어떻게 동작해야 하는지를 구현합니다.
액티비티의 상태는 다음처럼 크게 3가지로 구분할 수 있습니다.
- 활성 : 액티비티 화면이 출력되고 있고 사용자가 이벤트를 발생시킬 수 있는 상태
- 일시 정지 : 액티비티의 화면이 출력되고 있지만 사용자가 이벤트를 발생시킬 수 없는 상태
- 비활성 : 액티비티의 화면이 출력되고 있지 않은 상태
활성 상태
활성 상태는 액티비티가 실행되어 화면에 나오고 사용자 이벤트를 처리할 수 있는 상태를 의미합니다. 즉, 액티비티가 포커스를 가지는 상태를 의미하며 버튼을 클릭하거나 에디트 텍스트에 글을 입력할 수 있는 상태입니다.
처음 실행된 액티비티는 onCreate() → onStart() → onResume() 함수까지 호출됩니다. 그리고 setContentView() 함수로 출력한 내용이 액티비티 화면에 나옵니다. 이것은 setContentView() 함수를 onCreate()가 아니라 onStart()나 onResume() 함수에서 호출해도 화면이 출력된다는 의미입니다. 물론 onCreate() 함수는 최초에 한 번만 호출되고 onStart()나 onResume() 함수는 반복해서 호출할 수 있으므로, 일반적으로 setContentView()는 onCreate() 함수에서 많이 호출합니다.
일시 정지 상태
일시 정지 상태는 onPause() 함수까지 호출된 상태입니다. 일반적으로 액티비티가 화면에 보이지만 포커스를 잃어 사용자 이벤트를 처리할 수 없는 상태를 의미합니다. 일시 정지 상태의 가장 대표적인 예는 화면 분할 모드에서 확인할 수 있습니다.
갤러리 앱의 액티비티가 전체 화면에 나오고 사용자 이벤트를 처리할 수 있는 상황이면 onResume() 함수까지 실행된 활성 상태입니다. 그런데 사용자가 화면 분할 모드로 전환해서 위쪽에는 갤러리 앱이, 아래쪽에는 카카오톡 앱이 나오게 하면 갤러리 앱의 액티비티는 onPause() 함수까지 실행되어 일시 정지 상태가 됩니다. 화면에 보이기는 하지만 새로 실행한 카카오톡 앱이 포커스를 가지므로 갤러리 앱은 포커스를 잃습니다.
(PC 에서 창 두개를 띄운 상태에서 한쪽 창은 포커스를 잃고 다른쪽 창은 포커스를 얻은 상황과 유사)
물론 이 상태에서 사용자가 갤러리 앱의 화면을 터치하면 다시 포커스를 가져와 onResume() 함수가 호출되고 다시 활성 상태로 바뀝니다. 이처럼 일시 정지 상태의 액티비티가 다시 포커스를 얻어 사용자 이벤트를 처리할 수 있으면 onResume() 함수가 자동으로 호출됩니다.
참고로 액티비티에서 다이얼로그를 띄운 상태도 일시 정지로 오해할 수 있는데 그건 아닙니다. 액티비티에서 다이얼로그를 출력하면 그 다이얼로그도 액티비티의 뷰입니다. 결국 액티비티의 뷰에 이벤트가 발생하는 상황이므로 일시 정지 상태는 아닌 것입니다.
비활성 상태
비활성 상태란 액티비티가 종료되지 않고 화면에만 보이지 않는 상태를 말합니다. 인텐트로 다른 액티비티를 실행했거나 홈 버튼을 눌러 런처 화면으로 이동해 액티비티가 보이지 않는 상황입니다. 안드로이드 폰의 홈 버튼은 런처 앱의 액티비티를 실행합니다. 홈 버튼을 눌렀을 떄 화면에 보이던 액티비티가 종료되지는 않고 단지 비활성 상태가 되는 것뿐입니다.
활성 상태에서 비활성 상태가 되면 onPause() → onStop() 함수까지 호출됩니다. 그리고 다시 액티비티를 화면에 보이면 onRestart() → onStart() → onResume() 함수까지 호출되어 활성 상태가 됩니다.
액티비티의 상태 저장
일반적으로 액티비티가 종료되면 객체가 소멸하므로 액티비티의 데이터는 모두 사라집니다. 그리고 다시 그 액티비티를 실행하면 초기 상태로 나옵니다. 그런데 액티비티가 종료될 때 유지해야할 데이터를 저장했다가 다시 실행할 때 복원할 수 있습니다.
액티비티가 종료되는 상황으로는 코드에서 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(outState: bundle) {
super.onSaveInstanceState(outState)
}
onSaveInstanceState()는 onStop() 함수 다음에 호출되므로 이 함수가 호출된다는 것은 액티비티가 종료된다는 의미입니다. 그러므로 개발자가 onSaveInstanceState() 함수의 매개변수로 전달되는 Bundle에 데이터를 담아 주면 자동으로 데이터를 파일로 저장해 줍니다.
override fun onSaveInstanceState(outState: bundle) {
super.onSaveInstanceState(outState)
outState.putString("data1", "hello")
outState.putInt("data2", 10)
}
번들에 데이터를 저장하려면 putString(), putInt() 등의 함수를 이용합니다. 그러면 자체 캐싱 파일에 데이터를 저장해줍니다. 그리고 다시 액티비티가 생성되어 실행될 때 캐싱 파일이 있다면 그 내용을 읽어서 번들 객체에 담아 onCreate(), onRestoreInstanceState() 함수의 매개변수로 전달해 줍니다.
override fun onRestoreInstanceState(savedInstanceState: bundle) {
super.onRestoreInstanceState(outState)
val data1 = savedInstanceState.getString("data1")
val data2 = savedInstanceState.getInt("data2")
}
번들 객체에서 데이터를 가져오려면 getString(), getInt() 등의 함수를 이용합니다. 이처럼 onCreate(), onSavedInstanceState(), onRestoreInstanceState() 함수에 전달하는 번들 객체에 데이터를 담았다가 가져오면 액티비티가 종료되었다가 다시 실행될 때 액티비티의 데이터를 계속 유지할 수 있습니다.
화면을 회전해도 에디트 텍스트에 입력했던 글은 그대로 남아 있던데요?
화면을 회전하면 액티비티가 종료되었다가 다시 생성되므로 모든 데이터가 초기화됩니다. 그런데 에디트 텍스트에 입력한 글은 화면을 회전해도 사라지지 않습니다. 원래는 에디트 텍스트도 입력한 데이터가 사라졌습니다. 그런데 에디트 텍스트가 개선되면서 사용자가 입력한 글을 내부에도 저장합니다. 따라서 에디트 텍스트에 입력한 글은 저장과 복원을 신경 쓰지 않아도 됩니다.
3. 액티비티 제어
방향과 전체 화면 설정하기
액티비티의 방향을 고정하고 싶다면 매니페스트 파일의 <activity> 태그의 screenOrientation 속성을 이용합니다. 값은 landscape나 portrait를 지정해 주면 됩니다. landscape는 가로로 고정하고 portrait는 세로로 고정합니다.
<activity android:name=".SettingActivity" android:screenOrientation="landscape">
액티비티를 전체 화면으로 표시한다는 것은 액티비티의 콘텐츠 영역을 화면 전체에 나오게 한다는 의미입니다. 액티비티를 전체 화면으로 출력하고 싶으면 우선 액티비티 창의 액션바가 출력되지 않게 설정해야 합니다. 액티비티에 적용되는 테마를 NoActionBar 테마 등으로 지정하여 액션바가 나오지 않게 하면 됩니다.
<style name="Theme.AndroidLab" parent="Theme.MaterialComponents.DayNight.NoActionBar">
그다음 액티비티 코드에서 전체 화면으로 출력되게 설정합니다. 그런데 이 부분은 API 레벨 30에서 변경되었습니다. 즉, API 레벨 29까지는 window.setFlags() 함수를 이용해 전체 화면을 지정했는데 이때 값을 이용되는 FLAG_FULLSCREEN 등의 상수가 모두 deprecated 되었습니다. API 레벨 30부터는 WindowInsetsController 라는 클래스의 함수를 이용해 액티비티 창을 설정합니다. 물론 WindowInsetsController를 사용할 때는 아래 코드처럼 호환성을 고려해서 작성해야 합니다.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.setDecorFitsSystemWindows(false)
val controller = window.insetsController
if (controller != null) {
controller.hide(WindowInsets.Type.statusBars() or
WindowInsets.Type.navigationBars())
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN)
}
4. 태스크 관리
태스크 관리란 액티비티를 어떻게 생성하고 관리하는지를 제어하는 일을 의미합니다. 시스템에는 액티비티의 태스크를 유지하는 기본 규칙이 있으며 일반적으로는 이 기본 규칙을 그대로 이용하므로 개발자가 태스크를 제어할 일은 많지 않습니다. 그런데 특정한 상황에서 개발자가 액티비티의 태스크를 관리해야 한다면 설정을 추가할 수도 있습니다.
시스템에서 태스크 관리
액티비티 태스크란 앱이 실행될 때 시스템에서 액티비티의 각종 정보를 저장하는 공간이라고 이해하면 됩니다.
그림처럼 A_OneActivity와 A_TwoActivity로 구성된 A라는 앱이 있다고 가정하겠습니다. 사용자가 앱을 실행해 A_OneActivity 화면이 나오고 다시 A_OneActivity에서 인텐트로 A_TwoActivity를 실행했다고 생각해 봅시다. 그러면 액티비티 객체가 2개 생성되고 시스템은 이 액티비티가 실행되었다는 정보를 저장하려고 태스크를 만듭니다.
아래 그림은 A_OneActivity와 A_TwoActivity의 정보가 태스트게 올라간 모습입니다. 태스트에서 위쪽에 있는 액티비티인 A_TwoActivity가 화면에 출력됩니다. 이 상태에서 사용자가 홈 버튼을 눌러 런처 화면으로 나갔다가 앱을 다시 실행하면 저장된 태스크 정보가 그대로 적용되어 화면에는 태스크 위쪽에 있는 A_TwoActivity 액티비티가 나옵니다.
그런데 사용자가 기기의 뒤로가기 버튼을 누르면 이 태스크에서 위쪽에 있는 액티비티를 종료하고 다시 태스크 위쪽에 있는 액티비티를 화면에 출력합니다. 따라서 화면에는 A_OneActivity가 출력됩니다.
그렇다면 실행하는 앱마다 태스크는 하나만 존재할까요? 그렇지는 않습니다. 이번에는 앱과 앱이 연동되어 실행되는 구조를 알아보겠습니다.
그림처럼 사용자가 A앱을 실행해 A_OneActivity → A_TwoActivity → B_TwoActivity 를 차례로 실행했다고 생각해 봅시다. 사용자는 앱을 하나만 실행했지만 실제로는 2개 실행한 상태입니다. 이때 액티비티의 실행 정보를 담는 태스크는 하나만 만들어지니다.
B_TwoActivity는 B 앱의 액티비티지만 A 앱의 태스크에 등록됩니다. 여기서 태스크는 사용자 관점에서 프로그램의 논리적인 실행단위라고 정리할 수 있습니다. 시스템 내부에서는 앱이 2개 실행되었지만 사용자 관점에서는 앱을 하나만 실행해 화면이 3개 나온 것입니다.
태스크 제어
시스템에서 태스크를 관리하는 방법을 이해했다면 이제 개발자가 원하는 대로 액티비티 객체가 생성되고 태스크에 등록되도록 제어하는 방법을 살펴보겠습니다. 2가지 방법을 소개하겠습니다.
- 액티비티가 등록되는 매니페스트 파일의 <activity> 태그의 launchMode를 이용합니다.
- 인텐트의 flags 정보를 설정하여 제어합니다.
매니페스트 파일에서는 다음처럼 <activity> 태그의 launchMode 속성으로 실행 모드를 설정해 주면 됩니다.
<activity android:name=".TwoActivity" android:launchMode="singleTop">
반면에 코드에서 인텐트를 발생시키기 전에 인텐트의 flags 속성에 설정합니다.
val intent = Intent(this, TwoActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
startActivity(intent)
두 방법 모두 같은데 <activity> 태그의 launchMode 속성을 이용하면 이 액티비티는 인텐트에 의해 항상 설정한 대로 생성되어 태스크에 등록되고, 인텐트의 flags 속성을 이용하면 이번 인텐트가 발생할 때에 한 번만 적용되어 태스크에 등록됩니다.
그러면 launchMode에 설정할 수 있는 속성값을 알아봅시다.
스탠더드로 설정
실행 모드를 standard로 설정하면 설정하지 않은 기본값과 같습니다. 즉, 액티비티에서 인텐트가 발생하면 항상 객체가 생성되고 태스크에 등록됩니다. 아래 그림은 실행 모드를 standard로 설정한 A_TwoActivity가 인텐트로 자신을 다시 실행한 예입니다. 이렇게 하면 A_TwoActivity 객체는 2개가 생성되고 태스크에 액티비티 정보 2개가 등록됩니다.
스탠더드로 동작하는 예로는 카카오톡 알림 채팅 화면(A_TwoActivity)을 보고 있는 상태에서 푸시 알림 메시지를 터치하여 다른 채팅 화면(A_TwoActivity)으로 이동하는 경우를 생각해볼 수 있습니다.
싱글 톱으로 설정
실행 모드를 singleTop으로 설정하면 액티비티 정보가 태스크의 위쪽에 있을 떄 인텐트가 발생해도 객체를 생성하지 않습니다. 원래 액티비티는 인텐트만 발생하면 무조건 객체가 생성되어야 하지만 singleTop 실행 모드로 설정한 액티비티가 태스크 위쪽에 있으면 어디선가 그 액티비티를 실행하는 인텐트가 발생해도 객체가 생성되지 않습니다.
다음은 A_TwoActivity를 singleTop으로 설정한 예입니다.
태스크에서 A_TwoActivity가 위쪽에 있는 상황에서 어디선가 다시 인텐트로 A_TwoActivity를 실행해도 객체가 생성되지 않습니다. 그 대신 기존 객체의 onNewIntent() 함수가 자동으로 호출됩니다.
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
}
위 함수를 액티비티에 재정의해 놓으면 singleTop으로 설정한 액티비티 객체가 태스크의 위쪽에 있을 때 인텐트로 자신을 다시 실행하면 자동으로 호출됩니다.
그렇다면 singleTop으로 설정한 액티비티 객체가 태스크 위쪽이 아닌 곳에 있을 때는 어떻게 동작할까요? 다음 그림은 A_TwoActivity를 singleTop 으로 설정한 예입니다. 이미 객체가 생성되어 태스크에 등록되었지만 단지 태스크 위쪽이 아닌 상황입니다.
이 상황에서 액티비티를 실행하는 인텐트가 발생하면 객체는 다시 생성됩니다. 결국 singleTop은 태스크의 위쪽에 있을 때에만 객체를 생성하지 않습니다.
싱글탑으로 동작하는 예로는 카카오톡 알림 채팅 화면(A_TwoActivity)을 보고 있는 상태에서 푸시 알림 메시지를 터치하여 다른 채팅 화면(A_TwoActivity)을 보여주려는 경우 새로운 액티비티를 생성하는 것이 아니라 기본 액티비티에서 onNewIntent() 함수를 이용하여 대화 내용만 교체하는 경우를 생각해볼 수 있습니다.
싱글 태스크로 설정
실행 모드를 singleTask로 설정하면 새로운 태스크를 만들어 등록합니다. 그런데 singleTask 설정은 같은 앱에서는 적용되지 않으며 다른 앱의 액티비티를 인텐트로 실행할 때에만 적용됩니다. 따라서 사용자가 새로운 앱을 실행하지 않더라도 하나의 태스크를 다시 만들 때 사용합니다. 기기에 따라 다르지만 태스크가 바뀌면 화면 전환 애니메이션 효과가 나타날 수 있으며, 결국 새로운 앱이 실행되는 것을 사용자에게 알리고 싶을 때 사용합니다.
위 그림 순서로 실행하면 원래 하나의 태스크에 액티비티 정보가 4개 등록되어야 하는데 singleTask로 설정했으므로 B_OneActivity부터는 새 태스크를 만들어 정보를 등록합니다.
싱글 인스턴스로 설정
실행 모드를 singleInstance로 설정하면 싱글 태스크처럼 새로운 태스크를 만들어 등록하는데, 그 태스크에는 해당 설정이 적용된 액티비티 하나만 등록됩니다. 예를 들어 위와 같은 순서로 실행되는 상황에서 B_OneActivity를 singleInstance로 설정하면 다음과 같습니다.
singleTask와 마찬가지로 새로운 태스크를 만들어 등록하지만, 이후에 실행되는 액티비티는 또 다른 태스크에 등록되어 결국 B_OneActivity 혼자 태스크 하나를 차지합니다.
5. 액티비티 ANR 문제와 코루틴
ANR(Activity Not Response) 은 액티비티가 응답하지 않는 오류 상황을 의미합니다. 액티비티를 작성할 때 ANR을 고려하지 않으면 앱이 수시로 종료될 수 있습니다.
액티비티로 구성한 앱 화면은 사용자 이벤트에 빠르게 반응해야 합니다. 그런데 액티비티가 사용자 이벤트에 5초 이내에 반응하지 않으면 ANR 오류가 발생합니다. 이처럼 액티비티에서 사용자 이벤트를 처리하지 못하는 이유는 액티비티를 실행한 시스템에서 발생한 수행 흐름에서 이벤트를 처리하기 때문입니다. 즉, 시스템의 수행 흐름에서 시간이 오래 걸리는 작업이 끝나지 않으면 사용자 이벤트에 반응하지 못하는 거죠.
시스템에서 액티비티를 실행하는 수행 흐름을 메인 스레드 또는 화면을 출력하는 수행 흐름이라는 의미에서 UI 스레드라고 합니다.
메인 스레드가 오래 걸리는 작업을 실행한다고 해서 그 자체로 오류가 발생하지는 않습니다. 아무리 오래 걸려도 사용자가 액티비티 화면을 터치하지 않는 등 이벤트가 없다면 오류가 발생하지 않습니다. 그러나 사용자가 언제 화면을 터치할지 모릅니다. 따라서 액티비티를 작성할 때는 항상 ANR 오류를 고려해야 합니다.
ANR 문제를 해결하는 방법은 액티비티를 실행한 메인 스레드 이외에 실행 흐름(개발자 스레드)을 따로 만들어서 시간이 오래 걸리는 작업을 담당하게 하면 됩니다. 그러면 개발자가 만든 스레드가 시간이 오래 걸리는 작업을 수행 중이더라도 메인 스레드는 언제든지 이벤트를 처리할 수 있어서 ANR이 발생하지 않습니다.
그런데 이 방법으로 대처하면 ANR 오류는 해결되지만 화면을 변경할 수 없다는 또 다른 문제가 생깁니다. 왜냐하면 화면 변경은 개발자가 만든 스레드에서 할 수 없고 액티비티를 출력한 메인 스레드에서만 할 수 있기 때문입니다.
코루틴으로 ANR 오류 해결
액티비티의 ANR 오류를 해결하는 동시에 개발자가 만든 수행 흐름에서 화면을 변경할 수 없다는 것을 고려한다면 코틀린 언어가 제공하는 코루틴(coroutine)이라는 기능을 이용할 수 있습니다. 코루틴을 제대로 이해하려면 책 한 권을 읽어야 할 정도로 내용이 많습니다. 그러나 여기서는 ANR 문제를 해결하는 방법으로 코루틴을 소개하므로 기초 내용만 다루겠습니다.
코루틴은 한마디로 비동기 경량 스레드(non-blocking lightweight thread) 라고 요약할 수 있습니다. 코루틴은 안드로이드 시스템이 아니라 프로그래밍 언어에서 제공하는 기능입니다.
coroutine에서 co는 '함께'를, routine은 작업의 처리 단위를 뜻합니다. 즉, 코루틴은 어떤 작업을 함께 처리한다는 의미입니다. 프로그램이 단일 흐름으로 실행되면 작업이 차례대로 이뤄질 뿐 함께 처리되지는 않습니다. 이와는 달리 코루틴은 수행 흐름을 여러 갈래로 만들어 여러 작업을 함께 처리합니다. 결국 비동기 처리 방식과 같습니다.
일반적으로 비동기 처리라면 스레드를 생각하기 쉬운데 스레드는 성능에 문제가 많고 자유롭게 제어할 수 없거나 구현하기도 복잡합니다. 따라서 요즘은 스레드를 이용하지 않고 RX 프로그래밍이나 코루틴으로 비동기 처리를 구현합니다. 코루틴은 스레드보다 가벼우면서 더 많은 기능을 제공합니다.
안드로이드 앱에서 코루틴을 사용하려면 그레들 파일 dependencies 항목에 아래 내용처럼 등록해야 합니다.
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
시간이 오래 걸리는 작업을 가정해서 코루틴을 알아보겠습니다.
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"
1부터 20억까지 숫자를 더하는 단순 작업입니다. 테스트 기기에서 위 코드가 실행되어 결과가 나오기까지 약 6~8 초 정도 걸렸으며 클을 입력하려고 화면을 터치하니 ANR 오류가 발생했습니다.
ANR 오류를 해결하조가 스레드-핸들러 구조로 다시 작성해보겠습니다.
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()
message.arg1 = sum.toInt()
handler.sendMessage(message)
}
Log.d("kkang", "time : $time")
}
사용자가 버튼을 클릭했을 떄 실행되는 코드이며, 시간이 오래 걸리는 작업을 개발자 스레드로 작성했으므로 ANR 문제는 발생하지 않습니다. 연산 작업이 실행되는 동안에도 사용자가 화면의 에디트 텍스트에 글을 입력할 수 있습니다.
그런데 개발자 스레드에서 UI는 변경할 수 없으므로 텍스트 뷰에 결과를 직접 출력하지 않고 메인 스레드에 의뢰하여 처리했습니다. 개발자 스레드에서 sendMessage() 함수를 호출하면서 매개변수로 데이터를 넘기면 그 순간 메인 스레드가 handleMessage() 함수를 자동으로 호출합니다.
이제 이 코드를 코루틴으로 작성해 보겠습니다. 코루틴으로 구동하려면 먼저 스코프(scope)를 준비해야 합니다. 그리고 스코프에서 코루틴을 구동합니다. 스코프는 성격이 같은 코루틴을 묶는 개념으로 이해하면 됩니다. 한 스코프에 여러 코루틴을 구동할 수 있으며 한 애플리케이션에 여러 스코프를 만들 수도 있습니다. 결국 스코프는 성격이 같은 여러 코루틴이 동작하는 공간으로 이해할 수 있습니다.
코루틴 스코프는 CoroutineScope를 구현한 클래스의 객체이며 직접 구현할 수도 있고 Global Scope, 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"
}
}
위 코드에서는 backgroundScope와 mainScope를 만들었습니다. 여기서 주목할 점은 스코프를 만들면서 지정한 디스페처입니다. 디스패처는 이 스코프에서 구동한 코루틴이 어디에서 동작해야 하는지를 나타냅니다.
- Dispatchers.Main : 액티비티의 메인 스레드에서 동작하는 코루틴을 만듭니다.
- Dispatchers.IO : 파일에 읽어나 쓰기 또는 네트워크 작업 등에 최적화되었습니다.
- Dispatchers.Default : CPU를 많이 사용하는 작업을 백그라운드에서 실행합니다.
Dispatchers.Main 으로 만든 스코프에서 실행한 코루틴은 메인 스레드에서 동작합니다. 따라서 UI를 변경할 수 있습니다. 하지만 메인 스레드는 사용자 이벤트를 처리하는 곳이므로 이 코루틴에는 빨리 끝나는 작업을 맡기는 것이 좋습니다.
위 코드에서는 시간이 오래 걸리는 작업을 Dispatchers.Default로 지정한 스코프에서 구동한 코루틴이 처리하고, 그 결과를 Dispatchers.Main으로 지정한 스코프의 코루틴에서 화면에 출력하도록 작성했습니다.
또한 위 예를 보면 Channel을 이용했는데 이 클래스는 코루틴의 값을 전달받을 수 있는 방법을 제공합니다. Channel은 큐알고리즘과 비슷하며 Channel의 send() 함수로 데이터를 전달하면 그 데이터를 받는 코루틴에서는 receive()나 consumeEach() 등의 함수로 데이터를 받습니다.
6. 할 일 목록 앱 만들기
1. 모듈 그레들 파일 수정
android {
...
buildFeatures {
viewBinding = true
}
}
2. /res/drawable 이미지 추가
3. 코틀린 파일 추가
<MainActivity.kt>
package com.example.myapplication13
import android.content.Intent
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.myapplication13.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding
var datas: MutableList<String>? = null
lateinit var adapter: MyAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding= ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
//add................................
val requestLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()){
it.data!!.getStringExtra("result")?.let {
datas?.add(it)
adapter.notifyDataSetChanged()
}
}
binding.mainFab.setOnClickListener {
val intent = Intent(this, AddActivity::class.java)
requestLauncher.launch(intent)
}
datas = savedInstanceState?.let {
it.getStringArrayList("datas")?.toMutableList()
} ?: let {
mutableListOf<String>()
}
val layoutManager = LinearLayoutManager(this)
binding.mainRecyclerView.layoutManager=layoutManager
adapter=MyAdapter(datas)
binding.mainRecyclerView.adapter=adapter
binding.mainRecyclerView.addItemDecoration(
DividerItemDecoration(this, LinearLayoutManager.VERTICAL)
)
}
//add...............................
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putStringArrayList("datas", ArrayList(datas))
}
}
<AddActivity.kt>
package com.example.myapplication13
import android.app.Activity
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import com.example.myapplication13.databinding.ActivityAddBinding
class AddActivity : AppCompatActivity() {
lateinit var binding: ActivityAddBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding= ActivityAddBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.menu_add, menu)
return super.onCreateOptionsMenu(menu)
}
//add............................
override fun onOptionsItemSelected(item: MenuItem): Boolean = when(item.itemId){
R.id.menu_add_save -> {
val intent = intent
intent.putExtra("result", binding.addEditView.text.toString())
setResult(Activity.RESULT_OK, intent)
finish()
true
}
else -> true
}
}
<MyAdapter.kt>
package com.example.myapplication13
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.myapplication13.databinding.ItemRecyclerviewBinding
class MyViewHolder(val binding: ItemRecyclerviewBinding): RecyclerView.ViewHolder(binding.root)
class MyAdapter(val datas: MutableList<String>?): RecyclerView.Adapter<RecyclerView.ViewHolder>(){
override fun getItemCount(): Int{
return datas?.size ?: 0
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder
= MyViewHolder(ItemRecyclerviewBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val binding=(holder as MyViewHolder).binding
binding.itemData.text= datas!![position]
}
}
4. 레이아웃 파일 추가
<activity_main.xml>
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/main_recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/main_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="30dp"
app:icon="@android:drawable/ic_input_add"
android:text="Add Todo"
android:layout_alignParentRight="true"
android:layout_alignParentBottom="true"/>
</RelativeLayout>
<activity_add.xml>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:elevation="4dp"
app:theme="@style/ToolbarIconColor"
app:titleTextColor="#FFFFFF"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Todo 등록"
android:textSize="15dp"
android:textColor="@android:color/darker_gray"/>
<EditText
android:id="@+id/add_editView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"/>
</LinearLayout>
<item_recyclerview.xml>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:id="@+id/item_root"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:layout_margin="8dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/todo"/>
<TextView
android:id="@+id/item_data"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize="16dp"
android:layout_marginLeft="24dp"
/>
</LinearLayout>
5. 메뉴 파일 추가
<menu_add.xml>
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/menu_add_save"
android:title="저장"
app:showAsAction="always"/>
</menu>
6. 테마 파일 수정
<themes.xml>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.MyFirstApplication" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
<style name="Theme.MyFirstApplication" parent="Base.Theme.MyFirstApplication" />
<style name="ToolbarIconColor" parent="ThemeOverlay.AppCompat.ActionBar">
<item name="colorControlNormal">#FFFFFF</item>
</style>
</resources>
7. 매니페스트 파일 수정
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MyFirstApplication">
<activity
android:name=".AddActivity"
android:exported="false" />
<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>
</application>
</manifest>
8. 앱 실행 및 테스트
'Android' 카테고리의 다른 글
[깡쌤의 안드로이드 프로그래밍 with 코틀린][5. 컴포넌트 이해하기] 15. 서비스 컴포넌트 (0) | 2024.12.25 |
---|---|
+[깡쌤의 안드로이드 프로그래밍 with 코틀린][5. 컴포넌트 이해하기] 14. 브로드캐스트 리시버 컴포넌트 (0) | 2024.12.25 |
[깡쌤의 안드로이드 프로그래밍 with 코틀린][4. 구글의 라이브러리로 화면 구성하기] 12. 머티리얼 라이브러리 (0) | 2024.12.25 |
[깡쌤의 안드로이드 프로그래밍 with 코틀린][4. 구글의 라이브러리로 화면 구성하기] 11. 제트팩 라이브러리 (0) | 2024.12.25 |
[깡쌤의 안드로이드 프로그래밍 with 코틀린][3. 앱의 기본 기능 구현하기] 10. 다이얼로그와 알림 이용하기 (0) | 2024.12.25 |