iOS VoIP应用
目录:
一、IP电话
1、 Voice Over IP(VoIP)
1.1、什么是VoIP?
1.2、iOS审核
1.3、为什么要使用VoIP?
二、相关概念
1、PushKit
1.1、概述
1.2、功能场景
1.3、支持
2、CallKit
2.1、概述 (对于CallKit我们并不陌生)
2.2、功能场景
2.3、支持
3、CTCarrier
3.1、概述
3.2、功能场景
3.3、支持
三、配置使用
1、唤醒应用,PushKit的配置步骤
1.1、注册VoIP Push Notification通知证书
1.2、客户端工程配置
1.3、代码配置
1.4、服务端
2、CallKit如何控制
2.1、关于CallKit
2.2、呼入、来电处理(CXProvider)
2.3、呼出,拨号处理(CXCallController)
2.4、来电拦截与号码识别(CXCallDirectoryExtension)
3、CTCarrier的使用
3.1、导入需要的头文件
3.2、调用API获取
3.3、使用获取到的信息
4、音视频SDK
4.1、基本流程
4.2、代码示例
四、总结
五、参考
一、IP电话
Voice Over IP (VoIP)
1、 Voice Over IP (VoIP)
1.1、什么是VoIP?
很多朋友都喜欢使用网络聊天工具来进行语音聊天,这种语音并不是通过电信运营商的传统电话网络进行传输,而是通过互联网进行传输。这种将语音转化为IP数据包,部分或全部基于IP网络传输的技术就是VoIP (Voice over IP,IP承载语音)技术。
基于IP的语音传输(英语:Voice over Internet Protocol,缩写为VoIP)是一种语音通话技术,经由网际协议(IP)来达成语音通话与多媒体会议,也就是经由互联网来进行通信。其他非正式的名称有IP电话(IP telephony)、互联网电话(Internet telephony)、宽带电话(broadband telephony)以及宽带电话服务(broadband phone service)。
概述:
互联网协议语音 (VoIP) 应用程序允许用户使用互联网连接而不是设备的蜂窝服务拨打和接听电话
IP电话(VoIP)是Apple提供给开发者的网络电话功能接口
会导致高能耗,庆幸应用不活跃时VoIP应用程序可以完全空闲以节省资源
1.2、iOS审核
来自苹果关于CallKit的一封邮件:
Apple Dear Developer,The Chinese Ministry of Industry and Information Technology (MIIT) http://www.miit.gov.cn/n1146285/ … n3057713/index.html requested that CallKit functionality be deactivated in all apps available on the China App Store.Since your app currently includes CallKit and is available for sale on the China App Store, you will need to submit an update that removes CallKit functionality in China.
VOIP call functionality continues to be allowed but can no longer take advantage of CallKit ’ s intuitive look and feel. CallKit can continue to be used in apps outside of China.
If you have questions or do not believe your app is subject to this update, please contact MIIT.
Best regards,
App Store Review
中国工业和信息化部(MIIT)要求在中国App Store上所有可用的应用程序上停用CallKit功能
CallKit可以继续在中国以外的应用程序中使用
因此,我们可以结合CTCarrier(文章接下来的使用介绍),来屏蔽某些地区的VoIP功能,但对于中国App Store,则必须删除CallKit功能
1.3、为什么要使用VoIP?
特点:
无需电脑。不会像电脑一样,因为中病毒,或者操作系统故障,导致不能正确使用。
适合保障长时间在线。内网互打,关键是要保持在线。
音质清晰。语音处理采用国际先进的语音处理芯片,有效解决回音消除,数据包丢失、网络抖动产生的问题。比软件电话的音质普遍要高。
成本价格低廉,可以随意改号,显示号码。
精致的功能:可监控,可录音,支持三方通、密语、来电转接等功能
使用场景:
网络电话:完全基于Internet传输实现的语音通话方式,一般是PC和PC之间进行通话
与公众电话网互联的IP电话:通过宽带或专用的IP网络,实现语音传输。终端可以是PC或者专用的IP话机
传统电信运营商的VoIP业务:通过电信运营商的骨干IP网络传输语音。提供的业务仍然是传统的电话业务,使用传统的话机终端。通过使用IP电话卡,或者在拨打的电话号码之前加上IP拨号前缀,这就使用了电信运营商提供的VoIP业务
VoIP可用于包括VoIP电话、智能手机、个人计算机在内的诸多互联网接入设备,通过蜂窝网络、Wi-Fi进行通话及发送短信。
二、相关概念
RELEVANT CONCEPS
1、 PushKit
1.1、概述
响应与应用程序的复杂性、文件提供商和VoIP服务相关的推送通知
1.2、功能场景
仅当发生 VoIP 推送时才会唤醒设备,从而节省能源。
与标准推送通知(用户必须在您的应用执行操作之前做出响应)不同,VoIP 推送会直接发送到您的应用进行处理。
VoIP 推送被视为高优先级通知,并且会立即传送
VoIP 推送可以包含比标准推送通知提供的数据更多的数据
如果您的应用程序在收到 VoIP 推送时未运行,则会自动重新启动
即使您的应用程序在后台运行,您的应用程序也有运行时间来处理推送
1.3、支持
iOS 8.0+
iPadOS 8.0+
macOS 10.15+
Mac Catalyst 13.0+
watchOS 6.0+
2、 CallKit
2.1、概述 (对于CallKit我们并不陌生)
为您的应用程序的IP电话(VoIP)服务显示系统呼叫UI,并将您的呼叫服务与其他应用程序和系统协调。
2.2、功能场景
通过它能实现IP电话(VoIP)应用程序集成到iPhone的用户界面
CallKit功能可以让第三方社交APP的语音通话与运营商提供的电话功能在体验上相一致
收到通讯请求时弹出系统的通话界面进行交互,它可以让应用程序调用系统的通话和通话记录界面
2.3、支持
iOS 10.0+
iPadOS 10.0+
macOS 10.15+
Mac Catalyst 13.0+
3、 CTCarrier
3.1、概述
有关用户蜂窝服务提供商的信息
3.2、功能场景
可以通过唯一标识符获取是否可以在其网络上进行VoIP通话,从而屏蔽某些地域VoIP功能
可以获取到手机服务提供商的名称、ISO国家/地区代码、移动国家/地区代码(MCC)、移动网络代码
3.3、支持
iOS 4.0+
iPadOS 4.0+
Mac Catalyst 13.1+
三、配置使用
CONFIGURE USE
1、唤醒应用,PushKit的配置步骤
1.1、注册VoIP Push Notification通知证书
需要注意:普通的推送分开发环境和生产环境,VoIP证书不进行区分,生产环境和开发环境是通用的。之后选择一个AppID并且上传前面生成的cerSigningRequest文件来完成VoIP证书的创建。
创建完成后,在证书列表可以看到多了个 VoIP服务证书,可以加载此证书进行VoIP推送。
1.2、客户端工程配置
配置push
配置后台获取模块、打开VoIP
配置依赖系统库
1.3、代码配置
导入需要的头文件
#import <PushKit/PushKit.h>
#import <UserNotifications/UserNotifications.h>
#import <AudioToolbox/AudioToolbox.h></pre>
注册本地通知、设置代理
PKPushRegistry *pushRegistry = [[PKPushRegistry alloc] initWithQueue:dispatch_get_main_queue()];
pushRegistry.delegate = self;
pushRegistry.desiredPushTypes = [NSSet setWithObject:PKPushTypeVoIP];
//ios10注册本地通知
if ([[UIDevice currentDevice].systemVersion floatValue] >= 10.0) {
//iOS 10
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center requestAuthorizationWithOptions:(UNAuthorizationOptionBadge | UNAuthorizationOptionSound | UNAuthorizationOptionAlert) completionHandler:^(BOOL granted, NSError * _Nullable error) {
if (!error) {
NSLog(@"request authorization succeeded!");
}
}];
[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) {
NSLog(@"%@",settings);
}];
}
代理实现
如果您的应用程序在收到推送时(pushRegistry: didReceiveIncomingPushWithPayload: forType:)未运行,您的应用程序将自动启动,您有30s的时间处理这个事件
#pragma mark -pushkitDelegate
- (void)pushRegistry:(PKPushRegistry *)registry didUpdatePushCredentials:(PKPushCredentials *)credentials forType:(NSString *)type{
if([credentials.token length] == 0) {
NSLog(@"voip token NULL");
return;
}
//应用启动获取token,并上传服务器
token = [[[[credentials.token description] stringByReplacingOccurrencesOfString:@"<"withString:@""]
stringByReplacingOccurrencesOfString:@">" withString:@""]
stringByReplacingOccurrencesOfString:@" " withString:@""];
//token上传服务器
//[self uploadToken];
}
- (void)pushRegistry:(PKPushRegistry *)registry didReceiveIncomingPushWithPayload:(PKPushPayload *)payload forType:(NSString *)type{
if ([[UIDevice currentDevice].systemVersion floatValue] >= 10.0) {
UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
content.body =[NSString localizedUserNotificationStringForKey:[NSString
stringWithFormat:@"%@%@", CallerName,
@"邀请你进行通话。。。。"] arguments:nil];;
UNNotificationSound *customSound = [UNNotificationSound soundNamed:@"voip_call.caf"];
content.sound = customSound;
UNTimeIntervalNotificationTrigger* trigger = [UNTimeIntervalNotificationTrigger
triggerWithTimeInterval:1 repeats:NO];
request = [UNNotificationRequest requestWithIdentifier:@"Voip_Push"
content:content trigger:trigger];
[center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {
NSLog(@"结束");
}];
}else {
callNotification = [[UILocalNotification alloc] init];
callNotification.alertBody = [NSString
stringWithFormat:@"%@%@", CallerName,
@"邀请你进行通话。。。。"];
callNotification.soundName = @"voip_call.caf";
[[UIApplication sharedApplication]
presentLocalNotificationNow:callNotification];
}
/**
初始化计时器 每一秒振动一次
@param playkSystemSound 振动方法
@return
*/
if(_vibrationTimer){
[_vibrationTimer invalidate];
_vibrationTimer = nil;
}else{
_vibrationTimer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(playkSystemSound) userInfo:nil repeats:YES];
}
}
}
//振动
- (void)playkSystemSound{
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate);
}1.4、服务端
参考苹果官方文档,向APNs服务发起推送,关注VoIP推送部分的详细说明
注意:重要的(客户端应提前做好CallKit部分向系统的通知,而不是先试通知是否能到达,如不慎发现被系统拉黑,突然在应用杀死的情况下收不到VoIP推送,可以尝试卸载重装应用)
在 iOS 13.0 及更高版本上,如果您未能向 CallKit 报告呼叫,系统将终止您的应用程序。多次未能报告呼叫可能会导致系统停止向您的应用程序发送更多 VoIP 推送通知。如果您想在不使用CallKit 的情况下发起 VoIP 呼叫,请使用用户通知框架而不是 PushKit注册推送通知。有关更多信息,请参阅用户通知。
2、 CallKit如何控制
2.1、关于CallKit
下图比较形象的表达了应用程序与CallKit以及系统的关系:
CXProvider:
主要负责通话流程的控制,向系统注册通话和更新通话的连接状态等。
代表电话提供商对象,VoIP应用程序应该创建一个全局的实例,供使用,与系统双向通信。
CXCallController:
主要负责执行对通话的操作
呼叫控制器,当用户在应用程序内部进行的通讯操作时,可以使用这个类来通知系统,与系统双向通信
2.2、呼入、来电处理(CXProvider)
通过CXProviderConfiguration初始化一个全局的CXProvider实例,并指定代理&可选队列
接收到VOIP通知后弹出通话界面,需要使用CXProvider传递CXCallUpdate给系统来进行控制。如下图:
使用全局的CXProvider实例传递CXCallUpdate调起通话界面的简单的代码:
CXCallUpdate * callUpdate = [[CXCallUpdate alloc]init];
callUpdate.supportsGrouping = YES;
callUpdate.supportsDTMF = YES;
callUpdate.hasVideo = YES;
callUpdate.supportsHolding = YES;
[callUpdate setLocalizedCallerName:nickName];
CXHandle * handle = [[CXHandle alloc]initWithType:CXHandleTypePhoneNumber value:from];
callUpdate.remoteHandle = handle;
[[self shareInstance].callProvider reportNewIncomingCallWithUUID:[self shareInstance].uuid update:callUpdate completion:^(NSError * _Nullable error) {
LOG(@"吊起界面");
}];
之后系统会将一些用户操作通过CSAction传递给APP,如下:
在CXProviderDelegate中处理这些系统回调事件
//当接收到呼叫重置时 调用的函数,这个函数必须被实现,其不需做任何逻辑,只用来重置状态
- (void)providerDidReset:(CXProvider *)provider;
//呼叫开始时回调
- (void)providerDidBegin:(CXProvider *)provider;
//音频会话激活状态的回调
- (void)provider:(CXProvider *)provider didActivateAudioSession:(AVAudioSession *)audioSession;
//音频会话停用的回调
- (void)provider:(CXProvider *)provider didDeactivateAudioSession:(AVAudioSession *)audioSession;
//行为超时的回调
- (void)provider:(CXProvider *)provider timedOutPerformingAction:(CXAction *)action;
//有事务被提交时调用
//如果返回YES 则表示事务被捕获处理 后面的回调都不会调用 如果返回NO 则表示事务不被捕获,会回调后面的函数
- (BOOL)provider:(CXProvider *)provider executeTransaction:(CXTransaction *)transaction;
//点击开始按钮的回调
- (void)provider:(CXProvider *)provider performStartCallAction:(CXStartCallAction *)action;
//点击接听按钮的回调
- (void)provider:(CXProvider *)provider performAnswerCallAction:(CXAnswerCallAction *)action;
//点击结束按钮的回调
- (void)provider:(CXProvider *)provider performEndCallAction:(CXEndCallAction *)action;
//点击保持通话按钮的回调
- (void)provider:(CXProvider *)provider performSetHeldCallAction:(CXSetHeldCallAction *)action;
//点击静音按钮的回调
- (void)provider:(CXProvider *)provider performSetMutedCallAction:(CXSetMutedCallAction *)action;
//点击组按钮的回调
- (void)provider:(CXProvider *)provider performSetGroupCallAction:(CXSetGroupCallAction *)action;
//DTMF功能回调
- (void)provider:(CXProvider *)provider performPlayDTMFCallAction:(CXPlayDTMFCallAction *)action;
锁屏和应用程序在后台的效果分别如下所示:
通过CXProviderDelegate相关函数来处理系统通话界面的某些操作回调给应用程序。
2.3、呼出,拨号处理(CXCallController)
APP中进行的操作如果需要通知系统,需要使用CXCallController通过CXTransaction传递。
创建一个新的呼叫控制器CXCallController,可用指定队列创建。获取活动调用观察者CXCallObserver指定观察者的代理CXCallObserverDelegate
实例化一个CXStrartCallAction(发起呼出呼叫的行为封装),由唯一标识呼叫的UUID和指定接收方的对象CXHandle组成
再使用CXStrartCallAction实例创建CTTransaction实例
通过CXCallController的requestTransaction....将行为通知给系统
let uuid = UUID()let handle = CXHandle(type: .emailAddress, value: "jappleseed@apple.com") let startCallAction = CXStartCallAction(call: uuid)startCallAction.destination = handle
let transaction = CXTransaction(action: startCallAction)callController.request(transaction) { error in
if let error = error {
print("Error requesting transaction: \(error)")
} else {
print("Requested transaction successfully")
}}
接收者接听电话后,系统调用CXProvider的代理方法:
接听者在这个方法中配置音视频回话(第2部分有举例)
//点击开始按钮的回调
- (void)provider:(CXProvider *)provider performStartCallAction:(CXStartCallAction *)action;
APP中的其他操作也可以举一反三:例如App内的通讯需要添加到系统的历史通话列表:
通过CXCallObserverDelegate回调来处理系统事件
extension ViewController: CXCallObserverDelegate {
func callObserver(_ callObserver: CXCallObserver,callChanged call: CXCall) {
if call.isOutgoing {
print("电话播出")
if call.hasConnected {
print("电话接通")
operation(state: CurrentState.HasConnected)
}
if call.hasEnded {
print("电话挂断")
operation(state: CurrentState.HasEnded)
}
if call.isOnHold {
print("无人接听挂断")
operation(state: CurrentState.IsOnHold)
}
} else {
print("other error")
}
}}
2.4、来电拦截与号码识别(CXCallDirectoryExtension)
上面介绍了CallKit的结合PushKit&VoIP可实现的通讯功能,有通讯功能就需要进行联系人识别与黑名单。
社交网络应用程序有一个Call Directory应用程序扩展程序,可以下载并添加用户所有朋友的电话号码。
因此,当用户接到Jane的来电时,系统会显示类似“(应用程序名称)来电显示:Jane Appleseed”而不是“未知来电者”。
CallKit框架中还有一部分内容可以结合Call Directory Extension来实现号码拦截与识别。
Call Directory Extension:
CXCallDirectoryProvider:主机应用程序的Call Directory应用程序扩展的主要对象。
CXCallDirectoryExtensionContext:用于向Call Directory应用程序扩展添加标识和阻止条目的程序化界面。
CXCallDirectoryExtensionContextDelegate:当请求失败时,Call Directory扩展上下文对象调用的方法集合。
CXCallDirectoryManager:管理Call Directory应用程序扩展的对象的程序化界面。
2.4.1、创建应用扩展target
选择Call Directory Extension:
主程序会自动生成Call Directory应用程序扩展的主要对象(继承自CXCallDirectoryProvider的Handler)。
应用程序通知扩展程序更新号码库(切换app账号等场景)
CXCallDirectoryManager的api就比较简单了,凭场景组合使用:
CXCallDirectoryManager
/// 单例
@property (readonly, class) CXCallDirectoryManager *sharedInstance;
/// 利用扩展程序bundle id(在extension的info.plist中)来查询是否开启来电识别
func getEnabledStatusForExtension(withIdentifier: String, completionHandler: (CXCallDirectoryManager.EnabledStatus, Error?) -> Void)
/// 通知系统更新(主app与extension的数据通信、参考group共享数据的编程方式来实现)
func reloadExtension(withIdentifier: String, completionHandler: ((Error?) -> Void)?)
/// 打开设置页面
func openSettings(completionHandler: ((Error?) -> Void)?)
扩展程序中的增删电话操作
系统默认生成的应用程序扩展的主要对象中已经替我们优雅的生成了必要的代码,我们仅需取group(主程序给扩展程序使用的数据)中取出来设置就可以
override func beginRequest(with context: CXCallDirectoryExtensionContext) {
context.delegate = self
// Check whether this is an "incremental" data request. If so, only provide the set of phone number blocking
// and identification entries which have been added or removed since the last time this extension's data was loaded.
// But the extension must still be prepared to provide the full set of data at any time, so add all blocking
// and identification phone numbers if the request is not incremental.
if context.isIncremental {
addOrRemoveIncrementalBlockingPhoneNumbers(to: context)
addOrRemoveIncrementalIdentificationPhoneNumbers(to: context)
} else {
addAllBlockingPhoneNumbers(to: context)
addAllIdentificationPhoneNumbers(to: context)
}
context.completeRequest()}
CXCallDirectoryExtensionContext是操作上下文,不需要初始化,会自系统的方法传递出来
//是否支持增量更新
@property (nonatomic, readonly, getter=isIncremental) BOOL incremental API_AVAILABLE(ios(11.0));
//添加一个黑名单号码
- (void)addBlockingEntryWithNextSequentialPhoneNumber:(CXCallDirectoryPhoneNumber)phoneNumber;
//移除一个黑名单号码
- (void)removeBlockingEntryWithPhoneNumber:(CXCallDirectoryPhoneNumber)phoneNumber API_AVAILABLE(ios(11.0));
//移除所有的黑名单号码
- (void)removeAllBlockingEntries API_AVAILABLE(ios(11.0));
//添加一个身份识别
- (void)addIdentificationEntryWithNextSequentialPhoneNumber:(CXCallDirectoryPhoneNumber)phoneNumber label:(NSString *)label;
//移除一个身份识别
- (void)removeIdentificationEntryWithPhoneNumber:(CXCallDirectoryPhoneNumber)phoneNumber API_AVAILABLE(ios(11.0));
//移除所有身份识别
- (void)removeAllIdentificationEntries API_AVAILABLE(ios(11.0));
//完成操作后 需要手动调用此函数
- (void)completeRequestWithCompletionHandler:(nullable void (^)(BOOL expired))completion;
3、 CTCarrier的使用
3.1、导入需要的头文件
#import <CoreTelephony/CTTelephonyNetworkInfo.h>
#import <CoreTelephony/CTCarrier.h>
3.2、 调用API获取
CTTelephonyNetworkInfo *info = [[CTTelephonyNetworkInfo alloc] init];
CTCarrier *carrier = [info subscriberCellularProvider];
//运营商可用
BOOL use = carrier.allowsVOIP;
//运营商名字
NSString *name = carrier.carrierName;
//ISO国家代码
NSString *code = carrier.isoCountryCode;
//移动国家代码
NSString *mcc = [carrier mobileCountryCode];
//移动网络代码
NSString *mnc = [carrier mobileNetworkCode];
NSLog(@"================SIM卡信息================\n运营商可用:%d\n运营商名字:%@\nISO国家代码:%@\n移动国家代码:%@\n移动网络代码:%@\n",use,name,code,mcc,mnc);
3.3、使用获取到的信息
================SIM卡信息================
运营商可用:1
运营商名字:中国联通
国家代码:cn
移动国家代码:460
移动网络代码:01
4、 音视频SDK
4.1、基本流程
4.2、代码示例
使用 App ID初始化音视频SDK,并指定代理回调
private lazy var mediaSDKEngine: MediaSDKEngineKit = MediaSDKEngineKit.sharedEngine(withAppId: <#Your AppId#>, delegate: self)
设置好ChannelProfile和本地预览视图
override func viewDidLoad() {
super.viewDidLoad()
mediaSDKEngine.setChannelProfile(.communication)
let canvas = MediaSDKVideoCanvas()
canvas.uid = 0
canvas.view = localVideoView
canvas.renderMode = .hidden
mediaSDKEngine.setupLocalVideo(canvas)}
在MediaSDKEngineDelegate的远端用户加入频道事件中设置远端视图:
extension ViewController: MediaSDKEngineDelegate {
func mediaSDKEngine(_ engine: MediaSDKEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) {
let canvas = MdiaSDKVideoCanvas()
canvas.uid = uid
canvas.view = remoteVideoView
canvas.renderMode = .hidden
engine.setupRemoteVideo(canvas)
remoteUid = uid
remoteVideoView.isHidden = false
}}
实现通话开始、静音、结束的方法:
extension ViewController {
func startSession(_ session: String) {
mediaSDKEngine.startPreview()
mediaSDKEngine.joinChannel(byToken: nil, channelId: session, info: nil, uid: 0, joinSuccess: nil)
}
func muteAudio(_ mute: Bool) {
mediaSDKEngine.muteLocalAudioStream(mute)
}
func stopSession() {
remoteVideoView.isHidden = true
mediaSDKEngine.leaveChannel(nil)
mediaSDKEngine.stopPreview()
}}一个简单的视频通话应用搭建就完成了。双方只要调用startSession(_:)方法加入同一个频道,就可以进行视频通话。
四、总结
SUMMARY
VoIP应用,iOS中的使用总结为以下4点:
1、利用PushKit中的VoIP推送唤醒应用,处理业务逻辑
2、利用CallKit来与系统电话UI双向通信,做到传统运营商电话体验
3、利用CTCarrier来获取运营商信息,从而屏蔽某些地区VoIP功能
4、简单介绍了音视频SDK的基本工作流程,结合VoIP推送特点,提前准备好响应用户CallKit交互所需功能
五、参考资料
REFERENCES
文献:
Voice Over IP (VoIP) Best Practices https://developer.apple.com/library/archive/documentation/Performance/Conceptual/EnergyGuide-iOS/OptimizeVoIP.html#//apple_ref/doc/uid/TP40015243-CH30
CallKit https://developer.apple.com/documentation/callkit?language=objc
PushKit https://developer.apple.com/documentation/pushkit?language=objc
CTCarrier https://developer.apple.com/documentation/coretelephony/ctcarrier#//apple_ref/doc/c_ref/CTCarrier
应用扩展编程指南 https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/index.html#//apple_ref/doc/uid/TP40014214-CH20-SW1