跨程序共享数据:Android四大组件之内容提供器

前言

Android中包含了大量的应用程序,那么如何获取其它应用程序的数据,来做一些更有意思的事情呢?内容提供作为Android的四大组件之一就是用来实现应用程序之间数据共享的。

七、跨程序共享数据:Android四大组件之内容提供器

7.1 内容提供器(Content Provider)简介

内容提供器是实现Android跨程序之间数据共享的一种安全方式。前面我们讲到Android持久化技术,我们也可以让其它应用程序直接访问我们的持久化文件实现数据共享,但是这种方式存在安全隐患,因此又提出了内容提供器技术方案,通过内容提供器可以控制开发哪些数据让别的程序访问,进一步提高数据共享的安全性。

7.2 运行时权限(软件不能为所欲为,想要什么权限,还得主人决定)

内容提供器是实现跨程序数据共享,既然是访问别的程序数据,为了安全性起见,自然不是你想访问谁的数据就访问谁的数据,你需要先申请访问该程序的权限,然后由用户决定是否同意你的申请,只有权限申请通过了,才能利用内容提供器访问程序中的数据,而运行时权限就是实现这一过程的方案

申请的权限包括普通权限和危险权限,普通权限只需要在AndroidManifest.xml文件中声明该权限即可,由系统自动授权;危险权限不仅需要在AndroidManifest.xml文件中声明该权限,而且需要在代码中申请该权限,然后由用户决定是否授权

Android中涉及的危险权限如下表所示:
在这里插入图片描述

  • 普通权限申请,如网络状态权限申请的示例如下,只需要在AndroidManifest.xml文件中添加< use-permission >标签即可。,
<manifest ......>
	<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>
  • 危险权限申请,如读取电话本程序数据权限申请示例如下,需要同时在代码中添加权限申请逻辑。

AndroidManifest.xml文件配置:

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

代码申请权限:

if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS)!= PackageManager.PERMISSION_GRANTED)
{
	//没有读取电话簿的权限,需要代码申请权限,参数1:context 参数2:需要申请的权限列表 参数3:申请权限的标识,在申请结果的回调函数中使用
	ActivityCompat.requestPermissions(this,new String[]{Manifest.permission.READ_CONTACTS},1);
}
else
    //有读取电话簿的权限,可以进行读取电话簿数据
	readContacts();
 @Override
    //申请权限结果回调函数
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        switch (requestCode)
        {
            case 1:
                //如果用户同意了申请,则读取数据,进行相关操作
                if (grantResults.length>0 && grantResults[0]==PackageManager.PERMISSION_GRANTED)
                    readContacts();
                //如果用户没有同意申请,进行其它逻辑
                else
                    Toast.makeText(this, "未授予读取电话本权限", Toast.LENGTH_SHORT).show();
                break;
            default:
                break;
        }
    }

7.3 访问其它程序中的数据

Android提供ContentResolver类来获取其它应用程序中的数据,ContentResolver的对象通过Context的getContentResolver方法获得。ContentResolver包含操作数据库的常用方法如query,insert,delete,update等等。

7.3.1 什么是URI

我们知道操作数据库首先需要确定是操作数据库中的哪张表,通常我们直接通过表名来确定是哪张表。

而访问其它程序的数据,首先要确定访问的是哪个程序,然后才能确定是访问的哪张表,因此Android提供一个内容URI来内容提供器中的数据建立唯一的标准,简单来说URI就是指定哪个程序中的哪张表的唯一标识

答疑:URI中不需要指定是哪个数据库,因为开放内容提供器接口的程序内部已经设定了是哪个数据库,访问方只需要确定是哪张表就可以了,这个在下一节我们创建自己的内容提供器的时候你就能看到。

URI的格式为:content://authority和path

  • authority:是用于对不同的应用程序做区分的,一般为了避免冲突,都会采用程序包名的方式来进行命名。比如某个程序的包名是com.example.app,那么该程序对应的 authority就可以命名为com.example.app. provider。
  • path:是用于对同一应用程序中不同的表做区分的,通常都会添加到authority的后面。比如某个程序的数据库里存在两张表:table1和 table2,这时就可以将path分别命名为/table1和/table2。因此完整URI就是content://com.example.app. provider/table1 和content://com.example.app. provider/table2

7.3.2 基于内容提供器对其它程序的数据进行增删改查

  1. 查询数据语法
ContentResolver contentResolver = getContentResolver();
//参数1:唯一标识要查询的是哪个程序下的哪张表; 参数2:指定查询的列名
//参数3:指定where的约束条件; 参数4:为参数3的占位符提供具体的值
//参数5:对查询结果的排序方式
contentResolver.query(uri,projection,selection,selectionArgs,sortOrder);
  1. 插入数据语法
ContentResolver contentResolver = getContentResolver(# 参考书籍:第一行代码
链接:https://pan.baidu.com/s/1aXtOQCXL6qzxEFLBlqXs1Q?pwd=n5ag
提取码:n5ag);
//参数1:URI 参数2:待插入数据的键值对值
contentResolver.insert(uri,ContentValues)
  1. 更新数据语法
ContentResolver contentResolver = getContentResolver();
//参数1:URI 参数2:待插入数据的键值对值 参数3和参数4约束更新范围
contentResolver.update(uri,ContentValues,selection,selectionArgs)
  1. 删除数据语法
ContentResolver contentResolver = getContentResolver();
//参数1:URI; 参数2和参数3约束删除范围
contentResolver.delete(uri,selection,selectionArgs)

7.3.3 查询电话本程序中数据的核心代码示例

 Cursor cursor=null;
        try {
            ContentResolver contentResolver = getContentResolver();
            //电话本程序的URI由ContactsContract.CommonDataKinds.Phone封装好了,我们直接用可以了
            cursor= contentResolver.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, null, null, null);
            if (cursor!=null)
            {
                while (cursor.moveToNext())
                {
                    String name = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
                    String number = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER));
                }
            }
        }
        catch (Exception e)
        {
            e.printStackTrace();
        }
        finally {
            if (cursor!=null)
                cursor.close();
        }

7.4 创建自己的内容提供器(提供共享数据的接口)

7.4.1 创建自己的内容提供器步骤

  1. 创建向其它程序共享自己数据的一系列方法的类,该类继承自ContentProvider
  2. 创建UriMatcher对象,并将自己想要共享的数据URI添加到UriMatcher对象中
  3. 重写ContentProvider类下的增删改查等方法,为其它程序访问自己的数据提供接口
  4. 在AndroidManifest.xml文件中的 < application>标签下添加 < provider>子标签
    在AndroidManifest.xml中配置自己的内容提供器示例:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.xiaomi.databasetest">

    <application
        ......
        <provider
            android:name=".DatabaseProvider" <--创建的内容提供器类的路径-->
            android:authorities="com.xiaomi.databasetest.provider" <!--程序标识-->
            android:enabled="true"
            android:exported="true" />
	</application>
</manifest>

答疑:
UriMatcher对象添加URI的方法是addURI(String authority, String path, int code),authority和path在前面已经讲过,code是当前匹配URI的唯一标识。
上面讲到path代表的是表名,例如/table1,其实表名后面还可以加一个数字,代码这个表的ID,即访问表中的第几行数据,例如/table1/2。

7.4.2 创建自己的内容提供器核心代码示例

public class MyProvider extends ContentProvider {

    //自定义code,指定URI的唯一标识
    public static final int TABLE1_DIR=0;
    public static final int TABLE1_ITEM=1;
    private static UriMatcher uriMatcher;

    static {
        //正则表达式: *代码任意长度的字符,#代表任意长度的数字
        uriMatcher=new UriMatcher(UriMatcher.NO_MATCH);
        //将content://com.xiaomi.app.provider/table1添加到Matcher对象中,代码想要把table1整张表给其它程序访问
        uriMatcher.addURI("com.xiaomi.app.provider","table1",TABLE1_DIR);
         //将content://com.xiaomi.app.provider/table1/#添加到Matcher对象中,代码想要把table1表中的指定行给其它程序访问
        uriMatcher.addURI("com.xiaomi.app.provider","table1/#",TABLE1_ITEM);
    }

    @Override
    public boolean onCreate() {
        //指定或者创建想要共享数据的数据库(这儿也能解释为什么URI中不需要指定数据库的原因了)
        return false;
    }

    @Nullable
    @Override
    //提供给其它程序查询本程序数据库的方法
    public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
        switch (uriMatcher.match(uri))
        {
            case TABLE1_DIR:
                System.out.printf("向外部提供table1表下的所有数据");
                break;
            case TABLE1_ITEM:
                System.out.printf("向外部提供table1表下的单条数据");
                break;
            default:
                break;
        }
        return null;
    }
    @Nullable
    @Override
    public Uri insert(@NonNull Uri uri, @Nullable ContentValues contentValues) {
        return null;
    }

    @Override
    public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
        return 0;
    }

    @Override
    public int update(@NonNull Uri uri, @Nullable ContentValues contentValues, @Nullable String selection, @Nullable String[] selectionArgs) {
        return 0;
    }
    @Nullable
    @Override
    public String getType(@NonNull Uri uri) {
        //获取URI对应的MIME字符串
        //URI以路径结尾,MIME格式:vnd.android.cursor.dir/vnd.<authority>.<path>
        //URI以id结尾,MIME格式:vnd.android.cursor.item/vnd.<authority>.<path>
        switch (uriMatcher.match(uri))
        {
            case TABLE1_DIR:
                return "vnd:android.cursor.dir/vnd.com.xiaomi.app.provider.table1";
            case TABLE1_ITEM:
                return "vnd:android.cursor.item/vnd.com.xiaomi.app.provider.table1";
            default:
                break;
        }
        return null;
    }

}

答疑:
创建的自己的内容提供器除了实现增删改查等操作数据库的方法外,还需要实现getType方法,该方法是根据传入的URI来返回相应的MIME类型。
一个URI对应的MIME字符串主要由一下三部分组成:

    1. 必须以vnd.开头
    1. 如果URI以表名结尾,则后接 android.cursor.dir/;如果URI以id结尾,则后接android.cursor.item/
    1. 最后接上vnd.< authority>.< path>

示例:
URI:content://com.example.app.provider/table1
对应MIME:vnd.android.cursor.dir/vnd.com.example.app.provider.table1
URI:content://com.example.app.provider/table1/3
对应MIME:vnd.android.cursor.item/vnd.com.example.app.provider.table1

7.4.2 创建自己的内容提供器完整代码示例

  1. 创建程序的SQLIte数据库帮助类
public class MyDatabaseHelper extends SQLiteOpenHelper {

    private Context mContext;

    public static final String CREATE_BOOK_TABLE="create table Book(" +
            "id integer primary key autoincrement," +
            "author text," +
            "price real," +
            "pages integer," +
            "name text)";

    public static final String DROP_BOOK="drop table if exists Book";

    public static final String CREATE_CATEGORY_TABLE="create table Category(" +
            "id integer primary key autoincrement," +
            "category_name text," +
            "category_code integer)";

    public static final String DROP_CATEGORY="drop table if exists Category";

    public MyDatabaseHelper(@Nullable Context context, @Nullable String name, @Nullable SQLiteDatabase.CursorFactory factory, int version) {
        super(context, name, factory, version);
        this.mContext=context;
    }

    //数据库不存在的前提下,创建数据库完成后会调用该方法
    @Override
    public void onCreate(SQLiteDatabase sqLiteDatabase) {
        sqLiteDatabase.execSQL(CREATE_BOOK_TABLE);
        sqLiteDatabase.execSQL(CREATE_CATEGORY_TABLE);//新增这张表
        //Toast.makeText(mContext, "相关表创建完成", Toast.LENGTH_SHORT).show();
    }

    //数据库版本发生变化的时候,创建数据库操作后,会调用该方法
    @Override
    public void onUpgrade(SQLiteDatabase sqLiteDatabase, int i, int i1) {
        sqLiteDatabase.execSQL(DROP_BOOK);
        sqLiteDatabase.execSQL(DROP_CATEGORY);
        onCreate(sqLiteDatabase);
    }
}
  1. 创建内容提供器为别的程序访问我们的数据提供接口
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;

public class DatabaseProvider extends ContentProvider {

    public static final int BOOK_DIR=0;
    public static final int BOOK_ITEM=1;
    public static final int CATEGORY_DIR=2;
    public static final int CATEGORY_ITEM=3;

    public static final String AUTHORITY="com.xiaomi.databasetest.provider";
    private static UriMatcher uriMatcher;

    private MyDatabaseHelper dbHelper;

    static {
        uriMatcher=new UriMatcher(UriMatcher.NO_MATCH);
        uriMatcher.addURI(AUTHORITY,"book",BOOK_DIR);
        uriMatcher.addURI(AUTHORITY,"book/#",BOOK_ITEM);
        uriMatcher.addURI(AUTHORITY,"category",CATEGORY_DIR);
        uriMatcher.addURI(AUTHORITY,"category/#",CATEGORY_ITEM);
    }

    public DatabaseProvider() {
    }

    @Override
    public boolean onCreate() {
        // TODO: Implement this to initialize your content provider on startup.
        
        dbHelper=new MyDatabaseHelper(getContext(),"BookStore.db",null,1);
        return true;
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {
        // TODO: Implement this to handle query requests from clients.
        Cursor cursor=null;
        SQLiteDatabase database = dbHelper.getReadableDatabase();
        switch (uriMatcher.match(uri))
        {
            case BOOK_DIR:
                cursor=database.query("Book",projection,selection,selectionArgs,null,null,sortOrder);
                break;
            case BOOK_ITEM:
                String bookID = uri.getPathSegments().get(1);
                cursor=database.query("Book",projection,"id = ?",new String[]{bookID},null,null,sortOrder);
                break;
            case CATEGORY_DIR:
                cursor=database.query("Category",projection,selection,selectionArgs,null,null,sortOrder);
                break;
            case CATEGORY_ITEM:
                String categoryID = uri.getPathSegments().get(1);
                cursor=database.query("Category",projection,"id = ?",new String[]{categoryID},null,null,sortOrder);
                break;
            default:
                break;
        }
        return cursor;
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        // TODO: Implement this to handle requests to insert a new row.
        SQLiteDatabase writableDatabase = dbHelper.getWritableDatabase();
        Uri resUri=null;
        long newID=-1;
        switch (uriMatcher.match(uri))
        {
            case BOOK_DIR:
            case BOOK_ITEM:
                newID = writableDatabase.insert("Book", null, values);
                resUri=Uri.parse("content://"+AUTHORITY+"/book/"+newID);
                break;
            case CATEGORY_DIR:
            case CATEGORY_ITEM:
                newID = writableDatabase.insert("Category", null, values);
                resUri=Uri.parse("content://"+AUTHORITY+"/category/"+newID);
                break;
            default:
                break;
        }
        return resUri;
    }


    @Override
    public int update(Uri uri, ContentValues values, String selection,
                      String[] selectionArgs) {
        // TODO: Implement this to handle requests to update one or more rows.
        SQLiteDatabase writableDatabase = dbHelper.getWritableDatabase();
        int updatedRows=0;
        switch (uriMatcher.match(uri))
        {
            case BOOK_DIR:
                updatedRows = writableDatabase.update("Book", values, selection, selectionArgs);
                break;
            case BOOK_ITEM:
                String bookID = uri.getPathSegments().get(1);
                updatedRows = writableDatabase.update("Book", values, "id = ?", new String[]{bookID});
                break;
            case CATEGORY_DIR:
                updatedRows = writableDatabase.update("Category", values, selection, selectionArgs);
                break;
            case CATEGORY_ITEM:
                String categoryID = uri.getPathSegments().get(1);
                updatedRows = writableDatabase.update("Category", values, "id = ?", new String[]{categoryID});
                break;
            default:
                break;
        }
        return updatedRows;
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        // Implement this to handle requests to delete one or more rows.
        SQLiteDatabase writableDatabase = dbHelper.getWritableDatabase();
        int deleteRows=0;
        switch (uriMatcher.match(uri))
        {
            case BOOK_DIR:
                deleteRows = writableDatabase.delete("Book", selection, selectionArgs);
                break;
            case BOOK_ITEM:
                String bookID = uri.getPathSegments().get(1);
                deleteRows=writableDatabase.delete("Book","id = ?",new String[]{bookID});
                break;
            case CATEGORY_DIR:
                deleteRows = writableDatabase.delete("Category", selection, selectionArgs);
                break;
            case CATEGORY_ITEM:
                String categoryID = uri.getPathSegments().get(1);
                deleteRows=writableDatabase.delete("Category","id = ?",new String[]{categoryID});
                break;
            default:
                break;
        }
        return deleteRows;
    }

    @Override
    public String getType(Uri uri) {
        // TODO: Implement this to handle requests for the MIME type of the data
        // at the given URI.
        switch (uriMatcher.match(uri))
        {
            case BOOK_DIR:
                return "vnd.android.cursor.dir/vnd.com.xiaomi.databasetest.provider.book";
            case BOOK_ITEM:
                return "vnd.android.cursor.item/vnd.com.xiaomi.databasetest.provider.book";
            case CATEGORY_DIR:
                return "vnd.android.cursor.dir/vnd.com.xiaomi.databasetest.provider.category";
            case CATEGORY_ITEM:
                return "vnd.android.cursor.item/vnd.com.xiaomi.databasetest.provider.category";
        }
        return null;
    }# 参考书籍:第一行代码
链接:https://pan.baidu.com/s/1aXtOQCXL6qzxEFLBlqXs1Q?pwd=n5ag
提取码:n5ag
}
  1. 在AndroidManifest.xml中注册内容提供器
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.xiaomi.databasetest">

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.DatabaseTest"
        tools:targetApi="31">

        <provider
            android:name=".DatabaseProvider"
            android:authorities="com.xiaomi.databasetest.provider"
            android:enabled="true"
            android:exported="true" />

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>
  1. 其它程序通过我们定义的内容提供器访问我们的数据
  • 插入数据代码示例
ContentResolver contentResolver = getContentResolver();

ContentValues contentValues = new ContentValues();
contentValues.put("name","这个世界真是很美好呀");
contentValues.put("author","从外部程序来的作者");
contentValues.put("pages",666);
contentValues.put("price",19.99);

Uri uri = Uri.parse("content://com.xiaomi.databasetest.provider/book");
Uri newUri = contentResolver.insert(uri, contentValues);
String newID = newUri.getPathSegments().get(1);//返回的新插入数据的id
Toast.makeText(MainActivity.this, "添加数据成功", Toast.LENGTH_SHORT).show();
  • 查询数据代码示例
Uri uri = Uri.parse("content://com.xiaomi.databasetest.provider/book");
Cursor cursor = getContentResolver().query(uri, null, null, null, null);
if (cursor==null)
{
	Toast.makeText(MainActivity.this, "未查询到数据", Toast.LENGTH_SHORT).show();
	return;
}
if (cursor.moveToFirst())
{
	StringBuilder stringBuilder = new StringBuilder();
	stringBuilder.append("name"+"     "+"author"+"     "+"pages"+"     "+"price\r\n");
	do {
		String name = cursor.getString(cursor.getColumnIndex("name"));
		String author = cursor.getString(cursor.getColumnIndex("author"));
		int pages = cursor.getInt(cursor.getColumnIndex("pages"));
		double price = cursor.getDouble(cursor.getColumnIndex("price"));
		stringBuilder.append(name+"     "+author+"     "+pages+"     "+price+"\r\n");
}while (cursor.moveToNext());
  • 更新数据示例代码:
Uri uri = Uri.parse("content://com.xiaomi.databasetest.provider/book/"+newID);
ContentValues contentValues = new ContentValues();
contentValues.put("price","26.66");
contentValues.put("name","这个世界多么美好");
getContentResolver().update(uri,contentValues,null,null);
  • 删除代码示例
Uri uri = Uri.parse("content://com.xiaomi.databasetest.provider/book/"+newID);
getContentResolver().delete(uri,null,null);

参考书籍:第一行代码

链接:https://pan.baidu.com/s/1aXtOQCXL6qzxEFLBlqXs1Q?pwd=n5ag
提取码:n5ag

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Android内容提供Android中的一种组件,用于实现应用程序之间的数据共享。它运用了Android中的Binder机制,该机制是一种实现进程通信的方式。通过内容提供,我们可以安全地访问其他应用程序中的数据,同时可以控制开发哪些数据供其他程序访问,从而提高数据共享的安全性。使用内容提供可以让应用程序获取其他应用程序数据,实现一些更有意思的功能。但是需要注意的是,运行时权限的应用,即需要主人决定是否授予权限,以确保数据共享的安全性。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *3* [程序共享数据Android四大组件内容提供](https://blog.csdn.net/qq_34720818/article/details/127972494)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *2* [Android组件之内容提供](https://blog.csdn.net/Cristiano_san/article/details/108504505)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Mekeater

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值