본문 바로가기

Android/Architecutre

[안드로이드 Service] MVVM구조에서 BluetoothLE Service 사용하기

728x90
반응형

 

 

 

 

 

 

 

 

BLE와 블루투스의 안드로이드 예제를 포스팅 하였었습니다.

[Android/통신] - [안드로이드 Kotlin] BLE(Bluetooth Low Energy) 통신 예제

[Android/통신] - [안드로이드-아두이노] bluetooth classic 자동 페어링&연결 / 데이터 송,수신

글은 메서드 위주로 설명하였습니다. 

위 글들 하단의 github 소스 코드를 먼저 보면 코드를 MVVM구조로 구성하면서 BleRepository를 만들어서 Repository에 BLE 메서드를 전부 구현하였었습니다.

 

하지만 Service 사용의 필요를 느끼면서, 메서드를 Service로 옮기고, Service를 어디 둘지 고민하게 되었습니다.

 

Service 사용 이유?

만약 Activity와 Bluetooth간의 통신이 빈번하고, Activity가 바뀌어도 데이터 플로우를 자유롭게 유지하고 싶다면, Bluetooth를 Service로 사용하는것이 좋다고합니다.

Ble 서비스와 Activity가 연결되면, 서비스는 서버 역할을 하게 되고, Activity는 client 역할을 하게 됩니다.

그러므로 쉽게 Activity에서 서비스에 명령을 보내고, 서비스에서 명령을 받아 ble 모듈로 보낼 수 있습니다.

ble 모듈이 이에 응답하여 다시 서비스로 정보를 보내고, 서비스가 다시 Activity로 이 정보를 보내줍니다.

아래 출처의 그림은, Ble GPS Module과 연결하는 예제 그림입니다.

https://codingwithmitch.com/blog/bound-services-on-android/

 

그래서 Service를 사용해보기로 했는데, 이제 MVVM 구조에서 Service가 어디에 위치해야하는지?? 고민이 되었습니다.

검색해보면, 이러한 구조에 대해서 많은 토론이 이루어지고 있었고 다양한 의견들과 코드를 볼 수 있었습니다.

 

또한 Repository에서 구현해도 충분히 View와 통신가능한데 굳이 Service를 쓸 필요가 있을까? 하는 고민이 아직도 있습니다. 

어쨌든 구현해보았습니다. (항상 구현하고 유용함을 체험하였음 ㅎㅎㅎ)

수정됨: 다양한 어플리케이션에서 ble를 구현하면서, 어플이 보이지 않을때, 즉 백그라운드에서 돌아가며 데이터를 수집, 처리등등을 해야 할 경우가 있었습니다. 이를 위해 Service가 필요하다는것을 느끼게 되었습니다! 

또한 Oreo 이상 버전에서는, foreground 서비스를 사용해야 상단 noti와 함께 백그라운드에서 돌아가는것을 확인할 수 있습니다.

아래 코드는 bindService를 사용하여 구현하였지만, 최종적으로 Oreo 이상 버전을 타겟으로하여, foreground service로 변경하였습니다. 하단에  foreground service 구현을 작성하였고, 깃허브 링크에 수정된 코드가 있습니다.

 

 

MVVM구조에서 BLE Service 구현 (Profile)

그래서 이제 BluetoothLE Service를 만들어야 하는데, 다양한 방법들을 찾을 수 있었지만, 제가 하고자 하는 방식을 소개하자면 이렇습니다.

View-ViewModel-Repository의 구조를 유지한채,

기존에 Repository에 전부 넣었던 Ble 메서드들을 스캔과 Gatt 통신(Read,Write) 부분으로 나누어

Scan 부분은 ViewModel에서 처리하고, Scan된 결과를 콜백받아 특정 디바이스를 누르면 Repository로 가져와 Repository에서 BleService를 시작하고, 연결합니다.

따라서 Service는 Repository의 하나의 데이터로 보관됩니다.

그러면 BleService에서는 온전히 BluetoothGattCallback과 Connect / Disconnect , Read / Wrtie 부분(Gatt 서버)을 담당하게 됩니다.

그리고 BleService에서 Repository로 broadcast를 통해 피드백 합니다.  나머지는 LiveData로 만들어

( (BleGattService)  Repository ) ← ViewModel ← View 방향으로 관찰될 것입니다.

그래서 ViewModel이나 View에서 UI업데이트가 이루어지도록 하였습니다.

만약 여러개의 ble device가 서비스에 연결할 경우, 각각 BleGattService에 바인드하면 됩니다.

 

 

하단의 구현된 github 소스코드는 하나의 디바이스만 연결됩니다.

여러개의 디바이스를 연결해야할 경우 mService를 array에 넣어 관리해주어야 합니다.

 

복잡한 이 구조를 피하기 위해 ViewModel에서 바로 Service로 연결하는 예제도 보았습니다.

그러나 MVVM을 선택한 이상 구조는 항상 복잡했고, 그만큼 나중엔 좋은 결과를 보였기에 ^^;; 대형 소스코드를 앞두고 이렇게 생각했습니다.

제가 참고한 출처와 이미지가 다음과 같습니다. 다만 Repository에서 BleService의 LiveData Observing이 잘 되지 않아broadcastReceiver를 통해 이벤트를 전달받았습니다. 

https://stackoverflow.com/questions/46682425/android-architecture-components-viewmodel-communication-with-service-intentser

 

이제 BLE Service를 구현할 것인데, 앞서 BLE 메서드 들은 다음 글을 참고 해주세요.

[Android/통신] - [안드로이드 Kotlin] BLE(Bluetooth Low Energy) 통신 예제

 

Bound Service 만들기

Ble를 사용하면서 결과를 반환받아야하기 때문에, Unbound Service대신 Bound Service를 사용해야 합니다.

서비스바인딩이라고도 하는데, 서비스바인딩은 서비스로부터 결과를 받을 수 있어, 프로세스간 통신에도 사용되지만,

백그라운드에서 무한히 실행되진 않습니다. : 백그라운드에서 실행하기위해 foreground service로 코드를 수정하였음. 하단의 글 참조

또, 하나의 서비스에 다수의 액티비티 연결이 가능합니다. 주로 애플리케이션 안의 기능을 외부에 제공하는 경우에 많이 사용합니다.

 

이제 BleService.kt를 하나 만들겠습니다.

class BleService : Service() {
    // Binder given to clients (notice class declaration below)
    private val mBinder: IBinder = LocalBinder()


    /**
     * Class used for the client Binder. The Binder object is responsible for returning an instance
     * of "MyService" to the client.
     */
    inner class LocalBinder : Binder() {
        // Return this instance of MyService so clients can call public methods
        val service: BleService
            get() = this@BleService
    }

    /**
     * This is how the client gets the IBinder object from the service. It's retrieve by the "ServiceConnection"
     * which you'll see later.
     */
    override fun onBind(intent: Intent): IBinder {
        return mBinder
    }

}

 

Service()를 상속해주는 Class를 만들고, client binder를 위해 LocalBinder inner class를 만듭니다.

onBind 메서드는 클라이언트가 LocalBinder 개체를 가져올 수있는 방법입니다. 클라이언트가 서비스에 바인딩하면 onBind 메서드가 실행되고 LocalBinder 참조를 반환합니다.

 

이제 이 안에 BluetoothGatt Callback과 Connect/DisCooect, Read/Write 메서드 등을 넣어줍니다.

 

Service를 만들었으면, Manifest에 추가해줍니다.

<application>..</application> 안에 추가해야합니다.

<service android:name=".BleService"/>

 

 

그다음, ViewModel에서 스캔해 준 뒤, Callback받은 ScanResult 데이터를 통해 리스트로 띄워줍니다.

리스트에서 해당 기기를 선택하면, 그 기기를 연결해 줍니다. 그방향은 이렇게 됩니다.

Activity에서 scan list의 디바이스 클릭 이벤트를 받아 viewModel로 전달.

 adapter?.setItemClickListener(object : BleListAdapter.ItemClickListener {
     override fun onClick(view: View, device: BluetoothDevice?) {
         if (device != null) {
              viewModel.connectDevice(device)
         }
     }
})

 

viewModel에서 Repository로 이벤트 전달.

fun connectDevice(bluetoothDevice: BluetoothDevice){
    myRepository.connectDevice(bluetoothDevice)
}

 

Repository에서 Service를 실행합니다.

/**
 * Connect to the ble device
 */
fun connectDevice(device: BluetoothDevice?) {
   // TODO : bind Service.
}

 

서비스를 시작하는 방법은 두가지가 있습니다.

  • startService를 사용해 intent를 전해주고 bind하기
  • 먼저 클라이언트가 bind해 자동으로 Service 시작하기

그래서 만약에 Service가 시작되지 않았는데 클라이언트가 bind한다면, Service가 자동으로 시작됩니다.

또한 startService를 사용하지않고 bind하면, 클라이언트가 전부 unbind될때 서비스는 멈추게 됩니다. (startService를 사용하면 서비스는 계속 됩니다.)

상황에 따라 적절히 사용하면 되겠습니다.

 

만약 startService()를 사용한다면, 보통 Activity onCreate에서 시작해놓고, 적절한때에 bind하면 됩니다.

public class MainActivity extends AppCompatActivity {

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

    private void startMyService(){
        Intent serviceIntent = new Intent(this, MyService.class);
        startService(serviceIntent);
    }
}

 

저는 바로 binding해보겠습니다.

var mService: BleService? = null
var mBound: Boolean? = null
var deviceToConnect: BluetoothDevice? = null

private val mServiceConnection = object : ServiceConnection {

     override fun onServiceConnected(className: ComponentName, service: IBinder) {
         Log.d(TAG, "ServiceConnection: connected to service.")
         // We've bound to LocalService, cast the IBinder and get LocalService instance
         val binder = service as BleService.LocalBinder
         mService = binder.service
         mBound = true
         
         //connect device
         mService?.connectDevice(deviceToConnect)
     }

     override fun onServiceDisconnected(arg0: ComponentName) {
          Log.d(TAG, "ServiceConnection: disconnected from service.")
          mBound = false
     }
}
/**
 * Connect to the ble device
 */
fun connectDevice(device: BluetoothDevice?) {
   deviceToConnect = device
   // Bind to LocalService
   Intent(MyApplication.applicationContext(), BleService::class.java).also { intent ->
       MyApplication.applicationContext().bindService(intent, mServiceConnection, Context.BIND_AUTO_CREATE)
   }
}

context는 MyApplication.applicationContext()로 가져왔습니다.

[Android/Function] - [안드로이드] application context 어디서나 쉽게 가져오기 ― companion object에서 context 사용

또한 ServiceConnection 개체를 만들어 주어야 합니다. 이 개체는 클라이언트가 서비스랑 bound했는지, 또 서비스로부터 disconnect했는지를 감지해줍니다.

 

클라이언트가 연결됬으면, mService를 통해 BleService의 메서드들을 사용할 수 있습니다. (command 전달)

또한 서비스에서 BLE 상태를 업데이트해서 전해주기 위해 BroadCastReciever를 Repository에 등록합니다.

 

  • Constants
const val ACTION_GATT_CONNECTED = "com.lilly.ble.ACTION_GATT_CONNECTED"
const val ACTION_GATT_DISCONNECTED = "com.lilly.ble.ACTION_GATT_DISCONNECTED"
const val ACTION_STATUS_MSG = "com.lilly.ble.ACTION_STATUS_MSG"
const val ACTION_READ_DATA= "com.lilly.ble.ACTION_READ_DATA"
const val EXTRA_DATA = "com.lilly.ble.EXTRA_DATA"
const val MSG_DATA = "com.lilly.ble.MSG_DATA"

 

  • Repository
/**
 * Handles various events fired by the Service.
 */
var mGattUpdateReceiver: BroadcastReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        when(intent.action){
           ACTION_GATT_CONNECTED->{
                isConnected.postValue(Event(true))
                // intent.getStringExtra(MSG_DATA)?.let{statusTxt.postValue(Event(it))}   
           }
           ACTION_GATT_DISCONNECTED->{
                isConnected.postValue(Event(false))
                // intent.getStringExtra(MSG_DATA)?.let{statusTxt.postValue(Event(it))}
           }
           ACTION_STATUS_MSG->{
              intent.getStringExtra(MSG_DATA)?.let{
                  statusTxt.postValue(Event(it))
               }
           }
           ACTION_READ_DATA->{
              intent.getStringExtra(EXTRA_DATA)?.let{
                   txtRead.postValue(Event(it))
               }
            }
        }

    }
}
private fun registerGattReceiver(){
    MyApplication.applicationContext().registerReceiver(mGattUpdateReceiver,
       makeGattUpdateIntentFilter())
}
private fun makeGattUpdateIntentFilter(): IntentFilter {
    val intentFilter = IntentFilter()
    intentFilter.addAction(ACTION_GATT_CONNECTED)
    intentFilter.addAction(ACTION_GATT_DISCONNECTED)
    intentFilter.addAction(ACTION_READ_DATA)
    intentFilter.addAction(ACTION_STATUS_MSG)
    return intentFilter
}

 

BroadcastReciever 등록은 처음 Activity가 생길때 onCreate에서 해주면 됩니다.

class MainActivity : AppCompatActivity() {
    ...
    override fun onCreate(savedInstanceState: Bundle?) {
       ...
       viewModel.registBroadCastReceiver()
       ...
     }
    ...
}

class BleViewMode(private val myRepository: MyRepository) : ViewModel()l{
   ...
   fun registBroadCastReceiver(){
        myRepository.registerGattReceiver()
    }
    ...
}

 

아래처럼 서비스에서 sendBroadcast로 intent를 전달해줄 수 있습니다.

  • BleService
private fun broadcastUpdate(action: String) {
     val intent = Intent(action)
     sendBroadcast(intent)
}
private val gattClientCallback: BluetoothGattCallback = object : BluetoothGattCallback() {
    override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
        super.onConnectionStateChange(gatt, status, newState)

        val intentAction: String

        if( status == BluetoothGatt.GATT_FAILURE ) {
            disconnectGattServer()
            intentAction = ACTION_GATT_DISCONNECTED
            broadcastUpdate(intentAction)
            return
        } else if( status != BluetoothGatt.GATT_SUCCESS ) {
            disconnectGattServer()
            intentAction = ACTION_GATT_DISCONNECTED
            broadcastUpdate(intentAction)
            return
        }
        if( newState == BluetoothProfile.STATE_CONNECTED ) {
            // update the connection status message
            intentAction = ACTION_GATT_CONNECTED
            broadcastUpdate(intentAction)

            Log.d(TAG, "Connected to the GATT server")
            gatt.discoverServices()
         } else if ( newState == BluetoothProfile.STATE_DISCONNECTED ) {
            disconnectGattServer()
            intentAction = ACTION_GATT_DISCONNECTED
            broadcastUpdate(intentAction)
         }
     }
     .....

 

또는 intent를 저장할때 원하는 데이터(상태 메세지나 read data)등을 extra로 넣어 전달할 수 있습니다.

private fun broadcastUpdate(action: String) {
     val intent = Intent(action)
     sendBroadcast(intent)
}
private fun broadcastUpdate(action: String, msg: String) {
     val intent = Intent(action)
     intent.putExtra(MSG_DATA,msg)
     sendBroadcast(intent)
}
private fun broadcastDataUpdate(action: String, data: String) {
    val intent = Intent(action)
    intent.putExtra(EXTRA_DATA,data)
    sendBroadcast(intent)
}

 

            ...
            if( newState == BluetoothProfile.STATE_CONNECTED ) {
                // update the connection status message
                intentAction = ACTION_GATT_CONNECTED
                broadcastUpdate(intentAction,"Connected")

                Log.d(TAG, "Connected to the GATT server")
                gatt.discoverServices()
            } else if ( newState == BluetoothProfile.STATE_DISCONNECTED ) {
                intentAction = ACTION_GATT_DISCONNECTED
                broadcastUpdate(intentAction,"Disconnected")
            }
            ...

 

원하는 action을 지정하여 broadcastReciever로 서비스에서 Repository로 이벤트를 전달하고,

이것을 다시 ViewModel과 View에서 Observing하여 UI를 변경하면 됩니다.

[Android/이론] - [안드로이드 MVVM] Repository 에서 ViewModel, View(Activity,Fragment)에 Event/Data 전달하기

 

 

 

BleService Disconnect

device를 disconnect하거나 disconnected되면, 이벤트를 (보내고)받아 unbind해줍니다.

  • Repository
/**
 * Disconnect Gatt Server
 */
fun disconnectGattServer() {
    mService?.disconnectGattServer()
}


 private var mGattUpdateReceiver: BroadcastReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            Log.d(TAG,"action ${intent.action}")
            when(intent.action){
               ...
                ACTION_GATT_DISCONNECTED->{
                    // unbind service
                    MyApplication.applicationContext().unbindService(mServiceConnection)
                    // ui update
                    isConnected.postValue(Event(false))
                    intent.getStringExtra(MSG_DATA)?.let{
                        statusTxt.postValue(Event(it))
                    }
                }
                ...
            }

        }
}

 

  • BleService
override fun onUnbind(intent: Intent?): Boolean {
   Log.d(TAG,"onUnbind called")
   return super.onUnbind(intent)
}

override fun onDestroy() {
   Log.d(TAG,"onDestroy called")
   super.onDestroy()
}

/**
 * Disconnect Gatt Server
 */
fun disconnectGattServer() {
   Log.d(TAG, "Closing Gatt connection")
   // disconnect and close the gatt
   if (bleGatt != null) {
       bleGatt!!.disconnect()
       bleGatt!!.close()
   }
   broadcastUpdate(ACTION_GATT_DISCONNECTED,"Disconnected")
}
...
private val gattClientCallback: BluetoothGattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
            super.onConnectionStateChange(gatt, status, newState)


            ...
            } else if ( newState == BluetoothProfile.STATE_DISCONNECTED ) {
                broadcastUpdate(ACTION_GATT_DISCONNECTED,"Disconnected")
            }
        }
        ...

 

Foreground Service로 수정하여 백그라운드에서 실행하기

Oreo 이상버전에서는 단순 startService()로 백그라운드에서 실행할 수 없고, Foreground Service를 사용하여 상단의 notification과 함께 백그라운드에서 실행할 수 있습니다.

그래서 bindService를 foreground service로 바꾸는 과정을 진행하였습니다.

  • Permission 추가
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

다음과 같이 manifest에 permission을 추가해 줍니다.

 

  • Foreground Service Action Intent 전달 및 메서드 실행

우선, 스캔된 디바이스를 클릭시 service를 실행, 디바이스를 connect하기 위해,

Repository에서 connectDevice메서드를 다음과 같이 변경하였습니다.

    /**
     * Connect to the ble device
     */
    fun connectDevice(device: BluetoothDevice?) {
        deviceToConnect = device
        startForegroundService()
    }
    private fun startForegroundService(){
        Intent(MyApplication.applicationContext(), BleGattService::class.java).also { intent ->
            intent.action = Actions.START_FOREGROUND
            MyApplication.applicationContext().startForegroundService(intent)
        }
    }
    fun stopForegroundService(){
        Intent(MyApplication.applicationContext(), BleGattService::class.java).also { intent ->
            intent.action = Actions.STOP_FOREGROUND
            MyApplication.applicationContext().startForegroundService(intent)
        }
    }

서비스에 start, stop action을 전해주는 코드입니다.

다른점은 Actions Object를 만들어서 action string을 따로 관리하였습니다.

object Actions {
    private const val prefix = "lilly.ble.mvvmservice"
    const val START_FOREGROUND = prefix + "startforeground"
    const val STOP_FOREGROUND = prefix + "stopforeground"
    const val DISCONNECT_DEVICE = prefix + "disconnectdevice"
    const val CONNECT_DEVICE = prefix + "disconnectdevice"
    const val START_NOTIFICATION = prefix + "startnotification"
    const val STOP_NOTIFICATION = prefix + "stopnotification"
    const val WRITE_DATA = prefix + "writedata"
    const val READ_CHARACTERISTIC= prefix + "readcharacteristic"
    const val READ_BYTES = prefix + "readbytes"
    const val GATT_CONNECTED = prefix + "gattconnected"
    const val GATT_DISCONNECTED = prefix + "gattdisconnected"
    const val STATUS_MSG = prefix + "statusmsg"
    const val MSG_DATA = prefix + "msgdata"
}

 

Service class에서 action을 받아 실행해주는 코드를 추가해주면 됩니다.

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Log.d(TAG, "Action Received = ${intent?.action}")
        // intent가 시스템에 의해 재생성되었을때 null값이므로 Java에서는 null check 필수
        when (intent?.action) {
            Actions.START_FOREGROUND -> {
                startForegroundService()
            }
            Actions.STOP_FOREGROUND -> {
                stopForegroundService()
            }
            Actions.DISCONNECT_DEVICE->{
                disconnectGattServer("Disconnected")
            }
            Actions.START_NOTIFICATION->{
                startNotification()
            }
            Actions.STOP_NOTIFICATION->{
                stopNotification()
            }
            Actions.WRITE_DATA->{
                myRepository.cmdByteArray?.let { writeData(it) }
            }

        }
        return START_STICKY
    }

 

  • ForegroundService 시작 및 중지
   private fun startForegroundService() {
        startForeground()
    }

    private fun stopForegroundService() {
        stopForeground(true)
        stopSelf()
    }
    private fun startForeground() {
        val channelId =
            createNotificationChannel()

        val notificationBuilder = NotificationCompat.Builder(this, channelId )
        val notificationIntent: Intent = Intent(this, MainActivity::class.java)
        val pendingIntent: PendingIntent = PendingIntent.getActivity(this,0,notificationIntent,0)
        val notification = notificationBuilder.setOngoing(true)
            .setSmallIcon(R.mipmap.ic_launcher)
            .setContentTitle("Service is running in background")
            .setContentText("Tap to open")
            .setPriority(PRIORITY_MIN)
            .setCategory(Notification.CATEGORY_SERVICE)
            .setContentIntent(pendingIntent)
            .build()

        startForeground(1, notification)

        //connect
        connectDevice(myRepository.deviceToConnect)

    }
    private fun createNotificationChannel(): String{
        val channelId = "my_service"
        val channelName = "My Background Service"
        val chan = NotificationChannel(channelId,
            channelName, NotificationManager.IMPORTANCE_HIGH)
        chan.lightColor = Color.BLUE
        chan.importance = NotificationManager.IMPORTANCE_NONE
        chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
        val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        service.createNotificationChannel(chan)
        return channelId
    }

다음과 같이 Notification을 만들고, startForeground()를 통해 시작할 수 있습니다. 시작과 동시에 디바이스를 연결하는 코드를 추가 하였습니다.

또, 중지는 stopSelf(), stopForeground(true)를 통해 noti를 제거하여 중지할 수 있습니다.

 

그 외에 notification & write등을 service intent에 action으로 전달하여 실행하면 됩니다.

이를 실행하여 notify까지해보면, logcat을 통해 백그라운드에서 데이터를 받음을 확인할 수 있었습니다.

다만, ui업데이트에 관한 과제가 남아있는데요. read된 data를 출력하도록 ui를 만들었는데, 어플이 보이지 않는 경우 받은 데이터들이 ui로 출력되지 않습니다. 어플이 보이지 않을 경우 데이터를 홀딩해놓았다가 한번에 업데이트 시켜야 할 것 같은데, 시간나면 다시 업데이트 하도록 하겠습니다.

 

이 코드는 github에 올려놓았으니 참고해 주세요.

 

참고 문서

 

 

 

 

 

 

728x90
반응형