自定义UI 使用Camera做三维变换

系列文章目录

  1. 自定义UI 基础知识
  2. 自定义UI 绘制饼图
  3. 自定义UI 圆形头像
  4. 自定义UI 自制表盘
  5. 自定义UI 简易图文混排
  6. 自定义UI 使用Camera做三维变换
  7. 自定义UI 属性动画
  8. 自定义UI 自定义布局


前言

这系列的文章主要是基于扔物线的HenCoderPlus课程的源码来分析学习。



这一篇文章主要介绍的是Camera做三维变换,更多细节请见:HenCoder Android 开发进阶:自定义 View 1-4 Canvas 对绘制的辅助

创建绘制对象

我们需要创建一个画笔🖌Paint来绘制我们的头像的边框,也需要提前加载我们的头像到内存(Bitmap)中。

public class CameraView extends View {
    // 抗锯齿
    Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);

    public CameraView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }
}

加载图片

为避免出现java.lang.OutOfMemory异常,请先检查位图的尺寸,然后再对其进行解码,除非您绝对信任该来源可为您提供大小可预测的图片数据,以轻松适应可用的内存。——引用自Android官方文档:高效加载大型位图

下述源码的分析请见官方的说明:将按比例缩小的版本加载到内存中

public class CameraView extends View {
    // 图片大小
    private static final int IMAGE_WIDTH = 600;

    // 头像的Bitmap
    Bitmap bitmap;

    public CameraView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    {
        bitmap = Utils.getAvatar(getResources(), IMAGE_WIDTH);
    }
}

public class Utils {
    public static Bitmap getAvatar(Resources res, int width) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(res, R.drawable.avatar_rengwuxian, options);
        options.inJustDecodeBounds = false;
        options.inDensity = options.outWidth;
        options.inTargetDensity = width;
        return BitmapFactory.decodeResource(res, R.drawable.avatar_rengwuxian, options);
    }
}

自定义绘制内容

先上一下我们使用Camera做三维变换的效果图。

示意图 示意图
左边是原图,右边是3D变换效果图

定义绘制的位置

示意图
坐标轴的单位都是px
public class CameraView extends View {
    // 图片大小
    private static final int IMAGE_WIDTH = 600;
    // 图片绘制的偏移量(left、top)
    private static final int BITMAP_OFFSET = 200;

    public CameraView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }
}

变换流程解析

摘录自:HenCoder Android 开发进阶:自定义 View 1-4 Canvas 对绘制的辅助

Canvas 的几何变换顺序是反的,所以 Canvas 的位置变换 translate 、图形旋转 rotate 等都是反着写的。

变换参数

public class CameraView extends View {
    // 抗锯齿
    Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    // 相机
    Camera camera = new Camera();

    // 图片大小
    private static final int IMAGE_WIDTH = 600;
    // 图片的大小的一半
    private static final int HALF_OF_IMAGE_WIDTH = IMAGE_WIDTH / 2;
    // 图片绘制的偏移量(left、top)
    private static final int BITMAP_OFFSET = 200;
    // Canvas移动到圆点的偏移量(dx、dy)
    private static final int OFFSET = HALF_OF_IMAGE_WIDTH + BITMAP_OFFSET;
    // Canvas旋转的角度
    private static final int ROTATE_ANGLE = 45;
}

先放上两部分单独的绘制效果:

示意图 示意图
左边是正常的图片,右边是经过Camera变换后的图形

左上部分变换

示意图
1. 将图片的中心移动到视图的圆点位置
public class CameraView extends View {

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

        // 绘制上半部分
        canvas.save();
        // ... 省略 ...
		// 1. 将图片的中心移动到视图的圆点位置
        canvas.translate(-OFFSET, -OFFSET);
        canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
        canvas.restore();
    }
}
示意图
2. 图像以视图圆点为中心,旋转45°
3. 裁剪图像,保留x轴上方的灰色区域
public class CameraView extends View {

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

        // 绘制上半部分
        canvas.save();
        // ... 省略 ...
        // 3. 裁剪图像,保留x轴上方的灰色区域
        canvas.clipRect(-IMAGE_WIDTH, -IMAGE_WIDTH, IMAGE_WIDTH, 0);
        // 2. 图像以视图圆点为中心,旋转45°
        canvas.rotate(ROTATE_ANGLE);
		// 1. 将图片的中心移动到视图的圆点位置
        canvas.translate(-OFFSET, -OFFSET);
        canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
        canvas.restore();
    }
}

这里简单解释下 canvas.clipRect 入参采用的是 IMAGE_WIDTH 的原因:作者懒 计算方便……╮(╯▽╰)╭

示意图

IMAGE_WIDTH W W W,那么可以得出以下几个坐标位置:

坐标点坐标位置对应点坐标位置
A( − 1 2 W 2 -\sqrt{\frac{1}{2}W^2} 21W2 0 0 0)left( − W -W W 0 0 0)
B( 0 0 0 1 2 W 2 \sqrt{\frac{1}{2}W^2} 21W2 )top( 0 0 0 W W W)
C( 1 2 W 2 \sqrt{\frac{1}{2}W^2} 21W2 0 0 0)right( W W W 0 0 0)
D( 0 0 0 − 1 2 W 2 -\sqrt{\frac{1}{2}W^2} 21W2 )bottom( 0 0 0 − W -W W)

由上,易得: W > 1 2 W 2 W > \sqrt{\frac{1}{2}W^2} W>21W2


示意图
4. 图像以视图圆点为中心,旋转-45°
5. 将图像中心从视图圆点恢复到原来位置
public class CameraView extends View {

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

        // 绘制上半部分
        canvas.save();
        // 5. 将图像中心从视图圆点恢复到原来位置
        canvas.translate(OFFSET, OFFSET);
        // 4. 图像以视图圆点为中心,旋转-45°
        canvas.rotate(-ROTATE_ANGLE);
        // 3. 裁剪图像,保留x轴上方的灰色区域
        canvas.clipRect(-IMAGE_WIDTH, -IMAGE_WIDTH, IMAGE_WIDTH, 0);
        // 2. 图像以视图圆点为中心,旋转45°
        canvas.rotate(ROTATE_ANGLE);
		// 1. 将图片的中心移动到视图的圆点位置
        canvas.translate(-OFFSET, -OFFSET);
        canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
        canvas.restore();
    }
}

右下部分变换

右下部分基本和上一章节一致,这里仅仅介绍不一样的地方。

示意图
2. 图像以视图圆点为中心,旋转45°
3. 裁剪图像,保留x轴下方的灰色区域
public class CameraView extends View {

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

        // 绘制上半部分
        canvas.save();
        // ... 省略 ...
        // 3. 裁剪图像,保留x轴上方的灰色区域
        canvas.clipRect(-IMAGE_WIDTH, 0, IMAGE_WIDTH, IMAGE_WIDTH);
        // 2. 图像以视图圆点为中心,旋转45°
        canvas.rotate(ROTATE_ANGLE);
		// 1. 将图片的中心移动到视图的圆点位置
        canvas.translate(-OFFSET, -OFFSET);
        canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
        canvas.restore();
    }
}
3D变换

这里有一些API的介绍放到后面章节再讲,可以先忽略。

public class CameraView extends View {
    // 相机
    Camera camera = new Camera();

    public CameraView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    {
        bitmap = Utils.getAvatar(getResources(), IMAGE_WIDTH);
        // 沿着x轴旋转45°
        camera.rotateX(45);
        // 设置相机的z轴位置
        camera.setLocation(0, 0, Utils.getZForCamera()); // -8 = -8 * 72
    }

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

        // 绘制下半部分
        canvas.save();
        // 6. 将图像中心从视图圆点恢复到原来位置
        // canvas.translate(OFFSET, OFFSET);
		// 5. 图像以视图圆点为中心,旋转-45°
        // canvas.rotate(-ROTATE_ANGLE);
		// 4. 应用3D变换
        camera.applyToCanvas(canvas); // 关键代码
        // 3. 裁剪图像,保留x轴下方的灰色区域
        canvas.clipRect(-IMAGE_WIDTH, 0, IMAGE_WIDTH, IMAGE_WIDTH);
        // 2. 图像以视图圆点为中心,旋转45°
        canvas.rotate(ROTATE_ANGLE);
        // 1. 将图片的中心移动到视图的圆点位置
        canvas.translate(-OFFSET, -OFFSET);
        canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
        canvas.restore();
    }
}
示意图 示意图 示意图 示意图
3. 裁剪图像,保留x轴下方的灰色区域(第一张图)
4. 应用3D变换(第二张图)
5. 图像以视图圆点为中心,旋转-45°(第三张图)
最后放下合起来的效果(第四张图)

3D变换知识点

这里贴下上面章节缺失的一个方法的定义。

public class CameraView extends View {
    // 相机
    Camera camera = new Camera();

    public CameraView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    {
        // 设置相机的z轴位置
        camera.setLocation(0, 0, Utils.getZForCamera()); // -8 = -8 * 72
    }

}

public class Utils {

    public static float getZForCamera() {
        return -6 * Resources.getSystem().getDisplayMetrics().density;
    }
}

摘录自:扔物线官网之 HenCoder Android 开发进阶:自定义 View 1-4 Canvas 对绘制的辅助

  • Camera.setLocation(x, y, z):设置虚拟相机的位置。
  • x x x y y y z z z 的单位:inche。
  • 设计历史:这种设计源自 Android 底层的图像引擎 Skia 。在 Skia 中,Camera 的位置单位是英寸,英寸和像素的换算单位在 Skia 中被写死为了 72 像素,而 Android 中把这个换算单位照搬了过来。
  • 影响:在 Camera 中,相机的默认位置是 ( 0 , 0 , − 8 ) (0, 0, -8) (0,0,8)(inche)。 8 × 72 = 576 8 \times 72 = 576 8×72=576,所以它的默认位置是 ( 0 , 0 , − 576 ) (0, 0, -576) (0,0,576)(像素)。如果绘制的内容过大,当它翻转起来的时候,就有可能出现图像投影过大的「糊脸」效果。
  • 作用:实现 UI 一致性。o( ̄▽ ̄)d

让我们来考古看看相关代码实现,╮(╯▽╰)╭。先从对应的 android.graphics.Camera#setLocation 方法开始找:

源码地址:android.graphics.Camera

/**
 * A camera instance can be used to compute 3D transformations and
 * generate a matrix that can be applied, for instance, on a
 * {@link Canvas}.
 */
public class Camera {
    /**
     * Sets the location of the camera. The default location is set at
     * 0, 0, -8.
     * 
     * @param x The x location of the camera
     * @param y The y location of the camera
     * @param z The z location of the camera
     */
    public native void setLocation(float x, float y, float z);
}

这是一个 native 方法,我们可以通过 JNI 的特殊命名规则,找到对应的 native 实现。这里的 Camera 对应的 native 方式是:android_graphics_camera_setLocation。让我们搜索下看看。

无奈的是,这种方式没找到,╮(╯▽╰)╭。功夫不负有心人,总算通过其他办法找到了。

// ----------------------------------------------------------------------------

/*
 * JNI registration.
 */
static const JNINativeMethod gCameraMethods[] = {
    /* name, signature, funcPtr */

    { "nativeConstructor",   "()V",    (void*)Camera_constructor   },
    { "nativeDestructor",    "()V",    (void*)Camera_destructor    },
    { "save",                "()V",    (void*)Camera_save          },
    { "restore",             "()V",    (void*)Camera_restore       },
    { "translate",           "(FFF)V", (void*)Camera_translate     },
    { "rotateX",             "(F)V",   (void*)Camera_rotateX       },
    { "rotateY",             "(F)V",   (void*)Camera_rotateY       },
    { "rotateZ",             "(F)V",   (void*)Camera_rotateZ       },
    { "rotate",              "(FFF)V", (void*)Camera_rotate        },
    // 我们在找的方法
    { "setLocation",         "(FFF)V", (void*)Camera_setLocation   },
    { "getLocationX",        "()F",    (void*)Camera_getLocationX  },
    { "getLocationY",        "()F",    (void*)Camera_getLocationY  },
    { "getLocationZ",        "()F",    (void*)Camera_getLocationZ  },
    { "nativeGetMatrix",     "(J)V",   (void*)Camera_getMatrix     },
    { "nativeApplyToCanvas", "(J)V",   (void*)Camera_applyToCanvas },
    { "dotWithNormal",       "(FFF)F", (void*)Camera_dotWithNormal }
};

int register_android_graphics_Camera(JNIEnv* env) {
    jclass clazz = android::FindClassOrDie(env, "android/graphics/Camera");
    gNativeInstanceFieldID = android::GetFieldIDOrDie(env, clazz, "native_instance", "J");
    return android::RegisterMethodsOrDie(env, "android/graphics/Camera", gCameraMethods,
                                         NELEM(gCameraMethods));
}

static void Camera_setLocation(JNIEnv* env, jobject obj, jfloat x, jfloat y, jfloat z)

static void Camera_setLocation(JNIEnv* env, jobject obj, jfloat x, jfloat y, jfloat z) {
    jlong viewHandle = env->GetLongField(obj, gNativeInstanceFieldID);
    Sk3DView* v = reinterpret_cast<Sk3DView*>(viewHandle);
    v->setCameraLocation(x, y, z);
}

这样子,我们也知道了 native 对应实现的方法是 Sk3DView::setCameraLocation

事实上,我最早是通过 「Backport android.graphics.Camera.setLocation() functionality」 找到这个实现的。╮(╯▽╰)╭

先看看 skia/include/utils/SkCamera.h

// DEPRECATED
class SK_API Sk3DView : SkNoncopyable {
public:

#ifdef SK_BUILD_FOR_ANDROID_FRAMEWORK
	// 这里定义了 Android 使用特定函数
    void setCameraLocation(SkScalar x, SkScalar y, SkScalar z);
    SkScalar getCameraLocationX() const;
    SkScalar getCameraLocationY() const;
    SkScalar getCameraLocationZ() const;
#endif

private:
    struct Rec {
        Rec*    fNext;
        SkM44   fMatrix;
    };
    Rec*        fRec;
    Rec         fInitialRec;
    SkCamera3D  fCamera;
};

#endif

再看看对应实现内容 skia/src/utils/SkCamera.cpp

#ifdef SK_BUILD_FOR_ANDROID_FRAMEWORK
void Sk3DView::setCameraLocation(SkScalar x, SkScalar y, SkScalar z) {
    // the camera location is passed in inches, set in pt
    SkScalar lz = z * 72.0f;
    fCamera.fLocation = {x * 72.0f, y * 72.0f, lz};
    fCamera.fObserver = {0, 0, lz};
    fCamera.update();

}

SkScalar Sk3DView::getCameraLocationX() const {
    return fCamera.fLocation.x / 72.0f;
}

SkScalar Sk3DView::getCameraLocationY() const {
    return fCamera.fLocation.y / 72.0f;
}

SkScalar Sk3DView::getCameraLocationZ() const {
    return fCamera.fLocation.z / 72.0f;
}
#endif

看完了大致的代码,我们也通过分析源码进一步加深了对下面这句话的理解了。

  • 这种设计源自 Android 底层的图像引擎 Skia 。在 Skia 中,Camera 的位置单位是英寸,英寸和像素的换算单位在 Skia 中被写死为了 72 像素,而 Android 中把这个换算单位照搬了过来。

android.view.RenderNode

顺着 Sk3DView::setCameraLocation 翻下对应的引用,看看还能发现什么有意思的东西。

RenderProperties::setCameraDistance

/*
 * Data structure that holds the properties for a RenderNode
 */
class ANDROID_API RenderProperties {
public:
    RenderProperties();
    virtual ~RenderProperties();

    bool setCameraDistance(float distance) {
        if (distance != getCameraDistance()) {
            mPrimitiveFields.mMatrixOrPivotDirty = true;
            mComputedFields.mTransformCamera.setCameraLocation(0, 0, distance);
            return true;
        }
        return false;
    }
}

android_graphics_RenderNode.cpp

static jboolean android_view_RenderNode_setCameraDistance(CRITICAL_JNI_PARAMS_COMMA jlong renderNodePtr, float distance) {
    return SET_AND_DIRTY(setCameraDistance, distance, RenderNode::GENERIC);
}

android.view.RenderNode

public class RenderNode {
    /**
     * <p>Sets the distance along the Z axis (orthogonal to the X/Y plane on which
     * RenderNodes are drawn) from the camera to this RenderNode. The camera's distance
     * affects 3D transformations, for instance rotations around the X and Y
     * axis. If the rotationX or rotationY properties are changed and this view is
     * large (more than half the size of the screen), it is recommended to always
     * use a camera distance that's greater than the height (X axis rotation) or
     * the width (Y axis rotation) of this view.</p>
     *
     * <p>The distance of the camera from the drawing plane can have an affect on the
     * perspective distortion of the RenderNode when it is rotated around the x or y axis.
     * For example, a large distance will result in a large viewing angle, and there
     * will not be much perspective distortion of the view as it rotates. A short
     * distance may cause much more perspective distortion upon rotation, and can
     * also result in some drawing artifacts if the rotated view ends up partially
     * behind the camera (which is why the recommendation is to use a distance at
     * least as far as the size of the view, if the view is to be rotated.)</p>
     *
     * <p>The distance is expressed in pixels and must always be positive</p>
     *
     * @param distance The distance in pixels, must always be positive
     * @see #setRotationX(float)
     * @see #setRotationY(float)
     * @return True if the value changed, false if the new value was the same as the previous value.
     */
    public boolean setCameraDistance(
            @FloatRange(from = 0.0f, to = Float.MAX_VALUE) float distance) {
        if (!Float.isFinite(distance) || distance < 0.0f) {
            throw new IllegalArgumentException("distance must be finite & positive, given="
                    + distance);
        }
        // Native actually wants this to be negative not positive, so we flip it.
        return nSetCameraDistance(mNativeRenderNode, -distance);
    }
}

android.view.View

android.view.RenderNode,我们可以找到下一个相关的实现:

public class View implements Drawable.Callback, KeyEvent.Callback,
        AccessibilityEventSource {

    /**
     * <p>Sets the distance along the Z axis (orthogonal to the X/Y plane on which
     * views are drawn) from the camera to this view. The camera's distance
     * affects 3D transformations, for instance rotations around the X and Y
     * axis. If the rotationX or rotationY properties are changed and this view is
     * large (more than half the size of the screen), it is recommended to always
     * use a camera distance that's greater than the height (X axis rotation) or
     * the width (Y axis rotation) of this view.</p>
     *
     * <p>The distance of the camera from the view plane can have an affect on the
     * perspective distortion of the view when it is rotated around the x or y axis.
     * For example, a large distance will result in a large viewing angle, and there
     * will not be much perspective distortion of the view as it rotates. A short
     * distance may cause much more perspective distortion upon rotation, and can
     * also result in some drawing artifacts if the rotated view ends up partially
     * behind the camera (which is why the recommendation is to use a distance at
     * least as far as the size of the view, if the view is to be rotated.)</p>
     *
     * <p>The distance is expressed in "depth pixels." The default distance depends
     * on the screen density. For instance, on a medium density display, the
     * default distance is 1280. On a high density display, the default distance
     * is 1920.</p>
     *
     * <p>If you want to specify a distance that leads to visually consistent
     * results across various densities, use the following formula:</p>
     * <pre>
     * float scale = context.getResources().getDisplayMetrics().density;
     * view.setCameraDistance(distance * scale);
     * </pre>
     *
     * <p>The density scale factor of a high density display is 1.5,
     * and 1920 = 1280 * 1.5.</p>
     *
     * @param distance The distance in "depth pixels", if negative the opposite
     *        value is used
     *
     * @see #setRotationX(float)
     * @see #setRotationY(float)
     */
    public void setCameraDistance(float distance) {
        final float dpi = mResources.getDisplayMetrics().densityDpi;

        invalidateViewProperty(true, false);
        mRenderNode.setCameraDistance(-Math.abs(distance) / dpi);
        invalidateViewProperty(false, false);

        invalidateParentIfNeededAndWasQuickRejected();
    }
}

官方推荐使用UI一致性的方式:If you want to specify a distance that leads to visually consistent results across various densities, use the following formula.

float scale = context.getResources().getDisplayMetrics().density;
view.setCameraDistance(distance * scale);

这种方式和扔物线大佬推荐的方式基本一致,原理也是一样的。^_^

附录

源码

CameraView

public class CameraView extends View {
    // 抗锯齿
    Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    // 相机
    Camera camera = new Camera();

    // 图片大小
    private static final int IMAGE_WIDTH = 600;
    // 图片的大小的一半
    private static final int HALF_OF_IMAGE_WIDTH = IMAGE_WIDTH / 2;
    // 图片绘制的偏移量(left、top)
    private static final int BITMAP_OFFSET = 200;
    // Canvas移动到圆点的偏移量(dx、dy)
    private static final int OFFSET = HALF_OF_IMAGE_WIDTH + BITMAP_OFFSET;
    // Canvas旋转的角度
    private static final int ROTATE_ANGLE = 45;

    Bitmap bitmap;

    public CameraView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    {
        bitmap = Utils.getAvatar(getResources(), IMAGE_WIDTH);
        camera.rotateX(45);
        camera.setLocation(0, 0, Utils.getZForCamera()); // -8 = -8 * 72
    }

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

        // 绘制上半部分
        canvas.save();
        canvas.translate(OFFSET, OFFSET);
        canvas.rotate(-ROTATE_ANGLE);
        canvas.clipRect(-IMAGE_WIDTH, -IMAGE_WIDTH, IMAGE_WIDTH, 0);
        canvas.rotate(ROTATE_ANGLE);
        canvas.translate(-OFFSET, -OFFSET);
        canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
        canvas.restore();

        // 绘制下半部分
        canvas.save();
        canvas.translate(OFFSET, OFFSET);
        canvas.rotate(-ROTATE_ANGLE);
        camera.applyToCanvas(canvas);
        canvas.clipRect(-IMAGE_WIDTH, 0, IMAGE_WIDTH, IMAGE_WIDTH);
        canvas.rotate(ROTATE_ANGLE);
        canvas.translate(-OFFSET, -OFFSET);
        canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
        canvas.restore();
    }
}

Utils

public class Utils {

    public static Bitmap getAvatar(Resources res, int width) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(res, R.drawable.avatar_rengwuxian, options);
        options.inJustDecodeBounds = false;
        options.inDensity = options.outWidth;
        options.inTargetDensity = width;
        return BitmapFactory.decodeResource(res, R.drawable.avatar_rengwuxian, options);
    }

    public static float getZForCamera() {
        return -6 * Resources.getSystem().getDisplayMetrics().density;
    }
}
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值