记录学习Android基础的心得04:Android五大存储&P1


前言

似此星辰非昨夜,为谁风露立中宵。------黄景仁《绮怀》

文件IO是我们学习任何一个框架都必须掌握的!Android提供了以下五种存储方式:基于java语言原生支持的文件读写,采用.xml格式的文件存储简单数据的SharedPreferences共享参数,基于application的全局内存,基于数据库的SQLite轻量级数据库和ContentProvider内容提供器。存储的使用原则:对于少量和一些非关键的数据都应存放在手机客服端,而大量的数据和业务关键数据都应放在服务器端。接下来由易到难总结这几个存储。


一、java语言原生支持的文件读写

众所周知,Android开发即可采用java语言又可以采用Kotlin语言编写,所以java中的关于文件IO的操作完全可以稍加修改就适配到Android的开发中,而这些内容对大部分人来说是滚瓜烂熟了,我也就不班门弄斧。
在这里插入图片描述

1.文件的操作

Android的文件操作稍微有点不同,体现在文件存放的位置上,因为手机的存储分为两个部分:内部存储(ROM存储)和外部存储(SD卡)。众所周知,以前手机的外部存储,也就是SD卡,是可以单独拆卸的,但是现在的手机已经把SD固化了,我们使用的手机自带的文件浏览器,默认就是浏览外部存储,访问ROM内部存储需要一些专门的文件浏览器。
获取手机上的存储信息可以使用Environment类的相关方法查看,这个类是获取各种目录信息的工具类,查阅在线API文档
public class Environment
extends Object
可以看到其内部的常量有两大类:关于存储器状态的常量和关于常用文件的目录路径:

存储器状态
String MEDIA_BAD_REMOVAL
存储器在卸载之前被删除。

String MEDIA_CHECKING
存储器存在并正在进行磁盘检查状态。

String MEDIA_EJECTING
存储器正在被弹出的过程中。

String MEDIA_MOUNTED
存储器存在并且可以读/写访问。

String MEDIA_MOUNTED_READ_ONLY
存储器存在并且以只读访问权限挂载。

String MEDIA_NOFS
存储器存在但空白或正在使用不受支持的文件系统。

String MEDIA_REMOVED
存储器不存在。

String MEDIA_SHARED
存储器未安装,并通过USB存储共享。

String MEDIA_UNKNOWN
未知存储状态,例如路径未知。

String MEDIA_UNMOUNTABLE
存储器存在但无法安装。

String MEDIA_UNMOUNTED
存储器存在但未安装。

常用文件的目录路径
public static String DIRECTORY_ALARMS
用于放置音频文件的标准目录,该文件应位于用户可以选择的闹铃列表中(而不是普通音乐)。

public static String DIRECTORY_DCIM
相机使用的的照片和视频位置。

public static String DIRECTORY_DOCUMENTS
在其中放置用户创建的文档的标准目录。

public static String DIRECTORY_DOWNLOADS
用于放置用户下载文件的标准目录。

public static String DIRECTORY_MOVIES
用于放置用户可用电影的标准目录。

public static String DIRECTORY_MUSIC
用于放置音频文件的标准目录,该文件应该位于用户的常规音乐列表中。

public static String DIRECTORY_NOTIFICATIONS
用于放置音频文件的标准目录,该文件应该位于用户可以选择的通知列表中(而不是普通音乐)。

public static String DIRECTORY_PICTURES
在其中放置可供用户使用的图片的标准目录。

public static String DIRECTORY_PODCASTS
用于放置任何音频文件的标准目录,该文件应该位于用户可以选择的播客列表中(而不是普通音乐)。

public static String DIRECTORY_RINGTONES
用于放置任何音频文件的标准目录,该文件应位于用户可以选择的铃声列表中(而不是普通音乐)。

再来看看其静态方法
static File getDataDirectory()
返回用户数据目录。

static File getDownloadCacheDirectory()
返回下载/缓存内容目录。

static File getExternalStorageDirectory()
返回外部存储目录。

static File getExternalStoragePublicDirectory(String type)
获取用于外部存储中公有目录中放置的特定类型文件。

static String getExternalStorageState()
返回外部存储的当前状态。

static String getExternalStorageState(File path)
返回给定路径上的外部存储的当前状态。

static File getRootDirectory()
返回持有核心Android OS的“系统”分区的根目录。

static String getStorageState(File path)
此方法在API级别21中已弃用。请使用getExternalStorageState(File)

static boolean isExternalStorageEmulated()
是否模拟外部存储。

static boolean isExternalStorageEmulated(File path)
是否仿真给定路径上的外部存储。

static boolean isExternalStorageRemovable()
外部存储介质是否可物理移除。

static boolean isExternalStorageRemovable(File path)
给定路径上的外部存储是否可物理移除。

外部存储又分为了公有存储空间与私有存储空间。公有存储空间是所有应用均可访问的公共空间,用于存放一些系统自带的软件生成的文件,比如图片,视频,音乐等。私有存储空间是只有应用程序自己才可以访问的专享空间,位于”/Android/data/包名/“路径中,用于给应用程序保存需要临时处理的文件。显然,这两个存储空间的路径获取不同:

		 // 获取公共存储路径
        String publicPath = Environment.getExternalStoragePublicDirectory(Environment.文件类型).toString();
        // 获取私有存储路径
        String privatePath = getExternalFilesDir(Environment.文件类型).toString();

了解其常用方法之后,还要在配置文件中声明操作SD卡外部存储的权限:

    <!-- SD卡读写权限 -->
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAG" />

2.文件内容的操作

java拥有一个流家族,包含几十个流类,按照他们的功能可以分为:输入流和输出流类;按照他们的操作数据的方式,也可分为两类:使用字节流和使用字符流操作。这些流的来源或者目标可以是内存块,磁盘文件,网络连接等,基础的流可以单独使用,也可以被高级的流包装起来使用,从而使IO操作更简单。我们可以把IO文本,IO图片等常用的文件操作封装成一个工具类,每当我们在项目中使用到文件的操作时,就可以调用相关的方法,这样就减少了重复编码,如果在API>=19的环境下编码,还可以使用try-with-resources语句,自动释放流。
以下是java中文件IO的常识:

//省略导入语句
public class FileUtil {

    // 把字符串保存到指定路径的文本文件
    public static void saveText(String path, String txt) {
        try {
            // 根据指定文件路径构建文件输出流对象
            FileOutputStream fos = new FileOutputStream(path);
            // 把字符串写入文件输出流
            fos.write(txt.getBytes());
            // 关闭文件输出流
            fos.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // 从指定路径的文本文件中读取内容字符串
    public static String openText(String path) {
        String readStr = "";
        try {
            // 根据指定文件路径构建文件输入流对象
            FileInputStream fis = new FileInputStream(path);
            byte[] b = new byte[fis.available()];
            // 从文件输入流读取字节数组
            fis.read(b);
            // 把字节数组转换为字符串
            readStr = new String(b);
            // 关闭文件输入流
            fis.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
        // 返回文本文件中的文本字符串
        return readStr;
    }

    // 把位图数据保存到指定路径的图片文件
    public static void saveImage(String path, Bitmap bitmap) {
        try {
            // 根据指定文件路径构建缓存输出流对象
            BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(path));
            // 把位图数据压缩到缓存输出流中
            bitmap.compress(Bitmap.CompressFormat.JPEG, 80, bos);
            // 完成缓存输出流的写入动作
            bos.flush();
            // 关闭缓存输出流
            bos.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // 从指定路径的图片文件中读取位图数据
    public static Bitmap openImage(String path) {
        Bitmap bitmap = null;
        try {
            // 根据指定文件路径构建缓存输入流对象
            BufferedInputStream bis = new BufferedInputStream(new FileInputStream(path));
            // 从缓存输入流中解码位图数据
            bitmap = BitmapFactory.decodeStream(bis);
            bis.close(); // 关闭缓存输入流
        } catch (Exception e) {
            e.printStackTrace();
        }
        // 返回图片文件中的位图数据
        return bitmap;
    } 
}

二、SharedPreferences共享参数

Android可以使用SharedPreferences共享参数,采用.xml格式的文件来存储简单数据,其文件结构是键值对,xml文件路径在”/data/data/包名/shared_prefs“下。没有啥操作难度,做一个朴实无华的调库人员即可!
在这里插入图片描述

查阅API文档

public interface SharedPreferences
Nested classes
interface SharedPreferences.Editor
interface SharedPreferences.OnSharedPreferenceChangeListener
Public methods
abstract boolean contains(String key)
检查是否包指定的key。

abstract SharedPreferences.Editor edit()
通过它可以对数据进行修改,并自动将这些更改提交给SharedPreferences对象。

abstract Map<String, ?> getAll()
检索所有值。

abstract boolean get基本类型(String key, 基本类型defValue)
检索基本类型对应的key,指定默认值。

abstract void registerOnSharedPreferenceChangeListener(SharedPreferences.OnSharedPreferenceChangeListener listener)
注册发生更改时调用的回调。

abstract void unregisterOnSharedPreferenceChangeListener(SharedPreferences.OnSharedPreferenceChangeListener listener)
取消注册以前的回叫。
显然,通过分析,SharedPreferences这个接口需要使用Context类的
SharedPreferences getSharedPreferences (String name, int mode)方法来获取对象,同时借助这个接口的内部接口Editor修改SharedPreferences文件中的值,而获取key对应的值则不需要。Editor提供了一些简单的put方法来存入数据,最后提交给SharedPreferences则需要使用:
abstract void apply()
在后台提交到正在编辑的SharedPreferences对象,不会阻塞线程。

abstract boolean commit()
立即提交到正在编辑的SharedPreferences对象。

这里同样采取一个工具类来封装一些简单的文件操作方法。

//省略导入语句
public class SharedUtil {
    private static SharedUtil mUtil; // 声明一个共享参数工具类的实例
    private static SharedPreferences mShared; // 声明一个共享参数的实例
    
    // 通过单例模式获取共享参数工具类的唯一实例
    public static SharedUtil getIntance(Context ctx) {
        if (mUtil == null) {
            mUtil = new SharedUtil();
        }
        // 从share.xml中获取共享参数对象
        mShared = ctx.getSharedPreferences("share", Context.MODE_PRIVATE);
        return mUtil;
    }

    // 把配对信息写入共享参数
    public void writeShared(String key, String value) {
        SharedPreferences.Editor editor = mShared.edit(); // 获得编辑器的对象
        editor.putString(key, value); // 添加一个指定键名的字符串参数
        editor.commit(); // 提交编辑器中的修改
    }

    // 根据键名到共享参数中查找对应的值对象
    public String readShared(String key, String defaultValue) {
        return mShared.getString(key, defaultValue);
    }
}


三、基于application的全局内存

众所周知,java中使用全局内存是通过一个公共类中添加静态成员实现的,在Android的框架下,可以使用application,是不是对这个名字很熟悉?在程序运行过程中,有且仅有一个application贯穿整个生命周期,我们可以在各个activity中使用这个全局内存,它用于保存一些快速处理的文件。在配置文件中最顶层的节点便是它了,需要以下步骤才能正确使用之:

1.在代码中继承这个类,采用单例模式获取唯一对象,重写其生命周期实例化对象(只需重写onCreate方法即可)。然后添加需求的数据结构,如队列,哈希映射,链表等,来保存数据。

//省略导入语句
public class MainApplication extends Application {
    // 声明一个当前应用的静态实例
    private static MainApplication mApp;
    // 利用单例模式获取当前应用的唯一实例
    public static MainApplication getInstance() {
        return mApp;
    }

    @Override
    public void onCreate() {
        super.onCreate();
        // 在打开应用时对静态的应用实例赋值
        mApp = this;
    }

    // 声明一个公共的图片哈希映射对象
    public HashMap<Long, Bitmap> mIconMap = new HashMap<Long, Bitmap>();
    // 声明一个公共的信息映射对象,可当作全局变量使用
    public HashMap<String, String> mInfoMap = new HashMap<String, String>();

}

2.在配置文件中需要为application节点配置一个名字,即自定义类名。如:

android:name=".MainApplication"

3.在代码的任意位置皆可以获取其实例,来操作数据。

四、SQLite轻量级数据库

大部分人都有JDBC的编程经验,熟悉SQL语句,那么学习SQLite就是手到擒来,毫不费力的。
在这里插入图片描述

1.语法区别

SQLite是个轻量级的嵌入式数据库,SQLite的多数SQL语法与其他数据库一 样,只需注意以下几点:
1.建表时为避免重复操作,应加上IF NOT EXISTS关键词,例如CREATE TABLE IF NOT EXISTS 表名
2.删表时为避免重复操作,应加上IF EXISTS关键词,例如DROP TABLE IF EXISTS 表名
3.添加新列时使用ALTER TABLE 表名 ADD COLUMN 列名
4.在SQLite中,ALTER语句每次只能添加1列,如果要添加多列,就只能分多次调用。
5.SQLite支持整整型NTEGER、字符串VARCHAR、浮点数FLOAT,但不支持布尔类型。如果直接保存布尔数据,在入库时SQLite就会自动将其转为0或1,0表示false, 1表示true.
6. SQLite建表时需要一个唯一 标识字段, 字段名为_id.每建一张新表都要加上该字段定义,具体属性定义为_id INTEGER PRIMARY KEY AUTONCREMENT NOT NULL。
7. 条件语句等号后面的字符串值要用单引号包括。

2.使用方法

我们可以在得到Context实例的代码里使用openOrCreateDatabase获得一个SQLiteDatabase对象,这是一个数据库管理类,提供了完整的对数据库的一切操作方法。

查阅API文档:
public final class SQLiteDatabase
extends SQLiteClosable
SQLiteDatabase提供常用的方法有3类:
1.管理数据库的方法,用于数据库层面的操作,比如数据库的创建和删除,数据库版本号设置。
2.用于事务层面的操作方法。
3.数据的处理方法,用于数据库中的表层面的操作,如使用SQL语句对数据增删查改。

虽然SQLiteDatabase提供了管理数据库的方法,但是,我们还是使用SQLiteOpenHelper数据库帮助器来包装SQLiteDatabase,进而安全便捷的操作数据库。。。

查阅API文档:
public abstract class SQLiteOpenHelper
extends Object

SQLiteOpenHelper 的具体使用步骤如下:
1.新建一个继承自SQLiteOpenHelper的类,重写onCreate和onUpgrade两个方法。其中,onCreate方法只在第一次创建数据库时执行,在此可进行表结构创建的操作,onUpgrade方法在数据库版本升高时执行。
2.封装保证数据库安全的必要方法, 包括获取单例对象,打开数据库连接,关闭数据库连接。
获取单例对象,确保App运行时数据库只被打开一次,避免重复打开引起错误。
打开数据库连接, SQLite 有读和写的SQLiteDatabase数据库连接,读连接可调用SQLiteOpenHelper 的getReadableDatabase方法获得,写连接getWritableDatabase获得。
关闭数据库连接: 数据库操作完毕后,应当调用SQLiteDatabase对象的close方法。
3.提供对表记录进行增加,删除、修改.查询的操作方法:

被SQLite 直接使用的数据结构是ContenValues类,类似于映射Map,提供put和get方法用来存取键值对。区别之处在于ContenValues 的键只能是字符串。ContentValues主要用于记录增加和更新操作,即SQLiteDatabase的insert和update方法。

对于查询操作来说,使用的是游标Cursor,该接口提供对数据库查询返回的结果集的随机读写访问。
调用SQLiteDatabase 的query和rawQuery方法时,返回的都是Cursor对象。其有如下三类方法:

(1)游标控制类方法,用于指定游标的状态。
(2)游标移动类方法,把游标移动到指定位置。
(3)获取记录类方法,可获取记录的数量、类型以及取值。

3.一个简单的DEMO

实现一个使用数据库来存储姓名和电话的DEMO。(以下代码省略import语句)
首先创建一个简单的UserInfo保存记录标号,姓名和电话

public class UserInfo {
    public long rowid;
    public String name;
    public int phone;

    public UserInfo() {
        rowid = 0L;
        name = "";
        phone = "";
    }
}

创建继承自SQLiteOpenHelper 的类,重写并添加一些方法

public class UserDBHelper extends SQLiteOpenHelper {
    private static final String DB_NAME = "user.db"; // 数据库的名称
    private static final int DB_VERSION = 1; // 数据库的版本号
    private static UserDBHelper mHelper = null; // 数据库帮助器的实例
    private SQLiteDatabase mDB = null; // 数据库的实例
    public static final String TABLE_NAME = "user_info"; // 表的名称

    private UserDBHelper(Context context) {
        super(context, DB_NAME, null, DB_VERSION);
    }

    private UserDBHelper(Context context, int version) {
        super(context, DB_NAME, null, version);
    }

    // 利用单例模式获取数据库帮助器的唯一实例
    public static UserDBHelper getInstance(Context context, int version) {
        if (version > 0 && mHelper == null) {
            mHelper = new UserDBHelper(context, version);
        } else if (mHelper == null) {
            mHelper = new UserDBHelper(context);
        }
        return mHelper;
    }

    // 打开数据库的读连接
    public SQLiteDatabase openReadLink() {
        if (mDB == null || !mDB.isOpen()) {
            mDB = mHelper.getReadableDatabase();
        }
        return mDB;
    }

    // 打开数据库的写连接
    public SQLiteDatabase openWriteLink() {
        if (mDB == null || !mDB.isOpen()) {
            mDB = mHelper.getWritableDatabase();
        }
        return mDB;
    }

    // 关闭数据库连接
    public void closeLink() {
        if (mDB != null && mDB.isOpen()) {
            mDB.close();
            mDB = null;
        }
    }

    // 创建数据库,执行建表语句
    public void onCreate(SQLiteDatabase db) {
        String drop_sql = "DROP TABLE IF EXISTS " + TABLE_NAME + ";";
        db.execSQL(drop_sql);
        String create_sql = "CREATE TABLE IF NOT EXISTS " + TABLE_NAME + " ("
                + "_id INTEGER PRIMARY KEY  AUTOINCREMENT NOT NULL,"
                + "name VARCHAR NOT NULL," 
                + "phone VARCHAR" 
                + ");";
        db.execSQL(create_sql);
    }

    // 修改数据库,执行表结构变更语句
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) 
    {
    }

    // 根据指定条件删除表记录
    public int delete(String condition) {
        // 执行删除记录动作,该语句返回删除记录的数目
        return mDB.delete(TABLE_NAME, condition, null);
    }

    // 删除该表的所有记录
    public int deleteAll() {
        // 执行删除记录动作,该语句返回删除记录的数目
        return mDB.delete(TABLE_NAME, "1=1", null);
    }

    // 往该表添加一条记录
    public long insert(UserInfo info) {
        ArrayList<UserInfo> infoArray = new ArrayList<UserInfo>();
        infoArray.add(info);
        return insert(infoArray);
    }

    // 往该表添加多条记录
    public long insert(ArrayList<UserInfo> infoArray) {
        long result = -1;
        for (int i = 0; i < infoArray.size(); i++) {
            UserInfo info = infoArray.get(i);
            ArrayList<UserInfo> tempArray = new ArrayList<UserInfo>();
            // 如果存在同名记录,则更新记录
            // 注意条件语句的等号后面要用单引号括起来
            if (info.name != null && info.name.length() > 0) {
                String condition = String.format("name='%s'", info.name);
                tempArray = query(condition);
                if (tempArray.size() > 0) {
                    update(info, condition);
                    result = tempArray.get(0).rowid;
                    continue;
                }
            }
            // 如果存在同样的手机号码,则更新记录
            if (info.phone != null && info.phone.length() > 0) {
                String condition = String.format("phone='%s'", info.phone);
                tempArray = query(condition);
                if (tempArray.size() > 0) {
                    update(info, condition);
                    result = tempArray.get(0).rowid;
                    continue;
                }
            }
            // 不存在唯一性重复的记录,则插入新记录
            ContentValues cv = new ContentValues();
            cv.put("name", info.name);
            cv.put("phone", info.phone);
            // 执行插入记录动作,该语句返回插入记录的行号
            result = mDB.insert(TABLE_NAME, "", cv);
            // 添加成功后返回行号,失败后返回-1
            if (result == -1) {
                return result;
            }
        }
        return result;
    }

    // 根据条件更新指定的表记录
    public int update(UserInfo info, String condition) {
        ContentValues cv = new ContentValues();
        cv.put("name", info.name);
        cv.put("phone", info.phone);
        // 执行更新记录动作,该语句返回记录更新的数目
        return mDB.update(TABLE_NAME, cv, condition, null);
    }

    public int update(UserInfo info) {
        // 执行更新记录动作,该语句返回记录更新的数目
        return update(info, "rowid=" + info.rowid);
    }

    // 根据指定条件查询记录,并返回结果数据队列
    public ArrayList<UserInfo> query(String condition) {
        String sql = String.format("select rowid,_id,name," +
                "phone from %s where %s;", TABLE_NAME, condition);
        ArrayList<UserInfo> infoArray = new ArrayList<UserInfo>();
        // 执行记录查询动作,该语句返回结果集的游标
        Cursor cursor = mDB.rawQuery(sql, null);
        // 循环取出游标指向的每条记录
        while (cursor.moveToNext()) {
            UserInfo info = new UserInfo();
            info.rowid = cursor.getLong(0); // 取出长整型数
            info.name = cursor.getString(1); // 取出字符串
            info.phone = cursor.getString(3);
            infoArray.add(info);
        }
        cursor.close(); // 查询完毕,关闭游标
        return infoArray;
    }

    // 根据手机号码查询指定记录
    public UserInfo queryByPhone(String phone) {
        UserInfo info = null;
        ArrayList<UserInfo> infoArray = query(String.format("phone='%s'", phone));
        if (infoArray.size() > 0) {
            info = infoArray.get(0);
        }
        return info;
    }

}

新建一个activity叫SQLiteWriteActivity,其简单页面布局如下:
在这里插入图片描述
SQLiteWriteActivity代码如下:注意,重写onStart并打开数据库连接,重写onStop并关闭数据库连接!

public class SQLiteWriteActivity extends AppCompatActivity implements View.OnClickListener {
    private UserDBHelper mHelper; // 声明一个用户数据库帮助器的对象
    private EditText et_name;
    private EditText et_phone;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_s_q_lite_write);
        et_name = findViewById(R.id.et_name);
        et_phone = findViewById(R.id.et_phone);
        findViewById(R.id.btn_save).setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        if (v.getId() == R.id.btn_save) {
            String name = et_name.getText().toString();
            String phone = et_phone.getText().toString();
            if (TextUtils.isEmpty(name)) {
                Toast.makeText(this, "不能为空", Toast.LENGTH_SHORT).show();
                return;
            } else if (TextUtils.isEmpty(phone)) {
                Toast.makeText(this, "不能为空", Toast.LENGTH_SHORT).show();
                return;
            }
            // 以下声明一个用户信息对象,并填写它的各字段值
            UserInfo info = new UserInfo();
            info.name = name;
            info.phone = phone;
            // 执行数据库帮助器的插入操作
            mHelper.insert(info);
        }
    }
    @Override
    protected void onStart() {
        super.onStart();
        // 获得数据库帮助器的实例
        mHelper = UserDBHelper.getInstance(this, 2);
        // 打开数据库帮助器的写连接
        mHelper.openWriteLink();
    }

    @Override
    protected void onStop() {
        super.onStop();
        // 关闭数据库连接
        mHelper.closeLink();
    }
}

一顿操作之后,看到数据已经写入/data/data/com.example.firstapplication/databases/user.db这个文件,至于读取操作就大同小异,不再赘述了。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

搬砖工人_0803号

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

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

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

打赏作者

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

抵扣说明:

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

余额充值