Android Window学习记录(二)Window的创建

有关window和windowmaanger的理解可以参考这篇博客https://blog.csdn.net/qq_53749266/article/details/124332280?spm=1001.2014.3001.5501

一、什么是DecorView?

DecorView是在PhoneWindow中预设好的一个布局,是一个FrameLayout,这个布局长这样:
在这里插入图片描述

他是一个垂直排列的布局,上面是ActionBar,下面是ContentView,他是一个FrameLayoutActivity的布局就加载到ContentView里进行显示。所以DecorviewActivity布局最顶层的viewGroup内容栏是一定要存在的,并且具体固定的完整id是android.R.id.content
然后看一下怎么初始化DercorView的:

private void installDecor() {
    mForceDecorInstall = false;
    if (mDecor == null) {
        // 这里创建了DecorView
        mDecor = generateDecor(-1);
        ...
    } else {
        mDecor.setWindow(this);
    }
    if (mContentParent == null) {
        // 对DecorView进行初始化,得到ContentView
        mContentParent = generateLayout(mDecor);
        ...
    }
}
protected ViewGroup generateLayout(DecorView decor) {
		...
    mDecor.startChanging();
    mDecor.onResourcesLoaded(mLayoutInflater, layoutResource);
		
    ViewGroup contentParent = (ViewGroup)findViewById(ID_ANDROID_CONTENT);
		...
}

installDecor方法主要是新建一个DecorView对象,然后加载预设好的布局(Activity布局)对DecorView进行初始化,并获取到这个预设布局的ContentView

二、Window的创建

WindowManagerImpl是管理PhoneWindow的,有两种创建window的方式:如果已经存在PhoneWindow,直接通过WindowManagerImpl创建window。如果PhoneWindow尚未存在,先创建PhoneWindow,再利用windowManagerImpl来创建window

我们在Activity中使用getWindowManager方法获取到的就是应用PhoneWindow对应的WindowManagerImpl
无论是哪种window,它的添加过程在WMS 处理部分中基本是类似的,只不过会在权限和窗口显示次序等方面会有些不同,但是在 WindowManager处理部分会有所不同。

2.1 Activity的Window创建过程

要分析Activity中的Window的创建过程就必须了解Activity的启动过程,Activity的启动过程最后来到了ActivityThreadhandleLaunchActivity

public void handleLaunchActivity(IBinder token, boolean finalStateRequest, boolean isForward,
        String reason) {
    ...;
    // 这里对WindowManagerGlobal进行初始化
    WindowManagerGlobal.initialize();

   	// 启动Activity并回调activity的onCreate方法
    final Activity a = performLaunchActivity(r, customIntent);
    ...
}

private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
			...
			java.lang.ClassLoader cl = r.packageInfo.getClassLoader();
		  activity = mInstrumentation.newActivity(
      cl, component.getClassName(), r.intent);
			...
    try {
				
        // 这里创建Application
        Application app = r.packageInfo.makeApplication(false, mInstrumentation);
		...
        if (activity != null) {
            ...
            Window window = null;
            if (r.mPendingRemoveWindow != null && r.mPreserveWindow) {
                window = r.mPendingRemoveWindow;
                r.mPendingRemoveWindow = null;
                r.mPendingRemoveWindowManager = null;
            }
            appContext.setOuterContext(activity);
            // 这里将window作为参数传到activity的attach方法中
            // 一般情况下这里window==null
						//**关联**运行过程中所依赖的一系列上下文环境变量
            activity.attach(appContext, this, getInstrumentation(), r.token,
                    r.ident, app, r.intent, r.activityInfo, title, r.parent,
                    r.embeddedID, r.lastNonConfigurationInstances, config,
                    r.referrer, r.voiceInteractor, window, r.configCallback,
                    r.assistToken);  
            ...
            // 最后这里回调Activity的onCreate方法
            if (r.isPersistable()) {
                mInstrumentation.callActivityOnCreate(activity, r.state, r.persistentState);
            } else {
                mInstrumentation.callActivityOnCreate(activity, r.state);
            }
        }
    
    ...
}

handleLaunchActivity的代码中首先对WindowManagerGlobal进行初始化,然后调用了performLaunchActivity方法。
performLaunchActivity内部通过类加载器创建Activity的实例对象,创建Application对象,然后再调用Activity的attach方法关联运行过程中所依赖的一系列上下文环境变量,把window作为参数传进去,最后回调activityonCreate方法。所以window会是在Activityattach方法中创建:

final void attach(...,Context context,Window window, ...) {
    ...;
 	// 这里新建PhoneWindow对象,并对window进行初始化
//Activity的Window是通过**PolicyManager的一个工厂方法**来创建
	mWindow = new PhoneWindow(this, window, activityConfigCallback);
    // Activity实现window的callBack接口,把自己设置给window
    mWindow.setWindowControllerCallback(this);
    mWindow.setCallback(this);
    mWindow.setOnWindowDismissedCallback(this);
    mWindow.getLayoutInflater().setPrivateFactory(this);    
    ...
    // 这里初始化window的WindowManager对象
	mWindow.setWindowManager(
            (WindowManager)context.getSystemService(Context.WINDOW_SERVICE),
            mToken, mComponent.flattenToString(),
            (info.flags & ActivityInfo.FLAG_HARDWARE_ACCELERATED) != 0);        
}

首先利用传进来的window创建PhoneWindowActivity实现了windowcallBack接口,可以把Activity自己设置为window的观察者。然后再创建WindowManagerPhoneWindow绑定在一起,绑定后我们就可以通过windowManager操作PhoneWindow了。(这里不是setWindowManager吗,windowManager是什么时候创建的?)我们看一下setWindowManager方法:

public void setWindowManager(WindowManager wm, IBinder appToken, String appName,
        boolean hardwareAccelerated) {
    mAppToken = appToken;
    mAppName = appName;
    mHardwareAccelerated = hardwareAccelerated;
    if (wm == null) {
			//获取到应用服务的WindowManager
        wm = (WindowManager)mContext.getSystemService(Context.WINDOW_SERVICE);
    }
    // 这里创建了windowManager
    mWindowManager = ((WindowManagerImpl)wm).createLocalWindowManager(this);
}

首先会获取到应用服务WindowManager(实现类也是WindowManagerImpl),然后通过这应用服务WindowManager创建了新的windowManager。所以一个应用所有的WindowManagerImpl都是同个内核windowManager

这样PhoneWindowWindowManagerImpl就绑定在一起了。Activity就可以通过WindowManagerImpl来操作PhoneWindow

创建完成ActivityPhoneWindowWindowManagerImpl后,接下来看看是怎么Activity的布局文件设置给PhoneWindow。上面提到调用Activityattach方法之后,会回调ActivityonCreate方法,在其中会调用setContentView来设置布局,如下:

    public void setContentView(View view, ViewGroup.LayoutParams params) {
        getWindow().setContentView(layoutResID);
        initWindowDecorActionBar();
    }

Activity将setContentView具体实现交给了Window处理,这里的getWindow返回我们上面创建的PhoneWindow对象。我们继续看下去:

// 注意他有多个重载的方法,要选择参数对应的方法
public void setContentView(int layoutResID) {
    // 创建DecorView
    if (mContentParent == null) {
        installDecor();
    } else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
        mContentParent.removeAllViews();
    }

    if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
        final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
                getContext());
        transitionTo(newScene);
    } else {
        // **这里根据布局id加载布局,把Activity的布局加载到DecorView的**mContentParent**中**
        mLayoutInflater.inflate(layoutResID, mContentParent);
	
    }
    mContentParent.requestApplyInsets();
    final Callback cb = getCallback();
    if (cb != null && !isDestroyed()) {
        // 回调activity的方法,**通知Activity视图已经发生改变**
        cb.onContentChanged();
    }
    mContentParentExplicitlySet = true;
}

只看setContentView的重点代码:

  • 首先看decorView创建了没有,没有的话创建DecorView
  • 把布局加载到DecorView
  • 回调Activity的callBack方法

可以看到Activitiy的布局添加到的是DecorViewContentView,这也是onCreate中使用的是setContentView而不是setView的原因。

最后会回调Activity的方法告诉ActivityDecorView已经创建并初始化完成了。由于Activity实现了Window的Callback接口,Activity的onContentChanged方法是个空实现,我们可以在子Activity中处理这个回调。

Activity的布局文件已经添加到DecorView里面了,DecorView已经创建完成了,但还缺少了最重要的一步:把DecorView作为window添加到屏幕上。

我们已经知道添加window需要用到WindowManagerImpladdView方法。虽然说早在Activity的attach方法中Window就已经被创建了,但是这个时候由于DecorView并没有被WindowManager识别,所以这个时候的Window无法提供具体功能,因为它还无法接收外界的输入信息

这一步是在ActivityThreadhandleResumeActivity方法中被执行:

public void handleResumeActivity(IBinder token, boolean finalStateRequest, boolean isForward,
        String reason) {
    // 调用Activity的onResume方法
    final ActivityClientRecord r = performResumeActivity(token, finalStateRequest, reason);
    ...
    // 让decorView显示到屏幕上
	if (r.activity.mVisibleFromClient) {
        r.activity.makeVisible();
}

这一步方法有两个重点:回调onResume方法,decorView添加到屏幕上。我们看一下makeVisible方法做了什么:

void makeVisible() {
    if (!mWindowAdded) {
        ViewManager wm = getWindowManager();
        wm.addView(mDecor, getWindow().getAttributes());
        mWindowAdded = true;
    }
    mDecor.setVisibility(View.VISIBLE);
}

是不是非常熟悉?直接调用WindowManagerImpladdView方法来吧decorView添加到屏幕上,ecorView真正地完成了添加显示这两个过程,至此,我们的Activity界面就会显示在屏幕上了。
这部分很长,最后来总结一下:

  • Activity的启动流程可以得到Activity创建Window的过程
  • 创建PhoneWindow -> 创建WindowManager -> 创建DecorView -> 利用WindowManagerDecorView显示到屏幕上
  • 回调onResume方法的时候,DecorView还没有被添加到屏幕,所以当onResume被回调,指的是屏幕即将显示,而不是已经显示

2.2 Dialog的Window创建过程

Dialog dialog = new Dialog(context);//context要activity
TextView textView = new TextView(this);
textView.setText("this is toast! ");
dialog.setContentView(textView);
dialog.show();

DialogWindow的创建过程和Activity类似,有如下几个步骤。创建PhoneWindow,初始化DecorView,添加DecorView

  • 1.**创建Window** Dialog中创建的window就是PhoneWindow,这个过程和ActivityWindow的创建过程是一致

    Dialog(@NonNull Context context, @StyleRes int themeResId, boolean createContextThemeWrapper) {
        ...
        // 获取windowManager
        mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
    //mWindowManager其实是Activity的WindowManager,这里的context一般是activity
    	// 构造PhoneWindow
        final Window w = new PhoneWindow(mContext);
        mWindow = w;
        // 初始化PhoneWindow
        w.setCallback(this);
        w.setOnWindowDismissedCallback(this);
        w.setOnWindowSwipeDismissedCallback(() -> {
            if (mCancelable) {
                cancel();
            }
        });
        w.setWindowManager(mWindowManager, null, null);
        w.setGravity(Gravity.CENTER);
        mListenersHandler = new ListenersHandler(this);
    }
    
    public Object getSystemService(@ServiceName @NonNull String name) {
        if (getBaseContext() == null) {
            throw new IllegalStateException(
                    "System services not available to Activities before onCreate()");
        }
    	// 获取的是activity的windowManager
        if (WINDOW_SERVICE.equals(name)) {
            return mWindowManager;
        } else if (SEARCH_SERVICE.equals(name)) {
            ensureSearchManager();
            return mSearchManager;
        }
        return super.getSystemService(name);
    }
    

    普通的Dialog有一个特殊之处,那就是必须采用ActivityContext,如果采用ApplicationContext会报错,是没有应用token所导致的。应用token一般只有Activity拥有,所以这里只需要用Activity作为Context来显示dialog

  • 2.初始化DecorView并将Dialog的视图添加到DecorView中,这个过程也和Activity的类似,都是通过Window去添加指定的布局文件

      public void setContentView(int layoutResID) {
          mWindow.setContentView(layoutResID);
      }
    
    
  • 3.将DecorView添加到Window中并显示在Dialogshow方法中,会通过WindowManagerDecorView添加到Window中,如下所示。

    public void show() {
       ...
        // 回调onStart方法,获取前面初始化好的decorview
        onStart();
        mDecor = mWindow.getDecorView();
        ...
        WindowManager.LayoutParams l = mWindow.getAttributes();
        ...
        // 利用windowManager来添加window    
        mWindowManager.addView(mDecor, l);
    //这里的mWindowManager是Activity的WindowManager
        ...
        mShowing = true;
        sendShowMessage();
    }
    

系统Window比较特殊,它可以不需要token,如果context改为this.getApplicationContext(),只需要指定对话框的Window系统类型就可以正常弹出对话框。
之前讲到,WindowManager.LayoutParams中的type表示Window的类型,而系统Window的层级范围是2000~2999,这些层级范围就对应着type参数,系统Window的层级有很多值,对于本例来说,可以选用TYPE_SYSTEM_OVERLAY来指定对话框的Window类型为系统Window,如下所示。dialog.getWindow().setType(LayoutParams.TYPE_SYSTEM_ERROR)

然后别忘了在AndroidManifest文件中声明权限从而可以使用系统Window,如下所示。

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

2.3 Toast的Window创建过程

ToastDialog不同。Toast也是基于Window来实现的,但是由于Toast具有定时取消这一功能,所以采用了Handler
在Toast的内部有两类IPC过程,第一类是Toast访问NotificationManagerService,第二类是Notification-ManagerService回调Toast里的TN接口。

**Toast属于系统Window,它内部的视图由两种方式指定,一种是系统默认的样式**,另一种是通过setView方法指定一个自定义View,视图都对应Toast的一个View类型的内部成员mNextView。Toast提供了showcancel分别用于显示和隐藏Toastshowcancel内部是IPC过程

    public void show() {
        if (mNextView == null) {
            throw new RuntimeException("setView must have been called");
        }

        INotificationManager service = getService();
        String pkg = mContext.getOpPackageName();
        TN tn = mTN;
        tn.mNextView = mNextView;

        try {
            service.enqueueToast(pkg, tn, mDuration);
        } catch (RemoteException e) {
            // Empty
        }
    }

    public void cancel() {
        mTN.hide();

        try {
            getService().cancelToast(mContext.getPackageName(), mTN);
        } catch (RemoteException e) {
            // Empty
        }
    }

可以看到,显示和隐藏Toast都需要通过NMS来实现,NMS运行在系统的进程中,所以只能通过远程调用的方式来显示和隐藏Toast

**TN类是一个Binder类**,在ToastNMS进行IPC的过程中,当NMS处理Toast的显示或隐藏请求时会跨进程回调TN中的方法,由于**TN运行在Binder线程池中,所以需要通过**Handler将其切换到发送Toast请求所在的线程中。

注意,由于这里使用了Handler,所以这意味着Toast无法在没有Looper的线程中弹出,这是因为**Handler需要使用Looper才能完成切换线程的功能**。
首先看Toast的显示过程,它调用了NMS中的enqueueToast方法,如下所示。

    INotificationManager service = getService();
    String pkg = mContext.getOpPackageName();
    TN tn = mTN;
    tn.mNextView = mNextView;
    try {
        service.enqueueToast(pkg, tn, mDuration);
    } catch (RemoteException e) {
        // Empty
    }

NMSenqueueToast方法的第一个参数表示当前应用的包名,第二个参数tn表示远程回调,第三个参数表示Toast的时长。

  1. enqueueToastToast请求封装为ToastRecord,并将其添加到一个名为mToastQueue的队列。mToastQueue其实是一个ArrayList,对于非系统应用来说,mToastQueue中最多能同时存在50ToastRecord,以防止DOS(Denial of Service)。如果不这么做,在有通过大量的循环去连续弹出Toast的情况,会导致其他应用没有机会弹出Toast。
// Limit the number of toasts that any given package except the android
// package can enqueue.  Prevents DOS attacks and deals with leaks.
if (! isSystemToast) {
    int count = 0;
    final int N = mToastQueue.size();
    for (int i=0; i<N; i++) {
        final ToastRecord r = mToastQueue.get(i);
        if (r.pkg.equals(pkg)) {
                  count++;
                  if (count >= MAX_PACKAGE_NOTIFICATIONS) {
                      Slog.e(TAG, "Package has already posted " + count
                              + " toasts. Not showing more. Package=" + pkg);
                      return;
                  }
              }
          }
      }
}

正常情况下,当ToastRecord被添加到mToastQueue中后,NMS就会通过showNextToastLocked方法来显示当前的Toast
下面的代码就是showNextToastLocked方法,需要注意的是,Toast的显示是由ToastRecordcallback来完成的,这个callback实际上就是Toast中的**TN对象的远程Binder,通过callback来访问TN中的方法是需要跨进程来完成的,最终被调用的TN中的方法会运行在发起Toast请求的应用的Binder线程池**中。

    void showNextToastLocked() {
        ToastRecord record = mToastQueue.get(0);
        while (record ! = null) {
            if (DBG) Slog.d(TAG, "Show pkg=" + record.pkg + " callback=" + record.
            callback);
            try {
                record.callback.show();
                scheduleTimeoutLocked(record);
                return;
            } catch (RemoteException e) {
                Slog.w(TAG, "Object died trying to show notification " + record.
                callback
                        + " in package " + record.pkg);
                // remove it from the list and let the process die
                int index = mToastQueue.indexOf(record);
                if (index >= 0) {
                    mToastQueue.remove(index);
                }
                keepProcessAliveLocked(record.pid);
                if (mToastQueue.size() > 0) {
                    record = mToastQueue.get(0);
                } else {
                    record = null;
                      }
                  }
              }
          }

Toast显示以后,NMS还会通过scheduleTimeoutLocked方法来发送一个延时消息,具体的延时取决于Toast的时长,如下所示:

    private void scheduleTimeoutLocked(ToastRecord r)
    {
        mHandler.removeCallbacksAndMessages(r);
        Message m = Message.obtain(mHandler, MESSAGE_TIMEOUT, r);
        long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY;
        mHandler.sendMessageDelayed(m, delay);
    }

在上面的代码中,LONG_DELAY是3.5s,而SHORT_DELAY是2s。延迟相应的时间后,NMS会通过cancelToastLocked方法来隐藏Toast并将其从mToastQueue中移除,这个时候如果mToastQueue中还有其他Toast,那么NMS就继续显示其他Toast
Toast隐藏也是通过ToastRecordcallback来完成的,这同样也是一次IPC过程,它的工作方式和Toast的显示过程是类似的,如下所示。

    try {
        record.callback.hide();
    } catch (RemoteException e) {
        Slog.w(TAG, "Object died trying to hide notification " + record.callback
                + " in package " + record.pkg);
        // don't worry about this, we're about to remove it from
        // the list anyway
    }

通过上面的分析,大家知道Toast的显示和隐藏过程实际上是通过Toast中的TN这个类来实现的,对应两个方法showhide。这两个方法都是被NMS跨进程的方式调用的,因此这两个方法运行在Binder线程池中。为了将执行环境切换到Toast请求所在的线程,在它们的内部使用了Handler,如下所示。

    /**
     * schedule handleShow into the right thread
     */
    @Override
    public void show() {
        if (localLOGV) Log.v(TAG, "SHOW: " + this);
        mHandler.post(mShow);
    }

    /**
     * schedule handleHide into the right thread
     */
    @Override
    public void hide() {
        if (localLOGV) Log.v(TAG, "HIDE: " + this);
        mHandler.post(mHide);
    }

上述代码中,mShowmHide是两个Runnable,它们内部分别调用了handleShowhandleHide方法。由此可见,handleShowhandleHide才是真正完成显示和隐藏Toast的地方。TNhandleShow中会Toast的视图添加到Window,如下所示。

    mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
    mWM.addView(mView, mParams)

而NT的handleHide中会将Toast的视图从Window中移除,如下所示。

public void handleHide() { 
  if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView); 
  if (mView != null) { 
    // note: checking parent() just to make sure the view has 
    // been added... i have seen cases where we get here when 
    // the view isn't yet added, so let's try not to crash. 
    **if (mView.getParent() != null) { 
      if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this); 
      mWM.removeView(mView); 
    }** 
    mView = null; 
  } 
}

到这里ToastWindow的创建过程已经分析完了,到这里对Toast的工作过程就有了一个更加全面的理解了。除了上面已经提到的Activity、DialogToast以外,PopupWindow菜单以及状态栏等都是通过Window来实现的,这里就不一一介绍了。本章的意义在于让读者对Window有一个更加清晰的认识,同时能够深刻理解Window和View的依赖关系,这有助于理解其他更深层次的概念,比如SurfaceFlinger。任何View都是附属在一个Window上面的,那么这里问一个问题:一个应用中到底有多少个Window呢?

2.4 PopupWinodw的window创建过程

popupWindow也是利用windowManager来往屏幕上添加windowpopupWindow依附于activity而存在的,当Activity未运行时,是**无法弹出popupWindow**的。

弹出popupWindow的过程分为两步:创建view;通过windowManager添加window
首先看到PopupWindow的构造方法:

public PopupWindow(View contentView, int width, int height, boolean focusable) {
    if (contentView != null) {
        mContext = contentView.getContext();
        mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
    }

    setContentView(contentView);
    setWidth(width);
    setHeight(height);
    setFocusable(focusable);
}

他有多个重载方法,但最终都会调用到这个有四个参数的方法。主要是前面的得到context和根据context获得WindowManager

然后我们看到他的显示方法。显示方法有两个:showAtLocationshowAsDropDown。主要是处理显示的位置不同,其他都是相似的。

public void showAtLocation(View parent, int gravity, int x, int y) {
    mParentRootView = new WeakReference<>(parent.getRootView());
    showAtLocation(parent.getWindowToken(), gravity, x, y);
}

showAtLocation逻辑很简单,父view根布局存储了起来,然后调用另外的重载方法:

public void showAtLocation(IBinder token, int gravity, int x, int y) {
    // 如果contentView是空直接返回
    if (isShowing() || mContentView == null) {
        return;
    }

    TransitionManager.endTransitions(mDecorView);
    detachFromAnchor();
    mIsShowing = true;
    mIsDropdown = false;
    mGravity = gravity;
	// 得到WindowManager.LayoutParams对象
    final WindowManager.LayoutParams p = createPopupLayoutParams(token);
    // 做一些准备工作
    preparePopup(p);

    p.x = x;
    p.y = y;
	// 执行popupWindow显示工作
    invokePopup(p);
}

这个方法的逻辑主要有:

  • 判断contentView是否为空或者是否进行显示
  • 做一些准备工作
  • 进行popupWindow显示工作

这里我们看一下他的准备工作和显示工作做了什么:

private void preparePopup(WindowManager.LayoutParams p) {
    ...
        
    if (mBackground != null) {
        mBackgroundView = createBackgroundView(mContentView);
        mBackgroundView.setBackground(mBackground);
    } else {
        mBackgroundView = mContentView;
    }
	// 创建了DecorView
    // 注意,这里的DecorView并不是我们之前讲的DecorView,而是他的内部类:PopupDecorView
    mDecorView = createDecorView(mBackgroundView);
    mDecorView.setIsRootNamespace(true);

    ...
}
private void invokePopup(WindowManager.LayoutParams p) {
    ...
   	// 调用windowManager添加window
    mWindowManager.addView(decorView, p);

    ...
}

到这里popupWindow就会被添加到屏幕上了。


最后总结一下:

根据参数构建popupDecorViewpopupDecorView添加到屏幕上

参考资料

Android开发艺术探索

https://blog.csdn.net/weixin_43766753/article/details/108350589

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值