在iOS中有很多的设计模式,有一本书《Elements of Reusable Object-Oriented Software》(中文名字为《设计模式》)讲述了23种软件设计模式,这本书中的设计模式都是面向对象的,很多语言都有广泛的应用,在苹果的开发中,当然也会存在这些设计模式,我们所使用的无论是开发Mac OX系统的Cocoa框架还是开发iOS系统的Cocoa Touch框架,里面的设计模式也是由这23种设计模式演变而来。本文着重详细介绍在开发iOS时采用的单例模式,从设计过程的演变和细节的完善进行分析,相信大家能够从中获得重要的思路原理而不是仅仅知道应该这么写单例模式却不知为何这么写,当然,理解透彻后,为了我们的开发效率,我们可以将单例模式的代码封装到一个类中然后定义成宏,适配于ARC和MRC模式,让开发效率大大提高。这些操作在本文中都会一一讲到,接下来就进入正题。

在讲述之前,先说明本文的层次结构,本文分成了5个部分,下面依次罗列

  1. 单例模式中懒汉式的实现
  2. 单例模式中饿汉式的实现
  3. 使用GCD代替手动加锁判断处理
  4. 非ARC情况的单例模式
  5. 单例模式的代码实用化(封装便于开发直接使用)
前言:

所谓的单例模式,就是要实现在一个应用程序中,对应的一个类只会有一个实例,无论创建多少次,都是同一个对象。大家在开发过程中也见过不少的单例,比如UIApplication、UIAccelerometer(重力加速)、NSUserDefaults、NSNotificationCenter,当然,这些是开发Cocoa Touch框架中的,在Cocoa框架中还有NSFileManager、NSBundle等。在iOS中,懒加载几乎是无处不在的,其实,懒加载在某种意义上也是采用了单例模式的思想(如果对象存在就直接返回,对象不存在就创建对象),那么本文就从大家熟悉的懒加载入手进行讲解(整个过程都用实际的代码进行说明)。

一、单例模式中懒汉式的实现

新建一个工程(本文是single view工程),创建一个继承于NSObject的类,命名为NTMoviePlayer,首先我们尝试下使用懒加载,在viewController里面导入NTMoviePlayer.h,定义一个NTMoviePlayer的对象,然后写出懒加载代码,这样好像真的是可以做到在viewController里面只有一个NTMoviePlayer对象,但是如果又创建一个类,然后进行同样的操作,两次创建的对象还会是一样的吗?答案很明显,不一样,我们可以从这个现象去推导问题发生的根本原因,那就是在不同的类中,创建NTMoviePlayer对象的时候都会进行一个alloc操作,那么这个alloc实际上就是分配内存空间的一个操作,分配了不同的内存区域,那么当然创建了不同的对象,所以,如果要保证应用中就只有一个对象,就应该让NTMoviePlayer类的alloc方法只会进行一次内存空间的分配。这样,找到了问题所在,就去实现代码,重写alloc方法,这里提供了两种方法,一种是alloc,一种是allocWithZone方法,其实在alloc调用的底层也是allocWithZone方法,所以在此,我们需要重写allocWithZone方法:

1
2
3
4
5
6
7
8
9
id moviePlayer;
+ (instancetype)allocWithZone:(struct _NSZone *)zone
{
if (moviePlayer == nil) {
// 调用super的allocWithZone方法来分配内存空间
moviePlayer = [super allocWithZone:zone];
}
return moviePlayer;
}

在这里我们初步使用懒加载来控制保证只有一个单例,但是这种仅仅适合在单一线程中使用的情况,要是涉及到了多线程的话,那么就会出现这样的情况,当一个线程走到了if判断时,判断为空,然后进入其中去创建对象,在还没有返回的时候,另外一条线程又到了if判断,判断仍然为空,于是又进入进行对象的创建,所以这样的话就保证不了只有一个单例对象。于是,我们对代码进行手动加锁。

1
2
3
4
5
6
7
8
9
10
11
12
id moviePlayer;
+ (instancetype)allocWithZone:(struct _NSZone *)zone
{
// 在这里加一把锁(利用本类为锁)进行多线程问题的解决
@synchronized(self){
if (moviePlayer == nil) {
// 调用super的allocWithZone方法来分配内存空间
moviePlayer = [super allocWithZone:zone];
}
}
return moviePlayer;
}

这样的话,就可以解决上述问题,但是,每一次进行alloc的时候都会加锁和判断锁的存在,这一点是可以进行优化的(在java中也有对于这种情况的处理),于是在加锁之前再次进行判断,修改代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
id moviePlayer;
+ (instancetype)allocWithZone:(struct _NSZone *)zone
{
// 在这里判断,为了优化资源,防止多次加锁和判断锁
if (moviePlayer == nil) {
// 在这里加一把锁(利用本类为锁)进行多线程问题的解决
@synchronized(self){
if (moviePlayer == nil) {
// 调用super的allocWithZone方法来分配内存空间
moviePlayer = [super allocWithZone:zone];
}
}
}
return moviePlayer;
}

到此,在allocWithZone方法中的代码基本完善,接着,在我们进行开发中,也时常会使用到很多单例,我们在创建单例的时候都不是使用的alloc和init,而是使用的shared加上变量名这种创建方式,所以,我们自己写单例的话,也应该向外界暴露这个方法。在.h文件中先声明下方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
+ (instancetype)sharedMoviePlayer;
然后在.m文件中实现,逻辑上和allocWithZone方法是一样的
+ (instancetype)sharedMoviePlayer
{
if (moviePlayer == nil) {
@synchronized(self){
if (moviePlayer == nil) {
// 在这里写self和写本类名是一样的
moviePlayer = [[self alloc]init];
}
}
}
return moviePlayer;
}

这个对外暴露的方法完成之后,我们还需要注意一点,在使用copy这个语法的时候,是能够创建新的对象的,如果使用copy创建出新的对象的话,那么就不能够保证单例的存在了,所以我们需要重写copyWithZone方法,如果直接在.m文件中敲的话,会发现没有提示,这是没有声明协议的原因,可以在.h文件中声明NSCopying协议,然后重写copyWithZone方法:

1
2
3
4
- (id)copyWithZone:(NSZone *)zone
{
return moviePlayer;
}

在这里没有像上面两个方法一样实现逻辑是因为:使用copy的前提是必须现有一个对象,然后再使用,所以既然都已经创建了一个对象了,那么全局变量所代表的对象也就是这个单例,那么在copyWithZone方法中直接返回就好了
到这里,基本的代码差不多都写好了,还需要处理一些细节,首先,我们所声明的全局变量是没有使用static来修饰的,大家在开发过程中所遇见到的全局变量很多都是使用了static来修饰的,这里进行一个小插曲,简要说明下static的使用,有两种用法

1、static修饰局部变量:

简要来说,如果修饰了局部变量的话,那么这个局部变量的生命周期就和不加static的全局变量一样了(也就是只有一块内存区域,无论这个方法执行多少次,都不会进行内存的分配),不同的在于作用域仍然没有改变

2、static修饰全局变量(这点是我们应该注意的):

如果不适用static的全局变量,我们可以在其他的类中使用extern关键字直接获取到这个对象,可想而知,在我们所做的单例模式中,如果在其他类中利用extern拿到了这个对象,进行一个对象销毁,例如:

1
2
extern id moviePlayer;
moviePlayer = nil;

这时候在这句代码之前创建的单例就销毁了,再次创建的对象就不是同一个了,这样就无法保证单例的存在

所以对于全局变量的定义,需要加上static修饰符,到此,懒汉式的单例模式就写好了(非ARC和GCD模式在后面讨论),下面给出整合代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#import "NTMoviePlayer.h"
@implementation NTMoviePlayer
static id moviePlayer;
+ (instancetype)allocWithZone:(struct _NSZone *)zone
{
// 在这里判断,为了优化资源,防止多次加锁和判断锁
if (moviePlayer == nil) {
// 在这里加一把锁(利用本类为锁)进行多线程问题的解决
@synchronized(self){
if (moviePlayer == nil) {
// 调用super的allocWithZone方法来分配内存空间
moviePlayer = [super allocWithZone:zone];
}
}
}
return moviePlayer;
}
+ (instancetype)sharedMoviePlayer
{
if (moviePlayer == nil) {
@synchronized(self){
if (moviePlayer == nil) {
// 在这里写self和写本类名是一样的
moviePlayer = [[self alloc]init];
}
}
}
return moviePlayer;
}
- (id)copyWithZone:(NSZone *)zone
{
return moviePlayer;
}
@end

二、单例模式中饿汉式的实现
在第一个模块中,我们进行了单例模式中懒汉式的详细说明,也从懒汉式的模式中知道了实现单例模式的思路,但懒汉式和饿汉式还是有很大的区别,不过是从实现原理,和代码操作上。在这里先介绍懒汉式和饿汉式和特点(其实这两个名字都是很形象的)
1、懒汉式:实现原理和懒加载其实很像,如果在程序中不使用这个对象,那么就不会创建,只有在你使用代码创建这个对象,才会创建。这种实现思想或者说是原理都是iOS开发中非常重要的,所以,懒汉式的单例模式也是最为重要的,是开发中最常见的。
2、饿汉式:在没有使用代码去创建对象之前,这个对象已经加载好了,并且分配了内存空间,当你去使用代码创建的时候,实际上只是将这个原本创建好的对象拿出来而已。
接下来介绍的就是饿汉式:
刚刚在分析饿汉式和懒汉式的特点时提到过,饿汉式是在使用代码去创建对象之前就已经创建好了对象,这里提到的使用代码去创建对象实际上就是用alloc或者是对外暴露的shared方法,最根本上是调用了alloc方法,所以,换句话说,饿汉式也就是在我们手动写代码去alloc之前就已经将对象创建完毕了。此时我们就要思考了,什么方法能够实现这样的效果呢?这里介绍两个方法,第一个是load方法,第二个是initialize方法
1、load方法:当类加载到运行环境中的时候就会调用且仅调用一次,同时注意一个类只会加载一次(类加载有别于引用类,可以这么说,所有类都会在程序启动的时候加载一次,不管有没有在目前显示的视图类中引用到)
2、initialize方法:当第一次使用类的时候加载且仅加载一次
我们以load方法作为示范,在工程中再次创建一个新的类NTMusicPlayer,做一些基本的相同操作,在.h文件中暴露出sharedMusicPlayer方法,在.m文件中利用static定义一个全局变量musicPlayer,接着我们需要写出load方法

1
2
3
4
+ (void)load
{
musicPlayer = [[self alloc]init];
}

接着我们仍然需要重写allocWithZone方法,因为在load方法中是用alloc来创建对象,分配内存空间的,但是在饿汉式中的逻辑就和在懒汉式中的逻辑有所区别了

1
2
3
4
5
6
7
+ (instancetype)allocWithZone:(struct _NSZone *)zone
{
if (musicPlayer == nil) {
musicPlayer = [super allocWithZone:zone];
}
return musicPlayer;
}

在这里,我们可以发现有简洁了很多,去掉了多线程的问题的加锁方案,我们来分析下原因,首先,在类被加载的时候会调用且仅调用一次load方法,而load方法里面又调用了alloc方法,所以,第一次调用肯定是创建好了对象,而且这时候不会存在多线程问题。当我们手动去使用alloc的时候,无论如何都过不了判断,所以也不会存在多线程的问题了。接下来需要实现shareMusicPlayer方法和copy方法

1
2
3
4
5
6
7
8
+ (instancetype)sharedMusicPlayer
{
return musicPlayer;
}
- (id)copyWithZone:(NSZone *)zone
{
return musicPlayer;
}

代码又变简单,这里连判断都不用加,是因为我们使用shareMusicPlayer方法和copy的时候必然全局变量是有值的,而alloc方法中不直接返回是因为在load方法中调用了它,需要去创建一个对象
到这里,饿汉式的讲解也完成,下面是整合代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#import "NTMusicPlayer.h"
@implementation NTMusicPlayer
static id musicPlayer;
+ (void)load
{
musicPlayer = [[self alloc]init];
}
+ (instancetype)allocWithZone:(struct _NSZone *)zone
{
if (musicPlayer == nil) {
musicPlayer = [super allocWithZone:zone];
}
return musicPlayer;
}
+ (instancetype)sharedMusicPlayer
{
return musicPlayer;
}
- (id)copyWithZone:(NSZone *)zone
{
return musicPlayer;
}
@end
三、使用GCD代替手动加锁判断处理

再次新建一个类NTPicturePlayer,这里将详细说明适用GCD中的方法来代替我们手动加锁的情况,还是依照惯例,在.h文件中声明shared方法,然后在.m文件中使用static定义一个全局变量,首先,重写alloc方法

1
2
3
4
5
6
7
8
+ (instancetype)allocWithZone:(struct _NSZone *)zone
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
picturePlayer = [[super alloc]init];
});
return picturePlayer;
}

dispatch_once方法是已经在方法的内部解决了多线程问题的,所以我们不用再去加锁(开始定义了一个static常量,这句代码不是自己写的,敲dispatch_once有个提示的方法就会自动生成),dispatch_once在宏观上面表示内部方法只会执行一次。接着是sharedPicturePlayer方法

1
2
3
4
5
6
7
8
+ (instancetype)sharedPicturePlayer
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
picturePlayer = [[self alloc]init];
});
return picturePlayer;
}

最后是copy方法的重写

1
2
3
4
- (id)copyWithZone:(NSZone *)zone
{
return picturePlayer;
}

这样的话,GCD版的单例模式(这里是懒汉模式为例)就做好了,下面是整合代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#import "NTPicturePlayer.h"
@implementation NTPicturePlayer
static id picturePlayer;
+ (instancetype)allocWithZone:(struct _NSZone *)zone
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
picturePlayer = [[super alloc]init];
});
return picturePlayer;
}
+ (instancetype)sharedPicturePlayer
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
picturePlayer = [[self alloc]init];
});
return picturePlayer;
}
- (id)copyWithZone:(NSZone *)zone
{
return picturePlayer;
}
@end

可以看出,GCD版本的单例模式比我们之前手动进行加锁的单例模式要简单很多,因此在实际开发中GCD版本的单例模式也是使用最多的

四、非ARC情况的单例模式

我们知道,在MRC模式也就是非ARC模式中,我们是需要手动去管理内存的,因此,我们可以使用release去将一个对象手动销毁,那么这样的话,我们的创建出来的单例对象也可以被很轻易的销毁。所以在非ARC情况下的单例模式,我们将着重将目光放到内存管理的方法上去,首先我们可以先思考下,有哪些方法是用来进行内存管理的。这里就列举出来了:release、retain、retainCount、autorelease。下面就分别进行重写并说明(以上述GCD版为例):

1、首先是release方法

我们是不希望将我们的单例对象进行销毁掉的,那么很简单,重写release(需要将环境变为MRC,不然使用这些方法会报错)

1
2
3
4
- (oneway void)release
{
}

括号中的返回值是系统生成的,我们只需要将这个方法重写,然后不在里面写代码就可以了

2、retain方法

在这里面只需要返回这个单利本身就好了,不对引用计数做任何处理

1
2
3
4
- (instancetype)retain
{
return picturePlayer;
}

3、retainCount方法

这个方法返回的是对象的引用计数,我们已经重写了retain方法,不希望改变单例对象的引用计数,所以在这里返回1就好了

1
2
3
4
- (NSUInteger)retainCount
{
return 1;
}

4、autorelease方法

对这个方法的处理和retain方法类似,我们只需要将对象本身返回,不需要进行自动释放池的操作

1
2
3
4
- (instancetype)autorelease
{
return picturePlayer;
}

这样一来,在非ARC下的单例模式就写好了,下面是整合代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#import "NTPicturePlayer.h"
@implementation NTPicturePlayer
static id picturePlayer;
+ (instancetype)allocWithZone:(struct _NSZone *)zone
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
picturePlayer = [[super alloc]init];
});
return picturePlayer;
}
+ (instancetype)sharedPicturePlayer
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
picturePlayer = [[self alloc]init];
});
return picturePlayer;
}
- (id)copyWithZone:(NSZone *)zone
{
return picturePlayer;
}
- (oneway void)release
{
}
- (instancetype)retain
{
return picturePlayer;
}
- (NSUInteger)retainCount
{
return 1;
}
- (instancetype)autorelease
{
return picturePlayer;
}
@end
五、单例模式的代码实用化(封装便于开发直接使用)

我们或许会有这样的思路,将单例类放到工程中,然后让需要实现单例的类都继承于这个类,这个想法表面上是不错的,但是深入一点去研究的话,就会发现,这个单例类的所有子类所创建出来的单例都是一样的,这就未免不可行了,造成这个的原因是:在子类创建单例对象,实际上最根本上是调用了父类的alloc方法,而在父类中,只会存在一次创建对象,创建之后则是直接返回了创建好的那个单例。通俗来说,当一个子类创建单例对象的时候,调用到了父类的创建方法,获取到了这个单例对象,但如果第二个子类再创建单例对象,调用到父类的创建方法,这时候进行的操作不再是创建新的对象,而是返回第一个子类创建的对象。所以,这种利用继承关系来简化的方法是不可取的。
那么这个时候我们便可以考虑利用宏定义来进行代码的简化,因为我们比较刚刚写的三个单例类来说,代码有很大的相似度,我们可以抽取这些代码将他们定义成宏。在工程中创建一个专门放置宏的.h文件,创建方法是,新建文件->在iOS模块中选择Other->Empty->在Save As中填写类的名字,但是要记着加后缀.h->最后点击Create
这时候我们需要去分析下应该怎么去抽出代码,毕竟从刚刚所写的三个类还是有些差别,通过比较我们可以发现,有这些地方是不同的
在.h文件中:shared后面的名字是不同的
在.m文件中:定义的全局变量名字是不同的,shared后面的名字是不同的
所以我们不能够在宏中将这几个地方固定下来,可以发现这几个地方的名字都是和单例类的名字是有联系的,这里我们可以使用括号和#的关联作用来书写宏定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// .h文件代码
#define NTSingletonH(name) + (instancetype)shared##name;
// .m文件代码
#define NTSingletonM(name)\
static id instance;\
+ (instancetype)allocWithZone:(struct _NSZone *)zone\
{\
static dispatch_once_t onceToken;\
dispatch_once(&onceToken, ^{\
instance = [[super alloc]init];\
});\
return instance;\
}\
+ (instancetype)shared##name\
{\
static dispatch_once_t onceToken;\
dispatch_once(&onceToken, ^{\
instance = [[self alloc]init];\
});\
return instance;\
}\
- (id)copyWithZone:(NSZone *)zone\
{\
return instance;\
}

相比之前做了一些细节的优化,首先将全局变量的名字改为了instance,这样对于所有的类都是可以共用的,然后利用了括号和#号的联系来使宏定义变的灵活,我们使用的时候在宏定义的括号中敲出我们的单例对象名字就好了(注意由于name这个属性是直接拼接在了shared后面,所以我们在括号中写单例的名字的时候应该将首字母大写),最后要注意一点细节,对于很大一段代码,直接放到宏中是不能够识别的,所以这里我们需要使用 \ 这个符号,这个符号表示后面的一段是属于宏定义中的,所以我们在每条代码前面都添加上了这个符号。
这是ARC情况下的单例模式,那么在非ARC情况下的单例模式我们也要将其定义出来,再次用上述方法创建一个.h文件NTSingleton_MRC.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// .h文件代码
#define NTSingletonH(name) + (instancetype)shared##name;
// .m文件代码
#define NTSingletonM(name)\
static id instance;\
+ (instancetype)allocWithZone:(struct _NSZone *)zone\
{\
static dispatch_once_t onceToken;\
dispatch_once(&onceToken, ^{\
instance = [[super alloc]init];\
});\
return instance;\
}\
+ (instancetype)shared##name\
{\
static dispatch_once_t onceToken;\
dispatch_once(&onceToken, ^{\
instance = [[self alloc]init];\
});\
return instance;\
}\
- (id)copyWithZone:(NSZone *)zone\
{\
return instance;\
}\
- (oneway void)release\
{\
}\
- (instancetype)retain\
{\
return instance;\
}\
- (NSUInteger)retainCount\
{\
return 1;\
}\
- (instancetype)autorelease\
{\
return instance;\
}

这样,基本都做好的封装,但是有些人仍然觉得带上两个类很麻烦,可不可以将两个类封装成一个类,当然可以的,我们可以通过条件编译来进行处理,这里简要说明下条件编译,条件编译类似于if else的工作,但是原则上是有很大的不同,if else是在运行时进行处理,而条件编译是在编译时就进行处理,也就是说,使用条件编译,可以去在编译的时候检查环境是MRC还是ARC,然后跳转到相应的代码进行执行.。
说到这里,可能会想到对于MRC和ARC两个封装类来说,不同的地方就只是在于MRC添加了4个方法而已,那么我们就可以这样做,使用条件编译将这四个方法包装起来,检测到ARC的时候就不执行。这种想法是好的,但是在宏定义中却不是那么实际,因为在宏定义中#是有特殊的作用的,如果随意乱使用#,就会报错,所以我们还是老老实实在判断中写完两套代码吧,下面给出整个代码(整个代码可以收集,自己做一个类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// .h文件的代码
#define NTSingletonH(name) + (instancetype)shared##name;
// .m文件中的代码(使用条件编译来区别ARC和MRC)
#if __has_feature(objc_arc)
#define NTSingletonM(name)\
static id instance;\
+ (instancetype)allocWithZone:(struct _NSZone *)zone\
{\
static dispatch_once_t onceToken;\
dispatch_once(&onceToken, ^{\
instance = [[super alloc]init];\
});\
return instance;\
}\
+ (instancetype)shared##name\
{\
static dispatch_once_t onceToken;\
dispatch_once(&onceToken, ^{\
instance = [[self alloc]init];\
});\
return instance;\
}\
- (id)copyWithZone:(NSZone *)zone\
{\
return instance;\
}
#else
#define NTSingletonM(name)\
static id instance;\
+ (instancetype)allocWithZone:(struct _NSZone *)zone\
{\
static dispatch_once_t onceToken;\
dispatch_once(&onceToken, ^{\
instance = [[super alloc]init];\
});\
return instance;\
}\
+ (instancetype)shared##name\
{\
static dispatch_once_t onceToken;\
dispatch_once(&onceToken, ^{\
instance = [[self alloc]init];\
});\
return instance;\
}\
- (id)copyWithZone:(NSZone *)zone\
{\
return instance;\
}\
- (oneway void)release\
{\
}\
- (instancetype)retain\
{\
return instance;\
}\
- (NSUInteger)retainCount\
{\
return 1;\
}\
- (instancetype)autorelease\
{\
return instance;\
}
#endif

如果你看到了这里,我想,你对单例模式的掌握应该更深了^-^

参考资料
  • 《大话设计模式》
  • 《Objective-C编程之道-iOS设计模式解析》
  • 官方文档