浅谈Android版本更新

关于本文DownloadManager版本更新的源码链接详见我的开源项目AppUpdate

前言

版本升级对于app来讲已经是非常常见的功能了,每次项目的版本迭代、新功能的开发都需要下载更新新版本,通过安装新版本来实现我们的迭代。当然除了这种方式,实际上也有热更新与热修复的存在,无需安装的情况下实现版本的迭代,而且很多大型的项目在有了大量用户的积累后也大都采取了灰度发布的功能,先小范围升级试用,在正式推向市场。今天我只想单纯来讲讲基于系统自带的DownloadManager来实现的下载更新。

万能流程图

画图不易,这张流程图几乎包含了app检查更新的所有涉及到的流程,像流程图中进度框、下载失败的弹框,MD5校验个人觉得可以不需要,一般像DownloadManager来实现下载更新只需要在后台下载,下载完成用系统的Notification进行通知即可,然后自动弹出安装界面,这是个标准的流程。

涉及知识归纳

  • DownloadManager系统下载服务的相关api及使用。
  • Android M 运行时权限的动态申请,主要涉及读写存储卡权限。
  • Android N 关于文件的访问权限,不能以file://xxx格式的Uri来访问文件,需要使用FileProvider,Uri格式为content://xxx
  • Android O 关于未知来源应用的权限申请。
  • Android Q 增加沙箱并改变了应用程序访问设备外部存储上文件的方式,而且不可以在内部存储肆意的构建自己的目录
  • 文件MD5校验,防止apk下载被拦截篡改及验证apk文件的完整性。

DownloadManager介绍及使用

介绍

DownloadManager下载管理器是一种处理长时间运行的HTTP下载的系统服务。客户端可以请求将URI下载到特定目标文件。下载管理器将在后台进行下载,负责HTTP交互并在发生故障或跨连接更改和系统重新启动后重试下载。翻译过来的始终感觉不好,以下是官方的原话 (官方传送门

The download manager is a system service that handles long-running HTTP downloads. Clients may request that a URI be downloaded to a particular destination file. The download manager will conduct the download in the background, taking care of HTTP interactions and retrying downloads after failures or across connectivity changes and system reboots.

Apps that request downloads through this API should register a broadcast receiver for ACTION_NOTIFICATION_CLICKED to appropriately handle when the user clicks on a running download in a notification or from the downloads UI.

Note that the application must have the Manifest.permission.INTERNET permission to use this class.

从概念上都已经明确说明了DownloadManager系统下载服务的优越性:
1.可以长时间在后台运行下载
2.可以指定任意的下载路径,也可以支持Android Q
3.下载过程中遇见问题或者更改网络会重试下载,断点续传
4.原生系统下载服务,不依赖第三方,兼容性和稳定性无疑最好
5.默认已经帮你封装好了系统栏通知、wifi/移动网络/漫游等等下载限制

下载核心的API

类/常量/方法介绍
DownloadManager.Query主要用来在下载的过程中查询过滤,比如下载状态、进度等
DownloadManager.Request下载服务一些配置、下载地址、下载路径、通知栏配置、网络限制、媒体类型等
ACTION_DOWNLOAD_COMPLETE下载完成后,由下载管理器发送的广播意图操作
ACTION_NOTIFICATION_CLICKED当用户从系统通知或下载UI单击正在运行的下载时,下载管理器发送广播意图操作
ACTION_VIEW_DOWNLOADS启动活动以显示所有下载的意图操作,说白了手机系统的下载管理界面
COLUMN_BYTES_DOWNLOADED_SO_FAR目前下载的字节数,需要下载进度条的用得到
COLUMN_TOTAL_SIZE_BYTES下载文件的总大小,单位为字节,需要下载进度条的用得到
COLUMN_LOCAL_URI下载的文件将存储在Uri中,注意:N之前是file://xxx,N之后是content://xxx
EXTRA_DOWNLOAD_ID在广播ACTION_DOWNLOAD_COMPLETE中,可拿到download_id
COLUMN_REASON提供有关下载状态的更多详细信息
COLUMN_STATUS当前的下载状态,通过DownloadManager.Query来查询
STATUS_PENDING下载开始
STATUS_RUNNING下载进行中
STATUS_PAUSED下载暂停,这里会等待重试,注意这是断点续传,暂停原因可以通过COLUMN_REASON去查
STATUS_SUCCESSFUL下载成功
STATUS_FAILED下载失败,这里的失败不会重试的,原因可以通过COLUMN_REASON去查
enqueue(DownloadManager.Request request)开启一个下载服务
getMaxBytesOverMobile(Context context)返回手机移动网络限定下载的最大值
getMimeTypeForDownloadedFile(long id)通过download_id查询下载文件的媒体类型,也就是格式
getRecommendedMaxBytesOverMobile(Context context)获取建议的移动网络下载的大小
getUriForDownloadedFile(long id)如果文件下载成功,返回文件的Uri
openDownloadedFile(long id)打开下载的文件,读文件
query(DownloadManager.Query query)下载查询
remove(long… ids)取消下载并从下载管理器中删除文件

以上便是DownloadManager下载使用到的核心api了,基本上满足一个正常的下载了,当然并没有全部罗列出来,像下载暂停和下载失败关于COLUMN_REASON的描述 还有很多,就不罗列出来了,下面看看下载更新的代码片段:

  • 下载核心代码
// 获取下载管理器
downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
clearCurrentTask();
// 下载地址如果为null,抛出异常
String downloadUrl = Objects.requireNonNull(appUpdate.getNewVersionUrl());
Uri uri = Uri.parse(downloadUrl);
DownloadManager.Request request = new DownloadManager.Request(uri);
// 下载中和下载完成显示通知栏
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
if (TextUtils.isEmpty(appUpdate.getSavePath())) {
//使用系统默认的下载路径 此处为应用内 /android/data/packages ,所以兼容7.0
request.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, context.getPackageName() + ".apk");
deleteApkFile(Objects.requireNonNull(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS + File.separator + context.getPackageName() + ".apk")));
} else {
// 自定义的下载目录,注意这是涉及到android Q的存储权限,建议不要用getExternalStorageDirectory()
request.setDestinationInExternalFilesDir(context, appUpdate.getSavePath(), context.getPackageName() + ".apk");
deleteApkFile(Objects.requireNonNull(context.getExternalFilesDir(appUpdate.getSavePath() + File.separator + context.getPackageName() + ".apk")));
}
// 部分机型(暂时发现Nexus 6P)无法下载,猜测原因为默认下载通过计量网络连接造成的,通过动态判断一下
ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
if(connectivityManager !=null){
boolean activeNetworkMetered = connectivityManager.isActiveNetworkMetered();
request.setAllowedOverMetered(activeNetworkMetered);
}
// 设置通知栏的标题
request.setTitle(getAppName());
// 设置通知栏的描述
request.setDescription("正在下载中...");
// 设置媒体类型为apk文件
request.setMimeType("application/vnd.android.package-archive");
// 开启下载,返回下载id
lastDownloadId = downloadManager.enqueue(request);
// 如需要进度及下载状态,增加下载监听
if (!appUpdate.getIsSlentMode()) {
DownloadHandler downloadHandler = new DownloadHandler(this);
downloadObserver = new DownloadObserver(downloadHandler, downloadManager, lastDownloadId);
context.getContentResolver().registerContentObserver(Uri.parse("content://downloads/my_downloads"), true, downloadObserver);
}
  • 下载进度的监听
    默认采取的是系统的ContentObserver对于本地下载的文件变化监听进度,也可以通过开启定时器每隔一定的时间去查询当前的下载进度。
  /**
     * 检查下载的状态
     */
    private void queryDownloadStatus() {
        // Java 7 新的 try-with-resources ,凡是实现了AutoCloseable接口的可自动close(),所以此处不需要手动cursor.close()
        try (Cursor cursor = downloadManager.query(query)) {
            if (cursor != null && cursor.moveToNext()) {
                int status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS));
                long totalSize = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
                long currentSize = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
                // 当前进度
                int mProgress;
                if (totalSize != 0) {
                    mProgress = (int) ((currentSize * 100) / totalSize);
                } else {
                    mProgress = 0;
                }
                Log.d(TAG, String.valueOf(mProgress));
                switch (status) {
                    case DownloadManager.STATUS_PAUSED:
                        // 下载暂停
                        handler.sendEmptyMessage(DownloadManager.STATUS_PAUSED);
                        Log.d(TAG, "STATUS_PAUSED");
                        break;
                    case DownloadManager.STATUS_PENDING:
                        // 开始下载
                        handler.sendEmptyMessage(DownloadManager.STATUS_PENDING);
                        Log.d(TAG, "STATUS_PENDING");
                        break;
                    case DownloadManager.STATUS_RUNNING:
                        // 正在下载,不做任何事情
                        Message message = Message.obtain();
                        message.what = DownloadManager.STATUS_RUNNING;
                        message.arg1 = mProgress;
                        handler.sendMessage(message);
                        Log.d(TAG, "STATUS_RUNNING");
                        break;
                    case DownloadManager.STATUS_SUCCESSFUL:
                        if (!isEnd) {
                            // 完成
                            handler.sendEmptyMessage(DownloadManager.STATUS_SUCCESSFUL);
                            Log.d(TAG, "STATUS_SUCCESSFUL");
                        }
                        isEnd = true;
                        break;
                    case DownloadManager.STATUS_FAILED:
                        if (!isEnd) {
                            handler.sendEmptyMessage(DownloadManager.STATUS_FAILED);
                            Log.d(TAG, "STATUS_FAILED");
                        }
                        isEnd = true;
                        break;
                    default:
                        break;
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

Android M 运行时权限

android 6.0 版本引入了一种新的权限模式,如今,用户可直接在运行时管理应用权限。这种模式让用户能够更好地了解和控制权限,同时为应用开发者精简了安装和自动更新过程。用户可为所安装的各个应用分别授予或撤销权限。

对于以 Android 6.0(API级别23)或更高版本为目标平台的应用,请务必在运行时检查和请求权限。要确定您的应用是否已被授予权限,请调用新增的checkSelfPermission()方法。要请求权限,请调用新增的requestPermissions()方法。即使您的应用并不以Android6.0(API级别23)为目标平台,您也应该在新权限模式下测试您的应用.官方传送门

由于下载需要读写文件,Android M 需要动态申请运行时权限,关于如何查看运行时权限,可以通过AndroidStudio的Terminal终端执行如下命令:

  • 按组列出权限和状态:

$ adb shell pm list permissions -d -g

  • 授予或撤销一项或多项权限:

$ adb shell pm [grant|revoke] …

  • 列出所有权限:

$ adb shell pm list permissions -s

M运行时权限请求代码片段:

  /**
     * 判断存储卡权限
     */
    private void requestPermission() {
        //权限判断是否有访问外部存储空间权限
        int flag = ActivityCompat.checkSelfPermission(getActivity(), Manifest.permission.WRITE_EXTERNAL_STORAGE);
        if (flag != PackageManager.PERMISSION_GRANTED) {
            if (ActivityCompat.shouldShowRequestPermissionRationale(getActivity(), Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
                // 用户拒绝过这个权限了,应该提示用户,为什么需要这个权限。
                Toast.makeText(getActivity(), getResources().getString(R.string.update_permission), Toast.LENGTH_LONG).show();
            }
            // 申请授权
            requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);
        } else {
            // 拥有权限,执行下载相关逻辑
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (requestCode == 1) {
            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                 // 授予权限,执行下载相关逻辑
            } else {
                //拒绝权限,给出提示
                Toast.makeText(getActivity(), getResources().getString(R.string.update_permission), Toast.LENGTH_LONG).show();
                dismiss();
            }
        }
    }
}

Android N 文件的访问权限

为了提高私有文件的安全性,面向Android7.0或更高版本的应用私有目录被限制访问(0700)。此设置可防止私有文件的元数据泄漏,如它们的大小或存在性。此权限更改有多重副作用:

  • 私有文件的文件权限不应再由所有者放宽,为使用 MODE_WORLD_READABLE 和/或 MODE_WORLD_WRITEABLE 而进行的此类尝试将触发 SecurityException
注:迄今为止,这种限制尚不能完全执行。应用仍可能使用原生 API 或 File API 来修改它们的私有目录权限。但是,我们强烈反对放宽私有目录的权限
  • 传递软件包网域外的file://URI可能给接收器留下无法访问的路径。因此,尝试传递 file:// URI 会触发FileUriExposedException。分享私有文件内容的推荐方法是使用 FileProvider。
  • DownloadManager 不再按文件名分享私人存储的文件。旧版应用在访问COLUMN_LOCAL_FILENAME 时可能出现无法访问的路径。面向Android7.0或更高版本的应用在尝试访问COLUMN_LOCAL_FILENAME时会触发SecurityException。通过使用DownloadManager.Request.setDestinationInExternalFilesDir()DownloadManager.Request.setDestinationInExternalPublicDir()将下载位置设置为公共位置的旧版应用仍可以访问COLUMN_LOCAL_FILENAME中的路径,但是我们强烈反对使用这种方法。对于由DownloadManager公开的文件,首选的访问方式是使用ContentResolver.openFileDescriptor()
    下面看一下代码片段:

清单文件

<provider
   android:name=".DownloadFileProvider"
   android:authorities="${applicationId}.fileProvider"
   android:exported="false"
   android:grantUriPermissions="true">
      <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/update_file_path" />
    </provider>

文件存储配置

    <paths>
    <external-path
        name="external_storage_root"
        path="." />
    <files-path
        name="files-path"
        path="." />
    <cache-path
        name="cache-path"
        path="." />
    <!--/storage/emulated/0/Android/data/...-->
    <external-files-path
        name="external_file_path"
        path="." />
    <!--代表app 外部存储区域根目录下的文件 Context.getExternalCacheDir目录下的目录-->
    <external-cache-path
        name="external_cache_path"
        path="." />
    <!--配置root-path。这样子可以读取到sd卡和一些应用分身的目录,据说应用分身有bug-->
    <root-path
        name="root-path"
        path="" />
/paths>

app安装

    File downloadFile = getDownloadFile();
    Intent intent = new Intent(Intent.ACTION_VIEW);
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
        intent.setDataAndType(Uri.fromFile(downloadFile), "application/vnd.android.package-archive");
    } else {
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
            boolean allowInstall = context.getPackageManager().canRequestPackageInstalls();
            if (!allowInstall) {
                //不允许安装未知来源应用,请求安装未知应用来源的权限
                if (mainPageExtraListener != null) {
                    mainPageExtraListener.applyAndroidOInstall();
                }
                return;
            }
        }
        //Android7.0之后获取uri要用contentProvider
        Uri apkUri = FileProvider.getUriForFile(context.getApplicationContext(), context.getPackageName() + ".fileProvider", downloadFile);
        //Granting Temporary Permissions to a URI
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
    }
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startActivity(intent);

Android O 关于未知来源应用

针对 8.0 的应用需要在 AndroidManifest.xml 中声明REQUEST_INSTALL_PACKAGES 权限,否则将无法进行应用内升级

清单文件

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

权限检测

/**
     * 检测到无权限安装未知来源应用,回调接口中需要重新请求安装未知应用来源的权限
     */
    @RequiresApi(api = Build.VERSION_CODES.O)
    @Override
    public void applyAndroidOInstall() {
        //请求安装未知应用来源的权限
        ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.REQUEST_INSTALL_PACKAGES}, INSTALL_PACKAGES_REQUESTCODE);
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        // 8.0的权限请求结果回调
        if (requestCode == INSTALL_PACKAGES_REQUESTCODE) {
            // 授权成功
            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
               // 执行安装apk的逻辑...
            } else {
                // 授权失败,引导用户去未知应用安装的界面
                if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
                    //注意这个是8.0新API
                    Uri packageUri = Uri.parse("package:" + getPackageName());
                    Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, packageUri);
                    startActivityForResult(intent, GET_UNKNOWN_APP_SOURCES);
                }
            }
        }
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        //8.0应用设置界面未知安装开源返回时候
        if (requestCode == GET_UNKNOWN_APP_SOURCES) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                boolean allowInstall = getPackageManager().canRequestPackageInstalls();
                if (allowInstall) {
                   // 执行安装app的逻辑...
                } else {
                   // 拒绝权限逻辑...
                   Toast.makeText(MainActivity.this,"您拒绝了安装未知来源应用,应用暂时无法更新!",Toast.LENGTH_SHORT).show();
                }
            }
        }
    }

Android Q 存储变更

目前Android Q官网上还是处于Beta版,Android Q最大的变化 无非是对用户隐 私权的进一步保护,为每个应用程序在外部存储设备提供了一个独立的存储沙箱,应用通过路径创建的文件都保存在应用的沙箱目录。
关于下载,文件肯定需要保存到本地了,但是由于AndroidQ采取分区存储,致使:getExternalStorageDirectory()与getExternalStoragePublicDirectory()读写权限变化,用户在拥有读写权限的同时,不可以在内部存储肆意的构建自己的目录,这样也更容易管理,卸载应用的时候也可以将这块数据与文件完全删除。

   if (TextUtils.isEmpty(appUpdate.getSavePath())) {
        //使用系统默认的下载路径 此处为应用内 /android/data/packages ,所以兼容7.0
        request.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, context.getPackageName() + ".apk");
    } else {
        // 自定义的下载目录,注意这是涉及到android Q的存储权限,建议不要用getExternalStorageDirectory()
        request.setDestinationInExternalFilesDir(context, appUpdate.getSavePath(), context.getPackageName() + ".apk");
        // 清除本地缓存的文件
        deleteApkFile(Objects.requireNonNull(context.getExternalFilesDir(appUpdate.getSavePath())));
    }
通过setDestinationInExternalFilesDir()存储文件与getExternalFilesDir()获取文件,完全可以避免Android Q对于存储做出的限制。

文件MD5校验

如果采取系统的DownloadManager来实现更新的话,个人觉得可以不用进行校验,当然如果害怕下载的文件被篡改或者不完整的话建议可以加上MD5校验。关于MD5作用有以下几点:

  • 用于校验apk文件签名是否一致,防止下载被拦截与篡改
  • 用于校验文件大小的完整性

下面查看一下代码片段:

    /**
     * 检查文件的MD5的合法性,若不一致,则无法安装
     *
     * @param md5  服务器返回的文件md5值
     * @param file 下载的apk文件
     * @return true 则md5校验通过 false 则失败
     */
    public static boolean checkFileMd5(String md5, File file) {
        if (TextUtils.isEmpty(md5)) {
            return false;
        }
        String md5OfFile = getFileMd5ToString(file);
        if (TextUtils.isEmpty(md5OfFile)) {
            return false;
        }
        return md5.equalsIgnoreCase(md5OfFile);
    }

    /**
     * Return the MD5 of file.
     *
     * @param file The file.
     * @return the md5 of file
     */
    private static String getFileMd5ToString(final File file) {
        return bytes2HexString(getFileMd5(file));
    }

    private static final char[] HEX_DIGITS =
            {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};

    private static String bytes2HexString(final byte[] bytes) {
        if (bytes == null) {
            return "";
        }
        int len = bytes.length;
        if (len <= 0) {
            return "";
        }
        char[] ret = new char[len << 1];
        for (int i = 0, j = 0; i < len; i++) {
            ret[j++] = HEX_DIGITS[bytes[i] >> 4 & 0x0f];
            ret[j++] = HEX_DIGITS[bytes[i] & 0x0f];
        }
        return new String(ret);
    }

    /**
     * Return the MD5 of file.
     *
     * @param file The file.
     * @return the md5 of file
     */
    private static byte[] getFileMd5(final File file) {
        if (file == null) {
            return null;
        }
        DigestInputStream dis = null;
        try {
            FileInputStream fis = new FileInputStream(file);
            MessageDigest md = MessageDigest.getInstance("MD5");
            dis = new DigestInputStream(fis, md);
            byte[] buffer = new byte[1024 * 256];
            while (true) {
                if (dis.read(buffer) <= 0) {
                    break;
                }
            }
            md = dis.getMessageDigest();
            return md.digest();
        } catch (NoSuchAlgorithmException | IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (dis != null) {
                    dis.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }

最后

关于版本更新大概就这么多知识点了,比较简单,但是很零碎,如果想要了解详细的内容,卿可以下载源码进行查看哦,源码详见我的开源地址AppUpdate,本库经过长期的验证,稳定性很OK的啦,如果有好的想法,直接提issues。

本库目前的功能

  • 适配Android M,处理关于存储文件的运行时权限
  • 适配Android N,安卓增强了文件访问的安全性,利用FileProvider来访问文件
  • 适配Android O,增加未知来源应用的安装提示
  • 适配Android Q,关于Q增加沙箱,改变了应用程序访问设备外部存储上文件的方式如SD卡
  • 默认采取DownloadManager+系统通知实现后台下载,安装完毕自动弹出安装界面,也可以自由配置增加下载进度框与下载失败的提示框
  • 支持强制更新,未更新无法使用应用
  • 支持MD5文件防篡改及完整性校验
  • 支持自定义更新提示界面,定制适合自己的更新框
  • 下载失败支持通过系统浏览器直接下载
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值