即时通讯下数据粘包、断包处理实例(基于CocoaAsyncSocket)

From: https://www.jianshu.com/p/2e16572c9ddc

前言

本文旨以实例的方式,使用CocoaAsyncSocket这个框架进行数据封包和拆包。来解决频繁的数据发送下,导致的数据粘包、以及较大数据(例如图片、录音等等)的发送,导致的数据断包。

本文实例Github地址:即时通讯的数据粘包、断包处理实例

注:文章内容属于应用的范畴,内容相对简单易懂。给大家对数据包的处理提供了一个思路, 希望能抛砖引玉。
它是楼主CocoaAsyncSocket系列Read篇解析的一个前置插曲,至于详细的实现原理,作者会在后续的文章中写出。

正文
一、什么是粘包?

经常我们发现,如果用客户端同一时间发送几条数据,而服务端只能收到一大条数据,类似下图:

如图,由于传输的过程为数据流,经过TCP传输后,三条数据被合并成了一条,这就是数据粘包了。

那么为什么会造成粘包呢?

原来这是因为TCP使用了优化方法(Nagle算法)。
它将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。
这么做优点也很明显,就是为了减少广域网的小分组数目,从而减小网络拥塞的出现。

具体的内容感兴趣的可以看看这两篇文章:
TCP之Nagle算法&&延迟ACK
TCP NAGLE算法和实现

而UDP就不会有这种情况,它不会使用块的合并优化算法。
这里说到了就顺便提一下,由于它支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息)。

当然除了优化算法,TCP和UDP都会因为下面两种情况造成粘包:

  • 发送端需要等缓冲区满才发送出去,造成粘包
  • 接收方不及时接收缓冲区的包,造成多个包接收。
二、什么是断包?

断包应该还是比较好理解的,比如我们发送一条很大的数据包,类似图片和录音等等,很显然一次发送或者读取数据的缓冲区大小是有限的,所以我们会分段去发送或者读取数据。
类似下图:

无论是粘包还是断包,如果我们要正确解析数据,那么必须要使用一种合理的机制去解包。这个机制的思路其实很简单:

  • 我们在封包的时候给每个数据包加一个长度或者一个开始结束标记。
  • 然后我们拆包的时候就能区分每个数据包了,再按照长度或者分解符去分拆成各个数据包。

Talk is cheap. Show me the code

三、实例:基于CocoaAsyncSocket的封包,拆包处理。

开始动手之前,我们需要去理解下面这几个方法

//读取数据,有数据就会触发代理
- (void)readDataWithTimeout:(NSTimeInterval)timeout tag:(long)tag;
//直到读到这个长度的数据,才会触发代理
- (void)readDataToLength:(NSUInteger)length withTimeout:(NSTimeInterval)timeout tag:(long)tag;
//直到读到data这个边界,才会触发代理
- (void)readDataToData:(NSData *)data withTimeout:(NSTimeInterval)timeout tag:(long)tag;

还记得我们之前讲:[iOS即时通讯,从入门到“放弃”?][4]中提到过,这个框架每次读取数据,必须手动的去调用上述这些read方法,而我们之前的实现思路是,第一次连接成功的代理触发后调用:

[4]: https://www.jianshu.com/p/2dbb360886a8
  • (void)readDataWithTimeout:(NSTimeInterval)timeout tag:(long)tag;

之后每次收到消息之后,都在去调用一次这个方法,超时为-1,即不超时。这样我们每次收到消息,都会即时触发我们读取消息的代理:

- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag

然而这么做显然没有考虑数据的拆包,如果我们一条一条的发送文字信息,自然没什么问题。如果我们一次发送数条,或者发送大图片。那么问题就出来了,我们解析出来的数据显然是不对的。

这时候我们就需要另外两个read方法了,一个是读取到指定长度,另一个是读取到指定边界。
我们通过自己定义的数据边界,去调用这两个方法,而触发的读取代理,得到的数据才是正确的一个包的数据。

所以我们的核心思路有了:
  1. 封包的时候给每个包的数据加一个标记,来标明数据的长度和类型(类型显然是需要的,我们需要知道它是文本、图片、还是录音等等,来用正确的方式处理这个数据)。
  2. 拆包的时候,先获取到我们给每个包的标记,然后根据标记的数据长度,去获取数据。最后再根据标记的类型去处理数据。(文字输出、图片展示、录音播放等等)。

接着我们可以开始动手了:
这里我们首先需要一个服务端,一个客户端。为了简单,我们都用OC来实现。

其中我们客户端用手机,服务端我们用Xcode模拟器。(由于Xcode只能同一时间运行一个模拟器…)

这里我们用客户端封包发送数据,然后服务端拆包解析数据。

我们先来看看客户端的代码:

static  NSString * Khost = @"10.10.100.48";
static const uint16_t Kport = 6969;
//建立连接
- (BOOL)connect
{
    return  [gcdSocket connectToHost:Khost onPort:Kport error:nil];
}

初始化略过了,大家可以看看github中的代码,这里需要说的是,为了连接上本机的服务端,我们这里的host为服务端的IP地址:

端口为6969(只需和服务端accpet端口一致即可)。

注意:如果大家要运行github上的demo,只需修改这个host地址即可,把它改成你电脑(服务端)的IP地址。

接着我们来看看write方法,我们在该方法中进行封包:

//发送消息
- (void)sendMsg
{
    NSData *data  = [@"你好" dataUsingEncoding:NSUTF8StringEncoding];
    NSData *data1  = [@"猪头" dataUsingEncoding:NSUTF8StringEncoding];
    NSData *data2  = [@"先生" dataUsingEncoding:NSUTF8StringEncoding];


    NSData *data3  = [@"今天天气好" dataUsingEncoding:NSUTF8StringEncoding];
    NSData *data4  = [@"吃饭了吗" dataUsingEncoding:NSUTF8StringEncoding];

    [self sendData:data :@"txt"];
    [self sendData:data1 :@"txt"];
    [self sendData:data2 :@"txt"];
    [self sendData:data3 :@"txt"];
    [self sendData:data4 :@"txt"];

    NSString *filePath = [[NSBundle mainBundle]pathForResource:@"test1" ofType:@"jpg"];

    NSData *data5 = [NSData dataWithContentsOfFile:filePath];

    [self sendData:data5 :@"img"];
}

- (void)sendData:(NSData *)data :(NSString *)type
{
    NSUInteger size = data.length;

    NSMutableDictionary *headDic = [NSMutableDictionary dictionary];
    [headDic setObject:type forKey:@"type"];
    [headDic setObject:[NSString stringWithFormat:@"%ld",size] forKey:@"size"];
    NSString *jsonStr = [self dictionaryToJson:headDic];


    NSData *lengthData = [jsonStr dataUsingEncoding:NSUTF8StringEncoding];


    NSMutableData *mData = [NSMutableData dataWithData:lengthData];
    //分界
    [mData appendData:[GCDAsyncSocket CRLFData]];

    [mData appendData:data];


    //第二个参数,请求超时时间
    [gcdSocket writeData:mData withTimeout:-1 tag:110];

}
- (NSString *)dictionaryToJson:(NSDictionary *)dic
{
    NSError *error = nil;
    NSData *jsonData = [NSJSONSerialization dataWithJSONObject:dic options:NSJSONWritingPrettyPrinted error:&error];
    return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
}

总共上述两个方法,也很简单,我们发送了6条数据,前5条为文本形式,最后一条是一个20多M的图片。当我们点击发送的时候会触发这个方法,这6条数据会被同时发出。

这里我们来看看我们是如何封包的:

  • 我们定义了一个headDic,这个是我们数据包的头部,里面装了这个数据包的大小和类型信息(当然,你可以装更多的其他标识信息。)然后我们把它转成了json,最后转成data
  • 然后我们把这个head拼在最前面,接着拼了一个:

    [GCDAsyncSocket CRLFData]

这个是什么呢?其实它就是一个\r\n。我们用它来做头部的边界。(又或者我们可以规定一个固定的头部长度,来作为边界,这里仅仅是提供给大家一个思路)。

  • 最后我们把真正的数据包给拼接上。

注:如果你想的更远的话,甚至可以在结尾,再拼一个包结束的标识符,后面我们会讲到为什么可以这么做。这里暂时先这样。

就这样,我们完成了数据的封包和发送。

客户端有了,接着我们来看看服务端是如何来拆包的:

首先我们需要监听本机6969端口。(完整代码可以见github)

static const uint16_t Kport = 6969;

//等待连接
- (BOOL)accept
{
    NSError *error = nil;

    BOOL isSuccess =   [gcdSocket acceptOnPort:Kport error:&error];
    if (isSuccess) {
        NSLog(@"监听成功6969端口成功,等待连接");
        return YES;
    }else{
        NSLog(@"监听失败,原因:%@",error);
        return NO;
    }
}

当客户端连接上来后,调用成功接收到客户端连接的代理方法:

- (void)socket:(GCDAsyncSocket *)sock didAcceptNewSocket:(GCDAsyncSocket *)newSocket
{
    NSLog(@"接受到socket连接");

    [_sockets addObject:newSocket];
    [newSocket readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:110];
}

这里需要注意的是,成功接收到连接后,调用代理我们必须把新生成的这个newSocket保存起来,如果它被销毁了,那么连接就断开了,这里我们把它放到了一个数组中去了。
这里需要注意的是,成功连接后,我们就调用了:

[newSocket readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:110];

还记得我们封包的时候,数据包头部之后拼了这么一个分解符data。这样,当有数据包传输过来我们就能获取到这个数据包的头部(后面的信息先不读取)。

接着我们来看看服务端的read代理方法是如何拆包的:

- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag
{
    //先读取到当前数据包头部信息
    if (!currentPacketHead) {
        currentPacketHead = [NSJSONSerialization
                             JSONObjectWithData:data
                             options:NSJSONReadingMutableContainers
                             error:nil];
        if (!currentPacketHead) {
            NSLog(@"error:当前数据包的头为空");

            //断开这个socket连接或者丢弃这个包的数据进行下一个包的读取

            //....
            return;
        }        

        NSUInteger packetLength = [currentPacketHead[@"size"] integerValue];

        //读到数据包的大小
        [sock readDataToLength:packetLength withTimeout:-1 tag:110];

        return;
    }


    //正式的包处理
    NSUInteger packetLength = [currentPacketHead[@"size"] integerValue];
    //说明数据有问题
    if (packetLength <= 0 || data.length != packetLength) {
        NSLog(@"error:当前数据包数据大小不正确");
        return;
    }

    NSString *type = currentPacketHead[@"type"];

    if ([type isEqualToString:@"img"]) {
        NSLog(@"图片设置成功");
        self.recvImg.image = [UIImage imageWithData:data];
    }else{

        NSString *msg = [[NSString alloc]initWithData:data encoding:NSUTF8StringEncoding];
        NSLog(@"收到消息:%@",msg);
    }

    currentPacketHead = nil;

    [sock readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:110];
}

这个方法也很简单,我们判断,如果currentPacketHead(当前数据包的头部)为空,则说明这次读取,是一个头部信息,我们去获取到该数据包的头部信息。并且调用下一次读取,读取长度为从头部信息中取出来的数据包长度:

[sock readDataToLength:packetLength withTimeout:-1 tag:110];

这样当GCDAsyncSocket中数据缓冲区长度达到我们需要读取的length就能触发代理方法的第二次回调。(具体原理实现会在楼主的GCDAsyncSocket解析的后续系列Read篇中去讲,敬请期待)。
这时候因为currentPacketHead不为空,所以我们就知道是去获取一个数据包,我们从头部信息中拿到数据包的类型,如果是文本或者图片,则分别输出或展示到屏幕上。读取完成后我们再次调用:

[sock readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:110];

这样就开始了下一个数据包的头部信息读取。
就这样,整个数据拆包的处理就完成了

接着我们来讲讲我们之前所说的为什么可以在数据包之后加一个结束标识符。我们数据很可能在传输的过程中,丢失了一部分,或者头部信息不可读,导致我们无法正常读取这个数据包。
可能我们会有一个应用场景,当出现错误包的时候,我们就直接抛弃掉它,直接开始下一个数据包的读取(当然现实中,我们往往是需要重新发送,这里仅仅是举一个应用场景)。这样这个结束标识符就起作用了,我们可以直接把数据读取到这个错误包的结束标识处,不做任何处理,这样相当于丢弃掉这个错误包了。

最后我们来看看运行效果:

我们客户端手机连接上服务器后,点击发送,发出我们上述客户端写的6条数据,在我们服务端,按照顺序接受到数据如图:

写在结尾:

本来不打算写应用篇的,但是很多朋友在问数据包相关的内容,而且正好之后的Read篇会涉及到这些,所以就当为了后面的内容做一个铺垫吧。

关于IM的路还有很长,路漫漫其修远兮,吾将上下而求索。

Flex 布局

From: https://zhuanlan.zhihu.com/p/25303493

为什么我要写这一篇关于 Flex 布局的教程?

因为它十分简单灵活,区区简单几行代码就可以实现各种页面的的布局,以前我在学习页面布局的时候我深受其 float、display、position 这些属性的困扰。然而学习 Flex 布局,你只要学习几个 CSS 属性,就可以写出简洁优雅复杂的页面布局。

本教程适合人群:

  1. 前端小白,不太明白页面布局技巧,但想通过简单的学习学会如何进行页面布局
  2. 学过但是不太熟悉 Flex 布局,需要教程来巩固回顾 Flex 布局的知识点
  3. 项目不考虑兼容 IE 低版本浏览器,想简单优雅地写出漂亮的页面布局

废话就不多说了,我将尽可能地用简洁明了的言语来描述 Flex 布局,看完你会收获的。

Flex 基本概念:

在 flex 容器中默认存在两条轴,水平主轴(main axis) 和垂直的交叉轴(cross axis),这是默认的设置,当然你可以通过修改使垂直方向变为主轴,水平方向变为交叉轴,这个我们后面再说。

在容器中的每个单元块被称之为 flex item,每个项目占据的主轴空间为 (main size), 占据的交叉轴的空间为 (cross size)。

这里需要强调,不能先入为主认为宽度就是 main size,高度就是 cross size,这个还要取决于你主轴的方向,如果你垂直方向是主轴,那么项目的高度就是 main size。

Flex 容器:

首先,实现 flex 布局需要先指定一个容器,任何一个容器都可以被指定为 flex 布局,这样容器内部的元素就可以使用 flex 来进行布局。

.container {
    display: flex | inline-flex;       //可以有两种取值
}

分别生成一个块状或行内的 flex 容器盒子。简单说来,如果你使用块元素如 div,你就可以使用 flex,而如果你使用行内元素,你可以使用 inline-flex。

需要注意的是:当时设置 flex 布局之后,子元素的 float、clear、vertical-align 的属性将会失效。

有下面六种属性可以设置在容器上,它们分别是:

  1. flex-direction
  2. flex-wrap
  3. flex-flow
  4. justify-content
  5. align-items
  6. align-content

1. flex-direction: 决定主轴的方向(即项目的排列方向)

.container {
    flex-direction: row | row-reverse | column | column-reverse;
}

默认值:row,主轴为水平方向,起点在左端。

row-reverse:主轴为水平方向,起点在右端

column:主轴为垂直方向,起点在上沿

column-reverse:主轴为垂直方向,起点在下沿

2. flex-wrap: 决定容器内项目是否可换行

默认情况下,项目都排在主轴线上,使用 flex-wrap 可实现项目的换行。

.container {
    flex-wrap: nowrap | wrap | wrap-reverse;
}

默认值:nowrap 不换行,即当主轴尺寸固定时,当空间不足时,项目尺寸会随之调整而并不会挤到下一行。

wrap:项目主轴总尺寸超出容器时换行,第一行在上方

wrap-reverse:换行,第一行在下方

3. flex-flow: flex-direction 和 flex-wrap 的简写形式
8

1
2
3
.container {
flex-flow: <flex-direction> || <flex-wrap>;
}

默认值为: row nowrap,感觉没什么卵用,老老实实分开写就好了。这样就不用记住这个属性了。

4. justify-content:定义了项目在主轴的对齐方式。

.container {
    justify-content: flex-start | flex-end | center | space-between | space-around;
}

建立在主轴为水平方向时测试,即 flex-direction: row

默认值: flex-start 左对齐

flex-end:右对齐

center:居中

space-between:两端对齐,项目之间的间隔相等,即剩余空间等分成间隙。

space-around:每个项目两侧的间隔相等,所以项目之间的间隔比项目与边缘的间隔大一倍。

  1. align-items: 定义了项目在交叉轴上的对齐方式**

    ![13](http://images.chenzhao.date/blogimages/30%20%E5%88%86%E9%92%9F%E5%AD%A6%E4%BC%9A%20Flex%20%E5%B8%83%E5%B1%80%20_%20%E7%9F%A5%E4%B9%8E/pic_014.jpg
    

    )

.container {
align-items: flex-start | flex-end | center | baseline | stretch;
}

建立在主轴为水平方向时测试,即 flex-direction: row

默认值为 stretch 即如果项目未设置高度或者设为 auto,将占满整个容器的高度。

假设容器高度设置为 100px,而项目都没有设置高度的情况下,则项目的高度也为 100px。

flex-start:交叉轴的起点对齐

假设容器高度设置为 100px,而项目分别为 20px, 40px, 60px, 80px, 100px, 则如上图显示。

flex-end:交叉轴的终点对齐

center:交叉轴的中点对齐

baseline: 项目的第一行文字的基线对齐

以文字的底部为主,仔细看图可以理解。

6. align-content: 定义了多根轴线的对齐方式,如果项目只有一根轴线,那么该属性将不起作用

.container {
    align-content: flex-start | flex-end | center | space-between | space-around | stretch;
}

这个这样理解:

当你 flex-wrap 设置为 nowrap 的时候,容器仅存在一根轴线,因为项目不会换行,就不会产生多条轴线。

当你 flex-wrap 设置为 wrap 的时候,容器可能会出现多条轴线,这时候你就需要去设置多条轴线之间的对齐方式了。

建立在主轴为水平方向时测试,即 flex-direction: row, flex-wrap: wrap

默认值为 stretch,看下面的图就很好理解了

从图可以看出又三条轴线(因为容器宽度有限),当值为 stretch 时会三条轴线平分容器的垂直方向上的空间。

值得注意的是,虽然在每条轴线上项目的默认值也为 stretch,但是由于我每个项目我都设置了高度,所以它并没有撑开整个容器。如果项目不设置高度的话就会变成下面这样:

这个我在前面也有提到(align-items),这里重点还是理解三条轴线会平分垂直轴上的空间。

flex-start:轴线全部在交叉轴上的起点对齐

flex-end:轴线全部在交叉轴上的终点对齐

center:轴线全部在交叉轴上的中间对齐space-between:轴线两端对齐,之间的间隔相等,即剩余空间等分成间隙。

space-around:每个轴线两侧的间隔相等,所以轴线之间的间隔比轴线与边缘的间隔大一倍。

到这里关于容器上的所有属性都讲完了,接下来就来讲讲关于在 flex item 上的属性。

Flex 项目属性:

有六种属性可运用在 item 项目上:

  1. order
  2. flex-basis
  3. flex-grow
  4. flex-shrink
  5. flex
  6. align-self

1. order: 定义项目在容器中的排列顺序,数值越小,排列越靠前,默认值为 0

.item {
    order: <integer>;
}

在 HTML 结构中,虽然 -2,-1 的 item 排在后面,但是由于分别设置了 order,使之能够排到最前面。

2. flex-basis: 定义了在分配多余空间之前,项目占据的主轴空间,浏览器根据这个属性,计算主轴是否有多余空间

.item {
    flex-basis: <length> | auto;
}

默认值:auto,即项目本来的大小, 这时候 item 的宽高取决于 width 或 height 的值。

当主轴为水平方向的时候,当设置了 flex-basis,项目的宽度设置值会失效,flex-basis 需要跟 flex-grow 和 flex-shrink 配合使用才能发挥效果。

  • 当 flex-basis 值为 0 % 时,是把该项目视为零尺寸的,故即使声明该尺寸为 140px,也并没有什么用。
  • 当 flex-basis 值为 auto 时,则跟根据尺寸的设定值(假如为 100px),则这 100px 不会纳入剩余空间。

3. flex-grow: 定义项目的放大比例

.item {
    flex-grow: <number>;
}

默认值为 0,即如果存在剩余空间,也不放大

当所有的项目都以 flex-basis 的值进行排列后,仍有剩余空间,那么这时候 flex-grow 就会发挥作用了。

如果所有项目的 flex-grow 属性都为 1,则它们将等分剩余空间。(如果有的话)

如果一个项目的 flex-grow 属性为 2,其他项目都为 1,则前者占据的剩余空间将比其他项多一倍。

当然如果当所有项目以 flex-basis 的值排列完后发现空间不够了,且 flex-wrap:nowrap 时,此时 flex-grow 则不起作用了,这时候就需要接下来的这个属性。

4. flex-shrink: 定义了项目的缩小比例

.item {
    flex-shrink: <number>;
}

默认值: 1,即如果空间不足,该项目将缩小,负值对该属性无效。

这里可以看出,虽然每个项目都设置了宽度为 50px,但是由于自身容器宽度只有 200px,这时候每个项目会被同比例进行缩小,因为默认值为 1。

同理可得:

如果所有项目的 flex-shrink 属性都为 1,当空间不足时,都将等比例缩小。

如果一个项目的 flex-shrink 属性为 0,其他项目都为 1,则空间不足时,前者不缩小。

5. flex: flex-grow, flex-shrink 和 flex-basis的简写

.item{
    flex: none | [ <'flex-grow'> <'flex-shrink'>? || <'flex-basis'> ]
} 

flex 的默认值是以上三个属性值的组合。假设以上三个属性同样取默认值,则 flex 的默认值是 0 1 auto。

有关快捷值:auto (1 1 auto) 和 none (0 0 auto)

关于 flex 取值,还有许多特殊的情况,可以按以下来进行划分:

  • 当 flex 取值为一个非负数字,则该数字为 flex-grow 值,flex-shrink 取 1,flex-basis 取 0%,如下是等同的:
.item {flex: 1;}
.item {
    flex-grow: 1;
    flex-shrink: 1;
    flex-basis: 0%;
}
  • 当 flex 取值为 0 时,对应的三个值分别为 0 1 0%

    .item {flex: 0;}
    .item {

    flex-grow: 0;
    flex-shrink: 1;
    flex-basis: 0%;
    

    }

  • 当 flex 取值为一个长度或百分比,则视为 flex-basis 值,flex-grow 取 1,flex-shrink 取 1,有如下等同情况(注意 0% 是一个百分比而不是一个非负数字)
.item-1 {flex: 0%;}
.item-1 {
    flex-grow: 1;
    flex-shrink: 1;
    flex-basis: 0%;
}

.item-2 {flex: 24px;}
.item-2 {
    flex-grow: 1;
    flex-shrink: 1;
    flex-basis: 24px;
}
  • 当 flex 取值为两个非负数字,则分别视为 flex-grow 和 flex-shrink 的值,flex-basis 取 0%,如下是等同的:
.item {flex: 2 3;}
.item {
    flex-grow: 2;
    flex-shrink: 3;
    flex-basis: 0%;
}
  • 当 flex 取值为一个非负数字和一个长度或百分比,则分别视为 flex-grow 和 flex-basis 的值,flex-shrink 取 1,如下是等同的:
.item {flex: 11 32px;}
.item {
    flex-grow: 11;
    flex-shrink: 1;
    flex-basis: 32px;
}

建议优先使用这个属性,而不是单独写三个分离的属性。

grow 和 shrink 是一对双胞胎,grow 表示伸张因子,shrink 表示是收缩因子。

grow 在 flex 容器下的子元素的宽度和比容器和小的时候起作用。 grow 定义了子元素的尺寸增长因子,容器中除去子元素之和剩下的尺寸会按照各个子元素的 grow 值进行平分加大各个子元素上。

另外感谢 [@王嘉成] 在评论区的补充说明容器的 flex-wrap 与子项的 flex-shrink、flex-grow 之间的关系:

  1. 当 flex-wrap 为 wrap | wrap-reverse,且子项宽度和不及父容器宽度时,flex-grow 会起作用,子项会根据 flex-grow 设定的值放大(为0的项不放大)
  2. 当 flex-wrap 为 wrap | wrap-reverse,且子项宽度和超过父容器宽度时,首先一定会换行,换行后,每一行的右端都可能会有剩余空间(最后一行包含的子项可能比前几行少,所以剩余空间可能会更大),这时 flex-grow 会起作用,若当前行所有子项的 flex-grow 都为0,则剩余空间保留,若当前行存在一个子项的 flex-grow 不为0,则剩余空间会被 flex-grow 不为0的子项占据
  3. 当 flex-wrap 为 nowrap,且子项宽度和不及父容器宽度时,flex-grow 会起作用,子项会根据 flex-grow 设定的值放大(为0的项不放大)
  4. 当 flex-wrap 为 nowrap,且子项宽度和超过父容器宽度时,flex-shrink 会起作用,子项会根据 flex-shrink 设定的值进行缩小(为0的项不缩小)。但这里有一个较为特殊情况,就是当这一行所有子项 flex-shrink 都为0时,也就是说所有的子项都不能缩小,就会出现讨厌的横向滚动条
  5. 总结上面四点,可以看出不管在什么情况下,在同一时间,flex-shrink 和 flex-grow 只有一个能起作用,这其中的道理细想起来也很浅显:空间足够时,flex-grow 就有发挥的余地,而空间不足时,flex-shrink 就能起作用。当然,flex-wrap 的值为 wrap | wrap-reverse 时,表明可以换行,既然可以换行,一般情况下空间就总是足够的,flex-shrink 当然就不会起作用

6. align-self: 允许单个项目有与其他项目不一样的对齐方式

单个项目覆盖 align-items 定义的属性

默认值为 auto,表示继承父元素的 align-items 属性,如果没有父元素,则等同于 stretch。

.item {
     align-self: auto | flex-start | flex-end | center | baseline | stretch;
}

这个跟 align-items 属性时一样的,只不过 align-self 是对单个项目生效的,而 align-items 则是对容器下的所有项目生效的。

容器 align-items 设置为 flex-start,而第三

iOS 使用NSMethodSignature和 NSInvocation进行 method 或 block的调用

From: https://juejin.im/post/5a30c7c151882503eb4b44e2

这篇博文是我的另一篇 Aspects源码剖析中的一部分,考虑到这部分内容相对独立,单独成篇以便查询。

使用NSMethodSignatureNSInvocation 不仅可以完成对method的调用,也可以完成block的调用。在Aspect中,正是运用NSMethodSignature,NSInvocation 实现了对block的统一处理。这篇博文将演示NSMethodSignatureNSInvocation的使用方法及如何使用他们执行method 或 block。

##对象调用method代码示例 一个实例对象可以通过三种方式调用其方法。

- (void)test{

//type1
    [self printStr1:@"hello world 1"];

//type2
    [self performSelector:@selector(printStr1:) withObject:@"hello world 2"];

//type3
    //获取方法签名
    NSMethodSignature *sigOfPrintStr = [self methodSignatureForSelector:@selector(printStr1:)];

    //获取方法签名对应的invocation
    NSInvocation *invocationOfPrintStr = [NSInvocation invocationWithMethodSignature:sigOfPrintStr];

    /**
    设置消息接受者,与[invocationOfPrintStr setArgument:(__bridge void * _Nonnull)(self) atIndex:0]等价
    */
    [invocationOfPrintStr setTarget:self];

    /**设置要执行的selector。与[invocationOfPrintStr setArgument:@selector(printStr1:) atIndex:1] 等价*/
    [invocationOfPrintStr setSelector:@selector(printStr1:)];

    //设置参数 
    NSString *str = @"hello world 3";
    [invocationOfPrintStr setArgument:&str atIndex:2];

    //开始执行
    [invocationOfPrintStr invoke];
}

- (void)printStr1:(NSString*)str{
    NSLog(@"printStr1  %@",str);
}
复制代码

在调用test方法时,会分别输出:

2017-01-11 15:20:21.642 AspectTest[2997:146594] printStr1  hello world 1
2017-01-11 15:20:21.643 AspectTest[2997:146594] printStr1  hello world 2
2017-01-11 15:20:21.643 AspectTest[2997:146594] printStr1  hello world 3
复制代码

type1和type2是我们常用的,这里不在赘述,我们来说说type3。 NSMethodSignatureNSInvocationFoundation框架为我们提供的一种调用方法的方式,经常用于消息转发。

##NSMethodSignature概述

NSMethodSignature用于描述method的类型信息:返回值类型,及每个参数的类型。 可以通过下面的方式进行创建:

@interface NSObject 
//获取实例方法的签名
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector;
//获取类方法的签名
+ (NSMethodSignature *)instanceMethodSignatureForSelector:(SEL)aSelector;
@end

-------------
//使用ObjCTypes创建方法签名
@interface NSMethodSignature
+ (nullable NSMethodSignature *)signatureWithObjCTypes:(const char *)types;
@end
复制代码

使用NSObject的实例方法和类方法创建NSMethodSignature很简单,不说了。咱撩一撩signatureWithObjCTypes。 在OC中,每一种数据类型可以通过一个字符编码来表示(Objective-C type encodings)。例如字符‘@’代表一个object, ‘i’代表int。 那么,由这些字符组成的字符数组就可以表示方法类型了。举个例子:上面提到的printStr1:对应的ObjCTypes 为 v@:@。

  • ’v‘ : void类型,第一个字符代表返回值类型
  • ’@‘ : 一个id类型的对象,第一个参数类型
  • ’:‘ : 对应SEL,第二个参数类型
  • ’@‘ : 一个id类型的对象,第三个参数类型,也就是- (void)printStr1:(NSString*)str中的str。

printStr1:本来是一个参数,ObjCTypes怎么成了三个参数?要理解这个还必须理解OC中的消息机制。一个method对应的结构体如下,ObjCTypes中的参数其实与IMP method_imp 函数指针指向的函数的参数相一致。相关内容有很多,不了解的可以参考这篇文章[方法与消息][3]。

[3]: https://link.juejin.im?target=http%3A%2F%2Fwww.cocoachina.com%2Fios%2F20141106%2F10150.html

typedef struct objc_method Method;
struct objc_method {
SEL method_name OBJC2_UNAVAILABLE; // 方法名
char
method_types OBJC2_UNAVAILABLE;
IMP method_imp OBJC2_UNAVAILABLE; // 方法实现
}
复制代码

##NSInvocation概述

就像示例代码所示,我们可以通过+ (NSInvocation *)invocationWithMethodSignature:(NSMethodSignature *)sig;创建出NSInvocation对象。接下来你设置各个参数信息, 然后调用invoke进行调用。执行结束后,通过- (void)getReturnValue:(void *)retLoc;获取返回值。 这里需要注意,对NSInvocation对象设置的参数个数及类型和获取的返回值的类型要与创建对象时使用的NSMethodSignature对象代表的参数及返回值类型向一致,否则cresh。

##使用NSInvocation调用block 下面展示block 的两种调用方式

- (void)test{

    void (^block1)(int) = ^(int a){
         NSLog(@"block1 %d",a);
    };

    //type1
    block1(1);

    //type2
    //获取block类型对应的方法签名。
    NSMethodSignature *signature = aspect_blockMethodSignature(block1);
    NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
    [invocation setTarget:block1];
    int a=2;
    [invocation setArgument:&a atIndex:1];
    [invocation invoke];
}
复制代码

type1 就是常用的方法,不再赘述。看一下type2。 type2和上面调用method的type3用的一样的套路,只是参数不同:由block生成的NSInvocation对象的第一个参数是block本身,剩下的为 block自身的参数。

由于系统没有提供获取block的ObjCTypes的api,我们必须想办法找到这个ObjCTypes,只有这样才能生成NSMethodSignature对象! ###block的数据结构 & 从数据结构中获取 ObjCTypes oc是一门动态语言,通过编译 oc可以转变为c语言。经过编译后block对应的数据结构是struct。(block中技术点还是挺过的,推荐一本书“Objective-C 高级编程”)

//代码来自 Aspect
// Block internals.
typedef NS_OPTIONS(int, AspectBlockFlags) {
        AspectBlockFlagsHasCopyDisposeHelpers = (1 << 25),
        AspectBlockFlagsHasSignature          = (1 << 30)
};
typedef struct _AspectBlock {
        __unused Class isa;
        AspectBlockFlags flags;
        __unused int reserved;
        void (__unused *invoke)(struct _AspectBlock *block, ...);
        struct {
                unsigned long int reserved;
                unsigned long int size;
                // requires AspectBlockFlagsHasCopyDisposeHelpers
                void (*copy)(void *dst, const void *src);
                void (*dispose)(const void *);
                // requires AspectBlockFlagsHasSignature
                const char *signature;
                const char *layout;
        } *descriptor;
        // imported variables
} *AspectBlockRef;
复制代码

在此结构体中 const char *signature 字段就是我们想要的。通过下面的方法获取signature并创建NSMethodSignature对象。

static NSMethodSignature *aspect_blockMethodSignature(id block, NSError **error) {
    AspectBlockRef layout = (__bridge void *)block;
        if (!(layout->flags & AspectBlockFlagsHasSignature)) {
        NSString *description = [NSString stringWithFormat:@"The block %@ doesn't contain a type signature.", block];
        AspectError(AspectErrorMissingBlockSignature, description);
        return nil;
    }
        void *desc = layout->descriptor;
        desc += 2 * sizeof(unsigned long int);
        if (layout->flags & AspectBlockFlagsHasCopyDisposeHelpers) {
                desc += 2 * sizeof(void *);
    }
        if (!desc) {
        NSString *description = [NSString stringWithFormat:@"The block %@ doesn't has a type signature.", block];
        AspectError(AspectErrorMissingBlockSignature, description);
        return nil;
    }
        const char *signature = (*(const char **)desc);
        return [NSMethodSignature signatureWithObjCTypes:signature];
}

另外一篇

RunTime的使用案例

RunTime的使用案例
From: https://juejin.im/post/5ade99faf265da0b886d0c49

RunTime这个概念几乎是老生常谈了,但是有一些人对这个一直是仅仅对概念的理解,对于用到实例的次数并不太多,这里我就来说一下我项目中一些用到的实例方法吧,里面包含OC和Swift双版本。要是对RunTime的基础该要还有一些不了解的同学,可以点击这里,进行一些概念的普及。

案例

  • 1、防止Button的暴力点击
  • 2、防止UITapGestureRecognizer的暴力点击
  • 3、扩大button的点击范围
  • 4、UIButton 点击事件带多参数
  • 5、给View添加ViewID标志
  • 6、全局返回手势
  • 7、对MJRefresh的封装
  • 8、对DZNEmptyDataSet的封装

1、防止Button的暴力点击

第一篇案例,就说一篇网络上到处都有的一类文章吧,网上一搜,满满的都是。大家应该对这个也是特别的了解吧,所以先从这里开始。

感觉OC版的都没有什么难点,需要注意的Swift版的交换时机。

OC版代码

+ (void)load{
    Method originalMethod = class_getInstanceMethod([self class], @selector(sendAction:to:forEvent:));
    Method swizzledMethod = class_getInstanceMethod([self class], @selector(JH_SendAction:to:forEvent:));
    method_exchangeImplementations(originalMethod, swizzledMethod);
}


static const void *ButtonDurationTime = @"ButtonDurationTime";
- (NSTimeInterval)durationTime{
    NSNumber *number = objc_getAssociatedObject(self, &ButtonDurationTime);
    return number.doubleValue;
}
- (void)setDurationTime:(NSTimeInterval)durationTime{
    NSNumber *number = [NSNumber numberWithDouble:durationTime];
    objc_setAssociatedObject(self, &ButtonDurationTime, number, OBJC_ASSOCIATION_COPY_NONATOMIC);

}

- (void)JH_SendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event{

    self.userInteractionEnabled = NO;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.durationTime * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        self.userInteractionEnabled = YES;
    });

    [self JH_SendAction:action to:target forEvent:event];
}
复制代码

这里有三个小知识点

  • 1、+ (void)load{} 它是一个在整个文件被加载到运行时,在 main 函数调用之前被 ObjC 运行时调用的方法

规则一:父类先于子类调用 规则二:类先于分类调用

  • 2、objc_setAssociatedObject & objc_getAssociatedObject 给分类添加属性
  • 3、method_exchangeImplementations替换原方法实现

Swift版代码 我们知道在swift中取消了+load方法,然后swift4.0以后initialize()也被禁用了,所以想要在哪了实现交换方法还真的需要考虑一下了。这里我想到了三种方法
//部分代码是转载后觉得缺失代码新增的步骤, 函数名没更换,但是逻辑步骤是对的

  • 1、交换方法实用OC代码,然后用一个桥连接
  • 2、把交换方法放到application(_ application:, didFinishLaunchingWithOptions launchOptions: )中,这样交换方法也只会调用一次
  • 3、写一个静态方法,这里我就是实用的静态方法

    struct RunTimeButtonKey {

    ///连续两次点击相差时间
    static let timeInterval = UnsafeRawPointer.init(bitPattern: "timeInterval".hashValue)
    

    }
    extension UIButton {

    private static let changeFunction: () = {
        //交换方法
        let systemMethod = class_getInstanceMethod(UIButton.classForCoder(), #selector(self.sendAction))
        let swizzMethod = class_getInstanceMethod(UIButton.classForCoder(), #selector(self.swizzeMethod))
     //class_replaceMethod(object_getClass(self), #selector(self.swizzeMethod), method_getImplementation(originaMethodC), method_getTypeEncoding(originaMethodC))
       method_exchangeImplementations(systemMethod!, swizzMethod!)
    
        print("changeFunction")
    
    }()
    
    //添加属性,在设置 timeInterval 的时候 修改button的执行事件
    var timeInterval: CGFloat? {
        set {
            objc_setAssociatedObject(self, RunTimeButtonKey.timeInterval!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
    
            UIButton.changeFunction
        }
    
        get {
    
            return  objc_getAssociatedObject(self, RunTimeButtonKey.timeInterval!) as? CGFloat
        }
    
    }
    
    @objc private dynamic func mySendAction(_ action: Selector, to target: Any?, for event: UIEvent?) {
        self.isUserInteractionEnabled = false
        let time:TimeInterval = TimeInterval(timeInterval ?? 0.0)
        DispatchQueue.main.asyncAfter(deadline:.now() + time) {
            self.isUserInteractionEnabled = true
        }
        mySendAction(action, to: target, for: event)
    }
    

    }
    复制代码

其实我对这个方法也是不太满意,但是现在也没有想到更好的方法,哪位小伙伴想到了更好的方法,可以跟我交流一下

2、防止UITapGestureRecognizer的暴力点击

这里为什么要把UITapGestureRecognizer暴力点击也单独拿出来讨论一下呢,因为前一段时间项目中有很多的是执行的点击事件,但是因为是多处用到了,所以就想添加一个timeInterval来处理,但是我当时走到了一个误区。

当时我是参考防止Button的暴力点击的思路的,当时我在UITapGestureRecognizer中找到了addTarget:(id)target action:(SEL)action 这个方法,然后我就想着用一个自己写的方法来交换这个方法,因为button里面有sendAction:to:forEvent:,我当时不动脑子,直接就认为他们一样了,其实他们是有很大区别的sendAction:to:forEvent: 这个是执行的方法,我们交换的话就是交换的是执行方法,但是addTarget:(id)target action:(SEL)action 是添加方法,即使我们交换了,在执行的时候并没有什么变化的

后来一个偶然想起了可以在代理里面改变执行,思路就是添加一个timeInterval,然后在代理里面根据timeInterval设置UITapGestureRecognizer是否可用

OC版代码

@interface UITapGestureRecognizer ()
///时间间隔
@property (nonatomic,assign) NSTimeInterval duration;

@end

static const void *UITapGestureRecognizerduration = @"UITapGestureRecognizerduration";

@implementation UITapGestureRecognizer (JHExtension)



- (NSTimeInterval)duration{
    NSNumber *number = objc_getAssociatedObject(self, &UITapGestureRecognizerduration);
    return number.doubleValue;
}

- (void)setDuration:(NSTimeInterval)duration{
    NSNumber *number = [NSNumber numberWithDouble:duration];
    objc_setAssociatedObject(self, &UITapGestureRecognizerduration, number, OBJC_ASSOCIATION_COPY_NONATOMIC);
}



/**
 添加点击事件

 @param target taeget
 @param action action
 @param duration 时间间隔
 */
- (instancetype)initWithTarget:(id)target action:(SEL)action withDuration:(NSTimeInterval)duration{

    self = [super init];
    if (self) {
        self.duration = duration;
        self.delegate = self;
        [self addTarget:target action:action];
    }
    return self;

}


- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer{
    self.enabled = NO;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.duration * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        self.enabled = YES;
    });

    return YES;
}

@end
复制代码

我们使用UITapGestureRecognizer的时候,我们这么使用[[UITapGestureRecognizer alloc]initWithTarget:self action:@selector(事件)];,所以我直接把方法定义为initWithTarget:(id)target action:(SEL)action withDuration:(NSTimeInterval)duration这样就不改变我们平时的书写习惯了。

Swift版代码

import UIKit
struct RunTimeTapGestureKey {
    ///连续两次点击相差时间
    static let timeInterval = UnsafeRawPointer.init(bitPattern: "timeInterval".hashValue)
}

extension UITapGestureRecognizer:UIGestureRecognizerDelegate{
    //添加属性,在设置 timeInterval 的时候
    var timeInterval: CGFloat? {
        set {
            objc_setAssociatedObject(self, RunTimeTapGestureKey.timeInterval!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
            self.delegate = self
        }
        get {
            return  objc_getAssociatedObject(self, RunTimeTapGestureKey.timeInterval!) as? CGFloat
        }
    }


    convenience init(target: Any?, action: Selector?,timeInterval:CGFloat) {
        self.init(target: target, action: action)
        self.timeInterval = timeInterval
        self.delegate = self  
    }


    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        self.isEnabled = false
        let time:TimeInterval = TimeInterval(timeInterval ?? 0.0)
        DispatchQueue.main.asyncAfter(deadline:.now() + time) {
            self.isEnabled = true
        }
        return true
    }
}

复制代码

3、扩大button的点击范围

开始之前我们首先需要介绍一个方法- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event

iOS系统检测到手指触摸(Touch)操作时会将其放入当前活动Application的事件队列,UIApplication会从事件队列中取出触摸事件并传递给key window(当前接收用户事件的窗口)处理,window对象首先会使用hitTest:withEvent:方法寻找此次Touch操作初始点所在的视图(View),即需要将触摸事件传递给其处理的视图,称之为hit-test view。

window对象会在首先在view hierarchy的顶级view上调用hitTest:withEvent:,此方法会在视图层级结构中的每个视图上调用pointInside:withEvent:,如果pointInside:withEvent:返回YES,则继续逐级调用,直到找到touch操作发生的位置,这个视图也就是hit-test view。

hitTest:withEvent:方法的处理流程如下:

  1. 首先调用当前视图的pointInside:withEvent:方法判断触摸点是否在当前视图内;
  2. 若返回NO,则hitTest:withEvent:返回nil;
  3. 若返回YES,则向当前视图的所有子视图(subviews)发送hitTest:withEvent:消息,所有子视图的遍历顺序是从top到bottom,即从subviews数组的末尾向前遍历,直到有子视图返回非空对象或者全部子视图遍历完毕;
  4. 若第一次有子视图返回非空对象,则hitTest:withEvent:方法返回此对象,处理结束;
  5. 如所有子视图都返回非,则hitTest:withEvent:方法返回自身(self)。

操作思路

  • 1、我们自己添加属性,重新设置点击区域大小
  • 2、根据新的点击区域,重写hitTest:withEvent:方法

OC版代码

static const void *topNameKey = @"topNameKey";
static const void *rightNameKey = @"rightNameKey";
static const void *bottomNameKey = @"bottomNameKey";
static const void *leftNameKey = @"leftNameKey";


- (void)setEnlargeEdgeWithTop:(CGFloat)top right:(CGFloat)right bottom:(CGFloat)bottom left:(CGFloat)left{

    objc_setAssociatedObject(self, &topNameKey, [NSNumber numberWithFloat:top], OBJC_ASSOCIATION_COPY_NONATOMIC);
    objc_setAssociatedObject(self, &rightNameKey, [NSNumber numberWithFloat:right], OBJC_ASSOCIATION_COPY_NONATOMIC);
    objc_setAssociatedObject(self, &bottomNameKey, [NSNumber numberWithFloat:bottom], OBJC_ASSOCIATION_COPY_NONATOMIC);
    objc_setAssociatedObject(self, &leftNameKey, [NSNumber numberWithFloat:left], OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (CGRect)enlargedRect
{
    NSNumber *topEdge = objc_getAssociatedObject(self, &topNameKey);
    NSNumber *rightEdge = objc_getAssociatedObject(self, &rightNameKey);
    NSNumber *bottomEdge = objc_getAssociatedObject(self, &bottomNameKey);
    NSNumber *leftEdge = objc_getAssociatedObject(self, &leftNameKey);
    if (topEdge && rightEdge && bottomEdge && leftEdge) {
        return CGRectMake(self.bounds.origin.x - leftEdge.floatValue,
                          self.bounds.origin.y - topEdge.floatValue,
                          self.bounds.size.width + leftEdge.floatValue + rightEdge.floatValue,
                          self.bounds.size.height + topEdge.floatValue + bottomEdge.floatValue);
    }
    else
    {
        return self.bounds;
    }
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    CGRect rect = [self enlargedRect];
    if (CGRectEqualToRect(rect, self.bounds)) {
        return [super hitTest:point withEvent:event];
    }
    return CGRectContainsPoint(rect, point) ? self : nil;
}

复制代码

Swift版代码

//MARK: -- 扩大点击响应事件 --
struct RunTimeButtonKey {

    ///点击区域
    static let topNameKey = UnsafeRawPointer.init(bitPattern: "topNameKey".hashValue)
    static let rightNameKey = UnsafeRawPointer.init(bitPattern: "rightNameKey".hashValue)
    static let bottomNameKey = UnsafeRawPointer.init(bitPattern: "bottomNameKey".hashValue)
    static let leftNameKey = UnsafeRawPointer.init(bitPattern: "leftNameKey".hashValue)

}
extension UIButton {

    var topEdge: CGFloat? {
        set {
            objc_setAssociatedObject(self, RunTimeButtonKey.topNameKey!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
            UIButton.changeFunction
        }

        get {

            return  objc_getAssociatedObject(self, RunTimeButtonKey.topNameKey!) as? CGFloat
        }
    }
     var leftEdge: CGFloat? {
        set {
            objc_setAssociatedObject(self, RunTimeButtonKey.leftNameKey!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
            UIButton.changeFunction
        }

        get {

            return  objc_getAssociatedObject(self, RunTimeButtonKey.leftNameKey!) as? CGFloat
        }
    }
     var rightEdge: CGFloat? {
        set {
            objc_setAssociatedObject(self, RunTimeButtonKey.rightNameKey!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
            UIButton.changeFunction
        }

        get {

            return  objc_getAssociatedObject(self, RunTimeButtonKey.rightNameKey!) as? CGFloat
        }
    }

    var bottomEdge: CGFloat? {
        set {
            objc_setAssociatedObject(self, RunTimeButtonKey.bottomNameKey!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
            UIButton.changeFunction
        }

        get {

            return  objc_getAssociatedObject(self, RunTimeButtonKey.bottomNameKey!) as? CGFloat
        }
    }


    /// 扩大点击区域
    ///
    /// - Parameters:
    ///   - top: 上
    ///   - right: 右
    ///   - bottom: 下
    ///   - left: 左
    func setEnlargeEdge(top:CGFloat,right:CGFloat,bottom:CGFloat,left:CGFloat)  {
        self.topEdge = top
        self.rightEdge = right
        self.bottomEdge = bottom
        self.leftEdge = left

    }

    open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let left = self.leftEdge ?? 0
        let right = self.rightEdge ?? 0
        let bottom = self.bottomEdge ?? 0
        let top = self.topEdge ?? 0

        let rect:CGRect = CGRect(x: self.bounds.origin.x - left,
                             y: self.bounds.origin.y - top,
                             width: self.bounds.size.width + left + right, height: self.bounds.size.height + top + bottom)


        return rect.contains(point) ? self : nil
    }

}

复制代码

4、UIButton 点击事件带多参数

iOS 原生的 UIButton 点击事件是不允许带多参数的,唯一的一个参数就是默认UIButton本身 那么我们该怎么实现传递多个参数的点击事件呢?

  • 1、如果业务场景非常简单,要求传单参数并且是整数类型,可以用tag
  • 2、利用ObjC关联,runtime之所以被称为iOS 的动态特性是有道理的,当然关联甚至可以帮助NSArray等其他对象实现“多参数传递”

OC版代码

static const void *RunTimeButtonParam = @"RunTimeButtonParam";
- (NSDictionary*)ButtonParam{
    NSDictionary *param = objc_getAssociatedObject(self, &RunTimeButtonParam);
    return param;
}
- (void)setButtonParam:(NSDictionary *)ButtonParam{
    objc_setAssociatedObject(self, &RunTimeButtonParam, ButtonParam, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

复制代码

Swift版代码

//MARK: -- 携带参数 --
extension UIButton {
    var buttonParam: Dictionary<String, Any>? {
        set {
            objc_setAssociatedObject(self, RunTimeButtonKey.RunTimeButtonParam!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }

        get {
            return  objc_getAssociatedObject(self, RunTimeButtonKey.RunTimeButtonParam!) as? Dictionary
        }
    }
}
复制代码

5、给View添加ViewID标志

其实这个跟第四个案例是一样的,但是这里写出来是为了让大家有一个跟多的对比,同时也可以扩展一下思维。

为什么我会给view添加ViewID标志呢,在前一段时间做项目的时候,我需要给view添加标志,标记我点了哪一个view,我们知道我们iOS开发中tag是int类型的,如果后台给我们的id都是值类型的那一般都没有什么太大问题,关键是有的时候后台的id是字符串类型,有字母也有数字,这个时候我们就不能用tag来标记了,而使用字符串类型的ViewID标志那就十分的适合了。

OC版代码

static const void *RunTimeViewID = @"RunTimeViewID";
static const void *RunTimeViewParam = @"RunTimeViewParam";

@implementation UIView (JHExtension)

- (NSString *)viewID{
    NSString *ID = objc_getAssociatedObject(self, &RunTimeViewID);
    return ID;
}
- (void)setViewID:(NSString *)viewID{
    objc_setAssociatedObject(self, &RunTimeViewID, viewID, OBJC_ASSOCIATION_COPY_NONATOMIC);
}



- (NSDictionary *)viewParam{
    NSDictionary *param = objc_getAssociatedObject(self, &RunTimeViewParam);
    return param;
}
- (void)setViewParam:(NSDictionary *)viewParam{
     objc_setAssociatedObject(self, &RunTimeViewParam, viewParam, OBJC_ASSOCIATION_COPY_NONATOMIC);
}


@end

复制代码

Swift版代码

struct RunTimeViewKey {
    static let RunTimeViewID = UnsafeRawPointer.init(bitPattern: "RunTimeViewID".hashValue)
    static let RunTimeViewParam = UnsafeRawPointer.init(bitPattern: "RunTimeViewParam".hashValue)
}

extension UIView {
    var ViewID: String? {
        set {
            objc_setAssociatedObject(self, RunTimeViewKey.RunTimeViewID!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)

        }
        get {
            return  objc_getAssociatedObject(self, RunTimeViewKey.RunTimeViewID!) as? String
        }
    }

    var ViewParam: Dictionary<String, Any>? {
        set {
            objc_setAssociatedObject(self, RunTimeViewKey.RunTimeViewParam!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)

        }
        get {
            return  objc_getAssociatedObject(self, RunTimeViewKey.RunTimeViewParam!) as? Dictionary
        }
    }
}
复制代码

6、全局返回手势

对于全局返回手势,虽然能够知道原理,但是因为跟原生手势有交互,本着对自己技术不相信的态度,我就简单的说一下原理,具体的我们还是最好使用大神的。

其实系统是自带返回手势的,但是他的返回手势是在最左边,我们要做的就是找到这个这个系统方法,然后把他对象设置给控制器的View

 // 打印系统自带滑动手势的代理对象
    NSLog(@"%@",self.interactivePopGestureRecognizer.delegate);

复制代码

我们发现打印方法为handleNavigationTransition

然后我们就可以上代码了

OC版代码

- (void)viewDidLoad {
    [super viewDidLoad];

    // 获取系统自带滑动手势的target对象
    id target = self.interactivePopGestureRecognizer.delegate;

    // 创建全屏滑动手势,调用系统自带滑动手势的target的action方法
    UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:target action:@selector(handleNavigationTransition:)];

    // 设置手势代理,拦截手势触发
    pan.delegate = self;

    // 给导航控制器的view添加全屏滑动手势
    [self.view addGestureRecognizer:pan];

    // 禁止使用系统自带的滑动手势
    self.interactivePopGestureRecognizer.enabled = NO;

}

// 什么时候调用:每次触发手势之前都会询问下代理,是否触发。
// 作用:拦截手势触发
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
    // 注意:只有非根控制器才有滑动返回功能,根控制器没有。
    // 判断导航控制器是否只有一个子控制器,如果只有一个子控制器,肯定是根控制器
    if (self.childViewControllers.count == 1) {
        // 表示用户在根控制器界面,就不需要触发滑动手势,
        return NO;
    }
    return YES;
}

复制代码

注意:这些方法是写在UINavigationController里面的

文章参考自:【8行代码教你搞定导航控制器全屏滑动返回效果】 |那些人追的干货

Swift版代码

  override func viewDidLoad() {
        super.viewDidLoad()
        let target = self.interactivePopGestureRecognizer?.delegate
        let pan = UIPanGestureRecognizer(target: target, action: Selector(("handleNavigationTransition:")))
        pan.delegate = self
        self.view.addGestureRecognizer(pan)
        // 禁止使用系统自带的滑动手势
        self.interactivePopGestureRecognizer?.isEnabled = false;
    }

    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        if (self.childViewControllers.count == 1) {
            // 表示用户在根控制器界面,就不需要触发滑动手势,
            return false;
        }
        return true;
    }
复制代码

这两个demo没有用到runtime的方法,但是也是用到了替代方法, 是一个点赞量接近5千的demo,里面运用了运行时, 这里 有一篇介绍的文章

7、对MJRefresh的封装

想必大部分人都用过MJRefresh这个刷新控件吧,在我刚开始使用的时候,在每一个刷新的地方都会重新的定义一下这个控件,每一个tableview中都会创建这个刷新控件,然后把他的属性在写一遍,但是作为一个程序员怎么容忍自己写那么多的无意义代码呢。我们知道tableviewcollectionView都是继承自scrollView,那么我们可以在 scrollView的分类里面添加一些方法,那么我们在以后使用的时候,就不需要一遍一遍的重复写无用代码了,只需要调用scrollView分类方法就可以了。

OC版代码

@implementation UIScrollView (JHRefresh)
/**
 添加刷新事件

 @param headerBlock 头部刷新
 @param footerBlock 底部刷新
 */
- (void)setRefreshWithHeaderBlock:(void(^)(void))headerBlock
                      footerBlock:(void(^)(void))footerBlock{
    if (headerBlock) {

        MJRefreshNormalHeader *header= [MJRefreshNormalHeader headerWithRefreshingBlock:^{
            if (headerBlock) {
                headerBlock();
            }
        }];
        header.stateLabel.font = [UIFont systemFontOfSize:13];
        header.lastUpdatedTimeLabel.font = [UIFont systemFontOfSize:13];

        self.mj_header = header;
    }

    if (footerBlock) {
        MJRefreshBackNormalFooter *footer = [MJRefreshBackNormalFooter footerWithRefreshingBlock:^{
            footerBlock();
        }];
        footer.stateLabel.font = [UIFont systemFontOfSize:13];
        [footer setTitle:@"暂无更多数据" forState:MJRefreshStateNoMoreData];
        [footer setTitle:@"" forState:MJRefreshStateIdle];
        self.mj_footer.ignoredScrollViewContentInsetBottom = 44;
        self.mj_footer = footer;
    }
}



/**
 开启头部刷新
 */
- (void)headerBeginRefreshing{
    [self.mj_header beginRefreshing];
}


/**
 没有更多数据
 */
- (void)footerNoMoreData{
    [self.mj_footer setState:MJRefreshStateNoMoreData];
}

/**
 结束刷新
 */
- (void)endRefresh{

    if (self.mj_header) {
        [self.mj_header endRefreshing];
    }
    if (self.mj_footer) {
        [self.mj_footer endRefreshing];
    }
}


复制代码

Swift版代码

import UIKit
import MJRefresh
extension UIScrollView {

    /// 添加刷新事件
    ///
    /// - Parameters:
    ///   - refreshHeaderClosure: 头部刷新
    ///   - refreshFooterClosure: 底部刷新
    func addRefreshWithScrollView(refreshHeaderClosure:@escaping()->(), refreshFooterClosure:@escaping()->())  {

        ///*******头部刷新*************
        let header:MJRefreshNormalHeader = MJRefreshNormalHeader.init {
           refreshHeaderClosure()
        }

        //自动改变透明度 (当控件被导航条挡住后不显示)
        header.isAutomaticallyChangeAlpha = true
        // 设置字体
        header.stateLabel.font = UIFont.systemFont(ofSize: 13)
        header.lastUpdatedTimeLabel.font = UIFont.systemFont(ofSize: 13)
        self.mj_header = header

        ///**********尾部刷新**********
        let foot:MJRefreshBackNormalFooter = MJRefreshBackNormalFooter.init {
            refreshFooterClosure()
        }

        foot.stateLabel.font = UIFont.systemFont(ofSize: 13)
        foot.setTitle("", for: MJRefreshState.idle)
        foot.setTitle("暂无更多数据", for: MJRefreshState.noMoreData)

        self.mj_footer = foot

    }


    /// 添加头部刷新事件
    ///
    /// - Parameter refreshClosure: 闭包回调
    func addRefreshHeaderWithScrollView(refreshClosure:@escaping()->()) {

        let header:MJRefreshNormalHeader = MJRefreshNormalHeader.init {
            refreshClosure()
            }  

        //自动改变透明度 (当控件被导航条挡住后不显示)
        header.isAutomaticallyChangeAlpha = true
        // 设置字体
        header.stateLabel.font = UIFont.systemFont(ofSize: 13)
        header.lastUpdatedTimeLabel.font = UIFont.systemFont(ofSize: 13)
        self.mj_header = header
    }

    /// 下拉加载
    ///
    /// - Parameters:
    ///   -  tableView: tableView
    ///   - refreshClosure: 闭包回调
    func addRefreshFooterWithScrollView(refreshClosure:@escaping()->()) {
        let foot:MJRefreshBackNormalFooter = MJRefreshBackNormalFooter.init {
            refreshClosure()
        }

        foot.stateLabel.font = UIFont.systemFont(ofSize: 13)
        foot.setTitle("", for: MJRefreshState.idle)
        foot.setTitle("暂无更多数据", for: MJRefreshState.noMoreData)

        self.mj_footer = foot

    }


    /// 结束刷新
    ///
    /// - Parameter tableView: tableView
    func endRefreshWithTableView() {

        if (self.mj_header != nil) {
            self.mj_header.endRefreshing()
        }
        if (self.mj_footer != nil) {
            self.mj_footer.endRefreshing()
        }

    }


    /// 没有数据
    func NOMoreData() {
        self.mj_footer.state = .noMoreData
    }

}

复制代码

8、对DZNEmptyDataSet的封装

其实这个跟上面那一个封装是一个类型的,oc版代码几乎都是一样的,但是swift代码里面会有一个小小的坑需要我们来填。

OC版代码.h文件里面暴露了一下的方法

@property (nonatomic) ClickBlock clickBlock;                // 点击事件
@property (nonatomic, assign) CGFloat offset;               // 垂直偏移量
@property (nonatomic, strong) NSString *emptyText;          // 空数据显示内容
@property (nonatomic, strong) UIImage *emptyImage;          // 空数据的图片


- (void)setupEmptyData:(ClickBlock)clickBlock;
- (void)setupEmptyDataText:(NSString *)text tapBlock:(ClickBlock)clickBlock;
- (void)setupEmptyDataText:(NSString *)text verticalOffset:(CGFloat)offset tapBlock:(ClickBlock)clickBlock;
- (void)setupEmptyDataText:(NSString *)text verticalOffset:(CGFloat)offset emptyImage:(UIImage *)image tapBlock:(ClickBlock)clickBlock;
复制代码

.m文件中

static const void *KClickBlock = @"clickBlock";
static const void *KEmptyText = @"emptyText";
static const void *KOffSet = @"offset";
static const void *Kimage = @"emptyImage";

@implementation UIScrollView (JHEmptyDataSet)



- (ClickBlock)clickBlock{
    return objc_getAssociatedObject(self, &KClickBlock);
}

- (void)setClickBlock:(ClickBlock)clickBlock{

    objc_setAssociatedObject(self, &KClickBlock, clickBlock, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (NSString *)emptyText{
    return objc_getAssociatedObject(self, &KEmptyText);
}

- (void)setEmptyText:(NSString *)emptyText{
    objc_setAssociatedObject(self, &KEmptyText, emptyText, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (CGFloat)offset{

    NSNumber *number = objc_getAssociatedObject(self, &KOffSet);
    return number.floatValue;
}

- (void)setOffset:(CGFloat)offset{

    NSNumber *number = [NSNumber numberWithDouble:offset];

    objc_setAssociatedObject(self, &KOffSet, number, OBJC_ASSOCIATION_COPY_NONATOMIC);
}


- (UIImage *)emptyImage{
    return objc_getAssociatedObject(self, &Kimage);
}

- (void)setEmptyImage:(UIImage *)emptyImage{
    objc_setAssociatedObject(self, &Kimage, emptyImage, OBJC_ASSOCIATION_COPY_NONATOMIC);
}



- (void)setupEmptyData:(ClickBlock)clickBlock{
    self.clickBlock = clickBlock;
    self.emptyDataSetSource = self;
    if (clickBlock) {
        self.emptyDataSetDelegate = self;
    }
}


- (void)setupEmptyDataText:(NSString *)text tapBlock:(ClickBlock)clickBlock{

    self.clickBlock = clickBlock;
    self.emptyText = text;

    self.emptyDataSetSource = self;
    if (clickBlock) {
        self.emptyDataSetDelegate = self;
    }
}


- (void)setupEmptyDataText:(NSString *)text verticalOffset:(CGFloat)offset tapBlock:(ClickBlock)clickBlock{

    self.emptyText = text;
    self.offset = offset;
    self.clickBlock = clickBlock;

    self.emptyDataSetSource = self;
    if (clickBlock) {
        self.emptyDataSetDelegate = self;
    }
}


- (void)setupEmptyDataText:(NSString *)text verticalOffset:(CGFloat)offset emptyImage:(UIImage *)image tapBlock:(ClickBlock)clickBlock{

    self.emptyText = text;
    self.offset = offset;
    self.emptyImage = image;
    self.clickBlock = clickBlock;

    self.emptyDataSetSource = self;
    self.emptyDataSetDelegate = self;

}



// 空白界面的标题
- (NSAttributedString *)titleForEmptyDataSet:(UIScrollView *)scrollView{
    NSString *text = self.emptyText?:@"没有找到任何数据";
    UIFont *font = [UIFont systemFontOfSize:17.0];
    NSMutableAttributedString *attStr = [[NSMutableAttributedString alloc] initWithString:text];
    [attStr addAttribute:NSFontAttributeName value:font range:NSMakeRange(0, text.length)];
    [attStr addAttribute:NSForegroundColorAttributeName value:JHWordColorDark range:NSMakeRange(0, text.length)];

    return attStr;
}

// 空白页的图片
- (UIImage *)imageForEmptyDataSet:(UIScrollView *)scrollView{
    return self.emptyImage?:[UIImage imageNamed:@"mine"];
}

//是否允许滚动,默认NO
- (BOOL)emptyDataSetShouldAllowScroll:(UIScrollView *)scrollView {
    return YES;
}

// 垂直偏移量
- (CGFloat)verticalOffsetForEmptyDataSet:(UIScrollView *)scrollView{
    return self.offset;
}




- (void)emptyDataSet:(UIScrollView *)scrollView didTapView:(UIView *)view{
    if (self.clickBlock) {
        self.clickBlock();
    }
}

复制代码

swift版代码 这里出现了一个坑,就是swift中怎么在扩展中添加闭包回调属性。对于这个问题大家可以参考[这篇文章][5],关键就是一句话,要先定义一个类属性作为闭包容器,专门存放闭包的属性

[5]: https://link.juejin.im?target=https%3A%2F%2Fwww.jianshu.com%2Fp%2Fc6658ee16168

import UIKit
import DZNEmptyDataSet

struct RuntimeKey {
    ///空数据显示内容
    static let emptyText = UnsafeRawPointer.init(bitPattern: "emptyText".hashValue)
    ///空数据的图片
    static let emptyImage = UnsafeRawPointer.init(bitPattern: "emptyImage".hashValue)
    ///垂直偏移量
    static let offset = UnsafeRawPointer.init(bitPattern: "offset".hashValue)
    ///点击回调闭包
    static var clickClosure = UnsafeRawPointer.init(bitPattern: "clickClosure".hashValue)

}
//MARK: -- 给UIScrollView添加属性 --
extension UIScrollView {
    ///空数据显示内容
    var emptyText: String? {
        set {
            objc_setAssociatedObject(self, RuntimeKey.emptyText!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }

        get {
            return  objc_getAssociatedObject(self, RuntimeKey.emptyText!) as? String
        }
    }
    ///空数据的图片
    var emptyImage: String? {
        set {
            objc_setAssociatedObject(self, RuntimeKey.emptyImage!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }

        get {
            return  objc_getAssociatedObject(self, RuntimeKey.emptyImage!) as? String
        }
    }

    ///垂直偏移量
    var offset: CGFloat? {
        set {
            objc_setAssociatedObject(self, RuntimeKey.offset!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }

        get {
            return  objc_getAssociatedObject(self, RuntimeKey.offset!) as? CGFloat
        }
    }

    //闭包回调
    typealias clickTipClosure = () -> Void
    // 定义一个类属性作为闭包的容器,专门存放闭包的属性
    private class BlockContainer: NSObject, NSCopying {
        func copy(with zone: NSZone? = nil) -> Any {
            return self
        }
        var clickTipClosure: clickTipClosure?
    }
    // 定义个一个计算属性
    private var newDataBlock: BlockContainer? {

        set(newValue) {
            objc_setAssociatedObject(self, RuntimeKey.clickClosure!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }
        get {
            return objc_getAssociatedObject(self, RuntimeKey.clickClosure!) as? BlockContainer
        }

    }


}

//MARK: -- 给UIScrollView添加方法 --
extension UIScrollView :DZNEmptyDataSetSource,DZNEmptyDataSetDelegate{

    /// 设置空白页text。image。偏移量
    ///
    /// - Parameters:
    ///   - text: text
    ///   - image: image
    ///   - offSet: 偏移量
    func SetUPEmptyTextWithEmptyImageWithOffSet(text:String,image:String,offSet:CGFloat) {
        self.emptyText = text
        self.emptyImage = image
        self.offset = offSet

        self.emptyDataSetDelegate = self
        self.emptyDataSetSource = self
    }

    /// 设置空白页text。image
    ///
    /// - Parameters:
    ///   - text: text
    ///   - image: image
    ///
    func SetUPEmptyTextWithEmptyImage(text:String,image:String){
        self.emptyText = text
        self.emptyImage = image


        self.emptyDataSetDelegate = self
        self.emptyDataSetSource = self
    }


    /// 仅仅设置空白页图片
    ///
    /// - image: image
    func SetUPEmptyText(image:String){
         self.emptyImage = image
         self.emptyDataSetDelegate = self
         self.emptyDataSetSource = self
    }


    /// 仅仅设置空白页文本
    ///
    /// - Parameter text: text
    func SetUPEmptyText(text:String){
        self.emptyText = text
        self.emptyDataSetDelegate = self
        self.emptyDataSetSource = self
    }

    ///点击空白页回调
    func obtainClickClosure(Closure:@escaping clickTipClosure) {
        // 创建blockContainer,将外界传来的闭包赋值给类属性中的闭包变量
        let blockContainer: BlockContainer = BlockContainer()
        blockContainer.clickTipClosure = Closure
        self.newDataBlock = blockContainer
    }


}




//MARK: - DZNEmptyDataSetSource -
extension UIScrollView {
    // 空白界面的标题
    public func title(forEmptyDataSet scrollView: UIScrollView!) -> NSAttributedString! {

        guard self.emptyText != nil else {
            return nil
        }

        let text = self.emptyText ?? ""
        let attStr = NSMutableAttributedString.init(string: text)
        attStr.addAttribute(NSAttributedStringKey.strokeColor, value: UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 1), range: NSMakeRange(0, text.count))

        attStr.addAttribute(NSAttributedStringKey.font, value: UIFont.systemFont(ofSize: 17), range: NSMakeRange(0, text.count))

        return attStr
    }
    // 空白页的图片
    public func image(forEmptyDataSet scrollView: UIScrollView!) -> UIImage! {

        guard self.emptyImage != nil else {
            return nil
        }

        return UIImage.init(named: self.emptyImage!)
    }

    //是否允许滚动,默认NO
    public func emptyDataSetShouldAllowScroll(_ scrollView: UIScrollView!) -> Bool {
        return true
    }

    // 垂直偏移量
    public func verticalOffset(forEmptyDataSet scrollView: UIScrollView!) -> CGFloat {
        let set = self.offset ?? -50.0
        return CGFloat(set)
    }

    //点击
    public func emptyDataSet(_ scrollView: UIScrollView!, didTap view: UIView!) {
        self.newDataBlock?.clickTipClosure!()
    }

}


复制代码

iOS崩溃捕捉和分析

From: https://www.jianshu.com/p/09b6084bcd01

一、 崩溃日志

  • 1 什么是崩溃日志
    iOS设备上的应用闪退时, 操作系统会声称一个崩溃日志, 保存在设备上。

    路径是: 设置 -> 隐私 ->诊断与用量 ->诊断与用量数据。在这里可以看到设备上所有的设备崩溃日志.
    在“诊断与用量”界面,建议用户选择自动发送,这样可以每天自动发送诊断和用量数据到itunes,来帮助开发者分析崩溃.

  • 2如何获取崩溃日志
    2.1 连接设备获取崩溃日志
    设备与电脑上的ITunes Store同步后, 会将崩溃日志保存在电脑上,崩溃日志保存在以下位置:

    Mac OS X: ~/Library/Logs/CrashReporter/MobileDevice/

    可以看到所有和该电脑同步过的设备的崩溃日志(.crash文件)

iOS设备上的崩溃日志

2.2 通过Xcode获取崩溃日志
打开Xcode, 菜单栏上选择Window ->Devices,选中设备,点击View Device Logs -> All logs可以看到所有的崩溃日志。
选中某一个崩溃日志,点击Export Log可导出崩溃日志(.crash文件)

Xcode 查看崩溃日志

2.3 通过iTunes Connect获取使用者上传的崩溃日志
登录iTunes Connect, 选中APP, 点击可供销售的APP(即当前最新版本), 在最下面选中额外信息下的崩溃报告, 可以看到所有iOS版本下的崩溃报告。

iTunes Connect 崩溃日志

二、iOS 崩溃日志分析

首先来看一份崩溃日志

iOS崩溃日志

(1)Incident Identifier: 是崩溃报告的唯一标识符。
(2)CrashReporter Key: 是与设备标识相对应的唯一键值。虽然它不是真正的设备标识符,但也是一个非常有用的情报:如果你看到100个崩溃日志的CrashReporter Key值都是相同的,或者只有少数几个不同的CrashReport值,说明这不是一个普遍的问题,只发生在一个或少数几个设备上。
(3)Hardware Model: 标识设备类型。 如果很多崩溃日志都是来自相同的设备类型,说明应用只在某特定类型的设备上有问题。上面的日志里,崩溃日志产生的设备是iPhone 6(但是显示的是iPhone7,2? 暂时不清楚原因)。
(4)Process 是应用名称。中括号里面的数字是闪退时应用的进程ID。
(5)Version: App版本号
最重要的两部分
(1)Exception Type:EXC_CRASH (SIGABRT)
(2)Last Exception Backtrace(即发生崩溃的原因,也是我们要研究的重点)

Xcode会自动符号化代码, 翻译成明文, 如下:

Crash Logs

可以看到发生崩溃的代码位于[SCHomePageVC viewDidLoad]方法中第408行。
崩溃的代码是[NSArrayM insertObject:atIndex:]。
找到该行代码,可以看到崩溃日志中所描述的崩溃发生的位置,代码都和时机代码一致。

崩溃的代码

崩溃的原因是: The object to add to the array’s content. This value must not be nil.

三、如何通过.crash文件反编译得到明文的crash文件

步骤如下:
  • Step1: 在桌面上创建一个空的文件夹, 我将其命名为 DebugTest , 然后将三个文件放入该文件夹 “MyApp.app” , “MyApp.app.dSYM”, “MyApp_2016_4_1.crash”。
  • Step2 : 打开Applications文件夹,找到 symbolicatecrash 文件, Xcode和Xcode以上,文件位置

    //终端中输入以下命令:
    cd /Applications/Xcode.app/Contents/SharedFrameworks/DTDeviceKitBase.framework/Versions/A/Resources

然后你会发现symbolicatecrash文件,长这个样子,将其拷贝到DebugTest文件夹中

symbolicatecrash

到这一步,你的DebugTest目录机构应该是这样
(1MyAPP.app
(2)MyApp.app.dSYM
(3)MyApp_2016_4_1.crash
(4)symbolicatecrash

  • Step3: 在终端中输入以下3条命令

    //第一条命令(其中Yourname 应该是你的用户名)
    cd /Users/Yourname/Desktop/DebugTest
    // 第二条命令
    export DEVELOPER_DIR=”/Applications/Xcode.app/Contents/Developer”
    //第三条命令(二选一)
    (Xcode6.3和之前版本输入以下命令)
    ./symbolicatecrash -A -v MYApp_2016-4-1.crash MyApp.app.dSYM
    (Xcode6.4和之前版本输入以下命令)
    ./symbolicatecrash -v MYApp_2016-4-1.crash MyApp.app.dSYM

然后用控制台打开你的MyApp_2016_4_1.crash文件, 你就会看到编译后的crash文件, 同Xcode看到的崩溃日志一致。通过查看崩溃日志,可以轻易的找到崩溃原因并修正。

Crash Logs

四、如何在程序崩溃时手动捕捉到崩溃

   当我们debug的时候, 发生崩溃后可以在控制台上看到崩溃的堆栈信息和崩溃日志。上面三种方法都是我们获取.crash文件后解析的办法, 那么如果用户不发送崩溃日志到iTunes Connect时,我们如何获取崩溃信息呢?(尽可能的获取崩溃信息有助于热修复时定位代码)。当然,友盟支持搜集崩溃日志,那我们是否也可以在程序崩溃时,将崩溃信息写入本地,APP再次启动时,将崩溃信息上传到我们的服务器。这里就要用到apple的一个函数:NSSetUncaughtExceptionHandler。上代码:


//application didFinishLaunchingWithOptions中调用 [self catchCrashLogs];

- (void)catchCrashLogs{
    NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);
}
void UncaughtExceptionHandler(NSException *exception){
    if (exception ==nil)return;
    NSArray *array = [exception callStackSymbols];
    NSString *reason = [exception reason];
    NSString *name  = [exception name];
    NSDictionary *dict = @{@"appException":@{@"exceptioncallStachSymbols":array,@"exceptionreason":reason,@"exceptionname":name}};
    if([SDFileToolClass writeCrashFileOnDocumentsException:dict]){
        NSLog(@"Crash logs write ok!");
    }
}
//写入缓存中: 以下提供三个API,分别是:写入,获取,清空
NSString * const SDCrashFileDirectory = @"SDMapHomeCrashFileDirectory"; //你的项目中自定义文件夹名
+ (NSString *)sd_getCachesPath{
    return [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject];
}
+ (BOOL)writeCrashFileOnDocumentsException:(NSDictionary *)exception{
    NSString *time = [[NSDate date] formattedDateWithFormat:@"yyyyMMddHHmmss" locale:[NSLocale currentLocale]];
    NSDictionary *infoDictionary = [[NSBundle mainBundle] infoDictionary];
    NSString *crashname = [NSString stringWithFormat:@"%@_%@Crashlog.plist",time,infoDictionary[@"CFBundleName"]];
    NSString *crashPath = [[self sd_getCachesPath] stringByAppendingPathComponent:SDCrashFileDirectory];
    NSFileManager *manager = [NSFileManager defaultManager];
    //设备信息
    NSMutableDictionary *deviceInfos = [NSMutableDictionary dictionary];
    [deviceInfos setObject:[infoDictionary objectForKey:@"DTPlatformVersion"] forKey:@"DTPlatformVersion"];
    [deviceInfos setObject:[infoDictionary objectForKey:@"CFBundleShortVersionString"] forKey:@"CFBundleShortVersionString"];
    [deviceInfos setObject:[infoDictionary objectForKey:@"UIRequiredDeviceCapabilities"] forKey:@"UIRequiredDeviceCapabilities"];

    BOOL isSuccess = [manager createDirectoryAtPath:crashPath withIntermediateDirectories:YES attributes:nil error:nil];
    if (isSuccess) {
        NSLog(@"文件夹创建成功");
        NSString *filepath = [crashPath stringByAppendingPathComponent:crashname];
        NSMutableDictionary *logs = [NSMutableDictionary dictionaryWithContentsOfFile:filepath];
        if (!logs) {
            logs = [[NSMutableDictionary alloc] init];
        }
        //日志信息
        NSDictionary *infos = @{@"Exception":exception,@"DeviceInfo":deviceInfos};
        [logs setObject:infos forKey:[NSString stringWithFormat:@"%@_crashLogs",infoDictionary[@"CFBundleName"]]];
        BOOL writeOK = [logs writeToFile:filepath atomically:YES];
        NSLog(@"write result = %d,filePath = %@",writeOK,filepath);
        return writeOK;
    }else{
        return NO;
    }
}
+ (nullable NSArray *)sd_getCrashLogs{
     NSString *crashPath = [[self sd_getCachesPath] stringByAppendingPathComponent:SDCrashFileDirectory];
     NSFileManager *manager = [NSFileManager defaultManager];
     NSArray *array = [manager contentsOfDirectoryAtPath:crashPath error:nil];
     NSMutableArray *result = [NSMutableArray array];
    if (array.count == 0) return nil;
    for (NSString *name in array) {
        NSDictionary *dict = [NSDictionary dictionaryWithContentsOfFile:[crashPath stringByAppendingPathComponent:name]];
        [result addObject:dict];
    }
    return result;
}
+ (BOOL)sd_clearCrashLogs{
     NSString *crashPath = [[self sd_getCachesPath] stringByAppendingPathComponent:SDCrashFileDirectory];
    NSFileManager *manager = [NSFileManager defaultManager];
    if (![manager fileExistsAtPath:crashPath]) return YES; //如果不存在,则默认为删除成功
    NSArray *contents = [manager contentsOfDirectoryAtPath:crashPath error:NULL];
    if (contents.count == 0) return YES;
    NSEnumerator *enums = [contents objectEnumerator];
    NSString *filename;
    BOOL success = YES;
    while (filename = [enums nextObject]) {
        if(![manager removeItemAtPath:[crashPath stringByAppendingPathComponent:filename] error:NULL]){
            success = NO;
            break;
        }
    }
    return success;
}

Well done!

五、结论: 为了更好的分析崩溃原因,在每次上架APP的时候,应该保留对应的app文件和dsym文件。

六、参考链接:

iOS 私有api截屏

From: https://www.bbsmax.com/A/o75N8DeMzW/

昨天写了个用到截屏功能的插件,结果问题不断,今天终于解决好了,把debug过程中所有尝试过的截屏方法都贴出来吧~

第一种

这是iOS 3时代开始就被使用的方法,它被废止于iOS 7。iOS的私有方法,效率很高。

#import
extern "C" CGImageRef UIGetScreenImage();
UIImage * screenshot(void) NS_DEPRECATED_IOS(3_0,7_0);
UIImage * screenshot(){
    UIImage *image = [UIImage imageWithCGImage:UIGetScreenImage()];
    return image;
}

第二种

这是在比较常见的截图方法,不过不支持Retina屏幕。

UIImage * screenshot(UIView *);
UIImage * screenshot(UIView *view){
    UIGraphicsBeginImageContext(view.frame.size);
    [view.layer renderInContext:UIGraphicsGetCurrentContext()];
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return image;
}

第三种

从iPhone 4、iPod Touch 4开始,Apple逐渐采用Retina屏幕,于是在iOS 4的SDK中我们有了,上面的截图方法也自然变成了这样。

UIImage * screenshot(UIView *) NS_AVAILABLE_IOS(4_0);
UIImage * screenshot(UIView *view){
    if(UIGraphicsBeginImageContextWithOptions != NULL)
    {
        UIGraphicsBeginImageContextWithOptions(view.frame.size, NO, 0.0);
    } else {
        UIGraphicsBeginImageContext(view.frame.size);
    }
    [view.layer renderInContext:UIGraphicsGetCurrentContext()];
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return image;
}

第四种

或许你会说有时Hook的是一个按钮的方法,用第三个方法的话,根本找不到view来传值,不过还好,iOS 7又提供了一些UIScreen的API。

UIImage * screenshot(void) NS_AVAILABLE_IOS(7_0);
UIImage * screenshot(){
    UIView * view = [[UIScreen mainScreen] snapshotViewAfterScreenUpdates:YES];
    if(UIGraphicsBeginImageContextWithOptions != NULL)
    {
        UIGraphicsBeginImageContextWithOptions(view.frame.size, NO, 0.0);
    } else {
        UIGraphicsBeginImageContext(view.frame.size);
    }
    [view.layer renderInContext:UIGraphicsGetCurrentContext()];
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return image;
}

第五种

@interface SBScreenShotter : NSObject
+ (id)sharedInstance;
- (void)saveScreenshot:(_Bool)arg1;
@end

然后直接

[[SBScreenShotter sharedInstance] saveScreenshot:YES];

一 道白光之后,咱们就模拟了用户截屏的动作,不过这个方法在只需要截屏时比较好,如果要对屏幕录像(其实就是不断截图)的话,那不得闪瞎了。。而且我们也拿 不到UIImage的实例去拼成一个视频呀。即使通过Hook别的类拿到UIImage的实例,这个私有API的效率大概也是达不到30FPS的视频要求 的。

那么现在我们有5种方法了,第一种是私有API,私有API通常效率和质量都比Documented API的好,可是它在iOS 7以后就被废除了啊,就没有别的了吗?

答案当然是————有的!用Private Framework来完成这项任务!直接走底层拿屏幕的缓冲数据,然后生成UIImage的实例。

第六种

#import #import #import #import #import extern "C" IOReturn IOSurfaceLock(IOSurfaceRef buffer, uint32_t options, uint32_t *seed);
extern "C" IOReturn IOSurfaceUnlock(IOSurfaceRef buffer, uint32_t options, uint32_t *seed);
extern "C" size_t IOSurfaceGetWidth(IOSurfaceRef buffer);
extern "C" size_t IOSurfaceGetHeight(IOSurfaceRef buffer);
extern "C" IOSurfaceRef IOSurfaceCreate(CFDictionaryRef properties);
extern "C" void *IOSurfaceGetBaseAddress(IOSurfaceRef buffer);
extern "C" size_t IOSurfaceGetBytesPerRow(IOSurfaceRef buffer);
extern const CFStringRef kIOSurfaceAllocSize;
extern const CFStringRef kIOSurfaceWidth;
extern const CFStringRef kIOSurfaceHeight;
extern const CFStringRef kIOSurfaceIsGlobal;
extern const CFStringRef kIOSurfaceBytesPerRow;
extern const CFStringRef kIOSurfaceBytesPerElement;
extern const CFStringRef kIOSurfacePixelFormat;
enum
{
    kIOSurfaceLockReadOnly  =0x00000001,
    kIOSurfaceLockAvoidSync =0x00000002
};
UIImage * screenshot(void);
UIImage * screenshot(){
    IOMobileFramebufferConnection connect;
    kern_return_t result;
CoreSurfaceBufferRef screenSurface = NULL;
    io_service_t framebufferService = IOServiceGetMatchingService(kIOMasterPortDefault,IOServiceMatching("AppleH1CLCD"));
if(!framebufferService)
        framebufferService = IOServiceGetMatchingService(kIOMasterPortDefault,IOServiceMatching("AppleM2CLCD"));
if(!framebufferService)
        framebufferService = IOServiceGetMatchingService(kIOMasterPortDefault,IOServiceMatching("AppleCLCD"));
result = IOMobileFramebufferOpen(framebufferService, mach_task_self(), 0, &connect);
result = IOMobileFramebufferGetLayerDefaultSurface(connect, 0, &screenSurface);
    uint32_t aseed;
    IOSurfaceLock((IOSurfaceRef)screenSurface, 0x00000001, &aseed);
    size_t width = IOSurfaceGetWidth((IOSurfaceRef)screenSurface);
    size_t height = IOSurfaceGetHeight((IOSurfaceRef)screenSurface);
    CFMutableDictionaryRef dict;
size_t pitch = width*4, size = width*height*4;
    int bPE=4;
    char pixelFormat[4] = {'A','R','G','B'};
    dict = CFDictionaryCreateMutable(kCFAllocatorDefault, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
    CFDictionarySetValue(dict, kIOSurfaceIsGlobal, kCFBooleanTrue);
    CFDictionarySetValue(dict, kIOSurfaceBytesPerRow, CFNumberCreate(kCFAllocatorDefault,kCFNumberSInt32Type, &pitch));
    CFDictionarySetValue(dict, kIOSurfaceBytesPerElement, CFNumberCreate(kCFAllocatorDefault,kCFNumberSInt32Type, &bPE));
    CFDictionarySetValue(dict, kIOSurfaceWidth, CFNumberCreate(kCFAllocatorDefault,kCFNumberSInt32Type, &width));
    CFDictionarySetValue(dict, kIOSurfaceHeight, CFNumberCreate(kCFAllocatorDefault,kCFNumberSInt32Type, &height));
    CFDictionarySetValue(dict, kIOSurfacePixelFormat, CFNumberCreate(kCFAllocatorDefault,kCFNumberSInt32Type, pixelFormat));
    CFDictionarySetValue(dict, kIOSurfaceAllocSize, CFNumberCreate(kCFAllocatorDefault,kCFNumberSInt32Type, &size));
    IOSurfaceRef destSurf = IOSurfaceCreate(dict);
    IOSurfaceAcceleratorRef outAcc;
    IOSurfaceAcceleratorCreate(NULL, 0, &outAcc);
    IOSurfaceAcceleratorTransferSurface(outAcc, (IOSurfaceRef)screenSurface, destSurf, dict,NULL);
    IOSurfaceUnlock((IOSurfaceRef)screenSurface, kIOSurfaceLockReadOnly, &aseed);
CFRelease(outAcc);
    CGDataProviderRef provider =  CGDataProviderCreateWithData(NULL,  IOSurfaceGetBaseAddress(destSurf), (width * height * 4), NULL);
    CGImageRef cgImage = CGImageCreate(width, height, 8,
8*4, IOSurfaceGetBytesPerRow(destSurf),
 CGColorSpaceCreateDeviceRGB(), kCGImageAlphaNoneSkipFirst |kCGBitmapByteOrder32Little,provider, NULL, YES, kCGRenderingIntentDefault);
    UIImage *image = [UIImage imageWithCGImage:cgImage];
    return image;
}

需要注意的是,第五种方法需要修改一下IOMobileFramebuffer的头文件。

typedef void * IOMobileFramebufferConnection;

In the reversed header, IOMobileFramebufferConnection is typedef’d to io_connect_t, which is typedef’d to io_object_t, which is mach_port_t, which is darwin_mach_port_t, which is darwin_mach_port_name_t, which is __darwin_natural_t, which is unsigned int! Int just happens to be pointer-sized on 32-bit, but is not under 64-bit。

——StackoverFlow

修改好的头文件顺便也丢上来吧,解压后放在Project的根目录下。

如果你使用的是theos的话,记得在Makefile里写上,

YOUR_TWEAK_NAME_PRIVATE_FRAMEWORKS = IOSurface IOKit IOMobileFramebuffer

YOUR_TWEAK_NAME_CFLAGS = -I./headers/ -I./headers/IOSurface

如 果是XCode上的Logos Tweak的话,在Build Settings -> Search Paths -> Header Search Paths里面添加一项:$(PROJECT_DIR)/YOUR_PROJECT_NAME/headers, 搜索方式为recursive. 最后在Build Phases里Link上IOSurface IOKit IOMobileFramebuffer这三个私有Framework。

实现AVPlayer的防录屏功能

实现AVPlayer的防录屏功能
From: http://huanhoo.net/2016/09/16/%E5%AE%9E%E7%8E%B0AVPlayer%E7%9A%84%E9%98%B2%E5%BD%95%E5%B1%8F%E5%8A%9F%E8%83%BD/

“AVPlayer”

前言

保护好第三方的版权是视频类公司要考虑的问题。如何防止用户通过录屏手段取得受版权保护的视频就是我们要讨论的内容。

常见录屏方法

  • 越狱
  • 私有Api
  • AirPlay
  • QuickTime

越狱

越狱后基本上想干啥都行了,小小的录屏功能更是不在话下。越狱市场上也有很多插件支持录屏的,随便搞来一个用就好。

阻止方法

想防越狱也比较简单,代码里可以判断当前设备是否越过狱,如果越狱了,就直接exit吧。判断越狱的方法在念茜的博客里有比较详细的介绍。

私有Api

以前有很多私有Api可以进行屏幕录制,都是通过截取一张张的图片来达到目的。但是随着SDK的升级,这些方法大多不管用了。目前能用的私有录屏Api只有一种,也就是IOSurface私有库。通过这个库可以在iOS9以下的设备上实现后台截屏功能,从而达到录屏效果。

阻止方法

从iOS9开始IOSurface库录屏的Api被去掉了,对于这种录屏手段我们的解决方法也比较简单粗暴,直接让自家的App从iOS9开始支持。除了这种方案没有其他的有效手段了。

AirPlay

通过AirPlay可以把当前屏幕投影到另外的屏幕上,比如投到PC上。那么PC上就有不少应用程序可以通过AirPlay录屏了。不光录屏,还可以进行视频剪辑,美化等一系列功能。

阻止方法

通过AirPlay不论是投到PC上,还是在本地起一个AirPlay的虚拟服务投到本地,通过[UIScreen screens].count取到的屏幕个数都会大于1,如果检测到屏幕个数大于1的话,可以直接exit或者暂停视频播放弹出提示。

QuickTime

我们今天着重讲这种录屏方式,这个比较王道了,苹果自家出的应用,也提供录屏功能,而且录出来的效果比其他方法的效果好很多。

阻止方法

QuickTime是通过抓取屏幕图像流来进行录屏的,它还有个优化体验的地方,就是如果当前屏幕中,有AVPlayer的话,它将和AVPlayer做同步,如果你播的是mp4文件,就直接拿AVPlayer中的mp4流。如果你使用HLS的方式,它将会读到你AVPlayer中下载下来的ts切片,拿到切片数据后进行播放录制。

听起来相当霸道了,然而苹果也给出了官方解答,简单解释一下就是通过给HLS流加密,可以达到防止录屏的效果,因为AVPlayer虽然可以拿到切片,但是没有key的话就无法对流进行解密。

HLS流是苹果自家的流媒体传输协议,具体的概念就不在此赘述了,网上资料很多。苹果的SDK中的AVPlayer对于HLS相关一系列的功能支持的都非常好,比如字幕,加解密这些功能。

HLS加密普遍是通过AES-128的方式给视频流加密,服务端和客户端都拿着同一个key,在m3u8文件中也会出现EXT-X-KEY字段:

EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52",IV=0x9c7db8778570d05c3177c349fd9236aa  
  • METHOD:加密方式
  • URI:解密key的url地址
  • IV:可以当做是加密用的盐值

当AVPlayer开始加载m3u8文件时,会异步取ts切片和key,key如果通过http请求拿的话就比较危险了,可以考虑https,或者客户端本地组装key,比如和服务端的同学约定好,通过视频ID,类型ID等参数拼接在一起,再经过一系列加密后的结果作为key。

如果通过客户端自己组装key,组装好后要塞给AVPlayer,之后AVPlayer在拿到ts切片后,会自己用key来解密。

如果想这么做的话,我们需要将EXT-X-KEY项改成下面的写法:

EXT-X-KEY:METHOD=AES-128,URI="CustomScheme://priv.example.com/key.php?r=52",IV=0x9c7db8778570d05c3177c349fd9236aa  

URI这一项的Scheme变成了CustomScheme,后边具体是什么地址都不重要了,填个假的也可以。

AVPlayer在加载m3u8文件时,如果遇到用户自定义的Scheme,会认为你需要自己加载资源文件,例如key或者是ts切片。从而进入如下回调:

- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest {  



    dispatch_async(dispatch_get_main_queue(), ^{  



        NSString *scheme = [[[loadingRequest request] URL] scheme];  



            NSData *data = [self getDecryptKey];  



            if (data) {  

                [loadingRequest.dataRequest respondWithData:data];  

                [loadingRequest finishLoading];  

            }  



    });  



    return YES;  

}  

在回调中将自己组装的key传给AVPlayer,通过getDecryptKey函数获得key之后,就把data值赋给loadingRequest.dataRequest,再调用[loadingRequest finishLoading],AVPlayer就收到key了,后续的ts切片就将利用这个key来进行解密。

EXT-X-KEY字段可以有很多,意味着不同的ts切片将使用不同的key来解密。

通过这种方式就可以防止QuickTime的录屏,可以看到苹果自己SDK的AVPlayer对于自己的协议支持的相当好,回调用起来也非常方便。

安卓同学如果想支持HLS解密的话比较麻烦,因为安卓官方的播放器没有加载资源的回调。只能通过本地起Server,把key放到本地Server中,再替换EXT-X-KEY中的uri值来进行实现。或者使用Google自家开源的更强大的播放器EXO来实现。

后记

HLS加密的方式还是比较普遍的,然而一直也没有相关文章指出这种方式和QuickTime的防录屏之间有什么关系。

有同学想问如果用RTMP或者其他方式播放视频可以做到防录屏吗?只要你脱离AVPlayer去播放视频,最终拿到的都是一帧帧的视频画面,然后渲染到屏幕上,这样的话AVPlayer都是可以拿到屏幕画面流的。

唯一的方式是将RTMP转成HLS后加密,再通过AVPlayer来播,如果不能这样,就无法做到防录屏。


OC Runtime 修改YYModel nil变空字符处理

YYModel 没有好的 如果变量为nil 替换一个默认变量,OC 中 NSString 经常容易nil 崩溃, 所以有了此次需求,
注: 有一定的性能损失, 但是无需求的话不实现替换方法,性能损失应该很小, 有需求,用性能换稳定应该也划算

NSObject+YYMolde.h 新增

1
2
3
4
5
6
/**
转换完成后执行, 老变量新变量的替换
(只遍历当前类, 忽略父类)
@return <#return value description#>
*/
- (id)oldValueToNewValue:(id)value classTypeName:(const char *)classTypeName;

.m 新增

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 解析Property的Attributed字符串,参考Stackoverflow
*/
static const char *getPropertyType(objc_property_t property) {
const char *attributes = property_getAttributes(property);
// NSLog(@"%s", attributes);
char buffer[1 + strlen(attributes)];
strcpy(buffer, attributes);
char *state = buffer, *attribute;
while ((attribute = strsep(&state, ",")) != NULL) {
// 非对象类型
if (attribute[0] == 'T' && attribute[1] != '@') {
// 利用NSData复制一份字符串
return (const char *) [[NSData dataWithBytes:(attribute + 1) length:strlen(attribute) - 1] bytes];
// 纯id类型
} else if (attribute[0] == 'T' && attribute[1] == '@' && strlen(attribute) == 2) {
return "id";
// 对象类型
} else if (attribute[0] == 'T' && attribute[1] == '@') {
return (const char *) [[NSData dataWithBytes:(attribute + 3) length:strlen(attribute) - 4] bytes];
}
}
return "";
}

检查函数, 在合适的位置调用(model 转换完成后调用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
- (void)checkObj:(NSObject *)obj{
if ([obj respondsToSelector:@selector(oldValueToNewValue:classTypeName:)]){
@try{
unsigned int outCount, i;
//objc_property_t 数组获取
objc_property_t *properties = class_copyPropertyList([obj class], &outCount);
for (i = 0;i<outCount; i++){
// 单一的 objc_property_t
objc_property_t property = properties[i];
// 变量名获取
NSString *propertyName = [NSString stringWithUTF8String:property_getName(property)];
// 获取类型名称
const char * propertyTypeName = getPropertyType(property);
// 获取 property 值
id propertyValue = [obj valueForKey:propertyName];
id newValue = [(id<YYModel>)obj oldValueToNewValue:propertyValue classTypeName:propertyTypeName];
if (newValue != propertyValue){
[obj setValue:newValue forKey:propertyName];
}
}
free(properties);
// 如需遍历父类, 但是不需要的
// NSObject * subObj = obj.superclass;
// if (subObj){
//// NSLog(@"currentObj %@, subObjClassName---%@",[obj classForCoder],[subObj classForCoder]);
// [self checkObj:subObj];
// }
}@catch(NSException *exp){
}
}
}

然后自己的实现model 中 实现, 其他类型判断都可行

1
2
3
4
5
6
7
8
9
- (id)oldValueToNewValue:(id)value classTypeName:(const char *)classTypeName{
const char * className = NSStringFromClass([NSString class]).UTF8String;
if (strncmp(className, classTypeName, strlen(className)) == 0){
if(value == nil || value == NULL){
return @"";
}
}
return value;
}

重点 - 解析property_getAttributes函数的结果
在整个处理过程中,property_getAttributes函数是关键,因为我们要首先确定Property的类型,才能根据类型赋初值,但是property_getAttributes函数返回的字符串比较“晦涩难懂”:

如下定义的Property:

1
2
3
4
5
6
@property (copy, nonatomic) NSString *name;
@property (strong, nonatomic) NSNumber *number;
@property (strong, nonatomic) NSArray *array;
@property (assign, nonatomic) NSInteger i;
@property (assign, nonatomic) CGFloat f;
@property (assign, nonatomic) char *cStr;

依次通过property_getAttributes获取的结果是:

1
2
3
4
5
6
T@"NSString",C,N,V_name
T@"NSNumber",&,N,V_number
T@"NSArray",&,N,V_array
Tq,N,V_i
Td,N,V_f
T*,N,V_cStr

参考 Declared Properties of Objective-C Runtime Programming Guide
我们大概可以知道,T表示Type,后面跟着@表示Cocoa对象类型,后面的表示Property的属性,如Copy、strong等,然后就是变量名。
所以getPropertyType函数的工作就是纯粹的解析字符串,获取T@后面的类型名。

参考

git 地址

iOS音频播放 (九):边播边缓存

From: http://msching.github.io/blog/2016/05/24/audio-in-ios-9/

好久没写过博客了,在这期间有很多同学通过博客评论、微博私信、邮件和我交流iOS音频方面的相关问题,其中被问到最多的是如何实现“边播边缓存”,这篇就来说一说这个话题。顺便一提,本文的题目虽然为“iOS音频播放”,但其中所涉及的部分技术方案在OSX平台或者在流播放视频下同样适用。


这类的技术方案其实有不少(其实在第一篇的末尾也略微有所涉及):

思路1. 最直接的方式,自行实现音频数据的请求在请求的过程中把数据缓存到磁盘,然后基于磁盘的数据自己实现解码、播放等功能;这个方法作为直接也最为复杂,开发者需要对音频播放的原理、操作系统等知识有一定程度的理解。如果能够实现这种方式所达到的效果也将会是最好的,整个过程都由开发者掌控,出现问题也可以对症下药。开源播放器FreeStreamer就是一个很好的例子,使用带有cache功能开源播放器或在其基础上进行二次开发也是不错的选择;

思路2. 请求拦截的方式,首先你需要一个能够进行流播放的播放器(如Apple提供的AVPlayer),通过拦截播放器发送的请求可以知道需要下载哪一段数据,于是就可以根据本地缓存文件的情况分段为播放器提供数据,如遇到已缓存的数据段直接从缓存中获取数据塞回给播放器,如遇到未缓存的数据段就发送请求获取数据,得到response和数据后保存到磁盘同时塞回给播放器。这种思路下有三个分支:

思路2.1 流播放器 + LocalServer,首先在搭建一个LocalServer(例如使用GCDWebServer),然后将URL组织成类似这种形式:

http://localhost:port?url=urlEncode(audioUrl)

把组织好的URL交给播放器播放,播放器把请求发送到LocalServer上,LocalServer解析到实际的音频地址后发送请求或者读取已缓存的数据。

思路2.2 流播放器 + NSURLProtocol,大家都知道NSURLProtocol可以拦截Cocoa平台下URL Loading System中的请求,如果播放器的请求是运行在URL Loading System下的话使用这个方法可以轻松的拦截到播放器所发送的请求然后自己再进行请求或者读取缓存数据。这里需要注意如果使用AVPlayer作为播放器的话这种方法只在模拟器上才work,真机上并不能拦截到任何请求。这也证明AVPlayer在真机上并没有运行在URL Loading System下,但模拟器上却是(不知道在OSX下是否能work,有兴趣的同学可以尝试一下)。

注:如果播放器使用的是CFNetwork,也可以尝试拦截,例如使用FB的fishhook,这hook方法应该会遇上不少坑,请做好心理准备。。

思路2.3 AVPlayer + AVAssetResourceLoader,AVAssetResourceLoader是iOS 6之后添加的类其主要作用是让开发者能够掌控AVURLAsset加载数据的整个过程。这正好符合我们的需求,AVAssetResourceLoader会通过delegate把AVAssetResourceLoadingRequest对象传递给开发者,开发者可以根据其中的一些属性得知需要加载的数据段。在得到数据后也可以通过AVAssetResourceLoadingRequest向AVPlayer传递response和数据。

思路3. 取巧的方式,自行实现音频数据的请求在请求的过程中把数据缓存到磁盘,然后使用系统提供的播放器(如AVAudioPlayer、AVPlayer进行播放)。这种实现方式中需要注意的是要播放的音频文件需要预先缓存一定量之后才能够播放,具体缓存多少完全频个人感觉,并且有可能会产生播放失败或者播放错误。这种方式的另一个缺点是无法进行任意的seek;


上面提到了3种思路共5个方案,那么在实际开发过程中开发者应该可以根据各个方案的优劣结合自己的实际情况选择最适合自己的方案。

思路1:优点在于整个播放过程可控,出现问题可调试,但开发复杂度较高,故选择有对应功能的开源播放器是一个比较好途径。在使用开源播放器之前最好能阅读其代码,掌握整个播放流程,出了问题才能迅速定位。推荐以播放为核心功能的app使用此方案;

思路2:优点在于开发者不必关心播放的整个过程,对音频播放的相关知识也不必有太多的了解,整个开发过程只要关心请求的解析、缓存数据的读取和保存以及数据的回填即可;至于缺点,首先你的有一个靠谱的流播放器,如果使用AVPlayer那么请做踩坑准备;

思路2.1:各类流播放器通吃,如果方案2.2和2.3不管用2.1是最好的选择;

思路2.2:需要播放器有指定的请求方式,如运行在URL Loading System下;

思路2.3:如果你用的就是AVPlayer那么可以尝试使用这个思路,但对于播放列表形式(M3U8)的音频这种方式是无效的;

思路3:如果你选择这条路,那说明你真的懒得不行。。。


一般音频流或者视频流都会支持HTTP协议中的Range request header,所以大多数的流播放器都会对Range header进行支持,在数据源支持Range的情况下拦截到请求时有必要注意播放器所请求的数据段并根据当前数据缓存的状态进行分段处理。

举个例子,播放器请求bytes=0-100,其中10-20、50-60已经被缓存,那么这个请求就应该被分为下面几段来处理:

  1. 0-10,网络请求
  2. 10-20,本地缓存
  3. 20-50,网络请求
  4. 50-60,本地缓存
  5. 60-100,网络请求

以上几段数据请求按顺序执行并进行数据回填,其中通过网络请求的数据在收到之后加入缓存以便下一次请求再次使用。另外要注意的是由于播放器本身只发送了一个请求所以response还是只有一个并且Content-Range还是应该为0-100/FileLength


AVPlayerCacheSupport是我使用AVAssetResourceLoader进行实践后实现的一个开源项目,在开发的过程中踩到的坑也在这里分享给大家。

shceme必须自定义

非自定义的URL Scheme不会触发AVAssetResourceLoader的delegate方法。这一点并不难发现,Stackoverflow上和github上都有提到这一点。所以在构造AVPlayItem时必须使用自定义Scheme的URL才行,这里我是在原有的Scheme后加上了-streaming,在收到AVAssetResourceLoader的回调之后实际发送请求时再把-streaming后缀去掉。

AVURLAsset.resourceLoader的delegate必须在AVPlayerItem生成前赋值

看代码感受一下吧,这样写能接到回调:

AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url] options:options];
[asset.resourceLoader setDelegate:self queue:dispatch_get_main_queue()];
AVPlayerItem *item = [self playerItemWithAsset:asset];

下面这种写法是无法接到回调的:

AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url] options:options];
AVPlayerItem *item = [self playerItemWithAsset:asset];
[[(AVURLAsset *)item.asset resourceLoader] setDelegate:self queue:dispatch_get_main_queue()];

不支持Playlist类型的播放

AVAssetResourceLoader不支持类似M3U和M3U8这类播放列表类型的流,这个问题的回答来自SO链接官方文档中关于HTTP Live Streaming的一段话也印证了这一点。

在搜索相关问题之前,我尝试了使用AVAssetResourceLoader去加载M3U8播放列表,其中M3U8文件可以获取到,但并非获取了之后直接存储就完事了,还需要进行一些处理:

M3U8中一般有两种类型的URL:相对地址的URL和绝对地址的URL,其中相对地址的URL不需要处理AVPlayer会根据原先的host(也就是带了-streaming后缀的host)进行请求,这样的请求还是会被AVAssetResourceLoader拦截到。而绝对地址的URL则需要对其中的scheme进行处理使其能够被AVAssetResourceLoader拦截。

处理完所有的URL以后才能把M3U8文件进行保存。

M3U8处理完成之后,就尝试处理其中的一些媒体文件地址,例如ts格式的视频,但经过尝试后发现这类ts的链接并不能被AVAssetResourceLoader拦截到,这才去搜索相关内容后找到了上述的SO链接和官方文档。

AVAssetResourceLoadingContentInformationRequest的contentLength和contentType

AVAssetResourceLoadingContentInformationRequestAVAssetResourceLoadingRequest的一个属性

/*! 
 @property       contentInformationRequest
 @abstract       An instance of AVAssetResourceLoadingContentInformationRequest that you should populate with information about the resource. The value of this property will be nil if no such information is being requested.
*/
@property (nonatomic, readonly, nullable) AVAssetResourceLoadingContentInformationRequest *contentInformationRequest NS_AVAILABLE(10_9, 7_0);

其作用是告诉AVPlayer当前加载的资源类型、文件大小等信息。

AVAssetResourceLoadingContentInformationRequest有这样一个属性:

/*! 
 @property       contentLength
 @abstract       Indicates the length of the requested resource, in bytes.
 @discussion Before you finish loading an AVAssetResourceLoadingRequest, if its contentInformationRequest is not nil, you should set the value of this property to the number of bytes contained by the requested resource.
*/
@property (nonatomic) long long contentLength;

乍看上去可以把当前所请求数据的Content-Length直接赋给这个属性,例如请求range=0-100的那么其Content-Length就是100。如果当前数据无缓存的话,就直接把NSURLResponseexpectedContentLength属性值赋值给了contentLength。

但经过实践发现上面的做法并不正确。对于支持Range的请求,如range=0-100,NSURLResponseexpectedContentLength属性值为100,但这里需要填入的是文件的总长。所以对于response header中包含Content-Range的请求,需要解析出其中的文件总长再赋值给AVAssetResourceLoadingContentInformationRequestcontentLength属性。

接下来是contentType

/*! 
 @property       contentType
 @abstract       A UTI that indicates the type of data contained by the requested resource.
 @discussion Before you finish loading an AVAssetResourceLoadingRequest, if its contentInformationRequest is not nil, you should set the value of this property to a UTI indicating the type of data contained by the requested resource.
*/
@property (nonatomic, copy, nullable) NSString *contentType;

这里的contentType是UTI,和NSURLResponseMIMEType并不相同。需要进行转换:

NSString *mimeType = [response MIMEType];
CFStringRef contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, (__bridge CFStringRef)(mimeType), NULL);
loadingRequest.contentInformationRequest.contentType = CFBridgingRelease(contentType);

要说的就这么多,希望能帮到大家 =)。

iOS音频篇:AVPlayer的缓存实现

From: https://www.jianshu.com/p/93ce1748ea57

在上一篇文章《使用AVPlayer播放网络音乐》介绍了AVPlayer的基本使用,下面介绍如何通过AVAssetResourceLoader实现AVPlayer的缓存

需求梳理

没有任何工具能适用于所有的场景,在使用AVPlayer的过程中,我们会发现它有很多局限性,比如播放网络音乐时,往往不能控制其内部播放逻辑,比如我们会发现播放时seek会失败,数据加载完毕后不能获取到数据文件进行其他操作,因此我们需要寻找弥补其不足之处的方法,这里我们选择了AVAssetResourceLoader。

AVAssetResourceLoader的作用:让我们自行掌握AVPlayer数据的加载,包括获取AVPlayer需要的数据的信息,以及可以决定传递多少数据给AVPlayer。

AVAssetResourceLoader在AVPlayer中的位置如下:*

Location.jpeg

实现核心

使用AVAssetResourceLoader需要实现AVAssetResourceLoaderDelegate的方法:

- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader 
shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest; 

要求加载资源的代理方法,这时我们需要保存loadingRequest并对其所指定的数据进行读取或下载操作,当数据读取或下载完成,我们可以对loadingRequest进行完成操作。

- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader 
didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest; 

取消加载资源的代理方法,这时我们需要取消loadingRequest所指定的数据的读取或下载操作。

实现策略

通过AVAssetResourceLoader实现缓存的策略有多种,没有绝对的优与劣,只要符合我们的实际需求就可以了。

下面我们以模仿企鹅音乐的来演示AVAssetResourceLoader实现缓存的过程为例子。

先观察并猜测企鹅音乐的缓存策略(当然它不是用AVPlayer播放):
  1、开始播放,同时开始下载完整的文件,当文件下载完成时,保存到缓存文件夹中;
  2、当seek时
   (1)如果seek到已下载到的部分,直接seek成功;(如下载进度60%,seek进度50%)
   (2)如果seek到未下载到的部分,则开始新的下载(如下载进度60%,seek进度70%)
      PS1:此时文件下载的范围是70%-100%
      PS2:之前已下载的部分就被删除了
      PS3:如果有别的seek操作则重复步骤2,如果此时再seek到进度40%,则会开始新的下载(范围40%-100%)
  3、当开始新的下载之后,由于文件不完整,下载完成之后不会保存到缓存文件夹中;
  4、下次再播放同一歌曲时,如果在缓存文件夹中存在,则直接播放缓存文件;

实现流程

流程示意图:

1、通过自定义scheme来创建avplayer,并给AVURLAsset指定代理(SUPlayer对象)
AVURLAsset * asset = [AVURLAsset URLAssetWithURL:[self.url customSchemeURL] options:nil];            
[asset.resourceLoader setDelegate:self.resourceLoader queue:dispatch_get_main_queue()];
self.currentItem = [AVPlayerItem playerItemWithAsset:asset];
self.player = [AVPlayer playerWithPlayerItem:self.currentItem];
2、代理实现AVAssetResourceLoader的代理方法(SUResourceLoader对象)
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest {
    [self addLoadingRequest:loadingRequest];
    return YES;
}

- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest {
    [self removeLoadingRequest:loadingRequest];
}
3、对loadingRequest的处理(addLoadingRequest方法)

(1)将其加入到requestList中

[self.requestList addObject:loadingRequest];

(2)如果还没开始下载,则开始请求数据,否则静待数据的下载

[self newTaskWithLoadingRequest:loadingRequest cache:YES];

(3)如果是seek之后的loadingRequest,判断请求开始的位置,如果已经缓冲到,则直接读取数据

if (loadingRequest.dataRequest.requestedOffset >= self.requestTask.requestOffset &&
    loadingRequest.dataRequest.requestedOffset <= self.requestTask.requestOffset + self.requestTask.cacheLength) {
    [self processRequestList];
}
3.4如果还没缓冲到,则重新请求
if (self.seekRequired) {
    [self newTaskWithLoadingRequest:loadingRequest cache:NO];
}
4、数据请求的处理(newTaskWithLoadingRequest方法)

(1)先判断是否已经有下载任务,如果有,则先取消该任务

if (self.requestTask) {
    fileLength = self.requestTask.fileLength;
    self.requestTask.cancel = YES;
}

(2)建立新的请求,设置代理

self.requestTask = [[SURequestTask alloc]init];
self.requestTask.requestURL = loadingRequest.request.URL;
self.requestTask.requestOffset = loadingRequest.dataRequest.requestedOffset;
self.requestTask.cache = cache;
if (fileLength > 0) {
    self.requestTask.fileLength = fileLength;
}
self.requestTask.delegate = self;
[self.requestTask start];
self.seekRequired = NO;
5、数据响应的处理(processRequestList方法)

  对requestList里面的loadingRequest填充响应数据,如果已完全响应,则将其从requestList中移除

- (void)processRequestList {
    NSMutableArray * finishRequestList = [NSMutableArray array];
    for (AVAssetResourceLoadingRequest * loadingRequest in self.requestList) {
        if ([self finishLoadingWithLoadingRequest:loadingRequest]) {
            [finishRequestList addObject:loadingRequest];
        }
    }
    [self.requestList removeObjectsInArray:finishRequestList];
}

  填充响应数据的过程如下:
(1)填写 contentInformationRequest的信息,注意contentLength需要填写下载的文件的总长度,contentType需要转换

CFStringRef contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, (__bridge CFStringRef)(MimeType), NULL);
loadingRequest.contentInformationRequest.contentType = CFBridgingRelease(contentType);
loadingRequest.contentInformationRequest.byteRangeAccessSupported = YES;
loadingRequest.contentInformationRequest.contentLength = self.requestTask.fileLength;

(2)计算可以响应的数据长度,注意数据读取的起始位置是当前avplayer当前播放的位置,结束位置是loadingRequest的结束位置或者目前文件下载到的位置

NSUInteger cacheLength = self.requestTask.cacheLength;
NSUInteger requestedOffset = loadingRequest.dataRequest.requestedOffset;
if (loadingRequest.dataRequest.currentOffset != 0) {
    requestedOffset = loadingRequest.dataRequest.currentOffset;
}
NSUInteger canReadLength = cacheLength - (requestedOffset - self.requestTask.requestOffset);
NSUInteger respondLength = MIN(canReadLength, loadingRequest.dataRequest.requestedLength);

(3)读取数据并填充到loadingRequest

[loadingRequest.dataRequest respondWithData:[SUFileHandle readTempFileDataWithOffset:requestedOffset - self.requestTask.requestOffset length:respondLength]];

(4) 如果完全响应了所需要的数据,则完成loadingRequest,注意判断的依据是 响应数据结束的位置 >= loadingRequest结束的位置

NSUInteger nowendOffset = requestedOffset + canReadLength;
NSUInteger reqEndOffset = loadingRequest.dataRequest.requestedOffset + loadingRequest.dataRequest.requestedLength;
if (nowendOffset >= reqEndOffset) {
    [loadingRequest finishLoading];
    return YES;
}
return NO;
6、处理requestList的时机

当有新的loadingRequest或者文件下载进度更新时,都需要处理requestList

7、新的请求任务实现的过程(SURequestTask对象)

(1)初始化时,需要删除旧的临时文件,并创建新的空白临时文件

- (instancetype)init {
    if (self = [super init]) {
        [SUFileHandle createTempFile];
    }
    return self;
}

(2)建立新的连接,如果是seek后的请求,则指定其请求内容的范围

- (void)start {
    NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:[self.requestURL originalSchemeURL] cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:RequestTimeout];
    if (self.requestOffset > 0) {
        [request addValue:[NSString stringWithFormat:@"bytes=%ld-%ld", self.requestOffset, self.fileLength - 1] forHTTPHeaderField:@"Range"];
    }
    self.session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:[NSOperationQueue mainQueue]];
    self.task = [self.session dataTaskWithRequest:request];
    [self.task resume];
}

(3)当收到数据时,将数据写入临时文件,更新下载进度,同时通知代理处理requestList

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
    if (self.cancel) return;
    [SUFileHandle writeTempFileData:data];
    self.cacheLength += data.length;
    if (self.delegate && [self.delegate respondsToSelector:@selector(requestTaskDidUpdateCache)]) {
        [self.delegate requestTaskDidUpdateCache];
    }
}

(4)当下载完成时,如果满足缓存的条件,则将临时文件拷贝到缓存文件夹中

if (self.cache) {
    [SUFileHandle cacheTempFileWithFileName:[NSString fileNameWithURL:self.requestURL]];
}
if (self.delegate && [self.delegate respondsToSelector:@selector(requestTaskDidFinishLoadingWithCache:)]) {
    [self.delegate requestTaskDidFinishLoadingWithCache:self.cache];
}

示例Demo

以上就是总体的实现流程,当然每个人的思路都不同,你可以在对其理解得足够深刻之后使用更高效更安全的方式去实现。

本文的demo在我的github上可以下载:GitHub : SUCacheLoader

本demo是以缓存豆瓣FM的歌曲(MP4格式)为例写的,如果你追求更完美的效果,可以从以下几方面入手:
  1、对缓存格式支持的处理:并不是所有文件格式都支持的哦,对于不支持的格式,你应该不使用缓存功能;
  2、对缓存过程中各种错误的处理:比如下载超时、连接失败、读取数据错误等等的处理;
  3、缓存文件的命名处理,如果缓存文件没有后缀(如.mp4),可能会导致播放失败;
  4、AVPlayer播放状态的处理,要做到完美的播放体验,在这方面要下点功夫;

Next:

接下来将带来AudioFileStream + AudioQueue 播放本地文件、网络文件、缓存实现的讲解