Android touch事件传递及View的绘制流程

touch事件传递的三个重要方法

dispatchTouchEvent(MotionEvent ev)方法用于事件的分发,当事件传递给当前View时,会被调用,返回值受当前View的onTouchEvent和下级View的dispatchTouchEvent方法的影响,表示是否消耗当前事件

 /**
     * ViewGroup
     * @param ev
     * @return
     */
    public boolean dispatchTouchEvent(MotionEvent ev){
        ....//其他处理,在此不管
        if (disallowIntercept || !onInterceptTouchEvent(ev)) {  
            ev.setAction(MotionEvent.ACTION_DOWN);  
            final int scrolledXInt = (int) scrolledXFloat;  
            final int scrolledYInt = (int) scrolledYFloat;  
            final View[] children = mChildren;  
            final int count = mChildrenCount;  
            for (int i = count - 1; i >= 0; i--) {  
                final View child = children[i];  
                if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE  
                        || child.getAnimation() != null) {  
                    child.getHitRect(frame);  
                    if (frame.contains(scrolledXInt, scrolledYInt)) {  
                        final float xc = scrolledXFloat - child.mLeft;  
                        final float yc = scrolledYFloat - child.mTop;  
                        ev.setLocation(xc, yc);  
                        child.mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;  
                        if (child.dispatchTouchEvent(ev))  {  
                            mMotionTarget = child;  
                            return true;  
                        }  
                    }  
                }  
            }  

        ...//其他处理,在此不管
    }
    /**
     * View
     * @param ev
     * @return
     */
    public boolean dispatchTouchEvent(MotionEvent event) {  
    if (mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED &&  
            mOnTouchListener.onTouch(this, event)) {  
        return true;  
    }  
    return onTouchEvent(event);  
}  

由上面源码可以看出,当点击事件产生后,这个ViewGroup中的dispatchTouchEvent会被调用,如果这个ViewGroup的onInterceptTouchEvent方法返回true,就表示要拦截当前事件,然后把事件交给这个ViewGroup处理,即它的onTouchEvent方法被调用,如果这个ViewGroup的onInterceptTouchEvent方法返回false,就表示它 不拦截当前事件,然后继续传递给它的子元素,直至事件被最终处理。

一般情况下,我们不该在普通View内重写dispatchTouchEvent方法,因为它并不执行分发逻辑。当Touch事件到达View时,我们该做的就是是否在onTouchEvent事件中处理它。

流程分析

这里写图片描述
当一个Touch事件(触摸事件为例)到达根节点,即Acitivty的ViewGroup时,它会依次下发,下发的过程是调用子View(ViewGroup)的dispatchTouchEvent方法实现的。简单来说,就是ViewGroup遍历它包含着的子View,调用每个View的dispatchTouchEvent方法,而当子View为ViewGroup时,又会通过调用ViwGroup的dispatchTouchEvent方法继续调用其内部的View的dispatchTouchEvent方法。上述例子中的消息下发顺序是这样的:①-②-⑤-⑥-⑦-③-④。dispatchTouchEvent方法只负责事件的分发,它拥有boolean类型的返回值,当返回为true时,顺序下发会中断。在上述例子中如果⑤的dispatchTouchEvent返回结果为true,那么⑥-⑦-③-④将都接收不到本次Touch事件。

事件传递的相关结论:

  1. 同一个事件序列在手指接触到离开的整个过程以down事件开始,中间含有不定的move事件,最后以up事件结束,但有些特殊情况会没有up事件,需具体分析。
  2. 一个事件序列只能被一个View拦截消耗,同一个事件序列中的事件不能分别由两个View同时处理,但可以通过onTouchEvent强行传递给其他View处理。
  3. 某个View一旦决定拦截,同一个事件序列的onInterceptTouchEvent不会再被调用,只会调用一次。
  4. ViewGroup默认不拦截任何事件。因为Android源码中ViewGroup的onInterceptTouchEvent方法默认返回false
  5. View没有onEnterceptTouchEvent方法,一旦接收到点击事件,那么它的onTouchEvent方法就会被调用,
  6. View的onTouchEvent默认都会消耗事件(返回true),除非它是不可点击的(clickable和longClickable同时为false)。
  7. View的enable属性不影响onTouchEvent的默认返回值。
  8. onClick触发的前提是当前View是可点击的,且它收到了down和up的事件
  9. 通过requestDisallowInterceptTouchEvent方法可以在子元素中干预父元素的事件分发过程。

View的绘制流程

View的工作流程主要指measure、layout、draw三大流程,即测量,布局,和绘制,measure是确定View的测量宽高,layout是确定View的最终宽/高和四个顶点的位置,draw是将View绘制到屏幕上。
绘制流程图

measure

measure过程的核心方法: measure() - onMeasure() - setMeasuredDimension()
measure调用onMeasure,onMeasure调用setMeasureDimension,measure,setMeasureDimension是final类型,view的子类不需要重写,onMeasure在view的子类中重写。

public final void measure(int widthMeasureSpec, int heightMeasureSpec) {  
    if ((mPrivateFlags & FORCE_LAYOUT) == FORCE_LAYOUT ||  
            widthMeasureSpec != mOldWidthMeasureSpec ||  
            heightMeasureSpec != mOldHeightMeasureSpec) {  

        // first clears the measured dimension flag  
        mPrivateFlags &= ~MEASURED_DIMENSION_SET;  

        if (ViewDebug.TRACE_HIERARCHY) {  
            ViewDebug.trace(this, ViewDebug.HierarchyTraceType.ON_MEASURE);  
        }  

        // measure ourselves, this should set the measured dimension flag back  
        onMeasure(widthMeasureSpec, heightMeasureSpec);  

        // flag not set, setMeasuredDimension() was not invoked, we raise  
        // an exception to warn the developer  
        if ((mPrivateFlags & MEASURED_DIMENSION_SET) != MEASURED_DIMENSION_SET) {  
            throw new IllegalStateException("onMeasure() did not set the"  
                    + " measured dimension by calling"  
                    + " setMeasuredDimension()");  
        }  

        mPrivateFlags |= LAYOUT_REQUIRED;  
    }  

    mOldWidthMeasureSpec = widthMeasureSpec;  
    mOldHeightMeasureSpec = heightMeasureSpec;  
}  

View的measure方法中会去调用View的onMeasure方法,因此只需要看onMeasure的实现即可。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {  
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),  
            getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));  
}  

setMeasuredDimension方法会设置View的宽高度的测量值,我们可以看getDefaultSize这个方法即可。

public static int getDefaultSize(int size, int measureSpec) {  
    int result = size;  
    int specMode = MeasureSpec.getMode(measureSpec);  
    int specSize = MeasureSpec.getSize(measureSpec);  

    switch (specMode) {  
    case MeasureSpec.UNSPECIFIED:  
        result = size;  
        break;  
    case MeasureSpec.AT_MOST:  
    case MeasureSpec.EXACTLY:  
        result = specSize;  
        break;  
    }  
    return result;  
}  

MeasureSpec有三种模式分别是UNSPECIFIED, EXACTLY和AT_MOST。
EXACTLY表示父视图希望子视图的大小应该是由specSize的值来决定的,系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。
AT_MOST表示子视图最多只能是specSize中指定的大小,开发人员应该尽可能小得去设置这个视图,并且保证不会超过specSize。系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。
UNSPECIFIED表示开发人员可以将视图按照自己的意愿设置成任意的大小,没有任何限制。这种情况比较少见,不太会用到。

layout

measure过程确定视图的大小,而layout过程确定视图的位置。loyout是从view的layout方法开始的:

public void layout(int l, int t, int r, int b) {  
       int oldL = mLeft;  
       int oldT = mTop;  
       int oldB = mBottom;  
       int oldR = mRight;  
       boolean changed = setFrame(l, t, r, b);  
       if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {  
           onLayout(changed, l, t, r, b);  
           mPrivateFlags &= ~PFLAG_LAYOUT_REQUIRED;  

           ListenerInfo li = mListenerInfo;  
           if (li != null && li.mOnLayoutChangeListeners != null) {  
               ArrayList<OnLayoutChangeListener> listenersCopy =  
                       (ArrayList<OnLayoutChangeListener>)li.mOnLayoutChangeListeners.clone();  
               int numListeners = listenersCopy.size();  
               for (int i = 0; i < numListeners; ++i) {  
                   listenersCopy.get(i).onLayoutChange(this, l, t, r, b, oldL, oldT, oldR, oldB);  
               }  
           }  
       }  
       mPrivateFlags &= ~PFLAG_FORCE_LAYOUT;  
   }  

函数中参数l、t、r、b是指view的左、上、右、底的位置,这几个参数是父视图传入的,而根视图中参数是由performTraversals()方法传入的。

layout中调用了onLayout方法,在view中onLayout方法是一个空函数,他需要其子类实现。

我们来看一下LinearLayout的onlayout实现:

@Override  
  protected void onLayout(boolean changed, int l, int t, int r, int b) {  
      if (mOrientation == VERTICAL) {  
          layoutVertical();  
      } else {  
          layoutHorizontal();  
      }  
  }  

其中的layoutHorizontal()为:

void More ...layoutHorizontal(int left, int top, int right, int bottom) {
1579        final boolean isLayoutRtl = isLayoutRtl();
1580        final int paddingTop = mPaddingTop;
1581
1582        int childTop;
1583        int childLeft;
1584        
1585        // Where bottom of child should go
1586        final int height = bottom - top;
1587        int childBottom = height - mPaddingBottom; 
1588        
1589        // Space available for child
1590        int childSpace = height - paddingTop - mPaddingBottom;
1591
1592        final int count = getVirtualChildCount();
1593
1594        final int majorGravity = mGravity & Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK;
1595        final int minorGravity = mGravity & Gravity.VERTICAL_GRAVITY_MASK;
1596
1597        final boolean baselineAligned = mBaselineAligned;
1598
1599        final int[] maxAscent = mMaxAscent;
1600        final int[] maxDescent = mMaxDescent;
1601
1602        final int layoutDirection = getLayoutDirection();
1603        switch (Gravity.getAbsoluteGravity(majorGravity, layoutDirection)) {
1604            case Gravity.RIGHT:
1605                // mTotalLength contains the padding already
1606                childLeft = mPaddingLeft + right - left - mTotalLength;
1607                break;
1608
1609            case Gravity.CENTER_HORIZONTAL:
1610                // mTotalLength contains the padding already
1611                childLeft = mPaddingLeft + (right - left - mTotalLength) / 2;
1612                break;
1613
1614            case Gravity.LEFT:
1615            default:
1616                childLeft = mPaddingLeft;
1617                break;
1618        }
1619
1620        int start = 0;
1621        int dir = 1;
1622        //In case of RTL, start drawing from the last child.
1623        if (isLayoutRtl) {
1624            start = count - 1;
1625            dir = -1;
1626        }
1627
1628        for (int i = 0; i < count; i++) {
1629            int childIndex = start + dir * i;
1630            final View child = getVirtualChildAt(childIndex);
1631
1632            if (child == null) {
1633                childLeft += measureNullChild(childIndex);
1634            } else if (child.getVisibility() != GONE) {
1635                final int childWidth = child.getMeasuredWidth();
1636                final int childHeight = child.getMeasuredHeight();
1637                int childBaseline = -1;
1638
1639                final LinearLayout.LayoutParams lp =
1640                        (LinearLayout.LayoutParams) child.getLayoutParams();
1641
1642                if (baselineAligned && lp.height != LayoutParams.MATCH_PARENT) {
1643                    childBaseline = child.getBaseline();
1644                }
1645                
1646                int gravity = lp.gravity;
1647                if (gravity < 0) {
1648                    gravity = minorGravity;
1649                }
1650                
1651                switch (gravity & Gravity.VERTICAL_GRAVITY_MASK) {
1652                    case Gravity.TOP:
1653                        childTop = paddingTop + lp.topMargin;
1654                        if (childBaseline != -1) {
1655                            childTop += maxAscent[INDEX_TOP] - childBaseline;
1656                        }
1657                        break;
1658
1659                    case Gravity.CENTER_VERTICAL:
1660                        // Removed support for baseline alignment when layout_gravity or
1661                        // gravity == center_vertical. See bug #1038483.
1662                        // Keep the code around if we need to re-enable this feature
1663                        // if (childBaseline != -1) {
1664                        //     // Align baselines vertically only if the child is smaller than us
1665                        //     if (childSpace - childHeight > 0) {
1666                        //         childTop = paddingTop + (childSpace / 2) - childBaseline;
1667                        //     } else {
1668                        //         childTop = paddingTop + (childSpace - childHeight) / 2;
1669                        //     }
1670                        // } else {
1671                        childTop = paddingTop + ((childSpace - childHeight) / 2)
1672                                + lp.topMargin - lp.bottomMargin;
1673                        break;
1674
1675                    case Gravity.BOTTOM:
1676                        childTop = childBottom - childHeight - lp.bottomMargin;
1677                        if (childBaseline != -1) {
1678                            int descent = child.getMeasuredHeight() - childBaseline;
1679                            childTop -= (maxDescent[INDEX_BOTTOM] - descent);
1680                        }
1681                        break;
1682                    default:
1683                        childTop = paddingTop;
1684                        break;
1685                }
1686
1687                if (hasDividerBeforeChildAt(childIndex)) {
1688                    childLeft += mDividerWidth;
1689                }
1690
1691                childLeft += lp.leftMargin;
1692                setChildFrame(child, childLeft + getLocationOffset(child), childTop,
1693                        childWidth, childHeight);
1694                childLeft += childWidth + lp.rightMargin +
1695                        getNextLocationOffset(child);
1696
1697                i += getChildrenSkipCount(child, childIndex);
1698            }
1699        }
1700    }
1701
1702    private void More ...setChildFrame(View child, int left, int top, int width, int height) {        
1703        child.layout(left, top, left + width, top + height);
1704    }

注意到上面代码中末尾的setChildFrame方法,可以看出,layout是一个自上而下的过程,先设置父视图位置,在循环子视图,父视图位置一定程度上决定了子视图位置。

draw

  1. 所有视图最终都是调用View的draw方法进行绘制。 在自定义视图中, 也不应该复写该方法, 而是复写onDraw()方法进行绘制, 如果自定义的视图确实要复写该方法,先调用super.draw()完成系统的绘制,再进行自定义的绘制。

  2. onDraw()方法默认是空实现,自定义绘制过程需要复写方法,绘制自身的内容。

  3. dispatchDraw()发起对子视图的绘制,在View中默认为空实现,ViewGroup复写了dispatchDraw()来对其子视图进行绘制。自定义的ViewGroup不应该对dispatchDraw()进行复写。

drow方法有六个步骤:

/*  
        * 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)  
        */  

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) {  
           final Drawable background = mBackground;  
           if (background != null) {  
               final int scrollX = mScrollX;  
               final int scrollY = mScrollY;  

               if (mBackgroundSizeChanged) {  
                   background.setBounds(0, 0,  mRight - mLeft, mBottom - mTop);  
                   mBackgroundSizeChanged = false;  
               }  

               if ((scrollX | scrollY) == 0) {  
                   background.draw(canvas);  
               } else {  
                   canvas.translate(scrollX, scrollY);  
                   background.draw(canvas);  
                   canvas.translate(-scrollX, -scrollY);  
               }  
           }  
       }  

       // 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);  

           // Step 6, draw decorations (scrollbars)  
           onDrawScrollBars(canvas);  

           // we're done...  
           return;  
       }  

       /* 
        * Here we do the full fledged routine... 
        * (this is an uncommon case where speed matters less, 
        * this is why we repeat some of the tests that have been 
        * done above) 
        */  

       boolean drawTop = false;  
       boolean drawBottom = false;  
       boolean drawLeft = false;  
       boolean drawRight = false;  

       float topFadeStrength = 0.0f;  
       float bottomFadeStrength = 0.0f;  
       float leftFadeStrength = 0.0f;  
       float rightFadeStrength = 0.0f;  

       // Step 2, save the canvas' layers  
       int paddingLeft = mPaddingLeft;  

       final boolean offsetRequired = isPaddingOffsetRequired();  
       if (offsetRequired) {  
           paddingLeft += getLeftPaddingOffset();  
       }  

       int left = mScrollX + paddingLeft;  
       int right = left + mRight - mLeft - mPaddingRight - paddingLeft;  
       int top = mScrollY + getFadeTop(offsetRequired);  
       int bottom = top + getFadeHeight(offsetRequired);  

       if (offsetRequired) {  
           right += getRightPaddingOffset();  
           bottom += getBottomPaddingOffset();  
       }  

       final ScrollabilityCache scrollabilityCache = mScrollCache;  
       final float fadeHeight = scrollabilityCache.fadingEdgeLength;  
       int length = (int) fadeHeight;  

       // clip the fade length if top and bottom fades overlap  
       // overlapping fades produce odd-looking artifacts  
       if (verticalEdges && (top + length > bottom - length)) {  
           length = (bottom - top) / 2;  
       }  

       // also clip horizontal fades if necessary  
       if (horizontalEdges && (left + length > right - length)) {  
           length = (right - left) / 2;  
       }  

       if (verticalEdges) {  
           topFadeStrength = Math.max(0.0f, Math.min(1.0f, getTopFadingEdgeStrength()));  
           drawTop = topFadeStrength * fadeHeight > 1.0f;  
           bottomFadeStrength = Math.max(0.0f, Math.min(1.0f, getBottomFadingEdgeStrength()));  
           drawBottom = bottomFadeStrength * fadeHeight > 1.0f;  
       }  

       if (horizontalEdges) {  
           leftFadeStrength = Math.max(0.0f, Math.min(1.0f, getLeftFadingEdgeStrength()));  
           drawLeft = leftFadeStrength * fadeHeight > 1.0f;  
           rightFadeStrength = Math.max(0.0f, Math.min(1.0f, getRightFadingEdgeStrength()));  
           drawRight = rightFadeStrength * fadeHeight > 1.0f;  
       }  

       saveCount = canvas.getSaveCount();  

       int solidColor = getSolidColor();  
       if (solidColor == 0) {  
           final int flags = Canvas.HAS_ALPHA_LAYER_SAVE_FLAG;  

           if (drawTop) {  
               canvas.saveLayer(left, top, right, top + length, null, flags);  
           }  

           if (drawBottom) {  
               canvas.saveLayer(left, bottom - length, right, bottom, null, flags);  
           }  

           if (drawLeft) {  
               canvas.saveLayer(left, top, left + length, bottom, null, flags);  
           }  

           if (drawRight) {  
               canvas.saveLayer(right - length, top, right, bottom, null, flags);  
           }  
       } else {  
           scrollabilityCache.setFadeColor(solidColor);  
       }  

       // Step 3, draw the content  
       if (!dirtyOpaque) onDraw(canvas);  

       // Step 4, draw the children  
       dispatchDraw(canvas);  

       // Step 5, draw the fade effect and restore layers  
       final Paint p = scrollabilityCache.paint;  
       final Matrix matrix = scrollabilityCache.matrix;  
       final Shader fade = scrollabilityCache.shader;  

       if (drawTop) {  
           matrix.setScale(1, fadeHeight * topFadeStrength);  
           matrix.postTranslate(left, top);  
           fade.setLocalMatrix(matrix);  
           canvas.drawRect(left, top, right, top + length, p);  
       }  

       if (drawBottom) {  
           matrix.setScale(1, fadeHeight * bottomFadeStrength);  
           matrix.postRotate(180);  
           matrix.postTranslate(left, bottom);  
           fade.setLocalMatrix(matrix);  
           canvas.drawRect(left, bottom - length, right, bottom, p);  
       }  

       if (drawLeft) {  
           matrix.setScale(1, fadeHeight * leftFadeStrength);  
           matrix.postRotate(-90);  
           matrix.postTranslate(left, top);  
           fade.setLocalMatrix(matrix);  
           canvas.drawRect(left, top, left + length, bottom, p);  
       }  

       if (drawRight) {  
           matrix.setScale(1, fadeHeight * rightFadeStrength);  
           matrix.postRotate(90);  
           matrix.postTranslate(right, top);  
           fade.setLocalMatrix(matrix);  
           canvas.drawRect(right - length, top, right, bottom, p);  
       }  

       canvas.restoreToCount(saveCount);  

       // Step 6, draw decorations (scrollbars)  
       onDrawScrollBars(canvas);  
   }  

可以看到,第一步是从第20行代码开始的,这一步的作用是对视图的背景进行绘制。这里会先得到一个mBGDrawable对象,然后根据layout过程确定的视图位置来设置背景的绘制区域,之后再调用Drawable的draw()方法来完成背景的绘制工作。那么这个mBGDrawable对象是从哪里来的呢?其实就是在XML中通过android:background属性设置的图片或颜色。当然你也可以在代码中通过setBackgroundColor()、setBackgroundResource()等方法进行赋值。

接下来的第三步是在第150行执行的,这一步的作用是对视图的内容进行绘制。可以看到,这里去调用了一下onDraw()方法,那么onDraw()方法里又写了什么代码呢?进去一看你会发现,原来又是个空方法啊。其实也可以理解,因为每个视图的内容部分肯定都是各不相同的,这部分的功能交给子类来去实现也是理所当然的。

第三步完成之后紧接着会执行第四步,这一步的作用是对当前视图的所有子视图进行绘制。但如果当前的视图没有子视图,那么也就不需要进行绘制了。因此你会发现View中的dispatchDraw()方法又是一个空方法,而ViewGroup的dispatchDraw()方法中就会有具体的绘制代码。

以上都执行完后就会进入到第六步,也是最后一步,这一步的作用是对视图的滚动条进行绘制。那么你可能会奇怪,当前的视图又不一定是ListView或者ScrollView,为什么要绘制滚动条呢?其实不管是Button也好,TextView也好,任何一个视图都是有滚动条的,只是一般情况下我们都没有让它显示出来而已。绘制滚动条的代码逻辑也比较复杂,这里就不再贴出来了,因为我们的重点是第三步过程。

通过以上流程分析,相信大家已经知道,View是不会帮我们绘制内容部分的,因此需要每个视图根据想要展示的内容来自行绘制。如果你去观察TextView、ImageView等类的源码,你会发现它们都有重写onDraw()这个方法,并且在里面执行了相当不少的绘制逻辑。绘制的方式主要是借助Canvas这个类,它会作为参数传入到onDraw()方法中,供给每个视图使用。Canvas这个类的用法非常丰富,基本可以把它当成一块画布。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值