OC这门语言,里面有三个概念“类别、扩展、协议”,听起来像同一个东西的三种说法,实际却是三个完全不同、各管一摊的技术。我刚学OC的时候,把它们混在一起记,后来发现:搞懂它们,才算真正入门了OC的动态特性。这篇文章想把三件事彻底摊开讲清楚:每个特性是什么、能解决什么问题、实际开发中怎么用才不会踩坑。
1. 类别:给系统类加方法的“魔法补丁”
1.1 类别到底是个什么玩意儿
类别(Category)在OC里干的事情很“霸道”——它可以不修改原有类的源码、不创建子类,直接给任何一个类添加新的方法。比如你想让NSString多一个“判断是否为合法邮箱”的方法,不用去继承NSString,也不用去改Foundation源码,写一个分类就搞定了。
// NSString+EmailValidation.h #import <Foundation/Foundation.h> @interface NSString (EmailValidation) - (BOOL)isValidEmail; @end // NSString+EmailValidation.m #import "NSString+EmailValidation.h" @implementation NSString (EmailValidation) - (BOOL)isValidEmail { NSString *pattern = @"^[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,4}$"; NSPredicate *predicate = [NSPredicate predicateWithFormat:@"SELF MATCHES %@", pattern]; return [predicate evaluateWithObject:self]; } @end然后任何地方都能这样用:
if ([@"test@example.com" isValidEmail]) { NSLog(@"合法邮箱"); }这感觉就像给一个已经装好的软件包又塞了一个新插件,不需要重写内部逻辑,也不影响外部调用方式。
1.2 类别的适用场景,为什么得用它
类别真正的用武之地有三个:
- 给系统类或第三方库添加能力。比如给
UIColor加一个“生成随机颜色”的类方法,给UIView加一个“快速设置圆角边框”的方法,这类高频操作如果你每次都要写一遍完整代码,重复劳动太多了。分类的好处是只写一次,谁用谁知道。 - 大型类按职责拆分实现文件。一个
ViewController动辄上千行,代码堆在一起很难维护。你完全可以把“网络请求”相关的方法放在一个分类文件里,把“UI搭建”放在另一个分类文件里,主实现文件只保留生命周期方法。这种组织方式在多人协作时尤其舒服,各改各的文件,冲突率直线下降。 - 按需加载某些方法。OC中分类的方法会被加载到类的方法列表中,但每个方法真正执行时才去查找。某些低频功能放到分类里,不会在启动时拖累主类。
1.3 类别的底层原理,三分钟搞明白
类别的底层逻辑其实不玄乎。在运行时,OC的每个类都维护着一个方法列表,类别的作用就是把方法“塞”进这个列表。如果某个方法在主类实现和分类实现中同名,运行时会以分类为准——也就是说,分类可以“覆盖”主类的方法。这一点在实际项目中很危险,后文我单独说。
类别还有几个限制:
- 不能直接为类添加实例变量。
- 不能直接声明新的属性(注意是“直接”,后面会讲怎么绕过)。
- 如果多个分类里有同名方法,谁生效取决于加载顺序,这个顺序是不保证的。
这里有个知识点经常被初学者忽略:分类中虽然不能直接添加属性,但是可以用关联对象(objc_setAssociatedObject)来模拟。简单理解,关联对象就是给对象额外挂接一份“外部数据”,不占用实例变量存储区。
// UIView+Gradient.h #import <UIKit/UIKit.h> @interface UIView (Gradient) @property (nonatomic, strong) NSArray *gradientColors; @end // UIView+Gradient.m #import "UIView+Gradient.h" #import <objc/runtime.h> static const char *kGradientColorsKey = "kGradientColors"; @implementation UIView (Gradient) - (void)setGradientColors:(NSArray *)gradientColors { objc_setAssociatedObject(self, kGradientColorsKey, gradientColors, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } - (NSArray *)gradientColors { return objc_getAssociatedObject(self, kGradientColorsKey); } @end这样从调用方看来,UIView对象多了一个gradientColors属性,但本质上它还是由一个静态全局key帮你存取的“挂件数据”。
1.4 新手会踩的坑:同名方法、私有API和滥用
类别看着美好,坑也不少。我挑几个高频的:
- 同名方法覆盖是薛定谔的。如果主类、分类、另一个分类里都有同一个方法,最后谁被调用是不确定的。更麻烦的是,它不一定报错,就是行为不对。所以分类方法命名一定要加前缀,比如研究发现
APP_、XX_这样的前缀,能有效降低撞车率。 - 不要覆盖系统类核心方法。比如你去给
NSArray的count加一个分类实现,看起来没问题,但一旦覆盖了系统方法,整个App运行时到处都在调用count,某个逻辑突然返回了错误结果,排查起来会怀疑人生。覆盖面越广的类,越要谨慎。 - 分类中的方法是“侵入式”美化的。很多新手拿到一个模型类,喜欢写一个分类把所有UI逻辑塞进去。短期很方便,长期会导致项目的核心模型和UI层藕断丝连,维护成本剧增。类别适合做“工具方法”和“逻辑隔离”,不适合做“UI聚合”。
提示:在Xcode中,如果某次编译后分类方法没有生效,先清理一次构建目录(Product -> Clean Build Folder)。这种问题多半是编译缓存导致的,别第一时间怀疑自己的代码。
2. 扩展:藏在.m文件里的“隐形手术刀”
2.1 扩展与类别:看着像,本质不同
扩展(Extension)也叫“匿名类别”,语法长这样:
@interface MyClass () @property (nonatomic, strong) NSString *privateProperty; - (void)doSomethingPrivate; @end注意类名后面直接跟一对小括号,括号里不写名字。它的代码通常放在主实现文件(.m)内部。这就是和类别最直观的区别。
但本质区别更大:扩展是在编译期生效的,类别是在运行期生效的。什么意思呢?扩展里声明的方法、属性、实例变量,会被直接编译进类的主实现中,编译器强制你必须在当前类的实现里写完这些方法,否则直接编译不过。类别则是在类的编译完成之后,通过运行时的动态方法解析“补丁”上去了。
用个生活类比:扩展是“动设计图”,在盖楼之前就把新增的楼层画进去,必须画完才算图纸完整;类别是“给楼贴标签”,楼盖完之后你可以在外侧贴一些提示牌,但不能改变原有的承重结构。
2.2 扩展的核心用途:藏起不想公开的东西
扩展最常见的应用是把私有属性和私有方法“藏进”.m文件里。
// MyViewModel.m #import "MyViewModel.h" @interface MyViewModel () @property (nonatomic, strong) NSString *requestToken; @property (nonatomic, assign) NSInteger retryCount; - (void)resetInternalState; @end @implementation MyViewModel - (void)resetInternalState { self.retryCount = 0; self.requestToken = nil; } - (void)loadData { [self resetInternalState]; // ... } @end外面的调用方即使拿到了MyViewModel实例,也只能看到.h文件里公开的属性方法,永远接触不到requestToken。这个特性在做SDK和框架时特别重要:你可以给使用者一个干净、精简的接口层,内部复杂的实现细节全用扩展藏起来。
2.3 扩展与属性的“编译期魔法”
扩展里可以重新定义属性的readwrite修饰。有时候你在.h里把一个属性声明为readonly,这是为了给外部提供只读能力;但在类内部,你想修改它的值。用扩展就能做到“接口只读,实现可写”:
// MyClass.h @interface MyClass : NSObject @property (nonatomic, readonly) NSInteger currentCount; @end // MyClass.m #import "MyClass.h" @interface MyClass () @property (nonatomic, readwrite) NSInteger currentCount; @end @implementation MyClass - (void)increaseCount { self.currentCount += 1; // 这里可以写 } @end这种写法在MVVM架构和状态管理组件里非常常见,外部拿到的永远是读到的值,内部可以自由变更。
2.4 扩展的使用建议:多用、但别乱用
扩展本身没有类别那么多坑,因为它纯编译期、行为确定。我的建议是每个类都尽量使用扩展,至少把内部状态属性和私有方法放进去。这样.h文件里只剩下真正的公开接口,阅读代码时一眼就能看清这个类对外承诺了什么。
有一点要注意:扩展里声明的方法,即使你平时不主动调用,也必须在实现中写出来,不然编译会报错。这是特点,别嫌麻烦。
3. 协议:OC里的“合同”与“接口”
3.1 协议为什么是OC的“接口层”
协议(Protocol)是OC实现接口抽象和回调机制的基石。它定义了一组方法的声明,任何类都可以“遵守”这份协议,并实现其中必要的方法。调用方不用关心对象到底是什么类型,只需要关心它是否遵守了协议。
用生活里的话说,协议就像一份“合同”:谁签了字,谁就必须按合同里写明的条款办事。比如“能充电”这个协议,定义了充电方法;你既可以造一个手机类遵守它,也可以造一个电动车类遵守它。两者都能充电,但是其他业务逻辑完全不需要互相知道对方是谁。
3.2 协议的语法与必选可选方法
协议的标准写法:
@protocol DownloadTaskDelegate <NSObject> @required - (void)downloadDidFinish:(id)task; @optional - (void)downloadProgressChanged:(float)progress; @end@required下面的方法是必须实现的,@optional下面的方法是可选的。这里<NSObject>表示这个协议本身又遵守了NSObject协议,这样它就能继承isEqual:、hash这一类基础方法名。
类遵守协议:
@interface DownloadManager : NSObject @property (nonatomic, weak) id<DownloadTaskDelegate> delegate; @end实现时要注意:@optional方法不代表你不用判断,恰恰因为可选,调用方必须用respondsToSelector:确认对象已经实现了它,再安全调用。
if ([self.delegate respondsToSelector:@selector(downloadProgressChanged:)]) { [self.delegate downloadProgressChanged:self.progress]; }3.3 委托模式与实践案例:写一个自己的Delegate
你肯定用过UITableViewDelegate和UINavigationControllerDelegate,它们都是协议的应用。现在自己动手写一个就更能理解了。假设我做了一个底部弹窗组件,想让它响应用户点击“确认”和“取消”两个按钮:
// BottomSheetView.h @protocol BottomSheetViewDelegate <NSObject> @optional - (void)bottomSheetDidTapConfirm:(BottomSheetView *)sheet; - (void)bottomSheetDidTapCancel:(BottomSheetView *)sheet; @end @interface BottomSheetView : UIView @property (nonatomic, weak) id<BottomSheetViewDelegate> delegate; @end // BottomSheetView.m @implementation BottomSheetView - (void)confirmButtonClicked { if ([self.delegate respondsToSelector:@selector(bottomSheetDidTapConfirm:)]) { [self.delegate bottomSheetDidTapConfirm:self]; } } @end然后任何控制器遵守协议,就能知道这个弹窗发生了什么:
// MyViewController.m #import "BottomSheetView.h" @interface MyViewController () <BottomSheetViewDelegate> @end @implementation MyViewController - (void)showSheet { BottomSheetView *sheet = [BottomSheetView new]; sheet.delegate = self; [self.view addSubview:sheet]; } - (void)bottomSheetDidTapConfirm:(BottomSheetView *)sheet { NSLog(@"用户点了确认"); } @end这里还有个细节:delegate属性要用weak修饰。因为delegate通常是持有弹窗的控制器,如果弹窗反过来强引用控制器,会造成循环引用,内存就泄漏了。
3.4 协议组合与多协议支持,比你想的更灵活
OC的类只能有一个父类,但可以同时遵守多个协议,这弥补了“单继承”在接口层面的限制:
@interface MyManager : NSObject <FirstProtocol, SecondProtocol, ThirdProtocol>你还可以让一个协议“继承”另一个协议,形成层级:
@protocol SubProtocol <ParentProtocol> @end这种组合方式在做统一抽象的时候很好用。比如定义一个“网络请求插件协议”,再让它继承“日志输出协议”,任何遵守它的类都天然具备两套能力。
3.5 协议和Block怎么选,说说我的选择标准
协议和Block都可以做回调,新手常纠结用哪个。我给一个非常实操的建议:
- 一个方法、一次回调、场景简单:用Block。比如请求成功回调,一个block传完数据就结束,不需要额外定义协议。
- 多个回调方法、对象生命周期长、需要跨对象通信:用协议。比如一个
SKStoreProductViewControllerDelegate,用户会不会点购买、会不会退出页面,回调方法很多,用协议更清晰。 - 回调行为可能被多个对象关注:用协议。Block天然是“单点回调”,很难多播;协议可以让一个对象同时成为多个对象的delegate,也可以让多个对象同时监听一个事件源(需要手动支持)。
4. 三者结合与实战排查手册
4.1 常规配方:类别 + 协议 + 扩展的组合用法
在实际项目中,三者经常混着用,很多开源框架都展示过这种配方。比如给某个系统类加一个能力,同时定义一组回调协议:
// UIImageView+Download.h @protocol ImageDownloadDelegate <NSObject> @optional - (void)imageDownloadDidFinish:(UIImageView *)imageView; @end @interface UIImageView (Download) @property (nonatomic, weak) id<ImageDownloadDelegate> downloadDelegate; - (void)downloadImageFromURL:(NSURL *)url; @end实现时,内部用dispatch_async下载图片,回来之后在主线程设置self.image,并回调代理。这里既有类别附加属性的关联对象技巧,也有协议定义回调的能力,整体非常灵活。
还有一种组合:在类的扩展里声明它遵守某个协议,让外部看不到这一事实:
// MyViewController.m #import "SomeUtilityDelegate.h" @interface MyViewController () <SomeUtilityDelegate> @end这是“对外的类完全不知道协议存在,但内部正在默默遵守协议处理消息”的模式,用来做私有集成很合适。
4.2 常见编译错误和崩溃排查实录
| 表现 | 原因 | 解决办法 |
|---|---|---|
| 分类里声明属性,编译报“Cannot synthesize” | 分类不能自动合成实例变量 | 用关联对象实现存取器,或者不要使用属性,改用getter/setter方法 |
| 扩展里的方法没写实现,编译报错 | 扩展在编译期强制要求方法完整 | 去主实现文件里把方法补齐 |
协议只有一个@required方法没有实现,但没有收到警告 | Xcode里警告有时默认只在某个方言级别提示 | 用performSelector或respondsToSelector做运行时判断,必要时打开-Wprotocol警告 |
| 分类方法不生效,调用还是走主类方法 | 分类可能没被编译进二进制,或者同名方法被后加载的分类覆盖 | 检查TARGETS -> Build Phases -> Compile Sources;去掉重复方法 |
使用objc_getAssociatedObject返回nil | key没有传入同一个地址 | 关联对象的key必须是一个静态指针,相同key才能取到相同数据 |
| 控制器持有delegate,导致控制器无法释放 | 使用强引用持有delegate | 所有delegate/dataSource属性一律用weak |
4.3 运行时的“隐藏技能”:用类别做方法交换
类别和运行时结合能玩出花活,最逆天的当属“方法交换”(Method Swizzling)。比如想在UIViewController每次viewWillAppear时统一打点统计,不需要改每个控制器,写一个分类并且交换方法就行:
// UIViewController+Tracking.m #import "UIViewController+Tracking.h" #import <objc/runtime.h> @implementation UIViewController (Tracking) + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ Class class = [self class]; SEL originalSelector = @selector(viewWillAppear:); SEL swizzledSelector = @selector(app_viewWillAppear:); Method originalMethod = class_getInstanceMethod(class, originalSelector); Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector); method_exchangeImplementations(originalMethod, swizzledMethod); }); } - (void)app_viewWillAppear:(BOOL)animated { [self app_viewWillAppear:animated]; NSLog(@"页面出现: %@", NSStringFromClass([self class])); } @end这种技巧对App启动次数统计、用户行为埋点、全局接口性能监测非常有用。但要注意,它不是银弹,滥用会导致崩溃和奇怪的调用顺序。我自己的经验是:方法交换只适合加料,不要改变原方法的核心逻辑。
另外还有个小细节:上面代码里我只交换了实例方法,如果是类方法,得用class_getClassMethod。交换之后,原本调用viewWillAppear:的代码实际上走进来的是app_viewWillAppear:,而app_viewWillAppear:内部又调用了原实现,这样就能在不破坏原有逻辑的基础上额外执行统计。
4.4 我建议的学习顺序和实际工作划分
如果你刚接触OC,建议按“协议 -> 扩展 -> 类别”的顺序去学,理由是这样的:
- 协议最接近纯粹的接口思想,能帮你理解OC的面向协议编程,看系统源码时不会迷路。
- 扩展非常基础,它就是写代码时用来收敛私有逻辑的工具,每天都会用到,应该首先养成习惯。
- 类别虽然也让代码爽,但因为它有同名覆盖、关联对象等高级特性,放到最后去消化,基础牢固之后才会真正把它用成技巧而不是隐患。
工作分配上,我的习惯很明确:
- 接口层能抽象成
Protocol就抽象成Protocol,不求多种类继承。 - 类内部的私有状态,全部扔进
Extension,不给外面留任何好奇的入口。 - 只有系统类能力和通用工具方法,才用
Category,并且方法名一律加项目前缀。
把这三样分清之后,你再看OC源码、看第三方库,会发现很多看似绕来绕去的设计,其实就是这三板斧的组合。有一个项目里全用@protocol解决了多个页面之间的事件通知,没写一行通知中心代码;也有一个项目因为某处加了多个分类,查了一个晚上的方法冲突。怎么说呢,工具本身没有对错,关键是你用它们的方式。
我个人的体会是,OC这三个特性最大的价值不是“炫技”,而是提供了一种非常轻量的代码组织手段。写代码的时候不需要为了加一个小功能去继承一个庞大的基类,也不需要为了隐藏一个私有属性去搞复杂的访问控制。把类别、扩展、协议用熟了,代码会自然变得干净、低耦合、好测试。