본문 바로가기
안드로이드 프로젝트/유튜브 음정 조절 어플리케이션

[Android] # 문제 해결 - 5 앱 이슈 해결

by joh9911 2023. 7. 1.

 

 

비정상 종료 그래프

 

6월 28일쯤 비정상 종료 횟수가 치솟았습니다.

 

오류의 목록은 다음과 같습니다.

 

비정상 종료 원인 목록
비정상 종료 원인 목록

 

대부분 서비스 관련 오류입니다.

 

오류 횟수가 그렇게 많지가 않았어서, 오류 원인을 명확히 알았음에도 불구하고 미뤄두었습니다.

 

이번에 뷰모델을 공부하고 적용해 보면서 차근차근 고쳐나가자 생각하고 있었거든요.

 

 

갑자기 횟수가 많아져서, 오류부터 해결하고자 합니다.

 

 

 

첫 번째 오류

 

foreground 오류
foreground 오류

 

해당 오류를 해결하려다가 머릿속에 혼란이 와, 자그마치 3일 동안 삽질을 했네요.

일단 해당 오류는 백그라운드 상태에서, 포그라운드 서비스를 시작하려 할 때 나타나는 예외입니다.

 

 

시작 지점이 exoPlayer의 onPlayerStateChanged입니다.

이는 exoPlayer의 상태가 바뀔 때 호출되는 콜백 리스너이며, 저는 exoPlayer의 상태가 바뀔 때마다

포그라운드를 실행시키는 함수를 작성했었습니다.

 

 

동영상 클릭 -> 바로 홈키 누름 -> 동영상 url 변환 -> onPlayerStateChanged  호출 ->  포그라운드 서비스 실행

다음과 같은 과정에서,

 

백그라운드 상태일 때 포그라운드를 실행하려 하기 때문에 해당 오류가 발생한 것이라 생각합니다.

 

해당 과정을 여러 번 반복해 보니 같은 오류가 발생하는 것을 확인할 수 있었습니다.

오류 확인
오류 확인

 

 

동영상을 클릭하는 시점에서, 포그라운드를 실행하는 코드를 작성하였습니다.

 

fun playVideo(videoData: VideoData){
    if (exoPlayer.currentMediaItem != null)
        exoPlayer.removeMediaItem(0)

    currentVideoData = videoData
    currentVideoThumbnailBitmap = null

    playerServiceListener?.playerViewInvisible()
    val youtubeUrl = "https://www.youtube.com/watch?v=${videoData.videoId}".trim()
    startStream(youtubeUrl)
}


private fun startStream(url: String){
    playerServiceListener?.onIsPlaying(0)
    streamingCancel()
    
    // 포그라운드 실행
    startNotification(0)
    ../
}

 

이러고 다시 실험을 해보니, 오류가 발생하지 않았습니다.

 

하지만 제가 혼란을 겪었던 것은 이 부분이 아니었습니다.

 

 

 

1. 알림 유지 문제

 

아주 예전에 포그라운드 서비스를 사용해 본 적이 있습니다.

 

장바구니를 구현할 때, 포그라운드 서비스를 통해 알림을 띄워,


장바구니의 개수를 띄우는 기능을 구현하려 했었습니다.

 

그때 기억으로는, 포그라운드 서비스를 실행할 시, 해당 알림은 없어지지 않고 계속 유지되는 것으로 알고 있습니다.

 

 

다음은 포그라운드 서비스를 시작하는 코드입니다.

 

fun startNotification(type: Int) {
    if (currentVideoData != null){
        val serviceIntent = Intent(this, VideoService::class.java)

        val notification = when (type) {
            0 -> createPrepareNotification()
            1 -> createPlayingNotification()
            2 -> createPausedNotification()
            else -> createFinishedNotification()
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            applicationContext.startForegroundService(serviceIntent)
        }
        startForeground(NOTIFICATION_ID, notification)
    }
}

 

인자로 받는 type에 따라, 각 종류의 notification을 띄워줍니다.

return NotificationCompat.Builder(this,CHANNEL_ID)
    .setContentTitle(currentVideoData?.title)
    .setContentText(currentVideoData?.channelTitle)
    .setLargeIcon(bitmapThumbnail)
    .setSmallIcon(R.mipmap.app_icon)
    .setOngoing(isOnGoing)

 

Notification 코드 중 일부입니다.

저 밑에 setOngoing을 통해 알림을 스와이프를 통해 지울 수 있는지 여부를 결정할 수 있습니다.


동영상 url을 변환할 때는 type가 0인 prepareNotification을 띄우고,

동영상이 재생될 때는 type가 1인 playingNotification을 띄웁니다.

 

각각의 setOngoing 값은 true로 설정했습니다.

 

 

그러나, prepareNotification은 스와이프를 통해 지워지며,

playingNotification은 지울 수 없었습니다.

 

실험을 몇 번 해본 결과, setOngoing 설정은 전혀 영향이 없었습니다.

 

 

PrepareNotification
PrepareNotification

 

PlayingNotification
PlayingNotification

 

글을 쓰는 지금까지도 원인을 찾지 못했습니다. 원인이 뭘까요..?

 

 

한 가지 추측은, exoPlayer가 서비스에서 작동 중이라는 것을 포그라운드 서비스가 인식한다는 것입니다.

백그라운드 작업 중임을 인식하고, 알림 지우기를 제한한다는 것이 제 생각이었습니다.

근데 그렇게 따지면 저 prepareNotification도 동영상 url을 변환하기 직전에 생성되는데,

이것도 네트워킹 작업이라, 백그라운드 작업에 포함되지 않나요..?

 

아무튼 동영상 변환 직전에 포그라운드 서비스를 실행시켜 주는 것으로 해당 오류를 해결했습니다.

 

 

두 번째 오류

두 번째 오류
두 번째 오류

 

<VideoService.kt>

lateinit var currentVideoData: VideoData

fun playVideo(videoData: VideoData){
    if (exoPlayer.currentMediaItem != null)
        exoPlayer.removeMediaItem(0)

    currentVideoData = videoData
    currentVideoThumbnailBitmap = null

    playerServiceListener?.playerViewInvisible()
    val youtubeUrl = "https://www.youtube.com/watch?v=${videoData.videoId}".trim()
    startStream(youtubeUrl)
}

 

다음과 같이 서비스에 currentVideoData 변수를 설정해 놨었습니다.

 

동영상을 클릭할 때 동영상 데이터를 인자로 받아오며, currentVideoData를 초기화해 줍니다.


오류 스택을 봤을 때 onStartCommand로부터 발생한 것으로 보아,

currentVideoData가 초기화되기 전에 notification으로부터 재생 버튼이 눌린 것으로 추측됩니다.

 

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        when (intent?.action) {
            Actions.MINUS -> {
                serviceListenerToActivity?.clickMinus()
            }
            Actions.PREV -> {
                serviceListenerToActivity?.clickPrev()
            }
            Actions.REPLAY -> {
                exoPlayer.seekTo(0)
            }
            Actions.PLAY -> {
                exoPlayer.playWhenReady = !exoPlayer.isPlaying
            }
            Actions.NEXT -> {
                serviceListenerToActivity?.clickNext()
            }
            Actions.PLUS -> {
                serviceListenerToActivity?.clickPlus()
            }
            Actions.INIT -> {
                serviceListenerToActivity?.clickInit()
            }
        }
        return START_STICKY
}

 

START_STICKY로 설정을 해놔서 시스템이 앱을 강제 종료시켰을 경우, 서비스가 자동으로 다시 시작하게 됩니다.

제 예상에는 notification이 띄워진 채로 앱이 강제 종료되고 서비스가 다시 시작되었을 때,

 

사용자가 재생 버튼을 눌러서 발생한 오류 같습니다.


currentVideoData 변수를 null이 가능한 형태로 바꿔주었습니다.

또한 Nofitication을 시작하는 코드에 null의 여부를 확인하는 코드를 넣어주었습니다.

 

private var currentVideoData: VideoData? = null

    fun startNotification(type: Int) {
        if (currentVideoData != null){
            val serviceIntent = Intent(this, VideoService::class.java)

            val notification = when (type) {
                0 -> createPrepareNotification()
                1 -> createPlayingNotification()
                2 -> createPausedNotification()
                else -> createFinishedNotification()
            }

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                applicationContext.startForegroundService(serviceIntent)
            }
            startForeground(NOTIFICATION_ID, notification)
        }
    }

 

 

세 번째 오류

세 번째 오류
세 번째 오류

 

이전까지는 서비스로 액티비티와 동영상 프레그먼트의 context를 전달해 주었습니다.

 

    fun initActivity(param: Activity) {
        activity = param
    }
    fun initPlayerFragment(param: PlayerFragment){
        playerFragment = param
    }

 

변수 activity, playerFragment를 통해 해당 컴포넌트의 뷰에 접근했었습니다.

 

너무나도 무식한 방식이었습니다.


 

Interface를 사용하여 서비스와 액티비티의 종속성을 해결할 수 있었습니다.

 

// 인터페이스 리스너 구현
interface ServiceListenerToActivity {
    fun clickMinus()
    fun clickPlus()
    fun clickInit()
    fun clickNext()
    fun clickPrev()
    fun setPitch()
    fun setTempo()
    fun showStreamFailMessage()
}

interface PlayerServiceListener {
    fun onIsPlaying(type: Int)
    fun onStateEnded()
    fun playerViewInvisible()
    fun playerViewVisible()
}


// 서비스 내 코드

    private var playerServiceListener: PlayerServiceListener? = null

    private var serviceListenerToActivity: ServiceListenerToActivity? = null

    fun setServiceListenerToActivity(listener: ServiceListenerToActivity?){
        serviceListenerToActivity = listener
    }

    fun setPlayerServiceListener(listener: PlayerServiceListener?) {
        playerServiceListener = listener
    }

    fun getServiceListenerToActivity(): ServiceListenerToActivity? {
        return serviceListenerToActivity
    }

    fun getPlayerServiceListener(): PlayerServiceListener? {
        return playerServiceListener
    }
    
    fun disconnectPlayerServiceListener(){
    	playerServiceListener = null
    }

 

서비스에 각 필요한 리스너 변수를 생성했고, 리스너를 등록하고 상태를 확인하는 매소드를 만들었습니다.

 

 

class Activity: AppCompatActivity(), ServiceListenerToActivity

class PlayerFragment: Fragment(), PlayerServiceListener{
	
    // 서비스와 이어주기
    private fun initListener(){
    activity.isServiceBound.observe(viewLifecycleOwner) { isBound ->
        if (isBound) {
            activity.videoService?.setPlayerServiceListener(this)
        }
    }
    
    override fun onDestroy() {
    super.onDestroy()
    if (activity.videoService!!.getPlayerServiceListener() == this){
        activity.videoService!!.disconnectPlayerServiceListener()
        activity.videoService!!.stopForegroundService()
        activity.videoService!!.exoPlayer.stop()
        activity.videoService!!.streamingCancel()
    	}
    }
}

 

각 컴포넌트의 onCreate에서 서비스의 리스너와 연결해 주었습니다.

 

PlayerFragment는 동영상을 클릭할 때마다 replace 되는데,

이전의 playerFragment의 onDestroy가 현재의 playerFragment의 onCreate보다 늦게 호출될 때가 있었습니다.

따라서 리스너의 등록한 주체를 확인하는 코드를 넣어 예외처리를 해주었습니다.

 

 

네 번째 오류

네 번째 오류
네 번째 오류

 

리사이클러뷰 어댑터에서 발생한 오류였습니다.

 

리사이클러뷰 데이터가 변경되는 동안에 클릭을 하여 발생한 문제라고 생각합니다.

 

inner class MyViewHolder(private val binding: SearchRecyclerItemBinding) :
    RecyclerView.ViewHolder(binding.root) {
    init {
        itemView.setOnClickListener {
        	// 포지션이 유효한지 확인
            if (bindingAdapterPosition != RecyclerView.NO_POSITION)
                itemClickListener.onClick(it, bindingAdapterPosition)
        }
    }
    fun bind(searchKeywordData: String) {
        binding.suggestionKeyword.text = searchKeywordData
    }
}

 

위와 같이 bindAdapterPosition이 유효한 값 인지 확인하는 코드를 작성했습니다.

댓글