朋友提供了几个面试题,尝试回答一下

272 阅读10分钟

「这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战」。

朋友提供了几个面试题,本菜鸡尝试回答一下,大多是自己写的,一部分是整理别的大佬的,可能有不对的地方,如果出现了错误或者偏差,希望大佬及时指正,真心!拜托。(之后可能需要面试)

1、iOS 的内存管理原理说一下。

iOS 内存管理机制的原理是引用计数,当这块内存被创建后,它的引用计数 +1,当有对象获取其所有权时,引用计数会再次 +1,每有一个对象释放当前内存区域的所有权,引用计数就会 -1,当释放完当前内存区域的全部所有权,当前内存就会被释放。

在类结构中第一块 8 字节内存区域是 isa,如果是 noTaggedPointisa 联合体中 extra_rc 存储了引用计数的值,如果大于引用计数的值,isa 联合体中 has_sidetable_rc 就会被置为 1,并且创建一个 sidetalb 哈希表,将一半的引用计数的值存入。(存入一般的值是方便进行操作,因为从散列表中取值会进行加锁和解锁操作,没有直接从 isa 中获取性能好)。

2、你开发中遇到的内存泄漏吗?是怎么解决的?

一般开发中遇到内存泄漏:

  • Core 系列框架和 FoundationUIKit框架通过 __bridge 桥接的话, 忘记 free 或者 Release 的时候。

  • 循环引用问题:定时器 tagert 的循环引用,block 的循环引用,delegate 的循环引用,父类和子类之间的循环引用。其实都可以归为两个对象双向强引用而造成循环引用,导致引用计数不能归 0,内存区域不能释放。

解决方式:

  • 使用 Analyze 静态分析和 Instrument 工具库里的 Leaks 或者使用第三方库 MLeaksFinder 等寻找内存泄漏的点。

  • 桥接时忘记 free 或者 Release 添加上即可。

  • 定时器循环引用使用: NSProxy 进行消息转发解决。

  • delegate 和父类与子类之间循环引用:查找 @property 修饰词是否是 weak

  • 两个对象双向强引用:使用 weak 或者手动使一方置 nil 强制打破即可。

3、block 循环引用的解决方法有几种?

大方面说就两种:

  • 事前阻止:使用 weak 来解决。

  • 事后补救:使用完将 block = nil 强制打破循环引用。

  • 还有一种情况比较危险,不建议使用,使用 __block 修饰 block 内部使用的对象,在 block 中置为 nil。危险的原因在于,如果不调用 block 就一定会循环引用。

4、block 中使用 self 都会造成内存泄漏吗? 为什么?

不是所有的 block 中使用 self 都会造成内存。

开发中使用 block 的时候,哪怕变量超出作用域了,都可以在 block 正确的获取到值。按照作用域原理,作用域内申请的变量,超出作用域就会被释放,但是如果能获取到正确的值,那意味着 block 会保存作用域内申请的变量。

typedef void (^blk_t)();

@interface TestClass ()

@property (nonatomic, copy) blk_t blk;

@property (nonatomic, strong) id obj;

@end

@implementation TestClass

- (instancetype)init
{
    self = [super init];
    _obj = [NSObject alloc];
    
    if (self) {
        _blk = ^{
            /// 标注点 1
            NSLog(@"_obj = %@",self.obj);
        };
    }
    return self;
}

@end

Clang 一个捕获作用域值的 block

struct __TestClass__init_block_impl_0 {
  struct __block_impl impl;
  struct __TestClass__init_block_desc_0* Desc;
  TestClass *self;
  __TestClass__init_block_impl_0(void *fp, struct __TestClass__init_block_desc_0 *desc, TestClass *_self, int flags=0) : self(_self) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

static void __TestClass__init_block_func_0(struct __TestClass__init_block_impl_0 *__cself)
{
   // 这里
   TestClass *self = __cself->self; // bound by copy
   
   //...
}

这么看就非常清晰了,简述如下:

TestClass 的对象持有的了 blk_t blkClang 后发现 _block_impl_0 中捕获了 TestClass 对象即 self,在 block 的保存的方法 _block_func_0,使用 __cself->self 取出了 TestClass 对象,也就是 self->blk->self 就造成了循环引用。

所以只要持有 block 的对象不是 _block_func_0 捕获的对象,就可以在 block 中使用 self,比如 UIView 类对象使用的动画 block

5、为什么使用 weakself 可以解决循环引用问题?

weakself 其实是生成一个 self 的弱引用对象存入 SideTablesweak_table 中,当 self 对象销毁时候,对象会调用 dealloc ,该方法中会调用了 objc_destructInstance 时候检查散列表清理,将持有这个对象的 referent 指针置为 nil,此时 weak 对象就被释放了。

简述生成和保存弱引用对象的过程如下:

  • 传入弱引用对象地址的指针和原始对象的指针;

  • 如果旧值存在,从弱引用对象地址的指针取出旧值对象指针,用旧值对象取出上一次旧值的散列表;

  • 如果有新值,用新对象取出的新对象的散列表;

  • 如果有旧值,但是比较上一次取出的旧值和现在再取了一次的旧值不相等,可能由于多线程问题,就跳转上一次取旧值的地方重来;

  • 清理旧的弱引用:如果相等,说明没有线程问题,有旧值,找到保存旧值所有弱引用的结构体,从结构体中找到存储的弱引用对象指针的指针地址,把当前位置置为 nil,清理上一次的弱引用,判断 old_size > 1024 并且全局散列表下的弱引用对象的个数 num_entries < old_size / 16 就进行收缩表,防止内存浪费(清理值结束);

  • 插入新的弱引用:从用新值地址取出的新散列表中找到保存新值所有弱引用的结构体,如果找到了就拼接上到数组后面,如果没有找到的话,就新建一个弱引用的结构体是,然后放入弱引用对象指针的指针地址,判断表是否已经装载了 3/4 或者以上,如果大于了就新建表大小为原来的一倍,将原始值拷贝到新表中(保存新的弱引用结束)。

6、使用过 NSProxy 吗?使用的场景是什么?

NSProxyNSObject 是 iOS 的两个根基类,但是与后者不同的是前者没有 init 方法,没有实现全部的 NSObjectProtcol 协议。

NSProxy 一般用于对象或者框架解耦,AOP切面编程,特殊场景下可以用来避免循环引用(定时器)。

NSProxy 是虚基类,内部一般通过消息转发实现解耦,其实也就是使用了 iOS 运行时不检查对象类型的方式实现一些功能。

比如:

  • 对象解耦:继承 NSProxy 的子类内部保存转发对象 target 后返回子类对象,业务层可以强转成 target 的类型,使用返回的 proxy 对象调用方法 target 的方法,然后 NSProxy 的子类内部进行消息转发,以弱化对象和对象方法调用的耦合。

  • 框架解耦:继承 NSProxy 抽象类,实现自己定义的转发机制,将网络接口层的各个方法的实现与声明分离。创建协议 Protocol,在 NSProxy 的子类中注册 Protocol 和需要转发的对象,就可以直接使用 [HttpProxy sharedInstance] getUserWithID:@100]; 来调用 UserProfileHttpManager 中的 getUserWithID 方法了。

  • AOP 切面编程:常见的是基于 runtimemethod-swizzling,也可以使用 NSProxy 的消息转发实现,Aspects 框架就是基于这个理念。

7、有一个对象 obj 调用方法 sayHello 的时候都发生了什么?

这个问题其实就想问 iOS 发消息的过程。

  • 调用汇编 objc_msgSend,找到 isaisa.bit & ISA_MASK 取出类对象, 偏移 16 个字节找到类的 cache_t(第一个8字节是 isa, 第二个8个字节是 super_class);

  • 哈希查找 cache_t 是否有方法缓存(找方法其实就是比字符串,方法名唯一),汇编中找了2次,因为考虑了多线程写入缓存延迟问题;如果在缓存中找到了就直接调用,没找到(可能没调用过也可能没有当前方法)就开始慢速消息转发。

  • 调用 lookupImpOrForward 到类中开始找,再次查找缓存,防止一些其他问题造成之前在汇编中没有找到(容错),找到了就返回。没找到的话使用二分法到类中找实例方法,沿着类的父类一直找,直到 super_class == nil,找到了插入缓存后就返回。

  • 没找到的话设置 imp = imp_forward,准备消息转发。

  • 消息转发:调用 resolveInstanceMethod/resolveClassMethod方法,如果没实现,汇编调用 _objc_msgForward_impcache 再调用 forwardingTargetForSelector,还没有实现的话,调用 methodSignatureForSelectorforwardInvocation ,以上都没有实现会调用 doesNotRecognizeSelector 打印崩溃信息。

8、什么是哈希?为什么用哈希算法?

哈希: 原理就是把任意长度的输入,通过 Hash 算法变成固定长度的输出。

使用哈希算法的场景:

  • 信息加密:设计原则上 hash 不可以反向推导出原始的数据;

  • 数据校验:输入数据的微小变化会得到完全不同的 hash 值;

  • 快速查值:能将任意数据散列后映射到有限的空间上;

  • 负载均衡:服务端可以用 hash 来做负载均衡。

9、什么是哈希冲突?怎么解决哈希冲突?

哈希冲突:由于哈希算法被计算的数据是无限的,而计算后的结果范围有限,因此总会存在不同的数据经过计算后得到的值相同,这就是哈希冲突。

解决哈希冲突:

  • 开放寻址法:从发生冲突的那个单元起,按照一定的次序,从哈希表中找到一个空闲的单元。然后把发生冲突的元素存入到该单元的一种方法。开放定址法的缺点在于删除元素的时候不能真的删除,否则会引起查找错误,只能做一个特殊标记。只到有下个元素插入才能真正删除该元素。

  • 链地址法:链接地址法的思路是将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。链表法适用于经常进行插入和删除的情况。

10、说一下你对 copy、mutableCopy 和 浅拷贝、深拷贝的理解。

  • 浅拷贝:只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存。 iOS 内存引用计数 +1。

  • 深拷贝: 会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会改到原对象。

对于数组这类来说:分为 不完全深拷贝完全深拷贝,区分条件为:是否复制数组内的每一个对象。

  • copy:生成一个不可变副本。

  • mutableCopy:生成一个可变副本。

其实 copymutableCopy 和浅拷贝、深拷贝没有什么关系。只要符合深拷贝的条件,使用 copy 也可以是深拷贝。比如:NSMutableString 对象调用 copy,会生成一个和原始地址不同的对象,这就是深拷贝。

11、说一下你们工程内单例使用的场景,单例的优缺点是什么?

工程内单例使用的场景:

  • 数据库插入和读写维持一个队列的时候

  • 部分用户信息内存持有的时候,如:userSign, userId等。

  • 网络请求网关的设计(SSL证书配置)

优点:

  • 一个类只被实例化一次,提供了对唯一实例的受控访问
  • 由于在系统内存中只存在一个对象,因此可以节约系统资源,当需要频繁创建和销毁的对象时单例模式无疑可以提高系统的性能
  • 允许可变数目的实例
  • 避免对共享资源的多重占用

缺点:

  • 一个类只有一个对象,可能造成责任过重,在一定程度上违背了 “单一职责原则”
  • 由于单利模式中没有抽象层,因此单例类的扩展有很大的困难。
  • 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出

12、App 启动流程是什么?让你优化启动速度,你都从哪里入手?

App 冷启动流程:

  • 程序入口 __dyld_start

  • 获取主程序的架构

  • 保存主程序 Mach-O 的头

  • 保存主程序的 ASLR

  • 检测进程环境变量

  • 检查共享缓存库是否禁用,iOS不能禁用

  • 加载共享缓存(UIKIT,Fundation)

  • 为主要可执行文件实例化 ImageLoader

  • 实例化主程序,从 Mach - O 头部 读取 CPU 架构等,判断兼容性

  • 根据 DYLD_INSERT_LIBRARIES 的值判断是否允许加载插入动态库

  • 开始链接主程序

  • 链接动态库和第三方库,进行符号绑定

  • 链接插入库

  • weak 符号绑定,weakbind 是比其他后绑定的,其他的绑定是在上方 link 完成时就绑定好了

  • notifySingle 回调,调用 runtime 方法 map_images,处理由 dyld 映射的 image,计算 class 数量,根据总数调整各种表的大小;调用 runtime 方法 load_images ,对类初始化。

  • 调用 doInitialization 一些初始化,doModInitFunctions 调用全局 C++ 方法,可以做一些逆向插入操作。

  • 调用 libSystem_initializer

  • 调用 libdispatch_init

  • 调用 _os_object_init

  • 调用 main()

App 程序执行流程:

  • 执行 UApplicationMain 函数

  • 创建 UIApplication 对象

  • 创建 UIApplicationDelegate 对象并复制

  • 读取配置文件 info.plist,设置程序启动的一些属性

  • 创建应用程序的 Main Runloop 循环

  • UApplicationDelegate 对象开始处理监听事件

  • 程序启动之后,首先调用 application.didFinishLaunchingWithOptions: 方法

  • 如果 info.plist 中配置了启动的 story Board 的文件名,则加载 storyboard 文件

  • 如果没有配置,则根据代码创建 UIWindow -> rootViewController -> 显示

优化启动速度

一般分成 Pre-MainMain 之后。

Pre-Main

  • 动态库加载越多,启动越慢

  • objc 类越多,启动的时候 load_images 数量越多,启动越慢

  • constructor 函数越多,启动越慢

  • C++ 静态对象越多,启动越慢

  • +load() 越多,启动越慢

Main 之后:

  • didFinishLaunchingWithOptions 的耗时

  • 首屏 UI 的绘制

解决办法

  • 减少动态库数量的加载,静态库合并。

  • 删除不需要的类(我记得的类加载过程中能识别到用不到的类,我回头查一下)

  • 合并类的分类(一般懒加载分类会在编译期完成)

  • 尽可能少的使用 +load() 方法,如果分类实现了该方法,那么就不会在编译期编译到类中,并且会在类的加载过程中,迫使主类先初始化,主类初始化的时候就会把当前继承链中的所有父类初始化,非常耗费时间

  • 减少 didFinishLaunchingWithOptions 的框架初始化,有条件的可以在即将使用的时候再初始化。

  • 压缩首屏要显示的图片,可以在子线程解压缩图片,减少主线程时间。

13、说一下你对 TCP/IP、UDP、Http、Https 的理解。

在说这个协议之前,需要知道 OSI(开放式系统互联模型) 的七个分层:

OSI 的 七个分层

  • 应用层(Http、Https、FTP等)

  • 表示层(XDR、AFP等)

  • 会话层(SSL等)

  • 数据传输层(TCP、UDP等)

  • 网络层(IP)

  • 数据链路层(以太网等)

  • 物理层(光纤等) TCP/IP 是传输协议簇,TCP、UDP 属于数据传输层,主要解决数据如何在网络中传输,而 HTTP 是应用层协议,主要解决如何包装数据,IP 协议的基本功能是提供数据传输、数据包编止、数据包路由,分段等。通过 ip 编止约定,可以成功的将数据通过路由传输到正确的网络或者子网。

TCP 和 UDP 的区别:

  • TCP 需要链接(3次握手,4次挥手),UDP 不需要
  • TCP 可靠,UDP不可靠
  • TCP 传输慢,UDP 传输快
  • TCP 一般用于传输大量数据且需要使命必达的场景(必须送达的 FTP 文件传输),UDP 用于少量数据传输,成不成功都可以的情况(如:获取 DNS,ping 地址)。

Http

超文本传输协议(HTTP)是基于 TCP 的应用层协议。

1、报文分 2 部分说明:请求报文 和 响应报文。

请求报文:

  • Host:指明了该对象所在的主机(www.baidu.com)
  • Connection:决定当前的事务完成后,是否会关闭网络连接(keep-alive、 close)
  • Content-Type:内容类型,用于定义网络文件的类型和网页的编码,决定文件接收方将以什么形式、什么编码读取这个文件(text/html;charset=ISO-8859-1)
  • User-agent:首部包含了一个特征字符串,用来让网络协议的对端来识别发起请求的用户代理软件的应用类型、操作系统、软件开发商以及版本号
  • Accpect-lauguage:需要返回的语言类型

响应报文:

  • HTTP/1.1 200 OK:Http 版本号、响应状态码
  • Connection:header (close) 告诉客户,发送完报文后将关闭 TCP 连接。
  • Date:服务端向客户端返回数据的时间
  • Content-Length:header 指示了被发送对象中的字节数

2、Http 请求方式。

GET、POST、PUT、DELETE、HEAD

Get 和 Post 的区别:

  • Get 和 Post 都属于 TCP 链接无本质区别,只是使用时进行了区分;
  • Get 属于明文请求,所有请求参数都会拼接到 URL 后面,而 Post 会放到 Body 请求体里面,这么看相对于 Get,Post 相对安全,但是抓包下没什么区别;
  • Get 参数长度限制为 2048个字符,Post 没限制;
  • Get 请求可以被服务器缓存,可以减轻服务器负担,Post 不会;

Https

HTTPS 协议 = HTTP 协议 + SSL/TLS 协议,即 Https 是安全的 Http 协议。

  • SSL 的全称是 Secure Sockets Layer,即安全套接层协议,是为网络通信提供安全及数及数据完整性的一种安 全协议。
  • TLS 的全称是 Transport Layer Security,即安全传输层协议。

Https 的连接建立流程:

image.png

  • 客户端访问 Https 链接:客户端会把安全协议版本号、客户端支持的加密算法列表、随机数 C 发给服务端;

  • 服务端发送证书给客户端:服务端接收秘钥算法后,和自己所支持的加密算法进行比对,如果没有支持的,就断开链接。否则服务端会在接收的算法列表中,选择一种对称算法(AES)、公钥算法(RSA)和 MAC(Message Authentication Code,消息认证码算法) 算法,以及数组证书、随机数 S 发给客户端;

  • 客户端验证服务端证书:客户端使用 CA 公钥对证书的签名解密,得到 hash 值,再与证书的 hash 值进行比较是否一致,从而判断证书的有效性,如果验证失败了,Https 传输就无法继续;

  • 客户端组装会话密钥:如果公钥合格,那么客户端会用服务器公钥来生成一个前主秘钥(Pre-Master Secret,PMS),并通过该前主秘钥和随机数 C、S 来组装成会话秘钥

  • 客户端将前主密钥加密发给服务器:是通过服务端的公钥来对前主秘钥进行非对称加密,发送给服务端;

  • 服务端通过私钥解密得到客户端传输的密钥

  • 服务端组装会话秘钥:服务端通过前主秘钥和随机数 C、S 来组装会话秘钥。 至此,服务端和客户端都已经知道了用于此次会话的主密钥;

  • 数据传输:客户端收到服务器发来的密文,客户端使用自己的密钥进行对称解密。

14、说一下你常使用的第三方框架的原理。