自定義View——invalidate傳遞與繪制流程分析

AdrianaMcca 8年前發布 | 15K 次閱讀 Android Android開發 移動開發 view

上一篇文章 自定義View——View的彈性滑動 中,我們對View的滑動進行了實戰以及簡單分析。但在文章的最后,仍然遺留了兩個問題,第一個是invalidate與postInvalidate有什么區別呢?第二個是invalidate是如何調用computeScroll()方法的呢?這兩個問題將在這一篇文章中進行分析。

一、invalidate與postInvalidate

invalidate與postInvadlidate都是用于請求View重繪的API,invalidate在主線程中進行調用,而postInvadlidate則在子線程中進行調用。

我們來分析下postInvadlidate的源碼 :

public void postInvalidate() {
    postInvalidateDelayed(0);
}

postInvalidate()蔣會調用postInvalidateDelayed(0)方法,繼續跟進。

public void postInvalidateDelayed(long delayMilliseconds) {
    final AttachInfo attachInfo = mAttachInfo;
    if (attachInfo != null) {
        attachInfo.mViewRootImpl.dispatchInvalidateDelayed(this, delayMilliseconds);
    }
}

postInvalidateDelayed方法,通過attachInfo獲取到當前的ViewRootImpl對象,調用它的dispatchInvalidateDelayed方法

public void dispatchInvalidateDelayed(View view, long delayMilliseconds) {
    Message msg = mHandler.obtainMessage(MSG_INVALIDATE, view);
    mHandler.sendMessageDelayed(msg, delayMilliseconds);
}

從上面的源碼已經可以看出,postInvalidate的子線程這一個特性了。再繼續跟下去看看。

@Override
public void handleMessage(Message msg) {
    switch (msg.what) {
    case MSG_INVALIDATE:
        ((View) msg.obj).invalidate();
        break;

    ...
    }
}

代碼跟到這里,也就明白了,postInvalidate通過sendMessageDelayed的方法,加入到了looper中,之后在handleMessage中再調用對應View的invalidate()方法,請求View重繪。

二、invalidate流程分析

現在我們來看看invalidate是如何讓View進行重繪的呢?

(PS:我這里使用的API版本為23,具體的代碼可能和其他的版本有稍許不同)

1、invalidate的請求傳遞

我們的旅程從View的invalidate傳遞過程開始

現在來看看View#invalidate()方法。

public void invalidate() {
    invalidate(true);
}

void invalidate(boolean invalidateCache) {
    invalidateInternal(0, 0, mRight - mLeft, mBottom - mTop, invalidateCache, true);
}

invalidate調用View#invalidateInternal方法傳入當前View的位置參數。

void invalidateInternal(int l, int t, int r, int b, boolean invalidateCache,
        boolean fullInvalidate) {

    // 如果View重繪,則它也將重繪
    if (mGhostView != null) {
        mGhostView.invalidate(true);
        return;
    }

    // View是否可見,是否在動畫運行中
    if (skipInvalidate()) {
        return;
    }

    // 根據View的標記來判斷View是否需要進行重繪
    if ((mPrivateFlags & (PFLAG_DRAWN | PFLAG_HAS_BOUNDS)) == (PFLAG_DRAWN | PFLAG_HAS_BOUNDS)
            || (invalidateCache && (mPrivateFlags & PFLAG_DRAWING_CACHE_VALID) == PFLAG_DRAWING_CACHE_VALID)
            || (mPrivateFlags & PFLAG_INVALIDATED) != PFLAG_INVALIDATED
            || (fullInvalidate && isOpaque() != mLastIsOpaque)) {
        if (fullInvalidate) {
            mLastIsOpaque = isOpaque();
            mPrivateFlags &= ~PFLAG_DRAWN;
        }

        // 設置標志,表明View正在被重繪
        mPrivateFlags |= PFLAG_DIRTY;
        //清除緩存,設置標志,表明重繪由當前View發起
        if (invalidateCache) {
            mPrivateFlags |= PFLAG_INVALIDATED;
            mPrivateFlags &= ~PFLAG_DRAWING_CACHE_VALID;
        }
        // 把需要重繪的View區域傳遞給父View
        final AttachInfo ai = mAttachInfo;
        final ViewParent p = mParent;
        if (p != null && ai != null && l < r && t < b) {
            final Rect damage = ai.mTmpInvalRect;
            // 設置重繪區域
            damage.set(l, t, r, b);
            // 關鍵代碼,調用父View的方法,向上傳遞重繪事件
            p.invalidateChild(this, damage);
        }
        ...
    }
}

上述代碼中,會判斷當前View的狀態,是否需要進行重繪,之后設置一系列標記位。通過父View的invalidateChild(this, damage)方法,將需要重繪的區域傳遞給父View。

接著來看下ViewGroup#invalidateChild方法,這里僅截取了其中的主要代碼

public final void invalidateChild(View child, final Rect dirty) {
    ViewParent parent = this;
    final AttachInfo attachInfo = mAttachInfo;
    if (attachInfo != null) {
        ...
        // 保存子View的left、top
        final int[] location = attachInfo.mInvalidateChildLocation;
        location[CHILD_LEFT_INDEX] = child.mLeft;
        location[CHILD_TOP_INDEX] = child.mTop;
        if (!childMatrix.isIdentity() ||
                (mGroupFlags & ViewGroup.FLAG_SUPPORT_STATIC_TRANSFORMATIONS) != 0) {
            RectF boundingRect = attachInfo.mTmpTransformRect;
            boundingRect.set(dirty);
            Matrix transformMatrix;
            if ((mGroupFlags & ViewGroup.FLAG_SUPPORT_STATIC_TRANSFORMATIONS) != 0) {
                Transformation t = attachInfo.mTmpTransformation;
                boolean transformed = getChildStaticTransformation(child, t);
                if (transformed) {
                    transformMatrix = attachInfo.mTmpMatrix;
                    transformMatrix.set(t.getMatrix());
                    if (!childMatrix.isIdentity()) {
                        transformMatrix.preConcat(childMatrix);
                    }
                } else {
                    transformMatrix = childMatrix;
                }
            } else {
                transformMatrix = childMatrix;
            }
            transformMatrix.mapRect(boundingRect);
            // 設置需要重繪的區域
            dirty.set((int) (boundingRect.left - 0.5f),
                    (int) (boundingRect.top - 0.5f),
                    (int) (boundingRect.right + 0.5f),
                    (int) (boundingRect.bottom + 0.5f));
        }
        // 這里的do...while方法,讓view可以不斷的去調用父類的
        // invalidateChildInParent方法,來傳遞重繪請求
        do {
            View view = null;
            if (parent instanceof View) {
                view = (View) parent;
            }
            if (drawAnimation) {
                if (view != null) {
                    view.mPrivateFlags |= PFLAG_DRAW_ANIMATION;
                } else if (parent instanceof ViewRootImpl) {
                    ((ViewRootImpl) parent).mIsAnimating = true;
                }
            }
            // If the parent is dirty opaque or not dirty, mark it dirty with the opaque
            // flag coming from the child that initiated the invalidate
            if (view != null) {
                if ((view.mViewFlags & FADING_EDGE_MASK) != 0 &&
                        view.getSolidColor() == 0) {
                    opaqueFlag = PFLAG_DIRTY;
                }
                if ((view.mPrivateFlags & PFLAG_DIRTY_MASK) != PFLAG_DIRTY) {
                    view.mPrivateFlags = (view.mPrivateFlags & ~PFLAG_DIRTY_MASK) | opaqueFlag;
                }
            }
            // 這里是關鍵代碼,他會調用父類的
            parent = parent.invalidateChildInParent(location, dirty);
            if (view != null) {
                // Account for transform on current parent
                Matrix m = view.getMatrix();
                if (!m.isIdentity()) {
                    RectF boundingRect = attachInfo.mTmpTransformRect;
                    boundingRect.set(dirty);
                    m.mapRect(boundingRect);
                    dirty.set((int) (boundingRect.left - 0.5f),
                            (int) (boundingRect.top - 0.5f),
                            (int) (boundingRect.right + 0.5f),
                            (int) (boundingRect.bottom + 0.5f));
                }
            }
        } while (parent != null);
    }
}

上述代碼中,設置了需要重繪的區域dirty。之后再do…while方法中,反復的調用 parent = parent.invalidateChildInParent(location, dirty) 方法,來調用父類的invalidateChildInParent對View的重繪請求進行傳遞。這里的parent有可能是ViewGroup,也有可能是ViewRoot,我們先來看看ViewGroup#invalidateChildInParent方法

public ViewParent invalidateChildInParent(final int[] location, final Rect dirty) {
    if ((mPrivateFlags & PFLAG_DRAWN) == PFLAG_DRAWN ||
            (mPrivateFlags & PFLAG_DRAWING_CACHE_VALID) == PFLAG_DRAWING_CACHE_VALID) {
        if ((mGroupFlags & (FLAG_OPTIMIZE_INVALIDATE | FLAG_ANIMATION_DONE)) !=
                    FLAG_OPTIMIZE_INVALIDATE) {
            // 子View中的布局位置轉換為父View中的布局位置
            dirty.offset(location[CHILD_LEFT_INDEX] - mScrollX,
                    location[CHILD_TOP_INDEX] - mScrollY);
            if ((mGroupFlags & FLAG_CLIP_CHILDREN) == 0) {
                // 合并繪制區域集合
                dirty.union(0, 0, mRight - mLeft, mBottom - mTop);
            }
            final int left = mLeft;
            final int top = mTop;
            if ((mGroupFlags & FLAG_CLIP_CHILDREN) == FLAG_CLIP_CHILDREN) {
                if (!dirty.intersect(0, 0, mRight - left, mBottom - top)) {
                    dirty.setEmpty();
                }
            }
            mPrivateFlags &= ~PFLAG_DRAWING_CACHE_VALID;
            location[CHILD_LEFT_INDEX] = left;
            location[CHILD_TOP_INDEX] = top;
            if (mLayerType != LAYER_TYPE_NONE) {
                mPrivateFlags |= PFLAG_INVALIDATED;
            }
            return mParent;
        } else {
            mPrivateFlags &= ~PFLAG_DRAWN & ~PFLAG_DRAWING_CACHE_VALID;
            location[CHILD_LEFT_INDEX] = mLeft;
            location[CHILD_TOP_INDEX] = mTop;
            if ((mGroupFlags & FLAG_CLIP_CHILDREN) == FLAG_CLIP_CHILDREN) {
                dirty.set(0, 0, mRight - mLeft, mBottom - mTop);
            } else {
                // in case the dirty rect extends outside the bounds of this container
                dirty.union(0, 0, mRight - mLeft, mBottom - mTop);
            }
            if (mLayerType != LAYER_TYPE_NONE) {
                mPrivateFlags |= PFLAG_INVALIDATED;
            }
            return mParent;
        }
    }
    return null;
}

在上述代碼中,將會使用offset,把子View需要重繪的坐標區域轉換為父View中的坐標區域。之后使用union對子View與父View的區域進行集合運算,獲得需要繪制的區域。

接下來我們再來看看ViewRoot#invalidateChildInParent方法,ViewRoot并不是View,ViewRoot的實現類為ViewRootImpl,我們來看下它的invalidateChildInParent方法。

@Override
public ViewParent invalidateChildInParent(int[] location, Rect dirty) {
    // 檢查線程是否為創建View的線程,即創建View的線程中是否含有此ViewRootImpl
    checkThread();
    if (DEBUG_DRAW) Log.v(TAG, "Invalidate child: " + dirty);
    // 檢查重繪區域
    if (dirty == null) {
        invalidate();
        return null;
    } else if (dirty.isEmpty() && !mIsAnimating) {
        return null;
    }
    // 動畫和滑動的檢查設置
    if (mCurScrollY != 0 || mTranslator != null) {
        mTempRect.set(dirty);
        dirty = mTempRect;
        if (mCurScrollY != 0) {
            dirty.offset(0, -mCurScrollY);
        }
        if (mTranslator != null) {
            mTranslator.translateRectInAppWindowToScreen(dirty);
        }
        if (mAttachInfo.mScalingRequired) {
            dirty.inset(-1, -1);
        }
    }
    invalidateRectOnScreen(dirty);
    return null;
}

private void invalidateRectOnScreen(Rect dirty) {
    ...
    if (!mWillDrawSoon && (intersected || mIsAnimating)) {
        //關鍵代碼,ViewTree列表
        scheduleTraversals();
    }
}

上述代碼中,進入之后會線程以及重繪區域的檢查,之后調用invalidateRectOnScreen方法,然后調用scheduleTraversals()方法。

來繼續看看ViewRootImpl#scheduleTraversals()。

void scheduleTraversals() {
    if (!mTraversalScheduled) {
        mTraversalScheduled = true;
        // handler消息傳遞繪制請求
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
        mChoreographer.postCallback(
                Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        if (!mUnbufferedInputDispatch) {
            scheduleConsumeBatchedInput();
        }
        notifyRendererOfFramePending();
        pokeDrawLockIfNeeded();
    }
}


final TraversalRunnable mTraversalRunnable = new TraversalRunnable();

final class TraversalRunnable implements Runnable {
    @Override
    public void run() {
        doTraversal();
    }
}

void doTraversal() {
    if (mTraversalScheduled) {
        mTraversalScheduled = false;
        mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
        if (mProfile) {
            Debug.startMethodTracing("ViewAncestor");
        }
        // 關鍵代碼,執行ViewTree遍歷
        performTraversals();
        if (mProfile) {
            Debug.stopMethodTracing();
            mProfile = false;
        }
    }
}

上述代碼中,將會之后handler,之后會調用mTraversalRunnable類,從而調用doTraversal方法,最后調用performTraversals()執行ViewTree的遍歷。

現在繼續查看ViewRootImpl#performTraversals()方法。

private void performTraversals() {
    ...
    if (!cancelDraw && !newSurface) {
        if (!skipDraw || mReportNextDraw) {
            if (mPendingTransitions != null && mPendingTransitions.size() > 0) {
                for (int i = 0; i < mPendingTransitions.size(); ++i) {
                    mPendingTransitions.get(i).startChangingAnimations();
                }
                mPendingTransitions.clear();
            }
            // 關鍵代碼
            performDraw();
        }
    } 
    ...
}

private void performDraw() {
    ...
    final boolean fullRedrawNeeded = mFullRedrawNeeded;
    mFullRedrawNeeded = false;
    mIsDrawing = true;
    Trace.traceBegin(Trace.TRACE_TAG_VIEW, "draw");
    try {
        // 關鍵代碼
        draw(fullRedrawNeeded);
    } finally {
        mIsDrawing = false;
        Trace.traceEnd(Trace.TRACE_TAG_VIEW);
    }
    ...
}

在其中進行View的是否可見,是否為surfasce,是否正在繪制,是否存在于刪除列表中等判斷,之后調用performDraw()開始執行繪制。在performDraw()又調用了ViewRootImpl的draw方法,并傳遞了fullRedrawNeeded參數,此參數源自mFullRedrawNeeded成員變量,用于表示是否需要重新繪制全部的View。現在繼續看看ViewRootImpl#draw源碼。

private void draw(boolean fullRedrawNeeded) {
    Surface surface = mSurface;
    ...
    // 獲取mDirty,該值表示需要重繪的區域
    final Rect dirty = mDirty;
    if (mSurfaceHolder != null) {
        // The app owns the surface, we won't draw.
        dirty.setEmpty();
        if (animating) {
            if (mScroller != null) {
                mScroller.abortAnimation();
            }
            disposeResizeBuffer();
        }
        return;
    }
    // 如果為ture,則設置dirty區域為全屏
    if (fullRedrawNeeded) {
        mAttachInfo.mIgnoreDirtyState = true;
        dirty.set(0, 0, (int) (mWidth * appScale + 0.5f), (int) (mHeight * appScale + 0.5f));
    }
    ...
    // 重繪區域、動畫判斷
        // 硬件渲染判斷
            // 關鍵代碼
            if (!drawSoftware(surface, mAttachInfo, xOffset, yOffset, scalingRequired, dirty)) {
                return;
            }
    ...
}

在draw方法中,根據傳如fullRedrawNeeded參數,設置需要重繪的dirty區域,最后調用drawSoftware方法,把參數傳遞進去,現在繼續看ViewRootImpl#drawSoftware源碼。

private boolean drawSoftware(Surface surface, AttachInfo attachInfo, int xoff, int yoff,
        boolean scalingRequired, Rect dirty) {
    ...
    try {

        if (!canvas.isOpaque() || yoff != 0 || xoff != 0) {
            canvas.drawColor(0, PorterDuff.Mode.CLEAR);
        }
        dirty.setEmpty();
        mIsAnimating = false;
        mView.mPrivateFlags |= View.PFLAG_DRAWN;

        try {
            canvas.translate(-xoff, -yoff);
            if (mTranslator != null) {
                mTranslator.translateCanvas(canvas);
            }
            canvas.setScreenDensity(scalingRequired ? mNoncompatDensity : 0);
            attachInfo.mSetIgnoreDirtyState = false;
            // 關鍵代碼,mView為DecorView,開啟View繪制
            mView.draw(canvas);
            drawAccessibilityFocusedDrawableIfNeeded(canvas);
        } finally {
            if (!attachInfo.mSetIgnoreDirtyState) {
                // Only clear the flag if it was not set during the mView.draw() call
                attachInfo.mIgnoreDirtyState = false;
            }
        }
    } 
    ...
}

上述代碼中,首先對canvas進行一些屬性設置,包括色塊、平移等。之后調用mView.draw(canvas)方法,開始對View進行繪制。mView就是window中的頂級視圖DecorView(這個坑會在之后的文章中說明,這里當做一個頂級的ViewGroup即可)。

2、繪制流程

DecorView繼承自FrameLayout,而ViewGroup的draw方法繼承自View,so,所以我們直接看View#draw即可。

public void draw(Canvas canvas) {
    final int privateFlags = mPrivateFlags;
    final boolean dirtyOpaque = (privateFlags & PFLAG_DIRTY_MASK) == PFLAG_DIRTY_OPAQUE &&
            (mAttachInfo == null || !mAttachInfo.mIgnoreDirtyState);
    mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN;
    /*
     * Draw traversal performs several drawing steps which must be executed
     * in the appropriate order:
     *
     *      1. Draw the background
     *      2. If necessary, save the canvas' layers to prepare for fading
     *      3. Draw view's content
     *      4. Draw children
     *      5. If necessary, draw the fading edges and restore layers
     *      6. Draw decorations (scrollbars for instance)
     */
    // Step 1, draw the background, if needed
    int saveCount;
    if (!dirtyOpaque) {
        drawBackground(canvas);
    }
    // skip step 2 & 5 if possible (common case)
    final int viewFlags = mViewFlags;
    boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
    boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
    if (!verticalEdges && !horizontalEdges) {
        // Step 3, draw the content
        if (!dirtyOpaque) onDraw(canvas);
        // Step 4, draw the children
        dispatchDraw(canvas);
        // Overlay is part of the content and draws beneath Foreground
        if (mOverlay != null && !mOverlay.isEmpty()) {
            mOverlay.getOverlayView().dispatchDraw(canvas);
        }
        // Step 6, draw decorations (foreground, scrollbars)
        onDrawForeground(canvas);
        // we're done...
        return;
    }
    ...
}

draw方法中,官方對其的步驟進行了清晰的注釋,我們來看下流程,在執行流程之前會檢查繪制區域是否透明:

* 1、繪制View背景,如果透明則不繪制

* 2、如果需要,則保存畫布的圖層

* 3、繪制View內容,如果透明則不繪制

* 4、繪制子View————這個很重要

* 5、如果需要,則繪制View的褪色邊緣和恢復圖層

* 6、繪制裝飾滾動條

這里最重要的步驟是第四步,繪制子View,現在我們來看下這個ViewGroup#dispatchDraw(canvas)方法,注意這里的View是一個DecorView,所以要在ViewGroup中去查看這個方法,View中的這個方法是一個空方法。

protected void dispatchDraw(Canvas canvas) {
    ...
    for (int i = 0; i < childrenCount; i++) {
        while (transientIndex >= 0 && mTransientIndices.get(transientIndex) == i) {
            final View transientChild = mTransientViews.get(transientIndex);
            if ((transientChild.mViewFlags & VISIBILITY_MASK) == VISIBLE ||
                    transientChild.getAnimation() != null) {
                more |= drawChild(canvas, transientChild, drawingTime);
            }
            transientIndex++;
            if (transientIndex >= transientCount) {
                transientIndex = -1;
            }
        }
        int childIndex = customOrder ? getChildDrawingOrder(childrenCount, i) : i;
        final View child = (preorderedList == null)
                ? children[childIndex] : preorderedList.get(childIndex);
        if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {
            more |= drawChild(canvas, child, drawingTime);
        }
    }
    while (transientIndex >= 0) {
        // there may be additional transient views after the normal views
        final View transientChild = mTransientViews.get(transientIndex);
        if ((transientChild.mViewFlags & VISIBILITY_MASK) == VISIBLE ||
                transientChild.getAnimation() != null) {
            more |= drawChild(canvas, transientChild, drawingTime);
        }
        transientIndex++;
        if (transientIndex >= transientCount) {
            break;
        }
    }
    ...
}

上述代碼對所有的子View進行遍歷,并調用ViewGroup#drawChild方法。

protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
    return child.draw(canvas, this, drawingTime);
}

drawChild又調用了子View的draw方法,這樣繪制就傳遞了下去,當然這個draw方法和之前這一小節一開始介紹的View#draw方法并不一樣,我們來看看

boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {
    ...
    if (!drawingWithRenderNode) {
        computeScroll();
        sx = mScrollX;
        sy = mScrollY;
    }
    ...
    if (!drawingWithDrawingCache) {
        if (drawingWithRenderNode) {
            mPrivateFlags &= ~PFLAG_DIRTY_MASK;
            ((DisplayListCanvas) canvas).drawRenderNode(renderNode);
        } else {
            // Fast path for layouts with no backgrounds
            if ((mPrivateFlags & PFLAG_SKIP_DRAW) == PFLAG_SKIP_DRAW) {
                mPrivateFlags &= ~PFLAG_DIRTY_MASK;
                dispatchDraw(canvas);
            } else {
                draw(canvas);
            }
        }
    } else if (cache != null) {
        mPrivateFlags &= ~PFLAG_DIRTY_MASK;
        if (layerType == LAYER_TYPE_NONE) {
            // no layer paint, use temporary paint to draw bitmap
            Paint cachePaint = parent.mCachePaint;
            if (cachePaint == null) {
                cachePaint = new Paint();
                cachePaint.setDither(false);
                parent.mCachePaint = cachePaint;
            }
            cachePaint.setAlpha((int) (alpha * 255));
            canvas.drawBitmap(cache, 0.0f, 0.0f, cachePaint);
        } else {
            // use layer paint to draw the bitmap, merging the two alphas, but also restore
            int layerPaintAlpha = mLayerPaint.getAlpha();
            mLayerPaint.setAlpha((int) (alpha * layerPaintAlpha));
            canvas.drawBitmap(cache, 0.0f, 0.0f, mLayerPaint);
            mLayerPaint.setAlpha(layerPaintAlpha);
        }
    }
    ...
}

上述代碼會先判斷之前是否進行過了繪制,如果沒有則進入快速繪制通道,對沒有背景的View進行繪制。判斷是否需要跳過自身的draw繪制方法,如果跳過則進入dispatchDraw,不跳過則進入當前View的draw方法,即這一小節開頭的draw方法,就此形成了循環。同時我們在這里看到了 computeScroll() 方法,也就印證了上一篇文章對于彈性滑動過程的描述。

流程圖如下:

三、小結

本文對上一篇遺留的問題postInvalidate與invalidate的區別進行了回答與分析,對invalidate的傳遞流程,以及View的繪制流程進行了源碼分析,解答了invalidate是如何調用computeScroll()的問題。如果在閱讀過程中,有任何疑問與問題,歡迎與我聯系。

 

 

來自:http://www.idtkm.com/customview/customview9/

 

 本文由用戶 AdrianaMcca 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!