:继承、多态、分类、协议、Block与内存管理深度解析)
聊到“OC面向对象(下)”其实就是把那些写了好几年代码、用到烂熟却又未必真正想透过的东西一次性讲透。继承、多态、分类、协议、代理、Block、KVO、内存管理这些名词单独拎出来谁都认识但在真实项目里把它们组合起来、用得恰当且不埋雷才是这半部分的核心价值。这篇东西适合刚学完OC语法基础、准备往项目实战过渡的开发者也适合写Swift写久了回头补OC坑的老人——别的不说很多老项目的疑难杂症根子就在这一篇里。我把这些年实际踩过的坑、查过的源码、跟同事争论过的设计问题整理成一套能直接落地的思路。不搞学院派那套每一条都是真金白银换来的经验照着做至少能让你少熬几个夜。1. 三大特性的深入落地封装、继承、多态的实际写法OC的面向对象三大特性教科书上讲得都挺清楚但落到工程里其实有大量“约定大于语法”的东西。很多人写了两三年照样在封装、继承、多态上犯低级错误原因是只记住了语法没理解每种特性在工程里到底在解决什么问题。1.1 封装从实例变量到访问器先说封装。OC的封装在语法层面体现在两个地方实例变量默认是protected以及用property声明属性后自动生成getter/setter。很多人把property当成“方便”的代名词——点语法一点省事。但它的真实作用是为外部提供统一、可控的访问入口。这里有个关键认知property生成的setter不只是赋值它意味着你可以在赋值时做校验、通知、拷贝、加锁之类的附加动作。举个例子某个模型对象有个年龄字段你不能让它被外界直接塞一个-5进去所以在setter里做范围校验比在业务层做更可靠——因为业务层可能有多个入口而setter是唯一的。interface Person : NSObject property (nonatomic, assign) NSInteger age; property (nonatomic, copy) NSString *name; end implementation Person - (void)setAge:(NSInteger)age { if (age 0) { age 0; } else if (age 150) { age 150; } _age age; } end这也是为什么我特别强调给属性加readonly、copy、atomic这些修饰符时一定要想清楚这个属性的对外语义。比如NSString属性用copy为的是防止外部传一个NSMutableString进来之后被偷偷改掉NSArray、NSDictionary同理。这些不是死记硬背的规则是封装思想在OC里的具体体现。1.2 继承为什么重写init要考虑全局OC的继承有个容易翻车的地方子类重写init方法时总有人忘了先调super init或者调了却不处理返回值。- (instancetype)init { self [super init]; if (self) { _extraField default; } return self; }这行self [super init]不是形式主义。父类的init方法可能会返回一个不同的对象比如单例、类簇的私有子类如果你直接把super init的结果丢了后面给self赋值就全错。另一个工程里常见的坑是父类在init方法里调用了某个可被重写的方法。如果子类重写了这个方法并且这个方法里访问了子类自己的属性而子类属性此时还没初始化完毕就会拿到nil或0。这类问题极其隐蔽排查起来非常痛苦。我的习惯是父类初始化方法里只做自己的事不调用任何可被重写的方法。如果需要扩展点明确暴露一个带注释的模板方法出来。1.3 多态协议才是真正的解耦关键OC的多态主要通过两个机制实现继承体系和协议Protocol。但工程里用得更多的其实是协议因为OC是单继承靠继承做多态很容易把类体系搞得臃肿不堪。一个播放器功能可以定义协议protocol MediaPlayerProtocol NSObject required - (void)play; - (void)pause; - (void)stop; optional - (void)seekToTime:(NSTimeInterval)time; end然后音频播放器、视频播放器分别实现这个协议业务层只持有id MediaPlayerProtocol对象完全不关心具体类型。这就是多态的价值——让调用方和实现方彻底解耦。这个模式在实际项目中几乎是标配。它的核心不是语法而是接口设计能力哪些方法该放进required哪些该放进optional放进optional的方法调用时要做respondsToSelector:判断否则会崩溃。2. 分类、扩展与协议OC设计的真正精髓如果说封装、继承、多态是所有面向对象语言共通的概念那分类Category、扩展Extension、协议Protocol就是OC区别于其他语言的招牌。这三样东西用好了代码结构会非常清爽用不好就是一个大型事故现场。2.1 分类(Category)的边界与杀伤力分类的设计初衷是让开发者在不修改原类的情况下给类增加方法。比如给NSString增加一个判断是否为合法手机号的分类方法这是分类最常规的用法。interface NSString (PhoneNumberValidation) - (BOOL)isValidPhoneNumber; end implementation NSString (PhoneNumberValidation) - (BOOL)isValidPhoneNumber { NSString *regex ^1[3-9]\\d{9}$; NSPredicate *predicate [NSPredicate predicateWithFormat:SELF MATCHES %, regex]; return [predicate evaluateWithObject:self]; } end但分类有个必须牢记的特性同名方法会覆盖原类的方法而且多个分类之间的覆盖顺序不确定。这个覆盖是编译期行为还是运行期行为是运行期所以连报警告都无法完全避免。我见过有同事给UIViewController的viewDidLoad写了个分类版本结果整个App的页面初始化逻辑全乱了。分类的杀伤力还体现在另一个地方无法调用super。分类方法里用super是语法错误也就是说分类不能“先调原方法再补充逻辑”。如果确实需要在原有方法基础上增强要么用运行时方法交换Swizzling要么直接用继承。我个人的原则是单纯加新方法用分类需要修改原有方法行为优先考虑继承或方法交换但要极度谨慎绝不在分类里声明属性关联对象虽然在运行期能实现但滥用会让代码极难维护。2.2 扩展(Extension)与私有化扩展和分类写法上很像区别在于扩展没有名字声明在.m文件里所以它只能修饰自己这个类。这个特征让它成了“私有化”的利器。// Person.m 顶部 interface Person () property (nonatomic, strong) NSString *privateToken; - (void)doPrivateTask; end implementation Person // 在这里实现这些“私有”方法和属性 end用扩展声明的属性和方法对外部完全不可见但内部又能正常使用。这在细化接口、隐藏实现细节上特别有用——对外暴露的interface尽量精简内部需要的辅助成员全塞进扩展里头文件干净了整个项目的依赖关系也就清晰了。这里有一个跟分类配合的经典场景一个类需要对外暴露“只读”属性比如订单的总额但内部要能改。做法就是头文件里用readonly声明.m文件的扩展里用readwrite重新声明。编译器和运行期都认可这种写法内部能写、外部只能读完美。2.3 协议的两种用途协议在OC里有两张面孔一是代理Delegate模式二是数据源模式。其实本质一样只是语义侧重不同。代理模式的核心是事件回调——某个对象干了某件事通过协议把结果通知出去。比如网络请求完成、用户点击按钮、列表滑动到某个位置。数据源模式的核心是数据供给——某个对象不知道自己要展示的内容需要去问别的对象要。典型的例子是UITableViewDataSourcetable view不知道自己的行数、每行的内容得问数据源对象。这两个模式的区别在实际设计接口时很重要一个负责“通知”一个负责“请求”。很多新手设计协议时容易把这两类方法杂糅在一起导致实现方很困惑。我建议拆开回调类方法进代理协议查询类方法进数据源协议即使实现方是同一个对象也分开写可读性会好很多。3. 代理模式与Block的选型之争iOS开发里有个长期争论代理和Block到底用哪个两者都能实现回调但各自的适用场景差别很大。3.1 代理模式完整实现代理模式的标准结构是委托方持有id Protocol delegate事件发生时调用代理方法代理方遵循协议、设置自己为delegate、实现方法。protocol DownloadManagerDelegate NSObject optional - (void)downloadManager:(DownloadManager *)manager didFinishWithData:(NSData *)data; - (void)downloadManager:(DownloadManager *)manager didFailWithError:(NSError *)error; end interface DownloadManager : NSObject property (nonatomic, weak) id DownloadManagerDelegate delegate; - (void)startDownload; end注意这个weak极其关键。如果这里写strong或assign极容易产生循环引用或野指针。用weak是代理模式的铁律。代理模式有一个天然优势一个对象可以同时是多个对象的delegate。一个UIViewController既是table view的delegate又是scroll view的delegate还能是网络请求的delegate只要在tableView:didSelectRowAtIndexPath:里区分一下sender就能正确响应。这在复杂页面的组合场景里特别方便。3.2 Block的坑与生命周期Block的写法看起来更轻量DownloadManager *manager [[DownloadManager alloc] init]; [manager startDownloadWithCompletion:^(NSData *data, NSError *error) { if (error) { // 处理错误 } else { // 处理数据 } }];在封装独立小任务时Block确实比代理清爽——不需要写协议、不需要设置delegate、代码内聚在调用处。但Block有两大坑第一个坑是循环引用。Block内部如果强引用了外部对象而这个对象又强引用了这个Block比如对象的属性保存了这个Block那就形成环了。解决办法是在Block外声明__weak引用。__weak typeof(self) weakSelf self; [manager startDownloadWithCompletion:^(NSData *data, NSError *error) { __strong typeof(weakSelf) strongSelf weakSelf; if (strongSelf) { strongSelf-someProperty data; } }];这里的__strong是另一个技巧Block执行期间把weakSelf临时强引用一下防止执行到一半对象被释放。这个写法看起来繁琐但能避免一堆诡异崩溃。第二个坑是存储与生命周期。Block作为属性存储时必须用copy修饰否则在MRC时代会崩在ARC下语义也可能不对。现在ARC下大部分情况下编译器会帮你拷贝栈上的block但养成copy的习惯还是更稳妥。3.3 选型的实用标准直接给结论我多年项目的选型标准是这样的回调可能被多次触发比如进度上报、状态变化用代理回调只发生一次且逻辑内聚一次网络请求、一次操作完成用Block回调方法超过三个或需要区分大量事件用代理回调逻辑很轻量、只希望写在调用处用Block需要给多个对象同时回调代理天然支持一个对象多个delegate位Block也可以用数组存但作者心智负担较高两个对象之间是长期、固定的协作关系比如控制器和视图、网络层和业务层代理更合适临时性的协作Block更简洁。我的经验是代理适合“体系内”的设计Block适合“命令式”的编排。没有哪个绝对好乱用才是问题。4. KVC与KVO的底层逻辑与实操KVCKey-Value Coding和KVOKey-Value Observing是OC里一对很有“魔法感”的特性。用好它们能极大简化代码理解它们的底层逻辑能在出问题时快速定位。它们不是黑魔法背后都有明确的机制。4.1 KVC的查找机制KVC的核心方法是setValue:forKey:和valueForKey:。你以为它只是用key调用setter/getter实际查找流程比你想的复杂得多。当调用setValue:forKey:name时系统会按顺序尝试找setName:方法有就直接调用找不到看类方法accessInstanceVariablesDirectly是否返回YES默认是是则去找名为_name、_isName、name、isName的实例变量找到就直接赋值都找不到调用setValue:forUndefinedKey:默认实现是抛异常。所以KVC并不等同于调用setter它能直接操作实例变量这让很多绕过校验逻辑的“偷懒操作”成为可能也让一些人有机会把不想暴露的私有属性通过KVC改掉。KVC的另一个重要能力是键路径比如[person valueForKeyPath:department.business.name]可以直接穿透对象层级拿数据。这在写JSON解析、配置系统时特别省事。不过我的建议是普通业务代码里KVC适可而止它确实灵活但编译器帮不了你——key拼错了只有运行期崩排查成本高。倒是setValuesForKeysWithDictionary:在模型转换工具里可以放心用一旦发现key匹配不上setValue:forUndefinedKey:里统一处理即可。4.2 KVO的实现与移除KVO的实现机制稍微懂行的人都知道系统会在第一次观察某个对象时动态生成一个子类重写被观察属性的setter方法在setter里插入willChangeValueForKey:和didChangeValueForKey:的调用再把对象的isa指针指向这个子类。所以在断点时看到对象的类名变成了NSKVONotifying_Person别慌这是KVO在正常工作。使用KVO注册观察很简单[person addObserver:self forKeyPath:age options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];然后在observeValueForKeyPath:ofObject:change:context:里处理变化。KVO的坑集中在移除时机上。最怕的是重复移除——移除一个已经不存在的观察者会直接崩溃。常见的问题是对象已经释放了但观察者没有移除导致悬垂指针。我遇到过不止一次崩溃日志指向KVO实际原因是dealloc没写移除逻辑。苹果官方的建议是观察者在dealloc里移除或者在被观察对象释放前移除。多线程环境下尤其要小心注册和移除的配对关系一旦乱了就是崩溃现场。4.3 KVO在工程里的正确用法KVO能监听普通属性也能监听property声明的属性手动触发也支持automaticallyNotifiesObserversForKey:返回NO之后自己调willChange/didChange。工程里KVO最常见的场景监听模型变化刷新UI。但直接裸用KVO的问题在于观察回调集中在一个方法里多个key的多级判断让代码很丑而且注册/移除管理繁琐。业界都有封装方案比如用Block封装的KVO工具、基于消息转发的路由或者干脆用第三方框架。用之前想清楚取舍小项目裸KVO没问题大项目还是要有个统一管理层。我觉得KVO更适合“系统层面的观察”比如监听某个全局状态变化。它和代理、Block的定位不同代理是“一对一明确关系”Block是“临时内聚逻辑”KVO是“跨层被动感知”。三者并行使用才能覆盖各种协作模式。5. 对象生命周期与内存管理OC对象的内存管理是所有使用Objective-C的开发者都绕不过去的必修课。ARC普及之后手动管理时代的大部分痛苦消失了但ARC没有消灭内存管理的思维——它只是把一部分工作从手动变成了编译器插入代码你的思维跟不上照样写出泄漏、崩溃、诡异难查的Bug。5.1 从MRC到ARC的理解转变很多人对引用计数的理解停留在“ARC就是自动加retain/release”这话对但肤浅。引用计数Reference Count的含义其实很直白每个对象有一个计数器有人引用它计数加一引用结束计数减一归零对象被释放。MRC时代这个“加一减一”的时机要程序员自己在合适的位置写代码。ARC时代编译器在编译时替你插入相同逻辑的代码。区别在于编译器不仅分析每个方法内部的引用关系还要根据方法的命名约定推断所有权语义——所以alloc、new、copy、mutableCopy这些方法名不是随便起的它们是所有权转移规则的一部分。理解这层你就能明白ARC下你不需要写release但你不能写出“用完后还持有着一个根本不用的强引用”这样的代码因为编译器不知道你什么时候“不用”它只会按照作用域和生命周期规则的保守策略去插入内存管理代码。5.2 所有权修饰符与deallocARC下有四个所有权修饰符__strong默认、__weak、__unsafe_unretained、__autoreleasing。__strong是最常见的强引用持有谁谁就不能死。__weak是弱引用对象释放后自动变成nil这也是KVO、代理、Block捕获外部变量时最常用的修饰符。__unsafe_unretained和__weak功能类似但对象释放后不会置nil而是一个悬垂指针访问它就会崩溃所以除了兼容老代码和性能极端场景不建议使用。__autoreleasing用于延迟释放常在方法返回对象时由编译器自动处理手动场景多见于把即将返回的临时对象传出去。对象的dealloc方法是生命周期终结的最终一站。ARC下你不需要且不允许在dealloc里调用[super dealloc]但你仍然应该清理一些非ARC管理的资源通知观察者、KVO观察、CADisplayLink、NSTimer这些编译器不会自动帮你处理。- (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; [_timer invalidate]; }这个习惯一定要养成别把“ARC替你管理内存”理解成“啥都不用管”ARC只管引用计数的增减其他资源泄露它管不了。5.3 循环引用最隐蔽的内存泄漏循环引用是OC内存管理里最头大的问题。A强引用BB强引用A两者的引用计数都不归零都在内存里烂着而且很难通过Leaks工具直接看出来。最常见的三种循环引用场景第一Block捕获self。对象持有Block属性Block内部又用了self环就形成了。解法就是前面说的__weak。第二代理用strong修饰。委托方强引用delegatedelegate又强引用委托方两者互相抱死。解法就是代理一定要用weak。第三NSTimer的target保留。scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:如果repeats为YEStimer会强引用target如果你在target里又强持有了timer环就形成了。解法是用block版本的timer配合__weak或者在合适的时机invalidate。关于循环引用的排查我有个很实用的经验用Xcode的Instruments里的Leaks和Allocations工具先跑一波场景看到有的对象dealloc不知何故没被调用或者内存持续增长优先怀疑循环引用。还有一种情况网络层、持久层这类单例对象内部持有大量Block回调Block又捕获了业务对象这类“单例-块-对象”的环特别冷门但真实环境里很常见排查时也要想到。6. 实际项目中的常见问题与排查技巧前面讲了一堆理论最后这部分我们聚焦一些我在真实项目里实际踩过的坑每个都给结论和排查方法直接照着用。6.1 分类方法覆盖与主类方法的冲突分类方法覆盖主类方法这个问题我在第2节提过这里给具体的排查思路。现象你给UIViewController写了个分类实现了viewWillAppear:想统计页面曝光结果整个App的页面都出了问题很多页面自己的viewWillAppear:逻辑被覆盖了。原因分类方法在运行期确实会覆盖主类方法而且多个分类都实现了同名方法时哪个生效取决于加载顺序而加载顺序在链接期和编译选项影响下非常不可预测。排查方法用symbols或image lookup确认方法IMP实际指向哪个类或分类用class_copyMethodList列出类的全部方法列表找到重复的selector全局搜索这个selector看哪些分类实现了它。教训不要通过分类覆盖主类方法。真有必要用Swizzling并且保证只Swizzle一次加锁保护放在load或initialize里执行。6.2 多代理场景需求场景一个下载管理器同时有A页面、B页面、C组件都想监听它的下载进度。用传统单delegate模式根本做不到后设置的delegate会覆盖前面设置的。当时我用了一个简单的“多播代理”方案管理器持有一个NSHashTable里面放一堆id DownloadManagerDelegate用weak存储到NSHashTableWeak遍历调用时统一转发。实现思路property (nonatomic, strong) NSHashTableidDownloadManagerDelegate *delegates; - (void)addDelegate:(idDownloadManagerDelegate)delegate { [self.delegates addObject:delegate]; } - (void)removeDelegate:(idDownloadManagerDelegate)delegate { [self.delegates removeObject:delegate]; } - (void)notifyDidFinishWithData:(NSData *)data { for (idDownloadManagerDelegate delegate in self.delegates) { if ([delegate respondsToSelector:selector(downloadManager:didFinishWithData:)]) { [delegate downloadManager:self didFinishWithData:data]; } } }这个方案的好处是“观察者模式”的完整形态也能搭配KVO。坏处是调用者是弱引用如果某个delegate没被持有它会被提前释放导致监听收不到回调。所以更靠谱的工程方案是改造为“信号槽”式的统一事件总线或者直接用带Block堆栈的第三方事件框架。6.3 Block导致的循环引用检测Block循环引用虽然我用Instruments排查过但更快的方式还是“代码review时用眼睛找”。比较省力的一个检查步骤是看Block属性是否用了copy修饰看Block方法体内是否直接出现self-、self.、方法调用如果出现了看外面有没有__weak修饰的弱引用变量如果没有判断这个对象是否持有了这个Block——持有则循环引用不持有则安全。Xcode 14之后编辑器自带的Analysis工具已经能检测到一部分Block循环引用能标出环的参与者非常好用。建议平时提交代码前跑一次Product Analyze。另外一个容易忽略的点Block捕获了其他对象的强引用时即使没有直接用self也可能通过间接关系成环。比如self持有一个blockblock捕获了self的一个property对象而这个property对象又持有了self——这也是环。这类间接环静态分析工具不一定能发现还是得靠人脑推演。写在最后的几点个人体会从入行到现在OC面向对象这半部分的内容我翻了无数遍每遍都有新体会。最深的感触是语言特性是死的工程思维是活的。分类、协议、Block、KVC任何一个特性在项目里用得好不好取决于设计者是否清楚它的边界和代价。如果你正处在“语法都懂、写项目没底”的阶段我的建议是从今天开始给自己的代码立几条规矩比如“代理一律weak”“Block属性一律copy”“dealloc里清空非ARC资源”“分类只加方法不覆盖主类”先照着执行再慢慢理解每条规矩背后的道理。规矩立起来了坑踩得就少了。说实话很多复杂的线上问题追根溯源都是这些基础点的疏漏。把这个半部分的知识点吃透了你再看老代码、再写新功能思路都会完全不一样。