FanChat学习笔记(四)——主页面

主页面是FanChat的重点,本文还是接着前面继续总结。如果没有看过FanChat学习笔记(三)——注册页的话,建议先翻看前面的内容。因为本文打算研究主界面的很多主要功能,为了细致研究FanChat,我打算将其运行起来。但是之前也运行过,结果在实例化环信SDK时找不到libsqlite.so文件。
于是我猜测估计是环信SDK是旧版本,于是我将其更新到最新版本。后来我发现一个非常奇怪的事情,官方源码与我jar包的源码不一致!!!于是我找到了官网客服,他们也是吓了一跳但无可奈何!

这里写图片描述

后来我重新建了一个项目,然后重头配置,我发现根本找不到jniLibs文件夹下jar包内的文件,后来我才知道:
原来AndroidStudio是jar包放在libs文件夹,so文件放在jniLibs文件夹,如果so文件也是放在libs文件夹下的话,那么需要进行如下配置:

android {
    sourceSets {
        main {
            jniLibs.srcDirs = ['libs']
        }
    }
}

经过大半天的折腾,总算把该项目运行起来了!本来心里有点沮丧的,但是想想只要每天学习一点以前没有掌握的东西,那么也算是一种进步。于是今天又开始学习之前不曾掌握的东西了!
主界面其实Activity里面没有什么东西值得提到的,因为简单到都没有使用MVP的设计模式,这里也说明了我们在编写代码的时候也没有必要因为设计而过度设计。但是我觉得这里面还是有两个亮点:

  • Fragment的工厂类——FragmentFactory
  • 为了不重复造轮子选择开源库——BottomBar

其实FragmentFactory非常简单,就是一个单例,然后提供一个方法getFragment(int id)返回相对应的Fragment。并不是非常高深的写法,但是这样写的高明之处在于可以将Fragment分离出来,有利于后期扩展和维护,反正我是这样认为的。

package com.itheima.leon.qqdemo.factory;

import com.itheima.leon.qqdemo.R;
import com.itheima.leon.qqdemo.ui.fragment.BaseFragment;
import com.itheima.leon.qqdemo.ui.fragment.ContactFragment;
import com.itheima.leon.qqdemo.ui.fragment.ConversationFragment;
import com.itheima.leon.qqdemo.ui.fragment.DynamicFragment;

/**
 * 创建者:   Leon
 * 创建时间:  2016/10/17 22:05
 * 描述:    Fragment工厂类
 */
public class FragmentFactory {
    public static final String TAG = "FragmentFactory";

    private static FragmentFactory sFragmentFactory;

    private BaseFragment mMessageFragment;
    private BaseFragment mContactFragment;
    private BaseFragment mDynamicFragment;

    public static FragmentFactory getInstance() {
        if (sFragmentFactory == null) {
            synchronized (FragmentFactory.class) {
                if (sFragmentFactory == null) {
                    sFragmentFactory = new FragmentFactory();
                }
            }
        }
        return sFragmentFactory;
    }

    public BaseFragment getFragment(int id) {
        switch (id) {
            case R.id.conversations:
                return getConversationFragment();
            case R.id.contacts:
                return getContactFragment();
            case R.id.dynamic:
                return getDynamicFragment();
        }
        return null;
    }

    private BaseFragment getConversationFragment() {
        if (mMessageFragment == null) {
            mMessageFragment = new ConversationFragment();
        }
        return mMessageFragment;
    }

    private BaseFragment getDynamicFragment() {
        if (mDynamicFragment == null) {
            mDynamicFragment = new DynamicFragment();
        }
        return mDynamicFragment;
    }

    private BaseFragment getContactFragment() {
        if (mContactFragment == null) {
            mContactFragment = new ContactFragment();
        }
        return mContactFragment;
    }




}

BottomBar这部分我心里看见界面首先想到是RadioGroup,但是如果有封装好的开源控件的话,我觉得使用开源控件会提高开发速度,虽然弊端是如果后期遇到问题的时候可能处理起来比较费劲。但是这也需要我们在平时维护项目之余来研究别人的控件,看看牛人是如何构思的,这样我们可以更接近大牛,至少接近了它的技术。我记得好像郭神说过这样的话!
作者在这方面的还介绍了一些相关的开源控件:

  • BottomBar
  • AHBottomNavigation
  • BottomNavigation

我简单的看了看其它的效果,我觉得目前这种效果已经足够了,所以没有去研究其它的控件了!这里简单说一说如何使用,其实分为四步就可以实现全部效果:

  1. 添加依赖
  2. 添加图标及相关文本
  3. 使用控件
  4. 点击事件处理

添加依赖(控件版本号为当前最新版本)

 compile 'com.roughike:bottom-bar:2.0.2'

添加图标及相关文本——首先需要在res/xml文件夹(没有则自己创建)下新建bottombar_tabs.xml,然后指定相关的ID、文本、icon,如下:

<?xml version="1.0" encoding="utf-8"?>
<tabs>
<tab
    id="@id/conversations"
    icon="@mipmap/ic_conversation_selected_2"
    title="@string/messages"/>
<tab
    id="@id/contacts"
    icon="@mipmap/ic_contact_selected_2"
    title="@string/contacts"/>
<tab
    id="@id/dynamic"
    icon="@mipmap/ic_plugin_selected_2"
    title="@string/dynamic"/>
</tabs>

这里需要强调的是图标:

The icons must be fully opaque, solid black color, 24dp and with no padding. 

For example, with Android Asset Studio Generic Icon generator, select "TRIM" and make sure the padding is 0dp. 
图标必须完全不透明,实心黑色,24dp,没有填充。 例如,使用Android Asset Studio通用图标生成器,选择“TRIM”并确保填充为0dp。

使用控件,这个就简单了!

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">



    <com.roughike.bottombar.BottomBar
        android:id="@+id/bottomBar"
        android:layout_width="match_parent"
        android:layout_height="60dp"
        //tab资源文件
        app:bb_tabXmlResource="@xml/bottombar_tabs"
        //选中的颜色
        app:bb_activeTabColor="@color/qq_blue"/>

</LinearLayout>

点击事件处理,这个直接上代码:

 BottomBar bottomBar = (BottomBar) findViewById(R.id.bottomBar);
        bottomBar.setOnTabSelectListener(new OnTabSelectListener() {
            @Override
            public void onTabSelected(@IdRes int tabId) {
                if (tabId == R.id.tab_favorites) {
                    // The tab with id R.id.tab_favorites was selected,
                    // change your content accordingly.
                }
            }
        });

我们主界面的逻辑是默认为选中R.id.conversations,所以FragmentFactory返回的是会话界面,当我们点击其它tab的时候,FragmentFactory就返回其它相应的Fragment。
所以我们首先进入到个人动态DynamicFragment去看看!

其实这个Fragment和其它Activity极其类似,不过这里面我觉得知识点相对较少,所以我们只要看看String.format就好。因为代码中曾用到这方面的知识:

String.format(getString(R.string.logout), EMClient.getInstance().getCurrentUser());

 <string name="logout">退(%s)出</string>

这里写图片描述

之前曾多次看见通过String.format()来实现对数据的文本格式输出,但是不知道具体可以输出哪些内容,后来我看看了看这篇文章才发现原来这里还有这么多门道。(format方法虽然相对String拼接和Stringbuilder来讲性能差一些,但是并不妨碍我们学习人家的用法,至少我们可以多一种选择!)

JAVA字符串格式化-String.format()的使用

接下来是联系人ContactFragment,先看看效果图:

这里写图片描述

然后我们看看这是如何实现的?整个界面分为了三个部分:

  1. 联系人RecycleView
  2. 右边框SlideBar
  3. 显示字母TextView

联系人RecycleView并没有什么特别的,特别的是RecycleView的模块化思想。这部分体现在下面:

  • ContactListItemView
  • ContactListItem

ContactListItemView其实就是联系人的UI界面,预览如图:

这里写图片描述

ContactListItem其实就是一个ViewHolder,就是对ContactListItemView需要的数据进行了一个封装,代码如下:

package com.itheima.leon.qqdemo.model;

import com.itheima.leon.qqdemo.R;

/**
 * 创建者:   Leon
 * 创建时间:  2016/10/18 12:10
 * 描述:    联系人实体
 */
public class ContactListItem {
    public static final String TAG = "ContactListItem";
    /**
     * 头像资源
     */
    public int avatar = R.mipmap.avatar6;
    /**
     * 好友名称
     */
    public String userName;
    /**
     * 是否显示首先字母
     */
    public boolean showFirstLetter = true;

    /**
     * 获取首先字母字符
     * @return
     */
    public char getFirstLetter() {
        return userName.charAt(0);
    }

    /**
     * 获取首写字母大写字符串
     * @return
     */
    public String getFirstLetterString() {
        return String.valueOf(getFirstLetter()).toUpperCase();
    }
}

现在很多即时通信sdk里面都提供了统一的模块,不过我觉得还是自己写的这种模块可以更好的展示了个性化特点(当然了,demo并没有什么特点),主要是这种思维确实比一般写的这种实体和布局要好一些!

右边框SlideBar其实就是一个自定义View,一个有6个方法,重点在下面五个方法:

  1. onSizeChanged(int w, int h, int oldw, int oldh)
  2. onDraw(Canvas canvas)
  3. onTouchEvent(MotionEvent event)
  4. notifySectionChange(MotionEvent event)
  5. getTouchIndex(MotionEvent event)

先看看第一个onSizeChanged方法的具体代码:

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        mTextSize = h * 1.0f / SECTIONS.length;//计算分配给每个字符的高度
        Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
        float mTextHeight = fontMetrics.descent - fontMetrics.ascent;//获取默认绘制字符的高度
        mTextBaseline = mTextSize / 2 + mTextHeight/2 - fontMetrics.descent;//计算字符居中时的baseline

    }

这里写图片描述

假设每个字体高为50dp,默认ascent为30,默认descent为50,则矩形高度实际值应该是mTextSize / 2 + mTextHeight/2 =35,则TextBaseline = -15;这个居中并不是单纯的居中,而是以descent为基准向上移。如果上面的图片不清楚,那么这张图应该可以理解吧?

这里写图片描述

如果还是不能理解,建议将上面的mTextBaseline按照自己理解进行赋值,然后你就会明白为什么这样是对的啦!
把需要的数据找到后,接下来就可以进行绘制了,于是进入onDraw方法看看:

  @Override
    protected void onDraw(Canvas canvas) {
        float x = getWidth() * 1.0f / 2;
        float baseline = mTextBaseline;
        //绘制所有首写字母
        for(int i = 0; i < SECTIONS.length; i++) {
            canvas.drawText(SECTIONS[i], x, baseline, mPaint);
            //将距中线下移,注意是正方向
            baseline += mTextSize;
        }
    }

现在就把文本绘制出来了,接下来需要处理滑动事件了,于是我们看看这里是怎么处理的?

 @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                setBackgroundResource(R.drawable.bg_slide_bar);
                notifySectionChange(event);
                break;
            case MotionEvent.ACTION_MOVE:
                notifySectionChange(event);
                break;
            case MotionEvent.ACTION_UP:
                setBackgroundColor(Color.TRANSPARENT);
                if (mOnSlideBarChangeListener != null) {
                    mOnSlideBarChangeListener.onSlidingFinish();
                }
                break;
        }
        return true;
    }

这里其实很简单,首先是按下的时候将透明背景修改为半透明,然后在滑动过程中将滑动的MotionEvent事件传给相关方法,然后在抬起来的时候将半透明修改为透明,然后通过接口回调,使用相关方法。
接下来看看滑动过程中的notifySectionChange(event)执行了什么逻辑?

/**
     * 唤醒选择改变事件
     * @param event
     */
    private void notifySectionChange(MotionEvent event) {
        int index = getTouchIndex(event);
        if (mOnSlideBarChangeListener != null && mCurrentIndex != index) {
            mCurrentIndex = index;
            mOnSlideBarChangeListener.onSectionChange(index, SECTIONS[index]);
        }
    }

这里获取了当前位置,以SlideBar的26个字母为基准,计算当前位置应对应哪个字母,如果位置发生改变再次通过接口回调。所以这里可以看看getTouchIndex(event)方法的具体逻辑:

/**
     * 获取滑动事件的位置
     * @param event
     * @return
     */
    private int getTouchIndex(MotionEvent event) {
        int index = (int) (event.getY() / mTextSize);
        if (index < 0) {
            index = 0;
        } else if (index > SECTIONS.length - 1) {
            index = SECTIONS.length - 1;
        }
        return index;
    }

最后我们看看在ContactFragment里面看看这些个接口回调是干嘛的?在滑动结束和滑动的过程中分别执行了什么逻辑?

    private SlideBar.OnSlideBarChangeListener mOnSlideBarChangeListener = new SlideBar.OnSlideBarChangeListener() {
        @Override
        public void onSectionChange(int index, String section) {
            //滑动过程中
            //展示显示字母的TextView
            mSection.setVisibility(View.VISIBLE);
            //设置需要展示的字母
            mSection.setText(section);
            //RecycleView移动到该字母的位置
            scrollToSection(section);
        }

        @Override
        public void onSlidingFinish() {
            //滑动结束关闭显示字母的TextView
            mSection.setVisibility(View.GONE);
        }
    };
    /**
     * RecyclerView滚动直到界面出现对应section的联系人
     *
     * @param section 首字符
     */
    private void scrollToSection(String section) {
        int sectionPosition = getSectionPosition(section);
        if (sectionPosition != POSITION_NOT_FOUND) {
            mRecyclerView.smoothScrollToPosition(sectionPosition);
        }
    }


    /**
     *
     * @param section 首字符
     * @return 在联系人列表中首字符是section的第一个联系人在联系人列表中的位置
     */
    private int getSectionPosition(String section) {
        List<ContactListItem> contactListItems = mContactListAdapter.getContactListItems();
        for (int i = 0; i < contactListItems.size(); i++) {
            if (section.equals(contactListItems.get(i).getFirstLetterString())) {
                return i;
            }
        }
        return POSITION_NOT_FOUND;
    }

界面的逻辑我们理清楚了,接下来我们看看对于联系人是如何保存的?在此之前需要先介绍一个队伍来说算是一个新知识的数据库框架——greenDAO。

这个东西之前我也没有接触过,对于数据库框架我之前只接触过LitePal,二者都是一款开源的Android数据库框架,均采用了对象关系映射(ORM)的模式。由于对后者我也不是很熟悉,也不好评价二者孰好孰坏。不过我们或许应该换一个思维,两种框架并不一定非得争个第一,我们应该选择项目最适合的框架。比如LitePal支持配置简单,查询方便,数据库可以放置在SD卡等便利,但是greenDAO好像配置更简单,保存也是一样的方便。但是不管怎么样,用郭霖的话说,不管我们喜欢不喜欢,多一个选择总不是坏事,对吧?
greenDAO的配置基本上都是gradle配置加实体注释即可,已经有大神对此进行了傻瓜式的注释,堪称中文版官网,可以点击查看!
下面是实体类和数据库管理类,直接上代码:

package com.itheima.leon.qqdemo.database;

import org.greenrobot.greendao.annotation.Entity;
import org.greenrobot.greendao.annotation.Id;
import org.greenrobot.greendao.annotation.Generated;

/**
 * 创建者:   Leon
 * 创建时间:  2016/10/21 17:47
 * 描述:    联系人实体(数据库)
 */
@Entity
public class Contact {
    public static final String TAG = "Contact";
    @Id(autoincrement = true)
    private Long id;

    private String username;

    @Generated(hash = 1642963851)
    public Contact(Long id, String username) {
        this.id = id;
        this.username = username;
    }
//@Generated注解为greenDAO自动生成,如若误删,需要重新build
    @Generated(hash = 672515148)
    public Contact() {
    }

    public Long getId() {
        return this.id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return this.username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

}
package com.itheima.leon.qqdemo.database;

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;

import com.itheima.leon.qqdemo.app.Constant;

import java.util.ArrayList;
import java.util.List;

/**
 * 创建者:   Leon
 * 创建时间:  2016/10/21 18:57
 * 描述:    数据库管理
 */
public class DatabaseManager {
    public static final String TAG = "DatabaseManager";

    private static DatabaseManager sInstance;
    private DaoSession mDaoSession;


    public static DatabaseManager getInstance() {
        if (sInstance == null) {
            synchronized (DatabaseManager.class) {
                if (sInstance == null) {
                    sInstance = new DatabaseManager();
                }
            }
        }
        return sInstance;
    }

    /**
     * 实例化
     * @param context
     */
    public void init(Context context) {
        DaoMaster.DevOpenHelper devOpenHelper = new DaoMaster.DevOpenHelper(context, Constant.Database.DATABASE_NAME, null);
        SQLiteDatabase writableDatabase = devOpenHelper.getWritableDatabase();
        DaoMaster daoMaster = new DaoMaster(writableDatabase);
        mDaoSession = daoMaster.newSession();
    }

    /**
     * 保存联系人
     * @param userName
     */
    public void saveContact(String userName) {
        Contact contact = new Contact();
        contact.setUsername(userName);
        mDaoSession.getContactDao().save(contact);
    }

    /**
     * 查询联系人
     * @return
     */
    public List<String> queryAllContacts() {
        List<Contact> list = mDaoSession.getContactDao().queryBuilder().list();
        ArrayList<String> contacts = new ArrayList<String>();
        for (int i = 0; i < list.size(); i++) {
            String contact = list.get(i).getUsername();
            contacts.add(contact);
        }
        return contacts;
    }

    /**
     * 删除所有联系人
     */
    public void deleteAllContacts() {
        ContactDao contactDao = mDaoSession.getContactDao();
        contactDao.deleteAll();

    }
}

对于数据库的调用在ContactFragment的业务逻辑实现类ContactPresenterImpl,我们看看代码就明白了!

package com.itheima.leon.qqdemo.presenter.impl;

import com.hyphenate.chat.EMClient;
import com.hyphenate.exceptions.HyphenateException;
import com.itheima.leon.qqdemo.database.DatabaseManager;
import com.itheima.leon.qqdemo.model.ContactListItem;
import com.itheima.leon.qqdemo.presenter.ContactPresenter;
import com.itheima.leon.qqdemo.utils.ThreadUtils;
import com.itheima.leon.qqdemo.view.ContactView;

import java.util.ArrayList;
import java.util.List;

/**
 * 创建者:   Leon
 * 创建时间:  2016/10/18 15:34
 * 描述:    TODO
 */
public class ContactPresenterImpl implements ContactPresenter {
    private static final String TAG = "ContactPresenterImpl";

    private ContactView mContactView;

    private List<ContactListItem> mContactListItems;

    public ContactPresenterImpl(ContactView contactView) {
        mContactView = contactView;
        mContactListItems = new ArrayList<ContactListItem>();
    }

    /**
     * 获取联系人
     * @return
     */
    @Override
    public List<ContactListItem> getContactList() {
        return mContactListItems;
    }

    /**
     * 刷新联系人
     */
    @Override
    public void refreshContactList() {
        mContactListItems.clear();
        getContactsFromServer();
    }

    /**
     * 删除联系人
     * @param name
     */
    @Override
    public void deleteFriend(final String name) {
        ThreadUtils.runOnBackgroundThread(new Runnable() {
            @Override
            public void run() {
                try {
                    EMClient.getInstance().contactManager().deleteContact(name);
                    ThreadUtils.runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            mContactView.onDeleteFriendSuccess();
                        }
                    });
                } catch (HyphenateException e) {
                    e.printStackTrace();
                    ThreadUtils.runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            mContactView.onDeleteFriendFailed();
                        }
                    });
                }
            }
        });
    }

    /**
     *从服务器获取联系人列表
     */
    @Override
    public void getContactsFromServer() {
        if (mContactListItems.size() > 0) {
            mContactView.onGetContactListSuccess();
            return;
        }
        ThreadUtils.runOnBackgroundThread(new Runnable() {
            @Override
            public void run() {
                try {
                    startGetContactList();
                    notifyGetContactListSuccess();
                } catch (HyphenateException e) {
                    e.printStackTrace();
                    notifyGetContactListFailed();
                }
            }
        });

    }



    /**
     * 获取联系人列表数据
     * @throws HyphenateException
     */
    private void startGetContactList() throws HyphenateException {
        List<String> contacts = EMClient.getInstance().contactManager().getAllContactsFromServer();
        DatabaseManager.getInstance().deleteAllContacts();
        if (!contacts.isEmpty()) {
            for (int i = 0; i < contacts.size(); i++) {
                ContactListItem item = new ContactListItem();
                item.userName = contacts.get(i);
                if (itemInSameGroup(i, item)) {
                    item.showFirstLetter = false;
                }
                mContactListItems.add(item);
                saveContactToDatabase(item.userName);
            }
        }
    }

    private void saveContactToDatabase(String userName) {
        DatabaseManager.getInstance().saveContact(userName);
    }

    /**
     * 当前联系人跟上个联系人比较,如果首字符相同则返回true
     * @param i 当前联系人下标
     * @param item 当前联系人数据模型
     * @return true 表示当前联系人和上一联系人在同一组
     */
    private boolean itemInSameGroup(int i, ContactListItem item) {
        return i > 0 && (item.getFirstLetter() == mContactListItems.get(i - 1).getFirstLetter());
    }

    /**
     * 获取联系人成功
     */
    private void notifyGetContactListSuccess() {
        ThreadUtils.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                mContactView.onGetContactListSuccess();
            }
        });
    }

    /**
     * 获取联系人失败
     */
    private void notifyGetContactListFailed() {
        ThreadUtils.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                mContactView.onGetContactListFailed();
            }
        });
    }
}

最后一个Fragment是ConversationFragment,先看看效果图:

这里写图片描述

由于前面已经介绍过模块化思想,所以在这个界面并没有什么亮点。我觉得唯一值得一提的就是会话这边的排序。先看看代码:

    @Override
    public void loadAllConversations() {
        ThreadUtils.runOnBackgroundThread(new Runnable() {
            @Override
            public void run() {
                Map<String, EMConversation> conversations = EMClient.getInstance().chatManager().getAllConversations();
                mEMConversations.clear();
                mEMConversations.addAll(conversations.values());
                 //根据最后一条消息的时间进行排序
                Collections.sort(mEMConversations, new Comparator<EMConversation>() {
                    @Override
                    public int compare(EMConversation o1, EMConversation o2) {
                        return (int) (o2.getLastMessage().getMsgTime() - o1.getLastMessage().getMsgTime());
                    }
                });
                ThreadUtils.runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        mConversationView.onAllConversationsLoaded();
                    }
                });
            }
        });
    }

我们一般排序都是升序或者降序,其实都是根据一个量化的标准来排序。作者使用后一个数据同前一个数据比较,同样实现了一般情况下取反的降序,我觉得这个知识点也是一个之前很少接触的,因此仔细看了看这方面的知识。比如这篇博客:JAVA 利用Comparator实现自定义排序

好了,到这里本文就结束了。这个周末会好好看看搜索朋友和添加朋友两个Activity,然后看看还有没有什么自己之前没有接触过的知识点,遇到的话可以好好学习学习了!

学习的项目地址:

github:https://github.com/Vicent9920/FanChat

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值