android UI绘制流程初探

本文讲的是从布局加载、activity启动、绘制流程的讲解需要对照源码来看,如果有什么错误也请大家见谅!

每当我们启动一个activity之后,我们之前在xul里面写的标签对布局就会按照我们想要的样式呈现在屏幕上,android是如何将xml会知道屏幕上的呢?对于ui的绘制,我们就会有三个疑问:

  1. android是如何将xml布局加载进activity绘制的window上面的?
  2. 布局是在什么时候开始绘制的?
  3. ui的绘制流程是怎样的?

针对上面的疑问我们一探究竟吧!

目录

[TOC]

  • 将xml布局加载到window里面的过程

    通常我们将写好的xml布局都会在activity的onCreate()方法里面调用setContentView(int layoutResID)来引入我们的布局,它的目的就是将xml布局放入到window的decorView下面一个叫content的FrameLayout控件下面作为它的子View。
    我们就进入setContentView()方法里面进行分析吧!

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

上面的getWindow()是获取当前activity所持有的window,它是一个抽象类主要是呈现ui及ui相关的控制处理的。它有一个唯一子类PhoneWindow,而我们这里得到的window就是PhoneWindow。

Window里面会有一个内部类DecorView(以前),在后面的版本把它抽出来单独成一个类了(26版本是这样的了)

code_2:
public class DecorView extends FrameLayout implements RootViewSurfaceTaker, WindowCallbacks {
    private static final String TAG = "DecorView";
    ……
}

PhoneWindow会持有这个DecorView,也就是我们所说的根View.

我们到PhoneWindow的setContentView(int layoutResID)里面来

code_3:
@Override
    public void setContentView(int layoutResID) {
        // Note: FEATURE_CONTENT_TRANSITIONS may be set in the process of installing the window
        // decor, when theme attributes and the like are crystalized. Do not check the feature
        // before this happens.
        if (mContentParent == null) {
            //这里是安装DecorView的,跟我们app为window配置的样式,来安装哪一种布局,通常根据我们app设置的样式来安装,也可以根据activity布局文件里面配置的样式来安装。
            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 {
            //将上面安装到decorview的布局里面的名叫content的控件(FrameLayout)添加子空间,也就是我们写的xml布局。
            mLayoutInflater.inflate(layoutResID, mContentParent);
        }
        mContentParent.requestApplyInsets();
        final Callback cb = getCallback();
        if (cb != null && !isDestroyed()) {
            cb.onContentChanged();
        }
        mContentParentExplicitlySet = true;
    }

根据上面的代码说明,我们来看看installDecor()具体做了那些事情,贴代码如下:

code_4:
private void installDecor() {
        mForceDecorInstall = false;
        if (mDecor == null) {
            //生成一个DecorView,根View.
            mDecor = generateDecor(-1);
            
        } else {
            mDecor.setWindow(this);
        }
        if (mContentParent == null) {
            //mContentParent就是我们所说的名叫content的那个控件
            //generateLayout(mDecor)里面给mDecor绑定了之前设定样式的对应系统布局,并把系统布局的里面名叫content控件返回
            mContentParent = generateLayout(mDecor);

            ……
            //获取actionbar
            final DecorContentParent decorContentParent = (DecorContentParent) mDecor.findViewById(
                    R.id.decor_content_parent);

            ……
        } else {
            mTitleView = findViewById(R.id.title);
                if (mTitleView != null) {
                    if ((getLocalFeatures() & (1 << FEATURE_NO_TITLE)) != 0) {
                        final View titleContainer = findViewById(R.id.title_container);
                        if (titleContainer != null) {
                            titleContainer.setVisibility(View.GONE);
                        } else {
                            mTitleView.setVisibility(View.GONE);
                        }
                        mContentParent.setForeground(null);
                    } else {
                        mTitleView.setText(mTitle);
                    }
                }
            }

        }
        ……
    }
}

上面代码中我们可以看到第87行,根据描述我们可以确定为什么当我们要隐藏actionbar时
requestWindowFeature(Window.FEATURE_NO_TITLE)必须要在放在setContentView()之前了吧!
generateLayout(mDecor)方法里面的代码据不去深究了。
给mDercor绑定的具体系统样式的布局,我挑选一个最简单的给大家展示一下,叫做R.layout.screen_simple。

code_5:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    android:orientation="vertical">
    
    <!--这是一个actionbar的懒加载布局 -->
    <ViewStub android:id="@+id/action_mode_bar_stub"
              android:inflatedId="@+id/action_mode_bar"
              android:layout="@layout/action_mode_bar"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:theme="?attr/actionBarTheme" />
              
    <!--这是一个我们自己写的布局要添加到的地方 -->
    <FrameLayout
         android:id="@android:id/content"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         android:foregroundInsidePadding="false"
         android:foregroundGravity="fill_horizontal|top"
         android:foreground="?android:attr/windowContentOverlay" />
</LinearLayout>

我们的布局就是添加到content上面的。
我们的decorView安装完成之后,我再回到setContentView(int layoutResID)里面来,也就是代码code_3里面的64行, mLayoutInflater.inflate(layoutResID, mContentParent)将我们的xml添加到content里面去。这个方法的也是我们经常会用到的,它大概是这样的:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iQn3LTi9-1576064334422)(http://note.youdao.com/noteshare?id=b46292d34c78592cd623d0964418dc62&sub=6D6E1121C3C54B37BC972651ED9EA331)]link

  • 在activity的启动流程中的什么时候开始绘制的

首先我们来看一看ActivityThread这个类,它是整个程序的主入口,我们可以看到有个main()方法。

code_6:
   public static void main(String[] args) {
        ……

        Looper.prepareMainLooper();

        ActivityThread thread = new ActivityThread();
        thread.attach(false);

        if (sMainThreadHandler == null) {
            sMainThreadHandler = thread.getHandler();
        }

        if (false) {
            Looper.myLooper().setMessageLogging(new
                    LogPrinter(Log.DEBUG, "ActivityThread"));
        }

        // End of event ActivityThreadMain.
        Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
        Looper.loop();

        throw new RuntimeException("Main thread loop unexpectedly exited");
    }

主函数开启了一个looper,让我们的主线程一直保持运行。我们再来看看sMainThreadHandler接受了那些消息和处理吧,sMainThreadHandler是一个继承Handler的H类,由于代码过长就不贴出来,它里面处理了很多逻辑,比如启动application、activity、service等等。这个H应该好好深入研究一下的。

我们现在就看一下H里面启动activity的部分吧。

public void handleMessage(Message msg) {
            if (DEBUG_MESSAGES) Slog.v(TAG, ">>> handling: " + codeToString(msg.what));
            switch (msg.what) {
                case LAUNCH_ACTIVITY: {
                    Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityStart");
                    final ActivityClientRecord r = (ActivityClientRecord) msg.obj;

                    r.packageInfo = getPackageInfoNoCheck(
                            r.activityInfo.applicationInfo, r.compatInfo);
                    //启动activity
                    handleLaunchActivity(r, null, "LAUNCH_ACTIVITY");
                    Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
                } break;
                ……
            }
        }

handleLaunchActivity()方法里面会执行的流程我画一个时序图,根据这个时序图我们可以看到绘制流程是在onResume()之后执行的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T7zZJiin-1576064334423)(http://note.youdao.com/noteshare?id=b47b8a3fe1bbe01530a9d9f30296964b)]link

在这之后,WindowManagerGlobal会将Decorview和ViewRootImpl进行关联,调用requestLayout()发起绘制,流程如下。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6s9z2CXq-1576064334424)(http://note.youdao.com/noteshare?id=a74922eb7d38758c66ca9c115abf3a5e)]link

这里有个注意点:requestLayout() 和 invalidate() 都是重新绘制,他们有什么区别呢?
调用控件的requestLayout(),它回去寻找父节点一直递归调用到ViewRootImpl的requestLayout()来对整个布局进行重回,ViewRootImpl和所有ViewGroup都是实现了ViewParent的。调用控件的invalidate()只是重绘控件自己和它的子控件。

根据上面的图就不贴代码出来了,重点就是知道绘制的时机。

  • ui的具体绘制流程

通过前面的了解,ViewRootImpl的performTraversals()就是正真绘制的地方了,它里面包括了测量、摆放和画控件,这个方法有点长,有700多行。我们能在里面找到performMeasure()、performLayout ()、 performDraw()方法。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4oRaaRXQ-1576064334424)(http://note.youdao.com/noteshare?id=c414e72fb1c5d0e144c07bd668c5d15a&sub=7A5ACD702EC841D194026C9340F66C04)]link

1. 测量

控件的测量最终目的就是测量它的宽高,测量规律就是先遍历测量子控件的宽高然后再来测量自己的宽高。

在测量之前我们需要了解一个测量规格MesasureSpec,它是一个处理32位二进制的int数值的一个工具类,它把32位二进制的int值分成了两部分Size和Mode,低30位就代表测量尺寸,高2位就到测量模式。

低30位的Size代表可供参考的测量值,Mode有3种模式,如下:

MeasureSpec.EXACTLY 代表Size是精确的值,比如精确的数字100dp,match_parent等。

MeasureSpec.AT_MOST 代表Size是最大可参考的值,比如warp_content.

MeasureSpec.UNSPECIFIED 代表Size是不确定的值,通常子控件是不会参考这个值的,根据自己实际情况来绘制

MeasureSpec的作用是View树在遍历测量的时候,父容器告诉子控件他的大小和模式,子容器根据根据实际的情况来对自身进行测量。

我们来看一看MeasureSpec的源码:

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 = 0 << MODE_SHIFT;

        public static final int EXACTLY     = 1 << MODE_SHIFT;

        public static final int AT_MOST     = 2 << MODE_SHIFT;

        /*
        *将尺寸和大小封装一个int值
        */
        public static int makeMeasureSpec(int size, int mode) {
            if (sUseBrokenMakeMeasureSpec) {
                return size + mode;
            } else {
                return (size & ~MODE_MASK) | (mode & MODE_MASK);
            }
        }

        //获取mode值
        public static int getMode(int measureSpec) {
            return (measureSpec & MODE_MASK);
        }

        //获取size值
        public static int getSize(int measureSpec) {
            return (measureSpec & ~MODE_MASK);
        }

        static int adjust(int measureSpec, int delta) {
            final int mode = getMode(measureSpec);
            if (mode == UNSPECIFIED) {
                // No need to adjust size for UNSPECIFIED mode.
                return makeMeasureSpec(0, UNSPECIFIED);
            }
            int size = getSize(measureSpec) + delta;
            if (size < 0) {
                Log.e(VIEW_LOG_TAG, "MeasureSpec.adjust: new size would be negative! (" + size +
                        ") spec: " + toString(measureSpec) + " delta: " + delta);
                size = 0;
            }
            return makeMeasureSpec(size, mode);
        }

    
    }

可以看出来,MeasureSpec提供了存取size和mode的方法。关于如何组装int值可以看下面的计算示例。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jxypfuiw-1576064334424)(http://note.youdao.com/noteshare?id=3dd9ecdd517408d2f597c2f1e0753199&sub=A6DA687588644BA3B4B8E2200E81E249)]link

View和ViewGroup在测量的时候是有区别的,view只需要测量本身,而ViewGroup是需要测量子控件和自己的。所以ViewGroup就提供能三个测量子控件的方法measureChildren(int widthMeasureSpec, int heightMeasureSpec)、
measureChild(View child, int parentWidthMeasureSpec,
int parentHeightMeasureSpec)、
measureChildWithMargins(View child,
int parentWidthMeasureSpec, int widthUsed,
int parentHeightMeasureSpec, int heightUsed),在我们自定义控件的时候可供选用。
我们来看一下这三个方法的代码。

    protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {
        final int size = mChildrenCount;
        final View[] children = mChildren;
        for (int i = 0; i < size; ++i) {
            final View child = children[i];
            if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
                measureChild(child, widthMeasureSpec, heightMeasureSpec);
            }
        }
    }
    
    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);
    }

measureChildren()方法是对子控件进行遍历测量;measureChild()是测量子控件,measureChildWithMargins()也是测量子控件,但是它将子控件的margin也作为子控件的宽高的一部分。

我们在上面代码中发现一个方法getChildMeasureSpec(int spec, int padding, int childDimension),它是去计算子控件MeasureSpec的值的。我们点进去看一下。

public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
        int specMode = MeasureSpec.getMode(spec);
        int specSize = MeasureSpec.getSize(spec);

        int size = Math.max(0, specSize - padding);

        int resultSize = 0;
        int resultMode = 0;

        switch (specMode) {
        // Parent has imposed an exact size on us
        case MeasureSpec.EXACTLY:
            if (childDimension >= 0) {
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size. So be it.
                resultSize = size;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size. It can't be
                // bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // Parent has imposed a maximum size on us
        case MeasureSpec.AT_MOST:
            if (childDimension >= 0) {
                // Child wants a specific size... so be it
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } 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;
                resultMode = MeasureSpec.AT_MOST;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size. It can't be
                // bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // Parent asked to see how big we want to be
        case MeasureSpec.UNSPECIFIED:
            if (childDimension >= 0) {
                // Child wants a specific size... let him have it
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size... find out how big it should
                // be
                resultSize = 0;
                resultMode = MeasureSpec.UNSPECIFIED;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size.... find out how
                // big it should be
                resultSize = 0;
                resultMode = MeasureSpec.UNSPECIFIED;
            }
            break;
        }
        return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
    }

我这里有个计算子控件的MeasureSpec的规则表,我们可以来好好理解一下。这个表就是根据上面代码遵循的规则。我们写自定义控件的时候也尽量遵循这样的规则。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2IKf5eXF-1576064334424)(http://note.youdao.com/noteshare?id=822340c1dfcafefc0ec44bc0adf2c553&sub=24786B581D9343DEBF2CEB016BD50D78)]link

父控件测量完子控件之后再来测量自己,然后将测量的只给mMeasuredWidth、mMeasuredHeight,完成这个操作的方法就是setMeasuredDimension(int measuredWidth, int measuredHeight),切记一定要在本控件测量完成之后调用这个方法,看了代码之后我们就知道了当测量完成之后我们调用getMeasuredHeight()、getMeasuredWidth()才会有值。根据测量规则我们可以看看FrameLayout和LinearLayout是如何测量的吧。

2. 摆放

控件的摆放最终目的就是设置它的左上右下的坐标值,摆放规律就是先设置自己的坐标值,然后再遍历设置子空间的坐标值。

摆放流程就是先执行自己的layout()方法设置自己的坐上右下位置,然后再去调用onLayout(),layout()方法是是被ViewGroup类final了的。

我们分析两个问题,我们自定义控件的时候通常会添加一个onSizeChanged(int w, int h, int oldw, int oldh)那这个方法是在什么时候执行的呢?
我们在layout的时候会去设置mLeft、mTop、mRight、mBottom,在设置之前先判断新的宽高值和以前的宽高值有没有变化,如果有变化就会在设置完值之后去调用onSizeChanged()方法。

protected boolean setFrame(int left, int top, int right, int bottom) {
        boolean changed = false;


        if (mLeft != left || mRight != right || mTop != top || mBottom != bottom) {
            changed = true;

            // Remember our drawn bit
            int drawn = mPrivateFlags & PFLAG_DRAWN;

            int oldWidth = mRight - mLeft;
            int oldHeight = mBottom - mTop;
            int newWidth = right - left;
            int newHeight = bottom - top;
            boolean sizeChanged = (newWidth != oldWidth) || (newHeight != oldHeight);

            invalidate(sizeChanged);

            mLeft = left;
            mTop = top;
            mRight = right;
            mBottom = bottom;
            mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom);

            mPrivateFlags |= PFLAG_HAS_BOUNDS;


            if (sizeChanged) {
                sizeChange(newWidth, newHeight, oldWidth, oldHeight);
            }

            ……
        }
        return changed;
    }

所以onSizeChanged()方法是在设置上下左右的坐标值之后,如果宽高有变化就调用,在onlayout()方法之前执行。

onlayout()通常是去摆放子控件的方法,根据自身控件需要去定义子控件如何摆放,一定要在里面调用子控件的layout()进行摆放。通常view是不需要重写这个方法的。

我们调用getWidth()、getHeight()的时候,就一定要在摆放之后才能得到正确的值,大家看看这个两个方法的源码就知道了。

大家可能会有疑问,真正的绘制流程是在onResume()之后开始绘制的。我如果想要获取view的宽高怎么办呢?总的找个合适的地方去获取吧!

我们可以这样来获取控件的宽高,看下面代码,他们都是在执行ViewRootImpl的performTraversals()方法的时候调用的,第一种是在执行draw()之后执行的;第二种是在draw()之前layout()之后执行;第三种方法查看源码没发现它跟view的绘制流程有什么关系,看过前面acitivity的启动流程的同学知道,activity的启动流程是通过主线程handler机制来发起的,而view.post(new Runnable())也是通过主线程的looper来分发处理的。也就是说当activity的启动流程完成之后,view.post()的Runnable才能从消息队列里面拿出来执行,这个时候view的绘制已经完成了。

    //第一种
    myListView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                myListView.getWidth();
                myListView.getHeight();
            }
        });
    //d第二种
    myListView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
            @Override
            public boolean onPreDraw() {
                myListView.getWidth();
                myListView.getHeight();
                return true;
            }
        });
        
    //第三种 
    myListView.post(new Runnable() {
            @Override
            public void run() {
                myListView.getWidth();
                myListView.getHeight();
            }
        });

摆放也就大概这个样子了,我们可以看看FrameLayout和LinearLayout的摆放源码。

3. 绘制

在绘制流程里面绘制应该是最简单的,虽然真正绘制的时候很复杂。在view的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
        int saveCount;

        if (!dirtyOpaque) {
            drawBackground(canvas);
        }

        // skip step 2 & 5 if possible (common case)
        final int viewFlags = mViewFlags;
        boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
        boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
        if (!verticalEdges && !horizontalEdges) {
            // Step 3, draw the content
            if (!dirtyOpaque) onDraw(canvas);

            // Step 4, draw the children
            dispatchDraw(canvas);

            // Step 6, draw decorations (scrollbars)
            onDrawScrollBars(canvas);

            if (mOverlay != null && !mOverlay.isEmpty()) {
                mOverlay.getOverlayView().dispatchDraw(canvas);
            }

            // we're done...
            return;
        }

        ……
    }

绘制分了4步:

  1. 画背景
  2. 画自己
  3. 画子控件
  4. 画装饰,比如scrollbar

所以画控件的话只需要画自己就行了,在onDraw()把自己要实现的效果画上去就行了。如何画控件就不是本文的内容了,如果有兴趣想知道怎么画可以点我

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值