1. 서비스 이해하기
서비스는 오래 걸리는 작업을 백그라운드에서 처리할 수 있게 해주는 컴포넌트입니다. 따라서 서비스에 화면을 구현하지는 않습니다. 그리고 서비스 역시 안드로이드의 컴포넌트이므로 생명주기를 시스템에서 관리합니다.
서비스 생성과 실행
서비스 컴포넌트는 Service 클래스를 상속받아서 작성합니다. 서비스에는 다양한 생명주기 함수를 재정의할 수 있지만 onBind()는 필수적입니다.
class MyService : Service() {
override fun onBind(intent: Intent): IBinder? {
return null
}
}
서비스도 컴포넌트이므로 매니페스트에 등록해야 합니다.
<service
android:name=".MyService"
android:enabled="true"
android:exported="true"></service>
서비스를 실행하려면 시스템에 인텐트를 전달해야 하는데 이때 사용하는 함수는 startService()와 bindService() 2가지입니다. 두 함수에는 어떤 차이가 있는지 살펴보겠습니다.
startService() 함수로 실행
startService() 함수로 서비스를 실행하려면 해당 서비스를 인텐트에 담아서 매개변수로 전달해야합니다.
val intent = Intent(this, MyService::class.java)
startService(intent)
만약 외부 앱의 서비스라면 암시적 인텐트로 실행해야 하므로 setPackage() 함수를 이용해 앱의 패키지명을 명시해 줍니다. 그런데 외부 앱이 백그라운드 상태라면 서비스를 실행할 수 없습니다. 이는 백그라운드 제약에서 알아보겠습니다.
val intent = Intent("ACTION_OUTER_SERVICE")
intent.setPackage("com.example.test_outter")
startService(intent)
서비스는 다른 컴포넌트와 다르게 실행 중인 서비스를 종료하는 함수도 제공합니다. 서비스를 종료하려면 stopService() 함수로 인텐트를 전달해야 합니다.
val intent = Intent(this, MyService::class.java)
stopService(intent)
bindService() 함수로 실행
서비스를 실행하는 또 다른 함수는 bindService()입니다. 이 함수로 서비스를 실행하려면 먼저 ServiceConnection 인터페이스를 구현한 객체를 준비해야 합니다.
val connection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { }
override fun onServiceDisconnected(name: ComponentName?) { }
}
이렇게 ServiceConnection 을 구현한 객체를 준비한 후 bindService() 함수로 인텐트를 시스템에 전달해 서비스를 실행합니다. 만약 bindService()로 실행할 서비스가 외부 앱의 것이라면 setPackage() 함수로 패키지명을 명시해야 합니다.
val intent = Intent(this, MyService::class.java)
bindService(intent, connection, Context.BIND_AUTO_CREATE)
세 번째 매개변수는 Int 타입의 flags인데 이 값은 대부분 Context.BIND_AUTO_CREATE로 지정합니다. 이는 서비스가 실행 상태가 아니더라도 객체를 생성해서 실행하라는 의미입니다. 만약 Context.BIND_AUTO_CREATE를 지정하지 않으면 bindService() 함수로 인텐트를 전달해도 서비스가 실행 상태가 아니면 동작하지 않습니다.
bindService() 함수로 실행한 서비스는 unbindService() 함수로 종료할 수 있습니다.
unbindService(connection)
서비스 생명주기
서비스를 실행하는 2가지 방법은 startService()와 bindService()이므로 어느 함수를 이용해 서비스를 실행하는지에 따라 생명주기가 나뉩니다. 다음 그림에서 왼쪽은 startService()와 stopService() 함수에 해당하는 생명주기이며, 오른쪽은 bindService()와 unbindService() 함수에 해당하는 생명주기입니다.
startService() 함수에서 서비스 객체를 생성하면 onCreate() → onStartCommand() 함수가 호출되고 서비스가 실행됩니다. 이 상태에서 다시 startService() 함수를 호출하면 서비스 객체가 다시 생성되지 않고 onStartCommand() 함수만 다시 호출됩니다. 결국 onCreate() 함수는 서비스 객체가 생성될 떄 처음에 한 번만 호출되며, onStartCommand() 함수는 startService() 함수가 실행될 때마다 반복해서 호출됩니다. 그리고 stopService() 함수로 서비스가 종료되면 바로 전에 onDestroy() 함수가 호출됩니다.
bindService() 함수에서 서비스 객체를 생성하면 onCreate → onBind() 함수가 호출되고 서비스가 실행됩니다. bindService() 함수로 실행된 서비스를 다시 bindService() 함수로 실행하면 onBind() 함수만 다시 호출됩니다. 그리고 unbindService() 함수로 서비스를 종료하면 onUnbind() → onDestory() 함수까지 실행됩니다.
2. 바인딩 서비스
IBinder 객체 바인딩
앞에서 살펴본 것처럼 서비스를 실행하는 함수를 2개 제공하는 이유는 서비스를 이용하는 상황을 2가지로 구분하기 위해서입니다. 예를 들어서 액티비티에서 startService() 함수로 서비스를 실행했다고 가정해봅시다. 이 경우에는 백그라운드 작업은 진행하지만 액티비티와 데이터를 주고받지는 않습니다. 그런데 어떤 경우에는 서비스와 액티비티가 상호작용 해야 할 때가 있습니다. bindService()는 이러한 목적으로 호출하는 함수입니다.
bindService() 함수 이름에서 "bind"는 서비스가 실행되면서 자신을 실행한 곳에 객체를 바인딩합니다. 즉, 객체를 전달한다는 의미입니다. 예를 들어 액티비티에서 bindService() 함수로 서비스를 실행하면 서비스에서 넘어온 객체를 가지고 있다가 이 객체의 함수를 호출하여 데이터를 전달합니다.
서비스 코드
bindService() 함수로 서비스를 실행하면 생명주기 함수에서 onBind()가 실행되는데, 이 함수에는 반환 타입이 선언되어 있습니다.
class MyBinder : Binder() {
fun funA(arg: Int) {
}
fun funB(arg: Int): Int {
return arg * arg
}
}
override fun onBind(intent: Intent): IBinder? {
return MyBinder()
}
즉, onBind() 함수가 호출되면 서비스를 실행한 곳에 IBinder 인터페이스를 구현한 객체를 전달합니다. 그러면 서비스를 실행한 곳에서 이 클래스의 함수를 호출하면서 매개변수와 반환값으로 데이터를 주고 받습니다.
액티비티 코드
서비스를 bindService() 함수로 실행한 곳에서는 서비스의 onBind() 함수에서 반환한 객체를 ServiceConnection 인터페이스를 구현한 객체의 onServiceConnected() 함수로 받을 수 있습니다.
val connection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
serviceBinder = service as MyService.MyBinder
}
override fun onServiceDisconnected(name: ComponentName?) {
}
}
onServiceConnected() 함수의 두 번째 매개변수가 서비스에서 전달한 객체입니다. 이렇게 서비스의 객체를 전달받은 후 필요한 순간에 이 객체의 함수를 호출하면서 매개변수나 반환값으로 데이터를 주고받습니다.
serviceBinder.funA(10)
메신저 바인딩
bindService() 함수로 서비스를 실행한 곳에는 앞에서 살펴본 IBinder를 구현한 객체를 바인딩합니다. 그런데 API에서 제공하는 Messenger 객체를 바인딩하는 방법도 있습니다.
Messenger 객체를 이용하는 방법은 프로세스간 통신(inter-process communication, IPC)할 때도 사용할 수 있습니다. 안드로이드에서 프로세스 간 통신하는 방법에는 AIDL도 있지만 메신저를 이용하면 코드를 더 간단하게 작성할 수 있습니다. AIDL은 메신저를 이용하는 방법 먼저 알아보고 그 다음에 살펴보겠습니다.
서비스 코드
class MyService : Service() {
lateinit var messenger: Messenger
internal class IncomingHandler(
context: Context,
private val applicationContext: context = context.applicationContext
) : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
when (msg.what) {
10 ->
Toast.makeText(applicationContext, "${msg.obj}",
Toast.LENGTH_SHORT).show()
20 ->
Toast.makeText(applicationContext, "${msg.obj}",
Toast.LENGTH_SHORT).show()
else -> super.handleMessage(msg)
}
}
}
override fun onBind(intent: Intent): IBinder? {
messenger = Messenger(IncomingHandler(this))
return messenger.binder
}
}
IncomingHandler는 Handler 클래스를 상속받아 작성합니다. IncomingHandler 클래스에 재정의한 handleMessage() 함수는 외부에서 서비스에 데이터를 전달할 때 자동으로 호출됩니다. 이때 외부에서 전달한 데이터는 Message 타입으로, IncomingHandler() 함수의 매개변수로 받습니다. 전달받은 Message의 what값으로는 어떤 성격의 데이터인지를 구분하며 obj 속성으로는 전달된 데이터를 가져옵니다.
바인드 서비스에서 Messenger를 이용하려면 onBind() 함수의 반환값으로 Messenger 객체를 생성하면서 생성자 매개변수로 Handler를 구현한 객체를 지정합니다. 그리고 Messenger 객체의 binder 속성을 onBind() 함수의 결괏값으로 반환해 줍니다.
액티비티 코드
다음은 Messenger 객체를 이용하는 액티비티 쪽 코드입니다.
class MainActivity : AppCompatActivity() {
lateinit var messenger: Messenger
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
val intent = Intent(this, MyService::class.java)
bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
...
val connection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
messenger = Messenger(service)
}
override fun onServiceDisconnected(name: ComponentName?) {
}
}
}
Messenger를 이용하는 액티비티 코드는 IBinder를 전달받는 코드와 큰 차이가 없습니다. bindService() 함수로 서비스를 실행하면 되고, 서비스에서 넘어온 객체는 onServiceConnected() 함수의 매개변수로 받습니다. 단지 서비스로부터 넘어온 객체를 Messenger의 생성자 매개변수에 지정만 해주면 됩니다.
이렇게 서비스를 실행한 후 서비스에 데이터를 전달하고 싶을 때는 다음처럼 Messenger의 send() 함수를 호출합니다.
val msg = Message()
msg.what = 10
msg.obj = "hello"
messenger.send(msg)
액티비티에서 send() 함수를 호출하는 순간 서비스의 handleMessage() 함수가 자동으로 호출됩니다.
외부 앱 연동
Messenger 방식으로 외부 앱의 서비스를 bindService() 함수로 실행하려면 먼저 서비스를 등록한 매니페스트에 외부 앱을 연동할 수 있게끔 <intent-filter>가 선언되어 있어야 합니다.
<service
android:name=".MyService"
android:exported="true">
<intent-filter>
<action android:name="ACTION_OUTER_SERVICE" />
</intent-filter>
</service>
그리고 이 서비스를 bindService() 함수로 실행하는 앱에서는 외부 앱에 접근할 수 있도록 매니페스트에 다음처럼 선언합니다. <package> 태그의 name 속성값에는 연동하고자 하는 앱의 패키지명을 지정합니다.
<manifest ... >
...
<queries>
<package android:name="com.example.test_outter" />
</queries>
</manifest>
그리고 내부 앱의 서비스를 bindService() 함수로 실행할 때는 필요가 없지만, 외부 앱을 연동하고자 한다면 bindService() 함수로 발생하는 인텐트에 실행 대상인 앱의 패키지명을 명시해야 합니다.
val intent = Intent("ACTION_OUTER_SERVICE")
intent.setPackage("com.example.test_outter")
bindService(intent, connection, Context.BIND_AUTO_CREATE)
그리고 같은 앱에서 데이터를 주고받을 때 사용한 것처럼 문자열 데이터 Message의 obj 속성을 설정하여 외부 앱과 주고받으면 아래와 같은 오류가 발생합니다.
java.lang.RuntimeException: Can't marchal non-Parcelable objects across processers.
프로세스 간 통신에서는 주고받는 데이터는 Parcelable 이나 Bundle 타입이어야 합니다. 따라서 다음처럼 데이터를 Bundle에 담고 다시 Message 객체에 담아서 전달합니다.
val bundle = Bundle()
bundle.putString("data1", "hello")
bundle.putInt("data2", 10)
val msg = Message()
msg.what = 10
msg.obj = bundle
messenger.send(msg)
AIDL 통신 기법
AIDL(Android interface definition language)은 두 프로세스 사이에 데이터를 주고받는 프로세스 간 통신을 구현할 때 사용하는 기법으로, 서비스 컴포넌트의 bindService() 함수를 이용합니다. 안드로이드에서는 기본적으로 하나의 프로세스에서 다른 프로세스의 메모리에 접근할 수 없습니다. 따라서 데이터를 시스템에 전달한 후에 시스템이 다른 프로세스에 전달해 줘야 합니다.
그런데 시스템에 전달하는 데이터는 시스템이 해석할 수 있는 원시 타입으로 변환해야 하고 전송하는 데 적합한 형식으로 변환하는 마샬링(marshaling) 과정을 거쳐야 합니다. 그런데 AIDL을 이용하면 이러한 작업을 대신 처리해 주므로 편리합니다.
프로세스 간 통신은 앞에서 살펴본 메신저를 이용하면 AIDL보다 더 쉽게 구현할 수 있습니다. 하지만 메신저를 이용하는 방법은 플랫폼에서 제공하는 API를 이용해야 하므로 주고받는 데이터의 종류가 많을 때는 효율이 떨어질 수 있습니다. 또한 메신저는 모든 외부 요청을 싱글 스레드에서 처리하지만 AIDL은 여러 요청이 들어오면 멀티 스레드 환경에서 동시에 실행합니다. 그리고 메신저도 내부적으로는 AIDL 기법을 이용합니다.
서비스를 제공하는 앱
프로세스 간 통신에서 먼저 서비스 컴포넌트를 제공하는 앱을 살펴보겠습니다. 이 앱은 외부 앱과 AIDL로 데이터를 주고받으면서 작업을 처리합니다. AIDL을 이용하려면 우선 확장자가 aidl인 파일을 만들어야 합니다.
모듈의 aidl 디렉터리에 확장자가 '*.aidl'인 파일을 만들었습니다. 이 파일은 확장자가 aidl일 뿐 실제로는 자바로 작성한 인터페이스입니다.
package com.example.test_aidl;
interface MyAIDLInterface {
void funA(String data);
int funB();
}
이 함수는 외부 앱에서 AIDL 방식으로 처리하는 작업을 의뢰할 때 호출합니다. 함수를 호출할 때 매개변수와 반환값으로 데이터를 외부 앱과 주고받습니다.
AIDL 파일에는 외부와 통신하는 데 필요한 함수만 정의되어 있습니다. 따라서 어디선가 이 함수의 구체적인 로직을 구현해야 하는데 그 역할을 서비스 컴포넌트가 합니다.
class MyAIDLService : Service() {
override fun onBind(intent: Intent): IBinder {
return object : MyAIDLInterface.Stub() {
override fun funA(data: String?) {
}
override fun funB(): Int {
return 10
}
}
}
}
AIDL 파일에 선언된 함수를 구현해 실제 작업을 처리하는 내용을 작성하는 곳은 서비스입니다. AIDL은 바인드 서비스를 이용하므로 onBind() 함수에서 서비스를 인텐트로 실행한 곳에 객체를 전달해 줘야 합니다. 이때 AIDL 파일은 구현한 객체가 아니라 프로세스 간 통신을 대행해 주는 Stub을 전달합니다. 즉, 실제 로직이 구현된 객체가 아니라 프로세스 간 통신을 대행해 주는 객체를 전달합니다. 이 Stub 객체는 개발자가 직접 만들지 않고 MyAIDLInterface.Stub() 처럼 AIDL 파일명 뒤에 Stub()라고 선언만 해주면 됩니다.
AIDL을 구현한 서비스는 외부 앱과 연동하는 것이 목적이므로 매니페스트에 암시적 인텐트로 실행되게끔 <intent-filter>를 추가합니다.
<service
android:name=".MyAIDLService"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="ACTION_AIDL_SERVICE" />
</intent-filter>
</service>
서비스를 이용하는 외부 앱
이번에는 AIDL 서비스를 이용하는 앱의 코드를 살펴보겠습니다. 인텐트로 외부 앱의 서비스를 실행할 떄 bindService() 함수를 이용하며 인텐트에 패키지 정보를 포함해야 합니다. 그런데 안드로이드 11 버전부터는 외부 앱의 패키지 정보에 접근할 때 패키지 공개 상태에 영향을 받으므로 다음처럼 매니페스트에 등록해 줘야 합니다.
<queries>
<package android:name="com.example.test_outter" />
</queries>
AIDL 서비스를 이용하는 앱도 AIDL 서비스를 제공하는 앱에서 만든 AIDL 파일을 가지고 있어야 합니다.
이제 bindService() 함수를 이용해 외부 앱의 서비스를 실행합니다.
class MainActivity : AppCompatActivity() {
lateinit var aidlService: MyAIDLInterface
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
val intent = Intent("ACTION_AIDL_SERVICE")
intent.setPackage("com.example.test_outter")
bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
...
val connection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
aidlService = MyAIDLInterface.Stub.asInterface(service)
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.d("kkang", "onServiceDisconnected...")
}
}
}
bindService() 함수로 서비스를 실행할 때 자동으로 호출되는 onServiceConnected() 함수의 두 번째 매개변수가 서비스에서 전달한 객체입니다. 이 객체는 AIDL이 목적이므로 서비스를 포함하는 앱과 프로세스 간 통신을 대행해 주는 Stub입니다. 이 객체를 aidlService = MyAIDLInterface.Stub.asInterface(service) 처럼 AIDL 파일에 선언한 인터페이스 타입으로 받으면 됩니다.
이제 AIDL을 제공하는 앱과 연동해야 할 때 인터페이스의 함수만 호출하면 됩니다. 그러면 AIDL 서비스를 제공하는 앱의 함수가 실행됩니다.
aidlService.funA("hello")
3. 백그라운드 제약
리시버의 백그라운드 제약
브로드캐스트 리시버를 실행하려면 sendBroadcast() 함수로 인텐트를 시스템에 전달해야 합니다. 그런데 브로드캐스트 리시버는 암시적 인텐트로 실행할 수 없습니다.
<receiver
android:name=".MyReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="ACTION_RECEIVER" />
</intent-filter>
</receiver>
만약 어떤 앱의 매니페스트에서 위처럼 인텐트 필터를 포함해 리시버를 등록했다면 다음처럼 암시적 인텐트로 리시버를 실행하는 코드를 생각할 수 있습니다.
val intent = Intent("ACTION_RECEIVER")
sendBroadcast(intent)
그런데 앞의 코드로 인텐트를 시스템에 전달하면 리시버는 실행되지 않고 다음과 같은 오류가 발생합니다. 즉, 매니페스트에 등록한 리시버를 명시적으로 실행하는 것은 문제가 없지만 암시적 인텐트로 실행하면 백그라운드 제약으로 오류가 발생합니다.
Background execution not allowed: receiving Intent { act=ACTION_RECEIVER flg=0x10 } to com.example.test15/.MyReceiver
그런데 리시버를 매니페스트에 등록하지 않고 다음처럼 코드에서 registerReceiver() 함수로 등록하면 암시적 인텐트로도 잘 실행됩니다.
receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Log.d("kkang", "outer app dynamic receiver")
}
}
registerReceiver(receiver, IntentFilter("ACTION_OUTER_DYNAMIC_RECEIVER"))
위처럼 코드에서 registerReceiver() 함수로 리시버를 등록하면 다음처럼 암시적 인텐트로 실행할 수 있습니다.
val intent = Intent("ACTION_OUTER_DYNAMIC_RECEIVER")
sendBroadcast(intent)
정리하자면 매니페스트에 등록한 리시버를 암시적으로 실행할 떄는 같은 앱의 리시버든 외부 앱의 리시버든 실행되지 않습니다. 이것이 리시버의 백그라운드 제약입니다.
서비스의 백그라운드 제약
서비스는 앱이 백그라운드 상태일 때 인텐트를 전달하면 오류가 발생합니다. 포그라운드 상황에서는 잘 실행되던 인텐트도 백그라운드 상황에서는 다음과 같은 오류가 발생합니다.
Not allowed to start service intent { act=ACTION_OUTER_SERVICE pkg=com.example.test_outter }: app is in background uid null
안드로이드 시스템에서 서비스가 정상으로 실행되는 포그라운드 상황은 다음과 같습니다.
- 액티비티가 시작되든 일시 중지되든 상관없이 보이는 액티비티가 있을 때
- 포그라운드 서비스가 있을 때
- 앱의 서비스에 바인딩하거나 앱의 콘텐츠 프로바이더를 사용해 또 다른 포그라운드 앱이 연결되었을 때
이 외에는 백그라운드 상황으로 간주합니다. 그리고 앱이 백그라운드 상황이더라도 다음과 같은 경우에는 서비스가 정상으로 실행됩니다.
- 우선순위가 높은 파이어베이스 클라우드 메시징(FCM) 처리
- SMS/MMS 메시지와 같은 브로드캐스트 수신
- 알림에서 PendingIntent 실행
- VPN 앱이 포그라운드로 승격되기 전에 VpnService 시작
위에 나열한 상황 외에는 서비스가 실행되지 않습니다.
그런데 앱이 백그라운드 상황에서도 서비스를 실행할 방법이 하나 있습니다. startForegroundService() 함수로 인텐트를 시작하면 앱이 백그라운드 상황에서도 서비스가 실행됩니다.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
그런데 앱이 백그라운드 상황에서 startForegroundService() 함수로 실행한 서비스는 얼마 후 다음과 같은 오류가 발생하면서 강제로 종료됩니다. 즉, 앱이 백그라운드 상태더라도 startForegroundService() 함수로 서비스를 실행할 수 있지만, 결국 오류가 발생하므로 서비스를 정상으로 유지할 수는 없습니다.
Context.startForegroundService() did not then call Service.startForeground()
그렇다면 이런 함수를 왜 제공하는 걸까요? 결론부터 말하면 서비스를 startForegroundService() 함수로 실행했다면 빨리 startForeground() 함수를 호출해 포그라운드 상황으로 만들라는 의미입니다. 그러면 서비스가 종료되지 않습니다.
val notification = builder.build()
startForeground(1, notification)
위 코드는 백그라운드 상황에서 startForegroundService() 함수로 실행된 서비스 쪽에 작성합니다. 그런데 startForeground() 함수의 매개변수는 알림 객체이므로 이 함수를 정상으로 실행하려면 다음처럼 매니페스트에 퍼미션을 등록해줘야 합니다.
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
요약하자면 앱이 백그라운드 상황이더라도 startForegroundService() 함수를 이용하면 서비스를 실행할 수 있지만, 빨리 알림을 이용해 앱을 포그라운드 상황으로 만들어야 합니다. 즉, 사용자에게 앱이 실행되고 있다는 것을 알려야 한다는 것입니다. 그래야만 앱이 백그라운드 제약에서 벗어날 수 있습니다.
4. 잡 스케줄러
잡 스케줄러의 실행 조건
- 네트워크 타입
- 배터리 충전 상태
- 특정 앱의 콘텐츠 프로바이더 갱신(대표적으로 갤러리 앱)
위 조건에 부합되는 경우 시스템이 잡 스케줄러를 실행합니다. 그 밖에 다음과 같은 조건도 명시할 수 있습니다.
- 실행 주기
- 최소 지연 시간
- 시스템 재구동 시 현재 조건 유지 여부
잡 스케줄러의 3가지 구성 요소
- 잡 서비스 : 백그라운드에서 처리할 작업을 구현한 서비스입니다.
- 잡 인포 : 잡 서비스 정보와 실행될 조건을 지정합니다.
- 잡 스케줄러 : 잡 인포를 시스템에 등록합니다.
잡 서비스 - 백그라운드 작업 구현
잡 서비스는 개발자가 만드는 서비스이므로 매니페스트에 <service> 태그로 등록합니다. 이때 android.permission.BIND_JOB_SERVICE 퍼미션도 포함합니다.
<service
android:name=".MyJobService"
android:enabled="true"
android:exported="true"
android:permission="android.permission.BIND_JOB_SERVICE"></service>
그런 다음 코드에서 JobService를 상속받아 onCreate(), onStartCommand(), onDestroy() 같은 생명주기 함수를 재정의한 클래스를 작성합니다.
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
class MyJobService : JobService() {
override fun onCreate() {
super.onCreate()
Log.d("kkang", "MyJobService........onCreate()")
}
override fun onDestroy() {
super.onDestroy()
Log.d("kkang", "MyJobService........onDestroy()")
}
override fun onStartJob(params: JobParameters?): Boolean {
Log.d("kkang", "MyJobService........onStartJob()")
return false
}
override fun onStopJob(params: JobParameters?): Boolean {
Log.d("kkang", "MyJobService........onStopJob()")
return false
}
}
onStartJob() 함수에는 백그라운드에서 처리할 작업을 구현합니다. 이 함수의 반환값은 Boolean 타입인데 true인지 false인지에 따라서 다르게 동작합니다.
- false : 작업이 완벽하게 종료되었음을 의미합니다.
- true : 작업이 아직 끝나지 않았음을 의미합니다.
onStartJob() 함수가 false를 반환하면 백그라운드 작업이 완벽하게 끝났음을 의미합니다. 그러면 시스템은 더 처리할 필요가 없다고 판단하여 onStopJob() 함수를 건너뛰고 바로 onDestroy() 함수를 호출해 서비스를 종료합니다.
위 코드에서는 테스트를 위해 각 함수에 로그를 출력했습니다. onStartJob() 함수에서 false를 반환하면 다음과 같은 로그가 출력됩니다.
MyJobService........onCreate()
MyJobService........onStartJob()
MyJobService........onDestroy()
onStartJob() 함수가 true를 반환하면 백그라운드에서 처리할 작업이 아직 끝나지 않았다는 것을 의미하며 onDestory() 함수는 호출되지 않습니다. 따라서 오래 걸리는 작업을 스레드 등에서 처리하고 끝낼 때 jobFinish() 함수를 호출하는 식으로 onStartJob() 함수를 구현합니다.
위 코드에서 onStartJob() 함수를 다음처럼 true를 반환하도록 수정하고 테스트해 보겠습니다.
override fun onStartJob(jobParameters: JobParameters): Boolean {
Log.d("kkang", "JobSchedulerService... onStartJob....")
Thread(Runnable {
var sum = 0
for (i in 1..10) {
sum += i
SystemClock.sleep(1000)
}
Log.d("kkang", "JobSchedulerService... onStartJob... thread result : $sum")
jobFinished(jobParameters, false)
}).start()
return true
}
onStartJob() 함수에서 스레드를 이용해 시간이 10초 정도 걸리는 작업을 처리하고 true를 반환했습니다.
MyJobService........onCreate()
JobSchedulerService... onStartJob...
JobSchedulerService... onStartJob... thread result : 55
MyJobService... onDestroy()
onStartJob() 함수는 끝났지만 스레드가 종료될 때까지 서비스는 종료되지 않으며, 스레드에서 명시적으로 jobFinished() 함수가 호출될 때 onDestory() 함수가 호출되면서 서비스가 종료되었습니다.
그런데 이때에도 onStopJob() 함수는 호출되지 않았습니다. onStopJob() 함수가 호출되는 경우는 onStartJob() 함수에서 true를 반환해 서비스가 오랜 시간 살아있는 상황에서 갑자기 잡 스케줄러를 실행하는 조건이 변경되거나 어디선가 cancel() 함수로 취소했을 때입니다. 시스템은 이러한 상황을 잡 스케줄러가 비정상으로 종료된 것으로 인지하고 서비스를 종료하기 전에 처리할 로직을 실행하고자 onStopJob() 함수를 호출합니다.
onStopJob() 함수의 반환값도 Boolean 타입인데 true, false에 따라 다르게 동작합니다.
- false : 잡 스케줄러 등록을 취소합니다.
- true : 잡 스케줄러를 재등록합니다.
onStopJob() 함수는 작업이 정상으로 처리되지 않았을 때 호출됩니다. 이때 이 작업을 다시 시스템에 등록하거나 취소할지를 onStopJob() 함수의 반환값으로 지정합니다.
잡 인포 - 잡 서비스의 실행 조건 정의
잡 서비스를 만들었으면 잡 스케줄러를 이용해 시스템에 등록해야 합니다. 이때 잡 서비스가 실행되는 조건을 JobInfo 객체에 담습니다.
var jobScheduler: JobScheduler? = getSystemService<JobScheduler>()
JobInfo.Builder(1, ComponentName(this, MyJobService::class.java)).run {
setRequireNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
jobScheduler?.schedule(build())
}
JobInfo.Builder 생성자의 첫 번째 매개변수는 등록할 작업의 식별값입니다. 나중에 cancel() 함수로 작업을 취소할 때 이 값을 사용합니다. 두 번째 매개변수는 등록할 잡 서비스입니다. 이 서비스를 시스템에 등록하겠다는 의미입니다. 그리고 빌더의 세터 함수를 이용해 조건을 명시합니다. 이렇게 만든 JobInfo 객체를 JobScheduler의 schedule() 함수로 시스템에 등록하면 됩니다.
JobInfo.Builder에 지정한 잡 서비스가 실행되는 조건을 명시할 때는 다음과 같은 세터함수를 이용합니다.
- setPersisted(true) : 기기를 재부팅해도 작업 등록을 유지해야 하는지를 설정합니다.
- setPeriodic(long intervalMillis) : 작업의 실행 주기를 설정합니다.
- setMinimumLatency(long minLatencyMillis) : 작업의 실행 지연 시간을 설정합니다.
- setOverrideDeadline(long maxExecutionDelayMillis) : 다른 조건에 만족하지 않더라도 작업이 이 시간 안에 실행되어야 함을 설정합니다.
- setRequireNetworkType(int networkType) : 네트워크 타입을 설정합니다.
- setRequiresBatteryNotLow(boolean batteryNotLow) : 배터리가 낮은 상태가 아님을 설정합니다.
- setRequiresCharging(boolean requiresCharging) : 배터리가 충전 상태인지를 설정합니다.
잡 스케줄러 - 잡 서비스 등록 시 데이터 전달
잡 서비스를 JobInfo 객체를 이용해 시스템에 등록하면 조건에 만족할 때 실행됩니다. 이때 잡 서비스에 데이터를 전달하려면 JobInfo.Builder의 setExtras() 함수를 이용합니다.
var jobScheduler: JobScheduler? = getSystemService<JobScheduler>()
val extras = PersistableBundle()
extras.putString("extra_data", "hello kkang")
val builder = JobInfo.Builder(1, componentName)
builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
builder.setRequiresCharging(true)
builder.setExtras(extras)
val jobInfo = builder.build()
jobScheduler!!.scheduler(jobInfo)
이렇게 전달한 데이터를 잡 서비스에서 가져올 때는 onStartJob() 함수의 매개변수를 이용합니다.
override fun onStartJob(jobParameters: JobParameters): Boolean {
jobParameters.extras.getString("extra_data")
...
return false;
}
onStartJob() 함수의 매개변수 타입은 JobParameters 이며 이 매개변수의 extras 속성에 전달받은 데이터가 담겨 있습니다.
5. MP3 재생 앱 만들기
1. 모듈 생성하기
myapplication15_service, myapplication15_outer 두 모듈을 만듭니다.
outer 모듈은 음악을 재생하는 앱이며, service 모듈은 프로세스 간 통신으로 outer 앱을 조종하는 리모컨 같은 역할을 합니다.
2. 빌드 그래들 수정하기
service 모듈의 build.gradle
android {
...
buildFeatures {
viewBinding = true
aidl = true
}
}
outer 모듈의 build.gradle
android {
...
buildFeatures {
viewBinding = true
aidl = true
}
}
3. 리소스 파일 복사하기
3-1. service 모듈의 res/drawable
3-2. outer 모듈의 res/raw
4. service 모듈 파일 수정하기
4-1. aidl 추가하기
service 모듈 이름에 마우스 오른쪽을 클릭하고 [New → Folder → AIDL Folder] 를 선택합니다. 설정창이 열리면 <Finish>를 누릅니다.
이어서 aidl 디렉터리에 마우스 오른쪽을 클릭하고 [New → AIDL → AIDL File]을 선택합니다. 만약 AIDL File 이 비활성화되어 있다면 모듈 레벨 그레들 파일에서 buildFeatures 에 aidl = true 를 추가해준 뒤 Sync Now 를 실행해줍니다. 그리고 다시 AIDL File 을 선택한 후 인터페이스 이름으로 MyAIDLInterface 를 입력하고 <Finish> 를 눌러줍니다.
그리고 한 가지 주의할 점은 service 모듈의 AIDL File 이지만 통신하고자하는 외부 앱의 AIDL 패키지 명과 일치시켜줘야 한다는 점입니다. 이를 지키지 않을 시 앱이 비정상적으로 종료되는 문제가 발생합니다.
패키지 명을 보면 com.example.myapplication15_service 가 아닌 com.example.myapplication15_outer 로 설정되어야 합니다.
// MyAIDLInterface.aidl
package com.example.myapplication15_outer;
// Declare any non-default types here with import statements
interface MyAIDLInterface {
int getMaxDuration();
void start();
void stop();
}
그리고 AIDL 파일은 서비스 클래스에서 사용되어야 하는데 파일 확장자가 aidl 이므로 클래스에서 사용할 수 없습니다. 그래서 AIDL 파일을 만든 다음에는 쪽 [Build → Make Module] 메뉴를 실행해야 합니다. 그래야만 코틀린 코드에서 AIDL 파일을 사용할 수 있습니다.
4-2. MainActivity 수정하기
package com.example.myapplication15_service
import android.app.job.JobInfo
import android.app.job.JobScheduler
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.example.myapplication15_outer.MyAIDLInterface
import com.example.myapplication15_service.databinding.ActivityMainBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding
var connectionMode = "none"
//aidl...........
var aidlService: MyAIDLInterface? = null
var aidlJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
//aidl................
onCreateAIDLService()
//jobscheduler......................
val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) {
if (it.all { permission -> permission.value == true }) {
onCreateJobScheduler()
} else {
Toast.makeText(this, "permission denied...", Toast.LENGTH_SHORT).show()
}
}
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this,
"android.permission.POST_NOTIFICATIONS"
) == PackageManager.PERMISSION_GRANTED
) {
onCreateJobScheduler()
} else {
permissionLauncher.launch(
arrayOf(
"android.permission.POST_NOTIFICATIONS"
)
)
}
}else {
onCreateJobScheduler()
}
}
override fun onStop() {
super.onStop()
if(connectionMode === "aidl"){
onStopAIDLService()
}
connectionMode="none"
changeViewEnable()
}
fun changeViewEnable() = when (connectionMode) {
"aidl" -> {
binding.aidlPlay.isEnabled = false
binding.aidlStop.isEnabled = true
}
else -> {
//초기상태. stop 상태. 두 play 버튼 활성상태
binding.aidlPlay.isEnabled = true
binding.aidlStop.isEnabled = false
binding.aidlProgress.progress = 0
}
}
//aidl connection .......................
val aidlConnection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
aidlService = MyAIDLInterface.Stub.asInterface(service)
aidlService!!.start()
binding.aidlProgress.max = aidlService!!.maxDuration
val backgroundScope = CoroutineScope(Dispatchers.Default + Job())
aidlJob=backgroundScope.launch {
while (binding.aidlProgress.progress < binding.aidlProgress.max) {
delay(1000)
binding.aidlProgress.incrementProgressBy(1000)
}
}
connectionMode = "aidl"
changeViewEnable()
}
override fun onServiceDisconnected(name: ComponentName) {
aidlService = null
}
}
private fun onCreateAIDLService() {
binding.aidlPlay.setOnClickListener {
val intent = Intent("ACTION_SERVICE_AIDL")
intent.setPackage("com.example.myapplication15_outer")
bindService(intent, aidlConnection, Context.BIND_AUTO_CREATE)
}
binding.aidlStop.setOnClickListener {
aidlService!!.stop()
unbindService(aidlConnection)
aidlJob?.cancel()
connectionMode="none"
changeViewEnable()
}
}
private fun onStopAIDLService() {
unbindService(aidlConnection)
}
//JobScheduler
private fun onCreateJobScheduler(){
var jobScheduler: JobScheduler? = getSystemService(JOB_SCHEDULER_SERVICE) as JobScheduler
val builder = JobInfo.Builder(1, ComponentName(this, MyJobService::class.java))
builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
val jobInfo = builder.build()
jobScheduler!!.schedule(jobInfo)
}
}
4-3. MyJobService 추가하기
package com.example.myapplication15_service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.job.JobParameters
import android.app.job.JobService
import android.os.Build
import android.util.Log
class MyJobService : JobService() {
override fun onStartJob(jobParameters: JobParameters): Boolean {
Log.d("kkang", "onStartJob.....................")
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel("oneId", "oneName",
NotificationManager.IMPORTANCE_DEFAULT)
channel.description = "oneDesc"
manager.createNotificationChannel(channel)
Notification.Builder(this, "oneId")
} else {
Notification.Builder(this)
}.run {
setSmallIcon(android.R.drawable.ic_notification_overlay)
setContentTitle("JobScheduler Title")
setContentText("Content Message")
setAutoCancel(true)
manager.notify(1, build())
}
return false
}
override fun onStopJob(jobParameters: JobParameters): Boolean {
return true
}
}
4-4. activity_main.xml 수정하기
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#28353b">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/background"
android:layout_centerVertical="true"
android:layout_centerHorizontal="true"/>
<ImageView
android:id="@+id/aidlPlay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_play"
android:layout_alignParentBottom="true"
android:layout_marginBottom="36dp"
android:layout_marginLeft="24dp"
android:clickable="true"/>
<TextView
android:id="@+id/aidlTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="AIDL Play"
android:textColor="@android:color/white"
android:textStyle="bold"
android:textSize="20dp"
android:layout_alignParentBottom="true"
android:layout_alignTop="@id/aidlPlay"
android:layout_marginLeft="16dp"
android:layout_centerHorizontal="true"
android:layout_centerInParent="true"
android:layout_centerVertical="true"
/>
<ImageView
android:id="@+id/aidlStop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_stop"
android:layout_alignParentBottom="true"
android:layout_alignParentRight="true"
android:layout_marginBottom="36dp"
android:layout_marginRight="24dp"
android:clickable="true"/>
<ProgressBar
android:id="@+id/aidlProgress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/Base.Widget.AppCompat.ProgressBar.Horizontal"
android:layout_above="@id/aidlTitle"
android:layout_marginBottom="24dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"/>
</RelativeLayout>
4-5. 매니페스트 파일 수정하기
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" >
<queries>
<package android:name="com.example.myapplication15_outer" />
</queries>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<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" >
<service
android:name=".MyJobService"
android:enabled="true"
android:exported="true"
android:permission="android.permission.BIND_JOB_SERVICE"></service>
<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>
5. outer 모듈 파일 수정하기
5-1. aidl 추가하기
// MyAIDLInterface.aidl
package com.example.myapplication15_outer;
// Declare any non-default types here with import statements
interface MyAIDLInterface {
int getMaxDuration();
void start();
void stop();
}
5-2. MainActivity 수정하기
package com.example.myapplication15_outer
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}
}
5-3. MyAIDLService 추가하기
package com.example.myapplication15_outer
import android.app.Service
import android.content.Intent
import android.media.MediaPlayer
import android.os.IBinder
class MyAIDLService : Service() {
lateinit var player: MediaPlayer
override fun onCreate() {
super.onCreate()
player = MediaPlayer()
}
override fun onDestroy() {
player.release()
super.onDestroy()
}
override fun onBind(intent: Intent): IBinder?{
return object : MyAIDLInterface.Stub() {
override fun getMaxDuration(): Int {
return if (player.isPlaying)
player.duration
else 0
}
override fun start() {
if (!player.isPlaying) {
player = MediaPlayer.create(this@MyAIDLService, R.raw.music)
try {
player.start()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
override fun stop() {
if (player.isPlaying)
player.stop()
}
}
}
}
5-4. 매니페스트 파일 수정하기
<?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">
<service
android:name=".MyAIDLService"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="ACTION_SERVICE_AIDL" />
</intent-filter>
</service>
<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>
6. outer 모듈 앱 실행
7. service 모듈 앱 실행
'Android' 카테고리의 다른 글
[깡쌤의 안드로이드 프로그래밍 with 코틀린][6. 앱에 다양한 기능 추가하기] 17. 저장소에 데이터 보관하기 (0) | 2024.12.25 |
---|---|
[깡쌤의 안드로이드 프로그래밍 with 코틀린][5. 컴포넌트 이해하기] 16. 콘텐츠 프로바이더 컴포넌트 (0) | 2024.12.25 |
+[깡쌤의 안드로이드 프로그래밍 with 코틀린][5. 컴포넌트 이해하기] 14. 브로드캐스트 리시버 컴포넌트 (0) | 2024.12.25 |
[깡쌤의 안드로이드 프로그래밍 with 코틀린][5. 컴포넌트 이해하기] 13. 액티비티 컴포넌트 (0) | 2024.12.25 |
[깡쌤의 안드로이드 프로그래밍 with 코틀린][4. 구글의 라이브러리로 화면 구성하기] 12. 머티리얼 라이브러리 (0) | 2024.12.25 |