安卓Canvas梳理

Android中的canvas介绍 3、绘制的东西4、绘制的画笔Paint(颜色和样式)Canvas对象的获取方式有三种:第一种我们通过重写View.onDraw方法,View中的Canvas对象会被当做参数传递过来,我们操作这个Canvas,效果会直接反应在View中。第二种就是当你想自己创建一个Canvas对象。从上面的基本要素可以明白,一个Canvas对象一定是结合了一个Bitmap对象的。所以一定要为一个Canvas对象设置一个Bitmap对象。[java] view plain copy//得到一个Bitmap对象,当然也可以使用 阅读详情

安卓中Canvas是用来绘制图像的常用api。Canvas在java,hwui和skia中都有定义。

java

BaseCanvas

frameworks/base/graphics/java/android/graphics/BaseCanvas.java

/**
 * This class is a base class for Canvas's drawing operations. Any modifications here
 * should be accompanied by a similar modification to {@link BaseRecordingCanvas}.
 *
 * The purpose of this class is to minimize the cost of deciding between regular JNI
 * and @FastNative JNI to just the virtual call that Canvas already has.
 *
 * @hide
 */
public abstract class BaseCanvas {
    /**
     * Should only be assigned in constructors (or setBitmap if software canvas),
     * freed by NativeAllocation.
     * @hide
     */
    @UnsupportedAppUsage
    protected long mNativeCanvasWrapper;
    ...
    // ---------------------------------------------------------------------------
    // Drawing methods
    // These are also implemented in RecordingCanvas so that we can
    // selectively apply on them
    // Everything below here is copy/pasted from Canvas.java
    // The JNI registration is handled by android_graphics_Canvas.cpp
    // ---------------------------------------------------------------------------

    public void drawArc(float left, float top, float right, float bottom, float startAngle,
            float sweepAngle, boolean useCenter, @NonNull Paint paint) {
        throwIfHasHwFeaturesInSwMode(paint);
        nDrawArc(mNativeCanvasWrapper, left, top, right, bottom, startAngle, sweepAngle,
                useCenter, paint.getNativeInstance());
    }
    ...
}

BaseCanvas主要有一个C++对象mNativeCanvasWrapper,其draw方法都直接调用了C++对象的对应draw方法。

Canvas

frameworks/base/graphics/java/android/graphics/Canvas.java

/**
 * The Canvas class holds the "draw" calls. To draw something, you need
 * 4 basic components: A Bitmap to hold the pixels, a Canvas to host
 * the draw calls (writing into the bitmap), a drawing primitive (e.g. Rect,
 * Path, text, Bitmap), and a paint (to describe the colors and styles for the
 * drawing).
 *
 * <div class="special reference">
 * <h3>Developer Guides</h3>
 * <p>For more information about how to use Canvas, read the
 * <a href="{@docRoot}guide/topics/graphics/2d-graphics.html">
 * Canvas and Drawables</a> developer guide.</p></div>
 */
public class Canvas extends BaseCanvas {
    ...
    // may be null
    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 117521088)
    private Bitmap mBitmap;
    ...
    /**
     * Construct a canvas with the specified bitmap to draw into. The bitmap
     * must be mutable.
     *
     * <p>The initial target density of the canvas is the same as the given
     * bitmap's density.
     *
     * @param bitmap Specifies a mutable bitmap for the canvas to draw into.
     */
    public Canvas(@NonNull Bitmap bitmap) {
        if (!bitmap.isMutable()) {
            throw new IllegalStateException("Immutable bitmap passed to Canvas constructor");
        }
        throwIfCannotDraw(bitmap);
        bitmap.setGainmap(null);
        mNativeCanvasWrapper = nInitRaster(bitmap.getNativeInstance());
        mFinalizer = NoImagePreloadHolder.sRegistry.registerNativeAllocation(
                this, mNativeCanvasWrapper);
        mBitmap = bitmap;
        mDensity = bitmap.mDensity;
    }
    ...
    // ---------------- Draw Methods -------------------

    /**
     * <p>
     * Draw the specified arc, which will be scaled to fit inside the specified oval.
     * </p>
     * <p>
     * If the start angle is negative or >= 360, the start angle is treated as start angle modulo
     * 360.
     * </p>
     * <p>
     * If the sweep angle is >= 360, then the oval is drawn completely. Note that this differs
     * slightly from SkPath::arcTo, which treats the sweep angle modulo 360. If the sweep angle is
     * negative, the sweep angle is treated as sweep angle modulo 360
     * </p>
     * <p>
     * The arc is drawn clockwise. An angle of 0 degrees correspond to the geometric angle of 0
     * degrees (3 o'clock on a watch.)
     * </p>
     *
     * @param oval The bounds of oval used to define the shape and size of the arc
     * @param startAngle Starting angle (in degrees) where the arc begins
     * @param sweepAngle Sweep angle (in degrees) measured clockwise
     * @param useCenter If true, include the center of the oval in the arc, and close it if it is
     *            being stroked. This will draw a wedge
     * @param paint The paint used to draw the arc
     */
    public void drawArc(@NonNull RectF oval, float startAngle, float sweepAngle, boolean useCenter,
            @NonNull Paint paint) {
        super.drawArc(oval, startAngle, sweepAngle, useCenter, paint);
    }

Canvas继承了BaseCanvas,Canvas还有一个bitmap,Canvas主要是用来在bitmap上进行绘制,其draw方法直接调用了super的draw方法。

BaseRecordingCanvas

frameworks/base/graphics/java/android/graphics/BaseRecordingCanvas.java

/**
 * This class is a base class for canvases that defer drawing operations, so all
 * the draw operations can be marked @FastNative. It contains a re-implementation of
 * all the methods in {@link BaseCanvas}.
 *
 * @hide
 */
public class BaseRecordingCanvas extends Canvas {

    public BaseRecordingCanvas(long nativeCanvas) {
        super(nativeCanvas);
    }

    @Override
    public final void drawArc(float left, float top, float right, float bottom, float startAngle,
            float sweepAngle, boolean useCenter, @NonNull Paint paint) {
        nDrawArc(mNativeCanvasWrapper, left, top, right, bottom, startAngle, sweepAngle,
                useCenter, paint.getNativeInstance());
    }
    ...
}

BaseRecordingCanvas继承了Canvas,所有的draw方法直接调用了C++对象的draw方法。

RecordingCanvas

frameworks/base/graphics/java/android/graphics/RecordingCanvas.java

/**
 * A Canvas implementation that records view system drawing operations for deferred rendering.
 * This is used in combination with RenderNode. This class keeps a list of all the Paint and
 * Bitmap objects that it draws, preventing the backing memory of Bitmaps from being released while
 * the RecordingCanvas is still holding a native reference to the memory.
 *
 * This is obtained by calling {@link RenderNode#beginRecording()} and is valid until the matching
 * {@link RenderNode#endRecording()} is called. It must not be retained beyond that as it is
 * internally reused.
 */
public final class RecordingCanvas extends BaseRecordingCanvas {
    ...
    /**
     * TODO: Temporarily exposed for RenderNodeAnimator(Set)
     * @hide */
    public RenderNode mNode;
    private int mWidth;
    private int mHeight;

    /*package*/
    static RecordingCanvas obtain(@NonNull RenderNode node, int width, int height) {
        if (node == null) throw new IllegalArgumentException("node cannot be null");
        RecordingCanvas canvas = sPool.acquire();
        if (canvas == null) {
            canvas = new RecordingCanvas(node, width, height);
        } else {
            nResetDisplayListCanvas(canvas.mNativeCanvasWrapper, node.mNativeRenderNode,
                    width, height);
        }
        canvas.mNode = node;
        canvas.mWidth = width;
        canvas.mHeight = height;
        return canvas;
    }

    /*package*/
    void recycle() {
        mNode = null;
        sPool.release(this);
    }

    /*package*/
    void finishRecording(RenderNode node) {
        nFinishRecording(mNativeCanvasWrapper, node.mNativeRenderNode);
    }

    ///////////////////////////////////////////////////////////////////////////
    // Constructors
    ///////////////////////////////////////////////////////////////////////////

    private RecordingCanvas(@NonNull RenderNode node, int width, int height) {
        super(nCreateDisplayListCanvas(node.mNativeRenderNode, width, height));
        mDensity = 0; // disable bitmap density scaling
    }
    ...
    ///////////////////////////////////////////////////////////////////////////
    // Drawing
    ///////////////////////////////////////////////////////////////////////////

    /**
     * Draws a circle
     *
     * @param cx
     * @param cy
     * @param radius
     * @param paint
     *
     * @hide
     */
    public void drawCircle(CanvasProperty<Float> cx, CanvasProperty<Float> cy,
            CanvasProperty<Float> radius, CanvasProperty<Paint> paint) {
        nDrawCircle(mNativeCanvasWrapper, cx.getNativeContainer(), cy.getNativeContainer(),
                radius.getNativeContainer(), paint.getNativeContainer());
    }
    ...
}

RecordingCanvas继承BaseRecordingCanvas,主要是用来给RenderNode录制绘制操作用的。

在View的绘制流程中,会调用每个View的updateDisplayListIfDirty方法:(frameworks/base/core/java/android/view/View.java)

    /**
     * Gets the RenderNode for the view, and updates its DisplayList (if needed and supported)
     * @hide
     */
    @NonNull
    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
    public RenderNode updateDisplayListIfDirty() {
        final RenderNode renderNode = mRenderNode;
        ...

        if ((mPrivateFlags & PFLAG_DRAWING_CACHE_VALID) == 0
                || !renderNode.hasDisplayList()
                || (mRecreateDisplayList)) {
            ...
            final RecordingCanvas canvas = renderNode.beginRecording(width, height);

            try {
                if (layerType == LAYER_TYPE_SOFTWARE) {
                    ...
                } else {
                    ...
                    // Fast path for layouts with no backgrounds
                    if ((mPrivateFlags & PFLAG_SKIP_DRAW) == PFLAG_SKIP_DRAW) {
                        dispatchDraw(canvas);
                        ...
                    } else {
                        draw(canvas);
                    }
                }
            } finally {
                renderNode.endRecording();
                setDisplayListProperties(renderNode);
            }
        } else {
            ...
        }
        return renderNode;
    }

首先通过renderNode.beginRecording方法获取一个RecordingCanvas,然后调用View.draw方法使用这个canvas录制绘制命令,最后调用renderNode.endRecording完成绘制命令录制。

hwui

Canvas

frameworks/base/libs/hwui/hwui/Canvas.h

class ANDROID_API Canvas {
public:
    ...
    virtual void drawRoundRect(uirenderer::CanvasPropertyPrimitive* left,
                               uirenderer::CanvasPropertyPrimitive* top,
                               uirenderer::CanvasPropertyPrimitive* right,
                               uirenderer::CanvasPropertyPrimitive* bottom,
                               uirenderer::CanvasPropertyPrimitive* rx,
                               uirenderer::CanvasPropertyPrimitive* ry,
                               uirenderer::CanvasPropertyPaint* paint) = 0;
    ...
}

Canvas是一个抽象类,定义了许多draw方法,供子类去实现。

SkiaCanvas

frameworks/base/libs/hwui/SkiaCanvas.h

// Holds an SkCanvas reference plus additional native data.
class SkiaCanvas : public Canvas {
public:
    ...
    virtual void drawColor(int color, SkBlendMode mode) override;
    ...
private:
    ...
    std::unique_ptr<SkCanvas> mCanvasOwned;  // Might own a canvas we allocated.
    SkCanvas* mCanvas;                       // We do NOT own this canvas, it must survive us
                                             // unless it is the same as mCanvasOwned.get().
    ...
};

SkiaCanvas继承了Canvas,SkiaCanvas包装了一个SkCanvas,所有的draw方法都直接调用了这个SkCanvas的draw方法,例如drawColor的实现:(frameworks/base/libs/hwui/SkiaCanvas.cpp)

void SkiaCanvas::drawColor(int color, SkBlendMode mode) {
    mCanvas->drawColor(color, mode);
}

在java的Canvas构造器中使用nInitRaster方法创建C++的Canvas对象:(frameworks/base/libs/hwui/jni/android_graphics_Canvas.cpp)

// Native wrapper constructor used by Canvas(Bitmap)
static jlong initRaster(JNIEnv* env, jobject, jlong bitmapHandle) {
    SkBitmap bitmap;
    if (bitmapHandle != 0) {
        bitmap::toBitmap(bitmapHandle).getSkBitmap(&bitmap);
    }
    return reinterpret_cast<jlong>(Canvas::create_canvas(bitmap));
}

这里调用了Canvas的create_canvas方法:(frameworks/base/libs/hwui/SkiaCanvas.cpp)

Canvas* Canvas::create_canvas(const SkBitmap& bitmap) {
    return new SkiaCanvas(bitmap);
}

实际上就是创建了一个C++的SkiaCanvas对象。

SkiaRecordingCanvas

frameworks/base/libs/hwui/pipeline/skia/SkiaRecordingCanvas.h

/**
 * A SkiaCanvas implementation that records drawing operations for deferred rendering backed by a
 * SkLiteRecorder and a SkiaDisplayList.
 */
class SkiaRecordingCanvas : public SkiaCanvas {
public:
    explicit SkiaRecordingCanvas(uirenderer::RenderNode* renderNode, int width, int height) {
        initDisplayList(renderNode, width, height);
    }
    ...
    virtual void drawRoundRect(uirenderer::CanvasPropertyPrimitive* left,
                               uirenderer::CanvasPropertyPrimitive* top,
                               uirenderer::CanvasPropertyPrimitive* right,
                               uirenderer::CanvasPropertyPrimitive* bottom,
                               uirenderer::CanvasPropertyPrimitive* rx,
                               uirenderer::CanvasPropertyPrimitive* ry,
                               uirenderer::CanvasPropertyPaint* paint) override;
    ...
private:
    RecordingCanvas mRecorder;
    std::unique_ptr<SkiaDisplayList> mDisplayList;
    ...
    /**
     *  A new SkiaDisplayList is created or recycled if available.
     *
     *  @param renderNode is optional and used to recycle an old display list.
     *  @param width used to calculate recording bounds.
     *  @param height used to calculate recording bounds.
     */
    void initDisplayList(uirenderer::RenderNode* renderNode, int width, int height);
    ...
};

SkiaRecordingCanvas继承SkiaCanvas,存有RecordingCanvas和SkiaDisplayList。

SkiaRecordingCanvas主要是用过initDisplayList方法初始化:(frameworks/base/libs/hwui/pipeline/skia/SkiaRecordingCanvas.cpp)

void SkiaRecordingCanvas::initDisplayList(uirenderer::RenderNode* renderNode, int width,
                                          int height) {
    mCurrentBarrier = nullptr;
    LOG_FATAL_IF(mDisplayList.get() != nullptr);

    if (renderNode) {
        mDisplayList = renderNode->detachAvailableList();
    }
    if (!mDisplayList) {
        mDisplayList.reset(new SkiaDisplayList());
    }

    mDisplayList->attachRecorder(&mRecorder, SkIRect::MakeWH(width, height));
    SkiaCanvas::reset(&mRecorder);
    mDisplayList->setHasHolePunches(false);
}

这里调用了SkiaCanvas的reset方法:(frameworks/base/libs/hwui/SkiaCanvas.cpp)

void SkiaCanvas::reset(SkCanvas* skiaCanvas) {
    if (mCanvas != skiaCanvas) {
        mCanvas = skiaCanvas;
        mCanvasOwned.reset();
    }
    mSaveStack.reset(nullptr);
}

将RecordingCanvas设置到了内部包装的SkCanvas上面。

SkiaRecordingCanvas还实现了一些draw方法,例如SkiaRecordingCanvas的drawCircle方法:(frameworks/base/libs/hwui/pipeline/skia/SkiaRecordingCanvas.cpp)

void SkiaRecordingCanvas::drawCircle(uirenderer::CanvasPropertyPrimitive* x,
                                     uirenderer::CanvasPropertyPrimitive* y,
                                     uirenderer::CanvasPropertyPrimitive* radius,
                                     uirenderer::CanvasPropertyPaint* paint) {
    drawDrawable(mDisplayList->allocateDrawable<AnimatedCircle>(x, y, radius, paint));
}

调用了SkiaCanvas的drawDrawable方法:(frameworks/base/libs/hwui/SkiaCanvas.h)

// Holds an SkCanvas reference plus additional native data.
class SkiaCanvas : public Canvas {
    ...
    void drawDrawable(SkDrawable* drawable) { mCanvas->drawDrawable(drawable); }
    ...
}

最终调用了内部包装的SkCanvas的drawDrawable方法。

在java的RecordingCanvas的构造器中使用nCreateDisplayListCanvas方法创建C++的Canvas对象:(frameworks/base/libs/hwui/jni/android_graphics_DisplayListCanvas.cpp)

static jlong android_view_DisplayListCanvas_createDisplayListCanvas(CRITICAL_JNI_PARAMS_COMMA jlong renderNodePtr,
        jint width, jint height) {
    RenderNode* renderNode = reinterpret_cast<RenderNode*>(renderNodePtr);
    return reinterpret_cast<jlong>(Canvas::create_recording_canvas(width, height, renderNode));
}

调用了Canvas的reate_recording_canvas方法:(frameworks/base/libs/hwui/hwui/Canvas.cpp)

Canvas* Canvas::create_recording_canvas(int width, int height, uirenderer::RenderNode* renderNode) {
    return new uirenderer::skiapipeline::SkiaRecordingCanvas(renderNode, width, height);
}

实际上就是创建了一个SkiaRecordingCanvas对象。

RecordingCanvas

frameworks/base/libs/hwui/RecordingCanvas.h

class RecordingCanvas final : public SkCanvasVirtualEnforcer<SkNoDrawCanvas> {
public:
    RecordingCanvas();
    void reset(DisplayListData*, const SkIRect& bounds);
    ...
    void onDrawRect(const SkRect&, const SkPaint&) override;
    ...
private:
    ...
    DisplayListData* fDL;
    ...
};

RecordingCanvas里面主要是有一个DisplayListData,这个DisplayListData在reset方法里面赋值:(frameworks/base/libs/hwui/RecordingCanvas.cpp)

void RecordingCanvas::reset(DisplayListData* dl, const SkIRect& bounds) {
    this->resetCanvas(bounds.right(), bounds.bottom());
    fDL = dl;
    mClipMayBeComplex = false;
    mSaveCount = mComplexSaveCount = 0;
}

调用位置是在SkiaRecordingCanvas::initDisplayList中调用了SkiaDisplayList的attachRecorder方法:(frameworks/base/libs/hwui/RecordingCanvas.cpp)

class SkiaDisplayList {
    ...
    void attachRecorder(RecordingCanvas* recorder, const SkIRect& bounds) {
        recorder->reset(&mDisplayList, bounds);
    }
    ...
}

所以SkiaRecordingCanvas的RecordingCanvas的DisplayListData被设置成了指向SkiaRecordingCanvas的SkiaDisplayList的DisplayListData。也就是:

SkiaRecordingCanvas.mRecorder.fDL = &SkiaRecordingCanvas.mDisplayList.mDisplayList

SkiaRecordingCanvas.mCanvas = &SkiaRecordingCanvas.mRecorder

RecordingCanvas还重写了onDraw方法,例如RecordingCanvas的onDrawRect方法:(frameworks/base/libs/hwui/RecordingCanvas.cpp)

void RecordingCanvas::onDrawRect(const SkRect& rect, const SkPaint& paint) {
    fDL->drawRect(rect, paint);
}

直接调用了其DisplayListData的drawRect方法去录制。

DisplayListData

frameworks/base/libs/hwui/RecordingCanvas.h

class DisplayListData final {
public:
    ...
    void draw(SkCanvas* canvas) const;
    ...
private:
    ...
    void drawRect(const SkRect&, const SkPaint&);
    ...
    template <typename T, typename... Args>
    void* push(size_t, Args&&...);

    template <typename Fn, typename... Args>
    void map(const Fn[], Args...) const;

    AutoTMalloc<uint8_t> fBytes;
    size_t fUsed = 0;
    size_t fReserved = 0;

    bool mHasText : 1;
    bool mHasFill : 1;
};

DisplayListData的录制操作例如drawRect方法:(frameworks/base/libs/hwui/RecordingCanvas.cpp)

void DisplayListData::drawRect(const SkRect& rect, const SkPaint& paint) {
    this->push<DrawRect>(0, rect, paint);
}

调用了DisplayListData的push方法:(frameworks/base/libs/hwui/RecordingCanvas.cpp)

template <typename T, typename... Args>
void* DisplayListData::push(size_t pod, Args&&... args) {
    size_t skip = SkAlignPtr(sizeof(T) + pod);
    LOG_FATAL_IF(skip >= (1 << 24));
    if (fUsed + skip > fReserved) {
        static_assert(is_power_of_two(SKLITEDL_PAGE),
                      "This math needs updating for non-pow2.");
        // Next greater multiple of SKLITEDL_PAGE.
        fReserved = (fUsed + skip + SKLITEDL_PAGE) & ~(SKLITEDL_PAGE - 1);
        fBytes.realloc(fReserved);
        LOG_ALWAYS_FATAL_IF(fBytes.get() == nullptr, "realloc(%zd) failed", fReserved);
    }
    LOG_FATAL_IF((fUsed + skip) > fReserved);
    auto op = (T*)(fBytes.get() + fUsed);
    fUsed += skip;
    new (op) T{std::forward<Args>(args)...};
    op->type = (uint32_t)T::kType;
    op->skip = skip;

    // check if this is a fill op or not, in case we need to avoid messing with it with force invert
    if constexpr (!std::is_same_v<T, DrawTextBlob>) {
        if (hasPaintWithFill(args...)) {
            mHasFill = true;
        }
    }

    return op + 1;
}

这里使用了C++的placement new方式,在fBytes的最后构建并添加了类型为T的结构,此时T是DrawRect:(frameworks/base/libs/hwui/RecordingCanvas.cpp)

struct Op {
    uint32_t type : 8;
    uint32_t skip : 24;
};

struct DrawRect final : Op {
    static const auto kType = Type::DrawRect;
    DrawRect(const SkRect& rect, const SkPaint& paint) : rect(rect), paint(paint) {}
    SkRect rect;
    SkPaint paint;
    void draw(SkCanvas* c, const SkMatrix&) const { c->drawRect(rect, paint); }
};

DrawRect继承Op,并保存了这次操作所需的数据SkRect和SkPaint。这样就实现了绘制操作保存到了DisplayListData的fBytes中。

最后实际绘制之前录制的操作的时候,会调用DisplayListData的draw方法:(frameworks/base/libs/hwui/RecordingCanvas.cpp)

void DisplayListData::draw(SkCanvas* canvas) const {
    SkAutoCanvasRestore acr(canvas, false);
    this->map(draw_fns, canvas, canvas->getTotalMatrix());
}

调用了DisplayListData的map方法:(frameworks/base/libs/hwui/RecordingCanvas.cpp)

template <typename Fn, typename... Args>
inline void DisplayListData::map(const Fn fns[], Args... args) const {
    auto end = fBytes.get() + fUsed;
    for (const uint8_t* ptr = fBytes.get(); ptr < end;) {
        auto op = (const Op*)ptr;
        auto type = op->type;
        auto skip = op->skip;
        if (auto fn = fns[type]) {  // We replace no-op functions with nullptrs
            fn(op, args...);        // to avoid the overhead of a pointless call.
        }
        ptr += skip;
    }
}

这里遍历fBytes,对其中的每个op调用fn函数,fn函数是作为参数传进来的draw_fns的下标为type的元素。draw_fns定义:(frameworks/base/libs/hwui/RecordingCanvas.cpp)

// All ops implement draw().
#define X(T)                                                    \
    [](const void* op, SkCanvas* c, const SkMatrix& original) { \
        ((const T*)op)->draw(c, original);                      \
    },
static const draw_fn draw_fns[] = {
#include "DisplayListOps.in"
};
#undef X

其中DisplayListOps.in的内容:(frameworks/base/libs/hwui/DisplayListOps.in)

X(Save)
X(Restore)
X(SaveLayer)
X(SaveBehind)
X(Concat)
X(SetMatrix)
X(Scale)
X(Translate)
X(ClipPath)
X(ClipRect)
X(ClipRRect)
X(ClipRegion)
X(ClipShader)
X(ResetClip)
X(DrawPaint)
X(DrawBehind)
X(DrawPath)
X(DrawRect)
X(DrawRegion)
X(DrawOval)
X(DrawArc)
X(DrawRRect)
X(DrawDRRect)
X(DrawAnnotation)
X(DrawDrawable)
X(DrawPicture)
X(DrawImage)
X(DrawImageRect)
X(DrawImageLattice)
X(DrawTextBlob)
X(DrawPatch)
X(DrawPoints)
X(DrawVertices)
X(DrawAtlas)
X(DrawShadowRec)
X(DrawVectorDrawable)
X(DrawRippleDrawable)
X(DrawWebView)
X(DrawSkMesh)
X(DrawMesh)

这个X是写在draw_fns之前的宏定义,实际上就是一个lambda函数,函数里面调用了每种Op的draw方法。就这样实现了将录制好了的操作绘制到传进来的SkCanvas里面。

skia

SkCanvas

external/skia/include/core/SkCanvas.h

/** \class SkCanvas
    SkCanvas provides an interface for drawing, and how the drawing is clipped and transformed.
    SkCanvas contains a stack of SkMatrix and clip values.

    SkCanvas and SkPaint together provide the state to draw into SkSurface or SkDevice.
    Each SkCanvas draw call transforms the geometry of the object by the concatenation of all
    SkMatrix values in the stack. The transformed geometry is clipped by the intersection
    of all of clip values in the stack. The SkCanvas draw calls use SkPaint to supply drawing
    state such as color, SkTypeface, text size, stroke width, SkShader and so on.

    To draw to a pixel-based destination, create raster surface or GPU surface.
    Request SkCanvas from SkSurface to obtain the interface to draw.
    SkCanvas generated by raster surface draws to memory visible to the CPU.
    SkCanvas generated by GPU surface uses Vulkan or OpenGL to draw to the GPU.

    To draw to a document, obtain SkCanvas from SVG canvas, document PDF, or SkPictureRecorder.
    SkDocument based SkCanvas and other SkCanvas subclasses reference SkDevice describing the
    destination.

    SkCanvas can be constructed to draw to SkBitmap without first creating raster surface.
    This approach may be deprecated in the future.
*/
class SK_API SkCanvas {
public:
    ...
    /** Draws SkRect rect using clip, SkMatrix, and SkPaint paint.
        In paint: SkPaint::Style determines if rectangle is stroked or filled;
        if stroked, SkPaint stroke width describes the line thickness, and
        SkPaint::Join draws the corners rounded or square.

        @param rect   rectangle to draw
        @param paint  stroke or fill, blend, color, and so on, used to draw

        example: https://fiddle.skia.org/c/@Canvas_drawRect
    */
    void drawRect(const SkRect& rect, const SkPaint& paint);
    ...
protected:
    ...
    // NOTE: If you are adding a new onDraw virtual to SkCanvas, PLEASE add an override to
    // SkCanvasVirtualEnforcer (in SkCanvasVirtualEnforcer.h). This ensures that subclasses using
    // that mechanism  will be required to implement the new function.
    virtual void onDrawRect(const SkRect& rect, const SkPaint& paint);
    ...
};

SkCanvas有很多draw方法,例如SkCanvas的drawRect方法:(external/skia/src/core/SkCanvas.cpp)

void SkCanvas::drawRect(const SkRect& r, const SkPaint& paint) {
    TRACE_EVENT0("skia", TRACE_FUNC);
    // To avoid redundant logic in our culling code and various backends, we always sort rects
    // before passing them along.
    this->onDrawRect(r.makeSorted(), paint);
}

直接调用了SkCanvas的onDrawRect方法:(external/skia/src/core/SkCanvas.cpp)

void SkCanvas::onDrawRect(const SkRect& r, const SkPaint& paint) {
    SkASSERT(r.isSorted());
    if (this->internalQuickReject(r, paint)) {
        return;
    }

    auto layer = this->aboutToDraw(paint, &r, PredrawFlags::kCheckForOverwrite);
    if (layer) {
        this->topDevice()->drawRect(r, layer->paint());
    }
}

调用了SkDevice的drawRect方法,drawRect方法的内容取决于继承SkDevice的类的具体实现,将在其它文章中分析。

SkCanvasVirtualEnforcer

external/skia/include/core/SkCanvasVirtualEnforcer.h

// If you would ordinarily want to inherit from Base (eg SkCanvas, SkNWayCanvas), instead
// inherit from SkCanvasVirtualEnforcer<Base>, which will make the build fail if you forget
// to override one of SkCanvas' key virtual hooks.
template <typename Base>
class SkCanvasVirtualEnforcer : public Base {
public:
    using Base::Base;

protected:
    void onDrawPaint(const SkPaint& paint) override = 0;
    void onDrawBehind(const SkPaint&) override {} // make zero after android updates
    void onDrawRect(const SkRect& rect, const SkPaint& paint) override = 0;
    void onDrawRRect(const SkRRect& rrect, const SkPaint& paint) override = 0;
    void onDrawDRRect(const SkRRect& outer, const SkRRect& inner,
                      const SkPaint& paint) override = 0;
    void onDrawOval(const SkRect& rect, const SkPaint& paint) override = 0;
    void onDrawArc(const SkRect& rect, SkScalar startAngle, SkScalar sweepAngle, bool useCenter,
                   const SkPaint& paint) override = 0;
    void onDrawPath(const SkPath& path, const SkPaint& paint) override = 0;
    void onDrawRegion(const SkRegion& region, const SkPaint& paint) override = 0;

    void onDrawTextBlob(const SkTextBlob* blob, SkScalar x, SkScalar y,
                        const SkPaint& paint) override = 0;

    void onDrawPatch(const SkPoint cubics[12], const SkColor colors[4],
                     const SkPoint texCoords[4], SkBlendMode mode,
                     const SkPaint& paint) override = 0;
    void onDrawPoints(SkCanvas::PointMode mode, size_t count, const SkPoint pts[],
                      const SkPaint& paint) override = 0;

#ifdef SK_BUILD_FOR_ANDROID_FRAMEWORK
    // This is under active development for Chrome and not used in Android. Hold off on adding
    // implementations in Android's SkCanvas subclasses until this stabilizes.
    void onDrawEdgeAAQuad(const SkRect& rect, const SkPoint clip[4],
            SkCanvas::QuadAAFlags aaFlags, const SkColor4f& color, SkBlendMode mode) override {}
#else
    void onDrawEdgeAAQuad(const SkRect& rect, const SkPoint clip[4],
            SkCanvas::QuadAAFlags aaFlags, const SkColor4f& color, SkBlendMode mode) override = 0;
#endif

    void onDrawAnnotation(const SkRect& rect, const char key[], SkData* value) override = 0;
    void onDrawShadowRec(const SkPath&, const SkDrawShadowRec&) override = 0;

    void onDrawDrawable(SkDrawable* drawable, const SkMatrix* matrix) override = 0;
    void onDrawPicture(const SkPicture* picture, const SkMatrix* matrix,
                       const SkPaint* paint) override = 0;
};

SkCanvasVirtualEnforcer是一个抽象基类,将所有关键的onDraw方法都重写成抽象的了,作用是如果继承类少实现了某个方法就会导致编译不通过,从而确保继承类不会漏实现某个onDraw方法。

SkNoDrawCanvas

external/skia/include/utils/SkNoDrawCanvas.h

// SkNoDrawCanvas is a helper for SkCanvas subclasses which do not need to
// actually rasterize (e.g., analysis of the draw calls).
//
// It provides the following simplifications:
//
//   * not backed by any device/pixels
//   * conservative clipping (clipping calls only use rectangles)
//
class SK_API SkNoDrawCanvas : public SkCanvasVirtualEnforcer<SkCanvas> {
public:
    SkNoDrawCanvas(int width, int height);
    SkNoDrawCanvas(const SkIRect&);

    // Optimization to reset state to be the same as after construction.
    void resetCanvas(int w, int h)        { this->resetForNextPicture(SkIRect::MakeWH(w, h)); }
    void resetCanvas(const SkIRect& rect) { this->resetForNextPicture(rect); }

protected:
    SaveLayerStrategy getSaveLayerStrategy(const SaveLayerRec& rec) override;
    bool onDoSaveBehind(const SkRect*) override;

    // No-op overrides for aborting rasterization earlier than SkNullBlitter.
    void onDrawAnnotation(const SkRect&, const char[], SkData*) override {}
    void onDrawDRRect(const SkRRect&, const SkRRect&, const SkPaint&) override {}
    void onDrawDrawable(SkDrawable*, const SkMatrix*) override {}
    void onDrawTextBlob(const SkTextBlob*, SkScalar, SkScalar, const SkPaint&) override {}
    void onDrawPatch(const SkPoint[12], const SkColor[4], const SkPoint[4], SkBlendMode,
                     const SkPaint&) override {}

    void onDrawPaint(const SkPaint&) override {}
    void onDrawBehind(const SkPaint&) override {}
    void onDrawPoints(PointMode, size_t, const SkPoint[], const SkPaint&) override {}
    void onDrawRect(const SkRect&, const SkPaint&) override {}
    void onDrawRegion(const SkRegion&, const SkPaint&) override {}
    void onDrawOval(const SkRect&, const SkPaint&) override {}
    void onDrawArc(const SkRect&, SkScalar, SkScalar, bool, const SkPaint&) override {}
    void onDrawRRect(const SkRRect&, const SkPaint&) override {}
    void onDrawPath(const SkPath&, const SkPaint&) override {}

    void onDrawImage2(const SkImage*, SkScalar, SkScalar, const SkSamplingOptions&,
                      const SkPaint*) override {}
    void onDrawImageRect2(const SkImage*, const SkRect&, const SkRect&, const SkSamplingOptions&,
                          const SkPaint*, SrcRectConstraint) override {}
    void onDrawImageLattice2(const SkImage*, const Lattice&, const SkRect&, SkFilterMode,
                             const SkPaint*) override {}
    void onDrawAtlas2(const SkImage*, const SkRSXform[], const SkRect[], const SkColor[], int,
                  SkBlendMode, const SkSamplingOptions&, const SkRect*, const SkPaint*) override {}

    void onDrawVerticesObject(const SkVertices*, SkBlendMode, const SkPaint&) override {}
    void onDrawShadowRec(const SkPath&, const SkDrawShadowRec&) override {}
    void onDrawPicture(const SkPicture*, const SkMatrix*, const SkPaint*) override {}

    void onDrawEdgeAAQuad(const SkRect&, const SkPoint[4], QuadAAFlags, const SkColor4f&,
                          SkBlendMode) override {}
    void onDrawEdgeAAImageSet2(const ImageSetEntry[], int, const SkPoint[], const SkMatrix[],
                               const SkSamplingOptions&, const SkPaint*,
                               SrcRectConstraint) override {}

private:
    using INHERITED = SkCanvasVirtualEnforcer<SkCanvas>;
};

SkNoDrawCanvas继承了SkCanvasVirtualEnforcer<SkCanvas>,重写了所有onDraw方法为空方法,也就是不执行任何具体的绘制代码。

RecordingCanvas就是继承了SkCanvasVirtualEnforcer<SkNoDrawCanvas>。

Android 中 Canvas的功能 Android 中 Canvas的功能 一. 如何在一屏幕上绘图 三个概念: 需要有画布Canvas 需要有笔Paint 需要有坐标系,画笔默认是在左上角(0,0)位置绘制的。我们可以通过移动画布坐标原点的形式,实现在不同位置绘制。 1. 移动坐标原点 简单绘制一个图 我们将画笔Canvas.translate(100,400)移动之后再进行绘制,这时画布的原点已经变化了。 2. 视图坐标系 理论上 Canvas 这张纸是没有边界的,但是我们的手机屏幕是有界的。我们可以理解为我们透 阅读详情

相关推荐

微信小程序Canvas层级问题解决方案与实战

在微信小程序开发中,Canvas作为原生组件存在层级问题,会遮挡其他前端组件。这是由于微信小程序的渲染架构决定的:逻辑层运行JavaScript代码,视图层渲染页面结构,而原生组件由客户端原生渲染,导致常规CSS层级控制失效。理解这一原理后,开发者可以采用cover-view、动态隐藏Canvas、同层渲染或WebGL等解决方案。这些技术方案在电商小程序、签名板等场景中尤为重要,能有效提升用户体验。通过合理选择方案并注意iOS/Android平台差异,可以解决Canvas遮挡问题,实现复杂的交互界面。

weixin_29015127的博客 379

Android canvas

1.Canvas Canvas是一个画布,可以在这个画布上进行各种绘图操作。 在Android中,想要获得一个Canvas对象主要有三种方法: ①继承一个View,并重写onDraw()方法。View的Canvas对象会被当做参数传递过来,在这个Canvas上进行的操作会直接反映在View中。 ②调用SurfaceHolder.lockCanvas()返回一个Canvas对象。 ③通过构造方法自己创建一个Canvas对象。 Bitmap bitmap = Bitmap.createBitmap(1

zenmela2011的专栏 1万+

Android图片解码普通/HARDWARE Bitmap到GPU绘制最终上屏显示链路

本文分析了Android中普通Bitmap和HARDWARE Bitmap从解码到屏幕显示的完整链路及核心区别。普通Bitmap解码后在CPU内存存储像素数据,绘制时需上传GPU纹理,适合需要像素处理的场景;HARDWARE Bitmap则将像素直接存入GPU/硬件缓冲,减少纹理上传开销,但不可修改像素且不兼容软件渲染。文章详细比较了两者在解码、绘制阶段的技术实现差异,列举了HARDWARE Bitmap的使用限制和适用场景,最后总结了两者的核心区别:普通Bitmap是"CPU内存像素图&quot

Zhang Phil 362

HenCoder Android 开发进阶: 自定义 View 1-1 绘制基础

从今天开始,HenCoder 就正式开讲知识技能了。按照我的计划,第一季是 UI,UI 一共分为三部分:绘制、布局和触摸反馈。本期是绘制部分的第一期。绘制大概会用 5~6 期的时间讲完,整个 UI 的绘制、布局和触摸反馈三部分大概会用十来期。更新频率大约为每周一更(不承诺哟)。 如果你不知道 HenCoder 是什么,可以先看这里: HenCoder:给高级 Android 工程师的进阶手册 自定...

weixin_33911824的博客 546

深入解析Android软件渲染中的VSync信号与绘制流程

本文深入解析Android软件渲染中的VSync信号与绘制流程,详细介绍了VSync信号如何作为渲染节拍器确保画面流畅,以及从UI线程到SurfaceFlinger的完整绘制生命周期。通过实际案例和代码示例,帮助开发者理解软件渲染的关键路径和优化技巧,提升应用性能。

weixin_30823001的博客 345

招标信息爬取工具

一个能帮你节省大量时间的实用小工具,我自己用了挺久,分享出来给有需要的朋友

qq_56877290的博客 837

Android中Canvas绘图基础详解(附源码下载)

Android中,如果我们想绘制复杂的自定义View或游戏,我们就需要熟悉绘图API。Android通过Canvas类暴露了很多drawXXX方法,我们可以通过这些方法绘制各种各样的图形。Canvas绘图有三个基本要素:Canvas、绘图坐标系以及Paint。Canvas是画布,我们通过Canvas的各种drawXXX方法将图形绘制到Canvas上面,在drawXXX方法中我们需要传入要绘制的图形的

孙群 4万+

蓄热式加热炉燃烧技术

蓄热式加热炉燃烧技术

weixin_37928884的博客 2734

Android 如何在不显示文字时获取文字宽度

String text = “测试:XYZ xyz”;//1. 粗略计算文字宽度//2. 计算文字所在矩形,可以得到宽高//3. 精确计算文字宽度if (str!j < len;j++) {String text = “测试:XYZ xyz”;Log.e(TAG, “测试字符串:” + text);

2301_79098336的博客 720

通达信烽火起爆指标公式

STICKLINE(IF(起爆666,-120,0),+33,0,3.4,0),COLOR007777;STICKLINE(IF(起爆666,-120,0),+33,0,1.8,0),COLOR009999;STICKLINE(IF(起爆666,-120,0),+33,0,1.2,0),COLOR00BBBB;STICKLINE(IF(起爆666,-120,0),+33,0,0.6,0),COLOR00DDDD;STICKLINE(起爆666,0,100,9999,0)*起爆,COLOR000000;

zhibiaogong的博客 3891

Mac电脑用Latex论文排版3天速成5天实战de笔记

我后悔了,这五天竞赛搞得我视力都下降了...一个冲动的决定后成为数模竞赛论文写手,3天速成+5天时间=点亮攻克latex写论文技能💡关于Overleaf我是Mac,曾经按照大部分推荐的方式用TEXshop+vs code,但是把模板导入就开始报错。跟着少之又少的适配mac的教程(此时我已知道大概是走错路了)改了配置也无效。海鲜市场找佬改了半天,佬问我:一定要用VC吗?我说,那倒也不是。于是佬推荐我overleaf,三下五除二帮我挑配了环境,美汁汁收了我30块钱。

luwanjoyce的博客 2250

Android中new Canvas(Bitmap)引发的对Canvas和Bitmap关系的疑问

说可以改变的位图。

2401_86963909的博客 1066

AI视频生成提示词工程:结构化公式与实战案例详解

在实际使用 AI 生成视频或图像时,最常遇到的困惑是:为什么我的提示词(Prompt)生成的画面总是不理想?是模型能力不足,还是我的描述有问题?答案往往是后者。提示词工程(Prompt Engineering)并非简单的“翻译”需求,而是一门需要结构化思维和精准描述的技能。它决定了 AI 能否准确理解你的意图,并生成符合预期的视觉内容。 本文将通过两个核心案例,深入拆解提示词的构建逻辑。第一个案例聚焦于“文生视频”,我们将学习如何将一个简单的想法,如“小猫在雪地里玩雪球”,通过结构化公式扩展为一段富有细节和

weixin_30800987的博客 564

【Android】浅谈View绘制流程

measureSpec是一个int类型,在java占32字节,其1~30位表示实际测量长度,31~32位即测量模式通过MODE_MASK获得mode,MODE_MASK 即。

qq_40900926的博客 910

Android SystemUI——车载CarSystemUI加载(八)

车载 CarSystemUI 是指专门为汽车环境设计的用户界面和用户体验(UI/UX),它通常与 Android Automotive OS 或其他嵌入式操作系统一起工作,为驾驶者和乘客提供安全、直观且高效的交互方式。Android Automotive OS 是 Google 专门为汽车开发的操作系统版本,它基于 Android 平台,并针对车辆内部的使用进行了优化。

小旭的专栏 2175

AutoSAR MemMap模块详解及使用注意事项

在本文中,我们将深入探讨MemMap模块的实例解析,并介绍在使用该模块时需要注意的一些事项。在上述代码中,我们使用了MEMMAP_START_SEC_VAR_32BIT和MEMMAP_START_SEC_CODE来定义MemMap区域的起始位置,使用MEMMAP_STOP_SEC_VAR_32BIT和MEMMAP_STOP_SEC_CODE来定义MemMap区域的结束位置。1.合理划分MemMap区域:根据实际需求,合理划分MemMap区域,将不同类型的变量放置在不同的区域中,以便于管理和访问。

weixin_50547796的博客 2534

Android Drawable常见子类及其用法整理

概述 Drawable是一个抽象类,是对可绘制物件的抽象。与View不同的是Drawable没有事件和交互方法。Drawable有很多子类操作具体资源类型,比如BitmapDrawable操作位图,ColorDrawable操作颜色,ClipDrawable操作剪切板.。 参考资料:《Android开发艺术探索》 原理 draw(Canvas canvas)    //在draw中对画

liuconen的博客 1001

淋巴结病理数字玻片读取与处理代码

Whole-slide images are generally stored in a multi-resolution pyramid structure. 首先安装openslide模块 pip install openslide-python import os.path as osp import openslide from pathlib import Path...

猫头军师兵书 1276

Android Bitmap 知识点梳理

View绘制基本流程Android系统View的绘制流程:依次执行View类里面的如下三个方法: measure(int ,int) :测量View的大小layout(int ,int ,int ,int) :设置子View的位置draw(Canvas) :绘制View内容到Canvas画布上整个View树的绘图流程是在ViewRoot.Java类的performTraversals()函数展开的

学渣罗小贱9*w4YBsXS@3W 373

LinePhone3.2.7(android端最新版本) sip客户端

SIP客户端For android:LinePhone3.2.7(最新版本),官网地址:http://www.linphone.org/

Statistics and Machine Learning Toolbox

Statistics and Machine Learning Toolbox(带书签),MATLAB统计学习工具

android 最全 图片相关知识整理

android 最全 图片相关知识整理 图片基础知识梳理 图片基础知识梳理(1) - ImageView 的 ScaleType 属性解析 图片基础知识梳理(2) - Bitmap 占用内存分析 图片基础知识梳理(3) - Bitmap&BitmapFactory 解析 图片压缩知识梳理 图片压缩知识梳理(0) - 图片压缩学习计划 图片压缩知识梳理(1) - PNG 原理 图片压缩知识

philos3的博客 661

TMS_WebGMaps_1.9.8.0_Full_Source

TMS_WebGMaps_1.9.8.0_Full_Source 全源码 delphi cbuilder

hwui介绍与分析

hwui全称**HardwareAcceleratedRenderingEngineforUI,**hwui是一个基于GPU加速的2D图形引擎。HWUI的目标是提供高效、稳定、高质量的2D图形渲染能力,为Android系统的UI体验提供技术支持。相关源码位于目录android/platform/framework/base/libs/hwui文末有福利~hwui的大部分代码以C++实现,Andro...

eclipse_xu 2907

复分析 阿尔福斯(Ahlfors) 第三版答案 英文

答案分为十章,有部分习题没有答案,全英文复分析阿尔福斯(Ahlfors) 第三版答案

Pico VR开发避坑指南:串流调试、XRIT配置与性能优化实战

虚拟现实(VR)开发通过构建沉浸式三维环境,为用户提供全新的交互体验。其核心原理在于利用头戴显示设备与空间定位技术,实时渲染双眼视差图像并追踪用户动作。这项技术的价值在于能够突破二维屏幕限制,在游戏、教育、培训等领域创造高临场感的应用。在具体工程实践中,开发者常需使用Unity引擎配合特定XR工具链进行开发。针对Pico VR一体机开发时,**串流调试**是实现高效实时预览的关键环节,它直接影响开发迭代速度。而**UI交互**在三维空间中的实现方式与传统2D界面截然不同,需采用World Space渲染模式

weixin_33720956的博客 362

android 中各种阴影 汇总

通过layer-list或自定义Drawable手动绘制阴影,适合需要非对称阴影(如仅底部阴影)或复杂形状阴影(如圆形、多边形)的场景。在styles.xml中定义主题:xml-- 1. 弹窗背景(带圆角和阴影) -->-- 2. 弹窗阴影大小(API 21+) -->-- 3. 弹窗边框(可选,避免边缘生硬) -->-- 4. 取消默认标题栏 --></style>定义弹窗背景xml</shape>在代码中使用主题:java运行.setTitle("自定义阴影 Dialog")

hcgeng的专栏 1644

Android直通路线(工作)

前言:公司最近来了很多应届实习生,看着他们充满信心但略带稚气的脸庞上,想到了去年的自己,那是的我是不是也和    现在的他们一样呢?现在的我又改变了什么?公司的给这些实习生指定的相关培训制度还是比较完善的(我可是个菜鸟)。但我反    过来想,假如是我,我会制定一个怎样的短期或者长远的计划?总结了自己学到的知识,形成了一个学习线路,希望能给予同    道中人一点儿小小帮助。

xiaocheng2290的专栏 391

hongyangWeixinArticles自定义View合集:20个炫酷UI效果实现原理

hongyangWeixinArticles是一个记录微信公众号"hongyangAndroid"推送文章的项目,其中包含了大量关于Android自定义View的精彩内容。本文将为你精选20个炫酷UI效果的实现原理,帮助你快速掌握自定义View的核心技巧。 ## 一、基础自定义View实现 ### 1. 自定义密码输入框 自定义密码输入框是很多App的常见需求,通过自定义View可以实现各种炫

gitblog_00654的博客 1098

Android硬件加速深度解析:原理、优化与实战避坑指南

图形渲染是移动应用性能优化的核心环节,其本质是将视觉元素高效转换为屏幕像素的过程。在Android系统中,硬件加速机制通过将图形计算任务从CPU卸载到GPU,利用GPU的并行处理能力大幅提升渲染效率。这项技术对于实现流畅动画、复杂界面绘制至关重要,直接影响用户体验和应用性能。在Android开发实践中,硬件加速广泛应用于自定义View绘制、属性动画优化等场景。然而,开发者常会遇到**Canvas操作兼容性**和**图形内存管理**等挑战,例如`clipPath`等API在硬件加速模式下可能失效,不当的图层缓

weixin_29191761的博客 325
上一篇: 安卓RenderEffect原理
下一篇: 如何使用Rust写一个游戏引擎
SSSxCCC
博客等级 码龄10年 238粉丝 74原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

SSSxCCC

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值