[Android] MPAndroidChart Data와 Legend 동기화 문제

김병수·2021년 7월 13일
0
post-thumbnail

오늘은 MPAndroidChart 라이브러리를 사용하면서 겪었던 오류들 중에서 Data와 Legend를 실시간으로 변경해주는 과정에서 발생한 오류와 해결방법을 공유하고자 한다.

오류가 발생했던 상황은..

오류가 발생했던 상황은 다음과 같다.

  • Background Thread A가 일정 주기마다 차트 데이터를 서버로부터 받아온다.
  • A는 Handler.sendMessage()를 통해 Handler에게 메시지 B를 보낸다.
  • 메시지 B가 Looper에 의하여 Handler에게 전달되면, Handler는 새로운 차트 데이터를 UI에 반영한다.
  • 이러한 상황에서 사용자와 UI의 상호작용에 의하여, 차트의 Legend가 변경될 수 있다.

이를 이해하기 쉽게 이미지로 나타내면 다음과 같다.

그래서 오류가 뭔데??


위의 이미지는 오류가 발생했을 때의 Logcat이다.
사용자와 UI의 상호작용에 따라 차트의 Legend를 변경해줄 때, 무작위로 위와 같은 오류가 발생했다.
구글링과 MPAndroidChart Github를 뒤적뒤적 해본 결과, 이 오류는 차트가 랜더링될 때, 차트의 데이터가 변경되었기 때문에 발생한다는 것을 알게 되었다.

다시 위에서 언급했던 상황을 살펴보자.

  • Background Thread가 서버로부터 데이터를 받아오고, Handler가 이 데이터를 UI에 반영한다.
  • 그리고 사용자와 UI의 상호작용에 의하여 차트의 Legend가 변경될 수 있다.

처음에는 Handler.handleMessage()와 Legend를 변경하는 작업 모두 Main(UI) Thread에서 수행되기 때문에, 이 두 작업이 서로 영향을 줄 수 있을 것이라고는 생각하지 못했다.
그런데 생각해보면 차트의 데이터를 바꾸거나 Legend를 변경한다는 것은, 차트의 변수를 변경하고 이를 UI에 적용시킨다는 것이 된다.
그리고 이는 곧 다음과 같은 상황이 가능하다는 말이 된다.

  1. 차트의 데이터가 변경됨에 따라 차트가 렌더링 되는 도중에, 차트의 Legend가 변경될 수 있다.
  2. 차트의 Legend가 변경됨에 따라 차트가 렌더링 되는 도중에, 차트의 데이터가 변경될 수 있다.

여기까지 생각했을 때, 나는 이 오류의 원인이 1번과 2번 모두 될 수 있을 것이라고 생각했다.
그렇게 생각한 이유는 MPAndroidChart Github의 Issue에 게시되어 있는 글에서 이와 비슷한 내용이 존재했기 때문이다. (https://github.com/PhilJay/MPAndroidChart/issues/2962)

물론 나의 부족한 영어 실력과 지식 때문에 이 오류의 원인이 내가 생각한바와 다를 수 있다.
하지만 나는 1번과 2번 상황이 발생하지 않도록 코드를 수정했을 때, 이 오류가 다시 발생하지 않음을 확인했고, 따라서 1, 2번의 상황이 이 오류의 원인이라고 99% 확신했었다. (이때까지는..)

이제 문제를 해결해보자

문제를 해결하기 전의 코드는 다음과 같은 구조로 되어있었다.

val myHandler: MyHandler = MyHandler()

inner class MyHandler : Handler() {
    override fun handleMessage(msg: Message) {
        super.handleMessage(msg)

        val bundle: Bundle = msg.data
        if (!bundle.isEmpty) {
            // 여기서 새로운 데이터를 차트에 반영함.
        }
    }
}

inner class MyThread : Thread() {
    var stopFlag = false
    
    override fun run() {
    	while (!stopFlag) {
            val message = myHandler.obtainMessage()
            val bundle: Bundle = Bundle()
                
            // 여기서 서버로부터 새로운 데이터를 받아옴.
            
            message.data = bundle
            // MyHandler에게 메시지를 보냄.
            myHandler.sendMessage(message)
            sleep(300)
        }
    }

    fun threadStop(flag: Boolean) {
        this.stopFlag = flag
    }
}

binding.changeLegendBtn.setOnClickListener {
    changeChartLegend(legendType)
}

fun changeChartLegend(legendType: Int) {
    val legendList: List<LegendEntry> = when(legendType) {
        MainIndicator.BOLLINGER_BANDS -> {
            binding.priceChart.legend.isEnabled = true
            val upperLegend = LegendEntry()
            upperLegend.label = "Upper"
            upperLegend.formColor = Color.rgb(50, 51, 255)
            val middleLegend = LegendEntry()
            middleLegend.label = "Middle"
            middleLegend.formColor = Color.rgb(53, 153, 101)
            val lowerLegend = LegendEntry()
            lowerLegend.label = "Lower"
            lowerLegend.formColor = Color.rgb(255, 204, 1)
            listOf(upperLegend, middleLegend, lowerLegend)
        }
        MainIndicator.ENVELOPES -> {
            binding.priceChart.legend.isEnabled = true
            val upperLimitLegend = LegendEntry()
            upperLimitLegend.label = "상한선"
            upperLimitLegend.formColor = Color.rgb(101, 204, 51)
            val baselineLegend = LegendEntry()
            baselineLegend.label = "중심선"
            baselineLegend.formColor = Color.rgb(51, 50, 203)
            val lowerBoundLegend = LegendEntry()
            lowerBoundLegend.label = "하한선"
            lowerBoundLegend.formColor = Color.rgb(255, 51, 156)
            listOf(upperLimitLegend, baselineLegend, lowerBoundLegend)
        }
    }
    
    binding.priceChart.legend.apply {
        setCustom(priceChartLegendList)
        textColor = Color.WHITE
        verticalAlignment = Legend.LegendVerticalAlignment.TOP
        horizontalAlignment = Legend.LegendHorizontalAlignment.LEFT
        orientation = Legend.LegendOrientation.HORIZONTAL
        setDrawInside(true)
    }
}

여기서 나는 차트 데이터 갱신과 Legend 변경 작업을 한 번에 처리해줘야 하므로,
changeChartLegend() 내의 legendList를 전역변수로 변경하고,
changeChartLegend() 함수에서는 legendList 값을 변경하는 작업만 수행하도록 수정했다.
그리고 변경된 legendList는 차트 데이터 갱신 작업이 이루어지는 MyHandler.handleMessage()에서 차트에 적용되도록 수정했다.

코드는 다음과 같다.

val myHandler: MyHandler = MyHandler()
val legendList: ArrayList<LegendEntry> = ArrayList()

inner class MyHandler : Handler() {
    override fun handleMessage(msg: Message) {
        super.handleMessage(msg)

        val bundle: Bundle = msg.data
        if (!bundle.isEmpty) {
            // 여기서 새로운 데이터를 차트에 반영함.
            
            // 그 후에 Legend를 변경함.
            binding.priceChart.legend.apply {
                setCustom(priceChartLegendList)
                textColor = Color.WHITE
                verticalAlignment = Legend.LegendVerticalAlignment.TOP
                horizontalAlignment = Legend.LegendHorizontalAlignment.LEFT
                orientation = Legend.LegendOrientation.HORIZONTAL
                setDrawInside(true)
            }
        }
    }
}

inner class MyThread : Thread() {
    var stopFlag = false
    
    override fun run() {
    	while (!stopFlag) {
            val message = myHandler.obtainMessage()
            val bundle: Bundle = Bundle()
                
            // 여기서 서버로부터 새로운 데이터를 받아옴.
            
            message.data = bundle
            // MyHandler에게 메시지를 보냄.
            myHandler.sendMessage(message)
            sleep(300)
        }
    }

    fun threadStop(flag: Boolean) {
        this.stopFlag = flag
    }
}

binding.changeLegendBtn.setOnClickListener {
    changeChartLegend(legendType)
}

fun changeChartLegend(legendType: Int) {
    val tempList: List<LegendEntry> = when(legendType) {
        MainIndicator.BOLLINGER_BANDS -> {
            binding.priceChart.legend.isEnabled = true
            val upperLegend = LegendEntry()
            upperLegend.label = "Upper"
            upperLegend.formColor = Color.rgb(50, 51, 255)
            val middleLegend = LegendEntry()
            middleLegend.label = "Middle"
            middleLegend.formColor = Color.rgb(53, 153, 101)
            val lowerLegend = LegendEntry()
            lowerLegend.label = "Lower"
            lowerLegend.formColor = Color.rgb(255, 204, 1)
            listOf(upperLegend, middleLegend, lowerLegend)
        }
        MainIndicator.ENVELOPES -> {
            binding.priceChart.legend.isEnabled = true
            val upperLimitLegend = LegendEntry()
            upperLimitLegend.label = "상한선"
            upperLimitLegend.formColor = Color.rgb(101, 204, 51)
            val baselineLegend = LegendEntry()
            baselineLegend.label = "중심선"
            baselineLegend.formColor = Color.rgb(51, 50, 203)
            val lowerBoundLegend = LegendEntry()
            lowerBoundLegend.label = "하한선"
            lowerBoundLegend.formColor = Color.rgb(255, 51, 156)
            listOf(upperLimitLegend, baselineLegend, lowerBoundLegend)
        }
        else -> {
            Log.e("FragmentChart", "legendList is empty")
            listOf()
        }
    }
    
    legendList.clear()
    legendList.addAll(tempList)
}

이렇게 수정했더니 여전히 오류가 발생했다.
Handler.handleMessage()를 보면 차트 데이터를 갱신한 후에, 차트의 Legend를 변경하고 있고,
이는 앞서 언급했던 1번에 해당하는 경우이다.
코드를 다시 수정하자.

val myHandler: MyHandler = MyHandler()
val legendList: ArrayList<LegendEntry> = ArrayList()

inner class MyHandler : Handler() {
    override fun handleMessage(msg: Message) {
        super.handleMessage(msg)

        val bundle: Bundle = msg.data
        if (!bundle.isEmpty) {
            // Legend를 먼저 변경함.
            binding.priceChart.legend.apply {
                setCustom(priceChartLegendList)
                textColor = Color.WHITE
                verticalAlignment = Legend.LegendVerticalAlignment.TOP
                horizontalAlignment = Legend.LegendHorizontalAlignment.LEFT
                orientation = Legend.LegendOrientation.HORIZONTAL
                setDrawInside(true)
            }
            
            // 그 후에 새로운 데이터를 차트에 반영함.
            
        }
    }
}

inner class MyThread : Thread() {
    var stopFlag = false
    
    override fun run() {
    	while (!stopFlag) {
            val message = myHandler.obtainMessage()
            val bundle: Bundle = Bundle()
                
            // 여기서 서버로부터 새로운 데이터를 받아옴.
            
            message.data = bundle
            // MyHandler에게 메시지를 보냄.
            myHandler.sendMessage(message)
            sleep(300)
        }
    }

    fun threadStop(flag: Boolean) {
        this.stopFlag = flag
    }
}

binding.changeLegendBtn.setOnClickListener {
    changeChartLegend(legendType)
}

fun changeChartLegend(legendType: Int) {
    val tempList: List<LegendEntry> = when(legendType) {
        MainIndicator.BOLLINGER_BANDS -> {
            binding.priceChart.legend.isEnabled = true
            val upperLegend = LegendEntry()
            upperLegend.label = "Upper"
            upperLegend.formColor = Color.rgb(50, 51, 255)
            val middleLegend = LegendEntry()
            middleLegend.label = "Middle"
            middleLegend.formColor = Color.rgb(53, 153, 101)
            val lowerLegend = LegendEntry()
            lowerLegend.label = "Lower"
            lowerLegend.formColor = Color.rgb(255, 204, 1)
            listOf(upperLegend, middleLegend, lowerLegend)
        }
        MainIndicator.ENVELOPES -> {
            binding.priceChart.legend.isEnabled = true
            val upperLimitLegend = LegendEntry()
            upperLimitLegend.label = "상한선"
            upperLimitLegend.formColor = Color.rgb(101, 204, 51)
            val baselineLegend = LegendEntry()
            baselineLegend.label = "중심선"
            baselineLegend.formColor = Color.rgb(51, 50, 203)
            val lowerBoundLegend = LegendEntry()
            lowerBoundLegend.label = "하한선"
            lowerBoundLegend.formColor = Color.rgb(255, 51, 156)
            listOf(upperLimitLegend, baselineLegend, lowerBoundLegend)
        }
        else -> {
            Log.e("FragmentChart", "legendList is empty")
            listOf()
        }
    }
    
    legendList.clear()
    legendList.addAll(tempList)
}

이렇게 코드를 수정했더니 더 이상 오류가 발생하지 않았다.

근데 뭔가 이상하다.
Legend를 먼저 바꾸고 차트 데이터를 바꿨다. 이는 앞서 언급했던 2번에 해당하는 상황이다.
이를 통해 차트의 데이터를 변경하면 렌더링이 수행되지만, 차트의 Legend를 변경한다고 렌더링이 수행되지 않는다는 것을 유추할 수 있었다.

결론

다중 쓰레드 환경에서 프로그래밍 하는 것은 재밌다.
하지만 그에 상응하는 실력도 필요하다.
앞으로 더 많이 삽질해보고 경험해봐야겠다.

profile
주니어 안드 개발자

0개의 댓글