16_Android数学曲线绘制(View、Compose双版本

2023-01-26  本文已影响0人  刘加城

    很早就有一个想法:把数学中的各种曲线在Android上绘制出来。本文将从Android的View和Jetpack Compose双版本出发,绘制一元一次直线、二次方曲线、三次方曲线、指数曲线、对数曲线、正余弦曲线(及变形)、贝塞尔曲线。View版本使用Java语言编写,Compose版本使用Kotlin语言编写。

(1)坐标系绘制

    “工欲善其事,必先利其器”,在绘制各种曲线之前,首先要有一个坐标系,本小节就先来绘制它。
    目标效果图:



    View版本中的实现:将View的中心作为原点,画x轴、y轴、箭头及标识。

//坐标轴自定义View
public class AxisView extends View {
    Paint paint;
    static final int X_MARGIN = 40;
    static final int Y_MARGIN = 80;
    static final int DEFAULT_MARGIN = 50;
    final int DP_10 = dipToPixel(10);
    final int DP_5 = dipToPixel(5);
    final int DP_20 = dipToPixel(20);

    Point leftPoint; //坐标x轴起始点
    Point rightPoint;//坐标x轴终止点,箭头方向
    Point xArrowUpPoint; //x轴上箭头起始点坐标
    Point xArrowDownPoint;//x轴下箭头起始点坐标

    Point bottomPoint;//左边y轴起始点
    Point topPoint;//坐标y轴终止点,箭头方向
    Point yArrowLeftPoint;// y轴左箭头起始点坐标
    Point yArrowRightPoint;//y轴右箭头起始点坐标

    Point originPoint;//原点
    String originTxt = "0";

    Point xLabelPoint;//x字符坐标
    String xStr = "x";

    Point yLabelPoint;//y字符坐标
    String yStr = "y";

    public AxisView(Context context) {
        super(context);
        init();
    }

    public AxisView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    private int dipToPixel(int dip) {
        float scale = getResources().getDisplayMetrics().scaledDensity;
        return (int) (dip * scale + 0.5f);
    }

    private void init() {
        paint = new Paint();
        paint.setStrokeWidth(6.0f);
        paint.setAntiAlias(true);
        paint.setColor(Color.BLACK);
        paint.setTextSize(dipToPixel(15));
    }

    private void initPoint() {
        //View高宽
        int width = getWidth();
        int height = getHeight();

        //x轴起始点计算
        int leftX = getLeft() + X_MARGIN;
        int leftY = getTop() + height / 2;
        leftPoint = new Point(leftX, leftY);

        //x轴终止点计算
        int rightX = getRight() - X_MARGIN;
        int rightY = leftY;//一条水平线,y坐标相等
        rightPoint = new Point(rightX, rightY);

        //x轴箭头的上、下点计算
        xArrowUpPoint = new Point(rightX - DP_10, rightY - DP_5);
        xArrowDownPoint = new Point(rightX - DP_10, rightY + DP_5);

        //x label计算
        xLabelPoint = new Point(rightX - DP_20, rightY + DP_20);

        //y轴终止点计算
        int topX = getLeft() + width / 2;
        int topY = getTop() + Y_MARGIN;
        topPoint = new Point(topX, topY);

        //y轴箭头的左右点计算
        yArrowLeftPoint = new Point(topX - DP_5, topY + DP_10);
        yArrowRightPoint = new Point(topX + DP_5, topY + DP_10);

        //y轴起始点计算
        int bottomX = topX;//一条垂直线,x坐标相等
        int bottomY = getBottom() - Y_MARGIN;
        bottomPoint = new Point(bottomX, bottomY);

        //y label计算
        yLabelPoint = new Point(topX + DP_10, topY + DP_10 + DP_5);

        //坐标原点
        int originX = getLeft() + width / 2 - DEFAULT_MARGIN - dipToPixel(3);
        int originY = getTop() + height / 2 + DEFAULT_MARGIN + dipToPixel(8);
        originPoint = new Point(originX, originY);

    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        initPoint();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        drawX(canvas); //绘制x轴

        drawY(canvas);//绘制y轴

        drawOrigin(canvas);//绘制坐标原点

    }

    //绘制x轴
    private void drawX(Canvas canvas) {
        //绘制水平线
        canvas.drawLine(leftPoint.x, leftPoint.y, rightPoint.x, rightPoint.y, paint);

        //绘制箭头
        canvas.drawLine(xArrowUpPoint.x, xArrowUpPoint.y, rightPoint.x, rightPoint.y, paint);
        canvas.drawLine(xArrowDownPoint.x, xArrowDownPoint.y, rightPoint.x, rightPoint.y, paint);

        //绘制x标识
        canvas.drawText(xStr,xLabelPoint.x,xLabelPoint.y,paint);
    }

    //绘制y轴
    private void drawY(Canvas canvas) {
        //绘制y轴
        canvas.drawLine(bottomPoint.x, bottomPoint.y, topPoint.x, topPoint.y, paint);

        //绘制箭头
        canvas.drawLine(yArrowLeftPoint.x, yArrowLeftPoint.y, topPoint.x, topPoint.y, paint);
        canvas.drawLine(yArrowRightPoint.x, yArrowRightPoint.y, topPoint.x, topPoint.y, paint);

        //绘制y标识
        canvas.drawText(yStr,yLabelPoint.x,yLabelPoint.y,paint);
    }

    //绘制坐标原点
    private void drawOrigin(Canvas canvas) {
        //绘制原点
        canvas.drawText(originTxt, originPoint.x, originPoint.y, paint);
    }
}

    在xml中的引用:

    <com.xxx.xxx.curve.AxisView
        android:layout_width="match_parent"
        android:layout_height="match_parent"></com.xxx.xxx.curve.AxisView>

    再来看Compose版本,首先在build.gradle中添加各种配置项:

    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.3.2" 
    }

dependencies {
    def composeBom = platform('androidx.compose:compose-bom:2022.12.00')
    implementation composeBom
    androidTestImplementation composeBom
    // Material Design 3
    implementation 'androidx.compose.material3:material3'
    // or Material Design 2
    implementation 'androidx.compose.material:material'
    // or skip Material Design and build directly on top of foundational components
    implementation 'androidx.compose.foundation:foundation'
    // or only import the main APIs for the underlying toolkit systems,
    // such as input and measurement/layout
    implementation 'androidx.compose.ui:ui'

    implementation 'androidx.activity:activity-compose:1.6.1'

    // Android Studio Preview support
    implementation 'androidx.compose.ui:ui-tooling-preview'
    debugImplementation 'androidx.compose.ui:ui-tooling'
}

    Compose版本是通过函数Canvas()来实现自定义View的。这里需要区分,是Canvas类还是Canvas()函数,导入包时它们非常的相似,如下:

import androidx.compose.foundation.Canvas
import androidx.compose.ui.graphics.Canvas
import android.graphics.Canvas

    这三者中,第一个其实是Canvas()函数,也是我们将要使用的,它定义在文件androidx.compose.foundation.CanvasKt.kt中;第二个是compose ui包中的一个Canvas类,一开始被它误导了,以为它是正主,但又一直报错,找了很久的资料才纠正过来;第三个是Android 原始的Canvas。
    如果从Java虚拟机的角度,第一个导入会被当作CanvasKt类中的static方法Canvas(...)。Kotlin的这种设计,在一定程度上,非常地误导人。
    代码如下:

import android.os.Bundle
import android.graphics.Paint
import androidx.activity.ComponentActivity

import androidx.compose.runtime.Composable
import androidx.activity.compose.setContent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.Modifier

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AxisView()
        }
    }
}

@Preview
@Composable
fun AxisView() {
    Canvas(modifier = Modifier.fillMaxSize()) {
        val canvasWidth = size.width
        val canvasHeight = size.height
        val X_MARGIN = 40f
        val Y_MARGIN = 80f
        val MARGIN_30 = 30
        val MARGIN_15 = 15
        val MARGIN_50 = 50

        //绘制水平线
        drawLine(
            start = Offset(x = X_MARGIN, y = canvasHeight / 2),
            end = Offset(x = canvasWidth - X_MARGIN, y = canvasHeight / 2),
            color = Color.Black,
            strokeWidth = 6F
        )

        //绘制x轴箭头
        drawLine(
            start = Offset(
                x = canvasWidth - X_MARGIN - MARGIN_30,
                y = canvasHeight / 2 - MARGIN_15
            ),
            end = Offset(x = canvasWidth - X_MARGIN, y = canvasHeight / 2),
            color = Color.Black,
            strokeWidth = 6F
        )
        drawLine(
            start = Offset(
                x = canvasWidth - X_MARGIN - MARGIN_30,
                y = canvasHeight / 2 + MARGIN_15
            ),
            end = Offset(x = canvasWidth - X_MARGIN, y = canvasHeight / 2),
            color = Color.Black,
            strokeWidth = 6F
        )

        //绘制垂直线
        drawLine(
            start = Offset(x = canvasWidth / 2, y = canvasHeight - Y_MARGIN),
            end = Offset(x = canvasWidth / 2, y = Y_MARGIN),
            color = Color.Black,
            strokeWidth = 6F
        )

        //绘制垂直箭头
        drawLine(
            start = Offset(x = canvasWidth / 2 - MARGIN_15, y = Y_MARGIN + MARGIN_30),
            end = Offset(x = canvasWidth / 2, y = Y_MARGIN),
            color = Color.Black,
            strokeWidth = 6F
        )
        drawLine(
            start = Offset(x = canvasWidth / 2 + MARGIN_15, y = Y_MARGIN + MARGIN_30),
            end = Offset(x = canvasWidth / 2, y = Y_MARGIN),
            color = Color.Black,
            strokeWidth = 6F
        )

        //绘制原点"0"
        drawContext.canvas.nativeCanvas.apply {
            drawText("0", canvasWidth / 2 - MARGIN_50, canvasHeight / 2 + MARGIN_50 + MARGIN_15, Paint().apply {
                textSize = 60F
                color = 0xFF000000.toInt()
            })
        }

        //绘制"x"
        drawContext.canvas.nativeCanvas.apply {
            drawText("x", canvasWidth - X_MARGIN - MARGIN_50, canvasHeight / 2 + MARGIN_50 + MARGIN_15, Paint().apply {
                textSize = 60F
                color = 0xFF000000.toInt()
            })
        }

        //绘制"y"
        drawContext.canvas.nativeCanvas.apply {
            drawText("y", canvasWidth/2 + MARGIN_50, Y_MARGIN + MARGIN_50, Paint().apply {
                textSize = 60F
                color = 0xFF000000.toInt()
            })
        }
    }
}

(2)一元一次直线

    一元一次直线的函数表示是:y = ax + b;这里,为了简化,取a = 1,b = 300,即变成y = x + 300 。
    目标效果图:


    这里不再贴和坐标系有关的代码,只贴入口和一元一次直线的实现,入口在AxisView的onDraw()方法里,如下:

    @Override
    protected void onDraw(Canvas canvas) {
      ......
        //绘制一元一次直线
        drawOneVariantOnePowLine(canvas, new Rect(leftPoint.x, topPoint.y, rightPoint.x, bottomPoint.y), paint);
    }

    方法drawOneVariantOnePowLine()的实现:

    public static void drawOneVariantOnePowLine(Canvas canvas, Rect rect, Paint paint) {
        final int MARGIN_20 = 20;
        final int MARGIN_80 = 80;
        final int MARGIN_60 = 60;
        final int B_VALUE = 300;

        int centerX = rect.left + (rect.right - rect.left) / 2;
        int centerY = rect.top + (rect.bottom - rect.top) / 2;

        int startX = rect.left + MARGIN_80;
        //将startX转换为坐标系中的点
        int startAxisX = startX - centerX;
        //根据y = x + 300,计算坐标系中startAxisY
        int startAxisY = startAxisX + B_VALUE;
        //将坐标系中的startAxisY转换为屏幕y坐标
        int startY = centerY - startAxisY;

        int endX = centerX + centerX / 2;
        int endAxisX = endX - centerX;
        int endAxisY = endAxisX + B_VALUE;
        int endY = centerY - endAxisY;

        //绘制直线
        canvas.drawLine(startX, startY, endX, endY, paint);

        //绘制y = x + 300
        canvas.drawText("y = x + 300", endX + MARGIN_20, endY + MARGIN_80, paint);

        //绘制y轴上的相交点"300"
        canvas.drawText("300", centerX + MARGIN_20, centerY - 300 + MARGIN_20 + MARGIN_20, paint);

        //绘制x轴上的相交点"-300"
        canvas.drawText("-300", centerX - 300, centerY + MARGIN_60, paint);
    }

    上述方法里涉及到了屏幕坐标和坐标系坐标的相互转换,屏幕坐标是以左上角为原点,x轴向右是正方向,y轴向下是正方向;而在坐标系中,除了原点不同外,y轴向上才是正方向,这一点需要留意。
    对于Compose版本,入口在MainActivity的onCreate()方法中,如下:

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AxisView()
            OneVariantOnePowLine()
        }
    }

     OneVariantOnePowLine()方法实现:

/**
 * 绘制一元一次直线: y = x + 300
 */
@Preview
@Composable
fun OneVariantOnePowLine() {
    Canvas(modifier = Modifier.fillMaxSize()) {
        val canvasWidth = size.width
        val canvasHeight = size.height
        val MARGIN_80 = 80f
        val MARGIN_20 = 20f
        val MARGIN_60 = 60f
        val B_VALUE = 300f

        val centerX = canvasWidth / 2;
        val centerY = canvasHeight / 2

        val startX = MARGIN_80;
        //将startX转换为坐标系中的点
        val startAxisX = startX - centerX;
        //根据y = x + 300,计算坐标系中startAxisY
        val startAxisY = startAxisX + B_VALUE
        //将坐标系中的startAxisY转换为屏幕y坐标
        val startY = centerY - startAxisY;

        val endX = centerX + centerX / 2
        val endAxisX = endX - centerX;
        val endAxisY = endAxisX + B_VALUE;
        val endY = centerY - endAxisY;

        //绘制直线
        drawLine(
            start = Offset(x = startX, y = startY),
            end = Offset(x = endX, y = endY),
            color = Color.Black,
            strokeWidth = 6F
        )

        //绘制y = x + 300
        drawContext.canvas.nativeCanvas.apply {
            drawText("y = x + 300", endX + MARGIN_20, endY + MARGIN_80, Paint().apply {
                textSize = 60F
                color = 0xFF000000.toInt()
            })
        }

        //绘制y轴上的相交点"300"
        drawContext.canvas.nativeCanvas.apply {
            drawText("300", centerX + MARGIN_20, centerY - 300 + MARGIN_20 + MARGIN_20, Paint().apply {
                textSize = 60F
                color = 0xFF000000.toInt()
            })
        }

        //绘制x轴上的相交点"-300"
        drawContext.canvas.nativeCanvas.apply {
            drawText("-300", centerX - 300, centerY + MARGIN_60, Paint().apply {
                textSize = 60F
                color = 0xFF000000.toInt()
            })
        }

    }
}

    可以看到,Compose版坐标系和一元一次直线之间没有任何的耦合,互不影响。

(3)二次方曲线

    一元二次曲线的函数表示是:y = ax^2 + bx + c。这里的x可以是小数,但对于手机屏幕来说,是不存在小数的。因为屏幕由众多的像素点组成,像素点不可再分。而且像素点也是有限制的,例如一个2560x1440分辨率的屏幕,x、y的取值都必须在此范围内,否则就显示不下。如果选一个函数y = x^2来绘制,当x = 100时,y = 10000,已经超出范围太多了。横向不到屏幕的\frac{1}{14},纵向已经超出屏幕3~4倍,非常影响展示效果。因此,这里取函数y = 0.003x^2来绘制。
    本小节将采取较为原始的方式,以实现功能为主:根据x轴的取值范围,用函数依次计算y值,然后绘制各个点。等介绍完贝塞尔曲线后,会提供一个更高效的版本。两者之间再进行性能对比。
    目标效果图:

二次方曲线

    先来看View版本,入口仍然在AxisView类的onDraw()方法中:

    @Override
    protected void onDraw(Canvas canvas) {
      ......
        //绘制二次方曲线
        quadraticCurve(canvas, new Rect(leftPoint.x, topPoint.y, rightPoint.x, bottomPoint.y), paint);
    }

     quadraticCurve()方法如下:

static void quadraticCurve(Canvas canvas, Rect rect, Paint paint) {

        final int MARGIN_20 = 20;
        final int MARGIN_80 = 80;
        final int MARGIN_60 = 60;

        int centerX = rect.left + (rect.right - rect.left) / 2;
        int centerY = rect.top + (rect.bottom - rect.top) / 2;
        //原点
        Point point = new Point(centerX, centerY);

        // y = 0.003x·x
        int startX = rect.left + MARGIN_80;
        int endX = rect.right - MARGIN_80;

        long beforeDrawPoint = System.nanoTime();
        int count = 0;
        for (int i = startX; i <= endX; i++) {
            count++;
            int tmpAxisX = transferAxisX(i, point); //坐标系转换
            int tmpAxisY = (int) (0.003d * tmpAxisX * tmpAxisX);
            int tmpY = transferAxisY(tmpAxisY, point);//坐标系转屏幕
            canvas.drawPoint(i, tmpY, paint);
        }
        long afterDrawPoint = System.nanoTime();
        Log.d("MathCurve", "[ " + startX + ", " + endX + " ]" + "time = " + (afterDrawPoint - beforeDrawPoint) + "纳秒,drawPoint次数 = " + count);

        Paint textPaint = new Paint();
        textPaint.setStrokeWidth(6f);
        textPaint.setColor(Color.BLACK);
        textPaint.setTextSize(60);
        textPaint.setAntiAlias(true);

        //绘制y = 0.003x
        int labelX = centerX + centerX / 2 - MARGIN_80;
        int labelAxisX = transferAxisX(labelX, point);
        int labelAxisY = (int) (0.003d * labelAxisX * labelAxisX);
        int labelY = transferAxisY(labelAxisY, point);
        float width = textPaint.measureText("y = 0.003x");
        canvas.drawText("y = 0.003x", labelX + MARGIN_20, labelY + MARGIN_60, textPaint);

        Paint paintPow = new Paint();
        paintPow.setStrokeWidth(2f);
        paintPow.setColor(Color.BLACK);
        paintPow.setTextSize(30);
        paintPow.setAntiAlias(true);
        //绘制平方
        canvas.drawText("2", labelX + MARGIN_20 + width, labelY + MARGIN_20 + 10, paintPow);
    }

    //坐标系转换,屏幕->坐标系,将屏幕x坐标转为以point为原点的坐标系x坐标
    static int transferAxisX(int screenValue, Point point) {
        return screenValue - point.x;
    }

    //坐标系转换,坐标系->屏幕,将以point为原点的坐标系y坐标转为屏幕y坐标
    static int transferAxisY(int axisValue, Point point) {
        return point.y - axisValue;
    }

    上面的代码中,打印了每次需要绘制点的个数和以纳秒为单位的耗时。我使用的是2015年的、分辨率为2560x1440的Android测试机,输出数据如下:

01-28 12:26:11.525 15209 15209 D MathCurve: [ 120, 1320 ]time = 1533333纳秒,drawPoint次数 = 1201
01-28 12:26:11.732 15209 15209 D MathCurve: [ 120, 1320 ]time = 1663906纳秒,drawPoint次数 = 1201

    可以看到,绘制的屏幕水平范围是[ 120, 1320 ],且绘制了1201个点,耗时大约1.5ms。后面会介绍一个性能高它两个量级、使用贝塞尔曲线实现的版本。
    再来看Compose实现,入口MainActivity的onCreate()方法:

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AxisView()
            QuadraticCurve()
        }
    }

    QuadraticCurve()实现:

/**
 * 绘制二次方曲线: y = 0.003 * x * x
 */
@Preview
@Composable
fun QuadraticCurve() {
    Canvas(modifier = Modifier.fillMaxSize()) {
        val canvasWidth = size.width
        val canvasHeight = size.height
        val MARGIN_80 = 80F
        val MARGIN_20 = 20F
        val MARGIN_60 = 60F

        val centerX = canvasWidth / 2
        val centerY = canvasHeight / 2

        //原点
        val point = Offset(centerX, centerY)

        val startX = MARGIN_80 + MARGIN_20 * 2
        val endX = canvasWidth - startX

        val beforeDrawPoint = System.nanoTime()
        var count = 0
        var i = startX
        while (i <= endX) {
            val tmpAxisX = transferAxisX(i, point)
            val tmpAxisY = (0.003 * tmpAxisX * tmpAxisX).toFloat()
            val tmpY = transferAxisY(tmpAxisY, point)
            drawContext.canvas.nativeCanvas.apply {
                drawPoint(i, tmpY, Paint().apply {
                    textSize = 60F
                    color = 0xFF000000.toInt()
                    strokeWidth = 6F
                })
            }
            i += 1
            count++
        }
        val afterDrawPoint = System.nanoTime();
        Log.d("MathCurve", "[ " + startX + ", " + endX + " ]" + "time = " + (afterDrawPoint - beforeDrawPoint) + "纳秒,drawPoint次数 = " + count);


        val textPaint = Paint()
        textPaint.textSize = 60F
        textPaint.color = 0xFF000000.toInt()
        textPaint.isAntiAlias = true
        textPaint.strokeWidth = 6F

        //绘制y = 0.003x·x
        val labelX = centerX + centerX / 2 - MARGIN_80
        val labelAxisX = transferAxisX(labelX, point);
        val labelAxisY = (0.003 * labelAxisX * labelAxisX).toFloat()
        val labelY = transferAxisY(labelAxisY, point)
        val width = textPaint.measureText("y = 0.003x");
        drawContext.canvas.nativeCanvas.apply {
            drawText("y = 0.003x", labelX + MARGIN_20, labelY + MARGIN_60, textPaint)
        }

        //绘制平方
        val powPaint = Paint()
        powPaint.textSize = 30F
        powPaint.color = 0xFF000000.toInt()
        powPaint.isAntiAlias = true
        powPaint.strokeWidth = 2F
        drawContext.canvas.nativeCanvas.apply {
            drawText("2", labelX + MARGIN_20 + width, labelY + MARGIN_20 + 10, powPaint)
        }
    }
}

//坐标系转换,屏幕->坐标系,将屏幕x坐标转为以point为原点的坐标系x坐标
fun transferAxisX(i: Float, point: Offset): Float {
    return i - point.x
}

//坐标系转换,坐标系->屏幕,将以point为原点的坐标系y坐标转为屏幕y坐标
fun transferAxisY(tmpAxisY: Float, point: Offset): Float {
    return point.y - tmpAxisY
}

    打印的log如下:

01-28 15:35:39.368 28239 28239 D MathCurve: [ 120.0, 1320.0 ]time = 33650416纳秒,drawPoint次数 = 1201
01-28 15:36:21.563 28239 28239 D MathCurve: [ 120.0, 1320.0 ]time = 39323229纳秒,drawPoint次数 = 1201

    耗时约36ms,这个就太长了,无法接受。
    Compose Canvas 的DrawScope提供了一个drawPoints(...)方法,批量绘制点,下面使用它来代替上面的方式(while前后):

        val beforeDrawPoint = System.nanoTime()
        var i = startX
        val points = ArrayList<Offset>()
        while (i <= endX) {
            val tmpAxisX = transferAxisX(i, point)
            val tmpAxisY = (0.003 * tmpAxisX * tmpAxisX).toFloat()
            val tmpY = transferAxisY(tmpAxisY, point)
            i += 1;
            points.add(Offset(x = i,y = tmpY))
        }
        drawPoints(points = points, strokeWidth = 6F, pointMode = PointMode.Points, color = Color.Black)

        val afterDrawPoint = System.nanoTime();
        Log.d(
            "MathCurve",
            "[ " + startX + ", " + endX + " ]" + "time = " + (afterDrawPoint - beforeDrawPoint) + "纳秒"
        );

    耗时情况如下:

01-28 17:11:47.430 1427 1427 D MathCurve: [ 120.0, 1320.0 ]time = 8098646纳秒
01-28 17:12:15.930 1427 1427 D MathCurve: [ 120.0, 1320.0 ]time = 4807552纳秒
01-28 17:12:21.137 1427 1427 D MathCurve: [ 120.0, 1320.0 ]time = 5836875纳秒

    平均约5-6ms,比上面的36ms要好多了。

(4)贝塞尔曲线简介

    贝塞尔(Bezier)曲线是应用于计算机图形及相关领域的参数化曲线。由一系列的控制点(Control Point)P_0P_1、······、P_n组成平滑的、连续的、公式化的曲线。它由法国的Bezier于1960年左右发明,可以扩展到任意阶数。一阶的贝塞尔曲线是一条直线,二阶的贝塞尔曲线是二次方曲线,三阶的贝塞尔曲线是三次方曲线,以此类推。本小节主要介绍二阶和三阶的贝塞尔曲线。
    点P_0P_n是曲线的起点和终点,其他的点并不在曲线上,而是起控制作用。
    二阶的贝塞尔曲线由三个点组成,一个是起点,一个是终点,最后一个是控制点,起点和终点在曲线上的切线相交点即为控制点。如下图黑叉所示:

二阶贝塞尔曲线
    对于起点为P_0、终点为P_2和控制点为P_1的二阶贝塞尔曲线,有如下的公式:
        B(t) = {(1-t)}^2*P_0 + 2(1-t) t *P_1 + t^2 *P_2,0\leqt\leq1

    三阶贝塞尔曲线由4个点组成,一个起点,一个终点,另外两个是控制点。根据控制点的位置,有两种情况,如下:

三阶贝塞尔曲线的2种形式
    对于起点为P_0、终点为P_3和控制点为P_1P_2的三阶贝塞尔曲线,有如下的公式:
        B(t) = {(1-t)}^3*P_0 + 3(1-t)^2 t *P_1 + 3(1-t) t^2 *P_2 +t^3 *P_3,0\leqt\leq1
    需要注意的是,贝塞尔曲线的公式,都是针对平面上的点来定义的,和坐标系中定义的y = f(x)不同。点的x、y坐标,都属于因变量,t是自变量。例如,一个三阶贝塞尔曲线,起点是(110,150),控制点是(25,190)、(210,250),终点是(210,30),那么通过贝塞尔曲线,可以得到方程组:
三阶贝塞尔具体点公式

(5)再绘二次方曲线

    Android提供了贝塞尔曲线的实现,本小节通过二阶贝塞尔曲线来绘制y = 0.003x^2
    在绘制前,按照第(4)小节的内容,先要确定3个点:起点、终点和控制点。起点和终点很好选择,关键是如何确定控制点呢?如果只是一般的贝塞尔曲线,可以随意指定控制点,但要保证绘制出的曲线就是y = 0.003x^2,就不能随意指定了。

    控制点是起点、终点的切线相交点,因此,需要根据切线方程来求得控制点坐标。
    假设P_0的切线方程为y = k_0x + b_0P_2的切线方程为y = k_2x + b_2 。这两条直线的交点,就是要找的控制点的坐标。第一步就是要确定k_0b_0k_2b_2的值,然后再求解方程组的解。
    将原曲线换一种表示法:f(x) = 0.003x^2;对它求导数得到:f^{\prime}(x) = 0.006x。
    取起始点P_0(-600,1080),终点P_2(600,1080)。那么f^{\prime}(-600) = -3.6,即P_0处的切线斜率为-3.6,所以k_0 = -3.6,P_0是切线y = k_0x + b_0 上的点,将它代入,求得b_0 = -1080。使用同样的方式也可以求得k_2b_2的值,于是得到下面的方程组:
    \begin{cases}y=-3.6x-1080 \\y = 3.6x-1080\end{cases}
    求解方程组得到:
    \begin{cases}x=0 \\y = -1080\end{cases}
    即控制点坐标为(0,-1080),使用它作为控制点,就能保证画出的贝塞尔曲线刚好是y = 0.003x^2
    进一步扩展,先说结论:对于以y轴互相对称且在y = 0.003x^2上的起点、终点,假设它们的纵坐标是b,那么控制点坐标必然是(0,-b)。
    证明:设起始点为(-a,b),终点为(a,b),其中b = 0.003a^2,那么可以得到切线方程组:
    \begin{cases}y=-0.006ax+b-0.006a*a \\y=0.006ax+b-0.006a*a\end{cases}
    进而得到:
    \begin{cases}x=0 \\y = b-0.006a*a\end{cases}
    而b = 0.003a^2,那么y = b - 2b = -b,得到坐标(0,-b)。证毕!
    这个结论还可以再扩展:不要求以y轴对称,如果以x = x_0对称,起点、终点纵坐标依旧是b,那么控制点坐标是( x_0,-b)。证明的方式是相同的,在此就不再重复。
    如果是更一般的方程呢?如 y = 0.003x^2 + 0.9x + 60,它与x轴的交点坐标分别为(-200, 0)、(-100, 0),上面的结论不再适用,不过仍然可以使用切线方程组来求解控制点坐标。

    View版本:

    Path path;
    Paint beizerPaint;

    path = new Path();
    beizerPaint = new Paint();
    beizerPaint.setStrokeWidth(6.0f);
    beizerPaint.setAntiAlias(true);
    beizerPaint.setColor(Color.BLACK);
    beizerPaint.setStyle(Paint.Style.STROKE);

    //二次方曲线,贝塞尔
    public void quadraticCurve(Canvas canvas, Rect rect, Paint beizerPaint, Path path) {
        final int MARGIN_20 = 20;
        final int MARGIN_80 = 80;
        final int MARGIN_60 = 60;

        int centerX = rect.left + (rect.right - rect.left) / 2;
        int centerY = rect.top + (rect.bottom - rect.top) / 2;

        //原点
        Point point = new Point(centerX, centerY);

        //先取起始点的x坐标
        int startX = rect.left + MARGIN_80;
        int startAxisX = transferAxisX(startX, point);
        int startAxisY = (int)(0.003d * startAxisX * startAxisX);
        int startY = transferAxisY(startAxisY,point);

        int endX = rect.right - MARGIN_80;
        int endY = startY;

        Log.d("MathCurve", "屏幕起始点是:( "+startX+", "+startY+" ),转换后的坐标系坐标:( " + startAxisX  + ", "+startAxisY +" )");


        int controlX = centerX; //控制点屏幕坐标x
        int controlY = centerY + startAxisY; //控制点屏幕坐标y,为什么+ startAxisY,看上面的分析
        long beforequad = System.nanoTime();
        path.reset();
        path.moveTo(startX, startY);
        path.quadTo(controlX, controlY, endX, endY);
        canvas.drawPath(path, beizerPaint);
        long afterDrawquad = System.nanoTime();

        Log.d("MathCurve", "[ " + startX + ", " + endX + " ]" + "time = " + (afterDrawquad - beforequad) + "纳秒");

        Paint textPaint = new Paint();
        textPaint.setStrokeWidth(6f);
        textPaint.setColor(Color.BLACK);
        textPaint.setTextSize(60);
        textPaint.setAntiAlias(true);

        //绘制y = 0.003x·x
        int labelX = centerX + centerX / 2 - MARGIN_80;
        int labelAxisX = transferAxisX(labelX, point);
        int labelAxisY = (int) (0.003d * labelAxisX * labelAxisX);
        int labelY = transferAxisY(labelAxisY, point);
        float width = textPaint.measureText("y = 0.003x");
        canvas.drawText("y = 0.003x", labelX + MARGIN_20, labelY + MARGIN_60, textPaint);

        Paint paintPow = new Paint();
        paintPow.setStrokeWidth(2f);
        paintPow.setColor(Color.BLACK);
        paintPow.setTextSize(30);
        paintPow.setAntiAlias(true);
        //绘制平方
        canvas.drawText("2", labelX + MARGIN_20 + width, labelY + MARGIN_20 + 10, paintPow);

    }

    //坐标系转换,屏幕->坐标系,将屏幕x坐标转为以point为原点的坐标系x坐标
    static int transferAxisX(int screenValue, Point point) {
        return screenValue - point.x;
    }

    //坐标系转换,坐标系->屏幕,将以point为原点的坐标系y坐标转为屏幕y坐标
    static int transferAxisY(int axisValue, Point point) {
        return point.y - axisValue;
    }

    打印的log如下:

01-28 19:27:14.033 9497 9497 D MathCurve: 屏幕起始点是:( 120, 158 ),转换后的坐标系坐标:( -600, 1080 )
01-28 19:27:14.034 9497 9497 D MathCurve: [ 120, 1320 ]time = 73333纳秒
01-28 19:27:14.200 9497 9497 D MathCurve: 屏幕起始点是:( 120, 158 ),转换后的坐标系坐标:( -600, 1080 )
01-28 19:27:14.200 9497 9497 D MathCurve: [ 120, 1320 ]time = 43229纳秒

    可以看到,画同样的曲线,所需时间约为0.055ms。从性能上,比(3)中的View版1.5ms提高了约2个量级。
    再来看Compose版,入口依然在MainActivity的onCreate()方法里,这里不再贴了,重复的函数如坐标转换也没有贴(见上面的小节),只看具体实现:

/**
 * 贝塞尔 绘制二次方曲线: y = 0.003 * x * x
 */
@Preview
@Composable
fun QuadraticCurve2() {
    Canvas(modifier = Modifier.fillMaxSize()) {
        val canvasWidth = size.width
        val canvasHeight = size.height
        val MARGIN_80 = 80F
        val MARGIN_20 = 20F
        val MARGIN_60 = 60F

        val centerX = canvasWidth / 2
        val centerY = canvasHeight / 2

        //原点
        val point = Offset(centerX, centerY)

        val startX = MARGIN_80 + MARGIN_20 * 2
        val endX = canvasWidth - startX

        val startAxisX = transferAxisX(startX, point)
        val startAxisY = (0.003 * startAxisX * startAxisX).toFloat();
        val startY = transferAxisY(startAxisY,point);
        val endY = startY

        val controlX = centerX; //控制点屏幕坐标x
        val controlY = centerY + startAxisY; //控制点屏幕坐标y

        val path = Path()

        val beforequad = System.nanoTime()
        path.reset()
        path.moveTo(startX, startY)
        path.quadraticBezierTo(controlX, controlY, endX, endY)
        drawPath(path=path, color = Color.Black, style = Stroke(width = 6F))

        val afterDrawquad = System.nanoTime();
        Log.d("MathCurve", "[ " + startX + ", " + endX + " ]" + "time = " + (afterDrawquad - beforequad) + "纳秒");

        val textPaint = Paint();
        textPaint.setStrokeWidth(6f);
        textPaint.setColor(0xFF000000.toInt());
        textPaint.setTextSize(60F);
        textPaint.setAntiAlias(true);

        //绘制y = 0.003x
        val labelX = centerX + centerX / 2 - MARGIN_80;
        val labelAxisX = transferAxisX(labelX, point);
        val labelAxisY = (0.003 * labelAxisX * labelAxisX).toFloat();
        val labelY = transferAxisY(labelAxisY, point);
        val width = textPaint.measureText("y = 0.003x");
        drawContext.canvas.nativeCanvas.apply {
            drawText("y = 0.003x", labelX + MARGIN_20, labelY + MARGIN_60, textPaint)
        }

        val paintPow = Paint();
        paintPow.setStrokeWidth(2f);
        paintPow.setColor(0xFF000000.toInt());
        paintPow.setTextSize(30F);
        paintPow.setAntiAlias(true);
        //绘制平方
        drawContext.canvas.nativeCanvas.apply {
            drawText("2", labelX + MARGIN_20+ width, labelY + MARGIN_60 +10, paintPow)
        }
    }
}

    log信息如下:

01-29 09:46:03.393 7238 7238 D MathCurve: [ 120.0, 1320.0 ]time = 58698纳秒
01-29 09:46:06.365 7238 7238 D MathCurve: [ 120.0, 1320.0 ]time = 65729纳秒
01-29 09:46:08.949 7238 7238 D MathCurve: [ 120.0, 1320.0 ]time = 62135纳秒

    可以看到,所需时间约0.06ms,比(3)中的Compose版5-6ms提高了约2个量级。

(6)三次方曲线

    三次方曲线也可以通过贝塞尔来绘制。本小节将绘制曲线y = -x^3/30000,先看目标效果图:

三次方曲线
    在用贝塞尔绘制二次方曲线时,起点、终点切线相交点即是控制点。但从上面的第(4)小节得知,三阶的贝塞尔曲线有两个控制点,从直观上看,它们除了在切线上并没有什么其他规律。如何保证绘制的贝塞尔曲线刚好是我们的目标y = -x^3/30000呢?换句话说,如何选取两个控制点,使得贝塞尔曲线满足y = -x^3/30000呢?这个过程涉及到繁琐的数学运算,下面来说说大体思路:

    从第(4)小节得知,x、y是自变量t的因变量,对于固定的起始点、二个控制点、终点,可以获得一个方程组。而y = -x^3/30000,将这一个方程加入,就组成了包含3个方程的方程组。起点和终点很容易选择,选起点(-300, 900),终点(300, -900),两个控制点设为(a, b)、(c, d)。两个控制点应该分别在起点、终点的切线上,它们的切线方程为:y = -9x -1800,y = -9x + 1800,a和b、c和d应该满足这样的关系。因此,只要求得a、c的值,就可以得到b、d的值。
    将(-300, 900)、(a, b)、(c, d)、(300, -900)这组数据,代入方程组的第一个方程x = f(t)。从y = -x^3/30000可以知道,x 和t的关系最终必须是一次方的,因为如果是多次方,那么y和t的关系必然超过了3次方,这和另一个方程y = f(t)冲突。因此,将 x = f(t)这个方程展开,t^3t^2这两项前面的系数必须为0。这样就可以得到关于a、c的二个方程,从而获得解a = -100,c = 100。
    到此,两个控制点坐标就计算出了:(-100, -900)、(100, 900) 。最终得到的四个点:(-300, 900)、(-100, -900)、(100, 900)、(300, -900)。这组数据将会在下面的代码中使用。

    View版本:

    //贝塞尔曲线 绘制 y = - x * x * x / 30000
    public static void cubicCurve(Canvas canvas, Rect rect, Paint beizerPaint, Path path) {
        final int MARGIN_20 = 20;
        final int MARGIN_60 = 60;

        int centerX = rect.left + (rect.right - rect.left) / 2;
        int centerY = rect.top + (rect.bottom - rect.top) / 2;

        //原点
        Point point = new Point(centerX, centerY);

        //起点
        int startAxisX = -300;
        int startAxisY = 900;

        //终点
        int endAxisX = 300;
        int endAxisY = -900;

        //控制点1
        int ctrOnePointX = -100;
        int ctrOnePointY = -900;

        //控制点2
        int ctrTwoPointX = 100;
        int ctrTwoPointY = 900;

        //将上述点转换到屏幕坐标系
        int startX = transferToScreenX(startAxisX,point);
        int startY = transferToScreenY(startAxisY,point);

        int endX = transferToScreenX(endAxisX,point);
        int endY = transferToScreenY(endAxisY,point);

        int ctrOneX = transferToScreenX(ctrOnePointX,point);
        int ctrOneY = transferToScreenY(ctrOnePointY,point);

        int ctrTwoX = transferToScreenX(ctrTwoPointX,point);
        int ctrTwoY = transferToScreenY(ctrTwoPointY,point);

        long beforequad = System.nanoTime();
        path.reset();
        path.moveTo(startX, startY);
        path.cubicTo(ctrOneX, ctrOneY,ctrTwoX, ctrTwoY, endX, endY);
        canvas.drawPath(path, beizerPaint);
        long afterDrawquad = System.nanoTime();

        Log.d("MathCurve", "[ " + startX + ", " + endX + " ]" + "time = " + (afterDrawquad - beforequad) + "纳秒");

        Paint textPaint = new Paint();
        textPaint.setStrokeWidth(6f);
        textPaint.setColor(Color.BLACK);
        textPaint.setTextSize(60);
        textPaint.setAntiAlias(true);

        //绘制y = 0.003x
        int labelX = endX + MARGIN_20;
        int labelY = centerY + centerY/2;
        float width = textPaint.measureText("y = -x");
        canvas.drawText("y = -x", labelX + MARGIN_20, labelY + MARGIN_60, textPaint);

        Paint paintPow = new Paint();
        paintPow.setStrokeWidth(2f);
        paintPow.setColor(Color.BLACK);
        paintPow.setTextSize(30);
        paintPow.setAntiAlias(true);
        float width2 = paintPow.measureText("3");
        //绘制立方
        canvas.drawText("3", labelX + MARGIN_20 + width, labelY + MARGIN_20 + 10, paintPow);

        canvas.drawText("/30000", labelX + MARGIN_20 + width + width2, labelY + MARGIN_60, textPaint);
    }

    //坐标系转换,坐标系->屏幕,
    static int transferToScreenX(int axisX, Point point) {
        return axisX + point.x;
    }

    //坐标系转换,屏幕->坐标系,将屏幕x坐标转为以point为原点的坐标系x坐标
    static int transferToAxisX(int screenValue, Point point) {
        return screenValue - point.x;
    }

    //坐标系转换,坐标系->屏幕,将以point为原点的坐标系y坐标转为屏幕y坐标
    static int transferToScreenY(int axisY, Point point) {
        return point.y - axisY;
    }

    Compose版本:

@Composable
fun CubicCurve() {
    Canvas(modifier = Modifier.fillMaxSize()) {
        val canvasWidth = size.width
        val canvasHeight = size.height
        val MARGIN_80 = 80F
        val MARGIN_20 = 20F
        val MARGIN_60 = 60F

        val centerX = canvasWidth / 2
        val centerY = canvasHeight / 2

        //原点
        val point = Offset(centerX, centerY)

        //起点
        val startAxisX = -300F
        val startAxisY = 900F

        //终点
        val endAxisX = 300f
        val endAxisY = -900f

        //控制点1
        val ctrOnePointX = -100f
        val ctrOnePointY = -900f

        //控制点2
        val ctrTwoPointX = 100f
        val ctrTwoPointY = 900f

        //将上述点转换到屏幕坐标系
        val startX = transferToScreenX(startAxisX, point)
        val startY = transferToScreenY(startAxisY, point)

        val endX = transferToScreenX(endAxisX, point)
        val endY = transferToScreenY(endAxisY, point)

        val ctrOneX = transferToScreenX(ctrOnePointX, point)
        val ctrOneY = transferToScreenY(ctrOnePointY, point)

        val ctrTwoX = transferToScreenX(ctrTwoPointX, point)
        val ctrTwoY = transferToScreenY(ctrTwoPointY, point)

        val path = Path()
        val beforequad = System.nanoTime()

        path.reset()
        path.moveTo(startX, startY)
        path.cubicTo(ctrOneX, ctrOneY, ctrTwoX, ctrTwoY, endX, endY)
        drawPath(path = path, color = Color.Black, style = Stroke(width = 6F))

        val afterDrawquad = System.nanoTime()
        Log.d(
            "MathCurve",
            "[ " + startX + ", " + endX + " ]" + "time = " + (afterDrawquad - beforequad) + "纳秒"
        )

        val textPaint = Paint();
        textPaint.setStrokeWidth(6f);
        textPaint.setColor(0xFF000000.toInt());
        textPaint.setTextSize(60F);
        textPaint.setAntiAlias(true);

        //绘制y = -x
        val labelX = centerX + centerX / 2 - MARGIN_80;
        val labelY = centerY + centerY / 2
        val width = textPaint.measureText("y = -x");
        drawContext.canvas.nativeCanvas.apply {
            drawText("y = -x", labelX + MARGIN_20, labelY + MARGIN_60, textPaint)
        }

        val paintPow = Paint();
        paintPow.setStrokeWidth(2f);
        paintPow.setColor(0xFF000000.toInt());
        paintPow.setTextSize(30F);
        paintPow.setAntiAlias(true);
        val width2 = paintPow.measureText("3");
        //绘制3次方
        drawContext.canvas.nativeCanvas.apply {
            drawText("3", labelX + MARGIN_20 + width, labelY + MARGIN_60 + 10, paintPow)
        }

        //绘制剩余的
        drawContext.canvas.nativeCanvas.apply {
            drawText("/30000", labelX + MARGIN_20 + width + width2, labelY + MARGIN_60, textPaint)
        }
    }
}

//坐标系转换,坐标系->屏幕,x轴
fun transferToScreenX(axisX: Float, point: Offset): Float {
    return axisX + point.x
}

//坐标系转换,坐标系->屏幕,y轴
fun transferToScreenY(axisY: Float, point: Offset): Float {
    return point.y - axisY
}

(7)指数曲线

    贝塞尔方程决定了它只能绘制1到n次方的曲线,往后的曲线就不能再它来绘制了。
    本小节将绘制指数曲线y = {1.018}^{x}/(2 * 10^9) 。之所以选择它,是因为指数增长实在太快了,一般的指数曲线如y = 2^x, x轴不到十几个像素,y轴已经飞出了屏幕范围。为了让曲线能适配手机屏幕,视觉效果更好,才选择了该函数。目标效果图如下:

指数曲线
    View版本实现如下:
    // 指数曲线,y = 1.018^x/a ,a = 2 * pow(10,9)
    public static void powCurve(Canvas canvas, Rect rect, Paint paint) {
        final int MARGIN_20 = 20;
        final int MARGIN_60 = 60;

        int centerX = rect.left + (rect.right - rect.left) / 2;
        int centerY = rect.top + (rect.bottom - rect.top) / 2;

        //原点
        Point point = new Point(centerX, centerY);

        //起点
        int startAxisX = 0;

        //中间点
        int middleAxis = 320; //380

        Path path = new Path();
        
        long beforeDrawPoint = System.nanoTime();
        int count = 0;
        for (int i = startAxisX; i <= middleAxis; i++) {
            count++;
            int tmpX = transferToScreenX(i, point);
            BigDecimal tmp1 = BigDecimal.valueOf(1.018).pow(i);
            int tmpAxisY = tmp1.intValue();
            int tmpY = transferToScreenY(tmpAxisY, point);
            canvas.drawPoint(tmpX, tmpY, paint);
            if (i == middleAxis) {
                path.moveTo(tmpX, tmpY);
            }
        }
        long afterDrawPoint = System.nanoTime();
        Log.d("MathCurve", "time = " + (afterDrawPoint - beforeDrawPoint) + "纳秒,drawPoint次数 = " + count);

        int endAxis = 380;

        //使得后面的线连贯
        for (int i = middleAxis; i <= endAxis; i++) {
            count++;
            int tmpX = transferToScreenX(i, point);
            BigDecimal tmp1 = BigDecimal.valueOf(1.018).pow(i);
            int tmpAxisY = tmp1.intValue();
            int tmpY = transferToScreenY(tmpAxisY, point);
            path.lineTo(tmpX, tmpY);
        }
        canvas.drawPath(path, paint);

        Paint textPaint = new Paint();
        textPaint.setStrokeWidth(6f);
        textPaint.setColor(Color.BLACK);
        textPaint.setTextSize(60);
        textPaint.setAntiAlias(true);

        //绘制y = 1.018
        int labelX = centerX + 2 * MARGIN_20;
        int labelY = rect.top + 2 * MARGIN_60;
        float width = textPaint.measureText("y = 1.018");
        canvas.drawText("y = 1.018", labelX + MARGIN_20, labelY + MARGIN_60, textPaint);

        Paint paintPow = new Paint();
        paintPow.setStrokeWidth(2f);
        paintPow.setColor(Color.BLACK);
        paintPow.setTextSize(40);
        paintPow.setAntiAlias(true);
        float width2 = paintPow.measureText("x");
        //绘制x次方
        canvas.drawText("x", labelX + MARGIN_20 + width, labelY + MARGIN_20 + 10, paintPow);

        float width3 = textPaint.measureText("/(2 * 10");
        canvas.drawText("/(2 * 10", labelX + MARGIN_20 + width + width2, labelY + MARGIN_60, textPaint);

        float width4 = textPaint.measureText("9");
        //9次方
        canvas.drawText("9", labelX + MARGIN_20 + width + width2 + width3, labelY + MARGIN_20 + 10, paintPow);

        canvas.drawText(")", labelX + MARGIN_20 + width + width2 + width3 + width4, labelY + MARGIN_60, textPaint);
    }

    为了不损失精度,代码中使用了BigDecimal类来处理小数的指数运算及中间结果。

(8)对数曲线

    对数曲线:y = log_2x 。

(9)正弦曲线

    正弦曲线:y = sin(x)。

(10)余弦曲线

    余弦曲线:y = cos(x)。

(11)其他曲线

    y = tan(x)、y= cot(x)、y = arcsin(x)、y = arctan(x)等;

    未完待续!

上一篇下一篇

猜你喜欢

热点阅读