본문 바로가기
Projects/센서 모니터링 시스템

[센서 모니터링 시스템] 13. Android Chart 구현 (프로젝트 종료)

by DevJaewoo 2022. 8. 19.
반응형

Intro

이제 마지막이다. 이전에 받아온 센서 데이터를 차트 형태로 출력해보자.

차트 라이브러리는 MPAndroidChart를 사용했다.

 

GitHub - PhilJay/MPAndroidChart: A powerful 🚀 Android chart view / graph view library, supporting line- bar- pie- radar- bubb

A powerful 🚀 Android chart view / graph view library, supporting line- bar- pie- radar- bubble- and candlestick charts as well as scaling, panning and animations. - GitHub - PhilJay/MPAndroidChart:...

github.com


Gradle 설정

settings.gradle

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        ...
        maven { url 'https://jitpack.io' }
    }
}

build.gradle

dependencies {

    ...
    
    implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
}

일정 주기로 데이터를 요청해야 하기 때문에 coroutine도 추가했다.


MainActivity UI 작성

단순히 모든 차트를 수직으로 배열했다.

Height가 화면 크기 이상으로 나올것이 당연하기 때문에 ScrollView 안에 넣었다.

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <androidx.constraintlayout.widget.ConstraintLayout
                android:id="@+id/constraintLayout1"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent">

                <TextView
                    android:id="@+id/tvECO2"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:text="ECO2"
                    android:textSize="25sp"
                    android:textAlignment="center"
                    app:layout_constraintTop_toTopOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent" />

                <com.github.mikephil.charting.charts.LineChart
                    android:id="@+id/chartECO2"
                    android:layout_width="0dp"
                    android:layout_height="200dp"
                    android:layout_marginStart="30dp"
                    android:layout_marginEnd="30dp"
                    android:layout_marginTop="30dp"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/tvECO2" />

            </androidx.constraintlayout.widget.ConstraintLayout>

            <androidx.constraintlayout.widget.ConstraintLayout
                android:id="@+id/constraintLayout2"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                app:layout_constraintTop_toBottomOf="@id/constraintLayout1"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent">

                <TextView
                    android:id="@+id/tvTVOC"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:text="TVOC"
                    android:textSize="25sp"
                    android:textAlignment="center"
                    app:layout_constraintTop_toTopOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent" />

                <com.github.mikephil.charting.charts.LineChart
                    android:id="@+id/chartTVOC"
                    android:layout_width="0dp"
                    android:layout_height="200dp"
                    android:layout_marginStart="30dp"
                    android:layout_marginEnd="30dp"
                    android:layout_marginTop="30dp"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/tvTVOC" />

            </androidx.constraintlayout.widget.ConstraintLayout>

            <androidx.constraintlayout.widget.ConstraintLayout
                android:id="@+id/constraintLayout3"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                app:layout_constraintTop_toBottomOf="@id/constraintLayout2"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent">

                <TextView
                    android:id="@+id/tvTemp"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:text="Temperature"
                    android:textSize="25sp"
                    android:textAlignment="center"
                    app:layout_constraintTop_toTopOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent" />

                <com.github.mikephil.charting.charts.LineChart
                    android:id="@+id/chartTemp"
                    android:layout_width="0dp"
                    android:layout_height="200dp"
                    android:layout_marginStart="30dp"
                    android:layout_marginEnd="30dp"
                    android:layout_marginTop="30dp"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/tvTemp" />

            </androidx.constraintlayout.widget.ConstraintLayout>

            <androidx.constraintlayout.widget.ConstraintLayout
                android:id="@+id/constraintLayout4"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                app:layout_constraintTop_toBottomOf="@id/constraintLayout3"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent">

                <TextView
                    android:id="@+id/tvAccel"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:text="Accelerometer"
                    android:textSize="25sp"
                    android:textAlignment="center"
                    app:layout_constraintTop_toTopOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent" />

                <com.github.mikephil.charting.charts.LineChart
                    android:id="@+id/chartAccel"
                    android:layout_width="0dp"
                    android:layout_height="200dp"
                    android:layout_marginStart="30dp"
                    android:layout_marginEnd="30dp"
                    android:layout_marginTop="30dp"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/tvAccel" />

            </androidx.constraintlayout.widget.ConstraintLayout>

        </androidx.constraintlayout.widget.ConstraintLayout>

    </ScrollView>

</androidx.constraintlayout.widget.ConstraintLayout>

 

안드로이드 UI
이런 식으로 구성된다.


Chart Extension Function 생성

차트를 일일이 다 초기화하면 코드가 많이 더러워질 것이다.

때문에 Kotlin에서 제공하는 확장함수를 사용해 LineChart를 초기화하는 함수를 작성했다.

 

차트 내부의 격자는 연한 회색, 축과 다른 텍스트는 진한 회삭으로 설정했다.

X축 텍스트는 230142와 같은 실수를 HH:mm:ss 형태로 출력하도록 커스텀 ValueFormatter를 설정해줬다.

LineChart.initializeChart

private fun LineChart.initializeChart() {

    val colorGrid = Color.parseColor("#CCCCCC")
    val colorText = Color.parseColor("#666666")

    // 차트 초기화
    this.apply {
        invalidate()
        clear()
        description = null
    }

    this.xAxis.apply {
        position = XAxis.XAxisPosition.BOTTOM
        
        // 230142 형태의 실수를 23:01:42 형태의 String으로 변환
        valueFormatter = object : ValueFormatter() {
            override fun getFormattedValue(value: Float): String {
                val time = value.toInt()
                return "${(time / 10000).toString().padStart(2, '0')}:" +
                        "${((time % 10000) / 100).toString().padStart(2, '0')}:" +
                        "${(time % 100).toString().padStart(2, '0')}"
            }
        }

        setLabelCount(CHART_SIZE, true)
        textColor = colorText
        gridColor = colorGrid
    }

    this.axisLeft.apply {
        textColor = colorText
        gridColor = colorGrid
    }

    // 오른쪽 Y축 비활성화
    this.axisRight.apply {
        setDrawLabels(false)
        setDrawAxisLine(false)
        setDrawGridLines(false)
    }

    this.legend.apply {
        textColor = colorText
    }
}

 

이제 아래와 같이 모든 차트들을 깔끔하게 초기화할 수 있다.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    _binding = ActivityMainBinding.inflate(layoutInflater)

    binding.chartECO2.initializeChart()
    binding.chartTVOC.initializeChart()
    binding.chartTemp.initializeChart()
    binding.chartAccel.initializeChart()

    setContentView(binding.root)
}

 

Entry 리스트, 차트 제목과 선 색상을 받아 차트에 선을 그려주는 함수다.

단점은 Accel과 같이 여러 개의 Entry 리스트를 받아야 하는 경우는 처리를 못해준다.

LineChart.setData

private fun LineChart.setData(data: ArrayList<Entry>, label: String, colorString: String) {

    val color = Color.parseColor(colorString)

    val lineDataSet = LineDataSet(data, label)
    lineDataSet.color = color
    lineDataSet.setCircleColor(color)
    lineDataSet.circleHoleColor = ContextCompat.getColor(applicationContext, R.color.white)

    val lineData = LineData().apply {
        addDataSet(lineDataSet)
        setValueTextColor(R.color.black)
        setValueTextSize(9f)
    }

    this.invalidate()
    this.clear()
    this.data = lineData
}

UI 업데이트를 위한 Handler 작성

Coroutine에서 바로 UI를 업데이트하면 좋겠지만, 안드로이드는 메인 스레드에서만 UI를 변경할 수 있다.

이럴 경우 Handler를 사용해 메인이 아닌 스레드에서 Handler에 메시지를 보내고, Handler에서 메시지를 받아 메인 스레드에서 UI를 업데이트 시키는 것이 가능하다.

 

Accel 데이터는 LineChart.setData함수로 처리가 안돼서 직접 해줬다.

private fun String.ISOStringToFloat(): Float {
    return this.substring(11, 19).replace(":", "").toFloat()
}

private val handler = object : Handler(Looper.getMainLooper()) {
    override fun handleMessage(msg: Message) {
        super.handleMessage(msg)

        val response = msg.obj as JSONObject

        val eco2 = ArrayList<Entry>()
        val tvoc = ArrayList<Entry>()
        val temp = ArrayList<Entry>()

        val accelX = ArrayList<Entry>()
        val accelY = ArrayList<Entry>()
        val accelZ = ArrayList<Entry>()

        val array = response.getJSONArray("sensorData")
        for(i in 0 until array.length()) {
            val row = array.getJSONObject(i)

            val date = row.getString("createdDate").ISOStringToFloat()
            eco2.add(0, Entry(date, row.getInt("eco2").toFloat()))
            tvoc.add(0, Entry(date, row.getInt("tvoc").toFloat()))
            temp.add(0, Entry(date, row.getDouble("temp").toFloat()))

            val accel = row.getJSONObject("accel")
            accelX.add(0, Entry(date, accel.getDouble("x").toFloat()))
            accelY.add(0, Entry(date, accel.getDouble("y").toFloat()))
            accelZ.add(0, Entry(date, accel.getDouble("z").toFloat()))
        }

        binding.chartECO2.setData(eco2, "eCO2", "#7900FF")
        binding.chartTVOC.setData(tvoc, "TVOC", "#548CFF")
        binding.chartTemp.setData(temp, "Temperature", "#FF00BE")

        val lineDataSetX = LineDataSet(accelX, "X")
        lineDataSetX.color = Color.RED
        lineDataSetX.setCircleColor(Color.RED)
        lineDataSetX.circleHoleColor = ContextCompat.getColor(applicationContext, R.color.white)

        val lineDataSetY = LineDataSet(accelY, "Y")
        lineDataSetY.color = Color.GREEN
        lineDataSetY.setCircleColor(Color.GREEN)
        lineDataSetY.circleHoleColor = ContextCompat.getColor(applicationContext, R.color.white)

        val lineDataSetZ = LineDataSet(accelZ, "Z")
        lineDataSetZ.color = Color.BLUE
        lineDataSetZ.setCircleColor(Color.BLUE)
        lineDataSetZ.circleHoleColor = ContextCompat.getColor(applicationContext, R.color.white)

        binding.chartAccel.invalidate()
        binding.chartAccel.clear()
        binding.chartAccel.data = LineData().apply {
            addDataSet(lineDataSetX)
            addDataSet(lineDataSetY)
            addDataSet(lineDataSetZ)

            setValueTextColor(R.color.black)
            setValueTextSize(9f)
        }
    }
}

일정 간격으로 센서 데이터 Request

이전에 적용한 Coroutine이 여기서 쓰인다.

서버에 데이터를 요청하고 Callback함수를 등록하여 응답이 오면 위에 만들었던 Handler에 메시지를 보내 차트를 업데이트한다.

 

간격은 React와 동일하게 2초로 설정했다.

GlobalScope.launch {
            
    val onSensorData = { response: JSONObject ->
        handler.obtainMessage(0, response).sendToTarget()
    }

    while(true) {
        RequestHandler.request("/api/1/sensor?size=$CHART_SIZE", null, onSensorData, null, false, Request.Method.GET)
        Thread.sleep(2000)
    }
}

동작 테스트

프로젝트를 실행하며 아래와 같이 차트가 정상적으로 뜨는것을 볼 수 있다.

당연하겠지만 안드로이드와 서버가 같은 네트워크 안에 있어야 정상동작 한다.

 

동작 결과


이번 프로젝트는 이쯤에서 마무리하는 것이 좋을 것 같다.

그동안 공부했던 다양한 기술들을 사용할 수 있어서 좋았고, 결과물도 3일 한 것 치곤 상당히 잘 나온것 같아 만족스럽다.

 

모든 소스코드는 Github에 올라가있으니 필요하면 복사해서 사용해도 괜찮다.

https://github.com/DevJaewoo/sensor-monitoring-system

 

GitHub - DevJaewoo/sensor-monitoring-system

Contribute to DevJaewoo/sensor-monitoring-system development by creating an account on GitHub.

github.com

 

반응형