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时,一定要了解线头的意义,从而实现更复杂的组合,避免出现绘制不理想的情况。

相关推荐
dadaobusi3 小时前
学习:RV lkvm SBI
java·学习·spring
HEJOO95 小时前
深入理解 Java volatile 关键字:原理、用法与常见误区
java·开发语言·spring
曹牧5 小时前
Java:no content to map due to end of input
java·开发语言
子非鱼a5 小时前
【WEB】multiSQL
前端
阿维的博客日记5 小时前
Example 类全场景实战指南
java·mybatis
两点王爷5 小时前
SpringBoot 项目集成 GeoServer 源码,实现地图服务发布
java·spring boot·后端
Javatutouhouduan6 小时前
Java如何速通性能优化难题?
java·java面试·jvm调优·后端开发·java程序员·java八股文·java性能优化
2601_967760786 小时前
2026年PDF压缩与页码添加工具技术实测:性能、算法与本地化适配深度对比
算法·pdf
lemon_sjdk6 小时前
DOM 节点
java·前端·javascript
nvd116 小时前
深入现代 Web 鉴权架构:网关统一代理 (Forward-Auth) vs 前端持有 JWT 的终极选型与边缘同域实践
前端·架构·状态模式