阅读Android官方教程 Google Training 1.5 ----- Save Data

本文深入讲解Android应用中数据存储的多种方式,包括SharedPreferences保存键值对、文件存储、以及SQLite数据库管理等内容。针对每种存储方式的特点、应用场景及具体实现方法进行了详细说明。
摘要由CSDN通过智能技术生成

阅读谷歌官方教程第一章 Getting Started


今天看了Google Training 教程里面的第一章第五小节,讲了Android系统是如何保存数据的,包括以键值对形式保存的key - value set,以文件形式存储的 file 以及适用于大量重复数据,契约型的数据库存储 SQLite 等相关知识。可惜的是,这节Google Training上是中文,翻译过来有很多地方都很怪,英文版又找不到,真是难受啊。


Save Data

大多数 Android 应用需要保存数据,即使仅保存在 onPause() 过程中与应用状态有关的信息,以便用户进度不会丢失 。 大多数非平凡应用也需要保存用户设置,并且有些应用必须在文件和数据库中管理大量的信息。 Android 中的主要数据存储选项,包括:

  • 在 SharedPreferences 中保存简单数据类型的键值对
  • 在 Android 的文件系统中保存任意文件
  • 使用 SQLite 管理的数据库

第一个内容:Saving Key-Value Sets

如果我们想要保存相对较小的键值集合,我们就可以使用SharedPreferences API,SharedPreferences 指向包含键值集合的文件,并且提供了对其相应的读写方法。每个SharedPreferences可以进行管理,设置它的可访问性,比如说只让本身应用访问,或是可以和其他的应用共享文件信息。

官方上有这么一个提示:

注意:SharedPreferences API 仅用于读写键值对,您不得将其与 Preference API 混淆,后者帮助应用设置构建用户界面

至于Preference API 是干么用我也不知道,我在Android Studio创建项目的时候选择其他类型时,在代码默认的初始代码里见过,总之运行起来很流畅,很酷炫,日后学习。


1.获取SharedPreferences对象

我们可以通过调用以下两种方法之一创建新的SharedPreferences文件或访问现有的文件:

  • getSharedPreferences() — 如果需要用第一个参数指定的名称识别的多个共享首选项文件,使用此方法。 可以从应用中的任何 Context 调用此方法。

  • getPreferences() — 如果只需使用Activity的一个SharedPreferences,请从 Activity 中使用此方法。 因为此方法会检索属于该Activity的默认SharedPreferences文件,无需我们提供名称。

学到新东西了,第一个方法的意思是我们也已在任何的Activity都访问,但是要提供想访问SharedPreferences的名字,第二个方法的意思是我们就访问当前Activity的默认SharedPreferences文件,所以第二个方法就不同提供名称了。

官方给出了一个在Fragment中访问的示例,它访问通过资源字符串 R.string.preference_file_key 识别的共享首选项文件并且使用专用模式打开它,从而仅允许自己的应用访问文件:

Context context = getActivity();
SharedPreferences sharedPref = context.getSharedPreferences(
        getString(R.string.preference_file_key), Context.MODE_PRIVATE);

而且命名的时候我们要起个有标志性的名字,看到这个名字就知道是这个应用的文件,这样能保证不会混乱,所以,我们一般都应该用应用的包名来给文件命名,比如: “com.example.myapp.PREFERENCE_FILE_KEY”

如果只需Activity的一个SharedPreferences文件,可以使用 getPreferences() 方法:

SharedPreferences sharedPref = getActivity().getPreferences(Context.MODE_PRIVATE);

注意:如果您创建带 MODE_WORLD_READABLE 或 MODE_WORLD_WRITEABLE 的 SharedPreferences 文件,那么知道文件标识符 的任何其他应用都可以访问您的数据。


2.写入SharedPreferences文件

如果要写入SharedPreferences文件,那我们就应该先通过SharedPreferences的 edit() 创建一个 SharedPreferences. Editor。

然后用putInt() 或者是 putString() 写入的键和值,最后别忘了调用 commit() 保存更改,比如:

SharedPreferences sharedPref = getActivity().getPreferences(Context.MODE_PRIVATE);
SharedPreferences.Editor editor = sharedPref.edit();
editor.putInt(getString(R.string.saved_high_score), newHighScore);
editor.commit();

3.从SharedPreferences读取信息

如果要从SharedPreferences里面检索我们需要的值,我们应该用 getInt() 或是getString() 方法,放入key,然后还要加入一个如果没有检索到值返回的默认值,比如:

SharedPreferences sharedPref = getActivity().getPreferences(Context.MODE_PRIVATE);
int defaultValue = getResources().getInteger(R.string.saved_high_score_default);
long highScore = sharedPref.getInt(getString(R.string.saved_high_score), defaultValue);

第二个内容:Saving Files

File 对象适合按照从开始到结束的顺序不跳过地读取或写入大量数据。 例如,它适合于图像文件或通过网络交换的任何内容。

对于刚刚开学才学了大半年Android的我来说,这部分内容从来没有在编程中接触过,开始看Android的书的时候也是混混就过去了,不过看完了这节内容,还是很有收获,不过应该用项目来实践会更好些,现在先记录下来。


1.选择内部或外部存储

一般来说,手机有内存,4个G啊或是8个G,然后有SD卡,我原来一直以为内存就是内部存储,SD卡就是手机的外部存储,其实并不是这样的。在有些不能安装像SD卡这样的设备,我之前理解的“内存”,也分为内部和外部两部分,而且无论外部存储是不是可以移动,它们的API行为都是一致的。官方给出了两者的一些区别:

首先说内部存储:

  • 它始终可用。
  • 默认情况下只有您的应用可以访问此处保存的文件。
  • 当用户卸载您的应用时,系统会从内部存储中删除您的应用的所有文件。

问:那哪些应该存在内部存储里面呢?
答:当您希望确保用户或其他应用均无法访问您的文件时,内部存储是最佳选择。

再来看看外部存储:

  • 它并非始终可用,因为用户可采用 USB 存储的形式装载外部存储,并在某些情况下会从设备中将其删除(SD卡)。
  • 它是全局可读的,因此此处保存的文件可能不受您控制地被读取。
  • 当用户卸载您的应用时,只有在您通过 getExternalFilesDir() 将您的应用的文件保存在目录中时,系统才会从此处删除您的应用的文件。

问:那哪些应该存在外部存储里面呢?
答:对于无需访问限制以及您希望与其他应用共享或允许用户使用电脑访问的文件,外部存储是最佳位置。

从上面的对比发现,内部存储私密性更高,只能本身访问,而且卸载时会全部移除,与之相比,外部存储就显得比较暴露,其他应用可以访问,卸载时也会留一部分。恩,如果不太清楚,留什么没关系,继续看就明白了。

官方还给了我们一个提示:

提示:尽管应用默认安装在内部存储中,但您可在您的宣示说明中指定 android:installLocation 属性,这样您的应用便可安装在在外部存储中。 当 APK 非常大且它们的外部存储空间大于内部存储时,用户更青睐这个选择。

不过现在的智能手机都能通过本身的系统应用来改变应用的存放位置,比如应用搬家之类的功能,不过也有些应用,像支付宝这类对安全性要求比较高的APP,可能就不能搬了,现在也算知道原因了。


2.获取外部存储的权限

如果我们的APP要想外部存储区写一些数据,我们就要在manifest.xml文件里声明权限,如下所示:

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

但是值得我们注意的事,在默认情况下,所有的应用都可以读取外部存储的数据,而不用去声明对应的权限,但是Google给出了提示,说这个设定以后会取消,所以为了让我们的APP能够在Google取消这个默认的设定之后还能继续访问外部存储的数据,我们不妨也在manifest.xml文件里声明读取外部存储数据的权限。

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

但是Google说,如果我们已经声明了 WRITE_EXTERNAL_STORAGE 权限,那么它也隐含读取外部存储的权限。

相对外部而言,无需任何权限,我们即可在内部存储中保存文件。 APP始终具有在其内部存储目录中进行读写的权限。


3.将文件保存在内部存储中

因为这部分不太懂,我就直接引用官方的原话了:

在内部存储中保存文件时,您可以通过调用以下两种方法之一获取作为 File 的相应目录:

  • getFilesDir()
    • 返回表示您的应用的内部目录的 File
  • getCacheDir()
    • 返回表示您的应用临时缓存文件的内部目录的 File 。 务必删除所有不再需要的文件并对在指定时间您使用的内存量实现合理大小限制,比如,1MB。 如果在系统即将耗尽存储,它会在不进行警告的情况下删除您的缓存文件。

要在这些目录之一中新建文件,可以使用 File() 构造函数,传递指定内部存储目录。

File file = new File(context.getFilesDir(), filename);

或者,可以调用 openFileOutput() 获取写入到内部目录中的文件的 FileOutputStream 。例如,向文件写入一些文本:

String filename = "myfile";
String string = "Hello world!";
FileOutputStream outputStream;

try {
  outputStream = openFileOutput(filename, Context.MODE_PRIVATE);
  outputStream.write(string.getBytes());
  outputStream.close();
} catch (Exception e) {
  e.printStackTrace();
}

或者,如果您需要缓存某些文件,您应改用 createTempFile()。例如,以下方法从 URL 提取文件名并在您的应用的内部缓存目录中以该名称创建文件:

public File getTempFile(Context context, String url) {
    File file;
    try {
        String fileName = Uri.parse(url).getLastPathSegment();
        file = File.createTempFile(fileName, null, context.getCacheDir());
    } catch (IOException e) {
        // Error while creating file
    }
    return file;
}

对于这些关于File操作的API,我都不太懂,是个盲区,如果以后用到,再看API学习一下,现在先了解它的大概用法。

当然了,之前提到了APP文件存在内部存储似乎很安全,但是如果你把文件的权限设置成可读或者是可写,那么其他的应用就可以读取APP文件里的数据了,当然了它们也得知道文件的路径和文件名才能访问,只要我们把文件的权限声明成 MODE_PRIVATE ,这样就不会有应用能在内部访问到它们了。


3.将文件保存在外部存储中

由于外部存储可能用不了,比如说今天SD卡被我拿出来这种情况,所以,如果要想外部存储中写东西,一定要养成检查外部存储容量的好习惯。我们可以通过调用 getExternalStorageState() 方法去检查外部储存的状态,如果返回的状态为MEDIA_MOUNTED,那么我们就可以对文件进行读写了。

官方给出了如下例子,这个例子对确定文件是否可以读写非常有用:

/* Checks if external storage is available for read and write */
public boolean isExternalStorageWritable() {
    String state = Environment.getExternalStorageState();
    if (Environment.MEDIA_MOUNTED.equals(state)) {
        return true;
    }
    return false;
}

/* Checks if external storage is available to at least read */
public boolean isExternalStorageReadable() {
    String state = Environment.getExternalStorageState();
    if (Environment.MEDIA_MOUNTED.equals(state) ||
        Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
        return true;
    }
    return false;
}

好了,如果你在阅读上面的时候对系统删除应用会留什么,那句话不太懂,现在就到了解释疑问的时候了。

一般来说,外部存储的文件可以被用户和其他APP修改,但是它分为两种文件:

  • 公共文件

    • 可被用户和其他APP使用的文件,应用被卸载之后,公共文件会被被保留下来。比如新浪微博的照片,百度下载的应用。
  • 私有文件

    • 本来就属于这个APP的文件而且APP卸载的时候也会被删除掉的文件,虽然它在技术上来说可以被访问,但是它不会向其他的APP提供值, 当用户卸载APP的时候,系统会删除这个APP在外部存储上的专有目录的所有文件。

我觉得外部存储上的私有文件可以勉强理解为“APP的外部的内部存储”。

如果想要使用外部存储上的公有文件,使用 getExternalStoragePublicDirectory() 方法获取表示外部存储上相应目录的 File。该方法使用指定 您想要保存以便它们可以与其他公共文件在逻辑上组织在一起的文件类型的参数,比如 是音频之类的 - DIRECTORY_MUSIC 或 是图片之类 - DIRECTORY_PICTURES。其实参数的划分还是很细致的,比如声音还可以再分为铃声,大家可以根据实际情况到查询资料。

官方给出了使用外部的公有文件的示例:

public File getAlbumStorageDir(String albumName) {
    // Get the directory for the user's public pictures directory.
    File file = new File(Environment.getExternalStoragePublicDirectory(
            Environment.DIRECTORY_PICTURES), albumName);
    if (!file.mkdirs()) {
        Log.e(LOG_TAG, "Directory not created");
    }
    return file;
}

如果您要保存您的应用专用文件,您可以通过调用 getExternalFilesDir() 获取相应的目录并向其传递指示您想要的目录类型的名称。 通过这种方法创建的各个目录将添加至封装您的应用的所有外部存储文件的父目录,当用户卸载您的应用时,系统会删除这些文件。

官方给出了创建个人相册的示例:

public File getAlbumStorageDir(Context context, String albumName) {
    // Get the directory for the app's private pictures directory.
    File file = new File(context.getExternalFilesDir(
            Environment.DIRECTORY_PICTURES), albumName);
    if (!file.mkdirs()) {
        Log.e(LOG_TAG, "Directory not created");
    }
    return file;
}

如果没有适合您文件的预定义子目录名称,您可以改为调用 getExternalFilesDir() 并传递 null。这将返回外部存储上您的应用的专用目录 的根目录。

这句话我觉得是放在上面那个创建失败的 if (!file.mkdirs()) 里面的,就是公有的没了,那我们就存在私有文件里。

下面是官方给出的2点提示:

切记,getExternalFilesDir() 在用户卸载您的应用时删除的目录内创建目录。如果您正保存的文件应在用户卸载您的应用后仍然可用—比如,当您的应用是照相机并且用户要保留照片时—您应改用 getExternalStoragePublicDirectory()。

无论您对于共享的文件使用 getExternalStoragePublicDirectory() 还是对您的应用专用文件使用 getExternalFilesDir() ,您使用诸如 DIRECTORY_PICTURES 的 API 常数提供的目录名称非常重要。 这些目录名称可确保系统正确处理文件。 例如,保存在 DIRECTORY_RINGTONES 中的文件由系统介质扫描程序归类为铃声,而不是音乐。

简单的说,1,存之前想清楚那些要留,别存错了到时候没了;2,别乱指定文件的类型,要不然可能系统无法正确处理。


4.删除文件

要做好一个APP,应该保证APP的运行流畅,也要保证APP不过多占用设备多有的存储空间,所以我们应该经常删除那些不会再用到的文件,比如说乱七八糟的缓存文件,最直接的方法就是调用 delete() 方法。

myFile.delete();

如果文件保存在内部存储中,您还可以请求 Context 通过调用 deleteFile() 来定位和删除文件:

myContext.deleteFile(fileName);

如果我们卸载APP,系统将会删除以下内容:

  • 您保存在内部存储中的所有文件
  • 您使用 getExternalFilesDir() 保存在外部存储中的所有文件。

我们应该手动删除使用 getCacheDir() 定期创建的所有缓存文件并且定期删除不再需要的其他文件。


第三个内容:Saving Data in SQL Databases

相比于文件,数据库的内容我更为熟悉,之前要在SQLite里面存一个表,就先创建一个Java类来编好字段,然后用SQLiteOpenHelper 建表,最后在编写一个 Java 类 DBManager 来定义一些增删改查的方法,最后直接调用就好,但是会时不时出现卡顿的情况,如果按下最近应用的导航键的时候,系统会出现卡顿,也可能是数据库类长期运行,但没有把它放在后台线程的原因。不过看了Google的做法,感觉更加系统,各个方法之间也能很好的搭配,也多考虑的系统运行的流畅度。之前我好多关于数据库包里的方法都没用过,这次也学到了很多。


1.定义架构和契约

这个不知道是不是翻译的问题,感觉有点问题啊。没英文版,有问题也只能凑活看了。

SQL 数据库的主要原则之一是架构:数据库如何组织的正式声明。 架构体现于您用于创建数据库的 SQL 语句。您会发现它有助于创建伴随类,即契约 类,其以一种系统性、自记录的方式明确指定您的架构布局。

契约类是用于定义 URI、表格和列名称的常数的容器。 契约类允许您跨同一软件包中的所有其他类使用相同的常数。 您可以在一个位置更改列名称并使其在您整个代码中传播。

契约类是用于定义 URI、表格和列名称的常数的容器。 契约类允许您跨同一软件包中的所有其他类使用相同的常数。 您可以在一个位置更改列名称并使其在您整个代码中传播。

这个的大概意思就是和我开始先创建Java文件的意思一样,先编好这个表的字段。不过它更加系统。

注意:通过实现 BaseColumns 接口,您的内部类可继承调用的主键字段_ID ,某些 Android 类(比如光标适配器)将需要内部类拥有该字段。 这并非必需项,但可帮助您的数据库与 Android 框架协调工作。

官方给出了定义了单个表格的表格名称和列名称的示例:

public final class FeedReaderContract {
    // To prevent someone from accidentally instantiating the contract class,
    // give it an empty constructor.
    public FeedReaderContract() {}

    /* Inner class that defines the table contents */
    public static abstract class FeedEntry implements BaseColumns {
        public static final String TABLE_NAME = "entry";
        public static final String COLUMN_NAME_ENTRY_ID = "entryid";
        public static final String COLUMN_NAME_TITLE = "title";
        public static final String COLUMN_NAME_SUBTITLE = "subtitle";
        ...
    }
}

2.使用 SQL 辅助工具创建数据库

定义了数据库的外观后,应实现创建和维护数据库和表格的方法。 这里有一些典型的表格创建和删除语句:

private static final String TEXT_TYPE = " TEXT";
private static final String COMMA_SEP = ",";
private static final String SQL_CREATE_ENTRIES =
    "CREATE TABLE " + FeedEntry.TABLE_NAME + " (" +
    FeedEntry._ID + " INTEGER PRIMARY KEY," +
    FeedEntry.COLUMN_NAME_ENTRY_ID + TEXT_TYPE + COMMA_SEP +
    FeedEntry.COLUMN_NAME_TITLE + TEXT_TYPE + COMMA_SEP +
    ... // Any other options for the CREATE command
    " )";

private static final String SQL_DELETE_ENTRIES =
    "DROP TABLE IF EXISTS " + FeedEntry.TABLE_NAM

可以看到它拼接除了创建表,删除表等SQL语句。数据库的文件和内部文件一样,保存在私人磁盘空间,其他APP不能访问。

SQLiteOpenHelper 类中有一组有用的 API。当您使用此类获取对您数据库的引用时,系统将只在需要之时而不是 应用启动过程中执行可能长期运行的操作:创建和更新数据库。 您只需调用 getWritableDatabase() 或 getReadableDatabase()。

注意:由于它们可能长期运行,因此请确保您在后台线程中调用 getWritableDatabase() 或 getReadableDatabase() , 比如使用 AsyncTask 或 IntentService。

官方给出了使用 SQLiteOpenHelper 的示例:

public class FeedReaderDbHelper extends SQLiteOpenHelper {
    // If you change the database schema, you must increment the database version.
    public static final int DATABASE_VERSION = 1;
    public static final String DATABASE_NAME = "FeedReader.db";

    public FeedReaderDbHelper(Context context) {
        super(context, DATABASE_NAME, null, DATABASE_VERSION);
    }
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(SQL_CREATE_ENTRIES);
    }
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        // This database is only a cache for online data, so its upgrade policy is
        // to simply to discard the data and start over
        db.execSQL(SQL_DELETE_ENTRIES);
        onCreate(db);
    }
    public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        onUpgrade(db, oldVersion, newVersion);
    }
}

用了这个,访问数据库就必须要实例化它的子类:

 FeedReaderDbHelper mDbHelper = new FeedReaderDbHelper(getContext());

3.将信息输入到数据库

通过一个 ContentValues 对象传递至 insert() 方法,实现把数据插入数据库,让我们看看具体的操作:

// Gets the data repository in write mode
SQLiteDatabase db = mDbHelper.getWritableDatabase();

// Create a new map of values, where column names are the keys
ContentValues values = new ContentValues();
values.put(FeedEntry.COLUMN_NAME_ENTRY_ID, id);
values.put(FeedEntry.COLUMN_NAME_TITLE, title);
values.put(FeedEntry.COLUMN_NAME_CONTENT, content);

// Insert the new row, returning the primary key value of the new row
long newRowId;
newRowId = db.insert(
         FeedEntry.TABLE_NAME,
         FeedEntry.COLUMN_NAME_NULLABLE,
         values);

对于insert()的参数:

insert() 的第一个参数即为表格名称。第二个参数指定在 ContentValues 为空的情况下框架可在其中插入 NULL 的列的名称(如果您将其设置为 “null”, 那么框架将不会在没有值时插入行。)


4.从数据库读取信息

从数据库读取信息,我们要使用 query() 方法,我们需要将返回的列,选择条件,作为参数传递进去,这个方法好像是 insert() 和 update() 方法的结合,查询的结果将会在 Cursor 对象中返回给我们。

官方给出了如下示例:

SQLiteDatabase db = mDbHelper.getReadableDatabase();

// Define a projection that specifies which columns from the database
// you will actually use after this query.
String[] projection = {
    FeedEntry._ID,
    FeedEntry.COLUMN_NAME_TITLE,
    FeedEntry.COLUMN_NAME_UPDATED,
    ...
    };

// How you want the results sorted in the resulting Cursor
String sortOrder =
    FeedEntry.COLUMN_NAME_UPDATED + " DESC";

Cursor c = db.query(
    FeedEntry.TABLE_NAME,  // The table to query
    projection,                               // The columns to return
    selection,                                // The columns for the WHERE clause
    selectionArgs,                            // The values for the WHERE clause
    null,                                     // don't group the rows
    null,                                     // don't filter by row groups
    sortOrder                                 // The sort order
    );

对于包含返回结果的Cursor对象,我们一般都先通过 moveToFirst() 方法,把Cursor游标的位置定位在结果列表的第一行。对于其中的每一行,我们可以使用Cursor获取结果的不同方法,比如getLong()或getString()来获取结果值,对于每种方法,我们都需要先传递列的索引位置,我们可以通过调用 getColumnIndex() 或是 getColumnIndexOrThrow() 方法来取得列的索引位置。

官方给出了如下示例代码:

cursor.moveToFirst();
long itemId = cursor.getLong(
    cursor.getColumnIndexOrThrow(FeedEntry._ID)
);

5.从数据库删除信息

先看官方的一段表述:

要从表格中删除行,您需要提供识别行的选择条件。 数据库 API 提供了一种机制,用于创建防止 SQL 注入的选择条件。 该机制将选择规范划分为选择子句和选择参数。 该子句定义要查看的列,还允许您合并列测试。 参数是根据捆绑到子句的项进行测试的值。由于结果并未按照与常规 SQL 语句相同的方式进行处理,它不受 SQL 注入的影响。

简单的说,就是Android对于从数据库删除数据给我们提供的专门的方法,我们用哪个方法,可以避免SQL注入的影响。

问:那么问题来了,什么叫SQL注入呢?
答:所谓SQL注入,就是通过把SQL命令插入到Web表单提交或输入域名或页面请求的查询字符串,最终达到欺骗服务器执行恶意的SQL命令。

比如说有人恶意拼凑一个SQL语句结构的输入,利用存在安全漏洞的视频网站,瞬间找到了无数的会员账号这类的事情。

所以我们删除的时候,尽量使用Android提供给我们的安全的删除方法,不要自己拼凑有风险的SQL语句:

官方给出了如下示例:

// Define 'where' part of query.
String selection = FeedEntry.COLUMN_NAME_ENTRY_ID + " LIKE ?";
// Specify arguments in placeholder order.
String[] selectionArgs = { String.valueOf(rowId) };
// Issue SQL statement.
db.delete(table_name, selection, selectionArgs);

6.更新数据库

更新数据库也是,我们都使用官方的方法,不要直接执行自己拼凑好的SQL语句,防止SQL注入。

这里我们使用 数据库API update() 方法

SQLiteDatabase db = mDbHelper.getReadableDatabase();

// New value for one column
ContentValues values = new ContentValues();
values.put(FeedEntry.COLUMN_NAME_TITLE, title);

// Which row to update, based on the ID
String selection = FeedEntry.COLUMN_NAME_ENTRY_ID + " LIKE ?";
String[] selectionArgs = { String.valueOf(rowId) };

int count = db.update(
    FeedReaderDbHelper.FeedEntry.TABLE_NAME,
    values,
    selection,
    selectionArgs);

好了,今天的内容就到这里了,注意使用官方方法,不要随意冒险哦!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值