一日一学_okhttp(本地缓存)

在学习okhttp缓存策略之前,我先思考了web前端浏览器缓存的策略。
浏览器缓存(客户端缓存),它分为强缓存和协商缓存

  • 强缓存
    浏览器在加载资源时,先根据资源http header判断它是否命中强 缓存,强缓存如果命中,浏览器直接从自己的缓存中读取资源,不会发请求 到服务器。
  • 协商缓存
    当强缓存没有命中缓存时,浏览器一定会发送一个请求到服务器,通过服务器端依据资源的另外的http header验证这个资源是否命中协商缓存,如果协商缓存命中,服务器会将这个请求返回,但是不会返回这个资源的数据,而是告诉客户端可以直接从缓存中加载这个资源,于是浏览器就又会从自己的缓存中去加载这个资源;

强缓存

强缓存是利用Expires或者Cache-Control这两个http response header实现的,它们都用来表示资源在客户端缓存的有效期。

  • Expires
    Expires是http1.0提出的一个表示资源过期时间的header,它描述的是一个绝对时间,由服务器返回,用GMT格式的字符串表示,如:Expires:Thu, 31 Dec 2037 23:55:55 GMT,它的缓存原理是:
    1 .浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在respone的header加上Expires的header
  1. 浏览器在接收到这个资源后,会把这个资源连同所有response header一起缓存下来
  2. 浏览器再次请求这个资源时,先从缓存中寻找,找到这个资源后,拿出它的Expires跟当前的请求时间比较,如果请求时间在Expires指定的时间之前,就能命中缓存,否则就不行。
  3. 如果缓存没有命中,浏览器直接从服务器加载资源时,Expires Header在重新加载的时候会被更新。

Expires是较老的强缓存管理header,由于它是服务器返回的一个绝对时间,在服务器时间与客户端时间相差较大时,缓存管理容易出现问题,比如随意修改下客户端时间,就能影响缓存命中的结果。所以在http1.1的时候,提出了一个新的header,就是Cache-Control,这是一个相对时间,在配置缓存的时候,以秒为单位,用数值表示,如:Cache-Control:max-age=315360000,它的缓存原理与Expires相似。

  • Cache-Control与Expires不同之处
    Cache-Control描述的是一个相对时间,在进行缓存命中的时候,都是利用客户端时间进行判断,所以相比较Expires,Cache-Control的缓存管理更有效,安全一些。这两个header可以只启用一个,也可以同时启用,当response header中,Expires和Cache-Control同时存在时,Cache-Control优先级高于Expires:

协商缓存

协商缓存跟强缓存不一样,强缓存不发请求到服务器,所以有时候资源更新了都在本地,但是协商缓存会发请求到服务器,所以资源是否更新,服务器肯定知道。大部分web服务器都默认开启协商缓存,而且是同时启用(Last-Modified,If-Modified-Since) 和 (ETag、If-None-Match)。

  • Last-Modified,If-Modified-Since
  1. 浏览器第一次跟服务器请求资源时,服务器在返回这个资源的同时,在respone的header加上Last-Modified的header,这个header表示这个资源在服务器上的最后修改时间。
  2. 浏览器再次跟服务器请求这个资源时,在request的header上加上If-Modified-Since的header,这个header的值就是上一次请求时返回的Last-Modified的值。
  3. 服务器再次收到资源请求时,根据浏览器传过来If-Modified-Since与服务器上的最后修改时间判断,如果没有变化则返回304 Not Modified(不会返回资源内容,header也不会改变);如果有变化,就正常返回资源内容。
  4. 浏览器收到304的响应后,就会从缓存中加载资源。(没有命中,浏览器直接从服务器加载资源,Header在重新加载更新)

(Last-Modified,If-Modified-Since)根据服务器时间返回的header,一般来说,在没有调整服务器时间和篡改客户端缓存的情况下,这两个header配合是非常可靠的,但是有时候也会服务器上资源其实有变化,但是最后修改时间却没有变化的情况,而这种问题又很不容易被定位出来,而当这种情况出现的时候,就会影响协商缓存的可靠性。所以就有了另外一对header来管理协商缓存,这对header就是(ETag、If-None-Match)。

  • ETag、If-None-Match
  1. 览器第一次跟服务器请求资源,服务器在返回这个资源的同时,在respone的header加上ETag的header,这个header是服务器根据当前请求的资源生成的一个唯一标识,这个唯一标识是一个字符串,只要资源有变化这个串就不同,跟最后修改时间没有关系.
  2. 浏览器再次跟服务器请求这个资源时,在request的header上加上If-None-Match的header,这个header的值就是上一次请求时返回的ETag的值.
  3. 服务器再次收到资源请求时,根据浏览器传过来If-None-Match和然后再根据资源生成一个新的ETag,如果这两个值相同就说明资源没有变化,否则就是有变化;如果没有变化则返回304 Not Modified,如果有变化,就正常返回资源内容。与Last-Modified不一样的是,当服务器返回304 Not Modified的响应时,由于ETag重新生成过,response header中还会把这个ETag返回,即使这个ETag跟之前的没有变化.
  4. 浏览器收到304的响应后,就会从缓存中加载资源。

上面为http简单缓存知识。接下来我们来查看okhttp缓存策略(和浏览器原理差不多,要不总结这么多白瞎了)。

okHttp源码分析

OkHttp中缓存策略与浏览器处理大同小异。
我们只看CacheStrategy的getCandidate()方法

private CacheStrategy getCandidate() {
  //如果缓存没有命中,就不需要加缓存Header了
  if (cacheResponse == null) {
    //没有缓存的网络请求,直接访问
    return new CacheStrategy(request, null);
  }

  // 如果缓存的TLS握手信息丢失,返回进行直接连接
  if (request.isHttps() && cacheResponse.handshake() == null) {
    //直接访问
    return new CacheStrategy(request, null);
  }

  //检测response的状态码,Expired时间,是否有no-cache标签
  if (!isCacheable(cacheResponse, request)) {
    //直接访问
    return new CacheStrategy(request, null);
  }

  CacheControl requestCaching = request.cacheControl();

  //有ETag/Since标签
  if (requestCaching.noCache() || hasConditions(request)) {
    //直接连接,把缓存判断交给服务器
    return new CacheStrategy(request, null);
  }
  //根据RFC协议计算
  long ageMillis = cacheResponseAge();
  //max-age
  long freshMillis = computeFreshnessLifetime();

  if (requestCaching.maxAgeSeconds() != -1) {
    //max-age
    freshMillis = Math.min(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds()));
  }

  long minFreshMillis = 0;
  if (requestCaching.minFreshSeconds() != -1) {
    //大部分情况下设置是0
    minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds());
  }

  long maxStaleMillis = 0;
  //ParseHeader中的缓存控制信息
  CacheControl responseCaching = cacheResponse.cacheControl();
  if (!responseCaching.mustRevalidate() && requestCaching.maxStaleSeconds() != -1) {
    //设置最大过期时间,一般设置为0
    maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds());
  }

  //缓存在过期时间内,可以使用
  if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
    //返回上次的缓存
    Response.Builder builder = cacheResponse.newBuilder();
    return new CacheStrategy(null, builder.build());
  }

  //缓存失效, 如果有etag等信息
  //发送请求,交给服务器处理
  Request.Builder conditionalRequestBuilder = request.newBuilder();

  if (etag != null) {
    conditionalRequestBuilder.header("If-None-Match", etag);
  } else if (lastModified != null) {
    conditionalRequestBuilder.header("If-Modified-Since", lastModifiedString);
  } else if (servedDate != null) {
    conditionalRequestBuilder.header("If-Modified-Since", servedDateString);
  }
  //网络请求
  Request conditionalRequest = conditionalRequestBuilder.build();
  return hasConditions(conditionalRequest) ? new CacheStrategy(conditionalRequest,
      cacheResponse) : new CacheStrategy(conditionalRequest, null);
}

okhttp源码可以看出缓存完全由服务器Header决定的,自己没有必要进行控制。用Interceptor中手工添加缓存代码控制,在实时换取更换数据的时候,会出现使用缓存数据的风险(自己项目出现过bug)。

嘿嘿嘿,前面都是铺垫和我遇到的坑,接下来才是本文正文:

使用Rxjava进行缓存

先对RxCache 对象进行数据的初始化

public final class RxCache {
 //缓存是基于LruCache,DiskLruCache上进行的,这一步是初始化这俩个
//主角,后面会进行讲解。
 private RxCache(int memoryMaxSize, int appVersion, long diskMaxSize, File diskDir, IDiskConverter diskConverter) {
        cacheCore = new CacheCore(new LruMemoryCache(memoryMaxSize), new LruDiskCache(diskConverter,diskDir,appVersion,diskMaxSize));
    }

 public static final class Builder {
       private static final int MAX_DISK_CACHE_SIZE = 50 * 1024 * 1024; // 50MB
        private static final int DEFAULT_MEMORY_CACHE_SIZE=(int) (Runtime.getRuntime().maxMemory()/8);//运行内存的8分之1
        private int memoryMaxSize;
        private int appVersion;
        private long diskMaxSize;
        private File diskDir;
        private IDiskConverter diskConverter;

        public Builder(){
        }

        /**
         * 不设置,默认为运行内存的8分之1
         */
        public Builder memorySize(int maxSize) {
            this.memoryMaxSize = maxSize;
            return this;
        }

        /**
         * 不设置,默认为1
         */
        public Builder appVersion(int appVersion) {
            this.appVersion = appVersion;
            return this;
        }

        public Builder diskDir(File directory) {
            this.diskDir = directory;
            return this;
        }


        public Builder diskConverter(IDiskConverter converter) {
            this.diskConverter = converter;
            return this;
        }

        /**
         * 不设置, 默为认50MB
         */
        public Builder diskSize(long maxSize) {
            this.diskMaxSize = maxSize;
            return this;
        }
        public RxCache build() {
            if(this.diskDir==null){
                throw new NullPointerException("DiskDir can not be null");
            }
            if (!this.diskDir.exists()) {
               this.diskDir.mkdirs();
            }
            if(this.diskConverter==null){
                this.diskConverter=new DiskConverter();
            }
            if(memoryMaxSize<=0){
                memoryMaxSize= DEFAULT_MEMORY_CACHE_SIZE;
            }
            if(diskMaxSize<=0){
                diskMaxSize=MAX_DISK_CACHE_SIZE;

            }
            appVersion= Math.max(1,this.appVersion);
            //初始化
            return  new RxCache(memoryMaxSize,appVersion,diskMaxSize,diskDir,diskConverter);
        }

       
    }
}

也可以自己进行配置

  //前面我讲过Builder模式,这里就可以体会到了
  public static RxCache getRxCache(Context context) {

        RxCache rxCache = new RxCache.Builder()
                .diskDir(new File(context.getCacheDir().getPath() + File.separator + "data"))
                .diskConverter(new DiskConverter())
                .memorySize(2*1024*1024)
                .build();
       return rxCache;
    }

DiskConverter这个转换类,为了以后扩展你可以存到本地或者数据库。

public class DiskConverter implements IDiskConverter {

    @Override
    public Object load(InputStream source) {
        Object value = null;
        ObjectInputStream oin = null;
        try {
            oin = new ObjectInputStream(source);
            value = oin.readObject();
        } catch (IOException | ClassNotFoundException e) {
        } finally {
            close(oin);
        }
        return value;
    }

    @Override
    public boolean writer(OutputStream sink, Object data) {
        ObjectOutputStream oos = null;
        try {
            oos = new ObjectOutputStream(sink);
            oos.writeObject(data);
            oos.flush();
            return true;
        } catch (IOException e) {
            return false;
        } finally {
            close(oos);
        }
    }

    public  void close(Closeable close) {
        if (close != null) {
            try {
                closeThrowException(close);
            } catch (IOException ignored) {
            }
        }
    }

    public void closeThrowException(Closeable close) throws IOException {
        if (close != null) {
            close.close();
        }
    }
}


加载网络网址事例:

  RxNetwork.getInstance()
                .createApi(Api.class, false)
                .getAd(1, 2)
                 //主要这一部分,缓存的核心一步
                .compose(rxCache.<AdBean>transformer("cache", CacheProviders.cache))
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new RxSubscriber<AdBean>() {
                    @Override
                    public void onSuccess(AdBean adBeanResult) {
                        Log.i("MainActivity", adBeanResult.getData().getAdList().get(0).getPicUrl());
                    }

                    @Override
                    public void onFailed(Throwable e) {

                    }
                });

讲之前我们重新认识Rxjava的Transformer这个老朋友。

Transformer代码是Func1<Observable<T>, Observable<R>>,换言之就是:可以通过它将一种类型的Observable转换成另一种类型的Observable。呸,这是什么鬼,每篇博客都这么写,demo还不给,逗我玩。
我给大家留下demo,自己琢磨去吧

//运行....
main(){

Observable.just("1","2").compose(RxDemoTransformer.<String>transformerTest()).subscribe(new Subscriber<Integer>() {
            @Override
            public void onCompleted() {

            }

            @Override
            public void onError(Throwable e) {

            }

            @Override
            public void onNext(Integer integer) {
                Log.i("MainActivity",integer.intValue()+"");
            }
        });

}
-------------------------------
public class RxDemoTransformer {
  public  static <String>Observable.Transformer<String,Integer> transformerTest(){
      return new Observable.Transformer<String,Integer>(){

          @Override
          public Observable<Integer> call(Observable<String> stringObservable) {
              return stringObservable.map(new Func1<String, Integer>() {
                  @Override
                  public Integer call(String string) {
                      return Integer.decode((java.lang.String) string);
                  }
              });
          }
      };
  }
}

运行结果了,本人懒自己运行去吧。

  //通过key进行数据的内存缓存和本地缓存,CacheProviders 是控制网络和本地的终于类
  public <T> Observable.Transformer<T, Result<T>> transformer(final 
                      String key, final CacheProviders providers) {
        return new Observable.Transformer<T, Result<T>>() {
            @Override
            public Observable<Result<T>> call(Observable<T> tObservable) {
                return providers.execute(RxCache.this,key,tObservable);
            }
        };
    }

来看看CacheProviders这个如何进行网络,缓存切换的(还存在bug,大家慢慢找,呵呵。。。)

public final class CacheProviders {
   
    public static final CacheProviders cache=new CacheProviders();

    public <T> Observable<Result<T>> execute(RxCache rxCache, String key, Observable<T> source) {
        Observable<Result<T>> cache = loadCache(rxCache,key);
        Observable<Result<T>> remote = loadRemote(rxCache,key, source, CacheType.MemoryAndDisk)
                .onErrorReturn(new Func1<Throwable, Result<T>>() {
                    @Override
                    public Result<T> call(Throwable throwable) {
                        return null;
                    }
                });
        return Observable.concat(remote, cache)
                .firstOrDefault(null, new Func1<Result<T>, Boolean>() {
                    @Override
                    public Boolean call(Result<T> tResultData) {
                        return tResultData != null && tResultData.data != null;
                    }
                });

    }

   //加载缓存数据
    <T> Observable<Result<T>> loadCache(final RxCache rxCache, final String key) {
        return rxCache
                .<T>load(key)
                .map(new Func1<T, Result<T>>() {
                    @Override
                    public Result<T> call(T o) {

                        return new Result<>(ResultFrom.Cache, key,  o);
                    }
                });
    }
        //加载网络数据
     <T> Observable<Result<T>> loadRemote(final RxCache rxCache, final String key, Observable<T> source, final CacheType target) {
        return source
                .map(new Func1<T, Result<T>>() {
                    @Override
                    public Result<T> call(T t) {
                        //保存网络数据
                        rxCache.save(key, t,target).subscribeOn(Schedulers.io())
                                .subscribe(new Action1<Boolean>() {
                                    @Override
                                    public void call(Boolean status) {

                                    }
                                });
                        return new Result<>(ResultFrom.Remote, key, t);
                    }
                });
    }


}

OnErrorReturn是什么鬼。
OnErrorReturn-当发生错误的时候,让Observable发射一个预先定义好的数据并正常地终止


onErrorReturn

举上面的例子,当loadRemote没有网络就会报错,立马会执行onErrorReturn返回一个null。

concat()操作符持有多个Observable对象,并将它们按顺序串联成队列。
firstOrDefault() 阻塞直到Observable发射了一个数据或者终止,返回第一项数据,或者返回默认值.又是一些概念,让老夫撸一串代码,就明白了。


        Observable<String>  oba =Observable.just("1");
        Observable<String>  obb =Observable.just("2");

        Observable.concat(oba, obb).firstOrDefault(null, new Func1<String, Boolean>() {
            @Override
            public Boolean call(String s) {
                if (s.equals("2")){
                    return true;
                }
                return false;
            }
        }).subscribe(new Subscriber<String>() {
            @Override
            public void onCompleted() {

            }

            @Override
            public void onError(Throwable e) {

            }

            @Override
            public void onNext(String s) {
                Log.i("MainActivity","MainActivity --->"+s);
            }
        });

运行结果

MainActivity: MainActivity --->2

通过demo是不是我上面的缓存代码明白是什么策略了。

//缓存核心,LruMemoryCache 对lruCache封装,进行保存与读取
//LruDiskCache DiskLruCache进行了封装,进行保存与读取
class CacheCore {

    private LruMemoryCache memory;
    private LruDiskCache disk;

    CacheCore(LruMemoryCache memory, LruDiskCache disk) {
        this.memory = memory;
        this.disk = disk;
    }


    /**
     * 读取
     */
    <T> T load(String key) {
        if (memory != null) {
            T result = memory.load(key);
            if (result != null) {
                return result;
            }
        }

        if (disk != null) {
            T result = disk.load(key);
            if (result != null) {
                return result;
            }
        }

        return null;
    }

    /**
     * 保存
     */
    <T> boolean save(String key, T value, CacheType target) {
        if (value == null) { //如果要保存的值为空,则删除
            return memory.remove(key) && disk.remove(key);
        }

        if (target.supportMemory() && memory != null) {
            memory.save(key, value);
        }
        if (target.supportDisk() && disk != null) {
            return disk.save(key, value);
        }

        return false;
    }

}

LruMemoryCache 存储到内存中,下次加载页面更快加载,提高用户体验.

class LruMemoryCache {
    //lruCache算法是最近最少使用算法(LinkedHashMap封装)。
    //我会新写一篇解释lruCache的实现
    private LruCache<String, Serializable> mCache;
    private final HashSet<String> mKeySet;

    public LruMemoryCache(final int cacheSize) {
        mKeySet = new HashSet<>();
        mCache = new LruCache<String, Serializable>(cacheSize) {
            @Override
            protected int sizeOf(String key, Serializable value) {
               return  calcSize(value);
            }
        };
    }
   //现在明白,当获取数据的时候,数据会排到LinkedHashMap队尾就可以
    public <T> T load(String key) {
         return (T) mCache.get(key);
    }
    
    public <T> boolean save(String key, T value) {
        if (null != value) {
            mCache.put(key, (Serializable) value);
            mKeySet.add(key);
        }
        return true;
}
 ..........
}

LruDiskCache存储到本地核心类(DiskLruCache 我会新写一篇进行讲解)

class LruDiskCache {
    private IDiskConverter mDiskConverter;
    private DiskLruCache mDiskLruCache;


     LruDiskCache(IDiskConverter diskConverter, File diskDir, int appVersion, long diskMaxSize) {
        this.mDiskConverter = diskConverter;
        try {
            mDiskLruCache = DiskLruCache.open(diskDir, appVersion, 1, diskMaxSize);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

     <T> T load(String key) {
        if (mDiskLruCache == null) {
            return null;
        }
        try {
            DiskLruCache.Editor edit = mDiskLruCache.edit(key);
            if (edit == null) {
                return null;
            }
            InputStream source = edit.newInputStream(0);
            T value ;
            if (source != null) {
                value = (T) mDiskConverter.load(source);
                close(source);
                edit.commit();
                return value;
            }
            edit.abort();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

     <T> boolean save(String key, T value) {
        if (mDiskLruCache == null) {
            return false;
        }
        //如果要保存的值为空,则删除
        if (value == null) {
            return remove(key);
        }
        try {
            DiskLruCache.Editor edit = mDiskLruCache.edit(key);
            if (edit == null) {
                return false;
            }
            OutputStream sink = edit.newOutputStream(0);
            if (sink != null) {
                mDiskConverter.writer(sink, value);
                close(sink);
                edit.commit();
                return true;
            }
            edit.abort();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return false;
    }
    ....................
}

项目地址:https://github.com/quiet-wuxiao/RxHttp

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

推荐阅读更多精彩内容

  • 转载:浏览器缓存知识小结及应用 阅读目录 1. 浏览器缓存基本认识 2. 强缓存的原理 3. 强缓存的管理 4. ...
    meng_philip123阅读 1,087评论 4 18
  • 浏览器缓存,也就是客户端缓存,既是网页性能优化里面静态资源相关优化的一大利器,也是无数web开发人员在工作过程不可...
    单纯的土豆阅读 405评论 0 1
  • 浏览器缓存,也就是客户端缓存,既是网页性能优化里面静态资源相关优化的一大利器,也是无数web开发人员在工作过程不可...
    Www刘阅读 566评论 0 1
  • API定义规范 本规范设计基于如下使用场景: 请求频率不是非常高:如果产品的使用周期内请求频率非常高,建议使用双通...
    有涯逐无涯阅读 2,521评论 0 6
  • 把人店里衣服试个遍,都相不中。总结了一下,只怪自己太胖了。
    当尽欢阅读 195评论 0 0