那些设计iOS API需要知道的事
原文地址:http://chars.tech/2017/07/09/...
为了能够将我们项目中的代码能够在后续开发者使用(重用代码),通常使用的方法是将代码按照功能模块编写成API。那么我们就很有必要了解Objective-C语言中常见的编程范式(paradigm),同时还需了解各种可能碰到的陷阱。
命名
命名冲突的问题
Objective-C没有其他语言的那种内置命名空间(namespace)机制。因此,我们只能自己想办法来解决命名冲突问题。最常用的解决方式就是,仿照其他语言(C++)建立自己的namespace,例如,使用前缀。
所选前缀可以是与公司、应用程序或二者皆有关联之名。例如,ZAKER User Interface可以使用ZUI作为前缀。使用Cocoa创建应用程序时一定要注意,Apple宣称其保留使用所有“两字母前缀”(two-letter prefix)的权利,所以开发者选用的前缀应该是三个字母的。如果开发者使用了两个字母作前缀,那么很有可能开发者自定义的API和Apple的API冲突。
不仅仅是类名,应用程序中的所有名称都应该加前缀。如果要为既有类新增“分类”(category),那么一定要给“分类”及“分类”中的方法加上前缀。另外,类的实现文件中所用的纯C函数及全局变量也应该注意添加前缀。
如果使用了第三方库编写自己的代码,并准备将其发布为程序库供他人开发应用程序所用,则尤其要注意重复符号问题。这种情况下为了避免使用者使用了与你相同的第三方库,应该为第三方库都加上你自己的前缀。
命名方式
类、方法和变量的命名是Objective-C编程的重要环节。如果命名方式好,可以提高代码可读性,减少不必要的注释。
初学者通常会觉得Objective-C是门很繁琐的语言,因为其语法结构使得代码读起来和句子一样。命名中一般都带有“in”、“for”、“with”等介词,特别是在命名时还要讲究英文语法。例如:
NSString *text = @"This is a good idea."; NSString *newText = [text stringByReplacingOccurrencesOfString:@"idea" withString:@"think"];
上面的代码虽然用了比较啰嗦的方式描述一个看上去很简单的表达式。对于执行替换的那个方法,代码读起来就像日常语言里的那个句子:“Take text and give me a new string by replacing the occurrences of the string 'idea' with the string 'think'”。
这个句子准确描述了开发者想做的事。在命名不像Objective-C这般繁琐的语言中,类似的程序可能会写成:
string text = "This is a good idea."; string new Text = text.replace("idea", "think");
上面代码这样写,看起来方法名简洁很多,但是带来的代码不可读性却是非常大的。首先,我们不知道 text.replace 方法的两个参数到底按照什么顺序解读(除非查看方法声明);再者,这两个参数谁替换谁?
另外,和大多数语言一样,Objective-C也是采用“驼峰式大小写命名法”(camel casing)——以小写字母开头,其后每个单词首字母大写。
方法命名
清晰的方法名从左至右读起来好似一段文章。并不是说非得按照那些命名规则来给方法起名,不过这样做可以令代码变得更好维护,使他人更容易读懂。
虽然类似C++或Java中那种函数命名简单,但是,若想知道每个参数的用途,就得查看函数原型,这会令代码难于读懂。
NSString这个类展示了一套良好的命名习惯。下面列举几个方法及命名缘由:
1)+ (instancetype)string;
工厂方法(factory method),用于创建新的空字符串。方法名清晰地描述了返回值的类型。
2)+ (instancetype)stringWithString:(NSString *)string;
工厂方法,根据某字符串创建出与之内容相同的新字符串。与创建空字符串所用的那个工厂方法一样,方法名的第一个单词也指明了返回类型。
3)+ (instancetype)localizedStringWithFormat:(NSString *)format, ...;
工厂方法,根据特定格式创建出新的“本地化字符串”(localized string)。返回值类型是方法名的第二个单词(string),因为其前面还有个修饰语(localized)用来描述其逻辑含义。此方法的返回值依然是“字符串”(string),只不过是一种经过本地化处理的特殊字符串。
4)- (NSUInteger)lengthOfBytesUsingEncoding:(NSStringEncoding)enc;
若字符串是以给定的编码格式(ASCII、UTF8、UTF16)来编码的,则返回其字节数组长度。此方法与length相似,但该方法还需一个参数,该参数紧跟着方法名中描述其类型的那个名词(encoding)。
因此,我们可以总结成几条方法命名规则:
1)如果方法的返回值是新创建的,那么方法名的首个词应该是返回值的类型,除非前面还有修饰语,例如localizedString。属性的存取方法不遵循这种命名方式,因为一般认为这些方法不会创建新对象。即便有时返回内部对象的一份拷贝,我们也认为那相当于原有对象。这些存取方法应该按照其所对应的属性来命名。
2)应该把表示参数类型的名词放在参数前面。
3)如果方法要在当前对象上执行操作,那么就应该包含动词;若执行操作时还需要参数,则应该在动词后面加上一个或多个名词。
4)不要使用str这种简称,应该使用string这样的全称。
5)boolean属性应加is前缀。如果某方法返回非属性的boolean值,那么应该根据其功能,选用has或is当前缀。
6)将get这个前缀留给那些借由“输出参数”来保存返回值的方法,比如说,把返回值填充到“C语言式数组”(C-style array)里的那种方法就可以使用这个词做前缀。
类与协议命名
不仅仅是方法,类和协议也应该加上前缀,避免命名空间冲突。例如:
- UIView
- UIViewController
- UITableViewDelegate
错误模型
目前有很多编程语言都有“异常”(exception)机制,Objective-C也不例外。
“自动引用计数”(ARC, Automatic Reference Counting)在默认情况下不是“异常安全的”。这意味着:如果抛出异常,那么本应该在作用域末尾释放的对象现在却不会自动释放了。如果想生成“异常安全”的代码,可以通过设置编译器的标志来实现,不过这将引入额外代码,在不抛出异常时,也照样要执行这部分代码。需要打开的编译器标志叫做-fobjc-arc-exception
。
Objective-C现在所采用的办法是:只在极其罕见的情况下抛出异常,异常抛出之后,无须考虑恢复问题,而且应用程序此时也应该退出。这就是说,不用再编写复杂的“异常安全”代码了。
异常只应该用于极其严重的错误,比如,你编写了某个抽象基类,它的正确用法是先从中继承一个子类,然后使用这个子类。在这种情况下,如果有人直接使用了这个抽象基类,那么可以考虑抛出异常。与其他语言不同,Objective-C中没办法将某个类标识为“抽象类”。要想达成类似效果,最好的办法是在那些子类必须覆写的超类方法里抛出异常。
异常只用于处理严重错误(fatal error),对于其他错误,Objective-C语言所用的编程范式为:令方法返回nil/0,或使用NSError,以表明有错误发生。
NSError对象里封装了三条信息:
- Error domain (错误范围,其类型为字符串)
错误发生的范围,也就是产生错误的根源,通常用一个特有的全局变量来定义。例如,URL-handling-subsystem,在从URL中解析或获取数据时如果出错了,那么就使用NSURLErrorDomain来表示错误范围。
- Error code (错误码,其类型为整数)
独有的错误码,用以指明在某个范围内具体发生了何种错误。某个特定范围内可能会发生一系列相关错误,这些错误情况通常采用enum来定义。
- User info (用户信息,其类型为字典)
有关此错误的额外信息,其中或许包含一段“本地化描述”,或许还包含有导致该错误发生的另外一个错误,经由此种信息,可将相关错误串成一条“错误链”。
使用不可变对象
设计类的时候,应充分使用属性来封装数据。而在使用属性时,则可将其声明为readonly
。默认情况下,属性是readwrite
。
因为如果把可变对象(mutable object)放入collection之后又修改其内容,那么很容易就会破坏set的内部数据结构,使其失去固有的语义。故此,我们应该尽量减少对象中的可变内容。具体到编程实践中,则应该尽量把对外公布出来的属性设为readonly
,而且只在有必要时才将属性对外公布。
定义类的公共API时,需要注意,对象里表示各种collection的那些属性究竟应该设成可变的,还是不可变的。如果某个属性可以为外界所增删,那么这个属性就需要用可变的set来实现。在这种情况下,通常应该提供一个readonly属性供外界使用,该属性将返回不可变的set,而此set则是内部那个可变set的一份拷贝。
// ZKRPointOfInterest.h #import <UIKit/UIKit.h> @interface ZKRPointOfInterest : NSObject @property (nonatomic, copy, readonly) NSString *identifier; @property (nonatomic, copy, readonly) NSString *title; @property (nonatomic, assign, readonly) CGFloat latitude; @property (nonatomic, assign, readonly) CGFloat longitude; @property (nonatomic, strong, readonly) NSSet *locations; - (instancetype)initWithIdentifier:(NSString *)identifier title:(NSString *)title latitude:(CGFloat)latitude longitude:(CGFloat)longitude; - (void)addLocation:(ZKRPointOfInterest *)location; - (void)removeLocation:(ZKRPointOfInterest *)location; @end // ZKRPointOfInterest.m #import "ZKRPointOfInterest.h" @implementation ZKRPointOfInterest { NSMutableSet *_internalLocations; } - (instancetype)initWithIdentifier:(NSString *)identifier title:(NSString *)title latitude:(CGFloat)latitude longitude:(CGFloat)longitude { self = [super init]; if (self) { } return self; } - (NSSet *)locations { return [_internalLocations copy]; } - (void)addLocation:(ZKRPointOfInterest *)location { if (location) { [_internalLocations addObject:location]; } } - (void)removeLocation:(ZKRPointOfInterest *)location { [_internalLocations removeObject:location]; } @end
注意:不要在返回的对象上查询类型以确定其是否可变。(即使不用isKindOfClass:
方法来判断返回值类型是否可变)
description方法
在调试程序时,经常需要打印并查看对象信息。一种办法是编写代码把对象的全部属性都log到日志中。NSLog(@"object=%@", object);
在构建需要打印到日志的字符串时,object对象会收到description消息,该方法所返回的描述信息将取代“格式字符串”(format string)里的“%@”。
NSArray *obj = @[@"A string", @(123)]; NSLog(@"object=%@", obj);
输出:
object=( "A string", 123 )
如果在自定义类上这么做,那么则输出的信息却是如下:
object=<ZKRSqure: 0x7656d8a90060>
如果想要像上面NSArray那样打印出有用的信息,那么我们就应该在自己的类中覆写description方法,否则打印信息时就会调用NSObject类所实现的默认方法。此方法定义在NSObject协议里,不过NSObject类也实现了它。
- (NSString *)description { return [NSString stringWithFormat:@"<%@: %p, \"%f %f\">", [self class], self, _width, _height]; }
使用结果:
ZKRRectangle *rectangle = [[ZKRRectangle alloc] initWithWidth:5.0 height:7.0]; NSLog(@"%@", rectangle); //Output <ZKRRectangle: 0x60000002fc20, "5.000000 7.000000">
NSObject协议中还有个需要注意的方法,就是debugDescription
,此方法用意与description
相似。二者区别在于,debugDescription
方法是开发者在调试器(debugger)中以控制台命令打印对象时才调用的。在NSObject类的默认实现中,它只是直接调用description
。
初始化方法
所有对象均要初始化,在初始化时,有些对象可能无须开发者向其提供额外信息,不过一般来说还是需要提供的。通常情况下,对象若不知道必要的信息,则无法完成其工作。例如,UITAbleViewCell类初始化该类对象时,需要指明其样式及标识符,标识符能够区分不同类型的单元格。由于这种对象的创建成本较高,所以绘制表格时可依照标识符来复用,以提升程序效率。这种可为对象提供必要信息以便其能完成工作的初始化方法叫做“全能初始化方法”(designated initializer)。
如果创建类实例的方式不止一种,那么这个类就会有多个初始化方法。但是,我们仍然需要选定一个作为全能初始化方法,令其他初始化方法都来调用它。例如,NSDate类
- (instancetype)init NS_DESIGNATED_INITIALIZER; - (instancetype)initWithTimeIntervalSinceReferenceDate:(NSTimeInterval)ti NS_DESIGNATED_INITIALIZER; - (nullable instancetype)initWithCoder:(NSCoder *)aDecoder NS_DESIGNATED_INITIALIZER; - (instancetype)initWithTimeIntervalSinceNow:(NSTimeInterval)secs; - (instancetype)initWithTimeIntervalSince1970:(NSTimeInterval)secs; - (instancetype)initWithTimeInterval:(NSTimeInterval)secsToBeAdded sinceDate:(NSDate *)date;
在上面几个初始化方法中,initWithTimeIntervalSinceReferenceDate:
是全能初始化方法。只有在全能初始化方法中,才会存储内部数据。这样的话,当底层数据存储机制改变时,只需修改此方法的代码就好,无须改动其他初始化方法。
示例代码:
// ZKRRectangle.h #import <UIKit/UIKit.h> @interface ZKRRectangle : NSObject<NSCopying> @property (nonatomic, assign, readonly) CGFloat width; @property (nonatomic, assign, readonly) CGFloat height; - (instancetype)initWithWidth:(CGFloat)width height:(CGFloat)height; @end // ZKRRectangle.m #import "ZKRRectangle.h" @implementation ZKRRectangle - (instancetype)initWithCoder:(NSCoder *)aDecoder { self = [super init]; if (self) { _width = [[aDecoder decodeObjectForKey:@"width"] floatValue]; _height = [[aDecoder decodeObjectForKey:@"height"] floatValue]; } return self; } - (instancetype)init { @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Must use initWithWidth:height: instad." userInfo:nil]; return [self initWithWidth:0 height:0]; } - (instancetype)initWithWidth:(CGFloat)width height:(CGFloat)height { self = [super init]; if (self) { _width = width; _height = height; } return self; } @end // ZKRSquare.h #import "ZKRRectangle.h" @interface ZKRSquare : ZKRRectangle - (instancetype)initWithDimension:(CGFloat)dimension; @end // ZKRSquare.m #import "ZKRSquare.h" @implementation ZKRSquare - (instancetype)init { @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Must use initWithDimension: instad." userInfo:nil]; return [self initWithDimension:0]; } - (instancetype)initWithDimension:(CGFloat)dimension { return [super initWithWidth:dimension height:dimension]; } - (instancetype)initWithWidth:(CGFloat)width height:(CGFloat)height { @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Must use initWithDimension: instad." userInfo:nil]; CGFloat dimension = MIN(width, height); return [self initWithDimension:dimension]; } @end
小结
- 在类中提供一个全能初始化方法,并于文档里指明。其他初始化方法均调用此方法。
- 若全能方法于超类不同,则需要覆写超类中的对应方法。
- 如果超类的初始化方法不适用于子类,那么应该覆写这个超类方法,并在其中抛出异常。
NSCopying协议
使用对象时经常需要拷贝它。在Objective-C中,此操作通过copy方法完成。如果想令自己的类支持拷贝操作,那就要实现NSCopying协议,该协议只有一个方法:
- (id)copyWithZone:(nullable NSZone *)zone;
为什么会出现NSZone呢?因为以前开发程序时,会据此把内容分成不同的“区”(zone),而对象会创建在某个区里面。现在不用了,每个程序只有一个区:“默认区”(default zone)。所以说,尽管必须实现这个方法,但是你不必担心其中的zone参数。
copy方法由NSObject实现,该方法只是以“默认区”为参数来调用copyWithZone:
。我们总是想覆写copy方法,其实真正需要实现的是copyWithZone:
方法。若想使某个类支持拷贝功能,只需声明该类遵从NSCopying协议,并实现其中的那个方法即可。
- (id)copyWithZone:(NSZone *)zone { ZKRRectangle *copy = [[[self class] allocWithZone:zone] initWithWidth:_width height:_height]; return copy; }
说到copy方法,除了NSString这样的不可变类型的copy,与之类似的还有NSMutableString类的mutableCopy
方法。与copyWithZone:
方法相对应的可变内容的copy方法mutableCopyWithZone:
方法来自于NSMutableCopying
协议。如果你的类分为可变版本(mutable)与不可变版本(immutable),那么就应该实现NSMutableCopying协议。若采用此模式,则在可变类中覆写copyWithZone:
方法时,不要返回可变的拷贝,而应该返回一份不可变的版本。无论当前实例是否可变,需要获取其可变版本的拷贝,均应调用mutableCopy方法;获取不可变版本的拷贝,则总应该通过copy方法。
深拷贝就是在拷贝对象自身时,将其底层数据也一并复制过去。
浅拷贝就是在拷贝对象时,只拷贝容器对象本身,而不复制其中数据。