很早就有一个想法:把数学中的各种曲线在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 = a + bx + c。这里的x可以是小数,但对于手机屏幕来说,是不存在小数的。因为屏幕由众多的像素点组成,像素点不可再分。而且像素点也是有限制的,例如一个2560x1440分辨率的屏幕,x、y的取值都必须在此范围内,否则就显示不下。如果选一个函数y = 来绘制,当x = 100时,y = 10000,已经超出范围太多了。横向不到屏幕的,纵向已经超出屏幕3~4倍,非常影响展示效果。因此,这里取函数y = 0.003来绘制。
本小节将采取较为原始的方式,以实现功能为主:根据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)、、······、组成平滑的、连续的、公式化的曲线。它由法国的Bezier于1960年左右发明,可以扩展到任意阶数。一阶的贝塞尔曲线是一条直线,二阶的贝塞尔曲线是二次方曲线,三阶的贝塞尔曲线是三次方曲线,以此类推。本小节主要介绍二阶和三阶的贝塞尔曲线。
点和是曲线的起点和终点,其他的点并不在曲线上,而是起控制作用。
二阶的贝塞尔曲线由三个点组成,一个是起点,一个是终点,最后一个是控制点,起点和终点在曲线上的切线相交点即为控制点。如下图黑叉所示:
对于起点为、终点为和控制点为的二阶贝塞尔曲线,有如下的公式:
B(t) = * + 2(1-t) t * + *,0t1
三阶贝塞尔曲线由4个点组成,一个起点,一个终点,另外两个是控制点。根据控制点的位置,有两种情况,如下:
对于起点为、终点为和控制点为、的三阶贝塞尔曲线,有如下的公式:
B(t) = * + 3 t * + 3(1-t) * + *,0t1
需要注意的是,贝塞尔曲线的公式,都是针对平面上的点来定义的,和坐标系中定义的y = f(x)不同。点的x、y坐标,都属于因变量,t是自变量。例如,一个三阶贝塞尔曲线,起点是(110,150),控制点是(25,190)、(210,250),终点是(210,30),那么通过贝塞尔曲线,可以得到方程组:
三阶贝塞尔具体点公式
(5)再绘二次方曲线
Android提供了贝塞尔曲线的实现,本小节通过二阶贝塞尔曲线来绘制y = 0.003。
在绘制前,按照第(4)小节的内容,先要确定3个点:起点、终点和控制点。起点和终点很好选择,关键是如何确定控制点呢?如果只是一般的贝塞尔曲线,可以随意指定控制点,但要保证绘制出的曲线就是y = 0.003,就不能随意指定了。
控制点是起点、终点的切线相交点,因此,需要根据切线方程来求得控制点坐标。
假设的切线方程为y = x + ,的切线方程为y = x + 。这两条直线的交点,就是要找的控制点的坐标。第一步就是要确定、、、的值,然后再求解方程组的解。
将原曲线换一种表示法:f(x) = 0.003;对它求导数得到: = 0.006x。
取起始点(-600,1080),终点(600,1080)。那么 = -3.6,即处的切线斜率为-3.6,所以 = -3.6,是切线y = x + 上的点,将它代入,求得 = -1080。使用同样的方式也可以求得、的值,于是得到下面的方程组:
求解方程组得到:
即控制点坐标为(0,-1080),使用它作为控制点,就能保证画出的贝塞尔曲线刚好是y = 0.003。
进一步扩展,先说结论:对于以y轴互相对称且在y = 0.003上的起点、终点,假设它们的纵坐标是,那么控制点坐标必然是(0,-b)。
证明:设起始点为(-a,b),终点为(a,b),其中b = 0.003,那么可以得到切线方程组:
进而得到:
而b = 0.003,那么y = b - 2b = -b,得到坐标(0,-b)。证毕!
这个结论还可以再扩展:不要求以y轴对称,如果以x = 对称,起点、终点纵坐标依旧是b,那么控制点坐标是( ,-b)。证明的方式是相同的,在此就不再重复。
如果是更一般的方程呢?如 y = 0.003 + 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 = -/30000,先看目标效果图:
在用贝塞尔绘制二次方曲线时,起点、终点切线相交点即是控制点。但从上面的第(4)小节得知,三阶的贝塞尔曲线有两个控制点,从直观上看,它们除了在切线上并没有什么其他规律。如何保证绘制的贝塞尔曲线刚好是我们的目标y = -/30000呢?换句话说,如何选取两个控制点,使得贝塞尔曲线满足y = -/30000呢?这个过程涉及到繁琐的数学运算,下面来说说大体思路:
从第(4)小节得知,x、y是自变量t的因变量,对于固定的起始点、二个控制点、终点,可以获得一个方程组。而y = -/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 = -/30000可以知道,x 和t的关系最终必须是一次方的,因为如果是多次方,那么y和t的关系必然超过了3次方,这和另一个方程y = f(t)冲突。因此,将 x = f(t)这个方程展开,、这两项前面的系数必须为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 = /(2 * ) 。之所以选择它,是因为指数增长实在太快了,一般的指数曲线如y = , 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 = x 。
(9)正弦曲线
正弦曲线:y = sin(x)。
(10)余弦曲线
余弦曲线:y = cos(x)。
(11)其他曲线
y = tan(x)、y= cot(x)、y = arcsin(x)、y = arctan(x)等;
未完待续!
网友评论