• iOS 单例模式详解/避免滥用单例


    前言

    hihi勇敢的小伙伴儿们大家好,单例相信大家都很熟悉了,它在我们的开发中给到了不少帮助,但是你真的熟悉它吗?是否只是一个泛泛之交,而没有走进它的内心世界呢?

    今天,我们一起走近“单例”,偷偷地了解一下单例吧~

    正文

    一、单例介绍

    单例模式:单例模式,属于创建类型的一种常用的软件设计模式。通过单例模式的方法创建的类在当前进程中只有一个实例。

    附上23种设计模式:

    ​​​​​​​

     为了我们能更好的理解单例模式,我列举以下几个cocoa框架中常用的单例:

    1.UIApplication:应用程序。一个UIApplication对象就代表着一个应用程序,每个应用程序有且仅有一个UIApplication对象,开发中最常用的是使用它的openURL函数来跳转到其他应用程序,通过 [UIApplication sharedApplication] 类方法可以获得。

    2.NSNotificationCenter:通知中心。iOS中的通知中心是一种消息广播,采用观察者模式和单例模式,一个应用有且仅有一个通知中心。通过 [NSNotificationCenter defaultCenter] 类方法可以获得。

    3.NSFileManager:文件管理器。它是iOS文件系统的接口,用来创建、修改、访问文件。一个应用有且仅有一个文件管理器。通过 [NSFileManager defaultManager] 类方法可以获得。

    4.NSUserDefaults:用户偏好设置。它主要用来存储简单的键值对数据,数据持久化最简单和基础的一种方案。通过 [NSUserDefaults standardUserDefaults] 类方法可以获得。

    5.NSURLCache:URL缓存。通过将NSURLRequest对象映射到NSCachedURLResponse对象来实现对URL加载请求的响应的缓存。通过 [NSURLCache sharedURLCache] 类方法可以获得。

    1.1 单例模式的要点

    1.只能有一个实例;

    2.它必须自行创建这个实例;

    3.它必须自行向整个系统提供这个实例。

    从具体实现角度来说,是以下三点:

    1.单例模式的类只提供私有的构造函数;

    2.类定义中含有一个该类的静态私有对象(实例);

    3.提供一个静态的公有函数用于创建或获取它本身的静态私有对象(实例)。

    1.2 单例模式的优点

    1.实例控制:单例可以保证系统中该类有且仅有一个实例,确保所有对象都访问这个唯一实例;

    2.灵活性:因为类控制了实例化的过程,所以类可以灵活更改实例化过程;

    3.节省开销:因为只有一个实例,所以减少内存开发和系统的性能开销。

    1.3 单例模式的缺点

    1.由于单例模式中没有抽象层,可扩展性比较差。

    2.实例一旦被创造,对象指针保存在静态区,那么在堆区分配的空间只有在App结束后才会被释放;

    3.单例类职责过重,在一定程度上违背了“单一职责原则”。

    4.滥用单例会带来一些负面问题,比如,单例会隐性地让毫不相关的类产生耦合等问题。(详见3.1)

    1.4 单例的生命周期

    首先我们复习一下内存的五大存储区域:

    由于程序里,一个单例类只能初始化一次,为了保证它在使用中始终存在,所以单例对象一旦建立,对象指针保存在静态区,单例对象(实例)在堆中分配的内存空间,只有应用程序终止后才会被释放。

    那么问题来了,如果单例的实例被置为nil,内存是否会得到释放吗?

    1. static Singletion * singleton;
    2. - (void)dealloc
    3. {
    4. NSLog(@"%s", __func__);
    5. }
    6. //将singleton置为nil
    7. singleton = nil;

    当单例类的实例对象赋值 nil 后,触发了单例的 dealloc方法。

    由于静态变量修饰的指针保存在了全局区域,所以不会被释放,但是指针保存的关联对象地址是alloc申请的内存,在堆中,内存会因引用计数为0被系统调用 dealloc 方法释放掉。(我理解的不知道对不对)

    二、单例的实现

    单例的实现重点就是防止在外部调用的时候出现多个不同的实例,也就是说要从创建的方式入手禁止出现多个不同的实例。

    主要做到以下几点:

    1.防止调用 [[A alloc] init] 引起错误

    2.防止调用 new 引起错误

    3.防止调用 copy 引起错误

    4.防止调用 mutableCopy 引起错误

    2.1 典型的单例写法

    1. static id sharedMyManager;
    2. +(id)shareThemeManager{
    3. if(sharedThemeManager == nil){
    4. shareMyManager = [[self alloc]init];
    5. }
    6. return sharedMyManager;
    7. }

    缺点:无法保证多线程情况下只创建一个对象。适用于只有单线程。

    2.2 加锁的写法

    1. static Singleton *_sharedSingleton = nil;
    2. +(instancetype)sharedSingleton {
    3. @synchronized(self){ //加锁,保证多线程下也只能有一个线程进入
    4. if (! _sharedSingleton) {
    5. _sharedSingleton = [[self alloc] init];
    6. }
    7. }
    8. return _sharedSingleton;
    9. }

    2.3 GCD写法【常用】

    2.3.1 重写父类方法

    1. static Singleton *_sharedSingleton = nil;
    2. + (instancetype)sharedSingleton
    3. {
    4. static dispatch_once_t onceToken;
    5. dispatch_once(&onceToken, ^{
    6. // 不能再使用 alloc 方法
    7. // 因为已经重写了 allocWithZone 方法,所以这里要调用父类的分配空间的方法
    8. _sharedSingleton = [[super allocWithZone:NULL] init];
    9. });
    10. return _sharedSingleton;
    11. }
    12. // ②、防止 [[A alloc] init] 和 new 引起的错误。因为 [[A alloc] init] 和 new 实际是一样的工作原理,都是执行了下面方法
    13. + (instancetype)allocWithZone:(struct _NSZone *)zone
    14. {
    15. return [Singleton sharedSingleton];
    16. }
    17. // ③、NSCopying 防止 copy 引起的错误。当你的单例类不遵循 NSCopying 协议,外部调用本身就会出错.
    18. - (id)copyWithZone:(nullable NSZone *)zone
    19. {
    20. return [Singleton sharedSingleton];
    21. }
    22. // ④、防止 mutableCopy 引起的错误,当你的单例类不遵循 NSMutableCopying 协议,外部调用本身就会出错.
    23. - (id)mutableCopyWithZone:(nullable NSZone *)zone
    24. {
    25. return [Singleton sharedSingleton];
    26. }

    dispatch_once 主要是根据 onceToken 的值来决定怎么去执行代码。

    1.当 onceToken = 0 时,线程执行 dispatch_once 的 block 中代码;

    2.当 onceToken = -1 时,线程跳过 dispatch_once 的 block 中代码不执行;

    3.当 onceToken 为其他值时,线程被阻塞,等待 onceToken 值改变。

    当线程调用 shareInstance,此时 onceToken = 0,调用 block 中的代码,此时 onceToken = 其他值。当其他线程再调用 shareInstance 方法时,onceToken为其他值,线程阻塞。当 block 线程执行完 block 之后,onceToken = -1,其他线程不再阻塞,跳过 block。下次在调用 shareInstance 时, block 已经为 -1,直接跳过 block。

    2.3.2 禁止外部调用

    这种写法还蛮简单好用的~

    1. .h 文件
    2. - (instancetype)init NS_UNAVAILABLE;
    3. + (instancetype)new NS_UNAVAILABLE;
    4. - (id)copy NS_UNAVAILABLE;
    5. - (id)mutableCopy NS_UNAVAILABLE;
    6. .m 文件
    7. static Singleton *_sharedSingleton = nil;
    8. + (instancetype)sharedSingleton
    9. {
    10. static dispatch_once_t onceToken;
    11. dispatch_once(&onceToken, ^{
    12. _sharedSingleton = [[self alloc] init]; // 要使用 self 来调用
    13. });
    14. return _sharedSingleton;
    15. }

    当运行 init 或者 new 时,会报错 'init' is unavailable 或者 'new' is unavailable。

    2.3.3 宏定义写法

    写成宏是比较方便也比较常见的一种:

    1. #define MY_SINGLETON_DEF(_type_) + (_type_ *)sharedInstance; \
    2. +(instancetype) alloc __attribute__((unavailable("call sharedInstance instead"))); \
    3. +(instancetype) new __attribute__((unavailable("call sharedInstance instead"))); \
    4. -(instancetype) copy __attribute__((unavailable("call sharedInstance instead"))); \
    5. -(instancetype) mutableCopy __attribute__((unavailable("call sharedInstance instead"))); \
    6. #define MYY_SINGLETON_IMP(_type_) + (_type_ *)sharedInstance{ \
    7. static _type_ * sharedInstance = nil; \
    8. static dispatch_once_t onceToken; \
    9. dispatch_once(&onceToken, ^{ \
    10. sharedInstance = [[super alloc] init]; \
    11. }); \
    12. return sharedInstance; \
    13. }

    用法也很简单:

    1. //引用
    2. @interface MYSingleton : NSObject
    3. MY_SINGLETON_DEF(MYSingleton);
    4. @end
    5. //实现
    6. @implementation MYSingleton
    7. MY_SINGLETON_IMP(MYSingleton);
    8. @end

    2.4 免锁写法

    1. static Singleton *_sharedSingleton = nil;
    2. + (instancetype)sharedSingleton {
    3. static BOOL initialized = NO;
    4. if (initialized == NO){
    5. initialized = YES;
    6. _sharedSingleton = [[self alloc] init];
    7. }
    8. return _sharedSingleton;
    9. }

    1,2,4三种写法还需将 init new 重写或禁用才算完整哦~

    三、单例的滥用

    3.1 全局状态

    大多数的开发者都认同使用全局可变的状态是不好的行为。太多状态使得程序难以理解,难以调试。我们这些面向对象的程序员在最小化代码的状态复杂程度的方面,有很多需要向函数式编程学习的地方。(这段话挺费解的)

    1. @implementation SPMath {
    2. NSUInteger _a;
    3. NSUInteger _b;
    4. }
    5. - (NSUInteger)computeSum
    6. {
    7. return _a + _b;
    8. }

    在上面这个简单的数学库的实现中,程序员需要在调用 computeSum 前正确的设置实例变量 _a 和 _b。这样有以下问题:

    computeSum 没有显式地通过使用参数的形式声明它依赖于 _a 和 _b 的状态。与仅仅通过查看函数声明就可以知道这个函数的输出依赖于哪些变量不同的是,另一个开发者必须查看这个函数的具体实现才能明白这个函数依赖那些变量。隐藏依赖是不好的。

    当为调用 computeSum 做准备而修改 _a 和 _b 的数值时,程序员需要保证这些修改不会影响任何其他依赖于这两个变量的代码的正确性。而这在多线程的环境中是尤其困难的。

    把下面的代码和上面的例子做对比:

    1. + (NSUInteger)computeSumOf:(NSUInteger)a plus:(NSUInteger)b
    2. {
    3. return a + b;
    4. }

    这里,对变量 a 和 b 的依赖被显式地声明了。我们不需要为了调用这个方法而去改变实例变量的状态。并且我们也不需要担心调用这个函数会留下持久的副作用。我们甚至可以把这个方法声明为类方法,这样就告诉了代码的阅读者这个方法不会修改任何实例的状态。

    那么,这个例子和单例又有什么关系呢?用 Miško Hevery 的话来说,​​​​​​​"单例就是披着羊皮的全局状态"。一个单例可以被使用在任何地方,而不需要显式地声明依赖。就像变量 _a 和 _b 在 computeSum 内部被使用了,却没有被显式声明一样,程序的任意模块都可以调用 [SPMySingleton sharedInstance] 并且访问这个单例。这意味着任何和这个单例交互产生的副作用都会影响程序其他地方的任意代码。

    1. @interface SPSingleton : NSObject
    2. + (instancetype)sharedInstance;
    3. - (NSUInteger)badMutableState;
    4. - (void)setBadMutableState:(NSUInteger)badMutableState;
    5. @end
    6. @implementation SPConsumerA
    7. - (void)someMethod
    8. {
    9. if ([[SPSingleton sharedInstance] badMutableState]) {
    10. // ...
    11. }
    12. }
    13. @end
    14. @implementation SPConsumerB
    15. - (void)someOtherMethod
    16. {
    17. [[SPSingleton sharedInstance] setBadMutableState:0];
    18. }
    19. @end

    在上面的例子中,SPConsumerA 和 SPConsumerB 是两个完全独立的模块。但是 SPConsumerB 可以通过使用单例提供的共享状态来影响 SPConsumerA 的行为。这种情况应该只能发生在 consumer B 显式引用了 A,并表明了两者之间的关系时。这里使用了单例,由于其具有全局和多状态的特性,导致隐式地在两个看起来完全不相关的模块之间建立了耦合。

    让我们来看一个更具体的例子,并且暴露一个使用全局可变状态的额外问题。比如我们想要在我们的应用中构建一个网页查看器。为了支持这个查看器,我们构建了一个简单的 URL cache:

    1. @interface SPURLCache
    2. + (SPCache *)sharedURLCache;
    3. - (void)storeCachedResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request;
    4. @end

    这个开发者开始写一些单元测试来保证代码在一些不同的情况下都能达到预期。首先,他写了一个测试用例来保证网页查看器在设备没有连接时能够展示出错误信息。然后他写了一个测试用例来保证网页查看器能够正确的处理服务器错误。最后,他为成功情况时写了一个测试用例,来保证返回的网络内容能够被正确的显示出来。这个开发者运行了所有的测试用例,并且它们都如预期一样正确。赞!

    几个月以后,这些测试用例开始出现失败,尽管网页查看器的代码从它写完后就从来没有再改动过!到底发生了什么?

    原来,有人改变了测试的顺序。处理成功的那个测试用例首先被运行,然后再运行其他两个。处理错误的那两个测试用例现在竟然成功了,和预期不一样,因为 URL cache 这个单例把不同测试用例之间的 response 缓存起来了。

    持久化状态是单元测试的敌人,因为单元测试在各个测试用例相互独立的情况下才有效。如果状态从一个测试用例传递到了另外一个,这样就和测试用例的执行顺序就有关系了。有 bug 的测试用例,尤其是那些本来不应该通过的测试用例,是非常糟糕的事情。

    3.2 对象的生命周期

    另外一个关键问题就是单例的生命周期。当你在程序中添加一个单例时,很容易会认为 “永远只会有一个实例”。但是在很多我看到过的 iOS 代码中,这种假定都可能被打破。

    比如,假设我们正在构建一个应用,在这个应用里用户可以看到他们的好友列表。他们的每个朋友都有一张个人信息的图片,并且我们想使我们的应用能够下载并且在设备上缓存这些图片。 使用 dispatch_once 代码片段,我们可以写一个 SPThumbnailCache 单例:

    1. @interface SPThumbnailCache : NSObject
    2. + (instancetype)sharedThumbnailCache;
    3. - (void)cacheProfileImage:(NSData *)imageData forUserId:(NSString *)userId;
    4. - (NSData *)cachedProfileImageForUserId:(NSString *)userId;
    5. @end

    我们继续构建我们的应用,一切看起来都很正常,直到有一天,我们决定去实现“注销”功能,这样用户可以在应用中进行账号切换。

    突然我们发现我们将要面临一个讨厌的问题:用户相关的状态存储在全局单例中。当用户注销后,我们希望能够清理掉所有的硬盘上的持久化状态。否则,我们将会把这些被遗弃的数据残留在用户的设备上,浪费宝贵的硬盘空间。对于用户登出又登录了一个新的账号这种情况,我们也想能够对这个新用户使用一个全新的 SPThumbnailCache 实例。

    问题在于按照定义单例被认为是“创建一次,永久有效”的实例。你可以想到一些对于上述问题的解决方案。或许我们可以在用户登出时移除这个单例:

    1. static SPThumbnailCache *sharedThumbnailCache;
    2. + (instancetype)sharedThumbnailCache
    3. {
    4. if (!sharedThumbnailCache) {
    5. sharedThumbnailCache = [[self alloc] init];
    6. }
    7. return sharedThumbnailCache;
    8. }
    9. + (void)tearDown
    10. {
    11. // The SPThumbnailCache will clean up persistent states when deallocated
    12. sharedThumbnailCache = nil;
    13. }

    这是一个明显的对单例模式的滥用,但是它可以工作,对吧?

    我们当然可以使用这种方式去解决,但是代价实在是太大了。我们不能使用简单的的 dispatch_once 方案了,而这个方案能够保证线程安全以及所有调用 [SPThumbnailCache sharedThumbnailCache] 的地方都能访问到同一个实例。

    现在我们需要对使用缩略图 cache 的代码的执行顺序非常小心。假设当用户正在执行登出操作时,有一些后台任务正在执行把图片保存到缓存中的操作:​​​​​​​​​​​​​​

    1. dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    2. [[SPThumbnailCache sharedThumbnailCache] cacheProfileImage:newImage forUserId:userId];
    3. });

    我们需要保证在所有的后台任务完成前, tearDown 一定不能被执行。这确保了 newImage 数据可以被正确的清理掉。或者,我们需要保证在缩略图 cache 被移除时,后台缓存任务一定要被取消掉。否则,一个新的缩略图 cache 的实例将会被延迟创建,并且之前用户的数据 (newImage 对象) 会被存储在它里面。

    由于对于单例实例来说它没有明确的所有者(因为单例自己管理自己的生命周期),“关闭”一个单例变得非常的困难。

    分析到这里,我希望你能够意识到,“这个缩略图 cache 从来就不应该作为一个单例!”。问题在于一个对象得生命周期可能在项目的最初阶段没有被很好得考虑清楚。举一个具体的例子,Dropbox 的 iOS 客户端曾经只支持一个账号登录。它以这样的状态存在了数年,直到有一天我们希望能够同时支持多个用户账号登录 (同时登陆私人账号和工作账号)。突然之间,我们以前的的假设“只能够同时有一个用户处于登录状态”就不成立了。如果假定了一个对象的生命周期和应用的生命周期一致,那你的代码的灵活扩展就受到了限制,早晚有一天当产品的需求产生变化时,你会为当初的这个假定付出代价的。

    这里我们得到的教训是,单例应该只用来保存全局的状态,并且不能和任何作用域绑定。如果这些状态的作用域比一个完整的应用程序的生命周期要短,那么这个状态就不应该使用单例来管理。用一个单例来管理用户绑定的状态,是代码的坏味道,你应该认真的重新评估你的对象图的设计。

    3.3 避免使用单例

    既然单例对局部作用域的状态有这么多的坏处,那么我们应该怎样避免使用它们呢?

    让我们来重温一下上面的例子。既然我们的缩略图 cache 的缓存状态是和具体的用户绑定的,那么让我们来定义一个user对象吧:

    1. @interface SPUser : NSObject
    2. @property (nonatomic, readonly) SPThumbnailCache *thumbnailCache;
    3. @end
    4. @implementation SPUser
    5. - (instancetype)init
    6. {
    7. if ((self = [super init])) {
    8. _thumbnailCache = [[SPThumbnailCache alloc] init];
    9. // Initialize other user-specific state...
    10. }
    11. return self;
    12. }
    13. @end

    我们现在用一个对象来作为一个经过认证的用户会话的模型类,并且我们可以把所有和用户相关的状态存储在这个对象中。现在假设我们有一个 view controller 来展现好友列表:​​​​​​​

    1. @interface SPFriendListViewController : UIViewController
    2. - (instancetype)initWithUser:(SPUser *)user;
    3. @end

    我们可以显式地把经过认证的 user 对象作为参数传递给这个 view controller。这种把依赖性传递给依赖对象的技术正式的叫法是依赖注入,它有很多优点:​​​​​​​

    1.对于阅读这个 SPFriendListViewController 头文件的读者来说,可以很清楚的知道它只有在有登录用户的情况下才会被展示。
    这个 SPFriendListViewController 只要还在使用中,就可以强引用 user 对象。举例来说,对于前面的例子,我们可以像下面这样在后台任务中保存一个图片到缩略图 cache 中:

    1. dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    2. [_user.thumbnailCache cacheProfileImage:newImage forUserId:userId];
    3. });

    就算后台任务还没有完成,应用其他地方的代码也可以创建和使用一个全新的 SPUser 对象,而不会在清理第一个实例时阻塞用户交互。

    为了更详细的说明一下第二点,让我们画一下在使用依赖注入之前和之后的对象图。

    假设我们的 SPFriendListViewController 是当前 window 的 root view controller。使用单例时,我们的对象图看起来如下所示:

    view controller 自己,以及自定义的 image view 的列表,都会和 sharedThumbnailCache 产生交互。当用户登出后,我们想要清理 root view controller 并且退出到登录页面:

    这里的问题在于这个好友列表的 view controller 可能仍然在执行代码 (由于后台操作的原因),并且可能因此仍然有一些没有执行的涉及到 sharedThumbnailCache 的调用。

    和使用依赖注入的解决方案对比一下:

    简单起见,假设 SPApplicationDelegate 管理 SPUser 的实例 (在实践中,你可能会把这些用户状态的管理工作交给另外一个对象来做,这样可以使你的 application delegate 简化)。当展现好友列表 view controller 时,会传递进去一个 user 的引用。这个引用也会向下传递给 profile image views。现在,当用户登出时,我们的对象图如下所示:

    这个对象图看起来和使用单例时很像。那么,区别是什么呢?

    关键问题是作用域。在单例那种情况中,sharedThumbnailCache 仍然可以被程序的任意模块访问。假如用户快速的登录了一个新的账号。该用户也想看看他的好友列表,这也就意味着需要再一次的和缩略图 cache 产生交互:

    当用户登录一个新账号,我们应该能够构建并且与全新的 SPThumbnailCache 交互,而不需要再在销毁老的缩略图 cache 上花费精力。基于对象管理的典型规则,老的 view controllers 和老的缩略图 cache 应该能够自己在后台延迟被清理掉。简而言之,我们应该隔离用户 A 相关联的状态和用户 B 相关联的状态:

    3.4 结论

    希望这篇文章中的内容读起来不像奇幻小说那样难以理解。人们已经对单例的滥用抱怨了很多年了,并且我们也都知道全局状态是很不好的事情。但是在 iOS 开发的世界中,单例的使用是如此的普遍以至于我们有时候忘记了我们多年来在其他面向对象编程中学到的教训。

    这一切的关键点是,在面向对象编程中我们想要最小化可变状态的作用域。但是单例却因为使可变的状态可以被程序中的任何地方访问,而站在了对立面。下一次你想使用单例时,我希望你能够好好考虑一下使用依赖注入作为替代方案。

    写在最后

    上面关于避免滥用单例的部分,写的太好了,所以我把全文搬运过来了,看语法应该是外文翻译过来的,条理清晰,就是翻译成中文后读起来有些地方会有些晦涩,今天看到这篇文章虽收益颇丰,但是似乎并没有什么真正的收获,不知道在自己的代码上该如何运用,在设计程序之前考虑对象的生命周期等等经验真的很宝贵!所以在这里分享出来,希望更多人看到和学习到!

    希望对你们也有所帮助!

    如果有错误还请小伙伴儿们指正,感激不尽~

    ​​​​​​​参考文章:

    单例模式百度百科:单例模式_百度百科

    iOS 单例:https://www.cnblogs.com/dins/p/ios-singleton.html

    OC中单例的各种写法:OC中单例的各种写法及基本讲解 - 走看看

  • 相关阅读:
    测试自动化中遵循的最佳实践
    尝试 vue 实现 SEO
    TensorFlow 的基本概念和使用场景
    毅速丨哪些金属材料在3D打印中应用最多
    代码随想录算法训练营Day48 (day47休息) | 动态规划(9/17) LeetCode 198.打家劫舍 213.打家劫舍II 337.打家劫舍III
    SCAU 编译原理 实验1 词法分析实验
    第一章 赛前准备工作
    第五十六回 徐宁教使钩镰枪 宋江大破连环马-飞桨图像分类套件PaddleClas初探
    10 Minimax估计和Bayes估计
    electron build 打包时,背景图片失败,background-image: url 被转换成app:///img/
  • 原文地址:https://blog.csdn.net/u012265444/article/details/125414840