关于PtrFrameLayout自定义header的一些探索

先上效果图吧,项目需求是这样一个效果,类似uc浏览器的下拉刷新,当然,我这个要差得多。。。

这里写图片描述

大概的效果就是:

1.下拉的时候画一个带点的圆弧,根据下拉距离渐进闭合;
2.当拉倒可以刷新的距离后,放手刷新,再画一个带点的圆弧旋转;
3.刷新完成后,画一个渐进闭合的圆;
4.最后,画一个钩,然后关闭header。

在实现的过程中有两个地方比较难办,一个是打钩的过程,另一个是header驻留一下才收起。

对于打钩的过程,我试过贝塞尔曲线,但是三阶的我没尝试过,二阶贝塞尔画出来的钩是带弧度的,就不是直角,我也尝试过path,可能是由于打钩的拐点没计算好,导致钩不是很平滑,后来才选择了用直线拐点的方式,这是借用的这个项目 android打钩画叉,非常感谢。有需要的童鞋请自行传送过去看看。

对于header驻留在关闭的问题,令我一度陷入僵局,因为我翻看了PtrFrameLayout的源码,发现即使自定义PtrHandler,PtrHandler收到onUIRefreshComplete的时候是在PtrFrameLayout已经调onRefreshComplete用后再notify的,所以要实现header关闭的动作在我打完钩之后在执行,就找不到入口了。看下PtrFrameLayout refreshComplete的代码

   /**
     * Call this when data is loaded.
     * The UI will perform complete at once or after a delay, depends on the time    
     * elapsed is greater then {@link #mLoadingMinTime} or not.                        
     */
    final public void refreshComplete() {
        if (DEBUG) {
            PtrCLog.i(LOG_TAG, "refreshComplete");
        }

        if (mRefreshCompleteHook != null) {
            mRefreshCompleteHook.reset();
        }

        int delay = (int) (mLoadingMinTime - (System.currentTimeMillis() - mLoadingStartTime));
        if (delay <= 0) {
            if (DEBUG) {
                PtrCLog.d(LOG_TAG, "performRefreshComplete at once");
            }
            performRefreshComplete();
        } else {
            postDelayed(mPerformRefreshCompleteDelay, delay);
            if (DEBUG) {
                PtrCLog.d(LOG_TAG, "performRefreshComplete after delay: %s", delay);
            }
        }
    }

当我们数据加载完成后,大家都应该会调用这个方法吧,注释的很清楚,这里说到可能会延迟关闭,那取决于我们设置的 ptrRefresh.setLoadingMinTime(long delayTime),但是,我们怎么能保证数据加载的时间不会大于这个时间呢?所以想通过设置这个时间来延迟关闭header在打钩之后是不行的。

我们接着看performRefreshComplete()方法

   /**
     * Do refresh complete work when time elapsed is greater than{@link#mLoadingMinTime} 
     */
    private void performRefreshComplete() {
        mStatus = PTR_STATUS_COMPLETE;

        // if is auto refresh do nothing, wait scroller stop
        if (mScrollChecker.mIsRunning && isAutoRefresh()) {
            // do nothing
            if (DEBUG) {
                PtrCLog.d(LOG_TAG, "performRefreshComplete do nothing, scrolling: %s, auto refresh: %s",
                        mScrollChecker.mIsRunning, mFlag);
            }
            return;
        }

        notifyUIRefreshComplete(false);
    }

这个时候, mStatus = PTR_STATUS_COMPLETE,PtrFrameLayout已经设为刷新完成的状态了,开始通知PtrHandler onUIRefreshComplete了,这也是我解决问题的转折点,我们看notifyUIRefreshComplete(boolean ignoreHook)方法

   /**
     * Do real refresh work. If there is a hook, execute the hook first.
     * @param ignoreHook
     */
    private void notifyUIRefreshComplete(boolean ignoreHook) {
        /**
         * After hook operation is done, {@link #notifyUIRefreshComplete} will be call 
         * in resume action to ignore hook.
         */
        if (mPtrIndicator.hasLeftStartPosition() && !ignoreHook && mRefreshCompleteHook != null) {
            if (DEBUG) {
                PtrCLog.d(LOG_TAG, "notifyUIRefreshComplete mRefreshCompleteHook run.");
            }

            mRefreshCompleteHook.takeOver();
            return;
        }
        if (mPtrUIHandlerHolder.hasHandler()) {
            if (DEBUG) {
                PtrCLog.i(LOG_TAG, "PtrUIHandler: onUIRefreshComplete");
            }
            mPtrUIHandlerHolder.onUIRefreshComplete(this);
        }
        mPtrIndicator.onUIRefreshComplete();
        tryScrollBackToTopAfterComplete();
        tryToNotifyReset();
    }

看到mPtrUIHandlerHolder.onUIRefreshComplete(this),这个时候我们的PtrHandler就已经收到刷新完成的通知了,正常流程的话,header就开始关闭了。但是大家请看这句注释,After hook operation is done, {@link #notifyUIRefreshComplete} will be call in resume action to ignore hook,这里有一个关键的东西mRefreshCompleteHook,就是PtrUIHandlerHook,看这个名字你就知道他大概是干吗用的了,他就是截断header关闭的关键。他继承自Runnable 接口,我们看看他的代码

/**
 * Run a hook runnable, the runnable will run only once.
 * After the runnable is done, call resume to resume.
 * Once run, call takeover will directory call the resume action
 */
public abstract class PtrUIHandlerHook implements Runnable {

    private Runnable mResumeAction;
    private static final byte STATUS_PREPARE = 0;
    private static final byte STATUS_IN_HOOK = 1;
    private static final byte STATUS_RESUMED = 2;
    private byte mStatus = STATUS_PREPARE;

    public void takeOver() {
        takeOver(null);
    }

    public void takeOver(Runnable resumeAction) {
        if (resumeAction != null) {
            mResumeAction = resumeAction;
        }
        switch (mStatus) {
            case STATUS_PREPARE:
                mStatus = STATUS_IN_HOOK;
                run();
                break;
            case STATUS_IN_HOOK:
                break;
            case STATUS_RESUMED:
                resume();
                break;
        }
    }

    public void reset() {
        mStatus = STATUS_PREPARE;
    }

    public void resume() {
        if (mResumeAction != null) {
            mResumeAction.run();
        }
        mStatus = STATUS_RESUMED;
    }

    /**
     * Hook should always have a resume action, which is hooked by this hook.
     *
     * @param runnable
     */
    public void setResumeAction(Runnable runnable) {
        mResumeAction = runnable;
    }
}

PtrUIHandlerHook允许外界设置一个Runnable在resume的时候执行,这就意味着我们可以在截断PtrFrameLayout关闭header后做一些自己的事情,这些事情可以放到PtrUIHandlerHook的run方法里,因为notifyUIRefreshComplete(boolean ignoreHook)有这样一句代码 –>mRefreshCompleteHook.takeOver(),这会触发PtrUIHandlerHook的run方法,比如我们把通知加载完成的事情放在这个润方法里。当我们的事情执行完了,再去调用PtrUIHandlerHook的resume方法,通知PtrFrameLayout关闭header。为什么可以通知PtrFrameLayout关闭header呢?因为在PtrFrameLayout里被有这样一个方法

 /**
     * please DO REMEMBER resume the hook
     *
     * @param hook
     */

    public void setRefreshCompleteHook(PtrUIHandlerHook hook) {
        mRefreshCompleteHook = hook;
        hook.setResumeAction(new Runnable() {
            @Override
            public void run() {
                if (DEBUG) {
                    PtrCLog.d(LOG_TAG, "mRefreshCompleteHook resume.");
                }
                notifyUIRefreshComplete(true);
            }
        });
    }

我们把自定义的PtrUIHandlerHook放进去,PtrFrameLayout默认设置的ResumeAction的run方法执行的是啥?就是notifyUIRefreshComplete(boolean ignoreHook)方法!!!大家再回去看看notifyUIRefreshComplete(boolean ignoreHook)方法,这个时候传入的ignoreHook是true,那么会发生什么呢?对,就是PtrFrameLayout正常关闭header的流程,而这个流程发生在哪里?就是在我们手动调用PtrUIHandlerHook的resume方法的时候,至于大脚要在哪里去调PtrUIHandlerHook的resume方法,大家尽情发挥想象吧。

到了这个时候,整个流程就变得清晰了:

1.我们自定义一个PtrUIHandlerHook,在run方法里通知刷新完成,但这时候PtrFrameLayout还未执行关闭header等动作;
2.收到刷新的组件该干嘛干嘛,干完事情后,通知PtrUIHandlerHook,你可以resume了,这时候PtrUIHandlerHook执行PtrFrameLayout事先设入的Runnable的run方法,再次调用notifyUIRefreshComplete(boolean ignoreHook)方法,这个时候传入的ignoreHook是true,notifyUIRefreshComplete(boolean ignoreHook)方法就会正常执行关闭header等动作,刷新完成。

至此,延迟关闭header的原理就清楚了,剩下的就是代码实现的事情了,我是通过定义接口的方式来降低刷新组件、PtrUIHandler、PtrUIHandlerHook的耦合的,大家卡伊参考一下

自定义PtrUIHandler

package com.ykbjson.demo.customview.ptrheader;

import android.content.Context;
import android.util.AttributeSet;
import android.widget.FrameLayout;

import com.ykbjson.demo.R;

import in.srain.cube.views.ptr.PtrFrameLayout;
import in.srain.cube.views.ptr.PtrUIHandler;
import in.srain.cube.views.ptr.indicator.PtrIndicator;

/**
 * 包名:com.ykbjson.demo.customview.ptrheader
 * 描述:自定义下拉刷新头部
 * 创建者:yankebin
 * 日期:2016/12/7
 */
public class CustomerPtrHandler extends FrameLayout implements PtrUIHandler,
        CustomerPtrUIHandlerHook.OnPtrUIHandlerHookCallback, PullLoadingView.OnRefreshCompleteCallback {
    // 下拉图标
    private PullLoadingView pullLoadingView;
    private CustomerPtrUIHandlerHook handlerHook;

    public CustomerPtrHandler(Context context) {
        this(context, null);
    }

    public CustomerPtrHandler(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CustomerPtrHandler(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    /**
     * 初始化
     *
     * @param context
     */
    private void init(Context context) {
        inflate(context, R.layout.item_refresh_header, this);
        pullLoadingView = (PullLoadingView) findViewById(R.id.iv_rotate);
        pullLoadingView.setCompleteCallback(this);
    }

    public void setLoadingColor(int loadingColor) {
        if (checkNotNull(pullLoadingView)) {
            pullLoadingView.setLoadingColor(loadingColor);
        }
    }

    private boolean checkNotNull(Object o) {
        return null != o;
    }

    @Override
    public void onUIReset(PtrFrameLayout ptrFrameLayout) {
        if (checkNotNull(pullLoadingView)) {
            pullLoadingView.setMode(PullLoadingView.MODE_INIT);
        }
    }

    @Override
    public void onUIRefreshPrepare(PtrFrameLayout ptrFrameLayout) {
        if (!checkNotNull(handlerHook)) {
            handlerHook = new CustomerPtrUIHandlerHook(this);
            ptrFrameLayout.setRefreshCompleteHook(handlerHook);
            if (checkNotNull(pullLoadingView)) {
                pullLoadingView.setMaxPullY(ptrFrameLayout.getOffsetToRefresh());
            }
            //修改loading最少驻留时间,不要一闪而过
            ptrFrameLayout.setLoadingMinTime(2000);
            //header关闭时间
            ptrFrameLayout.setDurationToCloseHeader(1500);
        }
    }

    @Override
    public void onUIRefreshBegin(PtrFrameLayout ptrFrameLayout) {
        if (checkNotNull(pullLoadingView)) {
            pullLoadingView.setMode(PullLoadingView.MODE_LOADING);
        }
    }

    @Override
    public void onUIRefreshComplete(final PtrFrameLayout ptrFrameLayout) {
    }

    @Override
    public void onUIPositionChange(PtrFrameLayout frame, boolean isUnderTouch, byte status, PtrIndicator ptrIndicator) {
        if (!isUnderTouch || status == PtrFrameLayout.PTR_STATUS_LOADING || status == PtrFrameLayout.PTR_STATUS_COMPLETE) {
            return;
        }
        final int currentPos = ptrIndicator.getCurrentPosY();
        if (checkNotNull(pullLoadingView)) {
            pullLoadingView.onUIPositionChange(currentPos);
        }
    }

    @Override
    public void onPtrUIHandlerHookStart() {
        if (checkNotNull(pullLoadingView)) {
            pullLoadingView.setMode(PullLoadingView.MODE_LOADING_COMPLETE);
        }
        //如果没有pullLoadingView,强制hook resume,不然会导致刷新视图无法还原
        else {
            onRefreshComplete();
        }
    }

    @Override
    public void onRefreshComplete() {
        if (checkNotNull(handlerHook)) {
            handlerHook.resume();
        }
    }
}

自定义PtrUIHandlerHook

package com.ykbjson.demo.customview.ptrheader;

import in.srain.cube.views.ptr.PtrUIHandlerHook;

/**
 * 包名:com.ykbjson.demo.customview.ptrheader
 * 描述:下拉刷新关闭header的hook,可以再这里处理关闭header之前的事情
 * 创建者:yankebin
 * 日期:2016/12/7
 */

public class CustomerPtrUIHandlerHook extends PtrUIHandlerHook {

    public interface OnPtrUIHandlerHookCallback{
        void onPtrUIHandlerHookStart();
    }

    private OnPtrUIHandlerHookCallback handlerHookCallback;

    public CustomerPtrUIHandlerHook(OnPtrUIHandlerHookCallback handlerHookCallback) {
        this.handlerHookCallback = handlerHookCallback;
    }

    @Override
    public void run() {
        if (null != handlerHookCallback) {
            handlerHookCallback.onPtrUIHandlerHookStart();
        }
    }
}

自定义PullLoadingView

package com.ykbjson.demo.customview.ptrheader;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.widget.ImageView;


/**
 * 包名:com.ykbjson.demo.customview.ptrheader
 * 描述:下拉刷新加载视图
 * 创建者:yankebin
 * 日期:2016/12/7
 */

public class PullLoadingView extends ImageView {
    public static final int MODE_INIT = -1;
    public static final int MODE_PULLING = MODE_INIT + 1;
    public static final int MODE_LOADING = MODE_INIT + 2;
    public static final int MODE_LOADING_COMPLETE = MODE_INIT + 3;
    public static final int MODE_ALL_COMPLETE = MODE_INIT + 4;


    private static final int CIRCLE_DEGREE = 360;
    private static final String LOADING_TEXT = "loading...";

    private int delayToCloseHeader = 1000;
    private float maxPullY;
    private float width;
    private float height;
    private Paint arcPaint;
    private Paint textPaint;
    private RectF rectF;
    private int degree;
    private int mode = MODE_INIT;
    private float currentPosition;
    private long invalidDelayTime = 30L;
    private float loadingTextWidth;
    private float line1X;
    private float line1Y;
    private float line2X;
    private float line2Y;
    private float radius;
    private float center;
    private float checkStartX;
    private float step;
    private float lineThick;
    private boolean isNeedDrawSecondLine;
    private OnRefreshCompleteCallback completeCallback;
    private final Canvas mCanvas;//当布局本身或父布局被隐藏后,不会回调onDraw方法,此时手动传入此参数强制驱动onDraw方法执行,保证绘画不被阻断

    public interface OnRefreshCompleteCallback {
        void onRefreshComplete();
    }

    public PullLoadingView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public PullLoadingView(Context context) {
        this(context, null);
    }

    public PullLoadingView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mCanvas = new Canvas();

        final float density = getResources().getDisplayMetrics().density;
        step = density * 2;
        lineThick = density * 2;

        arcPaint = new Paint();
        arcPaint.setColor(Color.RED);
        arcPaint.setAntiAlias(true);
        arcPaint.setStrokeWidth(lineThick);
        arcPaint.setStyle(Paint.Style.STROKE);

        textPaint = new Paint();
        textPaint.setColor(Color.RED);
        textPaint.setAntiAlias(true);
        textPaint.setStyle(Paint.Style.FILL);
        textPaint.setTextSize(6 * density);

        loadingTextWidth = textPaint.measureText(LOADING_TEXT);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        width = w;
        height = h;
        center = width / 2;
        radius = width / 2 - lineThick;
        checkStartX = center - width / 5;
        rectF = new RectF(lineThick, lineThick, width - lineThick, height - lineThick);
    }

    @Override
    public void draw(Canvas canvas) {
        super.draw(canvas);
        //打钩
        if (mode == MODE_ALL_COMPLETE) {
            //画圆
            canvas.drawArc(rectF, 0, CIRCLE_DEGREE, false, arcPaint);
            if (line1X < radius / 3) {
                line1X += step;
                line1Y += step;
            }
            //画第一根线
            canvas.drawLine(checkStartX, center, checkStartX + line1X, center + line1Y, arcPaint);

            if (line1X >= radius / 3) {

                if (!isNeedDrawSecondLine) {
                    line2X = line1X;
                    line2Y = line1Y;
                    isNeedDrawSecondLine = true;
                }
                line2X += step;
                line2Y -= step;
                //画第二根线
                canvas.drawLine(checkStartX + line1X - lineThick / 2,
                        center + line1Y, checkStartX + line2X, center + line2Y, arcPaint);
            }
            if (line2X > radius) {
                line1X = line1Y = line2X = line2Y = 0;
                isNeedDrawSecondLine = false;
                refreshComplete();
                return;
            }
            uiChange();
        }
        //画圆
        else if (mode == MODE_LOADING_COMPLETE) {
            //画圆弧
            canvas.drawArc(rectF, -CIRCLE_DEGREE / 4, degree, false, arcPaint);
            //画圆和勾的过程做完后停止刷新界面
            if (degree == CIRCLE_DEGREE) {
                setMode(MODE_ALL_COMPLETE);
                return;
            }
            //继续画圆
            checkDegree();
            uiChange();
        }
        //渐进闭合圆
        else if (mode == MODE_PULLING) {
            float sweepAngle = currentPosition / maxPullY * CIRCLE_DEGREE;
            canvas.drawArc(rectF, -CIRCLE_DEGREE / 4, sweepAngle, false, arcPaint);
            canvas.drawArc(rectF, sweepAngle - (CIRCLE_DEGREE / 4 - 5), 5, false, arcPaint);
        }
        //旋转圆弧
        else if (mode == MODE_LOADING) {
            canvas.drawText(LOADING_TEXT, (width - loadingTextWidth) / 2, height / 2, textPaint);
            canvas.drawArc(rectF, degree, CIRCLE_DEGREE / 8, false, arcPaint);
            canvas.drawArc(rectF, degree + CIRCLE_DEGREE / 8 + 2, 5, false, arcPaint);
            degree += 10;
            if (degree == CIRCLE_DEGREE) {
                degree = 0;
            }
            uiChange();
        }
        //初始化
        else {
            super.draw(canvas);
        }
    }

    public void setCompleteCallback(OnRefreshCompleteCallback completeCallback) {
        this.completeCallback = completeCallback;
    }

    public void setDelayToCloseHeader(int delayToCloseHeader) {
        this.delayToCloseHeader = delayToCloseHeader;
    }

    public void setMaxPullY(float maxPullY) {
        this.maxPullY = maxPullY;
    }

    /**
     * 设置动画的颜色,包括文字和圆圈以及打钩的线
     *
     * @param loadingColor
     */
    public void setLoadingColor(int loadingColor) {
        arcPaint.setColor(loadingColor);
        textPaint.setColor(loadingColor);
    }

    public void setStep(float step) {
        this.step = step;
    }

    /**
     * 设置当前模式
     *
     * @param mode
     */
    public void setMode(int mode) {
        degree = 0;
        if (mode == MODE_PULLING) {
            this.mode = mode;
            dispatchInvalidate();
        } else {
            if (this.mode != mode) {
                this.mode = mode;
                if (mode == MODE_LOADING_COMPLETE) {
                    invalidDelayTime = 50L;
                } else if (mode == MODE_LOADING) {
                    invalidDelayTime = 15L;
                } else if (mode == MODE_ALL_COMPLETE) {
                    invalidDelayTime = 5L;
                } else {
                    invalidDelayTime = 30L;
                }
                uiChange();
            }
        }
    }

    /**
     * 检测角度
     */
    private void checkDegree() {
        if (degree < CIRCLE_DEGREE) {
            degree += 45;
            if (degree > CIRCLE_DEGREE) {
                degree = CIRCLE_DEGREE;
            }
        }
    }

    /**
     * ui变化
     */
    private synchronized void uiChange() {
        removeCallbacks(invalidateRunner);
        postDelayed(invalidateRunner, invalidDelayTime);
    }

    /**
     * 刷新完成
     */
    private synchronized void refreshComplete() {
        removeCallbacks(closeHeaderRunner);
        postDelayed(closeHeaderRunner, delayToCloseHeader);
    }


    /**
     * 下拉距离变化
     *
     * @param currentPos
     */
    public void onUIPositionChange(int currentPos) {
        currentPosition = currentPos;
        setMode(MODE_PULLING);
    }

    /**
     * 根据view的可见性决定调postInvalidate方法或传入空canvas去驱动onDraw方法继续执行,保证绘画不被阻断
     */
    private void dispatchInvalidate() {
        if (isShown()) {
            postInvalidate();
        } else {
            draw(mCanvas);
        }
    }

    private Runnable invalidateRunner = new Runnable() {
        @Override
        public void run() {
            dispatchInvalidate();
        }
    };

    private Runnable closeHeaderRunner = new Runnable() {
        @Override
        public void run() {
            if (null != completeCallback) {
                completeCallback.onRefreshComplete();
            }
        }
    };
}

再啰嗦几句,在实现这个效果的过程中还遇到了一个问题,当某个界面绘制还未完成时,打开了新页面,正在绘制的界面stop了,这个时候应该是android系统因为优化内存的原因,不可见的视图不会再调用draw相关方法的,即使你手动调用postInvalidate或是invalidate或是requestLayout方法,当我们的新页面关闭后,原来的页面resume了,view就会执行draw相关的方法,因为我的这个效果是纯绘制的,所有靠的是在draw方法里循环调用postInvalidate来驱动绘制,当界面stop后,收到了刷新结束的通知,但是因为draw方法没法继续驱动,所以状态就一直停留在了刷新结束的状态,还未闭合圆和打钩,当界面可见后,按照正常时间推算,这个界面的刷新早已结束,应该是看不见header的,但是由于刚才的那个原因,这个时候还会从界面刚刷新结束时的状态开始继续执行。所以这里才有dispatchInvalidate这个方法。

为了解决这个问题,我尝试了SurfaceView,子线程绘制,不受界面影响,但是最后放弃的原因是那个黑块块没办法去掉。唯一能去掉的办法就是每个有下拉刷新的页面都要给自定义PullLoadingView设置一个背景颜色,但是我们目前涉及的界面较多,色值严重不统一,所以我最后放弃了。最后还是继续用view来实现,只不过根据isShown方法来判断当前界面是否可见,不可见的时候给一个空画布手动驱动draw方法执行,驱动状态正常执行,由于是靠状态来绘制的,所以即使你很快的来回切页面也不会出现绘制错乱的问题。

最后就是要说一下isShown这个方法,大家可以去看看他的源码和注释,这和getVisibility方法还是有很大区别的,他的返回值是遍历整个view的树的可见性得到的结果,所以他的返回值是比较可靠的,这也是我解决我所遇到的问题的一个关键点。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值