文章目前大约有11000字,全篇读完差不多需要70分钟!未完待续……
前言
地狱之门:1971年苏联地质学家进行钻探时,意外地发现了一个充满天然气的地下洞穴。钻探装置下的一片泥土倒塌,留下一个直径约50-100米的大洞。为了防止有毒气体外泄,他们决定点燃漏出来的天然气。截至今日,洞口的火焰从未间断过。当地人称之为“地狱之门”。
地狱之门是一个巨大的天然气坑,号称世界首坑。而创建iOS的SDK基本上也相当于点燃了坑中的天然气,不费点时间和力气,还真是扑灭不了心中的怒火。
起因
先打个谜一样的广告,我所在的公司是一家极其伟大的公司,恩~公开场合不能说违心话,哈哈!公司的产品是一款主要面向初高中学生的在线教育App软件,孩子们只要把不会的题目用我们的App拍下来,就能够在1秒之内得到关于这道题目的结果,包括答案,解析,考点,老师评语,相似题目等很多非常实用的内容。到目前为止,只要你拍的题目是在K12教育体系之内并且图片清晰,那么几乎所有的题目都能搜到答案。如果孩子们看了结果之后还有疑惑,可以点击结果页面的【老师答疑】按钮,我们会根据这道题目的属性(哪个年级、哪门学科、哪个知识点)把孩子跟老师直接关联起来,除了实时同步老师和学生的声音之外,我们还能同步老师的笔记,能让孩子们感觉老师就像在自己身边辅导一样。我们平台上的老师都是在某个学科深耕一二十年的大牛级人物,他/她们会把这道题目用温柔的声音耐心地讲到孩子们听懂为止。
每天叫醒我的不是闹钟,而是我们公司的口号:改变中国的教育,让孩子们的学习不再煎熬!
看到这里,我想你已经猜出来了,没错,这里就是新闻联播的逗逼访谈节目。
咱们回到直播间,正是因为这么牛逼的产品,被某黑心度的分发渠道下架了,还做了个几乎跟我们产品没什么两样的东西。
既然能被某度盯上,那肯定也能被其他正经公司看上了。于是乎,各大公司高管万众来朝,想要在他们的App中也加入我们公司的核心产品 —— 拍照搜题和老师答疑。
显而易见,在技术层面,摆在眼前只有两种合作模式。第一,我们只提供服务器端的接口,由对方自己完成App端的实现;第二,我们提供App端的SDK(对于iOS来说,就是提供一个Framework),对方拿到我们的SDK,拖拽到自己的工程中,然后简单的调用一两个方法,就完成了拍照搜题和老师答疑的功能。最后的结果,毫无意外,肯定是选第二种方案,毕竟人家是金主,花钱买省心。然而,从技术角度来看,也是采用第二种方案更为合理。如果采用第一种方案,先抛开老师答疑的复杂性和可行性,就拍照搜题来说,对方公司就要花极大的力气来完成App端的实现,比如拍照页面、截图页面、答案展示页面等所有相关页面的设计与实现;还有数据库的存取、各种交互动画等,尤其是图片压缩算法,众所周知,iPhone拍的照片都有2、3M大小,就算是经过裁切,也还有800K左右,在我们的App端,我们会对裁剪后的图片进行预处理,根据图片的清晰度选择对应的压缩系数,既要让图片尽可能的压缩,又要能确保图片传到服务器之后能够被识别出来。要知道,我们服务器每天会处理几千万甚至上亿张的图片,只要能让每一张图片多压缩1KB,那都是能省出好大一笔费用的。此外,图片压缩的越小,网络传输的时间就越少,这对提升App的用户体验也有相当大的作用。就我罗列的那些拍照搜题功能的实现,对方公司要是从零开始,对于iOS来说,怎么也得数月时间才能完成吧,要是再加上老师答疑,排期得用年来计算了吧(半年也是年)。
终于,拍照搜题和老师答疑的SDK任务就像是一块陨石砸在了我的头上,由于这块石头太大,刚砸的时候没感觉,一个星期下来,反应迟钝的我开始了剧烈的疼痛,痛得我恨不得直接把代码给人家。如今,一个月过去了,每天躲在角落里舔着伤口,总算也不怎么疼了。
既然不疼了,那就把过去一个月的疼痛再回味一下吧。
看到现在,都是些满满的湿货,再不来点干货,估计要被唾沫淹死了!
iOS SDK 基础(库和框架)
SDK – Software Development Kit:软件开发包,这是一个抽象且包罗万象的名称。在本文中,具体是指iOS系统中的库(Library)或者框架(Framework)。
库是一段编译好的二进制代码,加上.h头文件就可以给别人使用了。既然是已经编译好的代码,被嵌入到App中运行的时候,这段代码就不需要再次编译,直接链接就可以了,某种程度上这也是缩短编译时间的一种方法吧(虽然很少有人做库是因为这个)。
iOS中的库分为静态库(Static Library,一般是以.a为扩展名的文件)和动态库(Dynamic Library,一般是以.dylib为扩展名的文件,自从Xcode7以后,很多.dylib文件变成了.tbd文件,名字没变,后缀名变了,这是苹果用来减小动态库体积做的一些优化,具体细节想要深究可查看这篇文章)。在生成静态库和动态库的时候,只需要编译就可以了,没有链接过程,更不存在运行状态。他们的区别主要是在被嵌入到App中运行之前的链接阶段,在这个过程中,静态库会被直接加载到内存中,不管App是否用到了静态库中的代码。而且,如果在同一个iOS设备上不同的App都使用了某个静态库,那么内存里就可能会有多份该静态库的拷贝。而动态库在链接之后,只会把动态库的引用链接到App中,只有当程序运行时需要用到库中的代码,才会真正的把库加载进内存,相对于静态库,动态库可以被多个程序共享使用。
在iOS中,库和框架严格来说是有一点区别的。
库仅仅是一堆代码的集合,不包括.h头文件,也不包括诸如.storyboard文件、.xcassets图片集合文件以及其他资源文件。而框架就能够在库的基础上包含这些资源。其实说白了,框架就是一种打包方式,是一种特殊的bundle文件。框架也分静态框架和动态框架,其链接原理跟静态库和动态库类似,不再赘述。
在iOS中,不存在真正意义上的第三方动态库和动态框架,只存在苹果自己的动态库和动态框架。比如,UIKit.framework、Foundation.framework、AVFoundation.framework等才是真正意义上的动态框架。这些框架在很多App中都会调用,但是在内存中只有一份拷贝给各个App共享。虽然自从iOS8之后,苹果允许制作第三方的动态框架,但是不同的App如果使用同一份动态框架,在内存中还是会存在多份拷贝,只不过在链接阶段还是链接动态框架的引用而已。
为什么苹果在iOS8的时候允许制作第三方动态框架呢?那是因为iOS8推出了Swift语言,而Swift代码只能编译成动态库或动态框架。想深究原因,给出网址,自行查看。网址1 网址2 这两个链接中的内容主要表达的意思是:目前Swift代码无法编译成静态框架;想要编译成静态框架,静候佳音吧。
因此,如果你想要把包含Swift语言的代码打包成静态框架,目前来说是无法实现的。
Swift的推出还直接导致了CocoaPods 0.36.0版本的延迟发布。在只有Objective-C的年代,CocoaPods把导入的第三方代码打包成一个静态库(libPods.a)嵌入到App中执行。Swift语言推出之后自然就有了很多Swift版本的第三方库(如AFNetworking的Swift版本Alamofire),只要你的项目中Pod了含有Swift代码的第三方库,那么在Podfile文件中就必须写上 use_frameworks!
,而一旦加上这个Flag,那么CocoaPods就会把所有Pod的第三方库都生成动态框架(Pod_项目名字.framework)嵌入到App中执行。因此,这是一种要么全是静态框架,要么全是动态框架的策略。想要更多八卦,再来看一篇相关文章
静态框架进阶一
想要搞清楚SDK的开发,那还是来点实践吧,Demo有点多,我会尽一切可能阐述清楚。
先来制作一个静态框架(BuddhaSDK.Framework,我网名叫燃灯古佛,就以Buddha来命名吧)!
Xcode7.3.1 + Objecitve-C
从网上能搜到很多关于静态库(xxx.a)的制作教程,其中大多都是Xcode6之前的。因为在Xcode6之前,苹果只提供了Cocoa Touch Static Library
模板。这个模板只能制作静态库(xxx.a),如果想要制作静态框架(xxx.framework),还是要费点力气的,不过你可以借用第三方的力量(iOS-Universal-Framework)来实现。随着Xcode6的发布,苹果新增了一个Cocoa Touch Framework
模板,这意味着Xcode6支持框架开发了,同时iOS-Universal-Framework
开发者也宣布不再继续维护此项目并建议开发者使用官方模板来开发。
静态库(xxx.a)和静态框架(xxx.framework)的区别上文已经有所阐述,考虑到方便性、整体性、专业性以及与时俱进,我觉得,哪怕再简单的库,还是建议制作成静态框架来提供给他人使用吧。
恩!那就开始了。。。
首先新建一个Cocoa Touch Framework
模板的工程。
然后取个名字(BuddhaSDK),选择好工程文件存放的位置,就算新建好了。
这时候你会看到,这个模板自带的文件其实就两个,一个 BuddhaSDK.h
文件,一个 Info.plist
文件。还有一个奇怪的红色 BuddhaSDK.framework
文件,你可以在这个文件上右击并点击 Show in Finder
,会发现什么也没发生,因为根本不存在这个文件,所以才会显示红色。而我们最关心的就是这个文件,接下来就是要让它变成黑色并实现我们想要的功能。
对于 BuddhaSDK.h
文件和 Info.plist
文件,确实有不少东西可以讲,下文慢慢分析吧,再扯会犊子!
那我们做个什么功能呢,网上大多都是各种log,那我就来个AES加解密和Base64编解码吧,争取在小白的崇山峻岭中占据制高点。如果你不知道什么叫做AES和Base64,那真是极好的,我去拿船桨,咱们友谊的小船轻轻荡起来吧。
App中总是会有很多敏感的信息需要在客户端和服务器端之间传输,比如用户的手机号和密码、我们公司针对图片搜索到的答案解析等等,这些都是公司的核心资产,不加个密伪装一下真不好意思在网络上走来走去的。不过,安全问题光靠对数据的加解密肯定是行不通的,需要许许多多的防护措施和前后端的配合。就光对数据的加解密算法来说,很多公司也是需要制作一个静态框架的,有些敏感的信息不光要防着外界的黑客,还得防着公司里别有用心的灰太狼。犊子扯完,回到主题!
来到Xcode,开始撸代码吧,新建一个 CanNotSee
的类,继承自 NSObject
类。
在 CanNotSee.h 文件中声明4个方法,如下:
/**
* base64编码
*/
- (NSString *)encodeWithBase64:(NSString *)sourceString;
/**
* base64解码
*/
- (NSString *)decodeWithBase64:(NSString *)encodedString;
/**
* AES加密(ECB模式 PKCS7填充 128位)
*/
- (NSData *)encryptWithAES:(NSString *)sourceString;
/**
* AES解密(ECB模式 PKCS7填充 128位)
*/
- (NSString *)decryptWithAES:(NSData *)encryptedData;
在 CanNotSee.m 文件中实现这4个方法,如下:
#import <CommonCrypto/CommonCrypto.h>
#pragma mark - Base64
// base64编码 (NSString -> NSData -> NSString)
- (NSString *)encodeWithBase64:(NSString *)sourceString {
NSData *sourceData = [sourceString dataUsingEncoding:NSUTF8StringEncoding];
NSString *encodedString = [sourceData base64EncodedStringWithOptions:0];
return encodedString;
}
// base64解码 (NSString -> NSData -> NSString)
- (NSString *)decodeWithBase64:(NSString *)encodedString {
NSData *encodedData = [[NSData alloc] initWithBase64EncodedString:encodedString options:0];
NSString *decodedString = [[NSString alloc] initWithData:encodedData encoding:NSUTF8StringEncoding];
return decodedString;
}
#pragma mark - AES
// AES加密(ECB模式 PKCS7填充 128位)
- (NSData *)encryptWithAES:(NSString *)sourceString {
NSData *sourceData = [sourceString dataUsingEncoding:NSUTF8StringEncoding];
// 准备秘钥(128位,16个字节)
char keyPtr[kCCKeySizeAES128+1];
bzero(keyPtr, sizeof(keyPtr));
[@"abcdefghigklmnop" getCString:keyPtr maxLength:sizeof(keyPtr) encoding:NSUTF8StringEncoding];
// 准备加密后数据的容器
NSUInteger dataLength = [sourceData length];
size_t bufferSize = dataLength + kCCBlockSizeAES128;
void *buffer = malloc(bufferSize);
memset(buffer, 0, bufferSize);
size_t dataOutMoved = 0;
NSInteger cryptStatus = CCCrypt(kCCEncrypt, // 加密
kCCAlgorithmAES128, // 128位
kCCOptionECBMode | kCCOptionPKCS7Padding, // ECB模式(0x0000或者默认都是CBC模式)
keyPtr, // 秘钥
kCCKeySizeAES128, // 秘钥长度
NULL, // 初始化向量
[sourceData bytes], // 未加密数据
dataLength, // 未加密数据的长度
buffer, // 加密后的数据
bufferSize, // 加密后数据的大小
&dataOutMoved // 加密后数据的位移
);
if (cryptStatus == kCCSuccess) {
NSData *resultData = [NSData dataWithBytesNoCopy:buffer length:dataOutMoved];
return resultData;
}
free(buffer);
return nil;
}
// AES解密(ECB模式 PKCS7填充 128位)
- (NSString *)decryptWithAES:(NSData *)encryptedData {
// 准备秘钥
char keyPtr[kCCKeySizeAES128+1];
bzero(keyPtr, sizeof(keyPtr));
[@"abcdefghigklmnop" getCString:keyPtr maxLength:sizeof(keyPtr) encoding:NSUTF8StringEncoding];
// 准备解密后数据的容器
NSUInteger dataLength = [encryptedData length];
size_t bufferSize = dataLength + kCCBlockSizeAES128;
void *buffer = malloc(bufferSize);
memset(buffer, 0, bufferSize);
size_t dataOutMoved = 0;
NSInteger cryptStatus = CCCrypt(kCCDecrypt, // 解密
kCCAlgorithmAES128, // 128位
kCCOptionECBMode | kCCOptionPKCS7Padding, // ECB模式(0x0000或者默认都是CBC模式)
keyPtr, // 秘钥
kCCKeySizeAES128, // 秘钥长度
NULL, // 初始化向量
[encryptedData bytes], // 加密数据
dataLength, // 加密数据长度
buffer, // 解密后的数据
bufferSize, // 解密后数据的大小
&dataOutMoved // 解密后数据的位移
);
if (cryptStatus == kCCSuccess) {
NSData *resultData = [NSData dataWithBytesNoCopy:buffer length:dataOutMoved];
return [[NSString alloc] initWithData:resultData encoding:NSUTF8StringEncoding];
}
free(buffer);
return nil;
}
Base64编解码的具体实现其实很简单,核心代码两行就能搞定了。
AES加解密稍微复杂一点,它分好几种模式,还有几种填充算法以及不同的加密位数等,不过这么多分支在使用上其实差不多,主要就是准备好所需的参数,然后调用系统提供的方法就可以了,本文不做深究。仅扩展一点,AES是一种对称加密算法,这个时候,请你再瞧一下上面的代码,可以看到AES加解密的具体实现几乎没多大区别,这时你心中是不是有种莫名的顿悟,原来对称加密算法是这个意思呀。哈哈,那就掉坑里了。所谓对称加密,最重要一点就是加解密的算法中使用的是同一个秘钥,跟代码写的对不对称无关。在上面的代码中,@"abcdefghigklmnop"
就是秘钥。因此,前后端的秘钥如果有更改,必须要周知相关人员。
其实,Base64和AES算法主要是对NSData的操作,最好的写法是对NSData进行分类(Categroy),这点下文会继续分析,并且还能挖到Categroy在静态框架中的几个小坑。
恩,到这里,编解码和加解密的功能都实现了,我们的目的是给框架使用者提供若干个接口,但是不能让他们看到具体的实现细节。
回到 BuddhaSDK.h
文件,忽略其他东西,在最后一行有这么一句话:
In this header, you should import all the public headers of your framework using statements like #import <BuddhaSDK/PublicHeader.h>
显而易见,那就在这句话的下面写上:#import <BuddhaSDK/CanNotSee.h>
这样,在 CanNotSee.h
里的4个方法的声明接口就能够被暴露出来了。
不过,难道这样就好了吗?是不是总觉得哪里不对劲。我怎么能确保 CanNotSee.m
这个文件确确实实在最后生成的静态框架中被隐藏起来了呢,而且,难道只有系统生成的 BuddhaSDK.h
文件才能够被暴露出来吗?肯定有个地方能让我们指定哪些文件可以暴露,哪些文件可以隐藏。
恩,直觉真是靠谱!点击 工程文件 -> TARGETS - > Build Phases
,展开 Headers
,能看到三个分组,分别是 Public
、Private
、Project
。
这三组的意思苹果也给出了相应的解释:
Public: The interface is finalized and meant to be used by your product’s clients. A public header is included in the product as readable source code without restriction.
Private: The interface isn’t intended for your clients or it’s in early stages of development. A private header is included in the product, but it’s marked “private”. Thus the symbols are visible to all clients, but clients should understand that they’re not supposed to use them.
Project: The interface is for use only by implementation files in the current project. A project header is not included in the target, except in object code. The symbols are not visible to clients at all, only to you.
从这个解释里可以看出,Private对于使用框架的人来说是没什么关系的,制作框架的时候也几乎不会把文件放在Private组之下。因此,该组可以忽略。剩下Public和Project组的作用也就很明显了,Public用来暴露那些你想要让框架使用者看到的代码文件,而不想暴露的代码文件全部都放在Project组。
在本项目中,确保 BuddhaSDK.h
和 CanNotSee.h
文件是在Public组中的,其他所有代码文件全都拉到Project中去(其实就是 CanNotSee.m
文件)。
OK,貌似该做的都做了,那就 Command + R
运行起来呗,可是键盘怎么按都没有反应,那就只能键盘 Command + B
或点击Xcode上的运行箭头,这下有反应了,而且 BuddhaSDK.framework
文件居然就这么变黑了。先暂缓激动,刚才为什么键盘按 Command + R
没反应呢?那是因为,咱们做的是框架,不是一个可执行的App或者命令行程序,框架只有编译的过程,没有链接和运行的过程。再次声明一下,静态和动态体现在该框架被嵌入到其他App中运行之前的链接阶段。因此,也可以推断出,框架在编译的时候肯定会有参数来指定最后生成的东西是静态框架还是动态框架,以便于让其他App能够判断出框架是否需要加载进内存。
那么,这个编译的参数是什么呢?点击 工程文件 -> TARGETS - > Build Settings
,在搜索框中输入 mach
:
这个 Mach-O Type
就是我们要寻找的编译参数。而这个 Dynamic Library
默认指定最后生成的是动态框架。点击 Dynamic Library
,还能看到 Executable
,Bundle
,Static Library
,Relocatable Object File
。对于这五个参数,苹果也给出了解释。
在我们的工程中,显然应该选 Static Library
。
键盘 Command + Shift + K
清理一下工程,BuddhaSDK.framework
变红色了,Command + B
再次编译一下,BuddhaSDK.framework
又变黑了!这样,我们就生成了一个静态框架。右击 BuddhaSDK.framework
文件,点击 Show in Finder
, 摆在眼前的就是咱们日思夜想的静态框架了。
针对这个静态框架,有几处可以说道说道。
首先,可以看到,framework本质上就是一种Bundle,而Bundle就是一个内部结构按照标准规则组织的特殊目录。在 BuddhaSDK.framework
目录下的 Header
文件夹里可以看到暴露出来的 BuddhaSDK.h
文件,而其他需要隐藏的文件就被编译到了 BuddhaSDK
这个没有后缀名的文件中去了,这是一个二进制文件(在命令行中通过 file
命令来查看该文件的类型,返回结果是 current ar archive random library
),我猜测它应该就是静态库的一种变形吧。
其次,在上图的最左边,有两个文件夹,分别是 Debug-iphonesimulator
和 Release-iphonesimulator
,而 Release-iphonesimulator
文件夹里什么也没有。那是因为我们在编译的时候只编译了 Debug
环境下的Framework,在开发阶段编译这个就够了,但是在给别人用的时候,如果你的Framework中有些逻辑是区分 Debug
和 Release
环境的,比如 Debug
的时候有Log,Release
的时候没有Log;再比如 Debug
的时候网络请求是 xxx.qa.google.com
网址,Release
的时候网络请求是 xxx.online.google.com
网址。那么,就需要编译一个 Release
环境的Framework。如果没有任何区别,那非得给人家 Debug
环境下的Framework也是可以的。而编译 Release
环境的Framework只需要跟着下图设置就可以了。
键盘 Command + Shift + K
清理一下工程,Command + B
重新编译一下。这回可以看到 Release-iphonesimulator
文件夹里有了我们想要的东西,而 Debug-iphonesimulator
文件夹里就什么都没有了。
Debug-iphonesimulator
和 Release-iphonesimulator
这两个文件夹名字的前半部分已经讲清了,后半部分的 iphonesimulator
又代表了什么意思呢?后文会加以阐述。
对于这个静态框架,暂时先说到这里。
接下来就需要建一个全新可执行的App工程,用来嵌入 BuddhaSDK.framework
并检查这个框架是否达到了我们的要求。对于这个框架,我们的要求很简单,能调用 CanNotSee.h
中的四个接口,并且对传的参数能返回正确的结果。
新建一个 Single View Application
模板的工程,名字为 TestSDKDemo
。
把刚才我们编译好的 BuddhaSDK.framework
直接拉到 TestSDKDemo
项目中,随便放在什么地方,看心情而定(只要不放在 Products
文件夹下)。然后在 ViewController.m
中加入代码,如下:
#import "ViewController.h"
#import "BuddhaSDK/BuddhaSDK.h"
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
NSString *sourceString = @"Hello, SDK!";
NSLog(@"原始数据:%@", sourceString);
CanNotSee *canNotSee = [[CanNotSee alloc] init];
NSString *encodedStringWithBase64 = [canNotSee encodeWithBase64:sourceString];
NSLog(@"Base64编码之后的数据:%@", encodedStringWithBase64);
NSString *decodedStringWithBase64 = [canNotSee decodeWithBase64:encodedStringWithBase64];
NSLog(@"Base64解码之后的数据:%@", decodedStringWithBase64);
NSData *encryptedStringWithAES = [canNotSee encryptWithAES:sourceString];
NSLog(@"AES加密之后的数据:%@", encryptedStringWithAES);
NSString *decryptedStringWithAES = [canNotSee decryptWithAES:encryptedStringWithAES];
NSLog(@"AES解密之后的数据:%@", decryptedStringWithAES);
}
@end
运行,结果如下:
2016-05-04 10:47:26.726 TestSDKDemo[1014:47973] 原始数据:Hello, SDK!
2016-05-04 10:47:26.727 TestSDKDemo[1014:47973] Base64编码之后的数据:SGVsbG8sIFNESyE=
2016-05-04 10:47:26.727 TestSDKDemo[1014:47973] Base64解码之后的数据:Hello, SDK!
2016-05-04 10:47:26.727 TestSDKDemo[1014:47973] AES加密之后的数据:<9226e6fe fc348008 c343d346 f210a560>
2016-05-04 10:47:26.727 TestSDKDemo[1014:47973] AES解密之后的数据:Hello, SDK!
TestSDKDemo
工程如下图所示:
我们完成了对Base64和AES算法的封装,暴露出了想要给他人使用的接口,隐藏了所有的实现细节。
见证奇迹成功,美好的事物就这么发生了!
到目前为止,除了我非得搞Base64和AES算法看上去可能略微有点小复杂,其他都非常简单。如果你项目中想要做的静态框架跟上文所做的东西差不多简单,那么仅仅掌握这些知识也就差不多了。
如果你还有点时间,并且怀着好奇心和探索精神,那咱们再继续扯会。
静态框架进阶二
在这个部分,请拿起铁锨,咱们挖两个坑,跳下去感受感受!
坑一
在咱们的静态框架 BuddhaSDK.framework
中,Base64和AES算法的具体实现都隐藏在一个叫做 CanNotSee.m
的实现类中。然而,在某一天,发生了一件非常不幸的事情,有个睁眼瞎非得在 TestSDKDemo
这个工程里新建一个叫做 CanNotSee
的同名类,仅仅是同名,它们里面写的是完全不同的代码。接下来咱们可以模拟一下睁眼瞎。
新建一个 CanNotSee
的类,继承自 NSObject
。
CanNotSee.h
中代码如下:
@interface CanNotSee : NSObject
+ (instancetype)sharedManager;
- (void)LogYourName:(NSString *)yourName;
@end
CanNotSee.m
中代码如下:
#import "CanNotSee.h"
@implementation CanNotSee
+ (instancetype)sharedManager {
static CanNotSee *instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[CanNotSee alloc] init];
});
return instance;
}
- (void)LogYourName:(NSString *)yourName {
if(yourName == nil || yourName.length == 0) {
NSLog(@"Hello, Lazy");
} else {
NSLog(@"Hello, %@", yourName);
}
}
@end
代码写完,没有报错。Command + B
编译一下,没有任何问题,Command + R
再运行一下,崩了!
这尼玛是咋滴了(故作呆萌状)?让我们来添加全局的异常断点,然后再运行一下,看看到底是出了什么幺蛾子。
聚焦到最下面的输入输出控制台,Xcode说在 CanNotSee
类中找不到 encodeWithBase64:
这个方法。从这个错误中可以看出,不管 BuddhaSDK.framework
中有没有 CanNotSee
类,TestSDKDemo
这个工程在运行的时候首先调用的还是自家的 CanNotSee
类中的方法,哪怕在我们都没有引入自家 CanNotSee.h
文件的情况下(在 ViewController.m
中引入的是 BuddhaSDK/BuddhaSDK.h
,相当于只是引入了 BuddhaSDK.framework
中的 CanNotSee.h
头文件)。因此,最后的结论就是,在同一个目标工程里面显然是不允许存在两个同名的类。
对于这个结论,还有两点需要突出补充说明一下。
首先,在咱们的测试工程中,可能你会觉得同名问题根本没人会这么做。当然咱们的测试工程文件那么少,你一眼就能看到。还有,如果你只是在你公司内部的项目中做一个类似对加解密的简单封装并且你对整个工程都有一个非常清晰的掌控,你可能也会注意到同名文件的问题。而如果你要做的Framework是给公司其他项目组使用甚至给其他公司使用,为了减少沟通成本,也为了不让别人觉得你low,那么你就必须要将自己Framework中的每一个类都取一个将来尽量不会产生冲突的名字。其实,这就涉及到了Objective-C命名空间的问题,很不幸的是,Objective-C是没有命名空间这个概念的,而为了避免类名冲突,Objective-C采用的是前缀的方法(可以粗略的理解为这是对命名空间的一种弥补办法)。就比如苹果自带框架的前缀,如 Foundation
框架的 NS
; UIKit
框架的 UI
; AVFoundation
框架的 AV
等等。因此,在交付Framework给他人使用的时候,最好能有相应的文档来说明一下Framework的前缀。那么,在咱们的 BuddhaSDK
工程中就需要更改一下了,加入我们自己的前缀 ABC
吧。可是,如果你的项目在实现过程中从来就没有过想过前缀的问题,并且项目也非常大了,这时候突然需要为你的项目进行SDK化,那怎么办呢?比如,就我当下的项目,拍照搜题和老师答疑,代码文件有100多个,这时候需要把这些文件都做成SDK,如果把每一个类都加上前缀,那不是要了我的亲命吗?可我还是个宝宝,美好的世界还没怎么看看呢。这里再留个悬念吧,后文本宝宝慢慢的再续上,这是一个终极大坑,也是咱们主题下的地狱之门!
其次,还有一个概念前文提到,但是没有举过例子,这边再强化一下。一般来说,如果一个项目中有两个同名的文件,首先在新建的时候就会提示项目中已经存在同名文件,问你是取消还是替换,如下图(项目中已经有了 ViewController
类,我又新建了一个 ViewController
类)。还有,哪怕你是从外部直接拉了一个同名文件到项目中,Xcode都不需要编译就会立刻报错。而如果有两个同名文件,一个在Framework中,一个不在Framework中,Xcode不但不会报错,还能通过编译过程,这是为什么呢?其实这点上文已经讲过,这是因为Framework是一个已经编译好的Buddle,因此Framework在嵌入到App之后,Xcode不会对其进行语法语义之类的分析,也不会对其再次编译,只有在链接和运行的时候才会去骚动那些Framework。
在上面的测试项目中,为了更快速的引出不能有同名文件的结论,我故意让两个 CanNotSee
类中的代码完全不一样,从而在运行的时候直接崩溃报错。而如果两个 CanNotSee.h
头文件中的内容完全一致,两个 CanNotSee.m
文件中的内容仅仅有略微的差别,比如不在Framework中的 CanNotSee.m
文件中AES加解密秘钥不是 @"abcdefghigklmnop"
,那么在运行的时候是不会崩溃的,但是加解密的结果就是不对,一旦跳进了这种坑,那你就多买点泡面和睡袋在公司以泪洗面吧!
到这里,咱们已经从第一个坑中爬出来了,这是个可大可小的坑,接下来就再看一个不大不小的坑吧!
坑二
还记得上文中有讲过会有一个关于分类(Categroy)的坑吗?下面咱们挖一下吧!
前面说过,Base64和AES算法主要是对NSData的操作,最好的写法是对NSData进行分类(Categroy)。
NSData+CanNotSee.h
代码如下:
@interface NSData (ABCCanNotSee)
/**
* base64编码
*/
- (NSString *)base64EncodedString;
/**
* base64解码
*/
- (NSString *)base64DecodedString;
/**
* AES加密(ECB模式 PKCS7填充 128位)
* @param key : @"abcdefghigklmnop"
*/
- (NSData *)AESEncryptWithKey:(NSString *)key;
/**
* AES解密(ECB模式 PKCS7填充 128位)
* @param key : @"abcdefghigklmnop"
*/
- (NSData *)AESDecryptWithKey:(NSString *)key;
@end
NSData+CanNotSee.m
代码如下:
#import "NSData+ABCCanNotSee.h"
#import <CommonCrypto/CommonCrypto.h>
@implementation NSData (ABCCanNotSee)
/**
* base64编码
*/
- (NSString *)base64EncodedString {
return [self base64EncodedStringWithOptions:0];
}
/**
* base64解码
*/
- (NSString *)base64DecodedString {
NSData *encodedData = [[NSData alloc] initWithBase64EncodedData:self options:0];
NSString *decodedString = [[NSString alloc] initWithData:encodedData encoding:NSUTF8StringEncoding];
return decodedString;
}
/**
* AES加密(ECB模式 PKCS7填充 128位)
* @param key : @"abcdefghigklmnop"
*/
- (NSData *)AESEncryptWithKey:(NSString *)key {
// 准备秘钥(128位,16个字节)
char keyPtr[kCCKeySizeAES128+1];
bzero(keyPtr, sizeof(keyPtr));
[key getCString:keyPtr maxLength:sizeof(keyPtr) encoding:NSUTF8StringEncoding];
// 准备加密后数据的容器
NSUInteger dataLength = [self length];
size_t bufferSize = dataLength + kCCBlockSizeAES128;
void *buffer = malloc(bufferSize);
memset(buffer, 0, bufferSize);
size_t dataOutMoved = 0;
NSInteger cryptStatus = CCCrypt(kCCEncrypt, // 加密
kCCAlgorithmAES128, // 128位
kCCOptionECBMode | kCCOptionPKCS7Padding, // ECB模式(0x0000或者默认都是CBC模式)
keyPtr, // 秘钥
kCCKeySizeAES128, // 秘钥长度
NULL, // 初始化向量
[self bytes], // 未加密数据
dataLength, // 未加密数据的长度
buffer, // 加密后的数据
bufferSize, // 加密后数据的大小
&dataOutMoved // 加密后数据的位移
);
if (cryptStatus == kCCSuccess) {
NSData *resultData = [NSData dataWithBytesNoCopy:buffer length:dataOutMoved];
return resultData;
}
free(buffer);
return nil;
}
/**
* AES解密(ECB模式 PKCS7填充 128位)
* @param key : @"abcdefghigklmnop"
*/
- (NSData *)AESDecryptWithKey:(NSString *)key {
// 准备秘钥
char keyPtr[kCCKeySizeAES128+1];
bzero(keyPtr, sizeof(keyPtr));
[key getCString:keyPtr maxLength:sizeof(keyPtr) encoding:NSUTF8StringEncoding];
// 准备解密后数据的容器
NSUInteger dataLength = [self length];
size_t bufferSize = dataLength + kCCBlockSizeAES128;
void *buffer = malloc(bufferSize);
memset(buffer, 0, bufferSize);
size_t dataOutMoved = 0;
NSInteger cryptStatus = CCCrypt(kCCDecrypt, // 解密
kCCAlgorithmAES128, // 128位
kCCOptionECBMode | kCCOptionPKCS7Padding, // ECB模式(0x0000或者默认都是CBC模式)
keyPtr, // 秘钥
kCCKeySizeAES128, // 秘钥长度
NULL, // 初始化向量
[self bytes], // 加密数据
dataLength, // 加密数据长度
buffer, // 解密后的数据
bufferSize, // 解密后数据的大小
&dataOutMoved // 解密后数据的位移
);
if (cryptStatus == kCCSuccess) {
NSData *resultData = [NSData dataWithBytesNoCopy:buffer length:dataOutMoved];
return resultData;
}
free(buffer);
return nil;
}
@end
代码写完,还有两步需要操作。
首先在 ABCBuddhaSDK.h
文件最下面加入一行代码 #import <ABCBuddhaSDK/NSData+ABCCanNotSee.h>
, 从而让 NSData
分类的头文件暴露出来。如下图:
然后来到工程的 Build Phases
的 Headers
,把 NSData+ABCCanNotSee.h
头文件拉到 Public
组下,把 NSData+ABCCanNotSee.m
实现文件拉到 Project
组下。如下图:
最后清理工程,重新编译生成带有 NSData
分类功能的Framework ABCBuddhaSDK.framework
。
再次来到 TestSDKDemo
Xcode工程中,把原来的Framework删了,把刚编译好的 ABCBuddhaSDK.framework
添加到工程中,并且在 ViewController.m
中输入如下代码(原来的代码你想保存的话可以注释掉):
#import "ABCBuddhaSDK/ABCBuddhaSDK.h"
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
NSString *sourceString = @"Hello, SDK!";
NSLog(@"原始数据:%@", sourceString);
NSData *sourceData = [sourceString dataUsingEncoding:NSUTF8StringEncoding];
NSString *encodedStringWithBase64 = [sourceData base64EncodedString];
NSLog(@"Base64编码之后的数据:%@", encodedStringWithBase64);
NSData *encodedData = [encodedStringWithBase64 dataUsingEncoding:NSUTF8StringEncoding];
NSString *decodedStringWithBase64 = [encodedData base64DecodedString];
NSLog(@"Base64解码之后的数据:%@", decodedStringWithBase64);
NSData *encryptedStringWithAES = [sourceData AESEncryptWithKey:@"abcdefghigklmnop"];
NSLog(@"AES加密之后的数据:%@", encryptedStringWithAES);
NSData *decryptedDataWithAES = [encryptedStringWithAES AESDecryptWithKey:@"abcdefghigklmnop"];
NSString *decryptedStringWithAES = [[NSString alloc] initWithData:decryptedDataWithAES encoding:NSUTF8StringEncoding];
NSLog(@"AES解密之后的数据:%@", decryptedStringWithAES);
}
@end
Command + R
运行,你猜结果是什么?要是不崩溃,那我上面那些就都是废话了。可是,这又是为什么呢?先来看看到底崩在哪里了!
几乎是同样的错误,方法不存在!可是这次就真的很疑惑了,没有同名文件,NSData+ABCCanNotSee
头文件里也存在 - base64EncodedString
这个方法,系统怎么就找不到这个方法呢?而且,不用分类就没问题,一用分类就出错了。
显然,应该很容易猜到,问题肯定出在分类上!
通过 iOS
、Framework
、Catergory
三个单词稍作查询,就能查到 苹果官方的解释 以及 stackoverflow上的这篇文章 。这两个网页上给出了产生原因和解决办法的详细说明,我下面结合起来尽可能用简洁的语言转述一下。文字有点多了,不过又比较重要,还是耐心的继续看吧。
首先介绍一下相关的背景知识。
对于静态库的编译,编译器的工作大体来说分为两步:首先把每一个源代码文件(比如 .c
、.cpp
、.m
等为后缀的代码文件)翻译成目标文件(.o
为后缀的二进制文件),然后把所有的目标文件放在一起打个包(.a
文件,a
就是 archive
的意思,与之匹配的命令叫做 ar
),仅此而已。
无论一个源代码文件中有多少个类,每一个源代码文件都只对应一个目标文件。目标文件中包含链接符号、代码和静态数据。代码和静态数据都很好理解,下面着重分析下链接符号。
当把一个静态库嵌入到其他App中并开始执行的时候,链接器首先会去检查在App中引用到了静态库中的哪些类,然后生成一张链接符号的表格,最后根据这张表格检查是否有不存在的类并把所需的类设置为【可用】。因此,只有那些起作用的目标文件(被标记为【可用】的类)才能在运行时被真正加载进内存,比如,你的静态库中有50个目标文件,但是App中只引用到了其中的20个目标文件,那么其余30个就会在链接之后给抛弃掉(设置为【不可用】)。
关于链接符号,我说的比较抽象,下面举个例子吧。比如App中有一个类 ABC001.m
调用了静态库中的一个类 ABC002.m
中的某些方法,那么在把 ABC001.m
编译成目标文件 ABC001.o
之后,对于目标文件 ABC001.o
来说,它会觉得 ABC002.o
是一个缺失的类并把这个类标记为【未定义的链接符号(undefined symbol)】,在这里尤其需要注意一点,对于Objective-C语言来说,它并没有为类中的每个函数都定义链接符号,它只为每个类创建链接符号。当一大堆目标文件打包成静态库嵌入到App中执行的时候,首先会有一个链接阶段,链接器会扫描每一个目标文件中的【未定义链接符号】并生成一个表格,然后看表格中的类所在的目标文件是否都存在,如果不存在会出现链接错误,从而导致程序无法顺利执行。
基于上面的理论,关于Category代码崩溃问题就很好解释了。由于编译器对于【未定义链接符号】只停留在类这一层,无法检测类中的函数和属性是否存在。因此,虽然 TestSDKDemo
中的 ViewController
类使用到了 NSData
分类中的方法,但是 ViewController.o
中的【未定义链接符号】只会记录下 NSData
这个类是缺失的,然而 NSData
作为系统类当然是存在的,因此,在 ViewController.o
中就不会有对于 NSData
这个类的【未定义链接符号】。所以在链接阶段结束的时候,NSData+ABCCanNotSee.o
这个目标文件就被彻底罢免、永不录用了。
如果按照这个逻辑,那为什么在App中直接新建一个Category就不会出现这种找不到方法的崩溃问题呢?这就得归功于Objective-C语言大量使用运行时特性(runtime-feature)所产生的副作用了,这种特性虽然十分重要,但是本文不过多介绍,实在是因为讲起来又得几天几夜了,既然网上的资料也多的很,还请自行查看阅读。回到问题本身,上文讲到,在链接阶段结束之后,静态库中用不到的目标文件会被彻底的抛弃掉,因此,在我们的项目中,TestSDKDemo
在运行的时候,会去找 NSData
中 base64EncodedString
方法,可是 NSData+ABCCanNotSee.o
已经被彻底抛弃掉了,App中找不到这个方法,从而导致了崩溃。然而对于App来说,在链接的时候不会标记任何自己的类是【不可用】的,因此,一旦App在运行时使用到了某个Category,运行时特性会找到那个对应的方法顺利执行下去。
说到这里,不知道您是否又有疑惑了?只要让App在链接阶段不抛弃掉任何静态库中的目标文件,那不就什么问题都没有了吗?苹果为什么要多此一举呢?如果找不到理由,那肯定是苹果歧视静态库或者静态框架!不幸的是,我找到了一个理由,苹果这么做是为了缩小App的大小,因为一旦静态库中目标文件被链接器标记为【不可用】,那么这个目标文件就不会被打包到最后的App中去。这么做还影响到了同名问题,在很上面,我们讲过如果有两个同名文件,一个是App自己建立的,一个是静态库建立的,如果把两个文件都标记为【可用】,那么在链接阶段由于有重复文件肯定就会报错了(下文会给出例子),可实际上,链接器不但没有报错,还能让App顺利运行起来,那是因为在链接阶段前期,苹果为了减小App大小,已经把静态库中的所有同名文件都给标记为【不可用】了,App运行之后对所有对同名文件的调用,都只会调用App自己的。
那静态库中如果存在Category,如何强制把这个Category标记为【可用】呢?有4种解决办法,如下:
1、-ObjC
:来到工程 TestSDKDemo
,如下图所示,在 Other Linker Flags
中添加 -ObjC
。
-ObjC
这个链接标记的意思是,在链接阶段,静态库中只要包含Objective-C代码的文件都设置为【可用】状态。这下重新运行 TestSDKDemo
工程,一切就都正常了,如下图。
这虽然是苹果官方唯一推荐的办法,但是苹果自己也说了,这样的设置可能会导致App变大,因为如果静态库中有些类确实是【不可用】的,但是最终也会全部打包到App中去。
既然 -ObjC
的意思已经知道了,那么下面就可以验证一下上文提到的同名问题在链接阶段是否会报错的结论。首先从上图中可以看到,在 ABCBuddhaSDK.framework
中有一个 ABCCanNotSee
类,我们在工程文件 TestSDKDemo
中也新建一个 ABCCanNotSee
类,在里面随便写点代码,然后运行,看看会有什么事情发生(请注意,这时候 -ObjC
链接标记已经添加上了)。
很明显,App没能运行起来,确实是直接报错了。从错误信息中可以明确的看到在链接阶段有2个重复的链接符号,至此,咱们的结论得以证明。
因此,如果Framework中有大量的类,就算没有Category,我们也可以通过 -ObjC
这个链接标记来检查同名文件,不过这也仅仅是用于检查,最后要不要留下这个链接标记还得具体问题具体分析。
2、-all_load
:同样,在 Other Linker Flags
中也可以添加 -all_load
来解决静态库中Category的崩溃问题。
-all_load
这个链接标记的意思是,在链接阶段,静态库中所有代码的文件都设置为【可用】状态。
-ObjC
与 -all_load
区别就是前者只扫描Objective-C代码的目标文件,后者扫描所有Xcode能支持语言的目标文件。这里需要注意一点,如果在一个文件中既有Objective-C代码,又有其他语言的代码,比如c或者c++,那么 -ObjC
也是能对这个文件起作用的。
我看到网上很有多文章都充斥着对于 -all_load
这个链接标记的滥用,其实只要理解它的意思,就知道 -all_load
的具体使用场景了。
3、-force_load
:在 Other Linker Flags
中也可以添加 -all_load $(PROJECT_DIR)/TestSDKDemo/ABCBuddhaSDK.framework/ABCBuddhaSDK
来解决静态库中Category的崩溃问题。
-force_load
这个链接标记需要在其后面添加你想要强制加载哪个具体的静态库或者静态框架,一旦被指定强制加载,那么这个静态库或者静态框架中的所有目标文件都被设置为【可用】了,包括非Objective-C语言的目标文件。
而 -all_load
与 -force_load
区别就是前者加载所有静态库或静态框架,后者只加载指定的静态库或静态框架。
4、第四种解决办法需要写几行代码,下面先给出解决办法,再解释其原理。
在 ABCBuddhaSDK.framework
项目的 NSData+ABCCanNotSee.h
文件中添加一个 ImportNSDataCategory
类,如下:
@interface ImportNSDataCategory : NSObject
@end
在 NSData+ABCCanNotSee.m
文件中添加对这个类的实现,如下:
@implementation ImportNSDataCategory
@end
请注意,ImportNSDataCategory
类的定于与实现是写在 NSData+ABCCanNotSee.h
和 NSData+ABCCanNotSee.m
中的。
然后 Command + Shift + K
清理下工程,Command + B
再次重新编译。
来到 TestSDKDemo
工程,删掉原来的 ABCBuddhaSDK.framework
,把刚最新生成的 ABCBuddhaSDK.framework
嵌入到 TestSDKDemo
工程中来。
在 ViewController.m
文件的最下面加入一个方法,如下:
- (void)importCategories {
// pragma 仅仅是用来消除警告,如果不加上下面三行pragma代码,会出现变量未被使用的警告。
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wunused-variable"
ImportNSDataCategory *importNSDataCategory = [ImportNSDataCategory new];
#pragma clang diagnostic pop
}
删掉 Other Linker Flags
中的所有东西,然后重新运行,App一切正常了。
为什么这么做可以解决问题,基于上面的那么多文字,我再多说肯定要遭你烦了。就解释一句吧,一个源代码文件对应一个目标文件,不管这个源代码文件中有多少个类,最后都只会生成一个目标文件。
对比与上面三种办法,采用这种办法的优势在于最精准的加载所需要的目标文件,能最大程度的缩小App的体积。当然劣势也显而易见,需要你在每个分类中都要写上一些代码,然后还要记得调用那个空类来确保包含分类的目标文件能被加载到内存中去。
稍微说个题外话吧,在网上确实能查到采用这种办法来解决静态库中Category问题,但是大多数只说了一半,这些不完全的办法主要来自于GitHub上的一个开源工程(https://github.com/NimbusKit/basics),由于他们缺少在外部调用 ImportNSDataCategory
这个类的过程,因此最终还是会导致崩溃。不过,在这个开源工程中有一个宏命令可以借鉴过来快速生成 ImportNSDataCategory
的定义与实现,宏命令如下:
#ifndef NI_FIX_CATEGORY_BUG
# define NI_FIX_CATEGORY_BUG(name) @interface NI_FIX_CATEGORY_BUG_##name : NSObject @end \
@implementation NI_FIX_CATEGORY_BUG_##name @end
呜~~~至此,四种方法全部介绍完毕了,选择哪个自己看着办吧。
关于静态库中有Category会导致App崩溃的问题我已经说完了。不过还有一个跟Category的小概率抽风问题我这边也顺带多两句嘴,这种问题跟分类在不在静态库中无关。上面讲过同名类的问题,下面讲下不同的分类中同名方法的问题。
如果你的代码中有多个对同一个类的分类,分类中有相同的方法声明,比如说有两个对 NSData
的分类,一个是 NSData+ABCCanNotSee
类,另一个是 NSData+ABCCanNotSeeAgain
类,但是这两个类中都有一个叫做 - (NSString *)base64EncodedString
的同名方法,如果两个同名方法的实现相同倒也罢了,如果实现不同,那程序最后仍能顺利执行,但是你也不知道系统最终会掉用哪个分类的哪个方法。因此,如果非要斤斤计较,分类方法名之前也需要加上前缀。