Connor学Android - Window和WindowManager

在这里插入图片描述

Learn && Live

虚度年华浮萍于世,勤学善思至死不渝

前言

Hey,欢迎阅读Connor学Android系列,这个系列记录了我的Android原理知识学习、复盘过程,欢迎各位大佬阅读斧正!原创不易,转载请注明出处:http://t.csdn.cn/CjLtU,话不多说我们马上开始!

1.Window 和 WindowManager

Window

(1)整体上看,Window 是一个窗口的概念,是所有视图的载体,不管是 Activity、Dialog、Toast,它们的视图都是附属在 Window 上面的。例如在桌面显示一个悬浮窗,就需要用到 Window 来实现

(2)从源码来看,Window 是一个抽象类,它的唯一实现类是 PhoneWindow,每一个 Window 都对应着一个 View 和一个 ViewRootImpl,Window 和 View 通过 ViewRootImpl 来建立联系,因此实际上 Window 是以 View 的形式存在的

(3)Activity 中的 DecorVIew,Dialog 中的 View 都是在 PhoneWindow 中创建的,因此 Window 实际上是 View 的直接管理者,例如在 View 事件分发机制中,在 Activity 里面收到点击事件后,会首先通过 window 将事件传递到 DecorView,最后再分发到我们的 View 上。Activity 的 SetContentView 在底层也是通过 Window 来完成的。还有 findViewById 也是调用的 window

(4)Window 分三种类型

  • 应用 Window:对应一个 Activity
  • 子 Window:不能单独存在,需要附属在特定的父 Window 之中,如 Dialog
  • 系统 Window:需要声明权限在能创建的 Window,如 Toast

.(5)Window 是分层的,层级大的会覆盖在层级小的 Window 上面,根据类型不同,层级范围不同

  • 应用 Window:1-99
  • 子 Window:1000-1999
  • 系统 Window:2000-2999

WindowManager

(1)WindowManager 主要用于管理 Window,可以实现的操作包括:

  • 创建一个 Window 并向其添加 View
  • 更新 Window 中的 View
  • 删除一个 Window,即删除其内的 View

(2)以添加 Window 为例,通过调用 Manager 的 addView() 方法实现,这个方法包含两个参数

  • flags:表示 Window 的显示属性

    • FLAG_NOT_FOCUSABLE:表示 Window 不需要获取焦点,也不需要接收各种输入事件,此标记会同时启用 FLAG_NOT_TOUCH_MODEL,最终事件会直接传递给下层的具有焦点的 Window
    • FLAG_NOT_TOUCH_MODEL:表示当前 Window 区域以外的单击事件传递给底层的 Window,当前 Window 区域以内的单击事件则自己处理,一般需要开启此标记,否则其他 Window 将无法收到单击事件
    • FLAG_SHOW_WHEN_LOCKED:开启此模式可以让 Window 显示在锁屏的界面上
  • type:表示 Window 的类型,包含三种类型

    • 应用类 Window:对应一个 Activity
    • 子 Window:不能单独存在,需要附属在特定的父 Window 之中,如 Dialog
    • 系统 Window:需要声明权限在能创建的 Window,如 Toast
val textView = TextView(this).apply {
    text = "window"
    textSize = 18f
    setTextColor(Color.BLACK)
    setBackgroundColor(Color.WHITE)
}
val parent = WindowManager.LayoutParams(
    WindowManager.LayoutParams.WRAP_CONTENT,
    WindowManager.LayoutParams.WRAP_CONTENT, 0, 0, PixelFormat.TRANSPARENT
)
parent.type = WindowManager.LayoutParams.TYPE_APPLICATION
parent.flags =
    WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
parent.gravity = Gravity.END or Gravity.BOTTOM
parent.y = 500
parent.x = 100
windowManager.addView(textView, parent)

2.Window 的内部机制

2.1 Window 的添加过程

(1)Window 的添加主要完成两个工作:绘制 View、完成 Window 添加,这一过程通过 WindowManager 的 addView() 方法实现

(2)WindowManager 是一个接口,实现类是 WindowManagerImpl,因此实际调用的是 WindowManagerImpl 中的 addView() 方法

(3)WindowManagerImpl 中的 addView() 方法内由 WindowManagerGlobal 的 addView() 来真正实现 Window 的添加

@Override void addView(View view, ViewGroup.LayoutParams params) {
    mGlobal.addView(view, params, mDisplay, mParentWindow);
}

WindowManagerGlobal 的 addView 方法主要分为如下三步

一、检查传入的参数是否合法,如果是子 Window 则还需要调整一些布局参数

if (view == null) {
	throw new IllegalArgumentException("view must not be null");
}
if (display == null) {
    throw new IllegalArgumentException("display must not be null");
}
if (!(params instanceof WindowManager.LayoutParams)) {
    throw new IllegalArgumentException("Params must be WindowManager.LayoutParams");
}

final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
if (parentWindow != null) {
    parentWindow.adjustLayoutParamsForSubWindow(wparams);
} else {
    //.... 
}

二、创建 ViewRootImpl 并将 View 添加到列表中

(1)WindowManagerGlobale 内部有如下几个列表:

  • mViews:存储所有 Window 所对应的 View
  • mRoots:存储所有 Window 所对应的 ViewRootImpl
  • mParams:存储所有 Window 所对应的布局参数
  • mDyingViews:存储被标记了即将被删除的 View 对象(Window)

(2)addView() 方法内会根据检查后的参数创建 ViewRootImpl 并为 View 设置布局参数,最后将内容加入到上述的对应的列表中

ViewRootImpl root;
View panelParentView = null;

root = new ViewRootImpl(view.getContext(), display);
	
view.setLayoutParams(wparams);
	
mViews.add(view);
mRoots.add(root);
mParams.add(wparams);

三、通过 ViewRootImpl 来更新界面并完成 Window 的添加过程

(1)在 addView 方法中通过调用 ViewRootImpl 的 setView 方法来完成

(2)setView 方法内部会完成以下任务:

  • 首先会通过 requestLayout 来完成异步刷新请求,其内的 scheduleTraversals 会进入 View 绘制的流程,从而完成页面的更新
  • 接着调用 WindowSession 来完成 Window 的添加过程,WindowSession 是一个 Binder 对象,实现类是 Session
  • Session 内部会通过 WindowManagerService 来实现 WIndow 的添加,所以可见 Window 的添加过程是一次 IPC 调用
// WindowManagerGlobal.addView
try {
    root.setView(view, wparams, panelParentView);
} catch (RuntimeException e) {
    if (index >= 0) {
    	removeViewLocked(index, true);
    }
    throw e;
}


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

            requestLayout();

            try {
                mOrigWindowType = mWindowAttributes.type;
                mAttachInfo.mRecomputeGlobalAttributes = true;
                collectViewAttributes();
                res = mWindowSession.addToDisplay(mWindow, mSeq, mWindowAttributes,
                        getHostVisibility(), mDisplay.getDisplayId(), mTmpFrame,
                        mAttachInfo.mContentInsets, mAttachInfo.mStableInsets,
                        mAttachInfo.mOutsets, mAttachInfo.mDisplayCutout, mInputChannel,
                        mTempInsets);
                setFrame(mTmpFrame);
            }
            //.....
        }
    }
}

@Override
public void requestLayout() {
    if (!mHandlingLayoutInLayoutRequest) {
        checkThread();
        mLayoutRequested = true;
        scheduleTraversals();
    }
}

@Override
public int addToDisplay(IWindow window, int seq, WindowManager.LayoutParams attrs,int viewVisibility, int displayId, Rect outFrame, Rect outContentInsets,
 Rect outStableInsets, Rect outOutsets, DisplayCutout.ParcelableWrapper outDisplayCutout, InputChannel outInputChannel, InsetsState outInsetsState) {
    return mService.addWindow(this, window, seq, attrs, viewVisibility, displayId, outFrame,outContentInsets, outStableInsets, outOutsets, outDisplayCutout, outInputChannel,outInsetsState);
}
2.2 Window 的删除过程

(1)WindowManager 中提供了两种删除接口

  • remoteView:异步删除,仅发送一个请求删除的消息后就立刻返回,不会等待完成删除操作
  • remoteViewImmediate:同步删除,等待删除操作完成后再返回

(1)与添加过程类似,都是先通过 WindowManagerImpl 后,再进一步通过 WindowManagerGlobal 来实现的

@Override
public void removeView(View view) {
	mGlobal.removeView(view, false);
}

(2)WindowManagerGlobal 的 removeView 中会完成以下工作

  • 调用 findViewLocked 来查找待删除的 View 的索引
  • 根据找到的索引调用 removeViewLocked 完成进一步的删除
@UnsupportedAppUsage
public void removeView(View view, boolean immediate) {
    if (view == null) {
        throw new IllegalArgumentException("view must not be null");
    }

    synchronized (mLock) {
        int index = findViewLocked(view, true);
        View curView = mRoots.get(index).getView();
        removeViewLocked(index, immediate);
        if (curView == view) {
            return;
        }

        throw new IllegalStateException("Calling with view " + view
                + " but the ViewAncestor is attached to " + curView);
    }
}

(3)removeViewLokced 内部会调用 ViewRootImpl 的 die 方法完成异步删除操作,即只发送一个请求删除的消息后就会立刻返回了,这个时候 View 并没有完成删除操作,最后会将其作为待删除 View 添加到 mDyingViews 中

private void removeViewLocked(int index, boolean immediate) {
    ViewRootImpl root = mRoots.get(index);
    View view = root.getView();

    if (view != null) {
        InputMethodManager imm = view.getContext().getSystemService(InputMethodManager.class);
        if (imm != null) {
            imm.windowDismissed(mViews.get(index).getWindowToken());
        }
    }
    boolean deferred = root.die(immediate);
    if (view != null) {
        view.assignParent(null);
        if (deferred) {
            mDyingViews.add(view);
        }
    }
}

(4)die 方法内部会根据传入的参数判断是异步删除还是同步删除

  • 如果是异步删除,发送一个 MSG_DIE 消息,ViewRootImpl 中的 Handler 会处理此消息并调用 doDie 方法
  • 如果是同步删除,则不发消息直接调用 doDie 方法
boolean die(boolean immediate) {
    if (immediate && !mIsInTraversal) {
        doDie();
        return false;
    }

    if (!mIsDrawing) {
        destroyHardwareRenderer();
    } else {
        Log.e(mTag, "Attempting to destroy the window while drawing!\n" +
                "  window=" + this + ", title=" + mWindowAttributes.getTitle());
    }
    mHandler.sendEmptyMessage(MSG_DIE);
    return true;
}

(5)在 doDie 方法内会调用 dispatchDetachedFromWIndow 方法,其内部完成了真正删除 View 的逻辑,主要做四件事

  • 垃圾回收相关的工作,比如清除数据和消息、移除回调
  • 通过 Session 的 remove 方法删除 Window,与添加相同,是一个 IPC 过程,最终会调用 WMS 的 removeWindow 方法
  • 调用 View 的 dispatchDetachedFromWindow 方法,其内部会调用 View 的 onDetachedFromWindow() 以及 onDetachedFromWindowInternal,当 View 从 Window 中移除时,会完成一些如终止动画、停止线程等资源回收工作
  • 调用 WindowManagerGlobal 的 doRemoteView 方法刷新数据,包括 mRoots、mParams、mDyingViews,需要将当前 Window 所关联的这三类对象从列表中删除
2.3 Window 的更新过程

也是一样调用 WindowManagerGlobal 的 updateViewLayout 方法,主要做如下工作

(1)首先更新 View 的 LayoutParams

(2)接着调用 findViewLocked 方法获取更新 View 的索引

(3)根据 View 获取对应的 ViewRootImpl,更新其中的 LayoutParams 参数

(4)通过 setLayoutParams 更新参数从而进一步对 View 重新绘制

(5)最后通过 WMS 的 relayoutWindow 更新 Window 的视图

public void updateViewLayout(View view, ViewGroup.LayoutParams params) {
    if (view == null) {
        throw new IllegalArgumentException("view must not be null");
    }
    if (!(params instanceof WindowManager.LayoutParams)) {
        throw new IllegalArgumentException("Params must be WindowManager.LayoutParams");
    }
	
    final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams)params;
	//将更新的参数设置到 view 中
    view.setLayoutParams(wparams);

    synchronized (mLock) {
        //获取到 view 在列表中的索引
        int index = findViewLocked(view, true);
        //拿到 view 对应的 ViewRootImpl
        ViewRootImpl root = mRoots.get(index);
        //从参数列表中移除旧的参数
        mParams.remove(index);
        //将新的参数添加到指定的位置中
        mParams.add(index, wparams);
        //调用 ViewRootImpl.setLayoutPrams 对参数进行更新
        root.setLayoutParams(wparams, false);
    }
}

3.Window 的创建过程

3.1 Activity 的 Window 创建过程

前置知识

(1)首先明确 Activity、Window、DecorView 的关系:Activtiy 内包含一个 Window,Window 内部包含一个 DecorView,这个 DecorView 的 content 部分是 Activity 的视图,由 setContentView 方法设置

(2)Activity 的启动过程在最终会调用 AcitvityThread 中的 performLaunchActivity() 方法,这个方法内部会

  • 通过类加载器创建 Activity 的实例对象

  • 调用 attach 方法为其关联运行过程中所依赖的一系列上下文环境变量

  • 此外,attach 方法中就会创建 Activity 所属的 Window 对象并为其设置回调接口,Window 对象的创建是通过 PolicyManager 的 makeNewWindow 方法实现的

  • 由于 Activity 实现了 Window 的 Callback 接口,因此当 Window 接收到外界的状态变化时会调用 Activity 的方法,如 onAttachedToWindow、onDetachedFromWindow、dispatchTouchEvent 等

private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
        if (activity != null) {
            
            appContext.setOuterContext(activity);
            
            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);

            //....
        }
    return activity;
}

(3)Activity 的 Window 创建只要完成两件事:Window 创建、将 Activity 的视图附属到 Window 上

Window 创建

(1)Activity 的 attach 方法中就会创建 Activity 所属的 Window 对象并为其设置回调接口

(2)Window 对象的创建是通过 PolicyManager 的 makeNewWindow 方法实现的

(3)makeNewWindow 方法内会根据 context ,new 并返回一个 PhoneWindow 对象

(4)由于 Activity 实现了 Window 的 Callback 接口,因此当 Window 接收到外界的状态变化时会调用 Activity 的方法,如 onAttachedToWindow、onDetachedFromWindow、dispatchTouchEvent 等

视图附属

(1)由 Activity 的 setContentView 方法,而具体的实现交给 Window 来完成

public void setContentView(@LayoutRes int layoutResID) {
    getWindow().setContentView(layoutResID);
    initWindowDecorActionBar();
}

(2)setContentView 大致分为如下三步

一、如果没有 DecorView,则创建

(1)DecorView 的创建过程由 installDecor 方法完成,其内部会通过 generateDecor 方法直接 new 并返回一个 DecorView

protected DecorView generateDecor() {
    return new DecorView(getContext(), -1);
}

(2)创建完成后继续初始化 DecorView 的结构,这个过程由 PhoneWindow 的 generateLayout 完成

  • 通过 inflate 拿到 View
  • 调用 DecorView 的 addView 将 View 添加进去
  • 将当前 DecorView 设置为顶级 View
  • 根据 R.id.content 获取 contentParent,方便后续将 View 设置为 ContentView
View in = mLayoutInflater.inflate(layoutResource, null);
decor.addView(in, new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT));
mContentRoot = (ViewGroup) in;
ViewGroup contentParent = (ViewGroup) findViewById(ID_ANDROID_CONTENT);

二、将 View 添加到 DecorView 的 mContentParent

将 Activity 的视图添加到 DecorView 的 mContentParent 中

mLayoutInflater.inflate(layoutResID, mContentParent);

三、回调 Activity 的 onContentChanged 方法通知 Activity 视图发生改变

由于 Activity 实现了 Window 的 Callback 接口,当完成第二步时会回调 onContentChanged 方法通知 Activity 视图发生改变

final Callback cb = getCallback();
if(cb != null && !isDestroyed()) {
    cb.onContentChanged();
}
3.2 Dialog 的 Window 创建过程

与 Activity 的类似

一、创建 PhoneWindow

同样由 PhoneWindow 的 makeNewWindow 方法直接创建一个对象完成

二、初始化 DecorView 并将 Dialog 的视图添加到 DecorView 中

通过 Window 的 setContentView 实现

三、将 DecorView 添加到 Window 中并显示

在 Dialog 的 show 方法中,会通过 WindowManager 将 DecorView 添加到 Window 中

mWindowManager.addView(mDecor, 1);
mShowing = true;

当 Dialog 被关闭时,通过 WindowManager 来删除 DecorView,注意这里是同步删除

mWindowManager.removeViewImmediate(mDecor);
3.3 Toast 的 Window 创建过程

(1)Toast 是系统 Window,其内部视图有两种指定方法:默认样式、通过 setView 指定自定义 View,两种方法都对应一个 View 对象

(2)Toast 提供 show、cancel 方法来显示、隐藏 Toast,其内部是一个 IPC 过程

(3)Toast 的显示和隐藏需要通过 NotificationManagerService,NMS 来实现,NMS 会回调 Toast 里的 TN 接口

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

(5)Toast 通过 WindowManager 将 view 直接添加到 Window 中,没有创建 PhoneWindow 和 DecorView,和 Activity、Dialog 不同

public void show() {
    INotificationManager service = getService();
    String pkg = mContext.getOpPackageName();
    TN tn = mTN;
    tn.mNextView = mNextView;
    final int displayId = mContext.getDisplayId();
    
    try {
        if (Compatibility.isChangeEnabled(CHANGE_TEXT_TOASTS_IN_THE_SYSTEM)) {
            if (mNextView != null) {
                service.enqueueToast(pkg, mToken, tn, mDuration, displayId);
            } else {
                // ...
            }
        }
    }
    //....
}
public void cancel() {
    mTN.hide();
    try {
        getService().cancelToast(mContext.getOpPackageName(), mToken);
    } catch (RemoteException e) {
        // Empty
    }
    //....
}

static private INotificationManager getService() {
	if (sService != null) {
    	return sService;
    }
    sService = INotificationManager.Stub.asInterface(
    	ServiceManager.getService(Context.NOTIFICATION_SERVICE));
    return sService;
}

Toast 显示和隐藏

(1)show 方法内会调用 enqueueToast 方法,这个方法需要三个参数

  • 当前应用包名
  • TN 远程回调对象
  • Toast 的时长
INotificationManager service = getService();
String pkg = mContext.getOpPackageName();
TN tn = mTN;
tn.mNextView = mNextView;
final int displayId = mContext.getDisplayId();
    
try {
	if (Compatibility.isChangeEnabled(CHANGE_TEXT_TOASTS_IN_THE_SYSTEM)) {
    	if (mNextView != null) {
        	service.enqueueToast(pkg, mToken, tn, mDuration, displayId);
        } else {
            // ...
        }
    }
}

(2)enqueueToast 方法会完成如下工作

  • 首先将 Toast 请求封装成 ToastRecord,并加入 mToastQueue 队列中
    • 这个队列最多能存放 50 个 Record
    • 如果队列中有,则更新
  • 添加完成后,NMS 会通过 showNextToastLocked 方法来显示当前的 Toast
public void enqueueToast(String pkg, IBinder token, ITransientNotification callback,
        int duration, int displayId) {
    enqueueToast(pkg, token, null, callback, duration, displayId, null);
}

private void enqueueToast(String pkg, IBinder token, @Nullable CharSequence text, @Nullable ITransientNotification callback, int duration, int displayId,
@Nullable ITransientNotificationCallback textCallback) {
    synchronized (mToastQueue) {
        int callingPid = Binder.getCallingPid();
        final long callingId = Binder.clearCallingIdentity();
        try {
            ToastRecord record;
            int index = indexOfToastLocked(pkg, token);
			//如果队列中有,就更新它,而不是重新排在末尾
            if (index >= 0) {
                record = mToastQueue.get(index);
                record.update(duration);
            } else {
                int count = 0;
                final int N = mToastQueue.size();
                for (int i = 0; i < N; i++) {
                    final ToastRecord r = mToastQueue.get(i);
                    //对于同一个应用,taost 不能超过 50 个
                    if (r.pkg.equals(pkg)) {
                        count++;
                        if (count >= MAX_PACKAGE_TOASTS) {
                            Slog.e(TAG, "Package has already queued " + count
                                   + " toasts. Not showing more. Package=" + pkg);
                            return;
                        }
                    }
                }

                //创建对应的 ToastRecord
                record = getToastRecord(callingUid, callingPid, pkg, 
                	isSystemToast, token,text, callback, duration, windowToken, displayId, textCallback);
                mToastQueue.add(record);
                index = mToastQueue.size() - 1;
                keepProcessAliveForToastIfNeededLocked(callingPid);
            }
            // ==0 表示只有一个 toast了,直接显示,否则就是还有toast,真在进行显示
            if (index == 0) {
                showNextToastLocked(false);
            }
        } finally {
            Binder.restoreCallingIdentity(callingId);
        }
    }
}

private ToastRecord getToastRecord(int uid, int pid, String packageName, boolean isSystemToast,
	IBinder token, @Nullable CharSequence text, @Nullable ITransientNotification callback,
    int duration, Binder windowToken, int displayId,
    @Nullable ITransientNotificationCallback textCallback) {
	if (callback == null) {
    	return new TextToastRecord(this, mStatusBar, uid, pid, packageName,
        	isSystemToast, token, text, duration, windowToken, displayId, textCallback);
    } else {
        return new CustomToastRecord(this, uid, pid, packageName,
        	isSystemToast, token, callback, duration, windowToken, displayId);
    }
}

(3)showNextToastLocked 方法内,Toast 的显示是通过 ToastRecord 的 callback 完成的,这个 callback 实际上是 TN 的远程 Binder

(4)为了将执行环境切换到 Toast 请求所在线程,其内部使用 Handler,TN 会调用 handleShow 方法将 Toast 视图添加到 Window 中

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

(5)显示以后,NMS 还会通过 scheduleTimeoutLocked 方法来发送一个延时消息,时长为之前的参数:LONG(3.5S) / SHORT(2S)

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

(6)延迟相应的时间后,NMS 会通过 cancelToastLocked 方法隐藏 Toast 并移除出 mToastQueue

(7)这里也是通过 ToastRecord 的 callback 完成,也是调用 TN 中的 handleHide 方法将 Toast 的视图从 Window 中移除

if(mView.getParent() != null) {
    ...
    mWM.removeView(mView);
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ConnorYan

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值