iOS当中的Cache设计
eeas8588
8年前
<p>Cache的设计是个基础计算机理论,也是程序员的重要基本功之一。Cache几乎无处不在,CPU的L1 L2 Cache,iOS系统的clean page和dirty page机制,HTTP的tag机制等,这些背后都是Cache设计思想的应用。</p> <p><strong>为什么需要Cache</strong></p> <p>Cache的目的是为了追求更高的速度体验,Cache的源头是两种数据读取方式在成本和性能上的差异。</p> <p>在开始着手设计Cache之前,需要先理清数据存储的媒介。作为客户端开发人员来说,我们所关注的数据存储方式也有不少种:</p> <ul> <li> <p>数据最开始是存储在Server上,这些数据需要通过网络请求获取。</p> </li> <li> <p>从Server获取数据时,会经过各种中间网络节点(比如代理),这些节点有时会缓存我们的数据。</p> </li> <li> <p>把数据下载到本地之后,我们会在本地disk缓存一份,这样或许不用每次都重新去服务器请求。</p> </li> <li> <p>存到disk之后,数据的存储方式会影响到读取的速度,以B+ Tree存储的sqlite就比直接序列化NSArray到文件之中要快不少。</p> </li> <li> <p>App启动时,系统会将从Server下载到的数据,从disk加载到memory,memory的读写性能比disk要快很多。</p> </li> <li> <p>到了Memory中,不同的数据结构存储方式也会存在速度上的差异。用NSDictionary(hash表)形式存储读数据,写性能都比Array好,但space开销更大。虽说memory的读写性能比disk都高了很多,但在大集合类数据操作的时候有时也会遇到瓶颈。</p> </li> <li> <p>比Memory更快的还有Register,L1,L2,只不过对于iOS App开发来说,很少深入到这一层面的优化。</p> </li> </ul> <p>上面所说的每一个环节,都存在性能和成本上的差别,Server的数据自然是最及时最准确的,但一个App要以NSArray的形式获取到Server的数据,中间要经过「漫长」的过程,可以说每一步中都存在cache的设计思想。</p> <p>对于Cache的理解和实践,前提是我们对于存储媒介,和不同数据结构差异,有比较深入的掌握。</p> <p>我们大部分App的性能优化,如果涉及到Cache,一般都是在Memory这一媒介上做处理。将需要从Disk中,或者通过CPU复杂计算才能获取的数据,通过合理的数据结构存储在Memory中,就能解决我们App开发里,绝大部分的Cache需求了。这一层面的Cache设计也有着不同的姿势,先来看看简单可用型。</p> <p><strong>简单可用型Cache</strong></p> <p>得益于Foundation中NSDictionary的封装,我们可以用hash表这种数据结构来实现一个简单可用的cache机制,先来看一个实例:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/985dfa1b132af286893d001516a846ef.png"></p> <p>这是个简单的格式化手机号码的函数,其中formatPhoneNumber函数是个CPU Intensive的调用,而且在业务场景中针对同一个手机号码,需要经常性的获取格式化之后的NSString,如果每次都重复计算显然是对CPU资源的浪费,而且性能也不好。我们可以加个简单的Cache来优化:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/d4ac74c291f4d69d781706354a86b12f.png"></p> <p>通过引入NSMutableDictionary,就避免了每次都需要重复调用formatPhoneNumber的问题,so easy就完成了一个快速的cache设计,马上就可以提交给测试,把优化成果甩产品经理脸上,这归功于hash表O(1)的时间复杂度。内存空间会多消耗一些,不过对于小量的数据影响比较小,现代的hash表不会一开始就分配大量的空间,而是随着数据的增加而逐渐扩容。</p> <p>这种简单可用型的Cache设计,最大的问题在于,代码过于零散且不可控。小量且分散的cache设计几乎等同于挖坑,在你设计cache的时候可能数据量还小,但后面维护的时候,业务改变的时候,谁也不能保证这块内存的开销依然可以忽略不计。而且这种内存方面的损耗很难察觉,巧妙的隐蔽在某个.m文件中,到后期想控制整个App的内存开销时,会感觉到处都有坑,无从下手。你可能也发现了,上面这段Cache代码没有释放Cache的地方。</p> <p>所有对我们整个App有副作用的代码都需要被集中管理,要能从架构的层面去理解和定位。怎么去定义副作用呢?可以抽象成一种「写操作」,往Cache中添加新的记录就是写操作,这种写操作的副作用是额外的内存开销,Cache的本质是以空间换时间,这空间损耗就是我们的副作用,一个副作用会引发其他更多的副作用,理清这些副作用往往需要反复查阅大量的代码。更好的办法是,一开始就把有副作用的代码集中管理。</p> <p><strong>优雅可控型Cache</strong></p> <p>避免Cache代码散乱放置的做法是,设计一个优雅可控的Cache模块。一个App中,可能会有各种各样的数据需要Cache,phoneNumberCache,avatarCache,spaceshipCache等等,我们需要有个源头来追踪这些cache,直观的做法是通过工厂类来生成和持有这些各式各样的cache:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/0a574ed5283e0314091de7ad82c1d0b6.png"></p> <p>这样当我们需要评估各种Cache对整个App内存开销的影响之时,只需要从CacheFactory代码着手即可,调试起来也有迹可循,其他工程师接手你的代码也会感激涕零的。</p> <p>通过protocol的方式,将cache的声明和实现想分离,这也是个好习惯。cache的另一个重要知识点是cache的淘汰策略,不同的策略表现也不一样,FIFO,LRU,2Queues等等,现在有不少成熟的第三方cache框架可以使用,系统也提供了淘汰策略不明确的NSCache,如果没有动手写过任何cache淘汰策略,我还是建议大家自己动手试着做一个,至少要读一下相关的实现源码,了解这些淘汰策略很有必要,在做一些深度优化的时候需要因地制宜来做决定。</p> <p>cache的使用要有收有放,不能只创建不释放,事实上,所有涉及到data的操作都要考虑data的生命周期。我们做业务的时候,多是以Controller为基础单位,有些场景下,一个Controller在退出之后被再次进入的可能性就非常之低了,适时的清理cache会让我们App的整体表现更好。</p> <p><strong>Immutable Cache</strong></p> <p>Cache中存放的是啥?是Data。说到Data,就不得不提peak君最爱啰嗦的”Immutability(不可变性)”了,Immutability和我们代码的稳定性有着极大的关系,大到就像「房间里的大象」,很重要也容易被忽视。</p> <p>在实践Immutability的时候,需要先将Data做分类,再去区分每一种类型Data如何去实施不可变性。做Data分类最重要的是分清楚值类型和引用类型的差别。传值的时候传递的是新的内存拷贝,所以值类型大多是安全的,传指针的时候传递的是同一块共享内存空间,这也是指针之所以危险的一大原因。bool,Int,long等等这些primitive type都是值类型,可以放心的传递,而对象类型往往是以指针的形式在传递,需要特别的注意,我们一般通过copy的方式(生成新的内存拷贝)来传递。这也是为什么Swift中将很多原先在Objective C中基础类变为值类型的原因,强化Immutability,让我们的代码更加安全。</p> <p>我们看下不同类型的数据在Cache中的读写操作。</p> <p><strong>值类型-读</strong></p> <p>值类型可以安心返回:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/c01cc8a7f2bd465147c03842709c57bd.png"></p> <p><strong>值类型-写</strong></p> <p>值类型也可以安全的写:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/a1bf4859ad013004b44538a7a842478b.png"></p> <p><strong>对象类型-读</strong></p> <p>指针类型需要生成新拷贝:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/f936a87e47be1d6f4bf5ebcb9f6f7ff1.png"></p> <p>对象类的copy方法需要我们手动实现NSCopying protocol,开发的初期虽然显得繁琐了些,但后期的回报很大。而且这里的copy必须是deep copy,User中的每一个被持有的property都需要递归copy。</p> <p><strong>对象类型-写</strong></p> <p>对象类型写操作的危险之处在于函数的入参,入参也是对象类型的话,传入的是一个共享的引用:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/89330c2f13f4dc4f381ad5eb9c641232.png"></p> <p><strong>集合类型-读</strong></p> <p>集合类也需要copy,是bug和crash的重灾区:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/cbf098f868ae4a27c9dec6f130bbffb6.png"></p> <p><strong>集合类型-写</strong></p> <p style="text-align:center"><img src="https://simg.open-open.com/show/ca0ad33e60ff52deb154573d97c889d9.png"></p> <p>看到这里,大家可能也发现了,其实原则也比较简单,只要保证业务模块从Cache中获取的数据都是独立的copy,就能避免数据共享带来的各种隐患。Cache模块有点类似函数式编程中的纯函数,既不依赖于外部的状态,也不会修改外部的状态,重点处理每一个函数调用的input(入参)和output(返回值)即可。</p> <p><strong>多线程安全</strong></p> <p>Cache多线程安全的重点在于对集合类的处理,Cache本身多数时候都是在管理数据的集合。需要特别注意的是NSString其实也应该归到集合类,从数据读写和多线程安全方面看,NSString和NSArray在很多方面表现都是一致的。一些成熟的第三方Cache库已经替我们处理好了多线程安全的问题,如果是自己造的轮子,尤其要注意保证读写都是原子操作,至于如何使用锁,相关的文章分享已经很多了,此处不做赘述了。</p> <p><strong>总结</strong></p> <p>了解Cache关键在于明白其背后的设计思想,进而能对我们App的行为有更全面的掌握,能明白每一个业务流程背后对数据处理的瓶颈在哪。随着代码越写越多,业务越来越复杂,今天或明天,我们总要遇到需要应用Cache设计的时候。</p> <p> </p> <p>来自:http://www.cocoachina.com/ios/20170104/18508.html</p> <p> </p>