鸿蒙开源第三方组件——SwipeCaptcha_ohos2.0滑动拼图验证组件 原创 精华

发布于 2021-10-15 14:46
浏览
15收藏

前言

基于安卓平台的滑动拼图验证组件SwipeCaptcha(https://github.com/mcxtzhang/SwipeCaptcha ),实现了鸿蒙化迁移和重构,代码已经开源到(https://gitee.com/isrc_ohos/swipe-captcha_ohos ),目前已经获得了很多人的Star和Fork ,欢迎各位下载使用并提出宝贵意见!

背景

在页面登录或者注册的时候,为了确保不是机器人操作(若要实现防机器人操作效果,需要增加加密算法,本期介绍的组件中不包含此部分),会让用户手动验证。验证方式分为滑动拼图验证和滑动验证两种。

  • 滑动拼图验证:有图片作为背景,通过图块拼接实现安全验证;
  • 滑动验证:无图片背景,只拖动滑块便可实现安全验证;
    本文的SwipeCaptcha_ohos2.0组件属于滑动拼图验证,操作简单,安全性强,可被应用于各种网站的登录、注册、找回密码或投票等场景中。
    我们之前已经实现了滑动拼图验证组件SwipeCaptcha_ohos,相关文章在:https://harmonyos.51cto.com/posts/3402 。本次SwipeCaptcha_ohos2.0是基于之前移植的项目进行了相关功能的优化,具体优化内容将在下文中详细介绍。

组件效果展示

SwipeCaptcha_ohos2.0的主要功能和之前的SwipeCaptcha_ohos基本一致,组件在使用时,有两个较为重要的元素:滑块和原图。二者被放置于同一水平线上,用户拖动滑块至原图处使二者重合,误差小于提前设定的验证阈值,即可验证成功。每次调用组件,滑块和原图的位置都会发生随机变化。
SwipeCaptcha_ohos2.0相较于之前的版本,大幅提升了组件功能的完整性以及使用体验,下面将依次从组件验证失败和验证成功两个状态,展示SwipeCaptcha_ohos2.0与之前版本的效果对比。

1.验证失败

通过图1(a)和图1(b)的对比可以看出,新版本移除了旧版本中“当前进度值预览”的不必要功能以及下方的状态栏,取而代之的功能如下:

  • 验证滑块由正方形小块升级为“拼图块”样式;
  • 待验证背景图块增加了阴影遮罩效果;
  • 验证失败后增加了滑块闪烁效果以及“验证失败,请重新验证!”的弹窗提醒。
    ::: hljs-center

鸿蒙开源第三方组件——SwipeCaptcha_ohos2.0滑动拼图验证组件-开源基础软件社区
(a)旧版本组件验证失败效果
:::
::: hljs-center

鸿蒙开源第三方组件——SwipeCaptcha_ohos2.0滑动拼图验证组件-开源基础软件社区
(b)新版本组件验证失败效果

:::
::: hljs-center

图1 新旧版本验证失败效果对比

:::

2.验证成功

通过图2(a)和图2(b)的对比可以看出,新版本移除了旧版本中“当前进度值预览”的不必要功能以及下方的状态栏,取而代之的功能如下:

  • 点击“重新生成验证码”按钮后,滑块和原图的位置都会发生随机变化;
  • 验证成功后增加了反光条划过的动画效果以及“验证成功!”的弹窗提醒。
    ::: hljs-center

鸿蒙开源第三方组件——SwipeCaptcha_ohos2.0滑动拼图验证组件-开源基础软件社区
(a)旧版本组件验证成功效果

:::
::: hljs-center

鸿蒙开源第三方组件——SwipeCaptcha_ohos2.0滑动拼图验证组件-开源基础软件社区
(b)新版本组件验证成功效果

:::

::: hljs-center

图2 新旧版本验证成功效果对比

:::

除了上述直观的功能优化外,SwipeCaptcha_ohos2.0还实现了以下功能:

  • 滑块大小和容错阈值的用户自定义
    滑块大小自定义是指用户可以通过代码自定义滑块的宽高;容错阈值自定义是指用户可以通过代码自定义匹配时的容错率,即相差多少视作匹配成功。
  • 拼图背景在指定范围内的自适应填充。
    原组件的图片不能在指定组件宽高的前提下自动填充图片,如果强行适配宽高会出现拼图块内容错位的情况;经过改进后,验证图片已经能够适配布局中规定的组件宽高。

Sample解析

通过上文相信大家已经了解SwipeCaptcha_ohos2.0组件的使用效果,下面将具体讲解SwipeCaptcha_ohos2.0组件的使用方法,共分为5个步骤:
步骤1. 导入SwipeCaptchaView类并声明类对象。
步骤2. 在xml文件中添加SwipeCaptchaView控件。
步骤3. 绑定SwipeCaptchaView控件。
步骤4. 设置回调处理函数。
步骤5. 设置Button控件监听事件,重新生成验证区域
(1)导入SwipeCaptchaView类并声明类对象
在MainAbilitySlice.java文件中,通过import关键字导入SwipeCaptchaView类。

//导入SwipeCaptchaView类
import com.huawei.swipecaptchaview.lib.SwipeCaptchaView;
public class MainAbilitySlice extends AbilitySlice {
//声明SwipeCaptchaView类对象
SwipeCaptchaView swipeCaptchaView;  
......
}

(2)在xml文件中添加SwipeCaptchaView控件
在xml文件中添加SwipeCaptchaView控件,用于显示滑动验证的背景图和动态效果。设置控件高和宽、滑块的高和宽以及验证阈值等属性。

<com.huawei.swipecaptchaview.lib.SwipeCaptchaView
    xmlns:captcha="http://schemas.huawei.com/res/ohos-auto" //声明一个用于传输自定义参数的命名空间
    ohos:id="$+id:swipeCaptchaView"	//规定控件id
    ohos:height="220vp"  //控件的高
    ohos:width="330vp"   //控件的宽
    captcha:captchaHeight="30vp" //拼图滑块高
    captcha:captchaWidth="30vp" //拼图滑块宽
    captcha:matchDeviation="9"/> //验证失败的阈值

(3)绑定SwipeCaptchaView控件
在MainAbilitySlice.java的onStart()方法中,使用findComponentById()方法将xml文件中SwipeCaptchaView控件与SwipeCaptchaView类对象绑定;再调用setImageId()方法设置组件的背景图片。

//根据id找到相应的控件
swipeCaptchaView = (SwipeCaptchaView) findComponentById(ResourceTable.Id_swipeCaptchaView);
...
button = (Button) findComponentById(ResourceTable.Id_btn_change);

//设置背景图片
swipeCaptchaView.setImageId(ResourceTable.Media_pic01);

(4)设置回调处理函数
设置SwipeCaptchaView组件的回调处理函数,来提示用户滑动验证结果。以提示用户验证成功为例:首先重写matchSuccess()方法,设置验证成功后的提示信息,然后实例化一个ToastDialog提示框对象,使用setText()方法设置显示文字为“验证成功!”;setAlignment()方法设置提示框的布局位置在整体布局的中央;show()方法用于显示提示框。
设置验证失败的情况和验证成功同理,只需重写matchFailed()方法将文字信息设置为“验证失败!”即可。

//每次滑动结束后会根据判定结果回调
swipeCaptchaView.setOnCaptchaMatchCallback(new SwipeCaptchaView.OnCaptchaMatchCallback() {
    @Override
    public void matchSuccess(SwipeCaptchaView swipeCaptchaView) {
        new ToastDialog(getContext())
                .setText(" 验证成功!")
                .setAlignment(LayoutAlignment.CENTER)
                .show();
    }
});

(5)设置Button控件监听事件,重新生成验证区域
绑定button对象和xml文件中“重新生成验证码”Button控件;为button设置监听事件,每次点击按钮,都会调用createCaptcha()方法随机生成滑块和原图的位置。

button = (Button) findComponentById(ResourceTable.Id_btn_change);//绑定Button
button.setClickedListener(new Component.ClickedListener() {//设置监听
    @Override
    public void onClick(Component component) {
        swipeCaptchaView.createCaptcha();//随机生成滑块和原图的位置
        ...
    }
});

Library解析

本部分将要重点介绍的类是图3中框出的2个类,分别是DrawHelperUtils、和SwipeCaptchaView。它们向开发者提供设置SwipeCaptcha_ohos2.0组件相关属性的具体执行方法,其中DrawHelperUtils是工具类,SwipeCaptchaView是具体实现滑块滑动效果的类,本节将分别讲解这两个类的内部逻辑实现。
::: hljs-center

鸿蒙开源第三方组件——SwipeCaptcha_ohos2.0滑动拼图验证组件-开源基础软件社区
图3 Library目录结构

:::

1、DrawHelperUtils类

Swipeptcha_ohos2.0升级实现的拼图滑块的原理是在方块的左、右两条竖边中点处分别绘制一个凸半圆或凹半圆(随机),可参考图4。DrawHelperUtils类的drawPartCircle()方法具体用于绘制拼图滑块两条竖边上的半圆,先来解释一下该方法涉及变量和参数的含义:

  • 起点坐标:开始绘制半圆的起点坐标,在图中由A表示,规定为方块竖边的前1/3处,由入参传入。
  • 终点坐标:开始绘制半圆的起点坐标,在图中由C表示,规定为方块竖边的后1/3处,由入参传入。
  • 中点坐标:半圆直径的中点坐标,在图中由B表示,由起点A和终点C的X、Y坐标计算得到。
  • r1:半圆半径 = AB长度 = AC长度/2 = 1/6方块竖边长度。
  • gap1:由r1乘以贝塞尔曲线(cubicTo()方法)系数c得到,用于确定控制点D和F的坐标,控制点作用是控制半圆绘制的轨迹。
  • flag:半圆的旋转系数,用来控制凹、凸半圆的绘制。当为1时,A、B、C坐标与变量相加,绘制向外的凸半圆;当为-1时,其坐标与变量相减,得到向内的凹半圆。
    ::: hljs-center

鸿蒙开源第三方组件——SwipeCaptcha_ohos2.0滑动拼图验证组件-开源基础软件社区
图4-1 凸半圆绘制原理图

:::
::: hljs-center

鸿蒙开源第三方组件——SwipeCaptcha_ohos2.0滑动拼图验证组件-开源基础软件社区
图4-2 凹半圆绘制原理图

:::

以竖直绘制一个凸半圆为例,根据A、B、C点计算得到上述变量后,调用两次贝塞尔曲线cubicTo(x1,y1,x2,y2,x3,y3)分别绘制前1/2和后1/2半圆,此方法中需要使用到两个控制点,共有6个参数,分别表示控制点1(x1,y1)、控制点2(x2,y2)和绘制终点(x3,y3)。
如图4-1,绘制前1/2半圆时以起点A右侧平行gap1flag1距离处作为第一个控制点D、中点B右侧平行r1距离的半圆顶点第二个控制点E、中点B作为绘制终点;绘制后1/2半圆同理,以E点作为第一个控制点,终点C右侧平行gap1flag1距离处作为第二个控制点F、终点C作为绘制终点。
其他绘制方向同理,若为从下向上绘制,则将flag设为-1;若绘制凹半圆,则在计算坐标时横坐标反方向计算即可可参考图4-2。

public static void drawPartCircle(Point start, Point end, Path path, boolean outer) {
    float c = 0.551915024494f;
    Point middle = new Point(start.getPointX() + (end.getPointX() - start.getPointX()) / 2,start.getPointY() + (end.getPointY() - start.getPointY()) / 2);//根据起点坐标A和终点坐标C算出中点B坐标
    //半径    
    float r1 = (float) Math.sqrt(Math.pow((middle.getPointX() - start.getPointX()), 2) + Math.pow((middle.getPointY() - start.getPointY()), 2));
    float gap1 = r1 * c;//距离gap

    if (start.getPointX() == end.getPointX()) {//绘制竖直方向
        boolean topToBottom = end.getPointY() - start.getPointY() > 0;
        int flag;//旋转系数
        if (topToBottom) { //若从上到下绘制
            flag = 1;//旋转系数设为1
        } else {    flag = -1;    }//若从下到上绘制,设为-1
        if (outer) {//若为凸半圆,相加
            path.cubicTo(start.getPointX() + gap1 * flag, start.getPointY(),middle.getPointX() + r1 * flag, middle.getPointY() - gap1 * flag,middle.getPointX() + r1 * flag, middle.getPointY());
            path.cubicTo(middle.getPointX() + r1 * flag, middle.getPointY() + gap1 * flag,end.getPointX() + gap1 * flag, end.getPointY(), end.getPointX(), end.getPointY());
        }...    }//若为凹半圆,则相减
}

2、SwipteCaptchaView类

SwipeCaptchaView是具体实现滑块滑动效果的类,下文将从初始化滑动条并设置滑动条监听、初始化验证区域背景、设置验证后的动画效果、生成滑动验证区域四个方面具体讲解实现逻辑。接下来将按类型讲解类中各方法间的调用逻辑,可参考图4。
::: hljs-center

鸿蒙开源第三方组件——SwipeCaptcha_ohos2.0滑动拼图验证组件-开源基础软件社区
图4 各类间的函数调用关系示意图

:::

(1)初始化滑动条并设置滑动条监听

在SwipteCaptchaView类的构造函数中,调用init()方法进行初始化。其中,获取xml文件中控件参数即宽、高和系统屏幕宽度;通过switch-case判断来获取滑块的宽、高和滑动误差值;

mHeight = getHeight();//获取控件高和款
mWidth = getWidth();//获取系统屏幕宽度
if (mWidth == 0) {//mWidth=0为设置了match_parent的情况
    mWidth = DisplayManager.getInstance().getDefaultDisplay(context).get().getAttributes().width;
}

for (int i = 0; i < attrSet.getLength(); i++) {
    Optional<Attr> attr = attrSet.getAttr(i);
    if (attr.isPresent()) {
        switch (attr.get().getName()) {
            case "captchaHeight"://获取滑块高度
                mCaptchaHeight = attr.get().getDimensionValue();
                break;
            case "captchaWidth"://获取滑块宽度
                ...
            case "matchDeviation"://获取滑动误差值
            	  ...
        }
    }
}

实例化Image类得到验证区域图片对象,并为其设置图片缩放模式以及位图格式等属性;实例化Slider类得到拖动条对象,为其设置宽、高、进度值、进度颜色等属性,以及监听事件;

mImage = new Image(context);//表示验证区域图片
...
mImage.setScaleMode(Image.ScaleMode.CLIP_CENTER);
mImage.setPixelMap(ResourceTable.Media_no_resource);
...
mSlider = new Slider(mLayout.getContext());//实例化Slider类表示拖动条
mSlider.setWidth(mWidth); //设置宽、高
mSlider.setHeight(SLIDER_HEIGHT);
mSlider.setMarginTop(mHeight - SLIDER_HEIGHT);
mSlider.setMinValue(0); //进度最小、最大值、当前进度值、进度颜色
mSlider.setMaxValue(10000);
mSlider.setProgressValue(0)
mSlider.setProgressColor(Color.BLACK);
setSlideListener(); //设置拖动条的监听事件
...

在拖动条监听事件setSlideListener()方法中,重写onTouchEnd()方法,判断滑动结束后滑块位置的误差值是否小于规定误差值。若小于则验证成功,取消滑块的阴影并设置回调;否则验证失败,直接设置回调;

@Override
public void onTouchEnd(Slider slider) {
      if (onCaptchaMatchCallback != null) {
           if (Math.abs(mSlider.getProgress() * (mWidth - mCaptchaWidth) / 10000 - mCaptchaX) < mMatchDeviation) {//滑动结束后滑块位置误差值小于规定误差值验证成功
                mCaptchaPaint.setMaskFilter(null); //取消滑块的阴影
                slider.setEnabled(false);
                onCaptchaMatchCallback.matchSuccess(SwipeCaptchaView.this);//设置验证成功后的回调
                mSuccessAnim.start();//播放验证成功动画
            } else {//滑动误差值大于规定误差值验证失败
                slider.setProgressValue(0);
                onCaptchaMatchCallback.matchFailed(SwipeCaptchaView.this);//设置验证失败后的回调
                mFailAnim.start();//播放验证失败动画
            }
       }
}

(2)初始化滑动验证区域

在通过Image类对象调用setPixelMap()方法设置完验证图片后,由initCaptcha()方法完成验证区域的初始化。
实例化两个Paint类分别得到画笔对象和滑块目标区域对象,为其设置画笔抗锯齿和阴影、滑块样式和颜色等属性;再分别调用 createMatchAnim()和createCaptcha()方法设置验证后的动画效果和生成滑动验证区域。

private void initCaptcha() {
    mRandom = new Random(System.nanoTime());
    //设置画笔
    mCaptchaPaint = new Paint();//画笔对象
    mCaptchaPaint.setAntiAlias(true);   //抗锯齿
    mCaptchaPaint.setDither(true);      //使位图进行有利的抖动的位掩码标志
    mCaptchaPaint.setStyle(Paint.Style.FILL_STYLE);
    mCaptchaPaint.setMaskFilter(new MaskFilter(10, MaskFilter.Blur.SOLID));//阴影
    //滑块目标区域
    mMaskPaint = new Paint();//滑块目标区域对象
    mMaskPaint.setAntiAlias(true);
    mMaskPaint.setDither(true);
    mMaskPaint.setStyle(Paint.Style.FILL_STYLE);    //填充样式
    mMaskPaint.setColor(new Color(Color.argb(188, 0, 0, 0)));   //填充颜色
    mMaskPaint.setMaskFilter(new MaskFilter(20, MaskFilter.Blur.INNER));    //阴影
    mCaptchaPath = new Path();

    createMatchAnim();//设置验证后的动画效果
    createCaptcha();//生成验证码区域
}

(3)设置验证后的动画效果

由createMatchAnim()方法实现,能够设置验证成功或失败后的动画效果。

  • 验证成功
    通过AnimatorValue类对象设置动画间隔时间为500毫秒;并为其设置当值更新时的监听事件,重写onUpdate()方法,设置成功动画中拼图的偏移量;
//成功动画
int width = AttrHelper.vp2px(60, mLayout.getContext());
mSuccessAnim = new AnimatorValue();
mSuccessAnim.setDuration(500);//间隔时间为500毫秒
mSuccessAnim.setValueUpdateListener(new AnimatorValue.ValueUpdateListener() {
    @Override//设置监听
    public void onUpdate(AnimatorValue animatorValue, float v) {
        mSuccessAnimOffset = (int) (v * (mWidth + width));//拼图偏移量
        invalidate();
    }
});

通过Paint类和Path类对象分别调用相关函数来完成阴影效果和动画路径的绘制。

mSuccessPaint = new Paint();
mSuccessPaint.setShader(new LinearShader(//设置阴影
        new Point[]{new Point(0, 0), new Point(width * 3 / 2, mHeight)},
        new float[]{0, 0.5f},
        new Color[]{new Color(0x00FFFFFF), new Color(0x66FFFFFF)},
        Shader.TileMode.MIRROR_TILEMODE), Paint.ShaderType.LINEAR_SHADER);
mSuccessPath = new Path();//绘制动画路径
mSuccessPath.moveTo(0, 0);
mSuccessPath.rLineTo(width, 0);
mSuccessPath.rLineTo(width / 2, mHeight - SLIDER_HEIGHT);
mSuccessPath.rLineTo(-width, 0);
mSuccessPath.close();//关闭
  • 验证失败
    与设置验证成功的前半部分流程相似,不同之处是将动画间隔设为200毫秒、还要设置画圈次数为2次;在值更新时的监听事件中,需要判断当更新值小于0.5f时,将isDrawMask置为false即不绘制滑块,反之为true则绘制。
//设置验证失败动画
mFailAnim = new AnimatorValue();//实例化验证失败的动画对象
mFailAnim.setDuration(200);//设置间隔时间为200毫秒
mFailAnim.setLoopedCount(2);//设置画圈次数为2次
mFailAnim.setValueUpdateListener(new AnimatorValue.ValueUpdateListener() {
     @Override
     public void onUpdate(AnimatorValue animatorValue, float v) {
         if (v < 0.5f) {
             isDrawMask = false;//不绘制滑块
         } else { isDrawMask = true; }//绘制滑块
         invalidate();
    }});
}

(4)生成滑动验证区域

由createCaptcha()方法实现。先调用createCaptchaPath()方法绘制拼图块的轮廓路径。其中通过Random类的nextInt()方法随机生成验证区域坐标,使滑块和原图位置随机变化;再使用工具类DrawHelperUtils的DrawPartCircle()方法绘制拼图块左上角、右上角、右下角和左下角的图形。

private void createCaptchaPath() {//绘制拼图块轮廓路径path
    int gap = mCaptchaWidth / 3;   //拼图缺口的位置,设置在中间 1/3 处
    mCaptchaX = mRandom.nextInt(mWidth - (mCaptchaWidth * 3) - gap) + (mCaptchaWidth * 2); //随机生成验证区域左上角的坐标
    mCaptchaY = mRandom.nextInt(mHeight - SLIDER_HEIGHT - mCaptchaHeight - gap);
    mCaptchaPath.reset();
    mCaptchaPath.lineTo(0, 0);
    //开始绘制图形
    mCaptchaPath.moveTo(mCaptchaX, mCaptchaY); //左上角
    mCaptchaPath.lineTo(mCaptchaX + gap, mCaptchaY);
    drawPartCircle(new Point(mCaptchaX + gap, mCaptchaY),new Point(mCaptchaX + gap * 2, mCaptchaY),
    mCaptchaPath, mRandom.nextBoolean());
    ...//右上角、右下角和左下角同理
    mCaptchaPath.close();//绘制完成后及时关闭
}

接着生成滑动验证区域,前面介绍过,SwipeCaptcha_ohos2.0版升级实现了验证区域背景图片自适应填充的效果。其实现原理是先获取位图;根据图片的宽高和控件实际的宽高分别计算出水平方向和竖直方向上的缩放比例,两者中较大的是图片真实的缩放比例,这是由于上文介绍的Image控件将图片缩放模式设为了CLIP_CENTER,该模式会将图片的短边缩放至合适的大小并对长边进行裁剪,因此较小的缩放比例代表被裁剪的边,较大的即在填充进控件时的真实缩放比例;接着绘制滑块目标区域的阴影,其不随拖动条的移动而更新;最后绘制滑块区域,根据拖动条的数值计算画布偏移量,调用drawPath()方法绘制边框,获取图片 PixelMapHolder,根据路径裁剪并将画布缩放至跟图片缩放程度一致,根据比例计算出垂直方向上由于 CLIP_CENTER 裁剪掉的图片的高度以及水平方向上被裁掉的宽度,即可绘制内容。

public void createCaptcha() {//生成验证区域
    if (mImage.getPixelMap() != null) {
        createCaptchaPath();//绘制拼图块轮廓路径Path
        ...}

    PixelMap mCaptchaPixelMap = mImage.getPixelMap();//getPixelMap(mLayout.getContext(),ResourceTable.Media_pic01);
    //根据图片的原宽度和控件宽度算出缩放比例
    int originWidth = mCaptchaPixelMap.getImageInfo().size.width;
    int originHeight = mCaptchaPixelMap.getImageInfo().size.height;
    float ratioWidth = (float) mWidth / originWidth;
    float ratioHeight = (float) (mHeight - SLIDER_HEIGHT) / originHeight;
    float ratio = Math.max(ratioWidth, ratioHeight);//更大的ratio

    mImage.addDrawTask((component, canvas) -> { //滑块目标区域阴影的绘制
        canvas.drawPath(mCaptchaPath, mMaskPaint);
    });
    mLayout.addDrawTask((component, canvas) -> {//滑块区域的绘制
        if (isDrawMask) {      
            canvas.translate(mSlider.getProgress() * (mWidth - mCaptchaWidth) / 10000 - mCaptchaX, 0); //根据拖动条的数值计算画布的偏移量    
            canvas.drawPath(mCaptchaPath, mCaptchaPaint);//绘制边框
            PixelMapHolder mCaptchaPixelMapHolder = new PixelMapHolder(mCaptchaPixelMap); //获取图片的 PixelMapHolder
            canvas.clipPath(mCaptchaPath, Canvas.ClipOp.INTERSECT);//根据路径裁剪
            canvas.scale(ratio, ratio);//画布缩放至跟图片缩放程度一致
            if(ratio == ratioWidth) {             
                float heightErr = (originHeight * ratio - (mHeight - SLIDER_HEIGHT)) / 2;//根据比例计算出垂直方向上由于 CLIP_CENTER 裁剪掉的图片的高度  
                canvas.drawPixelMapHolder(mCaptchaPixelMapHolder, 0, - heightErr / ratio, mCaptchaPaint);//绘制内容
            }
            else {
                float widthErr = (originWidth * ratio - mWidth) / 2;//根据比例计算出水平方向上由于 CLIP_CENTER 裁剪掉的图片的宽度            
                canvas.drawPixelMapHolder(mCaptchaPixelMapHolder, - widthErr / ratio, 0, mCaptchaPaint);//绘制内容
            }
        }});
}

项目贡献人

王时予 李珂 朱伟 郑森文 陈美汝 刘雨琦

©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任
已于2021-11-22 10:35:01修改
18
收藏 15
回复
举报
回复
添加资源
添加资源将有机会获得更多曝光,你也可以直接关联已上传资源 去关联
    相关推荐