Android MP3 재생 앱 만들어보기

timothy jeong·2021년 11월 16일
0

Android with Kotlin

목록 보기
46/69

MP3 음원을 재생하는 서비스를 가진 앱 모듈을 만들고, 다른 모듈에서 해당 앱의 서비스를 호출해서 사용하도록 만들어보자. AIDL 과 메시지를 모두 이용해보자.

서비스를 가진 모듈

AIDL 인터페이스 구현 서비스

우선 음원을 재생하는 기능을 가진 outer 모듈을 만들자. MyAidlInterface.adil 파일을 만들고, 이 파일은 음원을 시작, 종료 그리고 음원의 길이를 반환하는 함수를 갖는 인터페이스로 설계한다.

package com.example.outer;

interface MyAidlInterface {
    int getMaxDuration();
    void start();
    void stop();
}

AIDL 을 만들고 나서는 Build -> makemodule...을 눌러서 다시 빌드해줘야 코틀린 코드에서 인식할 수 있다.

aidl 인터페이스를 만들었다면 이를 구현하는 클래스가 있어야 한다. 아래와 같은 설계로 만들며,

class MyAIDLService: Service() {

    lateinit var player: MediaPlayer
    
    // 서비스가 만들어지면서 MedialPlayer 인스턴스를 만든다.
    override fun onCreate() {}

    // 서비스가 destroy 될때 MedialPlayer 자원을 release 해준다.
    override fun onDestroy() {}

    // 서비스가 시작되면 MyAidlInterface 를 서비스를 호출한 컴포넌트에 반환해준다. 여기서 해당 인터페이스의 함수들을 구현한다.
    override fun onBind(intent: Intent?): IBinder? {}

    // 서비스가 비정상적으로 멈추는 경우를 핸들링해준다.
    override fun stop() {}
        }
     }
}

실제 구현 코드는 다음과 같다.

class MyAIDLService: Service() {

    lateinit var player: MediaPlayer

    // 서비스가 만들어지면서 MedialPlayer 인스턴스를 만든다.
    override fun onCreate() {
        super.onCreate()
        player = MediaPlayer()
    }

    // 서비스가 destroy 될때 MedialPlayer 자원을 release 해준다.
    override fun onDestroy() {
        player.release()
        super.onDestroy()
    }

    // 서비스가 시작되면 MyAidlInterface 를 서비스를 호출한 컴포넌트에 반환해준다. 여기서 해당 인터페이스의 함수들을 구현한다.
    override fun onBind(intent: Intent?): IBinder? {
        return object : MyAidlInterface.Stub() {

            override fun getMaxDuration(): Int = if (player.isPlaying) player.duration else 0

            override fun start() {
                if (!player.isPlaying) {
                    player = MediaPlayer.create(this@MyAIDLService, R.raw.music)
                    try {
                        player.start()
                    } catch (e: Exception) {
                        e.printStackTrace()
                    }
                }
            }

            override fun stop() {
                if (player.isPlaying)
                    player.stop()
            }
        }
    }
}

메시지 서비스

그리고 메신저 서비스를 설계한다. 메시지로 10이 전송되면 duration 을 답신하고 음악을 시작, 20이 전송되면 음악을 멈추는 메시지 핸들러가 핵심이다.

class MyMessengerService : Service() {

    lateinit var messenger: Messenger
    lateinit var replyMessenger: Messenger
    lateinit var player: MediaPlayer

    override fun onCreate() { //상동 }

    override fun onDestroy() { //상동 }
    

    inner class IncomingHandler(
        context: Context,
        private val applicationContext: Context = context.applicationContext
    ) : Handler(Looper.getMainLooper()){
        override fun handleMessage(msg: Message) {}}
        
    // messenget.binder 리턴
    override fun onBind(intent: Intent): IBinder {
        messenger = Messenger(IncomingHandler(this))
        return messenger.binder
    }
}

서로 다른 프로세스간 통신을 가정했기 때문에 duration 은 Bundel() 객체에 담아서 전송하도록 하였다.

    inner class IncomingHandler(
        context: Context,
        private val applicationContext: Context = context.applicationContext
    ) : Handler(Looper.getMainLooper()){
        override fun handleMessage(msg: Message) {
            when(msg.what) {
                10 -> {
                    replyMessenger = msg.replyTo // 객체 정의
                    if (!player.isPlaying) {
                        player = MediaPlayer.create(this@MyMessengerService, R.raw.music)
                        try {
                            // 지속시간 전송
                            val replyMsg = Message()
                            replyMsg.what = 10
                            val replyBundle = Bundle()
                            replyBundle.putInt("duration", player.duration)
                            replyMsg.obj = replyBundle
                            replyMessenger.send(replyMsg)
                            // 음악 재생
                            player.start()
                        } catch (e: Exception) {
                            e.printStackTrace()
                        }
                    }
                } 20 -> {
                    if (player.isPlaying) player.stop()
                } else -> super.handleMessage(msg)
            }
        }
    }

메니페스트 등록

서비스 컴포넌트를 2개 만들었으므로 이들은 모두 메니페스트에 등록되어야 정상적으로 작동한다. 그런데 외부 앱에서 호출되는것이 가정되었으므로 intent-filter 를 반드시 설정하여야 한다.

        <service android:name=".MyMessengerService"
            android:exported="true"
            android:enabled="true" >
            <intent-filter>
                <action android:name="ACTION_SERVICE_Messenger"/>
            </intent-filter>
        </service>
        <service android:name=".MyAIDLService"
            android:exported="true"
            android:enabled="true">
            <intent-filter>
                <action android:name="ACTION_SERVICE_AIDL"/>
            </intent-filter>
        </service>

서비스를 호출하는 모듈

위에서 만들어진 서비스를 호출하는 service 모듈을 만들자.

레이아웃

이 앱은 main_activity 레이아웃만 사용할 것이다. 위에 만들어진 aidl, 메신저 서비스는 하는 역할이 동일하다. 다만, 공부하는 차원에서 둘다 구현했으므로 둘을 조작할 수 있는 버튼을 따로따로 만들어야 한다. 여기선 이미지뷰에 clickable 속성을 주겠다.

이후 코드에서 사용될 id 와 click 가능한 이미지를 표시할 수 있는 정도로만 레이아웃 코드를 간략하게 적었다.

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#28353b">

    <ImageView
        android:id="@+id/messengerPlay"
        android:clickable="true"/>

    <TextView
        android:id="@+id/messengerTitle" />
    <ImageView
        android:id="@+id/messengerStop"
        android:clickable="true"/>

    <ProgressBar
        android:id="@+id/messengerProgress"
        style="@style/Base.Widget.AppCompat.ProgressBar.Horizontal" />


    <ImageView
        android:id="@+id/aidlPlay"
        android:clickable="true"/>

    <TextView
        android:id="@+id/aidlTitle" />
    <ImageView
        android:id="@+id/aidlStop"
        android:clickable="true"/>

    <ProgressBar
        android:id="@+id/aidlProgress"
        style="@style/Base.Widget.AppCompat.ProgressBar.Horizontal" />
</RelativeLayout>

액티비티 코드

이제는 액티비티 코드에서 서비스를 불러오고, 각 서비스의 함수와 레이아웃의 이미지 버튼을 연동해야한다. 우선 AIDL 을 쓰기 위해서는 동일한 인터페이스가 servie 모듈에도 존재해야한다.

메신저나, AIDL 서비스를 동시에 이용하는 것은 충돌이 우려된다. 둘다 동일한 music 파일을 참조하고 있기 때문이다. 따라서 clickable 을 조절하여 이런 충돌을 방지하도록 하는게 현명할 것이다.

enum class ConnectionMode() {
    NONE, MESSENGER, AIDL
}

class MainActivity : AppCompatActivity() {
    var conMode : ConnectionMode = ConnectionMode.NONE
    ...

    fun changeClickAccess() = when (conMode) {
        ConnectionMode.MESSENGER -> {
            binding.messengerPlay.isEnabled = false
            binding.aidlPlay.isEnabled = false
            binding.messengerStop.isEnabled = true
            binding.aidlStop.isEnabled = false
        }
        ConnectionMode.AIDL -> {
            binding.messengerPlay.isEnabled = false
            binding.aidlPlay.isEnabled = false
            binding.messengerStop.isEnabled = false
            binding.aidlStop.isEnabled = true
        }
        else -> {
            //초기상태. stop 상태. 두 play 버튼 활성상태
            binding.messengerPlay.isEnabled = true
            binding.aidlPlay.isEnabled = true
            binding.messengerStop.isEnabled = false
            binding.aidlStop.isEnabled = false

            binding.messengerProgress.progress = 0
            binding.aidlProgress.progress = 0
        }
    }

}

액티비티가 create 되는 시점에 각각의 버튼들이 제역할을 하도록 clickListener 를 설정해야한다. 그러기 위해서는 이 액티비티 자체가 서비스와 바인딩 되어있어야 한다.


class MainActivity : AppCompatActivity() {

    var conMode : ConnectionMode = ConnectionMode.NONE
    lateinit var binding: ActivityMainBinding

    lateinit var messenger: Messenger
    lateinit var replyMessenger: Messenger
    var messengerJob: Job? = null

    var aidlService: MyAidlInterface? = null
    var aidlJob: Job? = null

    override fun onCreate(savedInstanceState: Bundle?) {

        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        // 각 clickable image의 리스너 설정과 서비스 바인딩 담당
        onCreateMessengerService()
        onCreateAidlService()
    }
    
    // 메시지 서비스를 바인딩하고 클릭 리스너를 설정하는 역할
    private fun onCreateMessengerService() {
        replyMessenger = Messenger(HandlerReplyMsg())
        binding.messengerPlay.setOnClickListener {
            val intent = Intent("ACTION_SERVICE_Messenger")
            intent.setPackage("com.example.outer")
            bindService(intent, messengerConnection, Context.BIND_AUTO_CREATE)
        }
        
        binding.messengerStop.setOnClickListener {
            val msg = Message()
            msg.what = 20
            messenger.send(msg)
            unbindService(messengerConnection)
            messengerJob?.cancel()
            conMode = ConnectionMode.NONE
            changeClickAccess()
        }
    }

    // AIDL 서비스를 바인딩하고 클릭 리스너를 설정하는 역할
    private fun onCreateAidlService() {
        binding.aidlPlay.setOnClickListener {
            val intent = Intent("ACTIOn_SERVICE_AIDL")
            intent.setPackage("com.example.outer")
            bindService(intent, aidlConnection, Context.BIND_AUTO_CREATE)
        }
        binding.aidlStop.setOnClickListener {
            aidlService!!.stop()
            unbindService(aidlConnection)
            aidlJob?.cancel()
            conMode=ConnectionMode.NONE
            changeClickAccess()
        }
    }
    
    // 메신저 서비스를 바인딩 할때 이용할 connection 을 만듦. 커넥션 되자마자 10 메시지를 보내서 duration 을 받고, 음악을 시작시킴
     val messengerConnection: ServiceConnection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
            messenger = Messenger(service)
            val msg = Message()
            msg.replyTo = replyMessenger
            msg.what = 10
            messenger.send(msg)
            conMode = ConnectionMode.MESSENGER
        }

        override fun onServiceDisconnected(name: ComponentName?) {
            Log.d("INFO", "MESSENGER SERVICE DISCONNECTED")
        }
    }

     // AIDL 서비스를 바인딩 할때 이용할 connection 을 만듦. 메시지를 보낼 필요없이 AIDL 인터페이스에 작성되어 있는 start 함수를 이용함.
    private val aidlConnection: ServiceConnection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
            aidlService = MyAidlInterface.Stub.asInterface(service)
            aidlService!!.start()
            binding.aidlProgress.max = aidlService!!.maxDuration
            // 백그라운드 스코프에서 progress 를 증가시키는 작업을 진행함.
            val backgroundScope = CoroutineScope(Dispatchers.Default + Job())
            aidlJob=backgroundScope.launch {
                while (binding.aidlProgress.progress < binding.aidlProgress.max) {
                    delay(1000)
                    binding.aidlProgress.incrementProgressBy(1000)
                }
            }
            conMode=ConnectionMode.AIDL
            changeClickAccess()
        }
        
        // 메시지 서비스에서 필요한 답신 메시지 핸들러를 만듦, AIDL이 백그라운드에서 하는 작업을 동일하게 처리함. 대신 답신을 받아서 unbind를 여기서 처리할 수 있게 함.
        inner class HandlerReplyMsg : Handler(Looper.getMainLooper()) {
        override fun handleMessage(msg: Message) {
            super.handleMessage(msg)
            when(msg.what) {
                10 -> {
                    //play 된후 duration 전송되면...
                    val bundle = msg.obj as Bundle
                    bundle.getInt("duration").let {
                        when {
                            it > 0 -> {
                                binding.messengerProgress.max = it

                                val backgroundScope = CoroutineScope(Dispatchers.Default + Job())
                                messengerJob=backgroundScope.launch {
                                    while (binding.messengerProgress.progress < binding.messengerProgress.max) {
                                        delay(1000)
                                        binding.messengerProgress.incrementProgressBy(1000)
                                    }
                                }
                                changeClickAccess()
                            }
                            else -> {
                                conMode=ConnectionMode.NONE
                                unbindService(messengerConnection)
                                changeClickAccess()
                            }
                        }
                    }
                }
            }
        }
    }
}

유저가 언제나 정확히 stop 버튼을 누르고 앱을 종료하는건 아니다. 우리는 MediaPlayer 라는 리소스를 가져다 쓰고 있으므로 앱이 의도치 않게 종료될때 리소스를 제대로 해제하기 위한 코드가 필요하다. onStop() 함수에서 구현하는게 좋을것 같다. 일반적인 음악앱은 포커스를 잃은 onPause() 상황에서도 백그라운드에서 음악을 재생하기 때문이다.

    override fun onStop() {
        super.onStop()
        if(conMode == ConnectionMode.MESSENGER){
            onStopMessengerService()
        }else if(conMode == ConnectionMode.AIDL){
            onStopAIDLService()
        }
        conMode=ConnectionMode.NONE
        changeClickAccess()

        onStopMessengerService()
    }
    
    // 메신저는 20 메시지를 서비스에 보내서 stop 한다.
    private fun onStopMessengerService() {
        val msg =Message()
        msg.what = 20
        messenger.send(msg)
        unbindService(messengerConnection)
    }
    
    // 미리 정의한 sopt() 함수를 호출하고 unbind 한다.
    private fun onStopAIDLService() {
        aidlService!!.stop()
        unbindService(aidlConnection)
    }

잡 스케줄러 적용

앱이 실행되면 알림을 띄우는 스케줄러를 만들자

class MyJobService: JobService() {

    override fun onStartJob(params: JobParameters?): Boolean {
        Log.d("INFO", "JobStart")
        val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                "oneId", "oneName",
                NotificationManager.IMPORTANCE_DEFAULT
            )
            channel.description = "oneDesc"
            manager.createNotificationChannel(channel)
            Notification.Builder(this, "oneId")
        } else {
            Notification.Builder(this)
        }.run {
            setSmallIcon(android.R.drawable.ic_notification_overlay)
            setContentTitle("JobScheduler Title")
            setContentText("Content message")
            setAutoCancel(true)
            manager.notify(1, build())
        }
        return false
    }

    override fun onStopJob(params: JobParameters?): Boolean {
        return true
    }
}

이 job을 액티비티코드의 onCreate() 에서 적용시켜주자

    override fun onCreate(savedInstanceState: Bundle?) {

        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        onCreateMessengerService()
        onCreateAidlService()

        onCreateJobScheduler()
    }

    private fun onCreateJobScheduler() {
        val jobScheduler: JobScheduler? = getSystemService(JOB_SCHEDULER_SERVICE) as JobScheduler
        val builder = JobInfo.Builder(1, ComponentName(this, MyJobService::class.java))
        builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
        val jobInfo = builder.build()
        jobScheduler!!.schedule(jobInfo)
    }

profile
개발자

0개의 댓글