可否使用 == 来判断两个NSString类型的字符串是否相同?为什么?
不能。==
判断的是两个变量的值的内存地址是否相等。如果两个字符串内容相同的NSString
对象的创建方式不一样,那这两个NSString
对象的内存地址是不同的。
// str1和str2的内存地址是相同的
NSString *str1 = [NSString stringWithFormat:@"a"];
NSString *str2 = [NSString stringWithFormat:@"a"];
// str3和str4的内存地址是相同的
NSString *str3 = @"a";
NSString *str4 = @"a";
// str5和str6的内存地址是不同的
NSString *str5 = [NSString stringWithFormat:@"a"];
NSString *str6 = "a";
NSObject为什么要有isEqual方法?
==
运算符在比较对象时,比较的是两个对象的内存地址是否相同。但是,当我们需要比较两个对象的内容是否相同时(例如,两个UIColor
对象的颜色是否相同,两个NSString
对象的字符串内容是否相同),就需要使用isEqual:
方法了。
NSObject
的isEqual:
方法默认还是比较两个对象的内存地址是否相同,如果想要比较两个对象的内容,就需要重写isEqual:
方法来自行实现。
官方为 Foundation 框架中某些类(这些类不仅重写了isEqual:
方法,还额外实现了一个isEqualToXXX:
方法)重新实现了isEqual
方法,例如:
-
NSString
的-isEqualToString:
方法; -
NSArray
的-isEqualToArray:
方法; -
NSDictionary
的-isEqualToDictionary:
方法; -
NSSet
的-isEqualToSet:
方法; -
NSNumber
的-isEqualToNumber:
方法; -
NSAttributedString
的-isEqualToAttributedString:
方法; -
NSData
的-isEqualToData:
方法; -
NSDate
的-isEqualToDate:
方法。
NSObject的-(NSUInteger)hash方法有什么用?
将对象添加到NSSet
中,或者对象作为NSDictionary
的 key 时,会调用对象的hash
方法来计算该对象的哈希值,NSSet
和NSDictionary
使用这个哈希值来确定存储对象在哈希表中的位置。
NSObject
的hash
方法默认返回的是对象的内存地址,这非常满足哈希值对于唯一性的要求。但在某些场景下,却不能满足需求。例如,对于两个字符串内容相同的NSString
对象来说,如果它们的创建方式不相同,就会导致它们的内存地址并不相同。当使用NSString
对象作为NSDictionary
的 key 时,如果还是使用内存地址作为NSString
对象的哈希值,就会出现问题。因此,官方重写了NSString
的hash
方法。
重写对象的hash
方法的最佳实践,就是对其关键属性的hash
方法的返回值进行按位异或运算,然后将结果值作为该对象的哈希值。
- (NSUInteger)hash {
return [self.name hash] ^ [self.birthday hash];
}
isEqual方法和hash方法之间的关系?
相等的两个对象,它们的 hash 值一定相等。但是,两个对象的 hash 值相等的话,这两个对象并不一定相等,还要比较对象的具体内容。
iOS的进程之间的通信方式有哪些?
-
URL Scheme:这是 iOS 应用程序之间通信最常用的的方式。在应用程序B的
TARGETS
->info
->URL Types
中添加一个 Scheme,在应用程序A的info.plist
文件的 URL Scheme 白名单中加入这个 Scheme。之后,应用程序A就能通过openURL:
方法跳转到应用程序B了。 - Keychain:Keychain 是一个安全的存储容器,其独立于每个应用程序的沙盒之外。即使应用程序被卸载了,其在 Keychain 中存储的数据也依然存在。Keychain 用于应用程序之间通信的一个典型场景是统一账户登录平台,使用同一个账号平台的多个应用程序,只要用户登录了其中一个应用程序,那么其他应用程序就可以实现自动登录,而不需要用户多次输入账号和密码。
-
UIPasteboard:剪贴板功能。长按
UITextView
,UITextField
,UIWebView
控件时,会弹出复制,粘贴等选项。每个应用程序都可以访问系统的剪贴板,所以可以利用剪贴板来在两个应用程序之间传递信息。例如,淘宝的淘口令。 - UIDocumentInteractionController:主要是用来实现同一个设备上 App 之间的文档共享,以及文档预览、打印、发邮件和复制等功能。
- UIActivityViewController:可以在 App 之间分享和操作数据。
assign,weak 和 __unsafe_unretained 的区别
assign
不仅可以修饰基本数据类型的属性,还可以修饰对象类型的属性,MRC 和 ARC 模式下都能使用。asign
在修饰对象类型的属性时,asign
指针在引用对象时,不会增加对象的引用计数。当引用对象被释放后,asign
指针还是会指向引用对象原来的内存地址,当继续使用asign
指针访问对象时,就会出现野指针,导致程序运行崩溃。
weak
只能在 ARC 模式下使用,且只能修饰对象类型。weak
指针在引用对象时,也不会增加对象的引用计数。但是引用对象被释放后,weak
指针会自动指向nil
。
__unsafe_unretained
也只能在 ARC 模式下使用,且只能修饰对象类型。__unsafe_unretained
指针在引用对象时,不会增加对象的引用计数。当引用对象被释放后,__unsafe_unretained
指针还是会指向对象原来的内存地址,当继续使用__unsafe_unretained
指针访问对象时,会出现野指针。
weak
指针的管理会消耗更多的 CPU 资源,如果我们可以明确对象的生命周期,那么使用__unsafe_unretained
会更加高效。
layoutSubviews 方法、setNeedsLayout 方法和 layoutIfNeeded 方法
视图的layoutSubviews
方法的默认实现不会做任何事情,当子视图的自动调整大小和基于约束的行为无法满足我们的需求时,可以重写视图的此方法来直接设置其子视图的frame
。如果在视图的layoutSubviews
方法中有直接设置子视图的frame
,那么就不要再给视图添加这个子视图的约束了。否则,会覆盖掉在视图的layoutSubviews
方法中设置的子视图frame
。调用视图的setNeedsLayout
或layoutIfNeeded
方法后,或者设置视图的frame
方法后,会触发其layoutSubviews
方法。
调用视图的setNeedsLayout
方法后,不会立即计算视图及其子视图的frame
,只是标记视图及其子视图的frame
需要重新计算。等到 Core Animation 绘制图形时,会先将视图的多次布局更新合并,然后再计算出视图的frame
。接着,会调用layoutSubviews
方法来直接设置其子视图的frame
。再然后,会根据添加的子视图约束来计算其子视图的frame
。
当视图需要更新布局时(设置视图的frame
或约束时,会调用其setNeedsLayout
方法来标记该视图需要更新布局),调用其layoutIfNeeded
方法会立即计算该视图的frame
,并调用其layoutSubviews
方法,然后再根据添加的子视图约束来计算子视图的frame
。如果视图不需要更新布局,调用其layoutIfNeeded
方法后,layoutIfNeeded
方法什么也不会做,会直接返回。
int,unsigned int,NSInteger和NSUInteger有什么区别?
#if __LP64__ || (TARGET_OS_EMBEDDED && !TARGET_OS_IPHONE) || TARGET_OS_WIN32 || NS_BUILD_32_LIKE_64
typedef long NSInteger;
typedef unsigned long NSUInteger;
#else
typedef int NSInteger;
typedef unsigned int NSUInteger;
#endif
int
类型在32位系统中只能是int
类型,但在64位系统中却有可能为int
类型,也有可能为long
类型。
NSInteger
和NSUInteger
是动态定义的类型,在不同架构下,它们可能是int
类型,也可能是long
类型。应该尽可能使用NSInteger
和NSUInteger
,这样就不用考虑设备是32位还是64位架构了。
应用程序的生命周期
iOS应用程序有五种状态:
- 未运行状态(Not running):应用程序尚未启动或者正在运行但已被系统终止。
- 未激活状态(Inactive):应用程序在前台运行,但是目前还未开始接收事件。(它可能正在执行其他代码)应用程序通常只在切换到不同状态的过程中,短暂保持此状态。
- 激活状态(Active):应用程序在前台运行并且正在接收事件,这是应用程序在前台运行时所处的正常状态。
- 后台状态(Background):应用程序在后台并执行代码。大多数应用程序进入后台后,会在这个状态停留一会儿。之后,应用程序会被系统挂起。
- 挂起状态(Suspended):应用程序在后台处于睡眠状态,不会执行代码。当应用程序被挂起时,应用程序依然保留在内存中。当出现内存不足的情况时,系统可能会清除被挂起的应用程序,为其他前台应用程序提供更多的内存。
启动尚未运行的应用程序时,应用程序会先切换到未激活状态,在执行didFinishLaunchingWithOptions:
代理方法后,切换为激活状态。锁屏或者按下 Home 键后,应用程序会先切换到后台状态,一段时间后,大多数应用程序会被系统挂起,应用程序切换到挂起状态。内存吃紧时,系统可能会清除被挂起的应用程序,应用程序切换到未运行状态。
Mach-O 是什么?
Mach-O 是 macOS 和 iOS 的可执行文件的文件格式,Mach-O 文件分为以下几类:
- Executable:应用程序的可执行文件;
- Dylib:动态库;
-
Bundle:无法被 dyld 链接的动态库,只能通过
dlopen()
函数加载;
Image(镜像)指的是 Executable,Dylib 或 Bundle 中的一种。Framework,指的是动态库(或者静态库)、头文件和资源文件的集合。
Mach-O 文件分为 Header,Load Commands,Data 三部分,如下图:
/// 64位架构下 mach header 的数据结构
struct mach_header_64 {
uint32_t magic; // CPU 架构(64位 or 32位)
cpu_type_t cputype; // CPU 类型,例如:arm
cpu_subtype_t cpusubtype; // CPU 的具体型号
uint32_t filetype; // 文件类型
uint32_t ncmds; // load commands 的数量
uint32_t sizeofcmds; // 所有 load commands 的总大小
uint32_t flags; // 标志位
uint32_t reserved; // 保留字段
};
// load command 的数据结构,不同类型的 load command 有它们各自的数据结构
struct load_command {
uint32_t cmd; // 指令类型,例如:LC_SEGMENT 是 segment 的加载指令,LC_LOAD_DYLINKER 是加载 dyld 的加载指令
uint32_t cmdsize; // 指令长度
};
/// 64位架构下 segment command 的数据结构
struct segment_command_64 {
uint32_t cmd; // 指令类型,这里固定为 LC_SEGMENT_64
uint32_t cmdsize; // 指令长度
char segname[16]; // segment name,例如:_PAGEZERO,_TEXT,_DATA,_LINKEDIT
uint64_t vmaddr; // segment 在虚拟内存中的起始地址
uint64_t vmsize; // segment 的虚拟内存大小
uint64_t fileoff; // segment 在文件中的偏移量
uint64_t filesize; // segment 在文件中的大小
vm_prot_t maxprot; // maximum VM protection
vm_prot_t initprot; // initial VM protection
uint32_t nsects; // segment 中包含的 sections 数量*/
uint32_t flags; // 保留字段
};
/// 64位架构下 section 的数据结构
struct section_64 {
char sectname[16]; // section name
char segname[16]; // 所在segment 的 name
uint64_t addr; // section 的内存起始地址
uint64_t size; // section 所占字节数
uint32_t offset; // section 在文件中的偏移量
uint32_t align; // section 的对齐方式
uint32_t reloff; // file offset of relocation entries
uint32_t nreloc; // number of relocation entries
uint32_t flags; // flags (section type and attributes)
uint32_t reserved1; // reserved (for offset or index)
uint32_t reserved2; // reserved (for count or sizeof)
uint32_t reserved3; // reserved
};
- Header:头部,包含 Load commands 部分的 load command 数量和 load command 的总大小,以及 Mach-O 文件的运行环境信息,例如:CPU 架构、CPU 类型等;
-
Load commands:加载命令,有多种不同类型的 load command,系统内核会根据 load command 来执行对应的加载操作。
LC_SEGMENT
描述了 Data 部分的 segment 在虚拟内存中的布局方式,LC_LOAD_DYLINKER
保存着 dyld 的加载路径; - Data:包含可执行的机器码和数据,其被划分为多个 segment,每个 segment 又分为多个 section,每个 segment 的大小必须是 16KB(64位架构下)或者 4KB(32位架构下)对齐的。
segment 映射到内存的过程为:从
fileoff
处加载filesize
大小的数据到虚拟内存的vmaddr
处,并占用大小为vmsize
的虚拟内存。
Data 部分的 segment 有以下几种类型(还有其他类型这里未列出):
-
_PAGEZERO
:空指针陷阱段,映射到虚拟内存空间第一页,捕捉对 NULL 指针的引用; -
__TEXT
:代码段,只读,包含可执行的机器码和只读数据; -
__DATA
:数据段,包含所有可读可写的数据; -
__LINKEDIT
:链接编辑段,包含 dyld 链接时需要用到的原始数据,例如:间接符号表,符号表,字符串表等;
_TEXT 段的 section 类型 |
包含内容 |
---|---|
__text | 程序可执行的机器码 |
__stubs | 间接符号存根,用于跳转到懒加载外部符号指针数组 |
__stubs_helper | 懒加载符号加载辅助函数 |
__cstring | 只读的 C 字符串常量 |
...... | ...... |
_DATA 段的 section 类型 |
包含内容 |
---|---|
__nl_symbol_ptr | 非懒加载外部符号指针数组,dyld 加载时立即绑定值 |
__la_symbol_ptr | 懒加载外部符号指针数组,第一次调用时才绑定值 |
__got | 非懒加载全局符号指针数组 |
__mod_init_func | C++ 的静态构造函数 |
...... | ...... |
有关更多 Mach-O 的信息,可以参看 iOS堆栈信息解析(Mach-O),iOS逆向之五-MACH-O文件解析,Mach-O介绍,Mach-O学习小结。
Mach-O 相关数据结构的定义在 XNU 源码的 xnu/EXTERNAL_HEADERS/mach-o/loader.h
应用程序的启动过程
启动应用程序时,系统内核会创建一个新进程,并读取磁盘(硬盘)中的应用程序 Mach-O 文件,然后根据 Mach-O 文件的 Header 信息将磁盘中的应用程序 Mach-O 文件加载到内存中。
在主程序 Mach-O 文件的加载过程中,会首先创建一个虚拟内存映射空间(虚拟内存是从硬盘中划分的一块连续区域,32位架构下最大为 4GB,64位架构下最大为 64GB)。接着,为主程序计算 ASLR 随机偏移量,以及为动态链接器 dyld 计算 ASLR 随机偏移量。然后,开始解析主程序的 Mach-O 文件。在主程序 Mach-O 文件的解析过程中,会先遍历所有的 load command。如果 load command 是 segment 的加载指令,则会将 segment 映射到虚拟内存中(从fileoff
处加载filesize
大小的数据到虚拟内存的vmaddr
处,并占用大小为vmsize
的虚拟内存);如果 load command 是 dyld 的加载指令,则会获取 dyld 的加载路径;如果是其他类型的加载指令,则会执行对应的加载操作。最后,会根据 dyld 的加载路径读取磁盘中 dyld 的 Mach-O 文件,并解析 dyld 的 Mach-O 文件,将其 segment 映射到虚拟内存中。
在主程序和 dyld 加载完成后,系统内核会执行 dyld 的入口函数,dyld 的入口函数会调用其_main()
函数。
在 dyld 的_main()
函数内部实现中,会调用mapSharedCache()
函数来加载共享缓存文件,共享缓存文件中包含着所有共享系统动态库的 Mach-O 文件,如果共享缓存文件还未加载,则会将共享缓存文件从磁盘映射到共享内存(虚拟内存中划分的一块区域)中;如果已加载,则会获取共享缓存文件的加载信息。
接着,会实例化主程序,并将主程序实例(image 对象)保存到sAllImages
数组中。这一步是为磁盘中主程序 Mach-O 文件的镜像(在内存中的主程序 Mach-O 文件)创建一个ImageLoader
类型的 image 对象,以便获取镜像的控制权。(程序运行时,操作的是 Mach-O 文件的镜像,而不是磁盘中的 Mach-O 文件。)
然后,加载环境变量中插入的动态库,并实例化这些动态库,以及将这些动态库实例(image 对象)保存到sAllImages
数组中。
再然后,会链接主程序。链接时主要做了以下事情:
- 递归加载主程序的依赖库以及这些依赖库的依赖库,如果依赖库是共享系统动态库,则会实例化共享缓存中的共享系统动态库,并将共享系统动态库的镜像实例保存到
sAllImages
数组中;如果不是,则会加载依赖库到内存中,并实例化依赖库镜像,然后将依赖库的镜像实例保存到sAllImages
数组中。 - 由于使用了 ASLR(地址空间布局随机化)技术,Mach-O 文件的镜像在虚拟内存中的起始地址会被增加一个随机偏移,所以镜像中的内部资源的基地址也会加上这个随机偏移。然而镜像中的内部数据指针指向的地址还是原始地址,因此 dyld 会在链接主程序时,修复主程序和其依赖库各自的内部数据指针的指向。
接着,会链接环境变量(Xcode
->Product
->Scheme
->Edit Scheme
->Run
->Arguments
可以设置环境变量)中插入的动态库。(与链接主程序时所做的事情相同)
然后,dyld 会将主程序和其依赖库各自的非懒加载外部符号绑定到真实的地址。(懒加载外部符号会在运行时动态绑定到真实的地址)
再然后,会将环境变量中插入的动态库和其依赖库的非懒加载外部符号绑定到真实的地址。(与绑定主程序时所做的事情相同)
接着,绑定主程序的弱符号。(弱符号:未初始化的全局变量;强符号:函数和已初始化的全局变量)
然后,dyld 会初始化主程序。在主程序初始化过程中,会首先调用环境变量中插入的动态库及其依赖库的初始化函数和 C++ 静态构造函数,然后再调用主程序及其依赖库的初始化函数和 C++ 静态构造函数。最先调用的是 libsystem 动态库的初始化函数,libsystem 动态库的初始化函数会触发 libobjc 动态库的_objc_init
初始化函数,该函数内部会调用runtime_init
和 dyld 的_dyld_objc_notify_register
等函数(其他函数未列出)。
runtime_init
函数会初始化用于存储类的未加载 category 的unattachedCategories
哈希表,以及初始化用于存储类和元类的allocatedClasses
集合。
_dyld_objc_notify_register
函数会调用registerObjcNotifiers
函数,该函数会保存 libobjc 传递过来的map_images
、load_images
和unmap_image
函数的地址。由于此时主程序和所有依赖库都已经被映射到内存中了,所以registerObjcNotifiers
函数在注册完回调之后,会立即调用 libobjc 的map_images
函数,并将所有使用了 libobjc 的 image 的文件路径和 mach header 传递过去。又由于此时已经有 image 完成了初始化,所以registerObjcNotifiers
函数还会遍历所有当前已经加载的 image ,如果当前 image 已经初始化了并且使用了 libobjc,则会立即调用 libobjc 的load_images
函数,并将这个 image 的文件路径和 mach header 传递过去。
之后,每当调用一个使用了 libobjc 的 image 的初始化函数之后,dyld 就会调用一次 libobjc 的load_images
回调函数,并将这个 image 的文件路径和 mach header 传递过去。
调用map_images
函数时,会保存所有依赖 libobjc 动态库的 image 的 header 信息,注册这些 image 中的 sel、协议、类,并实现这些 image 中非懒加载类和其元类。
调用load_images
函数时,如果是首次调用,则会加载所有依赖 libobjc 动态库的 image 中的 category。并且每次调用load_images
函数时,会调用本次初始化的 image 中的所有 objc 类和其 category 的+load
方法。
最后,dyld 会调用主程序的main
函数,main
函数会调用UIApplicationMain
函数,该函数首先会从可用的 storyboard 文件中加载应用程序的启动界面,并调用AppDelegate
对象的willFinishLaunchingWithOptions:
和didFinishLaunchingWithOptions:
方法来执行初始化设置,然后启动应用程序主线程的 runloop 来开始接收事件。
有关 App 启动过程的更多信息可以参看 dyld详解,深入理解虚拟内存机制,启动优化之Clang插桩实现二进制重排,iOS启动时间优化,XNU、dyld源码分析Mach-O和动态库的加载过程(上),XNU、dyld源码分析Mach-O和动态库的加载过程(下),dyld加载流程,深入iOS系统底层之程序镜像。
XNU 源码的
load_init_program()
函数在 xnu/bsd/kern/kern_exec.c,load_machfile()
函数在 xnu/bsd/kern/mach_loader.c。dyld 的入口函数_main()
在 dyld/src/dyld2.cpp。
如何优化应用程序的启动时长?
启动时长检测
- 点击 Xcode 的
Product
->Scheme
->Edit Scheme
->Run
->Auguments
,添加值为1
的环境变量DYLD_PRINT_STATISTICS
。 - 使用
Instruments
中的App Launch
。
main函数调用之前的优化
- 二进制重排;
- 删除没有使用的变量和方法;
- 删除没有使用的 Objective-C 类;
- 将类的
+load
方法中的初始化设置移到+initializer
方法中去。
main函数调用之后的优化
- 减少
willFinishLaunching
和didFinishLaunching
方法中的初始化设置,将不是第一时间需要执行的操作延后执行; -
didFinishLaunching
方法执行完毕后首次显示的应用界面使用代码布局来代替 xib 和 storyboard,因为 xib 和 storyboard 文件的解析需要耗费额外的时间。(这样做可以加快用户看到首界面的时间)
二进制重排
作用
虚拟内存和物理内存(运行内存,在内存条上)是分页的,每页大小为 16KB(64位架构下,page 大小为16KB;32位架构下,page 大小为4KB)。当访问一个虚拟内存页时,如果对应的物理内存页还未分配时,就会触发一次缺页中断。这时,系统会阻塞进程,分配物理内存页,从磁盘读取数据缓存到物理内存页中。如果应用程序是通过 App Store 分发的,触发缺页中断后,还会对代码进行签名验证。所以,处理缺页中断是一项比较耗时的操作。
由于系统使用懒加载方式加载 Mach-O 文件,Mach-O 文件一开始并没有从磁盘读入到内存,只是和内存有一个映射。因此,应用程序一开始只是分配了虚拟内存,但还未分配物理内存。假设应用程序在启动过程中需要调用方法 A 和 方法 B,当首次调用方法 A 时,由于方法 A 所在的虚拟内存页对应的物理内存页不存在,所以会触发缺页中断。此时,系统会从磁盘读取方法 A 对应的物理内存页所包含的所有数据,并将这些数据缓存到物理内存页中。如果方法 A 和方法 B 是在同一个内存页(page)中,那么后面调用方法 B 时,就不会触发缺页中断了。而函数的二进制代码在 Mach-O 文件中的位置是根据编译顺序而不是调用顺序来排列的,所以方法 A 和方法 B 有可能分布在不同的内存页上。可以通过重新排列应用程序在启动时调用的方法的二进制代码在 Mach-O 文件中的位置来使它们分配在同一个内存页中,这样就能减少触发缺页中断的次数,从而加快应用程序的启动过程。
如何重排二进制
Xcode 的Build Settings
中的Linking
项有一个Order File
参数,可以通过这个参数配置一个 order 文件的路径。在这个 order 文件中,将应用程序启动过程中调用的符号按顺序写在这个文件中。在构建工程的时候,Xcode 会读取这个文件,并按照文件中的符号顺序来调整对应代码在 Mach-O 文件中的偏移地址,将在启动过程加载的方法集中到 Mach-O 文件的最前面。
如何检测应用程序在启动过程中调用了哪些方法
使用Clang
静态插桩在编译期在每一个函数内部添加 hook 代码。
如何查看应用程序启动过程中的缺页中断(page fault)次数
使用Instruments
中的System Trace
,选择真机设备,然后选择调试的应用程序,点击启动,等 app 首界面展示之后,终止调试,查看 app 的Main Thread
的Virtual Memory
中的File Backed Page In
次数。
如何 hook 主程序所引用的系统动态库的 C 函数?
主程序 Mach-O 文件 DATA 部分的__LINKEDIT
段包含一个字符串表(string table),一个符号表(symbol table),一个间接符号表(indirect symbol table)。
字符串表是一个存放着所有符号的字符串名称的数组。
符号表是一个存储着主程序中所有符号(内部符号和外部符号)的数组。
符号是一个nlist
结构体,其中存储着符号的字符串名称在字符串表中的索引。
// 64位架构下符号的数据结构为
struct nlist_64 {
union {
uint32_t n_strx; // 符号的字符串名称在字符串表中的索引
} n_un;
uint8_t n_type; /* type flag, see below */
uint8_t n_sect; /* section number or NO_SECT */
uint16_t n_desc; /* see <mach-o/stab.h> */
uint64_t n_value; /* value of this symbol (or stab offset) */
};
间接符号表是一个存储着所有符号指针所对应的符号在符号表中的索引的数组。
Mach-O 文件的__DATA
段中包含一个非懒加载外部符号指针数组__nl_symbol_ptr
和一个懒加载外部符号指针数组__la_symbol_ptr
。
__nl_symbol_ptr
和__la_symbol_ptr
分别对应着一个 section,section header 数据结构中的reserved1
字段是【指针数组中第一个指针所对应的外部符号在符号表中的索引】在【间接符号表】中的【偏移量】,间接符号表的地址加上reserved1
偏移量就是数组中第一个指针对应的符号索引的地址。由于__nl_symbol_ptr
和__la_symbol_ptr
中每个指针对应的符号索引在间接符号表中是连续存储的,所以获取到第一个指针对应的符号索引后,就能获取到每个指针对应的符号索引了。
根据符号索引,可以从符号表中读取到外部符号指针对应的符号,再根据符号中存储的字符串索引,就可以从字符串表中读取到符号对应的符号名称了。
hook 系统 C 函数原理:以字符串名称相匹配的方式找到系统动态库 C 函数的指针,然后保存系统 C 函数的原始地址,再将指针指向自定义函数的地址。在自定义函数中,通过保存系统 C 函数的原始地址直接调用系统 C 函数,并执行其他额外操作。
更多信息,可以参看 Fishhook替换C函数的原理,iOS hook框架之——fishhook。
关于编译链接的相关信息,可以参看 彻底理解链接器。
动态库,静态库以及 framework 之间的区别
- 静态库的文件后缀名为
.a
,动态库的文件后缀名为.tbd
或者.dylib
; - 静态库在编译时会被拷贝到应用程序的可执行文件中去,如果有多个应用程序使用这个静态库,那么它会被多次加载到物理内存中;
- 动态库在编译时不会被拷贝到应用程序的可执行文件中去,而是在启动应用程序时,由系统动态加载到物理内存中,系统只会加载一次,多个应用程序可以共用。(iOS 不允许使用自定义的动态库,否则会被拒绝上架)
- 静态库和动态库只是一个纯二进制文件,而 framework 中除了二进制文件之外,还包含资源文件和头文件;
- 静态库和动态库不能直接使用,需要有
.h
头文件配合,framework 文件可以直接使用; - framework = 静态库(或者动态库)+ 头文件 + 资源文件;
UIViewController的生命周期
- 创建(
alloc
)并初始化(init
)视图控制器; - push 或者 present 视图控制器;
- 调用视图控制器的
loadView
方法来加载与其关联的视图; - 视图加载完毕后,调用
viewDidLoad
方法; - 在视图将要显示之前,会调用
viewWillAppear
方法; - 将要布局视图的子视图之前,会调用
viewWillLayoutSubviews
方法; - 在布局完视图的子视图之后,会调用
viewDidLayoutSubviews
方法; - 在视图已经显示之后,会调用
viewDidAppear
方法; - pop 或者 dismiss 视图控制器;
- 在视图将要从屏幕上移除时,会调用
viewWillDisappear
方法; - 在视图已经从屏幕上移除后,会调用
viewDidDisappear
方法。 - 当应用程序内存不足时,会调用
didReceiveMemoryWarning
方法。
触摸事件响应链
应用程序使用响应者对象来接收和处理事件,属于UIResponder
类的实例对象都是响应者。当应用程序接收到一个事件时,UIKit 会自动将该事件指向最合适的响应者对象,此响应者称为第一响应者。响应者接收到原始事件后,必须处理该事件或者将此事件转发给另一个响应者。UIkit 定义了如何将事件从一个响应者传递到下一个响应者的默认规则:
-
UIView
对象:如果这个视图是视图控制器的根视图,那么下一个响应者就是这个视图控制器;否则,下一个响应者就是它的父视图。 -
UIViewController
对象:如果视图控制器是window的根视图控制器,则下一个响应者就是window;否则,下一个响应者是该视图控制器的父视图控制器。 -
UIWindow
对象:window的下一个响应者是UIApplication
对象。 -
UIApplication
对象:UIApplication
对象的下一个响应者就是AppDelegate
对象。
可以随时通过覆盖响应者对象中的nextResponder
属性来更改 UIKit 定义的默认规则。
确定触摸事件的第一响应者
点击屏幕后,系统内核会生成一个触摸事件,并通过 mach port 将触摸事件传递给当前处于前台运行的应用程序。然后,该应用程序主线程的 runloop 所注册的基于端口的输入源(source1)会触发回调,并将这个触摸事件交给 UIKit 去进行应用内分发。
UIKit 会调用主window
的hitTest:withEvent:
方法来查找视图层中包含触摸点的最上层视图。在hitTest:withEvent:
方法内部实现中,如果当前视图不能响应用户交互,或者被隐藏,或者alph
小于0.01,则会忽略当前视图及其子视图。否则,会调用pointInside:withEvent:
方法来判断当前视图是否包含触摸点。
如果不包含,则会忽略当前视图及其子视图;如果包含,则会倒叙遍历(最先访问最后添加的子视图)当前视图的子视图,并调用每个子视图的hitTest:withEvent:
方法来查找当前子视图层中包含触摸点的最上层视图。
如果主window
的hitTest:withEvent:
方法最终返回nil
,则应用程序会忽略这个触摸事件;否则,UIKit会将触摸事件传递给主window
的hitTest:withEvent:
方法所返回的视图。
如果这个视图实现了touchesBegan:withEvent:
、touchesMoved:withEvent:
和touchesEnded:withEvent:
方法中的一个或者多个,并且这个视图所在视图层中的所有视图都没有添加手势识别器,那么当触摸开始发生时,系统会调用其touchesBegan:withEvent:
方法去响应触摸事件。当触摸位置移动时,会调用其touchesMoved:withEvent:
方法,当触摸结束时,会调用touchesEnded:withEvent:
方法;如果这几个方法一个都没有被实现,那么系统会沿着默认的响应者链去传递触摸事件。如果响应者链中有响应者实现了这些方法,那么该响应者对象就会去处理传递来的触摸事件。否则,该触摸事件就不会被处理。
如果这个视图实现了touchesBegan:withEvent:
、touchesMoved:withEvent:
和touchesEnded:withEvent:
方法中的一个或者多个,并且这个视图所在视图层中的某些视图有添加手势识别器,那么当触摸开始发生时,系统会调用其touchesBegan:withEvent:
方法去响应触摸事件。随后,如果视图层中的视图所添加的手势识别器识别手势成功了,则会立即将触摸事件传递给手势识别器去处理,然后会调用这个视图的touchesCancelled:withEvent:
方法。
UITableView的Cell重用机制
tableView 加载 cell 时,首先在重用池中查找有没有可以重用的 cell。如果没有可重用的 cell,则创建一个新的 cell 来加载,并将这个 cell 添加到当前正在显示的 cell 数组当中去;如果有可重用的 cell,则从重用池中取出这个 cell 来加载,并将 cell 添加到当前正在显示的 cell 数组中去。
滑动 tableview 时,会移除已经没有显示的 cell,然后从当前正在显示的 cell 数组中取出已经没有显示的 cell,并将其添加到重用池中。
刷新 tableview 时,会清空当前正在显示的 cell 数组,并将这些 cell 添加到重用池中,然后重新加载 cell。
UICollectionView自定义布局
-
prepareLayout
方法:提前计算好 cell 的布局信息和 UICollectionView 的内容区域大小,并将它们缓存; -
collectionViewContentSize
方法:返回 UICollectionView 的内容区域大小; -
layoutAttributesForElementsInRect:
方法:UICollectionView 基于当前的滚动位置调用此方法来查找在特定 rect 中的 cell 和补充视图(headerView 和 footerView)的布局信息,然后使用这些布局信息来展示 cell 和补充视图。需要在该方法中遍历提前计算好的所有布局信息,返回所有 frame 和给定 rect 相交的 cell 和补充视图的布局信息。
UIView和CALayer的区别和联系
-
CALayer
负责绘制内容,不能传递和响应事件; -
UIView
负责管理CALayer
需要绘制的内容,以及负责传递和响应事件; - 每个
UIView
都关联有一个CALayer
,并且UIView
是这个CALayer
的委托对象。对UIView
与显示内容相关的属性进行操作时,实际上是在对其关联的CALayer
的相关属性进行操作。
图像显示原理
计算机屏幕显示图像时,是以屏幕上的单个像素点来代表图像中的某个点的,对一组像素点进行排列和着色就能构成图像了。由像素点组成的图像,叫做位图。
在显示图像时,由 CPU 计算布局信息,并将需要显示的内容绘制成位图(也就是纹理),然后将这些位图传递给 GPU。接着,由 GPU 进行纹理的变换、合成和渲染,并将渲染结果提交到帧缓冲区。当硬件时钟发出 VSync 信号时,视频控制器会从帧缓冲区中读取数据来传递给屏幕去显示。
界面滑动卡顿的原因
在界面滑动过程中,如果人眼每隔 16.7ms 就能看到一帧新的画面,那么人眼所看到的动画效果就是流畅的。
iOS 每隔 16.7ms 就会产生一个 VSync 信号,如果在下一个 VSync 信号到来时,CPU 和 GPU 没有完成显示内容的提交,那么这一帧画面就会被丢弃。而此时,屏幕会保留之前的画面不变,这样就会导致人眼所看到的动画效果是卡顿的。
离屏渲染
什么是离屏渲染
离屏渲染,指的是 GPU 在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染。
哪些操作会触发离屏渲染
启用图层的masksToBounds
,shadow
、mask
属性时,会触发 GPU 的离屏渲染。
为何要避免离屏渲染
启用图层的这些属性之后,Core Animation 只是在 CPU 中绘制了图层内容,CPU 将图层内容交给 GPU 后,会由 GPU 去剪切内容区域、绘制阴影和遮罩。GPU 在绘制这些内容时,会新开辟一个缓冲区,并将上下文环境从当前屏幕缓冲区切换到屏幕外缓冲区。绘制完成后,又会将上下文环境从屏幕外缓冲区切换回当前屏幕缓冲区,然后进行纹理合成,并将结果渲染到当前屏幕缓冲区。由于上下文切换的开销非常昂贵,所以要尽量避免使用离屏渲染。
光栅化
如果不可避免的要触发离屏渲染,并且触发离屏渲染的图层的内容不会频繁的变化,则可以启用图层的光栅化shouldRasterize
属性。图层启用光栅化之后,在第一次离屏渲染完成后,会缓存这个图层的位图,这个位图的缓存时效是有限制的。在缓存时效内刷新屏幕时,会直接读取缓存来使用。
界面滑动卡顿的优化方案
使用 Time Profiler 来查看代码运行所耗费的时间,使用 Core Animation 获取图形绘制情况、FPS 和离屏渲染。
xcode 9.3 之后,运行app,勾选
debug
->view debugging
->rendering
来查看离屏渲染。
重用Cell
如果 cell 展示的内容比较复杂,那么视图对象的创建会比较耗时。而重用 cell 就可以减少 CPU 的工作量,使 CPU 更快输出位图。
预排版
向服务器请求数据成功后,预先计算 cell 中子视图的布局信息和 cell 的高度,并缓存下来,然后再刷新 tableview。这样,tableview 在滚动过程中显示 cell 时,就不用再进行布局计算了。这种方式减少了 CPU 在 tableview 滚动过程中的工作量,让 CPU 能够更快地输出位图。但是,这样做延迟了用户看到新数据的时间。
预渲染
如果 cell 中有图层启用了masksToBounds
、shadow
、mask
属性,GPU 会触发离屏渲染,而离屏渲染会增加 GPU 的工作量。当需要 GPU 进行离屏渲染的图层较多时,GPU 就会满负荷运转,导致不能及时输出渲染结果。可以使用贝塞尔曲线(UIBezierPath
)来设置圆角和阴影,将圆角和阴影的绘制转移到 CPU 中,从而减轻 GPU 的压力(相对于 CPU 而言,GPU 绘制圆角和阴影会更加耗时)。
减少视图层级
由 CPU 输出的多个位图最终会被 GPU 合成为一个,视图层级越复杂,GPU 纹理合成所耗费的时间就越长。
尽量避免设置视图透明
如果视图不透明,GPU 在进行纹理合成的时候,可以将其像素值直接覆盖到父视图上。而如果视图包含透明度的话,GPU 必须重新计算两个视图重叠区域的像素值,这会增加 GPU 的工作量,所有要尽量避免设置视图透明。
异步解码图片并缓存解码结果
PNG 和 JPEG 是压缩之后的位图图像格式,只有先对 PNG 和 JPEG 图片数据进行解压缩而得到其原始像素点数据后,GPU 才能合成和渲染。
UIKit 默认是在主线程串行执行图片的解码操作的,而图片的解码又比较耗时,所以我们可以将多个图片的解码操作移到子线程去并行执行,这样也能让 CPU 更快输出位图。而将解码后的结果缓存起来,在下一次显示图片时,就可以直接使用缓存而不用再次解码了。
异步绘制
UIKit 默认是在主线程串行执行文本绘制和图形绘制的,将多个文本绘制和图形绘制移到子线程去并发执行,也能够让 CPU 更快输出位图。但这需要我们自行实现视图的绘制,也可以使用第三方库Texture(AsyncDisplayKit)来实现异步绘制。
UIView的绘制过程
Core Animation 在主线程的 Runloop 中注册了一个 Observer 来监听 Runloop 状态的变化。
触摸事件唤醒主线程的 Runloop 后,Runloop 会执行一些操作,比如视图的外观调整和视图的层级调整,每个这样的操作都会触发view
的setNeedsDisplay
方法。view
的setNeedsDisplay
方法不会立刻就绘制内容,它只是调用其layer
的setNeedsDisplay
方法来标记这个视图需要重新绘制。
在主线程的 Runloop 进入休眠状态或者退出之前,会发送一个通知给 Core Animation。Core Animation 接收到通知后,会调用layer
的display
方法来绘制视图。
在display
方法的内部实现中,首先会判断layer
的delegate
(持有layer
的view
就是layer
的delegate
)有没有实现displayLayer:
代理方法。如果有实现,就会进入到自定义绘制流程中去。如果没有实现,就会进入到系统绘制流程;当绘制完成后,Core Animation 会将位图提交给 GPU 去处理。
系统绘制流程
如果layer
的delegate
(持有layer
的view
就是layer
的delegate
)不为nil
,则会调用view
的drawLayer:inContext:
方法来绘制内容。在drawLayer:inContext:
方法的内部实现中,还会调用view
的drawRect:
方法来绘制自定义内容;如果layer
的delegate
为nil
,则会调用layer
的drawInContext:
方法来绘制内容。
异步绘制原理
异步绘制的原理就是实现UIView
的dispayLayer:
代理方法来自定义绘制流程。在dispayLayer:
方法实现中,在子线程将需要显示的内容绘制到图形上下文中,然后根据这个图形上下文创建一个位图,最后在主线程将这个位图赋值给layer
的contents
属性。
如何监控界面滑动卡顿?
监听主线程的 runloop 的状态变化,当 runloop 处于BeforeSources
(非基于端口的输入源即将触发)或者AfterWaiting
(线程刚被唤醒)状态时,就发出一个信号量。同时,在子线程运行一个While
循环来不断等待这个信号量。如果连续几次等待信号量超时,则可以判定界面滑动时产生了卡顿。
如何计算 FPS
将CADisplayLink
添加到主线程的 runloop 中,并与 common 模式绑定。使用某个时间点到当前时间内CADisplayLink
触发的总次数除以某个时间点到当前时间的时长,就可以计算出 FPS 了。
正常情况下,主线程的 runloop 每隔 16.7ms 就会触发一次
CADisplayLink
回调。如果主线程执行了一个耗时 40ms 的任务,那么 runloop 就会少触发 2 次CADisplayLink
回调,而此时屏幕也会少更新了 2 帧画面。
网络七层协议
OSI 七层模型由上至下分别为应用层、表示层、会话层、传输层、网络层、数据链路层、物理层。
URL是什么?
统一资源定位符。通过一个 URL,能找到互联网上唯一的一个资源。
URL 的基本格式 = 协议://主机地址/路径。
- 协议:不同的协议代表着不同的资源查找方式、资源传输方式。
- 主机地址:存放资源的主机(服务器)的 IP 地址(域名)。
- 路径:资源在主机(服务器)中的具体位置。
URL 中常见的协议有以下几种:
- HTTP:超文本传输协议,访问的是远程的网络资源。
- file:访问的是本地计算机上的资源。
- mailto:访问的是电子邮件地址。
- FTP:访问的共享主机的文件资源。
HTTP
超文本传输协议,是一个应用层协议。
请求报文的内容
- 请求行:包含了请求方法、请求资源路径、HTTP 协议版本。(
GET
,/Server/resources/images/1.jpg
,HTTP/1.1
。) - 请求头:包含了客户端想访问的服务器主机地址以及客户端的类型、软件环境、语言环境、所能接收的数据类型和支持数据压缩格式信息。(客户端想访问的服务器主机地址:
Host : 192.168.1.105:8080
,客户端的类型和软件环境:User-Agent : Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9) Firefox/30.0
,客户端所能接收的数据类型:Accept : text/html, */*
,客户端的语言环境:Accept-Language : zh-cn
,客户端支持的数据压缩格式:Accept-Encoding : gzip
。) - 请求体:客户端发送给服务器的具体数据。
响应报文的内容
- 状态行:包含了 HTTP 协议版本、状态码、状态英文名称。(
HTTP/1.1
,200
,OK
。) - 响应头:包含了对服务器的描述、对返回数据的描述。(服务器的类型:
Server: Apache-Coyote/1.1
,返回数据的类型:Content-Type: image/jpeg
,返回数据的长度:Content-Length: 56811
,响应的时间:Date: Mon, 23 Jun 2014 12:54:52 GMT
。) - 实体内容:服务器返回给客户端的具体数据。
HTTP定义的请求方法
-
GET
:获取资源,不会对服务器中存储的资源进行修改; -
POST
:获取资源,但是可能会对服务器中存储的资源进行修改; -
PUT
:上传文件到服务器; -
DELETE
:删除服务器中的指定文件; -
HEAD
:和GET
方法一样,但是服务器返回的不是资源,而是资源的头信息 -
OPTIONS
:获取服务器支持的请求方法。
HTTP的优点和缺点
- HTTP 协议比较简单,程序规模小,因而通信速度很快。
- 允许传输任意类型的数据,非常灵活。
- HTTP 协议是一种无状态协议。无状态是指协议对于事物处理没有记忆能力,服务器不知道客户端是什么状态。缺少状态意味着如果后续处理需要前面的信息,则它必须重传,这样可能导致每次连接传送的数据量增大。
- 明文传输数据,缺乏安全性。
HTTP协议无状态的解决方案
- Cookie:Cookie 是一种在客户端保存状态信息的机制。客服端发送请求到服务器时,服务器会生成一个标识客户端的 Cookie,并将其与数据一起返回给客户端,由客户端保存在本地。客户端再次发送请求时,会将该 Cookie 发送给服务器,服务器通过 Cookie 来确认客户端的状态信息。如果 Cookie 不设定过期时间的话,关闭浏览器时,Cookie 就失效了。如果设置了 Cookie 的过期时间,那么浏览器会把 Cookie 保存到硬盘中,再次打开浏览器时,会依然有效,直到超过设置的有效期。Cookie 机制将数据保存在客户端,缺乏安全性,且数据大小有限制。
- Session:Session 是一种在服务器端保存状态信息的机制。客户端向服务器发送请求时,服务器会生成一个 Session 并保存,同时返回一个 Session id 给客户端。客户端在后续的请求中将 Session id 发给服务器,服务器通过该 Session id 来确认客户端的状态信息。服务器会把长时间没有活动的 Session 从服务器内存中清除,此时 Session 便失效了。Session 机制将数据保存在服务器,会占用服务器的性能。
常见的响应状态码及其含义
状态码的类别:
- 1XX:信息性状态码,请求正在处理;
- 2XX:成功性状态码,请求正常处理完毕;
- 3XX:重定向状态码,需要进行附加的操作以完成请求;
- 4XX:客户端错误状态码,服务器无法处理请求;
- 5XX:服务器错误状态码,服务器处理请求出错。
常见的状态码:
- 200:请求成功。
- 301:请求的资源的位置已被更改,并且是永久性的更改。
- 400:客户端请求的语法错误,服务器无法解析。
- 404:服务器无法找到客户端请求的资源。
- 500:服务器内部错误,无法完成请求。
GET请求和POST请求的区别
- GET 请求的参数以
?
分割拼接到 URL 上,POST 请求的参数是放在 HTTP Body 中; - GET 请求传递的参数是有限制的,通常不能超过 1KB,而 POST 请求一般是没有限制的;
- GET 请求只是获取数据,不会引起服务器状态变化。而 POST 请求是提交数据,可能会引起服务器的状态变化;
- 同一个 GET 请求执行多次和执行一次的效果完全相同,而同一个 POST 请求多次执行的结果可能不是完全相同的;
- GET 请求可以缓存,而 POST 请求不能缓存。
POST请求的 body 使用 form-urlencoded 和使用 multipart/from-data 有什么区别?
发送纯文本数据时,使用form-urlencoded
格式对数据进行编码。发送的数据包含图片、音频或其他二进制数据时,使用multipart/from-data
格式对数据进行编码。
HTTPS 协议
HTTPS 协议,安全套接字层超文本传输协议。为了数据传输的安全,HTTPS 协议在 HTTP 协议的基础上加入了 SSL/TLS 协议, SSL/TLS 协议依靠证书来验证服务器的身份,并为客户端和服务器之间的通信加密。
HTTPS连接的建立流程
单向认证,客户端和服务器都要存放向 CA 申请的服务器证书,其流程如下:
- 客户端发送
TLS协议版本号
、客户端支持的加密算法
以及随机数C
到服务端;
- 客户端发送
- 服务器收到客户端所支持的加密算法之后,会和自己支持的加密算法进行对比。如果不符合,则断开连接。否则,把
确认使用的加密算法
、服务器证书
和随机数S
发送给客户端;
- 服务器收到客户端所支持的加密算法之后,会和自己支持的加密算法进行对比。如果不符合,则断开连接。否则,把
- 客户端收到服务器证书后,会将其与本地存放的服务器证书进行对比。如果验证失败,则断开连接;如果验证成功,那么客户端会使用服务器证书中的
公钥
生成一个前主密钥
,并使用前主密钥
、随机数C
和随机数S
生成一个会话密钥
,然后客户端使用服务器证书中的公钥
对前主密钥
加密并发送给服务器;
- 客户端收到服务器证书后,会将其与本地存放的服务器证书进行对比。如果验证失败,则断开连接;如果验证成功,那么客户端会使用服务器证书中的
- 服务器接收到加密的
前主密钥
后,使用服务器证书的私钥
解密得到前主密钥
,然后使用前主密钥
、随机数S
和随机数C
生成本次会话所用的会话密钥
,握手结束。
- 服务器接收到加密的
- 客户端和服务器开始进行通信,通信内容使用
会话密钥
加密。
- 客户端和服务器开始进行通信,通信内容使用
双向认证,客户端和服务器除了要存放服务器证书之外,服务器还要存放一个 CA 根证书,客户端还要存放一个由 CA 根证书签名的 p12 证书。在客服端验证服务器成功后,客户端还会发送 p12 证书和一段由 p12 证书签名的数据到服务器,服务器会使用根证书对 p12 证书和由 p12 证书签名的数据进行验证。验证成功,就会继续后面的流程;验证失败,则会断开连接。
HTTPS 和 HTTP 的区别
- HTTPS协议是由 HTTP+SSL/TLS 协议构建的可进行加密传输、身份认证的网络协议,比 HTTP 协议安全。
- HTTP 和 HTTPS 使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443。
TCP
TCP 协议的全称是传输控制协议,它是一种面向连接的、可靠的、基于字节流的传输层协议。其主要解决数据如何在网络中传输,而应用层的 HTTP 协议主要解决如何包装数据。
三次握手
建立起一个 TCP 连接需要经过三次握手:
- 第一次握手:客户端发送
连接请求
到服务器,等待服务器确认;
- 第一次握手:客户端发送
- 第二次握手:服务器收到客户端的
连接请求
后,向客户端发送一个确认消息
,并等待客户端确认;
- 第二次握手:服务器收到客户端的
- 第三次握手:客户端收到服务器的
确认消息
后,也会向服务器发送一个确认消息
。
- 第三次握手:客户端收到服务器的
三次握手完毕后,正式开始在客户端和服务器之间传送数据。理想状态下,TCP 连接一旦建立,在通信双方中的任何一方主动关闭连接之前,TCP 连接将被一直保持下去。
服务器向客户端发送确认消息
后,还需要等待客户端的确认。这是因为如果客户端发送给服务器的连接请求
在超出等待时间后,客户端还未收到服务器的确认,客户端会将这个连接请求
标记为已失效,然后客户端再次发送连接请求
到服务器,并成功建立 TCP 连接。此时,之前失效的连接请求
突然又传送到了服务端,如果没有客户端的确认的话,服务端会又建立一个 TCP 连接。
四次挥手
需要断开 TCP 连接时,服务器和客户端均可以主动发起断开 TCP 连接的请求,断开过程需要经过四次挥手:
- 第一次挥手:客户端发送
断开请求
到服务器,等待服务器确认;
- 第一次挥手:客户端发送
- 第二次挥手:服务器收到客户端的
断开请求
后,会发送确认消息
给客户端;
- 第二次挥手:服务器收到客户端的
- 第三次挥手:服务器没有数据需要发送到客户端后,服务器会发送
断开请求
给客户端,并等待客户端确认;
- 第三次挥手:服务器没有数据需要发送到客户端后,服务器会发送
- 第四次挥手:客户端收到服务器的
断开请求
后,发送确认消息
给服务器。
- 第四次挥手:客户端收到服务器的
四次挥手完毕后,服务器和客户端之间就都断开了 TCP 连接。
断开 TCP 连接需要四次挥手而不是三次是因为关闭连接时,当接收方收到对方的断开请求
后,仅仅表示对方没有数据需要发送了。但是接收方可能还有数据需要发送给对方,所以不会马上断开 TCP 连接。
可靠数据传输
TCP 连接通过序号和确认应答来保证数据传输的可靠性。
当发送端发送数据之后需要等待接收端的确认,如果收到接收端的确认应答,表示数据成功发送到接收端;如果在一定时间内没有收到接收端的确认应答,则认为数据已丢失,需要重新发送。
没有收到接收端的确认应答的话,分两种情况,一种是接收端没收到数据,另一种是接收端收到了数据但是它的确认应答丢失了。如果是接收端的确认应答丢失了,那么发送端会重新发送数据,接收端就会重复接收相同的数据。为了解决接收端重复接收相同数据的问题,可以通过为发送的数据标上序号,接收端收到数据后,根据本次接收数据的序号,将下一次应该接收的序号作为应答返回给发送端。如果接收端接收的数据的序号是重复的,则会丢弃接收的数据。
流量控制(滑动窗口)
TCP 连接的双方各自为该 TCP 连接分配一个发送缓存和一个接收缓存。当接收到数据后,会将数据存放到接收缓存中。上层的应用进程会从接收缓存中读取数据,但不是数据一到达接收缓存就立刻读取,因为此时上层的应用进程可能正在处理其他事务。如果接收方的应用层读取数据较慢,而发送方发送数据太多太快,那么接收方的接收缓存很可能会溢出。所以,TCP 为应用程序提供了流量控制服务,以避免出现缓存溢出的情况。
TCP 连接的双方各自维护着一个发送窗口和一个接收窗口来提供流量控制,发送窗口的大小是由对方的接收窗口来决定的,接收窗口用于指示发送方该接收方的接收缓存还有多少可用空间,发送窗口决定了发送方还能发送多少数据给接收方。当接收缓存的可用空间为0时,发送端会停止发送数据。
拥塞控制
计算机网络中的带宽,交换结点中的缓存和处理机,都是网络的资源。在某段时间内,如果对网络中某一资源的需求超过了该资源所能提供的可用部分,网络的性能就会变坏,这种情况叫做网络拥塞。
如果出现拥塞而不进行控制,整个网络的吞吐量将会随输入负荷的增大而下降。为了解决这个问题,TCP 提供了拥塞控制机制,以便让连接的双方根据所感知到的网络拥塞程度来限制其向对方发送流量的速率。
TCP 连接的双方各自维护有一个拥塞窗口,拥塞窗口决定了发送方能向网络中发送流量的速率,拥塞窗口的大小取决于网络拥塞的程度。
TCP 发送方如何感知到发生了网络拥塞?
当接收端收到失序报文段(即该报文段的序号大于期望的按序报文段的序号)时,接收端不会对该失序报文段进行确认。由于 TCP 不使用否定确认,为了让发送方得知这一现象,会对上一个按序报文段进行重复确认,这样就会产生一个冗余ACK
。
因为发送方经常发送大量的报文段,如果其中一个报文段丢失,那么可能在定时器过期之前,发送方就会收到大量的冗余ACK
。一旦收到3个冗余ACK
,就说明已被确认3次的报文段之后的报文段已经丢失。这时,TCP 会执行快速重传(即在该报文段的定时器过期之前重传该报文段)。
当出现网络拥塞时,路由器的缓存会溢出,从而导致数据报被丢弃,这会引发 TCP 连接的丢包。所以,当 TCP 连接出现丢包时,发送方就可以确定出现了网络拥塞。
TCP如何限制发送方发送流量的速率?
当出现丢包事件时,降低发送方发送流量的速率;当接收到非冗余ACK
时,就增大发送方发送流量的速率。
TCP 拥塞控制算法
TCP 拥塞控制算法包括以下三个主要部分:
-
慢启动:每收到1个
非冗余ACK
,拥塞窗口的大小会指数级增加。如果收到了1个冗余ACK
,会重置拥塞窗口的大小,并重启慢启动。当连续收到3个冗余ACK
时,会进入快速恢复状态; -
拥塞避免:当拥塞窗口的大小超过慢启动阀值时,每收到一个
非冗余ACK
,会加法增大拥塞窗口的大小。如果收到了1个冗余ACK
,会乘法减小拥塞窗口的大小。当连续收到3个冗余ACK
时,会进入快速恢复状态; -
快速恢复:当收到1个
非冗余ACK
时,会进入拥塞避免状态。
Socket
Socket(套接字)是网络通信过程中端点的抽象表示,包含进行网络通信必须的五种信息:连接使用的协议、本地主机的 IP 地址、本地进程的协议端口、远程主机的 IP 地址、远程进程的协议端口。
应用层通过传输层进行数据通信时,TCP 连接会遇到同时为多个应用程序进程提供并发服务的问题。多个 TCP 连接或多个应用程序进程可能需要通过同一个 TCP 协议端口传输数据。为了区别不同的应用程序进程和连接,计算机操作系统为应用程序与 TCP/IP 协议交互提供了套接字接口。通过套接字接口区分来自不同应用程序进程或网络连接的通信,实现数据传输的并发服务。
建立 Socket 连接至少需要一对套接字,其中一个运行于客户端,另一个运行于服务器。建立 Socket 连接时,可以指定使用的传输层协议(TCP 或 UDP)。当使用 TCP 协议进行连接时,该 Socket 连接就是一个 TCP 连接。
UDP
UDP 协议的全称是用户数据报协议,它是一种传输层协议。
使用 UDP 协议传输数据时,服务器在发出数据报文之后,不会确认对方是否已接收到数据,所以不需要在客户端和服务器之间建立连接。因此,UDP 协议是不可靠的。
UDP 协议发送的每个数据报文的大小限制在64KB之内,所以其传输速度非常快。其应用场景包括多媒体教室、网络视频会议系统等。
JSON和XML两种数据结构的区别,JSON解析和XML解析的底层原理。
- XML 的可读性和对数据的描述性比 JSON 要好;
- JSON 的编码和解码更简单,因而数据体积小,传输速度更快。
JSON解析的底层原理
遍历文本中的字符,并根据{}
、[]
、,
和:
进行区分。{}
代表字典,[]
代表数组,,
是字典的键值对以及数组元素的分隔符,:
是键值对的 key 和 value 的分隔符。最终结果是将 JSON 文本转换为一个字典或者数组。
XML解析的底层原理
- DOM 解析:根据节点将 XML 文本转换为一个包含其所有内容的树,并对树进行遍历。使用 DOM 解析时,需要处理整个 XML 文本。
- SAX 解析:遍历 XML 文本,当发现给定的节点时,会触发回调来告知指定的标签已经找到。当只需要处理文本中所包含的部分数据时,使用 SAX 解析可以在找到指定的标签后就停止解析。