FileUtils如何跨平台查找文件

感谢原作者的辛勤劳作:http://blog.csdn.net/Xiejingfa/article/details/50424730

在Cocos2d-x中,如果我们需要创建一个资源,只要调用静态函数Sprite::create(string filename)函数,引擎就会到Resources目录下找到相应的图片并为我们创建一个精灵实例。大家有没有想过,这个过程是怎么实现的呢?我们只是提供了图片的名称,Cocos2d-x怎么就知道我们所需要的图片在Resources目录下呢?为了解开这个疑惑,我们今天就来研究一下Cocos2d-x是怎么实现跨平台查找资源的。

使用的引擎版本是3.8,开发工具是VS2013,我们仍然通过追踪代码的形式学习。好,马上开始我们的揭秘之旅…


我们先从Sprite::create函数入手,首先找到该函数的定义:


代码1:

Sprite* Sprite::create(const std::string& filename)
{
    Sprite *sprite = new (std::nothrow) Sprite();
    if (sprite && sprite->initWithFile(filename))
    {
        sprite->autorelease();
        return sprite;
    }
    CC_SAFE_DELETE(sprite);
    return nullptr;
}
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

在代码1中,我们看到,Cocos2d-x使用了二段构造的方法:先new了一个Sprite对象,然后调用iniWithFile方法进行初始化,最后将创建的Sprite对象放入自动释放池中。

关于自动释放池的知识,涉及到Cocos2d-x的内存管理机制,如果有兴趣,可以看一下我们另一篇博客Cocos2d-x内存管理解析

这段代码中并没有说明Cocos2d-x是如何查找文件路径的,但显然,只有initWithFile函数用到了filename参数,所以,我们继续跳转到该函数的定义中:

代码2:

bool Sprite::initWithFile(const std::string& filename)
{
    CCASSERT(filename.size()>0, "Invalid filename for sprite");

    Texture2D *texture = Director::getInstance()->getTextureCache()->addImage(filename);
    if (texture)
    {
        Rect rect = Rect::ZERO;
        rect.size = texture->getContentSize();
        return initWithTexture(texture, rect);
    }

    // don't release here.
    // when load texture failed, it's better to get a "transparent" sprite then a crashed program
    // this->release();
    return false;
}

   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

在代码2中,依旧没有看到,这里还没有出现文件路径相关的信息。但如同前面分析一样,只有下面这个Director::getInstance()->getTextureCache()->addImage(filename)函数调用中使用到了前面传进来的filename调用,所以我们继续追踪查看addImage的定义

代码3:

Texture2D * TextureCache::addImage(const std::string &path)
{
    Texture2D * texture = nullptr;
    Image* image = nullptr;
    // Split up directory and filename
    // MUTEX:
    // Needed since addImageAsync calls this method from a different thread

    std::string fullpath = FileUtils::getInstance()->fullPathForFilename(path);
    if (fullpath.size() == 0)
    {
        return nullptr;
    }
    auto it = _textures.find(fullpath);
    if( it != _textures.end() )
        texture = it->second;

    ......

    return texture;
}

   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

在代码3中,addImage的代码太多,我把后面无关的代码略去。我们看到,这里终于出现了fullpath全路径的字眼,这很可能就是Cocos2d-x实现文件查找的关键所在。

别急着往下看,到这里,我们先暂停一下,来测试一下FileUtils::getInstance()->fullPathForFilename(path)返回的是什么信息。

具体测试方法:我们到HellowWorldScene.cpp的init函数中添加下面代码:

 // 路径测试
    std::string fullPath = FileUtils::getInstance()->fullPathForFilename("HelloWorld.png");
    log("%s", fullPath.c_str());
   
   
  • 1
  • 2
  • 3
  • 1
  • 2
  • 3

然后运行项目,可以看到输出一下信息:

这里写图片描述

我们可以看到,上面输出中D:/cocos2d-x-3.8/projects/CocosTest/proj.win32/Debug.win32/部分是Visual Studio项目的调试Debug目录,而HelloWorld.png则是我们指定的文件名称。

到这里,我们是不是几乎可以肯定FileUtils类中的fullPathForFilename方法通过文件名称来查找文件的全路径,来实现文件的跨平台查找。

为了进一步证实我们的猜想,我们继续来查看fullPathForFilename的实现。

为了帮助我们理解fullPathForFilename的工作流程,Cocos2d-x对该函数进行了详细的注释,如下:

代码4:

   /** Returns the fullpath for a given filename.

     First it will try to get a new filename from the "filenameLookup" dictionary.
     If a new filename can't be found on the dictionary, it will use the original filename.
     Then it will try to obtain the full path of the filename using the FileUtils search rules: resolutions, and search paths.
     The file search is based on the array element order of search paths and resolution directories.

     For instance:

         We set two elements("/mnt/sdcard/", "internal_dir/") to search paths vector by setSearchPaths,
         and set three elements("resources-ipadhd/", "resources-ipad/", "resources-iphonehd")
         to resolutions vector by setSearchResolutionsOrder. The "internal_dir" is relative to "Resources/".

        If we have a file named 'sprite.png', the mapping in fileLookup dictionary contains `key: sprite.png -> value: sprite.pvr.gz`.
         Firstly, it will replace 'sprite.png' with 'sprite.pvr.gz', then searching the file sprite.pvr.gz as follows:

             /mnt/sdcard/resources-ipadhd/sprite.pvr.gz      (if not found, search next)
             /mnt/sdcard/resources-ipad/sprite.pvr.gz        (if not found, search next)
             /mnt/sdcard/resources-iphonehd/sprite.pvr.gz    (if not found, search next)
             /mnt/sdcard/sprite.pvr.gz                       (if not found, search next)
             internal_dir/resources-ipadhd/sprite.pvr.gz     (if not found, search next)
             internal_dir/resources-ipad/sprite.pvr.gz       (if not found, search next)
             internal_dir/resources-iphonehd/sprite.pvr.gz   (if not found, search next)
             internal_dir/sprite.pvr.gz                      (if not found, return "sprite.png")

        If the filename contains relative path like "gamescene/uilayer/sprite.png",
        and the mapping in fileLookup dictionary contains `key: gamescene/uilayer/sprite.png -> value: gamescene/uilayer/sprite.pvr.gz`.
        The file search order will be:

             /mnt/sdcard/gamescene/uilayer/resources-ipadhd/sprite.pvr.gz      (if not found, search next)
             /mnt/sdcard/gamescene/uilayer/resources-ipad/sprite.pvr.gz        (if not found, search next)
             /mnt/sdcard/gamescene/uilayer/resources-iphonehd/sprite.pvr.gz    (if not found, search next)
             /mnt/sdcard/gamescene/uilayer/sprite.pvr.gz                       (if not found, search next)
             internal_dir/gamescene/uilayer/resources-ipadhd/sprite.pvr.gz     (if not found, search next)
             internal_dir/gamescene/uilayer/resources-ipad/sprite.pvr.gz       (if not found, search next)
             internal_dir/gamescene/uilayer/resources-iphonehd/sprite.pvr.gz   (if not found, search next)
             internal_dir/gamescene/uilayer/sprite.pvr.gz                      (if not found, return "gamescene/uilayer/sprite.png")

     If the new file can't be found on the file system, it will return the parameter filename directly.

     This method was added to simplify multiplatform support. Whether you are using cocos2d-js or any cross-compilation toolchain like StellaSDK or Apportable,
     you might need to load different resources for a given file in the different platforms.

     @since v2.1
     */
    virtual std::string fullPathForFilename(const std::string &filename) const;
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46

在代码4中,我们看到fullPathForFilename函数的作用就是根据给定文件名返回其全路径fullpath。下面,我们就结合注释和具体实现来讲解一下Cocos2d-x是如何查找文件路径的。

1、首先尝试在缓存_fullPathCache中是否能找到该文件的路径

在游戏开发过程中,经常会遇到用同一张图片创建精灵实例的需求。我们想一下,如果每次创建一个精灵我们都要查找一次图片的全路径,这样是不是做了很多无用功?所以Cocos2d-x早就为我们考虑到这一点。引擎利用一个名叫_fullPathCache的成员变量把之前查找过的图片的全路径缓存起来,以后如果用到了同一张图片,则直接从_fullPathCache返回其路径,从而提高游戏的运行效率。

_fullPathCache的定义如下:

代码6:

 /**
     *  The full path cache. When a file is found, it will be added into this cache.
     *  This variable is used for improving the performance of file search.
     */
    mutable std::unordered_map<std::string, std::string> _fullPathCache;
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

在代码6中,我们看到_fullPathCache实质就是一个unordered_map,并没有什么神秘的。

当调用fullPathForFilename函数时,Cocos2d-x总是先看看该文件是不是已经在加载到_fullPathCache。你是不是已经想到它是怎么查找的了?没错,就是利用unordered_map的find方法,具体如下:

代码7:

 // Already Cached ?
 auto cacheIter = _fullPathCache.find(filename);
 if(cacheIter != _fullPathCache.end())
 {
     return cacheIter->second;
 }
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

如何该文件的路径已经存在在_fullPathCache中,就直接返回。如果没有找到呢?那么Cocos2d-x就尝试下面的操作。

2、结合_searchPathArray和_searchResolutionsOrderArray到相关目录下查找

具体过程是:假设我们通过setSearchPaths函数往_searchPathArray添加了两个元素(“/mnt/sdcard/”, “internal_dir/”),还通过setSearchResolutionsOrder函数往_searchResolutionsOrderArray添加了三个元素(“resources-ipadhd/”, “resources-ipad/”, “resources-iphonehd”)。对于给定一张名叫“sprite.png”的图片,在_filenameLookupDict中存在这样一个键值对:key: sprite.png -> value: sprite.pvr.gz,Cocos2d-x就会按照下面的顺序查找:

 /mnt/sdcard/resources-ipadhd/sprite.pvr.gz      (if not found, search next)
             /mnt/sdcard/resources-ipad/sprite.pvr.gz        (if not found, search next)
             /mnt/sdcard/resources-iphonehd/sprite.pvr.gz    (if not found, search next)
             /mnt/sdcard/sprite.pvr.gz                       (if not found, search next)
             internal_dir/resources-ipadhd/sprite.pvr.gz     (if not found, search next)
             internal_dir/resources-ipad/sprite.pvr.gz       (if not found, search next)
             internal_dir/resources-iphonehd/sprite.pvr.gz   (if not found, search next)
             internal_dir/sprite.pvr.gz                      (if not found, return "sprite.png")
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

如何找到对应的文件,则直接返回该目录,否则返回一个空字符串。

上面的操作中涉及到了两个新的成员变量:_searchPathArray和_searchResolutionsOrderArray,我们先来看看Cocos2d-x源码对这两个变量的解释。

代码7:

   /**
     * The vector contains search paths.
     * The lower index of the element in this vector, the higher priority for this search path.
     */
    std::vector<std::string> _searchPathArray;
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

代码7显示_searchPathArray代表一个查找路径的列表,而且索引值越小,优先级越高。暂时我们也不知道它是干什么用的。

代码8:

/**
  *  The vector contains resolution folders.
  *  The lower index of the element in this vector, the higher priority for this resolution directory.
  */
  std::vector<std::string> _searchResolutionsOrderArray;
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

代码8显示_searchResolutionsOrderArray是包含“分辨率文件夹”的列表,并且也是索引值越小,优先级越高。

对于_searchResolutionsOrderArray的作用则直观一点,一般游戏都需要视频不同分辨率的手机屏幕,_searchResolutionsOrderArray把不同分辨率的图片资源组织到不同的文件夹下,方便游戏的开发。

那_searchPathArray有事做什么用的呢?从它的注释上也没能得到什么有效的信息。别急,我们先做一个简单的测试,在HelloWorldScene.cpp中加入下面代码,把其内容打印出来。

代码9:

log("Search Paths Array: ");
    std::vector<std::string> searchPaths = FileUtils::getInstance()->getSearchPaths();
    for (int i = 0; i < searchPaths.size(); i++)
        log("\t%d => %s", i, searchPaths.at(i).c_str());

    log("\n Search Resolution Order Array:");
    std::vector<std::string> searchResolutionOrderArray = FileUtils::getInstance()->getSearchResolutionsOrder();
    for (int j = 0; j < searchResolutionOrderArray.size(); j++)
        log("\t%d => %s", j, searchResolutionOrderArray.at(j).c_str());
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

输出结果是这样的:

这里写图片描述

在这个测试案例中,_searchPathArray中包含一个元素,指向Win32程序的Debug目录。(值得注意的是:我们虽然把图片放在Resources目录下,但是,当发布到不同平台的时候,Cocos2d-x会把相应的文件拷贝到平台对应的目录下,Class目录也是这样)。由于我们没有适配不同分辨率的设备,_searchResolutionsOrderArray默认只有一个空字符串。

所以到这里我们就清楚了,_searchPathArray表示不同平台下放置图片资源的根查找目录,这里,我们不妨称它为“资源根目录”。而_searchResolutionsOrderArray对应相关分辨率目录,结合图片名称filename,Cocos2d-x构造了这样的文件路径:

_searchPathArray[i] + _searchResolutionsOrderArray[j] + filename
   
   
  • 1
  • 1

紧接着,Cocos2d-x就到Resources下判断该文件是否存在。

我们到源码中求证一下,下面是fullPathForFilename函数的部分源码

代码10:

 // Get the new file name.
    const std::string newFilename( getNewFilename(filename) );

    std::string fullpath;

    for (const auto& searchIt : _searchPathArray)
    {
        for (const auto& resolutionIt : _searchResolutionsOrderArray)
        {
            fullpath = this->getPathForFilename(newFilename, resolutionIt, searchIt);

            if (fullpath.length() > 0)
            {
                // Using the filename passed in as key.
                _fullPathCache.insert(std::make_pair(filename, fullpath));
                return fullpath;
            }
        }
    }

   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

源码面前,了无秘密,无须再解释!

另外,我们看到,Cocos2d-x查找出一个文件的路径后,会把它添加到_fullPathCache中缓存起来。

解决了这个问题是不是就分析完毕了呢?我们还剩下一个问题没有搞清楚:_searchPathArray为什么初始化时就能找到对应平台“资源根目录”呢?

到哪里去查找答案呢?我们不妨大胆猜测一下,一般都会在构造函数或初始化函数中对相关的成员变量初始化。对于FileUtils,它只有一个实例,我们跳转到getInstance函数。

代码10:

FileUtils* FileUtils::getInstance()
{
    if (s_sharedFileUtils == nullptr)
    {
        s_sharedFileUtils = new FileUtilsWin32();
        if(!s_sharedFileUtils->init())
        {
          delete s_sharedFileUtils;
          s_sharedFileUtils = nullptr;
          CCLOG("ERROR: Could not init CCFileUtilsWin32");
        }
    }
    return s_sharedFileUtils;
}
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

不看不知道,一看“吓一跳”,这个函数竟然定义再CCFileUtils-win32.cpp文件中,而不是FileUtils.cpp文件中!

在代码10中,getInstance返回的是一个FileUtilsApple实例,说明我们已经来到了平台相关的代码中。

class CC_DLL FileUtilsWin32 : public FileUtils
   
   
  • 1
  • 1

从FileUtilsWin32定义中可以看到,它是FileUtils的一个子类,涉及到了具体的某个平台。

我们继续查看init函数:

代码11:

bool FileUtilsWin32::init()
{
    _checkPath();
    _defaultResRootPath = s_resourcePath;
    return FileUtils::init();
}
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

在代码11中,FileUtilsWin32实例先执行自身的代码,最后调用父类FileUtils的init函数,该函数定义如下:

代码12:

bool FileUtils::init()
{
    _searchPathArray.push_back(_defaultResRootPath);
    _searchResolutionsOrderArray.push_back("");
    return true;
}
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

在代码12中,我们终于找到了_searchPathArray和_searchResolutionsOrderArray的初始化!但是我们有遇到了一个陌生的变量_defaultResRootPath,继续转到该变量的定义:

代码13:

/**
     *  The default root path of resources.
     *  If the default root path of resources needs to be changed, do it in the `init` method of FileUtils's subclass.
     *  For instance:
     *  On Android, the default root path of resources will be assigned with "assets/" in FileUtilsAndroid::init().
     *  Similarly on Blackberry, we assign "app/native/Resources/" to this variable in FileUtilsBlackberry::init().
     */
    std::string _defaultResRootPath;
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

代码12显示,_defaultResRootPath就是之前我们说的默认的“资源根目录”。而且,注释中明确说明,_defaultResRootPath的值跟具体的平台相关,所以我们应该到FileUtils-win32中查找Win32平台默认的_defaultResRootPath值。

继续回到上面FileUtils-win32的init函数中,也就是代码11中:

_checkPath();
_defaultResRootPath = s_resourcePath;
   
   
  • 1
  • 2
  • 1
  • 2

这两句代码为我们指明了方向,我们转到_checkPath的定义中:

代码14:

static void _checkPath()
{
    if (0 == s_resourcePath.length())
    {
        WCHAR *pUtf16ExePath = nullptr;
        _get_wpgmptr(&pUtf16ExePath);

        // We need only directory part without exe
        WCHAR *pUtf16DirEnd = wcsrchr(pUtf16ExePath, L'\\');

        char utf8ExeDir[CC_MAX_PATH] = { 0 };
        int nNum = WideCharToMultiByte(CP_UTF8, 0, pUtf16ExePath, pUtf16DirEnd-pUtf16ExePath+1, utf8ExeDir, sizeof(utf8ExeDir), nullptr, nullptr);

        s_resourcePath = convertPathFormatToUnixStyle(utf8ExeDir);
    }
}
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

在代码14中,_checkPath是一个静态函数,我们终于看到了Win32平台获取路径的实现。我们可以看到,_checkPath的结果保存在s_resourcePath变量中,s_resourcePath变量又被用来初始化了defaultResRootPath变量。

除此之外,我们还看到FileUtils-win32类中还根据Win32的API重写了父类getPathForFilename、removeFile等文件操作函数。

到此为止,我们终于找到了设置资源路径的函数,明白了_searchPathArray和_searchResolutionsOrderArray是何时,如何被初始化的。大家有没有猜到,Win32平台有FileUtils-win32类实现,那AndroidiOS这些平台有没有对应的FileUtils子类呢?答案是肯定的,这里就不带大家一一分析,感兴趣的童鞋可以自己去看看源码。


到这里,我们是否能理解Cocos2d-x的巧妙设计?我们来总结一下Cocos2d-x是如何实现跨平台查找文件的:

  1. 为了实现跨平台,引擎先抽象出一个父类FileUtils,并定义了s_sharedFileUtils 单例对象。
  2. 每一个不同的平台都对应了一个名叫FileUtils-xxx的子类,s_sharedFileUtils指向的是该子类的实例对象。在FileUtils-xxx的init函数中,产生该平台对应的资源路径,并添加到_searchPathArray变量中。
  3. FileUtils-xxx还根据目标平台的特点重写FileUtils函数中平台相关的文件操作函数。这些函数在FileUtils中被定义为virtual function,根据多台的原理,会调用FileUtils-xxx子类的对应函数。
感谢原作者的辛勤劳作:
http://blog.csdn.net/Xiejingfa/article/details/50424730

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值