【Settings开发】蓝牙模块(一)

概要

  蓝牙-bluetooth此名取自公元十世纪一位颇有作为的丹麦国王Harald bluetooth,是由爱立信公司于上世纪90年代牵头发展而来的一种短距离无线通信技术标准,其具有体积小、抗干扰、低功耗等特点,该协议目前由蓝牙技术联盟(SIG)负责维护管理。

历史协议版本

协议版本发布时间主要特点
1.0/1.0B1999产品互操作性差
1.12002添加了未加密信道,提供信号强度指示(RSSI)
1.22003自适应跳帧扩频,传输速率高达721 kbit/s
2.0 + EDR2004速率提升至3Mbps,采用π/4-DQPSK and 8DPSK调制技术
2.1 + EDR2007增加安全简易配对(SSP),减低功耗
3.0 + HS2009采用AMP(Alternative MAC/PHY)
4.02010提出蓝牙低功耗协议栈
4.12013支持IPv6,简化设备连接,降低与LTE网络的干扰
4.22014支持IPv6/6LoWPAN
5.020162Mbps速率,更大的覆盖范围
MESH2017增强MESH组网功能

蓝牙几种常用的常量

  • 蓝牙有以下几种状态:

    • STATE_OFF,蓝牙关闭。
    • STATE_TURNING_ON,声明蓝牙正在打开。
    • STATE_ON,蓝牙处于打开状态。
    • STATE_TURNING_OFF,蓝牙正在关闭。
    • STATE_BLE_TURNING_ON,蓝牙低功耗模式正在打开。
    • STATE_BLE_ON,蓝牙低功耗模式打开。
    • STATE_BLE_TURNING_OFF,蓝牙低功耗模式正在关闭。
  • 根据蓝牙设备可见性,其具有以下几种扫描模式:

    • SCAN_MODE_NONE,当前蓝牙设备对其他设备不可见。
    • SCAN_MODE_CONNECTABLE,当前蓝牙设备仅对曾配对设备可见。
    • SCAN_MODE_CONNECTABLE_DISCOVERABLE,当前蓝牙设备范围内任意可见。
  • 蓝牙广播ACTION

    • ACTION_STATE_CHANGED,蓝牙状态变化 on/off。
    • ACTION_DISCOVERY_STARTED,开始搜索蓝牙设备。
    • ACTION_DISCOVERY_FINISHED,完成蓝牙设备搜索。
    • ACTION_FOUND,搜索到蓝牙设备。
    • ACTION_DISAPPEARED,此广播标识此次未搜到,但上次能搜到的蓝牙设备。
    • ACTION_NAME_CHANGED,附近有蓝牙设备名称发生变化。
    • ACTION_BOND_STATE_CHANGED,蓝牙设备的配对情况发生变化。
    • ACTION_PAIRING_CANCEL,蓝牙配对取消。
    • ACTION_CLASS_CHANGED,蓝牙设备的类型变化,如电话/音频类型。
    • ACTION_UUID,标识蓝牙设备使用的profile协议变化。
    • ACTION_SCAN_MODE_CHANGED,蓝牙扫描模式发生变化。
    • ACTION_REQUEST_ENABLE,这是一个intent。 aciton,可通过此action调起系统Activity请求打开蓝牙,在onActivityResult总通过RESULT_OK或RESULT_CANCEL判断。
    • ACTION_REQUEST_DISABLE,与上一个刚好相反。
    • ACTION_REQUEST_BLE_SCAN_ALWAYS_AVAILABLE,非系统app无法调用,主要调起系统Activity允许用户强行打开BLE。
    • ACTION_REQUEST_DISCOVERABLE,intent action,通过此action可调起系统Activity请求本机设备的对范围内任意可见,同时,如果蓝牙未打开,此action也可请求打开蓝牙。
    • ACTION_LOCAL_NAME_CHANGED,蓝牙本机设备名称发生变化,如调用BluetoothAdapter.setName后就会发送该广播。
    • ACTION_BLE_STATE_CHANGED,表示蓝牙切换到低功耗(BLE)模式。
    • ACTION_BLUETOOTH_ADDRESS_CHANGED,本机蓝牙设备地址发生改变。
    • ACTION_CONNECTION_STATE_CHANGED,本机设备与其他蓝牙设备的连接变化。
  • 蓝牙匹配情况

    • BOND_NONE,指明蓝牙设备未曾配对过。
    • BOND_BONDING,蓝牙设备正处于配对过程中。
    • BOND_BONDED,蓝牙设备曾经配对过,并且本地保有pin/passkey。
  • 蓝牙连接情况

    • STATE_DISCONNECTED,本机与其他蓝牙设备未连接或已失去连接。
    • STATE_CONNECTING,本机与其他蓝牙设备正在连接。
    • STATE_CONNECTED,本机与其他蓝牙设备已连接,此后可以进行通信。
    • STATE_DISCONNECTING,本机与其他蓝牙设备正失去连接。

设置中的蓝牙页面BluetoothSettings

  分析蓝牙模块,可从其在清单文件中入口开始。由首页加载流程一文中可知,蓝牙页面的入口是meta-data中com.android.settings.FRAGMENT_CLASS值即BluetoothSettings类。

    <activity android:name="Settings$BluetoothSettingsActivity"
            android:label="@string/bluetooth_settings_title"
            android:taskAffinity="">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <action android:name="android.settings.BLUETOOTH_SETTINGS" />
            <category android:name="android.intent.category.VOICE_LAUNCH" />
            <category android:name="com.android.settings.SHORTCUT" />
            <category android:name="android.intent.category.DEFAULT" />
        </intent-filter>
        <meta-data android:name="com.android.settings.FRAGMENT_CLASS"
            android:value="com.android.settings.bluetooth.BluetoothSettings" />
        <meta-data android:name="com.android.settings.TOP_LEVEL_HEADER_ID"
            android:resource="@id/bluetooth_settings" />
    </activity>

    <!-- Keep compatibility with old shortcuts. -->
    <activity-alias android:name=".bluetooth.BluetoothSettings"
            android:label="@string/bluetooth_settings_title"
            android:targetActivity="Settings$BluetoothSettingsActivity"
            android:exported="true"
            android:clearTaskOnLaunch="true">
        <meta-data android:name="com.android.settings.FRAGMENT_CLASS"
            android:value="com.android.settings.bluetooth.BluetoothSettings" />
        <meta-data android:name="com.android.settings.TOP_LEVEL_HEADER_ID"
            android:resource="@id/bluetooth_settings" />
    </activity-alias>

  BluetoothSettings继承自DeviceListPreferenceFragment(最终继承自PreferenceFragment,此种fragment主要是记录用户喜好的设置模块使用),在DeviceListPreferenceFragment中的onCreate方法中留有给子类(BluetoothSettings)设置UI的方法。即PreferenceFragment通常是调用addPreferencesFromResource(类比于Activity的setContentView)来设置页面UI,而在我们的BluetoothSettings中设置的xml文件其实质上只是一个空的PreferenceScreen,其内容都需要后期填充进去。

    @Override
    void addPreferencesForActivity() {
        //内部就一个空的PreferenceScreen
        addPreferencesFromResource(R.xml.bluetooth_settings);
        //此句保证fragment的onCreateOptionsMenu被回调到
        setHasOptionsMenu(true);
    }

  在BluetoothSettings的onActivityCreated中指明了SettingsActivity(最终BluetoothSettings都是填充到SettingsActivity的子Activity中,其UI形式当然也继承自父类)中保留的控件SwitchBar的作用,此控件在BluetoothSettings作为控制蓝牙开关状态而存在。

    @Override
    public void onActivityCreated(Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        mInitialScanStarted = (savedInstanceState != null);    // don't auto start scan after rotation
        mInitiateDiscoverable = true;

        mEmptyView = (TextView) getView().findViewById(android.R.id.empty);
        getListView().setEmptyView(mEmptyView);
        //毕竟Fragment只填充了SettingsActivity的一部分UI,上半部分在settings_main_prefs中可见,是一个SwitchBar
        final SettingsActivity activity = (SettingsActivity) getActivity();
        mSwitchBar = activity.getSwitchBar();

        mBluetoothEnabler = new BluetoothEnabler(activity, mSwitchBar);
        //内部显示switch控件
        mBluetoothEnabler.setupSwitchBar();
    }

  在BluetoothSettings中为了监听蓝牙的各种状态变化,必然需要注册多种广播action。
  在蓝牙设置类中添加了本机蓝牙设备rename监听:

public BluetoothSettings() {
        super(DISALLOW_CONFIG_BLUETOOTH);
        mIntentFilter = new IntentFilter(BluetoothAdapter.ACTION_LOCAL_NAME_CHANGED);
    }

  当然,仅仅是蓝牙设备名称改变的广播监听远远达不到蓝牙设置页面的要求,在其父类DeviceListPreferenceFragment中添加了蓝牙Event回调Callback,这种registerCallback内部在通过BluetoothEventManager中注册的各种蓝牙action广播的接收者中以回调形式通知出去,这样,仅需通过BluetoothEventManager注册一次广播,可以免去很多重复注册,又可集中式管理蓝牙事件。

    @Override
    public void onResume() {
        super.onResume();
        if (mLocalManager == null || isUiRestricted()) return;
        //此处用于控制蓝牙配对弹窗是否显示,如果设置了前台Activity,则弹窗会显示,
        // 如果为null,则以通知形式显示在通知栏中
        mLocalManager.setForegroundActivity(getActivity());
        //使用BluetoothEventManager注册了各种蓝牙状态广播,此处callback是广播onReceive时的回调
        mLocalManager.getEventManager().registerCallback(this);
        //如果当前正处于搜索态,则显示圆形进度框
        updateProgressUi(mLocalAdapter.isDiscovering());
    }

蓝牙页面接口回调说明

  在BluetoothEventManager中我们通过registerCallback注册的监听接口如下:

interface BluetoothCallback {
    //蓝牙状态监听
    void onBluetoothStateChanged(int bluetoothState);
    //蓝牙扫描状态监听
    void onScanningStateChanged(boolean started);
    //蓝牙设备增加监听
    void onDeviceAdded(CachedBluetoothDevice cachedDevice);
    //蓝牙设备减少监听
    void onDeviceDeleted(CachedBluetoothDevice cachedDevice);
    //蓝牙绑定状态监听
    void onDeviceBondStateChanged(CachedBluetoothDevice cachedDevice, int bondState);
}

  BluetoothCallback的接口回调中以下分别讨论各接口在蓝牙设置页(主要是BluetoothSettings及其父类DeviceListPreferenceFragment)的处理过程。

  • onBluetoothStateChanged(),父类中处理了当蓝牙关闭时,扫描进度条的可见性。而在BluetoothSettings中的onBluetoothStateChanged最终会调用updateContent进行刷新UI等操作。从以下代码可以看见,当蓝牙处于打开状态时,页面显示了历史配对列表+可配对列表+本机设备信息,同时去扫描可用蓝牙设备。而在蓝牙处于其他状态时,清空了列表中的蓝牙设备,并重置了menu中某些按钮的可用性。此处需要指明的是重置menu是通过invalidateOptionsMenu方法触发reCreate Menu方法即onCreateOptionsMenu的重新调用。
private void updateContent(int bluetoothState) {
        final PreferenceScreen preferenceScreen = getPreferenceScreen();
        int messageId = 0;

        switch (bluetoothState) {
            case BluetoothAdapter.STATE_ON:
                //在蓝牙打开时,清除所有列表,包括历史配对与可用配对
                preferenceScreen.removeAll();
                //此处添加顺序即是列表顺序
                preferenceScreen.setOrderingAsAdded(true);
                mDevicePreferenceMap.clear();

                if (isUiRestricted()) {
                    messageId = R.string.bluetooth_empty_list_user_restricted;
                    break;
                }
                //清空已配对列表
                // Paired devices category
                if (mPairedDevicesCategory == null) {
                    mPairedDevicesCategory = new PreferenceCategory(getActivity());
                } else {
                    mPairedDevicesCategory.removeAll();
                }
                //添加已配对到PreferenceScreen中,这样保证其处于第一队列
                addDeviceCategory(mPairedDevicesCategory,
                        R.string.bluetooth_preference_paired_devices,
                        BluetoothDeviceFilter.BONDED_DEVICE_FILTER, true);
                int numberOfPairedDevices = mPairedDevicesCategory.getPreferenceCount();
                //当然,如果并无历史配对,则把历史配对的category删除
                if (isUiRestricted() || numberOfPairedDevices <= 0) {
                    preferenceScreen.removePreference(mPairedDevicesCategory);
                }
                //范围内可用配对列表,蓝牙刚打开时需清除数据
                // Available devices category
                if (mAvailableDevicesCategory == null) {
                    mAvailableDevicesCategory = new BluetoothProgressCategory(getActivity());
                    mAvailableDevicesCategory.setSelectable(false);
                } else {
                    mAvailableDevicesCategory.removeAll();
                }
                addDeviceCategory(mAvailableDevicesCategory,
                        R.string.bluetooth_preference_found_devices,
                        BluetoothDeviceFilter.UNBONDED_DEVICE_FILTER, mInitialScanStarted);
                int numberOfAvailableDevices = mAvailableDevicesCategory.getPreferenceCount();
                //开启蓝牙扫描
                if (!mInitialScanStarted) {
                    startScanning();
                }
                //此处用于显示本机设备信息,放在了最后?
                if (mMyDevicePreference == null) {
                    mMyDevicePreference = new Preference(getActivity());
                }

                mMyDevicePreference.setSummary(getResources().getString(
                            R.string.bluetooth_is_visible_message, mLocalAdapter.getName()));
                mMyDevicePreference.setSelectable(false);
                preferenceScreen.addPreference(mMyDevicePreference);

                getActivity().invalidateOptionsMenu();

                // mLocalAdapter.setScanMode is internally synchronized so it is okay for multiple
                // threads to execute.
                if (mInitiateDiscoverable) {
                    // Make the device visible to other devices.
                    mLocalAdapter.setScanMode(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE);
                    mInitiateDiscoverable = false;
                }
                return; // not break

            case BluetoothAdapter.STATE_TURNING_OFF:
                messageId = R.string.bluetooth_turning_off;
                break;

            case BluetoothAdapter.STATE_OFF:
                messageId = R.string.bluetooth_empty_list_bluetooth_off;
                if (isUiRestricted()) {
                    messageId = R.string.bluetooth_empty_list_user_restricted;
                }
                break;

            case BluetoothAdapter.STATE_TURNING_ON:
                messageId = R.string.bluetooth_turning_on;
                mInitialScanStarted = false;
                break;
        }
        //由于蓝牙打开的case已经return,所以此处的逻辑就是其他case逻辑,移除所有列表内容,
        setDeviceListGroup(preferenceScreen);
        removeAllDevices();
        //显示提示文字
        mEmptyView.setText(messageId);
        if (!isUiRestricted()) {
            //重置了menu中某些按钮的可点击性
            getActivity().invalidateOptionsMenu();
        }
    }
  • onScanningStateChanged(),此接口参数boolean类型,true表示开始扫描,false表示结束扫描。在父类DeviceListPreferenceFragment中此接口处理了正在扫描时出现的BluetoothProgressCategory状态,在蓝牙设置页BluetoothSettings中则重置了menu中某些按钮的可用性。
    //蓝牙扫描状态变化
    @Override
    public void onScanningStateChanged(boolean started) {
        super.onScanningStateChanged(started);
        // Update options' enabled state
        if (getActivity() != null) {
            getActivity().invalidateOptionsMenu();
        }
    }
  • onDeviceAdd(),此接口参数是扫描到的蓝牙device。父类中判断device重复性及蓝牙状态后添加到合适的DeviceList(关于各种蓝牙状态下的DeviceList即该添加列表有何不同在下边会在setDeviceListGroup中介绍)中,并预留给子类initDevicePreference方法方便其处理绑定状态下的preference的监听控制。
    public void onDeviceAdded(CachedBluetoothDevice cachedDevice) {
        //当前列表中已存在此蓝牙设备
        if (mDevicePreferenceMap.get(cachedDevice) != null) {
            return;
        }
        //蓝牙当前不处于打开状态
        // Prevent updates while the list shows one of the state messages
        if (mLocalAdapter.getBluetoothState() != BluetoothAdapter.STATE_ON) return;
        //此处使用的是BluetoothDeviceFilter.ALL_FILTER,在其中matches方法总是返回true
        if (mFilter.matches(cachedDevice.getDevice())) {
            //根据device创建Preference
            createDevicePreference(cachedDevice);
        }
     }

    void createDevicePreference(CachedBluetoothDevice cachedDevice) {
        if (mDeviceListGroup == null) {
            Log.w(TAG, "Trying to create a device preference before the list group/category "
                    + "exists!");
            return;
        }
        //内部注册了一个callback用于通知每个preference设备状态发生变化,即会onDeviceAttributesChanged
        BluetoothDevicePreference preference = new BluetoothDevicePreference(
                getActivity(), cachedDevice);
        //子类BluetoothSettings负责实现
        initDevicePreference(preference);
        //
        mDeviceListGroup.addPreference(preference);
        mDevicePreferenceMap.put(cachedDevice, preference);
    }
  • onDeviceDeleted(),将保存在map中的device及列表中的preference删除。
    public void onDeviceDeleted(CachedBluetoothDevice cachedDevice) {
        BluetoothDevicePreference preference = mDevicePreferenceMap.remove(cachedDevice);
        if (preference != null) {
            mDeviceListGroup.removePreference(preference);
        }
    }
  • onDeviceBondStateChanged(),绑定状态发生变化,此接口在BluetoothSettings中实现,主要是清除列表数据,再根据bluetoothState去更新页面UI及重新扫描。
    //绑定状态变化
    public void onDeviceBondStateChanged(CachedBluetoothDevice cachedDevice, int bondState) {
        setDeviceListGroup(getPreferenceScreen());
        removeAllDevices();
        updateContent(mLocalAdapter.getBluetoothState());
    }

蓝牙列表点击 - onPreferenceTreeClick

  在PreferenceFragment中,其列表的Preference点击是在onPreferenceTreeClick中监听。在蓝牙设置页面,则其蓝牙列表点击事件主要是在父类DeviceListPreferenceFragment中。

    @Override
    public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen,
            Preference preference) {
        //一般指的是搜索preference--目前未在源代码中找到,但在oppo手机上是有的
        if (KEY_BT_SCAN.equals(preference.getKey())) {
            mLocalAdapter.startScanning(true);
            return true;
        }
        //如果点击的是蓝牙设备
        if (preference instanceof BluetoothDevicePreference) {
            BluetoothDevicePreference btPreference = (BluetoothDevicePreference) preference;
            CachedBluetoothDevice device = btPreference.getCachedDevice();
            mSelectedDevice = device.getDevice();
            //执行preference的onClicked方法,其内部判断当前被点击蓝牙设备的状态1.连接,2.历史配对过,3.配对流程
            onDevicePreferenceClick(btPreference);
            return true;
        }

        return super.onPreferenceTreeClick(preferenceScreen, preference);
    }

    void onDevicePreferenceClick(BluetoothDevicePreference btPreference) {
        btPreference.onClicked();
    }

  在点击监听中,先判断是否点击的是搜索Preference(此Preference未在源码中找到),是的话则执行扫描蓝牙操作。如果点击的是已搜索到的蓝牙设备(包括历史配对列表及范围内可配对列表),则是执行此BluetoothDevicePreference的onClicked方法,在onClicked方法中,则对当前点击的蓝牙设备的配对情况进行判断。

  1. 当前蓝牙设备已经处于连接状态(通过BluetoothProfile的getConnectionStatus判断),则询问是否取消连接。
  2. 如果当前设备是历史配对设备,则尝试进行连接(内部有一定的超时判断)。
  3. 如果当前蓝牙设备未曾配对过,则进入配对流程。此三种流程这里暂不铺开,放在下章。
    void onClicked() {
        int bondState = mCachedDevice.getBondState();
        //获取蓝牙设备的连接状态
        if (mCachedDevice.isConnected()) {
            askDisconnect();
            //匹配状态---历史配对设备
        } else if (bondState == BluetoothDevice.BOND_BONDED) {
            //调用各自的connect
            mCachedDevice.connect(true);
        } else if (bondState == BluetoothDevice.BOND_NONE) {
            //开始配对流程
            pair();
        }
    }

device的添加控制 - setDeviceListGroup

  在蓝牙设置页面列表中,区分为历史配对设备及范围内可配对设备,这两种设备分别对应不同的PreferenceCategory。在不同的状态下,需根据Preference的来源选择要添加的列表。而此处我们要介绍的方法setDeviceListGroup就起到了这种作用。

  1. 在进入蓝牙页面时调用的onResume方法中,必要会调用一次updateContent方法(此方法根据bluetoothState更新列表),在updateContent中如果是STATE_ON及蓝牙打开状态,则先清除根列表PreferenceScreen中所有的子Preference,然后通过方法addDeviceCategory()逐一添加mPairedDevicesCategory(历史配对列表)与mAvailableDevicesCategory(范围内可配对列表),并通过添加时的参数决定是否把缓存的CachedBluetoothDevice(历史配对设备)加载到mPairedDevicesCategory中。如果是其他STATE,则通过setDeviceListGroup设置列表为PreferenceScreen,并删除根列表下的所有内容。
    /**
     * 有两种类型的category可能被添加,1.历史配对设备,2.可配对设备
     * @param preferenceGroup 需被添加到根列表中
     * @param titleId 当前被添加的category的title
     * @param filter 过滤器,决定哪些蓝牙设备被添加,其通常是根据蓝牙状态过滤掉
     * @param addCachedDevices 是否添加历史配对列表
     */
    private void addDeviceCategory(PreferenceGroup preferenceGroup, int titleId,
            BluetoothDeviceFilter.Filter filter, boolean addCachedDevices) {
        preferenceGroup.setTitle(titleId);
        getPreferenceScreen().addPreference(preferenceGroup);
        setFilter(filter);
        setDeviceListGroup(preferenceGroup);
        if (addCachedDevices) {
            addCachedDevices();
        }
        preferenceGroup.setEnabled(true);
    }

  在上述addDeviceCategory方法中参数filter在添加mPairedDevicesCategory和mAvailableDevicesCategory的值分别为BluetoothDeviceFilter.BONDED_DEVICE_FILTER与BluetoothDeviceFilter.UNBONDED_DEVICE_FILTER,即历史配对列表中仅过滤已配对过的设备,mAvailableDevicesCategory则反之。

  1. 在点击扫描按钮或蓝牙设备状态变为STATE_ON时触发的startScanning(搜索蓝牙设备)方法时,在startScanning中会把可配对列表删掉,然后重新通过discover获取列表内容。
    private void startScanning() {
        if (isUiRestricted()) {
            return;
        }

        if (!mAvailableDevicesCategoryIsPresent) {
            getPreferenceScreen().addPreference(mAvailableDevicesCategory);
            mAvailableDevicesCategoryIsPresent = true;
        }

        if (mAvailableDevicesCategory != null) {
            setDeviceListGroup(mAvailableDevicesCategory);
            removeAllDevices();
        }
        //清除掉未绑定过的蓝牙设备
        mLocalManager.getCachedDeviceManager().clearNonBondedDevices();
        //可配对设备清除
        mAvailableDevicesCategory.removeAll();
        mInitialScanStarted = true;
        mLocalAdapter.startScanning(true);
    }
  1. 当配对情况发生变化,如用户点击可配对列表进行配对成功,或删除历史配对列表中的某设备,则会触发bondStateChange(即绑定-paired状态变化),在方法中删除根列表下所有内容,并通过updateContent加载历史配对列表+可配对列表。
    //绑定状态变化
    public void onDeviceBondStateChanged(CachedBluetoothDevice cachedDevice, int bondState) {
        setDeviceListGroup(getPreferenceScreen());
        removeAllDevices();
        updateContent(mLocalAdapter.getBluetoothState());
    }

  以上基本上就是蓝牙页面首页所涉及的内容。当然,蓝牙体系比较庞大,如Profile及配对等流程较费笔墨,就留在下篇文章分析吧!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值