SharedPreferences源码解析

SharedPreferences是开发中很常见的一个类,它的主要作用是持久化本地的一些基础数据,方便我们做一些简单的业务判断。基础用法如下:

SharedPreferences sharedPrefs = context.getSharedPreferences("tts", Context.MODE_PRIVATE);

// 持久化值
SharedPreferences.Editor editor = sharedPrefs.edit();
editor.putString("version", "1.0");
editor.commit();

// 取出值
String version = sharedPrefs.getString("version", "");

SharedPreferences具有简单和无结构化的特点,对于简单的业务场景来说,它比Database更为实用,其本质上就是一个xml文件和I/O操作的集合,上述操作生成的tts.xml内容如下:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="version" value="1.0" />
</map>

SharedPreferences会对磁盘的文件做操作,但磁盘操作都是较为耗时的,所以Android会将磁盘内容读取到内存中,从而直接对内存进行操作,这就是SharedPreferences缓存机制。

源码分析

以下源码基于Android 8.1,先看下SharedPreferences的两种获取方式:context.getSharedPreferences()Preferencemanager.getDefaultSharedPreferences(),其中Preferencemanager.getDefaultSharedPreferences()也是调用了 context.getSharedPreferences(),只是将 Packagename + “_preferences” 作为SP文件的名字,代码如下:

/**
 * Gets a {@link SharedPreferences} instance that points to the default file that is used by
 * the preference framework in the given context.
 *
 * @param context The context of the preferences whose values are wanted.
 * @return A {@link SharedPreferences} instance that can be used to retrieve and listen
 *         to values of the preferences.
 */
public static SharedPreferences getDefaultSharedPreferences(Context context) {
    return context.getSharedPreferences(getDefaultSharedPreferencesName(context),
            getDefaultSharedPreferencesMode());
}

/**
 * Returns the name used for storing default shared preferences.
 *
 * @see #getDefaultSharedPreferences(Context)
 */
public static String getDefaultSharedPreferencesName(Context context) {
    return context.getPackageName() + "_preferences";
}

private static int getDefaultSharedPreferencesMode() {
    return Context.MODE_PRIVATE;
}

再看下context.getSharedPreferences() ,实现在 ContextImpl 里面:

@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
    // At least one application in the world actually passes in a null
    // name.  This happened to work because when we generated the file name
    // we would stringify it to "null.xml".  Nice.
    if (mPackageInfo.getApplicationInfo().targetSdkVersion <
            Build.VERSION_CODES.KITKAT) {
        if (name == null) {
            name = "null";
        }
    }
    // 1.通过name获取File
    File file;
    synchronized (ContextImpl.class) {
        if (mSharedPrefsPaths == null) {
            mSharedPrefsPaths = new ArrayMap<>();
        }
        file = mSharedPrefsPaths.get(name);
        if (file == null) {
            file = getSharedPreferencesPath(name);
            mSharedPrefsPaths.put(name, file);
        }
    }
    // 2.通过File获取SharedPreferences
    return getSharedPreferences(file, mode);
}

@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
    SharedPreferencesImpl sp;
    synchronized (ContextImpl.class) {
        final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
        sp = cache.get(file);
        if (sp == null) {
            checkMode(mode);
            if (getApplicationInfo().targetSdkVersion >= android.os.Build.VERSION_CODES.O) {
                if (isCredentialProtectedStorage()
                        && !getSystemService(UserManager.class)
                                .isUserUnlockingOrUnlocked(UserHandle.myUserId())) {
                    throw new IllegalStateException("SharedPreferences in credential encrypted "
                            + "storage are not available until after user is unlocked");
                }
            }
            sp = new SharedPreferencesImpl(file, mode);
            cache.put(file, sp);
            return sp;
        }
    }
    if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
        getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
        // If somebody else (some other process) changed the prefs
        // file behind our back, we reload it.  This has been the
        // historical (if undocumented) behavior.
        sp.startReloadIfChangedUnexpectedly();
    }
    return sp;
}

private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {
    if (sSharedPrefsCache == null) {
        sSharedPrefsCache = new ArrayMap<>();
    }

    final String packageName = getPackageName();
    ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
    if (packagePrefs == null) {
        packagePrefs = new ArrayMap<>();
        sSharedPrefsCache.put(packageName, packagePrefs);
    }

    return packagePrefs;
}

分析上述代码,getSharedPreferences()会先判断 mSharedPrefsPaths 中是否有缓存(它维护从SharedPreferences name到File的映射),如果没有只能根据File path获取File对象,再缓存至 mSharedPrefsPaths 中。

那么如何通过File对象获取SharedPreferences对象呢?主要依靠 sSharedprefsCache 缓存,sSharedprefsCache 的key为package name,value为File对象到SharedPreferences对象的映射,如果sSharedprefsCache 里面没有package name对应的缓存,则先创建File对象到SharedPreferences对象的映射,再new SharedpreferenceImpl(Sharedpreferences实现类)对象并缓存起来。

从这段代码看,getSharedPreferences()不仅是线程安全的,还有一些对于多进程的保护措施:当模式为MODE_MULTI_PROCESS时,会通过sp.startReloadIfChangedUnexpectedly()去尝试再次加载xml文件内容,然而通过这种方式来保证多进程访问的安全性会有以下问题:

  1. 使用MODE_MULTI_PROCESS时,不要在本地自行缓存Sharedpreferences,必须每次都从context.getSharedPreferences()获取,否则无法触发reload,可能导致两个进程数据不同步。
  2. 从磁盘加载xml文件是耗时的,此时如果进行Sharedpreferences其他操作都会阻塞等待,这意味着很多时候获取Sharedpreferences数据都不得不从xml文件再读一遍,大大降低了内存缓存的作用。
  3. 修改Sharedpreferences数据时只能用commit(),保证修改时写入了文件,这样其他进程才能通过文件大小或修改时间感知到文件发生了变化。

无论怎么说,MODE_MULTI_PROCESS都很糟糕,因此已被废弃,Android更建议使用ContentProvider来处理多进程间的文件共享。

根据上面的分析,在冷启动的场景下首次调用getSharedPreferences()时,会执行new ShaedPreferenceImpl()

SharedPreferencesImpl(File file, int mode) {
    mFile = file;
    mBackupFile = makeBackupFile(file);
    mMode = mode;
    mLoaded = false;
    mMap = null;          // SharedPreferences中所有键值对,从xml文件中读取
    startLoadFromDisk();  // 开启线程异步加载xml文件内容
}

private void startLoadFromDisk() {
    synchronized (mLock) {  // 悲观锁保证线程安全,可以优化成CAS乐观锁
        mLoaded = false;    // 标识xml文件未加载完成
    }
    new Thread("SharedPreferencesImpl-load") {
        public void run() {
            loadFromDisk();
        }
    }.start();
}

从代码看,主要是异步线程读取xml文件,线程的名字是 SharedpreferencesImpl-load,这个过程也是线程安全的。

private void loadFromDisk() {
    synchronized (mLock) {
        if (mLoaded) {
            return;
        }
        if (mBackupFile.exists()) {
            mFile.delete();
            mBackupFile.renameTo(mFile);
        }
    }

    // Debugging
    if (mFile.exists() && !mFile.canRead()) {
        Log.w(TAG, "Attempt to read preferences file " + mFile + " without permission");
    }

    Map map = null;
    StructStat stat = null;
    try {
        stat = Os.stat(mFile.getPath());
        if (mFile.canRead()) {
            BufferedInputStream str = null;
            try {
                str = new BufferedInputStream(
                        new FileInputStream(mFile), 16*1024);  // 通过Java IO对文件进行读取
                map = XmlUtils.readMapXml(str);  // xml解析
            } catch (Exception e) {
                Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
            } finally {
                IoUtils.closeQuietly(str);
            }
        }
    } catch (ErrnoException e) {
        /* ignore */
    }

    synchronized (mLock) {
        mLoaded = true; // xml文件加载完毕
        if (map != null) {
            mMap = map; // 读取的键值对缓存至mMap
            mStatTimestamp = stat.st_mtim;
            mStatSize = stat.st_size;
        } else {
            mMap = new HashMap<>();
        }
        mLock.notifyAll();// 激活正在等待的线程
    }
}

上述代码通过XmlUtils.readMapXml()读取xml中所有键值对,并缓存至 mMap。因此,Sharedpreferences在冷启动后首次使用时性能开销大,主要是把文件中所有的键值对读取到内存的过程。

那么我们每次从Sharedpreferences中读取数据,都会立刻取到吗?让我们看下实现:

@Nullable
public String getString(String key, @Nullable String defValue) {
    synchronized (mLock) {
        awaitLoadedLocked();    // 阻塞等待xml文件加载完成
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}

@GuardedBy("mLock")
private void awaitLoadedLocked() {
    if (!mLoaded) {
        // Raise an explicit StrictMode onReadFromDisk for this
        // thread, since the real read will be in a different
        // thread and otherwise ignored by StrictMode.
        BlockGuard.getThreadPolicy().onReadFromDisk();
    }
    while (!mLoaded) {
        try {
            mLock.wait();  // 阻塞等待
        } catch (InterruptedException unused) {
        }
    }
    if (mThrowable != null) {
        throw new IllegalStateException(mThrowable);
    }
}

从代码看getString()会从 mMap 中直接获取,而且是线程安全的,如果此时xml文件还没有加载到内存,则会阻塞等待。这是SP的一个缺点。

再看下写入过程,所有对于Sharedpreferences的修改操作都需要一个 Editor 对象,它的实现类是 EditorImpl:

public final class EditorImpl implements Editor {
    private final Object mLock = new Object();

    @GuardedBy("mLock")
    private final Map<String, Object> mModified = Maps.newHashMap();  // 记录diff数据 

    @GuardedBy("mLock")
    private boolean mClear = false;

    public Editor putString(String key, @Nullable String value) {
        synchronized (mLock) {
            mModified.put(key, value);
            return this;
        }
    }
}

从代码中可以看出,执行putString()时,只是写到了 mModified 中,并没有写入 mMap,更没有写入磁盘xml文件。

那什么时机写入呢?答案是在commit()apply()时:

public boolean commit() {
    long startTime = 0;

    if (DEBUG) {
        startTime = System.currentTimeMillis();
    }
    // 1.提交到内存
    MemoryCommitResult mcr = commitToMemory();
    // 2.写盘操作,完成后由mcr释放锁,注意第二个参数为null,表示在当前线程同步写盘
    SharedPreferencesImpl.this.enqueueDiskWrite(
        mcr, null /* sync write on this thread okay */);
    try {
        // 3.利用mcr开启CountDownLatch阻塞
        mcr.writtenToDiskLatch.await();
    } catch (InterruptedException e) {
        return false;
    } finally {
        if (DEBUG) {
            Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                    + " committed after " + (System.currentTimeMillis() - startTime)
                    + " ms");
        }
    }
    notifyListeners(mcr);
    // 4.锁被释放后,返回写操作的执行结果
    return mcr.writeToDiskResult;
}

commit() 先将修改更新至内存 mMap,再将修改同步写入磁盘xml,它利用CountDownLatch保证等待写盘完成后返回执行结果。

public void apply() {
    final long startTime = System.currentTimeMillis();
    // 1.提交到内存
    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() {
            public void run() {
                try {
                    mcr.writtenToDiskLatch.await();
                } catch (InterruptedException ignored) {
                }

                if (DEBUG && mcr.wasWritten) {
                    Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                            + " applied after " + (System.currentTimeMillis() - startTime)
                            + " ms");
                }
            }
        };
    // 2. 向QueuedWork中添加等待任务,确保即使Activity将要stop时仍要等待apply写盘操作执行完成
    // 详见ActivityThread#handleStopActivity()中调用的QueuedWork.waitToFinish()
    QueuedWork.addFinisher(awaitCommit);

    Runnable postWriteRunnable = new Runnable() {
            public void run() {
                // 4. 写盘操作执行完成后,执行等待任务,并将其从QueuedWork中移出
                awaitCommit.run();
                QueuedWork.removeFinisher(awaitCommit);
            }
        };
    // 3.写盘操作,完成后由mcr释放锁,注意第二个参数不为null,表示异步写盘
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);

    // Okay to notify the listeners before it's hit disk
    // because the listeners should always get the same
    // SharedPreferences instance back, which has the
    // changes reflected in memory.
    notifyListeners(mcr);
}

apply()则是先将修改更新至内存 mMap,再将修改异步写入磁盘xml,它并不关心写盘操作成功与否。

无论是commit()还是apply()都会先执行commitToMemory(),它的作用就是将 mModified 和 mMap 的值进行比较,从而更新 mMap 中的值,逻辑比较简单,这里就不详细分析了。唯一需要注意的是,commitToMemory() 的返回值 mcr 中包含了一个 mapToWriteToDisk,它指向了更新后的 mMap,目的是为后边的写盘操作enqueueDiskWrite()做准备。

/**
 * Enqueue an already-committed-to-memory result to be written
 * to disk.
 *
 * They will be written to disk one-at-a-time in the order
 * that they're enqueued.
 *
 * @param postWriteRunnable if non-null, we're being called
 *   from apply() and this is the runnable to run after
 *   the write proceeds.  if null (from a regular commit()),
 *   then we're allowed to do this disk write on the main
 *   thread (which in addition to reducing allocations and
 *   creating a background thread, this has the advantage that
 *   we catch them in userdebug StrictMode reports to convert
 *   them where possible to apply() ...)
 */
private void enqueueDiskWrite(final MemoryCommitResult mcr,
                              final Runnable postWriteRunnable) {
    final boolean isFromSyncCommit = (postWriteRunnable == null);

    final Runnable writeToDiskRunnable = new Runnable() {
            public void run() {
                synchronized (mWritingToDiskLock) {
                    writeToFile(mcr, isFromSyncCommit);
                }
                synchronized (mLock) {
                    mDiskWritesInFlight--;
                }
                if (postWriteRunnable != null) {
                    postWriteRunnable.run();
                }
            }
        };

    // Typical #commit() path with fewer allocations, doing a write on
    // the current thread.
    if (isFromSyncCommit) {
        boolean wasEmpty = false;
        synchronized (mLock) {
            wasEmpty = mDiskWritesInFlight == 1;
        }
        if (wasEmpty) { // 如果没有其他线程在写盘,直接在当前线程执行
            writeToDiskRunnable.run();
            return;
        }
    }
    // 异步线程写盘
    QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

从代码可以看出,commit()和apply()的主要区别就是在调用 enqueueDiskWrite() 进行写盘操作时传入的 postWriteRunnable 是否为 null.

如果是commit()且没有其他线程正在写盘,就会在当前线程上直接执行writeToDiskRunnable.run(),否则会将 writeToDiskRunnable 放入一个单线程队列中等待调度。

writeToDiskRunnable 的主要工作就是执行writeToFile()

// Note: must hold mWritingToDiskLock
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
    long startTime = 0;
    long existsTime = 0;
    long backupExistsTime = 0;
    long outputStreamCreateTime = 0;
    long writeTime = 0;
    long fsyncTime = 0;
    long setPermTime = 0;
    long fstatTime = 0;
    long deleteTime = 0;

    if (DEBUG) {
        startTime = System.currentTimeMillis();
    }

    boolean fileExists = mFile.exists();

    if (DEBUG) {
        existsTime = System.currentTimeMillis();

        // Might not be set, hence init them to a default value
        backupExistsTime = existsTime;
    }

    // Rename the current file so it may be used as a backup during the next read
    if (fileExists) {
        boolean needsWrite = false;

        // Only need to write if the disk state is older than this commit
        if (mDiskStateGeneration < mcr.memoryStateGeneration) {
            if (isFromSyncCommit) {
                needsWrite = true;
            } else {
                synchronized (mLock) {
                    // No need to persist intermediate states. Just wait for the latest state to
                    // be persisted.
                    if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
                        needsWrite = true;
                    }
                }
            }
        }

        if (!needsWrite) {
            mcr.setDiskWriteResult(false, true);
            return;
        }

        boolean backupFileExists = mBackupFile.exists();

        if (DEBUG) {
            backupExistsTime = System.currentTimeMillis();
        }

        if (!backupFileExists) {
            if (!mFile.renameTo(mBackupFile)) {
                Log.e(TAG, "Couldn't rename file " + mFile
                      + " to backup file " + mBackupFile);
                mcr.setDiskWriteResult(false, false);
                return;
            }
        } else {
            mFile.delete();
        }
    }

    // Attempt to write the file, delete the backup and return true as atomically as
    // possible.  If any exception occurs, delete the new file; next time we will restore
    // from the backup.
    try {
        FileOutputStream str = createFileOutputStream(mFile);

        if (DEBUG) {
            outputStreamCreateTime = System.currentTimeMillis();
        }

        if (str == null) {
            mcr.setDiskWriteResult(false, false);
            return;
        }
        // 全量写入xml文件
        XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);

        writeTime = System.currentTimeMillis();

        FileUtils.sync(str);

        fsyncTime = System.currentTimeMillis();

        str.close();
        ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);

        if (DEBUG) {
            setPermTime = System.currentTimeMillis();
        }

        try {
            final StructStat stat = Os.stat(mFile.getPath());
            synchronized (mLock) {
                mStatTimestamp = stat.st_mtim;
                mStatSize = stat.st_size;
            }
        } catch (ErrnoException e) {
            // Do nothing
        }

        if (DEBUG) {
            fstatTime = System.currentTimeMillis();
        }

        // Writing was successful, delete the backup file if there is one.
        mBackupFile.delete();

        if (DEBUG) {
            deleteTime = System.currentTimeMillis();
        }

        mDiskStateGeneration = mcr.memoryStateGeneration;
        // 操作成功
        mcr.setDiskWriteResult(true, true);

        if (DEBUG) {
            Log.d(TAG, "write: " + (existsTime - startTime) + "/"
                    + (backupExistsTime - startTime) + "/"
                    + (outputStreamCreateTime - startTime) + "/"
                    + (writeTime - startTime) + "/"
                    + (fsyncTime - startTime) + "/"
                    + (setPermTime - startTime) + "/"
                    + (fstatTime - startTime) + "/"
                    + (deleteTime - startTime));
        }

        long fsyncDuration = fsyncTime - writeTime;
        mSyncTimes.add((int) fsyncDuration);
        mNumSync++;

        if (DEBUG || mNumSync % 1024 == 0 || fsyncDuration > MAX_FSYNC_DURATION_MILLIS) {
            mSyncTimes.log(TAG, "Time required to fsync " + mFile + ": ");
        }

        return;
    } catch (XmlPullParserException e) {
        Log.w(TAG, "writeToFile: Got exception:", e);
    } catch (IOException e) {
        Log.w(TAG, "writeToFile: Got exception:", e);
    }

    // Clean up an unsuccessfully written file
    if (mFile.exists()) {
        if (!mFile.delete()) {
            Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
        }
    }
    mcr.setDiskWriteResult(false, false);
}

阅读上面代码可知,writeToFile() 通过XmlUtils.writeMapXml()将前面 commitToMemory() 返回的mapToWriteToDisk(即mMap)全量写入xml文件,即每次都是建立一个空文件,然后将所有数据一次性写入,而不是增量写。因此,数据越大耗时就越长,就越可能产生ANR。

写盘操作最终会通过mcr.setDiskWriteResult()释放锁,对于apply()还会回调postWriteRunnable的run()方法去执行等待任务awaitCommit,并将它从QueuedWork中移除。

通过上面的分析可知,尽管apply()是异步操作,它还是可能会阻塞UI线程导致ANR,因为系统要确保在Activity退出时数据可以正常保存,这也是SharedPreferences的一个缺陷。

参考资料:
《Android工程化最佳实践》第4章 SharedPreferences的再封装
https://juejin.cn/post/6881442312560803853

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

推荐阅读更多精彩内容