技术博客阅读笔记iOS篇(一、MRPEAK)

Author Avatar
纸简书生 4月 05, 2017

每天阅读那么多的博客,为什么自己对其中优秀的博客做一个笔记方便自己翻阅呢。而且牛逼的博主,其他文章含量金量也很高。虽然看博客不如看技术书籍来的全面、系统,读完之后也有不少的收获。关于泛读和精读,下面有篇文章有相应的介绍……

博主MRPEAK

一下内容大部分是摘自博客原文,如需细致阅读,建议看原文。
历史文章合集

一、 如何设计一个通讯协议

幸好作者提供了项目源码,可以直接看源码TKeyboard

google一搜如何设计一个通讯协议,出现了一大片文章。这里摘取比较有代表性的。
找到一个牛逼哄哄的网站即时通信网,做了IM这么久现在才知道有这样好资源。

理论联系实际:一套典型的IM通信协议设计详解
如何设计一个RPC系统

应用层常见的有三种:文本协议(Http)、二进制协议(ip)、流式XML协议(xmpp)。他们的各自优缺点可以大致总结出来。后文建议强列建议将Protobuf作为你的即时通讯应用数据传输格式

工作内容主要有:一是数据的序列化,即将我们平时所用的 model 转化为二进制流,其二是定义好包的格式,在通讯框架里做好包的切割,解析,和传递。最后简化调用流程,提供一套简单的类似 http 的双向数据调用 API 即可。

  • 基本思路
    1. 第一个序列化的问题好解决,已有 google 的成熟方案 protobuf 可以使用(感觉业界都推荐用这个,但是之前在iOS端用过,发现了很多坑爹的地方),而且还有基于 Objective C 的版本,model 的序列化和反序列化,一个 API 调用即可完成。
    2. 第二个问题是包的格式定义。学习 TCP/IP 的意义在这里就能体现了,无论是 TCP 包还是 IP 包,都有自己的包格式定义,而且往往是一个 header 配合一个 payload(类似于 http 的 body)。之所以要有包,是因为二进制流只完成 stream 的传输,并不知道一次数据请求与相应的起始和结束,我们要预先定义好包结构才能做解析。
    3. 要能实现包的准确切割,我们需要明确包的长度。所以必须在 header 中留一个字段,表达整个包(header + payload)由多少 bytes 构成,两个字节的长度就可以描述 0~65535 个字节数,具体使用多少个字节就看协议的使用场景了。
    4. 因为是 RPC 调用协议,所以包体里必须有调用的名称,即 API name 字段,这个 name 是可变长度,所以也需要将其长度信息加入包体中,原则上,所有可变长度的内容都需记录其精确的长度信息,否则无法做信息的切割。另外,调用方还需要知道包是请求的回应(response)还是另一端的通知(notify),所以我们还需要定义 call type 信息,这种信息一个 8 比特位的枚举量就绰绰有余了,这种固定长度的信息就不需要记录其长度信息了。
    5. 一般固定长度的信息我们放在 header 中,可变长度的信息我们则放入 payload 中,当然,我们 RPC 调用的具体参数(经由 protobuf 序列化之后的 stream)也是放入 payload 中,接收方接收以后,只需读取固定长度的字节,即可通过反序列化,再在接收方还原成具体的应用层数据。

特别注意:因为我们是在设计应用层协议,所以还需要考虑传输层是可靠还是不可靠,CoreBlueTooth 实际上既提供了类似于 TCP 的可靠传输(CBCharacteristicPropertyIndicate),也有类似于 UDP 的不可靠传输(CBCharacteristicPropertyNotify),不明白这一点,必然会踩坑

二、TCP/IP 系列之重新认识 IP 地址

大学里面网络基础学过,但是基本上都忘记了。看到这篇突然想起了一些远古的记忆。

internet 其实是由无数个子网所构成,是一个二级的结构,第一级是子网,第二级才是子网中的设备。所以 internet 中设备 A 的信息要抵达设备 B,必须先要找到 B 所在的子网,进而再在子网中找到 B。

IP 地址的结构:IP 地址 = 网络地址 + 主机地址。子网掩码(subnet mask) 就是为了分割 Network ID 和 Host ID 的

第一种切割方式

  • 第一个字节为 Network ID,剩下三个字节为 Host ID
  • 第二个字节为 Network ID,剩下两个字节为 Host ID
  • 第三个字节为 Network ID,剩下一个字节为 Host ID
    有问题

  • 我们如何确定一个 IP 地址是属于 A B C 的哪一类呢?我们以第一个字节来做一些约定:

    1. 如果第一个字节的起始比特位为 0,则是 A 类地址。
    2. 如果第一个字节的起始比特位为 10,则是 B 类地址。
    3. 如果第一个字节的起始比特位为 110,则是 C 类地址。

第二种切割方式CIDR

全称为 Classless Inter-Domain Routin。CIDR 是新的子网掩码的表达方式和路由方式。这里注意 CIDR 和 CIDR notation 的区别,CIDR notation 是描述 IP 地址如何切割的方式,而 CIDR 描述的是基于 CIDR notation 的路由方式。

CIDR notation 其实概念也很直白,它不再粗暴的以字节为粒度来切分 IP 地址,而是精确到 bit 位,我们看一个典型的 CIDR notation:

1
123.121.114.144/23

注意 IP 地址后面的 /23,这就是 CIDR notation,它表示 IP 地址的前 23 bits 为 Network ID,剩余的 9 bits 为 Host ID。23 并不是 8 的倍数,我们将切分的精读提高到了 bit。我们可以通过简单的位运算,得到具体的 Network ID 和 Host ID,我们将 IP 地址和 /23 先转为二进制:

1
2
01111011.01111001.01110010.10010000 IP 地址
11111111.11111111.11111110.00000000 /23 subnet mask

得到 Network ID 和 Host ID:

1
2
01111011.01111001.01110010.00000000 Network ID
00000000.00000000.00000000.10010000 Host ID

再将二进制转换为十进制,我们就得到了便于理解的 Network ID:123.121.114.0。由于 Host ID 占用 9 个 bits,这个子网里一共可以有 2 的 9 次方个主机数,也就是 512 个主机,这个子网网段的起始地址为 123.121.114.0,结束地址为 123.121.115.255。我们对于某一个网段内的 IP 地址,有个约定,第一个地址为 Network ID,最后一个地址是该子网内的 Broadcast ID,那么剩下的可用于子网内设备的 IP 地址数量就是 510 个了。

IP 地址虽然只是一个二级的结构,但 IP 地址的分配却是一层一层,经历多层往下分发的,由一个国际机构 IANA 统一分配。具体规则可以参考 IANA 官网:https://www.iana.org/numbers

用户拿到 IP 地址后,所发送包要经过一个个的路由器才能抵达正确的地址。

三、TCP/IP 系列之 Header 篇

如果以一个 HTTP 请求为例,右图中 Application 部分就代表我们用 Charles 抓包时所感知的部分,这一部分要最后转化为光信号,在光纤中传输,还需要经过一层层的转化,这个转化过程说白了,就是在每一层加上一个 header。

  • Application 层(HTTP)的数据在经过传输层(TCP Layer)的时候,会加上 TCP 的 header,成为一个 TCP Segment。
  • 传输层(TCP)的 Segment 在经过网络层(IP Layer)的时候,会加上 IP 的 header,成为一个 IP Packet。
  • 网络层的 IP Packet 在经过链路层(Link Layer)的时候,会加上Link Layer 的 header,成为一个 Frame。
  • 最后 Frame 会在物理层,将数字信号转化为物理信号传输。

看张图片一切就明了了

深入研究可以看TCPdump抓包命令详解
Linux tcpdump命令详解

四、TCP/IP 系列之初印象

0 和 1 是计算机世界的基础粒子,大量的 0 和 1 组合在一起就形成了一个流(Stream),客户端向服务器发送数据的时候,说白了就是一堆 0 和 1 的组合。一次完整的 http 会话是建立在一个 TCP 连接之上,这个 TCP 连接的生命周期内所有发送的数据最后可以看做是一个流。而在这个流里,我们可以按照某种规则把它切割成一个个的包(packet)。比如 TCP 三次握手里就包含了 SYN,SYN+ACK,ACK 三个包,而这三个包,不过是整个 TCP Stream 最开始的部分数据而已。

所以简单来说,一个 TCP 连接里,是既有流的概念,又有包的存在,有些问题场景下会谈论流,另一些则会说起包,端看具体的场景如何。

客户端和服务器之间有两根管道,一根上行(从客户端到服务器),一根下行(从服务器到客户端),管道里流动着无数的 0 和 1,有时候管道里是满的,有时候管道里则空空如也,每次发送数据,都会有大量的 0 和 1 从一端涌向另一端。

延迟

由两部分组成的,其一是 Transmission Delay,另一个是 Propagation Delay。

Transmission Delay。计算机世界里的 0 和 1, 最后要能在光纤中传输,需要在 Physical Layer 将数字信号转化为物理信号,这个转化也是存在速度瓶颈的,我们用 Rate (bits/seconds) 来描述这个转化的速度,Rate 表示每一秒钟里,硬件设备能将多少 bits 转化为光信号放入光纤中。

Propagation Delay。这才是大部分人所理解的传播延迟,和距离直接相关。

五、技术文章的阅读姿势🍎

作者和自己的感想比较相似,关于泛读和精读给出了自己的解释。对于自己而言,确实是这样,总结出来的经验也非常值得自己借鉴。

由于技术的知识体系往往是个树形的结构,单个术语下都有其相关的知识域,可以一层又一层牵扯出更多的子术语。在阅读文章遭遇这种树形结构的时候,要能抑制住自己不停探索的欲望,对于技术术语的学习只做适度延伸,最终的目的还是在于完成根部文章的阅读。尽量只做一到两层的延伸。

尽量选择没人打扰的时间段来做阅读,可以是早上刚到公司,或者别人午睡时,总之越安静,越没人找越好。

对于基础知识的阅读,要重官方文档,切莫心急动手,看完文档形成知识体系后再写代码不迟。减少泛读行为,避免漫无目的的随意浏览技术文章。注重精读,一天一篇不算少,一周一篇也正常。重阅读质量而非数量,挑选每天安静且不易被打断的时间点来阅读,尽量多啃原版书。

今天的阅读就到这里了 2017-4-5 pm5:22 下次继续更新

时隔一个多月

六、闲聊 Hash 算法

数据结构和算法是相辅相成的,基础的其实就那么些:时间复杂度的概念,List,Array,Stack,Queue,Tree 等。Graph 实际应用中较少遇到,可以不做深入了解,但 BFS,DFS,Dijkstra 还是应该知道。基础的算法需要能达到手写的程度,比如排序至少能写出两种时间复杂度为 N*logN 的算法。理解这些比去 leetcode 刷题重要,学习难度也并不高。学习这些的意义在于掌握解决问题的基础思路,形成计算机思维,比如 divide and conque,recursive 等常规思想。

七、Improving Immutable Object Initialization in Objective-C

  1. Initializer mapping arguments to properties
  2. Initializer taking dictionary
  3. Mutable subclass
  4. Improving builder pattern

八、危险的UITableView

总体来说就是调用了tableView的reloadData方法之后,代理方法有些不是同步执行的。具体来讲。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
当我们reloadData的时候,我们本意是刷新UITableView,随后会进入一系列UITableViewDataSource和UITableViewDelegate的回调,其中有些是和reloadData同步发生的,有些则是异步发生的。
我们熟悉的下面两个回调是同步的:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
return 20;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return _arr.count;
}
而另一个最常使用的回调则是异步的:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
//...
NSNumber* content = _arr[indexPath.row];
//...
}
经过上面的分析,我们不难UITableView的危险之处在于哪了,在于异步执行cellForRowAtIndexPath的时候,我们所依赖的状态可能会发生变化,上面代码中的_arr如果元素被修改过,极有可能发生数组越界的异常。

优化方案

throttle机制,控制刷新事件的产生频率,建立一个Queue以一定的时间间隔来调用reloadData。事实上这是一种很常见的界面优化机制,对于一些刷新频率可能很高的列表界面,比如微信的会话列表界面,如果很长时间没有登录了,打开App时,堆积了很久的离线消息会在短时间内,导致大量的界面刷新请求,频繁的调用reloadData还会造成界面的卡顿,所以此时建立一个FIFO的Queue,以一定的间隔来刷新界面就很有必要了。

九、iOS当中的Cache设计

需要看看 一个简单的 Cache 淘汰策略
副作用的理解:

所有对我们整个App有副作用的代码都需要被集中管理,要能从架构的层面去理解和定位。怎么去定义副作用呢?可以抽象成一种「写操作」,往Cache中添加新的记录就是写操作,这种写操作的副作用是额外的内存开销,Cache的本质是以空间换时间,这空间损耗就是我们的副作用,一个副作用会引发其他更多的副作用,理清这些副作用往往需要反复查阅大量的代码。更好的办法是,一开始就把有副作用的代码集中管理。

  • cache的另一个重要知识点是cache的淘汰策略,不同的策略表现也不一样,FIFO,LRU,2Queues等等,现在有不少成熟的第三方cache框架可以使用,系统也提供了淘汰策略不明确的NSCache。
  • cache的使用要有收有放,不能只创建不释放,事实上,所有涉及到data的操作都要考虑data的生命周期。我们做业务的时候,多是以Controller为基础单位,有些场景下,一个Controller在退出之后被再次进入的可能性就非常之低了,适时的清理cache会让我们App的整体表现更好。

  • 只要保证业务模块从Cache中获取的数据都是独立的copy,就能避免数据共享带来的各种隐患。

最后看一下YYCache加深学习

十、如何用Xcode8解决多线程问题

data race:当至少有两个线程同时访问同一个变量,而且至少其中有一个是写操作时,就发生了data race。

常见场景

  • 场景一:计算出错

    1
    2
    3
    4
    5
    6
    7
    8
    9
    __block int count = 0;
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    for (int i = 0; i < 10000; i ++) {
    count ++;
    }
    });
    for (int i = 0; i < 10000; i ++) {
    count ++;
    }
  • 场景二:Crash!

    1
    2
    3
    4
    5
    6
    7
    8
    9
    NSMutableString* str = [@"" mutableCopy];
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    for (int i = 0; i < 10000; i ++) {
    [str setString:@"1234567890"];
    }
    });
    for (int i = 0; i < 10000; i ++) {
    [str setString:@"abcdefghigk"];
    }

一般会出现在对于复杂对象(class或者struct)的多线程写操作中,原因是因为写操作本身不是原子的,而且写操作背后会调用更多的内存操作,多线程同时写时,会导致这块内存区间处于中间的不稳定状态,进而crash,这是真正的恶性的data race。

  • 场景三:乱序

    1
    2
    3
    4
    5
    6
    7
    8
    //thread 1
    count = 10;
    countFinished = true;
    //thread 2
    while (countFinished == false) {
    usleep(1000);
    }
    NSLog(@"count: %d", count);

公共变量线程同步。error

编译器并不知道thread 2对count和countFinished这两个变量的赋值顺序有依赖,所以基于优化的目的,有可能会调整thread 1中count = 10;和countFinished = true;生成的最后指令的执行顺序,最后也就导致count值输出的时机不对,虽然最后count的值还是10。这也可以看做是一种benign race,因为也不会crash,而是程序的流程出错。

遇到这种多线程读写状态,而且存在顺序依赖的场景,不能简单依赖代码逻辑。解决这种data race场景有一个简单办法:加锁,比如使用NSLock,将对顺序有依赖的代码块整个原子化,加锁之所以有用是因为会生成memory barrier,从而避免了编译器优化。

  • 场景四:内存泄漏(存在静态变量的时候)

    1
    2
    3
    4
    5
    6
    7
    Singleton *getSingleton() {
    static Singleton *sharedInstance = nil;
    if (sharedInstance == nil) {
    sharedInstance = [[Singleton alloc] init];
    }
    return sharedInstance;
    }

多线程环境下,thread A和thread B会同时进入sharedInstance = [[Singleton alloc] init];,Singleton被多创建了一次,MRC环境就产生了内存泄漏。

顶层的还是不够牢固。哎!恶补

十一、iOS多线程到底不安全在哪里?

  • self.userName = @"123"; 是在对指针本身进行赋值
  • [self.userName rangeOfString:@"123"];是在访问指针指向的字符串所在的内存区域,这二者并不一样

属性类型。基本类型、指针类型

至始至终只有三种:1.值类型Property、2.指针Property、3.指针Property指向的内存区域(这一类多线程的访问场景是我们很容易出错的地方)

atomic的作用只是给getter和setter加了个锁,atomic只能保证代码进入getter或者setter函数内部时是安全的,一旦出了getter和setter,多线程安全只能靠程序员自己保障了。所以atomic属性和使用property的多线程安全并没什么直接的联系。另外,atomic由于加锁也会带来一些性能损耗,所以我们在编写iOS代码的时候,一般声明property为nonatomic,在需要做多线程安全的场景,自己去额外加锁做同步。

多线程安全的时候,其实是在讨论多个线程同时访问一个内存区域的安全问题。针对同一块区域,我们有两种操作,读(load)和写(store),读和写同时发生在同一块区域的时候,就有可能出现多线程不安全。

多线程是如何同时访问内存的。不考虑CPU cache对变量的缓存,内存访问可以用下图表示:

只有一个地址总线,一个内存。即使是在多线程的环境下,也不可能存在两个线程同时访问同一块内存区域的场景,内存的访问一定是通过一个地址总线串行排队访问的

  • 结论一:内存的访问时串行的,并不会导致内存数据的错乱或者应用的crash。

  • 结论二:如果读写(load or store)的内存长度小于等于地址总线的长度,那么读写的操作是原子的,一次完成。比如bool,int,long在64位系统下的单次读写都是原子操作。

atomic作用:

  • 用处一: 生成原子操作的getter和setter。设置atomic之后,默认生成的getter和setter方法执行是原子的。也就是说,当我们在线程1执行getter方法的时候(创建调用栈,返回地址,出栈),线程B如果想执行setter方法,必须先等getter方法完成才能执行。举个例子,在32位系统里,如果通过getter返回64位的double,地址总线宽度为32位,从内存当中读取double的时候无法通过原子操作完成,如果不通过atomic加锁,有可能会在读取的中途在其他线程发生setter操作,从而出现异常值。如果出现这种异常值,就发生了多线程不安全。
  • 用处二:设置Memory Barrier。对于Objective C的实现来说,几乎所有的加锁操作最后都会设置memory barrier,atomic本质上是对getter,setter加了锁,所以也会设置memory barrier。memory barrier能够保证内存操作的顺序,按照我们代码的书写顺序来。听起来有点不可思议,事实是编译器会对我们的代码做优化,在它认为合理的场景改变我们代码最终翻译成的机器指令顺序。

问题代码:值类型Property

1
2
3
4
5
6
7
8
9
10
11
12
13
@property (atomic, assign) int intA;
//thread A
for (int i = 0; i < 10000; i ++) {
self.intA = self.intA + 1;
NSLog(@"Thread A: %d\n", self.intA);
}
//thread B
for (int i = 0; i < 10000; i ++) {
self.intA = self.intA + 1;
NSLog(@"Thread B: %d\n", self.intA);
}

即使我将intA声明为atomic,最后的结果也不一定会是20000。原因就是因为self.intA = self.intA + 1;不是原子操作,虽然intA的getter和setter是原子操作,但当我们使用intA的时候,整个语句并不是原子的,这行赋值的代码至少包含读取(load),+1(add),赋值(store)三步操作,当前线程store的时候可能其他线程已经执行了若干次store了,导致最后的值小于预期值。

问题代码:指针Property

1
2
3
4
5
6
7
8
9
@property (atomic, strong) NSString* userName;
- (void)setUserName:(NSString *)userName {
if(_uesrName != userName) {
[userName retain];
[_userName release];
_userName = userName;
}
}

如果property为nonatomic,上述的setter方法就不是原子操作,我们可以假设一种场景,线程1先通过getter获取当前_userName,之后线程2通过setter调用[_userName release];,线程1所持有的_userName就变成无效的地址空间了,如果再给这个地址空间发消息就会导致crash,出现多线程不安全的场景。

问题代码:指针Property指向的内存区域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@property (atomic, strong) NSString* stringA;
//thread A
for (int i = 0; i < 100000; i ++) {
if (i % 2 == 0) {
self.stringA = @"a very long string";
}
else {
self.stringA = @"string";
}
NSLog(@"Thread A: %@\n", self.stringA);
}
//thread B
for (int i = 0; i < 100000; i ++) {
if (self.stringA.length >= 10) {
NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)];
}
NSLog(@"Thread B: %@\n", self.stringA);
}

虽然stringA是atomic的property,而且在取substring的时候做了length判断,线程B还是很容易crash,因为在前一刻读length的时候self.stringA = @”a very long string”;,下一刻取substring的时候线程A已经将self.stringA = @”string”;,立即出现out of bounds的Exception,crash,多线程不安全。