ContentProvider应该知道的

ContentProvider 提供了一个接口用来发布数据,通过ContentResolver 来使用该数据。它们允许将使用数据的应用程序组件和底层数据源分离开来,并提供一种通用的机制来允许一个应用程序共享它们的数据或者使用其它应用程序供的数据。

一、Uri介绍

Uri代表要操作的数据,Uri主要包含了两部分信息:

  • 需要操作的ContentProvider
  • 对ContentProvider中的什么数据进行操作

URI主要分三个部分:scheme, authority and path。其中authority又分为hostport

  • 格式如下:scheme://host:port/path

ContentProvider(内容提供者)的scheme已经由Android所规定, scheme为:content://主机名(或叫Authority)用于唯一标识这个ContentProvider,外部调用者可以根据这个标识来找到它。路径(path)可以用来表示我们要操作的数据,路径的构建应根据业务而定。

如下以person 表为例

  1. 要操作person表中id为10的记录,可以构建这样的路径:/person/10
  2. 要操作person表中id为10的记录的name字段, person/10/name
  3. 要操作person表中的所有记录,可以构建这样的路径:/person
  4. 要操作xxx表中的记录,可以构建这样的路径:/xxx
    使用Uri类中的parse()方法把一个字符串转换成Uri,如下:
    Uri uri = Uri.parse("content://com.cfox.contentprovid.PersonProvider/person")
    当然要操作的数据不一定来自数据库,也可以是文件、xml或网络等其他存储方.

二、UriMatcher和ContentUris使用介绍

因为Uri代表了要操作的数据,所以我们经常需要解析Uri,并从Uri中获取数据。Android系统提供了两个用于操作Uri的工具类,分别为UriMatcherContentUris 。掌握它们的使用,会便于我们的开发工作。

UriMatcher

UriMatcher类用于匹配Uri,它的用法如下:

  • 首先第一步把你需要匹配Uri路径全部给注册上
    如下:

        //常量UriMatcher.NO_MATCH表示不匹配任何路径的返回码
        UriMatcher  sMatcher = new UriMatcher(UriMatcher.NO_MATCH);
        //如果match()方法匹配content://com.cfox.contentprovid.PersonProvider/person路径,返回匹配码为1
        sMatcher.addURI("com.cfox.contentprovid.PersonProvider", "person", 1);//添加需要匹配uri,如果匹配就会返回匹配码
        //如果match()方法匹配content://com.cfox.contentprovid.PersonProvider/person/230路径,返回匹配码为2
        sMatcher.addURI("com.cfox.contentprovid.PersonProvider", "person/#", 2);//#号为通配符
        switch (sMatcher.match(Uri.parse("content://com.cfox.contentprovid.PersonProvider/person/10"))) { 
             case 1
             break;
             case 2
             break;
             default://不匹配
        }
    

注册完需要匹配的Uri后,就可以使用sMatcher.match(uri)方法对输入的Uri进行匹配。如果匹配就返回匹配码,匹配码是调用 addURI()方法传入的第三个参数,假设匹配content://com.cfox.contentprovid.PersonProvider路径,返回的匹配码为1。

ContentUris使用介绍

ContentUris类用于操作Uri路径后面的ID部分,它有两个比较实用的方法:

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

    Uri uri = Uri.parse("content://com.cfox.contentprovid.PersonProvider/person")
    Uri resultUri = ContentUris.withAppendedId(uri, 10); 
    //生成后的Uri为:content://com.cfox.contentprovid.PersonProvider/person/10
    
  • parseId(uri)方法用于从路径中获取ID部分:

    Uri uri = Uri.parse("content://com.cfox.contentprovid.PersonProvider/person/10")
    long personid = ContentUris.parseId(uri);//获取的结果为:10
    

三、ContentProvider(内容提供者)共享数据

ContentProvider在android中的作用是对外共享数据,可以通过ContentProvider把应用中的数据共享给其他应用访问,其他应用可以通过ContentProvider对你应用中的数据进行增删改查。关于数据共享,以前我们学习过文件操作模式,知道通过指定文件的操作模式为Context.MODE_WORLD_READABLE或Context.MODE_WORLD_WRITEABLE同样也可以对外共享数据。那么,这里为何要使用ContentProvider对外共享数据呢?是这样的,如果采用文件操作模式对外共享数据,数据的访问方式会因数据存储的方式而不同,导致数据的访问方式无法统一,如:采用xml文件对外共享数 据,需要进行xml解析才能读取数据;采用sharedpreferences共享数据,需要使用sharedpreferences API读取数据。使用ContentProvider对外共享数据的好处是统一了数据的访问方式。

ContentProvider对外共享数

第一步需要继承ContentProvider并重写下面方法:

public class BaseContentProvider extends ContentProvider {

    @Override
    public boolean onCreate() {
        // TODO Auto-generated method stub
        return false;
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
        // TODO Auto-generated method stub
        return null;
    }

    @Override
    public String getType(Uri uri) {
        // TODO Auto-generated method stub
        return null;
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        // TODO Auto-generated method stub
        return null;
    }

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

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

ContentProvider类主要方法的作用:

  1. public boolean onCreate():该方法在ContentProvider创建后就会被调用,应用程序主线程上的所有已注册内容提供程序都会调用此方法。
  2. public Uri insert(Uri uri, ContentValues values):该方法用于供外部应用往ContentProvider添加数据。
  3. public int delete(Uri uri, String selection, String[] selectionArgs):该方法用于供外部应用从ContentProvider删除数据。
  4. public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs):该方法用于供外部应用更新ContentProvider中的数据。
  5. public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder):该方法用于供外部应用从ContentProvider中获取数据。
  6. public String getType(Uri uri):该方法用于返回当前Url所代表数据的MIME类型。

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

例如:要得到所有person记录的Uri为content://com.cfox.contentprovid.PersonProvider/person
     那么返回的MIME类型字符串应该为:"vnd.android.cursor.dir/person"

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

例如:得到id为10的person记录,Uri为content://com.cfox.contentprovid.PersonProvider/person/10
     那么返回的MIME类型字符串为:"vnd.android.cursor.item/person"
public String getType(Uri uri) {
        UriMatcher uriMatcher = getUriMatcher();
        

        int match = uriMatcher.match(uri);
        switch (match) {
        case ITEMS:
            return "vnd.android.cursor.dir/" + person;
        case ITEMS_ID:
            return "vnd.android.cursor.item/" + person;
        default:
            throw new IllegalArgumentException("Unknown URI: " + uri);
        }
    }

第二步需要在AndroidManifest.xml使用对该ContentProvider进行配置,为了能让其他应用找到该ContentProvider ,ContentProvider采用了authorities(主机名/域名)对它进行唯一标识,你可以把ContentProvider看作是一个网 站(想想,网站也是提供数据者),authorities 就是他的域名:

<provider
    android:exported="true"
    android:name="com.example.contentprovideranddb.ContentProvider.PersonProvider"
    android:authorities="com.cfox.contentprovid.PersonProvider" />

四、使用ContentResolver操作ContentProvider中的数据

当外部应用需要对ContentProvider中的数据进行添加、删除、修改和查询操作时,可以使用ContentResolver 类来完成,要获取ContentResolver 对象,可以使用Activity提供的getContentResolver()方法。 ContentResolver 类提供了与ContentProvider类相同签名的四个方法:

public Uri insert(Uri uri, ContentValues values):该方法用于往ContentProvider添加数据。
public int delete(Uri uri, String selection, String[] selectionArgs):该方法用于从ContentProvider删除数据。
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs):该方法用于更新ContentProvider中的数据。
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder):该方法用于从ContentProvider中获取数据。

这些方法的第一个参数为Uri,代表要操作的ContentProvider和对其中的什么数据进行操作, 例如 Uri是:Uri.parse("content://com.cfox.contentprovid.PersonProvider/person /10"),那么将会对主机名为com.cfox.contentprovid.PersonProviderContentProvider进行操作,操作的数 据为person表中id为10的记录。 使用ContentResolverContentProvider中的数据进行添加、删除、修改和查询操作。

五、监听ContentProvider中数据的变化

如果ContentProvider的访问者需要知道ContentProvider中的数据发生变化,可以在ContentProvider发生数据变化时调用getContentResolver().notifyChange(uri, null)来通知注册在此URI上的访问者,例子如下:

public class BaseContentProvider extends ContentProvider {

    略.........

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        
    //添加数据变化通知
    getContext().getContentResolver().notifyChange(uri, null);
        return 返回一个Uri;
    }
    略........

}

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

public class MainActivity extends Activity {
    private Handler mHandler = new Handler();
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        this.getContentResolver().registerContentObserver(PersonProvider.uri, false, new MyObserver(mHandler));
    }
    
    public class MyObserver extends ContentObserver{

        public MyObserver(Handler handler) {
            super(handler);
        }
        
        @Override
        public void onChange(boolean selfChange) {
            super.onChange(selfChange);
            //数据改变时调用
            System.out.println("changing ------>");
        }
    }
}

六、ContentProvider 进行批量操作

  1. 批量插入
    ContentProvider 中有特定的方法进行批量插入bulkInsert, 在ContentProvider 中从写这个方法就可以实现批量插入的功能。下面来看一下, 在实际开发中的一种应用。

    @Override
    public int bulkInsert(@NonNull Uri uri, @NonNull ContentValues[] values) {
        int result;
        synchronized (this) {
            SQLiteDatabase db = mDBHelper.getWritableDatabase();
            try {
                db.beginTransaction();
                result = super.bulkInsert(uri, values);
                db.setTransactionSuccessful();
            }finally {
                db.endTransaction();
            }
        }
        return result;
    }
    

    从上面代码中可以看出,是为了使用事务批量插入数据提高插入效率。代码很简单,不详细介绍,这里说一下super.bulkInsert(uri, values); 这个行代码, 下面看一下ContentProvider 类中是怎么实现的。

    public int bulkInsert(@NonNull Uri uri, @NonNull ContentValues[] values) {
        int numValues = values.length;
        for (int i = 0; i < numValues; i++) {
            insert(uri, values[i]);
        }
        return numValues;
    }
    

    看到insert 相信很多人,就明白了,这里是遍历ContentValues 中的每一个value , 最后还是通过insert 方法进行操作。

    调用方法
    通过ContentResolver 中bulkInsert 方法进行批量操作:

    ContentResolver mcr = getContentResolver();
    ----
    ContentValues value1 = new ContentValues();
    ContentValues value2 = new ContentValues();
    ContentValues value3 = new ContentValues();
    ContentValues[] contentValues = {value1, value2, value3};
    int num = mcr.bulkInsert(mUri, contentValues);
    
  2. 批量操作
    在ContentProvider中提供了一个功能更加丰富的批量操作方法applyBatch,使用这个可以进行增、删、 改、 批量操作。还是先从一个使用例子开始。

    @Override
    public ContentProviderResult[] applyBatch(@NonNull ArrayList<ContentProviderOperation> operations) throws OperationApplicationException {
        ContentProviderResult[] results;
        synchronized (this) {
            SQLiteDatabase db = mDBHelper.getWritableDatabase();
            try {
                db.beginTransaction();
                results = super.applyBatch(operations);
                db.setTransactionSuccessful();
            }finally {
                db.endTransaction();
            }
        }
        return results;
    }
    

    看了上面代码和批量插入有些类似,在这些代码中要注意两段代码,ContentProviderOperationsuper.applyBatch(operations);

    下面先来看看如何使用:

    private static final String AUTHORITY = "com.cfox.contentprovid.PersonProvider";
    ----
    ContentResolver mcr = getContentResolver();
    ---- 
    ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
    ContentValues values = new ContentValues();
    operations.add(ContentProviderOperation.newInsert(mUri).withValues(values).build());
    
    ContentValues values = new ContentValues();
    operations.add(ContentProviderOperation.newUpdate(mUri)
            .withSelection("name = ?" , new String[]{"xxx"})
            .withValues(values).build());
            
    operations.add(ContentProviderOperation.newDelete(mUri)
            .withSelection("name = ?" , new String[]{"xxx"}).build());
            
    try {
        mcr.applyBatch(AUTHORITY, operations);
    } catch (OperationApplicationException e) {
        e.printStackTrace();
    } catch (RemoteException e) {
        e.printStackTrace();
    }
    

    上面代码不讲解,下面通过源码介绍一下在ContentProvider中是怎么进行批量操作的。
    先看一下ContentProviderOperation 中的 newInsertnewUpdatenewDelete 三个方法实现:

    public static Builder newInsert(Uri uri) {
        return new Builder(TYPE_INSERT, uri);
    }
    
    public static Builder newUpdate(Uri uri) {
        return new Builder(TYPE_UPDATE, uri);
    }
    
    public static Builder newDelete(Uri uri) {
        return new Builder(TYPE_DELETE, uri);
    }
    

    三个方法都是创建了一个Builder实例,返回的也是Builder对象而不是ContentProviderOperation,不同的是传入的type不同。那么在什么时候创建ContentProviderOperation呢?在我们调用Builder实例中的build()方法是创建。下面来看一下具体代码:

    public static class Builder {
        private final int mType;
    .... //省略部分代码
        private Builder(int type, Uri uri) {
            if (uri == null) {
                throw new IllegalArgumentException("uri must not be null");
            }
            mType = type;
            mUri = uri;
        }
            
    .... //省略部分代码
    }
    

    build的时候将mType设置给ContentProviderOperation中的mType.所以才有下面使用mType 判断是什么操作。

    在看一下ContentProvider 中applyBatch 方法实现:

    public @NonNull ContentProviderResult[] applyBatch(
            @NonNull ArrayList<ContentProviderOperation> operations)
                    throws OperationApplicationException {
        final int numOperations = operations.size();
        final ContentProviderResult[] results = new ContentProviderResult[numOperations];
        for (int i = 0; i < numOperations; i++) {
            results[i] = operations.get(i).apply(this, results, i);
        }
        return results;
    }
    

    上面代码就是遍历出list中的ContentProviderOperation然后调用apply方法,但是,到这里要注意apply方法中有一个参数是this。继续查看ContentProviderOperationapply方法实现:

    public ContentProviderResult apply(ContentProvider provider, ContentProviderResult[] backRefs,
            int numBackRefs) throws OperationApplicationException {
        ContentValues values = resolveValueBackReferences(backRefs, numBackRefs);
        String[] selectionArgs =
                resolveSelectionArgsBackReferences(backRefs, numBackRefs);
    
        if (mType == TYPE_INSERT) {
            Uri newUri = provider.insert(mUri, values);
            if (newUri == null) {
                throw new OperationApplicationException("insert failed");
            }
            return new ContentProviderResult(newUri);
        }
    
        int numRows;
        if (mType == TYPE_DELETE) {
            numRows = provider.delete(mUri, mSelection, selectionArgs);
        } else if (mType == TYPE_UPDATE) {
            numRows = provider.update(mUri, values, mSelection, selectionArgs);
        } else if (mType == TYPE_ASSERT) {
            .... //省略部分代码, 如果感兴趣,自己阅读研究
        } else {
            Log.e(TAG, this.toString());
            throw new IllegalStateException("bad type, " + mType);
        }
    
        if (mExpectedCount != null && mExpectedCount != numRows) {
            Log.e(TAG, this.toString());
            throw new OperationApplicationException("wrong number of rows: " + numRows);
        }
    
        return new ContentProviderResult(numRows);
    }
    

    看到这里,就会恍然大悟,原来是在这里通过mType这个属性,判断增、删、改的,让后在通过传入的ContentProvider调用继承ContentProvider实现的insertupdatedelete 方法。

七、ContentProvider call 方法

先看一个使用例子:
ContentProvider 实现:

@Override
public Bundle call(@NonNull String method, @Nullable String arg, @Nullable Bundle extras) {
    extras.putString("data", "this is data");
    return extras;
}

在activity 中调用call 实现:

ContentResolver mcr = getContentResolver();
---- 
Bundle bundleArg = new Bundle();
bundleArg.putString("good", "hello");
Bundle bundle = mcr.call(mUri, "callMethod","call,,,,,,",bundleArg);
Log.d(TAG, "callMethod: data:" + bundle.get("data"));

调用和被调用参数都是相同的,注意:所有参数不可为NULL, 可以使用这个方法使用Bundle进行数据交互。

八、ContentProvider 文件共享

ContentProvider中用于共享大的文件的时候比较多。文件会以流的形式传递。
下面是一个简单的使用例子:
ContentProvider 中示例:

@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
    String basePath = Environment.getExternalStorageDirectory().getPath() + "/aaa.pdf";
    File file = new File(basePath);
    ParcelFileDescriptor descriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
    return descriptor;
}

activity 中调用示例:

Uri mUri = "";
----
ContentResolver mcr = getContentResolver();
---- 
String basePath = Environment.getExternalStorageDirectory().getPath() + "/bb.pdf";
File file = new File(basePath);
if (!file.isFile()) {
    try {
        boolean b = file.createNewFile();
        Log.d(TAG, "AssetFile: b:" + b);
    } catch (IOException e) {
        e.printStackTrace();
    }
}
try {
    AssetFileDescriptor descriptor = mcr.openAssetFileDescriptor(mUri, "r");

    InputStream in = descriptor.createInputStream();
    OutputStream out = new FileOutputStream(file);
    byte[] buffer = new byte[1024];
    int len;
    while ((len = in.read(buffer)) != -1) {
        out.write(buffer, 0 , len);
    }
    out.flush();
    in.close();
    out.close();
}catch (Exception e) {
    e.printStackTrace();
}finally {
    Log.d(TAG, "AssetFile: successs....");
}

上面代码不解释了,写个demo体会一下,很简单的, 这篇文章只是引路,告诉你ContentProvider 中有这些东西,具体在项目中如何使用,还要结合实际场景。

九、几点注意

  1. 不要在UI线程中调用, 容易出现ANR。
  2. ContentProvideronCreate在运行进程的主线程中执行。如果``ContentProvider和应用运行在同一进程,所以尽量不要在onCreate中执行耗时的操作或者不要做任何事情,如果在onCreate`中执行耗时的代码,会影响应用启动速度。
  3. 如果ContentProvider和调用者在同一个进程,则ContentProvider和调用者在同一个线程。
  4. 如果ContentProvider和调用者不在同一个进程,onCreate在进程的主线程中执行, insertupdatedeletequerycall等在同一个线程中执行(非主线程)。

附:

在android4.2 在使用到 ContentProvider 的时候遇到的问题,报错Permission Denial: opening provider uid=10033) .... that is not exported from uid 10036
AndroidManifest.xmlandroid:exported 属性,这个属性用于指示该服务是否能被其他程序应用组件调用或跟他交互; 取值为(true | false),如果设置成true,则能够被调用或交互,否则不能;设置为false时,只有同一个应用程序的组件或带有相同用户ID的应用程序才能启动或绑定该服务。

<provider
    android:exported="true"
    android:name="com.example.contentprovideranddb.ContentProvider.PersonProvider"
    android:authorities="com.cfox.contentprovid.PersonProvider" />
  • 重要:它的默认值是依赖于该服务所包含的过滤器
    1. 如果没有过滤器则意味着该服务只能通过指定明确的类名来调用,也就是说该服务只能在应用程序内部使用(因为其他外部使用者不会知道该服务的类名),此时它的默认值是false
    2. 如果至少包含了一个过滤器,则意味着该服务可以给外部的其他应用提供服务,因此默认值是true。

本文Uri 部分参考 :http://blog.sina.com.cn/s/blog_9f233c070101euqx.html

注:以上内容,如有不对之处请及时指出,相互交流学习。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容