17、图表绘制基础:使用Canvas或MPAndroidChart库绘制光照强度折线图

好,咱们今天聊聊图表绘制。说实话,做传感器应用开发,光看数字太枯燥了。你想想看,一串串的lux值堆在Logcat里,谁能一眼看出光线变化趋势?

所以,把数据变成图表,是每个Android工程师的必修课。我个人习惯用两种方式:一种是纯手工用Canvas画,另一种是直接用MPAndroidChart库。今天咱们两种都讲,你根据项目需求选。

17.1 先搞清楚:折线图要画什么?

光照强度折线图,说白了就是两个维度:

  • X轴:时间。单位可以是秒、分钟,或者采集点序号。
  • Y轴:光照强度,单位lux。

数据来源呢?就是咱们之前章节里从SensorManager拿到的SensorEvent.values[0]。每次传感器回调,我们就往列表里塞一个点。

核心数据结构

// 存储时间戳和lux值
data class LightDataPoint(
    val timestamp: Long,  // 采集时间
    val lux: Float        // 光照强度
)

// 全局数据列表
private val dataPoints = mutableListOf<LightDataPoint>()

嗯,这里要注意:时间戳建议用System.currentTimeMillis(),而不是传感器自带的timestamp。为什么?因为传感器时间戳是纳秒级的,处理起来容易溢出,我踩过这个坑。

17.2 方案一:用Canvas手撸折线图

如果你不想引入第三方库,或者你的App很小,不想增加包体积,那就用Canvas自己画。其实没那么复杂。

17.2.1 自定义View的基本骨架

class LightChartView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val dataPoints = mutableListOf<LightDataPoint>()

    // 边距
    private val marginLeft = 80f
    private val marginBottom = 80f
    private val marginTop = 40f
    private val marginRight = 40f

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        if (dataPoints.isEmpty()) return

        // 1. 计算绘图区域
        val chartWidth = width - marginLeft - marginRight
        val chartHeight = height - marginTop - marginBottom

        // 2. 找到Y轴范围
        val maxLux = dataPoints.maxOf { it.lux }
        val minLux = dataPoints.minOf { it.lux }
        val yRange = if (maxLux - minLux == 0f) 1f else maxLux - minLux

        // 3. 绘制坐标轴
        paint.color = Color.GRAY
        paint.strokeWidth = 2f
        // Y轴
        canvas.drawLine(marginLeft, marginTop, marginLeft, height - marginBottom, paint)
        // X轴
        canvas.drawLine(marginLeft, height - marginBottom, width - marginRight, height - marginBottom, paint)

        // 4. 绘制折线
        paint.color = Color.parseColor("#2196F3")
        paint.strokeWidth = 4f
        paint.style = Paint.Style.STROKE

        val path = Path()
        for (i in dataPoints.indices) {
            val x = marginLeft + (i.toFloat() / (dataPoints.size - 1).coerceAtLeast(1)) * chartWidth
            val y = marginTop + chartHeight - ((dataPoints[i].lux - minLux) / yRange) * chartHeight
            if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
        }
        canvas.drawPath(path, paint)
    }

    fun addDataPoint(point: LightDataPoint) {
        dataPoints.add(point)
        invalidate() // 触发重绘
    }
}

这段代码看着长,其实核心就三步:

  1. 算坐标:把数据映射到画布像素位置
  2. 画轴:两条线,Y轴和X轴
  3. 画线:用Path把点连起来

我的小技巧:Y轴范围别用固定值。我曾经在室内测到50lux,拿到室外直接飙到50000lux,固定范围根本没法看。所以每次重绘前都动态计算maxLuxminLux

17.2.2 在Activity中使用

class LightSensorActivity : AppCompatActivity() {
    private lateinit var chartView: LightChartView
    private var lastTimestamp = 0L

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        chartView = LightChartView(this)
        setContentView(chartView)

        // 注册传感器监听(略)
        // 在onSensorChanged中:
        sensorManager.registerListener(object : SensorEventListener {
            override fun onSensorChanged(event: SensorEvent) {
                val now = System.currentTimeMillis()
                if (now - lastTimestamp > 200) { // 每200ms采集一个点
                    chartView.addDataPoint(LightDataPoint(now, event.values[0]))
                    lastTimestamp = now
                }
            }
            // ...
        }, lightSensor, SensorManager.SENSOR_DELAY_NORMAL)
    }
}

注意我加了个200ms的限频。为什么?因为传感器回调太快了,每秒几百次,全画上去折线图就成一团墨了。你想想看,一秒钟画几百个点,屏幕才多宽?

17.3 方案二:用MPAndroidChart库

如果你追求美观,或者需要缩放、触摸交互,那就用MPAndroidChart。这个库我用了好几年,稳定可靠。

17.3.1 添加依赖

// build.gradle (Module)
dependencies {
    implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
}

别忘了在根build.gradle加jitpack仓库:

allprojects {
    repositories {
        maven { url 'https://jitpack.io' }
    }
}

17.3.2 布局文件

<com.github.mikephil.charting.charts.LineChart
    android:id="@+id/lineChart"
    android:layout_width="match_parent"
    android:layout_height="300dp" />

17.3.3 代码配置

class ChartActivity : AppCompatActivity() {
    private lateinit var lineChart: LineChart
    private val entries = mutableListOf<Entry>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_chart)

        lineChart = findViewById(R.id.lineChart)
        setupChart()
    }

    private fun setupChart() {
        // 基本配置
        lineChart.description.isEnabled = false
        lineChart.setTouchEnabled(true)
        lineChart.isDragEnabled = true
        lineChart.setScaleEnabled(true)

        // X轴配置(时间轴)
        val xAxis = lineChart.xAxis
        xAxis.position = XAxis.XAxisPosition.BOTTOM
        xAxis.valueFormatter = object : ValueFormatter() {
            override fun getFormattedValue(value: Float): String {
                // 假设value是时间戳,格式化为"HH:mm:ss"
                val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
                return sdf.format(Date(value.toLong()))
            }
        }

        // Y轴配置
        val yAxis = lineChart.axisLeft
        yAxis.axisMinimum = 0f
        lineChart.axisRight.isEnabled = false

        // 初始化空数据集
        val dataSet = LineDataSet(entries, "光照强度")
        dataSet.color = Color.parseColor("#FF9800")
        dataSet.setCircleColor(Color.parseColor("#FF9800"))
        dataSet.lineWidth = 2f
        dataSet.circleRadius = 3f
        dataSet.mode = LineDataSet.Mode.CUBIC_BEZIER // 平滑曲线

        lineChart.data = LineData(dataSet)
    }

    fun addEntry(lux: Float) {
        val now = System.currentTimeMillis().toFloat()
        entries.add(Entry(now, lux))

        // 只保留最近100个点
        if (entries.size > 100) entries.removeAt(0)

        // 通知数据更新
        lineChart.data.notifyDataChanged()
        lineChart.notifyDataSetChanged()
        lineChart.invalidate()
    }
}

注意:MPAndroidChart的X轴默认是float类型,所以时间戳要转成float。但long转float会丢失精度,如果时间戳很大(比如13位毫秒数),建议用相对时间:System.currentTimeMillis() - startTime,这样数值小,精度高。

17.4 两种方案对比

对比项 Canvas手绘 MPAndroidChart
包体积 无额外依赖 约200KB
开发效率 低,需自己处理坐标映射 高,配置即用
交互能力 需自己实现触摸事件 内置缩放、拖拽、高亮
美观度 基础线条 支持渐变、动画、标记
适用场景 简单展示、极简App 复杂图表、产品级应用

我个人建议:如果是学习或Demo,用Canvas画一遍,能帮你理解图形渲染原理。如果是正式项目,直接用MPAndroidChart,省时省力。

17.5 本章知识体系

下面这张图帮你理清今天的内容结构:

光照强度折线图绘制知识体系 折线图绘制 方案一:Canvas手绘 方案二:MPAndroidChart 自定义View + onDraw() 坐标映射:数据→像素 Path绘制折线 + invalidate() 添加依赖 + 布局文件 配置X/Y轴 + ValueFormatter Entry + LineDataSet + invalidate()

17.6 避坑指南

最后分享几个我踩过的坑:

  • 数据点太多导致卡顿:我曾经在Canvas里画了5000个点,直接卡成PPT。解决办法是限制数据点数量,比如只保留最近200个点。
  • Y轴范围突变:如果用户从室内走到室外,lux值从50跳到50000,折线图会瞬间压扁。我后来加了平滑过渡,或者用对数刻度。
  • 横竖屏切换:切换时View会重建,数据就丢了。记得在onSaveInstanceState里保存数据列表。
  • MPAndroidChart的版本兼容:v3.1.0对AndroidX支持良好,但如果你还在用support库,记得用v3.0.2版本。

好了,今天的内容就到这里。两种方案你都可以试试,先用手绘理解原理,再用库提升效率。代码写起来其实不难,关键是理解数据到图形的映射过程。


公众号:蓝海资料掘金营,微信deep3321