Objective-C类别、扩展、协议:三个概念彻底讲透
OC这门语言里面有三个概念“类别、扩展、协议”听起来像同一个东西的三种说法实际却是三个完全不同、各管一摊的技术。我刚学OC的时候把它们混在一起记后来发现搞懂它们才算真正入门了OC的动态特性。这篇文章想把三件事彻底摊开讲清楚每个特性是什么、能解决什么问题、实际开发中怎么用才不会踩坑。1. 类别给系统类加方法的“魔法补丁”1.1 类别到底是个什么玩意儿类别Category在OC里干的事情很“霸道”——它可以不修改原有类的源码、不创建子类直接给任何一个类添加新的方法。比如你想让NSString多一个“判断是否为合法邮箱”的方法不用去继承NSString也不用去改Foundation源码写一个分类就搞定了。// NSStringEmailValidation.h #import Foundation/Foundation.h interface NSString (EmailValidation) - (BOOL)isValidEmail; end // NSStringEmailValidation.m #import NSStringEmailValidation.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 ([testexample.com isValidEmail]) { NSLog(合法邮箱); }这感觉就像给一个已经装好的软件包又塞了一个新插件不需要重写内部逻辑也不影响外部调用方式。1.2 类别的适用场景为什么得用它类别真正的用武之地有三个给系统类或第三方库添加能力。比如给UIColor加一个“生成随机颜色”的类方法给UIView加一个“快速设置圆角边框”的方法这类高频操作如果你每次都要写一遍完整代码重复劳动太多了。分类的好处是只写一次谁用谁知道。大型类按职责拆分实现文件。一个ViewController动辄上千行代码堆在一起很难维护。你完全可以把“网络请求”相关的方法放在一个分类文件里把“UI搭建”放在另一个分类文件里主实现文件只保留生命周期方法。这种组织方式在多人协作时尤其舒服各改各的文件冲突率直线下降。按需加载某些方法。OC中分类的方法会被加载到类的方法列表中但每个方法真正执行时才去查找。某些低频功能放到分类里不会在启动时拖累主类。1.3 类别的底层原理三分钟搞明白类别的底层逻辑其实不玄乎。在运行时OC的每个类都维护着一个方法列表类别的作用就是把方法“塞”进这个列表。如果某个方法在主类实现和分类实现中同名运行时会以分类为准——也就是说分类可以“覆盖”主类的方法。这一点在实际项目中很危险后文我单独说。类别还有几个限制不能直接为类添加实例变量。不能直接声明新的属性注意是“直接”后面会讲怎么绕过。如果多个分类里有同名方法谁生效取决于加载顺序这个顺序是不保证的。这里有个知识点经常被初学者忽略分类中虽然不能直接添加属性但是可以用关联对象objc_setAssociatedObject来模拟。简单理解关联对象就是给对象额外挂接一份“外部数据”不占用实例变量存储区。// UIViewGradient.h #import UIKit/UIKit.h interface UIView (Gradient) property (nonatomic, strong) NSArray *gradientColors; end // UIViewGradient.m #import UIViewGradient.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; endrequired下面的方法是必须实现的optional下面的方法是可选的。这里NSObject表示这个协议本身又遵守了NSObject协议这样它就能继承isEqual:、hash这一类基础方法名。类遵守协议interface DownloadManager : NSObject property (nonatomic, weak) idDownloadTaskDelegate 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) idBottomSheetViewDelegate 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 常规配方类别 协议 扩展的组合用法在实际项目中三者经常混着用很多开源框架都展示过这种配方。比如给某个系统类加一个能力同时定义一组回调协议// UIImageViewDownload.h protocol ImageDownloadDelegate NSObject optional - (void)imageDownloadDidFinish:(UIImageView *)imageView; end interface UIImageView (Download) property (nonatomic, weak) idImageDownloadDelegate 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返回nilkey没有传入同一个地址关联对象的key必须是一个静态指针相同key才能取到相同数据控制器持有delegate导致控制器无法释放使用强引用持有delegate所有delegate/dataSource属性一律用weak4.3 运行时的“隐藏技能”用类别做方法交换类别和运行时结合能玩出花活最逆天的当属“方法交换”Method Swizzling。比如想在UIViewController每次viewWillAppear时统一打点统计不需要改每个控制器写一个分类并且交换方法就行// UIViewControllerTracking.m #import UIViewControllerTracking.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这三个特性最大的价值不是“炫技”而是提供了一种非常轻量的代码组织手段。写代码的时候不需要为了加一个小功能去继承一个庞大的基类也不需要为了隐藏一个私有属性去搞复杂的访问控制。把类别、扩展、协议用熟了代码会自然变得干净、低耦合、好测试。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →