Sky-Weihao的博客
欢迎光临寒舍

客户端tcp通道防封禁笔记

最近做了一些关于防封禁和网络安全的一些事情,其中主要是关于tcp通道进行自定义协议收发的防封禁工作,在这段时间对防封禁这一块有更多一些的思考。同时通过跟我们公司中美的后台工程师的讨论,整个流程产生了很多有价值的想法和思考,在IT的各方面我感觉他们非常卓越,在防封禁方案讨论的过程中大部分的想法都是他们提出的建议,让我获益良多。在近期完成了客户端第一版防封禁功能的编码后来到美国出差,工作压力相对轻松,于是趁着这段时间尝试对近期工作内容产生的想法及方案的优缺点大概进行一个整理归纳。自感在这方面修炼还还差得很远,希望大家一起交流沟通。

背景

从事开发的很多技术人员都知道,网络稳定性和可用性对于每个应用来说都非常重要,应用中经常会遇到影响网络稳定性的很多不同条件,比如国家政策、运营商情况、目标恶意攻击等,可能也有很多人员在调查网络链接成功率下降或者面对用户反馈服务失败的时候彻夜不眠去定位问题,但是容易陷入到治标不治本的陷阱里面去。如果没有进行任何防封禁的工作,即使在理想情况下,HTTPS服务成功率也只有在98%~99%之间,如果是自定义的tcp协议通道成功率只会比这个数值要低,尤其是在一些有网络限制的国家或多运营商的国家下。

在该方案之前,进行过的防封禁策略就有很多,有关于HTTPS防封禁和tcp防封禁的,比如:
备用域名替换、http前置后置ip(http dns)、tcp/udp切换、端口切换、云服务转发等。这里只对HTTPS伪装方案进行了说明。

为了提升网络的稳定性与可用性,可以通过以下一些建议得到一些启发:

基于SSL/TLS的tcp通道伪装

首先,我们可以通过把自定义协议格式伪装起来,让他们看起来是正常的https协议,而不是私有协议格式。这是防封禁中最基础的一件事情,因为HTTPS是所有国家互联网中广泛应用。

关于SSL/TLS协议的更多定义,你们可以通过以下链接查阅:
RFC 5246
Traffic Analysis of an SSL/TLS Session

基本特性

伪装的主要目的是能够防止运营商通过一些应用交互协议行为特征对app进行封禁,假设你当前网络交互的消息是这样的(先不考虑安全性):
message = data length(4 bytes) + data
在这种情况下,它很容易被识别到有以下特征:

  1. 自定义协议
  2. 长度前面的bits大多都为0

在我们使用TLS ApplicationData的头对我们协议进行简单伪装之后,协议会变为这样:
message = TLS record(5 bytes) + counter(8bytes) + data length(4 bytes) + data
这时看起来有什么特别? 此时已经有一个TLS ApplicationData子协议的头及他的counter。但当你通过Wireshark观察这个协议,你仍然能通过协议的多个为0 的bits及明文的数据找到这条协议的特征。

因此下一步需要使该协议的数据部分进行随机化
TLS协议的初始作用是在TLS handshake交互中协商出能用于后续进行对称性加密的key,并在业务协议交互过程中使用该key对数据进行加密,因此,如果有通过正式TLS协商过程的协议会是这样
message = TLS record(5 bytes) + counter(8bytes) + encrypted(data length(4 bytes) + data)

这里有一个问题,有人会问到tls record 中后两个bytes是记录当前tls proto的length,那data length是否是可以不用?答案是:否,原因是由于tls一个message的长度只支持最长65535 bytes,在某些情况下自定义消息长度会大于该长度,若需要进行扩展则需要data length。另外一个case是在防封禁中,有时候需要把自定义协议打散为多条tls 协议发送(即使协议长度没到达65535),因此需要在客户端对数据进行拼接,所以data length字段在message的首个tls协议中必须是含有的。

到此为止,tcp消息进行tls伪装的基调已经能定下来了,其中两个要素

  1. 为TLS record 子协议类型的协议类型
  2. 业务协议的内容通过加密,其中的内容无法通过任何行为特征进行分析

使用TLS 1.1中TLS Resumption进行握手的特性

如果实现了上述的基础特性,可能已经能解决协议特征行为识别的问题,运营商或者封禁技术人员已经无法识别你的协议是否是私有协议,他们只能认为你发送的消息与所有的https消息一样。
但在tcp正式发送tls伪装的消息前与tcp 三次握手链接成功之间的时间段,因使用tls则引入了一些能进行识别的因素。正常使用HTTPS需要先通过SSL/TLS握手验证,简单概括TLS正常握手过程为协商双方使用配置、验证身份、基于rsa算法协商并生成对称性加密key。 如果在不进行TLS握手的情况下直接使用TLS Record的ApplicationData子协议,运营商能可能会发现你们应用ip的流量上有异常,然后通过该路径进行识别进行封禁。

如果使用常规的TLS handshake流程,需要两个rtt进行交互。

  1. Client Hello
  2. Server Hello, Certificate, Server Hello Done
  3. Client Key Exchanged, Client Changed Cipher Spec, Finished
  4. Server Changed Cipher Spec, Finished

因此可以使用比较新的TLS 1.1的特性TLS Resumption handshake来模拟这一步交互,降低TLS 伪装交互所需的耗时,TLS Resumption的流程如下:

  1. Client Hello
  2. Server Hello, Change Cipher Spec, Finished
  3. Client Changed Cipher Spec, Finished
    在Client Finished之后,我们就能发送业务协议了。

到此,已经又解决了一个问题,已经较完整把tcp通道伪装成了HTTPS协议的交互模式,而且能在可以暂时不考虑安全的情况下,通过TLS一些较新的特性降低TLS伪装的耗时。

关注更多TLS handshake交互过程中的细节

通过回顾SSL/TLS的定义,我们能发现使用TLS resumption伪装的过程中仍有非常多的细节没有处理,主要信息在Client Hello与Server Hello中,若tls handshake的过程需要自己去实现,这时整个协议细节都可以进行调整,其中配置大致包含:

  1. TLS record header
  2. 32 bytes random bytes
  3. Session ID
  4. Cipher Suite
  5. Compression Suite
  6. Server name indicator
  7. Signature algo
    其中recorder header、session ID、cipher suite、compression suite、signature algo正常情况都是用于客户端与服务器协议的作用,这方面在防封禁上意义没那么大,其中最重要的是server name indicator,若你一直使用应用本身注册的domain,极容易出现针对性封禁(即某个国家或者运营商故意对该应用进行检测封禁,无论是https或者是tcp,都通过强硬的手段恶意封禁)。在这种情况下,我们的https伪装可以伪装得更好去解决这个问题。我们通过动态配置填写SNI字段,比如填写baidu.com,然后其他的配置也根据模拟目标域名的https配置进行下发替换。

OK,这时又解决一个难题,显然这个时候运营商已经较难识别到你app发送出来的数据是否是真的还是假的,他会认为这时你发出来的数据是百度、谷歌等app发送出来,极难通过网络数据行为分析针对你的app进行封禁(当然仍有其他方法进行封禁,但是封禁成本都已经非常高了)。

总结一下上面的方法就是为https伪装,让你自定义的协议看上去是一个HTTPS链接交互,但里面传输的内容却是自定义协议:

  1. 伪装SSL/TLS握手流程
  2. 伪装数据包为TLS ApplicationData数据包
    上面两步的每一步都是可以进行很多变化,这些都基于你的app实际需要使用的情况,但是思想离不开这两步。例如,如果也希望在tls handshake过程中验证服务器可靠性、进行密钥协商,均可以在伪装的过程中也利用TLS原有的特性。数据包格式也可以进行各种优化,如统一协议头、可变长度、协议压缩等。

Security

在安全性方面,我们这边的方案大概使用了0-RTT Pre Shared Key的方式进行密钥协商。涉及到安全相关,对于使用方案相关的策略不方便太多展开描述,大致进行几个原则说明。

  1. 使用0-RTT 的策略主要是考虑在TLS伪装之后,需要尽快进行业务协议交互,能直接使用AES方式进行对称性加密及业务数据传输,降低TLS伪装带来的额外损耗。
  2. 能覆盖使用TLS resumption策略从而没有对服务器进行身份校验的问题
  3. 在上述基础上需增加一些额外的安全策略

其他详细的扩展阅读可以看微信的文章,描述ECDH与PSK协商策略非常详细。
基于TLS1.3的微信安全通信协议mmtls介绍

项目中使用sqlite数据库使用异常情况总结

闲聊:好久没有更新博客啦,近期由于工作上的一些原因,相对来说比之前要有空一点,在逐步整理一下之前在做的一些事情,可是之前做的事情也没有进行太详细严谨的记录,包括怎样重新使用hexo更新文章到blog,还有markdown的语法都已经确实忘记得差不多了,只能逐渐恢复下先更新一点,不过文章先更新上来,文章的质量还要等我熟悉blog这块怎样更新要持续进行优化,感谢大家观看。
项目中使用了fmdb作为sqlite数据库上层封装控件,简单总结一下此前进行数据库数据异常丢失,数据库访问失败的原因情况:
如果在进行sql操作失败时有打印相关日志,会看到这条输出:“操作数据库失败! error:Error Domain=FMDatabase Code=11 “database disk image is malformed” UserInfo={NSLocalizedDescription=database disk image is malformed}”
当有这句输出时,sqlite数据库中的结构已经是损坏了,当然有可能是master表有损坏,也有可能只是其中一部分数据表有损坏。
以下来分析一下与数据库配置属性有关的一些参数,如果在数据库使用时没有设置好这些参数,会极大提升数据库损坏率。

1、设置了PRAGMA journal_mode = OFF

OFF日志模式让SQLite放弃在开始时创建回滚日志,它会禁用SQLite的原子提交和回滚功能,让ROLLBACK命令不可用。如果使用OFF日志模式的事务在中间某时刻发生崩溃或断电,则数据库文件不能恢复,可能会被损坏。

日志模式

SQLite中日志模式主要有DELETE和WAL两种,其他几种比如TRUNCATE,PERSIST,MEMORY基本原理都与DELETE模式相同,不作详细展开。DELETE模式采用影子分页技术(Shadow paging),DELETE模式下,日志中记录的变更前数据页内容;WAL模式下,日志中记录的是变更后的数据页内容。事务提交时,DELETE模式将日志刷盘,将DB文件刷盘,成功后,再将日志文件清理;WAL模式则是将日志文件刷盘,即可完成提交过程。那么WAL模式下,数据文件何时更新呢?这里引入了检查点概念,检查点的作用就是定期将日志中的新页覆盖DB文件中的老页,并通过参数wal_autocheckpoint来控制检查点时机,达到权衡读写的目的。
DELETE模式下,写事务直接更新db-page,并将old-page写入日志,读事务则直接读db-page,因为db-page中保存了提交的所有事务的更新。事务提交后,直接将日志文件删除;若事务需要回滚,则将日志中old-page中的内容覆盖db-page,恢复原始内容。WAL模式下,写事务将更新写到日志文件中,不更新db-page,事务提交时,也不影响db-page,只是将日志持久化而已。若事务回滚,则不将日志写入文件即可。由于最新的数据在日志文件中,那么如何读取到最新的数据呢?WAL模式通过end-mark(事务提交位点)达到这一目的。具体而已,事务开始时,会首先扫描日志文件,获取最近一个end-mark,在读取数据时,首先会判断page是否在wal日志文件中存在,因为同一个page,一定是wal文件中的比db文件中的要新。如果存在,则使用,否则,再从db文件中获取指定的page。从流程上来看,这个过程比较慢,因为极端情况下,每次读都需要扫描wal文件和db文件。为了提高性能,WAL模式中有一个wal-index文件,这个文件记录了页号和该页在WAL文件中的偏移,并且wal-index文件采用共享缓存实现,从文件名也可以看到,后缀是.shm,因此判断page是否在wal文件存在的操作实质是一次内存读。wal-index采用hash表存储,因此查询效率也非常高。与传统的DBMS不同,SQLite中记录的日志,实质是dirty-page,重做实质是对利用WAL中的日志页覆盖db-page,这种实现方式比较简单,同时也比较浪费空间,因为一个page是1k,即使只更新1byte,也会导致日志记录1k。
我们可以在数据库建立时就改变日志模式,但改变为wal模式后,该模式是不可逆的,而除了wal模式外其他模式可以进行变更。
WAL日志模式优点:
1) 读写可以并发,不会阻塞
2)只有一个WAL文件,性能优势明显。

2、PRAGMA synchronous = OFF

The sync operations that SQLite performs to help ensure integrity can be disabled at run-time using the synchronous pragma. By setting PRAGMA synchronous=OFF, all sync operations are omitted. This makes SQLite seem to run faster, but it also allows the operating system to freely reorder writes, which could result in database corruption if a power failure or hard reset occurs prior to all content reaching persistent storage.
For maximum reliability and for robustness against database corruption, SQLite should always be run with its default synchronous setting of FULL.

常见使用sqlite进行的默认属性配置

  1. PRAGMA locking_mode=NORMAL //与事务读写时锁持有时常有关
  2. PRAGMA synchronous=FULL //是否每次写事务都同步到磁盘中
  3. PRAGMA journal_mode=DELETE //数据库模式
  4. PRAGMA fullfsync=NO //是否按写入内存的顺序写入磁盘空间

推荐使用以下sqlite的配置:

PRAGMA journal_mode = WAL
PRAGMA synchronous = NORMAL
PRAGMA locking_mode=EXCLUSIVE
同时附上了微信WCDB(也是建立在sqlite上)对数据库属性的默认配置进行参考,其也是开启了wal模式,并且没有对数据库读写synchronous设置为FULL。
/**
brief Default config name.
The default config for WCDB is :

  1. PRAGMA locking_mode=NORMAL
  2. PRAGMA synchronous=NORMAL
  3. PRAGMA journal_mode=WAL
  4. PRAGMA fullfsync=ON
    Setting config for this name will overwrite the default config.
    return default config name
    */

但无论在任何情况及设计任何配置的情况下,数据库文件总会在某在异常情况下崩溃并且造成永久性损坏,这时候就需要我们引入一些数据库修复策略。

数据库修复策略(目前已知在app里内无法使用.dump命令到sql语句)

  1. 使用sqlite3_backup_init、sqlite3_backup_step、sqlite3_backup_finish方法进行数据库文件备份(目前已经实现)
    当发现数据库损坏时,使用相对应方法使用备份文件进行修复,使用双重备份提高成功率
    优点:
  • sqlite3库自带方法,使用比较方便
  • sqlite网上可查找相关的备份方法说明文档
  • 恢复快
    缺点:
  • 未知修复成功率能达到多少
  • 压缩后没有dump方法的数据量少 不适合db损坏率较低的场景
  • 备份耗时比较长
  • 无法解决用户数据库已损坏情况的恢复
    难度:
    实现难度低,fmdb新版本中有支持。
  1. 自己实现dump方法
    优点:
  • 容量小、备份耗时中等
  • 能针对数据库非master表部分损坏的情况进行修复
    缺点:
  • 实现dump的相关执行语句需要自己实现
  • 恢复慢
    难度:
    中等
  1. 使用微信wcdb repairKit方法进行数据库备份和恢复。大概逻辑为进行Master表备份,损坏时读取Master表记录的各业务表的根节点,遍历BTree读出各表各行数据,重建数据库。(微信的修复方案)
    详情可看微信数据库技术文章
    优点:
  • 微信验证修复成功率达90%以上
  • 能进行压缩,压缩比较高
  • 支持加解密
  • 支持ORM,比fmdb读写速度快
    缺点:
  • 需要读懂分析微信wcdb中的代码或者把项目接入从fmdb的使用迁移wcdb到项目中,并去除项目中fmdb的引用。
    难度:
    项目迁移成本较大

参考文章:
https://blog.csdn.net/wql2rainbow/article/details/73650056?utm_source=blogxgwz20
https://www.sqlite.org/wal.html
https://blog.csdn.net/chinaclock/article/details/48622243
http://garyliutw.blogspot.com/2013/07/sqlite_24.html
http://www.runoob.com/sqlite/sqlite-pragma.html

iOS音视频实现边下载边播放

近段时间制作视频播放社区的功能,期间查找了不少资料,做过很多尝试,现在来整理一下其中遇到的一些坑.由于考虑到AVPlayer对视频有更高自由度的控制,而且能够使用它自定义视频播放界面,iOS中所使用的视频播放控件为AVPlayer,而抛弃了高层次的MediaPlayer框架,现在想想挺庆幸当初使用了AVPlayer。

AVPlayer的基本知识

AVPlayer本身并不能显示视频,而且它也不像MPMoviePlayerController有一个view属性。如果AVPlayer要显示必须创建一个播放器层AVPlayerLayer用于展示,播放器层继承于CALayer,有了AVPlayerLayer之添加到控制器视图的layer中即可。要使用AVPlayer首先了解一下几个常用的类:

AVAsset:主要用于获取多媒体信息,是一个抽象类,不能直接使用。

AVURLAsset:AVAsset的子类,可以根据一个URL路径创建一个包含媒体信息的AVURLAsset对象。

AVPlayerItem:一个媒体资源管理对象,管理者视频的一些基本信息和状态,一个AVPlayerItem对应着一个视频资源。

iOS视频实现边下载边播放的几种实现

1.本地实现http server

在iOS本地开启Local Server服务,然后使用播放控件请求本地Local Server服务,本地的服务再不断请求视频地址获取视频流,本地服务请求的过程中把视频缓存到本地,这种方法在网上有很多例子,有兴趣了解的人可自己下载例子查看。

2.使用AVPlayer的方法开启下载服务

1
2
3
4
1.AVURLAsset *urlAsset = [[AVURLAsset alloc]initWithURL:url options:nil];
2.AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:urlAsset];
3.[self.avPlayer replaceCurrentItemWithPlayerItem:item];
4.[self addObserverToPlayerItem:item];

但由于AVPlayer是没有提供方法给我们直接获取它下载下来的数据,所以我们只能在视频下载完之后自己去寻找缓存视频数据的办法,AVFoundation框架中有一种从多媒体信息类AVAsset中提取视频数据的类AVMutableComposition和AVAssetExportSession。
其中AVMutableComposition的作用是能够从现有的asset实例中创建出一个新的AVComposition(它也是AVAsset的字类),使用者能够从别的asset中提取他们的音频轨道或视频轨道,并且把它们添加到新建的Composition中。
AVAssetExportSession的作用是把现有的自己创建的asset输出到本地文件中。
为什么需要把原先的AVAsset(AVURLAsset)实现的数据提取出来后拼接成另一个AVAsset(AVComposition)的数据后输出呢,由于通过网络url下载下来的视频没有保存视频的原始数据(或者苹果没有暴露接口给我们获取),下载后播放的avasset不能使用AVAssetExportSession输出到本地文件,要曲线地把下载下来的视频通过重构成另外一个AVAsset实例才能输出。代码例子如下:

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
NSString *documentDirectory = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
NSString *myPathDocument = [documentDirectory stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.mp4",[_source.videoUrl MD5]]];


NSURL *fileUrl = [NSURL fileURLWithPath:myPathDocument];

if (asset != nil) {
AVMutableComposition *mixComposition = [[AVMutableComposition alloc]init];
AVMutableCompositionTrack *firstTrack = [mixComposition addMutableTrackWithMediaType:AVMediaTypeVideo preferredTrackID:kCMPersistentTrackID_Invalid];
[firstTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, asset.duration) ofTrack:[[asset tracksWithMediaType:AVMediaTypeVideo]objectAtIndex:0] atTime:kCMTimeZero error:nil];

AVMutableCompositionTrack *audioTrack = [mixComposition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
[audioTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, asset.duration) ofTrack:[[asset tracksWithMediaType:AVMediaTypeAudio]objectAtIndex:0] atTime:kCMTimeZero error:nil];

AVAssetExportSession *exporter = [[AVAssetExportSession alloc]initWithAsset:mixComposition presetName:AVAssetExportPresetHighestQuality];
exporter.outputURL = fileUrl;
if (exporter.supportedFileTypes) {
exporter.outputFileType = [exporter.supportedFileTypes objectAtIndex:0] ;
exporter.shouldOptimizeForNetworkUse = YES;
[exporter exportAsynchronouslyWithCompletionHandler:^{

}];

}
}

3.使用AVAssetResourceLoader回调下载,也是最终决定使用的技术

AVAssetResourceLoader通过你提供的委托对象去调节AVURLAsset所需要的加载资源。而很重要的一点是,AVAssetResourceLoader仅在AVURLAsset不知道如何去加载这个URL资源时才会被调用,就是说你提供的委托对象在AVURLAsset不知道如何加载资源时才会得到调用。所以我们又要通过一些方法来曲线解决这个问题,把我们目标视频URL地址的scheme替换为系统不能识别的scheme,然后在我们调用网络请求去处理这个URL时把scheme切换为原来的scheme。

实现边下边播功能AVResourceLoader的委托对象必须要实现AVAssetResourceLoaderDelegate下五个协议的其中两个:

1
2
3
4
1//在系统不知道如何处理URLAsset资源时回调
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest NS_AVAILABLE(10_9, 6_0);
2//在取消加载资源后回调
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest NS_AVAILABLE(10_9, 7_0);

以下来说说具体要怎么做处理

第一步,创建一个AVURLAsset,并且用它来初始化一个AVPlayerItem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define kCustomVideoScheme @"yourScheme"
NSURL *currentURL = [NSURL URLWithString:@"http://***.***.***"];
NSURLComponents *components = [[NSURLComponents alloc]initWithURL:currentURL resolvingAgainstBaseURL:NO];
1////注意,不加这一句不能执行到回调操作
components.scheme = kCustomVideoScheme;
AVURLAsset *urlAsset = [AVURLAsset URLAssetWithURL:components.URL
options:nil];
2//_resourceManager在接下来讲述
[urlAsset.resourceLoader setDelegate:_resourceManager queue:dispatch_get_main_queue()];
AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:urlAsset];
_playerItem = item;

if (IOS9_OR_LATER) {
item.canUseNetworkResourcesForLiveStreamingWhilePaused = YES;
}
[self.avPlayer replaceCurrentItemWithPlayerItem:item];
self.playerLayer.player = self.avPlayer;
[self addObserverToPlayerItem:item];**

第二步,创建AVResourceManager实现AVResourceLoader协议

1
@interface AVAResourceLoaderManager : NSObject < AVAssetResourceLoaderDelegate >

第三步,实现两个必须的回调协议,实现中有几件需要做的事情

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest
{
1//获取系统中不能处理的URL
NSURL *resourceURL = [loadingRequest.request URL];
2//判断这个URL是否遵守URL规范和其是否是我们所设定的URL
if ([self checkIsLegalURL:resourceURL] && [resourceURL.scheme isEqualToString:kCustomVideoScheme]){
3//判断当前的URL网络请求是否已经被加载过了,如果缓存中里面有URL对应的网络加载器(自己封装,也可以直接使用NSURLRequest),则取出来添加请求,每一个URL对应一个网络加载器,loader的实现接下来会说明
AVResourceLoaderForASI *loader = [self asiresourceLoaderForRequest:loadingRequest];
if (loader == nil){
loader = [[AVResourceLoaderForASI alloc] initWithResourceURL:resourceURL];
loader.delegate = self;
4//缓存网络加载器
[self.resourceLoaders setObject:loader forKey:[self keyForResourceLoaderWithURL:resourceURL]];
}
5//加载器添加请求
[loader addRequest:loadingRequest];
6//返回YES则表明使用我们的代码对AVAsset中请求网络资源做处理
return YES;
}else{
return NO;
}

}
1
2
3
4
5
6
7
8
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest
{
//如果用户在下载的过程中调用者取消了获取视频,则从缓存中取消这个请求
NSURL *resourceURL = [loadingRequest.request URL];
NSString *actualURLString = [self actualURLStringWithURL:resourceURL];
AVResourceLoaderForASI *loader = [_resourceLoaders objectForKey:actualURLString];
[loader removeRequest:loadingRequest];
}

第四步,判断缓存中是否已下载完视频

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
- (void)addRequest:(AVAssetResourceLoadingRequest *)loadingRequest
{
//1判断自身是否已经取消加载
if(self.isCancelled==NO){
//2判断本地中是否已经有文件的缓存,如果有,则直接从缓存中读取数据,文件保存和读取这里不做详述,使用者可根据自身情况创建文件系统
AVAResourceFile *resourceFile = [self.resourceFileManager resourceFileWithURL:self.resourceURL];
if (resourceFile) {
//3若本地文件存在,则从文件中获取以下属性
loadingRequest.contentInformationRequest.byteRangeAccessSupported = YES;
//3.1contentType
loadingRequest.contentInformationRequest.contentType = resourceFile.contentType;
//3.2数据长度
loadingRequest.contentInformationRequest.contentLength = resourceFile.contentLength;
//3.3请求的偏移量
long long requestedOffset = loadingRequest.dataRequest.requestedOffset;
//3.4请求总长度
NSInteger requestedLength = loadingRequest.dataRequest.requestedLength;
//3.5取出本地文件中从偏移量到请求长度的数据
NSData *subData = [resourceFile.data subdataWithRange:NSMakeRange(@(requestedOffset).unsignedIntegerValue, requestedLength)];
//3.6返回数据给请求
[loadingRequest.dataRequest respondWithData:subData];
[loadingRequest finishLoading];
}else{
//4如果没有本地文件,则开启网络请求,从网络中获取 ,见第五步
[self startWithRequest:loadingRequest];
}
}
else{
//5如果已经取消请求,并且请求没有完成,则封装错误给请求,可自己实现
if(loadingRequest.isFinished==NO){
[loadingRequest finishLoadingWithError:[self loaderCancelledError]];
}
}
}

第五步,添加loadingRequest到网络文件加载器,这部分的操作比较长

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
- (void)startWithRequest:(AVAssetResourceLoadingRequest *)loadingRequest
{
1//判断当前请求是否已经开启,由于苹果系统原因,会有两次回调到AVResourceLoaderDelegate,我们对其进行判断,只开启一次请求
if (self.dataTask == nil){
2//根据loadingRequest中的URL创建NSURLRequest,注意在此把URL中的scheme修改为原先的scheme
NSURLRequest *request = [self requestWithLoadingRequest:loadingRequest];
__weak __typeof(self)weakSelf = self;
3//获取url的绝对路径,并使用ASIHttpRequest进行网络请求,下面的请求方法经过封装,就不详说如何对ASI进行封装了,但是每一步需要做的事情能以block的形式更好说明
NSString *urlString = request.URL.absoluteString;
self.dataTask = [self GET:urlString requestBlock:^(Request *req) {
NSLog(@"### %s %@ ###", __func__, req);
4//在接受到请求头部信息时,说明链接成功,数据开始传输
if (req.recvingHeader//意思是请求接受到头部信息状态){
NSLog(@"### %s recvingHeader ###", __func__);
__strong __typeof(weakSelf)strongSelf = weakSelf;
if ([urlString isEqualToString:req.originalURL.absoluteString]) {
4.1//,创建临时数据保存网络下载下来的视频信息
strongSelf.tempData = [NSMutableData data];
}
4.2//把头部信息内容写入到AVAssetResourceLoadingRequest,即loadingRequest中
[strongSelf processPendingRequests];
}
else if (req.recving//请求接受中状态){
NSLog(@"### %s recving ###", __func__);
__strong __typeof(weakSelf)strongSelf = weakSelf;
5//此处需多次调用把请求的信息写入到loadingRequest的步骤,实现下载的过程中数据能输出到loadingRequest播放
if (urlString == req.originalURL.absoluteString) {
5.1//这个处理是判断此时返回的头部信息是重定向还是实际视频的头部信息,如果是重定向信息,则不作处理
if (!_contentInformation && req.responseHeaders) {
if ([req.responseHeaders objectForKey:@"Location"] ) {
NSLog(@" ### %s redirection URL ###", __func__);
}else{
//5.2如果不是重定向信息,则把需要用到的信息提取出来
_contentInformation = [[RLContentInformationForASI alloc]init];
long long numer = [[req.responseHeaders objectForKey:@"Content-Length"]longLongValue];
_contentInformation.contentLength = numer;
_contentInformation.byteRangeAccessSupported = YES;
_contentInformation.contentType = [req.responseHeaders objectForKey:@"Content-type"];
}
}

//5.3开始从请求中获取返回数据
NSLog(@"### %s before tempData length = %lu ###", __FUNCTION__, (unsigned long)self.tempData.length);
strongSelf.tempData = [NSMutableData dataWithData:req.rawResponseData];
NSLog(@"### %s after tempData length = %lu ###",__FUNCTION__, (unsigned long)self.tempData.length);
//5.4把返回数据输出到loadingRequest中
[strongSelf processPendingRequests];
}
}else if (req.succeed){
6//请求返回成功,在这里做最后一次把数据输出到loadingRequest,且做一些成功后的事情
NSLog(@"### %s succeed ###", __func__);
NSLog(@"### %s tempData length = %lu ###", __func__, (unsigned long)self.tempData.length);
__strong __typeof(weakSelf)strongSelf = weakSelf;
if (strongSelf) {
[strongSelf processPendingRequests];

7//保存缓存文件,我在保存文件这里做了一次偷懒,如果有人参考我写的文件可对保存文件作改进,在每次返回数据时把数据追加写到文件,而不是下载成功之后才保存,这请求时也可以使用这个来实现断点重输的功能
AVAResourceFile *resourceFile = [[AVAResourceFile alloc]initWithContentType:strongSelf.contentInformation.contentType date:strongSelf.tempData];
[strongSelf.resourceFileManager saveResourceFile:resourceFile withURL:self.resourceURL];
8//在此做一些清理缓存、释放对象和回调到上层的操作
[strongSelf complete];
if (strongSelf.delegate && [strongSelf.delegate respondsToSelector:@selector(resourceLoader:didLoadResource:)]) {
[strongSelf.delegate resourceLoader:strongSelf didLoadResource:strongSelf.resourceURL];
}
}
}else if (req.failed){
//9如果请求返回失败,则向上层抛出错误,且清理缓存等操作
NSLog(@"### %s failed ###" , __func__);
[self completeWithError:req.error];
}
}];
}
[self.pendingRequests addObject:loadingRequest];
}

第六步,把请求返回数据输出到loadingRequest的操作

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
- (void)processPendingRequests
{
__weak __typeof(self)weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
__strong __typeof(weakSelf)strongSelf = weakSelf;
NSMutableArray *requestsCompleted = [NSMutableArray array];
1//从缓存信息中找出当前正在请求中的loadingRequest
for (AVAssetResourceLoadingRequest *loadingRequest in strongSelf.pendingRequests){
2//把头部信息输出到loadingRequest中
[strongSelf fillInContentInformation:loadingRequest.contentInformationRequest];
3//把视频数据输出到loadingRequest中
BOOL didRespondCompletely = [strongSelf respondWithDataForRequest:loadingRequest.dataRequest];
4//在success状态中做最后一次调用的时候,检测到请求已经完成,则从缓存信息中清除loadingRequest,并且把loadingRequest标志为完成处理状态
if (didRespondCompletely){
[requestsCompleted addObject:loadingRequest];
[loadingRequest finishLoading];
}
}
5//清理缓存
[strongSelf.pendingRequests removeObjectsInArray:requestsCompleted];
});
}


//把提取出来的头部信息输出到loadingRequest中,可以优化
- (void)fillInContentInformation:(AVAssetResourceLoadingContentInformationRequest *)contentInformationRequest
{
if (contentInformationRequest == nil || self.contentInformation == nil){
return;
}
contentInformationRequest.byteRangeAccessSupported = self.contentInformation.byteRangeAccessSupported;
contentInformationRequest.contentType = self.contentInformation.contentType;
contentInformationRequest.contentLength = self.contentInformation.contentLength;
}

//把缓存数据输出到loadingRequest中
- (BOOL)respondWithDataForRequest:(AVAssetResourceLoadingDataRequest *)dataRequest
{
long long startOffset = dataRequest.requestedOffset;
if (dataRequest.currentOffset != 0){
startOffset = dataRequest.currentOffset;
}

// Don't have any data at all for this request
if (self.tempData.length < startOffset){
return NO;
}

// This is the total data we have from startOffset to whatever has been downloaded so far
NSUInteger unreadBytes = self.tempData.length - (NSUInteger)startOffset;

// Respond with whatever is available if we can't satisfy the request fully yet
NSUInteger numberOfBytesToRespondWith = MIN((NSUInteger)dataRequest.requestedLength, unreadBytes);

[dataRequest respondWithData:[self.tempData subdataWithRange:NSMakeRange((NSUInteger)startOffset, numberOfBytesToRespondWith)]];

long long endOffset = startOffset + dataRequest.requestedLength;
BOOL didRespondFully = self.tempData.length >= endOffset;

return didRespondFully;
}

视频边下边播的流程大致上已经描述完毕,本博文中没有说到的代码有错误处理方式、缓存文件的读写和保存格式、部分内存缓存使用说明、

参考链接:
http://www.codeproject.com/Articles/875105/Audio-streaming-and-caching-in-iOS-using
http://www.cnblogs.com/kenshincui/p/4186022.html#mpMoviePlayerController

补充:
在开发过程中遇到的一些坑在这里补充一下
1.在iOS9后,AVPlayer的replaceCurrentItemWithPlayerItem方法在切换视频时底层会调用信号量等待然后导致当前线程卡顿,如果在UITableViewCell中切换视频播放使用这个方法,会导致当前线程冻结几秒钟。遇到这个坑还真不好在系统层面对它做什么,后来找到的解决方法是在每次需要切换视频时,需重新创建AVPlayer和AVPlayerItem。
2.iOS9后,AVFoundation框架还做了几点修改,如果需要切换视频播放的时间,或需要控制视频从头播放调用seekToDate方法,需要保持视频的播放rate大于0才能修改,还有canUseNetworkResourcesForLiveStreamingWhilePaused这个属性,在iOS9前默认为YES,之后默认为NO。
3.AVPlayer的replaceCurrentItemWithPlayerItem方法正常是会引用住参数AVPlayerItem的,但在某些情况下导致视频播放失败,它会马上释放对这个对象的持有,假如你对AVPlayerItem的实例对象添加了监听,但是自己没有对item的计数进行管理,不知道什么时候释放这个监听,则会导致程序崩溃。
4.为什么我选择第三种方法实现边下边播,第一种方法需要程序引入LocalServer库,需增加大量app包大小,且需要开启本地服务,从性能方面考虑也是不合适。第二种方式存在的缺陷很多,一来只能播放网络上返回格式contentType为public/mpeg4等视频格式的url视频地址,若保存下来之后,文件的格式也需要保存为.mp4或.mov等格式的本地文件才能从本地中读取,三来使用AVMutableComposition对视频进行重构后保存,经过检验会对视频源数据产生变化,对于程序开发人员来说,需要保证各端存在的视频数据一致。第三种边下边播的方法其实是对第二种方法的扩展,能够解决上面所说的三种问题,可操控的自由度更高。

总结一些有用的iOS工具

《Which Clang Warning Is Generating This Message》:Clang 的警告与语义对照表。通过它可以更加方便地知道 Clang 的警告所表达的意思。

SwiftDoc:SwiftDoc是一个在线的Swift帮助文档。

RAC函数使用交互图:列出Reactive Cocoa中常用函数的交互图

OSStatus 苹果底层错误码原因查询

IDA 支持Win、Linux、Mac OS的多平台反汇编器,有免费试用版

Reveal 能分析app UI布局,有点类似苹果自带的布局分析工具,但这个工具能够使用在逆向工程中帮助你分析界面层次,官网没有提供免费的版本,需自行寻找破解版本

iFunBox iOS文件管理工具,免费工具

Charles 网络抓包分析修改工具,可免费可收费

Navicat Premium 数据库管理工具,官网收费,自行寻找破解版

Dash 文档管理工具,免费,可下载所有语言文档

Source Tree git管理工具,免费

astah community 绘制UML图

[class-dump] iOS逆向中用于导出Mach-O文件的头部信息

持续更新…

Dynamic Library Usage Guidelines

间接寻址是一种代码生成技术的名字,它允许被定义在文件中的symbols能够被另外一个文件引用,并且不需要引用文件知道任何有关定义这些symbol文件的布局。因此,定义文件能够被独立地修改。间接寻址最小化了动态链接器需要修改的地址,这回提高代码分享和提高效率......

Overview of Dynamic Libraries

决定app性能的两个重要因素是他们的启动时间和他们的内存跟踪。当程序运行时减少一个app的执行的文件的大小和最少化它内存的使用率会使app更快地启动并且使用更少的内存。使用动态库来代替静态库回减少一个app执行文件的大小。他们同样也能让app去在加载库被需要时才加载库的特定功能,而不是在他们启动的时候就被加载。这个特性能够减少启动时间和让内存更有效地利用......

位置独立代码

位置独立代码(Position-independent code),是在PowerPC环境代码生成技术的名字,它允许动态链接器在不固定的虚拟内存地址中加载一个区域代码。除了位置独立代码生成的一些形式之外,操作系统会把你希望共享的代码放置在虚拟内存固定地址中,这会导致维护操作系统时相当困难。例如,它基本不会支持shared libraries和frameworks,因为他们都需要预先分配一个永远不会变的地址......

间接寻址

间接寻址是一种代码生成技术的名字,它允许被定义在文件中的symbols能够被另外一个文件引用,并且不需要引用文件知道任何有关定义这些symbol文件的布局。因此,定义文件能够被独立地修改。间接寻址最小化了动态链接器需要修改的地址,这会提高代码分享和提高效率......

在运行时加载代码

你可能需要用到动态分享库,它能够保存可重用代码,在多个程序使用时有函数的使用优势。例如,当你开发Cocoa应用程序时,你的程序最少会链接Foudation和Application Kit框架。通过这个实践,当你的应用程序用户更新电脑系统软件框架时,你的程序能够自动利用那些改进的框架......

运行Mach-O文件

为了展示代码的效果,程序必须启动线程和链接到dynamic shared libraries(动态分享库),为了与其它库和模块一起运行,你的应用程序必须在modules(.o)里面定义符号的引用,这些引用会在运行时被解决。在运行时应用程序中所有modules的symbol名字都会在共享的命名空间中使用。为了允许未来应用程序和使用库的使用,应用程序和库的开发者必须保证他们在功能和数据中选择的名字不会与其它modules冲突......