Android绘制过程

  • View的绘制流程

一.VIew的绘制流程

img

View的绘制流程是从ViewRootImplperformTraversals方法开始,它经过measurelayoutdraw三个过程才能最终将一个View绘制出来。

!!!记住:img

//===========ActivityThread.java==========
final void handleResumeActivity(...) {
  ......
   //跟踪代码后发现其初始赋值为mWindow = new PhoneWindow(this, window, activityConfigCallback);
   r.window r.activity.getWindow();
      //从PhoneWindow实例中获取DecorView
   View decor r.window.getDecorView();
  ......
   //跟踪代码后发现,vm值为上述PhoneWindow实例中获取的WindowManager。
   ViewManager wm a.getWindowManager();
  ......
   //当前window的属性,从代码跟踪来看是PhoneWindow窗口的属性
   WindowManager.LayoutParams r.window.getAttributes();
  ......
   wm.addView(decor, l);
  ......
}

 

小结:

在ActivityThread中传递获得decorView以及windowManager以及layoutParams参数,decor与windowManager以组合方式组合在一起,然后调用windowManager的AddView将decor以及Params传递,实际上是间接调用ViewRootImpl类中的performTraversals(),从而实现视图的绘制。

img

ViewRootImpl中的performTraversals()

// =====================ViewRootImpl.java=================
/*
上述代码中就是一个完成的绘制流程,对应上了第一节中提到的三个步骤:

     1)performMeasure():从根节点向下遍历View树,完成所有ViewGroup和View的测量工作,计算出所有ViewGroup和View显示出来需要的高度和宽度;

     2)performLayout():从根节点向下遍历View树,完成所有ViewGroup和View的布局计算工作,根据测量出来的宽高及自身属性,计算出所有ViewGroup和View显示在屏幕上的区域;

     3)performDraw():从根节点向下遍历View树,完成所有ViewGroup和View的绘制工作,根据布局过程计算出的显示区域,将所有View的当前需显示的内容画到屏幕上。

咱们后续就是通过对这三个方法来展开研究整个绘制过程
*/
private void performTraversals() {
  ......
  Rect frame mWinFrame;
  ......
  mWidth frame.width();
  mHeight frame.heigth();
  ......
    //一个View的MeasureSpec由父布局MeasureSpec和自身的LayoutParams共同产生。父布局的MeasureSpec从何而来?从父布局的父布局而来。最顶层的布局是DecorView,DecorView的MeasureSpec是通过ViewRootImpl中的getRootMeasureSpec方法得到的
    //其中mWidth和mHeight是windowManagerService服务计算的Activity窗口大小
  int childWidthMeasureSpec getRootMeasureSpec(mWidth, lp.width);
  int childHeightMeasureSpec getRootMeasureSpec(mHeight, lp.height);
  ......
  // Ask host how big it wants to be
  performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
  ......
  performLayout(lp, mWidth, mHeight);
  ......
  performDraw();
}
/*
1)基于window的layout params,在window中为root view 找出measure spec。(笔者注:也就是找出DecorView的MeasureSpec,这里的window也就是PhoneWindow了)

     2)参数windowSize:window的可用宽度和高度值。

     3)参数rootDimension:window的宽/高的layout param值。

     4)返回值:返回用于测量root view的MeasureSpec。    
*/
private static int getRootMeasureSpec(int windowSize, int rootDimension) {
       int measureSpec;
       switch (rootDimension) {

       case ViewGroup.LayoutParams.MATCH_PARENT:
           // Window can't resize. Force root view to be windowSize.
           measureSpec MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);
           break;
       case ViewGroup.LayoutParams.WRAP_CONTENT:
           // Window can resize. Set max size for root view.
           measureSpec MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
           break;
       default:
           // Window wants to be an exact size. Force root view to be that size.
           measureSpec MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY);
           break;
      }
       return measureSpec;
}

MeasureSpec

是view的一个内部类

其中有三个模式:

SepcMode的三种类型:

  • EXACTLY: 对应match_parent

  • AT_MOST 对应wrap_parent

  • UNSPECIFIED 父容器不对View有任何限制

     

    preview


   public static class MeasureSpec {
       private static final int MODE_SHIFT 30;
       private static final int MODE_MASK  0x3 << MODE_SHIFT;
   
       public static final int UNSPECIFIED << MODE_SHIFT;

       public static final int EXACTLY     << MODE_SHIFT;

       public static final int AT_MOST     << MODE_SHIFT;
  
     //用偏移以及与操作将模式以及size组合在一起,由于有三个模式占两位,所以size最多就是30位(2^30-1)
       public static int makeMeasureSpec(@IntRange(from 0, to = (<< MeasureSpec.MODE_SHIFT) 1) int size, @MeasureSpecMode int mode) {
           if (sUseBrokenMakeMeasureSpec) {
               return size mode;
          } else {
             //将size与mode组合
               return (size ~MODE_MASK) | (mode MODE_MASK);
          }
          ......
      }
 
       @MeasureSpecMode
       public static int getMode(int measureSpec) {
           //noinspection ResourceType
           return (measureSpec MODE_MASK);
      }

       public static int getSize(int measureSpec) {
           return (measureSpec ~MODE_MASK);
      }
      ......
}

ViewGroup.LayoutParams:

/*
  1)LayoutParams被view用于告诉它们的父布局它们想要怎样被布局。(笔者注:字面意思就是布局参数)

   2)该LayoutParams基类仅仅描述了view希望宽高有多大。对于每一个宽或者高,可以指定为以下三种值中的一个:MATCH_PARENT,WRAP_CONTENT,an exact number。(笔者注:FILL_PARENT从API8开始已经被MATCH_PARENT取代了,所以下文就只提MATCH_PARENT)

   3)MATCH_PARENT:意味着该view希望和父布局尺寸一样大,如果父布局有padding,则要减去该padding值。

   4)WRAP_CONTENT:意味着该view希望其大小为仅仅足够包裹住其内容即可,如果自己有padding,则要加上该padding值。

   5)对ViewGroup不同的子类,也有相应的LayoutParams子类。 

   6)其width和height属性对应着layout_width和layout_height属性
*/   
public static class LayoutParams {

   public static final int MATCH_PARENT -1;


   public static final int WRAP_CONTENT -2;


   public int width;


   public int height;
  ......
  }

 

从ViewRootImpl的performMeasure(int childWidthMeasureSpec, int childHeightMeasureSpec)开始测量

mView是decorView,在ActivityThread中的addView方法中传递进来

//ViewRootImpl
//
private void performMeasure(int childWidthMeasureSpec, int childHeightMeasureSpec) {
      ......
       mView.measure(childWidthMeasureSpec, childHeightMeasureSpec);
      ......
}

//ActivityThread.addView -> ViewRootImpl.setView
public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
    ......
     mView view;
    ......

     mWindowAttributes.copyFrom(attrs);
    ......
}

View中的Measure方法

public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
    ......
     // measure ourselves, this should set the measured dimension flag back
     onMeasure(widthMeasureSpec, heightMeasureSpec);
    ......
}
/*
1)该方法被调用,用于找出view应该多大。父布局在witdh和height参数中提供了限制信息;

2)一个view的实际测量工作是在被本方法所调用的onMeasure(int,int)方法中实现的。所以,只有onMeasure(int,int)可以并且必须被子类重写(笔者注:这里应该指的是,ViewGroup的子类必须重写该方法,才能绘制该容器内的子view。如果是自定义一个子控件,extends View,那么并不是必须重写该方法);

3)参数widthMeasureSpec:父布局加入的水平空间要求;

4)参数heightMeasureSpec:父布局加入的垂直空间要求。

系统将其定义为一个final方法,可见系统不希望整个测量流程框架被修改。
 */

View中的OnMeasure

//这些方法稍后会被用到
/*     @see #getMeasuredWidth()
     * @see #getMeasuredHeight()
     * @see #setMeasuredDimension(int, int)
     * @see #getSuggestedMinimumHeight()
     * @see #getSuggestedMinimumWidth()
     * @see android.view.View.MeasureSpec#getMode(int)
     * @see android.view.View.MeasureSpec#getSize(int)
*/

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
  //用来储存View测出的宽和高
       setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
               getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
  }

//根据模式来获得不同的值,这时候设置的模式就派上了用场
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;
}

// 返回建议该view应该使用的最小宽度值。该方法返回了view的最小宽度值和背景的最小宽度值(链接android.graphics.drawable.Drawable#getMinimumWidth())之间的最大值。
//当在onMeasure(int,int)使用时,调用者应该仍然确保返回的宽度值在父布局的要求之内。
//返回值:view的建议最小宽度值
// 这其中提到的"mininum width“指的是在xml布局文件中该view的“android:minWidth"属性值,“background's minimum width”值是指“android:background”的宽度。该方法的返回值就是两者之间较大的那一个值,用来作为该view的最小宽度值,现在应该很容易理解了吧,当一个view在layout文件中同时设置了这两个属性时,为了两个条件都满足,自然要选择值大一点的那个了
protected int getSuggestedMinimumWidth() {
       return (mBackground == null) mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
}


//   measuredWidth:该view被测量出宽度值。
//   measuredHeight:该view被测量出的高度值
protected final void setMeasuredDimension(int measuredWidth, int measuredHeight) {
      .....
       setMeasuredDimensionRaw(measuredWidth, measuredHeight);
  }


//View成员变量中的值被确定下来
private void setMeasuredDimensionRaw(int measuredWidth, int measuredHeight) {
       mMeasuredWidth measuredWidth;
       mMeasuredHeight measuredHeight;
      ......
}




//View中
public static final int MEASURED_SIZE_MASK 0x00ffffff;

//获取原始的测量宽度值,一般会拿这个方法和layout执行后getWidth()方法做比较。该方法需要在setMeasuredDimension()方法执行后才有效,否则返回值为0。
public final int getMeasuredWidth() {
  return mMeasuredWidth MEASURED_SIZE_MASK;
}

public final int getMeasuredHeight() {
  return mMeasuredHeight MEASURED_SIZE_MASK;
}

开始测量:

//=============DecorView.java=============
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
      ......
   super.onMeasure(widthMeasureSpec, heightMeasureSpec);
      ......
}

//=============FrameLayout.java=============
//因为decorView继承了FrameLayout
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
 int count getChildCount();
 //和事件分发流程差不多,只是他每一个都测量一下,事件分发是特定的view
 for (int 0; count; i++) {
      final View child getChildAt(i);
      ......
      measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
      ......
  }
  ......
    //最后设置自身的值
  setMeasuredDimension(......)
  ...... 
}

最后流程图大概如下:

img

小结:

从ActivityThread开始获得DecorView与WindowManager并获得好DecorView的LayoutParams,并一层一层传递下去,ViewGroup会递归传递,而View会设置自身大小,直到ViewGroup等待自身所有View设置好之后设置自身的值

设置值的规则:

MeasureSpec是由父View的MeasureSpec和子View的LayoutParams通过简单的计算得出一个针对子View的测量要求!

 

ViewGroup中辅助重写onMeasure的几个重要方法介绍:

//================ViewGroup.java===============
/**
* Ask one of the children of this view to measure itself, taking into
* account both the MeasureSpec requirements for this view and its padding.
* The heavy lifting is done in getChildMeasureSpec.
*
* @param child The child to measure
* @param parentWidthMeasureSpec The width requirements for this view
* @param parentHeightMeasureSpec The height requirements for this view
*/
protected void measureChild(View child, int parentWidthMeasureSpec,
int parentHeightMeasureSpec) {
final LayoutParams lp = child.getLayoutParams();

final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
mPaddingLeft + mPaddingRight, lp.width);
final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
mPaddingTop + mPaddingBottom, lp.height);

child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}






protected void measureChildWithMargins(View child,
int parentWidthMeasureSpec, int widthUsed,
int parentHeightMeasureSpec, int heightUsed) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
+ widthUsed, lp.width);
final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
+ heightUsed, lp.height);

child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}





/// spec参数 表示父View的MeasureSpec
// padding参数 父View的Padding+子View的Margin,父View的大小减去这些边距,才能精确算出
// 子View的MeasureSpec的size
// childDimension参数 表示该子View内部LayoutParams属性的值(lp.width或者lp.height)
// 可以是wrap_content、match_parent、一个精确指(an exactly size),
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
int specMode = MeasureSpec.getMode(spec); //获得父View的mode
int specSize = MeasureSpec.getSize(spec); //获得父View的大小

//父View的大小-自己的Padding+子View的Margin,得到值才是子View的大小。
int size = Math.max(0, specSize - padding);

int resultSize = 0; //初始化值,最后通过这个两个值生成子View的MeasureSpec
int resultMode = 0; //初始化值,最后通过这个两个值生成子View的MeasureSpec

switch (specMode) {
// Parent has imposed an exact size on us
//1、父View是EXACTLY的 !
case MeasureSpec.EXACTLY:
//1.1、子View的width或height是个精确值 (an exactly size)
if (childDimension >= 0) {
resultSize = childDimension; //size为精确值
resultMode = MeasureSpec.EXACTLY; //mode为 EXACTLY 。
}
//1.2、子View的width或height为 MATCH_PARENT/FILL_PARENT
else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size. So be it.
resultSize = size; //size为父视图大小
resultMode = MeasureSpec.EXACTLY; //mode为 EXACTLY 。
}
//1.3、子View的width或height为 WRAP_CONTENT
else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can't be
// bigger than us.
resultSize = size; //size为父视图大小
resultMode = MeasureSpec.AT_MOST; //mode为AT_MOST 。
}
break;

// Parent has imposed a maximum size on us
//2、父View是AT_MOST的 !
case MeasureSpec.AT_MOST:
//2.1、子View的width或height是个精确值 (an exactly size)
if (childDimension >= 0) {
// Child wants a specific size... so be it
resultSize = childDimension; //size为精确值
resultMode = MeasureSpec.EXACTLY; //mode为 EXACTLY 。
}
//2.2、子View的width或height为 MATCH_PARENT/FILL_PARENT
else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size, but our size is not fixed.
// Constrain child to not be bigger than us.
resultSize = size; //size为父视图大小
resultMode = MeasureSpec.AT_MOST; //mode为AT_MOST
}
//2.3、子View的width或height为 WRAP_CONTENT
else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can't be
// bigger than us.
resultSize = size; //size为父视图大小
resultMode = MeasureSpec.AT_MOST; //mode为AT_MOST
}
break;

// Parent asked to see how big we want to be
//3、父View是UNSPECIFIED的 !
case MeasureSpec.UNSPECIFIED:
//3.1、子View的width或height是个精确值 (an exactly size)
if (childDimension >= 0) {
// Child wants a specific size... let him have it
resultSize = childDimension; //size为精确值
resultMode = MeasureSpec.EXACTLY; //mode为 EXACTLY
}
//3.2、子View的width或height为 MATCH_PARENT/FILL_PARENT
else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size... find out how big it should
// be
resultSize = 0; //size为0! ,其值未定
resultMode = MeasureSpec.UNSPECIFIED; //mode为 UNSPECIFIED
}
//3.3、子View的width或height为 WRAP_CONTENT
else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size.... find out how
// big it should be
resultSize = 0; //size为0! ,其值未定
resultMode = MeasureSpec.UNSPECIFIED; //mode为 UNSPECIFIED
}
break;
}
//根据上面逻辑条件获取的mode和size构建MeasureSpec对象。
return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
  • 如果父View的MeasureSpec 是EXACTLY,说明父View的大小是确切的,(确切的意思很好理解,如果一个View的MeasureSpec 是EXACTLY,那么它的size 是多大,最后展示到屏幕就一定是那么大)。

1、如果子View 的layout_xxxx是MATCH_PARENT,父View的大小是确切,子View的大小又MATCH_PARENT(充满整个父View),那么子View的大小肯定是确切的,而且大小值就是父View的size。所以子View的size=父View的size,mode=EXACTLY

2、如果子View 的layout_xxxx是WRAP_CONTENT,也就是子View的大小是根据自己的content 来决定的,但是子View的毕竟是子View,大小不能超过父View的大小,但是子View的是WRAP_CONTENT,我们还不知道具体子View的大小是多少,要等到child.measure(childWidthMeasureSpec, childHeightMeasureSpec) 调用的时候才去真正测量子View 自己content的大小(比如TextView wrap_content 的时候你要测量TextView content 的大小,也就是字符占用的大小,这个测量就是在child.measure(childWidthMeasureSpec, childHeightMeasureSpec)的时候,才能测出字符的大小,MeasureSpec 的意思就是假设你字符100px,但是MeasureSpec 要求最大的只能50px,这时候就要截掉了)。通过上述描述,子View MeasureSpec mode的应该是AT_MOST,而size 暂定父View的 size,表示的意思就是子View的大小没有不确切的值,子View的大小最大为父View的大小,不能超过父View的大小(这就是AT_MOST 的意思),然后这个MeasureSpec 做为子View measure方法 的参数,做为子View的大小的约束或者说是要求,有了这个MeasureSpec子View再实现自己的测量。

3、如果如果子View 的layout_xxxx是确定的值(200dp),那么就更简单了,不管你父View的mode和size是什么,我都写死了就是200dp,那么控件最后展示就是就是200dp,不管我的父View有多大,也不管我自己的content 有多大,反正我就是这么大,所以这种情况MeasureSpec 的mode = EXACTLY 大小size=你在layout_xxxx 填的那个值。

  • 如果父View的MeasureSpec 是AT_MOST,说明父View的大小是不确定,最大的大小是MeasureSpec 的size值,不能超过这个值。

1、如果子View 的layout_xxxx是MATCH_PARENT,父View的大小是不确定(只知道最大只能多大),子View的大小MATCH_PARENT(充满整个父View),那么子View你即使充满父容器,你的大小也是不确定的,父View自己都确定不了自己的大小,你MATCH_PARENT你的大小肯定也不能确定的,所以子View的mode=AT_MOST,size=父View的size,也就是你在布局虽然写的是MATCH_PARENT,但是由于你的父容器自己的大小不确定,导致子View的大小也不确定,只知道最大就是父View的大小。

2、如果子View 的layout_xxxx是WRAP_CONTENT,父View的大小是不确定(只知道最大只能多大),子View又是WRAP_CONTENT,那么在子View的Content没算出大小之前,子View的大小最大就是父View的大小,所以子View MeasureSpec mode的就是AT_MOST,而size 暂定父View的 size。

3、如果如果子View 的layout_xxxx是确定的值(200dp),同上,写多少就是多少,改变不了的。

  • 如果父View的MeasureSpec 是UNSPECIFIED(未指定),表示没有任何束缚和约束,不像AT_MOST表示最大只能多大,不也像EXACTLY表示父View确定的大小,子View可以得到任意想要的大小,不受约束

1、如果子View 的layout_xxxx是MATCH_PARENT,因为父View的MeasureSpec是UNSPECIFIED,父View自己的大小并没有任何约束和要求, 那么对于子View来说无论是WRAP_CONTENT还是MATCH_PARENT,子View也是没有任何束缚的,想多大就多大,没有不能超过多少的要求,一旦没有任何要求和约束,size的值就没有任何意义了,所以一般都直接设置成0

2、同上...

3、如果如果子View 的layout_xxxx是确定的值(200dp),同上,写多少就是多少,改变不了的(记住,只有设置的确切的值,那么无论怎么测量,大小都是不变的,都是你写的那个值)

具体:https://www.jianshu.com/p/5a71014e7b1b?from=singlemessage

Layout过程

根据测量的值以及一些其他的参数设定布局(如gravity)

ViewGroup中的layout函数

public final void layout(int l, int t, int r, int b) {    
if (!mSuppressLayout && (mTransition == null || !mTransition.isChangingLayout())) {
if (mTransition != null) {
mTransition.layoutChange(this);
}
super.layout(l, t, r, b);
} else {
// record the fact that we noop'd it; request layout when transition finishes
mLayoutCalledWhileSuppressed = true;
}
}

//super
public final void layout(int l, int t, int r, int b) {
.....
//设置View位于父视图的坐标轴
boolean changed = setFrame(l, t, r, b);
//判断View的位置是否发生过变化,看有必要进行重新layout吗
if (changed || (mPrivateFlags & LAYOUT_REQUIRED) == LAYOUT_REQUIRED) {
if (ViewDebug.TRACE_HIERARCHY) {
ViewDebug.trace(this, ViewDebug.HierarchyTraceType.ON_LAYOUT);
}
//调用onLayout(changed, l, t, r, b); 函数
onLayout(changed, l, t, r, b);
mPrivateFlags &= ~LAYOUT_REQUIRED;
}
mPrivateFlags &= ~FORCE_LAYOUT;
.....
}

//回调 View
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {

}
//对于ViewGroup 来说,唯一的差别就是ViewGroup中多了关键字abstract的修饰,要求其子类必须重载onLayout函数
@Override
protected abstract void onLayout(boolean changed,
int l, int t, int r, int b);

 

draw过程

public void draw(Canvas canvas) {
...
/*
* 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
...
background.draw(canvas);
...
// skip step 2 & 5 if possible (common case)
...
// Step 2, save the canvas' layers
...
if (solidColor == 0) {
final int flags = Canvas.HAS_ALPHA_LAYER_SAVE_FLAG;

if (drawTop) {
canvas.saveLayer(left, top, right, top + length, null, flags);
}
...
// 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

if (drawTop) {
matrix.setScale(1, fadeHeight * topFadeStrength);
matrix.postTranslate(left, top);
fade.setLocalMatrix(matrix);
canvas.drawRect(left, top, right, top + length, p);
}
...
// Step 6, draw decorations (scrollbars)
onDrawScrollBars(canvas);
}

大致流程:

img

啰嗦链接:https://www.cnblogs.com/andy-songwei/p/10955062.html

posted @ 2021-12-27 19:38  码虫垒起代码之城  阅读(201)  评论(0编辑  收藏  举报