Android MediaProvider 掃描優(yōu)化

本文以 Android 9.0 為準(zhǔn)

概述

《Android MediaProvider》 這篇文章中分析了 MediaProvider 的源碼,不過當(dāng)多個外部存儲設(shè)備,并且存儲大量文件時嘀韧,MediaProvider 會存在掃描慢的情況揪阶,為了加快其掃描速度,修改策略為:

  1. 在第一次外部存儲設(shè)備插入時睬罗,掃描數(shù)據(jù)存入數(shù)據(jù)庫轨功,拔出時不刪除數(shù)據(jù)庫
  2. 針對多個外部存儲設(shè)備,存儲多個數(shù)據(jù)表
  3. 第二次插入外部存儲設(shè)備時容达,找到對應(yīng)的數(shù)據(jù)庫古涧,清理數(shù)據(jù)庫中的臟數(shù)據(jù)

當(dāng)然,這種方式花盐,在第一次掃描速度上羡滑,沒有進(jìn)行優(yōu)化,也沒有優(yōu)化的空間算芯,只能在第二次插拔上做文章柒昏,因為原生的 MediaProvider 會在拔出外部存儲設(shè)備時刪除數(shù)據(jù)庫。

并且目前連接多個外部設(shè)備時熙揍,只操作一個外部數(shù)據(jù)庫(數(shù)據(jù)庫位置:/data/user/userid/com.android.providers.media/databases/external.db)职祷,這樣就會導(dǎo)致數(shù)據(jù)量大,操作緩慢届囚。

分析過程

通常來說有梆,在 ContentProvider 中打開數(shù)據(jù)庫的操作,應(yīng)該在 onCreate() 方法中進(jìn)行的 意系。

@Override
public boolean onCreate() {
    ...
    // DatabaseHelper緩存
    mDatabases = new HashMap<String, DatabaseHelper>();
    // 綁定內(nèi)部存儲數(shù)據(jù)庫
    attachVolume(INTERNAL_VOLUME);
    ...
    String state = Environment.getExternalStorageState();
    if (Environment.MEDIA_MOUNTED.equals(state) ||
            Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
         // 如果已掛載外部存儲泥耀,綁定外部存儲數(shù)據(jù)庫
        attachVolume(EXTERNAL_VOLUME);
    }
    ...
    return true;
}

可以看到打開數(shù)據(jù)庫的位置在 attachVolume() 方法中:

/**
 * Attach the database for a volume (internal or external).
 * Does nothing if the volume is already attached, otherwise
 * checks the volume ID and sets up the corresponding database.
 *
 * @param volume to attach, either {@link #INTERNAL_VOLUME} or {@link #EXTERNAL_VOLUME}.
 * @return the content URI of the attached volume.
 */
private Uri attachVolume(String volume) {
    ...
    // Update paths to reflect currently mounted volumes
    // 更新路徑以反映當(dāng)前裝載的卷
    updateStoragePaths();

    DatabaseHelper helper = null;
    synchronized (mDatabases) {
        helper = mDatabases.get(volume);
        // 判斷是否已經(jīng)attached過了
        if (helper != null) {
            if (EXTERNAL_VOLUME.equals(volume)) {
                // 確保默認(rèn)的文件夾已經(jīng)被創(chuàng)建在掛載的主要存儲設(shè)備上,
                // 對每個存儲卷只做一次這種操作昔字,所以當(dāng)用戶手動刪除時不會打擾
                ensureDefaultFolders(helper, helper.getWritableDatabase());
            }
            return Uri.parse("content://media/" + volume);
        }

        Context context = getContext();
        if (INTERNAL_VOLUME.equals(volume)) {
            // 如果是內(nèi)部存儲則直接實例化DatabaseHelper
            helper = new DatabaseHelper(context, INTERNAL_DATABASE_NAME, true,
                    false, mObjectRemovedCallback);
        } else if (EXTERNAL_VOLUME.equals(volume)) {
            // 如果是外部存儲的操作爆袍,只獲取主要的外部卷ID
            // Only extract FAT volume ID for primary public
            final VolumeInfo vol = mStorageManager.getPrimaryPhysicalVolume();
            if (vol != null) {
                final StorageVolume actualVolume = mStorageManager.getPrimaryVolume();
                final int volumeId = actualVolume.getFatVolumeId();

                // Must check for failure!
                // If the volume is not (yet) mounted, this will create a new
                // external-ffffffff.db database instead of the one we expect.  Then, if
                // android.process.media is later killed and respawned, the real external
                // database will be attached, containing stale records, or worse, be empty.
                // 數(shù)據(jù)庫都是以類似 external-ffffffff.db 的形式命名的首繁,
                // 后面的 8 個 16 進(jìn)制字符是該 SD 卡 FAT 分區(qū)的 Volume ID。
                // 該 ID 是分區(qū)時決定的陨囊,只有重新分區(qū)或者手動改變才會更改弦疮,
                // 可以防止插入不同 SD 卡時數(shù)據(jù)庫沖突。
                if (volumeId == -1) {
                    String state = Environment.getExternalStorageState();
                    if (Environment.MEDIA_MOUNTED.equals(state) ||
                            Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
                        // This may happen if external storage was _just_ mounted.  It may also
                        // happen if the volume ID is _actually_ 0xffffffff, in which case it
                        // must be changed since FileUtils::getFatVolumeId doesn't allow for
                        // that.  It may also indicate that FileUtils::getFatVolumeId is broken
                        // (missing ioctl), which is also impossible to disambiguate.
                        // 已經(jīng)掛載但是sd卡是只讀狀態(tài)
                        Log.e(TAG, "Can't obtain external volume ID even though it's mounted.");
                    } else {
                        // 還沒有掛載
                        Log.i(TAG, "External volume is not (yet) mounted, cannot attach.");
                    }

                    throw new IllegalArgumentException("Can't obtain external volume ID for " +
                            volume + " volume.");
                }

                // generate database name based on volume ID
                // 根據(jù)volume ID設(shè)置數(shù)據(jù)庫的名稱
                String dbName = "external-" + Integer.toHexString(volumeId) + ".db";
                // 創(chuàng)建外部存儲數(shù)據(jù)庫
                helper = new DatabaseHelper(context, dbName, false,
                        false, mObjectRemovedCallback);
                mVolumeId = volumeId;
            } else {
                // external database name should be EXTERNAL_DATABASE_NAME
                // however earlier releases used the external-XXXXXXXX.db naming
                // for devices without removable storage, and in that case we need to convert
                // to this new convention
                // 外部數(shù)據(jù)庫名稱應(yīng)為EXTERNAL_DATABASE_NAME
                // 但是較早的版本對沒有可移動存儲的設(shè)備使用external-XXXXXXXX.db命名
                // 在這種情況下蜘醋,我們需要轉(zhuǎn)換為新的約定
                ...
                // 根據(jù)之前轉(zhuǎn)換的數(shù)據(jù)庫名胁塞,創(chuàng)建數(shù)據(jù)庫
                helper = new DatabaseHelper(context, dbFile.getName(), false,
                        false, mObjectRemovedCallback);
            }
        } else {
            throw new IllegalArgumentException("There is no volume named " + volume);
        }
        // 緩存起來,標(biāo)識已經(jīng)創(chuàng)建過了數(shù)據(jù)庫
        mDatabases.put(volume, helper);
        ...
    }

    if (EXTERNAL_VOLUME.equals(volume)) {
        // 給外部存儲創(chuàng)建默認(rèn)的文件夾
        ensureDefaultFolders(helper, helper.getWritableDatabase());
    }
    return Uri.parse("content://media/" + volume);
}

可以總結(jié):

  • 如果此存儲卷的 DatabaseHelper 已創(chuàng)建压语,則不執(zhí)行任何操作啸罢。
  • 對于內(nèi)部存儲,數(shù)據(jù)存儲在 internal.db 中胎食;
  • 對于外部存儲扰才,獲取主要的外部卷 ID,根據(jù) ID 數(shù)據(jù)存儲在 external-XXXXXXXX.db 中厕怜;
  • 對于較早版本中的外部存儲衩匣,數(shù)據(jù)保存在 external.db 中,需要轉(zhuǎn)換為新的約定粥航;
  • 緩存 DatabaseHelper琅捏。

切入點(diǎn)是,對于外部存儲递雀,創(chuàng)建 DatabaseHelper 時柄延,直接使用參數(shù)傳遞過來的 volumeId,打開 external-XXXXXXXX.db缀程。

代碼修改

一搜吧、MediaProvider

private Uri attachVolume(String volume) {
    ...
    if (INTERNAL_VOLUME.equals(volume)) {
        ...
    } else if (EXTERNAL_VOLUME.equals(volume)) {
        // Only extract FAT volume ID for primary public
        final VolumeInfo vol = mStorageManager.getPrimaryPhysicalVolume();
        if (vol != null) {
            final StorageVolume actualVolume = mStorageManager.getPrimaryVolume();
            final int volumeId = actualVolume.getFatVolumeId();
            ...
        } else {
            ...
        }
    } else {
        throw new IllegalArgumentException("There is no volume named " + volume);
    }
    ...
}

修改為

private Uri attachVolume(String volume) {
    ...
    if (INTERNAL_VOLUME.equals(volume)) {
        ...
    } else {
        final VolumeInfo vol = mStorageManager.getPrimaryPhysicalVolume();
        if (vol != null) {
            int volumeId = -1;
            try {
                volumeId = Integer.valueOf(volume);
            } catch (NumberFormatException e) {
                e.printStackTrace();
            }
            ...
        } else {
            ...
        }
    }
    ...
}

那么 attachVolume(String volume) ,對于外部存儲傳入的 volume 就不應(yīng)是 EXTERNAL_VOLUME 了杠输,而應(yīng)該是外部存儲的 volumeId赎败。

volumeId 可以通過 StorageVolume 的 getFatVolumeId 方法獲取,本質(zhì)就是將 UUID 轉(zhuǎn)換的 int 值蠢甲,而 UUID 是一個長度為 9 的字符串僵刮,類似與 “57E9-73B0”這樣。

繼而需要找到 attachVolume(String volume) 調(diào)用的地方鹦牛,修改參數(shù)為 volumeId搞糕,找到有兩個地方調(diào)用了:

1、onCreate() 方法中:

String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state) ||
        Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
    attachVolume(EXTERNAL_VOLUME);
}

可以通過 StorageManager.getStorageVolumes() 獲取全部的 StorageVolume曼追,因此修改為

final List<StorageVolume> storageVolumes = mStorageManager.getStorageVolumes();
String state;
for (StorageVolume storageVolume : storageVolumes) {
    state = storageVolume.getState();
    if (Environment.MEDIA_MOUNTED.equals(state) ||
            Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
        attachVolume(String.valueOf(storageVolume.getFatVolumeId()));
    }
}

2窍仰、在 insertInternal() 方法中:

private Uri insertInternal(Uri uri, int match, ContentValues initialValues,
                               ArrayList<Long> notifyRowIds) {
    ...
    switch (match) {
        case VOLUMES:
        {
            String name = initialValues.getAsString("name");
            Uri attachedVolume = attachVolume(name);
            if (mMediaScannerVolume != null && mMediaScannerVolume.equals(name)) {
                DatabaseHelper dbhelper = getDatabaseForUri(attachedVolume);
                if (dbhelper == null) {
                    Log.e(TAG, "no database for attached volume " + attachedVolume);
                } else {
                    dbhelper.mScanStartTime = SystemClock.currentTimeMicro();
                }
            }
            return attachedVolume;
        }
    }
    ...
}

這個方法調(diào)用的源頭是在 MediaScannerService 中,下面分析掃描流程礼殊。

二驹吮、MediaScannerService

private void openDatabase(String volumeName) {
    try {
        ContentValues values = new ContentValues();
        values.put("name", volumeName);
        getContentResolver().insert(Uri.parse("content://media/"), values);
    } catch (IllegalArgumentException ex) {
        Log.w(TAG, "failed to open media database");
    }         
}

private void scan(String[] directories, String volumeName) {
    Uri uri = Uri.parse("file://" + directories[0]);
    // don't sleep while scanning
    mWakeLock.acquire();

    try {
        ContentValues values = new ContentValues();
        values.put(MediaStore.MEDIA_SCANNER_VOLUME, volumeName);
        Uri scanUri = getContentResolver().insert(MediaStore.getMediaScannerUri(), values);

        sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_STARTED, uri));

        try {
            if (volumeName.equals(MediaProvider.EXTERNAL_VOLUME)) {
                openDatabase(volumeName);
            }

            try (MediaScanner scanner = new MediaScanner(this, volumeName)) {
                scanner.scanDirectories(directories);
            }
        } catch (Exception e) {
            Log.e(TAG, "exception in MediaScanner.scan()", e);
        }

        getContentResolver().delete(scanUri, null, null);

    } finally {
        sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_FINISHED, uri));
        mWakeLock.release();
    }
}

由于 volumeName 不再是 EXTERNAL_VOLUME针史,修改 openDatabase() 執(zhí)行的判斷語句。

if (!volumeName.equals(MediaProvider.INTERNAL_VOLUME)) {
    openDatabase(volumeName);
}

注意在調(diào)用 scan() 方法的地方碟狞,傳入的 directories 根據(jù) volume 做了一個判斷啄枕,注意也要修正過來。

private final class ServiceHandler extends Handler {
    @Override
    public void handleMessage(Message msg) {
        ...
        String volume = arguments.getString("volume");
        String[] directories = null;

        if (MediaProvider.INTERNAL_VOLUME.equals(volume)) {
            // scan internal media storage
            directories = new String[] {
                    Environment.getRootDirectory() + "/media",
                    Environment.getOemDirectory() + "/media",
                    Environment.getProductDirectory() + "/media",
            };
        }
        // 這里的判斷要修改 start
        /*else if (MediaProvider.EXTERNAL_VOLUME.equals(volume)) {*/
        else {
        // 這里的判斷要修改 end
            // scan external storage volumes
            if (getSystemService(UserManager.class).isDemoUser()) {
                directories = ArrayUtils.appendElement(String.class,
                        mExternalStoragePaths,
                        Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
            } else {
                directories = mExternalStoragePaths;
            }
        }

        if (directories != null) {
            if (false) Log.d(TAG, "start scanning volume " + volume + ": "
                    + Arrays.toString(directories));
            scan(directories, volume);
            if (false) Log.d(TAG, "done scanning volume " + volume);
        }
        ...
    }
}

找到 volumeName 賦值的源頭在 MediaScannerReceiver 中族沃。

這里猜測频祝,存在一個優(yōu)化點(diǎn),掃描的路徑 directories脆淹,源碼中是全部的外部存儲路徑常空,為了節(jié)省性能,directories 可以根據(jù)外部設(shè)備的 StorageVolume 的 getPath() 方法盖溺,進(jìn)行單個路徑掃描漓糙。如果要處理的話,我們在這里需要 MediaScannerReceiver 將路徑傳遞過來咐柜。(還未驗證兼蜈,如果不行的話,就不要對 directories 進(jìn)行修改了)拙友。

三、MediaScannerReceiver

@Override
public void onReceive(Context context, Intent intent) {
    final String action = intent.getAction();
    final Uri uri = intent.getData();
    if (Intent.ACTION_BOOT_COMPLETED.equals(action)) {
        ...
    } else if (Intent.ACTION_LOCALE_CHANGED.equals(action)) {
        ...
    } else {
        if (uri.getScheme().equals("file")) {
            ...
            if (Intent.ACTION_MEDIA_MOUNTED.equals(action)) {
                // scan whenever any volume is mounted
                scan(context, MediaProvider.EXTERNAL_VOLUME);
            } 
            ...
        }
    }
}

所以切入點(diǎn)就是歼郭,在接收 ACTION_MEDIA_MOUNTED 廣播的位置遗契,獲取到 Uri,傳入 scan() 方法病曾。

scan(context, uri.toString());

最后在回到 MediaScannerService 修改接收的參數(shù) "volume"牍蜂。

四、MediaScannerService

private final class ServiceHandler extends Handler {
    @Override
    public void handleMessage(Message msg) {
        ...
        String volume = arguments.getString("volume");
        String[] directories = null;

        if (MediaProvider.INTERNAL_VOLUME.equals(volume)) {
            // scan internal media storage
            directories = new String[] {
                    Environment.getRootDirectory() + "/media",
                    Environment.getOemDirectory() + "/media",
                    Environment.getProductDirectory() + "/media",
            };
        }
        // 這里需要修改 start
        /*else if (MediaProvider.EXTERNAL_VOLUME.equals(volume)) {
            // scan external storage volumes
            if (getSystemService(UserManager.class).isDemoUser()) {
                directories = ArrayUtils.appendElement(String.class,
                        mExternalStoragePaths,
                        Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
            } else {
                directories = mExternalStoragePaths;
            }
        }*/
        else {
            boolean isSinglePath = false;// 標(biāo)記單個路徑掃描
            String[] singleStoragePath = new String[1];
            try {
                // Uri 轉(zhuǎn)換為 File
                File file = new File(new URI(volume));
                // 根據(jù) File 獲取 StorageVolume
                StorageVolume storageVolume = mStorageManager.getStorageVolume(file);
                if (storageVolume != null) {
                    // 重新賦值 volume 為 volumeId
                    volume = String.valueOf(storageVolume.getFatVolumeId());
                    singleStoragePath[0] = storageVolume.getPath();
                    isSinglePath = true;
                }
            } catch (URISyntaxException e) {
                e.printStackTrace();
            }
            if (getSystemService(UserManager.class).isDemoUser()) {
                if (isSinglePath) {
                    directories = ArrayUtils.appendElement(String.class,
                            singleStoragePath,
                            Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
                } else {
                    directories = ArrayUtils.appendElement(String.class,
                            mExternalStoragePaths,
                            Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
                }
            } else {
                if (isSinglePath) {
                    directories = singleStoragePath;
                } else {
                    directories = mExternalStoragePaths;
                }
            }
        }
        // 這里需要修改 end

        if (directories != null) {
            if (false) Log.d(TAG, "start scanning volume " + volume + ": "
                    + Arrays.toString(directories));
            scan(directories, volume);
            if (false) Log.d(TAG, "done scanning volume " + volume);
        }
        ...
    }
}

五泰涂、ContentResolver

所有操作外部存儲的地方鲫竞,增刪改查使用的 URI 中的 path 都要修改為 volumeId,才能正常運(yùn)行逼蒙,而 MediaStore.Audio.Media.EXTERNAL_CONTENT_URI 就不能使用了从绘,看下 URI 的組成:

public static final Uri EXTERNAL_CONTENT_URI = getContentUri("external");

public static Uri getContentUri(String volumeName) {
    return Uri.parse(CONTENT_AUTHORITY_SLASH + volumeName +
            "/audio/media");
}

再看下 MediaProvider 中是根據(jù) URI 的 path 中 volumeName 去獲取 DatabaseHelper:

private DatabaseHelper getDatabaseForUri(Uri uri) {
    synchronized (mDatabases) {
        if (uri.getPathSegments().size() >= 1) {
            return mDatabases.get(uri.getPathSegments().get(0));
        }
    }
    return null;
}

所以開發(fā)者使用中,只要傳入 volumeId 即可完成閉環(huán)是牢。

MediaStore.Audio.Media.getContentUri(volumeId)

問題點(diǎn) :

1僵井、mVolumeId 需要處理嗎,添加多個到 MatrixCursor?

// Used temporarily (until we have unique media IDs) to get an identifier
// for the current sd card, so that the music app doesn't have to use the
// non-public getFatVolumeId method
if (table == FS_ID) {
    MatrixCursor c = new MatrixCursor(new String[] {"fsid"});
    c.addRow(new Integer[] {mVolumeId});
    return c;
}

目前沒有處理驳棱,可以正常運(yùn)行批什,先不處理該邏輯。


參考資料:
android 添加或者取消對于某種媒體文件格式的支持
Android源碼個個擊破之-多媒體掃描
Android媒體掃描詳細(xì)解析之一
Android媒體掃描詳細(xì)解析之二

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末社搅,一起剝皮案震驚了整個濱河市驻债,隨后出現(xiàn)的幾起案子乳规,更是在濱河造成了極大的恐慌,老刑警劉巖合呐,帶你破解...
    沈念sama閱讀 216,402評論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件暮的,死亡現(xiàn)場離奇詭異,居然都是意外死亡合砂,警方通過查閱死者的電腦和手機(jī)青扔,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評論 3 392
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來翩伪,“玉大人微猖,你說我怎么就攤上這事≡狄伲” “怎么了凛剥?”我有些...
    開封第一講書人閱讀 162,483評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長轻姿。 經(jīng)常有香客問我犁珠,道長,這世上最難降的妖魔是什么互亮? 我笑而不...
    開封第一講書人閱讀 58,165評論 1 292
  • 正文 為了忘掉前任犁享,我火速辦了婚禮,結(jié)果婚禮上豹休,老公的妹妹穿的比我還像新娘炊昆。我一直安慰自己,他們只是感情好威根,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,176評論 6 388
  • 文/花漫 我一把揭開白布凤巨。 她就那樣靜靜地躺著,像睡著了一般洛搀。 火紅的嫁衣襯著肌膚如雪敢茁。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,146評論 1 297
  • 那天留美,我揣著相機(jī)與錄音彰檬,去河邊找鬼。 笑死独榴,一個胖子當(dāng)著我的面吹牛僧叉,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播棺榔,決...
    沈念sama閱讀 40,032評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼瓶堕,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了症歇?” 一聲冷哼從身側(cè)響起郎笆,我...
    開封第一講書人閱讀 38,896評論 0 274
  • 序言:老撾萬榮一對情侶失蹤谭梗,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后宛蚓,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體激捏,經(jīng)...
    沈念sama閱讀 45,311評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,536評論 2 332
  • 正文 我和宋清朗相戀三年凄吏,在試婚紗的時候發(fā)現(xiàn)自己被綠了远舅。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,696評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡痕钢,死狀恐怖图柏,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情任连,我是刑警寧澤蚤吹,帶...
    沈念sama閱讀 35,413評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站随抠,受9級特大地震影響裁着,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜拱她,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,008評論 3 325
  • 文/蒙蒙 一二驰、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧秉沼,春花似錦诸蚕、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽坏瘩。三九已至盅抚,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間倔矾,已是汗流浹背妄均。 一陣腳步聲響...
    開封第一講書人閱讀 32,815評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留哪自,地道東北人丰包。 一個月前我還...
    沈念sama閱讀 47,698評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像壤巷,于是被迫代替她去往敵國和親邑彪。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,592評論 2 353