View工作原理--ViewRootImpl和performTraversals()

ViewRootImpl

ViewRoot(android 2.2之前的老版本)对应于ViewRootImpl(替代ViewRoot)类,它是连接WindowManager和DecorView的纽带,View的三大流程均是通过ViewRoot完成的。当Activity对象被创建完毕后,会将DecorView添加到Window中,同时会创建ViewRootImpl对象,并将ViewRootImpl对象和DecorView建立关联

关联过程

创建Activity实例后会调用Activity.attach()来初始化一些内容,Window对象在这里创建

Activity # attach()

final void attach(...{ 
    ...    
    mWindow = new PhoneWindow(this);    
    mWindow.setWindowManager((WindowManager)context.getSystemService(Context.WINDOW_SERVICE), 
        mToken, mComponent.flattenToString(),
        (info.flags & ActivityInfo.FLAG_HARDWARE_ACCELERATED) != 0);   
    if (mParent != null) {            
        mWindow.setContainer(mParent.getWindow());        
    }
    mWindowManager = mWindow.getWindowManager();    
    ...
}

每个Activity会有一个WindowManager对象,这个mWindowManager就是和WindowManagerService(WMS)进行通信,也是WMS识别View具体属于那个Activity的关键。

Activity # makeVisible()

在ActivityThread.handleResumeActivity()中,调用r.activity.makeVisible

    void makeVisible() {
        if (!mWindowAdded) {
            // 创建WindowManagerImpl(实现WindowManager接口)对象
            ViewManager wm = getWindowManager();
            // 传入DecorView,调用WindowManagerImpl的addView()
            wm.addView(mDecor, getWindow().getAttributes());
            mWindowAdded = true;
        }
        mDecor.setVisibility(View.VISIBLE);
    }
辨析getWindowManager().addView(mView,wl)和addContentView(mView,wmParams)

在Activity中调用WindowManager.LayoutParams wl = new WindowManager.LayoutParams(); getWindowManager().addView(mView,wl)

  • getWindowManager().addView(mView,wl)
    会调用到WindowManagerGlobal.addView,这时会创建一个新的ViewRootImpl,和原来的DecoView不在一条View链上
  • addContentView(mView,wmParams)
    直接将mView添加到DecoView中,会使ViewRootImpl链下的所有View重绘。

WindowManagerImpl # addView()

    @Override
    public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
        applyDefaultToken(params);
        // 调用WindowManagerGlobal的addView()
        mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);
    }

WindowManagerGlobal # addView()

public void addView(View view, ViewGroup.LayoutParams params,
        Display display, Window parentWindow) {
    // ......
    ViewRootImpl root;
    View panelParentView = null;
    synchronized (mLock) {
        // ......
        root = new ViewRootImpl(view.getContext(), display);
        // final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params
        view.setLayoutParams(wparams);
        // 将相关加入WindowManagerGlobal的三个全局集合中
        mViews.add(view);
        mRoots.add(root);
        mParams.add(wparams);
        try {
            // 将DecorView(view)与ViewRootImpl连接起来,以后DecorView的事件都由ViewRootImpl来管理了,比如在DecorView上增删改View
            root.setView(view, wparams, panelParentView);
        } catch (RuntimeException e) {
            if (index >= 0) {
                removeViewLocked(index, true);
            }
            throw e;
        }
    }
}

ViewRootImpl # setView()

public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
    synchronized (this) {
        // 将DecorView赋值给mView,表示根View
        if (mView == null) {
            mView = view;
            // ......
        }
    }
}

ViewRootImpl实现ViewParent这个接口(这个接口最常见的一个方法是requestLayout()),在DecoView添加View时,就会将View中的ViewParent设为DecoView所在的ViewRootImpl,View的ViewParent相同时,理解为这些View在一个View链上。所以每当调用View的requestLayout()时,其实是调用到ViewRootImpl,ViewRootImpl会控制整个事件的流程。可以看出一个ViewRootImpl对添加到DecoView的所有View进行事件管理。
注:requestLayout方法会导致View的onMeasure、onLayout、onDraw方法被调用;invalidate方法则只会导致View的onDraw方法被调用

关系图

注意:Activty里面持有一个Window,这个Window有一个唯一实现子类PhoneWindow,而PhoneWindow对象里面又持有一个DecorView对象
mView和DecoView是同级关系
在这里插入图片描述

performTraversals()

performTraversals()是ViewRootImpl的一个方法,performTraversals()作为三大流程的起点,创建、参数改变、界面刷新等时都有可能会需要从根部开始measure、layout、draw,就会调用到它。

performTraversals()的调用流程

在ViewRootImpl中只有一处调用了它

    void doTraversal() {
        if (mTraversalScheduled) {
            mTraversalScheduled = false;
            mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

            if (mProfile) {
                Debug.startMethodTracing("ViewAncestor");
            }

            performTraversals();

            if (mProfile) {
                Debug.stopMethodTracing();
                mProfile = false;
            }
        }
    }

再看doTraversal()在哪调用

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

这个mTraversalRunnable又在哪用到呢

    void scheduleTraversals() {
        if (!mTraversalScheduled) {
            mTraversalScheduled = true;
            // 往MessageQueue中加入了一个同步屏障(说白了同步屏障是一个特殊的Message)然后由于同步屏障的作用MessageQueue中那些非异步的消息都不进行获取操作了,这么做就是保障刷新的Message能够第一时间得到Looper的调用
            mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
            // 将runnable发送给handler执行
            mChoreographer.postCallback(
                    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
            if (!mUnbufferedInputDispatch) {
                scheduleConsumeBatchedInput();
            }
            notifyRendererOfFramePending();
            pokeDrawLockIfNeeded();
        }
    }

由此可以看出performTraversals()是在一个runnable中被调用的,通过把这个runnable加入队列执行

而scheduleTraversals()方法在requestLayout()方法中调用,而requestLayout()方法在setView()中调用

    public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
        synchronized (this) {
            if (mView == null) {
                mView = view;

                mAttachInfo.mDisplayState = mDisplay.getState();
                mDisplayManager.registerDisplayListener(mDisplayListener, mHandler);

                ...

                mAdded = true;
                // Schedule the first layout -before- adding to the window
                // manager, to make sure we do the relayout before receiving
                // any other events from the system.
                requestLayout();
                ...
            }
        }
    }
    
    @Override
    public void requestLayout() {
        if (!mHandlingLayoutInLayoutRequest) {
            checkThread();
            mLayoutRequested = true;
            scheduleTraversals();
        }
    }

以上所述,我们可以看到performTraversals()被调用的步骤:

  • setView()
  • requestLayout()
  • scheduleTraversals()
  • 创建了一个TraversalRunnable对象
  • 在其run方法中调用 doTraversal()
  • performTraversals()

performTraversals()的具体代码

可分为三个阶段

1.计算窗口期望尺寸

// 这里的mView在setView中被赋值为DecorView
final View host = mView;
// mAdded指DecorView是否被成功加入到window中,在setView()中被赋值为true
if (host == null || !mAdded)
    return;
初始化窗口的期望宽高

以下为API为27的源码

        // 用来保存窗口宽度和高度,来自于全局变量mWinFrame,这个mWinFrame保存了窗口最新尺寸
        Rect frame = mWinFrame;
        
        // mFirst在构造器中被初始化为true,表示第一次performTraversals()
        if (mFirst) {
            // 设置需要全部重新draw
            mFullRedrawNeeded = true;
            // 需要重新layout
            mLayoutRequested = true;

            final Configuration config = mContext.getResources().getConfiguration();
          
            // 判断要绘制的窗口是否包含状态栏,然后确定要绘制的Decorview的高度和宽度
            // 有的话就去掉
            if (shouldUseDisplaySize(lp)) {
                // NOTE -- system code, won't try to do compat mode.
                Point size = new Point();
                mDisplay.getRealSize(size);
                desiredWindowWidth = size.x;
                desiredWindowHeight = size.y;
            } else {
                // 宽度和高度为整个屏幕的值
                desiredWindowWidth = dipToPx(config.screenWidthDp);
                desiredWindowHeight = dipToPx(config.screenHeightDp);
            }

            ...
            
        // 如果不是第一次performTraversals()    
        } else {
            // 它的当前宽度和高度就从之前的保存在ViewRootImpl类的成员变量mWinFrame/frame获取
            desiredWindowWidth = frame.width();
            desiredWindowHeight = frame.height();
            // mWidth和mHeight是由WindowManagerService服务计算出的窗口大小,
            // 如果这次测量的窗口大小与这两个值不同,说明WMS单方面改变了窗口的尺寸
            if (desiredWindowWidth != mWidth || desiredWindowHeight != mHeight) {
                if (DEBUG_ORIENTATION) Log.v(mTag, "View " + host + " resized to: " + frame);
                // 需要进行全部重绘以适应新的窗口尺寸
                mFullRedrawNeeded = true;
                // 需要重新布局
                mLayoutRequested = true;
                // window窗口大小改变
                windowSizeMayChange = true;
            }
        }

注意!ViewRoot类的另外两个成员变量mWidth和mHeight也是用来描述Activity窗口当前的宽度和高度的,但是它们的值是由应用程序进程上一次主动请求WindowManagerService服务计算得到的,并且会一直保持不变到应用程序进程下一次再请求WindowManagerService服务来重新计算为止。Activity窗口的当前宽度和高度有时候是被WindowManagerService服务主动请求应用程序进程修改的,修改后的值就会保存在ViewRoot类的成员变量mWinFrame中,它们可能会与ViewRoot类的成员变量mWidth和mHeight的值不同。

再对窗口期望大小赋值并开始测量
boolean insetsChanged = false;
boolean layoutRequested = mLayoutRequested && (!mStopped || mReportNextDraw);
// 要求重新测量
// 进行预测量
if (layoutRequested) {
    final Resources res = mView.getContext().getResources();
    // 如果是第一次
    if (mFirst) {
        // make sure touch mode code executes by setting cached value
        // to opposite of the added touch mode.
        // 视图窗口当前是否处于触摸模式
        mAttachInfo.mInTouchMode = !mAddedTouchMode;
        // 确保window的触摸模式已经打开
        // 内部 mAttachInfo.mInTouchMode = inTouchMode
        ensureTouchModeLocally(mAddedTouchMode);
        // private boolean ensureTouchModeLocally(boolean inTouchMode) {
        //      if (DBG) Log.d("touchmode", "ensureTouchModeLocally(" + inTouchMode + "), current "
        //            + "touch mode is " + mAttachInfo.mInTouchMode);

        //      if (mAttachInfo.mInTouchMode == inTouchMode) return false;

        //      mAttachInfo.mInTouchMode = inTouchMode;
        //      mAttachInfo.mTreeObserver.dispatchOnTouchModeChanged(inTouchMode);

        //      return (inTouchMode) ? enterTouchMode() : leaveTouchMode();
        // }
    
    // 如果不是第一次
    } else {
        // mOverscanInsets 记录屏幕中的 overscan 区域
        // mContentInsets  记录了屏幕中的控件在布局时必须预留的空间
        // mStableInsets   记录了Stable区域,比mContentInsets区域大
        // mVisibleInsets  记录了被遮挡的区域
        // mPending...Insets是这一次请求traversals还未生效的值
        // mAttachInfo中的值是上一次traversals时保存的insets值
        // 比较两者看是否有变化,如果有变化就将insetsChanged置为true
        if (!mPendingOverscanInsets.equals(mAttachInfo.mOverscanInsets)) {
            insetsChanged = true;
        }
        if (!mPendingContentInsets.equals(mAttachInfo.mContentInsets)) {
            insetsChanged = true;
        }
        if (!mPendingStableInsets.equals(mAttachInfo.mStableInsets)) {
                insetsChanged = true;
        }
        if (!mPendingVisibleInsets.equals(mAttachInfo.mVisibleInsets)) {
            mAttachInfo.mVisibleInsets.set(mPendingVisibleInsets);
            if (DEBUG_LAYOUT) Log.v(mTag, "Visible insets changing to: "
                + mAttachInfo.mVisibleInsets);
        }
        if (!mPendingOutsets.equals(mAttachInfo.mOutsets)) {
            insetsChanged = true;
        }
        if (mPendingAlwaysConsumeNavBar != mAttachInfo.mAlwaysConsumeNavBar) {
            insetsChanged = true;
        }
        
        // 如果将窗口的宽或高设置为wrap_content了,最终还是会变为屏幕大小
        if (lp.width == ViewGroup.LayoutParams.WRAP_CONTENT
            || lp.height == ViewGroup.LayoutParams.WRAP_CONTENT) {
            // 窗口大小可能改变
            windowSizeMayChange = true;

            if (shouldUseDisplaySize(lp)) {
                // NOTE -- system code, won't try to do compat mode.
                Point size = new Point();
                mDisplay.getRealSize(size);
                desiredWindowWidth = size.x;
                desiredWindowHeight = size.y;
            } else {
                Configuration config = res.getConfiguration();
                desiredWindowWidth = dipToPx(config.screenWidthDp);
                desiredWindowHeight = dipToPx(config.screenHeightDp);
            }
        }
    }
    
    // 进行预测量窗口大小,以达到更好的显示大小
    // 比如dialog可能就是显示几个字而被铺满屏幕是奇怪的,通过measureHierarchy()去优化,尝试更小的宽度是否合适,这个方法会调用performMeasure()确定window大小,返回窗口大小是否会改变
    // host: Decor   lp: window attr   rs: decor res
    // desiredWindowWidth/Height: 上面初始的窗口期望宽高
    windowSizeMayChange |= measureHierarchy(host, lp, res,
            desiredWindowWidth, desiredWindowHeight);
}

正式确定是否重置窗口尺寸,是否需要重新计算insets
if (layoutRequested) {
    // 暂时清空这个标记,在下面再次需要的时候再重新赋值。
    // Clear this now, so that if anything requests a layout in the
    // rest of this function we will catch it and re-run a full
    // layout pass.
    mLayoutRequested = false;
}

// 同时满足三个条件
// 1.layoutRequested为true,已经发起了一次新的layout。
// 2.上面赋值的窗口尺寸可能发生改变
// 3.上面measureHierarchy()中测量的值和上一次保存的值不同 或
// 宽或高设置为wrap_content,WindowManagerService服务请求Activity窗口设置的宽度frame.width()和高度frame.height()与窗口期望大小不一致,而且与Activity窗口上一次请求WindowManagerService服务计算的宽度mWidth和高度mHeight也不一致
boolean windowShouldResize = layoutRequested && windowSizeMayChange
    && ((mWidth != host.getMeasuredWidth() || mHeight != host.getMeasuredHeight())
        || (lp.width == ViewGroup.LayoutParams.WRAP_CONTENT &&
                frame.width() < desiredWindowWidth && frame.width() != mWidth)
        || (lp.height == ViewGroup.LayoutParams.WRAP_CONTENT &&
                frame.height() < desiredWindowHeight && frame.height() != mHeight));
                
windowShouldResize |= mDragResizing && mResizeMode == RESIZE_MODE_FREEFORM;

// 如果activity重新启动,需要强制通过wms获取最新的值
windowShouldResize |= mActivityRelaunched;
// 如果没有监听器,那么我们可能仍然需要计算insets,以防旧insets不为空,必须重置。
// 设置是否需要指定insets,设置了监听或存在需要重新设置的insets(不为空)
final boolean computesInternalInsets =
        mAttachInfo.mTreeObserver.hasComputeInternalInsetsListeners()
        || mAttachInfo.mHasNonEmptyGivenInternalInsets;

2.确定窗口尺寸,调用WMS计算并保存

// 第一次performTraversals() 或 窗口尺寸有变化 或 insets有变化 或 窗口可见性有变化
// 或 params窗口属性有变化指向了mWindowAttributes 或 强迫窗口下一次重新layout
if (mFirst || windowShouldResize || insetsChanged ||
        viewVisibilityChanged || params != null || mForceNextWindowRelayout) {
    // 清空这个标记位
    mForceNextWindowRelayout = false;
    
    // 设置insetsPending
    if (isViewVisible) {
        // 如果insets发生改变  并且  是第一次performTraversals()或窗口从不可见变为可见
        // 就置insetsPending为true
        insetsPending = computesInternalInsets && (mFirst || viewVisibilityChanged);
    }
    ...
    
    // 调用WMS重新计算并保存
    try {
        ...
    
        // 调用relayoutWindow()(使用IPC)去请求WMS 重新计算窗口尺寸以及insets大小
        // 计算完毕之后,Activity窗口的大小就会保存在成员变量mWinFrame中
        // params: window attr  view可见性 是否有额外的insets
        relayoutResult = relayoutWindow(params, viewVisibility, insetsPending);
    
        ...
    
        // mPendingOverscanInsets等rect在relayoutWindow方法里保存了最新窗口大小值
        // 再与上一次测量的保存在mAttachInfo中的值进行比较
        final boolean overscanInsetsChanged = !mPendingOverscanInsets.equals(
            mAttachInfo.mOverscanInsets);
        contentInsetsChanged = !mPendingContentInsets.equals(
            mAttachInfo.mContentInsets);
        final boolean visibleInsetsChanged = !mPendingVisibleInsets.equals(
            mAttachInfo.mVisibleInsets);
        ...
        final boolean alwaysConsumeNavBarChanged =
            mPendingAlwaysConsumeNavBar != mAttachInfo.mAlwaysConsumeNavBar;
    
        // 如果发生了改变,就会进行重新赋值等操作
        if (contentInsetsChanged) {
            mAttachInfo.mContentInsets.set(mPendingContentInsets);
        }
        if (overscanInsetsChanged) {
            mAttachInfo.mOverscanInsets.set(mPendingOverscanInsets);
            contentInsetsChanged = true;
        }
        ...
        if (visibleInsetsChanged) {
            mAttachInfo.mVisibleInsets.set(mPendingVisibleInsets);
        }
    
        ...
    
    } catch (RemoteException e) {
    
    }

    // frame指向的是mWinFrame(也就是变量frame和mWinFrame引用的是同一个Rect对象), 此时已经是上面重新请求WMS计算后的值了
    // 将值保存在mAttachInfo中
    mAttachInfo.mWindowLeft = frame.left;
    mAttachInfo.mWindowTop = frame.top;

    // 如果前一次计算的值和这次计算的值有变化就重新赋值
    if (mWidth != frame.width() || mHeight != frame.height()) {
        mWidth = frame.width();
        mHeight = frame.height();
    }
    ...
    
    // 是否需要重新测量
    // 如果窗口不处于停止状态或者提交了下一次的绘制
    if (!mStopped || mReportNextDraw) {
        // Activity窗口的触摸模式发生了变化,并且由此引发了Activity窗口当前获得焦点的控件发生了变化,即变量focusChangedDueToTouchMode的值等于true
        boolean focusChangedDueToTouchMode = ensureTouchModeLocally(
            (relayoutResult&WindowManagerGlobal.RELAYOUT_RES_IN_TOUCH_MODE) != 0);
            
        // 判断是否需要重新测量窗口尺寸
        // 窗口触摸模式发生改变,焦点发生改变
        // 或 测量宽高与WMS计算的宽高不相等
        // 或 insets改变了
        // 或 配置发生改变,mPendingMergedConfiguration有变化
        if (focusChangedDueToTouchMode || mWidth != host.getMeasuredWidth()
            || mHeight != host.getMeasuredHeight() || contentInsetsChanged ||
            updatedConfiguration) {
            // 重新计算decorView的MeasureSpec
            int childWidthMeasureSpec = getRootMeasureSpec(mWidth, lp.width);
            int childHeightMeasureSpec = getRootMeasureSpec(mHeight, lp.height);
        
            // Ask host how big it wants to be
            // 执行测量操作
            performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
        
            int width = host.getMeasuredWidth();
            int height = host.getMeasuredHeight();
            // 判断是否需要重新测量
            boolean measureAgain = false;
            
            // 如果需要在水平方向上分配额外的像素,需要重新测量
            if (lp.horizontalWeight > 0.0f) {
                // 测量宽度加上额外的宽度
                width += (int) ((mWidth - width) * lp.horizontalWeight);
                // 重新计算MeasureSpec
                childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(width,
                    MeasureSpec.EXACTLY);
                // 设置需要重新测量
                measureAgain = true;
            }
            // 同上
            if (lp.verticalWeight > 0.0f) {
                height += (int) ((mHeight - height) * lp.verticalWeight);
                childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height,
                    MeasureSpec.EXACTLY);
                measureAgain = true;
            }
            // 如果需要重新测量了,就再调用一次performMeasure()
            if (measureAgain) {
                performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
            }
            layoutRequested = true;
        }
    }
   
// 不是第一次performTraversals()    
} else {
    // 判断窗口有没有移动,如果移并且存在动画动就执行移动动画
    maybeHandleWindowMove(frame);
}

3.调用layout和draw流程

// layout
// 需要layout并且窗口不是停止状态或提交了下一次draw
final boolean didLayout = layoutRequested && (!mStopped || mReportNextDraw);
boolean triggerGlobalLayoutListener = didLayout
        || mAttachInfo.mRecomputeGlobalAttributes;
if (didLayout) {

    // 开始layout流程
    performLayout(lp, mWidth, mHeight);
    
    // 处理透明区域
    if ((host.mPrivateFlags & View.PFLAG_REQUEST_TRANSPARENT_REGIONS) != 0) {
        ...
    }
    ...
}
...
// 处理insets
if (computesInternalInsets) {
    // Clear the original insets.
    final ViewTreeObserver.InternalInsetsInfo insets = mAttachInfo.mGivenInternalInsets;
    // 重置insets树,清空之前的状态
    insets.reset();

    // Compute new insets in place.
    // 遍历计算新的
    mAttachInfo.mTreeObserver.dispatchOnComputeInternalInsets(insets);
    mAttachInfo.mHasNonEmptyGivenInternalInsets = !insets.isEmpty();

    // Tell the window manager.
    if (insetsPending || !mLastGivenInsets.equals(insets)) {
        mLastGivenInsets.set(insets);

        // Translate insets to screen coordinates if needed.
        final Rect contentInsets;
        final Rect visibleInsets;
        final Region touchableRegion;
        // 转换成屏幕坐标
        if (mTranslator != null) {
            contentInsets = mTranslator.getTranslatedContentInsets(insets.contentInsets);
            visibleInsets = mTranslator.getTranslatedVisibleInsets(insets.visibleInsets);
            touchableRegion = mTranslator.getTranslatedTouchableArea(insets.touchableRegion);
        } else {
            contentInsets = insets.contentInsets;
            visibleInsets = insets.visibleInsets;
            touchableRegion = insets.touchableRegion;
        }
        // 远程调用WMS去设置insets
        try {
            mWindowSession.setInsets(mWindow, insets.mTouchableInsets,
                    contentInsets, visibleInsets, touchableRegion);
        } catch (RemoteException e) {
            
        }
    }
}

// draw
// 第一次performTraversals()不会调用performDraw()因为newSurface为true,然后在scheduleTraversals()中会再次调用performTraversals()会执行draw
boolean cancelDraw = mAttachInfo.mTreeObserver.dispatchOnPreDraw() || !isViewVisible;
// 没有取消draw并且没有创建新的surface
if (!cancelDraw && !newSurface) {
    // 执行等待执行的动画
    if (mPendingTransitions != null && mPendingTransitions.size() > 0) {
        for (int i = 0; i < mPendingTransitions.size(); ++i) {
            mPendingTransitions.get(i).startChangingAnimations();
        }
        mPendingTransitions.clear();
    }
    // 开始绘制
    performDraw();
} else {
    if (isViewVisible) {
        // Try again
        // 再次调用一次performTraversals()
        scheduleTraversals();
    } else if (mPendingTransitions != null && mPendingTransitions.size() > 0) {
        // 执行结束动画
        for (int i = 0; i < mPendingTransitions.size(); ++i) {
            mPendingTransitions.get(i).endChangingAnimations();
        }
        mPendingTransitions.clear();
    }
}

mIsInTraversal = false;

performTraversals()工作流程图

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值