WindowManagerService添加窗口流程(4)

WindowManagerService添加窗口流程(4)

简述

添加窗口流程是WMS很重要的一个功能,如果你是一个app开发,基本上接触WMS最多的场景也就是这个流程,在添加悬浮窗的时候使用的addView就是这个流程,其实activity也是通过类似的流程去添加显示的窗口的,我们会通过介绍addView这个流程了解添加窗口这个过程。
和之前介绍SurfaceFlinger一样,在继续了解WMS之前,我们先来介绍一下app和WMS的binder通信架构,以便我们后面更好的了解WMS的其他流程。等我们看过几个核心服务之后,就会发现Framework里面所有服务和app的通信方式都是类似的。

端侧binder结构

我们app一般都是通过WindowManager去访问WMS的接口,WindowManager接口实现了ViewManager接口,ViewManager的接口就三个,addView,updateViewLayout,removeView,这三个接口app开发应该会在悬浮窗开发时使用,是三个挺常用也挺重要的接口,下一篇文章我们会介绍添加窗口的流程,也就是addView。
WindowManager扩展了很多接口,实际我们在使用时候拿到的是WindowManager的子类WindowManagerImpl。

public interface ViewManager
{
    public void addView(View view, ViewGroup.LayoutParams params);
    public void updateViewLayout(View view, ViewGroup.LayoutParams params);
    public void removeView(View view);
}

WindowManagerImpl里面需要通过binder到WMS到方法都会通过WindowManagerGlobal去调用,WindowManagerGlobal是一个单例,WindowManagerGlobal里面有一个静态方法getWindowManagerService获取WMS binder句柄。sWindowManagerService是一个IWindowManager接口,IWindowManager是一个aidl接口用于和WMS通信。

public final class WindowManagerImpl implements WindowManager {
    @UnsupportedAppUsage
    private final WindowManagerGlobal mGlobal = WindowManagerGlobal.getInstance();
    // ...
}

public final class WindowManagerGlobal {
    // ...
    public static IWindowManager getWindowManagerService() {
        synchronized (WindowManagerGlobal.class) {
            if (sWindowManagerService == null) {
                sWindowManagerService = IWindowManager.Stub.asInterface(
                        ServiceManager.getService("window"));
                try {
                    if (sWindowManagerService != null) {
                        ValueAnimator.setDurationScale(
                                sWindowManagerService.getCurrentAnimatorScale());
                        sUseBLASTAdapter = sWindowManagerService.useBLAST();
                    }
                } catch (RemoteException e) {
                    throw e.rethrowFromSystemServer();
                }
            }
            return sWindowManagerService;
        }
    }
    // ...
}

还有一个getWindowSession,这里会通过之前的IWindowManager调用openSession,构建一个和WMS的会话,后续会用这个会话调用WMS的binder接口,这个Session实现了IWindowSession.aidl接口。

public static IWindowSession getWindowSession() {
    synchronized (WindowManagerGlobal.class) {
        if (sWindowSession == null) {
            try { 
                InputMethodManager.ensureDefaultInstanceForDefaultDisplayIfNecessary();
                IWindowManager windowManager = getWindowManagerService();
                sWindowSession = windowManager.openSession(
                        new IWindowSessionCallback.Stub() {
                            @Override
                            public void onAnimatorScaleChanged(float scale) {
                                ValueAnimator.setDurationScale(scale);
                            }
                        });
            } catch (RemoteException e) {
                throw e.rethrowFromSystemServer();
            }
        }
        return sWindowSession;
    }
}

在这里插入图片描述

app侧添加窗口

1.1 WindowManagerImpl.addView
前面我们提到过ViewManager里面有几个重要的接口,接下来我们就从addView开始了解app添加窗口的流程,这个流程是WMS很重要的一个流程。
我们在app开发中实现一个悬浮窗的时候,通常会通过这个方法来实现,本质上对话框也是通过这个实现的,所以如果你是app开发,应该也会常常接触到这个。
前面说过,WindowManagerImpl会通过WindowManagerGlobal去和WMS进行binder通信。顺带提一下,这里有一个参数mContext.getDisplayNoVerify(),这个是表示窗口需要添加到哪个屏幕上,所以当我们有虚拟屏的时候,app显示添加的窗口会显示在哪个屏幕上就是由这个参数决定的。

public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
    applyTokens(params);
    // 详见1.2
    mGlobal.addView(view, params, mContext.getDisplayNoVerify(), mParentWindow,
            mContext.getUserId());
}

1.2 WindowManagerGlobal.addView
首先做了一些参数校验
其次会构造一个ViewRootImpl,一个窗口在WindowManagerGlobal里面有三个关键的变量,一个View,一个ViewRootImpl,一个layoutParams。用三个列表记录。
ViewRootImpl里面有一个比较关键的变量是W类型的,是一个binder server,后面在添加窗口的过程会传给WMS,后续WMS就会通过这个binder和app交互。比如Insets发生变化就会通过这个binder通知app。

public void addView(View view, ViewGroup.LayoutParams params,
        Display display, Window parentWindow, int userId) {
    // ...参数校验

    final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
    if (parentWindow != null) {
        // 如果有父窗口,则将父窗口的token配置为layoutParams.token作为参数
        parentWindow.adjustLayoutParamsForSubWindow(wparams);
    } else {
        final Context context = view.getContext();
        if (context != null
                && (context.getApplicationInfo().flags
                        & ApplicationInfo.FLAG_HARDWARE_ACCELERATED) != 0) {
            wparams.flags |= WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED;
        }
    }

    ViewRootImpl root;
    View panelParentView = null;

    synchronized (mLock) {
        // ... 监听系统属性,RendorThread渲染有一些配置是通过系统属性决定的,所以需要监听系统属性实时更新配置

        // mRoots是一个ViewRootImpl数组,每一个窗口都会对应一个ViewRootImpl,存储在mRoots中,这里尝试通过view找到对应的ViewRootImpl,如果有的话说明View已经显示在其他窗口上了,就会抛异常。
        int index = findViewLocked(view, false);
        if (index >= 0) {
            // ... View已经添加,抛异常
        }

        // 如果窗口类型是子窗口,根据token找到父窗口的View存在panelParentView中供后续使用。
        if (wparams.type >= WindowManager.LayoutParams.FIRST_SUB_WINDOW &&
                wparams.type <= WindowManager.LayoutParams.LAST_SUB_WINDOW) {
            final int count = mViews.size();
            for (int i = 0; i < count; i++) {
                if (mRoots.get(i).mWindow.asBinder() == wparams.token) {
                    panelParentView = mViews.get(i);
                }
            }
        }

        IWindowSession windowlessSession = null;
        // mWindowlessRoots里面也是ViewRootImpl,SurfaceView之类的场景会出现没有Window但是SurfaceFlinger也有layer,这种场景就会放在mWindowlessRoots
        if (wparams.token != null && panelParentView == null) {
            for (int i = 0; i < mWindowlessRoots.size(); i++) {
                ViewRootImpl maybeParent = mWindowlessRoots.get(i);
                if (maybeParent.getWindowToken() == wparams.token) {
                    windowlessSession = maybeParent.getWindowSession();
                    break;
                }
            }
        }

        // 构建一个新的ViewRootImpl,里面主要是一些变量初始化,就不看了。
        // 构造函数里会构建一个W类,是一个binder的server,WMS后续会通过这个binder来回调client(比如Insets变化等),详见1.2.1
        if (windowlessSession == null) {
            root = new ViewRootImpl(view.getContext(), display);
        } else {
            root = new ViewRootImpl(view.getContext(), display,
                    windowlessSession, new WindowlessWindowLayout());
        }

        view.setLayoutParams(wparams);
        // 这三个列表里的对象是一一对应的,即一个窗口里有一个ViewRootImpl,一个View和一个LayoutParams
        mViews.add(view);
        mRoots.add(root);
        mParams.add(wparams);

        try {
            // 添加窗口的实际过程,详见1.3
            root.setView(view, wparams, panelParentView, userId);
        } catch (RuntimeException e) {
            // ... 抛异常
        }
    }
}

1.2.1 class W
W类还有不少回调,这里随便列了两个。

static class W extends IWindow.Stub {
    @Override
    public void resized(ClientWindowFrames frames, boolean reportDraw,
            MergedConfiguration mergedConfiguration, InsetsState insetsState,
            boolean forceLayout, boolean alwaysConsumeSystemBars, int displayId, int syncSeqId,
            boolean dragResizing) {
        final ViewRootImpl viewAncestor = mViewAncestor.get();
        if (viewAncestor != null) {
            viewAncestor.dispatchResized(frames, reportDraw, mergedConfiguration, insetsState,
                    forceLayout, alwaysConsumeSystemBars, displayId, syncSeqId, dragResizing);
        }
    }

    @Override
    public void insetsControlChanged(InsetsState insetsState,
            InsetsSourceControl[] activeControls) {
        final ViewRootImpl viewAncestor = mViewAncestor.get();
        if (viewAncestor != null) {
            viewAncestor.dispatchInsetsControlChanged(insetsState, activeControls);
        }
    }
    // ...
}

1.3 ViewRootImpl.setView
setView这个方法比较长,我们省略了一些不关注的代码逻辑。
这里处理了很多逻辑,兼容模式,计算了mAttachInfo的一些属性,然后最重要的是通过addToDisplayAsUser binder到WMS添加窗口,以及根据添加窗口根据WMS计算窗口大小以及Inset等,同时还有配置Input通道相关的逻辑。
调用了requestLayout,会请求下一个VSync进行窗口刷新,是一个比较重要的过程,我们后面章节再介绍。
这里我们主要关注addToDisplayAsUser添加窗口的过程。

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

            mViewLayoutDirectionInitial = mView.getRawLayoutDirection();
            mFallbackEventHandler.setView(view);
            // ... 设置一些attrs

            setAccessibilityFocus(null, null);
            // 设置回调,DecorView继承了RootViewSurfaceTaker
            // 如果在Activity通过taskSurface设置的回调就是在这里配置的回调。
            if (view instanceof RootViewSurfaceTaker) {
                mSurfaceHolderCallback =
                        ((RootViewSurfaceTaker)view).willYouTakeTheSurface();
                if (mSurfaceHolderCallback != null) {
                    mSurfaceHolder = new TakenSurfaceHolder();
                    mSurfaceHolder.setFormat(PixelFormat.UNKNOWN);
                    mSurfaceHolder.addCallback(mSurfaceHolderCallback);
                }
            }
            // 计算Insets
            if (!attrs.hasManualSurfaceInsets) {
                attrs.setSurfaceInsets(view, false /*manual*/, true /*preservePrevious*/);
            }
            // 获取屏幕兼容模式
            CompatibilityInfo compatibilityInfo =
                    mDisplay.getDisplayAdjustments().getCompatibilityInfo();
            mTranslator = compatibilityInfo.getTranslator();

            // 如果app设置了Surface回调,持有这个Surface就不能使用硬件加速,否则就可以开启硬件加速
            if (mSurfaceHolder == null) {
                enableHardwareAcceleration(attrs);
                // ...
            }

            boolean restore = false;
            // 兼容模式可能会放大或者缩小窗口,就是在这里实现,实际上就是修改attrs里面的属性。
            if (mTranslator != null) {
                mSurface.setCompatibilityTranslator(mTranslator);
                restore = true;
                attrs.backup();
                mTranslator.translateWindowLayout(attrs);
            }

            mSoftInputMode = attrs.softInputMode;
            // ...更新mAttachInfo
            mAdded = true;
            int res; /* = WindowManagerImpl.ADD_OKAY; */

            // 请求下一个VSync信号
            requestLayout();
            // 构建InputChannel
            InputChannel inputChannel = null;
            if ((mWindowAttributes.inputFeatures
                    & WindowManager.LayoutParams.INPUT_FEATURE_NO_INPUT_CHANNEL) == 0) {
                inputChannel = new InputChannel();
            }
            mForceDecorViewVisibility = (mWindowAttributes.privateFlags
                    & PRIVATE_FLAG_FORCE_DECOR_VIEW_VISIBILITY) != 0;
            // ...

            try {
                mOrigWindowType = mWindowAttributes.type;
                mAttachInfo.mRecomputeGlobalAttributes = true;
                collectViewAttributes();
                adjustLayoutParamsForCompatibility(mWindowAttributes);
                controlInsetsForCompatibility(mWindowAttributes);

                Rect attachedFrame = new Rect();
                final float[] compatScale = { 1f };
                // 通过binder接口添加窗口,详见1.4
                res = mWindowSession.addToDisplayAsUser(mWindow, mWindowAttributes,
                        getHostVisibility(), mDisplay.getDisplayId(), userId,
                        mInsetsController.getRequestedVisibleTypes(), inputChannel, mTempInsets,
                        mTempControls, attachedFrame, compatScale);
                if (!attachedFrame.isValid()) {
                    attachedFrame = null;
                }
                if (mTranslator != null) {
                    // 如果之前兼容模式有缩放,这里缩放Inset
                    mTranslator.translateInsetsStateInScreenToAppWindow(mTempInsets);
                    mTranslator.translateSourceControlsInScreenToAppWindow(mTempControls.get());
                    mTranslator.translateRectInScreenToAppWindow(attachedFrame);
                }
                mTmpFrames.attachedFrame = attachedFrame;
                mTmpFrames.compatScale = compatScale[0];
                mInvCompatScale = 1f / compatScale[0];
            } catch (RemoteException | RuntimeException e) {
                // ...
            } finally {
                if (restore) {
                    attrs.restore();
                }
            }
            // Inset相关的计算逻辑,到后面的章节单独介绍。
            mAttachInfo.mAlwaysConsumeSystemBars =
                    (res & WindowManagerGlobal.ADD_FLAG_ALWAYS_CONSUME_SYSTEM_BARS) != 0;
            mPendingAlwaysConsumeSystemBars = mAttachInfo.mAlwaysConsumeSystemBars;
            mInsetsController.onStateChanged(mTempInsets);
            mInsetsController.onControlsChanged(mTempControls.get());
            final InsetsState state = mInsetsController.getState();
            final Rect displayCutoutSafe = mTempRect;
            state.getDisplayCutoutSafe(displayCutoutSafe);
            final WindowConfiguration winConfig = getCompatWindowConfiguration();
            // 计算一些窗口大小逻辑。
            mWindowLayout.computeFrames(mWindowAttributes, state,
                    displayCutoutSafe, winConfig.getBounds(), winConfig.getWindowingMode(),
                    UNSPECIFIED_LENGTH, UNSPECIFIED_LENGTH,
                    mInsetsController.getRequestedVisibleTypes(), 1f /* compactScale */,
                    mTmpFrames);
            setFrame(mTmpFrames.frame, true /* withinRelayout */);
            registerBackCallbackOnWindow();
            if (DEBUG_LAYOUT) Log.v(mTag, "Added window " + mWindow);
            // 如果添加窗口没有成功
            if (res < WindowManagerGlobal.ADD_OKAY) {
                mAttachInfo.mRootView = null;
                mAdded = false;
                mFallbackEventHandler.setView(null);
                unscheduleTraversals();
                setAccessibilityFocus(null, null);
                switch (res) {
                    // ...根据不同的返回值打印不同的错误信息,抛出异常。
                }
            }
            // 注册屏幕变化监听,处理屏幕变化
            registerListeners();
            // ... 配置input通道接受器,设置InputStage,处理input事件在app层的逻辑
            // 后续我们会专门介绍input,这里的细节在那个章节会介绍。
        }
    }
}

WMS侧添加窗口

接下来就到了WMS侧了

1.4 Session.addToDisplayAsUser
这里就是直接调用了WMS的addWindow

public int addToDisplayAsUser(IWindow window, WindowManager.LayoutParams attrs,
        int viewVisibility, int displayId, int userId, @InsetsType int requestedVisibleTypes,
        InputChannel outInputChannel, InsetsState outInsetsState,
        InsetsSourceControl.Array outActiveControls, Rect outAttachedFrame,
        float[] outSizeCompatScale) {
    return mService.addWindow(this, window, attrs, viewVisibility, displayId, userId,
            requestedVisibleTypes, outInputChannel, outInsetsState, outActiveControls,
            outAttachedFrame, outSizeCompatScale);
}

1.5 WindowManagerService.addWindow
这里的第二个参数IWindow client是端侧W类,后续WMS会通过这个W类来binder访问client端,这里会讲client存在新建的WindowState里。
先做了一些权限校验,根据窗口type做一些合理性检查。
然后如果窗口类型是一个子窗口,则和父窗口共用一个token,否则就新建一个WindowToken。
接下来会构建一个新的WindowState,它在WMS侧就代表这个新添加的窗口。displayPolicy.addWindowLw会看新增的窗口是否包含Inset,如果包含则更新WMS对Inset对记录。
后面还有一些更新Input焦点,更新Configuration,计算Insets。这些不是我们这节关注的点,这里就不细说了。
这个方法很长,但是主要大多数逻辑都是一些校验和计算,最重要的事就是新建WindowState,并且把它添加到树结构合适的节点处。

public int addWindow(Session session, IWindow client, LayoutParams attrs, int viewVisibility,
        int displayId, int requestUserId, @InsetsType int requestedVisibleTypes,
        InputChannel outInputChannel, InsetsState outInsetsState,
        InsetsSourceControl.Array outActiveControls, Rect outAttachedFrame,
        float[] outSizeCompatScale) {
    // ...权限检测

    synchronized (mGlobalLock) {
        // 根据displayId获取DisplayContent,之前介绍过DisplayContent,在WMS树上就代表一个屏幕
        final DisplayContent displayContent = getDisplayContentOrCreate(displayId, attrs.token);

        // ...做一些有效检验,根据窗口type权限校验,userId等。

        ActivityRecord activity = null;
        final boolean hasParent = parentWindow != null;
        // 通过attrs里面的token找到窗口
        WindowToken token = displayContent.getWindowToken(
                hasParent ? parentWindow.mAttrs.token : attrs.token);
        final int rootType = hasParent ? parentWindow.mAttrs.type : type;

        boolean addToastWindowRequiresToken = false;

        final IBinder windowContextToken = attrs.mWindowContextToken;
        // 如果token为空,说明这次是新建一个窗口
        if (token == null) {
            // ...有效性检测
            if (hasParent) { 
                // 如果是一个子窗口,和父窗口共用一个token
                token = parentWindow.mToken;
            } else if (mWindowContextListenerController.hasListener(windowContextToken)) { 
                final IBinder binder = attrs.token != null ? attrs.token : windowContextToken;
                final Bundle options = mWindowContextListenerController
                        .getOptions(windowContextToken);
                token = new WindowToken.Builder(this, binder, type)
                        .setDisplayContent(displayContent)
                        .setOwnerCanManageAppTokens(session.mCanAddInternalSystemWindow)
                        .setRoundedCornerOverlay(isRoundedCornerOverlay)
                        .setFromClientToken(true)
                        .setOptions(options)
                        .build();
            } else {
                final IBinder binder = attrs.token != null ? attrs.token : client.asBinder();
                token = new WindowToken.Builder(this, binder, type)
                        .setDisplayContent(displayContent)
                        .setOwnerCanManageAppTokens(session.mCanAddInternalSystemWindow)
                        .setRoundedCornerOverlay(isRoundedCornerOverlay)
                        .build();
            }
        } 
        // ... 根据窗口Type做合理性检测
        // ... 新建WindowState。
        final WindowState win = new WindowState(this, session, client, token, parentWindow,
                appOp[0], attrs, viewVisibility, session.mUid, userId,
                session.mCanAddInternalSystemWindow);
        // ...

        final DisplayPolicy displayPolicy = displayContent.getDisplayPolicy();
        // 根据windowType对窗口attrs.flag做一些调整
        displayPolicy.adjustWindowParamsLw(win, win.mAttrs);
        attrs.flags = sanitizeFlagSlippery(attrs.flags, win.getName(), callingUid, callingPid);
        attrs.inputFeatures = sanitizeSpyWindow(attrs.inputFeatures, win.getName(), callingUid,
                callingPid);
        win.setRequestedVisibleTypes(requestedVisibleTypes);
        
        res = displayPolicy.validateAddingWindowLw(attrs, callingPid, callingUid);
        if (res != ADD_OKAY) {
            return res;
        }

        final boolean openInputChannels = (outInputChannel != null
                && (attrs.inputFeatures & INPUT_FEATURE_NO_INPUT_CHANNEL) == 0);
        if  (openInputChannels) {
            win.openInputChannel(outInputChannel);
        }

        // ...处理Toast类型窗口
        // 设置返回值为添加成功
        res = ADD_OKAY;
        // ...
        // 会更新Session里面窗口的计数,每个Session就能知道当前自己有多少个窗口。
        win.attach();
        // 不同规定结构记录windowState,以便不同场景快速访问。
        mWindowMap.put(client.asBinder(), win);
        win.initAppOpsState();

        final boolean suspended = mPmInternal.isPackageSuspended(win.getOwningPackage(),
                UserHandle.getUserId(win.getOwningUid()));
        win.setHiddenWhileSuspended(suspended);

        final boolean hideSystemAlertWindows = !mHidingNonSystemOverlayWindows.isEmpty();
        win.setForceHideNonSystemOverlayWindowIfNeeded(hideSystemAlertWindows);

        boolean imMayMove = true;
        // 将新建窗口添加到窗口树中
        win.mToken.addWindow(win);
        // 这里会判断新添加的窗口是否有Inset,有的话就更新WMS侧记录的Inset,这个会在后面讲Inset章节详细介绍。
        displayPolicy.addWindowLw(win, attrs);
        displayPolicy.setDropInputModePolicy(win, win.mAttrs);
        // 如果是app启动窗口,需要关联activity,在后续activity自己的窗口绘制完成后需要删除这个窗口
        if (type == TYPE_APPLICATION_STARTING && activity != null) {
            activity.attachStartingWindow(win);
        } 
        // 其他一些特殊窗口的特殊处理

        final WindowStateAnimator winAnimator = win.mWinAnimator;
        winAnimator.mEnterAnimationPending = true;
        winAnimator.mEnteringAnimation = true;

        // ...Input焦点的更新,添加窗口后可能会引起Input焦点变化,在Input章节再介绍

        // 处理Configuration,如果新的窗口可见并且引起横竖屏变化,则需要重新分法该屏幕的Configurataion
        // Configuration每个节点都有一个,但是每次更新是会从树多根节点以一定规则计算,每个节点都会更新。
        // 这个也比较好理解,比如由于前台app横屏幕,整个屏幕其实都进入了横屏状态,需要同时改变其他窗口的Configuration来更新UI。
        boolean needToSendNewConfiguration =
                win.isVisibleRequestedOrAdding() && displayContent.updateOrientation();
        if (win.providesDisplayDecorInsets()) {
            needToSendNewConfiguration |= displayPolicy.updateDecorInsetsInfo();
        }
        if (needToSendNewConfiguration) {
            displayContent.sendNewConfiguration();
        }

        // 计算更新Inset。细节我们后续章节在讲Inset的时候会介绍
        displayContent.getInsetsStateController().updateAboveInsetsState(
                false /* notifyInsetsChanged */);
        // 设置返回给app侧Insets的信息
        outInsetsState.set(win.getCompatInsetsState(), true /* copySources */);
        getInsetsSourceControls(win, outActiveControls);

        // 设置返回给app的窗口大小等信息
        if (win.mLayoutAttached) {
            outAttachedFrame.set(win.getParentWindow().getFrame());
            if (win.mInvGlobalScale != 1f) {
                outAttachedFrame.scale(win.mInvGlobalScale);
            }
        } else {
            outAttachedFrame.set(0, 0, -1, -1);
        }
        outSizeCompatScale[0] = win.getCompatScaleForClient();
    }

    Binder.restoreCallingIdentity(origId);

    return res;
}

小结

添加窗口这个流程有两个方法很长,主要是处理不同的窗口类型,做了一些权限校验和合理性检查,以及特定窗口类型的一些特殊业务逻辑,如果只关心其中核心逻辑其实很简单。
从app侧构建ViewRootImpl,ViewRootImpl在app侧和app自身的窗口是一一对应的,并且ViewRootImpl里有一个W类,是一个binder server侧。
然后向WMS发出添加窗口请求,会将W传给WMS,后续WMS会通过这个binder来回调app侧,WMS这边主要做的事就是新建一个WindowState,然后把它添加到窗口树合适的节点下。还有一些Inset相关的逻辑,处理一些Input相关的逻辑等等。
下一节我们会介绍Inset框架,介绍一下Inset到底是啥,以及WMS是怎么管理Inset,怎么同步给app的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值