Trindex 앱의 0.37.2 업데이트에서 차트의 X축 눈금 간격을 월 단위나 년 단위로 표시되도록 개선했다. 당시 MPAndroidChart 오픈 소스 라이브러리 자체를 뜯어고치는 작업을 진행했었는데, 해당 내용을 정리해 보았다.
MPAndroidChart가 주는 아쉬움
MPAndroidChart는 안드로이드 프로젝트에서 차트를 손쉽게 구현할 수 있도록 해주는 오픈 소스 라이브러리다. 가장 오래되고 널리 쓰이는 오픈 소스지만 2019년 이후로 버전 업데이트가 없고, 아직 기능적으로도 아쉬운 부분이 있는 라이브러리인데, 내가 가장 아쉽게 생각하는 부분은 X축을 날짜 기준으로 두었을 때 눈금의 간격을 인간 친화적으로 설정할 수 없다는 것이다.
바로 위와 같은 경우다. MPAndroidChart가 사용된 Trindex 앱의 초기 버전 모습인데, X축 눈금의 간격이 매월 1일이나 규칙적인 특정 일자를 가리키고 있는 것이 아니라서 사용자가 선뜻 그 간격을 인식하기 어렵다. 눈금의 간격은 일정해야 하는데, 1개월에 해당되는 날짜 수가 28~31일로 유동적이기 때문에 발생하는 문제라고 할 수 있다.
MPAndroidChart 라이브러리는 이 부분을 해결해주지 않았고, 그렇기 때문에 개발자가 직접 필요한 부분을 뜯어고칠 수밖에 없는 상황이었다.
MPAndroidChart 소스 코드에서 X축 눈금을 결정하는 부분 찾기
MPAndroidChart 라이브러리의 어느 부분에서 어떤 식으로 X축 눈금을 구현하는 것인지 파악하기 위해 Trindex 프로젝트의 차트를 초기화 과정 중 X축 setValueFormatter()를 호출하는 부분을 살펴보았다. 해당 메소드에서는 ValueFormatter 클래스의 getFormattedValue() 메소드를 재정의해 주면서 축의 눈금이 되는 value 값을 개발자가 원하는 방식으로 포맷팅 하여 차트에 출력하게 되는데, 여기서부터 추적해 들어가면 필요한 부분을 찾을 수 있을 것이라 예상한 것이다.
예상은 적중했다. 추적해 들어가다 보니 AxisRenderer라는 클래스의 computeAxisValues() 메소드에 이르게 되었는데, 이 메소드로 전달되는 min, max 값은 각각 차트 X축의 시작과 끝을 나타내는 값으로 computeAxisValues() 메소드 내에서 연산에 사용되어 mAxis.Entries[]라는 배열의 구성 값들에 영향을 미치게 되고, 결국 이 배열의 각 요소들이 최종적으로 getFormattedValue() 메소드의 value 값으로 전달되는 눈금의 실체라는 것을 알게 되었다.
코드 수정 계획
코드를 파악하고 나니 이제 어떤 방식으로 수정해야 원래의 코드를 최대한 훼손시키지 않는 범위 내에서 mAxis.Entries[]라는 배열의 값들을 내 마음대로(월이나 년 단위로) 결정할 수 있을까 하는 고민에 빠졌다. 몇 가지 아이디어가 떠올랐지만 고심 끝에 다음과 같이 처리하기로 결정을 했다.
- X축을 날짜 기준으로 처리하도록 알리는 플래그를 만든다. (기존 방식과 신규 방식을 분기해서 처리하도록 만드는 플래그)
- X축 초기화 단계에서 1번의 플래그를 세운다. (true로 만든다.)
- 차트가 생성되는 단계에서 1번의 플래그가 세워진 것을 확인했다면, X축 데이터의 전체 범위 중 최솟값과 최댓값을 이용해 미리 월 간격, 년 간격 등 필요한 눈금 테이블을 만들어 둔다.
- 차트가 디스플레이에 출력될 때, 사용자가 차트를 확대/축소할 때, 그리고 사용자가 차트를 움직일 때 1번의 플래그가 세워진 것을 확인하고, 세워진 상태라면 미리 만들어 놓은 눈금 테이블을 이용해서 mAxis.Entries[] 배열을 구성한다.
- getFormattedValue()로 전달받은 value 값은 그 간격에 맞게 적절한 포맷으로 변경하여 화면에 출력한다.
위와 같이 큰 흐름을 정한 뒤 MPAndroidChart의 원본 소스 코드 수정 작업에 착수했다. 이어지는 내용들이 바로 그 핵심 내용들이다.
Chart 클래스에 추가된 코드
Chart 클래스에는 optimizeXAxisForDate()라는 메소드를 하나 추가해 주었다. 이곳에서 X축이 새로운 방식으로 처리되도록 하는 옵션을 설정해 주고, 필요한 눈금 테이블을 미리 구축해 두는 작업을 진행하게 만들었다.
그 내부를 살펴보자면, 먼저 X축 전체 데이터로부터 최솟값과 최댓값을 구했다. 바로 X축에 표현하게 될 시작일과 종료일에 해당되는 값들이다.
그다음으로 각 눈금 목록의 첫 번째 눈금이 될 날짜를 찾는 작업을 수행하도록 했다. 날짜 연산을 쉽게 하기 위해서 LocalDate 클래스를 활용했고, while 반복문을 통해 월 단위 눈금 시작일은 1일씩 증가하면서 찾도록, 년 단위 눈금의 시작일은 월 단위 눈금 시작일로부터 1개월씩 증가하면서 찾도록 처리했다.
그렇게 찾은 월 단위, 년 단위 눈금의 첫 번째 날짜를 while 반복문을 통해 X축 데이터가 끝나는 날까지 1개월 단위, 또는 1년 단위로 더해가면서 gridListForMonth와 gridListForYear에 수집되도록 구현하였다.
수집된 각 눈금 목록은 mXAxis의 멤버로 세팅해 주고, X축이 새로운 방식으로 처리되어야 함을 알리는 플래그를 세우는 것으로 optimizeXAxisForDate() 메소드를 마무리했다. 뜬금없이 갑자기 튀어나온 setGridListForXXX() 시리즈 메소드와 setAxisBasedOnDate() 메소드는 모두 XAxis 클래스에 추가한 메소드로 뒤에서 다시 언급될 예정.
setGridListForDay(), setGridListForDay2() 메소드를 통해 짐작할 수 있듯이 사실 앞서 optimizeXAxisForDate() 메소드에서는 월 단위 눈금 목록과 년 단위 눈금 목록만을 만든 게 아니다. 5일 간격, 그리고 10일 간격의 일 단위 눈금 목록도 두 개 만들었는데, 설명이 반복되고 길어지는 것을 고려해 생략한 것이므로 이어지는 내용에서 관련 코드가 튀어나오더라도 그냥 그러려니 하면 된다.
setAxisBasedOnDate() 메소드는 앞서 코드 수정 계획과는 달리 순서가 뒤로 밀렸는데, 순서가 프로그램에 영향을 주는 부분이 아니라서 보기 좋게 뒤로 빼놓았을 뿐 큰 의미는 없다.
XAxis 클래스에 추가된 코드
XAxis 클래스는 Chart 클래스의 멤버 인스턴스인 mXAxis의 클래스로 다음 내용들이 추가되었다.
먼저 mAxisBasedOnDate다. 코드 수정 계획 1번에서 언급된 플래그인데, X축의 눈금을 새로운 방식(미리 만들어 놓은 눈금 목록 기준)으로 결정할 것인지에 대한 옵션 플래그다.
mModeForDate은 mAxisBasedOnDate이 true일 경우 미리 만들어 놓은 눈금 목록 중 어느 목록을 사용할지 분류하는 모드 값이라고 보면 된다.
mLatestScale은 가장 최근에 발생한 확대, 축소 액션에 대해 그 스케일 값을 보관해 두는 멤버이다. 이 값을 이용해서 사용자의 액션이 확대, 축소가 아닌 단순 스크롤일 경우를 걸러내면, 그만큼 작업 소요를 줄일 수 있다.
mGridListForXXX 시리즈는 각각 5일 단위 눈금, 10일 단위 눈금, 월 단위 눈금, 년 단위 눈금 목록에 해당하는 ArrayList 인스턴스이다. 앞서 Chart 클래스 optimizeXAxisForDate() 메소드의 마지막 부분에서 set 하게 되는 눈금 목록들이 바로 이 인스턴스들에 해당된다.
마지막 mGridListForScale은 Chart 클래스에서 set 되지 않은 인스턴스인데, 사용자가 차트를 확대, 축소할 때마다 그 정도에 따라 미리 준비된 눈금 목록으로부터 필요한 눈금 값들을 가져와 재구성하게 되는 눈금 목록이다. 예를 들어 월 단위 눈금이 필요한 상황이라고 해도 그 안에서 두 달 간격의 월 단위가 필요하게 될지, 세 달 간격의 월 단위가 필요하게 될지는 차트 확대 정도에 따라 다르게 된다. 그래서 필요한 인스턴스라고 보면 된다.
다음은 XAxis 클래스에 추가된 메소드들이다. 위 메소드들은 클래스에 추가된 멤버들에 대해 단순 get, set 하는 메소드이므로 프로그래밍에 익숙한 사람이라면 알고 있는 기본적인 것들이라 자세한 내용은 생략한다.
주의 깊게 봐야 할 부분은 위 세 개의 메소드.
먼저 getGridIndexForScale() 메소드는 전달받은 임의의 value 값에 해당되거나 바로 다음에 위치한 mGridListForScale의 눈금 값을 구하는 메소드인데, 내용은 위 코드와 같이 단순하다. Math.min()의 호출은 OutOfIndex 오류를 방지하기 위해 사용했다.
다음은 resetGridListForScale() 메소드인데, 이 메소드는 앞서 언급한 멤버 mGridListForScale의 눈금 목록을 재구성하는 동작을 수행한다. XAxisRenderer 클래스(뒤에서 나옴)로부터 전달받은 range 값에 따라 mModeForDate와 idxGap(인덱스 간격) 값을 결정하고, 그에 맞는 눈금 목록을 선택해 idxGap 간격으로 눈금을 수집하게 된다.
메소드가 제법 길어서 위 스샷에 코드 내용 전부를 담아내지는 못했지만 어느 정도 코딩 경험이 있는 사람이라면 앞서의 설명만으로도 생략된 부분이 어떤 식으로 동작하는지 예상할 수 있을 것이다.
마지막으로 getEntriesForScale() 메소드. XAxisRenderer 클래스(뒤에서 나옴)로부터 전달받은 first와 last 값을 받아 그 범위 내에 있는 mGridListForScale 눈금 값들을 돌려준다. 여기서 리턴되는 값들이 바로 앞서 코드 수정 계획 4번에 언급된 mAxis.Entries[] 배열이 되는 것이다.
XAxisRenderer 클래스에 수정 및 추가된 코드
앞서 [X축의 눈금을 결정하는 코드의 흐름 파악] 단계에서는 XAxisRenderer 클래스의 부모 클래스인 AxisRenderer 클래스의 computeAxisValues() 메소드를 언급했었는데, 부모 클래스의 코드를 수정하는 것도 방법이 될 수 있지만 좀 더 엄밀히 따졌을 때 이 문제는 X축만의 문제이고, 또, MPAndroidChart 라이브러리 원본을 가능한 한 훼손시키지 않기 위해 자식 클래스인 XAXisRenderer 클래스의 computeAxisValues() 메소드를 수정하는 쪽으로 작업을 했다.
위에서 볼 수 있는 것처럼 플래그가 올라갔을 때를 분기하여 처리하도록 말이다.
부모 클래스의 computeAxisValues() 메소드 내용에 비해 상당히 단순화된 것을 볼 수 있는데, 불필요한 요소들은 모두 제거했기 때문이다. 따라서 차트 사용 시 주의해야 할 필요가 있으며, 초반에 입력된 주석이 바로 그 내용을 담고 있다.
앞서 언급했지만 다시 한번 설명하자면 computeAxisValues() 메소드로 넘어오는 min, max 값은 차트에서 사용자가 현재 보고 있는 X축의 시작 값과 끝 값이다. 둘 다 LocalDate 클래스의 epoch 값에 대응되는 float 값으로 그 둘의 차이가 range 값이 된다.
여기서 range는 XAxis 클래스의 resetGridListForScale() 메소드로 던지는 바로 그 range 값인데, range가 10 미만, 그러니까 사용자가 보고 있는 화면의 X축 범위가 10일 이내를 표현할 정도로 차트가 확대되면 MPAnroidChart 원본의 알고리즘대로 처리되도록 super 메소드를 호출하고 return 처리를 했다. 기존의 방식을 따르더라도 UX 측면에서 불편함을 느끼지 못하는 영역이기 때문이다.
나머지 경우에는 차트가 확대, 축소될 때마다 XAxis 클래스의 mGridListForScale 이 재구성되도록, 그리고 min, max 값을 보정한 값 first, last를 이용하여 mAxis.Entries 배열과 mAxis.EntryCount 값을 구하도록 처리했다. 여기까지가 작업한 내용의 전부이다.
사용 예시
라이브러리 수정을 마친 뒤에는 Trindex 프로젝트에 적용해 주었다.
차트 초기화 중 X축 초기화 부분에서 CenterAxisLabels 옵션을 끄고, LableCount는 5로 설정해 주었다. ValueFormatter 설정에서는 눈금 단위 모드에 맞게 날짜 포맷을 분기하여 처리했다. 참고로 DateFormatter 클래스는 MPAndroidChart나 Android 라이브러리에 포함된 클래스가 아니라 편의를 위해 직접 만든 클래스이다.
차트 초기화 마지막 부분에서는 optimizeXAxisForDate() 메소드를 호출하여 X축이 작업한 방식으로 처리되도록 설정해 주었다. 이 메소드는 축에 데이터가 모두 추가된 이후에 호출해야 한다는 점이 중요하다.
그렇게 적용해 본 결과는...
위와 같다. 이제는 X축 눈금의 간격을 직관적으로 인지할 수 있다. 사용자 입장에서는 당연한 부분인데, 개발자 입장에서는 당연한 게 없다.
마치며...
지금까지 Trindex 프로젝트 차트의 X축 눈금 간격을 월 단위, 년 단위로 표현하기 위해 MPAndroidChart 라이브러리를 어떻게 수정했는지 알아보았다. 깃허브 원본 저장소에서 가지를 쳐, 나와 같은 고민을 했던 다른 사람들도 쉽게 사용할 수 있도록 했으면 좋았겠지만 아무래도 수정된 코드가 범용적이지 못하고, 개인 프로젝트에 특화되다 보니 가지를 칠 수준의 코드는 아닌 것 같아 이런 식으로 나마 공유해 본다.
코드에서 개선점을 보았거나 근본적으로 더 좋은 아이디어를 가진 사람들의 기탄없는 의견 공유를 희망하면서 긴 글을 마친다.