iOS 当中的 Cache 设计

1,050 阅读10分钟
原文链接: mp.weixin.qq.com

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)getSpaceshipCount
{        //...
    return _shipCount;
}

值类型-写

值类型也可以安全的写:

- (void)setSpaceshipCount:(int)count
{
    _shipCount = count;
}

对象类型-读

指针类型需要生成新拷贝:

- (User*)getLuckyUser
{        //...
    return [_luckyUser copy];   
}

对象类的copy方法需要我们手动实现NSCopying protocol,开发的初期虽然显得繁琐了些,但后期的回报很大。而且这里的copy必须是deep copy,User中的每一个被持有的property都需要递归copy。

对象类型-写

对象类型写操作的危险之处在于函数的入参,入参也是对象类型的话,传入的是一个共享的引用:

- (void)setLuckyUser:(User*)user
{        //...
    _luckyUser = [user copy]; 
}

集合类型-读

集合类也需要copy,是bug和crash的重灾区:

- (NSArray*)getHotDishes
{        //...
    return [_hotDishes copy];
}

集合类型-写

- (void)setHotDishes:(NSArray*)dishes
{        //...
    _hotDishes = [dished copy];
}

看到这里,大家可能也发现了,其实原则也比较简单,只要保证业务模块从Cache中获取的数据都是独立的copy,就能避免数据共享带来的各种隐患。Cache模块有点类似函数式编程中的纯函数,既不依赖于外部的状态,也不会修改外部的状态,重点处理每一个函数调用的input(入参)和output(返回值)即可。

多线程安全

只要谈到数据的处理,就避免不了多线程安全的话题,可以看下我之前写的几篇关于多线程安全的文章:

iOS多线程到底不安全在哪里?

正确使用@synchronized()

如何用Xcode8解决多线程问题

Cache多线程安全的重点在于对集合类的处理,Cache本身多数时候都是在管理数据的集合。需要特别注意的是NSString其实也应该归到集合类,从数据读写和多线程安全方面看,NSString和NSArray在很多方面表现都是一致的。一些成熟的第三方Cache库已经替我们处理好了多线程安全的问题,如果是自己造的轮子,尤其要注意保证读写都是原子操作,至于如何使用锁,相关的文章分享已经很多了,此处不做赘述了。

总结

了解Cache关键在于明白其背后的设计思想,进而能对我们App的行为有更全面的掌握,能明白每一个业务流程背后对数据处理的瓶颈在哪。随着代码越写越多,业务越来越复杂,今天或明天,我们总要遇到需要应用Cache设计的时候。

欢迎关注公众号:MrPeakTech,长按二维码关注。