프로그래밍/안드로이드

안드로이드 - Android AppWidget, RemoteViews and CheckBox

가카리 2013. 12. 31. 17:54
반응형

 

이전 포스트에서 안드로이드 AppWidget 전반에 걸쳐서 간단하게 이야기 해보았습니다. 하지만 AppWidget 이 대충 어떤식으로 돌아가는지에 관한 뜬구름 잡는 이야기가 되고 말았습니다.
이번에는 AppWidget 에서 GUI 를 그릴 때 사용되는 RemoteViews 객체를 중심으로, 실제 AppWidget 을 구현할 때 알아두어야 할 몇 가지 점들에 관해 이야기해 봅니다.

AppWidget 과 RemoteViews

앞선 포스트에서 이야기 했듯이, AppWidget 은 특정 건물에 세들어 가는 세입자라고 할 수 있습니다. 전세로 들어간 아파트에는 벽에 시계를 걸기 위하여 못 하나 새로 치는 것도 주인 눈치가 보이는 만큼, AppWidget 이 화면을 구성하는데도 여러가지 제약 조건이 따르게 됩니다.

무엇보다도 AppWidget 은 실재 어플리케이션과 서로 다른 프로세스에서 동작하는 만큼, 직접적으로 화면상에 그림을 그릴 수 없습니다. 자신이 원하는 형태로 화면을 그리도록 AppWidgetService 를 통해 AppWidget Host 에게 부탁해야합니다. 그리고 이 때 사용되는 클래스가 바로 RemoteViews 입니다.

 

 

 

<RemoteViews 는 실제 View 를 생성하기 위한 설계도 입니다.>

RemoteViews 의 가장 큰 특징은 RemoteViews 는 실재 View 가 아니라는 점 입니다. (예상과는 달리 View 를 상속받은 클래스도 아니랍니다. 자바의 최상의 클래스 Object 만을 상속받고 있지요.) RemoteViews 는 단지, View 를 만들기 위한 설계도 라고 이해하시면 쉽습니다.

그런데 어째서 RemoteViews 가 꼭 필요한 걸까요? AppWidget 의 첫 화면을 그리는데는 사실 RemoteViews 가 필요하지 않습니다. 첫 화면을 위한 Layout 정보가 'initialLayout' 이라는 속성 값으로 XML 파일에 정의되어 있기 때문입니다. 하지만 중간에 화면을 새롭게 갱신할 때라던가, XML 상으로는 정의할 수 없는 (예를 들어 특정 버튼이 눌릴 때 정해진 일을 수행한다든지...) 일을 수행하도록 하고 싶을 때는 어떻게 하면 좋을까요?

AppWidget 이 실재 자신이 사용하고 싶은 View 를 만들어서 특정 어플리케이션에게 넘겨줄 수 있으면 좋겠지만, View 는 자신이 생성된 스레드에서만 조작이 가능하다는 점, 그리고 AppWidget 은 하나의 BroadcastReceiver 에 불과하기 때문에, onReceive() API 가 종료되는 순간에 소멸되고 말아서 자신이 생성한 View 를 오래 간직할 수 없다는 한계를 갖고 있습니다. 따라서, 그 대안으로 자신이 직접 필요한 View 를 만드는 대신, 만들고 싶은 View 의 관한 설계도를 작성한 후, 그 설계도를 RemoteViews 라는 형식으로 전달하게 됩니다.

 


<모든 화면 갱신은 RemoteViews 라는 설계도를 전달 받은 AppWidgetHost 가 수행합니다.>

이러한 과정은 AppWidget 이 화면을 갱신하고자 할 때마다 반복적으로 이루어집니다. 즉, 그림을 변경하고 싶을 때는 변경할 부분에 관한 정보만을 보내는 것이 아니라 매번 새롭게 설계도를 작성한 후, 해당 설계도를 통채로 넘기는 방식으로 작동합니다. (AppWidget의 생명 주기상 어쩔 수 없는 선택이지요.) 하지만, 비록 설계도는 새롭게 넘겨지더라도, 해당 설계도를 그리는데 사용한 Layout 이 동일한 이상, AppWidgetHost 쪽에서 자신이 한 번 그려둔 View 를 재활용하기 때문에, 매번 새롭게 View 가 그려질까 걱정하실 필요는 없습니다.

이 RemoteViews 는 지원되는 View 종류가 제한적입니다. '@RemoteView' 라는 어노테이션이 붙은 녀석들만 지원하고 있는데, 그 목록은 아래와 같습니다.
지원되는 View 종류가 제한되는 것은 LayoutInflater 서비스의 Filter API 를 이용하기 때문인데, 시스템적으로 어떤 한계점 때문에 이런 제한이 필요한 것인지는 사실 잘 모르겠네요. 그저 미루어 짐작하기로는, 하나 이상의 App Widget 이 존재할 수 있고, 각각의 App Widget 은 모두 동일하게 화면상에 표시되도록 하기 위해(꼭 그래야 하는지는 의문이지만...) 사용자 입력에 따른 해당 View 의 특정 상태정보가 View 내부적으로 저장하는 녀석들이 제외된 것이 아닐까 예상만 하고 있습니다. 예를 들어서, ListView 나 CheckBox 같은 녀석들 (ListView 의 스크롤 위치나 CheckBox 의 Check/UnCheck 여부....) 말이지요. 만일 CheckBox 를 지원한다면, 어떤 위젯은 Checked 상태로 다른 위젯은 UnChecked 상태로 존재할 수도 있을테니까요... 진실은 저 너머에 있겠습니다만. (정확한 이유를 알고 계신 분이 있으면 관련 내용을 공유해주시면 정말 감사하겠습니다^^)

AppWidget 과 CheckBox

하여간 그래서, AppWidget 은 RemoteViews 라는 설계도를 통해 자신의 화면을 그리고, 여러가지 제한을 갖게 됩니다. 하지만, RemoteViews 에서 지원하지 않는 형식의 View 를 사용하고 싶을 때가 종종 발생합니다. 특히나 저는 CheckBox 를 사용해서 사용자가 스위치를 한번 터치하면 On 상태로 다시 한번 터치하면 Off 상태로 유지되는 위젯을 만들려고 했습니다만... (App Widget 관련 모든 포스트의 원흉) 생각보다 쉽지 않더군요.

CheckBox 가 지원되지 않는 이상, Button 을 이용해서 CheckBox 를 구현하고자 했습니다. 이를 위해 필요한 작업은 두 가지 입니다. 우선 첫번째, 특정 Button 이 눌렸을 경우, 해당 이벤트를 AppWidgetProvider 가 수신할 수 있어야합니다. 두 번째, AppWidgetProvider 는 내부적으로 특정 Button 이 선택된 상태인지 아닌지에 관한 정보를 유지하고 있어야 합니다. 즉, 버튼 클릭 이벤트가 발생하면, 자신이 저장하고 있는 버튼의 상태 정보를 바탕으로 새롭게 RemoteViews 를 작성해서 화면 갱신을 요청하면 되는 것 이지요.

우선 첫 번째, Button이 눌렸을 때 해당 이벤트를 수신하는 방법은 비교적 간단합니다. RemoteViews 에서 특정 Button 이 클릭될 때 특정한 PendingIntent 를 전달 하도록 설정하는 API 를 제공해 주고 있기 때문입니다. PendingIntent 는 위임된 Intent 라고 할 수 있으며, 서비스나 엑티비티를 시작하거나 Broadcast Intent 를 전송하는데 사용될 수 있습니다. 그런데, 여러번 강조했듯이 App Widget 은 하나의 BroadcstReceiver 입니다. 따라서, 당연히 Broacast Intent 를 수신 할 수 있지요.
즉, 아래와 같이 특정한 Button 이 눌릴 때, 정해진 Broadcast Intent 를 발송하도록 설계도를 작성하고,

Intent intent = new Intent(CLICK_ACTION);	//button is clicked.
PendingIntent pendingIntent 
	= PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.main);
views.setOnClickPendingIntent(R.id.onoff, pendingIntent);
App Widget 이 해당 메세지를 수신할 수 있도록 메니페스트 파일에 필터를 설정해 주면 됩니다.

<receiver android:name=".TestWidgetProvider">
	<intent-filter>
		<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
		<action android:name="com.huewu.example.widget.CLICK" />
	</intent-filter>
	<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget" />
</receiver>  	

이렇게 되면, 'onoff' 라는 아이디를 갖는 View 가 클릭될 경우 CLICK 이라는 인텐트가 송신되고 해당 내용을 수신한 AppWidgetProvider 의 onReceive() 메서드가 호출되게 됩니다.

두 번째는 특정 Button 의 상태 정보를 유지하는 문제인데요, 이게 생각보다 재미있는 점이 있습니다. 우선 상태 정보 유지를 위해서 가장 손 쉽게 사용가능한 방법이 AppWidget 인스턴스 변수로 플래그를 하나 두는 것 입니다.

private boolean isChecked = false;

@Override
public void onReceive(Context context, Intent intent) {

	if(intent.getAction().equals(CLICK_ACTION) == true){
		isChecked = !isChecked; //toggle button.
	}
	super.onReceive(context, intent);
}
하지만, 아쉽게도 이 방법은 정상적으로 작동하지 않습니다. 이유는 명확합니다. AppWidget 은 BroacastReceiver 의 생명주기를 따르며, onReceive() 호출이 완료된 시점에 해당 인스턴스가 소멸됩니다. 이 후에 또다른 Broadcast Intent 를 수신하면 인스턴스가 새롭게 생성되지요. 따라서, 'isChecked' 의 값은 매번 'false' 이 새롭게 할당될 뿐, 우리가 원하는데로 값이 유지되지 않습니다. 이 문제를 해결하는 방법은 다음의 세 가지가 있습니다.
  • static 변수를 사용한다.
  • Intent 에 Bundle 값으로 상태 값을 기록한다.
  • Preference 로 기록한다.
우선 가장 손쉽게 static 값을 사용하는 방법이 있습니다. static 변수의 생명 주기는 프로세스와 동일하기 때문에, AppWidget 인스턴스가 소멸되도 그 값을 유지할 수 있습니다. 하지만 한 가지 치명적인 문제는, AppWidget 이 소멸된 시점에 해당 프로세스의 우선순위는 가장 하위인 백그라운드 프로세스로 옮겨가기 때문에 (해당 프로세스에서 실행중인 어플리케이션 구성요소가 없을 경우...), 언제든지 프로세스가 종료될 수 있다는 점 입니다. 따라서 static 변수를 사용하는 것은 간편하지만, 예측할 수 없는 오작동을 일으킬 수 있으며 권장되는 방법은 아닙니다.

두 번째는 Intent 에 Bundle 값으로 상태 정보를 저장하는 방식입니다. 다음과 같이 말이지요.

//when set a pending intent.
Intent intent = new Intent(CLICK_ACTION);	//button is clicked.
intent.putExtra(CHECKED, isChecked);
PendingIntent pendingIntent 
	= PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);

//when receive an intent.	
@Override
public void onReceive(Context context, Intent intent) {
	if(intent.getAction().equals(CLICK_ACTION) == true){
		boolean isChecked = intent.getBooleanExtra(CHECKED, false);
	}
}

이른바, 함수형 언어에서 '변수' 라는 개념 대신, 재귀적 호출 방식을 이용해, 함수의 입력 인자를 '변수' 처럼 사용하는 것과 유사한 방식이라고 할 수 있습니다. 한가지 유의해서 살펴볼 부분은 PendingIntent 에 FLAG_UPDATE_CURRENT 로 플래그를 설정한 부분인데요, 안드로이드 프레임웍은 동일한 형식의 PendingIntent 는 하나만 유지하기 때문에 (카드 대량 남발로 인한 신용불량자 양산을 막기 위해...), 기존에 생성된 PendingIntent 가 있을 경우, 추가적으로 생산하지 않습니다. 이 경우, 새로운 Bundle 값을 설정하기 위해서는 FLAG_UPDATE_CURRENT 를 사용해서 현재 발행된 PendingIntent 의 값을 업데이트 해 줄것을 요청해야 합니다. 다만, 이러한 방식은 상태 값이 어디까지나 안드로이드 프레임워크의 메모리 상에서만 존재하는 만큼, 디바이스를 재부팅하거나 하는 경우에는 상태 값이 초기화 됩니다.

마지막 세 번째는, 단순하지만 확실한 방법입니다. 바로 SharedPreference 를 이용해서 상태 값을 파일에 저장하는 것이지요.

@Override
public void onReceive(Context context, Intent intent) {

	if(intent.getAction().equals(CLICK_ACTION) == true){
		//button is clicked.
		SharedPreferences pref = context.getSharedPreferences(CHECKED, Activity.MODE_PRIVATE);
		isChecked = pref.getBoolean(CHECKED, true);	//default value is true.
		Editor e = pref.edit();
		e.putBoolean(CHECKED, !isChecked);
		e.commit();
		//Update Widgets. 
		AppWidgetManager manager = AppWidgetManager.getInstance(context);
		this.onUpdate(context, manager, manager.getAppWidgetIds(new ComponentName(context, TestWidgetProvider.class)));
	} else {
		super.onReceive(context, intent);
	}
}

간단하지만, 파일에 값이 저장되기 때문에 폰이 재부팅 되는 경우에도 상태 값이 유지됩니다. 기본적으로 SharedPreference 를 사용하는 것은 File I/O 작업이 이루어짐으로 너무 빈번하게 사용될 경우 성능상에 문제점을 일으킬 수도 있습니다만, 얼마나 오랫동안 상태 값이 유지되어야 되는지를 잘 따져서, Intent 의 Bundle 을 사용하는 방식과 혼용에서 사용하면 AppWidget 을 이용해서 다양한 기능을 구현 하실 수 있을 듯 합니다.

AppWidget 으로 CheckeBox 를 구현한 예제

마지막으로, AppWidget 을 이용해서 어떻게 상태 값을 유지하는 CheckBox 를 구현할 수 있는 가에 관한 간단한 예제를 첨부합니다.


예제에서는 SharedPreference 를 이용해서 상태값을 저장하는 방법이 적용되어 있고, Intent Bundle 을 사용하는 방법은 주석처리 되어 있습니다. 실재 코드는 다음의 경로로 접속해 보시면 확인 하실 수 있습니다.

 

출처 : http://darksilber.tistory.com/entry/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-AppWidget-RemoteViews-%EA%B7%B8%EB%A6%AC%EA%B3%A0-CheckBox