目前图片框架,基本就是 Glide 一统江山了,除了极其简单的链式调用,里面丰富的 API 也让人爱不释手。
那么,这样一个好用的框架,里面的缓存机制是怎么样的呢?
我们知道,一般图片框架,加载图片,都是通过内存缓存 LruCache ,DiskLruCache 硬盘缓存中去拿,那 Glide 又是怎么样的呢?这里,我们一起来探讨一下;

这里的 Glide 版本为 4.9.0

Glide 的缓存可以分为两种,一种内存缓存,一种是硬盘缓存;其中内存缓存又包含 弱引用 和 LruCache ;而硬盘缓存就是 DiskLruCache
流程图,可以参考这个,再去跟比较好:

一. 内存缓存

首先,Glide 的图片加载在 Engine 中的 load 方法中,如下:

public synchronized <R> LoadStatus load(// 获取资源的 key ,参数有8个EngineKey key = keyFactory.buildKey(model, signature, width, height, transformations,resourceClass, transcodeClass, options);// 弱引用EngineResource<?> active = loadFromActiveResources(key, isMemoryCacheable);if (active != null) {cb.onResourceReady(active, DataSource.MEMORY_CACHE);if (VERBOSE_IS_LOGGABLE) {logWithTimeAndKey("Loaded resource from active resources", startTime, key);}return null;}// 通过 LruCacheEngineResource<?> cached = loadFromCache(key, isMemoryCacheable);if (cached != null) {cb.onResourceReady(cached, DataSource.MEMORY_CACHE);if (VERBOSE_IS_LOGGABLE) {logWithTimeAndKey("Loaded resource from cache", startTime, key);}return null;}// 如果都获取不到,则网络加载....

1.1 获取缓存key

可以看到,首先通过:

    EngineKey key = keyFactory.buildKey(model, signature, width, height, transformations,resourceClass, transcodeClass, options);

来获取 缓存的key,它的参数有8个,所以,当同一图片的,它的大小不一样时,也会生成一个新的缓存。

然后 Glide 默认是开启内存缓存的,如果你想关掉,可以使用:

//关闭内存缓存
skipMemoryCache(false)

1.2 弱引用

接着,会从弱引用中是否拿到资源,通过:

EngineResource<?> active = loadFromActiveResources(key, isMemoryCacheable);

里面的实现为:

  @Nullableprivate EngineResource<?> loadFromActiveResources(Key key, boolean isMemoryCacheable) {if (!isMemoryCacheable) {return null;}EngineResource<?> active = activeResources.get(key);//如果能拿到资源,则计数器 +1if (active != null) {active.acquire();}return active;}#ActiveResources#get()@Nullablesynchronized EngineResource<?> get(Key key) {ResourceWeakReference activeRef = activeEngineResources.get(key);if (activeRef == null) {return null;}EngineResource<?> active = activeRef.get();if (active == null) {cleanupActiveReference(activeRef);}return active;}#EngineResource#acquire()synchronized void acquire() {if (isRecycled) {throw new IllegalStateException("Cannot acquire a recycled resource");}++acquired;}

可以看到,activeEngineResources 为实现了弱引用的 hasmap,通过 key 拿到弱引用的对象,如果获取不到,则可能GC,对象被回收,则从 map 中移除;如果拿到对象,则引用计数 acquired +1, 计数器后面再讲;

1.3 LruCache

如果获取不到,则通过

// 从 lrucache 获取对象
EngineResource<?> cached = loadFromCache(key, isMemoryCacheable);#Engine#loadFromCache()private EngineResource<?> loadFromCache(Key key, boolean isMemoryCacheable) {if (!isMemoryCacheable) {return null;}EngineResource<?> cached = getEngineResourceFromCache(key);if (cached != null) {cached.acquire();activeResources.activate(key, cached);}return cached;}

我们看看 getEngineResourceFromCache 方法:


发现,它从 LruCache 那大对象,如果对象不为空,则通过 activeResources.activate(key, cached); 把它加入弱引用中,且从 LruCache 删除。且 调用 acquire() 让计数器 +1.

所以,我们知道了,Glide 的内存缓存的流程是这样的,先从弱引用中取对象,如果存在,引用计数+1,如果不存在,从 LruCache 取,如果存在,则引用计数+1,并把它存到弱引用中,且自身从 LruCache 移除。

上面,我们讲到的是取,那如果存呢?如果要对一个对象进行存储,那肯定在图片加载的时候去存。
回调 Engine 类的load 方法,其中通过加载的代码如下:

  public synchronized <R> LoadStatus load(..// 获取 keyEngineKey key = keyFactory.buildKey(model, signature, width, height, transformations,resourceClass, transcodeClass, options);// 从 弱引用中获取对象EngineResource<?> active = loadFromActiveResources(key, isMemoryCacheable);...// 从 LruCache 获取对象EngineResource<?> cached = loadFromCache(key, isMemoryCacheable);...EngineJob<R> engineJob =engineJobFactory.build(key,isMemoryCacheable,useUnlimitedSourceExecutorPool,useAnimationPool,onlyRetrieveFromCache);DecodeJob<R> decodeJob =decodeJobFactory.build(glideContext,model,key,signature,width,height,resourceClass,transcodeClass,priority,diskCacheStrategy,transformations,isTransformationRequired,isScaleOnlyOrNoTransform,onlyRetrieveFromCache,options,engineJob);jobs.put(key, engineJob);engineJob.addCallback(cb, callbackExecutor);engineJob.start(decodeJob);...

这里两个关键的对象,一个是 EngineJob ,它是一个线程池,维护着编码、资源解析、网络下载等工作;一个是 DecodeJob ,它继承 Runnable ,相当于于 EngineJob 的一个任务;

engineJob.start(decodeJob); 可以知道,调用的是 DecodeJob 里面的 run 方法,具体细节,等硬盘缓存的时候,我们再跟;最后会回调EngineJob 的 onResourceReady 方法:

  @Overridepublic void onResourceReady(Resource<R> resource, DataSource dataSource) {synchronized (this) {this.resource = resource;this.dataSource = dataSource;}notifyCallbacksOfResult();}#EngineJob #onResourceReady ()@Syntheticvoid notifyCallbacksOfResult() {ResourceCallbacksAndExecutors copy;Key localKey;EngineResource<?> localResource;synchronized (this) {stateVerifier.throwIfRecycled();//是否被取消if (isCancelled) {// TODO: Seems like we might as well put this in the memory cache instead of just recycling// it since we've gotten this far...resource.recycle();release();return;} else if (cbs.isEmpty()) {throw new IllegalStateException("Received a resource without any callbacks to notify");} else if (hasResource) {throw new IllegalStateException("Already have resource");}engineResource = engineResourceFactory.build(resource, isCacheable);// Hold on to resource for duration of our callbacks below so we don't recycle it in the// middle of notifying if it synchronously released by one of the callbacks. Acquire it under// a lock here so that any newly added callback that executes before the next locked section// below can't recycle the resource before we call the callbacks.hasResource = true;copy = cbs.copy();// 引用计数 +1incrementPendingCallbacks(copy.size() + 1);localKey = key;localResource = engineResource;}// 把对象put到弱引用上listener.onEngineJobComplete(this, localKey, localResource);// 遍历所有图片for (final ResourceCallbackAndExecutor entry : copy) {// 把资源加载到 imageview 中,引用计数 +1entry.executor.execute(new CallResourceReady(entry.cb));}//引用计数 -1decrementPendingCallbacks();}

从上面看,notifyCallbacksOfResult() 方法做了以下事情

  1. 图片的引用计数 +1
  2. 通过 listener.onEngineJobComplete() ,它的回调为 Engine#onEngineJobComplete(),把资源 put 到 弱引用上,实现如下:

  1. 遍历加载的图片,如果加载成功,则引用计数+1,且通过 cb.onResourceReady(engineResource, dataSource) 回调给 target (imageview) 去加载
  2. 通过 decrementPendingCallbacks() 释放资源,引用计数 -1
  synchronized void decrementPendingCallbacks() {....if (decremented == 0) {if (engineResource != null) {// 释放资源engineResource.release();}release();}}#ResourceEnginer#release()void release() {// To avoid deadlock, always acquire the listener lock before our lock so that the locking// scheme is consistent (Engine -> EngineResource). Violating this order leads to deadlock// (b/123646037).synchronized (listener) {synchronized (this) {if (acquired <= 0) {throw new IllegalStateException("Cannot release a recycled or not yet acquired resource");}if (--acquired == 0) {listener.onResourceReleased(key, this);}}}}

当引用计数减到 0 时,即图片已经没有使用时,就会调用 onResourceReleased() 接口,它的实现如下:

  @Overridepublic synchronized void onResourceReleased(Key cacheKey, EngineResource<?> resource) {// 从弱引用中移除activeResources.deactivate(cacheKey);if (resource.isCacheable()) {//添加到 LruCache 中cache.put(cacheKey, resource);} else {// 回收资源resourceRecycler.recycle(resource);}}

这样,我们就知道了真个流程了,这里,我们再对流程梳理一下:

首先,从弱引用去缓存,如果有,则引用计数+1,没有则从 LruCache 取,如果有,则引用计数+1,且该缓存从 LruCache移除,存到 弱引用中。反过来,当该资源不再被引用时,就会从弱引用移除,存存到 LruCache 中。

而这个 引用计数是啥呢?acquired 这个变量是用来记录图片被引用的次数的,当从 loadFromActiveResources()、loadFromCache()、incrementPendingCallbacks,CallResourceReady#run 获取图片时,都会调用 acquire() 方法,让 acquired +1,当暂停请求或加载完毕,或清除资源都会调用 release() 方法,让 acquired -1

可以看到这个图:

二. 硬盘缓存

上面已经解释了 Glide 如何从内存缓存中拿到图片,但如果还是拿不到图片,则此时 Glide 会从以下两个方法来检查:

  1. 资源类型(Resource) - 该图片是否之前曾被解码、转换并写入过磁盘缓存?
  2. 数据来源 (Data) - 构建这个图片的资源是否之前曾被写入过文件缓存?

即我们的硬盘缓存;Glide 的硬盘策略可以分为如下几种:

  • DiskCacheStrategy.RESOURCE :只缓存解码过的图片
  • DiskCacheStrategy.DATA :只缓存原始图片
  • DiskCacheStrategy.ALL : 即缓存原始图片,也缓存解码过的图片啊, 对于远程图片,缓存 DATA 和 RESOURCE;对本地使用 只缓存 RESOURCE。
  • DiskCacheStrategy.NONE :不使用硬盘缓存
  • DiskCacheStrategy.AUTOMATIC :默认策略,会对本地和和远程图片使用最佳的策略;对下载网络图片,使用 DATA,对于本地图片,使用 RESOURCE

**这里以下载一个远程图片为例子,且缓存策略为 DiskCacheStrategy.ALL **

从上面我们知道,一个图片的加载在 DecodeJob 这个类中,这个任务由 EngineJob 这个线程池去执行的。去到 run 方法,可以看到有个 runWrapped() 方法:

刚开始 runReason 初始化为 INITIALIZE , 所以它会走第一个 case,getNextStage 其实,就是对当前的缓存策略进行判断,由于我们的策略为DiskCacheStrategy.ALL ,所以 diskCacheStrategy.decodeCachedResource() 为true,即会解析解码的流程,所以 State 被赋值为 Stage.RESOURCE_CACHE,如下:

  private Stage getNextStage(Stage current) {switch (current) {case INITIALIZE:return diskCacheStrategy.decodeCachedResource()? Stage.RESOURCE_CACHE : getNextStage(Stage.RESOURCE_CACHE);case RESOURCE_CACHE:return diskCacheStrategy.decodeCachedData()? Stage.DATA_CACHE : getNextStage(Stage.DATA_CACHE);case DATA_CACHE:// Skip loading from source if the user opted to only retrieve the resource from cache.return onlyRetrieveFromCache ? Stage.FINISHED : Stage.SOURCE;case SOURCE:case FINISHED:return Stage.FINISHED;default:throw new IllegalArgumentException("Unrecognized stage: " + current);}}

接着,调用 currentGenerator = getNextGenerator() 拿到当前的解码器为 ResourceCacheGenerator;然后 调用 runGenerators() 方法,它才是关键;它里面维护着一个 while 循环,即不断通过 startNext() 去解析不同的缓存策略,当 stage == Stage.SOURCE 的时候,才会退出。

接着会调用 ResourceCacheGenerator 的 startNext() 方法,它会从生成缓存key,从 DiskLruCache 拿缓存,如下:

 @Overridepublic boolean startNext() {List<Key> sourceIds = helper.getCacheKeys();...while (modelLoaders == null || !hasNextModelLoader()) {resourceClassIndex++;if (resourceClassIndex >= resourceClasses.size()) {sourceIdIndex++;// 由于没有缓存,最后会在这里退出这个循环if (sourceIdIndex >= sourceIds.size()) {return false;}resourceClassIndex = 0;}Key sourceId = sourceIds.get(sourceIdIndex);//获取缓存 keycurrentKey =new ResourceCacheKey(// NOPMD AvoidInstantiatingObjectsInLoopshelper.getArrayPool(),sourceId,helper.getSignature(),helper.getWidth(),helper.getHeight(),transformation,resourceClass,helper.getOptions());// 尝试从 DiskLruCache 拿数据cacheFile = helper.getDiskCache().get(currentKey);if (cacheFile != null) {sourceKey = sourceId;modelLoaders = helper.getModelLoaders(cacheFile);modelLoaderIndex = 0;}}loadData = null;boolean started = false;while (!started && hasNextModelLoader()) {ModelLoader<File, ?> modelLoader = modelLoaders.get(modelLoaderIndex++);loadData = modelLoader.buildLoadData(cacheFile,helper.getWidth(), helper.getHeight(), helper.getOptions());if (loadData != null && helper.hasLoadPath(loadData.fetcher.getDataClass())) {started = true;loadData.fetcher.loadData(helper.getPriority(), this);}}return started;}

由于第一次肯定是拿不到缓存的,所以 while (modelLoaders == null || !hasNextModelLoader()) 循环会一直运行,直到返回 false。

 if (sourceIdIndex >= sourceIds.size()) {return false;}

同理,接着来则是 DataCacheGenerator 也是同样,最后,当 stage == Stage.SOURCE 的时候,才会退出,并调用reschedule() 方法

而在 reschedule() 方法中,会把 runReason 的状态改成RunReason.SWITCH_TO_SOURCE_SERVICE ,并重新回调 run 方法

  @Overridepublic void reschedule() {runReason = RunReason.SWITCH_TO_SOURCE_SERVICE;callback.reschedule(this);}

所以,它又会调用 runWrapped() 方法,但此时的 runReason 已经变成了 SWITCH_TO_SOURCE_SERVICE,所以它会执行 runGenerators() 方法

而在啊runGennerator() 方法中,它里面也是个 while 循环:

  private void runGenerators() {currentThread = Thread.currentThread();startFetchTime = LogTime.getLogTime();boolean isStarted = false;while (!isCancelled && currentGenerator != null&& !(isStarted = currentGenerator.startNext())) {stage = getNextStage(stage);currentGenerator = getNextGenerator();if (stage == Stage.SOURCE) {reschedule();return;}}... }

但此时的 currentGenerator 为 SourceGennerator ,已经不为null,所以,去到 SourceGennerator 的 startNext() 方法:

  @Overridepublic boolean startNext() {if (dataToCache != null) {Object data = dataToCache;dataToCache = null;// 存储到 DiskLruCachecacheData(data);}if (sourceCacheGenerator != null && sourceCacheGenerator.startNext()) {return true;}sourceCacheGenerator = null;loadData = null;boolean started = false;while (!started && hasNextModelLoader()) {loadData = helper.getLoadData().get(loadDataListIndex++);if (loadData != null&& (helper.getDiskCacheStrategy().isDataCacheable(loadData.fetcher.getDataSource())|| helper.hasLoadPath(loadData.fetcher.getDataClass()))) {started = true;//加载数据loadData.fetcher.loadData(helper.getPriority(), this);}}return started;}

首先它会判断 dataToCache 是否为 null,第一次肯定会 null,所以,可以先不管;这里看 loadData.fetcher.loadData(); 这个方法,loadData() 是个接口,它有很多个实现方法,由于我们这里假设是网络下载,所以去到 HttpUrlFetcher#loadData() 中:

可以看到,它拿到 inputStream 后,通过 onDataReady() 方法回调回去,在 SourceGenerator#onDataReady() 中,对 dataToCache 进行赋值;然后又调用 cb.reschedule(); 方法

  @Overridepublic void onDataReady(Object data) {DiskCacheStrategy diskCacheStrategy = helper.getDiskCacheStrategy();if (data != null && diskCacheStrategy.isDataCacheable(loadData.fetcher.getDataSource())) {dataToCache = data;// We might be being called back on someone else's thread. Before doing anything, we should// reschedule to get back onto Glide's thread.cb.reschedule();} else {cb.onDataFetcherReady(loadData.sourceKey, data, loadData.fetcher,loadData.fetcher.getDataSource(), originalKey);}}

可以看到,绕了一圈,又调用 cb.reschedule() 方法,所以,它还是会走 DecodeJob 的run方法,且执行 runWrapped();是不是看得很恶心?恩,我写得也是。
此时的 runReason 还是为 SWITCH_TO_SOURCE_SERVICE,currentGenerator 为 SourceGenerator ;所以,它还是会执行 SourceGenerator startNext() 方法,只不过此时 dataToCache 已经不为空,所以会执行 cacheData() 方法:

可以看到,对这个已经解析完的数据,通过 helper.getDiskCache().put() 方法,存到到 DiskLruCache 硬盘缓存中。并通过 loadData.fetcher.clearup() 清除任务,赋值 sourceCacheGenerator 为 DataCacheGenerator。

此时 sourceCacheGenerator 不为 null,所以会走 DataCacheGenerator 的startNext() 方法;

由于此时已经能从 DiskLruCache 拿到数据了,所以会跳出循环,走下一步:


然后则会调用 loadData.fetcher.loadData() 方法:

该方法会进入 MultiModeLoader#loadData 方法,里面才是重点;由于这种网络的,所以loadData () 方法中的 fetchers.get(currentIndex).loadData(),调用的是 ByteBufferFileLoader 方法:

    @Overridepublic void loadData(@NonNull Priority priority, @NonNull DataCallback<? super Data> callback) {this.priority = priority;this.callback = callback;exceptions = throwableListPool.acquire();fetchers.get(currentIndex).loadData(priority, this);...}#ByteBufferFileLoader@Overridepublic void loadData(@NonNull Priority priority,@NonNull DataCallback<? super ByteBuffer> callback) {ByteBuffer result;try {result = ByteBufferUtil.fromFile(file);} catch (IOException e) {if (Log.isLoggable(TAG, Log.DEBUG)) {Log.d(TAG, "Failed to obtain ByteBuffer for file", e);}callback.onLoadFailed(e);return;}// 回调 onDataReady 方法callback.onDataReady(result);}#DataCacheGenerator#onDataReady@Overridepublic void onDataReady(Object data) {cb.onDataFetcherReady(sourceKey, data, loadData.fetcher, DataSource.DATA_DISK_CACHE, sourceKey);}

可以看到,最后回调了DataCacheGenerator#onDataReady() 方法,接下来则是回到 EngineJob 的onDataFetcherReady() 方法了。

从调试可以看到,最后走了 decodeFromRetrievedData方法,然后走的方法为 EngineJob#notifyComplete() - EngineJob#onResourceReady()

可以看到,最后终于调用 EngineJob 的 onResourceReady() 方法了。
这个方法在内存缓存中已经分析,它会把资源存到 弱引用且加载图片等操作。

此致,Glide 的缓存机制我们就分析完了。

是不是记不住?记不住就对了,看下面的总结和流程图把。
总结:

硬盘缓存时通过在 EngineJob 中的 DecodeJob 中完成的,先通过ResourcesCacheGenerator、DataCacheGenerator 看是否能从 DiskLruCache 拿到数据,如果不能,从SourceGenerator去解析数据,并把数据存储到 DiskLruCache 中,后面通过 DataCacheGenerator 的 startNext() 去分发 fetcher 。
最后会回调 EngineJob 的 onResourceReady() 方法了,该方法会加载图片,并把数据存到弱引用中

流程图:

三. 为啥要用弱引用

我们知道,glide 是用弱引用缓存当前的活跃资源的;为啥不直接从 LruCache 取呢?原因猜测如下:

  1. 这样可以保护当前使用的资源不会被 LruCache 算法回收
  2. 使用弱引用,即可以缓存正在使用的强引用资源,又不阻碍系统需要回收的无引用资源。

Glide 缓存机制解析(为啥使用弱引用)相关推荐

  1. Android图片加载框架最全解析(三),深入探究Glide的缓存机制

    转载请注明出处:http://blog.csdn.net/guolin_blog/article/details/54895665 本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜索 郭 ...

  2. Glide的缓存机制

    Glide的缓存分为两个模块,一个是内存缓存,一个是硬盘缓存. 内存缓存的作用是防止应用重复将图片数据读取到内存当中: 硬盘缓存的作用是防止应用重复从网络或其他地方下载和读取数据. Glide的缓存K ...

  3. Android Glide图片加载框架(三)缓存机制

    文章目录 一.缓存简介 二.缓存用法 内存缓存方式 磁盘缓存方式 三.缓存KEY 四.内存缓存 内存缓存流程 五.磁盘缓存 磁盘缓存流程 Android Glide图片加载框架系列文章 Android ...

  4. 强引用、软引用、弱引用的区别和解析

    本文章转载自 https://www.cnblogs.com/skywang12345/p/3154474.html 1 Java引用介绍 Java从1.2版本开始引入了4种引用,这4种引用的级别由高 ...

  5. Glide系列(四) — Glide缓存流程分析

    文章目录 一.概述 1.1 背景 1.2 系列文章 二.准备知识 2.1 Glide 的缓存分层结构 2.2 Glide 缓存相关类的关联关系 三.缓存的获取流程 3.1 缓存获取的入口 3.2 内存 ...

  6. Python 中弱引用的神奇用法与原理探析

    文章源地址:神奇的弱引用 | 小菜学编程 背景 开始讨论弱引用( weakref )之前,我们先来看看什么是弱引用?它到底有什么作用? 假设我们有一个多线程程序,并发处理应用数据: # 占用大量资源, ...

  7. Glide缓存图片流程浅析

    如果没有缓存,在大量的网络请求从远程获取图片时会造成网络流量的浪费,尤其是面对高清大图的加载更是如此,为了节省带宽,也为了减少用户等待的时间,合理的缓存方式必不可少,这也是Glide图片框架的强大之处 ...

  8. JAVA基础 - 强引用、弱引用、软引用、虚引用

    前言 Java执行 GC(垃圾回收)判断对象是否存活有两种方式,分别是引用计数法和引用链法(可达性分析法). 引用计数:Java堆中给每个对象都有一个引用计数器,每当某个对象在其它地方被引用时,该对象 ...

  9. Java引用类型——强引用、软引用、弱引用和虚引用

    Java执行GC判断对象是否存活有两种方式其中一种是引用计数. 引用计数:Java堆中每一个对象都有一个引用计数属性,引用每新增1次计数加1,引用每释放1次计数减1. 在JDK 1.2以前的版本中,若 ...

最新文章

  1. ASP.NET 发邮件方法
  2. 移动端自动化==Appium定位方式总结
  3. Spring Boot怎么样注册Servlet三大组件[Servlet、Filter、Listener]
  4. 7个IntelliJ IDEA必备插件,提高编码效率
  5. kernelbasedll下载_kernel32.dll
  6. 腾讯网易音乐版权之争,拼的是什么?
  7. 5月上旬香港域名总量动态:大幅度下降 净减6466个
  8. 如何利用python整合excel_一篇文章告诉你如何利用python实施自动化办公,操作Excel...
  9. Java二维码的生成以及附加Logo
  10. CS188-Project 4
  11. github怎么删除代码库
  12. python3手机版画图软件_Python实现画图软件功能方法详解
  13. 史上屌炸天超详细的Java实现逆波兰表达式
  14. React中使用worker线程
  15. word只能以安全模式打开
  16. 个是云计算,一个大数据,一个人工智能,
  17. 彻查手机端浏览博客园出现广告一事!
  18. SDCC 2016架构运维技术峰会(成都站)启动,首批讲师披露
  19. CUDA编程--邻近点查询
  20. git本地仓库与GitHub的同步

热门文章

  1. 【平面设计基础】09:横幅banner的设计
  2. Redis正确关闭开启持久化
  3. 解决Curl curl_setopt_array() 函数的tmp 文件的错误处理方案
  4. Delphi DataSet超高效率导出到Excel——60万个数据9秒导出
  5. 华为p40android auto,华为P40 Pro升级EMUI 11并获Wi-Fi6认证:底层基于Android 10
  6. 如何利用python爬取网站数据
  7. 作为应届生的你,是不是也有这种疑惑和烦恼!
  8. android 中字体加粗
  9. 跟老男孩学Linux运维:Web集群实战(运维人员必备书籍)
  10. 工作总结6.月末总结