MediaScanner-MediaPrivider-插拔U盘多媒体扫描并优化

引言

有些行业上,需要涉及到Android系统插拔U盘并扫描数据,如音乐、视频并播放。目前来说,我接触到的扫描方式大概有两种:

  • 通过监听U盘挂载(mount)广播,拿到U盘地址,自己写函数去扫描文件,比如判断路径下的directory和file类型。这种能比较灵活的控制显示U盘数据,比如读取到5首或者10首歌曲就显示出来。
  • 还有一种就是原生系统MediaScanner扫描,MediaStore通过ContentProvider读取,也是可行的。但是这种会遇到U盘数据较大,扫描耗时很久的情况。

本文基于第二种方式介绍,并给出适当的优化建议。不同的公司不同的行业不同的老铁可能优化方式不同,可以大家一起讨论哈。

概括下几个关键类的作用

  • MediaScannerReceiver:多媒体扫描广播接收者,继承 BroadcastReceiver,主要响应APP发送的广播命令,并开启MediaScannerService 执行扫描工作。
  • MediaScannerService:多媒体扫描服务,继承 Service,主要是处理 APP 发送的请求,用到 Framework 中的 MediaScanner 来共同完成具体扫描工作,并获取媒体文件的 metadata,最后将数据写入或删除 MediaProvider 提供的数据库中。
  • MediaProvider:多媒体内容提供者,继承 ContentProvider,主要是负责操作数据库,并提供给别的程序 insert、query、delete、update 等操作。
  • MediaStore:查询入口。MediaProvider相当于存储文件的仓库,而MediaStore相当于展示媒体文件的柜台。当你想查看一个媒体文件时,通常是从柜台入手。MediaStore把所有的文件分为几类:MediaStore.Files所有的文件,包括非多媒体文件。MediaStore.InternalThumbnails,这个类是被图像缩略图,视频缩略图内部使用的。它没有提供uri,所以别的地方应该访问不了。MediaStore. Images,图像文件存储的地方。MediaStore.Audio,音频文件类存储的地方。MediaStore.Video,视频文件类存储的地方。

    每种类型都可以通过getContentUri()接口获取具体的引用位置。

多媒体数据库路径:data/data/com.android.providers.media/database/external.db或者internal.db

MediaSacannerReceiver.java

主要负责service的启动,接收android.intent.action.MEDIA_MOUNTED广播启动MediaScannerService。收到ACTION_BOOT_COMPLETED开机完成广播,此时会把内部卷(“internal”)和外部卷(“external”)都扫描一下;

// An highlighted block
    private void scan(Context context, String volume) {
        Bundle args = new Bundle();
        args.putString("volume", volume);
        context.startService(
                new Intent(context, MediaScannerService.class).putExtras(args));
    }

MediaScannerService.java

第一次被启动走onCreate,将自己的线程启动,什么保持系统不休眠啊之类的代码:

// A code block
    @Override
    public void onCreate() {
        PowerManager pm = (PowerManager)getSystemService(Context.POWER_SERVICE);
        mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
        StorageManager storageManager = (StorageManager)getSystemService(Context.STORAGE_SERVICE);
        mExternalStoragePaths = storageManager.getVolumePaths();

        // Start up the thread running the service.  Note that we create a
        // separate thread because the service normally runs in the process's
        // main thread, which we don't want to block.
        Thread thr = new Thread(null, this, "MediaScannerService");
        thr.start();
    }

第二次启动走onStartCommand,从广播里面获取信息发送给mServiceHandler,通过handler通知service启动线程扫描。

// An highlighted block
   @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        while (mServiceHandler == null) {
            synchronized (this) {
                try {
                    wait(100);
                } catch (InterruptedException e) {
                }
            }
        }

        if (intent == null) {
            Log.e(TAG, "Intent is null in onStartCommand: ",
                new NullPointerException());
            return Service.START_NOT_STICKY;
        }

        Message msg = mServiceHandler.obtainMessage();
        msg.arg1 = startId;
        msg.obj = intent.getExtras();
        mServiceHandler.sendMessage(msg);

        // Try again later if we are killed before we can finish scanning.
        return Service.START_REDELIVER_INTENT;
    }

mServiceHandler 解析路径和volume信息然后开始扫描:

// An highlighted block
	private final class ServiceHandler extends Handler {
        @Override
        public void handleMessage(Message msg) {
         ...
        	scan(directories, volume);
         ...
	    }
	};

scan(String[] directories, String volumeName)方法中先 openDatabase(volumeName);发消息给MedaiProvider,让数据库先准备好,然后MediaScanner scanner = new MediaScanner(this, volumeName),scanner.scanDirectories(directories);MediaScanner.java开始扫描。

MediaProvider.java+MediaScanner.java

具体流程可以在上面提供的时序图查看,这里主要讲解一下重要的方法:

  • prescan主要是做老数据删除,先从数据库将数据读取出来,然后判断文件存不存在,不存在就删除。
// An highlighted block
private void prescan(String filePath, boolean prescanFiles) throws RemoteException {
        Cursor c = null;
        String where = null;
        String[] selectionArgs = null;

        mPlayLists.clear();//清除列表,这个列表后面用来保存每个媒体问的信息:id,修改时间等

        if (filePath != null) {//获取单个数据
            // query for only one file
            where = MediaStore.Files.FileColumns._ID + ">?" +
                " AND " + Files.FileColumns.DATA + "=?";
            selectionArgs = new String[] { "", filePath };
        } else {//从数据库files表获取所有数据
            where = MediaStore.Files.FileColumns._ID + ">?";
            selectionArgs = new String[] { "" };
        }

        mDefaultRingtoneSet = wasRingtoneAlreadySet(Settings.System.RINGTONE);
        mDefaultNotificationSet = wasRingtoneAlreadySet(Settings.System.NOTIFICATION_SOUND);
        mDefaultAlarmSet = wasRingtoneAlreadySet(Settings.System.ALARM_ALERT);

        // Tell the provider to not delete the file.
        // If the file is truly gone the delete is unnecessary, and we want to avoid
        // accidentally deleting files that are really there (this may happen if the
        // filesystem is mounted and unmounted while the scanner is running).
        Uri.Builder builder = mFilesUri.buildUpon();
        builder.appendQueryParameter(MediaStore.PARAM_DELETE_DATA, "false");
        MediaBulkDeleter deleter = new MediaBulkDeleter(mMediaProvider, builder.build());

        // Build the list of files from the content provider
        try {
            if (prescanFiles) {
                // First read existing files from the files table.
                // Because we'll be deleting entries for missing files as we go,
                // we need to query the database in small batches, to avoid problems
                // with CursorWindow positioning.
                long lastId = Long.MIN_VALUE;
                //每次操作限制读取1000个数据
                Uri limitUri = mFilesUri.buildUpon().appendQueryParameter("limit", "1000").build();

                while (true) {
                    selectionArgs[0] = "" + lastId;
                    if (c != null) {
                        c.close();
                        c = null;
                    }
                    c = mMediaProvider.query(limitUri, FILES_PRESCAN_PROJECTION,
                            where, selectionArgs, MediaStore.Files.FileColumns._ID, null);
                    if (c == null) {
                        break;
                    }
					
                    int num = c.getCount();
					//获取到的数据个数判断是否为0,空的话就不用处理了
                    if (num == 0) {
                       break;
                    }
                    //对1000个数据进行处理
                    while (c.moveToNext()) {
                        long rowId = c.getLong(FILES_PRESCAN_ID_COLUMN_INDEX);
                        String path = c.getString(FILES_PRESCAN_PATH_COLUMN_INDEX);
                        int format = c.getInt(FILES_PRESCAN_FORMAT_COLUMN_INDEX);
                        //数据库里获取文件最后修改时间
                        long lastModified = c.getLong(FILES_PRESCAN_DATE_MODIFIED_COLUMN_INDEX);
                        lastId = rowId;
                        // Only consider entries with absolute path names.
                        // This allows storing URIs in the database without the
                        // media scanner removing them.
                        if (path != null && path.startsWith("/")) {
                            boolean exists = false;
                            try {
                            	//查询文件在系统里是否存在
                                exists = Os.access(path, android.system.OsConstants.F_OK);
                            } catch (ErrnoException e1) {
                            }
                            if (!exists && !MtpConstants.isAbstractObject(format)) {
                                // do not delete missing playlists, since they may have been
                                // modified by the user.
                                // The user can delete them in the media player instead.
                                // instead, clear the path and lastModified fields in the row
                                MediaFile.MediaFileType mediaFileType = MediaFile.getFileType(path);
                                int fileType = (mediaFileType == null ? 0 : mediaFileType.fileType);

									//添加要删除的id
                                    deleter.delete(rowId);
                                    //如果.nomedia文件被删除了,那么就需要重新扫描这个文件夹,因为之前没有扫描。
                                    if (path.toLowerCase(Locale.US).endsWith("/.nomedia")) {
                                    	//开始删除老数据
                                        deleter.flush();
                                        String parent = new File(path).getParent();
                                        mMediaProvider.call(MediaStore.UNHIDE_CALL, parent, null);
                                    }
                               }
                            }
                        }
                    }
                }
            }
        }
        finally {
            if (c != null) {
                c.close();
            }
            //开始删除老数据
            deleter.flush();
        }

        // compute original size of images
        mOriginalCount = 0;
        c = mMediaProvider.query(mImagesUri, ID_PROJECTION, null, null, null, null);
        if (c != null) {
            mOriginalCount = c.getCount();
            c.close();
        }
    }
  • beginFile,做一些准备工作,保存文件的一些信息,如id、最后修改时间、文件是否被更改等。

  • endFile,每个文件处理都会调用一次endfile,主要是来判别文件的类型,插入到对应的表,不过并不是每次都插入,MediaIsert.java文件会对插入的数据计数,超过250条数据就一起插入数据库,调用bulkInsert。

MediaScanner.cpp

主要负责文件的扫描,深度扫描存储设备,每扫描到文件就通过JNI调用Java侧的scanFile方法。

主要流程就是如此,不过多深入分析,其他文章也介绍的挺多的。

存在的问题

因为每次插拔U盘,都是存的一个external.db,所以多个U盘插拔的时候,读取数据较慢。

还有,每次移除U盘,如果删除数据库的话,再次插入又要重新读取,也会比较慢。

扫描数据,只发送scanner_start和scanner_finish广播,如果数据太大,中途数据变动也需要做优化。

尝试优化

优化点一provider层:

修改packages/providers/Mediaprovider部分代码,因为U盘的数据读取主要都是这里处理,修改这里的代码后,应用侧就能根据优化后的逻辑读取U盘音视频文件播放:

MediaProvider.java

private Uri attachVolume(String volume) {
    ...
    if (INTERNAL_VOLUME.equals(volume)) {
        ...
    } else if (EXTERNAL_VOLUME.equals(volume)) {
        // Only extract FAT volume ID for primary public
        final VolumeInfo vol = mStorageManager.getPrimaryPhysicalVolume();
        if (vol != null) {
            final StorageVolume actualVolume = mStorageManager.getPrimaryVolume();
            final int volumeId = actualVolume.getFatVolumeId();
            ...
        } else {
            ...
        }
    } else {
        throw new IllegalArgumentException("There is no volume named " + volume);
    }
    ...
}

修改为:

private Uri attachVolume(String volume) {
    ...
    if (INTERNAL_VOLUME.equals(volume)) {
        ...
    } else {
        final VolumeInfo vol = mStorageManager.getPrimaryPhysicalVolume();
        if (vol != null) {
            int volumeId = -1;
            try {
                volumeId = Integer.valueOf(volume);
            } catch (NumberFormatException e) {
                e.printStackTrace();
            }
            ...
        } else {
            ...
        }
    }
    ...
}

那么 attachVolume(String volume) ,对于外部存储传入的 volume 就不应是 EXTERNAL_VOLUME 了,而应该是外部存储的 volumeId。

volumeId 可以通过 StorageVolume 的 getFatVolumeId 方法获取,本质就是将 UUID 转换的 int 值,而 UUID 是一个长度为 9 的字符串,类似与 “57E9-73B0”这样。

需要找到 attachVolume(String volume) 调用的地方,修改参数为 volumeId,找到有两个地方调用了:

1、onCreate() 方法中:

String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state) ||
        Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
    attachVolume(EXTERNAL_VOLUME);
}

可以通过 StorageManager.getStorageVolumes() 获取全部的 StorageVolume,因此修改为

final List<StorageVolume> storageVolumes = mStorageManager.getStorageVolumes();
String state;
for (StorageVolume storageVolume : storageVolumes) {
    state = storageVolume.getState();
    if (Environment.MEDIA_MOUNTED.equals(state) ||
            Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
        attachVolume(String.valueOf(storageVolume.getFatVolumeId()));
    }
}

2、在 insertInternal() 方法中:

private Uri insertInternal(Uri uri, int match, ContentValues initialValues,
                               ArrayList<Long> notifyRowIds) {
    ...
    switch (match) {
        case VOLUMES:
        {
            String name = initialValues.getAsString("name");
            Uri attachedVolume = attachVolume(name);
            if (mMediaScannerVolume != null && mMediaScannerVolume.equals(name)) {
                DatabaseHelper dbhelper = getDatabaseForUri(attachedVolume);
                if (dbhelper == null) {
                    Log.e(TAG, "no database for attached volume " + attachedVolume);
                } else {
                    dbhelper.mScanStartTime = SystemClock.currentTimeMicro();
                }
            }
            return attachedVolume;
        }
    }
    ...
}

这个方法调用的源头是在 MediaScannerService 中,下面分析扫描流程。

MediaScannerService.java

private void openDatabase(String volumeName) {
    try {
        ContentValues values = new ContentValues();
        values.put("name", volumeName);
        getContentResolver().insert(Uri.parse("content://media/"), values);
    } catch (IllegalArgumentException ex) {
        Log.w(TAG, "failed to open media database");
    }         
}

private void scan(String[] directories, String volumeName) {
    Uri uri = Uri.parse("file://" + directories[0]);
    // don't sleep while scanning
    mWakeLock.acquire();

    try {
        ContentValues values = new ContentValues();
        values.put(MediaStore.MEDIA_SCANNER_VOLUME, volumeName);
        Uri scanUri = getContentResolver().insert(MediaStore.getMediaScannerUri(), values);

        sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_STARTED, uri));

        try {
            if (volumeName.equals(MediaProvider.EXTERNAL_VOLUME)) {
                openDatabase(volumeName);
            }

            try (MediaScanner scanner = new MediaScanner(this, volumeName)) {
                scanner.scanDirectories(directories);
            }
        } catch (Exception e) {
            Log.e(TAG, "exception in MediaScanner.scan()", e);
        }

        getContentResolver().delete(scanUri, null, null);

    } finally {
        sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_FINISHED, uri));
        mWakeLock.release();
    }
}

由于 volumeName 不再是 EXTERNAL_VOLUME,修改 openDatabase() 执行的判断语句。

if (!volumeName.equals(MediaProvider.INTERNAL_VOLUME)) {
    openDatabase(volumeName);
}

注意在调用 scan() 方法的地方,传入的 directories 根据 volume 做了一个判断,注意也要修正过来。

private final class ServiceHandler extends Handler {
    @Override
    public void handleMessage(Message msg) {
        ...
        String volume = arguments.getString("volume");
        String[] directories = null;

        if (MediaProvider.INTERNAL_VOLUME.equals(volume)) {
            // scan internal media storage
            directories = new String[] {
                    Environment.getRootDirectory() + "/media",
                    Environment.getOemDirectory() + "/media",
                    Environment.getProductDirectory() + "/media",
            };
        }
        // 这里的判断要修改 start
        /*else if (MediaProvider.EXTERNAL_VOLUME.equals(volume)) {*/
        else {
        // 这里的判断要修改 end
            // scan external storage volumes
            if (getSystemService(UserManager.class).isDemoUser()) {
                directories = ArrayUtils.appendElement(String.class,
                        mExternalStoragePaths,
                        Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
            } else {
                directories = mExternalStoragePaths;
            }
        }

        if (directories != null) {
            if (false) Log.d(TAG, "start scanning volume " + volume + ": "
                    + Arrays.toString(directories));
            scan(directories, volume);
            if (false) Log.d(TAG, "done scanning volume " + volume);
        }
        ...
    }
}

找到 volumeName 赋值的源头在 MediaScannerReceiver 中。

MediaScannerReceiver.java

@Override
public void onReceive(Context context, Intent intent) {
    final String action = intent.getAction();
    final Uri uri = intent.getData();
    if (Intent.ACTION_BOOT_COMPLETED.equals(action)) {
        ...
    } else if (Intent.ACTION_LOCALE_CHANGED.equals(action)) {
        ...
    } else {
        if (uri.getScheme().equals("file")) {
            ...
            if (Intent.ACTION_MEDIA_MOUNTED.equals(action)) {
                // scan whenever any volume is mounted
                scan(context, MediaProvider.EXTERNAL_VOLUME);
            } 
            ...
        }
    }
}

这里调用scan(...)实际上都是通过bundle传给mediaScannerService,我们在接收 ACTION_MEDIA_MOUNTED 广播的位置将获取到的 Uri,传入 scan() 方法。

scan(context, uri.toString());

最后在回到 MediaScannerService 修改接收的参数 "volume"对应的value。

MediaScannerService.java

private final class ServiceHandler extends Handler {
    @Override
    public void handleMessage(Message msg) {
        ...
        String volume = arguments.getString("volume");
        String[] directories = null;

        if (MediaProvider.INTERNAL_VOLUME.equals(volume)) {
            // scan internal media storage
            directories = new String[] {
                    Environment.getRootDirectory() + "/media",
                    Environment.getOemDirectory() + "/media",
                    Environment.getProductDirectory() + "/media",
            };
        }
        // 这里需要修改 start
        /*else if (MediaProvider.EXTERNAL_VOLUME.equals(volume)) {
            // scan external storage volumes
            if (getSystemService(UserManager.class).isDemoUser()) {
                directories = ArrayUtils.appendElement(String.class,
                        mExternalStoragePaths,
                        Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
            } else {
                directories = mExternalStoragePaths;
            }
        }*/
        else {
            boolean isSinglePath = false;// 标记单个路径扫描
            String[] singleStoragePath = new String[1];
            try {
                // Uri 转换为 File
                File file = new File(new URI(volume));
                // 根据 File 获取 StorageVolume
                StorageVolume storageVolume = mStorageManager.getStorageVolume(file);
                if (storageVolume != null) {
                    // 重新赋值 volume 为 volumeId
                    volume = String.valueOf(storageVolume.getFatVolumeId());
                    singleStoragePath[0] = storageVolume.getPath();
                    isSinglePath = true;
                }
            } catch (URISyntaxException e) {
                e.printStackTrace();
            }
            if (getSystemService(UserManager.class).isDemoUser()) {
                if (isSinglePath) {
                    directories = ArrayUtils.appendElement(String.class,
                            singleStoragePath,
                            Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
                } else {
                    directories = ArrayUtils.appendElement(String.class,
                            mExternalStoragePaths,
                            Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
                }
            } else {
                if (isSinglePath) {
                    directories = singleStoragePath;
                } else {
                    directories = mExternalStoragePaths;
                }
            }
        }
        // 这里需要修改 end

        if (directories != null) {
            if (false) Log.d(TAG, "start scanning volume " + volume + ": "
                    + Arrays.toString(directories));
            scan(directories, volume);
            if (false) Log.d(TAG, "done scanning volume " + volume);
        }
        ...
    }
}

ContentResolver.java

这个文件很复杂,几千行代码,很遗憾即便不能看懂,也要看懂大部分。至少要知道contentprovider那些重写的函数。

这个类里所有操作外部存储的地方,增删改查使用的 URI 中的 path 都要修改为 volumeId(根据实际情况),才能正常运行,而 MediaStore.Audio.Media.EXTERNAL_CONTENT_URI 就不能使用了,看下 MediaStore的URI 获取:

public static final Uri EXTERNAL_CONTENT_URI = getContentUri("external");

public static Uri getContentUri(String volumeName) {
    return Uri.parse(CONTENT_AUTHORITY_SLASH + volumeName +
            "/audio/media");
}

再看下 MediaProvider 中是根据 URI 的 path 中 volumeName 去获取 DatabaseHelper:

private DatabaseHelper getDatabaseForUri(Uri uri) {
    synchronized (mDatabases) {
        if (uri.getPathSegments().size() >= 1) {
            //这里就是获取uri的第一个"/"符号右边第一个string,即"external"或者其它
            return mDatabases.get(uri.getPathSegments().get(0));
        }
    }
    return null;
}

修改后,根据实际情况要传入 volumeId 或者另外的情况:

  //类似于这种直接想通过EXTERNAL获取的要判断修改
    database = mDatabases.get(getVolumeId(storage));

    /**
     * 根据StorageVolume获取数据库名称
     */
    private String getVolumeId(StorageVolume vol) {
        if (vol == null){
            return EXTERNAL_VOLUME;
        }
        String path = vol.getPath();
        if (isInternal(path)) {
            return INTERNAL_VOLUME;
        }
        return String.valueOf(vol.getFatVolumeId());
    }

    /**
     * 根据path获取数据库名称
     */
    private String getVolumeId(String path) {
        if (mStorageManager == null){
            return INTERNAL_VOLUME;
        }
        StorageVolume vol = mStorageManager.getStorageVolume(new File(path));
        if (vol == null) {
            Log.d(TAG, "getVolumeId, vol = null; path = " + path );
            return EXTERNAL_VOLUME;
        }
        int volumeId = vol.getFatVolumeId();
        if (volumeId == -1 && isInternal(vol.getPath())) {
            return INTERNAL_VOLUME;
        }
        return String.valueOf(volumeId);
    }

    /**
     * 根据路径判断是否是内部数据
     */
    public static boolean isInternal(String path) {
        // /storage/emulated/0 or /udisk
        return path.contains(MediaScannerReceiver.INTERNAL_KEY_PATH)
                || path.startsWith(MediaScannerReceiver.INTERNAL_KEY_PATH_EXTEND);
    }

优化点二应用层:

主要就是在收到mounted广播或者scan_started广播的时候就可以读取数据库了,这样就能很快的刷新到主线程的列表。但是这时候毕竟没有扫描结束,因此scan_finish广播的时候还要读取一次数据库,纠正读取的数据;

    @Override
    public void onReceive(Context context, Intent intent) {
        if (intent != null){
            LogUtil.d(TAG, "onReceive "+intent.getAction());
            MediaBrowserManager.getInstance().mLiveDataUState.postValue(intent.getAction());
            if (Intent.ACTION_MEDIA_SCANNER_STARTED.equals(intent.getAction())){
                MediaServiceManager.getInstance().readUAudioData();
            }else if (Intent.ACTION_MEDIA_SCANNER_FINISHED.equals(intent.getAction())){
                MediaServiceManager.getInstance().readUAudioData();
                // u盘重新插入就重新订阅
                MediaBrowserManager.getInstance().subscribeMediaService();
            }
    }

总结

实际上,这里优化仅仅就是把原本串行更新数据库改为并行更新了。优化的可能并不是很好,简单说下还能优化的地方:mediaScannerRecevier收到开机广播其实就可以调用mediaScanner.prescan方法预处理了。还有,provider层的db数据库,隔一段时间应该清空,不然总是插拔U盘增加数据库,不删除也不妥当。这个“隔一段时间”可以自己定义一周或者一个月。

总之要想优化的很好,read the fuck codes,反正尽量阅读修改。

  • 2
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 13
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值