android-广播、持久化、内容提供器

1.广播
android 广播与日常生活中的广播相似,比如我们向电台发送一条广播,收听这个电台的听众都可以收听到这条广播,在 android 中使用广播也一样,分为发送广播和接收广播,听众收听这个电台才可以收听到广播,同样接收广播也需要注册监听某个广播才能在广播发出时收听到这个广播。

首先,来了解一下相关概念

标准广播:异步执行的广播
有序广播:同步执行的广播
全局广播:跨应用程序可以收听到的广播
本地广播:只在应用程序内部传递的广播

我是这样分类的
这里写图片描述

我们主要是学习标准广播和有序广播,区别如下
这里写图片描述

标准广播是异步执行的,发送广播后,其广播接收器的接收顺序是无序的,一般都是几乎“同时”接收到的,而有序广播是同步执行的,按照顺序的,优先级高的先执行,此外还可以拦截不让广播向下传递,说白了就是一条广播链。

要使用广播,很容易就可以想到一个需要解决的问题就是,发送广播和接收广播应该有一种对应关系,这样才可以实现当某条广播发起时,其对应的接收方可以接收到,从上图中可以发现他们是一种一对多的关系,android 使用 action 来匹配这种对应关系。好吧,看如何使用。

先来学会使用如何接收广播:
①. 继承 BroadcastReceiver 重写 onReceive 方法实现自己的逻辑
②.注册广播接收器

public class MyBroadcastReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        Toast.makeText(context, "receive a broadcast", Toast.LENGTH_SHORT).show();
    }
}

注册有两种,分别是动态注册和静态注册

/**
*   动态注册,addAction 方法传入的是一个字符串参数,我把它叫做 action,
*   它就是用来匹配发送的广播的,等我们看到如何发送广播就会很清晰了,它的值
*   没有什么要求,只要与发送的广播相匹配就好,一般我们都会有规律的给值,
*   以提高可读性和减少冲突。
*/
IntentFilter intentFilter = new IntentFilter();       intentFilter.addAction("com.jialemo.android.blogtest.MyBroadcast");

// 如果发送有序广播可以设定优先级
intentFilter.setPriority(priority);

myBroadcastReceiver = new MyBroadcastReceiver();
registerReceiver(myBroadcastReceiver,intentFilter);

// 注意在不再需要使用广播时需要解除注册
unregisterReceiver(myBroadcastReceiver);

/**
*   静态注册,在 AndroidManifest.xml 文件中配置,看如下 xml 片段,
*   name 对应的就是广播接收者的类名,enabled 属性指是否可用,exported
*   指是否可以接收其他 app 发送的广播。然后就是子标签 intent-filter ,
*   类似于动态注册的 intentFilter 作用一样,action 标签就是广播的匹配
*   规则,priority 属性是指广播接收者的优先级。
*   
*   静态注册,可以在 app 不启动情况下接收广播。
*/
<receiver android:name=".MyBroadcastReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter
                android:priority="80">
                <action android:name="com.jialemo.android.blogtest.MyBroadcast"/>
            </intent-filter>    
</receiver>

上面的 ①② 均完成后,就差发送广播了,当发出一个广播,就会自动调用广播接收器的 onReceive 方法(如果是静态注册的话会先创建广播接收器对象),那么现在来看如何发送广播了:

 // 发送广播
Intent intent = new Intent("com.jialemo.android.blogtest.MyBroadcast");
sendBroadcast(intent);
// 发送有序广播
// 第二个参数是与权限有关的字符串,一般传入 null 就可以了
sendOrderedBroadcast(intent,null);

看上面的代码是不是很简单,你应该注意到了在 new Intent 对象时,传进入的字符串,它就是在注册时的 action 值。当发送广播,发送了一条 action 为 com.jialemo.android.blogtest.MyBroadcast 的广播,在注册的广播器中,注册了 action 为 com.jialemo.android.blogtest.MyBroadcast 的广播器都可以接收到这条广播。所以说 action 就是广播与广播接收器一对多关系的匹配规则。

至此我们学习了如何发送和接收自定义广播,实际上在 android 中系统或者第三方 app 经常会发出一些广播,如电量变化,数据连接打开可用,开机等,这些广播不用我们发送,我们只需要注册自定义广播接收器监听接收这些广播即可,注册广播接收器关键是要知道 action 值,可以通过网上查询查找你需要的,另外监听有些广播是需要开启权限的,如监听开机广播。

上面有讲到有序广播,可以拦截不让广播沿着广播链传递,可以在 onReceive 方法中调用 abortBroadcast() 方法进行拦截。

前面讲的都是全局广播,现在来看本地广播,本地广播只能在应用内部传递。使用也很简单,和全局广播一样,不同的是注册和发送广播要使用

/** 获取 localBroadcastManager 对象 */
LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(Context);   
/** 注册广播接收器 */ 
localBroadcastManager.registerReceiver(BroadcastReceiver,IntentFilter);
/** 解除注册 */     
localBroadcastManager.unregisterReceiver(BroadcastReceiver);
/** 发送广播 */
localBroadcastManager.sendBroadcast(Intent);
localBroadcastManager.sendBroadcastSync(Intent);

跑代码–使用广播强制下线 demo
因为强制下线时,你可能处于任何一界面,被强制下线需要销毁返回栈中的 activity 并且启动一个登录 activity,所有我们可以做一个工具类。

/**
*   activity 集合类,保存返回栈全部 activity 的引用
*/
public class ActivityCollector {
    private static List<Activity> activityList = new ArrayList<>();
    public static void addActivity(Activity activity){
        if(activity != null) {
            activityList.add(activity);
        }
    }
    public static void removeActivity(Activity activity){
        activityList.remove(activity);
    }
    public static void finishAll(){
        for(Activity activity :activityList){
            if(!activity.isFinishing()){
                activity.finish();
            }
            activityList.remove(activity);
        }
    }
}
/**
*   只要我们的 activity 继承 BaseActivity,就会在创建和销毁
*   activity 时通过父类 BaseActivity 的方法自动把 activity
*   添加到集合类或者移除出集合类。
*/
public class BaseActivity extends AppCompatActivity {

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ActivityCollector.addActivity(this);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        ActivityCollector.removeActivity(this);
    }
}

然后就是自定义强制下线广播

public class ForceOfflineBroadcastReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(final Context context, final Intent intent) {
        AlertDialog.Builder builder = new AlertDialog.Builder(context);
        builder.setTitle("warning");
        builder.setMessage("other where login and force offline");
        builder.setCancelable(false);
        builder.setPositiveButton("ok", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                ActivityCollector.finishAll();
                Intent intent = new Intent(context,LoginActivity.class);
                context.startActivity(intent);
            }
        });
        builder.show();
    }

广播很简单,就是弹出一个对话框,并且只能响应点击按钮操作来阻塞用户其他操作,由于在onReceive 显示UI控件,就只能使用动态注册了,显然我们不可能在每一个 activity 中注册,在 BaseActivity 中实现即可,这就是面向对象的好处之一吧。

public class BaseActivity extends AppCompatActivity {
    private ForceOfflineBroadcastReceiver forceOfflineBroadcastReceiver;
    @Override
    protected void onResume() {
        super.onResume();
        // 注册
        forceOfflineBroadcastReceiver = new ForceOfflineBroadcastReceiver();
        IntentFilter intentFilter = new IntentFilter();
     intentFilter.addAction("com.example.jailemo.broadcasttest.offline");
        registerReceiver(forceOfflineBroadcastReceiver,intentFilter);
    }

    @Override
    protected void onPause() {
        super.onPause();
        //解除注册
        unregisterReceiver(forceOfflineBroadcastReceiver);

    }
}

为什么把注册和解除放在 onResume() 和 onPause() 之间呢,因为他们之间是可视可交互生命周期,始终处于栈顶,非栈顶的 activity 不应该也没有必要接收到广播。

最后就是在 MainActivity 中显示一个 btn 来模拟触发广播了

Button forceOfflineBtn = (Button) findViewById(R.id.force_offline_btn);
forceOfflineBtn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent();
                intent.setAction("com.example.jailemo.broadcasttest.offline");
                sendBroadcast(intent);
            }
        });

效果:
这里写图片描述

注意事项:不再在 onReceive 方法中执行过多的逻辑和耗时操作,当执行时间过长,程序会报错,并且不允许在 onReceive 方法中开启线程。更多的是在 onReceive 中去触发某件事情的执行。

2.持久化
持久化就是把文件保存在硬件介质中,在 android 中有这几种方式进行持久化保存
①. 文件存储:常规的 java 流读取存储
②. 使用 SharedPreference
③. 使用 sqlite
④. 通过服务器端存储
我们主要学习前面的三种方式。

普通文件存储

context 实例中有两个可以打开文件输入输出流的方法

/** 
*   返回对应的文件输出流
*   name 是文件名,mode 是操作模式:MODE_PRIVATE、MODE_ADDPEND
*/
openFileOutput(name,mode);
/** 
*   返回对应的文件输入流
*   name 是文件名
*/
openFileInput(name);

获得了文件对应的文件流,我们就可以通过常规的 Java 流来读写文件了。看简单实例方法:

private void save(){
        StringBuffer sb = new StringBuffer("input content");
        FileOutputStream fos = null;
        BufferedWriter bw = null;
        try{
            fos = openFileOutput("fileName",MODE_PRIVATE);
            bw = new BufferedWriter(new OutputStreamWriter(fos));
            bw.write(sb.toString(),0,sb.length());
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            if(fos != null){
                try{
                    bw.close();
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
        }
    }

private String read(){
        FileInputStream fis = null;
        BufferedReader br = null;
        StringBuffer sb = new StringBuffer();
        try{
            fis = openFileInput("fileName");
            br = new BufferedReader(new InputStreamReader(fis));
            String line = null;
            while ((line=br.readLine()) != null){
                sb.append(line);
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            try{
                if(br != null){
                    br.close();
                }
            }catch (Exception e){
                e.printStackTrace();
            }
        }
        return sb.toString();
    }

注意:使用 context 实例中两个打开文件输入输出流,文件保存的路径是,/data/data/< package-name >/files/ 目录下。

使用 sharedPreference

SharedPreference 是一个封装好的类,利用键值对形式来存储数据,使用如下:

private void save(){
        /** 获取一个 SharedPreferences 对象 */
        SharedPreferences sharedPreference = getSharedPreferences("fileName",MODE_PRIVATE);
        /** 获取 SharedPreferences.Editor 对象,它是 SharedPreferences 的一个内部类,用来操作存储数据的 */
        SharedPreferences.Editor editor = sharedPreference.edit();
        editor.putString("key","value");
        /** 进行数据提交 */
        editor.apply();
}

 private String read(){
        SharedPreferences sharedPreferences = getSharedPreferences("fileName",MODE_PRIVATE);
        /** 获取键为 key 对应的 value,如果不存在这个 key 则返回默认值,也就是第二个返回第二个传入的参数值*/
        String value = sharedPreferences.getString("key","defaultValue");
        return value;
}

注意:使用上面的这些方法时,当打开关联一个文件,如果不存在这个文件会自动创建的,SharedPreferences 对应关联的文件是保存在 data/data/< package-name >/shared-prefs/目录下,文件格式是 xml。此外,这里的键值对,不局限于 String 类型,还可以是其他基本类型。

SharedPreferences 对象也可以通过 PreferenceManager.getDefaultSharedPreferences(context) 获得,区别是对应的 xml 文件是以项目包名作为文件名。

使用 sqlite

SQLite 是一款轻量级的关系型数据库,被嵌入到 android 系统中了。支持 sql 语法和数据库的 ACID 事务。好吧,我们看看如何使用 sqlite。

SQLiteOpenHelper 顾名思义,它就是一个打开数据库的帮助类,主要用来完成对数据库的创建打开升级等操作。它是一个抽象类,有两个抽象方法 onCreate 和 onUpgrate 方法,一个是在数据库创建时候触发执行,一个是在数据库升级时触发执行。

此外,还有两个重要的实例方法,返回一个 Database 对象,分别是:

/** 打开一个可读数据库,当没有这个数据库时会自动去创建 */
getReadableDatabase();
/** 打开一个可读写数据库,当没有这个数据库时会自动去创建 */
getWritableDatabse();

一般来说使用一个对象就要先获取这个对象,我们通过直接 new 构造方法(),创建一个对象。于是看看 SQLiteOpenHelper 的构造方法,SQLiteOpenHelper 有两个构造器,如下:
这里写图片描述
第一个构造方法,有四个参数,分别代表,context 上下文,数据库名,Cursor对象工厂,数据库版本。第二个参数比第一个多了一个 DatabaseErrorHandler 对象。除了这两个参数,都好理解,那么这两个参数是什么家伙。

SQLiteDatabase.CursorFactory
一个接口,允许在查询时返回一个 Cursor 的子类对象,也就是一个自定义的Cursor 对象。Cursor 对象,是我们在 query 数据库,返回的一个结果集对象,相当于一个 resultSet (一个 List< Map >) 。一般,我们传入 null ,就能满足需求了。

DatabaseErrorHandler
一个接口,允许定义一个在检测到数据库损坏时执行的操作。初学见识短浅,暂时忽略。

综上:在创建一个 SQLiteOpenHelper 时,直接这样就好了,new SQLiteOpenHelper(context,”databaseName”,null,version);

/**
*  将要创建或者打开一个名为 myDatabase ,版本号为 1 的数据库
*  为什么是将要呢,因为在调用其实例方法 getXxxDatabase 方法才
* 是真正打开或创建
*/
SQLiteOpenHelper sqliteOpenHelper = new SQLiteOpenHelper(this,"myDatabase",null,1) {
            /**
             * 在创建时触发执行,因此一般在这建表等初始化操作
             */
            @Override
            public void onCreate(SQLiteDatabase db) {
                /** 创建一个名为 Person 的表*/
                db.execSQL("create table Person(id integer primary key autoincrement,name text,age integer)");
            }

            /**
             * 在数据库升级,即在打开数据库时发现创建 SQLiteOpenHelper
             *  对象传入的版本号大于当前数据库版本号时,触发执行
             * 一般在此做升级操作
             */
            @Override
            public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {

            }
        };
        /** 打开一个可读写的数据库,当没有这个数据库,就自动会创建*/
        SQLiteDatabase db = sqliteOpenHelper.getWritableDatabase();

上面例子,展示了如何创建或打开一个数据库,获得 SQLiteDatabase 对象,紧接着就是对数据进行增删改查了。

增删改查
①. 通过基本的 sql 语句完成
②. 通过调用 SQLiteDatabase 中的实例方法完成

因为 sqlite 支持 sql 语法,因而可以直接通过 sql 语句进行操作,可用方法有如下:

/** 参数二为占位符*/

/** 增删改*/
sqliteDatabase.execSQL("sql语句",object[]);
sqliteDatabase.execSQL("sql语句");
/**查询*/
sqliteDatabase.rawQuery("sql语句",object[]);

使用 SQLiteDatabase 中提供的相关实例方法

首先,弄清楚 ContentValue 类,它是一个以列名和属性值为键值对的形式进行数据组装的一个类,用作对数据表的插入更改的传入参数。看到具体用法,你就会理解了。

完成增删改查的方法有:insert、update、delete、query,其中 query 在SQLiteDatabase 重载了许多方法,我挑简单的出来做个笔记,当需要其他的重载方法,可以查看文档查看具体用法。

插入

/** 
*   参数一为表名,参数二是在没有插入该列数据时,给这列自动赋值为null(前提
*   是可以为null),一般不用这个功能,传入null即可,参数三就是组装好的
*   要插入的数据,返回的是插入在第几行 ]
*/
long    insert(String table, String nullColumnHack, ContentValues values)

/** 简单使用,向 person 表插入一条数据 */
ContentValues contentValues = new ContentValues();
contentValues.put("name","moxiaole");
contentValues.put("age",21);
db.insert("Person",null,contentValues);

/** 等价于如下*/
db.execSQL("insert into Person(name,age) value('moxiaole','21')");
// 或
db.execSQL("insert into Person(name,age) value(?,?)",new String[]{"moxiaole","21"});

更改

/** whereClause 相当于 where 子句,whereArgs 就是占位符 */
int update(String table, ContentValues values, String whereClause, String[] whereArgs)

/** 把 person 表中 name 为 moxiaole 的那行的 age 改为 22 */
ContentValues contentValues = new ContentValues();
contentValues.put("age",22);
db.update("Person",contentValues,"name=?",new String[]{"moxiaole"});

/**等价于*/
db.execSQL("update person set age = ? where name = ?",new String[]{"22"});

删除

/** whereClause 相当于 where 子句,whereArgs 就是占位符 */
int delete(String table, String whereClause, String[] whereArgs)
/** 删除 name 为 moxiaole 的那一行数据*/
db.delete("person","name=?",new String[]{"moxiaole"});

查询,终于讲到查询了,在四种操作中查询时最复杂的。

首先弄清楚 Cursor 对象,它是我们在 query 数据库,返回的一个结果集对象,相当于一个 resultSet (一个 List< Map >),一般我们需要对 cursor 进行遍历获取结果集中的所有数据。

/** 把 cursor 指向第一行 */
cursor.moveToFirst();
/** 把 cursor 指向下一行 */
cursor.moveToNext();

/** 遍历数据 */
if(cursor.moveToFirst()){
    do{
        // 获取当行数据
        cursor.getXxxx
    }while(cursor.moveToNext());
}
// 或 (默认开始 cursor 是指向向第一行前面的)
while(cursor.moveToNext()){
    // 获取当行数据
    cursor.getXxxx
}

/** 最后一定要 close */
cursor.close();
/**
*   columns是需要查询出来的列,相当于 select 子句,selection 是选择条
*   件,相当于 where 子句,selectionArgs 是占位符,groupBy 相当于 
*   groupBy子句,having 相当于 having 子句,orderBy 相当于 orderBy 
*   子句,返回的就是一个结果集
*/
Cursor  query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy)

/** 查询并遍历名字为 moxiaole 的年龄 */
 Cursor cursor = db.query("person",new String[]{"age"},"name = ?",new String[]{"moxiaole"},null,null,null);
while (cursor.moveToNext()){
       int age = cursor.getInt(cursor.getColumnIndex("age"));
}
cursor.close();

注意在上面传一系列传入参数中,如果传入 null 表示,不指定这个条件。

关于 sqlite 最基本的知识也就到这里了。

3.内容提供器
内容提供器,可以实现在不同的应用程序之间共享数据信息,下面用一幅图来概括我理解的内容提供器的实现原理。
这里写图片描述

我们把数据存储在 SQLite 数据库中,如果需要共享数据,可以定义一个 ContentProvider , 在 ContentProvider 类中实现一些对需要共享的数据的访问操作接口,并把这些接口通过 uri 暴露出去,这样 ContentResolver 就可以通过 uri 访问到 ContentProvider 提供的接口,从而间接访问到共享数据。因为 ContentProvider 和对应的 ContentResolver 的可以在不同的应用程序中,所以实现了不同应用程序间的数据共享。

上面说通过 URI 来匹配访问,先来学习 URI

URI 组成:
这里写图片描述

URI 的组成如上,其具体取值无限制,只要保持唯一无冲突既可以了,一般为了提高可读性和减少冲突,取值有一定规律。

获取 Uri 对象:Uri uri = Uri.parse(“content://authority/path”);

此外,uri 在 path后面还可以加 “/id”,表示期望操作某个表中的某条数据。 id 可以是数字,也可以是字符串。在解析 uri 时,id 可以使用通配符 “* (通配任意长字符)”或 “#”(通配长数字)通配。

定义 ContentProvider

ContentProvider 是一个抽象类,含有 6 个抽象方法,我们需要继承重写这 6 个抽象方法,在这些方法中实现对要共享的数据的 CRUD 操作。

public class MyContentProvider extends ContentProvider {

    /**
     *  在创建 ContentProvider 后会紧接着调用该方法,ContentProvider 对象不用自己创建,
     *  需要的时候会自动被创建。在这个方法一般做一些初始化操作,如创建打开数据库等。
     *
     */
    @Override
    public boolean onCreate() {
        return false;
    }

    /**
     *  根据 ContentResolver 传过来的 uri 来返回相应的 MIME 类型
     *  规则如下:
     *  1.必须以 vnd 开头
     *  2.如果内容 uri 以路径结尾,则后接 android.cursor.dir/,如果以 id 结尾则后接 android.cursor.item/
     *  3.最后接上 vnd.<authority>.<path>
     *
     */
    @Override
    public String getType(Uri uri) {
        return null;
    }

    /**
     *  通过调用 ContentResolver 的 query 方法,就会触发 ContentProvider 的 query 方法
     *  来完成查询操作
     *
     */
    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
        return null;
    }

    /**
     *  通过调用 ContentResolver 的 insert 方法,就会触发 ContentProvider 的 insert 方法
     *  来完成插入操作
     *
     */
    @Override
    public Uri insert(Uri uri, ContentValues values) {
        return null;
    }

    /**
     *  通过调用 ContentResolver 的 delete 方法,就会触发 ContentProvider 的 delete 方法
     *  来完成删除操作
     *
     */
    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        return 0;
    }

    /**
     *  通过调用 ContentResolver 的 update 方法,就会触发 ContentProvider 的 update 方法
     *  来完成更新操作
     *
     */
    @Override
    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
        return 0;
    }
}

android 四大组件都要注册,因而还要在 AndroidManifest.xml 中进行注册

<provider               
    android:authorities="com.jialemo.android.blogtest.PROVIDER"
    android:name=".MyContentProvider"     
    android:enabled="true"
    android:exported="true">
</provider>

至此 ContentProvider 就准备好了(当然逻辑方法还没实现),现在就可以使用 ContentResolver 了。

使用 ContentResolver

ContentResolver 中关于 CURD 的方法和 ContentProvider 是一一对应的,不信你可以看看下面的 ContentResolver 的实例方法

/** 这些方法的作用显然而见了,不再细说,需要知道的是,调用这些方法相当于调用 ContentProvider 中对应的方法,达到操作数据的目的 */
final Cursor    query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder)

final Uri   insert(Uri url, ContentValues values)

final int   delete(Uri url, String where, String[] selectionArgs)

final int   update(Uri uri, ContentValues values, String where, String[] selectionArgs)

我们在使用 ContentResolver 基本如下

/** 获取 contentResolver 对象 */
ContentResolver contentResolver = getContentResolver();
/** 获取 uri 对象 */
Uri uri = Uri.parse("content://authority/path")
/** 调用 contentResolver 中的方法 */
contentResolver.Xxx();

UriMatcher
UriMatcher ,使用用来解析 uri 的,使用看下面的代码

 /** 创建一个 uriMatcher 对象*/
UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);

/** 加入匹配 uri 规则,即匹配到满足条件的 uri 就会返回 code 值 */
/** 注意这里的 path 是可以包含 id ,可以使用通配符通配 */
uriMatcher.addURi(String authority,String path,int code);

/** 匹配 uri,满足条件则返回相对应的 code 值 */
int code = uriMatcher.match(uri);

上面基本上讲完了使用内容提供器的基本流程,剩下来的就是自己 coding 了。正如 talk is cheap ,show me the code.

此外,在 android 的一些系统应用有很多可供使用的 ContentProvider ,通过他们,我们就可以获取这些系统应用的共享数据,如通讯录等。注意使用这些时,别忘了权限的问题 android 6.0 引入了运行时权限。运行时权限属于危险权限,需要代码申请,和在 xml 中配置。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值