iOS

2018-03-27  本文已影响0人  渐z

可否使用 == 来判断两个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:方法了。

NSObjectisEqual:方法默认还是比较两个对象的内存地址是否相同,如果想要比较两个对象的内容,就需要重写isEqual:方法来自行实现。

官方为 Foundation 框架中某些类(这些类不仅重写了isEqual:方法,还额外实现了一个isEqualToXXX:方法)重新实现了isEqual方法,例如:

NSObject的-(NSUInteger)hash方法有什么用?

将对象添加到NSSet中,或者对象作为NSDictionary的 key 时,会调用对象的hash方法来计算该对象的哈希值,NSSetNSDictionary使用这个哈希值来确定存储对象在哈希表中的位置。

NSObjecthash方法默认返回的是对象的内存地址,这非常满足哈希值对于唯一性的要求。但在某些场景下,却不能满足需求。例如,对于两个字符串内容相同的NSString对象来说,如果它们的创建方式不相同,就会导致它们的内存地址并不相同。当使用NSString对象作为NSDictionary的 key 时,如果还是使用内存地址作为NSString对象的哈希值,就会出现问题。因此,官方重写了NSStringhash方法。

重写对象的hash方法的最佳实践,就是对其关键属性的hash方法的返回值进行按位异或运算,然后将结果值作为该对象的哈希值。

- (NSUInteger)hash {
    return [self.name hash] ^ [self.birthday hash];
}

isEqual方法和hash方法之间的关系?

相等的两个对象,它们的 hash 值一定相等。但是,两个对象的 hash 值相等的话,这两个对象并不一定相等,还要比较对象的具体内容。

iOS的进程之间的通信方式有哪些?

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调用视图的setNeedsLayoutlayoutIfNeeded方法后,或者设置视图的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类型。

NSIntegerNSUInteger是动态定义的类型,在不同架构下,它们可能是int类型,也可能是long类型。应该尽可能使用NSIntegerNSUInteger,这样就不用考虑设备是32位还是64位架构了。

应用程序的生命周期

iOS应用程序有五种状态:

启动尚未运行的应用程序时,应用程序会先切换到未激活状态,在执行didFinishLaunchingWithOptions:代理方法后,切换为激活状态。锁屏或者按下 Home 键后,应用程序会先切换到后台状态,一段时间后,大多数应用程序会被系统挂起,应用程序切换到挂起状态。内存吃紧时,系统可能会清除被挂起的应用程序,应用程序切换到未运行状态。

Mach-O 是什么?

Mach-O 是 macOS 和 iOS 的可执行文件的文件格式,Mach-O 文件分为以下几类:

Image(镜像)指的是 Executable,Dylib 或 Bundle 中的一种。Framework,指的是动态库(或者静态库)、头文件和资源文件的集合。

Mach-O 文件分为 Header,Load Commands,Data 三部分,如下图:

Mach-O文件结构.png
/// 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 
};

segment 映射到内存的过程为:fileoff处加载filesize大小的数据到虚拟内存的vmaddr处,并占用大小为vmsize的虚拟内存。

Data 部分的 segment 有以下几种类型(还有其他类型这里未列出):

_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数组中。

再然后,会链接主程序。链接时主要做了以下事情:

接着,会链接环境变量(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_imagesload_imagesunmap_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.cload_machfile()函数在 xnu/bsd/kern/mach_loader.c。dyld 的入口函数_main()dyld/src/dyld2.cpp

如何优化应用程序的启动时长?

启动时长检测

main函数调用之前的优化

main函数调用之后的优化

二进制重排

作用

虚拟内存和物理内存(运行内存,在内存条上)是分页的,每页大小为 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 ThreadVirtual 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 之间的区别

UIViewController的生命周期

触摸事件响应链

应用程序使用响应者对象来接收和处理事件,属于UIResponder类的实例对象都是响应者。当应用程序接收到一个事件时,UIKit 会自动将该事件指向最合适的响应者对象,此响应者称为第一响应者。响应者接收到原始事件后,必须处理该事件或者将此事件转发给另一个响应者。UIkit 定义了如何将事件从一个响应者传递到下一个响应者的默认规则:

可以随时通过覆盖响应者对象中的nextResponder属性来更改 UIKit 定义的默认规则。

确定触摸事件的第一响应者

点击屏幕后,系统内核会生成一个触摸事件,并通过 mach port 将触摸事件传递给当前处于前台运行的应用程序。然后,该应用程序主线程的 runloop 所注册的基于端口的输入源(source1)会触发回调,并将这个触摸事件交给 UIKit 去进行应用内分发。

UIKit 会调用主windowhitTest:withEvent:方法来查找视图层中包含触摸点的最上层视图。在hitTest:withEvent:方法内部实现中,如果当前视图不能响应用户交互,或者被隐藏,或者alph小于0.01,则会忽略当前视图及其子视图。否则,会调用pointInside:withEvent:方法来判断当前视图是否包含触摸点。

如果不包含,则会忽略当前视图及其子视图;如果包含,则会倒叙遍历(最先访问最后添加的子视图)当前视图的子视图,并调用每个子视图的hitTest:withEvent:方法来查找当前子视图层中包含触摸点的最上层视图。

如果主windowhitTest:withEvent:方法最终返回nil,则应用程序会忽略这个触摸事件;否则,UIKit会将触摸事件传递给主windowhitTest: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自定义布局

UIView和CALayer的区别和联系

图像显示原理

ios_screen_display.png

计算机屏幕显示图像时,是以屏幕上的单个像素点来代表图像中的某个点的,对一组像素点进行排列和着色就能构成图像了。由像素点组成的图像,叫做位图。

在显示图像时,由 CPU 计算布局信息,并将需要显示的内容绘制成位图(也就是纹理),然后将这些位图传递给 GPU。接着,由 GPU 进行纹理的变换、合成和渲染,并将渲染结果提交到帧缓冲区。当硬件时钟发出 VSync 信号时,视频控制器会从帧缓冲区中读取数据来传递给屏幕去显示。

界面滑动卡顿的原因

卡顿产生的原因.png

在界面滑动过程中,如果人眼每隔 16.7ms 就能看到一帧新的画面,那么人眼所看到的动画效果就是流畅的。

iOS 每隔 16.7ms 就会产生一个 VSync 信号,如果在下一个 VSync 信号到来时,CPU 和 GPU 没有完成显示内容的提交,那么这一帧画面就会被丢弃。而此时,屏幕会保留之前的画面不变,这样就会导致人眼所看到的动画效果是卡顿的。

离屏渲染

什么是离屏渲染

离屏渲染,指的是 GPU 在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染。

哪些操作会触发离屏渲染

启用图层的masksToBoundsshadowmask属性时,会触发 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 中有图层启用了masksToBoundsshadowmask属性,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的绘制过程

UIView绘制过程.png

Core Animation 在主线程的 Runloop 中注册了一个 Observer 来监听 Runloop 状态的变化。

触摸事件唤醒主线程的 Runloop 后,Runloop 会执行一些操作,比如视图的外观调整和视图的层级调整,每个这样的操作都会触发viewsetNeedsDisplay方法。viewsetNeedsDisplay方法不会立刻就绘制内容,它只是调用其layersetNeedsDisplay方法来标记这个视图需要重新绘制。

在主线程的 Runloop 进入休眠状态或者退出之前,会发送一个通知给 Core Animation。Core Animation 接收到通知后,会调用layerdisplay方法来绘制视图。

display方法的内部实现中,首先会判断layerdelegate(持有layerview就是layerdelegate)有没有实现displayLayer:代理方法。如果有实现,就会进入到自定义绘制流程中去。如果没有实现,就会进入到系统绘制流程;当绘制完成后,Core Animation 会将位图提交给 GPU 去处理。

系统绘制流程

系统绘制流程.png

如果layerdelegate(持有layerview就是layerdelegate)不为nil,则会调用viewdrawLayer:inContext:方法来绘制内容。在drawLayer:inContext:方法的内部实现中,还会调用viewdrawRect:方法来绘制自定义内容;如果layerdelegatenil,则会调用layerdrawInContext:方法来绘制内容。

异步绘制原理

异步绘制的原理就是实现UIViewdispayLayer:代理方法来自定义绘制流程。在dispayLayer:方法实现中,在子线程将需要显示的内容绘制到图形上下文中,然后根据这个图形上下文创建一个位图,最后在主线程将这个位图赋值给layercontents属性。

如何监控界面滑动卡顿?

监听主线程的 runloop 的状态变化,当 runloop 处于BeforeSources(非基于端口的输入源即将触发)或者AfterWaiting(线程刚被唤醒)状态时,就发出一个信号量。同时,在子线程运行一个While循环来不断等待这个信号量。如果连续几次等待信号量超时,则可以判定界面滑动时产生了卡顿。

如何计算 FPS

CADisplayLink添加到主线程的 runloop 中,并与 common 模式绑定。使用某个时间点到当前时间内CADisplayLink触发的总次数除以某个时间点到当前时间的时长,就可以计算出 FPS 了。

正常情况下,主线程的 runloop 每隔 16.7ms 就会触发一次CADisplayLink回调。如果主线程执行了一个耗时 40ms 的任务,那么 runloop 就会少触发 2 次CADisplayLink回调,而此时屏幕也会少更新了 2 帧画面。

网络七层协议

网络七层协议.png

OSI 七层模型由上至下分别为应用层、表示层、会话层、传输层、网络层、数据链路层、物理层。

URL是什么?

统一资源定位符。通过一个 URL,能找到互联网上唯一的一个资源。

URL 的基本格式 = 协议://主机地址/路径。

URL 中常见的协议有以下几种:

HTTP

超文本传输协议,是一个应用层协议。

请求报文的内容

响应报文的内容

HTTP定义的请求方法

HTTP的优点和缺点

HTTP协议无状态的解决方案

常见的响应状态码及其含义

状态码的类别:

常见的状态码:

GET请求和POST请求的区别

POST请求的 body 使用 form-urlencoded 和使用 multipart/from-data 有什么区别?

发送纯文本数据时,使用form-urlencoded格式对数据进行编码。发送的数据包含图片、音频或其他二进制数据时,使用multipart/from-data格式对数据进行编码。

HTTPS 协议

HTTPS 协议,安全套接字层超文本传输协议。为了数据传输的安全,HTTPS 协议在 HTTP 协议的基础上加入了 SSL/TLS 协议, SSL/TLS 协议依靠证书来验证服务器的身份,并为客户端和服务器之间的通信加密。

HTTPS连接的建立流程

单向认证,客户端和服务器都要存放向 CA 申请的服务器证书,其流程如下:

双向认证,客户端和服务器除了要存放服务器证书之外,服务器还要存放一个 CA 根证书,客户端还要存放一个由 CA 根证书签名的 p12 证书。在客服端验证服务器成功后,客户端还会发送 p12 证书和一段由 p12 证书签名的数据到服务器,服务器会使用根证书对 p12 证书和由 p12 证书签名的数据进行验证。验证成功,就会继续后面的流程;验证失败,则会断开连接。

HTTPS 和 HTTP 的区别

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 拥塞控制算法包括以下三个主要部分:

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解析的底层原理。

JSON解析的底层原理

遍历文本中的字符,并根据{}[],:进行区分。{}代表字典,[]代表数组,,是字典的键值对以及数组元素的分隔符,:是键值对的 key 和 value 的分隔符。最终结果是将 JSON 文本转换为一个字典或者数组。

XML解析的底层原理

上一篇 下一篇

猜你喜欢

热点阅读