Android 绘制自定义线头

一、前言

Android 提供了线头设置的方法线头形状有三种:

BUTT 平头、ROUND 圆头、SQUARE 方头,默认为 BUTT。

而当线条变粗的时候,它们就会表现出不同的样子:

从上图我们能看出一些差别

BUTT: 符合线条的真实长度

ROUND: 线条真实长度 + 半径 (半径理论长度为线宽的一半)

SQUARE: 线条真实长度 + 矩形宽度 (宽度理论上也是线宽的一半)

但是这些并不能满足所有需求方案,如果要绘制下图左下侧和有下侧弧形的起点和终点半圆角的线条,这些显然是不行的

二、解决方案

其实这类问题又2种处理方法,第一种是利用Path路径,构建闭合区间;第二种是线条描边,我们给线条添加 border,boder 宽度 * 2 + 中心线条宽度 = 总宽度。

我们这里以第二种实现,原因是Path会增加一定的计算量和复杂程度、其次还要众所周知的原因,Path存在锯齿问题性能问题,当然最根本的原因还是我想这么写。

1条SQUARE + 2条ROUND,为什么这个组合呢?主要是BUTT长度是真实长度,需要增加一定的便宜,如果直线还好,但是曲线需要更多的计算。这里方式绘制2条ROUND,然后1条SQUARE可以,当然反正顺序也可以,至于要不要clip防止过度绘制,自行决定就行,绘制示意图如下。

统一颜色后的效果

三、案例

本篇三种线头+自定义线头都会用到。本篇以仪表盘为例子,进行绘制,核心逻辑如下。

ini 复制代码
private void drawArcRoundLine(Canvas canvas, float outArcR, float startAngle, float swipeAngle, float centerLineWidth) {
        RectF arcRect = new RectF();
        arcRect.left = -outArcR;
        arcRect.right = outArcR;
        arcRect.top = -outArcR;
        arcRect.bottom = outArcR;
        if (centerLineWidth <= 0) {
            //如果中间线宽为0,那么就没必要绘制圆角了,直接使用ROUND类型
            mTextPaint.setStrokeCap(Paint.Cap.ROUND);
            canvas.drawArc(arcRect, startAngle, swipeAngle, false, mTextPaint);
        } else {

            mTextPaint.setStrokeCap(Paint.Cap.SQUARE);  //绘制中间圆弧
            mTextPaint.setStrokeWidth(centerLineWidth);
            canvas.drawArc(arcRect, startAngle, swipeAngle, false, mTextPaint);

            //下面绘制两侧圆弧
            mTextPaint.setStrokeCap(Paint.Cap.ROUND);
            mTextPaint.setStrokeWidth(lineRadius * 2);

            RectF oArcRect = new RectF();
            oArcRect.left = -outArcR - centerLineWidth / 2;
            oArcRect.right = outArcR + centerLineWidth / 2;
            oArcRect.top = -outArcR - centerLineWidth / 2;
            oArcRect.bottom = outArcR + centerLineWidth / 2;

            canvas.drawArc(oArcRect, startAngle, swipeAngle, false, mTextPaint);

            RectF iArcRect = new RectF();
            iArcRect.left = -outArcR + centerLineWidth / 2;
            iArcRect.right = outArcR - centerLineWidth / 2;
            iArcRect.top = -outArcR + centerLineWidth / 2;
            iArcRect.bottom = outArcR - centerLineWidth / 2;
            canvas.drawArc(iArcRect, startAngle, swipeAngle, false, mTextPaint);
        }
    }

全部代码

ini 复制代码
public class SectionPointerMeterView extends View implements ValueAnimator.AnimatorUpdateListener {

    private final String TAG = "SectionPointerMeterView";
    private final boolean isDebug = true;  //注意这里是Debug
    private TextPaint mTextPaint;
    private DisplayMetrics mDisplayMetrics;
    private int mContentHeight = 0;
    private int mContentWidth = 0;

    private final float MIN_ARC_ANGLE = 120f;
    private final float MAX_ARC_ANGLE = 360 - MIN_ARC_ANGLE;
    private int progress = 0;
    private int maxProgress = 100;
    private String tagText = "";
    private final static String UNIT_PERCENT = "%";
    private ValueAnimator animatorProgress = null;
    private volatile boolean isRelease = false;
    private boolean disableComputeColorBlock = false;
    private PorterDuffXfermode MODE_CLEAR = new PorterDuffXfermode(PorterDuff.Mode.CLEAR);
    private SweepGradient colorShader = new SweepGradient(0, 0, new int[]{
            0x33ffffff,
            Color.TRANSPARENT,
            0x33ffffff,
            Color.WHITE,
            0x33ffffff
    }, new float[]{
            0,
            (90) / 360f,
            180f / 360f,
            270 / 360f,
            1.0f
    });

    private float MIN_PADDING = 0.0f;
    private float lineRadius = 0;

    private float minLength = 0f;
    private float outArcR = 0f;
    private float centerX = 0f;
    private float centerY = 0f;
    private float startAngle = 0f;
    private float offsetDegree = 5f;

    public static class ColorBlock {
        int color;
        float ratio;

        public ColorBlock(float ratio, int color) {
            this.color = color;
            this.ratio = ratio;
        }

        static ColorBlock build(float ratio, int color) {
            ColorBlock cb = new ColorBlock(ratio, color);
            return cb;
        }
    }

    public final ColorBlock[] colorBlocks = {
            ColorBlock.build(0f, 0xffFF1D1D),
            ColorBlock.build(0.1f, 0xffFF1D1D),
            ColorBlock.build(0.2f, 0xffF04D11),
            ColorBlock.build(0.3f, 0xffF04D11),
            ColorBlock.build(0.4f, 0xffFEA315),
            ColorBlock.build(0.5f, 0xffFEA315),
            ColorBlock.build(0.6f, 0xffFEA315),
            ColorBlock.build(0.7f, 0xffF8DF38),
            ColorBlock.build(0.8f, 0xffF8DF38),
            ColorBlock.build(0.9f, 0xff10D659),
            ColorBlock.build(1.0f, 0xff10D659),

    };

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

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

    public SectionPointerMeterView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initPaint();
        if (isInEditMode() ||isDebug) {
            setMaxProgress(100);
            setProgress(25);
            setTagText("功效");
        }
    }

    public void setMaxProgress(int maxProgress) {
        if (maxProgress <= 0) {
            throw new IllegalArgumentException(" max progress must be postive num");
        }
        this.maxProgress = maxProgress;
        invalidate();
    }

    public void setProgress(int progress) {

        if (progress < 0) {
            throw new IllegalArgumentException("  progress must be no-nagtive num");
        }
        this.progress = progress;
        invalidate();
    }

    public void setProgress(int progress, boolean isAnimate) {
        if (progress < 0) {
            throw new IllegalArgumentException("  progress must be no-nagtive num");
        }
        if (!isAnimate) {
            setProgress(progress);
            return;
        }

        int current = this.progress;
        int target = progress;

        if (animatorProgress != null) {
            animatorProgress.cancel();
            animatorProgress = null;
        }
        if (target == current) {
            return;
        }
        startProgressAnimation(current, target);

    }

    private void startProgressAnimation(int current, int target) {
        ValueAnimator animator = ValueAnimator.ofInt(current, target);
        animator.setDuration(1100);
        animator.setInterpolator(new BounceInterpolator());
        animatorProgress = animator;
        animatorProgress.addUpdateListener(this);
        animator.start();
    }

    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        this.progress = (int) animation.getAnimatedValue();
        invalidate();
    }

    public void setTagText(String tagText) {
        this.tagText = tagText;
    }

    private void initPaint() {
        mDisplayMetrics = getResources().getDisplayMetrics();
        mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mTextPaint.setAntiAlias(true);
        MIN_PADDING = dp2px(1);
        lineRadius = dp2px(5);

    }


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);

        if (widthMode != MeasureSpec.EXACTLY) {
            widthSize = mDisplayMetrics.widthPixels / 2;
        }

        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if (heightMode != MeasureSpec.EXACTLY) {
            heightSize = widthSize / 2;
        }
        setMeasuredDimension(widthSize, heightSize);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {

        super.onSizeChanged(w, h, oldw, oldh);

        mContentHeight = (int) (h - MIN_PADDING * 2);
        mContentWidth = (int) (w - MIN_PADDING * 2);

    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (isRelease) {
            return;
        }

        mTextPaint.setStyle(Paint.Style.STROKE);

        mTextPaint.setStrokeWidth(dp2px(20));
        float arcStrokeWidth = mTextPaint.getStrokeWidth();
        if (mContentWidth <= arcStrokeWidth || mContentHeight <= arcStrokeWidth) {
            return;
        }

        minLength = Math.min(mContentWidth, mContentHeight) - arcStrokeWidth;
        outArcR = (float) (minLength / (1f + Math.cos(Math.toRadians(MIN_ARC_ANGLE / 2))));
        centerX = getWidth() / 2f;
        centerY = outArcR + MIN_PADDING + arcStrokeWidth / 2;
        startAngle = (180f - MIN_ARC_ANGLE) / 2f + MIN_ARC_ANGLE;

        Bitmap targetBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
        drawArcSection(new Canvas(targetBitmap), arcStrokeWidth);
        RectF rectF = new RectF();
        rectF.left = 0;
        rectF.right = getWidth();
        rectF.top = 0;
        rectF.bottom = getHeight();
        canvas.drawBitmap(targetBitmap, null, rectF, null);
        targetBitmap.recycle();

        int saveCount = canvas.save();
        canvas.translate(centerX, centerY);

        float totalDegree = 360f - (MIN_ARC_ANGLE + offsetDegree * 2);
        float perDegree = totalDegree / 10f;
        mTextPaint.setStrokeWidth(dp2px(1));
        mTextPaint.setColor(Color.WHITE);
        mTextPaint.setStrokeCap(Paint.Cap.BUTT);
        for (int i = 0; i < 11; i++) {
            float angle = (float) Math.toRadians(startAngle + offsetDegree + i * perDegree);
            float sx = (float) (Math.cos(angle) * (outArcR - arcStrokeWidth / 2));
            float sy = (float) (Math.sin(angle) * (outArcR - arcStrokeWidth / 2));

            float ex = (float) (Math.cos(angle) * (outArcR - arcStrokeWidth / 2 - dp2px(5)));
            float ey = (float) (Math.sin(angle) * (outArcR - arcStrokeWidth / 2 - dp2px(5)));

            canvas.drawLine(sx, sy, ex, ey, mTextPaint);
        }


        drawPointer(canvas, outArcR);

        float innerArcR = outArcR / 2f;

        mTextPaint.setStyle(Paint.Style.STROKE);
        mTextPaint.setShader(colorShader);

        RectF innerArcRect = new RectF();
        innerArcRect.left = -innerArcR;
        innerArcRect.right = innerArcR;
        innerArcRect.top = -innerArcR;
        innerArcRect.bottom = innerArcR;
        canvas.drawArc(innerArcRect, startAngle + offsetDegree, MAX_ARC_ANGLE - 2 * offsetDegree, false, mTextPaint);

        mTextPaint.setShader(null);

        canvas.restoreToCount(saveCount);

        mTextPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        drawTextBlock(canvas, centerX, centerY);
        drawTextScale(canvas, centerX, centerY, outArcR - arcStrokeWidth - dp2px(8));

    }

    private void drawPointer(Canvas canvas, float compassRadius) {
        int count = canvas.save();
        float ratio = (progress * 1F / maxProgress);
        float CONTENT_MAX_ARC_ANGEL = (MAX_ARC_ANGLE - offsetDegree * 2);
        float progressAngle = ratio * (MAX_ARC_ANGLE - offsetDegree * 2);

        canvas.rotate(-(CONTENT_MAX_ARC_ANGEL) / 2f + progressAngle);
        RectF pointerRectF = new RectF();
        pointerRectF.left = -dp2px(1);
        pointerRectF.right = dp2px(1);
        pointerRectF.top = -compassRadius * 2 / 3f;
        pointerRectF.bottom = 0;

        mTextPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        mTextPaint.setColor(0xee5191FF);

        canvas.drawRoundRect(pointerRectF, 0, 0, mTextPaint);

        canvas.restoreToCount(count);
    }

    private void drawArcSection(Canvas canvas, float strokeWidth) {


        float centerLineWidth = strokeWidth - lineRadius * 2;
        float degree = MAX_ARC_ANGLE * 2f / (11f);

        int saveCount = canvas.save();
        canvas.translate(centerX, centerY);

        mTextPaint.setColor(0xffFF1D1D);  //圆弧 - 最左侧边缘
        drawArcRoundLine(canvas, outArcR, startAngle, degree, centerLineWidth);

        mTextPaint.setColor(0xff10D659);//圆弧 - 最右侧边缘
        drawArcRoundLine(canvas, outArcR, startAngle + MAX_ARC_ANGLE - degree, degree, centerLineWidth);

        mTextPaint.setStrokeWidth(strokeWidth); //圆弧 - 最做侧第二半圆
        mTextPaint.setColor(0xffF04D11);
        drawArcLine(canvas, outArcR, startAngle + degree, degree);


        mTextPaint.setColor(0xffF8DF38);  //圆弧 - 最右侧第二半圆
        drawArcLine(canvas, outArcR, startAngle + MAX_ARC_ANGLE - degree * 2, degree);

        //圆弧 - 顶部半圆
        mTextPaint.setColor(0xffFEA315);
        drawArcLine(canvas, outArcR, startAngle + degree * 2, degree + degree / 2);

        mTextPaint.setXfermode(MODE_CLEAR);
        drawSplitLines(canvas, outArcR, startAngle, degree, strokeWidth);
        mTextPaint.setXfermode(null);

        canvas.restoreToCount(saveCount);
    }

    private void drawSplitLines(Canvas canvas, float outArcR, float startAngle, float degree, float strokeWith) {
        float degreePadding = 5f;

        int color = mTextPaint.getColor();
        mTextPaint.setColor(Color.MAGENTA);
        mTextPaint.setStrokeWidth(strokeWith + dp2px(1));

        drawSplitLine(canvas, outArcR, startAngle + degree - degreePadding / 2, degreePadding / 2);

        drawSplitLine(canvas, outArcR, startAngle + degree * 2 - degreePadding / 2, degreePadding / 2);

        drawSplitLine(canvas, outArcR, (startAngle + degree * 2 + degree + degree / 2), degreePadding / 2);

        drawSplitLine(canvas, outArcR, startAngle + MAX_ARC_ANGLE - degree, degreePadding / 2);

        mTextPaint.setColor(color);
    }


    private void drawArcLine(Canvas canvas, float outArcR, float startAngle, float swipeAngle) {
        RectF arcRect = new RectF();

        arcRect.left = -outArcR;
        arcRect.right = outArcR;
        arcRect.top = -outArcR;
        arcRect.bottom = outArcR;

        mTextPaint.setStrokeCap(Paint.Cap.BUTT);
        canvas.drawArc(arcRect, startAngle, swipeAngle, false, mTextPaint);

    }

    private void drawSplitLine(Canvas canvas, float outArcR, float startAngle, float swipeAngle) {
        RectF arcRect = new RectF();
        arcRect.left = -outArcR;
        arcRect.right = outArcR;
        arcRect.top = -outArcR;
        arcRect.bottom = outArcR;

        mTextPaint.setStrokeCap(Paint.Cap.BUTT);
        canvas.drawArc(arcRect, startAngle, swipeAngle, false, mTextPaint);

    }


    private void drawArcRoundLine(Canvas canvas, float outArcR, float startAngle, float swipeAngle, float centerLineWidth) {
        RectF arcRect = new RectF();
        arcRect.left = -outArcR;
        arcRect.right = outArcR;
        arcRect.top = -outArcR;
        arcRect.bottom = outArcR;
        if (centerLineWidth <= 0) {
            //如果中间线宽为0,那么就没必要绘制圆角了,直接使用ROUND类型
            mTextPaint.setStrokeCap(Paint.Cap.ROUND);
            canvas.drawArc(arcRect, startAngle, swipeAngle, false, mTextPaint);
        } else {

            mTextPaint.setStrokeCap(Paint.Cap.SQUARE);  //绘制中间圆弧
            mTextPaint.setStrokeWidth(centerLineWidth);
            canvas.drawArc(arcRect, startAngle, swipeAngle, false, mTextPaint);

            //下面绘制两侧圆弧
            mTextPaint.setStrokeCap(Paint.Cap.ROUND);
            mTextPaint.setStrokeWidth(lineRadius * 2);

            RectF oArcRect = new RectF();
            oArcRect.left = -outArcR - centerLineWidth / 2;
            oArcRect.right = outArcR + centerLineWidth / 2;
            oArcRect.top = -outArcR - centerLineWidth / 2;
            oArcRect.bottom = outArcR + centerLineWidth / 2;

            canvas.drawArc(oArcRect, startAngle, swipeAngle, false, mTextPaint);

            RectF iArcRect = new RectF();
            iArcRect.left = -outArcR + centerLineWidth / 2;
            iArcRect.right = outArcR - centerLineWidth / 2;
            iArcRect.top = -outArcR + centerLineWidth / 2;
            iArcRect.bottom = outArcR - centerLineWidth / 2;
            canvas.drawArc(iArcRect, startAngle, swipeAngle, false, mTextPaint);
        }
    }

    private void drawTextScale(Canvas canvas, float centerX, float centerY, float pointerLength) {
        int id = canvas.save();
        canvas.translate(centerX, centerY);
        final String startText = "0%";
        final String endText = maxProgress + "%";

        mTextPaint.setTextSize(dp2px(12));

        float offsetAngle = (180 - MIN_ARC_ANGLE) / 2f; //计算从X轴方向逆时针的角度
        float sx = (float) (Math.cos(Math.toRadians((MIN_ARC_ANGLE + offsetAngle + offsetDegree))) * pointerLength);
        float sy = (float) (Math.sin(Math.toRadians((MIN_ARC_ANGLE + offsetAngle + offsetDegree))) * pointerLength);

        canvas.drawText(startText, sx - mTextPaint.measureText(startText) / 2, sy + getTextPaintBaseline(mTextPaint), mTextPaint);

        float x = (float) (Math.cos(Math.toRadians((offsetAngle - offsetDegree))) * pointerLength);
        float y = (float) (Math.sin(Math.toRadians((offsetAngle - offsetDegree))) * pointerLength);
        canvas.drawText(endText, x - mTextPaint.measureText(endText) / 2, y + getTextPaintBaseline(mTextPaint), mTextPaint);


        canvas.restoreToCount(id);
    }

    private void drawTextBlock(Canvas canvas, float centerX, float centerY) {

        float ratio = (progress * 1F / maxProgress * 1f);


        int id = canvas.save();
        canvas.translate(centerX, centerY);

        final String text = "+" + progress;
        ColorBlock colorBlock = computeColorBlock(ratio);

        if (colorBlock != null) {
            mTextPaint.setColor(colorBlock.color);
        }
        final float textSize = dp2px(40);
        final float textUnitSize = dp2px(20);
        final float textPadding = dp2px(2);
        final float topOffset = dp2px(5) * -1f;

        mTextPaint.setTextSize(textSize);
        mTextPaint.setFakeBoldText(true);
        final float textBaseline = getTextPaintBaseline(mTextPaint);
        float textWidth = mTextPaint.measureText(text);

        mTextPaint.setTextSize(textUnitSize);
        mTextPaint.setFakeBoldText(false);

        float textUnitWidth = mTextPaint.measureText(UNIT_PERCENT);
        float topTextWidth = textWidth + textUnitWidth + textPadding;


        mTextPaint.setTextSize(textSize);
        mTextPaint.setFakeBoldText(true);

        canvas.drawText(text, -topTextWidth / 2, textBaseline + topOffset, mTextPaint);

        mTextPaint.setTextSize(textUnitSize);
        mTextPaint.setFakeBoldText(false);
        canvas.drawText(UNIT_PERCENT, -topTextWidth / 2 + textWidth + textPadding, textBaseline + topOffset, mTextPaint);


        mTextPaint.setTextSize(dp2px(15));
        mTextPaint.setFakeBoldText(false);
        mTextPaint.setColor(0xddffffff);
        float bottomTextWidth = mTextPaint.measureText(tagText);

        canvas.drawText(tagText, -bottomTextWidth / 2, topOffset + textBaseline + getTextHeight(mTextPaint) + getTextPaintBaseline(mTextPaint), mTextPaint);
        canvas.restoreToCount(id);
    }

    //根据进度计算颜色范围,实现文字和指针的变色
    private ColorBlock computeColorBlock(float ratio) {
        if (disableComputeColorBlock) {
            return ColorBlock.build(ratio, Color.WHITE);
        }
        if (ratio <= 0) {
            return colorBlocks[0];
        }
        if (ratio >= 1f) {
            return colorBlocks[colorBlocks.length - 1];
        }
        for (int i = 0; i < colorBlocks.length; i++) {
            if (ratio > colorBlocks[i].ratio) {
                continue;
            }
            if (colorBlocks[i].ratio == ratio) {
                return colorBlocks[i];
            }
            int preIndex = i - 1;
            float dx = Math.abs(colorBlocks[i].ratio - ratio) - Math.abs(colorBlocks[preIndex].ratio - ratio);
            if (dx > 0) {
                return colorBlocks[preIndex];
            }
            return colorBlocks[i];
        }

        return ColorBlock.build(ratio, Color.WHITE);
    }

    public float dp2px(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDisplayMetrics);
    }

    public float sp2px(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDisplayMetrics);
    }

    public static int argb(
            int alpha,
            int red,
            int green,
            int blue) {
        return (alpha << 24) | (red << 16) | (green << 8) | blue;
    }

    //真实宽度 + 笔画上下两侧间隙(符合文本绘制基线)
    private static int getTextHeight(Paint paint) {
        Paint.FontMetricsInt fm = paint.getFontMetricsInt();
        int textHeight = ~fm.top - (~fm.top - ~fm.ascent) - (fm.bottom - fm.descent);
        return textHeight;
    }

    /**
     * 基线到中线的距离=(Descent+Ascent)/2-Descent
     * 注意,实际获取到的Ascent是负数。公式推导过程如下:
     * 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
     */
    public static float getTextPaintBaseline(Paint p) {
        Paint.FontMetrics fontMetrics = p.getFontMetrics();
        return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
    }


    public void setDisableComputeColorBlock(boolean disableComputeColorBlock) {
        this.disableComputeColorBlock = disableComputeColorBlock;
        invalidate();
    }
}

四、总结

线头看似是简单的问题,但是也是容易被忽略的细节,绘制View时,一定要了解线头的意义,从而实现更复杂的组合,避免出现绘制不理想的情况。

相关推荐
哎呦没14 分钟前
SpringBoot框架下的资产管理自动化
java·spring boot·后端
程序员爱技术1 小时前
Vue 2 + JavaScript + vue-count-to 集成案例
前端·javascript·vue.js
m0_571957582 小时前
Java | Leetcode Java题解之第543题二叉树的直径
java·leetcode·题解
并不会2 小时前
常见 CSS 选择器用法
前端·css·学习·html·前端开发·css选择器
衣乌安、2 小时前
【CSS】居中样式
前端·css·css3
兔老大的胡萝卜2 小时前
ppk谈JavaScript,悟透JavaScript,精通CSS高级Web,JavaScript DOM编程艺术,高性能JavaScript pdf
前端·javascript
低代码布道师2 小时前
CSS的三个重点
前端·css
耶啵奶膘4 小时前
uniapp-是否删除
linux·前端·uni-app
魔道不误砍柴功4 小时前
Java 中如何巧妙应用 Function 让方法复用性更强
java·开发语言·python
NiNg_1_2344 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端