概述
今天花了一天時間繪製了一個自定義的曲線圖和折線圖的自定義控制元件,可以說現在是身心疲憊了,有點累,下班回家寫這篇部落格總結下自己的繪製思路,如果有人喜歡的話,麻煩給個star了^_^; 其實這類曲線,折線和柱狀圖的庫現在特別多,而且也已經特別成熟了,目前使用對多的應該是hellochart,mpandroidchart這兩個庫,這兩個庫我之前在專案中還用過,擴充套件性真的超級強,而且基本包含了市面上能用到的大部分效果了,不過就是使用的時候有點麻煩,API特別多,有的效果找了好久才找到,不過有輪子用還是很高興的;
效果展示
這個控制元件主要的功能有折線圖,曲線圖,點的x,y的輔助虛線,區域漸變覆蓋等,當然如果你需要更多的功能,也可以在此基礎上進行擴充套件,比如可以增加具體的值在點的上方等,不過目前我只做了這些功能,其他的有時間再擴充吧; 看下具體的效果:
實現分析
首先理一下思路,看到這種控制元件首先要明確繪製的流程,先要繪製什麼,後要繪製什麼,具體再怎麼做等等 這裡說下我的繪製思路:
- 1,先繪製座標系,然後繪製座標系上的點,再繪製座標系上的文字
- 2,然後開始繪製折線圖和曲線圖
- 3,之後開始繪製虛線和點
- 4,最後繪製覆蓋的漸變區域
- 5,加入繪製的動畫效果
我的具體實現步驟大概就是這個樣子;
繪製
初始化
繪製前肯定要初始化各種畫筆,已經設定控制元件的寬高等操作; 首先初始化畫筆
// 繪製座標系的畫筆
private var mPaintCdt: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
//繪製座標系上刻度點的畫筆
private var mPaintSysPoint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
//繪製折線上的點的畫筆
private var mPaintLinePoint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
//繪製文字的畫筆
private var mPaintText: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
//繪製折線的畫筆
private var mPaintLine: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
//繪製虛線的畫筆
private var mPaintDash: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
//x,y軸的畫筆
private var mPaintSys: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
//繪製覆蓋區域
private var mPaintFillArea: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
//初始化畫筆
private fun initPaint() {
//虛線需要關閉硬體加速
setLayerType(LAYER_TYPE_SOFTWARE, null)
mPaintCdt.style = Paint.Style.STROKE
mPaintCdt.strokeWidth = brokenLineSize
mPaintCdt.color = brokenLineColor
mPaintLinePoint.style = Paint.Style.FILL
mPaintLinePoint.color = brokenLinePointColor
mPaintSysPoint.color = coordinateSystemPointColor
mPaintSys.style = Paint.Style.STROKE
mPaintSys.strokeWidth = coordinateSystemSize
mPaintSys.color = coordinateSystemColor
mPaintText.textAlign = Paint.Align.CENTER
mPaintText.color = Color.WHITE
mPaintText.textSize = 30f
mPaintFillArea.color = Color.YELLOW
mPaintFillArea.style = Paint.Style.FILL
mPaintLine.style = Paint.Style.STROKE
mPaintLine.strokeWidth = brokenLineSize
mPaintLine.color = brokenLineColor
mPaintDash = Paint()
mPaintDash.style = Paint.Style.STROKE
mPaintDash.strokeWidth = dashSize
mPaintDash.color = dashColor
mPaintDash.pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
mXBound = Rect()
mYBound = Rect()
}
複製程式碼
這裡要注意一個點,就是繪製虛線是要關閉硬體加速的,關閉方法setLayerType(LAYER_TYPE_SOFTWARE, null)
還有怎麼設定為繪製虛線,通過mPaintDash.pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
floatArrayOf(10f, 10f)
表示的意思是這條線上面10個畫素的實線,10個畫素的虛線;
然後開始設定寬高,具體程式碼如下:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
setMeasuredDimension(measureSpec(widthMeasureSpec), measureSpec(heightMeasureSpec))
}
private fun measureSpec(heightMeasureSpec: Int): Int {
var result: Int
val specSize = View.MeasureSpec.getSize(heightMeasureSpec) //獲取高的高度 單位 為px
val specMode = View.MeasureSpec.getMode(heightMeasureSpec)//獲取測量的模式
//如果是精確測量,就將獲取View的大小設定給將要返回的測量值
if (specMode == View.MeasureSpec.EXACTLY) {
result = specSize
} else {
result = 400
//如果設定成wrap_content時,給高度指定一個值
if (specMode == View.MeasureSpec.AT_MOST) {
result = Math.min(result, specSize)
}
}
return result
}
複製程式碼
然後獲取控制元件的高度和寬度,並且這裡可以根據寬高設定漸變的shader了;
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
//獲取當前View的寬高
mViewWidth = w
mViewHeight = h
//漸變
mShader = LinearGradient(mViewWidth.toFloat(), mViewHeight.toFloat(), mViewWidth.toFloat(), 0f, intArrayOf(Color.YELLOW, Color.TRANSPARENT), null, Shader.TileMode.REPEAT)
}
複製程式碼
繪製
現在就可以開始進行繪製了,繪製之前我們要根據設定的資料來進行繪製,所以要提供設定資料的方法
fun setChartPoints(points: ArrayList<Int>) {
pointList = ArrayList()
points.forEachIndexed { index, value ->
val point = Point()
point.x = index
point.y = value
pointList.add(point)
}
val yPointArray = arrayListOf<Int>()
for (i in pointList.indices) {
yPointArray.add( pointList[i].y)
}
if (maxY == -1) {
maxY = max(yPointArray)
}
maxX = points.size
//預設x有10格,y5格,這裡你可以修改
xScale = maxX / 10
yScale = maxY / 5
initAnimator(maxX)
invalidate()
}
複製程式碼
這裡我預設設定的是x分為10段,y分為5段,這個是可以隨意修改的; 並且我還設定了y軸的最大值可以手動設定,如果沒有手動設定那麼我是根據你資料中的最大值來進行設定的;
//傳入點的Y的最大座標
var maxY: Int = -1
set(value) {
field = value
invalidate()
}
複製程式碼
這樣設定資料的操作就好了;接下來還需要在繪製之前需要計算x軸和y軸每段的具體值,通過平分view的寬高來計算 程式碼如下:
private val getWidthAndHeight = {
//x軸上需要繪製的刻度的個數
val numX = maxX / xScale
//每格的寬度
everyXwidth = (mViewWidth - margin * 2) / (numX * 1f)
//y軸上需要繪製的刻度的個數
val numY = maxY / yScale
//每格的高度
everyYheight = (mViewHeight - margin * 2) / (numY * 1f)
}
複製程式碼
然後就可以進行繪製了 首先繪製座標系和座標原點,程式碼如下:
//繪製X軸Y軸 以及原點
private val drawCoordinate = { canvas: Canvas ->
canvas.drawLine(margin.toFloat(), (mViewHeight - margin).toFloat(), margin.toFloat(), 5f, mPaintSys)
canvas.drawLine(margin.toFloat(), (mViewHeight - margin).toFloat(), (mViewWidth - 5).toFloat(), (mViewHeight - margin).toFloat(), mPaintSys)
canvas.drawCircle(margin.toFloat(), (mViewHeight - margin).toFloat(), SystemPointRadius, mPaintSysPoint)
}
複製程式碼
效果如下:
private val drawCalibration = { canvas: Canvas ->
//x軸上的點
for (i in pointList.indices) {
canvas.drawCircle(margin + i * everyXwidth, (mViewHeight - margin).toFloat(), SystemPointRadius, mPaintSysPoint)
val indexX = (i * xScale).toString()
if (indexX.toInt() <= maxX) {
//將TextView 的文字放入一個矩形中, 測量TextView的高度和寬度
mPaintText.getTextBounds(indexX, 0, indexX.length, mXBound)
canvas.drawText(indexX, margin + i * everyXwidth, (mViewHeight - margin + 2 * mXBound.height()).toFloat(), mPaintText)
}
}
//繪製y軸文字
for (i in 0..maxY) {
canvas.drawCircle(margin.toFloat(), mViewHeight.toFloat() - margin.toFloat() - i * everyYheight, SystemPointRadius, mPaintSysPoint)
val indexY = (i * yScale).toString()
//這裡直接測量"0",不然會因為0和1或者其他的長度不同導致位置有點問題
mPaintText.getTextBounds("0", 0, "0".length, mYBound)
if (i != 0) {
canvas.drawText(indexY, margin.toFloat() - 2 * mYBound.width(), mViewHeight.toFloat() - margin.toFloat() - i * everyYheight + mYBound.height() / 2, mPaintText)
}
}
}
複製程式碼
效果如下:
之後就開始繪製折線圖和曲線圖了 這裡我搞了個自定義註解的方式來判斷是需要繪製折現還是曲線,順便提一下,kotlin的自定義註解和java的自定義註解寫法上面還是有點區別的;
@IntDef(value = [CURVELINE, BROKENLINE], flag = false)
@Retention(AnnotationRetention.SOURCE)
annotation class LineType
//曲線
const val CURVELINE = 1
//折線
const val BROKENLINE = 2
複製程式碼
然後繫結給控制元件的成員變數,預設是曲線圖
//線的型別,預設是折線
@LineType
var lineType: Int = CURVELINE
set(value) {
field = value
initAnimator(maxX)
invalidate()
}
複製程式碼
接下來是繪製的程式碼,曲線圖採用的是繪製貝塞爾的path,折線圖採用的是直接drawLine,畫線就可以了;
when (lineType) {
BROKENLINE -> {
drawBrokenLine(canvas)
}
CURVELINE -> {
drawCurve(canvas)
}
}
/**
* 繪製曲線
*/
private val drawCurve = { canvas: Canvas ->
val path = Path()
val zeroPoint = Point()
zeroPoint.x = (margin + pointList[0].x / (xScale * 1f) * everyXwidth).toInt()
zeroPoint.y = (mViewHeight - margin - pointList[0].y / (yScale * 1f) * everyYheight).toInt()
path.moveTo(zeroPoint.x.toFloat(), zeroPoint.y.toFloat())
for (i in 0 until mAnimatorValue - 1) {
val startX = pointList[i].x / (xScale * 1f) * everyXwidth
val startY = pointList[i].y / (yScale * 1f) * everyYheight
val startPx = margin + startX
val startPy = mViewHeight.toFloat() - margin.toFloat() - startY
val endX = pointList[i + 1].x / (xScale * 1f) * everyXwidth
val endY = pointList[i + 1].y / (yScale * 1f) * everyYheight
val endPx = margin + endX
val endPy = mViewHeight.toFloat() - margin.toFloat() - endY
val wt = (startPx + endPx) / 2
val p3 = Point()
val p4 = Point()
p3.y = startPy.toInt()
p3.x = wt.toInt()
p4.y = endPy.toInt()
p4.x = wt.toInt()
path.cubicTo(p3.x.toFloat(), p3.y.toFloat(), p4.x.toFloat(), p4.y.toFloat(), endPx, endPy)
canvas.drawPath(path, mPaintLine)
//這裡繪製兩次是為了把線蓋住,效果更好
if (i != 1) {
canvas.drawCircle(startPx, startPy, brokenLinePointRadius, mPaintLinePoint)
}
canvas.drawCircle(endPx, endPy, brokenLinePointRadius, mPaintLinePoint)
}
}
/**
* 繪製折線
* *計算繪製點的座標位置
* 繪製點的座標 = (傳入點的的最大的x,y座標/座標軸上的間隔) * 座標間隔對應的螢幕上的間隔
*/
private val drawBrokenLine = { canvas: Canvas ->
val zeroPoint = Point()
zeroPoint.x = (margin + pointList[0].x / (xScale * 1f) * everyXwidth).toInt()
zeroPoint.y = (mViewHeight - margin - pointList[0].y / (yScale * 1f) * everyYheight).toInt()
for (i in 1 until mAnimatorValue) {
//計算出脫離座標系的點所處的位置
val pointX = pointList[i].x / (xScale * 1f) * everyXwidth
val pointY = pointList[i].y / (yScale * 1f) * everyYheight
//座標系內的點的位置
val startX = zeroPoint.x.toFloat()
val startY = zeroPoint.y.toFloat()
val endX = margin + pointX
val endY = mViewHeight.toFloat() - margin.toFloat() - pointY
canvas.drawLine(startX, startY, endX, endY, mPaintLine)
//這裡繪製兩次是為了把線蓋住,效果更好
canvas.drawCircle(startX, startY, brokenLinePointRadius, mPaintLinePoint)
canvas.drawCircle(endX, endY, brokenLinePointRadius, mPaintLinePoint)
//記錄上一個座標點的位置
zeroPoint.x = endX.toInt()
zeroPoint.y = endY.toInt()
}
}
複製程式碼
效果圖
這裡提一下曲線我是採用的三階貝賽爾曲線進行繪製的,先取前後兩個點的x的中點,設定給兩個額外點的x值,兩個額外點的y值分別設定為兩個點的y值,具體的繪製的點的設定原理如下圖;
接下來就是繪製覆蓋漸變的區域,這裡設定了一個boolean值fillArea來設定是否需要覆蓋區域,如果需要才進行繪製; 程式碼如下
/**
* 繪製曲線
*/
private val drawCurve = { canvas: Canvas ->
val fillPath = Path()
val path = Path()
val zeroPoint = Point()
zeroPoint.x = (margin + pointList[0].x / (xScale * 1f) * everyXwidth).toInt()
zeroPoint.y = (mViewHeight - margin - pointList[0].y / (yScale * 1f) * everyYheight).toInt()
if (fillArea) {
//移動到原點
fillPath.moveTo(margin.toFloat(), (mViewHeight - margin).toFloat())
fillPath.lineTo(zeroPoint.x.toFloat(), zeroPoint.y.toFloat())
}
path.moveTo(zeroPoint.x.toFloat(), zeroPoint.y.toFloat())
for (i in 0 until mAnimatorValue - 1) {
val startX = pointList[i].x / (xScale * 1f) * everyXwidth
val startY = pointList[i].y / (yScale * 1f) * everyYheight
val startPx = margin + startX
val startPy = mViewHeight.toFloat() - margin.toFloat() - startY
val endX = pointList[i + 1].x / (xScale * 1f) * everyXwidth
val endY = pointList[i + 1].y / (yScale * 1f) * everyYheight
val endPx = margin + endX
val endPy = mViewHeight.toFloat() - margin.toFloat() - endY
val wt = (startPx + endPx) / 2
val p3 = Point()
val p4 = Point()
p3.y = startPy.toInt()
p3.x = wt.toInt()
p4.y = endPy.toInt()
p4.x = wt.toInt()
path.cubicTo(p3.x.toFloat(), p3.y.toFloat(), p4.x.toFloat(), p4.y.toFloat(), endPx, endPy)
if (fillArea) {
fillPath.cubicTo(p3.x.toFloat(), p3.y.toFloat(), p4.x.toFloat(), p4.y.toFloat(), endPx, endPy)
if (i == mAnimatorValue - 2) {
fillPath.lineTo(endPx, mViewHeight.toFloat() - margin.toFloat())
fillPath.close()
mPaintFillArea.shader = mShader
canvas.drawPath(fillPath, mPaintFillArea)
}
}
canvas.drawPath(path, mPaintLine)
//這裡繪製兩次是為了把線蓋住,效果更好
if (i != 1) {
canvas.drawCircle(startPx, startPy, brokenLinePointRadius, mPaintLinePoint)
}
canvas.drawCircle(endPx, endPy, brokenLinePointRadius, mPaintLinePoint)
}
}
/**
* 繪製折線
* *計算繪製點的座標位置
* 繪製點的座標 = (傳入點的的最大的x,y座標/座標軸上的間隔) * 座標間隔對應的螢幕上的間隔
*/
private val drawBrokenLine = { canvas: Canvas ->
val zeroPoint = Point()
zeroPoint.x = (margin + pointList[0].x / (xScale * 1f) * everyXwidth).toInt()
zeroPoint.y = (mViewHeight - margin - pointList[0].y / (yScale * 1f) * everyYheight).toInt()
val fillPath = Path()
if (fillArea) {
//移動到原點
fillPath.moveTo(margin.toFloat(), (mViewHeight - margin).toFloat())
fillPath.lineTo(zeroPoint.x.toFloat(), zeroPoint.y.toFloat())
}
for (i in 1 until mAnimatorValue) {
//計算出脫離座標系的點所處的位置
val pointX = pointList[i].x / (xScale * 1f) * everyXwidth
val pointY = pointList[i].y / (yScale * 1f) * everyYheight
//座標系內的點的位置
val startX = zeroPoint.x.toFloat()
val startY = zeroPoint.y.toFloat()
val endX = margin + pointX
val endY = mViewHeight.toFloat() - margin.toFloat() - pointY
canvas.drawLine(startX, startY, endX, endY, mPaintLine)
//這裡繪製兩次是為了把線蓋住,效果更好
canvas.drawCircle(startX, startY, brokenLinePointRadius, mPaintLinePoint)
canvas.drawCircle(endX, endY, brokenLinePointRadius, mPaintLinePoint)
//記錄上一個座標點的位置
zeroPoint.x = endX.toInt()
zeroPoint.y = endY.toInt()
if (fillArea) {
fillPath.lineTo(startX, startY)
if (i == mAnimatorValue - 1) {
fillPath.lineTo(endX, endY)
fillPath.lineTo(endX, mViewHeight.toFloat() - margin.toFloat())
fillPath.close()
mPaintFillArea.shader = mShader
canvas.drawPath(fillPath, mPaintFillArea)
}
}
}
}
複製程式碼
再來看一下效果:
接著開始新增輔助的虛線,同樣是通過一個boolean值isShowDash進行設定是否需要設定; 程式碼如下
/**
* 繪製曲線
*/
private val drawCurve = { canvas: Canvas ->
val fillPath = Path()
val path = Path()
val zeroPoint = Point()
zeroPoint.x = (margin + pointList[0].x / (xScale * 1f) * everyXwidth).toInt()
zeroPoint.y = (mViewHeight - margin - pointList[0].y / (yScale * 1f) * everyYheight).toInt()
if (fillArea) {
//移動到原點
fillPath.moveTo(margin.toFloat(), (mViewHeight - margin).toFloat())
fillPath.lineTo(zeroPoint.x.toFloat(), zeroPoint.y.toFloat())
}
path.moveTo(zeroPoint.x.toFloat(), zeroPoint.y.toFloat())
for (i in 0 until mAnimatorValue - 1) {
val startX = pointList[i].x / (xScale * 1f) * everyXwidth
val startY = pointList[i].y / (yScale * 1f) * everyYheight
val startPx = margin + startX
val startPy = mViewHeight.toFloat() - margin.toFloat() - startY
val endX = pointList[i + 1].x / (xScale * 1f) * everyXwidth
val endY = pointList[i + 1].y / (yScale * 1f) * everyYheight
val endPx = margin + endX
val endPy = mViewHeight.toFloat() - margin.toFloat() - endY
val wt = (startPx + endPx) / 2
val p3 = Point()
val p4 = Point()
p3.y = startPy.toInt()
p3.x = wt.toInt()
p4.y = endPy.toInt()
p4.x = wt.toInt()
path.cubicTo(p3.x.toFloat(), p3.y.toFloat(), p4.x.toFloat(), p4.y.toFloat(), endPx, endPy)
if (fillArea) {
fillPath.cubicTo(p3.x.toFloat(), p3.y.toFloat(), p4.x.toFloat(), p4.y.toFloat(), endPx, endPy)
if (i == mAnimatorValue - 2) {
fillPath.lineTo(endPx, mViewHeight.toFloat() - margin.toFloat())
fillPath.close()
mPaintFillArea.shader = mShader
canvas.drawPath(fillPath, mPaintFillArea)
}
}
canvas.drawPath(path, mPaintLine)
//這裡繪製兩次是為了把線蓋住,效果更好
if (i != 1) {
canvas.drawCircle(startPx, startPy, brokenLinePointRadius, mPaintLinePoint)
}
canvas.drawCircle(endPx, endPy, brokenLinePointRadius, mPaintLinePoint)
if (isShowDash) {
//繪製橫向虛線
canvas.drawLine(margin.toFloat(), mViewHeight.toFloat() - margin.toFloat() - startY - brokenLinePointRadius / 2, margin + startX - brokenLinePointRadius / 2, mViewHeight.toFloat() - margin.toFloat() - startY - brokenLinePointRadius / 2, mPaintDash)
//繪製豎向虛線
canvas.drawLine(endPx, endPy, endPx, mViewHeight.toFloat() - margin.toFloat() - brokenLinePointRadius, mPaintDash)
}
}
}
/**
* 繪製折線
* *計算繪製點的座標位置
* 繪製點的座標 = (傳入點的的最大的x,y座標/座標軸上的間隔) * 座標間隔對應的螢幕上的間隔
*/
private val drawBrokenLine = { canvas: Canvas ->
val zeroPoint = Point()
zeroPoint.x = (margin + pointList[0].x / (xScale * 1f) * everyXwidth).toInt()
zeroPoint.y = (mViewHeight - margin - pointList[0].y / (yScale * 1f) * everyYheight).toInt()
val fillPath = Path()
if (fillArea) {
//移動到原點
fillPath.moveTo(margin.toFloat(), (mViewHeight - margin).toFloat())
fillPath.lineTo(zeroPoint.x.toFloat(), zeroPoint.y.toFloat())
}
for (i in 1 until mAnimatorValue) {
//計算出脫離座標系的點所處的位置
val pointX = pointList[i].x / (xScale * 1f) * everyXwidth
val pointY = pointList[i].y / (yScale * 1f) * everyYheight
//座標系內的點的位置
val startX = zeroPoint.x.toFloat()
val startY = zeroPoint.y.toFloat()
val endX = margin + pointX
val endY = mViewHeight.toFloat() - margin.toFloat() - pointY
canvas.drawLine(startX, startY, endX, endY, mPaintLine)
//這裡繪製兩次是為了把線蓋住,效果更好
canvas.drawCircle(startX, startY, brokenLinePointRadius, mPaintLinePoint)
canvas.drawCircle(endX, endY, brokenLinePointRadius, mPaintLinePoint)
//記錄上一個座標點的位置
zeroPoint.x = endX.toInt()
zeroPoint.y = endY.toInt()
if (isShowDash) {
//繪製橫向虛線
canvas.drawLine(margin.toFloat(), mViewHeight.toFloat() - margin.toFloat() - pointY - brokenLinePointRadius / 2, margin + pointX - brokenLinePointRadius / 2, mViewHeight.toFloat() - margin.toFloat() - pointY - brokenLinePointRadius / 2, mPaintDash)
//繪製豎向虛線
canvas.drawLine(zeroPoint.x.toFloat(), zeroPoint.y.toFloat(), zeroPoint.x.toFloat(), mViewHeight.toFloat() - margin.toFloat() - brokenLinePointRadius, mPaintDash)
}
if (fillArea) {
fillPath.lineTo(startX, startY)
if (i == mAnimatorValue - 1) {
fillPath.lineTo(endX, endY)
fillPath.lineTo(endX, mViewHeight.toFloat() - margin.toFloat())
fillPath.close()
mPaintFillArea.shader = mShader
canvas.drawPath(fillPath, mPaintFillArea)
}
}
}
}
複製程式碼
看下現在的效果:
最後進行給繪製過程加上動畫效果: 具體程式碼如下:
private lateinit var valueAnimator: ValueAnimator
private var mAnimatorValue: Int = 0
private lateinit var mUpdateListener: ValueAnimator.AnimatorUpdateListener
private val defaultDuration = 2000
private fun initAnimator(maxX: Int) {
valueAnimator = ValueAnimator.ofInt(0, maxX).setDuration(defaultDuration.toLong())
mUpdateListener = ValueAnimator.AnimatorUpdateListener { animation ->
mAnimatorValue = animation.animatedValue as Int
invalidate()
}
valueAnimator.addUpdateListener(mUpdateListener)
valueAnimator.start()
}
複製程式碼
這樣一個曲線和折現圖就繪製完成了,再來看下效果:
如果喜歡請關注我的公眾號,時時分享技術乾貨,公眾號二維碼如下: