DownloadManager源码分析及暂停下载、继续下载功能添加

 

Android系统中有提供一个下载工具给第三方开发者使用,开发者只需要简单的几个步骤就可以完成下载文件的功能。那就是DownloadManager,为了更好地使用这个工具,得先理解它的工作原理、工作流程。下面就使用DownloadManager进行文件下载的流程进行源码的分析。

 

 

 

下面是整个工作流程的一个时序图:

 

 

 

 

从上面的时序图我们可以大致了解整个流程。从添加请求,到最后开启下载线程进行文件的下载。为了更好的理解这个下载工具的思想,下面会从源码上对一些重要的函数进行分析。

 

**********************************************************************************************************************************************************************

 

 

 

一开始,调用DownloadManager的enqueue()方法进行下载请求的添加,然后就会调用DownloadProvider的insert()方法进行数据库的数据的插入,insert()方法不单单是把数据插入到数据库,还会启动DownloadService这个服务。

代码如下:

@Override
public Uri insert(final Uri uri, final ContentValues values) {
    checkInsertPermissions(values);
    SQLiteDatabase db = mOpenHelper.getWritableDatabase();

    // note we disallow inserting into ALL_DOWNLOADS
       if (pckg != null && (clazz != null || isPublicApi)) {
        int uid = Binder.getCallingUid();
        try {
            if (uid == 0 || mSystemFacade.userOwnsPackage(uid, pckg)) {
                filteredValues.put(Downloads.COLUMN_NOTIFICATION_PACKAGE,
                        pckg);
                if (clazz != null) {
                    filteredValues.put(Downloads.COLUMN_NOTIFICATION_CLASS,
                            clazz);
                }
            }
        } catch (PackageManager.NameNotFoundException ex) {
    /* ignored for now */
        }
    }
    copyString(Downloads.COLUMN_NOTIFICATION_EXTRAS, values, filteredValues);
    copyString(Downloads.COLUMN_COOKIE_DATA, values, filteredValues);
    copyString(Downloads.COLUMN_USER_AGENT, values, filteredValues);
    copyString(Downloads.COLUMN_REFERER, values, filteredValues);
    if (getContext().checkCallingPermission(
            Downloads.PERMISSION_ACCESS_ADVANCED) == PackageManager.PERMISSION_GRANTED) {
        copyInteger(Downloads.COLUMN_OTHER_UID, values, filteredValues);
    }
    filteredValues.put(Constants.UID, Binder.getCallingUid());
    if (Binder.getCallingUid() == 0) {
        copyInteger(Constants.UID, values, filteredValues);
    }
    copyStringWithDefault(Downloads.COLUMN_TITLE, values, filteredValues,
            "");
    copyStringWithDefault(Downloads.COLUMN_DESCRIPTION, values,
            filteredValues, "");
    filteredValues.put(Downloads.COLUMN_TOTAL_BYTES, -1);
    filteredValues.put(Downloads.COLUMN_CURRENT_BYTES, 0);


    Context context = getContext();
    context.startService(new Intent(context, DownloadService.class));

    long rowID = db.insert(DB_TABLE, null, filteredValues);
    if (rowID == -1) {
        Log.d(Constants.TAG, "couldn't insert into downloads database");
        return null;
    }

    insertRequestHeaders(db, rowID, values);
    context.startService(new Intent(context, DownloadService.class));
    notifyContentChanged(uri, match);
    return ContentUris.withAppendedId(Downloads.CONTENT_URI, rowID);
}

 

可以看到上面的代码很长,但是我们只需要关注一些核心的代码。

 

long rowID = db.insert(DB_TABLE, null, filteredValues);

这行代码的作用主要是把下载的任务信息保存到数据库中,包括下载的URL、下载的控制状态、下载状态、总的文件大小、已下载的文件大小等默认的数据更新到数据库中。

 

context.startService(new Intent(context, DownloadService.class));

这行代码的作用就是启动DownloadService服务。

 

************************************************************************************************************************************************************************************

 

 

 

 

当我们启动DownloadService之后,DownloadService服务的onCreate()函数就会被调用。

public void onCreate() {
super.onCreate();
if (Constants.LOGVV) {
    Log.v(Constants.TAG, "Service onCreate");
}

if (mSystemFacade == null) {
    mSystemFacade = new RealSystemFacade(this);
}

mObserver = new DownloadManagerContentObserver();
getContentResolver().registerContentObserver(
   Downloads.ALL_DOWNLOADS_CONTENT_URI, true, mObserver);

mNotifier = new DownloadNotification(this, mSystemFacade);
mSystemFacade.cancelAllNotifications();

updateFromProvider();
   }

 

可以看到,在onCreate()函数中,会注册一个数据库变化监听器DownloadManagerContentObserver,就是说Downloads.ALL_DOWNLOADS_CONTENT_URI这个数据库的数据发生变化的时候,该监听器的监听函数onChange()就会被调用。

 

 

 

public void onChange(final boolean selfChange) {
    if (Constants.LOGVV) {
   Log.v(Constants.TAG,
      "Service ContentObserver received notification");
    }
    updateFromProvider();
}

 

 

 

 

可以看到onChange()函数会调用updateFromProvider()这个函数,从上面可以看到,onCreate()函数也会调到这个函数。

 

 

private void updateFromProvider() {
synchronized (this) {
    mPendingUpdate = true;
    if (mUpdateThread == null) {
   mUpdateThread = new UpdateThread();
   mSystemFacade.startThread(mUpdateThread);
    }
}
   }

 

 

可以看到,updateFormProvider()函数其实就是会启动UpdateThread()这个线程。

 

 

 

*********************************************************************************************************************************************************************************

 

 

 

下面就进入到UpdateThread这个线程中。

public void run() {
    Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);

    trimDatabase();
    removeSpuriousFiles();

    boolean keepService = false;
    // for each update from the database, remember which download is
    // supposed to get restarted soonest in the future
    long wakeUp = Long.MAX_VALUE;
    for (;;) {
   synchronized (DownloadService.this) {
       if (mUpdateThread != this) {
      throw new IllegalStateException(
         "multiple UpdateThreads in DownloadService");
       }
       if (!mPendingUpdate) {
      mUpdateThread = null;
      if (!keepService) {
          stopSelf();
      }
      if (wakeUp != Long.MAX_VALUE) {
          scheduleAlarm(wakeUp);
      }
      return;
       }
       mPendingUpdate = false;
   }

   long now = mSystemFacade.currentTimeMillis();
   keepService = false;
   wakeUp = Long.MAX_VALUE;
   Set<Long> idsNoLongerInDatabase = new HashSet<Long>(
      mDownloads.keySet());

   Cursor cursor = getContentResolver().query(
      Downloads.ALL_DOWNLOADS_CONTENT_URI, null, null, null,
      null);
   if (cursor == null) {
       continue;
   }
   try {
       DownloadInfo.Reader reader = new DownloadInfo.Reader(
          getContentResolver(), cursor);
       int idColumn = cursor.getColumnIndexOrThrow(Downloads._ID);

       for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor
          .moveToNext()) {
      long id = cursor.getLong(idColumn);
      idsNoLongerInDatabase.remove(id);
      DownloadInfo info = mDownloads.get(id);
      if (info != null) {
          updateDownload(reader, info, now);
      } else {
          info = insertDownload(reader, now);
      }
      if (info.hasCompletionNotification()) {
          keepService = true;
      }
      long next = info.nextAction(now);
      if (next == 0) {
          keepService = true;
      } else if (next > 0 && next < wakeUp) {
          wakeUp = next;
      }
       }
   } finally {
       cursor.close();
   }

   for (Long id : idsNoLongerInDatabase) {
       deleteDownload(id);
   }

   // is there a need to start the DownloadService? yes, if there
   // are rows to be deleted.

   for (DownloadInfo info : mDownloads.values()) {
       if (info.mDeleted) {
      keepService = true;
      break;
       }
   }

   mNotifier.updateNotification(mDownloads.values());

   // look for all rows with deleted flag set and delete the rows
   // from the database
   // permanently
   for (DownloadInfo info : mDownloads.values()) {
       if (info.mDeleted) {
      Helpers.deleteFile(getContentResolver(), info.mId,
         info.mFileName, info.mMimeType);
       }
   }
    }
}

 

可以看到UpdateThread这个线程也是很长,我们大概分析一下它的作用。

 

可以看到这里有一个for(;;)的死循环,它的作用是保证数据库中的下载任务都会被加载出来,然后启动所有的下载任务,同时会更新下载任务,包括更新下载任务的状态,删除一些下载任务。

它会从数据库中取出所有的下载任务,然后根据id从mDownloads集合中找到对应的下载任务,如果该任务已存在,则调用updateDownload()进行下载信息的更新,并启动下载任务;如果该任务没有执行过,则会调用insertDownload()方法插入下载信息,并启动下载任务。

 

 

 

 

private void updateDownload(DownloadInfo.Reader reader, DownloadInfo info,
    long now) {
int oldVisibility = info.mVisibility;
int oldStatus = info.mStatus;

reader.updateFromDatabase(info);

boolean lostVisibility = oldVisibility == Downloads.VISIBILITY_VISIBLE_NOTIFY_COMPLETED
   && info.mVisibility != Downloads.VISIBILITY_VISIBLE_NOTIFY_COMPLETED
   && Downloads.isStatusCompleted(info.mStatus);
boolean justCompleted = !Downloads.isStatusCompleted(oldStatus)
   && Downloads.isStatusCompleted(info.mStatus);
if (lostVisibility || justCompleted) {
    mSystemFacade.cancelNotification(info.mId);
}

info.startIfReady(now);
   }

 

 

 

可以看到该函数调用startIfReady()进行下载任务的启动。

 

 

 

*********************************************************************************************************************************************************

 

 

void startIfReady(long now) {
    if (!isReadyToStart(now)) {
        return;
    }

    if (Constants.LOGV) {
        Log.v(Constants.TAG, "Service spawning thread to handle download " + mId);
    }
    if (mHasActiveThread) {
        throw new IllegalStateException("Multiple threads on same download");
    }
    if (mStatus != Downloads.STATUS_RUNNING) {
        mStatus = Downloads.STATUS_RUNNING;
        ContentValues values = new ContentValues();
        values.put(Downloads.COLUMN_STATUS, mStatus);
        mContext.getContentResolver().update(getAllDownloadsUri(), values, null, null);
        return;
    }
    DownloadThread downloader = new DownloadThread(mContext, mSystemFacade, this);
    mHasActiveThread = true;
    mSystemFacade.startThread(downloader);
}

 

 

该函数是一个挺重要的函数,它会根据不同的情况判断下载任务是否需要启动。

判断函数是isReadyToStart。这个函数十分关键,在我们要实现暂停下载,继续下载这个功能,都是在这里起作用的。

private boolean isReadyToStart(long now) {
    if (mHasActiveThread) {
        // already running
        return false;
    }
    if (mControl == Downloads.CONTROL_PAUSED) {
        // the download is paused, so it's not going to start
        return false;
    }
    switch (mStatus) {
        case 0: // status hasn't been initialized yet, this is a new download
        case Downloads.STATUS_PENDING: // download is explicit marked as ready to start
        case Downloads.STATUS_RUNNING: // download interrupted (process killed etc) while
                                            // running, without a chance to update the database
            return true;

        case Downloads.STATUS_WAITING_FOR_NETWORK:
        case Downloads.STATUS_QUEUED_FOR_WIFI:
            return checkCanUseNetwork() == NETWORK_OK;

        case Downloads.STATUS_WAITING_TO_RETRY:
            // download was waiting for a delayed restart
            return restartTime(now) <= now;
    }
    return false;
}

 

可以看到,当下载任务正在进行,或者下载任务状态为暂停状态,或者网络状态是否正常,这时会返回false,就是没有准备好,就不会启动下载任务。

 

当返回true的时候,就会把当前下载任务的状态刷新为Downloads.STATUS_RUNNING,同时会启动DownloadThread下载线程。

 

 

 

*********************************************************************************************************************************************************************

 

 

 

public void run() {
    Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);

    State state = new State(mInfo);
    PowerManager.WakeLock wakeLock = null;
    int finalStatus = Downloads.STATUS_UNKNOWN_ERROR;

    try {
        PowerManager pm = (PowerManager) mContext
                .getSystemService(Context.POWER_SERVICE);
        wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
                Constants.TAG);
        wakeLock.acquire();

        if (Constants.LOGV) {
            Log.v(Constants.TAG, "initiating download for " + mInfo.mUri);
        }


        boolean finished = false;
        while (!finished) {
            Log.i(Constants.TAG, "Initiating request for download "
                    + mInfo.mId);


            Request.Builder requestBuilder = new Request.Builder();
            InnerState innerState = new InnerState();
            setupDestinationFile(state, innerState);
            addRequestHeaders(innerState, requestBuilder);
            requestBuilder.url(state.mRequestUri);

            Request request = requestBuilder.build();
            Call call = mOkHttpClient.newCall(request);
            try {
                executeDownload(innerState, state, call);
                finished = true;
            } catch (RetryDownload exc) {
                // fall through
            } finally {
                call.cancel();
            }
        }

        if (Constants.LOGV) {
            Log.v(Constants.TAG, "download completed for " + mInfo.mUri);
        }
        finalizeDestinationFile(state);
        finalStatus = Downloads.STATUS_SUCCESS;
    } catch (StopRequest error) {
        // remove the cause before printing, in case it contains PII
        Log.w(Constants.TAG, "Aborting request for download " + mInfo.mId
                + ": " + error.getMessage());
        finalStatus = error.mFinalStatus;
        // fall through to finally block
    } catch (Throwable ex) { // sometimes the socket code throws unchecked
        // exceptions
        Log.w(Constants.TAG, "Exception for id " + mInfo.mId + ": " + ex);
        finalStatus = Downloads.STATUS_UNKNOWN_ERROR;
        // falls through to the code that reports an error
    } finally {
        if (wakeLock != null) {
            wakeLock.release();
            wakeLock = null;
        }
        if (mOkHttpClient != null) {
            mOkHttpClient.cancel(null);
        }
        cleanupDestination(state, finalStatus);
        notifyDownloadCompleted(finalStatus, state.mCountRetry,
                state.mRetryAfter, state.mGotData, state.mFilename,
                state.mNewUri, state.mMimeType);
        mInfo.mHasActiveThread = false;
    }
}

 

 

 

 

 

 

setupDestinationFile(state, innerState);

 

这个方法是实现断点续传的关键点。

private void setupDestinationFile(State state, InnerState innerState)
        throws StopRequest {
    if (!TextUtils.isEmpty(state.mFilename)) { // only true if we've already
        // run a thread for this
        // download
        if (!Helpers.isFilenameValid(state.mFilename)) {
            // this should never happen
            throw new StopRequest(Downloads.STATUS_FILE_ERROR,
                    "found invalid internal destination filename");
        }
        // We're resuming a download that got interrupted
        File f = new File(state.mFilename);
        if (f.exists()) {
            long fileLength = f.length();
            if (fileLength == 0) {
                // The download hadn't actually started, we can restart from
                // scratch
                f.delete();
                state.mFilename = null;
            } else if (mInfo.mETag == null && !mInfo.mNoIntegrity) {
                // This should've been caught upon failure
                f.delete();
                throw new StopRequest(Downloads.STATUS_CANNOT_RESUME,
                        "Trying to resume a download that can't be resumed");
            } else {
                // All right, we'll be able to resume this download
                try {
                    state.mStream = new FileOutputStream(state.mFilename,
                            true);
                } catch (FileNotFoundException exc) {
                    throw new StopRequest(Downloads.STATUS_FILE_ERROR,
                            "while opening destination for resuming: "
                                    + exc.toString(), exc);
                }
                innerState.mBytesSoFar = (int) fileLength;
                if (mInfo.mTotalBytes != -1) {
                    innerState.mHeaderContentLength = Long
                            .toString(mInfo.mTotalBytes);
                }
                innerState.mHeaderETag = mInfo.mETag;
                innerState.mContinuingDownload = true;
            }
        }
    }

    if (state.mStream != null
            && mInfo.mDestination == Downloads.DESTINATION_EXTERNAL) {
        closeDestination(state);
    }
}

 

方法的流程大概是:先根据文件名建立一个文件对象,判断文件对象是否存在,如果存在再判断文件的大小,当文件大小为0的时候,把文件删除。

 

同时,会把当前的文件的输出流保存到state.mStream,把当前文件的长度、要下载文件的总长度、文件继续下载状态保存到innerState中。

 

 

再分析addRequestHeader()方法,该方法也是实现断点续传的关键。

 

private void addRequestHeaders(InnerState innerState, Request.Builder requestBuilder) {
    for (Pair<String, String> header : mInfo.getHeaders()) {
        requestBuilder.addHeader(header.first, header.second);
    }

    if (innerState.mContinuingDownload) {
        if (innerState.mHeaderETag != null) {
            requestBuilder.addHeader("If-Match", mInfo.mETag);
        }
        requestBuilder.addHeader("Range", "bytes=" + innerState.mBytesSoFar + "-");
    }
}

 

 

 

该方法会把请求头添加到请求中,最重要的是:如果是断点续传的话,会把当前的文件大小也放到请求头中,这样服务器就会知道当前的文件已经下载了多少。

 

 

 

 

下面来分析最重要的executeDownload方法。

 

 

 

private void executeDownload(InnerState innerState, State state, Call call) throws StopRequest, RetryDownload, IOException {
    byte data[] = new byte[Constants.BUFFER_SIZE];


    // check just before sending the request to avoid using an invalid
    // connection at all
    checkConnectivity(state);

    Response response = call.execute();
    handleExceptionalStatus(state, innerState, response);

    if (Constants.LOGV) {
        Log.v(Constants.TAG, "received response for " + mInfo.mUri);
    }

    processResponseHeaders(state, innerState, response);
    InputStream entityStream = openResponseEntity(state, response);
    transferData(state, innerState, data, entityStream);
}

 

 

 

可以看到这个下载工具用到okhttp这个开源库进行网络的请求。使用okhttp得到了Response对象。

 

 

 

先分析processResponseHeaders()这个方法,这个方法中会获取Http请求的header,同时,根据这次下载是否为断点下载,如果是则返回,如果不是,则会把要下载的文件的输入流对象保存到state.mStream变量中。

 

 

 

再分析openResponseEntity()这个方法。

private InputStream openResponseEntity(State state, Response response)
        throws StopRequest {
    try {
        return response.body().byteStream();
    } catch (IOException ex) {
        logNetworkState();
        throw new StopRequest(getFinalStatusForHttpError(state),
                "while getting entity: " + ex.toString(), ex);
    }
}

 

该方法是获取Response对象的输出流变量。

 

 

最后是transferData()方法。

 

 

private void transferData(State state, InnerState innerState, byte[] data,
                          InputStream entityStream) throws StopRequest {
    for (; ; ) {
        int bytesRead = readFromResponse(state, innerState, data,
                entityStream);
        if (bytesRead == -1) { // success, end of stream already reached
            handleEndOfStream(state, innerState);
            return;
        }

        state.mGotData = true;
        writeDataToDestination(state, data, bytesRead);
        innerState.mBytesSoFar += bytesRead;
        reportProgress(state, innerState);

        if (Constants.LOGVV) {
            Log.v(Constants.TAG, "downloaded " + innerState.mBytesSoFar
                    + " for " + mInfo.mUri);
        }

        checkPausedOrCanceled(state);
    }
}

 

可以看到,这是一个for(;;)死循环,用于读取下载的文件流。

先调用readFromResponse()函数,从文件输出流中读取数据,保存到data字节数组中。

然后调用writeDataToDestination()函数,把data字节数组中的数据写到本地的文件中。

然后调用reportProgress()函数,把已下载的文件的大小更新到数据库中。用于更新进度条的显示。

可以看到checkPauseOrCanceled()函数。这是实现暂停下载的关键函数。

private void checkPausedOrCanceled(State state) throws StopRequest {
    synchronized (mInfo) {
        if (mInfo.mControl == Downloads.CONTROL_PAUSED) {
            throw new StopRequest(Downloads.STATUS_PAUSED_BY_APP,
                    "download paused by owner");
        }
    }
    if (mInfo.mStatus == Downloads.STATUS_CANCELED) {
        throw new StopRequest(Downloads.STATUS_CANCELED,
                "download canceled");
    }
}

 

 

 

 

可以分析,这里会根据下载任务的当前状态进行判断,如果当前的任务状态被更改为Downloads.CONTROL_PAUSED时,就会抛出StopRequest的异常,当前的文件下载就会被终止,这样就可以实现暂停下载了。

 

到此为止,DownloadManager下载的整个流程就分析完了。

 

 

 

**************************************************************************************************************************************************************

 

 

DownloadManager的扩展使用

      通过上面的分析,我们几乎理解了DownloadManager的整个工作流程。在我们下载文件的时候,我们几乎都是需要暂停下载和继续下载还有断点续传的功能。DownloadProvider代码是可以让我们能够实现这个功能了。

      实现断点续传的原理其实就是我们每次添加下载任务,都会把任务的信息保存到数据库中,包括下载的URL,已下载的文件大小,总的文件大小。下次我们再进行下载的时候,把已下载的大小传到服务器中,就可以从上一次已下载的文件的基础上继续下载,就可以实现断点下载了。

      暂停下载和继续下载的实现,其实只需要更新下载任务的状态就可以实现了。因为从上面的下载可以知道,在下载文件的过程中,都会检验当前的下载任务的状态,若是暂停状态,就会停止下载,跳出死循环。当我们再次改变状态为继续下载时,下载任务会被再次启动。

具体实现可以参考这个博客:

http://www.trinea.cn/android/android-downloadmanager-pro/

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Android开发中,我们可以利用DownloadManager类实现下载功能DownloadManagerAndroid系统提供的一个用于管理下载任务的类,它负责处理下载请求,管理和控制下载任务的状态。 实现下载功能的步骤如下: 1. 创建DownloadManager对象: ``` DownloadManager downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE); ``` 2. 构建下载请求: ``` DownloadManager.Request request = new DownloadManager.Request(Uri.parse(downloadUrl)); request.setTitle("文件名"); // 设置下载文件的标题 request.setDescription("下载中"); // 设置下载的描述信息 request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE); // 设置下载通知的可见性 request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "文件名"); // 设置下载文件的保存路径和文件名 ``` 3. 添加下载请求到DownloadManager队列中: ``` long downloadId = downloadManager.enqueue(request); ``` 4. 监听下载状态: ``` BroadcastReceiver receiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { long id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1); if (id == downloadId) { DownloadManager.Query query = new DownloadManager.Query(); query.setFilterById(downloadId); Cursor cursor = downloadManager.query(query); if (cursor.moveToFirst()) { int status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)); if (status == DownloadManager.STATUS_SUCCESSFUL) { // 下载成功 } else if (status == DownloadManager.STATUS_FAILED) { // 下载失败 } else if (status == DownloadManager.STATUS_RUNNING) { // 下载中 } } cursor.close(); } } }; registerReceiver(receiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)); ``` 通过以上步骤,我们可以利用DownloadManager实现下载功能。用户可以通过监听下载状态,获取下载任务的状态并进行相应的处理,例如在下载完成时显示通知,或者在下载失败时提示用户重新下载

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值