Android 绘制沿贝塞尔曲线运动的气泡动画

使用了德卡斯特里奥算法 来计算曲线轨迹点,参考文章
https://blog.csdn.net/venshine/article/details/51750906

BezierData 贝塞尔曲线数据类,用于存储控制点,计算曲线轨迹点

import android.graphics.PointF;
import android.os.Parcel;
import android.os.Parcelable;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/**
 * @author ganmin.he
 * @date 2022/5/13
 */
public class BezierData implements Parcelable {
    private int mOrder; // Order of the Bezier curve
    private float[] mCache; // deCasteljau algorithm cache
    private final List<PointF> mControlPoints; // Control point List
    private final PointF mTmpPoint = new PointF();

    public BezierData() {
        mControlPoints = new ArrayList<>();
    }

    public BezierData(BezierData source) {
        mControlPoints = (ArrayList) ((ArrayList) source.mControlPoints).clone();
    }

    public BezierData(Parcel parcel) {
        mControlPoints = parcel.readArrayList(null);
    }

    public BezierData(Parcel parcel, ClassLoader loader) {
        mControlPoints = parcel.readArrayList(loader);
    }

    public void clearControlPoints() {
        mControlPoints.clear();
    }

    public void setControlPoints(BezierData source) {
        clearControlPoints();
        mControlPoints.addAll(source.mControlPoints);
    }

    public void addControlPoint(PointF point) {
        mControlPoints.add(point);
    }

    public void removeControlPoint() {
        if (!mControlPoints.isEmpty()) {
            mControlPoints.remove(mControlPoints.size() - 1);
        }
    }

    public PointF getControlPoint(int index) {
        return mControlPoints.get(index);
    }

    public int getControlPointsCount() {
        return mControlPoints.size();
    }

    private int getOrder() {
        mOrder = mControlPoints.size() - 1;
        return mOrder;
    }

    private void initCache() {
        int size = (mOrder + 1) * (mOrder + 2);
        if (mCache == null || mCache.length != size) {
            mCache = new float[size];
        }
        Arrays.fill(mCache, Float.NaN);
    }

    public PointF getLocation(float t) {
        getOrder();
        initCache();
        deCasteljau(mOrder, 0, t);
        float x = mCache[0];
        float y = mCache[1];
        mTmpPoint.set(x, y);
        return mTmpPoint;
    }

    /**
     * deCasteljau algorithm
     *
     * @param curOrder current order
     * @param j        control point index
     * @param t        time
     */
    private int deCasteljau(int curOrder, int j, float t) {
        int index = (mOrder - curOrder) * (mOrder - curOrder + 1) / 2 + j;
        int xIndex = 2 * index;
        int yIndex = 2 * index + 1;
        if (!Float.isNaN(mCache[xIndex])) {
            //Log.d("CACHED_VALUE", "cached value " + mCache[xIndex] + ", " + mCache[yIndex]);
            return index;
        }
        if (curOrder == 1) {
            PointF pJ = mControlPoints.get(j);
            PointF pJ1 = mControlPoints.get(j + 1);
            mCache[xIndex] = (1 - t) * pJ.x + t * pJ1.x;
            mCache[yIndex] = (1 - t) * pJ.y + t * pJ1.y;
            return index;
        }
        int nextIndex1 = deCasteljau(curOrder - 1, j, t);
        int nextIndex2 = deCasteljau(curOrder - 1, j + 1, t);
        mCache[xIndex] = (1 - t) * mCache[2 * nextIndex1] + t * mCache[2 * nextIndex2];
        mCache[yIndex] = (1 - t) * mCache[2 * nextIndex1 + 1] + t * mCache[2 * nextIndex2 + 1];
        return index;
    }

    @Override
    public int describeContents() {
        return 0;
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeList(mControlPoints);
    }

    public static final Parcelable.Creator<BezierData> CREATOR
            = new Parcelable.ClassLoaderCreator<BezierData>() {
        @Override
        public BezierData createFromParcel(Parcel in) {
            return new BezierData(in);
        }

        @Override
        public BezierData createFromParcel(Parcel in, ClassLoader loader) {
            return new BezierData(in, loader);
        }

        @Override
        public BezierData[] newArray(int size) {
            return new BezierData[size];
        }
    };
}

Bubble 气泡类,含坐标,半径,颜色,透明度等信息(偷懒,未使用 Builder 模式)

import android.graphics.PointF;

/**
 * @author ganmin.he
 * @date 2022/5/13
 */
public class Bubble implements Cloneable {
    private float mX;
    private float mY;
    private int mRadius;
    private int mColor;
    private int mAlpha;

    private final BezierData mBezierData = new BezierData();

    public Bubble() {
    }

    public Bubble clone() {
        Bubble clone = new Bubble();
        clone.mX = mX;
        clone.mY = mY;
        clone.mRadius = mRadius;
        clone.mColor = mColor;
        clone.mAlpha = mAlpha;
        clone.mBezierData.setControlPoints(mBezierData);
        return clone;
    }

    public Bubble setX(float x) {
        mX = x;
        return this;
    }

    public float getX() {
        return mX;
    }

    public Bubble setY(float y) {
        mY = y;
        return this;
    }

    public float getY() {
        return mY;
    }

    public Bubble setXY(float x, float y) {
        mX = x;
        mY = y;
        return this;
    }

    public Bubble setXY(PointF point) {
        return setXY(point.x, point.y);
    }

    public Bubble setRadius(int radius) {
        mRadius = radius;
        return this;
    }

    public int getRadius() {
        return mRadius;
    }

    public Bubble setColor(int color) {
        mColor = color;
        return this;
    }

    public int getColor() {
        return mColor;
    }

    public Bubble setAlpha(int alpha) {
        mAlpha = alpha;
        return this;
    }

    public int getAlpha() {
        return mAlpha;
    }

    public Bubble setControlPoints(BezierData source, float range) {
        mBezierData.clearControlPoints();
        int count = source.getControlPointsCount();
        if (count < 1) {
            return this;
        }
        mBezierData.addControlPoint(source.getControlPoint(0));
        for (int i = 1; i < count; i++) {
            PointF point = source.getControlPoint(i);
            //float newRange = range / (float) Math.sqrt(i);
            float newRange = range / i;
            float x = RandomUtils.getRandomValue(point.x, newRange);
            float y = RandomUtils.getRandomValue(point.y, newRange);
            mBezierData.addControlPoint(new PointF(x, y));
        }
        return this;
    }

    public void setLocation(float t) {
        setXY(mBezierData.getLocation(t));
    }
}

BezierBubbleView 用于增删调整控制点,以及绘制气泡的View

import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PointF;
import android.os.SystemClock;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.AccelerateInterpolator;

import java.util.ArrayList;
import java.util.List;

/**
 * @author ganmin.he
 * @date 2022/5/13
 */
public class BezierBubbleView extends View implements Animator.AnimatorListener,
        ValueAnimator.AnimatorUpdateListener {
    private static final boolean ADJUST_CONTROL_POINT = false;
    private static final boolean DEBUG = false;

    private static final int BEZIER_WIDTH = 10;   // Line width of the Bezier curve
    private static final int TEXT_SIZE = 40;    // Text paint size

    private static final int DEFAULT_BUBBLE_SIZE = 50;
    private static final int DEFAULT_BUBBLE_COLOR = Color.TRANSPARENT;
    private static final int DEFAULT_BUBBLE_RADIUS = 5;
    private static final int DEFAULT_BUBBLE_RADIUS_RANGE = 2;
    private static final int DEFAULT_BUBBLE_ALPHA = 155;
    private static final int DEFAULT_BUBBLE_ALPHA_RANGE = 100;
    private static final int DEFAULT_DURATION = 2600;
    private static final int DEFAULT_DURATION_RANGE = 400;
    private static final int DEFAULT_DELAY_RANGE = 300;
    private static final float DEFAULT_LOCATION_RANGE = 300f;
    private static final float DEFAULT_BEZIER_TIME_RANGE = 1 / 3f;

    private static final float BEZIER_TIME_START = 0.0f;    // Bezier curve start time
    private static final float BEZIER_TIME_END = 1.0f;    // Bezier curve end time

    private static final String LOCATION = "location";
    private static final String ALPHA = "alpha";

    private final int mBubbleSize;  // Bubble size
    private final int mBubbleRadius;  // Bubble circle radius
    private final int mBubbleRadiusRange;  // Bubble circle radius range
    private final int mBubbleColor; // Bubble color
    private final int mBubbleAlpha;  // Bubble alpha
    private final int mBubbleAlphaRange;  // Bubble alpha range
    private final int mBubbleDuration;    // Animator duration
    private final int mBubbleDurationRange;    // Animator duration range
    private final int mBubbleDelayRange;    // Animator delay range
    private final float mControlPointLocationRange;    // Control point location range
    private final float mBezierTimeRange;    // Bezier curve time range

    private final BezierData mBezierData = new BezierData();
    private final List<Bubble> mBubbles = new ArrayList<>();

    private final Path mBezierPath = new Path();
    private final Paint mBezierPaint = new Paint();
    private final Paint mControlPaint = new Paint();
    private final Paint mTextPaint = new Paint();
    private final Paint mBubblePaint = new Paint();

    private final AnimatorSet mBezierAnimatorSet = new AnimatorSet();
    private final AnimatorSet mAlphaAnimatorSet = new AnimatorSet();
    private boolean mAnimatorPrepared = false;
    private boolean mDrawingBubble = false;
    private boolean mCanAddControlPoint = false;
    private PointF mTargetControlPoint; // Newly added control point or control point to move

    {
        mBezierPaint.setColor(Color.RED);
        mBezierPaint.setStrokeWidth(BEZIER_WIDTH);
        mBezierPaint.setStyle(Paint.Style.STROKE);
        mBezierPaint.setAntiAlias(true);

        mControlPaint.setColor(Color.BLUE);
        mControlPaint.setStyle(Paint.Style.FILL);
        mControlPaint.setAntiAlias(true);

        mTextPaint.setColor(Color.BLACK);
        mTextPaint.setTextSize(TEXT_SIZE);
        mTextPaint.setAntiAlias(true);

        mBubblePaint.setColor(DEFAULT_BUBBLE_COLOR);
        mBubblePaint.setStyle(Paint.Style.FILL);
        mBubblePaint.setAntiAlias(true);
    }

    public BezierBubbleView(Context context) {
        this(context, null);
    }

    public BezierBubbleView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public BezierBubbleView(Context context, AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }

    public BezierBubbleView(Context context, AttributeSet attrs, int defStyleAttr,
                            int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);

        final TypedArray a = context.obtainStyledAttributes(
                attrs, R.styleable.BezierBubbleView, defStyleAttr, defStyleRes);

        mBubbleSize = a.getInt(R.styleable.BezierBubbleView_bubbleSize, DEFAULT_BUBBLE_SIZE);

        mBubbleRadius = a.getDimensionPixelSize(R.styleable.BezierBubbleView_bubbleRadius,
                DEFAULT_BUBBLE_RADIUS);

        mBubbleRadiusRange = a.getDimensionPixelSize(R.styleable.BezierBubbleView_bubbleRadiusRange,
                DEFAULT_BUBBLE_RADIUS_RANGE);

        int bubbleColor = a.getColor(R.styleable.BezierBubbleView_bubbleColor,
                DEFAULT_BUBBLE_COLOR);

        mBubbleAlpha = a.getInt(R.styleable.BezierBubbleView_bubbleAlpha, DEFAULT_BUBBLE_ALPHA);

        mBubbleAlphaRange = a.getInt(R.styleable.BezierBubbleView_bubbleAlphaRange,
                DEFAULT_BUBBLE_ALPHA_RANGE);

        mBubbleDuration = a.getInt(R.styleable.BezierBubbleView_duration, DEFAULT_DURATION);

        mBubbleDurationRange = a.getInt(R.styleable.BezierBubbleView_durationRange,
                DEFAULT_DURATION_RANGE);

        mBubbleDelayRange = a.getInt(R.styleable.BezierBubbleView_delayRange, DEFAULT_DELAY_RANGE);

        mControlPointLocationRange = a.getDimension(
                R.styleable.BezierBubbleView_controlPointLocationRange, DEFAULT_LOCATION_RANGE);

        mBezierTimeRange = a.getFloat(R.styleable.BezierBubbleView_bezierTimeRange,
                DEFAULT_BEZIER_TIME_RANGE);

        int controlPointsResId = a.getResourceId(R.styleable.BezierBubbleView_controlPoints, 0);
        if (controlPointsResId != 0) {
            String[] controlPointsArr = getResources().getStringArray(controlPointsResId);
            try {
                String colorStr = controlPointsArr[0].trim();
                if (bubbleColor == DEFAULT_BUBBLE_COLOR) {
                    bubbleColor = Color.parseColor(colorStr);
                }
                for (int i = 1; i < controlPointsArr.length; i++) {
                    String[] xy = controlPointsArr[i].trim().split("\\s*,\\s*");
                    float x = Float.parseFloat(xy[0]);
                    float y = Float.parseFloat(xy[1]);
                    mBezierData.addControlPoint(new PointF(x, y));
                }
            } catch (NumberFormatException e) {
                e.printStackTrace();
            }
        }
        mBubbleColor = bubbleColor;

        a.recycle();
    }

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

        int count = mBezierData.getControlPointsCount();
        if (count < 1) {
            return;
        }

        canvas.save();

        if (mDrawingBubble) {
            for (Bubble bubble : mBubbles) {
                if (bubble.getAlpha() <= 0) {
                    continue;
                }
                mBubblePaint.setColor(bubble.getColor());
                mBubblePaint.setAlpha(bubble.getAlpha());
                canvas.drawCircle(bubble.getX(), bubble.getY(), bubble.getRadius(), mBubblePaint);
            }
        } else if (ADJUST_CONTROL_POINT) {
            for (int i = 0; i < count; i++) {
                PointF controlPoint = mBezierData.getControlPoint(i);
                canvas.drawCircle(controlPoint.x, controlPoint.y, mBubbleRadius, mControlPaint);
                canvas.drawText("P" + i + "(" + controlPoint.x + "," + controlPoint.y + ")",
                        controlPoint.x + mBubbleRadius * 2,
                        controlPoint.y + mBubbleRadius * 2, mTextPaint);
            }

            if (count > 1) {
                int segment = 100;
                int delta = segment / 100;
                for (int time = 0; time <= segment; time += delta) {
                    float t = (float) time / segment;
                    PointF trackPoint = mBezierData.getLocation(t);
                    if (t == 0.0f) {
                        mBezierPath.reset();
                        mBezierPath.moveTo(trackPoint.x, trackPoint.y);
                    } else {
                        mBezierPath.lineTo(trackPoint.x, trackPoint.y);
                    }
                }
                canvas.drawPath(mBezierPath, mBezierPaint);
            }
        }

        canvas.restore();

        if (DEBUG && mDrawingBubble) {
            long curTime = SystemClock.elapsedRealtimeNanos() / 1000;
            mLogSb.append(curTime - lastTime).append(' ');
            lastTime = curTime;
        }
    }

    private PointF getNearestControlPoint(float x, float y) {
        int count = mBezierData.getControlPointsCount();
        if (count < 1) {
            return null;
        }
        PointF target = mBezierData.getControlPoint(0);
        double minD = Math.hypot(x - target.x, y - target.y);
        for (int i = 1; i < count; i++) {
            PointF tmpPoint = mBezierData.getControlPoint(i);
            double D = Math.hypot(x - tmpPoint.x, y - tmpPoint.y);
            if (minD > D) {
                minD = D;
                target = tmpPoint;
            }
        }
        return target;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (ADJUST_CONTROL_POINT) {
            int action = event.getAction();
            float x = event.getX();
            float y = event.getY();

            switch (action) {
                case MotionEvent.ACTION_DOWN:
                    if (mCanAddControlPoint) {
                        mTargetControlPoint = new PointF(x, y);
                        mBezierData.addControlPoint(mTargetControlPoint);
                        postInvalidate();
                    } else {
                        mTargetControlPoint = getNearestControlPoint(x, y);
                    }
                    if (mTargetControlPoint != null) {
                        return true;
                    }
                case MotionEvent.ACTION_MOVE:
                case MotionEvent.ACTION_UP:
                    if (mTargetControlPoint != null) {
                        mTargetControlPoint.set(x, y);
                        postInvalidate();
                        return true;
                    }
                default:
                    break;
            }
        }

        return super.onTouchEvent(event);
    }

    public void setCanAddControlPoint(boolean canAddControlPoint) {
        mCanAddControlPoint = canAddControlPoint;
    }

    public boolean isCanAddControlPoint() {
        return mCanAddControlPoint;
    }

    public void deleteControlPoint() {
        if (ADJUST_CONTROL_POINT) {
            int count = mBezierData.getControlPointsCount();
            if (count < 1) {
                return;
            }
            mBezierData.removeControlPoint();
            postInvalidate();
        }
    }

    public void startAnimator(long delay) {
        int count = mBezierData.getControlPointsCount();
        if (count < 2) {
            return;
        }
        setupBubbles();
        setupAnimator();
        mBezierAnimatorSet.setStartDelay(delay);
        mAlphaAnimatorSet.setStartDelay(delay);
        mBezierAnimatorSet.start();
        mAlphaAnimatorSet.start();
    }

    private void setupBubbles() {
        if (mBubbles.isEmpty()) {
            for (int i = 0; i < mBubbleSize; i++) {
                Bubble bubble = new Bubble()
                        .setRadius(RandomUtils.getRandomValue(mBubbleRadius, mBubbleRadiusRange))
                        .setColor(mBubbleColor)
                        .setAlpha(RandomUtils.getRandomValue(mBubbleAlpha, mBubbleAlphaRange));
                mBubbles.add(bubble);
            }
        }
        for (Bubble bubble : mBubbles) {
            bubble.setControlPoints(mBezierData, mControlPointLocationRange);
        }
    }

    private void setupAnimator() {
        if (mAnimatorPrepared) {
            return;
        }
        mAnimatorPrepared = true;
        mBezierAnimatorSet.setInterpolator(new AccelerateInterpolator(0.8f));
        mBezierAnimatorSet.addListener(this);
        List<Animator> bezierAnimators = new ArrayList<>(mBubbleSize);
        List<Animator> alphaAnimators = new ArrayList<>(mBubbleSize);

        for (Bubble bubble : mBubbles) {
            float endTime = RandomUtils.getRandomValue(BEZIER_TIME_END - mBezierTimeRange, mBezierTimeRange);
            ObjectAnimator bezierAnimator =
                    ObjectAnimator.ofFloat(bubble, LOCATION, BEZIER_TIME_START, endTime);
            final long bezierDelay;
            if (mBubbleDelayRange == 0) {
                bezierDelay = 0;
            } else {
                bezierDelay = RandomUtils.RAND.nextInt(mBubbleDelayRange);
            }
            bezierAnimator.setStartDelay(bezierDelay);
            long bezierDuration = RandomUtils.getRandomValue(mBubbleDuration, mBubbleDurationRange);
            bezierAnimator.setDuration(bezierDuration);
            bezierAnimator.addUpdateListener(this);
            bezierAnimators.add(bezierAnimator);

            ObjectAnimator alphaAnimator = ObjectAnimator.ofInt(bubble, ALPHA, 0);
            alphaAnimator.setInterpolator(new AccelerateInterpolator());
            long alphaDuration = (long) (bezierDuration * 0.1f);
            alphaAnimator.setStartDelay(bezierDelay + bezierDuration - alphaDuration);
            alphaAnimator.setDuration(alphaDuration);
            alphaAnimators.add(alphaAnimator);
        }
        mBezierAnimatorSet.playTogether(bezierAnimators);
        mAlphaAnimatorSet.playTogether(alphaAnimators);
    }

    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        invalidate();
    }

    @Override
    public void onAnimationStart(Animator animation) {
        if (animation == mBezierAnimatorSet) {
            mDrawingBubble = true;
        }
    }

    @Override
    public void onAnimationEnd(Animator animation) {
        if (animation == mBezierAnimatorSet) {
            mDrawingBubble = false;
            if (DEBUG) {
                printLog(mLogSb);
                mLogSb.delete(0, mLogSb.length());
            }
        }
    }

    @Override
    public void onAnimationCancel(Animator animation) {
    }

    @Override
    public void onAnimationRepeat(Animator animation) {
    }

    private long lastTime = 0;

    private final StringBuilder mLogSb = DEBUG ? new StringBuilder() : null;

    private static void printLog(StringBuilder sb) {
        if (sb == null) {
            return;
        }
        for (int i = 0, step = 1000, len = sb.length(); i < len; i += step) {
            int end = Math.min(i + step, len);
            Log.d("BEZIER_BUBBLE", sb.substring(i, end));
        }
    }

    /*
    @Override
    protected Parcelable onSaveInstanceState() {
        Parcelable parcelable = super.onSaveInstanceState();
        SavedState ss = new SavedState(parcelable);
        ss.mBezierData = mBezierData;
        return ss;
    }

    @Override
    protected void onRestoreInstanceState(Parcelable state) {
        SavedState ss = (SavedState) state;
        super.onRestoreInstanceState(ss.getSuperState());
        mBezierData.setControlPoints(ss.mBezierData);
    }

    public static class SavedState extends BaseSavedState {
        private BezierData mBezierData;

        public SavedState(Parcel source) {
            super(source);
            mBezierData = source.readParcelable(null);
        }

        public SavedState(Parcel source, ClassLoader loader) {
            super(source, loader);
            mBezierData = source.readParcelable(loader);
        }

        public SavedState(Parcelable superState) {
            super(superState);
        }

        @Override
        public void writeToParcel(Parcel out, int flags) {
            super.writeToParcel(out, flags);
            out.writeParcelable(mBezierData, flags);
        }

        public static final Parcelable.Creator<SavedState> CREATOR
                = new Parcelable.ClassLoaderCreator<SavedState>() {
            @Override
            public SavedState createFromParcel(Parcel in) {
                return new SavedState(in);
            }

            @Override
            public SavedState createFromParcel(Parcel in, ClassLoader loader) {
                return new SavedState(in, loader);
            }

            @Override
            public SavedState[] newArray(int size) {
                return new SavedState[size];
            }
        };
    }*/
}

RandomUtils 随机数工具类

import android.os.SystemClock;

import java.util.Random;

/**
 * @author ganmin.he
 * @date 2022/5/13
 */
public class RandomUtils {
    public static final Random RAND = new Random(SystemClock.elapsedRealtime());

    public static int getRandomRange(int range) {
        if (range == 0) {
            return 0;
        } else if (range < 0) {
            range = -range;
        }
        return RAND.nextInt(2 * range) - range;
    }

    public static float getRandomRange(float range) {
        return (2 * RAND.nextFloat() - 1) * range;
    }

    public static int getRandomValue(int base, int range) {
        return base + getRandomRange(range);
    }

    public static float getRandomValue(float base, float range) {
        return base + getRandomRange(range);
    }
}

attrs.xml BezierBubbleView 可配置的属性

<resources>
    <declare-styleable name="BezierBubbleView">
        <attr name="bubbleSize" format="integer" />
        <attr name="bubbleRadius" format="dimension" />
        <attr name="bubbleRadiusRange" format="dimension" />
        <attr name="bubbleColor" format="color" />
        <attr name="bubbleAlpha" format="integer" />
        <attr name="bubbleAlphaRange" format="integer" />
        <attr name="duration" format="integer" />
        <attr name="durationRange" format="integer" />
        <attr name="delayRange" format="integer" />
        <attr name="controlPointLocationRange" format="dimension" />
        <attr name="bezierTimeRange" format="float" />
        <attr name="controlPoints" format="reference" />
    </declare-styleable>
</resources>

效果如下(模拟器有些卡):
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值