前言
前些天看到這個效果圖
[改裝加強版,改進了圓入框的甩尾效果,最重要的一點是
增強ViewPager切換效果和卡片陰影]
整合方式【伸手黨福利】
github地址 : github.com/qdxxxx/Bezi…
多謝老鐵隨手就是一個star,抱拳。
[標題黨一般是: 轉瘋了,專案整合此酷炫動畫只要3步!]
- 注入依賴
Step 1. Add the JitPack repository to your build file
Step 2. Add the dependency
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}複製程式碼
dependencies {
compile 'com.github.qdxxxx:BezierViewPager:v1.0.5'
}複製程式碼
- xml佈局程式碼
<qdx.bezierviewpager_compile.vPage.BezierViewPager
android:id="@+id/view_page"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<qdx.bezierviewpager_compile.BezierRoundView
android:id="@+id/bezRound"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>複製程式碼
- Activity裡面整合程式碼
CardPagerAdapter cardAdapter = new CardPagerAdapter(getApplicationContext());
cardAdapter.addImgUrlList(imgList); //放置圖片url的list
BezierViewPager viewPager = (BezierViewPager) findViewById(R.id.view_page);
viewPager.setAdapter(cardAdapter);
BezierRoundView bezRound = (BezierRoundView) findViewById(R.id.bezRound);
bezRound.attach2ViewPage(viewPager);複製程式碼
方法及屬性介紹
- BezierRoundView
name | format | 中文解釋 |
---|---|---|
color_bez | color | 貝塞爾圓球顏色 |
color_touch | color | 觸控反饋 |
color_stroke | color | 圓框的顏色 |
time_animator | integer | 動畫時間 |
round_count | integer | 圓框數量,即Adapter.getCount |
radius | dimension | 貝塞爾圓球半徑,圓框半徑為(radius-2) |
attach2ViewPage | BezierViewPager | 繫結指定的ViewPager(處理滑動時觸控事件) 並自動設定round_count |
- BezierViewPager[extends ViewPager]
name | format | 中文解釋 |
---|---|---|
showTransformer | float | ViewPager滑動到當前顯示頁的放大比例 |
- CardPagerAdapter[extends PagerAdapter]
name | format | 中文解釋 |
---|---|---|
addImgUrlList | List | 包含圖片地址的list |
setOnCardItemClickListener | OnCardItemClickListener | 當前ViewPager點選事件 返回CurPosition |
setMaxElevationFactor | integer | Adapter裡CardView最大的Elevation |
實現解剖
private PointF p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11;
複製程式碼
p0 = new PointF(0, -mRadius);//mRadius圓的半徑
p6 = new PointF(0, mRadius);
p1 = new PointF(mRadius * bezFactor, -mRadius);//bezFactor即0.5519...
p5 = new PointF(mRadius * bezFactor, mRadius);
p2 = new PointF(mRadius, -mRadius * bezFactor);
p4 = new PointF(mRadius, mRadius * bezFactor);
p3 = new PointF(mRadius, 0);
p9 = new PointF(-mRadius, 0);
p11 = new PointF(-mRadius * bezFactor, -mRadius);
p7 = new PointF(-mRadius * bezFactor, mRadius);
p10 = new PointF(-mRadius, -mRadius * bezFactor);
p8 = new PointF(-mRadius, mRadius * bezFactor);複製程式碼
再繪製path
mPath.moveTo(p0.x, p0.y);
mPath.cubicTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
mPath.cubicTo(p4.x, p4.y, p5.x, p5.y, p6.x, p6.y);
mPath.cubicTo(p7.x, p7.y, p8.x, p8.y, p9.x, p9.y);
mPath.cubicTo(p10.x, p10.y, p11.x, p11.y, p0.x, p0.y);
mPath.close();複製程式碼
一個貝(ri)塞(ben)爾(guo)圓(qi)栩栩如生。
我們嘗試通過手指滑動改變,p2,p3,p4的x軸座標來觀察圓的變化
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
case MotionEvent.ACTION_DOWN:
p2 = new PointF(event.getX() - mWidth / 2, -mRadius * bezFactor);
p3 = new PointF(event.getX() - mWidth / 2, 0);
p4 = new PointF(event.getX() - mWidth / 2, mRadius * bezFactor);
invalidate();
break;
}
return true;
}複製程式碼
2.解剖效果圖
首先我們不考慮反彈效果,圓的變化有3種狀態
- bezier圓還沒離開圓框,p2,3,4 x軸座標由 r , 變化至 2r。
- bezier圓離開圓框,至到達中心位置
[p2,3,4 x軸座標由 2r 變化至 1.5r ],[p8,9,10 x軸座標由 r 變化至 1.5r ] - bezier圓由中心位置,至到達下一個圓框。
[p2,3,4, 8,9,10 x軸座標由 1.5r 變化至 r ]
老樣子,我們用ValueAnimator來模擬一下[0,1]變化的值。【因為ViewPager的onPageScrolled監聽中positionOffset是[0,1)變化的,類似。】
//展示動畫
private ValueAnimator animatorStart;
private TimeInterpolator timeInterpolator = new DecelerateInterpolator();
private float animatedValue; //[0,1]的值
public void startAnimator() {
if (animatorStart != null) {
if (animatorStart.isRunning()) {
return;
}
animatorStart.start();
} else {
animatorStart = ValueAnimator.ofFloat(0, 1f).setDuration(1500);
animatorStart.setInterpolator(timeInterpolator);
animatorStart.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
animatedValue = (float) animation.getAnimatedValue();
invalidate();
}
});
animatorStart.start();
}
}複製程式碼
private float rRadio=1; //P2,3,4 x軸倍數
private float lRadio=1; //P8,9,10倍數
private float tbRadio=1; //y軸縮放倍數
private float disL = 0.5f; //離開圓的閾值
private float disM = 0.8f; //最大值的閾值
private float disA = 0.9f; //到達下個圓框的閾值複製程式碼
if (0 < animatedValue && animatedValue <= disL) { //還沒離開圓框的時候
rRadio = 1f + animatedValue * 2; //[1,2]
}
if (disL < animatedValue && animatedValue <= disM) {//離開圓框,至最大值區域
rRadio = 2 - range0Until1(disL, disM) * 0.5f; // [2,1.5]
lRadio = 1 + range0Until1(disL, disM) * 0.5f; // [1,1.5]
}
if (disM < animatedValue && animatedValue <= disA) { //從最大值,至到達下一個圓框
rRadio = 1.5f - range0Until1(disM, disA) * 0.5f; // [1.5,1]
lRadio = 1.5f - range0Until1(disM, disA) * 0.5f; // [1.5,1]
}複製程式碼
/**
* 將值域轉化為[0,1]
*
* @param minValue 大於等於
* @param maxValue 小於等於
* @return 根據當前 animatedValue,返回 [0,1] 對應的數值
*/
private float range0Until1(float minValue, float maxValue) {
return (animatedValue - minValue) / (maxValue - minValue);
}複製程式碼
請再次原諒我用這麼簡單粗暴的方式畫圓…
mPath.moveTo(p0.x, p0.y * tbRadio);
mPath.cubicTo(p1.x, p1.y * tbRadio, p2.x * rRadio, p2.y, p3.x * rRadio, p3.y);
mPath.cubicTo(p4.x * rRadio, p4.y, p5.x, p5.y * tbRadio, p6.x, p6.y * tbRadio);
mPath.cubicTo(p7.x, p7.y * tbRadio, p8.x * lRadio, p8.y, p9.x * lRadio, p9.y);
mPath.cubicTo(p10.x * lRadio, p10.y, p11.x, p11.y * tbRadio, p0.x, p0.y * tbRadio);
mPath.close();
複製程式碼
理清了上面這些程式碼,一個有靈性的貝塞爾圓就即將繪製成功。我們再加上離開圓至到達下一個圓框這個區域y軸變化,[p,5,6,7, 1,0,11],效果就如下所示。
3.模擬效果
這時候我們已經將貝塞爾圓的運動方式給表達出來了,再加上一些效果[位移/反彈/翻轉],我們就能模擬出貝塞爾圓從一個圓框進入下一個圓框的動畫了。
在上面的基礎上,我們加上反彈效果
if (0 < animatedValue && animatedValue <= disL) { //還沒離開圓框的時候
rRadio = 1f + animatedValue * 2; //[1,2]
}
if (disL < animatedValue && animatedValue <= disM) {//離開圓框,至最大值區域
rRadio = 2 - range0Until1(disL, disM) * 0.5f; // [2,1.5]
lRadio = 1 + range0Until1(disL, disM) * 0.5f; // [1,1.5]
tbRadio = 1 - range0Until1(disL, disM) / 3; // [1 , 2/3]
}
if (disM < animatedValue && animatedValue <= disA) { //從最大值,至到達下一個圓框
rRadio = 1.5f - range0Until1(disM, disA) * 0.5f; // [1.5,1]
lRadio = 1.5f - range0Until1(disM, disA) * (1.5f - boundRadio); //反彈效果,進場 內彈boundRadio lRadio =[1.5,boundRadio]
tbRadio = (range0Until1(disM, disA) + 2) / 3; // [ 2/3,1]
}
if (disA < animatedValue && animatedValue <= 1f) {//到達圓框,lRadio=[boundRadio,1]
rRadio = 1;
tbRadio = 1;
lRadio = boundRadio + range0Until1(disA, 1) * (1 - boundRadio); //反彈效果,飽和
}複製程式碼
再加上位移效果。一開始我在想,貝塞爾圓要不斷的變化形態,還要移動位置。豈不相當的麻煩。後來把它分解成變化狀態+不斷位移效果。
boolean isTrans = false;
float transX = 1f;
if (disL <= animatedValue && animatedValue <= disA) { //離開圓框,至到達下一個圓框
isTrans = true;
//我們設定2個圓框距離為mWidth / 2f
transX = mWidth / 2f * range0Until1(disL, disA); //[0,mWidth / 2f]
}
if (disA < animatedValue && animatedValue <= 1) {//到達下一個圓
isTrans = true;
transX = mWidth / 2;
}
if (isTrans) {
canvas.translate(transX, 0);
}複製程式碼
至此貝塞爾圓球進入右側圓框的效果已經實現,那麼如果圓球要從右側圓框進入左側圓框呢?
【題外話:寫完上面這個效果已經是月黑風高的時候了,腦神經即將進入假死狀態,我心想,雖然複雜了點,但是應該還是可以做的出來的,腦袋執行的速度根本跟不上敲程式碼的速度。根據位移方向的判斷從而設定lRadio和rRadio。有點自信回頭的趕腳。。。休息了一覺第二天醒來天啊嚕,為什麼不用Matrix,只要用path.transform(matrix),就可以做到映象path,所以適當的休息有助於提升效率。】
matrix_bounceL = new Matrix();
matrix_bounceL.preScale(-1, 1);
mPath.transform(matrix_bounceL);
複製程式碼
4.Attach2ViewPager
關聯ViewPager總共有2個要點
- ViewPager的滑動監聽,onPageScrolled。
根據positionOffset和position,獲取我們所要的當前位置/下一個位置/移動方向。 - 手動選擇ViewPager,即手指點選非當前圓框。
4.1 onPageScrolled
首先我們來了解一下onPageScrolled
這個方法中2個我們要用到的引數
- position : 當前cur位置,如果當前是1,手指按住右滑(vPage向左滑動)那就立馬變為0。但如果當前是1,手指按住要左滑至下一個位置才為2
- positionOffset : [0,1) ,到達下一個pos就置為0
我們功能需求分析一下:
- 獲取正確的當前位置curPos
- 獲取正確的貝塞爾球進入的下一個位置nextPos
- 獲取正確的貝塞爾球運動方向
- 配置正確的animatedValue
之前我們用ValueAnimator
來模擬運動狀態,現在我們可以使用positionOffset
關聯到ViewPager
animatedValue = positionOffset;
direction = ((position + positionOffset) - curPos > 0); //運動方向。 true為右邊(手往左滑動)
nextPos = direction ? curPos + 1 : curPos - 1; //右 +1 左 -1
if (!direction) //如果是向左
animatedValue = 1 - animatedValue; //讓 animatedValue 不管是左滑還是右滑,都從[0,1)開始計算
if (positionOffset == 0) {
curPos = position;
nextPos = position;
}
複製程式碼
以上程式碼還需動手除錯,看看log才能更明白的領悟。
從上面的gif可以發現如果緩慢的滑動,pos的位置正確的,但是如果快速滑動,就會發現問題 : [例如0快速滑動到2,貝塞爾圓球會從0滑動到1,再從0滑動到2],打了Log之後我們才發現原來快速滑動的時候,positionOffset到達下一個pos不會置為0!!發現問題後就好解決了。我們加上這一段程式碼就可以解決該問題。(快速滑動可能存在或多或少的問題,我也是花了些時間去測試的。)
//快速滑動的時候,positionOffset有可能不會置於0
if (direction && position + positionOffset > nextPos) { //向右,而且
curPos = position;
nextPos = position + 1;
} else if (!direction && position + positionOffset < nextPos) {
curPos = position;
nextPos = position - 1;
}複製程式碼
onDraw
我們先要獲得每個圓框的圓心x軸座標
private float[] bezPos; //記錄每一個圓心x軸的位置
bezPos = new float[default_round_count]; //根據圓框個數
for (int i = 0; i < default_round_count; i++) {
bezPos[i] = mWidth / (default_round_count + 1) * (i + 1);
}
複製程式碼
假設我們的default_round_count 即圓框個數為4,那麼我們就要分成 4+1 份,再綜合上述的求圓心程式碼,應該會更清晰一點。
根據curPos和nextPos繪製貝塞爾圓球,po出onDraw程式碼
canvas.translate(0, mHeight / 2);
mBezPath.reset();
for (int i = 0; i < default_round_count; i++) {
canvas.drawCircle(bezPos[i], 0, mRadius - 2, mRoundStrokePaint); //繪製圓框
}
if (animatedValue == 1) {
canvas.drawCircle(bezPos[nextPos], 0, mRadius, mBezPaint);
return;
}
canvas.translate(bezPos[curPos], 0); //根據curPos,移動到當前圓框位置
if (0 < animatedValue && animatedValue <= disL) {
rRadio = 1f + animatedValue * 2; // [1,2]
lRadio = 1f;
tbRadio = 1f;
}
if (disL < animatedValue && animatedValue <= disM) {
rRadio = 2 - range0Until1(disL, disM) * 0.5f; // [2,1.5]
lRadio = 1 + range0Until1(disL, disM) * 0.5f; // [1,1.5]
tbRadio = 1 - range0Until1(disL, disM) / 3; // [1 , 2/3]
}
if (disM < animatedValue && animatedValue <= disA) {
rRadio = 1.5f - range0Until1(disM, disA) * 0.5f; // [1.5,1]
lRadio = 1.5f - range0Until1(disM, disA) * (1.5f - boundRadio); //反彈效果,進場 內彈boundRadio
tbRadio = (range0Until1(disM, disA) + 2) / 3; // [ 2/3,1]
}
if (disA < animatedValue && animatedValue <= 1f) {
rRadio = 1;
tbRadio = 1;
lRadio = boundRadio + range0Until1(disA, 1) * (1 - boundRadio); //反彈效果,飽和
}
if (animatedValue == 1 || animatedValue == 0) { //防止極其粗暴的滑動
rRadio = 1f;
lRadio = 1f;
tbRadio = 1f;
}
boolean isTrans = false; //根據nextPos和curPos求出位移距離
float transX = (nextPos - curPos) * (mWidth / (default_round_count + 1));
if (disL <= animatedValue && animatedValue <= disA) {
isTrans = true;
transX = transX * (animatedValue - disL) / (disA - disL);
}
if (disA < animatedValue && animatedValue <= 1) {
isTrans = true;
}
if (isTrans) {
canvas.translate(transX, 0);
}
mBezPath.moveTo(p0.x, p0.y * tbRadio);
mBezPath.cubicTo(p1.x, p1.y * tbRadio, p2.x * rRadio, p2.y, p3.x * rRadio, p3.y);
mBezPath.cubicTo(p4.x * rRadio, p4.y, p5.x, p5.y * tbRadio, p6.x, p6.y * tbRadio);
mBezPath.cubicTo(p7.x, p7.y * tbRadio, p8.x * lRadio, p8.y, p9.x * lRadio, p9.y);
mBezPath.cubicTo(p10.x * lRadio, p10.y, p11.x, p11.y * tbRadio, p0.x, p0.y * tbRadio);
mBezPath.close();
if (!direction) {
mBezPath.transform(matrix_bounceL);
}
canvas.drawPath(mBezPath, mBezPaint);
if (isTrans) {
canvas.save();
}
複製程式碼
4.2 點選圓框,設定ViewPager的curItem
我們需要判斷是否點選到了圓框上,和點選了具體哪個圓框。
在onPageScrolled
方法的時候不進行處理,而是通過ValueAnimator
來模擬數值。從而繪製貝塞爾圓球效果。
private float[] xPivotPos; //根據圓心x軸+mRadius,劃分成不同的區域 ,主要為了判斷觸控x軸的位置
xPivotPos = new float[default_round_count];
for (int i = 0; i < default_round_count; i++) {
xPivotPos[i] = mWidth / (default_round_count + 1) * (i + 1) + mRadius;
}複製程式碼
針對x軸 : 我的做法是用一個陣列xPivotPos 儲存每個圓框最邊緣的位置,即圓心+mRadius,然後我們觸控的時候,就可以找到當前觸控touchPos是屬於哪個(圓框+mRadius)範圍內。只要x >=bezPos[touchPos]-mRadius,就可以清楚的知道是否觸控到了該區域的圓框範圍。
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
float x = event.getX();
float y = event.getY();
if (y <= mHeight / 2 + mRadius && y >= mHeight / 2 - mRadius && !isAniming) { //先判斷y,如果y點選是在圓y軸的範圍
int pos = -Arrays.binarySearch(xPivotPos, x) - 1;
if (pos >= 0 && pos < default_round_count && x + mRadius >= bezPos[pos]) {
nextPos = pos;
if (mViewPage != null && curPos != nextPos) {
mViewPage.setCurrentItem(pos);
isAniming = true;
direction = (curPos < pos);
startAnimator(); //我們通過ValueAnimator來模擬具體的值,不使用ViewPager的onPageScrolled方法。
}
}
return true;
}
break;
}
return super.onTouchEvent(event);
}複製程式碼
至此我們BezierRoundView的用法和繪製方法已經講解完了,下面來看一下ViewPager是怎麼實現切換效果的。
實現ViewPager切換效果
setClipToPadding
【靈魂畫家】
上圖針對的是ViewPager設定Padding之後,
setMaxCardElevation
CardPagerAdapter是我們繼承PagerAdapter
的類,adapter裡的佈局是cardView
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/cardView"
app:cardCornerRadius="10dp"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:cardPreventCornerOverlap="true"
app:cardUseCompatPadding="true">
<!--cardUseCompatPadding 設定陰影之後自動縮小布局大小-->
<ImageView
android:id="@+id/item_iv"
android:scaleType="fitXY"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</android.support.v7.widget.CardView>複製程式碼
先來了解一下cardView setCardElevation(float)
方法。【針對CardViewApi21】
if (!cardView.getUseCompatPadding()) {
cardView.setShadowPadding(0, 0, 0, 0);
return;
}
float elevation = getMaxElevation(cardView);
final float radius = getRadius(cardView);
int hPadding = (int) Math.ceil(RoundRectDrawableWithShadow
.calculateHorizontalPadding(elevation, radius, cardView.getPreventCornerOverlap()));
int vPadding = (int) Math.ceil(RoundRectDrawableWithShadow
.calculateVerticalPadding(elevation, radius, cardView.getPreventCornerOverlap()));
cardView.setShadowPadding(hPadding, vPadding, hPadding, vPadding);複製程式碼
static float calculateVerticalPadding(float maxShadowSize, float cornerRadius,
boolean addPaddingForCorners) {
if (addPaddingForCorners) {
return (float) (maxShadowSize * SHADOW_MULTIPLIER + (1 - COS_45) * cornerRadius);
} else {
return maxShadowSize * SHADOW_MULTIPLIER;
}
}
static float calculateHorizontalPadding(float maxShadowSize, float cornerRadius,
boolean addPaddingForCorners) {
if (addPaddingForCorners) {
return (float) (maxShadowSize + (1 - COS_45) * cornerRadius);
} else {
return maxShadowSize;
}
}複製程式碼
下面看一下效果測試。
ViewPager效果測試
我們來看一下ViewPager左右設定Padding為mWidth / 10的效果
viewPager.setPadding(mWidth / 10, 0, mWidth / 10, 0);
viewPager.setClipToPadding(false);複製程式碼
再來看一下CardPagerAdapter設定MaxElevationFactor為mWidth / 10的效果【adapter.xml的cardCornerRadius不設值,cardUseCompatPadding一定要設定true!!】
int maxFactor = mWidth / 10;
cardAdapter.setMaxElevationFactor(maxFactor);複製程式碼
具體我也不贅述了,看圖應該能分析出兩者的不同。
所以現在綜上所述,制定一個需求
- 不管是設定padding還是Elevation都要保持圖片的寬高比例。
也就是說當我們知道圖片的寬高比例之後,程式碼裡面我們要動態的去調整和設定並保持這個寬高比例。
【這邊有個坑就是設定setMaxElevation它的寬高比是不可抗的,所以我們只能在setPadding的時候,去調節這個比例】
【setMaxElevation
寬的Padding為maxFactor + 0.3*CornerRadius 【0.3≈≈ (1 - COS_45)】
高的Padding為maxFactor*1.5f + 0.3*CornerRadius】
但是!
setMaxElevation
的情況下,在去設定padding,那麼如何保證我們的寬高比?具體請看如下程式碼分析。【可以通過去掉adapter.xml 裡ImagerView 的android:scaleType=”fitXY”屬性測試一下寬高比例是否除錯正確 】
//已知圖片的寬為1920,高1080.
int mWidth = getWindowManager().getDefaultDisplay().getWidth();
float heightRatio = 0.565f; //高是寬的 0.565 ,根據圖片比例
CardPagerAdapter cardAdapter = new CardPagerAdapter(getApplicationContext());
cardAdapter.addImgUrlList(imgList);//新增載入的圖片集合
//設定陰影大小,即vPage 左右兩個圖片相距邊框 maxFactor + 0.3*CornerRadius *2
//設定陰影大小,即vPage 上下圖片相距邊框 maxFactor*1.5f + 0.3*CornerRadius
int maxFactor = mWidth / 25;
cardAdapter.setMaxElevationFactor(maxFactor);
int mWidthPading = mWidth / 8;
//因為我們adapter裡的cardView CornerRadius已經寫死為10dp,所以0.3*CornerRadius=3
//設定Elevation之後,控制元件寬度要減去 (maxFactor + dp2px(3)) * heightRatio
//heightMore 設定Elevation之後,控制元件高度 比 控制元件寬度* heightRatio 多出的部分
float heightMore = (1.5f * maxFactor + dp2px(3)) - (maxFactor + dp2px(3)) * heightRatio;
int mHeightPading = (int) (mWidthPading * heightRatio - heightMore);
BezierViewPager viewPager = (BezierViewPager) findViewById(R.id.view_page);
viewPager.setLayoutParams(new RelativeLayout.LayoutParams(mWidth, (int) (mWidth * heightRatio)));
viewPager.setPadding(mWidthPading, mHeightPading, mWidthPading, mHeightPading);
viewPager.setClipToPadding(false);
viewPager.setAdapter(cardAdapter);複製程式碼
showTransformer
改方法是設定ViewPager移動的時候,cardView放大效果和Elevation陰影效果,具體過程可以自行在ShadowTransformer
檢視,實現過程上文基本也有覆蓋。
總結
零零碎碎也搗鼓了一陣子的自定義View,我在想既然邁出這一步了,就得做好它。
人生總是要有信仰,有夢想才能一直前行,哪怕走的再慢,也是在前行。
如果這篇文章寫的還湊合或者勾引起了你的鬥志的話,歡迎點個star
github.com/qdxxxx/Bezi…