IOS Cache设计详细介绍及简单示例
ios cache设计
cache的设计是个基础计算机理论,也是程序员的重要基本功之一。cache几乎无处不在,cpu的l1 l2 cache,ios系统的clean page和dirty page机制,http的tag机制等,这些背后都是cache设计思想的应用。
为什么需要cache
cache的目的是为了追求更高的速度体验,cache的源头是两种数据读取方式在成本和性能上的差异。
在开始着手设计cache之前,需要先理清数据存储的媒介。作为客户端开发人员来说,我们所关注的数据存储方式也有不少种:
- 数据最开始是存储在server上,这些数据需要通过网络请求获取。
- 从server获取数据时,会经过各种中间网络节点(比如代理),这些节点有时会缓存我们的数据。
- 把数据下载到本地之后,我们会在本地disk缓存一份,这样或许不用每次都重新去服务器请求。
- 存到disk之后,数据的存储方式会影响到读取的速度,以b+ tree存储的sqlite就比直接序列化nsarray到文件之中要快不少。
- app启动时,系统会将从server下载到的数据,从disk加载到memory,memory的读写性能比disk要快很多。
- 到了memory中,不同的数据结构存储方式也会存在速度上的差异。用nsdictionary(hash表)形式存储读数据,写性能都比array好,但space开销更大。虽说memory的读写性能比disk都高了很多,但在大集合类数据操作的时候有时也会遇到瓶颈。
- 比memory更快的还有register,l1,l2,只不过对于ios app开发来说,很少深入到这一层面的优化。
上面所说的每一个环节,都存在性能和成本上的差别,server的数据自然是最及时最准确的,但一个app要以nsarray的形式获取到server的数据,中间要经过「漫长」的过程,可以说每一步中都存在cache的设计思想。
对于cache的理解和实践,前提是我们对于存储媒介,和不同数据结构差异,有比较深入的掌握。
我们大部分app的性能优化,如果涉及到cache,一般都是在memory这一媒介上做处理。将需要从disk中,或者通过cpu复杂计算才能获取的数据,通过合理的数据结构存储在memory中,就能解决我们app开发里,绝大部分的cache需求了。这一层面的cache设计也有着不同的姿势,先来看看简单可用型。
简单可用型cache
得益于foundation中nsdictionary的封装,我们可以用hash表这种数据结构来实现一个简单可用的cache机制,先来看一个实例:
- (nsstring*)getformmatedphonenumber:(nsnumber*)phone { if(phone == nil) { return nil; } return [phoneformatlib formatphonenumber:phone]; //cpu费时操作 }
这是个简单的格式化手机号码的函数,其中 formatphonenumber 函数是个cpu intensive的调用,而且在业务场景中针对同一个手机号码,需要经常性的获取格式化之后的nsstring,如果每次都重复计算显然是对cpu资源的浪费,而且性能也不好。我们可以加个简单的cache来优化:
static nsmutabledictionary* gphonecache = nil; - (nsstring*)getformmatedphonenumber:(nsnumber*)phone { if(phone == nil) { return nil; } nsstring* phonenumberstr = nil; [_phonelock lock]; if(gphonecache == nil) { gphonecache = @{}.mutablecopy; } phonenumberstr = [gphonecache objectforkey:phone]; if (phonenumberstr == nil) { phonenumberstr = [phoneformatlib formatphonenumber:phone]; [gphonecache setobject:phonenumberstr forkey:phone]; } [_phonelock unlock]; return phonenumberstr; }
通过引入nsmutabledictionary,就避免了每次都需要重复调用 formatphonenumber 的问题,so easy就完成了一个快速的cache设计,马上就可以提交给测试,把优化成果甩产品经理脸上,这归功于hash表o(1)的时间复杂度。内存空间会多消耗一些,不过对于小量的数据影响比较小,现代的hash表不会一开始就分配大量的空间,而是随着数据的增加而逐渐扩容。
这种简单可用型的cache设计,最大的问题在于,代码过于零散且不可控。小量且分散的cache设计几乎等同于挖坑,在你设计cache的时候可能数据量还小,但后面维护的时候,业务改变的时候,谁也不能保证这块内存的开销依然可以忽略不计。而且这种内存方面的损耗很难察觉,巧妙的隐蔽在某个.m文件中,到后期想控制整个app的内存开销时,会感觉到处都有坑,无从下手。你可能也发现了,上面这段cache代码没有释放cache的地方。
所有对我们整个app有副作用的代码都需要被集中管理,要能从架构的层面去理解和定位。怎么去定义副作用呢?可以抽象成一种「写操作」,往cache中添加新的记录就是写操作,这种写操作的副作用是额外的内存开销,cache的本质是以空间换时间,这空间损耗就是我们的副作用,一个副作用会引发其他更多的副作用,理清这些副作用往往需要反复查阅大量的代码。更好的办法是,一开始就把有副作用的代码集中管理。
优雅可控型cache
避免cache代码散乱放置的做法是,设计一个优雅可控的cache模块。一个app中,可能会有各种各样的数据需要cache,phonenumbercache,avatarcache,spaceshipcache等等,我们需要有个源头来追踪这些cache,直观的做法是通过工厂类来生成和持有这些各式各样的cache:
//cachefactory.h @interface cachefactory : nsobject + (instancetype)sharedinstance; - (id<mycacheprotocol>)getphonenumbercache; - (void)clearphonenumbercache; - (id<mycacheprotocol>)getavatarcache; - (void)clearavatarcache; @end
这样当我们需要评估各种cache对整个app内存开销的影响之时,只需要从cachefactory代码着手即可,调试起来也有迹可循,其他工程师接手你的代码也会感激涕零的。
通过protocol的方式,将cache的声明和实现想分离,这也是个好习惯。cache的另一个重要知识点是cache的淘汰策略,不同的策略表现也不一样,fifo,lru,2queues等等,现在有不少成熟的第三方cache框架可以使用,系统也提供了淘汰策略不明确的nscache,如果没有动手写过任何cache淘汰策略,我还是建议大家自己动手试着做一个,至少要读一下相关的实现源码,了解这些淘汰策略很有必要,在做一些深度优化的时候需要因地制宜来做决定。
cache的使用要有收有放,不能只创建不释放,事实上,所有涉及到data的操作都要考虑data的生命周期。我们做业务的时候,多是以controller为基础单位,有些场景下,一个controller在退出之后被再次进入的可能性就非常之低了,适时的清理cache会让我们app的整体表现更好。
immutable cache
cache中存放的是啥?是data。说到data,就不得不提peak君最爱啰嗦的”immutability(不可变性)”了,immutability和我们代码的稳定性有着极大的关系,大到就像「房间里的大象」,很重要也容易被忽视。
在实践immutability的时候,需要先将data做分类,再去区分每一种类型data如何去实施不可变性。做data分类最重要的是分清楚值类型和引用类型的差别。传值的时候传递的是新的内存拷贝,所以值类型大多是安全的,传指针的时候传递的是同一块共享内存空间,这也是指针之所以危险的一大原因。bool,int,long等等这些primitive type都是值类型,可以放心的传递,而对象类型往往是以指针的形式在传递,需要特别的注意,我们一般通过copy的方式(生成新的内存拷贝)来传递。这也是为什么swift中将很多原先在objective c中基础类变为值类型的原因,强化immutability,让我们的代码更加安全。
我们看下不同类型的数据在cache中的读写操作。
值类型-读
值类型可以安心返回:
- (int)spaceshipcount { //... return _shipcount; }
值类型-写
值类型也可以安全的写:
- (void)setspaceshipcount:(int)count { _shipcount = count; }
对象类型-读
指针类型需要生成新拷贝:
- (user*)luckyuser { //... return [_luckyuser copy]; }
对象类的copy方法需要我们手动实现nscopying protocol,开发的初期虽然显得繁琐了些,但后期的回报很大。而且这里的copy必须是deep copy,user中的每一个被持有的property都需要递归copy。
对象类型-写
对象类型写操作的危险之处在于函数的入参,入参也是对象类型的话,传入的是一个共享的引用:
- (void)setluckyuser:(user*)user { //... _luckyuser = [user copy]; }
集合类型-读
集合类也需要copy,是bug和crash的重灾区:
- (nsarray*)hotdishes { //... return [_hotdishes copy]; }
集合类型-写
- (void)sethotdishes:(nsarray*)dishes { //... _hotdishes = [dished copy]; }
看到这里,大家可能也发现了,其实原则也比较简单,只要保证业务模块从cache中获取的数据都是独立的copy,就能避免数据共享带来的各种隐患。cache模块有点类似函数式编程中的纯函数,既不依赖于外部的状态,也不会修改外部的状态,重点处理每一个函数调用的input(入参)和output(返回值)即可。
多线程安全
cache多线程安全的重点在于对集合类的处理,cache本身多数时候都是在管理数据的集合。需要特别注意的是nsstring其实也应该归到集合类,从数据读写和多线程安全方面看,nsstring和nsarray在很多方面表现都是一致的。一些成熟的第三方cache库已经替我们处理好了多线程安全的问题,如果是自己造的*,尤其要注意保证读写都是原子操作,至于如何使用锁,相关的文章分享已经很多了,此处不做赘述了。
总结
了解cache关键在于明白其背后的设计思想,进而能对我们app的行为有更全面的掌握,能明白每一个业务流程背后对数据处理的瓶颈在哪。随着代码越写越多,业务越来越复杂,今天或明天,我们总要遇到需要应用cache设计的时候。
感谢阅读,希望能帮助到大家,谢谢大家对本站的支持!