(원문: http://developer.android.com/guide/components/processes-and-threads.html)

(위치: Develop > API Guides > App Components
> Processes and Threads
)

(전체 번역 글 목록으로 이동하기)


프로세스와 쓰레드


어떤 앱 컴포넌트가 해당 앱에 대하여 처음 시작되는 경우, 즉 그 앱에 (실행 중인) 다른 앱 컴포넌트가 없는 경우에, 안드로이드 시스템은 리눅스 프로세스를 새로 생성하여 실행하고 쓰레드를 만들어서 앱 컴포넌트를 실행시켜 줍니다. 기본적으로, 동일한 앱의 컴포넌트들은 동일한 프로세스 및 ("메인" 쓰레드라고 불리는) 쓰레드에서 실행됩니다. 앱의 다른 컴포넌트가 이미 존재하여 프로세스가 이미 생성되었다면, 또 다른 앱 컴포넌트가 실행될 때 그 프로세스에서 실행되며 쓰레드도 같습니다. 하지만 기본적인 동작이 그러할 뿐, 내 앱의 컴포넌트들을 서로 다른 프로세스에서 실행되도록 할 수 있으며, 쓰레드도 얼마든지 새로 생성하여 실행할 수 있습니다. 

본 문서에서는 안드로이드 앱에서 프로세스와 쓰레드가 어떻게 동작하는지에 대하여 설명합니다. 


프로세스

기본적으로 앱의 모든 컴포넌트들은 동일한 프로세스에서 실행되며, 대부분의 경우 이를 변경할 필요가 없습니다. 하지만 특정 앱 컴포넌트를 다른 프로세스에서 실행시켜야 할 필요가 있는 경우, 매니페스트 파일에서 그 앱 컴포넌트가 실행될 프로세스를 지정하도록 할 수 있습니다. 

매니페스트 파일에서 앱 컴포넌트들을 선언할 때, <activity>, <service>, <receiver>, <provider> 요소를 사용하는데, 이들은 android:process 속성을 통해 해당 컴포넌트가 실행될 프로세스를 지정할 수 있습니다. 이 속성을 지정함으로써, 각 컴포넌트들이 서로 다른 프로세스에서 실행되도록 할 수도 있고, 대체로 같은 프로세스에서 실행하되 일부만 다른 프로세스에서 실행되도록 할 수도 있으며, 심지어는 서로 다른 앱인데 같은 프로세스에서 실행되도록 할 수도 있습니다. 하지만 서로 다른 앱이 같은 프로세스에서 실행되려면, 해당 앱들이 동일한 리눅스 유저 ID를 공유해야하고, 동일한 인증서(certificates)로 서명되어(signed) 있어야 합니다. 

<application> 요소에서도 android:process 속성을 지정할 수 있는데, 이는 해당 앱의 모든 컴포넌트들에 대한 기본값이 됩니다. 

안드로이드는 메모리가 부족하거나 다른 앱의 컴포넌트가 먼저 메모리를 필요로 하는 경우 프로세스를 종료시킬 수도 있습니다. 그러면 결과적으로 그 프로세스에서 실행 중이던 앱 컴포넌트들도 모두 종료됩니다. 그리고 해당 앱 컴포넌트들이 다시 실행될 때 프로세스도 다시 시작됩니다. 

안드로이드 시스템은 어떤 프로세스를 종료시킬지 결정할 때, 그 프로세스가 사용자에게 얼마나 중요한지를 판단합니다. 예를 들어, 더 이상 화면에 보이지 않는 액티비티를 실행중인 프로세스가, 화면에 보이고 있는 액티비티를 실행중인 프로세스보다 더 우선적으로 종료되는 것입니다. 따라서 어떤 프로세스를 종료시킬지 결정하는 기준은, 프로세스에서 실행중인 앱 컴포넌트들의 상태에 달려있다고 볼 수 있습니다. 그에 대한 자세한 내용은 이어지는 섹션에서 설명합니다. 


프로세스의 생명주기

안드로이드 시스템은 앱의 프로세스를 가능한 한 유지하려고 노력합니다. 하지만 시간이 지날수록 새로운 프로세스나 더 중요한 프로세스를 실행하기 위해 메모리가 필요해지고, 그 때문에 오래된 프로세스는 종료시켜야 할 필요가 생깁니다. 시스템은, 프로세스들 중에 어떤 것을 살리고 어떤 것을 종료시킬지를 결정하기 위해서, 프로세스들을 해당 프로세스에서 실행 중인 컴포넌트들의 상태에 기반한 "중요도 계층"으로 나눕니다. 그리고 부족한 시스템 자원을 채우기 위해서 가장 중요도가 낮은 프로세스들을 먼저 종료시키고, 또 필요하면 그 다음으로 중요도가 낮은 프로세스들을 종료시킵니다. 

중요도 계층은 아래의 5가지로 구분됩니다. 중요도가 가장 높은 것부터 설명합니다. 

1. 전방에 있는 프로세스(Foreground process)

사용자가 현재 뭔가 하고 있는 프로세스입니다. 아래 조건들 중 하나 이상을 만족한다면 그 프로세스는 전방(foreground)에 있는 것으로 간주합니다.

  • 사용자와 상호작용하고 있는 Activity(onResume()이 호출된 Activity)를 실행중인 프로세스
  • 사용자와 상호작용하고 있는 Activity에 바인드된 Service를 실행 중인 프로세스
  • "전방에 있는" Service(startForeground()가 호출된 Service)를 실행 중인 프로세스
  • onCreate(), onStart(), onDestroy()와 같은 생명주기 콜백 메소드가 호출되어 리턴되기 전까지의 Service를 실행 중인 프로세스
  • onReceive() 메소드가 호출되어 리턴되기 전까지의 BroadcastReceiver를 실행 중인 프로세스

일반적으로 전방(foreground)에는 한번에 많은 프로세스가 존재하지 않습니다. 그리고 이러한 프로세스는, 가용 메모리가 너무 적어서 더이상 아무것도 실행할 수 없을 때에만 종료될 수 있습니다. 

2. 보여지고 있는 프로세스(Visible process)

전방에 있는 컴포넌트는 없지만, 사용자에게 보여지는 컴포넌트가 실행 중인 프로세스입니다. 아래 조건 중 하나를 만족하면 사용자에게 보여지는 것으로 간주합니다:

  • 전방에 있지는 않지만 여전히 화면에 보이는 Activity(onPause()가 호출된 Activity)를 실행 중인 프로세스. 예를 들어, 전방에 있는 Activity에서 다이얼로그를 실행한 경우, 다이얼로그가 전방으로 오고, Activity는 전방에서 벗어나 사용자와 상호작용할 수는 없지만 화면에는 보이는 상태가 됩니다. 
  • "보이는" Activity에 바인드 된 Service를 실행 중인 프로세스

"보이는" 프로세스들도 매우 중요하기 때문에, 전방에 있는 프로세스 외에 다른 상태의 프로세스가 없을 때에만 종료시킬 수 있습니다.

3. 서비스 프로세스(Service process)

startService()가 호출되어 Service를 실행 중인 프로세스지만, 위의 전방에 있는 프로세스나 보여지고 있는 프로세스가 아닌 경우에 서비스 프로세스로 구분합니다. 비록 사용자의 눈에 뭔가가 보이는 것은 아니지만, 후방(background)에서 음악을 재생한다거나, 네트워크를 통해 데이터를 다운로드 하는 등의 일을 합니다. 전방에 있는 프로세스와 보여지고 있는 프로세스 외에 다른 상태의 프로세스가 없을 때에만 종료시킬 수 있습니다. 

4. 후방에 있는 프로세스(Background process)

사용자에게 보여지지 않는 Activity(onStop()이 호출된 Activity)를 실행 중인 프로세스입니다. 이러한 프로세스들은 사용자에게 직접적인 영향을 미치지 못하며, 전방에 있는 프로세스나 보여지고 있는 프로세스나 서비스 프로세스를 실행할 때 메모리 복구를 위해 종료될 수 있습니다. 보통 후방에 있는 프로세스는 많이 존재할 수 있기 때문에, 시스템은 최근 사용 목록(least recently used list, LRU list)을 참조하여, 최근에 사용된 Activity를 가장 나중에 종료시킵니다. Activity가 생명주기 콜백 메소드들을 제대로 구현하여, Activity 종료시 현재 상태를 저장하고, 재생성시 현재 상태를 복구하고 있다면, Activity가 종료되고 나서 사용자가 뒤로 버튼을 눌러 다시 돌아왔을 때에 Activity가 재생성 되면서 이전 상태로 복구되기 때문에 사용자에게 비정상적인 경험을 주지는 않습니다. Activity에서 현재 상태를 저장하고 복구하는 방법에 대해서는 Activity 상태 저장하기에서 학습하실 수 있습니다. 

5. 텅 빈 프로세스(Empty process)

활성화된 앱 컴포넌트가 하나도 없는 프로세스입니다. 이러한 프로세스가 존재하는 이유는, 프로세스 자체를 캐싱(caching)함으로써 앱 컴포넌트를 다시 실행해야할 때 프로세스를 새로 생성하지 않기 위함입니다. 시스템은 메모리가 부족한 경우뿐만 아니라, 프로세스 캐시와 커널 캐시 간의 균형을 맞추기 위해 이러한 프로세스들을 종료시키기도 합니다. 

안드로이드 시스템은 프로세스가 실행 중인 앱 컴포넌트들의 상태에 기반하여, 해당 프로세스를 위의 5개 계층의 조건에 부합하는 계층 중 가장 상위 계층으로 지정합니다. 예를 들어, 프로세스가 Service와 보이는(visible) Activity를 실행 중이라면, 그 프로세스는 서비스 프로세스가 아니라 보여지고 있는 프로세스입니다. 

그리고, 프로세스의 계층은 프로세스 간의 의존성에 의해 결정될 수도 있습니다. 어떤 프로세스 B가 다른 프로세스 A에 의존적인 상황이라면, A는 B보다 낮은 계층일 수 없습니다. 예를 들면, 프로세스 A의 컨텐트 프로바이더가 프로세스 B의 클라이언트에게 데이터를 제공하고 있는 상황이거나, 또는 프로세스 A의 Service가 프로세스 B의 컴포넌트에 바인드 되어 있는 상황인 경우, 프로세스 A의 계층은 프로세스 B의 계층 이상이 되어야 합니다. 

Service를 실행 중인 프로세스가 후방에 있는 Activity를 실행 중인 프로세스보다 중요도 계층이 높기 때문에, Activity에서 시간이 오래 걸리는(long-running) 작업을 비동기로 실행시키기 위하여, 쓰레드 보다는 Service를 이용하는 것이 좋습니다. 예를 들어, 어떤 Activity에서 사진을 업로드 해야하는 경우에 Service를 생성하여 사용하면 그 Activity에서 빠져나가더라도 "서비스 프로세스"가 되지만, 쓰레드를 생성하여 사용했다면 Activity에서 빠져나가는 순간 "후방에 있는 프로세스"가 되기 때문에 더 중요도가 낮아지게 되는 것입니다. 그리고 이와 같은 이유로, 브로드캐스트 리시버의 경우에도 시간이 오래 걸리는(time-consuming) 작업은 쓰레드보다 Service를 사용하는 것이 좋습니다. 


쓰레드

앱이 실행되면, 시스템은 "메인 쓰레드"를 생성합니다. 메인 쓰레드는 버튼 같은 사용자 인터페이스의 이벤트 관련 동작 및 화면에 뷰를 그려주는 동작 등을 담당하며, android.widgetandroid.view 패키지에 포함된(안드로이드 UI 툴킷에 포함된) 뷰들과의 상호작용을 담당합니다. 그래서 메인 쓰레드를 UI 쓰레드라고 부르기도 합니다. 

시스템은 각 컴포넌트에 대하여 별도의 쓰레드를 생성하지 않습니다. 동일한 프로세스 내에서 실행되는 모든 컴포넌트들은 하나의 UI 쓰레드에서 실행됩니다. 따라서, onKeyDown() 같은 이벤트 관련 콜백 메소드나 앱 컴포넌트의 생명주기 콜백 메소드 등의 시스템 콜백 메소드는 항상 UI 쓰레드에서 실행됩니다. 

예를 들어, 사용자가 버튼을 터치하면, UI 쓰레드가 터치 이벤트를 버튼에게 보내서 눌려진(pressed) 상태로 변경시킨 후, 새로 그리기(invalidate) 요청을 이벤트 큐에 추가합니다. 그리고 적당한 시점에 UI 쓰레드가 다시 해당 요청을 꺼내서(dequeues) 버튼에게 새로 그리라고 알려주는 것입니다. 

사용자와의 상호작용과 관련된 콜백 메소드에서 많은 일을 해야하는 경우, UI 쓰레드에서만 처리하게 되면 퍼포먼스가 낮아질 수 있습니다. 특히, 네트워크 관련 작업이나 데이터베이스에 질의하는 등의 시간이 오래 걸리는 작업은, 그 시간 동안 전체 UI를 멈춰(block) 버립니다. 이렇게 UI 쓰레드가 멈추면, 어떠한 이벤트도 발생하지 못하고, 화면에 뷰를 새로 그리지도 못하게 되며, 이런 상태로 약 5초 정도 지나면 응답대기(application not responding, ANR) 다이얼로그를 출력하게 됩니다. 이것은 사용자로 하여금 짜증을 유발시킬 수 있고 앱을 삭제하게 만드는 원인이 될 수도 있습니다. 

게다가, 안드로이드 UI 툴킷은 멀티 쓰레드에 안전하지(thread-safe) 않습니다.  따라서 UI 관련 갱신 작업을 워커 쓰레드에서 하면 안됩니다. 간단히 정리하자면, UI 관련 작업과 관련하여 아래 2가지 규칙을 지켜야 합니다.

1. UI 쓰레드를 멈추면(block) 안됩니다.

2. UI 쓰레드가 아닌 쓰레드에서 안드로이드 UI 툴킷에 접근하면 안됩니다.


워커(Worker) 쓰레드

위에서 설명한 바와 같이, UI 쓰레드만 사용하는 경우 쓰레드가 멈추면 앱이 아무런 반응을 할 수 없는 상태가 됩니다. 따라서 시간이 걸리는 작업을 해야하는 경우에는 별도의 쓰레드(후방(background) 쓰레드 또는 워커(worker) 쓰레드)를 생성해야 합니다. 

아래 코드는, 클릭이 발생했을 때 별도의 쓰레드에서 이미지를 다운로드하여 ImageView에 보여주는 것을 나타냅니다:

public void onClick(View v) {
   
new Thread(new Runnable() {
       
public void run() {
           
Bitmap b = loadImageFromNetwork("http://example.com/image.png");
            mImageView
.setImageBitmap(b);
       
}
   
}).start();
}

위의 코드는 네트워크 관련 작업을 처리하기 위해 쓰레드를 생성하고 있기 때문에 제대로 동작할 것처럼 보입니다. 하지만, 위에서 설명한 2가지 규칙 중 하나인 "UI 쓰레드가 아닌 쓰레드에서 안드로이드 UI 툴킷에 접근하면 안된다"는 규칙에 어긋난 코드이기 때문에, ImageView에 이미지를 셋팅하는 작업은 UI 쓰레드에서 하도록 수정해야 합니다. 

워커 쓰레드에서 UI 쓰레드를 실행하는 방법은 아래와 같이 몇가지가 있습니다:

  • Activity.runOnUiThread(Runnable)
  • View.post(Runnable)
  • View.postDelayed(Runnable, long)

따라서, 위의 잘못된 코드는 View.post(Runnable) 메소드를 이용하여 아래 코드와 같이 수정할 수 있습니다:

public void onClick(View v) {
   
new Thread(new Runnable() {
       
public void run() {
           
final Bitmap bitmap =
                    loadImageFromNetwork
("http://example.com/image.png");
            mImageView
.post(new Runnable() {
               
public void run() {
                    mImageView
.setImageBitmap(bitmap);
               
}
           
});
       
}
   
}).start();
}

이제 이 구현은 멀티 쓰레드에 안전(thread-safe)합니다. 네트워크 관련 작업은 별도의 쓰레드를 생성하여 실행하고, ImageView에 이미지를 셋팅하는 작업은 UI 쓰레드에서 실행합니다. 

하지만 복잡도가 증가하면, 코드도 복잡해지고 유지하기 어려워질 수 있습니다. 그리고 UI 쓰레드와 워커 쓰레드 간에 메시지를 전달해야 한다면 Handler를 사용할 수 있으며, AsyncTask를 사용하여 UI와의 상호작용을 위한 워커 쓰레드의 실행을 단순화할 수도 있습니다. 


AsyncTask 사용하기

AsyncTask는 비동기로 작업을 수행하고 나서 UI를 갱신하는 경우에 사용합니다. AsyncTask를 사용하면, 워커 쓰레드에서 시간이 오래 걸릴 수 있는 작업을 수행하고 그 결과를 UI 쓰레드로 보내주는데, 이때 쓰레드를 직접 다루지 않아도 되고, 핸들러(handler)도 따로 사용할 필요가 없습니다.

AsyncTask를 사용하기 위해서는, AsyncTask 클래스를 확장하는 서브클래스를 만들어서 doInBackground() 메소드를 구현해야 합니다. 그리고 onPostExecute() 메소드를 구현함으로써 doInBackground()의 결과를 전달 받아서 UI를 멀티쓰레드에 안전하게(thread-safe) 갱신할 수 있습니다. 마지막으로 AsyncTask 서브클래스에 대한 인스턴스의 execute() 메소드를 호출함으로써 실행할 수 있습니다. 

아래 예제 코드는, 위에서 쓰레드를 사용했던 예제에 대하여 AsyncTask를 사용하는 방식으로 수정한 것입니다:

public void onClick(View v) {
   
new DownloadImageTask().execute("http://example.com/image.png");
}

private class DownloadImageTask extends AsyncTask<String, Void, Bitmap> {
   
/** The system calls this to perform work in a worker thread and
      * delivers it the parameters given to AsyncTask.execute() */

   
protected Bitmap doInBackground(String... urls) {
       
return loadImageFromNetwork(urls[0]);
   
}

   
/** The system calls this to perform work in the UI thread and delivers
      * the result from doInBackground() */

   
protected void onPostExecute(Bitmap result) {
        mImageView
.setImageBitmap(result);
   
}
}
이제 워커 쓰레드에서 수행할 작업과 UI 쓰레드에서 수행할 작업이 분리되었기 때문에, UI는 안전해졌고 코드는 단순해졌습니다.

AsyncTask에 대한 자세한 내용은 클래스 문서를 참조해야겠지만, 아래와 같이 간단히 요약할 수 있습니다:

  • 매개변수, 프로그레스(progress) 변수, 비동기 처리에 대한 결과값에 대하여 제너릭(generic)을 이용해서 클래스 타입을 지정할 수 있습니다. 
  • AsyncTask를 실행하면 워커 쓰레드에서 doInBackground() 메소드를 자동적으로 실행해줍니다.
  • onPreExecute(), onPostExecute(), onProgressUpdate() 메소드들은 UI 쓰레드에서 실행됩니다.
  • doInBackground()에서 리턴하는 값은 onPostExecute()로 전달됩니다.
  • doInBackground()에서 원하는 시점에 publishProgress()를 호출하면, UI 쓰레드에서 onProgressUpdate()가 실행됩니다.
  • 실행된 태스크는 때(any time)와 장소(any thread)를 가리지 않고 취소시킬 수 있습니다. 

주의사항: 워커 쓰레드를 사용하는 경우 만날 수 있는 또 다른 문제는, 실행 중에 상태가 바뀔 때(예를 들면 디바이스가 가로 또는 세로 모드로 변경되는 경우) 액티비티가 재시작이 되면서 워커 쓰레드가 종료될 수도 있다는 것입니다. 따라서 태스크를 사용하는 경우, 액티비티가 종료될 때 태스크를 취소했다가 액티비티가 다시 시작될 때 태스크를 다시 실행하도록 해야 합니다. Shelves 샘플앱의 소스 코드를 참고하실 수 있습니다. 


멀티쓰레드에 안전한(Thread-safe) 메소드

경우에 따라서는 메소드가 둘 이상의 쓰레드에서 호출될 수도 있기 때문에, 메소드를 구현할 때는 멀티쓰레드에 안전하게(thread-safe) 구현해야 합니다. 

이것은, 액티비티에 바인드 된 서비스의 메소드처럼 원격으로 호출되는 메소드의 경우에 특히 더 그렇습니다. IBinder 인터페이스를 구현한 클래스의 메소드들을 IBinder 구현 객체가 실행되고 있는 동일한 프로세스에서 호출하는 경우, 해당 메소드들은 호출하는 쪽의 쓰레드에서 실행됩니다. 하지만 다른 프로세스에서 호출하는 경우에는, IBinder 구현 객체가 실행되고 있는 프로세스의 쓰레드 풀에서(UI 쓰레드는 제외) 시스템에 의해 선택된 쓰레드에서 해당 메소드들이 실행됩니다. 예를 들어, onBind() 메소드는 서비스가 속한 프로세스의 UI 쓰레드에서 실행되지만, onBind()에서 리턴해주는 IBinder 객체의 메소드들은 (UI 쓰레드가 아닌) 시스템이 선택한 쓰레드에서 실행되는 것입니다. 그리고, 서비스는 둘 이상의 클라이언트에게 바인드 될 수 있기 때문에, IBinder 객체의 메소드들은 둘 이상의 서로 다른 쓰레드에서 실행될 수 있습니다. 따라서 IBinder의 구현 클래스는 멀티 쓰레드에 안전(thread-safe)해야 합니다. 

이와 비슷하게, 컨텐트 프로바이더도 둘 이상의 프로세스로부터 데이터 요청을 받을 수 있습니다. 비록 ContentResolverContentProvider 클래스는 프로세스간 통신에 대한 부분을 숨겨놨지만(캡슐화), ContentProvider의 query(), insert(), delete(), update(), getType()과 같은 메소드들은 ContentProvider가 속한 프로세스의 쓰레드 풀에서 선택된 쓰레드(UI 쓰레드 제외)에서 실행됩니다. 이러한 메소드들도 둘 이상의 쓰레드에서 호출될 수 있기 때문에, 반드시 멀티 쓰레드에 안전(thread-safe)하게 구현되어야 합니다. 


프로세스간 통신(IPC)

안드로이드는 원격 프로시저 호출(RPC) 방식을 사용하여 프로세스간 통신(IPC)을 지원합니다. 이는 원격에서(다른 프로세스에서) 실행되는 액티비티 또는 다른 앱 컴포넌트에서의 호출을 의미하며, 그 호출에 대한 리턴값을 호출한 쪽에 전달해 줄 수 있습니다. IPC를 할 때는 메소드 호출과 그에 따른 데이터들을 OS가 이해할 수 있도록 분해하고, 호출하는 쪽의 프로세스로부터 전송하고, 호출받는 쪽의 프로세스에서 수신되어 재조립 및 재지정의 과정을 거치게 됩니다. 그리고 리턴값을 반대 방향으로 전송합니다. 안드로이드에는 이미 이러한 IPC에 대한 모든 코드가 구현되어 있기 때문에, 내 앱에서는 RPC 인터페이스에 대한 정의 및 구현에만 집중하면 됩니다. 

IPC를 수행하기 위해서는, 내 앱에서 bindService() 메소드를 호출함으로써 서비스를 바인드해야 합니다. 이에 대한 자세한 내용은 서비스에 대하여 문서에서 학습하실 수 있습니다. 


Posted by 개발자 김태우
,

(원문: http://developer.android.com/guide/topics/appwidgets/index.html)

(위치: Develop > API Guides > App Components > App Widgets)

(전체 번역 글 목록으로 이동하기)


앱 위젯


앱 위젯은 (런처의 홈 스크린처럼) 다른 앱에 들어가며 주기적으로 업데이트 될 수 있는 내 앱의 독립적인 작은 뷰를 말합니다. 이러한 앱 위젯은 앱 위젯 프로바이더를 이용하여 만들 수 있습니다. 그리고, 다른 앱의 위젯을 담을 수 있는 앱 컴포넌트를 앱 위젯 호스트라고 합니다. 아래의 이미지는 국내 유명 음악 앱의 위젯입니다.

본 문서에서는 앱 위젯 프로바이더를 이용하여 앱 위젯을 만드는 방법에 대해 학습할 것입니다. 그리고 앱 위젯 호스트(AppWidgetHost)를 만드는 방법에 대해서는 앱 위젯 호스트 문서에서 학습하실 수 있습니다. 


기본 개념

앱 위젯을 만들기 위해서는 아래의 몇가지가 필요합니다:

AppWidgetProviderInfo 객체

앱 위젯의 레이아웃, 업데이트 시기와 같은 메타정보와 AppWidgetProvider 클래스에 대한 정보를 가지고 있는 객체이며, XML 파일로 정의해야 합니다.

AppWidgetProvider 클래스 구현

앱 위젯에 대한 인터페이스들을 구현하는 클래스입니다. BroadcastReceiver 클래스를 확장한 클래스로서 브로드캐스트 이벤트들을 받아서 처리합니다. 앱 위젯이 업데이트되거나, 활성화 및 비활성화되거나, 삭제될때 브로드캐스트 이벤트를 받습니다. 따라서 이 클래스를 확장하여 내 앱의 위젯이 해당 이벤트들을 받았을때 어떻게 동작할지를 구현할 수 있습니다.

View 레이아웃

앱 위젯의 레이아웃을 XML 파일로 정의해야 합니다. 

그리고, 앱 위젯에 대한 설정 액티비티를 만들 수 있습니다. 이것은 선택적으로 적용 가능한 액티비티로서, 사용자가 앱 위젯을 화면에 추가할 때 실행되어 앱 위젯에 대한 설정을 할 수 있습니다. 

이어지는 섹션들에서는 위의 컴포넌트들 각각에 대하여 설명합니다.


매니페스트 파일에서 앱 위젯 선언하기

첫번째로, 내 앱의 AndroidManifest.xml 파일에 AppWidgetProvider 클래스를 선언합니다. 예제 코드:

<receiver android:name="ExampleAppWidgetProvider" >
   
<intent-filter>
       
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
   
</intent-filter>
   
<meta-data android:name="android.appwidget.provider"
               
android:resource="@xml/example_appwidget_info" />
</receiver>

<receiver> 요소의 android:name에는 앱 위젯에 대한 AppWidgetProvider 클래스를 지정해줘야 합니다. 

<intent-filter> 요소는 <action>을 포함해야하고, 이 <action>은 android:name 속성을 가지고 있어야 합니다. 위 예제는, ExampleAppWidgetProviderandroid.appwidget.action.APPWIDGET_UPDATE 액션을 갖는 인텐트에 대하여 동작한다는 것을 의미합니다. 이 액션(action)은 앱 개발자가 매니페스트 파일에 선언해야 하는 유일한 액션이며, 다른 브로드캐스트들은 AppWidgetManager에 의해 자동적으로 AppWidgetProvider에 전달됩니다. 

<meta-data> 요소는 AppWidgetProviderInfo에 대한 정보를 지정하며, 아래 속성들을 필요로 합니다:

  • android:name - 메타데이터의 이름을 지정하는 것으로서, AppWidgetProviderInfo에 대한 데이터임을 나타내기 위해 android.appwidget.provider를 사용합니다.
  • android:resource - AppWidgetProviderInfo에 대한 리소스 파일을 지정합니다.


AppWidgetProviderInfo 메타정보 추가하기

AppWidgetProviderInfo는 앱 위젯의 본질에 대하여 정의합니다. 예를 들어, 앱 위젯의 최소 크기, 레이아웃, 업데이트 빈도, 그리고 (선택적으로) 위젯 생성시점에 실행할 설정 액티비티를 정의합니다. 이것은 하나의 <appwidget-provider> 요소를 갖는 XML 파일이며 res/xml/ 경로상에 저장됩니다.

예제 코드:

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
   
android:minWidth="40dp"
   
android:minHeight="40dp"
   
android:updatePeriodMillis="86400000"
   
android:previewImage="@drawable/preview"
   
android:initialLayout="@layout/example_appwidget"
   
android:configure="com.example.android.ExampleAppWidgetConfigure"
   
android:resizeMode="horizontal|vertical"
   
android:widgetCategory="home_screen|keyguard"
   
android:initialKeyguardLayout="@layout/example_keyguard">
</appwidget-provider>

<appwidget-provider> 요소의 각 속성들에 대하여 설명합니다:

  • minWidthminHeight 속성은 앱 위젯의 (기본적인, 최초의) 최소 크기를 지정합니다. 기본 홈 화면은 앱 위젯을 윈도우에 배치할 때 그리드 형태의 셀들에 기반하여 배치합니다. 만약 앱 위젯의 minWidth나 minHeight가 셀의 크기와 일치하지 않는다면, 근처의 셀이 앱 위젯의 영역으로 사용됩니다. 
    메모: 앱 위젯이 여러 디바이스에 호환되려면, 앱 위젯의 최소 크기가 4x4를 넘어가지 않도록 해야합니다.
  • minResizeWidthminResizeHeight 속성은 앱 위젯의 (절대적인) 최소 크기를 지정합니다. 이 값들은 앱 위젯이 너무 작아져서 내용들이 잘 보이지 않거나 사용하기 어려울 정도가 되지 않도록 지정해두는 최소값입니다. 이 값들을 이용하면 (사용자에 의해) 앱 위젯이 minWidth와 minHeight에 지정된 크기보다 더 작게 리사이즈 될 수 있습니다. (이 속성들은 안드로이드 3.1에서 소개되었습니다.)
  • updatePeriodMillis 속성은 AppWidgetProvider의 onUpdate() 콜백 메소드가 호출되는 주기를 정의합니다. 하지만 실제 업데이트 시기가 정확히 지정된 시간을 따른다고 보장할 수는 없습니다. 그리고 가능하다면 배터리의 사용량을 줄이기 위해서 한시간에 한번 이상은 업데이트를 하지 않는 것이 좋습니다. 이는 설정에서 사용자가 직접 지정하도록  할 수 있습니다. 어떤 사용자는 15분마다 업데이트 되는 주식 시세 표시 위젯을 원할 수도 있고, 또 어떤 사용자는 하루에 4번만 업데이트 되는 위젯을 원할 수도 있기 때문입니다. 
    메모: 만약 updatePeriodMillis에 지정한 시간이 지나 업데이트할 시간이 되었을 때 디바이스가 슬립 상태(화면이 꺼져 있는 상태)라면, 디바이스는 업데이트를 수행하기 위해 화면을 깨웁니다. 만약 한시간에 한번 이상 업데이틀 하지 않도록 했다면, 배터리 소모에 큰 악영향은 없겠지만, 자주 업데이트를 해야 하거나, 디바이스가 슬립 상태일 때는 업데이트를 하지 않아야 한다면, 알람(alarm)을 기반으로 개발하는 것이 나을 것입니다. 그러기 위해서는 일단 updatePeriodMillis 값을 0으로 지정하여 주기적 업데이트를 비활성화 한 후, AlarmManager를 이용하여 AppWidgetProvider가 받을 인텐트로 알람을 셋팅하면 됩니다. 알람 타입을 ELAPSED_REALTIME 또는 RTC로 지정하면 디바이스가 깨어날때만 알람이 전달됩니다.
  • initialLayout 속성은 앱 위젯의 레이아웃을 정의하는 xml 리소스 파일을 가리킵니다.
  • configure 속성은 사용자가 앱 위젯을 추가할 때 위젯 관련된 값들을 설정하기 위해 실행되는 액티비티를 정의합니다. 이것은 선택적인 사항이며, 아래의 앱 위젯의 설정 액티비티 만들기에서 자세히 설명합니다.
  • previewImage 속성은 앱 위젯이 어떤 모습일지 위젯을 고를 때 보여주는 이미지를 정의하며, 이 값이 없으면 앱 아이콘으로 대체됩니다. 이 속성은 안드로이드 3.0 버전부터 추가되었으며, 자세한 내용은 미리보기 이미지 설정하기에서 학습할 수 있습니다.
  • autoAdvanceViewId 속성은 위젯의 호스트에 의해 자동으로 변형되는 (auto-scaled) 뷰에 대한 ID를 지정합니다. (안드로이드 3.0 버전부터 추가되었습니다.)
  • resizeMode 속성은 위젯이 사용자에 의해 리사이즈 되는 것과 관련된 값을 지정하며, "horizontal"이나 "vertical"이나 "none"이 들어갈 수 있습니다. 위젯을 누르고 있으면 리사이즈 핸들이 나타나게 되고, 좌우(horizontal) 또는 상하(vertical)로 드래그하여 크기를 조정할 수 있습니다. 상하좌우 모두 리사이즈 되도록 하기 위해서는 "horizontal|vertical" 로 지정하면 됩니다. (안드로이드 3.0 버전부터 추가되었습니다.)
  • minResizeHeight 속성은 위젯이 리사이즈 될 수 있는 최소 높이를 지정합니다(dp단위로 지정). 이 값이 minHeight보다 크거나, resizeMode에 "vertical" 설정이 되어 있지 않으면 리사이즈 되지 않습니다. (안드로이드 4.0 버전부터 추가되었습니다.)
  • minResizeWidth 속성은 위젯이 리사이즈 될 수 있는 최소 너비를 지정합니다. 이 값이 minWidth보다 크거나, resizeMode에 "horizontal" 설정이 되어 있지 않으면 리사이즈 되지 않습니다. (안드로이드 4.0 버전부터 추가되었습니다.)
  • widgetCategory 속성은 앱 위젯을 홈화면에 노출할지("home_screen"), 잠금화면에 노출할지("keyguard"), 또는 둘 다 노출할지("home_screen|keyguard")를 선언합니다. 둘 다 노출되는 위젯은 위젯 클래스 작성시 디자인 가이드라인을 준수해야 하며, 자세한 내용은 잠금화면에 앱 위젯 보이기에서 학습할 수 있습니다. 이 속성의 기본값은 "home_screen"입니다. (안드로이드 4.2 버전부터 추가되었습니다. 안드로이드 5.0 버전에서 deprecated 된 것으로 보입니다.)
  • initialKeyguardLayout 속성은 잠금화면에 보여줄 앱 위젯의 레이아웃 xml 파일을 가리킵니다. 이것은 android:initialLayout과 같은 방식으로 동작합니다. 즉, 앱 위젯이 초기화될 때 보여지며 업데이트 될 수 있는 레이아웃입니다. (잠금화면용 레이아웃을 따로 지정할 수 있다는 것인데, 안드로이드 4.2 버전부터 추가되었습니다. 안드로이드 5.0 버전에서 deprecated 된 것으로 보입니다.)


앱 위젯 레이아웃 만들기

앱 위젯을 만들기 위해서는 res/layout 디렉토리에 xml 레이아웃 파일이 정의되어 있어야 하며, 아래 나열된 뷰 객체들을 사용할 수 있습니다. 

앱의 레이아웃을 만드는데 익숙하다면 앱 위젯의 레이아웃을 만드는 것도 어렵지 않습니다. 다만, 앱 위젯의 레이아웃은 RemoteViews를 기반으로 한다는 데에 주의해야 하며, 이는 모든 종류의 뷰나 레이아웃 클래스를 지원하지는 않는다는 것을 의미합니다. 

RemoteViews 객체는 아래의 레이아웃 클래스들을 지원하고:

FrameLayout
LinearLayout
RelativeLayout
GridLayout

아래의 뷰 클래스들을 지원합니다:

AnalogClock
Button
Chronometer
ImageButton
ImageView
ProgressBar
TextView
ViewFlipper
ListView
GridView
StackView
AdapterViewFlipper

위에 나열된 클래스들 이외의 클래스들은 지원되지 않으며, 심지어 위의 클래스들을 상속받아 만든 커스텀뷰도 지원되지 않습니다. 

RemoteViews는 ViewStub 도 지원하는데, 이것은 처음에는 보이지 않고 크기가 없는 상태였다가 나중에 필요시 런타임에 생성되는 (lazy inflate) 뷰입니다. 


앱 위젯에 여백 추가하기

위젯은 일반적으로 스크린 경계선까지 꽉 채워져서는 안되고 다른 위젯과 겹쳐서도 안되기 때문에, 위젯의 모든 경계 부분에는 여백(margin)이 있어야 합니다. 

안드로이드 4.0 버전부터는 앱 위젯이 홈 화면의 다른 위젯이나 앱 아이콘들과 더 잘 구분되어 보이도록 하기 위해, 위젯의 경계 부분과 위젯이 들어가는 영역의 경계 부분 사이에 공간(padding, 이하 패딩으로 지칭)을 자동으로 만들어 줍니다. 따라서 이와 같은 유용한 기능을 이용하기 위해서는 앱의 매니페스트 파일의 targetSdkVersion14 이상으로 선언해야 합니다. 

sdk 버전이 14 미만(안드로이드 4.0버전 미만)일 때는 자체적으로 정의한 패딩을 지정하고, 14 이상(안드로이드 4.0버전 이상)일 때는 자동으로 만들어진 패딩 이외에 패딩이 더 추가되지 않도록 하기 위해서 아래와 같이 하면 됩니다.

1. 앱의 매니페스트 파일의 targetSdkVersion14 또는 그 이상으로 선언합니다.

2. 아래와 같이 레이아웃 파일을 작성합니다. android:padding@dimen/widget_margin을 넣어줍니다. 

<FrameLayout
 
android:layout_width="match_parent"
 
android:layout_height="match_parent"
 
android:padding="@dimen/widget_margin">

 
<LinearLayout
   
android:layout_width="match_parent"
   
android:layout_height="match_parent"
   
android:orientation="horizontal"
   
android:background="@drawable/my_widget_background">
    …
 
</LinearLayout>

</FrameLayout>

3. 두 개의 디멘션(dimension) 리소스를 생성하는데, 하나는 안드로이드 4.0버전 미만에서 자체 패딩을 주기 위해 res/values 에 생성하고, 다른 하나는 안드로이드 4.0버전 이상에서 자체 패딩을 주지 않기 위해 res/values-v14 에 생성합니다:

res/values/dimens.xml:

<dimen name="widget_margin">8dp</dimen>

res/values-v14/dimens.xml:

<dimen name="widget_margin">0dp</dimen>


다른 방법으로는, 위젯의 배경을 나인패치(nine-patch) 이미지로 설정하되 안드로이드 4.0버전 미만과 그 이상을 위한 이미지를 따로 준비하면 됩니다. 


AppWidgetProvider 클래스 사용하기

AppWidgetProvider 클래스는 앱 위젯의 브로드캐스트들을 편리하게 다루기 위해 BroadcastReceiver 클래스를 상속받아 구현된 클래스입니다. AppWidgetProvider는 앱 위젯이 업데이트되거나, 삭제되거나, 활성화/비활성화될 때와 같이 위젯과 관련된 이벤트가 발생할 때 그에 대한 브로드캐스트를 받으며, 호출되는 콜백 메소드들은 아래와 같습니다:

onUpdate()

이 메소드는 AppWidgetProviderInfo에 정의된 updatePeriodMillis 만큼의 시간간격으로 주기적으로 호출되는 콜백 메소드입니다. 이 메소드는 또한 사용자가 위젯을 화면에 추가할때도 호출되며, 필요하다면 여기서 뷰에 이벤트 리스너를 달거나 서비스(Service component)를 시작하는 작업을 해야 합니다. 하지만 만약 앱 위젯에 대한 설정 액티비티를 선언했다면, 사용자가 위젯을 화면에 추가하는 시점에는 이 메소드가 호출되지 않으며, 위젯이 화면에 추가된 후에야 다시 주기적으로 호출됩니다. 따라서 설정 액티비티가 선언되어 있다면, 뷰에 이벤트 리스너를 달거나 하는 등의 초기화 작업을, 설정 액티비티에서 설정이 완료되는 시점에 하도록 해야 합니다. (앱 위젯의 설정 액티비티 만들기 참고.)

onAppWidgetOptionsChanged()

이 메소드는 위젯이 화면에 추가되어 처음 보여지거나 리사이즈될 때마다 호출되는 콜백 메소드이며, 위젯의 크기에 따라 내용을 변경해야 하는 경우 사용합니다. getAppWidgetOptions() 메소드를 호출하면 번들(Bundle)을 리턴해 주는데 여기에서 아래의 값들을 얻을 수 있습니다:

    • OPTION_APPWIDGET_MIN_WIDTH - 위젯의 현재 너비에 대한 작은 쪽 경계의 크기. dp 단위.
    • OPTION_APPWIDGET_MIN_HEIGHT - 위젯의 현재 높이에 대한 작은 쪽 경계의 크기. dp 단위.
    • OPTION_APPWIDGET_MAX_WIDTH - 위젯의 현재 너비에 대한 큰 쪽 경계의 크기. dp 단위.
    • OPTION_APPWIDGET_MAX_HEIGHT - 위젯의 현재 높이에 대한 큰 쪽 경계의 크기. dp 단위. 

이 메소드는 API 레벨 16(안드로이드 4.1버전)부터 추가되었습니다. 따라서 이 메소드를 구현하고자 한다면, 하위 버전의 디바이스에서 이 메소드가 호출되지 않는다는 점에 주의해야 합니다. 

onDeleted(Context, int[])

이 메소드는 앱 위젯이 앱 위젯 호스트에서 삭제될 때 호출되는 콜백 메소드입니다.

onEnabled(Context)

이 메소드는 앱 위젯의 인스턴스가 처음 생성될 때 호출되는 콜백 메소드입니다. 예를 들어, 사용자가 어떤 하나의 앱에 대하여 2개의 앱 위젯을 화면에 추가할 경우, 이 메소드는 첫번째 위젯이 추가될 때만 호출됩니다. 위젯에 대한 데이터베이스를 생성하거나, 해당 앱에 대한 모든 위젯에 대하여 한번만 실행되기를 바라는 부분이 있다면, 여기에 구현하면 됩니다. 

onDisabled(Context)

이 메소드는 앱 위젯 호스트에서 모든 위젯이 삭제되는 시점, 즉 마지막으로 남아있던 위젯이 삭제될 때 호출되는 콜백 메소드입니다. 여기서는 onEnabled(Context)에서 했던 일들을 정리해야(clean up) 합니다(데이터베이스 삭제 등).

onReceive(Context, Intent)

이 메소드는 BroadcastReceiver 클래스의 기본적인 콜백 메소드로서, AppWidgetProvider 클래스에서 적당한 시점에 위의 콜백 메소드들이 호출되도록 잘 구현되어 있기 때문에, 보통의 경우에는 구현할 필요가 없고, 위의 콜백 메소드들로 충분하지 않을 경우에만 구현하면 됩니다. 

AppWidgetProvider의 가장 중요한 콜백 메소드는 onUpdate() 입니다. 왜냐하면 (설정 액티비티가 선언되어 있지 않다면) 앱 위젯이 호스트에 추가될 때 호출되는 메소드이며, 여기서 위젯의 초기화 작업 및 변경사항 적용을 해야하기 때문입니다. 만약 위젯이 사용자와 상호작용을 해야 한다면, 이 콜백 메소드에서 이벤트 리스너를 등록해야 합니다. 그리고 만약 앱 위젯이 파일이나 데이터베이스를 생성하지 않고, 그 외에도 위젯이 삭제될 때 정리해야할(clean up) 무언가가 없다면, AppWidgetProvider 개발시 onUpdate()만 구현하면 됩니다. 아래의 예제 코드는, 눌렀을 때 액티비티를 실행하는 버튼으로 구성된 AppWidgetProvider의 구현 클래스입니다: 

public class ExampleAppWidgetProvider extends AppWidgetProvider {

   
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
       
final int N = appWidgetIds.length;

       
// Perform this loop procedure for each App Widget that belongs to this provider
       
for (int i=0; i<N; i++) {
           
int appWidgetId = appWidgetIds[i];

           
// Create an Intent to launch ExampleActivity
           
Intent intent = new Intent(context, ExampleActivity.class);
           
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0);

           
// Get the layout for the App Widget and attach an on-click listener
           
// to the button
           
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.appwidget_provider_layout);
            views
.setOnClickPendingIntent(R.id.button, pendingIntent);

           
// Tell the AppWidgetManager to perform an update on the current app widget
            appWidgetManager
.updateAppWidget(appWidgetId, views);
       
}
   
}
}
위의 AppWidgetProvider는 onUpdate() 콜백 메소드만 구현하고 있으며, 버튼 눌렀을 때 액티비티가 실행되도록 하기 위하여 setOnClickPendingIntent(int, PendingIntent)를 호출합니다. 그리고 화면에 ExampleAppWidgetProvider에 대한 위젯이 2개 이상 추가될 수 있기 때문에, 반복문을 통해 appWidgetIds의 모든 값에 대하여, 즉 모든 위젯에 대하여 업데이트 되는 내용을 적용해야 합니다. 그리고 위젯이 2개 이상일 경우에도 onUpdate()는 모든 위젯에 대하여 같은 시기에 호출됩니다. 예를 들어서 updatePeriodMillis가 2시간으로 정의되어 있고, 첫번째 위젯을 추가한 후 1시간 후에 두번째 위젯을 추가한 경우, onUpdate()는 첫번째 위젯이 추가된 시점을 기준으로 2시간 간격으로 호출되는 것입니다.  
메모: AppWidgetProvider는 BroadcastReceiver 클래스를 확장하여 만든 클래스이기 때문에, (BroadcastReceiver의 생명주기에 따라) 콜백 메소드가 실행된 후에 내 앱의 프로세스가 멈출 수도 있습니다. 그리고 앱 위젯의 셋업 과정이 (웹 요청 등의 원인으로) 오래 걸리는 경우에는 앱의 응답 지연 문제(Application Not Responding, ANR)를 방지하기 위하여, onUpdate() 메소드 안에서 서비스(Service)를 실행해 주는 것이 좋습니다. 


앱 위젯의 브로드캐스트 인텐트 받기

AppWidgetProvider는 브로드캐스트 인텐트의 액션값을 신경쓰지 않고 지정된 콜백 메소드들만 구현해 주면 되는 편리한 클래스입니다. 하지만 만약 브로드캐스트 인텐트를 직접 처리하고자 한다면, BroadcastReceiver의 확장 클래스를 만들거나 AppWidgetProvider의 onReceive(Context, Intent) 메소드를 오버라이드한 후 브로드캐스트 인텐트의 액션값에 대한 처리를 해주면 됩니다. 그 액션값들은 아래와 같습니다:

  • ACTION_APPWIDGET_UPDATE
  • ACTION_APPWIDGET_DELETED
  • ACTION_APPWIDGET_ENABLED
  • ACTION_APPWIDGET_DISABLED
  • ACTION_APPWIDGET_OPTIONS_CHANGED


앱 위젯의 설정 액티비티 만들기

사용자가 앱 위젯을 추가할 때 설정 액티비티를 보여줄 수 있습니다. 이 액티비티는 앱 위젯이 생성되는 시점에 앱 위젯 호스트에 의해 자동적으로 실행되며, 위젯의 색상이나 크기, 위젯 갱신 주기 등의 설정을 할 수 있습니다. 

설정 액티비티는 다른 액티비티들과 마찬가지로 매니페스트 파일에 정의되어야 하며, 앱 위젯 호스트에서 ACTION_APPWIDGET_CONFIGURE 액션값을 가진 암묵적 인텐트로 실행하기 때문에, 아래 예제 코드와 같이 해당 액션값에 대한 인텐트 필터를 추가해줘야 합니다:

<activity android:name=".ExampleAppWidgetConfigure">
   
<intent-filter>
       
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
   
</intent-filter>
</activity>

그리고 설정 액티비티는 AppWidgetProviderInfo XML파일의 android:configure 에도 선언되어야 합니다(위의 AppWidgetProviderInfo 메타정보 추가하기 참고). 예제 코드는 아래와 같습니다:

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    ...
   
android:configure="com.example.android.ExampleAppWidgetConfigure"
    ...
>
</appwidget-provider>

액티비티의 이름은 패키지명을 포함한 전체 이름으로 선언해야 합니다. 왜냐하면 이는 내 앱의 범위 밖에서 참조되는 값이기 때문입니다. 

설정 액티비티를 만들기 위한 사전 작업은 위의 내용이 다입니다. 이제 설정 액티비티를 구현하면 되는데, 이때 기억해야할 중요한 내용이 두 개 있습니다:

  • 앱 위젯 호스트는 설정 액티비티를 실행할 때 startActivityForResult()를 호출하며, 설정 액티비티는 종료되기 전에 결과 인텐트를 셋팅해줘야 합니다. 결과 인텐트에는 앱 위젯 ID가 포함되어 있어야 하는데, 이는 설정 화면이 실행될 때 받는 인텐트에 EXTRA_APPWIDGET_ID로 담겨 있습니다. 그리고 결과 인텐트에도 같은 키값으로 저장하면 됩니다. 
  • 설정 액티비티가 실행된 경우에는, 앱 위젯이 생성될 때, 시스템이 ACTION_APPWIDGET_UPDATE 을 보내지 않기 때문에 onUpdate() 메소드가 호출되지 않습니다. 따라서, 설정 액티비티에서 그 역할을 해줘야 합니다. 위젯이 처음 생성되는 시점에 설정 액티비티에서  AppWidgetManager를 이용하여 업데이트 요청을 해야 합니다. 하지만 그 이후부터는 onUpdate()가 다시 호출됩니다. 즉, 앱 위젯이 생성되는 처음 1회만 onUpdate()가 스킵되는 것입니다. 

이어지는 내용은 설정 화면에서 위젯에 대하여 설정한 결과들을 위젯 호스트에게 어떻게 다시 전달해 주는지에 대한 내용입니다.


설정 액티비티에서 앱 위젯 업데이트하기

설정 액티비티가 실행되는 경우에는, 설정이 완료되었을 때 설정 액티비티에서 직접 위젯을 업데이트 해줘야 합니다. 이때 AppWidgetManager를 사용합니다. 

위젯을 업데이트하고 설정 액티비티를 종료하는 일련의 과정은 아래와 같습니다:

1. 설정 액티비티를 실행하는 인텐트로부터 앱 위젯 ID를 전달 받습니다:

Intent intent = getIntent();
Bundle extras = intent.getExtras();
if (extras != null) {
    mAppWidgetId
= extras.getInt(
           
AppWidgetManager.EXTRA_APPWIDGET_ID,
           
AppWidgetManager.INVALID_APPWIDGET_ID);
}

2. 설정 액티비티 내에서 위젯 관련 설정이 진행됩니다.

3. 설정이 완료되었다면, AppWidgetManager의 인스턴스를 가져옵니다:

AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);

4. 아래와 같이 앱 위젯을 업데이트 합니다:

RemoteViews views = new RemoteViews(context.getPackageName(),
R
.layout.example_appwidget);
appWidgetManager
.updateAppWidget(mAppWidgetId, views);

5. 결과 인텐트를 만들어서 셋팅하고 설정 액티비티를 종료합니다:

    Intent resultValue = new Intent();
    resultValue
    .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId);
    setResult
    (RESULT_OK, resultValue);
    finish
    ();

팁: 설정이 완료되지 않고 설정 액티비티에서 벗어나는 경우에는 결과값에 RESULT_CANCELED를 셋팅합니다. 그러면 앱 위젯 호스트는 설정이 취소되었다는 결과를 받게 되고 앱 위젯은 화면에 추가되지 않을 것입니다. 

SDK에 포함되어 있는 샘플 프로젝트 중 하나인 ApiDemos에서 ExampleAppWidgetConfigure.java 파일을 참고하시기 바랍니다.


미리보기 이미지 설정하기

안드로이드 3.0 버전부터 앱 위젯이 어떻게 생겼는지를 나타내는 미리보기 이미지를 설정할 수 있으며, 이는 위젯 선택 화면에서 보여집니다. 미리보기 이미지를 따로 지정하지 않는 경우에는 앱의 아이콘이 그 역할을 대신합니다. 

예제 코드는 아래와 같습니다:

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
  ...
 
android:previewImage="@drawable/preview">
</appwidget-provider>

앱 위젯의 미리보기 이미지를 쉽게 생성해주기 위해 안드로이드 에뮬레이터에는 "Widget Preview"라는 앱이 포함되어 있습니다. 이 앱을 실행한 후 위젯을 선택하여 이미지를 생성할 수 있습니다. 


잠금화면에 앱 위젯 보이기

안드로이드 4.2버전부터 위젯을 잠금화면에도 추가할 수 있게 되었습니다. 앱 위젯이 잠금화면에도 추가될 수 있도록 하기 위해서는, AppWidgetProviderInfo를 정의하는 XML 파일에 android:widgetCategory를 선언해줘야 합니다. 여기에 들어갈 수 있는 값으로는 "home_screen"과 "keyguard"가 있고, 하나 또는 두개 모두 들어갈 수 있으며, 잠금화면에 들어가려면 "keyguard"가 포함되어 있어야 합니다. 

( * 역자주: 잠금화면에 앱 위젯을 추가하는 기능은 안드로이드 5.0버전에서 deprecated 된 것으로 보입니다. 4.2~4.4버전에서 잠금화면에 위젯을 추가하는 방법은, 잠금화면의 상단영역을 좌에서 우로 스와잎하면 위젯을 추가할 수 있는 플러스(+) 버튼이 나오는데, 5.0버전 이상에서는 해당 기능이 동작하지 않습니다. 그리고 설정 > 개인설정 > 보안 > 화면보안 섹션에 위젯 사용에 대한 항목이 있는데, 5.0버전 이상에서는 해당 항목이 보이지 않습니다. 5.0버전 이상에서는 노티피케이션바 영역에 들어가는 뷰 영역이 잠금화면에도 보여지기 때문에, 위젯을 대체할 수 있다고 판단하여 deprecated 된 것으로 보입니다. )

기본적으로 위젯은 홈 화면에 들어가는 것이었기 때문에, android:widgetCategory의 기본값은 "home_screen"입니다. 따라서 위젯이 홈 화면에 들어갈 수 있고 또 잠금화면에도 들어갈 수 있게 하려면, 아래와 같이 "keyguard"를 추가하면 됩니다:

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
   ...
   
android:widgetCategory="keyguard|home_screen">
</appwidget-provider>

위젯을 잠금화면과 홈화면에 모두 보여주고자 하는데 각각의 위젯에 다른 레이아웃을 적용하고 싶다면, 위젯이 추가되는 시점에 위젯 카테고리(widgetCategory)를 확인하여 각각에 맞는 레이아웃을 적용하면 됩니다. getAppWidgetOptions()를 호출하면 Bundle 객체를 리턴해 주는데, 여기에 OPTION_APPWIDGET_HOST_CATEGORY를 키로 하는 값이 WIDGET_CATEGORY_HOME_SCREEN 또는 WIDGET_CATEGORY_KEYGUARD 입니다. 이 값은 위젯이 속해 있는 곳의 위젯 호스트에 의해서 결정되는 값입니다. 그리고 아래 예제 코드에서와 같이 AppWidgetProvider에서 값을 확인할 수 있습니다:

AppWidgetManager appWidgetManager;
int widgetId;
Bundle myOptions = appWidgetManager.getAppWidgetOptions (widgetId);

// Get the value of OPTION_APPWIDGET_HOST_CATEGORY
int category = myOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_HOST_CATEGORY, -1);

// If the value is WIDGET_CATEGORY_KEYGUARD, it's a lockscreen widget
boolean isKeyguard = category == AppWidgetProviderInfo.WIDGET_CATEGORY_KEYGUARD;

위젯 카테고리 값이 확인되었다면 이제 그에 따라 레이아웃을 다르게 적용할 수도 있는 것입니다:

int baseLayout = isKeyguard ? R.layout.keyguard_widget_layout : R.layout.widget_layout;

그리고 잠금화면에 보여질 위젯에 대한 레이아웃을 android:initialKeyguardLayout에 지정해야 합니다. 이는 잠금화면용 레이아웃이라는 것만 빼면 나머지는 android:initialLayout과 동일합니다. 위젯이 초기화되기까지 시간이 다소 걸린다면 그 동안에 보여줄 레이아웃이 여기에 들어갑니다. 


크기 조정 가이드라인

잠금화면에 추가된 위젯에 대해서는 minWidth, minHeight, minResizeWidth, minResizeHeight 속성이 무시됩니다. 홈화면에 추가된 위젯에 대해서는 여전히 적용되는 속성이지만, 잠금화면에 추가된 위젯에 대해서만 무시되는 것입니다. 

잠금화면 위젯의 너비(width)는 항상 제공되는 영역을 꽉 채웁니다. 그리고 잠금화면 위젯의 높이(height)는 아래의 규칙들을 따릅니다:

  • 위젯이 위아래로 리사이즈 가능하다고 정의되어 있지 않은 경우(android:resizeMode에 vertical이 포함되어 있지 않은 경우), 위젯의 높이는 항상 "작은" 상태가 됩니다. 
    - 세로 모드(portrait mode)의 폰에서, "작은" 상태란 잠금해제 UI가 보여지는 영역을 제외한 나머지 영역을 의미합니다. 
    - 태블릿 또는 가로 모드(landscape mode)의 폰에서, "작은" 상태란 디바이스에 따라 다르게 정의될 수 있습니다. 
  • 위젯이 위아래로 리사이즈 가능하다고 정의되어 있는 경우, 세로 모드의 폰에서는 위젯의 높이가 "작은" 상태가 되고, 그 외의 모든 경우에는 가능한 공간을 꽉 채웁니다. 


앱 위젯에 목록 보이기

안드로이드 3.0 버전부터 앱 위젯에 목록(collections)을 보여줄 수 있게 되었습니다. 컨텐트 프로바이더와 같은 것들로부터 데이터를 받아와서 그 목록을 보여주기 위해 RemoteViewsService 를 사용할 수 있습니다. RemoteViewsService로부터 제공받는 데이터들을 보여주기 위해서 아래의 뷰들 중 하나를 사용할 수 있으며, 아래의 뷰들을 이제 "콜랙션 뷰(collection views)"라고 지칭하도록 하겠습니다. 

ListView

하나의 행에 하나의 항목이 있고, 위아래로 스크롤되는 뷰입니다. Gmail 앱의 위젯이 그 예입니다.

GridView

하나의 행에 하나 이상의 항목이 있을 수 있고, 위아래로 스크롤되는 뷰입니다. 북마크 앱의 위젯이 그 예입니다.

StackView

카드가 겹쳐져 있는 형태이며 위아래로 스와잎하면 가장 위의 카드가 뒤로 넘어가는 방식의 뷰입니다. 유튜브나 도서 앱의 위젯이 그 예입니다. 

AdapterViewFlipper

2개 이상의 뷰들을 ViewAnimator로 애니메이션하는 방식의 뷰이며, 한번에 하나의 뷰만이 화면에 보여집니다. 

위에서 언급한 바와 같이, 이러한 콜렉션 뷰들은 어댑터(Adapter)를 이용하여 다른 곳으로부터 데이터를 제공 받아서 보여줍니다. 어댑터는 데이터셋과 그에 대응되는 뷰 사이에서 중간자 역할을 하며 그 둘을 연결시켜 줍니다. 콜렉션 뷰들이 어댑터를 기반으로 해야하기 때문에, 안드로이드 프레임웍에는 위젯에서도 이러한 방식이 적용될 수 있도록 별도의 모듈이 포함되어 있어야 합니다. 기존의, 앱 내에 콜렉션 뷰를 보여줄 때의 방식은 RemoteViews를 사용하는 것이 아니기 때문에, 그에 대한 방식이 따로 있어야 하는 것입니다. 따라서 앱 위젯에서는 Adapter 대신에 Adapter 인터페이스를 래핑(wrapping)한 RemoteViewsFactory를 사용합니다. 동작하는 방식은 기존에 Adapter를 사용하던 방식과 유사합니다. RemoteViewsFactory가 항목의 위치를 받아서 그에 해당하는 아이템을 찾고 그 데이터를 RemoteViews 객체에 적용하여 화면에 보여주는 것입니다. 따라서 앱 위젯에 콜렉션 뷰를 사용하기 위해서는 RemoteViewsServiceRemoteViewsFactory를 구현해야 합니다. 

RemoteViewsService는 RemoteViewsFactory 인터페이스의 구현체인 어댑터를 가지고 있으며, 그 어댑터가 각 위치에 해당하는 RemoteViews 객체를 리턴해 줍니다. RemoteViewsFactory는 (ListView나 GridView와 같은) 콜렉션 뷰와 콜렉션 뷰에 보여줘야할 데이터 셋을 중간에서 연결해 주는 어댑터에 대한 인터페이스입니다. StackView Widget 샘플앱을 살펴보면, 아래와 같은 형태로 구현하고 있음을 알 수 있습니다:

public class StackWidgetService extends RemoteViewsService {
   
@Override
   
public RemoteViewsFactory onGetViewFactory(Intent intent) {
       
return new StackRemoteViewsFactory(this.getApplicationContext(), intent);
   
}
}

class StackRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {

//... include adapter-like methods here. See the StackView Widget sample.

}


샘플 앱

본 문서에 있는 예제 코드들은 StackView Widget 샘플앱으로부터 가져온 것들입니다:


위의 샘플 앱 위젯은 "0!"부터 "9!"까지의 문자를 보여주는 10개의 뷰의 스택으로 구성되어 있으며, 아래와 같이 동작합니다:

  • 사용자는 가장 앞에 있는 뷰를 아래로 플링(fling)하여 다음 뷰를 앞으로 가져오거나, 위로 플링하여 이전 뷰를 앞으로 가져올 수 있으며, 이는 StackView의 기본적인 동작입니다. 
  • 사용자가 뷰를 건드리지 않더라도, 슬라이드쇼처럼 적당한 시간을 주기로 자동으로 다음뷰로 넘어가도록 할 수 있습니다. 이는 res/xml/stackwidgetinfo.xml 파일에 android:autoAdvanceViewId="@id/stack_view"와 같이 선언하면 되며, @id/stack_view는 위젯 레이아웃의 StackView의 ID입니다.  
  • 사용자가 뷰를 클릭하면 "Touched view n" 이라는 토스트 메시지가 출력되며, 여기서 n은 터치된 뷰의 위치값을 나타냅니다. 이와 관련하여 더 자세한 내용은 각 항목에 기능 추가하기에서 학습하실 수 있습니다. 


목록을 보이게 하는 앱 위젯 구현하기

앱 위젯에서 목록을 보여주기 위해서는, (위에서 학습한 바와 같이) 앱 위젯을 구현하는데 필요한 일반적인 단계에 더하여, 아래에서 설명하는 추가적인 단계들을 진행해야 합니다. 


목록 구현을 위한 매니페스트 파일 수정사항

매니페스트 파일에서 앱 위젯 선언하기 섹션에서 학습한 내용에 더하여, 앱 위젯이 RemoteViewsService를 사용하여 목록을 보여주도록 하기 위해서는, 매니페스트 파일에 서비스(service)를 BIND_REMOTEVIEWS 퍼미션과 함께 선언해줘야 합니다. 이것은 다른 앱이 내 앱 위젯의 데이터에 접근하는 것을 막아줍니다. 예제 코드는 아래와 같습니다:

<service android:name="MyWidgetService"
...
android:permission="android.permission.BIND_REMOTEVIEWS" />

위 코드에서 MyWidgetService는 RemoteViewsService의 서브 클래스입니다. 


목록 구현을 위한 앱 위젯의 레이아웃

목록을 보여주고자 하는 앱 위젯의 레이아웃에는 콜렉션 뷰 중의 하나가 포함되어 있어야 합니다. 콜렉션 뷰에는 ListView, GridView, StackView, AdapterViewFlipper가 있습니다. 아래 예제 코드는 StackView Widget 샘플widget_layout.xml 파일입니다:

<?xml version="1.0" encoding="utf-8"?>

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
   
android:layout_width="match_parent"
   
android:layout_height="match_parent">
   
<StackView xmlns:android="http://schemas.android.com/apk/res/android"
       
android:id="@+id/stack_view"
       
android:layout_width="match_parent"
       
android:layout_height="match_parent"
       
android:gravity="center"
       
android:loopViews="true" />
   
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
       
android:id="@+id/empty_view"
       
android:layout_width="match_parent"
       
android:layout_height="match_parent"
       
android:gravity="center"
       
android:background="@drawable/widget_item_background"
       
android:textColor="#ffffff"
       
android:textStyle="bold"
       
android:text="@string/empty_view_text"
       
android:textSize="20sp" />
</FrameLayout>

위 코드에서 empty_view는 stack_view가 빈(empty) 상태일 때 텍스트("This is the empty view")를 보여주는 뷰입니다. 

위의 레이아웃 파일은 앱 위젯 자체에 대한 레이아웃이며, StackView의 각각의 항목을 나타내는 레이아웃 파일도 필요합니다. StackView Widget 샘플widget_item.xml 파일이 그것입니다. 


목록 구현을 위한 AppWidgetProvider 클래스 구현

일반적인 앱 위젯과 마찬가지로, 목록을 보여주는 경우에도 AppWidgetProvider의 서브 클래스를 구성하는 대부분의 코드는 onUpdate() 메소드입니다. 일반적인 경우와의 가장 큰 차이점은 setRemoteAdapter() 메소드를 호출한다는 것입니다. 이것은 콜렉션 뷰가 데이터를 어디에서 가져오는지를 말해줍니다. RemoteViewsService는 내부적으로 RemoteViewsFactory의 구현체를 사용하여 콜렉션 뷰 각각의 위치에 해당 데이터를 연결해주는 어댑터의 역할을 해줍니다. setRemoteAdapter(int viewId, Intent intent) 에서 viewId는 콜렉션 뷰의 ID이고, intent는 RemoteViewsService의 구현체를 가리키는 인텐트입니다. ( * 역자주: setRemoteAdapter(int appWidgetId, int viewId, Intent intent) 는 API Level 14에서 deprecated 되었기 때문에, minSdk가 14이상이라면 아래 예제 코드에서 for 루프문을 제거할 수 있습니다. )

아래 예제 코드는 StackView Widget 샘플StackWidgetProvider.java의 onUpdate() 메소드 부분이며, 여기서 목록을 보여주기 위해 어댑터를 셋팅하는 부분을 볼 수 있습니다:

public void onUpdate(Context context, AppWidgetManager appWidgetManager,
int[] appWidgetIds) {
   
// update each of the app widgets with the remote adapter
   
for (int i = 0; i < appWidgetIds.length; ++i) {
       
       
// Set up the intent that starts the StackViewService, which will
       
// provide the views for this collection.
       
Intent intent = new Intent(context, StackWidgetService.class);
       
// Add the app widget ID to the intent extras.
        intent
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetIds[i]);
        intent
.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
       
// Instantiate the RemoteViews object for the app widget layout.
       
RemoteViews rv = new RemoteViews(context.getPackageName(), R.layout.widget_layout);
       
// Set up the RemoteViews object to use a RemoteViews adapter.
       
// This adapter connects
       
// to a RemoteViewsService  through the specified intent.
       
// This is how you populate the data.
        rv
.setRemoteAdapter(appWidgetIds[i], R.id.stack_view, intent);
       
       
// The empty view is displayed when the collection has no items.
       
// It should be in the same layout used to instantiate the RemoteViews
       
// object above.
        rv
.setEmptyView(R.id.stack_view, R.id.empty_view);

       
//
       
// Do additional processing specific to this app widget...
       
//
       
        appWidgetManager
.updateAppWidget(appWidgetIds[i], rv);  
   
}
   
super.onUpdate(context, appWidgetManager, appWidgetIds);
}


RemoveViewsService 클래스

위에서 설명한 바와 같이, RemoteViewsService의 서브 클래스는 내부적으로 RemoteViewsFactory의 구현체를 이용하여 목록의 각 항목에 대응되는 RemoteViews에 해당 데이터를 주입합니다. 

1. RemoteViewsService의 서브 클래스를 구현합니다. RemoteViewsService는 어댑터를 이용하여 RemoteViews에 데이터를 주입합니다. 

2. RemoteViewsService의 서브 클래스에는 RemoteViewsFactory 인터페이스의 구현체를 리턴해 주는 부분을 구현해야 합니다. RemoteViewsFactory는 (ListView, GridView 등의) 콜렉션 뷰와 그에 대응되는 데이터들을 연결시켜 주는 어댑터 인터페이스로서, Adapter 인터페이스의 랩퍼(wrapper) 인터페이스입니다.

다시 말해서, RemoteViewsService의 서브 클래스를 구현할 때 가장 중요한 일은 RemoteViewsFactory 인터페이스의 구현체를 리턴해 주는 것입니다. 


RemoteViewsFactory 인터페이스

RemoteViewsFactory 인터페이스를 구현한 클래스는 앱 위젯에 데이터를 제공해 줍니다. 즉, 위젯의 콜렉션 뷰의 각 아이템에 대한 레이아웃 XML 파일에 해당 데이터를 주입할 수 있게 해줍니다. 데이터는 데이터베이스로부터 얻어온 커서(cursor) 객체도 되고, 간단한 배열(array)도 될 수 있습니다. StackView Widget 샘플에서 데이터는 WidgetItem 객체를 담는 ArrayList 였습니다. RemoteViewsFactory는 위젯의 콜렉션 뷰에 데이터를 주입해 주는 어댑터인 것입니다. 

RemoteViewsFactory 인터페이스를 구현할 때, onCreate()getViewAt() 메소드를 구현하는 것이 중요합니다. 

시스템은 RemoteViewsFactory의 구현체가 처음 생성되는 시점에 onCreate()를 호출해 주며, 여기에서 데이터를 가져오는 것과 관련된 일들을 해야 합니다. 예를 들어, StackView Widget 샘플에서는 onCreate()에서 ArrayList에 WidgetItem 객체들을 담는 일을 합니다. 그러면 위젯이 화면에 보여질 때, 시스템이 이 ArrayList에서 현재 위치에 해당하는 WidgetItem 객체를 가져와서 그 안에 셋팅되어 있는 텍스트를 뷰에 보여주는 것입니다. 

아래 예제 코드는 StackView Widget 샘플RemoteViewsFactory 구현체의 일부로서, onCreate()에 주목하시기 바랍니다:

class StackRemoteViewsFactory implements
RemoteViewsService.RemoteViewsFactory {
   
private static final int mCount = 10;
   
private List<WidgetItem> mWidgetItems = new ArrayList<WidgetItem>();
   
private Context mContext;
   
private int mAppWidgetId;

   
public StackRemoteViewsFactory(Context context, Intent intent) {
        mContext
= context;
        mAppWidgetId
= intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
               
AppWidgetManager.INVALID_APPWIDGET_ID);
   
}

   
public void onCreate() {
       
// In onCreate() you setup any connections / cursors to your data source. Heavy lifting,
       
// for example downloading or creating content etc, should be deferred to onDataSetChanged()
       
// or getViewAt(). Taking more than 20 seconds in this call will result in an ANR.
       
for (int i = 0; i < mCount; i++) {
            mWidgetItems
.add(new WidgetItem(i + "!"));
       
}
       
...
   
}
...

RemoteViewsFactory의 getViewAt(int position) 메소드는 position에 해당하는 데이터가 주입된 RemoteViews 객체를 리턴해 줍니다. 예제 코드는 아래와 같습니다:

public RemoteViews getViewAt(int position) {
   
   
// Construct a remote views item based on the app widget item XML file,
   
// and set the text based on the position.
   
RemoteViews rv = new RemoteViews(mContext.getPackageName(), R.layout.widget_item);
    rv
.setTextViewText(R.id.widget_item, mWidgetItems.get(position).text);

   
...
   
// Return the remote views object.
   
return rv;
}


각 항목에 기능 추가하기

위에서는 위젯의 콜렉션 뷰에 데이터를 어떻게 바인드할까에 대하여 설명했습니다. 그렇다면 콜렉션 뷰의 각 아이템들의 동작 방식은 어떻게 구현할 수 있을까요? 

위의 AppWidgetProvider 클래스 사용하기 섹션에서 설명한 바와 같이, 버튼을 클릭했을 때 액티비티를 실행하는 등의 동작을 하기 위해서 setOnClickPendingIntent() 메소드를 사용하는 것이 일반적입니다. 하지만 이러한 방식은 콜렉션 뷰의 각 아이템에 대해서는 적용할 수 없으며, 대신에 setOnClickFillInIntent() 메소드를 사용합니다. 콜렉션 뷰 자체에 대해서는 setPendingIntentTemplate() 메소드를 호출하여 콜렉션 뷰를 클릭했을 때 어떤 동작을 하게되는지에 대해서 정의하고, 콜렉션 뷰의 아이템 뷰에 대해서는 setOnClickFillInIntent() 메소드를 호출하여 위치값(position) 등의 데이터를 전달합니다. 

이번 섹션에서도 콜렉션 뷰의 아이템들의 동작을 추가하는 방식을 설명하기 위하여 StackView Widget 샘플의 코드를 인용합니다. 사용자가 위젯의 맨 앞의 뷰를 클릭하면, "Touched view n"이라는 토스트 메시지가 출력됩니다(n에는 터치한 뷰의 position값이 들어갑니다). 동작하는 흐름을 간단히 살펴보면 아래와 같습니다: 

  • (AppWidgetProvider의 서브클래스인) StackWidgetProviderTOAST_ACTION이라는 액션값을 갖는 펜딩 인텐트를 생성합니다.  
  • 사용자가 위젯을 터치하면, TOAST_ACTION이 브로드캐스트 됩니다.
  • StackWidgetProvider의 onReceive()에 해당 브로드캐스트 인텐트가 전달되고, 토스트 메시지를 출력합니다. 터치한 뷰의 position값은 RemoteViewsFactory의 getViewAt()에서 setOnClickFillInIntent()를 통해 전달된 값입니다. 


setPendingIntentTemplate() 호출하기

StackWidgetProvider는 펜딩 인텐트를 셋팅합니다. 하지만 콜렉션 뷰의 아이템 뷰들에 대하여 각각의 펜딩 인텐트를 셋팅할 수는 없기 때문에, 콜렉션 뷰 전체에 대해서는 펜딩 인텐트 템플릿을 셋팅하고(setPendingIntentTemplate() 호출), 아이템 뷰 각각에 대해서는 데이터 전달용 인텐트(fill-in intent)를 셋팅합니다(setOnClickFillInIntent() 호출). 

아래 예제 코드에서는, 사용자가 뷰를 터치하면 TOAST_ACTION으로 브로드캐스트하고, 그것을 onReceive()에서 받아서, 터치된 뷰에 대한 토스트 메시지를 출력해 주고 있습니다:

public class StackWidgetProvider extends AppWidgetProvider {
   
public static final String TOAST_ACTION = "com.example.android.stackwidget.TOAST_ACTION";
   
public static final String EXTRA_ITEM = "com.example.android.stackwidget.EXTRA_ITEM";

   
...

   
// Called when the BroadcastReceiver receives an Intent broadcast.
   
// Checks to see whether the intent's action is TOAST_ACTION. If it is, the app widget
   
// displays a Toast message for the current item.
   
@Override
   
public void onReceive(Context context, Intent intent) {
       
AppWidgetManager mgr = AppWidgetManager.getInstance(context);
       
if (intent.getAction().equals(TOAST_ACTION)) {
           
int appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
               
AppWidgetManager.INVALID_APPWIDGET_ID);
           
int viewIndex = intent.getIntExtra(EXTRA_ITEM, 0);
           
Toast.makeText(context, "Touched view " + viewIndex, Toast.LENGTH_SHORT).show();
       
}
       
super.onReceive(context, intent);
   
}
   
   
@Override
   
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
       
// update each of the app widgets with the remote adapter
       
for (int i = 0; i < appWidgetIds.length; ++i) {
   
           
// Sets up the intent that points to the StackViewService that will
           
// provide the views for this collection.
           
Intent intent = new Intent(context, StackWidgetService.class);
            intent
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetIds[i]);
           
// When intents are compared, the extras are ignored, so we need to embed the extras
           
// into the data so that the extras will not be ignored.
            intent
.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
           
RemoteViews rv = new RemoteViews(context.getPackageName(), R.layout.widget_layout);
            rv
.setRemoteAdapter(appWidgetIds[i], R.id.stack_view, intent);
   
           
// The empty view is displayed when the collection has no items. It should be a sibling
           
// of the collection view.
            rv
.setEmptyView(R.id.stack_view, R.id.empty_view);

           
// This section makes it possible for items to have individualized behavior.
           
// It does this by setting up a pending intent template. Individuals items of a collection
           
// cannot set up their own pending intents. Instead, the collection as a whole sets
           
// up a pending intent template, and the individual items set a fillInIntent
           
// to create unique behavior on an item-by-item basis.
           
Intent toastIntent = new Intent(context, StackWidgetProvider.class);
           
// Set the action for the intent.
           
// When the user touches a particular view, it will have the effect of
           
// broadcasting TOAST_ACTION.
            toastIntent
.setAction(StackWidgetProvider.TOAST_ACTION);
            toastIntent
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetIds[i]);
            intent
.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
           
PendingIntent toastPendingIntent = PendingIntent.getBroadcast(context, 0, toastIntent,
               
PendingIntent.FLAG_UPDATE_CURRENT);
            rv
.setPendingIntentTemplate(R.id.stack_view, toastPendingIntent);
           
            appWidgetManager
.updateAppWidget(appWidgetIds[i], rv);
       
}
   
super.onUpdate(context, appWidgetManager, appWidgetIds);
   
}
}


setOnClickFillInIntent() 호출하기

RemoteViewsFactory에서는 콜렉션 뷰의 각 아이템에 데이터 전달용 인텐트(fill-in intent)를 셋팅해야 합니다. 이것은 어느 아이템이 클릭되었는지에 대한 정보를 전달해 주는 것입니다. 데이터 전달용 인텐트에 담긴 데이터는, 아이템이 클릭되어 브로드캐스트 하는 시점에 최종적으로 전달되는 인텐트에 추가됩니다. 

public class StackWidgetService extends RemoteViewsService {
   
@Override
   
public RemoteViewsFactory onGetViewFactory(Intent intent) {
       
return new StackRemoteViewsFactory(this.getApplicationContext(), intent);
   
}
}

class StackRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
   
private static final int mCount = 10;
   
private List<WidgetItem> mWidgetItems = new ArrayList<WidgetItem>();
   
private Context mContext;
   
private int mAppWidgetId;

   
public StackRemoteViewsFactory(Context context, Intent intent) {
        mContext
= context;
        mAppWidgetId
= intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
               
AppWidgetManager.INVALID_APPWIDGET_ID);
   
}

   
// Initialize the data set.
       
public void onCreate() {
           
// In onCreate() you set up any connections / cursors to your data source. Heavy lifting,
           
// for example downloading or creating content etc, should be deferred to onDataSetChanged()
           
// or getViewAt(). Taking more than 20 seconds in this call will result in an ANR.
           
for (int i = 0; i < mCount; i++) {
                mWidgetItems
.add(new WidgetItem(i + "!"));
           
}
           
...
       
}
       
...
   
       
// Given the position (index) of a WidgetItem in the array, use the item's text value in
       
// combination with the app widget item XML file to construct a RemoteViews object.
       
public RemoteViews getViewAt(int position) {
           
// position will always range from 0 to getCount() - 1.
   
           
// Construct a RemoteViews item based on the app widget item XML file, and set the
           
// text based on the position.
           
RemoteViews rv = new RemoteViews(mContext.getPackageName(), R.layout.widget_item);
            rv
.setTextViewText(R.id.widget_item, mWidgetItems.get(position).text);
   
           
// Next, set a fill-intent, which will be used to fill in the pending intent template
           
// that is set on the collection view in StackWidgetProvider.
           
Bundle extras = new Bundle();
            extras
.putInt(StackWidgetProvider.EXTRA_ITEM, position);
           
Intent fillInIntent = new Intent();
            fillInIntent
.putExtras(extras);
           
// Make it possible to distinguish the individual on-click
           
// action of a given item
            rv
.setOnClickFillInIntent(R.id.widget_item, fillInIntent);
       
           
...
       
           
// Return the RemoteViews object.
           
return rv;
       
}
   
...
   
}


목록 데이터의 최신화

아래 그림은 위젯의 목록을 최신화할때 실행되는 메소드들의 흐름을 나타내며, RemoteViewsFactory와 어떻게 상호작용을 하는지 보여줍니다:



위젯에 목록을 보여주는 이유 중 하나는 어떤 최신 컨텐츠 일부를 보여주기 위해서일 것입니다. 예를 들어, Gmail 앱의 위젯은 받은 편지함의 최근 편지 몇개를 보여줍니다. 그러기 위해서는, RemoteViewsFactory가 새로 받은 편지를 보여주기 위해 콜렉션 뷰를 갱신할 필요가 있으며, 이를 위해 AppWidgetManager의 notifyAppWidgetViewDataChanged() 메소드를 호출해야 합니다. 그러면 RemoteViewsFactory의 onDataChanged() 콜백 메소드가 호출되고, 여기서 최신 데이터를 가져오는 작업을 할 수 있습니다. onDataChanged()는 동기적으로 동작합니다. 즉, onDataChanged()에서 데이터 가져오는 일이 끝나야 그 데이터를 아이템 뷰에 적용하는(fetch data) 메소드가 호출되는 것입니다. onDataChanged()getViewAt()에서는 시간이 꽤 오래 걸리는 작업들(processing-intensive operations)이 실행될 수 있을 것입니다. 따라서 그럴 때는 RemoteViewsFactory의 getLoadingView()를 구현함으로써 실행 중에 로딩뷰가 보여지도록 할 수 있습니다. 


Posted by 개발자 김태우
,

(원문: http://developer.android.com/guide/topics/providers/content-provider-basics.html)

(위치: Develop > API Guides > App Components > Content Providers 
> Content Provider Basics)

(전체 번역 글 목록으로 이동하기)


컨텐트 프로바이더 기초
(Content Provider Basics)


컨텐트 프로바이더는 데이터 저장소에 접근하는 방법을 제공합니다. 프로바이더는 저장된 데이터에 기반하여 서비스를 제공하는 앱의 일부이며, 주로 다른 앱에 의해 사용됩니다. 다른 앱은 프로바이더 클라이언트 객체를 이용하여 프로바이더 앱에 액세스할 수 있습니다. 프로바이더와 프로바이더 클라이언트는 일관되고 표준적인 인터페이스를 제공하는데, 이것은 내부적으로 프로세스간 통신(IPC)과 데이터 보안(secure data access) 관련 기능을 구현하고 있습니다. 

본 문서에서 다룰 내용은 대략 아래와 같습니다:

  • 컨텐트 프로바이더는 어떻게 동작하는가.
  • 컨텐트 프로바이더로부터 데이터를 가져오는 API.
  • 컨텐트 프로바이더에 데이터를 삽입, 갱신, 삭제하는 API.
  • 그 외의 유용한 API들


개요

컨텐트 프로바이더는 다른 앱에 데이터를 제공하는데, 그 데이터는 관계형 데이터베이스에서의 테이블들과 유사하게 하나 또는 그 이상의 테이블로써 제공됩니다. 하나의 행(row)은 프로바이더가 제공하는 어떤 데이터 타입에 대한 객체를 나타내고, 그 행의 각 열(column)은 해당 객체가 가지고 있는 각 데이터를 나타냅니다. 

예를 들어, 안드로이드 플랫폼에 내장된 프로바이더 중에 사용자 딕셔너리가 있는데, 이것은 사용자가 저장하고 싶어하는 비표준의(non-standard) 단어들을 저장합니다. 아래의 표1은 이 프로바이더의 테이블에 데이터들이 어떤 식으로 저장되는지를 보여줍니다:

표 1: 사용자 딕셔너리 테이블 샘플

 단어(word)

 앱ID
(app id)

 빈도
(frequency)

 지역(locale)

 _ID

 mapreduce

 user1

 100

 en_US

 1

 precompiler

 user14

 200

 fr_FR

 2

 applet

 user2

 225

 fr_CA

 3

 const

 user1

 255

 pt_BR

 4

 int

 user5

 100

 en_UK

 5

위의 표1에서, 각 행(row)은 표준 딕셔너리에 없을법한 단어에 대한 정보를 나타냅니다. 그리고 각 열(column)은 처음 입력됐을때의 지역정보와 같은, 해당 단어에 대한 정보를 나타냅니다. 각 열의 헤더는 프로바이더에 저장된, 열의 이름입니다. 어떤 행의 지역정보를 얻기 위해서는, 그 행의 locale 값을 찾으면 됩니다. 그리고 _ID는 프로바이더에서 자동으로 생성하는 "기본키(primary key)"를 의미합니다.

메모: 프로바이더에서 기본키인 _ID를 반드시 가지고 있어야 하는 것은 아닙니다. 하지만 프로바이더를 통해 데이터들을 ListView에 바인드해야 한다면, _ID를 가질 필요가 있습니다. 이와 관련해서는 쿼리 결과 보여주기 섹션에서 자세히 설명하겠습니다. 


프로바이더에 액세스하기

앱은 컨텐트 프로바이더로부터 데이터를 얻기 위해 컨텐트 리졸버(ContentResolver) 객체를 사용합니다. 이 객체는 ContentProvider클래스의 서브클래스에 대한 객체의 메소드들과 이름이 같은 메소드들을 갖고 있습니다. 그 메소드들에는 저장소에 대한 "CRUD" (create, read, update, delete) 함수들이 포함됩니다. 

클라이언트 앱에 있는 ContentResolver 객체와 데이터를 제공하는 앱에 있는 ContentProvider 객체는 프로세스간 통신(IPC)을 합니다. 그리고 ContentProvider는 실제 데이터 저장소와 외부에 드러난 데이터 테이블 사이에서 추상 레이어 역할도 합니다. 

메모: 프로바이더에 액세스하기 위해서는, 보통 내앱의 매니페스트 파일에 해당 권한들이 선언되어 있어야 합니다. 자세한 내용은 아래의 컨텐트 프로바이더 관련 권한들 섹션에서 학습하실 수 있습니다.

예를 들어, 사용자 사전 프로바이더에 있는 단어 및 장소 목록을 얻기 위해서는 ContentResolver.query() 메소드를 호출하는데, 이것은 사용자 사전 프로바이더에 정의되어 있는 ContentProvider.query() 메소드를 호출합니다. 아래 예제 코드는 ContentResolver.query() 메소드를 호출하는 것을 보여줍니다:

// Queries the user dictionary and returns results
mCursor
= getContentResolver().query(
   
UserDictionary.Words.CONTENT_URI,   // The content URI of the words table
    mProjection
,                        // The columns to return for each row
    mSelectionClause                    
// Selection criteria
    mSelectionArgs
,                     // Selection criteria
    mSortOrder
);                        // The sort order for the returned rows

아래의 표2는 query(Uri, projection, selection, selectionArgs, sortOrder) 메소드의 각 인자가 SQL의 SELECT 구문과 어떻게 대응되는지를 보여줍니다.

표2: query() 메소드와 SQL 쿼리의 대응관계

 query()의 인자

 SELECT의 구문

 비고

 Uri

 FROM table_name

 Uri는 프로바이더의 table_name 테이블과 대응됩니다.

 projection

 col,col,col,...

 projection은 select하여 가져올 데이터의 컬럼들과 대응됩니다.

 selection

 WHERE col = value

 selection은 where 조건절과 대응됩니다.

 selectionArgs

 where절의 value가 ?일때, ?를 대치하는 값. SQL 바인딩 참고

 

 sortOrder

 ORDER BY col,col,...

 sortOrder는 정렬 기준으로서 order by 절과 대응됩니다.


컨텐트 URI

컨텐트 URI는 프로바이더의 데이터를 가리키는 URI입니다. 컨텐트 URI는 프로바이더 자체를 나타내는 이름(uri의 authority부분. scheme 뒷부분)과 그 안의 테이블을 가리키는 이름(uri의 path부분. authority 뒷부분)을 포함하고 있습니다. 프로바이더의 테이블에 액세스하기 위해 클라이언트용 메소드를 호출할 때, 그 테이블에 대한 컨텐트 URI는 그 메소드의 인자 중 하나가 됩니다. 

위의 예제 코드에서, CONTENT_URI는 사용자 사전의 "words" 테이블을 가리키는 URI입니다. ContentResolver 객체는 그 URI의 authority부분을, 시스템이 알고 있는 프로바이더들의 authority들과 비교하여 맞는 것을 찾아서, 그 프로바이더로 쿼리 인자들을 보냅니다. 

ContentProvider는 액세스할 테이블을 알기 위해 URI의 path부분을 봅니다. 프로바이더는 보통 그 path부분과 일치하는 이름의 테이블을 가지고 있습니다.

위의 예제 코드에서 "words" 테이블에 해당하는 전체 URI는 아래와 같습니다:

content://user_dictionary/words

위에서, user_dictionary가 있는 부분이 URI의 authority부분이며 프로바이더를 가리키고, words가 있는 부분이 URI의 path부분이며 테이블을 가리킵니다. 그리고 content://가 있는 부분은 URI의 scheme부분이며 이 문자열이 컨텐트 URI임을 알려주는 것입니다. 

대부분의 프로바이더들은 URI의 끝에 ID값을 넣음으로써 해당 테이블의 ID에 해당하는 데이터에 액세스하는 것을 허용합니다. 예를 들어, 사용자 사전에서 _ID가 4인 데이터를 얻기 위해 사용하는 컨텐트 URI는 아래와 같이 구할 수 있습니다:

Uri singleUri = ContentUris.withAppendedId(UserDictionary.Words.CONTENT_URI,4);

원하는 데이터들을 얻거나, 업데이트하거나, 삭제할 때에도 ID값을 사용할 수 있습니다. 

메모: UriUri.Builder 클래스는 문자열로부터 Uri 객체를 만들어내는 편리한 메소드들을 가지고 있습니다. ContentUris 클래스는 URI에 ID값을 추가해주는 메소드를 가지고 있습니다. 위의 코드는 사용자 사전의 컨텐트 URI에 ID값인 4를 추가하기 위해 withAppendedId() 메소드를 사용하는 것을 보여줍니다.


프로바이더로부터 데이터 가져오기

이번 섹션에서는 사용자 사전 프로바이더를 예로 들어 프로바이더에서 데이터를 얻는 방법을 설명합니다. 

설명을 더 명확하게 하기 위해서 이번 섹션의 예제 코드들은 ContentResolver.query() 메소드를 "UI 쓰레드"에서 호출합니다. 하지만 실제 코드에서는 쿼리들이 별도의 쓰레드에서 비동기적으로 처리되도록 해야합니다. 그러기 위한 한가지 방법으로 CursorLoader 클래스를 사용할 수 있으며, 그에 대한 자세한 내용은 로더 문서에서 학습하실 수 있습니다. 또한, 예제 코드들은 전체 코드의 일부분일 뿐이며 앱 전체의 코드를 보여주지 않습니다.

프로바이더에서 데이터를 얻기 위해서는, 아래의 기본적인 절차를 따르면 됩니다.

1. 프로바이더에 대한 읽기 권한을 요청합니다.

2. 프로바이더에 쿼리를 보내는 코드를 작성합니다.


읽기 권한 요청하기

프로바이더로부터 데이터를 얻기 위해서는, 내 앱에 프로바이더에 대한 "읽기 권한(read access permission)"이 있어야 합니다. 이 권한에 대한 요청은 런타임에 할 수 없고, 매니페스트 파일에 <uses-permission> 요소를 추가하여 여기에 프로바이더가 정의한 정확한 권한 이름을 지정해줘야 합니다. 매니페스트 파일에 <uses-permission> 요소를 추가하면, 내 앱이 해당 권한을 "요청하는" 것과 같습니다. 이 경우 사용자는 앱을 설치하기 전에 권한 요청에 대한 사실을 확인할 수 있고, 앱을 설치한다면 해당 요청을 암묵적으로 승인한 것과 같다고 할 수 있습니다. 

프로바이더에 대한 읽기 권한의 정확한 이름을 찾기 위해서는 프로바이더 문서를 살펴봐야 하며, 이는 다른 권한들에 대해서도 마찬가지입니다. 

프로바이더에 액세스 하기 위한 권한의 역할에 대하여, 더 자세한 내용은 컨텐트 프로바이더의 권한들 섹션에서 학습하실 수 있습니다. 

사용자 사전 프로바이더는 자신의 매니페스트 파일에 android.permission.READ_USER_DICTIONARY 권한을 정의하고 있으며, 사용자 사전 프로바이더의 데이터를 읽기 원하는 앱은 이 권한을 요청해야 합니다. 


쿼리 만들기

프로바이더에서 데이터를 읽기 위해 다음으로 할 일은 쿼리를 만드는 것입니다. 아래 예제 코드에서는, 사용자 사전 프로바이더에 액세스 하기 위한 몇가지 변수들을 정의하고 있습니다:

// A "projection" defines the columns that will be returned for each row
String[] mProjection =
{
   
UserDictionary.Words._ID,    // Contract class constant for the _ID column name
   
UserDictionary.Words.WORD,   // Contract class constant for the word column name
   
UserDictionary.Words.LOCALE  // Contract class constant for the locale column name
};

// Defines a string to contain the selection clause
String mSelectionClause = null;

// Initializes an array to contain selection arguments
String[] mSelectionArgs = {""};

아래의 예제에서는 사용자 사전 프로바이더를 이용하여 ContentResolver.query()를 사용하는 방법을 보여줍니다. 프로바이더에 요청되는 쿼리는 SQL 쿼리와 거의 비슷하며, 리턴 받을 컬럼들(projection)과 조건절에 들어갈 값들(selection criteria), 그리고 정렬 기준들(sort order)을 포함합니다. 

조건절에 대한 표현은 selection 구문(clause)과 selection 인자들(arguments)로 분리될 수 있습니다. selection 구문은 논리적 표현(and, or 등), 참/거짓 표현, 컬럼 이름과 컬럼 값의 조합으로 구성됩니다(위 예제에서는 mSelectionClause 변수). 만약 selection 구문에 실제 값 대신에 물음표(?)가 들어가 있다면, selection 인자들에서 대응되는 실제 값을 가져오게 됩니다(위 예제에서는 mSelectionArgs). 

아래의 예제에서, 만약 사용자가 입력한 단어가 없다면, selection 구문은 null이 되고 프로바이더에 있는 모든 단어들이 리턴됩니다. 하지만 사용자가 입력한 단어가 있다면, selection 구문은 UserDictionary.Words.WORD + " = ?" 가 되고 selection 인자에는 사용자가 입력한 단어가 들어갑니다.

/*
 * This defines a one-element String array to contain the selection argument.
 */

String[] mSelectionArgs = {""};

// Gets a word from the UI
mSearchString
= mSearchWord.getText().toString();

// Remember to insert code here to check for invalid or malicious input.

// If the word is the empty string, gets everything
if (TextUtils.isEmpty(mSearchString)) {
   
// Setting the selection clause to null will return all words
    mSelectionClause
= null;
    mSelectionArgs
[0] = "";

} else {
   
// Constructs a selection clause that matches the word that the user entered.
    mSelectionClause
= UserDictionary.Words.WORD + " = ?";

   
// Moves the user's input string to the selection arguments.
    mSelectionArgs
[0] = mSearchString;

}

// Does a query against the table and returns a Cursor object
mCursor
= getContentResolver().query(
   
UserDictionary.Words.CONTENT_URI,  // The content URI of the words table
    mProjection
,                       // The columns to return for each row
    mSelectionClause                  
// Either null, or the word the user entered
    mSelectionArgs
,                    // Either empty, or the string the user entered
    mSortOrder
);                       // The sort order for the returned rows

// Some providers return null if an error occurs, others throw an exception
if (null == mCursor) {
   
/*
     * Insert code here to handle the error. Be sure not to use the cursor! You may want to
     * call android.util.Log.e() to log this error.
     *
     */

// If the Cursor is empty, the provider found no matches
} else if (mCursor.getCount() < 1) {

   
/*
     * Insert code here to notify the user that the search was unsuccessful. This isn't necessarily
     * an error. You may want to offer the user the option to insert a new row, or re-type the
     * search term.
     */


} else {
   
// Insert code here to do something with the results

}

위의 예제는 아래 SQL 구문을 실행하는 것과 비슷합니다.

SELECT _ID, word, locale FROM words WHERE word = <userinput> ORDER BY word ASC;

예제 코드와 SQL 구문의 차이점 중 하나로, SQL 구문에서는 해당 클래스의 상수 이름(클래스에 정의된 이름) 대신에 실제 words 테이블의 컬럼 이름이 들어갑니다. 


악의적인 입력으로부터 보호하기

컨텐트 프로바이더에 의해 관리되는 데이터가 SQL 데이터베이스에 있고, SQL 구문이 외부의 신뢰할 수 없는 데이터를 포함하게 된다면, 이는 SQL 인젝션 공격을 받을 수 있습니다. 

아래 selection 구문을 예로 들어 보겠습니다:

// Constructs a selection clause by concatenating the user's input to the column name
String mSelectionClause =  "var = " + mUserInput;

이와 같이 하면, 사용자가 SQL 구문에 악의적인 SQL을 추가할 수 있게 됩니다. 예를 들면, 사용자가 입력한 mUserInput"nothing; DROP TABLE *;" 일 경우, selection 구문은 var = nothing; DROP TABLE *; 이 됩니다. selection 구문이 SQL 구문에 들어가기 때문에, 이 SQL 구문으로 인해 프로바이더가 (SQL 인젝션 공격에 대한 대비를 하고 있지 않다면) SQLite 데이터베이스의 모든 테이블들을 지워버릴지도 모릅니다. 

이 문제를 피하기 위해서는, selection 구문에 실제 값으로 대치될 수 있는 물음표(?)를 사용하고, selection 인자들을 따로 배열로 입력하면 됩니다. 이렇게 하면, 사용자가 입력한 내용이 SQL 구문으로 바로 인식되지 않고 데이터로서 쿼리에 바인드 됩니다. 그러면 사용자가 입력한 내용이 요구되는 데이터 타입에 부합하는지 확인되기 때문에, 악의적인 목적으로 SQL 구문을 변형할 수 없습니다. 따라서 사용자가 입력한 내용을 바로 SQL 구문에 포함시키는 대신에, 아래와 같이 분리하는 것이 좋습니다.

selection 구문: 

// Constructs a selection clause with a replaceable parameter
String mSelectionClause =  "var = ?";

selection 인자들:

// Defines an array to contain the selection arguments
String[] selectionArgs = {""};

selection 인자들은 아래와 같이 값을 추가합니다:

// Sets the selection argument to the user's input
selectionArgs
[0] = mUserInput;

물음표(?)를 사용한 selection 구문과 selection 인자들을 배열로 입력하는 방식은 상당히 권장되는 방식이며, 심지어 프로바이더가 SQL 데이터베이스에 기반하는 경우가 아니더라도 마찬가지입니다. 


쿼리 결과 보여주기

ContentResolver.query() 메소드는 쿼리의 selection 조건에 부합하는 결과들에 대하여 쿼리의 projection에서 지정한 컬럼 값들을 담고있는 Cursor 객체를 리턴해 주며, Cursor 객체를 통해 결과값들에 액세스할 수 있습니다. Cursor의 메소드들을 이용하여, 여러 행의 결과들에 대하여 순차적으로 각 행을 읽을 수 있고, 각 컬럼의 데이터 타입을 확인하여, 그 컬럼들의 데이터를 얻을 수 있습니다. 몇몇 Cursor 구현 객체들은, 프로바이더의 데이터가 변경되거나, Cursor 객체가 변경될 때 옵저버 객체들의 메소드가 호출되도록 트리거가 걸려있거나, 또는 둘 다일때 해당 Cursor 객체가 자동적으로 업데이트 되도록 구현되어 있습니다. 

메모: 프로바이더는 쿼리를 만드는 객체의 특성에 기반하여 특정 컬럼에 대한 액세스를 제한할 수도 있습니다. 예를 들어, 주소록(Contacts) 프로바이더도 몇가지 컬럼들에 대한 액세스를 제한하고 있어서 그 컬럼들의 값들은 리턴해 주지 않습니다.

만약 selection 조건에 부합하는 결과가 없다면, 프로바이더가 리턴해준 Cursor의 Cursor.getCount()는 0이 됩니다. 

만약 에러가 발생한다면, 쿼리에 대한 결과는 해당 프로바이더가 어떻게 처리하느냐에 달려 있습니다. 이러한 경우 보통은 null을 리턴하거나 Exception을 발생시킵니다. 

Cursor 객체는 결과 행(row)들의 "리스트"이기 때문에, 그 결과들을 보여주기 위해서는 ListView에 SimpleCursorAdapter를 연결하여 사용하는 것이 좋은 방법 중 하나입니다.

다음의 예제 코드는 이전 예제 코드에 이어지는 내용입니다. 여기에서, 쿼리 결과로 얻은 Cursor 객체를 포함하는 SimpleCursorAdapter 객체를 만들고, 이것을 ListView에 연결하는 것을 보여줍니다:

// Defines a list of columns to retrieve from the Cursor and load into an output row
String[] mWordListColumns =
{
   
UserDictionary.Words.WORD,   // Contract class constant containing the word column name
   
UserDictionary.Words.LOCALE  // Contract class constant containing the locale column name
};

// Defines a list of View IDs that will receive the Cursor columns for each row
int[] mWordListItems = { R.id.dictWord, R.id.locale};

// Creates a new SimpleCursorAdapter
mCursorAdapter
= new SimpleCursorAdapter(
    getApplicationContext
(),               // The application's Context object
    R
.layout.wordlistrow,                  // A layout in XML for one row in the ListView
    mCursor
,                               // The result from the query
    mWordListColumns
,                      // A string array of column names in the cursor
    mWordListItems
,                        // An integer array of view IDs in the row layout
   
0);                                    // Flags (usually none are needed)

// Sets the adapter for the ListView
mWordList
.setAdapter(mCursorAdapter);

메모: (SimpleCursorAdapter에서) Cursor를 이용하여 ListView를 보여주기 위해서는, Cursor가 _ID 컬럼을 가지고 있어야 합니다. 이 때문에 실제로 ListView에서 _ID의 값을 사용하지 않음에도 불구하고, 위의 예제에서 쿼리를 만들때 projection에 _ID가 포함되어 있는 것입니다. 그리고 이 때문에 대부분의 프로바이더들이 관리하는 테이블들은 _ID 컬럼을 가지고 있습니다. 


쿼리 결과에서 데이터 구하기

쿼리 결과를 단순하게 보여주는데에 그치지 않고, 다른 작업들을 할 수 있습니다. 예를 들어, 사용자 사전에 있는 단어들의 스펠링을 확인하여 다른 프로바이더에 필요한 정보를 요청할 수도 있는 것입니다. 그러기 위해서는 아래 예제 코드에서 보이는 바와 같이 Cursor의 결과들을 순차적으로 읽을 수 있어야 합니다:

// Determine the column index of the column named "word"
int index = mCursor.getColumnIndex(UserDictionary.Words.WORD);

/*
 * Only executes if the cursor is valid. The User Dictionary Provider returns null if
 * an internal error occurs. Other providers may throw an Exception instead of returning null.
 */


if (mCursor != null) {
   
/*
     * Moves to the next row in the cursor. Before the first movement in the cursor, the
     * "row pointer" is -1, and if you try to retrieve data at that position you will get an
     * exception.
     */

   
while (mCursor.moveToNext()) {

       
// Gets the value from the column.
        newWord
= mCursor.getString(index);

       
// Insert code here to process the retrieved word.

       
...

       
// end of while loop
   
}
} else {

   
// Insert code here to report an error if the cursor is null or the provider threw an exception.
}

Cursor 구현 객체는 여러 타입의 결과값들을 얻을 수 있도록 하기 위하여 몇개의 "get" 메소드를 가지고 있습니다. 위의 코드에 있는 getString() 메소드도 그 중 하나입니다. Cursor 객체는 getType() 메소드도 가지고 있는데, 이것은 해당 컬럼 값의 데이터 타입이 무엇인지를 리턴해 줍니다. 


컨텐트 프로바이더 관련 권한들

프로바이더 역할을 하는 앱은, 다른 앱이 데이터에 액세스하기 위해 꼭 가지고 있어야 하는 권한(permission)들을 지정해 둘 수 있습니다. 이러한 권한들은, 앱이 무슨 데이터에 액세스하려고 하는지 사용자가 알도록 해줍니다. 프로바이더의 요구사항에 기반하여, 다른 앱들은 필요한 권한들을 매니페스트 파일에 추가해야하며, 이것들은 사용자들이 앱을 설치할 때 확인할 수 있는 것입니다. 

만약 프로바이더 앱이 아무 권한도 지정하지 않았다면, 다른 앱들은 프로바이더의 데이터에 액세스할 수 없습니다. 하지만 프로바이더 앱 내의 컴포넌트들은 지정된 권한이 없더라도 항상 읽기와 쓰기 권한을 가지고 있습니다. 

위에서 언급했듯이, 사용자 사전 프로바이더에서 데이터를 읽기 위해서는 android.permission.READ_USER_DICTIONARY 권한이 필요합니다. 그리고 데이터를 삽입, 갱신, 삭제하기 위해서는 android.permission.WRITE_USER_DICTIONARY 권한이 필요합니다. 

클라이언트 앱이 프로바이더에 액세스하기 위한 권한을 얻기 위해서는, 매니페스트 파일에 <uses-permission> 요소를 추가함으로써 권한을 요청해야 합니다. 그러면 안드로이드 패키지 매니저가 앱을 설치할 때 사용자에게 그 앱이 요청하고 있는 권한들을 보여주고 승인하도록 합니다. 여기서 사용자가 그것을 승인하면 패키지 매니저는 설치를 계속하게 되고, 승인하지 않으면 설치를 중단합니다. 

아래의 <user-permission>은 사용자 사전 프로바이더에 읽기 권한을 요청하는 코드입니다.

<uses-permission android:name="android.permission.READ_USER_DICTIONARY">


데이터의 삽입, 갱신, 삭제

프로바이더로부터 데이터를 얻을 때와 마찬가지 방식으로, 클라이언트가 ContentProvider의 데이터를 변경할 수도 있습니다. ContentProvider에 대응되는 ContentResolver의 메소드를 호출하면 됩니다. 프로바이더와 클라이언트는 보안적으로 안전하게 연결되며, 프로세스간 통신(IPC, inter-process communication)을 합니다. 


데이터 삽입(inserting)

프로바이더에 데이터를 삽입하기 위해서는, ContentResolver.insert() 메소드를 호출합니다. 이 메소드는 새 데이터(new row)를 삽입하고, 삽입된 데이터에 대한 컨텐트 URI를 리턴 받습니다. 아래 예제 코드는 사용자 사전 프로바이더에 새 단어를 삽입하는 방법을 보여줍니다: 

// Defines a new Uri object that receives the result of the insertion
Uri mNewUri;

...

// Defines an object to contain the new values to insert
ContentValues mNewValues = new ContentValues();

/*
 * Sets the values of each column and inserts the word. The arguments to the "put"
 * method are "column name" and "value"
 */

mNewValues
.put(UserDictionary.Words.APP_ID, "example.user");
mNewValues
.put(UserDictionary.Words.LOCALE, "en_US");
mNewValues
.put(UserDictionary.Words.WORD, "insert");
mNewValues
.put(UserDictionary.Words.FREQUENCY, "100");

mNewUri
= getContentResolver().insert(
   
UserDictionary.Word.CONTENT_URI,   // the user dictionary content URI
    mNewValues                          
// the values to insert
);

새 데이터는 하나의 ContentValues 객체에 들어가며, 이는 하나의 결과를 갖는 커서와 그 형태가 비슷합니다. ContentValues 객체의 컬럼들의 데이터 타입은 모두 같을 필요가 없으며, 어떤 컬럼에 값을 지정하고 싶지 않다면 ContentValues.putNull() 메소드를 호출함으로써 해당 컬럼을 null로 셋팅할 수 있습니다. 

위의 예제 코드에서 _ID 컬럼은 보이지 않는데, _ID 컬럼은 자동적으로 관리되기 때문입니다. 프로바이더는 새로운 데이터가 삽입될 때 유니크한 값을 생성하여 _ID 컬럼에 추가해 주며, 보통은 이 값을 테이블의 기본키(primary key)로 사용합니다. 

mNewUri는 새로 추가된 데이터를 가리키는 컨텐트 URI로서, 아래와 같은 형태가 됩니다:

content://user_dictionary/words/<id_value>

위의 <id_value>는 추가된 데이터의 _ID 값입니다. 대부분의 프로바이더들은 위와 같은 형태의 URI들을 컨텐트 URI로 인식하고, 요청된 작업을 수행합니다. 

위와 같이 리턴받은 URI에서 _ID 값을 얻기 위해서, ContentUris.parseId() 메소드를 사용할 수 있습니다. 


데이터 갱신(updating)

데이터(row)를 업데이트하기 위해서는, 데이터를 삽입할 때처럼 갱신할 값들을 담고 있는 ContentValues 객체를 만들고, 쿼리에 담을 selection 조건을 만들고, ContentResolver.update() 메소드를 호출하면 됩니다. 만약 어떤 컬럼의 값을 비우고(clear) 싶다면, ContentValues의 값을 null로 셋팅하면 됩니다. 

아래 예제 코드는 locale이 "en_"으로 시작하는 데이터들을 찾아서 그 데이터들의 locale을 null로 갱신하는 것을 보여줍니다. 리턴받는 값은 업데이트된 데이터(row)들의 갯수를 의미합니다:

// Defines an object to contain the updated values
ContentValues mUpdateValues = new ContentValues();

// Defines selection criteria for the rows you want to update
String mSelectionClause = UserDictionary.Words.LOCALE +  "LIKE ?";
String[] mSelectionArgs = {"en_%"};

// Defines a variable to contain the number of updated rows
int mRowsUpdated = 0;

...

/*
 * Sets the updated value and updates the selected words.
 */

mUpdateValues
.putNull(UserDictionary.Words.LOCALE);

mRowsUpdated
= getContentResolver().update(
   
UserDictionary.Words.CONTENT_URI,   // the user dictionary content URI
    mUpdateValues                      
// the columns to update
    mSelectionClause                    
// the column to select on
    mSelectionArgs                      
// the value to compare to
);

ContentResolver.update()를 호출할 때에는 사용자가 입력한 내용에 대한 유효성 검사가 선행되어야 합니다. 이에 대한 자세한 내용은 악의적인 입력으로부터 보호하기 섹션에서 학습하실 수 있습니다. 


데이터 삭제(deleting)

데이터들을 삭제하는 것은 데이터를 얻을 때와 방식이 비슷합니다. 지우길 원하는 데이터들에 대한 selection 조건을 지정한 쿼리를 요청하면 되며, 그러면 삭제된 데이터의 갯수(the number of deleted rows)가 리턴됩니다. 아래 예제 코드에서는 app id가 "user"인 데이터들을 삭제하는 방법을 보여주며, 삭제된 데이터의 수를 리턴받아 mRowsDeleted에 담고 있습니다.

// Defines selection criteria for the rows you want to delete
String mSelectionClause = UserDictionary.Words.APP_ID + " LIKE ?";
String[] mSelectionArgs = {"user"};

// Defines a variable to contain the number of rows deleted
int mRowsDeleted = 0;

...

// Deletes the words that match the selection criteria
mRowsDeleted
= getContentResolver().delete(
   
UserDictionary.Words.CONTENT_URI,   // the user dictionary content URI
    mSelectionClause                    
// the column to select on
    mSelectionArgs                      
// the value to compare to
);

ContentResolver.delete()를 호출할 때에도 사용자가 입력한 내용에 대한 유효성 검사가 선행되어야 합니다. 이에 대한 자세한 내용은 악의적인 입력으로부터 보호하기 섹션에서 학습하실 수 있습니다. 


프로바이더의 데이터 타입들

컨텐트 프로바이더는 다양한 데이터 타입들을 제공할 수 있습니다. 사용자 사전 프로바이더는 텍스트만 제공하고 있긴 하지만, 프로바이더들은 아래 데이터 타입들도 제공할 수 있습니다. 

  • 정수형 (integer)
  • 긴 정수형 (long)
  • 부동소수점 (floating point)
  • 긴 부동소수점 (double)

그 외에도 프로바이더가 종종 사용하는 데이터 타입 중 하나로 64KB 크기의 byte array인 Binary Large OBject (BLOB)가 있습니다. 

Cursor 객체의 "get" 메소드들을 살펴보면 어떤 데이터 타입들을 사용할 수 있는지 확인하기 쉬울 것입니다. 

프로바이더의 각 컬럼에 대한 데이터 타입은 보통 그 프로바이더에 대한 문서에 명시되어 있습니다. 그리고 Cursor.getType() 메소드를 통해 커서 객체가 가지고 있는 결과 데이터의 각 컬럼에 대한 데이터 타입을 확인할 수도 있습니다. 

그리고 프로바이더들은 각 컨텐트 URI들에 대하여 MIME 데이터 타입 정보를 지원합니다. 내 앱이 프로바이더가 제공하는 데이터를 다룰 수 있는지를 확인해야 하거나, MIME 타입에 기반하여 데이터 타입을 선택해야 하는 경우에 사용할 수 있습니다. 그리고 보통은 프로바이더가 복잡한 구조의 데이터나 파일들을 가지고 있을 때 MIME 타입을 필요로 하게 됩니다. 예를 들면, 주소록 프로바이더의 ContactsContract.Data 테이블은 저장된 주소록 데이터의 타입을 정의하는데에 MIME 타입을 사용합니다.

아래의 MIME 타입들 섹션에서는 표준 및 자체적 MIME 타입들의 문법에 대하여 설명합니다.


프로바이더에 액세스하는 방법들

프로바이더에 액세스하는 아래 세가지 방법은 앱 개발에 있어서 중요한 부분입니다:

  • 배치 액세스: ContentProviderOperation 클래스를 이용하여 배치 작업을 만들 수 있고, ContentResolver.applyBatch() 메소드를 배치 작업을 적용할 수 있습니다.  
  • 비동기 쿼리(Asynchronous queries): 쿼리는 별도의 쓰레드에서 요청해야 합니다. CursorLoader 객체를 사용하는 것이 한가지 방법입니다. 이와 관련한 예제 및 설명은 로더(Loaders) 문서에서 학습하실 수 있습니다. 
  • 인텐트를 이용한 데이터 액세스: 프로바이더로 직접 인텐트를 보낼 수는 없지만, 프로바이더의 앱으로는 인텐트를 보낼 수 있습니다. 프로바이더 앱이 그에 대한 처리를 제대로 하고 있다면, 프로바이더의 데이터를 읽고 변경하는데에 이 방법을 사용할 수 있습니다. 

배치 액세스와 인텐트를 이용한 데이터 액세스에 대해서는 이어지는 섹션에서 자세히 설명합니다. 


배치 액세스(Batch access)

프로바이더로의 배치 액세스 방식은, 많은 양의 데이터를 삽입하는 경우, 한번의 메소드 호출로 여러개의 테이블에 데이터들을 삽입하는 경우, 그리고 (하나 또는 그 이상의 프로세스에 대하여) 트랜젝션을 보장해줘야 하는 작업들의 집합을 실행해야 하는 경우에 유용합니다. 

"배치모드"로 프로바이더에 액세스하기 위해서는, ContentProviderOperation 객체들의 배열을 만들고, ContentResolver.applyBatch() 메소드를 호출하여 그 배열을 인자로 넘겨주면 됩니다. 여기서 applyBatch()의 인자로 컨텐트 URI를 넘기는 대신에 프로바이더의 authority 값(URI에서 scheme과 path의 사이)을 넘겨줍니다. 이렇게 함으로써 배열 안에 있는 각각의 ContentProviderOperation 객체들이 프로바이더 안에 있는 모든 테이블들에 대하여 요청된 작업을 할 수 있습니다. 그리고 ContentResolver.applyBatch() 메소드는 그 결과들에 대한 배열을 리턴해 줍니다. 

안드로이드 SDK에 포함되어 있는 샘플앱들 중 Contact Manager 샘플앱에 있는 ContactAdder.java 파일을 보면 배치 액세스에 대한 구현 내용을 확인할 수 있습니다. 


인텐트를 이용한 데이터 액세스

인텐트를 이용하면 컨텐트 프로바이더에 간접적으로 액세스할 수 있습니다. 내 앱이 액세스 권한을 가지고 있지 않더라도, 권한을 가지고 있는 앱으로부터 결과 인텐트를 받거나, 권한을 가지고 있는 앱을 실행하여 사용자가 거기서 필요한 일을 하도록 유도함으로써, 사용자가 간접적으로 데이터에 액세스하는 것입니다. 


임시 권한을 이용해서 액세스하기

내 앱이 필요한 권한을 가지고 있지 않더라도, 권한을 가지고 있는 앱에 인텐트를 보내서 "URI" 권한들을 포함하고 있는 결과 인텐트를 받아 데이터에 액세스할 수 있습니다. 이러한 URI 권한들은 인텐트를 받은 액티비티가 종료되기 전에 결과 인텐트에 담아준 컨텐트 URI에 대한 권한들입니다. 영구적인 권한을 가지고 있는 앱이 결과 인텐트에 플래그(flag)를 셋팅함으로써 임시 권한을 만들어 줄 수 있다는 것입니다:

  • 읽기 권한: FLAG_GRANT_READ_URI_PERMISSION
  • 쓰기 권한: FLAG_GRANT_WRITE_URI_PERMISSION

메모: 위의 플래그들은 프로바이더에 대한 일반적인 읽기 및 쓰기 권한을 주는 것이 아닙니다. 이것은 오직 해당 URI에만 주어지는 임시 권한입니다.

프로바이더는 매니페스트 파일에서, <provider> 요소의 android:grantUriPermission 속성값을 설정하거나, <provider> 요소의 자식 요소로 <grant-uri-permission> 요소를 추가함으로써, 컨텐트 URI들에 대한 URI 권한들을 정의합니다. 

예를 들어, 내 앱이 READ_CONTACTS 권한을 가지고 있지 않더라도 주소록 프로바이더에서 주소록 데이터를 얻을 수 있습니다. 주소록에 있는 누군가에게 생일 축하 메시지를 보내는 상황을 생각해 보겠습니다. 이때, 모든 주소록 정보에 액세스할 수 있는 READ_CONTACTS 권한을 요청하지 않고, 누구에게 보낼지 사용자가 직접 선택할 수 있도록만 하면 될 것입니다. 그러기 위해서는 아래 절차대로 진행하면 됩니다:

1. 내 앱은 액션값이 ACTION_PICK이고 CONTENT_ITEM_TYPE의 값이 "contacts"인 인텐트를 startActivityForResult()메소드를 통해 보냅니다.

2. 이 인텐트는 주소록앱의 사람 선택하는 액티비티와 매치되기 때문에, 그 액티비티가 전면(foreground)으로 나옵니다.

3. 그 액티비티에서 사용자가 사람을 선택하면, 액티비티는 setResult(resultCode, intent)를 호출함으로써 내 앱에 전해줄 결과 인텐트를 셋팅합니다. 그 결과 인텐트는 사용자가 선택한 사람의 주소록 정보에 대한 컨텐트 URI를 가지고 있으며, FLAG_GRANT_READ_URI_PERMISSION이 셋팅되어 있습니다. 이 플래그는 내 앱에서 해당 컨텐트 URI에 대한 정보를 읽을 수 있음을 의미합니다. 사람 선택하는 액티비티는 이렇게 setResult()메소드를 호출해주고 나서 finish()를 호출하여 종료됩니다. 

4. 그러면 내 앱의 마지막 액티비티가 다시 전면으로 나오게 되고, onActivityResult(requestCode, resultCode, intent) 메소드가 호출됩니다. 사람 선택 액티비티에서 셋팅한 결과 인텐트를 여기에서 매개변수로 받습니다. 

5. 이제 결과 인텐트에 있는 컨텐트 URI를 이용하여 주소록 프로바이더로부터 데이터를 읽을 수 있습니다. 매니페스트 파일에 읽기 권한을 선언하지 않았지만, 생일이나 이메일 등과 같은 주소록 데이터를 얻어낼 수 있는 것입니다. 


다른 앱을 이용하기

컨텐트 프로바이더가 제공하는 데이터를 변경하고 싶은데 내 앱에 그럴 권한이 없을때는, 권한이 있는 다른 앱을 실행하여 사용자가 거기서 데이터를 변경하도록 하면 됩니다.

예를 들어, 캘린더 앱은 ACTION_INTENT 액션값을 갖는 인텐트에 대한 동작이 정의되어 있어서 해당 인텐트를 받으면, 데이터를 추가할 수 있는 화면을 실행해 줍니다. 그리고 인텐트에 "extra" 데이터를 전달해 주면 화면의 입력란에 전달받은 값들을 미리 셋팅해 줍니다. 기념일을 설정하는 등의 작업을 API를 통해 직접 처리하려면 복잡하기 때문에, 캘린더 앱을 실행하여 사용자가 거기서 관련된 일을 하도록 하는 것이 좋습니다. 


컨트랙트 클래스(Contract Classes)

컨트랙트 클래스는 컨텐트 URI, 컬럼명, 인텐트의 액션값 및 컨텐트 프로바이더의 기타 다른 기능들에 대한 상수값들을 정의하는 클래스입니다. 컨트랙트 클래스는 프로바이더에 자동적으로 포함되는 것이 아니기 때문에, 프로바이더를 개발할 때 값들을 정의하고 다른 개발자들이 사용할 수 있도록 만들어줘야 합니다. 안드로이드 플랫폼에 포함되어 있는 프로바이더들의 컨트랙트 클래스들은 대부분 android.provider 패키지 안에 있습니다. 

예를 들어, 사용자 사전 프로바이더의 컨텐트 URI와 컬럼명들을 담고 있는 컨트랙트 클래스는 UserDictionary 클래스입니다. "words" 테이블에 대한 컨텐트 URI는 UserDictionary.Words.CONTENT_URI 에 정의되어 있습니다. UserDictionary.Words 클래스에는 "words" 테이블의 컬럼명들도 정의되어 있습니다. 그 사용예는 아래와 같습니다:

String[] mProjection =
{
   
UserDictionary.Words._ID,
   
UserDictionary.Words.WORD,
   
UserDictionary.Words.LOCALE
};

다른 예로, 주소록 프로바이더의 컨트랙트 클래스는 ContactsContract 클래스입니다. 이 클래스의 레퍼런스 문서에는 예제 코드들도 포함되어 있습니다. 이 클래스의 서브클래스인 ContactsContract.Intents.Insert 클래스는 인텐트에 담을 데이터들에 대한 상수값들을 정의하고 있습니다. 


MIME 타입들

컨텐트 프로바이더는 표준적인 MIME 미디어 타입을 리턴하거나, 커스텀 MIME 타입을 리턴할 수 있습니다.

MIME 타입은 아래와 같은 형태를 갖습니다:

type/subtype

예를 들어, 잘 알려진 MIME 타입 중에 text/html 이 있는데, type이 text이고 subtype이 html 입니다. 프로바이더가 리턴한 MIME 타입이 text/html 이라면, 그 내용은 HTML 태그를 포함한 텍스트입니다. 

"벤더 고유의(vendor-specific)" MIME 타입이라고도 불리는 커스텀 MIME 타입은 좀더 복잡하게 생긴 type과 subtype을 갖습니다.

type은 두개 이상의 행(row)을 위한 경우 아래와 같고,

vnd.android.cursor.dir

하나의 행(row)을 위한 경우는 아래와 같습니다:

vnd.android.cursor.item

subtype은 프로바이더를 나타냅니다(provider-specific). 안드로이드 플랫폼에 포함되어 있는 프로바이더들은 보통 간단한 subtype을 갖습니다. 예를 들어, 주소록 앱에서 전화번호 정보를 저장할 때, 저장되는 데이터의 MIME 타입은 아래와 같습니다:

vnd.android.cursor.item/phone_v2

다른 프로바이더 개발자들은 그들의 프로바이더의 authority 부분과 테이블명에 기반하여 subtype을 정의할 수도 있을 것입니다. 예를 들어, 열차시간표 정보를 제공하는 프로바이더가 있다고 가정해 보겠습니다. 프로바이더의 authority 부분은 com.example.trains 이고, 테이블은 Line1, Line2, Line3 가 있습니다. Line1 테이블의 데이터들에 대한 컨텐트 URI는 다음과 같을 것이고,

content://com.example.trains/Line1

리턴 받는 MIME 타입은 아래와 같을 것입니다:

vnd.android.cursor.dir/vnd.example.line1

그리고 Line2 테이블의 5번 행(row)에 대한 컨텐트 URI는 다음과 같을 것이고,

content://com.example.trains/Line2/5

리턴 받는 MIME 타입은 아래와 같을 것입니다:

vnd.android.cursor.item/vnd.example.line2

대부분의 컨텐트 프로바이더들은 사용할 MIME 타입들을 위해 컨트랙트 클래스를 정의합니다. 예를 들어, 주소록 프로바이더의 컨트랙트 클래스인 ContactsContract.RawContacts 클래스는 주소록 데이터의 MIME 타입으로 CONTENT_ITEM_TYPE 을 정의합니다. 

하나의 데이터(single row)를 얻기 위한 컨텐트 URI에 대해서는 컨텐트 URI 섹션에서 설명하고 있습니다. 


Posted by 개발자 김태우
,

(원문: 

http://developer.android.com/guide/topics/providers/content-providers.html)

(위치: Develop > API Guides > Content Providers)

(전체 번역 글 목록으로 이동하기)


컨텐트 프로바이더에 대하여
(Content Providers)


컨텐트 프로바이더는 구조화된 데이터들에 접근할 수 있도록 해주는 컴포넌트입니다. 컨텐트 프로바이더는 데이터를 캡슐화하고, 데이터 보안을 위한 메카니즘을 제공합니다. 컨텐트 프로바이더는 다른 프로세스에서 내 프로세스의 데이터를 사용할 수 있도록 하기 위한 표준적인 인터페이스를 제공합니다. 

컨텐트 프로바이더를 통해 데이터를 가져오고 싶다면, ContextgetContentResolver()로 얻은 ContentResolver 객체를 프로바이더에 대한 클라이언트로서 사용해야 합니다. ContentResolver 객체는 ContentProvider 추상 클래스를 구현하는 프로바이더 객체와 통신합니다. 프로바이더 객체는 클라이언트로부터 요청을 받고, 요청 받은 작업을 한 후, 결과를 리턴해 줍니다. 

만약 내 앱의 데이터를 다른 앱과 공유할 생각이 없다면, 자체적인 프로바이더를 개발할 필요도 없습니다. 하지만, 내 앱의 데이터를 활용한 검색 추천 단어들을 다른 앱에도 제공하고 싶다거나, 내 앱의 복잡한 데이터나 파일들을 다른 앱에서 가져갈 수 있도록 하고 싶다면, 자체적인 프로바이더를 개발할 필요가 있습니다. 

안드로이드 플랫폼은 오디오, 비디오, 이미지, 주소록 정보 등을 다루는 기본적인 몇가지 컨텐트 프로바이더들을 포함하고 있습니다. 이는 android.provider 패키지의 레퍼런스 문서에서 확인할 수 있습니다. 이 프로바이더들은 (일부 제한적인 부분들이 있긴 하지만) 모든 앱에서 사용가능 합니다. 

자세한 내용들은 아래 주제들을 통해 학습할 수 있습니다:

컨텐트 프로바이더 기초

데이터들이 테이블 형태로 조직화 되어 있을때, 컨텐트 프로바이더를 통해 데이터에 접근하는 방법

컨텐트 프로바이더 만들기

자체적인 컨텐트 프로바이더를 만드는 방법

캘린더 프로바이더

안드로이드 플랫폼에 포함되어 있는 캘린더 프로바이더 사용법

주소록 프로바이더

안드로이드 플랫폼에 포함되어 있는 주소록 프로바이더 사용법


Posted by 개발자 김태우
,

(원문: http://developer.android.com/guide/components/aidl.html)

(위치: Develop > API Guides > App Components > Services 
> AIDL)

(전체 번역 글 목록으로 이동하기)


안드로이드 인터페이스 정의 언어
(Android Interface Definition Language (AIDL))


AIDL은 IDL(인터페이스 정의 언어)들과 비슷합니다. 이것은 클라이언트와 서버가 서로 다른 프로세스에서도 통신(IPC)할 수 있도록 프로그래밍 인터페이스를 정의하는데에 사용됩니다. 안드로이드 시스템에서, 프로세스는 다른 프로세스의 메모리에 직접 접근할 수 없습니다. 따라서 다른 프로세스와 통신하기 위해서는, 우선 객체들을 OS가 이해할 수 있도록 원시 타입의 데이터(primitives)로 분리하고, 마샬링(직렬화와 유사)을 할 필요가 있습니다. 그런데 마샬링을 위한 개발작업은 상당히 지루하기 때문에, 안드로이드는 AIDL을 이용하여 그것의 상당부분을 대신 해줍니다. 

메모: AIDL은, 다른 앱의 클라이언트가 내 앱의 서비스에 IPC를 해야하고, 내 앱의 서비스가 IPC를 통해 들어온 요청들을 멀티쓰레드 방식으로 처리해야하는 경우에만 직접 사용하고, 그렇지 않은 경우에는 다른 방법들을 사용하기를 권장합니다. 만약 다른 앱에서 IPC를 하는 것이 아니라, 내 앱 내에서 앱컴포넌트가 서비스를 바인드하는 경우라면, Binder를 확장하는 방식을 사용합니다. 그리고 만약 IPC를 사용해야하기는 하지만 멀티쓰레드 방식으로 동시에 요청을 처리할 필요가 없다면, Messenger를 사용하면 됩니다. AIDL을 구현하기 전에 일단은 바인드 되는 서비스에 대해 학습하시기를 권장합니다. 

AIDL 인터페이스를 사용하도록 설계를 시작하기 전에, AIDL 인터페이스는 함수를 직접 호출하는 방식임을 기억하시기 바랍니다(Handler를 이용하는 방식이 아니라는 의미입니다). 그래서 호출이 내 로컬 프로세스의 쓰레드에서 발생한 것인지, 아니면 원격 프로세스의 쓰레드에서 발생한 것인지에 따라 차이가 있습니다. 자세한 내용은 아래와 같습니다:

  • 로컬 프로세스에서 호출이 발생하는 경우에는, 호출을 하는 쪽과 호출을 받는 쪽이 같은 쓰레드에서 실행됩니다. 메인 UI 쓰레드에서 AIDL 인터페이스를 호출했다면 메인 UI 쓰레드에서 서비스 로직이 실행되고, 다른 쓰레드에서 호출했다면 그 쓰레드에서 실행된다는 것입니다. 따라서, 로컬 쓰레드들에서만 서비스가 실행된다면, 어떤 쓰레드에서 서비스가 실행되는지를 관리할 수 있습니다 (그러나 이러한 경우라면, AIDL을 사용하지 말고, Binder를 확장하는 방법을 사용하시기 바랍니다). 
  • 원격 프로세스에서 호출이 발생하는 경우에는, 내 앱의 프로세스 안에서 관리되는 쓰레드풀로부터 호출이 옵니다. 따라서 임의의 쓰레드들(unknown threads)로부터 호출이 오는 것과, 동시에 여려개의 호출이 오는 것에 대한 대비를 해야 합니다. 다시 말해서, AIDL 인터페이스를 구현할 때는 멀티쓰레드 문제를 고려해야 한다는 것입니다(thread-safe).
  • oneway는 원격 호출의 실행 방식을 지정하는 키워드 중 하나로, 인터페이스 선언부나 인터페이스의 메소드 선언부에 붙일 수 있습니다. oneway 키워드를 사용하면, 원격 호출시 블록킹 되지 않고(not block), 데이터를 전송한 직후에 바로 리턴됩니다. 인터페이스의 구현체는 그 전송된 데이터를, Binder 쓰레드풀로부터 받습니다. 하지만 oneway로 지정된 인터페이스라도, 로컬 프로세스 내에서의 호출에 대해서는 효과가 없으며, 여전히 동기적 방식으로 동작하게 됩니다. 


AIDL 인터페이스 정의하기

AIDL 인터페이스는 .aidl 파일 안에 자바 언어의 문법으로 정의해야 하며, .aidl 파일은 서비스를 가지고 있는 앱과 그 서비스를 바인드 하려는 앱 모두의 소스코드 경로(src/ 디렉토리) 아래에 저장해야 합니다. 

.aidl 파일을 포함하고 있는 프로젝트를 빌드하게 되면, 안드로이드 SDK툴이 .aidl 파일에 기반한 IBinder 인터페이스를 생성하여 gen/ 디렉토리 아래에 저장합니다. 서비스는 IBinder 인터페이스를 적절히 구현해야 하며, 클라이언트는 그 IBinder 객체를 통해 서비스와 프로세스간 통신(IPC)을 할 수 있습니다. 

AIDL을 이용하여 바인드 되는 서비스를 만들기 위해서는, 아래 단계를 따르시기 바랍니다:

1. .aidl 파일 생성

.aidl 파일은 서비스에서 제공할 메소드에 대한 프로그래밍 인터페이스를 정의합니다.

2. 인터페이스 구현

안드로이드 SDK툴은 .aidl 파일에 기반하여 자바 인터페이스를 생성합니다. 이 인터페이스는 Binder클래스를 확장하는 Stub이라는 추상 클래스를 포함하고 있는데, 여기에는 .aidl 파일에 정의된 인터페이스들이 자바 인터페이스 메소드들로 선언되어 있습니다. 이 Stub 추상 클래스를 확장하여 인터페이스 메소드들을 구현해야 합니다.

3. 클라이언트에 인터페이스 전달

Service 클래스를 상속받아서 내 앱의 서비스를 만들고, onBind() 콜백 메소드를 오버라이드하여, Stub을 확장하여 구현한 클래스의 객체를 리턴해줍니다.

주의사항: 앱이 출시되어 서비스가 공개된 이후에 AIDL 인터페이스를 수정해야 하는 상황이 발생한다면, 이미 이전 버전의 AIDL 인터페이스를 이용하여 내 앱의 서비스를 사용하고 있는 다른 앱과의 충돌을 방지하기 위하여, 하위 호환(backward compatible)이 되어야 합니다. 다른 앱에서 AIDL 인터페이스를 이용하기 위해 내 앱의 .aidl 파일을 복사해서 사용했을 것이기 때문에, 여기에 정의되어 있는 인터페이스 메소드들은 그 형태가 유지되어야 합니다. 


1. .aidl 파일 생성

AIDL은 하나 또는 그 이상의 메소드를 포함하는 인터페이스를 선언하기 위해 간단한 문법을 사용하는데, 각 메소드는 매개변수와 리턴값을 가질 수 있습니다. 매개변수와 리턴값은 자바의 모든 타입이 가능하며, 심지어 다른 AIDL에 의해 생성된 인터페이스도 가능합니다. 

.aidl 파일은 자바 언어로 작성합니다. 하나의 .aidl 파일에는 하나의 인터페이스만 정의되어야 하며, 하나의 인터페이스 선언부와 메소드들의 선언부로 구성됩니다. 

기본적으로, AIDL은 아래의 데이터 타입들을 지원합니다:

  • 자바 언어의 원시 타입 데이터들 (int, long, char, boolean 등) 
  • String
  • CharSequence
  • List
    List의 모든 구성요소는 이 섹션에서 소개하는 데이터 타입이거나, 다른 AIDL에서 생성된 인터페이스이거나, Parcelable을 구현한 데이터 타입이어야 합니다. List는 (List<String>처럼) 제너릭("generic") 클래스가 될 수도 있습니다. 그리고 메소드를 정의할 때 List 인터페이스를 사용했다하더라도, 실제로 생성된 클래스에서는 항상 ArrayList로 생성됩니다. 
  • Map
    List와 마찬가지로 Map의 모든 구성요소도 이 섹션에서 소개하는 데이터 타입이거나, 다른 AIDL에서 생성된 인터페이스이거나, Parcelable을 구현한 데이터 타입이어야 합니다. 하지만 (Map<String, Integer>와 같은) 제너릭("generic") Map은 지원되지 않습니다. 그리고 메소드를 정의할 때 Map 인터페이스를 사용했다하더라도, 실제로 생성된 클래스에서는 항상 HashMap으로 생성됩니다.

위의 목록에 나열된 것들(원시 타입, String, CharSequence, List, Map) 이외의 데이터 타입들(다른 AIDL 인터페이스, Parcelable)을 사용하기 위해서는 import 구문을 작성해야 합니다. 이는 인터페이스와 같은 패키지에 속해 있는 것들이라도 마찬가지입니다. 

인터페이스를 정의할 때, 아래 내용들을 알아두시기 바랍니다:

  • 메소드들은 0개 이상의 매개변수를 가질 수 있고, 결과를 리턴하거나, 리턴 타입이 void일 수 있습니다.
  • 원시 타입이 아닌 모든 매개변수들 앞에는, 어느 방향으로의 매개변수인지를 가리키는 in 또는 out 또는 inout 이 있어야 합니다. 
    원시 타입은 기본적으로 in 이며, 다른 것은 불가능합니다.

    주의사항: 데이터를 마샬링하는 것은 시스템 입장에서 꽤 부담스러운 작업이기 때문에, 필요에 맞춰서 매개변수의 방향을 정확히 정해주는 것이 좋습니다.

  • .aidl 파일에 포함된 모든 주석들은 생성된 IBinder 인터페이스에도 포함됩니다(package 및 import 구문 위에 추가한 주석은 제외됩니다). 
  • 메소드만 지원됩니다. AIDL에서 정적 변수(static fields)는 전달할 수 없습니다. 

아래 코드는 .aidl 파일의 예제입니다:

// IRemoteService.aidl
package com.example.android;

// Declare any non-default types here with import statements

/** Example service interface */
interface IRemoteService {
   
/** Request the process ID of this service, to do evil things with it. */
   
int getPid();

   
/** Demonstrates some basic types that you can use as parameters
     * and return values in AIDL.
     */

   
void basicTypes(int anInt, long aLong, boolean aBoolean, float aFloat,
           
double aDouble, String aString);
}

.aidl 파일을 프로젝트의 src/ 디렉토리 아래의 적당한 경로에 저장하고 빌드를 하면, SDK툴이 IBinder 인터페이스 파일을 gen/ 디렉토리 아래의 대응되는 경로에 생성해 줍니다. 생성되는 IBinder 파일의 이름은 .aidl 파일의 이름과 같지만 확장자만 .java로 바뀝니다(예를 들어, IRemoteService.aidl에 의해서 생성된 파일은 IRemoteService.java입니다).

개발할 때 이클립스나 안드로이드 스튜디오를 사용한다면, 빌드할 때 .aidl이 있을 경우 알아서 IBinder 인터페이스를 생성해 줍니다. 하지만 이클립스나 안드로이드 스튜디오를 사용하지 않는다면, Ant툴을 사용하여 IBinder 인터페이스를 생성할 수 있습니다. 이 경우, .aidl 파일을 작성한 후에 ant debug(또는 ant release)를 하여 프로젝트를 빌드해야 IBinder 인터페이스가 생성되어 다른 코드들과 제대로 연결됩니다. 


2. 인터페이스 구현

.aidl 파일을 저장하고 나서 빌드를 하면, 안드로이드 SDK툴이 .aidl 파일과 (확장자 앞의) 이름이 같은 .java 인터페이스 파일을 생성합니다. 생성된 인터페이스 안에는 Stub 이라는 서브클래스가 포함되어 있는데, 이것은 부모인 인터페이스를 구현하는 추상 클래스입니다. 그리고 인터페이스 안에는 .aidl 파일 안에 있던 메소드들이 모두 선언되어 있습니다. 

메모: Stub에는 몇개의 헬퍼 메소드들도 정의되어 있는데, 그 중에서도 가장 눈에 띄는 것이 asInterface()입니다. 이것은 IBinder를 인자로 받고, Stub 인터페이스를 구현한 객체를 리턴해줍니다. asInterface()는 보통 클라이언트의 onServiceConnected()에서 매개변수로 받은 IBinder 객체를 인자로 넘깁니다. 자세한 내용은 아래의 IPC 메소드 호출하기에서 학습하실 수 있습니다. 

.aidl로부터 생성된 인터페이스를 구현하기 위해서는, YourInterface.Stub을 확장하고, .aidl에서 선언했던 메소드들을 구현해야 합니다. (YourInterface.StubBinder 클래스를 확장합니다.)

아래 예제 코드는, 위의 IRemoteService.aidl 파일로부터 생성된 IRemoteService 인터페이스를 구현하는 것을 보여줍니다:

private final IRemoteService.Stub mBinder = new IRemoteService.Stub() {
   
public int getPid(){
       
return Process.myPid();
   
}
   
public void basicTypes(int anInt, long aLong, boolean aBoolean,
       
float aFloat, double aDouble, String aString) {
       
// Does nothing
   
}
};

위의 코드에서 mBinder는 (Binder 클래스를 확장한) Stub 클래스의 객체이며, RPC(원격 프로시저 호출) 인터페이스를 정의하고 있습니다. 이 객체가 클라이언트에게 전달되며, 서비스와 상호작용할 수 있게 해주는 것입니다. 

AIDL 인터페이스를 구현할 때 알아둬야할 몇가지 규칙이 있습니다:

  • 바인더 객체의 메소드가 메인쓰레드에서 호출된다는 보장이 없기 때문에, 서비스를 멀티쓰레드 환경에서 안전하도록 만들어야 합니다(thread-safe).
  • 기본적으로, RPC 호출은 동기적으로(synchronous) 동작합니다. 만약 서비스가 어떤 요청을 처리하는데 시간이 꽤 걸린다는 것을 안다면, 액티비티의 메인쓰레드에서 그 요청에 대한 호출을 하면 안됩니다. 왜냐하면 그 시간 동안 앱이 멈추기 때문입니다(멈추고 나서 일정시간 이상 지나면 ANR(Application is Not Responding) 다이얼로그를 보게 됩니다). 따라서 이러한 경우에 클라이언트는 다른 쓰레드에서 호출하도록 해야 합니다.
  • 서비스에서 발생시킨 예외(exception)를 클라이언트로 보낼 수 없습니다.


3. 클라이언트에 인터페이스 전달

서비스에 바인더 인터페이스를 구현했다면, 그것을 클라이언트가 바인드하여 가져갈 수 있도록 해줘야 합니다. 그러기 위해서는, 서비스의 onBind() 콜백 메소드를 구현하여, Stub을 구현한 객체를 리턴해줘야 합니다. 아래 예제 코드는 IRemoteService에 대한 구현 객체를 클라이언트에게 리턴해주는 것을 보여줍니다.

public class RemoteService extends Service {
   
@Override
   
public void onCreate() {
       
super.onCreate();
   
}

   
@Override
   
public IBinder onBind(Intent intent) {
       
// Return the interface
       
return mBinder;
   
}

   
private final IRemoteService.Stub mBinder = new IRemoteService.Stub() {
       
public int getPid(){
           
return Process.myPid();
       
}
       
public void basicTypes(int anInt, long aLong, boolean aBoolean,
           
float aFloat, double aDouble, String aString) {
           
// Does nothing
       
}
   
};
}

이제 (액티비티와 같은) 클라이언트가 서비스를 바인드하기 위해 bindService()를 호출하면, 서비스의 onBind()에서 리턴해주는 mBinder를, 클라이언트의 onServiceConnected() 콜백 메소드에서 받을 수 있습니다. 

클라이언트는 넘겨받은 mBinder를 사용해야 하는데, 만약 클라이언트와 서비스가 서로 다른 앱에 있다면, 클라이언트 앱의 src/ 디렉토리 아래에는 서비스 앱의 .aidl 파일을 복사하여 저장해야 합니다(그래야 AIDL 메소드들을 호출할 수 있는 android.os.Binder 인터페이스에 대한 구현체가 클라이언트에 생성됩니다).

클라이언트가 onServiceConnected()에서 IBinder를 전달 받았다면, 이제 YourServiceInterface.Stub.asInterface(service)를 호출하여 YourServiceInterface 타입의 인터페이스를 받아서 그것을 사용하면 됩니다. 아래는 예제 코드입니다:

IRemoteService mIRemoteService;
private ServiceConnection mConnection = new ServiceConnection() {
   
// Called when the connection with the service is established
   
public void onServiceConnected(ComponentName className, IBinder service) {
       
// Following the example above for an AIDL interface,
       
// this gets an instance of the IRemoteInterface, which we can use to call on the service
        mIRemoteService
= IRemoteService.Stub.asInterface(service);
   
}

   
// Called when the connection with the service disconnects unexpectedly
   
public void onServiceDisconnected(ComponentName className) {
       
Log.e(TAG, "Service has unexpectedly disconnected");
        mIRemoteService
= null;
   
}
};

샘플 코드는 ApiDemos 샘플앱의 RemoteService.java를 참고하시기 바랍니다.


IPC로 객체 전달하기

IPC(프로세스간 통신) 인터페이스를 통해 어떤 프로세스에서 다른 프로세스로 전송할 수 있는 클래스를 만들기 위해서는, 그 클래스가 Parcelable 인터페이스를 구현하고 있어야 합니다. 그것은 객체를 원시타입의 데이터로 분리하는 마샬링 과정을 포함하는데, 이는 안드로이드 시스템이 서로 다른 프로세스 간에 객체를 전달하는데에 있어서 중요한 과정입니다. 

Parcelable 프로토콜을 지원하는 클래스를 만드는 과정은 아래와 같습니다: 

1. 클래스가 Parcelable 인터페이스를 구현하도록 선언합니다(클래스 선언부에 implements Parcelable 을 추가합니다).

2. writeToParcel 메소드를 구현하여, 매개변수로 받은 Parcel 객체에 본 객체의 현재 상태들을 저장합니다(writes).

3. Parcelable.Creator 인터페이스의 구현객체를 CREATOR라는 이름의 정적 변수에 저장합니다.

4. 마지막으로, parcelable 클래스를 선언하는 .aidl 파일을 생성합니다(아래에 Rect.aidl 파일이 있습니다). 만약 자체적인 빌드 프로세스를 사용한다면, .aidl 파일을 빌드에 추가하지 않아도 됩니다. C 언어에서의 헤더 파일처럼, .aidl 파일도 컴파일 되지는 않기 때문입니다. 

AIDL은 위에서 설명한 메소드 및 변수를 이용하여 객체를 마샬링 및 언마샬링 합니다.

아래 코드는 Rect 클래스가 parcelable 하다고 선언해주는 Rect.aidl 파일의 코드입니다:

package android.graphics;

// Declare Rect so AIDL can find it and knows that it implements
// the parcelable protocol.
parcelable
Rect;

그리고 아래 코드는 Rect 클래스가 Parcelable 프로토콜을 구현하는 방법을 보여줍니다:

import android.os.Parcel;
import android.os.Parcelable;

public final class Rect implements Parcelable {
   
public int left;
   
public int top;
   
public int right;
   
public int bottom;

   
public static final Parcelable.Creator<Rect> CREATOR = new
Parcelable.Creator<Rect>() {
       
public Rect createFromParcel(Parcel in) {
           
return new Rect(in);
       
}

       
public Rect[] newArray(int size) {
           
return new Rect[size];
       
}
   
};

   
public Rect() {
   
}

   
private Rect(Parcel in) {
        readFromParcel
(in);
   
}

   
public void writeToParcel(Parcel out) {
       
out.writeInt(left);
       
out.writeInt(top);
       
out.writeInt(right);
       
out.writeInt(bottom);
   
}

   
public void readFromParcel(Parcel in) {
        left
= in.readInt();
        top
= in.readInt();
        right
= in.readInt();
        bottom
= in.readInt();
   
}
}

Rect 클래스를 마샬링하는 것은 꽤 간단합니다. Parcel 객체는 저장하려는 여러 종류의 데이터들에 대하여 타입별로 메소드를 제공합니다(writeInt(), writeLong(), writeString() 등).

경고: 다른 프로세스로부터 데이터를 받을 때는 보안적 문제가 생길 수도 있다는 점을 명심해야 합니다. 위의 예제에서, Rect는 Parcel로부터 4개의 정수를 읽어오는데, 여기서 읽어온 값들이 허용가능한 범위의 값들인지를 확인하는 것은 내 앱에서 해야할 작업입니다. 내 앱이 악성코드로부터 안전하도록 만들기 위한 자세한 내용은 보안과 퍼미션에서 학습하실 수 있습니다.


IPC 메소드 호출하기

AIDL로 정의된 인터페이스를 구현한 클래스를 클라이언트에서 호출하기까지의 과정은 아래와 같습니다:

1. 프로젝트의 src/ 디렉토리에 .aidl 파일을 추가합니다.

2. AIDL에 의해 생성된 IBinder 객체를 선언합니다.

3. ServiceConnection을 구현합니다.

4. Context.bindService() 메소드를 호출하는데, 이때 ServiceConnection의 구현객체를 인자로 넘깁니다.

5. ServiceConnectiononServiceConnected() 콜백 메소드에서 IBinder 객체를 (service라는 매개변수로) 전달 받는데, 여기서 YourInterface.Stub.asInterface((IBinder)service) 를 호출하여 YourInterface 타입의 결과값을 받아 멤버변수로 저장합니다.

6. 이제 인터페이스에 정의한 메소드들을 호출하면 됩니다. 이때 DeadObjectException에 대한 예외처리를 해줘야 하는데, 이것은 원격 메소드 호출시에만 발생하는 예외로서, 이 경우에는 예상치 못하게 연결이 끊어진 상태에서 메소드를 호출했을 때 발생합니다.

7. 연결을 종료하기 위해서는 Context.unbindService()를 호출하는데, 이때 ServiceConnection의 구현객체를 인자로 넘깁니다.  

IPC로 서비스를 호출함에 있어서 알아둬야할 사항 2가지를 추가합니다:

  • 객체들은 프로세스를 가로질러 레퍼런스 카운트(reference counted)됩니다.
  • 원격 메소드의 인자로 익명의 객체(anonymous objects, new Object { ... }와 같은 형태)를 넘길 수 있습니다.

서비스를 바인드하는 것과 관련된 더 자세한 내용은 서비스 바인드하기에서 학습하실 수 있습니다.

아래 예제 코드는 AIDL로 생성된 인터페이스를 호출하는 샘플 코드이며, ApiDemos 샘플앱의 RemoteService를 바인드하는 액티비티입니다.

public static class Binding extends Activity {
   
/** The primary interface we will be calling on the service. */
   
IRemoteService mService = null;
   
/** Another interface we use on the service. */
   
ISecondary mSecondaryService = null;

   
Button mKillButton;
   
TextView mCallbackText;

   
private boolean mIsBound;

   
/**
     * Standard initialization of this activity.  Set up the UI, then wait
     * for the user to poke it before doing anything.
     */

   
@Override
   
protected void onCreate(Bundle savedInstanceState) {
       
super.onCreate(savedInstanceState);

        setContentView
(R.layout.remote_service_binding);

       
// Watch for button clicks.
       
Button button = (Button)findViewById(R.id.bind);
        button
.setOnClickListener(mBindListener);
        button
= (Button)findViewById(R.id.unbind);
        button
.setOnClickListener(mUnbindListener);
        mKillButton
= (Button)findViewById(R.id.kill);
        mKillButton
.setOnClickListener(mKillListener);
        mKillButton
.setEnabled(false);

        mCallbackText
= (TextView)findViewById(R.id.callback);
        mCallbackText
.setText("Not attached.");
   
}

   
/**
     * Class for interacting with the main interface of the service.
     */

   
private ServiceConnection mConnection = new ServiceConnection() {
       
public void onServiceConnected(ComponentName className,
               
IBinder service) {
           
// This is called when the connection with the service has been
           
// established, giving us the service object we can use to
           
// interact with the service.  We are communicating with our
           
// service through an IDL interface, so get a client-side
           
// representation of that from the raw service object.
            mService
= IRemoteService.Stub.asInterface(service);
            mKillButton
.setEnabled(true);
            mCallbackText
.setText("Attached.");

           
// We want to monitor the service for as long as we are
           
// connected to it.
           
try {
                mService
.registerCallback(mCallback);
           
} catch (RemoteException e) {
               
// In this case the service has crashed before we could even
               
// do anything with it; we can count on soon being
               
// disconnected (and then reconnected if it can be restarted)
               
// so there is no need to do anything here.
           
}

           
// As part of the sample, tell the user what happened.
           
Toast.makeText(Binding.this, R.string.remote_service_connected,
                   
Toast.LENGTH_SHORT).show();
       
}

       
public void onServiceDisconnected(ComponentName className) {
           
// This is called when the connection with the service has been
           
// unexpectedly disconnected -- that is, its process crashed.
            mService
= null;
            mKillButton
.setEnabled(false);
            mCallbackText
.setText("Disconnected.");

           
// As part of the sample, tell the user what happened.
           
Toast.makeText(Binding.this, R.string.remote_service_disconnected,
                   
Toast.LENGTH_SHORT).show();
       
}
   
};

   
/**
     * Class for interacting with the secondary interface of the service.
     */

   
private ServiceConnection mSecondaryConnection = new ServiceConnection() {
       
public void onServiceConnected(ComponentName className,
               
IBinder service) {
           
// Connecting to a secondary interface is the same as any
           
// other interface.
            mSecondaryService
= ISecondary.Stub.asInterface(service);
            mKillButton
.setEnabled(true);
       
}

       
public void onServiceDisconnected(ComponentName className) {
            mSecondaryService
= null;
            mKillButton
.setEnabled(false);
       
}
   
};

   
private OnClickListener mBindListener = new OnClickListener() {
       
public void onClick(View v) {
           
// Establish a couple connections with the service, binding
           
// by interface names.  This allows other applications to be
           
// installed that replace the remote service by implementing
           
// the same interface.
            bindService
(new Intent(IRemoteService.class.getName()),
                    mConnection
, Context.BIND_AUTO_CREATE);
            bindService
(new Intent(ISecondary.class.getName()),
                    mSecondaryConnection
, Context.BIND_AUTO_CREATE);
            mIsBound
= true;
            mCallbackText
.setText("Binding.");
       
}
   
};

   
private OnClickListener mUnbindListener = new OnClickListener() {
       
public void onClick(View v) {
           
if (mIsBound) {
               
// If we have received the service, and hence registered with
               
// it, then now is the time to unregister.
               
if (mService != null) {
                   
try {
                        mService
.unregisterCallback(mCallback);
                   
} catch (RemoteException e) {
                       
// There is nothing special we need to do if the service
                       
// has crashed.
                   
}
               
}

               
// Detach our existing connection.
                unbindService
(mConnection);
                unbindService
(mSecondaryConnection);
                mKillButton
.setEnabled(false);
                mIsBound
= false;
                mCallbackText
.setText("Unbinding.");
           
}
       
}
   
};

   
private OnClickListener mKillListener = new OnClickListener() {
       
public void onClick(View v) {
           
// To kill the process hosting our service, we need to know its
           
// PID.  Conveniently our service has a call that will return
           
// to us that information.
           
if (mSecondaryService != null) {
               
try {
                   
int pid = mSecondaryService.getPid();
                   
// Note that, though this API allows us to request to
                   
// kill any process based on its PID, the kernel will
                   
// still impose standard restrictions on which PIDs you
                   
// are actually able to kill.  Typically this means only
                   
// the process running your application and any additional
                   
// processes created by that app as shown here; packages
                   
// sharing a common UID will also be able to kill each
                   
// other's processes.
                   
Process.killProcess(pid);
                    mCallbackText
.setText("Killed service process.");
               
} catch (RemoteException ex) {
                   
// Recover gracefully from the process hosting the
                   
// server dying.
                   
// Just for purposes of the sample, put up a notification.
                   
Toast.makeText(Binding.this,
                            R
.string.remote_call_failed,
                           
Toast.LENGTH_SHORT).show();
               
}
           
}
       
}
   
};

   
// ----------------------------------------------------------------------
   
// Code showing how to deal with callbacks.
   
// ----------------------------------------------------------------------

   
/**
     * This implementation is used to receive callbacks from the remote
     * service.
     */

   
private IRemoteServiceCallback mCallback = new IRemoteServiceCallback.Stub() {
       
/**
         * This is called by the remote service regularly to tell us about
         * new values.  Note that IPC calls are dispatched through a thread
         * pool running in each process, so the code executing here will
         * NOT be running in our main thread like most other things -- so,
         * to update the UI, we need to use a Handler to hop over there.
         */

       
public void valueChanged(int value) {
            mHandler
.sendMessage(mHandler.obtainMessage(BUMP_MSG, value, 0));
       
}
   
};

   
private static final int BUMP_MSG = 1;

   
private Handler mHandler = new Handler() {
       
@Override public void handleMessage(Message msg) {
           
switch (msg.what) {
               
case BUMP_MSG:
                    mCallbackText
.setText("Received from service: " + msg.arg1);
                   
break;
               
default:
                   
super.handleMessage(msg);
           
}
       
}

   
};
}


Posted by 개발자 김태우
,

(원문: http://developer.android.com/guide/components/bound-services.html)

(위치: Develop > API Guides > App Components > Services 
> Bound Services)

(전체 번역 글 목록으로 이동하기)


서비스 바인드하기
(Bound Services)


바인드 된 서비스는 서버-클라이언트 구조에서 서버에 해당합니다. 서비스는 액티비티와 같은 컴포넌트들이 바인드 한 후, 요청을 보내고, 응답을 받고, 프로세스간 통신(IPC)를 할 수 있도록 만들 수 있습니다. 바인드 된 서비스는 보통 다른 앱컴포넌트들에게 바인드 되어 있는 동안에만 살아있으므로, 후면(background)에서 계속 실행되는 것은 아닙니다.

본 문서에서는 바인드 되는 서비스를 만드는 방법과 다른 앱컴포넌트가 서비스를 어떻게 바인드하는지에 대해 학습할 것입니다. 서비스에 대한 전반적인 내용과 서비스에서 알림을 발송하는 방법, 그리고 서비스를 전면(foreground)에서 실행되도록 하는 방법 등은 서비스에 대하여에서 학습하실 수 있습니다.


기본 개념

바인드 되는 서비스는 Service 클래스를 상속받아 만들며, 다른 앱컴포넌트들이 그것을 바인드하여 상호작용할 수 있는 서비스를 말합니다. 서비스가 바인드 되기 위해서는 onBind() 콜백 메소드를 구현해야 합니다. 이 메소드는 클라이언트들이 서비스와 상호작용할 수 있는 인터페이스를 정의한 IBinder 객체를 리턴해야 합니다. 

클라이언트는 bindService()를 호출하여 서비스에 바인드할 수 있습니다. bindService()를 호출할 때 인자로 ServiceConnection의 구현객체를 전달하는데, 이것은 서비스와의 연결상태를 모니터링하는 역할을 합니다. bindService()를 호출하면 결과값 없이 바로 리턴되지만, 안드로이드 시스템에 의해 클라이언트와 서비스가 연결되면 ServiceConnection 구현객체의 onServiceConnected() 메소드가 호출되며, 여기에서 서비스와 통신할 수 있는 IBinder 객체를 전달 받습니다. 

여러개의 클라이언트들이 동시에 하나의 서비스를 바인드 할 수 있습니다. 하지만 시스템은 IBinder를 구하기 위해 처음에만 onBind() 콜백 메소드를 호출하며, 두번째 바인드 요청부터는 onBind()를 호출하지 않고 처음에 얻은 IBinder를 전달해 줍니다.

모든 클라이언트가 서비스에서 언바인드(unbind)되면, 시스템은 (startService()로 시작된 서비스가 아닌 경우에 한하여) 서비스를 종료합니다. 

바인드 되는 서비스를 구현할 때 가장 중요한 부분은 onBind() 콜백 메소드의 구현입니다. IBinder 인터페이스를 정의하는 방법은 몇가지 있는데, 이어지는 섹션들에서 그 각각의 방법들을 학습할 것입니다.


바인드 되는 서비스 만들기

바인드 되는 서비스를 만들기 위해서는, 클라이언트가 서비스와 상호작용할 수 있도록 해주는 IBinder 인터페이스를 서비스가 제공해줘야 합니다. 여기에 세가지 방법이 있습니다:

Binder 클래스 확장하기

내 앱 내에서만 사용하고, 같은 프로세스 내에 있는 클라이언트에게만 바인드 되는 서비스라면, onBind()에서 Binder 클래스를 확장한 객체를 리턴해주면 됩니다. 클라이언트는 그 Binder 객체를 받아서, Binder에서 구현한 public 메소드나 서비스의 public 메소드를 호출할 수 있습니다. 
만약 서비스가 내 앱만을 위한 것이라면 이 방법을 사용하는 것이 좋습니다. 만약 다른 앱이나 다른 프로세스에서 서비스를 사용하려고 한다면, 이 방법을 사용하면 안됩니다.

Messenger 사용하기

서로 다른 프로세스간에 통신을 해야하는 상황이라면, Messenger를 이용하여 서비스를 위한 인터페이스를 만들 수 있습니다. 이 방법에 따르면, 서비스는 몇가지 종류의 Messenger 객체에 응답하는 Handler를 정의합니다. 이 Handler 안에서 Messenger는 클라이언트와 IBinder 객체를 공유하고, 클라이언트가 Message 객체를 이용하여 서비스에게 명령을 보낼 수 있도록 해줍니다. 게다가, 클라이언트는 자신의 Messenger 객체를 정의할 수 있고, 그것을 서비스로부터 돌려받을 수 있습니다.
이 방법은 프로세스간 통신(IPC)를 하는 가장 간단한 방법입니다. 왜냐하면, Messenger는 하나의 쓰레드에서 모든 요청을 큐(queue)에 쌓아 차례대로 처리하기 때문에, 멀티쓰레드 문제(thread-safe)를 신경쓰지 않아도 되기 때문입니다.

AIDL 사용하기

AIDL (Android Interface Definition Language)은 객체들을 원시 데이터들(primitives)로 분리하는 일을 하는데, 이것은 OS가 IPC를 하기 위해 객체를 마샬링(marshall them)할 때 그 원시 데이터들을 사용하기 때문입니다. 위의 Messenger 사용하기 방법도 실제로는 AIDL에 기반하고 있습니다. 위에서 언급했듯이 Messenger는 하나의 쓰레드를 사용하기 때문에 서비스는 한번에 하나의 요청을 차례대로 처리합니다. 하지만 한번에 여러개의 요청을 동시에 처리하기를 원한다면, AIDL을 직접 사용하여 구현해야 할 것입니다. 이러한 경우, 서비스는 멀티쓰레드 문제(thread-safe)를 신경써야 합니다.
AIDL을 직접 사용하기 위해서는, 프로그래밍 인터페이스를 정의하는 파일인 .aidl 파일을 만들어야 합니다. 안드로이드 SDK 툴은 이 파일을 이용하여, 파일 안에 정의된 인터페이스를 구현하고 IPC를 다루는 추상 클래스를 하나 만들어내는데, 서비스 클래스 안에서 이것을 확장하여 실제 동작을 구현해야 합니다.

메모: 대부분의 경우, 바인드 되는 서비스를 만들기 위해 AIDL을 직접 사용하지 않는 것이 좋습니다. 왜냐하면, 멀티쓰레드 문제를 신경써야 하고, 구현하기도 더 복잡하기 때문입니다. 따라서 권장하는 방법이 아니기 때문에 본 문서에서는 AIDL의 사용방법에 대해 구체적으로 설명하지는 않겠습니다. AIDL에 대한 자세한 내용은 따로 AIDL 문서에서 학습하실 수 있습니다.


Binder 클래스 확장하기

만약 서비스가 내 앱 내에서만 사용되고 다른 프로세스에서 바인드 될 일이 없다면, 서비스를 위한 Binder 클래스를 구현하여 그 객체를 onBind()에서 리턴해주면 됩니다. 그 Binder 객체는 서비스의 public 메소드에 직접 접근할 수 있게 해줍니다.

메모: 이 방법은 클라이언트와 서비스가 같은 앱 및 같은 프로세스 내에 있을 경우에만 동작하는데, 아마도 대부분의 경우가 그러할 것입니다. 예를 들면, 후면(background)에서 음악을 재생하는 서비스와 그것을 바인드하는 액티비티로 구성된 음악앱의 경우에 이 방법을 사용할 수 있을 것입니다.

구현방법은 아래와 같습니다:

1. 서비스 안에, Binder 클래스의 구현 객체를 만듭니다.(아래 방법 중 한 가지 적용):

  • 클라이언트가 호출할 수 있는 public 메소드를 포함한 Binder 객체를 만듭니다.
  • 클라이언트가 호출할 수 있는 public 메소드를 가지고 있는 Service 객체를 리턴합니다.
  • 클라이언트가 호출할 수 있는 public 메소드를 가지고 있는, (서비스 내에 있는) 다른 객체를 리턴합니다.

2. onBind() 콜백 메소드에서 Binder 객체를 리턴해줍니다.

3. 클라이언트의 onServiceConnected()에서 Binder 객체를 받아서, 바인드 된 서비스와 통신할 수 있습니다.

메모: 서비스와 클라이언트가 같은 앱에 있어야 하는 이유는, 클라이언트가 전달받은 객체를 앱에서 구현한 Binder 클래스로 타입 캐스팅해야하기 때문입니다. 그리고 서비스와 클라이언트가 같은 프로세스에 있어야 하는 이유는, 이 방법이 객체 전달시 마샬링을 하지 않기 때문에 다른 프로세스로는 객체를 전달할 수 없기 때문입니다.

이 방법을 적용한 예제 코드는 아래와 같습니다:

public class LocalService extends Service {
   
// Binder given to clients
   
private final IBinder mBinder = new LocalBinder();
   
// Random number generator
   
private final Random mGenerator = new Random();

   
/**
     * Class used for the client Binder.  Because we know this service always
     * runs in the same process as its clients, we don't need to deal with IPC.
     */

   
public class LocalBinder extends Binder {
       
LocalService getService() {
           
// Return this instance of LocalService so clients can call public methods
           
return LocalService.this;
       
}
   
}

   
@Override
   
public IBinder onBind(Intent intent) {
       
return mBinder;
   
}

   
/** method for clients */
   
public int getRandomNumber() {
     
return mGenerator.nextInt(100);
   
}
}

LocalBinder는 클라이언트에게 서비스 객체를 주기 위해 getService() 메소드를 제공합니다. 클라이언트는 서비스 객체를 가져와서 그것의 public 메소드를 호출할 수 있습니다. 예를 들면, 클라이언트는 서비스의 getRandomNumber() 메소드를 호출할 수 있는 것입니다.

아래 예제 코드는 LocalService를 바인드하는 액티비티이며, 버튼을 클릭했을 때 getRandomNumber()를 호출하는 것을 보여줍니다:

public class BindingActivity extends Activity {
   
LocalService mService;
   
boolean mBound = false;

   
@Override
   
protected void onCreate(Bundle savedInstanceState) {
       
super.onCreate(savedInstanceState);
        setContentView
(R.layout.main);
   
}

   
@Override
   
protected void onStart() {
       
super.onStart();
       
// Bind to LocalService
       
Intent intent = new Intent(this, LocalService.class);
        bindService
(intent, mConnection, Context.BIND_AUTO_CREATE);
   
}

   
@Override
   
protected void onStop() {
       
super.onStop();
       
// Unbind from the service
       
if (mBound) {
            unbindService
(mConnection);
            mBound
= false;
       
}
   
}

   
/** Called when a button is clicked (the button in the layout file attaches to
      * this method with the android:onClick attribute) */

   
public void onButtonClick(View v) {
       
if (mBound) {
           
// Call a method from the LocalService.
           
// However, if this call were something that might hang, then this request should
           
// occur in a separate thread to avoid slowing down the activity performance.
           
int num = mService.getRandomNumber();
           
Toast.makeText(this, "number: " + num, Toast.LENGTH_SHORT).show();
       
}
   
}

   
/** Defines callbacks for service binding, passed to bindService() */
   
private ServiceConnection mConnection = new ServiceConnection() {

       
@Override
       
public void onServiceConnected(ComponentName className,
               
IBinder service) {
           
// We've bound to LocalService, cast the IBinder and get LocalService instance
           
LocalBinder binder = (LocalBinder) service;
            mService
= binder.getService();
            mBound
= true;
       
}

       
@Override
       
public void onServiceDisconnected(ComponentName arg0) {
            mBound
= false;
       
}
   
};
}

위의 예제는 클라이언트가 서비스를 바인드하기 위해 ServiceConnectiononServiceConnected() 콜백 메소드를 구현하는 방법을 보여줍니다. 

이와 관련된 다른 샘플 코드는 ApiDemos 샘플앱의 LocalServiceActivities.java 파일을 참고하시기 바랍니다.


Messenger 사용하기

서비스를 다른 프로세스와 통신할 수 있도록 만들기 위해서는, 서비스를 위한 인터페이스를 제공하는 Messenger를 사용할 수 있습니다. 이 기술은 AIDL을 직접 사용하지 않고도 프로세스간 통신(IPC)을 할 수 있도록 해줍니다.

Messenger를 사용하는 방법은 아래와 같습니다:

  • 서비스는 클라이언트의 호출 발생시 콜백을 수신하는 Handler를 구현합니다.
  • 이 Handler는 Messenger 객체를 생성할 때 생성자의 인자로 사용됩니다.
  • 서비스의 onBind()에서, Messenger 객체는 IBinder 객체를 생성하여 리턴합니다.
  • 클라이언트는 전달받은 IBinder 객체를 이용하여 Messenger 객체를 생성하며, 이 Messenger 객체를 사용하여 서비스에 Message 객체를 보낼 수 있습니다.
  • 서비스는 Handler의 handleMessage()를 통해 Message 객체를 전달받습니다.

이 방법에 따르면, 서비스는 클라이언트가 직접 호출할 "메소드"를 제공해주지 않습니다. 대신에, 클라이언트는 Message 객체들을 Messanger 객체를 통해 서비스에 전달하며, 서비스는 Handler를 통해 그것들을 받습니다.

아래는 Messenger 인터페이스를 사용하는 서비스의 예제 코드입니다:

public class MessengerService extends Service {
   
/** Command to the service to display a message */
   
static final int MSG_SAY_HELLO = 1;

   
/**
     * Handler of incoming messages from clients.
     */

   
class IncomingHandler extends Handler {
       
@Override
       
public void handleMessage(Message msg) {
           
switch (msg.what) {
               
case MSG_SAY_HELLO:
                   
Toast.makeText(getApplicationContext(), "hello!", Toast.LENGTH_SHORT).show();
                   
break;
               
default:
                   
super.handleMessage(msg);
           
}
       
}
   
}

   
/**
     * Target we publish for clients to send messages to IncomingHandler.
     */

   
final Messenger mMessenger = new Messenger(new IncomingHandler());

   
/**
     * When binding to the service, we return an interface to our messenger
     * for sending messages to the service.
     */

   
@Override
   
public IBinder onBind(Intent intent) {
       
Toast.makeText(getApplicationContext(), "binding", Toast.LENGTH_SHORT).show();
       
return mMessenger.getBinder();
   
}
}

위의 예제 코드에서 HandlerhandleMessage() 메소드가 클라이언트로부터 Message 객체들을 전달받는 곳이며, Message 객체의 what 멤버변수를 통해 어떤 요청인지 구분해야 한다는 것을 알아야 합니다.

클라이언트는 서비스로부터 전달받은 IBinder 객체를 이용하여 Messenger 객체를 생성하고, Messenger 객체의 send() 메소드를 호출하여 Message 객체를 전달할 수 있습니다. 아래 예제에서는 서비스를 바인드하고, MSG_SAY_HELLO 메시지를 전달하는 것을 보여줍니다:

public class ActivityMessenger extends Activity {
   
/** Messenger for communicating with the service. */
   
Messenger mService = null;

   
/** Flag indicating whether we have called bind on the service. */
   
boolean mBound;

   
/**
     * Class for interacting with the main interface of the service.
     */

   
private ServiceConnection mConnection = new ServiceConnection() {
       
public void onServiceConnected(ComponentName className, IBinder service) {
           
// This is called when the connection with the service has been
           
// established, giving us the object we can use to
           
// interact with the service.  We are communicating with the
           
// service using a Messenger, so here we get a client-side
           
// representation of that from the raw IBinder object.
            mService
= new Messenger(service);
            mBound
= true;
       
}

       
public void onServiceDisconnected(ComponentName className) {
           
// This is called when the connection with the service has been
           
// unexpectedly disconnected -- that is, its process crashed.
            mService
= null;
            mBound
= false;
       
}
   
};

   
public void sayHello(View v) {
       
if (!mBound) return;
       
// Create and send a message to the service, using a supported 'what' value
       
Message msg = Message.obtain(null, MessengerService.MSG_SAY_HELLO, 0, 0);
       
try {
            mService
.send(msg);
       
} catch (RemoteException e) {
            e
.printStackTrace();
       
}
   
}

   
@Override
   
protected void onCreate(Bundle savedInstanceState) {
       
super.onCreate(savedInstanceState);
        setContentView
(R.layout.main);
   
}

   
@Override
   
protected void onStart() {
       
super.onStart();
       
// Bind to the service
        bindService
(new Intent(this, MessengerService.class), mConnection,
           
Context.BIND_AUTO_CREATE);
   
}

   
@Override
   
protected void onStop() {
       
super.onStop();
       
// Unbind from the service
       
if (mBound) {
            unbindService
(mConnection);
            mBound
= false;
       
}
   
}
}

위의 예제는 서비스가 클라이언트에게 응답하는 방법을 보여주지는 않습니다. 서비스가 클라이언트에게 응답하도록 하기 위해서는, 클라이언트에서도 서비스에서처럼 Messenger 객체를 생성할 필요가 있습니다. 그런 다음에, 클라이언트의 onServiceConnected() 콜백 메소드가 호출 되었을 때, 서비스에게 보낼 Message 객체의 replyTo 멤버변수에 클라이언트의 Messenger 객체를 담아 보내고, 서비스에서는 전달받은 Message 객체의 replyTo가 클라이언트의 Messenger 객체이므로 이것의 send() 메소드를 호출함으로써 클라이언트에게 Message를 보낼 수 있습니다. 

양방향 메시징의 샘플 코드는 ApiDemos 샘플앱의 MessengerService.java(service)와 MessengerServiceActivities.java(client)를 참고하시기 바랍니다.


서비스를 바인드하기

앱컴포넌트들(클라이언트들)은 bindService()를 호출함으로써 서비스를 바인드할 수 있습니다. bindService()가 호출되면, 안드로이드 시스템은 서비스의 onBind() 메소드를 호출하며, 여기에서 서비스와 상호작용할 수 있는 IBinder 객체를 리턴합니다. 

바인딩 작업은 비동기적으로 동작합니다. bindService()는 메소드 호출 성공여부(boolean)를 즉시 리턴해주며, 클라이언트에게 IBinder 객체를 리턴해주지 않습니다. IBinder 객체를 받기 위해서는, 클라이언트가 ServiceConnection 구현 객체를 만들어서, bindService()를 호출할 때 인자로 넘겨줘야 합니다. 시스템은 ServiceConnection의 onServiceConnected() 콜백 메소드를 통해 IBinder 객체를 전달해 줍니다.

메모: 액티비티, 서비스, 컨텐트 프로바이더만이 서비스를 바인드할 수 있으며, 브로드캐스트 리시버는 서비스를 바인드할 수 없습니다.

따라서, 클라이언트가 서비스를 바인드하기 위해서는 아래와 같이 해야합니다:

1. ServiceConnection을 구현합니다.
   아래 두개의 콜백 메소드를 구현해야 합니다.
   onServiceConnected()
       시스템은 서비스의 onBind()에서 리턴한 IBinder를 전달해주기 
       위해 이 메소드를 호출합니다.
   onServiceDisconnected()
       안드로이드 시스템은 서비스가 크래쉬 되었거나 갑자기 종료되었을
       때 이 메소드를 호출하며, 클라이언트가 언바인드 했을 때는 호출
       하지 않습니다.

2. bindService()를 호출하며, 이때 ServiceConnection 구현 객체를 
    인자로 넘깁니다.

3. 시스템에 의해 onServiceConnected()가 호출되었을 때, 전달받은
    인터페이스에 정의된 메소드들을 이용하여 서비스의 기능을 사용할
    수 있습니다. 

4. 서비스와의 연결을 끊으려면, unbindService()를 호출합니다.
    클라이언트가 종료되면 자연히 서비스와 언바인드 될 것입니다.
    그러나, 클라이언트가 더이상 서비스를 호출할 일이 없거나,
    액티비티가 정지상태가 되는 경우와 같이 서비스가 더이상 사용되지
    않을때는 언바인드 해주는 것이 좋습니다.(바인드 및 언바인드 하는 
    적절한 위치에 대해서는 좀더 아래 부분에서 학습할 것입니다.)

아래 예제 코드는 위의 Binder 클래스 확장하기에서 학습한 코드의 일부입니다. onServiceConnected()에서는 전달받은 IBinder를 이용하여 LocalService 객체를 얻어냅니다:

LocalService mService;
private ServiceConnection mConnection = new ServiceConnection() {
   
// Called when the connection with the service is established
   
public void onServiceConnected(ComponentName className, IBinder service) {
       
// Because we have bound to an explicit
       
// service that is running in our own process, we can
       
// cast its IBinder to a concrete class and directly access it.
       
LocalBinder binder = (LocalBinder) service;
        mService
= binder.getService();
        mBound
= true;
   
}

   
// Called when the connection with the service disconnects unexpectedly
   
public void onServiceDisconnected(ComponentName className) {
       
Log.e(TAG, "onServiceDisconnected");
        mBound
= false;
   
}
};

클라이언트가 서비스를 바인드하기 위해 bindService()를 호출할 때, 위의 ServiceConnection 구현 객체를 인자로 넘깁니다. 예제 코드:

Intent intent = new Intent(this, LocalService.class);
bindService
(intent, mConnection, Context.BIND_AUTO_CREATE);
  • bindService()의 첫번째 인자는 바인드할 서비스를 가리키는 인텐트입니다.(명시적 인텐트 및 암묵적 인텐트 모두 사용 가능합니다.)
  • 두번째 인자는 ServiceConnection의 구현 객체입니다.
  • 세번째 인자는 바인딩 옵션 값입니다. 보통은 서비스가 생성되어 있지 않을때 자동으로 생성해 주도록 BIND_AUTO_CREATE를 전달합니다. 전달 가능한 다른 값들로는, BIND_DEBUG_UNBINDBIND_NOT_FOREGROUND, 그리고 0(옵션 없음)이 있습니다.


추가 사항들

여기에 서비스 바인딩에 대한 몇가지 중요한 사항들을 추가합니다:

  • 다른 프로세스에 있는 서비스와의 연결이 끊어졌을 때 DeadObjectException 예외가 발생할 수 있습니다. 이는 원격 메소드 호출시에만 발생할 수 있는 예외입니다.
  • 객체들은 프로세스를 가로질러 레퍼런스 카운트됩니다.
  • 바인딩과 언바인딩은 보통 짝을 이루며, 클라이언트의 생명주기와 관계가 있습니다. 예를 들면:
    • 만약 액티비티가 화면에 보여질 때만 상호작용하게 하려면, 액티비티의 onStart()에서 바인드하고, onStop()에서 언바인드하면 됩니다.
    • 만약 액티비티가 정지되어 후면(background)에 있는 상태에서도 상호작용하게 하려면, 액티비티의 onCreate()에서 바인드하고, onDestroy()에서 언바인드하면 됩니다. 하지만 이렇게 하면 액티비티가 종료될 때까지 계속 서비스를 사용하게 된다는 점에 대해 주의해야 합니다. 

메모: 액티비티의 onResume()onPause()에서 바인드 및 언바인드하는 것은 피하는 것이 좋습니다. 이것들은 액티비티의 생명주기에서 가장 자주 호출되는 콜백 메소드인데, 서비스의 바인드 및 언바인드는 가능한한 최소한으로 하는 것이 좋기 때문입니다. 만약 여러개의 액티비티가 하나의 서비스를 바인드하고 있는 상황에서, stopped 상태에 있던 액티비티가 현재 화면에 보여지고 있던 액티비티를 제치고 resumed 상태가 되는 경우를 예로 들어보겠습니다. stopped 상태였던 액티비티가 resumed 상태로 되면서 서비스를 바인드하기 전에, 현재의 액티비티가 paused 상태로 되면서 언바인드 한다면, 서비스는 종료되었다가 다시 생성될 것입니다. (액티비티의 생명주기 변화에 대한 자세한 내용은 액티비티에 대하여에서 학습하실 수 있습니다.)  


서비스를 바인드하는 방법에 대한 샘플코드는 ApiDemos 샘플앱이 RemoteService.java 파일을 참고하시기 바랍니다.


바인드 되는 서비스의 생명주기 관리하기

서비스가 모든 클라이언트들로부터 언바인드 되면, (onStartCommand()가 호출되면서 시작된 서비스가 아닌 경우에 한하여) 안드로이드 시스템은 그 서비스를 종료시킵니다. 따라서 순전히 바인드만 된 서비스라면, 서비스의 생명주기를 관리할 필요가 없습니다. 안드로이드 시스템이 서비스의 바인드 여부를 판단하여 알아서 관리해주기 때문입니다. 

하지만 다른 앱컴포넌트에서 startService()를 호출하여, 서비스의 onStartCommand()가 호출된 경우, 즉 시작된(started) 서비스의 경우에는, 명시적으로 서비스를 종료시켜줘야 합니다. 이러한 경우에는 서비스가 클라이언트들에게 바인드 되었는지 여부와 상관없이, 서비스가 자체적으로 stopSelf()를 호출하거나 다른 앱컴포넌트가 stopService()를 호출할 때까지, 서비스는 실행됩니다. 

그리고, 서비스가 시작 및 바인드 되었다가 언바인드 되면서 onUnbind()가 호출되었을때, 여기서 true를 리턴하면, 다음번에 서비스가 다시 클라이언트에게 바인드될 때 onBind()가 호출되지 않고 onRebind()가 호출됩니다. onRebind()는 결과값을 리턴하지 않는 메소드지만, 클라이언트는 onServiceConnected()에서 (기존의) IBinder객체를 전달 받습니다. 아래의 그림1은 이러한 생명주기 흐름을 나타냅니다.


그림 1. 시작되고 바인드 되는 서비스의 생명주기


시작되는(started) 서비스의 생명주기에 대한 자세한 내용은 서비스에 대하여에서 학습하실 수 있습니다.


Posted by 개발자 김태우
,

(원문: 

http://developer.android.com/guide/components/services.html)

(위치: Develop > API Guides > App Components > Services)

(전체 번역 글 목록으로 이동하기)


서비스에 대하여 (Services)


서비스(Service)는 사용자 인터페이스(UI)를 제공하지 않고, 백그라운드에서 주로 시간이 오래 걸리는 작업들을 처리하는 앱 컴포넌트입니다. 다른 컴포넌트가 서비스를 실행할 수 있고, 사용자가 내 앱에서 벗어나 다른 앱으로 이동하더라도 서비스는 백그라운드에서 하던 일을 마저 할 것입니다. 그리고, 컴포넌트는 서비스와 상호작용을 하기 위해 바인드할 수 있고, 심지어 프로세스간 통신(IPC)을 할 수 있습니다. 서비스를 이용해서 할 수 있는 일들을 예로 들면, 네트워크를 통한 데이터 전송 및 수신, 음악 재생, 파일 I/O, 컨텐트 프로바이더와의 상호작용 등을 백그라운드에서 처리할 수 있습니다. 

서비스는 실행되는 방식에 따라 2가지 상태로 나뉩니다:

Started
(액티비티와 같은) 앱 컴포넌트가 startService()를 호출하면, 서비스는 "started" 상태가 됩니다. 서비스가 실행되면(started), 그 서비스를 실행한 컴포넌트가 종료되더라도 (할 일을 모두 마칠 때까지) 서비스는 종료되지 않습니다. 서비스는 별도로 실행되기 때문에 서비스를 실행한 컴포넌트에게 실행한 것에 대한 결과값을 리턴해 주지 않습니다. 네트워크 상에서 파일을 다운로드하거나 업로드 하는 경우, 작업이 끝나면 서비스는 스스로 종료되어야 합니다.

Bound
앱 컴포넌트가 bindService()를 호출하면, 서비스는 "bound" 상태(바인드 된 상태)가 됩니다. 바인드 된 서비스는, 앱 컴포넌트들이 서비스와 상호작용(요청 및 응답, 프로세스간 통신(IPC)) 할 수 있도록 해주는 클라이언트-서버 인터페이스를 제공해 줍니다. 서비스는 서비스가 아닌 다른 앱 컴포넌트에 의해 바인드 된 경우에 bound 상태로 동작합니다. 한번에 여러개의 컴포넌트가 바인드 될 수 있고, 바인드된 모든 컴포넌트가 언바인드 되면 서비스도 종료됩니다.

서비스의 상태를 위와 같이 둘로 구분해서 설명하고 있기는 하지만, 서비스는 Started 상태이면서 Bound 상태가 될 수 있습니다. Started 상태가 되는 경우 onStartCommand() 콜백 메소드가 호출되고, Bound 상태가 되는 경우 onBind() 콜백 메소드가 호출됩니다.

내 앱이 실행중인지 여부나, 서비스와 바인드 되어 있는지 여부와 상관없이, 내 앱 및 다른 앱의 컴포넌트에서 인텐트를 사용하여 서비스를 실행할 수 있습니다. 이는 액티비티가 내 앱 및 다른 앱의 모든 컴포넌트에서 인텐트를 통해 실행가능하다는 것과 비슷한 상황입니다. 하지만 매니페스트 파일에 서비스를 선언할 때, 다른 앱에서 서비스를 사용하지 못하게 선언할 수 있습니다. 이에 대해 자세한 내용은 아래의 매니페스트에서 서비스 선언하기 섹션에서 학습하실 수 있습니다.

주의사항: 서비스는 기본적으로 앱이 구동되는 프로세스의 메인 쓰레드에서 실행됩니다. 다시 말해서, 서비스는 별도의 프로세스나 쓰레드에서 실행되는 것이 아닙니다. 이것은, 서비스에서 CPU 사용량이 많은 작업, 또는 mp3 재생이나 네크워킹과 같은 블락킹(blocking) 작업을 수행해야 하는 경우, 서비스에서 별도의 쓰레드를 생성하여 그 안에서 작업이 수행되도록 구현해야 한다는 것을 의미합니다. 이렇게 별도의 쓰레드를 사용함으로써, 응답 지연 문제(ANR)를 예방하고, 앱의 메인 스레드가 사용자와의 상호작용에 집중할 수 있도록 해줍니다.  


기본 개념

서비스를 만들기 위해서는 Service 클래스(또는 Service의 서브클래스)를 상속받아 서브클래스를 만들면 됩니다. 서비스의 생명주기에 따른 동작을 위해 생명주기 콜백 메소드를 구현해야 하고, 서비스를 바인드한 컴포넌트에게 제공하는 메소드들을 구현할 수도 있습니다. 주요 콜백 메소드들은 아래와 같습니다:

onStartCommand()
다른 컴포넌트(액티비티 등)가 startService()를 호출함으로써 서비스 실행을 요청할 때, 시스템이 호출해주는 콜백 메소드입니다. 이 메소드가 실행되면 서비스는 started 상태가 되며, 후면(background)에서 작업을 수행합니다. 만약 이 메소드를 구현한다면, 서비스가 할 작업을 모두 마쳤을 때 서비스를 중지하기 위해 stopSelf()stopService()를 호출하는 부분도 구현해야 합니다. (만약 서비스를 바인드만 해서 사용한다면, 이 메소드를 구현하지 않아도 됩니다.)

onBind()
다른 컴포넌트가 (RPC를 하기 위해) bindService()를 호출함으로써 서비스를 바인드할 때, 시스템이 호출해주는 콜백 메소드입니다. 이 메소드에서는, 다른 컴포넌트가 서비스의 메소드들을 사용할 수 있도록 IBinder라는 인터페이스를 리턴해줘야 합니다. 다른 컴포넌트에 의해 바인드 되어 사용될 서비스라면 이 콜백 메소드를 반드시 구현해야 하지만, 바인드 되는 서비스가 아니라면 구현할 필요가 없습니다(기본적으로는 IBinder가 null로 리턴됩니다).

onCreate()
서비스가 생성될 때 호출되는 콜백 메소드이며, 여기에서는 (액티비티의 onCreate()와 마찬가지로) 서비스가 살아있는 동안 사용할 멤버 변수들을 셋팅하는 일을 합니다. 이 메소드는 onStartCommand()나 onBind()가 호출되기 전에 호출되며, 서비스가 실행되고 있는 중이라면 호출되지 않습니다.

onDestroy()
서비스가 더이상 사용되지 않아 종료될 때 호출되는 콜백 메소드입니다. 이 메소드는 서비스의 생명주기에서 가장 마지막에 호출되는 콜백 메소드이기 때문에, 여기에서는 서비스에서 사용하던 리소스들(쓰레드, 등록된 리스너, 리시버 등)을 모두 정리해줘야(clean up) 합니다. 

만약 어떤 컴포넌트가 startService()를 호출하여 서비스를 실행했다면(이 경우 서비스의 onStartCommand()가 호출됩니다), 그 서비스는 스스로 stopSelf()를 호출하거나 다른 컴포넌트가 stopService()를 호출할 때까지 started 상태로 유지됩니다. 

만약 어떤 컴포넌트가 bindService()를 호출하여 서비스를 생성했다면(이 경우 서비스의 onStartCommand()가 호출되지 않습니다), 그 서비스는 바인드되어 있는 동안에만 실행됩니다. 만약 서비스를 바인드한 컴포넌트들이 모두 언바인드(unbind)한다면, 시스템은 그 서비스를 종료시킵니다.

안드로이드 시스템은, 사용자에게 보여줄 액티비티 실행을 위한 메모리가 부족할 경우 서비스를 강제로 종료할 수 있습니다. 하지만 만약 서비스가 현재 사용자에게 보여지고 있는 액티비티에 바인드 되어 있다면, 다른 평범한 서비스보다는 중요하다고 판단하여 다른 서비스를 먼저 종료하도록 합니다. 그리고 서비스가 전면(foreground)에서 실행되고 있다면, 종료시키지 않습니다. 반면에, 서비스가 실행되어 꽤 오랜 시간이 흘렀다면, 시스템은 그 서비스를 후면(background)에 있는 컴포넌트들 중에서도 중요도가 낮은 쪽으로 분류할 것이고, 따라서 종료될 확률도 높아지게 됩니다. 그래서 서비스는 시스템에 의해 종료 및 재시작되는 경우에 대비하여 설계되어야 합니다. 시스템이 어떤 서비스를 종료시켰다 하더라도, 사용할 수 있는 환경이 된다면 시스템은 다시 그 서비스를 재시작해줄 수 있습니다(onStartCommand()가 리턴해주는 값에 따라 다르게 동작합니다). 시스템이 서비스를 종료하는 것과 관련하여 더 자세한 내용은 프로세스와 쓰레드에서 학습할 수 있습니다.

이어지는 섹션들에서는, 서비스를 만드는 방법과 다른 컴포넌트에서 서비스를 사용하는 방법에 대하여 학습합니다.  


매니페스트에서 서비스 선언하기

다른 컴포넌트들과 마찬가지로, 서비스도 앱의 매니페스트 파일에 선언해야 합니다. 

서비스를 선언하기 위해서는 아래 예제와 같이 <application> 요소의 자식 요소로 <service> 요소를 추가하면 됩니다:

<manifest ... >
  ...
 
<application ... >
     
<service android:name=".ExampleService" />
      ...
 
</application>
</manifest>

<service> 요소에는 서비스 실행시 요구되는 퍼미션과 어떤 프로세스에서 실행할지 등을 속성값으로 지정할 수도 있습니다. 하지만 android:name 만이 필수입력 사항이며, 여기에는 서비스의 클래스명을 넣습니다. 앱이 일단 출시되고 나면, 이 이름은 되도록 바꾸는 일이 없어야 합니다. 왜냐하면, 다른 앱에서 이 이름을 이용한 명시적 인텐트로 서비스를 실행하고 있을 수도 있기 때문입니다. 

앱의 보안을 강화하고자 한다면, 서비스를 시작하거나 바인드할때 명시적 인텐트를 사용하고, 매니페스트 파일에 서비스를 위한 인텐트 필터를 선언하지 않아야 합니다. 만약 인텐트 필터를 선언하고 서비스명이 없는 암묵적 인텐트로 서비스를 실행하는 상황에서, 어떤 서비스가 실행될지를 더 명확하게 하고 싶다면, 인텐트의 setPackage()를 호출함으로써 지정된 패키지 안에 있는 서비스가 실행되도록 제한할 수 있습니다.

추가적으로, android:exported"false"로 지정함으로써 서비스가 오직 내 앱 내에서만 실행되도록 할 수 있습니다. 이 방법은 다른 앱이 내 앱의 컴포넌트를 실행하지 못하도록 막아줍니다.


Started 상태의 서비스 생성하기

started 상태의 서비스란, 다른 컴포넌트의 startService() 호출에 의해 onStartCommand() 콜백 메소드가 호출된 상태의 서비스를 말합니다. 

서비스는 started 상태가 되면, 자신을 실행한 컴포넌트에 독립적인 생명주기를 갖고 후면(background)에서 실행되며, 자신을 실행한 컴포넌트가 종료되더라도 그와 상관없이 꿋꿋하게 자신이 해야할 일을 합니다. 따라서 서비스가 할일을 다하여 종료시키고 싶다면, 서비스 내에서 stopSelf()를 호출하여 스스로 종료되도록 하거나, 다른 컴포넌트에서 stopService()를 호출하여 종료시켜줘야 합니다.

액티비티 등의 앱 컴포넌트는 startService()를 호출함으로써 서비스를 실행할 수 있고, 이때 어떤 서비스를 실행할지에 대한 정보와 그 외에 서비스에 전달해야할 데이터를 담고 있는 인텐트를 인자로 넘길 수 있습니다. 그리고 서비스의 onStartCommand() 콜백 메소드에서 매개변수로 그 인텐트를 받게 됩니다.

예를 들어, 액티비티가 어떤 데이터를 온라인 데이터베이스에 저장해야 하는 상황을 가정해 보겠습니다. 액티비티는 startService()를 호출하여 서비스를 실행하며, 이때 넘겨주는 인텐트에 저장할 데이터를 담습니다. 서비스는 onStartCommand()가 호출될 때 매개변수로 그 인텐트를 넘겨받고, 인터넷을 통해 온라인 데이터베이스에 연결하여 데이터를 저장하는 작업을 수행합니다. 그리고 작업이 끝나면, stopSelf()를 호출하여 정지(stop) 및 종료(destroy) 합니다.

주의사항: 서비스는 기본적으로 앱의 다른 컴포넌트들과 같은 프로세스에서 실행되며, 메인 쓰레드(UI 쓰레드)에서 실행됩니다. 따라서 액티비티가 실행되고 있는 상태에서, 서비스가 CPU 사용량이 많거나 실행 흐름을 막는 작업(blocking operation)을 하게 되면 액티비티의 동작이 느려질 수 있습니다. 그러므로 이러한 문제를 피하기 위해서는, 서비스 내에서 쓰레드를 생성하여 사용하도록 구현해야 합니다.

일반적으로, 서비스를 만들때 아래 2개의 클래스 중 하나를 상속받습니다:

Service
가장 기본이 되는 추상 클래스이며, 모든 서비스 클래스는 이 클래스를 상속 받습니다. 서비스는 기본적으로 앱의 메인 쓰레드에서 실행되기 때문에 같은 프로세스에서 실행되는 액티비티의 퍼포먼스를 낮출 수 있습니다. 따라서 이 클래스를 상속받아 확장할 때, 쓰레드를 생성하여 서비스가 할 일을 생성된 쓰레드에서 하도록 만드는 것이 중요합니다.

IntentService
이 클래스는 Service 클래스의 서브 클래스이며, 하나의 워커 쓰레드를 만들어서 요청들을 한번에 하나씩 처리하도록 합니다. 만약 여러개의 요청을 동시에 처리해야만 하는 상황이 아니라면, 이 클래스를 사용하는 것이 가장 좋은 선택일 것입니다. 이 클래스를 상속받아 확장할 때는 onHandleIntent()만 구현하면 되는데, 이 콜백 메소드는 워커 쓰레드에서 실행되기 때문에, 매개변수로 인텐트를 받아서 그에 해당하는 요청을 처리하는 것만 구현해 주면 됩니다. 

이어지는 섹션들에서는 IntentServiceService를 상속받아 확장하는 방법을 설명합니다.


IntentService 클래스 확장하기

서비스에서 동시에 여러개의 요청을 처리해야만 하는 경우는 거의 없기 때문에, 아마도 IntentService 클래스를 상속받아 서비스 클래스를 만드는 것이 가장 좋은 선택일 것입니다. 

IntentService는 아래와 같은 일을 합니다:

  • 워커 쓰레드를 만든 후, onStartCommand()를 통해 전달받은 인텐트들을 (메인 쓰레드가 아닌) 워커 쓰레드에서 차근차근 실행합니다.
  • 작업큐를 만들어서 전달받은 인텐트들을 넣어놨다가 한번에 하나씩 onHandleIntent()에게 넘겨주기 때문에, 멀티쓰레드 구현에 대한 고민을 하지 않아도 됩니다.
  • 모든 요청에 대한 처리가 끝나면 종료되도록 이미 구현되어 있기 때문에, 확장한 클래스에서 stopSelf()를 호출할 필요가 없습니다.
  • onBind() 콜백 메소드는 기본적으로 null을 리턴하도록 되어 있습니다.
  • onStartCommand() 콜백 메소드는 기본적으로 전달 받은 인텐트를 작업큐에 넣는 일을 합니다.

이러한 사실들에 비춰봤을때, 확장 클래스에서는 onHandleIntent()만 구현하면 됩니다.(필요에 따라 생성자를 만들어야 할 수는 있습니다.)

IntentService의 구현 예제:

public class HelloIntentService extends IntentService {

 
/**
   * A constructor is required, and must call the super
IntentService(String)
   * constructor with a name for the worker thread.
   */

 
public HelloIntentService() {
     
super("HelloIntentService");
 
}

 
/**
   * The IntentService calls this method from the default worker thread with
   * the intent that started the service. When this method returns, IntentService
   * stops the service, as appropriate.
   */

 
@Override
 
protected void onHandleIntent(Intent intent) {
     
// Normally we would do some work here, like download a file.
     
// For our sample, we just sleep for 5 seconds.
     
long endTime = System.currentTimeMillis() + 5*1000;
     
while (System.currentTimeMillis() < endTime) {
         
synchronized (this) {
             
try {
                  wait
(endTime - System.currentTimeMillis());
             
} catch (Exception e) {
             
}
         
}
     
}
 
}
}

만약 onCreate(), onStartCommand(), onDestroy() 등의 콜백 메소드를 오버라이드 해야한다면, super.XXX()를 꼭 호출해줘야 합니다. 그래야 워커 쓰레드가 정상적으로 동작하기 때문입니다.

예를 들어, 오버라이드한 onStartCommand() 콜백 메소드는 아래와 같이 super.onStartCommand()를 리턴합니다:

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
   
Toast.makeText(this, "service starting", Toast.LENGTH_SHORT).show();
   
return super.onStartCommand(intent,flags,startId);
}

onHandleIntent()와 더불어 onBind()에서도 super.XXX()를 호출할 필요가 없습니다(그러나 바인드 되어 사용되는 서비스라면 onBind()만 구현하면 됩니다).

다음 섹션에서는, Service 클래스를 확장하여, 위에서 구현한 서비스와 같은 일을 하는 서비스를 만드는 방법을 설명합니다. 이것은 훨씬 더 많이 코딩해야 하지만, 동시에 여러개의 요청들을 처리해야 한다면 이 방법을 사용해야 할 것입니다.


Service 클래스 확장하기

이전 섹션에서 학습한 바에 따르면, IntentService를 확장하여 서비스를 만드는 것은 매우 간단합니다. 하지만 만약 여러 작업들을 작업큐에 담아 차례대로 처리하는 대신에 멀티쓰레드 방식으로 서비스가 동작해야 한다면, Service를 확장하고 각각의 인텐트를 처리하도록 직접 구현해야할 것입니다.

아래 예제는 Service를 확장하여 만든 서비스 클래스이고, 비교를 위하여 위의 IntentService를 확장하여 만든 클래스와 똑같은 동작을 하도록 만들었습니다. 이것은, 각 요청에 대하여 한번에 하나씩 처리하도록 하기 위해 워커 쓰레드를 사용합니다.

public class HelloService extends Service {
 
private Looper mServiceLooper;
 
private ServiceHandler mServiceHandler;

 
// Handler that receives messages from the thread
 
private final class ServiceHandler extends Handler {
     
public ServiceHandler(Looper looper) {
         
super(looper);
     
}
     
@Override
     
public void handleMessage(Message msg) {
         
// Normally we would do some work here, like download a file.
         
// For our sample, we just sleep for 5 seconds.
         
long endTime = System.currentTimeMillis() + 5*1000;
         
while (System.currentTimeMillis() < endTime) {
             
synchronized (this) {
                 
try {
                      wait
(endTime - System.currentTimeMillis());
                 
} catch (Exception e) {
                 
}
             
}
         
}
         
// Stop the service using the startId, so that we don't stop
         
// the service in the middle of handling another job
          stopSelf
(msg.arg1);
     
}
 
}

 
@Override
 
public void onCreate() {
   
// Start up the thread running the service.  Note that we create a
   
// separate thread because the service normally runs in the process's
   
// main thread, which we don't want to block.  We also make it
   
// background priority so CPU-intensive work will not disrupt our UI.
   
HandlerThread thread = new HandlerThread("ServiceStartArguments",
           
Process.THREAD_PRIORITY_BACKGROUND);
    thread
.start();

   
// Get the HandlerThread's Looper and use it for our Handler
    mServiceLooper
= thread.getLooper();
    mServiceHandler
= new ServiceHandler(mServiceLooper);
 
}

 
@Override
 
public int onStartCommand(Intent intent, int flags, int startId) {
     
Toast.makeText(this, "service starting", Toast.LENGTH_SHORT).show();

     
// For each start request, send a message to start a job and deliver the
     
// start ID so we know which request we're stopping when we finish the job
     
Message msg = mServiceHandler.obtainMessage();
      msg
.arg1 = startId;
      mServiceHandler
.sendMessage(msg);

     
// If we get killed, after returning from here, restart
     
return START_STICKY;
 
}

 
@Override
 
public IBinder onBind(Intent intent) {
     
// We don't provide binding, so return null
     
return null;
 
}

 
@Override
 
public void onDestroy() {
   
Toast.makeText(this, "service done", Toast.LENGTH_SHORT).show();
 
}
}

보시다시피 IntentService를 사용할 때보다 훨씬 더 일이 많습니다. 

하지만 서비스가 실행 요청을 받으면 각각의 요청에 대하여 onStartCommand()가 호출되기 때문에, 동시에 여러개의 요청을 처리할 수 있습니다. 위의 예제에서는 동시에 여러개의 요청을 처리하도록 구현되어 있지 않지만, 만약 그렇게 구현하고 싶다면, 각 요청에 대한 쓰레드를 생성하여, 이전 작업이 끝날때까지 기다릴 필요 없이 바로 실행하면 됩니다. 

onStartCommand()정수형의 값을 리턴해야 합니다. 그 값은, 시스템이 메모리가 부족하여 서비스를 종료한 이후에, 다시 여유가 생겼을 때 서비스를 이어서 실행할 것인지 여부를 나타내며, 아래의 3가지 중 한가지가 됩니다.

START_NOT_STICKY
onStartCommand()에서 이 값이 리턴된 후, 시스템이 서비스를 종료시켰다면, 다시 서비스가 실행될 수 있는 여건이 되더라도 서비스를 다시 생성하지 않습니다. 이것은 불필요하게 서비스가 실행되는 것을 막는 가장 안전한 방법이지만, 완료되지 못한 작업들 또한 더이상 수행되지 않습니다.

START_STICKY
onStartCommand()에서 이 값이 리턴된 후, 시스템이 서비스를 종료시켰다면, 다시 서비스가 실행될 수 있는 여건이 되었을 때, 서비스를 생성하고 onStartCommand()를 호출해 줍니다(하지만 종료전의 마지막 인텐트를 다시 전달해 주지는 않습니다). 이것은 미디어 플레이어처럼 계속 실행되길 원하는 경우에 적합합니다.

START_REDELIVER_INTENT
onStartCommand()에서 이 값이 리턴된 후, 시스템이 서비스를 종료시켰다면, 다시 서비스가 실행될 수 있는 여건이 되었을 때, 서비스를 생성하고 onStartCommand()를 호출하며 종료전의 마지막 인텐트를 인자로 넘겨줍니다. 이것은 파일 다운로드처럼 반드시 완료가 되어야 하는 경우에 적합합니다.


위의 값들에 대한 자세한 내용은 Service 클래스의 레퍼런스 문서에서 학습할 수 있습니다.


서비스 실행하기

startService() 호출시 실행할 서비스 정보를 가지고 있는 인텐트를 넘김으로써, 액티비티를 비롯한 앱 컴포넌트들에서 서비스를 실행할 수 있습니다. 안드로이드 시스템은 서비스를 실행시킬때 onStartCommand()를 호출해주면서 여기에 그 인텐트를 전달해 줍니다.(onStartCommand()를 직접 호출하지 않도록 합니다.)

아래 예제는, 이전 섹션에서 학습한 HelloService를 액티비티에서 명시적 인텐트로 실행하는 코드입니다.

Intent intent = new Intent(this, HelloService.class);
startService
(intent);

startService() 메소드는 호출 즉시 리턴되며, 그 뒤에 안드로이드 시스템이 서비스의 onStartCommand()를 호출합니다. 만약 서비스가 실행중인 상태가 아니었다면, 시스템은 먼저 onCreate()를 호출한 후 그 다음으로 onStartCommand()를 호출합니다.

서비스가 바인딩을 지원하지 않는다면, startService()에 넘겨주는 인텐트가 startService()를 호출하는 앱 컴포넌트와 호출되는 서비스의 유일한 소통 수단이 됩니다. 하지만, 만약 서비스로부터 결과값을 돌려받기를 원한다면, 서비스에서 결과값을 담은 인텐트로 sendBroadcast()하고, 결과를 받을 앱 컴포넌트에는 브로드캐스트 리시버가 등록되어 있으면 됩니다. (이 부분은 원문과 다릅니다. 원문에서는 PendingIntent가 언급되어 있는데 PendingIntent는 실행할 인텐트를 담아놓고 대기시켜 놨다가 원하는 적절한 시점에 실행할 수 있도록 해주는 것입니다. 원문대로라면 startService()할 때 PendingIntent를 넘기고 서비스에서 그 PendingIntent를 이용하여 브로드캐스트할 수 있어야 하는데, 그러한 메소드는 없으며 굳이 필요하지도 않습니다.)

서비스를 실행하기 위해 요청을 여러번 보내면 그때마다 서비스의 onStartCommand()가 호출되어 각 요청을 처리할 수 있습니다. 하지만 요청을 여러번 하더라도 서비스 객체는 하나이기 때문에 서비스를 정지시키기 위해서는 stopSelf()stopService()를 한번만 호출하면 됩니다.


서비스 정지시키기

서비스가 일단 시작되고 할일을 모두 마쳤다면, 자신 또는 다른 앱 컴포넌트에 의해 종료되어야 합니다. 시스템은, 메모리가 부족하여 어쩔 수 없이 서비스를 종료시키는 경우가 아니라면, 서비스를 종료시키지 않기 때문입니다. 서비스는 스스로 stopSelf()를 호출함으로써 종료되거나, 다른 앱 컴포넌트가 stopService()를 호출함으로써 종료될 수 있습니다.

stopSelf()나 stopService()를 호출하고나면, 시스템은 가능한한 빨리 서비스를 정지시킵니다. 하지만 서비스가 동시에 여러개의 요청을 받아 onStartCommand()가 요청들을 처리하고 있는 상태에서, 어느 하나의 요청이 완료되었다고 하여 서비스가 정지된다면, 다른 요청들이 처리되지 못하는 문제가 있을 수 있겠죠. 이러한 문제를 해결하기 위해서는 stopSelf(int startId)를 호출해야 합니다. 이것은 서비스가 가장 마지막에 받은 요청을 처리한 후에 종료될 수 있도록 합니다. 여기서 startId는 onStartCommand()에서 전달받은 값이며, 서비스가 요청을 받을 때마다 값이 증가합니다. 예를 들어, 서비스가 요청을 처리하고 나면 stopSelf(int)를 호출하도록 구현되어 있고, 연속적으로 두 개의 요청이 발생한 경우를 가정해 봅시다. 첫번째 요청시 onStartCommand()로부터 받은 startId가 1이고, 요청을 처리하고 있는 중에 두번째 요청이 들어와서 또 onStartCommand()가 호출되었고 startId는 2이며 해당 요청을 처리합니다. 그런데 두번째 요청이 모두 처리되기 전에 첫번째 요청에 대한 처리가 완료되어 stopSelf(1)가 호출되었다면, 마지막 요청에 대한 startId가 2이고 정지 요청시 전달한 startId가 1로서 서로 다르기 때문에 서비스를 정지시키지 않는 것입니다. 

주의사항: 서비스가 할 일을 마치고 나면 종료되도록 개발하는 것은 시스템 리소스의 낭비를 막고 배터리 수명을 늘리기 위해서 중요합니다. 필요하다면, 다른 앱 컴포넌트에서 stopService()를 호출하여 서비스를 정지시킬 수 있습니다. 바인드가 되는 서비스라 하더라도, onStartCommand()에서 실행한 작업이 완료되면 서비스를 정지시켜줘야 합니다. 

서비스의 생명주기에 관한 자세한 내용은, 아래 섹션의 서비스의 생명주기 관리하기에서 학습하실 수 있습니다.


바인드 되는 서비스 만들기

앱 컴포넌트들은 서비스와 연결상태를 계속 유지하기 위해 bindService()를 호출함으로써 (바인드 되는 서비스라면) 서비스와 바인드할 수 있습니다. 

서비스가 내 앱 내의 액티비티를 비롯한 다른 컴포넌트들과 상호작용을 하도록 하거나, 프로세스간 통신(IPC)를 통해 서비스의 일부 기능을 다른 앱에게 제공할 수 있도록 하기 위해서는 바인드 되는 서비스를 만들어야 합니다. 

바인드 되는 서비스를 만들기 위해서는, 서비스의 onBind() 콜백 메소드에서 서비스와 통신할 수 있는 인터페이스인 IBinder를 리턴해줘야 합니다. 그러면 다른 앱 컴포넌트에서 bindService()를 호출함으로써 그 인터페이스를 얻은 후에 서비스와 통신할 수 있습니다. 바인드 된 서비스는 자신을 바인드 한 앱컴포넌트에게 기능을 제공하기 위해 존재하며, 바인드 한 앱컴포넌트가 없으면 시스템에 의해 종료됩니다(onStartCommand()가 호출되어 시작된 서비스는 직접 종료시켜줘야 하지만, 바인드 된 서비스는 그럴 필요가 없습니다).

바인드 되는 서비스를 만들기 위해 가장 먼저 할 일은, 서비스와 통신하기 위한 인터페이스를 정의하는 것입니다. 서비스와 클라이언트 사이의 인터페이스는 IBinder를 구현한 것이어야 하며, 이것은 onBind()에서 리턴해 줍니다. 클라이언트가 일단 IBinder를 받고 나면, 그것을 통해 서비스와 상호작용할 수 있는 것입니다.

서비스는 한번에 여러개의 클라이언트에게 바인드 될 수 있습니다. 각각의 클라이언트는 서비스에 대한 용건을 마친후 unbindService()를 호출하여 바인드를 끊을 수 있으며, 모든 클라이언트가 바인드를 끊어 더이상 서비스를 바인드한 클라이언트가 없을 경우 시스템은 그 서비스를 종료시킵니다.

바인드 되는 서비스를 구현하는 방법은 몇가지가 있으며, 서비스를 시작시키는 것보다 더 복잡하기 때문에, 자세한 내용은 서비스 바인드하기에서 학습하시기 바랍니다.


사용자에게 알림 보내기

실행되고 있는 서비스는 사용자에게 토스트 알림이나 상태바 알림을 보내줄 수 있습니다. 

토스트 알림은 현재 화면에서 메시지가 잠깐 보였다 사라지는 방식입니다. 반면에 상태바 알림은 상태바에 아이콘과 메시지를 보여주고, 상태바를 열었을때 알림 목록을 볼 수 있으며, 그것을 눌러서 액티비티를 실행하는 등의 동작을 할 수 있습니다.

상태바 알림은 보통 후면(background)에서의 작업이 완료되었을 때 사용자에게 알리기 위해 사용하는 가장 좋은 방법입니다. 파일을 다운로드하는 경우를 예로 들어보겠습니다. 후면에서 파일 다운로드가 완료되어 사용자에게 상태바 알림을 보내주면, 사용자는 그 알림을 보고 상태바를 열어서 해당 항목을 누를 것이고, 그러면 다운로드한 파일을 보여주는 액티비티가 실행될 수 있을 것입니다.

더 자세한 내용은 토스트 알림상태바 알림에서 학습하실 수 있습니다.


전면(foreground)에서 서비스 실행하기

전면(foreground)에 있는 서비스는 현재 뭔가 하고 있다는 것을 사용자가 인지하고 있는 서비스로서 메모리 부족시 시스템에 의한 종료 대상에서 제외됩니다. 전면에 있는 서비스는 "진행중(Ongoing)"인 상태바 알림을 제공해야 하며, 그것은 서비스가 정지되거나 전면에서 제외되지 않으면 상태바 알림 목록에서 제거되지 않는다는 것을 의미합니다.

예를 들면, 서비스에서 음악을 재생중인 음악 플레이어는, 사용자가 음악 재생중이라는 것을 알고 있기 때문에 그 서비스를 전면에 두기 위해 상태바 알림을 제공합니다. 그 상태바 알림은 아마도 현재곡을 표시해 줄 것이며, 눌렀을 때 음악 플레이어 액티비티를 실행해 줄 것입니다.

서비스를 전면에 두기 위해서는 startForeground()를 호출합니다. 이 메소드에는 두 개의 인자를 넘기는데, 첫번째는 알림을 식별하기 위한 유니크한 정수형 ID이며, 두번째는 Notification 객체입니다. 예제 코드:

Notification notification = new Notification(R.drawable.icon, getText(R.string.ticker_text),
       
System.currentTimeMillis());
Intent notificationIntent = new Intent(this, ExampleActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0);
notification
.setLatestEventInfo(this, getText(R.string.notification_title),
        getText
(R.string.notification_message), pendingIntent);
startForeground
(ONGOING_NOTIFICATION_ID, notification);

주의사항: startForeground()의 첫번째 인자인 ID에 0을 넣으면 안됩니다.

서비스를 전면에서 제외시키기 위해서는 stopForeground()를 호출합니다. 이 메소드는 boolean형의 인자를 받는데 이것은 상태바에 있는 알림을 제거할 것인지 여부를 가리킵니다. 이 메소드는 서비스를 정지시키지는 않습니다. 하지만 서비스가 전면에서 실행되고 있는 중에 정지되면, 그에 따라 상태바 알림도 제거됩니다.

상태바 알림에 대한 자세한 내용은 상태바 알림 생성하기에서 학습하실 수 있습니다.


서비스의 생명주기 관리하기

서비스의 생명주기는 액티비티의 생명주기보다 훨씬 단순합니다. 하지만 서비스는 사용자의 눈에 보이지 않는 후면(background)에서 동작하기 때문에, 생성되고 종료되는 것에 더 신경을 써야 합니다.

서비스의 생명주기(생성에서 종료까지)는 두가지 경우가 있습니다:

  • 시작된(started) 서비스
    다른 앱컴포넌트의 startService() 호출에 의해 시작된 서비스입니다. 서비스 내에서 stopSelf()를 호출하거나, 다른 앱컴포넌트에서 stopService()를 호출하여 정지시키기 전까지는 계속 실행됩니다. 서비스가 정지되고 나면 시스템에 의해 종료됩니다.
  • 바인드 된(bound) 서비스
    다른 앱컴포넌트(클라이언트)의 bindService() 호출에 의해 바인드 된 서비스입니다. 클라이언트는 IBinder 인터페이스를 통해 서비스와 통신할 수 있으며, unbindService()를 호출하여 바인드를 끊을 수 있습니다. 여러개의 클라이언트가 같은 서비스를 바인드할 수 있으며, 바인드 된 모든 클라이언트가 바인드를 끊으면 시스템에 의해 종료됩니다.(시작된 서비스처럼 정지 메소드를 호출할 필요가 없습니다.)

위의 두가지 경우는 완전히 구분되는 것이 아닙니다. 이것은 startService()에 의해 시작된 서비스를 바인드 할 수 있다는 것을 의미합니다. 예를 들면, 음악을 재생하는 서비스는 일단 startService() 호출로 시작될 수 있고, 그 다음에 재생/정지/이전곡/다음곡 등의 기능 및 현재곡 정보를 가져오는 등의 일을 하기 위해 bindService()를 호출하여 서비스를 바인드할 수 있습니다. 이러한 경우, 바인드 했던 클라이언트들이 모두 바인드를 끊어도 stopSelf()나 stopService()가 호출되지 않는다면 서비스가 종료되지 않고, 반대로 stopSelf()나 stopService()가 호출되더라도 모든 클라이언트가 바인드를 끊을 때까지는 서비스가 종료되지 않습니다.


생명주기 콜백 메소드 구현하기

액티비티처럼, 서비스도 생명주기 상태를 모니터링 하면서 적절한 시점에 원하는 작업을 할 수 있는 생명주기 콜백 메소드를 갖습니다. 아래의 서비스 코드 뼈대는 서비스의 생명주기 콜백 메소드들을 보여줍니다.

public class ExampleService extends Service {
   
int mStartMode;       // indicates how to behave if the service is killed
   
IBinder mBinder;      // interface for clients that bind
   
boolean mAllowRebind; // indicates whether onRebind should be used

   
@Override
   
public void onCreate() {
       
// The service is being created
   
}
   
@Override
   
public int onStartCommand(Intent intent, int flags, int startId) {
       
// The service is starting, due to a call to startService()
       
return mStartMode;
   
}
   
@Override
   
public IBinder onBind(Intent intent) {
       
// A client is binding to the service with bindService()
       
return mBinder;
   
}
   
@Override
   
public boolean onUnbind(Intent intent) {
       
// All clients have unbound with unbindService()
       
return mAllowRebind;
   
}
   
@Override
   
public void onRebind(Intent intent) {
       
// A client is binding to the service with bindService(),
       
// after onUnbind() has already been called
   
}
   
@Override
   
public void onDestroy() {
       
// The service is no longer used and is being destroyed
   
}
}

메모: 액티비티의 콜백 메소드들과는 다르게 서비스의 콜백 메소드들을 구현할 때는 super class의 메소드들을 호출해 줄 필요가 없습니다.


그림 2. 서비스의 생명주기. 왼쪽은 startService()로 서비스가 생성된 경우이고, 오른쪽은 bindService()로 서비스가 생성된 경우입니다.


콜백 메소드들을 구현해보면, 서비스의 생명주기 안에서 두 개의 중첩된 루프 구간이 있다는 것을 알게 될 것입니다.

  • 전체 구간(entire lifetime)onCreate()onDestroy() 사이 구간입니다. 액티비티에서처럼, 서비스도 onCreate()에서 서비스가 실행되는 동안 사용할 멤버 변수 셋팅 등의 초기화를 하고, onDestroy()에서 그것들을 정리해줘야 합니다(release). 예를 들면, 음악 재생 서비스는 onCreate()에서 음악을 재생하는 쓰레드를 생성하고, onDestroy()에서 그 쓰레드를 종료합니다. 
    onCreate()와 onDestroy()는 startService()를 호출한 경우와 bindService()를 호출한 경우 모두에서 공통적으로 호출되는 콜백 메소드입니다.
  • 활성 구간(active lifetime)onStartCommand()onBind()가 호출되었을때부터 시작됩니다. 각 메소드는 각각 startService()와 bindService()로부터 인텐트를 전달 받습니다. 
    startService() 호출로 서비스가 시작된 경우에, 활성 구간의 끝은 전체 구간의 끝과 같이 onDestroy()입니다. 하지만 bindService() 호출로 서비스가 바인드된 경우에, 활성 구간의 끝은 onUnbind() 입니다.

메모: 시작된(started) 서비스가 stopSelf()나 stopService() 호출로 정지될 때, 그에 대응하는 onStop()과 같은 콜백 메소드는 없습니다. 따라서 서비스가 바인드 된 경우가 아니라면, 서비스가 정지될 때 호출되는 유일한 콜백 메소드는 onDestroy() 입니다.

그림2는 전형적인 서비스의 콜백 메소드들을 보여줍니다. 그림에서는 startService()로 생성된 경우와 bindService()로 생성된 경우를 구분하고 있기는 하지만, startService()로 시작된 서비스들을 bindService()로 바인드할 수 있다는 점을 명심해야할 것입니다. 따라서 onStartCommand()가 호출된 서비스에서 onBind()가 호출될 수도 있는 것입니다.

바인드 되는 서비스를 만드는 방법에 대한 자세한 내용은 서비스 바인드하기 문서에서 학습하실 수 있으며, onRebind() 콜백 메소드에 대한 내용은 해당 문서의 바인드 된 서비스의 생명주기 관리하기 섹션에서 학습하실 수 있습니다.


Posted by 개발자 김태우
,

(원문: http://developer.android.com/guide/components/tasks-and-back-stack.html)

(위치: Develop > API Guides > App Components > Activities
> Tasks and Back Stack
)

(전체 번역 글 목록으로 이동하기)


태스크와 백스택


앱은 보통 여러개의 액티비티로 구성됩니다. 각 액티비티는 특정한 목적을 갖는 화면을 보여주며, 다른 액티비티들을 실행할 수 있습니다. 예를 들어, 이메일앱은 읽지 않은 이메일 목록을 보여주는 액티비티가 있으며, 목록에서 이메일을 선택하면 그 이메일 내용을 보여주는 액티비티도 있습니다. 

액티비티는 디바이스에 설치된 다른 앱의 액티비티도 실행할 수 있습니다. 예를 들어, 내 앱에서 이메일 보내기가 되도록 하고 싶다면, Intent.ACTION_SEND 액션값과 이메일 주소 및 메시지를 담고 있는 인텐트를 만들어서 실행하면 됩니다. 그러면 디바이스에 설치된 모든 앱 중에서 해당 인텐트에 대응되는, 즉 이메일을 비롯한 데이터를 보내는 기능을 수행하는 액티비티가 실행될 것입니다. 만약 그러한 액티비티가 2개 이상이라면 사용자가 선택할 수 있도록 다이얼로그를 띄워 줍니다. 이메일 보내기 작업이 완료되면 이메일 쓰기 액티비티가 종료되면서 내 앱의 액티비티가 다시 resumed 상태가 되는데, 이는 마치 이메일 쓰기 액티비티가 내 앱의 일부인 것처럼 보이게 합니다. 이러한 경우, 비록 액티비티들이 서로 다른 앱에 속해 있긴 하지만, 같은 태스크에 있음으로써 사용자에게 자연스러운 사용자 경험(UX)을 제공할 수 있는 것입니다. 

태스크는 사용자에 의해 실행된 액티비티들의 모음입니다. 액티비티들은 생성되면서 "백스택"이라 불리는 스택에 쌓이게 됩니다.

디바이스의 홈 화면은 거의 모든 태스크들의 시작점입니다. 사용자가 앱 런처에서 앱 아이콘을 터치하거나, 홈 화면의 바로가기 아이콘을 터치하면, 앱의 태스크가 전면(foreground)으로 나오게 됩니다. 만약 앱을 최근에 실행한 적이 없어서 태스크가 없다면, 새 태스크가 생성되며 앱의 "메인" 액티비티가 실행되고 태스크의 root가 됩니다.

현재 액티비티가 다른 액티비티를 실행하면, 실행된 새 액티비티가 스택의 top에 들어가면서 포커스를 획득하며, 이전 액티비티는 여전히 스택에 들어가 있기는 하지만 stopped 상태가 됩니다. 액티비티가 정지되더라도(stop), 시스템은 액티비티의 사용자 인터페이스(UI)의 현재 상태를 보존하며, 사용자가 뒤로가기버튼을 눌렀을 때, 현재의 액티비티를 스택에서 꺼내고(popped. 이때 액티비티는 종료됩니다), 이전 액티비티를 resumed 상태로 만듭니다(정지되기전 UI 상태가 복구됩니다). 스택에 있는 액티비티의 배열은 절대 바뀌지 않습니다. 단지 스택에 넣고(push) 꺼내는(pop) 동작만 있을 뿐입니다. 액티비티가 생성될 때 스택에 넣고, 사용자가 뒤로가기버튼을 눌러서 종료될 때 꺼내는 것입니다. 이러한 백스택의 자료구조를 "후입선출"이라고 표현합니다. 아래의 그림1은 액티비티들이 백스택에 어떻게 들어가고 나오는지를 보여줍니다.


그림 1. 이 그림은 태스크의 새 액티비티가 백스택에 어떻게 추가되는지와, 뒤로가기버튼을 눌렀을 때 현재 액티비티가 종료되며 이전 액티비티가 다시 resumed 상태가 되는 것을 보여줍니다.


만약 사용자가 계속 뒤로가기버튼을 누른다면, 스택의 top에 있는 액티비티가 제거되면서 이전 액티비티가 resumed 상태로 스택의 top이 될 것이고, 결국은 홈화면으로 빠져나갈 것입니다(또는 해당 태스크가 시작되기 전의 마지막 액티비티가 실행될 것입니다). 태스크의 모든 액티비티가 제거되면, 그 태스크도 없어집니다.


그림 2. 두개의 태스크: 태스크 B는 전면(foreground)에서 사용자와 상호작용을 하지만, 태스크 A는 후면(background)에서 다시 resumed 상태가 되기를 기다립니다.


태스크는 사용자가 다른 태스크를 시작하거나, 홈버튼을 눌러서 홈화면으로 전환될 때, "후면(background)"으로 이동합니다. 후면에 있을 때, 태스크의 모든 액티비티들은 stopped 상태가 되지만, 태스크의 백스택은 액티비티들을 잘 유지하며, 그림2에서 보이는 바와 같이 다른 태스크에게 자리를 내주고 포커스만 잃게 되는 것입니다. 태스크는 다시 "전면(foreground)"으로 나설 수 있으며, 사용자는 해당 태스크의 마지막 화면을 볼 수 있습니다. 예를 들어, 현재의 태스크(Task A)가 스택에 3개의 액티비티를 담고 있는데, 사용자가 홈버튼을 눌러 홈화면으로 이동한 후 앱 런처에서 다른 앱을 실행한다고 가정해 봅시다. 홈화면으로 이동할 때, Task A는 후면으로 이동합니다. 그리고 다른 앱을 실행할 때, 시스템은 그 앱의 태스크인 Task B를 시작하는데 그것은 자신의 액티비티들을 담을 스택을 따로 갖습니다. 그 앱에서 사용자가 할 일을 마치고 홈화면으로 돌아갔다가 다시 이전의 앱을 실행하면, Task A가 다시 전면으로 나설 것이며, 스택에 있던 3개의 액티비티는 모두 무사하고, 스택의 top에 있는 액티비티(사용자가 Task A에서 마지막으로 실행한 액티비티)가 다시 resumed 상태로 될 것입니다. 홈화면으로 이동할 때 Task B는 후면으로 이동합니다. 이전 태스크를 다시 전면으로 가져오는 방법은 해당 앱의 아이콘을 클릭하거나, 홈버튼을 길게 누르면 보이는 최근 태스크 목록에서 태스크를 선택하면 됩니다. 이것은 안드로이드 멀티태스킹의 한 예입니다.

메모: 여러개의 태스크가 동시에 후면에 있을 수 있긴 하지만, 메모리가 부족할 경우 시스템이 메모리를 회복하기 위해 후면에 있는 태스크의 액티비티들을 종료할 수도 있습니다. 본 문서에서는 액티비티의 상태를 저장하는 방법에 대해서도 학습할 것입니다. 


그림 3. 하나의 액티비티가 여러개의 서로 다른 객체로 생성될 수 있습니다.


백스택의 액티비티들은 절대 재배치되지 않기 때문에, 만약 내 앱에서 특정 액티비티가 여러개의 다른 액티비티로부터 실행될 수 있다면, 그 액티비티는 생성될 때마다 새로운 객체로 스택에 추가될 것입니다(기본적으로는, 스택에 있는 동일한 액티비티의 객체를 재사용하지 않습니다). 위의 그림3에서 보이는 바와 같이 하나의 액티비티가 여러번 객체로 생성될 수 있는 것입니다. 그리고 그림3과 같은 상황에서 사용자가 뒤로가기버튼을 연이어 누른다면, 백스택의 top에 있는 액티비티부터 차례대로 (해당 액티비티의 현재 UI상태가) 화면에 출력될 것입니다. 하지만, 액티비티의 객체가 한번만 생성되고 그것이 재사용되도록 할 수도 있으며, 자세한 내용은 태스크 관리하기 섹션에서 학습하실 수 있습니다.

액티비티와 태스크에 관한 기본적인 동작을 요약하면 아래와 같습니다:

  • 액티비티 A가 액티비티 B를 실행하면, 액티비티 A는 stopped 상태가 되지만, 시스템은 (스크롤 위치나 입력 영역에 입력된 문자열 등의) 액티비티 A에 대한 현재 상태를 유지합니다. 그리고 액티비티 B가 전면(foreground)에 있는 상태에서 뒤로가기버튼을 누르면 액티비티 A가 이전 상태를 유지하면서 다시 resumed 상태가 됩니다.

  • 사용자가 홈 버튼을 눌러서 현재 태스크로부터 빠져나가면, 현재 액티비티는 stopped 상태가 되고 태스크는 후면(background)으로 이동하지만, 시스템은 태스크의 액티비티들에 대한 상태를 유지해 줍니다. 그리고 만약 사용자가 런처에서 해당 앱 아이콘을 눌러 다시 앱을 실행하고자 하면, 해당 태스크가 다시 전면(foreground)으로 나오면서 백스택의 top에 있는 액티비티가 다시 resumed 상태가 됩니다.

  • 사용자가 뒤로가기버튼을 누르면, 현재 액티비티가 백스택에서 빠지면서 종료되고, 이전 액티비티가 resumed 상태로 변경됩니다. 액티비티가 종료되면, 시스템은 더이상 종료된 액티비티의 상태를 유지해주지 않습니다.

  • 액티비티는 (어떤 태스크에서건) 여러번 객체로 생성될 수 있습니다. 

네비게이션 설계: 안드로이드에서 앱의 네비게이션이 어떻게 동작하는지에 대한 자세한 내용은 안드로이드 디자인 영역의 네비게이션 가이드에서 학습하실 수 있습니다.


액티비티의 상태 저장하기

위에서 학습한 바에 따르면, 시스템은 기본적으로 액티비티가 stopped 상태로 될 때, 그 상태를 유지해줍니다. 이대로라면, 사용자가 뒤로가기버튼을 눌러 이전 액티비티로 이동할 때, 보여지는 상태는 사용자가 그 액티비티에서 벗어날 때의 마지막 상태가 됩니다. 하지만, 액티비티는 사용자의 의도와 상관없이 시스템에 의해 종료 및 재생성 되는 경우가 있기 때문에, 액티비티의 콜백 메소드를 이용하여 상태를 저장해야 합니다.

액티비티가 stopped 상태로 변경되면(새 액티비티를 실행하거나, 태스크가 후면(background)으로 이동하거나 하는 경우), 시스템은 메모리를 확보하기 위해 액티비티를 완전히 종료시켜버릴 수도 있습니다. 이러한 경우, 액티비티의 상태에 대한 정보는 모두 잃게 되지만, 백스택에서도 해당 액티비티가 빠지는 것은 아닙니다. 따라서, 시스템에 의해 종료된 액티비티가 백스택의 top에 오게 되면, (재사용할 액티비티 객체가 종료되었으므로) 액티비티를 다시 생성해야 합니다. 이러한 경우 액티비티의 상태를 복구하기 위해서는, 액티비티의 onSaveInstanceState() 콜백 메소드에서 상태를 저장하도록 구현해야 합니다. 

이와 관련하여 더 자세한 내용은 액티비티의 상태 저장하기 문서에서 학습하실 수 있습니다.


태스크 관리하기 

위에서 학습한 바와 같이 안드로이드는 기본적으로, 연이어 생성되는 액티비티들을 같은 태스크 내에 그리고 "후입선출" 스택에 집어 넣습니다. 태스크와 백스택을 관리하는 안드로이드의 이러한 방식은 대부분의 앱에서 훌륭하게 작동하며, 보통은 액티비티들이 어떤 태스크에 속하고 어떤 백스택에 들어가 있는지를 신경쓸 필요가 없습니다. 하지만, 내 앱에서는 이러한 안드로이드의 기본 동작과는 다르게 구현하고 싶을 수 있겠죠. 액티비티가 생성될 때 현재 태스크가 아닌 새로운 태스크를 시작하고 싶을 수도 있고, 현재 태스크에 액티비티의 객체가 있다면 액티비티 객체를 새로 생성하지 않고 이미 있는 액티비티 객체를 재사용하고 싶을 수도 있을 것입니다. 또는 사용자가 현재 태스크에서 벗어날 때, root에 있는 액티비티를 제외하고 나머지는 모두 제거하고 싶을 수도 있을 것입니다.

이러한 요구사항들은, 매니페스트 파일의 <activity> 요소에 관련 속성을 추가하거나, 소스코드의 startActivity()에 넘길 인텐트에 플래그를 추가함으로써 구현할 수 있습니다.

<activity> 요소의 주요 속성들:

taskAffinity
launchMode
allowTaskReparenting
clearTaskOnLaunch
alwaysRetainTaskState
finishOnTaskLaunch

인텐트의 주요 플래그들:

FLAG_ACTIVITY_NEW_TASK
FLAG_ACTIVITY_CLEAR_TOP
FLAG_ACTIVITY_SINGLE_TOP

이어지는 섹션에서는, 액티비티들이 어떤 태스크에 속하고 어떤 백스택에 들어가게 될지를 정의하기 위해서, 매니페스트의 속성들 및 인텐트의 플래그들을 사용하는 방법에 대하여 학습할 것입니다.

주의사항: 대부분의 앱에서는 액티비티와 태스크에 대한 기본 동작을 변경할 필요가 없습니다. 만약 변경할 필요가 있다면, 앱이 실행되는 동안의 사용성과 다른 액티비티 및 태스크에서 뒤로가기버튼을 눌러 돌아오는 동작 등에 대하여 충분히 테스트를 함으로써, 사용자가 기대하는 동작과 어긋나지 않도록 신경을 써야 합니다.


시작(launch) 모드 정의하기

시작 모드는 액티비티의 새 객체가 현재의 태스크에 어떻게 속하게 될지를 결정해 줍니다. 아래와 같이 2가지 방식으로 시작 모드를 지정할 수 있습니다:

매니페스트 파일 사용하기
메니페스트 파일에 액티비티를 선언할 때, 액티비티 생성시 태스크에 어떻게 속하게 될지를 지정할 수 있습니다.

인텐트 플래그 사용하기
소스코드에서 startActivity() 호출할 때, 인텐트에 플래그를 추가함으로써 액티비티가 태스크에 어떻게 속하게 될지를 지정할 수 있습니다.

액티비티 A가 액티비티 B를 실행하는 경우에, 액티비티 B는 매니페스트 파일에 속성을 추가함으로써 자신이 현재 태스크에 어떻게 속하게 될지를 지정할 수 있고, 액티비티 A는 액티비티 B를 실행하는 인텐트에 플래그를 추가함으로써 같은 역할을 할 수 있습니다. 이 둘은 모두 액티비티 B와 태스크의 관계를 지정하는 것이며, 액티비티 A의 인텐트 실행이 액티비티 B의 매니페스트 파일 지정보다 우선합니다.

메모: 매니페스트 파일에 지정되는 일부 시작 모드는 인텐트를 통해 구현될 수 없고, 반대로 인텐트 플래그로 지정되는 일부 시작 모드는 매니페스트 파일에 지정될 수 없습니다.


매니페스트 파일 사용하기

매니페스트 파일에 액티비티를 선언할 때, <activity> 요소에 launchMode 속성을 추가함으로써 액티비티가 태스크와 어떤 관계를 맺게 될지를 지정할 수 있습니다.

launchMode 속성은 액티비티가 태스크에 어떤 방식으로 추가될 지를 지정합니다. 아래에 4가지 launchMode 속성값에 대해 간단히 설명하겠습니다:

"standard" (기본값)
시스템은 액티비티를 실행하려고 인텐트를 넘겨주고 있는 그 태스크에서 액티비티의 새 객체를 생성합니다. 액티비티는 여러번 객체를 생성할 수 있고, 각각은 서로 다른 태스크에 들어갈 수도 있으며, 하나의 태스크가 (한 액티비티에 대한) 여러개의 객체를 포함할 수 있습니다.

"singleTop"
어떤 액티비티의 객체가 이미 현재 태스크의 top에 있는데 그 액티비티에 대하여 startActivity()를 호출할 경우, 시스템은 액티비티의 새 객체를 생성하는 대신에 태스크의 top에 있는 액티비티 객체의 onNewIntent()를 호출함으로써 재사용합니다. 위의 standard와 마찬가지로, 액티비티는 여러번 객체를 생성할 수 있고, 각각은 서로 다른 태스크에 들어갈 수도 있으며, 하나의 태스크가 (한 액티비티에 대한) 여러개의 객체를 포함할 수 있습니다. 하지만, 태스크의 top에 하나의 액티비티에 대한 2개의 객체가 들어가는 상황만은 허용하지 않으며, 이미 있는 객체를 재사용하도록 하는 것입니다.

예를 들어, 태스크의 백스택이 A, B, C, D라는 액티비티로 채워져있다고 가정해 봅시다(스택은 A-B-C-D이며, D가 top입니다). 그리고 시스템에게 D를 실행해 달라는 인텐트가 왔다고 합시다. 만약 D의 시작 모드(launchMode)가 "standard"라면, D의 새 객체가 생성되어 백스택에 추가되고 스택은 A-B-C-D-D가 될 것입니다. 하지만 D의 시작 모드가 "singleTop"이라면, 백스택의 top에 이미 D의 객체가 있기 때문에 그 객체가 onNewIntent()를 통해 해당 인텐트를 받을 것이고 스택은 그대로 A-B-C-D가 될 것입니다. 하지만 시작되는 액티비티가 D가 아닌 B라면 시작 모드가 "singleTop"이라 하더라도, 새 객체가 생성되어 백스택에 추가될 것이고 스택은 A-B-C-D-B가 될 것입니다. 

메모: 액티비티의 새 객체가 생성되어 태스크에 추가되었을 때, 사용자가 뒤로가기버튼을 누르면 이전 액티비티로 돌아갈 수 있었습니다. 그러나 시작 모드가 "singleTop"이라서 태스크의 top에 있는 액티비티가 재사용된 상황이라면, 사용자가 뒤로가기버튼을 눌렀을 때, 재사용되기 전 상태로 돌아가는 것이 아니라, 현재 액티비티가 종료되고 그 이전의 액티비티가 재시작될 것입니다.

"singleTask"
시스템은 새 태스크를 생성하고, 태스크의 root에 액티비티의 새 객체를 생성합니다. 하지만, 태스크에 이미 해당 액티비티의 객체가 존재한다면, 액티비티의 새 객체를 생성하지 않고 기존 객체의 onNewIntent()를 호출함으로써 재사용합니다. 하나의 액티비티에 대한 객체는 오직 하나만 존재할 수 있습니다.

메모: 비록 액티비티가 새 태스크에 생성되었다 하더라도, 뒤로가기버튼을 누르면 이전 액티비티로 돌아갈 수 있습니다.

역자의 추가글: singleTask에 대한 설명이 오해의 소지가 많아 몇가지 설명을 덧붙입니다. 시스템은 시작 모드가 singleTask인 액티비티를 실행할 때 taskAffinity가 같은 태스크가 있는지를 확인하여, 만약 있다면 그 태스크에 액티비티의 객체를 생성하여 넣으며, 이때의 위치는 태스크의 root가 아니겠죠. 그리고 만약 taskAffinity가 같은 태스크가 없다면, 새 태스크를 생성하고 그 태스크의 root에 액티비티의 새 객체를 생성합니다. taskAffinity에 대한 자세한 내용은 아래의 친밀도(affinity) 다루기에서 학습하실 수 있습니다.

"singleInstance"
자신이 속한 태스크에 다른 액티비티의 객체들을 들어오지 못하게 한다는 점을 제외하고는 "singleTask"와 비슷합니다. 하나의 액티비티에 대한 객체는 오직 하나이고, 자신이 속한 태스크에는 오직 자신만 있을 뿐입니다. 이 액티비티에서 다른 액티비티를 실행한다면, 실행된 액티비티는 다른 태스크에 속하게 됩니다.

다른 예로, 인터넷 브라우저 앱은 브라우저 액티비티의 시작 모드를 singleTask로 지정했기 때문에, 내 앱에서 브라우저를 실행할 때 브라우저 액티비티가 내 앱의 태스크에 들어가지 않습니다. 대신에 새 태스크를 생성하거나, 이미 존재하는 브라우저의 태스크가 후면(background)에 있다면 전면(foreground)으로 가져와서 재사용합니다. 

액티비티가 새 태스크에 들어갔건, 기존의 태스크에 들어갔건 상관없이 사용자가 뒤로가기버튼을 누르면 현재 액티비티가 종료되고 이전 액티비티가 실행됩니다. 하지만, 시작 모드가 singleTask인 액티비티를 실행하는데, 그 액티비티는 이미 후면에 있는 태스크에 존재하고 그 태스크에는 다른 액티비티도 있는 경우, 시스템은 후면에 있던 태스크를 통째로 전면으로 가져옵니다. 이 때, 현재의 백스택의 top에다가, 후면에서 가져온 태스크의 액티비티들을 모두 집어넣습니다. 아래의 그림4가 이 과정을 보여줍니다.


그림 4. 시작 모드가 "singleTask"인 액티비티를 백스택에 추가하는 모습을 보여줍니다. 만약 액티비티가 후면에 있는 다른 태스크에 이미 존재한다면, 그 태스크가 통째로 현재의 태스크의 top으로 옮겨오게 됩니다.

매니페스트 파일에 시작 모드를 설정하는 방법에 대한 더 자세한 내용은, <activity>의 android:launchMode에서 학습하실 수 있습니다.

메모: 매니페스트 파일에서 지정한 launchMode는, 액티비티를 실행할 때 넘겨주는 인텐트에 포함된 플래그값으로 덮어씌워질 수 있습니다. 다시 말해서, 인텐트에 포함된 (시작 모드에 대한) 플래그값이 매니페스트 파일에 지정된 시작 모드보다 우선합니다.


인텐트의 플래그 사용하기

액티비티를 실행할 때, startActivity()에 넘겨주는 인텐트에 플래그를 설정함으로써 액티비티의 시작 모드를 바꿀 수 있습니다. 그 플래그값들은 아래와 같습니다:

FLAG_ACTIVITY_NEW_TASK
실행하려는 액티비티의 객체가 존재하지 않으면 새 태스크의 root에 액티비티의 새 객체가 들어갑니다. 하지만 후면(background)에 이미 그 액티비티를 담고 있는 태스크가 있다면, 전면(foreground)으로 가져와서 마지막 상태를 복구하고 인텐트를 onNewIntent()에 넘겨줍니다. 하지만 후면에 있던 태스크의 top에 있던 액티비티가 실행되기 때문에, 실행하고자 했던 액티비티와 실행된 액티비티가 다를 수도 있습니다. 따라서 대부분의 경우 FLAG_ACTIVITY_CLEAR_TOP과 함께 사용합니다.
이것(FLAG_ACTIVITY_NEW_TASK + FLAG_ACTIVITY_CLEAR_TOP)은 위에서 학습한 "singleTask"의 역할과 같습니다.

FLAG_ACTIVITY_SINGLE_TOP
현재 실행중인 액티비티(현재 태스크의 top에 있는)에서 다시 동일한 액티비티를 실행하려고 하는 경우, 새 객체를 생성하는 대신 현재 액티비티의 onNewIntent()를 호출함으로써 액티비티를 재사용합니다.
이것은 위에서 학습한 "singleTop"의 역할과 같습니다.

FLAG_ACTIVITY_CLEAR_TOP
만약 실행하려고 하는 액티비티의 객체가 이미 현재 태스크에 존재한다면, 새 객체를 생성하는 대신, 그 액티비티 객체의 위에 있는 다른 액티비티들을 모두 제거하여 그 액티비티가 태스크의 top이 되도록 합니다. 
이것과 역할이 같은 launchMode값은 없습니다.

다시 말하지만, FLAG_ACTIVITY_CLEAR_TOP은 대부분의 경우 FLAG_ACTIVITY_NEW_TASK와 함께 사용됩니다. 이 경우, 실행하려고 하는 액티비티가 후면(background)에 있는 태스크에 존재한다면, 그 태스크를 전면(foreground)으로 가져오고, 해당 액티비티 위에 다른 액티비티들이 있다면 모두 제거하여 그 액티비티가 실행되도록 합니다.

메모: 인텐트의 플래그가 FLAG_ACTIVITY_CLEAR_TOP인데, 만약 실행하려는 액티비티의 시작 모드가 "standard"이면, 기존에 있던 액티비티의 객체를 제거후 다시 생성하여 태스크에 넣습니다. 시작 모드가 "standard"일때는 시스템이 새 액티비티를 일단 생성하고 보기 때문입니다. 


친밀도(affinity) 다루기

친밀도(affinity)는 액티비티가 어떤 태스크에 속하고 싶어하는가를 지정할 수 있습니다. 기본적으로 같은 앱에 속한 액티비티들은 같은 친밀도(패키지명)를 가지고 있기 때문에, 같은 태스크에 속하고 싶어하지만, 액티비티의 친밀도를 바꿀 수 있습니다. 서로 다른 앱의 액티비티들이 같은 친밀도를 가질 수도 있고, 하나의 앱의 액티비티들이 다른 친밀도를 가질 수도 있습니다. 

친밀도는 매니페스트 파일의 <activity> 요소에 taskAffinity 속성을 통해 지정할 수 있습니다.

taskAffinity 속성의 기본값은 <manifest>에 선언된 패키지명이며 이것이 앱의 친밀도이기 때문에, 내 앱에서 다른 태스크를 생성하고자 taskAffinity를 지정하는 것이라면 패키지명과 다른 문자열로 지정해야 합니다.

친밀도는 아래의 2가지 상황에서 작동합니다:

  • 액티비티를 실행하는 인텐트에 FLAG_ACTIVITY_NEW_TASK가 포함되어 있을 때.
    기본적으로 새 액티비티는, 생성될 때 startActivity()를 호출한 액티비티와 같은 태스크에 들어갑니다. 하지만, startActivity()에 넘긴 인텐트에 FLAG_ACTIVITY_NEW_TASK가 포함되어 있다면, 시스템은 액티비티의 친밀도와 같은 태스크를 찾습니다. 만약 그러한 태스크가 존재한다면 그 태스크에 새 액티비티를 넣고, 존재하지 않는다면 새 태스크를 생성합니다. 
    만약 이 플래그를 이용하여 새 태스크를 만들었는데 사용자가 홈버튼을 눌러 빠져나간 경우 다시 그 태스크로 가고 싶다면, 그 태스크로 이동할 수 있는 방법이 존재해야 한다는 것을 개발시 고려해야 합니다. (노티피케이션 매니저와 같은) 일부 요소들은 항상 다른 태스크에서 액티비티를 실행하기 때문에, startActivity()에 넘기는 인텐트에 항상 FLAG_ACTIVITY_NEW_TASK를 설정합니다. 만약 이러한 방식으로 액티비티가 실행되었다면, 사용자가 그 태스크로 이동할 수 있는 독립적인 방법이 있어야 합니다. 예를 들면, 태스크의 root에 있는 액티비티가 매니페스트에 선언될 때 인텐트 필터에 CATEGORY_LAUNCHER가 선언되어 있다면, 런처화면에 아이콘이 제공되어 그 아이콘을 누르면 그 태스크로 이동하게 됩니다. 태스크를 시작하는 것과 관련한 자세한 내용은 태스크 시작하기에서 학습하실 수 있습니다.

  • <activity>의 allowTaskReparenting 속성값이 "true"일 때.
    이 경우에, 액티비티가 현재는 자신이 시작된 태스크에 있다하더라도, 자신과 친밀도가 같은 태스크가 전면(foreground)으로 나올 때, 그 태스크로 이동할 수 있습니다. 
    예를 들어, 어떤 여행 앱에, 선택된 도시의 날씨 정보를 보여주는 액티비티가 있다고 가정해 봅시다. 그 액티비티는 여행 앱의 다른 모든 액티비티들과 같은 친밀도를 가지고 있고, allowTaskReparentingtrue로 지정되어 있습니다. 내 앱에서 그 날씨 정보 액티비티를 실행하면, 그 액티비티는 그것을 실행한 내 액티비티의 태스크에 들어갈 것입니다. 하지만, 여행 앱의 태스크가 전면(foreground)으로 나올 때, 그 날씨 정보 액티비티는 여행 앱의 태스크로 옮겨질 것입니다.

팁: 만약 하나의 .apk파일이 사용자 관점에서 2개 이상의 "앱"으로 구성되어 있다면, 각 "앱"에 해당하는 액티비티들에 대하여 각각의 친밀도를 부여할 수도 있을 것입니다.


백스택 비우기

만약 사용자가 태스크에서 오랜 시간 벗어나 있었다면, 시스템은 그 태스크의 root 액티비티만 남기고 나머지는 모두 제거합니다. 사용자가 다시 그 태스크에 돌아왔을때는 root에 있던 액티비티 하나만 남아있겠죠. 시스템이 이렇게 동작하는 이유는, 사용자가 어떤 태스크에서 벗어나 오랜 시간이 지났다면 아마도 이전에 뭘했는지를 잊었을 것이고 다시 돌아왔을때는 새로 시작하는 것이 나을 것이라고 판단하기 때문입니다.

하지만 이러한 동작도 액티비티의 몇가지 속성을 통해 바꿀 수 있습니다:

alwaysRetainTaskState
태스크의 root 액티비티의 이 속성이 "true"이면, 오랜 시간이 지나도 액티비티들을 제거하지 않고 모든 액티비티들을 유지합니다.

clearTaskOnLaunch
태스크의 root 액티비티의 이 속성이 "true"이면, 사용자가 이 태스크에서 벗어났다가 다시 돌아올 때, (벗어나 있던 시간과 상관없이) root 액티비티만 남기고 나머지는 모두 제거합니다. 달리 말하면, alwaysRetainTaskState 속성과 반대되는 속성이라고 할 수 있습니다. 사용자가 아주 잠깐 벗어났다가 돌아오더라도 태스크는 초기 상태가 되는 것입니다. 

finishOnTaskLaunch
이 속성은 clearTaskOnLaunch와 비슷하지만, 태스크가 아닌 하나의 액티비티(root 액티비티 포함)에 대해서 동작합니다. 이 속성이 "true"일 때, 액티비티는 오직 태스크가 전면(foreground)에 있을 때만 태스크 안에 유지될 수 있습니다. 사용자가 태스크에서 벗어났다가 돌아올 경우에 그 액티비티는 제거됩니다.


태스크 시작하기

태스크의 진입점을 나타내기 위해, 액티비티에 액션값이 "android.intent.action.MAIN"이고 카테고리가 "android.intent.category.LAUNCHER"인 인텐트 필터를 선언할 수 있습니다. 예제 코드:

<activity ... >
   
<intent-filter ... >
       
<action android:name="android.intent.action.MAIN" />
       
<category android:name="android.intent.category.LAUNCHER" />
   
</intent-filter>
    ...
</activity>

위의 인텐트 필터는 액티비티에 설정된 아이콘과 이름을 앱의 런처화면에 보이게 해줍니다. 이는 사용자가 그 액티비티를 실행할 수 있도록 하고, 액티비티 실행후 태스크에서 벗어나더라도 언제던지 런처를 통해 태스크로 돌아올 수 있게 합니다. 

위에서 두번째로 얘기한, 태스크로 돌아올 수 있게 한다는 기능이 중요합니다: 사용자들은 태스크에서 벗어났다가 액티비티 런처를 통해 다시 돌아올 수 있어야 합니다. 이 때문에, 새 태스크를 생성할 수 있는 2가지 시작 모드인 "singleTask""singleInstance"는, 액티비티가 ACTION_MAINCATEGORY_LAUNCHER 필터를 가지고 있을 때만 사용하는 것이 좋습니다. 이러한 필터가 없이 실행된 경우를 상상해 보세요: 시작 모드가 "singleTask"인 액티비티를 실행하여 새 태스크가 생성되었고, 사용자가 거기서 홈버튼을 눌러 벗어났다면, 이제 그 태스크는 후면(background)으로 들어가게 되고 더이상 보이지 않습니다. 그 액티비티에는 위의 인텐트 필터가 없다고 했기 때문에 런처 화면에 보이지 않을 것입니다. 따라서 (내 앱이 태스크를 실행하지 않는 한) 이제는 사용자가 그 태스크로 돌아갈 방법이 없습니다.


Posted by 개발자 김태우
,

(원문: http://developer.android.com/guide/components/loaders.html)

(위치: Develop > API Guides > App Components > Activities 
> Loaders)

(전체 번역 글 목록으로 이동하기)


로더 (Loaders)


안드로이드3.0(허니컴)에서 처음 소개된 로더(loader)는 액티비티 및 프레그먼트에서 데이터를 비동기적으로 가져오는 작업을 이전보다 더 쉽게 할 수 있도록 해줍니다. 로더의 특징은 아래와 같습니다:

  • 모든 액티비티프레그먼트에서 사용가능합니다.
  • 비동기적으로 데이터를 가져오는 기능을 제공합니다.
  • 데이터를 모니터링하고 있다가 값에 변화가 생기면 그 새로운 결과를 전달해 줍니다.
  • 설정이 변경되어 로더가 재생성될 때, 자동적으로 마지막 위치를 다시 연결함으로써, 데이터를 다시 요청하지 않아도 됩니다.


로더 API 요약


아래 표에서는 로더와 관련된 클래스 및 인터페이스에 대하여 요약하고 있습니다:

 클래스/인터페이스

  설명 

 LoaderManager

 액티비티프레그먼트에서 하나 이상의 로더를 관리하기 위해 사용하는 클래스입니다(추상 클래스라서 객체를 직접 생성할 수 없고, getLoaderManager()를 호출하여 객체를 얻습니다). 이것은 앱이 액티비티나 프레그먼트의 생명주기와 함께, 시간이 오래걸리는 작업을 관리하는 것을 도와줍니다. CursorLoader와 함께 사용되는 것이 일반적이지만, 로더를 내 앱에 맞게 만들어 사용할 수 있습니다. 

 액티비티나 프레그먼트에는 오직 하나의 LoaderManager가 있지만, 하나의 LoaderManager는 여러개의 로더를 가질 수 있습니다.

 LoaderManager
 .LoaderCallbacks

 클라이언트와 LoaderManager 간의 상호작용을 위한 콜백 인터페이스입니다. 예를 들어, onCreateLoader()는 새로운 로더를 생성할 때 호출되는 콜백 메소드입니다.

 Loader

 데이터를 비동기적으로 가져오는 기능을 하는 추상 클래스입니다. 보통은 CursorLoader를 사용하면 되지만, Loader클래스를 상속받아서 내 앱에 맞는 서브 클래스를 만들 수 있습니다. 로더는 실행되고 있는 동안에 데이터를 모니터링하고 있다가 변화가 생기면 그 결과를 전달해 줍니다.

 AsyncTaskLoader

 Loader를 상속받아 구현한 추상 클래스로서, 기존에 AsyncTask가 하던 일을 하는 로더입니다.

 CursorLoader

 AsyncTaskLoader의 서브 클래스로서, ContentResolver에 질의하여 Cursor객체를 얻어 옵니다. 이 클래스는 커서에 질의하는 표준적인 방법으로 로더의 프로토콜을 구현하는데, 앱의 UI가 멈추지 않게 하기 위하여 백그라운드 쓰레드에서 동작하는 AsyncTaskLoader를 상속받아 만들어졌습니다. ContentProvider로부터 데이터를 가져올 때는, 프레그먼트나 액티비티에서 직접 질의하는 대신에, CursorLoader를 사용하는 것이 최선의 방법입니다.


위의 표에 소개된 클래스와 인터페이스들은 내 앱에 로더를 구현하기 위해 필수적인 요소들입니다. 로더를 만들 때 위의 모든 것들이 다 사용되지는 않을 수도 있지만, 로더를 초기화하기 위해 LoaderManager는 반드시 필요하고, CursorLoader와 같이 Loader클래스를 상속받은 로더가 필요합니다. 아래 이어지는 섹션들에서 이러한 클래스 및 인터페이스를 이용하는 방법에 대하여 학습할 것입니다.


앱에서 로더 사용하기


이번 섹션에서는 안드로이드 앱에서 로더를 사용하는 방법을 설명합니다. 로더를 사용하는 앱은 보통 아래 내용들을 포함합니다:

  • 액티비티 또는 프레그먼트
  • LoaderManager 객체
  • ContentProvider로부터 데이터를 가져오기 위한 CursorLoader, 또는 LoaderAsyncTaskLoader를 상속받아 구현한 서브 클래스
  • LoaderManager.LoaderCallbacks의 구현체. 여기서 로더를 만들고 관리합니다.
  • 로더의 데이터를 보여줄 수단들. 예를 들면, SimpleCursorAdapter.
  • 데이터 제공자. 예를 들면, ContentProvider.


로더 시작하기

LoaderManager는 액티비티나 프레그먼트에서 하나 이상의 Loader객체를 관리합니다. 하나의 액티비티나 프레그먼트에 하나의 LoaderManager가 사용됩니다(싱글톤). 

보통 액티비티의 onCreate()나 프레그먼트의 onActivityCreated()에 아래와 같은 코드를 추가하여 로더를 초기화합니다:

// Prepare the loader.  Either re-connect with an existing one,
// or start a new one.
getLoaderManager
().initLoader(0, null, this);

위 코드의 initLoader()에 들어가는 인자들은 아래와 같습니다:

  • 로더 구분을 위해 사용하는 유일한 ID값. 위의 예제에서는 0입니다.
  • 로더 생성시 전달할 인자들을 담는 Bundle객체. 위의 예제에서는 null입니다.
  • LoaderManager.LoaderCallbacks의 구현객체로서, 로더에서 각종 이벤트가 발생할 때, 이 객체의 콜백메소드들이 호출됩니다. 위의 예제에서는 현재 클래스(액티비티나 프레그먼트)에 해당 인터페이스를 구현했다고 전제하고 this를 넘깁니다. 

initLoader() 호출은 로더를 초기화하고 활성화시키며, 아래와 같이 경우에 따라 로더를 재사용하거나 생성합니다:

  • 인자로 넘기는 ID로 지정된 로더가 이미 존재한다면, 그 로더를 재사용합니다.
  • 인자로 넘기는 ID로 지정된 로더가 없다면, LoaderManager.LoaderCallbacksonCreateLoader()가 호출되며, 여기서 로더를 생성하여 리턴해 줘야 합니다. 자세한 내용은 아래의 onCreateLoader 섹션에서 학습하실 수 있습니다.

어떠한 경우건, LoaderManager.LoaderCallbacks의 구현객체는 로더와 관련이 있고, 로더의 상태가 변할 때 그에 따른 콜백 메소드가 호출됩니다. initLoader()가 호출된 순간에 로더매니저가 시작된 상태이고, 요청받은 로더가 이미 존재하며 데이터를 다 가져왔다면, 시스템은 initLoader()가 끝나지 않았더라도 즉시 onLoadFinished()를 호출합니다. 따라서 이러한 상황에 대해서도 대비해야 합니다. 자세한 내용은 onLoadFinished에서 학습하실 수 있습니다.

initLoader() 메소드는 Loader객체를 리턴하지만, 굳이 멤버변수로 저장해 둘 필요는 없습니다. LoaderManager는 로더를 관리하며, 데이터 로딩을 시작하거나 정지하고, 로더의 상태 및 데이터를 유지하는 역할을 합니다. 이것은 로더의 메소드들을 직접 사용하는 일이 거의 없다는 것을 의미합니다(LoaderThrottle 샘플에서는 로더의 메소드를 사용하긴 합니다). 데이터를 로딩하는 과정에서 이벤트 발생시 해야할 일들은 대부분 LoaderManager.LoaderCallbacks의 콜백 메소드들에 구현합니다. 자세한 내용은 LoaderManager의 콜백 사용하기에서 학습하실 수 있습니다.


로더 재시작하기

위에서 학습한 바에 따르면, initLoader()를 호출할 때, 로더가 이미 있으면 그 로더를 재사용하고, 없으면 생성한다고 했습니다. 그러나 가끔은 로더가 이미 있더라도 데이터를 삭제하고 다시 시작하고 싶을 것입니다.

기존 데이터를 삭제하고 다시 시작하기 위해서는 restartLoader()를 사용하면 됩니다. 예를 들면, 아래 예제의 SearchView.OnQueryTextListener 구현 메소드는 SearchView에서 사용자의 질의문이 변경되었을 때 호출되며, 로더를 재시작합니다. 로더를 재시작함으로써, 새로운 질의를 할 수 있는 검색 필터를 사용할 수 있는 것입니다. 

public boolean onQueryTextChanged(String newText) {
   
// Called when the action bar search text has changed.  Update
   
// the search filter, and restart the loader to do a new query
   
// with this filter.
    mCurFilter
= !TextUtils.isEmpty(newText) ? newText : null;
    getLoaderManager
().restartLoader(0, null, this);
   
return true;
}


LoaderManager의 콜백 메소드들 사용하기

LoaderManager.LoaderCallbacks는 클라이언트가 LoaderManager와 상호작용할 수 있도록 해주는 콜백 인터페이스입니다.

Loader, 특히 CursorLoader는 실행이 중지된 후에도 데이터를 유지합니다. 이것은 앱이 액티비티나 프레그먼트의 onStop()onStart() 호출 사이에 데이터를 유지한다는 의미이고, 또한 액티비티나 프레그먼트가 stopped상태에서 다시 started상태가 되었을 때 데이터를 리로드(reload)할 필요가 없다는 의미이기도 합니다. LoaderManager.LoaderCallbacks 메소드들은 새로운 로더가 생성될 때나, 로더의 데이터를 그만 사용하라고 앱에게 알릴 때 등에 호출됩니다.

LoaderManager.LoaderCallbacks 인터페이스에는 아래 3개의 메소드가 정의되어 있습니다:

  • onCreateLoader() - 주어진 ID에 해당하는 로더를 생성 및 리턴합니다. 
  • onLoadFinished() - 로더의 데이터 로딩 작업이 끝났을 때 호출됩니다.
  • onLoaderReset() - 로더가 리셋될 때 현재의 데이터들을 무효화하기 위하여 호출됩니다.

위 메소드들은 이어지는 섹션에서 더 자세히 학습할 수 있습니다.


onCreateLoader

로더를 사용하기 위해 initLoader()를 호출하면, 인자로 넘긴 ID값에 해당하는 로더가 있는지 확인한 후, 해당 로더가 없으면 onCreateLoader() 콜백 메소드가 호출되며, 여기서 새로운 로더를 생성하여 리턴해야 합니다. 보통은 CursorLoader를 리턴하지만, Loader클래스를 상속받아 만든 서브클래스를 리턴할 수도 있습니다.

아래 예제에서 onCreateLoader()CursorLoader를 생성하여 리턴합니다. 여기서 CursorLoader 객체 생성시, 컨텐트 프로바이더에 질의할 때 필요한 몇가지 정보를 인자로 넘겨야 합니다:

  • uri - 가져올 데이터에 대한 URI. 데이터의 위치.
  • projection - 리턴될 컬럼(column) 목록. null을 넘기면 모든 컬럼이 리턴되지만 다소 비효율적입니다.
  • selection - 리턴될 row들의 필터링 정의. SQLWHERE 절에서 "WHERE" 단어만 제외된 구문입니다. null을 넘기면 모든 row가 리턴됩니다.
  • selectionArgs - selection에 실제값 대신 ?(물음표 표시)로 표시된 부분이 있다면, 그 부분들을 selectionArgs에 있는 값들로 치환하게 됩니다. (이것은, SQL 쿼리문과 조건에 들어가는 값들을 분리함으로써 DB의 부담을 줄이기 위함이며, 자세한 내용은 "SQL, 바인딩"으로 구글링하여 학습하시기 바랍니다.)
  • sortOrder - 리턴될 row들의 정렬 기준. SQLORDER BY 절에서 "ORDER BY"만 제외된 구문입니다. null을 넘기면 시스템의 기본 정렬 기준을 사용하는데, 보통은 기본 정렬 기준이 없습니다.

예제 코드:

 // If non-null, this is the current filter the user has provided.
String mCurFilter;
...
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
   
// This is called when a new Loader needs to be created.  This
   
// sample only has one Loader, so we don't care about the ID.
   
// First, pick the base URI to use depending on whether we are
   
// currently filtering.
   
Uri baseUri;
   
if (mCurFilter != null) {
        baseUri
= Uri.withAppendedPath(Contacts.CONTENT_FILTER_URI,
                 
Uri.encode(mCurFilter));
   
} else {
        baseUri
= Contacts.CONTENT_URI;
   
}

   
// Now create and return a CursorLoader that will take care of
   
// creating a Cursor for the data being displayed.
   
String select = "((" + Contacts.DISPLAY_NAME + " NOTNULL) AND ("
           
+ Contacts.HAS_PHONE_NUMBER + "=1) AND ("
           
+ Contacts.DISPLAY_NAME + " != '' ))";
   
return new CursorLoader(getActivity(), baseUri,
            CONTACTS_SUMMARY_PROJECTION
, select, null,
           
Contacts.DISPLAY_NAME + " COLLATE LOCALIZED ASC");
}


onLoadFinished

이 메소드는 로더의 데이터 로딩 작업이 끝났을 때 호출되며, 로더가 로딩한 가장 최근의 데이터들이 날아가기(release) 전에 호출됩니다. 여기서 예전 데이터들을 사용하는 부분들을 제거해야 하지만, 로더가 새 데이터들을 다루기 전까지는 따로 저장하고 있던 데이터들을 날리면 안됩니다.

로더는 앱이 예전 데이터를 더이상 사용하지 않는다는 것을 알게되면 그 데이터를 날립니다(release). 예를 들어, 데이터가 CursorLoader로부터 얻은 cursor객체라면, 개발자가 구현하는 부분에서 close()를 호출하지 않도록 합니다. cursor객체가 CursorAdapter 안에 있다면, swapCursor()를 호출해야 합니다. 그러면 swapCursor()가 결과를 리턴하기 전까지는 예전 커서가 닫히지(closed) 않습니다.

예제 코드:

// This is the Adapter being used to display the list's data.
SimpleCursorAdapter mAdapter;
...

public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
   
// Swap the new cursor in.  (The framework will take care of closing the
   
// old cursor once we return.)
    mAdapter
.swapCursor(data);
}

 

onLoaderReset

이 메소드는 로더가 리셋될 때 현재의 데이터들을 무효화하기 위하여 호출됩니다. 이 콜백 메소드에서는 사용하던 데이터들의 참조를 제거하여 그 데이터들을 날리는 작업을 구현합니다.

아래 예제에서는 swapCursor()에 null을 넘기고 있습니다:

// This is the Adapter being used to display the list's data.
SimpleCursorAdapter mAdapter;
...

public void onLoaderReset(Loader<Cursor> loader) {
   
// This is called when the last Cursor provided to onLoadFinished()
   
// above is about to be closed.  We need to make sure we are no
   
// longer using it.
    mAdapter
.swapCursor(null);
}


예제


본 예제에서는, 주소록 컨텐트 프로바이더에 질의한 결과들을 담고 있는 리스트뷰를 화면에 보여주는 프레그먼트의 전체적인 구현내용을 보여줍니다. 여기서는 프로바이더에 질의하기 위하여 CursorLoader를 사용합니다. 

내 앱에서 사용자의 주소록 정보를 사용하기 위해서는, 매니페스트 파일에 READ_CONTACTS 퍼미션이 선언되어 있어야 합니다.

public static class CursorLoaderListFragment extends ListFragment
       
implements OnQueryTextListener, LoaderManager.LoaderCallbacks<Cursor> {

   
// This is the Adapter being used to display the list's data.
   
SimpleCursorAdapter mAdapter;

   
// If non-null, this is the current filter the user has provided.
   
String mCurFilter;

   
@Override public void onActivityCreated(Bundle savedInstanceState) {
       
super.onActivityCreated(savedInstanceState);

       
// Give some text to display if there is no data.  In a real
       
// application this would come from a resource.
        setEmptyText
("No phone numbers");

       
// We have a menu item to show in action bar.
        setHasOptionsMenu
(true);

       
// Create an empty adapter we will use to display the loaded data.
        mAdapter
= new SimpleCursorAdapter(getActivity(),
                android
.R.layout.simple_list_item_2, null,
               
new String[] { Contacts.DISPLAY_NAME, Contacts.CONTACT_STATUS },
               
new int[] { android.R.id.text1, android.R.id.text2 }, 0);
        setListAdapter
(mAdapter);

       
// Prepare the loader.  Either re-connect with an existing one,
       
// or start a new one.
        getLoaderManager
().initLoader(0, null, this);
   
}

   
@Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
       
// Place an action bar item for searching.
       
MenuItem item = menu.add("Search");
        item
.setIcon(android.R.drawable.ic_menu_search);
        item
.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
       
SearchView sv = new SearchView(getActivity());
        sv
.setOnQueryTextListener(this);
        item
.setActionView(sv);
   
}

   
public boolean onQueryTextChange(String newText) {
       
// Called when the action bar search text has changed.  Update
       
// the search filter, and restart the loader to do a new query
       
// with this filter.
        mCurFilter
= !TextUtils.isEmpty(newText) ? newText : null;
        getLoaderManager
().restartLoader(0, null, this);
       
return true;
   
}

   
@Override public boolean onQueryTextSubmit(String query) {
       
// Don't care about this.
       
return true;
   
}

   
@Override public void onListItemClick(ListView l, View v, int position, long id) {
       
// Insert desired behavior here.
       
Log.i("FragmentComplexList", "Item clicked: " + id);
   
}

   
// These are the Contacts rows that we will retrieve.
   
static final String[] CONTACTS_SUMMARY_PROJECTION = new String[] {
       
Contacts._ID,
       
Contacts.DISPLAY_NAME,
       
Contacts.CONTACT_STATUS,
       
Contacts.CONTACT_PRESENCE,
       
Contacts.PHOTO_ID,
       
Contacts.LOOKUP_KEY,
   
};
   
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
       
// This is called when a new Loader needs to be created.  This
       
// sample only has one Loader, so we don't care about the ID.
       
// First, pick the base URI to use depending on whether we are
       
// currently filtering.
       
Uri baseUri;
       
if (mCurFilter != null) {
            baseUri
= Uri.withAppendedPath(Contacts.CONTENT_FILTER_URI,
                   
Uri.encode(mCurFilter));
       
} else {
            baseUri
= Contacts.CONTENT_URI;
       
}

       
// Now create and return a CursorLoader that will take care of
       
// creating a Cursor for the data being displayed.
       
String select = "((" + Contacts.DISPLAY_NAME + " NOTNULL) AND ("
               
+ Contacts.HAS_PHONE_NUMBER + "=1) AND ("
               
+ Contacts.DISPLAY_NAME + " != '' ))";
       
return new CursorLoader(getActivity(), baseUri,
                CONTACTS_SUMMARY_PROJECTION
, select, null,
               
Contacts.DISPLAY_NAME + " COLLATE LOCALIZED ASC");
   
}

   
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
       
// Swap the new cursor in.  (The framework will take care of closing the
       
// old cursor once we return.)
        mAdapter
.swapCursor(data);
   
}

   
public void onLoaderReset(Loader<Cursor> loader) {
       
// This is called when the last Cursor provided to onLoadFinished()
       
// above is about to be closed.  We need to make sure we are no
       
// longer using it.
        mAdapter
.swapCursor(null);
   
}
}


다른 예제들

ApiDemos 샘플앱에 로더를 사용하는 방법에 대한 몇가지 다른 예제들이 있습니다:

  • LoaderCursor - 위의 예제 코드의 완성 버전.
  • LoaderThrottle - 데이터가 변함에 따라 발생하는 컨텐트 프로바이더로의 질의의 수를 어떻게 효율적으로 줄일 수 있는지 보여주는 예제.

안드로이드 SDK의 디렉토리 아래에 있는 samples 디렉토리에 각 OS 버전별 샘플앱들이 있습니다.


Posted by 개발자 김태우
,

(원문: http://developer.android.com/guide/components/fragments.html)

(위치: Develop > API Guides > App Components > Activities 
> Fragments)

(전체 번역 글 목록으로 이동하기)


프레그먼트 (Fragments)


프레그먼트는 액티비티가 담당하는 전체 사용자 인터페이스 중 독립적인 일부분을 맡습니다. 여러개의 화면이 하나의 액티비티에 표현되도록 하기 위해, 하나의 액티비티는 여러개의 프레그먼트를 가질 수 있습니다. 프레그먼트는 액티비티처럼 생명주기를 갖으며 사용자와 상호작용할 수 있습니다. 즉, 액티비티의 한 모듈이라고 볼 수 있습니다. 프레그먼트는 액티비티가 실행중일 때 액티비티에 추가 또는 제거될 수 있으며, 다른 액티비티들에 재사용될 수도 있습니다.

프레그먼트는 액티비티 안에 들어가며, 프레그먼트의 생명주기는 그 액티비티의 생명주기에 직접적으로 영향을 받습니다. 예를 들어, 액티비티가 paused상태가 되면 그 안에 있는 모든 프레그먼트들도 paused상태가 되며, 액티비티가 종료되면 프레그먼트들도 종료됩니다. 하지만 액티비티가 resumed상태(액티비티 생명주기 참고)로 있는 동안에, 그 안에 있는 각각의 프레그먼트는 독립적으로 추가되거나 제거될 수 있습니다. 액티비티에 추가될 때, 액티비티에 의해 관리되는 백스택에 추가될 수도 있습니다. 프레그먼트에서 다른 프레그먼트를 실행했다가, 뒤로버튼을 누르면 다시 이전 프레그먼트가 보이게 하고 싶다면, 백스택을 사용합니다.

프레그먼트가 액티비티 레이아웃의 일부가 되길 원한다면, 액티비티의 뷰 계층구조 안에서 ViewGroup 안에 해당 프레그먼트가 정의한 뷰 레이아웃이 들어가게 됩니다. 액티비티의 레이아웃 xml파일 안에 <fragment>요소로 추가할 수도 있고, 소스코드에서 동적으로 프레그먼트 객체를 생성하여 액티비티의 레이아웃에 있는 ViewGroup 객체 안에 추가할 수도 있습니다. 하지만 프레그먼트가 반드시 액티비티 레이아웃의 일부가 되어야 하는 것은 아닙니다. 프레그먼트는 눈에 보이는 UI 없이 액티비티가 필요로 하는 작업을 수행할 수도 있습니다. 

본 문서에서는 프레그먼트를 이용하여 어떻게 앱을 개발할 수 있는지에 대해 설명할 것입니다. 여기에는, 프레그먼트를 액티비티의 백스택에 추가할 때 상태를 어떻게 유지할 수 있는지, UI에서 발생한 이벤트를 어떻게 액티비티 및 다른 프레그먼트에 전달할 수 있는지, 액티비티의 액션바와 어떻게 상호작용할 수 있는지 등이 포함됩니다.


디자인 철학


프레그먼트는 태블릿과 같은 큰 화면 디바이스에서 동적이고 유연한 UI를 지원하기 위해 안드로이드 3.0(API레벨 11, 허니컴)부터 추가되었습니다. 태블릿의 화면은 스마트폰보다 훨씬 크기 때문에, UI 컴포넌트가 들어갈 공간이 상대적으로 더 많습니다. 따라서 스마트폰용으로 개발된 앱이 태블릿을 적절히 지원하기 위해서는 레이아웃의 수정이 필요한데, 프레그먼트를 이용하면 레이아웃의 수정 없이 액티비티의 전체 화면을 분할하여 프레그먼트들을 배치할 수 있는 것입니다. 

뉴스앱을 예로 들어 보겠습니다. 기사 목록을 보여주는 프레그먼트가 왼쪽에 있고, 기사 내용을 보여주는 프레그먼트가 오른쪽에 있다면, 화면의 전환 없이 한 화면에서 기사를 선택하여 읽을 수 있을 것입니다. 프레그먼트들은 각각 생명주기 콜백 메소드들을 구현할 수 있고, 각각이 따로 사용자와 상호작용할 수 있습니다. 따라서 기사 목록과 기사 내용을 각각의 화면에 보여주는 대신에 아래 그림1의 태블릿 레이아웃에서 보이는 바와 같이 한 화면에 보여줄 수 있는 것입니다.

프레그먼트는 모듈화가 되어 액티비티에서 재사용 가능하도록 개발되어야 합니다. 프레그먼트는 독립적인 존재로서 레이아웃을 정의하고, 생명주기를 갖기 때문에, 여러개의 액티비티에 재사용될 수 있습니다. 따라서, 재사용 가능하도록 설계되어야 하고, 다른 프레그먼트를 직접 참조하는 등의 의존성을 없애야 합니다. 이것은 다양한 크기의 스크린에서 그에 맞게 프레그먼트들을 조합할 수 있도록 하기 위해 중요합니다. 태블릿과 스마트폰을 동시에 지원하는 앱을 설계할 때, 각각의 레이아웃에 프레그먼트들을 적절히 배치할 수 있습니다. 예를 들어, 스마트폰에서는 태블릿과는 다르게 화면이 작기 때문에 두개의 프레그먼트를 한꺼번에 보여주기 어려울 것이며, 각각의 프레그먼트를 각각의 액티비티에 담아 별도의 화면에서 보여줄 수 있을 것입니다.

그림 1. 태블릿과 스마트폰에서, 프레그먼트로 정의된 2개의 UI모듈이 보여지는 예.


그림1을 보면
, 태블릿에서는 액티비티A에 두개의 프레그먼트를 모두 보여주고 있고, 스마트폰에서는 크기가 작기 때문에 액티비티A에서 프레그먼트A를, 액티비티B에서 프레그먼트B를 보여주고 있습니다. 프레그먼트를 재사용함으로써 태블릿과 스마트폰을 모두 지원하는 앱을 만들 수 있는 것입니다.

다양한 스크린을 지원하기 위해 프레그먼트들을 어떻게 설계하고 조합할 것인지에 대한 자세한 내용은 태블릿과 스마트폰 지원하기에서 학습하실 수 있습니다. 


프레그먼트 만들기


프레그먼트를 만들기 위해서는 Fragment 클래스나 이미 만들어진 Fragment의 서브클래스를 상속받아서, 생명주기 콜백 메소드나 그 외 필요한 기능들을 구현하면 됩니다. 프레그먼트의 콜백 메소드는 onCreate(), onStart(), onPause(), onStop() 등으로서 액티비티의 콜백 메소드들과 유사합니다. 그래서 액티비티 기반으로 이미 개발된 앱을 프레그먼트 기반으로 바꾸려고 한다면, 액티비티의 콜백 메소드에 구현된 내용들을 거의 그대로 프레그먼트의 콜백 메소드로 옮기기만 하면 됩니다.

보통 아래 몇가지 콜백 메소드들을 구현합니다.

onCreate()
프레그먼트가 생성될 때 호출됩니다. 여기서는 프레그먼트가 살아있는 동안 사용하게 될 중요한 구성요소들을 셋팅하며, 이는 프레그먼트가 paused나 stopped상태가 되었다가 다시 resumed상태가 될 때 유지가 되길 원하는 것들입니다.

onCreateView()
프레그먼트의 사용자 UI가 화면에 보여지는 첫번째 시점에 호출됩니다. 여기서는 화면에 보여줄 뷰, 즉 프레그먼트의 레이아웃을 생성하여 리턴해줘야 합니다. 만약 프레그먼트가 UI를 제공하지 않는다면, null을 리턴하면 됩니다.

onPause()
사용자가 해당 프레그먼트에서 벗어날 때 가장 먼저 호출되는 콜백 메소드입니다. 이후에 다시 resumed상태가 될 수도 있고, 아예 종료될 수도 있습니다. 따라서 여기서는 유지되길 원하는 값이 있다면 저장해 주는 일을 하도록 합니다.

대부분의 앱에서 프레그먼트를 만들 때, 위의 세가지 콜백 메소드를 구현하지만, 그 외에도 콜백 메소드가 몇가지 더 있습니다. 자세한 내용은 프레그먼트의 생명주기 다루기에서 학습하실 수 있습니다.

위에서도 언급했듯이 프레그먼트를 만들 때는 기본적으로 Fragment클래스를 상속받을 수 있지만, 이미 만들어진 서브클래스를 상속 받을 수도 있는데 그 몇가지를 소개합니다:

DialogFragment
현재 화면 위에 뜨는 다이얼로그를 구현한 프레그먼트입니다. 이것은 기존에 액티비티에서 AlertDialog를 이용해 다이얼로그를 띄우던 방식을 대체할 수 있습니다. 액티비티에 의해 관리되는 백스택에 들어가기 때문에, 뒤로 버튼을 눌렀을 때, 다이얼로그의 바닥에 보이는 이전 화면으로 돌아갈 수 있습니다.

ListFragment
ListActivity처럼 어댑터에 의해 관리되는 리스트를 보여주는 프레그먼트입니다. onListItemClick()과 같은 리스트뷰 관련 콜백 메소드들을 제공합니다.

PreferenceFragment
PreferenceActivity처럼 Preference객체를 보여주는 프레그먼트입니다. 앱의 "설정" 화면을 만들 때 사용하 수 있습니다.


사용자 인터페이스(UI) 추가하기

프레그먼트는 보통 액티비티의 사용자 인터페이스의 일부로 사용되고, 독립적인 레이아웃을 갖습니다. 

프레그먼트의 레이아웃을 구현하기 위해서는, 안드로이드 시스템이 프레그먼트의 레이아웃을 화면에 그리려고 할때 호출되는 onCreateView() 콜백 메소드를 구현하여, 여기서 프레그먼트 레이아웃의 루트가 되는 View 객체를 리턴해주면 됩니다. 

메모: 만약 ListFragment를 상속받아 프레그먼트를 만들었다면, 기본적으로 onCreateView()에서 ListView객체를 리턴하고 있기 때문에, onCreateView()를 구현하지 않아도 됩니다.

onCreateView()에서 레이아웃을 리턴하기 위해서, XML로 정의된 레이아웃 리소스로부터 뷰객체를 만들 수 있습니다. 이를 위하여 onCreateView()에서는 LayoutInflater객체를 매개변수로 제공해 줍니다.

아래 예제에서는 리소스에 있는 example_fragment.xml파일을 이용하여 레이이웃을 만드는 것을 보여줍니다.

public static class ExampleFragment extends Fragment {
   
@Override
   
public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             
Bundle savedInstanceState) {
       
// Inflate the layout for this fragment
       
return inflater.inflate(R.layout.example_fragment, container, false);
   
}
}

위 예제의 container 매개변수는, 프레그먼트의 레이아웃이 들어가게 되는 부모 ViewGroup객체(액티비티 레이이웃의 일부)를 가리킵니다. 그리고 savedInstanceState 매개변수는 프레그먼트가 resumed상태가 될 때 이전 상태를 복구하기 위해 제공되는 Bundle객체입니다. (자세한 내용은 프레그먼트 생명주기 다루기에서 학습하실 수 있습니다.)

inflate()메소드는 3개의 인자를 받습니다:

  • 만들고자 하는 레이아웃의 리소스 ID.
  • 프레그먼트 레이아웃의 부모 ViewGroup객체. 레이아웃을 자식으로 추가하거나, 레이아웃이 뭔지 미리 알려줌으로써 레이아웃에 LayoutParameter를 만들어 셋팅하기 위해 필요합니다.
  • 위의 두번째 매개변수로 받은 ViewGroup객체에 추가할지 여부(boolean). 위 예제에서는 상위 클래스의 onCreateView()에서, 리턴하는 뷰를 container에 추가하는 로직이 이미 구현되어 있기 때문에 이 값이 false입니다.

지금까지는 화면에 보여질 레이아웃을 갖는 프레그먼트를 어떻게 만드는지에 대해서 알아봤습니다. 다음으로 프레그먼트를 액티비티에 어떻게 추가하는지에 대해 알아보겠습니다.

레이아웃 생성: 위 예제에서 R.layout.example_fragment는 앱의 리소스로 저장된 example_fragment.xml파일을 가리킵니다. 레이아웃 xml파일을 만드는 방법에 대한 자세한 내용은 사용자 인터페이스에서 학습하실 수 있습니다. 


프레그먼트를 액티비티에 추가하기

프레그먼트는 액티비티의 뷰 계층구조의 일부로 추가되어, 액티비티 UI의 한 부분을 구성합니다. 액티비티 레이아웃에 추가되는 방법은 2가지가 있습니다.

● 액티비티의 레이아웃 파일에 프레그먼트 요소 선언하기

이 경우, 뷰그룹이나 뷰위젯을 추가할 때처럼 레이아웃 속성값을 지정할 수 있습니다. 아래 예제는 액티비티 레이아웃에 2개의 프레그먼트를 추가하는 것을 보여줍니다:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   
android:orientation="horizontal"
   
android:layout_width="match_parent"
   
android:layout_height="match_parent">
   
<fragment android:name="com.example.news.ArticleListFragment"
           
android:id="@+id/list"
           
android:layout_weight="1"
           
android:layout_width="0dp"
           
android:layout_height="match_parent" />
   
<fragment android:name="com.example.news.ArticleReaderFragment"
           
android:id="@+id/viewer"
           
android:layout_weight="2"
           
android:layout_width="0dp"
           
android:layout_height="match_parent" />
</LinearLayout>

위 예제에서 <fragment>의 android:name 속성에는 Fragment클래스의 패키지명 포함한 이름을 지정합니다.

시스템은 액티비티의 레이아웃을 생성할 때, 각 프레그먼트의 onCreateView()를 호출하여 리턴받은 뷰를 <fragment>요소 대신에 추가합니다.

메모: 각 프레그먼트는 유일한 식별자를 필요로 합니다. 이는 액티비티가 재시작될 때 시스템이 프레그먼트를 식별하기 위함이며, 내 액티비티에서 프레그먼트를 찾아 제거하거나 뭔가 작업을 수행하기 위해서이기도 합니다. 프레그먼트에 식별자를 제공하는 방법은 아래와 같이 3가지가 있습니다.

  • android:id에 유일한 ID를 지정합니다.
  • android:tag에 유일한 문자열을 지정합니다.
  • 위의 2가지를 모두 지정하지 않았다면, 시스템은 부모뷰의 ID를 사용합니다.

소스코드에서 ViewGroup객체에 프레그먼트 추가하기

액티비티가 실행되고 있는 동안, 프레그먼트를 액티비티의 레이아웃에 추가할 수 있습니다. 그러기 위해서는 프레그먼트가 들어갈 ViewGroup객체를 지정해야 합니다. 

프레그먼트를 액티비티에 추가하거나 제거하거나 다른 프레그먼트로 대체하거나 하려면, FragmentTransaction을 사용해야 합니다. FragmentTransaction객체는 액티비티에서 아래와 같이 얻을 수 있습니다.

FragmentManager fragmentManager = getFragmentManager()
FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();

그리고 나서 아래와 같이 add()메소드를 사용하여 프레그먼트를 추가할 수 있습니다. 

ExampleFragment fragment = new ExampleFragment();
fragmentTransaction
.add(R.id.fragment_container, fragment);
fragmentTransaction
.commit();

add()메소드의 첫번째 인자는 프레그먼트가 추가될 부모뷰(ViewGroup)의 리소스 ID이고, 두번째 인자는 추가할 프레그먼트입니다. 

FragmentTransaction객체에 뭔가 변화가 생겼을 때는, commit()메소드를 호출해야 그것이 실제로 반영됩니다.


UI 없이 프레그먼트 추가하기

위에서는, UI를 제공하는 프레그먼트를 어떻게 액티비티에 추가할 것인가를 학습했지만, UI 없이 액티비티에서 백그라운드 작업을 하기 위해 프레그먼트를 활용할 수도 있습니다.

UI 없는 프레그먼트를 추가하려면, add(Fragment, String) 메소드를 호출하면 됩니다. 여기서 두번째 인자인 String은 프레그먼트를 구분하기 위한 유일한 "태그"이며, 뷰의 리소스 ID는 인자로 들어가지 않습니다. 이 메소드를 호출함으로써 프레그먼트를 액티비티에 추가하기는 하지만, 뷰 레이아웃을 필요로 하지는 않기 때문에 프레그먼트의 onCreateView()가 호출되지 않습니다. 따라서 onCreateView() 콜백 메소드를 구현할 필요도 없습니다.

UI가 없는 프레그먼트를 추가할 때 태그를 반드시 넣어야만 하는 것은 아닙니다. 그리고 태그는 UI가 있는 프레그먼트에도 넣을 수 있습니다. 다만, UI가 없는 프레그먼트에서는 "태그"가 프레그먼트를 구분할 수 있는 유일한 방법이기 때문에 보통 태그를 넣습니다. 그리고 나서 프레그먼트를 찾을 때는 findFragmentByTag()메소드를 호출하면 됩니다.

프레그먼트로 백그라운드 작업을 하는 예는 FragmentRetainInstance.java 샘플에서 확인하실 수 있습니다.


프레그먼트 다루기 


액티비티에서 프레그먼트를 다루기 위해서는, FragmentManager객체가 필요하며, 이는 getFragmentManager()를 호출하여 얻을 수 있습니다.

FragmentManager객체로 할 수 있는 몇가지는 아래와 같습니다:

  • 액티비티에 있는 프레그먼트를 얻고자 할때는, findFragmentById()findFragmentByTag()를 사용합니다. findFragmentById()는 UI를 제공하는 프레그먼트의 경우 사용하고, findFragmentByTag()는 UI의 제공유무와 상관없이 사용할 수 있습니다.
  • 사용자가 뒤로버튼을 눌렀을 때와 같이 프레그먼트를 백스택에서 제거할 때는 popBackStack()을 호출합니다.
  • 백스택에 변화가 생기는 순간을 잡기 위해 리스너를 등록하는 메소드는 addOnBackStackChangedListener()입니다.

FragmentManager객체의 메소드들에 대한 더 자세한 내용은 FragmentManager 클래스 문서에서 학습하실 수 있습니다.

이전 섹션에서 학습한 바와 같이, 프레그먼트를 추가 및 제거하는 등의 작업을 할 때 필요한 FragmentTransactionFragmentManager에서 얻을 수 있습니다.


프레그먼트 트랜잭션 수행하기


액티비티에서 프레그먼트를 사용함으로써 얻을 수 있는 가장 큰 이점은, 하나의 액티비티 안에서 사용자와의 상호작용을 통해 프레그먼트들을 추가하거나 제거하거나 대체하거나 하는 등의 작업을 할 수 있는 것입니다. 이러한 액티비티의 변화를 트랜잭션이라 칭하며, FragmentTransaction의 API들을 호출함으로써 트랜잭션을 수행할 수 있습니다. 액티비티에 의해 관리되는 백스택에 해당 트랜잭션을 저장함으로써, 사용자가 뒤로버튼을 누르면 이전 액티비티가 보이게 하는 등의 처리를 할 수 있습니다. (이는 액티비티 백스택의 동작과 유사합니다.)

아래와 같은 방법으로 FragmentManager로부터 FragmentTransaction객체를 얻을 수 있습니다:

FragmentManager fragmentManager = getFragmentManager();
FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();

한번의 트랜잭션은, 동시에 처리되기 위한 변화들의 집합을 의미합니다. FragmentTransaction객체의 add(), remove(), replace()와 같은 메소드들을 호출함으로써 원하는 변화들을 지정할 수 있고, commit() 메소드를 호출함으로써 그 변화들의 집합을 액티비티에 반영할 수 있습니다.

프레그먼트의 백스택에 트랜잭션을 적용하기 위해서는, commit()을 호출하기 전에 addToBackStack()을 호출해야 합니다. 프레그먼트의 백스택은 액티비티에 의해 관리되며, 사용자가 뒤로버튼을 눌렀을 때 이전 프레그먼트가 보이도록 해주는 등의 작업을 위해 필요합니다.

아래 예제는 이전 프레그먼트를 다음 프레그먼트로 대체하면서, 백스택을 이용해 이전 상태로 돌아갈 수 있도록 하는 방법을 보여줍니다:

// Create new fragment and transaction
Fragment newFragment = new ExampleFragment();
FragmentTransaction transaction = getFragmentManager().beginTransaction();

// Replace whatever is in the fragment_container view with this fragment,
// and add the transaction to the back stack
transaction
.replace(R.id.fragment_container, newFragment);
transaction
.addToBackStack(null);

// Commit the transaction
transaction
.commit();

위 예제에서 newFragment는, R.id.fragment_container가 가리키는 레이아웃 안에 있는 프레그먼트를 대체합니다. 그리고 addToBackStack()을 호출함으로써 트랜잭션을 프레그먼트 백스택에 저장하여, 사용자가 뒤로버튼을 눌렀을 때 이전 프레그먼트가 다시 보일 수 있게 됩니다.

FragmentTransaction변화를 추가하는 순서는 별로 중요하지 않지만 아래 2가지는 지켜져야 합니다.

  • commit() 메소드가 제일 나중에 호출되어야 합니다.
  • 동일한 부모뷰 안에 2개 이상의 프레그먼트를 추가하는 경우에는, 추가하는 순서에 따라 먼저 추가한 프레그먼트가 뷰 계층구조에서 상위에 위치하게 됩니다.

만약 프레그먼트를 제거하는 트랜잭션을 수행할 때 addToBackStack()을 호출하지 않고 commit()을 호출한다면, 해당 프레그먼트는 종료되며(destroyed) 다시 이전상태로 돌아갈 수 없습니다. 반면에 addToBackStack()을 호출했다면, 프레그먼트는 stopped상태가 되며 사용자에 의해 이전상태로 돌아갈 때 다시 resumed상태가 됩니다.

팁: 프레그먼트 트랜잭션에 의해 프레그먼트가 바뀔 때 애니메이션을 적용하기 위해 setTransition() 메소드를 사용할 수 있습니다.

commit() 메소드 호출 직후 해당 트랜잭션이 바로 적용되는 것은 아닙니다. 안드로이드 시스템은 UI의 변화를 액티비티의 UI 쓰레드("메인" 쓰레드)에서 수행하며, commit() 호출시 해당 트랜잭션에 의한 UI 변화를 메인 쓰레드의 작업 큐(queue)에 추가하기 때문에, 이미 다른 작업들이 대기하고 있다면 그 작업들이 먼저 수행됩니다. 하지만, 현재 UI 쓰레드에서 executePendingTransactions()를 호출함으로써 commit() 호출 직후 해당 트랜잭션이 적용되도록 할 수도 있습니다. 그러나 현재 쓰레드가 다른 쓰레드들과 의존적 상관관계가 있지 않다면, executePendingTransactions()는 별로 필요하지 않을 것입니다. 다시 말해서, 메인 쓰레드의 작업큐에 다른 UI 쓰레드에 의해 추가된 "UI의 변화"가 있을 경우, 반드시 이보다 먼저 프레그먼트 트랜잭션을 반영해야 한다면 executePendingTransactions()를 수행하면 됩니다. 하지만 아마도 이러한 경우는 거의 없을 것입니다.

주의사항: commit() 메소드는 액티비티의 상태가 저장되는 시점(사용자가 액티비티를 벗어날 때) 이전에만 호출될 수 있습니다. 만약 그 이후에 호출하면 예외가 발생됩니다. 왜냐하면, commit() 메소드 호출 후의 액티비티 상태가 저장될 수 없기 때문이며, 이를 무시하고서라도 commit을 하고 싶다면, commitAllowingStateLoss()를 호출하면 됩니다.


액티비티와 소통하기


비록 프레그먼트가 액티비티에 의존적이지 않은 독립적인 존재이고 여러 액티비티에 재사용될 수 있는 존재이긴 하더라도, 그를 포함하고 있는 액티비티와 직접적으로 소통할 수 있습니다.

특히, 프레그먼트는 getActivity()를 호출하여 액티비티 객체를 얻을 수 있고 그것을 통해 아래와 같이 액티비티의 뷰를 얻을 수도 있습니다:

View listView = getActivity().findViewById(R.id.list);

이와 마찬가지로, 액티비티도 FragmentManagerfindFragmentById()findFragmentByTag() 메소드를 이용하여 아래와 같이 프레그먼트 객체를 얻을 수 있습니다:

ExampleFragment fragment = (ExampleFragment) getFragmentManager().findFragmentById(R.id.example_fragment);


액티비티에 이벤트 콜백 생성하기

프레그먼트가 액티비티와 이벤트를 공유해야 하는 경우 좋은 방법은, 이벤트를 받을 프레그먼트에 콜백 인터페이스를 정의하고 액티비티에서 그것을 구현하도록 하는 것입니다. 액티비티가 구현한 콜백 메소드가 호출되면, 다른 프레그먼트에 관련 정보를 공유할 수도 있습니다.

예를 들어, 뉴스앱의 액티비티에 두 개의 프레그먼트를 보여줘야 하는데 하나(프레그먼트 A)는 뉴스 목록을 보여주고 다른 하나(프레그먼트 B)는 뉴스 내용을 보여줘야 한다면, 프레그먼트 A에서 선택된 뉴스 항목을 액티비티에게 알려줘야 하고, 액티비티는 그것을 프레그먼트 B에게 공유하여 그 뉴스의 내용을 보여줘야 할 것입니다. 아래 예제에서는 FragmentAOnArticleSelectedListener를 정의하고 있습니다:

public static class FragmentA extends ListFragment {
   
...
   
// Container Activity must implement this interface
   
public interface OnArticleSelectedListener {
       
public void onArticleSelected(Uri articleUri);
   
}
   
...
}

그리고 FragmentA객체를 포함하고 있는 액티비티는 FragmentA로부터 전달받은 이벤트 정보를 FragmentB로 전달하기 위해 OnArticleSelectedListeneronArticleSelected()를 오버라이드하여 구현합니다. 그리고나서 FragmentAonAttach() 콜백 메소드를 구현해야 하는데, 여기서 매개변수로 넘겨받은 액티비티를 OnArticleSelectedListener로 타입캐스팅하여 멤버변수로 저장합니다:

public static class FragmentA extends ListFragment {
   
OnArticleSelectedListener mListener;
   
...
   
@Override
   
public void onAttach(Activity activity) {
       
super.onAttach(activity);
       
try {
            mListener
= (OnArticleSelectedListener) activity;
       
} catch (ClassCastException e) {
           
throw new ClassCastException(activity.toString() + " must implement OnArticleSelectedListener");
       
}
   
}
   
...
}

만약 액티비티가 OnArticleSelectedListener를 구현하고 있지 않다면 ClassCastException 예외가 발생될 것이며, 제대로 구현했다면 mListener 멤버변수에 해당 인터페이스에 대한 구현 객체가 저장되어 이벤트 발생시 콜백 메소드를 호출함으로써 액티비티로 이벤트 정보를 전달할 수 있을 것입니다. 아래 예제에서는, ListFragment를 상속받아 FragmentA를 구현하고, 사용자가 리스트 아이템을 클릭했을 때 호출되는 onListItemClick()을 구현하여, 여기서 액티비티에게 이벤트 정보를 전달하기 위해 mListeneronArticleSelected()를 호출합니다:

public static class FragmentA extends ListFragment {
   
OnArticleSelectedListener mListener;
   
...
   
@Override
   
public void onListItemClick(ListView l, View v, int position, long id) {
       
// Append the clicked item's row ID with the content provider Uri
       
Uri noteUri = ContentUris.withAppendedId(ArticleColumns.CONTENT_URI, id);
       
// Send the event and Uri to the host activity
        mListener
.onArticleSelected(noteUri);
   
}
   
...
}

위의 예제에서 onListItemClick()id 매개변수는, 리스트 정보를 앱의 컨텐트 프로바이더로부터 얻어오는 경우, 선택된 항목의 id 정보를 전달해줍니다. 

컨텐트 프로바이더를 사용하는 방법에 대한 더 자세한 내용은 컨텐트 프로바이더에 대하여에서 학습하실 수 있습니다.


액션바에 항목 추가하기

프레그먼트에서는 onCreateOptionsMenu() 콜백 메소드를 구현함으로써 액티비티의 옵션 메뉴에 들어갈 항목들을 지정할 수 있습니다(액션바의 옵션 메뉴 버튼을 클릭했을 때 보여지는 메뉴). 하지만 프레그먼트가 옵션 메뉴를 사용한다는 것을 시스템에게 알려주기 위해 프레그먼트의 onCreate()에서 setHasOptionsMenu()을 호출해야만, 사용자가 옵션 메뉴 버튼을 클릭했을 때 onCreateOptionsMenu() 콜백 메소드가 호출됩니다.

프레그먼트에 의해 추가된 옵션 메뉴들은 기존에 액티비티 또는 다른 프레그먼트에 의해 추가된 옵션 메뉴들 아래로 추가됩니다. 그리고 프레그먼트에서는 옵션 메뉴가 선택되었을 때 호출되는 onOptionsItemSelected() 콜백 메소드도 오버라이드하여 구현할 수 있습니다.

registerForContextMenu()를 호출함으로써 프레그먼트 내의 특정 뷰가 컨텍스트 메뉴를 제공하도록 할 수 있습니다(컨텍스트 메뉴는 뷰를 롱클릭했을 때 뜨는 리스트 다이얼로그입니다). 사용자가 컨텍스트 메뉴를 띄울 때, 프레그먼트의 onCreateContextMenu() 콜백 메소드가 호출됩니다. 그리고 사용자가 컨텍스트 메뉴의 항목을 선택할 때, onContextItemSelected()가 호출됩니다.

메모: 프레그먼트에서 옵션 메뉴나 컨텍스트 메뉴에서 항목 선택시 실행할 작업을 구현해 놨다 하더라도, 액티비티에서 구현한 작업이 먼저 실행됩니다. 선택된 메뉴 항목에 대한 작업을 액티비티에서 다루고 있지 않을 경우에, 해당 이벤트 정보가 프레그먼트의 콜백 메소드로 전달됩니다. 액티비티의 onOptionsItemSelected()onContextItemSelected()boolean을 리턴하도록 되어 있는데, 이 값이 true면 메뉴 항목에 대한 처리를 한 것이고, false면 하지 않은 것입니다.

메뉴와 관련하여 더 자세한 내용은 메뉴액션바 문서에서 학습하실 수 있습니다.


프레그먼트의 생명주기 다루기


프레그먼트의 생명주기를 관리하는 것은 액티비티의 생명주기를 관리하는 것과 매우 유사합니다. 액티비티처럼 프레그먼트도 3가지의 상태를 갖습니다.

Resumed
프레그먼트가 액티비티에서 보여지는 상태입니다.

Paused
다른 액티비티가 전면(foreground)에 나오며 포커스를 가져갔지만 (resumed상태가 되었지만), 일부가 투명하거나 전체화면을 덮지 않으면서 여전히 이전 액티비티가 보이는 상태가 된다면, 이전 액티비티에서 보여지고 있던 프레그먼트의 상태는 paused상태가 됩니다.

Stopped
프레그먼트가 액티비티에서 보여지지 않는 상태입니다. 액티비티가 stopped상태로 되었거나, 프레그먼트가 액티비티에서 제거되었으나 백스택에 추가된 상태입니다. stopped상태의 프레그먼트는 여전히 살아있으며 멤버 변수 및 상태정보들이 시스템에 의해 유지됩니다. 하지만 사용자에게 보여지지 않는 상태이며, 액티비티가 종료되면 같이 종료됩니다.

액티비티에서처럼, 프레그먼트도 Bundle객체를 이용하여 상태를 저장 및 복구할 수 있습니다. 액티비티의 프로세스가 종료되었다가 다시 생성될 때 프로세스의 상태를 복구할 수 있다는 것입니다. 프레그먼트의 onSaveInstanceState()을 구현함으로써 상태를 저장할 수 있고, onCreate()onCreateView()onActivityCreated()에서 상태를 복구할 수 있습니다. 상태를 저장 및 복구하는 방법에 대해서는 액티비티 상태 저장하기에서 학습하실 수 있습니다.

액티비티 생명주기와 프레그먼트 생명주기의 가장 큰 차이점은 각각의 백스택에 저장되는 방식입니다. 액티비티는 stopped상태로 될 때 시스템에 의해 관리되는 액티비티 백스택에 위치하게 되며, 사용자가 뒤로버튼을 눌렀을 때 이전 액티비티가 재시작됩니다(자세한 내용은 태스크와 백스택에서 학습하실 수 있습니다). 하지만, 프레그먼트는 액티비티로부터 제거되는 트랜잭션이 수행되는 중에 addToBackStack()이 호출되어야만, 액티비티에 의해 관리되는 백스택에 저장되어 이전 프레그먼트를 재실행할 수 있습니다.

이외에, 프레그먼트의 생명주기를 관리하는 것은 액티비티의 생명주기를 관리하는 것과 매우 유사합니다. 따라서, 액티비티의 생명주기 관리하기에서 학습한 내용들을 프레그먼트에도 유사하게 적용할 수 있습니다. 하지만, 액티비티의 생명주기 흐름이 프레그먼트의 생명주기 흐름에 어떠한 영향을 끼치는지에 대해서는 충분히 이해할 필요가 있습니다.

주의사항: 프레그먼트에서 Context객체를 얻을때는 getActivity()를 사용할 수 있는데, 프레그먼트가 액티비티에 포함되어 있을때만(attached) 사용할 수 있다는 점에 주의해야 합니다. 액티비티가 아직 포함되지 않았거나, 프레그먼트가 액티비티로부터 제거된 후에 getActivity()를 호출하면 null이 리턴됩니다.


그림 3. 액티비티의 생명주기가 프레그먼트의 생명주기에 미치는 영향


액티비티의 생명주기와 연계하여 구현하기


액티비티의 생명주기는 그 안에 있는 프레그먼트들의 생명주기에 직접적인 영향을 줍니다. 그래서 프레그먼트의 생명주기 콜백 메소드는 액티비티의 생명주기 콜백 메소드와 매우 유사합니다. 예를 들어, 액티비티의 onPause()가 호출될 때, 그 액티비티 안에 있는 프레그먼트들의 onPause()들이 호출됩니다.

하지만 프레그먼트는 UI를 생성하거나 제거하는 것과 관련하여 액티비티와의 상호작용을 위해 몇가지 액티비티의 생명주기에는 없는 몇가지 콜백 메소드들을 갖습니다:

onAttach()
프레그먼트가 액티비티 안에 소속될 때 호출됩니다(매개변수로 액티비티 객체를 받습니다).

onCreateView()
프레그먼트의 뷰를 생성하기 위해 호출됩니다.

onActivityCreated()
액티비티의 onCreate()가 호출된 직후에 호출됩니다.

onDestroyView()
프레그먼트의 뷰가 제거될 때 호출됩니다.

onDetach()
프레그먼트가 액티비티로부터 떨어져 나올 때 호출됩니다.

위의 그림3은 액티비티의 생명주기에 영향을 받는 프레그먼트의 생명주기를 보여줍니다. 여기서 액티비티의 각 상태에 따라 프레그먼트의 어떤 콜백 메소드들이 호출되는지를 볼 수 있습니다. 예를 들어, 액티비티의 onCreate() 콜백 메소드가 호출되어 created상태에 있다면, 프레그먼트는 onActivityCreated() 콜백 메소드까지만 호출이 됩니다. 

일단 액티비티가 resumed상태가 되기만 하면, 동적으로 생성되는 프레그먼트들이 액티비티에 자유롭게 추가 또는 제거될 수 있습니다. 다시 말해서, 액티비티가 resumed상태에 있는 동안에만 프레그먼트의 생명주기가 독립적으로 변할 수 있다는 것입니다.

하지만 액티비티가 resumed상태에서 벗어나면, 프레그먼트는 다시 액티비티에 의해 액티비티 생명주기의 영향권으로 들어가게 됩니다. 


예제


본 문서에서 학습한 모든 것들을 포함하는 예제 코드를 소개합니다. 여기서는 2개의 프레그먼트로 구성된 하나의 액티비티를 구현하는 과정을 볼 수 있습니다. 그 2개의 프레그먼트 중 하나는 셰익스피어의 작품 목록을 보여주고, 나머지 하나는 선택된 작품의 요약된 내용을 보여줍니다. 본 예제에서는 화면의 가로 또는 세로 모드에 대하여 각 프레그먼트가 대응한 방법들도 볼 수 있습니다.

메인 액티비티는 아래 예제에서 보이는 바와 같이 일반적인 방식으로 레이아웃을 적용합니다:

@Override
protected void onCreate(Bundle savedInstanceState) {
   
super.onCreate(savedInstanceState);

    setContentView
(R.layout.fragment_layout);
}

적용된 레이아웃인 fragment_layout.xml은 아래와 같습니다:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   
android:orientation="horizontal"
   
android:layout_width="match_parent" android:layout_height="match_parent">

   
<fragment class="com.example.android.apis.app.FragmentLayout$TitlesFragment"
           
android:id="@+id/titles" android:layout_weight="1"
           
android:layout_width="0px" android:layout_height="match_parent" />

   
<FrameLayout android:id="@+id/details" android:layout_weight="1"
           
android:layout_width="0px" android:layout_height="match_parent"
           
android:background="?android:attr/detailsElementBackground" />

</LinearLayout>

위의 레이아웃을 사용하면, 액티비티가 레이아웃을 로드할 때, 시스템은 작품 목록을 보여주는 TitlesFragment를 생성하고, 작품 내용이 들어갈 영역인 FrameLayout을 내용 없이 영역만 잡도록 생성합니다. 그리고 사용자가 작품 목록에서 작품을 선택하면 FrameLayout 안에 해당 프레그먼트가 채워질 것입니다.

하지만, 좌우로 2개의 프레그먼트를 모두 보여주는 것은 화면이 세로 모드일 때 상당히 부자연스러울 것입니다. 그래서 위의 레이아웃은 가로 모드에서만 적용되도록 하기 위해 res/layout-land/fragment_layout.xml로 저장합니다.

그에 따라 세로 모드의 레이아웃은 아래 예제와 같이 작성하여 res/layout/fragment_layout.xml로 저장하면 됩니다:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
   
android:layout_width="match_parent" android:layout_height="match_parent">
   
<fragment class="com.example.android.apis.app.FragmentLayout$TitlesFragment"
           
android:id="@+id/titles"
           
android:layout_width="match_parent" android:layout_height="match_parent" />
</FrameLayout>

위의 레이아웃은 TitlesFragment만 포함하고 있습니다. 이것은, 디바이스가 세로 모드일 때 작품 목록만 화면에 보여준다는 것을 의미합니다. 그리고 이 목록에서 사용자가 작품을 선택하면, 작품 내용을 보여주기 위해 프레그먼트를 생성하는 대신에 새 액티비티를 실행할 것입니다.

다음으로, 프레그먼트 클래스들이 어떻게 구현되어 있는지를 살펴볼 것입니다. 첫번째로 TitlesFragment는 셰익스피어 작품의 목록을 보여주는 프레그먼트로서, 리스트뷰의 역할을 수행하기 위해 ListFragment를 상속받습니다.

아래 코드를 살펴보면 사용자가 리스트의 항목을 클릭했을 때 상황에 따라 다음 동작이 2갈래로 분기되는 것을 알 수 있습니다. 화면에 2개의 레이아웃이 보여지고 있다면(FrameLayout이 있다면) 현재 액티비티에서 작품 내용도 보여주기 위해 프레그먼트를 생성하여 FrameLayout에 추가할 것이며, 그렇지 않다면(FrameLayout이 없다면) 새 액티비티를 실행하여 작품 내용을 보여주는 프레그먼트를 그 액티비티에 추가할 것입니다.

public static class TitlesFragment extends ListFragment {
   
boolean mDualPane;
   
int mCurCheckPosition = 0;

   
@Override
   
public void onActivityCreated(Bundle savedInstanceState) {
       
super.onActivityCreated(savedInstanceState);

       
// Populate list with our static array of titles.
        setListAdapter
(new ArrayAdapter<String>(getActivity(),
                android
.R.layout.simple_list_item_activated_1, Shakespeare.TITLES));

       
// Check to see if we have a frame in which to embed the details
       
// fragment directly in the containing UI.
       
View detailsFrame = getActivity().findViewById(R.id.details);
        mDualPane
= detailsFrame != null && detailsFrame.getVisibility() == View.VISIBLE;

       
if (savedInstanceState != null) {
           
// Restore last state for checked position.
            mCurCheckPosition
= savedInstanceState.getInt("curChoice", 0);
       
}

       
if (mDualPane) {
           
// In dual-pane mode, the list view highlights the selected item.
            getListView
().setChoiceMode(ListView.CHOICE_MODE_SINGLE);
           
// Make sure our UI is in the correct state.
            showDetails
(mCurCheckPosition);
       
}
   
}

   
@Override
   
public void onSaveInstanceState(Bundle outState) {
       
super.onSaveInstanceState(outState);
        outState
.putInt("curChoice", mCurCheckPosition);
   
}

   
@Override
   
public void onListItemClick(ListView l, View v, int position, long id) {
        showDetails
(position);
   
}

   
/**
     * Helper function to show the details of a selected item, either by
     * displaying a fragment in-place in the current UI, or starting a
     * whole new activity in which it is displayed.
     */

   
void showDetails(int index) {
        mCurCheckPosition
= index;

       
if (mDualPane) {
           
// We can display everything in-place with fragments, so update
           
// the list to highlight the selected item and show the data.
            getListView
().setItemChecked(index, true);

           
// Check what fragment is currently shown, replace if needed.
           
DetailsFragment details = (DetailsFragment)
                    getFragmentManager
().findFragmentById(R.id.details);
           
if (details == null || details.getShownIndex() != index) {
               
// Make new fragment to show this selection.
                details
= DetailsFragment.newInstance(index);

               
// Execute a transaction, replacing any existing fragment
               
// with this one inside the frame.
               
FragmentTransaction ft = getFragmentManager().beginTransaction();
               
if (index == 0) {
                    ft
.replace(R.id.details, details);
               
} else {
                    ft
.replace(R.id.a_item, details);
               
}
                ft
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
                ft
.commit();
           
}

       
} else {
           
// Otherwise we need to launch a new activity to display
           
// the dialog fragment with selected text.
           
Intent intent = new Intent();
            intent
.setClass(getActivity(), DetailsActivity.class);
            intent
.putExtra("index", index);
            startActivity
(intent);
       
}
   
}
}

두번째로 DetailsFragmentTitlesFragment에서 선택된 작품의 요약된 내용을 보여주는 프레그먼트입니다:

public static class DetailsFragment extends Fragment {
   
/**
     * Create a new instance of DetailsFragment, initialized to
     * show the text at 'index'.
     */

   
public static DetailsFragment newInstance(int index) {
       
DetailsFragment f = new DetailsFragment();

       
// Supply index input as an argument.
       
Bundle args = new Bundle();
        args
.putInt("index", index);
        f
.setArguments(args);

       
return f;
   
}

   
public int getShownIndex() {
       
return getArguments().getInt("index", 0);
   
}

   
@Override
   
public View onCreateView(LayoutInflater inflater, ViewGroup container,
           
Bundle savedInstanceState) {
       
if (container == null) {
           
// We have different layouts, and in one of them this
           
// fragment's containing frame doesn't exist.  The fragment
           
// may still be created from its saved state, but there is
           
// no reason to try to create its view hierarchy because it
           
// won't be displayed.  Note this is not needed -- we could
           
// just run the code below, where we would create and return
           
// the view hierarchy; it would just never be used.
           
return null;
       
}

       
ScrollView scroller = new ScrollView(getActivity());
       
TextView text = new TextView(getActivity());
       
int padding = (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
               
4, getActivity().getResources().getDisplayMetrics());
        text
.setPadding(padding, padding, padding, padding);
        scroller
.addView(text);
        text
.setText(Shakespeare.DIALOGUE[getShownIndex()]);
       
return scroller;
   
}
}

TitlesFragment 코드를 다시 살펴보면, 사용자가 리스트의 항목을 클릭했을 때, (DetailsFragment가 들어갈 자리인) R.id.details가 가리키는 뷰가 없다면 DetailsActivity를 실행하라고 되어 있습니다.

아래 코드가 DetailsActivity이며, 여기서는 단순히 DetailsFragment를 포함하고 있으며 화면이 세로 모드일 때 작품의 요약된 내용을 보여주도록 되어 있습니다:

public static class DetailsActivity extends Activity {

   
@Override
   
protected void onCreate(Bundle savedInstanceState) {
       
super.onCreate(savedInstanceState);

       
if (getResources().getConfiguration().orientation
               
== Configuration.ORIENTATION_LANDSCAPE) {
           
// If the screen is now in landscape mode, we can show the
           
// dialog in-line with the list so we don't need this activity.
            finish
();
           
return;
       
}

       
if (savedInstanceState == null) {
           
// During initial setup, plug in the details fragment.
           
DetailsFragment details = new DetailsFragment();
            details
.setArguments(getIntent().getExtras());
            getFragmentManager
().beginTransaction().add(android.R.id.content, details).commit();
       
}
   
}
}

위의 액티비티에서 화면이 가로 모드이면(Configuration.ORIENTATION_LANDSCAPE) 액티비티를 종료하는 부분에 주목하시기 바랍니다. 화면이 가로 모드이면 메인 액티비티에서 TitlesFragment 옆에 DetailsFragment를 보여주도록 되어 있기 때문에 DetailsActivity는 종료시키는 것입니다. 메인 액티비티를 보여줄 때 이미 가로 모드였다면 DetailsActivity를 실행할 일도 없었을 것입니다. 이러한 경우는 메인 액티비티를 보여줄 때는 세로 모드였다가, 항목을 선택하여 DetailsActivity를 실행하는 과정에서 onCreate()가 호출되기 전에 화면이 가로 모드로 바뀌는 경우라고 볼 수 있습니다.

프레그먼트와 관련하여 더 많은 샘플 코드를 확인하기 위해서는, SDK에 포함되어 있는 샘플 중 ApiDemos 를 참고하시기 바랍니다. 


Posted by 개발자 김태우
,