WKWebView 缓存策略

问题描述

今天遇到一个问题,网页加载的页面,服务器数据提交更新了,但是 iOS 这边显示的还是老的内容。页面使用的 WKWebView 来加载数据的。第一时间我想到的是 WebView 的缓存,那既然安卓端能刷新,应该是 iOS 这边出现了问题,是什么原因导致的呢?首先去苹果官方文档查看 WKWebView 缓存策略描述。

WKWebView 的默认缓存策略

先来看下苹果给的枚举文档

WKWebView 支持的缓存策略枚举

typedef NS_ENUM(NSUInteger, NSURLRequestCachePolicy)
{
    NSURLRequestUseProtocolCachePolicy = 0, // 默认策略,具体的缓存逻辑和协议的声明有关,如果协议没有声明,不需要每次重新验证cache
    NSURLRequestReloadIgnoringLocalCacheData = 1, // 忽略本地缓存,直接从服务器请求数据
    NSURLRequestReloadIgnoringLocalAndRemoteCacheData = 4, // iOS 13 才实现,忽略本地缓存数据、代理和其他中介的缓存,直接从从服务器请求数据
    NSURLRequestReloadIgnoringCacheData = NSURLRequestReloadIgnoringLocalCacheData,
    NSURLRequestReturnCacheDataElseLoad = 2, // 优先从本地拿数据,且忽略请求生命时长和过期时间。但是如果没有本地cache,则从服务器请求数据
    NSURLRequestReturnCacheDataDontLoad = 3, // 只从本地拿数据
    NSURLRequestReloadRevalidatingCacheData = 5, //  iOS 13才实现,从原始地址确认缓存数据的合法性后,缓存数据就可以使用,否则从服务器请求数据
};

这里需要注意一下 NSURLRequestReloadIgnoringLocalAndRemoteCacheData = 4NSURLRequestReloadRevalidatingCacheData = 5iOS 13 之前并未实现该协议,所以如果使用该协议一定要注意系统版本判断。

NSURLRequestUseProtocolCachePolicy

下面是官方文档提供的默认缓存策略的流程图

苹果官方文档

官方文档描述

For the HTTP and HTTPS protocols, NSURLRequestUseProtocolCachePolicy >performs the following behavior:

  1. If a cached response does not exist for the request, the URL loading system fetches the data from the originating source.

  2. Otherwise, if the cached response does not indicate that it must be revalidated every time, and if the cached response is not stale (past its expiration date), the URL loading system returns the cached response.

  3. If the cached response is stale or requires revalidation, the URL loading system makes a HEAD request to the originating source to see if the resource has changed. If so, the URL loading system fetches the data from the originating source. Otherwise, it returns the cached response.

This behavior is illustrated in Figure 1.
Figure 1 NSURLRequestUseProtocolCachePolicy decision tree for HTTP and >HTTPS

中文翻译

  1. 缓存不存在,则直接从服务器请求数据;
  2. 缓存存在,在 response 头信息中没有指定每次必须进行资源更新,且缓存没有过期,则系统不会从服务器请求拉取数据,而是直接走缓存;
  3. 如果缓存过期了或者要求每次必须校验资源更新,此时会发起一个资源校验更新的请求,如果服务器有更新,则使用服务器返回的新数据,如果没有资源更新则还是使用本地缓存数据

对于上面官方文档的解释只是理解了大概的原理,具体的细节和数据指标还是不清楚,例如:

  1. 什么情况下会缓存数据?
  2. 什么情况下每次都需要校验资源更新?
  3. 缓存过期时间多久?
  4. 校验资源更新的过程是怎么样的?

对于这些,需要了解 HTTP 的缓存协议,实际上,苹果的 WKWebView 默认的缓存策略完全遵守 HTTP 缓存协议,没有做额外的事情。就是说如果你想详细知道 WKWebView 默认的缓存策略,那么你得先去弄清楚 HTTP 的缓存协议。

HTTP 缓存

客户端 默认缓存行为实际上是由 服务器 控制的,客户端服务器 通过 HTTP 请求头响应头 中的缓存字段来交流,进而影响 客户端 的行为。

Pragma、Expires

HTTP 1.0 时代,给客户端设定缓存方式可通过这两个字段。Pragma 是一个通用头,它只有 no-cache 这一个值。

通用头:该字段可以用于请求头,也可用于响应头。(同一个属性在请求头和响应头中意义可能不一样)

作为请求头,表示不使用缓存,直接从服务器获取资源,这是 HTTP 1.0的用法,HTTP1.1 的用法是 Cache-Control:no-cache。不过为了兼容 HTTP1.0 ,一般 Pragma:no-cacheCache-Control:no-cache 联用:

Cache-Control:no-cache
Pragme:no-cache

作为响应头,Pragma : no-cache 的行为并没有被定义,不能保证它的意义和 Cache-Control:no-cache 一致。

Expires(响应头)表示缓存过期的时间点(这里的时间是服务器时间):

Expires: Wed, 14 Apr 2021 12:01:33 GMT

PragmaExpires 的局限:响应报文中 Expires 所定义的缓存时间是相对于服务器上的时间而言的,如果客户端上用户修改了自己设备上的系统时间,那缓存时间能就没有意义了。

Cache-Control

HTTP 1.1 新增了 Cache-Control 来配置缓存信息,主要包括:能否缓存、缓存过期时间、是否每次校验等。

Cache-Control 是通用头:

作为请求头,可选值有

字段名称 说明
no-cache 告知(代理)服务器不直接使用缓存,直接向原服务器发起请求
no-store 所有内容都不会保存到缓存或 internet 临时文件中
max-age=delta-seconds 告知服务器客户端希望接收一个存在时间(Age)不大于 delta-seconds 秒的资源
max-stale[=delta-seconds] 告知(代理)服务器客户端愿意接收一个超过缓存时间的资源,若有定义 delta-seconds 则为 delta-seconds 秒,若没有则为任意超出时间
min-fresh=delta-seconds 告知(代理)服务器客户端希望接收一个在小于 delta-seconds 秒内被更新过的资源
no-transform 告知(代理)服务器客户端希望获取实体数据没有被转换(如压缩)过的资源
only-if-cached 告知(代理)服务器客户端希望获取缓存的内容(若有),而不用向原服务器发去请求
cache-extension 自定义扩展值,若服务器不识别该值将被忽略

作为响应头,可选值有

字段名称 说明
public 任何情况下都得缓存该资源
Private[=“field-name”] 返回报文中全部或部分(若指定了 field-name 则为 field-name 的字段数据)仅开放给某些用户(服务器指定的 share-user,如代理服务器)做缓存使用,其他用户不能缓存这些数据
no-cache 不直接使用缓存,要求向服务器发起(新鲜度校验)请求
no-store 所有内容都不会被保存到缓存或 internet 临时文件中
no-transform 告知服务器缓存文件时不得对实体数据做任何改变
only-if-cached 告知(代理)服务器客户端希望获取缓存的内容(若有),而不用向原服务器发去请求
must-revalidate 当前资源一定是向原服务器发去验证请求的,若请求失败会返回 504(而非代理服务器上的缓存)
proxy-revalidate 与 must-revalidate 类似,但仅能应用于共享缓存(如代理)
max-age=delta-seconds 告知客户端该资源在 delta-seconds 秒内是新鲜的,无需向服务器发请求
$-maxage=delta-seconds 同 max-age,但仅应用于共享缓存(如代理)
cache-extension 自定义扩展值,若服务器不识别该值将被忽略掉
max-age=delta-seconds 告知服务器客户端希望接收一个存在时间(age)不大于 delta-seconds 秒的资源
max-stale[=delta-seconds] 告知服务器客户端愿意接收一个超过缓存时间的资源,若有定义 delta-seconds 则为 delta-seconds 秒,若没有则为任意超出时间
min-fresh=delta-seconds 告知服务器客户端希望接收一个在小于 delta-seconds 秒内被更新过的资源
no-transform 告知服务器客户端希望获取实体数据没有被转换(如压缩)过的资源
only-if-cached 告知服务器客户端希望获取缓存的内容(若有),而不用向服务器发去请求
cache-extension 自定义扩展值,若服务器不识别该值将被忽略

Cache-Control 允许自由组合可选值,用逗号分隔:

// 缓存过期时间是半小时,半小时后,每次都必须向服务器进行资源更新校验
Cache-Control: max-age=1800, no-cache
must-revalidate

坑点:
在苹果官方文档和流程图中有个判断,缓存存在,则需要判断是否需要每次都校验,用的是 >revalidated,那我们是不是可以这样写:Cache-Control: max-age=3600, must->revalidate,设置一个过期时间,但是又希望每次去检验更新。结果是客户端仍然是用的缓存,根本没有网络请求发出去。

HTTP 规范是不允许客户端使用过期缓存的,除了一些特殊情况,比如校验请求发送失败的时候。而 must-revalidate 指令是用来排除这些特殊情况的。带有 must-revalidate 的缓存过期后,在任何情况下,都必须成功 revalidate 后才能使用,没有例外,即使校验请求发送失败也不可以使用过期的缓存。也就是说,有个大前提是缓存过期了,如果缓存没过期客户端会直接使用缓存,并不会发起校验,显然不是字面上每次都校验更新的意思。must-revalidate 命名为 never-return-stale 更合理。而真正每次都校验更新,应该用 no-cache 这个字段。

所以上面错误的写法改成 Cache-Control: max-age=1800, no-cache 这样就可以了,缓存有效期半小时,每次请求都校验更新。

no-cache

作为请求头,告知中间服务器不使用缓存,向源服务器发起请求。
作为响应头,no-cache 并不是字面上的不缓存,而是每次使用前都得先校验一下资源更新。

no-store

作为响应头,带有 no-store 的响应不会被缓存到任意的磁盘或者内存里,no-store 它才是真正的 no-cache

max-age

作为请求头,max-age=0 表示不管 response 怎么设置,在重新获取资源之前,先进行资源更新校验。
作为响应头,max-age=x 表示,缓存有效期是 x 秒。

Cache-Control 的局限

很多时候,缓存过期了但是资源并没有修改,会发送多余的请求和数据;或者资源修改了缓存还没过期,客户端仍然在用缓存。Cache-Control 无法及时和客户端同步。

Last-Modified、If-Modified-Since

为了弥补 Cache-Control 无法及时判断资源是否有更新的不足,有了 Last-Modifiedif-Modified-Since 字段。

Last-Modified

响应头,这次命名没有问题了,这个字段的值就是资源在服务器上最后修改时刻。如:

If-Modified-Since: Wed, 14 Apr 2021 14:01:33 GMT
If-Modified-Since

请求头,客户端通过该字段把 Last-Modified 的值回传给服务端;客户端带上这个字段表示这次请求是向服务端做校验资源更新校验。如果资源没有修改,则服务端返回 304 不返回数据,客户端用缓存;资源有修改则返回 200 和数据。如:

If-Modified-Since: Wed, 14 Apr 2021 14:05:33 GMT
Last-Modified 的启发式(heuristic)缓存

有以下响应信息:

HTTP/2 200
Date: Wed, 14 Apr 2021 14:30:00 GMT
Last-Modified: Wed, 14 Apr 2021 14:10:00 GMT

上面这个响应,没有显示地指明需要缓存,没有 Cache-Control,也没有 Expires,只有 Last-Modified 修改时间,这种情况会产生启发式缓存。缓存时长= (date_value - last_modified_value) * 0.10 ,这是由 HTTP 规范推荐的算法,但规范中仅仅是推荐而已,并没有做强制要求。

如何禁用由 Last-Modified 响应头造成的启发式缓存:正确的做法是在响应头中加上 Cache-Control: no-cache

Last-Modified、If-Modified-Since 的缺陷

无法识别内容是否发生实质性的变化,可能只是修改了文件但是内容没有变化;无法识别一秒内进行多次修改的情况。

ETag、If-None-Match

为了弥补 Last-Modified 的无法判断内容实质性变化的缺陷,于是有了 ETagIf-None-Match 字段,这对字段的用法和 Last-ModifiedIf-Modified-Since 相似,服务器在响应头中返回 ETag 字段,客户端在下次请求时在 If-None-Match 中回传 ETag 对应的值。

ETag

响应头,给资源计算得出一个唯一标志符(比如 md5 标志),加在响应头里一起返给客户端,如:

Etag: "7d6e49djhkd84rh3"
If-None-Match

请求头,客户端在下次请求时回传 ETag 值给服务器。

If-None-Match: "7d6e49djhkd84rh3"

优先级

Pragma > Cache-Control > Expires > Last-Modified > ETag

响应头没有任何缓存字段,每次启动都会发起请求,返回 200

第一次启动,响应头添加 Pragma:no-cacheCache-Control:max-age;第二次启动,会发起请求,返回 304,说明 Pragma 生效了,Pragma > Cache-Control

第一次启动,响应头没有过期时间,只有 Last-Modified;第二次启动,使用缓存,没有发起请求,说明启发式缓存(上文中有提到)生效。

第一次启动,响应头没有过期时间,只有 ETag;第二次启动,会发起请求,返回 304,说明做了资源更新校验。

第一次启动,响应头没有过期时间,同时有 ETagLast-Modified;第二次启动,使用缓存,没有发起请求,启发式缓存生效,说明 Last-Modified > ETag

WKWebView 默认缓存策略

针对上文中提到的几个问题

1. 什么情况下会缓存数据?

客户端第一次启动的时候,如果 响应头 中不包含任何缓存控制字段(ExpiresCache-Control:max-ageLast-Modified 等),那么不会缓存(仍然可能会有物理缓存,只是不使用),下次直接发起请求。如果响应头包含了缓存控制字段,大多数情况下这次数据会被缓存,下次启动的时候执行缓存逻辑判断。

2. 什么情况下每次都需要校验资源更新?

  1. 响应头中包含 Cache-Control:no-cachePragma:no-cache
  2. 响应头中缓存控制字段只有 ETag 字段,没有过期时间和修改时间。

3. 缓存过期时间是多久?

  1. 响应头中 Cache-Control:max-age=1800,表示缓存半小时。
  2. 响应头中 Expires 的值表示过期时刻(服务器时间)。
  3. 响应头中,如果没有上述两个字段,但有 Last-Modified 字段,则触发启发式缓存,缓存时间 = (date_value - last_modified_value) * 0.1

4. 校验资源更新的过程是怎么样的?

revalidated 的指标有两个:Last-Modified(最后修改时刻)、ETag(资源唯一标识)。服务器返回数据时会在响应头中返回上面两个指标(有可能只有1个,也可以2个都有),客户端再次发起请求时会把这两个指标回传给服务器。If-Modified-SinceLast-Modified 的值),If-None-MatchETag 的值)。服务器进行比对,如果客户端的资源是最新的,则返回 304,客户端使用缓存数据;如果服务器资源更新了,则返回 200 和新数据。

总结

WKWebView 默认缓存策略流程总结如下:

  1. 是否有缓存,没有则直接发起请求。有则进行下一步。
  2. 是否每次都得进行资源更新校验(响应头是否有 Cache-Control:no-cachePragma:no-cache 字段),不需要则进入 3,需要则进入 4
  3. 缓存是否过期(响应头,Cache-Control:max-ageExpiresLast-Modified 启发式缓存),没过期则使用缓存,不发起请求。过期了则进入 4
  4. 客户端发起资源更新校验请求(请求头,If-Modified-Since : Last-Modified值If-None-Match : ETag值),如果资源没有更新,服务器返回 304,客户端使用缓存;如果资源有更新,服务器返回 200 和资源。

服务器数据更新后客户端仍然有缓存

如果我们使用了 WKWebView 默认缓存策略(即 NSURLRequestUseProtocolCachePolicy),这个时候 App 什么也做不了,需要后台去处理。

方案一:

添加响应头

如果使用了 Cache-Contol: max-age=xxx 而且没有配置资源更新校验字段。但是这个 xxx 设置的很大,缓存时间很长,即使服务器资源更新了客户端也不会请求新的资源,而是直接使用“没有过期”的资源。所以可以将这个字段设置的小一些外加配置资源更新校验字段。

我们也可以设置每次都会先去校验资源更新,就是默认不走缓存,这样也解决了服务器更新数据,客户端仍然不更新的问题。

Cache-Control:no-cache

方案二

这个方案没有亲自体验过,参考自WKWebView默认缓存策略与HTTP缓存协议

链接加后缀

<script src="test.js?ver=113"></script>
https://hm.baidu.com/hm.js?e23800c454aa573c0ccb16b52665ac26
http://tb1.bdstatic.com/tb/_/tbean_safe_ajax_94e7ca2.js
http://img1.gtimg.com/ninja/2/2016/04/ninja145972803357449.jpg

可以在资源文件后面加上版本号,每次更新资源的时候变更版本号;还可以在 URL 后面加上了 md5 参数,甚至还可以将 md5 值作为文件名的一部分。
采用上述方法,你可以把缓存时间设置的特别长,那么在文件没有变动的时候,浏览器直接使用缓存文件;而在文件有变化的时候,由于文件版本号的变更,或 md5 变化导致文件名变化,请求的 url 变了,浏览器会当做新的资源去处理,一定会发起请求,所以不存在更新后仍然有缓存的情况。通过这样的处理,增长了静态资源,特别是图片资源的缓存时间,避免该资源很快过期,客户端频繁向服务端发起资源请求,服务器再返回 304 响应的情况(有 Last-Modified/Etag)。

参考代码

NSURLRequest 的默认缓存策略是 NSURLRequestUseProtocolCachePolicy,完全遵循 HTTP 缓存协议。

我们来看以下代码

NSString *url = @"https://xxx";
NSURLRequest *request = [[NSURLRequest alloc] initWithURL:[NSURL URLWithString:url]];
[[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
    NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
    NSLog(@"httpResponse == %@", httpResponse);
}] resume];

运行项目,打印结果仅供参考

在这个链接的响应头 Cache-Control: max-age=30,过期时间为 30 秒,但是无论我运行多少次,code 始终返回 200(网段了还是返回数据,code 也是 200)。而且还发现,第一次请求时界面刷新耗费的时间较长,第二次直接显示界面了。这个也验证了默认缓存是有的,但是为什么 code 一直是 200 呢?

苹果系统内部对 304 Not Modified 响应做了特殊处理

  • code 字段,固定返回 200
  • data 字段,因为服务端返回的 304 报文是不带 data 数据字段的,但是苹果又得把 data 通过 completionHandler 回调给客户端,苹果会去缓存中取 data 数据,返回的 data 字段和第一次响应的 data 是同一个。
  • response 字段,返回的是第二次请求 304 的响应头,而不是第一次请求缓存的响应头。第一次和第二次回调的响应头不一致。

综上,苹果内部帮我们处理了 304 Not Modified 响应。对客户端来说,你只需要知道返回 200 就是没有异常,拿着 data 用就行了。至于,数据来自缓存还是来自服务器,缓存有没有过期,需不需要校验资源更新等,都交给苹果吧。

code 都返回 200,那我怎么知道返回的是缓存数据还是服务器数据呢?

我们可以通过以下测试代码来验证下(注意:这是系统自己实现的,并不需要客户端手动添加)

NSString *url = @"https://xxx";
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url] cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:20];
NSDictionary *cachedHeaders = [[NSUserDefaults standardUserDefaults] objectForKey:url];
//设置request headers (带上上次的请求头下面两参数一种就可以,也可以两个都带上)
if (cachedHeaders) {
    NSString *etag = [cachedHeaders objectForKey:@"Etag"];
    if (etag) {
        [request setValue:etag forHTTPHeaderField:@"If-None-Match"];
    }
    NSString *lastModified = [cachedHeaders objectForKey:@"Last-Modified"];
    if (lastModified) {
        [request setValue:lastModified forHTTPHeaderField:@"If-Modified-Since"];
    }
}

[self.couponWebView loadRequest:request];

[[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
    
    NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
    NSLog(@"httpResponse == %@", httpResponse);
    // 根据statusCode设置缓存策略
    if (httpResponse.statusCode == 304 || httpResponse.statusCode == 0) {
        [request setCachePolicy:NSURLRequestReturnCacheDataElseLoad];
    }
    else {
        [request setCachePolicy:NSURLRequestReloadIgnoringLocalCacheData];
        // 保存当前的 NSHTTPURLResponse
        [[NSUserDefaults standardUserDefaults] setObject:httpResponse.allHeaderFields forKey:url];
        [[NSUserDefaults standardUserDefaults] synchronize];
    }
    // 重新刷新
    dispatch_async(dispatch_get_main_queue(), ^{
        [self.couponWebView reload];
    });
    
}] resume];

第一次打印如下:

第二次打印如下:

总结

对于使用 WKWebView 加载的页面,使用默认的缓存策略,客户端不需要关心返回的数据是否来自缓存,只需要缓存字段配置好后即可。

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

推荐阅读更多精彩内容