详解Android ContentProvider的基本原理和使用

一、前言

Android 的数据存储方式总共有五种,分别是:Shared Preferences、网络存储、文件存储、外储存储、SQLite。但一般这些存储都只是在单独的一个应用程序之中达到一个数据的共享,有时候我们需要操作其他应用程序的一些数据,就会用到 ContentProvider。而且 Android 为常见的一些数据提供了默认的 ContentProvider(包括音频、视频、图片和通讯录等)。

要实现与其他的 ContentProvider 通信首先要查找到对应的 ContentProvider 进行匹配。Android 中 ContenProvider 借助 ContentResolver 通过 Uri 与其他的 ContentProvider 进行匹配通信。

二、URI(Uniform Resource Identifier)

其它应用可以通过 ContentResolver 来访问 ContentProvider 提供的数据,而 ContentResolver 通过 uri 来定位自己要访问的数据,所以我们要先了解 uri。URI(Universal Resource Identifier)统一资源定位符,如果您使用过安卓的隐式启动就会发现,在隐式启动的过程中我们也是通过 uri 来定位我们需要打开的 Activity 并且可以在 uri 中传递参数。

URI 为系统中的每一个资源赋予一个名字,比方说通话记录。每一个 ContentProvider 都拥有一个公共的 URI,用于表示 ContentProvider 所提供的数据。URI 的格式如下:

// 规则

[scheme:][//host:port][path][?query]

// 示例

content://com.wang.provider.myprovider/tablename/id:

1.标准前缀(scheme)——content://,用来说明一个Content Provider控制这些数据;

2.URI 的标识 (host:port)—— com.wang.provider.myprovider,用于唯一标识这个 ContentProvider,外部调用者可以根据这个标识来找到它。对于第三方应用程序,为了保证 URI 标识的唯一性,它必须是一个完整的、小写的类名。这个标识在元素的authorities属性中说明,一般是定义该 ContentProvider 的包.类的名称;

3.路径(path)——tablename,通俗的讲就是你要操作的数据库中表的名字,或者你也可以自己定义,记得在使用的时候保持一致就可以了;

4.记录ID(query)——id,如果URI中包含表示需要获取的记录的 ID,则返回该id对应的数据,如果没有ID,就表示返回全部;

对于第三部分路径(path)做进一步的解释,用来表示要操作的数据,构建时应根据实际项目需求而定。如:

  • 操作tablename表中id为11的记录,构建路径:/tablename/11;
  • 操作tablename表中id为11的记录的name字段:tablename/11/name;
  • 操作tablename表中的所有记录:/tablename;
  • 操作来自文件、xml或网络等其他存储方式的数据,如要操作xml文件中tablename节点下name字段:/ tablename/name;
  • 若需要将一个字符串转换成Uri,可以使用Uri类中的parse()方法,
Uri uri = Uri.parse("content://com.wang.provider.myprovider/tablename");

再来看一个例子:

http://www.baidu.com:8080/wenku/jiatiao.html?id=123456&name=jack

uri 的各个部分在安卓中都是可以通过代码获取的,下面我们就以上面这个 uri 为例来说下获取各个部分的方法:

  • getScheme():获取 Uri 中的 scheme 字符串部分,在这里是 http
  • getHost():获取 Authority 中的 Host 字符串,即 www.baidu.com
  • getPost():获取 Authority 中的 Port 字符串,即 8080
  • getPath():获取 Uri 中 path 部分,即 wenku/jiatiao.html
  • getQuery():获取 Uri 中的 query 部分,即 id=15&name=du

三、MIME

MIME 是指定某个扩展名的文件用一种应用程序来打开,就像你用浏览器查看 PDF 格式的文件,浏览器会选择合适的应用来打开一样。Android 中的工作方式跟 HTTP 类似,ContentProvider 会根据 URI 来返回 MIME 类型,ContentProvider 会返回一个包含两部分的字符串。MIME 类型一般包含两部分,如:

text/html

text/css

text/xml

application/pdf

分为类型和子类型,Android 遵循类似的约定来定义MIME类型,每个内容类型的 Android MIME 类型有两种形式:多条记录(集合)和单条记录。

集合记录(dir):

vnd.android.cursor.dir/自定义 

单条记录(item):

vnd.android.cursor.item/自定义 

vnd 表示这些类型和子类型具有非标准的、供应商特定的形式。Android中类型已经固定好了,不能更改,只能区别是集合还是单条具体记录,子类型可以按照格式自己填写。

在使用 Intent 时,会用到 MIME,根据 Mimetype 打开符合条件的活动。

四、UriMatcher

Uri 代表要操作的数据,在开发过程中对数据进行获取时需要解析 Uri,Android 提供了两个用于操作 Uri 的工具类,分别为 UriMatcher 和 ContentUris 。掌握它们的基本概念和使用方法,对一个 Android 开发者来说是一项必要的技能。

UriMatcher 类用于匹配 Uri,它的使用步骤如下:

将需要匹配的Uri路径进行注册,代码如下:

//常量UriMatcher.NO_MATCH表示不匹配任何路径的返回码
UriMatcher  sMatcher = new UriMatcher(UriMatcher.NO_MATCH);
//如果match()方法匹配“content://com.wang.provider.myprovider/tablename”路径,返回匹配码为1
sMatcher.addURI("content://com.wang.provider.myprovider", " tablename ", 1);
//如果match()方法匹配content://com.wang.provider.myprovider/tablename/11路径,返回匹配码为2
sMatcher.addURI("com.wang.provider.myprovider", "tablename/#", 2);

此处采用 addURI 注册了两个需要用到的 URI;注意,添加第二个 URI 时,路径后面的 id 采用了通配符形式 “#”,表示只要前面三个部分都匹配上了就 OK。

注册完需要匹配的 Uri 后,可以使用 sMatcher.match(Uri) 方法对输入的 Uri 进行匹配,如果匹配就返回对应的匹配码,匹配码为调用 addURI() 方法时传入的第三个参数。

switch (sMatcher.match(Uri.parse("content://com.zhang.provider.yourprovider/tablename/100"))) {
    case 1:
        //match 1, todo something
        break;
    case 2
        //match 2, todo something
        break;
    default:
        //match nothing, todo something
        break;
}

五、ContentUris

ContentUris 类用于操作 Uri 路径后面的 ID 部分,它有两个比较实用的方法:withAppendedId(Uri uri, long id) 和 parseId(Uri uri)。

withAppendedId(Uri uri, long id) 用于为路径加上 ID 部分:

Uri uri = Uri.parse("content://cn.scu.myprovider/user")

//生成后的Uri为:content://cn.scu.myprovider/user/7
Uri resultUri = ContentUris.withAppendedId(uri, 7); 

parseId(Uri uri) 则从路径中获取 ID 部分:

Uri uri = Uri.parse("content://cn.scu.myprovider/user/7")

//获取的结果为:7
long personid = ContentUris.parseId(uri);

ContentProvider 主要方法ContentProvider 是一个抽象类,如果我们需要开发自己的内容提供者我们就需要继承这个类并复写其方法,需要实现的主要方法如下:

  • public boolean onCreate():在创建 ContentProvider 时使用
  • public Cursor query():用于查询指定 uri 的数据返回一个 Cursor
  • public Uri insert():用于向指定uri的 ContentProvider 中添加数据
  • public int delete():用于删除指定 uri 的数据
  • public int update():用户更新指定 uri 的数据
  • public String getType():用于返回指定的 Uri 中的数据 MIME 类型

数据访问的方法 insert,delete 和 update 可能被多个线程同时调用,此时必须是线程安全的。

如果操作的数据属于集合类型,那么 MIME 类型字符串应该以 vnd.android.cursor.dir/ 开头,

要得到所有 tablename 记录: Uri 为 content://com.wang.provider.myprovider/tablename,那么返回的MIME类型字符串应该为:vnd.android.cursor.dir/table。

如果要操作的数据属于非集合类型数据,那么 MIME 类型字符串应该以 vnd.android.cursor.item/ 开头,

要得到 id 为 10 的 tablename 记录,Uri 为 content://com.wang.provider.myprovider/tablename/10,那么返回的 MIME 类型字符串为:vnd.android.cursor.item/tablename 。

5.1、方法使用示例

使用 ContentResolver 对 ContentProvider 中的数据进行操作的代码如下:

ContentResolver resolver = getContentResolver();
Uri uri = Uri.parse("content://com.wang.provider.myprovider/tablename");
// 添加一条记录
ContentValues values = new ContentValues();
values.put("name", "wang1");
values.put("age", 28);
resolver.insert(uri, values);
// 获取tablename表中所有记录
Cursor cursor = resolver.query(uri, null, null, null, "tablename data");
while(cursor.moveToNext()){
   Log.i("ContentTest", "tablename_id="+ cursor.getInt(0)+ ", name="+ cursor.getString(1));
}
// 把id为1的记录的name字段值更改新为zhang1
ContentValues updateValues = new ContentValues();
updateValues.put("name", "zhang1");
Uri updateIdUri = ContentUris.withAppendedId(uri, 2);
resolver.update(updateIdUri, updateValues, null, null);
// 删除id为2的记录,即字段age
Uri deleteIdUri = ContentUris.withAppendedId(uri, 2);
resolver.delete(deleteIdUri, null, null);

5.2、监听数据变化

如果ContentProvider的访问者需要知道数据发生的变化,可以在ContentProvider发生数据变化时调用getContentResolver().notifyChange(uri, null)来通知注册在此URI上的访问者。只给出类中监听部分的代码:

public class MyProvider extends ContentProvider {
   public Uri insert(Uri uri, ContentValues values) {
      db.insert("tablename", "tablenameid", values);
      getContext().getContentResolver().notifyChange(uri, null);
   }
}

而访问者必须使用 ContentObserver 对数据(数据采用 uri 描述)进行监听,当监听到数据变化通知时,系统就会调用 ContentObserver 的 onChange() 方法:

getContentResolver().registerContentObserver(Uri.parse("content://com.ljq.providers.personprovider/person"),
       true, new PersonObserver(new Handler()));
public class PersonObserver extends ContentObserver{
   public PersonObserver(Handler handler) {
      super(handler);
   }
   public void onChange(boolean selfChange) {
      //to do something
   }
}

六、实例说明

数据源是 SQLite, 用 ContentResolver 操作 ContentProvider。

Constant.java(储存一些常量)

public class Constant {  

    public static final String TABLE_NAME = "user";  

    public static final String COLUMN_ID = "_id";
    public static final String COLUMN_NAME = "name";  

    public static final String AUTOHORITY = "cn.scu.myprovider";
    public static final int ITEM = 1;
    public static final int ITEM_ID = 2;  

    public static final String CONTENT_TYPE = "vnd.android.cursor.dir/user";
    public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/user";  

    public static final Uri CONTENT_URI = Uri.parse("content://" + AUTOHORITY + "/user");
}

DBHelper.java (操作数据库)

public class DBHelper extends SQLiteOpenHelper {  

    private static final String DATABASE_NAME = "finch.db";
    private static final int DATABASE_VERSION = 1;    

    public DBHelper(Context context) {
        super(context, DATABASE_NAME, null, DATABASE_VERSION);
    }  

    @Override
    public void onCreate(SQLiteDatabase db)  throws SQLException {
        //创建表格
        db.execSQL("CREATE TABLE IF NOT EXISTS "+ Constant.TABLE_NAME + "("+ Constant.COLUMN_ID +" INTEGER PRIMARY KEY AUTOINCREMENT," + Constant.COLUMN_NAME +" VARCHAR NOT NULL);");
    }  

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)  throws SQLException {
        // 这里知识简单删除并创建表格
        // 如果需要保留原来的数据,需要先备份再删除
        db.execSQL("DROP TABLE IF EXISTS "+ Constant.TABLE_NAME+";");
        onCreate(db);
    }
}

MyProvider.java (自定义的 ContentProvider ) 

public class MyProvider extends ContentProvider {    

    DBHelper mDbHelper = null;
    SQLiteDatabase db = null;    

    private static final UriMatcher mMatcher;
    static{
        mMatcher = new UriMatcher(UriMatcher.NO_MATCH);      // 注册 uri
        mMatcher.addURI(Constant.AUTOHORITY,Constant.TABLE_NAME, Constant.ITEM);
        mMatcher.addURI(Constant.AUTOHORITY, Constant.TABLE_NAME+"/#", Constant.ITEM_ID);
    }    

    @Override
    public String getType(Uri uri) {      // 根据匹配规则返回对应的类型
        switch (mMatcher.match(uri)) {
        case Constant.ITEM:
            return Constant.CONTENT_TYPE;
        case Constant.ITEM_ID:
            return Constant.CONTENT_ITEM_TYPE;
        default:
            throw new IllegalArgumentException("Unknown URI"+uri);
        }
    }    

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        // TODO Auto-generated method stub
        long rowId;
        if(mMatcher.match(uri)!=Constant.ITEM){
            throw new IllegalArgumentException("Unknown URI"+uri);
        }
        rowId = db.insert(Constant.TABLE_NAME,null,values);
        if(rowId>0){
            Uri noteUri=ContentUris.withAppendedId(Constant.CONTENT_URI, rowId);
            getContext().getContentResolver().notifyChange(noteUri, null);
            return noteUri;
        }    

        throw new SQLException("Failed to insert row into " + uri);
    }    

    @Override
    public boolean onCreate() {
        // TODO Auto-generated method stub
        mDbHelper = new DBHelper(getContext());
        db = mDbHelper.getReadableDatabase();    

        return true;
    }    

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
            String[] selectionArgs, String sortOrder) {
        // TODO Auto-generated method stub
        Cursor c = null;
        switch (mMatcher.match(uri)) {
        case Constant.ITEM:
            c =  db.query(Constant.TABLE_NAME, projection, selection, selectionArgs, null, null, sortOrder);
            break;
        case Constant.ITEM_ID:
            c = db.query(Constant.TABLE_NAME, projection,Constant.COLUMN_ID + "="+uri.getLastPathSegment(), selectionArgs, null, null, sortOrder);
            break;
        default:
            throw new IllegalArgumentException("Unknown URI"+uri);
        }    

        c.setNotificationUri(getContext().getContentResolver(), uri);
        return c;
    }    

    @Override
    public int update(Uri uri, ContentValues values, String selection,
            String[] selectionArgs) {
        // TODO Auto-generated method stub
        return 0;
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        // TODO Auto-generated method stub
        return 0;
    }    

}

MainActivity.java(ContentResolver操作)

public class MainActivity extends Activity {
    private ContentResolver mContentResolver = null;
    private Cursor cursor = null;
         @Override
        protected void onCreate(Bundle savedInstanceState) {
            // TODO Auto-generated method stub
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);

               TextView tv = (TextView) findViewById(R.id.tv);

                mContentResolver = getContentResolver();
                tv.setText("添加初始数据 ");
                for (int i = 0; i < 10; i++) {
                    ContentValues values = new ContentValues();
                    values.put(Constant.COLUMN_NAME, "fanrunqi"+i);
                    mContentResolver.insert(Constant.CONTENT_URI, values);
                } 

                tv.setText("查询数据 ");
                cursor = mContentResolver.query(Constant.CONTENT_URI, new String[]{Constant.COLUMN_ID,Constant.COLUMN_NAME}, null, null, null);
                if (cursor.moveToFirst()) {
                    String s = cursor.getString(cursor.getColumnIndex(Constant.COLUMN_NAME));
                    tv.setText("第一个数据: "+s);
                }
        }

}

最后在manifest申明 :

<provider android:name="MyProvider" android:authorities="cn.scu.myprovider" />

七、总结

  • 如何通过 ContentProvider 查询数据? 通过 ContentResolver 进行uri匹配
  • 如何实现自己的ContentProvider? 继承 ContentProvider,实现对应的方法。在 manifest 中声明

7.1、额外补充:隐式 Intent 中 <data> 标签

该部分内容与ContentProvider 没关系,只是这里讲到了 URI,就顺便此处在插入另外一个知识点:Intent 中 <data> 标签。看不懂的可以直接略过,看下一步分的内容,此处内容与 activity 相关。

Data 的匹配规则:如果过滤规则 intent-filter 中定义了 data,那么 Intent 中必须也要携带可匹配的 data

data 的语法如下所示:

<data android:scheme=“string”
      android:host=“string”
      android:port=“string”
      android:path=“string”
      android:pathPattern=“string”
      android:pathPrefix=“string”
      android:mimeType=“string”>

data 由两部分组成:mimeType 和 URI。mimeType 可以为空,URI 一定不会为空,因为有默认值。mimeType 指媒体类型,比如 image/jpeg,video/* 等,可表示图片,视频等不同的媒体格式

示例1

data 的匹配:

<intent-filter>
     <action android:name="com.action.demo1"></action>
     <category android:name="android.intent.category.DEFAULT" />
     <data
         android:scheme="x7"
         android:host="www.360.com"
            />
 </intent-filter>

清单文件 intent-filter 定义的 data 中,只有 URI, 没有 mimeType 类型,匹配如下

intent.setData(Uri.parse("x7://www.360.com"))

示例2

<intent-filter>
       <action android:name="com.action.demo1"></action>
       <category android:name="android.intent.category.DEFAULT" />
       <data android:mimeType="image/*" />
 </intent-filter>

清单文件 intent-filter 定义的 data 中,没有定义 URI,只有 mimeType 类型,但是 URI 却有默认值,URI 中的 scheme 默认为 content 或者 file,host 一定不能为空,随便给个字符串abc 都可以,匹配如下

intent.setDataAndType(Uri.parse("content://abc"),"image/png");

注意:

content://abc 换成 file://abc 在 7.0 以上的版本(即把 targetSdkVersion 指定成 24 及之上并且在 API>=24 的设备上运行)会出现 FileUriExposedException 异常,google 提供了FileProvider 解决这个异常,使用它可以生成 content://Uri 来替代 file://Uri.

示例3

<intent-filter>
      <action android:name="com.action.demo1"></action>
      <category android:name="android.intent.category.DEFAULT" />
        <data
               android:mimeType="image/*"
               android:scheme="x7"
               android:host="www.360.com"
         />
        <data
             android:mimeType="video/*"
             android:scheme="x7"
             android:host="www.360.com"
         />
</intent-filter>

清单文件 intent-filter 定义的 data 中,URI 和 mimeType 都有, 匹配如下:

intent.setDataAndType(Uri.parse("x7://www.360.com"),"image/png");
// 或者
intent.setDataAndType(Uri.parse("x7://www.360.com"),"video/mpeg");

Inent 中携带的 data 标签对应的数据,在某一组 intent-filter 中可以找到,即匹配成功。data 标签数据在 intent-filter 中也可以有多组隐示启动,防止匹配失败的可以提前检测。

判断的方法如下:

PackageManager mPackageManager = getPackageManager();
//返回匹配成功中最佳匹配的一个act信息,intent 需要按照前面的把 action, data 等都设置好
ResolveInfo info = mPackageManager.resolveActivity(intent,PackageManager.MATCH_DEFAULT_ONLY);
//返回所有匹配成功的act信息,是一个集合
 List<ResolveInfo> infoList = mPackageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);

只要上述 2 个方法的返回值不为 null,那么 startActivity 一定可以成功

以上就是详解Android ContentProvider的基本原理和使用的详细内容,更多关于Android ContentProvider的资料请关注我们其它相关文章!

(0)

相关推荐

  • Android中自定义ContentProvider实例

    //以下为TestBaidu MainActivity如下: 复制代码 代码如下: package cn.testbaidu; import android.net.Uri; import android.os.Bundle; import android.view.View; import android.view.View.OnClickListener; import android.widget.Button; import android.app.Activity; import an

  • Android应用中使用ContentProvider扫描本地图片并显示

    之前群里面有朋友问我,有没有关于本地图片选择的Demo,类似微信的效果,他说网上没有这方面的Demo,问我能不能写一篇关于这个效果的Demo,于是我研究了下微信的本地图片选择的Demo,自己仿照的写了下分享给大家,希望对以后有这样子需求的朋友有一点帮助吧,主要使用的是ContentProvider扫描手机中的图片,并用GridView将图片显示出来,关于GridView和ListView显示图片的问题,一直是一个很头疼的问题,因为我们手机的内存有限,手机给每个应用程序分配的内存也有限,所以图片多

  • Android开发教程之ContentProvider数据存储

    一.ContentProvider保存数据介绍 一个程序可以通过实现一个ContentProvider的抽象接口将自己的数据完全暴露出去,而且ContentProvider是以类似数据库中表的方式将数据暴露的.那么外界获取其提供的数据,也就应该与从数据库中获取数据的操作基本一样,只不过是采用URL来表示外界需要访问的"数据库". ContentProvider提供了一种多应用间数据共享的方式. ContentProvider是个实现了一组用于提供其他应用程序存取数据的标准方法的类.应用

  • Android 自定义ContentProvider简单实例

    Android 自定义ContentProvider简单实例 Android允许我们定义自己的的ContentProvider对象来共享数据,练练手,简单来实现一下. 要使用ContentProvider来操作数据,必须要有保存数据的场所.可以使用文件或SQLite数据库的方式来保存数据,通常使用SQLite数据库. 1,创建一个数据库帮助类,归根结底都是它在操作数据库.代码如下: package com.njue; import android.content.Context; import

  • 实例讲解Android中ContentProvider组件的使用方法

    ContentProvider基本使用 为了在应用程序之间交换数据,android提供了ContentProvider,ContentProvider是不同应用程序之间进行数据交换的标准API,当一个应用程序需要把自己的数据暴露给其他程序使用时,该应用程序就可以通过提供ContentPRovider来实现,其他应用程序就可以通过ContentResolver来操作ContentProvider暴露的数据. 实现ContentProvider的步骤: 1)编写一个类,继承ContentProvid

  • Android数据持久化之ContentProvider机制详解

    本文实例讲述了Android数据持久化之ContentProvider机制.分享给大家供大家参考,具体如下: 一般而言,android操作系统的应用程序所建立的数据只允许自己使用,应用程序彼此间无法借助公用存储器来共享数据,android系统提供了一个机制,即内容提供器(ContentProvider),来公开自己私有的数据到数据内容器,通过该机制,可以供其他应用程序来读取自己内部的数据,当然也可以访问其他应用程序的数据.通常,内容提供器背后都有SQLite数据库的支持,用以存储内容提供内部数据

  • Android ContentProvider的实现及简单实例代码

    一.概念及说明 ContentProvider定义: 内容提供者是一个Android应用的基础模块,提供内容给这个应用,它们封装数据和提供它给应用通过这个ContentResolver接口,使用ContentProvider可以在不同的应用程序之间共享数据,android为常见的一些数据提供了ContentProvider(视频.音频),ContentProvider使用表的形式来组织数据. URI定义: 每一个ContentProvider都拥有一个公共的URI,这个URI用于表示这个Cont

  • Android学习笔记之ContentProvider和Uri详解

    本文介绍了自定义Content Provider的相关内容,完全解析内容提供者的用法.Content Provider,内容提供者,相信大家对这个组件的名字都不陌生,可能是自己平时做的都是一些简单的App,所以对于Content Provider的使用并不是很多,也不是特别熟悉.但是这里还是对Content Provider作个简单的总结,不是很深入,但是希望能给包括我在内的初学者一点帮助,看完这篇能对这个组件有个总体上的了解. 一.使用ContentProvider(内容提供者)共享数据 Co

  • Android开发之ContentProvider的使用详解

    前言         Content Provider为存储数据和获取数据提供了统一的接口,它可以完成在不同应用程序下的数据共享,而在上一篇文章Android开发之SQLite的使用方法讲到的SQLite只能在同一个程序中共享数据.另外android为一些常见的数据,比如说音频,视频,图片,通讯录等提供了Content Provider,这样我们就可以很方便的对这些类型的数据操作了.使用ContentProvider的好处是开发人员不需要考虑数据内部是怎么存储的,比如说如果我们想利用Conten

  • android基础总结篇之八:创建及调用自己的ContentProvider

    今天我们来讲解一下如何创建及调用自己的ContentProvider. 在前面两篇文章中我们分别讲了如何读写联系人和短消息,相信大家对于ContentProvider的操作方法已经有了一定程度的了解.在有些场合,除了操作ContentProvider之外,我们还有可能需要创建自己的ContentProvider,来提供信息共享的服务,这就要求我们很好的掌握ContentProvider的创建及使用技巧.下面我们就由表及里的逐步讲解每个步骤. 在正式开始实例演示之前,我们先来了解以下两个知识点:

随机推荐