안녕하세요!
오늘은 안드로이드의 4대 구성 요소 중 하나인 BroadcastReceiver에 대해서 소개해드리려 합니다.
BroadcastReceiver는
" Android 앱은 Android 시스템 및 기타 Android 앱에서 게시-구독 디자인 패턴과 유사한 브로드캐스트 메시지를 받거나 보낼 수 있습니다. 관심 있는 이벤트가 발생할 때 이러한 브로드캐스트가 전송됩니다. 예를 들어 Android 시스템은 시스템 부팅 또는 기기 충전 시작과 같은 다양한 시스템 이벤트가 발생할 때 브로드캐스트를 전송합니다. 또한 앱은 맞춤 브로드캐스트를 전송하여 다른 앱이 관심을 가질만한 사항(예: 일부 새로운 데이터가 다운로드됨)을 관련 앱에 알릴 수 있습니다.
앱은 특정 브로드캐스트를 수신하도록 등록할 수 있습니다. 브로드캐스트가 전송되면 시스템은 특정 유형의 브로드캐스트를 수신하도록 신청한 앱에 브로드캐스트를 자동으로 라우팅합니다.
일반적으로 말하면 브로드캐스트는 앱 전체에 걸쳐 그리고 일반 사용자 플로우 외부에서 메시징 시스템으로 사용될 수 있습니다. 그러나 다음 동영상에 설명된 것처럼 브로드캐스트에 응답하고 백그라운드에서 시스템 성능 저하의 원인이 될 수 있는 작업을 실행하는 기회를 남용하지 않도록 주의해야 합니다. "
라고 안드로이드 공식 문서에서 소개하고 있습니다.
간단하게 정리 요약해드리면 Broadcast라는 단어 그대로 TV 방송을 생각하시면 쉽게 이해하실 수 있습니다.
한 방송국에서 7번 채널로 방송을 송신하면 TV를 갖고 있는 모든 사람들 중에서 7번 채널로 고정한 사람들은 7번 채널의 방송을 수신할 수 있습니다. 이 위의 한 줄이 Broadcast의 전체적인 요약이며 오늘의 주제인 BroadcastReceiver는 Receiver라는 단어의 어원과 동일하게 수신측을 담당합니다.
방송국을 담당하는 것은 타 앱이 될 수도 있고 안드로이드 시스템일 수도 있고 BroadcastReceiver를 통해 수신하고 있는 앱 자신이 될 수도 있습니다.
그러나 마지막처럼 앱 자신이 수신하고 송신하는 경우에는 안드로이드 공식 문서에서 보안 이슈와 효율적인 성능을 위해 LocalBroadcastManager를 통해 송수신 하도록 가이드하고 있습니다.
7번 채널을 담당하는 것은 action이라는 String 문자입니다.
안드로이드 시스템에서 송신하는 broadcast의 경우 "android.intent.action.XXXX" 형식으로 만들어져있습니다. BroadcastReceiver를 사용할 때는 앱에 receiver를 등록해야 하고 이 등록을 할 때 action 값을 같이 등록하면 action 값에 대응되는 정보들만 broadcastReceiver로 수신합니다.
위의 내용처럼 등록만 한다면 안드로이드에서는 BroadcastReceiver를 통해 쉽게 정보를 수신할 수 있습니다.
하지만 반대로 다른 앱이 Broadcast를 임의로 송수신함에 따른 보안 이슈를 안고 있습니다. 그래서 안드로이드 공식 문서에서는 특정 Package에게만 Broadcast를 전송하게 하는 방법, 앱 자체에서 Broadcast를 송수신하는 경우 LocalBroadcastManger를 사용하는 방법 등으로 보안 이슈를 해결하도록 권장하고 있습니다.
더 자세한 내용은 안드로이드 공식 문서를 확인해주세요 : )
(https://developer.android.com/guide/components/broadcasts?hl=ko#security-and-best-practices)
BroadcastReceiver를 사용하려면 BroadcastReceiver를 상속 받아 구현한 후 등록을 해주어야합니다.
앱에서 두가지 방식을 통해 등록할 수 있습니다. 하나는 Manifest에 receiver태그를 통해 등록하는 방법이고 다른 하나는 registerBroadcast메서드를 통해 등록하는 방법입니다. 안드로이드 공식 문서에서는 Manifest에 receiver를 등록하는 것을 "manifest에 선언된 수신자"라고 하며, registerBroadcast메서드를 통해 등록된 receiver를 "컨텍스트에 등록된 수신자"라고 얘기하고 있습니다. 그리고 특정 앱을 지정하지 않고 수신 등록한 모든 앱들에게 보내는 브로드캐스트를 "암시적 브로드캐스트"라고 합니다.
이 암시적 브로드캐스트에 대해서 안드로이드는 SDK 24 Nougat부터 규제하기 시작했습니다. SDK 24 Nougat 이상을 타겟팅하는 앱들은 몇가지의 암시적 브로드캐스트를 제외하고는 manifest에 선언해도 작동하지 않으며 registerBroadcast메서드를 통해 receiver를 등록해야만 정상 작동합니다.
이 몇가지 예외 암시적 브로드캐스트에 대해서는 안드로이드 공식 문서를 확인하시면 더 자세하게 공부하실 수 있습니다.
(https://developer.android.com/guide/components/broadcast-exceptions?hl=ko)
registerBroadcast메서드를 통해 컨텍스트에 등록한 receiver들은 일시중지되었을 때 브로드캐스트를 수신하지 않기 위해 onCreate메서드에서 receiver를 등록했으면 onDestroy메서드에서 receiver를 등록 취소해야 하고, onResume메서드에서 receiver를 등록했으면 onPause메서드에서 receiver를 등록 취소하여 receiver가 여러 번 등록되지 않도록 해야 한다고 안드로이드 공식 문서에서 권장하고 있습니다.
Manifest에 receiver를 선언할 경우 broadcast가 전송될 때 앱이 아직 실행 중이 아니라면 안드로이드 시스템에서 앱을 실행한 후 전달됩니다.
그래서 휴대폰이 켜졌을 때, 인터넷 연결이 끊겼을 때, 비행기 모드가 해제됬을 때 등의 암시적 브로드캐스트인 시스템 브로드캐스트가 수신되면 본인의 앱 안의 onReceive 메서드에서 필요한 기능을 구현하면 됩니다. 안드로이드 시스템에서 휴대폰의 상태 정보를 송신하는 시스템 브로드캐스트 외에도 일반 앱에서 sendBroadcast를 통해 broadcast를 송신할 수 있는데 이를 "일반 브로드캐스트"라고 합니다.
오늘 소개해드릴 예제는 broadcast를 송신하는 앱, 수신하는 앱 두 개를 만들어 볼 예정입니다.
송신하는 앱에서 EditText로 문자열을 입력 받아 sendBroadcast메서드를 통해 문자열과 함께 일반 브로드캐스트를 송신한 후 수신앱에서 broadcast를 수신받아서 문자열을 TextView에 넣는 기능과 휴대폰이 켜지면 송신되는 시스템 브로드캐스트를 수신앱에서 받아서 Service를 통해 노래를 재생시키는 기능을 만들어 볼 예정입니다. 그리고 각 부분에 로그를 추가해서 호출되는 순서에 대해서 확인하고 공부할 수 있도록 만들 예정입니다.
아래의 두 사진은 오늘 만들 예제의 화면 캡처입니다.
오른쪽의 수신앱은 송신앱으로부터 받은 문자열을 "값"이라고 적혀있는 TextView에 입력할 예정이고, "BGM 정지" 버튼은 휴대폰을 부팅하면 안드로이드 시스템으로 부터 송신되는 시스템 브로드캐스트를 수신하여 재생되는 노래를 정지시키는 버튼입니다.왼쪽이 sendBroadcast메서드를 통해서 일반 브로드캐스트를 전송하는 송신앱이고 오른쪽이 broadcast를 받는 수신앱입니다.
먼저 예제 만들기 순서를 소개하겠습니다.
송신앱 만들기
1. activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="15dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="BroadcastReceiver 값 보내기"
android:textSize="20dp"
android:textColor="@android:color/black"/>
<EditText
android:id="@+id/etText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:hint="송신할 문자열을 입력해주세요" />
<Button
android:id="@+id/btnSend"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="전 송"/>
</LinearLayout>
</LinearLayout>
2. MainActivity.java
package com.smparkworld.receiverexample;
import androidx.appcompat.app.AppCompatActivity;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.EditText;
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
private EditText etText;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
etText = findViewById(R.id.etText);
findViewById(R.id.btnSend).setOnClickListener(this);
}
@Override
public void onClick(View v) {
Intent intent = new Intent("com.smparkworld.broadcastreceiver.EXAMPLE_ACTION");
intent.putExtra("data", etText.getText().toString());
sendBroadcast(intent);
}
}
여기서 중요하게 봐야될 부분은 26~29번째 줄입니다.
26번째 줄처럼 임의의 문자열 action을 아규먼트로 받는 Intent 생성자를 통해 인스턴스를 만든 후 27번째 줄처럼 "data"라는 key값으로 EditText의 문자열 값을 Intent 인스턴스에 담습니다. 그리고 29번째 줄의 sendBroadcast메서드를 통해 일반 브로드캐스트를 전송합니다.
수신앱 만들기
1. actvitiy_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical" >
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="전달받은 값"
android:textSize="20dp"
android:textColor="@android:color/black"/>
<TextView
android:id="@+id/tvText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:gravity="center"
android:text="값" />
<Button
android:id="@+id/btnStopService"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:layout_gravity="center"
android:text="BGM 정지" />
</LinearLayout>
</LinearLayout>
2. BGMService.java
이 클래스의 경우는 이전에 포스팅했던 "Service에 대해서" 게시글을 참고하시면 도움이될 것 같습니다!
package com.smparkworld.receiverexample_client;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.media.MediaPlayer;
import android.os.IBinder;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
public class BGMService extends Service {
private MediaPlayer mMP;
@Override
public void onCreate() {
super.onCreate();
Log.v("ReceiverExample", "Called onCreate method in Service!");
mMP = MediaPlayer.create(getApplicationContext(), R.raw.sample_music);
mMP.setLooping(true);
mMP.start();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.v("ReceiverExample", "Called onStartCommand method in Service! || startId is " + startId);
Intent notiIntent = new Intent(this, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notiIntent, 0);
String channelID = "ReceiverExample";
NotificationChannel channel = new NotificationChannel(channelID, "BGMService", NotificationManager.IMPORTANCE_DEFAULT);
((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).createNotificationChannel(channel);
Notification noti = new NotificationCompat.Builder(this, channelID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentText("BGMService")
.setContentText("BGM 실행 중")
.setContentIntent(pendingIntent)
.build();
startForeground(1, noti);
return START_NOT_STICKY;
}
@Override
public void onDestroy() {
Log.v("ReceiverExample", "Called onDestroy method in Service!");
mMP.stop();
super.onDestroy();
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
Log.v("ReceiverExample", "Called onBind method in Service!");
return null;
}
}
26번째 줄에 R.raw.sample_music은 resource의 raw 디렉토리 안에 있는 재생시킬 음원 파일이름입니다.
음원 추가하는 방법은 "Service에 대해서" 게시글을 참고해주세요 : )
3. ExampleReceiver.java
package com.smparkworld.receiverexample_client;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
import android.widget.Toast;
public class ExampleReceiver extends BroadcastReceiver {
public static final String EXAMPLE_ACTION = "com.smparkworld.broadcastreceiver.EXAMPLE_ACTION";
@Override
public void onReceive(Context context, Intent intent) {
Log.v("ReceiverExample", "Called onReceive method in ExampleReceiver");
String action = intent.getAction();
Log.v("ReceiverExample", "Received action is " + action);
if (action.equals(EXAMPLE_ACTION)) {
((MainActivity)context).changeValue(intent.getStringExtra("data"));
} else if (action.equals(Intent.ACTION_BOOT_COMPLETED)) {
Toast.makeText(context, "Called BOOT_COMPLETED action.", Toast.LENGTH_SHORT).show();
context.startForegroundService(new Intent(context, BGMService.class));
Log.v("ReceiverExample", "action is android.intent.action.BOOT_COMPLETED");
}
}
}
위의 11번째 줄의 문자열 action 값은 송신앱의 MainActivity.java 26번째 줄 action 값과 같아야 합니다.
그리고 송신앱 안에 MainActivity.java의 27번째 줄 키값 "data"와 동일하게 20번째 줄에서 값을 받아온 후 MainActivity에서 값을 변경하도록 메서드를 호출합니다. 24번째 줄의 startForegroundService 메서드는 SDK 26버전 이상부터 사용할 수 있으므로 if (Build.VERSION.SDK_INT > 26) { .. } 와 같이 버전을 나누어 해주시거나 build.gradle에서 minSdkVersion을 26으로 수정한 뒤 Sync now 해주시면 정상적으로 사용할 수 있습니다.
4. MainActivity.java
package com.smparkworld.receiverexample_client;
import androidx.appcompat.app.AppCompatActivity;
import android.content.BroadcastReceiver;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
public class MainActivity extends AppCompatActivity implements View.OnClickListener{
private BroadcastReceiver mReceiver;
private TextView tvText;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
tvText = findViewById(R.id.tvText);
mReceiver = new ExampleReceiver();
IntentFilter filter = new IntentFilter();
filter.addAction(ExampleReceiver.EXAMPLE_ACTION);
registerReceiver(mReceiver, filter);
Log.v("ReceiverExample", "The Receiver has been registered");
findViewById(R.id.btnStopService).setOnClickListener(this);
}
@Override
protected void onDestroy() {
unregisterReceiver(mReceiver);
Log.v("ReceiverExample", "The Receiver has been unregistered");
super.onDestroy();
}
public void changeValue(String value) {
tvText.setText(value);
}
@Override
public void onClick(View v) {
stopService(new Intent(this, BGMService.class));
}
}
여기서 중요하게 보셔야할 부분은 26~30번째 줄과 38번째 줄입니다.
26번째 줄처럼 receiver를 생성한 후 28~30번째 줄처럼 IntentFilter를 통해 action을 등록해야합니다. 처음 소개해드릴 때 방송국 예를 들었을 때의 채널조정과 같은 작업입니다 : )
그리고 안드로이드 공식 문서에서 권장에 따라 30번째 줄처럼 onCreate메서드 안에서 broadcast를 등록했으면 38번째 줄처럼 onDestroy메서드 안에서 등록 해제 해주어야 합니다.
5. AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.smparkworld.receiverexample_client">
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ReceiverExample_client">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".ExampleReceiver"
android:enabled="true"
android:exported="false" >
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<service android:name=".BGMService" />
</application>
</manifest>
이제 권한 등록과 예외된 암시적 브로드캐스트인 BOOT_COMPELTED 시스템 브로드캐스트를 수신받기 위해 Manifest에 receiver를 등록합니다.
5~6번째 줄처럼 휴대폰이 켜지고나서 발생되는 시스템 브로드캐스트를 수신하도록 권한을 허가해주고 Service를 Foreground에서 실행할 것이므로 FOREGROUND_SERVICE 권한도 허가해줍니다.
25번째 줄의 enabled 속성은 브로드캐스트가 수신될 때 앱이 실행 중이 아니라면 앱을 실행할 수 있게 할지를 선택하는 속성이며 기본값은 true입니다. 휴대폰이 켜지고나서 송신되는 시스템 브로드캐스트를 수신해 앱을 실행해야하므로 부팅하고나서 안드로이드 시스템이 앱을 실행할 수 있도록 true로 지정합니다.
26번째 줄의 exported 속성은 외부 앱에서부터 등록된 action을 수신할지를 선택하는 속성입니다. 본 예제는 외부 앱(송신앱)으로부터 EXAMPLE_ACTION 브로드캐스트를 수신하지만 이 broadcast는 컨텍스트에 등록된 receiver에서 수신하는 것이므로 Manifest에 등록된 receiver의 exported 속성과는 별개입니다. Manifest에 등록되는 receiver는 외부 앱이 아닌 안드로이드 시스템으로부터 BOOT_COMPLETED 시스템 브로드캐스트를 수신받기 때문에 false로 지정했습니다.
여기서도 27~29번째 줄을 보면 intent-filter로 action을 등록하여 채널조정의 역할을 하고 있습니다. 그리고 BGMService를 작동하기 위해 32번째 줄처럼 Service를 등록해줍니다.
이제 앱을 테스트하려고 합니다.
먼저 수신앱을 켜면 로그에 아래의 사진처럼 receiver가 등록된 것을 볼 수 있습니다.
그 다음에 꼭 "홈버튼"을 클릭해서 멀티태스킹을 해주세요! 그리고 아래와 같이 송신앱을 켜서 아무 문자를 입력하고 전송을 누룹니다.
그러면 아래와 같이 로그가 남는 것을 볼 수 있습니다.
그 다음에 다시 수신앱을 들어가보면 아래와 같이 전송한 문자가 TextView에 변경되어있는 것을 볼 수 있습니다.
그리고 수신앱을 뒤로가기 버튼으로 종료하면 onDestroy메서드가 호출되며 아래와 같이 등록해제된 로그를 볼 수 있습니다.
이렇게 외부 앱에서 값을 broadcast를 전송해서 onReceive메서드를 호출하고, 필요에 따라 값도 전달받을 수 있음을 공부했습니다.
그 다음에 Manifest에 선언한 것처럼 시스템 브로드캐스트를 수신해보려합니다.
휴대폰을 재부팅한 후 기다리면 아래의 사진처럼 Notification알림과 함께 노래가 재생됩니다. 이 Notification알림을 터치하면 수신앱이 켜지고 "BGM 정지" 버튼을 통해 노래를 종료할 수 있습니다. 기기에 따라 다른 것 같은데 제 휴대폰에서는 부팅 후 1분 30초 후에 Service가 실행되더라구요 : (
이상으로 BroadcastReceiver에 대한 포스팅을 마치겠습니다!
부족하거나 궁금하신 사항에 대해서 댓글로 남겨주시면 감사드리겠습니다 : )